mirror of
https://github.com/cloudflare/vinext.git
synced 2026-09-14 19:04:59 +08:00
bf5b09c46f
* feat(pages-router): add runPagesRequest pipeline owner with focused tests
Introduces server/pages-request-pipeline.ts which owns the canonical
Next.js 9-step request ordering once. Returns a PagesPipelineResult
discriminated union: type:response for prod/worker callers that supply
render/api callbacks, type:render|api|next intents for dev callers that
omit them. Mirrors createAppRscHandler's thin-closure injection pattern.
Two latent drift bugs reconciled vs the worker copy:
- middlewareStatus now uses result.status ?? result.rewriteStatus
- applyMiddlewareRequestHeaders now passes preserveCredentialHeaders
Adds 28 focused behavior tests covering all pipeline branches.
Adds ./server/pages-request-pipeline export to package.json.
* refactor(pages-router): migrate prod-server.ts to delegate to runPagesRequest
Replaces the ~400-line inlined try block in startPagesRouterServer with a
thin adapter that calls runPagesRequest(webRequest, deps). The adapter
retains only Node-specific concerns: open-redirect guard, decode/400,
prerender endpoint, static assets, image opt, basePath strip, _next/data
normalization, and compression/streaming output.
Extends renderPage callback signature with optional stagedHeaders param
so CSP nonces and other pre-render header injection continue to work.
Copies __vinextStreamedHtmlResponse marker through mergeHeaders to
preserve stream-vs-buffer decision in the adapter.
276 pages-router tests, 112 routing tests, 28 pipeline tests all pass.
* refactor(pages-router): migrate deploy.ts worker template to delegate to runPagesRequest
Collapses the ~280-line inlined fetch handler into ~65 lines. Worker adapter
keeps only: registerConfiguredCacheAdapters, open-redirect guard, static-asset
404, x-nextjs-data header capture, filterInternalHeaders+cloneRequestWithHeaders,
basePath strip with hadBasePath, and image optimization. Delegates the rest to
runPagesRequest via PagesPipelineDeps.
Removes inlined imports: matchRedirect, matchRewrite, preserveRedirectDestinationQuery,
requestContextFromRequest, applyMiddlewareRequestHeaders, isExternalUrl, proxyExternalRequest,
sanitizeDestination, applyConfigHeadersToHeaderRecord, normalizeTrailingSlash, mergeHeaders,
normalizeDefaultLocalePathname, stripI18nLocaleForApiRoute, mergeRewriteQuery.
Updates deploy.test.ts assertions to verify delegation rather than inlined
step logic. 245 deploy tests pass.
* refactor(pages-router): migrate index.ts dev to runPagesRequest, delete parallel helpers
Replaces the inlined dev Pages Router pipeline with runPagesRequest. The
adapter keeps Node-specific concerns: Vite skips, cross-origin guard, image
302 redirect, .html normalization, open-redirect guard, decode/400, basePath
strip with hadBasePath, trailing-slash, _next/data normalization, rawHeaders
snapshot, cloudflare-plugin delegation.
Creates devRunMiddlewareAdapter closure that wraps the dev runMiddleware
(runner, path, i18n, basePath, trailingSlash, isDataRequest args) and
returns a MiddlewareResult-compatible object. Sets VINEXT_MW_CTX_HEADER for
hybrid app+pages mode as a side effect before returning.
Adds writeWebResponseToNodeRes() helper to stream Web Response to Node res
preserving multi-value Set-Cookie. Adds requestHeaders field to render/api
intents so dev adapter can flush post-middleware request headers to req.headers
before calling createSSRHandler/handleApiRoute.
Deletes:
- applyRedirects (lines ~4829-4869)
- proxyExternalRewriteNode (lines ~4867-4928)
- applyRewrites (lines ~4929-4951)
- applyHeaders (lines ~4951-5015)
276 pages-router tests, 112 routing tests, 245 deploy tests, 28 pipeline
tests all pass. vp check clean.
* refactor(pages-router): format deploy.test.ts string literals consistently
* fix(pages-router): restore hasAppDir dev gate; fix external proxy body/headers; update stale after-deploy assertions
Three regressions introduced by the dev pipeline refactor:
1. Hybrid app+pages dev mode: the 'render' intent branch in handlePagesMiddleware
unconditionally called createSSRHandler, missing the original hasAppDir fallthrough
gate. App Router page requests in the hybrid app-basic fixture returned 404 instead
of being deferred to the RSC plugin. Restored by matching the route before calling
the Pages SSR handler and returning next() when there is no pages match and hasAppDir
is true.
2. External proxy body/headers: proxyExternalRewriteNode read the body from the Node
IncomingMessage and the post-middleware headers from req. The refactored pipeline used
a body-less webRequest, so POST bodies were empty and middleware-modified request
headers (x-hello-from-middleware1 etc.) were not forwarded. Fixed by adding an
optional proxyExternal dep to PagesPipelineDeps; the dev adapter supplies it, building
a proper Request from the pipeline's current (post-middleware) headers and the Node
req body stream. Prod and worker adapters fall through to proxyExternalRequest as before.
3. Stale after-deploy assertions: two tests checked old inline call-site strings that
no longer appear in the generated worker entry after the delegation refactor. ctx is
still forwarded (deploy.ts:654,657); updated assertions to match the new call sites.
* fix(pages-router): restore public-file serving + API content-type; gate dev api flushes
Three behavioral regressions from the pipeline extraction, plus the bonk
review findings:
- prod-server (Node): restore post-middleware public-directory static file
serving (original step 5b). The adapter now supplies a serveStaticFile dep
that the pipeline calls after middleware (so middleware can intercept) and
before rewrites. Without it, public/ files (favicon.ico, robots.txt, etc.)
fell through to the renderer and 404'd. Adds a {type:"handled"} result for
callbacks that write their own output.
- prod-server (Node): restore the application/octet-stream content-type default
for API responses. The unified response path collapsed API and page fallbacks
to text/html; the pipeline now tags API results with isApiResponse so the Node
adapter picks octet-stream (arbitrary data) over text/html (content-sniffing
hazard). [bonk finding 1]
- index.ts (dev): gate the staged-header/req-header flushes behind a pages-api
match, mirroring the original 'if (apiMatch)' guard. The unconditional
flushRequestHeaders() wiped req.headers (incl. VINEXT_MW_CTX_HEADER) on the
api-miss -> next() path, dropping middleware context for app/api/* routes in
hybrid app+pages dev mode.
- pipeline: note {type:"next"} is reserved/unused [bonk finding 2]; document
the intentional external-proxy merge asymmetry on the bare proxyExternal
returns [bonk finding 3].
Adds 5 pipeline tests (serveStaticFile/handled ordering, isApiResponse tag).
* fix(pages-router): forward staged headers to worker renderPage; gate data-request defer
Two divergences found in code review of the extraction:
- deploy.ts (worker): the renderPage adapter dropped the pipeline's 4th
stagedHeaders arg, passing undefined for the SSR renderer's middlewareHeaders
param — so middleware-set CSP nonces were never applied to rendered HTML on
the worker, unlike the prod path. Widen the wrapper to forward stagedHeaders.
- pipeline: shouldDeferErrorPageOnMiss only gated on isDataReq, which the worker
never sets (it doesn't normalize /_next/data paths). The pre-refactor worker
gated on the x-nextjs-data header (isDataRequest), so a data-request miss now
wrongly deferred + ran fallback rewrites + re-rendered. Gate on both signals;
no-op for Node/dev where a data request already has isDataReq=true.
Updates the after-deploy assertion to the new call site and adds a pipeline
test for the data-request defer gate.
* fix(pages-router): address code-review findings (content-type, redirect query, dedup)
- prod content-type: passthrough responses (middleware short-circuits, external
proxies) no longer get a text/html default injected. Generalize the response
tag to defaultContentType (render -> text/html, api -> octet-stream, passthrough
-> unset); the Node adapter sends untagged responses verbatim via sendWebResponse,
matching pre-refactor behavior.
- redirect query: config-redirect Location now uses the raw req.url query (via a
new rawSearch dep supplied by the Node prod + dev adapters) instead of
new URL().search, which re-encoded chars and truncated at a literal '#'. The
worker keeps url.search (it only ever had a Web Request). Restores the original
prod/dev behavior.
- efficiency: matchPageRoute no longer runs twice per request on the common
no-afterFiles-rewrite path; reuse the Step 12 match unless afterFiles changed
the pathname.
- dedup: collapse prod-server's mergeWebResponse into the canonical mergeHeaders
(worker-utils) via delegation, removing the duplicate implementation and the
now-orphaned hasHeader/stripHeaders helpers. Existing mergeWebResponse tests
validate the delegation.
Updates pipeline tests for defaultContentType and adds a passthrough-verbatim
assertion. 36 pipeline + 309 features + 276 pages-router + 245 deploy + 9
after-deploy + 112 routing pass; vp check clean.
---------
Co-authored-by: James <james@eli.cx>
220 lines
8.2 KiB
TypeScript
220 lines
8.2 KiB
TypeScript
/**
|
|
* Deploy-mode tests for `next/after` (`unstable_after`).
|
|
*
|
|
* Verify that the per-request Cloudflare Workers `ExecutionContext` is wired
|
|
* through to the `after()` shim so callbacks are kept alive past the response
|
|
* via `ctx.waitUntil()`.
|
|
*
|
|
* Ported behavior tests from:
|
|
* https://github.com/vercel/next.js/blob/canary/test/e2e/app-dir/next-after-app-deploy/index.test.ts
|
|
*
|
|
* Issue: https://github.com/cloudflare/vinext/issues/1365
|
|
*/
|
|
import { describe, expect, it } from "vite-plus/test";
|
|
import {
|
|
generateAppRouterWorkerEntry,
|
|
generatePagesRouterWorkerEntry,
|
|
} from "../packages/vinext/src/deploy.js";
|
|
|
|
type ExecutionContextLike = {
|
|
waitUntil(promise: Promise<unknown>): void;
|
|
};
|
|
|
|
function createMockCtx(): ExecutionContextLike & { promises: Promise<unknown>[] } {
|
|
const promises: Promise<unknown>[] = [];
|
|
return {
|
|
promises,
|
|
waitUntil(promise: Promise<unknown>) {
|
|
promises.push(promise);
|
|
},
|
|
};
|
|
}
|
|
|
|
describe("after() in deploy mode — ctx.waitUntil wiring", () => {
|
|
it("after() forwards its task to ctx.waitUntil when an ExecutionContext is in scope", async () => {
|
|
const { after } = await import("../packages/vinext/src/shims/server.js");
|
|
const { runWithExecutionContext } =
|
|
await import("../packages/vinext/src/shims/request-context.js");
|
|
|
|
const ctx = createMockCtx();
|
|
let observedSideEffect = false;
|
|
|
|
await runWithExecutionContext(ctx, () => {
|
|
after(async () => {
|
|
// Simulate a real after() workload (e.g. revalidate, analytics) that
|
|
// resolves after the response would have been sent.
|
|
await Promise.resolve();
|
|
observedSideEffect = true;
|
|
});
|
|
});
|
|
|
|
// ctx.waitUntil must be called synchronously inside after() so the
|
|
// Workers runtime knows to keep the isolate alive.
|
|
expect(ctx.promises).toHaveLength(1);
|
|
|
|
// Awaiting the queued promise mirrors what the Workers runtime does
|
|
// after the response is sent. The callback must complete.
|
|
await ctx.promises[0];
|
|
expect(observedSideEffect).toBe(true);
|
|
});
|
|
|
|
it("after() preserves ctx across async hops simulating the App Router request lifecycle", async () => {
|
|
const { after } = await import("../packages/vinext/src/shims/server.js");
|
|
const { runWithExecutionContext, getRequestExecutionContext } =
|
|
await import("../packages/vinext/src/shims/request-context.js");
|
|
|
|
const ctx = createMockCtx();
|
|
let observedCtxInsideCallback: unknown = "not-run";
|
|
|
|
await runWithExecutionContext(ctx, async () => {
|
|
// Simulate the chain of async work that happens between worker entry
|
|
// and a user component / route handler calling after().
|
|
await Promise.resolve();
|
|
await new Promise<void>((resolve) => setTimeout(resolve, 1));
|
|
// The ALS must still surface ctx here — this is what after() relies on.
|
|
expect(getRequestExecutionContext()).toBe(ctx);
|
|
after(() => {
|
|
observedCtxInsideCallback = "ran";
|
|
});
|
|
});
|
|
|
|
expect(ctx.promises).toHaveLength(1);
|
|
await ctx.promises[0];
|
|
expect(observedCtxInsideCallback).toBe("ran");
|
|
});
|
|
|
|
it("after() inside a unified request scope still calls ctx.waitUntil", async () => {
|
|
// The unified request context replaces the standalone executionContext ALS
|
|
// for the duration of an App Router request. Verify after() still reads
|
|
// the ctx via the unified scope so server components / route handlers
|
|
// can schedule deferred work.
|
|
const { after } = await import("../packages/vinext/src/shims/server.js");
|
|
const { createRequestContext, runWithRequestContext } =
|
|
await import("../packages/vinext/src/shims/unified-request-context.js");
|
|
|
|
const ctx = createMockCtx();
|
|
let ran = false;
|
|
|
|
await runWithRequestContext(createRequestContext({ executionContext: ctx }), async () => {
|
|
after(async () => {
|
|
await Promise.resolve();
|
|
ran = true;
|
|
});
|
|
});
|
|
|
|
expect(ctx.promises).toHaveLength(1);
|
|
await ctx.promises[0];
|
|
expect(ran).toBe(true);
|
|
});
|
|
|
|
it("App Router worker entry wraps the RSC handler with runWithExecutionContext when ctx is provided", async () => {
|
|
// Verify the default vinext/server/app-router-entry hooks ctx.waitUntil
|
|
// before calling the RSC handler so after() callbacks survive the response.
|
|
// We can't import the module directly (it pulls in a virtual RSC entry
|
|
// that's resolved at build time), so we assert its source structure
|
|
// alongside the runtime behavior of runWithExecutionContext.
|
|
const { readFile } = await import("node:fs/promises");
|
|
const { resolve } = await import("node:path");
|
|
const src = await readFile(
|
|
resolve(import.meta.dirname, "../packages/vinext/src/server/app-router-entry.ts"),
|
|
"utf8",
|
|
);
|
|
|
|
expect(src).toContain("runWithExecutionContext(ctx, handleFn) : handleFn()");
|
|
|
|
// Sanity-check the underlying primitive: with a ctx in scope,
|
|
// getRequestExecutionContext() must surface it for after().
|
|
const { runWithExecutionContext, getRequestExecutionContext } =
|
|
await import("../packages/vinext/src/shims/request-context.js");
|
|
const ctx = createMockCtx();
|
|
let observedCtx: unknown = "missing";
|
|
void runWithExecutionContext(ctx, () => {
|
|
observedCtx = getRequestExecutionContext();
|
|
});
|
|
expect(observedCtx).toBe(ctx);
|
|
});
|
|
});
|
|
|
|
describe("after() in deploy mode — Pages Router worker entry", () => {
|
|
it("forwards ctx to handleApiRoute so api routes can call after()", () => {
|
|
// Regression for #1365: handleApiRoute previously ignored ctx, leaving
|
|
// after() inside Pages Router api routes without a way to call
|
|
// ctx.waitUntil(). The generated worker entry must thread ctx through.
|
|
//
|
|
// After #1336 item 3 the dispatch URL is `apiLookupUrl` (the locale-
|
|
// stripped form of `resolvedUrl`), but `ctx` is still threaded through.
|
|
const content = generatePagesRouterWorkerEntry();
|
|
expect(content).toContain("handleApiRoute(req, apiUrl, ctx)");
|
|
});
|
|
|
|
it("forwards ctx and staged middleware headers to renderPage so page renders can call after() and apply CSP nonces", () => {
|
|
const content = generatePagesRouterWorkerEntry();
|
|
expect(content).toContain("renderPage(req, resolvedUrl, null, ctx, stagedHeaders, options)");
|
|
});
|
|
});
|
|
|
|
describe("after() in deploy mode — App Router worker entry", () => {
|
|
it("delegates to handler.fetch with ctx so vinext/server/app-router-entry can wrap with runWithExecutionContext", () => {
|
|
const content = generateAppRouterWorkerEntry();
|
|
expect(content).toContain("handler.fetch(request, env, ctx)");
|
|
});
|
|
});
|
|
|
|
describe("after() in deploy mode — Pages Router API handler", () => {
|
|
it("handlePagesApiRoute wraps the user handler in runWithExecutionContext when ctx is provided", async () => {
|
|
const { handlePagesApiRoute } =
|
|
await import("../packages/vinext/src/server/pages-api-route.js");
|
|
const { getRequestExecutionContext } =
|
|
await import("../packages/vinext/src/shims/request-context.js");
|
|
|
|
const ctx = createMockCtx();
|
|
let observedCtx: unknown = "missing";
|
|
|
|
const route = {
|
|
pattern: "/api/test",
|
|
module: {
|
|
async default(_req: unknown, res: { end: (body?: string) => void; statusCode: number }) {
|
|
observedCtx = getRequestExecutionContext();
|
|
res.statusCode = 200;
|
|
res.end("ok");
|
|
},
|
|
},
|
|
};
|
|
|
|
await handlePagesApiRoute({
|
|
match: { params: {}, route },
|
|
request: new Request("https://example.test/api/test"),
|
|
url: "/api/test",
|
|
ctx,
|
|
});
|
|
|
|
expect(observedCtx).toBe(ctx);
|
|
});
|
|
|
|
it("handlePagesApiRoute works without ctx (Node dev parity)", async () => {
|
|
const { handlePagesApiRoute } =
|
|
await import("../packages/vinext/src/server/pages-api-route.js");
|
|
|
|
let ran = false;
|
|
const route = {
|
|
pattern: "/api/test",
|
|
module: {
|
|
default(_req: unknown, res: { end: (body?: string) => void; statusCode: number }) {
|
|
ran = true;
|
|
res.statusCode = 200;
|
|
res.end("ok");
|
|
},
|
|
},
|
|
};
|
|
|
|
const response = await handlePagesApiRoute({
|
|
match: { params: {}, route },
|
|
request: new Request("https://example.test/api/test"),
|
|
url: "/api/test",
|
|
});
|
|
|
|
expect(ran).toBe(true);
|
|
expect(response.status).toBe(200);
|
|
});
|
|
});
|