|
| 1 | +import type { BirpcGroup, EventOptions } from 'birpc' |
| 2 | +import type { Peer } from 'crossws' |
| 3 | +import type { DevframeNodeContext, DevframeNodeRpcSession, DevframeNodeRpcSessionMeta, DevframeRpcClientFunctions, DevframeRpcServerFunctions } from 'devframe/types' |
| 4 | +import type { DevframeAuthHandler } from './auth' |
| 5 | +import type { RpcFunctionsHostImpl } from './host-functions' |
| 6 | +import { AsyncLocalStorage } from 'node:async_hooks' |
| 7 | +import { createRpcServer } from 'devframe/rpc/server' |
| 8 | +import { diagnostics } from './diagnostics' |
| 9 | + |
| 10 | +export interface CreateContextRpcServerOptions { |
| 11 | + context: DevframeNodeContext |
| 12 | + /** See `StartHttpAndWsOptions.auth` — same contract, transport-agnostic. */ |
| 13 | + auth?: boolean | DevframeAuthHandler |
| 14 | + /** See `StartHttpAndWsOptions.authorize`. */ |
| 15 | + authorize?: (methodName: string, session: DevframeNodeRpcSession) => boolean |
| 16 | + /** See `StartHttpAndWsOptions.onPeerConnect`. */ |
| 17 | + onPeerConnect?: (peer: Peer, session: DevframeNodeRpcSession) => void |
| 18 | + /** See `StartHttpAndWsOptions.onPeerDisconnect`. */ |
| 19 | + onPeerDisconnect?: (peer: Peer, meta: DevframeNodeRpcSessionMeta) => void |
| 20 | + /** See `StartHttpAndWsOptions.rpcOptions`. */ |
| 21 | + rpcOptions?: Pick< |
| 22 | + EventOptions<DevframeRpcClientFunctions, DevframeRpcServerFunctions, false>, |
| 23 | + 'onFunctionError' | 'onGeneralError' |
| 24 | + > |
| 25 | +} |
| 26 | + |
| 27 | +export interface ContextRpcServer { |
| 28 | + rpcGroup: BirpcGroup<DevframeRpcClientFunctions, DevframeRpcServerFunctions, false> |
| 29 | + /** The resolved auth handler when `auth` was passed as one. */ |
| 30 | + authHandler?: DevframeAuthHandler |
| 31 | + /** |
| 32 | + * Peer lifecycle handlers to wire into a WS transport |
| 33 | + * (`attachWsRpcTransport`'s `onConnected` / `onDisconnected`, or any other |
| 34 | + * crossws adapter's peer hooks via `createWsRpcPeerHooks`). |
| 35 | + */ |
| 36 | + onConnected?: (peer: Peer, meta: DevframeNodeRpcSessionMeta) => void |
| 37 | + onDisconnected: (peer: Peer, meta: DevframeNodeRpcSessionMeta) => void |
| 38 | +} |
| 39 | + |
| 40 | +/** |
| 41 | + * Bind a devframe context's registered RPC functions to a birpc group, |
| 42 | + * transport-agnostically — the shared core under `startHttpAndWs` (Node |
| 43 | + * http + WS) and the Bun fetch-upgrade tier of `createHandler`. |
| 44 | + * |
| 45 | + * Owns everything about serving RPC that is independent of *how* peers |
| 46 | + * connect: the auth handler's function registration, the |
| 47 | + * `AsyncLocalStorage`-based session resolver (so |
| 48 | + * `ctx.rpc.getCurrentRpcSession()` works inside handlers), the |
| 49 | + * `authorize` gate, and the `auth: false` auto-trust handshake shim. |
| 50 | + */ |
| 51 | +export function createContextRpcServer(options: CreateContextRpcServerOptions): ContextRpcServer { |
| 52 | + const { context } = options |
| 53 | + const rpcHost = context.rpc as unknown as RpcFunctionsHostImpl |
| 54 | + |
| 55 | + const asyncStorage = new AsyncLocalStorage<DevframeNodeRpcSession>() |
| 56 | + |
| 57 | + // A full auth handler (e.g. from `createInteractiveAuth`) registers its own |
| 58 | + // RPC functions and supplies both the resolver gate and the connect-time |
| 59 | + // trust hook. `authorize`/`onPeerConnect` are the lower-level escape |
| 60 | + // hatches for callers not using a full handler. |
| 61 | + const authHandler: DevframeAuthHandler | undefined = typeof options.auth === 'object' ? options.auth : undefined |
| 62 | + const effectiveAuthorize = options.authorize ?? authHandler?.authorize |
| 63 | + |
| 64 | + if (authHandler) { |
| 65 | + for (const fn of authHandler.rpcFunctions) { |
| 66 | + if (!rpcHost.definitions.has(fn.name)) |
| 67 | + rpcHost.register(fn) |
| 68 | + } |
| 69 | + } |
| 70 | + |
| 71 | + const rpcGroup = createRpcServer<DevframeRpcClientFunctions, DevframeRpcServerFunctions>( |
| 72 | + rpcHost.functions, |
| 73 | + { |
| 74 | + rpcOptions: { |
| 75 | + // Forwarded as-is so a host with its own structured diagnostics |
| 76 | + // keeps seeing RPC failures; see `StartHttpAndWsOptions.rpcOptions`. |
| 77 | + onFunctionError: options.rpcOptions?.onFunctionError, |
| 78 | + onGeneralError: options.rpcOptions?.onGeneralError, |
| 79 | + // Wrap each RPC handler in an AsyncLocalStorage context so |
| 80 | + // `ctx.rpc.getCurrentRpcSession()` works inside handlers (used |
| 81 | + // by streaming subscribe/unsubscribe/cancel and shared-state |
| 82 | + // sync), and — when an `authorize` gate is configured — reject |
| 83 | + // the call before it ever reaches the handler. Mirrors |
| 84 | + // `packages/core/src/node/ws.ts`'s resolver. |
| 85 | + resolver(name, fn) { |
| 86 | + // eslint-disable-next-line ts/no-this-alias |
| 87 | + const rpc = this |
| 88 | + if (!fn) |
| 89 | + return undefined |
| 90 | + return async function (this: any, ...args) { |
| 91 | + const meta = rpc.$meta as DevframeNodeRpcSessionMeta |
| 92 | + if (effectiveAuthorize && !effectiveAuthorize(name, { meta, rpc: rpc as any })) |
| 93 | + throw diagnostics.DF0036({ name }) |
| 94 | + return await asyncStorage.run({ |
| 95 | + rpc, |
| 96 | + meta, |
| 97 | + }, async () => { |
| 98 | + return (await fn).apply(this, args) |
| 99 | + }) |
| 100 | + } |
| 101 | + }, |
| 102 | + }, |
| 103 | + }, |
| 104 | + ) |
| 105 | + |
| 106 | + ;(rpcHost as any)._rpcGroup = rpcGroup |
| 107 | + ;(rpcHost as any)._asyncStorage = asyncStorage |
| 108 | + ;(rpcHost as any)._authDisabled = options.auth === false |
| 109 | + |
| 110 | + // The browser client unconditionally calls `anonymous:devframe:auth` on |
| 111 | + // connect (see `client/rpc-ws.ts`). When `auth: false` is set on the |
| 112 | + // standalone server, register a noop handler that auto-trusts so the |
| 113 | + // client's hardcoded handshake succeeds. A host passing a full |
| 114 | + // `DevframeAuthHandler` already registered the real handler above, and |
| 115 | + // never opts into `auth: false`, so the two paths never overlap. |
| 116 | + if (options.auth === false && !rpcHost.definitions.has('anonymous:devframe:auth')) { |
| 117 | + rpcHost.register({ |
| 118 | + name: 'anonymous:devframe:auth', |
| 119 | + type: 'action', |
| 120 | + handler: () => { |
| 121 | + const session = rpcHost.getCurrentRpcSession() |
| 122 | + if (session) |
| 123 | + session.meta.isTrusted = true |
| 124 | + return { isTrusted: true } |
| 125 | + }, |
| 126 | + }) |
| 127 | + } |
| 128 | + |
| 129 | + const onConnected = (authHandler || options.onPeerConnect) |
| 130 | + ? (peer: Peer, meta: DevframeNodeRpcSessionMeta) => { |
| 131 | + const session: DevframeNodeRpcSession = { |
| 132 | + meta, |
| 133 | + rpc: rpcGroup.clients.find(client => (client as any).$meta === meta) as any, |
| 134 | + } |
| 135 | + authHandler?.onConnect(peer, session) |
| 136 | + options.onPeerConnect?.(peer, session) |
| 137 | + } |
| 138 | + : undefined |
| 139 | + |
| 140 | + const onDisconnected = (peer: Peer, meta: DevframeNodeRpcSessionMeta): void => { |
| 141 | + options.onPeerDisconnect?.(peer, meta) |
| 142 | + rpcHost._emitSessionDisconnected(meta) |
| 143 | + } |
| 144 | + |
| 145 | + return { |
| 146 | + rpcGroup, |
| 147 | + authHandler, |
| 148 | + onConnected, |
| 149 | + onDisconnected, |
| 150 | + } |
| 151 | +} |
0 commit comments