Просмотр исходного кода

feat(connection): register exact Fetch routes

imccyu 1 месяц назад
Родитель
Сommit
e5e4b02742

+ 3 - 0
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'

+ 56 - 1
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<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 - 0
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<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

+ 80 - 0
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<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()
+  })
+})