@sparkelf/dsh-plugin-mobile-gateway 0.9.0 → 0.9.1

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.
@@ -59,7 +59,57 @@ function tokenHashes() {
59
59
  } catch { return new Set() }
60
60
  }
61
61
 
62
+ /**
63
+ * Write to a socket that may already be gone.
64
+ *
65
+ * `destroyed` is checked before the write but the peer can disappear between the check and the
66
+ * write, and a write to a closed socket emits an 'error' that, unhandled, ends the process — one
67
+ * phone hanging up took this proxy down (EPIPE from FrameReader.onFrame). Every write on a
68
+ * connection the peer controls goes through here.
69
+ *
70
+ * @param socket - the stream to write to; may be destroyed or undefined.
71
+ * @param payload - bytes or text to send.
72
+ * @returns whether the write was attempted.
73
+ */
74
+ function safeWrite(socket, payload) {
75
+ if (socket === undefined || socket === null || socket.destroyed === true || socket.writable === false) return false
76
+ try {
77
+ socket.write(payload)
78
+ return true
79
+ } catch {
80
+ try { socket.destroy() } catch {}
81
+ return false
82
+ }
83
+ }
84
+
62
85
  const frame = {
86
+ /**
87
+ * Wrap an already-serialized payload in one unmasked text frame.
88
+ *
89
+ * The upstream socket from an 'upgrade' event is a raw TCP stream, not a WebSocket object, so
90
+ * anything written to it must already be framed. Writing the bare JSON text instead is a silent
91
+ * corruption: the payload's first byte becomes the frame header, and '{' is 0x7B = FIN 0, RSV1 1,
92
+ * RSV2 1, RSV3 1, opcode 0xB — which the receiving parser rejects as "RSV2 and RSV3 must be
93
+ * clear" and closes the connection over.
94
+ */
95
+ encodeText(text) {
96
+ const payload = Buffer.from(text, 'utf8')
97
+ if (payload.length < 126) {
98
+ return Buffer.concat([Buffer.from([0x81, payload.length]), payload])
99
+ }
100
+ if (payload.length < 65536) {
101
+ const header = Buffer.alloc(4)
102
+ header[0] = 0x81
103
+ header[1] = 126
104
+ header.writeUInt16BE(payload.length, 2)
105
+ return Buffer.concat([header, payload])
106
+ }
107
+ const header = Buffer.alloc(10)
108
+ header[0] = 0x81
109
+ header[1] = 127
110
+ header.writeBigUInt64BE(BigInt(payload.length), 2)
111
+ return Buffer.concat([header, payload])
112
+ },
63
113
  /** Encode one server frame. Payloads are text by construction. */
64
114
  encode(value) {
65
115
  const payload = Buffer.from(JSON.stringify(value), 'utf8')
@@ -269,7 +319,7 @@ server.on('upgrade', (request, socket, head) => {
269
319
  // asked for dsh-mobile-v1 must see it echoed or it will not treat the socket as established.
270
320
  const accept = response2.headers['sec-websocket-accept']
271
321
  const protocol = response2.headers['sec-websocket-protocol']
272
- socket.write(
322
+ safeWrite(socket,
273
323
  'HTTP/1.1 101 Switching Protocols\r\n'
274
324
  + 'Upgrade: websocket\r\n'
275
325
  + 'Connection: Upgrade\r\n'
@@ -277,11 +327,14 @@ server.on('upgrade', (request, socket, head) => {
277
327
  + (protocol === undefined ? '' : 'Sec-WebSocket-Protocol: ' + String(protocol) + '\r\n')
278
328
  + '\r\n',
279
329
  )
280
- if (head.length > 0) liveSocket.write(head)
281
- if (liveHead.length > 0) socket.write(liveHead)
330
+ // `head` holds leftover bytes the client sent after its handshake, already framed.
331
+ if (head.length > 0) safeWrite(liveSocket, head)
332
+ if (liveHead.length > 0) safeWrite(socket, liveHead)
282
333
 
283
334
  const liveReader = new FrameReader((text) => {
284
- if (!socket.destroyed) socket.write(frame.encode(JSON.parse(text)))
335
+ let parsed
336
+ try { parsed = JSON.parse(text) } catch { return }
337
+ safeWrite(socket, frame.encode(parsed))
285
338
  }, () => {})
286
339
  liveSocket.on('data', (chunk) => liveReader.push(chunk))
287
340
  liveSocket.on('close', () => { if (!socket.destroyed) socket.destroy() })
@@ -291,16 +344,16 @@ server.on('upgrade', (request, socket, head) => {
291
344
  // local storage, and every other frame is forwarded unchanged.
292
345
  const clientReader = new FrameReader((text) => {
293
346
  let message
294
- try { message = JSON.parse(text) } catch { liveSocket.write(text); return }
347
+ try { message = JSON.parse(text) } catch { safeWrite(liveSocket, frame.encodeText(text)); return }
295
348
  if (message?.type === 'history' && typeof message.sessionId === 'string') {
296
349
  let cached
297
350
  try { cached = cachedEvents(message.sessionId) } catch { cached = undefined }
298
351
  if (cached !== undefined && cached.events.length > 0) {
299
- socket.write(frame.encode(historyFrame(message, cached)))
352
+ safeWrite(socket, frame.encode(historyFrame(message, cached)))
300
353
  return
301
354
  }
302
355
  }
303
- liveSocket.write(text)
356
+ safeWrite(liveSocket, frame.encodeText(text))
304
357
  }, (kind) => { if (kind === 'close') liveSocket.destroy() })
305
358
  socket.on('data', (chunk) => clientReader.push(chunk))
306
359
  })
@@ -313,20 +366,17 @@ server.on('upgrade', (request, socket, head) => {
313
366
  response2.on('end', () => {
314
367
  if (socket.destroyed) return
315
368
  const body = Buffer.concat(chunks)
316
- socket.write('HTTP/1.1 ' + String(response2.statusCode ?? 502) + ' ' + String(response2.statusMessage ?? 'Bad Gateway') + '\r\nContent-Length: ' + String(body.length) + '\r\nConnection: close\r\n\r\n')
317
- socket.end(body)
369
+ if (safeWrite(socket, 'HTTP/1.1 ' + String(response2.statusCode ?? 502) + ' ' + String(response2.statusMessage ?? 'Bad Gateway') + '\r\nContent-Length: ' + String(body.length) + '\r\nConnection: close\r\n\r\n')) socket.end(body)
318
370
  })
319
371
  })
320
372
  live.on('error', () => {
321
373
  if (socket.destroyed) return
322
- socket.write('HTTP/1.1 502 Bad Gateway\r\nConnection: close\r\n\r\n')
323
- socket.end()
374
+ if (safeWrite(socket, 'HTTP/1.1 502 Bad Gateway\r\nConnection: close\r\n\r\n')) socket.end()
324
375
  })
325
376
  live.setTimeout(20000, () => {
326
377
  live.destroy()
327
378
  if (!socket.destroyed) {
328
- socket.write('HTTP/1.1 504 Gateway Timeout\r\nConnection: close\r\n\r\n')
329
- socket.end()
379
+ if (safeWrite(socket, 'HTTP/1.1 504 Gateway Timeout\r\nConnection: close\r\n\r\n')) socket.end()
330
380
  }
331
381
  })
332
382
  live.end()
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sparkelf/dsh-plugin-mobile-gateway",
3
- "version": "0.9.0",
3
+ "version": "0.9.1",
4
4
  "description": "Mobile gateway for DSH 0.1.7 (Session format 4) with an entry-VPS Session cache. Reads DSH 0.1.7 Session format 4 and lifts retired v3 message shapes on read; a companion cache service plus gateway proxy let a phone fetch history from the entry host at 109 MB/s instead of through the desktop's 0.9 MB/s upstream. Per-session Agent preset selection, live preset updates, and the upstream LAN/pairing protocol are unchanged.",
5
5
  "main": "lib/index.mjs",
6
6
  "files": [