Files
cloudflare__vinext/tests/after-deploy.test.ts
T
James Anderson b06a1a1d04 fix(i18n): strip locale prefix for API routes (#1408)
* 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).
2026-05-21 15:02:18 +01:00

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);
});
});