dsh-zen-remote 1.1.16 → 2.0.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 (255) hide show
  1. package/AGENTS.md +54 -9
  2. package/README.md +249 -190
  3. package/README.zh-CN.md +247 -201
  4. package/assets/home.png +0 -0
  5. package/assets/info.png +0 -0
  6. package/assets/pairing.png +0 -0
  7. package/assets/session.png +0 -0
  8. package/assets/sheet.png +0 -0
  9. package/cordis.patch.yml +12 -11
  10. package/cordis.patch.yml.example +24 -9
  11. package/docs/interface.md +57 -2
  12. package/docs/remote-access.en.md +99 -58
  13. package/docs/remote-access.md +99 -42
  14. package/dsh-push.mjs +67 -54
  15. package/lan-gate.mjs +88 -20
  16. package/lib/activity.js +168 -0
  17. package/lib/activity.js.map +1 -0
  18. package/lib/admin-routes.js +526 -0
  19. package/lib/admin-routes.js.map +1 -0
  20. package/lib/client-pairing.js +224 -0
  21. package/lib/client-pairing.js.map +1 -0
  22. package/lib/client-routes.js +674 -0
  23. package/lib/client-routes.js.map +1 -0
  24. package/lib/client.js +10051 -4777
  25. package/lib/config.js +346 -0
  26. package/lib/config.js.map +1 -0
  27. package/lib/fetch-route-intercept.js +418 -0
  28. package/lib/fetch-route-intercept.js.map +1 -0
  29. package/lib/fingerprint.js +363 -0
  30. package/lib/fingerprint.js.map +1 -0
  31. package/lib/http.js +69 -0
  32. package/lib/http.js.map +1 -0
  33. package/lib/index.js +675 -54
  34. package/lib/index.js.map +1 -1
  35. package/lib/intercept-shape.js +81 -0
  36. package/lib/intercept-shape.js.map +1 -0
  37. package/lib/intercept.js +3040 -0
  38. package/lib/intercept.js.map +1 -0
  39. package/lib/lan-gate-server.cjs +532 -121
  40. package/lib/merge-streams.js +1165 -0
  41. package/lib/merge-streams.js.map +1 -0
  42. package/lib/relay-access.js +496 -0
  43. package/lib/relay-access.js.map +1 -0
  44. package/lib/relay-client.js +1303 -0
  45. package/lib/relay-client.js.map +1 -0
  46. package/lib/relay-filter.js +282 -0
  47. package/lib/relay-filter.js.map +1 -0
  48. package/lib/relay-server.js +1792 -0
  49. package/lib/relay-server.js.map +1 -0
  50. package/lib/restart-fields.js +34 -0
  51. package/lib/restart-fields.js.map +1 -0
  52. package/lib/restart-watcher.js +106 -0
  53. package/lib/restart-watcher.js.map +1 -0
  54. package/lib/session-reference.js +157 -0
  55. package/lib/session-reference.js.map +1 -0
  56. package/lib/share-export.js +16 -1
  57. package/lib/share-export.js.map +1 -1
  58. package/lib/share-ops.js +86 -0
  59. package/lib/share-ops.js.map +1 -0
  60. package/lib/share-store.js +294 -0
  61. package/lib/share-store.js.map +1 -0
  62. package/lib/types/activity.d.ts +99 -0
  63. package/lib/types/activity.d.ts.map +1 -0
  64. package/lib/types/admin-routes.d.ts +129 -0
  65. package/lib/types/admin-routes.d.ts.map +1 -0
  66. package/lib/types/client/MobileDrawerFooter.d.ts.map +1 -1
  67. package/lib/types/client/MobileHome.d.ts +1 -1
  68. package/lib/types/client/MobileHome.d.ts.map +1 -1
  69. package/lib/types/client/MobileHomeChips.d.ts +1 -1
  70. package/lib/types/client/MobileHomeChips.d.ts.map +1 -1
  71. package/lib/types/client/MobileSessionHeader.d.ts +13 -2
  72. package/lib/types/client/MobileSessionHeader.d.ts.map +1 -1
  73. package/lib/types/client/MobileSessionInfo.d.ts +20 -3
  74. package/lib/types/client/MobileSessionInfo.d.ts.map +1 -1
  75. package/lib/types/client/RemoteComposerBanner.d.ts +42 -0
  76. package/lib/types/client/RemoteComposerBanner.d.ts.map +1 -0
  77. package/lib/types/client/RemoteHeaderIcon.d.ts +37 -0
  78. package/lib/types/client/RemoteHeaderIcon.d.ts.map +1 -0
  79. package/lib/types/client/RemoteShareMenu.d.ts +28 -0
  80. package/lib/types/client/RemoteShareMenu.d.ts.map +1 -0
  81. package/lib/types/client/RemoteStatusIcon.d.ts +36 -0
  82. package/lib/types/client/RemoteStatusIcon.d.ts.map +1 -0
  83. package/lib/types/client/compat/desktop.d.ts +21 -0
  84. package/lib/types/client/compat/desktop.d.ts.map +1 -0
  85. package/lib/types/client/compat/types.d.ts +57 -0
  86. package/lib/types/client/compat/types.d.ts.map +1 -1
  87. package/lib/types/client/current-session-report.d.ts +37 -0
  88. package/lib/types/client/current-session-report.d.ts.map +1 -0
  89. package/lib/types/client/effects/header-status.d.ts.map +1 -1
  90. package/lib/types/client/effects/keyboard-guard.d.ts.map +1 -1
  91. package/lib/types/client/effects/permission-sheet.d.ts +39 -0
  92. package/lib/types/client/effects/permission-sheet.d.ts.map +1 -0
  93. package/lib/types/client/effects/remote-session.d.ts +25 -0
  94. package/lib/types/client/effects/remote-session.d.ts.map +1 -0
  95. package/lib/types/client/effects/turn-fold.d.ts +9 -0
  96. package/lib/types/client/effects/turn-fold.d.ts.map +1 -1
  97. package/lib/types/client/index.d.ts.map +1 -1
  98. package/lib/types/client/locales.d.ts +187 -2
  99. package/lib/types/client/locales.d.ts.map +1 -1
  100. package/lib/types/client/remote-fetch.d.ts +51 -0
  101. package/lib/types/client/remote-fetch.d.ts.map +1 -0
  102. package/lib/types/client/remote-group-expansion.d.ts +41 -0
  103. package/lib/types/client/remote-group-expansion.d.ts.map +1 -0
  104. package/lib/types/client/remote-share-css.d.ts +19 -0
  105. package/lib/types/client/remote-share-css.d.ts.map +1 -0
  106. package/lib/types/client/remote-share-register.d.ts +113 -0
  107. package/lib/types/client/remote-share-register.d.ts.map +1 -0
  108. package/lib/types/client/remote-status-css.d.ts +15 -0
  109. package/lib/types/client/remote-status-css.d.ts.map +1 -0
  110. package/lib/types/client/remote-status-register.d.ts +46 -0
  111. package/lib/types/client/remote-status-register.d.ts.map +1 -0
  112. package/lib/types/client/settings/SettingsSection.d.ts +46 -0
  113. package/lib/types/client/settings/SettingsSection.d.ts.map +1 -0
  114. package/lib/types/client/settings/register-settings.d.ts +58 -0
  115. package/lib/types/client/settings/register-settings.d.ts.map +1 -0
  116. package/lib/types/client/settings/settings-css.d.ts +10 -0
  117. package/lib/types/client/settings/settings-css.d.ts.map +1 -0
  118. package/lib/types/client/share/share-preview.d.ts.map +1 -1
  119. package/lib/types/client/styles/composer.css.d.ts +1 -1
  120. package/lib/types/client/styles/composer.css.d.ts.map +1 -1
  121. package/lib/types/client/styles/header.css.d.ts +1 -1
  122. package/lib/types/client/styles/header.css.d.ts.map +1 -1
  123. package/lib/types/client/styles/info.css.d.ts +1 -1
  124. package/lib/types/client/styles/info.css.d.ts.map +1 -1
  125. package/lib/types/client/styles/remote-session.css.d.ts +2 -0
  126. package/lib/types/client/styles/remote-session.css.d.ts.map +1 -0
  127. package/lib/types/client-data/current-session.d.ts +41 -0
  128. package/lib/types/client-data/current-session.d.ts.map +1 -0
  129. package/lib/types/client-data/remote-group-expansion.d.ts +76 -0
  130. package/lib/types/client-data/remote-group-expansion.d.ts.map +1 -0
  131. package/lib/types/client-data/remote-status.d.ts +148 -0
  132. package/lib/types/client-data/remote-status.d.ts.map +1 -0
  133. package/lib/types/client-data/settings-form.d.ts +774 -0
  134. package/lib/types/client-data/settings-form.d.ts.map +1 -0
  135. package/lib/types/client-data/shares.d.ts +211 -0
  136. package/lib/types/client-data/shares.d.ts.map +1 -0
  137. package/lib/types/client-pairing.d.ts +114 -0
  138. package/lib/types/client-pairing.d.ts.map +1 -0
  139. package/lib/types/client-routes.d.ts +112 -0
  140. package/lib/types/client-routes.d.ts.map +1 -0
  141. package/lib/types/config.d.ts +200 -0
  142. package/lib/types/config.d.ts.map +1 -0
  143. package/lib/types/fetch-route-intercept.d.ts +156 -0
  144. package/lib/types/fetch-route-intercept.d.ts.map +1 -0
  145. package/lib/types/fingerprint.d.ts +164 -0
  146. package/lib/types/fingerprint.d.ts.map +1 -0
  147. package/lib/types/http.d.ts +41 -0
  148. package/lib/types/http.d.ts.map +1 -0
  149. package/lib/types/index.d.ts +56 -24
  150. package/lib/types/index.d.ts.map +1 -1
  151. package/lib/types/intercept-shape.d.ts +32 -0
  152. package/lib/types/intercept-shape.d.ts.map +1 -0
  153. package/lib/types/intercept.d.ts +432 -0
  154. package/lib/types/intercept.d.ts.map +1 -0
  155. package/lib/types/merge-streams.d.ts +393 -0
  156. package/lib/types/merge-streams.d.ts.map +1 -0
  157. package/lib/types/relay-access.d.ts +310 -0
  158. package/lib/types/relay-access.d.ts.map +1 -0
  159. package/lib/types/relay-client.d.ts +274 -0
  160. package/lib/types/relay-client.d.ts.map +1 -0
  161. package/lib/types/relay-filter.d.ts +103 -0
  162. package/lib/types/relay-filter.d.ts.map +1 -0
  163. package/lib/types/relay-server.d.ts +208 -0
  164. package/lib/types/relay-server.d.ts.map +1 -0
  165. package/lib/types/restart-fields.d.ts +22 -0
  166. package/lib/types/restart-fields.d.ts.map +1 -0
  167. package/lib/types/restart-watcher.d.ts +83 -0
  168. package/lib/types/restart-watcher.d.ts.map +1 -0
  169. package/lib/types/session-reference.d.ts +66 -0
  170. package/lib/types/session-reference.d.ts.map +1 -0
  171. package/lib/types/share-export.d.ts +14 -1
  172. package/lib/types/share-export.d.ts.map +1 -1
  173. package/lib/types/share-ops.d.ts +79 -0
  174. package/lib/types/share-ops.d.ts.map +1 -0
  175. package/lib/types/share-store.d.ts +87 -0
  176. package/lib/types/share-store.d.ts.map +1 -0
  177. package/lib/types/virtual-id.d.ts +38 -0
  178. package/lib/types/virtual-id.d.ts.map +1 -0
  179. package/lib/virtual-id.js +52 -0
  180. package/lib/virtual-id.js.map +1 -0
  181. package/llms.txt +17 -14
  182. package/package.json +60 -50
  183. package/scripts/build-client.mjs +5 -3
  184. package/scripts/check-host-shadow.mjs +19 -2
  185. package/scripts/check-settings-form.mjs +933 -0
  186. package/scripts/check-shares.mjs +397 -0
  187. package/scripts/check-upload-endpoint.mjs +59 -3
  188. package/scripts/sync-doc-version.mjs +8 -15
  189. package/scripts/verify-fingerprint-live.mjs +111 -0
  190. package/src/activity.ts +218 -0
  191. package/src/admin-routes.ts +627 -0
  192. package/src/client/MobileAttachButton.tsx +2 -2
  193. package/src/client/MobileAttachChips.tsx +3 -3
  194. package/src/client/MobileDrawerFooter.tsx +7 -4
  195. package/src/client/MobileHome.tsx +20 -16
  196. package/src/client/MobileHomeChips.tsx +16 -16
  197. package/src/client/MobileNavOverlay.tsx +2 -2
  198. package/src/client/MobileNavToggle.tsx +3 -3
  199. package/src/client/MobileSessionHeader.tsx +63 -42
  200. package/src/client/MobileSessionInfo.tsx +118 -17
  201. package/src/client/RemoteComposerBanner.tsx +76 -0
  202. package/src/client/RemoteHeaderIcon.tsx +103 -0
  203. package/src/client/RemoteShareMenu.tsx +57 -0
  204. package/src/client/RemoteStatusIcon.tsx +91 -0
  205. package/src/client/compat/desktop.ts +22 -0
  206. package/src/client/compat/types.ts +62 -0
  207. package/src/client/current-session-report.ts +164 -0
  208. package/src/client/effects/header-status.ts +21 -17
  209. package/src/client/effects/keyboard-guard.ts +65 -1
  210. package/src/client/effects/permission-sheet.ts +149 -0
  211. package/src/client/effects/remote-session.ts +68 -0
  212. package/src/client/effects/turn-fold.ts +52 -0
  213. package/src/client/index.tsx +151 -36
  214. package/src/client/locales.ts +396 -2
  215. package/src/client/remote-fetch.ts +125 -0
  216. package/src/client/remote-group-expansion.ts +190 -0
  217. package/src/client/remote-share-css.ts +54 -0
  218. package/src/client/remote-share-register.ts +244 -0
  219. package/src/client/remote-status-css.ts +64 -0
  220. package/src/client/remote-status-register.ts +107 -0
  221. package/src/client/settings/SettingsSection.tsx +1229 -0
  222. package/src/client/settings/register-settings.ts +116 -0
  223. package/src/client/settings/settings-css.ts +236 -0
  224. package/src/client/share/share-preview.tsx +6 -3
  225. package/src/client/share/share-sheet.tsx +3 -3
  226. package/src/client/styles/composer.css.ts +119 -26
  227. package/src/client/styles/header.css.ts +7 -7
  228. package/src/client/styles/info.css.ts +74 -0
  229. package/src/client/styles/remote-session.css.ts +251 -0
  230. package/src/client-data/current-session.ts +57 -0
  231. package/src/client-data/remote-group-expansion.ts +145 -0
  232. package/src/client-data/remote-status.ts +374 -0
  233. package/src/client-data/settings-form.ts +1371 -0
  234. package/src/client-data/shares.ts +467 -0
  235. package/src/client-pairing.ts +244 -0
  236. package/src/client-routes.ts +732 -0
  237. package/src/config.ts +447 -0
  238. package/src/fetch-route-intercept.ts +510 -0
  239. package/src/fingerprint.ts +408 -0
  240. package/src/http.ts +68 -0
  241. package/src/index.ts +719 -62
  242. package/src/intercept-shape.ts +87 -0
  243. package/src/intercept.ts +3362 -0
  244. package/src/merge-streams.ts +1272 -0
  245. package/src/relay-access.ts +669 -0
  246. package/src/relay-client.ts +1514 -0
  247. package/src/relay-filter.ts +291 -0
  248. package/src/relay-server.ts +2025 -0
  249. package/src/restart-fields.ts +36 -0
  250. package/src/restart-watcher.ts +140 -0
  251. package/src/session-reference.ts +156 -0
  252. package/src/share-export.ts +26 -1
  253. package/src/share-ops.ts +114 -0
  254. package/src/share-store.ts +373 -0
  255. package/src/virtual-id.ts +59 -0
@@ -0,0 +1,1303 @@
1
+ /**
2
+ * Relay client for the sub-client half (T23a): the `client`-role side of the
3
+ * 2.0.0 relay protocol. A DSH process on another machine pairs with a relay
4
+ * server's gateway (Bearer device token, T16) and reaches the server's
5
+ * shared sessions through `POST relay/v1/handshake|invoke|stream` — this
6
+ * module speaks that wire from the far end, with no dependencies beyond the
7
+ * Node 18+ standard library and the global `fetch`.
8
+ *
9
+ * Every call re-reads the credentials through the injected getters, so a
10
+ * re-pair in the settings page applies to the NEXT request without touching
11
+ * this module (the same volatile-row discipline src/client-routes.ts keeps).
12
+ * Missing credentials never touch the network: the client reports
13
+ * `unpaired` and every entry point throws immediately.
14
+ *
15
+ * Two wire details are load-bearing. `accept: application/json` — the
16
+ * gateway answers its pairing wall as a JSON 401 `{reason:'unpaired'}` only
17
+ * to requests that accept JSON; without the header a revoked token would
18
+ * draw the HTML pairing page and be unclassifiable. `redirect: 'manual'` —
19
+ * a wrong or hijacked server must not walk the pairing token through a
20
+ * redirect chain (the same rule src/client-routes.ts probes with).
21
+ *
22
+ * State moves only where the contract says so: `offline` on transport
23
+ * failure, `revoked` on the gateway's unpaired wall, `incompatible` on the
24
+ * relay-only wall or a handshake protocol mismatch, and back to `online` on
25
+ * any success envelope. Everything else — the per-call refusals
26
+ * (`not-shared` / `no-session` / `forbidden-method`), a 200 `{ok:false}`
27
+ * envelope, the gateway's own `relay-unauthorized` — is an ANSWER about the
28
+ * call, not about the link, and leaves the state alone.
29
+ *
30
+ * Since T43 the client also reconnects on its own: an `offline` client walks
31
+ * a 1s → 2s → 5s → 10s → 30s ladder (0–20% jitter per wait) until a success
32
+ * lifts it back to `online`; `unpaired`, `revoked` and `incompatible` never
33
+ * reconnect on their own — they need a user action, which arrives as
34
+ * `credentialsChanged()` (or a fresh `connect()`). The wait is observable as
35
+ * `nextRetryAt`, the last failure code as `lastError`; both carry no
36
+ * credential material.
37
+ *
38
+ * Since T42 the client also judges INTERFACE compatibility: when the wiring
39
+ * injects `computeOwnFingerprints`, every completed handshake is followed by
40
+ * a group-by-group comparison of the server's `fingerprints` map against the
41
+ * locally computed one, stored as `compat` ({@link RelayCompatVerdict}).
42
+ * Groups either side could not compute land in `unavailable` — never in
43
+ * `different` — so a partial view stays silent. The verdict is read live by
44
+ * the status route; listeners additionally hear about it through the same
45
+ * notification channel the state changes use.
46
+ */
47
+ import { createHash } from 'node:crypto';
48
+ import { compareFingerprints } from './fingerprint.js';
49
+ /** The one protocol version this client speaks; the handshake verifies it. */
50
+ const RELAY_PROTOCOL = 1;
51
+ const HANDSHAKE_PATH = '/_dsh/zen-remote/relay/v1/handshake';
52
+ const INVOKE_PATH = '/_dsh/zen-remote/relay/v1/invoke';
53
+ const STREAM_PATH = '/_dsh/zen-remote/relay/v1/stream';
54
+ const UNSHARE_PATH = '/_dsh/zen-remote/relay/v1/unshare';
55
+ const EVENT_RESULT_PATH = '/_dsh/zen-remote/relay/v1/event-result';
56
+ const HTTP_PATH = '/_dsh/zen-remote/relay/v1/http';
57
+ const UPLOAD_PATH = '/_dsh/zen-remote/relay/v1/upload';
58
+ /** T59: a desktop-client device renaming ITSELF in the gateway's table. */
59
+ const DEVICE_NAME_PATH = '/_dsh/zen-remote/relay/v1/device/name';
60
+ /** How long a stream may stay line-silent before it is judged dead. */
61
+ const DEFAULT_IDLE_TIMEOUT_MS = 45_000;
62
+ /** How long one request/response round-trip may take in full. */
63
+ const DEFAULT_REQUEST_TIMEOUT_MS = 15_000;
64
+ /**
65
+ * The invoke round-trip budget (T31-fix): the configured request timeout
66
+ * plus 2 s per MiB of request body, capped at five minutes. The large
67
+ * bodies are the prompt routes' INLINE images — `session/prompt` /
68
+ * `subagents/prompt` content blocks carry base64 `image` data, one image up
69
+ * to 20 MiB by default (`maxImageBytes`, dsh-attachment-local) or ~27 MiB
70
+ * encoded — so a full-size picture over a slow link gets minutes, not the
71
+ * base seconds. The cap never lands below the configured base timeout.
72
+ */
73
+ const INVOKE_TIMEOUT_PER_MIB_MS = 2_000;
74
+ const INVOKE_TIMEOUT_CAP_MS = 300_000;
75
+ /**
76
+ * How often an ONLINE client re-runs the handshake purely to refresh the two
77
+ * names (T59): the server's display name and this device's name in its
78
+ * table. The stream heartbeat covers the server name mid-stream, but a
79
+ * device-side rename (an admin edit in the gateway's list) only reaches the
80
+ * server→client direction when THIS side sends a request — the handshake's
81
+ * marking header is always the gateway's freshest record. Deliberately quiet:
82
+ * a failed refresh changes nothing (the heartbeat/idle clocks judge the
83
+ * link), and the state never leaves `online`.
84
+ */
85
+ const INFO_REFRESH_MS = 30_000;
86
+ /**
87
+ * The budget one size-backed exchange gets (invoke since T31-fix, upload
88
+ * since T51): whole MiB only (floored), so a small call keeps the plain
89
+ * base budget. The upload's size is the DECLARED `Content-Length` of the
90
+ * local request when it carried one; an unknown size (a chunked body has no
91
+ * length header, and this process's undici forbids hand-set length headers)
92
+ * gets the full cap — the honest budget for an upload whose end is not in
93
+ * sight.
94
+ */
95
+ function sizeBackedTimeoutMs(requestTimeoutMs, bytes) {
96
+ const mib = bytes === undefined ? Number.POSITIVE_INFINITY : Math.floor(bytes / (1024 * 1024));
97
+ return Math.min(requestTimeoutMs + INVOKE_TIMEOUT_PER_MIB_MS * mib, Math.max(requestTimeoutMs, INVOKE_TIMEOUT_CAP_MS));
98
+ }
99
+ /** The budget one invoke exchange gets, from its serialized body size. */
100
+ function invokeTimeoutMs(requestTimeoutMs, bodyJson) {
101
+ return sizeBackedTimeoutMs(requestTimeoutMs, Buffer.byteLength(bodyJson, 'utf8'));
102
+ }
103
+ /** 0 or less disables the periodic refresh entirely (tests that count
104
+ * handshakes inject 0; the T59 refresh test injects the real cadence). */
105
+ function infoRefreshDelay(options) {
106
+ return options.infoRefreshMs ?? INFO_REFRESH_MS;
107
+ }
108
+ /**
109
+ * The automatic reconnect ladder (T43): after the client lands `offline` it
110
+ * retries after 1s, 2s, 5s, 10s, 30s — and then every 30s until the link
111
+ * comes back. Each wait gains 0–20% of random jitter so a fleet of clients
112
+ * that lost the same server does not retry in lockstep. A success back to
113
+ * `online` resets the ladder to the first step.
114
+ */
115
+ const RETRY_STEPS_MS = [1_000, 2_000, 5_000, 10_000, 30_000];
116
+ /** Upper bound of the jitter fraction added to one retry delay (0–20%). */
117
+ const RETRY_JITTER = 0.2;
118
+ /**
119
+ * The default clock: real time, `unref()`ed timers, `Math.random`. Exported
120
+ * so the interceptor's own waits (the merged-stream reopen delays) run on
121
+ * the SAME clock face — one injectable seam for tests instead of two.
122
+ */
123
+ export const defaultClock = {
124
+ now: () => Date.now(),
125
+ setTimeout: (fn, ms) => {
126
+ const handle = setTimeout(fn, ms);
127
+ if (typeof handle.unref === 'function')
128
+ handle.unref();
129
+ return handle;
130
+ },
131
+ clearTimeout: (handle) => clearTimeout(handle),
132
+ random: () => Math.random(),
133
+ };
134
+ /**
135
+ * One relay failure. `code` is the mapped reason (the contract's state
136
+ * vocabulary, a server refusal like `not-shared`, or a DSH error code from
137
+ * a 200 `{ok:false}` envelope); `status` carries the HTTP status when a
138
+ * response existed. `reason` is the OPTIONAL structured detail some error
139
+ * frames carry beside the message — today only the server's `unshared`
140
+ * stream-closure frame, whose `reason` ('manual' | 'client' | 'idle', T34)
141
+ * the interceptor's closed-session display keys on. Fields are assigned in
142
+ * the constructor body rather than declared as parameter properties: Node's
143
+ * strip-only type mode rejects that syntax (the same rule as UploadError in
144
+ * index.ts).
145
+ */
146
+ export class RelayError extends Error {
147
+ code;
148
+ status;
149
+ reason;
150
+ constructor(code, message, httpStatus, reason) {
151
+ super(message === undefined ? code : message);
152
+ this.name = 'RelayError';
153
+ this.code = code;
154
+ if (httpStatus !== undefined)
155
+ this.status = httpStatus;
156
+ if (reason !== undefined)
157
+ this.reason = reason;
158
+ }
159
+ }
160
+ /**
161
+ * The credential digest recorded beside a completed handshake (T23a-fix):
162
+ * sha256 over `url + "\n" + token`, first 16 hex chars. Enough to detect a
163
+ * changed address or a re-pair, without keeping — or ever exposing — a
164
+ * plaintext copy of either value.
165
+ */
166
+ export function relayCredentialsDigest(serverUrl, token) {
167
+ return createHash('sha256').update(`${serverUrl}\n${token}`).digest('hex').slice(0, 16);
168
+ }
169
+ function isRecord(value) {
170
+ return typeof value === 'object' && value !== null && !Array.isArray(value);
171
+ }
172
+ /**
173
+ * Whether the loader's `loader/volatile-update` announcement names the
174
+ * device's own field (T59-fix): the announcement rides EVERY volatile
175
+ * commit — an unrelated knob, or the settings page's own follow write — and
176
+ * only a commit that actually moved `serverName` may queue a push. The
177
+ * paths are the loader's changed-field lists (`[['serverName'], …]`).
178
+ */
179
+ export function volatileUpdateTouchesServerName(paths) {
180
+ return Array.isArray(paths) && paths.some((entry) => Array.isArray(entry) && entry[0] === 'serverName');
181
+ }
182
+ function messageOf(error) {
183
+ return error instanceof Error ? error.message : String(error);
184
+ }
185
+ /**
186
+ * One response body, parsed leniently. Reading the text is allowed to
187
+ * REJECT (a connection cut mid-body is a transport failure the caller maps
188
+ * to `offline`); only the JSON step is lenient — an HTML wall or any other
189
+ * non-JSON body becomes `undefined`, which the classifier then treats as
190
+ * "no usable body".
191
+ */
192
+ async function readPayload(response) {
193
+ const text = await response.text();
194
+ if (text === '')
195
+ return undefined;
196
+ try {
197
+ return JSON.parse(text);
198
+ }
199
+ catch {
200
+ return undefined;
201
+ }
202
+ }
203
+ /** A string field of an info line, or undefined — absent, blank or not a string. */
204
+ function infoString(value) {
205
+ return typeof value === 'string' && value !== '' ? value : undefined;
206
+ }
207
+ function classifyLine(line) {
208
+ let parsed;
209
+ try {
210
+ parsed = JSON.parse(line);
211
+ }
212
+ catch {
213
+ return { kind: 'none' }; // a damaged line is skipped, never fatal
214
+ }
215
+ if (!isRecord(parsed))
216
+ return { kind: 'none' };
217
+ if (parsed.type === 'frame' && 'frame' in parsed)
218
+ return { kind: 'frame', frame: parsed.frame };
219
+ if (parsed.type === 'end')
220
+ return { kind: 'end' };
221
+ if (parsed.type === 'error') {
222
+ const detail = isRecord(parsed.error) ? parsed.error : {};
223
+ const code = typeof detail.code === 'string' && detail.code !== '' ? detail.code : 'internal';
224
+ const message = typeof detail.message === 'string' ? detail.message : undefined;
225
+ // The structured detail beside the message (T34): today only the
226
+ // `unshared` frame carries one. An absent or non-string reason stays
227
+ // undefined — the consumer degrades to its own default.
228
+ const reason = typeof detail.reason === 'string' ? detail.reason : undefined;
229
+ return { kind: 'error', error: new RelayError(code, message, undefined, reason) };
230
+ }
231
+ // T59: a ping may carry the names as of NOW (`serverName` — the server's
232
+ // display name; `deviceName` — this device's name in its table). An old
233
+ // server's bare `{"type":"ping"}` carries neither and stays `none`.
234
+ if (parsed.type === 'ping') {
235
+ const serverName = infoString(parsed.serverName);
236
+ const deviceName = infoString(parsed.deviceName);
237
+ if (serverName !== undefined || deviceName !== undefined)
238
+ return { kind: 'info', serverName, deviceName };
239
+ }
240
+ // Any shape the protocol does not define — ignored.
241
+ return { kind: 'none' };
242
+ }
243
+ /**
244
+ * Build one relay client. Pure state machine + fetch plumbing; the row is
245
+ * only ever seen through the two getters.
246
+ */
247
+ export function createRelayClient(options) {
248
+ const idleTimeoutMs = options.idleTimeoutMs ?? DEFAULT_IDLE_TIMEOUT_MS;
249
+ const requestTimeoutMs = options.requestTimeoutMs ?? DEFAULT_REQUEST_TIMEOUT_MS;
250
+ const fetchImpl = options.fetchImpl ?? ((url, init) => fetch(url, init));
251
+ const clock = options.clock ?? defaultClock;
252
+ let state = 'unpaired';
253
+ let handshakeInfo;
254
+ let compat;
255
+ let lastHandshakeDigest;
256
+ let connectInFlight;
257
+ // T43 reconnect machinery: the armed timer handle, how many consecutive
258
+ // attempts have failed (the ladder index), when the armed timer fires, the
259
+ // code of the most recent failure, and whether the row teardown stopped
260
+ // the machinery.
261
+ let retryTimer;
262
+ let retryAttempt = 0;
263
+ let nextRetryAt = null;
264
+ let lastError;
265
+ let stopped = false;
266
+ // Digest of the credentials the machinery last SAW — the change detector
267
+ // behind credentialsChanged().
268
+ const initialCredentials = currentCredentials();
269
+ let credentialsDigest = initialCredentials === undefined ? undefined : relayCredentialsDigest(initialCredentials.url, initialCredentials.token);
270
+ const listeners = new Set();
271
+ /** One broadcast of the CURRENT state to every subscriber; a throwing
272
+ * listener never blocks the rest. State transitions are the only trigger —
273
+ * including the handshake's connecting→online pair, which is how the T42
274
+ * compat verdict (stored just before `online`) reaches the settings
275
+ * surface: woken listeners read the getter fresh. */
276
+ function notify() {
277
+ for (const listener of [...listeners]) {
278
+ try {
279
+ listener(state);
280
+ }
281
+ catch {
282
+ // One broken listener never blocks the rest.
283
+ }
284
+ }
285
+ }
286
+ function setState(next) {
287
+ if (state === next)
288
+ return;
289
+ state = next;
290
+ // The reconnect hooks ride the transitions (T43): landing offline arms
291
+ // the next wait; online resets the ladder; the three user-action states
292
+ // never reconnect and drop any pending wait; an attempt start leaves the
293
+ // ladder alone but the wait display is fireRetry's business.
294
+ if (next === 'offline')
295
+ scheduleRetry();
296
+ else if (next === 'online') {
297
+ resetRetry();
298
+ // T59: a server confirmation retires the local push authority (the
299
+ // handshake/stream/invoke that just landed saw the table as it is),
300
+ // the periodic name refresh arms, and a rename queued while offline
301
+ // goes out now.
302
+ deviceNameAuthoritative = false;
303
+ armInfoRefresh();
304
+ flushPendingDeviceName();
305
+ }
306
+ else if (next === 'unpaired' || next === 'revoked' || next === 'incompatible')
307
+ cancelRetry();
308
+ // A compat verdict describes the server the CURRENT credentials pointed
309
+ // at. Unpairing (or a revocation — the same "this server is gone" wall)
310
+ // invalidates that answer, so the verdict goes with the link (T42-fix).
311
+ if (next === 'unpaired' || next === 'revoked')
312
+ compat = undefined;
313
+ notify();
314
+ }
315
+ /**
316
+ * The live credentials, or undefined — the requireCredentials logic
317
+ * without the announcement, shared with credentialsChanged's change
318
+ * detection.
319
+ */
320
+ function currentCredentials() {
321
+ const url = options.getServerUrl();
322
+ const token = options.getToken();
323
+ if (url === undefined || url.trim() === '' || token === undefined || token === '')
324
+ return undefined;
325
+ // The getter contract delivers a normalized address; stripping stray
326
+ // trailing slashes anyway keeps a hand-edited row from silently producing
327
+ // `…/relay//v1/…`, which no route would ever match.
328
+ return { url: url.trim().replace(/\/+$/u, ''), token };
329
+ }
330
+ /**
331
+ * The live credentials, or the unpaired failure. Never a request without
332
+ * both halves — and the unpaired state is (re)announced, so a row whose
333
+ * address or token was cleared later shows up as unpaired too.
334
+ */
335
+ function requireCredentials() {
336
+ const creds = currentCredentials();
337
+ if (creds === undefined) {
338
+ setState('unpaired');
339
+ throw new RelayError('unpaired', 'no server address or pairing token is configured');
340
+ }
341
+ credentialsDigest = relayCredentialsDigest(creds.url, creds.token);
342
+ return creds;
343
+ }
344
+ // ---- the reconnect ladder (T43) ------------------------------------------
345
+ function cancelRetry() {
346
+ if (retryTimer !== undefined) {
347
+ clock.clearTimeout(retryTimer);
348
+ retryTimer = undefined;
349
+ }
350
+ retryAttempt = 0;
351
+ nextRetryAt = null;
352
+ }
353
+ function resetRetry() {
354
+ cancelRetry();
355
+ lastError = undefined;
356
+ }
357
+ function scheduleRetry() {
358
+ // One wait at a time — a second offline entry while a timer is armed
359
+ // (e.g. a status-route connect failing between two ticks) leaves the
360
+ // armed timer in charge.
361
+ if (stopped || retryTimer !== undefined)
362
+ return;
363
+ const base = RETRY_STEPS_MS[Math.min(retryAttempt, RETRY_STEPS_MS.length - 1)];
364
+ const delay = Math.round(base * (1 + clock.random() * RETRY_JITTER));
365
+ retryAttempt += 1;
366
+ nextRetryAt = clock.now() + delay;
367
+ retryTimer = clock.setTimeout(fireRetry, delay);
368
+ }
369
+ function fireRetry() {
370
+ retryTimer = undefined;
371
+ nextRetryAt = null;
372
+ if (stopped || state !== 'offline')
373
+ return;
374
+ // The attempt's own outcome drives the machine: success → online (ladder
375
+ // reset), a still-offline failure → scheduleRetry armed the next step,
376
+ // a wall (revoked/incompatible/unpaired) → cancelRetry ran.
377
+ void connect().catch(() => { });
378
+ }
379
+ /**
380
+ * The error-code vocabulary the diagnostics readout accepts (T43-fix):
381
+ * short, alphanumeric with / _ - separators. A server that answers a
382
+ * "code" quoting URLs or anything stranger does not get to write it into
383
+ * the client's state — it degrades to `unexpected`.
384
+ */
385
+ function sanitizeCode(code) {
386
+ return /^[a-z0-9/_-]{1,64}$/i.test(code) ? code : 'unexpected';
387
+ }
388
+ /** Record the failing call's code for the diagnostics surface (the CODE
389
+ * only — messages quote URLs, and codes never carry credentials). */
390
+ function noteFailure(code) {
391
+ lastError = sanitizeCode(code);
392
+ }
393
+ function subscribe(listener) {
394
+ listeners.add(listener);
395
+ return () => {
396
+ listeners.delete(listener);
397
+ };
398
+ }
399
+ // ---- the T59 device-name sync ----------------------------------------------
400
+ /**
401
+ * True while THIS side's `deviceName` is the authority: a rename this
402
+ * client pushed that the gateway just answered OK to. In-flight stream
403
+ * heartbeats still carry the OLD name (their marking header was written
404
+ * when the stream opened), and without this flag those pings would pull
405
+ * `handshakeInfo` back to the stale name — and the settings page would
406
+ * then "follow" it straight over the user's fresh save. The next server
407
+ * confirmation (handshake or refresh) retires the flag: the answer there
408
+ * IS the table's current record.
409
+ */
410
+ let deviceNameAuthoritative = false;
411
+ /** A rename waiting for the link: queued while offline (or before the
412
+ * first handshake), flushed once `online` lands. */
413
+ let pendingDeviceName;
414
+ // T59-fix: a push that is IN FLIGHT right now, plus a sequence counter
415
+ // bumped at every push start AND settle. An info-refresh answer that
416
+ // overlapped a push (seq moved, or a push is still queued) carries the
417
+ // PRE-push record and must not win — see {@link refreshInfo}.
418
+ let deviceNamePushBusy = false;
419
+ let deviceNamePushSeq = 0;
420
+ /**
421
+ * Whether a locally-pushed rename is queued or in flight (T59-fix). The
422
+ * status route answers an EMPTY `deviceName` while this is true: an
423
+ * in-flight status answer could still carry the pre-push record, and a
424
+ * page that followed it would bounce the user's fresh save straight back
425
+ * to the old name.
426
+ */
427
+ function deviceNameSyncing() {
428
+ return deviceNamePushBusy || pendingDeviceName !== undefined;
429
+ }
430
+ /** The periodic info-refresh's armed timer (see {@link INFO_REFRESH_MS}). */
431
+ let infoTimer;
432
+ /** Fold one heartbeat's names into the stored handshake (T59). */
433
+ function applyInfoDelta(serverName, deviceName) {
434
+ if (handshakeInfo === undefined)
435
+ return;
436
+ const next = { ...handshakeInfo };
437
+ let changed = false;
438
+ if (serverName !== undefined && serverName !== next.serverName) {
439
+ next.serverName = serverName;
440
+ changed = true;
441
+ }
442
+ // A locally-pushed rename outranks the stream heartbeat until the server
443
+ // confirms it — see {@link deviceNameAuthoritative}; a push IN FLIGHT is
444
+ // equally authoritative for the same reason (T59-fix): the heartbeat's
445
+ // name was stamped before our rename reached the table.
446
+ if (deviceName !== undefined && deviceName !== next.deviceName && !deviceNameAuthoritative && !deviceNamePushBusy) {
447
+ next.deviceName = deviceName;
448
+ changed = true;
449
+ }
450
+ if (!changed)
451
+ return;
452
+ handshakeInfo = next;
453
+ // The state itself did not move, but the identity did: subscribers (the
454
+ // interceptor's rename path above all) must see the new names. The
455
+ // current-state broadcast is exactly that — no reconnect can come from
456
+ // it, because only setState moves the machine.
457
+ notify();
458
+ }
459
+ /**
460
+ * Push one name to the gateway's `device/name` endpoint (T59): the gateway
461
+ * renames the CALLING device's own record. Resolves `true` when the table
462
+ * now carries `name` (the push answered OK) or when no push was needed;
463
+ * a failed push queues the name for the next `online` and resolves false.
464
+ * Never touches the connection state — a refused rename is not a dead
465
+ * link.
466
+ */
467
+ async function pushDeviceName(name) {
468
+ const creds = currentCredentials();
469
+ if (creds === undefined) {
470
+ pendingDeviceName = name;
471
+ return false;
472
+ }
473
+ // T59-fix: the busy window covers the whole round-trip (the status route
474
+ // answers an empty deviceName while it lasts), and the sequence counter
475
+ // lets an overlapping info refresh know its answer is stale.
476
+ const digestAtStart = credentialsDigest;
477
+ deviceNamePushBusy = true;
478
+ deviceNamePushSeq += 1;
479
+ let payload;
480
+ try {
481
+ const controller = new AbortController();
482
+ const timer = setTimeout(() => controller.abort(), requestTimeoutMs);
483
+ if (typeof timer.unref === 'function')
484
+ timer.unref();
485
+ try {
486
+ const response = await fetchImpl(`${creds.url}${DEVICE_NAME_PATH}`, {
487
+ ...requestInit(creds.token, JSON.stringify({ name })),
488
+ signal: controller.signal,
489
+ });
490
+ if (response.ok)
491
+ payload = await readPayload(response);
492
+ }
493
+ finally {
494
+ clearTimeout(timer);
495
+ }
496
+ }
497
+ catch {
498
+ payload = undefined;
499
+ }
500
+ deviceNamePushBusy = false;
501
+ deviceNamePushSeq += 1;
502
+ // T59-fix2: TWO different endings hide behind the old combined guard.
503
+ // A stopped client has nothing to come back for, and an answer about
504
+ // DIFFERENT credentials describes a table we no longer talk to — those
505
+ // give up outright (a later row commit re-queues a fresh name). A
506
+ // merely dropped link is NOT the end: the name re-queues and the next
507
+ // `online` transition flushes it, exactly like a push that was refused
508
+ // before it ever left.
509
+ if (stopped || credentialsDigest !== digestAtStart)
510
+ return false;
511
+ if (state !== 'online') {
512
+ pendingDeviceName = name;
513
+ return false;
514
+ }
515
+ const confirmed = isRecord(payload) && payload.ok === true && infoString(payload.name) === name;
516
+ if (!confirmed) {
517
+ pendingDeviceName = name;
518
+ return false;
519
+ }
520
+ // The table took the new name: remember it locally (without a state
521
+ // announcement — nothing but the name moved) and mark it authoritative
522
+ // until the server's own answer says the same.
523
+ if (handshakeInfo !== undefined && handshakeInfo.deviceName !== name) {
524
+ handshakeInfo = { ...handshakeInfo, deviceName: name };
525
+ }
526
+ deviceNameAuthoritative = true;
527
+ pendingDeviceName = undefined;
528
+ return true;
529
+ }
530
+ /** Flush a queued rename against the fresh online state (T59). */
531
+ function flushPendingDeviceName() {
532
+ if (pendingDeviceName === undefined)
533
+ return;
534
+ const name = pendingDeviceName;
535
+ pendingDeviceName = undefined;
536
+ if (handshakeInfo === undefined) {
537
+ pendingDeviceName = name;
538
+ return;
539
+ }
540
+ if (name === (handshakeInfo.deviceName ?? ''))
541
+ return;
542
+ void pushDeviceName(name);
543
+ }
544
+ /**
545
+ * The backend's one entry point (T59): the row's `serverName` was just
546
+ * committed. Online with a handshake, the name travels to the gateway only
547
+ * when it DIFFERS from the server's record — the settings page's
548
+ * server-driven write lands here with both sides equal, which is exactly
549
+ * what keeps the two ends from overwriting each other. Offline (or not yet
550
+ * handshaken), the name queues for the next `online`.
551
+ */
552
+ function queueDeviceName(name) {
553
+ const trimmed = name.trim();
554
+ if (trimmed === '')
555
+ return;
556
+ if (state !== 'online' || handshakeInfo === undefined) {
557
+ pendingDeviceName = trimmed;
558
+ return;
559
+ }
560
+ if (trimmed === (handshakeInfo.deviceName ?? '')) {
561
+ pendingDeviceName = undefined;
562
+ return;
563
+ }
564
+ void pushDeviceName(trimmed);
565
+ }
566
+ /**
567
+ * One QUIET handshake re-run (T59): refreshes the two names while online —
568
+ * no state announcement, no ladder movement, failures silent (the next
569
+ * tick retries; the idle and heartbeat clocks own the link's verdict).
570
+ * The reply carries the gateway's freshest record of BOTH names (its
571
+ * marking headers are written per forward), so this is also how a rename
572
+ * an admin made on the server reaches an otherwise-idle client.
573
+ */
574
+ async function refreshInfo() {
575
+ if (stopped || state !== 'online' || connectInFlight !== undefined)
576
+ return;
577
+ const creds = currentCredentials();
578
+ if (creds === undefined)
579
+ return;
580
+ // T59-fix: remember the world at request time — the answer only counts
581
+ // if we are still the same online client (the credential digest catches
582
+ // a re-pair to another server), and a name push that overlapped the
583
+ // round-trip makes the answer's deviceName a PRE-push record.
584
+ const digestAtStart = credentialsDigest;
585
+ const pushSeqAtStart = deviceNamePushSeq;
586
+ let payload;
587
+ try {
588
+ const controller = new AbortController();
589
+ const timer = setTimeout(() => controller.abort(), requestTimeoutMs);
590
+ if (typeof timer.unref === 'function')
591
+ timer.unref();
592
+ try {
593
+ const response = await fetchImpl(`${creds.url}${HANDSHAKE_PATH}`, {
594
+ ...requestInit(creds.token, '{}'),
595
+ signal: controller.signal,
596
+ });
597
+ if (!response.ok)
598
+ return;
599
+ payload = await readPayload(response);
600
+ }
601
+ finally {
602
+ clearTimeout(timer);
603
+ }
604
+ }
605
+ catch {
606
+ return;
607
+ }
608
+ if (stopped || state !== 'online' || credentialsDigest !== digestAtStart)
609
+ return;
610
+ if (!isRecord(payload) || payload.ok !== true || payload.relayProtocol !== RELAY_PROTOCOL)
611
+ return;
612
+ // T59-fix: NAMES ONLY. serverId / dshVersion / fingerprints belong to the
613
+ // real handshake (the compat verdict is computed there, on both maps
614
+ // fresh); a quiet refresh never disturbs them.
615
+ const previous = handshakeInfo;
616
+ if (previous === undefined)
617
+ return;
618
+ const serverName = typeof payload.serverName === 'string' ? payload.serverName : previous.serverName;
619
+ const serverDeviceName = infoString(payload.deviceName);
620
+ // A push that overlapped this round-trip — STARTED before it (the seq
621
+ // moved by the settle), still IN FLIGHT, or still QUEUED — makes the
622
+ // answer's deviceName a pre-push record: dropped, and the local
623
+ // authority stays; the next clean refresh confirms instead.
624
+ const pushedDuringRefresh = deviceNamePushSeq !== pushSeqAtStart || deviceNamePushBusy || pendingDeviceName !== undefined;
625
+ const deviceName = pushedDuringRefresh ? undefined : serverDeviceName;
626
+ if (deviceName !== undefined)
627
+ deviceNameAuthoritative = false;
628
+ let changed = previous.serverName !== serverName;
629
+ if (deviceName !== undefined && deviceName !== previous.deviceName)
630
+ changed = true;
631
+ if (!changed)
632
+ return;
633
+ handshakeInfo = { ...previous, serverName, ...(deviceName !== undefined ? { deviceName } : {}) };
634
+ notify();
635
+ }
636
+ /** Arm one refresh tick; re-arms itself while the client stays online. */
637
+ function armInfoRefresh() {
638
+ const delay = infoRefreshDelay(options);
639
+ if (delay <= 0 || infoTimer !== undefined || stopped)
640
+ return;
641
+ infoTimer = clock.setTimeout(() => {
642
+ infoTimer = undefined;
643
+ if (stopped || state !== 'online')
644
+ return;
645
+ // T59-fix2: a rename whose push FAILED while the link stayed up waits
646
+ // in the queue with no state transition coming to flush it — it
647
+ // retries on this cadence, or it would hang forever: syncing (and
648
+ // the empty `deviceName` the status route answers) would never clear,
649
+ // and the next reconnect would push the stale queued name over
650
+ // whatever an administrator renamed the device to meanwhile.
651
+ flushPendingDeviceName();
652
+ void refreshInfo().then(() => {
653
+ if (state === 'online' && !stopped)
654
+ armInfoRefresh();
655
+ });
656
+ }, delay);
657
+ }
658
+ /** The shared request face of every route. ONLY endpoint headers live
659
+ * here — content-length, host, connection and transfer-encoding are the
660
+ * dispatcher's business and must never be set by hand: DSH's process
661
+ * swaps the global fetch dispatcher for its own undici, which rejects a
662
+ * manual content-length outright (fetch failed / UND_ERR_INVALID_ARG).
663
+ * The JSON body arrives PRE-SERIALIZED: the invoke caller needs the JSON
664
+ * once for its size-based timeout, so every route stringifies exactly
665
+ * one. */
666
+ function requestInit(token, bodyJson) {
667
+ return {
668
+ method: 'POST',
669
+ headers: {
670
+ authorization: `Bearer ${token}`,
671
+ 'content-type': 'application/json',
672
+ // Load-bearing (module comment): without it the gateway's pairing
673
+ // wall arrives as HTML and a revocation cannot be classified.
674
+ accept: 'application/json',
675
+ },
676
+ body: bodyJson,
677
+ // A wrong server must not walk the pairing token through a redirect.
678
+ redirect: 'manual',
679
+ };
680
+ }
681
+ /**
682
+ * The binary upload's init (T51): the same endpoint-headers-only
683
+ * discipline as {@link requestInit} — the body rides as the raw byte
684
+ * stream with `content-type: application/octet-stream` (the ONE header
685
+ * the host's upload route mandates, RT dsh-client-file-upload
686
+ * lib/index.js:18) and no content-length: DSH's undici refuses a hand-set
687
+ * length outright, so a stream body goes chunked and the server's
688
+ * streaming byte count is the size gate. `duplex: 'half'` is what a
689
+ * streaming request body requires.
690
+ */
691
+ function uploadInit(token, body) {
692
+ const init = {
693
+ method: 'POST',
694
+ headers: {
695
+ authorization: `Bearer ${token}`,
696
+ 'content-type': 'application/octet-stream',
697
+ accept: 'application/json',
698
+ },
699
+ body,
700
+ redirect: 'manual',
701
+ };
702
+ init.duplex = 'half';
703
+ return init;
704
+ }
705
+ /**
706
+ * Map one non-success response onto the RelayError (and state) contract.
707
+ * Only the two walls move the state — the gateway's unpaired 401 means
708
+ * the token died (`revoked`), the relay-only 403 means the far end is not
709
+ * a 2.0.0 relay server (`incompatible`). The 401 `relay-unauthorized`
710
+ * deliberately does NOT become `revoked`: the gateway ACCEPTED the token
711
+ * and the server's internal secret disagrees — a server-side fault, and
712
+ * unpairing a valid device would be the wrong lesson.
713
+ */
714
+ function failureOf(status, payload) {
715
+ const body = isRecord(payload) ? payload : {};
716
+ const error = isRecord(body.error) ? body.error : {};
717
+ const code = typeof error.code === 'string' && error.code !== '' ? error.code : undefined;
718
+ const message = typeof error.message === 'string' ? error.message : undefined;
719
+ const reason = typeof body.reason === 'string' ? body.reason : undefined;
720
+ if (status === 401) {
721
+ if (reason === 'unpaired') {
722
+ setState('revoked');
723
+ return new RelayError('revoked', 'the pairing token was rejected by the server gateway', 401);
724
+ }
725
+ if (code === 'relay-unauthorized')
726
+ return new RelayError('relay-unauthorized', message, 401);
727
+ }
728
+ if (status === 403) {
729
+ if (reason === 'relay-only') {
730
+ setState('incompatible');
731
+ return new RelayError('incompatible', 'the server refused the relay prefix — it is not running the 2.0.0 relay', 403);
732
+ }
733
+ if (code === 'not-shared' || code === 'no-session' || code === 'forbidden-method') {
734
+ return new RelayError(code, message, 403);
735
+ }
736
+ }
737
+ // 200 {ok:false} envelopes, 400/404/429 answers and anything else
738
+ // unheard-of: the body's error code is the answer, the state is not
739
+ // touched.
740
+ if (code !== undefined)
741
+ return new RelayError(code, message, status);
742
+ // The bad-gateway band carries no relay body of its own — it is what a
743
+ // reverse proxy answers when the gateway behind it is down (nginx: an
744
+ // HTML 502). That IS the offline case, and the e2e contract expects it.
745
+ if (status === 502 || status === 503 || status === 504) {
746
+ noteFailure('offline');
747
+ setState('offline');
748
+ return new RelayError('offline', `the relay chain answered ${status} — the gateway is unreachable`, status);
749
+ }
750
+ return new RelayError(`http-${status}`, `unexpected relay response with status ${status}`, status);
751
+ }
752
+ /**
753
+ * The server-restart stream line (the server App exits cleanly or reloads
754
+ * its plugin row: relay-server.ts's closeAll ends every NDJSON stream with
755
+ * `error{code:'server-restart'}`) is a LINK fact, not an answer about one
756
+ * call — the whole server is going down. Mark the client offline exactly
757
+ * like a transport death would: the reconnect ladder arms, the merged
758
+ * streams' offline annotations apply, and the eventual reconnect runs a
759
+ * fresh handshake that picks up the (possibly changed) server name. The
760
+ * thrown RelayError keeps the specific code for the diagnostics ring.
761
+ */
762
+ function noteServerRestart(error) {
763
+ if (error.code !== 'server-restart')
764
+ return;
765
+ noteFailure('offline');
766
+ setState('offline');
767
+ }
768
+ /** `aborted` — a caller's signal, not a transport fault. */
769
+ function abortedError() {
770
+ return new RelayError('aborted', 'the caller aborted the request');
771
+ }
772
+ /**
773
+ * One request/response exchange (handshake, invoke, upload) with the
774
+ * shared timeout wiring. The URL and the RequestInit arrive PREBUILT —
775
+ * the JSON routes compose theirs from {@link requestInit}, the upload
776
+ * from {@link uploadInit} — so the body shape (serialized string or byte
777
+ * stream) stays the caller's business. Resolves only on a success
778
+ * envelope — 2xx plus `ok:true` — and then lifts a stale `offline` back
779
+ * to `online`, because a success proves the link. EVERY other outcome
780
+ * throws the mapped RelayError; transport failures (fetch rejection,
781
+ * mid-body cut, timeout) set `offline` first — EXCEPT on a route that
782
+ * opted out (`timeoutSetsOffline: false`, the invoke and upload routes):
783
+ * a slow call — a 28 MiB inline-image prompt or a 100 MiB attachment
784
+ * crawling up a slow link — and, since T51-fix, an upload CUT MID-STREAM
785
+ * (a reset under a huge body is the size gate and the drain doing their
786
+ * job, not a dead link) both fail THIS call and leave the connection
787
+ * state exactly where it was; the diagnostics still record the failure
788
+ * code. Only the handshake / stream-header legs and real network-layer
789
+ * failures on the opted-in routes judge the link.
790
+ */
791
+ async function exchangeRequest(url, init, signal, applySuccessState, opts = {}) {
792
+ const timeoutMs = opts.timeoutMs ?? requestTimeoutMs;
793
+ const timeoutSetsOffline = opts.timeoutSetsOffline ?? true;
794
+ // The transport-failure opt-out (T51-fix) is SEPARATE from the timeout
795
+ // one: the invoke route opts out of the timeout judging the link but
796
+ // keeps real transport deaths offline (the T43 reconnect ladder hangs
797
+ // off them); the upload route opts out of BOTH — a reset under a huge
798
+ // body is the size gate doing its job, not a dead link.
799
+ const transportSetsOffline = opts.transportSetsOffline ?? true;
800
+ const controller = new AbortController();
801
+ const onExternalAbort = () => {
802
+ controller.abort();
803
+ };
804
+ let timedOut = false;
805
+ if (signal !== undefined) {
806
+ if (signal.aborted)
807
+ controller.abort();
808
+ else
809
+ signal.addEventListener('abort', onExternalAbort);
810
+ }
811
+ const timer = setTimeout(() => {
812
+ timedOut = true;
813
+ controller.abort();
814
+ }, timeoutMs);
815
+ if (typeof timer.unref === 'function')
816
+ timer.unref();
817
+ try {
818
+ let response;
819
+ try {
820
+ response = await fetchImpl(url, { ...init, signal: controller.signal });
821
+ }
822
+ catch (error) {
823
+ if (signal?.aborted)
824
+ throw abortedError();
825
+ if (timedOut && !timeoutSetsOffline) {
826
+ noteFailure('request-timeout');
827
+ throw new RelayError('request-timeout', `no response within ${timeoutMs} ms`);
828
+ }
829
+ noteFailure('offline');
830
+ if (transportSetsOffline)
831
+ setState('offline');
832
+ throw new RelayError('offline', timedOut ? `no response within ${timeoutMs} ms` : messageOf(error));
833
+ }
834
+ let payload;
835
+ try {
836
+ payload = await readPayload(response);
837
+ }
838
+ catch (error) {
839
+ if (signal?.aborted)
840
+ throw abortedError();
841
+ noteFailure('offline');
842
+ if (transportSetsOffline)
843
+ setState('offline');
844
+ throw new RelayError('offline', messageOf(error));
845
+ }
846
+ if (response.status >= 200 && response.status < 300 && isRecord(payload) && payload.ok === true) {
847
+ // A success answers the link question: the last failure is superseded.
848
+ lastError = undefined;
849
+ if (applySuccessState)
850
+ setState('online');
851
+ return payload;
852
+ }
853
+ throw failureOf(response.status, payload);
854
+ }
855
+ finally {
856
+ clearTimeout(timer);
857
+ if (signal !== undefined)
858
+ signal.removeEventListener('abort', onExternalAbort);
859
+ }
860
+ }
861
+ /** The JSON routes' one exchange: path + pre-serialized body over
862
+ * {@link exchangeRequest}. */
863
+ async function exchange(pathName, bodyJson, signal, applySuccessState, creds, opts = {}) {
864
+ return exchangeRequest(`${creds.url}${pathName}`, requestInit(creds.token, bodyJson), signal, applySuccessState, opts);
865
+ }
866
+ function connect() {
867
+ // A connect already running IS the answer (T23a-fix2): a caller that
868
+ // raced one — the status route abandons its wait after 5s while the
869
+ // attempt keeps going — joins the SAME promise instead of stacking a
870
+ // second handshake on the wire. The slot clears the moment the attempt
871
+ // settles, so a later connect is a genuinely fresh one.
872
+ if (connectInFlight !== undefined)
873
+ return connectInFlight;
874
+ const attempt = (async () => {
875
+ // Fail before 'connecting' is announced when nothing is configured —
876
+ // an unpaired client must never look like it is trying.
877
+ const creds = requireCredentials();
878
+ const previous = state;
879
+ setState('connecting');
880
+ let payload;
881
+ try {
882
+ payload = await exchange(HANDSHAKE_PATH, '{}', undefined, false, creds);
883
+ }
884
+ catch (error) {
885
+ // The two walls keep the state the mapping gave them (revoked /
886
+ // incompatible). Every OTHER failure is an answer the contract says
887
+ // must not move the state — but the entry into this attempt already
888
+ // announced 'connecting', and a refusal would leave it stuck there
889
+ // forever, so the entry state is restored instead (T23a-fix). One
890
+ // correction (T23a-fix2): `unpaired` is reserved for "no credentials
891
+ // configured" — this attempt HAD them, so a restored first attempt
892
+ // lands on `offline`, the honest "configured but not reached".
893
+ if (state === 'connecting')
894
+ setState(previous === 'unpaired' ? 'offline' : previous);
895
+ // Whatever the failure was, its code is the diagnostics answer — even
896
+ // when the restore left the state untouched (e.g. an online verdict
897
+ // surviving an unmapped handshake failure). Server-supplied codes go
898
+ // through the sanitizer first (T43-fix).
899
+ if (error instanceof RelayError)
900
+ lastError = sanitizeCode(error.code);
901
+ throw error;
902
+ }
903
+ // The protocol gate runs BEFORE the online transition: an incompatible
904
+ // server must end `incompatible`, never flicker through online.
905
+ if (payload.relayProtocol !== RELAY_PROTOCOL) {
906
+ setState('incompatible');
907
+ lastError = 'incompatible';
908
+ throw new RelayError('incompatible', `the server speaks relay protocol ${String(payload.relayProtocol)}, this client speaks ${RELAY_PROTOCOL}`);
909
+ }
910
+ handshakeInfo = {
911
+ relayProtocol: RELAY_PROTOCOL,
912
+ serverId: typeof payload.serverId === 'string' ? payload.serverId : '',
913
+ serverName: typeof payload.serverName === 'string' ? payload.serverName : '',
914
+ dshVersion: typeof payload.dshVersion === 'string' ? payload.dshVersion : '',
915
+ fingerprints: isRecord(payload.fingerprints) ? payload.fingerprints : {},
916
+ // T59: the gateway's record of THIS device's name, when the server
917
+ // speaks the field at all (an older server omits it and the client
918
+ // keeps whatever it had).
919
+ ...(infoString(payload.deviceName) !== undefined ? { deviceName: infoString(payload.deviceName) } : {}),
920
+ };
921
+ // Interface compatibility (T42): compare the handshake's group map
922
+ // against this side's own, right here where both are fresh. A compute
923
+ // failure degrades to "nothing computed" — every group lands in
924
+ // `unavailable`, which the comparison contract keeps away from
925
+ // `different`. The verdict is stored BEFORE the online transition, so
926
+ // every subscriber woken by it reads the getter fresh.
927
+ if (options.computeOwnFingerprints !== undefined) {
928
+ let own = {};
929
+ try {
930
+ own = (await options.computeOwnFingerprints()) ?? {};
931
+ }
932
+ catch {
933
+ // Own fingerprints unavailable — the verdict says so, honestly.
934
+ }
935
+ compat = compareFingerprints(handshakeInfo.fingerprints, own);
936
+ }
937
+ // Digest of exactly the credentials this handshake used — never the
938
+ // plaintext values (T23a-fix).
939
+ lastHandshakeDigest = relayCredentialsDigest(creds.url, creds.token);
940
+ setState('online');
941
+ return handshakeInfo;
942
+ })();
943
+ connectInFlight = attempt;
944
+ const clear = () => {
945
+ if (connectInFlight === attempt)
946
+ connectInFlight = undefined;
947
+ };
948
+ attempt.then(clear, clear);
949
+ return attempt;
950
+ }
951
+ /** One immediate attempt, ladder reset to the first step (T43). Only an
952
+ * `offline` client has anything to reconnect. */
953
+ function reconnect() {
954
+ if (stopped || state !== 'offline')
955
+ return false;
956
+ cancelRetry();
957
+ void connect().catch(() => { });
958
+ return true;
959
+ }
960
+ /**
961
+ * The credentials source moved (a pairing write, an unpair, a hand edit
962
+ * committed by the loader): drop any pending wait — the ladder belongs to
963
+ * the OLD credentials — clear the stored compat verdict (it describes the
964
+ * server the OLD credentials pointed at, T23b2-fix3) and dial the new
965
+ * values at once. Nothing configured lands `unpaired`, exactly like a
966
+ * request would. A connect already running joined in with the old values
967
+ * (its getters were read at its start); when it settles, ONE follow-up
968
+ * goes out with the new values — skipped if a still-newer change
969
+ * superseded this one.
970
+ */
971
+ function credentialsChanged() {
972
+ const creds = currentCredentials();
973
+ const digest = creds === undefined ? undefined : relayCredentialsDigest(creds.url, creds.token);
974
+ if (digest === credentialsDigest)
975
+ return;
976
+ credentialsDigest = digest;
977
+ // Same invalidation rule as setState's unpaired/revoked branch: a
978
+ // verdict the current credentials never earned must not stay visible —
979
+ // even when the very next handshake lands online, there is a window
980
+ // between the change and that handshake.
981
+ compat = undefined;
982
+ cancelRetry();
983
+ if (stopped)
984
+ return;
985
+ if (creds === undefined) {
986
+ setState('unpaired');
987
+ return;
988
+ }
989
+ const inFlight = connectInFlight;
990
+ if (inFlight === undefined) {
991
+ void connect().catch(() => { });
992
+ return;
993
+ }
994
+ // The running attempt is the old credentials' dial. Joining it would be
995
+ // a no-op (T23a-fix2 dedupe) AND answer nothing about the new values —
996
+ // the follow-up below is the new dial (T43-fix).
997
+ void inFlight.catch(() => { }).then(() => {
998
+ if (stopped)
999
+ return;
1000
+ const latest = currentCredentials();
1001
+ if (latest === undefined || relayCredentialsDigest(latest.url, latest.token) !== digest)
1002
+ return;
1003
+ void connect().catch(() => { });
1004
+ });
1005
+ }
1006
+ function stop() {
1007
+ stopped = true;
1008
+ cancelRetry();
1009
+ if (infoTimer !== undefined) {
1010
+ clock.clearTimeout(infoTimer);
1011
+ infoTimer = undefined;
1012
+ }
1013
+ }
1014
+ async function invoke(namespace, method, args, signal) {
1015
+ const creds = requireCredentials();
1016
+ // One serialization, two uses: the body on the wire and the size the
1017
+ // round-trip budget scales with. The timeout deliberately does NOT move
1018
+ // the connection state — a slow prompt is a slow call, not a dead link.
1019
+ const bodyJson = JSON.stringify({ namespace, method, args });
1020
+ const payload = await exchange(INVOKE_PATH, bodyJson, signal, true, creds, {
1021
+ timeoutMs: invokeTimeoutMs(requestTimeoutMs, bodyJson),
1022
+ timeoutSetsOffline: false,
1023
+ });
1024
+ return payload.value;
1025
+ }
1026
+ async function postEventResult(eventId, result, signal) {
1027
+ const creds = requireCredentials();
1028
+ const payload = await exchange(EVENT_RESULT_PATH, JSON.stringify({ eventId, result }), signal, true, creds);
1029
+ return payload.value;
1030
+ }
1031
+ async function unshare(sessionId, signal) {
1032
+ const creds = requireCredentials();
1033
+ await exchange(UNSHARE_PATH, JSON.stringify({ sessionId }), signal, true, creds);
1034
+ }
1035
+ async function http(route, query, signal) {
1036
+ const creds = requireCredentials();
1037
+ const payload = await exchange(HTTP_PATH, JSON.stringify({ route, query }), signal, true, creds);
1038
+ const value = payload.value;
1039
+ if (!isRecord(value) ||
1040
+ typeof value.status !== 'number' ||
1041
+ !Number.isSafeInteger(value.status) ||
1042
+ typeof value.body !== 'string') {
1043
+ throw new RelayError('internal', 'the relay http answer carries no upstream response');
1044
+ }
1045
+ return {
1046
+ status: value.status,
1047
+ contentType: typeof value.contentType === 'string' ? value.contentType : undefined,
1048
+ body: value.body,
1049
+ };
1050
+ }
1051
+ async function upload(options, signal) {
1052
+ const creds = requireCredentials();
1053
+ const params = new URLSearchParams();
1054
+ params.set('sessionId', options.sessionId);
1055
+ if (options.name !== undefined)
1056
+ params.set('name', options.name);
1057
+ // The empty-body upload still rides a (closed) stream: the wire route
1058
+ // always speaks a request body, and a missing one would be a transport
1059
+ // error, not the host's own empty-upload business failure.
1060
+ const body = options.body ??
1061
+ new ReadableStream({
1062
+ start: (controller) => {
1063
+ controller.close();
1064
+ },
1065
+ });
1066
+ // The budget scales with the DECLARED size (T31-fix's rule); a timeout
1067
+ // deliberately does not move the connection state — a slow upload is a
1068
+ // slow call, not a dead link.
1069
+ const payload = await exchangeRequest(`${creds.url}${UPLOAD_PATH}?${params.toString()}`, uploadInit(creds.token, body), signal, true, { timeoutMs: sizeBackedTimeoutMs(requestTimeoutMs, options.bytes), timeoutSetsOffline: false, transportSetsOffline: false });
1070
+ const value = payload.value;
1071
+ if (!isRecord(value) ||
1072
+ typeof value.status !== 'number' ||
1073
+ !Number.isSafeInteger(value.status) ||
1074
+ typeof value.body !== 'string') {
1075
+ throw new RelayError('internal', 'the relay upload answer carries no upstream response');
1076
+ }
1077
+ return {
1078
+ status: value.status,
1079
+ contentType: typeof value.contentType === 'string' ? value.contentType : undefined,
1080
+ body: value.body,
1081
+ };
1082
+ }
1083
+ function openStream(namespace, method, args, signal) {
1084
+ // Eager, not generator-lazy: an unconfigured client fails at CALL time,
1085
+ // exactly like connect/invoke, instead of hiding the error inside the
1086
+ // first next().
1087
+ requireCredentials();
1088
+ return streamLines(namespace, method, args, signal);
1089
+ }
1090
+ /**
1091
+ * The NDJSON body pump. The request is owned by `controller`, which three
1092
+ * things may abort: the caller's signal (→ the iteration ends normally),
1093
+ * the idle clock (no line of any kind within `idleTimeoutMs` → offline),
1094
+ * and the generator's own exit — `break`, a thrown error line, normal
1095
+ * end — which tears the request down so the server's upstream does not
1096
+ * keep pumping behind a consumer that left.
1097
+ */
1098
+ async function* streamLines(namespace, method, args, signal) {
1099
+ if (signal?.aborted)
1100
+ return;
1101
+ // Re-checked: the generator body runs at the first next(), so the
1102
+ // credentials may have vanished since openStream validated them.
1103
+ const { url, token } = requireCredentials();
1104
+ const controller = new AbortController();
1105
+ const onExternalAbort = () => {
1106
+ controller.abort();
1107
+ };
1108
+ if (signal !== undefined)
1109
+ signal.addEventListener('abort', onExternalAbort);
1110
+ let idleFired = false;
1111
+ let headersTimedOut = false;
1112
+ let idleTimer;
1113
+ const armIdle = () => {
1114
+ if (idleTimer !== undefined)
1115
+ clearTimeout(idleTimer);
1116
+ idleTimer = setTimeout(() => {
1117
+ idleFired = true;
1118
+ controller.abort();
1119
+ }, idleTimeoutMs);
1120
+ if (typeof idleTimer.unref === 'function')
1121
+ idleTimer.unref();
1122
+ };
1123
+ const disarmIdle = () => {
1124
+ if (idleTimer !== undefined) {
1125
+ clearTimeout(idleTimer);
1126
+ idleTimer = undefined;
1127
+ }
1128
+ };
1129
+ // Headers get the request timeout; the BODY is governed by the idle
1130
+ // clock alone — a healthy stream stays silent for hours. The timer is
1131
+ // disarmed the moment the response HEADERS arrive (it is cleared again
1132
+ // in the finally for the pre-header failure paths), or it would abort
1133
+ // every stream still alive past requestTimeoutMs (T23a-fix).
1134
+ const headerTimer = setTimeout(() => {
1135
+ headersTimedOut = true;
1136
+ controller.abort();
1137
+ }, requestTimeoutMs);
1138
+ if (typeof headerTimer.unref === 'function')
1139
+ headerTimer.unref();
1140
+ // Hoisted for the teardown below: even a fetchImpl that ignores the
1141
+ // signal must have its response body released (T23a-fix).
1142
+ let reader;
1143
+ try {
1144
+ let response;
1145
+ try {
1146
+ response = await fetchImpl(`${url}${STREAM_PATH}`, {
1147
+ ...requestInit(token, JSON.stringify({ namespace, method, args })),
1148
+ signal: controller.signal,
1149
+ });
1150
+ }
1151
+ catch (error) {
1152
+ if (signal?.aborted)
1153
+ return;
1154
+ noteFailure('offline');
1155
+ setState('offline');
1156
+ throw new RelayError('offline', headersTimedOut ? `no response within ${requestTimeoutMs} ms` : messageOf(error));
1157
+ }
1158
+ if (!response.ok) {
1159
+ // The NDJSON protocol only starts once the stream is allowed — a
1160
+ // refusal is the same JSON the invoke route answers, mapped the
1161
+ // same way. The header clock keeps guarding this leg: a server that
1162
+ // answers 4xx headers but never sends the body must not hang the
1163
+ // stream forever (T23a-fix2) — the abort lands here as offline.
1164
+ let payload;
1165
+ try {
1166
+ payload = await readPayload(response);
1167
+ }
1168
+ catch (error) {
1169
+ if (signal?.aborted)
1170
+ return;
1171
+ noteFailure('offline');
1172
+ setState('offline');
1173
+ throw new RelayError('offline', messageOf(error));
1174
+ }
1175
+ throw failureOf(response.status, payload);
1176
+ }
1177
+ // The 200 itself is a successful response: the link works. Only NOW
1178
+ // is the header phase truly over and its clock disarmed.
1179
+ clearTimeout(headerTimer);
1180
+ setState('online');
1181
+ if (response.body === null)
1182
+ return;
1183
+ armIdle();
1184
+ const decoder = new TextDecoder();
1185
+ let buffer = '';
1186
+ reader = response.body.getReader();
1187
+ while (true) {
1188
+ const { done, value } = await reader.read();
1189
+ armIdle(); // bytes arrived — any line content refreshes the clock
1190
+ if (done)
1191
+ break;
1192
+ buffer += decoder.decode(value, { stream: true });
1193
+ let idx;
1194
+ while ((idx = buffer.indexOf('\n')) >= 0) {
1195
+ const line = buffer.slice(0, idx);
1196
+ buffer = buffer.slice(idx + 1);
1197
+ const verdict = classifyLine(line);
1198
+ if (verdict.kind === 'frame') {
1199
+ // The idle clock measures TRANSPORT silence, not the consumer's
1200
+ // processing time: it is paused across the yield and re-armed
1201
+ // when the value is pulled.
1202
+ disarmIdle();
1203
+ yield verdict.frame;
1204
+ armIdle();
1205
+ }
1206
+ else if (verdict.kind === 'end') {
1207
+ return;
1208
+ }
1209
+ else if (verdict.kind === 'error') {
1210
+ noteServerRestart(verdict.error);
1211
+ throw verdict.error;
1212
+ }
1213
+ else if (verdict.kind === 'info') {
1214
+ // T59: the heartbeat's names, folded in quietly — a state-sparing
1215
+ // notify wakes the interceptor's rename path without any reconnect.
1216
+ applyInfoDelta(verdict.serverName, verdict.deviceName);
1217
+ }
1218
+ }
1219
+ }
1220
+ // Tolerant tail: a final line without its newline still counts.
1221
+ buffer += decoder.decode();
1222
+ if (buffer.trim() !== '') {
1223
+ const verdict = classifyLine(buffer);
1224
+ if (verdict.kind === 'frame') {
1225
+ disarmIdle();
1226
+ yield verdict.frame;
1227
+ }
1228
+ else if (verdict.kind === 'end') {
1229
+ return;
1230
+ }
1231
+ else if (verdict.kind === 'error') {
1232
+ noteServerRestart(verdict.error);
1233
+ throw verdict.error;
1234
+ }
1235
+ else if (verdict.kind === 'info') {
1236
+ applyInfoDelta(verdict.serverName, verdict.deviceName);
1237
+ }
1238
+ }
1239
+ }
1240
+ catch (error) {
1241
+ if (signal?.aborted)
1242
+ return;
1243
+ if (error instanceof RelayError)
1244
+ throw error;
1245
+ // Anything else escaping the body read is the transport dying.
1246
+ noteFailure('offline');
1247
+ setState('offline');
1248
+ throw new RelayError('offline', idleFired ? `no line received for ${idleTimeoutMs} ms` : messageOf(error));
1249
+ }
1250
+ finally {
1251
+ disarmIdle();
1252
+ clearTimeout(headerTimer);
1253
+ if (signal !== undefined)
1254
+ signal.removeEventListener('abort', onExternalAbort);
1255
+ // Release the body even if a custom fetchImpl ignored the signal —
1256
+ // an in-flight reader keeps the connection's buffers alive. Cancel
1257
+ // may reject on an already-dead stream; that is fine.
1258
+ if (reader !== undefined)
1259
+ reader.cancel().catch(() => { });
1260
+ // However we left the body — break, error line, normal end, caller
1261
+ // abort — the request itself must not linger: the server aborts its
1262
+ // upstream when the socket closes. Aborting a finished request is a
1263
+ // no-op.
1264
+ controller.abort();
1265
+ }
1266
+ }
1267
+ return {
1268
+ get state() {
1269
+ return state;
1270
+ },
1271
+ get handshakeInfo() {
1272
+ return handshakeInfo;
1273
+ },
1274
+ get lastHandshakeDigest() {
1275
+ return lastHandshakeDigest;
1276
+ },
1277
+ get nextRetryAt() {
1278
+ return nextRetryAt;
1279
+ },
1280
+ get lastError() {
1281
+ return lastError;
1282
+ },
1283
+ get compat() {
1284
+ return compat;
1285
+ },
1286
+ get deviceNameSyncing() {
1287
+ return deviceNameSyncing();
1288
+ },
1289
+ subscribe,
1290
+ queueDeviceName,
1291
+ connect,
1292
+ reconnect,
1293
+ credentialsChanged,
1294
+ stop,
1295
+ invoke,
1296
+ unshare,
1297
+ postEventResult,
1298
+ openStream,
1299
+ http,
1300
+ upload,
1301
+ };
1302
+ }
1303
+ //# sourceMappingURL=relay-client.js.map