herdr-remote 0.2.9 → 0.2.10

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/tui.mjs CHANGED
@@ -4015,7 +4015,7 @@ function About({ ctx }) {
4015
4015
  children: updateLabel
4016
4016
  }
4017
4017
  ) }),
4018
- /* @__PURE__ */ jsx10(Row, { label: t("about.version"), children: /* @__PURE__ */ jsx10(Text9, { color: theme.muted, children: "0.2.9" }) }),
4018
+ /* @__PURE__ */ jsx10(Row, { label: t("about.version"), children: /* @__PURE__ */ jsx10(Text9, { color: theme.muted, children: "0.2.10" }) }),
4019
4019
  /* @__PURE__ */ jsx10(Row, { label: t("about.configPath"), children: /* @__PURE__ */ jsx10(Text9, { color: theme.muted, children: (0, import_config.configPath)() }) }),
4020
4020
  /* @__PURE__ */ jsx10(Row, { label: t("about.statePath"), children: /* @__PURE__ */ jsx10(Text9, { color: theme.muted, children: (0, import_config.stateDir)() }) }),
4021
4021
  /* @__PURE__ */ jsx10(Box9, { marginTop: 1, children: /* @__PURE__ */ jsx10(Text9, { color: theme.muted, children: t("about.docs") }) }),
package/herdr-plugin.toml CHANGED
@@ -1,6 +1,6 @@
1
1
  id = "herdr.remote.web"
2
2
  name = "Herdr Remote Web"
3
- version = "0.2.9"
3
+ version = "0.2.10"
4
4
  min_herdr_version = "0.9.0"
5
5
  description = "Browser access to Herdr workspaces via local or self-hosted relay"
6
6
  platforms = ["linux", "macos"]
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "herdr-remote",
3
- "version": "0.2.9",
3
+ "version": "0.2.10",
4
4
  "description": "Browser access to Herdr workspaces: plugin, host connector, and configuration TUI",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -12,7 +12,13 @@ const { PtySession } = require('./pty-session');
12
12
  const { resolveHerdrCommand, verifyHerdrCommand, herdrNotFoundMessage } = require('./herdr-command');
13
13
  // The wire format lives in the relay package so both ends of the protocol are
14
14
  // generated from one definition.
15
- const { packStreamFrame, unpackStreamFrame, PROTOCOL_VERSION } = require('herdr-remote-relay/protocol');
15
+ const {
16
+ packStreamFrame,
17
+ unpackStreamFrame,
18
+ packStreamFrameV2,
19
+ FRAME_TYPE_OUTPUT,
20
+ PROTOCOL_VERSION,
21
+ } = require('herdr-remote-relay/protocol');
16
22
  const { resolveHostPalette } = require('./terminal-palette');
17
23
  const { EXIT_REPLACED, EXIT_AUTH_FAILED } = require('./exit-codes');
18
24
  const { savePastedFile, cleanPastedDir } = require('./pasted-files');
@@ -77,6 +83,8 @@ class HostConnector {
77
83
  : resolveHostPalette();
78
84
  this.ws = null;
79
85
  this.sessions = new Map();
86
+ this.streamIndexToId = new Map();
87
+ this.PtySession = options.PtySession || PtySession;
80
88
  this.fastFailures = 0;
81
89
  this.reconnectTimer = null;
82
90
  this.heartbeatTimer = null;
@@ -170,6 +178,8 @@ class HostConnector {
170
178
  this.ws = ws;
171
179
  ws.isAlive = true;
172
180
  ws.on('open', () => {
181
+ // Disable Nagle's algorithm to prevent small frames from stalling ~40ms when delayed ACK is active.
182
+ try { ws._socket?.setNoDelay(true); } catch {}
173
183
  ws.isAlive = true;
174
184
  this.ready = false;
175
185
  this.authFailure = false;
@@ -183,7 +193,7 @@ class HostConnector {
183
193
  platform: process.platform,
184
194
  arch: process.arch,
185
195
  terminalPalette: this.terminalPalette || null,
186
- capabilities: ['host_handoff', 'idle_heartbeat'],
196
+ capabilities: ['host_handoff', 'idle_heartbeat', 'binary_frame_v2'],
187
197
  });
188
198
  // The relay sends host_ready with the current browser count. No business
189
199
  // heartbeat is started until that message says somebody is watching.
@@ -246,7 +256,10 @@ class HostConnector {
246
256
  process.stderr.write(`herdr-remote host connector: invalid relay frame: ${error.message}\n`);
247
257
  return;
248
258
  }
249
- if (frame.type === 'input') this.sessions.get(frame.streamId)?.pty.write(frame.payload);
259
+ if (frame.type === 'input') {
260
+ const streamId = frame.version === 2 ? this.streamIndexToId.get(frame.streamIndex) : frame.streamId;
261
+ if (streamId) this.sessions.get(streamId)?.pty.write(frame.payload);
262
+ }
250
263
  return;
251
264
  }
252
265
  let message;
@@ -312,6 +325,7 @@ class HostConnector {
312
325
  const streamId = typeof message.streamId === 'string' ? message.streamId : message.clientId;
313
326
  if (!streamId) return;
314
327
  this.stopSession(streamId);
328
+ const streamIndex = typeof message.streamIndex === 'number' ? message.streamIndex : null;
315
329
  const missingHerdr = this.ensureHerdrCommand();
316
330
  if (missingHerdr) {
317
331
  process.stderr.write(`herdr-remote host connector: ${missingHerdr}\n`);
@@ -323,7 +337,7 @@ class HostConnector {
323
337
  sendJson(this.ws, { type: 'error', clientId: streamId, code: 'herdr_socket_unavailable', message: socketInfo.reason });
324
338
  return;
325
339
  }
326
- const pty = new PtySession({
340
+ const pty = new this.PtySession({
327
341
  command: this.herdrCommand,
328
342
  args: this.herdrArgs,
329
343
  cwd: this.cwd,
@@ -331,34 +345,75 @@ class HostConnector {
331
345
  });
332
346
  const session = {
333
347
  id: streamId,
348
+ streamIndex,
334
349
  pty,
335
350
  cols: Number(message.cols) || PtySession.DEFAULT_COLS,
336
351
  rows: Number(message.rows) || PtySession.DEFAULT_ROWS,
337
352
  createdAt: new Date().toISOString(),
338
353
  startedAtMs: Date.now(),
339
354
  clientId: streamId,
355
+ pendingOutput: [],
356
+ flushImmediate: null,
357
+ };
358
+ const flushOutput = () => {
359
+ if (session.flushImmediate) {
360
+ clearImmediate(session.flushImmediate);
361
+ session.flushImmediate = null;
362
+ }
363
+ if (session.pendingOutput.length === 0) return;
364
+ const chunks = session.pendingOutput;
365
+ session.pendingOutput = [];
366
+ if (this.ws?.readyState === WebSocket.OPEN) {
367
+ const payload = Buffer.concat(chunks);
368
+ const frame = typeof session.streamIndex === 'number'
369
+ ? packStreamFrameV2(FRAME_TYPE_OUTPUT, session.streamIndex, payload)
370
+ : packStreamFrame('output', streamId, payload);
371
+ this.ws.send(frame);
372
+ }
340
373
  };
341
374
  try {
342
375
  pty.start({
343
376
  cols: session.cols,
344
377
  rows: session.rows,
345
378
  onData: (data) => {
346
- const payload = Buffer.from(data, 'utf8');
347
- if (this.ws?.readyState === WebSocket.OPEN) this.ws.send(packStreamFrame('output', streamId, payload));
379
+ const chunk = Buffer.isBuffer(data) ? data : Buffer.from(data, 'utf8');
380
+ if (chunk.length === 0) return;
381
+ session.pendingOutput.push(chunk);
382
+ if (!session.flushImmediate) {
383
+ // Coalesce burst output from the same tick into a single frame to reduce
384
+ // packet overhead without introducing timer latency.
385
+ session.flushImmediate = setImmediate(flushOutput);
386
+ }
348
387
  },
349
388
  onExit: ({ exitCode }) => {
350
389
  if (this.sessions.get(streamId) !== session) return;
351
390
  this.sessions.delete(streamId);
391
+ if (typeof session.streamIndex === 'number') {
392
+ this.streamIndexToId.delete(session.streamIndex);
393
+ }
394
+ if (session.flushImmediate) {
395
+ clearImmediate(session.flushImmediate);
396
+ session.flushImmediate = null;
397
+ }
398
+ session.pendingOutput = [];
352
399
  this.reportSessionExit(session, exitCode);
353
400
  this.sendHeartbeat();
354
401
  },
355
402
  });
356
403
  } catch (error) {
404
+ if (session.flushImmediate) {
405
+ clearImmediate(session.flushImmediate);
406
+ session.flushImmediate = null;
407
+ }
408
+ session.pendingOutput = [];
357
409
  sendJson(this.ws, { type: 'error', clientId: streamId, code: 'pty_start_failed', message: error.message });
358
410
  pty.kill();
359
411
  return;
360
412
  }
361
413
  this.sessions.set(streamId, session);
414
+ if (typeof streamIndex === 'number') {
415
+ this.streamIndexToId.set(streamIndex, streamId);
416
+ }
362
417
  sendJson(this.ws, { type: 'session_ready', clientId: streamId });
363
418
  this.sendHeartbeat();
364
419
  }
@@ -393,6 +448,14 @@ class HostConnector {
393
448
  const session = this.sessions.get(streamId);
394
449
  if (!session) return;
395
450
  this.sessions.delete(streamId);
451
+ if (typeof session.streamIndex === 'number') {
452
+ this.streamIndexToId.delete(session.streamIndex);
453
+ }
454
+ if (session.flushImmediate) {
455
+ clearImmediate(session.flushImmediate);
456
+ session.flushImmediate = null;
457
+ }
458
+ session.pendingOutput = [];
396
459
  session.pty.kill();
397
460
  this.sendHeartbeat();
398
461
  }
@@ -445,8 +508,16 @@ class HostConnector {
445
508
  }
446
509
 
447
510
  destroySessions() {
448
- for (const session of this.sessions.values()) session.pty.kill();
511
+ for (const session of this.sessions.values()) {
512
+ if (session.flushImmediate) {
513
+ clearImmediate(session.flushImmediate);
514
+ session.flushImmediate = null;
515
+ }
516
+ session.pendingOutput = [];
517
+ session.pty.kill();
518
+ }
449
519
  this.sessions.clear();
520
+ this.streamIndexToId.clear();
450
521
  }
451
522
 
452
523
  sendHeartbeat(force = false) {