t3-code-android-nightly/.repos/alchemy-effect/packages/alchemy/test/Cloudflare/Website/ViteRoutePrefix.test.ts
Julius Marminge 6f9cea00ae
chore(refs): sync Effect and Alchemy references to 4.0.1 and beta.80 (#16170)
Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-05 13:22:30 -07:00

348 lines
12 KiB
TypeScript

/**
* Regression test for serving a Vite site on a zone route with a path
* prefix.
*
* Cloudflare matches static assets against the FULL request pathname
* (https://developers.cloudflare.com/workers/static-assets/routing/advanced/serving-a-subdirectory/):
* a Worker routed at `zone/prefix*` only serves an asset for
* `/prefix/index.html` if the uploaded asset manifest contains that key.
* Vite's `base` (from the app's own vite.config.ts) rewrites the URLs the
* build emits, and Alchemy keys the uploaded manifest with the same
* resolved base — both halves are required for the site to work behind
* the route. The SPA fallback shell is aliased back to `/index.html`
* (Cloudflare resolves it at the manifest root, hard-coded), pinned here
* by the deep-link probe.
*/
import { CloudflareEnvironment } from "@/Cloudflare/CloudflareEnvironment";
import * as Cloudflare from "@/Cloudflare/index.ts";
import { findZoneByName } from "@/Cloudflare/Zone/lookup";
import * as Test from "@/Test/Alchemy";
import * as dns from "@distilled.cloud/cloudflare/dns";
import * as workers from "@distilled.cloud/cloudflare/workers";
import { describe, expect } from "alchemy-test";
import * as Data from "effect/Data";
import * as Effect from "effect/Effect";
import * as FileSystem from "effect/FileSystem";
import * as Path from "effect/Path";
import { MinimumLogLevel } from "effect/References";
import * as Schedule from "effect/Schedule";
import * as Stream from "effect/Stream";
import * as pathe from "pathe";
import { cloneFixture } from "../Utils/Fixture.ts";
import { waitForWorkerToBeDeleted } from "../Utils/Worker.ts";
const { test } = Test.make({ providers: Cloudflare.providers() });
const logLevel = Effect.provideService(
MinimumLogLevel,
process.env.DEBUG ? "Debug" : "Info",
);
const spaFixtureDir = pathe.resolve(import.meta.dirname, "vite-spa-fixture");
const tempRoot = pathe.resolve(import.meta.dirname, "../../../.tmp");
const zoneName =
process.env.CLOUDFLARE_TEST_WORKER_ROUTE_ZONE_NAME ?? "alchemy-test-2.us";
// Deterministic per-user path prefix on the zone apex (never Date.now()).
const routePrefix = `alchemy-vite-route-repro-${process.env.PULL_REQUEST ?? process.env.USER}`;
const basePath = `/${routePrefix}/app`;
const routePattern = `${zoneName}${basePath}*`;
const routePageUrl = `https://${zoneName}${basePath}/`;
const marker = "vite-route-prefix-repro";
const htmlPage = `<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<title>${marker}</title>
</head>
<body>
<div id="app">${marker}</div>
<script type="module" src="/src/main.ts"></script>
</body>
</html>
`;
// Capped: an uncapped exponential over 8 recurs sums to ~2 minutes and turns
// a persistent 403 into an apparent hang.
const forbiddenRetrySchedule = Schedule.min([
Schedule.exponential("500 millis"),
Schedule.spaced("2 seconds"),
]);
const resolveZoneId = Effect.gen(function* () {
const { accountId } = yield* yield* CloudflareEnvironment;
const zone = yield* findZoneByName({ accountId, name: zoneName });
if (!zone) {
return yield* Effect.die(
new Error(`zone "${zoneName}" not found in account`),
);
}
return zone.id;
});
// Workers only run on proxied hostnames — the apex placeholder is standing
// test-zone infrastructure (see WorkerRoutes.test.ts); ensure, never delete.
const ensureApexPlaceholder = (zoneId: string) =>
Effect.gen(function* () {
const existing = yield* dns.listRecords.items({ zoneId }).pipe(
Stream.filter(
(r) => r.name === zoneName && (r.type === "A" || r.type === "AAAA"),
),
Stream.runCollect,
Effect.map((chunk) => Array.from(chunk)[0]),
Effect.retry({
while: (e) => e._tag === "Forbidden",
schedule: forbiddenRetrySchedule,
times: 8,
}),
);
if (existing) return;
yield* dns.createRecord({
zoneId,
name: zoneName,
type: "AAAA",
content: "100::",
proxied: true,
ttl: 1,
comment: "standing placeholder so Workers routes serve on the zone apex",
});
});
const purgeRoutes = (zoneId: string, ...patterns: string[]) =>
Effect.forEach(patterns, (pattern) =>
workers.listRoutes.items({ zoneId }).pipe(
Stream.filter((r) => r.pattern === pattern),
Stream.runCollect,
Effect.flatMap(
Effect.forEach((r) =>
workers
.deleteRoute({ zoneId, routeId: r.id })
.pipe(Effect.catch(() => Effect.void)),
),
),
Effect.retry({
while: (e) => e._tag === "Forbidden",
schedule: forbiddenRetrySchedule,
times: 8,
}),
),
);
class ProbeFailed extends Data.TaggedError("ProbeFailed")<{
url: string;
message: string;
}> {}
interface ProbeResult {
url: string;
status: number;
contentType: string | undefined;
body: string;
}
// One GET with cache busting; retried below until the edge stops serving
// 5xx / Cloudflare error pages / stale-deploy 404s (route + worker + asset
// propagation).
const probeOnce = (url: string) =>
Effect.tryPromise({
try: async (signal): Promise<ProbeResult> => {
const u = new URL(url);
u.searchParams.set("__alchemy_cb", String(Date.now()));
const res = await fetch(u, {
signal,
cache: "no-store",
headers: { "cache-control": "no-cache", accept: "*/*" },
});
const body = await res.text();
return {
url,
status: res.status,
contentType: res.headers.get("content-type") ?? undefined,
body,
};
},
catch: (e) =>
new ProbeFailed({
url,
message: e instanceof Error ? e.message : String(e),
}),
});
const isPropagating = (r: ProbeResult) =>
r.status !== 200 ||
r.body.includes("There is nothing here yet") ||
/Error\s+1\d{3}/i.test(r.body);
const probeStable = (url: string) =>
probeOnce(url).pipe(
Effect.repeat({
schedule: Schedule.spaced("3 seconds"),
until: (r: ProbeResult) => !isPropagating(r),
// ~90s: workers.dev/zone-route propagation slows down when many
// concurrent deploys enable fresh subdomains at once.
times: 30,
}),
Effect.retry({
while: (e) => e._tag === "ProbeFailed",
schedule: Schedule.exponential("1 second"),
times: 5,
}),
);
const excerpt = (s: string) => s.replace(/\s+/g, " ").slice(0, 200);
/**
* End-to-end "does the SPA actually work here" check: the page must serve
* the marker HTML AND the module script it references must be fetchable
* (as JavaScript) from the same host.
*/
const evaluateSite = Effect.fn(function* (pageUrl: string, label: string) {
const page = yield* probeStable(pageUrl);
yield* Effect.log(
`[${label}] page ${page.status} ${page.contentType ?? "-"} :: ${excerpt(page.body)}`,
);
const src = page.body.match(/<script[^>]+src="([^"]+)"/)?.[1];
let script: ProbeResult | undefined;
if (page.status === 200 && src) {
const scriptUrl = new URL(src, pageUrl).toString();
script = yield* probeStable(scriptUrl);
yield* Effect.log(
`[${label}] script ${scriptUrl} -> ${script.status} ${script.contentType ?? "-"} :: ${excerpt(script.body)}`,
);
}
return { page, src, script };
});
// Tests are independent (per-test scratch stacks, private fixture clones),
// so run them concurrently; suites are sequential by default.
describe.concurrent(
"ViteRoutePrefix",
{
tags: [
"provider:cloudflare",
"provider:cloudflare:dns",
"provider:cloudflare:website",
"provider:cloudflare:worker",
"provider:cloudflare:zone",
"live",
],
},
() => {
test.provider(
"Vite: base from vite.config.ts serves the site on a path-prefixed zone route",
(stack) =>
Effect.gen(function* () {
const { accountId } = yield* yield* CloudflareEnvironment;
const fs = yield* FileSystem.FileSystem;
const path = yield* Path.Path;
const zoneId = yield* resolveZoneId;
yield* stack.destroy();
yield* purgeRoutes(zoneId, routePattern);
yield* ensureApexPlaceholder(zoneId);
const rootDir = yield* cloneFixture(spaFixtureDir, {
prefix: "alchemy-vite-route-",
tempRoot,
entries: ["index.html", "package.json", "src"],
});
yield* fs.writeFileString(path.join(rootDir, "index.html"), htmlPage);
// The base lives in the app's own Vite config — the deploy adopts
// the resolved value; nothing is configured on the alchemy side.
yield* fs.writeFileString(
path.join(rootDir, "vite.config.ts"),
`import { defineConfig } from "vite";\n\nexport default defineConfig({ base: "${basePath}/" });\n`,
);
const memoInclude = [
"index.html",
"src/**",
"package.json",
"vite.config.ts",
];
let workerName: string | undefined;
yield* Effect.gen(function* () {
const site = yield* stack.deploy(
Effect.gen(function* () {
return yield* Cloudflare.Website.Vite("ViteRoutePrefix", {
rootDir,
workersDev: true,
compatibility: {
date: "2024-09-23",
flags: ["nodejs_compat"],
},
memo: { include: memoInclude },
assets: { notFoundHandling: "single-page-application" },
routes: [{ pattern: routePattern, zoneName }],
});
}),
);
workerName = site.workerName;
expect(site.url).toBeDefined();
expect(site.routes).toHaveLength(1);
expect(site.routes[0]?.pattern).toEqual(routePattern);
// Control — the same manifest serves under the prefix on
// workers.dev too (the manifest is nested, not the route).
const control = yield* evaluateSite(
`${site.url!}${basePath}/`,
"workers.dev",
);
expect(control.page.status).toBe(200);
expect(control.page.body).toContain(marker);
// Vite `base` bakes the prefix into the emitted script URL.
expect(control.src).toContain(`${basePath}/`);
expect(control.script?.status).toBe(200);
expect(control.script?.contentType).toContain("javascript");
// The point of the feature: the site works end-to-end behind the
// path-prefixed zone route — HTML serves AND the module script it
// references resolves under the prefix (inside the route).
const routed = yield* evaluateSite(routePageUrl, "zone route");
expect(routed.page.status).toBe(200);
expect(routed.page.body).toContain(marker);
expect(routed.script?.status).toBe(200);
expect(routed.script?.contentType).toContain("javascript");
// `(hydrated)` is a string literal in the fixture's client module,
// so it proves the response is the real script — a 404 here would
// be answered by the SPA fallback with the shell, which contains
// the marker but never that literal.
expect(routed.script?.body).toContain("(hydrated)");
expect(
routed.script?.url.startsWith(`https://${zoneName}${basePath}/`),
).toBe(true);
// Cloudflare's SPA fallback resolves `/index.html` at the manifest
// root, hard-coded — the shell is aliased back there so client-side
// routes under the base still boot the app.
const deep = yield* probeStable(
`https://${zoneName}${basePath}/deep/route`,
);
yield* Effect.log(
`[spa deep link] ${deep.status} ${deep.contentType ?? "-"} :: ${excerpt(deep.body)}`,
);
expect(deep.status).toBe(200);
expect(deep.body).toContain(marker);
}).pipe(
Effect.ensuring(
Effect.gen(function* () {
yield* stack.destroy().pipe(Effect.ignore);
yield* purgeRoutes(zoneId, routePattern).pipe(Effect.ignore);
if (workerName) {
yield* waitForWorkerToBeDeleted(workerName, accountId).pipe(
Effect.ignore,
);
}
}),
),
);
}).pipe(logLevel),
{ timeout: 360_000 },
);
},
);