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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions docs/guide/built-with.md
Original file line number Diff line number Diff line change
Expand Up @@ -14,3 +14,4 @@ End-to-end examples in this repo, exercising the full adapter surface:

- [**files-inspector**](https://github.com/devframes/devframe/tree/main/examples/files-inspector) — lists files in cwd via RPC; exercises CLI dev/build/spa surfaces.
- [**streaming-chat**](https://github.com/devframes/devframe/tree/main/examples/streaming-chat) — streams synthetic chat tokens from server to client via `ctx.rpc.streaming`.
- [**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).
44 changes: 44 additions & 0 deletions docs/guide/standalone-cli.md
Original file line number Diff line number Diff line change
Expand Up @@ -100,6 +100,50 @@ export default defineNuxtConfig({

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.

## Next.js SPA setup

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:

```js [next.config.mjs]
/** @type {import('next').NextConfig} */
export default {
output: 'export',
assetPrefix: '.',
trailingSlash: true,
images: { unoptimized: true },
}
```

- **`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.
- **`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.
- **`trailingSlash: true`** emits `foo/index.html` rather than `foo.html`, which composes cleanly with devframe's static-handler directory-with-index resolution.

`next build` writes the export to `<project>/out/` next to `next.config.mjs`. Copy or move that to wherever you point `cli.distDir`:

```json [package.json]
{
"scripts": {
"build": "next build src/client && rm -rf dist/client && mkdir -p dist && cp -r src/client/out dist/client"
}
}
```

```ts [src/cli.ts]
import { fileURLToPath } from 'node:url'

defineDevframe({
id: 'my-tool',
cli: {
distDir: fileURLToPath(new URL('../dist/client', import.meta.url)),
},
// …
})
```

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.

End-to-end example: [`examples/next-runtime-snapshot`](https://github.com/devframes/devframe/tree/main/examples/next-runtime-snapshot).

## Connecting from the client

With the Nuxt helper installed, use `$rpc` directly:
Expand Down
3 changes: 3 additions & 0 deletions eslint.config.js
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,9 @@ export default antfu({
ignores: [
'skills',
'**/dist',
'**/.next',
'**/out',
'**/next-env.d.ts',
'**/.vitepress/cache',
'**/.vitepress/dist',
],
Expand Down
6 changes: 6 additions & 0 deletions examples/next-runtime-snapshot/.gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
.next
dist
next-env.d.ts
node_modules
out
.turbo
60 changes: 60 additions & 0 deletions examples/next-runtime-snapshot/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
# next-runtime-snapshot

End-to-end devframe demo with a **Next.js App Router** SPA. Shows that any
React+Next.js build is a drop-in replacement for a Preact+Vite SPA: devframe
serves the static export, the client calls into the host Node process via
type-safe RPC.

## What it shows

- `next-runtime-snapshot:system` — a `static` RPC function. Runs once at
build time when baked into a static dump, otherwise resolved live over
WebSocket. Returns Node version, platform/arch, pid, cwd, start time.
- `next-runtime-snapshot:memory` — a `query` RPC function. Re-runnable;
the UI has a refresh button that re-invokes the handler.
- `next-runtime-snapshot:env` — a `query` RPC function with valibot-validated
args (`pattern`, `limit`). Lists environment variables matching a regex,
redacting keys that look secret.
- Next.js App Router with `'use client'` components calling
`connectDevframe()` once and passing the RPC client through React context.

## Run it

```sh
pnpm -C examples/next-runtime-snapshot run build # next build → static export → dist/client/
pnpm -C examples/next-runtime-snapshot run dev # node bin.mjs → devframe CLI
```

Open `http://localhost:9899/__next-runtime-snapshot/` — the three cards
populate from RPC, the env filter is debounced, and the footer shows the
connection backend.

## Build a static deployment

```sh
pnpm -C examples/next-runtime-snapshot run cli:build
```

Output lands in `dist/static/`. Serve it from any static host (`npx serve
dist/static`) — the `static` and `query` RPCs that opted into the dump still
work because the snapshot is baked at build time.

## Next.js config — three settings worth knowing

`src/client/next.config.mjs` is short on purpose. The three non-defaults
each correspond to a devframe design principle:

- **`output: 'export'`** — devframe owns the HTTP server; Next.js produces a
fully static SPA. No Next.js runtime is required at serve time, so server
components and route handlers are rendered at build time only.
- **`assetPrefix: '.'`** — relative asset paths so the same build works at
`/`, `/__next-runtime-snapshot/`, and any custom base. Devframe's design
principle: SPAs own their base path at runtime, discovered from
`document.baseURI`.
- **`trailingSlash: true`** — emits `foo/index.html` rather than `foo.html`,
which composes cleanly with devframe's static handler directory-with-index
resolution.

The `dist/client/` artifact is a copy of `src/client/out/` (Next.js's
default export directory) — the `build` script just renames it so the
example matches the layout used by the other examples in this repo.
14 changes: 14 additions & 0 deletions examples/next-runtime-snapshot/bin.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
#!/usr/bin/env node
import process from 'node:process'
import { createCli } from 'devframe/adapters/cli'
import devframe from './src/devframe.ts'

async function main() {
const cli = createCli(devframe)
await cli.parse()
}

main().catch((error) => {
console.error(error)
process.exit(1)
})
32 changes: 32 additions & 0 deletions examples/next-runtime-snapshot/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
{
"name": "next-runtime-snapshot-example",
"type": "module",
"version": "0.4.1",
"private": true,
"description": "End-to-end devframe demo — Next.js App Router SPA over RPC, exposing the host Node runtime snapshot (system info, memory, env).",
"main": "src/devframe.ts",
"bin": {
"next-runtime-snapshot": "./bin.mjs"
},
"scripts": {
"build": "next build src/client && rm -rf dist/client && mkdir -p dist && cp -r src/client/out dist/client",
"cli:build": "node bin.mjs build --out-dir dist/static",
"dev": "node bin.mjs",
"next:dev": "next dev src/client",
"test": "vitest run"
},
"dependencies": {
"devframe": "workspace:*",
"next": "catalog:frontend",
"react": "catalog:frontend",
"react-dom": "catalog:frontend"
},
"devDependencies": {
"@types/react": "catalog:types",
"@types/react-dom": "catalog:types",
"get-port-please": "catalog:deps",
"h3": "catalog:deps",
"vitest": "catalog:testing",
"ws": "catalog:deps"
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
'use client'

import type { DevToolsRpcClient } from 'devframe/client'
import type { ReactNode } from 'react'
import { connectDevframe } from 'devframe/client'
import { createContext, useContext, useEffect, useState } from 'react'

interface ConnectionState {
rpc: DevToolsRpcClient | null
error: string | null
}

const RpcContext = createContext<ConnectionState>({ rpc: null, error: null })

export function useRpc(): ConnectionState {
return useContext(RpcContext)
}

export function RpcProvider({ children }: { children: ReactNode }) {
const [state, setState] = useState<ConnectionState>({ rpc: null, error: null })

useEffect(() => {
let cancelled = false
connectDevframe().then(
(rpc) => {
if (!cancelled)
setState({ rpc, error: null })
},
(err: unknown) => {
if (cancelled)
return
const message = err instanceof Error ? err.message : String(err)
setState({ rpc: null, error: message })
},
)
return () => {
cancelled = true
}
}, [])

return <RpcContext.Provider value={state}>{children}</RpcContext.Provider>
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
'use client'

import type { EnvSnapshot } from '../../../devframe'
import { useCallback, useEffect, useState } from 'react'
import { useRpc } from './connect'

export function SnapshotEnv() {
const { rpc } = useRpc()
const [pattern, setPattern] = useState('NODE')
const [snap, setSnap] = useState<EnvSnapshot | null>(null)
const [loading, setLoading] = useState(false)

const fetchEnv = useCallback(async (p: string) => {
if (!rpc)
return
setLoading(true)
try {
const r = await rpc.call('next-runtime-snapshot:env' as any, { pattern: p }) as EnvSnapshot
setSnap(r)
}
finally {
setLoading(false)
}
}, [rpc])

useEffect(() => {
const t = setTimeout(() => void fetchEnv(pattern), 200)
return () => clearTimeout(t)
}, [pattern, fetchEnv])

return (
<section className="card">
<h2>
<span>Environment</span>
{snap && (
<span className="actions">
<span style={{ fontSize: 12, color: '#8b95a3' }}>
{snap.entries.length}
{' / '}
{snap.total}
</span>
</span>
)}
</h2>
<div className="env-filter">
<input
type="text"
value={pattern}
onChange={e => setPattern(e.target.value)}
placeholder="Regex filter (case-insensitive) — e.g. NODE | PATH | HOME"
aria-label="Environment variable filter (case-insensitive regex)"
/>
Comment on lines +46 to +52
</div>
{snap === null && <p className="loading">Loading…</p>}
{snap && snap.entries.length === 0 && (
<p className="empty">
{loading ? 'Searching…' : 'No environment variables match this pattern.'}
</p>
)}
{snap && snap.entries.length > 0 && (
<div className="env-list">
{snap.entries.map(entry => (
<div
key={entry.key}
className={entry.redacted ? 'env-row redacted' : 'env-row'}
>
<span className="k">{entry.key}</span>
<span className="v">{entry.value}</span>
</div>
))}
</div>
)}
</section>
)
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
'use client'

import type { MemorySnapshot } from '../../../devframe'
import { useCallback, useEffect, useState } from 'react'
import { useRpc } from './connect'

function fmtBytes(bytes: number): string {
const mb = bytes / (1024 * 1024)
return `${mb.toFixed(2)} MB`
}

function fmtUptime(seconds: number): string {
const s = Math.floor(seconds)
const h = Math.floor(s / 3600)
const m = Math.floor((s % 3600) / 60)
const rem = s % 60
if (h > 0)
return `${h}h ${m}m ${rem}s`
if (m > 0)
return `${m}m ${rem}s`
return `${rem}s`
}

export function SnapshotMemory() {
const { rpc } = useRpc()
const [snap, setSnap] = useState<MemorySnapshot | null>(null)
const [loading, setLoading] = useState(false)

const refresh = useCallback(async () => {
if (!rpc)
return
setLoading(true)
try {
const r = await rpc.call('next-runtime-snapshot:memory' as any) as MemorySnapshot
setSnap(r)
}
finally {
setLoading(false)
}
}, [rpc])

useEffect(() => {
void refresh()
}, [refresh])

return (
<section className="card">
<h2>
<span>Memory & Uptime</span>
<span className="actions">
<button type="button" onClick={refresh} disabled={!rpc || loading}>
{loading ? 'Refreshing…' : 'Refresh'}
</button>
</span>
</h2>
{snap
? (
<div className="kv">
<span className="k">uptime</span>
<span className="v">{fmtUptime(snap.uptimeSeconds)}</span>
<span className="k">rss</span>
<span className="v">{fmtBytes(snap.memory.rss)}</span>
<span className="k">heap used</span>
<span className="v">{fmtBytes(snap.memory.heapUsed)}</span>
<span className="k">heap total</span>
<span className="v">{fmtBytes(snap.memory.heapTotal)}</span>
<span className="k">external</span>
<span className="v">{fmtBytes(snap.memory.external)}</span>
<span className="k">array buffers</span>
<span className="v">{fmtBytes(snap.memory.arrayBuffers)}</span>
</div>
)
: <p className="loading">Loading…</p>}
</section>
)
}
Loading
Loading