@linxin666/dsh-remote-web-ui 0.1.19 → 0.1.20
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/client.js +20 -3
- package/lib/client.js.map +1 -1
- package/lib/index.js +119 -78
- package/lib/mobile.js +18 -5
- package/lib/mobile.js.map +1 -1
- package/lib/types/client/FooterRemoteEntry.js +1 -1
- package/lib/types/client/PluginSettingsCard.d.ts +1 -1
- package/lib/types/client/PluginSettingsCard.d.ts.map +1 -1
- package/lib/types/client/RemoteEntry.d.ts.map +1 -1
- package/lib/types/client/RemoteEntry.js +41 -11
- 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 +2 -2
- package/lib/types/client/UpdateEntry.js +1 -1
- package/lib/types/client/UpdatePanel.js +1 -1
- package/lib/types/client/deep-link.js +1 -1
- package/lib/types/client/index.js +7 -7
- 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.js +1 -1
- 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 +3 -1
- package/lib/types/index.d.ts.map +1 -1
- package/lib/types/index.js +12 -10
- package/lib/types/invariant.d.ts.map +1 -1
- package/lib/types/mobile/api.js +1 -1
- package/lib/types/mobile/index.js +3 -3
- 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-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/rpc.d.ts.map +1 -1
- package/lib/types/mobile/theme-toggle.js +1 -1
- package/lib/types/mobile/views/App.d.ts +0 -11
- package/lib/types/mobile/views/App.d.ts.map +1 -1
- package/lib/types/mobile/views/App.js +6 -6
- 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-routes.d.ts.map +1 -1
- package/lib/types/mobile-routes.js +17 -11
- 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.d.ts.map +1 -1
- package/lib/types/routes.d.ts +6 -6
- package/lib/types/routes.d.ts.map +1 -1
- package/lib/types/routes.js +14 -19
- 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.map +1 -1
- package/lib/types/update.js +6 -3
- package/package.json +1 -1
- package/src/client/RemoteEntry.tsx +38 -6
- package/src/client/RemotePanel.tsx +1 -1
- package/src/http.ts +30 -0
- package/src/index.ts +4 -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/views/App.tsx +0 -12
- package/src/mobile-api.ts +9 -17
- package/src/mobile-routes.ts +16 -10
- package/src/mount-once.ts +48 -0
- package/src/routes.ts +11 -17
- package/src/update-routes.ts +1 -7
- package/src/update.ts +6 -3
|
@@ -16,7 +16,7 @@ import css from './remote.module.css'
|
|
|
16
16
|
|
|
17
17
|
/** The panel's view state, owned by the entry component. */
|
|
18
18
|
export type PanelState =
|
|
19
|
-
| { kind: 'lan-required' }
|
|
19
|
+
| { kind: 'lan-required'; tunnel?: TunnelStatusFrame }
|
|
20
20
|
| { kind: 'loopback-required' }
|
|
21
21
|
| { kind: 'unreachable' }
|
|
22
22
|
| {
|
package/src/http.ts
ADDED
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shared HTTP helpers for the route families: one JSON writer and one
|
|
3
|
+
* bounded JSON body reader. Previously copy-pasted across routes.ts,
|
|
4
|
+
* update-routes.ts, and mobile-api.ts with drifting failure contracts.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import type { IncomingMessage, ServerResponse } from 'node:http'
|
|
8
|
+
|
|
9
|
+
/** One JSON response. */
|
|
10
|
+
export function writeJson(res: ServerResponse, status: number, body: unknown): void {
|
|
11
|
+
const payload = JSON.stringify(body)
|
|
12
|
+
res.writeHead(status, { 'content-type': 'application/json; charset=utf-8', 'referrer-policy': 'no-referrer' })
|
|
13
|
+
res.end(payload)
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Read a request body up to maxBytes and parse it as JSON.
|
|
18
|
+
* @throws 'body too large' beyond the cap, or the JSON.parse error.
|
|
19
|
+
*/
|
|
20
|
+
export async function readBoundedJson(req: IncomingMessage, maxBytes: number): Promise<unknown> {
|
|
21
|
+
const chunks: Buffer[] = []
|
|
22
|
+
let size = 0
|
|
23
|
+
for await (const chunk of req) {
|
|
24
|
+
const buffer = chunk as Buffer
|
|
25
|
+
size += buffer.length
|
|
26
|
+
if (size > maxBytes) throw new Error('body too large')
|
|
27
|
+
chunks.push(buffer)
|
|
28
|
+
}
|
|
29
|
+
return JSON.parse(Buffer.concat(chunks).toString('utf8'))
|
|
30
|
+
}
|
package/src/index.ts
CHANGED
|
@@ -31,6 +31,7 @@ import {
|
|
|
31
31
|
type UpdateRunResult,
|
|
32
32
|
} from './update.ts'
|
|
33
33
|
import { makeUpdateRoutes } from './update-routes.ts'
|
|
34
|
+
import { mountOnce } from './mount-once.ts'
|
|
34
35
|
|
|
35
36
|
declare module '@deepseek-ai/cordis' {
|
|
36
37
|
interface Events {
|
|
@@ -147,7 +148,9 @@ const DEFAULTS: ResolvedConfig = {
|
|
|
147
148
|
* @param ctx - host plugin context carrying webServer.
|
|
148
149
|
* @param config - resolved plugin config (schema defaults applied by the loader).
|
|
149
150
|
*/
|
|
150
|
-
export
|
|
151
|
+
export const apply = mountOnce('@linxin666/dsh-remote-web-ui', applyImpl)
|
|
152
|
+
|
|
153
|
+
function applyImpl(ctx: Context, config?: Config): void {
|
|
151
154
|
const resolved: ResolvedConfig = {
|
|
152
155
|
tokenTtlMs: config?.tokenTtlMs ?? DEFAULTS.tokenTtlMs,
|
|
153
156
|
offlineAfterMs: config?.offlineAfterMs ?? DEFAULTS.offlineAfterMs,
|
|
@@ -22,6 +22,11 @@ describe('safeUrl', () => {
|
|
|
22
22
|
expect(safeUrl('')).toBeNull()
|
|
23
23
|
expect(safeUrl(' ')).toBeNull()
|
|
24
24
|
})
|
|
25
|
+
|
|
26
|
+
it('rejects protocol-relative targets that escape the origin', () => {
|
|
27
|
+
expect(safeUrl('//attacker.example/x')).toBeNull()
|
|
28
|
+
expect(safeUrl(' //attacker.example/track.png')).toBeNull()
|
|
29
|
+
})
|
|
25
30
|
})
|
|
26
31
|
|
|
27
32
|
describe('renderInline', () => {
|
package/src/mobile/markdown.ts
CHANGED
|
@@ -22,15 +22,18 @@ export function escapeHtml(text: string): string {
|
|
|
22
22
|
|
|
23
23
|
/**
|
|
24
24
|
* Guard a raw link/image target against dangerous protocols. Returns the
|
|
25
|
-
* (trimmed) raw string when safe, else null. Only http:, https:, mailto
|
|
26
|
-
*
|
|
27
|
-
* — javascript:, data:, vbscript:, etc. —
|
|
28
|
-
*
|
|
25
|
+
* (trimmed) raw string when safe, else null. Only http:, https:, mailto:,
|
|
26
|
+
* fragment anchors (#...) and strictly relative paths are allowed; anything
|
|
27
|
+
* with another scheme — javascript:, data:, vbscript:, etc. — or a
|
|
28
|
+
* protocol-relative //host target (the browser resolves it against the
|
|
29
|
+
* current scheme, reaching an arbitrary origin) is rejected so the value
|
|
30
|
+
* never reaches dangerouslySetInnerHTML.
|
|
29
31
|
*/
|
|
30
32
|
export function safeUrl(raw: string): string | null {
|
|
31
33
|
const trimmed = raw.trim()
|
|
32
34
|
if (trimmed === '') return null
|
|
33
35
|
if (trimmed.startsWith('#')) return trimmed
|
|
36
|
+
if (trimmed.startsWith('//')) return null
|
|
34
37
|
const scheme = /^([a-zA-Z][a-zA-Z0-9+.-]*):/.exec(trimmed)
|
|
35
38
|
if (scheme === null) return trimmed
|
|
36
39
|
const name = scheme[1].toLowerCase()
|
package/src/mobile/messages.ts
CHANGED
|
@@ -615,5 +615,18 @@ export function foldEvents(events: readonly WireEvent[], existing?: readonly Ren
|
|
|
615
615
|
if (event.seq <= watermark) continue
|
|
616
616
|
applyEvent(state, event)
|
|
617
617
|
}
|
|
618
|
-
|
|
618
|
+
// Incremental tails (the live-stream hot path) are appended in seq order
|
|
619
|
+
// or updated in place, so the list is usually ordered already — verify in
|
|
620
|
+
// O(n) and skip the O(n log n) re-sort when it is.
|
|
621
|
+
const out = state.messages
|
|
622
|
+
let ordered = true
|
|
623
|
+
for (let index = 1; index < out.length; index += 1) {
|
|
624
|
+
const prev = out[index - 1]!
|
|
625
|
+
const current = out[index]!
|
|
626
|
+
if (prev.seq > current.seq || (prev.seq === current.seq && prev.id >= current.id)) {
|
|
627
|
+
ordered = false
|
|
628
|
+
break
|
|
629
|
+
}
|
|
630
|
+
}
|
|
631
|
+
return ordered ? out : [...out].sort((a, b) => a.seq - b.seq || (a.id < b.id ? -1 : 1))
|
|
619
632
|
}
|
package/src/mobile/views/App.tsx
CHANGED
|
@@ -30,18 +30,6 @@ export interface SessionView {
|
|
|
30
30
|
blank: boolean
|
|
31
31
|
}
|
|
32
32
|
|
|
33
|
-
/** One rendered chat message (produced by the fold). */
|
|
34
|
-
export interface RenderMessage {
|
|
35
|
-
id: string
|
|
36
|
-
kind: 'user' | 'assistant'
|
|
37
|
-
text: string
|
|
38
|
-
seq: number
|
|
39
|
-
time: number
|
|
40
|
-
pending?: boolean
|
|
41
|
-
failed?: boolean
|
|
42
|
-
toolSummary?: string
|
|
43
|
-
}
|
|
44
|
-
|
|
45
33
|
/** Read the optional workspace target carried from the pairing QR flow. */
|
|
46
34
|
export function mobileWorkspaceTarget(search: string): string | undefined {
|
|
47
35
|
const value = new URLSearchParams(search).get('workspace')
|
package/src/mobile-api.ts
CHANGED
|
@@ -25,6 +25,7 @@ import type { ApiProxy } from '@deepseek-ai/dsh-host-apiproxy'
|
|
|
25
25
|
import type { RpcRequest } from '@deepseek-ai/dsh-host-apiproxy/api/rpc'
|
|
26
26
|
import { RpcId } from '@deepseek-ai/dsh-host-apiproxy/api/rpc'
|
|
27
27
|
import type { PairingService } from './pairing.ts'
|
|
28
|
+
import { readBoundedJson, writeJson } from './http.ts'
|
|
28
29
|
import { readCookie } from './gate.ts'
|
|
29
30
|
|
|
30
31
|
/** Methods the phone surface may call. Everything else is refused. */
|
|
@@ -122,11 +123,6 @@ export function makeMobileApiRoutes(deps: MobileApiDeps): WebRoute[] {
|
|
|
122
123
|
return touchDeviceFor(req)
|
|
123
124
|
}
|
|
124
125
|
|
|
125
|
-
const writeJson = (res: ServerResponse, status: number, body: unknown): void => {
|
|
126
|
-
res.writeHead(status, { 'content-type': 'application/json; charset=utf-8' })
|
|
127
|
-
res.end(JSON.stringify(body))
|
|
128
|
-
}
|
|
129
|
-
|
|
130
126
|
const handleMethod = async (req: IncomingMessage, res: ServerResponse): Promise<void> => {
|
|
131
127
|
if (req.method !== 'POST') {
|
|
132
128
|
res.writeHead(405)
|
|
@@ -170,7 +166,11 @@ export function makeMobileApiRoutes(deps: MobileApiDeps): WebRoute[] {
|
|
|
170
166
|
return
|
|
171
167
|
}
|
|
172
168
|
try {
|
|
173
|
-
|
|
169
|
+
// Cancel the host-side work when the phone goes away mid-call (the
|
|
170
|
+
// response stream closing before we answer means nobody is listening).
|
|
171
|
+
const abort = new AbortController()
|
|
172
|
+
res.on('close', () => { if (!res.writableEnded) abort.abort() })
|
|
173
|
+
const response = await dispatch(apiProxy, method, parsed?.payload, rpcId, abort.signal)
|
|
174
174
|
writeJson(res, 200, response)
|
|
175
175
|
} catch (error) {
|
|
176
176
|
const message = error instanceof Error ? error.message : String(error)
|
|
@@ -244,19 +244,11 @@ export function makeMobileApiRoutes(deps: MobileApiDeps): WebRoute[] {
|
|
|
244
244
|
|
|
245
245
|
/** Read a request body as JSON (bounded). */
|
|
246
246
|
async function readJsonBody(req: IncomingMessage): Promise<unknown> {
|
|
247
|
-
|
|
248
|
-
let size = 0
|
|
249
|
-
for await (const chunk of req) {
|
|
250
|
-
const buffer = chunk as Buffer
|
|
251
|
-
size += buffer.length
|
|
252
|
-
if (size > 64 * 1024) throw new Error('body too large')
|
|
253
|
-
chunks.push(buffer)
|
|
254
|
-
}
|
|
255
|
-
return JSON.parse(Buffer.concat(chunks).toString('utf8'))
|
|
247
|
+
return readBoundedJson(req, 64 * 1024)
|
|
256
248
|
}
|
|
257
249
|
|
|
258
250
|
/** Dispatch one allowlisted method through the host ApiProxy. */
|
|
259
|
-
async function dispatch(apiProxy: ApiProxy, method: string, payload: unknown, rpcId: string): Promise<unknown> {
|
|
251
|
+
async function dispatch(apiProxy: ApiProxy, method: string, payload: unknown, rpcId: string, signal?: AbortSignal): Promise<unknown> {
|
|
260
252
|
const request: RpcRequest<unknown> = { rpcId: RpcId(rpcId), payload }
|
|
261
253
|
if (method === 'session.list') {
|
|
262
254
|
const full = await apiProxy.sessions.list(request as never)
|
|
@@ -302,7 +294,7 @@ async function dispatch(apiProxy: ApiProxy, method: string, payload: unknown, rp
|
|
|
302
294
|
if (method === 'workspace.list') return wrap(await apiProxy.workspace.list(request as never))
|
|
303
295
|
if (method === 'session.create') return wrap(await apiProxy.sessions.create(request as never))
|
|
304
296
|
if (method === 'session.history') return wrap(await apiProxy.sessions.history(request as never))
|
|
305
|
-
if (method === 'session.search') return wrap(await apiProxy.sessions.search(request as never, new AbortController().signal))
|
|
297
|
+
if (method === 'session.search') return wrap(await apiProxy.sessions.search(request as never, signal ?? new AbortController().signal))
|
|
306
298
|
if (method === 'session.prompt') return wrap(await apiProxy.sessions.prompt(request as never))
|
|
307
299
|
if (method === 'session.models') return wrap(await apiProxy.sessions.models(request as never))
|
|
308
300
|
if (method === 'session.selectModel') return wrap(await apiProxy.sessions.selectModel(request as never))
|
package/src/mobile-routes.ts
CHANGED
|
@@ -76,18 +76,24 @@ export function makeMobileRoutes(): WebRoute[] {
|
|
|
76
76
|
const handlePage = (_req: IncomingMessage, res: ServerResponse): void => {
|
|
77
77
|
writeStatic(res, 200, 'text/html', pageHtml('/m/mobile.js'))
|
|
78
78
|
}
|
|
79
|
+
// The bundle is immutable for the process lifetime (a rebuild requires a
|
|
80
|
+
// host restart), so the ~456KB body is read from disk once, not per phone.
|
|
81
|
+
let bundleBody: string | undefined
|
|
79
82
|
const handleBundle = async (_req: IncomingMessage, res: ServerResponse): Promise<void> => {
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
83
|
+
if (bundleBody === undefined) {
|
|
84
|
+
const path = mobileBundlePath()
|
|
85
|
+
if (!existsSync(path)) {
|
|
86
|
+
writeStatic(res, 503, 'text/plain', 'mobile bundle not built: run pnpm --filter @linxin666/dsh-remote-web-ui build')
|
|
87
|
+
return
|
|
88
|
+
}
|
|
89
|
+
try {
|
|
90
|
+
bundleBody = await readFile(path, 'utf8')
|
|
91
|
+
} catch {
|
|
92
|
+
writeStatic(res, 500, 'text/plain', 'failed to read the mobile bundle')
|
|
93
|
+
return
|
|
94
|
+
}
|
|
90
95
|
}
|
|
96
|
+
writeStatic(res, 200, 'text/javascript', bundleBody)
|
|
91
97
|
}
|
|
92
98
|
const handleIcon = async (_req: IncomingMessage, res: ServerResponse): Promise<void> => {
|
|
93
99
|
const path = appleTouchIconPath()
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
// Generated by scripts/sync-shared.mjs from shared/host/mount-once.ts. Do not edit this copy; edit the shared source and run "node scripts/sync-shared.mjs".
|
|
2
|
+
/**
|
|
3
|
+
* Host single-instance guard shared by the plugin family. The family bundle
|
|
4
|
+
* (dsh-web-ui-all / dsh-skins) namespaces every child row id (web-ui-*), so
|
|
5
|
+
* the loader accepts a standalone install of the same package side by side;
|
|
6
|
+
* without this guard the second instance would still re-register the same
|
|
7
|
+
* webserver routes, tools, settings namespaces, and system-prompt sections
|
|
8
|
+
* and fail the boot. mountOnce makes the second host apply a no-op for the
|
|
9
|
+
* lifetime of the first instance (the browser half is already deduped by
|
|
10
|
+
* package name in the client module host).
|
|
11
|
+
*
|
|
12
|
+
* The registry rides a global symbol so two module instances of the same
|
|
13
|
+
* package (npm copy vs repository link) still share one verdict. cordis
|
|
14
|
+
* `ctx.effect` runs its callback immediately and treats the callback's
|
|
15
|
+
* return value as the fiber disposer, so the unmarker is returned, not run.
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
const MOUNTED = Symbol.for('dsh-web-ui.mounted-plugins')
|
|
19
|
+
|
|
20
|
+
interface MountRegistry {
|
|
21
|
+
[MOUNTED]?: Set<string>
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
function mountedSet(): Set<string> {
|
|
25
|
+
const registry = globalThis as MountRegistry
|
|
26
|
+
return (registry[MOUNTED] ??= new Set())
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Wrap a cordis plugin apply so the package runs at most once per process.
|
|
31
|
+
* The first mount registers normally and unmarks when its fiber disposes;
|
|
32
|
+
* any later mount of the same package name is a no-op.
|
|
33
|
+
* @param packageName - npm package identity shared by every install source.
|
|
34
|
+
* @param fn - the original plugin apply.
|
|
35
|
+
* @returns an apply of the same shape.
|
|
36
|
+
*/
|
|
37
|
+
export function mountOnce<T extends (...args: any[]) => unknown>(packageName: string, fn: T): T {
|
|
38
|
+
return ((...args: unknown[]) => {
|
|
39
|
+
const mounted = mountedSet()
|
|
40
|
+
if (mounted.has(packageName)) return
|
|
41
|
+
mounted.add(packageName)
|
|
42
|
+
const ctx = args[0] as { effect?: (effect: () => unknown) => unknown } | undefined
|
|
43
|
+
ctx?.effect?.(() => () => {
|
|
44
|
+
mounted.delete(packageName)
|
|
45
|
+
})
|
|
46
|
+
return fn(...args)
|
|
47
|
+
}) as T
|
|
48
|
+
}
|
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 })
|
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
|
@@ -332,10 +332,13 @@ export async function checkUpdates(deps: UpdateCheckDeps): Promise<UpdateStatus>
|
|
|
332
332
|
return { mode: 'link', packages: [], outdated: false }
|
|
333
333
|
}
|
|
334
334
|
const names = [anchor, ...familyChildren(manifest)]
|
|
335
|
+
// The registry probes are independent: run them together instead of
|
|
336
|
+
// serializing up to N x 10s of registry latency behind one status call.
|
|
337
|
+
const latestList = await Promise.all(names.map(name => deps.fetchLatest(name)))
|
|
335
338
|
const packages: UpdatePackageStatus[] = []
|
|
336
339
|
let probeFailures = 0
|
|
337
|
-
|
|
338
|
-
const latest =
|
|
340
|
+
names.forEach((name, index) => {
|
|
341
|
+
const latest = latestList[index]
|
|
339
342
|
if (latest === undefined) probeFailures++
|
|
340
343
|
const current = readInstalledVersion(deps.resolve, name)
|
|
341
344
|
packages.push({
|
|
@@ -344,7 +347,7 @@ export async function checkUpdates(deps: UpdateCheckDeps): Promise<UpdateStatus>
|
|
|
344
347
|
latest,
|
|
345
348
|
outdated: latest !== undefined && latest !== current && compareVersions(latest, current) > 0,
|
|
346
349
|
})
|
|
347
|
-
}
|
|
350
|
+
})
|
|
348
351
|
// Registry unreachable: every probe failed — report the outage distinctly
|
|
349
352
|
// instead of a misleading "all up to date" (the panel needs the reason).
|
|
350
353
|
const error = probeFailures === names.length && names.length > 0 ? 'registry-unreachable' : undefined
|