Skip to content

Commit 15987cc

Browse files
committed
Merge branch 'main' into chore/upgrade-vite-plus-0.2.6
2 parents a842603 + 05eee9f commit 15987cc

128 files changed

Lines changed: 6184 additions & 56 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.
Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
---
2+
"@vinext/cloudflare": patch
3+
"vinext": patch
4+
---
5+
6+
- fix(cache): preserve prerendered page cache tags (#709)
7+
- fix(pages-router): load custom \_app/\_document via resolved file paths in dev (#2692)
8+
- fix(pages): preserve live Set-Cookie header arrays (#2689)
9+
- fix(css): load Sass partials from paths containing tildes (#2691)
10+
- fix: honor build --mode dotenv files (#2523)
11+
- fix(build): normalize jsx-in-js module ids on Windows (#2687)
12+
- fix(rsc): handle Windows paths in client reference dedup (#2686)
13+
- fix(app-router): install default not-found boundary (#2670)
14+
- fix(app-router): scope dynamic params by segment (#2228)
15+
- fix(app-router): honor client cache stale times (#2449)
16+
- perf(app-router): serialize streamed metadata once (#2675)
17+
- fix(app-router): resolve interception-only RSC targets (#2256)
18+
- fix(app-router): preserve hash query navigation semantics (#2669)
19+
- fix(pages): normalize decoded edge responses (#2668)
20+
- fix(app-router): reconcile streamed metadata icons (#2320)
21+
- fix(pages): propagate Document script security props (#2044)
22+
- fix(app-router): align prefetch server protocol (#2318)

.changeset/pre.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@
1616
"auto-_vinext_cloudflare_1.0.0-beta.0-4c8399d",
1717
"auto-_vinext_cloudflare_1.0.0-beta.1-0252ea1",
1818
"auto-_vinext_cloudflare_1.0.0-beta.2-023aa76",
19+
"auto-_vinext_cloudflare_1.0.0-beta.3-1c69ce3",
1920
"enter-v1-beta"
2021
]
2122
}

.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+
};

0 commit comments

Comments
 (0)