1/**
2 * Enforces the deploy-target decoupling: the framework-generic half of this
3 * package (everything reachable from `src/index.ts`) must contain ZERO
4 * Cloudflare imports — no `@alchemy.run/cloudflare-runtime` package, no
5 * `cloudflare:` module ids, no `@cloudflare/*` types, and no reach into the
6 * Cloudflare target module (`src/cloudflare.ts`), the integration fork, or
7 * the vendored runtime. All of that lives exclusively behind the
8 * `@alchemy.run/frontend-frameworks/astro/cloudflare` subpath (and `./source`, which is the
9 * alchemy *Cloudflare Worker* source provider by definition).
10 */
11import * as NodeFsPromises from "node:fs/promises";
12import * as NodePath from "node:path";
13import { describe, expect, it } from "vitest";
14
15const SRC = NodePath.resolve(import.meta.dirname, "..");
16
17/** Import/export specifiers of a TS module (static, type-only, and dynamic). */
18const collectSpecifiers = (source: string): Array<string> => {
19 const specifiers: Array<string> = [];
20 const patterns = [
21 /(?:import|export)\s[^"']*?from\s*["']([^"']+)["']/g,
22 /import\s*["']([^"']+)["']/g,
23 /import\s*\(\s*["']([^"']+)["']\s*\)/g,
24 ];
25 for (const pattern of patterns) {
26 for (const match of source.matchAll(pattern)) {
27 specifiers.push(match[1]!);
28 }
29 }
30 return specifiers;
31};
32
33/** Transitively walk relative imports starting from the given entry module. */
34const walkModuleGraph = async (
35 entry: string,
36): Promise<Map<string, Array<string>>> => {
37 const graph = new Map<string, Array<string>>();
38 const queue = [entry];
39 while (queue.length > 0) {
40 const file = queue.pop()!;
41 if (graph.has(file)) continue;
42 const source = await NodeFsPromises.readFile(file, "utf8");
43 const specifiers = collectSpecifiers(source);
44 graph.set(file, specifiers);
45 for (const specifier of specifiers) {
46 if (!specifier.startsWith(".")) continue;
47 queue.push(NodePath.resolve(NodePath.dirname(file), specifier));
48 }
49 }
50 return graph;
51};
52
53describe("framework-generic core is cloudflare-free", () => {
54 it("no module reachable from src/index.ts imports anything cloudflare", async () => {
55 const graph = await walkModuleGraph(NodePath.join(SRC, "index.ts"));
56
57 // The walk must have actually covered the core modules.
58 const reached = [...graph.keys()].map((file) =>
59 NodePath.relative(SRC, file),
60 );
61 expect(reached).toContain("index.ts");
62 expect(reached).toContain("Astro.ts");
63 expect(reached).toContain("Target.ts");
64 expect(reached).toContain("environments.ts");
65
66 for (const [file, specifiers] of graph) {
67 const relative = NodePath.relative(SRC, file);
68
69 // The cloudflare halves must be unreachable from the core.
70 expect(
71 relative,
72 `core reaches cloudflare module ${relative}`,
73 ).not.toMatch(
74 /^(cloudflare\.ts|integration\.ts|config-plugin\.ts|prerender-middleware\.ts|source\.ts|runtime[/\\])/,
75 );
76
77 for (const specifier of specifiers) {
78 // The package name itself is Cloudflare-specific, so allow its generic
79 // `/core` export while rejecting actual platform runtime modules and
80 // vendor types — including type-only imports.
81 expect(specifier, `${relative} imports ${specifier}`).not.toMatch(
82 /^(?:@alchemy\.run\/cloudflare-runtime(?:\/|$)|@cloudflare\/|cloudflare:)/,
83 );
84 }
85 }
86 });
87
88 it("the cloudflare target module is self-contained under the ./cloudflare subpath", async () => {
89 // Sanity check the inverse direction: the target module exists and is the
90 // only public seam re-exporting the integration fork.
91 const index = await NodeFsPromises.readFile(
92 NodePath.join(SRC, "index.ts"),
93 "utf8",
94 );
95 expect(index).not.toContain("./integration.ts");
96 const cloudflare = await NodeFsPromises.readFile(
97 NodePath.join(SRC, "cloudflare.ts"),
98 "utf8",
99 );
100 expect(cloudflare).toContain("./integration.ts");
101 });
102});