dsh-remote-control-plugin 0.0.0-stage → 0.3.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/INSTALL.md +35 -0
- package/README.md +96 -2
- package/client.js +563 -0
- package/cordis.patch.yml +3 -0
- package/host/fabric.js +578 -0
- package/host/relay-core.js +189 -0
- package/host/relay-link.js +335 -0
- package/icon.svg +15 -0
- package/index.js +1010 -0
- package/locale/en.json +6 -0
- package/locale/zh.json +6 -0
- package/package.json +45 -4
- package/web/app.css +420 -0
- package/web/app.js +594 -0
- package/web/icon.svg +15 -0
- package/web/index.html +85 -0
|
@@ -0,0 +1,189 @@
|
|
|
1
|
+
import { randomBytes } from 'node:crypto'
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Rendezvous relay core.
|
|
5
|
+
*
|
|
6
|
+
* Mountable in two ways: standalone (`relay/server.js`) or inside a DSH Host
|
|
7
|
+
* (the Devices panel's "Create relay"), which is why it exposes `handle`
|
|
8
|
+
* instead of owning a socket. The protocol is plain HTTP long-poll so it works
|
|
9
|
+
* through any proxy and needs no dependency.
|
|
10
|
+
*
|
|
11
|
+
* Groups are keyed by sha256(workspaceKey), computed by the client, so the
|
|
12
|
+
* relay never sees the workspace key. Peer control stays end-to-end signed by
|
|
13
|
+
* the devices themselves; this process only routes.
|
|
14
|
+
*/
|
|
15
|
+
export function createRelay({ log = console } = {}) {
|
|
16
|
+
const devices = new Map() // deviceId -> record
|
|
17
|
+
const tokens = new Map() // token -> deviceId
|
|
18
|
+
|
|
19
|
+
const DEVICE_TTL_MS = 60_000
|
|
20
|
+
const MAX_QUEUE = 200
|
|
21
|
+
const MAX_BODY_BYTES = 2 * 1024 * 1024
|
|
22
|
+
const MAX_WAIT_SECONDS = 30
|
|
23
|
+
|
|
24
|
+
const reaper = setInterval(() => {
|
|
25
|
+
const now = Date.now()
|
|
26
|
+
for (const [deviceId, record] of devices) {
|
|
27
|
+
if (now - record.lastSeen <= DEVICE_TTL_MS) continue
|
|
28
|
+
devices.delete(deviceId)
|
|
29
|
+
tokens.delete(record.token)
|
|
30
|
+
}
|
|
31
|
+
}, 15_000)
|
|
32
|
+
reaper.unref?.()
|
|
33
|
+
|
|
34
|
+
async function handle(req, res) {
|
|
35
|
+
const url = new URL(req.url ?? '/', 'http://relay')
|
|
36
|
+
if (req.method === 'GET' && url.pathname === '/health') {
|
|
37
|
+
return send(res, 200, { ok: true, devices: devices.size })
|
|
38
|
+
}
|
|
39
|
+
if (req.method === 'POST' && url.pathname === '/v1/register') return register(req, res)
|
|
40
|
+
if (req.method === 'POST' && url.pathname === '/v1/heartbeat') return heartbeat(req, res)
|
|
41
|
+
if (req.method === 'POST' && url.pathname === '/v1/send') return forward(req, res)
|
|
42
|
+
if (req.method === 'GET' && url.pathname === '/v1/inbox') return inbox(url, res)
|
|
43
|
+
return send(res, 404, { error: 'not-found' })
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
async function register(req, res) {
|
|
47
|
+
const body = await readJson(req)
|
|
48
|
+
const groupId = typeof body?.groupId === 'string' ? body.groupId : ''
|
|
49
|
+
const deviceId = typeof body?.deviceId === 'string' ? body.deviceId : ''
|
|
50
|
+
if (!/^[0-9a-f]{32,64}$/.test(groupId)) return send(res, 400, { error: 'bad-group' })
|
|
51
|
+
if (!deviceId || deviceId.length > 128) return send(res, 400, { error: 'bad-device' })
|
|
52
|
+
|
|
53
|
+
let record = devices.get(deviceId)
|
|
54
|
+
if (record && record.groupId !== groupId) return send(res, 409, { error: 'device-in-use' })
|
|
55
|
+
if (!record) {
|
|
56
|
+
record = {
|
|
57
|
+
deviceId,
|
|
58
|
+
groupId,
|
|
59
|
+
token: randomBytes(24).toString('hex'),
|
|
60
|
+
name: typeof body?.name === 'string' ? body.name.slice(0, 80) : 'DSH device',
|
|
61
|
+
userId: typeof body?.userId === 'string' ? body.userId : null,
|
|
62
|
+
lastSeen: Date.now(),
|
|
63
|
+
queue: [],
|
|
64
|
+
waiters: new Set(),
|
|
65
|
+
}
|
|
66
|
+
devices.set(deviceId, record)
|
|
67
|
+
tokens.set(record.token, deviceId)
|
|
68
|
+
} else {
|
|
69
|
+
record.lastSeen = Date.now()
|
|
70
|
+
if (typeof body?.name === 'string') record.name = body.name.slice(0, 80)
|
|
71
|
+
if (typeof body?.userId === 'string') record.userId = body.userId
|
|
72
|
+
}
|
|
73
|
+
log.info?.(`[relay] register ${record.name} (${deviceId.slice(0, 12)})`)
|
|
74
|
+
return send(res, 200, { token: record.token, peers: peerList(record) })
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
async function heartbeat(req, res) {
|
|
78
|
+
const body = await readJson(req)
|
|
79
|
+
const record = authorize(body?.token)
|
|
80
|
+
if (!record) return send(res, 401, { error: 'unauthorized' })
|
|
81
|
+
record.lastSeen = Date.now()
|
|
82
|
+
return send(res, 200, { ok: true, peers: peerList(record) })
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
async function forward(req, res) {
|
|
86
|
+
const body = await readJson(req)
|
|
87
|
+
const from = authorize(body?.token)
|
|
88
|
+
if (!from) return send(res, 401, { error: 'unauthorized' })
|
|
89
|
+
const to = devices.get(String(body?.to || ''))
|
|
90
|
+
if (!to || to.groupId !== from.groupId) return send(res, 404, { error: 'unknown-peer' })
|
|
91
|
+
const message = body?.message
|
|
92
|
+
if (message === undefined || JSON.stringify(message).length > MAX_BODY_BYTES) {
|
|
93
|
+
return send(res, 413, { error: 'too-large' })
|
|
94
|
+
}
|
|
95
|
+
if (to.queue.length >= MAX_QUEUE) to.queue.shift()
|
|
96
|
+
to.queue.push({ from: from.deviceId, message, at: Date.now() })
|
|
97
|
+
wake(to)
|
|
98
|
+
return send(res, 200, { ok: true })
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
function inbox(url, res) {
|
|
102
|
+
const record = authorize(url.searchParams.get('token'))
|
|
103
|
+
if (!record) return send(res, 401, { error: 'unauthorized' })
|
|
104
|
+
record.lastSeen = Date.now()
|
|
105
|
+
if (record.queue.length > 0) return send(res, 200, { messages: drain(record), peers: peerList(record) })
|
|
106
|
+
|
|
107
|
+
const waitSeconds = Math.min(Number(url.searchParams.get('wait') || 25) || 25, MAX_WAIT_SECONDS)
|
|
108
|
+
let settled = false
|
|
109
|
+
const finish = () => {
|
|
110
|
+
if (settled) return
|
|
111
|
+
settled = true
|
|
112
|
+
clearTimeout(timer)
|
|
113
|
+
record.waiters.delete(finish)
|
|
114
|
+
try { send(res, 200, { messages: drain(record), peers: peerList(record) }) } catch { /* client gone */ }
|
|
115
|
+
}
|
|
116
|
+
record.waiters.add(finish)
|
|
117
|
+
const timer = setTimeout(finish, waitSeconds * 1000)
|
|
118
|
+
res.on('close', () => {
|
|
119
|
+
if (settled) return
|
|
120
|
+
settled = true
|
|
121
|
+
clearTimeout(timer)
|
|
122
|
+
record.waiters.delete(finish)
|
|
123
|
+
})
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
function drain(record) {
|
|
127
|
+
const messages = record.queue
|
|
128
|
+
record.queue = []
|
|
129
|
+
return messages
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
function wake(record) {
|
|
133
|
+
for (const waiter of [...record.waiters]) waiter()
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
function authorize(token) {
|
|
137
|
+
if (typeof token !== 'string' || !token) return undefined
|
|
138
|
+
const deviceId = tokens.get(token)
|
|
139
|
+
if (!deviceId) return undefined
|
|
140
|
+
const record = devices.get(deviceId)
|
|
141
|
+
if (!record) { tokens.delete(token); return undefined }
|
|
142
|
+
return record
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
function peerList(self) {
|
|
146
|
+
const now = Date.now()
|
|
147
|
+
const items = []
|
|
148
|
+
for (const record of devices.values()) {
|
|
149
|
+
if (record.groupId !== self.groupId || record.deviceId === self.deviceId) continue
|
|
150
|
+
if (now - record.lastSeen > DEVICE_TTL_MS) continue
|
|
151
|
+
items.push({ deviceId: record.deviceId, name: record.name, userId: record.userId, lastSeen: record.lastSeen })
|
|
152
|
+
}
|
|
153
|
+
return items
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
return {
|
|
157
|
+
handle,
|
|
158
|
+
stats: () => ({ devices: devices.size }),
|
|
159
|
+
close: () => clearInterval(reaper),
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
function send(res, status, value) {
|
|
164
|
+
if (res.writableEnded) return
|
|
165
|
+
const body = JSON.stringify(value)
|
|
166
|
+
res.writeHead(status, {
|
|
167
|
+
'content-type': 'application/json; charset=utf-8',
|
|
168
|
+
'content-length': Buffer.byteLength(body),
|
|
169
|
+
'cache-control': 'no-store',
|
|
170
|
+
})
|
|
171
|
+
res.end(body)
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
function readJson(req) {
|
|
175
|
+
return new Promise((resolve, reject) => {
|
|
176
|
+
const chunks = []
|
|
177
|
+
let size = 0
|
|
178
|
+
req.on('data', (chunk) => {
|
|
179
|
+
size += chunk.length
|
|
180
|
+
if (size > 2 * 1024 * 1024) { reject(new Error('too large')); req.destroy(); return }
|
|
181
|
+
chunks.push(chunk)
|
|
182
|
+
})
|
|
183
|
+
req.on('end', () => {
|
|
184
|
+
if (chunks.length === 0) return resolve({})
|
|
185
|
+
try { resolve(JSON.parse(Buffer.concat(chunks).toString('utf8'))) } catch (error) { reject(error) }
|
|
186
|
+
})
|
|
187
|
+
req.on('error', reject)
|
|
188
|
+
})
|
|
189
|
+
}
|
|
@@ -0,0 +1,335 @@
|
|
|
1
|
+
import { createHash } from 'node:crypto'
|
|
2
|
+
import http from 'node:http'
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Outbound link to the remote-control relay.
|
|
6
|
+
*
|
|
7
|
+
* The relay is a rendezvous point, not a trust anchor: this link tunnels the
|
|
8
|
+
* same signed `/peer/*` HTTP calls the LAN transport uses, so the receiving
|
|
9
|
+
* Host still verifies the per-peer HMAC end to end. A compromised relay can
|
|
10
|
+
* drop or delay traffic, but cannot forge a control command.
|
|
11
|
+
*
|
|
12
|
+
* Messages:
|
|
13
|
+
* http / http-response unary call and its reply
|
|
14
|
+
* stream-open / stream-status / stream-chunk / stream-end / stream-cancel
|
|
15
|
+
* SSE transcript relayed frame by frame
|
|
16
|
+
*/
|
|
17
|
+
export function createRelayLink({ url, key, deviceId, deviceName, userId, localPort, log }) {
|
|
18
|
+
const base = String(url).replace(/\/+$/, '')
|
|
19
|
+
const groupId = createHash('sha256').update(String(key)).digest('hex')
|
|
20
|
+
const peers = new Map()
|
|
21
|
+
const pending = new Map()
|
|
22
|
+
const streams = new Map()
|
|
23
|
+
const locals = new Map()
|
|
24
|
+
|
|
25
|
+
let token
|
|
26
|
+
let stopped = false
|
|
27
|
+
let heartbeatTimer
|
|
28
|
+
let sequence = 0
|
|
29
|
+
|
|
30
|
+
async function start() {
|
|
31
|
+
await register()
|
|
32
|
+
heartbeatTimer = setInterval(() => { heartbeat().catch(() => {}) }, 5000)
|
|
33
|
+
heartbeatTimer.unref?.()
|
|
34
|
+
inboxLoop().catch((error) => log.warn(`[remote-control] relay inbox stopped: ${error?.message || error}`))
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function stop() {
|
|
38
|
+
stopped = true
|
|
39
|
+
clearInterval(heartbeatTimer)
|
|
40
|
+
for (const entry of pending.values()) entry.resolve({ status: 503, body: '{"error":"relay-stopped"}' })
|
|
41
|
+
pending.clear()
|
|
42
|
+
for (const entry of streams.values()) entry.onEnd()
|
|
43
|
+
streams.clear()
|
|
44
|
+
for (const request of locals.values()) { try { request.destroy() } catch { /* ignore */ } }
|
|
45
|
+
locals.clear()
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
async function register() {
|
|
49
|
+
const response = await fetch(`${base}/v1/register`, {
|
|
50
|
+
method: 'POST',
|
|
51
|
+
headers: { 'content-type': 'application/json' },
|
|
52
|
+
body: JSON.stringify({ groupId, deviceId, name: deviceName, userId }),
|
|
53
|
+
})
|
|
54
|
+
if (!response.ok) throw new Error(`relay register failed: ${response.status}`)
|
|
55
|
+
const data = await response.json()
|
|
56
|
+
token = data.token
|
|
57
|
+
applyPeers(data.peers)
|
|
58
|
+
log.info(`[remote-control] relay connected: ${base}`)
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
async function heartbeat() {
|
|
62
|
+
if (!token || stopped) return
|
|
63
|
+
try {
|
|
64
|
+
const response = await fetch(`${base}/v1/heartbeat`, {
|
|
65
|
+
method: 'POST',
|
|
66
|
+
headers: { 'content-type': 'application/json' },
|
|
67
|
+
body: JSON.stringify({ token }),
|
|
68
|
+
})
|
|
69
|
+
if (response.status === 401) { token = undefined; return }
|
|
70
|
+
if (!response.ok) return
|
|
71
|
+
const data = await response.json()
|
|
72
|
+
applyPeers(data.peers)
|
|
73
|
+
} catch { /* relay unreachable; next tick retries */ }
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
function applyPeers(list) {
|
|
77
|
+
if (!Array.isArray(list)) return
|
|
78
|
+
const seen = new Set()
|
|
79
|
+
for (const item of list) {
|
|
80
|
+
if (!item || typeof item.deviceId !== 'string') continue
|
|
81
|
+
seen.add(item.deviceId)
|
|
82
|
+
peers.set(item.deviceId, {
|
|
83
|
+
deviceId: item.deviceId,
|
|
84
|
+
name: typeof item.name === 'string' ? item.name : 'DSH device',
|
|
85
|
+
userId: typeof item.userId === 'string' ? item.userId : null,
|
|
86
|
+
lastSeen: Number(item.lastSeen) || Date.now(),
|
|
87
|
+
})
|
|
88
|
+
}
|
|
89
|
+
for (const deviceId of peers.keys()) if (!seen.has(deviceId)) peers.delete(deviceId)
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
async function inboxLoop() {
|
|
93
|
+
while (!stopped) {
|
|
94
|
+
if (!token) {
|
|
95
|
+
await delay(1500)
|
|
96
|
+
try { await register() } catch { /* retry */ }
|
|
97
|
+
continue
|
|
98
|
+
}
|
|
99
|
+
try {
|
|
100
|
+
const controller = new AbortController()
|
|
101
|
+
const timer = setTimeout(() => controller.abort(), 40000)
|
|
102
|
+
const response = await fetch(`${base}/v1/inbox?token=${encodeURIComponent(token)}&wait=25`, { signal: controller.signal })
|
|
103
|
+
clearTimeout(timer)
|
|
104
|
+
if (response.status === 401) { token = undefined; continue }
|
|
105
|
+
if (!response.ok) { await delay(1000); continue }
|
|
106
|
+
const data = await response.json()
|
|
107
|
+
if (data.peers) applyPeers(data.peers)
|
|
108
|
+
for (const item of data.messages ?? []) {
|
|
109
|
+
dispatch(item.from, item.message).catch((error) => {
|
|
110
|
+
log.warn(`[remote-control] relay dispatch failed: ${error?.message || error}`)
|
|
111
|
+
})
|
|
112
|
+
}
|
|
113
|
+
} catch {
|
|
114
|
+
if (stopped) return
|
|
115
|
+
await delay(1000)
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
async function dispatch(from, message) {
|
|
121
|
+
if (!message || typeof message !== 'object') return
|
|
122
|
+
switch (message.kind) {
|
|
123
|
+
case 'http': {
|
|
124
|
+
const result = await localRequest(message.method, message.path, message.headers, message.body)
|
|
125
|
+
await send(from, { kind: 'http-response', id: message.id, status: result.status, body: result.body })
|
|
126
|
+
return
|
|
127
|
+
}
|
|
128
|
+
case 'http-response': {
|
|
129
|
+
const entry = pending.get(message.id)
|
|
130
|
+
if (!entry) return
|
|
131
|
+
pending.delete(message.id)
|
|
132
|
+
entry.resolve({ status: message.status, body: message.body })
|
|
133
|
+
return
|
|
134
|
+
}
|
|
135
|
+
case 'stream-open':
|
|
136
|
+
openLocalStream(from, message)
|
|
137
|
+
return
|
|
138
|
+
case 'stream-status': {
|
|
139
|
+
const entry = streams.get(message.id)
|
|
140
|
+
entry?.onStatus?.(message.status)
|
|
141
|
+
return
|
|
142
|
+
}
|
|
143
|
+
case 'stream-chunk': {
|
|
144
|
+
streams.get(message.id)?.onChunk(message.chunk)
|
|
145
|
+
return
|
|
146
|
+
}
|
|
147
|
+
case 'stream-end': {
|
|
148
|
+
const entry = streams.get(message.id)
|
|
149
|
+
if (!entry) return
|
|
150
|
+
streams.delete(message.id)
|
|
151
|
+
entry.onEnd()
|
|
152
|
+
return
|
|
153
|
+
}
|
|
154
|
+
case 'stream-cancel': {
|
|
155
|
+
const request = locals.get(message.id)
|
|
156
|
+
if (!request) return
|
|
157
|
+
locals.delete(message.id)
|
|
158
|
+
try { request.destroy() } catch { /* ignore */ }
|
|
159
|
+
return
|
|
160
|
+
}
|
|
161
|
+
default:
|
|
162
|
+
return
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
function localRequest(method, pathname, headers, body) {
|
|
167
|
+
return new Promise((resolve) => {
|
|
168
|
+
const request = http.request({
|
|
169
|
+
host: '127.0.0.1',
|
|
170
|
+
port: localPort,
|
|
171
|
+
path: pathname,
|
|
172
|
+
method,
|
|
173
|
+
headers: { ...headers, host: `127.0.0.1:${localPort}` },
|
|
174
|
+
}, (response) => {
|
|
175
|
+
let data = ''
|
|
176
|
+
response.on('data', (chunk) => { data += chunk })
|
|
177
|
+
response.on('end', () => resolve({ status: response.statusCode ?? 502, body: data }))
|
|
178
|
+
})
|
|
179
|
+
request.on('error', (error) => resolve({
|
|
180
|
+
status: 502,
|
|
181
|
+
body: JSON.stringify({ error: 'local-unreachable', message: error?.message || String(error) }),
|
|
182
|
+
}))
|
|
183
|
+
if (body !== undefined && body !== null) request.write(body)
|
|
184
|
+
request.end()
|
|
185
|
+
})
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
function openLocalStream(from, message) {
|
|
189
|
+
const id = message.id
|
|
190
|
+
const request = http.request({
|
|
191
|
+
host: '127.0.0.1',
|
|
192
|
+
port: localPort,
|
|
193
|
+
path: message.path,
|
|
194
|
+
method: 'GET',
|
|
195
|
+
headers: { ...message.headers, host: `127.0.0.1:${localPort}` },
|
|
196
|
+
}, (response) => {
|
|
197
|
+
send(from, { kind: 'stream-status', id, status: response.statusCode ?? 502 }).catch(() => {})
|
|
198
|
+
if ((response.statusCode ?? 500) >= 400) {
|
|
199
|
+
response.resume()
|
|
200
|
+
locals.delete(id)
|
|
201
|
+
send(from, { kind: 'stream-end', id }).catch(() => {})
|
|
202
|
+
return
|
|
203
|
+
}
|
|
204
|
+
response.on('data', (chunk) => {
|
|
205
|
+
send(from, { kind: 'stream-chunk', id, chunk: chunk.toString('utf8') }).catch(() => {})
|
|
206
|
+
})
|
|
207
|
+
response.on('end', () => {
|
|
208
|
+
locals.delete(id)
|
|
209
|
+
send(from, { kind: 'stream-end', id }).catch(() => {})
|
|
210
|
+
})
|
|
211
|
+
response.on('error', () => {
|
|
212
|
+
locals.delete(id)
|
|
213
|
+
send(from, { kind: 'stream-end', id }).catch(() => {})
|
|
214
|
+
})
|
|
215
|
+
})
|
|
216
|
+
request.on('error', () => {
|
|
217
|
+
locals.delete(id)
|
|
218
|
+
send(from, { kind: 'stream-end', id }).catch(() => {})
|
|
219
|
+
})
|
|
220
|
+
locals.set(id, request)
|
|
221
|
+
request.end()
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
async function send(to, message) {
|
|
225
|
+
if (!token) throw new Error('relay not registered')
|
|
226
|
+
const response = await fetch(`${base}/v1/send`, {
|
|
227
|
+
method: 'POST',
|
|
228
|
+
headers: { 'content-type': 'application/json' },
|
|
229
|
+
body: JSON.stringify({ token, to, message }),
|
|
230
|
+
})
|
|
231
|
+
if (response.status === 401) { token = undefined; throw new Error('relay unauthorized') }
|
|
232
|
+
if (!response.ok) throw new Error(`relay send failed: ${response.status}`)
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
async function request(target, options) {
|
|
236
|
+
if (!token) return { status: 503, body: '{"error":"relay-offline"}' }
|
|
237
|
+
const id = nextId()
|
|
238
|
+
const reply = new Promise((resolve) => {
|
|
239
|
+
const timer = setTimeout(() => {
|
|
240
|
+
pending.delete(id)
|
|
241
|
+
resolve({ status: 504, body: '{"error":"relay-timeout"}' })
|
|
242
|
+
}, 20000)
|
|
243
|
+
timer.unref?.()
|
|
244
|
+
pending.set(id, { resolve: (value) => { clearTimeout(timer); resolve(value) } })
|
|
245
|
+
})
|
|
246
|
+
await send(target, {
|
|
247
|
+
kind: 'http',
|
|
248
|
+
id,
|
|
249
|
+
method: options.method,
|
|
250
|
+
path: options.path,
|
|
251
|
+
headers: options.headers,
|
|
252
|
+
body: options.body,
|
|
253
|
+
})
|
|
254
|
+
return reply
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
function stream(target, options, res) {
|
|
258
|
+
const id = nextId()
|
|
259
|
+
let opened = false
|
|
260
|
+
const entry = {
|
|
261
|
+
onStatus(status) {
|
|
262
|
+
if (opened) return
|
|
263
|
+
opened = true
|
|
264
|
+
if (status >= 400) {
|
|
265
|
+
streams.delete(id)
|
|
266
|
+
try {
|
|
267
|
+
res.writeHead(status, { 'content-type': 'application/json; charset=utf-8', 'cache-control': 'no-store' })
|
|
268
|
+
res.end('{"error":"peer-refused"}')
|
|
269
|
+
} catch { /* ignore */ }
|
|
270
|
+
return
|
|
271
|
+
}
|
|
272
|
+
try {
|
|
273
|
+
res.writeHead(200, {
|
|
274
|
+
'content-type': 'text/event-stream; charset=utf-8',
|
|
275
|
+
'cache-control': 'no-store, no-transform',
|
|
276
|
+
connection: 'keep-alive',
|
|
277
|
+
'x-accel-buffering': 'no',
|
|
278
|
+
})
|
|
279
|
+
} catch { /* ignore */ }
|
|
280
|
+
},
|
|
281
|
+
onChunk(chunk) { try { res.write(chunk) } catch { /* ignore */ } },
|
|
282
|
+
onEnd() { try { res.end() } catch { /* ignore */ } },
|
|
283
|
+
}
|
|
284
|
+
streams.set(id, entry)
|
|
285
|
+
const timeout = setTimeout(() => {
|
|
286
|
+
if (opened) return
|
|
287
|
+
opened = true
|
|
288
|
+
streams.delete(id)
|
|
289
|
+
try {
|
|
290
|
+
res.writeHead(504, { 'content-type': 'application/json; charset=utf-8' })
|
|
291
|
+
res.end('{"error":"relay-timeout"}')
|
|
292
|
+
} catch { /* ignore */ }
|
|
293
|
+
}, 15000)
|
|
294
|
+
timeout.unref?.()
|
|
295
|
+
|
|
296
|
+
res.on('close', () => {
|
|
297
|
+
clearTimeout(timeout)
|
|
298
|
+
if (!streams.has(id)) return
|
|
299
|
+
streams.delete(id)
|
|
300
|
+
send(target, { kind: 'stream-cancel', id }).catch(() => {})
|
|
301
|
+
})
|
|
302
|
+
|
|
303
|
+
send(target, {
|
|
304
|
+
kind: 'stream-open',
|
|
305
|
+
id,
|
|
306
|
+
path: options.path,
|
|
307
|
+
headers: options.headers,
|
|
308
|
+
}).catch((error) => {
|
|
309
|
+
clearTimeout(timeout)
|
|
310
|
+
streams.delete(id)
|
|
311
|
+
if (!res.writableEnded) {
|
|
312
|
+
try { res.writeHead(502, { 'content-type': 'application/json; charset=utf-8' }); res.end(JSON.stringify({ error: error?.message || 'relay-send-failed' })) } catch { /* ignore */ }
|
|
313
|
+
}
|
|
314
|
+
})
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
function nextId() {
|
|
318
|
+
sequence += 1
|
|
319
|
+
return `${deviceId}-${Date.now().toString(36)}-${sequence}`
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
return {
|
|
323
|
+
start,
|
|
324
|
+
stop,
|
|
325
|
+
request,
|
|
326
|
+
stream,
|
|
327
|
+
peers: () => [...peers.values()],
|
|
328
|
+
url: () => base,
|
|
329
|
+
connected: () => Boolean(token),
|
|
330
|
+
}
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
function delay(ms) {
|
|
334
|
+
return new Promise((resolve) => setTimeout(resolve, ms))
|
|
335
|
+
}
|
package/icon.svg
ADDED
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64" width="64" height="64" role="img" aria-label="Remote Control">
|
|
2
|
+
<defs>
|
|
3
|
+
<linearGradient id="bg" x1="0" y1="0" x2="1" y2="1">
|
|
4
|
+
<stop offset="0" stop-color="#2f6bff"/>
|
|
5
|
+
<stop offset="1" stop-color="#7a3cff"/>
|
|
6
|
+
</linearGradient>
|
|
7
|
+
</defs>
|
|
8
|
+
<rect x="2" y="2" width="60" height="60" rx="16" fill="url(#bg)"/>
|
|
9
|
+
<rect x="20" y="12" width="24" height="40" rx="6" fill="none" stroke="#ffffff" stroke-width="3.2"/>
|
|
10
|
+
<line x1="28" y1="17" x2="36" y2="17" stroke="#ffffff" stroke-width="2.6" stroke-linecap="round"/>
|
|
11
|
+
<path d="M27 30l-4 5 4 5" fill="none" stroke="#dce6ff" stroke-width="3" stroke-linecap="round" stroke-linejoin="round"/>
|
|
12
|
+
<line x1="35" y1="29" x2="35" y2="41" stroke="#ffffff" stroke-width="3" stroke-linecap="round"/>
|
|
13
|
+
<path d="M44 24c3 2.5 3 13.5 0 16" fill="none" stroke="#ffffff" stroke-width="2.6" stroke-linecap="round" opacity="0.9"/>
|
|
14
|
+
<path d="M49 19c5.5 4.5 5.5 21.5 0 26" fill="none" stroke="#ffffff" stroke-width="2.6" stroke-linecap="round" opacity="0.55"/>
|
|
15
|
+
</svg>
|