From 34b657dae4beca4c18395a5d3309f195ca869285 Mon Sep 17 00:00:00 2001 From: Stephen Zhou <38493346+hyoban@users.noreply.github.com> Date: Fri, 3 Apr 2026 16:54:50 +0800 Subject: [PATCH 1/4] fix: serve public files in prod --- packages/vinext/src/entries/app-rsc-entry.ts | 34 ++++++++++ packages/vinext/src/index.ts | 37 ++++++++++ packages/vinext/src/server/prod-server.ts | 39 +++++++++++ tests/app-router.test.ts | 67 +++++++++++++++++++ tests/fixtures/app-basic/public/logo/logo.svg | 6 ++ 5 files changed, 183 insertions(+) create mode 100644 tests/fixtures/app-basic/public/logo/logo.svg diff --git a/packages/vinext/src/entries/app-rsc-entry.ts b/packages/vinext/src/entries/app-rsc-entry.ts index 23e1c3ec..4db03b75 100644 --- a/packages/vinext/src/entries/app-rsc-entry.ts +++ b/packages/vinext/src/entries/app-rsc-entry.ts @@ -93,6 +93,8 @@ export type AppRouterConfig = { * `virtual:vinext-server-entry` when this flag is set. */ hasPagesDir?: boolean; + /** Exact public/ file routes, using normalized leading-slash pathnames. */ + publicFiles?: string[]; }; /** @@ -122,6 +124,7 @@ export function generateRscEntry( const bodySizeLimit = config?.bodySizeLimit ?? 1 * 1024 * 1024; const i18nConfig = config?.i18n ?? null; const hasPagesDir = config?.hasPagesDir ?? false; + const publicFiles = config?.publicFiles ?? []; // Build import map for all page and layout files const imports: string[] = []; const importMap: Map = new Map(); @@ -841,6 +844,21 @@ function matchRoute(url) { return _trieMatch(_routeTrie, urlParts); } +function __createStaticFileSignal(pathname, _mwCtx) { + const headers = new Headers({ + "x-vinext-static-file": encodeURIComponent(pathname), + }); + if (_mwCtx.headers) { + for (const [key, value] of _mwCtx.headers) { + headers.append(key, value); + } + } + return new Response(null, { + status: _mwCtx.status ?? 200, + headers, + }); +} + // matchPattern is kept for findIntercept (linear scan over small interceptLookup array). function matchPattern(urlParts, patternParts) { const params = Object.create(null); @@ -1210,6 +1228,7 @@ const __i18nConfig = ${JSON.stringify(i18nConfig)}; const __configRedirects = ${JSON.stringify(redirects)}; const __configRewrites = ${JSON.stringify(rewrites)}; const __configHeaders = ${JSON.stringify(headers)}; +const __publicFiles = new Set(${JSON.stringify(publicFiles)}); const __allowedOrigins = ${JSON.stringify(allowedOrigins)}; ${generateDevOriginCheckCode(config?.allowedDevOrigins)} @@ -1420,6 +1439,9 @@ async function _handleRequest(request, __reqCtx, _mwCtx) { ${ bp ? ` + if (!hasBasePath(pathname, __basePath) && !pathname.startsWith("/__vinext/")) { + return new Response("Not Found", { status: 404 }); + } // Strip basePath prefix pathname = stripBasePath(pathname, __basePath); ` @@ -1777,6 +1799,18 @@ async function _handleRequest(request, __reqCtx, _mwCtx) { } } + // Serve public/ files as filesystem routes after middleware and before + // afterFiles/fallback rewrites, matching Next.js routing semantics. + if ( + (request.method === "GET" || request.method === "HEAD") && + !pathname.endsWith(".rsc") && + __publicFiles.has(cleanPathname) + ) { + setHeadersContext(null); + setNavigationContext(null); + return __createStaticFileSignal(cleanPathname, _mwCtx); + } + // Set navigation context for Server Components. // Note: Headers context is already set by runWithRequestContext in the handler wrapper. setNavigationContext({ diff --git a/packages/vinext/src/index.ts b/packages/vinext/src/index.ts index 7495f663..80b4aedb 100644 --- a/packages/vinext/src/index.ts +++ b/packages/vinext/src/index.ts @@ -1986,6 +1986,7 @@ export default function vinext(options: VinextOptions = {}): PluginOption[] { bodySizeLimit: nextConfig?.serverActionsBodySizeLimit, i18n: nextConfig?.i18n, hasPagesDir, + publicFiles: scanPublicFileRoutes(root), }, instrumentationPath, ); @@ -4070,6 +4071,7 @@ function findFileWithExts( /** Module-level cache for hasMdxFiles — avoids re-scanning per Vite environment. */ const _mdxScanCache = new Map(); +const _publicFileRoutesCache = new Map(); /** * Check if the project has .mdx files in app/ or pages/ directories. @@ -4106,6 +4108,40 @@ function scanDirForMdx(dir: string): boolean { return false; } +function scanPublicFileRoutes(root: string): string[] { + const publicDir = path.join(root, "public"); + if (_publicFileRoutesCache.has(publicDir)) return _publicFileRoutesCache.get(publicDir)!; + + const routes: string[] = []; + + function walk(dir: string): void { + const entries = fs.readdirSync(dir, { withFileTypes: true }); + for (const entry of entries) { + if (entry.name === "node_modules") continue; + const fullPath = path.join(dir, entry.name); + if (entry.isDirectory()) { + walk(fullPath); + continue; + } + if (!entry.isFile()) continue; + const relativePath = path.relative(publicDir, fullPath).split(path.sep).join("/"); + routes.push("/" + relativePath); + } + } + + if (fs.existsSync(publicDir)) { + try { + walk(publicDir); + } catch { + // ignore unreadable dirs + } + } + + routes.sort(); + _publicFileRoutesCache.set(publicDir, routes); + return routes; +} + // Public exports for static export export { staticExportPages, staticExportApp } from "./build/static-export.js"; export type { @@ -4132,6 +4168,7 @@ export { resolvePostcssStringPlugins as _resolvePostcssStringPlugins }; export { _postcssCache }; export { hasMdxFiles as _hasMdxFiles }; export { _mdxScanCache }; +export { scanPublicFileRoutes as _scanPublicFileRoutes, _publicFileRoutesCache }; export { parseStaticObjectLiteral as _parseStaticObjectLiteral }; export { _findBalancedObject, _findCallEnd }; export { stripServerExports as _stripServerExports }; diff --git a/packages/vinext/src/server/prod-server.ts b/packages/vinext/src/server/prod-server.ts index 523910cc..4c3065ae 100644 --- a/packages/vinext/src/server/prod-server.ts +++ b/packages/vinext/src/server/prod-server.ts @@ -1044,6 +1044,45 @@ async function startAppRouterServer(options: AppRouterServerOptions) { const request = nodeToWebRequest(req, normalizedUrl); const response = await rscHandler(request); + const staticFileSignal = response.headers.get("x-vinext-static-file"); + if (staticFileSignal) { + let staticFilePath = "/"; + try { + staticFilePath = decodeURIComponent(staticFileSignal); + } catch { + staticFilePath = staticFileSignal; + } + + const staticResponseHeaders = omitHeadersCaseInsensitive( + mergeResponseHeaders({}, response), + ["x-vinext-static-file", "content-encoding", "content-length", "content-type"], + ); + + const served = await tryServeStatic( + req, + res, + clientDir, + staticFilePath, + compress, + staticCache, + staticResponseHeaders, + ); + cancelResponseBody(response); + if (served) { + return; + } + await sendWebResponse( + new Response("Not Found", { + status: 404, + headers: toWebHeaders(staticResponseHeaders), + }), + req, + res, + compress, + ); + return; + } + // Stream the Web Response back to the Node.js response await sendWebResponse(response, req, res, compress); } catch (e) { diff --git a/tests/app-router.test.ts b/tests/app-router.test.ts index 29871a2a..f699781d 100644 --- a/tests/app-router.test.ts +++ b/tests/app-router.test.ts @@ -1612,6 +1612,73 @@ describe("App Router Production server (startProdServer)", () => { expect(res.headers.get("cache-control")).toContain("immutable"); }); + it("serves public files from the build output", async () => { + // Ported from Next.js: test/production/export/index.test.ts + // https://github.com/vercel/next.js/blob/canary/test/production/export/index.test.ts + const res = await fetch(`${baseUrl}/logo/logo.svg`); + expect(res.status).toBe(200); + expect(res.headers.get("content-type")).toContain("image/svg+xml"); + expect(await res.text()).toContain("vinext"); + }); + + it("serves public files under basePath and 404s without it", async () => { + // Ported from Next.js: test/e2e/basepath/basepath.test.ts + // https://github.com/vercel/next.js/blob/canary/test/e2e/basepath/basepath.test.ts + const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "vinext-app-public-basepath-")); + const fixtureRoot = path.join(tmpDir, "fixture"); + let basePathServer: import("node:http").Server | undefined; + + try { + fs.cpSync(APP_FIXTURE_DIR, fixtureRoot, { recursive: true }); + const fixtureNodeModules = path.join(fixtureRoot, "node_modules"); + if (!fs.existsSync(fixtureNodeModules)) { + fs.symlinkSync( + path.resolve(__dirname, "..", "node_modules"), + fixtureNodeModules, + "junction", + ); + } + + const nextConfigPath = path.join(fixtureRoot, "next.config.ts"); + const nextConfig = fs.readFileSync(nextConfigPath, "utf-8"); + fs.writeFileSync( + nextConfigPath, + nextConfig.replace( + "const nextConfig: NextConfig = {", + 'const nextConfig: NextConfig = {\n basePath: "/app",', + ), + ); + + const builder = await createBuilder({ + root: fixtureRoot, + configFile: false, + plugins: [vinext({ appDir: fixtureRoot })], + logLevel: "silent", + }); + await builder.buildApp(); + + const { startProdServer } = await import("../packages/vinext/src/server/prod-server.js"); + ({ server: basePathServer } = await startProdServer({ + port: 0, + outDir: path.join(fixtureRoot, "dist"), + noCompression: true, + })); + const addr = basePathServer.address(); + const port = typeof addr === "object" && addr ? addr.port : 0; + const tmpBaseUrl = `http://localhost:${port}`; + + const withBasePathRes = await fetch(`${tmpBaseUrl}/app/logo/logo.svg`); + expect(withBasePathRes.status).toBe(200); + expect(withBasePathRes.headers.get("content-type")).toContain("image/svg+xml"); + + const withoutBasePathRes = await fetch(`${tmpBaseUrl}/logo/logo.svg`); + expect(withoutBasePathRes.status).toBe(404); + } finally { + basePathServer?.close(); + fs.rmSync(tmpDir, { recursive: true, force: true }); + } + }); + it("supports gzip compression for HTML", async () => { const res = await fetch(`${baseUrl}/`, { headers: { "Accept-Encoding": "gzip" }, diff --git a/tests/fixtures/app-basic/public/logo/logo.svg b/tests/fixtures/app-basic/public/logo/logo.svg new file mode 100644 index 00000000..e7bf41c1 --- /dev/null +++ b/tests/fixtures/app-basic/public/logo/logo.svg @@ -0,0 +1,6 @@ + + + + vinext + + From 5a3b769c347297af22432bbd6353a6f82219b3ea Mon Sep 17 00:00:00 2001 From: Stephen Zhou <38493346+hyoban@users.noreply.github.com> Date: Fri, 3 Apr 2026 17:11:32 +0800 Subject: [PATCH 2/4] fix test --- .../entry-templates.test.ts.snap | 171 ++++++++++++++++++ 1 file changed, 171 insertions(+) diff --git a/tests/__snapshots__/entry-templates.test.ts.snap b/tests/__snapshots__/entry-templates.test.ts.snap index 7c8a503e..387f28b7 100644 --- a/tests/__snapshots__/entry-templates.test.ts.snap +++ b/tests/__snapshots__/entry-templates.test.ts.snap @@ -608,6 +608,21 @@ function matchRoute(url) { return _trieMatch(_routeTrie, urlParts); } +function __createStaticFileSignal(pathname, _mwCtx) { + const headers = new Headers({ + "x-vinext-static-file": encodeURIComponent(pathname), + }); + if (_mwCtx.headers) { + for (const [key, value] of _mwCtx.headers) { + headers.append(key, value); + } + } + return new Response(null, { + status: _mwCtx.status ?? 200, + headers, + }); +} + // matchPattern is kept for findIntercept (linear scan over small interceptLookup array). function matchPattern(urlParts, patternParts) { const params = Object.create(null); @@ -965,6 +980,7 @@ const __i18nConfig = null; const __configRedirects = []; const __configRewrites = {"beforeFiles":[],"afterFiles":[],"fallback":[]}; const __configHeaders = []; +const __publicFiles = new Set([]); const __allowedOrigins = []; @@ -1503,6 +1519,18 @@ async function _handleRequest(request, __reqCtx, _mwCtx) { } } + // Serve public/ files as filesystem routes after middleware and before + // afterFiles/fallback rewrites, matching Next.js routing semantics. + if ( + (request.method === "GET" || request.method === "HEAD") && + !pathname.endsWith(".rsc") && + __publicFiles.has(cleanPathname) + ) { + setHeadersContext(null); + setNavigationContext(null); + return __createStaticFileSignal(cleanPathname, _mwCtx); + } + // Set navigation context for Server Components. // Note: Headers context is already set by runWithRequestContext in the handler wrapper. setNavigationContext({ @@ -2802,6 +2830,21 @@ function matchRoute(url) { return _trieMatch(_routeTrie, urlParts); } +function __createStaticFileSignal(pathname, _mwCtx) { + const headers = new Headers({ + "x-vinext-static-file": encodeURIComponent(pathname), + }); + if (_mwCtx.headers) { + for (const [key, value] of _mwCtx.headers) { + headers.append(key, value); + } + } + return new Response(null, { + status: _mwCtx.status ?? 200, + headers, + }); +} + // matchPattern is kept for findIntercept (linear scan over small interceptLookup array). function matchPattern(urlParts, patternParts) { const params = Object.create(null); @@ -3159,6 +3202,7 @@ const __i18nConfig = null; const __configRedirects = [{"source":"/old","destination":"/new","permanent":true}]; const __configRewrites = {"beforeFiles":[{"source":"/api/:path*","destination":"/backend/:path*"}],"afterFiles":[],"fallback":[]}; const __configHeaders = [{"source":"/api/:path*","headers":[{"key":"X-Custom","value":"test"}]}]; +const __publicFiles = new Set([]); const __allowedOrigins = ["https://example.com"]; @@ -3525,6 +3569,9 @@ async function _handleRequest(request, __reqCtx, _mwCtx) { let pathname = __normalizePath(decodedUrlPathname); + if (!hasBasePath(pathname, __basePath) && !pathname.startsWith("/__vinext/")) { + return new Response("Not Found", { status: 404 }); + } // Strip basePath prefix pathname = stripBasePath(pathname, __basePath); @@ -3700,6 +3747,18 @@ async function _handleRequest(request, __reqCtx, _mwCtx) { } } + // Serve public/ files as filesystem routes after middleware and before + // afterFiles/fallback rewrites, matching Next.js routing semantics. + if ( + (request.method === "GET" || request.method === "HEAD") && + !pathname.endsWith(".rsc") && + __publicFiles.has(cleanPathname) + ) { + setHeadersContext(null); + setNavigationContext(null); + return __createStaticFileSignal(cleanPathname, _mwCtx); + } + // Set navigation context for Server Components. // Note: Headers context is already set by runWithRequestContext in the handler wrapper. setNavigationContext({ @@ -5000,6 +5059,21 @@ function matchRoute(url) { return _trieMatch(_routeTrie, urlParts); } +function __createStaticFileSignal(pathname, _mwCtx) { + const headers = new Headers({ + "x-vinext-static-file": encodeURIComponent(pathname), + }); + if (_mwCtx.headers) { + for (const [key, value] of _mwCtx.headers) { + headers.append(key, value); + } + } + return new Response(null, { + status: _mwCtx.status ?? 200, + headers, + }); +} + // matchPattern is kept for findIntercept (linear scan over small interceptLookup array). function matchPattern(urlParts, patternParts) { const params = Object.create(null); @@ -5365,6 +5439,7 @@ const __i18nConfig = null; const __configRedirects = []; const __configRewrites = {"beforeFiles":[],"afterFiles":[],"fallback":[]}; const __configHeaders = []; +const __publicFiles = new Set([]); const __allowedOrigins = []; @@ -5903,6 +5978,18 @@ async function _handleRequest(request, __reqCtx, _mwCtx) { } } + // Serve public/ files as filesystem routes after middleware and before + // afterFiles/fallback rewrites, matching Next.js routing semantics. + if ( + (request.method === "GET" || request.method === "HEAD") && + !pathname.endsWith(".rsc") && + __publicFiles.has(cleanPathname) + ) { + setHeadersContext(null); + setNavigationContext(null); + return __createStaticFileSignal(cleanPathname, _mwCtx); + } + // Set navigation context for Server Components. // Note: Headers context is already set by runWithRequestContext in the handler wrapper. setNavigationContext({ @@ -7232,6 +7319,21 @@ function matchRoute(url) { return _trieMatch(_routeTrie, urlParts); } +function __createStaticFileSignal(pathname, _mwCtx) { + const headers = new Headers({ + "x-vinext-static-file": encodeURIComponent(pathname), + }); + if (_mwCtx.headers) { + for (const [key, value] of _mwCtx.headers) { + headers.append(key, value); + } + } + return new Response(null, { + status: _mwCtx.status ?? 200, + headers, + }); +} + // matchPattern is kept for findIntercept (linear scan over small interceptLookup array). function matchPattern(urlParts, patternParts) { const params = Object.create(null); @@ -7589,6 +7691,7 @@ const __i18nConfig = null; const __configRedirects = []; const __configRewrites = {"beforeFiles":[],"afterFiles":[],"fallback":[]}; const __configHeaders = []; +const __publicFiles = new Set([]); const __allowedOrigins = []; @@ -8130,6 +8233,18 @@ async function _handleRequest(request, __reqCtx, _mwCtx) { } } + // Serve public/ files as filesystem routes after middleware and before + // afterFiles/fallback rewrites, matching Next.js routing semantics. + if ( + (request.method === "GET" || request.method === "HEAD") && + !pathname.endsWith(".rsc") && + __publicFiles.has(cleanPathname) + ) { + setHeadersContext(null); + setNavigationContext(null); + return __createStaticFileSignal(cleanPathname, _mwCtx); + } + // Set navigation context for Server Components. // Note: Headers context is already set by runWithRequestContext in the handler wrapper. setNavigationContext({ @@ -9436,6 +9551,21 @@ function matchRoute(url) { return _trieMatch(_routeTrie, urlParts); } +function __createStaticFileSignal(pathname, _mwCtx) { + const headers = new Headers({ + "x-vinext-static-file": encodeURIComponent(pathname), + }); + if (_mwCtx.headers) { + for (const [key, value] of _mwCtx.headers) { + headers.append(key, value); + } + } + return new Response(null, { + status: _mwCtx.status ?? 200, + headers, + }); +} + // matchPattern is kept for findIntercept (linear scan over small interceptLookup array). function matchPattern(urlParts, patternParts) { const params = Object.create(null); @@ -9793,6 +9923,7 @@ const __i18nConfig = null; const __configRedirects = []; const __configRewrites = {"beforeFiles":[],"afterFiles":[],"fallback":[]}; const __configHeaders = []; +const __publicFiles = new Set([]); const __allowedOrigins = []; @@ -10331,6 +10462,18 @@ async function _handleRequest(request, __reqCtx, _mwCtx) { } } + // Serve public/ files as filesystem routes after middleware and before + // afterFiles/fallback rewrites, matching Next.js routing semantics. + if ( + (request.method === "GET" || request.method === "HEAD") && + !pathname.endsWith(".rsc") && + __publicFiles.has(cleanPathname) + ) { + setHeadersContext(null); + setNavigationContext(null); + return __createStaticFileSignal(cleanPathname, _mwCtx); + } + // Set navigation context for Server Components. // Note: Headers context is already set by runWithRequestContext in the handler wrapper. setNavigationContext({ @@ -11630,6 +11773,21 @@ function matchRoute(url) { return _trieMatch(_routeTrie, urlParts); } +function __createStaticFileSignal(pathname, _mwCtx) { + const headers = new Headers({ + "x-vinext-static-file": encodeURIComponent(pathname), + }); + if (_mwCtx.headers) { + for (const [key, value] of _mwCtx.headers) { + headers.append(key, value); + } + } + return new Response(null, { + status: _mwCtx.status ?? 200, + headers, + }); +} + // matchPattern is kept for findIntercept (linear scan over small interceptLookup array). function matchPattern(urlParts, patternParts) { const params = Object.create(null); @@ -12216,6 +12374,7 @@ const __i18nConfig = null; const __configRedirects = []; const __configRewrites = {"beforeFiles":[],"afterFiles":[],"fallback":[]}; const __configHeaders = []; +const __publicFiles = new Set([]); const __allowedOrigins = []; @@ -12889,6 +13048,18 @@ async function _handleRequest(request, __reqCtx, _mwCtx) { } } + // Serve public/ files as filesystem routes after middleware and before + // afterFiles/fallback rewrites, matching Next.js routing semantics. + if ( + (request.method === "GET" || request.method === "HEAD") && + !pathname.endsWith(".rsc") && + __publicFiles.has(cleanPathname) + ) { + setHeadersContext(null); + setNavigationContext(null); + return __createStaticFileSignal(cleanPathname, _mwCtx); + } + // Set navigation context for Server Components. // Note: Headers context is already set by runWithRequestContext in the handler wrapper. setNavigationContext({ From ecc58f5156538a803426e6d4b71af07bfe7cbf0f Mon Sep 17 00:00:00 2001 From: Stephen Zhou <38493346+hyoban@users.noreply.github.com> Date: Fri, 3 Apr 2026 19:24:14 +0800 Subject: [PATCH 3/4] resolve comments --- packages/vinext/src/deploy.ts | 8 ++- packages/vinext/src/index.ts | 34 +++++++--- .../vinext/src/server/app-router-entry.ts | 20 +++++- packages/vinext/src/server/prod-server.ts | 30 ++++++--- packages/vinext/src/server/worker-utils.ts | 45 ++++++++++++++ tests/deploy.test.ts | 62 ++++++++++++++++++- tests/serve-static.test.ts | 27 ++++++++ 7 files changed, 205 insertions(+), 21 deletions(-) diff --git a/packages/vinext/src/deploy.ts b/packages/vinext/src/deploy.ts index 827e7e5e..aa041020 100644 --- a/packages/vinext/src/deploy.ts +++ b/packages/vinext/src/deploy.ts @@ -454,6 +454,7 @@ import { setCacheHandler } from "vinext/shims/cache"; import { handleImageOptimization, DEFAULT_DEVICE_SIZES, DEFAULT_IMAGE_SIZES } from "vinext/server/image-optimization"; import type { ImageConfig } from "vinext/server/image-optimization"; import handler from "vinext/server/app-router-entry"; +import { resolveStaticAssetSignal } from "vinext/server/worker-utils"; ${isrImports} interface Env { ASSETS: Fetcher;${isrEnvField} @@ -498,7 +499,12 @@ ${isrSetup} const url = new URL(request.url); // Delegate everything else to vinext, forwarding ctx so that // ctx.waitUntil() is available to background cache writes and // other deferred work via getRequestExecutionContext(). - return handler.fetch(request, env, ctx); + const response = await handler.fetch(request, env, ctx); + return ( + (await resolveStaticAssetSignal(response, { + fetchAsset: (path) => Promise.resolve(env.ASSETS.fetch(new Request(new URL(path, request.url)))), + })) ?? response + ); }, }; `; diff --git a/packages/vinext/src/index.ts b/packages/vinext/src/index.ts index 80b4aedb..6f163909 100644 --- a/packages/vinext/src/index.ts +++ b/packages/vinext/src/index.ts @@ -4071,8 +4071,6 @@ function findFileWithExts( /** Module-level cache for hasMdxFiles — avoids re-scanning per Vite environment. */ const _mdxScanCache = new Map(); -const _publicFileRoutesCache = new Map(); - /** * Check if the project has .mdx files in app/ or pages/ directories. */ @@ -4110,20 +4108,41 @@ function scanDirForMdx(dir: string): boolean { function scanPublicFileRoutes(root: string): string[] { const publicDir = path.join(root, "public"); - if (_publicFileRoutesCache.has(publicDir)) return _publicFileRoutesCache.get(publicDir)!; - const routes: string[] = []; + const visitedDirs = new Set(); function walk(dir: string): void { + let realDir: string; + try { + realDir = fs.realpathSync(dir); + } catch { + return; + } + if (visitedDirs.has(realDir)) return; + visitedDirs.add(realDir); + const entries = fs.readdirSync(dir, { withFileTypes: true }); for (const entry of entries) { - if (entry.name === "node_modules") continue; const fullPath = path.join(dir, entry.name); if (entry.isDirectory()) { walk(fullPath); continue; } - if (!entry.isFile()) continue; + if (entry.isSymbolicLink()) { + let stat: fs.Stats; + try { + stat = fs.statSync(fullPath); + } catch { + continue; + } + if (stat.isDirectory()) { + walk(fullPath); + continue; + } + if (!stat.isFile()) continue; + } else if (!entry.isFile()) { + continue; + } const relativePath = path.relative(publicDir, fullPath).split(path.sep).join("/"); routes.push("/" + relativePath); } @@ -4138,7 +4157,6 @@ function scanPublicFileRoutes(root: string): string[] { } routes.sort(); - _publicFileRoutesCache.set(publicDir, routes); return routes; } @@ -4168,7 +4186,7 @@ export { resolvePostcssStringPlugins as _resolvePostcssStringPlugins }; export { _postcssCache }; export { hasMdxFiles as _hasMdxFiles }; export { _mdxScanCache }; -export { scanPublicFileRoutes as _scanPublicFileRoutes, _publicFileRoutesCache }; +export { scanPublicFileRoutes as _scanPublicFileRoutes }; export { parseStaticObjectLiteral as _parseStaticObjectLiteral }; export { _findBalancedObject, _findCallEnd }; export { stripServerExports as _stripServerExports }; diff --git a/packages/vinext/src/server/app-router-entry.ts b/packages/vinext/src/server/app-router-entry.ts index adbbc488..3010a37f 100644 --- a/packages/vinext/src/server/app-router-entry.ts +++ b/packages/vinext/src/server/app-router-entry.ts @@ -15,9 +15,20 @@ // @ts-expect-error — virtual module resolved by vinext import rscHandler from "virtual:vinext-rsc-entry"; import { runWithExecutionContext, type ExecutionContextLike } from "../shims/request-context.js"; +import { resolveStaticAssetSignal } from "./worker-utils.js"; + +type WorkerAssetEnv = { + ASSETS?: { + fetch(request: Request): Promise | Response; + }; +}; export default { - async fetch(request: Request, _env?: unknown, ctx?: ExecutionContextLike): Promise { + async fetch( + request: Request, + env?: WorkerAssetEnv, + ctx?: ExecutionContextLike, + ): Promise { const url = new URL(request.url); // Normalize backslashes (browsers treat /\ as //) before any other checks. @@ -52,6 +63,13 @@ export default { const result = await (ctx ? runWithExecutionContext(ctx, handleFn) : handleFn()); if (result instanceof Response) { + if (env?.ASSETS) { + const assetResponse = await resolveStaticAssetSignal(result, { + fetchAsset: (path) => + Promise.resolve(env.ASSETS!.fetch(new Request(new URL(path, request.url)))), + }); + if (assetResponse) return assetResponse; + } return result; } diff --git a/packages/vinext/src/server/prod-server.ts b/packages/vinext/src/server/prod-server.ts index 4c3065ae..b6211b41 100644 --- a/packages/vinext/src/server/prod-server.ts +++ b/packages/vinext/src/server/prod-server.ts @@ -391,8 +391,11 @@ async function tryServeStatic( compress: boolean, cache?: StaticFileCache, extraHeaders?: Record, + statusCode?: number, ): Promise { if (pathname === "/") return false; + const responseStatus = statusCode ?? 200; + const omitBody = isNoBodyResponseStatus(responseStatus); // ── Fast path: pre-computed headers, minimal per-request work ── // When a cache is provided, all path validation happened at startup. @@ -419,7 +422,11 @@ async function tryServeStatic( // 304 Not Modified: string compare against pre-computed ETag const ifNoneMatch = req.headers["if-none-match"]; - if (typeof ifNoneMatch === "string" && matchesIfNoneMatchHeader(ifNoneMatch, entry.etag)) { + if ( + responseStatus === 200 && + typeof ifNoneMatch === "string" && + matchesIfNoneMatchHeader(ifNoneMatch, entry.etag) + ) { if (extraHeaders) { res.writeHead(304, { ...entry.notModifiedHeaders, ...extraHeaders }); } else { @@ -449,12 +456,12 @@ async function tryServeStatic( : entry.original; if (extraHeaders) { - res.writeHead(200, { ...variant.headers, ...extraHeaders }); + res.writeHead(responseStatus, { ...variant.headers, ...extraHeaders }); } else { - res.writeHead(200, variant.headers); + res.writeHead(responseStatus, variant.headers); } - if (req.method === "HEAD") { + if (omitBody || req.method === "HEAD") { res.end(); return true; } @@ -515,7 +522,11 @@ async function tryServeStatic( // compress=false also skips all compressed variants. // Spreading undefined is a no-op in object literals (ES2018+). const ifNoneMatch = req.headers["if-none-match"]; - if (typeof ifNoneMatch === "string" && matchesIfNoneMatchHeader(ifNoneMatch, etag)) { + if ( + responseStatus === 200 && + typeof ifNoneMatch === "string" && + matchesIfNoneMatchHeader(ifNoneMatch, etag) + ) { const notModifiedHeaders: Record = { ETag: etag, "Cache-Control": cacheControl, @@ -539,12 +550,12 @@ async function tryServeStatic( if (encoding) { // Content-Length omitted intentionally: compressed size isn't known // ahead of time, so Node.js uses chunked transfer encoding. - res.writeHead(200, { + res.writeHead(responseStatus, { ...baseHeaders, "Content-Encoding": encoding, Vary: "Accept-Encoding", }); - if (req.method === "HEAD") { + if (omitBody || req.method === "HEAD") { res.end(); return true; } @@ -561,11 +572,11 @@ async function tryServeStatic( } } - res.writeHead(200, { + res.writeHead(responseStatus, { ...baseHeaders, "Content-Length": String(resolved.size), }); - if (req.method === "HEAD") { + if (omitBody || req.method === "HEAD") { res.end(); return true; } @@ -1066,6 +1077,7 @@ async function startAppRouterServer(options: AppRouterServerOptions) { compress, staticCache, staticResponseHeaders, + response.status, ); cancelResponseBody(response); if (served) { diff --git a/packages/vinext/src/server/worker-utils.ts b/packages/vinext/src/server/worker-utils.ts index 05aa32e1..89727f13 100644 --- a/packages/vinext/src/server/worker-utils.ts +++ b/packages/vinext/src/server/worker-utils.ts @@ -35,6 +35,21 @@ function cancelResponseBody(response: Response): void { }); } +function buildHeaderRecord( + response: Response, + omitNames: readonly string[] = [], +): Record { + const omitted = new Set(omitNames.map((name) => name.toLowerCase())); + const headers: Record = {}; + response.headers.forEach((value, key) => { + if (omitted.has(key.toLowerCase()) || key === "set-cookie") return; + headers[key] = value; + }); + const cookies = response.headers.getSetCookie?.() ?? []; + if (cookies.length > 0) headers["set-cookie"] = cookies; + return headers; +} + export function mergeHeaders( response: Response, extraHeaders: Record, @@ -93,3 +108,33 @@ export function mergeHeaders( headers: merged, }); } + +export async function resolveStaticAssetSignal( + signalResponse: Response, + options: { + fetchAsset(path: string): Promise; + }, +): Promise { + const signal = signalResponse.headers.get("x-vinext-static-file"); + if (!signal) return null; + + let assetPath = "/"; + try { + assetPath = decodeURIComponent(signal); + } catch { + assetPath = signal; + } + + const extraHeaders = buildHeaderRecord(signalResponse, [ + "x-vinext-static-file", + "content-encoding", + "content-length", + "content-type", + ]); + + cancelResponseBody(signalResponse); + const assetResponse = await options.fetchAsset(assetPath); + const statusOverride = + assetResponse.ok && signalResponse.status !== 200 ? signalResponse.status : undefined; + return mergeHeaders(assetResponse, extraHeaders, statusOverride); +} diff --git a/tests/deploy.test.ts b/tests/deploy.test.ts index cd19afd2..836f15f8 100644 --- a/tests/deploy.test.ts +++ b/tests/deploy.test.ts @@ -27,8 +27,14 @@ import { ensureViteConfigCompatibility, } from "../packages/vinext/src/utils/project.js"; import { manifestFileWithBase } from "../packages/vinext/src/utils/manifest-paths.js"; -import { computeLazyChunks } from "../packages/vinext/src/index.js"; -import { mergeHeaders } from "../packages/vinext/src/server/worker-utils.js"; +import { + _scanPublicFileRoutes as scanPublicFileRoutes, + computeLazyChunks, +} from "../packages/vinext/src/index.js"; +import { + mergeHeaders, + resolveStaticAssetSignal, +} from "../packages/vinext/src/server/worker-utils.js"; import { domainCandidates, parseWranglerConfig } from "../packages/vinext/src/cloudflare/tpr.js"; // ─── Test Helpers ──────────────────────────────────────────────────────────── @@ -387,6 +393,15 @@ describe("generateAppRouterWorkerEntry", () => { expect(content).toContain("handler.fetch(request, env, ctx)"); }); + it("resolves static asset signals via the ASSETS binding", () => { + const content = generateAppRouterWorkerEntry(); + expect(content).toContain( + 'import { resolveStaticAssetSignal } from "vinext/server/worker-utils"', + ); + expect(content).toContain("await resolveStaticAssetSignal(response, {"); + expect(content).toContain("env.ASSETS.fetch(new Request(new URL(path, request.url)))"); + }); + it("includes auto-generated comment", () => { const content = generateAppRouterWorkerEntry(); expect(content).toContain("auto-generated by vinext deploy"); @@ -449,6 +464,19 @@ describe("generateAppRouterWorkerEntry", () => { }); }); +describe("scanPublicFileRoutes", () => { + it("rescans public files on each call instead of returning stale cached results", () => { + mkdir(tmpDir, "public"); + writeFile(tmpDir, "public/first.txt", "one"); + + expect(scanPublicFileRoutes(tmpDir)).toEqual(["/first.txt"]); + + writeFile(tmpDir, "public/nested/second.txt", "two"); + + expect(scanPublicFileRoutes(tmpDir)).toEqual(["/first.txt", "/nested/second.txt"]); + }); +}); + describe("generatePagesRouterWorkerEntry", () => { it("generates valid TypeScript", () => { const content = generatePagesRouterWorkerEntry(); @@ -784,6 +812,36 @@ describe("generatePagesRouterWorkerEntry", () => { expect(content).toContain("cancelResponseBody(response)"); }); + it("resolveStaticAssetSignal fetches and merges static asset responses with middleware status", async () => { + const signalResponse = new Response(null, { + status: 403, + headers: [ + ["x-vinext-static-file", encodeURIComponent("/logo/logo.svg")], + ["x-middleware", "blocked"], + ["content-type", "text/plain"], + ], + }); + + const resolved = await resolveStaticAssetSignal(signalResponse, { + fetchAsset: async (path) => + new Response("", { + status: 200, + headers: { + "content-type": "image/svg+xml", + "cache-control": "public, max-age=3600", + "x-asset-path": path, + }, + }), + }); + + expect(resolved).not.toBeNull(); + expect(resolved!.status).toBe(403); + expect(resolved!.headers.get("content-type")).toBe("image/svg+xml"); + expect(resolved!.headers.get("x-middleware")).toBe("blocked"); + expect(resolved!.headers.get("x-asset-path")).toBe("/logo/logo.svg"); + expect(await resolved!.text()).toBe(""); + }); + it("preserves x-middleware-request-* headers for prod request override handling", () => { const content = generatePagesRouterWorkerEntry(); // Worker entry must import applyMiddlewareRequestHeaders from config-matchers diff --git a/tests/serve-static.test.ts b/tests/serve-static.test.ts index 9922cc2d..ab6d6783 100644 --- a/tests/serve-static.test.ts +++ b/tests/serve-static.test.ts @@ -178,6 +178,33 @@ describe("tryServeStatic (with StaticFileCache)", () => { expect(captured.headers["Content-Length"]).toBe(String(jsContent.length)); }); + it("preserves a non-200 status override for cached static files", async () => { + const jsContent = "export const blocked = true;\n"; + await writeFile(clientDir, "assets/status-override-abc123.js", jsContent); + + const cache = await StaticFileCache.create(clientDir); + const req = mockReq(undefined, { "if-none-match": 'W/"abc123"' }); + const { res, captured } = mockRes(); + + const served = await tryServeStatic( + req, + res, + clientDir, + "/assets/status-override-abc123.js", + true, + cache, + { "x-middleware": "blocked" }, + 403, + ); + + await captured.ended; + expect(served).toBe(true); + expect(captured.status).toBe(403); + expect(captured.headers["x-middleware"]).toBe("blocked"); + expect(captured.headers["ETag"]).toBe('W/"abc123"'); + expect(captured.body.toString()).toBe(jsContent); + }); + // ── Cache miss / non-existent ────────────────────────────────── it("returns false for non-existent files", async () => { From 66d3db36f644b87ce37cfe3a9754e0c5100f870c Mon Sep 17 00:00:00 2001 From: Stephen Zhou <38493346+hyoban@users.noreply.github.com> Date: Fri, 3 Apr 2026 19:33:10 +0800 Subject: [PATCH 4/4] update --- packages/vinext/src/deploy.ts | 8 +------- packages/vinext/src/server/worker-utils.ts | 3 +++ tests/deploy.test.ts | 7 +++---- 3 files changed, 7 insertions(+), 11 deletions(-) diff --git a/packages/vinext/src/deploy.ts b/packages/vinext/src/deploy.ts index aa041020..827e7e5e 100644 --- a/packages/vinext/src/deploy.ts +++ b/packages/vinext/src/deploy.ts @@ -454,7 +454,6 @@ import { setCacheHandler } from "vinext/shims/cache"; import { handleImageOptimization, DEFAULT_DEVICE_SIZES, DEFAULT_IMAGE_SIZES } from "vinext/server/image-optimization"; import type { ImageConfig } from "vinext/server/image-optimization"; import handler from "vinext/server/app-router-entry"; -import { resolveStaticAssetSignal } from "vinext/server/worker-utils"; ${isrImports} interface Env { ASSETS: Fetcher;${isrEnvField} @@ -499,12 +498,7 @@ ${isrSetup} const url = new URL(request.url); // Delegate everything else to vinext, forwarding ctx so that // ctx.waitUntil() is available to background cache writes and // other deferred work via getRequestExecutionContext(). - const response = await handler.fetch(request, env, ctx); - return ( - (await resolveStaticAssetSignal(response, { - fetchAsset: (path) => Promise.resolve(env.ASSETS.fetch(new Request(new URL(path, request.url)))), - })) ?? response - ); + return handler.fetch(request, env, ctx); }, }; `; diff --git a/packages/vinext/src/server/worker-utils.ts b/packages/vinext/src/server/worker-utils.ts index 89727f13..6518ae63 100644 --- a/packages/vinext/src/server/worker-utils.ts +++ b/packages/vinext/src/server/worker-utils.ts @@ -134,6 +134,9 @@ export async function resolveStaticAssetSignal( cancelResponseBody(signalResponse); const assetResponse = await options.fetchAsset(assetPath); + // Only preserve the middleware/status-layer override when we actually got a + // real asset response back. If the asset lookup misses (404/other non-ok), + // keep that filesystem result instead of masking it with the signal status. const statusOverride = assetResponse.ok && signalResponse.status !== 200 ? signalResponse.status : undefined; return mergeHeaders(assetResponse, extraHeaders, statusOverride); diff --git a/tests/deploy.test.ts b/tests/deploy.test.ts index 836f15f8..b9942275 100644 --- a/tests/deploy.test.ts +++ b/tests/deploy.test.ts @@ -393,13 +393,12 @@ describe("generateAppRouterWorkerEntry", () => { expect(content).toContain("handler.fetch(request, env, ctx)"); }); - it("resolves static asset signals via the ASSETS binding", () => { + it("does not duplicate static asset signal resolution in the generated worker", () => { const content = generateAppRouterWorkerEntry(); - expect(content).toContain( + expect(content).not.toContain( 'import { resolveStaticAssetSignal } from "vinext/server/worker-utils"', ); - expect(content).toContain("await resolveStaticAssetSignal(response, {"); - expect(content).toContain("env.ASSETS.fetch(new Request(new URL(path, request.url)))"); + expect(content).not.toContain("resolveStaticAssetSignal(response"); }); it("includes auto-generated comment", () => {