feat(connection): register exact Fetch routes

This commit is contained in:
imccyu
2026-08-27 22:26:27 +08:00
parent 5ba36aa350
commit e5e4b02742
4 changed files with 164 additions and 1 deletions
+3
View File
@@ -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'
+56 -1
View File
@@ -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<string>
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<string, ConnectionRpcInterceptor>()
private readonly fetchRoutes = new Map<string, RegisteredFetchRoute>()
/**
* 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<void> {
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)}`)
}
}
}
+25
View File
@@ -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<Response>
}
/** 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<void>
}
/** 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
@@ -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<void>
}> {
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<Response> => 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()
})
})