1import { CloudflareEnvironment } from "@/Cloudflare/CloudflareEnvironment";
2import * as Cloudflare from "@/Cloudflare/index.ts";
3import { isLocalId } from "@/Cloudflare/LocalRuntime";
4import * as Alchemy from "@/index.ts";
5import * as Test from "@/Test/Alchemy";
6import * as kv from "@distilled.cloud/cloudflare/kv";
7import { describe, expect } from "alchemy-test";
8import * as Effect from "effect/Effect";
9import * as FileSystem from "effect/FileSystem";
10import * as Path from "effect/Path";
11import { MinimumLogLevel } from "effect/References";
12import * as Schedule from "effect/Schedule";
13import * as Stream from "effect/Stream";
14import * as HttpClient from "effect/http/HttpClient";
15import * as HttpClientRequest from "effect/http/HttpClientRequest";
16import * as pathe from "pathe";
17import { cloneFixture } from "../Utils/Fixture.ts";
18import { expectUrlContains } from "../Utils/Http.ts";
19
20const { test } = Test.make({ providers: Cloudflare.providers(), dev: true });
21
22const logLevel = Effect.provideService(
23 MinimumLogLevel,
24 process.env.DEBUG ? "Debug" : "Info",
25);
26
27const fixtureDir = pathe.resolve(import.meta.dirname, "fixtures", "waku-app");
28const tempRoot = pathe.resolve(import.meta.dirname, "../../../.tmp");
29
30const fixtureEntries = [
31 ".gitignore",
32 "package.json",
33 "tsconfig.json",
34 "public",
35 "src",
36];
37
38/**
39 * Execute `request` until it answers 200 with a JSON body — the dev server
40 * compiles routes lazily and briefly answers non-200 mid-rebuild.
41 */
42const requestJsonReady = <T>(request: HttpClientRequest.HttpClientRequest) =>
43 Effect.gen(function* () {
44 const client = yield* HttpClient.HttpClient;
45 return yield* client.execute(request).pipe(
46 Effect.flatMap((res) =>
47 res.status === 200
48 ? Effect.flatMap(res.text, (body) =>
49 Effect.try({
50 try: () => JSON.parse(body) as T,
51 catch: () => new Error(`non-json body: ${body}`),
52 }),
53 )
54 : Effect.flatMap(res.text, (body) =>
55 Effect.fail(
56 new Error(
57 `dev server not ready: ${res.status} ${body.slice(0, 300)}`,
58 ),
59 ),
60 ),
61 ),
62 Effect.retry({
63 schedule: Schedule.max([
64 Schedule.min([
65 Schedule.exponential("500 millis"),
66 Schedule.spaced("2 seconds"),
67 ]),
68 Schedule.recurs(15),
69 ]),
70 }),
71 );
72 });
73
74/** PUT then GET `key` through the fixture's `/api/kv` route. */
75const kvRoundTrip = Effect.fn(function* (
76 baseUrl: string,
77 key: string,
78 value: string,
79) {
80 yield* requestJsonReady<{ ok: boolean }>(
81 HttpClientRequest.put(`${baseUrl}/api/kv`).pipe(
82 HttpClientRequest.bodyJsonUnsafe({ key, value }),
83 ),
84 );
85 return yield* requestJsonReady<{ key: string; value: string | null }>(
86 HttpClientRequest.get(`${baseUrl}/api/kv?key=${encodeURIComponent(key)}`),
87 );
88});
89
90/**
91 * Under `alchemy dev` the whole site runs locally: the KV Namespace is
92 * emulated by the local provider (a `dev:` id, no cloud API calls) and
93 * waku's own dev server serves the app behind the alchemy dev proxy, with
94 * the `kv_namespace` binding lowered onto the local workerd simulator.
95 */
96// Concurrent like the other Website suites: the Waku build runs in a
97// child process with cwd = project root (core/BuildChild.ts), and the
98// dev server only chdirs briefly inside the RPC sidecar during startup.
99describe.concurrent(
100 "Waku dev",
101 {
102 tags: [
103 "provider:cloudflare",
104 "provider:cloudflare:kv",
105 "provider:cloudflare:website",
106 ],
107 },
108 () => {
109 test.provider(
110 "Waku dev: local dev server renders RSC SSR with bindings, local KV, and HMR",
111 (stack) =>
112 Effect.gen(function* () {
113 const fs = yield* FileSystem.FileSystem;
114 const path = yield* Path.Path;
115
116 yield* stack.destroy();
117
118 const rootDir = yield* cloneFixture(fixtureDir, {
119 prefix: "alchemy-waku-dev-",
120 tempRoot,
121 entries: fixtureEntries,
122 });
123
124 const bindingMarker = "waku-dev-binding-marker";
125
126 const { site, siteKv } = yield* stack.deploy(
127 Effect.gen(function* () {
128 const siteKv = yield* Cloudflare.KV.Namespace("WakuLocalKV");
129 const site = yield* Cloudflare.Website.Waku("WakuLocal", {
130 rootDir,
131 dev: { port: 0 },
132 memo: {
133 include: [
134 "src/**",
135 "public/**",
136 "package.json",
137 "tsconfig.json",
138 ],
139 },
140 env: {
141 MESSAGE: bindingMarker,
142 SITE_KV: siteKv,
143 },
144 });
145 return { site, siteKv };
146 }),
147 );
148
149 // Local identity: the url points at the alchemy dev proxy — no
150 // cloud Worker exists — and the namespace id carries the `dev:`
151 // marker, proof the KV provider never called the cloud.
152 expect(site.url).toBeDefined();
153 expect(site.url).toMatch(/^http:\/\/localhost:\d+/);
154 expect(isLocalId(siteKv.namespaceId)).toBe(true);
155
156 // Dynamic RSC page rendered by waku's dev server (rsc environment
157 // runs in workerd behind the proxy). Waku's cold first compile
158 // (RSC + vite dep optimization) can exceed 120s when the whole
159 // suite's builds and dev servers contend for the CPU.
160 yield* expectUrlContains(`${site.url!}/`, "WAKU_PAGE_MARKER", {
161 timeout: "180 seconds",
162 label: "waku dev dynamic home page",
163 });
164
165 // The page reads the `MESSAGE` binding from `cloudflare:workers`
166 // env — proves alchemy-managed bindings reach the dev RSC server.
167 yield* expectUrlContains(
168 `${site.url!}/`,
169 `MESSAGE=${bindingMarker}`,
170 {
171 timeout: "60 seconds",
172 label: "waku dev env binding in SSR output",
173 },
174 );
175
176 // Static asset from `public/` through the dev server.
177 yield* expectUrlContains(
178 `${site.url!}/hello.txt`,
179 "hello from public/",
180 {
181 timeout: "60 seconds",
182 label: "waku dev static asset",
183 },
184 );
185
186 // The KV binding round-trips through the `/api/kv` route against the
187 // local simulator.
188 const roundTrip = yield* kvRoundTrip(
189 site.url!,
190 "waku-dev-key",
191 "kv-dev-value",
192 );
193 expect(roundTrip.value).toBe("kv-dev-value");
194
195 // ── HMR: edit the page in place; the dev server rebuilds and serves
196 // the new marker at the same URL without a redeploy ─────────────────
197 const indexPath = path.join(rootDir, "src/pages/index.tsx");
198 const index = yield* fs.readFileString(indexPath);
199 yield* fs.writeFileString(
200 indexPath,
201 index.replace("WAKU_PAGE_MARKER", "WAKU_PAGE_MARKER_V2"),
202 );
203
204 // Bounded poll; transient non-200s mid-rebuild are retried inside
205 // expectUrlContains' budget.
206 yield* expectUrlContains(`${site.url!}/`, "WAKU_PAGE_MARKER_V2", {
207 timeout: "120 seconds",
208 label: "waku dev page after HMR edit",
209 });
210
211 // The KV binding survives the rebuild: the previous write is still
212 // readable and new writes land.
213 const afterHmr = yield* kvRoundTrip(
214 site.url!,
215 "waku-dev-key-2",
216 "kv-dev-value-2",
217 );
218 expect(afterHmr.value).toBe("kv-dev-value-2");
219 const stillThere = yield* requestJsonReady<{ value: string | null }>(
220 HttpClientRequest.get(`${site.url!}/api/kv?key=waku-dev-key`),
221 );
222 expect(stillThere.value).toBe("kv-dev-value");
223
224 yield* stack.destroy();
225 }).pipe(logLevel),
226 { tags: ["local"], timeout: 300_000 },
227 );
228
229 /**
230 * `Alchemy.remote()` opts the namespace OUT of local emulation: even under
231 * `alchemy dev` it is created on real Cloudflare and the dev worker's
232 * binding proxies to it remotely. Out-of-band reads through the cloud API
233 * prove the local site's writes landed in the real namespace, and destroy
234 * (stamped-mode delete path) removes it from the cloud.
235 */
236 test.provider(
237 "Waku dev: Alchemy.remote() KV namespace runs live behind the local site",
238 (stack) =>
239 Effect.gen(function* () {
240 yield* stack.destroy();
241
242 const rootDir = yield* cloneFixture(fixtureDir, {
243 prefix: "alchemy-waku-dev-remote-",
244 tempRoot,
245 entries: fixtureEntries,
246 });
247
248 const { site, liveKv } = yield* stack.deploy(
249 Effect.gen(function* () {
250 const liveKv = yield* Cloudflare.KV.Namespace(
251 "WakuRemoteKV",
252 ).pipe(Alchemy.remote());
253 const site = yield* Cloudflare.Website.Waku("WakuLocalRemoteKV", {
254 rootDir,
255 dev: { port: 0 },
256 memo: {
257 include: [
258 "src/**",
259 "public/**",
260 "package.json",
261 "tsconfig.json",
262 ],
263 },
264 env: {
265 MESSAGE: "waku-dev-remote-marker",
266 SITE_KV: liveKv,
267 },
268 });
269 return { site, liveKv };
270 }),
271 );
272
273 // The site is local, the namespace is REAL: a non-`dev:` id proves
274 // the live provider ran even in a dev deploy.
275 expect(site.url).toMatch(/^http:\/\/localhost:\d+/);
276 expect(isLocalId(liveKv.namespaceId)).toBe(false);
277
278 // Write through the locally-served site into the remote-proxied
279 // binding.
280 const roundTrip = yield* kvRoundTrip(
281 site.url!,
282 "waku-remote-key",
283 "kv-remote-value",
284 );
285 expect(roundTrip.value).toBe("kv-remote-value");
286
287 // Out-of-band: the write is visible through the cloud API — the
288 // remote-proxied binding really hit the live namespace.
289 const { accountId } = yield* yield* CloudflareEnvironment;
290 const rawValue = yield* kv
291 .getNamespaceValue({
292 accountId,
293 namespaceId: liveKv.namespaceId,
294 keyName: "waku-remote-key",
295 })
296 .pipe(
297 Effect.flatMap((res) =>
298 Effect.tryPromise(() =>
299 new Response(
300 Stream.toReadableStream(res.body) as BodyInit,
301 ).text(),
302 ),
303 ),
304 // The KV REST read can lag the proxied write — propagation is
305 // documented at up to 60s and runs longer under account load
306 // (a fully concurrent suite), so budget ~91s: exponential ramp
307 // capped at 10s spacing.
308 Effect.retry({
309 while: (e) => e._tag === "KeyNotFound",
310 schedule: Schedule.min([
311 Schedule.exponential("1 second", 1.5),
312 Schedule.spaced("10 seconds"),
313 ]),
314 times: 13,
315 }),
316 );
317 expect(rawValue).toBe("kv-remote-value");
318
319 yield* stack.destroy();
320
321 // The live namespace was deleted from the cloud on destroy (its
322 // state row is stamped live, so the live provider handles the delete
323 // even in a dev run).
324 const gone = yield* kv
325 .getNamespace({ accountId, namespaceId: liveKv.namespaceId })
326 .pipe(
327 Effect.as(false),
328 Effect.catchTag("NamespaceNotFound", () => Effect.succeed(true)),
329 );
330 expect(gone).toBe(true);
331 }).pipe(logLevel),
332 { tags: ["live"], timeout: 300_000 },
333 );
334 },
335);