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.
@@ -0,0 +1,280 @@
1
+ /**
2
+ * Client for the *local* DSH instance.
3
+ *
4
+ * Responsibilities (spec §4.1):
5
+ * 1. Discover the endpoint: `$DSH_HOME/desktop-shell/endpoint.json`, then
6
+ * `$DSH_WEB_URL`, then `http://127.0.0.1:54499`. The file changes on every
7
+ * DSH restart, so it is re-read on every (re)connect.
8
+ * 2. Exchange `?token=` for a `dsh-auth-*` cookie without following the 303.
9
+ * The cookie is bound to the authority, so the client always dials
10
+ * `127.0.0.1:<port>` and never rewrites `Host` (see NOTES.md on spec §7).
11
+ * 3. Unary RPC via `POST /api/<method>` and one shared multiplexing
12
+ * WebSocket at `/api/remote.mux` for every logical stream.
13
+ */
14
+
15
+ import { EventEmitter } from 'node:events'
16
+ import { readFile } from 'node:fs/promises'
17
+
18
+ import { defaultEndpointFile, defaultShellLogFile } from './state.js'
19
+ import { connect as wsConnect } from './ws.js'
20
+
21
+ export class DshUnavailable extends Error {}
22
+
23
+ const FALLBACK_PORT = 54499
24
+ const MUX_PATH = '/api/remote.mux'
25
+
26
+ function portOf(url) {
27
+ if (url.port) return Number(url.port)
28
+ return url.protocol === 'https:' ? 443 : 80
29
+ }
30
+
31
+ function parseBase(raw, { forceLoopback = true } = {}) {
32
+ const url = new URL(raw)
33
+ const port = portOf(url)
34
+ const host = forceLoopback ? '127.0.0.1' : url.hostname
35
+ const scheme = url.protocol === 'https:' ? 'https' : 'http'
36
+ return {
37
+ base: `${scheme}://${host}:${port}`,
38
+ wsBase: `${scheme === 'https' ? 'wss' : 'ws'}://${host}:${port}`,
39
+ port,
40
+ token: url.searchParams.get('token') || '',
41
+ }
42
+ }
43
+
44
+ /**
45
+ * Resolve where the local DSH web server lives.
46
+ * @returns {Promise<{base: string, wsBase: string, port: number, token: string, source: string}>}
47
+ */
48
+ export async function discoverEndpoint({
49
+ endpointFile = defaultEndpointFile(),
50
+ shellLogFile = defaultShellLogFile(),
51
+ explicitUrl,
52
+ } = {}) {
53
+ if (explicitUrl) {
54
+ const parsed = parseBase(explicitUrl, { forceLoopback: false })
55
+ parsed.source = 'config'
56
+ return parsed
57
+ }
58
+ try {
59
+ const raw = JSON.parse(await readFile(endpointFile, 'utf8'))
60
+ const url = typeof raw?.url === 'string' && raw.url ? raw.url : `http://127.0.0.1:${raw?.port ?? FALLBACK_PORT}`
61
+ const parsed = parseBase(url)
62
+ if (!parsed.port) parsed.port = Number(raw?.port) || FALLBACK_PORT
63
+ parsed.base = `http://127.0.0.1:${parsed.port}`
64
+ parsed.wsBase = `ws://127.0.0.1:${parsed.port}`
65
+ parsed.token = parsed.token || url.searchParams?.get?.('token') || ''
66
+ parsed.source = `endpoint.json:${endpointFile}`
67
+ return parsed
68
+ } catch {
69
+ /* fall through to the log, the environment and the built-in default */
70
+ }
71
+ // The handoff file can be missing while the host is very much alive: the
72
+ // desktop shell deletes it when it quits, and a bare `dsh web --port 0` never
73
+ // writes one at all. The shell's own log keeps every launch line, so the last
74
+ // one that still answers is the live endpoint. This is the same second source
75
+ // `test/tools/host.mjs` reads for the same reason.
76
+ try {
77
+ const log = await readFile(shellLogFile, 'utf8')
78
+ const matches = log.match(/http:\/\/127\.0\.0\.1:(\d+)\/\?token=([A-Za-z0-9_-]+)/g)
79
+ if (matches && matches.length) {
80
+ const parsed = parseBase(matches[matches.length - 1])
81
+ parsed.source = `dsh-shell.log:${shellLogFile}`
82
+ return parsed
83
+ }
84
+ } catch {
85
+ /* fall through to the environment and the built-in default */
86
+ }
87
+ if (process.env.DSH_WEB_URL) {
88
+ const parsed = parseBase(process.env.DSH_WEB_URL)
89
+ parsed.source = 'DSH_WEB_URL'
90
+ return parsed
91
+ }
92
+ const parsed = parseBase(`http://127.0.0.1:${FALLBACK_PORT}`)
93
+ parsed.source = 'default'
94
+ return parsed
95
+ }
96
+
97
+ function cookieFrom(response) {
98
+ const list = typeof response.headers.getSetCookie === 'function'
99
+ ? response.headers.getSetCookie()
100
+ : [response.headers.get('set-cookie')].filter(Boolean)
101
+ const cookies = list
102
+ .map((value) => String(value).split(';')[0].trim())
103
+ .filter((value) => value.startsWith('dsh-auth-'))
104
+ return cookies.join('; ')
105
+ }
106
+
107
+ export class DshClient extends EventEmitter {
108
+ constructor({ endpointFile, explicitUrl, logger = console, fetchImpl = fetch, connectImpl = wsConnect } = {}) {
109
+ super()
110
+ this.endpointFile = endpointFile || defaultEndpointFile()
111
+ this.explicitUrl = explicitUrl
112
+ this.logger = logger
113
+ this.fetchImpl = fetchImpl
114
+ this.connectImpl = connectImpl
115
+ this.endpoint = undefined
116
+ this.cookie = ''
117
+ this.mux = undefined
118
+ this.muxOpening = undefined
119
+ this.muxReady = false
120
+ this.pendingOpens = []
121
+ this.lastError = undefined
122
+ }
123
+
124
+ /** Re-read endpoint discovery and re-do the token -> cookie exchange. */
125
+ async refresh({ force = false } = {}) {
126
+ const endpoint = await discoverEndpoint({ endpointFile: this.endpointFile, explicitUrl: this.explicitUrl })
127
+ if (force || !this.endpoint || this.endpoint.base !== endpoint.base) this.cookie = ''
128
+ this.endpoint = endpoint
129
+ if (!this.cookie) await this.authenticate()
130
+ return this.endpoint
131
+ }
132
+
133
+ async authenticate() {
134
+ const endpoint = this.endpoint ?? await discoverEndpoint({
135
+ endpointFile: this.endpointFile, explicitUrl: this.explicitUrl,
136
+ })
137
+ this.endpoint = endpoint
138
+ if (!endpoint.token) {
139
+ throw new DshUnavailable(
140
+ `no DSH launch token found (looked at ${this.endpoint.source}); restart DSH or set dshUrl in the plugin config`,
141
+ )
142
+ }
143
+ const url = `${endpoint.base}/?token=${encodeURIComponent(endpoint.token)}`
144
+ let response
145
+ try {
146
+ response = await this.fetchImpl(url, { redirect: 'manual' })
147
+ } catch (error) {
148
+ throw new DshUnavailable(`cannot reach DSH at ${endpoint.base}: ${error instanceof Error ? error.message : error}`)
149
+ }
150
+ const cookie = cookieFrom(response)
151
+ if (!cookie) {
152
+ throw new DshUnavailable(`DSH auth handshake returned HTTP ${response.status} without a dsh-auth cookie`)
153
+ }
154
+ this.cookie = cookie
155
+ this.logger.debug?.(`mobile-link: authenticated with DSH at ${endpoint.base}`)
156
+ return cookie
157
+ }
158
+
159
+ async ensure() {
160
+ if (!this.endpoint || !this.cookie) await this.refresh()
161
+ return this.endpoint
162
+ }
163
+
164
+ /** One unary RPC. Returns the DSH `RemoteResult` object verbatim. */
165
+ async rpc(method, args = {}, { retry = true } = {}) {
166
+ await this.ensure()
167
+ const rpcId = `ml-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 8)}`
168
+ const body = JSON.stringify({
169
+ type: 'client-request',
170
+ rpcId,
171
+ method,
172
+ payload: { args: args ?? {} },
173
+ })
174
+ let response
175
+ try {
176
+ response = await this.fetchImpl(`${this.endpoint.base}/api/${method}`, {
177
+ method: 'POST',
178
+ headers: { 'content-type': 'application/json', cookie: this.cookie },
179
+ body,
180
+ })
181
+ } catch (error) {
182
+ throw new DshUnavailable(`DSH request ${method} failed: ${error instanceof Error ? error.message : error}`)
183
+ }
184
+ if (response.status === 401 && retry) {
185
+ this.logger.debug?.(`mobile-link: DSH session expired, re-authenticating (${method})`)
186
+ this.cookie = ''
187
+ await this.refresh({ force: true })
188
+ return this.rpc(method, args, { retry: false })
189
+ }
190
+ let json
191
+ try {
192
+ json = await response.json()
193
+ } catch {
194
+ return { ok: false, error: { code: 'gateway/bad-response', message: `DSH returned HTTP ${response.status}`, details: {} } }
195
+ }
196
+ if (json?.result && typeof json.result === 'object') return json.result
197
+ return { ok: false, error: { code: 'gateway/bad-response', message: 'DSH returned no result', details: {} } }
198
+ }
199
+
200
+ /** The single multiplexing socket; opened lazily and shared by every stream. */
201
+ async ensureMux() {
202
+ if (this.muxReady && this.mux && !this.mux.closed) return this.mux
203
+ if (this.muxOpening) return this.muxOpening
204
+ this.muxOpening = (async () => {
205
+ await this.ensure()
206
+ const handle = await this.connectImpl(`${this.endpoint.wsBase}${MUX_PATH}`, {
207
+ headers: { cookie: this.cookie },
208
+ timeoutMs: 20000,
209
+ })
210
+ this.mux = handle
211
+ this.muxReady = true
212
+ handle.onMessage((text) => this.#onMuxMessage(text))
213
+ handle.onClose((code, reason) => this.#onMuxDown(`closed (${code ?? '?'} ${reason ?? ''})`))
214
+ handle.onError((error) => this.logger.debug?.(`mobile-link: mux socket error: ${error?.message ?? error}`))
215
+ for (const frame of this.pendingOpens.splice(0)) handle.send(frame)
216
+ this.logger.debug?.('mobile-link: DSH mux socket open')
217
+ return handle
218
+ })()
219
+ try {
220
+ return await this.muxOpening
221
+ } catch (error) {
222
+ this.muxReady = false
223
+ this.mux = undefined
224
+ throw error
225
+ } finally {
226
+ this.muxOpening = undefined
227
+ }
228
+ }
229
+
230
+ #onMuxMessage(text) {
231
+ let frame
232
+ try {
233
+ frame = JSON.parse(text)
234
+ } catch {
235
+ return
236
+ }
237
+ const streamId = frame?.streamId
238
+ if (typeof streamId !== 'string') return
239
+ if (frame.type === 'item') this.emit('stream-item', streamId, frame.value)
240
+ else if (frame.type === 'error') this.emit('stream-error', streamId, frame.error)
241
+ else if (frame.type === 'end') this.emit('stream-end', streamId)
242
+ }
243
+
244
+ #onMuxDown(reason) {
245
+ if (!this.muxReady && !this.mux) return
246
+ this.muxReady = false
247
+ this.mux = undefined
248
+ this.logger.debug?.(`mobile-link: DSH mux socket down: ${reason}`)
249
+ this.emit('mux-down', reason)
250
+ }
251
+
252
+ openStream(muxId, endpoint, args) {
253
+ const text = JSON.stringify({ type: 'open', streamId: muxId, endpoint, payload: { args: args ?? {} } })
254
+ if (this.muxReady && this.mux && !this.mux.closed) this.mux.send(text)
255
+ else this.pendingOpens.push(text)
256
+ }
257
+
258
+ cancelStream(muxId) {
259
+ const needle = `"streamId":${JSON.stringify(muxId)}`
260
+ this.pendingOpens = this.pendingOpens.filter((text) => !text.includes(needle))
261
+ if (this.muxReady && this.mux && !this.mux.closed) {
262
+ try {
263
+ this.mux.send(JSON.stringify({ type: 'cancel', streamId: muxId }))
264
+ } catch {
265
+ /* socket died between checks */
266
+ }
267
+ }
268
+ }
269
+
270
+ close() {
271
+ this.muxReady = false
272
+ this.pendingOpens = []
273
+ try {
274
+ this.mux?.close(1000, 'agent shutdown')
275
+ } catch {
276
+ /* already closed */
277
+ }
278
+ this.mux = undefined
279
+ }
280
+ }
package/lib/enroll.js ADDED
@@ -0,0 +1,142 @@
1
+ /**
2
+ * First-run enrollment: turn an invite code into a durable agent identity.
3
+ *
4
+ * Until now an identity could only be provisioned by whoever owns the relay
5
+ * (`relay/admin.py agent-register --write-config`). That does not distribute:
6
+ * somebody else installs DSH plus this connector on their own computer and has
7
+ * to be handed a credential by the operator, out of band.
8
+ *
9
+ * So the relay grew `POST /agents/enroll` (relay/api.py), which redeems a
10
+ * one-time invite code for a fresh `agentId` / `agentSecret`. This module is the
11
+ * connector half: one call, then the identity is written to the same
12
+ * `~/.dsh/mobile-link/agent.json` (mode 0600) that `agent-register` writes, so
13
+ * nothing downstream can tell the two apart.
14
+ *
15
+ * Enrollment is deliberately *not* automatic against an open endpoint: an
16
+ * invite code must be present, because the relay is on the public internet.
17
+ */
18
+
19
+ import { enrollEndpoint } from './dlp.js'
20
+ import { DEFAULT_AGENT_NAME, INVITE_ENV, defaultStatePath, readState, writeState } from './state.js'
21
+
22
+ /** Where the invite code may come from: explicit argument, then environment. */
23
+ export function inviteFrom({ inviteCode, env = process.env } = {}) {
24
+ const value = typeof inviteCode === 'string' && inviteCode.trim() ? inviteCode : env?.[INVITE_ENV]
25
+ return typeof value === 'string' && value.trim() ? value.trim() : undefined
26
+ }
27
+
28
+ /**
29
+ * Whether an enrollment should be attempted, and with what code.
30
+ *
31
+ * Enrollment happens only when there is no identity yet; an existing identity is
32
+ * never replaced behind the user's back (that would silently unpair every phone
33
+ * they had already paired).
34
+ */
35
+ export async function enrollPlan({ stateFile, inviteCode, relayUrl, env = process.env } = {}) {
36
+ const path = stateFile || defaultStatePath()
37
+ const existing = await readState(path)
38
+ if (existing?.agentId && existing?.agentSecret) {
39
+ return { needed: false, stateFile: path, agentId: existing.agentId }
40
+ }
41
+ const invite = inviteFrom({ inviteCode, env })
42
+ const relay = relayUrl || env?.DSH_MOBILE_LINK_RELAY || ''
43
+ return { needed: true, stateFile: path, invite, relayUrl: relay, canEnroll: Boolean(invite && relay) }
44
+ }
45
+
46
+ /**
47
+ * Redeem one invite code and persist the resulting identity.
48
+ *
49
+ * The state file is written **after** the relay has answered, and atomically, so
50
+ * a failure anywhere leaves whatever was there before untouched.
51
+ *
52
+ * @param {object} options
53
+ * @param {string} options.relayUrl relay origin, e.g. `wss://host/dsh-link`
54
+ * @param {string} options.inviteCode the one-time code
55
+ * @param {string} [options.name] what this computer is called on the phone
56
+ * @param {string} [options.stateFile] override, for tests and `$DSH_HOME`
57
+ * @param {typeof fetch} [options.fetchImpl] injectable for tests
58
+ * @returns {Promise<{agentId: string, agentSecret: string, agentName: string, relayUrl: string, stateFile: string}>}
59
+ */
60
+ export async function enrollAgent({
61
+ relayUrl, inviteCode, name, stateFile, deviceName,
62
+ fetchImpl = fetch, logger = undefined,
63
+ } = {}) {
64
+ if (!inviteCode || !String(inviteCode).trim()) {
65
+ throw new Error('mobile-link: an invite code is required to enroll (--invite <code>)')
66
+ }
67
+ if (!relayUrl || !String(relayUrl).trim()) {
68
+ throw new Error('mobile-link: a relay URL is required to enroll (--relay <url>)')
69
+ }
70
+ const path = stateFile || defaultStatePath()
71
+ const agentName = (name || deviceName || '').trim() || DEFAULT_AGENT_NAME
72
+
73
+ let response
74
+ try {
75
+ response = await fetchImpl(enrollEndpoint(relayUrl), {
76
+ method: 'POST',
77
+ headers: { 'content-type': 'application/json' },
78
+ body: JSON.stringify({ inviteCode: String(inviteCode).trim(), name: agentName }),
79
+ })
80
+ } catch (error) {
81
+ throw new Error(`mobile-link: cannot reach the relay at ${relayUrl}: ${messageOf(error)}`)
82
+ }
83
+
84
+ let body
85
+ try {
86
+ body = await response.json()
87
+ } catch {
88
+ throw new Error(`mobile-link: the relay answered HTTP ${response.status} to /agents/enroll`)
89
+ }
90
+ if (!response.ok || body?.ok !== true || !body.agentId || !body.agentSecret) {
91
+ throw new Error(describeEnrollFailure(body, response.status))
92
+ }
93
+
94
+ await writeState(path, {
95
+ relayUrl,
96
+ agentId: body.agentId,
97
+ agentSecret: body.agentSecret,
98
+ agentName: body.agentName || agentName,
99
+ })
100
+ logger?.info?.(`mobile-link: enrolled as ${body.agentId} (${body.agentName || agentName})`)
101
+ return {
102
+ agentId: body.agentId,
103
+ agentSecret: body.agentSecret,
104
+ agentName: body.agentName || agentName,
105
+ relayUrl,
106
+ stateFile: path,
107
+ }
108
+ }
109
+
110
+ /**
111
+ * Turn the relay's failure into something a person can act on.
112
+ *
113
+ * The three invite failures need different advice, so the relay's error code is
114
+ * mapped rather than replaced by a generic message.
115
+ */
116
+ export function describeEnrollFailure(body, status) {
117
+ const code = body?.error?.code ?? ''
118
+ const message = body?.error?.message
119
+ switch (code) {
120
+ case 'enroll/unknown':
121
+ return 'mobile-link: that invite code is not valid — check it for a typo, and that it was issued by this relay'
122
+ case 'enroll/used':
123
+ return 'mobile-link: that invite code has already been used — each code enrolls exactly one computer'
124
+ case 'enroll/expired':
125
+ return 'mobile-link: that invite code has expired — ask for a new one'
126
+ case 'enroll/rate-limited':
127
+ return 'mobile-link: too many failed attempts with that code — wait a few minutes and try again'
128
+ default:
129
+ return `mobile-link: enrollment failed (HTTP ${status})${message ? `: ${message}` : ''}`
130
+ }
131
+ }
132
+
133
+ /** The one-liner a fresh install runs, quoted for copy/paste. */
134
+ export function enrollCommand(relayUrl, name = undefined) {
135
+ const parts = ['dsh-mobile-link enroll', `--relay ${relayUrl || '<relay-url>'}`, '--invite <code>']
136
+ if (name) parts.push(`--name ${JSON.stringify(name)}`)
137
+ return parts.join(' ')
138
+ }
139
+
140
+ function messageOf(error) {
141
+ return error instanceof Error ? error.message : String(error)
142
+ }
package/lib/files.js ADDED
@@ -0,0 +1,127 @@
1
+ /**
2
+ * Files the phone sends, staged on this computer.
3
+ *
4
+ * The Host has no upload endpoint — `workspaceFiles/*` is read-only and the
5
+ * protocol's `{type:'file', receiptId}` part needs a receipt from an
6
+ * `uploadFile` call that does not exist on this build. So the link carries the
7
+ * bytes itself and writes them where the agent can read them with its ordinary
8
+ * tools.
9
+ *
10
+ * The transfer rides the protocol's existing unary `req` frame, addressed to a
11
+ * reserved method name the connector answers locally instead of forwarding, so
12
+ * neither the relay nor the protocol needs to change.
13
+ */
14
+
15
+ import { mkdirSync, writeFileSync, rmSync, statSync } from 'node:fs'
16
+ import { homedir } from 'node:os'
17
+ import { basename, join } from 'node:path'
18
+
19
+ /** Reserved method names this module answers; never forwarded to the Host. */
20
+ export const FILE_BEGIN = '_link/fileBegin'
21
+ export const FILE_CHUNK = '_link/fileChunk'
22
+ export const FILE_END = '_link/fileEnd'
23
+
24
+ export function isFileMethod(method) {
25
+ return method === FILE_BEGIN || method === FILE_CHUNK || method === FILE_END
26
+ }
27
+
28
+ /**
29
+ * One in-flight transfer. Chunks are appended in order and the file only
30
+ * appears at its final path once the end frame arrives, so a half-sent file is
31
+ * never mistaken for a complete one.
32
+ */
33
+ class Transfer {
34
+ constructor({ sessionId, name, bytes }) {
35
+ this.sessionId = sessionId
36
+ this.name = basename(name || 'file')
37
+ this.bytes = Number.isFinite(bytes) ? bytes : undefined
38
+ this.parts = []
39
+ this.received = 0
40
+ this.expectedSeq = 0
41
+ this.directory = join(homedir(), '.dsh', 'inbox', sessionId)
42
+ mkdirSync(this.directory, { recursive: true, mode: 0o700 })
43
+ this.staging = join(this.directory, `.${this.name}.part`)
44
+ }
45
+
46
+ append(seq, base64) {
47
+ if (seq !== this.expectedSeq) {
48
+ throw new Error(`chunk ${seq} arrived out of order (expected ${this.expectedSeq})`)
49
+ }
50
+ const buffer = Buffer.from(base64, 'base64')
51
+ this.parts.push(buffer)
52
+ this.received += buffer.length
53
+ this.expectedSeq += 1
54
+ }
55
+
56
+ finish() {
57
+ const body = Buffer.concat(this.parts)
58
+ if (this.bytes !== undefined && body.length !== this.bytes) {
59
+ throw new Error(`expected ${this.bytes} bytes but received ${body.length}`)
60
+ }
61
+ writeFileSync(this.staging, body, { mode: 0o600 })
62
+ // Renamed only now: the final name means "complete".
63
+ const final = join(this.directory, this.name)
64
+ rmSync(final, { force: true })
65
+ writeFileSync(final, body, { mode: 0o600 })
66
+ rmSync(this.staging, { force: true })
67
+ return { path: final, bytes: statSync(final).size }
68
+ }
69
+
70
+ dispose() {
71
+ rmSync(this.staging, { force: true })
72
+ }
73
+ }
74
+
75
+ /** Owns the transfers for one connector. */
76
+ export class FileInbox {
77
+ #transfers = new Map()
78
+ #logger
79
+
80
+ constructor(logger) {
81
+ this.#logger = logger
82
+ }
83
+
84
+ /** Answers one reserved call. Throws `Error` for the router to report. */
85
+ handle(method, args = {}) {
86
+ const transferId = String(args.transferId ?? '')
87
+ if (!transferId) throw new Error('transferId is required')
88
+
89
+ if (method === FILE_BEGIN) {
90
+ const sessionId = String(args.sessionId ?? '')
91
+ if (!sessionId) throw new Error('sessionId is required')
92
+ this.#transfers.get(transferId)?.dispose()
93
+ this.#transfers.set(
94
+ transferId,
95
+ new Transfer({ sessionId, name: args.name, bytes: Number(args.bytes) })
96
+ )
97
+ this.#logger.debug?.(`mobile-link: file transfer ${transferId} started for ${sessionId}`)
98
+ return { accepted: true }
99
+ }
100
+
101
+ const transfer = this.#transfers.get(transferId)
102
+ if (!transfer) throw new Error(`unknown transfer ${transferId}`)
103
+
104
+ if (method === FILE_CHUNK) {
105
+ transfer.append(Number(args.seq ?? 0), String(args.data ?? ''))
106
+ return { received: transfer.received }
107
+ }
108
+
109
+ // FILE_END
110
+ try {
111
+ const done = transfer.finish()
112
+ this.#logger.info?.(
113
+ `mobile-link: file ${transfer.name} (${done.bytes} bytes) staged at ${done.path}`
114
+ )
115
+ return done
116
+ } finally {
117
+ transfer.dispose()
118
+ this.#transfers.delete(transferId)
119
+ }
120
+ }
121
+
122
+ /** Drops every in-flight transfer, e.g. when the link goes down. */
123
+ clear() {
124
+ for (const transfer of this.#transfers.values()) transfer.dispose()
125
+ this.#transfers.clear()
126
+ }
127
+ }
package/lib/hello.js ADDED
@@ -0,0 +1,65 @@
1
+ /**
2
+ * What this connector is and what it can do.
3
+ *
4
+ * The connector and the app are deployed separately, so the app has to ask
5
+ * rather than assume. It asks over the link — a reserved method the connector
6
+ * answers locally — because the HTTP status route only exists on the direct
7
+ * path, and the direct path is not what users run.
8
+ *
9
+ * Feature detection, not version comparison: a connector that lacks a
10
+ * capability simply does not list it, and the app hides that entry instead of
11
+ * calling something that would come back as a 404 from the Host.
12
+ */
13
+
14
+ import { createRequire } from 'node:module'
15
+
16
+ /** Reserved method the app calls once per connection. */
17
+ export const HELLO_METHOD = '_link/hello'
18
+
19
+ /**
20
+ * The connector's own version.
21
+ *
22
+ * Read from package.json rather than written out twice: the two drifting apart
23
+ * is exactly the kind of thing nobody notices until a support question depends
24
+ * on it (they had drifted: the manifest said 0.1.0 and this said 0.2.0).
25
+ */
26
+ export const SERVER_VERSION = readManifestVersion()
27
+
28
+ function readManifestVersion() {
29
+ try {
30
+ const require = createRequire(import.meta.url)
31
+ return require('../package.json').version ?? '0.0.0'
32
+ } catch {
33
+ return '0.0.0'
34
+ }
35
+ }
36
+
37
+ /**
38
+ * Capabilities the app may rely on.
39
+ *
40
+ * - `file-transfer` files can be staged on the computer (see files.js)
41
+ * - `events` the per-device `$events` stream is served
42
+ * - `session-streams` logical streams (`session/follow`, …) are served
43
+ * - `pair-code` this connector can mint a pairing code on request
44
+ * - `qr-pairing` this connector can render the pairing QR itself
45
+ * (`GET /mobile-link/qr`), so the desktop needs no client
46
+ * - `self-enroll` this connector can register itself with an invite code
47
+ */
48
+ export const SERVER_CAPABILITIES = [
49
+ 'file-transfer', 'events', 'session-streams', 'pair-code', 'qr-pairing', 'self-enroll',
50
+ ]
51
+
52
+ export function isHelloMethod(method) {
53
+ return method === HELLO_METHOD
54
+ }
55
+
56
+ /** The answer to {@link HELLO_METHOD}. */
57
+ export function helloPayload({ protocolVersion, agentId, name } = {}) {
58
+ return {
59
+ serverVersion: SERVER_VERSION,
60
+ capabilities: SERVER_CAPABILITIES,
61
+ protocolVersion,
62
+ agentId: agentId ?? null,
63
+ name: name ?? null,
64
+ }
65
+ }