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.
- package/AGENTS.md +54 -9
- package/README.md +249 -190
- package/README.zh-CN.md +247 -201
- package/assets/home.png +0 -0
- package/assets/info.png +0 -0
- package/assets/pairing.png +0 -0
- package/assets/session.png +0 -0
- package/assets/sheet.png +0 -0
- package/cordis.patch.yml +12 -11
- package/cordis.patch.yml.example +24 -9
- package/docs/interface.md +57 -2
- package/docs/remote-access.en.md +99 -58
- package/docs/remote-access.md +99 -42
- package/dsh-push.mjs +67 -54
- package/lan-gate.mjs +88 -20
- package/lib/activity.js +168 -0
- package/lib/activity.js.map +1 -0
- package/lib/admin-routes.js +526 -0
- package/lib/admin-routes.js.map +1 -0
- package/lib/client-pairing.js +224 -0
- package/lib/client-pairing.js.map +1 -0
- package/lib/client-routes.js +674 -0
- package/lib/client-routes.js.map +1 -0
- package/lib/client.js +10051 -4777
- package/lib/config.js +346 -0
- package/lib/config.js.map +1 -0
- package/lib/fetch-route-intercept.js +418 -0
- package/lib/fetch-route-intercept.js.map +1 -0
- package/lib/fingerprint.js +363 -0
- package/lib/fingerprint.js.map +1 -0
- package/lib/http.js +69 -0
- package/lib/http.js.map +1 -0
- package/lib/index.js +675 -54
- package/lib/index.js.map +1 -1
- package/lib/intercept-shape.js +81 -0
- package/lib/intercept-shape.js.map +1 -0
- package/lib/intercept.js +3040 -0
- package/lib/intercept.js.map +1 -0
- package/lib/lan-gate-server.cjs +532 -121
- package/lib/merge-streams.js +1165 -0
- package/lib/merge-streams.js.map +1 -0
- package/lib/relay-access.js +496 -0
- package/lib/relay-access.js.map +1 -0
- package/lib/relay-client.js +1303 -0
- package/lib/relay-client.js.map +1 -0
- package/lib/relay-filter.js +282 -0
- package/lib/relay-filter.js.map +1 -0
- package/lib/relay-server.js +1792 -0
- package/lib/relay-server.js.map +1 -0
- package/lib/restart-fields.js +34 -0
- package/lib/restart-fields.js.map +1 -0
- package/lib/restart-watcher.js +106 -0
- package/lib/restart-watcher.js.map +1 -0
- package/lib/session-reference.js +157 -0
- package/lib/session-reference.js.map +1 -0
- package/lib/share-export.js +16 -1
- package/lib/share-export.js.map +1 -1
- package/lib/share-ops.js +86 -0
- package/lib/share-ops.js.map +1 -0
- package/lib/share-store.js +294 -0
- package/lib/share-store.js.map +1 -0
- package/lib/types/activity.d.ts +99 -0
- package/lib/types/activity.d.ts.map +1 -0
- package/lib/types/admin-routes.d.ts +129 -0
- package/lib/types/admin-routes.d.ts.map +1 -0
- package/lib/types/client/MobileDrawerFooter.d.ts.map +1 -1
- package/lib/types/client/MobileHome.d.ts +1 -1
- package/lib/types/client/MobileHome.d.ts.map +1 -1
- package/lib/types/client/MobileHomeChips.d.ts +1 -1
- package/lib/types/client/MobileHomeChips.d.ts.map +1 -1
- package/lib/types/client/MobileSessionHeader.d.ts +13 -2
- package/lib/types/client/MobileSessionHeader.d.ts.map +1 -1
- package/lib/types/client/MobileSessionInfo.d.ts +20 -3
- package/lib/types/client/MobileSessionInfo.d.ts.map +1 -1
- package/lib/types/client/RemoteComposerBanner.d.ts +42 -0
- package/lib/types/client/RemoteComposerBanner.d.ts.map +1 -0
- package/lib/types/client/RemoteHeaderIcon.d.ts +37 -0
- package/lib/types/client/RemoteHeaderIcon.d.ts.map +1 -0
- package/lib/types/client/RemoteShareMenu.d.ts +28 -0
- package/lib/types/client/RemoteShareMenu.d.ts.map +1 -0
- package/lib/types/client/RemoteStatusIcon.d.ts +36 -0
- package/lib/types/client/RemoteStatusIcon.d.ts.map +1 -0
- package/lib/types/client/compat/desktop.d.ts +21 -0
- package/lib/types/client/compat/desktop.d.ts.map +1 -0
- package/lib/types/client/compat/types.d.ts +57 -0
- package/lib/types/client/compat/types.d.ts.map +1 -1
- package/lib/types/client/current-session-report.d.ts +37 -0
- package/lib/types/client/current-session-report.d.ts.map +1 -0
- package/lib/types/client/effects/header-status.d.ts.map +1 -1
- package/lib/types/client/effects/keyboard-guard.d.ts.map +1 -1
- package/lib/types/client/effects/permission-sheet.d.ts +39 -0
- package/lib/types/client/effects/permission-sheet.d.ts.map +1 -0
- package/lib/types/client/effects/remote-session.d.ts +25 -0
- package/lib/types/client/effects/remote-session.d.ts.map +1 -0
- package/lib/types/client/effects/turn-fold.d.ts +9 -0
- package/lib/types/client/effects/turn-fold.d.ts.map +1 -1
- package/lib/types/client/index.d.ts.map +1 -1
- package/lib/types/client/locales.d.ts +187 -2
- package/lib/types/client/locales.d.ts.map +1 -1
- package/lib/types/client/remote-fetch.d.ts +51 -0
- package/lib/types/client/remote-fetch.d.ts.map +1 -0
- package/lib/types/client/remote-group-expansion.d.ts +41 -0
- package/lib/types/client/remote-group-expansion.d.ts.map +1 -0
- package/lib/types/client/remote-share-css.d.ts +19 -0
- package/lib/types/client/remote-share-css.d.ts.map +1 -0
- package/lib/types/client/remote-share-register.d.ts +113 -0
- package/lib/types/client/remote-share-register.d.ts.map +1 -0
- package/lib/types/client/remote-status-css.d.ts +15 -0
- package/lib/types/client/remote-status-css.d.ts.map +1 -0
- package/lib/types/client/remote-status-register.d.ts +46 -0
- package/lib/types/client/remote-status-register.d.ts.map +1 -0
- package/lib/types/client/settings/SettingsSection.d.ts +46 -0
- package/lib/types/client/settings/SettingsSection.d.ts.map +1 -0
- package/lib/types/client/settings/register-settings.d.ts +58 -0
- package/lib/types/client/settings/register-settings.d.ts.map +1 -0
- package/lib/types/client/settings/settings-css.d.ts +10 -0
- package/lib/types/client/settings/settings-css.d.ts.map +1 -0
- package/lib/types/client/share/share-preview.d.ts.map +1 -1
- package/lib/types/client/styles/composer.css.d.ts +1 -1
- package/lib/types/client/styles/composer.css.d.ts.map +1 -1
- package/lib/types/client/styles/header.css.d.ts +1 -1
- package/lib/types/client/styles/header.css.d.ts.map +1 -1
- package/lib/types/client/styles/info.css.d.ts +1 -1
- package/lib/types/client/styles/info.css.d.ts.map +1 -1
- package/lib/types/client/styles/remote-session.css.d.ts +2 -0
- package/lib/types/client/styles/remote-session.css.d.ts.map +1 -0
- package/lib/types/client-data/current-session.d.ts +41 -0
- package/lib/types/client-data/current-session.d.ts.map +1 -0
- package/lib/types/client-data/remote-group-expansion.d.ts +76 -0
- package/lib/types/client-data/remote-group-expansion.d.ts.map +1 -0
- package/lib/types/client-data/remote-status.d.ts +148 -0
- package/lib/types/client-data/remote-status.d.ts.map +1 -0
- package/lib/types/client-data/settings-form.d.ts +774 -0
- package/lib/types/client-data/settings-form.d.ts.map +1 -0
- package/lib/types/client-data/shares.d.ts +211 -0
- package/lib/types/client-data/shares.d.ts.map +1 -0
- package/lib/types/client-pairing.d.ts +114 -0
- package/lib/types/client-pairing.d.ts.map +1 -0
- package/lib/types/client-routes.d.ts +112 -0
- package/lib/types/client-routes.d.ts.map +1 -0
- package/lib/types/config.d.ts +200 -0
- package/lib/types/config.d.ts.map +1 -0
- package/lib/types/fetch-route-intercept.d.ts +156 -0
- package/lib/types/fetch-route-intercept.d.ts.map +1 -0
- package/lib/types/fingerprint.d.ts +164 -0
- package/lib/types/fingerprint.d.ts.map +1 -0
- package/lib/types/http.d.ts +41 -0
- package/lib/types/http.d.ts.map +1 -0
- package/lib/types/index.d.ts +56 -24
- package/lib/types/index.d.ts.map +1 -1
- package/lib/types/intercept-shape.d.ts +32 -0
- package/lib/types/intercept-shape.d.ts.map +1 -0
- package/lib/types/intercept.d.ts +432 -0
- package/lib/types/intercept.d.ts.map +1 -0
- package/lib/types/merge-streams.d.ts +393 -0
- package/lib/types/merge-streams.d.ts.map +1 -0
- package/lib/types/relay-access.d.ts +310 -0
- package/lib/types/relay-access.d.ts.map +1 -0
- package/lib/types/relay-client.d.ts +274 -0
- package/lib/types/relay-client.d.ts.map +1 -0
- package/lib/types/relay-filter.d.ts +103 -0
- package/lib/types/relay-filter.d.ts.map +1 -0
- package/lib/types/relay-server.d.ts +208 -0
- package/lib/types/relay-server.d.ts.map +1 -0
- package/lib/types/restart-fields.d.ts +22 -0
- package/lib/types/restart-fields.d.ts.map +1 -0
- package/lib/types/restart-watcher.d.ts +83 -0
- package/lib/types/restart-watcher.d.ts.map +1 -0
- package/lib/types/session-reference.d.ts +66 -0
- package/lib/types/session-reference.d.ts.map +1 -0
- package/lib/types/share-export.d.ts +14 -1
- package/lib/types/share-export.d.ts.map +1 -1
- package/lib/types/share-ops.d.ts +79 -0
- package/lib/types/share-ops.d.ts.map +1 -0
- package/lib/types/share-store.d.ts +87 -0
- package/lib/types/share-store.d.ts.map +1 -0
- package/lib/types/virtual-id.d.ts +38 -0
- package/lib/types/virtual-id.d.ts.map +1 -0
- package/lib/virtual-id.js +52 -0
- package/lib/virtual-id.js.map +1 -0
- package/llms.txt +17 -14
- package/package.json +60 -50
- package/scripts/build-client.mjs +5 -3
- package/scripts/check-host-shadow.mjs +19 -2
- package/scripts/check-settings-form.mjs +933 -0
- package/scripts/check-shares.mjs +397 -0
- package/scripts/check-upload-endpoint.mjs +59 -3
- package/scripts/sync-doc-version.mjs +8 -15
- package/scripts/verify-fingerprint-live.mjs +111 -0
- package/src/activity.ts +218 -0
- package/src/admin-routes.ts +627 -0
- package/src/client/MobileAttachButton.tsx +2 -2
- package/src/client/MobileAttachChips.tsx +3 -3
- package/src/client/MobileDrawerFooter.tsx +7 -4
- package/src/client/MobileHome.tsx +20 -16
- package/src/client/MobileHomeChips.tsx +16 -16
- package/src/client/MobileNavOverlay.tsx +2 -2
- package/src/client/MobileNavToggle.tsx +3 -3
- package/src/client/MobileSessionHeader.tsx +63 -42
- package/src/client/MobileSessionInfo.tsx +118 -17
- package/src/client/RemoteComposerBanner.tsx +76 -0
- package/src/client/RemoteHeaderIcon.tsx +103 -0
- package/src/client/RemoteShareMenu.tsx +57 -0
- package/src/client/RemoteStatusIcon.tsx +91 -0
- package/src/client/compat/desktop.ts +22 -0
- package/src/client/compat/types.ts +62 -0
- package/src/client/current-session-report.ts +164 -0
- package/src/client/effects/header-status.ts +21 -17
- package/src/client/effects/keyboard-guard.ts +65 -1
- package/src/client/effects/permission-sheet.ts +149 -0
- package/src/client/effects/remote-session.ts +68 -0
- package/src/client/effects/turn-fold.ts +52 -0
- package/src/client/index.tsx +151 -36
- package/src/client/locales.ts +396 -2
- package/src/client/remote-fetch.ts +125 -0
- package/src/client/remote-group-expansion.ts +190 -0
- package/src/client/remote-share-css.ts +54 -0
- package/src/client/remote-share-register.ts +244 -0
- package/src/client/remote-status-css.ts +64 -0
- package/src/client/remote-status-register.ts +107 -0
- package/src/client/settings/SettingsSection.tsx +1229 -0
- package/src/client/settings/register-settings.ts +116 -0
- package/src/client/settings/settings-css.ts +236 -0
- package/src/client/share/share-preview.tsx +6 -3
- package/src/client/share/share-sheet.tsx +3 -3
- package/src/client/styles/composer.css.ts +119 -26
- package/src/client/styles/header.css.ts +7 -7
- package/src/client/styles/info.css.ts +74 -0
- package/src/client/styles/remote-session.css.ts +251 -0
- package/src/client-data/current-session.ts +57 -0
- package/src/client-data/remote-group-expansion.ts +145 -0
- package/src/client-data/remote-status.ts +374 -0
- package/src/client-data/settings-form.ts +1371 -0
- package/src/client-data/shares.ts +467 -0
- package/src/client-pairing.ts +244 -0
- package/src/client-routes.ts +732 -0
- package/src/config.ts +447 -0
- package/src/fetch-route-intercept.ts +510 -0
- package/src/fingerprint.ts +408 -0
- package/src/http.ts +68 -0
- package/src/index.ts +719 -62
- package/src/intercept-shape.ts +87 -0
- package/src/intercept.ts +3362 -0
- package/src/merge-streams.ts +1272 -0
- package/src/relay-access.ts +669 -0
- package/src/relay-client.ts +1514 -0
- package/src/relay-filter.ts +291 -0
- package/src/relay-server.ts +2025 -0
- package/src/restart-fields.ts +36 -0
- package/src/restart-watcher.ts +140 -0
- package/src/session-reference.ts +156 -0
- package/src/share-export.ts +26 -1
- package/src/share-ops.ts +114 -0
- package/src/share-store.ts +373 -0
- 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
|