Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
18 commits
Select commit Hold shift + click to select a range
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
1 change: 1 addition & 0 deletions examples/pages-router-cloudflare/public/hello copy.txt
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
Hello from an encoded Pages Router public asset.
1 change: 1 addition & 0 deletions examples/pages-router-cloudflare/public/static.txt
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
Hello from a Pages Router public asset on Workers.
2 changes: 2 additions & 0 deletions packages/vinext/src/entries/pages-server-entry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,7 @@ export async function generateServerEntry(
fileMatcher: ReturnType<typeof createValidFileMatcher>,
middlewarePath: string | null,
instrumentationPath: string | null,
publicFiles: string[] = [],
): Promise<string> {
const pageRoutes = await pagesRouter(pagesDir, nextConfig?.pageExtensions, fileMatcher);
const apiRoutes = await apiRouter(pagesDir, nextConfig?.pageExtensions, fileMatcher);
Expand Down Expand Up @@ -309,6 +310,7 @@ ${errorImportCode}
export const pageRoutes = [
${pageRouteEntries.join(",\n")}
];
export const publicFiles = new Set(${JSON.stringify(publicFiles)});
const _pageRouteTrie = _buildRouteTrie(pageRoutes);
const _errorPageRoute = {
pattern: "/_error",
Expand Down
157 changes: 149 additions & 8 deletions packages/vinext/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -242,6 +242,8 @@ import { createWasmModuleImportPlugin } from "./plugins/wasm-module-import.js";
import { getTypeofWindowReplacement, replaceTypeofWindow } from "./plugins/typeof-window.js";
import { hasMdxFiles } from "./utils/mdx-scan.js";
import { scanPublicFileRoutes } from "./utils/public-routes.js";
import { publicFilePathVariants } from "./utils/public-file-path.js";
import { methodNotAllowedResponse } from "./server/http-error-responses.js";
import type { Options as VitePluginReactOptions } from "@vitejs/plugin-react";
import MagicString from "magic-string";
import path, { toSlash } from "pathslash";
Expand Down Expand Up @@ -1458,6 +1460,11 @@ export default function vinext(options: VinextOptions = {}): PluginOption[] {
let pagesOptimizeEntries: string[] = [];
const pagesClientAssetsOutputDirs = new Set<string>();
let pagesClientAssetsModule: string | null = null;
// Dev-only public route inventory. Vite's watcher keeps this synchronized,
// so request handling can use O(1) membership checks without filesystem I/O.
// Production builds leave it null and scan the configured public directory
// once while generating the RSC entry.
let devPublicFileRoutes: Set<string> | null = null;
let rscCompatibilityId: string | undefined;
let draftModeSecret = getPagesPreviewModeId();
let previewBuildCredentials: PreviewBuildCredentials | undefined;
Expand Down Expand Up @@ -1515,13 +1522,18 @@ export default function vinext(options: VinextOptions = {}): PluginOption[] {
* Generate the virtual SSR server entry module.
* This is the entry point for `vite build --ssr`.
*/
async function generateServerEntry(): Promise<string> {
async function generateServerEntry(configuredPublicDir: string | false): Promise<string> {
const publicFiles =
isServeCommand && devPublicFileRoutes
? [...devPublicFileRoutes].sort()
: scanPublicFileRoutes(root, configuredPublicDir === "" ? false : configuredPublicDir);
return _generateServerEntry(
pagesDir,
nextConfig,
fileMatcher,
middlewarePath,
instrumentationPath,
publicFiles,
);
}

Expand Down Expand Up @@ -3697,7 +3709,9 @@ export default function vinext(options: VinextOptions = {}): PluginOption[] {
}
// Pages Router virtual modules
if (id === RESOLVED_SERVER_ENTRY) {
return await generateServerEntry();
return await generateServerEntry(
this.environment.config.publicDir === "" ? false : this.environment.config.publicDir,
);
}
if (id === RESOLVED_CLIENT_ENTRY) {
return await generateClientEntry();
Expand Down Expand Up @@ -3795,7 +3809,15 @@ export default function vinext(options: VinextOptions = {}): PluginOption[] {
contentSecurityPolicy: nextConfig?.images?.contentSecurityPolicy,
},
hasPagesDir,
publicFiles: scanPublicFileRoutes(root),
publicFiles:
isServeCommand && devPublicFileRoutes
? [...devPublicFileRoutes].sort()
: scanPublicFileRoutes(
root,
this.environment.config.publicDir === ""
? false
: this.environment.config.publicDir,
),
globalNotFoundPath,
draftModeSecret,
},
Expand Down Expand Up @@ -4499,7 +4521,46 @@ export const loadServerActionClient = ${
socket.on("error", () => {});
});

const configuredPublicDir =
server.config.publicDir === "" ? false : (server.config.publicDir as string | false);
const devPublicDir =
configuredPublicDir === false
? null
: path.resolve(toSlash(server.config.root), toSlash(configuredPublicDir));
devPublicFileRoutes = new Set(scanPublicFileRoutes(root, configuredPublicDir));
const publicRoutesForFile = (filePath: string): string[] | null => {
if (devPublicDir === null) return null;
const cleanPath = toSlash(stripViteModuleQuery(filePath));
const relativePath = path.relative(devPublicDir, cleanPath);
if (
relativePath === "" ||
relativePath.startsWith("../") ||
path.isAbsolute(relativePath)
) {
return null;
}
return publicFilePathVariants(`/${relativePath}`);
};
const updatePublicFileRoute = (filePath: string, present: boolean): void => {
const routes = publicRoutesForFile(filePath);
if (routes === null || devPublicFileRoutes === null) return;
let changed = false;
for (const route of routes) {
const routeChanged = present
? !devPublicFileRoutes.has(route)
: devPublicFileRoutes.has(route);
if (!routeChanged) continue;
changed = true;
if (present) devPublicFileRoutes.add(route);
else devPublicFileRoutes.delete(route);
}
if (!changed) return;
if (hasAppDir) invalidateRscEntryModule();
if (hasCloudflarePlugin && hasPagesDir && !hasAppDir) invalidatePagesServerEntry();
};

server.watcher.on("add", (filePath: string) => {
updatePublicFileRoute(filePath, true);
let routeChanged = false;
const pagesAppChanged = isPagesAppFile(filePath);
const pagesAssetGraphScriptChanged = isPotentialPagesAssetGraphScript(filePath);
Expand Down Expand Up @@ -4541,6 +4602,7 @@ export const loadServerActionClient = ${
}
});
server.watcher.on("unlink", (filePath: string) => {
updatePublicFileRoute(filePath, false);
let routeChanged = false;
const pagesAppChanged = isPagesAppFile(filePath);
const pagesAssetGraphScriptChanged = isPotentialPagesAssetGraphScript(filePath);
Expand Down Expand Up @@ -4572,6 +4634,35 @@ export const loadServerActionClient = ${
}
});

type DevPagesMiddleware = (
req: import("node:http").IncomingMessage,
res: import("node:http").ServerResponse,
next: (error?: unknown) => void,
) => Promise<void>;
let handlePagesMiddleware: DevPagesMiddleware | null = null;
const isExistingDevPublicFile = (requestPathname: string): boolean =>
devPublicFileRoutes?.has(requestPathname) === true;
const isUnsupportedDevPublicRequest = (
req: import("node:http").IncomingMessage,
pathnameState: "pre-base" | "post-base" = "pre-base",
): boolean => {
if (req.method === "GET" || req.method === "HEAD") return false;
let pathname: string;
try {
pathname = normalizePathnameForRouteMatchStrict(
new URL(req.url ?? "/", "http://vinext.invalid").pathname,
);
} catch {
return false;
}
const basePath = nextConfig?.basePath ?? "";
if (pathnameState === "pre-base" && basePath) {
if (!hasBasePath(pathname, basePath)) return false;
pathname = stripBasePath(pathname, basePath);
}
return isExistingDevPublicFile(pathname);
};

// ── Dev request origin check ─────────────────────────────────────
// Registered directly (not in the returned function) so it runs
// BEFORE Vite's built-in middleware. This ensures all requests
Expand All @@ -4597,6 +4688,16 @@ export const loadServerActionClient = ${
next();
});

// Vite serves public files for every method. Intercept only mutations
// to known public files before Vite's internals, then run the canonical
// Pages pipeline so config/middleware ordering and headers are retained.
server.middlewares.use((req, res, next) => {
if (!hasPagesDir || !handlePagesMiddleware || !isUnsupportedDevPublicRequest(req)) {
return next();
}
void handlePagesMiddleware(req, res, next);
});

installDevStackSourcemapMiddleware(server);

// Return a function to register middleware AFTER Vite's built-in middleware
Expand All @@ -4612,6 +4713,34 @@ export const loadServerActionClient = ${
typeof handle === "function",
);

// The patched Vite sirv middleware serves public files for every
// method. In App-only and Cloudflare dev projects, let mutations pass
// through to the framework request handler. App Router applies the
// canonical middleware -> beforeFiles -> filesystem ordering; the
// Cloudflare plugin delegates to the Worker/miniflare request path.
if ((hasAppDir && !hasPagesDir) || hasCloudflarePlugin) {
const publicMiddlewareEntry = server.middlewares.stack.find(
({ handle }) =>
typeof handle === "function" && handle.name === "viteServePublicMiddleware",
);
const viteServePublic = publicMiddlewareEntry?.handle as
| import("vite").Connect.NextHandleFunction
| undefined;
if (publicMiddlewareEntry && viteServePublic) {
const vinextServePublicMiddleware: import("vite").Connect.NextHandleFunction = (
req,
res,
next,
) => {
// Vite's base middleware runs before its public middleware,
// so req.url may already have had nextConfig.basePath removed.
if (isUnsupportedDevPublicRequest(req, "post-base")) return next();
return viteServePublic(req, res, next);
};
publicMiddlewareEntry.handle = vinextServePublicMiddleware;
}
}

const serveRewrittenViteFilesystemRoute = async (
req: import("node:http").IncomingMessage,
res: import("node:http").ServerResponse,
Expand Down Expand Up @@ -4826,7 +4955,7 @@ export const loadServerActionClient = ${
});
}

const handlePagesMiddleware = async (
handlePagesMiddleware = async (
req: import("node:http").IncomingMessage,
res: import("node:http").ServerResponse,
next: (err?: unknown) => void,
Expand Down Expand Up @@ -5126,6 +5255,8 @@ export const loadServerActionClient = ${
}),
);
const isFilePathRequest = pathname.includes(".") && !pathname.endsWith(".html");
const isExistingPublicMutation =
req.method !== "GET" && req.method !== "HEAD" && isExistingDevPublicFile(pathname);
let filePathMatchesPagesRoute = false;
const requestHostname = getUrlHostname(requestOrigin);
if (isFilePathRequest && !filePathMatchesRewrite) {
Expand All @@ -5150,7 +5281,12 @@ export const loadServerActionClient = ${
matchRoute(pageRouteUrl, pageRoutes) !== null ||
matchRoute(apiRouteUrl, apiRoutes) !== null;
}
if (isFilePathRequest && !filePathMatchesRewrite && !filePathMatchesPagesRoute) {
if (
isFilePathRequest &&
!filePathMatchesRewrite &&
!filePathMatchesPagesRoute &&
!isExistingPublicMutation
) {
return next();
}

Expand Down Expand Up @@ -5356,15 +5492,20 @@ export const loadServerActionClient = ${
return proxyExternalRequest(reqWithBody, externalUrl);
},
serveFilesystemRoute: async (requestPathname, stagedHeaders, phase) => {
const isRetrievalMethod = req.method === "GET" || req.method === "HEAD";
if (
phase === "direct" ||
(req.method !== "GET" && req.method !== "HEAD") ||
(phase === "direct" && isRetrievalMethod) ||
requestPathname === "/" ||
requestPathname === "/api" ||
requestPathname.startsWith("/api/")
) {
return false;
}
if (!isRetrievalMethod) {
return isExistingDevPublicFile(requestPathname)
? methodNotAllowedResponse("GET, HEAD")
: false;
}
return serveRewrittenViteFilesystemRoute(
req,
res,
Expand Down Expand Up @@ -5544,7 +5685,7 @@ export const loadServerActionClient = ${
};

server.middlewares.use((req, res, next) => {
void handlePagesMiddleware(req, res, next);
void handlePagesMiddleware!(req, res, next);
});
};
},
Expand Down
8 changes: 8 additions & 0 deletions packages/vinext/src/server/app-rsc-response-finalizer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import { VINEXT_RSC_VARY_HEADER } from "./app-rsc-cache-busting.js";
import { mergeVaryHeader } from "./middleware-response-headers.js";
import { hasBasePath, stripBasePath } from "../utils/base-path.js";
import { normalizeDefaultLocalePathname } from "./pages-i18n.js";
import { sanitizeMethodNotAllowedHeaders } from "./http-error-responses.js";

type FinalizeAppRscResponseOptions = {
basePath: string;
Expand Down Expand Up @@ -102,5 +103,12 @@ export async function finalizeAppRscResponse(
basePathState: { basePath: options.basePath, hadBasePath },
});

// Static-file 405 responses are synthesized before config headers run.
// Reassert their body metadata afterward so a matching headers() rule cannot
// describe a different body or replace the canonical Allow value.
if (response.status === 405 && response.headers.get("Allow") === "GET, HEAD") {
sanitizeMethodNotAllowedHeaders(response.headers, "GET, HEAD");
}

return response;
}
16 changes: 15 additions & 1 deletion packages/vinext/src/server/http-error-responses.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,20 @@ type ErrorResponseInit = {
headers?: HeadersInit;
};

const METHOD_NOT_ALLOWED_BODY_HEADERS = [
"content-encoding",
"content-length",
"content-range",
"content-type",
"transfer-encoding",
] as const;

export function sanitizeMethodNotAllowedHeaders(headers: Headers, allowedMethods: string): void {
for (const name of METHOD_NOT_ALLOWED_BODY_HEADERS) headers.delete(name);
headers.set("Allow", allowedMethods);
headers.set("Content-Type", "text/plain; charset=utf-8");
}

/**
* Build a 400 Bad Request plain-text response.
*
Expand Down Expand Up @@ -96,7 +110,7 @@ export function methodNotAllowedResponse(
init?: ErrorResponseInit,
): Response {
const headers = new Headers(init?.headers);
headers.set("Allow", allowedMethods);
sanitizeMethodNotAllowedHeaders(headers, allowedMethods);
return new Response("Method Not Allowed", { status: 405, headers });
}

Expand Down
Loading
Loading