Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 13 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:

Expand Down Expand Up @@ -249,6 +249,7 @@ hono batch <source> [file]

- `-H, --header <header>` - Shared headers for every step
- `--compact` - Print only the failed steps and the summary, as one-line JSON
- `--runtime <runtime>` - runtime to execute the app: `node` (default) or `workerd`
- `--no-bindings` - Skip loading the local Cloudflare bindings
- `-e, --external <package>` - Mark package as external (can be used multiple times)

Expand All @@ -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`
Expand All @@ -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>` - runtime to execute the app: `node` (default) or `workerd`
- `--no-bindings` - Skip loading the local Cloudflare bindings
- `-e, --external <package>` - 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.
Expand Down
18 changes: 18 additions & 0 deletions src/commands/batch/batch.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 }))
Expand Down
6 changes: 3 additions & 3 deletions src/commands/batch/batch.ts
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -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<string, string> = {},
env?: Record<string, unknown>
Expand All @@ -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
Expand Down
50 changes: 50 additions & 0 deletions src/commands/batch/index.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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', () => {
Expand Down Expand Up @@ -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)
Expand Down
47 changes: 35 additions & 12 deletions src/commands/batch/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 = {
Expand All @@ -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'
Expand All @@ -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.',
],
}

Expand All @@ -44,6 +51,7 @@ interface BatchOptions {
external?: string[]
compact: boolean
bindings: boolean
runtime: string
}

export function batchCommand(program: Command) {
Expand All @@ -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>', 'Runtime to execute the app: node (default) or workerd', 'node')
.option('--no-bindings', 'Skip loading the local Cloudflare bindings')
.option(
'-e, --external <package>',
Expand All @@ -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()
Expand Down
4 changes: 2 additions & 2 deletions src/commands/request/index.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(),
}))

Expand Down Expand Up @@ -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,
Expand Down
2 changes: 1 addition & 1 deletion src/commands/request/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
50 changes: 0 additions & 50 deletions src/commands/request/workerd.test.ts

This file was deleted.

Loading
Loading