dsh-remote-control-plugin 0.3.5 → 0.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/INSTALL.md +5 -4
- package/client.js +125 -6
- package/host/crypto.js +145 -0
- package/host/fabric.js +711 -40
- package/host/handshake.js +39 -0
- package/host/identity.js +112 -0
- package/host/lane.js +10 -0
- package/host/nat.js +113 -0
- package/host/p2p.js +169 -0
- package/host/punch.js +69 -0
- package/host/qr.js +212 -0
- package/host/reachability.js +252 -0
- package/host/relay-core.js +2 -1
- package/host/relay-link.js +33 -10
- package/host/udp-channel.js +176 -0
- package/host/uploads.js +170 -0
- package/index.js +218 -34
- package/package.json +1 -1
|
@@ -0,0 +1,176 @@
|
|
|
1
|
+
import dgram from 'node:dgram'
|
|
2
|
+
import { randomBytes } from 'node:crypto'
|
|
3
|
+
import { buildBindingRequest, parseBindingResponse } from './nat.js'
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Minimal reliable datagram channel over UDP, for the punched P2P lane.
|
|
7
|
+
*
|
|
8
|
+
* A message is JSON, split into 1200-byte fragments. Each fragment carries
|
|
9
|
+
* (type, messageId, seq, total); the sender retransmits the whole message on an
|
|
10
|
+
* interval until the receiver acknowledges it, and the receiver reassembles
|
|
11
|
+
* fragments and delivers only complete messages. Responses reference the
|
|
12
|
+
* request's message id via `replyTo`, so a caller can correlate them without a
|
|
13
|
+
* connection.
|
|
14
|
+
*
|
|
15
|
+
* This is a handshake-free, single-peer-ish channel: it is deliberately small
|
|
16
|
+
* and is not a general transport. Ordering across concurrent messages is not
|
|
17
|
+
* guaranteed; callers that need it must serialize.
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
const MAGIC = Buffer.from('DSHUDP01', 'utf8')
|
|
21
|
+
const TYPE_DATA = 0
|
|
22
|
+
const TYPE_ACK = 1
|
|
23
|
+
const HEADER = 8 + 1 + 4 + 2 + 2
|
|
24
|
+
const MAX_PAYLOAD = 1200
|
|
25
|
+
|
|
26
|
+
function pack(type, messageId, seq, total, payload) {
|
|
27
|
+
const buffer = Buffer.alloc(HEADER + payload.length)
|
|
28
|
+
MAGIC.copy(buffer, 0)
|
|
29
|
+
buffer.writeUInt8(type, 8)
|
|
30
|
+
buffer.writeUInt32BE(messageId >>> 0, 9)
|
|
31
|
+
buffer.writeUInt16BE(seq, 13)
|
|
32
|
+
buffer.writeUInt16BE(total, 15)
|
|
33
|
+
payload.copy(buffer, HEADER)
|
|
34
|
+
return buffer
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function unpack(buffer) {
|
|
38
|
+
if (!Buffer.isBuffer(buffer) || buffer.length < HEADER) return undefined
|
|
39
|
+
if (!buffer.subarray(0, MAGIC.length).equals(MAGIC)) return undefined
|
|
40
|
+
return {
|
|
41
|
+
type: buffer.readUInt8(8),
|
|
42
|
+
messageId: buffer.readUInt32BE(9),
|
|
43
|
+
seq: buffer.readUInt16BE(13),
|
|
44
|
+
total: buffer.readUInt16BE(15),
|
|
45
|
+
payload: buffer.subarray(HEADER),
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export async function createUdpChannel({ handler, intervalMs = 300, timeoutMs = 5000, bindPort = 0 } = {}) {
|
|
50
|
+
const socket = dgram.createSocket('udp4')
|
|
51
|
+
await new Promise((resolve, reject) => {
|
|
52
|
+
socket.once('error', reject)
|
|
53
|
+
socket.bind(bindPort, resolve)
|
|
54
|
+
})
|
|
55
|
+
const port = socket.address().port
|
|
56
|
+
let nextId = 0
|
|
57
|
+
const deliveries = new Map() // messageId -> resolve(ok) on ack
|
|
58
|
+
const responses = new Map() // requestId -> resolve(message)
|
|
59
|
+
const inbound = new Map() // key -> { parts, total, remote }
|
|
60
|
+
|
|
61
|
+
function transmit(remote, message) {
|
|
62
|
+
const messageId = nextId++
|
|
63
|
+
const text = Buffer.from(JSON.stringify(message), 'utf8')
|
|
64
|
+
const total = Math.max(1, Math.ceil(text.length / MAX_PAYLOAD))
|
|
65
|
+
const chunks = []
|
|
66
|
+
for (let index = 0; index < total; index += 1) {
|
|
67
|
+
chunks.push(text.subarray(index * MAX_PAYLOAD, (index + 1) * MAX_PAYLOAD))
|
|
68
|
+
}
|
|
69
|
+
const send = () => {
|
|
70
|
+
chunks.forEach((chunk, seq) => {
|
|
71
|
+
try { socket.send(pack(TYPE_DATA, messageId, seq, total, chunk), remote.port, remote.address) } catch { /* unreachable */ }
|
|
72
|
+
})
|
|
73
|
+
}
|
|
74
|
+
send()
|
|
75
|
+
const timer = setInterval(send, intervalMs)
|
|
76
|
+
timer.unref?.()
|
|
77
|
+
return new Promise((resolve) => {
|
|
78
|
+
const deadline = setTimeout(() => { clearInterval(timer); deliveries.delete(messageId); resolve(false) }, timeoutMs)
|
|
79
|
+
deliveries.set(messageId, () => { clearInterval(timer); clearTimeout(deadline); deliveries.delete(messageId); resolve(true) })
|
|
80
|
+
})
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
async function request(remote, message) {
|
|
84
|
+
const responsePromise = new Promise((resolve) => {
|
|
85
|
+
const requestId = nextId
|
|
86
|
+
responses.set(requestId, resolve)
|
|
87
|
+
setTimeout(() => {
|
|
88
|
+
if (!responses.has(requestId)) return
|
|
89
|
+
responses.delete(requestId)
|
|
90
|
+
resolve(undefined)
|
|
91
|
+
}, timeoutMs).unref?.()
|
|
92
|
+
})
|
|
93
|
+
const delivered = await transmit(remote, message)
|
|
94
|
+
if (!delivered) return { ok: false, error: 'timeout' }
|
|
95
|
+
const reply = await responsePromise
|
|
96
|
+
if (!reply) return { ok: false, error: 'no-reply' }
|
|
97
|
+
return { ok: true, message: reply }
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/** Fire-and-forget datagram; used for streaming frames where a reply is wrong. */
|
|
101
|
+
function notify(remote, message) {
|
|
102
|
+
const messageId = nextId++
|
|
103
|
+
const text = Buffer.from(JSON.stringify(message), 'utf8')
|
|
104
|
+
const total = Math.max(1, Math.ceil(text.length / MAX_PAYLOAD))
|
|
105
|
+
for (let seq = 0; seq < total; seq += 1) {
|
|
106
|
+
const chunk = text.subarray(seq * MAX_PAYLOAD, (seq + 1) * MAX_PAYLOAD)
|
|
107
|
+
try { socket.send(pack(TYPE_DATA, messageId, seq, total, chunk), remote.port, remote.address) } catch { /* unreachable */ }
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
socket.on('message', async (buffer, rinfo) => {
|
|
112
|
+
const frame = unpack(buffer)
|
|
113
|
+
if (!frame) return
|
|
114
|
+
if (frame.type === TYPE_ACK) {
|
|
115
|
+
deliveries.get(frame.messageId)?.()
|
|
116
|
+
return
|
|
117
|
+
}
|
|
118
|
+
if (frame.type !== TYPE_DATA) return
|
|
119
|
+
const key = `${frame.messageId}:${rinfo.address}:${rinfo.port}`
|
|
120
|
+
let entry = inbound.get(key)
|
|
121
|
+
if (!entry) {
|
|
122
|
+
entry = { parts: new Array(frame.total), total: frame.total, remote: rinfo }
|
|
123
|
+
inbound.set(key, entry)
|
|
124
|
+
}
|
|
125
|
+
entry.parts[frame.seq] = frame.payload
|
|
126
|
+
if (entry.parts.filter(Boolean).length < entry.total) return
|
|
127
|
+
inbound.delete(key)
|
|
128
|
+
const message = JSON.parse(Buffer.concat(entry.parts).toString('utf8'))
|
|
129
|
+
try { socket.send(pack(TYPE_ACK, frame.messageId, 0, 0, Buffer.alloc(0)), rinfo.port, rinfo.address) } catch { /* ignore */ }
|
|
130
|
+
if (typeof message.replyTo === 'number') {
|
|
131
|
+
const resolve = responses.get(message.replyTo)
|
|
132
|
+
if (resolve) { responses.delete(message.replyTo); resolve(message) }
|
|
133
|
+
return
|
|
134
|
+
}
|
|
135
|
+
if (typeof handler !== 'function') return
|
|
136
|
+
try {
|
|
137
|
+
const reply = await handler(message, rinfo)
|
|
138
|
+
transmit(rinfo, { ...(reply ?? {}), replyTo: frame.messageId })
|
|
139
|
+
} catch { /* handler failure drops the request */ }
|
|
140
|
+
})
|
|
141
|
+
|
|
142
|
+
/** Discover this socket's public mapping with STUN, from this very socket. */
|
|
143
|
+
function stun({ host = 'stun.l.google.com', port = 19302, timeoutMs = 1500 } = {}) {
|
|
144
|
+
return new Promise((resolve) => {
|
|
145
|
+
const transactionId = randomBytes(12)
|
|
146
|
+
const request = buildBindingRequest(transactionId)
|
|
147
|
+
let settled = false
|
|
148
|
+
const finish = (value) => {
|
|
149
|
+
if (settled) return
|
|
150
|
+
settled = true
|
|
151
|
+
socket.removeListener('message', onMessage)
|
|
152
|
+
clearTimeout(timer)
|
|
153
|
+
resolve(value)
|
|
154
|
+
}
|
|
155
|
+
const onMessage = (buffer) => {
|
|
156
|
+
if (buffer.length >= 20 && buffer.readUInt32BE(4) === 0x2112a442 && buffer.subarray(8, 20).equals(transactionId)) {
|
|
157
|
+
finish(parseBindingResponse(buffer))
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
socket.on('message', onMessage)
|
|
161
|
+
const timer = setTimeout(() => finish(undefined), timeoutMs)
|
|
162
|
+
timer.unref?.()
|
|
163
|
+
try { socket.send(request, 0, request.length, port, host) } catch { finish(undefined) }
|
|
164
|
+
})
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
return {
|
|
168
|
+
port,
|
|
169
|
+
request,
|
|
170
|
+
notify,
|
|
171
|
+
stun,
|
|
172
|
+
close: () => new Promise((resolve) => { try { socket.close(resolve) } catch { resolve() } }),
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
export { pack, unpack, TYPE_DATA, TYPE_ACK }
|
package/host/uploads.js
ADDED
|
@@ -0,0 +1,170 @@
|
|
|
1
|
+
import { randomUUID } from 'node:crypto'
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Peer file uploads.
|
|
5
|
+
*
|
|
6
|
+
* A remote device never writes a path: it streams base64 chunks to this Host,
|
|
7
|
+
* and `commit` stores the assembled bytes through the attachment service, the
|
|
8
|
+
* same store the browser UI uses. State is per-upload, in memory, and bound to
|
|
9
|
+
* one session; nothing survives a commit except the attachment itself.
|
|
10
|
+
*
|
|
11
|
+
* Chunk size stays below the relay's per-message limit: 1 MiB of bytes becomes
|
|
12
|
+
* ~1.37 MiB of base64, comfortably inside the 2 MB message cap.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
const IMAGE_TYPES = new Set(['image/png', 'image/jpeg', 'image/webp', 'image/gif'])
|
|
16
|
+
const DEFAULT_TYPES = [
|
|
17
|
+
...IMAGE_TYPES,
|
|
18
|
+
'application/pdf',
|
|
19
|
+
'text/plain',
|
|
20
|
+
'text/markdown',
|
|
21
|
+
'application/json',
|
|
22
|
+
'application/zip',
|
|
23
|
+
'application/octet-stream',
|
|
24
|
+
]
|
|
25
|
+
|
|
26
|
+
export function createUploadManager({ getAttachments, limits = {}, log = console } = {}) {
|
|
27
|
+
const config = {
|
|
28
|
+
maxFileBytes: limits.maxFileBytes ?? 25 * 1024 * 1024,
|
|
29
|
+
chunkBytes: limits.chunkBytes ?? 1024 * 1024,
|
|
30
|
+
sessionQuotaBytes: limits.sessionQuotaBytes ?? 100 * 1024 * 1024,
|
|
31
|
+
ttlMs: limits.ttlMs ?? 10 * 60 * 1000,
|
|
32
|
+
allowedTypes: new Set(limits.allowedTypes ?? DEFAULT_TYPES),
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
const uploads = new Map()
|
|
36
|
+
const usage = new Map() // sessionId -> committed bytes
|
|
37
|
+
|
|
38
|
+
const reaper = setInterval(() => {
|
|
39
|
+
const now = Date.now()
|
|
40
|
+
for (const [uploadId, upload] of uploads) {
|
|
41
|
+
if (now - upload.at > config.ttlMs) uploads.delete(uploadId)
|
|
42
|
+
}
|
|
43
|
+
}, 60_000)
|
|
44
|
+
reaper.unref?.()
|
|
45
|
+
|
|
46
|
+
const fail = (status, error, message) => ({ status, error, message })
|
|
47
|
+
|
|
48
|
+
function sanitizeName(name) {
|
|
49
|
+
const base = String(name ?? 'file').split(/[\\/]/).pop() || 'file'
|
|
50
|
+
return base.replace(/[\u0000-\u001f<>:"|?*]/g, '_').trim().slice(0, 120) || 'file'
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function init(body) {
|
|
54
|
+
const sessionId = typeof body?.sessionId === 'string' ? body.sessionId : ''
|
|
55
|
+
if (!sessionId) return fail(400, 'missing-session', 'An upload must name its session.')
|
|
56
|
+
const size = Number(body?.size)
|
|
57
|
+
if (!Number.isInteger(size) || size <= 0) return fail(400, 'bad-size', 'size must be a positive integer.')
|
|
58
|
+
if (size > config.maxFileBytes) {
|
|
59
|
+
return fail(413, 'too-large', `Files are limited to ${Math.floor(config.maxFileBytes / (1024 * 1024))} MB.`)
|
|
60
|
+
}
|
|
61
|
+
const mediaType = typeof body?.mediaType === 'string' ? body.mediaType : 'application/octet-stream'
|
|
62
|
+
if (!config.allowedTypes.has(mediaType)) return fail(415, 'bad-type', `Unsupported media type: ${mediaType}`)
|
|
63
|
+
const used = usage.get(sessionId) ?? 0
|
|
64
|
+
if (used + size > config.sessionQuotaBytes) {
|
|
65
|
+
return fail(507, 'quota', 'This session has reached its upload quota.')
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
const uploadId = randomUUID()
|
|
69
|
+
uploads.set(uploadId, {
|
|
70
|
+
uploadId,
|
|
71
|
+
sessionId,
|
|
72
|
+
name: sanitizeName(body?.name),
|
|
73
|
+
mediaType,
|
|
74
|
+
size,
|
|
75
|
+
chunkBytes: config.chunkBytes,
|
|
76
|
+
chunks: new Map(),
|
|
77
|
+
at: Date.now(),
|
|
78
|
+
})
|
|
79
|
+
return { status: 200, uploadId, chunkSize: config.chunkBytes, maxFileBytes: config.maxFileBytes }
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
function chunk(body) {
|
|
83
|
+
const upload = uploads.get(String(body?.uploadId ?? ''))
|
|
84
|
+
if (!upload) return fail(404, 'unknown-upload', 'This upload is unknown or expired.')
|
|
85
|
+
const index = Number(body?.index)
|
|
86
|
+
if (!Number.isInteger(index) || index < 0) return fail(400, 'bad-index', 'index must be a non-negative integer.')
|
|
87
|
+
const encoded = typeof body?.data === 'string' ? body.data : ''
|
|
88
|
+
if (!encoded) return fail(400, 'empty-chunk', 'Chunk data is required.')
|
|
89
|
+
let bytes
|
|
90
|
+
try {
|
|
91
|
+
bytes = Buffer.from(encoded, 'base64')
|
|
92
|
+
} catch {
|
|
93
|
+
return fail(400, 'bad-encoding', 'Chunk data must be base64.')
|
|
94
|
+
}
|
|
95
|
+
if (bytes.length > upload.chunkBytes) return fail(413, 'chunk-too-large', 'Chunk exceeds the negotiated size.')
|
|
96
|
+
|
|
97
|
+
const previous = upload.chunks.get(index)?.length ?? 0
|
|
98
|
+
const total = [...upload.chunks.values()].reduce((sum, value) => sum + value.length, 0)
|
|
99
|
+
if (total - previous + bytes.length > upload.size) {
|
|
100
|
+
return fail(413, 'exceeds-declared', 'Chunks exceed the declared file size.')
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
upload.chunks.set(index, bytes)
|
|
104
|
+
upload.at = Date.now()
|
|
105
|
+
const receivedBytes = [...upload.chunks.values()].reduce((sum, value) => sum + value.length, 0)
|
|
106
|
+
return { status: 200, received: upload.chunks.size, receivedBytes }
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
function status(body) {
|
|
110
|
+
const upload = uploads.get(String(body?.uploadId ?? ''))
|
|
111
|
+
if (!upload) return fail(404, 'unknown-upload', 'This upload is unknown or expired.')
|
|
112
|
+
return {
|
|
113
|
+
status: 200,
|
|
114
|
+
uploadId: upload.uploadId,
|
|
115
|
+
size: upload.size,
|
|
116
|
+
chunkSize: upload.chunkBytes,
|
|
117
|
+
received: [...upload.chunks.keys()].sort((a, b) => a - b),
|
|
118
|
+
receivedBytes: [...upload.chunks.values()].reduce((sum, value) => sum + value.length, 0),
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
async function commit(body) {
|
|
123
|
+
const upload = uploads.get(String(body?.uploadId ?? ''))
|
|
124
|
+
if (!upload) return fail(404, 'unknown-upload', 'This upload is unknown or expired.')
|
|
125
|
+
const receivedBytes = [...upload.chunks.values()].reduce((sum, value) => sum + value.length, 0)
|
|
126
|
+
if (receivedBytes !== upload.size) {
|
|
127
|
+
return { ...fail(409, 'incomplete', 'Not every chunk arrived.'), receivedBytes, size: upload.size }
|
|
128
|
+
}
|
|
129
|
+
const attachments = getAttachments?.()
|
|
130
|
+
if (!attachments) return fail(503, 'attachments-unavailable', 'This Host has no attachment store.')
|
|
131
|
+
|
|
132
|
+
const data = Buffer.concat([...upload.chunks.keys()].sort((a, b) => a - b).map((index) => upload.chunks.get(index)))
|
|
133
|
+
let attachment
|
|
134
|
+
let kind
|
|
135
|
+
try {
|
|
136
|
+
if (IMAGE_TYPES.has(upload.mediaType)) {
|
|
137
|
+
attachment = await attachments.saveImage({ data, mediaType: upload.mediaType, name: upload.name })
|
|
138
|
+
kind = 'image'
|
|
139
|
+
} else {
|
|
140
|
+
attachment = await attachments.saveFile({ data, name: upload.name })
|
|
141
|
+
kind = 'file'
|
|
142
|
+
}
|
|
143
|
+
} catch (error) {
|
|
144
|
+
log.warn?.(`[remote-control] upload commit failed: ${error?.message || error}`)
|
|
145
|
+
return fail(422, 'store-rejected', `The attachment store refused the file: ${error?.message || error}`)
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
uploads.delete(upload.uploadId)
|
|
149
|
+
usage.set(upload.sessionId, (usage.get(upload.sessionId) ?? 0) + upload.size)
|
|
150
|
+
return { status: 200, kind, attachment, sessionId: upload.sessionId, bytes: upload.size }
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
function cancel(body) {
|
|
154
|
+
const uploadId = String(body?.uploadId ?? '')
|
|
155
|
+
if (!uploads.has(uploadId)) return fail(404, 'unknown-upload', 'This upload is unknown or expired.')
|
|
156
|
+
uploads.delete(uploadId)
|
|
157
|
+
return { status: 200, ok: true }
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
return {
|
|
161
|
+
init,
|
|
162
|
+
chunk,
|
|
163
|
+
status,
|
|
164
|
+
commit,
|
|
165
|
+
cancel,
|
|
166
|
+
limits: config,
|
|
167
|
+
stats: () => ({ uploads: uploads.size }),
|
|
168
|
+
close: () => clearInterval(reaper),
|
|
169
|
+
}
|
|
170
|
+
}
|