dsh-zen-remote 2.3.11 → 2.4.1
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 +45 -78
- package/README.md +94 -84
- package/README.zh-CN.md +90 -81
- package/bin/zen-remote.mjs +5 -0
- package/docs/interface.md +6 -6
- package/docs/remote-access.en.md +6 -4
- package/docs/remote-access.md +6 -4
- package/dsh-push.mjs +96 -10
- package/lib/adapters/dsh-better-sidebar/index.js +7 -3
- package/lib/adapters/dsh-better-sidebar/index.js.map +1 -1
- package/lib/admin-routes.js +10 -2
- package/lib/admin-routes.js.map +1 -1
- package/lib/cli/args.js +251 -0
- package/lib/cli/args.js.map +1 -0
- package/lib/cli/bootstrap.js +449 -0
- package/lib/cli/bootstrap.js.map +1 -0
- package/lib/cli/command-setup.js +484 -0
- package/lib/cli/command-setup.js.map +1 -0
- package/lib/cli/commands-client.js +359 -0
- package/lib/cli/commands-client.js.map +1 -0
- package/lib/cli/commands-host.js +1108 -0
- package/lib/cli/commands-host.js.map +1 -0
- package/lib/cli/commands-status.js +279 -0
- package/lib/cli/commands-status.js.map +1 -0
- package/lib/cli/config-writer.js +334 -0
- package/lib/cli/config-writer.js.map +1 -0
- package/lib/cli/context.js +10 -0
- package/lib/cli/context.js.map +1 -0
- package/lib/cli/i18n.js +620 -0
- package/lib/cli/i18n.js.map +1 -0
- package/lib/cli/ipc-client.js +160 -0
- package/lib/cli/ipc-client.js.map +1 -0
- package/lib/cli/ipc-server.js +464 -0
- package/lib/cli/ipc-server.js.map +1 -0
- package/lib/cli/main.js +245 -0
- package/lib/cli/main.js.map +1 -0
- package/lib/cli/qr-terminal.js +48 -0
- package/lib/cli/qr-terminal.js.map +1 -0
- package/lib/client-data/qr.js +286 -0
- package/lib/client-data/qr.js.map +1 -0
- package/lib/client-routes.js +308 -25
- package/lib/client-routes.js.map +1 -1
- package/lib/client.js +1013 -229
- package/lib/config.js +50 -12
- package/lib/config.js.map +1 -1
- package/lib/debug-report.js +28 -1
- package/lib/debug-report.js.map +1 -1
- package/lib/fetch-route-intercept.js +334 -26
- package/lib/fetch-route-intercept.js.map +1 -1
- package/lib/index.js +608 -375
- package/lib/index.js.map +1 -1
- package/lib/intercept.js +1833 -280
- package/lib/intercept.js.map +1 -1
- package/lib/lan-gate-server.cjs +36 -10
- package/lib/merge-streams.js +103 -11
- package/lib/merge-streams.js.map +1 -1
- package/lib/plugin-route-intercept.js +93 -63
- package/lib/plugin-route-intercept.js.map +1 -1
- package/lib/relay-access.js +8 -1
- package/lib/relay-access.js.map +1 -1
- package/lib/relay-client.js +133 -4
- package/lib/relay-client.js.map +1 -1
- package/lib/relay-pool.js +275 -0
- package/lib/relay-pool.js.map +1 -0
- package/lib/relay-server.js +269 -17
- package/lib/relay-server.js.map +1 -1
- package/lib/restart-fields.js +6 -4
- package/lib/restart-fields.js.map +1 -1
- package/lib/share-ops.js +68 -0
- package/lib/share-ops.js.map +1 -1
- package/lib/share-store.js +65 -27
- package/lib/share-store.js.map +1 -1
- package/lib/state-store.js +400 -0
- package/lib/state-store.js.map +1 -0
- package/lib/types/adapters/dsh-better-sidebar/index.d.ts +3 -2
- package/lib/types/adapters/dsh-better-sidebar/index.d.ts.map +1 -1
- package/lib/types/admin-routes.d.ts.map +1 -1
- package/lib/types/cli/args.d.ts +78 -0
- package/lib/types/cli/args.d.ts.map +1 -0
- package/lib/types/cli/bootstrap.d.ts +170 -0
- package/lib/types/cli/bootstrap.d.ts.map +1 -0
- package/lib/types/cli/command-setup.d.ts +72 -0
- package/lib/types/cli/command-setup.d.ts.map +1 -0
- package/lib/types/cli/commands-client.d.ts +36 -0
- package/lib/types/cli/commands-client.d.ts.map +1 -0
- package/lib/types/cli/commands-host.d.ts +82 -0
- package/lib/types/cli/commands-host.d.ts.map +1 -0
- package/lib/types/cli/commands-status.d.ts +46 -0
- package/lib/types/cli/commands-status.d.ts.map +1 -0
- package/lib/types/cli/config-writer.d.ts +92 -0
- package/lib/types/cli/config-writer.d.ts.map +1 -0
- package/lib/types/cli/context.d.ts +39 -0
- package/lib/types/cli/context.d.ts.map +1 -0
- package/lib/types/cli/i18n.d.ts +32 -0
- package/lib/types/cli/i18n.d.ts.map +1 -0
- package/lib/types/cli/ipc-client.d.ts +83 -0
- package/lib/types/cli/ipc-client.d.ts.map +1 -0
- package/lib/types/cli/ipc-server.d.ts +113 -0
- package/lib/types/cli/ipc-server.d.ts.map +1 -0
- package/lib/types/cli/main.d.ts +57 -0
- package/lib/types/cli/main.d.ts.map +1 -0
- package/lib/types/cli/qr-terminal.d.ts +17 -0
- package/lib/types/cli/qr-terminal.d.ts.map +1 -0
- package/lib/types/client/RemoteStatusIcon.d.ts.map +1 -1
- package/lib/types/client/current-session-report.d.ts +4 -0
- package/lib/types/client/current-session-report.d.ts.map +1 -1
- package/lib/types/client/effects/model-group-side.d.ts.map +1 -1
- package/lib/types/client/effects/remote-session.d.ts.map +1 -1
- package/lib/types/client/locales.d.ts +47 -3
- package/lib/types/client/locales.d.ts.map +1 -1
- package/lib/types/client/settings/SettingsSection.d.ts.map +1 -1
- package/lib/types/client/settings/settings-css.d.ts +1 -1
- package/lib/types/client/settings/settings-css.d.ts.map +1 -1
- package/lib/types/client/styles/remote-session.css.d.ts +1 -1
- package/lib/types/client/styles/remote-session.css.d.ts.map +1 -1
- package/lib/types/client/wizard/GatewayWizard.d.ts.map +1 -1
- package/lib/types/client/wizard/cards/ProxyCard.d.ts.map +1 -1
- package/lib/types/client/wizard/wizard-css.d.ts +1 -1
- package/lib/types/client/wizard/wizard-css.d.ts.map +1 -1
- package/lib/types/client-data/model-group-side.d.ts +23 -2
- package/lib/types/client-data/model-group-side.d.ts.map +1 -1
- package/lib/types/client-data/remote-status.d.ts +47 -1
- package/lib/types/client-data/remote-status.d.ts.map +1 -1
- package/lib/types/client-data/settings-form.d.ts +85 -1
- package/lib/types/client-data/settings-form.d.ts.map +1 -1
- package/lib/types/client-data/wizard.d.ts +56 -1
- package/lib/types/client-data/wizard.d.ts.map +1 -1
- package/lib/types/client-routes.d.ts +25 -1
- package/lib/types/client-routes.d.ts.map +1 -1
- package/lib/types/config.d.ts +40 -14
- package/lib/types/config.d.ts.map +1 -1
- package/lib/types/debug-report.d.ts.map +1 -1
- package/lib/types/fetch-route-intercept.d.ts +66 -8
- package/lib/types/fetch-route-intercept.d.ts.map +1 -1
- package/lib/types/index.d.ts +9 -2
- package/lib/types/index.d.ts.map +1 -1
- package/lib/types/intercept.d.ts +72 -10
- package/lib/types/intercept.d.ts.map +1 -1
- package/lib/types/merge-streams.d.ts +79 -1
- package/lib/types/merge-streams.d.ts.map +1 -1
- package/lib/types/plugin-route-intercept.d.ts +25 -9
- package/lib/types/plugin-route-intercept.d.ts.map +1 -1
- package/lib/types/relay-access.d.ts +3 -1
- package/lib/types/relay-access.d.ts.map +1 -1
- package/lib/types/relay-client.d.ts +23 -2
- package/lib/types/relay-client.d.ts.map +1 -1
- package/lib/types/relay-pool.d.ts +148 -0
- package/lib/types/relay-pool.d.ts.map +1 -0
- package/lib/types/relay-server.d.ts +30 -3
- package/lib/types/relay-server.d.ts.map +1 -1
- package/lib/types/restart-fields.d.ts +5 -5
- package/lib/types/restart-fields.d.ts.map +1 -1
- package/lib/types/share-ops.d.ts +47 -1
- package/lib/types/share-ops.d.ts.map +1 -1
- package/lib/types/share-store.d.ts.map +1 -1
- package/lib/types/state-store.d.ts +179 -0
- package/lib/types/state-store.d.ts.map +1 -0
- package/lib/types/upload-limits.d.ts +15 -0
- package/lib/types/upload-limits.d.ts.map +1 -1
- package/lib/types/virtual-id.d.ts +21 -0
- package/lib/types/virtual-id.d.ts.map +1 -1
- package/lib/types/wizard/probe.d.ts.map +1 -1
- package/lib/types/wizard/proxy-snippets.d.ts +3 -0
- package/lib/types/wizard/proxy-snippets.d.ts.map +1 -1
- package/lib/types/wizard/routes.d.ts +1 -0
- package/lib/types/wizard/routes.d.ts.map +1 -1
- package/lib/types/wizard/status.d.ts +9 -0
- package/lib/types/wizard/status.d.ts.map +1 -1
- package/lib/types/wizard/verify.d.ts +3 -0
- package/lib/types/wizard/verify.d.ts.map +1 -1
- package/lib/upload-limits.js +15 -0
- package/lib/upload-limits.js.map +1 -1
- package/lib/virtual-id.js +60 -0
- package/lib/virtual-id.js.map +1 -1
- package/lib/wizard/probe.js +46 -1
- package/lib/wizard/probe.js.map +1 -1
- package/lib/wizard/proxy-snippets.js +14 -1
- package/lib/wizard/proxy-snippets.js.map +1 -1
- package/lib/wizard/routes.js +23 -4
- package/lib/wizard/routes.js.map +1 -1
- package/lib/wizard/status.js +21 -0
- package/lib/wizard/status.js.map +1 -1
- package/lib/wizard/verify.js +31 -8
- package/lib/wizard/verify.js.map +1 -1
- package/package.json +8 -4
- package/pwa/icons/badge-96.png +0 -0
- package/pwa/icons/badge.svg +1 -0
- package/pwa/manifest.json +2 -1
- package/pwa/sw.js +3 -1
- package/scripts/check-debug-report.mjs +41 -1
- package/scripts/check-settings-form.mjs +14 -3
- package/scripts/check-settings-view.mjs +247 -0
- package/scripts/check-wizard-ui.mjs +37 -0
- package/src/adapters/dsh-better-sidebar/index.ts +7 -3
- package/src/admin-routes.ts +10 -2
- package/src/cli/args.ts +289 -0
- package/src/cli/bootstrap.ts +569 -0
- package/src/cli/command-setup.ts +540 -0
- package/src/cli/commands-client.ts +369 -0
- package/src/cli/commands-host.ts +1127 -0
- package/src/cli/commands-status.ts +296 -0
- package/src/cli/config-writer.ts +354 -0
- package/src/cli/context.ts +38 -0
- package/src/cli/i18n.ts +645 -0
- package/src/cli/ipc-client.ts +216 -0
- package/src/cli/ipc-server.ts +531 -0
- package/src/cli/main.ts +285 -0
- package/src/cli/qr-terminal.ts +49 -0
- package/src/client/RemoteStatusIcon.tsx +4 -1
- package/src/client/current-session-report.ts +28 -4
- package/src/client/effects/model-group-side.ts +19 -5
- package/src/client/effects/remote-session.ts +26 -6
- package/src/client/locales.ts +96 -6
- package/src/client/settings/SettingsSection.tsx +287 -92
- package/src/client/settings/settings-css.ts +18 -0
- package/src/client/styles/remote-session.css.ts +15 -1
- package/src/client/wizard/GatewayWizard.tsx +470 -109
- package/src/client/wizard/cards/ProxyCard.tsx +2 -1
- package/src/client/wizard/wizard-css.ts +23 -0
- package/src/client-data/model-group-side.ts +37 -10
- package/src/client-data/remote-status.ts +112 -17
- package/src/client-data/settings-form.ts +164 -8
- package/src/client-data/wizard.ts +112 -2
- package/src/client-routes.ts +334 -26
- package/src/config.ts +62 -16
- package/src/debug-report.ts +27 -1
- package/src/fetch-route-intercept.ts +381 -32
- package/src/index.ts +608 -380
- package/src/intercept.ts +1866 -286
- package/src/merge-streams.ts +155 -10
- package/src/plugin-route-intercept.ts +120 -71
- package/src/relay-access.ts +14 -3
- package/src/relay-client.ts +163 -5
- package/src/relay-pool.ts +387 -0
- package/src/relay-server.ts +312 -16
- package/src/restart-fields.ts +6 -4
- package/src/share-ops.ts +82 -1
- package/src/share-store.ts +59 -26
- package/src/state-store.ts +428 -0
- package/src/upload-limits.ts +16 -0
- package/src/virtual-id.ts +57 -0
- package/src/wizard/probe.ts +54 -2
- package/src/wizard/proxy-snippets.ts +17 -2
- package/src/wizard/routes.ts +24 -5
- package/src/wizard/status.ts +28 -0
- package/src/wizard/verify.ts +33 -11
- package/AGENTS.md +0 -185
- package/docs/adr/0001-plugin-bridge-forwards-plugin-routes.md +0 -15
- package/docs/adr/0002-relay-emulates-websocket.md +0 -8
package/lib/relay-server.js
CHANGED
|
@@ -29,9 +29,9 @@ import { withPeer } from './owner-peer.js';
|
|
|
29
29
|
import { controlProjectionFrame } from './merge-streams.js';
|
|
30
30
|
import { randomBytes, timingSafeEqual } from 'node:crypto';
|
|
31
31
|
import { Readable, Transform } from 'node:stream';
|
|
32
|
-
import { mkdirSync, readFileSync, renameSync, writeFileSync } from 'node:fs';
|
|
33
32
|
import { createRequire } from 'node:module';
|
|
34
33
|
import { join } from 'node:path';
|
|
34
|
+
import { ensureServerIdMigrated, readStateFile, STATE_FILE_NAME, writeStateFile } from './state-store.js';
|
|
35
35
|
import { decideHttpRoute, decideInvoke, decideStream, decideUploadQuery, parseEventResultBody } from './relay-access.js';
|
|
36
36
|
import { createWorkspaceFollowState, filterControlFrame, filterJobListFrame, filterModelCatalogResult, filterSessionListResult, filterWorkspaceFrame, } from './relay-filter.js';
|
|
37
37
|
import { SESSION_REFERENCE_URI, collectReferenceTexts, decodeSessionReferenceUri } from './session-reference.js';
|
|
@@ -60,6 +60,15 @@ export { MAX_UPLOAD_BYTES };
|
|
|
60
60
|
/** The `/api` route the upload channel dispatches into, verbatim from the
|
|
61
61
|
* host's own registration (RT dsh-client-file-upload lib/index.js:73). */
|
|
62
62
|
export const UPLOAD_HTTP_PATH = '/api/session/uploadFileBinary';
|
|
63
|
+
/**
|
|
64
|
+
* The duplex uplink's buffering ceiling (TA-1): how many bytes of
|
|
65
|
+
* not-yet-consumed uplink frames one live stream may hold — the same budget
|
|
66
|
+
* the host's own stream inbox applies (`DEFAULT_STREAM_INBOX_BYTES`), so a
|
|
67
|
+
* stalled terminal consumer backs up at exactly the point the local session
|
|
68
|
+
* would have. Past it the `/v1/stream-uplink` route answers 413
|
|
69
|
+
* `uplink-overflow` and the stream is aborted.
|
|
70
|
+
*/
|
|
71
|
+
export const MAX_STREAM_UPLINK_BYTES = 262_144;
|
|
63
72
|
/** Error messages forwarded to the client are clipped to this many chars. */
|
|
64
73
|
const MAX_MESSAGE_CHARS = 500;
|
|
65
74
|
/**
|
|
@@ -133,31 +142,39 @@ const JOB_CACHE_LIMIT = 1024;
|
|
|
133
142
|
* handful of servers apart in a client's server list without leaking anything
|
|
134
143
|
* countable about the deployment. */
|
|
135
144
|
const SERVER_ID_PATTERN = /^[0-9a-f]{8}$/;
|
|
136
|
-
|
|
145
|
+
/**
|
|
146
|
+
* The state file the server identity lives in since MS-1 — the same file the
|
|
147
|
+
* share table (`sessions`) and the paired-servers list (`servers`) use, so
|
|
148
|
+
* every writer of one field must preserve the other two. `loadServerId`
|
|
149
|
+
* reads and writes it only through state-store's merged doors.
|
|
150
|
+
*/
|
|
151
|
+
const SERVER_ID_FILE = STATE_FILE_NAME;
|
|
137
152
|
/**
|
|
138
153
|
* Read the persisted server id, creating (and persisting) one on first use.
|
|
139
|
-
* A
|
|
140
|
-
*
|
|
141
|
-
*
|
|
154
|
+
* A pre-multi-server `zen-remote-server.json` identity migrates in first
|
|
155
|
+
* (and its file is deleted) — an upgraded host keeps its id, so paired
|
|
156
|
+
* devices never see a stranger. A missing file is the normal first run; an
|
|
157
|
+
* unreadable or damaged file must never take the host down, so every failure
|
|
158
|
+
* degrades to a fresh per-process id that simply is not remembered across
|
|
159
|
+
* restarts.
|
|
142
160
|
*/
|
|
143
161
|
export function loadServerId(home) {
|
|
144
162
|
const file = join(home, SERVER_ID_FILE);
|
|
163
|
+
ensureServerIdMigrated(file);
|
|
145
164
|
try {
|
|
146
|
-
const
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
return id;
|
|
165
|
+
const state = readStateFile(file);
|
|
166
|
+
if (state.serverId !== undefined && SERVER_ID_PATTERN.test(state.serverId))
|
|
167
|
+
return state.serverId;
|
|
150
168
|
}
|
|
151
169
|
catch {
|
|
152
|
-
//
|
|
170
|
+
// Damaged state: mint a new one below (state-store never throws, but the
|
|
171
|
+
// belt stays on).
|
|
153
172
|
}
|
|
154
173
|
const id = randomBytes(4).toString('hex');
|
|
155
174
|
try {
|
|
156
|
-
//
|
|
157
|
-
//
|
|
158
|
-
|
|
159
|
-
writeFileSync(`${file}.tmp`, JSON.stringify({ serverId: id }));
|
|
160
|
-
renameSync(`${file}.tmp`, file);
|
|
175
|
+
// The merged write: sessions/servers already in the file survive, and the
|
|
176
|
+
// atomic tmp(0600)+rename dance is state-store's job now.
|
|
177
|
+
writeStateFile(file, (current) => ({ ...current, version: 1, serverId: id }));
|
|
161
178
|
}
|
|
162
179
|
catch {
|
|
163
180
|
// Unwritable home: the id stays process-local. Nothing may throw here.
|
|
@@ -520,6 +537,114 @@ function errorOf(error) {
|
|
|
520
537
|
out.details = details;
|
|
521
538
|
return out;
|
|
522
539
|
}
|
|
540
|
+
/**
|
|
541
|
+
* The bounded async queue behind one live duplex stream's uplink half (TA-1).
|
|
542
|
+
* The `/v1/stream-uplink` route pushes the client's frames in; the gateway's
|
|
543
|
+
* stream implementation (the host's own uplink consumer) pulls them out.
|
|
544
|
+
*
|
|
545
|
+
* Three states beyond "open", each with its own door:
|
|
546
|
+
* - `ended`: the client half-closed (`end: true`). Queued items still drain;
|
|
547
|
+
* the iteration finishes cleanly once the buffer is empty. A push AFTER the
|
|
548
|
+
* end is a client bug — it records `failure` and aborts the stream.
|
|
549
|
+
* - `failure`: a post-end push or a buffer overflow. The recorded error is
|
|
550
|
+
* thrown to the consumer, and the stream's abort fires (the route cannot
|
|
551
|
+
* push anymore, so the downstream is torn down with it).
|
|
552
|
+
* - `closed`: the stream is over (its own finally, or closeAll). Everything
|
|
553
|
+
* is dropped; further pushes are silently swallowed (`'ok'`) — the route's
|
|
554
|
+
* answer for a dead stream is the 404 unknown-stream, so the swallow only
|
|
555
|
+
* covers the race between the two.
|
|
556
|
+
*
|
|
557
|
+
* The waiter is woken on EVERY state change (item, end, close, failure) so a
|
|
558
|
+
* parked `next()` never sits on a stale promise: the iteration is the host's
|
|
559
|
+
* consumer, and a missed wake would hang a terminal that just died.
|
|
560
|
+
*/
|
|
561
|
+
class RelayUplinkQueue {
|
|
562
|
+
maxBytes;
|
|
563
|
+
onFail;
|
|
564
|
+
items = [];
|
|
565
|
+
bufferedBytes = 0;
|
|
566
|
+
ended = false;
|
|
567
|
+
closed = false;
|
|
568
|
+
failure;
|
|
569
|
+
waiter;
|
|
570
|
+
constructor(maxBytes, onFail) {
|
|
571
|
+
this.maxBytes = maxBytes;
|
|
572
|
+
this.onFail = onFail;
|
|
573
|
+
}
|
|
574
|
+
/** One frame in. `frameBytes` is the frame's serialized size — the same
|
|
575
|
+
* accounting the host's inbox charges, so the ceiling here means the same
|
|
576
|
+
* thing it means locally. */
|
|
577
|
+
push(value, frameBytes) {
|
|
578
|
+
if (this.closed || this.failure !== undefined)
|
|
579
|
+
return 'ok';
|
|
580
|
+
if (this.ended) {
|
|
581
|
+
this.failure = new Error('uplink frame arrived after end');
|
|
582
|
+
this.wake();
|
|
583
|
+
this.onFail();
|
|
584
|
+
return 'after-end';
|
|
585
|
+
}
|
|
586
|
+
if (this.bufferedBytes + frameBytes > this.maxBytes) {
|
|
587
|
+
this.failure = new Error('uplink buffer overflow');
|
|
588
|
+
this.wake();
|
|
589
|
+
this.onFail();
|
|
590
|
+
return 'overflow';
|
|
591
|
+
}
|
|
592
|
+
this.items.push({ value, bytes: frameBytes });
|
|
593
|
+
this.bufferedBytes += frameBytes;
|
|
594
|
+
this.wake();
|
|
595
|
+
return 'ok';
|
|
596
|
+
}
|
|
597
|
+
/** The client half-closed: queued items still drain, then the iteration
|
|
598
|
+
* ends. Idempotent. */
|
|
599
|
+
end() {
|
|
600
|
+
if (this.ended)
|
|
601
|
+
return;
|
|
602
|
+
this.ended = true;
|
|
603
|
+
this.wake();
|
|
604
|
+
}
|
|
605
|
+
/** The whole stream is over: drop everything, finish every parked reader,
|
|
606
|
+
* swallow all future pushes. Idempotent. */
|
|
607
|
+
close() {
|
|
608
|
+
if (this.closed)
|
|
609
|
+
return;
|
|
610
|
+
this.closed = true;
|
|
611
|
+
this.items.length = 0;
|
|
612
|
+
this.bufferedBytes = 0;
|
|
613
|
+
this.wake();
|
|
614
|
+
}
|
|
615
|
+
wake() {
|
|
616
|
+
const wake = this.waiter;
|
|
617
|
+
this.waiter = undefined;
|
|
618
|
+
wake?.();
|
|
619
|
+
}
|
|
620
|
+
[Symbol.asyncIterator]() {
|
|
621
|
+
return {
|
|
622
|
+
next: () => this.next(),
|
|
623
|
+
return: () => {
|
|
624
|
+
this.close();
|
|
625
|
+
return Promise.resolve({ value: undefined, done: true });
|
|
626
|
+
},
|
|
627
|
+
};
|
|
628
|
+
}
|
|
629
|
+
async next() {
|
|
630
|
+
while (true) {
|
|
631
|
+
if (this.closed)
|
|
632
|
+
return { value: undefined, done: true };
|
|
633
|
+
const entry = this.items.shift();
|
|
634
|
+
if (entry !== undefined) {
|
|
635
|
+
this.bufferedBytes -= entry.bytes;
|
|
636
|
+
return { value: entry.value, done: false };
|
|
637
|
+
}
|
|
638
|
+
if (this.failure !== undefined)
|
|
639
|
+
throw this.failure;
|
|
640
|
+
if (this.ended)
|
|
641
|
+
return { value: undefined, done: true };
|
|
642
|
+
await new Promise((resolve) => {
|
|
643
|
+
this.waiter = resolve;
|
|
644
|
+
});
|
|
645
|
+
}
|
|
646
|
+
}
|
|
647
|
+
}
|
|
523
648
|
/**
|
|
524
649
|
* Build the relay route handler mounted under {@link RELAY_PREFIX}. Exported
|
|
525
650
|
* as a factory so the route tests can drive it against a plain node:http
|
|
@@ -592,6 +717,7 @@ export function createRelayHandler(options) {
|
|
|
592
717
|
* route searches these for every incoming eventId. A closed
|
|
593
718
|
* subscription removes itself; closeAll empties the set. */
|
|
594
719
|
const liveEventSubscriptions = new Set();
|
|
720
|
+
const liveUplinkStreams = new Map();
|
|
595
721
|
/** Latest `job/list` rows seen per session — the recent-state half of the
|
|
596
722
|
* job ownership check (the other half is a one-shot list on a cache miss).
|
|
597
723
|
* An unshared session's rows are forgotten (T22b-fix): its jobs are none of
|
|
@@ -1991,6 +2117,68 @@ export function createRelayHandler(options) {
|
|
|
1991
2117
|
}
|
|
1992
2118
|
return;
|
|
1993
2119
|
}
|
|
2120
|
+
if (req.method === 'POST' && pathname === `${RELAY_PREFIX}/v1/stream-uplink`) {
|
|
2121
|
+
// The duplex uplink's post route (TA-1): one JSON body of frames for a
|
|
2122
|
+
// stream opened with `uplink: true`, resolved against the live-uplink
|
|
2123
|
+
// table. Four walls in order, each with its own status: the shape
|
|
2124
|
+
// (400), the stream id and its owning device (404 — unknown-stream,
|
|
2125
|
+
// deliberately indistinguishable between "no such stream" and "not
|
|
2126
|
+
// yours", so a foreign device learns nothing), the share table at post
|
|
2127
|
+
// time (403 — an unshared session's keystrokes must die with the
|
|
2128
|
+
// share), and the sequence counter (409 — replays and reordered posts
|
|
2129
|
+
// would corrupt the host's own input sequencing).
|
|
2130
|
+
const body = await readBodyOrRespond(req, res);
|
|
2131
|
+
if (body === undefined)
|
|
2132
|
+
return;
|
|
2133
|
+
const streamId = body.streamId;
|
|
2134
|
+
const seq = body.seq;
|
|
2135
|
+
const items = body.items;
|
|
2136
|
+
const end = body.end;
|
|
2137
|
+
if (typeof streamId !== 'string' ||
|
|
2138
|
+
streamId === '' ||
|
|
2139
|
+
typeof seq !== 'number' ||
|
|
2140
|
+
!Number.isSafeInteger(seq) ||
|
|
2141
|
+
seq < 1 ||
|
|
2142
|
+
(items !== undefined && !Array.isArray(items)) ||
|
|
2143
|
+
(end !== undefined && typeof end !== 'boolean') ||
|
|
2144
|
+
!((Array.isArray(items) && items.length > 0) || end === true)) {
|
|
2145
|
+
responseJson(res, 400, { ok: false, error: { code: 'bad-request' } });
|
|
2146
|
+
return;
|
|
2147
|
+
}
|
|
2148
|
+
const device = headerValue(req, 'x-zen-remote-device') ?? '';
|
|
2149
|
+
const entry = liveUplinkStreams.get(streamId);
|
|
2150
|
+
if (entry === undefined || entry.device !== device) {
|
|
2151
|
+
responseJson(res, 404, { ok: false, error: { code: 'unknown-stream' } });
|
|
2152
|
+
return;
|
|
2153
|
+
}
|
|
2154
|
+
if (!entry.sessionIds.every((id) => isAccessible(id))) {
|
|
2155
|
+
responseJson(res, 403, { ok: false, error: { code: 'not-shared' } });
|
|
2156
|
+
return;
|
|
2157
|
+
}
|
|
2158
|
+
if (seq !== entry.nextSeq) {
|
|
2159
|
+
responseJson(res, 409, { ok: false, error: { code: 'out-of-order' } });
|
|
2160
|
+
return;
|
|
2161
|
+
}
|
|
2162
|
+
entry.nextSeq += 1;
|
|
2163
|
+
if (Array.isArray(items)) {
|
|
2164
|
+
for (const item of items) {
|
|
2165
|
+
const itemBytes = Buffer.byteLength(JSON.stringify(item) ?? '', 'utf8');
|
|
2166
|
+
const status = entry.queue.push(item, itemBytes);
|
|
2167
|
+
if (status === 'after-end') {
|
|
2168
|
+
responseJson(res, 400, { ok: false, error: { code: 'uplink-after-end' } });
|
|
2169
|
+
return;
|
|
2170
|
+
}
|
|
2171
|
+
if (status === 'overflow') {
|
|
2172
|
+
responseJson(res, 413, { ok: false, error: { code: 'uplink-overflow' } });
|
|
2173
|
+
return;
|
|
2174
|
+
}
|
|
2175
|
+
}
|
|
2176
|
+
}
|
|
2177
|
+
if (end === true)
|
|
2178
|
+
entry.queue.end();
|
|
2179
|
+
responseJson(res, 200, { ok: true });
|
|
2180
|
+
return;
|
|
2181
|
+
}
|
|
1994
2182
|
if (req.method === 'POST' && pathname === `${RELAY_PREFIX}/v1/stream`) {
|
|
1995
2183
|
// Body validation identical to invoke: one JSON object naming the
|
|
1996
2184
|
// method and carrying object args.
|
|
@@ -2017,6 +2205,23 @@ export function createRelayHandler(options) {
|
|
|
2017
2205
|
responseJson(res, 403, { ok: false, error: { code: decision.reason } });
|
|
2018
2206
|
return;
|
|
2019
2207
|
}
|
|
2208
|
+
// TA-1: the client's advertised uplink window, shape-checked ONCE here —
|
|
2209
|
+
// only a well-formed window travels to the gateway; garbage (a zero or
|
|
2210
|
+
// negative budget, a fractional value) is dropped, never refused: the
|
|
2211
|
+
// numbers are the host's own contract with itself, and a missing one
|
|
2212
|
+
// just means the host default applies.
|
|
2213
|
+
let uplinkWindow;
|
|
2214
|
+
if (isPlainObject(body.uplinkWindow)) {
|
|
2215
|
+
const candidate = body.uplinkWindow;
|
|
2216
|
+
if (typeof candidate.maxBytes === 'number' &&
|
|
2217
|
+
Number.isSafeInteger(candidate.maxBytes) &&
|
|
2218
|
+
candidate.maxBytes > 0 &&
|
|
2219
|
+
typeof candidate.frameOverheadBytes === 'number' &&
|
|
2220
|
+
Number.isSafeInteger(candidate.frameOverheadBytes) &&
|
|
2221
|
+
candidate.frameOverheadBytes >= 0) {
|
|
2222
|
+
uplinkWindow = { maxBytes: candidate.maxBytes, frameOverheadBytes: candidate.frameOverheadBytes };
|
|
2223
|
+
}
|
|
2224
|
+
}
|
|
2020
2225
|
const device = headerValue(req, 'x-zen-remote-device') ?? '';
|
|
2021
2226
|
if ((streamsByDevice.get(device) ?? 0) >= MAX_STREAMS_PER_DEVICE) {
|
|
2022
2227
|
responseJson(res, 429, { ok: false, error: { code: 'too-many-streams' } });
|
|
@@ -2178,6 +2383,30 @@ export function createRelayHandler(options) {
|
|
|
2178
2383
|
res.end();
|
|
2179
2384
|
});
|
|
2180
2385
|
};
|
|
2386
|
+
// ---- TA-1: the duplex uplink -------------------------------------
|
|
2387
|
+
// A duplex decision the client took up: build the bounded queue the
|
|
2388
|
+
// gateway will read keystrokes from, register it for the
|
|
2389
|
+
// /v1/stream-uplink route, and send the handshake line FIRST —
|
|
2390
|
+
// before the upstream is even open — so the client may start posting
|
|
2391
|
+
// the moment it sees the id. The write chain serializes the line
|
|
2392
|
+
// ahead of any frame; registration precedes the open so a post that
|
|
2393
|
+
// races the handshake still finds the entry (its seq order and the
|
|
2394
|
+
// share table keep it honest either way).
|
|
2395
|
+
let uplinkStreamId;
|
|
2396
|
+
let uplinkQueue;
|
|
2397
|
+
if (decision.uplink === true && body.uplink === true) {
|
|
2398
|
+
uplinkStreamId = randomBytes(16).toString('hex');
|
|
2399
|
+
uplinkQueue = new RelayUplinkQueue(MAX_STREAM_UPLINK_BYTES, () => hangUp.abort());
|
|
2400
|
+
liveUplinkStreams.set(uplinkStreamId, {
|
|
2401
|
+
streamId: uplinkStreamId,
|
|
2402
|
+
device,
|
|
2403
|
+
sessionIds: decision.sessionIds,
|
|
2404
|
+
nextSeq: 1,
|
|
2405
|
+
queue: uplinkQueue,
|
|
2406
|
+
abort: () => hangUp.abort(),
|
|
2407
|
+
});
|
|
2408
|
+
void writeLine({ type: 'uplink', streamId: uplinkStreamId });
|
|
2409
|
+
}
|
|
2181
2410
|
// ---- share-change synchronization (T22b §4) --------------------
|
|
2182
2411
|
// The `$zr/events` forwarding bookkeeping (T32): registered for the
|
|
2183
2412
|
// answer route the moment this stream starts being served, removed
|
|
@@ -2342,10 +2571,19 @@ export function createRelayHandler(options) {
|
|
|
2342
2571
|
else {
|
|
2343
2572
|
// `$zr/events` opens through the wire adapter (the ONLY door to
|
|
2344
2573
|
// the forwarded-event stream); every other route is a typert
|
|
2345
|
-
// stream method.
|
|
2574
|
+
// stream method. TA-1: a duplex stream rides with its uplink
|
|
2575
|
+
// queue (the field's PRESENCE is the duplex flag the gateway
|
|
2576
|
+
// reads) and the shape-checked window, both optional.
|
|
2346
2577
|
const iterable = eventsSub !== undefined
|
|
2347
2578
|
? await openEventsStream(hangUp.signal)
|
|
2348
|
-
: await gateway.stream(withPeer({
|
|
2579
|
+
: await gateway.stream(withPeer({
|
|
2580
|
+
namespace,
|
|
2581
|
+
method,
|
|
2582
|
+
args,
|
|
2583
|
+
signal: hangUp.signal,
|
|
2584
|
+
...(uplinkQueue !== undefined ? { uplink: uplinkQueue } : {}),
|
|
2585
|
+
...(uplinkWindow !== undefined ? { uplinkWindow } : {}),
|
|
2586
|
+
}, options.getPeer));
|
|
2349
2587
|
for await (const frame of iterable) {
|
|
2350
2588
|
if (finished || clientGone)
|
|
2351
2589
|
break;
|
|
@@ -2376,6 +2614,13 @@ export function createRelayHandler(options) {
|
|
|
2376
2614
|
clearInterval(heartbeat);
|
|
2377
2615
|
unsubscribe();
|
|
2378
2616
|
tableUnsubscribers.delete(unsubscribe);
|
|
2617
|
+
// TA-1: the uplink half dies with the stream — deregistered BEFORE
|
|
2618
|
+
// the queue closes, so a post racing the teardown answers
|
|
2619
|
+
// unknown-stream instead of feeding a dead queue, and the gateway's
|
|
2620
|
+
// consumer is finished instead of parked forever.
|
|
2621
|
+
if (uplinkStreamId !== undefined)
|
|
2622
|
+
liveUplinkStreams.delete(uplinkStreamId);
|
|
2623
|
+
uplinkQueue?.close();
|
|
2379
2624
|
if (eventsSub !== undefined) {
|
|
2380
2625
|
liveEventSubscriptions.delete(eventsSub);
|
|
2381
2626
|
eventsSub.events.clear();
|
|
@@ -2423,6 +2668,13 @@ export function createRelayHandler(options) {
|
|
|
2423
2668
|
sub.events.clear();
|
|
2424
2669
|
}
|
|
2425
2670
|
liveEventSubscriptions.clear();
|
|
2671
|
+
// TA-1: the duplex uplinks die with the handler — close() finishes every
|
|
2672
|
+
// parked gateway consumer (a terminal read must not hang on a reload)
|
|
2673
|
+
// and later posts answer unknown-stream; the streams' own finallys then
|
|
2674
|
+
// find their entries already gone and delete nothing.
|
|
2675
|
+
for (const entry of liveUplinkStreams.values())
|
|
2676
|
+
entry.queue.close();
|
|
2677
|
+
liveUplinkStreams.clear();
|
|
2426
2678
|
// Remember the reason for requests still inside their ownership probe —
|
|
2427
2679
|
// they read it at the pre-open gate. Killing the open streams runs their
|
|
2428
2680
|
// normal teardown (error line, res.end, abort), whose finally releases
|