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", "nuxt-app");
28const tempRoot = pathe.resolve(import.meta.dirname, "../../../.tmp");
29
30const fixtureEntries = [
31 ".gitignore",
32 "package.json",
33 "nuxt.config.ts",
34 "app",
35 "server",
36 "public",
37];
38
39const memoInclude = [
40 "app/**",
41 "server/**",
42 "public/**",
43 "nuxt.config.ts",
44 "package.json",
45];
46
47// Tests are independent (per-test scratch stacks, private fixture clones),
48// so run them concurrently; suites are sequential by default.
49describe.concurrent(
50 "Nuxt dev",
51 {
52 tags: [
53 "provider:cloudflare",
54 "provider:cloudflare:kv",
55 "provider:cloudflare:website",
56 ],
57 },
58 () => {
59 test.provider(
60 "Nuxt dev: local dev server renders SSR with event.context.cloudflare bindings",
61 (stack) =>
62 Effect.gen(function* () {
63 const fs = yield* FileSystem.FileSystem;
64 const path = yield* Path.Path;
65
66 yield* stack.destroy();
67
68 const rootDir = yield* cloneFixture(fixtureDir, {
69 prefix: "alchemy-nuxt-dev-",
70 tempRoot,
71 entries: fixtureEntries,
72 });
73
74 const bindingMarker = "nuxt-dev-binding-marker";
75
76 const deployed = yield* stack.deploy(
77 Effect.gen(function* () {
78 const siteKv = yield* Cloudflare.KV.Namespace("NuxtDevKV");
79 const site = yield* Cloudflare.Website.Nuxt("NuxtLocal", {
80 rootDir,
81 dev: { port: 0 },
82 memo: { include: memoInclude },
83 env: {
84 TEST_BINDING: bindingMarker,
85 SITE_KV: siteKv,
86 },
87 });
88 return { site, siteKv };
89 }),
90 );
91 const site = deployed.site;
92
93 // Local identity: the url points at the alchemy dev proxy — no
94 // cloud Worker exists — and the KV namespace is emulated (a `dev:`
95 // id, proof no cloud API call ran).
96 expect(site.url).toBeDefined();
97 expect(site.url).toMatch(/^http:\/\/localhost:\d+/);
98 expect(isLocalId(deployed.siteKv.namespaceId)).toBe(true);
99
100 // SSR page rendered by nitro's dev server behind the proxy.
101 yield* expectUrlContains(`${site.url!}/`, "NUXT_PAGE_MARKER", {
102 timeout: "120 seconds",
103 label: "nuxt dev SSR home page",
104 });
105
106 // The SSR page reads `event.context.cloudflare.env.TEST_BINDING` —
107 // the dev platform bridge reconstructs the runtime contract over
108 // the cloudflare-runtime platform proxy.
109 yield* expectUrlContains(
110 `${site.url!}/`,
111 `binding:${bindingMarker}`,
112 {
113 timeout: "60 seconds",
114 label:
115 "nuxt dev SSR page with event.context.cloudflare.env binding",
116 },
117 );
118
119 // API route through the same contract.
120 yield* expectUrlContains(`${site.url!}/api/hello`, "api-route-ok", {
121 timeout: "60 seconds",
122 label: "nuxt dev API route",
123 });
124
125 // KV binding round-trip against the local simulator, through the
126 // platform-proxy bridge (nitro dev worker thread → host workerd).
127 const put = yield* putJsonReady<{ put: boolean }>(
128 `${site.url!}/api/kv?key=dev-key&value=dev-value`,
129 );
130 expect(put.put).toBe(true);
131 const got = yield* fetchJsonReady<{ value: string | null }>(
132 `${site.url!}/api/kv?key=dev-key`,
133 );
134 expect(got.value).toBe("dev-value");
135
136 // ── HMR: edit an API route in place. The stack is NOT re-applied —
137 // nitro's dev rebuild must pick the change up and serve it through
138 // the same alchemy proxy ─────────────────────────────────────────
139 const helloPath = path.join(rootDir, "server/api/hello.ts");
140 const hello = yield* fs.readFileString(helloPath);
141 yield* fs.writeFileString(
142 helloPath,
143 hello.replace('marker: "api-route-ok"', 'marker: "api-route-v2"'),
144 );
145
146 // Bounded poll until nitro's dev rebuild serves the new marker.
147 yield* expectUrlContains(`${site.url!}/api/hello`, "api-route-v2", {
148 timeout: "90 seconds",
149 label: "nuxt dev API route after HMR edit",
150 });
151
152 // The binding bridge survived the dev-server reload: the KV binding
153 // still round-trips (old key readable, new write lands).
154 const stillThere = yield* fetchJsonReady<{ value: string | null }>(
155 `${site.url!}/api/kv?key=dev-key`,
156 );
157 expect(stillThere.value).toBe("dev-value");
158 const put2 = yield* putJsonReady<{ put: boolean }>(
159 `${site.url!}/api/kv?key=post-hmr-key&value=post-hmr-value`,
160 );
161 expect(put2.put).toBe(true);
162 const got2 = yield* fetchJsonReady<{ value: string | null }>(
163 `${site.url!}/api/kv?key=post-hmr-key`,
164 );
165 expect(got2.value).toBe("post-hmr-value");
166
167 yield* stack.destroy();
168 }).pipe(logLevel),
169 { tags: ["local"], timeout: 300_000 },
170 );
171
172 /**
173 * `Alchemy.remote()` opts the KV namespace OUT of local emulation: even
174 * under `alchemy dev` the namespace is created on real Cloudflare and the
175 * dev server's binding proxies to it remotely. Out-of-band reads through
176 * the cloud API prove the write landed in the real namespace, and destroy
177 * (with the state row stamped live) deletes it from the cloud.
178 *
179 * The dev platform proxy is hosted in the sidecar's runtime stack
180 * (`DevContext.runtimeContext` threaded through the framework dev bridge),
181 * which includes remote-bindings support — the proxy's internal layer is
182 * local-only by design.
183 */
184 test.provider(
185 "Nuxt dev: Alchemy.remote() KV namespace runs live behind the dev server",
186 (stack) =>
187 Effect.gen(function* () {
188 yield* stack.destroy();
189
190 const rootDir = yield* cloneFixture(fixtureDir, {
191 prefix: "alchemy-nuxt-dev-remote-",
192 tempRoot,
193 entries: fixtureEntries,
194 });
195
196 const deployed = yield* stack.deploy(
197 Effect.gen(function* () {
198 const siteKv = yield* Cloudflare.KV.Namespace(
199 "NuxtDevRemoteKV",
200 ).pipe(Alchemy.remote());
201 const site = yield* Cloudflare.Website.Nuxt("NuxtRemoteKvLocal", {
202 rootDir,
203 dev: { port: 0 },
204 memo: { include: memoInclude },
205 env: {
206 TEST_BINDING: "nuxt-dev-remote-marker",
207 SITE_KV: siteKv,
208 },
209 });
210 return { site, siteKv };
211 }),
212 );
213 const site = deployed.site;
214
215 // The site is served locally, but the remote() namespace is REAL —
216 // a live cloud id, not a `dev:` fabrication.
217 expect(site.url).toMatch(/^http:\/\/localhost:\d+/);
218 expect(deployed.siteKv.namespaceId).toBeDefined();
219 expect(deployed.siteKv.namespaceId).not.toMatch(/^dev:/);
220
221 // Write through the dev server's remote-proxied binding.
222 const put = yield* putJsonReady<{ put: boolean }>(
223 `${site.url!}/api/kv?key=remote-key&value=remote-value`,
224 );
225 expect(put.put).toBe(true);
226 const got = yield* fetchJsonReady<{ value: string | null }>(
227 `${site.url!}/api/kv?key=remote-key`,
228 );
229 expect(got.value).toBe("remote-value");
230
231 // Out-of-band: the write is visible through the cloud KV API — the
232 // remote-proxied binding really hit the live namespace.
233 const { accountId } = yield* yield* CloudflareEnvironment;
234 const observed = yield* kv
235 .getNamespaceValue({
236 accountId,
237 namespaceId: deployed.siteKv.namespaceId,
238 keyName: "remote-key",
239 })
240 .pipe(
241 Effect.retry({
242 while: (e): boolean => e._tag === "KeyNotFound",
243 schedule: Schedule.min([
244 Schedule.exponential("1 second"),
245 Schedule.spaced("3 seconds"),
246 ]),
247 times: 8,
248 }),
249 Effect.flatMap((res) =>
250 Effect.tryPromise(() =>
251 new Response(
252 Stream.toReadableStream(res.body) as BodyInit,
253 ).text(),
254 ),
255 ),
256 );
257 expect(observed).toBe("remote-value");
258
259 yield* stack.destroy();
260
261 // The live namespace was deleted from the cloud on destroy (its
262 // state row is stamped live, so the live provider handles the
263 // delete even in a dev run).
264 const gone = yield* kv
265 .getNamespace({
266 accountId,
267 namespaceId: deployed.siteKv.namespaceId,
268 })
269 .pipe(
270 Effect.as(false),
271 Effect.catchTag("NamespaceNotFound", () => Effect.succeed(true)),
272 );
273 expect(gone).toBe(true);
274 }).pipe(logLevel),
275 { tags: ["live"], timeout: 300_000 },
276 );
277 },
278);
279
280/** GET `url` until it answers 200 with a JSON body. */
281const fetchJsonReady = <T>(url: string) =>
282 Effect.gen(function* () {
283 const client = yield* HttpClient.HttpClient;
284 return yield* client.get(url).pipe(
285 Effect.flatMap((res) =>
286 res.status === 200
287 ? Effect.flatMap(res.text, (body) =>
288 Effect.try({
289 try: () => JSON.parse(body) as T,
290 catch: () => new Error(`non-json body: ${body}`),
291 }),
292 )
293 : Effect.fail(new Error(`Worker not ready: ${res.status}`)),
294 ),
295 Effect.retry({
296 schedule: Schedule.min([
297 Schedule.exponential("500 millis"),
298 Schedule.spaced("2 seconds"),
299 ]),
300 times: 10,
301 }),
302 );
303 });
304
305/** PUT (empty body) to `url` until it answers 200 with a JSON body. */
306const putJsonReady = <T>(url: string) =>
307 HttpClient.execute(HttpClientRequest.put(url)).pipe(
308 Effect.flatMap((res) =>
309 res.status === 200
310 ? Effect.flatMap(res.text, (responseBody) =>
311 Effect.try({
312 try: () => JSON.parse(responseBody) as T,
313 catch: () => new Error(`non-json body: ${responseBody}`),
314 }),
315 )
316 : Effect.fail(new Error(`Worker not ready: ${res.status}`)),
317 ),
318 Effect.retry({
319 schedule: Schedule.min([
320 Schedule.exponential("500 millis"),
321 Schedule.spaced("2 seconds"),
322 ]),
323 times: 10,
324 }),
325 );