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
63 changes: 48 additions & 15 deletions packages/void/src/plugin-framework-triggers.ts
Original file line number Diff line number Diff line change
@@ -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';
Expand Down Expand Up @@ -51,9 +52,9 @@ export function writeFrameworkTriggersDispatch(root: string, devTriggerToken: st
/**
* Writes a real file at `<root>/.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 {
Expand Down Expand Up @@ -571,14 +572,43 @@ function astroMiddlewareClaims(): Map<string, AstroMiddlewareClaim> {
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
Expand All @@ -587,25 +617,28 @@ function astroMiddlewareClaims(): Map<string, AstroMiddlewareClaim> {
*/
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) {
Expand Down
55 changes: 42 additions & 13 deletions packages/void/src/plugin-framework.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ import {
astroDevTriggerInjectPlugin,
frameworkTriggersDispatchPath,
frameworkTriggersPlugin,
hasOwnAstroMiddleware,
nitroDevTriggerInjectPlugin,
} from './plugin-framework-triggers.ts';
import { migrationPlugin } from './plugin-migration.ts';
Expand Down Expand Up @@ -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;
Expand All @@ -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()'};
},
};
`;
Expand Down Expand Up @@ -335,7 +364,7 @@ export default {
wranglerCompatPlugin,
wranglerSyncPlugin,
...(nuxtVite8WebSocketCompatPlugin ? [nuxtVite8WebSocketCompatPlugin] : []),
...(astroRequestScopePlugin ? [astroRequestScopePlugin] : []),
...(astroServerEntryPlugin ? [astroServerEntryPlugin] : []),
svelteKitHooksInjectPlugin,
...(svelteKitWorkerBuildPlugin ? [svelteKitWorkerBuildPlugin] : []),
clientStubsPlugin,
Expand Down
23 changes: 21 additions & 2 deletions packages/void/test/integration/astro.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -142,6 +154,10 @@ describe('playground/astro with PostgreSQL', () => {
let child: ReturnType<typeof spawn> | 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<StartedServer> {
const root = playground.root;
Expand Down Expand Up @@ -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';
Expand All @@ -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');
});
Expand Down Expand Up @@ -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(
Expand Down
62 changes: 49 additions & 13 deletions packages/void/test/unit/plugin-framework-triggers.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ import {
astroDevTriggerInjectPlugin,
frameworkTriggersDispatchPath,
frameworkTriggersPlugin,
hasOwnAstroMiddleware,
nitroDevTriggerInjectPlugin,
} from '../../src/plugin-framework-triggers.ts';
import type { QueueBatch } from '../../src/runtime/handler.ts';
Expand Down Expand Up @@ -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', () => {
Expand Down
Loading
Loading