1import * as Cloudflare from "@/Cloudflare";
2import * as Effect from "effect/Effect";
3import * as Layer from "effect/Layer";
4import { HttpServerRequest } from "effect/http/HttpServerRequest";
5import * as HttpServerResponse from "effect/http/HttpServerResponse";
6
7/**
8 * Effect-native Worker fixture that exercises all three Cloudflare tunnel
9 * runtime bindings from a single Worker:
10 *
11 * - {@link Cloudflare.Tunnel.ReadTunnel} — read-only (`/read`)
12 * - {@link Cloudflare.Tunnel.WriteTunnel} — mutating (`/write`)
13 * - {@link Cloudflare.Tunnel.ReadWriteTunnel} — full CRUD (`/readwrite`)
14 *
15 * Each binding provisions its own least-privilege {@link Cloudflare.ApiToken.AccountApiToken}
16 * (binding the token's outputs into the Worker) and the routes below run a
17 * self-contained scenario per binding so the test can assert one behavior each.
18 */
19export default class TunnelEffectWorker extends Cloudflare.Worker<TunnelEffectWorker>()(
20 "TunnelEffectWorker",
21 {
22 main: import.meta.url,
23 },
24 Effect.gen(function* () {
25 const read = yield* Cloudflare.Tunnel.ReadTunnel();
26 const write = yield* Cloudflare.Tunnel.WriteTunnel();
27 const tunnels = yield* Cloudflare.Tunnel.ReadWriteTunnel();
28
29 // Tests use deterministic tunnel names, and Cloudflare rejects duplicate
30 // names — delete any leftover tunnel from a previously crashed run so
31 // create always succeeds.
32 const purge = Effect.fn(function* (...names: string[]) {
33 for (const name of names) {
34 const list = yield* tunnels
35 .list({ name, isDeleted: false })
36 .pipe(Effect.orDie);
37 yield* Effect.forEach(
38 (list.result ?? []).filter((t) => t.name === name && t.id),
39 (t) => tunnels.delete(t.id!).pipe(Effect.orDie),
40 );
41 }
42 });
43
44 return {
45 fetch: Effect.gen(function* () {
46 const request = yield* HttpServerRequest;
47 const url = new URL(request.originalUrl);
48 const name = url.searchParams.get("name") ?? "alchemy-tunnel";
49
50 // Read-only: prove the read token works by listing tunnels.
51 if (url.pathname === "/read") {
52 const list = yield* read
53 .list({ isDeleted: false })
54 .pipe(Effect.orDie);
55 return yield* HttpServerResponse.json({ count: list.result?.length });
56 }
57
58 // Write: create then delete with the write-scoped token.
59 if (url.pathname === "/write") {
60 yield* purge(name);
61 const tunnel = yield* write
62 .create({ name, configSrc: "cloudflare" })
63 .pipe(Effect.orDie);
64 yield* write.delete(tunnel.id!).pipe(Effect.orDie);
65 return yield* HttpServerResponse.json({
66 id: tunnel.id,
67 deleted: true,
68 });
69 }
70
71 // Read + write: drive the full CRUD surface end to end. Purge the
72 // renamed variant too — a crash after the rename step would
73 // otherwise leak it forever.
74 if (url.pathname === "/readwrite") {
75 yield* purge(name, `${name}-renamed`);
76 const created = yield* tunnels
77 .create({ name, configSrc: "cloudflare" })
78 .pipe(Effect.orDie);
79 const id = created.id!;
80 const got = yield* tunnels.get(id).pipe(Effect.orDie);
81 const list = yield* tunnels.list().pipe(Effect.orDie);
82 const updated = yield* tunnels
83 .update(id, { name: `${name}-renamed` })
84 .pipe(Effect.orDie);
85 const token = yield* tunnels.getToken(id).pipe(Effect.orDie);
86 yield* tunnels.delete(id).pipe(Effect.orDie);
87 return yield* HttpServerResponse.json({
88 id,
89 getName: got.name,
90 count: list.result?.length,
91 updatedName: updated.name,
92 hasToken: token.length > 0,
93 deleted: true,
94 });
95 }
96
97 return HttpServerResponse.text("ok");
98 }),
99 };
100 }).pipe(
101 Effect.provide(
102 Layer.mergeAll(
103 Cloudflare.Tunnel.ReadTunnelBinding,
104 Cloudflare.Tunnel.WriteTunnelBinding,
105 Cloudflare.Tunnel.ReadWriteTunnelBinding,
106 ),
107 ),
108 ),
109) {}