Skip to content

Commit 31de7cf

Browse files
antfuclaude
andcommitted
refactor(devframe): inline per-call channel options into ws transports
Removes `makePerCallChannelOptions` from `rpc/serialization.ts` and inlines the wire dispatch logic directly into `ws-server.ts` and `ws-client.ts`. The helper had only two callers and the abstraction wasn't pulling its weight — a single function name shouldn't need a factory builder. `STRUCTURED_CLONE_PREFIX`, `strictJsonStringify`, and the `structuredClone*` re-exports remain — they're the actual reusable primitives. The unit tests targeting `makePerCallChannelOptions` are dropped; the dispatch behavior is now exercised end-to-end via the existing static-rpc and static-dump tests. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent 5b17ee9 commit 31de7cf

6 files changed

Lines changed: 66 additions & 150 deletions

File tree

devframe/packages/devframe/src/rpc/serialization.test.ts

Lines changed: 1 addition & 68 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,5 @@
11
import { describe, expect, it, vi } from 'vitest'
2-
import {
3-
makePerCallChannelOptions,
4-
strictJsonStringify,
5-
STRUCTURED_CLONE_PREFIX,
6-
} from './serialization'
2+
import { strictJsonStringify } from './serialization'
73

84
describe('strictJsonStringify', () => {
95
it('matches JSON.stringify for plain JSON values', () => {
@@ -87,66 +83,3 @@ describe('strictJsonStringify', () => {
8783
expect(replacerCalls).toEqual(['', 'a', 'b', 'c', '0', '1'])
8884
})
8985
})
90-
91-
describe('makePerCallChannelOptions', () => {
92-
function makeChannel(jsonMethods: string[]) {
93-
const defs = new Map(
94-
jsonMethods.map(name => [name, { jsonSerializable: true as const }]),
95-
)
96-
return makePerCallChannelOptions(defs)
97-
}
98-
99-
it('encodes JSON-flagged requests without a prefix', () => {
100-
const ch = makeChannel(['fn'])
101-
const wire = ch.serialize!({ t: 'q', i: '1', m: 'fn', a: [1, 2] })
102-
expect(wire).toBe('{"t":"q","i":"1","m":"fn","a":[1,2]}')
103-
expect(wire.startsWith('s:')).toBe(false)
104-
})
105-
106-
it('encodes structured-clone requests with the s: prefix', () => {
107-
const ch = makeChannel([])
108-
const wire = ch.serialize!({ t: 'q', i: '1', m: 'fn', a: [new Map([['k', 1]])] })
109-
expect(typeof wire).toBe('string')
110-
expect((wire as string).startsWith(STRUCTURED_CLONE_PREFIX)).toBe(true)
111-
})
112-
113-
it('decodes per the wire prefix without consulting defs', () => {
114-
const ch = makeChannel([]) // no defs at all on this channel
115-
// JSON-encoded request — no prefix.
116-
const json = ch.deserialize!('{"t":"q","i":"1","m":"fn","a":[1]}')
117-
expect(json).toEqual({ t: 'q', i: '1', m: 'fn', a: [1] })
118-
119-
// SC-encoded message: produce a real SC wire string from a sender
120-
// that doesn't know `fn` (so it falls through to SC), then route
121-
// that string through this channel's deserialize. Map round-trips.
122-
const sender = makeChannel([])
123-
const wire = sender.serialize!({ t: 'q', i: '2', m: 'fn', a: [new Map([['k', 1]])] }) as string
124-
expect(wire.startsWith(STRUCTURED_CLONE_PREFIX)).toBe(true)
125-
const decoded = ch.deserialize!(wire) as { t: 'q', a: [Map<string, number>] }
126-
expect(decoded.a[0]).toBeInstanceOf(Map)
127-
expect(decoded.a[0].get('k')).toBe(1)
128-
})
129-
130-
it('mirrors the originating method to dispatch the response encoding', () => {
131-
const ch = makeChannel(['fn'])
132-
// Receive a request → record method
133-
ch.deserialize!('{"t":"q","i":"abc","m":"fn","a":[]}')
134-
// Send the response → uses JSON because fn is jsonSerializable: true
135-
const wire = ch.serialize!({ t: 's', i: 'abc', r: { ok: 1 } }) as string
136-
expect(wire.startsWith(STRUCTURED_CLONE_PREFIX)).toBe(false)
137-
expect(JSON.parse(wire)).toEqual({ t: 's', i: 'abc', r: { ok: 1 } })
138-
})
139-
140-
it('falls back to structured-clone for unknown methods', () => {
141-
const ch = makeChannel(['known'])
142-
const wire = ch.serialize!({ t: 'q', i: '1', m: 'unknown', a: [] }) as string
143-
expect(wire.startsWith(STRUCTURED_CLONE_PREFIX)).toBe(true)
144-
})
145-
146-
it('throws DF0019 when a JSON-flagged request carries non-JSON args', () => {
147-
const ch = makeChannel(['fn'])
148-
expect(() =>
149-
ch.serialize!({ t: 'q', i: '1', m: 'fn', a: [new Map()] }),
150-
).toThrowError(/jsonSerializable: true/)
151-
})
152-
})

devframe/packages/devframe/src/rpc/serialization.ts

Lines changed: 0 additions & 69 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,3 @@
1-
import type { ChannelOptions } from 'birpc'
21
import {
32
deserialize as structuredCloneDeserialize,
43
parse as structuredCloneParse,
@@ -27,74 +26,6 @@ export { structuredCloneDeserialize, structuredCloneParse, structuredCloneString
2726
*/
2827
export const STRUCTURED_CLONE_PREFIX = 's:'
2928

30-
interface BirpcRequest {
31-
t: 'q'
32-
i?: string
33-
m: string
34-
a: unknown[]
35-
o?: boolean
36-
}
37-
38-
interface BirpcResponse {
39-
t: 's'
40-
i: string
41-
r?: unknown
42-
e?: unknown
43-
}
44-
45-
type BirpcMessage = BirpcRequest | BirpcResponse
46-
47-
function isJsonMethod(
48-
defs: ReadonlyMap<string, { jsonSerializable?: boolean }>,
49-
name: string | undefined,
50-
): boolean {
51-
return !!name && defs.get(name)?.jsonSerializable === true
52-
}
53-
54-
/**
55-
* Build a per-call `serialize`/`deserialize` pair for birpc channels.
56-
*
57-
* The returned options switch encoder per-message based on the
58-
* `jsonSerializable` flag of the dispatched function. Outgoing requests
59-
* read the method from `msg.m`; outgoing responses look the method back
60-
* up from a per-channel `pendingRequestMethods` map populated whenever
61-
* a request is observed in `deserialize`.
62-
*
63-
* Pass an empty/partial `defs` map on peers that don't have the full
64-
* registry — encoding falls back to structured-clone (the safer
65-
* superset), and decoding still routes correctly via the wire prefix.
66-
*/
67-
export function makePerCallChannelOptions(
68-
defs: ReadonlyMap<string, { jsonSerializable?: boolean }>,
69-
): Pick<ChannelOptions, 'serialize' | 'deserialize'> {
70-
const pendingRequestMethods = new Map<string, string>()
71-
72-
return {
73-
serialize(msg: BirpcMessage): string {
74-
let method: string | undefined
75-
if (msg.t === 'q') {
76-
method = msg.m
77-
}
78-
else {
79-
method = pendingRequestMethods.get(msg.i)
80-
pendingRequestMethods.delete(msg.i)
81-
}
82-
const useJson = isJsonMethod(defs, method)
83-
if (useJson)
84-
return strictJsonStringify(msg, method ?? '')
85-
return `${STRUCTURED_CLONE_PREFIX}${structuredCloneStringify(msg)}`
86-
},
87-
deserialize(raw: string): BirpcMessage {
88-
const msg: BirpcMessage = raw.startsWith(STRUCTURED_CLONE_PREFIX)
89-
? (structuredCloneParse(raw.slice(STRUCTURED_CLONE_PREFIX.length)) as BirpcMessage)
90-
: (JSON.parse(raw) as BirpcMessage)
91-
if (msg.t === 'q' && msg.i && msg.m)
92-
pendingRequestMethods.set(msg.i, msg.m)
93-
return msg
94-
},
95-
}
96-
}
97-
9829
/**
9930
* `JSON.stringify` with a single-pass strict replacer.
10031
*

devframe/packages/devframe/src/rpc/transports/ws-client.ts

Lines changed: 32 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,11 @@
11
import type { ChannelOptions } from 'birpc'
22
import type { RpcFunctionDefinitionAny } from '../types'
3-
import { makePerCallChannelOptions } from '../serialization'
3+
import {
4+
strictJsonStringify,
5+
STRUCTURED_CLONE_PREFIX,
6+
structuredCloneParse,
7+
structuredCloneStringify,
8+
} from '../serialization'
49

510
export interface WsRpcChannelOptions {
611
url: string
@@ -52,7 +57,10 @@ export function createWsRpcChannel(options: WsRpcChannelOptions): ChannelOptions
5257
onDisconnected(e)
5358
})
5459

55-
const perCall = makePerCallChannelOptions(definitions)
60+
// Per-channel state: maps an incoming request id to its method name
61+
// so the matching outgoing response can independently look the
62+
// method up in `definitions` and pick the right encoder.
63+
const pendingRequestMethods = new Map<string, string>()
5664
return {
5765
on: (handler: (data: string) => void) => {
5866
ws.addEventListener('message', (e) => {
@@ -71,7 +79,27 @@ export function createWsRpcChannel(options: WsRpcChannelOptions): ChannelOptions
7179
ws.addEventListener('open', handler)
7280
}
7381
},
74-
serialize: perCall.serialize,
75-
deserialize: perCall.deserialize,
82+
serialize: (msg: any): string => {
83+
let method: string | undefined
84+
if (msg.t === 'q') {
85+
method = msg.m
86+
}
87+
else {
88+
method = pendingRequestMethods.get(msg.i)
89+
pendingRequestMethods.delete(msg.i)
90+
}
91+
const useJson = !!method && definitions.get(method)?.jsonSerializable === true
92+
if (useJson)
93+
return strictJsonStringify(msg, method ?? '')
94+
return `${STRUCTURED_CLONE_PREFIX}${structuredCloneStringify(msg)}`
95+
},
96+
deserialize: (raw: string): any => {
97+
const msg: any = raw.startsWith(STRUCTURED_CLONE_PREFIX)
98+
? structuredCloneParse(raw.slice(STRUCTURED_CLONE_PREFIX.length))
99+
: JSON.parse(raw)
100+
if (msg.t === 'q' && msg.i && msg.m)
101+
pendingRequestMethods.set(msg.i, msg.m)
102+
return msg
103+
},
76104
}
77105
}

devframe/packages/devframe/src/rpc/transports/ws-server.ts

Lines changed: 33 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,12 @@ import type { WebSocket } from 'ws'
55
import type { RpcFunctionDefinitionAny } from '../types'
66
import { createServer as createHttpsServer } from 'node:https'
77
import { WebSocketServer } from 'ws'
8-
import { makePerCallChannelOptions } from '../serialization'
8+
import {
9+
strictJsonStringify,
10+
STRUCTURED_CLONE_PREFIX,
11+
structuredCloneParse,
12+
structuredCloneStringify,
13+
} from '../serialization'
914

1015
export interface DevToolsNodeRpcSessionMeta {
1116
id: number
@@ -91,10 +96,11 @@ export function attachWsRpcTransport<
9196
subscribedStates: new Set(),
9297
}
9398

94-
// Per-connection serializer state (the pending request-id map that
95-
// mirrors method metadata from request to response). Each WS gets
96-
// its own so request-id spaces don't collide across sessions.
97-
const perCall = makePerCallChannelOptions(definitions)
99+
// Per-connection state: maps an incoming request id to its method
100+
// name so the matching outgoing response can look the method back
101+
// up in `definitions` and pick the right encoder. One map per WS
102+
// session — request-id spaces don't collide across sessions.
103+
const pendingRequestMethods = new Map<string, string>()
98104
const channel: ChannelOptions = {
99105
post: (data) => {
100106
ws.send(data)
@@ -104,8 +110,28 @@ export function attachWsRpcTransport<
104110
fn(data.toString())
105111
})
106112
},
107-
serialize: serializeOverride ?? perCall.serialize,
108-
deserialize: deserializeOverride ?? perCall.deserialize,
113+
serialize: serializeOverride ?? ((msg: any): string => {
114+
let method: string | undefined
115+
if (msg.t === 'q') {
116+
method = msg.m
117+
}
118+
else {
119+
method = pendingRequestMethods.get(msg.i)
120+
pendingRequestMethods.delete(msg.i)
121+
}
122+
const useJson = !!method && definitions.get(method)?.jsonSerializable === true
123+
if (useJson)
124+
return strictJsonStringify(msg, method ?? '')
125+
return `${STRUCTURED_CLONE_PREFIX}${structuredCloneStringify(msg)}`
126+
}),
127+
deserialize: deserializeOverride ?? ((raw: string): any => {
128+
const msg: any = raw.startsWith(STRUCTURED_CLONE_PREFIX)
129+
? structuredCloneParse(raw.slice(STRUCTURED_CLONE_PREFIX.length))
130+
: JSON.parse(raw)
131+
if (msg.t === 'q' && msg.i && msg.m)
132+
pendingRequestMethods.set(msg.i, msg.m)
133+
return msg
134+
}),
109135
meta,
110136
}
111137

devframe/tests/__snapshots__/tsnapi/devframe/rpc.snapshot.d.ts

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,6 @@ export { EntriesToObject }
1212
export { getDefinitionsWithDumps }
1313
export { getRpcHandler }
1414
export { getRpcResolvedSetupResult }
15-
export { makePerCallChannelOptions }
1615
export { RpcArgsSchema }
1716
export { RpcCacheManager }
1817
export { RpcCacheOptions }

devframe/tests/__snapshots__/tsnapi/devframe/rpc.snapshot.js

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,6 @@ export { dumpFunctions }
99
export { getDefinitionsWithDumps }
1010
export { getRpcHandler }
1111
export { getRpcResolvedSetupResult }
12-
export { makePerCallChannelOptions }
1312
export { RpcCacheManager }
1413
export { RpcFunctionsCollectorBase }
1514
export { strictJsonStringify }

0 commit comments

Comments
 (0)