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.
- package/LICENSE +19 -0
- package/README.md +27 -0
- package/cordis.patch.yml +29 -0
- package/lib/index.js +3377 -0
- package/lib/invariant.js +26 -0
- package/lib/types/bridge-url.d.ts +27 -0
- package/lib/types/browser-context.d.ts +38 -0
- package/lib/types/dsh-gateway.d.ts +42 -0
- package/lib/types/event-generation.d.ts +56 -0
- package/lib/types/extension-sessions.d.ts +26 -0
- package/lib/types/host-api.d.ts +47 -0
- package/lib/types/image-relay.d.ts +43 -0
- package/lib/types/index.d.ts +158 -0
- package/lib/types/invariant.d.ts +16 -0
- package/lib/types/remote-host-api.d.ts +12 -0
- package/lib/types/server.d.ts +166 -0
- package/lib/types/session-deferral.d.ts +33 -0
- package/lib/types/session-history.d.ts +30 -0
- package/lib/types/session-purge.d.ts +55 -0
- package/lib/types/session-workspace.d.ts +37 -0
- package/lib/types/token.d.ts +57 -0
- package/lib/types/tools.d.ts +42 -0
- package/lib/types/vision-selfcheck.d.ts +18 -0
- package/lib/types/vision.d.ts +57 -0
- package/package.json +95 -0
- package/src/bridge-url.ts +57 -0
- package/src/browser-context.ts +102 -0
- package/src/dsh-gateway.ts +66 -0
- package/src/event-generation.ts +385 -0
- package/src/extension-sessions.ts +40 -0
- package/src/host-api.ts +64 -0
- package/src/image-relay.ts +118 -0
- package/src/index.ts +575 -0
- package/src/invariant.ts +33 -0
- package/src/remote-host-api.ts +397 -0
- package/src/server.ts +658 -0
- package/src/session-deferral.ts +296 -0
- package/src/session-history.ts +220 -0
- package/src/session-purge.ts +154 -0
- package/src/session-workspace.ts +147 -0
- package/src/token.ts +100 -0
- package/src/tools.ts +301 -0
- package/src/vision-selfcheck.ts +35 -0
- 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
|
+
}
|
package/src/host-api.ts
ADDED
|
@@ -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
|
+
}
|