Skip to content

Commit fb5789b

Browse files
committed
refactor: move the RPC wire codec to devframe/internal
`createRpcWireCodec` / `peekRpcWireFrame` / `RpcWireCodec` are cross-transport plumbing, not user API — they rode onto the public `devframe/rpc` surface through the serialization module's star export. Move them to their own module surfaced only via `devframe/internal` (explicitly unstable), where custom transport implementations reach them. Also add an AGENTS.md convention: be very strict about adding or changing public APIs — prefer devframe/internal for shared plumbing, watch what rides along star-exported barrels, and treat every tsnapi snapshot diff as an API-design decision.
1 parent f722847 commit fb5789b

12 files changed

Lines changed: 109 additions & 84 deletions

File tree

AGENTS.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,7 @@ The `pnpm test` script intentionally runs `build` first so `tsnapi` snapshots co
3838

3939
## Conventions
4040

41+
- **Be very strict about the public API surface.** Every exported symbol on a published subpath is a contract users can depend on — additions and changes must be deliberate, not a side effect of where code happens to live. Before exporting anything new, ask whether it needs to be public at all: helpers shared between first-party packages and transports belong on **`devframe/internal`** (explicitly unstable, can change in any minor release), and module-local code should simply not be exported. Barrel files that `export *` make accidental exposure easy — when adding to a star-exported module, check what rides along. The `tsnapi` snapshots under `tests/__snapshots__/tsnapi/` guard the entire surface: review every snapshot diff as an API-design decision, never regenerate it as a chore, and treat a `TSNAPI_ALLOW_BREAKING` update as something that needs the same scrutiny as the breaking change itself.
4142
- RPC functions must use `defineRpcFunction`; always namespace IDs `devframes:plugin:<slug>:<fn-name>` (matching the plugin's `@devframes/plugin-<slug>` package name).
4243
- **Stay validator-neutral.** `devframe` and every `@devframes/*` package must not introduce a preferred schema validator dependency — no `valibot`, `zod`, `arktype`, etc. in their runtime `dependencies`. `args`/`returns`/flag schemas are typed against [Standard Schema](https://standardschema.dev/) (`@standard-schema/spec`, types-only); first-party code that needs to author a schema uses the built-in zero-dep `devframe/utils/simple-schema` builder (deliberately minimal — not a general validator). JSON-schema conversion uses each schema's own Standard JSON Schema converter (`~standard.jsonSchema`, implemented by e.g. zod 4) when present and degrades to a permissive object otherwise — no converter library and no vendor dependency is required. Docs, by contrast, should point *users* at a real validator for their own integrations — recommend **valibot** (lightest) or **zod** (worth reusing if they already pull it via the JSON-render or MCP integrations).
4344
- Shared state via `devframe/utils/shared-state`; keep values serializable.

packages/devframe/src/internal/index.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,10 @@
1414
// - `registerDevframeInstance` / `listLiveDevframeInstances` — the instance
1515
// registry: a custom host advertises itself; a devtool (the inspect plugin's
1616
// Instances tab, the connector) enumerates what's running.
17+
// - `createRpcWireCodec` / `peekRpcWireFrame` — the per-connection wire
18+
// codec (strict-JSON ⇄ structured-clone dispatch) and envelope peeker the
19+
// built-in WS/SSE transports share; a custom transport implementation
20+
// reuses them to speak the identical wire protocol.
1721
// - `createH3DevframeHost` — the node/standalone `DevframeHost` implementation
1822
// (filesystem storage paths + origin resolution) passed to `createHostContext`.
1923
// - `createInstanceShell` — the shared machinery behind `initDevframe` and
@@ -42,3 +46,5 @@ export type {
4246
export { createContextRpcServer } from '../node/rpc-core'
4347
export type { ContextRpcServer, CreateContextRpcServerOptions } from '../node/rpc-core'
4448
export { normalizeHttpServerUrl } from '../node/utils'
49+
export { createRpcWireCodec, peekRpcWireFrame } from '../rpc/wire-codec'
50+
export type { RpcWireCodec } from '../rpc/wire-codec'

packages/devframe/src/rpc/serialization.ts

Lines changed: 0 additions & 75 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,3 @@
1-
import type { RpcFunctionDefinitionAny } from './types'
2-
import { structuredCloneParse, structuredCloneStringify } from 'devframe/utils/structured-clone'
31
import { diagnostics } from './diagnostics'
42

53
/**
@@ -84,79 +82,6 @@ export function strictJsonStringify(value: unknown, fnName: string = ''): string
8482
})
8583
}
8684

87-
/** The per-connection `serialize`/`deserialize` pair for a live RPC wire. */
88-
export interface RpcWireCodec {
89-
serialize: (msg: any) => string
90-
deserialize: (raw: string) => any
91-
}
92-
93-
const EMPTY_WIRE_DEFS: ReadonlyMap<string, Pick<RpcFunctionDefinitionAny, 'jsonSerializable'>> = new Map()
94-
95-
/**
96-
* Build the per-connection wire codec every live transport (WS server, WS
97-
* client, SSE server, SSE client) shares: per-method dispatch between strict
98-
* JSON (methods declared `jsonSerializable: true`) and `s:`-prefixed
99-
* structured-clone (everything else, including all error envelopes), with a
100-
* request-id → method map so a response independently picks the same
101-
* encoder as its request. One codec per connection — request-id spaces
102-
* don't collide across connections.
103-
*/
104-
export function createRpcWireCodec(
105-
definitions: ReadonlyMap<string, Pick<RpcFunctionDefinitionAny, 'jsonSerializable'>> = EMPTY_WIRE_DEFS,
106-
): RpcWireCodec {
107-
// Maps an incoming request id to its method name so the matching
108-
// outgoing response can look the method back up in `definitions` and
109-
// pick the right encoder.
110-
const pendingRequestMethods = new Map<string, string>()
111-
return {
112-
serialize: (msg: any): string => {
113-
let method: string | undefined
114-
if (msg.t === 'q') {
115-
method = msg.m
116-
}
117-
else {
118-
method = pendingRequestMethods.get(msg.i)
119-
pendingRequestMethods.delete(msg.i)
120-
}
121-
// `jsonSerializable` constrains the return-value path (args + return).
122-
// Error envelopes (`{ t: 's', i, e }`) carry a thrown value — fall back
123-
// to structured-clone so they round-trip instead of crashing the serializer.
124-
// Detect via `'e' in msg` so `throw undefined` still routes through SC.
125-
const isErrorResponse = msg.t === 's' && 'e' in msg
126-
const useJson = !isErrorResponse && !!method && definitions.get(method)?.jsonSerializable === true
127-
if (useJson)
128-
return strictJsonStringify(msg, method ?? '')
129-
return `${STRUCTURED_CLONE_PREFIX}${structuredCloneStringify(msg)}`
130-
},
131-
deserialize: (raw: string): any => {
132-
const msg: any = raw.startsWith(STRUCTURED_CLONE_PREFIX)
133-
? structuredCloneParse(raw.slice(STRUCTURED_CLONE_PREFIX.length))
134-
: JSON.parse(raw)
135-
if (msg.t === 'q' && msg.i && msg.m)
136-
pendingRequestMethods.set(msg.i, msg.m)
137-
return msg
138-
},
139-
}
140-
}
141-
142-
/**
143-
* Peek at a wire frame's birpc envelope without engaging a codec's
144-
* request-id bookkeeping — used by the SSE transport to route a frame
145-
* (park a POST for its response / answer with a bare 202) before it is
146-
* handed to birpc proper.
147-
*/
148-
export function peekRpcWireFrame(raw: string): { t?: string, i?: string } {
149-
try {
150-
const msg: any = raw.startsWith(STRUCTURED_CLONE_PREFIX)
151-
? structuredCloneParse(raw.slice(STRUCTURED_CLONE_PREFIX.length))
152-
: JSON.parse(raw)
153-
return { t: msg?.t, i: msg?.i }
154-
}
155-
catch {
156-
return {}
157-
}
158-
}
159-
16085
function nonJsonAt(fnName: string, type: string, parent: unknown, key: string): Error {
16186
const path = formatPath(parent, key)
16287
return diagnostics.DF0020({ name: fnName || '<anonymous>', type, path })

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

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import type { ChannelOptions } from 'birpc'
22
import type { RpcFunctionDefinitionAny } from '../types'
33
import { DEVFRAME_AUTH_TOKEN_QUERY_PARAM, DEVFRAME_SSE_SESSION_HEADER } from 'devframe/constants'
4-
import { createRpcWireCodec } from '../serialization'
4+
import { createRpcWireCodec } from '../wire-codec'
55

66
export interface SseRpcChannelOptions {
77
/** Resolved `http(s)://` URL of the SSE endpoint. */

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

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@ import type { RpcFunctionDefinitionAny } from '../types'
33
import type { DevframeNodeRpcSessionMeta, DevframeRpcConnection } from './session'
44
import type { WsOriginRegistry } from './ws-server'
55
import { DEVFRAME_SSE_SESSION_HEADER } from 'devframe/constants'
6-
import { createRpcWireCodec, peekRpcWireFrame } from '../serialization'
6+
import { createRpcWireCodec, peekRpcWireFrame } from '../wire-codec'
77
import { createRpcSessionMeta } from './session'
88
import { isAllowedOrigin } from './ws-server'
99

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

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import type { ChannelOptions } from 'birpc'
22
import type { RpcFunctionDefinitionAny } from '../types'
33
import { DEVFRAME_AUTH_TOKEN_QUERY_PARAM } from 'devframe/constants'
4-
import { createRpcWireCodec } from '../serialization'
4+
import { createRpcWireCodec } from '../wire-codec'
55

66
export interface WsRpcChannelOptions {
77
url: string

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

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,7 @@ import { createServer as createHttpsServer } from 'node:https'
1313
import crossws from 'crossws/adapters/node'
1414
import { DEVFRAME_VIEWER_ORIGIN_QUERY_PARAM, DEVFRAME_VIEWER_ORIGIN_TOKEN_QUERY_PARAM } from 'devframe/constants'
1515
import { randomToken, timingSafeEqual } from 'devframe/utils/crypto-token'
16-
import { createRpcWireCodec } from '../serialization'
16+
import { createRpcWireCodec } from '../wire-codec'
1717
import { createRpcSessionMeta } from './session'
1818

1919
export type {
Lines changed: 87 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,87 @@
1+
import type { RpcFunctionDefinitionAny } from './types'
2+
import { structuredCloneParse, structuredCloneStringify } from 'devframe/utils/structured-clone'
3+
import { strictJsonStringify, STRUCTURED_CLONE_PREFIX } from './serialization'
4+
5+
/**
6+
* The per-connection `serialize`/`deserialize` pair for a live RPC wire.
7+
*
8+
* @internal
9+
* implementations; not part of the stable public API.
10+
*/
11+
export interface RpcWireCodec {
12+
serialize: (msg: any) => string
13+
deserialize: (raw: string) => any
14+
}
15+
16+
const EMPTY_WIRE_DEFS: ReadonlyMap<string, Pick<RpcFunctionDefinitionAny, 'jsonSerializable'>> = new Map()
17+
18+
/**
19+
* Build the per-connection wire codec every live transport (WS server, WS
20+
* client, SSE server, SSE client) shares: per-method dispatch between strict
21+
* JSON (methods declared `jsonSerializable: true`) and `s:`-prefixed
22+
* structured-clone (everything else, including all error envelopes), with a
23+
* request-id → method map so a response independently picks the same
24+
* encoder as its request. One codec per connection — request-id spaces
25+
* don't collide across connections.
26+
*
27+
* @internal
28+
* implementations; not part of the stable public API.
29+
*/
30+
export function createRpcWireCodec(
31+
definitions: ReadonlyMap<string, Pick<RpcFunctionDefinitionAny, 'jsonSerializable'>> = EMPTY_WIRE_DEFS,
32+
): RpcWireCodec {
33+
// Maps an incoming request id to its method name so the matching
34+
// outgoing response can look the method back up in `definitions` and
35+
// pick the right encoder.
36+
const pendingRequestMethods = new Map<string, string>()
37+
return {
38+
serialize: (msg: any): string => {
39+
let method: string | undefined
40+
if (msg.t === 'q') {
41+
method = msg.m
42+
}
43+
else {
44+
method = pendingRequestMethods.get(msg.i)
45+
pendingRequestMethods.delete(msg.i)
46+
}
47+
// `jsonSerializable` constrains the return-value path (args + return).
48+
// Error envelopes (`{ t: 's', i, e }`) carry a thrown value — fall back
49+
// to structured-clone so they round-trip instead of crashing the serializer.
50+
// Detect via `'e' in msg` so `throw undefined` still routes through SC.
51+
const isErrorResponse = msg.t === 's' && 'e' in msg
52+
const useJson = !isErrorResponse && !!method && definitions.get(method)?.jsonSerializable === true
53+
if (useJson)
54+
return strictJsonStringify(msg, method ?? '')
55+
return `${STRUCTURED_CLONE_PREFIX}${structuredCloneStringify(msg)}`
56+
},
57+
deserialize: (raw: string): any => {
58+
const msg: any = raw.startsWith(STRUCTURED_CLONE_PREFIX)
59+
? structuredCloneParse(raw.slice(STRUCTURED_CLONE_PREFIX.length))
60+
: JSON.parse(raw)
61+
if (msg.t === 'q' && msg.i && msg.m)
62+
pendingRequestMethods.set(msg.i, msg.m)
63+
return msg
64+
},
65+
}
66+
}
67+
68+
/**
69+
* Peek at a wire frame's birpc envelope without engaging a codec's
70+
* request-id bookkeeping — used by the SSE transport to route a frame
71+
* (park a POST for its response / answer with a bare 202) before it is
72+
* handed to birpc proper.
73+
*
74+
* @internal
75+
* implementations; not part of the stable public API.
76+
*/
77+
export function peekRpcWireFrame(raw: string): { t?: string, i?: string } {
78+
try {
79+
const msg: any = raw.startsWith(STRUCTURED_CLONE_PREFIX)
80+
? structuredCloneParse(raw.slice(STRUCTURED_CLONE_PREFIX.length))
81+
: JSON.parse(raw)
82+
return { t: msg?.t, i: msg?.i }
83+
}
84+
catch {
85+
return {}
86+
}
87+
}

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

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,10 @@ export interface CreateH3DevframeHostOptions {
99
appName: string;
1010
workspaceRoot?: string;
1111
}
12+
export interface RpcWireCodec {
13+
serialize: (_: any) => string;
14+
deserialize: (_: string) => any;
15+
}
1216
// #endregion
1317

1418
// #region Types
@@ -46,7 +50,12 @@ export declare class DevframeAgentHost implements DevframeAgentHost$1 {
4650
// #region Functions
4751
export declare function coerceAgentPositionalArgs(_: unknown, _: readonly unknown[] | undefined, _?: AgentArgsFallback): unknown[];
4852
export declare function createH3DevframeHost(_: CreateH3DevframeHostOptions): DevframeHost;
53+
export declare function createRpcWireCodec(_?: ReadonlyMap<string, Pick<RpcFunctionDefinitionAny, 'jsonSerializable'>>): RpcWireCodec;
4954
export declare function normalizeHttpServerUrl(_: string, _: number | string): string;
55+
export declare function peekRpcWireFrame(_: string): {
56+
t?: string;
57+
i?: string;
58+
};
5059
// #endregion
5160

5261
// #region Other

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

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,9 +6,11 @@ export { coerceAgentPositionalArgs }
66
export { createContextRpcServer }
77
export { createH3DevframeHost }
88
export { createInstanceShell }
9+
export { createRpcWireCodec }
910
export { DevframeAgentHost }
1011
export { listLiveDevframeInstances }
1112
export { normalizeHttpServerUrl }
13+
export { peekRpcWireFrame }
1214
export { registerDevframeInstance }
1315
export { resolveInstanceRegister }
1416
export { samePath }

0 commit comments

Comments
 (0)