dsh-zen-remote 2.3.11 → 2.4.0
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 +39 -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 +564 -363
- 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-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-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 +566 -367
- 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-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/src/relay-server.ts
CHANGED
|
@@ -30,11 +30,11 @@ import { withPeer } from './owner-peer.js'
|
|
|
30
30
|
import { controlProjectionFrame } from './merge-streams.js'
|
|
31
31
|
import { randomBytes, timingSafeEqual } from 'node:crypto'
|
|
32
32
|
import { Readable, Transform } from 'node:stream'
|
|
33
|
-
import { mkdirSync, readFileSync, renameSync, writeFileSync } from 'node:fs'
|
|
34
33
|
import { createRequire } from 'node:module'
|
|
35
34
|
import { join } from 'node:path'
|
|
36
35
|
import type { IncomingMessage, ServerResponse } from 'node:http'
|
|
37
36
|
import type { ShareStore } from './share-store.js'
|
|
37
|
+
import { ensureServerIdMigrated, readStateFile, STATE_FILE_NAME, writeStateFile } from './state-store.js'
|
|
38
38
|
import { decideHttpRoute, decideInvoke, decideStream, decideUploadQuery, parseEventResultBody } from './relay-access.js'
|
|
39
39
|
import {
|
|
40
40
|
createWorkspaceFollowState,
|
|
@@ -79,6 +79,26 @@ export { MAX_UPLOAD_BYTES }
|
|
|
79
79
|
* host's own registration (RT dsh-client-file-upload lib/index.js:73). */
|
|
80
80
|
export const UPLOAD_HTTP_PATH = '/api/session/uploadFileBinary'
|
|
81
81
|
|
|
82
|
+
/**
|
|
83
|
+
* The duplex uplink's buffering ceiling (TA-1): how many bytes of
|
|
84
|
+
* not-yet-consumed uplink frames one live stream may hold — the same budget
|
|
85
|
+
* the host's own stream inbox applies (`DEFAULT_STREAM_INBOX_BYTES`), so a
|
|
86
|
+
* stalled terminal consumer backs up at exactly the point the local session
|
|
87
|
+
* would have. Past it the `/v1/stream-uplink` route answers 413
|
|
88
|
+
* `uplink-overflow` and the stream is aborted.
|
|
89
|
+
*/
|
|
90
|
+
export const MAX_STREAM_UPLINK_BYTES = 262_144
|
|
91
|
+
|
|
92
|
+
/** The per-stream uplink window the client may advertise (TA-1): how many
|
|
93
|
+
* bytes the host's uplink inbox buffers before it stops draining, and the
|
|
94
|
+
* per-frame framing overhead it charges against that budget. The relay
|
|
95
|
+
* forwards it verbatim — only the shape is validated, the numbers are the
|
|
96
|
+
* host's own contract with itself. */
|
|
97
|
+
export interface RelayUplinkWindow {
|
|
98
|
+
maxBytes: number
|
|
99
|
+
frameOverheadBytes: number
|
|
100
|
+
}
|
|
101
|
+
|
|
82
102
|
/** Error messages forwarded to the client are clipped to this many chars. */
|
|
83
103
|
const MAX_MESSAGE_CHARS = 500
|
|
84
104
|
|
|
@@ -187,6 +207,12 @@ export interface RelayGateway {
|
|
|
187
207
|
args: unknown
|
|
188
208
|
signal?: AbortSignal
|
|
189
209
|
peer?: unknown
|
|
210
|
+
/** TA-1: the duplex uplink queue (a duplex method like `terminal/attach`
|
|
211
|
+
* reads keystrokes/resize frames from it). Absent on one-direction
|
|
212
|
+
* streams — the field's presence IS the duplex flag. */
|
|
213
|
+
uplink?: AsyncIterable<unknown>
|
|
214
|
+
/** TA-1: the client's advertised uplink window, forwarded verbatim. */
|
|
215
|
+
uplinkWindow?: RelayUplinkWindow
|
|
190
216
|
}): Promise<AsyncIterable<unknown>>
|
|
191
217
|
/** The host's wire adapter, shared by the WebSocket mux and the local
|
|
192
218
|
* transports. `open` is the async 0.2.0 method. */
|
|
@@ -316,30 +342,38 @@ export interface RelayHandler {
|
|
|
316
342
|
* countable about the deployment. */
|
|
317
343
|
const SERVER_ID_PATTERN = /^[0-9a-f]{8}$/
|
|
318
344
|
|
|
319
|
-
|
|
345
|
+
/**
|
|
346
|
+
* The state file the server identity lives in since MS-1 — the same file the
|
|
347
|
+
* share table (`sessions`) and the paired-servers list (`servers`) use, so
|
|
348
|
+
* every writer of one field must preserve the other two. `loadServerId`
|
|
349
|
+
* reads and writes it only through state-store's merged doors.
|
|
350
|
+
*/
|
|
351
|
+
const SERVER_ID_FILE = STATE_FILE_NAME
|
|
320
352
|
|
|
321
353
|
/**
|
|
322
354
|
* Read the persisted server id, creating (and persisting) one on first use.
|
|
323
|
-
* A
|
|
324
|
-
*
|
|
325
|
-
*
|
|
355
|
+
* A pre-multi-server `zen-remote-server.json` identity migrates in first
|
|
356
|
+
* (and its file is deleted) — an upgraded host keeps its id, so paired
|
|
357
|
+
* devices never see a stranger. A missing file is the normal first run; an
|
|
358
|
+
* unreadable or damaged file must never take the host down, so every failure
|
|
359
|
+
* degrades to a fresh per-process id that simply is not remembered across
|
|
360
|
+
* restarts.
|
|
326
361
|
*/
|
|
327
362
|
export function loadServerId(home: string): string {
|
|
328
363
|
const file = join(home, SERVER_ID_FILE)
|
|
364
|
+
ensureServerIdMigrated(file)
|
|
329
365
|
try {
|
|
330
|
-
const
|
|
331
|
-
|
|
332
|
-
if (typeof id === 'string' && SERVER_ID_PATTERN.test(id)) return id
|
|
366
|
+
const state = readStateFile(file)
|
|
367
|
+
if (state.serverId !== undefined && SERVER_ID_PATTERN.test(state.serverId)) return state.serverId
|
|
333
368
|
} catch {
|
|
334
|
-
//
|
|
369
|
+
// Damaged state: mint a new one below (state-store never throws, but the
|
|
370
|
+
// belt stays on).
|
|
335
371
|
}
|
|
336
372
|
const id = randomBytes(4).toString('hex')
|
|
337
373
|
try {
|
|
338
|
-
//
|
|
339
|
-
//
|
|
340
|
-
|
|
341
|
-
writeFileSync(`${file}.tmp`, JSON.stringify({ serverId: id }))
|
|
342
|
-
renameSync(`${file}.tmp`, file)
|
|
374
|
+
// The merged write: sessions/servers already in the file survive, and the
|
|
375
|
+
// atomic tmp(0600)+rename dance is state-store's job now.
|
|
376
|
+
writeStateFile(file, (current) => ({ ...current, version: 1, serverId: id }))
|
|
343
377
|
} catch {
|
|
344
378
|
// Unwritable home: the id stays process-local. Nothing may throw here.
|
|
345
379
|
}
|
|
@@ -782,6 +816,114 @@ function errorOf(error: unknown): { code: string; message?: string; details?: Re
|
|
|
782
816
|
return out
|
|
783
817
|
}
|
|
784
818
|
|
|
819
|
+
/**
|
|
820
|
+
* The bounded async queue behind one live duplex stream's uplink half (TA-1).
|
|
821
|
+
* The `/v1/stream-uplink` route pushes the client's frames in; the gateway's
|
|
822
|
+
* stream implementation (the host's own uplink consumer) pulls them out.
|
|
823
|
+
*
|
|
824
|
+
* Three states beyond "open", each with its own door:
|
|
825
|
+
* - `ended`: the client half-closed (`end: true`). Queued items still drain;
|
|
826
|
+
* the iteration finishes cleanly once the buffer is empty. A push AFTER the
|
|
827
|
+
* end is a client bug — it records `failure` and aborts the stream.
|
|
828
|
+
* - `failure`: a post-end push or a buffer overflow. The recorded error is
|
|
829
|
+
* thrown to the consumer, and the stream's abort fires (the route cannot
|
|
830
|
+
* push anymore, so the downstream is torn down with it).
|
|
831
|
+
* - `closed`: the stream is over (its own finally, or closeAll). Everything
|
|
832
|
+
* is dropped; further pushes are silently swallowed (`'ok'`) — the route's
|
|
833
|
+
* answer for a dead stream is the 404 unknown-stream, so the swallow only
|
|
834
|
+
* covers the race between the two.
|
|
835
|
+
*
|
|
836
|
+
* The waiter is woken on EVERY state change (item, end, close, failure) so a
|
|
837
|
+
* parked `next()` never sits on a stale promise: the iteration is the host's
|
|
838
|
+
* consumer, and a missed wake would hang a terminal that just died.
|
|
839
|
+
*/
|
|
840
|
+
class RelayUplinkQueue {
|
|
841
|
+
private readonly items: Array<{ value: unknown; bytes: number }> = []
|
|
842
|
+
private bufferedBytes = 0
|
|
843
|
+
private ended = false
|
|
844
|
+
private closed = false
|
|
845
|
+
private failure: Error | undefined
|
|
846
|
+
private waiter: (() => void) | undefined
|
|
847
|
+
|
|
848
|
+
constructor(
|
|
849
|
+
private readonly maxBytes: number,
|
|
850
|
+
private readonly onFail: () => void,
|
|
851
|
+
) {}
|
|
852
|
+
|
|
853
|
+
/** One frame in. `frameBytes` is the frame's serialized size — the same
|
|
854
|
+
* accounting the host's inbox charges, so the ceiling here means the same
|
|
855
|
+
* thing it means locally. */
|
|
856
|
+
push(value: unknown, frameBytes: number): 'ok' | 'after-end' | 'overflow' {
|
|
857
|
+
if (this.closed || this.failure !== undefined) return 'ok'
|
|
858
|
+
if (this.ended) {
|
|
859
|
+
this.failure = new Error('uplink frame arrived after end')
|
|
860
|
+
this.wake()
|
|
861
|
+
this.onFail()
|
|
862
|
+
return 'after-end'
|
|
863
|
+
}
|
|
864
|
+
if (this.bufferedBytes + frameBytes > this.maxBytes) {
|
|
865
|
+
this.failure = new Error('uplink buffer overflow')
|
|
866
|
+
this.wake()
|
|
867
|
+
this.onFail()
|
|
868
|
+
return 'overflow'
|
|
869
|
+
}
|
|
870
|
+
this.items.push({ value, bytes: frameBytes })
|
|
871
|
+
this.bufferedBytes += frameBytes
|
|
872
|
+
this.wake()
|
|
873
|
+
return 'ok'
|
|
874
|
+
}
|
|
875
|
+
|
|
876
|
+
/** The client half-closed: queued items still drain, then the iteration
|
|
877
|
+
* ends. Idempotent. */
|
|
878
|
+
end(): void {
|
|
879
|
+
if (this.ended) return
|
|
880
|
+
this.ended = true
|
|
881
|
+
this.wake()
|
|
882
|
+
}
|
|
883
|
+
|
|
884
|
+
/** The whole stream is over: drop everything, finish every parked reader,
|
|
885
|
+
* swallow all future pushes. Idempotent. */
|
|
886
|
+
close(): void {
|
|
887
|
+
if (this.closed) return
|
|
888
|
+
this.closed = true
|
|
889
|
+
this.items.length = 0
|
|
890
|
+
this.bufferedBytes = 0
|
|
891
|
+
this.wake()
|
|
892
|
+
}
|
|
893
|
+
|
|
894
|
+
private wake(): void {
|
|
895
|
+
const wake = this.waiter
|
|
896
|
+
this.waiter = undefined
|
|
897
|
+
wake?.()
|
|
898
|
+
}
|
|
899
|
+
|
|
900
|
+
[Symbol.asyncIterator](): AsyncIterator<unknown> {
|
|
901
|
+
return {
|
|
902
|
+
next: () => this.next(),
|
|
903
|
+
return: () => {
|
|
904
|
+
this.close()
|
|
905
|
+
return Promise.resolve({ value: undefined, done: true })
|
|
906
|
+
},
|
|
907
|
+
}
|
|
908
|
+
}
|
|
909
|
+
|
|
910
|
+
private async next(): Promise<IteratorResult<unknown>> {
|
|
911
|
+
while (true) {
|
|
912
|
+
if (this.closed) return { value: undefined, done: true }
|
|
913
|
+
const entry = this.items.shift()
|
|
914
|
+
if (entry !== undefined) {
|
|
915
|
+
this.bufferedBytes -= entry.bytes
|
|
916
|
+
return { value: entry.value, done: false }
|
|
917
|
+
}
|
|
918
|
+
if (this.failure !== undefined) throw this.failure
|
|
919
|
+
if (this.ended) return { value: undefined, done: true }
|
|
920
|
+
await new Promise<void>((resolve) => {
|
|
921
|
+
this.waiter = resolve
|
|
922
|
+
})
|
|
923
|
+
}
|
|
924
|
+
}
|
|
925
|
+
}
|
|
926
|
+
|
|
785
927
|
/**
|
|
786
928
|
* One live `$zr/events` subscription's forwarding bookkeeping (T32). The
|
|
787
929
|
* answer route (`relay/v1/event-result`) proves an `eventId` was forwarded
|
|
@@ -896,6 +1038,27 @@ export function createRelayHandler(options: RelayHandlerOptions): RelayHandler {
|
|
|
896
1038
|
* subscription removes itself; closeAll empties the set. */
|
|
897
1039
|
const liveEventSubscriptions = new Set<EventsSubscription>()
|
|
898
1040
|
|
|
1041
|
+
/**
|
|
1042
|
+
* The live duplex streams' uplink halves (TA-1), keyed by the `streamId`
|
|
1043
|
+
* the handshake line handed the client. The `/v1/stream-uplink` route
|
|
1044
|
+
* resolves every post through THIS table, which is the whole ownership
|
|
1045
|
+
* story: the id is a 16-byte random value only the opening device ever
|
|
1046
|
+
* learned, the device check binds it to that one pairing, the session
|
|
1047
|
+
* list is re-judged against the share table at post time, and the seq
|
|
1048
|
+
* counter keeps replays and reordered posts out. A stream's own finally
|
|
1049
|
+
* removes its entry; closeAll closes every queue first so no consumer
|
|
1050
|
+
* parks on a dead stream.
|
|
1051
|
+
*/
|
|
1052
|
+
interface ActiveUplinkStream {
|
|
1053
|
+
streamId: string
|
|
1054
|
+
device: string
|
|
1055
|
+
sessionIds: readonly string[]
|
|
1056
|
+
nextSeq: number
|
|
1057
|
+
queue: RelayUplinkQueue
|
|
1058
|
+
abort: () => void
|
|
1059
|
+
}
|
|
1060
|
+
const liveUplinkStreams = new Map<string, ActiveUplinkStream>()
|
|
1061
|
+
|
|
899
1062
|
/** Latest `job/list` rows seen per session — the recent-state half of the
|
|
900
1063
|
* job ownership check (the other half is a one-shot list on a cache miss).
|
|
901
1064
|
* An unshared session's rows are forgotten (T22b-fix): its jobs are none of
|
|
@@ -2287,6 +2450,69 @@ export function createRelayHandler(options: RelayHandlerOptions): RelayHandler {
|
|
|
2287
2450
|
return
|
|
2288
2451
|
}
|
|
2289
2452
|
|
|
2453
|
+
if (req.method === 'POST' && pathname === `${RELAY_PREFIX}/v1/stream-uplink`) {
|
|
2454
|
+
// The duplex uplink's post route (TA-1): one JSON body of frames for a
|
|
2455
|
+
// stream opened with `uplink: true`, resolved against the live-uplink
|
|
2456
|
+
// table. Four walls in order, each with its own status: the shape
|
|
2457
|
+
// (400), the stream id and its owning device (404 — unknown-stream,
|
|
2458
|
+
// deliberately indistinguishable between "no such stream" and "not
|
|
2459
|
+
// yours", so a foreign device learns nothing), the share table at post
|
|
2460
|
+
// time (403 — an unshared session's keystrokes must die with the
|
|
2461
|
+
// share), and the sequence counter (409 — replays and reordered posts
|
|
2462
|
+
// would corrupt the host's own input sequencing).
|
|
2463
|
+
const body = await readBodyOrRespond(req, res)
|
|
2464
|
+
if (body === undefined) return
|
|
2465
|
+
const streamId = body.streamId
|
|
2466
|
+
const seq = body.seq
|
|
2467
|
+
const items = body.items
|
|
2468
|
+
const end = body.end
|
|
2469
|
+
if (
|
|
2470
|
+
typeof streamId !== 'string' ||
|
|
2471
|
+
streamId === '' ||
|
|
2472
|
+
typeof seq !== 'number' ||
|
|
2473
|
+
!Number.isSafeInteger(seq) ||
|
|
2474
|
+
seq < 1 ||
|
|
2475
|
+
(items !== undefined && !Array.isArray(items)) ||
|
|
2476
|
+
(end !== undefined && typeof end !== 'boolean') ||
|
|
2477
|
+
!((Array.isArray(items) && items.length > 0) || end === true)
|
|
2478
|
+
) {
|
|
2479
|
+
responseJson(res, 400, { ok: false, error: { code: 'bad-request' } })
|
|
2480
|
+
return
|
|
2481
|
+
}
|
|
2482
|
+
const device = headerValue(req, 'x-zen-remote-device') ?? ''
|
|
2483
|
+
const entry = liveUplinkStreams.get(streamId)
|
|
2484
|
+
if (entry === undefined || entry.device !== device) {
|
|
2485
|
+
responseJson(res, 404, { ok: false, error: { code: 'unknown-stream' } })
|
|
2486
|
+
return
|
|
2487
|
+
}
|
|
2488
|
+
if (!entry.sessionIds.every((id) => isAccessible(id))) {
|
|
2489
|
+
responseJson(res, 403, { ok: false, error: { code: 'not-shared' } })
|
|
2490
|
+
return
|
|
2491
|
+
}
|
|
2492
|
+
if (seq !== entry.nextSeq) {
|
|
2493
|
+
responseJson(res, 409, { ok: false, error: { code: 'out-of-order' } })
|
|
2494
|
+
return
|
|
2495
|
+
}
|
|
2496
|
+
entry.nextSeq += 1
|
|
2497
|
+
if (Array.isArray(items)) {
|
|
2498
|
+
for (const item of items) {
|
|
2499
|
+
const itemBytes = Buffer.byteLength(JSON.stringify(item) ?? '', 'utf8')
|
|
2500
|
+
const status = entry.queue.push(item, itemBytes)
|
|
2501
|
+
if (status === 'after-end') {
|
|
2502
|
+
responseJson(res, 400, { ok: false, error: { code: 'uplink-after-end' } })
|
|
2503
|
+
return
|
|
2504
|
+
}
|
|
2505
|
+
if (status === 'overflow') {
|
|
2506
|
+
responseJson(res, 413, { ok: false, error: { code: 'uplink-overflow' } })
|
|
2507
|
+
return
|
|
2508
|
+
}
|
|
2509
|
+
}
|
|
2510
|
+
}
|
|
2511
|
+
if (end === true) entry.queue.end()
|
|
2512
|
+
responseJson(res, 200, { ok: true })
|
|
2513
|
+
return
|
|
2514
|
+
}
|
|
2515
|
+
|
|
2290
2516
|
if (req.method === 'POST' && pathname === `${RELAY_PREFIX}/v1/stream`) {
|
|
2291
2517
|
// Body validation identical to invoke: one JSON object naming the
|
|
2292
2518
|
// method and carrying object args.
|
|
@@ -2314,6 +2540,25 @@ export function createRelayHandler(options: RelayHandlerOptions): RelayHandler {
|
|
|
2314
2540
|
responseJson(res, 403, { ok: false, error: { code: decision.reason } })
|
|
2315
2541
|
return
|
|
2316
2542
|
}
|
|
2543
|
+
// TA-1: the client's advertised uplink window, shape-checked ONCE here —
|
|
2544
|
+
// only a well-formed window travels to the gateway; garbage (a zero or
|
|
2545
|
+
// negative budget, a fractional value) is dropped, never refused: the
|
|
2546
|
+
// numbers are the host's own contract with itself, and a missing one
|
|
2547
|
+
// just means the host default applies.
|
|
2548
|
+
let uplinkWindow: RelayUplinkWindow | undefined
|
|
2549
|
+
if (isPlainObject(body.uplinkWindow)) {
|
|
2550
|
+
const candidate = body.uplinkWindow
|
|
2551
|
+
if (
|
|
2552
|
+
typeof candidate.maxBytes === 'number' &&
|
|
2553
|
+
Number.isSafeInteger(candidate.maxBytes) &&
|
|
2554
|
+
candidate.maxBytes > 0 &&
|
|
2555
|
+
typeof candidate.frameOverheadBytes === 'number' &&
|
|
2556
|
+
Number.isSafeInteger(candidate.frameOverheadBytes) &&
|
|
2557
|
+
candidate.frameOverheadBytes >= 0
|
|
2558
|
+
) {
|
|
2559
|
+
uplinkWindow = { maxBytes: candidate.maxBytes, frameOverheadBytes: candidate.frameOverheadBytes }
|
|
2560
|
+
}
|
|
2561
|
+
}
|
|
2317
2562
|
const device = headerValue(req, 'x-zen-remote-device') ?? ''
|
|
2318
2563
|
if ((streamsByDevice.get(device) ?? 0) >= MAX_STREAMS_PER_DEVICE) {
|
|
2319
2564
|
responseJson(res, 429, { ok: false, error: { code: 'too-many-streams' } })
|
|
@@ -2479,6 +2724,31 @@ export function createRelayHandler(options: RelayHandlerOptions): RelayHandler {
|
|
|
2479
2724
|
})
|
|
2480
2725
|
}
|
|
2481
2726
|
|
|
2727
|
+
// ---- TA-1: the duplex uplink -------------------------------------
|
|
2728
|
+
// A duplex decision the client took up: build the bounded queue the
|
|
2729
|
+
// gateway will read keystrokes from, register it for the
|
|
2730
|
+
// /v1/stream-uplink route, and send the handshake line FIRST —
|
|
2731
|
+
// before the upstream is even open — so the client may start posting
|
|
2732
|
+
// the moment it sees the id. The write chain serializes the line
|
|
2733
|
+
// ahead of any frame; registration precedes the open so a post that
|
|
2734
|
+
// races the handshake still finds the entry (its seq order and the
|
|
2735
|
+
// share table keep it honest either way).
|
|
2736
|
+
let uplinkStreamId: string | undefined
|
|
2737
|
+
let uplinkQueue: RelayUplinkQueue | undefined
|
|
2738
|
+
if (decision.uplink === true && body.uplink === true) {
|
|
2739
|
+
uplinkStreamId = randomBytes(16).toString('hex')
|
|
2740
|
+
uplinkQueue = new RelayUplinkQueue(MAX_STREAM_UPLINK_BYTES, () => hangUp.abort())
|
|
2741
|
+
liveUplinkStreams.set(uplinkStreamId, {
|
|
2742
|
+
streamId: uplinkStreamId,
|
|
2743
|
+
device,
|
|
2744
|
+
sessionIds: decision.sessionIds,
|
|
2745
|
+
nextSeq: 1,
|
|
2746
|
+
queue: uplinkQueue,
|
|
2747
|
+
abort: () => hangUp.abort(),
|
|
2748
|
+
})
|
|
2749
|
+
void writeLine({ type: 'uplink', streamId: uplinkStreamId })
|
|
2750
|
+
}
|
|
2751
|
+
|
|
2482
2752
|
// ---- share-change synchronization (T22b §4) --------------------
|
|
2483
2753
|
|
|
2484
2754
|
// The `$zr/events` forwarding bookkeeping (T32): registered for the
|
|
@@ -2638,11 +2908,25 @@ export function createRelayHandler(options: RelayHandlerOptions): RelayHandler {
|
|
|
2638
2908
|
} else {
|
|
2639
2909
|
// `$zr/events` opens through the wire adapter (the ONLY door to
|
|
2640
2910
|
// the forwarded-event stream); every other route is a typert
|
|
2641
|
-
// stream method.
|
|
2911
|
+
// stream method. TA-1: a duplex stream rides with its uplink
|
|
2912
|
+
// queue (the field's PRESENCE is the duplex flag the gateway
|
|
2913
|
+
// reads) and the shape-checked window, both optional.
|
|
2642
2914
|
const iterable =
|
|
2643
2915
|
eventsSub !== undefined
|
|
2644
2916
|
? await openEventsStream(hangUp.signal)
|
|
2645
|
-
: await gateway.stream(
|
|
2917
|
+
: await gateway.stream(
|
|
2918
|
+
withPeer(
|
|
2919
|
+
{
|
|
2920
|
+
namespace,
|
|
2921
|
+
method,
|
|
2922
|
+
args,
|
|
2923
|
+
signal: hangUp.signal,
|
|
2924
|
+
...(uplinkQueue !== undefined ? { uplink: uplinkQueue } : {}),
|
|
2925
|
+
...(uplinkWindow !== undefined ? { uplinkWindow } : {}),
|
|
2926
|
+
},
|
|
2927
|
+
options.getPeer,
|
|
2928
|
+
),
|
|
2929
|
+
)
|
|
2646
2930
|
for await (const frame of iterable) {
|
|
2647
2931
|
if (finished || clientGone) break
|
|
2648
2932
|
if (namespace === 'job' && method === 'list') rememberJobs(ownerSessionIdOf(args), frame)
|
|
@@ -2667,6 +2951,12 @@ export function createRelayHandler(options: RelayHandlerOptions): RelayHandler {
|
|
|
2667
2951
|
clearInterval(heartbeat)
|
|
2668
2952
|
unsubscribe()
|
|
2669
2953
|
tableUnsubscribers.delete(unsubscribe)
|
|
2954
|
+
// TA-1: the uplink half dies with the stream — deregistered BEFORE
|
|
2955
|
+
// the queue closes, so a post racing the teardown answers
|
|
2956
|
+
// unknown-stream instead of feeding a dead queue, and the gateway's
|
|
2957
|
+
// consumer is finished instead of parked forever.
|
|
2958
|
+
if (uplinkStreamId !== undefined) liveUplinkStreams.delete(uplinkStreamId)
|
|
2959
|
+
uplinkQueue?.close()
|
|
2670
2960
|
if (eventsSub !== undefined) {
|
|
2671
2961
|
liveEventSubscriptions.delete(eventsSub)
|
|
2672
2962
|
eventsSub.events.clear()
|
|
@@ -2710,6 +3000,12 @@ export function createRelayHandler(options: RelayHandlerOptions): RelayHandler {
|
|
|
2710
3000
|
sub.events.clear()
|
|
2711
3001
|
}
|
|
2712
3002
|
liveEventSubscriptions.clear()
|
|
3003
|
+
// TA-1: the duplex uplinks die with the handler — close() finishes every
|
|
3004
|
+
// parked gateway consumer (a terminal read must not hang on a reload)
|
|
3005
|
+
// and later posts answer unknown-stream; the streams' own finallys then
|
|
3006
|
+
// find their entries already gone and delete nothing.
|
|
3007
|
+
for (const entry of liveUplinkStreams.values()) entry.queue.close()
|
|
3008
|
+
liveUplinkStreams.clear()
|
|
2713
3009
|
// Remember the reason for requests still inside their ownership probe —
|
|
2714
3010
|
// they read it at the pre-open gate. Killing the open streams runs their
|
|
2715
3011
|
// normal teardown (error line, res.end, abort), whose finally releases
|
package/src/restart-fields.ts
CHANGED
|
@@ -3,10 +3,10 @@
|
|
|
3
3
|
* effect (T17): `role` chooses which halves load; `port` / `host` /
|
|
4
4
|
* `targetPort` / `rateLimit` / `trustedProxies` become the gateway child's
|
|
5
5
|
* runtime shape; `vapidSubject` / `lang` / `pushEvents` / `pushDebounceMs` /
|
|
6
|
-
* `pushSummary` / `pushTurnEnd` / `pushTool`
|
|
7
|
-
* values. Everything else
|
|
8
|
-
* serverUrl, deviceToken, and
|
|
9
|
-
* and needs no reload.
|
|
6
|
+
* `pushSummary` / `pushTurnEnd` / `pushTool` / `pushEnabled` /
|
|
7
|
+
* `pushSessionName` are the push leg's startup values. Everything else
|
|
8
|
+
* (serverName, idleHours, autoShareNewSessions, serverUrl, deviceToken, and
|
|
9
|
+
* the interface-half knobs) is read live per use and needs no reload.
|
|
10
10
|
*
|
|
11
11
|
* Single source shared by both halves: the host's restart watcher
|
|
12
12
|
* (src/restart-watcher.ts) fingerprints exactly these fields, and the
|
|
@@ -30,6 +30,8 @@ export const RESTART_FIELDS = [
|
|
|
30
30
|
'pushSummary',
|
|
31
31
|
'pushTurnEnd',
|
|
32
32
|
'pushTool',
|
|
33
|
+
'pushEnabled',
|
|
34
|
+
'pushSessionName',
|
|
33
35
|
] as const
|
|
34
36
|
|
|
35
37
|
/** One field of {@link RESTART_FIELDS}. */
|
package/src/share-store.ts
CHANGED
|
@@ -24,8 +24,10 @@
|
|
|
24
24
|
* idle by definition until the caller says otherwise.
|
|
25
25
|
*/
|
|
26
26
|
|
|
27
|
-
import {
|
|
28
|
-
import {
|
|
27
|
+
import { readFileSync, renameSync } from 'node:fs'
|
|
28
|
+
import { basename } from 'node:path'
|
|
29
|
+
import { isVirtual } from './virtual-id.js'
|
|
30
|
+
import { ensureSharesMigrated, readStateFile, STATE_FILE_NAME, writeStateFile } from './state-store.js'
|
|
29
31
|
|
|
30
32
|
/** One row of the table, as `list()` reports it. */
|
|
31
33
|
export interface ShareEntry {
|
|
@@ -154,37 +156,62 @@ export function createShareStore(options: ShareStoreOptions): ShareStore {
|
|
|
154
156
|
|
|
155
157
|
// ---- startup load (synchronous, once) ----
|
|
156
158
|
//
|
|
157
|
-
//
|
|
158
|
-
//
|
|
159
|
-
//
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
}
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
159
|
+
// Two container shapes. When this store backs the MS-1 three-in-one state
|
|
160
|
+
// file (`zen-remote-state.json`), loading goes through state-store's
|
|
161
|
+
// reader: the legacy `zen-remote-shares.json` migrates in first (and is
|
|
162
|
+
// deleted), and a version:1 container without a sessions map is legal —
|
|
163
|
+
// the file also carries serverId/servers other writers may have filled.
|
|
164
|
+
// Any OTHER file name keeps the historical shares-file semantics verbatim:
|
|
165
|
+
// anything that is not `{version:1, sessions:{…}}` quarantines the whole
|
|
166
|
+
// file. Unreadable paths (a directory, permissions) start empty either way
|
|
167
|
+
// and are NOT quarantined — renaming something we could not even read
|
|
168
|
+
// could eat a live directory.
|
|
169
|
+
const isStateFile = basename(file) === STATE_FILE_NAME
|
|
170
|
+
if (isStateFile) {
|
|
168
171
|
try {
|
|
169
|
-
|
|
172
|
+
ensureSharesMigrated(file)
|
|
170
173
|
} catch {
|
|
171
|
-
|
|
174
|
+
// Startup must not fail on a migration hiccup; the legacy file stays.
|
|
172
175
|
}
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
if (isPlainObject(parsed) && parsed.version === FILE_VERSION && isPlainObject(parsed.sessions)) {
|
|
177
|
-
for (const [id, value] of Object.entries(parsed.sessions)) {
|
|
176
|
+
const state = readStateFile(file)
|
|
177
|
+
if (state.sessions !== undefined) {
|
|
178
|
+
for (const [id, value] of Object.entries(state.sessions)) {
|
|
178
179
|
if (!isValidId(id) || !isPlainObject(value)) continue
|
|
179
180
|
const { sharedAt, lastActivityAt } = value
|
|
180
181
|
if (!isFiniteNumber(sharedAt) || !isFiniteNumber(lastActivityAt)) continue
|
|
181
182
|
sessions.set(id, { sharedAt, lastActivityAt, busy: false })
|
|
182
183
|
}
|
|
183
|
-
}
|
|
184
|
+
}
|
|
185
|
+
} else {
|
|
186
|
+
let raw: string | null = null
|
|
187
|
+
try {
|
|
188
|
+
raw = readFileSync(file, 'utf8')
|
|
189
|
+
} catch {
|
|
190
|
+
raw = null
|
|
191
|
+
}
|
|
192
|
+
if (raw !== null) {
|
|
193
|
+
let parsed: unknown
|
|
184
194
|
try {
|
|
185
|
-
|
|
195
|
+
parsed = JSON.parse(raw)
|
|
186
196
|
} catch {
|
|
187
|
-
|
|
197
|
+
parsed = undefined
|
|
198
|
+
}
|
|
199
|
+
// Container-level damage (unparseable JSON, wrong version, wrong shape)
|
|
200
|
+
// quarantines the whole file; row-level damage (one bad record) only
|
|
201
|
+
// skips that record — the rest of the table is worth keeping.
|
|
202
|
+
if (isPlainObject(parsed) && parsed.version === FILE_VERSION && isPlainObject(parsed.sessions)) {
|
|
203
|
+
for (const [id, value] of Object.entries(parsed.sessions)) {
|
|
204
|
+
if (!isValidId(id) || !isPlainObject(value)) continue
|
|
205
|
+
const { sharedAt, lastActivityAt } = value
|
|
206
|
+
if (!isFiniteNumber(sharedAt) || !isFiniteNumber(lastActivityAt)) continue
|
|
207
|
+
sessions.set(id, { sharedAt, lastActivityAt, busy: false })
|
|
208
|
+
}
|
|
209
|
+
} else {
|
|
210
|
+
try {
|
|
211
|
+
renameSync(file, `${file}.corrupt-${now()}`)
|
|
212
|
+
} catch {
|
|
213
|
+
// Keep the empty table either way; startup must not fail here.
|
|
214
|
+
}
|
|
188
215
|
}
|
|
189
216
|
}
|
|
190
217
|
}
|
|
@@ -212,9 +239,10 @@ export function createShareStore(options: ShareStoreOptions): ShareStore {
|
|
|
212
239
|
fileSessions[id] = { sharedAt: row.sharedAt, lastActivityAt: row.lastActivityAt }
|
|
213
240
|
}
|
|
214
241
|
try {
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
242
|
+
// The merged write door: whatever ELSE the file carries (serverId and
|
|
243
|
+
// servers in the MS-1 state file, any extra top-level fields in a
|
|
244
|
+
// plain shares file) survives every table write.
|
|
245
|
+
writeStateFile(file, (current) => ({ ...current, version: FILE_VERSION, sessions: fileSessions }))
|
|
218
246
|
lastWriteError = undefined
|
|
219
247
|
} catch (error) {
|
|
220
248
|
lastWriteError = error
|
|
@@ -237,6 +265,11 @@ export function createShareStore(options: ShareStoreOptions): ShareStore {
|
|
|
237
265
|
|
|
238
266
|
const store: ShareStore = {
|
|
239
267
|
share(sessionId) {
|
|
268
|
+
// A virtual id (`zr~<serverId>~…`) names a session that lives on a
|
|
269
|
+
// REMOTE server — this table is the local server's access control, and
|
|
270
|
+
// letting a virtual id in would make the local relay hand out access
|
|
271
|
+
// under an id it cannot resolve. Never enters the table.
|
|
272
|
+
if (isVirtual(sessionId)) return false
|
|
240
273
|
if (!isValidId(sessionId)) return false
|
|
241
274
|
const t = now()
|
|
242
275
|
const existing = sessions.get(sessionId)
|