Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
42 changes: 42 additions & 0 deletions docs/guide/rpc.md
Original file line number Diff line number Diff line change
Expand Up @@ -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<typeof serverFunctions> {}
}
```

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<typeof getModules>
}
}
```

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:
Expand Down
17 changes: 17 additions & 0 deletions docs/guide/shared-state.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 |
Expand Down
1 change: 1 addition & 0 deletions packages/devframe/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
24 changes: 24 additions & 0 deletions packages/devframe/test/dts-dedupe.test.ts
Original file line number Diff line number Diff line change
@@ -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)
}
})
})
122 changes: 71 additions & 51 deletions packages/devframe/tsdown.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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')
Expand All @@ -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 },
},
])
3 changes: 3 additions & 0 deletions pnpm-lock.yaml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Original file line number Diff line number Diff line change
@@ -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<string, Pick<RpcFunctionDefinitionAny, 'jsonSerializable'>>;
}
// #endregion

// #region Functions
export declare function createWsRpcChannel(_: WsRpcChannelOptions): ChannelOptions;
// #region Other
export { createWsRpcChannel }
export { WsRpcChannelOptions }
// #endregion
Loading