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