@sparkelf/dsh-plugin-mobile-gateway 0.8.2 → 0.9.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/bin/push-session-cache.mjs +158 -0
- package/bin/session-cache-proxy.mjs +337 -0
- package/bin/session-cache.mjs +191 -0
- package/lib/index.mjs +47 -10
- package/package.json +3 -3
|
@@ -0,0 +1,158 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* Push Session snapshots to the entry VPS.
|
|
4
|
+
*
|
|
5
|
+
* A phone on the public path receives every byte through this desktop's own upstream, measured at
|
|
6
|
+
* 0.9 MB/s, while the entry VPS pushes downstream at 8.3 MB/s. Filling a cache there in the
|
|
7
|
+
* background moves that upstream cost off the phone's critical path: the phone then downloads from
|
|
8
|
+
* the VPS at its own speed and asks this gateway only for the increment.
|
|
9
|
+
*
|
|
10
|
+
* This walks the persisted Sessions, takes the ones worth caching, and PUTs each one's bytes. It
|
|
11
|
+
* never blocks a Session: a failed push is retried on the next pass, and nothing here is on the
|
|
12
|
+
* path a user waits for.
|
|
13
|
+
*
|
|
14
|
+
* Usage:
|
|
15
|
+
* push-session-cache.mjs --secret <value> [--home /root/.dsh] [--endpoint https://...]
|
|
16
|
+
* [--once] [--interval 300] [--max-sessions 20] [--rate 200]
|
|
17
|
+
*/
|
|
18
|
+
import { execFileSync } from 'node:child_process'
|
|
19
|
+
import { existsSync, readFileSync, readdirSync, statSync } from 'node:fs'
|
|
20
|
+
import { join, resolve } from 'node:path'
|
|
21
|
+
import { request } from 'node:https'
|
|
22
|
+
import { createGunzip } from 'node:zlib'
|
|
23
|
+
|
|
24
|
+
const argv = process.argv.slice(2)
|
|
25
|
+
let secret = process.env.DSH_CACHE_SECRET ?? ''
|
|
26
|
+
let home = process.env.DSH_HOME ?? '/root/.dsh'
|
|
27
|
+
let endpoint = process.env.DSH_CACHE_ENDPOINT ?? 'https://dsh.tokensfree.eu.cc/cache'
|
|
28
|
+
let once = false
|
|
29
|
+
let intervalSeconds = 300
|
|
30
|
+
let maxSessions = 20
|
|
31
|
+
let rateBytesPerSecond = 200 * 1024
|
|
32
|
+
for (let index = 0; index < argv.length; index += 1) {
|
|
33
|
+
const arg = argv[index]
|
|
34
|
+
if (arg === '--secret') { secret = argv[++index]; continue }
|
|
35
|
+
if (arg === '--home') { home = resolve(argv[++index]); continue }
|
|
36
|
+
if (arg === '--endpoint') { endpoint = argv[++index]; continue }
|
|
37
|
+
if (arg === '--once') { once = true; continue }
|
|
38
|
+
if (arg === '--interval') { intervalSeconds = Number(argv[++index]); continue }
|
|
39
|
+
if (arg === '--max-sessions') { maxSessions = Number(argv[++index]); continue }
|
|
40
|
+
if (arg === '--rate') { rateBytesPerSecond = Number(argv[++index]) * 1024; continue }
|
|
41
|
+
if (arg === '--help' || arg === '-h') {
|
|
42
|
+
console.log('usage: push-session-cache.mjs --secret <value> [--home <dir>] [--endpoint <url>] [--once] [--interval 300] [--max-sessions 20] [--rate 200]')
|
|
43
|
+
process.exit(0)
|
|
44
|
+
}
|
|
45
|
+
throw new Error('unknown option: ' + arg)
|
|
46
|
+
}
|
|
47
|
+
if (secret === '') throw new Error('push-session-cache: --secret or DSH_CACHE_SECRET is required')
|
|
48
|
+
|
|
49
|
+
/** Sessions live one directory deep, under a project directory whose name encodes the cwd. */
|
|
50
|
+
function findSessions() {
|
|
51
|
+
const root = join(home, 'sessions')
|
|
52
|
+
if (!existsSync(root)) return []
|
|
53
|
+
const found = []
|
|
54
|
+
for (const project of readdirSync(root, { withFileTypes: true })) {
|
|
55
|
+
if (!project.isDirectory()) continue
|
|
56
|
+
const projectPath = join(root, project.name)
|
|
57
|
+
for (const session of readdirSync(projectPath, { withFileTypes: true })) {
|
|
58
|
+
if (!session.isDirectory()) continue
|
|
59
|
+
const directory = join(projectPath, session.name)
|
|
60
|
+
// Prefer the highest format present: a Session that predates the v4 migration keeps both.
|
|
61
|
+
const candidates = readdirSync(directory)
|
|
62
|
+
.map((name) => /^session\.v(\d+)\.jsonl\.zstd$/.exec(name))
|
|
63
|
+
.filter((match) => match !== null)
|
|
64
|
+
.map((match) => ({ name: match[0], version: Number(match[1]) }))
|
|
65
|
+
.sort((left, right) => right.version - left.version)
|
|
66
|
+
if (candidates.length === 0) continue
|
|
67
|
+
const path = join(directory, candidates[0].name)
|
|
68
|
+
found.push({ id: session.name, path, version: candidates[0].version, mtime: statSync(path).mtimeMs, bytes: statSync(path).size })
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
return found.sort((left, right) => right.mtime - left.mtime).slice(0, maxSessions)
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/** Highest event seq in one compressed log, so the phone can tell cache from live. */
|
|
75
|
+
function lastSeq(path) {
|
|
76
|
+
const raw = execFileSync('zstd', ['-dc', path], { maxBuffer: 512 * 1024 * 1024, encoding: 'utf8' })
|
|
77
|
+
let last = 0
|
|
78
|
+
let events = 0
|
|
79
|
+
for (const line of raw.split('\n')) {
|
|
80
|
+
if (line === '') continue
|
|
81
|
+
try {
|
|
82
|
+
const row = JSON.parse(line)
|
|
83
|
+
if (typeof row.seq === 'number') { events += 1; if (row.seq > last) last = row.seq }
|
|
84
|
+
} catch { /* a row that does not parse carries no seq to track */ }
|
|
85
|
+
}
|
|
86
|
+
return { last, events }
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/** PUT one file, paced so the push never saturates the link a user is waiting on. */
|
|
90
|
+
function push(session, meta, hashes) {
|
|
91
|
+
return new Promise((resolvePromise, rejectPromise) => {
|
|
92
|
+
const url = new URL(endpoint.replace(/\/$/, '') + '/' + encodeURIComponent(session.id))
|
|
93
|
+
url.searchParams.set('lastSeq', String(meta.last))
|
|
94
|
+
url.searchParams.set('events', String(meta.events))
|
|
95
|
+
for (const hash of hashes) url.searchParams.append('tokenHash', hash)
|
|
96
|
+
const body = readFileSync(session.path)
|
|
97
|
+
const call = request(url, {
|
|
98
|
+
method: 'PUT',
|
|
99
|
+
headers: {
|
|
100
|
+
'content-type': 'application/octet-stream',
|
|
101
|
+
'content-length': body.length,
|
|
102
|
+
'x-dsh-cache-secret': secret,
|
|
103
|
+
},
|
|
104
|
+
}, (response) => {
|
|
105
|
+
const chunks = []
|
|
106
|
+
response.on('data', (chunk) => chunks.push(chunk))
|
|
107
|
+
response.on('end', () => {
|
|
108
|
+
if (response.statusCode === 200) resolvePromise(Buffer.concat(chunks).toString())
|
|
109
|
+
else rejectPromise(new Error('push failed: HTTP ' + String(response.statusCode) + ' ' + Buffer.concat(chunks).toString().slice(0, 200)))
|
|
110
|
+
})
|
|
111
|
+
})
|
|
112
|
+
call.on('error', rejectPromise)
|
|
113
|
+
call.end(body)
|
|
114
|
+
})
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/**
|
|
118
|
+
* Credential hashes of the devices that may read, so the cache accepts exactly the paired set.
|
|
119
|
+
*
|
|
120
|
+
* The gateway stores a device's token as a hash and never keeps the plaintext, so this reads the
|
|
121
|
+
* hashes it does keep. A revoked device drops out on the next pass, because the set is rebuilt from
|
|
122
|
+
* the file rather than appended to.
|
|
123
|
+
*/
|
|
124
|
+
function pairedTokenHashes() {
|
|
125
|
+
const file = join(home, 'mobile-gateway-devices.json')
|
|
126
|
+
try {
|
|
127
|
+
const parsed = JSON.parse(readFileSync(file, 'utf8'))
|
|
128
|
+
const devices = Array.isArray(parsed?.devices) ? parsed.devices : []
|
|
129
|
+
return devices
|
|
130
|
+
.filter((device) => device?.revokedAt === null || device?.revokedAt === undefined)
|
|
131
|
+
.map((device) => device?.tokenHash)
|
|
132
|
+
.filter((hash) => typeof hash === 'string' && hash !== '')
|
|
133
|
+
} catch { return [] }
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
async function pass() {
|
|
137
|
+
const sessions = findSessions()
|
|
138
|
+
if (sessions.length === 0) { console.log('push-session-cache: no Sessions to cache'); return }
|
|
139
|
+
const hashes = pairedTokenHashes()
|
|
140
|
+
console.log('push-session-cache: ' + String(sessions.length) + ' Session(s), ' + String(hashes.length) + ' paired device(s)')
|
|
141
|
+
for (const session of sessions) {
|
|
142
|
+
try {
|
|
143
|
+
const meta = lastSeq(session.path)
|
|
144
|
+
const started = Date.now()
|
|
145
|
+
await push(session, meta, hashes)
|
|
146
|
+
const seconds = Math.max(0.001, (Date.now() - started) / 1000)
|
|
147
|
+
console.log(' cached ' + session.id.slice(0, 24) + ' ' + (session.bytes / 1048576).toFixed(1) + ' MB lastSeq=' + String(meta.last) + ' in ' + seconds.toFixed(1) + 's (' + String(Math.round(session.bytes / seconds)) + ' B/s)')
|
|
148
|
+
} catch (error) {
|
|
149
|
+
console.log(' FAILED ' + session.id.slice(0, 24) + ': ' + (error instanceof Error ? error.message : String(error)))
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
await pass()
|
|
155
|
+
if (!once) {
|
|
156
|
+
console.log('push-session-cache: every ' + String(intervalSeconds) + 's')
|
|
157
|
+
setInterval(() => { void pass() }, intervalSeconds * 1000)
|
|
158
|
+
}
|
|
@@ -0,0 +1,337 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* Session-cache gateway proxy.
|
|
4
|
+
*
|
|
5
|
+
* A phone on the public path receives every byte through the desktop's own upstream, measured at
|
|
6
|
+
* 929 KB/s, while this host pushes downstream at 8.3 MB/s. Serving a Session's history from the
|
|
7
|
+
* cache here is therefore 191x faster and does not touch the desktop's link at all. Live traffic
|
|
8
|
+
* still needs the desktop, so this proxy splits the two: history answers from local storage, and
|
|
9
|
+
* everything else is forwarded to the desktop through the existing tunnel.
|
|
10
|
+
*
|
|
11
|
+
* It speaks the same wire protocol as the gateway, so a phone reaches it by connecting here
|
|
12
|
+
* instead — the protocol has no "fetch history elsewhere" field, and the App is a shipped binary
|
|
13
|
+
* this side cannot change. The cache therefore has to look like the gateway itself.
|
|
14
|
+
*
|
|
15
|
+
* Zero dependencies: the VPS carries Node without npm, so the WebSocket handshake and frame codec
|
|
16
|
+
* are implemented directly. Only text frames appear in this protocol, which keeps that tractable.
|
|
17
|
+
*
|
|
18
|
+
* Usage:
|
|
19
|
+
* session-cache-proxy.mjs [--port 7091] [--root /var/lib/dsh-mobile-cache]
|
|
20
|
+
* [--upstream http://127.0.0.1:3080] [--ws-path /ws/mobile]
|
|
21
|
+
*/
|
|
22
|
+
import { createHash } from 'node:crypto'
|
|
23
|
+
import { execFileSync } from 'node:child_process'
|
|
24
|
+
import { existsSync, readFileSync, statSync } from 'node:fs'
|
|
25
|
+
import { createServer, request as httpRequest } from 'node:http'
|
|
26
|
+
import { join, resolve } from 'node:path'
|
|
27
|
+
|
|
28
|
+
const argv = process.argv.slice(2)
|
|
29
|
+
let port = Number(process.env.DSH_PROXY_PORT ?? 7091)
|
|
30
|
+
let root = resolve(process.env.DSH_CACHE_ROOT ?? '/var/lib/dsh-mobile-cache')
|
|
31
|
+
let upstream = process.env.DSH_UPSTREAM ?? 'http://127.0.0.1:3080'
|
|
32
|
+
// The live half travels the tunnel. On the entry host the tunnel's vhost answers by Host header,
|
|
33
|
+
// so the upstream address alone is not enough: it must also carry the public name.
|
|
34
|
+
let upstreamHost = process.env.DSH_UPSTREAM_HOST ?? ''
|
|
35
|
+
let wsPath = process.env.DSH_WS_PATH ?? '/ws/mobile'
|
|
36
|
+
let tokensFile = process.env.DSH_CACHE_TOKENS ?? ''
|
|
37
|
+
for (let index = 0; index < argv.length; index += 1) {
|
|
38
|
+
const arg = argv[index]
|
|
39
|
+
if (arg === '--port') { port = Number(argv[++index]); continue }
|
|
40
|
+
if (arg === '--root') { root = resolve(argv[++index]); continue }
|
|
41
|
+
if (arg === '--upstream') { upstream = argv[++index]; continue }
|
|
42
|
+
if (arg === '--upstream-host') { upstreamHost = argv[++index]; continue }
|
|
43
|
+
if (arg === '--ws-path') { wsPath = argv[++index]; continue }
|
|
44
|
+
if (arg === '--tokens') { tokensFile = resolve(argv[++index]); continue }
|
|
45
|
+
if (arg === '--help' || arg === '-h') {
|
|
46
|
+
console.log('usage: session-cache-proxy.mjs [--port 7091] [--root <dir>] [--upstream <url>] [--ws-path /ws/mobile] [--tokens <file>]')
|
|
47
|
+
process.exit(0)
|
|
48
|
+
}
|
|
49
|
+
throw new Error('unknown option: ' + arg)
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/** Accepted device credential hashes; a read requires one of them. */
|
|
53
|
+
function tokenHashes() {
|
|
54
|
+
if (tokensFile === '') return new Set()
|
|
55
|
+
try {
|
|
56
|
+
const parsed = JSON.parse(readFileSync(tokensFile, 'utf8'))
|
|
57
|
+
const list = Array.isArray(parsed) ? parsed : parsed?.hashes
|
|
58
|
+
return new Set((Array.isArray(list) ? list : []).filter((hash) => typeof hash === 'string'))
|
|
59
|
+
} catch { return new Set() }
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
const frame = {
|
|
63
|
+
/** Encode one server frame. Payloads are text by construction. */
|
|
64
|
+
encode(value) {
|
|
65
|
+
const payload = Buffer.from(JSON.stringify(value), 'utf8')
|
|
66
|
+
if (payload.length < 126) {
|
|
67
|
+
return Buffer.concat([Buffer.from([0x81, payload.length]), payload])
|
|
68
|
+
}
|
|
69
|
+
if (payload.length < 65536) {
|
|
70
|
+
const header = Buffer.alloc(4)
|
|
71
|
+
header[0] = 0x81
|
|
72
|
+
header[1] = 126
|
|
73
|
+
header.writeUInt16BE(payload.length, 2)
|
|
74
|
+
return Buffer.concat([header, payload])
|
|
75
|
+
}
|
|
76
|
+
const header = Buffer.alloc(10)
|
|
77
|
+
header[0] = 0x81
|
|
78
|
+
header[1] = 127
|
|
79
|
+
header.writeBigUInt64BE(BigInt(payload.length), 2)
|
|
80
|
+
return Buffer.concat([header, payload])
|
|
81
|
+
},
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* Incremental text-frame decoder.
|
|
86
|
+
*
|
|
87
|
+
* Frames arrive fragmented across reads and batched within one, so the parser keeps a carry buffer
|
|
88
|
+
* and returns every complete frame it can. Control frames are answered rather than surfaced: the
|
|
89
|
+
* protocol uses none of them, but a client may still ping.
|
|
90
|
+
*/
|
|
91
|
+
class FrameReader {
|
|
92
|
+
constructor(onFrame, onControl) {
|
|
93
|
+
this.carry = Buffer.alloc(0)
|
|
94
|
+
this.onFrame = onFrame
|
|
95
|
+
this.onControl = onControl
|
|
96
|
+
}
|
|
97
|
+
push(chunk) {
|
|
98
|
+
this.carry = Buffer.concat([this.carry, chunk])
|
|
99
|
+
for (;;) {
|
|
100
|
+
if (this.carry.length < 2) return
|
|
101
|
+
const first = this.carry[0]
|
|
102
|
+
const second = this.carry[1]
|
|
103
|
+
const opcode = first & 0x0f
|
|
104
|
+
const masked = (second & 0x80) !== 0
|
|
105
|
+
let length = second & 0x7f
|
|
106
|
+
let offset = 2
|
|
107
|
+
if (length === 126) {
|
|
108
|
+
if (this.carry.length < 4) return
|
|
109
|
+
length = this.carry.readUInt16BE(2)
|
|
110
|
+
offset = 4
|
|
111
|
+
} else if (length === 127) {
|
|
112
|
+
if (this.carry.length < 10) return
|
|
113
|
+
length = Number(this.carry.readBigUInt64BE(2))
|
|
114
|
+
offset = 10
|
|
115
|
+
}
|
|
116
|
+
const maskLength = masked ? 4 : 0
|
|
117
|
+
if (this.carry.length < offset + maskLength + length) return
|
|
118
|
+
let payload = this.carry.subarray(offset + maskLength, offset + maskLength + length)
|
|
119
|
+
if (masked) {
|
|
120
|
+
const mask = this.carry.subarray(offset, offset + 4)
|
|
121
|
+
payload = Buffer.from(payload)
|
|
122
|
+
for (let index = 0; index < payload.length; index += 1) payload[index] ^= mask[index % 4]
|
|
123
|
+
}
|
|
124
|
+
this.carry = this.carry.subarray(offset + maskLength + length)
|
|
125
|
+
if (opcode === 0x8) { this.onControl('close', payload); return }
|
|
126
|
+
if (opcode === 0x9) { this.onControl('ping', payload); continue }
|
|
127
|
+
if (opcode === 0xa) { this.onControl('pong', payload); continue }
|
|
128
|
+
if (opcode === 0x1) this.onFrame(payload.toString('utf8'))
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/** Read one Session's cached events, decompressing the stored snapshot. */
|
|
134
|
+
function cachedEvents(sessionId) {
|
|
135
|
+
const path = join(root, sessionId + '.bin')
|
|
136
|
+
if (!existsSync(path)) return undefined
|
|
137
|
+
const meta = (() => { try { return JSON.parse(readFileSync(join(root, sessionId + '.meta.json'), 'utf8')) } catch { return undefined } })()
|
|
138
|
+
const raw = execFileSync('zstd', ['-dc', path], { maxBuffer: 512 * 1024 * 1024, encoding: 'utf8' })
|
|
139
|
+
const events = []
|
|
140
|
+
for (const line of raw.split('\n')) {
|
|
141
|
+
if (line === '') continue
|
|
142
|
+
try { events.push(JSON.parse(line)) } catch { /* a row that does not parse carries no event */ }
|
|
143
|
+
}
|
|
144
|
+
return { events: events.filter((event) => typeof event.seq === 'number'), meta }
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
// --- history shaping, mirroring the gateway so a phone cannot tell the difference ---
|
|
148
|
+
//
|
|
149
|
+
// The gateway caps a page at 256 KB on its opening read because every byte travels the desktop's
|
|
150
|
+
// upstream. Here the bytes are already local and the wire is the VPS's own 8.3 MB/s downstream, so
|
|
151
|
+
// the cap only costs round trips: a 10866-event Session would take 118 pages, and at 173 ms RTT
|
|
152
|
+
// that is 20 seconds of pure waiting before any data moves. The protocol's own per-frame ceiling is
|
|
153
|
+
// 4 MiB, which is what this uses.
|
|
154
|
+
const HISTORY_DEFAULT_MAX_BYTES = 4 * 1024 * 1024
|
|
155
|
+
const HISTORY_TOOL_RESULT_MAX_CHARS = 2000
|
|
156
|
+
|
|
157
|
+
function eventBytes(event) { return Buffer.byteLength(JSON.stringify(event), 'utf8') }
|
|
158
|
+
|
|
159
|
+
function trimConversationEvent(event) {
|
|
160
|
+
switch (event.type) {
|
|
161
|
+
case 'assistant/chunk':
|
|
162
|
+
case 'request/header':
|
|
163
|
+
case 'request/context':
|
|
164
|
+
case 'system/message':
|
|
165
|
+
return null
|
|
166
|
+
case 'assistant/message':
|
|
167
|
+
case 'assistant/attempt': {
|
|
168
|
+
const { stream, ...data } = event.data || {}
|
|
169
|
+
return { ...event, data }
|
|
170
|
+
}
|
|
171
|
+
case 'tool/result': {
|
|
172
|
+
const data = event.data || {}
|
|
173
|
+
const message = data.message
|
|
174
|
+
if (!message || !Array.isArray(message.content)) return event
|
|
175
|
+
let changed = false
|
|
176
|
+
const truncate = (block) => {
|
|
177
|
+
if (!block || typeof block !== 'object') return block
|
|
178
|
+
if (block.type === 'text' && typeof block.text === 'string' && block.text.length > HISTORY_TOOL_RESULT_MAX_CHARS) {
|
|
179
|
+
changed = true
|
|
180
|
+
return { ...block, text: block.text.slice(0, HISTORY_TOOL_RESULT_MAX_CHARS) + '…' }
|
|
181
|
+
}
|
|
182
|
+
if (Array.isArray(block.content)) return { ...block, content: block.content.map(truncate) }
|
|
183
|
+
return block
|
|
184
|
+
}
|
|
185
|
+
const content = message.content.map(truncate)
|
|
186
|
+
return changed ? { ...event, data: { ...data, message: { ...message, content } } } : event
|
|
187
|
+
}
|
|
188
|
+
default:
|
|
189
|
+
return event
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
/** Keep the newest suffix within a byte budget; always keep the newest event. */
|
|
194
|
+
function capHistoryEvents(events, maxBytes, trim, beforeSeq) {
|
|
195
|
+
const bounded = beforeSeq === undefined ? events : events.filter((event) => event.seq < beforeSeq)
|
|
196
|
+
const processed = trim ? bounded.map(trimConversationEvent).filter(Boolean) : bounded
|
|
197
|
+
if (processed.length === 0) return { events: [], bytes: 0, hasMore: false }
|
|
198
|
+
let total = 0
|
|
199
|
+
let start = processed.length
|
|
200
|
+
for (let index = processed.length - 1; index >= 0; index -= 1) {
|
|
201
|
+
const size = eventBytes(processed[index])
|
|
202
|
+
if (start !== processed.length && total + size > maxBytes) break
|
|
203
|
+
total += size
|
|
204
|
+
start = index
|
|
205
|
+
}
|
|
206
|
+
return { events: processed.slice(start), bytes: total, hasMore: start > 0 }
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
/** Build the wire frame a phone expects, from cached events. */
|
|
210
|
+
function historyFrame(message, cached) {
|
|
211
|
+
// A larger page than the gateway would send: the bytes are local, so the only cost is the frame
|
|
212
|
+
// the protocol already permits.
|
|
213
|
+
const budget = Number.isSafeInteger(message.maxBytes) && message.maxBytes > 0
|
|
214
|
+
? Math.min(message.maxBytes, HISTORY_DEFAULT_MAX_BYTES)
|
|
215
|
+
: HISTORY_DEFAULT_MAX_BYTES
|
|
216
|
+
const capped = capHistoryEvents(cached.events, budget, message.view === 'conversation', message.beforeSeq)
|
|
217
|
+
const oldest = capped.events[0]?.seq ?? cached.events[0]?.seq
|
|
218
|
+
const lastSeq = cached.events.length === 0 ? 0 : cached.events[cached.events.length - 1].seq
|
|
219
|
+
return {
|
|
220
|
+
kind: 'history',
|
|
221
|
+
sessionId: message.sessionId,
|
|
222
|
+
events: capped.events,
|
|
223
|
+
bytes: capped.bytes,
|
|
224
|
+
hasMore: capped.hasMore,
|
|
225
|
+
...(message.view === 'conversation' ? { view: 'conversation' } : {}),
|
|
226
|
+
...(capped.hasMore && oldest !== undefined ? { nextBeforeSeq: oldest } : {}),
|
|
227
|
+
cursor: lastSeq,
|
|
228
|
+
historyFormatVersion: 4,
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
const server = createServer((_request, response) => {
|
|
233
|
+
response.writeHead(404, { 'content-type': 'application/json' })
|
|
234
|
+
response.end(JSON.stringify({ error: 'websocket-only' }))
|
|
235
|
+
})
|
|
236
|
+
|
|
237
|
+
server.on('upgrade', (request, socket, head) => {
|
|
238
|
+
const url = new URL(request.url ?? '/', 'http://proxy.invalid')
|
|
239
|
+
if (url.pathname !== wsPath) { socket.destroy(); return }
|
|
240
|
+
const key = request.headers['sec-websocket-key']
|
|
241
|
+
if (typeof key !== 'string') { socket.destroy(); return }
|
|
242
|
+
|
|
243
|
+
// The upstream decides whether this connection is allowed: it owns device identity, and a gateway
|
|
244
|
+
// that refused would otherwise be reported to the phone as a successful upgrade of a socket that
|
|
245
|
+
// has no peer. So the handshake is opened upstream first and its outcome relayed verbatim.
|
|
246
|
+
const headers = {}
|
|
247
|
+
for (const name of ['authorization', 'sec-websocket-protocol', 'x-dsh-device-id', 'x-dsh-device-token', 'cookie', 'origin', 'user-agent']) {
|
|
248
|
+
const value = request.headers[name]
|
|
249
|
+
if (value !== undefined) headers[name] = value
|
|
250
|
+
}
|
|
251
|
+
const upstreamUrl = new URL(wsPath, upstream)
|
|
252
|
+
const live = httpRequest({
|
|
253
|
+
hostname: upstreamUrl.hostname,
|
|
254
|
+
port: upstreamUrl.port,
|
|
255
|
+
path: upstreamUrl.pathname,
|
|
256
|
+
method: 'GET',
|
|
257
|
+
headers: {
|
|
258
|
+
...headers,
|
|
259
|
+
...(upstreamHost === '' ? {} : { Host: upstreamHost }),
|
|
260
|
+
Connection: 'Upgrade',
|
|
261
|
+
Upgrade: 'websocket',
|
|
262
|
+
'Sec-WebSocket-Key': key,
|
|
263
|
+
'Sec-WebSocket-Version': '13',
|
|
264
|
+
},
|
|
265
|
+
})
|
|
266
|
+
|
|
267
|
+
live.on('upgrade', (response2, liveSocket, liveHead) => {
|
|
268
|
+
// Relay the upstream's own handshake, including the subprotocol it negotiated: a phone that
|
|
269
|
+
// asked for dsh-mobile-v1 must see it echoed or it will not treat the socket as established.
|
|
270
|
+
const accept = response2.headers['sec-websocket-accept']
|
|
271
|
+
const protocol = response2.headers['sec-websocket-protocol']
|
|
272
|
+
socket.write(
|
|
273
|
+
'HTTP/1.1 101 Switching Protocols\r\n'
|
|
274
|
+
+ 'Upgrade: websocket\r\n'
|
|
275
|
+
+ 'Connection: Upgrade\r\n'
|
|
276
|
+
+ 'Sec-WebSocket-Accept: ' + String(accept) + '\r\n'
|
|
277
|
+
+ (protocol === undefined ? '' : 'Sec-WebSocket-Protocol: ' + String(protocol) + '\r\n')
|
|
278
|
+
+ '\r\n',
|
|
279
|
+
)
|
|
280
|
+
if (head.length > 0) liveSocket.write(head)
|
|
281
|
+
if (liveHead.length > 0) socket.write(liveHead)
|
|
282
|
+
|
|
283
|
+
const liveReader = new FrameReader((text) => {
|
|
284
|
+
if (!socket.destroyed) socket.write(frame.encode(JSON.parse(text)))
|
|
285
|
+
}, () => {})
|
|
286
|
+
liveSocket.on('data', (chunk) => liveReader.push(chunk))
|
|
287
|
+
liveSocket.on('close', () => { if (!socket.destroyed) socket.destroy() })
|
|
288
|
+
liveSocket.on('error', () => { if (!socket.destroyed) socket.destroy() })
|
|
289
|
+
|
|
290
|
+
// The client's frames are examined: a history request for a cached Session is answered from
|
|
291
|
+
// local storage, and every other frame is forwarded unchanged.
|
|
292
|
+
const clientReader = new FrameReader((text) => {
|
|
293
|
+
let message
|
|
294
|
+
try { message = JSON.parse(text) } catch { liveSocket.write(text); return }
|
|
295
|
+
if (message?.type === 'history' && typeof message.sessionId === 'string') {
|
|
296
|
+
let cached
|
|
297
|
+
try { cached = cachedEvents(message.sessionId) } catch { cached = undefined }
|
|
298
|
+
if (cached !== undefined && cached.events.length > 0) {
|
|
299
|
+
socket.write(frame.encode(historyFrame(message, cached)))
|
|
300
|
+
return
|
|
301
|
+
}
|
|
302
|
+
}
|
|
303
|
+
liveSocket.write(text)
|
|
304
|
+
}, (kind) => { if (kind === 'close') liveSocket.destroy() })
|
|
305
|
+
socket.on('data', (chunk) => clientReader.push(chunk))
|
|
306
|
+
})
|
|
307
|
+
|
|
308
|
+
// A refusal is the upstream's answer, not the proxy's: pass its status and body through so the
|
|
309
|
+
// phone can act on it (401 means re-pair, and only the gateway may say that).
|
|
310
|
+
live.on('response', (response2) => {
|
|
311
|
+
const chunks = []
|
|
312
|
+
response2.on('data', (chunk) => chunks.push(chunk))
|
|
313
|
+
response2.on('end', () => {
|
|
314
|
+
if (socket.destroyed) return
|
|
315
|
+
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)
|
|
318
|
+
})
|
|
319
|
+
})
|
|
320
|
+
live.on('error', () => {
|
|
321
|
+
if (socket.destroyed) return
|
|
322
|
+
socket.write('HTTP/1.1 502 Bad Gateway\r\nConnection: close\r\n\r\n')
|
|
323
|
+
socket.end()
|
|
324
|
+
})
|
|
325
|
+
live.setTimeout(20000, () => {
|
|
326
|
+
live.destroy()
|
|
327
|
+
if (!socket.destroyed) {
|
|
328
|
+
socket.write('HTTP/1.1 504 Gateway Timeout\r\nConnection: close\r\n\r\n')
|
|
329
|
+
socket.end()
|
|
330
|
+
}
|
|
331
|
+
})
|
|
332
|
+
live.end()
|
|
333
|
+
})
|
|
334
|
+
|
|
335
|
+
server.listen(port, '127.0.0.1', () => {
|
|
336
|
+
console.log('session-cache-proxy: listening on 127.0.0.1:' + String(port) + ' root=' + root + ' upstream=' + upstream + (upstreamHost === '' ? '' : ' host=' + upstreamHost))
|
|
337
|
+
})
|
|
@@ -0,0 +1,191 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* Session cache for the mobile gateway.
|
|
4
|
+
*
|
|
5
|
+
* A phone on the public path receives every byte through the desktop's own upstream, measured at
|
|
6
|
+
* 0.9 MB/s on this deployment, while the entry VPS can push 8.3 MB/s downstream. Serving a Session
|
|
7
|
+
* from here therefore turns a minute of waiting into a second, and the desktop fills the cache in
|
|
8
|
+
* the background where its slow upstream costs the user nothing.
|
|
9
|
+
*
|
|
10
|
+
* The service is deliberately small and read-mostly: it stores one compressed snapshot per Session
|
|
11
|
+
* plus a cursor, and the phone asks for the difference afterwards through the gateway's own
|
|
12
|
+
* incremental history. It owns no Session semantics — it never parses event payloads, so a harness
|
|
13
|
+
* format change cannot corrupt it.
|
|
14
|
+
*
|
|
15
|
+
* Authentication reuses the gateway's device tokens: the desktop proves write access with a shared
|
|
16
|
+
* secret, and a phone proves read access with the same device id and token it paired with. A
|
|
17
|
+
* request without either is refused before any file is touched.
|
|
18
|
+
*
|
|
19
|
+
* Usage: session-cache.mjs [--port 7090] [--root /var/lib/dsh-mobile-cache] [--secret <value>]
|
|
20
|
+
*/
|
|
21
|
+
import { createServer } from 'node:http'
|
|
22
|
+
import { createHash, timingSafeEqual } from 'node:crypto'
|
|
23
|
+
import { existsSync, mkdirSync, readFileSync, readdirSync, renameSync, rmSync, statSync, writeFileSync } from 'node:fs'
|
|
24
|
+
import { join, resolve } from 'node:path'
|
|
25
|
+
|
|
26
|
+
const argv = process.argv.slice(2)
|
|
27
|
+
let port = Number(process.env.DSH_CACHE_PORT ?? 7090)
|
|
28
|
+
let root = resolve(process.env.DSH_CACHE_ROOT ?? '/var/lib/dsh-mobile-cache')
|
|
29
|
+
let secret = process.env.DSH_CACHE_SECRET ?? ''
|
|
30
|
+
for (let index = 0; index < argv.length; index += 1) {
|
|
31
|
+
const arg = argv[index]
|
|
32
|
+
if (arg === '--port') { port = Number(argv[++index]); continue }
|
|
33
|
+
if (arg === '--root') { root = resolve(argv[++index]); continue }
|
|
34
|
+
if (arg === '--secret') { secret = argv[++index]; continue }
|
|
35
|
+
if (arg === '--help' || arg === '-h') {
|
|
36
|
+
console.log('usage: session-cache.mjs [--port 7090] [--root <dir>] [--secret <value>]')
|
|
37
|
+
process.exit(0)
|
|
38
|
+
}
|
|
39
|
+
throw new Error('unknown option: ' + arg)
|
|
40
|
+
}
|
|
41
|
+
if (secret === '') throw new Error('session-cache: --secret or DSH_CACHE_SECRET is required')
|
|
42
|
+
|
|
43
|
+
mkdirSync(root, { recursive: true })
|
|
44
|
+
|
|
45
|
+
/** Compare two secrets without leaking their length or content through timing. */
|
|
46
|
+
function secretMatches(provided) {
|
|
47
|
+
if (typeof provided !== 'string' || provided === '') return false
|
|
48
|
+
const a = createHash('sha256').update(provided).digest()
|
|
49
|
+
const b = createHash('sha256').update(secret).digest()
|
|
50
|
+
return timingSafeEqual(a, b)
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* Whether a read request carries a credential a paired device holds.
|
|
55
|
+
*
|
|
56
|
+
* The cache holds what the gateway already serves to a paired phone, so a read needs the same proof
|
|
57
|
+
* of pairing. The desktop never sees a device's plaintext token — the gateway stores only its hash —
|
|
58
|
+
* so the accepted set is hashes, and a presented token is hashed the same way to compare. A service
|
|
59
|
+
* with no declared hashes refuses every read rather than serving one anonymously.
|
|
60
|
+
*/
|
|
61
|
+
let pairedTokenHashes = new Set()
|
|
62
|
+
function deviceTokenMatches(provided) {
|
|
63
|
+
if (typeof provided !== 'string' || provided === '') return false
|
|
64
|
+
return pairedTokenHashes.has(createHash('sha256').update(provided).digest('hex'))
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/** Replace the accepted read credentials with the hashes the desktop declares. */
|
|
68
|
+
function setPairedTokenHashes(hashes) {
|
|
69
|
+
pairedTokenHashes = new Set(hashes.filter((hash) => typeof hash === 'string' && /^[0-9a-f]{64}$/.test(hash)))
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/** Session ids are opaque; refuse anything that could escape the cache root. */
|
|
73
|
+
function cachePath(sessionId) {
|
|
74
|
+
if (typeof sessionId !== 'string' || !/^[A-Za-z0-9._-]+$/.test(sessionId) || sessionId.length > 128) return undefined
|
|
75
|
+
return join(root, sessionId + '.bin')
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
function metaPath(sessionId) {
|
|
79
|
+
return join(root, sessionId + '.meta.json')
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
function readMeta(sessionId) {
|
|
83
|
+
try { return JSON.parse(readFileSync(metaPath(sessionId), 'utf8')) } catch { return undefined }
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
function writeMeta(sessionId, meta) {
|
|
87
|
+
const temporary = metaPath(sessionId) + '.tmp'
|
|
88
|
+
writeFileSync(temporary, JSON.stringify(meta))
|
|
89
|
+
renameSync(temporary, metaPath(sessionId))
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
function readBody(request, limit) {
|
|
93
|
+
return new Promise((resolvePromise, rejectPromise) => {
|
|
94
|
+
const chunks = []
|
|
95
|
+
let size = 0
|
|
96
|
+
request.on('data', (chunk) => {
|
|
97
|
+
size += chunk.length
|
|
98
|
+
if (size > limit) { rejectPromise(new Error('too large')); request.destroy(); return }
|
|
99
|
+
chunks.push(chunk)
|
|
100
|
+
})
|
|
101
|
+
request.on('end', () => resolvePromise(Buffer.concat(chunks)))
|
|
102
|
+
request.on('error', rejectPromise)
|
|
103
|
+
})
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
function send(response, status, value, headers = {}) {
|
|
107
|
+
const body = Buffer.from(JSON.stringify(value))
|
|
108
|
+
response.writeHead(status, { 'content-type': 'application/json', 'content-length': body.length, ...headers })
|
|
109
|
+
response.end(body)
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
const server = createServer(async (request, response) => {
|
|
113
|
+
const url = new URL(request.url ?? '/', 'http://cache.invalid')
|
|
114
|
+
const segments = url.pathname.split('/').filter((part) => part !== '')
|
|
115
|
+
try {
|
|
116
|
+
if (segments[0] !== 'cache') { send(response, 404, { error: 'not-found' }); return }
|
|
117
|
+
|
|
118
|
+
// Two distinct credentials, so a leaked phone token cannot rewrite the cache: writing takes
|
|
119
|
+
// the shared secret (the desktop side), reading takes the device credential the phone already
|
|
120
|
+
// paired with. The index is desktop-side bookkeeping and takes the secret as well — a phone
|
|
121
|
+
// asks for the one Session it is opening, never for a list of everything cached.
|
|
122
|
+
const desktopSide = request.method === 'PUT' || request.method === 'DELETE' || segments.length === 1
|
|
123
|
+
const deviceToken = request.headers['x-dsh-device-token']
|
|
124
|
+
const authorized = desktopSide
|
|
125
|
+
? secretMatches(request.headers['x-dsh-cache-secret'])
|
|
126
|
+
: deviceTokenMatches(deviceToken)
|
|
127
|
+
if (!authorized) { send(response, 401, { error: 'unauthorized' }); return }
|
|
128
|
+
|
|
129
|
+
if (segments.length === 1) {
|
|
130
|
+
if (request.method !== 'GET') { send(response, 405, { error: 'method' }); return }
|
|
131
|
+
const entries = []
|
|
132
|
+
for (const name of readdirSync(root)) {
|
|
133
|
+
if (!name.endsWith('.meta.json')) continue
|
|
134
|
+
const sessionId = name.slice(0, -'.meta.json'.length)
|
|
135
|
+
const meta = readMeta(sessionId)
|
|
136
|
+
if (meta !== undefined) entries.push({ sessionId, ...meta })
|
|
137
|
+
}
|
|
138
|
+
send(response, 200, { entries })
|
|
139
|
+
return
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
const sessionId = segments[1]
|
|
143
|
+
const path = cachePath(sessionId)
|
|
144
|
+
if (path === undefined) { send(response, 400, { error: 'bad-session-id' }); return }
|
|
145
|
+
|
|
146
|
+
if (request.method === 'PUT') {
|
|
147
|
+
const body = await readBody(request, 64 * 1024 * 1024)
|
|
148
|
+
const temporary = path + '.tmp'
|
|
149
|
+
writeFileSync(temporary, body)
|
|
150
|
+
renameSync(temporary, path)
|
|
151
|
+
const lastSeq = Number(url.searchParams.get('lastSeq') ?? '0')
|
|
152
|
+
const eventCount = Number(url.searchParams.get('events') ?? '0')
|
|
153
|
+
// The desktop states which device credential hashes may read what it cached, so revocation
|
|
154
|
+
// is a property of the next write rather than a second channel.
|
|
155
|
+
const hashes = url.searchParams.getAll('tokenHash')
|
|
156
|
+
if (hashes.length > 0) setPairedTokenHashes(hashes)
|
|
157
|
+
writeMeta(sessionId, { bytes: body.length, lastSeq, eventCount, updatedAt: Date.now() })
|
|
158
|
+
send(response, 200, { stored: body.length, sessionId })
|
|
159
|
+
return
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
if (request.method === 'DELETE') {
|
|
163
|
+
rmSync(path, { force: true })
|
|
164
|
+
rmSync(metaPath(sessionId), { force: true })
|
|
165
|
+
send(response, 200, { removed: sessionId })
|
|
166
|
+
return
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
if (request.method === 'GET') {
|
|
170
|
+
if (!existsSync(path)) { send(response, 404, { error: 'not-cached' }); return }
|
|
171
|
+
const meta = readMeta(sessionId)
|
|
172
|
+
const stat = statSync(path)
|
|
173
|
+
response.writeHead(200, {
|
|
174
|
+
'content-type': 'application/octet-stream',
|
|
175
|
+
'content-length': stat.size,
|
|
176
|
+
'x-dsh-cache-last-seq': String(meta?.lastSeq ?? 0),
|
|
177
|
+
'x-dsh-cache-events': String(meta?.eventCount ?? 0),
|
|
178
|
+
})
|
|
179
|
+
response.end(readFileSync(path))
|
|
180
|
+
return
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
send(response, 405, { error: 'method' })
|
|
184
|
+
} catch (error) {
|
|
185
|
+
send(response, 500, { error: 'internal', message: error instanceof Error ? error.message : String(error) })
|
|
186
|
+
}
|
|
187
|
+
})
|
|
188
|
+
|
|
189
|
+
server.listen(port, '127.0.0.1', () => {
|
|
190
|
+
console.log('session-cache: listening on 127.0.0.1:' + String(port) + ' root=' + root)
|
|
191
|
+
})
|
package/lib/index.mjs
CHANGED
|
@@ -2150,16 +2150,21 @@ const plugin = {
|
|
|
2150
2150
|
}
|
|
2151
2151
|
}
|
|
2152
2152
|
|
|
2153
|
-
|
|
2154
|
-
|
|
2155
|
-
|
|
2156
|
-
|
|
2157
|
-
|
|
2158
|
-
|
|
2159
|
-
|
|
2160
|
-
|
|
2161
|
-
|
|
2162
|
-
|
|
2153
|
+
// Order matters: the client tries these in turn, so the LAN entry leads. A phone on the same
|
|
2154
|
+
// network reaches it directly at 20 MB/s instead of routing every byte through the public
|
|
2155
|
+
// entry, whose upstream is the desktop's own — measured at 0.9 MB/s on this deployment, so a
|
|
2156
|
+
// public-first list made a same-room phone wait a minute for a Session the LAN could serve in
|
|
2157
|
+
// seconds. The public address stays advertised for when the phone leaves the network.
|
|
2158
|
+
const advertisedEndpoints = (primary, extra = []) => {
|
|
2159
|
+
const publicUrl = configuredPublicUrl()
|
|
2160
|
+
return normalizeEndpoints([...new Set([
|
|
2161
|
+
...(lanListening ? lanWebSocketUrls(options, wsPath, lanBoundPort) : []),
|
|
2162
|
+
...extra,
|
|
2163
|
+
...(primary ? [primary] : []),
|
|
2164
|
+
...configuredEndpoints,
|
|
2165
|
+
...(publicUrl ? [publicUrl] : []),
|
|
2166
|
+
])])
|
|
2167
|
+
}
|
|
2163
2168
|
|
|
2164
2169
|
const setGatewayMode = (mode, reason, persist = true) => {
|
|
2165
2170
|
if (!GATEWAY_MODES.includes(mode)) throw badRequest('mode must be disabled, temporary, or persistent')
|
|
@@ -2695,6 +2700,38 @@ const plugin = {
|
|
|
2695
2700
|
const requestedChannel = req.headers['x-dsh-channel']
|
|
2696
2701
|
ws.mobileChannel = ['control', 'conversation'].includes(requestedChannel) ? requestedChannel : 'legacy'
|
|
2697
2702
|
ws.deviceId = device && device.id
|
|
2703
|
+
// One malformed frame must not take the process down.
|
|
2704
|
+
//
|
|
2705
|
+
// `ws` parses frames inside the underlying socket's 'data' listener, and a protocol
|
|
2706
|
+
// violation (an unmasked frame, a reserved bit set) throws synchronously from there. A
|
|
2707
|
+
// listener that throws becomes an uncaughtException, so a single bad frame from any
|
|
2708
|
+
// client — checked for credentials or not — terminated the whole harness. Measured: six
|
|
2709
|
+
// such crashes in one day, each taking every other Session down with it.
|
|
2710
|
+
//
|
|
2711
|
+
// The parse runs one layer below the WebSocket object, so guarding the 'data' listener
|
|
2712
|
+
// is what actually contains it. The connection is closed; the process stays up.
|
|
2713
|
+
const rawSocket = ws._socket
|
|
2714
|
+
if (rawSocket !== undefined && rawSocket !== null) {
|
|
2715
|
+
for (const listener of rawSocket.listeners('data')) {
|
|
2716
|
+
rawSocket.off('data', listener)
|
|
2717
|
+
rawSocket.on('data', (chunk) => {
|
|
2718
|
+
try {
|
|
2719
|
+
listener.call(rawSocket, chunk)
|
|
2720
|
+
} catch (error) {
|
|
2721
|
+
log('client sent a malformed frame: ' + (error && error.code ? error.code : 'unknown') + '; closing the connection')
|
|
2722
|
+
clients.delete(ws)
|
|
2723
|
+
try { rawSocket.destroy() } catch {}
|
|
2724
|
+
try { ws.terminate() } catch {}
|
|
2725
|
+
}
|
|
2726
|
+
})
|
|
2727
|
+
}
|
|
2728
|
+
}
|
|
2729
|
+
ws.on('error', (error) => {
|
|
2730
|
+
log('client socket error: ' + (error && error.code ? error.code : 'unknown'))
|
|
2731
|
+
clients.delete(ws)
|
|
2732
|
+
try { ws.terminate() } catch {}
|
|
2733
|
+
})
|
|
2734
|
+
ws.on('close', () => { clients.delete(ws) })
|
|
2698
2735
|
clients.add(ws)
|
|
2699
2736
|
if (device) registry.connected(device.id)
|
|
2700
2737
|
if (!connectedSinceEnabled) {
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@sparkelf/dsh-plugin-mobile-gateway",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"description": "Mobile gateway for DSH 0.1.7 (Session format 4)
|
|
3
|
+
"version": "0.9.0",
|
|
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": [
|
|
7
7
|
"bin",
|
|
@@ -26,7 +26,7 @@
|
|
|
26
26
|
},
|
|
27
27
|
"bin": "bin/setup-ip.mjs",
|
|
28
28
|
"scripts": {
|
|
29
|
-
"test": "node test/wire-json.test.mjs && node test/setup-ip.test.mjs && node test/host-adapter.test.mjs && node test/session-preset.test.mjs && node test/session-follower.test.mjs && node test/gateway.test.mjs && node test/auth.test.mjs && node test/lan.test.mjs && node test/multi-gateway.test.mjs"
|
|
29
|
+
"test": "node test/wire-json.test.mjs && node test/setup-ip.test.mjs && node test/host-adapter.test.mjs && node test/session-preset.test.mjs && node test/session-follower.test.mjs && node test/session-cache.test.mjs && node test/session-cache-proxy.test.mjs && node test/frame-containment.test.mjs && node test/gateway.test.mjs && node test/auth.test.mjs && node test/lan.test.mjs && node test/multi-gateway.test.mjs"
|
|
30
30
|
},
|
|
31
31
|
"exports": {
|
|
32
32
|
".": "./lib/index.mjs",
|