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.
- package/bin/alchemy.js +12590 -2452
- package/bin/alchemy.js.map +1 -1
- package/bin/alchemy.ts +2 -0
- package/bin/cli.js +18 -0
- package/bin/commands/bootstrap.ts +88 -4
- package/bin/commands/deploy.ts +54 -20
- package/bin/commands/dev.ts +45 -0
- package/bin/commands/logs.ts +1 -1
- package/bin/commands/tail.ts +1 -1
- package/bin/exec.ts +22 -0
- package/lib/AWS/Lambda/Function.d.ts.map +1 -1
- package/lib/AWS/Lambda/Function.js +25 -21
- package/lib/AWS/Lambda/Function.js.map +1 -1
- package/lib/AWS/Lambda/HttpServer.d.ts.map +1 -1
- package/lib/AWS/Lambda/HttpServer.js +3 -1
- package/lib/AWS/Lambda/HttpServer.js.map +1 -1
- package/lib/AdoptPolicy.d.ts +2 -2
- package/lib/AdoptPolicy.d.ts.map +1 -1
- package/lib/AdoptPolicy.js.map +1 -1
- package/lib/AlchemyContext.d.ts +1 -0
- package/lib/AlchemyContext.d.ts.map +1 -1
- package/lib/AlchemyContext.js +1 -0
- package/lib/AlchemyContext.js.map +1 -1
- package/lib/Build/Command.d.ts.map +1 -1
- package/lib/Build/Command.js +8 -1
- package/lib/Build/Command.js.map +1 -1
- package/lib/Bundle/Bundle.d.ts +21 -9
- package/lib/Bundle/Bundle.d.ts.map +1 -1
- package/lib/Bundle/Bundle.js +29 -15
- package/lib/Bundle/Bundle.js.map +1 -1
- package/lib/Cloudflare/Container/Container.d.ts +55 -2
- package/lib/Cloudflare/Container/Container.d.ts.map +1 -1
- package/lib/Cloudflare/Container/Container.js +55 -2
- package/lib/Cloudflare/Container/Container.js.map +1 -1
- package/lib/Cloudflare/D1/D1Export.d.ts.map +1 -1
- package/lib/Cloudflare/D1/D1Export.js.map +1 -1
- package/lib/Cloudflare/D1/D1SqlFile.d.ts.map +1 -1
- package/lib/Cloudflare/D1/D1SqlFile.js.map +1 -1
- package/lib/Cloudflare/Local/Sidecar.d.ts +27 -0
- package/lib/Cloudflare/Local/Sidecar.d.ts.map +1 -0
- package/lib/Cloudflare/Local/Sidecar.js +24 -0
- package/lib/Cloudflare/Local/Sidecar.js.map +1 -0
- package/lib/Cloudflare/Local/SidecarHandlers.d.ts +5 -0
- package/lib/Cloudflare/Local/SidecarHandlers.d.ts.map +1 -0
- package/lib/Cloudflare/Local/SidecarHandlers.js +119 -0
- package/lib/Cloudflare/Local/SidecarHandlers.js.map +1 -0
- package/lib/Cloudflare/Local/SidecarServer.d.ts +2 -0
- package/lib/Cloudflare/Local/SidecarServer.d.ts.map +1 -0
- package/lib/Cloudflare/Local/SidecarServer.js +27 -0
- package/lib/Cloudflare/Local/SidecarServer.js.map +1 -0
- package/lib/Cloudflare/Queue/Queue.d.ts +34 -4
- package/lib/Cloudflare/Queue/Queue.d.ts.map +1 -1
- package/lib/Cloudflare/Queue/Queue.js +34 -4
- package/lib/Cloudflare/Queue/Queue.js.map +1 -1
- package/lib/Cloudflare/Queue/QueueBinding.d.ts +33 -0
- package/lib/Cloudflare/Queue/QueueBinding.d.ts.map +1 -1
- package/lib/Cloudflare/Queue/QueueBinding.js +33 -0
- package/lib/Cloudflare/Queue/QueueBinding.js.map +1 -1
- package/lib/Cloudflare/SecretsStore/Secret.d.ts.map +1 -1
- package/lib/Cloudflare/SecretsStore/Secret.js +55 -7
- package/lib/Cloudflare/SecretsStore/Secret.js.map +1 -1
- package/lib/Cloudflare/SecretsStore/SecretsStore.d.ts.map +1 -1
- package/lib/Cloudflare/SecretsStore/SecretsStore.js +35 -11
- package/lib/Cloudflare/SecretsStore/SecretsStore.js.map +1 -1
- package/lib/Cloudflare/StateStore/State.d.ts +35 -0
- package/lib/Cloudflare/StateStore/State.d.ts.map +1 -1
- package/lib/Cloudflare/StateStore/State.js +187 -45
- package/lib/Cloudflare/StateStore/State.js.map +1 -1
- package/lib/Cloudflare/StateStore/index.d.ts +1 -1
- package/lib/Cloudflare/StateStore/index.d.ts.map +1 -1
- package/lib/Cloudflare/StateStore/index.js +1 -1
- package/lib/Cloudflare/StateStore/index.js.map +1 -1
- package/lib/Cloudflare/Workers/Assets.d.ts +1 -1
- package/lib/Cloudflare/Workers/Assets.d.ts.map +1 -1
- package/lib/Cloudflare/Workers/Assets.js +6 -1
- package/lib/Cloudflare/Workers/Assets.js.map +1 -1
- package/lib/Cloudflare/Workers/Compatibility.d.ts +6 -0
- package/lib/Cloudflare/Workers/Compatibility.d.ts.map +1 -0
- package/lib/Cloudflare/Workers/Compatibility.js +10 -0
- package/lib/Cloudflare/Workers/Compatibility.js.map +1 -0
- package/lib/Cloudflare/Workers/DurableObjectNamespace.d.ts +103 -6
- package/lib/Cloudflare/Workers/DurableObjectNamespace.d.ts.map +1 -1
- package/lib/Cloudflare/Workers/DurableObjectNamespace.js +103 -6
- package/lib/Cloudflare/Workers/DurableObjectNamespace.js.map +1 -1
- package/lib/Cloudflare/Workers/LocalWorkerProvider.d.ts +7 -0
- package/lib/Cloudflare/Workers/LocalWorkerProvider.d.ts.map +1 -0
- package/lib/Cloudflare/Workers/LocalWorkerProvider.js +81 -0
- package/lib/Cloudflare/Workers/LocalWorkerProvider.js.map +1 -0
- package/lib/Cloudflare/Workers/Worker.d.ts +3 -1
- package/lib/Cloudflare/Workers/Worker.d.ts.map +1 -1
- package/lib/Cloudflare/Workers/Worker.js +54 -7
- package/lib/Cloudflare/Workers/Worker.js.map +1 -1
- package/lib/Cloudflare/Workers/WorkerBundle.d.ts +31 -0
- package/lib/Cloudflare/Workers/WorkerBundle.d.ts.map +1 -0
- package/lib/Cloudflare/Workers/WorkerBundle.js +174 -0
- package/lib/Cloudflare/Workers/WorkerBundle.js.map +1 -0
- package/lib/Cloudflare/Workers/WorkerName.d.ts +3 -0
- package/lib/Cloudflare/Workers/WorkerName.d.ts.map +1 -0
- package/lib/Cloudflare/Workers/WorkerName.js +9 -0
- package/lib/Cloudflare/Workers/WorkerName.js.map +1 -0
- package/lib/Cloudflare/Workers/Workflow.d.ts +94 -0
- package/lib/Cloudflare/Workers/Workflow.d.ts.map +1 -1
- package/lib/Cloudflare/Workers/Workflow.js +94 -0
- package/lib/Cloudflare/Workers/Workflow.js.map +1 -1
- package/lib/Cloudflare/Workers/index.d.ts +1 -0
- package/lib/Cloudflare/Workers/index.d.ts.map +1 -1
- package/lib/Cloudflare/Workers/index.js +1 -0
- package/lib/Cloudflare/Workers/index.js.map +1 -1
- package/lib/Platform.d.ts +1 -1
- package/lib/Platform.d.ts.map +1 -1
- package/lib/Platform.js +1 -1
- package/lib/Platform.js.map +1 -1
- package/lib/Sidecar/Lock.d.ts +24 -0
- package/lib/Sidecar/Lock.d.ts.map +1 -0
- package/lib/Sidecar/Lock.js +102 -0
- package/lib/Sidecar/Lock.js.map +1 -0
- package/lib/Sidecar/RpcClient.d.ts +15 -0
- package/lib/Sidecar/RpcClient.d.ts.map +1 -0
- package/lib/Sidecar/RpcClient.js +75 -0
- package/lib/Sidecar/RpcClient.js.map +1 -0
- package/lib/Sidecar/RpcHandler.d.ts +48 -0
- package/lib/Sidecar/RpcHandler.d.ts.map +1 -0
- package/lib/Sidecar/RpcHandler.js +64 -0
- package/lib/Sidecar/RpcHandler.js.map +1 -0
- package/lib/Sidecar/RpcPaths.d.ts +14 -0
- package/lib/Sidecar/RpcPaths.d.ts.map +1 -0
- package/lib/Sidecar/RpcPaths.js +22 -0
- package/lib/Sidecar/RpcPaths.js.map +1 -0
- package/lib/Sidecar/RpcServer.d.ts +10 -0
- package/lib/Sidecar/RpcServer.d.ts.map +1 -0
- package/lib/Sidecar/RpcServer.js +50 -0
- package/lib/Sidecar/RpcServer.js.map +1 -0
- package/lib/Sidecar/RpcTransport.d.ts +22 -0
- package/lib/Sidecar/RpcTransport.d.ts.map +1 -0
- package/lib/Sidecar/RpcTransport.js +81 -0
- package/lib/Sidecar/RpcTransport.js.map +1 -0
- package/lib/Telemetry/Metrics.d.ts +15 -0
- package/lib/Telemetry/Metrics.d.ts.map +1 -1
- package/lib/Telemetry/Metrics.js +20 -0
- package/lib/Telemetry/Metrics.js.map +1 -1
- package/lib/Util/PlatformServices.d.ts +3 -0
- package/lib/Util/PlatformServices.d.ts.map +1 -1
- package/lib/Util/PlatformServices.js +13 -0
- package/lib/Util/PlatformServices.js.map +1 -1
- package/lib/Util/layer-scoped.d.ts +16 -0
- package/lib/Util/layer-scoped.d.ts.map +1 -0
- package/lib/Util/layer-scoped.js +14 -0
- package/lib/Util/layer-scoped.js.map +1 -0
- package/package.json +4 -2
- package/src/AWS/Lambda/Function.ts +30 -24
- package/src/AWS/Lambda/HttpServer.ts +3 -1
- package/src/AdoptPolicy.ts +9 -9
- package/src/AlchemyContext.ts +2 -0
- package/src/Build/Command.ts +8 -1
- package/src/Bundle/Bundle.ts +65 -25
- package/src/Cloudflare/Container/Container.ts +55 -2
- package/src/Cloudflare/D1/D1Export.ts +1 -5
- package/src/Cloudflare/D1/D1SqlFile.ts +1 -3
- package/src/Cloudflare/Local/Sidecar.ts +44 -0
- package/src/Cloudflare/Local/SidecarHandlers.ts +182 -0
- package/src/Cloudflare/Local/SidecarServer.ts +57 -0
- package/src/Cloudflare/Queue/Queue.ts +34 -4
- package/src/Cloudflare/Queue/QueueBinding.ts +33 -0
- package/src/Cloudflare/SecretsStore/Secret.ts +78 -7
- package/src/Cloudflare/SecretsStore/SecretsStore.ts +47 -12
- package/src/Cloudflare/StateStore/State.ts +281 -64
- package/src/Cloudflare/StateStore/index.ts +1 -1
- package/src/Cloudflare/Workers/Assets.ts +10 -1
- package/src/Cloudflare/Workers/Compatibility.ts +12 -0
- package/src/Cloudflare/Workers/DurableObjectNamespace.ts +103 -6
- package/src/Cloudflare/Workers/LocalWorkerProvider.ts +93 -0
- package/src/Cloudflare/Workers/Worker.ts +102 -19
- package/src/Cloudflare/Workers/WorkerBundle.ts +234 -0
- package/src/Cloudflare/Workers/WorkerName.ts +10 -0
- package/src/Cloudflare/Workers/Workflow.ts +94 -0
- package/src/Cloudflare/Workers/index.ts +1 -0
- package/src/Platform.ts +2 -2
- package/src/Sidecar/Lock.ts +190 -0
- package/src/Sidecar/RpcClient.ts +141 -0
- package/src/Sidecar/RpcHandler.ts +169 -0
- package/src/Sidecar/RpcPaths.ts +29 -0
- package/src/Sidecar/RpcServer.ts +80 -0
- package/src/Sidecar/RpcTransport.ts +85 -0
- package/src/Telemetry/Metrics.ts +38 -0
- package/src/Util/PlatformServices.ts +19 -0
- 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
|
+
);
|