dsh-zen-remote 2.3.10 → 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 +44 -72
- 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 +573 -366
- 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 +575 -370
- 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-access.ts
CHANGED
|
@@ -183,6 +183,9 @@ interface RelayMethod {
|
|
|
183
183
|
* forwarded. Like the global reads it claims no session field — access is
|
|
184
184
|
* judged per frame / per answer. */
|
|
185
185
|
events?: true
|
|
186
|
+
/** Set on duplex streams (`terminal/attach` in DSH 0.2.2+): the stream
|
|
187
|
+
* route accepts an `uplink` queue and exposes it to `/v1/stream-uplink`. */
|
|
188
|
+
uplink?: true
|
|
186
189
|
}
|
|
187
190
|
|
|
188
191
|
/**
|
|
@@ -302,6 +305,11 @@ const RELAY_METHODS: Record<string, RelayMethod> = {
|
|
|
302
305
|
'terminal/rename': { fields: ['agentId'] },
|
|
303
306
|
'terminal/close': { fields: ['agentId'] },
|
|
304
307
|
'terminal/follow': { fields: ['agentId'], stream: true },
|
|
308
|
+
// TA-1: the 0.2.2 duplex stream — follow+write+resize merged into one
|
|
309
|
+
// subscription with an `uplink` half (keystrokes/resize from the client).
|
|
310
|
+
// Same attachment semantics as follow (exclusive input control), same
|
|
311
|
+
// server-side trust premise; follow/write/resize stay for ≤ 0.2.1 hosts.
|
|
312
|
+
'terminal/attach': { fields: ['agentId'], stream: true, uplink: true },
|
|
305
313
|
// the retain/list half locates by a plain top-level json sessionId
|
|
306
314
|
'terminal/list': { fields: ['sessionId'] },
|
|
307
315
|
'terminal/retain': { fields: ['sessionId'], stream: true },
|
|
@@ -350,9 +358,10 @@ export type InvokeDecision = { allow: true; filter?: InvokeFilter } | { allow: f
|
|
|
350
358
|
/** One stream decision: allow (global streams carry a `streamFilter`, scoped
|
|
351
359
|
* streams list the session ids the subscription depends on — the relay kills
|
|
352
360
|
* the stream and counts viewers with them, and the event subscription sets
|
|
353
|
-
* `events`
|
|
361
|
+
* `events`; a duplex entry sets `uplink` so the route arms the
|
|
362
|
+
* `/v1/stream-uplink` channel), or the 403 reason. */
|
|
354
363
|
export type StreamDecision =
|
|
355
|
-
| { allow: true; filter?: StreamFilter; sessionIds: string[]; events?: true }
|
|
364
|
+
| { allow: true; filter?: StreamFilter; sessionIds: string[]; events?: true; uplink?: true }
|
|
356
365
|
| { allow: false; reason: InvokeDenyReason }
|
|
357
366
|
|
|
358
367
|
function isPlainObject(value: unknown): value is Record<string, unknown> {
|
|
@@ -496,7 +505,9 @@ export function decideStream(
|
|
|
496
505
|
for (const id of claimed.ids) {
|
|
497
506
|
if (!isAccessible(id)) return { allow: false, reason: 'not-shared' }
|
|
498
507
|
}
|
|
499
|
-
return
|
|
508
|
+
return entry.uplink === true
|
|
509
|
+
? { allow: true, sessionIds: claimed.ids, uplink: true }
|
|
510
|
+
: { allow: true, sessionIds: claimed.ids }
|
|
500
511
|
}
|
|
501
512
|
|
|
502
513
|
/**
|
package/src/relay-client.ts
CHANGED
|
@@ -57,6 +57,9 @@ const RELAY_PROTOCOL = 1
|
|
|
57
57
|
const HANDSHAKE_PATH = '/_dsh/zen-remote/relay/v1/handshake'
|
|
58
58
|
const INVOKE_PATH = '/_dsh/zen-remote/relay/v1/invoke'
|
|
59
59
|
const STREAM_PATH = '/_dsh/zen-remote/relay/v1/stream'
|
|
60
|
+
/** TA-1: the duplex uplink's post route — one JSON body of frames per call,
|
|
61
|
+
* sequenced by the pump below. */
|
|
62
|
+
const STREAM_UPLINK_PATH = '/_dsh/zen-remote/relay/v1/stream-uplink'
|
|
60
63
|
/** the relay's existing authenticated health route
|
|
61
64
|
* (`GET …/relay/ping`, answers `{ok:true}`). Used by probeHealth() only. */
|
|
62
65
|
const PING_PATH = '/_dsh/zen-remote/relay/ping'
|
|
@@ -360,8 +363,12 @@ export interface RelayClient {
|
|
|
360
363
|
/** Open the NDJSON stream route. `frame` lines are yielded, `ping` lines
|
|
361
364
|
* only refresh the idle clock, `end` finishes the iteration, an `error`
|
|
362
365
|
* line throws its RelayError. A caller abort ENDS the iteration normally;
|
|
363
|
-
* `break` aborts the underlying request.
|
|
364
|
-
|
|
366
|
+
* `break` aborts the underlying request. TA-1: with `options.uplink` the
|
|
367
|
+
* stream is DUPLEX — the server's handshake line names the stream id and
|
|
368
|
+
* the pump forwards the host's frames through `/v1/stream-uplink`
|
|
369
|
+
* (single-flight batched, strictly ordered); an uplink refusal surfaces as
|
|
370
|
+
* the stream's error, and the host iterable is always released. */
|
|
371
|
+
openStream(namespace: string, method: string, args: unknown, signal?: AbortSignal, options?: RelayStreamOptions): AsyncIterable<unknown>
|
|
365
372
|
/** One plain-HTTP round-trip (T41b): `route` / `query` name a registered
|
|
366
373
|
* `/api` GET (relay-access's RELAY_HTTP_ROUTES; `query` is the URL-encoded
|
|
367
374
|
* query string with the session id already restored). Resolves with the
|
|
@@ -469,6 +476,21 @@ export interface RelayHttpResult {
|
|
|
469
476
|
body: string
|
|
470
477
|
}
|
|
471
478
|
|
|
479
|
+
/**
|
|
480
|
+
* The duplex-stream options (TA-1). `uplink` is the HOST-side frame source
|
|
481
|
+
* (the local `UplinkInbox` a `terminal/attach` session writes keystrokes and
|
|
482
|
+
* resize frames into); while the stream is open the pump below forwards its
|
|
483
|
+
* frames to the server through `/v1/stream-uplink`, single-flight batched,
|
|
484
|
+
* and — on stream exit — calls its `return()` so the host inbox is released
|
|
485
|
+
* even when the pump was parked mid-read. `uplinkWindow` is the host's own
|
|
486
|
+
* inbox budget, forwarded verbatim so the relay's bounded queue and the
|
|
487
|
+
* host's overflow accounting mean the same thing.
|
|
488
|
+
*/
|
|
489
|
+
export interface RelayStreamOptions {
|
|
490
|
+
uplink?: AsyncIterable<unknown>
|
|
491
|
+
uplinkWindow?: { maxBytes: number; frameOverheadBytes: number }
|
|
492
|
+
}
|
|
493
|
+
|
|
472
494
|
/**
|
|
473
495
|
* The credential digest recorded beside a completed handshake (T23a-fix):
|
|
474
496
|
* sha256 over `url + "\n" + token`, first 16 hex chars. Enough to detect a
|
|
@@ -522,6 +544,7 @@ type LineVerdict =
|
|
|
522
544
|
| { kind: 'end' }
|
|
523
545
|
| { kind: 'error'; error: RelayError }
|
|
524
546
|
| { kind: 'info'; serverName?: string; deviceName?: string }
|
|
547
|
+
| { kind: 'uplink'; streamId: string }
|
|
525
548
|
|
|
526
549
|
/** A string field of an info line, or undefined — absent, blank or not a string. */
|
|
527
550
|
function infoString(value: unknown): string | undefined {
|
|
@@ -538,6 +561,13 @@ function classifyLine(line: string): LineVerdict {
|
|
|
538
561
|
if (!isRecord(parsed)) return { kind: 'none' }
|
|
539
562
|
if (parsed.type === 'frame' && 'frame' in parsed) return { kind: 'frame', frame: parsed.frame }
|
|
540
563
|
if (parsed.type === 'end') return { kind: 'end' }
|
|
564
|
+
// TA-1: the duplex handshake line — the server naming the stream id the
|
|
565
|
+
// uplink posts must carry. A client that opened one-direction (or an older
|
|
566
|
+
// client that predates the channel) never sees this verdict: the check
|
|
567
|
+
// rides inside classifyLine, so unrecognized shapes fall to `none` below.
|
|
568
|
+
if (parsed.type === 'uplink' && typeof parsed.streamId === 'string' && parsed.streamId !== '') {
|
|
569
|
+
return { kind: 'uplink', streamId: parsed.streamId }
|
|
570
|
+
}
|
|
541
571
|
if (parsed.type === 'error') {
|
|
542
572
|
const detail = isRecord(parsed.error) ? parsed.error : {}
|
|
543
573
|
const code = typeof detail.code === 'string' && detail.code !== '' ? detail.code : 'internal'
|
|
@@ -1782,12 +1812,18 @@ export function createRelayClient(options: CreateRelayClientOptions): RelayClien
|
|
|
1782
1812
|
})
|
|
1783
1813
|
}
|
|
1784
1814
|
|
|
1785
|
-
function openStream(
|
|
1815
|
+
function openStream(
|
|
1816
|
+
namespace: string,
|
|
1817
|
+
method: string,
|
|
1818
|
+
args: unknown,
|
|
1819
|
+
signal?: AbortSignal,
|
|
1820
|
+
options?: RelayStreamOptions,
|
|
1821
|
+
): AsyncIterable<unknown> {
|
|
1786
1822
|
// Eager, not generator-lazy: an unconfigured client fails at CALL time,
|
|
1787
1823
|
// exactly like connect/invoke, instead of hiding the error inside the
|
|
1788
1824
|
// first next().
|
|
1789
1825
|
requireCredentials()
|
|
1790
|
-
return streamLines(namespace, method, args, signal)
|
|
1826
|
+
return streamLines(namespace, method, args, signal, options)
|
|
1791
1827
|
}
|
|
1792
1828
|
|
|
1793
1829
|
/**
|
|
@@ -1797,12 +1833,24 @@ export function createRelayClient(options: CreateRelayClientOptions): RelayClien
|
|
|
1797
1833
|
* and the generator's own exit — `break`, a thrown error line, normal
|
|
1798
1834
|
* end — which tears the request down so the server's upstream does not
|
|
1799
1835
|
* keep pumping behind a consumer that left.
|
|
1836
|
+
*
|
|
1837
|
+
* TA-1: with `options.uplink` the pump is DUPLEX. An independent async
|
|
1838
|
+
* task consumes the host's iterable the moment the 200 lands (frames read
|
|
1839
|
+
* before the handshake line are buffered locally, never lost), and a
|
|
1840
|
+
* single-in-flight flush loop batches them into `/v1/stream-uplink` posts:
|
|
1841
|
+
* while one post is on the wire the newer frames accumulate in
|
|
1842
|
+
* `pendingItems` and leave as ONE follow-up batch — strictly ordered seq
|
|
1843
|
+
* numbers without a second concurrent request, which is what keeps the
|
|
1844
|
+
* host's own input sequencing intact. An uplink refusal is the stream's
|
|
1845
|
+
* error: it aborts the downstream controller so the consumer sees the
|
|
1846
|
+
* RelayError, never a silent drop.
|
|
1800
1847
|
*/
|
|
1801
1848
|
async function* streamLines(
|
|
1802
1849
|
namespace: string,
|
|
1803
1850
|
method: string,
|
|
1804
1851
|
args: unknown,
|
|
1805
1852
|
signal: AbortSignal | undefined,
|
|
1853
|
+
options: RelayStreamOptions | undefined,
|
|
1806
1854
|
): AsyncGenerator<unknown> {
|
|
1807
1855
|
if (signal?.aborted) return
|
|
1808
1856
|
// Re-checked: the generator body runs at the first next(), so the
|
|
@@ -1814,6 +1862,62 @@ export function createRelayClient(options: CreateRelayClientOptions): RelayClien
|
|
|
1814
1862
|
}
|
|
1815
1863
|
if (signal !== undefined) signal.addEventListener('abort', onExternalAbort)
|
|
1816
1864
|
|
|
1865
|
+
// ---- the uplink pump's state (TA-1) --------------------------------
|
|
1866
|
+
const uplink = options?.uplink
|
|
1867
|
+
let uplinkIterator: AsyncIterator<unknown> | undefined
|
|
1868
|
+
let uplinkError: unknown
|
|
1869
|
+
let pumpStopped = false
|
|
1870
|
+
let assignedStreamId: string | undefined
|
|
1871
|
+
let nextSeq = 1
|
|
1872
|
+
let uplinkEnded = false
|
|
1873
|
+
let endSent = false
|
|
1874
|
+
let flushing = false
|
|
1875
|
+
const pendingItems: unknown[] = []
|
|
1876
|
+
|
|
1877
|
+
/** The single-flight flush loop: one POST at a time, everything read
|
|
1878
|
+
* while it flew leaving as the next batch. Called from the consume task
|
|
1879
|
+
* and from the handshake-line verdict; re-entry while flushing is a
|
|
1880
|
+
* no-op (the running loop already sees the newest state on every turn). */
|
|
1881
|
+
const flushUplink = async (): Promise<void> => {
|
|
1882
|
+
if (flushing || pumpStopped || assignedStreamId === undefined) return
|
|
1883
|
+
flushing = true
|
|
1884
|
+
try {
|
|
1885
|
+
while (!pumpStopped && (pendingItems.length > 0 || (uplinkEnded && !endSent))) {
|
|
1886
|
+
const batch = pendingItems.splice(0, pendingItems.length)
|
|
1887
|
+
const sendEnd = uplinkEnded && pendingItems.length === 0 && !endSent
|
|
1888
|
+
if (batch.length === 0 && !sendEnd) break
|
|
1889
|
+
if (sendEnd) endSent = true
|
|
1890
|
+
const seq = nextSeq++
|
|
1891
|
+
try {
|
|
1892
|
+
await exchange(
|
|
1893
|
+
STREAM_UPLINK_PATH,
|
|
1894
|
+
JSON.stringify({
|
|
1895
|
+
streamId: assignedStreamId,
|
|
1896
|
+
seq,
|
|
1897
|
+
...(batch.length > 0 ? { items: batch } : {}),
|
|
1898
|
+
...(sendEnd ? { end: true } : {}),
|
|
1899
|
+
}),
|
|
1900
|
+
controller.signal,
|
|
1901
|
+
true,
|
|
1902
|
+
{ url, token },
|
|
1903
|
+
{ timeoutSetsOffline: false },
|
|
1904
|
+
)
|
|
1905
|
+
} catch (error) {
|
|
1906
|
+
// The refusal (or a transport death that is not the caller's own
|
|
1907
|
+
// abort) kills the DOWNSTREAM too: the caller must see the
|
|
1908
|
+
// uplink's error instead of a silently dead terminal input.
|
|
1909
|
+
if (!controller.signal.aborted) {
|
|
1910
|
+
uplinkError = error
|
|
1911
|
+
controller.abort()
|
|
1912
|
+
}
|
|
1913
|
+
return
|
|
1914
|
+
}
|
|
1915
|
+
}
|
|
1916
|
+
} finally {
|
|
1917
|
+
flushing = false
|
|
1918
|
+
}
|
|
1919
|
+
}
|
|
1920
|
+
|
|
1817
1921
|
let idleFired = false
|
|
1818
1922
|
let headersTimedOut = false
|
|
1819
1923
|
let idleTimer: ReturnType<typeof setTimeout> | undefined
|
|
@@ -1849,7 +1953,16 @@ export function createRelayClient(options: CreateRelayClientOptions): RelayClien
|
|
|
1849
1953
|
let response: Response
|
|
1850
1954
|
try {
|
|
1851
1955
|
response = await fetchImpl(`${url}${STREAM_PATH}`, {
|
|
1852
|
-
...requestInit(
|
|
1956
|
+
...requestInit(
|
|
1957
|
+
token,
|
|
1958
|
+
JSON.stringify({
|
|
1959
|
+
namespace,
|
|
1960
|
+
method,
|
|
1961
|
+
args,
|
|
1962
|
+
...(uplink !== undefined ? { uplink: true } : {}),
|
|
1963
|
+
...(uplink !== undefined && options?.uplinkWindow !== undefined ? { uplinkWindow: options.uplinkWindow } : {}),
|
|
1964
|
+
}),
|
|
1965
|
+
),
|
|
1853
1966
|
signal: controller.signal,
|
|
1854
1967
|
})
|
|
1855
1968
|
} catch (error) {
|
|
@@ -1881,6 +1994,31 @@ export function createRelayClient(options: CreateRelayClientOptions): RelayClien
|
|
|
1881
1994
|
setState('online')
|
|
1882
1995
|
if (response.body === null) return
|
|
1883
1996
|
armIdle()
|
|
1997
|
+
// TA-1: the uplink consume task starts with the downstream — frames
|
|
1998
|
+
// the host pushes before the handshake line arrives land in
|
|
1999
|
+
// pendingItems and flush the moment the id is known.
|
|
2000
|
+
if (uplink !== undefined) {
|
|
2001
|
+
const iterator = uplink[Symbol.asyncIterator]()
|
|
2002
|
+
uplinkIterator = iterator
|
|
2003
|
+
void (async (): Promise<void> => {
|
|
2004
|
+
try {
|
|
2005
|
+
while (true) {
|
|
2006
|
+
const result = await iterator.next()
|
|
2007
|
+
if (result.done) break
|
|
2008
|
+
pendingItems.push(result.value)
|
|
2009
|
+
void flushUplink()
|
|
2010
|
+
}
|
|
2011
|
+
uplinkEnded = true
|
|
2012
|
+
void flushUplink()
|
|
2013
|
+
} catch (error) {
|
|
2014
|
+
// A host-side iterable failure is this stream's failure too.
|
|
2015
|
+
if (!controller.signal.aborted && !pumpStopped) {
|
|
2016
|
+
uplinkError = error
|
|
2017
|
+
controller.abort()
|
|
2018
|
+
}
|
|
2019
|
+
}
|
|
2020
|
+
})()
|
|
2021
|
+
}
|
|
1884
2022
|
const decoder = new TextDecoder()
|
|
1885
2023
|
let buffer = ''
|
|
1886
2024
|
reader = response.body.getReader()
|
|
@@ -1910,6 +2048,13 @@ export function createRelayClient(options: CreateRelayClientOptions): RelayClien
|
|
|
1910
2048
|
// T59: the heartbeat's names, folded in quietly — a state-sparing
|
|
1911
2049
|
// notify wakes the interceptor's rename path without any reconnect.
|
|
1912
2050
|
applyInfoDelta(verdict.serverName, verdict.deviceName)
|
|
2051
|
+
} else if (verdict.kind === 'uplink') {
|
|
2052
|
+
// TA-1: the duplex handshake. The FIRST line names the stream;
|
|
2053
|
+
// any later one is ignored (one id per stream, ever).
|
|
2054
|
+
if (assignedStreamId === undefined) {
|
|
2055
|
+
assignedStreamId = verdict.streamId
|
|
2056
|
+
void flushUplink()
|
|
2057
|
+
}
|
|
1913
2058
|
}
|
|
1914
2059
|
}
|
|
1915
2060
|
}
|
|
@@ -1927,10 +2072,17 @@ export function createRelayClient(options: CreateRelayClientOptions): RelayClien
|
|
|
1927
2072
|
throw verdict.error
|
|
1928
2073
|
} else if (verdict.kind === 'info') {
|
|
1929
2074
|
applyInfoDelta(verdict.serverName, verdict.deviceName)
|
|
2075
|
+
} else if (verdict.kind === 'uplink' && assignedStreamId === undefined) {
|
|
2076
|
+
assignedStreamId = verdict.streamId
|
|
2077
|
+
void flushUplink()
|
|
1930
2078
|
}
|
|
1931
2079
|
}
|
|
1932
2080
|
} catch (error) {
|
|
1933
2081
|
if (signal?.aborted) return
|
|
2082
|
+
// TA-1: an uplink refusal outranks the transport noise the abort it
|
|
2083
|
+
// caused surfaces as — the caller sees the relay's own error code
|
|
2084
|
+
// (`out-of-order`, `uplink-overflow`, …), the actionable one.
|
|
2085
|
+
if (uplinkError !== undefined) throw uplinkError
|
|
1934
2086
|
if (error instanceof RelayError) throw error
|
|
1935
2087
|
// Anything else escaping the body read is the transport dying.
|
|
1936
2088
|
noteFailure('offline')
|
|
@@ -1940,6 +2092,12 @@ export function createRelayClient(options: CreateRelayClientOptions): RelayClien
|
|
|
1940
2092
|
disarmIdle()
|
|
1941
2093
|
clearTimeout(headerTimer)
|
|
1942
2094
|
if (signal !== undefined) signal.removeEventListener('abort', onExternalAbort)
|
|
2095
|
+
// TA-1: stop the pump and release the host iterable — a parked next()
|
|
2096
|
+
// (empty inbox, pump waiting) would otherwise pin the host's
|
|
2097
|
+
// UplinkInbox after the stream is gone. return() on an exhausted
|
|
2098
|
+
// iterator is a no-op; a missing one is skipped.
|
|
2099
|
+
pumpStopped = true
|
|
2100
|
+
void Promise.resolve(uplinkIterator?.return?.()).catch(() => {})
|
|
1943
2101
|
// Release the body even if a custom fetchImpl ignored the signal —
|
|
1944
2102
|
// an in-flight reader keeps the connection's buffers alive. Cancel
|
|
1945
2103
|
// may reject on an already-dead stream; that is fine.
|