@rynx-ai/remote-runtime-client 0.1.11-beta.32 → 0.1.11-beta.34

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/client.d.ts CHANGED
@@ -1,4 +1,4 @@
1
- import { type RemoteRuntimeRpcMethod, type RemoteRuntimeRpcParams, type RemoteRuntimeRpcResultFor, type RemoteRuntimeSequencedSessionEvent, type RemoteRuntimeSessionTerminalClosedFrame, type RemoteRuntimeSessionTerminalRole } from "@rynx-ai/protocol/remote-runtime-rpc";
1
+ import { type RemoteRuntimeRpcMethod, type RemoteRuntimeRpcParams, type RemoteRuntimeRpcResultFor, type RemoteRuntimeSequencedSessionEvent, type RemoteRuntimeSessionTerminalRole } from "@rynx-ai/protocol/remote-runtime-rpc";
2
2
  import { type DaemonStatus } from "@rynx-ai/protocol/remote-runtime";
3
3
  import { type RuntimeEmulatorSurfaceClosedFrame, type RuntimeEmulatorSurfaceImageFrame, type RuntimeEmulatorSurfaceReadyFrame } from "@rynx-ai/protocol/runtime-emulator-surface";
4
4
  import { type DirectRuntimeClientIdentity, type DirectRuntimeCredential } from "./credential.js";
@@ -34,11 +34,34 @@ export interface DirectRuntimeSessionTerminalOptions {
34
34
  /** Timeout for the opened barrier and close acknowledgement. */
35
35
  timeoutMs?: number;
36
36
  }
37
- export interface DirectRuntimeSessionTerminal extends AsyncIterable<Uint8Array> {
37
+ export interface DirectRuntimeSessionTerminalRead {
38
+ data: Uint8Array;
39
+ nextOffset: number;
40
+ done: boolean;
41
+ finalOffset?: number;
42
+ }
43
+ export interface DirectRuntimeSessionTerminalCloseInfo {
44
+ reason: "client_closed" | "terminal_exited" | "not_live" | "unauthorized" | "capacity" | "backpressure" | "internal";
45
+ exitCode?: number;
46
+ finalOffset?: number;
47
+ }
48
+ export interface DirectRuntimeSessionTerminal {
38
49
  readonly attachmentId: string;
39
50
  readonly sessionId: string;
40
51
  readonly role: RemoteRuntimeSessionTerminalRole;
41
- readonly closed: Promise<RemoteRuntimeSessionTerminalClosedFrame>;
52
+ readonly seedBytes: number;
53
+ readonly dimensions?: {
54
+ cols: number;
55
+ rows: number;
56
+ };
57
+ readonly closed: Promise<DirectRuntimeSessionTerminalCloseInfo>;
58
+ readSeed(offset: number, maxBytes: number): Promise<DirectRuntimeSessionTerminalRead>;
59
+ start(): Promise<void>;
60
+ read(offset: number, maxBytes: number): Promise<DirectRuntimeSessionTerminalRead>;
61
+ onResize(listener: (dimensions: {
62
+ cols: number;
63
+ rows: number;
64
+ }) => void): void;
42
65
  write(data: Uint8Array): Promise<void>;
43
66
  resize(cols: number, rows: number): Promise<void>;
44
67
  /** Idempotently detach and wait for the remote close acknowledgement. */
package/dist/client.js CHANGED
@@ -25,8 +25,6 @@ const MAX_PENDING_OUTBOUND_BYTES = 1024 * 1024;
25
25
  const MAX_E2EE_ENVELOPE_OVERHEAD_BYTES = 256;
26
26
  const MAX_SUBSCRIPTION_QUEUED_EVENTS = 128;
27
27
  const MAX_SUBSCRIPTION_QUEUED_BYTES = 256 * 1024;
28
- const MAX_TERMINAL_QUEUED_OUTPUT_FRAMES = 128;
29
- const MAX_TERMINAL_QUEUED_OUTPUT_BYTES = 512 * 1024;
30
28
  const establishedTextDecoder = new TextDecoder("utf-8", { fatal: true });
31
29
  /** Enroll a client identity, authenticate the issued grant, and probe status. */
32
30
  export async function pairDirectRuntime(untrustedOffer, options = {}) {
@@ -478,8 +476,14 @@ class DirectRuntimeConnectionImpl {
478
476
  case "session.events.closed":
479
477
  this.receiveSessionFrame(value, frame.byteLength);
480
478
  return;
481
- case "session.terminal.opened":
482
- case "session.terminal.output":
479
+ case "session.terminal.prepared":
480
+ case "session.terminal.seed.chunk":
481
+ case "session.terminal.started":
482
+ case "session.terminal.chunk":
483
+ case "session.terminal.input.ack":
484
+ case "session.terminal.resize.ack":
485
+ case "session.terminal.reader.done":
486
+ case "session.terminal.dimensions":
483
487
  case "session.terminal.closed":
484
488
  this.receiveTerminalFrame(value);
485
489
  return;
@@ -625,16 +629,30 @@ class DirectRuntimeConnectionImpl {
625
629
  return;
626
630
  }
627
631
  switch (frame.type) {
628
- case "session.terminal.opened":
632
+ case "session.terminal.prepared":
629
633
  this.acceptTerminal(frame);
630
634
  return;
631
- case "session.terminal.output": {
635
+ case "session.terminal.seed.chunk":
636
+ case "session.terminal.chunk":
637
+ case "session.terminal.started":
638
+ case "session.terminal.input.ack":
639
+ case "session.terminal.resize.ack":
640
+ case "session.terminal.reader.done": {
632
641
  const terminal = this.terminal;
633
642
  if (!terminal || terminal.attachmentId !== frame.attachmentId) {
634
- this.fail(clientError("protocol_error", "Terminal output has no active attachment"));
643
+ this.fail(clientError("protocol_error", "Terminal response has no active attachment"));
635
644
  return;
636
645
  }
637
- terminal.push(Uint8Array.from(Buffer.from(frame.data, "base64")));
646
+ terminal.receive(frame);
647
+ return;
648
+ }
649
+ case "session.terminal.dimensions": {
650
+ const terminal = this.terminal;
651
+ if (!terminal || terminal.attachmentId !== frame.attachmentId) {
652
+ this.fail(clientError("protocol_error", "Terminal dimensions have no active attachment"));
653
+ return;
654
+ }
655
+ terminal.updateDimensions(frame.cols, frame.rows);
638
656
  return;
639
657
  }
640
658
  case "session.terminal.closed":
@@ -653,7 +671,7 @@ class DirectRuntimeConnectionImpl {
653
671
  }
654
672
  this.pendingTerminal = undefined;
655
673
  clearTimeout(pending.timer);
656
- const terminal = new SessionTerminalImpl(frame, pending.timeoutMs, (data) => this.writeTerminal(frame.attachmentId, data), (cols, rows) => this.resizeTerminal(frame.attachmentId, cols, rows), () => this.closeTerminal(frame.attachmentId), (error) => this.fail(error));
674
+ const terminal = new SessionTerminalImpl(frame, pending.timeoutMs, (value) => this.channel.socket.sendText(encodeRemoteRuntimeSessionTerminalClientFrame(value)), () => this.closeTerminal(frame.attachmentId), (error) => this.fail(error));
657
675
  this.terminal = terminal;
658
676
  pending.resolve(terminal);
659
677
  }
@@ -756,28 +774,6 @@ class DirectRuntimeConnectionImpl {
756
774
  ...point,
757
775
  }));
758
776
  }
759
- async writeTerminal(attachmentId, data) {
760
- if (data.byteLength === 0)
761
- return;
762
- for (let offset = 0; offset < data.byteLength; offset += REMOTE_RUNTIME_SESSION_TERMINAL_MAX_DATA_BYTES) {
763
- const chunk = data.subarray(offset, Math.min(offset + REMOTE_RUNTIME_SESSION_TERMINAL_MAX_DATA_BYTES, data.byteLength));
764
- const frame = encodeRemoteRuntimeSessionTerminalClientFrame({
765
- type: "session.terminal.input",
766
- attachmentId,
767
- data: Buffer.from(chunk).toString("base64"),
768
- });
769
- await this.channel.socket.sendText(frame);
770
- }
771
- }
772
- async resizeTerminal(attachmentId, cols, rows) {
773
- const frame = encodeRemoteRuntimeSessionTerminalClientFrame({
774
- type: "session.terminal.resize",
775
- attachmentId,
776
- cols,
777
- rows,
778
- });
779
- await this.channel.socket.sendText(frame);
780
- }
781
777
  async closeTerminal(attachmentId) {
782
778
  const terminal = this.terminal;
783
779
  if (!terminal || terminal.attachmentId !== attachmentId)
@@ -1187,31 +1183,39 @@ function sessionEventsError(frame, sessionId) {
1187
1183
  }
1188
1184
  class SessionTerminalImpl {
1189
1185
  timeoutMs;
1190
- sendInput;
1191
- sendResize;
1186
+ sendFrame;
1192
1187
  requestClose;
1193
1188
  reportBackpressure;
1194
- queue = [];
1195
- queuedBytes = 0;
1196
- waiter;
1197
- terminalError;
1198
- ended = false;
1199
- closing = false;
1189
+ state = "prepared";
1190
+ seedOffset = 0;
1191
+ liveOffset = 0;
1192
+ closedSettled = false;
1193
+ detachedSettled = false;
1194
+ pending = new Map();
1200
1195
  resolveClosed;
1201
1196
  rejectClosed;
1197
+ resolveDetached;
1198
+ rejectDetached;
1202
1199
  closed;
1200
+ detached;
1203
1201
  attachmentId;
1204
1202
  sessionId;
1205
1203
  role;
1206
- constructor(opened, timeoutMs, sendInput, sendResize, requestClose, reportBackpressure) {
1204
+ seedBytes;
1205
+ dimensions;
1206
+ resizeListener;
1207
+ constructor(opened, timeoutMs, sendFrame, requestClose, reportBackpressure) {
1207
1208
  this.timeoutMs = timeoutMs;
1208
- this.sendInput = sendInput;
1209
- this.sendResize = sendResize;
1209
+ this.sendFrame = sendFrame;
1210
1210
  this.requestClose = requestClose;
1211
1211
  this.reportBackpressure = reportBackpressure;
1212
1212
  this.attachmentId = opened.attachmentId;
1213
1213
  this.sessionId = opened.sessionId;
1214
1214
  this.role = opened.role;
1215
+ this.seedBytes = opened.seedBytes;
1216
+ this.dimensions = opened.cols === undefined || opened.rows === undefined
1217
+ ? undefined
1218
+ : { cols: opened.cols, rows: opened.rows };
1215
1219
  let resolveClosed;
1216
1220
  let rejectClosed;
1217
1221
  this.closed = new Promise((resolve, reject) => {
@@ -1220,111 +1224,225 @@ class SessionTerminalImpl {
1220
1224
  });
1221
1225
  this.resolveClosed = resolveClosed;
1222
1226
  this.rejectClosed = rejectClosed;
1227
+ let resolveDetached;
1228
+ let rejectDetached;
1229
+ this.detached = new Promise((resolve, reject) => {
1230
+ resolveDetached = resolve;
1231
+ rejectDetached = reject;
1232
+ });
1233
+ this.resolveDetached = resolveDetached;
1234
+ this.rejectDetached = rejectDetached;
1223
1235
  void this.closed.catch(() => undefined);
1236
+ void this.detached.catch(() => undefined);
1224
1237
  }
1225
- [Symbol.asyncIterator]() {
1226
- return this;
1238
+ onResize(listener) {
1239
+ this.resizeListener = listener;
1240
+ if (this.dimensions)
1241
+ queueMicrotask(() => listener(this.dimensions));
1227
1242
  }
1228
- next() {
1229
- const queued = this.queue.shift();
1230
- if (queued) {
1231
- this.queuedBytes -= queued.bytes;
1232
- return Promise.resolve({ done: false, value: queued.data });
1233
- }
1234
- if (this.terminalError !== undefined)
1235
- return Promise.reject(this.terminalError);
1236
- if (this.ended)
1237
- return Promise.resolve({ done: true, value: undefined });
1238
- if (this.waiter) {
1239
- return Promise.reject(clientError("protocol_error", "concurrent reads from one Session terminal are not allowed"));
1240
- }
1241
- return new Promise((resolve, reject) => {
1242
- this.waiter = { resolve, reject };
1243
+ updateDimensions(cols, rows) {
1244
+ if (this.state === "ended")
1245
+ return;
1246
+ this.dimensions = { cols, rows };
1247
+ this.resizeListener?.(this.dimensions);
1248
+ }
1249
+ readSeed(offset, maxBytes) {
1250
+ if (this.state !== "prepared" || offset !== this.seedOffset) {
1251
+ return Promise.reject(clientError("protocol_error", "Session terminal seed read is out of order"));
1252
+ }
1253
+ return this.requestRead("session.terminal.seed.chunk", {
1254
+ type: "session.terminal.seed.read",
1255
+ attachmentId: this.attachmentId,
1256
+ requestId: this.nextRequestId(),
1257
+ offset,
1258
+ maxBytes: Math.min(maxBytes, REMOTE_RUNTIME_SESSION_TERMINAL_MAX_DATA_BYTES),
1259
+ }).then((result) => {
1260
+ this.seedOffset = result.nextOffset;
1261
+ if (result.done && result.nextOffset !== this.seedBytes) {
1262
+ throw clientError("protocol_error", "Session terminal seed length changed");
1263
+ }
1264
+ return result;
1243
1265
  });
1244
1266
  }
1245
- async return() {
1246
- await this.close();
1247
- return { done: true, value: undefined };
1267
+ async start() {
1268
+ if (this.state !== "prepared" || this.seedOffset !== this.seedBytes) {
1269
+ throw clientError("protocol_error", "Session terminal history must be consumed before start");
1270
+ }
1271
+ this.state = "starting";
1272
+ await this.requestVoid("session.terminal.started", {
1273
+ type: "session.terminal.start",
1274
+ attachmentId: this.attachmentId,
1275
+ requestId: this.nextRequestId(),
1276
+ });
1277
+ if (this.state === "starting")
1278
+ this.state = "started";
1279
+ }
1280
+ read(offset, maxBytes) {
1281
+ if (this.state !== "started" || offset !== this.liveOffset) {
1282
+ return Promise.reject(clientError("protocol_error", "Session terminal read is out of order"));
1283
+ }
1284
+ return this.requestRead("session.terminal.chunk", {
1285
+ type: "session.terminal.read",
1286
+ attachmentId: this.attachmentId,
1287
+ requestId: this.nextRequestId(),
1288
+ offset,
1289
+ maxBytes: Math.min(maxBytes, REMOTE_RUNTIME_SESSION_TERMINAL_MAX_DATA_BYTES),
1290
+ }).then((result) => {
1291
+ this.liveOffset = result.nextOffset;
1292
+ return result;
1293
+ });
1248
1294
  }
1249
1295
  async write(data) {
1250
- if (this.ended || this.closing || this.role !== "owner" || data.byteLength === 0)
1296
+ if (this.state !== "started" || this.role !== "owner" || data.byteLength === 0)
1251
1297
  return;
1252
- await this.sendInput(Uint8Array.from(data));
1298
+ for (let offset = 0; offset < data.byteLength; offset += REMOTE_RUNTIME_SESSION_TERMINAL_MAX_DATA_BYTES) {
1299
+ const chunk = data.subarray(offset, Math.min(offset + REMOTE_RUNTIME_SESSION_TERMINAL_MAX_DATA_BYTES, data.byteLength));
1300
+ await this.requestVoid("session.terminal.input.ack", {
1301
+ type: "session.terminal.input",
1302
+ attachmentId: this.attachmentId,
1303
+ requestId: this.nextRequestId(),
1304
+ data: Buffer.from(chunk).toString("base64"),
1305
+ });
1306
+ }
1253
1307
  }
1254
1308
  async resize(cols, rows) {
1255
- if (this.ended || this.closing)
1309
+ if (this.state !== "started")
1256
1310
  return;
1257
- await this.sendResize(cols, rows);
1311
+ await this.requestVoid("session.terminal.resize.ack", {
1312
+ type: "session.terminal.resize",
1313
+ attachmentId: this.attachmentId,
1314
+ requestId: this.nextRequestId(),
1315
+ cols,
1316
+ rows,
1317
+ });
1258
1318
  }
1259
1319
  async close() {
1260
- if (this.ended)
1320
+ if (this.state === "ended")
1261
1321
  return;
1262
1322
  await this.requestClose();
1263
1323
  }
1264
- push(data) {
1265
- if (this.ended || this.closing || data.byteLength === 0)
1266
- return;
1267
- if (this.waiter) {
1268
- const waiter = this.waiter;
1269
- this.waiter = undefined;
1270
- waiter.resolve({ done: false, value: data });
1324
+ receive(frame) {
1325
+ if (frame.type === "session.terminal.reader.done") {
1326
+ if (!this.closedSettled) {
1327
+ this.closedSettled = true;
1328
+ this.resolveClosed({
1329
+ reason: frame.reason,
1330
+ exitCode: frame.exitCode,
1331
+ finalOffset: frame.finalOffset,
1332
+ });
1333
+ }
1271
1334
  return;
1272
1335
  }
1273
- if (this.queue.length >= MAX_TERMINAL_QUEUED_OUTPUT_FRAMES ||
1274
- this.queuedBytes + data.byteLength > MAX_TERMINAL_QUEUED_OUTPUT_BYTES) {
1275
- this.reportBackpressure(clientError("unreachable", "Remote Session terminal output exceeded its bounded client queue"));
1336
+ const pending = this.pending.get(frame.requestId);
1337
+ if (!pending || pending.expectedType !== frame.type) {
1338
+ this.reportBackpressure(clientError("protocol_error", "Remote Session terminal response is duplicate or out of order"));
1276
1339
  return;
1277
1340
  }
1278
- const copy = Uint8Array.from(data);
1279
- this.queue.push({ data: copy, bytes: copy.byteLength });
1280
- this.queuedBytes += copy.byteLength;
1341
+ this.pending.delete(frame.requestId);
1342
+ clearTimeout(pending.timer);
1343
+ if (frame.type === "session.terminal.seed.chunk" || frame.type === "session.terminal.chunk") {
1344
+ pending.resolve({
1345
+ data: Uint8Array.from(Buffer.from(frame.data, "base64")),
1346
+ nextOffset: frame.nextOffset,
1347
+ done: frame.done,
1348
+ ...(frame.finalOffset === undefined ? {} : { finalOffset: frame.finalOffset }),
1349
+ });
1350
+ }
1351
+ else
1352
+ pending.resolve(undefined);
1281
1353
  }
1282
1354
  beginClose() {
1283
- if (this.ended || this.closing)
1355
+ if (this.state === "ended" || this.state === "closing")
1284
1356
  return false;
1285
- this.closing = true;
1286
- this.clearQueue();
1357
+ this.state = "closing";
1358
+ this.rejectPending(clientError("closed", "Remote Session terminal is closing"));
1287
1359
  return true;
1288
1360
  }
1289
1361
  remoteClose(frame) {
1290
- if (this.ended)
1362
+ if (this.state === "ended")
1291
1363
  return;
1364
+ this.state = "ended";
1292
1365
  const error = frame.reason === "client_closed" || frame.reason === "terminal_exited"
1293
1366
  ? undefined
1294
1367
  : sessionTerminalError(frame, this.sessionId);
1295
- this.finish(frame, error, frame.reason !== "terminal_exited");
1368
+ this.rejectPending(error ?? clientError("closed", "Remote Session terminal detached"));
1369
+ if (!this.closedSettled) {
1370
+ this.closedSettled = true;
1371
+ if (error)
1372
+ this.rejectClosed(error);
1373
+ else
1374
+ this.resolveClosed({ reason: frame.reason, ...(frame.exitCode === undefined ? {} : { exitCode: frame.exitCode }) });
1375
+ }
1376
+ if (!this.detachedSettled) {
1377
+ this.detachedSettled = true;
1378
+ if (error)
1379
+ this.rejectDetached(error);
1380
+ else
1381
+ this.resolveDetached();
1382
+ }
1296
1383
  }
1297
1384
  terminate(error) {
1298
- if (this.ended)
1385
+ if (this.state === "ended")
1299
1386
  return;
1300
- this.finish(undefined, error, true);
1387
+ this.state = "ended";
1388
+ this.rejectPending(error);
1389
+ if (!this.closedSettled) {
1390
+ this.closedSettled = true;
1391
+ this.rejectClosed(error);
1392
+ }
1393
+ if (!this.detachedSettled) {
1394
+ this.detachedSettled = true;
1395
+ this.rejectDetached(error);
1396
+ }
1301
1397
  }
1302
1398
  waitClosed() {
1303
- return this.closed.then(() => undefined);
1399
+ return this.detached;
1304
1400
  }
1305
- finish(frame, error, discardQueue = false) {
1306
- if (this.ended)
1307
- return;
1308
- this.ended = true;
1309
- this.terminalError = error;
1310
- if (discardQueue)
1311
- this.clearQueue();
1312
- const waiter = this.waiter;
1313
- this.waiter = undefined;
1314
- if (waiter) {
1315
- if (error === undefined)
1316
- waiter.resolve({ done: true, value: undefined });
1317
- else
1318
- waiter.reject(error);
1401
+ requestRead(expectedType, frame) {
1402
+ return this.request(expectedType, frame);
1403
+ }
1404
+ requestVoid(expectedType, frame) {
1405
+ return this.request(expectedType, frame).then(() => undefined);
1406
+ }
1407
+ request(expectedType, frame) {
1408
+ if (!("requestId" in frame)) {
1409
+ return Promise.reject(clientError("protocol_error", "terminal request id is missing"));
1319
1410
  }
1320
- if (frame)
1321
- this.resolveClosed(frame);
1322
- else
1323
- this.rejectClosed(error ?? clientError("closed", "Remote Session terminal closed"));
1411
+ const pull = expectedType === "session.terminal.seed.chunk" || expectedType === "session.terminal.chunk";
1412
+ const sameClassPending = [...this.pending.values()].some((pending) => {
1413
+ const pendingPull = pending.expectedType === "session.terminal.seed.chunk" ||
1414
+ pending.expectedType === "session.terminal.chunk";
1415
+ return pendingPull === pull;
1416
+ });
1417
+ if (sameClassPending) {
1418
+ return Promise.reject(clientError("protocol_error", pull ? "concurrent terminal pulls are not allowed" : "concurrent terminal controls are not allowed"));
1419
+ }
1420
+ return new Promise((resolve, reject) => {
1421
+ const timer = setTimeout(() => {
1422
+ this.pending.delete(frame.requestId);
1423
+ reject(clientError("deadline_exceeded", "Remote Session terminal operation timed out"));
1424
+ }, this.timeoutMs);
1425
+ timer.unref?.();
1426
+ this.pending.set(frame.requestId, { expectedType, resolve, reject, timer });
1427
+ void this.sendFrame(frame).catch((error) => {
1428
+ const pending = this.pending.get(frame.requestId);
1429
+ if (!pending)
1430
+ return;
1431
+ this.pending.delete(frame.requestId);
1432
+ clearTimeout(pending.timer);
1433
+ pending.reject(error);
1434
+ });
1435
+ });
1324
1436
  }
1325
- clearQueue() {
1326
- this.queue.length = 0;
1327
- this.queuedBytes = 0;
1437
+ rejectPending(error) {
1438
+ for (const pending of this.pending.values()) {
1439
+ clearTimeout(pending.timer);
1440
+ pending.reject(error);
1441
+ }
1442
+ this.pending.clear();
1443
+ }
1444
+ nextRequestId() {
1445
+ return `treq_${randomBytes(12).toString("base64url")}`;
1328
1446
  }
1329
1447
  }
1330
1448
  function sessionTerminalError(frame, sessionId) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@rynx-ai/remote-runtime-client",
3
- "version": "0.1.11-beta.32",
3
+ "version": "0.1.11-beta.34",
4
4
  "repository": {
5
5
  "type": "git",
6
6
  "url": "git+https://github.com/rynx-ai/rynx.git",
@@ -25,8 +25,8 @@
25
25
  },
26
26
  "dependencies": {
27
27
  "ws": "^8.21.0",
28
- "@rynx-ai/protocol": "0.1.11-beta.32",
29
- "@rynx-ai/remote-runtime-e2ee": "0.1.11-beta.32"
28
+ "@rynx-ai/remote-runtime-e2ee": "0.1.11-beta.34",
29
+ "@rynx-ai/protocol": "0.1.11-beta.34"
30
30
  },
31
31
  "devDependencies": {
32
32
  "@types/ws": "^8.18.1"