effect-cf 0.48.0 → 0.50.0

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.
@@ -0,0 +1,388 @@
1
+ import * as Context from "effect/Context";
2
+ import * as Data from "effect/Data";
3
+ import * as Duration from "effect/Duration";
4
+ import * as Effect from "effect/Effect";
5
+ import * as Layer from "effect/Layer";
6
+ import * as Option from "effect/Option";
7
+ import * as Stream from "effect/Stream";
8
+ import type { Scope } from "effect";
9
+
10
+ import { DurableObjectState } from "./DurableObjectState";
11
+ import * as ErrorMessage from "./internal/ErrorMessage";
12
+
13
+ export type ContainerStartOptions = globalThis.ContainerStartupOptions;
14
+ export type ContainerExecOptions = Omit<globalThis.ContainerExecOptions, "signal">;
15
+ export type ContainerInfo = globalThis.ContainerInfo;
16
+ export type ContainerSnapshot = globalThis.ContainerSnapshot;
17
+ export type ContainerSnapshotOptions = globalThis.ContainerSnapshotOptions;
18
+ export type ContainerExecOutput = globalThis.ExecOutput;
19
+
20
+ export interface ContainerExecOutputText {
21
+ readonly stdout: string;
22
+ readonly stderr: string;
23
+ readonly exitCode: number;
24
+ }
25
+
26
+ export interface ContainerProcessLog {
27
+ readonly stream: "stdout" | "stderr";
28
+ readonly data: Uint8Array;
29
+ }
30
+
31
+ export interface ContainerHttpReadinessOptions {
32
+ /** Defaults to 30 seconds. All durations accept numbers in milliseconds. */
33
+ readonly timeout?: Duration.Input;
34
+ /** Delay between probes. Defaults to 100 milliseconds. */
35
+ readonly interval?: Duration.Input;
36
+ /** Bounds each connection or HTTP probe. Defaults to 1 second. */
37
+ readonly attemptTimeout?: Duration.Input;
38
+ readonly path?: string;
39
+ /** Accepts any HTTP response unless a status range is supplied. */
40
+ readonly status?: { readonly min: number; readonly max: number };
41
+ }
42
+
43
+ export class ContainerNotConfiguredError extends Data.TaggedError("ContainerNotConfiguredError")<{
44
+ readonly durableObjectId: string;
45
+ }> {
46
+ override get message(): string {
47
+ return `No container is configured for Durable Object "${this.durableObjectId}"`;
48
+ }
49
+ }
50
+
51
+ export class ContainerError extends Data.TaggedError("ContainerError")<{
52
+ readonly operation: string;
53
+ readonly cause: unknown;
54
+ }> {
55
+ override get message(): string {
56
+ return `Durable Object container ${this.operation} failed: ${ErrorMessage.causeMessage(this.cause)}`;
57
+ }
58
+ }
59
+
60
+ export class ContainerReadinessTimeoutError extends Data.TaggedError(
61
+ "ContainerReadinessTimeoutError",
62
+ )<{
63
+ readonly port: number;
64
+ readonly timeout: Duration.Duration;
65
+ /** The last completed probe failure, when available. */
66
+ readonly cause: unknown;
67
+ }> {
68
+ override get message(): string {
69
+ return `Container port ${this.port} was not ready within ${Duration.toMillis(this.timeout)}ms`;
70
+ }
71
+ }
72
+
73
+ /** A native process handle owned by its creating request. Choose one output consumer. */
74
+ export interface ContainerProcess {
75
+ readonly raw: globalThis.ExecProcess;
76
+ readonly pid: number;
77
+ readonly isPty: boolean;
78
+ readonly stdin: globalThis.ExecProcess["stdin"];
79
+ /** Empty when the corresponding native stream is absent. */
80
+ readonly stdout: Stream.Stream<Uint8Array, ContainerError>;
81
+ readonly stderr: Stream.Stream<Uint8Array, ContainerError>;
82
+ /** Drains both channels concurrently. Single-use, without buffering or replay. */
83
+ readonly logs: Stream.Stream<ContainerProcessLog, ContainerError>;
84
+ /** Nonzero exit codes are values, not Effect failures. */
85
+ readonly exitCode: Effect.Effect<number, ContainerError>;
86
+ readonly output: Effect.Effect<ContainerExecOutput, ContainerError>;
87
+ readonly outputText: Effect.Effect<ContainerExecOutputText, ContainerError>;
88
+ /** Signals the main process only; does nothing after its exit has been observed. */
89
+ readonly kill: (signal?: number) => Effect.Effect<void, ContainerError>;
90
+ readonly resize: (cols: number, rows: number) => Effect.Effect<void, ContainerError>;
91
+ }
92
+
93
+ export interface ContainerTcpPort {
94
+ readonly raw: globalThis.Fetcher;
95
+ readonly fetch: (
96
+ input: RequestInfo | URL,
97
+ init?: RequestInit,
98
+ ) => Effect.Effect<Response, ContainerError>;
99
+ /** Forwards an authorized preview request once, using the container's HTTP transport. */
100
+ readonly proxy: (request: Request) => Effect.Effect<Response, ContainerError>;
101
+ /** The caller owns the returned socket and must close it. */
102
+ readonly connect: (
103
+ ...args: Parameters<globalThis.Fetcher["connect"]>
104
+ ) => Effect.Effect<ReturnType<globalThis.Fetcher["connect"]>, ContainerError>;
105
+ }
106
+
107
+ /** Direct container control inside a Durable Object; lifecycle policy belongs to the application. */
108
+ export interface DurableObjectContainerService {
109
+ readonly raw: globalThis.Container;
110
+ readonly running: Effect.Effect<boolean, ContainerError>;
111
+ readonly images: Effect.Effect<Readonly<Record<string, string>>, ContainerError>;
112
+ readonly inspect: Effect.Effect<Option.Option<ContainerInfo>, ContainerError>;
113
+ /** Validates options and initiates startup. It does not wait for readiness. */
114
+ readonly start: (options?: ContainerStartOptions) => Effect.Effect<void, ContainerError>;
115
+ /** Waits for the container to stop; failures retain the native cause, including its exitCode. */
116
+ readonly monitor: Effect.Effect<void, ContainerError>;
117
+ readonly destroy: (
118
+ reason?: Parameters<globalThis.Container["destroy"]>[0],
119
+ ) => Effect.Effect<void, ContainerError>;
120
+ readonly signal: (signal: number) => Effect.Effect<void, ContainerError>;
121
+ /** Effect duration input: numbers are milliseconds; bigints are nanoseconds. */
122
+ readonly setInactivityTimeout: (duration: Duration.Input) => Effect.Effect<void, ContainerError>;
123
+ readonly getTcpPort: (port: number) => Effect.Effect<ContainerTcpPort, ContainerError>;
124
+ /** Sends HTTP GET probes to an already-started container. Never retries a caller's request. */
125
+ readonly waitForHttp: (
126
+ port: number,
127
+ options?: ContainerHttpReadinessOptions,
128
+ ) => Effect.Effect<void, ContainerError | ContainerReadinessTimeoutError>;
129
+ /**
130
+ * Runs an executable with arguments, without a shell or automatic container startup.
131
+ * Interruption cancels acquisition. After acquisition, the caller owns the process.
132
+ * To observe it from later requests, redirect output to files and ignore the native pipes.
133
+ */
134
+ readonly exec: (
135
+ command: ReadonlyArray<string>,
136
+ options?: ContainerExecOptions,
137
+ ) => Effect.Effect<ContainerProcess, ContainerError>;
138
+ /** Sends SIGKILL to a still-running main process when the scope closes. */
139
+ readonly execScoped: (
140
+ command: ReadonlyArray<string>,
141
+ options?: ContainerExecOptions,
142
+ ) => Effect.Effect<ContainerProcess, ContainerError, Scope.Scope>;
143
+ readonly snapshotContainer: (
144
+ options?: ContainerSnapshotOptions,
145
+ ) => Effect.Effect<ContainerSnapshot, ContainerError>;
146
+ readonly interceptOutboundHttp: (
147
+ address: string,
148
+ binding: globalThis.Fetcher,
149
+ ) => Effect.Effect<void, ContainerError>;
150
+ readonly interceptAllOutboundHttp: (
151
+ binding: globalThis.Fetcher,
152
+ ) => Effect.Effect<void, ContainerError>;
153
+ readonly interceptOutboundHttps: (
154
+ address: string,
155
+ binding: globalThis.Fetcher,
156
+ ) => Effect.Effect<void, ContainerError>;
157
+ }
158
+
159
+ export class DurableObjectContainer extends Context.Service<
160
+ DurableObjectContainer,
161
+ DurableObjectContainerService
162
+ >()("effect-cf/DurableObjectContainer") {}
163
+
164
+ const attempt = <A>(operation: string, evaluate: () => A): Effect.Effect<A, ContainerError> =>
165
+ Effect.try({
166
+ try: evaluate,
167
+ catch: (cause) => new ContainerError({ operation, cause }),
168
+ });
169
+
170
+ const attemptPromise = <A>(
171
+ operation: string,
172
+ evaluate: (signal: AbortSignal) => Promise<A>,
173
+ ): Effect.Effect<A, ContainerError> =>
174
+ Effect.tryPromise({
175
+ try: evaluate,
176
+ catch: (cause) => new ContainerError({ operation, cause }),
177
+ });
178
+
179
+ const processStream = (
180
+ stream: globalThis.ExecProcess["stdout"] | undefined,
181
+ operation: string,
182
+ ): Stream.Stream<Uint8Array, ContainerError> =>
183
+ stream === null || stream === undefined
184
+ ? Stream.empty
185
+ : Stream.fromReadableStream({
186
+ evaluate: () => stream,
187
+ onError: (cause) => new ContainerError({ operation, cause }),
188
+ });
189
+
190
+ const fromProcess = (process: globalThis.ExecProcess): ContainerProcess => {
191
+ let exited = false;
192
+ const onExit = () => {
193
+ exited = true;
194
+ };
195
+
196
+ // Signaling an exited native process can raise an uncaught runtime error.
197
+ // Observe completion immediately, even if the caller never awaits exitCode.
198
+ process.exitCode.then(onExit, onExit);
199
+
200
+ const stdout = processStream(process.stdout, "exec.stdout");
201
+ const stderr = processStream(process.stderr, "exec.stderr");
202
+ const output = attemptPromise("exec.output", () => process.output());
203
+
204
+ return {
205
+ raw: process,
206
+ pid: process.pid,
207
+ isPty: process.isPty,
208
+ stdin: process.stdin ?? null,
209
+ stdout,
210
+ stderr,
211
+ logs: Stream.merge(
212
+ Stream.map(stdout, (data): ContainerProcessLog => ({ stream: "stdout", data })),
213
+ Stream.map(stderr, (data): ContainerProcessLog => ({ stream: "stderr", data })),
214
+ ),
215
+ exitCode: attemptPromise("exec.exitCode", () => process.exitCode),
216
+ output,
217
+ outputText: Effect.map(output, (result) => ({
218
+ stdout: new TextDecoder().decode(result.stdout),
219
+ stderr: new TextDecoder().decode(result.stderr),
220
+ exitCode: result.exitCode,
221
+ })),
222
+ kill: (signal) =>
223
+ attemptPromise("exec.kill", async () => {
224
+ // Let an already-settled exitCode notify us before attempting a signal.
225
+ await Promise.resolve();
226
+ if (!exited) process.kill(signal);
227
+ }),
228
+ resize: (cols, rows) => attempt("exec.resize", () => process.resize(cols, rows)),
229
+ };
230
+ };
231
+
232
+ const fromTcpPort = (port: globalThis.Fetcher): ContainerTcpPort => {
233
+ const fetch: ContainerTcpPort["fetch"] = (input, init) =>
234
+ attemptPromise("getTcpPort.fetch", (signal) => {
235
+ const callerSignal =
236
+ init?.signal !== undefined
237
+ ? init.signal
238
+ : input instanceof Request
239
+ ? input.signal
240
+ : undefined;
241
+
242
+ return port.fetch(input, {
243
+ ...init,
244
+ signal:
245
+ callerSignal === null || callerSignal === undefined
246
+ ? signal
247
+ : AbortSignal.any([callerSignal, signal]),
248
+ });
249
+ });
250
+
251
+ return {
252
+ raw: port,
253
+ fetch,
254
+ proxy: (request) =>
255
+ attempt("getTcpPort.proxy", () => {
256
+ const url = new URL(request.url);
257
+
258
+ url.protocol = "http:";
259
+
260
+ return new Request(url, request);
261
+ }).pipe(Effect.flatMap((forwarded) => fetch(forwarded))),
262
+ connect: (...args) => attempt("getTcpPort.connect", () => port.connect(...args)),
263
+ };
264
+ };
265
+
266
+ /** Wraps `ctx.container` without starting it or taking ownership of its lifecycle. */
267
+ export const fromContainer = (container: globalThis.Container): DurableObjectContainerService => {
268
+ const exec = (command: ReadonlyArray<string>, options?: ContainerExecOptions) =>
269
+ attemptPromise("exec", (signal) => container.exec([...command], { ...options, signal })).pipe(
270
+ Effect.map(fromProcess),
271
+ );
272
+ const waitForHttp = Effect.fnUntraced(function* (
273
+ portNumber: number,
274
+ options: ContainerHttpReadinessOptions = {},
275
+ ) {
276
+ const timeout = yield* attempt("waitForHttp", () =>
277
+ Duration.fromInputUnsafe(options.timeout ?? "30 seconds"),
278
+ );
279
+ let lastFailure: unknown;
280
+ const probe = attemptPromise("waitForHttp", async (signal) => {
281
+ // Reacquire after a failed probe so workerd can discard a failed cached port capability.
282
+ const response = await container
283
+ .getTcpPort(portNumber)
284
+ .fetch(new URL(options.path ?? "/", "http://container"), { signal });
285
+
286
+ await response.body?.cancel();
287
+ if (
288
+ options.status !== undefined &&
289
+ (response.status < options.status.min || response.status > options.status.max)
290
+ ) {
291
+ throw new Error(`HTTP readiness probe returned status ${response.status}`);
292
+ }
293
+ });
294
+ const poll = Effect.gen(function* () {
295
+ while (true) {
296
+ if (!(yield* attempt("running", () => container.running))) {
297
+ return yield* new ContainerError({
298
+ operation: "waitForHttp",
299
+ cause: new Error("Container stopped before the port became ready"),
300
+ });
301
+ }
302
+ const ready = yield* probe.pipe(
303
+ Effect.timeout(options.attemptTimeout ?? "1 second"),
304
+ Effect.match({
305
+ onSuccess: () => true,
306
+ onFailure: (error) => {
307
+ lastFailure = error;
308
+
309
+ return false;
310
+ },
311
+ }),
312
+ );
313
+
314
+ if (ready) return;
315
+ yield* Effect.sleep(options.interval ?? "100 millis");
316
+ }
317
+ });
318
+
319
+ return yield* poll.pipe(
320
+ Effect.timeoutOrElse({
321
+ duration: timeout,
322
+ orElse: () =>
323
+ Effect.fail(
324
+ new ContainerReadinessTimeoutError({ port: portNumber, timeout, cause: lastFailure }),
325
+ ),
326
+ }),
327
+ );
328
+ });
329
+
330
+ return {
331
+ raw: container,
332
+ running: attempt("running", () => container.running),
333
+ images: attempt("images", () => container.images),
334
+ inspect: attemptPromise("inspect", () => container.inspect()).pipe(
335
+ Effect.map(Option.fromNullishOr),
336
+ ),
337
+ start: (options) => attempt("start", () => container.start(options)),
338
+ monitor: attemptPromise("monitor", () => container.monitor()),
339
+ destroy: (reason) => attemptPromise("destroy", () => container.destroy(reason)),
340
+ signal: (signal) => attempt("signal", () => container.signal(signal)),
341
+ setInactivityTimeout: (duration) =>
342
+ attemptPromise("setInactivityTimeout", () =>
343
+ container.setInactivityTimeout(Duration.toMillis(duration)),
344
+ ),
345
+ getTcpPort: (port) => attempt("getTcpPort", () => fromTcpPort(container.getTcpPort(port))),
346
+ waitForHttp,
347
+ exec,
348
+ execScoped: (command, options) =>
349
+ Effect.acquireRelease(
350
+ exec(command, options),
351
+ (process) => process.kill(9).pipe(Effect.orDie),
352
+ {
353
+ interruptible: true,
354
+ },
355
+ ),
356
+ snapshotContainer: (options = {}) =>
357
+ attemptPromise("snapshotContainer", () => container.snapshotContainer(options)),
358
+ interceptOutboundHttp: (address, binding) =>
359
+ attemptPromise("interceptOutboundHttp", () =>
360
+ container.interceptOutboundHttp(address, binding),
361
+ ),
362
+ interceptAllOutboundHttp: (binding) =>
363
+ attemptPromise("interceptAllOutboundHttp", () => container.interceptAllOutboundHttp(binding)),
364
+ interceptOutboundHttps: (address, binding) =>
365
+ attemptPromise("interceptOutboundHttps", () =>
366
+ container.interceptOutboundHttps(address, binding),
367
+ ),
368
+ };
369
+ };
370
+
371
+ /** Supplies the container attached to the current Durable Object, without owning it. */
372
+ export const layer: Layer.Layer<
373
+ DurableObjectContainer,
374
+ ContainerNotConfiguredError,
375
+ DurableObjectState
376
+ > = Layer.effect(
377
+ DurableObjectContainer,
378
+ Effect.gen(function* () {
379
+ const state = yield* DurableObjectState;
380
+ const container = state.raw.container;
381
+
382
+ if (container === undefined) {
383
+ return yield* new ContainerNotConfiguredError({ durableObjectId: state.id.toString() });
384
+ }
385
+
386
+ return fromContainer(container);
387
+ }),
388
+ );