From eb91a2635e922f84266cf2958529d6ad588482fc Mon Sep 17 00:00:00 2001 From: Anthony Fu Date: Fri, 22 May 2026 11:11:23 +0900 Subject: [PATCH 1/2] feat(examples): add next-runtime-snapshot Next.js example + docs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- docs/guide/built-with.md | 1 + docs/guide/standalone-cli.md | 44 ++ eslint.config.js | 3 + examples/next-runtime-snapshot/.gitignore | 6 + examples/next-runtime-snapshot/README.md | 60 ++ examples/next-runtime-snapshot/bin.mjs | 14 + examples/next-runtime-snapshot/package.json | 32 ++ .../src/client/app/components/connect.tsx | 42 ++ .../client/app/components/snapshot-env.tsx | 74 +++ .../client/app/components/snapshot-memory.tsx | 76 +++ .../client/app/components/snapshot-system.tsx | 49 ++ .../src/client/app/globals.css | 177 ++++++ .../src/client/app/layout.tsx | 16 + .../src/client/app/page.tsx | 54 ++ .../src/client/next.config.mjs | 15 + .../src/client/tsconfig.json | 13 + .../next-runtime-snapshot/src/devframe.ts | 140 +++++ .../next-runtime-snapshot/tests/_utils.ts | 68 +++ .../tests/next-runtime-snapshot.test.ts | 118 ++++ examples/next-runtime-snapshot/tsconfig.json | 24 + playwright.config.ts | 18 + pnpm-lock.yaml | 542 +++++++++++++++++- pnpm-workspace.yaml | 6 + tests/e2e/next-runtime-snapshot-dev.spec.ts | 44 ++ .../e2e/next-runtime-snapshot-static.spec.ts | 26 + turbo.json | 10 + vitest.config.ts | 1 + 27 files changed, 1671 insertions(+), 2 deletions(-) create mode 100644 examples/next-runtime-snapshot/.gitignore create mode 100644 examples/next-runtime-snapshot/README.md create mode 100755 examples/next-runtime-snapshot/bin.mjs create mode 100644 examples/next-runtime-snapshot/package.json create mode 100644 examples/next-runtime-snapshot/src/client/app/components/connect.tsx create mode 100644 examples/next-runtime-snapshot/src/client/app/components/snapshot-env.tsx create mode 100644 examples/next-runtime-snapshot/src/client/app/components/snapshot-memory.tsx create mode 100644 examples/next-runtime-snapshot/src/client/app/components/snapshot-system.tsx create mode 100644 examples/next-runtime-snapshot/src/client/app/globals.css create mode 100644 examples/next-runtime-snapshot/src/client/app/layout.tsx create mode 100644 examples/next-runtime-snapshot/src/client/app/page.tsx create mode 100644 examples/next-runtime-snapshot/src/client/next.config.mjs create mode 100644 examples/next-runtime-snapshot/src/client/tsconfig.json create mode 100644 examples/next-runtime-snapshot/src/devframe.ts create mode 100644 examples/next-runtime-snapshot/tests/_utils.ts create mode 100644 examples/next-runtime-snapshot/tests/next-runtime-snapshot.test.ts create mode 100644 examples/next-runtime-snapshot/tsconfig.json create mode 100644 tests/e2e/next-runtime-snapshot-dev.spec.ts create mode 100644 tests/e2e/next-runtime-snapshot-static.spec.ts diff --git a/docs/guide/built-with.md b/docs/guide/built-with.md index ef770aa..7c8372b 100644 --- a/docs/guide/built-with.md +++ b/docs/guide/built-with.md @@ -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). diff --git a/docs/guide/standalone-cli.md b/docs/guide/standalone-cli.md index 08db130..c066844 100644 --- a/docs/guide/standalone-cli.md +++ b/docs/guide/standalone-cli.md @@ -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 `/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 && 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: diff --git a/eslint.config.js b/eslint.config.js index d7ff12d..b01f4f8 100644 --- a/eslint.config.js +++ b/eslint.config.js @@ -6,6 +6,9 @@ export default antfu({ ignores: [ 'skills', '**/dist', + '**/.next', + '**/out', + '**/next-env.d.ts', '**/.vitepress/cache', '**/.vitepress/dist', ], diff --git a/examples/next-runtime-snapshot/.gitignore b/examples/next-runtime-snapshot/.gitignore new file mode 100644 index 0000000..999e272 --- /dev/null +++ b/examples/next-runtime-snapshot/.gitignore @@ -0,0 +1,6 @@ +.next +dist +next-env.d.ts +node_modules +out +.turbo diff --git a/examples/next-runtime-snapshot/README.md b/examples/next-runtime-snapshot/README.md new file mode 100644 index 0000000..dbd7755 --- /dev/null +++ b/examples/next-runtime-snapshot/README.md @@ -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. diff --git a/examples/next-runtime-snapshot/bin.mjs b/examples/next-runtime-snapshot/bin.mjs new file mode 100755 index 0000000..e760d99 --- /dev/null +++ b/examples/next-runtime-snapshot/bin.mjs @@ -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) +}) diff --git a/examples/next-runtime-snapshot/package.json b/examples/next-runtime-snapshot/package.json new file mode 100644 index 0000000..a8ccbd0 --- /dev/null +++ b/examples/next-runtime-snapshot/package.json @@ -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" + } +} diff --git a/examples/next-runtime-snapshot/src/client/app/components/connect.tsx b/examples/next-runtime-snapshot/src/client/app/components/connect.tsx new file mode 100644 index 0000000..e1c6599 --- /dev/null +++ b/examples/next-runtime-snapshot/src/client/app/components/connect.tsx @@ -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({ rpc: null, error: null }) + +export function useRpc(): ConnectionState { + return useContext(RpcContext) +} + +export function RpcProvider({ children }: { children: ReactNode }) { + const [state, setState] = useState({ 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 {children} +} 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 new file mode 100644 index 0000000..cc0935b --- /dev/null +++ b/examples/next-runtime-snapshot/src/client/app/components/snapshot-env.tsx @@ -0,0 +1,74 @@ +'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(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 ( +
+

+ Environment + {snap && ( + + + {snap.entries.length} + {' / '} + {snap.total} + + + )} +

+
+ setPattern(e.target.value)} + placeholder="Regex filter (case-insensitive) — e.g. NODE | PATH | HOME" + /> +
+ {snap === null &&

Loading…

} + {snap && snap.entries.length === 0 && ( +

+ {loading ? 'Searching…' : 'No environment variables match this pattern.'} +

+ )} + {snap && snap.entries.length > 0 && ( +
+ {snap.entries.map(entry => ( +
+ {entry.key} + {entry.value} +
+ ))} +
+ )} +
+ ) +} 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 new file mode 100644 index 0000000..bad829b --- /dev/null +++ b/examples/next-runtime-snapshot/src/client/app/components/snapshot-memory.tsx @@ -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(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 ( +
+

+ Memory & Uptime + + + +

+ {snap + ? ( +
+ uptime + {fmtUptime(snap.uptimeSeconds)} + rss + {fmtBytes(snap.memory.rss)} + heap used + {fmtBytes(snap.memory.heapUsed)} + heap total + {fmtBytes(snap.memory.heapTotal)} + external + {fmtBytes(snap.memory.external)} + array buffers + {fmtBytes(snap.memory.arrayBuffers)} +
+ ) + :

Loading…

} +
+ ) +} 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 new file mode 100644 index 0000000..dffcc18 --- /dev/null +++ b/examples/next-runtime-snapshot/src/client/app/components/snapshot-system.tsx @@ -0,0 +1,49 @@ +'use client' + +import type { SystemInfo } from '../../../devframe' +import { useEffect, useState } from 'react' +import { useRpc } from './connect' + +function formatStartedAt(epoch: number): string { + return new Date(epoch).toLocaleString() +} + +export function SnapshotSystem() { + const { rpc } = useRpc() + const [info, setInfo] = useState(null) + + useEffect(() => { + if (!rpc) + return + let active = true + rpc.call('next-runtime-snapshot:system' as any).then((r: unknown) => { + if (active) + setInfo(r as SystemInfo) + }) + return () => { + active = false + } + }, [rpc]) + + return ( +
+

System

+ {info + ? ( +
+ node + {info.node} + platform + {`${info.platform} (${info.arch})`} + pid + {info.pid} + cwd + {info.cwd} + started + {formatStartedAt(info.startedAt)} +
+ ) + :

Loading…

} +
+ ) +} diff --git a/examples/next-runtime-snapshot/src/client/app/globals.css b/examples/next-runtime-snapshot/src/client/app/globals.css new file mode 100644 index 0000000..42fac44 --- /dev/null +++ b/examples/next-runtime-snapshot/src/client/app/globals.css @@ -0,0 +1,177 @@ +*, *::before, *::after { + box-sizing: border-box; +} + +html, body { + margin: 0; + padding: 0; + background: #0e1014; + color: #e6e8eb; + font-family: system-ui, -apple-system, 'Segoe UI', sans-serif; + font-size: 14px; + line-height: 1.5; +} + +main { + max-width: 880px; + margin: 0 auto; + padding: 32px 24px 64px; + display: flex; + flex-direction: column; + gap: 24px; +} + +header h1 { + margin: 0 0 4px; + font-size: 20px; + font-weight: 600; +} + +header small { + color: #8b95a3; +} + +.status { + font-size: 12px; + color: #8b95a3; + border-top: 1px solid #232730; + padding-top: 12px; +} + +.status code { + color: #c5cdd9; + background: #1a1d24; + padding: 1px 6px; + border-radius: 4px; +} + +.status .err { + color: #ff7a7a; +} + +.cards { + display: grid; + grid-template-columns: 1fr; + gap: 16px; +} + +.card { + background: #15181f; + border: 1px solid #232730; + border-radius: 8px; + padding: 16px 20px; + display: flex; + flex-direction: column; + gap: 12px; +} + +.card h2 { + margin: 0; + font-size: 14px; + font-weight: 600; + color: #c5cdd9; + display: flex; + align-items: center; + justify-content: space-between; +} + +.card .actions button { + background: #232730; + border: 1px solid #2e333d; + color: #c5cdd9; + border-radius: 4px; + padding: 4px 10px; + font: inherit; + font-size: 12px; + cursor: pointer; +} + +.card .actions button:hover { + background: #2e333d; +} + +.card .actions button:disabled { + opacity: 0.5; + cursor: not-allowed; +} + +.kv { + display: grid; + grid-template-columns: minmax(120px, max-content) 1fr; + gap: 4px 16px; + font-family: ui-monospace, SFMono-Regular, 'Cascadia Code', monospace; + font-size: 12px; +} + +.kv .k { + color: #8b95a3; +} + +.kv .v { + color: #e6e8eb; + word-break: break-all; +} + +.env-filter { + display: flex; + gap: 8px; + align-items: center; +} + +.env-filter input { + flex: 1; + background: #0e1014; + border: 1px solid #2e333d; + color: #e6e8eb; + padding: 6px 10px; + border-radius: 4px; + font: inherit; + font-family: ui-monospace, SFMono-Regular, monospace; + font-size: 12px; +} + +.env-filter input:focus { + outline: none; + border-color: #5b8def; +} + +.env-list { + font-family: ui-monospace, SFMono-Regular, monospace; + font-size: 12px; + display: flex; + flex-direction: column; + gap: 4px; + max-height: 260px; + overflow-y: auto; +} + +.env-row { + display: grid; + grid-template-columns: minmax(140px, 200px) 1fr; + gap: 12px; + padding: 2px 0; +} + +.env-row .k { + color: #8b95a3; +} + +.env-row .v { + color: #e6e8eb; + word-break: break-all; +} + +.env-row.redacted .v { + color: #8b95a3; + font-style: italic; +} + +.empty { + color: #8b95a3; + font-size: 12px; +} + +.loading { + color: #8b95a3; + font-size: 12px; +} diff --git a/examples/next-runtime-snapshot/src/client/app/layout.tsx b/examples/next-runtime-snapshot/src/client/app/layout.tsx new file mode 100644 index 0000000..5c1aeec --- /dev/null +++ b/examples/next-runtime-snapshot/src/client/app/layout.tsx @@ -0,0 +1,16 @@ +import type { Metadata } from 'next' +import type { ReactNode } from 'react' +import './globals.css' + +export const metadata: Metadata = { + title: 'Next Runtime Snapshot', + description: 'A devframe demo with a Next.js App Router SPA.', +} + +export default function RootLayout({ children }: { children: ReactNode }) { + return ( + + {children} + + ) +} diff --git a/examples/next-runtime-snapshot/src/client/app/page.tsx b/examples/next-runtime-snapshot/src/client/app/page.tsx new file mode 100644 index 0000000..fe95c60 --- /dev/null +++ b/examples/next-runtime-snapshot/src/client/app/page.tsx @@ -0,0 +1,54 @@ +'use client' + +import { RpcProvider, useRpc } from './components/connect' +import { SnapshotEnv } from './components/snapshot-env' +import { SnapshotMemory } from './components/snapshot-memory' +import { SnapshotSystem } from './components/snapshot-system' + +function StatusBar() { + const { rpc, error } = useRpc() + if (error) { + return ( +
+ + connection failed — + {' '} + {error} + +
+ ) + } + if (!rpc) { + return
connecting…
+ } + return ( +
+ backend: + {' '} + {rpc.connectionMeta.backend} +
+ ) +} + +export default function Page() { + return ( + +
+
+

Next Runtime Snapshot

+ + devframe + Next.js App Router · live RPC into the host Node process + +
+ +
+ + + +
+ + +
+
+ ) +} diff --git a/examples/next-runtime-snapshot/src/client/next.config.mjs b/examples/next-runtime-snapshot/src/client/next.config.mjs new file mode 100644 index 0000000..55bd2e3 --- /dev/null +++ b/examples/next-runtime-snapshot/src/client/next.config.mjs @@ -0,0 +1,15 @@ +/** @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 new file mode 100644 index 0000000..62aa7e6 --- /dev/null +++ b/examples/next-runtime-snapshot/src/client/tsconfig.json @@ -0,0 +1,13 @@ +{ + "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/devframe.ts new file mode 100644 index 0000000..7b09b38 --- /dev/null +++ b/examples/next-runtime-snapshot/src/devframe.ts @@ -0,0 +1,140 @@ +import process from 'node:process' +import { fileURLToPath } from 'node:url' +import { defineRpcFunction } from 'devframe' +import { defineDevframe } from 'devframe/types' +import * as v from 'valibot' + +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', + icon: 'ph:gauge-duotone', + basePath: BASE_PATH, + cli: { + command: 'next-runtime-snapshot', + port: 9899, + distDir, + auth: false, + }, + 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 => { + let regex: RegExp | null = null + if (pattern) { + try { + regex = new RegExp(pattern, 'i') + } + catch { + regex = null + } + } + const keys = Object.keys(process.env).sort() + const matched = regex ? keys.filter(k => regex.test(k)) : keys + 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/tests/_utils.ts b/examples/next-runtime-snapshot/tests/_utils.ts new file mode 100644 index 0000000..a8a3501 --- /dev/null +++ b/examples/next-runtime-snapshot/tests/_utils.ts @@ -0,0 +1,68 @@ +import type { StartedServer } from 'devframe/node' +import process from 'node:process' +import { fileURLToPath } from 'node:url' +import { DEVTOOLS_CONNECTION_META_FILENAME } from 'devframe/constants' +import { + createH3DevToolsHost, + createHostContext, + startHttpAndWs, +} from 'devframe/node' +import { mountStaticHandler } from 'devframe/utils/serve-static' +import { getPort } from 'get-port-please' +import { H3 } from 'h3' +import { resolve } from 'pathe' +import devframe from '../src/devframe' + +const HERE = fileURLToPath(new URL('.', import.meta.url)) +export const CLIENT_DIST = resolve(HERE, '../dist/client') + +export interface SnapshotServer extends StartedServer { + basePath: string +} + +/** + * Boot the snapshot server in-process. Mirrors the cli adapter's wiring + * so the WS+HTTP path is exercised end-to-end, with a random free port + * so tests can run in parallel. + * + * Bound to 127.0.0.1 to avoid the IPv4/IPv6 race documented in + * `packages/devframe/src/rpc/transports/ws.test.ts`. + */ +export async function startSnapshotServer(): Promise { + const distDir = devframe.cli!.distDir! + const basePath = devframe.basePath! + const host = '127.0.0.1' + const port = await getPort({ host, random: true }) + + const app = new H3() + const origin = `http://${host}:${port}` + const h3Host = createH3DevToolsHost({ + origin, + appName: devframe.id, + mount: (base, dir) => mountStaticHandler(app, base, dir), + }) + + const ctx = await createHostContext({ cwd: process.cwd(), mode: 'dev', host: h3Host }) + await devframe.setup(ctx) + + const metaPath = `${basePath}${DEVTOOLS_CONNECTION_META_FILENAME}` + app.use(metaPath, () => ({ backend: 'websocket', websocket: port })) + // SPA mount is conditional — RPC-only tests don't need the built dist. + // Tests that fetch HTML should run `pnpm run build` first. + try { + mountStaticHandler(app, basePath, resolve(distDir)) + } + catch { + // No dist yet; that's fine for RPC-only tests. + } + + const server = await startHttpAndWs({ + context: ctx, + host, + port, + app, + auth: false, + }) + + return Object.assign(server, { basePath }) +} diff --git a/examples/next-runtime-snapshot/tests/next-runtime-snapshot.test.ts b/examples/next-runtime-snapshot/tests/next-runtime-snapshot.test.ts new file mode 100644 index 0000000..df99ca0 --- /dev/null +++ b/examples/next-runtime-snapshot/tests/next-runtime-snapshot.test.ts @@ -0,0 +1,118 @@ +import type { EnvSnapshot, MemorySnapshot, SystemInfo } from '../src/devframe' +import process from 'node:process' +import { createRpcClient } from 'devframe/rpc/client' +import { createWsRpcChannel } from 'devframe/rpc/transports/ws-client' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { WebSocket } from 'ws' +import { startSnapshotServer } from './_utils' + +vi.stubGlobal('WebSocket', WebSocket) + +function bootRpc(port: number) { + const channel = createWsRpcChannel({ url: `ws://127.0.0.1:${port}` }) + return createRpcClient({}, { channel }) +} + +describe('next-runtime-snapshot (example)', () => { + let server: Awaited> + + beforeEach(async () => { + server = await startSnapshotServer() + }) + + afterEach(async () => { + await server?.close() + }) + + it('serves connection meta pointing at the WS backend', async () => { + const res = await fetch(`${server.origin}${server.basePath}__connection.json`) + expect(res.status).toBe(200) + const meta = await res.json() as { backend: string, websocket: number } + expect(meta.backend).toBe('websocket') + expect(meta.websocket).toBe(server.port) + }) + + it('returns system info from the static RPC', async () => { + const rpc = bootRpc(server.port) + const info = await rpc.$call('next-runtime-snapshot:system') as SystemInfo + expect(info.node).toBe(process.version) + expect(info.platform).toBe(process.platform) + expect(info.arch).toBe(process.arch) + expect(info.pid).toBe(process.pid) + expect(info.cwd).toBe(process.cwd()) + expect(typeof info.startedAt).toBe('number') + }) + + it('returns a memory snapshot with the expected shape', async () => { + const rpc = bootRpc(server.port) + const snap = await rpc.$call('next-runtime-snapshot:memory') as MemorySnapshot + expect(snap.memory.rss).toBeGreaterThan(0) + expect(snap.memory.heapTotal).toBeGreaterThan(0) + expect(snap.memory.heapUsed).toBeGreaterThan(0) + expect(snap.uptimeSeconds).toBeGreaterThan(0) + expect(typeof snap.capturedAt).toBe('number') + }) + + it('refreshing memory yields monotonically non-decreasing uptime', async () => { + const rpc = bootRpc(server.port) + const first = await rpc.$call('next-runtime-snapshot:memory') as MemorySnapshot + await new Promise(r => setTimeout(r, 30)) + const second = await rpc.$call('next-runtime-snapshot:memory') as MemorySnapshot + expect(second.uptimeSeconds).toBeGreaterThanOrEqual(first.uptimeSeconds) + expect(second.capturedAt).toBeGreaterThanOrEqual(first.capturedAt) + }) + + it('filters env vars by case-insensitive regex pattern', async () => { + const rpc = bootRpc(server.port) + // Seed an env var that is guaranteed to match the filter. + process.env.DEVFRAME_TEST_MARKER = 'present' + try { + const snap = await rpc.$call('next-runtime-snapshot:env', { pattern: 'devframe_test_marker' }) as EnvSnapshot + expect(snap.pattern).toBe('devframe_test_marker') + expect(snap.total).toBe(1) + const entry = snap.entries.find(e => e.key === 'DEVFRAME_TEST_MARKER') + expect(entry).toBeDefined() + expect(entry!.value).toBe('present') + expect(entry!.redacted).toBe(false) + } + finally { + delete process.env.DEVFRAME_TEST_MARKER + } + }) + + it('redacts values for keys matching the secret pattern', async () => { + const rpc = bootRpc(server.port) + process.env.DEVFRAME_TEST_API_KEY = 'sk-xyz-123' + process.env.DEVFRAME_TEST_SECRET_PAYLOAD = 'shh' + try { + const snap = await rpc.$call('next-runtime-snapshot:env', { pattern: 'DEVFRAME_TEST_' }) as EnvSnapshot + const apiKey = snap.entries.find(e => e.key === 'DEVFRAME_TEST_API_KEY')! + const secret = snap.entries.find(e => e.key === 'DEVFRAME_TEST_SECRET_PAYLOAD')! + expect(apiKey.redacted).toBe(true) + expect(apiKey.value).not.toContain('xyz') + expect(secret.redacted).toBe(true) + expect(secret.value).not.toContain('shh') + } + finally { + delete process.env.DEVFRAME_TEST_API_KEY + delete process.env.DEVFRAME_TEST_SECRET_PAYLOAD + } + }) + + it('returns all env vars when no pattern is supplied (up to the limit)', async () => { + const rpc = bootRpc(server.port) + const snap = await rpc.$call('next-runtime-snapshot:env', { pattern: '', limit: 5 }) as EnvSnapshot + expect(snap.entries.length).toBeLessThanOrEqual(5) + expect(snap.total).toBeGreaterThan(0) + expect(snap.total).toBeGreaterThanOrEqual(snap.entries.length) + }) + + it('respects the limit cap on the entries slice', async () => { + const rpc = bootRpc(server.port) + const small = await rpc.$call('next-runtime-snapshot:env', { pattern: '', limit: 2 }) as EnvSnapshot + const big = await rpc.$call('next-runtime-snapshot:env', { pattern: '', limit: 100 }) as EnvSnapshot + expect(small.entries.length).toBeLessThanOrEqual(2) + expect(big.entries.length).toBeGreaterThanOrEqual(small.entries.length) + expect(big.total).toBe(small.total) + }) +}) diff --git a/examples/next-runtime-snapshot/tsconfig.json b/examples/next-runtime-snapshot/tsconfig.json new file mode 100644 index 0000000..91baff8 --- /dev/null +++ b/examples/next-runtime-snapshot/tsconfig.json @@ -0,0 +1,24 @@ +{ + "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": [ + "src", + "bin.mjs" + ], + "exclude": [ + "dist", + ".next", + "out" + ] +} diff --git a/playwright.config.ts b/playwright.config.ts index 040a6c3..6720bca 100644 --- a/playwright.config.ts +++ b/playwright.config.ts @@ -60,5 +60,23 @@ export default defineConfig({ stdout: 'pipe', stderr: 'pipe', }, + { + command: 'node bin.mjs', + cwd: 'examples/next-runtime-snapshot', + url: 'http://localhost:9899/__next-runtime-snapshot/', + timeout: 60_000, + reuseExistingServer: !process.env.CI, + stdout: 'pipe', + stderr: 'pipe', + }, + { + command: `node bin.mjs build --out-dir dist/static && node ${JSON.stringify(serveStatic)} dist/static 9889`, + cwd: 'examples/next-runtime-snapshot', + url: 'http://127.0.0.1:9889/', + timeout: 60_000, + reuseExistingServer: !process.env.CI, + stdout: 'pipe', + stderr: 'pipe', + }, ], }) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index db93fda..b51cbb7 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -130,9 +130,18 @@ catalogs: specifier: ^2.0.17 version: 2.0.17 frontend: + next: + specifier: ^16.2.6 + version: 16.2.6 preact: specifier: ^10.29.1 version: 10.29.1 + react: + specifier: ^19.2.6 + version: 19.2.6 + react-dom: + specifier: ^19.2.6 + version: 19.2.6 inlined: '@antfu/utils': specifier: ^9.3.0 @@ -154,6 +163,12 @@ catalogs: '@types/node': specifier: ^25.7.0 version: 25.7.0 + '@types/react': + specifier: ^19.2.15 + version: 19.2.15 + '@types/react-dom': + specifier: ^19.2.3 + version: 19.2.3 '@types/ws': specifier: ^8.18.1 version: 8.18.1 @@ -270,6 +285,40 @@ importers: specifier: catalog:deps version: 8.20.0 + examples/next-runtime-snapshot: + dependencies: + devframe: + specifier: workspace:* + version: link:../../packages/devframe + next: + specifier: catalog:frontend + version: 16.2.6(@playwright/test@1.60.0)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) + react: + specifier: catalog:frontend + version: 19.2.6 + react-dom: + specifier: catalog:frontend + version: 19.2.6(react@19.2.6) + devDependencies: + '@types/react': + specifier: catalog:types + version: 19.2.15 + '@types/react-dom': + specifier: catalog:types + version: 19.2.3(@types/react@19.2.15) + get-port-please: + specifier: catalog:deps + version: 3.2.0 + h3: + specifier: catalog:deps + version: 2.0.1-rc.22(crossws@0.4.5(srvx@0.11.15)) + vitest: + specifier: catalog:testing + version: 4.1.6(@types/node@25.7.0)(vite@8.0.12(@types/node@25.7.0)(esbuild@0.28.0)(jiti@2.7.0)(terser@5.47.1)(tsx@4.21.0)(yaml@2.8.4)) + ws: + specifier: catalog:deps + version: 8.20.0 + examples/streaming-chat: dependencies: devframe: @@ -1107,6 +1156,159 @@ packages: '@iconify/utils@3.1.3': resolution: {integrity: sha512-LPKOXPn/zV+zis1oOfGWogaXVpqUybF3ZS6SCZIsz8vg0ivVp9+fVqyYB7xq0aiST/VhUQYGO1qo6uoYSiEJqw==} + '@img/colour@1.1.0': + resolution: {integrity: sha512-Td76q7j57o/tLVdgS746cYARfSyxk8iEfRxewL9h4OMzYhbW4TAcppl0mT4eyqXddh6L/jwoM75mo7ixa/pCeQ==} + engines: {node: '>=18'} + + '@img/sharp-darwin-arm64@0.34.5': + resolution: {integrity: sha512-imtQ3WMJXbMY4fxb/Ndp6HBTNVtWCUI0WdobyheGf5+ad6xX8VIDO8u2xE4qc/fr08CKG/7dDseFtn6M6g/r3w==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + cpu: [arm64] + os: [darwin] + + '@img/sharp-darwin-x64@0.34.5': + resolution: {integrity: sha512-YNEFAF/4KQ/PeW0N+r+aVVsoIY0/qxxikF2SWdp+NRkmMB7y9LBZAVqQ4yhGCm/H3H270OSykqmQMKLBhBJDEw==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + cpu: [x64] + os: [darwin] + + '@img/sharp-libvips-darwin-arm64@1.2.4': + resolution: {integrity: sha512-zqjjo7RatFfFoP0MkQ51jfuFZBnVE2pRiaydKJ1G/rHZvnsrHAOcQALIi9sA5co5xenQdTugCvtb1cuf78Vf4g==} + cpu: [arm64] + os: [darwin] + + '@img/sharp-libvips-darwin-x64@1.2.4': + resolution: {integrity: sha512-1IOd5xfVhlGwX+zXv2N93k0yMONvUlANylbJw1eTah8K/Jtpi15KC+WSiaX/nBmbm2HxRM1gZ0nSdjSsrZbGKg==} + cpu: [x64] + os: [darwin] + + '@img/sharp-libvips-linux-arm64@1.2.4': + resolution: {integrity: sha512-excjX8DfsIcJ10x1Kzr4RcWe1edC9PquDRRPx3YVCvQv+U5p7Yin2s32ftzikXojb1PIFc/9Mt28/y+iRklkrw==} + cpu: [arm64] + os: [linux] + libc: [glibc] + + '@img/sharp-libvips-linux-arm@1.2.4': + resolution: {integrity: sha512-bFI7xcKFELdiNCVov8e44Ia4u2byA+l3XtsAj+Q8tfCwO6BQ8iDojYdvoPMqsKDkuoOo+X6HZA0s0q11ANMQ8A==} + cpu: [arm] + os: [linux] + libc: [glibc] + + '@img/sharp-libvips-linux-ppc64@1.2.4': + resolution: {integrity: sha512-FMuvGijLDYG6lW+b/UvyilUWu5Ayu+3r2d1S8notiGCIyYU/76eig1UfMmkZ7vwgOrzKzlQbFSuQfgm7GYUPpA==} + cpu: [ppc64] + os: [linux] + libc: [glibc] + + '@img/sharp-libvips-linux-riscv64@1.2.4': + resolution: {integrity: sha512-oVDbcR4zUC0ce82teubSm+x6ETixtKZBh/qbREIOcI3cULzDyb18Sr/Wcyx7NRQeQzOiHTNbZFF1UwPS2scyGA==} + cpu: [riscv64] + os: [linux] + libc: [glibc] + + '@img/sharp-libvips-linux-s390x@1.2.4': + resolution: {integrity: sha512-qmp9VrzgPgMoGZyPvrQHqk02uyjA0/QrTO26Tqk6l4ZV0MPWIW6LTkqOIov+J1yEu7MbFQaDpwdwJKhbJvuRxQ==} + cpu: [s390x] + os: [linux] + libc: [glibc] + + '@img/sharp-libvips-linux-x64@1.2.4': + resolution: {integrity: sha512-tJxiiLsmHc9Ax1bz3oaOYBURTXGIRDODBqhveVHonrHJ9/+k89qbLl0bcJns+e4t4rvaNBxaEZsFtSfAdquPrw==} + cpu: [x64] + os: [linux] + libc: [glibc] + + '@img/sharp-libvips-linuxmusl-arm64@1.2.4': + resolution: {integrity: sha512-FVQHuwx1IIuNow9QAbYUzJ+En8KcVm9Lk5+uGUQJHaZmMECZmOlix9HnH7n1TRkXMS0pGxIJokIVB9SuqZGGXw==} + cpu: [arm64] + os: [linux] + libc: [musl] + + '@img/sharp-libvips-linuxmusl-x64@1.2.4': + resolution: {integrity: sha512-+LpyBk7L44ZIXwz/VYfglaX/okxezESc6UxDSoyo2Ks6Jxc4Y7sGjpgU9s4PMgqgjj1gZCylTieNamqA1MF7Dg==} + cpu: [x64] + os: [linux] + libc: [musl] + + '@img/sharp-linux-arm64@0.34.5': + resolution: {integrity: sha512-bKQzaJRY/bkPOXyKx5EVup7qkaojECG6NLYswgktOZjaXecSAeCWiZwwiFf3/Y+O1HrauiE3FVsGxFg8c24rZg==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + cpu: [arm64] + os: [linux] + libc: [glibc] + + '@img/sharp-linux-arm@0.34.5': + resolution: {integrity: sha512-9dLqsvwtg1uuXBGZKsxem9595+ujv0sJ6Vi8wcTANSFpwV/GONat5eCkzQo/1O6zRIkh0m/8+5BjrRr7jDUSZw==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + cpu: [arm] + os: [linux] + libc: [glibc] + + '@img/sharp-linux-ppc64@0.34.5': + resolution: {integrity: sha512-7zznwNaqW6YtsfrGGDA6BRkISKAAE1Jo0QdpNYXNMHu2+0dTrPflTLNkpc8l7MUP5M16ZJcUvysVWWrMefZquA==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + cpu: [ppc64] + os: [linux] + libc: [glibc] + + '@img/sharp-linux-riscv64@0.34.5': + resolution: {integrity: sha512-51gJuLPTKa7piYPaVs8GmByo7/U7/7TZOq+cnXJIHZKavIRHAP77e3N2HEl3dgiqdD/w0yUfiJnII77PuDDFdw==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + cpu: [riscv64] + os: [linux] + libc: [glibc] + + '@img/sharp-linux-s390x@0.34.5': + resolution: {integrity: sha512-nQtCk0PdKfho3eC5MrbQoigJ2gd1CgddUMkabUj+rBevs8tZ2cULOx46E7oyX+04WGfABgIwmMC0VqieTiR4jg==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + cpu: [s390x] + os: [linux] + libc: [glibc] + + '@img/sharp-linux-x64@0.34.5': + resolution: {integrity: sha512-MEzd8HPKxVxVenwAa+JRPwEC7QFjoPWuS5NZnBt6B3pu7EG2Ge0id1oLHZpPJdn3OQK+BQDiw9zStiHBTJQQQQ==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + cpu: [x64] + os: [linux] + libc: [glibc] + + '@img/sharp-linuxmusl-arm64@0.34.5': + resolution: {integrity: sha512-fprJR6GtRsMt6Kyfq44IsChVZeGN97gTD331weR1ex1c1rypDEABN6Tm2xa1wE6lYb5DdEnk03NZPqA7Id21yg==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + cpu: [arm64] + os: [linux] + libc: [musl] + + '@img/sharp-linuxmusl-x64@0.34.5': + resolution: {integrity: sha512-Jg8wNT1MUzIvhBFxViqrEhWDGzqymo3sV7z7ZsaWbZNDLXRJZoRGrjulp60YYtV4wfY8VIKcWidjojlLcWrd8Q==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + cpu: [x64] + os: [linux] + libc: [musl] + + '@img/sharp-wasm32@0.34.5': + resolution: {integrity: sha512-OdWTEiVkY2PHwqkbBI8frFxQQFekHaSSkUIJkwzclWZe64O1X4UlUjqqqLaPbUpMOQk6FBu/HtlGXNblIs0huw==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + cpu: [wasm32] + + '@img/sharp-win32-arm64@0.34.5': + resolution: {integrity: sha512-WQ3AgWCWYSb2yt+IG8mnC6Jdk9Whs7O0gxphblsLvdhSpSTtmu69ZG1Gkb6NuvxsNACwiPV6cNSZNzt0KPsw7g==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + cpu: [arm64] + os: [win32] + + '@img/sharp-win32-ia32@0.34.5': + resolution: {integrity: sha512-FV9m/7NmeCmSHDD5j4+4pNI8Cp3aW+JvLoXcTUo0IqyjSfAZJ8dIUmijx1qaJsIiU+Hosw6xM5KijAWRJCSgNg==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + cpu: [ia32] + os: [win32] + + '@img/sharp-win32-x64@0.34.5': + resolution: {integrity: sha512-+29YMsqY2/9eFEiW93eqWnuLcWcufowXewwSNIT6UwZdUUCrM3oFjMWH/Z6/TMmb4hlFenmfAVbpWeup2jryCw==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + cpu: [x64] + os: [win32] + '@ioredis/commands@1.5.1': resolution: {integrity: sha512-JH8ZL/ywcJyR9MmJ5BNqZllXNZQqQbnVZOqpPQqE1vHiFgAw4NHbvE0FOduNU8IX9babitBT46571OnPTT0Zcw==} @@ -1170,6 +1372,61 @@ packages: '@emnapi/core': ^1.7.1 '@emnapi/runtime': ^1.7.1 + '@next/env@16.2.6': + resolution: {integrity: sha512-gd8HoHN4ufj73WmR3JmVolrpJR47ILK6LouP5xElPglaVxir6e1a7VzvTvDWkOoPXT9rkkTzyCxBu4yeZfZwcw==} + + '@next/swc-darwin-arm64@16.2.6': + resolution: {integrity: sha512-ZJGkkcNfYgrrMkqOdZ7zoLa1TOy0qpcMfk/z4Mh/FKUz40gVO+HNQWqmLxf67Z5WB64DRp0dhEbyHfel+6sJUg==} + engines: {node: '>= 10'} + cpu: [arm64] + os: [darwin] + + '@next/swc-darwin-x64@16.2.6': + resolution: {integrity: sha512-v/YLBHIY132Ced3puBJ7YJKw1lqsCrgcNo2aRJlCEyQrrCeRJlvGlnmxhPxNQI3KE3N1DN5r9TPNPvka3nq5RQ==} + engines: {node: '>= 10'} + cpu: [x64] + os: [darwin] + + '@next/swc-linux-arm64-gnu@16.2.6': + resolution: {integrity: sha512-RPOvqlYBbcQjkz9VQQDZ2T2bARIjXZV1KFlt+V2Mr6SW/e4I9fcKsaA0hdyf2FHoTlsV2xnBd5Y912rP/1Ce6w==} + engines: {node: '>= 10'} + cpu: [arm64] + os: [linux] + libc: [glibc] + + '@next/swc-linux-arm64-musl@16.2.6': + resolution: {integrity: sha512-URUTu1+dMkxJsPFgm+OeEvq9wf5sujw0EvgYy80TDGHTSLTnIHeqb0Eu8A3sC95IRgjejQL+kC4mw+4yPxiAXA==} + engines: {node: '>= 10'} + cpu: [arm64] + os: [linux] + libc: [musl] + + '@next/swc-linux-x64-gnu@16.2.6': + resolution: {integrity: sha512-DOj182mPV8G3UkrayLoREM5YEYI+Dk5wv7Ox9xl1fFibAELEsFD0lDPfHIeILlutMMfdyhlzYPELG3peuKaurw==} + engines: {node: '>= 10'} + cpu: [x64] + os: [linux] + libc: [glibc] + + '@next/swc-linux-x64-musl@16.2.6': + resolution: {integrity: sha512-HKQ5SP/V/ub73UvF7n/zeJlxk2kLmtL7Wzrg4WfmkjmNos5onJ2tKu7yZOPdL18A6Svfn3max29ym+ry7NkK4g==} + engines: {node: '>= 10'} + cpu: [x64] + os: [linux] + libc: [musl] + + '@next/swc-win32-arm64-msvc@16.2.6': + resolution: {integrity: sha512-LZXpTlPyS5v7HhSmnvsLGP3iIYgYOBnc8r8ArlT55sGHV89bR2HlDdBjWQ+PY6SJMmk8TuVGFuxalnP3k/0Dwg==} + engines: {node: '>= 10'} + cpu: [arm64] + os: [win32] + + '@next/swc-win32-x64-msvc@16.2.6': + resolution: {integrity: sha512-F0+4i0h9J6C4eE3EAPWsoCk7UW/dbzOjyzxY0qnDUOYFu6FFmdZ6l97/XdV3/Nz3VYyO7UWjyEJUXkGqcoXfMA==} + engines: {node: '>= 10'} + cpu: [x64] + os: [win32] + '@nodelib/fs.scandir@2.1.5': resolution: {integrity: sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==} engines: {node: '>= 8'} @@ -2424,6 +2681,9 @@ packages: peerDependencies: eslint: ^9.0.0 || ^10.0.0 + '@swc/helpers@0.5.15': + resolution: {integrity: sha512-JQ5TuMi45Owi4/BIMAJBoSQoOJu12oOk/gADqlcUL9JEdHB8vyjUSsxqeNXnmXHjYKMi2WcYtezGEEhqUI/E2g==} + '@turbo/darwin-64@2.9.12': resolution: {integrity: sha512-eu3eFRmE9NjgZ0wPdRJ44l+LGSeIky+tz5ZQd8zQkw/Yqi+BM7wq+8nbabeoiVUcICi/IZweMOKl/MCmkrd1+g==} cpu: [x64] @@ -2601,6 +2861,14 @@ packages: '@types/node@25.7.0': resolution: {integrity: sha512-z+pdZyxE+RTQE9AcboAZCb4otwcrvgHD+GlBpPgn0emDVt0ohrTMhAwlr2Wd9nZ+nihhYFxO2pThz3C5qSu2Eg==} + '@types/react-dom@19.2.3': + resolution: {integrity: sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==} + peerDependencies: + '@types/react': ^19.2.0 + + '@types/react@19.2.15': + resolution: {integrity: sha512-eRwcGNHve+E8qtEQSSRl6urh+rFop4v8gm6O8rGv25CodbvFdLjA1vVQ1KkiFE0w0UPOnb8tDiFKL5lp0rtY5Q==} + '@types/resolve@1.20.2': resolution: {integrity: sha512-60BCwRFOZCQhDncwQdxxeOEEkbc5dIMccYLwbxsS4TUNeVECQ/pBJ0j09mrHOl/JJvpRPGwO9SvE4nR2Nb/a4Q==} @@ -3210,6 +3478,9 @@ packages: resolution: {integrity: sha512-GfisEZEJvzKrmGWkvfhgzcz/BllN1USeqD2V6tg14OAOgaCD2Z/PUEuxnAZ/nPvmaHRG7a8y77p1T/IRQ4D1Hw==} engines: {node: '>=4'} + client-only@0.0.1: + resolution: {integrity: sha512-IV3Ou0jSMzZrd3pZ48nLkT9DA7Ag1pnPzaiQhpW7c3RbcqqzvzzVu+L8gfqMp/8IM2MQtSiqaCxrrcfu8I8rMA==} + cliui@9.0.1: resolution: {integrity: sha512-k7ndgKhwoQveBL+/1tqGJYNz097I7WOvwbmmU2AR5+magtbjPWQTS1C5vzGkBC8Ym8UWRzfKUzUUqFLypY4Q+w==} engines: {node: '>=20'} @@ -4967,6 +5238,27 @@ packages: resolution: {integrity: sha512-8Ofs/AUQh8MaEcrlq5xOX0CQ9ypTF5dl78mjlMNfOK08fzpgTHQRQPBxcPlEtIw0yRpws+Zo/3r+5WRby7u3Gg==} engines: {node: '>= 0.6'} + next@16.2.6: + resolution: {integrity: sha512-qOVgKJg1+At15NpeUP+eJgCHvTCgXsogweq87Ri/Ix7PkqQHg4sdaXmSFqKlgaIXE4kW0g25LE68W87UANlHtw==} + engines: {node: '>=20.9.0'} + hasBin: true + peerDependencies: + '@opentelemetry/api': ^1.1.0 + '@playwright/test': ^1.51.1 + babel-plugin-react-compiler: '*' + react: ^18.2.0 || 19.0.0-rc-de68d2f4-20241204 || ^19.0.0 + react-dom: ^18.2.0 || 19.0.0-rc-de68d2f4-20241204 || ^19.0.0 + sass: ^1.3.0 + peerDependenciesMeta: + '@opentelemetry/api': + optional: true + '@playwright/test': + optional: true + babel-plugin-react-compiler: + optional: true + sass: + optional: true + nitropack@2.13.4: resolution: {integrity: sha512-tX7bT6zxNeMwkc6hxHiZeUoTOjVrcjoh1Z3cmxOlodIqjl4HISgqfGOmkWSayky3Nv9Z5+KQH52F8nmXJY5AAA==} engines: {node: ^20.19.0 || >=22.12.0} @@ -5417,6 +5709,10 @@ packages: postcss-value-parser@4.2.0: resolution: {integrity: sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ==} + postcss@8.4.31: + resolution: {integrity: sha512-PS08Iboia9mts/2ygV3eLpY5ghnUcfLV/EXTOW1E2qYxJKGGBUtNjN76FYHnMs36RmARn41bC0AZmn+rR0OVpQ==} + engines: {node: ^10 || ^12 || >=14} + postcss@8.5.14: resolution: {integrity: sha512-SoSL4+OSEtR99LHFZQiJLkT59C5B1amGO1NzTwj7TT1qCUgUO6hxOvzkOYxD+vMrXBM3XJIKzokoERdqQq/Zmg==} engines: {node: ^10 || ^12 || >=14} @@ -5484,6 +5780,15 @@ packages: rc9@3.0.1: resolution: {integrity: sha512-gMDyleLWVE+i6Sgtc0QbbY6pEKqYs97NGi6isHQPqYlLemPoO8dxQ3uGi0f4NiP98c+jMW6cG1Kx9dDwfvqARQ==} + react-dom@19.2.6: + resolution: {integrity: sha512-0prMI+hvBbPjsWnxDLxlCGyM8PN6UuWjEUCYmZhO67xIV9Xasa/r/vDnq+Xyq4Lo27g8QSbO5YzARu0D1Sps3g==} + peerDependencies: + react: ^19.2.6 + + react@19.2.6: + resolution: {integrity: sha512-sfWGGfavi0xr8Pg0sVsyHMAOziVYKgPLNrS7ig+ivMNb3wbCBw3KxtflsGBAwD3gYQlE/AEZsTLgToRrSCjb0Q==} + engines: {node: '>=0.10.0'} + readable-stream@2.3.8: resolution: {integrity: sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA==} @@ -5637,6 +5942,9 @@ packages: resolution: {integrity: sha512-6R3J5M4AcbtLUdZmRv2SygeVaM7IhrLXu9BmnOGmmACak8fiUtOsYNWUS4uK7upbmHIBbLBeFeI//477BKLBzA==} engines: {node: '>=11.0.0'} + scheduler@0.27.0: + resolution: {integrity: sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==} + scslre@0.3.0: resolution: {integrity: sha512-3A6sD0WYP7+QrjbfNA2FN3FsOaGGFoekCVgTyypy53gPxhbkCIjtO6YWgdrfM+n/8sI8JeXZOIxsHjMTNxQ4nQ==} engines: {node: ^14.0.0 || >=16.0.0} @@ -5675,6 +5983,10 @@ packages: setprototypeof@1.2.0: resolution: {integrity: sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==} + sharp@0.34.5: + resolution: {integrity: sha512-Ou9I5Ft9WNcCbXrU9cMgPBcCK8LiwLqcbywW3t4oDV37n1pzpuNLsYiAV8eODnjbtQlSDwZ2cUEeQz4E54Hltg==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + shebang-command@2.0.0: resolution: {integrity: sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==} engines: {node: '>=8'} @@ -5847,6 +6159,19 @@ packages: structured-clone-es@2.0.0: resolution: {integrity: sha512-5UuAHmBLXYPCl22xWJrFuGmIhBKQzxISPVz6E7nmTmTcAOpUzlbjKJsRrCE4vADmMQ0dzeCnlWn9XufnAGf76Q==} + styled-jsx@5.1.6: + resolution: {integrity: sha512-qSVyDTeMotdvQYoHWLNGwRFJHC+i+ZvdBRYosOFgC+Wg1vx4frN2/RG/NA7SYqqvKNLf39P2LSRA2pu6n0XYZA==} + engines: {node: '>= 12.0.0'} + peerDependencies: + '@babel/core': '*' + babel-plugin-macros: '*' + react: '>= 16.8.0 || 17.x.x || ^18.0.0-0 || ^19.0.0-0' + peerDependenciesMeta: + '@babel/core': + optional: true + babel-plugin-macros: + optional: true + stylehacks@7.0.11: resolution: {integrity: sha512-iODNfhXVLqc5LADs+Y6Oh5wJuK5ZcHbVng8aiK3y9pjMQdc5hLrBW0eFU6FtnpNrE6PoEg/MmFTU4waotj5WNg==} engines: {node: ^18.12.0 || ^20.9.0 || >=22.0} @@ -7239,6 +7564,103 @@ snapshots: '@iconify/types': 2.0.0 import-meta-resolve: 4.2.0 + '@img/colour@1.1.0': + optional: true + + '@img/sharp-darwin-arm64@0.34.5': + optionalDependencies: + '@img/sharp-libvips-darwin-arm64': 1.2.4 + optional: true + + '@img/sharp-darwin-x64@0.34.5': + optionalDependencies: + '@img/sharp-libvips-darwin-x64': 1.2.4 + optional: true + + '@img/sharp-libvips-darwin-arm64@1.2.4': + optional: true + + '@img/sharp-libvips-darwin-x64@1.2.4': + optional: true + + '@img/sharp-libvips-linux-arm64@1.2.4': + optional: true + + '@img/sharp-libvips-linux-arm@1.2.4': + optional: true + + '@img/sharp-libvips-linux-ppc64@1.2.4': + optional: true + + '@img/sharp-libvips-linux-riscv64@1.2.4': + optional: true + + '@img/sharp-libvips-linux-s390x@1.2.4': + optional: true + + '@img/sharp-libvips-linux-x64@1.2.4': + optional: true + + '@img/sharp-libvips-linuxmusl-arm64@1.2.4': + optional: true + + '@img/sharp-libvips-linuxmusl-x64@1.2.4': + optional: true + + '@img/sharp-linux-arm64@0.34.5': + optionalDependencies: + '@img/sharp-libvips-linux-arm64': 1.2.4 + optional: true + + '@img/sharp-linux-arm@0.34.5': + optionalDependencies: + '@img/sharp-libvips-linux-arm': 1.2.4 + optional: true + + '@img/sharp-linux-ppc64@0.34.5': + optionalDependencies: + '@img/sharp-libvips-linux-ppc64': 1.2.4 + optional: true + + '@img/sharp-linux-riscv64@0.34.5': + optionalDependencies: + '@img/sharp-libvips-linux-riscv64': 1.2.4 + optional: true + + '@img/sharp-linux-s390x@0.34.5': + optionalDependencies: + '@img/sharp-libvips-linux-s390x': 1.2.4 + optional: true + + '@img/sharp-linux-x64@0.34.5': + optionalDependencies: + '@img/sharp-libvips-linux-x64': 1.2.4 + optional: true + + '@img/sharp-linuxmusl-arm64@0.34.5': + optionalDependencies: + '@img/sharp-libvips-linuxmusl-arm64': 1.2.4 + optional: true + + '@img/sharp-linuxmusl-x64@0.34.5': + optionalDependencies: + '@img/sharp-libvips-linuxmusl-x64': 1.2.4 + optional: true + + '@img/sharp-wasm32@0.34.5': + dependencies: + '@emnapi/runtime': 1.10.0 + optional: true + + '@img/sharp-win32-arm64@0.34.5': + optional: true + + '@img/sharp-win32-ia32@0.34.5': + optional: true + + '@img/sharp-win32-x64@0.34.5': + optional: true + '@ioredis/commands@1.5.1': {} '@isaacs/cliui@8.0.2': @@ -7343,6 +7765,32 @@ snapshots: '@tybys/wasm-util': 0.10.2 optional: true + '@next/env@16.2.6': {} + + '@next/swc-darwin-arm64@16.2.6': + optional: true + + '@next/swc-darwin-x64@16.2.6': + optional: true + + '@next/swc-linux-arm64-gnu@16.2.6': + optional: true + + '@next/swc-linux-arm64-musl@16.2.6': + optional: true + + '@next/swc-linux-x64-gnu@16.2.6': + optional: true + + '@next/swc-linux-x64-musl@16.2.6': + optional: true + + '@next/swc-win32-arm64-msvc@16.2.6': + optional: true + + '@next/swc-win32-x64-msvc@16.2.6': + optional: true + '@nodelib/fs.scandir@2.1.5': dependencies: '@nodelib/fs.stat': 2.0.5 @@ -8347,6 +8795,10 @@ snapshots: estraverse: 5.3.0 picomatch: 4.0.4 + '@swc/helpers@0.5.15': + dependencies: + tslib: 2.8.1 + '@turbo/darwin-64@2.9.12': optional: true @@ -8535,6 +8987,14 @@ snapshots: dependencies: undici-types: 7.21.0 + '@types/react-dom@19.2.3(@types/react@19.2.15)': + dependencies: + '@types/react': 19.2.15 + + '@types/react@19.2.15': + dependencies: + csstype: 3.2.3 + '@types/resolve@1.20.2': {} '@types/trusted-types@2.0.7': @@ -9217,6 +9677,8 @@ snapshots: dependencies: escape-string-regexp: 1.0.5 + client-only@0.0.1: {} + cliui@9.0.1: dependencies: string-width: 7.2.0 @@ -11202,6 +11664,31 @@ snapshots: negotiator@1.0.0: {} + next@16.2.6(@playwright/test@1.60.0)(react-dom@19.2.6(react@19.2.6))(react@19.2.6): + dependencies: + '@next/env': 16.2.6 + '@swc/helpers': 0.5.15 + baseline-browser-mapping: 2.10.29 + caniuse-lite: 1.0.30001792 + postcss: 8.4.31 + react: 19.2.6 + react-dom: 19.2.6(react@19.2.6) + styled-jsx: 5.1.6(react@19.2.6) + optionalDependencies: + '@next/swc-darwin-arm64': 16.2.6 + '@next/swc-darwin-x64': 16.2.6 + '@next/swc-linux-arm64-gnu': 16.2.6 + '@next/swc-linux-arm64-musl': 16.2.6 + '@next/swc-linux-x64-gnu': 16.2.6 + '@next/swc-linux-x64-musl': 16.2.6 + '@next/swc-win32-arm64-msvc': 16.2.6 + '@next/swc-win32-x64-msvc': 16.2.6 + '@playwright/test': 1.60.0 + sharp: 0.34.5 + transitivePeerDependencies: + - '@babel/core' + - babel-plugin-macros + nitropack@2.13.4(oxc-parser@0.128.0)(rolldown@1.0.0)(srvx@0.11.15): dependencies: '@cloudflare/kv-asset-handler': 0.4.2 @@ -11936,6 +12423,12 @@ snapshots: postcss-value-parser@4.2.0: {} + postcss@8.4.31: + dependencies: + nanoid: 3.3.12 + picocolors: 1.1.1 + source-map-js: 1.2.1 + postcss@8.5.14: dependencies: nanoid: 3.3.12 @@ -11995,6 +12488,13 @@ snapshots: defu: 6.1.7 destr: 2.0.5 + react-dom@19.2.6(react@19.2.6): + dependencies: + react: 19.2.6 + scheduler: 0.27.0 + + react@19.2.6: {} + readable-stream@2.3.8: dependencies: core-util-is: 1.0.3 @@ -12184,6 +12684,8 @@ snapshots: sax@1.6.0: {} + scheduler@0.27.0: {} + scslre@0.3.0: dependencies: '@eslint-community/regexpp': 4.12.2 @@ -12234,6 +12736,38 @@ snapshots: setprototypeof@1.2.0: {} + sharp@0.34.5: + dependencies: + '@img/colour': 1.1.0 + detect-libc: 2.1.2 + semver: 7.8.0 + optionalDependencies: + '@img/sharp-darwin-arm64': 0.34.5 + '@img/sharp-darwin-x64': 0.34.5 + '@img/sharp-libvips-darwin-arm64': 1.2.4 + '@img/sharp-libvips-darwin-x64': 1.2.4 + '@img/sharp-libvips-linux-arm': 1.2.4 + '@img/sharp-libvips-linux-arm64': 1.2.4 + '@img/sharp-libvips-linux-ppc64': 1.2.4 + '@img/sharp-libvips-linux-riscv64': 1.2.4 + '@img/sharp-libvips-linux-s390x': 1.2.4 + '@img/sharp-libvips-linux-x64': 1.2.4 + '@img/sharp-libvips-linuxmusl-arm64': 1.2.4 + '@img/sharp-libvips-linuxmusl-x64': 1.2.4 + '@img/sharp-linux-arm': 0.34.5 + '@img/sharp-linux-arm64': 0.34.5 + '@img/sharp-linux-ppc64': 0.34.5 + '@img/sharp-linux-riscv64': 0.34.5 + '@img/sharp-linux-s390x': 0.34.5 + '@img/sharp-linux-x64': 0.34.5 + '@img/sharp-linuxmusl-arm64': 0.34.5 + '@img/sharp-linuxmusl-x64': 0.34.5 + '@img/sharp-wasm32': 0.34.5 + '@img/sharp-win32-arm64': 0.34.5 + '@img/sharp-win32-ia32': 0.34.5 + '@img/sharp-win32-x64': 0.34.5 + optional: true + shebang-command@2.0.0: dependencies: shebang-regex: 3.0.0 @@ -12422,6 +12956,11 @@ snapshots: structured-clone-es@2.0.0: {} + styled-jsx@5.1.6(react@19.2.6): + dependencies: + client-only: 0.0.1 + react: 19.2.6 + stylehacks@7.0.11(postcss@8.5.14): dependencies: browserslist: 4.28.2 @@ -12563,8 +13102,7 @@ snapshots: - oxc-resolver - vue-tsc - tslib@2.8.1: - optional: true + tslib@2.8.1: {} tsnapi@0.3.3(vitest@4.1.6(@types/node@25.7.0)(vite@8.0.12(@types/node@25.7.0)(esbuild@0.28.0)(jiti@2.7.0)(terser@5.47.1)(tsx@4.21.0)(yaml@2.8.4))): dependencies: diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index 85c8623..d0f179c 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -1,6 +1,7 @@ allowBuilds: '@parcel/watcher': false esbuild: true + sharp: false simple-git-hooks: true unrs-resolver: true @@ -66,7 +67,10 @@ catalogs: vitepress: ^2.0.0-alpha.17 vitepress-plugin-mermaid: ^2.0.17 frontend: + next: ^16.2.6 preact: ^10.29.1 + react: ^19.2.6 + react-dom: ^19.2.6 inlined: '@antfu/utils': ^9.3.0 ua-parser-modern: ^0.1.1 @@ -76,4 +80,6 @@ catalogs: vitest: ^4.1.6 types: '@types/node': ^25.7.0 + '@types/react': ^19.2.15 + '@types/react-dom': ^19.2.3 '@types/ws': ^8.18.1 diff --git a/tests/e2e/next-runtime-snapshot-dev.spec.ts b/tests/e2e/next-runtime-snapshot-dev.spec.ts new file mode 100644 index 0000000..be9dc1a --- /dev/null +++ b/tests/e2e/next-runtime-snapshot-dev.spec.ts @@ -0,0 +1,44 @@ +import { expect, test } from '@playwright/test' + +const BASE = 'http://localhost:9899/__next-runtime-snapshot/' + +test.describe('next-runtime-snapshot (dev)', () => { + test.beforeEach(async ({ page }) => { + await page.goto(BASE) + await expect(page.locator('h1')).toHaveText('Next Runtime Snapshot') + }) + + test('system card populates with node + platform info', async ({ page }) => { + const systemCard = page.locator('.card').filter({ hasText: 'System' }) + await expect(systemCard.locator('.kv .v').first()).toContainText(/v\d+\.\d+/, { timeout: 10_000 }) + await expect(systemCard).toContainText(/pid/) + await expect(systemCard).toContainText(/cwd/) + }) + + test('memory card populates and refresh re-invokes the RPC', async ({ page }) => { + const memCard = page.locator('.card').filter({ hasText: 'Memory & Uptime' }) + await expect(memCard).toContainText(/heap used/i, { timeout: 10_000 }) + + const initialRss = await memCard.locator('.kv .v').nth(1).textContent() + expect(initialRss).toMatch(/\d+(?:\.\d+)?\s*MB/) + + await memCard.locator('button:has-text("Refresh")').click() + // After refresh the uptime row should still render — the call resolved. + await expect(memCard.locator('.kv .k').first()).toHaveText('uptime') + }) + + test('env filter triggers a query call', async ({ page }) => { + const envCard = page.locator('.card').filter({ hasText: 'Environment' }) + // Default pattern "NODE" should yield at least one row on most systems. + await expect(envCard.locator('.env-list')).toBeVisible({ timeout: 10_000 }) + + const input = envCard.locator('input') + await input.fill('___definitely_not_a_real_env_var___') + await expect(envCard.locator('.empty')).toBeVisible({ timeout: 5_000 }) + await expect(envCard.locator('.empty')).toContainText('No environment variables match') + }) + + test('status bar reports websocket backend', async ({ page }) => { + await expect(page.locator('.status code').first()).toHaveText('websocket', { timeout: 10_000 }) + }) +}) diff --git a/tests/e2e/next-runtime-snapshot-static.spec.ts b/tests/e2e/next-runtime-snapshot-static.spec.ts new file mode 100644 index 0000000..a1eba1b --- /dev/null +++ b/tests/e2e/next-runtime-snapshot-static.spec.ts @@ -0,0 +1,26 @@ +import { expect, test } from '@playwright/test' + +const BASE = 'http://127.0.0.1:9889/' + +// Static dumps only carry pre-computed `static` (and `query{snapshot:true}`) +// RPC results. The example's `system` function is `static` so it bakes +// into the dump; `memory` and `env` are live `query`s with no `snapshot`, +// so they don't render anything in static mode — the cards stay in their +// "Loading…" placeholder. + +test.describe('next-runtime-snapshot (static build)', () => { + test.beforeEach(async ({ page }) => { + await page.goto(BASE) + await expect(page.locator('h1')).toHaveText('Next Runtime Snapshot') + }) + + test('renders system info from the static RPC dump', async ({ page }) => { + const systemCard = page.locator('.card').filter({ hasText: 'System' }) + await expect(systemCard.locator('.kv .v').first()).toContainText(/v\d+\.\d+/, { timeout: 10_000 }) + await expect(systemCard).toContainText(/cwd/) + }) + + test('reports static backend in the status bar', async ({ page }) => { + await expect(page.locator('.status code').first()).toHaveText('static', { timeout: 10_000 }) + }) +}) diff --git a/turbo.json b/turbo.json index 676bba6..1b3617b 100644 --- a/turbo.json +++ b/turbo.json @@ -20,6 +20,11 @@ "dependsOn": ["devframe#build"], "outputs": ["dist/**"] }, + "next-runtime-snapshot-example#build": { + "outputLogs": "new-only", + "dependsOn": ["devframe#build"], + "outputs": ["dist/**"] + }, "files-inspector-example#cli:build": { "outputLogs": "new-only", "dependsOn": ["files-inspector-example#build"], @@ -30,6 +35,11 @@ "outputLogs": "new-only", "dependsOn": ["streaming-chat-example#build"], "outputs": ["dist/static/**"] + }, + "next-runtime-snapshot-example#cli:build": { + "outputLogs": "new-only", + "dependsOn": ["next-runtime-snapshot-example#build"], + "outputs": ["dist/static/**"] } } } diff --git a/vitest.config.ts b/vitest.config.ts index 4536eb2..391bf30 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -6,6 +6,7 @@ export default defineConfig({ 'packages/devframe', 'examples/files-inspector', 'examples/streaming-chat', + 'examples/next-runtime-snapshot', { test: { name: 'tests', From 071416f6a5445053d4125f06a8eec3ab8c2dab1e Mon Sep 17 00:00:00 2001 From: Anthony Fu Date: Fri, 22 May 2026 11:27:28 +0900 Subject: [PATCH 2/2] chore(examples): address Copilot review feedback on next-runtime-snapshot MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Invalid regex pattern in `env` RPC now matches nothing rather than silently widening to all keys (could leak vars the redaction heuristic doesn't catch). Adds a vitest case to cover. - Drop dead try/catch around `mountStaticHandler()` in test utils — it doesn't throw on missing distDir. - Replace `
` with `` children with `
` in the system + memory cards (invalid definition-list structure). - Add `aria-label` to the env filter input. - Coalesce `Locator.textContent()` to `''` in the Playwright dev spec. - Match the example's actual build script in the docs snippet (`mkdir -p dist` before `cp -r`). --- docs/guide/standalone-cli.md | 2 +- .../src/client/app/components/snapshot-env.tsx | 1 + .../client/app/components/snapshot-memory.tsx | 4 ++-- .../client/app/components/snapshot-system.tsx | 4 ++-- examples/next-runtime-snapshot/src/devframe.ts | 17 +++++++++++------ examples/next-runtime-snapshot/tests/_utils.ts | 12 ++++-------- .../tests/next-runtime-snapshot.test.ts | 9 +++++++++ tests/e2e/next-runtime-snapshot-dev.spec.ts | 2 +- 8 files changed, 31 insertions(+), 20 deletions(-) diff --git a/docs/guide/standalone-cli.md b/docs/guide/standalone-cli.md index c066844..d4276b3 100644 --- a/docs/guide/standalone-cli.md +++ b/docs/guide/standalone-cli.md @@ -123,7 +123,7 @@ export default { ```json [package.json] { "scripts": { - "build": "next build src/client && rm -rf dist/client && cp -r src/client/out dist/client" + "build": "next build src/client && rm -rf dist/client && mkdir -p dist && cp -r src/client/out dist/client" } } ``` 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 cc0935b..027c866 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 @@ -48,6 +48,7 @@ export function SnapshotEnv() { 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)" />
{snap === null &&

Loading…

} 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 bad829b..b2a31f8 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 @@ -55,7 +55,7 @@ export function SnapshotMemory() { {snap ? ( -
+
uptime {fmtUptime(snap.uptimeSeconds)} rss @@ -68,7 +68,7 @@ export function SnapshotMemory() { {fmtBytes(snap.memory.external)} array buffers {fmtBytes(snap.memory.arrayBuffers)} -
+ ) :

Loading…

} 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 dffcc18..6eee893 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 @@ -30,7 +30,7 @@ export function SnapshotSystem() {

System

{info ? ( -
+
node {info.node} platform @@ -41,7 +41,7 @@ export function SnapshotSystem() { {info.cwd} started {formatStartedAt(info.startedAt)} -
+ ) :

Loading…

} diff --git a/examples/next-runtime-snapshot/src/devframe.ts b/examples/next-runtime-snapshot/src/devframe.ts index 7b09b38..39748ee 100644 --- a/examples/next-runtime-snapshot/src/devframe.ts +++ b/examples/next-runtime-snapshot/src/devframe.ts @@ -117,17 +117,22 @@ export default defineDevframe({ pattern: v.string(), }), handler: ({ pattern, limit }): EnvSnapshot => { - let regex: RegExp | null = null - if (pattern) { + const keys = Object.keys(process.env).sort() + let matched: string[] + if (!pattern) { + matched = keys + } + else { try { - regex = new RegExp(pattern, 'i') + 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 { - regex = null + matched = [] } } - const keys = Object.keys(process.env).sort() - const matched = regex ? keys.filter(k => regex.test(k)) : keys const entries = matched.slice(0, limit).map(k => redact(k, process.env[k] ?? '')) return { entries, diff --git a/examples/next-runtime-snapshot/tests/_utils.ts b/examples/next-runtime-snapshot/tests/_utils.ts index a8a3501..adcac27 100644 --- a/examples/next-runtime-snapshot/tests/_utils.ts +++ b/examples/next-runtime-snapshot/tests/_utils.ts @@ -47,14 +47,10 @@ export async function startSnapshotServer(): Promise { const metaPath = `${basePath}${DEVTOOLS_CONNECTION_META_FILENAME}` app.use(metaPath, () => ({ backend: 'websocket', websocket: port })) - // SPA mount is conditional — RPC-only tests don't need the built dist. - // Tests that fetch HTML should run `pnpm run build` first. - try { - mountStaticHandler(app, basePath, resolve(distDir)) - } - catch { - // No dist yet; that's fine for RPC-only tests. - } + // Mount the static handler unconditionally — it only stat()s on + // request, so a missing dist just produces 404s for HTML routes. + // RPC-only tests don't fetch the SPA, so they're unaffected. + mountStaticHandler(app, basePath, resolve(distDir)) const server = await startHttpAndWs({ context: ctx, 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 df99ca0..8a048d7 100644 --- a/examples/next-runtime-snapshot/tests/next-runtime-snapshot.test.ts +++ b/examples/next-runtime-snapshot/tests/next-runtime-snapshot.test.ts @@ -107,6 +107,15 @@ describe('next-runtime-snapshot (example)', () => { expect(snap.total).toBeGreaterThanOrEqual(snap.entries.length) }) + it('matches nothing on an invalid regex pattern', async () => { + const rpc = bootRpc(server.port) + // '[' is unterminated — `new RegExp('[', 'i')` throws SyntaxError. + const snap = await rpc.$call('next-runtime-snapshot:env', { pattern: '[' }) as EnvSnapshot + expect(snap.entries).toEqual([]) + expect(snap.total).toBe(0) + expect(snap.pattern).toBe('[') + }) + it('respects the limit cap on the entries slice', async () => { const rpc = bootRpc(server.port) const small = await rpc.$call('next-runtime-snapshot:env', { pattern: '', limit: 2 }) as EnvSnapshot diff --git a/tests/e2e/next-runtime-snapshot-dev.spec.ts b/tests/e2e/next-runtime-snapshot-dev.spec.ts index be9dc1a..f88884b 100644 --- a/tests/e2e/next-runtime-snapshot-dev.spec.ts +++ b/tests/e2e/next-runtime-snapshot-dev.spec.ts @@ -19,7 +19,7 @@ test.describe('next-runtime-snapshot (dev)', () => { const memCard = page.locator('.card').filter({ hasText: 'Memory & Uptime' }) await expect(memCard).toContainText(/heap used/i, { timeout: 10_000 }) - const initialRss = await memCard.locator('.kv .v').nth(1).textContent() + const initialRss = (await memCard.locator('.kv .v').nth(1).textContent()) ?? '' expect(initialRss).toMatch(/\d+(?:\.\d+)?\s*MB/) await memCard.locator('button:has-text("Refresh")').click()