Skip to content

Commit 136c37d

Browse files
committed
refactor(rpc): extract transport-agnostic rpc core and reusable ws peer hooks
createContextRpcServer owns everything about serving RPC that is independent of how peers connect (auth wiring, session resolver, auto-trust shim); createWsRpcPeerHooks shapes the per-peer lifecycle for any crossws adapter. startHttpAndWs behavior is unchanged — it now composes the two, so other transports (fetch-upgrade runtimes) can reuse the same wiring.
1 parent c590674 commit 136c37d

3 files changed

Lines changed: 275 additions & 179 deletions

File tree

Lines changed: 151 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,151 @@
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+
}

packages/devframe/src/node/server.ts

Lines changed: 14 additions & 90 deletions
Original file line numberDiff line numberDiff line change
@@ -6,13 +6,12 @@ import type { ConnectionMeta, DevframeNodeContext, DevframeNodeRpcSession, Devfr
66
import type { Server as NodeHttpServer } from 'node:http'
77
import type { DevframeAuthHandler } from './auth'
88
import type { RpcFunctionsHostImpl } from './host-functions'
9-
import { AsyncLocalStorage } from 'node:async_hooks'
109
import { createServer } from 'node:http'
11-
import { createRpcServer } from 'devframe/rpc/server'
1210
import { attachWsRpcTransport } from 'devframe/rpc/transports/ws-server'
1311
import { H3, toNodeHandler } from 'h3'
1412
import { diagnostics } from './diagnostics'
1513
import { getInternalContext } from './hub-internals/context'
14+
import { createContextRpcServer } from './rpc-core'
1615
import { formatHostForUrl, normalizeHttpServerUrl } from './utils'
1716

1817
export interface StartHttpAndWsOptions {
@@ -26,7 +25,7 @@ export interface StartHttpAndWsOptions {
2625
*/
2726
app?: H3
2827
/**
29-
* Bind the WS endpoint to a single upgrade route (e.g. `/__devframe_ws`) instead of
28+
* Bind the WS endpoint to a single upgrade route (e.g. `/__ws`) instead of
3029
* claiming every upgrade on the port. This lets the socket share a server
3130
* with other upgrade handlers (Vite HMR, a host framework's own sockets)
3231
* and is what the SPA's `__connection.json` points at. When omitted, the WS
@@ -163,56 +162,16 @@ export async function startHttpAndWs(options: StartHttpAndWsOptions): Promise<St
163162
const httpServer = options.server ?? createServer(toNodeHandler(app))
164163
const rpcHost = context.rpc as unknown as RpcFunctionsHostImpl
165164

166-
const asyncStorage = new AsyncLocalStorage<DevframeNodeRpcSession>()
167-
168-
// A full auth handler (e.g. from `createInteractiveAuth`) registers its own
169-
// RPC functions and supplies both the resolver gate and the connect-time
170-
// trust hook. `authorize`/`onPeerConnect` are the lower-level escape
171-
// hatches for callers not using a full handler.
172-
const authHandler: DevframeAuthHandler | undefined = typeof options.auth === 'object' ? options.auth : undefined
173-
const effectiveAuthorize = options.authorize ?? authHandler?.authorize
174-
175-
if (authHandler) {
176-
for (const fn of authHandler.rpcFunctions) {
177-
if (!rpcHost.definitions.has(fn.name))
178-
rpcHost.register(fn)
179-
}
180-
}
181-
182-
const rpcGroup = createRpcServer<DevframeRpcClientFunctions, DevframeRpcServerFunctions>(
183-
rpcHost.functions,
184-
{
185-
rpcOptions: {
186-
// Forwarded as-is so a host with its own structured diagnostics
187-
// keeps seeing RPC failures; see `StartHttpAndWsOptions.rpcOptions`.
188-
onFunctionError: options.rpcOptions?.onFunctionError,
189-
onGeneralError: options.rpcOptions?.onGeneralError,
190-
// Wrap each RPC handler in an AsyncLocalStorage context so
191-
// `ctx.rpc.getCurrentRpcSession()` works inside handlers (used
192-
// by streaming subscribe/unsubscribe/cancel and shared-state
193-
// sync), and — when an `authorize` gate is configured — reject
194-
// the call before it ever reaches the handler. Mirrors
195-
// `packages/core/src/node/ws.ts`'s resolver.
196-
resolver(name, fn) {
197-
// eslint-disable-next-line ts/no-this-alias
198-
const rpc = this
199-
if (!fn)
200-
return undefined
201-
return async function (this: any, ...args) {
202-
const meta = rpc.$meta as DevframeNodeRpcSessionMeta
203-
if (effectiveAuthorize && !effectiveAuthorize(name, { meta, rpc: rpc as any }))
204-
throw diagnostics.DF0036({ name })
205-
return await asyncStorage.run({
206-
rpc,
207-
meta,
208-
}, async () => {
209-
return (await fn).apply(this, args)
210-
})
211-
}
212-
},
213-
},
214-
},
215-
)
165+
// Transport-agnostic RPC core: auth wiring, session resolver, and the
166+
// peer lifecycle handlers the WS transport below plugs into.
167+
const { rpcGroup, onConnected, onDisconnected } = createContextRpcServer({
168+
context,
169+
auth: options.auth,
170+
authorize: options.authorize,
171+
onPeerConnect: options.onPeerConnect,
172+
onPeerDisconnect: options.onPeerDisconnect,
173+
rpcOptions: options.rpcOptions,
174+
})
216175

217176
// A dedicated WS port (the "different port" scenario) only applies when we
218177
// own the HTTP server — a shared host server already dictates the port.
@@ -231,45 +190,10 @@ export async function startHttpAndWs(options: StartHttpAndWsOptions): Promise<St
231190
// other sockets, so leave non-matching upgrades for them.
232191
destroyUnmatched: ownsHttpServer,
233192
allowedOrigins: options.allowedOrigins,
234-
onConnected: (authHandler || options.onPeerConnect)
235-
? (peer, meta) => {
236-
const session: DevframeNodeRpcSession = {
237-
meta,
238-
rpc: rpcGroup.clients.find(client => (client as any).$meta === meta) as any,
239-
}
240-
authHandler?.onConnect(peer, session)
241-
options.onPeerConnect?.(peer, session)
242-
}
243-
: undefined,
244-
onDisconnected: (peer, meta) => {
245-
options.onPeerDisconnect?.(peer, meta)
246-
rpcHost._emitSessionDisconnected(meta)
247-
},
193+
onConnected,
194+
onDisconnected,
248195
})
249196

250-
;(rpcHost as any)._rpcGroup = rpcGroup
251-
;(rpcHost as any)._asyncStorage = asyncStorage
252-
;(rpcHost as any)._authDisabled = options.auth === false
253-
254-
// The browser client unconditionally calls `anonymous:devframe:auth` on
255-
// connect (see `client/rpc-ws.ts`). When `auth: false` is set on the
256-
// standalone server, register a noop handler that auto-trusts so the
257-
// client's hardcoded handshake succeeds. A host passing a full
258-
// `DevframeAuthHandler` already registered the real handler above, and
259-
// never opts into `auth: false`, so the two paths never overlap.
260-
if (options.auth === false && !rpcHost.definitions.has('anonymous:devframe:auth')) {
261-
rpcHost.register({
262-
name: 'anonymous:devframe:auth',
263-
type: 'action',
264-
handler: () => {
265-
const session = rpcHost.getCurrentRpcSession()
266-
if (session)
267-
session.meta.isTrusted = true
268-
return { isTrusted: true }
269-
},
270-
})
271-
}
272-
273197
// Only start listening on a server we created. A shared server is already
274198
// (or about to be) listening under the caller's control.
275199
if (ownsHttpServer) {

0 commit comments

Comments
 (0)