From 3f6b5e177fcdd0b31d0ff7aef4d18f3788b02cea Mon Sep 17 00:00:00 2001 From: zhaozian <1161954287@qq.com> Date: Sun, 18 Oct 2026 20:36:34 +0800 Subject: [PATCH 1/4] fix(bridge): pass owner Peer to DSH 0.2.1 Gateway streams DSH 0.2.1-rc.2 requires a live Peer on wireStream.open, invoke, and shared-fetch. Passing undefined made $events throw on peer.signal after hello.ok, closing the extension WebSocket with 1011 stream-failed. --- .../bridge-browser/src/remote-host-api.ts | 72 +++++++++++++++---- .../bridge-browser/tests/composition.spec.ts | 5 ++ .../bridge-browser/tests/index.spec.ts | 5 ++ .../tests/remote-host-api.spec.ts | 25 ++++--- 4 files changed, 84 insertions(+), 23 deletions(-) diff --git a/packages/browser/bridge-browser/src/remote-host-api.ts b/packages/browser/bridge-browser/src/remote-host-api.ts index e620d517e..1507aed54 100644 --- a/packages/browser/bridge-browser/src/remote-host-api.ts +++ b/packages/browser/bridge-browser/src/remote-host-api.ts @@ -23,6 +23,24 @@ import { } from './extension-sessions.ts' import type { RespondResult } from './protocol.ts' +/** + * Structural subset of dsh 0.2.1+'s Connection Peer. + * + * Gateway `wireStream.open` / `invoke` and Connection shared-fetch all require + * a live Peer; `$events` additionally needs `interactions: "answer"`. + */ +export interface HostPeerLike { + readonly signal: AbortSignal + readonly principal: { readonly kind: string } + readonly interactions: string +} + +/** Options accepted by Connection.createPeer for an in-process owner Peer. */ +export interface HostPeerCreateOptions { + readonly principal: { readonly kind: 'owner' } + readonly interactions?: 'answer' | 'none' +} + /** Structural subset of dsh 0.2's Host TypertGateway service. */ export interface TypertGatewayLike { readonly wireStream: { @@ -34,7 +52,7 @@ export interface TypertGatewayLike { endpoint: string, payload: unknown, uplinkOrSignal: AsyncIterable | AbortSignal, - peer?: unknown, + peer?: HostPeerLike, signal?: AbortSignal, ): Promise> failure(error: unknown): HostRpcFailure @@ -43,6 +61,7 @@ export interface TypertGatewayLike { readonly namespace: string readonly method: string readonly args: Readonly> + readonly peer: HostPeerLike readonly signal?: AbortSignal }): Promise } @@ -60,24 +79,33 @@ const EMPTY_WIRE_UPLINK: AsyncIterable = { * Open a Host wire stream against either dsh 0.2 or the legacy three-arg form. * * - arity 3: composition/unit stubs still use `(endpoint, payload, signal)`. - * - arity 5: real dsh 0.2 TypertGatewayWireStream. + * - arity 5: real dsh 0.2 TypertGatewayWireStream (peer is required on 0.2.1+). * - arity 0: Cordis/service wrappers — must use the five-arg call. Treating * these as three-arg maps AbortSignal onto uplink and leaves signal * undefined (hello.ok → stream-failed → WS 1011). */ -function openWireStream(gateway: TypertGatewayLike, endpoint: string, payload: unknown, signal: AbortSignal): Promise> { +function openWireStream( + gateway: TypertGatewayLike, + endpoint: string, + payload: unknown, + peer: HostPeerLike, + signal: AbortSignal, +): Promise> { const open = gateway.wireStream.open if (open.length === 3) { return open(endpoint, payload, signal) } - return open(endpoint, payload, EMPTY_WIRE_UPLINK, undefined, signal) + return open(endpoint, payload, EMPTY_WIRE_UPLINK, peer, signal) } /** Structural subset of dsh 0.2's Host Connection service. */ export interface HostConnectionLike { createSharedFetchHandler(channel: '/api'): { - fetch(request: Request): Promise + /** dsh 0.2.1+: peer is required; older stubs may ignore the second arg. */ + fetch(request: Request, peer?: HostPeerLike): Promise } + /** dsh 0.2.1+: mint an in-process Peer for Gateway / shared-fetch calls. */ + createPeer(options: HostPeerCreateOptions): HostPeerLike } interface InvokeTarget { @@ -112,6 +140,7 @@ export function createRemoteHostApi( class RemoteHostApi implements BrowserHostApi { private readonly fetchHandler: ReturnType + private readonly peer: HostPeerLike private readonly extensionSessions = new ExtensionSessionRegistry() /** Last known session/follow tip per Session; drives session/page throughSeq. */ private readonly historyCursors = new Map() @@ -122,6 +151,12 @@ class RemoteHostApi implements BrowserHostApi { connection: HostConnectionLike, ) { this.fetchHandler = connection.createSharedFetchHandler('/api') + // 0.2.1+ Gateway refuses wireStream/invoke without a live Peer; $events + // further requires interactions=answer (Peer default when omitted). + this.peer = connection.createPeer({ + principal: { kind: 'owner' }, + interactions: 'answer', + }) } async call(call: HostRpcCall): Promise { @@ -144,6 +179,7 @@ class RemoteHostApi implements BrowserHostApi { namespace: target.namespace, method: target.method, args: target.args, + peer: this.peer, signal: call.signal, }) // Only claim ownership after a successful create/prompt. A failed prompt @@ -163,6 +199,7 @@ class RemoteHostApi implements BrowserHostApi { async *events(signal: AbortSignal): AsyncIterable { const generation = new EventGeneration( this.gateway, + this.peer, this.sendRemoteEventResult.bind(this), this.extensionSessions, this.noteHistoryCursor.bind(this), @@ -211,13 +248,14 @@ class RemoteHostApi implements BrowserHostApi { ...(maxMessages === undefined ? {} : { maxMessages }), }, }, + peer: this.peer, signal: call.signal, }) return { ok: true, value: historyPageValue(page) } } const snapshot = this.activeEvents === undefined - ? await oneShotSessionSnapshot(this.gateway, sessionId, call.signal, maxMessages) + ? await oneShotSessionSnapshot(this.gateway, this.peer, sessionId, call.signal, maxMessages) : await this.activeEvents.openSessionHistory(sessionId, call.signal, maxMessages) this.noteHistoryCursor(sessionId, snapshot.cursor) return { ok: true, value: historyValue(snapshot) } @@ -232,11 +270,11 @@ class RemoteHostApi implements BrowserHostApi { const sessionId = sessionIdOf(call.payload) try { const catalog = await this.gateway.invoke({ - namespace: 'session', method: 'modelCatalog', args: {}, signal: call.signal, + namespace: 'session', method: 'modelCatalog', args: {}, peer: this.peer, signal: call.signal, }) // Provisional Sessions request only the catalog and overlay their pending choice. const projections = sessionId === undefined ? undefined : await this.gateway.invoke({ - namespace: 'session', method: 'projections', args: { request: { sessionId } }, signal: call.signal, + namespace: 'session', method: 'projections', args: { request: { sessionId } }, peer: this.peer, signal: call.signal, }) const values = isRecord(projections) ? projections.values : undefined const modelSelection = isRecord(values) ? values.modelSelection : undefined @@ -255,7 +293,7 @@ class RemoteHostApi implements BrowserHostApi { const cached = this.historyCursors.get(sessionId) if (cached !== undefined) return cached const snapshot = this.activeEvents === undefined - ? await oneShotSessionSnapshot(this.gateway, sessionId, signal) + ? await oneShotSessionSnapshot(this.gateway, this.peer, sessionId, signal) : await this.activeEvents.openSessionHistory(sessionId, signal) this.noteHistoryCursor(sessionId, snapshot.cursor) const throughSeq = this.historyCursors.get(sessionId) @@ -277,7 +315,7 @@ class RemoteHostApi implements BrowserHostApi { try { const controller = new AbortController() const signal = AbortSignal.any([call.signal, controller.signal]) - const source = await openWireStream(this.gateway, 'workspace/follow', { args: {} }, signal) + const source = await openWireStream(this.gateway, 'workspace/follow', { args: {} }, this.peer, signal) const iterator = source[Symbol.asyncIterator]() try { const first = await iterator.next() @@ -320,7 +358,7 @@ class RemoteHostApi implements BrowserHostApi { }), signal, }) - const response = await this.fetchHandler.fetch(request) + const response = await this.fetchHandler.fetch(request, this.peer) if (!response.ok) { throw new Error(`$events/result transport failed with HTTP ${String(response.status)}: ${await response.text()}`) } @@ -376,6 +414,7 @@ class EventGeneration { constructor( private readonly gateway: TypertGatewayLike, + private readonly peer: HostPeerLike, private readonly sendResult: SendRemoteEventResult, private readonly extensionSessions: ExtensionSessionRegistry, private readonly onHistoryCursor: (sessionId: string, cursor: number) => void, @@ -442,7 +481,8 @@ class EventGeneration { this.followedSessionId = sessionId const signal = AbortSignal.any([this.signal, callSignal, controller.signal]) try { - const source = await openWireStream(this.gateway, + const source = await openWireStream( + this.gateway, 'session/follow', { args: { @@ -453,6 +493,7 @@ class EventGeneration { }, }, }, + this.peer, signal, ) const iterator = source[Symbol.asyncIterator]() @@ -549,7 +590,7 @@ class EventGeneration { private async pumpRemoteEvents(): Promise { try { - const source = await openWireStream(this.gateway, '$events', { args: {} }, this.signal) + const source = await openWireStream(this.gateway, '$events', { args: {} }, this.peer, this.signal) let ready = false for await (const value of source) { if (!ready) { @@ -736,13 +777,15 @@ function invokeTarget(call: HostRpcCall): InvokeTarget | { readonly error: HostR async function oneShotSessionSnapshot( gateway: TypertGatewayLike, + peer: HostPeerLike, sessionId: string, outerSignal: AbortSignal, maxMessages?: number, ): Promise { const controller = new AbortController() const signal = AbortSignal.any([outerSignal, controller.signal]) - const source = await openWireStream(gateway, + const source = await openWireStream( + gateway, 'session/follow', { args: { @@ -753,6 +796,7 @@ async function oneShotSessionSnapshot( }, }, }, + peer, signal, ) const iterator = source[Symbol.asyncIterator]() diff --git a/packages/browser/bridge-browser/tests/composition.spec.ts b/packages/browser/bridge-browser/tests/composition.spec.ts index 34961fbe0..3ad32b962 100644 --- a/packages/browser/bridge-browser/tests/composition.spec.ts +++ b/packages/browser/bridge-browser/tests/composition.spec.ts @@ -109,6 +109,11 @@ const RemoteApiHost = { }) }, }), + createPeer: () => ({ + signal: new AbortController().signal, + principal: { kind: 'owner' as const }, + interactions: 'answer', + }), } ctx.provide('typertGateway' as never, gateway as never) ctx.provide('connection' as never, connection as never) diff --git a/packages/browser/bridge-browser/tests/index.spec.ts b/packages/browser/bridge-browser/tests/index.spec.ts index 08d0ee652..3fc05290d 100644 --- a/packages/browser/bridge-browser/tests/index.spec.ts +++ b/packages/browser/bridge-browser/tests/index.spec.ts @@ -19,6 +19,11 @@ function stubContext(): Context { } const connection = { createSharedFetchHandler: () => ({ fetch: async () => new Response('not found', { status: 404 }) }), + createPeer: () => ({ + signal: new AbortController().signal, + principal: { kind: 'owner' }, + interactions: 'answer', + }), } return { webServer: { port: 0, registerUpgrade: () => () => {}, register: () => () => {} }, diff --git a/packages/browser/bridge-browser/tests/remote-host-api.spec.ts b/packages/browser/bridge-browser/tests/remote-host-api.spec.ts index 471e1c82e..0998322ad 100644 --- a/packages/browser/bridge-browser/tests/remote-host-api.spec.ts +++ b/packages/browser/bridge-browser/tests/remote-host-api.spec.ts @@ -44,10 +44,16 @@ function harness(options: { }, }, } + const peer = { + signal: new AbortController().signal, + principal: { kind: 'owner' as const }, + interactions: 'answer', + } const connection: HostConnectionLike = { createSharedFetchHandler: () => ({ fetch }), + createPeer: () => peer, } - return { api: createRemoteHostApi(gateway, connection), invoke, open, fetch, requestBodies } + return { api: createRemoteHostApi(gateway, connection), invoke, open, fetch, requestBodies, peer } } describe('dsh 0.2 Remote Host adapter', () => { @@ -84,20 +90,21 @@ describe('dsh 0.2 Remote Host adapter', () => { }) }) - it('passes the signal in the fifth argument for DSH Desktop 2.x streams', async () => { + it('passes the owner Peer and signal for DSH 0.2.1+ streams', async () => { const open = vi.fn(async (_endpoint: string, _payload: unknown, uplink: AsyncIterable, peer: unknown, signal: AbortSignal) => { expect(typeof uplink[Symbol.asyncIterator]).toBe('function') - expect(peer).toBeUndefined() + expect(peer).toMatchObject({ principal: { kind: 'owner' }, interactions: 'answer' }) expect(signal).toBeInstanceOf(AbortSignal) return { async *[Symbol.asyncIterator]() { yield { type: 'ready', clientId: 'test', host: {} }; await abortWait(signal) } } }) - const { api } = harness({ open }) + const { api, peer: expectedPeer } = harness({ open }) const controller = new AbortController() const iterator = api.events(controller.signal)[Symbol.asyncIterator]() const pending = iterator.next() await vi.waitFor(() => expect(open).toHaveBeenCalled()) controller.abort() await pending.catch(() => undefined) + expect(open.mock.calls[0]?.[3]).toBe(expectedPeer) expect(open.mock.calls[0]?.[4]).toBeInstanceOf(AbortSignal) }) it('preserves V3 durable streams and orders reconnect baselines before transient frames without advancing the durable cursor', async () => { @@ -269,7 +276,7 @@ describe('dsh 0.2 Remote Host adapter', () => { const abort = new AbortController() const events = api.events(abort.signal)[Symbol.asyncIterator]() const live = events.next() - await vi.waitFor(() => { expect(open).toHaveBeenCalledWith('$events', { args: {} }, expect.anything(), undefined, expect.any(AbortSignal)) }) + await vi.waitFor(() => { expect(open).toHaveBeenCalledWith('$events', { args: {} }, expect.anything(), expect.objectContaining({ principal: { kind: 'owner' }, interactions: 'answer' }), expect.any(AbortSignal)) }) await expect(api.call(call('session.history', { sessionId: 'session-1', @@ -321,7 +328,7 @@ describe('dsh 0.2 Remote Host adapter', () => { assistantStream: true, }, }, - }, expect.anything(), undefined, expect.any(AbortSignal)) + }, expect.anything(), expect.objectContaining({ principal: { kind: 'owner' }, interactions: 'answer' }), expect.any(AbortSignal)) abort.abort() await events.return?.() }) @@ -353,7 +360,7 @@ describe('dsh 0.2 Remote Host adapter', () => { assistantStream: true, }, }, - }, expect.anything(), undefined, expect.any(AbortSignal)) + }, expect.anything(), expect.objectContaining({ principal: { kind: 'owner' }, interactions: 'answer' }), expect.any(AbortSignal)) }) it('rejects invalid history pagination before calling the Host', async () => { @@ -419,7 +426,7 @@ describe('dsh 0.2 Remote Host adapter', () => { }) expect(open).toHaveBeenCalledWith('session/follow', { args: { request: { address: { kind: 'session', sessionId: 'session-1' }, assistantStream: true } }, - }, expect.anything(), undefined, expect.any(AbortSignal)) + }, expect.anything(), expect.objectContaining({ principal: { kind: 'owner' }, interactions: 'answer' }), expect.any(AbortSignal)) expect(invoke).toHaveBeenCalledOnce() const pageArgs = invoke.mock.calls[0]?.[0]?.args as { request: { throughSeq: number } } expect(pageArgs.request.throughSeq).toBe(42) @@ -577,7 +584,7 @@ describe('dsh 0.2 Remote Host adapter', () => { ok: true, value: { items: [{ workspaceId: 'w1' }], archivedSessionIds: ['s1'] }, }) - expect(open).toHaveBeenCalledWith('workspace/follow', { args: {} }, expect.anything(), undefined, expect.any(AbortSignal)) + expect(open).toHaveBeenCalledWith('workspace/follow', { args: {} }, expect.anything(), expect.objectContaining({ principal: { kind: 'owner' }, interactions: 'answer' }), expect.any(AbortSignal)) }) it('bridges user questions through $events/result and unwraps the extension answer', async () => { From a39f93000625ac4b481450bd340cb487399a88bc Mon Sep 17 00:00:00 2001 From: zhaozian <1161954287@qq.com> Date: Sun, 18 Oct 2026 20:46:12 +0800 Subject: [PATCH 2/4] fix(browser): stub createPeer in panel session transition test HostConnectionLike now requires createPeer for DSH 0.2.1; update the extension typecheck fixture so CI passes. --- .../dsh-browser/tests/panel-session-transition.spec.ts | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/extensions/dsh-browser/tests/panel-session-transition.spec.ts b/extensions/dsh-browser/tests/panel-session-transition.spec.ts index 9c2c4aa1f..498bd35d0 100644 --- a/extensions/dsh-browser/tests/panel-session-transition.spec.ts +++ b/extensions/dsh-browser/tests/panel-session-transition.spec.ts @@ -114,7 +114,14 @@ describe('panel session transitions', () => { open: async () => { throw new Error('unexpected stream') }, failure: (error) => ({ code: 'internal', message: String(error), details: {} }), }, - }, { createSharedFetchHandler: () => ({ fetch: async () => { throw new Error('unexpected fetch') } }) }) + }, { + createSharedFetchHandler: () => ({ fetch: async () => { throw new Error('unexpected fetch') } }), + createPeer: () => ({ + signal: new AbortController().signal, + principal: { kind: 'owner' as const }, + interactions: 'answer', + }), + }) const original = rpc.getMockImplementation()! rpc.mockImplementation(async (method, payload) => { if (method !== 'session.models' && method !== 'session.selectModel') return original(method, payload) From 58f61e995e41f73e330e2b36b5011ebab0acc45f Mon Sep 17 00:00:00 2001 From: zhaozian <1161954287@qq.com> Date: Sun, 18 Oct 2026 20:53:00 +0800 Subject: [PATCH 3/4] fix(browser): dispose selection watchers before restoring real timers Pending SelectionWatcher debounce callbacks were surviving afterEach vi.useRealTimers() and throwing document is not defined after jsdom teardown, which Vitest reported as unhandled errors. --- .../tests/content-selection.spec.ts | 40 +++++++++++++------ 1 file changed, 27 insertions(+), 13 deletions(-) diff --git a/extensions/dsh-browser/tests/content-selection.spec.ts b/extensions/dsh-browser/tests/content-selection.spec.ts index 74e131d74..d9f03f702 100644 --- a/extensions/dsh-browser/tests/content-selection.spec.ts +++ b/extensions/dsh-browser/tests/content-selection.spec.ts @@ -1,6 +1,6 @@ // @vitest-environment jsdom import { afterEach, describe, expect, it, vi } from 'vitest' -import { MAX_SELECTION_CHARS } from '../src/selection.ts' +import { MAX_SELECTION_CHARS, type SelectionCapture } from '../src/selection.ts' import { SelectionWatcher, readSelectionCapture } from '../src/content/selection.ts' function selectText(value: string, anchor: Node | null = document.body): void { @@ -23,7 +23,21 @@ function setUserGesture(active: boolean): void { }) } +/** Watchers kept alive across an assertion so afterEach can disarm debounce timers. */ +const liveWatchers = new Set() + +function watch(emit: (capture: SelectionCapture) => void, settleMs = 10): SelectionWatcher { + const watcher = new SelectionWatcher(emit, settleMs) + liveWatchers.add(watcher) + return watcher +} + afterEach(() => { + // Disarm before restoring real timers: pending debounce callbacks otherwise + // fire after jsdom is gone and throw `document is not defined`. + for (const watcher of liveWatchers) watcher.dispose() + liveWatchers.clear() + vi.clearAllTimers() document.body.innerHTML = '' document.title = '' Reflect.deleteProperty(navigator, 'userActivation') @@ -99,7 +113,7 @@ describe('selection watcher', () => { it('ignores a selection the page moved without a user gesture', () => { vi.useFakeTimers() const emit = vi.fn() - const watcher = new SelectionWatcher(emit, 10) + const watcher = watch(emit, 10) watcher.setEnabled(true) // An iframe calling getSelection().selectAllChildren() looks like this. @@ -119,7 +133,7 @@ describe('selection watcher', () => { it('reports the same passage again after the panel dropped it', () => { vi.useFakeTimers() const emit = vi.fn() - const watcher = new SelectionWatcher(emit, 10) + const watcher = watch(emit, 10) watcher.setEnabled(true) selectText('quoted text') @@ -137,7 +151,7 @@ describe('selection watcher', () => { it('ignores an arm command older than the one already applied', () => { vi.useFakeTimers() const emit = vi.fn() - const watcher = new SelectionWatcher(emit, 10) + const watcher = watch(emit, 10) expect(watcher.setEnabled(true, 5, 'worker-a')).toBe(true) // A slow DSH_CONTENT_READY reply computed before the panel opened. @@ -153,7 +167,7 @@ describe('selection watcher', () => { it('accepts a fresh worker even when its revision counter restarted', () => { vi.useFakeTimers() const emit = vi.fn() - const watcher = new SelectionWatcher(emit, 10) + const watcher = watch(emit, 10) expect(watcher.setEnabled(true, 5, 'worker-a')).toBe(true) expect(watcher.setEnabled(false, 0, 'worker-b')).toBe(true) @@ -167,7 +181,7 @@ describe('selection watcher', () => { it('stays silent until a panel arms it', () => { vi.useFakeTimers() const emit = vi.fn() - const watcher = new SelectionWatcher(emit, 10) + const watcher = watch(emit, 10) selectText('quoted text') document.dispatchEvent(new Event('selectionchange')) @@ -184,7 +198,7 @@ describe('selection watcher', () => { it('reports a highlight made before the panel opened', () => { vi.useFakeTimers() const emit = vi.fn() - const watcher = new SelectionWatcher(emit, 10) + const watcher = watch(emit, 10) selectText('highlighted before opening') watcher.setEnabled(true) @@ -197,7 +211,7 @@ describe('selection watcher', () => { it('emits once for a drag that fires many selection changes', () => { vi.useFakeTimers() const emit = vi.fn() - const watcher = new SelectionWatcher(emit, 10) + const watcher = watch(emit, 10) watcher.setEnabled(true) for (const partial of ['q', 'qu', 'quoted text']) { @@ -214,7 +228,7 @@ describe('selection watcher', () => { it('keeps the captured quote when the user clears the highlight', () => { vi.useFakeTimers() const emit = vi.fn() - const watcher = new SelectionWatcher(emit, 10) + const watcher = watch(emit, 10) watcher.setEnabled(true) selectText('quoted text') @@ -230,7 +244,7 @@ describe('selection watcher', () => { it('reports the same passage after the highlight was cleared', () => { vi.useFakeTimers() const emit = vi.fn() - const watcher = new SelectionWatcher(emit, 10) + const watcher = watch(emit, 10) watcher.setEnabled(true) selectText('quoted text') @@ -249,7 +263,7 @@ describe('selection watcher', () => { it('reports when the same captured prefix becomes truncated', () => { vi.useFakeTimers() const emit = vi.fn() - const watcher = new SelectionWatcher(emit, 10) + const watcher = watch(emit, 10) watcher.setEnabled(true) selectText('x'.repeat(MAX_SELECTION_CHARS)) @@ -266,7 +280,7 @@ describe('selection watcher', () => { it('does not resend an unchanged highlight', () => { vi.useFakeTimers() const emit = vi.fn() - const watcher = new SelectionWatcher(emit, 10) + const watcher = watch(emit, 10) watcher.setEnabled(true) selectText('quoted text') @@ -281,7 +295,7 @@ describe('selection watcher', () => { it('releases the page listener when it is disarmed', () => { vi.useFakeTimers() const emit = vi.fn() - const watcher = new SelectionWatcher(emit, 10) + const watcher = watch(emit, 10) watcher.setEnabled(true) watcher.dispose() From 2826d57b14c25b4783a8f4dae49778615a842dc8 Mon Sep 17 00:00:00 2001 From: zhaozian <1161954287@qq.com> Date: Sun, 18 Oct 2026 21:00:57 +0800 Subject: [PATCH 4/4] fix(bridge): tolerate Hosts without connection.createPeer Smoke and dsh 0.2.0-rc.2 Connection lack createPeer. Use it when present (0.2.1+), otherwise fall back to a local owner Peer stand-in so the plugin still activates. --- .../bridge-browser/src/remote-host-api.ts | 32 +++++++++++++++---- .../tests/remote-host-api.spec.ts | 26 +++++++++++++++ 2 files changed, 51 insertions(+), 7 deletions(-) diff --git a/packages/browser/bridge-browser/src/remote-host-api.ts b/packages/browser/bridge-browser/src/remote-host-api.ts index 1507aed54..0fca3224a 100644 --- a/packages/browser/bridge-browser/src/remote-host-api.ts +++ b/packages/browser/bridge-browser/src/remote-host-api.ts @@ -104,8 +104,29 @@ export interface HostConnectionLike { /** dsh 0.2.1+: peer is required; older stubs may ignore the second arg. */ fetch(request: Request, peer?: HostPeerLike): Promise } - /** dsh 0.2.1+: mint an in-process Peer for Gateway / shared-fetch calls. */ - createPeer(options: HostPeerCreateOptions): HostPeerLike + /** + * dsh 0.2.1+: mint an in-process Peer for Gateway / shared-fetch calls. + * Optional so 0.2.0-rc.x Hosts (no createPeer) still load; those get a + * local stand-in Peer below. + */ + createPeer?(options: HostPeerCreateOptions): HostPeerLike +} + +/** Owner Peer for Gateway calls: real Connection Peer when available. */ +function resolveOwnerPeer(connection: HostConnectionLike): HostPeerLike { + if (typeof connection.createPeer === 'function') { + return connection.createPeer({ + principal: { kind: 'owner' }, + interactions: 'answer', + }) + } + // dsh ≤0.2.0-rc.2 Connection has no createPeer; Gateway ignored peer there. + // Keep a structural stand-in so call sites can always pass a Peer object. + return { + signal: new AbortController().signal, + principal: { kind: 'owner' }, + interactions: 'answer', + } } interface InvokeTarget { @@ -152,11 +173,8 @@ class RemoteHostApi implements BrowserHostApi { ) { this.fetchHandler = connection.createSharedFetchHandler('/api') // 0.2.1+ Gateway refuses wireStream/invoke without a live Peer; $events - // further requires interactions=answer (Peer default when omitted). - this.peer = connection.createPeer({ - principal: { kind: 'owner' }, - interactions: 'answer', - }) + // further requires interactions=answer. Older Hosts omit createPeer. + this.peer = resolveOwnerPeer(connection) } async call(call: HostRpcCall): Promise { diff --git a/packages/browser/bridge-browser/tests/remote-host-api.spec.ts b/packages/browser/bridge-browser/tests/remote-host-api.spec.ts index 0998322ad..59c143fc3 100644 --- a/packages/browser/bridge-browser/tests/remote-host-api.spec.ts +++ b/packages/browser/bridge-browser/tests/remote-host-api.spec.ts @@ -107,6 +107,32 @@ describe('dsh 0.2 Remote Host adapter', () => { expect(open.mock.calls[0]?.[3]).toBe(expectedPeer) expect(open.mock.calls[0]?.[4]).toBeInstanceOf(AbortSignal) }) + + it('still mounts when Connection has no createPeer (dsh 0.2.0-rc.x)', async () => { + const open = vi.fn(async (_endpoint: string, _payload: unknown, _uplink: unknown, peer: unknown, signal: AbortSignal) => { + expect(peer).toMatchObject({ principal: { kind: 'owner' }, interactions: 'answer' }) + return { async *[Symbol.asyncIterator]() { yield { type: 'ready', clientId: 'legacy', host: {} }; await abortWait(signal) } } + }) + const gateway: TypertGatewayLike = { + invoke: vi.fn(async () => ({})), + wireStream: { + open, + failure: (error: unknown) => ({ code: 'internal', message: String(error), details: {} }), + }, + } + const connection: HostConnectionLike = { + createSharedFetchHandler: () => ({ + fetch: async () => new Response('not found', { status: 404 }), + }), + } + const api = createRemoteHostApi(gateway, connection) + const controller = new AbortController() + const iterator = api.events(controller.signal)[Symbol.asyncIterator]() + const pending = iterator.next() + await vi.waitFor(() => expect(open).toHaveBeenCalled()) + controller.abort() + await pending.catch(() => undefined) + }) it('preserves V3 durable streams and orders reconnect baselines before transient frames without advancing the durable cursor', async () => { const baseline = { revision: 2,