From aa0e4309152cb4fc3658c964e572653a3ded05d5 Mon Sep 17 00:00:00 2001 From: Yusuke Wada Date: Fri, 18 Sep 2026 10:43:25 +0900 Subject: [PATCH] feat(batch,snapshot): add --runtime workerd --- README.md | 14 +- src/commands/batch/batch.test.ts | 18 +++ src/commands/batch/batch.ts | 6 +- src/commands/batch/index.test.ts | 50 +++++++ src/commands/batch/index.ts | 47 ++++-- src/commands/request/index.test.ts | 4 +- src/commands/request/index.ts | 2 +- src/commands/request/workerd.test.ts | 50 ------- src/commands/request/workerd.ts | 112 -------------- src/commands/snapshot/index.test.ts | 75 ++++++++++ src/commands/snapshot/index.ts | 34 ++++- src/commands/snapshot/snapshot.test.ts | 22 +++ src/commands/snapshot/snapshot.ts | 20 ++- src/utils/runtime-option.test.ts | 25 ++++ src/utils/runtime-option.ts | 25 ++++ src/utils/target.ts | 7 + src/utils/workerd.test.ts | 118 +++++++++++++++ src/utils/workerd.ts | 200 +++++++++++++++++++++++++ 18 files changed, 640 insertions(+), 189 deletions(-) delete mode 100644 src/commands/request/workerd.test.ts delete mode 100644 src/commands/request/workerd.ts create mode 100644 src/commands/snapshot/index.test.ts create mode 100644 src/utils/runtime-option.test.ts create mode 100644 src/utils/runtime-option.ts create mode 100644 src/utils/target.ts create mode 100644 src/utils/workerd.test.ts create mode 100644 src/utils/workerd.ts diff --git a/README.md b/README.md index 3616504..be5a7b6 100644 --- a/README.md +++ b/README.md @@ -188,7 +188,7 @@ hono request /api --runtime workerd In a project with a wrangler config, `c.env` carries the real local bindings (KV, D1, R2, vars) automatically — wrangler's `getPlatformProxy` simulates the binding backends while the app runs on Node.js. This works in `request`, `batch`, and `snapshot`; skip it with `--no-bindings`. It does not apply to `--runtime bun`/`deno` (the proxy cannot cross the process boundary) — `--runtime workerd` has the real bindings natively. It needs [wrangler](https://developers.cloudflare.com/workers/wrangler/) installed in the project (wrangler is not a dependency of Hono CLI — without it, `c.env` stays empty and a note goes to stderr). -`--runtime workerd` runs the whole app inside workerd instead — heavier, but the full runtime. It starts the app with the wrangler config, so pass no file argument. +`--runtime workerd` runs the whole app inside workerd instead — heavier, but the full runtime. It starts the app with the wrangler config, so pass no file argument. `batch` and `snapshot` take it too; `request` alone also runs on `bun` and `deno`. With `--trace`, the output has `matchedRoutes`. `responded` marks the route that returned the response: @@ -249,6 +249,7 @@ hono batch [file] - `-H, --header
` - Shared headers for every step - `--compact` - Print only the failed steps and the summary, as one-line JSON +- `--runtime ` - runtime to execute the app: `node` (default) or `workerd` - `--no-bindings` - Skip loading the local Cloudflare bindings - `-e, --external ` - Mark package as external (can be used multiple times) @@ -261,6 +262,8 @@ hono batch - <<'EOF' EOF ``` +With `--runtime workerd`, one workerd starts and every step runs in it, so a flow over the real bindings (put to KV, then get; D1; an AI binding) runs in one call. The entry is `main` in the wrangler config, so pass no file argument. + One JSON object per line: `method`, `path`, `body`, `headers`, `expect`, `save`. `save` stores a value from the response body by dot path, and later steps use it as `{{id}}` (a whole-variable string keeps the saved type). `expect` declares the acceptance criteria: `status` matches exactly, `body` is a deep partial match (declared fields must match, extra response fields are ignored). The output carries the actual `status` and `body`, `pass` per step, and a `summary` — rerun until `failed` is 0. A step without `expect` passes on any 2xx or 3xx and fails on a 4xx or 5xx; to accept a 4xx on purpose, declare it with `expect.status`. ### `snapshot` @@ -271,10 +274,19 @@ Print the current behavior of the app as batch JSONL lines, to stdout — no fil hono snapshot [file] ``` +**Options:** + +- `--status-only` - Capture only the status codes, not the bodies +- `--runtime ` - runtime to execute the app: `node` (default) or `workerd` +- `--no-bindings` - Skip loading the local Cloudflare bindings +- `-e, --external ` - Mark package as external (can be used multiple times) + Paramless GET routes are executed and their actual response becomes the `expect` (`--status-only` captures only the status codes — much smaller on a large app; the probe line keeps its body either way). Param and non-GET routes are printed without one, to fill in. One probe line records the current response for a path that matches no route. Capture before a refactor, then rerun the lines with `hono batch` until `failed` is 0. Unlike `routes`, this command sends real requests to the app — middleware runs. `routes` never sends a request. +With `--runtime workerd`, the requests go to the app running inside workerd. The routes are read from `main` in the wrangler config in-process, so pass no file argument. + ### `benchmark` Measure the performance of your Hono app. It is a micro benchmark of routing and handlers: `app.request()` is called directly, with no HTTP stack and no network. Each run happens in a fresh process, so results are comparable. diff --git a/src/commands/batch/batch.test.ts b/src/commands/batch/batch.test.ts index 2cbc09b..c8aeb39 100644 --- a/src/commands/batch/batch.test.ts +++ b/src/commands/batch/batch.test.ts @@ -220,6 +220,24 @@ describe('runBatch', () => { expect(result.steps[0].saved).toBeUndefined() }) + it('sends the steps to the given target instead of a Hono app', async () => { + const seen: string[] = [] + const target = { + request: async (input: Request) => { + seen.push(`${input.method} ${new URL(input.url).pathname}`) + return Response.json({ id: 7 }, { status: 201 }) + }, + } + const result = await runBatch( + target, + parseBatch( + '{"method":"POST","path":"/users","body":{},"save":{"id":".id"}}\n{"path":"/users/{{id}}"}' + ) + ) + expect(seen).toEqual(['POST /users', 'GET /users/7']) + expect(result.summary).toEqual({ total: 2, passed: 2, failed: 0 }) + }) + it('passes the env through to c.env', async () => { const app = new Hono() app.get('/env', (c) => c.json({ v: (c.env as { MY_VAR: string }).MY_VAR })) diff --git a/src/commands/batch/batch.ts b/src/commands/batch/batch.ts index 977ba05..b0d8813 100644 --- a/src/commands/batch/batch.ts +++ b/src/commands/batch/batch.ts @@ -1,5 +1,5 @@ -import type { Hono } from 'hono' import { CliError } from '../../utils/output.js' +import type { RequestTarget } from '../../utils/target.js' export interface StepExpect { status?: number @@ -213,7 +213,7 @@ export const getByPath = (body: unknown, path: string): unknown => { * as `failed: 0` reads as "it works". */ export const runBatch = async ( - app: Hono, + target: RequestTarget, steps: BatchStep[], sharedHeaders: Record = {}, env?: Record @@ -240,7 +240,7 @@ export const runBatch = async ( } } - const response = await app.request( + const response = await target.request( new Request(new URL(path, 'http://localhost').href, init), undefined, env diff --git a/src/commands/batch/index.test.ts b/src/commands/batch/index.test.ts index f7e8315..494802d 100644 --- a/src/commands/batch/index.test.ts +++ b/src/commands/batch/index.test.ts @@ -20,6 +20,10 @@ vi.mock('../../utils/bindings.js', () => ({ maybeLoadBindings: vi.fn(async () => undefined), })) +vi.mock('../../utils/workerd.js', () => ({ + startWorkerd: vi.fn(), +})) + import { batchCommand } from './index.js' describe('batchCommand', () => { @@ -98,6 +102,52 @@ describe('batchCommand', () => { }) }) + it('should run every step in one workerd with --runtime workerd', async () => { + const workerd = await import('../../utils/workerd.js') + const target = { + request: vi.fn(async (input: Request) => + Response.json({ path: new URL(input.url).pathname }) + ), + fetch: vi.fn(), + dispose: vi.fn(async () => {}), + } + vi.mocked(workerd.startWorkerd).mockResolvedValue(target) + const fs = await import('node:fs') + vi.mocked(fs.readFileSync).mockReturnValue('{"path":"/a"}\n{"path":"/b"}') + await program.parseAsync(['node', 'test', 'batch', 'steps.jsonl', '--runtime', 'workerd']) + expect(workerd.startWorkerd).toHaveBeenCalledTimes(1) + expect(target.request).toHaveBeenCalledTimes(2) + expect(target.dispose).toHaveBeenCalledTimes(1) + const output = JSON.parse(consoleLogSpy.mock.calls[0][0] as string) + expect(output.data.steps.map((s: { body: { path: string } }) => s.body.path)).toEqual([ + '/a', + '/b', + ]) + expect(output.data.summary).toEqual({ total: 2, passed: 2, failed: 0 }) + }) + + it('should reject a file argument with --runtime workerd', async () => { + await program.parseAsync([ + 'node', + 'test', + 'batch', + 'steps.jsonl', + 'test-app.js', + '--runtime', + 'workerd', + ]) + const output = JSON.parse(consoleLogSpy.mock.calls[0][0] as string) + expect(output.ok).toBe(false) + expect(output.error.code).toBe('INVALID_OPTION') + }) + + it('should reject --runtime bun', async () => { + await program.parseAsync(['node', 'test', 'batch', 'steps.jsonl', '--runtime', 'bun']) + const output = JSON.parse(consoleLogSpy.mock.calls[0][0] as string) + expect(output.error.code).toBe('INVALID_OPTION') + expect(output.error.message).toBe('Unknown runtime: bun') + }) + it('should reject the app and the batch both from stdin', async () => { await program.parseAsync(['node', 'test', 'batch', '-', '-']) const output = JSON.parse(consoleLogSpy.mock.calls[0][0] as string) diff --git a/src/commands/batch/index.ts b/src/commands/batch/index.ts index 62ecd3e..ed5107f 100644 --- a/src/commands/batch/index.ts +++ b/src/commands/batch/index.ts @@ -6,6 +6,9 @@ import { maybeLoadBindings } from '../../utils/bindings.js' import { parseHeaders } from '../../utils/headers.js' import { getBuildIterator, readStdin } from '../../utils/load-app.js' import { CliError, handleErrors, printResult } from '../../utils/output.js' +import { resolveRuntime } from '../../utils/runtime-option.js' +import { startWorkerd } from '../../utils/workerd.js' +import type { BatchResult } from './batch.js' import { parseBatch, runBatch } from './batch.js' export const agentContext: CommandAgentContext = { @@ -18,6 +21,9 @@ export const agentContext: CommandAgentContext = { 'BUILD_FAILED', 'INVALID_APP', 'BINDINGS_FAILED', + 'WRANGLER_NOT_FOUND', + 'WRANGLER_CONFIG_NOT_FOUND', + 'RUNTIME_FAILED', ], examples: [ `hono batch - <<'EOF' @@ -36,6 +42,7 @@ EOF`, 'A failed step carries "diff": one line per mismatch (e.g. "body.name: expected \'Alice\', got \'Bob\'"). Fix what the diff names — no need to compare the bodies yourself.', 'hono snapshot prints the current behavior of an app in this format — capture before a refactor, rerun after.', 'In a project with a wrangler config, c.env carries the real local bindings (KV, D1, R2, vars) automatically — no server, no --runtime needed. Skip it with --no-bindings.', + '--runtime workerd runs the whole app inside workerd instead: one workerd starts, every step runs in it. The entry is main in the wrangler config, so pass no file argument.', ], } @@ -44,6 +51,7 @@ interface BatchOptions { external?: string[] compact: boolean bindings: boolean + runtime: string } export function batchCommand(program: Command) { @@ -61,6 +69,7 @@ export function batchCommand(program: Command) { [] as string[] ) .option('--compact', 'Print only the failed steps and the summary', false) + .option('--runtime ', 'Runtime to execute the app: node (default) or workerd', 'node') .option('--no-bindings', 'Skip loading the local Cloudflare bindings') .option( '-e, --external ', @@ -81,23 +90,37 @@ export function batchCommand(program: Command) { } ) } + const runtime = resolveRuntime(options.runtime, file) const input = source === '-' ? await readStdin() : readBatchFile(source) const steps = parseBatch(input) + const print = (result: BatchResult) => { + if (options.compact) { + printResult( + { + steps: result.steps.filter((step) => !step.pass), + summary: result.summary, + }, + true + ) + } else { + printResult(result) + } + } + + if (runtime === 'workerd') { + const target = await startWorkerd() + try { + print(await runBatch(target, steps, parseHeaders(options.header))) + } finally { + await target.dispose().catch(() => {}) + } + return + } + const proxy = options.bindings ? await maybeLoadBindings() : undefined try { for await (const app of getBuildIterator(file, false, options.external || [])) { - const result = await runBatch(app, steps, parseHeaders(options.header), proxy?.env) - if (options.compact) { - printResult( - { - steps: result.steps.filter((step) => !step.pass), - summary: result.summary, - }, - true - ) - } else { - printResult(result) - } + print(await runBatch(app, steps, parseHeaders(options.header), proxy?.env)) } } finally { await proxy?.dispose() diff --git a/src/commands/request/index.test.ts b/src/commands/request/index.test.ts index d566f54..c603eb9 100644 --- a/src/commands/request/index.test.ts +++ b/src/commands/request/index.test.ts @@ -28,7 +28,7 @@ vi.mock('./runtime.js', async (importOriginal) => { return { ...original, runInRuntime: vi.fn() } }) -vi.mock('./workerd.js', () => ({ +vi.mock('../../utils/workerd.js', () => ({ runOnWorkerd: vi.fn(), })) @@ -1161,7 +1161,7 @@ describe('requestCommand', () => { }) it('should run the app on workerd with the wrangler config', async () => { - const runOnWorkerd = vi.mocked((await import('./workerd.js')).runOnWorkerd) + const runOnWorkerd = vi.mocked((await import('../../utils/workerd.js')).runOnWorkerd) const body = JSON.stringify({ who: 'workerd' }) runOnWorkerd.mockResolvedValue({ status: 200, diff --git a/src/commands/request/index.ts b/src/commands/request/index.ts index c954ae9..70e6ad5 100644 --- a/src/commands/request/index.ts +++ b/src/commands/request/index.ts @@ -7,11 +7,11 @@ import { getFilenameFromPath, saveFile } from '../../utils/file.js' import { parseHeaders } from '../../utils/headers.js' import { getBuildIterator, resolveData, resolveEntry } from '../../utils/load-app.js' import { CliError, handleErrors, printResult } from '../../utils/output.js' +import { runOnWorkerd } from '../../utils/workerd.js' import { resolvePositionals } from './positionals.js' import type { Runtime } from './runtime.js' import { RUNTIMES, runInRuntime } from './runtime.js' import { withTracer } from './trace.js' -import { runOnWorkerd } from './workerd.js' export const agentContext: CommandAgentContext = { output: diff --git a/src/commands/request/workerd.test.ts b/src/commands/request/workerd.test.ts deleted file mode 100644 index eedd4bf..0000000 --- a/src/commands/request/workerd.test.ts +++ /dev/null @@ -1,50 +0,0 @@ -import { describe, it, expect, vi, beforeEach } from 'vitest' -import { CliError } from '../../utils/output' - -vi.mock('node:fs', () => ({ - existsSync: vi.fn(), -})) - -import { findWranglerConfig, runOnWorkerd } from './workerd' - -const getMockExistsSync = async () => vi.mocked((await import('node:fs')).existsSync) - -describe('findWranglerConfig', () => { - beforeEach(() => { - vi.clearAllMocks() - }) - - it('should pick the first existing candidate', async () => { - const existsSync = await getMockExistsSync() - existsSync.mockImplementation((path) => String(path).endsWith('wrangler.jsonc')) - expect(findWranglerConfig()).toBe('wrangler.jsonc') - }) - - it('should return undefined without a config', async () => { - const existsSync = await getMockExistsSync() - existsSync.mockReturnValue(false) - expect(findWranglerConfig()).toBeUndefined() - }) -}) - -describe('runOnWorkerd', () => { - beforeEach(() => { - vi.clearAllMocks() - }) - - it('should fail with WRANGLER_CONFIG_NOT_FOUND without a config', async () => { - const existsSync = await getMockExistsSync() - existsSync.mockReturnValue(false) - const promise = runOnWorkerd({ path: '/', method: 'GET', headers: {} }) - await expect(promise).rejects.toThrowError(CliError) - await expect(promise).rejects.toMatchObject({ code: 'WRANGLER_CONFIG_NOT_FOUND' }) - }) - - it('should fail with WRANGLER_NOT_FOUND when wrangler is not installed', async () => { - // This repo has a config (mocked) but no wrangler dependency - const existsSync = await getMockExistsSync() - existsSync.mockReturnValue(true) - const promise = runOnWorkerd({ path: '/', method: 'GET', headers: {} }) - await expect(promise).rejects.toMatchObject({ code: 'WRANGLER_NOT_FOUND' }) - }) -}) diff --git a/src/commands/request/workerd.ts b/src/commands/request/workerd.ts deleted file mode 100644 index fa9a240..0000000 --- a/src/commands/request/workerd.ts +++ /dev/null @@ -1,112 +0,0 @@ -import { existsSync } from 'node:fs' -import { createRequire } from 'node:module' -import { join } from 'node:path' -import { pathToFileURL } from 'node:url' -import { CliError } from '../../utils/output.js' -import type { RunnerRequest } from './runtime.js' - -export interface WorkerdResult { - status: number - headers: Record - body: string - response: Response -} - -interface StartedWorker { - fetch(url: string, init?: RequestInit): Promise - dispose(): Promise -} - -interface WranglerModule { - unstable_startWorker(options: { - config: string - dev: { logLevel: 'error' } - }): Promise -} - -const CONFIG_CANDIDATES = ['wrangler.json', 'wrangler.jsonc', 'wrangler.toml'] - -export const findWranglerConfig = (): string | undefined => - CONFIG_CANDIDATES.find((file) => existsSync(join(process.cwd(), file))) - -/** - * wrangler is not a dependency of Hono CLI. It resolves from the user's - * project, which has it when the app targets Cloudflare. - */ -const loadWrangler = async (): Promise => { - const require = createRequire(join(process.cwd(), 'package.json')) - let resolved: string - try { - resolved = require.resolve('wrangler') - } catch { - throw new CliError('WRANGLER_NOT_FOUND', 'wrangler is not installed in this project', { - suggestions: ['Install it: npm install -D wrangler'], - docs: 'https://developers.cloudflare.com/workers/wrangler/', - }) - } - return import(pathToFileURL(resolved).href) -} - -const TIMEOUT_MS = 10000 - -export const runOnWorkerd = async (request: RunnerRequest): Promise => { - const config = findWranglerConfig() - if (!config) { - throw new CliError('WRANGLER_CONFIG_NOT_FOUND', 'No wrangler config found', { - suggestions: ['Create wrangler.jsonc with a main entry'], - docs: 'https://developers.cloudflare.com/workers/wrangler/configuration/', - }) - } - - const { unstable_startWorker } = await loadWrangler() - const worker = await unstable_startWorker({ config, dev: { logLevel: 'error' } }) - - let timeoutId: NodeJS.Timeout | undefined - try { - // When the runtime fails to start, worker.fetch() hangs and - // dispose() rejects with the root cause. The timeout uncovers it. - const timeout = new Promise((_, reject) => { - timeoutId = setTimeout(() => { - reject(new CliError('RUNTIME_FAILED', `No response from workerd in ${TIMEOUT_MS / 1000}s`)) - }, TIMEOUT_MS) - }) - const response = await Promise.race([ - worker.fetch( - `http://localhost${request.path.startsWith('/') ? request.path : `/${request.path}`}`, - { - method: request.method, - headers: request.headers, - ...(request.body === undefined ? {} : { body: request.body }), - } - ), - timeout, - ]) - - const headers: Record = {} - response.headers.forEach((value, key) => { - headers[key] = value - }) - const buffer = await response.clone().arrayBuffer() - - return { - status: response.status, - headers, - body: new TextDecoder().decode(buffer), - response, - } - } catch (error) { - const cause = await worker.dispose().then( - () => undefined, - (disposeError: unknown) => disposeError - ) - if (error instanceof CliError && cause instanceof Error) { - throw new CliError('RUNTIME_FAILED', `The app failed on workerd: ${cause.message}`, { - suggestions: ['Check the wrangler config and the error above'], - }) - } - throw error - } finally { - clearTimeout(timeoutId) - await worker.dispose().catch(() => {}) - } -} diff --git a/src/commands/snapshot/index.test.ts b/src/commands/snapshot/index.test.ts new file mode 100644 index 0000000..53228ce --- /dev/null +++ b/src/commands/snapshot/index.test.ts @@ -0,0 +1,75 @@ +import { Command } from 'commander' +import { Hono } from 'hono' +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest' + +vi.mock('node:fs', () => ({ + existsSync: vi.fn(() => true), + realpathSync: vi.fn((p: string) => p), + readFileSync: vi.fn(), +})) + +vi.mock('../../utils/build.js', () => ({ + buildAndImportApp: vi.fn(), +})) + +vi.mock('../../utils/bindings.js', () => ({ + maybeLoadBindings: vi.fn(async () => undefined), +})) + +vi.mock('../../utils/workerd.js', () => ({ + startWorkerd: vi.fn(), + readWorkerdMain: vi.fn(), +})) + +import { snapshotCommand } from './index.js' + +describe('snapshotCommand', () => { + let program: Command + let consoleLogSpy: ReturnType + + async function* iteratorOf(app: Hono): AsyncGenerator { + yield app + } + + beforeEach(async () => { + program = new Command() + snapshotCommand(program) + consoleLogSpy = vi.spyOn(console, 'log').mockImplementation(() => {}) + const build = await import('../../utils/build.js') + const app = new Hono() + app.get('/data', (c) => c.json({ from: 'node' })) + vi.mocked(build.buildAndImportApp).mockReturnValue(iteratorOf(app)) + }) + + afterEach(() => { + vi.restoreAllMocks() + vi.clearAllMocks() + }) + + it('should read the routes from main and send the requests to workerd', async () => { + const workerd = await import('../../utils/workerd.js') + const build = await import('../../utils/build.js') + const target = { + request: vi.fn(async () => Response.json({ from: 'workerd' })), + fetch: vi.fn(), + dispose: vi.fn(async () => {}), + } + vi.mocked(workerd.readWorkerdMain).mockResolvedValue('/proj/src/index.ts') + vi.mocked(workerd.startWorkerd).mockResolvedValue(target) + + await program.parseAsync(['node', 'test', 'snapshot', '--runtime', 'workerd']) + + expect(vi.mocked(build.buildAndImportApp).mock.calls[0][0]).toBe('/proj/src/index.ts') + expect(target.request).toHaveBeenCalledTimes(2) + expect(target.dispose).toHaveBeenCalledTimes(1) + const lines = (consoleLogSpy.mock.calls[0][0] as string).split('\n').map((l) => JSON.parse(l)) + expect(lines[0]).toEqual({ path: '/data', expect: { status: 200, body: { from: 'workerd' } } }) + }) + + it('should reject a file argument with --runtime workerd', async () => { + await program.parseAsync(['node', 'test', 'snapshot', 'src/app.ts', '--runtime', 'workerd']) + const output = JSON.parse(consoleLogSpy.mock.calls[0][0] as string) + expect(output.ok).toBe(false) + expect(output.error.code).toBe('INVALID_OPTION') + }) +}) diff --git a/src/commands/snapshot/index.ts b/src/commands/snapshot/index.ts index 5a68197..1c5be28 100644 --- a/src/commands/snapshot/index.ts +++ b/src/commands/snapshot/index.ts @@ -3,12 +3,22 @@ import type { CommandAgentContext } from '../../utils/agent-context.js' import { maybeLoadBindings } from '../../utils/bindings.js' import { getBuildIterator } from '../../utils/load-app.js' import { handleErrors } from '../../utils/output.js' +import { resolveRuntime } from '../../utils/runtime-option.js' +import { readWorkerdMain, startWorkerd } from '../../utils/workerd.js' import { snapshotLines } from './snapshot.js' export const agentContext: CommandAgentContext = { output: '{"path":"/users","expect":{"status":200,"body":[{"id":1}]}} — one batch JSONL line per route, not the JSON envelope', - errors: ['ENTRY_NOT_FOUND', 'BUILD_FAILED', 'INVALID_APP', 'BINDINGS_FAILED'], + errors: [ + 'ENTRY_NOT_FOUND', + 'BUILD_FAILED', + 'INVALID_APP', + 'BINDINGS_FAILED', + 'WRANGLER_NOT_FOUND', + 'WRANGLER_CONFIG_NOT_FOUND', + 'RUNTIME_FAILED', + ], examples: ['hono snapshot', 'hono snapshot src/app.ts'], notes: [ 'Prints the current behavior of the app as batch JSONL lines, to stdout. No file is written — keep the lines in your context, or redirect if you want one.', @@ -18,6 +28,7 @@ export const agentContext: CommandAgentContext = { '--status-only captures only the status codes — much smaller on a large app. The probe line keeps its body either way: a dropped notFound handler still answers 404, only the body changes.', 'Unlike routes, this command sends real requests to the app — middleware runs.', 'In a project with a wrangler config, c.env carries the real local bindings automatically. Skip it with --no-bindings.', + '--runtime workerd sends the requests to the app running inside workerd. The routes are read from main in the wrangler config, so pass no file argument.', ], } @@ -25,6 +36,7 @@ interface SnapshotOptions { external?: string[] statusOnly: boolean bindings: boolean + runtime: string } export function snapshotCommand(program: Command) { @@ -33,6 +45,7 @@ export function snapshotCommand(program: Command) { .description('Print the current behavior as batch JSONL lines') .argument('[file]', 'Path to the Hono app file') .option('--status-only', 'Capture only the status codes, not the bodies', false) + .option('--runtime ', 'Runtime to execute the app: node (default) or workerd', 'node') .option('--no-bindings', 'Skip loading the local Cloudflare bindings') .option( '-e, --external ', @@ -44,9 +57,26 @@ export function snapshotCommand(program: Command) { ) .action( handleErrors(async (file: string | undefined, options: SnapshotOptions) => { + const external = options.external || [] + if (resolveRuntime(options.runtime, file) === 'workerd') { + // The routes come from the entry in-process; the requests go to workerd. + const main = await readWorkerdMain() + const target = await startWorkerd() + try { + for await (const app of getBuildIterator(main, false, external)) { + console.log( + (await snapshotLines(app, options.statusOnly, undefined, target)).join('\n') + ) + } + } finally { + await target.dispose().catch(() => {}) + } + return + } + const proxy = options.bindings ? await maybeLoadBindings() : undefined try { - for await (const app of getBuildIterator(file, false, options.external || [])) { + for await (const app of getBuildIterator(file, false, external)) { console.log((await snapshotLines(app, options.statusOnly, proxy?.env)).join('\n')) } } finally { diff --git a/src/commands/snapshot/snapshot.test.ts b/src/commands/snapshot/snapshot.test.ts index 810984f..f217e1f 100644 --- a/src/commands/snapshot/snapshot.test.ts +++ b/src/commands/snapshot/snapshot.test.ts @@ -58,3 +58,25 @@ describe('snapshotLines', () => { expect(() => parseBatch(lines.join('\n'))).not.toThrow() }) }) + +describe('snapshotLines with a target', () => { + it('reads the routes from the app and sends the requests to the target', async () => { + const a = new Hono() + a.get('/users', (c) => c.json([{ id: 1 }])) + a.get('/users/:id', (c) => c.json({ id: c.req.param('id') })) + const seen: string[] = [] + const target = { + request: async (input: Request) => { + seen.push(new URL(input.url).pathname) + return Response.json({ from: 'workerd' }) + }, + } + const lines = (await snapshotLines(a, false, undefined, target)).map((l) => JSON.parse(l)) + expect(seen).toEqual(['/users', '/__no_such_path__']) + expect(lines).toEqual([ + { path: '/users', expect: { status: 200, body: { from: 'workerd' } } }, + { path: '/users/:id' }, + { path: '/__no_such_path__', expect: { status: 200, body: { from: 'workerd' } } }, + ]) + }) +}) diff --git a/src/commands/snapshot/snapshot.ts b/src/commands/snapshot/snapshot.ts index 695d359..4943eae 100644 --- a/src/commands/snapshot/snapshot.ts +++ b/src/commands/snapshot/snapshot.ts @@ -1,17 +1,21 @@ import type { Hono } from 'hono' import { inspectRoutes } from 'hono/dev' +import type { RequestTarget } from '../../utils/target.js' /** * Print the current behavior of the app as batch JSONL lines, to * stdout — no file. Parameterless GET routes are executed and their * actual status and body become the `expect`. Other routes are * printed without an `expect`, for the caller to fill in. One probe - * line records the current not-found behavior as a fact. + * line records the current not-found behavior as a fact. The routes + * come from the app; the requests go to `target` (the app itself, or + * a running workerd). */ export const snapshotLines = async ( app: Hono, statusOnly = false, - env?: Record + env?: Record, + target: RequestTarget = app ): Promise => { const lines: string[] = [] const routes = inspectRoutes(app).filter((route) => !route.isMiddleware) @@ -19,7 +23,7 @@ export const snapshotLines = async ( for (const route of routes) { const isParamless = !route.path.includes(':') && !route.path.includes('*') if (route.method === 'GET' && isParamless) { - const captured = await capture(app, route.path, env) + const captured = await capture(target, route.path, env) const expect = statusOnly ? { status: captured.status } : captured lines.push(JSON.stringify({ path: route.path, expect })) } else { @@ -33,7 +37,7 @@ export const snapshotLines = async ( lines.push( JSON.stringify({ path: '/__no_such_path__', - expect: await capture(app, '/__no_such_path__', env), + expect: await capture(target, '/__no_such_path__', env), }) ) @@ -41,11 +45,15 @@ export const snapshotLines = async ( } const capture = async ( - app: Hono, + target: RequestTarget, path: string, env?: Record ): Promise<{ status: number; body?: unknown }> => { - const response = await app.request(new URL(path, 'http://localhost').href, undefined, env) + const response = await target.request( + new Request(new URL(path, 'http://localhost').href), + undefined, + env + ) const text = await response.text() const isJson = response.headers.get('content-type')?.includes('json') let body: unknown = text diff --git a/src/utils/runtime-option.test.ts b/src/utils/runtime-option.test.ts new file mode 100644 index 0000000..1f8c13f --- /dev/null +++ b/src/utils/runtime-option.test.ts @@ -0,0 +1,25 @@ +import { describe, it, expect } from 'vitest' +import { resolveRuntime } from './runtime-option' + +describe('resolveRuntime', () => { + it('accepts node and workerd', () => { + expect(resolveRuntime('node', 'src/app.ts')).toBe('node') + expect(resolveRuntime('workerd', undefined)).toBe('workerd') + }) + + it('rejects other runtimes and points at request', () => { + expect(() => resolveRuntime('bun', undefined)).toThrowError(/Unknown runtime: bun/) + try { + resolveRuntime('deno', undefined) + } catch (e) { + expect(e).toMatchObject({ code: 'INVALID_OPTION' }) + expect((e as { suggestions: string[] }).suggestions[1]).toContain('hono request') + } + }) + + it('rejects a file argument with workerd', () => { + expect(() => resolveRuntime('workerd', 'src/app.ts')).toThrowError( + /workerd runs the app from your wrangler config/ + ) + }) +}) diff --git a/src/utils/runtime-option.ts b/src/utils/runtime-option.ts new file mode 100644 index 0000000..2d49faa --- /dev/null +++ b/src/utils/runtime-option.ts @@ -0,0 +1,25 @@ +import { CliError } from './output.js' + +export type BatchRuntime = 'node' | 'workerd' + +/** + * `--runtime` for the commands that run many requests: `node` (the + * default) or `workerd`. workerd starts the app from the wrangler + * config, so a file argument is an error there. + */ +export const resolveRuntime = (runtime: string, file: string | undefined): BatchRuntime => { + if (runtime !== 'node' && runtime !== 'workerd') { + throw new CliError('INVALID_OPTION', `Unknown runtime: ${runtime}`, { + suggestions: [ + 'Use node or workerd', + 'For a single request on bun or deno: hono request --runtime bun', + ], + }) + } + if (runtime === 'workerd' && file !== undefined) { + throw new CliError('INVALID_OPTION', 'workerd runs the app from your wrangler config', { + suggestions: ['Drop the file argument. The entry is `main` in the wrangler config'], + }) + } + return runtime +} diff --git a/src/utils/target.ts b/src/utils/target.ts new file mode 100644 index 0000000..e3bad61 --- /dev/null +++ b/src/utils/target.ts @@ -0,0 +1,7 @@ +/** + * Where a request goes: the Hono app in this process, or a running + * workerd. `Hono#request` satisfies it as-is. + */ +export interface RequestTarget { + request(input: Request, requestInit?: RequestInit, env?: unknown): Response | Promise +} diff --git a/src/utils/workerd.test.ts b/src/utils/workerd.test.ts new file mode 100644 index 0000000..8bdf663 --- /dev/null +++ b/src/utils/workerd.test.ts @@ -0,0 +1,118 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest' +import { CliError } from './output' + +vi.mock('node:fs', () => ({ + existsSync: vi.fn(), +})) + +import { findWranglerConfig, runOnWorkerd, workerdTarget } from './workerd' +import type { StartedWorker } from './workerd' + +const getMockExistsSync = async () => vi.mocked((await import('node:fs')).existsSync) + +describe('findWranglerConfig', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + it('should pick the first existing candidate', async () => { + const existsSync = await getMockExistsSync() + existsSync.mockImplementation((path) => String(path).endsWith('wrangler.jsonc')) + expect(findWranglerConfig()).toBe('wrangler.jsonc') + }) + + it('should return undefined without a config', async () => { + const existsSync = await getMockExistsSync() + existsSync.mockReturnValue(false) + expect(findWranglerConfig()).toBeUndefined() + }) +}) + +describe('runOnWorkerd', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + it('should fail with WRANGLER_CONFIG_NOT_FOUND without a config', async () => { + const existsSync = await getMockExistsSync() + existsSync.mockReturnValue(false) + const promise = runOnWorkerd({ path: '/', method: 'GET', headers: {} }) + await expect(promise).rejects.toThrowError(CliError) + await expect(promise).rejects.toMatchObject({ code: 'WRANGLER_CONFIG_NOT_FOUND' }) + }) + + it('should fail with WRANGLER_NOT_FOUND when wrangler is not installed', async () => { + // This repo has a config (mocked) but no wrangler dependency + const existsSync = await getMockExistsSync() + existsSync.mockReturnValue(true) + const promise = runOnWorkerd({ path: '/', method: 'GET', headers: {} }) + await expect(promise).rejects.toMatchObject({ code: 'WRANGLER_NOT_FOUND' }) + }) +}) + +describe('workerdTarget', () => { + const fakeWorker = (fetch: StartedWorker['fetch']) => { + const worker = { fetch: vi.fn(fetch), dispose: vi.fn(async () => {}) } + return worker + } + + it('forwards a Request as path, method, headers and body, and keeps the worker up', async () => { + const worker = fakeWorker(async (url, init) => { + const body = init?.body ? new TextDecoder().decode(init.body as ArrayBuffer) : '' + return new Response(`${init?.method} ${url} ${body}`, { status: 201 }) + }) + const target = workerdTarget(worker) + + const first = await target.request( + new Request('http://localhost/users?x=1', { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: '{"name":"Momo"}', + }) + ) + expect(first.status).toBe(201) + expect(await first.text()).toBe('POST http://localhost/users?x=1 {"name":"Momo"}') + expect(worker.fetch.mock.calls[0][1]?.headers).toEqual({ 'content-type': 'application/json' }) + + const second = await target.request(new Request('http://localhost/users')) + expect(await second.text()).toBe('GET http://localhost/users ') + expect(worker.fetch).toHaveBeenCalledTimes(2) + expect(worker.dispose).not.toHaveBeenCalled() + + await target.dispose() + await target.dispose() + expect(worker.dispose).toHaveBeenCalledTimes(1) + }) + + it('returns the status, headers and text from fetch', async () => { + const worker = fakeWorker( + async () => + new Response('{"ok":true}', { + status: 200, + headers: { 'content-type': 'application/json' }, + }) + ) + const result = await workerdTarget(worker).fetch({ path: 'api', method: 'GET', headers: {} }) + expect(result).toMatchObject({ + status: 200, + headers: { 'content-type': 'application/json' }, + body: '{"ok":true}', + }) + expect(worker.fetch.mock.calls[0][0]).toBe('http://localhost/api') + }) + + it('reports the dispose cause when the first fetch hangs', async () => { + const worker = { + fetch: vi.fn(() => new Promise(() => {})), + dispose: vi.fn(async () => { + throw new Error('bad config') + }), + } + const promise = workerdTarget(worker, 20).request(new Request('http://localhost/')) + await expect(promise).rejects.toMatchObject({ + code: 'RUNTIME_FAILED', + message: 'The app failed on workerd: bad config', + }) + expect(worker.dispose).toHaveBeenCalledTimes(1) + }) +}) diff --git a/src/utils/workerd.ts b/src/utils/workerd.ts new file mode 100644 index 0000000..67a7e49 --- /dev/null +++ b/src/utils/workerd.ts @@ -0,0 +1,200 @@ +import { existsSync } from 'node:fs' +import { createRequire } from 'node:module' +import { isAbsolute, join, resolve } from 'node:path' +import { pathToFileURL } from 'node:url' +import { CliError } from './output.js' +import type { RequestTarget } from './target.js' + +export interface WorkerdRequest { + path: string + method: string + headers: Record + body?: string +} + +export interface WorkerdResult { + status: number + headers: Record + body: string + response: Response +} + +export interface StartedWorker { + fetch(url: string, init?: RequestInit): Promise + dispose(): Promise +} + +interface WranglerModule { + unstable_startWorker(options: { + config: string + dev: { logLevel: 'error' } + }): Promise + unstable_readConfig(args: { config: string }): { main?: string } +} + +/** + * A running workerd. `request` sends one request and keeps the worker + * up, so many steps share one start. Callers must `dispose`. + */ +export interface WorkerdTarget extends RequestTarget { + request(input: Request): Promise + fetch(request: WorkerdRequest): Promise + dispose(): Promise +} + +const CONFIG_CANDIDATES = ['wrangler.json', 'wrangler.jsonc', 'wrangler.toml'] + +export const findWranglerConfig = (): string | undefined => + CONFIG_CANDIDATES.find((file) => existsSync(join(process.cwd(), file))) + +const requireWranglerConfig = (): string => { + const config = findWranglerConfig() + if (!config) { + throw new CliError('WRANGLER_CONFIG_NOT_FOUND', 'No wrangler config found', { + suggestions: ['Create wrangler.jsonc with a main entry'], + docs: 'https://developers.cloudflare.com/workers/wrangler/configuration/', + }) + } + return config +} + +/** + * wrangler is not a dependency of Hono CLI. It resolves from the user's + * project, which has it when the app targets Cloudflare. + */ +const loadWrangler = async (): Promise => { + const require = createRequire(join(process.cwd(), 'package.json')) + let resolved: string + try { + resolved = require.resolve('wrangler') + } catch { + throw new CliError('WRANGLER_NOT_FOUND', 'wrangler is not installed in this project', { + suggestions: ['Install it: npm install -D wrangler'], + docs: 'https://developers.cloudflare.com/workers/wrangler/', + }) + } + return import(pathToFileURL(resolved).href) +} + +const TIMEOUT_MS = 10000 + +const toUrl = (path: string): string => + `http://localhost${path.startsWith('/') ? path : `/${path}`}` + +/** + * Wrap a started worker as a target. The first fetch has a timeout: + * when the runtime fails to start, `worker.fetch()` hangs and only + * `dispose()` rejects with the root cause. + */ +export const workerdTarget = (worker: StartedWorker, timeoutMs = TIMEOUT_MS): WorkerdTarget => { + let first = true + let disposed = false + + const dispose = async () => { + if (disposed) { + return + } + disposed = true + await worker.dispose() + } + + const send = async (url: string, init: RequestInit): Promise => { + if (!first) { + return worker.fetch(url, init) + } + first = false + let timeoutId: NodeJS.Timeout | undefined + try { + const timeout = new Promise((_, reject) => { + timeoutId = setTimeout(() => { + reject(new CliError('RUNTIME_FAILED', `No response from workerd in ${timeoutMs / 1000}s`)) + }, timeoutMs) + }) + return await Promise.race([worker.fetch(url, init), timeout]) + } catch (error) { + const cause = await dispose().then( + () => undefined, + (disposeError: unknown) => disposeError + ) + if (error instanceof CliError && cause instanceof Error) { + throw new CliError('RUNTIME_FAILED', `The app failed on workerd: ${cause.message}`, { + suggestions: ['Check the wrangler config and the error above'], + }) + } + throw error + } finally { + clearTimeout(timeoutId) + } + } + + return { + async request(input: Request) { + const { pathname, search } = new URL(input.url) + const headers: Record = {} + input.headers.forEach((value, key) => { + headers[key] = value + }) + const hasBody = input.method !== 'GET' && input.method !== 'HEAD' + return send(toUrl(pathname + search), { + method: input.method, + headers, + ...(hasBody ? { body: await input.arrayBuffer() } : {}), + }) + }, + async fetch(request: WorkerdRequest) { + const response = await send(toUrl(request.path), { + method: request.method, + headers: request.headers, + ...(request.body === undefined ? {} : { body: request.body }), + }) + const headers: Record = {} + response.headers.forEach((value, key) => { + headers[key] = value + }) + const buffer = await response.clone().arrayBuffer() + return { + status: response.status, + headers, + body: new TextDecoder().decode(buffer), + response, + } + }, + dispose, + } +} + +/** + * Start the app from the wrangler config inside workerd. + */ +export const startWorkerd = async (): Promise => { + const config = requireWranglerConfig() + const { unstable_startWorker } = await loadWrangler() + const worker = await unstable_startWorker({ config, dev: { logLevel: 'error' } }) + return workerdTarget(worker) +} + +/** + * The `main` entry from the wrangler config, as an absolute path. + * `snapshot --runtime workerd` reads the routes from it in-process. + */ +export const readWorkerdMain = async (): Promise => { + const config = requireWranglerConfig() + const { unstable_readConfig } = await loadWrangler() + const { main } = unstable_readConfig({ config: join(process.cwd(), config) }) + if (!main) { + throw new CliError('WRANGLER_CONFIG_NOT_FOUND', `No main entry in ${config}`, { + suggestions: ['Set main in the wrangler config, e.g. "main": "src/index.ts"'], + docs: 'https://developers.cloudflare.com/workers/wrangler/configuration/', + }) + } + return isAbsolute(main) ? main : resolve(process.cwd(), main) +} + +export const runOnWorkerd = async (request: WorkerdRequest): Promise => { + const target = await startWorkerd() + try { + return await target.fetch(request) + } finally { + await target.dispose().catch(() => {}) + } +}