@watasu/sdk 0.1.67 → 0.1.68

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.
@@ -94,21 +94,28 @@ export interface CommandConnectOpts extends CommandRequestOpts {
94
94
  export type Stdout = string;
95
95
  export type Stderr = string;
96
96
  export type PtyOutput = Uint8Array;
97
+ type ProcessReconnect = (cursor: number) => Promise<{
98
+ socket: ProcessSocket;
99
+ events: AsyncIterable<ProcessFrame>;
100
+ }>;
97
101
  /** Live handle for one sandbox process stream. */
98
102
  export declare class CommandHandle implements Partial<CommandResult> {
99
103
  readonly pid: number | string;
100
- private readonly socket;
104
+ private socket;
101
105
  private readonly handleKill;
102
- private readonly events;
106
+ private events;
103
107
  private readonly onStdout?;
104
108
  private readonly onStderr?;
105
109
  private readonly onPty?;
106
110
  private readonly onExit?;
111
+ private readonly reconnect?;
107
112
  private _stdout;
108
113
  private _stderr;
109
114
  private result?;
110
115
  private readonly pending;
111
- constructor(pid: number | string, socket: ProcessSocket, handleKill: () => Promise<boolean>, events: AsyncIterable<ProcessFrame>, onStdout?: ((data: string) => void | Promise<void>) | undefined, onStderr?: ((data: string) => void | Promise<void>) | undefined, onPty?: ((data: Uint8Array) => void | Promise<void>) | undefined, onExit?: ((exitCode: number) => void | Promise<void>) | undefined);
116
+ private nextCursor;
117
+ private disconnected;
118
+ constructor(pid: number | string, socket: ProcessSocket, handleKill: () => Promise<boolean>, events: AsyncIterable<ProcessFrame>, onStdout?: ((data: string) => void | Promise<void>) | undefined, onStderr?: ((data: string) => void | Promise<void>) | undefined, onPty?: ((data: Uint8Array) => void | Promise<void>) | undefined, onExit?: ((exitCode: number) => void | Promise<void>) | undefined, reconnect?: ProcessReconnect | undefined);
112
119
  get stdout(): string;
113
120
  get stderr(): string;
114
121
  get exitCode(): number | undefined;
@@ -129,6 +136,8 @@ export declare class CommandHandle implements Partial<CommandResult> {
129
136
  /** Detach the local stream without killing the process. */
130
137
  disconnect(): Promise<void>;
131
138
  private handleEvents;
139
+ private advanceCursor;
140
+ private reconnectStream;
132
141
  }
133
142
  /** Command runner for a sandbox data-plane session. */
134
143
  export declare class Commands {
@@ -174,4 +183,6 @@ export declare class Commands {
174
183
  stopProcess(pid: number | string, opts?: StopProcessOptions): Promise<ProcessStatus>;
175
184
  /** Start a command and return a live handle immediately. */
176
185
  start(cmd: string, opts?: CommandStartOpts): Promise<CommandHandle>;
186
+ private openProcessStream;
177
187
  }
188
+ export {};
package/dist/commands.js CHANGED
@@ -14,6 +14,9 @@ export class CommandExitError extends SandboxError {
14
14
  get stdout() { return this.result.stdout; }
15
15
  get stderr() { return this.result.stderr; }
16
16
  }
17
+ const STREAM_RECONNECT_ATTEMPTS = 12;
18
+ const STREAM_RECONNECT_BASE_DELAY_MS = 250;
19
+ const STREAM_RECONNECT_MAX_DELAY_MS = 2_000;
17
20
  /** Live handle for one sandbox process stream. */
18
21
  export class CommandHandle {
19
22
  pid;
@@ -24,11 +27,14 @@ export class CommandHandle {
24
27
  onStderr;
25
28
  onPty;
26
29
  onExit;
30
+ reconnect;
27
31
  _stdout = '';
28
32
  _stderr = '';
29
33
  result;
30
34
  pending;
31
- constructor(pid, socket, handleKill, events, onStdout, onStderr, onPty, onExit) {
35
+ nextCursor = 0;
36
+ disconnected = false;
37
+ constructor(pid, socket, handleKill, events, onStdout, onStderr, onPty, onExit, reconnect) {
32
38
  this.pid = pid;
33
39
  this.socket = socket;
34
40
  this.handleKill = handleKill;
@@ -37,6 +43,7 @@ export class CommandHandle {
37
43
  this.onStderr = onStderr;
38
44
  this.onPty = onPty;
39
45
  this.onExit = onExit;
46
+ this.reconnect = reconnect;
40
47
  this.pending = this.handleEvents();
41
48
  }
42
49
  get stdout() { return this._stdout; }
@@ -70,11 +77,14 @@ export class CommandHandle {
70
77
  }
71
78
  /** Detach the local stream without killing the process. */
72
79
  async disconnect() {
80
+ this.disconnected = true;
73
81
  this.socket.close();
74
82
  }
75
83
  async handleEvents() {
76
- try {
84
+ while (!this.disconnected && !this.result) {
85
+ let streamError;
77
86
  for await (const frame of this.events) {
87
+ this.advanceCursor(frame);
78
88
  const type = frame.type;
79
89
  if (type === 'started' || type === 'ready' || type === 'pong')
80
90
  continue;
@@ -103,16 +113,51 @@ export class CommandHandle {
103
113
  stderr: this._stderr,
104
114
  };
105
115
  await this.onExit?.(exitCode);
116
+ this.socket.close();
106
117
  return;
107
118
  }
108
119
  else if (type === 'error') {
109
- throw new SandboxError(String(frame.message ?? frame.code ?? 'process error'));
120
+ streamError = new SandboxError(String(frame.message ?? frame.code ?? 'process error'));
121
+ if (!isReconnectableStreamError(streamError))
122
+ throw streamError;
123
+ break;
110
124
  }
111
125
  }
126
+ if (this.result || this.disconnected)
127
+ return;
128
+ if (!this.reconnect) {
129
+ this.socket.close();
130
+ if (streamError)
131
+ throw streamError;
132
+ return;
133
+ }
134
+ await this.reconnectStream();
112
135
  }
113
- finally {
136
+ }
137
+ advanceCursor(frame) {
138
+ const cursor = numberValue(frame.cursor);
139
+ if (cursor !== undefined)
140
+ this.nextCursor = Math.max(this.nextCursor, cursor + 1);
141
+ }
142
+ async reconnectStream() {
143
+ let lastError;
144
+ for (let attempt = 0; attempt < STREAM_RECONNECT_ATTEMPTS && !this.disconnected; attempt += 1) {
114
145
  this.socket.close();
146
+ if (attempt > 0)
147
+ await sleep(reconnectDelayMs(attempt));
148
+ try {
149
+ const next = await this.reconnect(this.nextCursor);
150
+ this.socket = next.socket;
151
+ this.events = next.events;
152
+ return;
153
+ }
154
+ catch (error) {
155
+ lastError = error;
156
+ }
115
157
  }
158
+ if (lastError instanceof Error)
159
+ throw lastError;
160
+ throw new SandboxError('process websocket closed before exit and could not reconnect');
116
161
  }
117
162
  }
118
163
  /** Command runner for a sandbox data-plane session. */
@@ -177,11 +222,9 @@ export class Commands {
177
222
  }
178
223
  /** Reconnect to a live process stream by pid starting at a cursor. */
179
224
  async connectSince(pid, cursor = 0, opts = {}) {
180
- const encodedPid = encodeURIComponent(String(pid));
181
- const socket = await new ProcessSocket(this.dataPlane.baseUrl, this.dataPlane.token, withQuery(`/runtime/v1/process/${encodedPid}/connect`, { since: cursor }), opts.requestTimeoutMs ?? this.config.requestTimeoutMs, this.config.headers).connect();
182
- const first = await nextStarted(socket);
183
- const actualPid = framePid(first) ?? pid;
184
- return new CommandHandle(actualPid, socket, () => this.kill(actualPid), socket, opts.onStdout, opts.onStderr, opts.onPty);
225
+ const stream = await this.openProcessStream(pid, cursor, opts);
226
+ const reconnect = async (nextCursor) => this.openProcessStream(stream.actualPid, nextCursor, opts);
227
+ return new CommandHandle(stream.actualPid, stream.socket, () => this.kill(stream.actualPid), stream.events, opts.onStdout, opts.onStderr, opts.onPty, undefined, reconnect);
185
228
  }
186
229
  /** Look up process status without attaching a WebSocket. */
187
230
  async process(pid, opts = {}) {
@@ -229,7 +272,18 @@ export class Commands {
229
272
  const pid = framePid(first);
230
273
  if (pid === undefined)
231
274
  throw new SandboxError('process started frame did not include pid');
232
- return new CommandHandle(pid, socket, () => this.kill(pid), withFirst(first, socket), opts.onStdout, opts.onStderr, opts.onPty, opts.onExit);
275
+ const reconnect = async (nextCursor) => this.openProcessStream(pid, nextCursor, opts);
276
+ return new CommandHandle(pid, socket, () => this.kill(pid), withFirst(first, socket), opts.onStdout, opts.onStderr, opts.onPty, opts.onExit, reconnect);
277
+ }
278
+ async openProcessStream(pid, cursor, opts = {}) {
279
+ const encodedPid = encodeURIComponent(String(pid));
280
+ const socket = await new ProcessSocket(this.dataPlane.baseUrl, this.dataPlane.token, withQuery(`/runtime/v1/process/${encodedPid}/connect`, { since: cursor }), opts.requestTimeoutMs ?? this.config.requestTimeoutMs, this.config.headers).connect();
281
+ const first = await nextStarted(socket);
282
+ return {
283
+ actualPid: framePid(first) ?? pid,
284
+ socket,
285
+ events: socket,
286
+ };
233
287
  }
234
288
  }
235
289
  function processStartConfig(cmd, opts) {
@@ -246,6 +300,15 @@ function waitFor(promise, timeoutMs) {
246
300
  promise.then(resolve, reject).finally(() => clearTimeout(timer));
247
301
  });
248
302
  }
303
+ function sleep(ms) {
304
+ return new Promise((resolve) => setTimeout(resolve, ms));
305
+ }
306
+ function reconnectDelayMs(attempt) {
307
+ return Math.min(STREAM_RECONNECT_MAX_DELAY_MS, STREAM_RECONNECT_BASE_DELAY_MS * 2 ** Math.max(0, attempt - 1));
308
+ }
309
+ function isReconnectableStreamError(error) {
310
+ return error instanceof Error && /websocket|closed/i.test(error.message);
311
+ }
249
312
  async function nextStarted(events) {
250
313
  for await (const frame of events) {
251
314
  if (frame.type === 'started')
package/dist/pty.d.ts CHANGED
@@ -39,4 +39,5 @@ export declare class Pty {
39
39
  resize(pid: number | string, size: PtySize, opts?: PtyConnectOpts): Promise<void>;
40
40
  /** Kill a running PTY. */
41
41
  kill(pid: number | string, opts?: Pick<ConnectionOpts, 'requestTimeoutMs' | 'signal'>): Promise<boolean>;
42
+ private openPtyStream;
42
43
  }
package/dist/pty.js CHANGED
@@ -32,14 +32,14 @@ export class Pty {
32
32
  const pid = framePid(first);
33
33
  if (pid === undefined)
34
34
  throw new SandboxError('PTY started frame did not include pid');
35
- return new CommandHandle(pid, socket, () => this.kill(pid), withFirst(first, socket), undefined, undefined, opts.onData ?? opts.onPty);
35
+ const reconnect = async (cursor) => this.openPtyStream(pid, cursor, opts);
36
+ return new CommandHandle(pid, socket, () => this.kill(pid), withFirst(first, socket), undefined, undefined, opts.onData ?? opts.onPty, undefined, reconnect);
36
37
  }
37
38
  /** Connect to a running PTY by pid. */
38
39
  async connect(pid, opts = {}) {
39
- const socket = await new ProcessSocket(this.dataPlane.baseUrl, this.dataPlane.token, withQuery(`/runtime/v1/process/${encodeURIComponent(String(pid))}/connect`, { since: 0 }), opts.requestTimeoutMs ?? this.config.requestTimeoutMs, this.config.headers).connect();
40
- const first = await nextStarted(socket);
41
- const actualPid = framePid(first) ?? pid;
42
- return new CommandHandle(actualPid, socket, () => this.kill(actualPid), withFirst(first, socket), undefined, undefined, opts.onData);
40
+ const stream = await this.openPtyStream(pid, 0, opts);
41
+ const reconnect = async (cursor) => this.openPtyStream(stream.actualPid, cursor, opts);
42
+ return new CommandHandle(stream.actualPid, stream.socket, () => this.kill(stream.actualPid), stream.events, undefined, undefined, opts.onData, undefined, reconnect);
43
43
  }
44
44
  /** Send input bytes or text to a PTY. */
45
45
  async sendStdin(pid, data, opts = {}) {
@@ -76,6 +76,15 @@ export class Pty {
76
76
  });
77
77
  return true;
78
78
  }
79
+ async openPtyStream(pid, cursor, opts = {}) {
80
+ const socket = await new ProcessSocket(this.dataPlane.baseUrl, this.dataPlane.token, withQuery(`/runtime/v1/process/${encodeURIComponent(String(pid))}/connect`, { since: cursor }), opts.requestTimeoutMs ?? this.config.requestTimeoutMs, this.config.headers).connect();
81
+ const first = await nextStarted(socket);
82
+ return {
83
+ actualPid: framePid(first) ?? pid,
84
+ socket,
85
+ events: withFirst(first, socket),
86
+ };
87
+ }
79
88
  }
80
89
  async function nextStarted(events) {
81
90
  for await (const frame of events) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@watasu/sdk",
3
- "version": "0.1.67",
3
+ "version": "0.1.68",
4
4
  "type": "module",
5
5
  "license": "MIT OR Apache-2.0",
6
6
  "description": "TypeScript SDK for Watasu",