@xxgxiaoyu/dsh-session-sync 0.0.0-stage → 0.8.3

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/lib/lan.js ADDED
@@ -0,0 +1,213 @@
1
+ /**
2
+ * The LAN-facing HTTP sync endpoint.
3
+ *
4
+ * This is deliberately a separate listener from the GUI web server: the GUI
5
+ * binds one loopback literal (the app keeps it off the network), while syncing
6
+ * must be reachable from other machines. It therefore binds 0.0.0.0 on its own
7
+ * port and only answers requests that present the sync header (and the shared
8
+ * token when one is configured).
9
+ *
10
+ * GET /v1/health liveness probe
11
+ * GET /v1/info identity + session count
12
+ * GET /v1/sessions session index (metadata only)
13
+ * GET /v1/sessions/:id one session: header + replay-validated events
14
+ * POST /v1/sessions import a session from another device
15
+ */
16
+ import http from 'node:http'
17
+ import { SessionSyncError, sessionSignature } from './sessions.js'
18
+
19
+ const MAX_BODY_BYTES = 256 * 1024 * 1024
20
+ const SYNC_HEADER = 'x-dsh-session-sync'
21
+
22
+ function sendJson(res, status, value) {
23
+ if (res.headersSent) return
24
+ const body = JSON.stringify(value)
25
+ res.writeHead(status, {
26
+ 'content-type': 'application/json; charset=utf-8',
27
+ 'cache-control': 'no-store',
28
+ 'content-length': Buffer.byteLength(body),
29
+ })
30
+ res.end(body)
31
+ }
32
+
33
+ function readJsonBody(req) {
34
+ return new Promise((resolve, reject) => {
35
+ const chunks = []
36
+ let size = 0
37
+ req.on('data', (chunk) => {
38
+ size += chunk.length
39
+ if (size > MAX_BODY_BYTES) {
40
+ reject(new SessionSyncError('too-large', '会话数据超过 256MB 上限', 413))
41
+ req.destroy()
42
+ return
43
+ }
44
+ chunks.push(chunk)
45
+ })
46
+ req.on('end', () => {
47
+ if (size === 0) {
48
+ resolve({})
49
+ return
50
+ }
51
+ try {
52
+ resolve(JSON.parse(Buffer.concat(chunks).toString('utf8')))
53
+ } catch {
54
+ reject(new SessionSyncError('bad-json', '请求体不是合法 JSON', 400))
55
+ }
56
+ })
57
+ req.on('error', (error) => reject(error))
58
+ })
59
+ }
60
+
61
+ export class LanServer {
62
+ /**
63
+ * @param {object} options
64
+ * @param {number} options.port - preferred port; next free ports are tried.
65
+ * @param {string} [options.token] - optional shared secret.
66
+ * @param {import('./sessions.js').SessionBridge} options.bridge
67
+ * @param {() => object} options.info - identity fields for /v1/info.
68
+ * @param {(message: string) => void} [options.log]
69
+ */
70
+ constructor({ port, token, bridge, info, duplicateOf, log }) {
71
+ this.preferredPort = port
72
+ this.port = null
73
+ this.token = token ?? null
74
+ this.bridge = bridge
75
+ this.info = info
76
+ this.duplicateOf = duplicateOf
77
+ this.log = log ?? (() => {})
78
+ this.server = null
79
+ }
80
+
81
+ async start() {
82
+ if (this.server !== null) return this.port
83
+ const server = http.createServer((req, res) => {
84
+ this._handle(req, res).catch((error) => {
85
+ const status = error instanceof SessionSyncError ? error.status : 500
86
+ const code = error instanceof SessionSyncError ? error.code : 'internal'
87
+ if (!(error instanceof SessionSyncError)) {
88
+ this.log(`同步请求失败: ${error?.stack ?? error}`)
89
+ }
90
+ sendJson(res, status, { ok: false, error: { code, message: String(error?.message ?? error) } })
91
+ })
92
+ })
93
+ server.on('clientError', (_error, socket) => {
94
+ try {
95
+ socket.end('HTTP/1.1 400 Bad Request\r\n\r\n')
96
+ } catch {
97
+ /* ignore */
98
+ }
99
+ })
100
+ this.server = server
101
+ const base = Number.isInteger(this.preferredPort) && this.preferredPort >= 0 ? this.preferredPort : 47802
102
+ if (base === 0) {
103
+ // Explicit ephemeral port (tests, or a NAT'd host that forwards).
104
+ await listen(server, 0)
105
+ this.port = server.address().port
106
+ this.log(`局域网同步服务已监听 0.0.0.0:${this.port}`)
107
+ return this.port
108
+ }
109
+ for (let attempt = 0; attempt < 16; attempt += 1) {
110
+ const candidate = base + attempt
111
+ if (candidate > 65535) break
112
+ try {
113
+ await listen(server, candidate)
114
+ this.port = candidate
115
+ this.log(`局域网同步服务已监听 0.0.0.0:${candidate}`)
116
+ return candidate
117
+ } catch (error) {
118
+ if (error?.code !== 'EADDRINUSE') throw error
119
+ }
120
+ }
121
+ throw new Error(`端口 ${base} 起连续 16 个端口都被占用,无法启动局域网同步服务`)
122
+ }
123
+
124
+ async _handle(req, res) {
125
+ const url = new URL(req.url ?? '/', 'http://dsh-session-sync.internal')
126
+ if (req.method === 'OPTIONS') {
127
+ res.writeHead(204, {
128
+ 'access-control-allow-origin': '*',
129
+ 'access-control-allow-headers': `${SYNC_HEADER}, content-type, x-dsh-session-sync-token`,
130
+ 'access-control-allow-methods': 'GET, POST, OPTIONS',
131
+ })
132
+ res.end()
133
+ return
134
+ }
135
+ if (url.pathname === '/v1/health') {
136
+ sendJson(res, 200, { ok: true, service: 'dsh-session-sync', proto: 1 })
137
+ return
138
+ }
139
+ if (req.headers[SYNC_HEADER] === undefined) {
140
+ throw new SessionSyncError('unauthorized', '缺少同步请求头', 401)
141
+ }
142
+ if (this.token !== null && req.headers['x-dsh-session-sync-token'] !== this.token) {
143
+ throw new SessionSyncError('unauthorized', '同步口令不匹配', 401)
144
+ }
145
+ if (url.pathname === '/v1/info') {
146
+ sendJson(res, 200, { ok: true, ...this.info() })
147
+ return
148
+ }
149
+ if (url.pathname === '/v1/sessions' && req.method === 'GET') {
150
+ const limit = Number(url.searchParams.get('limit') ?? '200')
151
+ const sessions = await this.bridge.list({ limit: Number.isFinite(limit) ? Math.min(Math.max(limit, 1), 500) : 200 })
152
+ sendJson(res, 200, { ok: true, sessions })
153
+ return
154
+ }
155
+ if (url.pathname.startsWith('/v1/sessions/') && req.method === 'GET') {
156
+ const id = decodeURIComponent(url.pathname.slice('/v1/sessions/'.length))
157
+ if (id.length === 0 || id.includes('/')) throw new SessionSyncError('bad-id', '会话 id 无效', 400)
158
+ const payload = await this.bridge.exportSession(id)
159
+ sendJson(res, 200, { ok: true, ...payload })
160
+ return
161
+ }
162
+ if (url.pathname === '/v1/sessions' && req.method === 'POST') {
163
+ const body = await readJsonBody(req)
164
+ // Dedupe before writing: the same conversation pushed twice (or pushed
165
+ // back after being pulled) must not become a second local copy. Callers
166
+ // that really want a copy ask for it explicitly with `?force=1`.
167
+ const force = url.searchParams.get('force') === '1'
168
+ if (!force && Array.isArray(body?.events)) {
169
+ const signature = sessionSignature(body.session, body.events)
170
+ const duplicate = this.duplicateOf?.(signature) ?? null
171
+ if (duplicate !== null && duplicate !== undefined) {
172
+ sendJson(res, 200, { ok: true, skipped: true, id: duplicate, signature })
173
+ return
174
+ }
175
+ }
176
+ const imported = await this.bridge.importSession(body)
177
+ this.log(`已接收来自 ${req.socket.remoteAddress} 的会话 → ${imported.id}`)
178
+ sendJson(res, 200, { ok: true, ...imported })
179
+ return
180
+ }
181
+ throw new SessionSyncError('not-found', `未知接口: ${req.method} ${url.pathname}`, 404)
182
+ }
183
+
184
+ close() {
185
+ const server = this.server
186
+ this.server = null
187
+ this.port = null
188
+ if (server !== null) {
189
+ try {
190
+ server.close()
191
+ server.closeAllConnections?.()
192
+ } catch {
193
+ /* ignore */
194
+ }
195
+ }
196
+ }
197
+ }
198
+
199
+ function listen(server, port) {
200
+ return new Promise((resolve, reject) => {
201
+ const onError = (error) => {
202
+ server.off('listening', onListening)
203
+ reject(error)
204
+ }
205
+ const onListening = () => {
206
+ server.off('error', onError)
207
+ resolve()
208
+ }
209
+ server.once('error', onError)
210
+ server.once('listening', onListening)
211
+ server.listen(port, '0.0.0.0')
212
+ })
213
+ }
package/lib/logfile.js ADDED
@@ -0,0 +1,80 @@
1
+ /**
2
+ * Offline helpers for the JSONL session logs DSH keeps in `$DSH_HOME/sessions`.
3
+ *
4
+ * `zlib.zstdDecompressSync` only decodes the *first* Zstandard frame of a file,
5
+ * and DSH appends one frame per write batch, so reading a stored log needs a
6
+ * frame walker first. Writing back emits a single frame, which every decoder
7
+ * (including DSH's streaming reader) accepts.
8
+ */
9
+ import { zstdCompressSync, zstdDecompressSync } from 'node:zlib'
10
+
11
+ const MAGIC = 0xfd2fb528
12
+
13
+ /**
14
+ * Split a buffer into its concatenated Zstandard frames.
15
+ *
16
+ * @param {Buffer} buffer - raw `*.jsonl.zstd` content.
17
+ * @returns {Buffer[]} one slice per frame, in file order.
18
+ */
19
+ export function splitZstdFrames(buffer) {
20
+ const frames = []
21
+ let offset = 0
22
+ while (offset + 4 <= buffer.length) {
23
+ const start = offset
24
+ if (buffer.readUInt32LE(offset) !== MAGIC) {
25
+ // A torn tail (crash mid-write) is the only legitimate reason to stop;
26
+ // keep what was decoded so far instead of refusing the whole log.
27
+ break
28
+ }
29
+ offset += 4
30
+ const descriptor = buffer.readUInt8(offset)
31
+ offset += 1
32
+ const fcsFlag = descriptor >> 6
33
+ const singleSegment = (descriptor & 0x20) !== 0
34
+ const checksum = (descriptor & 0x04) !== 0
35
+ const dictFlag = descriptor & 0x03
36
+ const dictSize = dictFlag === 0 ? 0 : dictFlag === 1 ? 1 : dictFlag === 2 ? 2 : 4
37
+ if (singleSegment === false) offset += 1
38
+ offset += dictSize
39
+ offset += fcsFlag === 0 ? (singleSegment ? 1 : 0) : fcsFlag === 1 ? 2 : fcsFlag === 2 ? 4 : 8
40
+ for (;;) {
41
+ if (offset + 3 > buffer.length) return frames
42
+ const header = buffer.readUIntLE(offset, 3)
43
+ offset += 3
44
+ const last = (header & 0x01) !== 0
45
+ const type = (header >> 1) & 0x03
46
+ const size = header >> 3
47
+ if (type === 0) offset += size
48
+ else if (type === 1) offset += 1
49
+ else if (type === 2) offset += size
50
+ else throw new Error('zstd frame uses a reserved block type')
51
+ if (offset > buffer.length) return frames
52
+ if (last) break
53
+ }
54
+ if (checksum) offset += 4
55
+ frames.push(buffer.subarray(start, Math.min(offset, buffer.length)))
56
+ if (offset >= buffer.length) break
57
+ }
58
+ return frames
59
+ }
60
+
61
+ /**
62
+ * Decode a whole stored log (all frames) into its JSONL text.
63
+ *
64
+ * @param {Buffer} buffer - raw `*.jsonl.zstd` content.
65
+ * @returns {string} newline-delimited JSON text without a trailing newline requirement.
66
+ */
67
+ export function decodeStoredLog(buffer) {
68
+ const parts = splitZstdFrames(buffer).map((frame) => zstdDecompressSync(frame))
69
+ return Buffer.concat(parts).toString('utf8')
70
+ }
71
+
72
+ /**
73
+ * Encode JSONL text as a single Zstandard frame.
74
+ *
75
+ * @param {string} text - newline-delimited JSON.
76
+ * @returns {Buffer} compressed bytes ready to write as `session.v4.jsonl.zstd`.
77
+ */
78
+ export function encodeStoredLog(text) {
79
+ return zstdCompressSync(Buffer.from(text, 'utf8'))
80
+ }
package/lib/probe.js ADDED
@@ -0,0 +1,100 @@
1
+ /**
2
+ * Quiet HTTP fallback for networks where UDP broadcast (and even unicast UDP)
3
+ * cannot cross a router or a firewall.
4
+ *
5
+ * Only *explicit* targets are probed — a bare IP from the "cross-subnet" box or
6
+ * a `host:port` from the manual list — never a whole CIDR: 254 hosts times a
7
+ * port range would be a port scan, not a discovery handshake.
8
+ *
9
+ * The probe is a normal `GET /v1/info` against the same API every peer already
10
+ * exposes on the LAN; the reply carries the device id, so the rest of the
11
+ * plugin can treat an HTTP-discovered device exactly like a broadcast one.
12
+ */
13
+
14
+ /** Sync port the plugin starts on, plus the ports it falls back to (16 tries). */
15
+ export const SYNC_PORT_BASE = 47802
16
+ export const SYNC_PORT_TRIES = 16
17
+
18
+ export const DEFAULT_SYNC_PORTS = Array.from({ length: SYNC_PORT_TRIES }, (_, index) => SYNC_PORT_BASE + index)
19
+
20
+ /**
21
+ * Split `192.168.1.20:47802` into host and port. IPv6 literals in brackets are
22
+ * rejected on purpose: the LAN API is IPv4-only (the discovery socket is udp4).
23
+ *
24
+ * @param {string} spec
25
+ * @returns {{host: string, port: number|null} | null}
26
+ */
27
+ export function parseTarget(spec) {
28
+ const text = String(spec ?? '').trim()
29
+ if (text.length === 0) return null
30
+ const match = /^(\d{1,3}(?:\.\d{1,3}){3})(?::(\d{1,5}))?$/.exec(text)
31
+ if (match === null) return null
32
+ const host = match[1]
33
+ const parts = host.split('.').map(Number)
34
+ if (parts.some((part) => part < 0 || part > 255)) return null
35
+ if (match[2] === undefined) return { host, port: null }
36
+ const port = Number(match[2])
37
+ if (!Number.isInteger(port) || port < 1 || port > 65535) return null
38
+ return { host, port }
39
+ }
40
+
41
+ /**
42
+ * Try the candidate ports of one host and return the first `/v1/info` answer.
43
+ *
44
+ * @param {string} host
45
+ * @param {number[]} ports
46
+ * @param {{token?: string|null, timeoutMs?: number, header?: string}} options
47
+ * @returns {Promise<{host: string, port: number, info: object} | null>}
48
+ */
49
+ export async function probeHost(host, ports, options = {}) {
50
+ const timeoutMs = options.timeoutMs ?? 450
51
+ const headers = { 'x-dsh-session-sync': '1' }
52
+ if (typeof options.token === 'string' && options.token.length > 0) headers['x-dsh-session-sync-token'] = options.token
53
+ // Ports are tried concurrently: a filtered port stalls the full timeout, so
54
+ // sequential probing of 16 ports would take 7s on a quiet host.
55
+ const attempt = async (port) => {
56
+ const controller = new AbortController()
57
+ const timer = setTimeout(() => controller.abort(), timeoutMs)
58
+ timer.unref?.()
59
+ try {
60
+ const response = await fetch(`http://${host}:${port}/v1/info`, { headers, signal: controller.signal })
61
+ if (!response.ok) return null
62
+ const info = await response.json()
63
+ if (info !== null && typeof info === 'object' && typeof info.id === 'string' && info.id.length > 0) {
64
+ return { host, port, info }
65
+ }
66
+ return null
67
+ } catch {
68
+ return null
69
+ } finally {
70
+ clearTimeout(timer)
71
+ }
72
+ }
73
+ const settled = await Promise.allSettled(ports.map((port) => attempt(port)))
74
+ for (const entry of settled) {
75
+ if (entry.status === 'fulfilled' && entry.value !== null) return entry.value
76
+ }
77
+ return null
78
+ }
79
+
80
+ /**
81
+ * Probe a list of explicit targets, stopping at the first answer per host.
82
+ * Sequential on purpose: a handful of addresses, and we do not want a burst.
83
+ *
84
+ * @param {Array<{host: string, port: number|null}>} targets
85
+ * @param {{token?: string|null, timeoutMs?: number, ports?: number[]}} options
86
+ * @returns {Promise<Array<{host: string, port: number, info: object}>>}
87
+ */
88
+ export async function probeTargets(targets, options = {}) {
89
+ const ports = options.ports ?? DEFAULT_SYNC_PORTS
90
+ const found = []
91
+ const seen = new Set()
92
+ for (const target of targets) {
93
+ if (target === null || typeof target.host !== 'string') continue
94
+ if (seen.has(target.host)) continue
95
+ seen.add(target.host)
96
+ const result = await probeHost(target.host, target.port === null ? ports : [target.port], options)
97
+ if (result !== null) found.push(result)
98
+ }
99
+ return found
100
+ }
package/lib/repair.js ADDED
@@ -0,0 +1,150 @@
1
+ /**
2
+ * Repair session logs that an older plugin build imported incorrectly.
3
+ *
4
+ * Those copies carried the source's inherited `session/end-seed` marker into an
5
+ * unseeded header, so DSH rejects them with
6
+ * "released v2 unseeded Session contains an inherited end-seed marker".
7
+ * Dropping the inherited prefix (the source's own conversation is kept) and
8
+ * renumbering `seq` makes the log valid again.
9
+ */
10
+ import fs from 'node:fs'
11
+ import path from 'node:path'
12
+
13
+ import { decodeStoredLog, encodeStoredLog } from './logfile.js'
14
+
15
+ /** @typedef {{ dropped: number, events: number }} RepairReport */
16
+
17
+ /**
18
+ * Remove an inherited prefix from decoded JSONL text.
19
+ *
20
+ * @param {string} text - decoded log including its header line.
21
+ * @returns {{ text: string, report: RepairReport } | null} null when nothing needs repair.
22
+ */
23
+ export function repairLogText(text) {
24
+ const lines = text.split('\n').filter((line) => line.length > 0)
25
+ if (lines.length < 2) return null
26
+ let header
27
+ try {
28
+ header = JSON.parse(lines[0])
29
+ } catch {
30
+ return null
31
+ }
32
+ if (header?.type !== 'session' || header.version !== 4) return null
33
+ if (header.isSeeded === true) return null
34
+
35
+ let cut = -1
36
+ for (const [index, line] of lines.slice(1).entries()) {
37
+ let event
38
+ try {
39
+ event = JSON.parse(line)
40
+ } catch {
41
+ continue
42
+ }
43
+ if (event?.type === 'session/end-seed' && event.data?.inherited === true) cut = index
44
+ }
45
+ if (cut < 0) return null
46
+
47
+ const kept = lines.slice(cut + 2).map((line, index) => {
48
+ const event = JSON.parse(line)
49
+ return JSON.stringify({ ...event, seq: index })
50
+ })
51
+ return {
52
+ text: [JSON.stringify(header), ...kept].join('\n') + '\n',
53
+ report: { dropped: cut + 1, events: kept.length },
54
+ }
55
+ }
56
+
57
+ /**
58
+ * Every `session*.jsonl.zstd` under a sessions root, with its lock state.
59
+ *
60
+ * @param {string} root - `$DSH_HOME/sessions`.
61
+ * @returns {{ file: string, dir: string, locked: boolean }[]}
62
+ */
63
+ export function listStoredLogs(root) {
64
+ const found = []
65
+ let slugs = []
66
+ try {
67
+ slugs = fs.readdirSync(root, { withFileTypes: true })
68
+ } catch {
69
+ return found
70
+ }
71
+ for (const slug of slugs) {
72
+ if (!slug.isDirectory()) continue
73
+ const slugDir = path.join(root, slug.name)
74
+ let sessions = []
75
+ try {
76
+ sessions = fs.readdirSync(slugDir, { withFileTypes: true })
77
+ } catch {
78
+ continue
79
+ }
80
+ for (const session of sessions) {
81
+ if (!session.isDirectory()) continue
82
+ const dir = path.join(slugDir, session.name)
83
+ let files = []
84
+ try {
85
+ files = fs.readdirSync(dir)
86
+ } catch {
87
+ continue
88
+ }
89
+ const file = files.find((name) => name.endsWith('.jsonl.zstd'))
90
+ if (file === undefined) continue
91
+ found.push({ file: path.join(dir, file), dir, locked: files.includes('session.lock') })
92
+ }
93
+ }
94
+ return found
95
+ }
96
+
97
+ /**
98
+ * Repair one stored log in place, keeping a `*.bak` next to it.
99
+ *
100
+ * @param {string} file - path to `session.v4.jsonl.zstd`.
101
+ * @param {{ dryRun?: boolean }} [options]
102
+ * @returns {RepairReport | null} null when the log needed no repair.
103
+ */
104
+ export function repairStoredLog(file, options = {}) {
105
+ const raw = fs.readFileSync(file)
106
+ let text
107
+ try {
108
+ text = decodeStoredLog(raw)
109
+ } catch {
110
+ return null
111
+ }
112
+ const repaired = repairLogText(text)
113
+ if (repaired === null) return null
114
+ if (options.dryRun === true) return repaired.report
115
+ fs.writeFileSync(`${file}.bak`, raw)
116
+ const tmp = `${file}.tmp`
117
+ fs.writeFileSync(tmp, encodeStoredLog(repaired.text))
118
+ fs.renameSync(tmp, file)
119
+ return repaired.report
120
+ }
121
+
122
+ /**
123
+ * Repair every unlocked stored log under a sessions root.
124
+ *
125
+ * @param {string} root - `$DSH_HOME/sessions`.
126
+ * @param {{ dryRun?: boolean, log?: (message: string) => void }} [options]
127
+ * @returns {{ scanned: number, repaired: number, locked: number }}
128
+ */
129
+ export function repairStoredSessions(root, options = {}) {
130
+ const log = options.log ?? (() => {})
131
+ const summary = { scanned: 0, repaired: 0, locked: 0 }
132
+ for (const entry of listStoredLogs(root)) {
133
+ summary.scanned += 1
134
+ if (entry.locked) summary.locked += 1
135
+ // `session.lock` is an empty marker DSH leaves behind, not a reliable
136
+ // "someone is writing" signal — and a corrupt log cannot be open anyway,
137
+ // because opening it fails validation. Repair it and keep the backup.
138
+ let report = null
139
+ try {
140
+ report = repairStoredLog(entry.file, { dryRun: options.dryRun })
141
+ } catch (error) {
142
+ log(`修复失败 ${entry.file}: ${error?.message ?? error}`)
143
+ continue
144
+ }
145
+ if (report === null) continue
146
+ summary.repaired += 1
147
+ log(`已修复 ${path.basename(entry.dir)}:丢弃继承前缀 ${report.dropped} 条,保留 ${report.events} 条`)
148
+ }
149
+ return summary
150
+ }