Skip to content

Commit f79453a

Browse files
authored
fix(rpc): harden static RPC client against placeholder args and enveloped dumps (#30)
1 parent 0654a77 commit f79453a

2 files changed

Lines changed: 105 additions & 6 deletions

File tree

packages/devframe/src/client/static-rpc.test.ts

Lines changed: 73 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -170,4 +170,77 @@ describe('createStaticRpcCaller', () => {
170170

171171
await expect(caller.call('demo:legacy-static', [])).resolves.toEqual({ items: [1, 2, 3] })
172172
})
173+
174+
it('treats placeholder args on static entries as a no-arg call', async () => {
175+
const caller = createStaticRpcCaller(
176+
{
177+
'demo:messages': {
178+
type: 'static',
179+
path: DEMO_STATIC_VERSION_PATH,
180+
},
181+
},
182+
async () => ({ output: ['ok'] }),
183+
)
184+
185+
await expect(caller.call('demo:messages', [null])).resolves.toEqual(['ok'])
186+
await expect(caller.call('demo:messages', [undefined])).resolves.toEqual(['ok'])
187+
await expect(caller.call('demo:messages', ['real'])).rejects.toThrow('No dump match')
188+
})
189+
190+
it('treats placeholder args on legacy inline entries as a no-arg call', async () => {
191+
const caller = createStaticRpcCaller(
192+
{
193+
'demo:legacy': { ok: true },
194+
},
195+
async () => {
196+
throw new Error('Should not fetch')
197+
},
198+
)
199+
200+
await expect(caller.call('demo:legacy', [null])).resolves.toEqual({ ok: true })
201+
await expect(caller.call('demo:legacy', ['real'])).rejects.toThrow('No dump match')
202+
})
203+
204+
it('unwraps enveloped static files written as the full StaticRpcDumpFile', async () => {
205+
const caller = createStaticRpcCaller(
206+
{
207+
'demo:graph': {
208+
type: 'static',
209+
path: `${DEVFRAME_RPC_DUMP_DIRNAME}/demo~graph.static.json`,
210+
serialization: 'structured-clone',
211+
},
212+
},
213+
async () => ({
214+
serialization: 'structured-clone',
215+
fnName: 'demo:graph',
216+
data: JSON.parse(structuredCloneStringify({ output: new Map([['a', 1]]) })),
217+
}),
218+
)
219+
220+
const result = await caller.call('demo:graph', []) as Map<string, number>
221+
expect(result).toBeInstanceOf(Map)
222+
expect(result.get('a')).toBe(1)
223+
})
224+
225+
it('unwraps enveloped query records written as the full StaticRpcDumpFile', async () => {
226+
const recordPath = `${DEMO_QUERY_BASE_PATH}.record.${hash(['k'])}.json`
227+
const caller = createStaticRpcCaller(
228+
{
229+
'demo:query-set': {
230+
type: 'query',
231+
serialization: 'structured-clone',
232+
records: { [hash(['k'])]: recordPath },
233+
},
234+
},
235+
async () => ({
236+
serialization: 'structured-clone',
237+
fnName: 'demo:query-set',
238+
data: JSON.parse(structuredCloneStringify({ inputs: ['k'], output: new Set(['x']) })),
239+
}),
240+
)
241+
242+
const result = await caller.call('demo:query-set', ['k']) as Set<string>
243+
expect(result).toBeInstanceOf(Set)
244+
expect(result.has('x')).toBe(true)
245+
})
173246
})

packages/devframe/src/client/static-rpc.ts

Lines changed: 32 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -60,6 +60,26 @@ function resolveRecordOutput(record: StaticRpcRecord): any {
6060
return record.output
6161
}
6262

63+
// Placeholder args (`[null]`/`[undefined]`) from framework setup hooks carry no
64+
// addressing info and must be treated as a no-arg call.
65+
function hasMeaningfulArgs(args: any[]): boolean {
66+
return args.some(arg => arg !== null && arg !== undefined)
67+
}
68+
69+
// `collectStaticRpcDump`/`StaticRpcDumpFile` are public, so consumers may persist
70+
// the whole `{ serialization, fnName, data }` envelope instead of just `data`.
71+
function unwrapEnvelope(raw: unknown): unknown {
72+
if (
73+
raw !== null
74+
&& typeof raw === 'object'
75+
&& 'serialization' in raw
76+
&& 'data' in raw
77+
) {
78+
return (raw as { data: unknown }).data
79+
}
80+
return raw
81+
}
82+
6383
export function createStaticRpcCaller(
6484
manifest: StaticRpcManifest,
6585
fetchJson: (path: string) => Promise<any>,
@@ -68,16 +88,22 @@ export function createStaticRpcCaller(
6888
const queryRecordCache = new Map<string, Promise<StaticRpcRecord>>()
6989

7090
function reviveIfStructuredClone(value: unknown, serialization: StaticRpcSerialization | undefined): any {
71-
if (serialization === 'structured-clone')
72-
return structuredCloneDeserialize(value as any)
91+
// structured-clone-es always encodes to a records array; a non-array here
92+
// means the payload was not SC-encoded, so pass it through untouched.
93+
if (serialization === 'structured-clone' && Array.isArray(value))
94+
return structuredCloneDeserialize(value)
7395
return value
7496
}
7597

98+
function decode(raw: unknown, serialization: StaticRpcSerialization | undefined): any {
99+
return reviveIfStructuredClone(unwrapEnvelope(raw), serialization)
100+
}
101+
76102
async function loadStatic(entry: StaticRpcManifestStaticEntry): Promise<any> {
77103
if (!staticCache.has(entry.path)) {
78104
staticCache.set(
79105
entry.path,
80-
fetchJson(entry.path).then(raw => reviveIfStructuredClone(raw, entry.serialization)),
106+
fetchJson(entry.path).then(raw => decode(raw, entry.serialization)),
81107
)
82108
}
83109
const data = await staticCache.get(entry.path)!
@@ -94,7 +120,7 @@ export function createStaticRpcCaller(
94120
if (!queryRecordCache.has(path)) {
95121
queryRecordCache.set(
96122
path,
97-
fetchJson(path).then(raw => reviveIfStructuredClone(raw, serialization)),
123+
fetchJson(path).then(raw => decode(raw, serialization)),
98124
)
99125
}
100126
return await queryRecordCache.get(path)!
@@ -107,7 +133,7 @@ export function createStaticRpcCaller(
107133

108134
const entry = manifest[functionName]
109135
if (isStaticEntry(entry)) {
110-
if (args.length > 0) {
136+
if (hasMeaningfulArgs(args)) {
111137
throw new Error(
112138
`[devframe-rpc] No dump match for "${functionName}" with args: ${JSON.stringify(args)}`,
113139
)
@@ -134,7 +160,7 @@ export function createStaticRpcCaller(
134160
)
135161
}
136162

137-
if (args.length === 0) {
163+
if (!hasMeaningfulArgs(args)) {
138164
return entry
139165
}
140166

0 commit comments

Comments
 (0)