Skip to content

Commit fb8cf6a

Browse files
committed
feat(rpc): expose native shared state for custom channels
1 parent 9aa752a commit fb8cf6a

14 files changed

Lines changed: 251 additions & 203 deletions

File tree

‎alias.ts‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ export const alias = {
1616
'devframe/rpc/transports/ws-client': r('devframe/src/rpc/transports/ws-client.ts'),
1717
'devframe/rpc/client': r('devframe/src/rpc/client.ts'),
1818
'devframe/rpc/dump': r('devframe/src/rpc/dump/index.ts'),
19+
'devframe/rpc/shared-state': r('devframe/src/rpc/shared-state.ts'),
1920
'devframe/rpc/server': r('devframe/src/rpc/server.ts'),
2021
'devframe/rpc': r('devframe/src/rpc'),
2122
'devframe/types': r('devframe/src/types/index.ts'),

‎docs/content/8.references/5.browser-api.md‎

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -72,3 +72,16 @@ The `error.code` values of `InPageChannelError`: [Errors and fallbacks](/guide/i
7272
| `not-cloneable` | The port refused to clone a payload (`DataCloneError`) | Strip functions/DOM nodes/reactivity proxies, or declare `jsonSerializable: true` for the precise error above |
7373
| `invalid-args` | Incoming arguments failed their Standard-Schema validation | The message lists the schema issues |
7474
| `state-uninitialized` | The page script read a shared state before providing its `initialValue` | Initialize on first access |
75+
76+
## Shared state over custom RPC channels
77+
78+
`devframe/rpc/shared-state` exports the same `RpcSharedStateHost` implementations used by the RPC client and node context. Custom channel bindings can compose them with `RpcFunctionsCollectorBase`, `createRpcClient()` and `createRpcServer()`.
79+
80+
| Factory | Required connection members | Result |
81+
|---------|-----------------------------|--------|
82+
| `createRpcSharedStateClientHost(rpc)` | `call`, `callEvent`, function registration through `client`, trust events, `isTrusted`, and `connectionMeta.backend` | Native `get`, `keys`, `onKeyAdded` and `delete`. |
83+
| `createRpcSharedStateServerHost(rpc)` | Function registration and filtered broadcasts | Native state publication, including `get(key, { sharedState })`. |
84+
85+
The channel binding owns peer authentication, serialization and disconnection. Each serving channel supplies birpc metadata with a `subscribedStates: Set<string>` and retains the default RPC `this` binding. Subscription handlers use that calling connection's metadata; filtered broadcasts reach its subscribed keys. Close the birpc connection when its transport disconnects and delete mirrored keys when disposing their owner.
86+
87+
A snapshot requested without an initial value rejects if its RPC call fails. Supplying an initial value retains the RPC client's existing immediate-state behavior.

‎knip.jsonc‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -137,7 +137,7 @@
137137
"src/node/index.ts",
138138
"src/node/{auth,hub-internals}/index.ts",
139139
"src/recipes/interactive-auth.ts",
140-
"src/rpc/{index,client,server}.ts",
140+
"src/rpc/{index,client,server,shared-state}.ts",
141141
"src/rpc/dump/index.ts",
142142
"src/rpc/transports/{sse-client,sse-server,ws-bun,ws-deno,ws-client,ws-server}.ts",
143143
"src/types/index.ts",

‎packages/devframe/package.json‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,7 @@
3838
"./rpc/client": "./dist/rpc/client.mjs",
3939
"./rpc/dump": "./dist/rpc/dump.mjs",
4040
"./rpc/server": "./dist/rpc/server.mjs",
41+
"./rpc/shared-state": "./dist/rpc/shared-state.mjs",
4142
"./rpc/transports/sse-client": "./dist/rpc/transports/sse-client.mjs",
4243
"./rpc/transports/sse-server": "./dist/rpc/transports/sse-server.mjs",
4344
"./rpc/transports/ws-bun": "./dist/rpc/transports/ws-bun.mjs",

‎packages/devframe/src/client/rpc-shared-state.ts‎

Lines changed: 11 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,17 @@
1-
import type { RpcSharedStateGetOptions, RpcSharedStateHost } from 'devframe/types'
1+
import type { RpcFunctionsCollector } from 'devframe/rpc'
2+
import type { ConnectionMeta, DevframeRpcClientFunctions, RpcSharedStateGetOptions, RpcSharedStateHost } from 'devframe/types'
23
import type { SharedState, SharedStatePatch } from 'devframe/utils/shared-state'
34
import type { DevframeRpcClient } from './rpc'
45
import { createSharedState } from 'devframe/utils/shared-state'
56
import { DEVFRAME_EVENTS } from '../events'
67

7-
export function createRpcSharedStateClientHost(rpc: DevframeRpcClient): RpcSharedStateHost {
8+
/** Native shared-state synchronization over an authenticated RPC connection. */
9+
export function createRpcSharedStateClientHost<Context>(
10+
rpc: Pick<DevframeRpcClient, 'call' | 'callEvent' | 'events' | 'isTrusted'> & {
11+
client: Pick<RpcFunctionsCollector<DevframeRpcClientFunctions, Context>, 'register'>
12+
connectionMeta: Pick<ConnectionMeta, 'backend'>
13+
},
14+
): RpcSharedStateHost {
815
const sharedState = new Map<string, SharedState<any>>()
916
const stateDisposers = new Map<string, () => void>()
1017
const initialValues = new Map<string, any>()
@@ -121,7 +128,7 @@ export function createRpcSharedStateClientHost(rpc: DevframeRpcClient): RpcShare
121128
}
122129
}
123130

124-
return new Promise<SharedState<T>>((resolve) => {
131+
return new Promise<SharedState<T>>((resolve, reject) => {
125132
if (!rpc.isTrusted) {
126133
resolve(state)
127134
let initialized = false
@@ -133,7 +140,7 @@ export function createRpcSharedStateClientHost(rpc: DevframeRpcClient): RpcShare
133140
})
134141
}
135142
else {
136-
initSharedState().then(resolve)
143+
initSharedState().then(resolve, reject)
137144
}
138145
})
139146
},

‎packages/devframe/src/node/rpc-shared-state.ts‎

Lines changed: 15 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
1-
import type { RpcFunctionsHost, RpcSharedStateGetOptions, RpcSharedStateHost } from 'devframe/types'
1+
import type { RpcFunctionsCollector } from 'devframe/rpc'
2+
import type { DevframeNodeRpcSessionMeta, DevframeRpcServerFunctions, RpcFunctionsHost, RpcSharedStateGetOptions, RpcSharedStateHost } from 'devframe/types'
23
import type { SharedState, SharedStatePatch } from 'devframe/utils/shared-state'
34
import { createSharedState } from 'devframe/utils/shared-state'
45
import { createDebug } from 'obug'
@@ -8,8 +9,13 @@ import { diagnostics } from './diagnostics'
89
const debug = createDebug('devframe:rpc:state:changed')
910
const debugSubscribe = createDebug('devframe:rpc:state:subscribe')
1011

11-
export function createRpcSharedStateServerHost(
12-
rpc: RpcFunctionsHost,
12+
/**
13+
* Publish native shared state over registered RPC functions and broadcasts.
14+
* Each channel supplies session metadata with a `subscribedStates` set and
15+
* retains birpc's default RPC `this` binding for subscription handlers.
16+
*/
17+
export function createRpcSharedStateServerHost<Context>(
18+
rpc: Pick<RpcFunctionsCollector<DevframeRpcServerFunctions, Context>, 'register'> & Pick<RpcFunctionsHost, 'broadcast'>,
1319
): RpcSharedStateHost {
1420
const sharedState = new Map<string, SharedState<any>>()
1521
const stateDisposers = new Map<string, () => void>()
@@ -93,12 +99,13 @@ export function createRpcSharedStateServerHost(
9399
rpc.register({
94100
name: 'devframe:rpc:server-state:subscribe',
95101
type: 'event',
96-
handler(key: string) {
97-
const session = rpc.getCurrentRpcSession()
98-
if (!session)
102+
handler(this: { $meta?: DevframeNodeRpcSessionMeta } | undefined, key: string) {
103+
/** birpc binds this handler to the calling connection across transports. */
104+
const meta = this?.$meta
105+
if (!meta)
99106
return
100-
debugSubscribe('subscribe', { key, session: session.meta.id })
101-
session.meta.subscribedStates.add(key)
107+
debugSubscribe('subscribe', { key, session: meta.id })
108+
meta.subscribedStates.add(key)
102109
},
103110
})
104111

Lines changed: 144 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,144 @@
1+
import type { ChannelOptions } from 'birpc'
2+
import type { RpcClientEvents } from 'devframe/client'
3+
import type { DevframeRpcClientFunctions, DevframeRpcServerFunctions, RpcFunctionsHost } from 'devframe/types'
4+
import type { MessagePort } from 'node:worker_threads'
5+
import { MessageChannel } from 'node:worker_threads'
6+
import { createHostContext } from 'devframe/node'
7+
import { RpcFunctionsCollectorBase } from 'devframe/rpc'
8+
import { createRpcClient } from 'devframe/rpc/client'
9+
import { createRpcServer } from 'devframe/rpc/server'
10+
import { createRpcSharedStateClientHost, createRpcSharedStateServerHost } from 'devframe/rpc/shared-state'
11+
import { createEventEmitter } from 'devframe/utils/events'
12+
import { structuredCloneDeserialize, structuredCloneSerialize } from 'devframe/utils/structured-clone'
13+
import { expect, it } from 'vitest'
14+
import { createContextRpcServer } from '../node/rpc-core'
15+
16+
/** JSON records also travel through transports that cannot clone native values. */
17+
function channelFor(port: MessagePort): ChannelOptions {
18+
return {
19+
post: message => port.postMessage(message),
20+
on: (handler) => {
21+
port.on('message', handler)
22+
},
23+
off: (handler) => {
24+
port.off('message', handler)
25+
},
26+
serialize: value => JSON.stringify(structuredCloneSerialize(value)),
27+
deserialize: value => structuredCloneDeserialize(JSON.parse(value)),
28+
}
29+
}
30+
31+
async function createStateServer(mode: string) {
32+
if (mode === 'node context') {
33+
const context = await createHostContext({
34+
cwd: process.cwd(),
35+
mode: 'dev',
36+
host: {
37+
mountStatic() {},
38+
resolveOrigin: () => 'http://localhost',
39+
getStorageDir: () => process.cwd(),
40+
},
41+
})
42+
const { rpcGroup } = createContextRpcServer({ context, auth: false })
43+
return { group: rpcGroup, sharedState: context.rpc.sharedState }
44+
}
45+
46+
const collector = new RpcFunctionsCollectorBase<DevframeRpcServerFunctions, undefined>(undefined)
47+
const group = createRpcServer<DevframeRpcClientFunctions, DevframeRpcServerFunctions>(collector.functions)
48+
const broadcast: RpcFunctionsHost['broadcast'] = async (options) => {
49+
await Promise.all(group.clients
50+
.filter(client => options.filter?.(client) !== false)
51+
.map(client => client.$callRaw({ ...options, optional: true, event: true })))
52+
}
53+
const sharedState = createRpcSharedStateServerHost({ register: collector.register.bind(collector), broadcast })
54+
return { group, sharedState }
55+
}
56+
57+
it.each(['custom channels', 'node context'])('shares state across %s with per-connection subscriptions', async (mode) => {
58+
expect.assertions(13)
59+
const { group, sharedState } = await createStateServer(mode)
60+
const counter = await sharedState.get('counter', { initialValue: { count: 1 } })
61+
const channels = [new MessageChannel(), new MessageChannel()]
62+
const peers = channels.map((channel, index) => {
63+
const meta = { id: index, subscribedStates: new Set<string>() }
64+
const serverChannel = { ...channelFor(channel.port1), meta }
65+
group.updateChannels(current => current.push(serverChannel))
66+
const client = new RpcFunctionsCollectorBase<DevframeRpcClientFunctions, undefined>(undefined)
67+
const rpc = createRpcClient<DevframeRpcServerFunctions, DevframeRpcClientFunctions>(client.functions, {
68+
channel: channelFor(channel.port2),
69+
})
70+
const state = createRpcSharedStateClientHost({
71+
call: rpc.$call,
72+
callEvent: rpc.$callEvent,
73+
client,
74+
isTrusted: true,
75+
events: createEventEmitter<RpcClientEvents>(),
76+
connectionMeta: { backend: 'none' },
77+
})
78+
return { meta, rpc, state, serverChannel }
79+
})
80+
const [first, second] = peers
81+
try {
82+
const firstCounter = await first.state.get<{ count: number }>('counter')
83+
expect(firstCounter.value()).toEqual({ count: 1 })
84+
expect(first.meta.subscribedStates.has('counter')).toBe(true)
85+
expect(second.meta.subscribedStates.has('counter')).toBe(false)
86+
87+
const secondCounter = await second.state.get<{ count: number }>('counter')
88+
expect(second.meta.subscribedStates.has('counter')).toBe(true)
89+
firstCounter.mutate((draft) => {
90+
draft.count = 2
91+
})
92+
await expect.poll(() => counter.value().count).toBe(2)
93+
await expect.poll(() => secondCounter.value().count).toBe(2)
94+
95+
group.clients.find(client => client.$meta === first.meta)?.$close()
96+
group.updateChannels(current => current.splice(current.indexOf(first.serverChannel), 1))
97+
first.rpc.$close()
98+
await expect(first.rpc.$call('devframe:rpc:server-state:get', 'counter')).rejects.toThrow()
99+
secondCounter.mutate((draft) => {
100+
draft.count = 3
101+
})
102+
await expect.poll(() => counter.value().count).toBe(3)
103+
expect(firstCounter.value().count).toBe(2)
104+
expect(sharedState.keys()).toEqual(['counter'])
105+
expect(first.state.delete('counter')).toBe(true)
106+
expect(sharedState.delete('counter')).toBe(true)
107+
expect(sharedState.keys()).toEqual([])
108+
}
109+
finally {
110+
for (const peer of peers) peer.rpc.$close()
111+
for (const client of group.clients) client.$close()
112+
group.updateChannels(current => current.splice(0))
113+
for (const channel of channels) {
114+
channel.port1.close()
115+
channel.port2.close()
116+
}
117+
}
118+
})
119+
120+
it('rejects an initial snapshot when its RPC connection closes', async () => {
121+
expect.assertions(1)
122+
const client = new RpcFunctionsCollectorBase<DevframeRpcClientFunctions, undefined>(undefined)
123+
const channel = new MessageChannel()
124+
const rpc = createRpcClient<DevframeRpcServerFunctions, DevframeRpcClientFunctions>(client.functions, {
125+
channel: channelFor(channel.port1),
126+
})
127+
const state = createRpcSharedStateClientHost({
128+
call: rpc.$call,
129+
callEvent: rpc.$callEvent,
130+
client,
131+
isTrusted: true,
132+
events: createEventEmitter<RpcClientEvents>(),
133+
connectionMeta: { backend: 'none' },
134+
})
135+
try {
136+
const pending = state.get('counter')
137+
rpc.$close()
138+
await expect(pending).rejects.toThrow('closed')
139+
}
140+
finally {
141+
channel.port1.close()
142+
channel.port2.close()
143+
}
144+
}, 1000)
Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
export { createRpcSharedStateClientHost } from '../client/rpc-shared-state'
2+
export { createRpcSharedStateServerHost } from '../node/rpc-shared-state'

‎packages/devframe/tsdown.config.ts‎

Lines changed: 8 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -54,6 +54,10 @@ const nodeDeps = {
5454
const clientEntries = {
5555
'client/index': 'src/client/index.ts',
5656
'in-page-channel/index': 'src/in-page-channel/index.ts',
57+
'rpc/index': 'src/rpc/index.ts',
58+
'rpc/client': 'src/rpc/client.ts',
59+
'rpc/server': 'src/rpc/server.ts',
60+
'rpc/shared-state': 'src/rpc/shared-state.ts',
5761
'utils/agent-tool-name': 'src/utils/agent-tool-name.ts',
5862
'utils/colors': 'src/utils/colors.ts',
5963
'utils/crypto-token': 'src/utils/crypto-token.ts',
@@ -75,10 +79,7 @@ const serverEntries = {
7579
'index': 'src/index.ts',
7680
'constants': 'src/constants.ts',
7781
'types/index': 'src/types/index.ts',
78-
'rpc/index': 'src/rpc/index.ts',
79-
'rpc/client': 'src/rpc/client.ts',
8082
'rpc/dump': 'src/rpc/dump/index.ts',
81-
'rpc/server': 'src/rpc/server.ts',
8283
'rpc/transports/sse-client': 'src/rpc/transports/sse-client.ts',
8384
'rpc/transports/sse-server': 'src/rpc/transports/sse-server.ts',
8485
'rpc/transports/ws-bun': 'src/rpc/transports/ws-bun.ts',
@@ -140,6 +141,10 @@ export default defineConfig([
140141
await checkClientDist({
141142
entries: [
142143
resolve(distDir, 'client/index.mjs'),
144+
resolve(distDir, 'rpc/index.mjs'),
145+
resolve(distDir, 'rpc/client.mjs'),
146+
resolve(distDir, 'rpc/server.mjs'),
147+
resolve(distDir, 'rpc/shared-state.mjs'),
143148
resolve(distDir, 'in-page-channel/index.mjs'),
144149
resolve(distDir, 'utils/agent-tool-name.mjs'),
145150
resolve(distDir, 'utils/colors.mjs'),

0 commit comments

Comments
 (0)