effect-cf 0.28.1 → 0.30.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.
package/src/Sandbox.ts ADDED
@@ -0,0 +1,1094 @@
1
+ import type {
2
+ BackupOptions,
3
+ CheckChangesOptions,
4
+ CheckChangesResult,
5
+ CreateTerminalOptions,
6
+ DirectoryBackup,
7
+ ExecOptions,
8
+ FileWatchSSEEvent,
9
+ ISandbox,
10
+ ListFilesOptions,
11
+ MountBucketOptions,
12
+ ProcessExit,
13
+ ProcessLogEvent,
14
+ ProcessLogsOptions,
15
+ ProcessOutput,
16
+ ProcessOutputOptions,
17
+ ProcessStatus,
18
+ RestoreBackupResult,
19
+ SandboxCommand,
20
+ SandboxOptions,
21
+ SandboxProcess,
22
+ Terminal,
23
+ TerminalOutputCursor,
24
+ TerminalOutputEvent,
25
+ TerminalOutputOptions,
26
+ TerminalSnapshot,
27
+ TunnelInfo,
28
+ TunnelOptions,
29
+ WaitForExitOptions,
30
+ WaitForLogOptions,
31
+ WaitForLogResult,
32
+ WaitForPortOptions,
33
+ WatchOptions,
34
+ } from "@cloudflare/sandbox";
35
+ import { Context, Data, Effect, Option, Predicate, Stream, type Layer } from "effect";
36
+
37
+ import * as Binding from "./Binding";
38
+ import type { ContainerStartOptions, ContainerStopSignal } from "./ContainerNamespace";
39
+ import { WorkerEnvironment } from "./Environment";
40
+ import * as ErrorMessage from "./internal/ErrorMessage";
41
+
42
+ export type {
43
+ BackupOptions,
44
+ CheckChangesOptions,
45
+ CheckChangesResult,
46
+ CreateTerminalOptions,
47
+ DirectoryBackup,
48
+ ExecOptions,
49
+ FileWatchSSEEvent,
50
+ ISandbox,
51
+ ListFilesOptions,
52
+ MountBucketOptions,
53
+ ProcessExit,
54
+ ProcessLogEvent,
55
+ ProcessLogsOptions,
56
+ ProcessOutput,
57
+ ProcessOutputOptions,
58
+ ProcessStatus,
59
+ RestoreBackupResult,
60
+ SandboxCommand,
61
+ SandboxOptions,
62
+ SandboxProcess,
63
+ Terminal,
64
+ TerminalOutputCursor,
65
+ TerminalOutputEvent,
66
+ TerminalOutputOptions,
67
+ TerminalSnapshot,
68
+ TunnelInfo,
69
+ TunnelOptions,
70
+ WaitForExitOptions,
71
+ WaitForLogOptions,
72
+ WaitForLogResult,
73
+ WaitForPortOptions,
74
+ WatchOptions,
75
+ } from "@cloudflare/sandbox";
76
+
77
+ const expectedSandboxNamespace =
78
+ "Sandbox Durable Object namespace binding with idFromName() and get()";
79
+
80
+ /** Sandbox namespace binding metadata. */
81
+ export interface SandboxDefinition {
82
+ /** Binding name as configured in `wrangler.jsonc`. */
83
+ readonly binding: string;
84
+ }
85
+
86
+ /** Failure raised when invoking a sandbox operation. */
87
+ export class SandboxOperationError extends Data.TaggedError("SandboxOperationError")<{
88
+ readonly binding: string;
89
+ readonly instance: string;
90
+ readonly operation: string;
91
+ readonly cause: unknown;
92
+ }> {
93
+ override get message(): string {
94
+ return `Sandbox ${this.operation} failed for binding "${this.binding}" instance "${this.instance}": ${ErrorMessage.causeMessage(this.cause)}`;
95
+ }
96
+ }
97
+
98
+ /**
99
+ * Structural Durable Object namespace shape required by `getSandbox()`.
100
+ *
101
+ * This is structural so importing this module does not require
102
+ * `@cloudflare/sandbox` at runtime; the package is loaded lazily when a
103
+ * sandbox instance is first requested.
104
+ */
105
+ export interface SandboxNamespaceResource {
106
+ idFromName(name: string): globalThis.DurableObjectId;
107
+ get(
108
+ id: globalThis.DurableObjectId,
109
+ options?: globalThis.DurableObjectNamespaceGetDurableObjectOptions,
110
+ ): globalThis.DurableObjectStub;
111
+ }
112
+
113
+ /** Result of `writeFile` as reported by the sandbox client. */
114
+ export type WriteFileResult = Awaited<ReturnType<ISandbox["writeFile"]>>;
115
+
116
+ /** Result of `readFile` as reported by the sandbox client. */
117
+ export type ReadFileResult = Awaited<ReturnType<ISandbox["readFile"]>>;
118
+
119
+ /** Result of `mkdir` as reported by the sandbox client. */
120
+ export type MkdirResult = Awaited<ReturnType<ISandbox["mkdir"]>>;
121
+
122
+ /** Result of `deleteFile` as reported by the sandbox client. */
123
+ export type DeleteFileResult = Awaited<ReturnType<ISandbox["deleteFile"]>>;
124
+
125
+ /** Result of `renameFile` as reported by the sandbox client. */
126
+ export type RenameFileResult = Awaited<ReturnType<ISandbox["renameFile"]>>;
127
+
128
+ /** Result of `moveFile` as reported by the sandbox client. */
129
+ export type MoveFileResult = Awaited<ReturnType<ISandbox["moveFile"]>>;
130
+
131
+ /** Result of `listFiles` as reported by the sandbox client. */
132
+ export type ListFilesResult = Awaited<ReturnType<ISandbox["listFiles"]>>;
133
+
134
+ /** Result of `exists` as reported by the sandbox client. */
135
+ export type FileExistsResult = Awaited<ReturnType<ISandbox["exists"]>>;
136
+
137
+ /** Text encodings accepted by `readFile`. */
138
+ export type ReadFileEncoding = "utf-8" | "utf8" | "base64";
139
+
140
+ /** Options accepted by `exposePort`. */
141
+ export interface ExposePortOptions {
142
+ readonly name?: string;
143
+ readonly hostname: string;
144
+ readonly token?: string;
145
+ }
146
+
147
+ /** Preview URL information returned by `exposePort`. */
148
+ export interface ExposedPort {
149
+ readonly url: string;
150
+ readonly port: number;
151
+ readonly name?: string;
152
+ }
153
+
154
+ /** Currently forwardable preview URL returned by `getExposedPorts`. */
155
+ export interface ExposedPortStatus {
156
+ readonly url: string;
157
+ readonly port: number;
158
+ readonly status: "active";
159
+ }
160
+
161
+ /** Structural tunnel API surface exposed by a sandbox client. */
162
+ export interface SandboxTunnelsResource {
163
+ get(port: number, options?: TunnelOptions): Promise<TunnelInfo>;
164
+ list(): Promise<Array<TunnelInfo>>;
165
+ destroy(portOrInfo: number | TunnelInfo): Promise<void>;
166
+ }
167
+
168
+ /**
169
+ * Structural surface of the client returned by `getSandbox()` that this
170
+ * module wraps: the `ISandbox` RPC contract plus the lifecycle, port, and
171
+ * tunnel methods the client proxy forwards to the Sandbox Durable Object.
172
+ */
173
+ export interface SandboxClientResource extends ISandbox {
174
+ start(options?: ContainerStartOptions): Promise<void>;
175
+ stop(signal?: ContainerStopSignal): Promise<void>;
176
+ destroy(): Promise<void>;
177
+ containerFetch(requestOrUrl: Request | string | URL, port?: number): Promise<Response>;
178
+ exposePort(port: number, options: ExposePortOptions): Promise<ExposedPort>;
179
+ unexposePort(port: number): Promise<void>;
180
+ getExposedPorts(hostname: string): Promise<Array<ExposedPortStatus>>;
181
+ isPortExposed(port: number): Promise<boolean>;
182
+ validatePortToken(port: number, token: string): Promise<boolean>;
183
+ readonly tunnels: SandboxTunnelsResource;
184
+ }
185
+
186
+ /** Effect-wrapped handle for one process started inside a sandbox. */
187
+ export interface SandboxProcessHandle {
188
+ readonly id: string;
189
+ readonly pid: number;
190
+ readonly rawUnsafe: Effect.Effect<SandboxProcess>;
191
+ readonly status: Effect.Effect<ProcessStatus, SandboxOperationError>;
192
+ readonly exitCode: Effect.Effect<number, SandboxOperationError>;
193
+ readonly logs: (
194
+ options?: Omit<ProcessLogsOptions, "signal">,
195
+ ) => Stream.Stream<ProcessLogEvent, SandboxOperationError>;
196
+ readonly waitForLog: (
197
+ pattern: string | RegExp,
198
+ options?: Omit<WaitForLogOptions, "signal">,
199
+ ) => Effect.Effect<WaitForLogResult, SandboxOperationError>;
200
+ readonly waitForExit: (
201
+ options?: Omit<WaitForExitOptions, "signal">,
202
+ ) => Effect.Effect<ProcessExit, SandboxOperationError>;
203
+ readonly output: (
204
+ options?: Omit<ProcessOutputOptions, "signal">,
205
+ ) => Effect.Effect<ProcessOutput<Uint8Array>, SandboxOperationError>;
206
+ readonly outputText: (
207
+ options?: Omit<ProcessOutputOptions, "signal">,
208
+ ) => Effect.Effect<ProcessOutput<string>, SandboxOperationError>;
209
+ readonly waitForPort: (
210
+ port: number,
211
+ options?: Omit<WaitForPortOptions, "signal">,
212
+ ) => Effect.Effect<void, SandboxOperationError>;
213
+ readonly kill: (signal?: number) => Effect.Effect<void, SandboxOperationError>;
214
+ }
215
+
216
+ /** Effect-wrapped handle for one interactive terminal inside a sandbox. */
217
+ export interface SandboxTerminalHandle {
218
+ readonly id: string;
219
+ readonly rawUnsafe: Effect.Effect<Terminal>;
220
+ readonly snapshot: Effect.Effect<TerminalSnapshot, SandboxOperationError>;
221
+ readonly write: (data: Uint8Array) => Effect.Effect<void, SandboxOperationError>;
222
+ readonly resize: (cols: number, rows: number) => Effect.Effect<void, SandboxOperationError>;
223
+ readonly output: (
224
+ options?: Omit<TerminalOutputOptions, "signal">,
225
+ ) => Stream.Stream<TerminalOutputEvent, SandboxOperationError>;
226
+ readonly waitForExit: (
227
+ options?: Omit<WaitForExitOptions, "signal">,
228
+ ) => Effect.Effect<ProcessExit, SandboxOperationError>;
229
+ readonly interrupt: Effect.Effect<void, SandboxOperationError>;
230
+ readonly terminate: Effect.Effect<void, SandboxOperationError>;
231
+ readonly connect: (
232
+ request: Request,
233
+ options?: {
234
+ readonly cursor?: TerminalOutputCursor;
235
+ readonly cols?: number;
236
+ readonly rows?: number;
237
+ },
238
+ ) => Effect.Effect<Response, SandboxOperationError>;
239
+ }
240
+
241
+ /** Effect-wrapped tunnel operations for one sandbox instance. */
242
+ export interface SandboxTunnelsClient {
243
+ readonly get: (
244
+ port: number,
245
+ options?: TunnelOptions,
246
+ ) => Effect.Effect<TunnelInfo, SandboxOperationError>;
247
+ readonly list: Effect.Effect<Array<TunnelInfo>, SandboxOperationError>;
248
+ readonly destroy: (portOrInfo: number | TunnelInfo) => Effect.Effect<void, SandboxOperationError>;
249
+ }
250
+
251
+ /** Effect-wrapped client for one named sandbox instance. */
252
+ export interface SandboxInstanceClient {
253
+ readonly rawUnsafe: Effect.Effect<SandboxClientResource>;
254
+ readonly exec: (
255
+ command: SandboxCommand,
256
+ options?: ExecOptions,
257
+ ) => Effect.Effect<SandboxProcessHandle, SandboxOperationError>;
258
+ readonly getProcess: (
259
+ id: string,
260
+ ) => Effect.Effect<Option.Option<SandboxProcessHandle>, SandboxOperationError>;
261
+ readonly listProcesses: Effect.Effect<Array<ProcessStatus>, SandboxOperationError>;
262
+ readonly writeFile: (
263
+ path: string,
264
+ content: string | ReadableStream<Uint8Array>,
265
+ options?: { readonly encoding?: string },
266
+ ) => Effect.Effect<WriteFileResult, SandboxOperationError>;
267
+ readonly readFile: (
268
+ path: string,
269
+ options?: { readonly encoding?: ReadFileEncoding },
270
+ ) => Effect.Effect<ReadFileResult, SandboxOperationError>;
271
+ readonly readFileStream: (path: string) => Stream.Stream<Uint8Array, SandboxOperationError>;
272
+ readonly mkdir: (
273
+ path: string,
274
+ options?: { readonly recursive?: boolean },
275
+ ) => Effect.Effect<MkdirResult, SandboxOperationError>;
276
+ readonly deleteFile: (path: string) => Effect.Effect<DeleteFileResult, SandboxOperationError>;
277
+ readonly renameFile: (
278
+ oldPath: string,
279
+ newPath: string,
280
+ ) => Effect.Effect<RenameFileResult, SandboxOperationError>;
281
+ readonly moveFile: (
282
+ sourcePath: string,
283
+ destinationPath: string,
284
+ ) => Effect.Effect<MoveFileResult, SandboxOperationError>;
285
+ readonly listFiles: (
286
+ path: string,
287
+ options?: ListFilesOptions,
288
+ ) => Effect.Effect<ListFilesResult, SandboxOperationError>;
289
+ readonly exists: (path: string) => Effect.Effect<FileExistsResult, SandboxOperationError>;
290
+ readonly watch: (
291
+ path: string,
292
+ options?: WatchOptions,
293
+ ) => Stream.Stream<FileWatchSSEEvent, SandboxOperationError>;
294
+ readonly checkChanges: (
295
+ path: string,
296
+ options?: CheckChangesOptions,
297
+ ) => Effect.Effect<CheckChangesResult, SandboxOperationError>;
298
+ readonly setEnvVars: (
299
+ envVars: Record<string, string | undefined>,
300
+ ) => Effect.Effect<void, SandboxOperationError>;
301
+ readonly exposePort: (
302
+ port: number,
303
+ options: ExposePortOptions,
304
+ ) => Effect.Effect<ExposedPort, SandboxOperationError>;
305
+ readonly unexposePort: (port: number) => Effect.Effect<void, SandboxOperationError>;
306
+ readonly getExposedPorts: (
307
+ hostname: string,
308
+ ) => Effect.Effect<Array<ExposedPortStatus>, SandboxOperationError>;
309
+ readonly isPortExposed: (port: number) => Effect.Effect<boolean, SandboxOperationError>;
310
+ readonly validatePortToken: (
311
+ port: number,
312
+ token: string,
313
+ ) => Effect.Effect<boolean, SandboxOperationError>;
314
+ readonly containerFetch: (
315
+ requestOrUrl: Request | string | URL,
316
+ port?: number,
317
+ ) => Effect.Effect<Response, SandboxOperationError>;
318
+ readonly wsConnect: (
319
+ request: Request,
320
+ port: number,
321
+ ) => Effect.Effect<Response, SandboxOperationError>;
322
+ readonly mountBucket: (
323
+ bucket: string,
324
+ mountPath: string,
325
+ options: MountBucketOptions,
326
+ ) => Effect.Effect<void, SandboxOperationError>;
327
+ readonly unmountBucket: (mountPath: string) => Effect.Effect<void, SandboxOperationError>;
328
+ readonly createBackup: (
329
+ options: BackupOptions,
330
+ ) => Effect.Effect<DirectoryBackup, SandboxOperationError>;
331
+ readonly restoreBackup: (
332
+ backup: DirectoryBackup,
333
+ ) => Effect.Effect<RestoreBackupResult, SandboxOperationError>;
334
+ readonly createTerminal: (
335
+ options: CreateTerminalOptions,
336
+ ) => Effect.Effect<SandboxTerminalHandle, SandboxOperationError>;
337
+ readonly getTerminal: (
338
+ id: string,
339
+ ) => Effect.Effect<Option.Option<SandboxTerminalHandle>, SandboxOperationError>;
340
+ readonly listTerminals: Effect.Effect<Array<SandboxTerminalHandle>, SandboxOperationError>;
341
+ readonly tunnels: SandboxTunnelsClient;
342
+ readonly start: (options?: ContainerStartOptions) => Effect.Effect<void, SandboxOperationError>;
343
+ readonly stop: (signal?: ContainerStopSignal) => Effect.Effect<void, SandboxOperationError>;
344
+ readonly destroy: Effect.Effect<void, SandboxOperationError>;
345
+ }
346
+
347
+ /** Effect client for a Sandbox Durable Object namespace binding. */
348
+ export interface SandboxNamespaceClient<
349
+ Namespace extends SandboxNamespaceResource = SandboxNamespaceResource,
350
+ > {
351
+ readonly definition: SandboxDefinition;
352
+ readonly get: (
353
+ name: string,
354
+ options?: SandboxOptions,
355
+ ) => Effect.Effect<SandboxInstanceClient, SandboxOperationError>;
356
+ readonly rawUnsafe: Effect.Effect<Namespace>;
357
+ }
358
+
359
+ export type LayerOptions = {
360
+ readonly binding: string;
361
+ };
362
+
363
+ export interface TagClass<
364
+ Self,
365
+ Id extends string,
366
+ Namespace extends SandboxNamespaceResource = SandboxNamespaceResource,
367
+ > extends Context.ServiceClass<Self, `effect-cf/Sandbox/${Id}`, SandboxNamespaceClient<Namespace>> {
368
+ readonly id: Id;
369
+ readonly layer: (
370
+ options: LayerOptions,
371
+ ) => Layer.Layer<
372
+ Self,
373
+ Binding.BindingNotFoundError | Binding.BindingValidationError,
374
+ WorkerEnvironment
375
+ >;
376
+ readonly get: (
377
+ name: string,
378
+ options?: SandboxOptions,
379
+ ) => Effect.Effect<SandboxInstanceClient, SandboxOperationError, Self>;
380
+ readonly rawUnsafe: Effect.Effect<Namespace, never, Self>;
381
+ }
382
+
383
+ const trySandboxPromise = <A>(
384
+ definition: SandboxDefinition,
385
+ instance: string,
386
+ operation: string,
387
+ evaluate: (signal: AbortSignal) => PromiseLike<A>,
388
+ ): Effect.Effect<A, SandboxOperationError> =>
389
+ Effect.tryPromise({
390
+ try: evaluate,
391
+ catch: (cause) =>
392
+ new SandboxOperationError({
393
+ binding: definition.binding,
394
+ instance,
395
+ operation,
396
+ cause,
397
+ }),
398
+ });
399
+
400
+ const sandboxStream = <A>(
401
+ definition: SandboxDefinition,
402
+ instance: string,
403
+ operation: string,
404
+ acquire: (signal: AbortSignal) => PromiseLike<ReadableStream<A>>,
405
+ ): Stream.Stream<A, SandboxOperationError> =>
406
+ Stream.unwrap(
407
+ Effect.map(trySandboxPromise(definition, instance, operation, acquire), (readable) =>
408
+ Stream.fromReadableStream({
409
+ evaluate: () => readable,
410
+ onError: (cause) =>
411
+ new SandboxOperationError({
412
+ binding: definition.binding,
413
+ instance,
414
+ operation,
415
+ cause,
416
+ }),
417
+ }),
418
+ ),
419
+ );
420
+
421
+ const decodeSseData = <A>(payload: string): ReadonlyArray<A> => {
422
+ if (payload === "[DONE]" || payload.trim() === "") {
423
+ return [];
424
+ }
425
+
426
+ try {
427
+ // SAFETY: sandbox watch frames are JSON-encoded event payloads produced by
428
+ // the sandbox runtime; the caller fixes A to the matching event type.
429
+ return [JSON.parse(payload) as A];
430
+ } catch {
431
+ return [];
432
+ }
433
+ };
434
+
435
+ /**
436
+ * Decodes the sandbox runtime's `data:`-framed Server-Sent Events byte stream
437
+ * into JSON event values, mirroring `parseSSEStream` from
438
+ * `@cloudflare/sandbox` without requiring the package at runtime.
439
+ */
440
+ const decodeSseEvents = <A, E, R>(bytes: Stream.Stream<Uint8Array, E, R>): Stream.Stream<A, E, R> =>
441
+ bytes.pipe(
442
+ Stream.decodeText(),
443
+ Stream.splitLines,
444
+ Stream.mapAccum(
445
+ (): ReadonlyArray<string> => [],
446
+ (pending, line) => {
447
+ if (line === "") {
448
+ return pending.length > 0 ? [[], decodeSseData<A>(pending.join("\n"))] : [pending, []];
449
+ }
450
+
451
+ if (line.startsWith("data:")) {
452
+ const value = line.startsWith("data: ") ? line.slice(6) : line.slice(5);
453
+
454
+ return [[...pending, value], []];
455
+ }
456
+
457
+ return [pending, []];
458
+ },
459
+ {
460
+ onHalt: (pending) => (pending.length > 0 ? decodeSseData<A>(pending.join("\n")) : []),
461
+ },
462
+ ),
463
+ );
464
+
465
+ const makeProcessHandle = (
466
+ definition: SandboxDefinition,
467
+ instance: string,
468
+ process: SandboxProcess,
469
+ ): SandboxProcessHandle => {
470
+ const spanOptions = (operation: string) => ({
471
+ attributes: {
472
+ binding: definition.binding,
473
+ instance,
474
+ processId: process.id,
475
+ operation,
476
+ },
477
+ });
478
+
479
+ return {
480
+ id: process.id,
481
+ pid: process.pid,
482
+ rawUnsafe: Effect.succeed(process),
483
+ status: trySandboxPromise(definition, instance, "process.status", () => process.status()).pipe(
484
+ Effect.withSpan("Sandbox.process.status", spanOptions("process.status")),
485
+ ),
486
+ exitCode: trySandboxPromise(
487
+ definition,
488
+ instance,
489
+ "process.exitCode",
490
+ () => process.exitCode,
491
+ ).pipe(Effect.withSpan("Sandbox.process.exitCode", spanOptions("process.exitCode"))),
492
+ logs: (options?: Omit<ProcessLogsOptions, "signal">) =>
493
+ sandboxStream(definition, instance, "process.logs", (signal) =>
494
+ process.logs({ ...options, signal }),
495
+ ),
496
+ waitForLog: Effect.fn(
497
+ "Sandbox.process.waitForLog",
498
+ spanOptions("process.waitForLog"),
499
+ )(function* (pattern: string | RegExp, options?: Omit<WaitForLogOptions, "signal">) {
500
+ return yield* trySandboxPromise(definition, instance, "process.waitForLog", (signal) =>
501
+ process.waitForLog(pattern, { ...options, signal }),
502
+ );
503
+ }),
504
+ waitForExit: Effect.fn(
505
+ "Sandbox.process.waitForExit",
506
+ spanOptions("process.waitForExit"),
507
+ )(function* (options?: Omit<WaitForExitOptions, "signal">) {
508
+ return yield* trySandboxPromise(definition, instance, "process.waitForExit", (signal) =>
509
+ process.waitForExit({ ...options, signal }),
510
+ );
511
+ }),
512
+ output: Effect.fn(
513
+ "Sandbox.process.output",
514
+ spanOptions("process.output"),
515
+ )(function* (options?: Omit<ProcessOutputOptions, "signal">) {
516
+ return yield* trySandboxPromise(definition, instance, "process.output", (signal) =>
517
+ process.output({ ...options, signal }),
518
+ );
519
+ }),
520
+ outputText: Effect.fn(
521
+ "Sandbox.process.outputText",
522
+ spanOptions("process.outputText"),
523
+ )(function* (options?: Omit<ProcessOutputOptions, "signal">) {
524
+ return yield* trySandboxPromise(definition, instance, "process.outputText", (signal) =>
525
+ process.output({ ...options, encoding: "utf8", signal }),
526
+ );
527
+ }),
528
+ waitForPort: Effect.fn(
529
+ "Sandbox.process.waitForPort",
530
+ spanOptions("process.waitForPort"),
531
+ )(function* (port: number, options?: Omit<WaitForPortOptions, "signal">) {
532
+ return yield* trySandboxPromise(definition, instance, "process.waitForPort", (signal) =>
533
+ process.waitForPort(port, { ...options, signal }),
534
+ );
535
+ }),
536
+ kill: Effect.fn(
537
+ "Sandbox.process.kill",
538
+ spanOptions("process.kill"),
539
+ )(function* (signal?: number) {
540
+ return yield* trySandboxPromise(definition, instance, "process.kill", () =>
541
+ process.kill(signal),
542
+ );
543
+ }),
544
+ };
545
+ };
546
+
547
+ const makeTerminalHandle = (
548
+ definition: SandboxDefinition,
549
+ instance: string,
550
+ terminal: Terminal,
551
+ ): SandboxTerminalHandle => {
552
+ const spanOptions = (operation: string) => ({
553
+ attributes: {
554
+ binding: definition.binding,
555
+ instance,
556
+ terminalId: terminal.id,
557
+ operation,
558
+ },
559
+ });
560
+
561
+ return {
562
+ id: terminal.id,
563
+ rawUnsafe: Effect.succeed(terminal),
564
+ snapshot: trySandboxPromise(definition, instance, "terminal.snapshot", () =>
565
+ terminal.getSnapshot(),
566
+ ).pipe(Effect.withSpan("Sandbox.terminal.snapshot", spanOptions("terminal.snapshot"))),
567
+ write: Effect.fn(
568
+ "Sandbox.terminal.write",
569
+ spanOptions("terminal.write"),
570
+ )(function* (data: Uint8Array) {
571
+ return yield* trySandboxPromise(definition, instance, "terminal.write", () =>
572
+ terminal.write(data),
573
+ );
574
+ }),
575
+ resize: Effect.fn(
576
+ "Sandbox.terminal.resize",
577
+ spanOptions("terminal.resize"),
578
+ )(function* (cols: number, rows: number) {
579
+ return yield* trySandboxPromise(definition, instance, "terminal.resize", () =>
580
+ terminal.resize(cols, rows),
581
+ );
582
+ }),
583
+ output: (options?: Omit<TerminalOutputOptions, "signal">) =>
584
+ sandboxStream(definition, instance, "terminal.output", (signal) =>
585
+ terminal.output({ ...options, signal }),
586
+ ),
587
+ waitForExit: Effect.fn(
588
+ "Sandbox.terminal.waitForExit",
589
+ spanOptions("terminal.waitForExit"),
590
+ )(function* (options?: Omit<WaitForExitOptions, "signal">) {
591
+ return yield* trySandboxPromise(definition, instance, "terminal.waitForExit", (signal) =>
592
+ terminal.waitForExit({ ...options, signal }),
593
+ );
594
+ }),
595
+ interrupt: trySandboxPromise(definition, instance, "terminal.interrupt", () =>
596
+ terminal.interrupt(),
597
+ ).pipe(Effect.withSpan("Sandbox.terminal.interrupt", spanOptions("terminal.interrupt"))),
598
+ terminate: trySandboxPromise(definition, instance, "terminal.terminate", () =>
599
+ terminal.terminate(),
600
+ ).pipe(Effect.withSpan("Sandbox.terminal.terminate", spanOptions("terminal.terminate"))),
601
+ connect: Effect.fn(
602
+ "Sandbox.terminal.connect",
603
+ spanOptions("terminal.connect"),
604
+ )(function* (
605
+ request: Request,
606
+ options?: {
607
+ readonly cursor?: TerminalOutputCursor;
608
+ readonly cols?: number;
609
+ readonly rows?: number;
610
+ },
611
+ ) {
612
+ return yield* trySandboxPromise(definition, instance, "terminal.connect", () =>
613
+ terminal.connect(request, options),
614
+ );
615
+ }),
616
+ };
617
+ };
618
+
619
+ /**
620
+ * Wraps an already-created sandbox client (the value returned by
621
+ * `getSandbox()` from `@cloudflare/sandbox`) into an Effect client.
622
+ *
623
+ * Prefer {@link Tag}/{@link make} with a layer for application code; this
624
+ * entry point exists for advanced composition and testing.
625
+ */
626
+ export const fromSandboxClient = (
627
+ client: SandboxClientResource,
628
+ definition: SandboxDefinition,
629
+ instance: string,
630
+ ): SandboxInstanceClient => {
631
+ const spanOptions = (operation: string) => ({
632
+ attributes: { binding: definition.binding, instance, operation },
633
+ });
634
+
635
+ return {
636
+ rawUnsafe: Effect.succeed(client),
637
+ exec: Effect.fn(
638
+ "Sandbox.exec",
639
+ spanOptions("exec"),
640
+ )(function* (command: SandboxCommand, options?: ExecOptions) {
641
+ const process = yield* trySandboxPromise(definition, instance, "exec", () =>
642
+ client.exec(command, options),
643
+ );
644
+
645
+ return makeProcessHandle(definition, instance, process);
646
+ }),
647
+ getProcess: Effect.fn(
648
+ "Sandbox.getProcess",
649
+ spanOptions("getProcess"),
650
+ )(function* (id: string) {
651
+ const process = yield* trySandboxPromise(definition, instance, "getProcess", () =>
652
+ client.getProcess(id),
653
+ );
654
+
655
+ return Option.map(Option.fromNullishOr(process), (found) =>
656
+ makeProcessHandle(definition, instance, found),
657
+ );
658
+ }),
659
+ listProcesses: trySandboxPromise(definition, instance, "listProcesses", () =>
660
+ client.listProcesses(),
661
+ ).pipe(Effect.withSpan("Sandbox.listProcesses", spanOptions("listProcesses"))),
662
+ writeFile: Effect.fn(
663
+ "Sandbox.writeFile",
664
+ spanOptions("writeFile"),
665
+ )(function* (
666
+ path: string,
667
+ content: string | ReadableStream<Uint8Array>,
668
+ options?: { readonly encoding?: string },
669
+ ) {
670
+ yield* Effect.annotateCurrentSpan("path", path);
671
+
672
+ return yield* trySandboxPromise(definition, instance, "writeFile", () =>
673
+ client.writeFile(path, content, options),
674
+ );
675
+ }),
676
+ readFile: Effect.fn(
677
+ "Sandbox.readFile",
678
+ spanOptions("readFile"),
679
+ )(function* (path: string, options?: { readonly encoding?: ReadFileEncoding }) {
680
+ yield* Effect.annotateCurrentSpan("path", path);
681
+
682
+ return yield* trySandboxPromise(definition, instance, "readFile", () =>
683
+ client.readFile(path, options),
684
+ );
685
+ }),
686
+ readFileStream: (path: string) =>
687
+ sandboxStream(definition, instance, "readFileStream", () => client.readFileStream(path)),
688
+ mkdir: Effect.fn(
689
+ "Sandbox.mkdir",
690
+ spanOptions("mkdir"),
691
+ )(function* (path: string, options?: { readonly recursive?: boolean }) {
692
+ yield* Effect.annotateCurrentSpan("path", path);
693
+
694
+ return yield* trySandboxPromise(definition, instance, "mkdir", () =>
695
+ client.mkdir(path, options),
696
+ );
697
+ }),
698
+ deleteFile: Effect.fn(
699
+ "Sandbox.deleteFile",
700
+ spanOptions("deleteFile"),
701
+ )(function* (path: string) {
702
+ yield* Effect.annotateCurrentSpan("path", path);
703
+
704
+ return yield* trySandboxPromise(definition, instance, "deleteFile", () =>
705
+ client.deleteFile(path),
706
+ );
707
+ }),
708
+ renameFile: Effect.fn(
709
+ "Sandbox.renameFile",
710
+ spanOptions("renameFile"),
711
+ )(function* (oldPath: string, newPath: string) {
712
+ yield* Effect.annotateCurrentSpan({ oldPath, newPath });
713
+
714
+ return yield* trySandboxPromise(definition, instance, "renameFile", () =>
715
+ client.renameFile(oldPath, newPath),
716
+ );
717
+ }),
718
+ moveFile: Effect.fn(
719
+ "Sandbox.moveFile",
720
+ spanOptions("moveFile"),
721
+ )(function* (sourcePath: string, destinationPath: string) {
722
+ yield* Effect.annotateCurrentSpan({ sourcePath, destinationPath });
723
+
724
+ return yield* trySandboxPromise(definition, instance, "moveFile", () =>
725
+ client.moveFile(sourcePath, destinationPath),
726
+ );
727
+ }),
728
+ listFiles: Effect.fn(
729
+ "Sandbox.listFiles",
730
+ spanOptions("listFiles"),
731
+ )(function* (path: string, options?: ListFilesOptions) {
732
+ yield* Effect.annotateCurrentSpan("path", path);
733
+
734
+ return yield* trySandboxPromise(definition, instance, "listFiles", () =>
735
+ client.listFiles(path, options),
736
+ );
737
+ }),
738
+ exists: Effect.fn(
739
+ "Sandbox.exists",
740
+ spanOptions("exists"),
741
+ )(function* (path: string) {
742
+ yield* Effect.annotateCurrentSpan("path", path);
743
+
744
+ return yield* trySandboxPromise(definition, instance, "exists", () => client.exists(path));
745
+ }),
746
+ watch: (path: string, options?: WatchOptions) =>
747
+ decodeSseEvents<FileWatchSSEEvent, SandboxOperationError, never>(
748
+ sandboxStream(definition, instance, "watch", () => client.watch(path, options)),
749
+ ),
750
+ checkChanges: Effect.fn(
751
+ "Sandbox.checkChanges",
752
+ spanOptions("checkChanges"),
753
+ )(function* (path: string, options?: CheckChangesOptions) {
754
+ yield* Effect.annotateCurrentSpan("path", path);
755
+
756
+ return yield* trySandboxPromise(definition, instance, "checkChanges", () =>
757
+ client.checkChanges(path, options),
758
+ );
759
+ }),
760
+ setEnvVars: Effect.fn(
761
+ "Sandbox.setEnvVars",
762
+ spanOptions("setEnvVars"),
763
+ )(function* (envVars: Record<string, string | undefined>) {
764
+ return yield* trySandboxPromise(definition, instance, "setEnvVars", () =>
765
+ client.setEnvVars(envVars),
766
+ );
767
+ }),
768
+ exposePort: Effect.fn(
769
+ "Sandbox.exposePort",
770
+ spanOptions("exposePort"),
771
+ )(function* (port: number, options: ExposePortOptions) {
772
+ yield* Effect.annotateCurrentSpan("port", port);
773
+
774
+ return yield* trySandboxPromise(definition, instance, "exposePort", () =>
775
+ client.exposePort(port, options),
776
+ );
777
+ }),
778
+ unexposePort: Effect.fn(
779
+ "Sandbox.unexposePort",
780
+ spanOptions("unexposePort"),
781
+ )(function* (port: number) {
782
+ yield* Effect.annotateCurrentSpan("port", port);
783
+
784
+ return yield* trySandboxPromise(definition, instance, "unexposePort", () =>
785
+ client.unexposePort(port),
786
+ );
787
+ }),
788
+ getExposedPorts: Effect.fn(
789
+ "Sandbox.getExposedPorts",
790
+ spanOptions("getExposedPorts"),
791
+ )(function* (hostname: string) {
792
+ return yield* trySandboxPromise(definition, instance, "getExposedPorts", () =>
793
+ client.getExposedPorts(hostname),
794
+ );
795
+ }),
796
+ isPortExposed: Effect.fn(
797
+ "Sandbox.isPortExposed",
798
+ spanOptions("isPortExposed"),
799
+ )(function* (port: number) {
800
+ yield* Effect.annotateCurrentSpan("port", port);
801
+
802
+ return yield* trySandboxPromise(definition, instance, "isPortExposed", () =>
803
+ client.isPortExposed(port),
804
+ );
805
+ }),
806
+ validatePortToken: Effect.fn(
807
+ "Sandbox.validatePortToken",
808
+ spanOptions("validatePortToken"),
809
+ )(function* (port: number, token: string) {
810
+ yield* Effect.annotateCurrentSpan("port", port);
811
+
812
+ return yield* trySandboxPromise(definition, instance, "validatePortToken", () =>
813
+ client.validatePortToken(port, token),
814
+ );
815
+ }),
816
+ containerFetch: Effect.fn(
817
+ "Sandbox.containerFetch",
818
+ spanOptions("containerFetch"),
819
+ )(function* (requestOrUrl: Request | string | URL, port?: number) {
820
+ return yield* trySandboxPromise(definition, instance, "containerFetch", () =>
821
+ client.containerFetch(requestOrUrl, port),
822
+ );
823
+ }),
824
+ wsConnect: Effect.fn(
825
+ "Sandbox.wsConnect",
826
+ spanOptions("wsConnect"),
827
+ )(function* (request: Request, port: number) {
828
+ yield* Effect.annotateCurrentSpan("port", port);
829
+
830
+ return yield* trySandboxPromise(definition, instance, "wsConnect", () =>
831
+ client.wsConnect(request, port),
832
+ );
833
+ }),
834
+ mountBucket: Effect.fn(
835
+ "Sandbox.mountBucket",
836
+ spanOptions("mountBucket"),
837
+ )(function* (bucket: string, mountPath: string, options: MountBucketOptions) {
838
+ yield* Effect.annotateCurrentSpan({ bucket, mountPath });
839
+
840
+ return yield* trySandboxPromise(definition, instance, "mountBucket", () =>
841
+ client.mountBucket(bucket, mountPath, options),
842
+ );
843
+ }),
844
+ unmountBucket: Effect.fn(
845
+ "Sandbox.unmountBucket",
846
+ spanOptions("unmountBucket"),
847
+ )(function* (mountPath: string) {
848
+ yield* Effect.annotateCurrentSpan("mountPath", mountPath);
849
+
850
+ return yield* trySandboxPromise(definition, instance, "unmountBucket", () =>
851
+ client.unmountBucket(mountPath),
852
+ );
853
+ }),
854
+ createBackup: Effect.fn(
855
+ "Sandbox.createBackup",
856
+ spanOptions("createBackup"),
857
+ )(function* (options: BackupOptions) {
858
+ yield* Effect.annotateCurrentSpan("dir", options.dir);
859
+
860
+ return yield* trySandboxPromise(definition, instance, "createBackup", () =>
861
+ client.createBackup(options),
862
+ );
863
+ }),
864
+ restoreBackup: Effect.fn(
865
+ "Sandbox.restoreBackup",
866
+ spanOptions("restoreBackup"),
867
+ )(function* (backup: DirectoryBackup) {
868
+ yield* Effect.annotateCurrentSpan({ backupId: backup.id, dir: backup.dir });
869
+
870
+ return yield* trySandboxPromise(definition, instance, "restoreBackup", () =>
871
+ client.restoreBackup(backup),
872
+ );
873
+ }),
874
+ createTerminal: Effect.fn(
875
+ "Sandbox.createTerminal",
876
+ spanOptions("createTerminal"),
877
+ )(function* (options: CreateTerminalOptions) {
878
+ const terminal = yield* trySandboxPromise(definition, instance, "createTerminal", () =>
879
+ client.createTerminal(options),
880
+ );
881
+
882
+ return makeTerminalHandle(definition, instance, terminal);
883
+ }),
884
+ getTerminal: Effect.fn(
885
+ "Sandbox.getTerminal",
886
+ spanOptions("getTerminal"),
887
+ )(function* (id: string) {
888
+ const terminal = yield* trySandboxPromise(definition, instance, "getTerminal", () =>
889
+ client.getTerminal(id),
890
+ );
891
+
892
+ return Option.map(Option.fromNullishOr(terminal), (found) =>
893
+ makeTerminalHandle(definition, instance, found),
894
+ );
895
+ }),
896
+ listTerminals: trySandboxPromise(definition, instance, "listTerminals", () =>
897
+ client.listTerminals(),
898
+ ).pipe(
899
+ Effect.map((terminals) =>
900
+ terminals.map((terminal) => makeTerminalHandle(definition, instance, terminal)),
901
+ ),
902
+ Effect.withSpan("Sandbox.listTerminals", spanOptions("listTerminals")),
903
+ ),
904
+ tunnels: {
905
+ get: Effect.fn(
906
+ "Sandbox.tunnels.get",
907
+ spanOptions("tunnels.get"),
908
+ )(function* (port: number, options?: TunnelOptions) {
909
+ yield* Effect.annotateCurrentSpan("port", port);
910
+
911
+ return yield* trySandboxPromise(definition, instance, "tunnels.get", () =>
912
+ client.tunnels.get(port, options),
913
+ );
914
+ }),
915
+ list: trySandboxPromise(definition, instance, "tunnels.list", () =>
916
+ client.tunnels.list(),
917
+ ).pipe(Effect.withSpan("Sandbox.tunnels.list", spanOptions("tunnels.list"))),
918
+ destroy: Effect.fn(
919
+ "Sandbox.tunnels.destroy",
920
+ spanOptions("tunnels.destroy"),
921
+ )(function* (portOrInfo: number | TunnelInfo) {
922
+ return yield* trySandboxPromise(definition, instance, "tunnels.destroy", () =>
923
+ client.tunnels.destroy(portOrInfo),
924
+ );
925
+ }),
926
+ },
927
+ start: Effect.fn(
928
+ "Sandbox.start",
929
+ spanOptions("start"),
930
+ )(function* (options?: ContainerStartOptions) {
931
+ return yield* trySandboxPromise(definition, instance, "start", () => client.start(options));
932
+ }),
933
+ stop: Effect.fn(
934
+ "Sandbox.stop",
935
+ spanOptions("stop"),
936
+ )(function* (signal?: ContainerStopSignal) {
937
+ return yield* trySandboxPromise(definition, instance, "stop", () => client.stop(signal));
938
+ }),
939
+ destroy: trySandboxPromise(definition, instance, "destroy", () => client.destroy()).pipe(
940
+ Effect.withSpan("Sandbox.destroy", spanOptions("destroy")),
941
+ ),
942
+ };
943
+ };
944
+
945
+ /**
946
+ * Lazily imports `@cloudflare/sandbox`.
947
+ *
948
+ * The package is an optional peer dependency: loading it on first use keeps
949
+ * this module importable (and the rest of `effect-cf` bundleable) in
950
+ * applications that never touch sandboxes.
951
+ */
952
+ const importSandboxModule = (definition: SandboxDefinition, instance: string) =>
953
+ Effect.tryPromise({
954
+ try: () => import("@cloudflare/sandbox"),
955
+ catch: (cause) =>
956
+ new SandboxOperationError({
957
+ binding: definition.binding,
958
+ instance,
959
+ operation: "import",
960
+ cause,
961
+ }),
962
+ });
963
+
964
+ export const isSandboxNamespaceResource = <Candidate>(
965
+ value: Candidate,
966
+ ): value is Candidate & SandboxNamespaceResource =>
967
+ Predicate.hasProperty(value, "idFromName") &&
968
+ Predicate.isFunction(value.idFromName) &&
969
+ Predicate.hasProperty(value, "get") &&
970
+ Predicate.isFunction(value.get);
971
+
972
+ export const makeClient =
973
+ (definition: SandboxDefinition) =>
974
+ <Namespace extends SandboxNamespaceResource>(
975
+ namespace: Namespace,
976
+ ): SandboxNamespaceClient<Namespace> => {
977
+ const get = Effect.fn("Sandbox.get", {
978
+ attributes: { binding: definition.binding, operation: "get" },
979
+ })(function* (name: string, options?: SandboxOptions) {
980
+ yield* Effect.annotateCurrentSpan("instance", name);
981
+
982
+ const sandboxModule = yield* importSandboxModule(definition, name);
983
+ const nativeNamespace: unknown = namespace;
984
+ const client = yield* Effect.try({
985
+ try: () =>
986
+ sandboxModule.getSandbox(
987
+ // SAFETY: the layer validated the binding as a Durable Object
988
+ // namespace, and getSandbox only requires idFromName() and get();
989
+ // the structural namespace type is narrower than the SDK's nominal
990
+ // one, so the conversion goes through unknown.
991
+ nativeNamespace as Parameters<typeof sandboxModule.getSandbox>[0],
992
+ name,
993
+ options,
994
+ ),
995
+ catch: (cause) =>
996
+ new SandboxOperationError({
997
+ binding: definition.binding,
998
+ instance: name,
999
+ operation: "get",
1000
+ cause,
1001
+ }),
1002
+ });
1003
+
1004
+ return fromSandboxClient(client, definition, name);
1005
+ });
1006
+
1007
+ return {
1008
+ definition,
1009
+ get,
1010
+ rawUnsafe: Effect.succeed(namespace),
1011
+ };
1012
+ };
1013
+
1014
+ export const layer = <Self, Namespace extends SandboxNamespaceResource>(
1015
+ tag: Context.Service<Self, SandboxNamespaceClient<Namespace>>,
1016
+ definition: SandboxDefinition,
1017
+ ) =>
1018
+ Binding.layer(
1019
+ tag,
1020
+ definition.binding,
1021
+ (value): value is Namespace => isSandboxNamespaceResource(value),
1022
+ makeClient(definition),
1023
+ {
1024
+ expected: expectedSandboxNamespace,
1025
+ },
1026
+ );
1027
+
1028
+ export const make = <Id extends string>(id: Id) => Tag<SandboxNamespaceService<Id>>()<Id>(id);
1029
+
1030
+ declare const SandboxNamespaceServiceTypeId: unique symbol;
1031
+
1032
+ /** Nominal service marker for Sandbox namespaces created with {@link make}. */
1033
+ export interface SandboxNamespaceService<Id extends string> {
1034
+ readonly [SandboxNamespaceServiceTypeId]: {
1035
+ readonly id: Id;
1036
+ };
1037
+ }
1038
+
1039
+ /** Creates a typed Effect service for a Cloudflare Sandbox namespace. */
1040
+ export const Tag =
1041
+ <Self, Namespace extends SandboxNamespaceResource = SandboxNamespaceResource>() =>
1042
+ <Id extends string>(id: Id) => {
1043
+ const tag = Context.Service<Self, SandboxNamespaceClient<Namespace>>()(
1044
+ `effect-cf/Sandbox/${id}` as const,
1045
+ );
1046
+ const makeLayer = (definition: LayerOptions) => layer(tag, definition);
1047
+
1048
+ const get = Effect.fnUntraced(function* (name: string, options?: SandboxOptions) {
1049
+ const namespace = yield* tag;
1050
+
1051
+ return yield* namespace.get(name, options);
1052
+ });
1053
+
1054
+ const rawUnsafe = Effect.flatMap(tag, (namespace) => namespace.rawUnsafe);
1055
+
1056
+ // SAFETY: the assigned namespace helpers exactly implement TagClass for this service tag.
1057
+ return Object.assign(tag, {
1058
+ id,
1059
+ layer: makeLayer,
1060
+ get,
1061
+ rawUnsafe,
1062
+ }) as TagClass<Self, Id, Namespace>;
1063
+ };
1064
+
1065
+ export const Sandbox = Tag;
1066
+
1067
+ /**
1068
+ * Routes sandbox preview-URL requests (`https://<port>-<sandbox-id>-<token>.<host>`)
1069
+ * to the owning sandbox, mirroring `proxyToSandbox` from `@cloudflare/sandbox`.
1070
+ *
1071
+ * Call this before any other routing in a Worker `fetch` handler; it resolves
1072
+ * to `Option.none()` for requests that are not preview-URL traffic.
1073
+ */
1074
+ export const proxyToSandbox = Effect.fn("Sandbox.proxyToSandbox")(function* (
1075
+ request: Request,
1076
+ options?: { readonly binding?: string },
1077
+ ) {
1078
+ const binding = options?.binding ?? "Sandbox";
1079
+ const definition: SandboxDefinition = { binding };
1080
+ const env = yield* WorkerEnvironment;
1081
+ const resource = Predicate.hasProperty(env, binding) ? env[binding] : undefined;
1082
+ const sandboxModule = yield* importSandboxModule(definition, "preview");
1083
+ const response = yield* trySandboxPromise(definition, "preview", "proxyToSandbox", () =>
1084
+ sandboxModule.proxyToSandbox(
1085
+ request,
1086
+ // SAFETY: proxyToSandbox reads exactly one binding, hardcoded as
1087
+ // env.Sandbox; this adapts the configured binding name to that shape,
1088
+ // and getSandbox validates the binding value when a preview route hits it.
1089
+ { Sandbox: resource } as Parameters<typeof sandboxModule.proxyToSandbox>[1],
1090
+ ),
1091
+ );
1092
+
1093
+ return Option.fromNullishOr(response);
1094
+ });