diff --git a/docs/guide/rpc.md b/docs/guide/rpc.md index d07d72c..9bb164f 100644 --- a/docs/guide/rpc.md +++ b/docs/guide/rpc.md @@ -177,6 +177,48 @@ const modules = await rpc.call('my-devframe:get-modules', { limit: 10 }) Client-side registration (for server→client calls) goes through `rpc.client.register()` — the mirror API of `ctx.rpc.register()`. +## Type-safe client registry + +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'`. + +The recommended pattern collects every server-side definition into a const array and feeds it through `RpcDefinitionsToFunctions`: + +```ts +import type { RpcDefinitionsToFunctions } from 'devframe/rpc' +import { getFile, getModules } from './rpc' + +const serverFunctions = [getModules, getFile] as const + +declare module 'devframe' { + interface DevToolsRpcServerFunctions + extends RpcDefinitionsToFunctions {} +} +``` + +Now `connectDevframe()` returns a client where every registered name is autocompletable and argument-typed: + +```ts +import { connectDevframe } from 'devframe/client' + +const rpc = await connectDevframe() +const modules = await rpc.call('my-devframe:get-modules', { limit: 10 }) +// ^? typed from the augmentation above +``` + +For one-off augmentations, declare a single key with `RpcFunctionDefinitionToFunction`: + +```ts +import type { RpcFunctionDefinitionToFunction } from 'devframe/rpc' + +declare module 'devframe' { + interface DevToolsRpcServerFunctions { + 'my-devframe:get-modules': RpcFunctionDefinitionToFunction + } +} +``` + +For server→client calls invoked via `ctx.rpc.broadcast`, augment `DevToolsRpcClientFunctions` the same way. + ## Static dumps For `static` functions, Devframe records the handler's output during `createBuild` and bakes it into the build: diff --git a/docs/guide/shared-state.md b/docs/guide/shared-state.md index c38602e..ed01a75 100644 --- a/docs/guide/shared-state.md +++ b/docs/guide/shared-state.md @@ -137,6 +137,23 @@ const unsubscribe = ctx.rpc.sharedState.onKeyAdded((key) => { Protocol adapters (the [MCP adapter](./agent-native), for example) use this to surface shared state as dynamic resources. +## Type-safe keys + +Augment `DevToolsRpcSharedStates` to type each shared-state key once, then both server and client lookups stay typed without per-call generics: + +```ts +declare module 'devframe' { + interface DevToolsRpcSharedStates { + 'my-devframe:state': { + count: number + items: { id: string, name: string }[] + } + } +} +``` + +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. + ## When to use shared state vs RPC | Use shared state for | Use RPC for | diff --git a/packages/devframe/package.json b/packages/devframe/package.json index e3cf021..0327527 100644 --- a/packages/devframe/package.json +++ b/packages/devframe/package.json @@ -95,6 +95,7 @@ "p-limit": "catalog:deps", "perfect-debounce": "catalog:deps", "structured-clone-es": "catalog:deps", + "tinyglobby": "catalog:deps", "tsdown": "catalog:build", "ua-parser-modern": "catalog:inlined", "whenexpr": "catalog:deps" diff --git a/packages/devframe/test/dts-dedupe.test.ts b/packages/devframe/test/dts-dedupe.test.ts new file mode 100644 index 0000000..c96cbbc --- /dev/null +++ b/packages/devframe/test/dts-dedupe.test.ts @@ -0,0 +1,24 @@ +import { readFile } from 'node:fs/promises' +import { fileURLToPath } from 'node:url' +import { glob } from 'tinyglobby' +import { describe, expect, it } from 'vitest' + +const distRoot = fileURLToPath(new URL('../dist/', import.meta.url)) + +const AUGMENTABLE_INTERFACES = [ + 'DevToolsRpcClientFunctions', + 'DevToolsRpcServerFunctions', + 'DevToolsRpcSharedStates', +] as const + +describe('rpc-augments dedupe', () => { + it('declares each augmentable interface exactly once across dist/**/*.d.mts', async () => { + const files = await glob('**/*.d.mts', { cwd: distRoot, absolute: true }) + const contents = await Promise.all(files.map(f => readFile(f, 'utf8'))) + for (const name of AUGMENTABLE_INTERFACES) { + const re = new RegExp(`\\binterface ${name}\\b`, 'g') + const total = contents.reduce((n, c) => n + (c.match(re)?.length ?? 0), 0) + expect(total, name).toBe(1) + } + }) +}) diff --git a/packages/devframe/tsdown.config.ts b/packages/devframe/tsdown.config.ts index ac28737..96f7a6f 100644 --- a/packages/devframe/tsdown.config.ts +++ b/packages/devframe/tsdown.config.ts @@ -55,37 +55,72 @@ const deps = { ], } -// Split into two configs so client/agnostic and server entries live in -// independent rolldown chunk graphs. A single combined build lets rolldown -// hoist shared helpers into chunks that mix server-only imports like -// `devframe/rpc/transports/ws-server` or `node:crypto`, which then leak into -// browser-loaded outputs (e.g. `client/index.mjs`, `utils/hash.mjs`). +// Shared by the runtime client build and the combined dts build below. +const clientEntries = { + 'client/index': 'src/client/index.ts', + 'utils/colors': 'src/utils/colors.ts', + 'utils/events': 'src/utils/events.ts', + 'utils/hash': 'src/utils/hash.ts', + 'utils/human-id': 'src/utils/human-id.ts', + 'utils/nanoid': 'src/utils/nanoid.ts', + 'utils/promise': 'src/utils/promise.ts', + 'utils/shared-state': 'src/utils/shared-state.ts', + 'utils/streaming-channel': 'src/utils/streaming-channel.ts', + 'utils/structured-clone': 'src/utils/structured-clone.ts', + 'utils/when': 'src/utils/when.ts', +} + +// Shared by the runtime server build and the combined dts build below. +const serverEntries = { + 'index': 'src/index.ts', + 'constants': 'src/constants.ts', + 'types/index': 'src/types/index.ts', + 'rpc/index': 'src/rpc/index.ts', + 'rpc/client': 'src/rpc/client.ts', + 'rpc/dump': 'src/rpc/dump/index.ts', + 'rpc/server': 'src/rpc/server.ts', + 'rpc/transports/ws-client': 'src/rpc/transports/ws-client.ts', + 'rpc/transports/ws-server': 'src/rpc/transports/ws-server.ts', + 'node/index': 'src/node/index.ts', + 'node/auth': 'src/node/auth/index.ts', + 'node/internal': 'src/node/internal/index.ts', + 'utils/launch-editor': 'src/utils/launch-editor.ts', + 'utils/open': 'src/utils/open.ts', + 'utils/serve-static': 'src/utils/serve-static.ts', + 'adapters/cli': 'src/adapters/cli.ts', + 'adapters/dev': 'src/adapters/dev.ts', + 'adapters/build': 'src/adapters/build.ts', + 'adapters/embedded': 'src/adapters/embedded.ts', + 'adapters/mcp': 'src/adapters/mcp/index.ts', + 'helpers/vite': 'src/helpers/vite.ts', + 'recipes/open-helpers': 'src/recipes/open-helpers.ts', +} + +// Three configs: +// +// 1. Runtime client/agnostic build (`dts: false`). Independent rolldown +// chunk graph so server-only imports like `devframe/rpc/transports/ws-server` +// or `node:crypto` can't leak into browser-loaded outputs +// (`client/index.mjs`, `utils/hash.mjs`, …). `clean: true` clears +// dist/ before the server build appends. +// 2. Runtime server/node build (`dts: false`). `clean: false` appends to +// the client output. +// 3. Combined dts build (`emitDtsOnly: true`). All entries live in a +// single rolldown graph so shared modules — notably +// `src/types/rpc-augments.ts` — produce exactly one declaration site. +// This is what lets consumer `declare module 'devframe'` augmentations +// propagate across every import chain. export default defineConfig([ - // Client / agnostic build — runs first; `clean: true` clears dist/ before - // the server build appends to it. Keep this first in the array. { clean: true, platform: 'browser', tsconfig, deps, - dts: true, - // Force `.mjs` / `.d.mts` extensions to match the server config and the - // `packages/devframe/package.json` `exports` map. `platform: 'browser'` - // defaults to `.js`, which would break those entry paths. - outExtensions: () => ({ js: '.mjs', dts: '.d.mts' }), - entry: { - 'client/index': 'src/client/index.ts', - 'utils/colors': 'src/utils/colors.ts', - 'utils/events': 'src/utils/events.ts', - 'utils/hash': 'src/utils/hash.ts', - 'utils/human-id': 'src/utils/human-id.ts', - 'utils/nanoid': 'src/utils/nanoid.ts', - 'utils/promise': 'src/utils/promise.ts', - 'utils/shared-state': 'src/utils/shared-state.ts', - 'utils/streaming-channel': 'src/utils/streaming-channel.ts', - 'utils/structured-clone': 'src/utils/structured-clone.ts', - 'utils/when': 'src/utils/when.ts', - }, + dts: false, + // `platform: 'browser'` defaults to `.js`; force `.mjs` to match the + // `packages/devframe/package.json` `exports` map. + outExtensions: () => ({ js: '.mjs' }), + entry: clientEntries, hooks: { 'build:done': async () => { const { checkClientDist } = await import('./scripts/check-client-dist.ts') @@ -108,36 +143,21 @@ export default defineConfig([ }, }, }, - // Server / node build — `clean: false` so it appends to the client output. { clean: false, platform: 'node', tsconfig, deps, - dts: true, - entry: { - 'index': 'src/index.ts', - 'constants': 'src/constants.ts', - 'types/index': 'src/types/index.ts', - 'rpc/index': 'src/rpc/index.ts', - 'rpc/client': 'src/rpc/client.ts', - 'rpc/dump': 'src/rpc/dump/index.ts', - 'rpc/server': 'src/rpc/server.ts', - 'rpc/transports/ws-client': 'src/rpc/transports/ws-client.ts', - 'rpc/transports/ws-server': 'src/rpc/transports/ws-server.ts', - 'node/index': 'src/node/index.ts', - 'node/auth': 'src/node/auth/index.ts', - 'node/internal': 'src/node/internal/index.ts', - 'utils/launch-editor': 'src/utils/launch-editor.ts', - 'utils/open': 'src/utils/open.ts', - 'utils/serve-static': 'src/utils/serve-static.ts', - 'adapters/cli': 'src/adapters/cli.ts', - 'adapters/dev': 'src/adapters/dev.ts', - 'adapters/build': 'src/adapters/build.ts', - 'adapters/embedded': 'src/adapters/embedded.ts', - 'adapters/mcp': 'src/adapters/mcp/index.ts', - 'helpers/vite': 'src/helpers/vite.ts', - 'recipes/open-helpers': 'src/recipes/open-helpers.ts', - }, + dts: false, + entry: serverEntries, + }, + { + clean: false, + platform: 'neutral', + tsconfig, + deps, + dts: { emitDtsOnly: true }, + outExtensions: () => ({ dts: '.d.mts' }), + entry: { ...clientEntries, ...serverEntries }, }, ]) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index b79502b..db93fda 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -361,6 +361,9 @@ importers: structured-clone-es: specifier: catalog:deps version: 2.0.0 + tinyglobby: + specifier: catalog:deps + version: 0.2.16 tsdown: specifier: catalog:build version: 0.22.0(tsx@4.21.0)(typescript@6.0.3) diff --git a/tests/__snapshots__/tsnapi/devframe/rpc/transports/ws-client.snapshot.d.ts b/tests/__snapshots__/tsnapi/devframe/rpc/transports/ws-client.snapshot.d.ts index 656516d..22cde9f 100644 --- a/tests/__snapshots__/tsnapi/devframe/rpc/transports/ws-client.snapshot.d.ts +++ b/tests/__snapshots__/tsnapi/devframe/rpc/transports/ws-client.snapshot.d.ts @@ -1,17 +1,7 @@ /** * Generated by tsnapi — public API snapshot of `devframe/rpc/transports/ws-client` */ -// #region Interfaces -export interface WsRpcChannelOptions { - url: string; - onConnected?: (_: Event) => void; - onError?: (_: Error) => void; - onDisconnected?: (_: CloseEvent) => void; - authToken?: string; - definitions?: ReadonlyMap>; -} -// #endregion - -// #region Functions -export declare function createWsRpcChannel(_: WsRpcChannelOptions): ChannelOptions; +// #region Other +export { createWsRpcChannel } +export { WsRpcChannelOptions } // #endregion \ No newline at end of file