dsh-browser-application 0.37.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.
Files changed (44) hide show
  1. package/LICENSE +19 -0
  2. package/README.md +27 -0
  3. package/cordis.patch.yml +29 -0
  4. package/lib/index.js +3377 -0
  5. package/lib/invariant.js +26 -0
  6. package/lib/types/bridge-url.d.ts +27 -0
  7. package/lib/types/browser-context.d.ts +38 -0
  8. package/lib/types/dsh-gateway.d.ts +42 -0
  9. package/lib/types/event-generation.d.ts +56 -0
  10. package/lib/types/extension-sessions.d.ts +26 -0
  11. package/lib/types/host-api.d.ts +47 -0
  12. package/lib/types/image-relay.d.ts +43 -0
  13. package/lib/types/index.d.ts +158 -0
  14. package/lib/types/invariant.d.ts +16 -0
  15. package/lib/types/remote-host-api.d.ts +12 -0
  16. package/lib/types/server.d.ts +166 -0
  17. package/lib/types/session-deferral.d.ts +33 -0
  18. package/lib/types/session-history.d.ts +30 -0
  19. package/lib/types/session-purge.d.ts +55 -0
  20. package/lib/types/session-workspace.d.ts +37 -0
  21. package/lib/types/token.d.ts +57 -0
  22. package/lib/types/tools.d.ts +42 -0
  23. package/lib/types/vision-selfcheck.d.ts +18 -0
  24. package/lib/types/vision.d.ts +57 -0
  25. package/package.json +95 -0
  26. package/src/bridge-url.ts +57 -0
  27. package/src/browser-context.ts +102 -0
  28. package/src/dsh-gateway.ts +66 -0
  29. package/src/event-generation.ts +385 -0
  30. package/src/extension-sessions.ts +40 -0
  31. package/src/host-api.ts +64 -0
  32. package/src/image-relay.ts +118 -0
  33. package/src/index.ts +575 -0
  34. package/src/invariant.ts +33 -0
  35. package/src/remote-host-api.ts +397 -0
  36. package/src/server.ts +658 -0
  37. package/src/session-deferral.ts +296 -0
  38. package/src/session-history.ts +220 -0
  39. package/src/session-purge.ts +154 -0
  40. package/src/session-workspace.ts +147 -0
  41. package/src/token.ts +100 -0
  42. package/src/tools.ts +301 -0
  43. package/src/vision-selfcheck.ts +35 -0
  44. package/src/vision.ts +135 -0
@@ -0,0 +1,385 @@
1
+ /**
2
+ * One authenticated extension connection's event streams, its active Session
3
+ * follower, and the forwarded Host waterfalls it answers.
4
+ *
5
+ * @module dsh-browser-application/src/event-generation
6
+ */
7
+
8
+ import type { RespondResult } from '@dsh-browser/protocol'
9
+ import { openWireStream, type TypertGatewayLike } from './dsh-gateway.ts'
10
+ import { isSessionSnapshot, type SessionSnapshot } from './session-history.ts'
11
+ import type { HostEventFrame } from './host-api.ts'
12
+ import { isRecord } from './host-api.ts'
13
+ import { ExtensionSessionRegistry, shouldBridgeOwnQuestion } from './extension-sessions.ts'
14
+
15
+ interface PendingQuestion {
16
+ readonly sessionId: string
17
+ settled: boolean
18
+ }
19
+
20
+ export type SendRemoteEventResult = (
21
+ clientId: string,
22
+ eventId: string,
23
+ outcome: RemoteEventOutcome,
24
+ signal: AbortSignal,
25
+ ) => Promise<void>
26
+
27
+ export type RemoteEventOutcome =
28
+ | { readonly kind: 'next' }
29
+ | { readonly kind: 'result'; readonly value?: unknown }
30
+ | {
31
+ readonly kind: 'rejected'
32
+ readonly error: {
33
+ readonly name: string
34
+ readonly message: string
35
+ readonly code?: string
36
+ readonly details?: unknown
37
+ }
38
+ }
39
+
40
+ /** One authenticated extension connection's event streams and active Session follower. */
41
+ export class EventGeneration {
42
+ private readonly lifetime = new AbortController()
43
+ private readonly signal: AbortSignal
44
+ private readonly queue = new AsyncEventQueue()
45
+ private readonly tasks = new Set<Promise<void>>()
46
+ private readonly pendingQuestions = new Map<string, PendingQuestion>()
47
+ private clientId: string | undefined
48
+ private followAbort: AbortController | undefined
49
+ private followedSessionId: string | undefined
50
+ private followRevision = 0
51
+ private disposed = false
52
+
53
+ constructor(
54
+ private readonly gateway: TypertGatewayLike,
55
+ private readonly sendResult: SendRemoteEventResult,
56
+ private readonly extensionSessions: ExtensionSessionRegistry,
57
+ private readonly onHistoryCursor: (sessionId: string, cursor: number) => void,
58
+ outerSignal: AbortSignal,
59
+ ) {
60
+ this.signal = AbortSignal.any([outerSignal, this.lifetime.signal])
61
+ }
62
+
63
+ start(): void {
64
+ this.track(this.pumpRemoteEvents())
65
+ }
66
+
67
+ events(): AsyncIterable<HostEventFrame> {
68
+ return this.queue.iterate(this.signal)
69
+ }
70
+
71
+ async openSessionHistory(
72
+ sessionId: string,
73
+ callSignal: AbortSignal,
74
+ maxMessages?: number,
75
+ ): Promise<SessionSnapshot> {
76
+ return this.openSessionFollow(sessionId, callSignal, maxMessages)
77
+ }
78
+
79
+ async ensureSessionFollow(sessionId: string, callSignal: AbortSignal): Promise<void> {
80
+ if (this.followedSessionId === sessionId && this.followAbort?.signal.aborted === false) return
81
+ await this.openSessionFollow(sessionId, callSignal)
82
+ }
83
+
84
+ async respond(rpcId: string, result: RespondResult, signal: AbortSignal): Promise<unknown> {
85
+ const pending = this.pendingQuestions.get(rpcId)
86
+ const clientId = this.clientId
87
+ if (pending === undefined || pending.settled || clientId === undefined) {
88
+ return { accepted: false, reason: 'not-pending' }
89
+ }
90
+ pending.settled = true
91
+ try {
92
+ await this.sendResult(clientId, rpcId, respondOutcome(result), AbortSignal.any([this.signal, signal]))
93
+ return { accepted: true }
94
+ } catch (error: unknown) {
95
+ pending.settled = false
96
+ throw error
97
+ }
98
+ }
99
+
100
+ async dispose(): Promise<void> {
101
+ if (this.disposed) return
102
+ this.disposed = true
103
+ this.followAbort?.abort(new Error('browser bridge event generation closed'))
104
+ this.lifetime.abort(new Error('browser bridge event generation closed'))
105
+ this.queue.end()
106
+ await Promise.all(this.tasks)
107
+ }
108
+
109
+ private async openSessionFollow(
110
+ sessionId: string,
111
+ callSignal: AbortSignal,
112
+ maxMessages?: number,
113
+ ): Promise<SessionSnapshot> {
114
+ const revision = ++this.followRevision
115
+ this.followAbort?.abort(new Error('browser bridge Session follower replaced'))
116
+ const controller = new AbortController()
117
+ this.followAbort = controller
118
+ this.followedSessionId = sessionId
119
+ const signal = AbortSignal.any([this.signal, callSignal, controller.signal])
120
+ try {
121
+ const source = await openWireStream(this.gateway,
122
+ 'session/follow',
123
+ {
124
+ args: {
125
+ request: {
126
+ address: { kind: 'session', sessionId },
127
+ assistantStream: true,
128
+ ...(maxMessages === undefined ? {} : { maxMessages }),
129
+ },
130
+ },
131
+ },
132
+ signal,
133
+ )
134
+ const iterator = source[Symbol.asyncIterator]()
135
+ const first = await iterator.next()
136
+ if (first.done || !isSessionSnapshot(first.value)) {
137
+ await iterator.return?.()
138
+ throw new TypeError('session/follow did not begin with a snapshot')
139
+ }
140
+ if (revision !== this.followRevision || signal.aborted) {
141
+ await iterator.return?.()
142
+ signal.throwIfAborted()
143
+ throw new Error('browser bridge Session follower was replaced while opening')
144
+ }
145
+ this.onHistoryCursor(sessionId, first.value.cursor)
146
+ const snapshotId = first.value.assistantStream === undefined ? undefined : crypto.randomUUID()
147
+ // Publish the reconnect prefix before any suffix chunks. RPC responses
148
+ // and pushed events can otherwise race, dropping the beginning of an
149
+ // already-running attempt when the panel reopens its history.
150
+ if (first.value.assistantStream !== undefined) {
151
+ this.queue.push({
152
+ rpcId: crypto.randomUUID(),
153
+ method: 'session/assistant-stream',
154
+ payload: {
155
+ sessionId,
156
+ snapshotId,
157
+ frame: { type: 'snapshot', baseline: first.value.assistantStream },
158
+ },
159
+ })
160
+ }
161
+ this.track(this.pumpSessionEvents(sessionId, revision, iterator, signal))
162
+ return {
163
+ cursor: first.value.cursor,
164
+ records: first.value.records,
165
+ hasMore: first.value.hasMore,
166
+ ...(first.value.projections === undefined ? {} : { projections: first.value.projections }),
167
+ ...(first.value.assistantStream === undefined ? {} : { assistantStream: first.value.assistantStream }),
168
+ ...(snapshotId === undefined ? {} : { snapshotId }),
169
+ }
170
+ } catch (error: unknown) {
171
+ if (revision === this.followRevision) {
172
+ this.followedSessionId = undefined
173
+ this.followAbort = undefined
174
+ }
175
+ throw error
176
+ }
177
+ }
178
+
179
+ private async pumpSessionEvents(
180
+ sessionId: string,
181
+ revision: number,
182
+ iterator: AsyncIterator<unknown>,
183
+ signal: AbortSignal,
184
+ ): Promise<void> {
185
+ try {
186
+ while (!signal.aborted) {
187
+ const next = await iterator.next()
188
+ // Abort is advisory to an AsyncIterator: a buffered frame may still
189
+ // resolve after this follower was replaced. Never let that stale
190
+ // generation update the extension's active/recent session state.
191
+ if (signal.aborted || revision !== this.followRevision) break
192
+ if (next.done) break
193
+ if (isRecord(next.value) && next.value.type === 'assistant-stream' && isRecord(next.value.frame)) {
194
+ this.queue.push({
195
+ rpcId: crypto.randomUUID(),
196
+ method: 'session/assistant-stream',
197
+ payload: { sessionId, frame: next.value.frame },
198
+ })
199
+ continue
200
+ }
201
+ if (!isSessionEventEntry(next.value)) {
202
+ throw new TypeError('session/follow emitted an invalid incremental frame')
203
+ }
204
+ const seq = next.value.event.seq
205
+ if (typeof seq === 'number') this.onHistoryCursor(sessionId, seq)
206
+ this.queue.push({
207
+ rpcId: crypto.randomUUID(),
208
+ method: 'session/event',
209
+ payload: { type: 'session/event', sessionId, event: next.value.event },
210
+ })
211
+ }
212
+ if (!signal.aborted && revision === this.followRevision) {
213
+ throw new Error('session/follow ended unexpectedly')
214
+ }
215
+ } catch (error: unknown) {
216
+ if (!signal.aborted && revision === this.followRevision) this.queue.fail(error)
217
+ } finally {
218
+ await iterator.return?.()
219
+ if (revision === this.followRevision) {
220
+ this.followedSessionId = undefined
221
+ this.followAbort = undefined
222
+ }
223
+ }
224
+ }
225
+
226
+ private async pumpRemoteEvents(): Promise<void> {
227
+ try {
228
+ const source = await openWireStream(this.gateway, '$events', { args: {} }, this.signal)
229
+ let ready = false
230
+ for await (const value of source) {
231
+ if (!ready) {
232
+ if (!isRemoteEventReady(value)) throw new TypeError('$events did not begin with ready')
233
+ this.clientId = value.clientId
234
+ ready = true
235
+ continue
236
+ }
237
+ await this.handleRemoteEvent(value)
238
+ }
239
+ if (!this.signal.aborted) throw new Error('$events ended unexpectedly')
240
+ } catch (error: unknown) {
241
+ if (!this.signal.aborted) this.queue.fail(error)
242
+ }
243
+ }
244
+
245
+ private async handleRemoteEvent(value: unknown): Promise<void> {
246
+ if (!isRecord(value) || typeof value.type !== 'string') {
247
+ throw new TypeError('$events emitted an invalid frame')
248
+ }
249
+ if (value.type === 'emit') return
250
+ if (value.type === 'cancel' && typeof value.eventId === 'string') {
251
+ const pending = this.pendingQuestions.get(value.eventId)
252
+ if (pending === undefined) return
253
+ this.pendingQuestions.delete(value.eventId)
254
+ this.queue.push({
255
+ rpcId: crypto.randomUUID(),
256
+ method: 'question/resolved',
257
+ payload: {
258
+ type: 'question/resolved',
259
+ sessionId: pending.sessionId,
260
+ questionRpcId: value.eventId,
261
+ },
262
+ })
263
+ return
264
+ }
265
+ if (value.type !== 'waterfall'
266
+ || typeof value.event !== 'string'
267
+ || typeof value.eventId !== 'string'
268
+ || typeof value.agentId !== 'string'
269
+ || !isRecord(value.request)) {
270
+ throw new TypeError('$events emitted an invalid waterfall frame')
271
+ }
272
+ if (value.event !== 'user-questions/request' || !Array.isArray(value.request.questions)) {
273
+ const clientId = this.clientId
274
+ if (clientId !== undefined) {
275
+ await this.sendResult(clientId, value.eventId, { kind: 'next' }, this.signal)
276
+ }
277
+ return
278
+ }
279
+ // Desktop-owned sessions keep the native waterfall. Only forward questions
280
+ // for sessions the extension successfully created or prompted.
281
+ if (!shouldBridgeOwnQuestion({
282
+ hasExtensionConnection: true,
283
+ sessionId: value.agentId,
284
+ extensionSessions: this.extensionSessions,
285
+ })) {
286
+ const clientId = this.clientId
287
+ if (clientId !== undefined) {
288
+ await this.sendResult(clientId, value.eventId, { kind: 'next' }, this.signal)
289
+ }
290
+ return
291
+ }
292
+ this.pendingQuestions.set(value.eventId, { sessionId: value.agentId, settled: false })
293
+ this.queue.push({
294
+ rpcId: value.eventId,
295
+ method: 'question/requested',
296
+ payload: {
297
+ type: 'question/requested',
298
+ sessionId: value.agentId,
299
+ questions: value.request.questions,
300
+ },
301
+ })
302
+ }
303
+
304
+ private track(task: Promise<void>): void {
305
+ const tracked = task.catch((error: unknown) => {
306
+ if (!this.signal.aborted) this.queue.fail(error)
307
+ })
308
+ this.tasks.add(tracked)
309
+ void tracked.finally(() => { this.tasks.delete(tracked) })
310
+ }
311
+ }
312
+
313
+ class AsyncEventQueue {
314
+ private readonly frames: HostEventFrame[] = []
315
+ private wake: (() => void) | undefined
316
+ private failure: unknown
317
+ private closed = false
318
+
319
+ push(frame: HostEventFrame): void {
320
+ if (this.closed || this.failure !== undefined) return
321
+ this.frames.push(frame)
322
+ this.wake?.()
323
+ }
324
+
325
+ fail(error: unknown): void {
326
+ if (this.closed || this.failure !== undefined) return
327
+ this.failure = error
328
+ this.wake?.()
329
+ }
330
+
331
+ end(): void {
332
+ if (this.closed) return
333
+ this.closed = true
334
+ this.wake?.()
335
+ }
336
+
337
+ async *iterate(signal: AbortSignal): AsyncGenerator<HostEventFrame> {
338
+ const onAbort = (): void => { this.wake?.() }
339
+ signal.addEventListener('abort', onAbort, { once: true })
340
+ try {
341
+ while (true) {
342
+ while (this.frames.length > 0) yield this.frames.shift() as HostEventFrame
343
+ if (this.failure !== undefined) throw this.failure
344
+ if (this.closed || signal.aborted) return
345
+ await new Promise<void>((resolve) => { this.wake = resolve })
346
+ this.wake = undefined
347
+ }
348
+ } finally {
349
+ signal.removeEventListener('abort', onAbort)
350
+ }
351
+ }
352
+ }
353
+
354
+ function respondOutcome(result: RespondResult): RemoteEventOutcome {
355
+ if (result.ok) {
356
+ const value = isRecord(result.value) && isRecord(result.value.answer)
357
+ ? result.value.answer
358
+ : result.value
359
+ return value === undefined ? { kind: 'result' } : { kind: 'result', value }
360
+ }
361
+ return {
362
+ kind: 'rejected',
363
+ error: {
364
+ name: 'Error',
365
+ message: result.error.message,
366
+ code: result.error.code,
367
+ details: result.error.details,
368
+ },
369
+ }
370
+ }
371
+
372
+ function isSessionEventEntry(value: unknown): value is {
373
+ readonly type: 'event'
374
+ readonly event: Record<string, unknown>
375
+ } {
376
+ return isRecord(value) && value.type === 'event' && isRecord(value.event)
377
+ }
378
+
379
+ function isRemoteEventReady(value: unknown): value is {
380
+ readonly type: 'ready'
381
+ readonly clientId: string
382
+ } {
383
+ return isRecord(value) && value.type === 'ready'
384
+ && typeof value.clientId === 'string' && value.clientId.length > 0
385
+ }
@@ -0,0 +1,40 @@
1
+ /**
2
+ * Track session ids that the browser extension has driven through the bridge.
3
+ * Desktop-native sessions must keep the host userQuestions waterfall so the
4
+ * Desktop UI can render ask_user_question cards.
5
+ * @module dsh-browser-application/src/extension-sessions
6
+ */
7
+
8
+ /** Mutable registry of extension-owned session ids. */
9
+ export class ExtensionSessionRegistry {
10
+ private readonly ids = new Set<string>()
11
+
12
+ /** Remember a session the extension successfully created or prompted. */
13
+ note(sessionId: string | undefined): void {
14
+ if (typeof sessionId === 'string' && sessionId.length > 0) this.ids.add(sessionId)
15
+ }
16
+
17
+ /** Whether the extension has touched this session over the bridge. */
18
+ has(sessionId: string | undefined): boolean {
19
+ return typeof sessionId === 'string' && this.ids.has(sessionId)
20
+ }
21
+
22
+ /** Test helper: drop all tracked ids. */
23
+ clear(): void {
24
+ this.ids.clear()
25
+ }
26
+ }
27
+
28
+ /**
29
+ * Decide whether the bridge should own ask_user_question for this request.
30
+ * Desktop sessions must fall through to the native answerer waterfall.
31
+ */
32
+ export function shouldBridgeOwnQuestion(input: {
33
+ hasExtensionConnection: boolean
34
+ sessionId: string | undefined
35
+ extensionSessions: Pick<ExtensionSessionRegistry, 'has'>
36
+ }): boolean {
37
+ return input.hasExtensionConnection
38
+ && input.sessionId !== undefined
39
+ && input.extensionSessions.has(input.sessionId)
40
+ }
@@ -0,0 +1,64 @@
1
+ /**
2
+ * Bridge-owned Host API consumed by the WebSocket carrier.
3
+ *
4
+ * This boundary keeps release-specific Host topology out of the browser wire
5
+ * server. dsh 0.1.5 implements it with Typert Remotes and Connection.
6
+ *
7
+ * @module
8
+ */
9
+
10
+ import type { RespondResult } from '@dsh-browser/protocol'
11
+
12
+ /** Stable failure envelope understood by the extension panel. */
13
+ export interface HostRpcFailure {
14
+ readonly code: string
15
+ readonly message: string
16
+ readonly details: object
17
+ }
18
+
19
+ /** Business result returned by the active dsh Host adapter. */
20
+ export type HostRpcResult<T = unknown> =
21
+ | { readonly ok: true; readonly value: T }
22
+ | { readonly ok: false; readonly error: HostRpcFailure }
23
+
24
+ /** One extension-originated unary call after WebSocket decoding. */
25
+ export interface HostRpcCall {
26
+ readonly rpcId: string
27
+ readonly method: string
28
+ readonly payload: unknown
29
+ readonly signal: AbortSignal
30
+ }
31
+
32
+ /** Event retained as the private bridge-to-extension protocol. */
33
+ export interface HostEventFrame {
34
+ readonly rpcId: string
35
+ readonly method: string
36
+ readonly payload: unknown
37
+ }
38
+
39
+ /** API surface the WebSocket carrier needs from a dsh Host. */
40
+ export interface BrowserHostApi {
41
+ call(call: HostRpcCall): Promise<HostRpcResult>
42
+ events(signal: AbortSignal): AsyncIterable<HostEventFrame>
43
+ respond(rpcId: string, result: RespondResult, signal: AbortSignal): Promise<unknown>
44
+ }
45
+
46
+ /** Convert an arbitrary Host rejection to the open wire failure vocabulary. */
47
+ export function hostFailure(error: unknown): HostRpcFailure {
48
+ if (isRecord(error)) {
49
+ const code = typeof error.code === 'string' ? error.code : 'internal'
50
+ const message = typeof error.message === 'string' ? error.message : String(error)
51
+ const details = isRecord(error.details) ? error.details : {}
52
+ return { code, message, details }
53
+ }
54
+ return {
55
+ code: 'internal',
56
+ message: error instanceof Error ? error.message : String(error),
57
+ details: {},
58
+ }
59
+ }
60
+
61
+ /** Narrow unknown JSON-like data without accepting arrays. */
62
+ export function isRecord(value: unknown): value is Record<string, unknown> {
63
+ return typeof value === 'object' && value !== null && !Array.isArray(value)
64
+ }
@@ -0,0 +1,118 @@
1
+ /**
2
+ * Serving the extension's image-recognition requests on the desktop side.
3
+ *
4
+ * The extension is asked for bytes first because only it carries the user's login
5
+ * state; when it could not fetch at all — its content-security policy, a host
6
+ * permission, or an enterprise rule — it sends the URL and the desktop tries with
7
+ * its own network stack instead. The two paths are complementary rather than
8
+ * redundant, which is why the frame carries a source rather than always one kind.
9
+ *
10
+ * The desktop has no image codec, so bytes it fetches itself are passed through
11
+ * unchanged once they are known to be an image and to be a sane size. Anything
12
+ * the extension already normalized arrives pre-scaled.
13
+ *
14
+ * @module
15
+ */
16
+
17
+ import type { ImageRecognitionRequest, ImageSource } from '@dsh-browser/protocol'
18
+ import type { VisionClient } from './vision.ts'
19
+
20
+ export type ImageRelayResult =
21
+ | { ok: true; desc: string }
22
+ | { ok: false; code: string; message: string }
23
+
24
+ /** Cap on bytes the desktop will pull from the network for one image. */
25
+ const MAX_RELAY_BYTES = 12_000_000
26
+ const RELAY_TIMEOUT_MS = 15_000
27
+
28
+ export class ImageRelay {
29
+ private readonly vision: VisionClient | undefined
30
+ private readonly fetchImpl: typeof fetch
31
+
32
+ constructor(vision: VisionClient | undefined, fetchImpl: typeof fetch = fetch) {
33
+ this.vision = vision
34
+ this.fetchImpl = fetchImpl
35
+ }
36
+
37
+ /** Whether a vision model is configured, which is what `hello.ok` advertises. */
38
+ get available(): boolean {
39
+ return this.vision !== undefined
40
+ }
41
+
42
+ /**
43
+ * Recognize one image for the extension.
44
+ *
45
+ * @param request - the page context the extension gathered.
46
+ * @param source - normalized bytes, or a URL for the desktop to fetch.
47
+ * @returns a one-line description, or a classified failure.
48
+ */
49
+ async recognize(request: ImageRecognitionRequest, source: ImageSource): Promise<ImageRelayResult> {
50
+ const vision = this.vision
51
+ if (vision === undefined) {
52
+ return { ok: false, code: 'no-vision', message: 'no vision model is configured on the desktop' }
53
+ }
54
+
55
+ let bytes: { base64: string; mediaType: string }
56
+ if (source.kind === 'bytes') {
57
+ if (source.base64.length > MAX_RELAY_BYTES) {
58
+ return { ok: false, code: 'too-large', message: `${String(source.base64.length)} base64 characters` }
59
+ }
60
+ bytes = { base64: source.base64, mediaType: source.mediaType }
61
+ } else {
62
+ const fetched = await this.fetchByUrl(source.url)
63
+ if (!fetched.ok) return fetched
64
+ bytes = fetched
65
+ }
66
+
67
+ const result = await vision.describe({
68
+ base64: bytes.base64,
69
+ mediaType: bytes.mediaType,
70
+ context: {
71
+ alt: request.alt,
72
+ near: request.near,
73
+ heading: request.heading,
74
+ kind: request.kind,
75
+ // The tier travels with the request so the relayed call asks exactly what
76
+ // the extension's own call would have asked. Omitted rather than set to
77
+ // undefined, which `exactOptionalPropertyTypes` rejects.
78
+ ...request.tier === undefined ? {} : { tier: request.tier },
79
+ },
80
+ })
81
+ return result.ok
82
+ ? { ok: true, desc: result.desc }
83
+ : { ok: false, code: result.code, message: result.message }
84
+ }
85
+
86
+ /** The desktop's own attempt, for when the extension's fetch did not work. */
87
+ private async fetchByUrl(
88
+ url: string,
89
+ ): Promise<{ ok: true; base64: string; mediaType: string } | { ok: false; code: string; message: string }> {
90
+ if (!/^https?:/i.test(url)) return { ok: false, code: 'bad-url', message: `unsupported scheme: ${url.slice(0, 40)}` }
91
+ let response: Response
92
+ try {
93
+ response = await this.fetchImpl(url, {
94
+ redirect: 'follow',
95
+ signal: AbortSignal.timeout(RELAY_TIMEOUT_MS),
96
+ })
97
+ } catch (error: unknown) {
98
+ return { ok: false, code: 'relay-network', message: error instanceof Error ? error.message : String(error) }
99
+ }
100
+ if (!response.ok) return { ok: false, code: 'relay-http', message: String(response.status) }
101
+
102
+ const mediaType = (response.headers.get('content-type') ?? '').split(';')[0]?.trim().toLowerCase() ?? ''
103
+ if (!mediaType.startsWith('image/')) {
104
+ return { ok: false, code: 'not-an-image', message: mediaType === '' ? 'no content-type' : mediaType }
105
+ }
106
+
107
+ let buffer: ArrayBuffer
108
+ try {
109
+ buffer = await response.arrayBuffer()
110
+ } catch (error: unknown) {
111
+ return { ok: false, code: 'relay-network', message: error instanceof Error ? error.message : String(error) }
112
+ }
113
+ if (buffer.byteLength > MAX_RELAY_BYTES) {
114
+ return { ok: false, code: 'too-large', message: String(buffer.byteLength) }
115
+ }
116
+ return { ok: true, base64: Buffer.from(buffer).toString('base64'), mediaType }
117
+ }
118
+ }