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
6 changes: 5 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -141,6 +141,7 @@ hono request <path> [file] [options]
- `-i, --include` - Include status and headers in the output (with `--plain`)
- `-I, --head` - Show only status and headers in the output (with `--plain`)
- `--compact` - One-line JSON without the headers
- `--no-bindings` - Skip loading the local Cloudflare bindings
- `-e, --external <package>` - Mark package as external (can be used multiple times)

**Examples:**
Expand Down Expand Up @@ -185,7 +186,9 @@ hono request /api --runtime workerd

```

`workerd` starts the app with the wrangler config of the project, so pass no file argument. It needs [wrangler](https://developers.cloudflare.com/workers/wrangler/) installed in the project. wrangler is not a dependency of Hono CLI.
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.

With `--trace`, the output has `matchedRoutes`. `responded` marks the route that returned the response:

Expand Down Expand Up @@ -246,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
- `--no-bindings` - Skip loading the local Cloudflare bindings
- `-e, --external <package>` - Mark package as external (can be used multiple times)

```bash
Expand Down
19 changes: 19 additions & 0 deletions docs/agent-dx-log.md
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,25 @@ changed Hono CLI. Newest first.
**Changes**: the combo line joins the skill (honojs/skills#6). No CLI
change.

## 2026-09-12: The bindings come to the app, not the app to workerd

**Experiment**: the dev-server measurement named the condition ("plain
`request` does not reach `c.env`"), and the isolation runs showed the
only working lever is making the agent-directed path the correct one.

**Findings**: `--runtime workerd` covers bindings but is heavy and
excludes `batch` and `snapshot` — the commands agents actually live
in. wrangler's `getPlatformProxy` builds the real local bindings
while the app stays on Node.js.

**Changes**: in a project with a wrangler config, `c.env` carries the
real local bindings automatically in `request`, `batch`, and
`snapshot` (`--no-bindings` to skip; a missing wrangler warns and
continues, a broken config fails as `BINDINGS_FAILED`). Automatic on
purpose: a flag nobody routes to does not exist, and the correct
default needs no rail at all. agent-dx measures next with a D1/KV
fixture.

## 2026-09-09: The origin story, finally measured

**Experiment**: the weekly matrix (`next.7` + skills#6, n=5 per cell,
Expand Down
7 changes: 7 additions & 0 deletions src/commands/batch/batch.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -185,6 +185,13 @@ describe('runBatch', () => {
expect(result.steps[0].saved).toBeUndefined()
})

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 }))
const result = await runBatch(app, parseBatch('{"path":"/env"}'), {}, { MY_VAR: 'hello' })
expect(result.steps[0].body).toEqual({ v: 'hello' })
})

it('sends shared headers, and step headers win', async () => {
const app = new Hono()
app.get('/echo', (c) => c.json({ auth: c.req.header('authorization'), x: c.req.header('x-a') }))
Expand Down
9 changes: 7 additions & 2 deletions src/commands/batch/batch.ts
Original file line number Diff line number Diff line change
Expand Up @@ -213,7 +213,8 @@ export const getByPath = (body: unknown, path: string): unknown => {
export const runBatch = async (
app: Hono,
steps: BatchStep[],
sharedHeaders: Record<string, string> = {}
sharedHeaders: Record<string, string> = {},
env?: Record<string, unknown>
): Promise<BatchResult> => {
const vars: Record<string, unknown> = {}
const results: StepResult[] = []
Expand All @@ -237,7 +238,11 @@ export const runBatch = async (
}
}

const response = await app.request(new Request(new URL(path, 'http://localhost').href, init))
const response = await app.request(
new Request(new URL(path, 'http://localhost').href, init),
undefined,
env
)
const text = await response.text()
const isJson = response.headers.get('content-type')?.includes('json')
let body: unknown = text
Expand Down
4 changes: 4 additions & 0 deletions src/commands/batch/index.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,10 @@ vi.mock('../../utils/build.js', () => ({
buildAndImportApp: vi.fn(),
}))

vi.mock('../../utils/bindings.js', () => ({
maybeLoadBindings: vi.fn(async () => undefined),
}))

import { batchCommand } from './index.js'

describe('batchCommand', () => {
Expand Down
42 changes: 29 additions & 13 deletions src/commands/batch/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import type { Command } from 'commander'
import { existsSync, readFileSync } from 'node:fs'
import { resolve } from 'node:path'
import type { CommandAgentContext } from '../../utils/agent-context.js'
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'
Expand All @@ -10,7 +11,14 @@ import { parseBatch, runBatch } from './batch.js'
export const agentContext: CommandAgentContext = {
output:
'{ "steps": [{ "method": "GET", "path": "/users", "status": 200, "body": [], "pass": true, "expect": { "status": 200 } }], "summary": { "total": 1, "passed": 1, "failed": 0 } }',
errors: ['BATCH_INVALID', 'BATCH_NOT_FOUND', 'ENTRY_NOT_FOUND', 'BUILD_FAILED', 'INVALID_APP'],
errors: [
'BATCH_INVALID',
'BATCH_NOT_FOUND',
'ENTRY_NOT_FOUND',
'BUILD_FAILED',
'INVALID_APP',
'BINDINGS_FAILED',
],
examples: [
`hono batch - <<'EOF'
{"path":"/users","expect":{"status":200}}
Expand All @@ -26,13 +34,15 @@ EOF`,
'--compact prints only the failed steps and the summary — use it when you only need the failed: 0 loop.',
'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.',
],
}

interface BatchOptions {
header?: string[]
external?: string[]
compact: boolean
bindings: boolean
}

export function batchCommand(program: Command) {
Expand All @@ -50,6 +60,7 @@ export function batchCommand(program: Command) {
[] as string[]
)
.option('--compact', 'Print only the failed steps and the summary', false)
.option('--no-bindings', 'Skip loading the local Cloudflare bindings')
.option(
'-e, --external <package>',
'Mark package as external (can be used multiple times)',
Expand All @@ -71,19 +82,24 @@ export function batchCommand(program: Command) {
}
const input = source === '-' ? await readStdin() : readBatchFile(source)
const steps = parseBatch(input)
for await (const app of getBuildIterator(file, false, options.external || [])) {
const result = await runBatch(app, steps, parseHeaders(options.header))
if (options.compact) {
printResult(
{
steps: result.steps.filter((step) => !step.pass),
summary: result.summary,
},
true
)
} else {
printResult(result)
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)
}
}
} finally {
await proxy?.dispose()
}
})
)
Expand Down
4 changes: 4 additions & 0 deletions src/commands/request/index.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,10 @@ vi.mock('../../utils/build.js', () => ({
buildAndImportApp: vi.fn(),
}))

vi.mock('../../utils/bindings.js', () => ({
maybeLoadBindings: vi.fn(async () => undefined),
}))

vi.mock('./runtime.js', async (importOriginal) => {
const original = await importOriginal<typeof RuntimeModule>()
return { ...original, runInRuntime: vi.fn() }
Expand Down
34 changes: 24 additions & 10 deletions src/commands/request/index.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
import type { Command } from 'commander'
import type { Hono } from 'hono'
import type { CommandAgentContext } from '../../utils/agent-context.js'
import { maybeLoadBindings } from '../../utils/bindings.js'
import type { PlatformProxy } from '../../utils/bindings.js'
import { getFilenameFromPath, saveFile } from '../../utils/file.js'
import { parseHeaders } from '../../utils/headers.js'
import { getBuildIterator, resolveData, resolveEntry } from '../../utils/load-app.js'
Expand All @@ -22,6 +24,7 @@ export const agentContext: CommandAgentContext = {
'RUNTIME_FAILED',
'WRANGLER_NOT_FOUND',
'WRANGLER_CONFIG_NOT_FOUND',
'BINDINGS_FAILED',
],
examples: [
'hono request /api/users',
Expand All @@ -36,7 +39,8 @@ export const agentContext: CommandAgentContext = {
'No server needed. The request goes directly to app.request().',
'Pass - as the file to read the app code from stdin. `app` is predefined and exported for you — write only routes. Code with its own `export default` is used as-is.',
'-d @file reads the body from a file, -d @- reads it from stdin.',
'--runtime runs the app on bun, deno, or workerd instead of Node.js. bun and deno must be installed. workerd starts the app with the wrangler config of the project, so the local bindings (c.env) are real — it needs wrangler installed and no file argument.',
'In a project with a wrangler config, c.env carries the real local bindings (KV, D1, R2, vars) automatically, while the app runs on Node.js. Skip it with --no-bindings.',
'--runtime runs the app on bun, deno, or workerd instead of Node.js. bun and deno must be installed. workerd runs the whole app inside workerd with the wrangler config — heavier than the automatic bindings, but the full runtime.',
'--trace adds matchedRoutes to the output: which middleware and handler matched, and which one responded. Use it to debug an unexpected response. A 404 result includes a suggestion to run it.',
'A JSON response body is embedded as an object. A binary body becomes null with "binary": true — save it with -o.',
'For several requests, or a flow that keeps state, use hono batch. To capture the current behavior of the app, use hono snapshot.',
Expand All @@ -58,6 +62,7 @@ interface RequestOptions {
head: boolean
external?: string[]
compact: boolean
bindings: boolean
}

export function requestCommand(program: Command) {
Expand Down Expand Up @@ -87,6 +92,7 @@ export function requestCommand(program: Command) {
'node'
)
.option('--compact', 'One-line JSON without the headers', false)
.option('--no-bindings', 'Skip loading the local Cloudflare bindings')
.option('-i, --include', 'Include protocol and headers in the output (with --plain)', false)
.option('-I, --head', 'Show only protocol and headers in the output (with --plain)', false)
.option(
Expand Down Expand Up @@ -189,13 +195,20 @@ export function requestCommand(program: Command) {
return
}

const buildIterator = getBuildIterator(file, watch, external)
for await (const app of buildIterator) {
const traced = options.trace ? withTracer(app) : undefined
const result = await executeRequest(traced?.app ?? app, path, options)
await printResponse(result, path, options, doSaveFile, {
...(traced ? { matchedRoutes: traced.getTrace() } : {}),
})
const proxy: PlatformProxy | undefined = options.bindings
? await maybeLoadBindings()
: undefined
try {
const buildIterator = getBuildIterator(file, watch, external)
for await (const app of buildIterator) {
const traced = options.trace ? withTracer(app) : undefined
const result = await executeRequest(traced?.app ?? app, path, options, proxy?.env)
await printResponse(result, path, options, doSaveFile, {
...(traced ? { matchedRoutes: traced.getTrace() } : {}),
})
}
} finally {
await proxy?.dispose()
}
}
)
Expand Down Expand Up @@ -296,7 +309,8 @@ const handleSaveOutput = async (
export async function executeRequest(
app: Hono,
requestPath: string,
options: RequestOptions
options: RequestOptions,
env?: Record<string, unknown>
): Promise<{ status: number; body: string; headers: Record<string, string>; response: Response }> {
// Build request
const url = new URL(requestPath, 'http://localhost')
Expand All @@ -316,7 +330,7 @@ export async function executeRequest(

// Execute request
const request = new Request(url.href, requestInit)
const response = await app.request(request)
const response = await app.request(request, undefined, env)

// Convert response to our format
const responseHeaders: Record<string, string> = {}
Expand Down
15 changes: 12 additions & 3 deletions src/commands/snapshot/index.ts
Original file line number Diff line number Diff line change
@@ -1,13 +1,14 @@
import type { Command } from 'commander'
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 { 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'],
errors: ['ENTRY_NOT_FOUND', 'BUILD_FAILED', 'INVALID_APP', 'BINDINGS_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.',
Expand All @@ -16,12 +17,14 @@ export const agentContext: CommandAgentContext = {
'Capture before a refactor, then rerun the lines with hono batch until "failed" is 0.',
'--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.',
],
}

interface SnapshotOptions {
external?: string[]
statusOnly: boolean
bindings: boolean
}

export function snapshotCommand(program: Command) {
Expand All @@ -30,6 +33,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('--no-bindings', 'Skip loading the local Cloudflare bindings')
.option(
'-e, --external <package>',
'Mark package as external (can be used multiple times)',
Expand All @@ -40,8 +44,13 @@ export function snapshotCommand(program: Command) {
)
.action(
handleErrors(async (file: string | undefined, options: SnapshotOptions) => {
for await (const app of getBuildIterator(file, false, options.external || [])) {
console.log((await snapshotLines(app, options.statusOnly)).join('\n'))
const proxy = options.bindings ? await maybeLoadBindings() : undefined
try {
for await (const app of getBuildIterator(file, false, options.external || [])) {
console.log((await snapshotLines(app, options.statusOnly, proxy?.env)).join('\n'))
}
} finally {
await proxy?.dispose()
}
})
)
Expand Down
7 changes: 7 additions & 0 deletions src/commands/snapshot/snapshot.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,13 @@ describe('snapshotLines', () => {
})
})

it('passes the env through to c.env', async () => {
const a = new Hono()
a.get('/env', (c) => c.json({ v: (c.env as { MY_VAR: string }).MY_VAR }))
const lines = (await snapshotLines(a, false, { MY_VAR: 'hello' })).map((l) => JSON.parse(l))
expect(lines).toContainEqual({ path: '/env', expect: { status: 200, body: { v: 'hello' } } })
})

it('every line is valid batch input', async () => {
const { parseBatch } = await import('../batch/batch.js')
const lines = await snapshotLines(app())
Expand Down
18 changes: 13 additions & 5 deletions src/commands/snapshot/snapshot.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,14 +8,18 @@ import { inspectRoutes } from 'hono/dev'
* printed without an `expect`, for the caller to fill in. One probe
* line records the current not-found behavior as a fact.
*/
export const snapshotLines = async (app: Hono, statusOnly = false): Promise<string[]> => {
export const snapshotLines = async (
app: Hono,
statusOnly = false,
env?: Record<string, unknown>
): Promise<string[]> => {
const lines: string[] = []
const routes = inspectRoutes(app).filter((route) => !route.isMiddleware)

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)
const captured = await capture(app, route.path, env)
const expect = statusOnly ? { status: captured.status } : captured
lines.push(JSON.stringify({ path: route.path, expect }))
} else {
Expand All @@ -29,15 +33,19 @@ export const snapshotLines = async (app: Hono, statusOnly = false): Promise<stri
lines.push(
JSON.stringify({
path: '/__no_such_path__',
expect: await capture(app, '/__no_such_path__'),
expect: await capture(app, '/__no_such_path__', env),
})
)

return lines
}

const capture = async (app: Hono, path: string): Promise<{ status: number; body?: unknown }> => {
const response = await app.request(new URL(path, 'http://localhost').href)
const capture = async (
app: Hono,
path: string,
env?: Record<string, unknown>
): Promise<{ status: number; body?: unknown }> => {
const response = await app.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
Expand Down
Loading
Loading