Skip to content

Commit 1141007

Browse files
authored
fix(devframe): dedupe RPC augmentation interfaces in published dist (#21)
1 parent 9f38f73 commit 1141007

7 files changed

Lines changed: 161 additions & 64 deletions

File tree

docs/guide/rpc.md

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -177,6 +177,48 @@ const modules = await rpc.call('my-devframe:get-modules', { limit: 10 })
177177

178178
Client-side registration (for server→client calls) goes through `rpc.client.register()` — the mirror API of `ctx.rpc.register()`.
179179

180+
## Type-safe client registry
181+
182+
Devframe exposes two augmentable interfaces — `DevToolsRpcServerFunctions` (client→server calls) and `DevToolsRpcClientFunctions` (server→client calls) — so each registered RPC name shows up on the typed client. Augment them once per devframe via `declare module 'devframe'`.
183+
184+
The recommended pattern collects every server-side definition into a const array and feeds it through `RpcDefinitionsToFunctions`:
185+
186+
```ts
187+
import type { RpcDefinitionsToFunctions } from 'devframe/rpc'
188+
import { getFile, getModules } from './rpc'
189+
190+
const serverFunctions = [getModules, getFile] as const
191+
192+
declare module 'devframe' {
193+
interface DevToolsRpcServerFunctions
194+
extends RpcDefinitionsToFunctions<typeof serverFunctions> {}
195+
}
196+
```
197+
198+
Now `connectDevframe()` returns a client where every registered name is autocompletable and argument-typed:
199+
200+
```ts
201+
import { connectDevframe } from 'devframe/client'
202+
203+
const rpc = await connectDevframe()
204+
const modules = await rpc.call('my-devframe:get-modules', { limit: 10 })
205+
// ^? typed from the augmentation above
206+
```
207+
208+
For one-off augmentations, declare a single key with `RpcFunctionDefinitionToFunction`:
209+
210+
```ts
211+
import type { RpcFunctionDefinitionToFunction } from 'devframe/rpc'
212+
213+
declare module 'devframe' {
214+
interface DevToolsRpcServerFunctions {
215+
'my-devframe:get-modules': RpcFunctionDefinitionToFunction<typeof getModules>
216+
}
217+
}
218+
```
219+
220+
For server→client calls invoked via `ctx.rpc.broadcast`, augment `DevToolsRpcClientFunctions` the same way.
221+
180222
## Static dumps
181223

182224
For `static` functions, Devframe records the handler's output during `createBuild` and bakes it into the build:

docs/guide/shared-state.md

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -137,6 +137,23 @@ const unsubscribe = ctx.rpc.sharedState.onKeyAdded((key) => {
137137

138138
Protocol adapters (the [MCP adapter](./agent-native), for example) use this to surface shared state as dynamic resources.
139139

140+
## Type-safe keys
141+
142+
Augment `DevToolsRpcSharedStates` to type each shared-state key once, then both server and client lookups stay typed without per-call generics:
143+
144+
```ts
145+
declare module 'devframe' {
146+
interface DevToolsRpcSharedStates {
147+
'my-devframe:state': {
148+
count: number
149+
items: { id: string, name: string }[]
150+
}
151+
}
152+
}
153+
```
154+
155+
After this declaration, `ctx.rpc.sharedState.get('my-devframe:state')` and `rpc.sharedState.get('my-devframe:state')` both return a host typed to the declared shape.
156+
140157
## When to use shared state vs RPC
141158

142159
| Use shared state for | Use RPC for |

packages/devframe/package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -95,6 +95,7 @@
9595
"p-limit": "catalog:deps",
9696
"perfect-debounce": "catalog:deps",
9797
"structured-clone-es": "catalog:deps",
98+
"tinyglobby": "catalog:deps",
9899
"tsdown": "catalog:build",
99100
"ua-parser-modern": "catalog:inlined",
100101
"whenexpr": "catalog:deps"
Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
import { readFile } from 'node:fs/promises'
2+
import { fileURLToPath } from 'node:url'
3+
import { glob } from 'tinyglobby'
4+
import { describe, expect, it } from 'vitest'
5+
6+
const distRoot = fileURLToPath(new URL('../dist/', import.meta.url))
7+
8+
const AUGMENTABLE_INTERFACES = [
9+
'DevToolsRpcClientFunctions',
10+
'DevToolsRpcServerFunctions',
11+
'DevToolsRpcSharedStates',
12+
] as const
13+
14+
describe('rpc-augments dedupe', () => {
15+
it('declares each augmentable interface exactly once across dist/**/*.d.mts', async () => {
16+
const files = await glob('**/*.d.mts', { cwd: distRoot, absolute: true })
17+
const contents = await Promise.all(files.map(f => readFile(f, 'utf8')))
18+
for (const name of AUGMENTABLE_INTERFACES) {
19+
const re = new RegExp(`\\binterface ${name}\\b`, 'g')
20+
const total = contents.reduce((n, c) => n + (c.match(re)?.length ?? 0), 0)
21+
expect(total, name).toBe(1)
22+
}
23+
})
24+
})

packages/devframe/tsdown.config.ts

Lines changed: 71 additions & 51 deletions
Original file line numberDiff line numberDiff line change
@@ -55,37 +55,72 @@ const deps = {
5555
],
5656
}
5757

58-
// Split into two configs so client/agnostic and server entries live in
59-
// independent rolldown chunk graphs. A single combined build lets rolldown
60-
// hoist shared helpers into chunks that mix server-only imports like
61-
// `devframe/rpc/transports/ws-server` or `node:crypto`, which then leak into
62-
// browser-loaded outputs (e.g. `client/index.mjs`, `utils/hash.mjs`).
58+
// Shared by the runtime client build and the combined dts build below.
59+
const clientEntries = {
60+
'client/index': 'src/client/index.ts',
61+
'utils/colors': 'src/utils/colors.ts',
62+
'utils/events': 'src/utils/events.ts',
63+
'utils/hash': 'src/utils/hash.ts',
64+
'utils/human-id': 'src/utils/human-id.ts',
65+
'utils/nanoid': 'src/utils/nanoid.ts',
66+
'utils/promise': 'src/utils/promise.ts',
67+
'utils/shared-state': 'src/utils/shared-state.ts',
68+
'utils/streaming-channel': 'src/utils/streaming-channel.ts',
69+
'utils/structured-clone': 'src/utils/structured-clone.ts',
70+
'utils/when': 'src/utils/when.ts',
71+
}
72+
73+
// Shared by the runtime server build and the combined dts build below.
74+
const serverEntries = {
75+
'index': 'src/index.ts',
76+
'constants': 'src/constants.ts',
77+
'types/index': 'src/types/index.ts',
78+
'rpc/index': 'src/rpc/index.ts',
79+
'rpc/client': 'src/rpc/client.ts',
80+
'rpc/dump': 'src/rpc/dump/index.ts',
81+
'rpc/server': 'src/rpc/server.ts',
82+
'rpc/transports/ws-client': 'src/rpc/transports/ws-client.ts',
83+
'rpc/transports/ws-server': 'src/rpc/transports/ws-server.ts',
84+
'node/index': 'src/node/index.ts',
85+
'node/auth': 'src/node/auth/index.ts',
86+
'node/internal': 'src/node/internal/index.ts',
87+
'utils/launch-editor': 'src/utils/launch-editor.ts',
88+
'utils/open': 'src/utils/open.ts',
89+
'utils/serve-static': 'src/utils/serve-static.ts',
90+
'adapters/cli': 'src/adapters/cli.ts',
91+
'adapters/dev': 'src/adapters/dev.ts',
92+
'adapters/build': 'src/adapters/build.ts',
93+
'adapters/embedded': 'src/adapters/embedded.ts',
94+
'adapters/mcp': 'src/adapters/mcp/index.ts',
95+
'helpers/vite': 'src/helpers/vite.ts',
96+
'recipes/open-helpers': 'src/recipes/open-helpers.ts',
97+
}
98+
99+
// Three configs:
100+
//
101+
// 1. Runtime client/agnostic build (`dts: false`). Independent rolldown
102+
// chunk graph so server-only imports like `devframe/rpc/transports/ws-server`
103+
// or `node:crypto` can't leak into browser-loaded outputs
104+
// (`client/index.mjs`, `utils/hash.mjs`, …). `clean: true` clears
105+
// dist/ before the server build appends.
106+
// 2. Runtime server/node build (`dts: false`). `clean: false` appends to
107+
// the client output.
108+
// 3. Combined dts build (`emitDtsOnly: true`). All entries live in a
109+
// single rolldown graph so shared modules — notably
110+
// `src/types/rpc-augments.ts` — produce exactly one declaration site.
111+
// This is what lets consumer `declare module 'devframe'` augmentations
112+
// propagate across every import chain.
63113
export default defineConfig([
64-
// Client / agnostic build — runs first; `clean: true` clears dist/ before
65-
// the server build appends to it. Keep this first in the array.
66114
{
67115
clean: true,
68116
platform: 'browser',
69117
tsconfig,
70118
deps,
71-
dts: true,
72-
// Force `.mjs` / `.d.mts` extensions to match the server config and the
73-
// `packages/devframe/package.json` `exports` map. `platform: 'browser'`
74-
// defaults to `.js`, which would break those entry paths.
75-
outExtensions: () => ({ js: '.mjs', dts: '.d.mts' }),
76-
entry: {
77-
'client/index': 'src/client/index.ts',
78-
'utils/colors': 'src/utils/colors.ts',
79-
'utils/events': 'src/utils/events.ts',
80-
'utils/hash': 'src/utils/hash.ts',
81-
'utils/human-id': 'src/utils/human-id.ts',
82-
'utils/nanoid': 'src/utils/nanoid.ts',
83-
'utils/promise': 'src/utils/promise.ts',
84-
'utils/shared-state': 'src/utils/shared-state.ts',
85-
'utils/streaming-channel': 'src/utils/streaming-channel.ts',
86-
'utils/structured-clone': 'src/utils/structured-clone.ts',
87-
'utils/when': 'src/utils/when.ts',
88-
},
119+
dts: false,
120+
// `platform: 'browser'` defaults to `.js`; force `.mjs` to match the
121+
// `packages/devframe/package.json` `exports` map.
122+
outExtensions: () => ({ js: '.mjs' }),
123+
entry: clientEntries,
89124
hooks: {
90125
'build:done': async () => {
91126
const { checkClientDist } = await import('./scripts/check-client-dist.ts')
@@ -108,36 +143,21 @@ export default defineConfig([
108143
},
109144
},
110145
},
111-
// Server / node build — `clean: false` so it appends to the client output.
112146
{
113147
clean: false,
114148
platform: 'node',
115149
tsconfig,
116150
deps,
117-
dts: true,
118-
entry: {
119-
'index': 'src/index.ts',
120-
'constants': 'src/constants.ts',
121-
'types/index': 'src/types/index.ts',
122-
'rpc/index': 'src/rpc/index.ts',
123-
'rpc/client': 'src/rpc/client.ts',
124-
'rpc/dump': 'src/rpc/dump/index.ts',
125-
'rpc/server': 'src/rpc/server.ts',
126-
'rpc/transports/ws-client': 'src/rpc/transports/ws-client.ts',
127-
'rpc/transports/ws-server': 'src/rpc/transports/ws-server.ts',
128-
'node/index': 'src/node/index.ts',
129-
'node/auth': 'src/node/auth/index.ts',
130-
'node/internal': 'src/node/internal/index.ts',
131-
'utils/launch-editor': 'src/utils/launch-editor.ts',
132-
'utils/open': 'src/utils/open.ts',
133-
'utils/serve-static': 'src/utils/serve-static.ts',
134-
'adapters/cli': 'src/adapters/cli.ts',
135-
'adapters/dev': 'src/adapters/dev.ts',
136-
'adapters/build': 'src/adapters/build.ts',
137-
'adapters/embedded': 'src/adapters/embedded.ts',
138-
'adapters/mcp': 'src/adapters/mcp/index.ts',
139-
'helpers/vite': 'src/helpers/vite.ts',
140-
'recipes/open-helpers': 'src/recipes/open-helpers.ts',
141-
},
151+
dts: false,
152+
entry: serverEntries,
153+
},
154+
{
155+
clean: false,
156+
platform: 'neutral',
157+
tsconfig,
158+
deps,
159+
dts: { emitDtsOnly: true },
160+
outExtensions: () => ({ dts: '.d.mts' }),
161+
entry: { ...clientEntries, ...serverEntries },
142162
},
143163
])

pnpm-lock.yaml

Lines changed: 3 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.
Lines changed: 3 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -1,17 +1,7 @@
11
/**
22
* Generated by tsnapi — public API snapshot of `devframe/rpc/transports/ws-client`
33
*/
4-
// #region Interfaces
5-
export interface WsRpcChannelOptions {
6-
url: string;
7-
onConnected?: (_: Event) => void;
8-
onError?: (_: Error) => void;
9-
onDisconnected?: (_: CloseEvent) => void;
10-
authToken?: string;
11-
definitions?: ReadonlyMap<string, Pick<RpcFunctionDefinitionAny, 'jsonSerializable'>>;
12-
}
13-
// #endregion
14-
15-
// #region Functions
16-
export declare function createWsRpcChannel(_: WsRpcChannelOptions): ChannelOptions;
4+
// #region Other
5+
export { createWsRpcChannel }
6+
export { WsRpcChannelOptions }
177
// #endregion

0 commit comments

Comments
 (0)