| 1 | /** |
| 2 | * Enforces the deploy-target decoupling contract: the framework-generic |
| 3 | * modules (`SvelteKit.ts`, `index.ts`) must contain ZERO Cloudflare-specific |
| 4 | * imports. Everything platform-specific (the adapter fork, the worker shim, |
| 5 | * the rolldown finishing pass) lives behind the `./cloudflare` subpath |
| 6 | * module. |
| 7 | */ |
| 8 | import * as NodeFs from "node:fs"; |
| 9 | import * as NodePath from "node:path"; |
| 10 | import { fileURLToPath } from "node:url"; |
| 11 | import { describe, expect, it } from "vitest"; |
| 12 | |
| 13 | const srcDir = NodePath.resolve( |
| 14 | NodePath.dirname(fileURLToPath(import.meta.url)), |
| 15 | "..", |
| 16 | ); |
| 17 | |
| 18 | /** Every static import / re-export specifier in a module's source. */ |
| 19 | const importSpecifiers = (file: string): Array<string> => { |
| 20 | const source = NodeFs.readFileSync(NodePath.join(srcDir, file), "utf8"); |
| 21 | const specifiers: Array<string> = []; |
| 22 | // `import ... from "x"`, `export ... from "x"`, and bare `import "x"`. |
| 23 | const pattern = |
| 24 | /(?:^|\n)\s*(?:import|export)\s+(?:[^"'\n]*?from\s*)?["']([^"']+)["']/g; |
| 25 | for (const match of source.matchAll(pattern)) { |
| 26 | specifiers.push(match[1] as string); |
| 27 | } |
| 28 | return specifiers; |
| 29 | }; |
| 30 | |
| 31 | // Cloudflare-specific dependency surface: platform packages, the bundler for |
| 32 | // the workerd finishing pass, and the in-package cloudflare modules. |
| 33 | const FORBIDDEN = [ |
| 34 | /^@alchemy\.run\/cloudflare-runtime(?:\/|$)/, |
| 35 | /^@cloudflare\//, |
| 36 | /^cloudflare:/, |
| 37 | /^\.\/cloudflare(?:\.ts)?$/, |
| 38 | /^rolldown$/, |
| 39 | /\/Adapter(\.ts)?$/, |
| 40 | /\.\/Adapter/, |
| 41 | /\.\/WorkerShim/, |
| 42 | ]; |
| 43 | |
| 44 | const FRAMEWORK_GENERIC_MODULES = ["SvelteKit.ts", "UserConfig.ts", "index.ts"]; |
| 45 | |
| 46 | describe("target decoupling", () => { |
| 47 | for (const file of FRAMEWORK_GENERIC_MODULES) { |
| 48 | it(`${file} imports nothing Cloudflare-specific`, () => { |
| 49 | const specifiers = importSpecifiers(file); |
| 50 | expect(specifiers.length).toBeGreaterThan(0); |
| 51 | const offending = specifiers.filter((specifier) => |
| 52 | FORBIDDEN.some((pattern) => pattern.test(specifier)), |
| 53 | ); |
| 54 | expect(offending).toEqual([]); |
| 55 | }); |
| 56 | } |
| 57 | |
| 58 | it("platform target modules are the only src modules importing the adapter and rolldown", () => { |
| 59 | const files = NodeFs.readdirSync(srcDir).filter((file) => |
| 60 | file.endsWith(".ts"), |
| 61 | ); |
| 62 | for (const file of files) { |
| 63 | if ( |
| 64 | file === "cloudflare.ts" || |
| 65 | file === "Adapter.ts" || |
| 66 | file === "WorkerShim.ts" |
| 67 | ) |
| 68 | continue; |
| 69 | // `source.ts` is Cloudflare-specific by contract (it implements |
| 70 | // alchemy's Cloudflare Worker source) — it may import ./cloudflare.ts |
| 71 | // but must not reach around it to the adapter/bundler internals. |
| 72 | // `aws.ts` is the AWS target module — platform-specific by the same |
| 73 | // contract: it bundles the Lambda server with rolldown, but must |
| 74 | // stay decoupled from the Cloudflare adapter internals. |
| 75 | const allowRolldown = file === "aws.ts" || file === "node.ts"; |
| 76 | const specifiers = importSpecifiers(file); |
| 77 | const offending = specifiers.filter( |
| 78 | (specifier) => |
| 79 | (!allowRolldown && /^rolldown$/.test(specifier)) || |
| 80 | /\.\/Adapter/.test(specifier) || |
| 81 | /\.\/WorkerShim/.test(specifier) || |
| 82 | /@alchemy\.run\/cloudflare-runtime/.test(specifier), |
| 83 | ); |
| 84 | expect( |
| 85 | offending, |
| 86 | `${file} must not import ${offending.join(", ")}`, |
| 87 | ).toEqual([]); |
| 88 | } |
| 89 | }); |
| 90 | }); |