@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.
Files changed (86) hide show
  1. package/lib/client.js +20 -3
  2. package/lib/client.js.map +1 -1
  3. package/lib/index.js +119 -78
  4. package/lib/mobile.js +18 -5
  5. package/lib/mobile.js.map +1 -1
  6. package/lib/types/client/FooterRemoteEntry.js +1 -1
  7. package/lib/types/client/PluginSettingsCard.d.ts +1 -1
  8. package/lib/types/client/PluginSettingsCard.d.ts.map +1 -1
  9. package/lib/types/client/RemoteEntry.d.ts.map +1 -1
  10. package/lib/types/client/RemoteEntry.js +41 -11
  11. package/lib/types/client/RemotePanel.d.ts +1 -0
  12. package/lib/types/client/RemotePanel.d.ts.map +1 -1
  13. package/lib/types/client/RemotePanel.js +2 -2
  14. package/lib/types/client/RemoteSettingsCard.d.ts.map +1 -1
  15. package/lib/types/client/RemoteSettingsCard.js +2 -2
  16. package/lib/types/client/UpdateEntry.js +1 -1
  17. package/lib/types/client/UpdatePanel.js +1 -1
  18. package/lib/types/client/deep-link.js +1 -1
  19. package/lib/types/client/index.js +7 -7
  20. package/lib/types/client/settings-form.d.ts.map +1 -1
  21. package/lib/types/client/update-api.d.ts.map +1 -1
  22. package/lib/types/gate.js +1 -1
  23. package/lib/types/http.d.ts +14 -0
  24. package/lib/types/http.d.ts.map +1 -0
  25. package/lib/types/http.js +27 -0
  26. package/lib/types/index.d.ts +3 -1
  27. package/lib/types/index.d.ts.map +1 -1
  28. package/lib/types/index.js +12 -10
  29. package/lib/types/invariant.d.ts.map +1 -1
  30. package/lib/types/mobile/api.js +1 -1
  31. package/lib/types/mobile/index.js +3 -3
  32. package/lib/types/mobile/markdown.d.ts +6 -4
  33. package/lib/types/mobile/markdown.d.ts.map +1 -1
  34. package/lib/types/mobile/markdown.js +8 -4
  35. package/lib/types/mobile/markdown.test.js +5 -1
  36. package/lib/types/mobile/messages.d.ts.map +1 -1
  37. package/lib/types/mobile/messages.js +14 -1
  38. package/lib/types/mobile/messages.test.js +1 -1
  39. package/lib/types/mobile/mobile-theme.test.js +2 -2
  40. package/lib/types/mobile/mux.d.ts.map +1 -1
  41. package/lib/types/mobile/mux.js +1 -1
  42. package/lib/types/mobile/mux.test.js +1 -1
  43. package/lib/types/mobile/rpc.d.ts.map +1 -1
  44. package/lib/types/mobile/theme-toggle.js +1 -1
  45. package/lib/types/mobile/views/App.d.ts +0 -11
  46. package/lib/types/mobile/views/App.d.ts.map +1 -1
  47. package/lib/types/mobile/views/App.js +6 -6
  48. package/lib/types/mobile/views/ChatView.js +6 -6
  49. package/lib/types/mobile/views/ChatView.test.js +3 -3
  50. package/lib/types/mobile/views/SessionListView.js +3 -3
  51. package/lib/types/mobile/views/SessionListView.test.js +2 -2
  52. package/lib/types/mobile/views/WorkspaceView.js +3 -3
  53. package/lib/types/mobile/views/WorkspaceView.test.js +3 -3
  54. package/lib/types/mobile-api.d.ts +1 -1
  55. package/lib/types/mobile-api.d.ts.map +1 -1
  56. package/lib/types/mobile-api.js +11 -18
  57. package/lib/types/mobile-routes.d.ts.map +1 -1
  58. package/lib/types/mobile-routes.js +17 -11
  59. package/lib/types/mount-once.d.ts +25 -0
  60. package/lib/types/mount-once.d.ts.map +1 -0
  61. package/lib/types/mount-once.js +42 -0
  62. package/lib/types/pairing.d.ts.map +1 -1
  63. package/lib/types/routes.d.ts +6 -6
  64. package/lib/types/routes.d.ts.map +1 -1
  65. package/lib/types/routes.js +14 -19
  66. package/lib/types/tunnel.d.ts.map +1 -1
  67. package/lib/types/update-routes.d.ts +2 -2
  68. package/lib/types/update-routes.d.ts.map +1 -1
  69. package/lib/types/update-routes.js +1 -6
  70. package/lib/types/update.d.ts.map +1 -1
  71. package/lib/types/update.js +6 -3
  72. package/package.json +1 -1
  73. package/src/client/RemoteEntry.tsx +38 -6
  74. package/src/client/RemotePanel.tsx +1 -1
  75. package/src/http.ts +30 -0
  76. package/src/index.ts +4 -1
  77. package/src/mobile/markdown.test.ts +5 -0
  78. package/src/mobile/markdown.ts +7 -4
  79. package/src/mobile/messages.ts +14 -1
  80. package/src/mobile/views/App.tsx +0 -12
  81. package/src/mobile-api.ts +9 -17
  82. package/src/mobile-routes.ts +16 -10
  83. package/src/mount-once.ts +48 -0
  84. package/src/routes.ts +11 -17
  85. package/src/update-routes.ts +1 -7
  86. 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 function apply(ctx: Context, config?: Config): void {
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', () => {
@@ -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
- * and fragment anchors (#...) are allowed; anything with another scheme
27
- * — javascript:, data:, vbscript:, etc. — is rejected so the value never
28
- * reaches dangerouslySetInnerHTML.
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()
@@ -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
- return [...state.messages].sort((a, b) => a.seq - b.seq || (a.id < b.id ? -1 : 1))
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
  }
@@ -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
- const response = await dispatch(apiProxy, method, parsed?.payload, rpcId)
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
- const chunks: Buffer[] = []
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))
@@ -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
- const path = mobileBundlePath()
81
- if (!existsSync(path)) {
82
- writeStatic(res, 503, 'text/plain', 'mobile bundle not built: run pnpm --filter @linxin666/dsh-remote-web-ui build')
83
- return
84
- }
85
- try {
86
- const body = await readFile(path, 'utf8')
87
- writeStatic(res, 200, 'text/javascript', body)
88
- } catch {
89
- writeStatic(res, 500, 'text/plain', 'failed to read the mobile bundle')
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
- /** One JSON response. */
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 = JSON.parse(Buffer.concat(chunks).toString('utf8'))
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 })
@@ -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
- for (const name of names) {
338
- const latest = await deps.fetchLatest(name)
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