From ce0b73ae0d703c2b4b27d82b3345f6b3d49391f0 Mon Sep 17 00:00:00 2001 From: Yusuke Wada Date: Sat, 12 Sep 2026 16:21:42 +0900 Subject: [PATCH 1/2] feat: load the local Cloudflare bindings automatically --- README.md | 6 ++- docs/agent-dx-log.md | 19 ++++++++++ src/commands/batch/batch.test.ts | 7 ++++ src/commands/batch/batch.ts | 9 ++++- src/commands/batch/index.test.ts | 4 ++ src/commands/batch/index.ts | 42 ++++++++++++++------- src/commands/request/index.test.ts | 4 ++ src/commands/request/index.ts | 34 ++++++++++++----- src/commands/snapshot/index.ts | 15 ++++++-- src/commands/snapshot/snapshot.test.ts | 7 ++++ src/commands/snapshot/snapshot.ts | 18 ++++++--- src/utils/bindings.test.ts | 28 ++++++++++++++ src/utils/bindings.ts | 51 ++++++++++++++++++++++++++ 13 files changed, 210 insertions(+), 34 deletions(-) create mode 100644 src/utils/bindings.test.ts create mode 100644 src/utils/bindings.ts diff --git a/README.md b/README.md index 4200745..3ffecce 100644 --- a/README.md +++ b/README.md @@ -141,6 +141,7 @@ hono request [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 ` - Mark package as external (can be used multiple times) **Examples:** @@ -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 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: @@ -246,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 +- `--no-bindings` - Skip loading the local Cloudflare bindings - `-e, --external ` - Mark package as external (can be used multiple times) ```bash diff --git a/docs/agent-dx-log.md b/docs/agent-dx-log.md index 303006b..398a974 100644 --- a/docs/agent-dx-log.md +++ b/docs/agent-dx-log.md @@ -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, diff --git a/src/commands/batch/batch.test.ts b/src/commands/batch/batch.test.ts index 46241de..43505bc 100644 --- a/src/commands/batch/batch.test.ts +++ b/src/commands/batch/batch.test.ts @@ -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') })) diff --git a/src/commands/batch/batch.ts b/src/commands/batch/batch.ts index e804f39..03bc4ef 100644 --- a/src/commands/batch/batch.ts +++ b/src/commands/batch/batch.ts @@ -213,7 +213,8 @@ export const getByPath = (body: unknown, path: string): unknown => { export const runBatch = async ( app: Hono, steps: BatchStep[], - sharedHeaders: Record = {} + sharedHeaders: Record = {}, + env?: Record ): Promise => { const vars: Record = {} const results: StepResult[] = [] @@ -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 diff --git a/src/commands/batch/index.test.ts b/src/commands/batch/index.test.ts index 14d139a..f7e8315 100644 --- a/src/commands/batch/index.test.ts +++ b/src/commands/batch/index.test.ts @@ -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', () => { diff --git a/src/commands/batch/index.ts b/src/commands/batch/index.ts index d950825..0a571d3 100644 --- a/src/commands/batch/index.ts +++ b/src/commands/batch/index.ts @@ -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' @@ -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}} @@ -26,6 +34,7 @@ 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.', ], } @@ -33,6 +42,7 @@ interface BatchOptions { header?: string[] external?: string[] compact: boolean + bindings: boolean } export function batchCommand(program: Command) { @@ -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 ', 'Mark package as external (can be used multiple times)', @@ -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() } }) ) diff --git a/src/commands/request/index.test.ts b/src/commands/request/index.test.ts index 62e6c21..d566f54 100644 --- a/src/commands/request/index.test.ts +++ b/src/commands/request/index.test.ts @@ -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() return { ...original, runInRuntime: vi.fn() } diff --git a/src/commands/request/index.ts b/src/commands/request/index.ts index 9bcc009..c954ae9 100644 --- a/src/commands/request/index.ts +++ b/src/commands/request/index.ts @@ -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' @@ -22,6 +24,7 @@ export const agentContext: CommandAgentContext = { 'RUNTIME_FAILED', 'WRANGLER_NOT_FOUND', 'WRANGLER_CONFIG_NOT_FOUND', + 'BINDINGS_FAILED', ], examples: [ 'hono request /api/users', @@ -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.', @@ -58,6 +62,7 @@ interface RequestOptions { head: boolean external?: string[] compact: boolean + bindings: boolean } export function requestCommand(program: Command) { @@ -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( @@ -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() } } ) @@ -296,7 +309,8 @@ const handleSaveOutput = async ( export async function executeRequest( app: Hono, requestPath: string, - options: RequestOptions + options: RequestOptions, + env?: Record ): Promise<{ status: number; body: string; headers: Record; response: Response }> { // Build request const url = new URL(requestPath, 'http://localhost') @@ -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 = {} diff --git a/src/commands/snapshot/index.ts b/src/commands/snapshot/index.ts index eadbbc1..5a68197 100644 --- a/src/commands/snapshot/index.ts +++ b/src/commands/snapshot/index.ts @@ -1,5 +1,6 @@ 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' @@ -7,7 +8,7 @@ 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.', @@ -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) { @@ -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 ', 'Mark package as external (can be used multiple times)', @@ -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() } }) ) diff --git a/src/commands/snapshot/snapshot.test.ts b/src/commands/snapshot/snapshot.test.ts index 341f5eb..810984f 100644 --- a/src/commands/snapshot/snapshot.test.ts +++ b/src/commands/snapshot/snapshot.test.ts @@ -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()) diff --git a/src/commands/snapshot/snapshot.ts b/src/commands/snapshot/snapshot.ts index a6e871d..695d359 100644 --- a/src/commands/snapshot/snapshot.ts +++ b/src/commands/snapshot/snapshot.ts @@ -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 => { +export const snapshotLines = async ( + app: Hono, + statusOnly = false, + env?: Record +): Promise => { 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 { @@ -29,15 +33,19 @@ export const snapshotLines = async (app: Hono, statusOnly = false): Promise => { - const response = await app.request(new URL(path, 'http://localhost').href) +const capture = async ( + app: Hono, + path: string, + env?: Record +): 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 diff --git a/src/utils/bindings.test.ts b/src/utils/bindings.test.ts new file mode 100644 index 0000000..c37002f --- /dev/null +++ b/src/utils/bindings.test.ts @@ -0,0 +1,28 @@ +import { describe, it, expect, vi, afterEach } from 'vitest' +import { mkdtempSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { maybeLoadBindings } from './bindings.js' + +describe('maybeLoadBindings', () => { + const cwd = process.cwd() + afterEach(() => { + process.chdir(cwd) + }) + + it('is a no-op without a wrangler config', async () => { + process.chdir(mkdtempSync(join(tmpdir(), 'hono-cli-bindings-'))) + expect(await maybeLoadBindings()).toBeUndefined() + }) + + it('warns and continues when wrangler is not installed', async () => { + const dir = mkdtempSync(join(tmpdir(), 'hono-cli-bindings-')) + writeFileSync(join(dir, 'wrangler.jsonc'), '{"name":"probe"}') + writeFileSync(join(dir, 'package.json'), '{"name":"probe"}') + process.chdir(dir) + const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {}) + expect(await maybeLoadBindings()).toBeUndefined() + expect(errorSpy).toHaveBeenCalledWith(expect.stringContaining('wrangler is not installed')) + errorSpy.mockRestore() + }) +}) diff --git a/src/utils/bindings.ts b/src/utils/bindings.ts new file mode 100644 index 0000000..7226a24 --- /dev/null +++ b/src/utils/bindings.ts @@ -0,0 +1,51 @@ +import { existsSync } from 'node:fs' +import { createRequire } from 'node:module' +import { join } from 'node:path' +import { pathToFileURL } from 'node:url' +import { CliError } from './output.js' + +export interface PlatformProxy { + env: Record + dispose: () => Promise +} + +interface WranglerModule { + getPlatformProxy(options: { configPath: string }): Promise +} + +const CONFIG_CANDIDATES = ['wrangler.json', 'wrangler.jsonc', 'wrangler.toml'] + +/** + * In a project with a wrangler config, load the real local bindings + * (KV, D1, R2, vars) for `c.env` via wrangler's `getPlatformProxy`. + * The app keeps running on Node.js — wrangler simulates only the + * binding backends. Without a config this is a no-op; with a config + * but no wrangler it warns and continues, so the basic flow never + * breaks. Callers must dispose the proxy, or the process hangs. + */ +export const maybeLoadBindings = async (): Promise => { + const config = CONFIG_CANDIDATES.find((file) => existsSync(join(process.cwd(), file))) + if (!config) { + return undefined + } + const require = createRequire(join(process.cwd(), 'package.json')) + let resolved: string + try { + resolved = require.resolve('wrangler') + } catch { + console.error( + 'wrangler config found but wrangler is not installed — c.env stays empty. Install wrangler, or pass --no-bindings.' + ) + return undefined + } + try { + const wrangler: WranglerModule = await import(pathToFileURL(resolved).href) + const proxy = await wrangler.getPlatformProxy({ configPath: join(process.cwd(), config) }) + return { env: proxy.env, dispose: () => proxy.dispose() } + } catch (e) { + throw new CliError('BINDINGS_FAILED', e instanceof Error ? e.message : String(e), { + suggestions: ['Check the wrangler config, or pass --no-bindings'], + docs: 'https://developers.cloudflare.com/workers/wrangler/api/#getplatformproxy', + }) + } +} From e2efa9836b1804271a63042749ebf16fa872c335 Mon Sep 17 00:00:00 2001 From: Yusuke Wada Date: Sat, 12 Sep 2026 16:23:40 +0900 Subject: [PATCH 2/2] docs: note the runtime interplay --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 3ffecce..f98e66f 100644 --- a/README.md +++ b/README.md @@ -186,7 +186,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 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). +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.