dsh-plugin-mobile-link 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md ADDED
@@ -0,0 +1,111 @@
1
+ # dsh-plugin-mobile-link
2
+
3
+ **The computer half of [DSH Mobile](https://github.com/jayantTang/DSH_Mobile): it
4
+ dials a relay over WSS, so the native iOS client reaches this DSH instance even
5
+ though the computer has no public IP.**
6
+
7
+ ```
8
+ iPhone App ──WSS──▶ Relay(公网,只鉴权与转发)──WSS──▶ this plugin ──HTTP+WS──▶ 127.0.0.1:<port>
9
+ ```
10
+
11
+ The connector always talks to the local DSH as `127.0.0.1:<port>`, so the host's
12
+ authority and its trust fence are unaffected: no `--trusted-host`, no rewritten
13
+ `Host`, no new listening port on this machine.
14
+
15
+ ```
16
+ plugins/mobile-link/
17
+ ├── package.json # type: module, dsh.bundle.patch, bin: dsh-mobile-link
18
+ ├── cordis.patch.yml # bundle patch: the loader row + config reference
19
+ ├── lib/
20
+ │ ├── index.js # cordis plugin: starts the agent, registers 3 routes
21
+ │ ├── link.js # the DLP agent transport: identity, socket, heartbeat, reconnect
22
+ │ ├── router.js # frames ⇄ local DSH mux streams, per-device `$events`
23
+ │ ├── dlp.js # DLP v1 frame codec (shared with the relay)
24
+ │ ├── files.js # phone → computer file transfers (`_link/file*` methods)
25
+ │ ├── enroll.js # invite-code redemption, writes ~/.dsh/mobile-link/agent.json
26
+ │ ├── routes.js # GET /mobile-link/status, POST /pair-code, GET /qr
27
+ │ ├── qr.js # dependency-free QR SVG
28
+ │ └── cli.js # dsh-mobile-link: enroll / mint-pair-code
29
+ └── test/ # node --test, 100+ tests (no DSH, no network)
30
+ ```
31
+
32
+ ## Install
33
+
34
+ ```bash
35
+ dsh plugin --profile web add dsh-plugin-mobile-link
36
+ ```
37
+
38
+ Then restart DSH (or reload the profile). From a checkout the same command takes
39
+ a path — the leading `./` matters, because DSH anchors a relative spec against
40
+ your current directory instead of resolving it inside the profile:
41
+
42
+ ```bash
43
+ dsh plugin --profile web add ./plugins/mobile-link
44
+ ```
45
+
46
+ ## Use
47
+
48
+ 1. Register this computer with a relay (once). Either redeem an invite code:
49
+
50
+ ```bash
51
+ dsh-mobile-link enroll --invite <邀请码> --relay wss://<你的站点>/dsh-link
52
+ ```
53
+
54
+ …or register it on the relay host with `relay/admin.py agent-register
55
+ --write-config ~/.dsh/mobile-link/agent.json`.
56
+
57
+ 2. Mint a pairing code, and show it as a QR the phone camera can read:
58
+
59
+ ```bash
60
+ dsh-mobile-link --mint-pair-code # prints JSON: code + relay + agentId
61
+ ```
62
+
63
+ Or open `/mobile-link/qr` on the local DSH (behind DSH's own authentication).
64
+
65
+ 3. Scan it in the iOS app. After that the phone reconnects on its own — nothing
66
+ else has to be run by hand.
67
+
68
+ `GET /mobile-link/status` reports whether the link is up, which devices are
69
+ attached, and whether this computer is registered yet.
70
+
71
+ ## Configuration
72
+
73
+ All optional; the defaults are what a stock deployment needs. Set them in the
74
+ plugin's config block (`cordis.patch.yml`), not by editing this file.
75
+
76
+ | Key | Default | Meaning |
77
+ | --- | --- | --- |
78
+ | `enabled` | `true` | `false` stops the outbound link without uninstalling |
79
+ | `relayUrl` | from `agent.json` | Relay base URL; a bare deployment dials `<relay>/link/agent` |
80
+ | `agentId` / `agentSecret` | from `agent.json` | Override the enrolled identity |
81
+ | `stateFile` | `~/.dsh/mobile-link/agent.json` | Where the identity lives |
82
+ | `dshUrl` | discovery | Explicit local DSH base URL |
83
+ | `endpointFile` | `$DSH_HOME/desktop-shell/endpoint.json` | DSH endpoint handoff file |
84
+ | `heartbeatMs` | `20000` | DLP ping interval |
85
+
86
+ Address resolution order: this config block, then `DSH_RELAY_URL` (or
87
+ `DSH_MOBILE_LINK_RELAY`), then the enrolled identity file, then the repository
88
+ placeholder `wss://relay.example.com/dsh-link` — the placeholder being the
89
+ "nothing configured yet" sentinel rather than a real address.
90
+
91
+ ## Design notes
92
+
93
+ - **No required services.** `inject` is empty and the routes are attached through
94
+ `ctx.inject([...])`, so a future DSH that renames or drops `webServer` /
95
+ `connection` cannot make the tunnel fatal on boot.
96
+ - **Protocol-transparent.** Nothing outside `lib/` is imported and no DSH
97
+ business API is touched: the connector forwards frames, it does not interpret
98
+ sessions. The relay has the same property.
99
+ - **Disable** by removing the row from the profile's `cordis.patch.yml`, or by
100
+ setting `enabled: false`.
101
+
102
+ ## Test
103
+
104
+ ```bash
105
+ cd plugins/mobile-link && npm test # zero dependencies
106
+ ```
107
+
108
+ The wire protocol is specified in
109
+ [`docs/RELAY-PROTOCOL.md`](https://github.com/jayantTang/DSH_Mobile/blob/main/docs/RELAY-PROTOCOL.md).
110
+
111
+ MIT.
@@ -0,0 +1,37 @@
1
+ # Bundle patch for dsh-plugin-mobile-link.
2
+ #
3
+ # `dsh plugin --profile web add <this directory>` installs the package into the
4
+ # profile and appends it to dsh.profile.bundles, so the row below joins the tree
5
+ # on every boot with no hand-edited block in the profile's own cordis.patch.yml.
6
+ #
7
+ # `name` is anchored relative to this file (DSH rewrites ./ paths to file URLs),
8
+ # so the plugin keeps resolving wherever the profile lives.
9
+ #
10
+ # Config reference (all optional; the defaults are what a stock deployment
11
+ # needs):
12
+ # enabled false disables the outbound link without uninstalling
13
+ # relayUrl DLP relay base URL. The relay is published under a path prefix
14
+ # on an existing site, so a bare deployment dials
15
+ # <relay>/link/agent. The configured path is always kept, never
16
+ # replaced. ws:// is accepted for local testing (e.g.
17
+ # ws://127.0.0.1:8787). Left unset here on purpose: a value
18
+ # pinned in this file outranks both DSH_RELAY_URL and the
19
+ # address already enrolled in ~/.dsh/mobile-link/agent.json.
20
+ # agentId overrides ~/.dsh/mobile-link/agent.json
21
+ # agentSecret overrides ~/.dsh/mobile-link/agent.json
22
+ # stateFile agent identity file (default ~/.dsh/mobile-link/agent.json)
23
+ # dshUrl explicit local DSH base URL override (default: discovery)
24
+ # endpointFile DSH endpoint handoff file (default $DSH_HOME/desktop-shell/endpoint.json)
25
+ #
26
+ # Address resolution order: this config block, then DSH_RELAY_URL (or
27
+ # DSH_MOBILE_LINK_RELAY), then the enrolled identity file, then the repository
28
+ # placeholder wss://relay.example.com/dsh-link — the placeholder being the
29
+ # "nothing configured yet" sentinel rather than a real address.
30
+
31
+ - insert:
32
+ - id: mobile-link
33
+ name: './lib/index.js'
34
+ config:
35
+ enabled: true
36
+ heartbeatMs: 20000
37
+ reconnect: true
package/lib/cli.js ADDED
@@ -0,0 +1,191 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * Standalone runner for the DLP agent, outside DSH.
4
+ *
5
+ * The cordis plugin is the normal entry point; this CLI exists so the agent can
6
+ * be exercised (and the whole middle tier integration-tested) without booting a
7
+ * DSH profile:
8
+ *
9
+ * node lib/cli.js --relay ws://127.0.0.1:8787 \
10
+ * --agent-id agt_x --agent-secret as_y --status-file /tmp/mobile-link.json
11
+ *
12
+ * It prints one machine-readable line per connection state change:
13
+ * MOBILE_LINK_STATE {"state":"connected", ...}
14
+ *
15
+ * `--mint-pair-code` skips the link entirely and just asks the relay for a
16
+ * pairing code (plus its `dsh://pair?...` payload) through the same
17
+ * prefix-preserving URL join the live agent uses.
18
+ *
19
+ * `enroll` is the first-run path for someone else's computer: it redeems an
20
+ * invite code for an identity and writes `~/.dsh/mobile-link/agent.json`, then
21
+ * exits. Everything after that is the ordinary agent.
22
+ */
23
+
24
+ import { rename, writeFile } from 'node:fs/promises'
25
+
26
+ import { enrollAgent, inviteFrom } from './enroll.js'
27
+ import { MobileLinkAgent } from './link.js'
28
+ import { mintPairCode } from './pairing.js'
29
+ import { INVITE_ENV, defaultStatePath, resolveIdentity } from './state.js'
30
+
31
+ /** Atomic status snapshot so readers never observe a half-written file. */
32
+ async function writeJsonAtomic(path, value) {
33
+ const temporary = `${path}.${process.pid}.tmp`
34
+ await writeFile(temporary, `${JSON.stringify(value, null, 2)}\n`, { mode: 0o600 })
35
+ await rename(temporary, path)
36
+ }
37
+
38
+ function parseArgs(argv) {
39
+ const args = { _: [] }
40
+ for (let index = 0; index < argv.length; index += 1) {
41
+ const token = argv[index]
42
+ if (!token.startsWith('--')) {
43
+ args._.push(token)
44
+ continue
45
+ }
46
+ const eq = token.indexOf('=')
47
+ const key = (eq === -1 ? token.slice(2) : token.slice(2, eq)).replace(/-([a-z])/g, (_m, c) => c.toUpperCase())
48
+ if (eq !== -1) {
49
+ args[key] = token.slice(eq + 1)
50
+ } else if (argv[index + 1] !== undefined && !argv[index + 1].startsWith('--')) {
51
+ args[key] = argv[index + 1]
52
+ index += 1
53
+ } else {
54
+ args[key] = true
55
+ }
56
+ }
57
+ return args
58
+ }
59
+
60
+ async function main() {
61
+ const args = parseArgs(process.argv.slice(2))
62
+ if (args.help) {
63
+ process.stdout.write(`usage: cli.js enroll --invite <code> [--relay <ws url>] [--name <label>]
64
+ Redeem a one-time invite code for an identity in ${defaultStatePath()}.
65
+ The invite may also arrive as ${INVITE_ENV}. Exits non-zero on failure.
66
+
67
+ cli.js --relay <ws url> --agent-id <id> --agent-secret <secret>
68
+ [--state-file <path>] [--dsh-url <url>] [--endpoint-file <path>]
69
+ [--status-file <path>] [--heartbeat-ms <n>] [--log-level debug|info|warn]
70
+
71
+ cli.js --mint-pair-code --relay <ws url> --agent-id <id> --agent-secret <secret>
72
+ [--ttl-ms <n>] print one pairing code as JSON and exit
73
+ `)
74
+ return 0
75
+ }
76
+
77
+ if (args._[0] === 'enroll' || args.enroll) {
78
+ const explicit = typeof args.enroll === 'string' ? args.enroll : undefined
79
+ const settled = await enrollAgent({
80
+ relayUrl: args.relay,
81
+ inviteCode: inviteFrom({ inviteCode: explicit ?? args.invite }),
82
+ name: args.name,
83
+ stateFile: typeof args.stateFile === 'string' ? args.stateFile : undefined,
84
+ })
85
+ process.stdout.write(`${JSON.stringify({
86
+ ok: true,
87
+ agentId: settled.agentId,
88
+ agentName: settled.agentName,
89
+ relayUrl: settled.relayUrl,
90
+ stateFile: settled.stateFile,
91
+ }, null, 2)}\n`)
92
+ process.stdout.write('这台电脑已登记。重启 DSH 后连接器会使用新的身份。\n')
93
+ return 0
94
+ }
95
+
96
+ if (args.mintPairCode) {
97
+ const identity = await resolveIdentity({
98
+ stateFile: typeof args.stateFile === 'string' ? args.stateFile : undefined,
99
+ agentId: args.agentId,
100
+ agentSecret: args.agentSecret,
101
+ relayUrl: args.relay,
102
+ })
103
+ const minted = await mintPairCode({
104
+ identity,
105
+ ttlMs: Number(args.ttlMs) || 10 * 60 * 1000,
106
+ })
107
+ process.stdout.write(`${JSON.stringify(minted, null, 2)}\n`)
108
+ return 0
109
+ }
110
+
111
+ const level = String(args.logLevel ?? process.env.DSH_MOBILE_LINK_LOG ?? 'info')
112
+ const enabledLevels = level === 'debug' ? ['debug', 'info', 'warn', 'error'] : ['info', 'warn', 'error']
113
+ const write = (stream, prefix) => (...parts) => {
114
+ stream.write(`${prefix} ${parts.map(String).join(' ')}\n`)
115
+ }
116
+ const logger = {
117
+ debug: enabledLevels.includes('debug') ? write(process.stdout, '[debug]') : () => {},
118
+ info: write(process.stdout, '[info]'),
119
+ warn: write(process.stderr, '[warn]'),
120
+ error: write(process.stderr, '[error]'),
121
+ }
122
+
123
+ const agent = new MobileLinkAgent({
124
+ relayUrl: args.relay,
125
+ agentId: args.agentId,
126
+ agentSecret: args.agentSecret,
127
+ stateFile: typeof args.stateFile === 'string' ? args.stateFile : undefined,
128
+ dshUrl: typeof args.dshUrl === 'string' ? args.dshUrl : undefined,
129
+ endpointFile: typeof args.endpointFile === 'string' ? args.endpointFile : undefined,
130
+ heartbeatMs: Number(args.heartbeatMs) || 20000,
131
+ logger,
132
+ })
133
+
134
+ let previous = ''
135
+ agent.on('status', () => {
136
+ const status = agent.status()
137
+ const stamp = JSON.stringify(status)
138
+ if (stamp === previous) return
139
+ previous = stamp
140
+ if (args.statusFile) {
141
+ void writeJsonAtomic(String(args.statusFile), status).catch(() => {})
142
+ }
143
+ process.stdout.write(`MOBILE_LINK_STATE ${JSON.stringify({
144
+ state: status.state,
145
+ connected: status.connected,
146
+ agentId: status.agentId,
147
+ relayUrl: status.relayUrl,
148
+ deviceCount: status.deviceCount,
149
+ lastError: status.lastError,
150
+ })}\n`)
151
+ })
152
+
153
+ const shutdown = async (signal) => {
154
+ logger.info(`received ${signal}, stopping`)
155
+ await agent.stop()
156
+ process.exit(0)
157
+ }
158
+ process.on('SIGINT', () => void shutdown('SIGINT'))
159
+ process.on('SIGTERM', () => void shutdown('SIGTERM'))
160
+
161
+ await agent.start()
162
+ if (args.once) {
163
+ if (agent.state !== 'connected') {
164
+ await new Promise((resolve) => {
165
+ const timer = setTimeout(resolve, Number(args.onceMs) || 5000)
166
+ agent.on('status', () => {
167
+ if (agent.state === 'connected') {
168
+ clearTimeout(timer)
169
+ resolve()
170
+ }
171
+ })
172
+ })
173
+ }
174
+ const connected = agent.state === 'connected'
175
+ await agent.stop()
176
+ return connected ? 0 : 1
177
+ }
178
+ // Keep the process alive until a signal arrives.
179
+ await new Promise(() => {})
180
+ return 0
181
+ }
182
+
183
+ main().then(
184
+ (code) => {
185
+ process.exitCode = code
186
+ },
187
+ (error) => {
188
+ process.stderr.write(`mobile-link: fatal: ${error instanceof Error ? error.stack : error}\n`)
189
+ process.exitCode = 1
190
+ },
191
+ )
package/lib/dlp.js ADDED
@@ -0,0 +1,296 @@
1
+ /**
2
+ * Pure DLP v1 helpers: frame codec, stream-id mapping, waterfall dedupe and
3
+ * reconnect backoff. No sockets, no timers, no filesystem — everything here is
4
+ * driven by `node --test` without a DSH instance.
5
+ *
6
+ * Wire contract: docs/RELAY-PROTOCOL.md §3.
7
+ */
8
+
9
+ /** Frame ceiling aligned with DSH's image-attachment limit (spec §5). */
10
+ export const MAX_FRAME_BYTES = 32 * 1024 * 1024
11
+
12
+ export const DEVICE_TO_AGENT = new Set(['req', 'open', 'cancel', 'eventResult', 'ping', 'hello'])
13
+ export const AGENT_TO_DEVICE = new Set(['res', 'item', 'end', 'streamError', 'event', 'hostStatus', 'pong', 'error'])
14
+ /** Relay-internal control frames carrying device lifecycle (see relay/NOTES.md). */
15
+ export const RELAY_CONTROL = new Set(['deviceAttach', 'deviceDetach'])
16
+
17
+ const ID_FRAMES = new Set(['req', 'open', 'cancel', 'res', 'item', 'end', 'streamError', 'eventResult'])
18
+
19
+ export function encodeFrame(frame) {
20
+ return JSON.stringify(frame)
21
+ }
22
+
23
+ /**
24
+ * Decode one wire payload.
25
+ * @returns the frame object, or `undefined` when the payload is unusable
26
+ * (not JSON, not an object, or missing a non-empty string `t`).
27
+ */
28
+ export function decodeFrame(raw) {
29
+ if (typeof raw !== 'string') return undefined
30
+ if (raw.length > MAX_FRAME_BYTES) return undefined
31
+ let value
32
+ try {
33
+ value = JSON.parse(raw)
34
+ } catch {
35
+ return undefined
36
+ }
37
+ if (value === null || typeof value !== 'object' || Array.isArray(value)) return undefined
38
+ if (typeof value.t !== 'string' || value.t.length === 0) return undefined
39
+ return value
40
+ }
41
+
42
+ export function frameType(frame) {
43
+ return typeof frame?.t === 'string' ? frame.t : ''
44
+ }
45
+
46
+ /** Structural check for one device -> agent frame; returns an error string or undefined. */
47
+ export function deviceFrameError(frame) {
48
+ const kind = frameType(frame)
49
+ if (!DEVICE_TO_AGENT.has(kind)) return undefined
50
+ if (ID_FRAMES.has(kind) && !nonEmptyString(frame.id)) return `${kind}: missing \`id\``
51
+ if (kind === 'req' && !nonEmptyString(frame.method)) return 'req: missing `method`'
52
+ if (kind === 'open' && !nonEmptyString(frame.endpoint)) return 'open: missing `endpoint`'
53
+ return undefined
54
+ }
55
+
56
+ export function nonEmptyString(value) {
57
+ return typeof value === 'string' && value.length > 0
58
+ }
59
+
60
+ export function deviceIdOf(frame) {
61
+ return nonEmptyString(frame?.deviceId) ? frame.deviceId : undefined
62
+ }
63
+
64
+ /**
65
+ * Exponential backoff with ±jitter, capped (spec §4.2: 1s -> 2s -> ... -> 30s ±20%).
66
+ * @param attempt zero-based retry index.
67
+ */
68
+ export function nextBackoff(attempt, { baseMs = 1000, maxMs = 30000, jitter = 0.2, random = Math.random } = {}) {
69
+ const exponent = Math.max(0, Math.min(30, Number(attempt) || 0))
70
+ const raw = Math.min(maxMs, baseMs * 2 ** exponent)
71
+ const spread = raw * jitter
72
+ const offset = (random() * 2 - 1) * spread
73
+ return Math.max(0, Math.round(raw + offset))
74
+ }
75
+
76
+ /**
77
+ * Split a relay URL into its HTTP and WebSocket bases, keeping the path prefix.
78
+ *
79
+ * The relay is published under a path on an existing site, so the configured
80
+ * base carries a prefix (`wss://relay.example.com/dsh-link`) that must be
81
+ * *kept* when an endpoint is appended — never replaced. A trailing slash is
82
+ * tolerated and normalised away. Accepts `wss://host`, `https://host/prefix`,
83
+ * `ws://127.0.0.1:8787` and bare hosts.
84
+ */
85
+ export function normalizeRelayUrl(raw) {
86
+ const text = String(raw ?? '').trim()
87
+ if (!text) throw new Error('relay URL is required')
88
+ const withScheme = /^[a-z][a-z0-9+.-]*:\/\//i.test(text) ? text : `wss://${text}`
89
+ const url = new URL(withScheme)
90
+ const secure = url.protocol === 'wss:' || url.protocol === 'https:'
91
+ if (!['ws:', 'wss:', 'http:', 'https:'].includes(url.protocol)) {
92
+ throw new Error(`unsupported relay scheme ${url.protocol}`)
93
+ }
94
+ const authority = `${url.host}`
95
+ const basePath = url.pathname.replace(/\/+$/, '')
96
+ return {
97
+ httpBase: `${secure ? 'https' : 'http'}://${authority}${basePath}`,
98
+ wsBase: `${secure ? 'wss' : 'ws'}://${authority}${basePath}`,
99
+ secure,
100
+ authority,
101
+ }
102
+ }
103
+
104
+ /**
105
+ * The relay address the repository ships as a placeholder.
106
+ *
107
+ * It is a *sentinel*, not a deployment: a value equal to it means "no relay has
108
+ * been configured here", never "dial relay.example.com". The distinction is
109
+ * load-bearing — a configured placeholder used to outrank the identity already
110
+ * enrolled in `agent.json`, so one DSH restart pointed an enrolled computer at
111
+ * the placeholder *and* wrote that back over the real address, which left the
112
+ * phone unable to connect until somebody noticed. Anything that resolves an
113
+ * address has to recognise the sentinel.
114
+ */
115
+ export const PLACEHOLDER_RELAY_URL = 'wss://relay.example.com/dsh-link'
116
+
117
+ /**
118
+ * True when `raw` is, or normalises to, the repository placeholder — a trailing
119
+ * slash or an `https://` spelling of the same address still counts.
120
+ */
121
+ export function isPlaceholderRelayUrl(raw) {
122
+ if (typeof raw !== 'string' || !raw.trim()) return false
123
+ try {
124
+ return normalizeRelayUrl(raw).wsBase === normalizeRelayUrl(PLACEHOLDER_RELAY_URL).wsBase
125
+ } catch {
126
+ return false
127
+ }
128
+ }
129
+
130
+ /**
131
+ * Join one endpoint onto a relay base without losing the base's path prefix.
132
+ * `('wss://host/prefix/', '/link/agent')` -> `'wss://host/prefix/link/agent'`.
133
+ */
134
+ export function joinRelayPath(base, suffix) {
135
+ const head = String(base ?? '').replace(/\/+$/, '')
136
+ const tail = String(suffix ?? '').replace(/^\/+/, '')
137
+ return `${head}/${tail}`
138
+ }
139
+
140
+ /** The connector's relay endpoint, keeping any configured path prefix. */
141
+ export function agentEndpoint(relayUrl) {
142
+ return joinRelayPath(normalizeRelayUrl(relayUrl).wsBase, 'link/agent')
143
+ }
144
+
145
+ /** The relay's pairing-code endpoint, keeping any configured path prefix. */
146
+ export function pairCodeEndpoint(relayUrl) {
147
+ return joinRelayPath(normalizeRelayUrl(relayUrl).httpBase, 'pair/code')
148
+ }
149
+
150
+ /** The relay's self-service enrollment endpoint (`POST /agents/enroll`). */
151
+ export function enrollEndpoint(relayUrl) {
152
+ return joinRelayPath(normalizeRelayUrl(relayUrl).httpBase, 'agents/enroll')
153
+ }
154
+
155
+ /**
156
+ * The `dsh://pair?relay=<relay>&code=<code>` deep link the desktop shows and
157
+ * the iOS app scans. `relay` keeps the full path prefix so the phone can reach
158
+ * `/pair/claim` and `/link/device` under exactly the same base.
159
+ */
160
+ export function qrPayload({ relay, code }) {
161
+ const params = new URLSearchParams({ relay: String(relay), code: String(code) })
162
+ return `dsh://pair?${params.toString()}`
163
+ }
164
+
165
+ /**
166
+ * DLP id <-> mux streamId mapping. One mux stream per (device, DLP stream) so
167
+ * two devices can both call their stream `"2"` without colliding.
168
+ */
169
+ export class StreamTable {
170
+ constructor() {
171
+ /** @type {Map<string, {deviceId: string, dlpId: string, muxId: string, endpoint: string}>} */
172
+ this.byMux = new Map()
173
+ /** @type {Map<string, string>} `${deviceId}\u0000${dlpId}` -> muxId */
174
+ this.byDlp = new Map()
175
+ }
176
+
177
+ static key(deviceId, dlpId) {
178
+ return `${deviceId}\u0000${dlpId}`
179
+ }
180
+
181
+ /** @returns {boolean} false when this device already owns that DLP id. */
182
+ add({ deviceId, dlpId, muxId, endpoint }) {
183
+ if (this.byDlp.has(StreamTable.key(deviceId, dlpId))) return false
184
+ this.byMux.set(muxId, { deviceId, dlpId, muxId, endpoint })
185
+ this.byDlp.set(StreamTable.key(deviceId, dlpId), muxId)
186
+ return true
187
+ }
188
+
189
+ muxOf(deviceId, dlpId) {
190
+ return this.byDlp.get(StreamTable.key(deviceId, dlpId))
191
+ }
192
+
193
+ entryOfMux(muxId) {
194
+ return this.byMux.get(muxId)
195
+ }
196
+
197
+ /** Remove one (device, DLP) pair; returns its mux streamId when it existed. */
198
+ removeByDlp(deviceId, dlpId) {
199
+ const key = StreamTable.key(deviceId, dlpId)
200
+ const muxId = this.byDlp.get(key)
201
+ if (muxId === undefined) return undefined
202
+ this.byDlp.delete(key)
203
+ this.byMux.delete(muxId)
204
+ return muxId
205
+ }
206
+
207
+ /** Remove one mux stream; returns its entry when it existed. */
208
+ removeByMux(muxId) {
209
+ const entry = this.byMux.get(muxId)
210
+ if (entry === undefined) return undefined
211
+ this.byMux.delete(muxId)
212
+ this.byDlp.delete(StreamTable.key(entry.deviceId, entry.dlpId))
213
+ return entry
214
+ }
215
+
216
+ /** Every mux streamId owned by one device (used when it disconnects). */
217
+ removeDevice(deviceId) {
218
+ const removed = []
219
+ for (const entry of [...this.byMux.values()]) {
220
+ if (entry.deviceId === deviceId) {
221
+ removed.push(entry.muxId)
222
+ this.byMux.delete(entry.muxId)
223
+ this.byDlp.delete(StreamTable.key(entry.deviceId, entry.dlpId))
224
+ }
225
+ }
226
+ return removed
227
+ }
228
+
229
+ get size() {
230
+ return this.byMux.size
231
+ }
232
+ }
233
+
234
+ /**
235
+ * First-answer-wins bookkeeping for `$events` waterfalls (spec §4.1.6).
236
+ *
237
+ * `claim` reserves an event synchronously so two devices answering at the same
238
+ * moment cannot both reach `$events/result`; `release` hands it back when the
239
+ * reservation failed to produce a delivered answer.
240
+ */
241
+ export class WaterfallDedupe {
242
+ constructor({ ttlMs = 5 * 60 * 1000, now = Date.now } = {}) {
243
+ this.ttlMs = ttlMs
244
+ this.now = now
245
+ /** @type {Map<string, number>} eventId -> claimedAt */
246
+ this.claimed = new Map()
247
+ }
248
+
249
+ /** @returns {boolean} true when this caller won the right to answer. */
250
+ claim(eventId) {
251
+ if (!nonEmptyString(eventId)) return false
252
+ this.prune()
253
+ if (this.claimed.has(eventId)) return false
254
+ this.claimed.set(eventId, this.now())
255
+ return true
256
+ }
257
+
258
+ release(eventId) {
259
+ this.claimed.delete(eventId)
260
+ }
261
+
262
+ has(eventId) {
263
+ return this.claimed.has(eventId)
264
+ }
265
+
266
+ prune(at = this.now()) {
267
+ for (const [eventId, claimedAt] of this.claimed) {
268
+ if (at - claimedAt > this.ttlMs) this.claimed.delete(eventId)
269
+ }
270
+ }
271
+
272
+ get size() {
273
+ return this.claimed.size
274
+ }
275
+ }
276
+
277
+ /** `{t:'pong', ts}` for a device/relay heartbeat, preserving `ts` verbatim. */
278
+ export function pongFor(frame) {
279
+ return { t: 'pong', ts: frame?.ts }
280
+ }
281
+
282
+ /** `{t:'res', ...}` mapped from a DSH `RemoteResult` (`{ok,value}|{ok:false,error}`). */
283
+ export function resultFrame(dlpId, result, deviceId) {
284
+ const base = { t: 'res', id: dlpId, deviceId }
285
+ if (result && result.ok === true) return { ...base, ok: true, value: result.value }
286
+ const error = result?.error
287
+ return {
288
+ ...base,
289
+ ok: false,
290
+ error: {
291
+ code: nonEmptyString(error?.code) ? error.code : 'gateway/failed',
292
+ message: nonEmptyString(error?.message) ? error.message : 'DSH request failed',
293
+ details: error && typeof error.details === 'object' && error.details !== null ? error.details : {},
294
+ },
295
+ }
296
+ }