@linxin666/dsh-remote-web-ui 0.1.19 → 0.2.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/README.i18n.yaml +2 -2
- package/README.md +41 -19
- package/README.zh.md +17 -12
- package/assets/mobile-icon-192.png +0 -0
- package/assets/mobile-icon-512.png +0 -0
- package/assets/mobile-service-worker.js +64 -0
- package/lib/client.js +57 -13
- package/lib/client.js.map +1 -1
- package/lib/index.js +408 -130
- package/lib/mobile.js +296 -22
- package/lib/mobile.js.map +1 -1
- package/lib/types/client/FooterRemoteEntry.js +1 -1
- package/lib/types/client/PluginSettingsCard.d.ts +7 -1
- package/lib/types/client/PluginSettingsCard.d.ts.map +1 -1
- package/lib/types/client/PluginSettingsCard.js +5 -3
- package/lib/types/client/RemoteEntry.d.ts.map +1 -1
- package/lib/types/client/RemoteEntry.js +43 -13
- package/lib/types/client/RemotePanel.d.ts +1 -0
- package/lib/types/client/RemotePanel.d.ts.map +1 -1
- package/lib/types/client/RemotePanel.js +2 -2
- package/lib/types/client/RemoteSettingsCard.d.ts.map +1 -1
- package/lib/types/client/RemoteSettingsCard.js +3 -3
- package/lib/types/client/UpdateEntry.d.ts.map +1 -1
- package/lib/types/client/UpdateEntry.js +37 -4
- package/lib/types/client/UpdatePanel.js +1 -1
- package/lib/types/client/deep-link.js +2 -2
- package/lib/types/client/index.js +7 -7
- package/lib/types/client/locales.d.ts +2 -0
- package/lib/types/client/locales.d.ts.map +1 -1
- package/lib/types/client/locales.js +2 -0
- package/lib/types/client/settings-form.d.ts.map +1 -1
- package/lib/types/client/update-api.d.ts.map +1 -1
- package/lib/types/gate.d.ts +9 -0
- package/lib/types/gate.d.ts.map +1 -1
- package/lib/types/gate.js +16 -5
- package/lib/types/http.d.ts +14 -0
- package/lib/types/http.d.ts.map +1 -0
- package/lib/types/http.js +27 -0
- package/lib/types/index.d.ts +28 -1
- package/lib/types/index.d.ts.map +1 -1
- package/lib/types/index.js +41 -22
- package/lib/types/invariant.d.ts.map +1 -1
- package/lib/types/mobile/PairRequiredView.d.ts +7 -0
- package/lib/types/mobile/PairRequiredView.d.ts.map +1 -0
- package/lib/types/mobile/PairRequiredView.js +28 -0
- package/lib/types/mobile/PairRequiredView.test.d.ts +2 -0
- package/lib/types/mobile/PairRequiredView.test.d.ts.map +1 -0
- package/lib/types/mobile/PairRequiredView.test.js +24 -0
- package/lib/types/mobile/api.js +1 -1
- package/lib/types/mobile/index.js +17 -4
- package/lib/types/mobile/markdown.d.ts +6 -4
- package/lib/types/mobile/markdown.d.ts.map +1 -1
- package/lib/types/mobile/markdown.js +8 -4
- package/lib/types/mobile/markdown.test.js +5 -1
- package/lib/types/mobile/messages.d.ts.map +1 -1
- package/lib/types/mobile/messages.js +14 -1
- package/lib/types/mobile/messages.test.js +1 -1
- package/lib/types/mobile/mobile-styles.d.ts +1 -1
- package/lib/types/mobile/mobile-styles.d.ts.map +1 -1
- package/lib/types/mobile/mobile-styles.js +48 -0
- package/lib/types/mobile/mobile-theme.test.js +2 -2
- package/lib/types/mobile/mux.d.ts.map +1 -1
- package/lib/types/mobile/mux.js +1 -1
- package/lib/types/mobile/mux.test.js +1 -1
- package/lib/types/mobile/pairing.d.ts +31 -0
- package/lib/types/mobile/pairing.d.ts.map +1 -0
- package/lib/types/mobile/pairing.js +62 -0
- package/lib/types/mobile/rpc.d.ts.map +1 -1
- package/lib/types/mobile/theme-toggle.js +1 -1
- package/lib/types/mobile/views/App.d.ts +12 -16
- package/lib/types/mobile/views/App.d.ts.map +1 -1
- package/lib/types/mobile/views/App.js +39 -12
- package/lib/types/mobile/views/App.test.d.ts +2 -0
- package/lib/types/mobile/views/App.test.d.ts.map +1 -0
- package/lib/types/mobile/views/App.test.js +52 -0
- package/lib/types/mobile/views/ChatView.js +6 -6
- package/lib/types/mobile/views/ChatView.test.js +3 -3
- package/lib/types/mobile/views/SessionListView.js +3 -3
- package/lib/types/mobile/views/SessionListView.test.js +2 -2
- package/lib/types/mobile/views/WorkspaceView.js +3 -3
- package/lib/types/mobile/views/WorkspaceView.test.js +3 -3
- package/lib/types/mobile-api.d.ts +1 -1
- package/lib/types/mobile-api.d.ts.map +1 -1
- package/lib/types/mobile-api.js +11 -18
- package/lib/types/mobile-pwa.d.ts +6 -0
- package/lib/types/mobile-pwa.d.ts.map +1 -0
- package/lib/types/mobile-pwa.js +17 -0
- package/lib/types/mobile-routes.d.ts +5 -8
- package/lib/types/mobile-routes.d.ts.map +1 -1
- package/lib/types/mobile-routes.js +130 -39
- package/lib/types/mount-once.d.ts +25 -0
- package/lib/types/mount-once.d.ts.map +1 -0
- package/lib/types/mount-once.js +42 -0
- package/lib/types/pairing-access.d.ts +28 -0
- package/lib/types/pairing-access.d.ts.map +1 -0
- package/lib/types/pairing-access.js +26 -0
- package/lib/types/pairing.d.ts +27 -0
- package/lib/types/pairing.d.ts.map +1 -1
- package/lib/types/pairing.js +68 -0
- package/lib/types/pairing.persist.test.d.ts +2 -0
- package/lib/types/pairing.persist.test.d.ts.map +1 -0
- package/lib/types/pairing.persist.test.js +128 -0
- package/lib/types/routes.d.ts +6 -6
- package/lib/types/routes.d.ts.map +1 -1
- package/lib/types/routes.js +15 -20
- package/lib/types/tunnel.d.ts.map +1 -1
- package/lib/types/update-routes.d.ts +2 -2
- package/lib/types/update-routes.d.ts.map +1 -1
- package/lib/types/update-routes.js +1 -6
- package/lib/types/update.d.ts +3 -3
- package/lib/types/update.d.ts.map +1 -1
- package/lib/types/update.js +40 -14
- package/package.json +12 -12
- package/src/client/PluginSettingsCard.tsx +10 -0
- package/src/client/RemoteEntry.tsx +40 -8
- package/src/client/RemotePanel.tsx +1 -1
- package/src/client/RemoteSettingsCard.tsx +1 -0
- package/src/client/UpdateEntry.tsx +36 -4
- package/src/client/deep-link.ts +1 -1
- package/src/client/locales.ts +2 -0
- package/src/client/remote.module.css +26 -0
- package/src/gate.ts +15 -3
- package/src/http.ts +30 -0
- package/src/index.ts +50 -16
- package/src/mobile/PairRequiredView.test.tsx +28 -0
- package/src/mobile/PairRequiredView.tsx +58 -0
- package/src/mobile/index.tsx +15 -1
- package/src/mobile/markdown.test.ts +5 -0
- package/src/mobile/markdown.ts +7 -4
- package/src/mobile/messages.ts +14 -1
- package/src/mobile/mobile-styles.ts +48 -0
- package/src/mobile/pairing.ts +77 -0
- package/src/mobile/views/App.test.tsx +62 -0
- package/src/mobile/views/App.tsx +53 -18
- package/src/mobile-api.ts +9 -17
- package/src/mobile-pwa.ts +19 -0
- package/src/mobile-routes.ts +137 -38
- package/src/mount-once.ts +48 -0
- package/src/pairing-access.ts +36 -0
- package/src/pairing.persist.test.ts +145 -0
- package/src/pairing.ts +70 -1
- package/src/routes.ts +12 -18
- package/src/update-routes.ts +1 -7
- package/src/update.ts +46 -15
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* PairingService device-session persistence: with `devicesFile` set, sessions
|
|
3
|
+
* written on accept/stop survive a process restart (a new PairingService
|
|
4
|
+
* instance), so a previously paired phone — whose cookie already lives 365
|
|
5
|
+
* days — never needs to re-scan the QR after `dsh web` restarts.
|
|
6
|
+
*/
|
|
7
|
+
import { mkdtempSync, readFileSync, rmSync, statSync, writeFileSync } from 'node:fs'
|
|
8
|
+
import { tmpdir } from 'node:os'
|
|
9
|
+
import { join } from 'node:path'
|
|
10
|
+
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
|
|
11
|
+
import { pairingConfigOf } from './index.ts'
|
|
12
|
+
import { PairingService, type PairingClock, type PairingConfig } from './pairing.ts'
|
|
13
|
+
|
|
14
|
+
const BASE_CONFIG: Omit<PairingConfig, 'devicesFile'> = {
|
|
15
|
+
tokenTtlMs: 60_000,
|
|
16
|
+
offlineAfterMs: 25_000,
|
|
17
|
+
maxDevices: 4,
|
|
18
|
+
cookieName: 'dsh_pair',
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/** Deterministic clock: tokens are issued in a fixed, readable sequence. */
|
|
22
|
+
function makeClock(): PairingClock {
|
|
23
|
+
let n = 0
|
|
24
|
+
return {
|
|
25
|
+
now: () => 1_000_000 + n,
|
|
26
|
+
randomToken: () => `tok${(n++).toString().padStart(6, '0')}`,
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/** Pair a device on a service: issue a token and immediately accept it. */
|
|
31
|
+
function pairDevice(service: PairingService): string {
|
|
32
|
+
service.setPublicBaseUrl('https://pairing.example.trycloudflare.com')
|
|
33
|
+
const { token } = service.issue()
|
|
34
|
+
const result = service.accept(token)
|
|
35
|
+
if (!result.ok) throw new Error(`pair failed: ${result.code}`)
|
|
36
|
+
return result.deviceId
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
describe('PairingService device persistence', () => {
|
|
40
|
+
let dir: string
|
|
41
|
+
|
|
42
|
+
beforeEach(() => {
|
|
43
|
+
dir = mkdtempSync(join(tmpdir(), 'remote-web-ui-persist-'))
|
|
44
|
+
})
|
|
45
|
+
|
|
46
|
+
afterEach(() => {
|
|
47
|
+
rmSync(dir, { recursive: true, force: true })
|
|
48
|
+
})
|
|
49
|
+
|
|
50
|
+
it('restores paired sessions across a process restart', () => {
|
|
51
|
+
const file = join(dir, 'devices.json')
|
|
52
|
+
const first = new PairingService({ ...BASE_CONFIG, devicesFile: file }, makeClock())
|
|
53
|
+
const deviceId = pairDevice(first)
|
|
54
|
+
expect(readFileSync(file, 'utf8')).toContain(deviceId)
|
|
55
|
+
// Device ids are session credentials: the persisted file is 0600.
|
|
56
|
+
expect(statSync(file).mode & 0o777).toBe(0o600)
|
|
57
|
+
|
|
58
|
+
// Simulated restart: a brand-new service instance reading the same file.
|
|
59
|
+
const second = new PairingService({ ...BASE_CONFIG, devicesFile: file }, makeClock())
|
|
60
|
+
expect(second.hasDevice(deviceId)).toBe(true)
|
|
61
|
+
})
|
|
62
|
+
|
|
63
|
+
it('carries devicesFile through the config mapping used by sync() (regression)', () => {
|
|
64
|
+
const file = join(dir, 'devices.json')
|
|
65
|
+
const mapped = pairingConfigOf({
|
|
66
|
+
tokenTtlMs: 60_000,
|
|
67
|
+
offlineAfterMs: 25_000,
|
|
68
|
+
maxDevices: 4,
|
|
69
|
+
cookieName: 'dsh_pair',
|
|
70
|
+
devicesFile: file,
|
|
71
|
+
})
|
|
72
|
+
expect(mapped.devicesFile).toBe(file)
|
|
73
|
+
})
|
|
74
|
+
|
|
75
|
+
it('keeps persisting after a sync-style service.config rebuild', () => {
|
|
76
|
+
const file = join(dir, 'devices.json')
|
|
77
|
+
const service = new PairingService({ ...BASE_CONFIG, devicesFile: file }, makeClock())
|
|
78
|
+
// applyImpl/sync() reassigns service.config from the resolved config on
|
|
79
|
+
// mount and on every settings change; the rebuild must not drop devicesFile.
|
|
80
|
+
service.config = pairingConfigOf({
|
|
81
|
+
tokenTtlMs: 60_000,
|
|
82
|
+
offlineAfterMs: 25_000,
|
|
83
|
+
maxDevices: 4,
|
|
84
|
+
cookieName: 'dsh_pair',
|
|
85
|
+
devicesFile: file,
|
|
86
|
+
})
|
|
87
|
+
const deviceId = pairDevice(service)
|
|
88
|
+
const restarted = new PairingService({ ...BASE_CONFIG, devicesFile: file }, makeClock())
|
|
89
|
+
expect(restarted.hasDevice(deviceId)).toBe(true)
|
|
90
|
+
})
|
|
91
|
+
|
|
92
|
+
it('clamps a persisted table to maxDevices when the file holds more sessions', () => {
|
|
93
|
+
const file = join(dir, 'devices.json')
|
|
94
|
+
writeFileSync(file, JSON.stringify({
|
|
95
|
+
dev1: { createdAt: 1_000, lastSeenAt: 1_000 },
|
|
96
|
+
dev2: { createdAt: 2_000, lastSeenAt: 2_000 },
|
|
97
|
+
dev3: { createdAt: 3_000, lastSeenAt: 3_000 },
|
|
98
|
+
}))
|
|
99
|
+
const service = new PairingService({ ...BASE_CONFIG, maxDevices: 2, devicesFile: file }, makeClock())
|
|
100
|
+
expect(service.hasDevice('dev3')).toBe(true)
|
|
101
|
+
expect(service.hasDevice('dev2')).toBe(true)
|
|
102
|
+
expect(service.hasDevice('dev1')).toBe(false)
|
|
103
|
+
})
|
|
104
|
+
|
|
105
|
+
it('keeps sessions memory-only when devicesFile is unset', () => {
|
|
106
|
+
const first = new PairingService({ ...BASE_CONFIG }, makeClock())
|
|
107
|
+
const deviceId = pairDevice(first)
|
|
108
|
+
// No file was written; a fresh instance without the option knows nothing.
|
|
109
|
+
const second = new PairingService({ ...BASE_CONFIG }, makeClock())
|
|
110
|
+
expect(second.hasDevice(deviceId)).toBe(false)
|
|
111
|
+
})
|
|
112
|
+
|
|
113
|
+
it('persists revocation by stop() across a restart', () => {
|
|
114
|
+
const file = join(dir, 'devices.json')
|
|
115
|
+
const first = new PairingService({ ...BASE_CONFIG, devicesFile: file }, makeClock())
|
|
116
|
+
const deviceId = pairDevice(first)
|
|
117
|
+
first.stop()
|
|
118
|
+
|
|
119
|
+
const second = new PairingService({ ...BASE_CONFIG, devicesFile: file }, makeClock())
|
|
120
|
+
expect(second.hasDevice(deviceId)).toBe(false)
|
|
121
|
+
})
|
|
122
|
+
|
|
123
|
+
it('persists FIFO eviction when the device cap is reached', () => {
|
|
124
|
+
const file = join(dir, 'devices.json')
|
|
125
|
+
const first = new PairingService({ ...BASE_CONFIG, maxDevices: 2, devicesFile: file }, makeClock())
|
|
126
|
+
const firstDevice = pairDevice(first)
|
|
127
|
+
const secondDevice = pairDevice(first)
|
|
128
|
+
const thirdDevice = pairDevice(first) // evicts firstDevice
|
|
129
|
+
expect(first.hasDevice(firstDevice)).toBe(false)
|
|
130
|
+
|
|
131
|
+
const second = new PairingService({ ...BASE_CONFIG, maxDevices: 2, devicesFile: file }, makeClock())
|
|
132
|
+
expect(second.hasDevice(secondDevice)).toBe(true)
|
|
133
|
+
expect(second.hasDevice(thirdDevice)).toBe(true)
|
|
134
|
+
expect(second.hasDevice(firstDevice)).toBe(false)
|
|
135
|
+
})
|
|
136
|
+
|
|
137
|
+
it('tolerates a corrupt or missing file instead of refusing to boot', () => {
|
|
138
|
+
const file = join(dir, 'devices.json')
|
|
139
|
+
writeFileSync(file, '{ this is not json !!!')
|
|
140
|
+
expect(() => new PairingService({ ...BASE_CONFIG, devicesFile: file }, makeClock())).not.toThrow()
|
|
141
|
+
|
|
142
|
+
const ghost = join(dir, 'does-not-exist.json')
|
|
143
|
+
expect(() => new PairingService({ ...BASE_CONFIG, devicesFile: ghost }, makeClock())).not.toThrow()
|
|
144
|
+
})
|
|
145
|
+
})
|
package/src/pairing.ts
CHANGED
|
@@ -16,6 +16,8 @@
|
|
|
16
16
|
*/
|
|
17
17
|
|
|
18
18
|
import { randomBytes } from 'node:crypto'
|
|
19
|
+
import { mkdirSync, readFileSync, renameSync, writeFileSync } from 'node:fs'
|
|
20
|
+
import { dirname } from 'node:path'
|
|
19
21
|
|
|
20
22
|
/** The observable pairing phases the panel renders. */
|
|
21
23
|
export type PairingPhase =
|
|
@@ -95,6 +97,13 @@ export interface PairingConfig {
|
|
|
95
97
|
maxDevices: number
|
|
96
98
|
/** Cookie name carrying the device id. */
|
|
97
99
|
cookieName: string
|
|
100
|
+
/**
|
|
101
|
+
* Path to a JSON file where paired device sessions are persisted. When
|
|
102
|
+
* set, sessions survive process restarts (the phone keeps its 365-day
|
|
103
|
+
* cookie), so re-pairing after a dsh web restart is not required. When
|
|
104
|
+
* unset (default), sessions stay memory-only — the previous behavior.
|
|
105
|
+
*/
|
|
106
|
+
devicesFile?: string
|
|
98
107
|
}
|
|
99
108
|
|
|
100
109
|
/** Result of one accept() attempt. */
|
|
@@ -154,7 +163,65 @@ export class PairingService {
|
|
|
154
163
|
constructor(
|
|
155
164
|
public config: PairingConfig,
|
|
156
165
|
private readonly clock: PairingClock = defaultClock,
|
|
157
|
-
) {
|
|
166
|
+
) {
|
|
167
|
+
this.loadPersisted()
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
/**
|
|
171
|
+
* Restore device sessions persisted by a previous process run. A corrupt
|
|
172
|
+
* or missing file is tolerated (an empty device table, never a throw) —
|
|
173
|
+
* persistence is an availability convenience, not a security boundary.
|
|
174
|
+
*/
|
|
175
|
+
private loadPersisted(): void {
|
|
176
|
+
const file = this.config.devicesFile
|
|
177
|
+
if (file === undefined) return
|
|
178
|
+
try {
|
|
179
|
+
const saved = JSON.parse(readFileSync(file, 'utf8')) as unknown
|
|
180
|
+
if (typeof saved !== 'object' || saved === null) return
|
|
181
|
+
for (const [deviceId, session] of Object.entries(saved)) {
|
|
182
|
+
if (typeof deviceId !== 'string') continue
|
|
183
|
+
if (typeof session !== 'object' || session === null) continue
|
|
184
|
+
const { createdAt, lastSeenAt } = session as { createdAt?: unknown; lastSeenAt?: unknown }
|
|
185
|
+
if (typeof createdAt !== 'number' || typeof lastSeenAt !== 'number') continue
|
|
186
|
+
this.devices.set(deviceId, { createdAt, lastSeenAt })
|
|
187
|
+
}
|
|
188
|
+
this.clampToMaxDevices()
|
|
189
|
+
} catch {
|
|
190
|
+
// Unreadable/corrupt: start empty rather than refusing to boot.
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
/** FIFO-cap the device table (a persisted file may outlive a lowered cap). */
|
|
195
|
+
private clampToMaxDevices(): void {
|
|
196
|
+
if (this.devices.size <= this.config.maxDevices) return
|
|
197
|
+
const overflow = this.devices.size - this.config.maxDevices
|
|
198
|
+
const ordered = [...this.devices.entries()].sort((a, b) => a[1].createdAt - b[1].createdAt)
|
|
199
|
+
for (const [id] of ordered.slice(0, overflow)) this.devices.delete(id)
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
/**
|
|
203
|
+
* Write the current device table to the configured file. Called on the
|
|
204
|
+
* mutation boundaries that change the set of live sessions (accept and
|
|
205
|
+
* stop); `lastSeenAt` refreshes are deliberately not persisted here so a
|
|
206
|
+
* per-request write storm is avoided — after a restart the phone's first
|
|
207
|
+
* heartbeat re-warms its own session.
|
|
208
|
+
*
|
|
209
|
+
* Device ids are session credentials (the gate authorizes requests by the
|
|
210
|
+
* cookie's device id), so the file is written 0600 via a temp file and
|
|
211
|
+
* atomic rename; a crash mid-write can never leave a half-written store.
|
|
212
|
+
*/
|
|
213
|
+
private persist(): void {
|
|
214
|
+
const file = this.config.devicesFile
|
|
215
|
+
if (file === undefined) return
|
|
216
|
+
try {
|
|
217
|
+
mkdirSync(dirname(file), { recursive: true })
|
|
218
|
+
const temp = `${file}.${process.pid}.${randomBytes(4).toString('hex')}.tmp`
|
|
219
|
+
writeFileSync(temp, JSON.stringify(Object.fromEntries(this.devices)), { mode: 0o600 })
|
|
220
|
+
renameSync(temp, file)
|
|
221
|
+
} catch (error) {
|
|
222
|
+
console.error('remote-web-ui: failed to persist paired devices', error)
|
|
223
|
+
}
|
|
224
|
+
}
|
|
158
225
|
|
|
159
226
|
/** The default LAN base URL (the first interface; undefined when not LAN-reachable). */
|
|
160
227
|
get lanBaseUrl(): string | undefined {
|
|
@@ -254,6 +321,7 @@ export class PairingService {
|
|
|
254
321
|
if (oldest !== undefined) this.devices.delete(oldest.id)
|
|
255
322
|
}
|
|
256
323
|
this.devices.set(deviceId, { createdAt: now, lastSeenAt: now })
|
|
324
|
+
this.persist()
|
|
257
325
|
this.notify()
|
|
258
326
|
return { ok: true, deviceId }
|
|
259
327
|
}
|
|
@@ -266,6 +334,7 @@ export class PairingService {
|
|
|
266
334
|
stop(): void {
|
|
267
335
|
this.tokens.clear()
|
|
268
336
|
this.devices.clear()
|
|
337
|
+
this.persist()
|
|
269
338
|
this.stopped = true
|
|
270
339
|
this.notify()
|
|
271
340
|
}
|
package/src/routes.ts
CHANGED
|
@@ -13,6 +13,7 @@ import type { WebRoute } from '@deepseek-ai/dsh-host-webserver'
|
|
|
13
13
|
import { z, type ZodType } from 'zod'
|
|
14
14
|
import { UnknownLanAddressError, type PairingService, type PairingSnapshot } from './pairing.ts'
|
|
15
15
|
import { isLoopbackClient, readCookie } from './gate.ts'
|
|
16
|
+
import { readBoundedJson, writeJson } from './http.ts'
|
|
16
17
|
|
|
17
18
|
/**
|
|
18
19
|
* Browser-trust fence for the /api/pair routes, mirroring the connection
|
|
@@ -120,25 +121,10 @@ function parsePairPayload<T>(schema: ZodType<T>, body: Record<string, unknown> |
|
|
|
120
121
|
return result.success ? result.data : undefined
|
|
121
122
|
}
|
|
122
123
|
|
|
123
|
-
/**
|
|
124
|
-
function writeJson(res: ServerResponse, status: number, body: unknown): void {
|
|
125
|
-
const payload = JSON.stringify(body)
|
|
126
|
-
res.writeHead(status, { 'content-type': 'application/json; charset=utf-8', 'referrer-policy': 'no-referrer' })
|
|
127
|
-
res.end(payload)
|
|
128
|
-
}
|
|
129
|
-
|
|
130
|
-
/** Read a request body up to MAX_BODY_BYTES and parse it as JSON. */
|
|
124
|
+
/** Read a request body up to MAX_BODY_BYTES and parse it as JSON (undefined on failure). */
|
|
131
125
|
async function readJsonBody(req: IncomingMessage): Promise<Record<string, unknown> | undefined> {
|
|
132
|
-
const chunks: Buffer[] = []
|
|
133
|
-
let size = 0
|
|
134
|
-
for await (const chunk of req) {
|
|
135
|
-
const buffer = chunk as Buffer
|
|
136
|
-
size += buffer.length
|
|
137
|
-
if (size > MAX_BODY_BYTES) return undefined
|
|
138
|
-
chunks.push(buffer)
|
|
139
|
-
}
|
|
140
126
|
try {
|
|
141
|
-
const parsed: unknown =
|
|
127
|
+
const parsed: unknown = await readBoundedJson(req, MAX_BODY_BYTES)
|
|
142
128
|
return typeof parsed === 'object' && parsed !== null ? parsed as Record<string, unknown> : undefined
|
|
143
129
|
} catch {
|
|
144
130
|
return undefined
|
|
@@ -238,6 +224,14 @@ export function makeRoutes(deps: PairRoutesDeps): WebRoute[] {
|
|
|
238
224
|
const rateLimitAccept = (req: IncomingMessage): boolean => {
|
|
239
225
|
const ip = (req.socket as { remoteAddress?: string } | undefined)?.remoteAddress ?? 'unknown'
|
|
240
226
|
const nowMs = Date.now()
|
|
227
|
+
// The map lives as long as the plugin: prune expired windows once the
|
|
228
|
+
// table grows past a modest size so distinct source IPs (LAN clients,
|
|
229
|
+
// brute-force scans) cannot accumulate forever.
|
|
230
|
+
if (acceptAttempts.size > 256) {
|
|
231
|
+
for (const [key, attempt] of acceptAttempts) {
|
|
232
|
+
if (nowMs - attempt.windowStart > ACCEPT_WINDOW_MS) acceptAttempts.delete(key)
|
|
233
|
+
}
|
|
234
|
+
}
|
|
241
235
|
const entry = acceptAttempts.get(ip)
|
|
242
236
|
if (entry === undefined || nowMs - entry.windowStart > ACCEPT_WINDOW_MS) {
|
|
243
237
|
acceptAttempts.set(ip, { count: 1, windowStart: nowMs })
|
|
@@ -270,7 +264,7 @@ export function makeRoutes(deps: PairRoutesDeps): WebRoute[] {
|
|
|
270
264
|
const workspaceQuery = workspaceId === undefined ? '' : `&workspace=${encodeURIComponent(workspaceId)}`
|
|
271
265
|
writeJson(res, 200, {
|
|
272
266
|
ok: true,
|
|
273
|
-
url: `${base}/?pair=${token}${workspaceQuery}`,
|
|
267
|
+
url: `${base}/m/?pair=${token}${workspaceQuery}`,
|
|
274
268
|
token,
|
|
275
269
|
expiresAt,
|
|
276
270
|
// Every constructible base, so a multi-homed panel can switch the
|
package/src/update-routes.ts
CHANGED
|
@@ -8,6 +8,7 @@
|
|
|
8
8
|
import type { IncomingMessage, ServerResponse } from 'node:http'
|
|
9
9
|
import type { WebRoute } from '@deepseek-ai/dsh-host-webserver'
|
|
10
10
|
import type { UpdateRunResult, UpdateStatus } from './update.ts'
|
|
11
|
+
import { writeJson } from './http.ts'
|
|
11
12
|
|
|
12
13
|
/** Route paths (exact matches under /api). */
|
|
13
14
|
export const UPDATE_PATHS = {
|
|
@@ -25,13 +26,6 @@ export interface UpdateRoutesDeps {
|
|
|
25
26
|
run(): Promise<UpdateRunResult>
|
|
26
27
|
}
|
|
27
28
|
|
|
28
|
-
/** One JSON response. */
|
|
29
|
-
function writeJson(res: ServerResponse, status: number, body: unknown): void {
|
|
30
|
-
const payload = JSON.stringify(body)
|
|
31
|
-
res.writeHead(status, { 'content-type': 'application/json; charset=utf-8', 'referrer-policy': 'no-referrer' })
|
|
32
|
-
res.end(payload)
|
|
33
|
-
}
|
|
34
|
-
|
|
35
29
|
/**
|
|
36
30
|
* Build the /api/update route family.
|
|
37
31
|
* @param deps - fence + check/run seams.
|
package/src/update.ts
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Remote update support for the dsh-web-ui family — host half. Detects the
|
|
3
|
-
* installed aggregate package (@linxin666/dsh-web-ui-all)
|
|
4
|
-
*
|
|
5
|
-
*
|
|
3
|
+
* installed aggregate package (@linxin666/dsh-web-ui-all), or the directly
|
|
4
|
+
* installed family packages when the aggregate is absent, probes npm for newer
|
|
5
|
+
* releases, and runs `pnpm update --latest` inside the owning dsh profile.
|
|
6
6
|
*
|
|
7
7
|
* Pure logic with injected seams (manifest reading, registry fetches, process
|
|
8
8
|
* spawning) so the whole surface is unit-testable without touching disk,
|
|
@@ -235,10 +235,13 @@ export function resolveUpdateTarget(
|
|
|
235
235
|
const profileManifest = readManifest(join(profile.dir, 'package.json'))
|
|
236
236
|
const spec = (profileManifest?.dependencies as Record<string, DependencySpec> | undefined)?.[anchor]
|
|
237
237
|
if (isLinkedSpec(spec)) return { error: 'link' }
|
|
238
|
+
// Standalone installs carry no aggregate: the anchor's own dependency
|
|
239
|
+
// list misses every sibling @linxin666/* plugin installed directly into
|
|
240
|
+
// the profile, so union the profile's direct family deps (#377).
|
|
238
241
|
return {
|
|
239
242
|
profileName: profile.name,
|
|
240
243
|
profileDir: profile.dir,
|
|
241
|
-
packages:
|
|
244
|
+
packages: familyUpdatePackages(anchor, manifest, profileManifest),
|
|
242
245
|
}
|
|
243
246
|
}
|
|
244
247
|
|
|
@@ -253,6 +256,21 @@ export function familyChildren(anchorManifest: Record<string, unknown>): string[
|
|
|
253
256
|
return names
|
|
254
257
|
}
|
|
255
258
|
|
|
259
|
+
/** Registry-managed family packages covered by one update operation. */
|
|
260
|
+
function familyUpdatePackages(
|
|
261
|
+
anchor: string,
|
|
262
|
+
anchorManifest: Record<string, unknown>,
|
|
263
|
+
profileManifest: Record<string, unknown> | undefined,
|
|
264
|
+
): string[] {
|
|
265
|
+
const names = new Set([anchor, ...familyChildren(anchorManifest)])
|
|
266
|
+
const dependencies = profileManifest?.dependencies
|
|
267
|
+
if (typeof dependencies !== 'object' || dependencies === null) return [...names]
|
|
268
|
+
for (const [name, spec] of Object.entries(dependencies)) {
|
|
269
|
+
if (name.startsWith(FAMILY_SCOPE) && typeof spec === 'string' && !isLinkedSpec(spec)) names.add(name)
|
|
270
|
+
}
|
|
271
|
+
return [...names]
|
|
272
|
+
}
|
|
273
|
+
|
|
256
274
|
/**
|
|
257
275
|
* Probe the npm registry for one package's latest release.
|
|
258
276
|
* @param name - the package name (scope slash URL-encoded).
|
|
@@ -292,14 +310,21 @@ export async function fetchLatestVersion(
|
|
|
292
310
|
const VERSION_UNKNOWN = '0.0.0'
|
|
293
311
|
|
|
294
312
|
/** The resolved current version of one family package (probe failure tolerated). */
|
|
295
|
-
function readInstalledVersion(
|
|
313
|
+
function readInstalledVersion(
|
|
314
|
+
resolve: (specifier: string) => string | undefined,
|
|
315
|
+
name: string,
|
|
316
|
+
profileDir?: string,
|
|
317
|
+
): string {
|
|
296
318
|
try {
|
|
297
319
|
const path = resolve(name + '/package.json')
|
|
298
320
|
const version = path === undefined ? undefined : readManifest(path)?.version
|
|
299
|
-
|
|
300
|
-
} catch {
|
|
301
|
-
|
|
302
|
-
|
|
321
|
+
if (typeof version === 'string') return version
|
|
322
|
+
} catch { /* fall through to the profile's direct dependency path */ }
|
|
323
|
+
// Names originate in the profile dependency map, but still validate the
|
|
324
|
+
// npm package shape before using one as path segments.
|
|
325
|
+
if (profileDir === undefined || !/^@linxin666\/[a-z0-9][a-z0-9._-]*$/.test(name)) return VERSION_UNKNOWN
|
|
326
|
+
const version = readManifest(join(profileDir, 'node_modules', ...name.split('/'), 'package.json'))?.version
|
|
327
|
+
return typeof version === 'string' ? version : VERSION_UNKNOWN
|
|
303
328
|
}
|
|
304
329
|
|
|
305
330
|
/**
|
|
@@ -331,20 +356,26 @@ export async function checkUpdates(deps: UpdateCheckDeps): Promise<UpdateStatus>
|
|
|
331
356
|
if (profile === undefined) {
|
|
332
357
|
return { mode: 'link', packages: [], outdated: false }
|
|
333
358
|
}
|
|
334
|
-
|
|
359
|
+
// Union the profile's direct family deps so standalone installs (no
|
|
360
|
+
// aggregate) still check every installed @linxin666/* plugin (#377);
|
|
361
|
+
// familyUpdatePackages skips link:/file: development dependencies.
|
|
362
|
+
const names = familyUpdatePackages(anchor, manifest, profileManifest)
|
|
363
|
+
// The registry probes are independent: run them together instead of
|
|
364
|
+
// serializing up to N x 10s of registry latency behind one status call.
|
|
365
|
+
const latestList = await Promise.all(names.map(name => deps.fetchLatest(name)))
|
|
335
366
|
const packages: UpdatePackageStatus[] = []
|
|
336
367
|
let probeFailures = 0
|
|
337
|
-
|
|
338
|
-
const latest =
|
|
368
|
+
names.forEach((name, index) => {
|
|
369
|
+
const latest = latestList[index]
|
|
339
370
|
if (latest === undefined) probeFailures++
|
|
340
|
-
const current = readInstalledVersion(deps.resolve, name)
|
|
371
|
+
const current = readInstalledVersion(deps.resolve, name, profile.dir)
|
|
341
372
|
packages.push({
|
|
342
373
|
name,
|
|
343
374
|
current,
|
|
344
375
|
latest,
|
|
345
376
|
outdated: latest !== undefined && latest !== current && compareVersions(latest, current) > 0,
|
|
346
377
|
})
|
|
347
|
-
}
|
|
378
|
+
})
|
|
348
379
|
// Registry unreachable: every probe failed — report the outage distinctly
|
|
349
380
|
// instead of a misleading "all up to date" (the panel needs the reason).
|
|
350
381
|
const error = probeFailures === names.length && names.length > 0 ? 'registry-unreachable' : undefined
|
|
@@ -601,7 +632,7 @@ export async function runUpdateVerified(deps: UpdateRunVerifiedDeps): Promise<Up
|
|
|
601
632
|
// green exit that left everything in place could be mistaken for success.
|
|
602
633
|
const before = new Map<string, string>()
|
|
603
634
|
for (const name of deps.run.packages) {
|
|
604
|
-
const version = readInstalledVersion(deps.check.resolve, name)
|
|
635
|
+
const version = readInstalledVersion(deps.check.resolve, name, deps.run.profileDir)
|
|
605
636
|
if (version !== VERSION_UNKNOWN) before.set(name, version)
|
|
606
637
|
}
|
|
607
638
|
const result = await runUpdate(deps.run)
|