@sealant/sdk-next 0.40.0-next.760 → 0.40.0-next.763

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/dist/errors.d.ts CHANGED
@@ -1,4 +1,5 @@
1
1
  import type { ForwardRefusalReason } from "@sealant/api-contracts";
2
+ import type { ForwardResetCause } from "./types.js";
2
3
  /**
3
4
  * Public SDK errors. The Effect core fails on a typed `Schema.TaggedError` channel; the facade maps
4
5
  * every tagged failure onto one of these PLAIN `Error` subclasses (a single `_tag` switch) so the
@@ -82,3 +83,11 @@ export declare class SealantNotImplementedError extends SealantError {
82
83
  readonly name = "SealantNotImplementedError";
83
84
  constructor(operation: string);
84
85
  }
86
+ /** A bounded forward was reset after it opened. Its streams error with this. */
87
+ export declare class ForwardResetError extends SealantError {
88
+ readonly name: string;
89
+ readonly reason: ForwardResetCause;
90
+ /** The reason Core sent, when this SDK does not know it (`unrecognized-reset`). */
91
+ readonly wireReason?: string;
92
+ constructor(reason: ForwardResetCause, wireReason?: string);
93
+ }
package/dist/errors.js CHANGED
@@ -85,3 +85,16 @@ export class SealantNotImplementedError extends SealantError {
85
85
  super(`${operation} is part of the Sealant SDK surface but is not implemented in this build yet.`, { code: "not_implemented" });
86
86
  }
87
87
  }
88
+ /** A bounded forward was reset after it opened. Its streams error with this. */
89
+ export class ForwardResetError extends SealantError {
90
+ name = "ForwardResetError";
91
+ reason;
92
+ /** The reason Core sent, when this SDK does not know it (`unrecognized-reset`). */
93
+ wireReason;
94
+ constructor(reason, wireReason) {
95
+ super(`The forward was reset: ${wireReason ?? reason}.`, { code: "ForwardResetError" });
96
+ this.reason = reason;
97
+ if (wireReason !== undefined)
98
+ this.wireReason = wireReason;
99
+ }
100
+ }
@@ -0,0 +1,7 @@
1
+ import type { BoundedWorkspaceForward } from "../types.js";
2
+ export declare const boundedForward: (ws: WebSocket, binding: {
3
+ readonly kind: "port";
4
+ } | {
5
+ readonly kind: "session";
6
+ readonly session: string;
7
+ }, onWindowOpen: () => void) => BoundedWorkspaceForward;
@@ -0,0 +1,191 @@
1
+ import { CountQueuingStrategy, ReadableStream, WritableStream, } from "node:stream/web";
2
+ import { FORWARD_V2_MAX_FRAME_BYTES, ForwardFlow, forwardFrames, forwardResetReasonSchema, } from "@sealant/api-contracts";
3
+ import { Schema } from "effect";
4
+ import { ForwardResetError } from "../errors.js";
5
+ const isKnownReset = Schema.is(forwardResetReasonSchema);
6
+ const SOCKET_HIGH_WATER_BYTES = FORWARD_V2_MAX_FRAME_BYTES;
7
+ /** A WHATWG socket has no drain event, so a held `progress` checks again this often. */
8
+ const HELD_PROGRESS_RETRY_MS = 10;
9
+ export const boundedForward = (ws, binding, onWindowOpen) => {
10
+ const flow = new ForwardFlow();
11
+ const unread = [];
12
+ const closed = Promise.withResolvers();
13
+ let state = { kind: "open" };
14
+ let windowOpen = false;
15
+ let finReceived = false;
16
+ let reader;
17
+ let writer;
18
+ let pendingRead;
19
+ let creditWaiter;
20
+ let progressRetry;
21
+ const sendText = (frame) => ws.send(JSON.stringify(frame));
22
+ const wakeReader = () => {
23
+ const waiter = pendingRead;
24
+ pendingRead = undefined;
25
+ waiter?.();
26
+ };
27
+ const wakeWriter = () => {
28
+ const waiter = creditWaiter;
29
+ creditWaiter = undefined;
30
+ waiter?.();
31
+ };
32
+ const settle = (next, close) => {
33
+ if (state.kind !== "open")
34
+ return;
35
+ state = next;
36
+ clearTimeout(progressRetry);
37
+ closed.resolve(close);
38
+ if (next.kind === "failed") {
39
+ writer?.error(next.error);
40
+ if (next.readable === "errored")
41
+ reader?.error(next.error);
42
+ }
43
+ wakeReader();
44
+ wakeWriter();
45
+ };
46
+ const resetBy = (reason, wireReason) => settle({ kind: "failed", error: new ForwardResetError(reason, wireReason), readable: "errored" }, { kind: "reset", reason, ...(wireReason === undefined ? {} : { wireReason }) });
47
+ const reset = (reason) => {
48
+ if (state.kind !== "open")
49
+ return;
50
+ if (ws.readyState === WebSocket.OPEN)
51
+ sendText({ t: "reset", reason });
52
+ resetBy(reason);
53
+ ws.close();
54
+ };
55
+ const endedByWorkspace = () => {
56
+ if (flow.delivered && creditWaiter === undefined) {
57
+ return settle({ kind: "ended" }, { kind: "end" });
58
+ }
59
+ settle({
60
+ kind: "failed",
61
+ error: new ForwardResetError("workspace-ended"),
62
+ readable: "drains-what-arrived",
63
+ }, { kind: "reset", reason: "workspace-ended" });
64
+ };
65
+ const sendProgress = () => {
66
+ if (state.kind !== "open" || ws.readyState !== WebSocket.OPEN || !flow.progressDue)
67
+ return;
68
+ if (ws.bufferedAmount > SOCKET_HIGH_WATER_BYTES) {
69
+ progressRetry ??= setTimeout(() => {
70
+ progressRetry = undefined;
71
+ sendProgress();
72
+ }, HELD_PROGRESS_RETRY_MS);
73
+ return;
74
+ }
75
+ const progress = flow.takeProgress();
76
+ if (progress !== undefined)
77
+ sendText(progress);
78
+ };
79
+ const deliver = (controller, chunk) => {
80
+ controller.enqueue(chunk);
81
+ flow.consumed(chunk.byteLength);
82
+ sendProgress();
83
+ };
84
+ const onInbound = (frame) => {
85
+ const inbound = flow.receive(frame);
86
+ switch (inbound.kind) {
87
+ case "data":
88
+ if (pendingRead === undefined || reader === undefined) {
89
+ unread.push(inbound.data);
90
+ return;
91
+ }
92
+ deliver(reader, inbound.data);
93
+ return wakeReader();
94
+ case "progress":
95
+ if (!windowOpen) {
96
+ windowOpen = true;
97
+ onWindowOpen();
98
+ }
99
+ return wakeWriter();
100
+ case "fin":
101
+ finReceived = true;
102
+ if (unread.length === 0)
103
+ reader?.close();
104
+ wakeReader();
105
+ return endedByWorkspace();
106
+ case "reset": {
107
+ const known = isKnownReset(inbound.reason);
108
+ resetBy(known ? inbound.reason : "unrecognized-reset", known ? undefined : inbound.reason);
109
+ return ws.close();
110
+ }
111
+ case "violation":
112
+ return reset(inbound.reason);
113
+ }
114
+ };
115
+ ws.addEventListener("message", (event) => {
116
+ if (state.kind !== "open")
117
+ return;
118
+ if (event.data instanceof ArrayBuffer)
119
+ onInbound(new Uint8Array(event.data));
120
+ else if (typeof event.data === "string")
121
+ onInbound(event.data);
122
+ });
123
+ ws.addEventListener("close", () => resetBy("connection-lost"));
124
+ const readable = new ReadableStream({
125
+ start: (controller) => {
126
+ reader = controller;
127
+ },
128
+ pull: (controller) => {
129
+ const chunk = unread.shift();
130
+ if (chunk === undefined) {
131
+ return new Promise((resolve) => {
132
+ pendingRead = resolve;
133
+ });
134
+ }
135
+ deliver(controller, chunk);
136
+ if (finReceived && unread.length === 0)
137
+ controller.close();
138
+ },
139
+ cancel: () => reset("aborted"),
140
+ }, { highWaterMark: 0 });
141
+ const waitForCredit = (fits) => new Promise((resolve, reject) => {
142
+ const check = () => {
143
+ if (state.kind === "failed")
144
+ return reject(state.error);
145
+ if (state.kind === "ended")
146
+ return reject(new ForwardResetError("workspace-ended"));
147
+ if (fits())
148
+ return resolve();
149
+ creditWaiter = check;
150
+ };
151
+ check();
152
+ });
153
+ const holdReadyUntilCredit = () => waitForCredit(() => flow.canSend(1));
154
+ const writable = new WritableStream({
155
+ start: (controller) => {
156
+ writer = controller;
157
+ // The sink's own `abort` runs only after a write waiting for credit settles. Node has had
158
+ // `signal` since 18; its types still lack it.
159
+ const signal = "signal" in controller ? controller.signal : undefined;
160
+ if (!(signal instanceof AbortSignal))
161
+ return;
162
+ signal.addEventListener("abort", () => {
163
+ // The stream is already erroring: erroring it again from here breaks Node's abort.
164
+ writer = undefined;
165
+ reset("aborted");
166
+ });
167
+ },
168
+ write: async (chunk) => {
169
+ for (const frame of forwardFrames(chunk)) {
170
+ await waitForCredit(() => flow.canSend(frame.byteLength));
171
+ flow.sent(frame.byteLength);
172
+ // A copy: a WHATWG socket refuses a view of a SharedArrayBuffer.
173
+ ws.send(new Uint8Array(frame));
174
+ }
175
+ await holdReadyUntilCredit();
176
+ },
177
+ close: () => {
178
+ if (state.kind === "open")
179
+ sendText(flow.fin());
180
+ },
181
+ abort: () => reset("aborted"),
182
+ }, new CountQueuingStrategy({ highWaterMark: 1 }));
183
+ sendText(flow.open());
184
+ return {
185
+ readable,
186
+ writable,
187
+ binding,
188
+ closed: closed.promise,
189
+ close: () => reset("aborted"),
190
+ };
191
+ };
@@ -1,18 +1,9 @@
1
- import { type ForwardBinding } from "@sealant/api-contracts";
2
- import type { WorkspaceForward, WorkspaceForwardOptions } from "../types.js";
1
+ import type { BoundedForwardTarget, BoundedWorkspaceForward, BoundedWorkspaceForwardOptions, WorkspaceForward, WorkspaceForwardOptions } from "../types.js";
3
2
  export interface ForwardEndpoint {
4
3
  readonly baseUrl: string;
5
4
  readonly apiKey: string | undefined;
6
5
  readonly ownerUserId: string;
7
6
  }
8
7
  export declare const openForward: (endpoint: ForwardEndpoint, workspaceId: string, port: number, options?: WorkspaceForwardOptions | undefined) => Promise<WorkspaceForward>;
9
- export interface ForwardV2Options {
10
- readonly host?: "127.0.0.1" | "localhost" | "docker";
11
- readonly signal?: AbortSignal;
12
- readonly deadlineMs?: number;
13
- }
14
- export interface OpenedForwardV2 {
15
- readonly binding: ForwardBinding;
16
- readonly forward: WorkspaceForward;
17
- }
18
- export declare const openForwardV2: (endpoint: ForwardEndpoint, workspaceId: string, port: number, options?: ForwardV2Options | undefined) => Promise<OpenedForwardV2>;
8
+ export type ForwardV2Options = BoundedForwardTarget & Pick<BoundedWorkspaceForwardOptions, "signal" | "deadlineMs">;
9
+ export declare const openForwardV2: (endpoint: ForwardEndpoint, workspaceId: string, port: number, options?: ForwardV2Options | undefined) => Promise<BoundedWorkspaceForward>;
@@ -1,6 +1,7 @@
1
- import { FORWARD_V2_MAX_FRAME_BYTES, FORWARD_V2_SUBPROTOCOL, forwardOpenFrameSchema, forwardRefusalReasonSchema, } from "@sealant/api-contracts";
1
+ import { FORWARD_REFUSAL_STATUS, FORWARD_V2_SUBPROTOCOL, forwardOpenFrameSchema, forwardRefusalReasonSchema, } from "@sealant/api-contracts";
2
2
  import { Option, Schema } from "effect";
3
3
  import { ForwardDialError } from "../errors.js";
4
+ import { boundedForward } from "./bounded-forward.js";
4
5
  const forwardUrl = (endpoint, path, query) => {
5
6
  const url = new URL(path, endpoint.baseUrl);
6
7
  url.protocol = url.protocol === "https:" ? "wss:" : "ws:";
@@ -16,7 +17,7 @@ const forwardUrl = (endpoint, path, query) => {
16
17
  }
17
18
  return url;
18
19
  };
19
- const bridgeForward = (ws, maxFrameBytes = Number.POSITIVE_INFINITY) => {
20
+ const bridgeForward = (ws) => {
20
21
  const pending = [];
21
22
  let wake;
22
23
  let finished = false;
@@ -30,7 +31,7 @@ const bridgeForward = (ws, maxFrameBytes = Number.POSITIVE_INFINITY) => {
30
31
  wake?.();
31
32
  };
32
33
  ws.addEventListener("close", () => finish("closed"));
33
- const receive = (event) => {
34
+ ws.addEventListener("message", (event) => {
34
35
  if (typeof event.data === "string") {
35
36
  try {
36
37
  const frame = JSON.parse(event.data);
@@ -43,7 +44,7 @@ const bridgeForward = (ws, maxFrameBytes = Number.POSITIVE_INFINITY) => {
43
44
  }
44
45
  pending.push(new Uint8Array(event.data));
45
46
  wake?.();
46
- };
47
+ });
47
48
  const output = {
48
49
  [Symbol.asyncIterator]: () => ({
49
50
  next: async () => {
@@ -63,14 +64,10 @@ const bridgeForward = (ws, maxFrameBytes = Number.POSITIVE_INFINITY) => {
63
64
  },
64
65
  }),
65
66
  };
66
- const forward = {
67
+ return {
67
68
  send: (input) => {
68
- let offset = 0;
69
- do {
70
- // Copy into a plain ArrayBuffer-backed view (WebSocket.send rejects SharedArrayBuffer views).
71
- ws.send(new Uint8Array(input.subarray(offset, offset + maxFrameBytes)).buffer);
72
- offset += maxFrameBytes;
73
- } while (offset < input.byteLength);
69
+ // Copy into a plain ArrayBuffer-backed view (WebSocket.send rejects SharedArrayBuffer views).
70
+ ws.send(new Uint8Array(input).buffer);
74
71
  },
75
72
  eof: () => {
76
73
  ws.send(JSON.stringify({ t: "eof" }));
@@ -82,7 +79,6 @@ const bridgeForward = (ws, maxFrameBytes = Number.POSITIVE_INFINITY) => {
82
79
  ws.close();
83
80
  },
84
81
  };
85
- return { forward, receive };
86
82
  };
87
83
  export const openForward = (endpoint, workspaceId, port, options) => {
88
84
  const url = forwardUrl(endpoint, `/v1/workspaces/${workspaceId}/forward`, {
@@ -93,8 +89,7 @@ export const openForward = (endpoint, workspaceId, port, options) => {
93
89
  return new Promise((resolve, reject) => {
94
90
  const ws = new WebSocket(url);
95
91
  ws.binaryType = "arraybuffer";
96
- const { forward, receive } = bridgeForward(ws);
97
- ws.addEventListener("message", receive);
92
+ const forward = bridgeForward(ws);
98
93
  ws.addEventListener("open", () => resolve(forward), { once: true });
99
94
  ws.addEventListener("error", () => reject(new Error(`workspace forward failed: could not connect to ${url.host} (is anything listening on 127.0.0.1:${port} in the workspace?)`)), { once: true });
100
95
  });
@@ -136,11 +131,13 @@ const failedHandshakeError = async (url, origin, signal) => {
136
131
  };
137
132
  export const openForwardV2 = (endpoint, workspaceId, port, options) => {
138
133
  const host = options?.host ?? "127.0.0.1";
134
+ const session = options?.session;
139
135
  const signal = options?.signal;
140
136
  const deadlineMs = options?.deadlineMs ?? FORWARD_V2_OPEN_DEADLINE_MS;
141
137
  const url = forwardUrl(endpoint, `/v1/workspaces/${workspaceId}/forward/v2`, {
142
138
  port: String(port),
143
139
  host,
140
+ session,
144
141
  });
145
142
  const origin = new URL(endpoint.baseUrl).origin;
146
143
  return new Promise((resolve, reject) => {
@@ -148,7 +145,6 @@ export const openForwardV2 = (endpoint, workspaceId, port, options) => {
148
145
  return reject(signal.reason);
149
146
  const ws = new WebSocket(url, FORWARD_V2_SUBPROTOCOL);
150
147
  ws.binaryType = "arraybuffer";
151
- const { forward, receive } = bridgeForward(ws, FORWARD_V2_MAX_FRAME_BYTES);
152
148
  const probing = new AbortController();
153
149
  let phase = "handshake";
154
150
  const settle = (next) => {
@@ -178,8 +174,6 @@ export const openForwardV2 = (endpoint, workspaceId, port, options) => {
178
174
  }
179
175
  });
180
176
  ws.addEventListener("message", (event) => {
181
- if (phase === "open")
182
- return receive(event);
183
177
  if (phase !== "awaiting-answer")
184
178
  return;
185
179
  const frame = typeof event.data === "string" ? decodeOpenFrame(event.data) : Option.none();
@@ -188,11 +182,22 @@ export const openForwardV2 = (endpoint, workspaceId, port, options) => {
188
182
  }
189
183
  if (frame.value.t === "refused")
190
184
  return fail(refusalError(frame.value));
191
- settle("open");
192
- resolve({ binding: frame.value.binding, forward });
185
+ const { binding } = frame.value;
186
+ if (session !== undefined && (binding.kind !== "session" || binding.session !== session)) {
187
+ return fail(new ForwardDialError({
188
+ reason: "binding-unsupported",
189
+ status: FORWARD_REFUSAL_STATUS["binding-unsupported"],
190
+ message: `${origin} opened the forward without binding it to session ${session}.`,
191
+ }));
192
+ }
193
+ phase = "awaiting-window";
194
+ const forward = boundedForward(ws, binding, () => {
195
+ settle("open");
196
+ resolve(forward);
197
+ });
193
198
  });
194
199
  ws.addEventListener("close", () => {
195
- if (phase === "awaiting-answer") {
200
+ if (phase === "awaiting-answer" || phase === "awaiting-window") {
196
201
  return fail(new ForwardDialError({
197
202
  reason: "transport-unavailable",
198
203
  message: `The forward's connection to ${origin} closed before the control plane answered.`,
@@ -7,7 +7,7 @@ import { elapsed } from "../internal/clock.js";
7
7
  import { mapAccountRef } from "../internal/credentials.js";
8
8
  import { parseTtlSeconds } from "../internal/duration.js";
9
9
  import { requireProcessUser } from "../internal/process-user.js";
10
- import { openForward } from "./forward.js";
10
+ import { openForward, openForwardV2 } from "./forward.js";
11
11
  import { makeInteractiveSession } from "./session.js";
12
12
  /** The wire runtime as the SDK reports it. */
13
13
  export const toRuntimeInfo = (runtime) => ({
@@ -295,6 +295,22 @@ const olderControlPlaneRefusal = (argv, error) => {
295
295
  });
296
296
  };
297
297
  export const makeWorkspace = (ctx, init) => {
298
+ const endpoint = {
299
+ baseUrl: ctx.config.baseUrl,
300
+ apiKey: ctx.config.apiKey,
301
+ ownerUserId: ctx.config.hostLocal.ownerUserId,
302
+ };
303
+ function forward(port, options) {
304
+ if (options?.flow === "bounded") {
305
+ return openForwardV2(endpoint, init.id, port, options);
306
+ }
307
+ if (options !== undefined && "session" in options) {
308
+ return Promise.reject(new SealantError('A forward bound to a session needs flow: "bounded".', {
309
+ code: "invalid_options",
310
+ }));
311
+ }
312
+ return openForward(endpoint, init.id, port, options);
313
+ }
298
314
  const openSession = async (argv, options) => {
299
315
  // The contract's own rule, checked before anything is asked, with the same value-free reason.
300
316
  const refused = sessionArgvIssue(argv);
@@ -801,11 +817,7 @@ export const makeWorkspace = (ctx, init) => {
801
817
  sshAsRoot: async () => {
802
818
  await ctx.runtime.run(clearWorkspaceSshUserOp(init.id, { ownerUserId: ctx.config.hostLocal.ownerUserId }));
803
819
  },
804
- forward: (port, options) => openForward({
805
- baseUrl: ctx.config.baseUrl,
806
- apiKey: ctx.config.apiKey,
807
- ownerUserId: ctx.config.hostLocal.ownerUserId,
808
- }, init.id, port, options),
820
+ forward,
809
821
  };
810
822
  return workspace;
811
823
  };
package/dist/index.d.ts CHANGED
@@ -16,7 +16,7 @@
16
16
  */
17
17
  export { Sealant } from "./client.js";
18
18
  export { claudeCode, codex, customHarness, opencode, pi } from "./harness.js";
19
- export { ForwardDialError, type ForwardDialFailure, type ForwardDialReason, SealantApiError, SealantError, SealantNotImplementedError, SealantRuntimeError, } from "./errors.js";
19
+ export { ForwardDialError, type ForwardDialFailure, type ForwardDialReason, ForwardResetError, SealantApiError, SealantError, SealantNotImplementedError, SealantRuntimeError, } from "./errors.js";
20
20
  export type * from "./types.js";
21
21
  export { findWorkspaceEnvReservedRule, findWorkspaceSecretEnvReservedRule, formatWorkspaceEnvIssue, parseWorkspaceEnv, parseWorkspaceSecretEnv, WORKSPACE_ENV_MAX_ENTRIES, WORKSPACE_ENV_MAX_NAME_LENGTH, WORKSPACE_ENV_MAX_TOTAL_BYTES, WORKSPACE_ENV_MAX_VALUE_BYTES, WORKSPACE_ENV_NAME_PATTERN, WORKSPACE_ENV_SECRET_MARKERS, } from "@sealant/api-contracts/workspace-environment";
22
22
  export type { WorkspaceEnvIssue, WorkspaceEnvParseResult, WorkspaceEnvReservedRule, } from "@sealant/api-contracts/workspace-environment";
package/dist/index.js CHANGED
@@ -16,7 +16,7 @@
16
16
  */
17
17
  export { Sealant } from "./client.js";
18
18
  export { claudeCode, codex, customHarness, opencode, pi } from "./harness.js";
19
- export { ForwardDialError, SealantApiError, SealantError, SealantNotImplementedError, SealantRuntimeError, } from "./errors.js";
19
+ export { ForwardDialError, ForwardResetError, SealantApiError, SealantError, SealantNotImplementedError, SealantRuntimeError, } from "./errors.js";
20
20
  // The workspace environment policy (`CreateOptions.env` validation) is public API: downstream
21
21
  // products validate at their own boundaries with the exact rules the platform enforces.
22
22
  export { findWorkspaceEnvReservedRule, findWorkspaceSecretEnvReservedRule, formatWorkspaceEnvIssue, parseWorkspaceEnv, parseWorkspaceSecretEnv, WORKSPACE_ENV_MAX_ENTRIES, WORKSPACE_ENV_MAX_NAME_LENGTH, WORKSPACE_ENV_MAX_TOTAL_BYTES, WORKSPACE_ENV_MAX_VALUE_BYTES, WORKSPACE_ENV_NAME_PATTERN, WORKSPACE_ENV_SECRET_MARKERS, } from "@sealant/api-contracts/workspace-environment";
package/dist/types.d.ts CHANGED
@@ -14,6 +14,8 @@
14
14
  * compile against a stable contract from day one (unimplemented paths reject with
15
15
  * `SealantNotImplementedError` at runtime, see `./errors.js`).
16
16
  */
17
+ import type { ReadableStream as NodeReadableStream, WritableStream as NodeWritableStream } from "node:stream/web";
18
+ import type { ForwardBinding, ForwardResetReason } from "@sealant/api-contracts";
17
19
  /**
18
20
  * Public client configuration. Intentionally minimal: a base URL and an API key. Host-local
19
21
  * concerns required by the current slice (owner identity, registry, direct database access) live in
@@ -1160,7 +1162,13 @@ export interface Workspace {
1160
1162
  * workspace-private set: the container's loopback (default), or `docker` —
1161
1163
  * the workspace-scoped Docker sidecar's alias, where `docker compose`
1162
1164
  * publishes its ports.
1165
+ *
1166
+ * With `flow: "bounded"`, a TCP forward whose two directions are bounded: the workspace's
1167
+ * producer is held back while `readable` is not read, and `writable` holds back its writer while
1168
+ * the workspace does not take the bytes. It resolves once Core and the workspace runtime have
1169
+ * opened it, and rejects with a {@link ForwardDialError} that says why when they did not.
1163
1170
  */
1171
+ forward(port: number, options: BoundedWorkspaceForwardOptions): Promise<BoundedWorkspaceForward>;
1164
1172
  forward(port: number, options?: WorkspaceForwardOptions): Promise<WorkspaceForward>;
1165
1173
  }
1166
1174
  /**
@@ -1442,6 +1450,10 @@ export interface WorkspaceCredentialsHomeOptions {
1442
1450
  }
1443
1451
  /** Options for {@link Workspace.forward}. */
1444
1452
  export interface WorkspaceForwardOptions {
1453
+ /** Only a bounded forward has a `flow`. */
1454
+ readonly flow?: never;
1455
+ /** Only a bounded forward can be bound to a session. */
1456
+ readonly session?: never;
1445
1457
  /** Target inside the workspace: its loopback (default) or the Docker sidecar. */
1446
1458
  readonly host?: "127.0.0.1" | "localhost" | "docker";
1447
1459
  /**
@@ -1469,6 +1481,64 @@ export interface WorkspaceForward {
1469
1481
  /** Tear the forward down. */
1470
1482
  close(): void;
1471
1483
  }
1484
+ /**
1485
+ * Where a bounded forward goes: the port on the workspace's loopback (default) or on the Docker
1486
+ * sidecar, whatever holds it; or, with `session`, the port on the loopback only when a process of
1487
+ * that session holds it. A bound forward resets with `owner-exited` once the session's leader exits.
1488
+ */
1489
+ export type BoundedForwardTarget = {
1490
+ readonly host?: "127.0.0.1" | "localhost" | "docker";
1491
+ readonly session?: never;
1492
+ } | {
1493
+ readonly host?: "127.0.0.1" | "localhost";
1494
+ readonly session: string;
1495
+ };
1496
+ /** Options for a bounded {@link Workspace.forward}. TCP only. */
1497
+ export type BoundedWorkspaceForwardOptions = BoundedForwardTarget & {
1498
+ readonly flow: "bounded";
1499
+ /** Gives up opening the forward; an open forward ignores it. */
1500
+ readonly signal?: AbortSignal;
1501
+ /** How long opening may take before it rejects, in milliseconds. 30 000 by default. */
1502
+ readonly deadlineMs?: number;
1503
+ };
1504
+ /**
1505
+ * Why a bounded forward was reset: a reason either side sent, `connection-lost` when the socket
1506
+ * closed with neither a `fin` nor a reset, `unrecognized-reset` when Core sent a reason newer than
1507
+ * this SDK (kept in `wireReason`), or `workspace-ended` when the workspace ended the forward before
1508
+ * it took everything written to it. A write after the workspace ended fails with `workspace-ended`
1509
+ * too.
1510
+ */
1511
+ export type ForwardResetCause = ForwardResetReason | "connection-lost" | "unrecognized-reset" | "workspace-ended";
1512
+ /**
1513
+ * How a bounded forward ended: the workspace closed its side after it took everything written to
1514
+ * it (`end`), or the forward was reset.
1515
+ */
1516
+ export type BoundedWorkspaceForwardClose = {
1517
+ readonly kind: "end";
1518
+ } | {
1519
+ readonly kind: "reset";
1520
+ readonly reason: ForwardResetCause;
1521
+ readonly wireReason?: string;
1522
+ };
1523
+ /**
1524
+ * A bounded forward. Reading `readable` is what lets the workspace send more; `writer.ready` waits
1525
+ * while the workspace can take no more; `writable.close()` half-closes toward the workspace. Wrap
1526
+ * it as a Node stream with `Duplex.fromWeb(forward)`. The streams are typed as `node:stream/web`'s,
1527
+ * which `Duplex.fromWeb` accepts also where the DOM's stream types are loaded.
1528
+ *
1529
+ * A reset fails both streams with a `ForwardResetError`, at once: data the workspace sent that was
1530
+ * not read yet is dropped. When the workspace ends first, `readable` still yields all it sent, and
1531
+ * the writer fails with `workspace-ended` only if something written did not reach the workspace.
1532
+ */
1533
+ export interface BoundedWorkspaceForward {
1534
+ readonly readable: NodeReadableStream<Uint8Array>;
1535
+ readonly writable: NodeWritableStream<Uint8Array>;
1536
+ /** What the forward is bound to. */
1537
+ readonly binding: ForwardBinding;
1538
+ readonly closed: Promise<BoundedWorkspaceForwardClose>;
1539
+ /** Resets both directions. */
1540
+ close(): void;
1541
+ }
1472
1542
  export interface RunOptions {
1473
1543
  /** Cancel the run by aborting this signal. */
1474
1544
  readonly signal?: AbortSignal;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sealant/sdk-next",
3
- "version": "0.40.0-next.760",
3
+ "version": "0.40.0-next.763",
4
4
  "description": "The fluent public SDK for Sealant — create a workspace, run a harness, replay the record.",
5
5
  "license": "Apache-2.0",
6
6
  "repository": {
@@ -26,12 +26,12 @@
26
26
  "node": ">=20"
27
27
  },
28
28
  "dependencies": {
29
- "@sealant/api-contracts": "npm:@sealant/api-contracts-next@0.40.0-next.760"
29
+ "@sealant/api-contracts": "npm:@sealant/api-contracts-next@0.40.0-next.763"
30
30
  },
31
31
  "peerDependencies": {
32
32
  "effect": "^4.0.5"
33
33
  },
34
- "gitHead": "eb43ab8076dbdd584f026d068c478078a294d570",
34
+ "gitHead": "c741e10743b2c553b526f93565c256fe8fa4bd8b",
35
35
  "publishConfig": {
36
36
  "access": "public"
37
37
  }