dsh-zen-remote 2.0.3 → 2.2.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/AGENTS.md +15 -1
- package/README.md +11 -5
- package/README.zh-CN.md +11 -5
- package/docs/adr/0001-plugin-bridge-forwards-plugin-routes.md +15 -0
- package/docs/adr/0002-relay-emulates-websocket.md +8 -0
- package/docs/interface.md +3 -1
- package/docs/plugin-bridge.md +140 -0
- package/docs/remote-access.en.md +57 -0
- package/docs/remote-access.md +57 -0
- package/lib/adapters/dsh-better-sidebar/index.js +144 -0
- package/lib/adapters/dsh-better-sidebar/index.js.map +1 -0
- package/lib/adapters/dsh-better-sidebar/methods.js +198 -0
- package/lib/adapters/dsh-better-sidebar/methods.js.map +1 -0
- package/lib/adapters/index.js +17 -0
- package/lib/adapters/index.js.map +1 -0
- package/lib/adapters/types.js +17 -0
- package/lib/adapters/types.js.map +1 -0
- package/lib/admin-routes.js +51 -0
- package/lib/admin-routes.js.map +1 -1
- package/lib/client-routes.js +18 -1
- package/lib/client-routes.js.map +1 -1
- package/lib/client.js +449 -45
- package/lib/config.js +27 -0
- package/lib/config.js.map +1 -1
- package/lib/http.js +10 -0
- package/lib/http.js.map +1 -1
- package/lib/index.js +246 -0
- package/lib/index.js.map +1 -1
- package/lib/plugin-adapter-core.js +385 -0
- package/lib/plugin-adapter-core.js.map +1 -0
- package/lib/plugin-bridge.js +318 -0
- package/lib/plugin-bridge.js.map +1 -0
- package/lib/plugin-loopback.js +207 -0
- package/lib/plugin-loopback.js.map +1 -0
- package/lib/plugin-route-intercept.js +1103 -0
- package/lib/plugin-route-intercept.js.map +1 -0
- package/lib/plugin-tunnel.js +416 -0
- package/lib/plugin-tunnel.js.map +1 -0
- package/lib/relay-client.js +319 -2
- package/lib/relay-client.js.map +1 -1
- package/lib/relay-server.js +633 -0
- package/lib/relay-server.js.map +1 -1
- package/lib/types/adapters/dsh-better-sidebar/index.d.ts +27 -0
- package/lib/types/adapters/dsh-better-sidebar/index.d.ts.map +1 -0
- package/lib/types/adapters/dsh-better-sidebar/methods.d.ts +51 -0
- package/lib/types/adapters/dsh-better-sidebar/methods.d.ts.map +1 -0
- package/lib/types/adapters/index.d.ts +17 -0
- package/lib/types/adapters/index.d.ts.map +1 -0
- package/lib/types/adapters/types.d.ts +42 -0
- package/lib/types/adapters/types.d.ts.map +1 -0
- package/lib/types/admin-routes.d.ts +16 -0
- package/lib/types/admin-routes.d.ts.map +1 -1
- package/lib/types/client/effects/remote-session.d.ts +0 -22
- package/lib/types/client/effects/remote-session.d.ts.map +1 -1
- package/lib/types/client/locales.d.ts +16 -0
- 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/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-data/plugin-bridge.d.ts +79 -0
- package/lib/types/client-data/plugin-bridge.d.ts.map +1 -0
- package/lib/types/client-data/remote-status.d.ts +20 -0
- package/lib/types/client-data/remote-status.d.ts.map +1 -1
- package/lib/types/client-data/settings-form.d.ts +37 -1
- package/lib/types/client-data/settings-form.d.ts.map +1 -1
- package/lib/types/client-routes.d.ts +13 -0
- package/lib/types/client-routes.d.ts.map +1 -1
- package/lib/types/config.d.ts +8 -0
- package/lib/types/config.d.ts.map +1 -1
- package/lib/types/http.d.ts +7 -0
- package/lib/types/http.d.ts.map +1 -1
- package/lib/types/index.d.ts +4 -0
- package/lib/types/index.d.ts.map +1 -1
- package/lib/types/plugin-adapter-core.d.ts +263 -0
- package/lib/types/plugin-adapter-core.d.ts.map +1 -0
- package/lib/types/plugin-bridge.d.ts +187 -0
- package/lib/types/plugin-bridge.d.ts.map +1 -0
- package/lib/types/plugin-loopback.d.ts +104 -0
- package/lib/types/plugin-loopback.d.ts.map +1 -0
- package/lib/types/plugin-route-intercept.d.ts +114 -0
- package/lib/types/plugin-route-intercept.d.ts.map +1 -0
- package/lib/types/plugin-tunnel.d.ts +96 -0
- package/lib/types/plugin-tunnel.d.ts.map +1 -0
- package/lib/types/relay-client.d.ts +76 -0
- package/lib/types/relay-client.d.ts.map +1 -1
- package/lib/types/relay-server.d.ts +32 -1
- package/lib/types/relay-server.d.ts.map +1 -1
- package/llms.txt +1 -0
- package/package.json +2 -2
- package/scripts/check-plugin-bridge-ui.mjs +168 -0
- package/scripts/check-settings-form.mjs +3 -1
- package/src/adapters/dsh-better-sidebar/index.ts +144 -0
- package/src/adapters/dsh-better-sidebar/methods.ts +215 -0
- package/src/adapters/index.ts +19 -0
- package/src/adapters/types.ts +44 -0
- package/src/admin-routes.ts +64 -0
- package/src/client/effects/remote-session.ts +82 -10
- package/src/client/locales.ts +36 -0
- package/src/client/settings/SettingsSection.tsx +107 -3
- package/src/client/styles/remote-session.css.ts +70 -31
- package/src/client-data/plugin-bridge.ts +150 -0
- package/src/client-data/remote-status.ts +30 -0
- package/src/client-data/settings-form.ts +78 -0
- package/src/client-routes.ts +29 -1
- package/src/config.ts +32 -0
- package/src/http.ts +12 -0
- package/src/index.ts +252 -0
- package/src/plugin-adapter-core.ts +530 -0
- package/src/plugin-bridge.ts +435 -0
- package/src/plugin-loopback.ts +299 -0
- package/src/plugin-route-intercept.ts +1193 -0
- package/src/plugin-tunnel.ts +470 -0
- package/src/relay-client.ts +394 -2
- package/src/relay-server.ts +656 -1
package/src/relay-client.ts
CHANGED
|
@@ -49,6 +49,7 @@ import { createHash } from 'node:crypto'
|
|
|
49
49
|
|
|
50
50
|
import { compareFingerprints } from './fingerprint.js'
|
|
51
51
|
import type { RelayCompatVerdict } from './fingerprint.js'
|
|
52
|
+
import type { ServerPluginEntry } from './plugin-bridge.js'
|
|
52
53
|
|
|
53
54
|
/** The one protocol version this client speaks; the handshake verifies it. */
|
|
54
55
|
const RELAY_PROTOCOL = 1
|
|
@@ -60,6 +61,10 @@ const UNSHARE_PATH = '/_dsh/zen-remote/relay/v1/unshare'
|
|
|
60
61
|
const EVENT_RESULT_PATH = '/_dsh/zen-remote/relay/v1/event-result'
|
|
61
62
|
const HTTP_PATH = '/_dsh/zen-remote/relay/v1/http'
|
|
62
63
|
const UPLOAD_PATH = '/_dsh/zen-remote/relay/v1/upload'
|
|
64
|
+
const PLUGIN_PATH = '/_dsh/zen-remote/relay/v1/plugin'
|
|
65
|
+
const PLUGIN_TUNNEL_OPEN_PATH = '/_dsh/zen-remote/relay/v1/plugin-tunnel/open'
|
|
66
|
+
const PLUGIN_TUNNEL_SEND_PATH = '/_dsh/zen-remote/relay/v1/plugin-tunnel/send'
|
|
67
|
+
const PLUGIN_TUNNEL_CLOSE_PATH = '/_dsh/zen-remote/relay/v1/plugin-tunnel/close'
|
|
63
68
|
/** T59: a desktop-client device renaming ITSELF in the gateway's table. */
|
|
64
69
|
const DEVICE_NAME_PATH = '/_dsh/zen-remote/relay/v1/device/name'
|
|
65
70
|
|
|
@@ -179,6 +184,15 @@ export interface RelayHandshake {
|
|
|
179
184
|
* has and pushes nothing.
|
|
180
185
|
*/
|
|
181
186
|
deviceName?: string
|
|
187
|
+
/**
|
|
188
|
+
* The server's per-plugin bridge table (PB-2), verbatim from the
|
|
189
|
+
* handshake. `undefined` from an OLDER server whose handshake carries no
|
|
190
|
+
* `plugins` field at all — and that distinction is load-bearing:
|
|
191
|
+
* {@link compareBridges} reads `undefined` as `server-too-old`, while an
|
|
192
|
+
* empty table means "this server speaks the field, nothing registered"
|
|
193
|
+
* (`not-on-server`). Never normalized into `{}` here.
|
|
194
|
+
*/
|
|
195
|
+
plugins?: Record<string, ServerPluginEntry>
|
|
182
196
|
}
|
|
183
197
|
|
|
184
198
|
/**
|
|
@@ -350,6 +364,68 @@ export interface RelayClient {
|
|
|
350
364
|
* `Content-Length`, when it had one) and a timeout there never moves the
|
|
351
365
|
* connection state — the invoke route's rules (T31-fix). */
|
|
352
366
|
upload(options: RelayUploadOptions, signal?: AbortSignal): Promise<RelayHttpResult>
|
|
367
|
+
/** One bridged-plugin round-trip (PB-4): the coordinates travel in the
|
|
368
|
+
* x-zr-* headers (PB-3's protocol), no body. Resolves with the UNREAD
|
|
369
|
+
* fetch Response the moment the headers arrive — the caller streams the
|
|
370
|
+
* body itself — and EVERY HTTP status is carried verbatim (a refusal's
|
|
371
|
+
* `x-zen-remote-error` header is the caller's classifier, never an
|
|
372
|
+
* exception here). The header wait gets the plain request timeout; the
|
|
373
|
+
* BODY carries no deadline of our own (a stream takes as long as it
|
|
374
|
+
* takes) — the caller's signal aborts it. Transport deaths judge the
|
|
375
|
+
* link like every exchange (offline); `requireCredentials` throws the
|
|
376
|
+
* usual RelayError when nothing is configured. */
|
|
377
|
+
pluginRequest(request: RelayPluginRequest, signal?: AbortSignal): Promise<Response>
|
|
378
|
+
/** Open one WebSocket byte tunnel (PB-10): the coordinates travel like a
|
|
379
|
+
* pluginRequest's, plus the browser handshake's `sec-websocket-*` headers
|
|
380
|
+
* and any early head bytes (base64). Resolves once the NDJSON stream's
|
|
381
|
+
* FIRST line named the tunnel id; `frames` then yields the upstream's
|
|
382
|
+
* raw bytes (each `data` line decoded) and ENDS when the server writes
|
|
383
|
+
* the `end` line (upstream closed or errored). A refusal (any non-200 or
|
|
384
|
+
* a missing open line) throws RelayError with the wire's
|
|
385
|
+
* `x-zen-remote-error` code. */
|
|
386
|
+
pluginTunnelOpen(
|
|
387
|
+
request: RelayPluginRequest & {
|
|
388
|
+
wsHeaders: { 'sec-websocket-key': string; 'sec-websocket-version': string; 'sec-websocket-protocol'?: string; 'sec-websocket-extensions'?: string }
|
|
389
|
+
head?: string
|
|
390
|
+
},
|
|
391
|
+
signal?: AbortSignal,
|
|
392
|
+
): Promise<{ tunnel: string; frames: AsyncIterable<Uint8Array> }>
|
|
393
|
+
/** One ordered upstream chunk (PB-10): `seq` must count from 0 without
|
|
394
|
+
* gaps — the server answers 409 `tunnel-out-of-order` and closes the
|
|
395
|
+
* tunnel otherwise. Resolves after the server took the bytes (drain
|
|
396
|
+
* included). */
|
|
397
|
+
pluginTunnelSend(tunnel: string, seq: number, bytes: Uint8Array, signal?: AbortSignal): Promise<void>
|
|
398
|
+
/** Idempotent teardown (PB-10). */
|
|
399
|
+
pluginTunnelClose(tunnel: string, signal?: AbortSignal): Promise<void>
|
|
400
|
+
}
|
|
401
|
+
|
|
402
|
+
/** The inputs of one {@link RelayClient.pluginRequest} round-trip (PB-4) —
|
|
403
|
+
* the PB-3 wire protocol's coordinates, already validated and virtual-id-
|
|
404
|
+
* restored by the caller. */
|
|
405
|
+
export interface RelayPluginRequest {
|
|
406
|
+
plugin: string
|
|
407
|
+
version: string
|
|
408
|
+
digest: string
|
|
409
|
+
/** The declared route path, verbatim (never decoded). */
|
|
410
|
+
path: string
|
|
411
|
+
/** The re-serialized query string, session id already restored to its
|
|
412
|
+
* ORIGINAL form. */
|
|
413
|
+
query: string
|
|
414
|
+
/** The request method (PB-9); defaults to GET. Must be one the route
|
|
415
|
+
* declared — the server answers 405 `method-undeclared` otherwise. */
|
|
416
|
+
method?: string
|
|
417
|
+
/** The request BODY (PB-9), forwarded verbatim as a stream. `null` means
|
|
418
|
+
* no body. A streaming body rides chunked (undici forbids hand-set length
|
|
419
|
+
* headers), so the server's streaming byte count is the size gate. */
|
|
420
|
+
body?: ReadableStream<Uint8Array> | null
|
|
421
|
+
/** The body's content type, passed through to the plugin when set. */
|
|
422
|
+
contentType?: string
|
|
423
|
+
/** The declared body size, when the local request carried one — sizes the
|
|
424
|
+
* header-wait budget and nothing else (a streaming body goes chunked). */
|
|
425
|
+
contentLength?: number
|
|
426
|
+
/** The one request header passed through to the plugin beside `range`. */
|
|
427
|
+
accept?: string
|
|
428
|
+
range?: string
|
|
353
429
|
}
|
|
354
430
|
|
|
355
431
|
/** The inputs of one {@link RelayClient.upload} round-trip. */
|
|
@@ -799,6 +875,25 @@ export function createRelayClient(options: CreateRelayClientOptions): RelayClien
|
|
|
799
875
|
void pushDeviceName(trimmed)
|
|
800
876
|
}
|
|
801
877
|
|
|
878
|
+
/**
|
|
879
|
+
* Canonical JSON for CHANGE DETECTION (PB-2-fix): every object's keys are
|
|
880
|
+
* sorted, recursively, so two tables that differ only in key order — the
|
|
881
|
+
* server's map insertion order AND each entry's field order — compare
|
|
882
|
+
* equal. Anything else (values, entry sets) differs, and JSON.stringify
|
|
883
|
+
* answers scalars verbatim.
|
|
884
|
+
*/
|
|
885
|
+
function canonicalJson(value: unknown): string {
|
|
886
|
+
if (Array.isArray(value)) return `[${value.map(canonicalJson).join(',')}]`
|
|
887
|
+
if (value !== null && typeof value === 'object') {
|
|
888
|
+
const record = value as Record<string, unknown>
|
|
889
|
+
return `{${Object.keys(record)
|
|
890
|
+
.sort()
|
|
891
|
+
.map((key) => `${JSON.stringify(key)}:${canonicalJson(record[key])}`)
|
|
892
|
+
.join(',')}}`
|
|
893
|
+
}
|
|
894
|
+
return JSON.stringify(value)
|
|
895
|
+
}
|
|
896
|
+
|
|
802
897
|
/**
|
|
803
898
|
* One QUIET handshake re-run (T59): refreshes the two names while online —
|
|
804
899
|
* no state announcement, no ladder movement, failures silent (the next
|
|
@@ -839,7 +934,12 @@ export function createRelayClient(options: CreateRelayClientOptions): RelayClien
|
|
|
839
934
|
if (!isRecord(payload) || payload.ok !== true || payload.relayProtocol !== RELAY_PROTOCOL) return
|
|
840
935
|
// T59-fix: NAMES ONLY. serverId / dshVersion / fingerprints belong to the
|
|
841
936
|
// real handshake (the compat verdict is computed there, on both maps
|
|
842
|
-
// fresh); a quiet refresh never disturbs them.
|
|
937
|
+
// fresh); a quiet refresh never disturbs them. PB-2 is the one addition:
|
|
938
|
+
// the plugin bridge table rides along, because a consent change on the
|
|
939
|
+
// server must reach an otherwise-idle client without a reconnect — and
|
|
940
|
+
// unlike the compat verdict it is never cached as a conclusion, the
|
|
941
|
+
// comparison runs live off this map. A refresh answer WITHOUT the field
|
|
942
|
+
// keeps whatever the real handshake stored.
|
|
843
943
|
const previous = handshakeInfo
|
|
844
944
|
if (previous === undefined) return
|
|
845
945
|
const serverName = typeof payload.serverName === 'string' ? payload.serverName : previous.serverName
|
|
@@ -851,10 +951,27 @@ export function createRelayClient(options: CreateRelayClientOptions): RelayClien
|
|
|
851
951
|
const pushedDuringRefresh = deviceNamePushSeq !== pushSeqAtStart || deviceNamePushBusy || pendingDeviceName !== undefined
|
|
852
952
|
const deviceName = pushedDuringRefresh ? undefined : serverDeviceName
|
|
853
953
|
if (deviceName !== undefined) deviceNameAuthoritative = false
|
|
954
|
+
// PB-2-fix: compare the incoming table by CONTENT, not by reference — the
|
|
955
|
+
// payload was parsed fresh, so an unchanged table would otherwise read as
|
|
956
|
+
// changed on every tick and wake every subscriber (the interceptor's
|
|
957
|
+
// catalog listener re-pulls on each wake) for nothing. An unchanged table
|
|
958
|
+
// keeps the PREVIOUS object: identity is the "nothing moved" proof the
|
|
959
|
+
// changed flag reads.
|
|
960
|
+
let plugins = previous.plugins
|
|
961
|
+
if (isRecord(payload.plugins)) {
|
|
962
|
+
const incoming = payload.plugins as Record<string, ServerPluginEntry>
|
|
963
|
+
if (canonicalJson(incoming) !== canonicalJson(previous.plugins ?? {})) plugins = incoming
|
|
964
|
+
}
|
|
854
965
|
let changed = previous.serverName !== serverName
|
|
855
966
|
if (deviceName !== undefined && deviceName !== previous.deviceName) changed = true
|
|
967
|
+
if (plugins !== previous.plugins) changed = true
|
|
856
968
|
if (!changed) return
|
|
857
|
-
handshakeInfo = {
|
|
969
|
+
handshakeInfo = {
|
|
970
|
+
...previous,
|
|
971
|
+
serverName,
|
|
972
|
+
...(deviceName !== undefined ? { deviceName } : {}),
|
|
973
|
+
...(plugins !== previous.plugins ? { plugins } : {}),
|
|
974
|
+
}
|
|
858
975
|
notify()
|
|
859
976
|
}
|
|
860
977
|
|
|
@@ -1148,6 +1265,10 @@ export function createRelayClient(options: CreateRelayClientOptions): RelayClien
|
|
|
1148
1265
|
// speaks the field at all (an older server omits it and the client
|
|
1149
1266
|
// keeps whatever it had).
|
|
1150
1267
|
...(infoString(payload.deviceName) !== undefined ? { deviceName: infoString(payload.deviceName) } : {}),
|
|
1268
|
+
// PB-2: the plugin bridge table, kept VERBATIM — an absent or
|
|
1269
|
+
// malformed field stays `undefined` so the comparison reads
|
|
1270
|
+
// server-too-old, never a silently empty table.
|
|
1271
|
+
...(isRecord(payload.plugins) ? { plugins: payload.plugins as Record<string, ServerPluginEntry> } : {}),
|
|
1151
1272
|
}
|
|
1152
1273
|
// Interface compatibility (T42): compare the handshake's group map
|
|
1153
1274
|
// against this side's own, right here where both are fresh. A compute
|
|
@@ -1323,6 +1444,273 @@ export function createRelayClient(options: CreateRelayClientOptions): RelayClien
|
|
|
1323
1444
|
}
|
|
1324
1445
|
}
|
|
1325
1446
|
|
|
1447
|
+
/**
|
|
1448
|
+
* The bridged-plugin forward (PB-4): headers in, an UNREAD Response out.
|
|
1449
|
+
* Deliberately NOT the shared exchange (it buffers the body): the fetch
|
|
1450
|
+
* resolves on headers, the plain request timeout guards exactly that
|
|
1451
|
+
* wait, and the body streams under the CALLER's signal alone. The
|
|
1452
|
+
* caller's abort listener survives past the header arrival (registered
|
|
1453
|
+
* `once` on the caller's own request-scoped signal, so it dies with that
|
|
1454
|
+
* request) — a browser hang-up mid-stream must tear the relay round-trip
|
|
1455
|
+
* down, and the server's own route then aborts its loopback in turn.
|
|
1456
|
+
*/
|
|
1457
|
+
async function pluginRequest(request: RelayPluginRequest, signal?: AbortSignal): Promise<Response> {
|
|
1458
|
+
const creds = requireCredentials()
|
|
1459
|
+
const controller = new AbortController()
|
|
1460
|
+
const onExternalAbort = (): void => {
|
|
1461
|
+
controller.abort()
|
|
1462
|
+
}
|
|
1463
|
+
if (signal !== undefined) {
|
|
1464
|
+
if (signal.aborted) controller.abort()
|
|
1465
|
+
else signal.addEventListener('abort', onExternalAbort, { once: true })
|
|
1466
|
+
}
|
|
1467
|
+
// The header wait gets the plain request timeout for a bodyless call;
|
|
1468
|
+
// with a BODY (PB-9) the budget scales with its declared size like the
|
|
1469
|
+
// upload's (a slow body is a slow call, never a dead link), and the
|
|
1470
|
+
// timeout never moves the connection state.
|
|
1471
|
+
const hasBody = request.body !== undefined && request.body !== null
|
|
1472
|
+
const headerTimeoutMs = hasBody
|
|
1473
|
+
? sizeBackedTimeoutMs(requestTimeoutMs, request.contentLength)
|
|
1474
|
+
: requestTimeoutMs
|
|
1475
|
+
const timer = setTimeout(() => controller.abort(), headerTimeoutMs)
|
|
1476
|
+
if (typeof timer.unref === 'function') timer.unref()
|
|
1477
|
+
try {
|
|
1478
|
+
let response: Response
|
|
1479
|
+
const init: RequestInit = {
|
|
1480
|
+
// The WIRE method is always POST — the relay route is a POST endpoint
|
|
1481
|
+
// whatever the bridged call is; the bridged method travels in
|
|
1482
|
+
// `x-zr-method` (the server never reads this request line's verb).
|
|
1483
|
+
method: 'POST',
|
|
1484
|
+
headers: {
|
|
1485
|
+
authorization: `Bearer ${creds.token}`,
|
|
1486
|
+
// No accept of our own unless the caller carries one: this
|
|
1487
|
+
// header FORWARDS to the plugin (PB-3's protocol), and the
|
|
1488
|
+
// default below exists only so a revoked-token wall still
|
|
1489
|
+
// answers JSON instead of the pairing HTML page.
|
|
1490
|
+
accept: request.accept ?? 'application/json',
|
|
1491
|
+
...(request.range !== undefined ? { range: request.range } : {}),
|
|
1492
|
+
// PB-9: the body's content type is the OTHER passthrough header.
|
|
1493
|
+
...(hasBody && request.contentType !== undefined ? { 'content-type': request.contentType } : {}),
|
|
1494
|
+
'x-zr-plugin': request.plugin,
|
|
1495
|
+
'x-zr-plugin-version': request.version,
|
|
1496
|
+
'x-zr-plugin-digest': request.digest,
|
|
1497
|
+
'x-zr-path': request.path,
|
|
1498
|
+
'x-zr-query': request.query,
|
|
1499
|
+
// PB-9: the method travels in the HEADER (the wire protocol's
|
|
1500
|
+
// coordinate), not just the request line — the server reads it
|
|
1501
|
+
// from here, and an omitted header means GET there no matter what
|
|
1502
|
+
// this request line says.
|
|
1503
|
+
'x-zr-method': request.method ?? 'GET',
|
|
1504
|
+
},
|
|
1505
|
+
...(hasBody ? { body: request.body } : {}),
|
|
1506
|
+
// A wrong server must not walk the pairing token through a redirect.
|
|
1507
|
+
redirect: 'manual',
|
|
1508
|
+
signal: controller.signal,
|
|
1509
|
+
}
|
|
1510
|
+
if (hasBody) {
|
|
1511
|
+
// `duplex: 'half'` is mandatory for a streaming request body; the
|
|
1512
|
+
// resolved RequestInit type hides the field (the DOM-vs-undici
|
|
1513
|
+
// conditional in @types/node), so the assignment carries its own cast.
|
|
1514
|
+
;(init as { duplex?: string }).duplex = 'half'
|
|
1515
|
+
}
|
|
1516
|
+
try {
|
|
1517
|
+
response = await fetchImpl(`${creds.url}${PLUGIN_PATH}`, init)
|
|
1518
|
+
} catch (error) {
|
|
1519
|
+
// A caller abort is NOT a transport death (PB-9-fix): the caller
|
|
1520
|
+
// aborts exactly when its own request-body gate tripped or the
|
|
1521
|
+
// browser hung up — both are this call's verdicts to answer, never
|
|
1522
|
+
// the link's. Same discipline as the invoke/upload timeouts
|
|
1523
|
+
// (T31-fix/T51-fix): no offline, no failure recorded on the link.
|
|
1524
|
+
if (signal?.aborted) throw abortedError()
|
|
1525
|
+
// A body-bearing call keeps the upload route's rule (T51-fix): the
|
|
1526
|
+
// timeout judges THIS call, not the link — but a real transport
|
|
1527
|
+
// death still reads offline (the reconnect ladder hangs off it).
|
|
1528
|
+
if (hasBody && controller.signal.aborted && !signal?.aborted) {
|
|
1529
|
+
noteFailure('request-timeout')
|
|
1530
|
+
throw new RelayError('request-timeout', `no response within ${headerTimeoutMs} ms`)
|
|
1531
|
+
}
|
|
1532
|
+
noteFailure('offline')
|
|
1533
|
+
setState('offline')
|
|
1534
|
+
throw new RelayError('offline', messageOf(error))
|
|
1535
|
+
}
|
|
1536
|
+
// Headers are here: every status — bridged answer or x-zen-remote-error
|
|
1537
|
+
// refusal — travels to the caller verbatim, body untouched.
|
|
1538
|
+
return response
|
|
1539
|
+
} finally {
|
|
1540
|
+
// The header wait is over (or never started): no deadline on the body.
|
|
1541
|
+
clearTimeout(timer)
|
|
1542
|
+
}
|
|
1543
|
+
}
|
|
1544
|
+
|
|
1545
|
+
/**
|
|
1546
|
+
* The WebSocket byte tunnel's open half (PB-10). The wire: one POST whose
|
|
1547
|
+
* answer is an NDJSON river — first line names the tunnel, `data` lines
|
|
1548
|
+
* carry base64 chunks of the upstream socket's bytes, `ping` lines keep
|
|
1549
|
+
* proxies warm, an `end` line closes the river. Everything after the
|
|
1550
|
+
* headers is line-framed HERE: the caller gets an async iterable of
|
|
1551
|
+
* decoded byte chunks and never sees the framing.
|
|
1552
|
+
*/
|
|
1553
|
+
async function pluginTunnelOpen(
|
|
1554
|
+
request: RelayPluginRequest & {
|
|
1555
|
+
wsHeaders: { 'sec-websocket-key': string; 'sec-websocket-version': string; 'sec-websocket-protocol'?: string; 'sec-websocket-extensions'?: string }
|
|
1556
|
+
head?: string
|
|
1557
|
+
},
|
|
1558
|
+
signal?: AbortSignal,
|
|
1559
|
+
): Promise<{ tunnel: string; frames: AsyncIterable<Uint8Array> }> {
|
|
1560
|
+
const creds = requireCredentials()
|
|
1561
|
+
const payload = {
|
|
1562
|
+
headers: request.wsHeaders,
|
|
1563
|
+
...(request.head !== undefined && request.head !== '' ? { head: request.head } : {}),
|
|
1564
|
+
}
|
|
1565
|
+
const response = await fetch(`${creds.url}${PLUGIN_TUNNEL_OPEN_PATH}`, {
|
|
1566
|
+
method: 'POST',
|
|
1567
|
+
headers: {
|
|
1568
|
+
authorization: `Bearer ${creds.token}`,
|
|
1569
|
+
accept: 'application/x-ndjson',
|
|
1570
|
+
'content-type': 'application/json',
|
|
1571
|
+
'x-zr-plugin': request.plugin,
|
|
1572
|
+
'x-zr-plugin-version': request.version,
|
|
1573
|
+
'x-zr-plugin-digest': request.digest,
|
|
1574
|
+
'x-zr-path': request.path,
|
|
1575
|
+
'x-zr-query': request.query,
|
|
1576
|
+
},
|
|
1577
|
+
body: JSON.stringify(payload),
|
|
1578
|
+
redirect: 'manual',
|
|
1579
|
+
signal,
|
|
1580
|
+
}).catch((error: unknown) => {
|
|
1581
|
+
noteFailure('offline')
|
|
1582
|
+
setState('offline')
|
|
1583
|
+
throw new RelayError('offline', messageOf(error))
|
|
1584
|
+
})
|
|
1585
|
+
if (response.status < 200 || response.status >= 300 || response.body === null) {
|
|
1586
|
+
// The refusal carries its code in the marker header (every
|
|
1587
|
+
// plugin-bridge wall does) — surface it verbatim.
|
|
1588
|
+
const code = response.headers.get('x-zen-remote-error') ?? `http-${response.status}`
|
|
1589
|
+
const bodyText = await response.text().catch(() => '')
|
|
1590
|
+
throw new RelayError(code, bodyText === '' ? code : bodyText, response.status)
|
|
1591
|
+
}
|
|
1592
|
+
let tunnelId: string | undefined
|
|
1593
|
+
let upstreamClosed = false
|
|
1594
|
+
let pendingError: RelayError | undefined
|
|
1595
|
+
const reader = response.body.getReader()
|
|
1596
|
+
const decoder = new TextDecoder()
|
|
1597
|
+
let lineBuffer = ''
|
|
1598
|
+
const nextLine = async (): Promise<string | null> => {
|
|
1599
|
+
while (true) {
|
|
1600
|
+
const idx = lineBuffer.indexOf('\n')
|
|
1601
|
+
if (idx >= 0) {
|
|
1602
|
+
const line = lineBuffer.slice(0, idx)
|
|
1603
|
+
lineBuffer = lineBuffer.slice(idx + 1)
|
|
1604
|
+
return line
|
|
1605
|
+
}
|
|
1606
|
+
const { done, value } = await reader.read()
|
|
1607
|
+
if (done) {
|
|
1608
|
+
const tail = lineBuffer
|
|
1609
|
+
lineBuffer = ''
|
|
1610
|
+
return tail === '' ? null : tail
|
|
1611
|
+
}
|
|
1612
|
+
lineBuffer += decoder.decode(value, { stream: true })
|
|
1613
|
+
}
|
|
1614
|
+
}
|
|
1615
|
+
// The FIRST line must name the tunnel; anything else up front is a
|
|
1616
|
+
// protocol violation the caller sees as a refused open.
|
|
1617
|
+
const first = await nextLine()
|
|
1618
|
+
if (first !== null) {
|
|
1619
|
+
try {
|
|
1620
|
+
const parsed: unknown = JSON.parse(first)
|
|
1621
|
+
if (typeof parsed === 'object' && parsed !== null && (parsed as { type?: unknown }).type === 'open' &&
|
|
1622
|
+
typeof (parsed as { tunnel?: unknown }).tunnel === 'string') {
|
|
1623
|
+
tunnelId = (parsed as { tunnel: string }).tunnel
|
|
1624
|
+
}
|
|
1625
|
+
} catch {
|
|
1626
|
+
// Falls through to the refusal below.
|
|
1627
|
+
}
|
|
1628
|
+
}
|
|
1629
|
+
if (tunnelId === undefined) {
|
|
1630
|
+
const code = response.headers.get('x-zen-remote-error') ?? 'internal'
|
|
1631
|
+
throw new RelayError(code, `the tunnel open stream never named a tunnel`, response.status)
|
|
1632
|
+
}
|
|
1633
|
+
const frames: AsyncIterable<Uint8Array> = {
|
|
1634
|
+
[Symbol.asyncIterator]() {
|
|
1635
|
+
return {
|
|
1636
|
+
async next(): Promise<IteratorResult<Uint8Array>> {
|
|
1637
|
+
if (upstreamClosed) return { done: true, value: undefined }
|
|
1638
|
+
while (true) {
|
|
1639
|
+
const line = await nextLine()
|
|
1640
|
+
if (line === null) {
|
|
1641
|
+
upstreamClosed = true
|
|
1642
|
+
return { done: true, value: undefined }
|
|
1643
|
+
}
|
|
1644
|
+
let parsed: unknown
|
|
1645
|
+
try {
|
|
1646
|
+
parsed = JSON.parse(line)
|
|
1647
|
+
} catch {
|
|
1648
|
+
continue // a damaged line is skipped, like every NDJSON reader here
|
|
1649
|
+
}
|
|
1650
|
+
if (parsed === null || typeof parsed !== 'object') continue
|
|
1651
|
+
const frame = parsed as { type?: unknown; b64?: unknown; reason?: unknown }
|
|
1652
|
+
if (frame.type === 'data' && typeof frame.b64 === 'string' && frame.b64 !== '') {
|
|
1653
|
+
return { done: false, value: new Uint8Array(Buffer.from(frame.b64, 'base64')) }
|
|
1654
|
+
}
|
|
1655
|
+
if (frame.type === 'end') {
|
|
1656
|
+
upstreamClosed = true
|
|
1657
|
+
if (typeof frame.reason === 'string' && frame.reason === 'upstream-error') {
|
|
1658
|
+
pendingError = new RelayError('internal', 'the upstream tunnel socket failed')
|
|
1659
|
+
}
|
|
1660
|
+
return { done: true, value: undefined }
|
|
1661
|
+
}
|
|
1662
|
+
// ping and anything unrecognized: skipped.
|
|
1663
|
+
}
|
|
1664
|
+
},
|
|
1665
|
+
}
|
|
1666
|
+
},
|
|
1667
|
+
}
|
|
1668
|
+
void pendingError
|
|
1669
|
+
return { tunnel: tunnelId, frames }
|
|
1670
|
+
}
|
|
1671
|
+
|
|
1672
|
+
async function pluginTunnelSend(tunnel: string, seq: number, bytes: Uint8Array, signal?: AbortSignal): Promise<void> {
|
|
1673
|
+
const creds = requireCredentials()
|
|
1674
|
+
// The body IS the payload — raw bytes, not JSON. The requestInit shape
|
|
1675
|
+
// lives in exchangeRequest's url+init contract: headers hand-built here.
|
|
1676
|
+
await exchangeRequest(
|
|
1677
|
+
`${creds.url}${PLUGIN_TUNNEL_SEND_PATH}`,
|
|
1678
|
+
{
|
|
1679
|
+
method: 'POST',
|
|
1680
|
+
headers: {
|
|
1681
|
+
authorization: `Bearer ${creds.token}`,
|
|
1682
|
+
accept: 'application/json',
|
|
1683
|
+
'content-type': 'application/octet-stream',
|
|
1684
|
+
'x-zr-tunnel': tunnel,
|
|
1685
|
+
'x-zr-seq': String(seq),
|
|
1686
|
+
},
|
|
1687
|
+
// undici's BodyInit types reject ArrayBufferView here (the DOM face
|
|
1688
|
+
// wins in @types/node) — the cast carries the wire reality.
|
|
1689
|
+
body: new Uint8Array(bytes) as unknown as BodyInit,
|
|
1690
|
+
redirect: 'manual',
|
|
1691
|
+
},
|
|
1692
|
+
signal,
|
|
1693
|
+
true,
|
|
1694
|
+
{ timeoutMs: sizeBackedTimeoutMs(requestTimeoutMs, bytes.byteLength), timeoutSetsOffline: false },
|
|
1695
|
+
)
|
|
1696
|
+
}
|
|
1697
|
+
|
|
1698
|
+
async function pluginTunnelClose(tunnel: string, signal?: AbortSignal): Promise<void> {
|
|
1699
|
+
const creds = requireCredentials()
|
|
1700
|
+
await fetch(`${creds.url}${PLUGIN_TUNNEL_CLOSE_PATH}`, {
|
|
1701
|
+
method: 'POST',
|
|
1702
|
+
headers: {
|
|
1703
|
+
authorization: `Bearer ${creds.token}`,
|
|
1704
|
+
accept: 'application/json',
|
|
1705
|
+
'x-zr-tunnel': tunnel,
|
|
1706
|
+
},
|
|
1707
|
+
redirect: 'manual',
|
|
1708
|
+
signal,
|
|
1709
|
+
}).catch((error: unknown) => {
|
|
1710
|
+
throw new RelayError('offline', messageOf(error))
|
|
1711
|
+
})
|
|
1712
|
+
}
|
|
1713
|
+
|
|
1326
1714
|
function openStream(namespace: string, method: string, args: unknown, signal?: AbortSignal): AsyncIterable<unknown> {
|
|
1327
1715
|
// Eager, not generator-lazy: an unconfigured client fails at CALL time,
|
|
1328
1716
|
// exactly like connect/invoke, instead of hiding the error inside the
|
|
@@ -1527,5 +1915,9 @@ export function createRelayClient(options: CreateRelayClientOptions): RelayClien
|
|
|
1527
1915
|
openStream,
|
|
1528
1916
|
http,
|
|
1529
1917
|
upload,
|
|
1918
|
+
pluginRequest,
|
|
1919
|
+
pluginTunnelOpen,
|
|
1920
|
+
pluginTunnelSend,
|
|
1921
|
+
pluginTunnelClose,
|
|
1530
1922
|
}
|
|
1531
1923
|
}
|