Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
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
42 changes: 39 additions & 3 deletions packages/vinext/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,7 @@ import { scanMetadataFiles } from "./server/metadata-routes.js";
import { staticExportPages } from "./build/static-export.js";
import { detectPackageManager } from "./utils/project.js";
import { asyncHooksStubPlugin } from "./plugins/async-hooks-stub.js";
import { mimeType } from "./server/mime.js";
import tsconfigPaths from "vite-tsconfig-paths";
import react, { Options as VitePluginReactOptions } from "@vitejs/plugin-react";
import MagicString from "magic-string";
Expand Down Expand Up @@ -2343,7 +2344,7 @@ hydrate();
headers: nextConfig?.headers,
allowedOrigins: nextConfig?.serverActionsAllowedOrigins,
allowedDevOrigins: nextConfig?.allowedDevOrigins,
}, instrumentationPath);
}, instrumentationPath, root);
}
if (id === RESOLVED_APP_SSR_ENTRY && hasAppDir) {
return generateSsrEntry();
Expand Down Expand Up @@ -2969,10 +2970,32 @@ hydrate();
nextConfig.rewrites.afterFiles,
reqCtx,
);
if (afterRewrite) resolvedUrl = afterRewrite;
if (afterRewrite) {
// External rewrite from afterFiles — proxy to external URL
if (isExternalUrl(afterRewrite)) {
await proxyExternalRewriteNode(req, res, afterRewrite);
return;
}
resolvedUrl = afterRewrite;
// If the rewritten path has a file extension, it may point to a
// static file in public/. Serve it directly before route matching
// (which would miss it and SSR would return 404).
const afterFilesPathname = afterRewrite.split("?")[0];
if (path.extname(afterFilesPathname)) {
const resolvedPublicDir = path.resolve(root, "public");
const publicFilePath = path.resolve(root, "public", "." + afterFilesPathname);
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

path.resolve(root, "public") is computed for resolvedPublicDir on the line above, then path.resolve(root, "public", ...) is called again here instead of reusing resolvedPublicDir. Minor but easy to clean up:

Suggested change
const publicFilePath = path.resolve(root, "public", "." + afterFilesPathname);
const publicFilePath = path.resolve(resolvedPublicDir, "." + afterFilesPathname);

Same pattern applies to the fallback block at line 3031.

if (publicFilePath.startsWith(resolvedPublicDir + path.sep) && fs.existsSync(publicFilePath) && fs.statSync(publicFilePath).isFile()) {
Comment on lines +2985 to +2987
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nit: path.resolve(root, "public") is called twice — once for resolvedPublicDir and again inside the path.resolve(root, "public", ...) on the next line. You can reuse resolvedPublicDir:

Suggested change
const resolvedPublicDir = path.resolve(root, "public");
const publicFilePath = path.resolve(root, "public", "." + afterFilesPathname);
if (publicFilePath.startsWith(resolvedPublicDir + path.sep) && fs.existsSync(publicFilePath) && fs.statSync(publicFilePath).isFile()) {
const resolvedPublicDir = path.resolve(root, "public");
const publicFilePath = path.resolve(resolvedPublicDir, "." + afterFilesPathname);
if (publicFilePath.startsWith(resolvedPublicDir + path.sep) && fs.existsSync(publicFilePath) && fs.statSync(publicFilePath).isFile()) {

Same pattern applies to the fallback block at lines 3030-3032.

const content = fs.readFileSync(publicFilePath);
const ext = (path.extname(afterFilesPathname).slice(1)).toLowerCase();
res.writeHead(200, { "Content-Type": mimeType(ext) });
res.end(content);
return;
}
}
}
}

// External rewrite from afterFiles — proxy to external URL
// External rewrite (from beforeFiles) — proxy to external URL
if (isExternalUrl(resolvedUrl)) {
await proxyExternalRewriteNode(req, res, resolvedUrl);
return;
Expand Down Expand Up @@ -3001,6 +3024,19 @@ hydrate();
await proxyExternalRewriteNode(req, res, fallbackRewrite);
return;
}
// Check if fallback targets a static file in public/
const fallbackPathname = fallbackRewrite.split("?")[0];
if (path.extname(fallbackPathname)) {
const resolvedPublicDir = path.resolve(root, "public");
const publicFilePath = path.resolve(root, "public", "." + fallbackPathname);
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Same redundant path.resolve(root, "public") — reuse resolvedPublicDir here too:

Suggested change
const publicFilePath = path.resolve(root, "public", "." + fallbackPathname);
const publicFilePath = path.resolve(resolvedPublicDir, "." + fallbackPathname);

if (publicFilePath.startsWith(resolvedPublicDir + path.sep) && fs.existsSync(publicFilePath) && fs.statSync(publicFilePath).isFile()) {
const content = fs.readFileSync(publicFilePath);
const ext = (path.extname(fallbackPathname).slice(1)).toLowerCase();
res.writeHead(200, { "Content-Type": mimeType(ext) });
res.end(content);
return;
}
}
await handler(req, res, fallbackRewrite, mwStatus);
return;
}
Expand Down
31 changes: 31 additions & 0 deletions packages/vinext/src/server/app-dev-server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,13 +7,15 @@
* the SSR entry for HTML generation.
*/
import fs from "node:fs";
import path from "node:path";
import { fileURLToPath } from "node:url";
import type { AppRoute } from "../routing/app-router.js";
import type { MetadataFileRoute } from "./metadata-routes.js";
import type { NextRedirect, NextRewrite, NextHeader } from "../config/next-config.js";
import { generateDevOriginCheckCode } from "./dev-origin-check.js";
import { generateSafeRegExpCode, generateMiddlewareMatcherCode, generateNormalizePathCode } from "./middleware-codegen.js";
import { isProxyFile } from "./middleware.js";
import { MIME_TYPES } from "./mime.js";

/**
* Resolved config options relevant to App Router request handling.
Expand Down Expand Up @@ -50,13 +52,19 @@ export function generateRscEntry(
trailingSlash?: boolean,
config?: AppRouterConfig,
instrumentationPath?: string | null,
root?: string,
): string {
const bp = basePath ?? "";
const ts = trailingSlash ?? false;
const redirects = config?.redirects ?? [];
const rewrites = config?.rewrites ?? { beforeFiles: [], afterFiles: [], fallback: [] };
const headers = config?.headers ?? [];
const allowedOrigins = config?.allowedOrigins ?? [];
// Compute the public/ directory path for serving static files after rewrites.
// appDir is something like /project/app or /project/src/app; root is the Vite root.
// We require `root` for correctness — path.dirname(appDir) is wrong for src/app layouts
// (e.g. /project/src/public instead of /project/public).
const publicDir = root ? path.join(root, "public") : null;
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

When root is not provided, publicDir becomes null and static file serving silently degrades (the generated code has if (__extname && null !== null) which is always false). The only call site (index.ts line 2347) always passes root, so this won't fire in practice. But the silent degradation makes it easy for a future caller to miss root and get no error or warning.

Consider either:

  • Making root required (since the single call site always provides it), or
  • Logging a warning when it's missing, e.g.:
if (!root) console.warn("[vinext] generateRscEntry: root not provided, static file serving after rewrites will be disabled");

// Build import map for all page and layout files
const imports: string[] = [];
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The fallback root ?? path.resolve(appDir, "..") is documented as wrong for src/app layouts, but still present. Since root is always passed from index.ts, this code path won't execute in normal usage. But if someone adds a new call site without root, they'll get a silently wrong path. Consider making this defensive:

Suggested change
const imports: string[] = [];
const publicDir = path.join(root ?? (() => { console.warn("[vinext] generateRscEntry: root not provided, public/ path may be incorrect"); return path.resolve(appDir, ".."); })(), "public").replace(/\\/g, "/");

Or more simply, just assert that root is provided and throw if not.

const importMap: Map<string, string> = new Map();
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

When root is not provided, publicDir becomes null and static file serving silently degrades. Since the only call site (index.ts line 2347) always passes root, this won't fire in practice. But the silent null makes it easy for a future caller to miss root and get no error.

Consider either making root required (since the single call site always provides it), or logging a warning when it's missing.

Expand Down Expand Up @@ -207,6 +215,8 @@ ${slotEntries.join(",\n")}
});

return `
import __nodeFs from "node:fs";
import __nodePath from "node:path";
import {
renderToReadableStream,
decodeReply,
Expand Down Expand Up @@ -1825,6 +1835,27 @@ async function _handleRequest(request, __reqCtx, _mwCtx) {
}
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Dev/prod parity issue: This static file check only runs inside if (!match) — i.e. after afterFiles rewrites, matchRoute(), and fallback rewrites have all been attempted. In contrast, prod-server.ts (line 958) and index.ts (line 2984) check for static files immediately after afterFiles rewrites resolve, before route matching.

The current flow in the App Router dev path:

  1. afterFiles rewrites /foo/foo.html (line 1773)
  2. matchRoute("/foo.html", routes) — always misses (line 1777)
  3. Fallback rewrites attempted — unnecessary for .html (line 1780)
  4. !match block finally checks public/ (here)

The check should be inserted right after cleanPathname = __afterRewritten; (after line 1773), and again after cleanPathname = __fallbackRewritten; (after line 1788), matching the other server paths. This avoids unnecessary matchRoute and fallback rewrite calls and keeps dev/prod parity — something AGENTS.md explicitly calls out.


if (!match) {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Dev/prod parity issue — This is the third review cycle flagging this, and it remains the most important structural issue in the PR.

This static file check only runs inside if (!match), i.e. after afterFiles rewrites → matchRoute() → fallback rewrites have all been attempted. But in index.ts (line 2984) and prod-server.ts (line 958), the check runs immediately after afterFiles rewrites resolve, before route matching.

Concrete flow difference in the App Router dev path:

  1. afterFiles rewrites /foo/foo.html (line 1811)
  2. matchRoute("/foo.html", routes) is called (line 1821) — always misses
  3. Fallback rewrites attempted (line 1824) — unnecessary for a .html path
  4. !match block finally checks public/ (here, line 1837)

The fix is to insert a static file check right after cleanPathname = __afterRewritten; (after line 1817) and again after cleanPathname = __fallbackRewritten; (after line 1832), matching the other two server paths. This avoids unnecessary matchRoute and fallback rewrite calls.

Per AGENTS.md: "When fixing a bug in any of these files, check whether the same bug exists in the others. Do not leave known bugs as 'follow-ups' — fix them in the same PR." The parity requirement applies equally to behavioral consistency across the three server implementations.

// If the path has a file extension (e.g. /auth/no-access.html after a rewrite),
// check whether it corresponds to a static file in public/ before returning 404.
const __extname = __nodePath.extname(cleanPathname);
if (__extname && ${JSON.stringify(publicDir)} !== null) {
const __publicRoot = ${JSON.stringify(publicDir)};
const __publicFilePath = __nodePath.resolve(__publicRoot, "." + cleanPathname);
// Path traversal guard — resolved path must stay inside public/
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Minor: this traversal guard uses hardcoded "/" as the separator. On Windows (if anyone runs the dev server there), path.resolve returns backslash-separated paths, so startsWith(__publicRoot + "/") would fail. Consider using __nodePath.sep instead:

if (__publicFilePath.startsWith(__publicRoot + __nodePath.sep)) {

The index.ts version correctly uses path.sep. The generated RSC entry should match.

if (__publicFilePath.startsWith(__publicRoot + __nodePath.sep)) {
try {
const __stat = __nodeFs.statSync(__publicFilePath);
if (__stat.isFile()) {
const __content = __nodeFs.readFileSync(__publicFilePath);
const __ext = __extname.slice(1).toLowerCase();
const __mimeTypes = ${JSON.stringify(MIME_TYPES)};
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Minor: ${JSON.stringify(MIME_TYPES)} creates a new object literal on every request that enters this branch. Since this is generated code inside _handleRequest(), the __mimeTypes constant should be hoisted to module scope in the generated code (near the other generated constants like routes, __configRewrites, etc.).

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This ${JSON.stringify(MIME_TYPES)} expression creates a new object literal on every request that enters this code path. Since this is generated code inside _handleRequest(), the constant should be hoisted to module scope in the generated code (near the other generated constants like routes, __configRewrites, etc.).

Something like:

// At module scope in the generated code
const __mimeTypes = { ... };

Then reference __mimeTypes here instead of creating a new object each time.

setHeadersContext(null);
setNavigationContext(null);
return new Response(__content, { status: 200, headers: { "Content-Type": __mimeTypes[__ext] ?? "application/octet-stream" } });
}
} catch { /* file doesn't exist or not readable */ }
}
}
// Render custom not-found page if available, otherwise plain 404
const notFoundResponse = await renderNotFoundPage(null, isRscRequest, request);
if (notFoundResponse) return notFoundResponse;
Expand Down
46 changes: 46 additions & 0 deletions packages/vinext/src/server/mime.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
/**
* Shared MIME type map for serving static files.
*
* Used by index.ts (Pages Router dev), app-dev-server.ts (generated RSC entry),
* and prod-server.ts (production server). Centralised here to avoid drift.
*
* Keys are bare extensions (no leading dot). Use `mimeType()` for lookup.
*/
export const MIME_TYPES: Record<string, string> = {
html: "text/html; charset=utf-8",
htm: "text/html; charset=utf-8",
css: "text/css",
js: "application/javascript",
mjs: "application/javascript",
cjs: "application/javascript",
json: "application/json",
txt: "text/plain",
xml: "application/xml",
svg: "image/svg+xml",
png: "image/png",
jpg: "image/jpeg",
jpeg: "image/jpeg",
gif: "image/gif",
webp: "image/webp",
avif: "image/avif",
ico: "image/x-icon",
woff: "font/woff",
woff2: "font/woff2",
ttf: "font/ttf",
otf: "font/otf",
eot: "application/vnd.ms-fontobject",
pdf: "application/pdf",
map: "application/json",
mp4: "video/mp4",
webm: "video/webm",
mp3: "audio/mpeg",
ogg: "audio/ogg",
};

/**
* Look up a MIME type by bare extension (no leading dot).
* Returns "application/octet-stream" for unknown extensions.
*/
export function mimeType(ext: string): string {
return MIME_TYPES[ext] ?? "application/octet-stream";
}
44 changes: 20 additions & 24 deletions packages/vinext/src/server/prod-server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ import type { RequestContext } from "../config/config-matchers.js";
import { IMAGE_OPTIMIZATION_PATH, IMAGE_CONTENT_SECURITY_POLICY, parseImageParams, isSafeImageContentType, DEFAULT_DEVICE_SIZES, DEFAULT_IMAGE_SIZES, type ImageConfig } from "./image-optimization.js";
import { normalizePath } from "./normalize-path.js";
import { computeLazyChunks } from "../index.js";
import { mimeType } from "./mime.js";

/** Convert a Node.js IncomingMessage into a ReadableStream for Web Request body. */
function readNodeStream(req: IncomingMessage): ReadableStream<Uint8Array> {
Expand Down Expand Up @@ -184,27 +185,11 @@ function sendCompressed(
}
}

/** Content-type lookup for static assets. */
const CONTENT_TYPES: Record<string, string> = {
".js": "application/javascript",
".mjs": "application/javascript",
".css": "text/css",
".html": "text/html",
".json": "application/json",
".png": "image/png",
".jpg": "image/jpeg",
".jpeg": "image/jpeg",
".gif": "image/gif",
".svg": "image/svg+xml",
".ico": "image/x-icon",
".woff": "font/woff",
".woff2": "font/woff2",
".ttf": "font/ttf",
".eot": "application/vnd.ms-fontobject",
".webp": "image/webp",
".avif": "image/avif",
".map": "application/json",
};
/** Content-type lookup for static assets using the shared MIME map. */
function contentType(ext: string): string {
// ext comes from path.extname() so it has a leading dot (e.g. ".js")
return mimeType(ext.startsWith(".") ? ext.slice(1) : ext);
}

/**
* Try to serve a static file from the client build directory.
Expand Down Expand Up @@ -247,7 +232,7 @@ function tryServeStatic(
}

const ext = path.extname(staticFile);
const ct = CONTENT_TYPES[ext] ?? "application/octet-stream";
const ct = contentType(ext);
const isHashed = pathname.startsWith("/assets/");
const cacheControl = isHashed
? "public, max-age=31536000, immutable"
Expand Down Expand Up @@ -566,7 +551,7 @@ async function startAppRouterServer(options: AppRouterServerOptions) {
// Block SVG and other unsafe content types by checking the file extension.
// SVG is only allowed when dangerouslyAllowSVG is enabled in next.config.js.
const ext = path.extname(params.imageUrl).toLowerCase();
const ct = CONTENT_TYPES[ext] ?? "application/octet-stream";
const ct = contentType(ext);
if (!isSafeImageContentType(ct, imageConfig?.dangerouslyAllowSVG)) {
res.writeHead(400);
res.end("The requested resource is not an allowed image type");
Expand Down Expand Up @@ -733,7 +718,7 @@ async function startPagesRouterServer(options: PagesRouterServerOptions) {
// Block SVG and other unsafe content types.
// SVG is only allowed when dangerouslyAllowSVG is enabled.
const ext = path.extname(params.imageUrl).toLowerCase();
const ct = CONTENT_TYPES[ext] ?? "application/octet-stream";
const ct = contentType(ext);
if (!isSafeImageContentType(ct, pagesImageConfig?.dangerouslyAllowSVG)) {
res.writeHead(400);
res.end("The requested resource is not an allowed image type");
Expand Down Expand Up @@ -967,6 +952,12 @@ async function startPagesRouterServer(options: PagesRouterServerOptions) {
}
resolvedUrl = rewritten;
resolvedPathname = rewritten.split("?")[0];
// If the rewritten path has a file extension, it may point to a static
// file in public/ (copied to clientDir during build). Try to serve it
// directly before falling through to SSR (which would return 404).
if (path.extname(resolvedPathname) && tryServeStatic(req, res, clientDir, resolvedPathname, compress)) {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good — using path.extname() here is correct and consistent with the other server paths. The prior review flagged includes(".") as too broad; glad to see this was addressed.

return;
}
}
}

Expand All @@ -984,6 +975,11 @@ async function startPagesRouterServer(options: PagesRouterServerOptions) {
await sendWebResponse(proxyResponse, req, res, compress);
return;
}
// Check if fallback targets a static file in public/
const fallbackPathname = fallbackRewrite.split("?")[0];
if (path.extname(fallbackPathname) && tryServeStatic(req, res, clientDir, fallbackPathname, compress)) {
return;
}
response = await renderPage(webRequest, fallbackRewrite, ssrManifest);
}
}
Expand Down
37 changes: 37 additions & 0 deletions tests/app-router.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1997,6 +1997,27 @@ describe("App Router next.config.js features (dev server integration)", () => {
expect(html).toContain("About");
});

it("serves static HTML file from public/ when afterFiles rewrite points to .html path", async () => {
// Regresses issue #199: async rewrites() returning flat array (→ afterFiles) that maps
// a clean URL to a .html file in public/ should serve the file, not return 404.
const res = await fetch(`${baseUrl}/static-html-page`);
expect(res.status).toBe(200);
const html = await res.text();
expect(html).toContain("Hello from static HTML");
// Should be served with text/html content-type
expect(res.headers.get("content-type")).toMatch(/text\/html/i);
});

it("serves nested static HTML file from public/ subdirectory via rewrite", async () => {
// Nested rewrites: /auth/no-access → /auth/no-access.html should resolve
// to public/auth/no-access.html and serve it.
const res = await fetch(`${baseUrl}/auth/no-access`);
expect(res.status).toBe(200);
const html = await res.text();
expect(html).toContain("Access denied from nested static HTML");
expect(res.headers.get("content-type")).toMatch(/text\/html/i);
});

it("applies custom headers from next.config.js on API routes", async () => {
const res = await fetch(`${baseUrl}/api/hello`);
expect(res.headers.get("x-custom-header")).toBe("vinext-app");
Expand Down Expand Up @@ -2128,6 +2149,22 @@ describe("App Router next.config.js features (generateRscEntry)", () => {
expect(code).toContain("/fallback-rewrite");
});

it("embeds root/public path for serving static files after rewrite", () => {
// When root is provided, the generated code should contain that public path
// so it can serve .html files from public/ when a rewrite produces a .html path.
const code = generateRscEntry("/tmp/test/app", minimalRoutes, null, [], null, "", false, {}, null, "/tmp/test");
// path.join uses OS separators; the generated code embeds via JSON.stringify
const expectedPublicDir = path.join("/tmp/test", "public");
expect(code).toContain(JSON.stringify(expectedPublicDir));
// Should contain the node:fs and node:path imports for the static file handler
expect(code).toContain("__nodeFs");
expect(code).toContain("__nodePath");
expect(code).toContain("statSync");
// Should use path.resolve + startsWith for traversal protection
expect(code).toContain("__nodePath.resolve");
expect(code).toContain("startsWith");
});

it("generates custom header handling code when headers are provided", () => {
const code = generateRscEntry("/tmp/test/app", minimalRoutes, null, [], null, "", false, {
headers: [
Expand Down
4 changes: 4 additions & 0 deletions tests/fixtures/app-basic/next.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -87,6 +87,10 @@ const nextConfig: NextConfig = {
{ source: "/after-rewrite-about", destination: "/about" },
// Used by E2E: config-redirect.spec.ts
{ source: "/config-rewrite", destination: "/" },
// Used by Vitest: app-router.test.ts (rewrite to static HTML in public/)
{ source: "/static-html-page", destination: "/static-html-page.html" },
// Used by Vitest: app-router.test.ts (nested rewrite to static HTML in public/)
{ source: "/auth/no-access", destination: "/auth/no-access.html" },
],
fallback: [
// Used by Vitest: app-router.test.ts — fallback rewrite gated on a
Expand Down
5 changes: 5 additions & 0 deletions tests/fixtures/app-basic/public/auth/no-access.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
<!DOCTYPE html>
<html>
<head><title>No Access</title></head>
<body>Access denied from nested static HTML</body>
</html>
5 changes: 5 additions & 0 deletions tests/fixtures/app-basic/public/static-html-page.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
<!DOCTYPE html>
<html lang="en">
<head><meta charset="UTF-8"><title>Static HTML Page</title></head>
<body><h1>Hello from static HTML</h1></body>
</html>
10 changes: 10 additions & 0 deletions tests/fixtures/pages-basic/next.config.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,16 @@ const nextConfig = {
has: [{ type: "cookie", key: "mw-user" }],
destination: "/about",
},
// Used by Vitest: pages-router.test.ts (rewrite to static HTML in public/)
{
source: "/static-html-page",
destination: "/static-html-page.html",
},
// Used by Vitest: pages-router.test.ts (nested rewrite to static HTML in public/)
{
source: "/auth/no-access",
destination: "/auth/no-access.html",
},
],
fallback: [
{
Expand Down
5 changes: 5 additions & 0 deletions tests/fixtures/pages-basic/public/auth/no-access.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
<!DOCTYPE html>
<html>
<head><title>No Access</title></head>
<body>Access denied from nested static HTML</body>
</html>
5 changes: 5 additions & 0 deletions tests/fixtures/pages-basic/public/static-html-page.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
<!DOCTYPE html>
<html lang="en">
<head><meta charset="UTF-8"><title>Static HTML Page</title></head>
<body><h1>Hello from static HTML</h1></body>
</html>
Loading
Loading