From f0977c31444d5d6d68078518db50861b2ef4a03a Mon Sep 17 00:00:00 2001 From: Yusuke Tanaka Date: Fri, 6 Nov 2026 17:35:55 +0900 Subject: [PATCH] fix(vinext): run middleware and next.config rules in Pages Router apps Void's generated Pages Router worker entry called vinext's renderPage and handleApiRoute directly, which skipped middleware and next.config redirects, rewrites and headers. Auth checks in middleware were bypassed. Use vinext's own Pages Router entry (vinext/server/pages-router-entry), as the App Router path already does. Void's cron and queue wrapper is unchanged. Add middleware and next.config fixtures to playground/vinext-pages, with dev and Miniflare build tests (both plugin orders) for redirects, rewrites and headers. --- packages/void/src/plugin-framework-entry.ts | 32 +---- .../test/integration/vinext-pages.test.ts | 124 +++++++++++++++++- .../test/unit/plugin-class-a-wrapper.test.ts | 8 ++ playground/vinext-pages/middleware.ts | 15 +++ playground/vinext-pages/next.config.mjs | 11 ++ 5 files changed, 157 insertions(+), 33 deletions(-) create mode 100644 playground/vinext-pages/middleware.ts create mode 100644 playground/vinext-pages/next.config.mjs 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' }] }]; + }, +};