alchemy 2.0.0-beta.23 → 2.0.0-beta.25
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 +12574 -2451
- 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/exec.ts +22 -0
- 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/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/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/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/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 +89 -31
- 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/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/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/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/Local/Sidecar.ts +44 -0
- package/src/Cloudflare/Local/SidecarHandlers.ts +182 -0
- package/src/Cloudflare/Local/SidecarServer.ts +57 -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/Compatibility.ts +12 -0
- package/src/Cloudflare/Workers/LocalWorkerProvider.ts +93 -0
- package/src/Cloudflare/Workers/Worker.ts +151 -55
- package/src/Cloudflare/Workers/WorkerBundle.ts +234 -0
- package/src/Cloudflare/Workers/WorkerName.ts +10 -0
- package/src/Cloudflare/Workers/index.ts +1 -0
- package/src/Platform.ts +1 -1
- 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
|
@@ -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
|
+
);
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
import * as Effect from "effect/Effect";
|
|
2
|
+
import * as Fiber from "effect/Fiber";
|
|
3
|
+
import * as FileSystem from "effect/FileSystem";
|
|
4
|
+
import * as Layer from "effect/Layer";
|
|
5
|
+
import * as Schedule from "effect/Schedule";
|
|
6
|
+
import * as AlchemyContext from "../AlchemyContext.ts";
|
|
7
|
+
import * as Lock from "./Lock.ts";
|
|
8
|
+
import {
|
|
9
|
+
serializeRpcHandlers,
|
|
10
|
+
type RpcHandlerEncoders,
|
|
11
|
+
type RpcHandlers,
|
|
12
|
+
} from "./RpcHandler.ts";
|
|
13
|
+
import * as RpcPaths from "./RpcPaths.ts";
|
|
14
|
+
import { makeBunWebSocketRpcServer } from "./RpcTransport.ts";
|
|
15
|
+
|
|
16
|
+
export const layerServices = (main: string) =>
|
|
17
|
+
Layer.provideMerge(
|
|
18
|
+
Lock.LockLive,
|
|
19
|
+
Layer.provideMerge(RpcPaths.layer(main), AlchemyContext.AlchemyContextLive),
|
|
20
|
+
);
|
|
21
|
+
|
|
22
|
+
export const makeRpcServer = Effect.fn(function* <T extends RpcHandlers, E, R>(
|
|
23
|
+
handlersEffect: Effect.Effect<T, E, R>,
|
|
24
|
+
schema: RpcHandlerEncoders<T>,
|
|
25
|
+
) {
|
|
26
|
+
const lock = yield* Lock.Lock.use((lock) => lock.acquire);
|
|
27
|
+
const fs = yield* FileSystem.FileSystem;
|
|
28
|
+
const paths = yield* RpcPaths.RpcPaths;
|
|
29
|
+
|
|
30
|
+
const server = yield* Effect.gen(function* () {
|
|
31
|
+
const heartbeat = yield* Heartbeat;
|
|
32
|
+
const handlers = yield* handlersEffect;
|
|
33
|
+
const server = yield* Effect.acquireRelease(
|
|
34
|
+
Effect.sync(() =>
|
|
35
|
+
makeBunWebSocketRpcServer(() => {
|
|
36
|
+
const methods = Object.assign(
|
|
37
|
+
serializeRpcHandlers(handlers, schema),
|
|
38
|
+
{
|
|
39
|
+
heartbeat: () => Effect.runPromise(heartbeat.touch),
|
|
40
|
+
shutdown: () => Effect.runPromise(heartbeat.shutdown),
|
|
41
|
+
},
|
|
42
|
+
);
|
|
43
|
+
void methods.heartbeat();
|
|
44
|
+
return methods;
|
|
45
|
+
}),
|
|
46
|
+
),
|
|
47
|
+
(server) => Effect.promise(() => server.stop(true)),
|
|
48
|
+
);
|
|
49
|
+
const address = `ws://${server.hostname}:${server.port}`;
|
|
50
|
+
yield* fs.writeFileString(paths.url, address);
|
|
51
|
+
yield* Effect.addFinalizer(() =>
|
|
52
|
+
fs.readFileString(paths.url).pipe(
|
|
53
|
+
Effect.flatMap((text) =>
|
|
54
|
+
text === address ? fs.remove(paths.url) : Effect.void,
|
|
55
|
+
),
|
|
56
|
+
Effect.ignore,
|
|
57
|
+
),
|
|
58
|
+
);
|
|
59
|
+
yield* heartbeat.await;
|
|
60
|
+
}).pipe(Effect.forkScoped);
|
|
61
|
+
|
|
62
|
+
yield* Fiber.joinAll([lock, server]);
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
const Heartbeat = Effect.gen(function* () {
|
|
66
|
+
let last = Date.now();
|
|
67
|
+
const fiber = yield* Effect.suspend(() => {
|
|
68
|
+
if (Date.now() - last > 10_000) {
|
|
69
|
+
return Effect.fail({ _tag: "Timeout" } as const);
|
|
70
|
+
}
|
|
71
|
+
return Effect.void;
|
|
72
|
+
}).pipe(Effect.repeat(Schedule.spaced("4 seconds")), Effect.forkScoped);
|
|
73
|
+
return {
|
|
74
|
+
touch: Effect.sync(() => {
|
|
75
|
+
last = Date.now();
|
|
76
|
+
}),
|
|
77
|
+
shutdown: Fiber.interrupt(fiber),
|
|
78
|
+
await: Fiber.join(fiber),
|
|
79
|
+
};
|
|
80
|
+
});
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
import type { ServerWebSocket } from "bun";
|
|
2
|
+
import type { RpcCompatible, RpcTransport } from "capnweb";
|
|
3
|
+
import { RpcSession } from "capnweb";
|
|
4
|
+
|
|
5
|
+
export function makeBunWebSocketRpcServer<T extends RpcCompatible<T>>(
|
|
6
|
+
main: () => T,
|
|
7
|
+
) {
|
|
8
|
+
return Bun.serve<{
|
|
9
|
+
transport: BunWebSocketRpcTransport;
|
|
10
|
+
session: RpcSession<T>;
|
|
11
|
+
}>({
|
|
12
|
+
port: 0,
|
|
13
|
+
fetch: (request, server) => {
|
|
14
|
+
if (server.upgrade(request, { data: undefined! })) {
|
|
15
|
+
return;
|
|
16
|
+
}
|
|
17
|
+
return new Response("Upgrade failed", { status: 400 });
|
|
18
|
+
},
|
|
19
|
+
websocket: {
|
|
20
|
+
open: (ws) => {
|
|
21
|
+
const transport = new BunWebSocketRpcTransport(ws);
|
|
22
|
+
const session = new RpcSession<T>(transport, main());
|
|
23
|
+
ws.data = { transport, session };
|
|
24
|
+
},
|
|
25
|
+
message: (ws, message) => {
|
|
26
|
+
ws.data.transport.dispatchMessage(message);
|
|
27
|
+
},
|
|
28
|
+
close: (ws, code, reason) => {
|
|
29
|
+
ws.data.transport.dispatchClose(code, reason);
|
|
30
|
+
},
|
|
31
|
+
},
|
|
32
|
+
});
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
class BunWebSocketRpcTransport implements RpcTransport {
|
|
36
|
+
private receiveQueue: Array<string> = [];
|
|
37
|
+
private receiveResolver?: (value: string) => void;
|
|
38
|
+
private receiveRejecter?: (reason: unknown) => void;
|
|
39
|
+
private error?: unknown;
|
|
40
|
+
constructor(private readonly ws: ServerWebSocket<any>) {}
|
|
41
|
+
async send(message: string): Promise<void> {
|
|
42
|
+
this.ws.send(message);
|
|
43
|
+
}
|
|
44
|
+
async receive(): Promise<string> {
|
|
45
|
+
const next = this.receiveQueue.shift();
|
|
46
|
+
if (next) {
|
|
47
|
+
return next;
|
|
48
|
+
} else if (this.error) {
|
|
49
|
+
throw this.error;
|
|
50
|
+
}
|
|
51
|
+
return new Promise((resolve, reject) => {
|
|
52
|
+
this.receiveResolver = resolve;
|
|
53
|
+
this.receiveRejecter = reject;
|
|
54
|
+
});
|
|
55
|
+
}
|
|
56
|
+
abort?(reason: any): void {
|
|
57
|
+
const message = reason instanceof Error ? reason.message : String(reason);
|
|
58
|
+
this.ws.close(3000, message);
|
|
59
|
+
this.error ??= reason;
|
|
60
|
+
}
|
|
61
|
+
dispatchMessage(data: string | Buffer<ArrayBuffer>): void {
|
|
62
|
+
if (this.error) {
|
|
63
|
+
return;
|
|
64
|
+
}
|
|
65
|
+
data = typeof data === "string" ? data : data.toString("utf-8");
|
|
66
|
+
|
|
67
|
+
if (this.receiveResolver) {
|
|
68
|
+
this.receiveResolver(data);
|
|
69
|
+
this.receiveResolver = undefined;
|
|
70
|
+
this.receiveRejecter = undefined;
|
|
71
|
+
} else {
|
|
72
|
+
this.receiveQueue.push(data);
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
dispatchClose(code: number, reason: string): void {
|
|
76
|
+
if (!this.error) {
|
|
77
|
+
this.error = new Error(`WebSocket closed with code ${code}: ${reason}`);
|
|
78
|
+
if (this.receiveRejecter) {
|
|
79
|
+
this.receiveRejecter(this.error);
|
|
80
|
+
this.receiveRejecter = undefined;
|
|
81
|
+
this.receiveResolver = undefined;
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
}
|