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.
Files changed (118) hide show
  1. package/AGENTS.md +15 -1
  2. package/README.md +11 -5
  3. package/README.zh-CN.md +11 -5
  4. package/docs/adr/0001-plugin-bridge-forwards-plugin-routes.md +15 -0
  5. package/docs/adr/0002-relay-emulates-websocket.md +8 -0
  6. package/docs/interface.md +3 -1
  7. package/docs/plugin-bridge.md +140 -0
  8. package/docs/remote-access.en.md +57 -0
  9. package/docs/remote-access.md +63 -0
  10. package/lib/adapters/dsh-better-sidebar/index.js +144 -0
  11. package/lib/adapters/dsh-better-sidebar/index.js.map +1 -0
  12. package/lib/adapters/dsh-better-sidebar/methods.js +198 -0
  13. package/lib/adapters/dsh-better-sidebar/methods.js.map +1 -0
  14. package/lib/adapters/index.js +17 -0
  15. package/lib/adapters/index.js.map +1 -0
  16. package/lib/adapters/types.js +17 -0
  17. package/lib/adapters/types.js.map +1 -0
  18. package/lib/admin-routes.js +51 -0
  19. package/lib/admin-routes.js.map +1 -1
  20. package/lib/client-routes.js +18 -1
  21. package/lib/client-routes.js.map +1 -1
  22. package/lib/client.js +449 -45
  23. package/lib/config.js +27 -0
  24. package/lib/config.js.map +1 -1
  25. package/lib/http.js +10 -0
  26. package/lib/http.js.map +1 -1
  27. package/lib/index.js +246 -0
  28. package/lib/index.js.map +1 -1
  29. package/lib/intercept.js +5 -2
  30. package/lib/intercept.js.map +1 -1
  31. package/lib/plugin-adapter-core.js +385 -0
  32. package/lib/plugin-adapter-core.js.map +1 -0
  33. package/lib/plugin-bridge.js +318 -0
  34. package/lib/plugin-bridge.js.map +1 -0
  35. package/lib/plugin-loopback.js +207 -0
  36. package/lib/plugin-loopback.js.map +1 -0
  37. package/lib/plugin-route-intercept.js +1103 -0
  38. package/lib/plugin-route-intercept.js.map +1 -0
  39. package/lib/plugin-tunnel.js +416 -0
  40. package/lib/plugin-tunnel.js.map +1 -0
  41. package/lib/relay-client.js +340 -4
  42. package/lib/relay-client.js.map +1 -1
  43. package/lib/relay-server.js +665 -7
  44. package/lib/relay-server.js.map +1 -1
  45. package/lib/types/adapters/dsh-better-sidebar/index.d.ts +27 -0
  46. package/lib/types/adapters/dsh-better-sidebar/index.d.ts.map +1 -0
  47. package/lib/types/adapters/dsh-better-sidebar/methods.d.ts +51 -0
  48. package/lib/types/adapters/dsh-better-sidebar/methods.d.ts.map +1 -0
  49. package/lib/types/adapters/index.d.ts +17 -0
  50. package/lib/types/adapters/index.d.ts.map +1 -0
  51. package/lib/types/adapters/types.d.ts +42 -0
  52. package/lib/types/adapters/types.d.ts.map +1 -0
  53. package/lib/types/admin-routes.d.ts +16 -0
  54. package/lib/types/admin-routes.d.ts.map +1 -1
  55. package/lib/types/client/effects/remote-session.d.ts +0 -22
  56. package/lib/types/client/effects/remote-session.d.ts.map +1 -1
  57. package/lib/types/client/locales.d.ts +16 -0
  58. package/lib/types/client/locales.d.ts.map +1 -1
  59. package/lib/types/client/settings/SettingsSection.d.ts.map +1 -1
  60. package/lib/types/client/styles/remote-session.css.d.ts +1 -1
  61. package/lib/types/client/styles/remote-session.css.d.ts.map +1 -1
  62. package/lib/types/client-data/plugin-bridge.d.ts +79 -0
  63. package/lib/types/client-data/plugin-bridge.d.ts.map +1 -0
  64. package/lib/types/client-data/remote-status.d.ts +20 -0
  65. package/lib/types/client-data/remote-status.d.ts.map +1 -1
  66. package/lib/types/client-data/settings-form.d.ts +37 -1
  67. package/lib/types/client-data/settings-form.d.ts.map +1 -1
  68. package/lib/types/client-routes.d.ts +13 -0
  69. package/lib/types/client-routes.d.ts.map +1 -1
  70. package/lib/types/config.d.ts +8 -0
  71. package/lib/types/config.d.ts.map +1 -1
  72. package/lib/types/http.d.ts +7 -0
  73. package/lib/types/http.d.ts.map +1 -1
  74. package/lib/types/index.d.ts +4 -0
  75. package/lib/types/index.d.ts.map +1 -1
  76. package/lib/types/intercept.d.ts.map +1 -1
  77. package/lib/types/plugin-adapter-core.d.ts +263 -0
  78. package/lib/types/plugin-adapter-core.d.ts.map +1 -0
  79. package/lib/types/plugin-bridge.d.ts +187 -0
  80. package/lib/types/plugin-bridge.d.ts.map +1 -0
  81. package/lib/types/plugin-loopback.d.ts +104 -0
  82. package/lib/types/plugin-loopback.d.ts.map +1 -0
  83. package/lib/types/plugin-route-intercept.d.ts +114 -0
  84. package/lib/types/plugin-route-intercept.d.ts.map +1 -0
  85. package/lib/types/plugin-tunnel.d.ts +96 -0
  86. package/lib/types/plugin-tunnel.d.ts.map +1 -0
  87. package/lib/types/relay-client.d.ts +81 -1
  88. package/lib/types/relay-client.d.ts.map +1 -1
  89. package/lib/types/relay-server.d.ts +32 -1
  90. package/lib/types/relay-server.d.ts.map +1 -1
  91. package/llms.txt +1 -0
  92. package/package.json +2 -2
  93. package/scripts/check-plugin-bridge-ui.mjs +168 -0
  94. package/scripts/check-settings-form.mjs +3 -1
  95. package/src/adapters/dsh-better-sidebar/index.ts +144 -0
  96. package/src/adapters/dsh-better-sidebar/methods.ts +215 -0
  97. package/src/adapters/index.ts +19 -0
  98. package/src/adapters/types.ts +44 -0
  99. package/src/admin-routes.ts +64 -0
  100. package/src/client/effects/remote-session.ts +82 -10
  101. package/src/client/locales.ts +36 -0
  102. package/src/client/settings/SettingsSection.tsx +107 -3
  103. package/src/client/styles/remote-session.css.ts +70 -31
  104. package/src/client-data/plugin-bridge.ts +150 -0
  105. package/src/client-data/remote-status.ts +30 -0
  106. package/src/client-data/settings-form.ts +78 -0
  107. package/src/client-routes.ts +29 -1
  108. package/src/config.ts +32 -0
  109. package/src/http.ts +12 -0
  110. package/src/index.ts +252 -0
  111. package/src/intercept.ts +5 -2
  112. package/src/plugin-adapter-core.ts +530 -0
  113. package/src/plugin-bridge.ts +435 -0
  114. package/src/plugin-loopback.ts +299 -0
  115. package/src/plugin-route-intercept.ts +1193 -0
  116. package/src/plugin-tunnel.ts +470 -0
  117. package/src/relay-client.ts +413 -4
  118. package/src/relay-server.ts +687 -9
@@ -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 = { ...previous, serverName, ...(deviceName !== undefined ? { deviceName } : {}) }
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
  }