mirror of
https://github.com/cloudflare/vinext.git
synced 2026-09-14 19:04:59 +08:00
b06a1a1d04
* fix(i18n): strip locale prefix for API routes Mirror Next.js's behaviour where a locale-prefixed request path like `/fr/api/ok` is matched against `pages/api/ok` after stripping the locale segment. Previously the locale prefix was kept through the `/api/` startsWith check, so any locale-prefixed API URL 404'd. Add a small helper `stripI18nLocaleForApiRoute(url, i18nConfig)` in `server/pages-i18n.ts` that mirrors Next.js's `normalizeLocalePath().pathname` (see packages/next/src/shared/lib/i18n/normalize-locale-path.ts) and wire it into all three Pages Router request pipelines so dev/prod parity holds: - dev plugin: `packages/vinext/src/index.ts` - prod (Node): `packages/vinext/src/server/prod-server.ts` - prod (Cloudflare worker entry): `packages/vinext/src/deploy.ts` App Router is intentionally unchanged: Next.js does not support the `i18n` config field with App Router, so App Router route handlers under `app/*/route.ts` never see a locale prefix to strip. Refs #1336 (item 3). Items 2 (sticky locale on client navigations) and 4 (default-locale path normalisation) are being handled in parallel PRs. Tests: - Unit tests for `stripI18nLocaleForApiRoute` covering basic, regional (`nl-NL`), query-preserving, unconfigured-prefix, and null-config cases (`tests/pages-i18n.test.ts`). - Dev integration tests (`tests/features.test.ts`) and prod integration tests (`tests/pages-i18n-prod.test.ts`) that GET `/fr/api/ok` and assert a 200 "ok" response against the existing `pages-i18n-domains` fixture, ported from Next.js's `test/e2e/middleware-redirects/test/index.test.ts` ("should redirect to api route with locale"). - Updates to `tests/deploy.test.ts` assertions for the generated worker-entry template strings. * test(after-deploy): update worker-entry assertion after locale strip The Pages Router worker entry now calls handleApiRoute(request, apiLookupUrl, ctx) instead of using resolvedUrl directly, so this generated-code assertion needs the new variable name. Refs #1336 (item 3).
220 lines
8.1 KiB
TypeScript
220 lines
8.1 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(request, apiLookupUrl, ctx)");
|
|
});
|
|
|
|
it("forwards ctx to renderPage so page renders can call after()", () => {
|
|
const content = generatePagesRouterWorkerEntry();
|
|
expect(content).toContain("renderPage(request, resolvedUrl, null, ctx)");
|
|
});
|
|
});
|
|
|
|
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);
|
|
});
|
|
});
|