Skip to content

Commit eb91a26

Browse files
committed
feat(examples): add next-runtime-snapshot Next.js example + docs
Adds examples/next-runtime-snapshot proving Next.js App Router works as a devframe SPA via static export — three RPC functions (system / memory / env) surface host Node runtime info, exercising static + query types and valibot-validated args. Documents the Next.js SPA setup recipe alongside the existing Nuxt one, broadens the pnpm frontend catalog to React + Next, and ships vitest + Playwright coverage.
1 parent 1efe914 commit eb91a26

27 files changed

Lines changed: 1671 additions & 2 deletions

docs/guide/built-with.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,3 +14,4 @@ End-to-end examples in this repo, exercising the full adapter surface:
1414

1515
- [**files-inspector**](https://github.com/devframes/devframe/tree/main/examples/files-inspector) — lists files in cwd via RPC; exercises CLI dev/build/spa surfaces.
1616
- [**streaming-chat**](https://github.com/devframes/devframe/tree/main/examples/streaming-chat) — streams synthetic chat tokens from server to client via `ctx.rpc.streaming`.
17+
- [**next-runtime-snapshot**](https://github.com/devframes/devframe/tree/main/examples/next-runtime-snapshot) — Next.js App Router SPA over RPC, surfacing the host Node runtime (system info, memory, env).

docs/guide/standalone-cli.md

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -100,6 +100,50 @@ export default defineNuxtConfig({
100100

101101
Build with `nuxt build` and point `cli.distDir` at `./dist/public`. The SPA discovers its effective base at runtime — no `--base` rewrite needed. See the [Nuxt helper docs](/helpers/nuxt) for the full reference.
102102

103+
## Next.js SPA setup
104+
105+
For a Next.js App Router SPA, the integration is plain Next.js static export — devframe owns the HTTP and RPC server, Next.js produces the static bundle and stops there. Three config settings cover the integration:
106+
107+
```js [next.config.mjs]
108+
/** @type {import('next').NextConfig} */
109+
export default {
110+
output: 'export',
111+
assetPrefix: '.',
112+
trailingSlash: true,
113+
images: { unoptimized: true },
114+
}
115+
```
116+
117+
- **`output: 'export'`** emits the SPA as static HTML/JS/CSS — no Next.js runtime is needed at serve time. Server Components are pre-rendered at build; Client Components hydrate against the devframe RPC connection.
118+
- **`assetPrefix: '.'`** is the setting that makes the build base-agnostic. Assets are referenced as `./_next/...` so the same bundle works at `/`, `/__my-tool/`, and any other mount path the host adapter chooses. Without it, Next.js bakes in `/_next/...` and the build only works at the root.
119+
- **`trailingSlash: true`** emits `foo/index.html` rather than `foo.html`, which composes cleanly with devframe's static-handler directory-with-index resolution.
120+
121+
`next build` writes the export to `<project>/out/` next to `next.config.mjs`. Copy or move that to wherever you point `cli.distDir`:
122+
123+
```json [package.json]
124+
{
125+
"scripts": {
126+
"build": "next build src/client && rm -rf dist/client && cp -r src/client/out dist/client"
127+
}
128+
}
129+
```
130+
131+
```ts [src/cli.ts]
132+
import { fileURLToPath } from 'node:url'
133+
134+
defineDevframe({
135+
id: 'my-tool',
136+
cli: {
137+
distDir: fileURLToPath(new URL('../dist/client', import.meta.url)),
138+
},
139+
//
140+
})
141+
```
142+
143+
Inside Client Components, call `connectDevframe()` once and share the result via React context. See [Client](./client) for the full reference — the Next.js side is plain React, with no devframe-specific wrapper.
144+
145+
End-to-end example: [`examples/next-runtime-snapshot`](https://github.com/devframes/devframe/tree/main/examples/next-runtime-snapshot).
146+
103147
## Connecting from the client
104148

105149
With the Nuxt helper installed, use `$rpc` directly:

eslint.config.js

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,9 @@ export default antfu({
66
ignores: [
77
'skills',
88
'**/dist',
9+
'**/.next',
10+
'**/out',
11+
'**/next-env.d.ts',
912
'**/.vitepress/cache',
1013
'**/.vitepress/dist',
1114
],
Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
.next
2+
dist
3+
next-env.d.ts
4+
node_modules
5+
out
6+
.turbo
Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,60 @@
1+
# next-runtime-snapshot
2+
3+
End-to-end devframe demo with a **Next.js App Router** SPA. Shows that any
4+
React+Next.js build is a drop-in replacement for a Preact+Vite SPA: devframe
5+
serves the static export, the client calls into the host Node process via
6+
type-safe RPC.
7+
8+
## What it shows
9+
10+
- `next-runtime-snapshot:system` — a `static` RPC function. Runs once at
11+
build time when baked into a static dump, otherwise resolved live over
12+
WebSocket. Returns Node version, platform/arch, pid, cwd, start time.
13+
- `next-runtime-snapshot:memory` — a `query` RPC function. Re-runnable;
14+
the UI has a refresh button that re-invokes the handler.
15+
- `next-runtime-snapshot:env` — a `query` RPC function with valibot-validated
16+
args (`pattern`, `limit`). Lists environment variables matching a regex,
17+
redacting keys that look secret.
18+
- Next.js App Router with `'use client'` components calling
19+
`connectDevframe()` once and passing the RPC client through React context.
20+
21+
## Run it
22+
23+
```sh
24+
pnpm -C examples/next-runtime-snapshot run build # next build → static export → dist/client/
25+
pnpm -C examples/next-runtime-snapshot run dev # node bin.mjs → devframe CLI
26+
```
27+
28+
Open `http://localhost:9899/__next-runtime-snapshot/` — the three cards
29+
populate from RPC, the env filter is debounced, and the footer shows the
30+
connection backend.
31+
32+
## Build a static deployment
33+
34+
```sh
35+
pnpm -C examples/next-runtime-snapshot run cli:build
36+
```
37+
38+
Output lands in `dist/static/`. Serve it from any static host (`npx serve
39+
dist/static`) — the `static` and `query` RPCs that opted into the dump still
40+
work because the snapshot is baked at build time.
41+
42+
## Next.js config — three settings worth knowing
43+
44+
`src/client/next.config.mjs` is short on purpose. The three non-defaults
45+
each correspond to a devframe design principle:
46+
47+
- **`output: 'export'`** — devframe owns the HTTP server; Next.js produces a
48+
fully static SPA. No Next.js runtime is required at serve time, so server
49+
components and route handlers are rendered at build time only.
50+
- **`assetPrefix: '.'`** — relative asset paths so the same build works at
51+
`/`, `/__next-runtime-snapshot/`, and any custom base. Devframe's design
52+
principle: SPAs own their base path at runtime, discovered from
53+
`document.baseURI`.
54+
- **`trailingSlash: true`** — emits `foo/index.html` rather than `foo.html`,
55+
which composes cleanly with devframe's static handler directory-with-index
56+
resolution.
57+
58+
The `dist/client/` artifact is a copy of `src/client/out/` (Next.js's
59+
default export directory) — the `build` script just renames it so the
60+
example matches the layout used by the other examples in this repo.
Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
#!/usr/bin/env node
2+
import process from 'node:process'
3+
import { createCli } from 'devframe/adapters/cli'
4+
import devframe from './src/devframe.ts'
5+
6+
async function main() {
7+
const cli = createCli(devframe)
8+
await cli.parse()
9+
}
10+
11+
main().catch((error) => {
12+
console.error(error)
13+
process.exit(1)
14+
})
Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
{
2+
"name": "next-runtime-snapshot-example",
3+
"type": "module",
4+
"version": "0.4.1",
5+
"private": true,
6+
"description": "End-to-end devframe demo — Next.js App Router SPA over RPC, exposing the host Node runtime snapshot (system info, memory, env).",
7+
"main": "src/devframe.ts",
8+
"bin": {
9+
"next-runtime-snapshot": "./bin.mjs"
10+
},
11+
"scripts": {
12+
"build": "next build src/client && rm -rf dist/client && mkdir -p dist && cp -r src/client/out dist/client",
13+
"cli:build": "node bin.mjs build --out-dir dist/static",
14+
"dev": "node bin.mjs",
15+
"next:dev": "next dev src/client",
16+
"test": "vitest run"
17+
},
18+
"dependencies": {
19+
"devframe": "workspace:*",
20+
"next": "catalog:frontend",
21+
"react": "catalog:frontend",
22+
"react-dom": "catalog:frontend"
23+
},
24+
"devDependencies": {
25+
"@types/react": "catalog:types",
26+
"@types/react-dom": "catalog:types",
27+
"get-port-please": "catalog:deps",
28+
"h3": "catalog:deps",
29+
"vitest": "catalog:testing",
30+
"ws": "catalog:deps"
31+
}
32+
}
Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,42 @@
1+
'use client'
2+
3+
import type { DevToolsRpcClient } from 'devframe/client'
4+
import type { ReactNode } from 'react'
5+
import { connectDevframe } from 'devframe/client'
6+
import { createContext, useContext, useEffect, useState } from 'react'
7+
8+
interface ConnectionState {
9+
rpc: DevToolsRpcClient | null
10+
error: string | null
11+
}
12+
13+
const RpcContext = createContext<ConnectionState>({ rpc: null, error: null })
14+
15+
export function useRpc(): ConnectionState {
16+
return useContext(RpcContext)
17+
}
18+
19+
export function RpcProvider({ children }: { children: ReactNode }) {
20+
const [state, setState] = useState<ConnectionState>({ rpc: null, error: null })
21+
22+
useEffect(() => {
23+
let cancelled = false
24+
connectDevframe().then(
25+
(rpc) => {
26+
if (!cancelled)
27+
setState({ rpc, error: null })
28+
},
29+
(err: unknown) => {
30+
if (cancelled)
31+
return
32+
const message = err instanceof Error ? err.message : String(err)
33+
setState({ rpc: null, error: message })
34+
},
35+
)
36+
return () => {
37+
cancelled = true
38+
}
39+
}, [])
40+
41+
return <RpcContext.Provider value={state}>{children}</RpcContext.Provider>
42+
}
Lines changed: 74 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,74 @@
1+
'use client'
2+
3+
import type { EnvSnapshot } from '../../../devframe'
4+
import { useCallback, useEffect, useState } from 'react'
5+
import { useRpc } from './connect'
6+
7+
export function SnapshotEnv() {
8+
const { rpc } = useRpc()
9+
const [pattern, setPattern] = useState('NODE')
10+
const [snap, setSnap] = useState<EnvSnapshot | null>(null)
11+
const [loading, setLoading] = useState(false)
12+
13+
const fetchEnv = useCallback(async (p: string) => {
14+
if (!rpc)
15+
return
16+
setLoading(true)
17+
try {
18+
const r = await rpc.call('next-runtime-snapshot:env' as any, { pattern: p }) as EnvSnapshot
19+
setSnap(r)
20+
}
21+
finally {
22+
setLoading(false)
23+
}
24+
}, [rpc])
25+
26+
useEffect(() => {
27+
const t = setTimeout(() => void fetchEnv(pattern), 200)
28+
return () => clearTimeout(t)
29+
}, [pattern, fetchEnv])
30+
31+
return (
32+
<section className="card">
33+
<h2>
34+
<span>Environment</span>
35+
{snap && (
36+
<span className="actions">
37+
<span style={{ fontSize: 12, color: '#8b95a3' }}>
38+
{snap.entries.length}
39+
{' / '}
40+
{snap.total}
41+
</span>
42+
</span>
43+
)}
44+
</h2>
45+
<div className="env-filter">
46+
<input
47+
type="text"
48+
value={pattern}
49+
onChange={e => setPattern(e.target.value)}
50+
placeholder="Regex filter (case-insensitive) — e.g. NODE | PATH | HOME"
51+
/>
52+
</div>
53+
{snap === null && <p className="loading">Loading…</p>}
54+
{snap && snap.entries.length === 0 && (
55+
<p className="empty">
56+
{loading ? 'Searching…' : 'No environment variables match this pattern.'}
57+
</p>
58+
)}
59+
{snap && snap.entries.length > 0 && (
60+
<div className="env-list">
61+
{snap.entries.map(entry => (
62+
<div
63+
key={entry.key}
64+
className={entry.redacted ? 'env-row redacted' : 'env-row'}
65+
>
66+
<span className="k">{entry.key}</span>
67+
<span className="v">{entry.value}</span>
68+
</div>
69+
))}
70+
</div>
71+
)}
72+
</section>
73+
)
74+
}
Lines changed: 76 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,76 @@
1+
'use client'
2+
3+
import type { MemorySnapshot } from '../../../devframe'
4+
import { useCallback, useEffect, useState } from 'react'
5+
import { useRpc } from './connect'
6+
7+
function fmtBytes(bytes: number): string {
8+
const mb = bytes / (1024 * 1024)
9+
return `${mb.toFixed(2)} MB`
10+
}
11+
12+
function fmtUptime(seconds: number): string {
13+
const s = Math.floor(seconds)
14+
const h = Math.floor(s / 3600)
15+
const m = Math.floor((s % 3600) / 60)
16+
const rem = s % 60
17+
if (h > 0)
18+
return `${h}h ${m}m ${rem}s`
19+
if (m > 0)
20+
return `${m}m ${rem}s`
21+
return `${rem}s`
22+
}
23+
24+
export function SnapshotMemory() {
25+
const { rpc } = useRpc()
26+
const [snap, setSnap] = useState<MemorySnapshot | null>(null)
27+
const [loading, setLoading] = useState(false)
28+
29+
const refresh = useCallback(async () => {
30+
if (!rpc)
31+
return
32+
setLoading(true)
33+
try {
34+
const r = await rpc.call('next-runtime-snapshot:memory' as any) as MemorySnapshot
35+
setSnap(r)
36+
}
37+
finally {
38+
setLoading(false)
39+
}
40+
}, [rpc])
41+
42+
useEffect(() => {
43+
void refresh()
44+
}, [refresh])
45+
46+
return (
47+
<section className="card">
48+
<h2>
49+
<span>Memory & Uptime</span>
50+
<span className="actions">
51+
<button type="button" onClick={refresh} disabled={!rpc || loading}>
52+
{loading ? 'Refreshing…' : 'Refresh'}
53+
</button>
54+
</span>
55+
</h2>
56+
{snap
57+
? (
58+
<dl className="kv">
59+
<span className="k">uptime</span>
60+
<span className="v">{fmtUptime(snap.uptimeSeconds)}</span>
61+
<span className="k">rss</span>
62+
<span className="v">{fmtBytes(snap.memory.rss)}</span>
63+
<span className="k">heap used</span>
64+
<span className="v">{fmtBytes(snap.memory.heapUsed)}</span>
65+
<span className="k">heap total</span>
66+
<span className="v">{fmtBytes(snap.memory.heapTotal)}</span>
67+
<span className="k">external</span>
68+
<span className="v">{fmtBytes(snap.memory.external)}</span>
69+
<span className="k">array buffers</span>
70+
<span className="v">{fmtBytes(snap.memory.arrayBuffers)}</span>
71+
</dl>
72+
)
73+
: <p className="loading">Loading…</p>}
74+
</section>
75+
)
76+
}

0 commit comments

Comments
 (0)