diff --git a/docs/guide/rpc.md b/docs/guide/rpc.md index 9bb164f..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,6 +53,8 @@ export default defineDevframe({ }) ``` +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 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..d1508c0 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/index.ts' 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/functions/get-cwd.ts b/examples/files-inspector/src/rpc/functions/get-cwd.ts new file mode 100644 index 0000000..ed653a0 --- /dev/null +++ b/examples/files-inspector/src/rpc/functions/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/functions/list-files.ts b/examples/files-inspector/src/rpc/functions/list-files.ts new file mode 100644 index 0000000..e2576bc --- /dev/null +++ b/examples/files-inspector/src/rpc/functions/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/files-inspector/src/rpc/index.ts b/examples/files-inspector/src/rpc/index.ts new file mode 100644 index 0000000..181cb1b --- /dev/null +++ b/examples/files-inspector/src/rpc/index.ts @@ -0,0 +1,9 @@ +import type { RpcDefinitionsToFunctions } from 'devframe/rpc' +import { getCwd } from './functions/get-cwd.ts' +import { listFiles } from './functions/list-files.ts' + +export const serverFunctions = [getCwd, listFiles] as const + +declare module 'devframe' { + interface DevToolsRpcServerFunctions extends RpcDefinitionsToFunctions {} +} 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..00db187 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/index.ts' + +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)) -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/functions/env.ts b/examples/next-runtime-snapshot/src/rpc/functions/env.ts new file mode 100644 index 0000000..2f2175e --- /dev/null +++ b/examples/next-runtime-snapshot/src/rpc/functions/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/functions/memory.ts b/examples/next-runtime-snapshot/src/rpc/functions/memory.ts new file mode 100644 index 0000000..f3a869d --- /dev/null +++ b/examples/next-runtime-snapshot/src/rpc/functions/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/functions/system.ts b/examples/next-runtime-snapshot/src/rpc/functions/system.ts new file mode 100644 index 0000000..eb9590e --- /dev/null +++ b/examples/next-runtime-snapshot/src/rpc/functions/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/next-runtime-snapshot/src/rpc/index.ts b/examples/next-runtime-snapshot/src/rpc/index.ts new file mode 100644 index 0000000..66f044b --- /dev/null +++ b/examples/next-runtime-snapshot/src/rpc/index.ts @@ -0,0 +1,10 @@ +import type { RpcDefinitionsToFunctions } from 'devframe/rpc' +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 + +declare module 'devframe' { + interface DevToolsRpcServerFunctions extends RpcDefinitionsToFunctions {} +} 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..8b1c0eb --- /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.ts' + +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..488f82b 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.ts' +import { setStreamingChatContext } from './context.ts' +import { serverFunctions } from './rpc/index.ts' + +export type { ChatHistory, ChatMessage } from './types.ts' 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/functions/clear.ts b/examples/streaming-chat/src/rpc/functions/clear.ts new file mode 100644 index 0000000..e1f0874 --- /dev/null +++ b/examples/streaming-chat/src/rpc/functions/clear.ts @@ -0,0 +1,18 @@ +import { defineRpcFunction } from 'devframe' +import { getStreamingChatContext } from '../../context.ts' + +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/functions/demo-prompts.ts b/examples/streaming-chat/src/rpc/functions/demo-prompts.ts new file mode 100644 index 0000000..c34644d --- /dev/null +++ b/examples/streaming-chat/src/rpc/functions/demo-prompts.ts @@ -0,0 +1,9 @@ +import { defineRpcFunction } from 'devframe' +import { DEMO_PROMPTS } from '../../constants.ts' + +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/functions/send.ts b/examples/streaming-chat/src/rpc/functions/send.ts new file mode 100644 index 0000000..272e477 --- /dev/null +++ b/examples/streaming-chat/src/rpc/functions/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.ts' +import { getStreamingChatContext } from '../../context.ts' + +/** + * 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/rpc/index.ts b/examples/streaming-chat/src/rpc/index.ts new file mode 100644 index 0000000..4e800c1 --- /dev/null +++ b/examples/streaming-chat/src/rpc/index.ts @@ -0,0 +1,10 @@ +import type { RpcDefinitionsToFunctions } from 'devframe/rpc' +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 + +declare module 'devframe' { + interface DevToolsRpcServerFunctions extends RpcDefinitionsToFunctions {} +} diff --git a/examples/streaming-chat/src/types.ts b/examples/streaming-chat/src/types.ts new file mode 100644 index 0000000..961f9a7 --- /dev/null +++ b/examples/streaming-chat/src/types.ts @@ -0,0 +1,22 @@ +import type { HISTORY_KEY } from './constants.ts' + +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/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/skills/devframe/SKILL.md b/skills/devframe/SKILL.md index 0e4b871..5c39507 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/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/functions/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 './functions/get-cwd' +import { listFiles } from './functions/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/functions/` 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`: 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