effect-cf 0.49.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.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,17 @@
1
1
  # effect-cf
2
2
 
3
+ ## 0.50.0
4
+
5
+ ### Minor Changes
6
+
7
+ - [#197](https://github.com/danieljvdm/effect-cf/pull/197) [`e174201`](https://github.com/danieljvdm/effect-cf/commit/e174201015b89202988c1bac5a3b25f1bfa6f5d0) Thanks [@danieljvdm](https://github.com/danieljvdm)! - Add `DurableObjectContainer` for direct Effect access to `ctx.container`, including runtime image and instance selection, native process execution with scoped cleanup and output streams, filesystem snapshots, HTTP readiness, TCP/HTTP ports with preview proxying, outbound interception, and idle timeouts. Timeout and polling options accept Effect duration inputs. Supply its layer to `DurableObject.make`, or wrap a native container with `fromContainer`.
8
+
9
+ Add `ContainerFiles.fromFiles` for Effect file operations and streams backed by Sandbox SDK 1.x `Files` and its matching `sandbox-shim`. File and native container failures retain their original causes.
10
+
11
+ Update the tested Cloudflare baseline to workerd `1.20260926.1`, Workers types `5.20260926.1`, Miniflare `5.20260926.1-alpha`, and Wrangler `4.144.0`, with compatibility date `2026-09-26`. Consumers using the optional Workers types package should update to the supported range or regenerate their Wrangler runtime types.
12
+
13
+ Retain the legacy Container and Sandbox adapters and document migration to the native API. Allow Sandbox SDK 1.x utilities alongside the native adapter; `effect-cf/sandbox` continues to require the legacy SDK 0.13 API.
14
+
3
15
  ## 0.49.0
4
16
 
5
17
  ### Minor Changes
package/README.md CHANGED
@@ -6,12 +6,14 @@ Cloudflare entrypoints and bindings as Effect services.
6
6
  npm install effect-cf "effect@^4.0.0-rc.115"
7
7
  ```
8
8
 
9
- The repository tests against workerd `1.20260825.1` and `@cloudflare/workers-types@5.20260825.1`. Use `compatibility_date: "2026-08-25"` in Wrangler.
9
+ The repository tests against workerd `1.20260926.1` and `@cloudflare/workers-types@5.20260926.1`. Use `compatibility_date: "2026-09-26"` in Wrangler.
10
10
 
11
11
  See the [runtime performance guide](https://github.com/danieljvdm/effect-cf/blob/main/docs/runtime-performance.md) for measured alarm batching and telemetry configuration guidance.
12
12
 
13
13
  For typed WebSocket RPC clients whose connection lifetime is owned by an Effect layer, see the [WebSocket RPC guide](https://github.com/danieljvdm/effect-cf/blob/main/docs/websocket-rpc.md).
14
14
 
15
+ `DurableObjectContainer.layer` adds native `ctx.container` control to `DurableObject.make`: runtime image selection, command execution, filesystem snapshots, networking, and idle timeouts. See the [container guide](https://github.com/danieljvdm/effect-cf/blob/main/docs/containers.md) for setup and migration from the legacy Container and Sandbox classes.
16
+
15
17
  ## Worker
16
18
 
17
19
  ```ts
@@ -108,7 +110,7 @@ Enable tracing in `wrangler.jsonc`:
108
110
 
109
111
  ```jsonc
110
112
  {
111
- "compatibility_date": "2026-08-25",
113
+ "compatibility_date": "2026-09-26",
112
114
  "observability": {
113
115
  "traces": { "enabled": true },
114
116
  },
@@ -0,0 +1,66 @@
1
+ import { ContainerError } from "./DurableObjectContainer.mjs";
2
+ import * as Effect from "effect/Effect";
3
+ import * as Stream from "effect/Stream";
4
+ declare namespace ContainerFiles_d_exports {
5
+ export { ContainerFiles, DirectoryEntry, FileContent, FileOperationOptions, FileStat, FileType, FilesResource, MkdirOptions, RemoveOptions, fromFiles };
6
+ }
7
+ type FileContent = string | ArrayBuffer | ArrayBufferView | Blob | ReadableStream<Uint8Array>;
8
+ type FileType = "file" | "directory" | "symlink" | "blockDevice" | "characterDevice" | "fifo" | "socket";
9
+ interface FileOperationOptions {
10
+ readonly cwd?: string;
11
+ readonly user?: string;
12
+ }
13
+ interface MkdirOptions extends FileOperationOptions {
14
+ readonly recursive?: boolean;
15
+ }
16
+ interface RemoveOptions extends FileOperationOptions {
17
+ readonly recursive?: boolean;
18
+ readonly force?: boolean;
19
+ }
20
+ interface FileStat {
21
+ readonly type: FileType;
22
+ readonly size: bigint;
23
+ readonly mode: number;
24
+ readonly uid: number;
25
+ readonly gid: number;
26
+ readonly accessedAt: Date;
27
+ readonly modifiedAt: Date;
28
+ readonly changedAt: Date;
29
+ }
30
+ interface DirectoryEntry {
31
+ readonly name: string;
32
+ readonly type: FileType;
33
+ }
34
+ type WithSignal<A> = A & {
35
+ readonly signal?: AbortSignal;
36
+ };
37
+ /** Structural contract of Sandbox SDK 1.x `Files`, without loading an SDK version. */
38
+ interface FilesResource {
39
+ readFile(path: string, options?: WithSignal<FileOperationOptions>): Promise<Response>;
40
+ writeFile(path: string, content: FileContent, options?: WithSignal<FileOperationOptions>): Promise<void>;
41
+ stat(path: string, options?: WithSignal<FileOperationOptions>): Promise<FileStat>;
42
+ lstat(path: string, options?: WithSignal<FileOperationOptions>): Promise<FileStat>;
43
+ readDirectory(path: string, options?: WithSignal<FileOperationOptions>): Promise<ReadonlyArray<DirectoryEntry>>;
44
+ mkdir(path: string, options?: WithSignal<MkdirOptions>): Promise<void>;
45
+ rename(source: string, destination: string, options?: WithSignal<FileOperationOptions>): Promise<void>;
46
+ remove(path: string, options?: WithSignal<RemoveOptions>): Promise<void>;
47
+ }
48
+ interface ContainerFiles {
49
+ readonly raw: FilesResource;
50
+ /** The caller consumes or cancels the response body. Late failures retain native SDK errors. */
51
+ readonly readFile: (path: string, options?: FileOperationOptions) => Effect.Effect<Response, ContainerError>;
52
+ /** Includes failures that arrive while reading the response body. */
53
+ readonly readFileStream: (path: string, options?: FileOperationOptions) => Stream.Stream<Uint8Array, ContainerError>;
54
+ readonly readFileString: (path: string, options?: FileOperationOptions) => Effect.Effect<string, ContainerError>;
55
+ readonly writeFile: (path: string, content: FileContent, options?: FileOperationOptions) => Effect.Effect<void, ContainerError>;
56
+ readonly stat: (path: string, options?: FileOperationOptions) => Effect.Effect<FileStat, ContainerError>;
57
+ readonly lstat: (path: string, options?: FileOperationOptions) => Effect.Effect<FileStat, ContainerError>;
58
+ readonly readDirectory: (path: string, options?: FileOperationOptions) => Effect.Effect<ReadonlyArray<DirectoryEntry>, ContainerError>;
59
+ readonly mkdir: (path: string, options?: MkdirOptions) => Effect.Effect<void, ContainerError>;
60
+ readonly rename: (source: string, destination: string, options?: FileOperationOptions) => Effect.Effect<void, ContainerError>;
61
+ readonly remove: (path: string, options?: RemoveOptions) => Effect.Effect<void, ContainerError>;
62
+ }
63
+ /** Pass `new Files(container.raw)` from SDK 1.x. The image must supply its matching sandbox-shim. */
64
+ declare const fromFiles: (files: FilesResource) => ContainerFiles;
65
+ //#endregion
66
+ export { ContainerFiles, ContainerFiles_d_exports, DirectoryEntry, FileContent, FileOperationOptions, FileStat, FileType, FilesResource, MkdirOptions, RemoveOptions, fromFiles };
@@ -0,0 +1,66 @@
1
+ import { __exportAll } from "./_virtual/_rolldown/runtime.mjs";
2
+ import { ContainerError } from "./DurableObjectContainer.mjs";
3
+ import * as Effect from "effect/Effect";
4
+ import * as Stream from "effect/Stream";
5
+ //#region src/ContainerFiles.ts
6
+ var ContainerFiles_exports = /* @__PURE__ */ __exportAll({ fromFiles: () => fromFiles });
7
+ const attempt = (operation, evaluate) => Effect.tryPromise({
8
+ try: evaluate,
9
+ catch: (cause) => new ContainerError({
10
+ operation: `files.${operation}`,
11
+ cause
12
+ })
13
+ });
14
+ /** Pass `new Files(container.raw)` from SDK 1.x. The image must supply its matching sandbox-shim. */
15
+ const fromFiles = (files) => {
16
+ const readFile = (path, options) => attempt("readFile", (signal) => files.readFile(path, {
17
+ ...options,
18
+ signal
19
+ }));
20
+ const readFileStream = (path, options) => Stream.unwrap(Effect.map(readFile(path, options), (response) => {
21
+ const body = response.body;
22
+ return body === null ? Stream.empty : Stream.fromReadableStream({
23
+ evaluate: () => body,
24
+ onError: (cause) => new ContainerError({
25
+ operation: "files.readFile",
26
+ cause
27
+ })
28
+ });
29
+ }));
30
+ return {
31
+ raw: files,
32
+ readFile,
33
+ readFileStream,
34
+ readFileString: (path, options) => readFileStream(path, options).pipe(Stream.decodeText(), Stream.mkString),
35
+ writeFile: (path, content, options) => attempt("writeFile", (signal) => files.writeFile(path, content, {
36
+ ...options,
37
+ signal
38
+ })),
39
+ stat: (path, options) => attempt("stat", (signal) => files.stat(path, {
40
+ ...options,
41
+ signal
42
+ })),
43
+ lstat: (path, options) => attempt("lstat", (signal) => files.lstat(path, {
44
+ ...options,
45
+ signal
46
+ })),
47
+ readDirectory: (path, options) => attempt("readDirectory", (signal) => files.readDirectory(path, {
48
+ ...options,
49
+ signal
50
+ })),
51
+ mkdir: (path, options) => attempt("mkdir", (signal) => files.mkdir(path, {
52
+ ...options,
53
+ signal
54
+ })),
55
+ rename: (source, destination, options) => attempt("rename", (signal) => files.rename(source, destination, {
56
+ ...options,
57
+ signal
58
+ })),
59
+ remove: (path, options) => attempt("remove", (signal) => files.remove(path, {
60
+ ...options,
61
+ signal
62
+ }))
63
+ };
64
+ };
65
+ //#endregion
66
+ export { ContainerFiles_exports, fromFiles };
@@ -43,10 +43,12 @@ declare const ContainerState: S.Union<readonly [S.Struct<{
43
43
  }>]>;
44
44
  type ContainerState = typeof ContainerState.Type;
45
45
  /**
46
- * Native Container stub shape exposed by a Container namespace binding.
46
+ * RPC stub exposed by the legacy `@cloudflare/containers` Container class.
47
47
  *
48
48
  * This is structural so using Container bindings does not make
49
49
  * `@cloudflare/containers` a runtime dependency of effect-cf.
50
+ * Use `DurableObjectContainer` inside a Durable Object for the native
51
+ * `ctx.container` API and the `durable_object` scheduling policy.
50
52
  */
51
53
  interface ContainerStub {
52
54
  fetch(input: RequestInfo | URL, init?: RequestInit): Promise<Response>;
@@ -0,0 +1,133 @@
1
+ import { DurableObjectState } from "./DurableObjectState.mjs";
2
+ import * as Context$1 from "effect/Context";
3
+ import * as Effect from "effect/Effect";
4
+ import * as Layer$1 from "effect/Layer";
5
+ import * as Option from "effect/Option";
6
+ import * as Duration from "effect/Duration";
7
+ import * as Stream from "effect/Stream";
8
+ import { Scope } from "effect";
9
+ declare namespace DurableObjectContainer_d_exports {
10
+ export { ContainerError, ContainerExecOptions, ContainerExecOutput, ContainerExecOutputText, ContainerHttpReadinessOptions, ContainerInfo, ContainerNotConfiguredError, ContainerProcess, ContainerProcessLog, ContainerReadinessTimeoutError, ContainerSnapshot, ContainerSnapshotOptions, ContainerStartOptions, ContainerTcpPort, DurableObjectContainer, DurableObjectContainerService, fromContainer, layer };
11
+ }
12
+ type ContainerStartOptions = globalThis.ContainerStartupOptions;
13
+ type ContainerExecOptions = Omit<globalThis.ContainerExecOptions, "signal">;
14
+ type ContainerInfo = globalThis.ContainerInfo;
15
+ type ContainerSnapshot = globalThis.ContainerSnapshot;
16
+ type ContainerSnapshotOptions = globalThis.ContainerSnapshotOptions;
17
+ type ContainerExecOutput = globalThis.ExecOutput;
18
+ interface ContainerExecOutputText {
19
+ readonly stdout: string;
20
+ readonly stderr: string;
21
+ readonly exitCode: number;
22
+ }
23
+ interface ContainerProcessLog {
24
+ readonly stream: "stdout" | "stderr";
25
+ readonly data: Uint8Array;
26
+ }
27
+ interface ContainerHttpReadinessOptions {
28
+ /** Defaults to 30 seconds. All durations accept numbers in milliseconds. */
29
+ readonly timeout?: Duration.Input;
30
+ /** Delay between probes. Defaults to 100 milliseconds. */
31
+ readonly interval?: Duration.Input;
32
+ /** Bounds each connection or HTTP probe. Defaults to 1 second. */
33
+ readonly attemptTimeout?: Duration.Input;
34
+ readonly path?: string;
35
+ /** Accepts any HTTP response unless a status range is supplied. */
36
+ readonly status?: {
37
+ readonly min: number;
38
+ readonly max: number;
39
+ };
40
+ }
41
+ declare const ContainerNotConfiguredError_base: new <A extends Record<string, any> = {}>(args: import("effect/Types").VoidIfEmpty<{ readonly [P in keyof A as P extends "_tag" ? never : P]: A[P]; }>) => import("effect/Cause").YieldableError & {
42
+ readonly _tag: "ContainerNotConfiguredError";
43
+ } & Readonly<A>;
44
+ declare class ContainerNotConfiguredError extends ContainerNotConfiguredError_base<{
45
+ readonly durableObjectId: string;
46
+ }> {
47
+ get message(): string;
48
+ }
49
+ declare const ContainerError_base: new <A extends Record<string, any> = {}>(args: import("effect/Types").VoidIfEmpty<{ readonly [P in keyof A as P extends "_tag" ? never : P]: A[P]; }>) => import("effect/Cause").YieldableError & {
50
+ readonly _tag: "ContainerError";
51
+ } & Readonly<A>;
52
+ declare class ContainerError extends ContainerError_base<{
53
+ readonly operation: string;
54
+ readonly cause: unknown;
55
+ }> {
56
+ get message(): string;
57
+ }
58
+ declare const ContainerReadinessTimeoutError_base: new <A extends Record<string, any> = {}>(args: import("effect/Types").VoidIfEmpty<{ readonly [P in keyof A as P extends "_tag" ? never : P]: A[P]; }>) => import("effect/Cause").YieldableError & {
59
+ readonly _tag: "ContainerReadinessTimeoutError";
60
+ } & Readonly<A>;
61
+ declare class ContainerReadinessTimeoutError extends ContainerReadinessTimeoutError_base<{
62
+ readonly port: number;
63
+ readonly timeout: Duration.Duration;
64
+ /** The last completed probe failure, when available. */
65
+ readonly cause: unknown;
66
+ }> {
67
+ get message(): string;
68
+ }
69
+ /** A native process handle owned by its creating request. Choose one output consumer. */
70
+ interface ContainerProcess {
71
+ readonly raw: globalThis.ExecProcess;
72
+ readonly pid: number;
73
+ readonly isPty: boolean;
74
+ readonly stdin: globalThis.ExecProcess["stdin"];
75
+ /** Empty when the corresponding native stream is absent. */
76
+ readonly stdout: Stream.Stream<Uint8Array, ContainerError>;
77
+ readonly stderr: Stream.Stream<Uint8Array, ContainerError>;
78
+ /** Drains both channels concurrently. Single-use, without buffering or replay. */
79
+ readonly logs: Stream.Stream<ContainerProcessLog, ContainerError>;
80
+ /** Nonzero exit codes are values, not Effect failures. */
81
+ readonly exitCode: Effect.Effect<number, ContainerError>;
82
+ readonly output: Effect.Effect<ContainerExecOutput, ContainerError>;
83
+ readonly outputText: Effect.Effect<ContainerExecOutputText, ContainerError>;
84
+ /** Signals the main process only; does nothing after its exit has been observed. */
85
+ readonly kill: (signal?: number) => Effect.Effect<void, ContainerError>;
86
+ readonly resize: (cols: number, rows: number) => Effect.Effect<void, ContainerError>;
87
+ }
88
+ interface ContainerTcpPort {
89
+ readonly raw: globalThis.Fetcher;
90
+ readonly fetch: (input: RequestInfo | URL, init?: RequestInit) => Effect.Effect<Response, ContainerError>;
91
+ /** Forwards an authorized preview request once, using the container's HTTP transport. */
92
+ readonly proxy: (request: Request) => Effect.Effect<Response, ContainerError>;
93
+ /** The caller owns the returned socket and must close it. */
94
+ readonly connect: (...args: Parameters<globalThis.Fetcher["connect"]>) => Effect.Effect<ReturnType<globalThis.Fetcher["connect"]>, ContainerError>;
95
+ }
96
+ /** Direct container control inside a Durable Object; lifecycle policy belongs to the application. */
97
+ interface DurableObjectContainerService {
98
+ readonly raw: globalThis.Container;
99
+ readonly running: Effect.Effect<boolean, ContainerError>;
100
+ readonly images: Effect.Effect<Readonly<Record<string, string>>, ContainerError>;
101
+ readonly inspect: Effect.Effect<Option.Option<ContainerInfo>, ContainerError>;
102
+ /** Validates options and initiates startup. It does not wait for readiness. */
103
+ readonly start: (options?: ContainerStartOptions) => Effect.Effect<void, ContainerError>;
104
+ /** Waits for the container to stop; failures retain the native cause, including its exitCode. */
105
+ readonly monitor: Effect.Effect<void, ContainerError>;
106
+ readonly destroy: (reason?: Parameters<globalThis.Container["destroy"]>[0]) => Effect.Effect<void, ContainerError>;
107
+ readonly signal: (signal: number) => Effect.Effect<void, ContainerError>;
108
+ /** Effect duration input: numbers are milliseconds; bigints are nanoseconds. */
109
+ readonly setInactivityTimeout: (duration: Duration.Input) => Effect.Effect<void, ContainerError>;
110
+ readonly getTcpPort: (port: number) => Effect.Effect<ContainerTcpPort, ContainerError>;
111
+ /** Sends HTTP GET probes to an already-started container. Never retries a caller's request. */
112
+ readonly waitForHttp: (port: number, options?: ContainerHttpReadinessOptions) => Effect.Effect<void, ContainerError | ContainerReadinessTimeoutError>;
113
+ /**
114
+ * Runs an executable with arguments, without a shell or automatic container startup.
115
+ * Interruption cancels acquisition. After acquisition, the caller owns the process.
116
+ * To observe it from later requests, redirect output to files and ignore the native pipes.
117
+ */
118
+ readonly exec: (command: ReadonlyArray<string>, options?: ContainerExecOptions) => Effect.Effect<ContainerProcess, ContainerError>;
119
+ /** Sends SIGKILL to a still-running main process when the scope closes. */
120
+ readonly execScoped: (command: ReadonlyArray<string>, options?: ContainerExecOptions) => Effect.Effect<ContainerProcess, ContainerError, Scope.Scope>;
121
+ readonly snapshotContainer: (options?: ContainerSnapshotOptions) => Effect.Effect<ContainerSnapshot, ContainerError>;
122
+ readonly interceptOutboundHttp: (address: string, binding: globalThis.Fetcher) => Effect.Effect<void, ContainerError>;
123
+ readonly interceptAllOutboundHttp: (binding: globalThis.Fetcher) => Effect.Effect<void, ContainerError>;
124
+ readonly interceptOutboundHttps: (address: string, binding: globalThis.Fetcher) => Effect.Effect<void, ContainerError>;
125
+ }
126
+ declare const DurableObjectContainer_base: Context$1.ServiceClass<DurableObjectContainer, "effect-cf/DurableObjectContainer", DurableObjectContainerService>;
127
+ declare class DurableObjectContainer extends DurableObjectContainer_base {}
128
+ /** Wraps `ctx.container` without starting it or taking ownership of its lifecycle. */
129
+ declare const fromContainer: (container: globalThis.Container) => DurableObjectContainerService;
130
+ /** Supplies the container attached to the current Durable Object, without owning it. */
131
+ declare const layer: Layer$1.Layer<DurableObjectContainer, ContainerNotConfiguredError, DurableObjectState>;
132
+ //#endregion
133
+ export { ContainerError, ContainerExecOptions, ContainerExecOutput, ContainerExecOutputText, ContainerHttpReadinessOptions, ContainerInfo, ContainerNotConfiguredError, ContainerProcess, ContainerProcessLog, ContainerReadinessTimeoutError, ContainerSnapshot, ContainerSnapshotOptions, ContainerStartOptions, ContainerTcpPort, DurableObjectContainer, DurableObjectContainerService, DurableObjectContainer_d_exports, fromContainer, layer };
@@ -0,0 +1,179 @@
1
+ import { __exportAll } from "./_virtual/_rolldown/runtime.mjs";
2
+ import { causeMessage } from "./internal/ErrorMessage.mjs";
3
+ import { DurableObjectState } from "./DurableObjectState.mjs";
4
+ import * as Context from "effect/Context";
5
+ import * as Data from "effect/Data";
6
+ import * as Effect from "effect/Effect";
7
+ import * as Layer from "effect/Layer";
8
+ import * as Option from "effect/Option";
9
+ import * as Duration from "effect/Duration";
10
+ import * as Stream from "effect/Stream";
11
+ //#region src/DurableObjectContainer.ts
12
+ var DurableObjectContainer_exports = /* @__PURE__ */ __exportAll({
13
+ ContainerError: () => ContainerError,
14
+ ContainerNotConfiguredError: () => ContainerNotConfiguredError,
15
+ ContainerReadinessTimeoutError: () => ContainerReadinessTimeoutError,
16
+ DurableObjectContainer: () => DurableObjectContainer,
17
+ fromContainer: () => fromContainer,
18
+ layer: () => layer
19
+ });
20
+ var ContainerNotConfiguredError = class extends Data.TaggedError("ContainerNotConfiguredError") {
21
+ get message() {
22
+ return `No container is configured for Durable Object "${this.durableObjectId}"`;
23
+ }
24
+ };
25
+ var ContainerError = class extends Data.TaggedError("ContainerError") {
26
+ get message() {
27
+ return `Durable Object container ${this.operation} failed: ${causeMessage(this.cause)}`;
28
+ }
29
+ };
30
+ var ContainerReadinessTimeoutError = class extends Data.TaggedError("ContainerReadinessTimeoutError") {
31
+ get message() {
32
+ return `Container port ${this.port} was not ready within ${Duration.toMillis(this.timeout)}ms`;
33
+ }
34
+ };
35
+ var DurableObjectContainer = class extends Context.Service()("effect-cf/DurableObjectContainer") {};
36
+ const attempt = (operation, evaluate) => Effect.try({
37
+ try: evaluate,
38
+ catch: (cause) => new ContainerError({
39
+ operation,
40
+ cause
41
+ })
42
+ });
43
+ const attemptPromise = (operation, evaluate) => Effect.tryPromise({
44
+ try: evaluate,
45
+ catch: (cause) => new ContainerError({
46
+ operation,
47
+ cause
48
+ })
49
+ });
50
+ const processStream = (stream, operation) => stream === null || stream === void 0 ? Stream.empty : Stream.fromReadableStream({
51
+ evaluate: () => stream,
52
+ onError: (cause) => new ContainerError({
53
+ operation,
54
+ cause
55
+ })
56
+ });
57
+ const fromProcess = (process) => {
58
+ let exited = false;
59
+ const onExit = () => {
60
+ exited = true;
61
+ };
62
+ process.exitCode.then(onExit, onExit);
63
+ const stdout = processStream(process.stdout, "exec.stdout");
64
+ const stderr = processStream(process.stderr, "exec.stderr");
65
+ const output = attemptPromise("exec.output", () => process.output());
66
+ return {
67
+ raw: process,
68
+ pid: process.pid,
69
+ isPty: process.isPty,
70
+ stdin: process.stdin ?? null,
71
+ stdout,
72
+ stderr,
73
+ logs: Stream.merge(Stream.map(stdout, (data) => ({
74
+ stream: "stdout",
75
+ data
76
+ })), Stream.map(stderr, (data) => ({
77
+ stream: "stderr",
78
+ data
79
+ }))),
80
+ exitCode: attemptPromise("exec.exitCode", () => process.exitCode),
81
+ output,
82
+ outputText: Effect.map(output, (result) => ({
83
+ stdout: new TextDecoder().decode(result.stdout),
84
+ stderr: new TextDecoder().decode(result.stderr),
85
+ exitCode: result.exitCode
86
+ })),
87
+ kill: (signal) => attemptPromise("exec.kill", async () => {
88
+ await Promise.resolve();
89
+ if (!exited) process.kill(signal);
90
+ }),
91
+ resize: (cols, rows) => attempt("exec.resize", () => process.resize(cols, rows))
92
+ };
93
+ };
94
+ const fromTcpPort = (port) => {
95
+ const fetch = (input, init) => attemptPromise("getTcpPort.fetch", (signal) => {
96
+ const callerSignal = init?.signal !== void 0 ? init.signal : input instanceof Request ? input.signal : void 0;
97
+ return port.fetch(input, {
98
+ ...init,
99
+ signal: callerSignal === null || callerSignal === void 0 ? signal : AbortSignal.any([callerSignal, signal])
100
+ });
101
+ });
102
+ return {
103
+ raw: port,
104
+ fetch,
105
+ proxy: (request) => attempt("getTcpPort.proxy", () => {
106
+ const url = new URL(request.url);
107
+ url.protocol = "http:";
108
+ return new Request(url, request);
109
+ }).pipe(Effect.flatMap((forwarded) => fetch(forwarded))),
110
+ connect: (...args) => attempt("getTcpPort.connect", () => port.connect(...args))
111
+ };
112
+ };
113
+ /** Wraps `ctx.container` without starting it or taking ownership of its lifecycle. */
114
+ const fromContainer = (container) => {
115
+ const exec = (command, options) => attemptPromise("exec", (signal) => container.exec([...command], {
116
+ ...options,
117
+ signal
118
+ })).pipe(Effect.map(fromProcess));
119
+ const waitForHttp = Effect.fnUntraced(function* (portNumber, options = {}) {
120
+ const timeout = yield* attempt("waitForHttp", () => Duration.fromInputUnsafe(options.timeout ?? "30 seconds"));
121
+ let lastFailure;
122
+ const probe = attemptPromise("waitForHttp", async (signal) => {
123
+ const response = await container.getTcpPort(portNumber).fetch(new URL(options.path ?? "/", "http://container"), { signal });
124
+ await response.body?.cancel();
125
+ if (options.status !== void 0 && (response.status < options.status.min || response.status > options.status.max)) throw new Error(`HTTP readiness probe returned status ${response.status}`);
126
+ });
127
+ return yield* Effect.gen(function* () {
128
+ while (true) {
129
+ if (!(yield* attempt("running", () => container.running))) return yield* new ContainerError({
130
+ operation: "waitForHttp",
131
+ cause: /* @__PURE__ */ new Error("Container stopped before the port became ready")
132
+ });
133
+ if (yield* probe.pipe(Effect.timeout(options.attemptTimeout ?? "1 second"), Effect.match({
134
+ onSuccess: () => true,
135
+ onFailure: (error) => {
136
+ lastFailure = error;
137
+ return false;
138
+ }
139
+ }))) return;
140
+ yield* Effect.sleep(options.interval ?? "100 millis");
141
+ }
142
+ }).pipe(Effect.timeoutOrElse({
143
+ duration: timeout,
144
+ orElse: () => Effect.fail(new ContainerReadinessTimeoutError({
145
+ port: portNumber,
146
+ timeout,
147
+ cause: lastFailure
148
+ }))
149
+ }));
150
+ });
151
+ return {
152
+ raw: container,
153
+ running: attempt("running", () => container.running),
154
+ images: attempt("images", () => container.images),
155
+ inspect: attemptPromise("inspect", () => container.inspect()).pipe(Effect.map(Option.fromNullishOr)),
156
+ start: (options) => attempt("start", () => container.start(options)),
157
+ monitor: attemptPromise("monitor", () => container.monitor()),
158
+ destroy: (reason) => attemptPromise("destroy", () => container.destroy(reason)),
159
+ signal: (signal) => attempt("signal", () => container.signal(signal)),
160
+ setInactivityTimeout: (duration) => attemptPromise("setInactivityTimeout", () => container.setInactivityTimeout(Duration.toMillis(duration))),
161
+ getTcpPort: (port) => attempt("getTcpPort", () => fromTcpPort(container.getTcpPort(port))),
162
+ waitForHttp,
163
+ exec,
164
+ execScoped: (command, options) => Effect.acquireRelease(exec(command, options), (process) => process.kill(9).pipe(Effect.orDie), { interruptible: true }),
165
+ snapshotContainer: (options = {}) => attemptPromise("snapshotContainer", () => container.snapshotContainer(options)),
166
+ interceptOutboundHttp: (address, binding) => attemptPromise("interceptOutboundHttp", () => container.interceptOutboundHttp(address, binding)),
167
+ interceptAllOutboundHttp: (binding) => attemptPromise("interceptAllOutboundHttp", () => container.interceptAllOutboundHttp(binding)),
168
+ interceptOutboundHttps: (address, binding) => attemptPromise("interceptOutboundHttps", () => container.interceptOutboundHttps(address, binding))
169
+ };
170
+ };
171
+ /** Supplies the container attached to the current Durable Object, without owning it. */
172
+ const layer = Layer.effect(DurableObjectContainer, Effect.gen(function* () {
173
+ const state = yield* DurableObjectState;
174
+ const container = state.raw.container;
175
+ if (container === void 0) return yield* new ContainerNotConfiguredError({ durableObjectId: state.id.toString() });
176
+ return fromContainer(container);
177
+ }));
178
+ //#endregion
179
+ export { ContainerError, ContainerNotConfiguredError, ContainerReadinessTimeoutError, DurableObjectContainer, DurableObjectContainer_exports, fromContainer, layer };
package/dist/index.d.mts CHANGED
@@ -24,6 +24,8 @@ import { BrowserRendering_d_exports } from "./BrowserRendering.mjs";
24
24
  import { Cache_d_exports } from "./Cache.mjs";
25
25
  import { CloudflareOtlp_d_exports } from "./CloudflareOtlp.mjs";
26
26
  import { CloudflareTracer_d_exports } from "./CloudflareTracer.mjs";
27
+ import { DurableObjectContainer_d_exports } from "./DurableObjectContainer.mjs";
28
+ import { ContainerFiles_d_exports } from "./ContainerFiles.mjs";
27
29
  import { D1_d_exports } from "./D1.mjs";
28
30
  import { DurableObjectAlarm_d_exports } from "./DurableObjectAlarm.mjs";
29
31
  import { DurableObjectDefinition_d_exports } from "./DurableObjectDefinition.mjs";
@@ -40,4 +42,4 @@ import { Vectorize_d_exports } from "./Vectorize.mjs";
40
42
  import { WebTransport_d_exports } from "./WebTransport.mjs";
41
43
  import { WorkflowDefinition_d_exports } from "./WorkflowDefinition.mjs";
42
44
  import { Workflow_d_exports } from "./Workflow.mjs";
43
- export { AiGateway_d_exports as AiGateway, AnalyticsEngine_d_exports as AnalyticsEngine, Artifacts_d_exports as Artifacts, Binding_d_exports as Binding, BrowserRendering_d_exports as BrowserRendering, Cache_d_exports as Cache, CloudflareOtlp_d_exports as CloudflareOtlp, CloudflareTracer_d_exports as CloudflareTracer, ContainerNamespace_d_exports as ContainerNamespace, D1_d_exports as D1, DurableObject_d_exports as DurableObject, DurableObjectAlarm_d_exports as DurableObjectAlarm, DurableObjectDefinition_d_exports as DurableObjectDefinition, DurableObjectNamespace_d_exports as DurableObjectNamespace, DurableObjectRpcWebSocket_d_exports as DurableObjectRpcWebSocket, DurableObjectSqlite_d_exports as DurableObjectSqlite, DurableObjectState_d_exports as DurableObjectState, DurableObjectStorage_d_exports as DurableObjectStorage, DurableObjectWebSocket_d_exports as DurableObjectWebSocket, Email_d_exports as Email, Environment_d_exports as Environment, Hyperdrive_d_exports as Hyperdrive, Images_d_exports as Images, Kv_d_exports as Kv, Queue_d_exports as Queue, QueueBinding_d_exports as QueueBinding, QueueDefinition_d_exports as QueueDefinition, R2_d_exports as R2, Rpc_d_exports as Rpc, RpcDefinition_d_exports as RpcDefinition, RpcTargets_d_exports as RpcTargets, RpcTracing_d_exports as RpcTracing, RpcWebSocketClient_d_exports as RpcWebSocketClient, ServiceBinding_d_exports as ServiceBinding, Vectorize_d_exports as Vectorize, WebTransport_d_exports as WebTransport, Worker_d_exports as Worker, WorkerConfig, WorkerDefinition_d_exports as WorkerDefinition, type WorkerEnv, WorkerEnvironment, WorkersAi_d_exports as WorkersAi, Workflow_d_exports as Workflow, WorkflowBinding_d_exports as WorkflowBinding, WorkflowDefinition_d_exports as WorkflowDefinition };
45
+ export { AiGateway_d_exports as AiGateway, AnalyticsEngine_d_exports as AnalyticsEngine, Artifacts_d_exports as Artifacts, Binding_d_exports as Binding, BrowserRendering_d_exports as BrowserRendering, Cache_d_exports as Cache, CloudflareOtlp_d_exports as CloudflareOtlp, CloudflareTracer_d_exports as CloudflareTracer, ContainerFiles_d_exports as ContainerFiles, ContainerNamespace_d_exports as ContainerNamespace, D1_d_exports as D1, DurableObject_d_exports as DurableObject, DurableObjectAlarm_d_exports as DurableObjectAlarm, DurableObjectContainer_d_exports as DurableObjectContainer, DurableObjectDefinition_d_exports as DurableObjectDefinition, DurableObjectNamespace_d_exports as DurableObjectNamespace, DurableObjectRpcWebSocket_d_exports as DurableObjectRpcWebSocket, DurableObjectSqlite_d_exports as DurableObjectSqlite, DurableObjectState_d_exports as DurableObjectState, DurableObjectStorage_d_exports as DurableObjectStorage, DurableObjectWebSocket_d_exports as DurableObjectWebSocket, Email_d_exports as Email, Environment_d_exports as Environment, Hyperdrive_d_exports as Hyperdrive, Images_d_exports as Images, Kv_d_exports as Kv, Queue_d_exports as Queue, QueueBinding_d_exports as QueueBinding, QueueDefinition_d_exports as QueueDefinition, R2_d_exports as R2, Rpc_d_exports as Rpc, RpcDefinition_d_exports as RpcDefinition, RpcTargets_d_exports as RpcTargets, RpcTracing_d_exports as RpcTracing, RpcWebSocketClient_d_exports as RpcWebSocketClient, ServiceBinding_d_exports as ServiceBinding, Vectorize_d_exports as Vectorize, WebTransport_d_exports as WebTransport, Worker_d_exports as Worker, WorkerConfig, WorkerDefinition_d_exports as WorkerDefinition, type WorkerEnv, WorkerEnvironment, WorkersAi_d_exports as WorkersAi, Workflow_d_exports as Workflow, WorkflowBinding_d_exports as WorkflowBinding, WorkflowDefinition_d_exports as WorkflowDefinition };
package/dist/index.mjs CHANGED
@@ -11,6 +11,8 @@ import { DurableObjectWebSocket_exports } from "./DurableObjectWebSocket.mjs";
11
11
  import { DurableObjectState_exports } from "./DurableObjectState.mjs";
12
12
  import { CloudflareOtlp_exports } from "./CloudflareOtlp.mjs";
13
13
  import { CloudflareTracer_exports } from "./CloudflareTracer.mjs";
14
+ import { DurableObjectContainer_exports } from "./DurableObjectContainer.mjs";
15
+ import { ContainerFiles_exports } from "./ContainerFiles.mjs";
14
16
  import { RpcTargets_exports } from "./RpcTargets.mjs";
15
17
  import { ContainerNamespace_exports } from "./ContainerNamespace.mjs";
16
18
  import { D1_exports } from "./D1.mjs";
@@ -40,4 +42,4 @@ import { WebTransport_exports } from "./WebTransport.mjs";
40
42
  import { WorkflowBinding_exports } from "./WorkflowBinding.mjs";
41
43
  import { WorkflowDefinition_exports } from "./WorkflowDefinition.mjs";
42
44
  import { Workflow_exports } from "./Workflow.mjs";
43
- export { AiGateway_exports as AiGateway, AnalyticsEngine_exports as AnalyticsEngine, Artifacts_exports as Artifacts, Binding_exports as Binding, BrowserRendering_exports as BrowserRendering, Cache_exports as Cache, CloudflareOtlp_exports as CloudflareOtlp, CloudflareTracer_exports as CloudflareTracer, ContainerNamespace_exports as ContainerNamespace, D1_exports as D1, DurableObject_exports as DurableObject, DurableObjectAlarm_exports as DurableObjectAlarm, DurableObjectDefinition_exports as DurableObjectDefinition, DurableObjectNamespace_exports as DurableObjectNamespace, DurableObjectRpcWebSocket_exports as DurableObjectRpcWebSocket, DurableObjectSqlite_exports as DurableObjectSqlite, DurableObjectState_exports as DurableObjectState, DurableObjectStorage_exports as DurableObjectStorage, DurableObjectWebSocket_exports as DurableObjectWebSocket, Email_exports as Email, Environment_exports as Environment, Hyperdrive_exports as Hyperdrive, Images_exports as Images, Kv_exports as Kv, Queue_exports as Queue, QueueBinding_exports as QueueBinding, QueueDefinition_exports as QueueDefinition, R2_exports as R2, Rpc_exports as Rpc, RpcDefinition_exports as RpcDefinition, RpcTargets_exports as RpcTargets, RpcTracing_exports as RpcTracing, RpcWebSocketClient_exports as RpcWebSocketClient, ServiceBinding_exports as ServiceBinding, Vectorize_exports as Vectorize, WebTransport_exports as WebTransport, Worker_exports as Worker, WorkerConfig, WorkerDefinition_exports as WorkerDefinition, WorkerEnvironment, WorkersAi_exports as WorkersAi, Workflow_exports as Workflow, WorkflowBinding_exports as WorkflowBinding, WorkflowDefinition_exports as WorkflowDefinition };
45
+ export { AiGateway_exports as AiGateway, AnalyticsEngine_exports as AnalyticsEngine, Artifacts_exports as Artifacts, Binding_exports as Binding, BrowserRendering_exports as BrowserRendering, Cache_exports as Cache, CloudflareOtlp_exports as CloudflareOtlp, CloudflareTracer_exports as CloudflareTracer, ContainerFiles_exports as ContainerFiles, ContainerNamespace_exports as ContainerNamespace, D1_exports as D1, DurableObject_exports as DurableObject, DurableObjectAlarm_exports as DurableObjectAlarm, DurableObjectContainer_exports as DurableObjectContainer, DurableObjectDefinition_exports as DurableObjectDefinition, DurableObjectNamespace_exports as DurableObjectNamespace, DurableObjectRpcWebSocket_exports as DurableObjectRpcWebSocket, DurableObjectSqlite_exports as DurableObjectSqlite, DurableObjectState_exports as DurableObjectState, DurableObjectStorage_exports as DurableObjectStorage, DurableObjectWebSocket_exports as DurableObjectWebSocket, Email_exports as Email, Environment_exports as Environment, Hyperdrive_exports as Hyperdrive, Images_exports as Images, Kv_exports as Kv, Queue_exports as Queue, QueueBinding_exports as QueueBinding, QueueDefinition_exports as QueueDefinition, R2_exports as R2, Rpc_exports as Rpc, RpcDefinition_exports as RpcDefinition, RpcTargets_exports as RpcTargets, RpcTracing_exports as RpcTracing, RpcWebSocketClient_exports as RpcWebSocketClient, ServiceBinding_exports as ServiceBinding, Vectorize_exports as Vectorize, WebTransport_exports as WebTransport, Worker_exports as Worker, WorkerConfig, WorkerDefinition_exports as WorkerDefinition, WorkerEnvironment, WorkersAi_exports as WorkersAi, Workflow_exports as Workflow, WorkflowBinding_exports as WorkflowBinding, WorkflowDefinition_exports as WorkflowDefinition };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "effect-cf",
3
- "version": "0.49.0",
3
+ "version": "0.50.0",
4
4
  "description": "Effect-native primitives for Cloudflare Workers and bindings.",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -78,6 +78,7 @@
78
78
  "@cloudflare/computer": "0.2.0",
79
79
  "@cloudflare/containers": "catalog:",
80
80
  "@cloudflare/sandbox": "0.13.0-next.738.2",
81
+ "@cloudflare/sandbox-v1": "npm:@cloudflare/sandbox@1.0.0",
81
82
  "@cloudflare/vitest-plugin": "catalog:",
82
83
  "@cloudflare/workers-types": "catalog:",
83
84
  "@effect/platform-node": "catalog:",
@@ -96,9 +97,9 @@
96
97
  },
97
98
  "peerDependencies": {
98
99
  "@cloudflare/computer": "^0.2.0",
99
- "@cloudflare/sandbox": "^0.13.0-next.738.2",
100
+ "@cloudflare/sandbox": "^0.13.0-next.738.2 || ^1.0.0",
100
101
  "@cloudflare/vitest-plugin": "^1.0.0",
101
- "@cloudflare/workers-types": "^5.20260825.1",
102
+ "@cloudflare/workers-types": "^5.20260926.1",
102
103
  "@effect/sql-d1": "^4.0.0-rc.115",
103
104
  "@effect/sql-pg": "^4.0.0-rc.115",
104
105
  "@effect/sql-sqlite-do": "^4.0.0-rc.115",
@@ -0,0 +1,162 @@
1
+ import * as Effect from "effect/Effect";
2
+ import * as Stream from "effect/Stream";
3
+
4
+ import { ContainerError } from "./DurableObjectContainer";
5
+
6
+ export type FileContent =
7
+ | string
8
+ | ArrayBuffer
9
+ | ArrayBufferView
10
+ | Blob
11
+ | ReadableStream<Uint8Array>;
12
+ export type FileType =
13
+ | "file"
14
+ | "directory"
15
+ | "symlink"
16
+ | "blockDevice"
17
+ | "characterDevice"
18
+ | "fifo"
19
+ | "socket";
20
+
21
+ export interface FileOperationOptions {
22
+ readonly cwd?: string;
23
+ readonly user?: string;
24
+ }
25
+
26
+ export interface MkdirOptions extends FileOperationOptions {
27
+ readonly recursive?: boolean;
28
+ }
29
+
30
+ export interface RemoveOptions extends FileOperationOptions {
31
+ readonly recursive?: boolean;
32
+ readonly force?: boolean;
33
+ }
34
+
35
+ export interface FileStat {
36
+ readonly type: FileType;
37
+ readonly size: bigint;
38
+ readonly mode: number;
39
+ readonly uid: number;
40
+ readonly gid: number;
41
+ readonly accessedAt: Date;
42
+ readonly modifiedAt: Date;
43
+ readonly changedAt: Date;
44
+ }
45
+
46
+ export interface DirectoryEntry {
47
+ readonly name: string;
48
+ readonly type: FileType;
49
+ }
50
+
51
+ type WithSignal<A> = A & { readonly signal?: AbortSignal };
52
+
53
+ /** Structural contract of Sandbox SDK 1.x `Files`, without loading an SDK version. */
54
+ export interface FilesResource {
55
+ readFile(path: string, options?: WithSignal<FileOperationOptions>): Promise<Response>;
56
+ writeFile(
57
+ path: string,
58
+ content: FileContent,
59
+ options?: WithSignal<FileOperationOptions>,
60
+ ): Promise<void>;
61
+ stat(path: string, options?: WithSignal<FileOperationOptions>): Promise<FileStat>;
62
+ lstat(path: string, options?: WithSignal<FileOperationOptions>): Promise<FileStat>;
63
+ readDirectory(
64
+ path: string,
65
+ options?: WithSignal<FileOperationOptions>,
66
+ ): Promise<ReadonlyArray<DirectoryEntry>>;
67
+ mkdir(path: string, options?: WithSignal<MkdirOptions>): Promise<void>;
68
+ rename(
69
+ source: string,
70
+ destination: string,
71
+ options?: WithSignal<FileOperationOptions>,
72
+ ): Promise<void>;
73
+ remove(path: string, options?: WithSignal<RemoveOptions>): Promise<void>;
74
+ }
75
+
76
+ export interface ContainerFiles {
77
+ readonly raw: FilesResource;
78
+ /** The caller consumes or cancels the response body. Late failures retain native SDK errors. */
79
+ readonly readFile: (
80
+ path: string,
81
+ options?: FileOperationOptions,
82
+ ) => Effect.Effect<Response, ContainerError>;
83
+ /** Includes failures that arrive while reading the response body. */
84
+ readonly readFileStream: (
85
+ path: string,
86
+ options?: FileOperationOptions,
87
+ ) => Stream.Stream<Uint8Array, ContainerError>;
88
+ readonly readFileString: (
89
+ path: string,
90
+ options?: FileOperationOptions,
91
+ ) => Effect.Effect<string, ContainerError>;
92
+ readonly writeFile: (
93
+ path: string,
94
+ content: FileContent,
95
+ options?: FileOperationOptions,
96
+ ) => Effect.Effect<void, ContainerError>;
97
+ readonly stat: (
98
+ path: string,
99
+ options?: FileOperationOptions,
100
+ ) => Effect.Effect<FileStat, ContainerError>;
101
+ readonly lstat: (
102
+ path: string,
103
+ options?: FileOperationOptions,
104
+ ) => Effect.Effect<FileStat, ContainerError>;
105
+ readonly readDirectory: (
106
+ path: string,
107
+ options?: FileOperationOptions,
108
+ ) => Effect.Effect<ReadonlyArray<DirectoryEntry>, ContainerError>;
109
+ readonly mkdir: (path: string, options?: MkdirOptions) => Effect.Effect<void, ContainerError>;
110
+ readonly rename: (
111
+ source: string,
112
+ destination: string,
113
+ options?: FileOperationOptions,
114
+ ) => Effect.Effect<void, ContainerError>;
115
+ readonly remove: (path: string, options?: RemoveOptions) => Effect.Effect<void, ContainerError>;
116
+ }
117
+
118
+ const attempt = <A>(operation: string, evaluate: (signal: AbortSignal) => Promise<A>) =>
119
+ Effect.tryPromise({
120
+ try: evaluate,
121
+ catch: (cause) => new ContainerError({ operation: `files.${operation}`, cause }),
122
+ });
123
+
124
+ /** Pass `new Files(container.raw)` from SDK 1.x. The image must supply its matching sandbox-shim. */
125
+ export const fromFiles = (files: FilesResource): ContainerFiles => {
126
+ const readFile = (path: string, options?: FileOperationOptions) =>
127
+ attempt("readFile", (signal) => files.readFile(path, { ...options, signal }));
128
+ const readFileStream = (path: string, options?: FileOperationOptions) =>
129
+ Stream.unwrap(
130
+ Effect.map(readFile(path, options), (response) => {
131
+ const body = response.body;
132
+
133
+ return body === null
134
+ ? Stream.empty
135
+ : Stream.fromReadableStream({
136
+ evaluate: () => body,
137
+ onError: (cause) => new ContainerError({ operation: "files.readFile", cause }),
138
+ });
139
+ }),
140
+ );
141
+
142
+ return {
143
+ raw: files,
144
+ readFile,
145
+ readFileStream,
146
+ readFileString: (path, options) =>
147
+ readFileStream(path, options).pipe(Stream.decodeText(), Stream.mkString),
148
+ writeFile: (path, content, options) =>
149
+ attempt("writeFile", (signal) => files.writeFile(path, content, { ...options, signal })),
150
+ stat: (path, options) => attempt("stat", (signal) => files.stat(path, { ...options, signal })),
151
+ lstat: (path, options) =>
152
+ attempt("lstat", (signal) => files.lstat(path, { ...options, signal })),
153
+ readDirectory: (path, options) =>
154
+ attempt("readDirectory", (signal) => files.readDirectory(path, { ...options, signal })),
155
+ mkdir: (path, options) =>
156
+ attempt("mkdir", (signal) => files.mkdir(path, { ...options, signal })),
157
+ rename: (source, destination, options) =>
158
+ attempt("rename", (signal) => files.rename(source, destination, { ...options, signal })),
159
+ remove: (path, options) =>
160
+ attempt("remove", (signal) => files.remove(path, { ...options, signal })),
161
+ };
162
+ };
@@ -60,10 +60,12 @@ export const ContainerState = S.Union([
60
60
  export type ContainerState = typeof ContainerState.Type;
61
61
 
62
62
  /**
63
- * Native Container stub shape exposed by a Container namespace binding.
63
+ * RPC stub exposed by the legacy `@cloudflare/containers` Container class.
64
64
  *
65
65
  * This is structural so using Container bindings does not make
66
66
  * `@cloudflare/containers` a runtime dependency of effect-cf.
67
+ * Use `DurableObjectContainer` inside a Durable Object for the native
68
+ * `ctx.container` API and the `durable_object` scheduling policy.
67
69
  */
68
70
  export interface ContainerStub {
69
71
  fetch(input: RequestInfo | URL, init?: RequestInit): Promise<Response>;
@@ -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
+ );
package/src/Sandbox.ts CHANGED
@@ -1,3 +1,7 @@
1
+ /**
2
+ * Adapter for the legacy `@cloudflare/sandbox` 0.13 namespace/client API.
3
+ * SDK 1.x utilities can instead use `DurableObjectContainer`'s native `raw` handle.
4
+ */
1
5
  import type {
2
6
  BackupOptions,
3
7
  CheckChangesOptions,
package/src/index.ts CHANGED
@@ -6,10 +6,12 @@ export * as BrowserRendering from "./BrowserRendering";
6
6
  export * as Cache from "./Cache";
7
7
  export * as CloudflareOtlp from "./CloudflareOtlp";
8
8
  export * as CloudflareTracer from "./CloudflareTracer";
9
+ export * as ContainerFiles from "./ContainerFiles";
9
10
  export * as ContainerNamespace from "./ContainerNamespace";
10
11
  export * as D1 from "./D1";
11
12
  export * as DurableObject from "./DurableObject";
12
13
  export * as DurableObjectAlarm from "./DurableObjectAlarm";
14
+ export * as DurableObjectContainer from "./DurableObjectContainer";
13
15
  export * as DurableObjectDefinition from "./DurableObjectDefinition";
14
16
  export * as DurableObjectNamespace from "./DurableObjectNamespace";
15
17
  export * as DurableObjectRpcWebSocket from "./DurableObjectRpcWebSocket";