From f3f302fe6a488595d9cb806fb37319415019ccd4 Mon Sep 17 00:00:00 2001 From: LongYinan Date: Thu, 15 Oct 2026 13:20:37 +0800 Subject: [PATCH] fix(void): answer Astro dev triggers in apps that have their own middleware Void adds the dev trigger endpoints to Astro through a generated src/middleware.ts. When the app already has its own middleware, Void cannot write that file, so /__void/scheduled and /__void/queue returned 404 and the warning linked to setup steps that do not exist. For those apps, Void now answers the endpoints in a dev-only wrapper around Astro's Cloudflare server entry. Apps without middleware keep the generated file. An app with its own middleware and a custom Worker entry gets a warning that says what to change. Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_0123r5HnanmW2VUrZ7VWDufH --- .../void/src/plugin-framework-triggers.ts | 63 +++-- packages/void/src/plugin-framework.ts | 55 +++-- packages/void/test/integration/astro.test.ts | 23 +- .../unit/plugin-framework-triggers.test.ts | 62 +++-- .../test/unit/plugin-vite-environment.test.ts | 221 ++++++++++++++---- 5 files changed, 333 insertions(+), 91 deletions(-) diff --git a/packages/void/src/plugin-framework-triggers.ts b/packages/void/src/plugin-framework-triggers.ts index 65d7205ec..6e1723d29 100644 --- a/packages/void/src/plugin-framework-triggers.ts +++ b/packages/void/src/plugin-framework-triggers.ts @@ -1,6 +1,7 @@ import { chmodSync, existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs'; import { dirname, join } from 'pathslash'; import { normalizePath, type Plugin } from 'vite'; +import { readRootWranglerConfig } from './cli/wrangler.ts'; import { scanJobsSync, type JobDefinition } from './crons/scan.ts'; import { voidWarn } from './log.ts'; import { readProjectPaths } from './project-paths.ts'; @@ -51,9 +52,9 @@ export function writeFrameworkTriggersDispatch(root: string, devTriggerToken: st /** * Writes a real file at `/.void/dev-triggers.ts` exporting * `handleDevTrigger(request, env, ctx)`. We use a real file (not a Vite - * virtual module) because Nitro and Astro have their own server bundlers - * that don't go through Vite's plugin pipeline. The framework injection - * code imports this path at runtime to dispatch cron and queue handlers + * virtual module) because Nitro has its own server bundler that doesn't go + * through Vite's plugin pipeline. The framework injection code imports + * this path at runtime to dispatch cron and queue handlers * with the framework adapter's real bindings. */ export function frameworkTriggersPlugin(root: string, options: FrameworkTriggersOptions): Plugin { @@ -571,14 +572,43 @@ function astroMiddlewareClaims(): Map { return (store[ASTRO_MIDDLEWARE_CLAIMS] ??= new Map()); } +/** + * Whether the Astro app has middleware that Void did not write. Void cannot add its dev trigger + * middleware then, so the wrapper around Astro's Cloudflare server entry answers the triggers. + */ +export function hasOwnAstroMiddleware(root: string): boolean { + const middlewarePath = join(root, ASTRO_MIDDLEWARE_RELATIVE); + if (existsSync(middlewarePath)) { + // A restart: the dev server being replaced wrote this file and still holds it. + return !astroMiddlewareClaims().has(middlewarePath); + } + return existsSync(join(root, 'src', 'middleware', 'index.ts')); +} + +/** The Worker entry from the app's Cloudflare config, unless it is Astro's server entry. */ +function customAstroWorkerEntry(root: string): string | undefined { + let main: unknown; + try { + main = readRootWranglerConfig(root)?.config.main; + } catch { + return undefined; + } + return typeof main === 'string' && + !/^@astrojs\/cloudflare\/entrypoints\/server(?:\.js)?$/.test(main) + ? main + : undefined; +} + /** * Writes a tiny Astro middleware to the user's project so * `/__void/scheduled` and `/__void/queue` are intercepted inside Astro's - * request pipeline, with bindings from `cloudflare:workers`. + * request pipeline, with bindings from `cloudflare:workers`. Astro runs + * middleware for a custom Worker entry too. * * Astro middleware lives at a single discoverable entry, so if the user - * already has `src/middleware.ts` we don't overwrite it — instead we log a - * one-shot snippet they can paste into their existing middleware. + * already has `src/middleware.ts` we don't overwrite it. The wrapper around + * Astro's Cloudflare server entry answers the triggers then. A custom Worker + * entry can skip that server entry, so we warn when the app sets one. * * If we wrote the file we remove it when the last dev server using it closes, * so the user's source tree stays clean. `src/middleware.ts` belongs to the @@ -587,25 +617,28 @@ function astroMiddlewareClaims(): Map { */ export function astroDevTriggerInjectPlugin(root: string): Plugin { const middlewarePath = join(root, ASTRO_MIDDLEWARE_RELATIVE); - const middlewareDirIndexPath = join(root, 'src', 'middleware', 'index.ts'); const claims = astroMiddlewareClaims(); /** Whether the dev server being set up now holds the middleware Void wrote. */ const ensureFile = (): boolean => { + if (hasOwnAstroMiddleware(root)) { + const entry = customAstroWorkerEntry(root); + if (entry) { + voidWarn( + `Your Astro app has its own middleware and its own Worker entry (${entry}).\n` + + ' In dev, /__void/scheduled and /__void/queue do not work if this entry calls\n' + + ' handle() from @astrojs/cloudflare/handler. To use them, call fetch() on the\n' + + ' default export of @astrojs/cloudflare/entrypoints/server instead.', + ); + } + return false; + } const claim = claims.get(middlewarePath); if (claim && existsSync(middlewarePath)) { // A restart: the server being replaced wrote this file and still holds it. claim.servers++; return true; } - if (existsSync(middlewarePath) || existsSync(middlewareDirIndexPath)) { - voidWarn( - 'Astro middleware already exists. To enable /__void/scheduled and /__void/queue,\n' + - ' add the Void dev-trigger middleware to your existing onRequest sequence.\n' + - ' See https://void.cloud/guide/jobs#local-development.', - ); - return false; - } mkdirSync(dirname(middlewarePath), { recursive: true }); writeFileSync(middlewarePath, astroMiddlewareSource(frameworkTriggersDispatchPath(root))); if (claim) { diff --git a/packages/void/src/plugin-framework.ts b/packages/void/src/plugin-framework.ts index e7a98bc90..9427b811f 100644 --- a/packages/void/src/plugin-framework.ts +++ b/packages/void/src/plugin-framework.ts @@ -26,6 +26,7 @@ import { astroDevTriggerInjectPlugin, frameworkTriggersDispatchPath, frameworkTriggersPlugin, + hasOwnAstroMiddleware, nitroDevTriggerInjectPlugin, } from './plugin-framework-triggers.ts'; import { migrationPlugin } from './plugin-migration.ts'; @@ -235,20 +236,34 @@ export function createFrameworkPlugins(input: { }, }; - // Astro's Cloudflare adapter owns the Worker entry. For PostgreSQL and MySQL, its default - // entry runs each request inside Void's request scope (`withRuntimeEnv`), as the Class A - // wrapper does, so void/db keeps one client per request instead of one per query. D1 apps - // keep Astro's own entry: in a request scope, void/db reads D1 through a replica-capable - // session instead of the primary. - const astroRequestScopePlugin: Plugin | undefined = - detected.name === 'astro' && dialect !== 'sqlite' + // Astro's Cloudflare adapter owns the Worker entry. Void wraps its default entry: + // - For PostgreSQL and MySQL, each request runs inside Void's request scope + // (`withRuntimeEnv`), as the Class A wrapper does, so void/db keeps one client per request + // instead of one per query. D1 apps run without it: inside a request scope, void/db reads D1 + // through a replica-capable session instead of the primary. + // - In dev, Void's own middleware answers `/__void/scheduled` and `/__void/queue` + // (`astroDevTriggerInjectPlugin`). When the app has its own middleware, Void cannot add + // one, so the wrapper sends these requests to the generated trigger dispatch before Astro + // sees them. A build never imports the dispatch file, which exists only in dev and embeds + // the dev-trigger token. + const scopeAstroRequests = dialect !== 'sqlite'; + let astroDevTriggers = false; + const astroServerEntryPlugin: Plugin | undefined = + detected.name === 'astro' ? { - name: 'void:astro-request-scope', + name: 'void:astro-server-entry', enforce: 'pre', + configResolved(resolvedConfig) { + astroDevTriggers = resolvedConfig.command === 'serve' && hasOwnAstroMiddleware(root); + }, resolveId: { filter: { id: /^@astrojs\/cloudflare\/entrypoints\/server(?:\.js)?$/ }, handler(_id, importer) { - if (importer === ASTRO_SCOPED_SERVER_ENTRY || this.environment?.name === 'client') { + if ( + (!scopeAstroRequests && !astroDevTriggers) || + importer === ASTRO_SCOPED_SERVER_ENTRY || + this.environment?.name === 'client' + ) { return; } return ASTRO_SCOPED_SERVER_ENTRY; @@ -258,14 +273,28 @@ export function createFrameworkPlugins(input: { // oxlint-disable-next-line no-control-regex -- \0 prefix is Vite's virtual module convention filter: { id: /^\0void:astro-server-entry$/ }, handler() { - return `import original from ${JSON.stringify(ASTRO_SERVER_ENTRY)}; -import { withRuntimeEnv } from ${JSON.stringify(envModuleFile)}; + const imports = [ + `import original from ${JSON.stringify(ASTRO_SERVER_ENTRY)};`, + ...(scopeAstroRequests + ? [`import { withRuntimeEnv } from ${JSON.stringify(envModuleFile)};`] + : []), + ...(astroDevTriggers + ? [ + `import { handleDevTrigger } from ${JSON.stringify(frameworkTriggersDispatchPath(root))};`, + ] + : []), + ]; + const handle = astroDevTriggers + ? 'async () => (await handleDevTrigger(request, env, ctx)) ?? original.fetch(request, env, ctx)' + : '() => original.fetch(request, env, ctx)'; + return `${imports.join('\n')} export * from ${JSON.stringify(ASTRO_SERVER_ENTRY)}; export default { ...original, fetch(request, env, ctx) { - return withRuntimeEnv(env, () => original.fetch(request, env, ctx)); + const handle = ${handle}; + return ${scopeAstroRequests ? 'withRuntimeEnv(env, handle)' : 'handle()'}; }, }; `; @@ -335,7 +364,7 @@ export default { wranglerCompatPlugin, wranglerSyncPlugin, ...(nuxtVite8WebSocketCompatPlugin ? [nuxtVite8WebSocketCompatPlugin] : []), - ...(astroRequestScopePlugin ? [astroRequestScopePlugin] : []), + ...(astroServerEntryPlugin ? [astroServerEntryPlugin] : []), svelteKitHooksInjectPlugin, ...(svelteKitWorkerBuildPlugin ? [svelteKitWorkerBuildPlugin] : []), clientStubsPlugin, diff --git a/packages/void/test/integration/astro.test.ts b/packages/void/test/integration/astro.test.ts index 8b98c9ab8..6d49d2007 100644 --- a/packages/void/test/integration/astro.test.ts +++ b/packages/void/test/integration/astro.test.ts @@ -128,6 +128,18 @@ describe('playground/astro dev', () => { await browser.close(); } }); + + // An app without middleware gets Void's dev middleware, which answers the trigger. + it('runs a cron through the dev trigger endpoint', async () => { + const token = readFileSync(join(ROOT, PLAYGROUND, '.void/dev-trigger-token'), 'utf8').trim(); + const response = await fetch(`${DEV_BASE}/__void/scheduled`, { + method: 'POST', + headers: { 'x-void-dev-trigger': token }, + body: JSON.stringify({ cron: '0 * * * *', scheduledTime: Date.now() }), + }); + const result = { status: response.status, body: await response.text() }; + expect(result).toEqual({ status: 200, body: '{"ok":true}' }); + }); }); // Astro owns its Worker entry; Void runs it in a request scope for PostgreSQL. @@ -142,6 +154,10 @@ describe('playground/astro with PostgreSQL', () => { let child: ReturnType | undefined; let dev: StartedServer; let connections = 0; + const USER_MIDDLEWARE = `import { defineMiddleware } from 'astro:middleware'; + +export const onRequest = defineMiddleware((_context, next) => next()); +`; function start(script: 'dev' | 'preview'): Promise { const root = playground.root; @@ -261,7 +277,7 @@ export async function GET() { `, ); // The import the jobs guide shows. The package entry is Void's Vite plugin, which workerd - // cannot load; the dev middleware imports every cron on each request. + // cannot load; the dev trigger dispatch imports every cron with the Worker entry. writeFileSync( join(root, 'crons/tick.ts'), `import { defineScheduled } from 'void'; @@ -275,6 +291,8 @@ export default defineScheduled(async () => {}); join(root, '.env'), `DATABASE_URL=postgresql://postgres@127.0.0.1:${port}/postgres\n`, ); + // An app's own middleware must not hide the dev trigger endpoints. + writeFileSync(join(root, 'src/middleware.ts'), USER_MIDDLEWARE); dev = await start('dev'); }); @@ -315,10 +333,11 @@ export default defineScheduled(async () => {}); }); const result = { status: response.status, body: await response.text() }; expect(result, dev.output().slice(-4000)).toEqual({ status: 200, body: '{"ok":true}' }); + expect(readFileSync(join(playground.root, 'src/middleware.ts'), 'utf8')).toBe(USER_MIDDLEWARE); }); it('runs every query of a request on one connection in the built Worker', async () => { - // The dev server writes Void's dev-only middleware into src/ until it stops. + // Stop dev so the build and preview run on their own. await killChild(dev.child); child = undefined; execFileSync( diff --git a/packages/void/test/unit/plugin-framework-triggers.test.ts b/packages/void/test/unit/plugin-framework-triggers.test.ts index adade2714..7bd3538a2 100644 --- a/packages/void/test/unit/plugin-framework-triggers.test.ts +++ b/packages/void/test/unit/plugin-framework-triggers.test.ts @@ -18,6 +18,7 @@ import { astroDevTriggerInjectPlugin, frameworkTriggersDispatchPath, frameworkTriggersPlugin, + hasOwnAstroMiddleware, nitroDevTriggerInjectPlugin, } from '../../src/plugin-framework-triggers.ts'; import type { QueueBatch } from '../../src/runtime/handler.ts'; @@ -538,22 +539,57 @@ describe('astroDevTriggerInjectPlugin', () => { expect(code).toContain(`from "${frameworkTriggersDispatchPath(root)}"`); }); - it('warns and skips when src/middleware.ts already exists', () => { + // Next to the app's own middleware, the wrapper around Astro's server entry answers the + // triggers. A custom Worker entry can skip that server entry. + it.each([ + [undefined, false], + ['@astrojs/cloudflare/entrypoints/server', false], + ['./src/worker.ts', true], + ])( + 'skips an app with its own middleware and warns only for a custom Worker entry (main: %s)', + (main, warns) => { + const root = createTempRoot(); + mkdirSync(join(root, 'src'), { recursive: true }); + const existing = '// user middleware\nexport const onRequest = () => {};\n'; + writeFileSync(join(root, 'src', 'middleware.ts'), existing); + writeFileSync(join(root, 'wrangler.jsonc'), JSON.stringify({ main })); + const log = vi.spyOn(console, 'log').mockImplementation(() => {}); + + callConfigureServer(astroDevTriggerInjectPlugin(root)); + + expect(readFileSync(join(root, 'src', 'middleware.ts'), 'utf-8')).toBe(existing); + const output = log.mock.calls.map((call) => String(call[0])).join('\n'); + if (warns) { + expect(output).toContain('its own Worker entry (./src/worker.ts)'); + expect(output).toContain('default export of @astrojs/cloudflare/entrypoints/server'); + } else { + expect(output).toBe(''); + } + }, + ); + + it('treats only middleware that Void did not write as app middleware', () => { const root = createTempRoot(); - mkdirSync(join(root, 'src'), { recursive: true }); - const existing = '// user middleware\nexport const onRequest = () => {};\n'; - writeFileSync(join(root, 'src', 'middleware.ts'), existing); - const log = vi.spyOn(console, 'log').mockImplementation(() => {}); + let onClose: (() => void) | undefined; + vi.spyOn(process, 'once').mockReturnValue(process); + expect(hasOwnAstroMiddleware(root)).toBe(false); - const plugin = astroDevTriggerInjectPlugin(root); - callConfigureServer(plugin); + callConfigureServer(astroDevTriggerInjectPlugin(root), { + httpServer: { + once(_event: string, cb: () => void) { + onClose = cb; + }, + }, + }); + expect(hasOwnAstroMiddleware(root)).toBe(false); - // User file untouched - expect(readFileSync(join(root, 'src', 'middleware.ts'), 'utf-8')).toBe(existing); - // Warned - expect(log.mock.calls.map((c) => String(c[0])).join('\n')).toContain( - 'Astro middleware already exists', - ); + onClose!(); + writeFileSync(join(root, 'src', 'middleware.ts'), 'export const onRequest = () => {};\n'); + expect(hasOwnAstroMiddleware(root)).toBe(true); + rmSync(join(root, 'src', 'middleware.ts')); + mkdirSync(join(root, 'src', 'middleware')); + writeFileSync(join(root, 'src', 'middleware', 'index.ts'), ''); + expect(hasOwnAstroMiddleware(root)).toBe(true); }); it('stops ignoring src/middleware.ts after the dev server closes', () => { diff --git a/packages/void/test/unit/plugin-vite-environment.test.ts b/packages/void/test/unit/plugin-vite-environment.test.ts index 012951c6b..0a5174f95 100644 --- a/packages/void/test/unit/plugin-vite-environment.test.ts +++ b/packages/void/test/unit/plugin-vite-environment.test.ts @@ -1041,49 +1041,123 @@ describe('Astro preview', () => { ); }); -describe('Astro request scope', () => { +describe('Astro server entry', () => { const entry = '@astrojs/cloudflare/entrypoints/server'; const scopedEntry = '\0void:astro-server-entry'; + const envUrl = new URL('../../src/runtime/env.ts', import.meta.url).href; + type WrapperModule = { + named: string; + default: { + marker: string; + fetch(request: Request, env: unknown, ctx: unknown): Promise; + }; + }; + + function createAstroRoot(database: 'pg' | 'mysql' | undefined): string { + const root = realpathSync.native(createProjectRoot()); + writeFileSync( + join(root, 'package.json'), + JSON.stringify({ type: 'module', dependencies: { astro: '*' } }), + ); + writeFileSync( + join(root, 'void.json'), + JSON.stringify({ database, worker: { compatibility_date: '2026-02-24' } }), + ); + vi.spyOn(process, 'cwd').mockReturnValue(root); + return root; + } + + function startDevServer(root: string) { + return createServer({ + root, + configFile: false, + logLevel: 'silent', + plugins: [voidPlugin()], + optimizeDeps: { noDiscovery: true }, + server: { middlewareMode: true, ws: false, preTransformRequests: false }, + }); + } + + /** Import the wrapper with stand-ins for the adapter entry and the generated trigger dispatch. */ + async function importWrapper( + root: string, + code: string, + adapter: string, + dispatch: string, + ): Promise { + writeFileSync(join(root, 'adapter-entry.mjs'), adapter); + writeFileSync(join(root, 'dispatch.mjs'), dispatch); + writeFileSync( + join(root, 'entry.mjs'), + code + .replaceAll(JSON.stringify(entry), '"./adapter-entry.mjs"') + .replace( + /^import \{ withRuntimeEnv \} from .*;$/m, + `import { withRuntimeEnv } from ${JSON.stringify(envUrl)};`, + ) + .replace( + /^import \{ handleDevTrigger \} from .*;$/m, + 'import { handleDevTrigger } from "./dispatch.mjs";', + ), + ); + return (await import(pathToFileURL(join(root, 'entry.mjs')).href)) as WrapperModule; + } + + const loadedCode = (loaded: unknown): string => + typeof loaded === 'string' ? loaded : ((loaded as { code?: string } | null)?.code ?? ''); + + const APP_MIDDLEWARE = 'export const onRequest = (_context, next) => next();\n'; + + function writeAppMiddleware(root: string): void { + mkdirSync(join(root, 'src'), { recursive: true }); + writeFileSync(join(root, 'src/middleware.ts'), APP_MIDDLEWARE); + } + + // Void adds its own middleware for the dev triggers when the app has none, as it also runs for a + // custom Worker entry. The wrapper answers the triggers only next to the app's own middleware. it.each([ - ['pg', true], - ['mysql', true], + ['pg', false], + ['mysql', false], [undefined, false], + ['pg', true], + [undefined, true], ] as const)( - 'runs the Cloudflare adapter entry in a Void request scope (database: %s): %s', - async (database, scoped) => { - const root = realpathSync.native(createProjectRoot()); - writeFileSync( - join(root, 'package.json'), - JSON.stringify({ type: 'module', dependencies: { astro: '*' } }), - ); - writeFileSync( - join(root, 'void.json'), - JSON.stringify({ database, worker: { compatibility_date: '2026-02-24' } }), - ); - vi.spyOn(process, 'cwd').mockReturnValue(root); - - const server = await createServer({ - root, - configFile: false, - logLevel: 'silent', - plugins: [voidPlugin()], - optimizeDeps: { noDiscovery: true }, - server: { middlewareMode: true, ws: false, preTransformRequests: false }, - }); + 'wraps the Cloudflare adapter entry in dev (database: %s, app middleware: %s)', + async (database, appMiddleware) => { + const root = createAstroRoot(database); + if (appMiddleware) { + writeAppMiddleware(root); + } + const server = await startDevServer(root); try { + const middleware = readFileSync(join(root, 'src/middleware.ts'), 'utf8'); + expect(middleware.includes('Auto-generated by Void in dev')).toBe(!appMiddleware); const container = server.environments.ssr!.pluginContainer; - const resolved = await container.resolveId(entry); - expect(resolved?.id === scopedEntry).toBe(scoped); - if (!scoped) { - return; + const wrapped = database !== undefined || appMiddleware; + expect((await container.resolveId(entry))?.id === scopedEntry).toBe(wrapped); + if (wrapped) { + // The wrapper imports the adapter's own entry. + expect((await container.resolveId(entry, scopedEntry))?.id).not.toBe(scopedEntry); + const code = loadedCode(await container.load(scopedEntry)); + expect(code.includes('handleDevTrigger')).toBe(appMiddleware); } - // The wrapper imports the adapter's own entry. - expect((await container.resolveId(entry, scopedEntry))?.id).not.toBe(scopedEntry); + } finally { + await server.close(); + } + }, + ); - const envUrl = new URL('../../src/runtime/env.ts', import.meta.url).href; - writeFileSync( - join(root, 'adapter-entry.mjs'), + it.each(['pg', 'mysql'] as const)( + 'runs the Cloudflare adapter entry in a Void request scope (database: %s)', + async (database) => { + const root = createAstroRoot(database); + const server = await startDevServer(root); + try { + const container = server.environments.ssr!.pluginContainer; + const wrapper = await importWrapper( + root, + loadedCode(await container.load(scopedEntry)), `import { getRuntimeBinding, getRuntimeRequestResource } from ${JSON.stringify(envUrl)}; export const named = "kept"; export default { @@ -1095,22 +1169,8 @@ export default { }, }; `, + 'export async function handleDevTrigger() {\n return null;\n}\n', ); - const loaded = await container.load(scopedEntry); - const code = (typeof loaded === 'string' ? loaded : (loaded?.code ?? '')) - .replaceAll(JSON.stringify(entry), '"./adapter-entry.mjs"') - .replace( - /^import \{ withRuntimeEnv \} from .*;$/m, - `import { withRuntimeEnv } from ${JSON.stringify(envUrl)};`, - ); - writeFileSync(join(root, 'entry.mjs'), code); - const wrapper = (await import(pathToFileURL(join(root, 'entry.mjs')).href)) as { - named: string; - default: { - marker: string; - fetch(request: Request, env: unknown, ctx: unknown): Promise; - }; - }; expect([wrapper.named, wrapper.default.marker]).toEqual(['kept', 'kept']); const response = await wrapper.default.fetch( @@ -1128,4 +1188,69 @@ export default { } }, ); + + // Void cannot add its middleware next to the app's own, so the wrapper answers the triggers. A + // build never imports the dev-only dispatch. + it.each(['pg', undefined] as const)( + 'sends dev triggers to the generated dispatch before the adapter entry, only in dev (database: %s)', + async (database) => { + const root = createAstroRoot(database); + writeAppMiddleware(root); + const server = await startDevServer(root); + let code: string; + try { + code = loadedCode(await server.environments.ssr!.pluginContainer.load(scopedEntry)); + } finally { + await server.close(); + } + expect(code).toContain(JSON.stringify(normalizePath(join(root, '.void/dev-triggers.ts')))); + const wrapper = await importWrapper( + root, + code, + 'export default {\n fetch() {\n return new Response("astro");\n },\n};\n', + `export async function handleDevTrigger(request, env, ctx) { + const { pathname } = new URL(request.url); + if (request.method !== "POST" || pathname !== "/__void/scheduled") return null; + return Response.json({ pathname, env, ctx }); +} +`, + ); + const trigger = await wrapper.default.fetch( + new Request('http://localhost/__void/scheduled', { method: 'POST' }), + { DB: 'binding' }, + { id: 'ctx' }, + ); + expect(await trigger.json()).toEqual({ + pathname: '/__void/scheduled', + env: { DB: 'binding' }, + ctx: { id: 'ctx' }, + }); + const page = await wrapper.default.fetch(new Request('http://localhost/'), {}, {}); + expect(await page.text()).toBe('astro'); + + const config = await resolveConfig( + { root, configFile: false, logLevel: 'silent', plugins: [voidPlugin()] }, + 'build', + 'production', + ); + const plugin = config.plugins.find( + (candidate) => candidate.name === 'void:astro-server-entry', + )!; + const resolveId = getHookHandler(plugin.resolveId) as ( + this: { environment: { name: string } }, + source: string, + importer: string | undefined, + ) => unknown; + const resolved = await resolveId.call({ environment: { name: 'ssr' } }, entry, undefined); + // A D1 build keeps the adapter's own entry; a PostgreSQL build keeps only the request scope. + expect(resolved).toBe(database ? scopedEntry : undefined); + if (database) { + const load = getHookHandler(plugin.load) as (id: string) => unknown; + const buildCode = loadedCode(await load(scopedEntry)); + expect(buildCode).toContain('withRuntimeEnv'); + expect(buildCode).not.toContain('dev-triggers'); + expect(buildCode).not.toContain('handleDevTrigger'); + } + }, + ); });