diff --git a/packages/vinext/src/entries/app-rsc-entry.ts b/packages/vinext/src/entries/app-rsc-entry.ts index 29499db1..1fb7601c 100644 --- a/packages/vinext/src/entries/app-rsc-entry.ts +++ b/packages/vinext/src/entries/app-rsc-entry.ts @@ -94,6 +94,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[]; }; /** @@ -123,6 +125,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(); @@ -845,6 +848,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); @@ -1214,6 +1232,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)} @@ -1424,6 +1443,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); ` @@ -1781,6 +1803,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..6f163909 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,7 +4071,6 @@ function findFileWithExts( /** Module-level cache for hasMdxFiles — avoids re-scanning per Vite environment. */ const _mdxScanCache = new Map(); - /** * Check if the project has .mdx files in app/ or pages/ directories. */ @@ -4106,6 +4106,60 @@ function scanDirForMdx(dir: string): boolean { return false; } +function scanPublicFileRoutes(root: string): string[] { + const publicDir = path.join(root, "public"); + 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) { + const fullPath = path.join(dir, entry.name); + if (entry.isDirectory()) { + walk(fullPath); + 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); + } + } + + if (fs.existsSync(publicDir)) { + try { + walk(publicDir); + } catch { + // ignore unreadable dirs + } + } + + routes.sort(); + return routes; +} + // Public exports for static export export { staticExportPages, staticExportApp } from "./build/static-export.js"; export type { @@ -4132,6 +4186,7 @@ export { resolvePostcssStringPlugins as _resolvePostcssStringPlugins }; export { _postcssCache }; export { hasMdxFiles as _hasMdxFiles }; export { _mdxScanCache }; +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 523910cc..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; } @@ -1044,6 +1055,46 @@ 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, + response.status, + ); + 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/packages/vinext/src/server/worker-utils.ts b/packages/vinext/src/server/worker-utils.ts index 05aa32e1..6518ae63 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,36 @@ 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); + // 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/__snapshots__/entry-templates.test.ts.snap b/tests/__snapshots__/entry-templates.test.ts.snap index 66802fa0..e6c91a96 100644 --- a/tests/__snapshots__/entry-templates.test.ts.snap +++ b/tests/__snapshots__/entry-templates.test.ts.snap @@ -611,6 +611,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); @@ -968,6 +983,7 @@ const __i18nConfig = null; const __configRedirects = []; const __configRewrites = {"beforeFiles":[],"afterFiles":[],"fallback":[]}; const __configHeaders = []; +const __publicFiles = new Set([]); const __allowedOrigins = []; @@ -1506,6 +1522,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({ @@ -2828,6 +2856,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); @@ -3185,6 +3228,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"]; @@ -3551,6 +3595,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); @@ -3726,6 +3773,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({ @@ -5049,6 +5108,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); @@ -5414,6 +5488,7 @@ const __i18nConfig = null; const __configRedirects = []; const __configRewrites = {"beforeFiles":[],"afterFiles":[],"fallback":[]}; const __configHeaders = []; +const __publicFiles = new Set([]); const __allowedOrigins = []; @@ -5952,6 +6027,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({ @@ -7304,6 +7391,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); @@ -7661,6 +7763,7 @@ const __i18nConfig = null; const __configRedirects = []; const __configRewrites = {"beforeFiles":[],"afterFiles":[],"fallback":[]}; const __configHeaders = []; +const __publicFiles = new Set([]); const __allowedOrigins = []; @@ -8202,6 +8305,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({ @@ -9531,6 +9646,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); @@ -9888,6 +10018,7 @@ const __i18nConfig = null; const __configRedirects = []; const __configRewrites = {"beforeFiles":[],"afterFiles":[],"fallback":[]}; const __configHeaders = []; +const __publicFiles = new Set([]); const __allowedOrigins = []; @@ -10426,6 +10557,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({ @@ -11748,6 +11891,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); @@ -12334,6 +12492,7 @@ const __i18nConfig = null; const __configRedirects = []; const __configRewrites = {"beforeFiles":[],"afterFiles":[],"fallback":[]}; const __configHeaders = []; +const __publicFiles = new Set([]); const __allowedOrigins = []; @@ -13007,6 +13166,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/tests/app-router.test.ts b/tests/app-router.test.ts index 07ce9114..e3e88ada 100644 --- a/tests/app-router.test.ts +++ b/tests/app-router.test.ts @@ -1651,6 +1651,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/deploy.test.ts b/tests/deploy.test.ts index cd19afd2..b9942275 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,14 @@ describe("generateAppRouterWorkerEntry", () => { expect(content).toContain("handler.fetch(request, env, ctx)"); }); + it("does not duplicate static asset signal resolution in the generated worker", () => { + const content = generateAppRouterWorkerEntry(); + expect(content).not.toContain( + 'import { resolveStaticAssetSignal } from "vinext/server/worker-utils"', + ); + expect(content).not.toContain("resolveStaticAssetSignal(response"); + }); + it("includes auto-generated comment", () => { const content = generateAppRouterWorkerEntry(); expect(content).toContain("auto-generated by vinext deploy"); @@ -449,6 +463,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 +811,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/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 + + 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 () => {