diff --git a/README.md b/README.md index 7d55728..93abfba 100644 --- a/README.md +++ b/README.md @@ -139,7 +139,7 @@ hono request [file] [options] - `-X, --method ` - HTTP method (default: GET) - `-d, --data ` - Request body data (`@file` reads a file, `@-` reads stdin). Without a `Content-Type` header, a JSON object or array goes as `application/json` and anything else as a form, like curl's `-d` - `-H, --header
` - Custom headers (can be used multiple times) -- `-w, --watch` - Watch for changes and resend request +- `-w, --watch` - Watch for changes and resend request (with `--runtime node` or `vite`) - `-o, --output ` - Write response body to file instead of stdout - `-O, --remote-name` - Write response body to file named as remote file - `--plain` - human-readable output instead of JSON @@ -204,7 +204,7 @@ In a project with a wrangler config, `c.env` carries the real local bindings (KV In a Vite project with no `src/index.ts` (or `.tsx`, `.js`, `.jsx`) — the app comes from a Vite plugin — `--runtime vite` is the default for `request` and `batch`, so they work with no flag. -In a `cf` project — `cloudflare.config.ts` and a Vite config, no wrangler config — `--runtime vite` is the default, so `c.env` has the bindings with no flag. The default entry as the file argument (`src/index.ts`, `src/index.tsx`, ...) still goes through Vite. Another file argument, `--no-bindings`, `--trace`, or `--watch` runs the app on Node.js instead: `--trace` and `--watch` need the Hono app itself. +In a `cf` project — `cloudflare.config.ts` and a Vite config, no wrangler config — `--runtime vite` is the default, so `c.env` has the bindings with no flag. The default entry as the file argument (`src/index.ts`, `src/index.tsx`, ...) still goes through Vite. Another file argument, `--no-bindings`, or `--trace` runs the app on Node.js instead: `--trace` needs the Hono app itself. `--watch` stays on Vite: the request goes again when a file changes. With `--trace`, the output has `matchedRoutes`. `responded` marks the route that returned the response: diff --git a/src/commands/batch/index.test.ts b/src/commands/batch/index.test.ts index 31f46ad..19127e7 100644 --- a/src/commands/batch/index.test.ts +++ b/src/commands/batch/index.test.ts @@ -118,6 +118,7 @@ describe('batchCommand', () => { ), fetch: vi.fn(), loadModule: vi.fn(), + onChange: vi.fn(), dispose: vi.fn(async () => {}), } vi.mocked(workerd.startWorkerd).mockResolvedValue(target) @@ -142,6 +143,7 @@ describe('batchCommand', () => { Response.json({ path: new URL(input.url).pathname }) ), loadModule: vi.fn(), + onChange: vi.fn(), dispose: vi.fn(async () => {}), } vi.mocked(vite.startVite).mockResolvedValue(target) diff --git a/src/commands/request/index.test.ts b/src/commands/request/index.test.ts index 2e04d51..3434c82 100644 --- a/src/commands/request/index.test.ts +++ b/src/commands/request/index.test.ts @@ -1203,6 +1203,7 @@ describe('requestCommand', () => { Response.json({ method: input.method, body: await input.text() }) ), loadModule: vi.fn(), + onChange: vi.fn(), dispose: vi.fn(async () => {}), } vi.mocked(vite.startVite).mockResolvedValue(target) @@ -1237,6 +1238,7 @@ describe('requestCommand', () => { const target = { request: vi.fn(async () => new Response('from vite')), loadModule: vi.fn(), + onChange: vi.fn(), dispose: vi.fn(async () => {}), } vi.mocked(vite.startVite).mockResolvedValue(target) @@ -1260,6 +1262,39 @@ describe('requestCommand', () => { vi.mocked(vite.hasViteConfig).mockReturnValue(false) }) + it('should resend through Vite on a change with --watch in a cf project', async () => { + const workerd = await import('../../utils/workerd.js') + const vite = await import('../../utils/vite.js') + vi.mocked(workerd.hasCloudflareConfig).mockReturnValue(true) + vi.mocked(vite.hasViteConfig).mockReturnValue(true) + let changed = () => {} + let version = 0 + const target = { + request: vi.fn(async () => new Response(`v${++version}`)), + loadModule: vi.fn(), + onChange: vi.fn((listener: () => void) => { + changed = listener + }), + dispose: vi.fn(async () => {}), + } + vi.mocked(vite.startVite).mockResolvedValue(target) + + // --watch never returns + void program.parseAsync(['node', 'test', 'request', '/', '--watch', '--compact']) + await vi.waitFor(() => expect(consoleLogSpy).toHaveBeenCalledTimes(1)) + // Changes close together send one request + changed() + changed() + await vi.waitFor(() => expect(consoleLogSpy).toHaveBeenCalledTimes(2)) + expect(target.request).toHaveBeenCalledTimes(2) + expect(JSON.parse(consoleLogSpy.mock.calls[1][0])).toEqual({ + ok: true, + data: { status: 200, body: 'v2', runtime: 'vite' }, + }) + vi.mocked(workerd.hasCloudflareConfig).mockReturnValue(false) + vi.mocked(vite.hasViteConfig).mockReturnValue(false) + }) + it('should reject a file argument and --trace with vite', async () => { await program.parseAsync(['node', 'test', 'request', '/', 'src/app.ts', '--runtime', 'vite']) await program.parseAsync(['node', 'test', 'request', '/', '--runtime', 'vite', '--trace']) diff --git a/src/commands/request/index.ts b/src/commands/request/index.ts index d0a6034..f4ec0ee 100644 --- a/src/commands/request/index.ts +++ b/src/commands/request/index.ts @@ -10,6 +10,7 @@ import { CliError, handleErrors, printResult } from '../../utils/output.js' import { assertNoViteFile, defaultRuntime } from '../../utils/runtime-option.js' import type { RequestTarget } from '../../utils/target.js' import { startVite, VITE_NOTE } from '../../utils/vite.js' +import type { ViteTarget } from '../../utils/vite.js' import { runOnWorkerd } from '../../utils/workerd.js' import { resolvePositionals } from './positionals.js' import type { Runtime } from './runtime.js' @@ -118,20 +119,19 @@ export function requestCommand(program: Command) { suggestions: ['Use one of: node, bun, deno, workerd, vite'], }) } - // --watch and --trace need the app on Node.js + // --trace needs the app on Node.js const runtime = (options.runtime ?? - (options.watch || options.trace - ? 'node' - : defaultRuntime(file, options.bindings))) as Runtime + (options.trace ? 'node' : defaultRuntime(file, options.bindings))) as Runtime options.runtime = runtime - if (runtime !== 'node' && (options.watch || options.trace)) { - throw new CliError( - 'INVALID_OPTION', - `Cannot use --watch or --trace with --runtime ${runtime}`, - { - suggestions: ['Drop --watch and --trace, or use --runtime node'], - } - ) + if (runtime !== 'node' && options.trace) { + throw new CliError('INVALID_OPTION', `Cannot use --trace with --runtime ${runtime}`, { + suggestions: ['Drop --trace, or use --runtime node'], + }) + } + if (runtime !== 'node' && runtime !== 'vite' && options.watch) { + throw new CliError('INVALID_OPTION', `Cannot use --watch with --runtime ${runtime}`, { + suggestions: ['Drop --watch, or use --runtime node or vite'], + }) } if (runtime !== 'node' && !options.bindings) { @@ -190,8 +190,14 @@ export function requestCommand(program: Command) { assertNoViteFile(file) const target = await startVite() try { - const result = await executeRequest(target, path, options) - await printResponse(result, path, options, doSaveFile, { runtime }) + const send = async () => { + const result = await executeRequest(target, path, options) + await printResponse(result, path, options, doSaveFile, { runtime }) + } + await send() + if (watch) { + await watchVite(target, send) + } } finally { await target.dispose().catch(() => {}) } @@ -239,6 +245,24 @@ export function requestCommand(program: Command) { ) } +/** + * Resend the request on every change until the process stops. Vite + * reloads the changed modules, so the next request sees the new code. + * Changes close together send one request. A failed request logs and + * waits for the next change, like a failed build on Node.js. + */ +const watchVite = (target: ViteTarget, send: () => Promise): Promise => { + let timer: ReturnType | undefined + let queue = Promise.resolve() + target.onChange(() => { + clearTimeout(timer) + timer = setTimeout(() => { + queue = queue.then(send).catch((error) => console.error('Error sending request', error)) + }, 100) + }) + return new Promise(() => {}) +} + const printResponse = async ( result: { status: number; body: string; headers: Record; response: Response }, path: string, diff --git a/src/commands/routes/index.test.ts b/src/commands/routes/index.test.ts index 3349ad4..9cb3cab 100644 --- a/src/commands/routes/index.test.ts +++ b/src/commands/routes/index.test.ts @@ -87,7 +87,12 @@ describe('routesCommand', () => { const voidApp = new Hono() voidApp.get('/api/hello', (c) => c.json({})) vi.mocked(vite.loadVoidApp).mockResolvedValue(voidApp) - const target = { request: vi.fn(), loadModule: vi.fn(), dispose: vi.fn(async () => {}) } + const target = { + request: vi.fn(), + loadModule: vi.fn(), + onChange: vi.fn(), + dispose: vi.fn(async () => {}), + } vi.mocked(vite.startVite).mockResolvedValue(target) try { diff --git a/src/commands/snapshot/index.test.ts b/src/commands/snapshot/index.test.ts index 5d546c0..d306003 100644 --- a/src/commands/snapshot/index.test.ts +++ b/src/commands/snapshot/index.test.ts @@ -62,6 +62,7 @@ describe('snapshotCommand', () => { request: vi.fn(async () => Response.json({ from: 'workerd' })), fetch: vi.fn(), loadModule: vi.fn(), + onChange: vi.fn(), dispose: vi.fn(async () => {}), } vi.mocked(workerd.readWorkerdMain).mockResolvedValue('/proj/src/index.ts') @@ -81,6 +82,7 @@ describe('snapshotCommand', () => { const target = { request: vi.fn(async () => Response.json({ from: 'vite' })), loadModule: vi.fn(), + onChange: vi.fn(), dispose: vi.fn(async () => {}), } vi.mocked(vite.startVite).mockResolvedValue(target) @@ -103,6 +105,7 @@ describe('snapshotCommand', () => { const target = { request: vi.fn(async () => Response.json({ from: 'vite' })), loadModule: vi.fn(), + onChange: vi.fn(), dispose: vi.fn(async () => {}), } vi.mocked(vite.startVite).mockResolvedValue(target) @@ -131,6 +134,7 @@ describe('snapshotCommand', () => { const target = { request: vi.fn(async () => Response.json({ from: 'vite' })), loadModule: vi.fn(), + onChange: vi.fn(), dispose: vi.fn(async () => {}), } vi.mocked(vite.startVite).mockResolvedValue(target) diff --git a/src/utils/vite.ts b/src/utils/vite.ts index 7b04abf..6a7a378 100644 --- a/src/utils/vite.ts +++ b/src/utils/vite.ts @@ -20,6 +20,7 @@ interface ViteLogger { interface ViteDevServer { middlewares: (req: IncomingMessage, res: ServerResponse) => void ssrLoadModule(id: string): Promise> + watcher: { on(event: 'change' | 'add' | 'unlink', listener: () => void): void } close(): Promise } @@ -41,6 +42,8 @@ export interface ViteTarget extends RequestTarget { request(input: Request): Promise /** Load a module, virtual ones too, through the project's Vite plugins */ loadModule(id: string): Promise> + /** Call `listener` when a file of the project changes */ + onChange(listener: () => void): void dispose(): Promise } @@ -193,6 +196,11 @@ export const startVite = async (): Promise => { loadModule(id: string) { return logsToStderr(() => vite.ssrLoadModule(id)) }, + onChange(listener: () => void) { + for (const event of ['change', 'add', 'unlink'] as const) { + vite.watcher.on(event, listener) + } + }, async dispose() { server.closeAllConnections() await new Promise((resolve) => server.close(resolve))