Skip to content

Commit 1c69ce3

Browse files
authored
fix(pages-router): load custom _app/_document via resolved file paths in dev (#2692)
* fix(pages-router): load custom _app/_document via resolved file paths in dev The dev server imported pages/_app and pages/_document by extensionless id and relied on the module runner applying custom resolve.extensions (e.g. ".page.tsx" from pageExtensions). The runner no longer resolves those, so apps using pageExtensions silently lost their custom App/Document (the load failure is swallowed). Import the file path resolved by findFileWithExts instead, at all three call sites. * test(e2e): add pages-router-complex example app and behaviour suite A deliberately convoluted Pages Router app that serves as a compatibility target: the kinds of patterns that only surface in large, long-lived enterprise pages-router codebases, each pinned by a Playwright spec whose behaviour is verified against real Next.js (73/73 under next dev --webpack). Highlights: custom pageExtensions everywhere (middleware.page.ts, instrumentation.page.ts), app-shell getInitialProps with a TTL-memoised chrome fetch and embedded-shell/draft bypasses, class _document with its own GIP, a zone route dimension driven by middleware rewrites and a real i18next/react-i18next runtime, catch-all routes with static-sibling precedence and cacheable-404/redirect hygiene, record-driven template branching on a dynamic/static/dynamic route, an urql-style server-snapshot data layer, shallow routing + router.events + next/compat/router + next/navigation hooks in pages, next/image with a custom loader, draft-mode gateway APIs, a function-form next.config, and a Cloudflare setup matching the vinext init scaffold. The playwright project (pages-router-complex, port 4199) is intentionally not in the CI e2e matrix: vinext currently passes 59/73, and the failures are the compat backlog documented in the example README. * fix(pages-router): cover compound page extensions in CI
1 parent 956bed2 commit 1c69ce3

122 files changed

Lines changed: 6110 additions & 54 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

.github/workflows/ci.yml

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -457,6 +457,10 @@ jobs:
457457
label: pages-router
458458
shardIndex: 1
459459
shardTotal: 1
460+
- project: pages-router-complex
461+
label: pages-router-complex
462+
shardIndex: 1
463+
shardTotal: 1
460464
- project: pages-router-basepath-dev
461465
label: pages-router-basepath-dev
462466
shardIndex: 1
Lines changed: 124 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,124 @@
1+
# pages-router-complex
2+
3+
A deliberately convoluted Pages Router app. It exists as a compatibility
4+
target for vinext: the patterns here are the kind that only surface in big,
5+
old, enterprise pages-router codebases. It is set up for Cloudflare the way
6+
`vinext init` scaffolds it (vinext + cloudflare Vite plugins, KV data cache,
7+
Workers CDN cache, Images optimizer, `wrangler.jsonc` with the
8+
`vinext/server/fetch-handler` worker entry).
9+
10+
**It is expected to run correctly under real Next.js (`pnpm dev:next`).**
11+
Running it under vinext (`pnpm dev`) is the goal, not (yet) the guarantee —
12+
the e2e suite in `tests/e2e/pages-router-complex/` documents the target
13+
behaviour and may fail under vinext today.
14+
15+
## The patterns being exercised
16+
17+
- **`pageExtensions: ["page.tsx", "page.ts"]`** — every routable file uses the
18+
suffix, non-page helpers live alongside pages (`pages/shell-initial-props.ts`),
19+
and `middleware.page.ts` / `instrumentation.page.ts` carry the suffix too.
20+
- **App-shell `getInitialProps`** (disables automatic static optimisation) that
21+
fetches masthead/baseboard chrome through an in-memory TTL memoiser, skips
22+
the fetch entirely for embedded-shell (cookie-flagged) requests, bypasses the
23+
memo in draft mode, reads env-based settings, and emits a beacon when it
24+
re-runs client-side on navigations.
25+
- **Extra named exports from special files**: `_app` re-exports a conditional
26+
top-level-await `outboundStub` promise; `middleware.page.ts` exports an
27+
unrelated async loader alongside `middleware` + `config`.
28+
- **Class-based `_document`** with its own `getInitialProps` (palette derived
29+
from `ctx.req.url`, `<html lang>` from the zone), `beforeInteractive`
30+
scripts (external + `dangerouslySetInnerHTML` bootstrap), raw inline
31+
`<script>` tags, and data attributes on `<body>`.
32+
- **Middleware pipeline** (regex `matcher` form): CDN-prefix rewrite
33+
(`/atlas/cdn/_next/*``/_next/*`), a 403 for `/_next/image`, a JSON
34+
`hardNavTo` response for raw `/_next/data` requests
35+
(`skipMiddlewareUrlNormalize`), editor-draft redirect on a request header,
36+
draft-cookie scrubbing for API routes via `NextResponse.next({ request })`,
37+
then a single-segment zone rewrite/redirect with an `x-zone` response
38+
header.
39+
- **A `[zone]` route dimension** hidden from public URLs for the home zone,
40+
feeding a real **i18next/react-i18next** runtime (one instance per tree,
41+
synchronous init for SSR, per-zone bundles with fallback) and a zone-aware
42+
`next/link` wrapper for all chrome links.
43+
- **Catch-all + siblings**: `gallery/[...facets]` with static siblings that
44+
must win precedence (`gallery/curated/first`, `gallery/directory/a-z`),
45+
facet-dedupe redirects, character-scrub redirects, permanent deep-trail
46+
collapses, cacheable 404s, and per-wall surrogate TTL overrides.
47+
- **A dynamic/static/dynamic route sandwich** (`[collection]/item/[assetId]`)
48+
whose page-data function branches into three templates off what the
49+
catalogue record says the asset is (withdrawn records and malformed ids
50+
404 first).
51+
- **`getServerSideProps` wrapped in a metering HOF** on every page, custom CDN
52+
cache headers (`Surrogate-Control`/`Surrogate-Key`), a conditional CSP
53+
response-header side effect, and one page with **no data-fetching function
54+
at all** (`detail-tools/client-flags`).
55+
- **Server-snapshot data layer**: gSSP runs ops through a server handle that
56+
records a snapshot, the snapshot rides page props, and the browser handle
57+
is seeded with it so hydration reads from memory (`useGraphOp`).
58+
- **Client-side routing machinery**: shallow `router.push` with the internal
59+
dynamic-route pattern as `pathname` and the public URL as `as`;
60+
`router.events` driving a transition overlay with a failsafe timeout; a
61+
router-agnostic reset hook on `next/compat/router` + `usePathname`.
62+
- **App Router hooks in Pages Router pages**: `useSearchParams` seeding
63+
initial state and `usePathname`, combined with raw
64+
`window.history.replaceState` query updates that bypass the router.
65+
- **`next/image` with a custom loader** in `fill` mode + css-module class on
66+
the fault screens — the framework optimizer endpoint is never used, which
67+
is what makes the middleware's `/_next/image` 403 safe.
68+
- **API routes**: draft-mode gateway with landing-path resolution, a
69+
bearer-gated memo purge endpoint, a cookie-reflecting upstream relay proxy
70+
in promise-chain style, a legacy path rewritten in `afterFiles`, and a
71+
204-with-cache-headers type-ahead shim.
72+
- **`next.config.js` (CJS)** exporting the **function form** — an async
73+
function of `(phase, context)` built by a decorator composer — with a
74+
throwing `generateBuildId`, prod-only `assetPrefix`, env-conditional
75+
`fallback` rewrites, and a webpack hook (workspace alias + test-module
76+
replacement via `NormalModuleReplacementPlugin`).
77+
- **`instrumentation.page.ts`** gated on `NEXT_RUNTIME === "nodejs"`, with its
78+
effect observable through `/api/status`.
79+
80+
## Running
81+
82+
```bash
83+
pnpm dev # vinext dev server (needs RELEASE_TAG for now, see below)
84+
pnpm dev:next # real Next.js dev server (ground truth; --webpack, see below)
85+
pnpm build # vinext build (RELEASE_TAG is required and set inline)
86+
pnpm build:next # next build
87+
88+
# The behaviour suite (server starts under vinext automatically):
89+
PLAYWRIGHT_PROJECT=pages-router-complex pnpm run test:e2e
90+
```
91+
92+
## Known findings
93+
94+
- **Next.js 16.2.7 + Turbopack** fails to compile: the global-CSS-in-_app
95+
validation false-positives when `pageExtensions` renames `_app` to
96+
`_app.page.tsx`. The `dev:next`/`build:next` scripts pass `--webpack`.
97+
- **TypeScript 7 (native preview)**: Next's own tsconfig-paths support
98+
degrades under it, so the `@atlas/*` alias is wired into both bundlers
99+
explicitly (webpack hook + Vite `resolve.alias`); tsconfig `paths` (without
100+
the removed `baseUrl`) stays authoritative for the type checker.
101+
- **vinext dev (Cloudflare plugin): 59/73 specs pass; the 14 known gaps are
102+
marked `test.fixme` so the passing surface runs in CI.** Known gaps:
103+
`generateBuildId` is invoked at dev startup (Next.js only calls it at build
104+
time — the e2e server exports `RELEASE_TAG` to compensate), shallow routing
105+
+ `router.events` (including a hydration knock-on that breaks page
106+
interactivity), the `next/image` custom-loader/`fill` path, raw
107+
`/_next/data` interception and the `/_next/image` 403 middleware branches,
108+
the `history.replaceState`-beside-the-router hybrid, the gallery scrub
109+
redirect, cacheable-404 surrogate headers, the `afterFiles` type-ahead
110+
rewrite, and the purge endpoint pair (possibly env vars not reaching the
111+
workerd runtime).
112+
- The pinned workerd binary caps `compatibility_date` at 2026-04-08, so
113+
`wrangler.jsonc` pins 2026-04-01 rather than the scaffold's "today".
114+
115+
## Layout
116+
117+
- `lib/` — the app's internal platform libraries, consumed through tsconfig
118+
path aliases (`@atlas/*`) the way a monorepo app consumes shared packages:
119+
zones (zone routing + i18next runtime), edge-policy (CDN headers), memo
120+
(TTL result cache), beacon (metrics/RUM), graph-handle (data layer), chrome
121+
(masthead/baseboard), wiring + blocks (the provider pyramid and frame),
122+
trials, draft, and so on.
123+
- `helpers/`, `surfaces/` — app-level page-data helpers and page templates.
124+
- `pages/` — the route tree described above.
Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
/**
2+
* Bridge between instrumentation.page.ts and the /api/status route.
3+
*
4+
* The boot hook and API routes may evaluate in different module graphs (or
5+
* different processes under some dev servers), so process-global state is not
6+
* a reliable bridge on its own. A tmp-file fallback keeps the signal visible
7+
* across process boundaries; same-process reads hit the global first.
8+
*/
9+
10+
import fs from "node:fs";
11+
import os from "node:os";
12+
import path from "node:path";
13+
14+
const GLOBAL_KEY = "__atlas_boot_state__";
15+
16+
const stateFile = path.join(os.tmpdir(), "atlas-pages-router-complex-boot.json");
17+
18+
export const markBootHookCalled = (): void => {
19+
(globalThis as Record<string, unknown>)[GLOBAL_KEY] = true;
20+
try {
21+
fs.writeFileSync(stateFile, JSON.stringify({ booted: true }), "utf-8");
22+
} catch {
23+
// best-effort — the global flag still covers same-process readers
24+
}
25+
};
26+
27+
export const wasBootHookCalled = (): boolean => {
28+
if ((globalThis as Record<string, unknown>)[GLOBAL_KEY] === true) {
29+
return true;
30+
}
31+
try {
32+
return (
33+
(JSON.parse(fs.readFileSync(stateFile, "utf-8")) as { booted?: boolean })
34+
.booted === true
35+
);
36+
} catch {
37+
return false;
38+
}
39+
};
Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
import type { GetServerSidePropsContext } from "next";
2+
3+
/**
4+
* Response-header side effect performed from getServerSideProps when the
5+
* corresponding trial arm is on.
6+
*/
7+
export const sendTightAssetPolicyHeader = (
8+
context: GetServerSidePropsContext,
9+
reportOnly: boolean,
10+
): void => {
11+
const headerName = reportOnly
12+
? "Content-Security-Policy-Report-Only"
13+
: "Content-Security-Policy";
14+
context.res.setHeader(
15+
headerName,
16+
"default-src 'self'; img-src 'self' https://media.atlas-fixture.test",
17+
);
18+
};
Lines changed: 92 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,92 @@
1+
import type { GetServerSidePropsContext } from "next";
2+
3+
import type { Zone } from "@atlas/zones/zone";
4+
import { HOME_ZONE } from "@atlas/zones/zone";
5+
6+
/**
7+
* Gallery URL hygiene: query validation, "known bad shape" detection with a
8+
* redirect to the deduplicated wall, and character-level sanitisation with a
9+
* redirect to the public canonical form.
10+
*/
11+
12+
const REPEATABLE_PARAMS = new Set(["facet"]);
13+
14+
export const isFacetQueryAcceptable = (
15+
context: GetServerSidePropsContext,
16+
): boolean => {
17+
for (const [key, value] of Object.entries(context.query)) {
18+
if (Array.isArray(value) && !REPEATABLE_PARAMS.has(key) && key !== "facets") {
19+
return false;
20+
}
21+
}
22+
const page = context.query["page"];
23+
if (typeof page === "string" && Number.isNaN(Number.parseInt(page, 10))) {
24+
return false;
25+
}
26+
return true;
27+
};
28+
29+
export const wallPathFromQuery = (
30+
query: GetServerSidePropsContext["query"],
31+
): string => {
32+
const facets = query["facets"];
33+
const segments = Array.isArray(facets) ? facets : facets ? [facets] : [];
34+
return `/gallery/${segments.join("/")}`;
35+
};
36+
37+
/** A trail is malformed when a facet segment repeats back-to-back. */
38+
export const isMalformedTrail = (
39+
query: GetServerSidePropsContext["query"],
40+
wallPath: string,
41+
): boolean => {
42+
const segments = wallPath.split("/").filter(Boolean).slice(1);
43+
return segments.some((segment, i) => segment === segments[i - 1]);
44+
};
45+
46+
export const redirectForMalformedTrail = (
47+
wallPath: string,
48+
_resolvedUrl: string,
49+
_ctx: { zone: Zone },
50+
) => {
51+
const segments = wallPath.split("/").filter(Boolean).slice(1);
52+
const deduped = segments.filter((segment, i) => segment !== segments[i - 1]);
53+
return {
54+
redirect: {
55+
destination: `/gallery/${deduped.join("/")}`,
56+
permanent: false,
57+
},
58+
};
59+
};
60+
61+
/**
62+
* Character-level clean-up: lowercase, collapse whitespace runs to dashes.
63+
* The redirect destination must be the *public* URL, so the internal zone
64+
* prefix is stripped and re-added only for non-home zones.
65+
*/
66+
export const scrubWallPath = ({
67+
resolvedUrl,
68+
ctx,
69+
}: {
70+
resolvedUrl: string;
71+
ctx: { zone: Zone };
72+
}): { isOriginalPathClean: boolean; scrubbedPath: string } => {
73+
const [path, queryString] = resolvedUrl.split("?");
74+
const scrubbedInternal = path
75+
.toLowerCase()
76+
.replace(/%20| /g, "-")
77+
.replace(/-{2,}/g, "-");
78+
79+
let scrubbed = scrubbedInternal;
80+
const internalPrefix = `/${ctx.zone.slug}`;
81+
if (scrubbed.startsWith(`${internalPrefix}/`)) {
82+
const bare = scrubbed.slice(internalPrefix.length);
83+
scrubbed = ctx.zone.slug === HOME_ZONE.slug ? bare : `${internalPrefix}${bare}`;
84+
}
85+
86+
return {
87+
isOriginalPathClean: scrubbedInternal === path,
88+
scrubbedPath: queryString ? `${scrubbed}?${queryString}` : scrubbed,
89+
};
90+
};
91+
92+
export const missForWall = (): { notFound: true } => ({ notFound: true });
Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
/**
2+
* The front door's surrogate TTL is tunable at runtime (ops can shorten it
3+
* during launches). Async because the real lookup consults a config service.
4+
*/
5+
export const readFrontDoorTtl = async (): Promise<{ frontDoorTtl: number }> => {
6+
const raw = process.env["ATLAS_FRONT_DOOR_TTL"];
7+
const parsed = raw ? Number.parseInt(raw, 10) : Number.NaN;
8+
return {
9+
frontDoorTtl: Number.isFinite(parsed) && parsed > 0 ? parsed : 900,
10+
};
11+
};
Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
export const register = async () => {
2+
if (process.env.NEXT_RUNTIME === "nodejs") {
3+
(await import("./boot-state")).markBootHookCalled();
4+
}
5+
};
Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
export type Beacon = {
2+
name: string;
3+
attributes: Record<string, unknown>;
4+
at: number;
5+
};
6+
7+
declare global {
8+
interface Window {
9+
__ATLAS_BEACONS__?: Beacon[];
10+
}
11+
}
12+
13+
/**
14+
* Isomorphic beacon sink. In the browser, beacons accumulate on
15+
* `window.__ATLAS_BEACONS__` (the stand-in for a RUM agent) so end-to-end
16+
* tests can assert on them; on the server they go to stdout.
17+
*/
18+
export const emitBeacon = (
19+
name: string,
20+
attributes: Record<string, unknown> = {},
21+
): void => {
22+
if (typeof window === "undefined") {
23+
console.log(`[beacon] ${name}`, JSON.stringify(attributes));
24+
return;
25+
}
26+
window.__ATLAS_BEACONS__ = window.__ATLAS_BEACONS__ ?? [];
27+
window.__ATLAS_BEACONS__.push({ name, attributes, at: Date.now() });
28+
};
Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
1+
import type { NextApiHandler, NextApiRequest, NextApiResponse } from "next";
2+
3+
import { bumpRequestTally, looksLikeCrawler } from "./tally";
4+
5+
export const meterApiRoute = <T = Record<string, unknown>>(
6+
handler: NextApiHandler<T>,
7+
) => {
8+
const meteredHandler: NextApiHandler<T> = async (
9+
req: NextApiRequest,
10+
res: NextApiResponse<T>,
11+
) => {
12+
const fromCrawler = looksLikeCrawler(req.headers["user-agent"]);
13+
const path = req.url;
14+
const method = req.method;
15+
16+
const record = (statusCode: number) =>
17+
path &&
18+
method &&
19+
bumpRequestTally({
20+
path,
21+
method,
22+
fromCrawler,
23+
statusCode,
24+
});
25+
26+
try {
27+
await handler(req, res);
28+
29+
record(res.statusCode);
30+
} catch (error) {
31+
record(500);
32+
33+
throw error;
34+
}
35+
};
36+
37+
return meteredHandler;
38+
};

0 commit comments

Comments
 (0)