dsh-zen-remote 2.3.11 → 2.4.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/CHANGELOG.md +39 -78
- package/README.md +94 -84
- package/README.zh-CN.md +90 -81
- package/bin/zen-remote.mjs +5 -0
- package/docs/interface.md +6 -6
- package/docs/remote-access.en.md +6 -4
- package/docs/remote-access.md +6 -4
- package/dsh-push.mjs +96 -10
- package/lib/adapters/dsh-better-sidebar/index.js +7 -3
- package/lib/adapters/dsh-better-sidebar/index.js.map +1 -1
- package/lib/admin-routes.js +10 -2
- package/lib/admin-routes.js.map +1 -1
- package/lib/cli/args.js +251 -0
- package/lib/cli/args.js.map +1 -0
- package/lib/cli/bootstrap.js +449 -0
- package/lib/cli/bootstrap.js.map +1 -0
- package/lib/cli/command-setup.js +484 -0
- package/lib/cli/command-setup.js.map +1 -0
- package/lib/cli/commands-client.js +359 -0
- package/lib/cli/commands-client.js.map +1 -0
- package/lib/cli/commands-host.js +1108 -0
- package/lib/cli/commands-host.js.map +1 -0
- package/lib/cli/commands-status.js +279 -0
- package/lib/cli/commands-status.js.map +1 -0
- package/lib/cli/config-writer.js +334 -0
- package/lib/cli/config-writer.js.map +1 -0
- package/lib/cli/context.js +10 -0
- package/lib/cli/context.js.map +1 -0
- package/lib/cli/i18n.js +620 -0
- package/lib/cli/i18n.js.map +1 -0
- package/lib/cli/ipc-client.js +160 -0
- package/lib/cli/ipc-client.js.map +1 -0
- package/lib/cli/ipc-server.js +464 -0
- package/lib/cli/ipc-server.js.map +1 -0
- package/lib/cli/main.js +245 -0
- package/lib/cli/main.js.map +1 -0
- package/lib/cli/qr-terminal.js +48 -0
- package/lib/cli/qr-terminal.js.map +1 -0
- package/lib/client-data/qr.js +286 -0
- package/lib/client-data/qr.js.map +1 -0
- package/lib/client-routes.js +308 -25
- package/lib/client-routes.js.map +1 -1
- package/lib/client.js +1013 -229
- package/lib/config.js +50 -12
- package/lib/config.js.map +1 -1
- package/lib/debug-report.js +28 -1
- package/lib/debug-report.js.map +1 -1
- package/lib/fetch-route-intercept.js +334 -26
- package/lib/fetch-route-intercept.js.map +1 -1
- package/lib/index.js +564 -363
- package/lib/index.js.map +1 -1
- package/lib/intercept.js +1833 -280
- package/lib/intercept.js.map +1 -1
- package/lib/lan-gate-server.cjs +36 -10
- package/lib/merge-streams.js +103 -11
- package/lib/merge-streams.js.map +1 -1
- package/lib/plugin-route-intercept.js +93 -63
- package/lib/plugin-route-intercept.js.map +1 -1
- package/lib/relay-access.js +8 -1
- package/lib/relay-access.js.map +1 -1
- package/lib/relay-client.js +133 -4
- package/lib/relay-client.js.map +1 -1
- package/lib/relay-pool.js +275 -0
- package/lib/relay-pool.js.map +1 -0
- package/lib/relay-server.js +269 -17
- package/lib/relay-server.js.map +1 -1
- package/lib/restart-fields.js +6 -4
- package/lib/restart-fields.js.map +1 -1
- package/lib/share-store.js +65 -27
- package/lib/share-store.js.map +1 -1
- package/lib/state-store.js +400 -0
- package/lib/state-store.js.map +1 -0
- package/lib/types/adapters/dsh-better-sidebar/index.d.ts +3 -2
- package/lib/types/adapters/dsh-better-sidebar/index.d.ts.map +1 -1
- package/lib/types/admin-routes.d.ts.map +1 -1
- package/lib/types/cli/args.d.ts +78 -0
- package/lib/types/cli/args.d.ts.map +1 -0
- package/lib/types/cli/bootstrap.d.ts +170 -0
- package/lib/types/cli/bootstrap.d.ts.map +1 -0
- package/lib/types/cli/command-setup.d.ts +72 -0
- package/lib/types/cli/command-setup.d.ts.map +1 -0
- package/lib/types/cli/commands-client.d.ts +36 -0
- package/lib/types/cli/commands-client.d.ts.map +1 -0
- package/lib/types/cli/commands-host.d.ts +82 -0
- package/lib/types/cli/commands-host.d.ts.map +1 -0
- package/lib/types/cli/commands-status.d.ts +46 -0
- package/lib/types/cli/commands-status.d.ts.map +1 -0
- package/lib/types/cli/config-writer.d.ts +92 -0
- package/lib/types/cli/config-writer.d.ts.map +1 -0
- package/lib/types/cli/context.d.ts +39 -0
- package/lib/types/cli/context.d.ts.map +1 -0
- package/lib/types/cli/i18n.d.ts +32 -0
- package/lib/types/cli/i18n.d.ts.map +1 -0
- package/lib/types/cli/ipc-client.d.ts +83 -0
- package/lib/types/cli/ipc-client.d.ts.map +1 -0
- package/lib/types/cli/ipc-server.d.ts +113 -0
- package/lib/types/cli/ipc-server.d.ts.map +1 -0
- package/lib/types/cli/main.d.ts +57 -0
- package/lib/types/cli/main.d.ts.map +1 -0
- package/lib/types/cli/qr-terminal.d.ts +17 -0
- package/lib/types/cli/qr-terminal.d.ts.map +1 -0
- package/lib/types/client/RemoteStatusIcon.d.ts.map +1 -1
- package/lib/types/client/current-session-report.d.ts +4 -0
- package/lib/types/client/current-session-report.d.ts.map +1 -1
- package/lib/types/client/effects/model-group-side.d.ts.map +1 -1
- package/lib/types/client/effects/remote-session.d.ts.map +1 -1
- package/lib/types/client/locales.d.ts +47 -3
- package/lib/types/client/locales.d.ts.map +1 -1
- package/lib/types/client/settings/SettingsSection.d.ts.map +1 -1
- package/lib/types/client/settings/settings-css.d.ts +1 -1
- package/lib/types/client/settings/settings-css.d.ts.map +1 -1
- package/lib/types/client/styles/remote-session.css.d.ts +1 -1
- package/lib/types/client/styles/remote-session.css.d.ts.map +1 -1
- package/lib/types/client/wizard/GatewayWizard.d.ts.map +1 -1
- package/lib/types/client/wizard/cards/ProxyCard.d.ts.map +1 -1
- package/lib/types/client/wizard/wizard-css.d.ts +1 -1
- package/lib/types/client/wizard/wizard-css.d.ts.map +1 -1
- package/lib/types/client-data/model-group-side.d.ts +23 -2
- package/lib/types/client-data/model-group-side.d.ts.map +1 -1
- package/lib/types/client-data/remote-status.d.ts +47 -1
- package/lib/types/client-data/remote-status.d.ts.map +1 -1
- package/lib/types/client-data/settings-form.d.ts +85 -1
- package/lib/types/client-data/settings-form.d.ts.map +1 -1
- package/lib/types/client-data/wizard.d.ts +56 -1
- package/lib/types/client-data/wizard.d.ts.map +1 -1
- package/lib/types/client-routes.d.ts +25 -1
- package/lib/types/client-routes.d.ts.map +1 -1
- package/lib/types/config.d.ts +40 -14
- package/lib/types/config.d.ts.map +1 -1
- package/lib/types/debug-report.d.ts.map +1 -1
- package/lib/types/fetch-route-intercept.d.ts +66 -8
- package/lib/types/fetch-route-intercept.d.ts.map +1 -1
- package/lib/types/index.d.ts +9 -2
- package/lib/types/index.d.ts.map +1 -1
- package/lib/types/intercept.d.ts +72 -10
- package/lib/types/intercept.d.ts.map +1 -1
- package/lib/types/merge-streams.d.ts +79 -1
- package/lib/types/merge-streams.d.ts.map +1 -1
- package/lib/types/plugin-route-intercept.d.ts +25 -9
- package/lib/types/plugin-route-intercept.d.ts.map +1 -1
- package/lib/types/relay-access.d.ts +3 -1
- package/lib/types/relay-access.d.ts.map +1 -1
- package/lib/types/relay-client.d.ts +23 -2
- package/lib/types/relay-client.d.ts.map +1 -1
- package/lib/types/relay-pool.d.ts +148 -0
- package/lib/types/relay-pool.d.ts.map +1 -0
- package/lib/types/relay-server.d.ts +30 -3
- package/lib/types/relay-server.d.ts.map +1 -1
- package/lib/types/restart-fields.d.ts +5 -5
- package/lib/types/restart-fields.d.ts.map +1 -1
- package/lib/types/share-store.d.ts.map +1 -1
- package/lib/types/state-store.d.ts +179 -0
- package/lib/types/state-store.d.ts.map +1 -0
- package/lib/types/upload-limits.d.ts +15 -0
- package/lib/types/upload-limits.d.ts.map +1 -1
- package/lib/types/virtual-id.d.ts +21 -0
- package/lib/types/virtual-id.d.ts.map +1 -1
- package/lib/types/wizard/probe.d.ts.map +1 -1
- package/lib/types/wizard/proxy-snippets.d.ts +3 -0
- package/lib/types/wizard/proxy-snippets.d.ts.map +1 -1
- package/lib/types/wizard/routes.d.ts +1 -0
- package/lib/types/wizard/routes.d.ts.map +1 -1
- package/lib/types/wizard/status.d.ts +9 -0
- package/lib/types/wizard/status.d.ts.map +1 -1
- package/lib/types/wizard/verify.d.ts +3 -0
- package/lib/types/wizard/verify.d.ts.map +1 -1
- package/lib/upload-limits.js +15 -0
- package/lib/upload-limits.js.map +1 -1
- package/lib/virtual-id.js +60 -0
- package/lib/virtual-id.js.map +1 -1
- package/lib/wizard/probe.js +46 -1
- package/lib/wizard/probe.js.map +1 -1
- package/lib/wizard/proxy-snippets.js +14 -1
- package/lib/wizard/proxy-snippets.js.map +1 -1
- package/lib/wizard/routes.js +23 -4
- package/lib/wizard/routes.js.map +1 -1
- package/lib/wizard/status.js +21 -0
- package/lib/wizard/status.js.map +1 -1
- package/lib/wizard/verify.js +31 -8
- package/lib/wizard/verify.js.map +1 -1
- package/package.json +8 -4
- package/pwa/icons/badge-96.png +0 -0
- package/pwa/icons/badge.svg +1 -0
- package/pwa/manifest.json +2 -1
- package/pwa/sw.js +3 -1
- package/scripts/check-debug-report.mjs +41 -1
- package/scripts/check-settings-form.mjs +14 -3
- package/scripts/check-settings-view.mjs +247 -0
- package/scripts/check-wizard-ui.mjs +37 -0
- package/src/adapters/dsh-better-sidebar/index.ts +7 -3
- package/src/admin-routes.ts +10 -2
- package/src/cli/args.ts +289 -0
- package/src/cli/bootstrap.ts +569 -0
- package/src/cli/command-setup.ts +540 -0
- package/src/cli/commands-client.ts +369 -0
- package/src/cli/commands-host.ts +1127 -0
- package/src/cli/commands-status.ts +296 -0
- package/src/cli/config-writer.ts +354 -0
- package/src/cli/context.ts +38 -0
- package/src/cli/i18n.ts +645 -0
- package/src/cli/ipc-client.ts +216 -0
- package/src/cli/ipc-server.ts +531 -0
- package/src/cli/main.ts +285 -0
- package/src/cli/qr-terminal.ts +49 -0
- package/src/client/RemoteStatusIcon.tsx +4 -1
- package/src/client/current-session-report.ts +28 -4
- package/src/client/effects/model-group-side.ts +19 -5
- package/src/client/effects/remote-session.ts +26 -6
- package/src/client/locales.ts +96 -6
- package/src/client/settings/SettingsSection.tsx +287 -92
- package/src/client/settings/settings-css.ts +18 -0
- package/src/client/styles/remote-session.css.ts +15 -1
- package/src/client/wizard/GatewayWizard.tsx +470 -109
- package/src/client/wizard/cards/ProxyCard.tsx +2 -1
- package/src/client/wizard/wizard-css.ts +23 -0
- package/src/client-data/model-group-side.ts +37 -10
- package/src/client-data/remote-status.ts +112 -17
- package/src/client-data/settings-form.ts +164 -8
- package/src/client-data/wizard.ts +112 -2
- package/src/client-routes.ts +334 -26
- package/src/config.ts +62 -16
- package/src/debug-report.ts +27 -1
- package/src/fetch-route-intercept.ts +381 -32
- package/src/index.ts +566 -367
- package/src/intercept.ts +1866 -286
- package/src/merge-streams.ts +155 -10
- package/src/plugin-route-intercept.ts +120 -71
- package/src/relay-access.ts +14 -3
- package/src/relay-client.ts +163 -5
- package/src/relay-pool.ts +387 -0
- package/src/relay-server.ts +312 -16
- package/src/restart-fields.ts +6 -4
- package/src/share-store.ts +59 -26
- package/src/state-store.ts +428 -0
- package/src/upload-limits.ts +16 -0
- package/src/virtual-id.ts +57 -0
- package/src/wizard/probe.ts +54 -2
- package/src/wizard/proxy-snippets.ts +17 -2
- package/src/wizard/routes.ts +24 -5
- package/src/wizard/status.ts +28 -0
- package/src/wizard/verify.ts +33 -11
- package/AGENTS.md +0 -185
- package/docs/adr/0001-plugin-bridge-forwards-plugin-routes.md +0 -15
- package/docs/adr/0002-relay-emulates-websocket.md +0 -8
|
@@ -0,0 +1,387 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The multi-server relay connection pool (MS-2): up to {@link MAX_PAIRED_SERVERS}
|
|
3
|
+
* `RelayClient` instances, one per paired server in the state file, replacing
|
|
4
|
+
* the single row-driven client the client role used to keep. The paired
|
|
5
|
+
* list itself lives in `zen-remote-state.json` (src/state-store.ts — MS-1's
|
|
6
|
+
* three-in-one file); this module owns the LIVE half: start, handshake
|
|
7
|
+
* bookkeeping, re-pair, unpair, and the second dedupe layer's teardown.
|
|
8
|
+
*
|
|
9
|
+
* Two bookkeeping paths meet here. When one client's handshake lands, the
|
|
10
|
+
* handshake facts (serverId/serverName/deviceName) are persisted onto its
|
|
11
|
+
* state-file record — `recordServerHandshake` also REMOVES any other record
|
|
12
|
+
* that carries the same serverId (the same server re-paired under a new
|
|
13
|
+
* address), and this module stops that stale entry's client and announces it
|
|
14
|
+
* with `reason: 'dedupe'`. Removals the USER makes go out with
|
|
15
|
+
* `reason: 'unpair'`.
|
|
16
|
+
*
|
|
17
|
+
* Display names: several servers report the same `serverName` (the default is
|
|
18
|
+
* just the hostname), so the merged sidebar would show N identical rows.
|
|
19
|
+
* {@link deriveDisplayServerNames} numbers duplicates in pairing order
|
|
20
|
+
* (`home-1`, `home-2`, …) and leaves unique names bare; a rename that removes
|
|
21
|
+
* the collision removes the suffixes with it, because the derivation is
|
|
22
|
+
* recomputed on every `entries()` read.
|
|
23
|
+
*
|
|
24
|
+
* The clients themselves are the plain T23a relay clients — their getters
|
|
25
|
+
* read the pool's in-memory record per request, so a re-pair only updates
|
|
26
|
+
* that record and calls `credentialsChanged()`. `createClient` is injectable:
|
|
27
|
+
* the tests drive the whole lifecycle without a socket.
|
|
28
|
+
*/
|
|
29
|
+
|
|
30
|
+
import { createRelayClient } from './relay-client.js'
|
|
31
|
+
import type { RelayClient, RelayClock, RelayState, CreateRelayClientOptions } from './relay-client.js'
|
|
32
|
+
import type { PairedServerRecord } from './state-store.js'
|
|
33
|
+
import {
|
|
34
|
+
clearPairedServers,
|
|
35
|
+
listPairedServers,
|
|
36
|
+
MAX_PAIRED_SERVERS,
|
|
37
|
+
migrateLegacyClientIntoServers,
|
|
38
|
+
recordServerHandshake,
|
|
39
|
+
removePairedServer,
|
|
40
|
+
upsertPairedServer,
|
|
41
|
+
} from './state-store.js'
|
|
42
|
+
|
|
43
|
+
/** One entry's name inputs for {@link deriveDisplayServerNames}. */
|
|
44
|
+
export interface ServerNameEntry {
|
|
45
|
+
entryId: string
|
|
46
|
+
serverName: string
|
|
47
|
+
serverUrl: string
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* The display name of every entry: a trimmed non-empty `serverName` is the
|
|
52
|
+
* base, otherwise the server URL. Bases appearing once keep their name; bases
|
|
53
|
+
* appearing twice or more are numbered `-1`, `-2`, `-3`… in `entries` order
|
|
54
|
+
* (pairing order). Pure — recomputed whenever the pool is asked for entries,
|
|
55
|
+
* so a rename that removes a collision removes the suffixes too.
|
|
56
|
+
*/
|
|
57
|
+
export function deriveDisplayServerNames(entries: readonly ServerNameEntry[]): Map<string, string> {
|
|
58
|
+
const baseOf = (entry: ServerNameEntry): string =>
|
|
59
|
+
typeof entry.serverName === 'string' && entry.serverName.trim() !== '' ? entry.serverName.trim() : entry.serverUrl
|
|
60
|
+
const counts = new Map<string, number>()
|
|
61
|
+
for (const entry of entries) {
|
|
62
|
+
const base = baseOf(entry)
|
|
63
|
+
counts.set(base, (counts.get(base) ?? 0) + 1)
|
|
64
|
+
}
|
|
65
|
+
const seen = new Map<string, number>()
|
|
66
|
+
const out = new Map<string, string>()
|
|
67
|
+
for (const entry of entries) {
|
|
68
|
+
const base = baseOf(entry)
|
|
69
|
+
if ((counts.get(base) ?? 0) >= 2) {
|
|
70
|
+
const n = (seen.get(base) ?? 0) + 1
|
|
71
|
+
seen.set(base, n)
|
|
72
|
+
out.set(entry.entryId, `${base}-${n}`)
|
|
73
|
+
} else {
|
|
74
|
+
out.set(entry.entryId, base)
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
return out
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/** One live pool entry as the callers see it. */
|
|
81
|
+
export interface RelayPoolEntryView {
|
|
82
|
+
/** The stable state-file record id. */
|
|
83
|
+
entryId: string
|
|
84
|
+
/** The normalized server address. */
|
|
85
|
+
serverUrl: string
|
|
86
|
+
/** The handshake's server id; the persisted record id until one lands. */
|
|
87
|
+
serverId: string | undefined
|
|
88
|
+
/** The handshake's server name; the persisted name until one lands. */
|
|
89
|
+
serverName: string | undefined
|
|
90
|
+
/** {@link deriveDisplayServerNames} over the current entries. */
|
|
91
|
+
displayName: string
|
|
92
|
+
/** The live client. */
|
|
93
|
+
client: RelayClient
|
|
94
|
+
/** When the pairing was first added (epoch ms). */
|
|
95
|
+
createdAt: number
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/** Why an entry left the pool: the user removed it, or a handshake proved it
|
|
99
|
+
* a stale duplicate of another entry pointing at the same server. */
|
|
100
|
+
export type RelayPoolRemovalReason = 'unpair' | 'dedupe'
|
|
101
|
+
|
|
102
|
+
export interface RelayPoolOptions {
|
|
103
|
+
/** The state file (`<DSH_HOME>/zen-remote-state.json`). */
|
|
104
|
+
stateFile: string
|
|
105
|
+
/** The row's legacy single-server credentials, read once at startup: when
|
|
106
|
+
* the paired list is still empty they become `servers[0]`. */
|
|
107
|
+
legacySource?: () => { serverUrl?: string; deviceToken?: string } | undefined
|
|
108
|
+
/** This side's own interface fingerprints (T42), handed to every client.
|
|
109
|
+
* May be async (the real wiring computes against the typert registry,
|
|
110
|
+
* which loads package by package); the clients await it per handshake. */
|
|
111
|
+
computeOwnFingerprints: () => Record<string, string> | Promise<Record<string, string>>
|
|
112
|
+
/** Outbound fetch for the clients; defaults to the global one. */
|
|
113
|
+
fetchImpl?: typeof fetch
|
|
114
|
+
/** Clock for the clients' reconnect machinery; defaults to the real one. */
|
|
115
|
+
clock?: RelayClock
|
|
116
|
+
/** The client factory; injectable so tests run without sockets. */
|
|
117
|
+
createClient?: typeof createRelayClient
|
|
118
|
+
/** Every client state change, with the entry it belongs to. */
|
|
119
|
+
onStateChange?: (event: { entryId: string; serverId?: string; state: RelayState; lastError?: string }) => void
|
|
120
|
+
/** Every entry removal, with why it left. */
|
|
121
|
+
onServerRemoved?: (removed: { entryId: string; serverId?: string; reason: RelayPoolRemovalReason }) => void
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/** One in-memory entry: the record (mutated in place on re-pair/handshake),
|
|
125
|
+
* its client and the state-listener unsubscribe handle. */
|
|
126
|
+
interface PoolEntry {
|
|
127
|
+
record: PairedServerRecord
|
|
128
|
+
client: RelayClient
|
|
129
|
+
unsubscribe: () => void
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
export interface RelayPool {
|
|
133
|
+
/** Every entry in pairing order, with fresh display names. */
|
|
134
|
+
entries(): RelayPoolEntryView[]
|
|
135
|
+
/** Locate one entry by its state-file record id. */
|
|
136
|
+
getByEntryId(entryId: string): RelayPoolEntryView | undefined
|
|
137
|
+
/** Locate one entry by its (handshake or persisted) server id. */
|
|
138
|
+
getByServerId(serverId: string): RelayPoolEntryView | undefined
|
|
139
|
+
/** Add or re-pair one server: the record lands on disk first, then the
|
|
140
|
+
* live half follows (re-credentialed in place, or a fresh client). */
|
|
141
|
+
pair(input: { serverUrl: string; token: string; deviceId?: string; deviceName?: string }): Promise<
|
|
142
|
+
{ ok: true; entry: RelayPoolEntryView; replaced: boolean } | { ok: false; code: 'invalid-url' | 'limit-reached' }
|
|
143
|
+
>
|
|
144
|
+
/** Stop and remove one entry (disk record included). */
|
|
145
|
+
unpair(entryId: string): boolean
|
|
146
|
+
/** Stop everything and empty the paired list; answers the record count. */
|
|
147
|
+
unpairAll(): number
|
|
148
|
+
/** One entry's immediate reconnect, or every `offline` client's. Answers
|
|
149
|
+
* whether any attempt was started. */
|
|
150
|
+
reconnect(entryId?: string): Promise<boolean>
|
|
151
|
+
/** One authenticated ping per `offline` client; the reachable ones get
|
|
152
|
+
* `networkRestored()` (the controlled fast-path back online). */
|
|
153
|
+
probeAndRestoreAll(): Promise<{ reachable: boolean; reconnected: boolean }>
|
|
154
|
+
/** Forward a device rename to every paired server (T59). */
|
|
155
|
+
queueDeviceNameAll(name: string): void
|
|
156
|
+
/** Stop every client and detach the listeners (row teardown). */
|
|
157
|
+
stop(): void
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
/**
|
|
161
|
+
* Build the pool: one legacy migration pass, then one client per persisted
|
|
162
|
+
* record, each with a fire-and-forget startup connect — a failed first dial
|
|
163
|
+
* is the backoff ladder's business, exactly like the single-client wiring
|
|
164
|
+
* before it.
|
|
165
|
+
*/
|
|
166
|
+
export function createRelayPool(options: RelayPoolOptions): RelayPool {
|
|
167
|
+
const stateFile = options.stateFile
|
|
168
|
+
const createClientImpl = options.createClient ?? createRelayClient
|
|
169
|
+
|
|
170
|
+
/** Insertion order = pairing order = `listPairedServers` order. */
|
|
171
|
+
const pool = new Map<string, PoolEntry>()
|
|
172
|
+
|
|
173
|
+
/** The handshake id a view should report: live handshake first, then the
|
|
174
|
+
* persisted record (a fresh entry that never reached the server yet). */
|
|
175
|
+
const effectiveServerId = (entry: PoolEntry): string | undefined =>
|
|
176
|
+
entry.client.handshakeInfo?.serverId ?? entry.record.serverId
|
|
177
|
+
const effectiveServerName = (entry: PoolEntry): string | undefined =>
|
|
178
|
+
entry.client.handshakeInfo?.serverName ?? entry.record.serverName
|
|
179
|
+
|
|
180
|
+
const buildView = (entry: PoolEntry, displayName: string): RelayPoolEntryView => ({
|
|
181
|
+
entryId: entry.record.id,
|
|
182
|
+
serverUrl: entry.record.serverUrl,
|
|
183
|
+
serverId: effectiveServerId(entry),
|
|
184
|
+
serverName: effectiveServerName(entry),
|
|
185
|
+
displayName,
|
|
186
|
+
client: entry.client,
|
|
187
|
+
createdAt: entry.record.createdAt,
|
|
188
|
+
})
|
|
189
|
+
|
|
190
|
+
const entries = (): RelayPoolEntryView[] => {
|
|
191
|
+
const list = [...pool.values()]
|
|
192
|
+
const names = deriveDisplayServerNames(
|
|
193
|
+
list.map((entry) => ({ entryId: entry.record.id, serverName: effectiveServerName(entry) ?? '', serverUrl: entry.record.serverUrl })),
|
|
194
|
+
)
|
|
195
|
+
return list.map((entry) => buildView(entry, names.get(entry.record.id) ?? entry.record.serverUrl))
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
/** The client's credential getters read the CURRENT record per request, so
|
|
199
|
+
* a re-pair (an in-place record swap) reaches the next attempt directly. */
|
|
200
|
+
const createClientFor = (record: PairedServerRecord): RelayClient => {
|
|
201
|
+
const liveRecord = (): PairedServerRecord => pool.get(record.id)?.record ?? record
|
|
202
|
+
const clientOptions: CreateRelayClientOptions = {
|
|
203
|
+
getServerUrl: () => liveRecord().serverUrl,
|
|
204
|
+
getToken: () => liveRecord().token,
|
|
205
|
+
computeOwnFingerprints: options.computeOwnFingerprints,
|
|
206
|
+
...(options.fetchImpl !== undefined ? { fetchImpl: options.fetchImpl } : {}),
|
|
207
|
+
...(options.clock !== undefined ? { clock: options.clock } : {}),
|
|
208
|
+
}
|
|
209
|
+
return createClientImpl(clientOptions)
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
/** Fold one completed handshake into the record — on disk (whose dedupe
|
|
213
|
+
* may retire stale same-server entries) and in memory — then stop and
|
|
214
|
+
* announce the retired ones. A non-8-hex server id is not handshake
|
|
215
|
+
* material; skip the whole bookkeeping for it. */
|
|
216
|
+
const handleHandshake = (entry: PoolEntry): void => {
|
|
217
|
+
const info = entry.client.handshakeInfo
|
|
218
|
+
if (info === undefined) return
|
|
219
|
+
const result = recordServerHandshake(stateFile, entry.record.id, {
|
|
220
|
+
serverId: info.serverId,
|
|
221
|
+
serverName: info.serverName,
|
|
222
|
+
...(info.deviceName !== undefined ? { deviceName: info.deviceName } : {}),
|
|
223
|
+
})
|
|
224
|
+
entry.record = {
|
|
225
|
+
...entry.record,
|
|
226
|
+
serverId: info.serverId,
|
|
227
|
+
...(info.serverName !== '' ? { serverName: info.serverName } : {}),
|
|
228
|
+
...(info.deviceName !== undefined ? { deviceName: info.deviceName } : {}),
|
|
229
|
+
}
|
|
230
|
+
for (const removedId of result.removedEntryIds) {
|
|
231
|
+
const stale = pool.get(removedId)
|
|
232
|
+
if (stale !== undefined) {
|
|
233
|
+
stale.unsubscribe()
|
|
234
|
+
stale.client.stop()
|
|
235
|
+
pool.delete(removedId)
|
|
236
|
+
}
|
|
237
|
+
options.onServerRemoved?.({ entryId: removedId, serverId: info.serverId, reason: 'dedupe' })
|
|
238
|
+
}
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
const attach = (entry: PoolEntry): void => {
|
|
242
|
+
entry.unsubscribe = entry.client.subscribe((state) => {
|
|
243
|
+
if (state === 'online') handleHandshake(entry)
|
|
244
|
+
options.onStateChange?.({
|
|
245
|
+
entryId: entry.record.id,
|
|
246
|
+
serverId: effectiveServerId(entry),
|
|
247
|
+
state,
|
|
248
|
+
...(entry.client.lastError !== undefined ? { lastError: entry.client.lastError } : {}),
|
|
249
|
+
})
|
|
250
|
+
})
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
const addEntry = (record: PairedServerRecord): void => {
|
|
254
|
+
const entry: PoolEntry = { record, client: createClientFor(record), unsubscribe: () => {} }
|
|
255
|
+
attach(entry)
|
|
256
|
+
pool.set(record.id, entry)
|
|
257
|
+
// Startup dials are fire-and-forget: the failure is the client's own
|
|
258
|
+
// backoff ladder's business, never the wiring's (same as index.ts).
|
|
259
|
+
void entry.client.connect().catch(() => {})
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
// Startup: fold the legacy row credentials in (only when the list is still
|
|
263
|
+
// empty), then one client per record.
|
|
264
|
+
const legacy = options.legacySource?.()
|
|
265
|
+
if (legacy !== undefined) {
|
|
266
|
+
try {
|
|
267
|
+
migrateLegacyClientIntoServers(stateFile, legacy)
|
|
268
|
+
} catch {
|
|
269
|
+
// Best effort — the row keeps its credentials for the next start.
|
|
270
|
+
}
|
|
271
|
+
}
|
|
272
|
+
for (const record of listPairedServers(stateFile)) addEntry(record)
|
|
273
|
+
|
|
274
|
+
const detach = (entry: PoolEntry): void => {
|
|
275
|
+
entry.unsubscribe()
|
|
276
|
+
entry.client.stop()
|
|
277
|
+
pool.delete(entry.record.id)
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
return {
|
|
281
|
+
entries,
|
|
282
|
+
|
|
283
|
+
getByEntryId(entryId) {
|
|
284
|
+
return entries().find((view) => view.entryId === entryId)
|
|
285
|
+
},
|
|
286
|
+
|
|
287
|
+
getByServerId(serverId) {
|
|
288
|
+
return entries().find((view) => view.serverId === serverId)
|
|
289
|
+
},
|
|
290
|
+
|
|
291
|
+
async pair(input) {
|
|
292
|
+
const result = upsertPairedServer(stateFile, input)
|
|
293
|
+
if (!result.ok) return result
|
|
294
|
+
const existing = pool.get(result.record.id)
|
|
295
|
+
if (existing !== undefined) {
|
|
296
|
+
existing.record = result.record
|
|
297
|
+
// The getters read the swapped record per request; this both cancels
|
|
298
|
+
// a pending retry wait and dials the new credentials immediately.
|
|
299
|
+
existing.client.credentialsChanged()
|
|
300
|
+
} else {
|
|
301
|
+
addEntry(result.record)
|
|
302
|
+
}
|
|
303
|
+
// The entry is in the pool by now by construction; entries() also gives
|
|
304
|
+
// it the dedupe-numbered display name the other views would show.
|
|
305
|
+
const view = entries().find((candidate) => candidate.entryId === result.record.id)
|
|
306
|
+
return { ok: true, entry: view as RelayPoolEntryView, replaced: result.replaced }
|
|
307
|
+
},
|
|
308
|
+
|
|
309
|
+
unpair(entryId) {
|
|
310
|
+
const entry = pool.get(entryId)
|
|
311
|
+
const removed = removePairedServer(stateFile, entryId)
|
|
312
|
+
if (entry !== undefined) detach(entry)
|
|
313
|
+
if (removed) {
|
|
314
|
+
options.onServerRemoved?.({
|
|
315
|
+
entryId,
|
|
316
|
+
serverId: entry !== undefined ? effectiveServerId(entry) : undefined,
|
|
317
|
+
reason: 'unpair',
|
|
318
|
+
})
|
|
319
|
+
}
|
|
320
|
+
return removed
|
|
321
|
+
},
|
|
322
|
+
|
|
323
|
+
unpairAll() {
|
|
324
|
+
const records = listPairedServers(stateFile)
|
|
325
|
+
const count = clearPairedServers(stateFile)
|
|
326
|
+
const onDisk = new Set(records.map((record) => record.id))
|
|
327
|
+
for (const record of records) {
|
|
328
|
+
const entry = pool.get(record.id)
|
|
329
|
+
if (entry !== undefined) detach(entry)
|
|
330
|
+
options.onServerRemoved?.({
|
|
331
|
+
entryId: record.id,
|
|
332
|
+
serverId: entry !== undefined ? effectiveServerId(entry) : record.serverId,
|
|
333
|
+
reason: 'unpair',
|
|
334
|
+
})
|
|
335
|
+
}
|
|
336
|
+
// Memory-only stragglers (a record removed underneath us mid-flight):
|
|
337
|
+
// stop them too — unpairAll means ALL — without a second announcement
|
|
338
|
+
// for the ones the disk pass already reported.
|
|
339
|
+
for (const entry of [...pool.values()]) {
|
|
340
|
+
detach(entry)
|
|
341
|
+
if (!onDisk.has(entry.record.id)) {
|
|
342
|
+
options.onServerRemoved?.({ entryId: entry.record.id, serverId: effectiveServerId(entry), reason: 'unpair' })
|
|
343
|
+
}
|
|
344
|
+
}
|
|
345
|
+
return count
|
|
346
|
+
},
|
|
347
|
+
|
|
348
|
+
async reconnect(entryId) {
|
|
349
|
+
if (entryId !== undefined) {
|
|
350
|
+
const entry = pool.get(entryId)
|
|
351
|
+
return entry !== undefined ? entry.client.reconnect() : false
|
|
352
|
+
}
|
|
353
|
+
let any = false
|
|
354
|
+
for (const entry of pool.values()) {
|
|
355
|
+
if (entry.client.state === 'offline') any = entry.client.reconnect() || any
|
|
356
|
+
}
|
|
357
|
+
return any
|
|
358
|
+
},
|
|
359
|
+
|
|
360
|
+
async probeAndRestoreAll() {
|
|
361
|
+
let reachable = false
|
|
362
|
+
let reconnected = false
|
|
363
|
+
for (const entry of pool.values()) {
|
|
364
|
+
if (entry.client.state !== 'offline') continue
|
|
365
|
+
if (!(await entry.client.probeHealth())) continue
|
|
366
|
+
reachable = true
|
|
367
|
+
reconnected = entry.client.networkRestored() || reconnected
|
|
368
|
+
}
|
|
369
|
+
return { reachable, reconnected }
|
|
370
|
+
},
|
|
371
|
+
|
|
372
|
+
queueDeviceNameAll(name) {
|
|
373
|
+
for (const entry of pool.values()) entry.client.queueDeviceName(name)
|
|
374
|
+
},
|
|
375
|
+
|
|
376
|
+
stop() {
|
|
377
|
+
for (const entry of [...pool.values()]) {
|
|
378
|
+
entry.unsubscribe()
|
|
379
|
+
entry.client.stop()
|
|
380
|
+
}
|
|
381
|
+
pool.clear()
|
|
382
|
+
},
|
|
383
|
+
}
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
/** Re-exported so the pool's consumers never import state-store for the cap. */
|
|
387
|
+
export { MAX_PAIRED_SERVERS }
|