diff --git a/packages/void/src/plugin-framework-entry.ts b/packages/void/src/plugin-framework-entry.ts index 7debf81e4..bb8f9541b 100644 --- a/packages/void/src/plugin-framework-entry.ts +++ b/packages/void/src/plugin-framework-entry.ts @@ -94,7 +94,7 @@ export function getOriginalFrameworkEntry( case 'vinext-app': return 'vinext/server/app-router-entry'; case 'vinext-pages': - return generateVinextPagesEntry(root); + return 'vinext/server/pages-router-entry'; } } @@ -173,36 +173,6 @@ ${isBuild ? `${compileAssetFallbackHelper(assetsDir, false, notFound)}\n` : ''}` return wrapperPath; } -/** Generate a worker entry for vinext Pages Router projects. */ -function generateVinextPagesEntry(root: string): string { - const outDir = join(root, '.void'); - mkdirSync(outDir, { recursive: true }); - const entryPath = join(outDir, 'vinext-pages-entry.ts'); - writeFileIfChanged( - entryPath, - `// Auto-generated by Void — vinext Pages Router worker entry -// @ts-expect-error virtual module resolved by vinext at build time -import { renderPage, handleApiRoute } from "virtual:vinext-server-entry"; - -export default { - async fetch(request: Request): Promise { - const url = new URL(request.url); - const pathname = url.pathname; - if (pathname.replaceAll("\\\\", "/").startsWith("//")) { - return new Response("404 Not Found", { status: 404 }); - } - const urlWithQuery = pathname + url.search; - if (pathname.startsWith("/api/") || pathname === "/api") { - return await handleApiRoute(request, urlWithQuery); - } - return await renderPage(request, urlWithQuery, null); - }, -}; -`, - ); - return entryPath; -} - /** Generate a worker entry for React Router v7 projects. */ function generateReactRouterEntry(root: string): string { const outDir = join(root, '.void'); diff --git a/packages/void/test/integration/vinext-pages.test.ts b/packages/void/test/integration/vinext-pages.test.ts index 9bf555600..a854e68ad 100644 --- a/packages/void/test/integration/vinext-pages.test.ts +++ b/packages/void/test/integration/vinext-pages.test.ts @@ -1,12 +1,50 @@ +import { readFileSync, writeFileSync } from 'node:fs'; import { join } from 'node:path'; import spawn from 'cross-spawn'; -import { afterAll, beforeAll, describe, expect, it } from 'vite-plus/test'; -import { killChild, startServer } from '../../../../test/integration-helpers.ts'; +import { Miniflare, convertV4MiniflareOptions } from 'miniflare'; +import { afterAll, afterEach, beforeAll, describe, expect, it } from 'vite-plus/test'; +import { + createIsolatedPlayground, + killChild, + startServer, + type IsolatedPlayground, +} from '../../../../test/integration-helpers.ts'; +import { collectWorkerFiles } from '../../src/cli/package.ts'; const ROOT = join(import.meta.dirname, '..', '..', '..', '..'); const PLAYGROUND = 'playground/vinext-pages'; let DEV_BASE: string; +/** The response surface shared by `fetch` and Miniflare's `dispatchFetch`. */ +type Fetch = (path: string) => Promise<{ + status: number; + headers: { get(name: string): string | null }; + text(): Promise; +}>; + +/** + * The playground's `middleware.ts` and `next.config.mjs` each add a redirect, a + * rewrite and a response header. They run only when the worker goes through vinext's own + * Pages Router request pipeline. + */ +function expectNextRequestPipeline(fetchPath: Fetch) { + return Promise.all([ + ...['/', '/middleware-rewrite', '/config-rewrite'].map(async (path) => { + const response = await fetchPath(path); + const html = await response.text(); + expect(response.status, `${path}: ${html.slice(0, 500)}`).toBe(200); + expect(html, path).toContain('vinext Pages Router + Void'); + expect(response.headers.get('x-void-middleware'), path).toBe('1'); + expect(response.headers.get('x-void-config'), path).toBe('1'); + }), + ...['/middleware-redirect', '/config-redirect'].map(async (path) => { + const response = await fetchPath(path); + expect(response.status, path).toBe(307); + expect(new URL(response.headers.get('location')!, 'http://localhost').pathname).toBe('/'); + }), + ]); +} + describe('playground/vinext-pages dev', () => { let child: ReturnType; @@ -59,4 +97,86 @@ describe('playground/vinext-pages dev', () => { expect(html).toContain('Alice'); expect(html).toContain('ok'); }); + + it('runs middleware and next.config redirects, rewrites and headers', async () => { + await expectNextRequestPipeline((path) => fetch(`${DEV_BASE}${path}`, { redirect: 'manual' })); + }); +}); + +describe('playground/vinext-pages build', () => { + const playgrounds: Array = []; + let runtime: Miniflare | undefined; + + afterEach(async () => { + await runtime?.dispose(); + runtime = undefined; + }); + + afterAll(() => { + for (const playground of playgrounds.splice(0)) { + playground.cleanup(); + } + }); + + it.each([['voidPlugin(), vinext()'], ['vinext(), voidPlugin()']])( + 'builds a worker that runs the Next.js request pipeline with plugins [%s]', + async (order) => { + const playground = createIsolatedPlayground(ROOT, PLAYGROUND); + playgrounds.push(playground); + writeFileSync( + join(playground.root, 'vite.config.ts'), + `import vinext from 'vinext'; +import { defineConfig } from 'vite-plus'; +import { voidPlugin } from 'void'; + +export default defineConfig({ + plugins: [${order}], +}); +`, + ); + const result = await new Promise<{ status: number | null; output: string }>( + (resolve, reject) => { + const child = spawn('vp', ['build'], { + cwd: playground.root, + env: { ...process.env, VOID_INSPECTOR_PORT: 'false' }, + stdio: ['ignore', 'pipe', 'pipe'], + }); + let output = ''; + child.stdout!.on('data', (chunk: Buffer) => (output += String(chunk))); + child.stderr!.on('data', (chunk: Buffer) => (output += String(chunk))); + child.on('error', reject); + child.on('close', (status) => resolve({ status, output })); + }, + ); + expect(result.status, result.output).toBe(0); + + const serverDir = join(playground.root, 'dist', 'ssr'); + const config = JSON.parse(readFileSync(join(serverDir, 'wrangler.json'), 'utf8')); + const files = collectWorkerFiles(serverDir); + const modulePaths = [ + config.main, + ...Array.from(files.keys()).filter((path) => path !== config.main), + ]; + runtime = new Miniflare( + convertV4MiniflareOptions({ + compatibilityDate: config.compatibility_date, + compatibilityFlags: config.compatibility_flags, + modules: modulePaths + .filter((path) => /\.(?:js|mjs)$/.test(path)) + .map((path) => ({ + type: 'ESModule', + path: join(serverDir, path), + contents: files.get(path)!, + })), + modulesRoot: serverDir, + d1Databases: ['DB'], + kvNamespaces: ['KV'], + }), + ); + await expectNextRequestPipeline((path) => + runtime!.dispatchFetch(`http://localhost${path}`, { redirect: 'manual' }), + ); + }, + 180_000, + ); }); diff --git a/packages/void/test/unit/plugin-class-a-wrapper.test.ts b/packages/void/test/unit/plugin-class-a-wrapper.test.ts index 444697473..1815e0ebd 100644 --- a/packages/void/test/unit/plugin-class-a-wrapper.test.ts +++ b/packages/void/test/unit/plugin-class-a-wrapper.test.ts @@ -121,6 +121,14 @@ describe('Class A framework custom entry', () => { }, ); + it.each([ + ['vinext-app', 'vinext/server/app-router-entry'], + // vinext's own entry runs middleware and next.config redirects, rewrites and headers. + ['vinext-pages', 'vinext/server/pages-router-entry'], + ] as const)('uses vinext’s own worker entry for %s', (framework, entry) => { + expect(getOriginalFrameworkEntry(framework, createTanStackRoot())).toBe(entry); + }); + it('keeps the default TanStack server entry when no custom entry is configured', () => { expect(getOriginalFrameworkEntry('tanstack-start', createTanStackRoot())).toBe( '@tanstack/react-start/server-entry', diff --git a/playground/vinext-pages/middleware.ts b/playground/vinext-pages/middleware.ts new file mode 100644 index 000000000..919f177c2 --- /dev/null +++ b/playground/vinext-pages/middleware.ts @@ -0,0 +1,15 @@ +import { NextResponse, type NextRequest } from 'next/server'; + +export function middleware(request: NextRequest) { + if (request.nextUrl.pathname === '/middleware-redirect') { + return NextResponse.redirect(new URL('/', request.url)); + } + const response = + request.nextUrl.pathname === '/middleware-rewrite' + ? NextResponse.rewrite(new URL('/', request.url)) + : NextResponse.next(); + response.headers.set('x-void-middleware', '1'); + return response; +} + +export const config = { matcher: ['/((?!_next).*)'] }; diff --git a/playground/vinext-pages/next.config.mjs b/playground/vinext-pages/next.config.mjs new file mode 100644 index 000000000..47e215542 --- /dev/null +++ b/playground/vinext-pages/next.config.mjs @@ -0,0 +1,11 @@ +export default { + async redirects() { + return [{ source: '/config-redirect', destination: '/', permanent: false }]; + }, + async rewrites() { + return [{ source: '/config-rewrite', destination: '/' }]; + }, + async headers() { + return [{ source: '/:path*', headers: [{ key: 'x-void-config', value: '1' }] }]; + }, +};