mirror of
https://github.com/cloudflare/vinext.git
synced 2026-09-14 19:04:59 +08:00
dc054592ea
* fix(pages-router): stream piped API responses with backpressure A handler that pipes into `res` (proxying an upstream, echoing a request body) could queue the entire stream in memory. The response bridge acknowledged every write immediately, so `write()` never returned false and a piped source was never paused, regardless of how slowly the client consumed the body. The Node production server also read API responses with `arrayBuffer()`, which held the full body in memory and deferred delivery until the source closed, so long-lived streams (e.g. proxied SSE) never flushed. Hold the write callback while the response body's queue is full and release it from the stream's pull hook. A held callback makes `write()` return false, which pauses any piped source until the consumer reads. Mark responses that are still being written when the handler settles and send them through the streaming path in the Node production server. Complete bodies (`res.json`, `res.send`, `res.end(data)`) keep the buffered path and its Content-Length behaviour. * fix(pages-router): avoid API stream backpressure deadlocks * fix(pages-router): preserve streamed API handler lifecycle * docs(pages-router): clarify API stream backpressure --------- Co-authored-by: James <james@eli.cx>
276 lines
10 KiB
TypeScript
276 lines
10 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 fs from "node:fs";
|
|
import path from "node:path";
|
|
import { readPagesRouterEntrySource } from "./worker-entry-source.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 { closeAfterResponse, createRequestContext, runWithRequestContext } =
|
|
await import("../packages/vinext/src/shims/unified-request-context.js");
|
|
|
|
const ctx = createMockCtx();
|
|
const requestContext = createRequestContext({ executionContext: ctx });
|
|
let ran = false;
|
|
|
|
await runWithRequestContext(requestContext, async () => {
|
|
after(async () => {
|
|
await Promise.resolve();
|
|
ran = true;
|
|
});
|
|
});
|
|
|
|
expect(ctx.promises).toHaveLength(1);
|
|
expect(ran).toBe(false);
|
|
await closeAfterResponse(requestContext);
|
|
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 = readPagesRouterEntrySource();
|
|
expect(content).toContain(
|
|
'handleApiRoute(req, apiUrl, ctx, new URL(req.url).origin, "worker")',
|
|
);
|
|
});
|
|
|
|
it("forwards ctx and staged middleware headers to renderPage so page renders can call after() and apply CSP nonces", () => {
|
|
const content = readPagesRouterEntrySource();
|
|
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 = fs.readFileSync(
|
|
path.resolve(import.meta.dirname, "../packages/vinext/src/server/app-router-entry.ts"),
|
|
"utf-8",
|
|
);
|
|
expect(content).toContain("runWithExecutionContext(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("keeps streaming handler completion alive through the active execution context", async () => {
|
|
const { handlePagesApiRoute } =
|
|
await import("../packages/vinext/src/server/pages-api-route.js");
|
|
const { runWithExecutionContext } =
|
|
await import("../packages/vinext/src/shims/request-context.js");
|
|
|
|
const ctx = createMockCtx();
|
|
let releaseHandler!: () => void;
|
|
const handlerGate = new Promise<void>((resolve) => {
|
|
releaseHandler = resolve;
|
|
});
|
|
let handlerSettled = false;
|
|
|
|
// The ambient form matches the hybrid App/Pages bridge, where the App
|
|
// Router owns the request scope and handlePagesApiRoute receives no ctx
|
|
// argument of its own.
|
|
const response = await runWithExecutionContext(ctx, () =>
|
|
handlePagesApiRoute({
|
|
match: {
|
|
params: {},
|
|
route: {
|
|
pattern: "/api/test",
|
|
module: {
|
|
async default(
|
|
_req: unknown,
|
|
res: { end: (body?: string) => void; statusCode: number },
|
|
) {
|
|
res.statusCode = 200;
|
|
res.end("ok");
|
|
await handlerGate;
|
|
handlerSettled = true;
|
|
},
|
|
},
|
|
},
|
|
},
|
|
request: new Request("https://example.test/api/test"),
|
|
url: "/api/test",
|
|
}),
|
|
);
|
|
|
|
await expect(response.text()).resolves.toBe("ok");
|
|
expect(handlerSettled).toBe(false);
|
|
expect(ctx.promises).toHaveLength(1);
|
|
|
|
releaseHandler();
|
|
await ctx.promises[0];
|
|
expect(handlerSettled).toBe(true);
|
|
});
|
|
|
|
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);
|
|
});
|
|
});
|