From 45db91f6ac40d4876c2d74a053f2d140050fdf6b Mon Sep 17 00:00:00 2001 From: Anthony Fu Date: Fri, 22 May 2026 19:07:04 +0900 Subject: [PATCH 1/4] fix(devframe): cache rpc setup per-context via WeakMap Module-level RPC defs registered into multiple contexts in the same process (multi-server tests, hot-reload teardown/replay) previously shared a handler closed over the first context's state. Cache by context object so each context gets its own setup result. --- packages/devframe/src/rpc/handler.ts | 27 ++++++++++++------- packages/devframe/src/rpc/types.ts | 8 ++++-- .../recipes/open-helpers.snapshot.d.ts | 8 +++--- 3 files changed, 27 insertions(+), 16 deletions(-) diff --git a/packages/devframe/src/rpc/handler.ts b/packages/devframe/src/rpc/handler.ts index 88fea4c..afd60c3 100644 --- a/packages/devframe/src/rpc/handler.ts +++ b/packages/devframe/src/rpc/handler.ts @@ -11,20 +11,27 @@ export async function getRpcResolvedSetupResult< definition: RpcFunctionDefinition, context: CONTEXT, ): Promise> { - if (definition.__resolved) { - return definition.__resolved - } if (!definition.setup) { return {} } + + // Cache the setup result per-context so a single module-level definition + // can serve multiple contexts in the same process (multi-server tests, + // hot-reload teardown/replay, etc.) without leaking a handler that + // closed over a prior context's state. + if (typeof context === 'object' && context !== null) { + definition.__cache ??= new WeakMap() + let promise = definition.__cache.get(context as object) + if (!promise) { + promise = Promise.resolve(definition.setup(context)) + definition.__cache.set(context as object, promise) + } + return await promise + } + + // Primitive / undefined context — fall back to a single-slot cache. definition.__promise ??= Promise.resolve(definition.setup(context)) - .then((r) => { - definition.__resolved = r - definition.__promise = undefined - return r - }) - const result = definition.__resolved ??= await definition.__promise - return result + return await definition.__promise } export async function getRpcHandler< diff --git a/packages/devframe/src/rpc/types.ts b/packages/devframe/src/rpc/types.ts index d5f5d81..7563e41 100644 --- a/packages/devframe/src/rpc/types.ts +++ b/packages/devframe/src/rpc/types.ts @@ -269,7 +269,9 @@ export type RpcFunctionDefinition< * functions — `static` already has equivalent default behavior. */ snapshot?: boolean - __resolved?: RpcFunctionSetupResult + /** Per-context setup-result cache, populated by `getRpcResolvedSetupResult`. @internal */ + __cache?: WeakMap>> + /** Single-slot fallback for primitive contexts. @internal */ __promise?: Thenable> } : { @@ -315,7 +317,9 @@ export type RpcFunctionDefinition< * functions — `static` already has equivalent default behavior. */ snapshot?: boolean - __resolved?: RpcFunctionSetupResult, InferReturnType> + /** Per-context setup-result cache, populated by `getRpcResolvedSetupResult`. @internal */ + __cache?: WeakMap, InferReturnType>>> + /** Single-slot fallback for primitive contexts. @internal */ __promise?: Thenable, InferReturnType>> } diff --git a/tests/__snapshots__/tsnapi/devframe/recipes/open-helpers.snapshot.d.ts b/tests/__snapshots__/tsnapi/devframe/recipes/open-helpers.snapshot.d.ts index e97fdc7..ea53a20 100644 --- a/tests/__snapshots__/tsnapi/devframe/recipes/open-helpers.snapshot.d.ts +++ b/tests/__snapshots__/tsnapi/devframe/recipes/open-helpers.snapshot.d.ts @@ -14,7 +14,7 @@ export declare const openHelpers: readonly [{ handler?: ((args_0: string) => void) | undefined; dump?: RpcDump<[string], void, undefined> | undefined; snapshot?: boolean; - __resolved?: RpcFunctionSetupResult<[string], void> | undefined; + __cache?: WeakMap>> | undefined; __promise?: Thenable> | undefined; }, { name: "devframe:open-in-finder"; @@ -28,7 +28,7 @@ export declare const openHelpers: readonly [{ handler?: ((args_0: string) => void) | undefined; dump?: RpcDump<[string], void, undefined> | undefined; snapshot?: boolean; - __resolved?: RpcFunctionSetupResult<[string], void> | undefined; + __cache?: WeakMap>> | undefined; __promise?: Thenable> | undefined; }]; export declare const openInEditor: { @@ -43,7 +43,7 @@ export declare const openInEditor: { handler?: ((args_0: string) => void) | undefined; dump?: RpcDump<[string], void, undefined> | undefined; snapshot?: boolean; - __resolved?: RpcFunctionSetupResult<[string], void> | undefined; + __cache?: WeakMap>> | undefined; __promise?: Thenable> | undefined; }; export declare const openInFinder: { @@ -58,7 +58,7 @@ export declare const openInFinder: { handler?: ((args_0: string) => void) | undefined; dump?: RpcDump<[string], void, undefined> | undefined; snapshot?: boolean; - __resolved?: RpcFunctionSetupResult<[string], void> | undefined; + __cache?: WeakMap>> | undefined; __promise?: Thenable> | undefined; }; // #endregion \ No newline at end of file From 3575a68567cd40f9ccc1b19ce5dba2bfeb7acbe9 Mon Sep 17 00:00:00 2001 From: Anthony Fu Date: Fri, 22 May 2026 19:07:29 +0900 Subject: [PATCH 2/4] docs(devframe): document rpc/ folder + WeakMap context pattern Skill and rpc guide now describe the one-file-per-RPC layout under src/rpc/ and the WeakMap-based src/context.ts for sharing setup-time state across files. All three examples adopt the pattern, with streaming-chat demonstrating the WeakMap context for its shared channel + history. --- docs/guide/rpc.md | 2 + .../src/client/routes/about.tsx | 2 +- .../src/client/routes/home.tsx | 2 +- examples/files-inspector/src/devframe.ts | 27 +-- examples/files-inspector/src/rpc/get-cwd.ts | 11 ++ examples/files-inspector/src/rpc/index.ts | 9 + .../files-inspector/src/rpc/list-files.ts | 17 ++ .../client/app/components/snapshot-env.tsx | 2 +- .../client/app/components/snapshot-memory.tsx | 2 +- .../client/app/components/snapshot-system.tsx | 4 +- .../next-runtime-snapshot/src/devframe.ts | 131 +------------ examples/next-runtime-snapshot/src/rpc/env.ts | 68 +++++++ .../next-runtime-snapshot/src/rpc/index.ts | 10 + .../next-runtime-snapshot/src/rpc/memory.ts | 34 ++++ .../next-runtime-snapshot/src/rpc/system.ts | 27 +++ examples/streaming-chat/src/client/app.tsx | 14 +- examples/streaming-chat/src/constants.ts | 9 + examples/streaming-chat/src/context.ts | 22 +++ examples/streaming-chat/src/devframe.ts | 185 +----------------- examples/streaming-chat/src/rpc/clear.ts | 18 ++ .../streaming-chat/src/rpc/demo-prompts.ts | 9 + examples/streaming-chat/src/rpc/index.ts | 10 + examples/streaming-chat/src/rpc/send.ts | 136 +++++++++++++ examples/streaming-chat/src/types.ts | 22 +++ skills/devframe/SKILL.md | 77 ++++++++ 25 files changed, 511 insertions(+), 339 deletions(-) create mode 100644 examples/files-inspector/src/rpc/get-cwd.ts create mode 100644 examples/files-inspector/src/rpc/index.ts create mode 100644 examples/files-inspector/src/rpc/list-files.ts create mode 100644 examples/next-runtime-snapshot/src/rpc/env.ts create mode 100644 examples/next-runtime-snapshot/src/rpc/index.ts create mode 100644 examples/next-runtime-snapshot/src/rpc/memory.ts create mode 100644 examples/next-runtime-snapshot/src/rpc/system.ts create mode 100644 examples/streaming-chat/src/constants.ts create mode 100644 examples/streaming-chat/src/context.ts create mode 100644 examples/streaming-chat/src/rpc/clear.ts create mode 100644 examples/streaming-chat/src/rpc/demo-prompts.ts create mode 100644 examples/streaming-chat/src/rpc/index.ts create mode 100644 examples/streaming-chat/src/rpc/send.ts create mode 100644 examples/streaming-chat/src/types.ts diff --git a/docs/guide/rpc.md b/docs/guide/rpc.md index 9bb164f..9de0ac7 100644 --- a/docs/guide/rpc.md +++ b/docs/guide/rpc.md @@ -53,6 +53,8 @@ export default defineDevframe({ }) ``` +Place each function in its own file under `src/rpc/`, and barrel them in `src/rpc/index.ts` as `const serverFunctions = [...] as const`. The same array feeds the [type-safe client registry](#type-safe-client-registry) and keeps registration order explicit. When per-file functions need to share setup-time state (channels, shared state handles, loaders), expose it through a `WeakMap` in a sibling `src/context.ts`. + ### Naming convention Scope with your devframe id and use kebab-case for the action: `my-devframe:get-modules`, `my-devframe:read-file`, `my-devframe:trigger-rebuild`. diff --git a/examples/files-inspector/src/client/routes/about.tsx b/examples/files-inspector/src/client/routes/about.tsx index f6cfd0c..1300666 100644 --- a/examples/files-inspector/src/client/routes/about.tsx +++ b/examples/files-inspector/src/client/routes/about.tsx @@ -5,7 +5,7 @@ export function About({ rpc, basePath }: { rpc: DevToolsRpcClient, basePath: str const [cwd, setCwd] = useState('') useEffect(() => { - rpc.call('devframe-files-inspector:get-cwd' as any).then((r: any) => { + rpc.call('devframe-files-inspector:get-cwd').then((r) => { setCwd(r.cwd) }) }, [rpc]) diff --git a/examples/files-inspector/src/client/routes/home.tsx b/examples/files-inspector/src/client/routes/home.tsx index 5f7dd5e..ca6dfd9 100644 --- a/examples/files-inspector/src/client/routes/home.tsx +++ b/examples/files-inspector/src/client/routes/home.tsx @@ -8,7 +8,7 @@ export function Home({ rpc }: { rpc: DevToolsRpcClient }) { async function refresh() { setLoading(true) try { - const result = await rpc.call('devframe-files-inspector:list-files' as any) as string[] + const result = await rpc.call('devframe-files-inspector:list-files') setFiles(result) } finally { diff --git a/examples/files-inspector/src/devframe.ts b/examples/files-inspector/src/devframe.ts index 13819ea..271d2dd 100644 --- a/examples/files-inspector/src/devframe.ts +++ b/examples/files-inspector/src/devframe.ts @@ -1,8 +1,6 @@ -import process from 'node:process' import { fileURLToPath } from 'node:url' -import { defineRpcFunction } from 'devframe' import { defineDevframe } from 'devframe/types' -import { glob } from 'tinyglobby' +import { serverFunctions } from './rpc' const BASE_PATH = '/__devframe-files-inspector/' const distDir = fileURLToPath(new URL('../dist/client', import.meta.url)) @@ -18,25 +16,8 @@ export default defineDevframe({ distDir, }, spa: { loader: 'none' }, - async setup(ctx) { - const targetCwd = process.env.DEVFRAME_E2E_CWD || ctx.cwd - - ctx.rpc.register(defineRpcFunction({ - name: 'devframe-files-inspector:get-cwd', - type: 'static', - jsonSerializable: true, - handler: () => ({ cwd: targetCwd }), - })) - - ctx.rpc.register(defineRpcFunction({ - name: 'devframe-files-inspector:list-files', - type: 'query', - jsonSerializable: true, - handler: async () => { - const files = await glob(['*'], { cwd: targetCwd, onlyFiles: true, dot: false }) - return files.map(f => f.replace(/\\/g, '/')).sort() - }, - snapshot: true, - })) + setup(ctx) { + for (const fn of serverFunctions) + ctx.rpc.register(fn) }, }) diff --git a/examples/files-inspector/src/rpc/get-cwd.ts b/examples/files-inspector/src/rpc/get-cwd.ts new file mode 100644 index 0000000..ed653a0 --- /dev/null +++ b/examples/files-inspector/src/rpc/get-cwd.ts @@ -0,0 +1,11 @@ +import process from 'node:process' +import { defineRpcFunction } from 'devframe' + +export const getCwd = defineRpcFunction({ + name: 'devframe-files-inspector:get-cwd', + type: 'static', + jsonSerializable: true, + setup: ctx => ({ + handler: () => ({ cwd: process.env.DEVFRAME_E2E_CWD || ctx.cwd }), + }), +}) diff --git a/examples/files-inspector/src/rpc/index.ts b/examples/files-inspector/src/rpc/index.ts new file mode 100644 index 0000000..1364ce7 --- /dev/null +++ b/examples/files-inspector/src/rpc/index.ts @@ -0,0 +1,9 @@ +import type { RpcDefinitionsToFunctions } from 'devframe/rpc' +import { getCwd } from './get-cwd' +import { listFiles } from './list-files' + +export const serverFunctions = [getCwd, listFiles] as const + +declare module 'devframe' { + interface DevToolsRpcServerFunctions extends RpcDefinitionsToFunctions {} +} diff --git a/examples/files-inspector/src/rpc/list-files.ts b/examples/files-inspector/src/rpc/list-files.ts new file mode 100644 index 0000000..e2576bc --- /dev/null +++ b/examples/files-inspector/src/rpc/list-files.ts @@ -0,0 +1,17 @@ +import process from 'node:process' +import { defineRpcFunction } from 'devframe' +import { glob } from 'tinyglobby' + +export const listFiles = defineRpcFunction({ + name: 'devframe-files-inspector:list-files', + type: 'query', + jsonSerializable: true, + snapshot: true, + setup: ctx => ({ + handler: async () => { + const cwd = process.env.DEVFRAME_E2E_CWD || ctx.cwd + const files = await glob(['*'], { cwd, onlyFiles: true, dot: false }) + return files.map(f => f.replace(/\\/g, '/')).sort() + }, + }), +}) diff --git a/examples/next-runtime-snapshot/src/client/app/components/snapshot-env.tsx b/examples/next-runtime-snapshot/src/client/app/components/snapshot-env.tsx index 027c866..4579460 100644 --- a/examples/next-runtime-snapshot/src/client/app/components/snapshot-env.tsx +++ b/examples/next-runtime-snapshot/src/client/app/components/snapshot-env.tsx @@ -15,7 +15,7 @@ export function SnapshotEnv() { return setLoading(true) try { - const r = await rpc.call('next-runtime-snapshot:env' as any, { pattern: p }) as EnvSnapshot + const r = await rpc.call('next-runtime-snapshot:env', { pattern: p }) setSnap(r) } finally { diff --git a/examples/next-runtime-snapshot/src/client/app/components/snapshot-memory.tsx b/examples/next-runtime-snapshot/src/client/app/components/snapshot-memory.tsx index b2a31f8..0710687 100644 --- a/examples/next-runtime-snapshot/src/client/app/components/snapshot-memory.tsx +++ b/examples/next-runtime-snapshot/src/client/app/components/snapshot-memory.tsx @@ -31,7 +31,7 @@ export function SnapshotMemory() { return setLoading(true) try { - const r = await rpc.call('next-runtime-snapshot:memory' as any) as MemorySnapshot + const r = await rpc.call('next-runtime-snapshot:memory') setSnap(r) } finally { diff --git a/examples/next-runtime-snapshot/src/client/app/components/snapshot-system.tsx b/examples/next-runtime-snapshot/src/client/app/components/snapshot-system.tsx index 6eee893..c83b854 100644 --- a/examples/next-runtime-snapshot/src/client/app/components/snapshot-system.tsx +++ b/examples/next-runtime-snapshot/src/client/app/components/snapshot-system.tsx @@ -16,9 +16,9 @@ export function SnapshotSystem() { if (!rpc) return let active = true - rpc.call('next-runtime-snapshot:system' as any).then((r: unknown) => { + rpc.call('next-runtime-snapshot:system').then((r) => { if (active) - setInfo(r as SystemInfo) + setInfo(r) }) return () => { active = false diff --git a/examples/next-runtime-snapshot/src/devframe.ts b/examples/next-runtime-snapshot/src/devframe.ts index 39748ee..0238956 100644 --- a/examples/next-runtime-snapshot/src/devframe.ts +++ b/examples/next-runtime-snapshot/src/devframe.ts @@ -1,55 +1,14 @@ -import process from 'node:process' import { fileURLToPath } from 'node:url' -import { defineRpcFunction } from 'devframe' import { defineDevframe } from 'devframe/types' -import * as v from 'valibot' +import { serverFunctions } from './rpc' + +export type { EnvEntry, EnvSnapshot } from './rpc/env' +export type { MemorySnapshot } from './rpc/memory' +export type { SystemInfo } from './rpc/system' const BASE_PATH = '/__next-runtime-snapshot/' const distDir = fileURLToPath(new URL('../dist/client', import.meta.url)) -const SECRET_KEY_PATTERN = /SECRET|TOKEN|KEY|PASSWORD|PASS|AUTH|CREDENTIAL/i - -export interface SystemInfo { - node: string - platform: NodeJS.Platform - arch: string - pid: number - cwd: string - startedAt: number -} - -export interface MemorySnapshot { - memory: { - rss: number - heapTotal: number - heapUsed: number - external: number - arrayBuffers: number - } - uptimeSeconds: number - capturedAt: number -} - -export interface EnvEntry { - key: string - value: string - redacted: boolean -} - -export interface EnvSnapshot { - entries: EnvEntry[] - total: number - pattern: string -} - -function redact(key: string, value: string): EnvEntry { - if (SECRET_KEY_PATTERN.test(key)) - return { key, value: '••••••••', redacted: true } - return { key, value, redacted: false } -} - -const startedAt = Date.now() - export default defineDevframe({ id: 'next-runtime-snapshot', name: 'Next Runtime Snapshot', @@ -63,83 +22,7 @@ export default defineDevframe({ }, spa: { loader: 'none' }, setup(ctx) { - ctx.rpc.register(defineRpcFunction({ - name: 'next-runtime-snapshot:system', - type: 'static', - jsonSerializable: true, - handler: (): SystemInfo => ({ - node: process.version, - platform: process.platform, - arch: process.arch, - pid: process.pid, - cwd: process.cwd(), - startedAt, - }), - })) - - ctx.rpc.register(defineRpcFunction({ - name: 'next-runtime-snapshot:memory', - type: 'query', - jsonSerializable: true, - handler: (): MemorySnapshot => { - const m = process.memoryUsage() - return { - memory: { - rss: m.rss, - heapTotal: m.heapTotal, - heapUsed: m.heapUsed, - external: m.external, - arrayBuffers: m.arrayBuffers, - }, - uptimeSeconds: process.uptime(), - capturedAt: Date.now(), - } - }, - })) - - const EnvEntrySchema = v.object({ - key: v.string(), - value: v.string(), - redacted: v.boolean(), - }) - - ctx.rpc.register(defineRpcFunction({ - name: 'next-runtime-snapshot:env', - type: 'query', - jsonSerializable: true, - args: [v.object({ - pattern: v.optional(v.string(), ''), - limit: v.optional(v.pipe(v.number(), v.integer(), v.minValue(1), v.maxValue(500)), 50), - })], - returns: v.object({ - entries: v.array(EnvEntrySchema), - total: v.number(), - pattern: v.string(), - }), - handler: ({ pattern, limit }): EnvSnapshot => { - const keys = Object.keys(process.env).sort() - let matched: string[] - if (!pattern) { - matched = keys - } - else { - try { - const regex = new RegExp(pattern, 'i') - matched = keys.filter(k => regex.test(k)) - } - // Invalid regex: match nothing rather than silently widening to all - // keys (which could leak vars the redaction heuristic doesn't catch). - catch { - matched = [] - } - } - const entries = matched.slice(0, limit).map(k => redact(k, process.env[k] ?? '')) - return { - entries, - total: matched.length, - pattern, - } - }, - })) + for (const fn of serverFunctions) + ctx.rpc.register(fn) }, }) diff --git a/examples/next-runtime-snapshot/src/rpc/env.ts b/examples/next-runtime-snapshot/src/rpc/env.ts new file mode 100644 index 0000000..2f2175e --- /dev/null +++ b/examples/next-runtime-snapshot/src/rpc/env.ts @@ -0,0 +1,68 @@ +import process from 'node:process' +import { defineRpcFunction } from 'devframe' +import * as v from 'valibot' + +const SECRET_KEY_PATTERN = /SECRET|TOKEN|KEY|PASSWORD|PASS|AUTH|CREDENTIAL/i + +export interface EnvEntry { + key: string + value: string + redacted: boolean +} + +export interface EnvSnapshot { + entries: EnvEntry[] + total: number + pattern: string +} + +function redact(key: string, value: string): EnvEntry { + if (SECRET_KEY_PATTERN.test(key)) + return { key, value: '••••••••', redacted: true } + return { key, value, redacted: false } +} + +const EnvEntrySchema = v.object({ + key: v.string(), + value: v.string(), + redacted: v.boolean(), +}) + +export const env = defineRpcFunction({ + name: 'next-runtime-snapshot:env', + type: 'query', + jsonSerializable: true, + args: [v.object({ + pattern: v.optional(v.string(), ''), + limit: v.optional(v.pipe(v.number(), v.integer(), v.minValue(1), v.maxValue(500)), 50), + })], + returns: v.object({ + entries: v.array(EnvEntrySchema), + total: v.number(), + pattern: v.string(), + }), + handler: ({ pattern, limit }): EnvSnapshot => { + const keys = Object.keys(process.env).sort() + let matched: string[] + if (!pattern) { + matched = keys + } + else { + try { + const regex = new RegExp(pattern, 'i') + matched = keys.filter(k => regex.test(k)) + } + // Invalid regex: match nothing rather than silently widening to all + // keys (which could leak vars the redaction heuristic doesn't catch). + catch { + matched = [] + } + } + const entries = matched.slice(0, limit).map(k => redact(k, process.env[k] ?? '')) + return { + entries, + total: matched.length, + pattern, + } + }, +}) diff --git a/examples/next-runtime-snapshot/src/rpc/index.ts b/examples/next-runtime-snapshot/src/rpc/index.ts new file mode 100644 index 0000000..406a339 --- /dev/null +++ b/examples/next-runtime-snapshot/src/rpc/index.ts @@ -0,0 +1,10 @@ +import type { RpcDefinitionsToFunctions } from 'devframe/rpc' +import { env } from './env' +import { memory } from './memory' +import { system } from './system' + +export const serverFunctions = [system, memory, env] as const + +declare module 'devframe' { + interface DevToolsRpcServerFunctions extends RpcDefinitionsToFunctions {} +} diff --git a/examples/next-runtime-snapshot/src/rpc/memory.ts b/examples/next-runtime-snapshot/src/rpc/memory.ts new file mode 100644 index 0000000..f3a869d --- /dev/null +++ b/examples/next-runtime-snapshot/src/rpc/memory.ts @@ -0,0 +1,34 @@ +import process from 'node:process' +import { defineRpcFunction } from 'devframe' + +export interface MemorySnapshot { + memory: { + rss: number + heapTotal: number + heapUsed: number + external: number + arrayBuffers: number + } + uptimeSeconds: number + capturedAt: number +} + +export const memory = defineRpcFunction({ + name: 'next-runtime-snapshot:memory', + type: 'query', + jsonSerializable: true, + handler: (): MemorySnapshot => { + const m = process.memoryUsage() + return { + memory: { + rss: m.rss, + heapTotal: m.heapTotal, + heapUsed: m.heapUsed, + external: m.external, + arrayBuffers: m.arrayBuffers, + }, + uptimeSeconds: process.uptime(), + capturedAt: Date.now(), + } + }, +}) diff --git a/examples/next-runtime-snapshot/src/rpc/system.ts b/examples/next-runtime-snapshot/src/rpc/system.ts new file mode 100644 index 0000000..eb9590e --- /dev/null +++ b/examples/next-runtime-snapshot/src/rpc/system.ts @@ -0,0 +1,27 @@ +import process from 'node:process' +import { defineRpcFunction } from 'devframe' + +export interface SystemInfo { + node: string + platform: NodeJS.Platform + arch: string + pid: number + cwd: string + startedAt: number +} + +const startedAt = Date.now() + +export const system = defineRpcFunction({ + name: 'next-runtime-snapshot:system', + type: 'static', + jsonSerializable: true, + handler: (): SystemInfo => ({ + node: process.version, + platform: process.platform, + arch: process.arch, + pid: process.pid, + cwd: process.cwd(), + startedAt, + }), +}) diff --git a/examples/streaming-chat/src/client/app.tsx b/examples/streaming-chat/src/client/app.tsx index 327aae5..b7bcdf6 100644 --- a/examples/streaming-chat/src/client/app.tsx +++ b/examples/streaming-chat/src/client/app.tsx @@ -1,11 +1,9 @@ import type { DevToolsRpcClient } from 'devframe/client' import type { StreamReader } from 'devframe/utils/streaming-channel' -import type { ChatHistory, ChatMessage } from '../devframe' +import type { ChatHistory, ChatMessage } from '../types' import { connectDevframe } from 'devframe/client' import { useCallback, useEffect, useMemo, useRef, useState } from 'preact/hooks' - -const CHANNEL_NAME = 'devframe-streaming-chat:tokens' -const HISTORY_KEY = 'devframe-streaming-chat:history' +import { CHANNEL_NAME, HISTORY_KEY } from '../constants' export function App() { const [rpc, setRpc] = useState(null) @@ -26,9 +24,7 @@ export function App() { return setRpc(r) try { - const result = await r.call( - 'devframe-streaming-chat:demo-prompts' as any, - ) as { prompts: string[] } + const result = await r.call('devframe-streaming-chat:demo-prompts') if (!cancelled) setDemoPrompts(result.prompts) } @@ -138,7 +134,7 @@ export function App() { setError(null) setPrompt('') try { - await rpc.call('devframe-streaming-chat:send' as any, { + await rpc.call('devframe-streaming-chat:send', { prompt: text.trim(), }) } @@ -158,7 +154,7 @@ export function App() { if (!rpc || isStreaming) return try { - await rpc.call('devframe-streaming-chat:clear' as any) + await rpc.call('devframe-streaming-chat:clear') } catch (err) { setError(err instanceof Error ? err.message : String(err)) diff --git a/examples/streaming-chat/src/constants.ts b/examples/streaming-chat/src/constants.ts new file mode 100644 index 0000000..18a5eb0 --- /dev/null +++ b/examples/streaming-chat/src/constants.ts @@ -0,0 +1,9 @@ +export const CHANNEL_NAME = 'devframe-streaming-chat:tokens' +export const HISTORY_KEY = 'devframe-streaming-chat:history' +export const MAX_HISTORY = 200 + +export const DEMO_PROMPTS = [ + 'Tell me about devframe.', + 'How does streaming work?', + 'Write a haiku about RPC.', +] as const diff --git a/examples/streaming-chat/src/context.ts b/examples/streaming-chat/src/context.ts new file mode 100644 index 0000000..efe6742 --- /dev/null +++ b/examples/streaming-chat/src/context.ts @@ -0,0 +1,22 @@ +import type { DevToolsNodeContext, RpcStreamingChannel } from 'devframe/types' +import type { SharedState } from 'devframe/utils/shared-state' +import type { ChatHistory } from './types' + +export interface StreamingChatContext { + channel: RpcStreamingChannel + history: SharedState + pruneIfTooLarge: () => void +} + +const map = new WeakMap() + +export function setStreamingChatContext(ctx: DevToolsNodeContext, value: StreamingChatContext): void { + map.set(ctx, value) +} + +export function getStreamingChatContext(ctx: DevToolsNodeContext): StreamingChatContext { + const value = map.get(ctx) + if (!value) + throw new Error('streaming-chat context not initialised — call setStreamingChatContext in devframe.setup') + return value +} diff --git a/examples/streaming-chat/src/devframe.ts b/examples/streaming-chat/src/devframe.ts index 09d245a..181f2c7 100644 --- a/examples/streaming-chat/src/devframe.ts +++ b/examples/streaming-chat/src/devframe.ts @@ -1,84 +1,14 @@ import { fileURLToPath } from 'node:url' -import { defineRpcFunction } from 'devframe' import { defineDevframe } from 'devframe/types' -import { nanoid } from 'devframe/utils/nanoid' -import * as v from 'valibot' +import { CHANNEL_NAME, HISTORY_KEY, MAX_HISTORY } from './constants' +import { setStreamingChatContext } from './context' +import { serverFunctions } from './rpc' + +export type { ChatHistory, ChatMessage } from './types' const BASE_PATH = '/__devframe-streaming-chat/' const distDir = fileURLToPath(new URL('../dist/client', import.meta.url)) -const CHANNEL_NAME = 'devframe-streaming-chat:tokens' -const HISTORY_KEY = 'devframe-streaming-chat:history' -const MAX_HISTORY = 200 - -const DEMO_PROMPTS = [ - 'Tell me about devframe.', - 'How does streaming work?', - 'Write a haiku about RPC.', -] as const - -export interface ChatMessage { - id: string - role: 'user' | 'assistant' - content: string - /** Set on assistant messages while their stream is in flight. */ - streamId?: string - /** True if the assistant stream was cancelled before completing. */ - cancelled?: boolean - timestamp: number -} - -export interface ChatHistory { - messages: ChatMessage[] -} - -declare module 'devframe/types' { - interface DevToolsRpcSharedStates { - [HISTORY_KEY]: ChatHistory - } -} - -/** - * Synthetic "AI" — splits a canned response into tokens and emits them - * one at a time. Swap in `OpenAI`'s `chat.completions.create({ stream: true })` - * (or any async iterable of strings) to make it real. - */ -function* fakeTokens(prompt: string): Generator { - const lower = prompt.toLowerCase() - let response: string - if (/^(?:hi|hello|hey)\b/.test(lower)) { - response = `Hello! Ask me about devframe, streaming, or anything else — I'll fake-stream a response one token at a time.` - } - else if (lower.includes('haiku')) { - response = 'Tiny chunks arrive — / type-safe over WebSocket / streams compose with ease.' - } - else if (lower.includes('streaming')) { - response - = 'Streams start with `ctx.rpc.streaming.create()` on the server. ' - + 'Producers `write()` chunks; clients subscribe and consume them via ' - + '`for await (const chunk of reader)`. Cancellation, replay, and ' - + 'backpressure are wired by the host — your handler stays small.' - } - else if (lower.includes('history') || lower.includes('persist')) { - response - = `History lives in a devframe shared state ("${HISTORY_KEY}"). ` - + 'Each `send` appends a user + assistant pair; tokens stream live, ' - + 'and the final content is committed back to the shared state when ' - + 'the producer closes. Refresh the page and the log comes back.' - } - else { - response - = `You asked: "${prompt}". ` - + 'devframe is a framework-neutral foundation for building developer ' - + 'tooling — six adapters, type-safe RPC, shared state, and a ' - + 'first-class streaming channel for delta-style server↔client data. ' - + 'Pipe `ReadableStream`s into a sink, or write chunks by hand.' - } - // Split on whitespace but keep the spaces so `tokens.join('')` round-trips. - const tokens = response.split(/(\s+)/).filter(Boolean) - for (const token of tokens) yield token -} - export default defineDevframe({ id: 'devframe-streaming-chat', name: 'Streaming Chat', @@ -97,7 +27,6 @@ export default defineDevframe({ const channel = ctx.rpc.streaming.create(CHANNEL_NAME, { replayWindow: 1024, }) - const history = await ctx.rpc.sharedState.get(HISTORY_KEY, { initialValue: { messages: [] }, }) @@ -110,107 +39,9 @@ export default defineDevframe({ } } - ctx.rpc.register(defineRpcFunction({ - name: 'devframe-streaming-chat:demo-prompts', - type: 'static', - jsonSerializable: true, - handler: () => ({ prompts: [...DEMO_PROMPTS] }), - })) - - ctx.rpc.register(defineRpcFunction({ - name: 'devframe-streaming-chat:send', - type: 'action', - jsonSerializable: true, - args: [v.object({ - prompt: v.string(), - intervalMs: v.optional(v.number(), 35), - })], - returns: v.object({ - userId: v.string(), - assistantId: v.string(), - streamId: v.string(), - }), - handler: async ({ prompt, intervalMs = 35 }) => { - const stream = channel.start() - const userId = nanoid() - const assistantId = nanoid() - const now = Date.now() + setStreamingChatContext(ctx, { channel, history, pruneIfTooLarge }) - // Append both messages atomically — clients see the user prompt - // and the empty assistant placeholder appear together. - history.mutate((draft) => { - draft.messages.push({ - id: userId, - role: 'user', - content: prompt, - timestamp: now, - }) - draft.messages.push({ - id: assistantId, - role: 'assistant', - content: '', - streamId: stream.id, - timestamp: now, - }) - }) - pruneIfTooLarge() - - // Producer — token-by-token via streaming, full content committed - // to shared state when done so refreshes / new clients see the - // finished message without re-streaming. - ;(async () => { - let acc = '' - let cancelled = false - try { - for (const token of fakeTokens(prompt)) { - if (stream.signal.aborted) { - cancelled = true - break - } - stream.write(token) - acc += token - await new Promise(r => setTimeout(r, intervalMs)) - } - if (!cancelled) - stream.close() - } - catch (err) { - stream.error(err) - history.mutate((draft) => { - const msg = draft.messages.find(m => m.id === assistantId) - if (msg) { - msg.content = acc - msg.streamId = undefined - msg.cancelled = true - } - }) - return - } - - history.mutate((draft) => { - const msg = draft.messages.find(m => m.id === assistantId) - if (msg) { - msg.content = acc - msg.streamId = undefined - if (cancelled) - msg.cancelled = true - } - }) - })() - - return { userId, assistantId, streamId: stream.id } - }, - })) - - ctx.rpc.register(defineRpcFunction({ - name: 'devframe-streaming-chat:clear', - type: 'action', - jsonSerializable: true, - handler: () => { - history.mutate((draft) => { - draft.messages.length = 0 - }) - }, - })) + for (const fn of serverFunctions) + ctx.rpc.register(fn) }, }) diff --git a/examples/streaming-chat/src/rpc/clear.ts b/examples/streaming-chat/src/rpc/clear.ts new file mode 100644 index 0000000..4d3d08d --- /dev/null +++ b/examples/streaming-chat/src/rpc/clear.ts @@ -0,0 +1,18 @@ +import { defineRpcFunction } from 'devframe' +import { getStreamingChatContext } from '../context' + +export const clear = defineRpcFunction({ + name: 'devframe-streaming-chat:clear', + type: 'action', + jsonSerializable: true, + setup: (ctx) => { + const { history } = getStreamingChatContext(ctx) + return { + handler: () => { + history.mutate((draft) => { + draft.messages.length = 0 + }) + }, + } + }, +}) diff --git a/examples/streaming-chat/src/rpc/demo-prompts.ts b/examples/streaming-chat/src/rpc/demo-prompts.ts new file mode 100644 index 0000000..865a9b4 --- /dev/null +++ b/examples/streaming-chat/src/rpc/demo-prompts.ts @@ -0,0 +1,9 @@ +import { defineRpcFunction } from 'devframe' +import { DEMO_PROMPTS } from '../constants' + +export const demoPrompts = defineRpcFunction({ + name: 'devframe-streaming-chat:demo-prompts', + type: 'static', + jsonSerializable: true, + handler: () => ({ prompts: [...DEMO_PROMPTS] }), +}) diff --git a/examples/streaming-chat/src/rpc/index.ts b/examples/streaming-chat/src/rpc/index.ts new file mode 100644 index 0000000..ef173e6 --- /dev/null +++ b/examples/streaming-chat/src/rpc/index.ts @@ -0,0 +1,10 @@ +import type { RpcDefinitionsToFunctions } from 'devframe/rpc' +import { clear } from './clear' +import { demoPrompts } from './demo-prompts' +import { send } from './send' + +export const serverFunctions = [demoPrompts, send, clear] as const + +declare module 'devframe' { + interface DevToolsRpcServerFunctions extends RpcDefinitionsToFunctions {} +} diff --git a/examples/streaming-chat/src/rpc/send.ts b/examples/streaming-chat/src/rpc/send.ts new file mode 100644 index 0000000..5e9a248 --- /dev/null +++ b/examples/streaming-chat/src/rpc/send.ts @@ -0,0 +1,136 @@ +import { defineRpcFunction } from 'devframe' +import { nanoid } from 'devframe/utils/nanoid' +import * as v from 'valibot' +import { HISTORY_KEY } from '../constants' +import { getStreamingChatContext } from '../context' + +/** + * Synthetic "AI" — splits a canned response into tokens and emits them + * one at a time. Swap in `OpenAI`'s `chat.completions.create({ stream: true })` + * (or any async iterable of strings) to make it real. + */ +function* fakeTokens(prompt: string): Generator { + const lower = prompt.toLowerCase() + let response: string + if (/^(?:hi|hello|hey)\b/.test(lower)) { + response = `Hello! Ask me about devframe, streaming, or anything else — I'll fake-stream a response one token at a time.` + } + else if (lower.includes('haiku')) { + response = 'Tiny chunks arrive — / type-safe over WebSocket / streams compose with ease.' + } + else if (lower.includes('streaming')) { + response + = 'Streams start with `ctx.rpc.streaming.create()` on the server. ' + + 'Producers `write()` chunks; clients subscribe and consume them via ' + + '`for await (const chunk of reader)`. Cancellation, replay, and ' + + 'backpressure are wired by the host — your handler stays small.' + } + else if (lower.includes('history') || lower.includes('persist')) { + response + = `History lives in a devframe shared state ("${HISTORY_KEY}"). ` + + 'Each `send` appends a user + assistant pair; tokens stream live, ' + + 'and the final content is committed back to the shared state when ' + + 'the producer closes. Refresh the page and the log comes back.' + } + else { + response + = `You asked: "${prompt}". ` + + 'devframe is a framework-neutral foundation for building developer ' + + 'tooling — six adapters, type-safe RPC, shared state, and a ' + + 'first-class streaming channel for delta-style server↔client data. ' + + 'Pipe `ReadableStream`s into a sink, or write chunks by hand.' + } + // Split on whitespace but keep the spaces so `tokens.join('')` round-trips. + const tokens = response.split(/(\s+)/).filter(Boolean) + for (const token of tokens) yield token +} + +export const send = defineRpcFunction({ + name: 'devframe-streaming-chat:send', + type: 'action', + jsonSerializable: true, + args: [v.object({ + prompt: v.string(), + intervalMs: v.optional(v.number(), 35), + })], + returns: v.object({ + userId: v.string(), + assistantId: v.string(), + streamId: v.string(), + }), + setup: (ctx) => { + const { channel, history, pruneIfTooLarge } = getStreamingChatContext(ctx) + return { + handler: async ({ prompt, intervalMs = 35 }) => { + const stream = channel.start() + const userId = nanoid() + const assistantId = nanoid() + const now = Date.now() + + // Append both messages atomically — clients see the user prompt + // and the empty assistant placeholder appear together. + history.mutate((draft) => { + draft.messages.push({ + id: userId, + role: 'user', + content: prompt, + timestamp: now, + }) + draft.messages.push({ + id: assistantId, + role: 'assistant', + content: '', + streamId: stream.id, + timestamp: now, + }) + }) + pruneIfTooLarge() + + // Producer — token-by-token via streaming, full content committed + // to shared state when done so refreshes / new clients see the + // finished message without re-streaming. + ;(async () => { + let acc = '' + let cancelled = false + try { + for (const token of fakeTokens(prompt)) { + if (stream.signal.aborted) { + cancelled = true + break + } + stream.write(token) + acc += token + await new Promise(r => setTimeout(r, intervalMs)) + } + if (!cancelled) + stream.close() + } + catch (err) { + stream.error(err) + history.mutate((draft) => { + const msg = draft.messages.find(m => m.id === assistantId) + if (msg) { + msg.content = acc + msg.streamId = undefined + msg.cancelled = true + } + }) + return + } + + history.mutate((draft) => { + const msg = draft.messages.find(m => m.id === assistantId) + if (msg) { + msg.content = acc + msg.streamId = undefined + if (cancelled) + msg.cancelled = true + } + }) + })() + + return { userId, assistantId, streamId: stream.id } + }, + } + }, +}) diff --git a/examples/streaming-chat/src/types.ts b/examples/streaming-chat/src/types.ts new file mode 100644 index 0000000..39f97b8 --- /dev/null +++ b/examples/streaming-chat/src/types.ts @@ -0,0 +1,22 @@ +import type { HISTORY_KEY } from './constants' + +export interface ChatMessage { + id: string + role: 'user' | 'assistant' + content: string + /** Set on assistant messages while their stream is in flight. */ + streamId?: string + /** True if the assistant stream was cancelled before completing. */ + cancelled?: boolean + timestamp: number +} + +export interface ChatHistory { + messages: ChatMessage[] +} + +declare module 'devframe/types' { + interface DevToolsRpcSharedStates { + [HISTORY_KEY]: ChatHistory + } +} diff --git a/skills/devframe/SKILL.md b/skills/devframe/SKILL.md index 0e4b871..c6d61aa 100644 --- a/skills/devframe/SKILL.md +++ b/skills/devframe/SKILL.md @@ -66,6 +66,83 @@ export default defineDevframe({ See `templates/counter-devframe.ts` for a runnable counter example, `templates/spa-devframe.ts` for an SPA-ready shape, and `templates/vite-client.ts` for the author's client entry. +## Project layout + +Once a devframe grows past a handful of RPC functions, split them out — one file per function under `src/rpc/`, mirroring the framework's own internal layout (`packages/devframe/src/node/rpc/agent-*.ts`). Each file exports a named const; `src/rpc/index.ts` barrels them into a `const serverFunctions = [...] as const` that feeds directly into the type-safe client registry recipe (`RpcDefinitionsToFunctions`). + +```ts +// src/rpc/list-files.ts +import { defineRpcFunction } from 'devframe' +import { getMyToolContext } from '../context' + +export const listFiles = defineRpcFunction({ + name: 'my-tool:list-files', + type: 'query', + jsonSerializable: true, + setup: (ctx) => { + const { loaders } = getMyToolContext(ctx) + return { handler: () => loaders.list() } + }, +}) +``` + +```ts +// src/rpc/index.ts +import { getCwd } from './get-cwd' +import { listFiles } from './list-files' + +export const serverFunctions = [getCwd, listFiles] as const + +declare module 'devframe' { + interface DevToolsRpcServerFunctions + extends import('devframe/rpc').RpcDefinitionsToFunctions {} +} +``` + +```ts +// src/devframe.ts +import { defineDevframe } from 'devframe/types' +import { setMyToolContext } from './context' +import { serverFunctions } from './rpc' + +export default defineDevframe({ + id: 'my-tool', + setup(ctx) { + setMyToolContext(ctx, { loaders: createLoaders() }) + serverFunctions.forEach(fn => ctx.rpc.register(fn)) + }, +}) +``` + +### Sharing setup-time state via `src/context.ts` + +When per-file RPCs need access to runtime values that `setup(ctx)` constructs once — streaming channels, shared state handles, watchers, loaders, caches — expose them through a `WeakMap` in a sibling `src/context.ts`. This mirrors the framework's own `internalContextMap` in `packages/devframe/src/node/internal/context.ts`. The WeakMap keys off the existing `DevToolsNodeContext` so contexts are garbage-collected automatically when the host tears down. + +```ts +// src/context.ts +import type { DevToolsNodeContext } from 'devframe/types' + +export interface MyToolContext { + loaders: { list: () => Promise } + // …channels, shared state handles, watchers, etc. +} + +const map = new WeakMap() + +export function setMyToolContext(ctx: DevToolsNodeContext, value: MyToolContext): void { + map.set(ctx, value) +} + +export function getMyToolContext(ctx: DevToolsNodeContext): MyToolContext { + const value = map.get(ctx) + if (!value) + throw new Error('my-tool context not initialised — call setMyToolContext in devframe.setup') + return value +} +``` + +Stateless RPCs and tiny demos can keep the inline shorthand inside `setup(ctx)` — reach for `src/rpc/` and `src/context.ts` once you have more than one or two functions, or any shared setup state. + ## Namespacing **Always prefix** RPC names, dock IDs, command IDs, shared-state keys, and agent tool IDs with the devframe `id`: From d56cee41ce919cb29b2aeb920ff93c40c421f2ef Mon Sep 17 00:00:00 2001 From: Anthony Fu Date: Fri, 22 May 2026 19:13:58 +0900 Subject: [PATCH 3/4] refactor: nest per-rpc files under rpc/functions/ Mirrors @vitejs/devtools-kit conventions and leaves room for sibling files like rpc/utils.ts next to rpc/index.ts as the rpc surface grows. --- docs/guide/rpc.md | 4 ++-- .../src/rpc/{ => functions}/get-cwd.ts | 0 .../src/rpc/{ => functions}/list-files.ts | 0 examples/files-inspector/src/rpc/index.ts | 4 ++-- examples/next-runtime-snapshot/src/devframe.ts | 6 +++--- .../src/rpc/{ => functions}/env.ts | 0 .../src/rpc/{ => functions}/memory.ts | 0 .../src/rpc/{ => functions}/system.ts | 0 examples/next-runtime-snapshot/src/rpc/index.ts | 6 +++--- .../streaming-chat/src/rpc/{ => functions}/clear.ts | 2 +- .../src/rpc/{ => functions}/demo-prompts.ts | 2 +- .../streaming-chat/src/rpc/{ => functions}/send.ts | 4 ++-- examples/streaming-chat/src/rpc/index.ts | 6 +++--- skills/devframe/SKILL.md | 12 ++++++------ 14 files changed, 23 insertions(+), 23 deletions(-) rename examples/files-inspector/src/rpc/{ => functions}/get-cwd.ts (100%) rename examples/files-inspector/src/rpc/{ => functions}/list-files.ts (100%) rename examples/next-runtime-snapshot/src/rpc/{ => functions}/env.ts (100%) rename examples/next-runtime-snapshot/src/rpc/{ => functions}/memory.ts (100%) rename examples/next-runtime-snapshot/src/rpc/{ => functions}/system.ts (100%) rename examples/streaming-chat/src/rpc/{ => functions}/clear.ts (87%) rename examples/streaming-chat/src/rpc/{ => functions}/demo-prompts.ts (83%) rename examples/streaming-chat/src/rpc/{ => functions}/send.ts (97%) diff --git a/docs/guide/rpc.md b/docs/guide/rpc.md index 9de0ac7..9b23684 100644 --- a/docs/guide/rpc.md +++ b/docs/guide/rpc.md @@ -42,7 +42,7 @@ Register it in `setup`: ```ts import { defineDevframe } from 'devframe' -import { getModules } from './rpc/get-modules' +import { getModules } from './rpc/functions/get-modules' export default defineDevframe({ id: 'my-devframe', @@ -53,7 +53,7 @@ export default defineDevframe({ }) ``` -Place each function in its own file under `src/rpc/`, and barrel them in `src/rpc/index.ts` as `const serverFunctions = [...] as const`. The same array feeds the [type-safe client registry](#type-safe-client-registry) and keeps registration order explicit. When per-file functions need to share setup-time state (channels, shared state handles, loaders), expose it through a `WeakMap` in a sibling `src/context.ts`. +Place each function in its own file under `src/rpc/functions/`, and barrel them in `src/rpc/index.ts` as `const serverFunctions = [...] as const`. The same array feeds the [type-safe client registry](#type-safe-client-registry) and keeps registration order explicit. When per-file functions need to share setup-time state (channels, shared state handles, loaders), expose it through a `WeakMap` in a sibling `src/context.ts`. ### Naming convention diff --git a/examples/files-inspector/src/rpc/get-cwd.ts b/examples/files-inspector/src/rpc/functions/get-cwd.ts similarity index 100% rename from examples/files-inspector/src/rpc/get-cwd.ts rename to examples/files-inspector/src/rpc/functions/get-cwd.ts diff --git a/examples/files-inspector/src/rpc/list-files.ts b/examples/files-inspector/src/rpc/functions/list-files.ts similarity index 100% rename from examples/files-inspector/src/rpc/list-files.ts rename to examples/files-inspector/src/rpc/functions/list-files.ts diff --git a/examples/files-inspector/src/rpc/index.ts b/examples/files-inspector/src/rpc/index.ts index 1364ce7..d355b04 100644 --- a/examples/files-inspector/src/rpc/index.ts +++ b/examples/files-inspector/src/rpc/index.ts @@ -1,6 +1,6 @@ import type { RpcDefinitionsToFunctions } from 'devframe/rpc' -import { getCwd } from './get-cwd' -import { listFiles } from './list-files' +import { getCwd } from './functions/get-cwd' +import { listFiles } from './functions/list-files' export const serverFunctions = [getCwd, listFiles] as const diff --git a/examples/next-runtime-snapshot/src/devframe.ts b/examples/next-runtime-snapshot/src/devframe.ts index 0238956..4440b73 100644 --- a/examples/next-runtime-snapshot/src/devframe.ts +++ b/examples/next-runtime-snapshot/src/devframe.ts @@ -2,9 +2,9 @@ import { fileURLToPath } from 'node:url' import { defineDevframe } from 'devframe/types' import { serverFunctions } from './rpc' -export type { EnvEntry, EnvSnapshot } from './rpc/env' -export type { MemorySnapshot } from './rpc/memory' -export type { SystemInfo } from './rpc/system' +export type { EnvEntry, EnvSnapshot } from './rpc/functions/env' +export type { MemorySnapshot } from './rpc/functions/memory' +export type { SystemInfo } from './rpc/functions/system' const BASE_PATH = '/__next-runtime-snapshot/' const distDir = fileURLToPath(new URL('../dist/client', import.meta.url)) diff --git a/examples/next-runtime-snapshot/src/rpc/env.ts b/examples/next-runtime-snapshot/src/rpc/functions/env.ts similarity index 100% rename from examples/next-runtime-snapshot/src/rpc/env.ts rename to examples/next-runtime-snapshot/src/rpc/functions/env.ts diff --git a/examples/next-runtime-snapshot/src/rpc/memory.ts b/examples/next-runtime-snapshot/src/rpc/functions/memory.ts similarity index 100% rename from examples/next-runtime-snapshot/src/rpc/memory.ts rename to examples/next-runtime-snapshot/src/rpc/functions/memory.ts diff --git a/examples/next-runtime-snapshot/src/rpc/system.ts b/examples/next-runtime-snapshot/src/rpc/functions/system.ts similarity index 100% rename from examples/next-runtime-snapshot/src/rpc/system.ts rename to examples/next-runtime-snapshot/src/rpc/functions/system.ts diff --git a/examples/next-runtime-snapshot/src/rpc/index.ts b/examples/next-runtime-snapshot/src/rpc/index.ts index 406a339..4c1171c 100644 --- a/examples/next-runtime-snapshot/src/rpc/index.ts +++ b/examples/next-runtime-snapshot/src/rpc/index.ts @@ -1,7 +1,7 @@ import type { RpcDefinitionsToFunctions } from 'devframe/rpc' -import { env } from './env' -import { memory } from './memory' -import { system } from './system' +import { env } from './functions/env' +import { memory } from './functions/memory' +import { system } from './functions/system' export const serverFunctions = [system, memory, env] as const diff --git a/examples/streaming-chat/src/rpc/clear.ts b/examples/streaming-chat/src/rpc/functions/clear.ts similarity index 87% rename from examples/streaming-chat/src/rpc/clear.ts rename to examples/streaming-chat/src/rpc/functions/clear.ts index 4d3d08d..5e494fb 100644 --- a/examples/streaming-chat/src/rpc/clear.ts +++ b/examples/streaming-chat/src/rpc/functions/clear.ts @@ -1,5 +1,5 @@ import { defineRpcFunction } from 'devframe' -import { getStreamingChatContext } from '../context' +import { getStreamingChatContext } from '../../context' export const clear = defineRpcFunction({ name: 'devframe-streaming-chat:clear', diff --git a/examples/streaming-chat/src/rpc/demo-prompts.ts b/examples/streaming-chat/src/rpc/functions/demo-prompts.ts similarity index 83% rename from examples/streaming-chat/src/rpc/demo-prompts.ts rename to examples/streaming-chat/src/rpc/functions/demo-prompts.ts index 865a9b4..1e78f82 100644 --- a/examples/streaming-chat/src/rpc/demo-prompts.ts +++ b/examples/streaming-chat/src/rpc/functions/demo-prompts.ts @@ -1,5 +1,5 @@ import { defineRpcFunction } from 'devframe' -import { DEMO_PROMPTS } from '../constants' +import { DEMO_PROMPTS } from '../../constants' export const demoPrompts = defineRpcFunction({ name: 'devframe-streaming-chat:demo-prompts', diff --git a/examples/streaming-chat/src/rpc/send.ts b/examples/streaming-chat/src/rpc/functions/send.ts similarity index 97% rename from examples/streaming-chat/src/rpc/send.ts rename to examples/streaming-chat/src/rpc/functions/send.ts index 5e9a248..6a77ef4 100644 --- a/examples/streaming-chat/src/rpc/send.ts +++ b/examples/streaming-chat/src/rpc/functions/send.ts @@ -1,8 +1,8 @@ import { defineRpcFunction } from 'devframe' import { nanoid } from 'devframe/utils/nanoid' import * as v from 'valibot' -import { HISTORY_KEY } from '../constants' -import { getStreamingChatContext } from '../context' +import { HISTORY_KEY } from '../../constants' +import { getStreamingChatContext } from '../../context' /** * Synthetic "AI" — splits a canned response into tokens and emits them diff --git a/examples/streaming-chat/src/rpc/index.ts b/examples/streaming-chat/src/rpc/index.ts index ef173e6..5bf23dc 100644 --- a/examples/streaming-chat/src/rpc/index.ts +++ b/examples/streaming-chat/src/rpc/index.ts @@ -1,7 +1,7 @@ import type { RpcDefinitionsToFunctions } from 'devframe/rpc' -import { clear } from './clear' -import { demoPrompts } from './demo-prompts' -import { send } from './send' +import { clear } from './functions/clear' +import { demoPrompts } from './functions/demo-prompts' +import { send } from './functions/send' export const serverFunctions = [demoPrompts, send, clear] as const diff --git a/skills/devframe/SKILL.md b/skills/devframe/SKILL.md index c6d61aa..5c39507 100644 --- a/skills/devframe/SKILL.md +++ b/skills/devframe/SKILL.md @@ -68,12 +68,12 @@ See `templates/counter-devframe.ts` for a runnable counter example, `templates/s ## Project layout -Once a devframe grows past a handful of RPC functions, split them out — one file per function under `src/rpc/`, mirroring the framework's own internal layout (`packages/devframe/src/node/rpc/agent-*.ts`). Each file exports a named const; `src/rpc/index.ts` barrels them into a `const serverFunctions = [...] as const` that feeds directly into the type-safe client registry recipe (`RpcDefinitionsToFunctions`). +Once a devframe grows past a handful of RPC functions, split them out — one file per function under `src/rpc/functions/`, with `src/rpc/index.ts` as the barrel. The `functions/` subdirectory leaves room for sibling files like `src/rpc/utils.ts` (helpers, type aliases) as the surface grows. Each function file exports a named const; the barrel collects them into a `const serverFunctions = [...] as const` that feeds the type-safe client registry recipe (`RpcDefinitionsToFunctions`). ```ts -// src/rpc/list-files.ts +// src/rpc/functions/list-files.ts import { defineRpcFunction } from 'devframe' -import { getMyToolContext } from '../context' +import { getMyToolContext } from '../../context' export const listFiles = defineRpcFunction({ name: 'my-tool:list-files', @@ -88,8 +88,8 @@ export const listFiles = defineRpcFunction({ ```ts // src/rpc/index.ts -import { getCwd } from './get-cwd' -import { listFiles } from './list-files' +import { getCwd } from './functions/get-cwd' +import { listFiles } from './functions/list-files' export const serverFunctions = [getCwd, listFiles] as const @@ -141,7 +141,7 @@ export function getMyToolContext(ctx: DevToolsNodeContext): MyToolContext { } ``` -Stateless RPCs and tiny demos can keep the inline shorthand inside `setup(ctx)` — reach for `src/rpc/` and `src/context.ts` once you have more than one or two functions, or any shared setup state. +Stateless RPCs and tiny demos can keep the inline shorthand inside `setup(ctx)` — reach for `src/rpc/functions/` and `src/context.ts` once you have more than one or two functions, or any shared setup state. ## Namespacing From e279aa3c2b39ad68cc560f223fab09a6d1426537 Mon Sep 17 00:00:00 2001 From: Anthony Fu Date: Fri, 22 May 2026 19:22:20 +0900 Subject: [PATCH 4/4] fix(examples): use explicit .ts extensions on relative imports MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Node native ESM (bin.mjs -> src/devframe.ts) doesn't auto-resolve directory or extension-less imports — only the bundler-backed test harness did. Add explicit .ts extensions so the playwright e2e webserver can boot the examples. --- examples/files-inspector/src/devframe.ts | 2 +- examples/files-inspector/src/rpc/index.ts | 4 ++-- examples/next-runtime-snapshot/src/devframe.ts | 8 ++++---- examples/next-runtime-snapshot/src/rpc/index.ts | 6 +++--- examples/streaming-chat/src/context.ts | 2 +- examples/streaming-chat/src/devframe.ts | 8 ++++---- examples/streaming-chat/src/rpc/functions/clear.ts | 2 +- examples/streaming-chat/src/rpc/functions/demo-prompts.ts | 2 +- examples/streaming-chat/src/rpc/functions/send.ts | 4 ++-- examples/streaming-chat/src/rpc/index.ts | 6 +++--- examples/streaming-chat/src/types.ts | 2 +- 11 files changed, 23 insertions(+), 23 deletions(-) diff --git a/examples/files-inspector/src/devframe.ts b/examples/files-inspector/src/devframe.ts index 271d2dd..d1508c0 100644 --- a/examples/files-inspector/src/devframe.ts +++ b/examples/files-inspector/src/devframe.ts @@ -1,6 +1,6 @@ import { fileURLToPath } from 'node:url' import { defineDevframe } from 'devframe/types' -import { serverFunctions } from './rpc' +import { serverFunctions } from './rpc/index.ts' const BASE_PATH = '/__devframe-files-inspector/' const distDir = fileURLToPath(new URL('../dist/client', import.meta.url)) diff --git a/examples/files-inspector/src/rpc/index.ts b/examples/files-inspector/src/rpc/index.ts index d355b04..181cb1b 100644 --- a/examples/files-inspector/src/rpc/index.ts +++ b/examples/files-inspector/src/rpc/index.ts @@ -1,6 +1,6 @@ import type { RpcDefinitionsToFunctions } from 'devframe/rpc' -import { getCwd } from './functions/get-cwd' -import { listFiles } from './functions/list-files' +import { getCwd } from './functions/get-cwd.ts' +import { listFiles } from './functions/list-files.ts' export const serverFunctions = [getCwd, listFiles] as const diff --git a/examples/next-runtime-snapshot/src/devframe.ts b/examples/next-runtime-snapshot/src/devframe.ts index 4440b73..00db187 100644 --- a/examples/next-runtime-snapshot/src/devframe.ts +++ b/examples/next-runtime-snapshot/src/devframe.ts @@ -1,10 +1,10 @@ import { fileURLToPath } from 'node:url' import { defineDevframe } from 'devframe/types' -import { serverFunctions } from './rpc' +import { serverFunctions } from './rpc/index.ts' -export type { EnvEntry, EnvSnapshot } from './rpc/functions/env' -export type { MemorySnapshot } from './rpc/functions/memory' -export type { SystemInfo } from './rpc/functions/system' +export type { EnvEntry, EnvSnapshot } from './rpc/functions/env.ts' +export type { MemorySnapshot } from './rpc/functions/memory.ts' +export type { SystemInfo } from './rpc/functions/system.ts' const BASE_PATH = '/__next-runtime-snapshot/' const distDir = fileURLToPath(new URL('../dist/client', import.meta.url)) diff --git a/examples/next-runtime-snapshot/src/rpc/index.ts b/examples/next-runtime-snapshot/src/rpc/index.ts index 4c1171c..66f044b 100644 --- a/examples/next-runtime-snapshot/src/rpc/index.ts +++ b/examples/next-runtime-snapshot/src/rpc/index.ts @@ -1,7 +1,7 @@ import type { RpcDefinitionsToFunctions } from 'devframe/rpc' -import { env } from './functions/env' -import { memory } from './functions/memory' -import { system } from './functions/system' +import { env } from './functions/env.ts' +import { memory } from './functions/memory.ts' +import { system } from './functions/system.ts' export const serverFunctions = [system, memory, env] as const diff --git a/examples/streaming-chat/src/context.ts b/examples/streaming-chat/src/context.ts index efe6742..8b1c0eb 100644 --- a/examples/streaming-chat/src/context.ts +++ b/examples/streaming-chat/src/context.ts @@ -1,6 +1,6 @@ import type { DevToolsNodeContext, RpcStreamingChannel } from 'devframe/types' import type { SharedState } from 'devframe/utils/shared-state' -import type { ChatHistory } from './types' +import type { ChatHistory } from './types.ts' export interface StreamingChatContext { channel: RpcStreamingChannel diff --git a/examples/streaming-chat/src/devframe.ts b/examples/streaming-chat/src/devframe.ts index 181f2c7..488f82b 100644 --- a/examples/streaming-chat/src/devframe.ts +++ b/examples/streaming-chat/src/devframe.ts @@ -1,10 +1,10 @@ import { fileURLToPath } from 'node:url' import { defineDevframe } from 'devframe/types' -import { CHANNEL_NAME, HISTORY_KEY, MAX_HISTORY } from './constants' -import { setStreamingChatContext } from './context' -import { serverFunctions } from './rpc' +import { CHANNEL_NAME, HISTORY_KEY, MAX_HISTORY } from './constants.ts' +import { setStreamingChatContext } from './context.ts' +import { serverFunctions } from './rpc/index.ts' -export type { ChatHistory, ChatMessage } from './types' +export type { ChatHistory, ChatMessage } from './types.ts' const BASE_PATH = '/__devframe-streaming-chat/' const distDir = fileURLToPath(new URL('../dist/client', import.meta.url)) diff --git a/examples/streaming-chat/src/rpc/functions/clear.ts b/examples/streaming-chat/src/rpc/functions/clear.ts index 5e494fb..e1f0874 100644 --- a/examples/streaming-chat/src/rpc/functions/clear.ts +++ b/examples/streaming-chat/src/rpc/functions/clear.ts @@ -1,5 +1,5 @@ import { defineRpcFunction } from 'devframe' -import { getStreamingChatContext } from '../../context' +import { getStreamingChatContext } from '../../context.ts' export const clear = defineRpcFunction({ name: 'devframe-streaming-chat:clear', diff --git a/examples/streaming-chat/src/rpc/functions/demo-prompts.ts b/examples/streaming-chat/src/rpc/functions/demo-prompts.ts index 1e78f82..c34644d 100644 --- a/examples/streaming-chat/src/rpc/functions/demo-prompts.ts +++ b/examples/streaming-chat/src/rpc/functions/demo-prompts.ts @@ -1,5 +1,5 @@ import { defineRpcFunction } from 'devframe' -import { DEMO_PROMPTS } from '../../constants' +import { DEMO_PROMPTS } from '../../constants.ts' export const demoPrompts = defineRpcFunction({ name: 'devframe-streaming-chat:demo-prompts', diff --git a/examples/streaming-chat/src/rpc/functions/send.ts b/examples/streaming-chat/src/rpc/functions/send.ts index 6a77ef4..272e477 100644 --- a/examples/streaming-chat/src/rpc/functions/send.ts +++ b/examples/streaming-chat/src/rpc/functions/send.ts @@ -1,8 +1,8 @@ import { defineRpcFunction } from 'devframe' import { nanoid } from 'devframe/utils/nanoid' import * as v from 'valibot' -import { HISTORY_KEY } from '../../constants' -import { getStreamingChatContext } from '../../context' +import { HISTORY_KEY } from '../../constants.ts' +import { getStreamingChatContext } from '../../context.ts' /** * Synthetic "AI" — splits a canned response into tokens and emits them diff --git a/examples/streaming-chat/src/rpc/index.ts b/examples/streaming-chat/src/rpc/index.ts index 5bf23dc..4e800c1 100644 --- a/examples/streaming-chat/src/rpc/index.ts +++ b/examples/streaming-chat/src/rpc/index.ts @@ -1,7 +1,7 @@ import type { RpcDefinitionsToFunctions } from 'devframe/rpc' -import { clear } from './functions/clear' -import { demoPrompts } from './functions/demo-prompts' -import { send } from './functions/send' +import { clear } from './functions/clear.ts' +import { demoPrompts } from './functions/demo-prompts.ts' +import { send } from './functions/send.ts' export const serverFunctions = [demoPrompts, send, clear] as const diff --git a/examples/streaming-chat/src/types.ts b/examples/streaming-chat/src/types.ts index 39f97b8..961f9a7 100644 --- a/examples/streaming-chat/src/types.ts +++ b/examples/streaming-chat/src/types.ts @@ -1,4 +1,4 @@ -import type { HISTORY_KEY } from './constants' +import type { HISTORY_KEY } from './constants.ts' export interface ChatMessage { id: string