@kinocode/dsh-mobile-bridge 0.0.0-stage → 0.3.2
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/LICENSE +21 -0
- package/README.md +47 -2
- package/cordis.patch.yml +16 -0
- package/lib/auth.mjs +180 -0
- package/lib/client.js +219 -0
- package/lib/control.mjs +226 -0
- package/lib/desktop-api.mjs +102 -0
- package/lib/gateway-client.mjs +236 -0
- package/lib/gateway.mjs +319 -0
- package/lib/index.mjs +246 -0
- package/lib/preview.mjs +149 -0
- package/lib/push.mjs +173 -0
- package/lib/relay-tunnel.mjs +256 -0
- package/lib/sessions.mjs +131 -0
- package/lib/state.mjs +187 -0
- package/lib/tls-store.mjs +107 -0
- package/lib/upstream.mjs +61 -0
- package/lib/vendor/qrcode.mjs +2291 -0
- package/lib/web-sessions.mjs +113 -0
- package/lib/x509.mjs +228 -0
- package/package.json +63 -4
package/lib/preview.mjs
ADDED
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
// What a phone may see about a pending request.
|
|
2
|
+
// - `describe` builds the full in-app view (served only to signed devices).
|
|
3
|
+
// - `pushPreview` builds what leaves the computer through the relay and FCM: redacted by default.
|
|
4
|
+
|
|
5
|
+
const CONTROL_CHARS = /[\u0000-\u0008\u000B\u000C\u000E-\u001F\u007F]/g
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* @param {string} text
|
|
9
|
+
* @param {number} max
|
|
10
|
+
*/
|
|
11
|
+
export function clip(text, max) {
|
|
12
|
+
const clean = String(text ?? '').replace(CONTROL_CHARS, '').trim()
|
|
13
|
+
if (clean.length <= max) return clean
|
|
14
|
+
return `${clean.slice(0, Math.max(0, max - 1))}…`
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Human-readable arguments: prefer a shell command, then a path, else compact JSON.
|
|
19
|
+
* @param {string} raw - tool-call arguments JSON text
|
|
20
|
+
*/
|
|
21
|
+
export function formatArguments(raw) {
|
|
22
|
+
if (typeof raw !== 'string' || raw === '') return ''
|
|
23
|
+
let parsed
|
|
24
|
+
try {
|
|
25
|
+
parsed = JSON.parse(raw)
|
|
26
|
+
} catch {
|
|
27
|
+
return raw
|
|
28
|
+
}
|
|
29
|
+
if (parsed === null || typeof parsed !== 'object') return String(parsed)
|
|
30
|
+
for (const key of ['command', 'cmd', 'script']) {
|
|
31
|
+
const value = parsed[key]
|
|
32
|
+
if (typeof value === 'string') return value
|
|
33
|
+
if (Array.isArray(value) && value.every(item => typeof item === 'string')) return value.join(' ')
|
|
34
|
+
}
|
|
35
|
+
for (const key of ['path', 'file_path', 'filePath', 'url', 'pattern']) {
|
|
36
|
+
if (typeof parsed[key] === 'string') return `${key}: ${parsed[key]}`
|
|
37
|
+
}
|
|
38
|
+
return JSON.stringify(parsed)
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* @typedef {object} QuestionView
|
|
43
|
+
* @property {string} id
|
|
44
|
+
* @property {string} question
|
|
45
|
+
* @property {string} header
|
|
46
|
+
* @property {boolean} multiSelect
|
|
47
|
+
* @property {string[]} options
|
|
48
|
+
*/
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Normalize ask_user_question items from a forwarded request.
|
|
52
|
+
* @param {unknown} questions
|
|
53
|
+
* @returns {QuestionView[]}
|
|
54
|
+
*/
|
|
55
|
+
export function questionViews(questions) {
|
|
56
|
+
if (!Array.isArray(questions)) return []
|
|
57
|
+
return questions
|
|
58
|
+
.filter(q => q !== null && typeof q === 'object' && typeof q.id === 'string' && typeof q.question === 'string')
|
|
59
|
+
.slice(0, 20)
|
|
60
|
+
.map(q => ({
|
|
61
|
+
id: q.id,
|
|
62
|
+
question: clip(q.question, 2000),
|
|
63
|
+
header: typeof q.header === 'string' ? clip(q.header, 80) : '',
|
|
64
|
+
multiSelect: q.multiSelect === true,
|
|
65
|
+
options: Array.isArray(q.options)
|
|
66
|
+
? q.options.filter(o => o !== null && typeof o === 'object' && typeof o.label === 'string').slice(0, 20).map(o => clip(o.label, 200))
|
|
67
|
+
: [],
|
|
68
|
+
}))
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* Full view for the in-app inbox.
|
|
73
|
+
* @param {import('./gateway-client.mjs').PendingEvent} pending
|
|
74
|
+
* @param {import('./sessions.mjs').SessionTracker} sessions
|
|
75
|
+
* @param {number} maxChars
|
|
76
|
+
*/
|
|
77
|
+
export function describe(pending, sessions, maxChars) {
|
|
78
|
+
const request = pending.request
|
|
79
|
+
const base = {
|
|
80
|
+
eventId: pending.eventId,
|
|
81
|
+
kind: pending.kind,
|
|
82
|
+
sessionId: pending.sessionId,
|
|
83
|
+
sessionTitle: sessions.title(pending.sessionId),
|
|
84
|
+
receivedAt: pending.receivedAt,
|
|
85
|
+
}
|
|
86
|
+
if (pending.kind === 'approval') {
|
|
87
|
+
const callId = typeof request.callId === 'string' ? request.callId : undefined
|
|
88
|
+
const call = sessions.call(pending.sessionId, callId)
|
|
89
|
+
return {
|
|
90
|
+
...base,
|
|
91
|
+
toolName: typeof request.toolName === 'string' ? request.toolName : '',
|
|
92
|
+
reason: typeof request.reason === 'string' ? clip(request.reason, 1000) : '',
|
|
93
|
+
preview: call === undefined ? '' : clip(formatArguments(call.arguments), maxChars),
|
|
94
|
+
questions: [],
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
return { ...base, toolName: 'ask_user_question', reason: '', preview: '', questions: questionViews(request.questions) }
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/**
|
|
101
|
+
* Text that may leave the computer in a push notification.
|
|
102
|
+
* @param {ReturnType<typeof describe>} view
|
|
103
|
+
* @param {'redacted'|'full'} mode
|
|
104
|
+
*/
|
|
105
|
+
export function pushPreview(view, mode) {
|
|
106
|
+
if (view.kind === 'approval') {
|
|
107
|
+
if (mode !== 'full') return view.toolName
|
|
108
|
+
return clip(view.preview !== '' ? `${view.toolName}: ${view.preview}` : `${view.toolName} ${view.reason}`, 400)
|
|
109
|
+
}
|
|
110
|
+
const first = view.questions[0]
|
|
111
|
+
if (mode !== 'full' || first === undefined) return first?.header ?? ''
|
|
112
|
+
return clip(first.question, 400)
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/**
|
|
116
|
+
* Validate an answer value against the pending request.
|
|
117
|
+
* @param {ReturnType<typeof describe>} view
|
|
118
|
+
* @param {unknown} value
|
|
119
|
+
* @returns {unknown} the value to forward
|
|
120
|
+
* @throws {Error} with `.code = 'invalid_value'`
|
|
121
|
+
*/
|
|
122
|
+
export function validateAnswer(view, value) {
|
|
123
|
+
const fail = (message) => Object.assign(new Error(message), { code: 'invalid_value' })
|
|
124
|
+
if (view.kind === 'approval') {
|
|
125
|
+
if (value === 'allowed-once' || value === 'rejected') return value
|
|
126
|
+
throw fail('approval value must be "allowed-once" or "rejected"')
|
|
127
|
+
}
|
|
128
|
+
if (value === null || typeof value !== 'object' || !Array.isArray(value.answers)) throw fail('answers array required')
|
|
129
|
+
const byId = new Map(view.questions.map(q => [q.id, q]))
|
|
130
|
+
const seen = new Set()
|
|
131
|
+
const answers = value.answers.map((answer) => {
|
|
132
|
+
if (answer === null || typeof answer !== 'object' || typeof answer.id !== 'string') throw fail('answer id required')
|
|
133
|
+
const question = byId.get(answer.id)
|
|
134
|
+
if (question === undefined || seen.has(answer.id)) throw fail('unknown or duplicate question id')
|
|
135
|
+
seen.add(answer.id)
|
|
136
|
+
const selected = Array.isArray(answer.selected) ? answer.selected : []
|
|
137
|
+
if (!selected.every(label => typeof label === 'string' && question.options.includes(label))) throw fail('selected must be option labels')
|
|
138
|
+
if (new Set(selected).size !== selected.length) throw fail('duplicate selection')
|
|
139
|
+
if (!question.multiSelect && selected.length > 1) throw fail('single-select question')
|
|
140
|
+
const out = { id: answer.id, selected }
|
|
141
|
+
if (answer.custom !== undefined) {
|
|
142
|
+
if (typeof answer.custom !== 'string' || answer.custom.length > 4000) throw fail('custom must be a string ≤ 4000 chars')
|
|
143
|
+
if (answer.custom.trim() !== '') out.custom = answer.custom
|
|
144
|
+
}
|
|
145
|
+
return out
|
|
146
|
+
})
|
|
147
|
+
if (seen.size !== byId.size) throw fail('every question must be answered')
|
|
148
|
+
return { answers }
|
|
149
|
+
}
|
package/lib/push.mjs
ADDED
|
@@ -0,0 +1,173 @@
|
|
|
1
|
+
// Decides which phones hear about what, and what text leaves the computer (docs/PROTOCOL.md §6).
|
|
2
|
+
|
|
3
|
+
import { clip, describe, pushPreview } from './preview.mjs'
|
|
4
|
+
|
|
5
|
+
const NOTIFIED_TTL_MS = 6 * 60 * 60 * 1000
|
|
6
|
+
const TURN_STATUSES = new Set(['completed', 'error', 'blocked', 'max-tokens', 'aborted', 'interrupted'])
|
|
7
|
+
|
|
8
|
+
export class Notifier {
|
|
9
|
+
/**
|
|
10
|
+
* @param {object} deps
|
|
11
|
+
* @param {import('./state.mjs').StateStore} deps.state
|
|
12
|
+
* @param {import('./sessions.mjs').SessionTracker} deps.sessions
|
|
13
|
+
* @param {() => (import('./relay-tunnel.mjs').RelayTunnel | undefined)} deps.tunnel
|
|
14
|
+
* @param {number} deps.previewMaxChars
|
|
15
|
+
* @param {(...args: unknown[]) => void} deps.log
|
|
16
|
+
* @param {() => number} [deps.now]
|
|
17
|
+
*/
|
|
18
|
+
constructor(deps) {
|
|
19
|
+
this.deps = deps
|
|
20
|
+
this.now = deps.now ?? Date.now
|
|
21
|
+
/** eventId -> {devices, sessionId, at} so `resolved` reaches exactly the phones that were told. */
|
|
22
|
+
this.notified = new Map()
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/** @param {import('./gateway-client.mjs').PendingEvent} pending */
|
|
26
|
+
async pending(pending) {
|
|
27
|
+
// The Gateway redelivers open requests after a reconnect; notify once per request.
|
|
28
|
+
if (this.notified.has(pending.eventId)) return
|
|
29
|
+
const watchers = this.deps.state.watchers(pending.sessionId)
|
|
30
|
+
if (watchers.length === 0) {
|
|
31
|
+
this.deps.log('push: no watching phone', JSON.stringify({ kind: pending.kind, sessionId: pending.sessionId }))
|
|
32
|
+
return
|
|
33
|
+
}
|
|
34
|
+
const view = describe(pending, this.deps.sessions, this.deps.previewMaxChars)
|
|
35
|
+
const title = view.sessionTitle !== '' ? clip(view.sessionTitle, 80) : 'DeepSeek Harness'
|
|
36
|
+
const firstQuestion = view.questions[0]
|
|
37
|
+
const groups = groupByMode(watchers)
|
|
38
|
+
const told = new Set()
|
|
39
|
+
for (const [mode, devices] of groups) {
|
|
40
|
+
/** @type {Record<string, string>} */
|
|
41
|
+
const data = {
|
|
42
|
+
v: '1',
|
|
43
|
+
kind: pending.kind,
|
|
44
|
+
hostId: this.deps.state.hostId,
|
|
45
|
+
sessionId: pending.sessionId,
|
|
46
|
+
eventId: pending.eventId,
|
|
47
|
+
title,
|
|
48
|
+
preview: pushPreview(view, mode),
|
|
49
|
+
createdAt: String(pending.receivedAt),
|
|
50
|
+
}
|
|
51
|
+
if (pending.kind === 'approval') data.toolName = clip(view.toolName, 80)
|
|
52
|
+
if (pending.kind === 'question' && firstQuestion !== undefined) {
|
|
53
|
+
data.questionId = firstQuestion.id
|
|
54
|
+
data.multiSelect = String(firstQuestion.multiSelect)
|
|
55
|
+
const usable = view.questions.length === 1 && !firstQuestion.multiSelect && firstQuestion.options.length > 0 && firstQuestion.options.length <= 3
|
|
56
|
+
data.options = JSON.stringify(usable ? firstQuestion.options.map(o => clip(o, 40)) : [])
|
|
57
|
+
}
|
|
58
|
+
if (await this.send(devices, data, { collapseKey: pending.eventId, ttlSec: 6 * 3600 })) {
|
|
59
|
+
for (const [id] of devices) told.add(id)
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
if (told.size > 0) this.remember(pending.eventId, told, pending.sessionId)
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* Withdraw notifications for a request that is no longer pending.
|
|
67
|
+
* @param {string} eventId
|
|
68
|
+
* @param {string} [answeredBy] - device id that answered (it updates its own notification)
|
|
69
|
+
*/
|
|
70
|
+
async resolved(eventId, answeredBy) {
|
|
71
|
+
const entry = this.notified.get(eventId)
|
|
72
|
+
if (entry === undefined) return
|
|
73
|
+
this.notified.delete(eventId)
|
|
74
|
+
const devices = [...entry.devices]
|
|
75
|
+
.filter(id => id !== answeredBy)
|
|
76
|
+
.map(id => [id, this.deps.state.device(id)])
|
|
77
|
+
.filter(([, record]) => record?.fcmToken)
|
|
78
|
+
if (devices.length === 0) return
|
|
79
|
+
await this.send(devices, {
|
|
80
|
+
v: '1',
|
|
81
|
+
kind: 'resolved',
|
|
82
|
+
hostId: this.deps.state.hostId,
|
|
83
|
+
sessionId: entry.sessionId,
|
|
84
|
+
eventId,
|
|
85
|
+
createdAt: String(this.now()),
|
|
86
|
+
}, { collapseKey: eventId, ttlSec: 6 * 3600 })
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* Withdraw every notification this process sent for a still-open request, for example when DSH
|
|
91
|
+
* is quitting and the requests die with it. Best effort; the app also drops notifications that
|
|
92
|
+
* are missing from `/v1/pending` when it next lists them.
|
|
93
|
+
*/
|
|
94
|
+
async withdrawAll() {
|
|
95
|
+
const eventIds = [...this.notified.keys()]
|
|
96
|
+
await Promise.allSettled(eventIds.map(eventId => this.resolved(eventId)))
|
|
97
|
+
return eventIds.length
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/**
|
|
101
|
+
* @param {string} sessionId
|
|
102
|
+
* @param {string} reason - TurnEndReason kind
|
|
103
|
+
*/
|
|
104
|
+
async turnEnded(sessionId, reason) {
|
|
105
|
+
if (!TURN_STATUSES.has(reason)) return
|
|
106
|
+
const watchers = this.deps.state.watchers(sessionId)
|
|
107
|
+
if (watchers.length === 0) {
|
|
108
|
+
this.deps.log('push: no watching phone', JSON.stringify({ kind: 'turn', sessionId }))
|
|
109
|
+
return
|
|
110
|
+
}
|
|
111
|
+
const title = this.deps.sessions.title(sessionId)
|
|
112
|
+
for (const [mode, devices] of groupByMode(watchers)) {
|
|
113
|
+
const text = this.deps.sessions.lastAssistantText(sessionId)
|
|
114
|
+
await this.send(devices, {
|
|
115
|
+
v: '1',
|
|
116
|
+
kind: 'turn',
|
|
117
|
+
hostId: this.deps.state.hostId,
|
|
118
|
+
sessionId,
|
|
119
|
+
eventId: '',
|
|
120
|
+
status: reason,
|
|
121
|
+
title: title !== '' ? clip(title, 80) : 'DeepSeek Harness',
|
|
122
|
+
preview: mode === 'full' ? clip(text, 200) : '',
|
|
123
|
+
createdAt: String(this.now()),
|
|
124
|
+
}, { collapseKey: `turn-${sessionId}`, ttlSec: 3600 })
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/**
|
|
129
|
+
* @param {Array<[string, import('./state.mjs').DeviceRecord]>} devices
|
|
130
|
+
* @param {Record<string, string>} data
|
|
131
|
+
* @param {{collapseKey?: string, ttlSec?: number}} options
|
|
132
|
+
*/
|
|
133
|
+
async send(devices, data, options) {
|
|
134
|
+
const tunnel = this.deps.tunnel()
|
|
135
|
+
if (tunnel === undefined || !tunnel.connected) {
|
|
136
|
+
this.deps.log('push skipped: relay not connected', JSON.stringify({ kind: data.kind }))
|
|
137
|
+
return false
|
|
138
|
+
}
|
|
139
|
+
const tokens = devices.map(([, record]) => record.fcmToken).filter(Boolean)
|
|
140
|
+
const result = await tunnel.push(tokens, data, options)
|
|
141
|
+
this.deps.log('push', JSON.stringify({ kind: data.kind, devices: tokens.length, ok: result.ok }))
|
|
142
|
+
if (result.invalidTokens.length > 0) {
|
|
143
|
+
const invalid = new Set(result.invalidTokens)
|
|
144
|
+
for (const [id, record] of devices) {
|
|
145
|
+
if (invalid.has(record.fcmToken)) this.deps.state.updateDevice(id, (r) => { r.fcmToken = null }, true)
|
|
146
|
+
}
|
|
147
|
+
this.deps.log('dropped invalid FCM tokens', JSON.stringify({ count: invalid.size }))
|
|
148
|
+
}
|
|
149
|
+
return result.ok
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
remember(eventId, devices, sessionId) {
|
|
153
|
+
const now = this.now()
|
|
154
|
+
this.notified.set(eventId, { devices, sessionId, at: now })
|
|
155
|
+
if (this.notified.size > 256) {
|
|
156
|
+
for (const [id, entry] of this.notified) if (now - entry.at > NOTIFIED_TTL_MS) this.notified.delete(id)
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
/**
|
|
162
|
+
* @param {Array<[string, import('./state.mjs').DeviceRecord]>} devices
|
|
163
|
+
* @returns {Map<'redacted'|'full', Array<[string, import('./state.mjs').DeviceRecord]>>}
|
|
164
|
+
*/
|
|
165
|
+
function groupByMode(devices) {
|
|
166
|
+
const groups = new Map()
|
|
167
|
+
for (const entry of devices) {
|
|
168
|
+
const mode = entry[1].previewMode === 'full' ? 'full' : 'redacted'
|
|
169
|
+
if (!groups.has(mode)) groups.set(mode, [])
|
|
170
|
+
groups.get(mode).push(entry)
|
|
171
|
+
}
|
|
172
|
+
return groups
|
|
173
|
+
}
|
|
@@ -0,0 +1,256 @@
|
|
|
1
|
+
// Outbound WebSocket from this computer to the relay (docs/PROTOCOL.md §5).
|
|
2
|
+
// The relay forwards phone control requests over it and asks us nothing else;
|
|
3
|
+
// we send push requests the other way.
|
|
4
|
+
|
|
5
|
+
import { randomUUID } from 'node:crypto'
|
|
6
|
+
|
|
7
|
+
const PUSH_TIMEOUT_MS = 15_000
|
|
8
|
+
const HELLO_TIMEOUT_MS = 10_000
|
|
9
|
+
const MAX_FRAME_BYTES = 256 * 1024
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* @typedef {object} TunnelRequest
|
|
13
|
+
* @property {string} method
|
|
14
|
+
* @property {string} path
|
|
15
|
+
* @property {Record<string, string>} headers
|
|
16
|
+
* @property {string} body
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
export class RelayTunnel {
|
|
20
|
+
/**
|
|
21
|
+
* @param {object} options
|
|
22
|
+
* @param {string} options.relayUrl - https://… base
|
|
23
|
+
* @param {string} options.hostId
|
|
24
|
+
* @param {string} options.hostSecret
|
|
25
|
+
* @param {string} [options.enrollToken]
|
|
26
|
+
* @param {string} options.label
|
|
27
|
+
* @param {string} options.version
|
|
28
|
+
* @param {(request: TunnelRequest) => Promise<{status: number, body: unknown}>} options.handle
|
|
29
|
+
* @param {(...args: unknown[]) => void} options.log
|
|
30
|
+
* @param {typeof WebSocket} [options.WebSocketImpl]
|
|
31
|
+
*/
|
|
32
|
+
constructor(options) {
|
|
33
|
+
this.options = options
|
|
34
|
+
this.url = tunnelUrl(options.relayUrl, options.hostId)
|
|
35
|
+
this.socket = undefined
|
|
36
|
+
this.open = false
|
|
37
|
+
/** @type {Map<string, {resolve: Function, timer: NodeJS.Timeout}>} */
|
|
38
|
+
this.pushes = new Map()
|
|
39
|
+
this.stopped = false
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
get connected() { return this.open }
|
|
43
|
+
|
|
44
|
+
/** @param {AbortSignal} signal */
|
|
45
|
+
async run(signal) {
|
|
46
|
+
const WebSocketImpl = this.options.WebSocketImpl ?? await loadWebSocket()
|
|
47
|
+
let delay = 1000
|
|
48
|
+
while (!signal.aborted) {
|
|
49
|
+
const started = Date.now()
|
|
50
|
+
await this.connectOnce(WebSocketImpl, signal)
|
|
51
|
+
if (signal.aborted) break
|
|
52
|
+
if (Date.now() - started > 60_000) delay = 1000
|
|
53
|
+
const jitter = Math.floor(Math.random() * 500)
|
|
54
|
+
await sleep(delay + jitter, signal)
|
|
55
|
+
delay = Math.min(delay * 2, 60_000)
|
|
56
|
+
}
|
|
57
|
+
this.failPushes('tunnel stopped')
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* @param {typeof WebSocket} WebSocketImpl
|
|
62
|
+
* @param {AbortSignal} signal
|
|
63
|
+
*/
|
|
64
|
+
connectOnce(WebSocketImpl, signal) {
|
|
65
|
+
return new Promise((resolve) => {
|
|
66
|
+
let settled = false
|
|
67
|
+
let socket
|
|
68
|
+
try {
|
|
69
|
+
socket = new WebSocketImpl(this.url)
|
|
70
|
+
} catch (error) {
|
|
71
|
+
this.options.log('relay connect failed:', error?.message ?? String(error))
|
|
72
|
+
resolve()
|
|
73
|
+
return
|
|
74
|
+
}
|
|
75
|
+
this.socket = socket
|
|
76
|
+
const helloTimer = setTimeout(() => {
|
|
77
|
+
this.options.log('relay hello timed out')
|
|
78
|
+
try { socket.close(4408, 'hello timeout') } catch { /* already closing */ }
|
|
79
|
+
}, HELLO_TIMEOUT_MS)
|
|
80
|
+
const keepAlive = setInterval(() => this.send({ type: 'ping' }), 25_000)
|
|
81
|
+
const finish = () => {
|
|
82
|
+
if (settled) return
|
|
83
|
+
settled = true
|
|
84
|
+
clearTimeout(helloTimer)
|
|
85
|
+
clearInterval(keepAlive)
|
|
86
|
+
signal.removeEventListener('abort', onAbort)
|
|
87
|
+
this.open = false
|
|
88
|
+
this.socket = undefined
|
|
89
|
+
this.failPushes('tunnel closed')
|
|
90
|
+
resolve()
|
|
91
|
+
}
|
|
92
|
+
const onAbort = () => {
|
|
93
|
+
try { socket.close(1000, 'plugin stopping') } catch { /* already closed */ }
|
|
94
|
+
finish()
|
|
95
|
+
}
|
|
96
|
+
signal.addEventListener('abort', onAbort, { once: true })
|
|
97
|
+
socket.addEventListener('open', () => {
|
|
98
|
+
socket.send(JSON.stringify({
|
|
99
|
+
type: 'hello',
|
|
100
|
+
v: 1,
|
|
101
|
+
hostId: this.options.hostId,
|
|
102
|
+
hostSecret: this.options.hostSecret,
|
|
103
|
+
...(this.options.enrollToken ? { enrollToken: this.options.enrollToken } : {}),
|
|
104
|
+
label: this.options.label,
|
|
105
|
+
plugin: this.options.version,
|
|
106
|
+
}))
|
|
107
|
+
})
|
|
108
|
+
socket.addEventListener('message', (event) => {
|
|
109
|
+
const raw = typeof event.data === 'string' ? event.data : Buffer.from(event.data).toString('utf8')
|
|
110
|
+
if (raw.length > MAX_FRAME_BYTES) return
|
|
111
|
+
let frame
|
|
112
|
+
try {
|
|
113
|
+
frame = JSON.parse(raw)
|
|
114
|
+
} catch {
|
|
115
|
+
return
|
|
116
|
+
}
|
|
117
|
+
if (frame?.type === 'hello-ok') {
|
|
118
|
+
clearTimeout(helloTimer)
|
|
119
|
+
this.open = true
|
|
120
|
+
this.options.log('relay connected')
|
|
121
|
+
return
|
|
122
|
+
}
|
|
123
|
+
if (!this.open) return
|
|
124
|
+
this.onFrame(frame)
|
|
125
|
+
})
|
|
126
|
+
socket.addEventListener('close', (event) => {
|
|
127
|
+
if (event.code === 4401) this.options.log('relay refused this host (check relayEnrollToken / host secret)')
|
|
128
|
+
else if (event.code === 4000) this.options.log('relay tunnel replaced by a newer connection')
|
|
129
|
+
finish()
|
|
130
|
+
})
|
|
131
|
+
socket.addEventListener('error', () => { /* close follows */ })
|
|
132
|
+
})
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/** @param {any} frame */
|
|
136
|
+
onFrame(frame) {
|
|
137
|
+
switch (frame?.type) {
|
|
138
|
+
case 'request':
|
|
139
|
+
void this.serve(frame)
|
|
140
|
+
return
|
|
141
|
+
case 'push-result': {
|
|
142
|
+
const waiter = this.pushes.get(frame.id)
|
|
143
|
+
if (waiter === undefined) return
|
|
144
|
+
this.pushes.delete(frame.id)
|
|
145
|
+
clearTimeout(waiter.timer)
|
|
146
|
+
waiter.resolve({ ok: frame.ok === true, invalidTokens: Array.isArray(frame.invalidTokens) ? frame.invalidTokens : [] })
|
|
147
|
+
return
|
|
148
|
+
}
|
|
149
|
+
case 'ping':
|
|
150
|
+
this.send({ type: 'pong' })
|
|
151
|
+
return
|
|
152
|
+
default:
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
/** @param {any} frame */
|
|
157
|
+
async serve(frame) {
|
|
158
|
+
if (typeof frame.id !== 'string' || typeof frame.method !== 'string' || typeof frame.path !== 'string') return
|
|
159
|
+
let result
|
|
160
|
+
try {
|
|
161
|
+
result = await this.options.handle({
|
|
162
|
+
method: frame.method,
|
|
163
|
+
path: frame.path,
|
|
164
|
+
headers: frame.headers !== null && typeof frame.headers === 'object' ? frame.headers : {},
|
|
165
|
+
body: typeof frame.body === 'string' ? frame.body : '',
|
|
166
|
+
})
|
|
167
|
+
} catch (error) {
|
|
168
|
+
this.options.log('control handler failed:', error?.message ?? String(error))
|
|
169
|
+
result = { status: 500, body: { error: 'internal_error' } }
|
|
170
|
+
}
|
|
171
|
+
this.send({ type: 'response', id: frame.id, status: result.status, body: JSON.stringify(result.body ?? {}) })
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
/**
|
|
175
|
+
* Ask the relay to deliver one FCM data message.
|
|
176
|
+
* @param {string[]} tokens
|
|
177
|
+
* @param {Record<string, string>} data
|
|
178
|
+
* @param {{ttlSec?: number, collapseKey?: string}} [options]
|
|
179
|
+
* @returns {Promise<{ok: boolean, invalidTokens: string[]}>}
|
|
180
|
+
*/
|
|
181
|
+
push(tokens, data, options = {}) {
|
|
182
|
+
if (!this.open || tokens.length === 0) return Promise.resolve({ ok: false, invalidTokens: [] })
|
|
183
|
+
const id = randomUUID()
|
|
184
|
+
return new Promise((resolve) => {
|
|
185
|
+
const timer = setTimeout(() => {
|
|
186
|
+
this.pushes.delete(id)
|
|
187
|
+
resolve({ ok: false, invalidTokens: [] })
|
|
188
|
+
}, PUSH_TIMEOUT_MS)
|
|
189
|
+
this.pushes.set(id, { resolve, timer })
|
|
190
|
+
const sent = this.send({ type: 'push', id, tokens, data, ttlSec: options.ttlSec ?? 3600, ...(options.collapseKey ? { collapseKey: options.collapseKey } : {}) })
|
|
191
|
+
if (!sent) {
|
|
192
|
+
clearTimeout(timer)
|
|
193
|
+
this.pushes.delete(id)
|
|
194
|
+
resolve({ ok: false, invalidTokens: [] })
|
|
195
|
+
}
|
|
196
|
+
})
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
/** @param {object} frame */
|
|
200
|
+
send(frame) {
|
|
201
|
+
const socket = this.socket
|
|
202
|
+
if (socket === undefined || socket.readyState !== 1) return false
|
|
203
|
+
try {
|
|
204
|
+
socket.send(JSON.stringify(frame))
|
|
205
|
+
return true
|
|
206
|
+
} catch {
|
|
207
|
+
return false
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
failPushes(reason) {
|
|
212
|
+
for (const [id, waiter] of this.pushes) {
|
|
213
|
+
clearTimeout(waiter.timer)
|
|
214
|
+
waiter.resolve({ ok: false, invalidTokens: [], error: reason })
|
|
215
|
+
this.pushes.delete(id)
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
/**
|
|
221
|
+
* @param {string} relayUrl
|
|
222
|
+
* @param {string} hostId
|
|
223
|
+
*/
|
|
224
|
+
export function tunnelUrl(relayUrl, hostId) {
|
|
225
|
+
const url = new URL(relayUrl)
|
|
226
|
+
if (url.protocol !== 'https:' && !(url.protocol === 'http:' && ['127.0.0.1', 'localhost', '[::1]'].includes(url.hostname))) {
|
|
227
|
+
throw new Error('relayUrl must be https:// (http:// only for loopback testing)')
|
|
228
|
+
}
|
|
229
|
+
url.protocol = url.protocol === 'https:' ? 'wss:' : 'ws:'
|
|
230
|
+
url.pathname = `${url.pathname.replace(/\/+$/, '')}/v1/hosts/${encodeURIComponent(hostId)}/tunnel`
|
|
231
|
+
url.search = ''
|
|
232
|
+
url.hash = ''
|
|
233
|
+
return url.toString()
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
async function loadWebSocket() {
|
|
237
|
+
if (typeof globalThis.WebSocket === 'function') return globalThis.WebSocket
|
|
238
|
+
try {
|
|
239
|
+
const mod = await import('ws')
|
|
240
|
+
return mod.WebSocket ?? mod.default
|
|
241
|
+
} catch {
|
|
242
|
+
throw new Error('no WebSocket implementation available (needs Node 22+ or the "ws" package)')
|
|
243
|
+
}
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
function sleep(ms, signal) {
|
|
247
|
+
return new Promise((resolve) => {
|
|
248
|
+
const timer = setTimeout(done, ms)
|
|
249
|
+
function done() {
|
|
250
|
+
signal?.removeEventListener('abort', done)
|
|
251
|
+
clearTimeout(timer)
|
|
252
|
+
resolve()
|
|
253
|
+
}
|
|
254
|
+
signal?.addEventListener('abort', done, { once: true })
|
|
255
|
+
})
|
|
256
|
+
}
|