alchemy 2.0.0-beta.22 → 2.0.0-beta.24

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (186) hide show
  1. package/bin/alchemy.js +12590 -2452
  2. package/bin/alchemy.js.map +1 -1
  3. package/bin/alchemy.ts +2 -0
  4. package/bin/cli.js +18 -0
  5. package/bin/commands/bootstrap.ts +88 -4
  6. package/bin/commands/deploy.ts +54 -20
  7. package/bin/commands/dev.ts +45 -0
  8. package/bin/commands/logs.ts +1 -1
  9. package/bin/commands/tail.ts +1 -1
  10. package/bin/exec.ts +22 -0
  11. package/lib/AWS/Lambda/Function.d.ts.map +1 -1
  12. package/lib/AWS/Lambda/Function.js +25 -21
  13. package/lib/AWS/Lambda/Function.js.map +1 -1
  14. package/lib/AWS/Lambda/HttpServer.d.ts.map +1 -1
  15. package/lib/AWS/Lambda/HttpServer.js +3 -1
  16. package/lib/AWS/Lambda/HttpServer.js.map +1 -1
  17. package/lib/AdoptPolicy.d.ts +2 -2
  18. package/lib/AdoptPolicy.d.ts.map +1 -1
  19. package/lib/AdoptPolicy.js.map +1 -1
  20. package/lib/AlchemyContext.d.ts +1 -0
  21. package/lib/AlchemyContext.d.ts.map +1 -1
  22. package/lib/AlchemyContext.js +1 -0
  23. package/lib/AlchemyContext.js.map +1 -1
  24. package/lib/Build/Command.d.ts.map +1 -1
  25. package/lib/Build/Command.js +8 -1
  26. package/lib/Build/Command.js.map +1 -1
  27. package/lib/Bundle/Bundle.d.ts +21 -9
  28. package/lib/Bundle/Bundle.d.ts.map +1 -1
  29. package/lib/Bundle/Bundle.js +29 -15
  30. package/lib/Bundle/Bundle.js.map +1 -1
  31. package/lib/Cloudflare/Container/Container.d.ts +55 -2
  32. package/lib/Cloudflare/Container/Container.d.ts.map +1 -1
  33. package/lib/Cloudflare/Container/Container.js +55 -2
  34. package/lib/Cloudflare/Container/Container.js.map +1 -1
  35. package/lib/Cloudflare/D1/D1Export.d.ts.map +1 -1
  36. package/lib/Cloudflare/D1/D1Export.js.map +1 -1
  37. package/lib/Cloudflare/D1/D1SqlFile.d.ts.map +1 -1
  38. package/lib/Cloudflare/D1/D1SqlFile.js.map +1 -1
  39. package/lib/Cloudflare/Local/Sidecar.d.ts +27 -0
  40. package/lib/Cloudflare/Local/Sidecar.d.ts.map +1 -0
  41. package/lib/Cloudflare/Local/Sidecar.js +24 -0
  42. package/lib/Cloudflare/Local/Sidecar.js.map +1 -0
  43. package/lib/Cloudflare/Local/SidecarHandlers.d.ts +5 -0
  44. package/lib/Cloudflare/Local/SidecarHandlers.d.ts.map +1 -0
  45. package/lib/Cloudflare/Local/SidecarHandlers.js +119 -0
  46. package/lib/Cloudflare/Local/SidecarHandlers.js.map +1 -0
  47. package/lib/Cloudflare/Local/SidecarServer.d.ts +2 -0
  48. package/lib/Cloudflare/Local/SidecarServer.d.ts.map +1 -0
  49. package/lib/Cloudflare/Local/SidecarServer.js +27 -0
  50. package/lib/Cloudflare/Local/SidecarServer.js.map +1 -0
  51. package/lib/Cloudflare/Queue/Queue.d.ts +34 -4
  52. package/lib/Cloudflare/Queue/Queue.d.ts.map +1 -1
  53. package/lib/Cloudflare/Queue/Queue.js +34 -4
  54. package/lib/Cloudflare/Queue/Queue.js.map +1 -1
  55. package/lib/Cloudflare/Queue/QueueBinding.d.ts +33 -0
  56. package/lib/Cloudflare/Queue/QueueBinding.d.ts.map +1 -1
  57. package/lib/Cloudflare/Queue/QueueBinding.js +33 -0
  58. package/lib/Cloudflare/Queue/QueueBinding.js.map +1 -1
  59. package/lib/Cloudflare/SecretsStore/Secret.d.ts.map +1 -1
  60. package/lib/Cloudflare/SecretsStore/Secret.js +55 -7
  61. package/lib/Cloudflare/SecretsStore/Secret.js.map +1 -1
  62. package/lib/Cloudflare/SecretsStore/SecretsStore.d.ts.map +1 -1
  63. package/lib/Cloudflare/SecretsStore/SecretsStore.js +35 -11
  64. package/lib/Cloudflare/SecretsStore/SecretsStore.js.map +1 -1
  65. package/lib/Cloudflare/StateStore/State.d.ts +35 -0
  66. package/lib/Cloudflare/StateStore/State.d.ts.map +1 -1
  67. package/lib/Cloudflare/StateStore/State.js +187 -45
  68. package/lib/Cloudflare/StateStore/State.js.map +1 -1
  69. package/lib/Cloudflare/StateStore/index.d.ts +1 -1
  70. package/lib/Cloudflare/StateStore/index.d.ts.map +1 -1
  71. package/lib/Cloudflare/StateStore/index.js +1 -1
  72. package/lib/Cloudflare/StateStore/index.js.map +1 -1
  73. package/lib/Cloudflare/Workers/Assets.d.ts +1 -1
  74. package/lib/Cloudflare/Workers/Assets.d.ts.map +1 -1
  75. package/lib/Cloudflare/Workers/Assets.js +6 -1
  76. package/lib/Cloudflare/Workers/Assets.js.map +1 -1
  77. package/lib/Cloudflare/Workers/Compatibility.d.ts +6 -0
  78. package/lib/Cloudflare/Workers/Compatibility.d.ts.map +1 -0
  79. package/lib/Cloudflare/Workers/Compatibility.js +10 -0
  80. package/lib/Cloudflare/Workers/Compatibility.js.map +1 -0
  81. package/lib/Cloudflare/Workers/DurableObjectNamespace.d.ts +103 -6
  82. package/lib/Cloudflare/Workers/DurableObjectNamespace.d.ts.map +1 -1
  83. package/lib/Cloudflare/Workers/DurableObjectNamespace.js +103 -6
  84. package/lib/Cloudflare/Workers/DurableObjectNamespace.js.map +1 -1
  85. package/lib/Cloudflare/Workers/LocalWorkerProvider.d.ts +7 -0
  86. package/lib/Cloudflare/Workers/LocalWorkerProvider.d.ts.map +1 -0
  87. package/lib/Cloudflare/Workers/LocalWorkerProvider.js +81 -0
  88. package/lib/Cloudflare/Workers/LocalWorkerProvider.js.map +1 -0
  89. package/lib/Cloudflare/Workers/Worker.d.ts +3 -1
  90. package/lib/Cloudflare/Workers/Worker.d.ts.map +1 -1
  91. package/lib/Cloudflare/Workers/Worker.js +54 -7
  92. package/lib/Cloudflare/Workers/Worker.js.map +1 -1
  93. package/lib/Cloudflare/Workers/WorkerBundle.d.ts +31 -0
  94. package/lib/Cloudflare/Workers/WorkerBundle.d.ts.map +1 -0
  95. package/lib/Cloudflare/Workers/WorkerBundle.js +174 -0
  96. package/lib/Cloudflare/Workers/WorkerBundle.js.map +1 -0
  97. package/lib/Cloudflare/Workers/WorkerName.d.ts +3 -0
  98. package/lib/Cloudflare/Workers/WorkerName.d.ts.map +1 -0
  99. package/lib/Cloudflare/Workers/WorkerName.js +9 -0
  100. package/lib/Cloudflare/Workers/WorkerName.js.map +1 -0
  101. package/lib/Cloudflare/Workers/Workflow.d.ts +94 -0
  102. package/lib/Cloudflare/Workers/Workflow.d.ts.map +1 -1
  103. package/lib/Cloudflare/Workers/Workflow.js +94 -0
  104. package/lib/Cloudflare/Workers/Workflow.js.map +1 -1
  105. package/lib/Cloudflare/Workers/index.d.ts +1 -0
  106. package/lib/Cloudflare/Workers/index.d.ts.map +1 -1
  107. package/lib/Cloudflare/Workers/index.js +1 -0
  108. package/lib/Cloudflare/Workers/index.js.map +1 -1
  109. package/lib/Platform.d.ts +1 -1
  110. package/lib/Platform.d.ts.map +1 -1
  111. package/lib/Platform.js +1 -1
  112. package/lib/Platform.js.map +1 -1
  113. package/lib/Sidecar/Lock.d.ts +24 -0
  114. package/lib/Sidecar/Lock.d.ts.map +1 -0
  115. package/lib/Sidecar/Lock.js +102 -0
  116. package/lib/Sidecar/Lock.js.map +1 -0
  117. package/lib/Sidecar/RpcClient.d.ts +15 -0
  118. package/lib/Sidecar/RpcClient.d.ts.map +1 -0
  119. package/lib/Sidecar/RpcClient.js +75 -0
  120. package/lib/Sidecar/RpcClient.js.map +1 -0
  121. package/lib/Sidecar/RpcHandler.d.ts +48 -0
  122. package/lib/Sidecar/RpcHandler.d.ts.map +1 -0
  123. package/lib/Sidecar/RpcHandler.js +64 -0
  124. package/lib/Sidecar/RpcHandler.js.map +1 -0
  125. package/lib/Sidecar/RpcPaths.d.ts +14 -0
  126. package/lib/Sidecar/RpcPaths.d.ts.map +1 -0
  127. package/lib/Sidecar/RpcPaths.js +22 -0
  128. package/lib/Sidecar/RpcPaths.js.map +1 -0
  129. package/lib/Sidecar/RpcServer.d.ts +10 -0
  130. package/lib/Sidecar/RpcServer.d.ts.map +1 -0
  131. package/lib/Sidecar/RpcServer.js +50 -0
  132. package/lib/Sidecar/RpcServer.js.map +1 -0
  133. package/lib/Sidecar/RpcTransport.d.ts +22 -0
  134. package/lib/Sidecar/RpcTransport.d.ts.map +1 -0
  135. package/lib/Sidecar/RpcTransport.js +81 -0
  136. package/lib/Sidecar/RpcTransport.js.map +1 -0
  137. package/lib/Telemetry/Metrics.d.ts +15 -0
  138. package/lib/Telemetry/Metrics.d.ts.map +1 -1
  139. package/lib/Telemetry/Metrics.js +20 -0
  140. package/lib/Telemetry/Metrics.js.map +1 -1
  141. package/lib/Util/PlatformServices.d.ts +3 -0
  142. package/lib/Util/PlatformServices.d.ts.map +1 -1
  143. package/lib/Util/PlatformServices.js +13 -0
  144. package/lib/Util/PlatformServices.js.map +1 -1
  145. package/lib/Util/layer-scoped.d.ts +16 -0
  146. package/lib/Util/layer-scoped.d.ts.map +1 -0
  147. package/lib/Util/layer-scoped.js +14 -0
  148. package/lib/Util/layer-scoped.js.map +1 -0
  149. package/package.json +4 -2
  150. package/src/AWS/Lambda/Function.ts +30 -24
  151. package/src/AWS/Lambda/HttpServer.ts +3 -1
  152. package/src/AdoptPolicy.ts +9 -9
  153. package/src/AlchemyContext.ts +2 -0
  154. package/src/Build/Command.ts +8 -1
  155. package/src/Bundle/Bundle.ts +65 -25
  156. package/src/Cloudflare/Container/Container.ts +55 -2
  157. package/src/Cloudflare/D1/D1Export.ts +1 -5
  158. package/src/Cloudflare/D1/D1SqlFile.ts +1 -3
  159. package/src/Cloudflare/Local/Sidecar.ts +44 -0
  160. package/src/Cloudflare/Local/SidecarHandlers.ts +182 -0
  161. package/src/Cloudflare/Local/SidecarServer.ts +57 -0
  162. package/src/Cloudflare/Queue/Queue.ts +34 -4
  163. package/src/Cloudflare/Queue/QueueBinding.ts +33 -0
  164. package/src/Cloudflare/SecretsStore/Secret.ts +78 -7
  165. package/src/Cloudflare/SecretsStore/SecretsStore.ts +47 -12
  166. package/src/Cloudflare/StateStore/State.ts +281 -64
  167. package/src/Cloudflare/StateStore/index.ts +1 -1
  168. package/src/Cloudflare/Workers/Assets.ts +10 -1
  169. package/src/Cloudflare/Workers/Compatibility.ts +12 -0
  170. package/src/Cloudflare/Workers/DurableObjectNamespace.ts +103 -6
  171. package/src/Cloudflare/Workers/LocalWorkerProvider.ts +93 -0
  172. package/src/Cloudflare/Workers/Worker.ts +102 -19
  173. package/src/Cloudflare/Workers/WorkerBundle.ts +234 -0
  174. package/src/Cloudflare/Workers/WorkerName.ts +10 -0
  175. package/src/Cloudflare/Workers/Workflow.ts +94 -0
  176. package/src/Cloudflare/Workers/index.ts +1 -0
  177. package/src/Platform.ts +2 -2
  178. package/src/Sidecar/Lock.ts +190 -0
  179. package/src/Sidecar/RpcClient.ts +141 -0
  180. package/src/Sidecar/RpcHandler.ts +169 -0
  181. package/src/Sidecar/RpcPaths.ts +29 -0
  182. package/src/Sidecar/RpcServer.ts +80 -0
  183. package/src/Sidecar/RpcTransport.ts +85 -0
  184. package/src/Telemetry/Metrics.ts +38 -0
  185. package/src/Util/PlatformServices.ts +19 -0
  186. package/src/Util/layer-scoped.ts +25 -0
@@ -248,6 +248,36 @@ export class WorkflowScope extends Context.Service<
248
248
  * yield* Cloudflare.sleep("cooldown", "30 seconds");
249
249
  * ```
250
250
  *
251
+ * @example Accessing env bindings inside a step
252
+ * `Cloudflare.WorkerEnvironment` is the same service available
253
+ * inside a Worker — it gives you typed access to env bindings (KV,
254
+ * R2, etc.) from inside a workflow body. Wrap any side-effecting
255
+ * call in `task` so the result is persisted across replays.
256
+ *
257
+ * ```typescript
258
+ * Effect.gen(function* () {
259
+ * const env = yield* Cloudflare.WorkerEnvironment;
260
+ * const event = yield* Cloudflare.WorkflowEvent;
261
+ * const { roomId, message } = event.payload as {
262
+ * roomId: string;
263
+ * message: string;
264
+ * };
265
+ *
266
+ * const stored = yield* Cloudflare.task(
267
+ * "kv-roundtrip",
268
+ * Effect.tryPromise({
269
+ * try: async () => {
270
+ * await env.KV.put(`workflow:${roomId}`, message);
271
+ * return await env.KV.get(`workflow:${roomId}`);
272
+ * },
273
+ * catch: (cause) =>
274
+ * cause instanceof Error ? cause : new Error(String(cause)),
275
+ * }).pipe(Effect.orDie),
276
+ * );
277
+ * return stored;
278
+ * });
279
+ * ```
280
+ *
251
281
  * @section Starting and Monitoring Instances
252
282
  * @example Creating an instance from a Worker
253
283
  * ```typescript
@@ -261,6 +291,70 @@ export class WorkflowScope extends Context.Service<
261
291
  * const handle = yield* workflow.get(instanceId);
262
292
  * const status = yield* handle.status();
263
293
  * ```
294
+ *
295
+ * @section Triggering from a Worker
296
+ * Wire the workflow into HTTP routes so callers can fire instances
297
+ * and poll for completion.
298
+ *
299
+ * @example Workflow start + status routes
300
+ * ```typescript
301
+ * // src/worker.ts
302
+ * const notifier = yield* MyWorkflow;
303
+ *
304
+ * return {
305
+ * fetch: Effect.gen(function* () {
306
+ * const request = yield* HttpServerRequest;
307
+ *
308
+ * if (request.url.startsWith("/workflow/start/")) {
309
+ * const id = request.url.split("/").pop()!;
310
+ * const instance = yield* notifier.create({ id });
311
+ * return HttpServerResponse.json({ instanceId: instance.id });
312
+ * }
313
+ *
314
+ * if (request.url.startsWith("/workflow/status/")) {
315
+ * const id = request.url.split("/").pop()!;
316
+ * const instance = yield* notifier.get(id);
317
+ * return HttpServerResponse.json(yield* instance.status());
318
+ * }
319
+ *
320
+ * return HttpServerResponse.text("Not Found", { status: 404 });
321
+ * }),
322
+ * };
323
+ * ```
324
+ *
325
+ * @section Testing Workflows
326
+ * Workflows run asynchronously, so tests start an instance and
327
+ * poll until it reaches a terminal status. A simple recipe with
328
+ * `alchemy/Test/Bun`:
329
+ *
330
+ * @example Polling for workflow completion
331
+ * ```typescript
332
+ * test(
333
+ * "workflow completes",
334
+ * Effect.gen(function* () {
335
+ * const { url } = yield* stack;
336
+ *
337
+ * const start = yield* HttpClient.post(`${url}/workflow/start/x`);
338
+ * const { instanceId } = (yield* start.json) as { instanceId: string };
339
+ *
340
+ * let status: { status: string } | undefined;
341
+ * const deadline = Date.now() + 60_000;
342
+ * while (Date.now() < deadline) {
343
+ * const res = yield* HttpClient.get(
344
+ * `${url}/workflow/status/${instanceId}`,
345
+ * );
346
+ * status = (yield* res.json) as { status: string };
347
+ * if (status.status === "complete" || status.status === "errored") {
348
+ * break;
349
+ * }
350
+ * yield* Effect.sleep("2 seconds");
351
+ * }
352
+ *
353
+ * expect(status?.status).toBe("complete");
354
+ * }),
355
+ * { timeout: 120_000 },
356
+ * );
357
+ * ```
264
358
  */
265
359
  export const Workflow: WorkflowClass = taggedFunction(WorkflowScope, ((
266
360
  ...args: [] | [name: string, impl: Effect.Effect<WorkflowBody>]
@@ -7,6 +7,7 @@ export * from "./DynamicWorkerLoader.ts";
7
7
  export * from "./Fetch.ts";
8
8
  export * from "./HttpServer.ts";
9
9
  export * from "./InferEnv.ts";
10
+ export * from "./LocalWorkerProvider.ts";
10
11
  export * from "./Request.ts";
11
12
  export * from "./Rpc.ts";
12
13
  export * from "./ScheduledEvents.ts";
package/src/Platform.ts CHANGED
@@ -180,7 +180,7 @@ export const Platform = <
180
180
  string,
181
181
  | {
182
182
  env?: Record<string, any>;
183
- exports?: string[];
183
+ exports?: string[] | Record<string, any>;
184
184
  }
185
185
  | undefined
186
186
  >,
@@ -326,7 +326,7 @@ export const Platform = <
326
326
 
327
327
  yield* impl.pipe(
328
328
  Effect.flatMap((impl) =>
329
- impl
329
+ impl?.fetch
330
330
  ? (executionContext.serve?.(impl.fetch) ??
331
331
  Effect.die("No serve handler"))
332
332
  : Effect.void,
@@ -0,0 +1,190 @@
1
+ import * as Context from "effect/Context";
2
+ import * as Duration from "effect/Duration";
3
+ import * as Effect from "effect/Effect";
4
+ import * as Fiber from "effect/Fiber";
5
+ import * as FileSystem from "effect/FileSystem";
6
+ import * as Layer from "effect/Layer";
7
+ import * as Option from "effect/Option";
8
+ import * as Schedule from "effect/Schedule";
9
+ import * as Schema from "effect/Schema";
10
+ import type * as Scope from "effect/Scope";
11
+ import * as RpcPaths from "./RpcPaths.ts";
12
+
13
+ export class LockError extends Schema.TaggedErrorClass<LockError>()(
14
+ "LockError",
15
+ {
16
+ reason: Schema.Literals([
17
+ "Cancelled",
18
+ "Conflict",
19
+ "Invalid",
20
+ "Timeout",
21
+ "PlatformError",
22
+ ]),
23
+ message: Schema.String,
24
+ cause: Schema.optional(Schema.DefectWithStack),
25
+ },
26
+ ) {}
27
+
28
+ export class Lock extends Context.Service<
29
+ Lock,
30
+ {
31
+ readonly check: Effect.Effect<boolean>;
32
+ readonly acquire: Effect.Effect<
33
+ Fiber.Fiber<number, LockError>,
34
+ LockError,
35
+ Scope.Scope
36
+ >;
37
+ }
38
+ >()("Lock") {}
39
+
40
+ const LOCK_TTL = Duration.seconds(10);
41
+ const TOUCH_INTERVAL = Duration.seconds(1);
42
+
43
+ const make = Effect.gen(function* () {
44
+ const fs = yield* FileSystem.FileSystem;
45
+ const paths = yield* RpcPaths.RpcPaths;
46
+
47
+ const readLockFile = fs.readFileString(paths.lock).pipe(
48
+ Effect.mapError(
49
+ (e) =>
50
+ new LockError({
51
+ reason: "PlatformError",
52
+ message:
53
+ e.reason._tag === "NotFound"
54
+ ? "Lock file not found"
55
+ : "Failed to read lock file",
56
+ cause: e,
57
+ }),
58
+ ),
59
+ Effect.flatMap((text) => {
60
+ const pid = Number.parseInt(text);
61
+ return Number.isNaN(pid)
62
+ ? Effect.fail(
63
+ new LockError({
64
+ reason: "Invalid",
65
+ message: "Lock file is invalid",
66
+ }),
67
+ )
68
+ : Effect.succeed(pid);
69
+ }),
70
+ );
71
+
72
+ const isLockProcessAlive = readLockFile.pipe(
73
+ Effect.flatMap((pid) =>
74
+ Effect.sync(() => {
75
+ try {
76
+ process.kill(pid, 0);
77
+ return true;
78
+ } catch {
79
+ return false;
80
+ }
81
+ }),
82
+ ),
83
+ Effect.orElseSucceed(() => false),
84
+ );
85
+
86
+ const isLockFileStale = fs.stat(paths.lock).pipe(
87
+ Effect.map((stat) => {
88
+ const mtime = Option.getOrElse(stat.mtime, () => new Date(0));
89
+ return mtime.getTime() < Date.now() - Duration.toMillis(LOCK_TTL);
90
+ }),
91
+ Effect.orElseSucceed(() => true),
92
+ );
93
+
94
+ const isLockValid = Effect.zipWith(
95
+ isLockProcessAlive,
96
+ isLockFileStale,
97
+ (alive, stale) => alive && !stale,
98
+ {
99
+ concurrent: true,
100
+ },
101
+ );
102
+
103
+ const makeLockFile = fs
104
+ .writeFileString(paths.lock, process.pid.toString(), { flag: "wx" })
105
+ .pipe(
106
+ Effect.mapError((e) =>
107
+ e.reason._tag === "AlreadyExists"
108
+ ? new LockError({
109
+ reason: "Conflict",
110
+ message: "Lock already held by another process",
111
+ })
112
+ : new LockError({
113
+ reason: "PlatformError",
114
+ message: "Failed to create lock file",
115
+ cause: e,
116
+ }),
117
+ ),
118
+ );
119
+
120
+ const assertOwnLock = readLockFile.pipe(
121
+ Effect.flatMap((pid) =>
122
+ pid === process.pid
123
+ ? Effect.void
124
+ : Effect.fail(
125
+ new LockError({
126
+ reason: "Conflict",
127
+ message: "Lock not held by this process",
128
+ }),
129
+ ),
130
+ ),
131
+ );
132
+
133
+ const removeLock = fs.remove(paths.lock, { force: true }).pipe(
134
+ Effect.mapError(
135
+ (e) =>
136
+ new LockError({
137
+ reason: "PlatformError",
138
+ message: "Failed to remove lock file",
139
+ cause: e,
140
+ }),
141
+ ),
142
+ );
143
+
144
+ const releaseLock = assertOwnLock.pipe(Effect.andThen(() => removeLock));
145
+
146
+ const acquireLock = makeLockFile.pipe(
147
+ Effect.catchIf(
148
+ (e) => e.reason === "Conflict",
149
+ (e) =>
150
+ isLockValid.pipe(
151
+ Effect.flatMap((valid) =>
152
+ valid
153
+ ? Effect.fail(e)
154
+ : removeLock.pipe(Effect.andThen(() => makeLockFile)),
155
+ ),
156
+ ),
157
+ ),
158
+ );
159
+
160
+ const touchLock = assertOwnLock.pipe(
161
+ Effect.flatMap(() => {
162
+ const now = Date.now();
163
+ return fs.utimes(paths.lock, now, now).pipe(
164
+ Effect.mapError(
165
+ (e) =>
166
+ new LockError({
167
+ reason: "PlatformError",
168
+ message: "Failed to update lock file",
169
+ cause: e,
170
+ }),
171
+ ),
172
+ );
173
+ }),
174
+ );
175
+
176
+ return Lock.of({
177
+ check: isLockValid,
178
+ acquire: acquireLock.pipe(
179
+ Effect.flatMap(() =>
180
+ touchLock.pipe(
181
+ Effect.repeat(Schedule.spaced(TOUCH_INTERVAL)),
182
+ Effect.ensuring(releaseLock.pipe(Effect.ignore)),
183
+ Effect.forkScoped,
184
+ ),
185
+ ),
186
+ ),
187
+ });
188
+ });
189
+
190
+ export const LockLive = Layer.effect(Lock, make);
@@ -0,0 +1,141 @@
1
+ import { newWebSocketRpcSession } from "capnweb";
2
+ import * as Context from "effect/Context";
3
+ import * as Effect from "effect/Effect";
4
+ import * as Fiber from "effect/Fiber";
5
+ import * as FileSystem from "effect/FileSystem";
6
+ import * as Layer from "effect/Layer";
7
+ import * as Schedule from "effect/Schedule";
8
+ import * as Schema from "effect/Schema";
9
+ import * as ChildProcess from "effect/unstable/process/ChildProcess";
10
+ import { fileURLToPath } from "node:url";
11
+ import * as Lock from "./Lock.ts";
12
+ import {
13
+ deserializeRpcHandlers,
14
+ type RpcHandlerDecoders,
15
+ type RpcHandlers,
16
+ type SerializedRpcHandlers,
17
+ } from "./RpcHandler.ts";
18
+ import * as RpcPaths from "./RpcPaths.ts";
19
+
20
+ export const RpcClientService = <Self, T extends RpcHandlers>() =>
21
+ Context.Service<Self, T>();
22
+
23
+ class RpcClientError extends Schema.TaggedErrorClass<RpcClientError>()(
24
+ "RpcClientError",
25
+ {
26
+ reason: Schema.Literals(["InvalidURL", "WebSocketError"]),
27
+ message: Schema.String,
28
+ cause: Schema.optional(Schema.DefectWithStack),
29
+ },
30
+ ) {}
31
+
32
+ export const layer = <Self, T extends RpcHandlers>(
33
+ tag: Context.ServiceClass<Self, any, T>,
34
+ options: {
35
+ main: string;
36
+ schema: RpcHandlerDecoders<T>;
37
+ },
38
+ ) =>
39
+ Layer.effect(
40
+ tag,
41
+ Effect.gen(function* () {
42
+ const fiber = yield* maybeStartRpcServer(
43
+ fileURLToPath(options.main),
44
+ ).pipe(
45
+ Effect.flatMap(() => RpcSession),
46
+ Effect.map((session) =>
47
+ deserializeRpcHandlers(
48
+ session as SerializedRpcHandlers<T>,
49
+ options.schema,
50
+ ),
51
+ ),
52
+ Effect.retry(Schedule.exponential("100 millis")),
53
+ Effect.provide(
54
+ Layer.provideMerge(Lock.LockLive, RpcPaths.layer(options.main)),
55
+ ),
56
+ Effect.forkScoped,
57
+ );
58
+ return new Proxy({} as T, {
59
+ get(target, prop) {
60
+ return (...args: any[]) =>
61
+ Fiber.join(fiber).pipe(
62
+ Effect.flatMap((session) => session[prop as never](...args)),
63
+ );
64
+ },
65
+ });
66
+ }),
67
+ );
68
+
69
+ const maybeStartRpcServer = Effect.fn(function* (main: string) {
70
+ const lock = yield* Lock.Lock;
71
+ if (!(yield* lock.check)) {
72
+ yield* Effect.logDebug("[RpcClient] Starting RPC server", main);
73
+ yield* ChildProcess.make("bun", ["run", main], {
74
+ stdout: "inherit",
75
+ stderr: "inherit",
76
+ detached: true,
77
+ });
78
+ } else {
79
+ yield* Effect.logDebug("[RpcClient] RPC server already running", main);
80
+ }
81
+ });
82
+
83
+ const RpcSession = Effect.gen(function* () {
84
+ const paths = yield* RpcPaths.RpcPaths;
85
+ const fs = yield* FileSystem.FileSystem;
86
+ const ws = yield* fs.readFileString(paths.url).pipe(
87
+ Effect.flatMap((url) =>
88
+ Effect.try({
89
+ try: () => new URL(url),
90
+ catch: (e) =>
91
+ new RpcClientError({
92
+ reason: "InvalidURL",
93
+ message: `"${url}" is not a valid URL`,
94
+ cause: e,
95
+ }),
96
+ }),
97
+ ),
98
+ Effect.flatMap((url) =>
99
+ Effect.callback<WebSocket, RpcClientError>((resume) => {
100
+ const ws = new WebSocket(url);
101
+ ws.onopen = () => resume(Effect.succeed(ws));
102
+ ws.onerror = (e) =>
103
+ resume(
104
+ Effect.fail(
105
+ new RpcClientError({
106
+ reason: "WebSocketError",
107
+ message: "WebSocket connection failed",
108
+ cause: e,
109
+ }),
110
+ ),
111
+ );
112
+ return Effect.sync(() => ws.close());
113
+ }),
114
+ ),
115
+ Effect.retry({
116
+ while: (e) => e.reason !== "WebSocketError",
117
+ schedule: Schedule.spaced("50 millis"),
118
+ times: 100,
119
+ }),
120
+ Effect.retry({
121
+ while: (e) => e.reason === "WebSocketError",
122
+ schedule: Schedule.exponential("100 millis"),
123
+ times: 3,
124
+ }),
125
+ );
126
+ const session = yield* Effect.acquireRelease(
127
+ Effect.sync(() =>
128
+ newWebSocketRpcSession<{
129
+ heartbeat: () => Promise<void>;
130
+ shutdown: () => Promise<void>;
131
+ }>(ws),
132
+ ),
133
+ (session) => Effect.sync(() => session[Symbol.dispose]()),
134
+ );
135
+ yield* Effect.promise(() => session.heartbeat()).pipe(
136
+ Effect.repeat(Schedule.spaced("1 second")),
137
+ Effect.ensuring(Effect.promise(() => session.shutdown())),
138
+ Effect.forkScoped,
139
+ );
140
+ return session;
141
+ });
@@ -0,0 +1,169 @@
1
+ import type { RpcCompatible } from "capnweb";
2
+ import * as Cause from "effect/Cause";
3
+ import * as Effect from "effect/Effect";
4
+ import * as Exit from "effect/Exit";
5
+ import * as Schema from "effect/Schema";
6
+
7
+ type RpcHandler<Args extends Array<any>, Success, Error> = (
8
+ ...args: Args
9
+ ) => Effect.Effect<Success, Error>;
10
+
11
+ type SerializedRpcHandler<Args extends Array<any>, Success, Error> = (
12
+ ...args: Args
13
+ ) => Promise<SerializedExit<RpcCompatible<Success>, RpcCompatible<Error>>>;
14
+
15
+ type SerializedCause<Error> =
16
+ | { _tag: "Fail"; error: Error }
17
+ | { _tag: "Die"; defect: unknown }
18
+ | { _tag: "Interrupt"; fiberId: number | undefined };
19
+
20
+ type SerializedExit<Success, Error> =
21
+ | { _tag: "Success"; value: Success }
22
+ | {
23
+ _tag: "Failure";
24
+ cause: Array<SerializedCause<Error>>;
25
+ };
26
+
27
+ export type RpcHandlers = Record<string, RpcHandler<any, any, any>>;
28
+
29
+ export type SerializedRpcHandlers<T extends RpcHandlers> = {
30
+ [K in keyof T]: T[K] extends RpcHandler<
31
+ infer Args,
32
+ infer Success,
33
+ infer Error
34
+ >
35
+ ? SerializedRpcHandler<Args, Success, Error>
36
+ : never;
37
+ } extends infer O extends object & RpcCompatible<O>
38
+ ? O
39
+ : never;
40
+
41
+ type RpcHandlerEncoder<Success, Error> = {
42
+ success: Schema.Encoder<Success>;
43
+ error: Schema.Encoder<Error>;
44
+ };
45
+
46
+ export type RpcHandlerEncoders<T extends RpcHandlers> = {
47
+ [K in keyof T]: T[K] extends RpcHandler<any, infer Success, infer Error>
48
+ ? RpcHandlerEncoder<Success, Error>
49
+ : never;
50
+ };
51
+
52
+ type RpcHandlerDecoder<Success, Error> = {
53
+ success: Schema.Decoder<Success>;
54
+ error: Schema.Decoder<Error>;
55
+ };
56
+
57
+ export type RpcHandlerDecoders<T extends RpcHandlers> = {
58
+ [K in keyof T]: T[K] extends RpcHandler<any, infer Success, infer Error>
59
+ ? RpcHandlerDecoder<Success, Error>
60
+ : never;
61
+ };
62
+
63
+ const serializeRpcHandler =
64
+ <Args extends Array<any>, Success, Error>(
65
+ handler: RpcHandler<Args, Success, Error>,
66
+ schema: RpcHandlerEncoder<Success, Error>,
67
+ ): SerializedRpcHandler<Args, Success, Error> =>
68
+ (...args) =>
69
+ handler(...args).pipe(
70
+ Effect.exit,
71
+ Effect.map((exit): SerializedExit<Success, Error> => {
72
+ if (exit._tag === "Success") {
73
+ return {
74
+ _tag: "Success",
75
+ value: Schema.encodeSync(schema.success)(exit.value),
76
+ } as const;
77
+ }
78
+ return {
79
+ _tag: "Failure",
80
+ cause: exit.cause.reasons.map((reason): SerializedCause<Error> => {
81
+ switch (reason._tag) {
82
+ case "Fail":
83
+ return {
84
+ _tag: "Fail",
85
+ error: Schema.encodeSync(schema.error)(reason.error),
86
+ };
87
+ case "Die":
88
+ return {
89
+ _tag: "Die",
90
+ defect: Schema.encodeSync(Schema.DefectWithStack)(
91
+ reason.defect,
92
+ ),
93
+ };
94
+ case "Interrupt":
95
+ return { _tag: "Interrupt", fiberId: reason.fiberId };
96
+ }
97
+ }),
98
+ } as const;
99
+ }),
100
+ Effect.runPromise,
101
+ );
102
+
103
+ export const serializeRpcHandlers = <T extends RpcHandlers>(
104
+ handlers: T,
105
+ schema: RpcHandlerEncoders<T>,
106
+ ): SerializedRpcHandlers<T> =>
107
+ Object.fromEntries(
108
+ Object.entries(schema).map(([key, schema]) => [
109
+ key,
110
+ serializeRpcHandler(handlers[key], schema),
111
+ ]),
112
+ ) as SerializedRpcHandlers<T>;
113
+
114
+ const deserializeRpcHandler =
115
+ <Args extends Array<any>, Success, Error>(
116
+ handler: SerializedRpcHandler<Args, Success, Error>,
117
+ schema: RpcHandlerDecoder<Success, Error>,
118
+ ): RpcHandler<Args, Success, Error> =>
119
+ (...args) =>
120
+ Effect.promise(async () => {
121
+ try {
122
+ return await handler(...args);
123
+ } catch (error) {
124
+ console.error("Error calling handler", error);
125
+ throw error;
126
+ }
127
+ }).pipe(
128
+ Effect.map((exit): Exit.Exit<Success, Error> => {
129
+ if (exit._tag === "Success") {
130
+ return Exit.succeed(Schema.decodeSync(schema.success)(exit.value));
131
+ }
132
+ return Exit.failCause(
133
+ Cause.fromReasons(
134
+ exit.cause.map((reason): Cause.Reason<Error> => {
135
+ switch (reason._tag) {
136
+ case "Fail":
137
+ return Cause.makeFailReason(
138
+ Schema.decodeSync(schema.error)(reason.error),
139
+ );
140
+ case "Die":
141
+ return Cause.makeDieReason(
142
+ Schema.decodeSync(Schema.DefectWithStack)(reason.defect),
143
+ );
144
+ case "Interrupt":
145
+ return Cause.makeInterruptReason(reason.fiberId);
146
+ }
147
+ }),
148
+ ),
149
+ );
150
+ }),
151
+ Effect.flatMap((exit) => exit),
152
+ );
153
+
154
+ export const deserializeRpcHandlers = <T extends RpcHandlers>(
155
+ handlers: SerializedRpcHandlers<T>,
156
+ schema: RpcHandlerDecoders<T>,
157
+ ): T =>
158
+ Object.fromEntries(
159
+ Object.entries(schema).map(([key, schema]) => [
160
+ key,
161
+ deserializeRpcHandler(handlers[key as never], schema),
162
+ ]),
163
+ ) as T;
164
+
165
+ export const defineSchema = <T extends RpcHandlers>(schema: {
166
+ [K in keyof T]: T[K] extends RpcHandler<any, infer Success, infer Error>
167
+ ? { success: Schema.Codec<Success, any>; error: Schema.Codec<Error, any> }
168
+ : never;
169
+ }) => schema as RpcHandlerEncoders<T> & RpcHandlerDecoders<T>;
@@ -0,0 +1,29 @@
1
+ import * as Context from "effect/Context";
2
+ import * as Effect from "effect/Effect";
3
+ import * as FileSystem from "effect/FileSystem";
4
+ import * as Hash from "effect/Hash";
5
+ import * as Layer from "effect/Layer";
6
+ import * as Path from "effect/Path";
7
+ import * as AlchemyContext from "../AlchemyContext.ts";
8
+
9
+ export class RpcPaths extends Context.Service<
10
+ RpcPaths,
11
+ { readonly lock: string; readonly url: string }
12
+ >()("RpcPaths") {}
13
+
14
+ export const layer = (main: string) =>
15
+ Layer.effect(
16
+ RpcPaths,
17
+ Effect.gen(function* () {
18
+ const { dotAlchemy } = yield* AlchemyContext.AlchemyContext;
19
+ const path = yield* Path.Path;
20
+ const fs = yield* FileSystem.FileSystem;
21
+ const id = Math.abs(Hash.string(main));
22
+ const dir = path.resolve(dotAlchemy, "local");
23
+ yield* fs.makeDirectory(dir, { recursive: true });
24
+ return RpcPaths.of({
25
+ lock: path.join(dir, `${id}.lock`),
26
+ url: path.join(dir, `${id}.url`),
27
+ });
28
+ }),
29
+ );