Skip to content

Commit ed8f3a1

Browse files
SaKaNa-Yantfu
andauthored
fix(rpc): honor automatic client caching (#430)
* 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. * fix(rpc): retry cache discovery after failures Clear failed discovery promises so later RPC calls can retry. Preserve newer discovery after invalidation and keep timed-out actions from running. Cover rejection, timeout recovery, and invalidation races over WebSocket and SSE. Align caching documentation with repository terminology. * fix(rpc): stop expired calls before cache discovery dispatch Keep each call within its original timeout across cache discovery refreshes. Stop expired calls before dispatch while allowing later retries to use the shared discovery result. Cover call and callOptional over WebSocket and SSE. All 28 cache tests and the full suite pass, along with lint, knip, typecheck, and build. --------- Co-authored-by: Anthony Fu <github@antfu.me>
1 parent b911dea commit ed8f3a1

9 files changed

Lines changed: 385 additions & 6 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 RPC client reads declarations from the node side. It memoizes `static` responses and `query` responses marked `cacheable: true`, per argument hash. Actions and events continue to reach the node side. 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 RPC 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 RPC client also clears its cache when RPC declarations or connection trust change, and when it disconnects or closes. Older devframes serve automatic-cache calls without caching when their node side lacks cache discovery support. If discovery fails, the waiting calls reject and a later RPC call retries discovery.
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 the devframe's node side 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: 290 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,290 @@
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, callTimeout = 3000) {
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,
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('retries discovery after a rejected request without blocking actions', async () => {
136+
const { ctx, client, call, counts } = await setup(transport)
137+
await call('test:query', 1)
138+
const clear = vi.spyOn(client.cacheManager, 'clear')
139+
const discovery = vi.fn()
140+
.mockRejectedValueOnce(new Error('temporary discovery failure'))
141+
.mockResolvedValue(['test:query'])
142+
ctx.rpc.update(defineRpcFunction({ name: 'devframe:rpc:cacheable-functions', type: 'query', handler: discovery }))
143+
await vi.waitFor(() => expect(clear).toHaveBeenCalled())
144+
145+
await expect(call('test:action', 1)).rejects.toThrow('temporary discovery failure')
146+
expect(counts.action).toBeUndefined()
147+
await expect(call('test:action', 2)).resolves.toBe(2)
148+
await expect(call('test:action', 2)).resolves.toBe(2)
149+
await call('test:query', 1)
150+
await call('test:query', 1)
151+
expect(counts).toEqual({ action: 2, query: 2 })
152+
expect(discovery).toHaveBeenCalledTimes(2)
153+
})
154+
155+
it('retries discovery after a timeout and ignores its late response', async () => {
156+
const { ctx, client, call, counts } = await setup(transport)
157+
await call('test:query', 1)
158+
const clear = vi.spyOn(client.cacheManager, 'clear')
159+
const pending = Promise.withResolvers<string[]>()
160+
const discovery = vi.fn()
161+
.mockReturnValueOnce(pending.promise)
162+
.mockResolvedValue(['test:query'])
163+
const timedOut = Promise.withResolvers<void>()
164+
client.events.on(DEVFRAME_EVENTS.client.error, (_error, method) => {
165+
if (method === 'devframe:rpc:cacheable-functions')
166+
timedOut.resolve()
167+
})
168+
ctx.rpc.update(defineRpcFunction({ name: 'devframe:rpc:cacheable-functions', type: 'query', handler: discovery }))
169+
await vi.waitFor(() => expect(clear).toHaveBeenCalled())
170+
171+
await expect(call('test:action', 1)).rejects.toMatchObject({ kind: 'timeout' })
172+
await timedOut.promise
173+
// The error event fires before the discovery promise rejects.
174+
await new Promise(resolve => setTimeout(resolve, 0))
175+
pending.resolve(['test:action'])
176+
expect(counts.action).toBeUndefined()
177+
await expect(call('test:action', 2)).resolves.toBe(2)
178+
await expect(call('test:action', 2)).resolves.toBe(2)
179+
await call('test:query', 1)
180+
await call('test:query', 1)
181+
expect(counts).toEqual({ action: 2, query: 2 })
182+
expect(discovery).toHaveBeenCalledTimes(2)
183+
})
184+
185+
it.each(['call', 'callOptional'] as const)('does not send an expired %s after rediscovery, but allows a later retry', async (method) => {
186+
const { ctx, client, call, counts } = await setup(transport, true, false, 800)
187+
await call('test:query', 1)
188+
const clear = vi.spyOn(client.cacheManager, 'clear')
189+
const first = Promise.withResolvers<string[]>()
190+
const second = Promise.withResolvers<string[]>()
191+
const discovery = vi.fn()
192+
.mockReturnValueOnce(first.promise)
193+
.mockReturnValueOnce(second.promise)
194+
ctx.rpc.update(defineRpcFunction({ name: 'devframe:rpc:cacheable-functions', type: 'query', handler: discovery }))
195+
await vi.waitFor(() => expect(clear).toHaveBeenCalled())
196+
197+
const expired = expect(client[method]('test:action' as any, 1)).rejects.toMatchObject({ kind: 'timeout' })
198+
await vi.waitFor(() => expect(discovery).toHaveBeenCalledTimes(1))
199+
clear.mockClear()
200+
await ctx.rpc.broadcast({ method: DEVFRAME_EVENTS.broadcast.cacheInvalidate, args: [] })
201+
await vi.waitFor(() => expect(clear).toHaveBeenCalled())
202+
await new Promise(resolve => setTimeout(resolve, 400))
203+
first.resolve(['test:query'])
204+
await vi.waitFor(() => expect(discovery).toHaveBeenCalledTimes(2))
205+
206+
await expired
207+
expect(counts.action).toBeUndefined()
208+
const retry = client[method]('test:action' as any, 1)
209+
second.resolve(['test:query'])
210+
await expect(retry).resolves.toBe(1)
211+
expect(counts.action).toBe(1)
212+
expect(discovery).toHaveBeenCalledTimes(2)
213+
})
214+
215+
it('keeps refreshed discovery when an invalidated request rejects', async () => {
216+
const { ctx, client, call } = await setup(transport)
217+
await call('test:query', 1)
218+
const clear = vi.spyOn(client.cacheManager, 'clear')
219+
const pending = Promise.withResolvers<string[]>()
220+
const discovery = vi.fn()
221+
.mockReturnValueOnce(pending.promise)
222+
.mockResolvedValue(['test:query'])
223+
ctx.rpc.update(defineRpcFunction({ name: 'devframe:rpc:cacheable-functions', type: 'query', handler: discovery }))
224+
await vi.waitFor(() => expect(clear).toHaveBeenCalled())
225+
226+
const rejected = expect(call('test:action', 1)).rejects.toThrow('outdated discovery')
227+
await vi.waitFor(() => expect(discovery).toHaveBeenCalledTimes(1))
228+
clear.mockClear()
229+
await ctx.rpc.broadcast({ method: DEVFRAME_EVENTS.broadcast.cacheInvalidate, args: [] })
230+
await vi.waitFor(() => expect(clear).toHaveBeenCalled())
231+
await call('test:query', 1)
232+
pending.reject(new Error('outdated discovery'))
233+
await rejected
234+
await call('test:query', 1)
235+
expect(discovery).toHaveBeenCalledTimes(2)
236+
})
237+
238+
it('refreshes eligibility when a function is registered or updated', async () => {
239+
const { ctx, client, call } = await setup(transport)
240+
await call('test:query', 1)
241+
const clear = vi.spyOn(client.cacheManager, 'clear')
242+
const handler = vi.fn(() => 'new')
243+
ctx.rpc.register(defineRpcFunction({ name: 'test:late', type: 'query', cacheable: true, handler }))
244+
await vi.waitFor(() => expect(clear).toHaveBeenCalled())
245+
await expect(call('test:late')).resolves.toBe('new')
246+
await expect(call('test:late')).resolves.toBe('new')
247+
expect(handler).toHaveBeenCalledTimes(1)
248+
clear.mockClear()
249+
ctx.rpc.update(defineRpcFunction({ name: 'test:late', type: 'action', handler }))
250+
await vi.waitFor(() => expect(clear).toHaveBeenCalled())
251+
await call('test:late')
252+
await call('test:late')
253+
expect(handler).toHaveBeenCalledTimes(3)
254+
})
255+
256+
it('clears results on invalidation and rejects late cache writes', async () => {
257+
const { ctx, client, call, counts } = await setup(transport)
258+
const pending = Promise.withResolvers<string>()
259+
const handler = vi.fn(() => pending.promise)
260+
ctx.rpc.register(defineRpcFunction({ name: 'test:slow', type: 'query', cacheable: true, handler }))
261+
await call('test:query', 1)
262+
const slow = call('test:slow')
263+
await vi.waitFor(() => expect(handler).toHaveBeenCalledTimes(1))
264+
const clear = vi.spyOn(client.cacheManager, 'clear')
265+
await ctx.rpc.broadcast({ method: DEVFRAME_EVENTS.broadcast.cacheInvalidate, args: [] })
266+
await vi.waitFor(() => expect(clear).toHaveBeenCalled())
267+
pending.resolve('old')
268+
await slow
269+
await call('test:slow')
270+
expect(handler).toHaveBeenCalledTimes(2)
271+
await call('test:query', 1)
272+
expect(counts.query).toBe(2)
273+
})
274+
275+
it('clears cached results when trust is revoked and when closed', async () => {
276+
const { ctx, client, call } = await setup(transport)
277+
await call('test:query', 1)
278+
expect(client.cacheManager.has('test:query', [1])).toBe(true)
279+
await client.services.state()
280+
await ctx.rpc.broadcast({ method: DEVFRAME_EVENTS.broadcast.authRevoked, args: [] })
281+
await vi.waitFor(() => expect(client.isTrusted).toBe(false))
282+
expect(client.cacheManager.has('test:query', [1])).toBe(false)
283+
await expect(call('test:query', 1)).rejects.toThrow(/Not authorized/)
284+
await client.requestTrust()
285+
await call('test:query', 1)
286+
client.close?.()
287+
expect(client.cacheManager.has('test:query', [1])).toBe(false)
288+
await expect(call('test:query', 1)).rejects.toMatchObject({ kind: 'connection' })
289+
})
290+
})

0 commit comments

Comments
 (0)