Skip to content

Commit b7ef5b9

Browse files
committed
fix(rpc): honor automatic client caching
Discover cacheable methods from server declarations and invalidate client caches when declarations or connection state change. Preserve explicit cache options and reject stale in-flight cache writes.
1 parent 302b53a commit b7ef5b9

9 files changed

Lines changed: 266 additions & 5 deletions

File tree

‎docs/content/1.guide/11.client.md‎

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -195,7 +195,20 @@ Set `cacheOptions: true` (or an object):
195195
const rpc = await connectDevframe({ cacheOptions: true })
196196
```
197197

198-
`query` / `static` responses are memoized per argument hash; the `rpc:cache:invalidate` broadcast clears entries after a mutation.
198+
With `true`, the client reads the server's RPC declarations and memoizes `static` responses and `query` responses marked `cacheable: true`, per argument hash. Actions and events continue to reach the server. An options object selects an explicit list, for example `{ functions: ['my:query'] }`, and can provide a `keySerializer`.
199+
200+
After changing data that a cached query reads, clear connected clients' caches:
201+
202+
```ts
203+
import { DEVFRAME_EVENTS } from 'devframe/constants'
204+
205+
await ctx.rpc.broadcast({
206+
method: DEVFRAME_EVENTS.broadcast.cacheInvalidate,
207+
args: [],
208+
})
209+
```
210+
211+
The client also clears its cache when RPC declarations or connection trust change, and when it disconnects or closes. A server without cache discovery support serves automatic-cache calls without caching.
199212

200213
## Discovery (`__connection.json`)
201214

‎docs/content/8.references/3.events.md‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -105,6 +105,7 @@ Pushed to subscribed RPC clients, wired by the core node side.
105105

106106
| Name | Carries |
107107
|---|---|
108+
| `devframe:rpc:cache:invalidate` | Clear cached RPC results and refresh automatic cache eligibility. Sent when RPC declarations change, or by hosts after data changes. |
108109
| `devframe:auth:revoked` | This connection's bearer token was revoked; the RPC client drops to untrusted. |
109110
| `devframe:rpc:client-state:updated` | Full shared-state snapshot for a key. |
110111
| `devframe:rpc:client-state:patch` | Incremental shared-state patch for a key. |
Lines changed: 187 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,187 @@
1+
import type { AddressInfo } from 'node:net'
2+
import type { DevframeRpcClientOptions } from './rpc'
3+
import { createServer } from 'node:http'
4+
import { defineDevframe, defineRpcFunction } from 'devframe'
5+
import { DEVFRAME_EVENTS } from 'devframe/constants'
6+
import { afterEach, beforeEach, describe, expect, it, onTestFinished, vi } from 'vitest'
7+
import { initDevframe } from '../adapters/initiate'
8+
import { connectDevframe } from './index'
9+
10+
const connectionGlobals = ['__DEVFRAME_CONNECTION_META__', '__DEVFRAME_CONNECTION_AUTH_TOKEN__', '__DEVFRAME_CONNECTION__']
11+
12+
beforeEach(() => {
13+
vi.stubGlobal('navigator', { userAgent: 'vitest' })
14+
for (const key of connectionGlobals) delete (globalThis as any)[key]
15+
})
16+
17+
afterEach(() => {
18+
vi.unstubAllGlobals()
19+
for (const key of connectionGlobals) delete (globalThis as any)[key]
20+
})
21+
22+
async function setup(transport: 'websocket' | 'sse', cacheOptions: DevframeRpcClientOptions['cacheOptions'] = true, legacy = false) {
23+
const devframe = initDevframe(defineDevframe({
24+
id: 'cache-test',
25+
name: 'Cache test',
26+
version: '0.0.0',
27+
packageName: 'cache-test',
28+
homepage: 'https://example.test',
29+
description: 'RPC cache regression tests.',
30+
setup: () => {},
31+
}), { base: '/__cache/', auth: false })
32+
const server = createServer((req, res) => devframe.nodeMiddleware(req, res))
33+
devframe.attach(server)
34+
await new Promise<void>(resolve => server.listen(0, '127.0.0.1', resolve))
35+
onTestFinished(async () => {
36+
await devframe.close()
37+
await new Promise<void>(resolve => server.close(() => resolve()))
38+
})
39+
const ctx = await devframe.context
40+
if (legacy)
41+
ctx.rpc.definitions.delete('devframe:rpc:cacheable-functions')
42+
const counts: Record<string, number> = {}
43+
for (const [name, type, cacheable] of [
44+
['static', 'static', false],
45+
['query', 'query', true],
46+
['uncached', 'query', false],
47+
['default', 'query', undefined],
48+
['action', 'action', true],
49+
['event', 'event', true],
50+
] as const) {
51+
ctx.rpc.register(defineRpcFunction({
52+
name: `test:${name}`,
53+
type,
54+
cacheable,
55+
handler: (value: unknown) => {
56+
counts[name] = (counts[name] ?? 0) + 1
57+
return value
58+
},
59+
}))
60+
}
61+
const origin = `http://127.0.0.1:${(server.address() as AddressInfo).port}`
62+
vi.stubGlobal('location', new URL(`${origin}/__cache/`))
63+
const client = await connectDevframe({
64+
baseURL: `${origin}/__cache/`,
65+
transport,
66+
cacheOptions,
67+
otpParam: false,
68+
simpleAuth: false,
69+
webmcp: false,
70+
callTimeout: 3000,
71+
})
72+
onTestFinished(() => client.close?.())
73+
return { ctx, client, counts, call: client.call as (name: string, value?: unknown) => Promise<unknown> }
74+
}
75+
76+
describe.each(['websocket', 'sse'] as const)('automatic RPC cache over %s', (transport) => {
77+
it('caches static and opted-in queries from the first call, by arguments', async () => {
78+
const { call, counts } = await setup(transport)
79+
for (const name of ['static', 'query', 'uncached', 'default', 'action', 'event']) {
80+
for (const value of [1, 1, 2])
81+
await expect(call(`test:${name}`, value)).resolves.toBe(value)
82+
}
83+
expect(counts).toEqual({ static: 2, query: 2, uncached: 3, default: 3, action: 3, event: 3 })
84+
})
85+
86+
it('caches falsy results and keeps event calls reaching the server', async () => {
87+
const { client, call, counts } = await setup(transport)
88+
for (const value of [false, 0, '', null, undefined]) {
89+
await expect(call('test:query', value)).resolves.toBe(value)
90+
await expect(call('test:query', value)).resolves.toBe(value)
91+
}
92+
expect(counts.query).toBe(5)
93+
await client.callEvent('test:query' as any, 0)
94+
await client.callEvent('test:query' as any, 0)
95+
await vi.waitFor(() => expect(counts.query).toBe(7))
96+
})
97+
98+
it('preserves explicit function lists and custom serializers', async () => {
99+
const keySerializer = vi.fn(() => 'same-key')
100+
const { call, counts } = await setup(transport, { functions: ['test:uncached'], keySerializer })
101+
await expect(call('test:uncached', 1)).resolves.toBe(1)
102+
await expect(call('test:uncached', 2)).resolves.toBe(1)
103+
await call('test:query', 1)
104+
await call('test:query', 1)
105+
expect(counts).toEqual({ uncached: 1, query: 2 })
106+
expect(keySerializer).toHaveBeenCalled()
107+
})
108+
109+
it('keeps caching disabled with false', async () => {
110+
const { call, counts } = await setup(transport, false)
111+
await call('test:query', 1)
112+
await call('test:query', 1)
113+
expect(counts.query).toBe(2)
114+
})
115+
116+
it('does not cache rejected results', async () => {
117+
const { ctx, call } = await setup(transport)
118+
const handler = vi.fn()
119+
.mockRejectedValueOnce(new Error('retry me'))
120+
.mockResolvedValue('ok')
121+
ctx.rpc.register(defineRpcFunction({ name: 'test:retry', type: 'query', cacheable: true, handler }))
122+
await expect(call('test:retry')).rejects.toThrow('retry me')
123+
await expect(call('test:retry')).resolves.toBe('ok')
124+
await expect(call('test:retry')).resolves.toBe('ok')
125+
expect(handler).toHaveBeenCalledTimes(2)
126+
})
127+
128+
it('falls back to uncached calls with an older server', async () => {
129+
const { call, counts } = await setup(transport, true, true)
130+
await call('test:query', 1)
131+
await call('test:query', 1)
132+
expect(counts.query).toBe(2)
133+
})
134+
135+
it('refreshes eligibility when a function is registered or updated', async () => {
136+
const { ctx, client, call } = await setup(transport)
137+
await call('test:query', 1)
138+
const clear = vi.spyOn(client.cacheManager, 'clear')
139+
const handler = vi.fn(() => 'new')
140+
ctx.rpc.register(defineRpcFunction({ name: 'test:late', type: 'query', cacheable: true, handler }))
141+
await vi.waitFor(() => expect(clear).toHaveBeenCalled())
142+
await expect(call('test:late')).resolves.toBe('new')
143+
await expect(call('test:late')).resolves.toBe('new')
144+
expect(handler).toHaveBeenCalledTimes(1)
145+
clear.mockClear()
146+
ctx.rpc.update(defineRpcFunction({ name: 'test:late', type: 'action', handler }))
147+
await vi.waitFor(() => expect(clear).toHaveBeenCalled())
148+
await call('test:late')
149+
await call('test:late')
150+
expect(handler).toHaveBeenCalledTimes(3)
151+
})
152+
153+
it('clears results on invalidation and rejects late cache writes', async () => {
154+
const { ctx, client, call, counts } = await setup(transport)
155+
const pending = Promise.withResolvers<string>()
156+
const handler = vi.fn(() => pending.promise)
157+
ctx.rpc.register(defineRpcFunction({ name: 'test:slow', type: 'query', cacheable: true, handler }))
158+
await call('test:query', 1)
159+
const slow = call('test:slow')
160+
await vi.waitFor(() => expect(handler).toHaveBeenCalledTimes(1))
161+
const clear = vi.spyOn(client.cacheManager, 'clear')
162+
await ctx.rpc.broadcast({ method: DEVFRAME_EVENTS.broadcast.cacheInvalidate, args: [] })
163+
await vi.waitFor(() => expect(clear).toHaveBeenCalled())
164+
pending.resolve('old')
165+
await slow
166+
await call('test:slow')
167+
expect(handler).toHaveBeenCalledTimes(2)
168+
await call('test:query', 1)
169+
expect(counts.query).toBe(2)
170+
})
171+
172+
it('clears cached results when trust is revoked and when closed', async () => {
173+
const { ctx, client, call } = await setup(transport)
174+
await call('test:query', 1)
175+
expect(client.cacheManager.has('test:query', [1])).toBe(true)
176+
await client.services.state()
177+
await ctx.rpc.broadcast({ method: DEVFRAME_EVENTS.broadcast.authRevoked, args: [] })
178+
await vi.waitFor(() => expect(client.isTrusted).toBe(false))
179+
expect(client.cacheManager.has('test:query', [1])).toBe(false)
180+
await expect(call('test:query', 1)).rejects.toThrow(/Not authorized/)
181+
await client.requestTrust()
182+
await call('test:query', 1)
183+
client.close?.()
184+
expect(client.cacheManager.has('test:query', [1])).toBe(false)
185+
await expect(call('test:query', 1)).rejects.toMatchObject({ kind: 'connection' })
186+
})
187+
})

‎packages/devframe/src/client/rpc.ts‎

Lines changed: 43 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@ import type { DevframeConnection, DevframeConnectionStatus, SetupDevframeConnect
77
import type { DevframeServicesClient } from './rpc-services'
88
import type { RpcStreamingClientHost } from './rpc-streaming'
99
import type { DevframeScopedClientContext } from './scope'
10-
import { DEVFRAME_OTP_URL_PARAM } from 'devframe/constants'
10+
import { DEVFRAME_EVENTS, DEVFRAME_OTP_URL_PARAM } from 'devframe/constants'
1111
import { RpcCacheManager, RpcFunctionsCollectorBase } from 'devframe/rpc'
1212
import { createEventEmitter } from 'devframe/utils/events'
1313
import { withBase } from 'devframe/utils/url'
@@ -99,6 +99,7 @@ export interface DevframeRpcClientOptions extends SetupDevframeConnectionOptions
9999
/** Channel overrides for the SSE transport, the `wsOptions` counterpart. */
100100
sseOptions?: Partial<SseRpcChannelOptions>
101101
rpcOptions?: Partial<BirpcOptions<DevframeRpcServerFunctions, DevframeRpcClientFunctions, boolean>>
102+
/** Cache declared static/opted-in query methods with `true`, or select an explicit function list. */
102103
cacheOptions?: boolean | Partial<RpcCacheOptions>
103104
/**
104105
* Mirror `agent`-flagged client RPC functions (functions registered on
@@ -358,6 +359,26 @@ export async function getDevframeRpcClient(
358359
const disposeWebMcp = options.webmcp === false ? undefined : registerWebMcpTools(clientRpc)
359360
let disposeBrowserAgentBridge: (() => void) | undefined
360361
let closed = false
362+
let cacheGeneration = 0
363+
let cacheFunctions: Promise<void> | undefined
364+
365+
function invalidateCache(): void {
366+
cacheGeneration++
367+
cacheFunctions = undefined
368+
cacheManager.clear()
369+
if (cacheOptions === true)
370+
cacheManager.updateOptions({ functions: [] })
371+
}
372+
373+
if (cacheOptions) {
374+
clientRpc.register({
375+
name: DEVFRAME_EVENTS.broadcast.cacheInvalidate,
376+
type: 'event',
377+
handler: invalidateCache,
378+
})
379+
events.on(DEVFRAME_EVENTS.client.connectionStatus, invalidateCache)
380+
events.on(DEVFRAME_EVENTS.client.isTrustedUpdated, invalidateCache)
381+
}
361382

362383
async function fetchJsonFromBases(path: string): Promise<any> {
363384
const candidates = [
@@ -385,6 +406,7 @@ export async function getDevframeRpcClient(
385406
})
386407
}
387408

409+
let mode: DevframeRpcClientMode
388410
const liveModeOptions = {
389411
authToken,
390412
connectionMeta,
@@ -396,12 +418,28 @@ export async function getDevframeRpcClient(
396418
...rpcOptions,
397419
async onRequest(req, next, resolve) {
398420
await rpcOptions.onRequest?.call(this, req, next, resolve)
399-
if (cacheOptions && cacheManager?.validate(req.m)) {
421+
// Auth and protocol calls must be able to run before cache discovery.
422+
if (cacheOptions === true && req.i && mode.isTrusted && !req.m.startsWith('anonymous:') && req.m !== 'devframe:rpc:cacheable-functions') {
423+
while (!cacheFunctions) {
424+
if (closed || mode.status !== 'connected')
425+
break
426+
const generation = cacheGeneration
427+
cacheFunctions = mode.callOptional('devframe:rpc:cacheable-functions').then((functions) => {
428+
if (generation === cacheGeneration)
429+
cacheManager.updateOptions({ functions: functions ?? [] })
430+
})
431+
await cacheFunctions
432+
}
433+
await cacheFunctions
434+
}
435+
const generation = cacheGeneration
436+
if (cacheOptions && req.i && !closed && mode.isTrusted && cacheManager.validate(req.m)) {
400437
if (cacheManager.has(req.m, req.a)) {
401438
return resolve(cacheManager.cached(req.m, req.a))
402439
}
403440
const res = await next(req)
404-
cacheManager.apply(req, res)
441+
if (generation === cacheGeneration && !closed)
442+
cacheManager.apply(req, res)
405443
}
406444
else {
407445
await next(req)
@@ -411,7 +449,7 @@ export async function getDevframeRpcClient(
411449
}
412450

413451
const transport = resolveClientTransport(options.transport ?? 'auto', connectionMeta)
414-
const mode = transport === 'static'
452+
mode = transport === 'static'
415453
? await createStaticRpcClientMode({
416454
fetchJsonFromBases,
417455
})
@@ -450,6 +488,7 @@ export async function getDevframeRpcClient(
450488
/** Release authentication and transport resources even if another disposer fails. */
451489
function closeRpcClient(): void {
452490
closed = true
491+
invalidateCache()
453492
try {
454493
disposeBrowserAgentBridge?.()
455494
disposeWebMcp?.()

‎packages/devframe/src/events.ts‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -51,6 +51,7 @@ export const DEVFRAME_EVENTS = {
5151
*/
5252
broadcast: {
5353
authRevoked: 'devframe:auth:revoked',
54+
cacheInvalidate: 'devframe:rpc:cache:invalidate',
5455
clientStateUpdated: 'devframe:rpc:client-state:updated',
5556
clientStatePatch: 'devframe:rpc:client-state:patch',
5657
streamingChunk: 'devframe:streaming:chunk',

‎packages/devframe/src/node/host-functions.ts‎

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,8 @@
11
import type { BirpcGroup } from 'birpc'
22
import type { DevframeNodeContext, DevframeNodeRpcSession, DevframeNodeRpcSessionMeta, DevframeRpcClientFunctions, DevframeRpcServerFunctions, RpcBroadcastOptions, RpcFunctionsHost as RpcFunctionsHostType, RpcSharedStateHost, RpcStreamingHost } from 'devframe/types'
33
import type { AsyncLocalStorage } from 'node:async_hooks'
4+
import { defineRpcFunction } from 'devframe'
5+
import { DEVFRAME_EVENTS } from 'devframe/constants'
46
import { RpcFunctionsCollectorBase } from 'devframe/rpc'
57
import { createDebug } from 'obug'
68
import { removeClientAgentSession } from './client-agent'
@@ -40,6 +42,17 @@ export class RpcFunctionsHostImpl extends RpcFunctionsCollectorBase<DevframeRpcS
4042

4143
this.sharedState = createRpcSharedStateServerHost(this)
4244
this.streaming = createRpcStreamingServerHost(this)
45+
46+
this.register(defineRpcFunction({
47+
name: 'devframe:rpc:cacheable-functions',
48+
type: 'query',
49+
handler: () => [...this.definitions.values()]
50+
.filter(fn => fn.type === 'static' || (fn.type === 'query' && fn.cacheable === true))
51+
.map(fn => fn.name),
52+
}))
53+
this.onChanged(() => {
54+
void this.broadcast({ method: DEVFRAME_EVENTS.broadcast.cacheInvalidate, args: [] })
55+
})
4356
}
4457

4558
sharedState: RpcSharedStateHost

‎packages/devframe/src/types/rpc-augments.ts‎

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,8 @@
22
* To be extended
33
*/
44
export interface DevframeRpcClientFunctions {
5+
/** Clear cached RPC results and refresh cache eligibility. */
6+
'devframe:rpc:cache:invalidate': () => Promise<void>
57
/** Invoke a tool registered in this browser document. @internal */
68
'devframe:agent:invoke-client-tool': (id: string, args: Record<string, unknown>) => Promise<unknown>
79
/**
@@ -53,6 +55,8 @@ export interface DevframeRpcClientFunctions {
5355
* To be extended
5456
*/
5557
export interface DevframeRpcServerFunctions {
58+
/** Methods eligible for automatic client caching. @internal */
59+
'devframe:rpc:cacheable-functions': () => Promise<string[]>
5660
/** Replace this connection's browser-agent tool manifest, tagged with the calling tab's stable client id. @internal */
5761
'devframe:agent:sync-client-tools': (clientId: string, tools: import('../client/browser-agent').BrowserAgentToolManifest[]) => Promise<void>
5862
/**

‎tests/__snapshots__/tsnapi/devframe/constants.snapshot.d.ts‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,7 @@ export declare const DEVFRAME_EVENTS: {
2727
};
2828
readonly broadcast: {
2929
readonly authRevoked: "devframe:auth:revoked";
30+
readonly cacheInvalidate: "devframe:rpc:cache:invalidate";
3031
readonly clientStateUpdated: "devframe:rpc:client-state:updated";
3132
readonly clientStatePatch: "devframe:rpc:client-state:patch";
3233
readonly streamingChunk: "devframe:streaming:chunk";

‎tests/__snapshots__/tsnapi/devframe/index.snapshot.d.ts‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -227,6 +227,7 @@ export interface DevframeNodeRpcSessionMeta {
227227
uploadingStreams?: Set<string>;
228228
}
229229
export interface DevframeRpcClientFunctions {
230+
'devframe:rpc:cache:invalidate': () => Promise<void>;
230231
'devframe:agent:invoke-client-tool': (_: string, _: Record<string, unknown>) => Promise<unknown>;
231232
'devframe:auth:revoked': () => Promise<void>;
232233
'devframe:streaming:chunk': (_: string, _: string, _: number, _: any) => Promise<void>;
@@ -256,6 +257,7 @@ export interface DevframeRpcOptions {
256257
snapshot?: DevframeSnapshotRpcEntry[];
257258
}
258259
export interface DevframeRpcServerFunctions {
260+
'devframe:rpc:cacheable-functions': () => Promise<string[]>;
259261
'devframe:agent:sync-client-tools': (_: string, _: BrowserAgentToolManifest[]) => Promise<void>;
260262
'anonymous:devframe:auth': (_: {
261263
authToken: string;

0 commit comments

Comments
 (0)