@linxin666/dsh-remote-web-ui 0.2.0 → 0.2.2

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 (77) hide show
  1. package/README.i18n.yaml +2 -2
  2. package/README.md +17 -8
  3. package/README.zh.md +5 -5
  4. package/lib/client.js +259 -43
  5. package/lib/client.js.map +1 -1
  6. package/lib/index.js +26 -7
  7. package/lib/mobile.js +209 -39
  8. package/lib/mobile.js.map +1 -1
  9. package/lib/types/client/PluginSettingsCard.d.ts +22 -0
  10. package/lib/types/client/PluginSettingsCard.d.ts.map +1 -1
  11. package/lib/types/client/PluginSettingsCard.js +142 -3
  12. package/lib/types/client/RemoteEntry.d.ts.map +1 -1
  13. package/lib/types/client/RemoteEntry.js +11 -0
  14. package/lib/types/client/RemoteSettingsCard.d.ts +2 -2
  15. package/lib/types/client/UpdateEntry.d.ts.map +1 -1
  16. package/lib/types/client/UpdateEntry.js +8 -5
  17. package/lib/types/client/UpdatePanel.d.ts +3 -1
  18. package/lib/types/client/UpdatePanel.d.ts.map +1 -1
  19. package/lib/types/client/UpdatePanel.js +3 -3
  20. package/lib/types/client/locales.d.ts +4 -0
  21. package/lib/types/client/locales.d.ts.map +1 -1
  22. package/lib/types/client/locales.js +8 -4
  23. package/lib/types/client/pair-api.d.ts +1 -6
  24. package/lib/types/client/pair-api.d.ts.map +1 -1
  25. package/lib/types/client/pair-api.js +1 -10
  26. package/lib/types/gate.d.ts.map +1 -1
  27. package/lib/types/gate.js +2 -0
  28. package/lib/types/mobile/api.d.ts +10 -0
  29. package/lib/types/mobile/api.d.ts.map +1 -1
  30. package/lib/types/mobile/api.js +4 -0
  31. package/lib/types/mobile/messages.d.ts +24 -0
  32. package/lib/types/mobile/messages.d.ts.map +1 -1
  33. package/lib/types/mobile/messages.js +60 -23
  34. package/lib/types/mobile/messages.test.js +49 -1
  35. package/lib/types/mobile/mobile-styles.d.ts +1 -1
  36. package/lib/types/mobile/mobile-styles.d.ts.map +1 -1
  37. package/lib/types/mobile/mobile-styles.js +38 -0
  38. package/lib/types/mobile/views/ChatView.d.ts.map +1 -1
  39. package/lib/types/mobile/views/ChatView.js +34 -11
  40. package/lib/types/mobile/views/SessionListView.d.ts.map +1 -1
  41. package/lib/types/mobile/views/SessionListView.js +30 -4
  42. package/lib/types/mobile/views/SessionListView.test.js +58 -1
  43. package/lib/types/mobile/views/WorkspaceView.d.ts.map +1 -1
  44. package/lib/types/mobile/views/WorkspaceView.js +4 -2
  45. package/lib/types/mobile-api.d.ts.map +1 -1
  46. package/lib/types/mobile-api.js +10 -1
  47. package/lib/types/routes.d.ts.map +1 -1
  48. package/lib/types/routes.js +22 -2
  49. package/lib/types/tunnel.d.ts +1 -0
  50. package/lib/types/tunnel.d.ts.map +1 -1
  51. package/lib/types/tunnel.js +7 -2
  52. package/lib/types/update.d.ts +1 -1
  53. package/lib/types/update.d.ts.map +1 -1
  54. package/lib/types/update.js +1 -1
  55. package/package.json +1 -2
  56. package/src/client/PluginSettingsCard.tsx +219 -18
  57. package/src/client/RemoteEntry.tsx +10 -0
  58. package/src/client/RemoteSettingsCard.tsx +2 -2
  59. package/src/client/UpdateEntry.tsx +9 -5
  60. package/src/client/UpdatePanel.tsx +12 -1
  61. package/src/client/locales.ts +8 -4
  62. package/src/client/pair-api.ts +1 -10
  63. package/src/client/settings-card.module.css +90 -1
  64. package/src/gate.ts +2 -0
  65. package/src/mobile/api.ts +14 -1
  66. package/src/mobile/messages.test.ts +52 -1
  67. package/src/mobile/messages.ts +65 -22
  68. package/src/mobile/mobile-styles.ts +38 -0
  69. package/src/mobile/mobile-theme.test.ts +1 -1
  70. package/src/mobile/views/ChatView.tsx +33 -10
  71. package/src/mobile/views/SessionListView.test.tsx +69 -1
  72. package/src/mobile/views/SessionListView.tsx +58 -5
  73. package/src/mobile/views/WorkspaceView.tsx +4 -2
  74. package/src/mobile-api.ts +9 -1
  75. package/src/routes.ts +22 -2
  76. package/src/tunnel.ts +7 -2
  77. package/src/update.ts +2 -2
@@ -10,12 +10,14 @@ import { type SessionView } from './App.tsx'
10
10
  vi.mock('../api.ts', () => ({
11
11
  listSessions: vi.fn(),
12
12
  listWorkspaces: vi.fn(),
13
+ listAgentPresets: vi.fn(),
13
14
  createSession: vi.fn(),
14
15
  }))
15
- import { createSession, listSessions, listWorkspaces } from '../api.ts'
16
+ import { createSession, listAgentPresets, listSessions, listWorkspaces } from '../api.ts'
16
17
 
17
18
  const listSessionsMock = vi.mocked(listSessions)
18
19
  const listWorkspacesMock = vi.mocked(listWorkspaces)
20
+ const listAgentPresetsMock = vi.mocked(listAgentPresets)
19
21
  const createSessionMock = vi.mocked(createSession)
20
22
 
21
23
  const workspace: WorkspaceRow = {
@@ -49,6 +51,7 @@ function renderList(props: Partial<SessionListViewProps> = {}): void {
49
51
  beforeEach(() => {
50
52
  listSessionsMock.mockResolvedValue({ items: [], hasMore: false })
51
53
  listWorkspacesMock.mockResolvedValue([workspace])
54
+ listAgentPresetsMock.mockResolvedValue({ presets: [], authorable: false, hasDocument: false })
52
55
  createSessionMock.mockResolvedValue({ sessionId: 's-new' })
53
56
  })
54
57
 
@@ -111,6 +114,71 @@ describe('SessionListView creation', () => {
111
114
  expect(await screen.findByText('新会话')).toBeTruthy()
112
115
  })
113
116
 
117
+ it('creates the session with the selected agent preset', async () => {
118
+ listAgentPresetsMock.mockResolvedValue({
119
+ presets: [
120
+ { id: 'router-standard', name: 'Router Standard', trust: 'system', isDefault: true },
121
+ { id: 'router-spec', name: 'Router Spec', description: '规格驱动模式', trust: 'system', isDefault: false },
122
+ ],
123
+ authorable: false,
124
+ hasDocument: false,
125
+ })
126
+ renderList()
127
+
128
+ const picker = await screen.findByRole('combobox', { name: 'Agent 模式' })
129
+ expect((picker as HTMLSelectElement).value).toBe('router-standard')
130
+ fireEvent.change(picker, { target: { value: 'router-spec' } })
131
+ fireEvent.click(screen.getByRole('button', { name: '+ 新建会话' }))
132
+
133
+ await waitFor(() => {
134
+ expect(createSessionMock).toHaveBeenCalledWith({ workspaceId: 'w-1', agentPreset: 'router-spec' })
135
+ })
136
+ expect(await screen.findByText('规格驱动模式')).toBeTruthy()
137
+ })
138
+
139
+ it('skips a broken default preset and disables it in the picker', async () => {
140
+ listAgentPresetsMock.mockResolvedValue({
141
+ presets: [
142
+ { id: 'broken-default', trust: 'system', isDefault: true, broken: 'missing plugin' },
143
+ { id: 'local-router', name: 'Local Router', trust: 'user', isDefault: false },
144
+ ],
145
+ authorable: false,
146
+ hasDocument: false,
147
+ })
148
+ renderList()
149
+
150
+ const picker = await screen.findByRole('combobox', { name: 'Agent 模式' }) as HTMLSelectElement
151
+ expect(picker.value).toBe('local-router')
152
+ expect(screen.getByRole('option', { name: 'broken-default(默认)(不可用)' }).hasAttribute('disabled')).toBe(true)
153
+ expect(screen.getByRole('option', { name: 'Local Router(本地)' })).toBeTruthy()
154
+ })
155
+
156
+ it('keeps default session creation available when the preset roster fails', async () => {
157
+ listAgentPresetsMock.mockRejectedValue(new Error('preset roster unavailable'))
158
+ renderList()
159
+ await screen.findByText(/还没有会话/)
160
+
161
+ const button = await screen.findByRole('button', { name: '+ 新建会话' })
162
+ await waitFor(() => { expect(button.hasAttribute('disabled')).toBe(false) })
163
+ fireEvent.click(button)
164
+
165
+ await waitFor(() => {
166
+ expect(createSessionMock).toHaveBeenCalledWith({ workspaceId: 'w-1' })
167
+ })
168
+ })
169
+
170
+ it('waits for the preset roster before enabling session creation', async () => {
171
+ let resolvePresets: (value: Awaited<ReturnType<typeof listAgentPresets>>) => void = () => {}
172
+ listAgentPresetsMock.mockReturnValue(new Promise(resolve => { resolvePresets = resolve }))
173
+ renderList()
174
+
175
+ const button = screen.getByRole('button', { name: '+ 新建会话' })
176
+ expect(button.hasAttribute('disabled')).toBe(true)
177
+
178
+ resolvePresets({ presets: [], authorable: false, hasDocument: false })
179
+ await waitFor(() => { expect(button.hasAttribute('disabled')).toBe(false) })
180
+ })
181
+
114
182
  it('keeps the button disabled while a creation is in flight', async () => {
115
183
  let resolveCreate: (value: { sessionId: string }) => void = () => {}
116
184
  createSessionMock.mockReturnValue(new Promise(resolve => { resolveCreate = resolve }))
@@ -13,8 +13,9 @@
13
13
 
14
14
  import { useCallback, useEffect, useRef, useState } from 'react'
15
15
  import type { WorkspaceView as WorkspaceRow } from '@deepseek-ai/dsh-host-apiproxy/api/workspace'
16
+ import type { AgentPresetEntry } from '@deepseek-ai/dsh-host-apiproxy/api/agent-presets'
16
17
  import type { SessionSummary } from '@deepseek-ai/dsh-host-apiproxy/api/sessions'
17
- import { createSession, listSessions, listWorkspaces } from '../api.ts'
18
+ import { createSession, listAgentPresets, listSessions, listWorkspaces } from '../api.ts'
18
19
  import { errorText, formatTime, staleHostHint, toSessionView, type SessionView } from './App.tsx'
19
20
  import { ThemeToggle } from '../theme-toggle.tsx'
20
21
 
@@ -45,6 +46,9 @@ export function SessionListView({ workspace, onBack, onPick }: SessionListViewPr
45
46
  const [error, setError] = useState<string | undefined>(undefined)
46
47
  const [creating, setCreating] = useState(false)
47
48
  const [createError, setCreateError] = useState<string | undefined>(undefined)
49
+ const [presets, setPresets] = useState<readonly AgentPresetEntry[]>([])
50
+ const [selectedPreset, setSelectedPreset] = useState<string | undefined>(undefined)
51
+ const [presetsLoading, setPresetsLoading] = useState(true)
48
52
  const cursorRef = useRef<string | undefined>(undefined)
49
53
  const busyRef = useRef(false)
50
54
 
@@ -74,6 +78,27 @@ export function SessionListView({ workspace, onBack, onPick }: SessionListViewPr
74
78
  return () => { cancelled = true }
75
79
  }, [workspace])
76
80
 
81
+ useEffect(() => {
82
+ let cancelled = false
83
+ setPresets([])
84
+ setSelectedPreset(undefined)
85
+ setPresetsLoading(true)
86
+ void listAgentPresets().then(
87
+ (roster) => {
88
+ if (cancelled) return
89
+ const usable = roster.presets.filter(preset => preset.broken === undefined)
90
+ setPresets(roster.presets)
91
+ setSelectedPreset((usable.find(preset => preset.isDefault) ?? usable[0])?.id)
92
+ setPresetsLoading(false)
93
+ },
94
+ () => {
95
+ if (cancelled) return
96
+ setPresetsLoading(false)
97
+ },
98
+ )
99
+ return () => { cancelled = true }
100
+ }, [workspace])
101
+
77
102
  /** Pull the next page and append the rows that belong to this workspace. */
78
103
  const loadMore = useCallback(() => {
79
104
  if (busyRef.current) return
@@ -102,7 +127,10 @@ export function SessionListView({ workspace, onBack, onPick }: SessionListViewPr
102
127
  if (creating) return
103
128
  setCreating(true)
104
129
  setCreateError(undefined)
105
- void createSession({ workspaceId: workspace.workspaceId }).then(
130
+ void createSession({
131
+ workspaceId: workspace.workspaceId,
132
+ ...(selectedPreset !== undefined ? { agentPreset: selectedPreset } : {}),
133
+ }).then(
106
134
  (created) => {
107
135
  setCreating(false)
108
136
  const view: SessionView = {
@@ -120,9 +148,10 @@ export function SessionListView({ workspace, onBack, onPick }: SessionListViewPr
120
148
  setCreateError(errorText(reason))
121
149
  },
122
150
  )
123
- }, [creating, workspace, onPick])
151
+ }, [creating, workspace, onPick, selectedPreset])
124
152
 
125
153
  const createHint = createError !== undefined ? staleHostHint(createError) : undefined
154
+ const selectedPresetEntry = presets.find(preset => preset.id === selectedPreset)
126
155
 
127
156
  return (
128
157
  <div className="mobile">
@@ -132,11 +161,35 @@ export function SessionListView({ workspace, onBack, onPick }: SessionListViewPr
132
161
  <ThemeToggle />
133
162
  </header>
134
163
  {error !== undefined && <p className="mobile-error mobile-pad">{error}</p>}
135
- <div className="mobile-pad">
164
+ <div className="mobile-create mobile-pad">
165
+ {presets.length > 0 && (
166
+ <label className="mobile-preset">
167
+ <span className="mobile-presetLabel">Agent 模式</span>
168
+ <select
169
+ className="mobile-presetSelect"
170
+ value={selectedPreset ?? ''}
171
+ disabled={selectedPreset === undefined}
172
+ onChange={(event) => { setSelectedPreset(event.target.value) }}
173
+ >
174
+ {selectedPreset === undefined && <option value="">无可用 Agent 模式</option>}
175
+ {presets.map(preset => (
176
+ <option key={preset.id} value={preset.id} disabled={preset.broken !== undefined}>
177
+ {preset.name ?? preset.id}
178
+ {preset.isDefault ? '(默认)' : ''}
179
+ {preset.trust === 'user' ? '(本地)' : ''}
180
+ {preset.broken !== undefined ? '(不可用)' : ''}
181
+ </option>
182
+ ))}
183
+ </select>
184
+ </label>
185
+ )}
186
+ {selectedPresetEntry?.description !== undefined && (
187
+ <p className="mobile-presetDescription">{selectedPresetEntry.description}</p>
188
+ )}
136
189
  <button
137
190
  type="button"
138
191
  className="mobile-new"
139
- disabled={creating}
192
+ disabled={creating || presetsLoading}
140
193
  onClick={() => { void handleCreate() }}
141
194
  >
142
195
  {creating ? '创建中…' : '+ 新建会话'}
@@ -27,6 +27,8 @@ export interface WorkspaceViewProps {
27
27
  export function WorkspaceView({ initialWorkspaceId, onPick }: WorkspaceViewProps) {
28
28
  const [items, setItems] = useState<WorkspaceRow[] | undefined>(undefined)
29
29
  const [error, setError] = useState<string | undefined>(undefined)
30
+ // Bumped by the retry button to re-run the roster fetch effect.
31
+ const [reload, setReload] = useState(0)
30
32
 
31
33
  useEffect(() => {
32
34
  let cancelled = false
@@ -48,7 +50,7 @@ export function WorkspaceView({ initialWorkspaceId, onPick }: WorkspaceViewProps
48
50
  },
49
51
  )
50
52
  return () => { cancelled = true }
51
- }, [initialWorkspaceId, onPick])
53
+ }, [initialWorkspaceId, onPick, reload])
52
54
 
53
55
  if (error !== undefined) {
54
56
  return (
@@ -59,7 +61,7 @@ export function WorkspaceView({ initialWorkspaceId, onPick }: WorkspaceViewProps
59
61
  </header>
60
62
  <div className="mobile-empty">
61
63
  <p className="mobile-error">加载失败:{error}</p>
62
- <button type="button" className="mobile-button" onClick={() => { setError(undefined); setItems(undefined) }}>
64
+ <button type="button" className="mobile-button" onClick={() => { setError(undefined); setItems(undefined); setReload(n => n + 1) }}>
63
65
  重试
64
66
  </button>
65
67
  </div>
package/src/mobile-api.ts CHANGED
@@ -28,9 +28,16 @@ import type { PairingService } from './pairing.ts'
28
28
  import { readBoundedJson, writeJson } from './http.ts'
29
29
  import { readCookie } from './gate.ts'
30
30
 
31
- /** Methods the phone surface may call. Everything else is refused. */
31
+ /**
32
+ * Methods the phone surface may call. Everything else is refused HERE — but
33
+ * note the paired-device cookie also passes the global api/gate for the full
34
+ * ApiProxy surface (gate.ts), so a paired phone is a full-control credential:
35
+ * the allowlist only constrains this /m/api proxy, not the cookie's reach.
36
+ * stop() revokes every device; there is no per-device revocation yet.
37
+ */
32
38
  const MOBILE_ALLOWLIST = new Set([
33
39
  'workspace.list',
40
+ 'agentPreset.list',
34
41
  'session.create',
35
42
  'session.list',
36
43
  'session.history',
@@ -292,6 +299,7 @@ async function dispatch(apiProxy: ApiProxy, method: string, payload: unknown, rp
292
299
  result: response.result,
293
300
  })
294
301
  if (method === 'workspace.list') return wrap(await apiProxy.workspace.list(request as never))
302
+ if (method === 'agentPreset.list') return wrap(await apiProxy.agentPresets.list(request as never))
295
303
  if (method === 'session.create') return wrap(await apiProxy.sessions.create(request as never))
296
304
  if (method === 'session.history') return wrap(await apiProxy.sessions.history(request as never))
297
305
  if (method === 'session.search') return wrap(await apiProxy.sessions.search(request as never, signal ?? new AbortController().signal))
package/src/routes.ts CHANGED
@@ -222,7 +222,16 @@ export function makeRoutes(deps: PairRoutesDeps): WebRoute[] {
222
222
  const ACCEPT_MAX_ATTEMPTS = 10
223
223
  const ACCEPT_WINDOW_MS = 30_000
224
224
  const rateLimitAccept = (req: IncomingMessage): boolean => {
225
- const ip = (req.socket as { remoteAddress?: string } | undefined)?.remoteAddress ?? 'unknown'
225
+ const socketIp = (req.socket as { remoteAddress?: string } | undefined)?.remoteAddress ?? 'unknown'
226
+ // Behind the auto-tunnel every internet client arrives from 127.0.0.1,
227
+ // so a single shared bucket would let one attacker keep the legitimate
228
+ // owner rate-limited. Partition the availability bucket by the first
229
+ // client-visible XFF hop (set by the tunnel edge): XFF is untrusted for
230
+ // authentication and only separates buckets, it never grants access.
231
+ const forwarded = typeof req.headers['x-forwarded-for'] === 'string'
232
+ ? (req.headers['x-forwarded-for'].split(',')[0] ?? '').trim()
233
+ : undefined
234
+ const ip = forwarded === undefined || forwarded === '' ? socketIp : socketIp + '|' + forwarded
226
235
  const nowMs = Date.now()
227
236
  // The map lives as long as the plugin: prune expired windows once the
228
237
  // table grows past a modest size so distinct source IPs (LAN clients,
@@ -306,6 +315,9 @@ export function makeRoutes(deps: PairRoutesDeps): WebRoute[] {
306
315
  writeJson(res, result.code === 'used' ? 409 : 404, { ok: false, code: result.code })
307
316
  return
308
317
  }
318
+ // No Secure attribute: LAN pairing runs over plain HTTP (the cookie must
319
+ // work there), and the same cookie rides HTTPS on the tunnel. Lax keeps
320
+ // top-level navigations working while blocking cross-site subrequests.
309
321
  res.writeHead(200, {
310
322
  'content-type': 'application/json; charset=utf-8',
311
323
  'set-cookie': [
@@ -356,7 +368,15 @@ export function makeRoutes(deps: PairRoutesDeps): WebRoute[] {
356
368
  return
357
369
  }
358
370
  const deviceId = readCookie(req.headers.cookie, service.config.cookieName)
359
- writeJson(res, 200, { ok: true, paired: deviceId !== undefined && service.hasDevice(deviceId), ...service.snapshot() })
371
+ const paired = deviceId !== undefined && service.hasDevice(deviceId)
372
+ const snapshot = service.snapshot()
373
+ // Unpaired LAN/tunnel clients get only the pairing-relevant fields; the
374
+ // token expiry, device roster, and public tunnel URL are an oracle for
375
+ // targeting and timing and stay behind a live device cookie.
376
+ const visible = paired
377
+ ? snapshot
378
+ : { phase: snapshot.phase, lanAvailable: snapshot.lanAvailable, lanAddresses: snapshot.lanAddresses }
379
+ writeJson(res, 200, { ok: true, paired, ...visible })
360
380
  }
361
381
 
362
382
  const handleEvents = (req: IncomingMessage, res: ServerResponse): void => {
package/src/tunnel.ts CHANGED
@@ -86,6 +86,9 @@ export class TunnelManager {
86
86
  private urlTimer: unknown | undefined
87
87
  private restartTimer: unknown | undefined
88
88
  private attempts = 0
89
+ // Generation counter: a stale ensureBinary resolution from an earlier
90
+ // start() must not spawn a second handle after a stop/start cycle.
91
+ private generation = 0
89
92
  private stopping = false
90
93
  private readonly urlListeners = new Set<(url: string) => void>()
91
94
  private readonly phaseListeners = new Set<(info: TunnelInfo) => void>()
@@ -123,6 +126,7 @@ export class TunnelManager {
123
126
  this.stopping = false
124
127
  this.targetUrl = targetUrl
125
128
  this.attempts = 0
129
+ this.generation += 1
126
130
  this.attempt()
127
131
  }
128
132
 
@@ -153,12 +157,13 @@ export class TunnelManager {
153
157
 
154
158
  private attempt(): void {
155
159
  if (this.stopping || this.targetUrl === undefined) return
160
+ const gen = this.generation
156
161
  this.setPhase('starting')
157
162
  this.handle = undefined
158
163
  this.url = undefined
159
164
  this.error = undefined
160
165
  void this.ensureBinary().then(() => {
161
- if (this.stopping || this.targetUrl === undefined) return
166
+ if (this.stopping || this.targetUrl === undefined || gen !== this.generation) return
162
167
  const handle = this.factory(this.targetUrl)
163
168
  this.handle = handle
164
169
  this.urlTimer = this.timer.setTimeout(() => {
@@ -181,7 +186,7 @@ export class TunnelManager {
181
186
  })
182
187
  }).catch((value: unknown) => {
183
188
  // Binary install failed (no network, no platform build): report it.
184
- if (this.stopping || this.targetUrl === undefined) return
189
+ if (this.stopping || this.targetUrl === undefined || gen !== this.generation) return
185
190
  const message = value instanceof Error ? value.message : String(value)
186
191
  this.fail(`could not obtain the cloudflared binary: ${message}`)
187
192
  })
package/src/update.ts CHANGED
@@ -280,14 +280,14 @@ function familyUpdatePackages(
280
280
  */
281
281
  export async function fetchLatestVersion(
282
282
  name: string,
283
- fetchImpl: (url: string) => Promise<{ ok: boolean; json(): Promise<unknown> }>,
283
+ fetchImpl: (url: string, init?: RequestInit) => Promise<{ ok: boolean; json(): Promise<unknown> }>,
284
284
  timeoutMs = 10_000,
285
285
  ): Promise<string | undefined> {
286
286
  try {
287
287
  const controller = new AbortController()
288
288
  const timer = setTimeout(() => { controller.abort() }, timeoutMs)
289
289
  try {
290
- const response = await fetchImpl(NPM_REGISTRY + '/' + name.replace('/', '%2F') + '/latest')
290
+ const response = await fetchImpl(NPM_REGISTRY + '/' + name.replace('/', '%2F') + '/latest', { signal: controller.signal })
291
291
  if (!response.ok) return undefined
292
292
  const body = await response.json()
293
293
  if (typeof body !== 'object' || body === null) return undefined