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.
@@ -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 }
@@ -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
+ }