diff --git a/packages/devframe/src/client/static-rpc.test.ts b/packages/devframe/src/client/static-rpc.test.ts index e91fe3a..387ce1b 100644 --- a/packages/devframe/src/client/static-rpc.test.ts +++ b/packages/devframe/src/client/static-rpc.test.ts @@ -170,4 +170,77 @@ describe('createStaticRpcCaller', () => { await expect(caller.call('demo:legacy-static', [])).resolves.toEqual({ items: [1, 2, 3] }) }) + + it('treats placeholder args on static entries as a no-arg call', async () => { + const caller = createStaticRpcCaller( + { + 'demo:messages': { + type: 'static', + path: DEMO_STATIC_VERSION_PATH, + }, + }, + async () => ({ output: ['ok'] }), + ) + + await expect(caller.call('demo:messages', [null])).resolves.toEqual(['ok']) + await expect(caller.call('demo:messages', [undefined])).resolves.toEqual(['ok']) + await expect(caller.call('demo:messages', ['real'])).rejects.toThrow('No dump match') + }) + + it('treats placeholder args on legacy inline entries as a no-arg call', async () => { + const caller = createStaticRpcCaller( + { + 'demo:legacy': { ok: true }, + }, + async () => { + throw new Error('Should not fetch') + }, + ) + + await expect(caller.call('demo:legacy', [null])).resolves.toEqual({ ok: true }) + await expect(caller.call('demo:legacy', ['real'])).rejects.toThrow('No dump match') + }) + + it('unwraps enveloped static files written as the full StaticRpcDumpFile', async () => { + const caller = createStaticRpcCaller( + { + 'demo:graph': { + type: 'static', + path: `${DEVFRAME_RPC_DUMP_DIRNAME}/demo~graph.static.json`, + serialization: 'structured-clone', + }, + }, + async () => ({ + serialization: 'structured-clone', + fnName: 'demo:graph', + data: JSON.parse(structuredCloneStringify({ output: new Map([['a', 1]]) })), + }), + ) + + const result = await caller.call('demo:graph', []) as Map + expect(result).toBeInstanceOf(Map) + expect(result.get('a')).toBe(1) + }) + + it('unwraps enveloped query records written as the full StaticRpcDumpFile', async () => { + const recordPath = `${DEMO_QUERY_BASE_PATH}.record.${hash(['k'])}.json` + const caller = createStaticRpcCaller( + { + 'demo:query-set': { + type: 'query', + serialization: 'structured-clone', + records: { [hash(['k'])]: recordPath }, + }, + }, + async () => ({ + serialization: 'structured-clone', + fnName: 'demo:query-set', + data: JSON.parse(structuredCloneStringify({ inputs: ['k'], output: new Set(['x']) })), + }), + ) + + const result = await caller.call('demo:query-set', ['k']) as Set + expect(result).toBeInstanceOf(Set) + expect(result.has('x')).toBe(true) + }) }) diff --git a/packages/devframe/src/client/static-rpc.ts b/packages/devframe/src/client/static-rpc.ts index 06ba74d..611e763 100644 --- a/packages/devframe/src/client/static-rpc.ts +++ b/packages/devframe/src/client/static-rpc.ts @@ -60,6 +60,26 @@ function resolveRecordOutput(record: StaticRpcRecord): any { return record.output } +// Placeholder args (`[null]`/`[undefined]`) from framework setup hooks carry no +// addressing info and must be treated as a no-arg call. +function hasMeaningfulArgs(args: any[]): boolean { + return args.some(arg => arg !== null && arg !== undefined) +} + +// `collectStaticRpcDump`/`StaticRpcDumpFile` are public, so consumers may persist +// the whole `{ serialization, fnName, data }` envelope instead of just `data`. +function unwrapEnvelope(raw: unknown): unknown { + if ( + raw !== null + && typeof raw === 'object' + && 'serialization' in raw + && 'data' in raw + ) { + return (raw as { data: unknown }).data + } + return raw +} + export function createStaticRpcCaller( manifest: StaticRpcManifest, fetchJson: (path: string) => Promise, @@ -68,16 +88,22 @@ export function createStaticRpcCaller( const queryRecordCache = new Map>() function reviveIfStructuredClone(value: unknown, serialization: StaticRpcSerialization | undefined): any { - if (serialization === 'structured-clone') - return structuredCloneDeserialize(value as any) + // structured-clone-es always encodes to a records array; a non-array here + // means the payload was not SC-encoded, so pass it through untouched. + if (serialization === 'structured-clone' && Array.isArray(value)) + return structuredCloneDeserialize(value) return value } + function decode(raw: unknown, serialization: StaticRpcSerialization | undefined): any { + return reviveIfStructuredClone(unwrapEnvelope(raw), serialization) + } + async function loadStatic(entry: StaticRpcManifestStaticEntry): Promise { if (!staticCache.has(entry.path)) { staticCache.set( entry.path, - fetchJson(entry.path).then(raw => reviveIfStructuredClone(raw, entry.serialization)), + fetchJson(entry.path).then(raw => decode(raw, entry.serialization)), ) } const data = await staticCache.get(entry.path)! @@ -94,7 +120,7 @@ export function createStaticRpcCaller( if (!queryRecordCache.has(path)) { queryRecordCache.set( path, - fetchJson(path).then(raw => reviveIfStructuredClone(raw, serialization)), + fetchJson(path).then(raw => decode(raw, serialization)), ) } return await queryRecordCache.get(path)! @@ -107,7 +133,7 @@ export function createStaticRpcCaller( const entry = manifest[functionName] if (isStaticEntry(entry)) { - if (args.length > 0) { + if (hasMeaningfulArgs(args)) { throw new Error( `[devframe-rpc] No dump match for "${functionName}" with args: ${JSON.stringify(args)}`, ) @@ -134,7 +160,7 @@ export function createStaticRpcCaller( ) } - if (args.length === 0) { + if (!hasMeaningfulArgs(args)) { return entry }