diff --git a/alias.ts b/alias.ts index 6cefefa01..3e8986693 100644 --- a/alias.ts +++ b/alias.ts @@ -77,7 +77,7 @@ export const alias = { '@devframes/json-render-ui/hub': r('json-render-ui/src/hub.ts'), '@devframes/json-render-ui/spa': r('json-render-ui/src/spa.ts'), '@devframes/json-render-ui': r('json-render-ui/src/index.ts'), - 'json-render/dashboard': fileURLToPath(new URL('./examples/json-render/src/dashboard.ts', import.meta.url)), + 'json-render/dashboard': fileURLToPath(new URL('./examples/json-render/src/node/dashboard.ts', import.meta.url)), '@devframes/plugin-code-server/node': p('code-server/src/node/setup.ts'), '@devframes/plugin-code-server/constants': p('code-server/src/node/constants.ts'), '@devframes/plugin-code-server/types': p('code-server/src/node/types.ts'), diff --git a/examples/files-inspector/src/client/app.tsx b/examples/files-inspector/app/app.tsx similarity index 100% rename from examples/files-inspector/src/client/app.tsx rename to examples/files-inspector/app/app.tsx diff --git a/examples/streaming-chat/src/client/design.ts b/examples/files-inspector/app/design.ts similarity index 76% rename from examples/streaming-chat/src/client/design.ts rename to examples/files-inspector/app/design.ts index d8e49c73a..0bf9debcd 100644 --- a/examples/streaming-chat/src/client/design.ts +++ b/examples/files-inspector/app/design.ts @@ -1,3 +1,3 @@ // Re-exports the shared devframe class-helper builders (see // `design/design.ts`) so this surface stays in lockstep with every other. -export * from '../../../../design/design' +export * from '../../../design/design' diff --git a/examples/files-inspector/src/client/index.html b/examples/files-inspector/app/index.html similarity index 100% rename from examples/files-inspector/src/client/index.html rename to examples/files-inspector/app/index.html diff --git a/examples/files-inspector/src/client/main.tsx b/examples/files-inspector/app/main.tsx similarity index 100% rename from examples/files-inspector/src/client/main.tsx rename to examples/files-inspector/app/main.tsx diff --git a/examples/files-inspector/src/client/routes/about.tsx b/examples/files-inspector/app/routes/about.tsx similarity index 100% rename from examples/files-inspector/src/client/routes/about.tsx rename to examples/files-inspector/app/routes/about.tsx diff --git a/examples/files-inspector/src/client/routes/home.tsx b/examples/files-inspector/app/routes/home.tsx similarity index 100% rename from examples/files-inspector/src/client/routes/home.tsx rename to examples/files-inspector/app/routes/home.tsx diff --git a/examples/files-inspector/src/client/shims.d.ts b/examples/files-inspector/app/shims.d.ts similarity index 100% rename from examples/files-inspector/src/client/shims.d.ts rename to examples/files-inspector/app/shims.d.ts diff --git a/examples/files-inspector/app/vite.config.ts b/examples/files-inspector/app/vite.config.ts new file mode 100644 index 000000000..2312cec06 --- /dev/null +++ b/examples/files-inspector/app/vite.config.ts @@ -0,0 +1,50 @@ +import type { Server } from 'node:http' +import type { Plugin } from 'vite' +import { fileURLToPath } from 'node:url' +import preact from '@preact/preset-vite' +import { initDevframe } from 'devframe/initiate' +import { resolveBasePath } from 'devframe/internal' +import UnoCSS from 'unocss/vite' +import { defineConfig } from 'vite' +import { alias } from '../../../alias' +import devframe from '../src/node/index.ts' + +/** + * Serve-only plugin that bridges the node side (RPC + WebSocket + + * `__connection.json`) onto Vite's dev server under the devframe base path, + * mounted as a post middleware so Vite serves the HMR SPA first and the + * devframe host only answers the routes it owns. Inert during `vite build`. + */ +function filesInspectorDevBridge(): Plugin { + return { + name: 'files-inspector-dev-bridge', + apply: 'serve', + configureServer(server) { + const instance = initDevframe(devframe, { + base: resolveBasePath(devframe, 'hosted'), + distDir: false, + server: server.httpServer as Server, + auth: false, + }) + return () => server.middlewares.use(instance.nodeMiddleware) + }, + } +} + +/** + * `base: './'` for the build keeps the mount path portable: the same output + * works whether devframe serves it at `/` (standalone) or under a base path + * (mounted in a hub). In `vite dev` the panel is served under the devframe + * base path so its `document.baseURI` matches production, and the dev bridge + * bridges the node side there with HMR. + */ +export default defineConfig(({ command }) => ({ + base: command === 'serve' ? resolveBasePath(devframe, 'hosted') : './', + root: fileURLToPath(new URL('.', import.meta.url)), + resolve: { alias }, + plugins: [UnoCSS(), preact(), filesInspectorDevBridge()], + build: { + outDir: fileURLToPath(new URL('../dist/client', import.meta.url)), + emptyOutDir: true, + }, +})) diff --git a/examples/files-inspector/bin.mjs b/examples/files-inspector/bin.mjs index c1ed8f007..5e7c4709e 100755 --- a/examples/files-inspector/bin.mjs +++ b/examples/files-inspector/bin.mjs @@ -1,7 +1,7 @@ #!/usr/bin/env node import process from 'node:process' import { createCac } from 'devframe/adapters/cac' -import devframe from './src/devframe.ts' +import devframe from './src/node/index.ts' async function main() { // Serve the agent surface at `/__mcp` and register for `devframe connect` diff --git a/examples/files-inspector/package.json b/examples/files-inspector/package.json index ade078f14..3c99b98c1 100644 --- a/examples/files-inspector/package.json +++ b/examples/files-inspector/package.json @@ -5,13 +5,15 @@ "private": true, "description": "Devframe demo that lists files in the working directory over RPC across the dev and build surfaces.", "homepage": "https://github.com/devframes/devframe/tree/main/examples/files-inspector", - "main": "src/devframe.ts", + "main": "src/node/index.ts", "bin": { "files-inspector": "./bin.mjs" }, "scripts": { - "build": "vite build --config src/client/vite.config.ts", - "dev": "node bin.mjs", + "build": "vite build --config app/vite.config.ts", + "build:app": "vite build --config app/vite.config.ts", + "dev": "vite --config app/vite.config.ts --host", + "play": "pnpm run build && node playgrounds/server.mjs", "cli:build": "node bin.mjs build --out-dir dist/static", "test": "vitest run", "typecheck": "tsc --noEmit" diff --git a/examples/files-inspector/playgrounds/server.mjs b/examples/files-inspector/playgrounds/server.mjs new file mode 100644 index 000000000..e6ac50eea --- /dev/null +++ b/examples/files-inspector/playgrounds/server.mjs @@ -0,0 +1,65 @@ +#!/usr/bin/env node +/** + * Playground host for the Files Inspector: boots the built tool and serves its + * SPA panel + live WebSocket RPC off one origin, mirroring how a hub would + * mount it under the devframe base path. + * + * node playgrounds/server.mjs → serves `dist/client` with live RPC + */ +import { existsSync } from 'node:fs' +import { createServer } from 'node:http' +import process from 'node:process' +import { fileURLToPath } from 'node:url' +import { initDevframe } from 'devframe/initiate' +import { getPort } from 'devframe/utils/get-port' +import { H3, toNodeHandler } from 'h3' +import { resolve } from 'pathe' +import devframe from '../src/node/index.ts' + +const HERE = fileURLToPath(new URL('.', import.meta.url)) +const ROOT = resolve(HERE, '..') + +const basePath = devframe.basePath +const panelDir = resolve(ROOT, 'dist/client') + +function requireBuilt(file, hint) { + if (!existsSync(file)) { + console.error(`\n[files-inspector playground] missing ${file}\n → run \`${hint}\` first.\n`) + process.exit(1) + } +} + +function banner(origin) { + process.stdout.write( + `\n Files Inspector demo: dev (live WebSocket RPC)\n` + + ` ▸ panel: ${origin}${basePath}\n\n`, + ) +} + +async function main() { + requireBuilt(resolve(panelDir, 'index.html'), 'pnpm -C examples/files-inspector build') + + const bindHost = '0.0.0.0' + const port = await getPort({ host: bindHost, port: 9876 }) + const origin = `http://localhost:${port}` + + const app = new H3() + const server = createServer(toNodeHandler(app)) + const instance = initDevframe(devframe, { + base: basePath, + distDir: panelDir, + app, + server, + host: bindHost, + origin, + auth: false, + }) + await new Promise(r => server.listen(port, bindHost, r)) + await instance.ready + banner(origin) +} + +main().catch((error) => { + console.error(error) + process.exit(1) +}) diff --git a/examples/files-inspector/src/client/vite.config.ts b/examples/files-inspector/src/client/vite.config.ts deleted file mode 100644 index d542a1bab..000000000 --- a/examples/files-inspector/src/client/vite.config.ts +++ /dev/null @@ -1,16 +0,0 @@ -import { fileURLToPath } from 'node:url' -import preact from '@preact/preset-vite' -import UnoCSS from 'unocss/vite' -import { defineConfig } from 'vite' -import { alias } from '../../../../alias' - -export default defineConfig({ - base: './', - root: fileURLToPath(new URL('.', import.meta.url)), - resolve: { alias }, - plugins: [UnoCSS(), preact()], - build: { - outDir: fileURLToPath(new URL('../../dist/client', import.meta.url)), - emptyOutDir: true, - }, -}) diff --git a/examples/files-inspector/src/devframe.ts b/examples/files-inspector/src/node/index.ts similarity index 87% rename from examples/files-inspector/src/devframe.ts rename to examples/files-inspector/src/node/index.ts index 49be230de..674eb082c 100644 --- a/examples/files-inspector/src/devframe.ts +++ b/examples/files-inspector/src/node/index.ts @@ -1,10 +1,10 @@ import { fileURLToPath } from 'node:url' import { defineDevframe } from 'devframe' -import pkg from '../package.json' with { type: 'json' } +import pkg from '../../package.json' with { type: 'json' } import { NAMESPACE, serverFunctions } from './rpc/index.ts' const BASE_PATH = '/__devframe-files-inspector/' -const distDir = fileURLToPath(new URL('../dist/client', import.meta.url)) +const distDir = fileURLToPath(new URL('../../dist/client', import.meta.url)) export default defineDevframe({ id: 'example:files-inspector', @@ -39,7 +39,7 @@ export default defineDevframe({ description: 'Locate the Files Inspector\'s documentation on disk. Call before answering questions about how this tool works, then read the returned files directly.', safety: 'read', handler: () => ({ - readmePath: fileURLToPath(new URL('../README.md', import.meta.url)), + readmePath: fileURLToPath(new URL('../../README.md', import.meta.url)), hint: 'Read the file at readmePath with your own file tools; do not rely on training-data knowledge of this example.', }), }) diff --git a/examples/files-inspector/src/rpc/functions/get-cwd.ts b/examples/files-inspector/src/node/rpc/functions/get-cwd.ts similarity index 100% rename from examples/files-inspector/src/rpc/functions/get-cwd.ts rename to examples/files-inspector/src/node/rpc/functions/get-cwd.ts diff --git a/examples/files-inspector/src/rpc/functions/list-files.ts b/examples/files-inspector/src/node/rpc/functions/list-files.ts similarity index 100% rename from examples/files-inspector/src/rpc/functions/list-files.ts rename to examples/files-inspector/src/node/rpc/functions/list-files.ts diff --git a/examples/files-inspector/src/rpc/index.ts b/examples/files-inspector/src/node/rpc/index.ts similarity index 100% rename from examples/files-inspector/src/rpc/index.ts rename to examples/files-inspector/src/node/rpc/index.ts diff --git a/examples/files-inspector/tests/_utils.ts b/examples/files-inspector/tests/_utils.ts index ae40de659..e03d8b748 100644 --- a/examples/files-inspector/tests/_utils.ts +++ b/examples/files-inspector/tests/_utils.ts @@ -14,7 +14,7 @@ import { getPort } from 'get-port-please' import { H3 } from 'h3' import { resolve } from 'pathe' import { serveTestContext } from '../../../tests/helpers/serve-test-context' -import devframe from '../src/devframe' +import devframe from '../src/node/index' const HERE = fileURLToPath(new URL('.', import.meta.url)) const CLIENT_DIST = resolve(HERE, '../dist/client') diff --git a/examples/files-inspector/tests/static-build.test.ts b/examples/files-inspector/tests/static-build.test.ts index 5ccb21318..b05a4b397 100644 --- a/examples/files-inspector/tests/static-build.test.ts +++ b/examples/files-inspector/tests/static-build.test.ts @@ -8,7 +8,7 @@ import { DEVFRAME_RPC_DUMP_MANIFEST_FILENAME, } from 'devframe/constants' import { afterAll, beforeAll, describe, expect, it } from 'vitest' -import devframe from '../src/devframe' +import devframe from '../src/node/index' import { assertClientBuilt, makeFixtureCwd } from './_utils' interface DumpManifest { diff --git a/examples/files-inspector/tests/static-serve.test.ts b/examples/files-inspector/tests/static-serve.test.ts index d7bca9598..8f79a2a9b 100644 --- a/examples/files-inspector/tests/static-serve.test.ts +++ b/examples/files-inspector/tests/static-serve.test.ts @@ -7,7 +7,7 @@ import { mountStaticHandler } from 'devframe/utils/serve-static' import { getPort } from 'get-port-please' import { H3, toNodeHandler } from 'h3' import { afterAll, beforeAll, describe, expect, it } from 'vitest' -import devframe from '../src/devframe' +import devframe from '../src/node/index' import { assertClientBuilt, makeFixtureCwd } from './_utils' interface StaticServer { diff --git a/examples/files-inspector/tsconfig.json b/examples/files-inspector/tsconfig.json index d4bdee9d3..a1fa38032 100644 --- a/examples/files-inspector/tsconfig.json +++ b/examples/files-inspector/tsconfig.json @@ -10,5 +10,5 @@ "esModuleInterop": true, "isolatedDeclarations": false }, - "include": ["src", "tests", "bin.mjs"] + "include": ["src", "app", "tests", "playgrounds", "bin.mjs"] } diff --git a/examples/json-render/bin.mjs b/examples/json-render/bin.mjs index d35b5779b..eae5b5012 100755 --- a/examples/json-render/bin.mjs +++ b/examples/json-render/bin.mjs @@ -1,7 +1,7 @@ #!/usr/bin/env node import process from 'node:process' import { createCac } from 'devframe/adapters/cac' -import devframe from './src/devframe.ts' +import devframe from './src/node/index.ts' async function main() { const cli = createCac(devframe) diff --git a/examples/json-render/package.json b/examples/json-render/package.json index 1aa9b8366..88b170ea8 100644 --- a/examples/json-render/package.json +++ b/examples/json-render/package.json @@ -6,10 +6,10 @@ "description": "Standalone devframe that serves a JSON view spec with live state and an action bridge.", "homepage": "https://github.com/devframes/devframe/tree/main/examples/json-render", "exports": { - ".": "./src/devframe.ts", - "./dashboard": "./src/dashboard.ts" + ".": "./src/node/index.ts", + "./dashboard": "./src/node/dashboard.ts" }, - "main": "src/devframe.ts", + "main": "src/node/index.ts", "bin": { "json-render": "./bin.mjs" }, diff --git a/examples/json-render/src/dashboard.ts b/examples/json-render/src/node/dashboard.ts similarity index 100% rename from examples/json-render/src/dashboard.ts rename to examples/json-render/src/node/dashboard.ts diff --git a/examples/json-render/src/devframe.ts b/examples/json-render/src/node/index.ts similarity index 93% rename from examples/json-render/src/devframe.ts rename to examples/json-render/src/node/index.ts index 6813b554c..89395e505 100644 --- a/examples/json-render/src/devframe.ts +++ b/examples/json-render/src/node/index.ts @@ -1,5 +1,5 @@ import { createJsonRenderDevframe } from '@devframes/json-render-ui/spa' -import pkg from '../package.json' with { type: 'json' } +import pkg from '../../package.json' with { type: 'json' } import { createDashboardView } from './dashboard.ts' /** diff --git a/examples/json-render/src/shared.ts b/examples/json-render/src/node/shared.ts similarity index 100% rename from examples/json-render/src/shared.ts rename to examples/json-render/src/node/shared.ts diff --git a/examples/next-runtime-snapshot/src/client/app/components/connect.tsx b/examples/next-runtime-snapshot/app/app/components/connect.tsx similarity index 100% rename from examples/next-runtime-snapshot/src/client/app/components/connect.tsx rename to examples/next-runtime-snapshot/app/app/components/connect.tsx diff --git a/examples/next-runtime-snapshot/src/client/app/components/snapshot-env.tsx b/examples/next-runtime-snapshot/app/app/components/snapshot-env.tsx similarity index 97% rename from examples/next-runtime-snapshot/src/client/app/components/snapshot-env.tsx rename to examples/next-runtime-snapshot/app/app/components/snapshot-env.tsx index d0e5ddeb7..8d8f09393 100644 --- a/examples/next-runtime-snapshot/src/client/app/components/snapshot-env.tsx +++ b/examples/next-runtime-snapshot/app/app/components/snapshot-env.tsx @@ -1,6 +1,6 @@ 'use client' -import type { EnvSnapshot } from '../../../devframe' +import type { EnvSnapshot } from '../../../src/node/index' import { useCallback, useEffect, useState } from 'react' import { card, input as inputClass } from '../design' import { useRpc } from './connect' diff --git a/examples/next-runtime-snapshot/src/client/app/components/snapshot-memory.tsx b/examples/next-runtime-snapshot/app/app/components/snapshot-memory.tsx similarity index 97% rename from examples/next-runtime-snapshot/src/client/app/components/snapshot-memory.tsx rename to examples/next-runtime-snapshot/app/app/components/snapshot-memory.tsx index 6b7ab9efc..635c80d34 100644 --- a/examples/next-runtime-snapshot/src/client/app/components/snapshot-memory.tsx +++ b/examples/next-runtime-snapshot/app/app/components/snapshot-memory.tsx @@ -1,6 +1,6 @@ 'use client' -import type { MemorySnapshot } from '../../../devframe' +import type { MemorySnapshot } from '../../../src/node/index' import { useCallback, useEffect, useState } from 'react' import { button, card } from '../design' import { useRpc } from './connect' diff --git a/examples/next-runtime-snapshot/src/client/app/components/snapshot-system.tsx b/examples/next-runtime-snapshot/app/app/components/snapshot-system.tsx similarity index 96% rename from examples/next-runtime-snapshot/src/client/app/components/snapshot-system.tsx rename to examples/next-runtime-snapshot/app/app/components/snapshot-system.tsx index 26d3a347f..8ebb0b8cb 100644 --- a/examples/next-runtime-snapshot/src/client/app/components/snapshot-system.tsx +++ b/examples/next-runtime-snapshot/app/app/components/snapshot-system.tsx @@ -1,6 +1,6 @@ 'use client' -import type { SystemInfo } from '../../../devframe' +import type { SystemInfo } from '../../../src/node/index' import { useEffect, useState } from 'react' import { card } from '../design' import { useRpc } from './connect' diff --git a/examples/files-inspector/src/client/design.ts b/examples/next-runtime-snapshot/app/app/design.ts similarity index 100% rename from examples/files-inspector/src/client/design.ts rename to examples/next-runtime-snapshot/app/app/design.ts diff --git a/examples/next-runtime-snapshot/src/client/app/globals.css b/examples/next-runtime-snapshot/app/app/globals.css similarity index 100% rename from examples/next-runtime-snapshot/src/client/app/globals.css rename to examples/next-runtime-snapshot/app/app/globals.css diff --git a/examples/next-runtime-snapshot/src/client/app/layout.tsx b/examples/next-runtime-snapshot/app/app/layout.tsx similarity index 100% rename from examples/next-runtime-snapshot/src/client/app/layout.tsx rename to examples/next-runtime-snapshot/app/app/layout.tsx diff --git a/examples/next-runtime-snapshot/src/client/app/page.tsx b/examples/next-runtime-snapshot/app/app/page.tsx similarity index 100% rename from examples/next-runtime-snapshot/src/client/app/page.tsx rename to examples/next-runtime-snapshot/app/app/page.tsx diff --git a/examples/next-runtime-snapshot/app/next.config.mjs b/examples/next-runtime-snapshot/app/next.config.mjs new file mode 100644 index 000000000..b2305bb15 --- /dev/null +++ b/examples/next-runtime-snapshot/app/next.config.mjs @@ -0,0 +1,27 @@ +import { PHASE_DEVELOPMENT_SERVER } from 'next/constants.js' + +/** + * The static-export options (`output: 'export'`, a relative `assetPrefix`, and + * `trailingSlash`) exist so `next build` emits a self-contained SPA that mounts + * at any base. They are wrong for `next dev`: a relative `assetPrefix` breaks + * the dev client runtime's chunk base, so the page never hydrates (no + * interactivity, and the RPC client never connects). Apply them only for the + * production build so `pnpm dev:client` hydrates normally. + * + * @type {(phase: string) => import('next').NextConfig} + */ +export default (phase) => { + const isDev = phase === PHASE_DEVELOPMENT_SERVER + return { + ...(isDev ? {} : { output: 'export', assetPrefix: '.', trailingSlash: true }), + images: { unoptimized: true }, + /** + * The workspace tsconfig uses path aliases that point at devframe's + * source so source-level edits HMR cleanly. Next.js's incremental TS + * check can't follow workspace project references through those aliases + * and ends up type-checking unrelated source. Defer typechecking to the + * workspace's own `tsc -b` (`pnpm typecheck`), which honors references. + */ + typescript: { ignoreBuildErrors: true }, + } +} diff --git a/examples/next-runtime-snapshot/src/client/postcss.config.mjs b/examples/next-runtime-snapshot/app/postcss.config.mjs similarity index 100% rename from examples/next-runtime-snapshot/src/client/postcss.config.mjs rename to examples/next-runtime-snapshot/app/postcss.config.mjs diff --git a/examples/next-runtime-snapshot/app/tsconfig.json b/examples/next-runtime-snapshot/app/tsconfig.json new file mode 100644 index 000000000..df7f7d07c --- /dev/null +++ b/examples/next-runtime-snapshot/app/tsconfig.json @@ -0,0 +1,25 @@ +{ + "extends": "../../../tsconfig.base.json", + "compilerOptions": { + "incremental": true, + "jsx": "preserve", + "lib": ["ESNext", "DOM"], + "module": "ESNext", + "moduleResolution": "Bundler", + "allowJs": true, + "noEmit": true, + "esModuleInterop": true, + "isolatedDeclarations": false, + "plugins": [{ "name": "next" }] + }, + "include": [ + "next-env.d.ts", + "**/*.ts", + "**/*.tsx" + ], + "exclude": [ + "node_modules", + ".next", + "out" + ] +} diff --git a/examples/next-runtime-snapshot/bin.mjs b/examples/next-runtime-snapshot/bin.mjs index d35b5779b..eae5b5012 100755 --- a/examples/next-runtime-snapshot/bin.mjs +++ b/examples/next-runtime-snapshot/bin.mjs @@ -1,7 +1,7 @@ #!/usr/bin/env node import process from 'node:process' import { createCac } from 'devframe/adapters/cac' -import devframe from './src/devframe.ts' +import devframe from './src/node/index.ts' async function main() { const cli = createCac(devframe) diff --git a/examples/next-runtime-snapshot/package.json b/examples/next-runtime-snapshot/package.json index eebbf8e4d..a02a05baf 100644 --- a/examples/next-runtime-snapshot/package.json +++ b/examples/next-runtime-snapshot/package.json @@ -5,15 +5,16 @@ "private": true, "description": "Devframe demo exposing the host Node runtime snapshot through a Next.js App Router SPA.", "homepage": "https://github.com/devframes/devframe/tree/main/examples/next-runtime-snapshot", - "main": "src/devframe.ts", + "main": "src/node/index.ts", "bin": { "next-runtime-snapshot": "./bin.mjs" }, "scripts": { - "build": "next build src/client && node scripts/build-spa.mjs", + "build": "next build app && node scripts/build-spa.mjs", "cli:build": "node bin.mjs build --out-dir dist/static", "dev": "node bin.mjs", - "next:dev": "next dev src/client", + "dev:client": "next dev app", + "play": "pnpm run build && node playgrounds/server.mjs", "test": "vitest run", "typecheck": "tsc --noEmit" }, diff --git a/examples/next-runtime-snapshot/playgrounds/server.mjs b/examples/next-runtime-snapshot/playgrounds/server.mjs new file mode 100644 index 000000000..b5d79b780 --- /dev/null +++ b/examples/next-runtime-snapshot/playgrounds/server.mjs @@ -0,0 +1,57 @@ +#!/usr/bin/env node +/** + * Standalone demo host for the Next Runtime Snapshot panel. + * + * `initDevframe` runs setup, serves the built Next.js panel SPA + + * `__connection.json` from `dist/client` under the devframe base path, and + * binds the WebSocket RPC upgrade onto our http server. + * + * node playgrounds/server.mjs → live WebSocket RPC (`dist/client`) + */ +import { existsSync } from 'node:fs' +import { createServer } from 'node:http' +import { dirname, resolve } from 'node:path' +import process from 'node:process' +import { fileURLToPath } from 'node:url' +import { initDevframe } from 'devframe/initiate' +import { getPort } from 'get-port-please' +import { H3, toNodeHandler } from 'h3' +import devframe from '../src/node/index.ts' + +const HERE = dirname(fileURLToPath(import.meta.url)) +const ROOT = resolve(HERE, '..') +const panelDir = resolve(ROOT, 'dist/client') + +const basePath = devframe.basePath + +async function main() { + if (!existsSync(resolve(panelDir, 'index.html'))) { + console.error('\n[next-runtime-snapshot playground] missing dist/client/index.html\n → run `pnpm -C examples/next-runtime-snapshot build` first.\n') + process.exit(1) + } + + const bindHost = '0.0.0.0' + const port = await getPort({ host: bindHost, port: 9899 }) + const origin = `http://localhost:${port}` + + const app = new H3() + const server = createServer(toNodeHandler(app)) + const instance = initDevframe(devframe, { + base: basePath, + distDir: panelDir, + app, + server, + host: bindHost, + origin, + auth: false, + }) + await new Promise(r => server.listen(port, bindHost, r)) + await instance.ready + + process.stdout.write(`\n Next Runtime Snapshot: dev (live WebSocket RPC)\n ▸ panel: ${origin}${basePath}\n\n`) +} + +main().catch((error) => { + console.error(error) + process.exit(1) +}) diff --git a/examples/next-runtime-snapshot/scripts/build-spa.mjs b/examples/next-runtime-snapshot/scripts/build-spa.mjs index 4b3d477e1..90d29fd38 100644 --- a/examples/next-runtime-snapshot/scripts/build-spa.mjs +++ b/examples/next-runtime-snapshot/scripts/build-spa.mjs @@ -1,25 +1,23 @@ -import { chmodSync, cpSync, mkdirSync, readdirSync, rmSync } from 'node:fs' +import { copyFileSync, mkdirSync, readdirSync, rmSync } from 'node:fs' import { join } from 'node:path' -rmSync('dist/client', { recursive: true, force: true }) -mkdirSync('dist', { recursive: true }) -cpSync('src/client/out', 'dist/client', { recursive: true }) +// The Next.js static export is this example's iframe SPA; it is served from +// `dist/client` by the standalone CLI, the playground host, and the tests. -// A built SPA must be readable to be served. Some filesystems (e.g. Docker -// Desktop's shared mounts) drop the read bits when copying, which yields -// unservable assets and trips downstream `createBuild` copies. Normalize the -// tree so directories are traversable and files are world-readable. -function normalizePermissions(dir) { - for (const entry of readdirSync(dir, { withFileTypes: true })) { - const path = join(dir, entry.name) - if (entry.isDirectory()) { - chmodSync(path, 0o755) - normalizePermissions(path) - } - else if (entry.isFile()) { - chmodSync(path, 0o644) - } +// A manual walk with explicit modes: Next's export emits entries with +// restrictive modes that node's `cpSync` inherits and then trips over, so +// recreate the tree with normal directory/file permissions instead. +function copyTree(src, dest) { + mkdirSync(dest, { recursive: true, mode: 0o755 }) + for (const entry of readdirSync(src, { withFileTypes: true })) { + const from = join(src, entry.name) + const to = join(dest, entry.name) + if (entry.isDirectory()) + copyTree(from, to) + else if (entry.isFile()) + copyFileSync(from, to) } } -normalizePermissions('dist/client') +rmSync('dist/client', { recursive: true, force: true }) +copyTree('app/out', 'dist/client') diff --git a/examples/next-runtime-snapshot/src/client/next.config.mjs b/examples/next-runtime-snapshot/src/client/next.config.mjs deleted file mode 100644 index 75d1fe71b..000000000 --- a/examples/next-runtime-snapshot/src/client/next.config.mjs +++ /dev/null @@ -1,17 +0,0 @@ -/** @type {import('next').NextConfig} */ -const nextConfig = { - output: 'export', - assetPrefix: '.', - trailingSlash: true, - images: { unoptimized: true }, - /** - * The workspace tsconfig uses path aliases that point at devframe's - * source so source-level edits HMR cleanly. Next.js's incremental TS - * check can't follow workspace project references through those aliases - * and ends up type-checking unrelated source. Defer typechecking to the - * workspace's own `tsc -b` (`pnpm typecheck`), which honors references. - */ - typescript: { ignoreBuildErrors: true }, -} - -export default nextConfig diff --git a/examples/next-runtime-snapshot/src/client/tsconfig.json b/examples/next-runtime-snapshot/src/client/tsconfig.json deleted file mode 100644 index 62aa7e681..000000000 --- a/examples/next-runtime-snapshot/src/client/tsconfig.json +++ /dev/null @@ -1,13 +0,0 @@ -{ - "extends": "../../tsconfig.json", - "include": [ - "next-env.d.ts", - "**/*.ts", - "**/*.tsx" - ], - "exclude": [ - "node_modules", - ".next", - "out" - ] -} diff --git a/examples/next-runtime-snapshot/src/devframe.ts b/examples/next-runtime-snapshot/src/node/index.ts similarity index 87% rename from examples/next-runtime-snapshot/src/devframe.ts rename to examples/next-runtime-snapshot/src/node/index.ts index e9160a93a..62bc486f1 100644 --- a/examples/next-runtime-snapshot/src/devframe.ts +++ b/examples/next-runtime-snapshot/src/node/index.ts @@ -1,6 +1,6 @@ import { fileURLToPath } from 'node:url' import { defineDevframe } from 'devframe' -import pkg from '../package.json' with { type: 'json' } +import pkg from '../../package.json' with { type: 'json' } import { NAMESPACE, serverFunctions } from './rpc/index.ts' export type { EnvEntry, EnvSnapshot } from './rpc/functions/env.ts' @@ -8,7 +8,7 @@ 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 distDir = fileURLToPath(new URL('../../dist/client', import.meta.url)) export default defineDevframe({ id: 'example:next-runtime-snapshot', diff --git a/examples/next-runtime-snapshot/src/rpc/functions/env.ts b/examples/next-runtime-snapshot/src/node/rpc/functions/env.ts similarity index 100% rename from examples/next-runtime-snapshot/src/rpc/functions/env.ts rename to examples/next-runtime-snapshot/src/node/rpc/functions/env.ts diff --git a/examples/next-runtime-snapshot/src/rpc/functions/memory.ts b/examples/next-runtime-snapshot/src/node/rpc/functions/memory.ts similarity index 100% rename from examples/next-runtime-snapshot/src/rpc/functions/memory.ts rename to examples/next-runtime-snapshot/src/node/rpc/functions/memory.ts diff --git a/examples/next-runtime-snapshot/src/rpc/functions/system.ts b/examples/next-runtime-snapshot/src/node/rpc/functions/system.ts similarity index 100% rename from examples/next-runtime-snapshot/src/rpc/functions/system.ts rename to examples/next-runtime-snapshot/src/node/rpc/functions/system.ts diff --git a/examples/next-runtime-snapshot/src/rpc/index.ts b/examples/next-runtime-snapshot/src/node/rpc/index.ts similarity index 100% rename from examples/next-runtime-snapshot/src/rpc/index.ts rename to examples/next-runtime-snapshot/src/node/rpc/index.ts diff --git a/examples/next-runtime-snapshot/tests/_utils.ts b/examples/next-runtime-snapshot/tests/_utils.ts index 83ecb24f2..b80d0c7db 100644 --- a/examples/next-runtime-snapshot/tests/_utils.ts +++ b/examples/next-runtime-snapshot/tests/_utils.ts @@ -8,7 +8,7 @@ import { getPort } from 'get-port-please' import { H3 } from 'h3' import { resolve } from 'pathe' import { serveTestContext } from '../../../tests/helpers/serve-test-context' -import devframe from '../src/devframe' +import devframe from '../src/node/index' export interface SnapshotServer extends StartedServer { basePath: string diff --git a/examples/next-runtime-snapshot/tests/next-runtime-snapshot.test.ts b/examples/next-runtime-snapshot/tests/next-runtime-snapshot.test.ts index fa1d60be9..e2ff3019a 100644 --- a/examples/next-runtime-snapshot/tests/next-runtime-snapshot.test.ts +++ b/examples/next-runtime-snapshot/tests/next-runtime-snapshot.test.ts @@ -1,4 +1,4 @@ -import type { EnvSnapshot, MemorySnapshot, SystemInfo } from '../src/devframe' +import type { EnvSnapshot, MemorySnapshot, SystemInfo } from '../src/node/index' import process from 'node:process' import { createRpcClient } from 'devframe/rpc/client' import { createWsRpcChannel } from 'devframe/rpc/transports/ws-client' diff --git a/examples/next-runtime-snapshot/tsconfig.json b/examples/next-runtime-snapshot/tsconfig.json index 91baff874..96b30a86a 100644 --- a/examples/next-runtime-snapshot/tsconfig.json +++ b/examples/next-runtime-snapshot/tsconfig.json @@ -1,24 +1,23 @@ { "extends": "../../tsconfig.base.json", "compilerOptions": { - "incremental": true, - "jsx": "preserve", - "lib": ["ESNext", "DOM"], - "module": "ESNext", - "moduleResolution": "Bundler", + "lib": ["esnext", "dom"], "allowJs": true, "noEmit": true, - "esModuleInterop": true, - "isolatedDeclarations": false, - "plugins": [{ "name": "next" }] + "esModuleInterop": true }, "include": [ "src", - "bin.mjs" + "tests", + "playgrounds", + "bin.mjs", + "uno.config.ts" ], "exclude": [ "dist", + "app", ".next", - "out" + "out", + "node_modules" ] } diff --git a/examples/next-runtime-snapshot/uno.config.ts b/examples/next-runtime-snapshot/uno.config.ts index 4f6e6bf8f..ad8efc708 100644 --- a/examples/next-runtime-snapshot/uno.config.ts +++ b/examples/next-runtime-snapshot/uno.config.ts @@ -3,17 +3,17 @@ import { mergeConfigs } from 'unocss' import { designConfig } from '../../design/uno.config' // This example composes the shared devframe base (see `design/uno.config.ts`). -// `@unocss/postcss` (see src/client/postcss.config.mjs) loads this config; the +// `@unocss/postcss` (see app/postcss.config.mjs) loads this config; the // absolute glob keeps class extraction working regardless of the directory Next // builds from. The co-located `app/design.ts` (carrying `@unocss-include`) is // covered by the same glob. -const client = fileURLToPath(new URL('./src/client', import.meta.url)) +const app = fileURLToPath(new URL('./app', import.meta.url)) export default mergeConfigs([ designConfig, { content: { - filesystem: [`${client}/app/**/*.{ts,tsx}`], + filesystem: [`${app}/app/**/*.{ts,tsx}`], }, }, ]) diff --git a/examples/sse-basic/README.md b/examples/sse-basic/README.md index e97951ccd..81ea44ada 100644 --- a/examples/sse-basic/README.md +++ b/examples/sse-basic/README.md @@ -3,7 +3,8 @@ The smallest SSE-only devframe: `ws: false` binds no WebSocket, so every RPC frame - calls, shared state, the auth handshake - rides plain HTTP at `/__sse-basic/__sse`. This is the transport for host frameworks and proxies where the WebSocket upgrade isn't available. ```sh -pnpm --filter sse-basic dev +pnpm --filter sse-basic dev # Vite app + the SSE node bridge, with HMR +pnpm --filter sse-basic play # build the SPA, then serve it over SSE from a standalone host ``` Open the printed URL. The page shows: @@ -14,12 +15,8 @@ Open the printed URL. The page shows: ## How it works -`vite.config.ts` holds the whole thing: a `defineDevframe` definition (two RPC functions and a shared-state clock) served by `initDevframe(def, { base: '/__sse-basic/', ws: false, auth: false })`, mounted with one line - +`src/node/index.ts` is the whole tool: a `defineDevframe` definition with two RPC functions and a shared-state clock. `app/` is the vanilla SPA (`app/main.ts` calls `connectDevframe({ baseURL: '/__sse-basic/' })`), and `app/vite.config.ts` bridges the node side onto the dev server with `initDevframe(def, { base: '/__sse-basic/', ws: false, auth: false })` for HMR. `playgrounds/server.mjs` boots the built SPA over the same SSE host. -```ts -server.middlewares.use(instance.nodeMiddleware) -``` - -There is no upgrade wiring anywhere; ordinary request middleware serves discovery (`__connection.json`), the SSE stream (`GET __sse`), and RPC frames (`POST __sse`). `src/main.ts` is the vanilla client: `connectDevframe({ baseURL: '/__sse-basic/' })` and everything else works exactly as it would over a WebSocket. +There is no upgrade wiring anywhere; ordinary request middleware serves discovery (`__connection.json`), the SSE stream (`GET __sse`), and RPC frames (`POST __sse`). Everything works exactly as it would over a WebSocket. See the [Transports guide](https://devfra.me/guide/transports) for the full transport model, including running both transports side by side and pinning one from the client. diff --git a/examples/sse-basic/index.html b/examples/sse-basic/app/index.html similarity index 95% rename from examples/sse-basic/index.html rename to examples/sse-basic/app/index.html index af0b64148..7e236a109 100644 --- a/examples/sse-basic/index.html +++ b/examples/sse-basic/app/index.html @@ -3,6 +3,7 @@ + SSE Basic + diff --git a/examples/sse-basic/src/main.ts b/examples/sse-basic/app/main.ts similarity index 100% rename from examples/sse-basic/src/main.ts rename to examples/sse-basic/app/main.ts diff --git a/examples/sse-basic/app/vite.config.ts b/examples/sse-basic/app/vite.config.ts new file mode 100644 index 000000000..4ece9d15a --- /dev/null +++ b/examples/sse-basic/app/vite.config.ts @@ -0,0 +1,63 @@ +import type { Plugin } from 'vite' +import { fileURLToPath } from 'node:url' +import { initDevframe } from 'devframe/initiate' +import UnoCSS from 'unocss/vite' +import { defineConfig } from 'vite' +import { alias } from '../../../alias' +import devframe from '../src/node/index.ts' + +const BASE = '/__sse-basic/' + +/** + * Serve-only plugin that bridges the SSE-only node side (`__connection.json` + + * the `__sse` RPC stream, no WebSocket) onto Vite's dev server. Only the + * devframe-owned `__*` routes go to the instance; every other request + * (the SPA at the base, its HMR assets) falls through to Vite. `distDir: false` + * keeps the instance in bridge mode. Inert during `vite build`. + */ +function sseBridge(): Plugin { + return { + name: 'sse-basic-dev-bridge', + apply: 'serve', + configureServer(server) { + const instance = initDevframe(devframe, { + base: BASE, + ws: false, + distDir: false, + /** + * Single-user localhost demo; a server reachable beyond localhost + * should gate (see docs/content/1.guide/13.security.md). + */ + auth: false, + }) + server.middlewares.use((req, res, next) => { + const path = new URL(req.url ?? '/', 'http://localhost').pathname + if (path.startsWith(`${BASE}__`)) + instance.nodeMiddleware(req, res, next) + else next() + }) + server.httpServer?.once('close', () => { + void instance.close().catch(() => {}) + }) + }, + } +} + +/** + * `base: './'` for the build keeps the mount path portable: the same `app` + * output works whether devframe serves it at `/` (standalone) or + * `/__sse-basic/` (mounted in a host). In `vite dev` the SPA is served under + * the devframe base path so its `document.baseURI` matches production, and + * {@link sseBridge} bridges the node side there with HMR. + */ +export default defineConfig(({ command }) => ({ + base: command === 'serve' ? BASE : './', + root: fileURLToPath(new URL('.', import.meta.url)), + resolve: { alias }, + server: { allowedHosts: true, strictPort: false }, + plugins: [UnoCSS(), sseBridge()], + build: { + outDir: fileURLToPath(new URL('../dist/client', import.meta.url)), + emptyOutDir: true, + }, +})) diff --git a/examples/sse-basic/package.json b/examples/sse-basic/package.json index a031c8332..b48529f83 100644 --- a/examples/sse-basic/package.json +++ b/examples/sse-basic/package.json @@ -6,7 +6,9 @@ "description": "Minimal devframe that carries every RPC frame over plain HTTP with SSE.", "homepage": "https://github.com/devframes/devframe/tree/main/examples/sse-basic", "scripts": { - "dev": "vite", + "build": "vite build --config app/vite.config.ts", + "dev": "vite --config app/vite.config.ts --host", + "play": "pnpm run build && node playgrounds/server.mjs", "typecheck": "tsc --noEmit" }, "dependencies": { @@ -16,6 +18,7 @@ "devDependencies": { "@iconify-json/ph": "catalog:frontend", "@types/node": "catalog:types", + "h3": "catalog:deps", "unocss": "catalog:frontend", "vite": "catalog:build" } diff --git a/examples/sse-basic/playgrounds/server.mjs b/examples/sse-basic/playgrounds/server.mjs new file mode 100644 index 000000000..584e1db60 --- /dev/null +++ b/examples/sse-basic/playgrounds/server.mjs @@ -0,0 +1,69 @@ +#!/usr/bin/env node +/** + * Standalone demo host for the SSE-only tool. + * + * Serves the built SPA at the devframe base path and the SSE RPC on the same + * origin, so the panel reaches the node side over plain HTTP (`ws: false`): + * + * GET /__sse-basic/** → the vanilla SPA + `__connection.json` + `__sse` + * + * node playgrounds/server.mjs → SSE RPC (`dist/client`) + */ +import { existsSync } from 'node:fs' +import { createServer } from 'node:http' +import process from 'node:process' +import { fileURLToPath } from 'node:url' +import { initDevframe } from 'devframe/initiate' +import { getPort } from 'devframe/utils/get-port' +import { H3, toNodeHandler } from 'h3' +import { resolve } from 'pathe' +import devframe from '../src/node/index.ts' + +const HERE = fileURLToPath(new URL('.', import.meta.url)) +const ROOT = resolve(HERE, '..') + +const basePath = '/__sse-basic/' +const panelDir = resolve(ROOT, 'dist/client') + +function requireBuilt(file, hint) { + if (!existsSync(file)) { + console.error(`\n[sse-basic playground] missing ${file}\n → run \`${hint}\` first.\n`) + process.exit(1) + } +} + +function banner(origin) { + process.stdout.write( + `\n SSE Basic demo: SSE-only RPC (ws: false)\n` + + ` ▸ panel: ${origin}${basePath}\n\n` + + ' The server clock ticks over the SSE event stream; no WebSocket anywhere.\n\n', + ) +} + +async function main() { + requireBuilt(resolve(panelDir, 'index.html'), 'pnpm -C examples/sse-basic build') + + const bindHost = '0.0.0.0' + const port = await getPort({ host: bindHost, port: 9898 }) + const origin = `http://localhost:${port}` + + const app = new H3() + const server = createServer(toNodeHandler(app)) + const instance = initDevframe(devframe, { + base: basePath, + ws: false, + distDir: panelDir, + app, + host: bindHost, + origin, + auth: false, + }) + await new Promise(r => server.listen(port, bindHost, r)) + await instance.ready + banner(origin) +} + +main().catch((error) => { + console.error(error) + process.exit(1) +}) diff --git a/examples/sse-basic/src/node/index.ts b/examples/sse-basic/src/node/index.ts new file mode 100644 index 000000000..9b39a6b3a --- /dev/null +++ b/examples/sse-basic/src/node/index.ts @@ -0,0 +1,46 @@ +import { fileURLToPath } from 'node:url' +import { defineDevframe } from 'devframe' + +/** + * The whole devframe: two RPC functions and a shared-state clock. `ws: false` + * (set where this is mounted) makes it SSE-only - the instance binds no + * WebSocket, advertises `backend: 'sse'`, and every RPC frame rides plain HTTP + * at `__sse`. + */ +export default defineDevframe({ + id: 'sse-basic', + name: 'SSE Basic', + version: '0.0.0', + packageName: 'sse-basic', + homepage: 'https://github.com/devframes/devframe/tree/main/examples/sse-basic', + description: 'Minimal SSE-only devframe.', + /** The built SPA the playground host serves alongside the SSE RPC. */ + clientAssets: fileURLToPath(new URL('../../dist/client', import.meta.url)), + async setup(ctx) { + const startedAt = Date.now() + ctx.rpc.register({ + name: 'sse-basic:uptime', + type: 'query', + jsonSerializable: true, + handler: () => Math.round((Date.now() - startedAt) / 1000), + }) + + let count = 0 + ctx.rpc.register({ + name: 'sse-basic:increment', + type: 'action', + jsonSerializable: true, + handler: () => ++count, + }) + + // A server-driven clock: each tick streams to every client over the SSE + // event stream - the server→client half of the transport. + const clock = await ctx.rpc.sharedState.get<{ now: string }>('sse-basic:clock', { + initialValue: { now: new Date().toLocaleTimeString() }, + }) + const timer = setInterval(() => { + clock.mutate(() => ({ now: new Date().toLocaleTimeString() })) + }, 1000) + timer.unref?.() + }, +}) diff --git a/examples/sse-basic/tsconfig.json b/examples/sse-basic/tsconfig.json index 8167986ee..268e7d34c 100644 --- a/examples/sse-basic/tsconfig.json +++ b/examples/sse-basic/tsconfig.json @@ -13,7 +13,8 @@ }, "include": [ "src", - "vite.config.ts", + "app", + "playgrounds", "uno.config.ts" ], "exclude": [ diff --git a/examples/sse-basic/vite.config.ts b/examples/sse-basic/vite.config.ts deleted file mode 100644 index 2a3ca9faa..000000000 --- a/examples/sse-basic/vite.config.ts +++ /dev/null @@ -1,73 +0,0 @@ -import { defineDevframe } from 'devframe' -import { initDevframe } from 'devframe/initiate' -import UnoCSS from 'unocss/vite' -import { defineConfig } from 'vite' -import { alias } from '../../alias' - -// The whole devframe: two RPC functions and a shared-state clock. `ws: false` -// makes it SSE-only - the instance binds no WebSocket, advertises -// `backend: 'sse'`, and every RPC frame rides plain HTTP at `__sse`. -const devframe = defineDevframe({ - id: 'sse-basic', - name: 'SSE Basic', - version: '0.0.0', - packageName: 'sse-basic', - homepage: 'https://github.com/devframes/devframe/tree/main/examples/sse-basic', - description: 'Minimal SSE-only devframe.', - async setup(ctx) { - const startedAt = Date.now() - ctx.rpc.register({ - name: 'sse-basic:uptime', - type: 'query', - jsonSerializable: true, - handler: () => Math.round((Date.now() - startedAt) / 1000), - }) - - let count = 0 - ctx.rpc.register({ - name: 'sse-basic:increment', - type: 'action', - jsonSerializable: true, - handler: () => ++count, - }) - - // A server-driven clock: each tick streams to every client over the SSE - // event stream - the server→client half of the transport. - const clock = await ctx.rpc.sharedState.get<{ now: string }>('sse-basic:clock', { - initialValue: { now: new Date().toLocaleTimeString() }, - }) - const timer = setInterval(() => { - clock.mutate(() => ({ now: new Date().toLocaleTimeString() })) - }, 1000) - timer.unref?.() - }, -}) - -export default defineConfig({ - resolve: { alias }, - server: { allowedHosts: true, strictPort: false }, - plugins: [ - UnoCSS(), - { - name: 'sse-basic:devframe', - apply: 'serve', - configureServer(server) { - // No upgrade wiring anywhere: the instance mounts as ordinary HTTP - // middleware and that alone serves discovery, auth, and all RPC. - const instance = initDevframe(devframe, { - base: '/__sse-basic/', - ws: false, - /** - * Single-user localhost demo; a server reachable beyond localhost - * should gate (see docs/content/1.guide/13.security.md). - */ - auth: false, - }) - server.middlewares.use(instance.nodeMiddleware) - server.httpServer?.once('close', () => { - void instance.close().catch(() => {}) - }) - }, - }, - ], -}) diff --git a/examples/streaming-chat/README.md b/examples/streaming-chat/README.md index 3f54a82b1..0227e2529 100644 --- a/examples/streaming-chat/README.md +++ b/examples/streaming-chat/README.md @@ -28,14 +28,22 @@ client (re)joins mid-stream. ## Run it +Dev mode runs the Vite app with HMR and a live node bridge: + ```sh -pnpm -C examples/streaming-chat run build pnpm -C examples/streaming-chat run dev ``` -Then open http://localhost:9897/ - type a prompt, watch tokens stream -in, refresh the page mid-conversation, cancel a long answer, click -**Clear** to wipe the log. +Play mode builds the app, then boots the playground host that serves it +with live WebSocket RPC: + +```sh +pnpm -C examples/streaming-chat run play +``` + +Both serve the panel at `/__devframe-streaming-chat/` - type a prompt, +watch tokens stream in, refresh mid-conversation, cancel a long answer, +click **Clear** to wipe the log. ## Run the tests @@ -49,7 +57,7 @@ with partial content, clear, and replay-after-finish. ## Wire it to a real LLM -Replace `fakeTokens(prompt)` in `src/devframe.ts` with anything that +Replace `fakeTokens(prompt)` in `src/node/rpc/functions/send.ts` with anything that yields strings - the rest of the example doesn't care. For OpenAI: ```ts diff --git a/examples/streaming-chat/src/client/app.tsx b/examples/streaming-chat/app/app.tsx similarity index 98% rename from examples/streaming-chat/src/client/app.tsx rename to examples/streaming-chat/app/app.tsx index d9a94e360..36c0745d2 100644 --- a/examples/streaming-chat/src/client/app.tsx +++ b/examples/streaming-chat/app/app.tsx @@ -1,9 +1,9 @@ import type { DevframeScopedClientContext } from 'devframe/client' import type { StreamReader } from 'devframe/utils/streaming-channel' -import type { ChatHistory, ChatMessage } from '../types' +import type { ChatHistory, ChatMessage } from '../src/node/types' import { connectDevframe } from 'devframe/client' import { useCallback, useEffect, useMemo, useRef, useState } from 'preact/hooks' -import { CHANNEL, HISTORY, NAMESPACE } from '../constants' +import { CHANNEL, HISTORY, NAMESPACE } from '../src/node/constants' import { button, cx, input, nav, navBrand, spinner } from './design' type ChatCtx = DevframeScopedClientContext diff --git a/examples/next-runtime-snapshot/src/client/app/design.ts b/examples/streaming-chat/app/design.ts similarity index 75% rename from examples/next-runtime-snapshot/src/client/app/design.ts rename to examples/streaming-chat/app/design.ts index af3b99e68..0bf9debcd 100644 --- a/examples/next-runtime-snapshot/src/client/app/design.ts +++ b/examples/streaming-chat/app/design.ts @@ -1,3 +1,3 @@ // Re-exports the shared devframe class-helper builders (see // `design/design.ts`) so this surface stays in lockstep with every other. -export * from '../../../../../design/design' +export * from '../../../design/design' diff --git a/examples/streaming-chat/src/client/index.html b/examples/streaming-chat/app/index.html similarity index 100% rename from examples/streaming-chat/src/client/index.html rename to examples/streaming-chat/app/index.html diff --git a/examples/streaming-chat/src/client/main.tsx b/examples/streaming-chat/app/main.tsx similarity index 100% rename from examples/streaming-chat/src/client/main.tsx rename to examples/streaming-chat/app/main.tsx diff --git a/examples/streaming-chat/src/client/shims.d.ts b/examples/streaming-chat/app/shims.d.ts similarity index 100% rename from examples/streaming-chat/src/client/shims.d.ts rename to examples/streaming-chat/app/shims.d.ts diff --git a/examples/streaming-chat/app/vite.config.ts b/examples/streaming-chat/app/vite.config.ts new file mode 100644 index 000000000..31d531029 --- /dev/null +++ b/examples/streaming-chat/app/vite.config.ts @@ -0,0 +1,53 @@ +import type { Server } from 'node:http' +import type { Plugin } from 'vite' +import { fileURLToPath } from 'node:url' +import preact from '@preact/preset-vite' +import { initDevframe } from 'devframe/initiate' +import { resolveBasePath } from 'devframe/internal' +import UnoCSS from 'unocss/vite' +import { defineConfig } from 'vite' +import { alias } from '../../../alias' +import devframe from '../src/node/index.ts' + +/** + * Serve-only plugin that bridges the streaming-chat node side (RPC + + * WebSocket + `__connection.json`) onto Vite's dev server under the devframe + * base path, mounted as a post middleware so Vite serves the HMR SPA first and + * the devframe host only answers the routes it owns. Inert during `vite build`. + */ +function streamingChatDevBridge(): Plugin { + return { + name: 'streaming-chat-dev-bridge', + apply: 'serve', + configureServer(server) { + const instance = initDevframe(devframe, { + base: resolveBasePath(devframe, 'hosted'), + distDir: false, + server: server.httpServer as Server, + auth: false, + }) + return () => server.middlewares.use(instance.nodeMiddleware) + }, + } +} + +/** + * `base: './'` for the build keeps the mount path portable: the same `app` + * output works whether devframe serves it at `/` (standalone) or the devframe + * base path (mounted in a hub). `connectDevframe` resolves its connection meta + * relative to `document.baseURI` to match. + * + * In `vite dev` the panel is served under the devframe base path so its + * `document.baseURI` matches production, and {@link streamingChatDevBridge} + * bridges the node side there with HMR. + */ +export default defineConfig(({ command }) => ({ + base: command === 'serve' ? resolveBasePath(devframe, 'hosted') : './', + root: fileURLToPath(new URL('.', import.meta.url)), + resolve: { alias }, + plugins: [UnoCSS(), preact(), streamingChatDevBridge()], + build: { + outDir: fileURLToPath(new URL('../dist/client', import.meta.url)), + emptyOutDir: true, + }, +})) diff --git a/examples/streaming-chat/bin.mjs b/examples/streaming-chat/bin.mjs index d35b5779b..eae5b5012 100755 --- a/examples/streaming-chat/bin.mjs +++ b/examples/streaming-chat/bin.mjs @@ -1,7 +1,7 @@ #!/usr/bin/env node import process from 'node:process' import { createCac } from 'devframe/adapters/cac' -import devframe from './src/devframe.ts' +import devframe from './src/node/index.ts' async function main() { const cli = createCac(devframe) diff --git a/examples/streaming-chat/package.json b/examples/streaming-chat/package.json index cf22eb5b8..e79c2dee7 100644 --- a/examples/streaming-chat/package.json +++ b/examples/streaming-chat/package.json @@ -5,14 +5,16 @@ "private": true, "description": "Devframe demo that streams synthetic chat tokens from server to client over streaming RPC.", "homepage": "https://github.com/devframes/devframe/tree/main/examples/streaming-chat", - "main": "src/devframe.ts", + "main": "src/node/index.ts", "bin": { "streaming-chat": "./bin.mjs" }, "scripts": { - "build": "vite build --config src/client/vite.config.ts", + "build": "vite build --config app/vite.config.ts", + "build:app": "vite build --config app/vite.config.ts", "cli:build": "node bin.mjs build --out-dir dist/static", - "dev": "node bin.mjs", + "dev": "vite --config app/vite.config.ts --host", + "play": "pnpm run build && node playgrounds/server.mjs", "test": "vitest run", "typecheck": "tsc --noEmit" }, diff --git a/examples/streaming-chat/playgrounds/server.mjs b/examples/streaming-chat/playgrounds/server.mjs new file mode 100644 index 000000000..6c74d946f --- /dev/null +++ b/examples/streaming-chat/playgrounds/server.mjs @@ -0,0 +1,70 @@ +#!/usr/bin/env node +/** + * Standalone demo host for the streaming-chat tool. + * + * Serves the built panel SPA at the devframe base path and binds the live + * WebSocket RPC onto the same server, so the panel streams tokens from the + * node side over a real connection: + * + * GET /__devframe-streaming-chat/** → the Preact panel SPA + live RPC + * + * node playgrounds/server.mjs → live WebSocket RPC (`dist/client`) + */ +import { existsSync } from 'node:fs' +import { createServer } from 'node:http' +import process from 'node:process' +import { fileURLToPath } from 'node:url' +import { initDevframe } from 'devframe/initiate' +import { getPort } from 'devframe/utils/get-port' +import { H3, toNodeHandler } from 'h3' +import { resolve } from 'pathe' +import devframe from '../src/node/index.ts' + +const HERE = fileURLToPath(new URL('.', import.meta.url)) +const ROOT = resolve(HERE, '..') + +const basePath = devframe.basePath +const panelDir = resolve(ROOT, 'dist/client') + +function requireBuilt(file, hint) { + if (!existsSync(file)) { + console.error(`\n[streaming-chat playground] missing ${file}\n → run \`${hint}\` first.\n`) + process.exit(1) + } +} + +function banner(origin) { + process.stdout.write( + `\n Streaming Chat demo: dev (live WebSocket RPC)\n` + + ` ▸ panel: ${origin}${basePath}\n\n` + + ' Send a prompt to fake-stream a response one token at a time.\n\n', + ) +} + +async function main() { + requireBuilt(resolve(panelDir, 'index.html'), 'pnpm -C examples/streaming-chat build') + + const bindHost = '0.0.0.0' + const port = await getPort({ host: bindHost, port: 9897 }) + const origin = `http://localhost:${port}` + + const app = new H3() + const server = createServer(toNodeHandler(app)) + const instance = initDevframe(devframe, { + base: basePath, + distDir: panelDir, + app, + server, + host: bindHost, + origin, + auth: false, + }) + await new Promise(r => server.listen(port, bindHost, r)) + await instance.ready + banner(origin) +} + +main().catch((error) => { + console.error(error) + process.exit(1) +}) diff --git a/examples/streaming-chat/src/client/vite.config.ts b/examples/streaming-chat/src/client/vite.config.ts deleted file mode 100644 index d542a1bab..000000000 --- a/examples/streaming-chat/src/client/vite.config.ts +++ /dev/null @@ -1,16 +0,0 @@ -import { fileURLToPath } from 'node:url' -import preact from '@preact/preset-vite' -import UnoCSS from 'unocss/vite' -import { defineConfig } from 'vite' -import { alias } from '../../../../alias' - -export default defineConfig({ - base: './', - root: fileURLToPath(new URL('.', import.meta.url)), - resolve: { alias }, - plugins: [UnoCSS(), preact()], - build: { - outDir: fileURLToPath(new URL('../../dist/client', import.meta.url)), - emptyOutDir: true, - }, -}) diff --git a/examples/streaming-chat/src/constants.ts b/examples/streaming-chat/src/node/constants.ts similarity index 100% rename from examples/streaming-chat/src/constants.ts rename to examples/streaming-chat/src/node/constants.ts diff --git a/examples/streaming-chat/src/context.ts b/examples/streaming-chat/src/node/context.ts similarity index 100% rename from examples/streaming-chat/src/context.ts rename to examples/streaming-chat/src/node/context.ts diff --git a/examples/streaming-chat/src/devframe.ts b/examples/streaming-chat/src/node/index.ts similarity index 92% rename from examples/streaming-chat/src/devframe.ts rename to examples/streaming-chat/src/node/index.ts index 2895146f1..4c7909060 100644 --- a/examples/streaming-chat/src/devframe.ts +++ b/examples/streaming-chat/src/node/index.ts @@ -1,6 +1,6 @@ import { fileURLToPath } from 'node:url' import { defineDevframe } from 'devframe' -import pkg from '../package.json' with { type: 'json' } +import pkg from '../../package.json' with { type: 'json' } import { CHANNEL, HISTORY, MAX_HISTORY, NAMESPACE } from './constants.ts' import { setStreamingChatContext } from './context.ts' import { serverFunctions } from './rpc/index.ts' @@ -8,7 +8,7 @@ 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 distDir = fileURLToPath(new URL('../../dist/client', import.meta.url)) export default defineDevframe({ id: 'example:streaming-chat', diff --git a/examples/streaming-chat/src/rpc/functions/clear.ts b/examples/streaming-chat/src/node/rpc/functions/clear.ts similarity index 100% rename from examples/streaming-chat/src/rpc/functions/clear.ts rename to examples/streaming-chat/src/node/rpc/functions/clear.ts diff --git a/examples/streaming-chat/src/rpc/functions/demo-prompts.ts b/examples/streaming-chat/src/node/rpc/functions/demo-prompts.ts similarity index 100% rename from examples/streaming-chat/src/rpc/functions/demo-prompts.ts rename to examples/streaming-chat/src/node/rpc/functions/demo-prompts.ts diff --git a/examples/streaming-chat/src/rpc/functions/send.ts b/examples/streaming-chat/src/node/rpc/functions/send.ts similarity index 100% rename from examples/streaming-chat/src/rpc/functions/send.ts rename to examples/streaming-chat/src/node/rpc/functions/send.ts diff --git a/examples/streaming-chat/src/rpc/index.ts b/examples/streaming-chat/src/node/rpc/index.ts similarity index 100% rename from examples/streaming-chat/src/rpc/index.ts rename to examples/streaming-chat/src/node/rpc/index.ts diff --git a/examples/streaming-chat/src/types.ts b/examples/streaming-chat/src/node/types.ts similarity index 100% rename from examples/streaming-chat/src/types.ts rename to examples/streaming-chat/src/node/types.ts diff --git a/examples/streaming-chat/tests/_utils.ts b/examples/streaming-chat/tests/_utils.ts index 3bdccf5a8..f98cd80f9 100644 --- a/examples/streaming-chat/tests/_utils.ts +++ b/examples/streaming-chat/tests/_utils.ts @@ -13,7 +13,7 @@ import { getPort } from 'get-port-please' import { H3 } from 'h3' import { resolve } from 'pathe' import { serveTestContext } from '../../../tests/helpers/serve-test-context' -import devframe from '../src/devframe' +import devframe from '../src/node/index' /** * Boot the streaming-chat server in-process for tests. Mirrors the diff --git a/examples/streaming-chat/tests/streaming-chat.test.ts b/examples/streaming-chat/tests/streaming-chat.test.ts index 49ee20eb6..e287e844d 100644 --- a/examples/streaming-chat/tests/streaming-chat.test.ts +++ b/examples/streaming-chat/tests/streaming-chat.test.ts @@ -1,6 +1,6 @@ import type { DevframeNodeContext } from 'devframe' import type { StartedServer } from 'devframe/internal' -import type { ChatHistory } from '../src/devframe' +import type { ChatHistory } from '../src/node/index' import { createRpcStreamingClientHost } from 'devframe/client' import { createRpcClient } from 'devframe/rpc/client' import { createWsRpcChannel } from 'devframe/rpc/transports/ws-client' diff --git a/examples/streaming-chat/tsconfig.json b/examples/streaming-chat/tsconfig.json index d4bdee9d3..a1fa38032 100644 --- a/examples/streaming-chat/tsconfig.json +++ b/examples/streaming-chat/tsconfig.json @@ -10,5 +10,5 @@ "esModuleInterop": true, "isolatedDeclarations": false }, - "include": ["src", "tests", "bin.mjs"] + "include": ["src", "app", "tests", "playgrounds", "bin.mjs"] } diff --git a/knip.jsonc b/knip.jsonc index 24548d877..2a4b420d1 100644 --- a/knip.jsonc +++ b/knip.jsonc @@ -113,9 +113,10 @@ "entry": ["src/{index,node}.ts"] }, "examples/next-runtime-snapshot": { - // Same nested Next.js client root as `hub-next` above. + // The Next.js app root sits at `app/` (`next build app`), not the + // workspace root, so knip's default PostCSS config glob misses it. "postcss": { - "config": ["src/client/postcss.config.mjs"] + "config": ["app/postcss.config.mjs"] } }, // The Deno entry runs via `deno run src/server.ts`, a command string knip diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 5292a7a5b..a1cb78833 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -1353,6 +1353,9 @@ importers: '@types/node': specifier: catalog:types version: 26.4.0 + h3: + specifier: catalog:deps + version: 2.0.1-rc.29(crossws@0.4.12(srvx@0.12.7)) unocss: specifier: catalog:frontend version: 66.8.1(@unocss/postcss@66.8.1(postcss@8.5.26))(vite@8.2.2(@types/node@26.4.0)(esbuild@0.28.2)(jiti@2.7.0)(terser@5.47.1)(tsx@4.23.13)(yaml@2.9.0)) diff --git a/tsconfig.base.json b/tsconfig.base.json index 194a71a3d..f825816ee 100644 --- a/tsconfig.base.json +++ b/tsconfig.base.json @@ -215,7 +215,7 @@ "./packages/json-render-ui/src/index.ts" ], "json-render/dashboard": [ - "./examples/json-render/src/dashboard.ts" + "./examples/json-render/src/node/dashboard.ts" ], "@devframes/plugin-code-server/node": [ "./plugins/code-server/src/node/setup.ts"