1import * as Alchemy from "alchemy";
2import * as Cloudflare from "alchemy/Cloudflare";
3import * as Config from "effect/Config";
4import * as Effect from "effect/Effect";
5import * as Redacted from "effect/Redacted";
6import type { Counter, QueueMessages } from "./src/AsyncWorker.ts";
7import EffectWorker from "./src/EffectWorker.ts";
8import HyperdriveWorker from "./src/HyperdriveWorker.ts";
9import { SandboxLive } from "./src/SandboxContainer.ts";
10
11export type AsyncWorkerEnv = Cloudflare.InferEnv<
12 ReturnType<typeof AsyncWorker>
13>;
14
15/**
16 * Value the Secrets Store secret is seeded with. The integ test asserts the
17 * exact value round-trips through the `secrets_store_secret` binding.
18 */
19export const SECRETS_STORE_VALUE = "store-secret-abc123";
20
21/**
22 * Hyperdrive needs a reachable Postgres origin even in local dev (the local
23 * provider is a passthrough to the `dev` origin, not a SQL simulator). Set
24 * e.g. `HYPERDRIVE_DEV_URL=postgres://user:pass@localhost:5432/postgres` to
25 * include the HyperdriveWorker in the stack; without it the resource is
26 * omitted and the integ test skips.
27 */
28export const HYPERDRIVE_DEV_URL = process.env.HYPERDRIVE_DEV_URL;
29
30/**
31 * Tail consumer: listed in AsyncWorker's `tailConsumers`, it receives a trace
32 * batch for every AsyncWorker invocation and records them into a KV
33 * namespace, exposed over `GET /events`.
34 */
35const TailWorker = Effect.gen(function* () {
36 const events = yield* Cloudflare.KV.Namespace("TailEvents");
37 return yield* Cloudflare.Worker("TailWorker", {
38 main: "./src/TailWorker.ts",
39 env: {
40 EVENTS: events,
41 },
42 });
43});
44
45const AsyncWorker = (deps: {
46 tailWorker: Cloudflare.Worker;
47 liveKv: Cloudflare.KV.Namespace;
48}) =>
49 Effect.gen(function* () {
50 const queue = yield* Cloudflare.Queues.Queue("AsyncWorkerQueue");
51 const bucket = yield* Cloudflare.R2.Bucket("AsyncWorkerBucket", {
52 forceDestroy: true,
53 });
54 const db = yield* Cloudflare.D1.Database("AsyncWorkerDB", {
55 // Applied on deploy — including local dev, where they run against the
56 // local D1 simulator through an ephemeral workerd gateway.
57 migrations: "./migrations",
58 });
59 const worker = yield* Cloudflare.Worker("AsyncWorker", {
60 main: "./src/AsyncWorker.ts",
61 assets: {
62 directory: "./assets",
63 runWorkerFirst: true,
64 },
65 // Every invocation of this worker delivers a trace batch (console
66 // logs, outcome) to the tail worker.
67 tailConsumers: [deps.tailWorker],
68 env: {
69 COUNTER: Cloudflare.DurableObject<Counter>("Counter", {
70 className: "Counter",
71 }),
72 QUEUE: queue,
73 BUCKET: bucket,
74 DB: db,
75 MESSAGES: Cloudflare.DurableObject<QueueMessages>("QueueMessages", {
76 className: "QueueMessages",
77 }),
78 MY_VARIABLE: "my-variable-abc123",
79 MY_SECRET: Config.Redacted("MY_SECRET").pipe(
80 Config.withDefault(Redacted.make("my-secret-abc123")),
81 ),
82 // The worker's own URL, injected as a plain-text binding (`self_url`).
83 PUBLIC_URL: Cloudflare.Worker.URL,
84 // Rate limit binding — a Worker-only binding with no backing cloud
85 // resource; fully emulated (real throttling) in local dev.
86 THROTTLE: Cloudflare.RateLimit("THROTTLE", {
87 namespaceId: 1001,
88 simple: { limit: 2, period: 10 },
89 }),
90 // Version metadata — locally stubbed with a random `id`.
91 CF_VERSION_METADATA: Cloudflare.Workers.VersionMetadata(),
92 // Service binding to the Effect-native worker (worker → worker call).
93 SERVICE: EffectWorker,
94 // Hybrid: this KV namespace opted out of local emulation via
95 // `Alchemy.remote()` — the local worker proxies to the REAL namespace.
96 LIVE_KV: deps.liveKv,
97 },
98 });
99 yield* Cloudflare.Queues.Consumer("Consumer", {
100 queueId: queue.queueId,
101 scriptName: worker.workerName,
102 });
103 return worker;
104 });
105
106/**
107 * Media & messaging worker: Browser Rendering, Images, Stream, Secrets
108 * Store, and Email (send + inbound handler) — all served by local
109 * simulators under `alchemy dev`. `IMAGES_REMOTE` demonstrates the per-
110 * binding hybrid escape hatch: piping the binding through
111 * `Alchemy.remote()` proxies just that binding to the real Images service
112 * while the worker stays local.
113 */
114const MediaWorker = Effect.gen(function* () {
115 const store = yield* Cloudflare.SecretsStore.Store("Secrets");
116 const apiKey = yield* Cloudflare.SecretsStore.Secret("ApiKey", {
117 store,
118 value: Redacted.make(SECRETS_STORE_VALUE),
119 });
120 const email = yield* Cloudflare.Email.SendEmail("Notifications", {
121 allowedDestinationAddresses: ["allowed@example.com"],
122 });
123 const worker = yield* Cloudflare.Worker("MediaWorker", {
124 main: "./src/MediaWorker.ts",
125 env: {
126 BROWSER: Cloudflare.Browser("BROWSER"),
127 IMAGES: Cloudflare.Images.Images("IMAGES"),
128 IMAGES_REMOTE: Cloudflare.Images.Images("IMAGES_REMOTE").pipe(
129 Alchemy.remote(),
130 ),
131 STREAM: Cloudflare.Stream.Stream("STREAM"),
132 EMAIL: email,
133 API_KEY: apiKey,
134 },
135 });
136 return { worker, store, apiKey };
137});
138
139/**
140 * Inbound email worker: exposes an `email()` handler driven locally via
141 * `POST /cdn-cgi/handler/email`. Kept on its own worker — see the note in
142 * src/InboxWorker.ts for why it must not share a worker with images/stream
143 * bindings in local dev.
144 */
145const InboxWorker = Effect.gen(function* () {
146 const inbox = yield* Cloudflare.KV.Namespace("Inbox");
147 return yield* Cloudflare.Worker("InboxWorker", {
148 main: "./src/InboxWorker.ts",
149 env: {
150 INBOX: inbox,
151 },
152 });
153});
154
155export default Alchemy.Stack(
156 "CloudflareDev",
157 {
158 providers: Cloudflare.providers(),
159 state: Cloudflare.state(),
160 },
161 Effect.gen(function* () {
162 const tailWorker = yield* TailWorker;
163 const liveKv = yield* Cloudflare.KV.Namespace("LiveKV").pipe(
164 Alchemy.remote(),
165 );
166 const asyncWorker = yield* AsyncWorker({ tailWorker, liveKv });
167 const effectWorker = yield* EffectWorker;
168 const media = yield* MediaWorker;
169 const inboxWorker = yield* InboxWorker;
170 const hyperdrive = HYPERDRIVE_DEV_URL ? yield* HyperdriveWorker : undefined;
171
172 return {
173 asyncWorker: asyncWorker.url,
174 effectWorker: effectWorker.url,
175 mediaWorker: media.worker.url,
176 tailWorker: tailWorker.url,
177 inboxWorker: inboxWorker.url,
178 // `dev:`-prefixed ids mean "locally emulated"; LiveKV must NOT carry
179 // one (it's a real cloud namespace, even during dev).
180 liveKvNamespaceId: liveKv.namespaceId,
181 secretsStoreId: media.store.storeId,
182 secretsSecretId: media.apiKey.secretId,
183 ...(hyperdrive ? { hyperdrive: hyperdrive.url } : {}),
184 };
185 }).pipe(Effect.provide(SandboxLive)),
186);