diff --git a/packages/client/connection/src/index.ts b/packages/client/connection/src/index.ts index c7203f38d8..c1c1f4d933 100644 --- a/packages/client/connection/src/index.ts +++ b/packages/client/connection/src/index.ts @@ -13,6 +13,8 @@ import { BrowserAuth } from './browser-auth.ts' import { HostConnectionService } from './rpc-host.ts' export type { + ConnectionFetchMethod, + ConnectionFetchRoute, ConnectionIndexRequest, ConnectionIndexResponse, ConnectionRpcEndpointMatcher, @@ -22,6 +24,7 @@ export type { ConnectionRpcResult, ConnectionTrustRequest, HostConnectionHandle, + HostConnectionFetch, HostConnectionRpc, } from './rpc.ts' export { HostConnectionService } from './rpc-host.ts' diff --git a/packages/client/connection/src/rpc-host.ts b/packages/client/connection/src/rpc-host.ts index 4f1e78341b..92de25729f 100644 --- a/packages/client/connection/src/rpc-host.ts +++ b/packages/client/connection/src/rpc-host.ts @@ -17,6 +17,8 @@ import type { BrowserAuth } from './browser-auth.ts' import type { ConnectionIndexRequest, ConnectionIndexResponse, + ConnectionFetchRoute, + HostConnectionFetch, ConnectionRpcEndpointMatcher, ConnectionRpcHandler, ConnectionRpcResult, @@ -35,6 +37,11 @@ interface ConnectionRpcInterceptor { readonly fetchHandler: FetchHandler } +interface RegisteredFetchRoute { + readonly methods: ReadonlySet + readonly fetch: ConnectionFetchRoute['fetch'] +} + interface ConnectionServerResponse { readonly type: 'server-response' readonly rpcId: RpcIdType @@ -51,6 +58,7 @@ declare module '@deepseek-ai/cordis' { /** Host Connection service whose channel registrations belong to the caller fiber. */ export class HostConnectionService extends Service implements HostConnectionHandle { private readonly interceptors = new Map() + private readonly fetchRoutes = new Map() /** * Provide the Host half over the active HTTP server. @@ -76,6 +84,14 @@ export class HostConnectionService extends Service implements HostConnectionHand } } + /** Exact Fetch-route registry scoped to the Context reading this service. */ + get fetch(): HostConnectionFetch { + const owner = this.ctx + return { + register: route => this.registerFetchRoute(owner, route), + } + } + /** Apply the configured Host/Origin fence, then browser authentication. */ requestRejection(request: ConnectionTrustRequest): ConnectionRequestRejection { if (!isTrustedApiRequest(request, this.trustedHosts)) return 403 @@ -104,7 +120,10 @@ export class HostConnectionService extends Service implements HostConnectionHand ): FetchHandler { return { fetch: (request) => { - const endpoint = endpointFromPath(channel, new URL(request.url).pathname) + const pathname = new URL(request.url).pathname + const route = this.fetchRoutes.get(pathname) + if (route?.methods.has(request.method) === true) return route.fetch(request) + const endpoint = endpointFromPath(channel, pathname) const interceptor = this.interceptors.get(channel) if (endpoint === undefined || interceptor === undefined || !interceptor.matches(endpoint)) { return fallback.fetch(request) @@ -114,6 +133,24 @@ export class HostConnectionService extends Service implements HostConnectionHand } } + private registerFetchRoute( + owner: Context, + route: ConnectionFetchRoute, + ): () => Promise { + assertFetchRoute(route) + const registered: RegisteredFetchRoute = { + methods: new Set(route.methods), + fetch: route.fetch, + } + return owner.effect(() => { + if (this.fetchRoutes.has(route.path)) { + throw new Error(`connection: exact Fetch route ${JSON.stringify(route.path)} is already registered`) + } + this.fetchRoutes.set(route.path, registered) + return () => { this.fetchRoutes.delete(route.path) } + }, `client-connection: ${route.path} Fetch route`) + } + private register( owner: Context, channel: string, @@ -246,3 +283,21 @@ function assertChannel(channel: string): void { throw new Error(`connection: invalid or reserved RPC channel ${JSON.stringify(channel)}`) } } + +function assertFetchRoute(route: ConnectionFetchRoute): void { + if (endpointFromPath(API_PATH, route.path) === undefined) { + throw new Error(`connection: invalid exact Fetch route ${JSON.stringify(route.path)}`) + } + if (route.methods.length === 0) { + throw new Error(`connection: exact Fetch route ${JSON.stringify(route.path)} declares no methods`) + } + const methods = new Set(route.methods) + if (methods.size !== route.methods.length) { + throw new Error(`connection: exact Fetch route ${JSON.stringify(route.path)} repeats a method`) + } + for (const method of methods) { + if (method !== 'GET' && method !== 'HEAD') { + throw new Error(`connection: exact Fetch route ${JSON.stringify(route.path)} has unsupported method ${JSON.stringify(method)}`) + } + } +} diff --git a/packages/client/connection/src/rpc.ts b/packages/client/connection/src/rpc.ts index 1f879963af..9cfb47ab1c 100644 --- a/packages/client/connection/src/rpc.ts +++ b/packages/client/connection/src/rpc.ts @@ -43,6 +43,29 @@ export type ConnectionRpcHandler = ( /** Synchronous ownership test for one endpoint on a shared RPC channel. */ export type ConnectionRpcEndpointMatcher = (endpoint: string) => boolean +/** HTTP methods supported by exact Fetch routes on the shared API channel. */ +export type ConnectionFetchMethod = 'GET' | 'HEAD' + +/** One exact, transport-independent Fetch route owned by a Host feature. */ +export interface ConnectionFetchRoute { + /** Absolute path below `/api`; query parameters remain available on the request URL. */ + readonly path: string + /** Methods this route owns. Other methods continue through normal shared-channel dispatch. */ + readonly methods: readonly ConnectionFetchMethod[] + /** Handle one request after the physical carrier has applied its trust and authentication policy. */ + readonly fetch: (request: Request) => Promise +} + +/** Host registry for exact Fetch routes that cannot use JSON Remote invocation. */ +export interface HostConnectionFetch { + /** + * Register one exact route on the shared API channel. + * @param route - path, methods, and Fetch-shaped implementation. + * @returns asynchronous disposer removing this exact contribution. + */ + register(route: ConnectionFetchRoute): () => Promise +} + /** Host registry for logical RPC channels carried by the current transport. */ export interface HostConnectionRpc { /** @@ -74,6 +97,8 @@ export interface HostConnectionRpc { export interface HostConnectionHandle { /** Generic RPC channel registry. */ readonly rpc: HostConnectionRpc + /** Exact Fetch routes for streaming or browser-native responses. */ + readonly fetch: HostConnectionFetch /** * Apply Connection's Host/Origin checks and browser authentication to diff --git a/packages/client/connection/tests/fetch-routes.host.spec.ts b/packages/client/connection/tests/fetch-routes.host.spec.ts new file mode 100644 index 0000000000..7c83fed1b5 --- /dev/null +++ b/packages/client/connection/tests/fetch-routes.host.spec.ts @@ -0,0 +1,80 @@ +import { Context } from '@deepseek-ai/cordis' +import { describe, expect, it, vi } from 'vitest' +import type { BrowserAuth } from '../src/browser-auth.ts' +import { HostConnectionService } from '../src/rpc-host.ts' + +async function mounted(): Promise<{ + readonly connection: HostConnectionService + readonly dispose: () => Promise +}> { + const ctx = new Context() + const fiber = ctx.plugin((pluginCtx) => { + new HostConnectionService(pluginCtx, [], {} as BrowserAuth) + }) + await fiber.await() + return { + connection: ctx.get('connection') as HostConnectionService, + dispose: () => fiber.dispose(), + } +} + +describe('Connection exact Fetch routes', () => { + it('dispatches owned methods before the transitional fallback', async () => { + const { connection, dispose: disposeFiber } = await mounted() + const route = vi.fn(async (request: Request) => + Response.json({ query: new URL(request.url).searchParams.get('sessionId') })) + const fallback = vi.fn(async () => new Response('fallback', { status: 418 })) + const dispose = connection.fetch.register({ + path: '/api/session.export', + methods: ['GET', 'HEAD'], + fetch: route, + }) + const shared = connection.createSharedFetchHandler('/api', { fetch: fallback }) + + const response = await shared.fetch(new Request( + 'http://host/api/session.export?sessionId=session-1', + )) + expect(response.status).toBe(200) + expect(await response.json()).toEqual({ query: 'session-1' }) + expect(route).toHaveBeenCalledOnce() + expect(fallback).not.toHaveBeenCalled() + + const post = await shared.fetch(new Request('http://host/api/session.export', { method: 'POST' })) + expect(post.status).toBe(418) + expect(fallback).toHaveBeenCalledOnce() + + await dispose() + const withdrawn = await shared.fetch(new Request('http://host/api/session.export')) + expect(withdrawn.status).toBe(418) + expect(fallback).toHaveBeenCalledTimes(2) + await disposeFiber() + }) + + it('rejects invalid and duplicate registrations', async () => { + const { connection, dispose: disposeFiber } = await mounted() + const fetch = async (): Promise => new Response() + + expect(() => connection.fetch.register({ path: '/outside', methods: ['GET'], fetch })) + .toThrow('invalid exact Fetch route') + expect(() => connection.fetch.register({ path: '/api/session.export', methods: [], fetch })) + .toThrow('declares no methods') + expect(() => connection.fetch.register({ + path: '/api/session.export', methods: ['GET', 'GET'], fetch, + })).toThrow('repeats a method') + expect(() => connection.fetch.register({ + path: '/api/session.export', methods: ['POST' as 'GET'], fetch, + })).toThrow('unsupported method') + + const dispose = connection.fetch.register({ + path: '/api/session.export', methods: ['GET'], fetch, + }) + expect(() => connection.fetch.register({ + path: '/api/session.export', methods: ['HEAD'], fetch, + })).toThrow('already registered') + await dispose() + expect(() => connection.fetch.register({ + path: '/api/session.export', methods: ['HEAD'], fetch, + })).not.toThrow() + await disposeFiber() + }) +})