1import * as NodeServices from "@effect/platform-node/NodeServices";
2import { expect, layer } from "alchemy-test";
3import * as Effect from "effect/Effect";
4import * as FileSystem from "effect/FileSystem";
5import * as Option from "effect/Option";
6import * as Path from "effect/Path";
7
8const subpath = "./Cloudflare/RpcWebSocketClient";
9const sourceEntry = "src/Cloudflare/Workers/RpcWebSocketClient.ts";
10const compiledEntry = "lib/Cloudflare/Workers/RpcWebSocketClient.js";
11const declarationEntry = "lib/Cloudflare/Workers/RpcWebSocketClient.d.ts";
12const fixtureDirectory =
13 "test/Cloudflare/Workers/fixtures/rpc-websocket-client";
14
15const packageInfo = Effect.gen(function* () {
16 const fs = yield* FileSystem.FileSystem;
17 const path = yield* Path.Path;
18 const root = yield* path.fromFileUrl(new URL("../../../", import.meta.url));
19 const json = yield* fs.readFileString(path.join(root, "package.json"));
20 const manifest = yield* Effect.try(
21 () => JSON.parse(json) as typeof import("../../../package.json"),
22 );
23 return { root, manifest };
24});
25
26const bundleBrowser = Effect.fn(function* (
27 root: string,
28 entry: string,
29 conditions: string[] = ["browser", "import", "default"],
30) {
31 const { rolldown } = yield* Effect.promise(() => import("rolldown"));
32 const modules = new Set<string>();
33 return yield* Effect.acquireUseRelease(
34 Effect.promise(() =>
35 rolldown({
36 cwd: root,
37 input: entry,
38 platform: "browser",
39 resolve: { conditionNames: conditions },
40 treeshake: false,
41 plugins: [
42 {
43 name: "browser-dependency-graph",
44 moduleParsed(info) {
45 modules.add(info.id);
46 for (const id of [
47 ...info.importedIds,
48 ...info.dynamicallyImportedIds,
49 ]) {
50 modules.add(id);
51 }
52 },
53 },
54 ],
55 }),
56 ),
57 (bundle) =>
58 Effect.gen(function* () {
59 const { output } = yield* Effect.promise(() =>
60 bundle.generate({ format: "esm" }),
61 );
62 const chunks = output.filter((item) => item.type === "chunk");
63 const entryChunk = chunks.find((chunk) => chunk.isEntry)!;
64 expect(entryChunk.exports).toEqual(
65 expect.arrayContaining(["BrowserClient", "clientLayer", "echo"]),
66 );
67 expect(entryChunk.code.length).toBeGreaterThan(0);
68 const emitted = new Set(chunks.map((chunk) => chunk.fileName));
69 expect(
70 chunks.flatMap((chunk) =>
71 [...chunk.imports, ...chunk.dynamicImports].filter(
72 (id) => !emitted.has(id),
73 ),
74 ),
75 ).toEqual([]);
76 return [...modules].map((id) => id.replaceAll("\\", "/"));
77 }),
78 (bundle) => Effect.promise(() => bundle.close()),
79 );
80});
81
82const assertBrowserGraph = Effect.fn(function* (
83 root: string,
84 modules: string[],
85 expectedEntry: string,
86) {
87 const path = yield* Path.Path;
88 const sourceRoot = `${path.join(root, "src").replaceAll("\\", "/")}/`;
89 const libRoot = `${path.join(root, "lib").replaceAll("\\", "/")}/`;
90 const alchemyModules = modules.filter(
91 (id) => id.startsWith(sourceRoot) || id.startsWith(libRoot),
92 );
93 expect(alchemyModules).toEqual([
94 path.join(root, expectedEntry).replaceAll("\\", "/"),
95 ]);
96 expect(modules.some((id) => id.includes("/effect/"))).toBe(true);
97 expect(
98 modules.filter(
99 (id) =>
100 /^(?:node|cloudflare|bun):/.test(id) ||
101 id.includes("/submodules/distilled/") ||
102 id.includes("/packages/cloudflare-runtime/") ||
103 /\/node_modules\/(?:@distilled\.cloud\/|@cloudflare\/|@alchemy\.run\/cloudflare-runtime\/|@effect\/platform-node(?:-shared)?\/|(?:cloudflare|workerd|wrangler|unenv)\/)/.test(
104 id,
105 ),
106 ),
107 ).toEqual([]);
108});
109
110const stagePublishedPackage = Effect.gen(function* () {
111 const fs = yield* FileSystem.FileSystem;
112 const path = yield* Path.Path;
113 const { root, manifest } = yield* packageInfo;
114 const directory = yield* fs.makeTempDirectoryScoped({
115 prefix: "alchemy-rpc-websocket-client-",
116 });
117 const packageDirectory = path.join(directory, "node_modules/alchemy");
118 yield* fs.makeDirectory(packageDirectory, { recursive: true });
119 yield* fs.writeFileString(
120 path.join(packageDirectory, "package.json"),
121 JSON.stringify({
122 name: manifest.name,
123 version: manifest.version,
124 type: manifest.type,
125 sideEffects: manifest.sideEffects,
126 exports: manifest.publishConfig.exports,
127 }),
128 );
129 for (const entry of ["src", "lib"]) {
130 yield* fs.symlink(
131 path.join(root, entry),
132 path.join(packageDirectory, entry),
133 );
134 }
135 yield* fs.symlink(
136 path.join(root, "node_modules/effect"),
137 path.join(directory, "node_modules/effect"),
138 );
139 for (const file of ["browser.ts", "rpcs.ts"]) {
140 yield* fs.copyFile(
141 path.join(root, fixtureDirectory, file),
142 path.join(directory, file),
143 );
144 }
145 return { root, directory, entry: path.join(directory, "browser.ts") };
146});
147
148layer(NodeServices.layer)("alchemy/Cloudflare/RpcWebSocketClient", (it) => {
149 it.effect(
150 "maps the public source and published browser entry points",
151 () =>
152 Effect.gen(function* () {
153 const { manifest } = yield* packageInfo;
154 expect(manifest.exports[subpath]).toBe(`./${sourceEntry}`);
155 expect(manifest.publishConfig.exports[subpath]).toEqual({
156 types: `./${declarationEntry}`,
157 bun: `./${sourceEntry}`,
158 default: `./${compiledEntry}`,
159 });
160 expect(manifest.files).toEqual(expect.arrayContaining(["src", "lib"]));
161 }),
162 {
163 tags: [
164 "unit",
165 "provider:cloudflare",
166 "provider:cloudflare:worker",
167 "local",
168 ],
169 },
170 );
171
172 it.effect(
173 "bundles the actual public subpath for a browser without server modules",
174 () =>
175 Effect.gen(function* () {
176 const path = yield* Path.Path;
177 const { root } = yield* packageInfo;
178 const modules = yield* bundleBrowser(
179 root,
180 path.join(root, fixtureDirectory, "browser.ts"),
181 );
182 yield* assertBrowserGraph(root, modules, sourceEntry);
183 }),
184 {
185 tags: [
186 "unit",
187 "provider:cloudflare",
188 "provider:cloudflare:worker",
189 "local",
190 ],
191 },
192 );
193
194 it.effect(
195 "resolves the published source condition through the public subpath",
196 () =>
197 Effect.gen(function* () {
198 const { root, directory, entry } = yield* stagePublishedPackage;
199 const modules = yield* bundleBrowser(directory, entry, [
200 "bun",
201 "browser",
202 "import",
203 "default",
204 ]);
205 yield* assertBrowserGraph(root, modules, sourceEntry);
206 }),
207 {
208 tags: [
209 "unit",
210 "provider:cloudflare",
211 "provider:cloudflare:worker",
212 "local",
213 ],
214 },
215 );
216
217 // Compiled packaging checks require a prior workspace build.
218 it.effect.skipIf(!process.env.ALCHEMY_TEST_RPC_CLIENT_LIB)(
219 "bundles the fresh published JavaScript entry for browsers",
220 () =>
221 Effect.gen(function* () {
222 const fs = yield* FileSystem.FileSystem;
223 const path = yield* Path.Path;
224 const { root, directory, entry } = yield* stagePublishedPackage;
225 const source = yield* fs.stat(path.join(root, sourceEntry));
226 for (const file of [compiledEntry, declarationEntry]) {
227 const info = yield* fs.stat(path.join(root, file));
228 expect(
229 Option.getOrThrow(info.mtime).getTime(),
230 ).toBeGreaterThanOrEqual(Option.getOrThrow(source.mtime).getTime());
231 }
232 const modules = yield* bundleBrowser(directory, entry);
233 yield* assertBrowserGraph(root, modules, compiledEntry);
234 }),
235 {
236 tags: [
237 "unit",
238 "provider:cloudflare",
239 "provider:cloudflare:worker",
240 "local",
241 ],
242 },
243 );
244});