dsh-zen-remote 2.0.2 → 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 +63 -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/intercept.js +5 -2
- package/lib/intercept.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 +340 -4
- package/lib/relay-client.js.map +1 -1
- package/lib/relay-server.js +665 -7
- 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/intercept.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 +81 -1
- 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/intercept.ts +5 -2
- 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 +413 -4
- package/src/relay-server.ts +687 -9
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
|
/**
|
|
@@ -197,14 +211,31 @@ export class RelayError extends Error {
|
|
|
197
211
|
code: string
|
|
198
212
|
status?: number
|
|
199
213
|
reason?: string
|
|
214
|
+
/** The host error's sanitized `details` (invoke path): the UI maps
|
|
215
|
+
* `details.reason` codes like `MODEL_DOES_NOT_SUPPORT_IMAGES` onto a
|
|
216
|
+
* user-facing line — absent details stay undefined. */
|
|
217
|
+
details?: Record<string, unknown>
|
|
200
218
|
|
|
201
|
-
constructor(code: string, message?: string, httpStatus?: number, reason?: string) {
|
|
219
|
+
constructor(code: string, message?: string, httpStatus?: number, reason?: string, details?: Record<string, unknown>) {
|
|
202
220
|
super(message === undefined ? code : message)
|
|
203
221
|
this.name = 'RelayError'
|
|
204
222
|
this.code = code
|
|
205
223
|
if (httpStatus !== undefined) this.status = httpStatus
|
|
206
224
|
if (reason !== undefined) this.reason = reason
|
|
225
|
+
if (details !== undefined) this.details = details
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
/** The error body's structured `details`, when it is a record of scalars —
|
|
230
|
+
* the shape the server's own sanitizer emits. */
|
|
231
|
+
function detailsOf(error: Record<string, unknown>): Record<string, unknown> | undefined {
|
|
232
|
+
const raw = error.details
|
|
233
|
+
if (!isRecord(raw)) return undefined
|
|
234
|
+
const out: Record<string, unknown> = {}
|
|
235
|
+
for (const [key, value] of Object.entries(raw)) {
|
|
236
|
+
if (typeof value === 'string' || typeof value === 'number' || typeof value === 'boolean' || value === null) out[key] = value
|
|
207
237
|
}
|
|
238
|
+
return Object.keys(out).length > 0 ? out : undefined
|
|
208
239
|
}
|
|
209
240
|
|
|
210
241
|
export interface CreateRelayClientOptions {
|
|
@@ -333,6 +364,68 @@ export interface RelayClient {
|
|
|
333
364
|
* `Content-Length`, when it had one) and a timeout there never moves the
|
|
334
365
|
* connection state — the invoke route's rules (T31-fix). */
|
|
335
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
|
|
336
429
|
}
|
|
337
430
|
|
|
338
431
|
/** The inputs of one {@link RelayClient.upload} round-trip. */
|
|
@@ -782,6 +875,25 @@ export function createRelayClient(options: CreateRelayClientOptions): RelayClien
|
|
|
782
875
|
void pushDeviceName(trimmed)
|
|
783
876
|
}
|
|
784
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
|
+
|
|
785
897
|
/**
|
|
786
898
|
* One QUIET handshake re-run (T59): refreshes the two names while online —
|
|
787
899
|
* no state announcement, no ladder movement, failures silent (the next
|
|
@@ -822,7 +934,12 @@ export function createRelayClient(options: CreateRelayClientOptions): RelayClien
|
|
|
822
934
|
if (!isRecord(payload) || payload.ok !== true || payload.relayProtocol !== RELAY_PROTOCOL) return
|
|
823
935
|
// T59-fix: NAMES ONLY. serverId / dshVersion / fingerprints belong to the
|
|
824
936
|
// real handshake (the compat verdict is computed there, on both maps
|
|
825
|
-
// 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.
|
|
826
943
|
const previous = handshakeInfo
|
|
827
944
|
if (previous === undefined) return
|
|
828
945
|
const serverName = typeof payload.serverName === 'string' ? payload.serverName : previous.serverName
|
|
@@ -834,10 +951,27 @@ export function createRelayClient(options: CreateRelayClientOptions): RelayClien
|
|
|
834
951
|
const pushedDuringRefresh = deviceNamePushSeq !== pushSeqAtStart || deviceNamePushBusy || pendingDeviceName !== undefined
|
|
835
952
|
const deviceName = pushedDuringRefresh ? undefined : serverDeviceName
|
|
836
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
|
+
}
|
|
837
965
|
let changed = previous.serverName !== serverName
|
|
838
966
|
if (deviceName !== undefined && deviceName !== previous.deviceName) changed = true
|
|
967
|
+
if (plugins !== previous.plugins) changed = true
|
|
839
968
|
if (!changed) return
|
|
840
|
-
handshakeInfo = {
|
|
969
|
+
handshakeInfo = {
|
|
970
|
+
...previous,
|
|
971
|
+
serverName,
|
|
972
|
+
...(deviceName !== undefined ? { deviceName } : {}),
|
|
973
|
+
...(plugins !== previous.plugins ? { plugins } : {}),
|
|
974
|
+
}
|
|
841
975
|
notify()
|
|
842
976
|
}
|
|
843
977
|
|
|
@@ -948,7 +1082,7 @@ export function createRelayClient(options: CreateRelayClientOptions): RelayClien
|
|
|
948
1082
|
// 200 {ok:false} envelopes, 400/404/429 answers and anything else
|
|
949
1083
|
// unheard-of: the body's error code is the answer, the state is not
|
|
950
1084
|
// touched.
|
|
951
|
-
if (code !== undefined) return new RelayError(code, message, status)
|
|
1085
|
+
if (code !== undefined) return new RelayError(code, message, status, undefined, detailsOf(error))
|
|
952
1086
|
// The bad-gateway band carries no relay body of its own — it is what a
|
|
953
1087
|
// reverse proxy answers when the gateway behind it is down (nginx: an
|
|
954
1088
|
// HTML 502). That IS the offline case, and the e2e contract expects it.
|
|
@@ -1131,6 +1265,10 @@ export function createRelayClient(options: CreateRelayClientOptions): RelayClien
|
|
|
1131
1265
|
// speaks the field at all (an older server omits it and the client
|
|
1132
1266
|
// keeps whatever it had).
|
|
1133
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> } : {}),
|
|
1134
1272
|
}
|
|
1135
1273
|
// Interface compatibility (T42): compare the handshake's group map
|
|
1136
1274
|
// against this side's own, right here where both are fresh. A compute
|
|
@@ -1306,6 +1444,273 @@ export function createRelayClient(options: CreateRelayClientOptions): RelayClien
|
|
|
1306
1444
|
}
|
|
1307
1445
|
}
|
|
1308
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
|
+
|
|
1309
1714
|
function openStream(namespace: string, method: string, args: unknown, signal?: AbortSignal): AsyncIterable<unknown> {
|
|
1310
1715
|
// Eager, not generator-lazy: an unconfigured client fails at CALL time,
|
|
1311
1716
|
// exactly like connect/invoke, instead of hiding the error inside the
|
|
@@ -1510,5 +1915,9 @@ export function createRelayClient(options: CreateRelayClientOptions): RelayClien
|
|
|
1510
1915
|
openStream,
|
|
1511
1916
|
http,
|
|
1512
1917
|
upload,
|
|
1918
|
+
pluginRequest,
|
|
1919
|
+
pluginTunnelOpen,
|
|
1920
|
+
pluginTunnelSend,
|
|
1921
|
+
pluginTunnelClose,
|
|
1513
1922
|
}
|
|
1514
1923
|
}
|