From 2e5b0b72ea07739032867ad9e9067f3f5641290f Mon Sep 17 00:00:00 2001 From: "Anthony Fu (via agent)" Date: Thu, 20 Aug 2026 08:04:56 +0000 Subject: [PATCH 1/3] feat: add self-contained starter template Add a top-level starter/ folder: a minimal devframe integration with a vanilla-TS Vite client, single + hub playgrounds, and unit + e2e tests. - devframe.ts + rpc/ define the DevframeDefinition and two RPC functions (get-info static, list-items query+snapshot). - src/client/ is a dependency-light vanilla-TS SPA (hand-rolled DOM helpers, no UI framework) served by the CLI (bin.mjs) or built as a static deploy. - playground/single/ dev-serves the SPA with Vite HMR while devframeViteBridge answers RPC at the devframe's own base path - documented why the two can't share one base. - playground/hub/ mounts the devframe as a dock inside a @devframes/hub host. - test/ (vitest, RPC over a real WebSocket) and e2e/ (playwright, against the single playground) cover both layers. The starter pins real dependency versions instead of the monorepo's catalog:/workspace:* specifiers, so it's copy-paste ready outside the workspace; pnpm still links devframe/@devframes/* to the local packages during in-repo development. A root bump.config.ts + scripts/sync-starter-version.ts keep those versions in sync automatically whenever `bumpp -r` releases the monorepo. Wires the starter into the monorepo's tooling: pnpm-workspace.yaml, vitest.config.ts (root test aggregation), turbo.json (explicit devframe-starter#build/#cli:build task entries - this repo has no generic build task, every package needs one), and verify-typecheck-coverage.ts. Documented in AGENTS.md. This PR was created with the help of an agent. --- AGENTS.md | 2 + bump.config.ts | 10 ++ pnpm-lock.yaml | 124 +++++++++++++++++ pnpm-workspace.yaml | 5 + scripts/sync-starter-version.ts | 36 +++++ scripts/verify-typecheck-coverage.ts | 2 +- starter/.gitignore | 9 ++ starter/README.md | 36 +++++ starter/bin.mjs | 14 ++ starter/e2e/app.test.ts | 33 +++++ starter/e2e/fixtures/dir1/.gitkeep | 0 starter/e2e/fixtures/file1.txt | 1 + starter/eslint.config.js | 19 +++ starter/package.json | 59 ++++++++ starter/playground/hub/index.html | 16 +++ starter/playground/hub/vite.config.ts | 43 ++++++ starter/playground/single/index.html | 12 ++ starter/playground/single/main.ts | 21 +++ starter/playground/single/vite.config.ts | 36 +++++ starter/playwright.config.ts | 39 ++++++ starter/src/client/app.ts | 109 +++++++++++++++ starter/src/client/index.html | 13 ++ starter/src/client/main.ts | 16 +++ starter/src/client/shims.d.ts | 2 + starter/src/client/styles.css | 168 +++++++++++++++++++++++ starter/src/client/vite.config.ts | 14 ++ starter/src/devframe.ts | 40 ++++++ starter/src/rpc/functions/get-info.ts | 19 +++ starter/src/rpc/functions/list-items.ts | 25 ++++ starter/src/rpc/index.ts | 15 ++ starter/src/shared/base-path.ts | 6 + starter/test/rpc.test.ts | 70 ++++++++++ starter/tsconfig.json | 27 ++++ starter/vitest.config.ts | 10 ++ turbo.json | 11 ++ vitest.config.ts | 1 + 36 files changed, 1062 insertions(+), 1 deletion(-) create mode 100644 bump.config.ts create mode 100644 scripts/sync-starter-version.ts create mode 100644 starter/.gitignore create mode 100644 starter/README.md create mode 100755 starter/bin.mjs create mode 100644 starter/e2e/app.test.ts create mode 100644 starter/e2e/fixtures/dir1/.gitkeep create mode 100644 starter/e2e/fixtures/file1.txt create mode 100644 starter/eslint.config.js create mode 100644 starter/package.json create mode 100644 starter/playground/hub/index.html create mode 100644 starter/playground/hub/vite.config.ts create mode 100644 starter/playground/single/index.html create mode 100644 starter/playground/single/main.ts create mode 100644 starter/playground/single/vite.config.ts create mode 100644 starter/playwright.config.ts create mode 100644 starter/src/client/app.ts create mode 100644 starter/src/client/index.html create mode 100644 starter/src/client/main.ts create mode 100644 starter/src/client/shims.d.ts create mode 100644 starter/src/client/styles.css create mode 100644 starter/src/client/vite.config.ts create mode 100644 starter/src/devframe.ts create mode 100644 starter/src/rpc/functions/get-info.ts create mode 100644 starter/src/rpc/functions/list-items.ts create mode 100644 starter/src/rpc/index.ts create mode 100644 starter/src/shared/base-path.ts create mode 100644 starter/test/rpc.test.ts create mode 100644 starter/tsconfig.json create mode 100644 starter/vitest.config.ts diff --git a/AGENTS.md b/AGENTS.md index 02e73dc0..42f10558 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -36,6 +36,8 @@ The `pnpm test` script intentionally runs `build` first so `tsnapi` snapshots co Ahead-of-time build artifacts that live under `src/` - the shadow-root stylesheets in `packages/hub-ui/src/client/.generated/` and `packages/json-render-ui/src/.generated/` - are **generated, not committed** (`.generated` is gitignored). Each owning package builds its own with `pnpm run build:css`, and three things guarantee the file is on disk before anything imports it: the root `postinstall` runs `turbo run build:css`, the Turbo `typecheck` task depends on both `build:css` tasks, and each package's `build` script chains `build:css` first. A new generated-under-`src` artifact follows the same shape - its own build script, declared `outputs` in `turbo.json`, and a `typecheck` dependency - rather than being checked in, since a minified single-line blob conflicts on every concurrent edit. +**`starter/`** is the top-level, self-contained template for creating a new devframe integration (Vanilla TS, Vite client, playgrounds, tests). It uses real versions in its `package.json` (no catalogs, no `workspace:*`) so it's copy-paste ready for users. Pnpm links its `devframe`/`@devframes/*` dependencies to the local workspace copies during development. When `bumpp -r` bumps the repo versions, `bumpp.config.ts` runs `scripts/sync-starter-version.ts` to update the starter's dependencies to match. + `pnpm knip` finds unused files, dependencies, and exports across every workspace (config in `knip.jsonc`). It runs against source directly - no prior build needed. Most workspaces need no configuration; `knip.jsonc` only carries per-workspace overrides for cases knip's defaults can't infer on their own: a package's non-`index.ts` `exports` subpaths (knip's package.json→`dist`→`src` source mapping needs a workspace `tsconfig.json` `outDir`, which conflicts with this repo's cross-workspace `src/*.ts` imports, so multi-entry packages list their `exports`-mapped entry files explicitly instead - keep that list in sync with each `tsdown.config.ts`), config files knip's plugins don't discover in a nested location (a Next.js app rooted below the workspace root, `storybook-solidjs-vite` not matching the Storybook plugin trigger), and dependencies referenced dynamically outside its static import graph (icon collections consumed by UnoCSS at build time, plugin packages loaded via a runtime `import()` string). Prefer fixing the underlying gap or a scoped `ignoreDependencies`/`entry` override over a blanket `ignore`. ## Conventions diff --git a/bump.config.ts b/bump.config.ts new file mode 100644 index 00000000..3ae364fc --- /dev/null +++ b/bump.config.ts @@ -0,0 +1,10 @@ +import { defineConfig } from 'bumpp' +import { syncStarterVersion } from './scripts/sync-starter-version.ts' + +export default defineConfig({ + // `starter/` pins real `devframe`/`@devframes/*` versions (it's a + // copy-paste-ready template, not a workspace member consuming + // `catalog:`/`workspace:*`), so `bumpp -r` can't reach it on its own - + // sync it here, before the version-bump commit is made. + execute: operation => syncStarterVersion(operation.state.newVersion), +}) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index be89ef3e..07fd2dcd 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -2411,6 +2411,52 @@ importers: specifier: catalog:testing version: 4.1.10(@types/node@26.2.0)(vite@8.2.1(@types/node@26.2.0)(esbuild@0.28.0)(jiti@2.7.0)(terser@5.47.1)(tsx@4.23.12)(yaml@2.9.0)) + starter: + dependencies: + '@devframes/hub': + specifier: ^0.9.4 + version: 0.9.4(crossws@0.4.10(srvx@0.12.4))(devframe@packages+devframe) + '@devframes/hub-ui': + specifier: ^0.9.4 + version: 0.9.4(@devframes/hub@0.9.4(crossws@0.4.10(srvx@0.12.4))(devframe@packages+devframe))(@devframes/json-render@0.9.4(@devframes/hub@0.9.4(crossws@0.4.10(srvx@0.12.4))(devframe@packages+devframe))(devframe@packages+devframe))(devframe@packages+devframe) + '@devframes/vite': + specifier: ^0.9.4 + version: 0.9.4(@devframes/hub-ui@0.9.4(@devframes/hub@0.9.4(crossws@0.4.10(srvx@0.12.4))(devframe@packages+devframe))(@devframes/json-render@0.9.4(@devframes/hub@0.9.4(crossws@0.4.10(srvx@0.12.4))(devframe@packages+devframe))(devframe@packages+devframe))(devframe@packages+devframe))(@devframes/hub@0.9.4(crossws@0.4.10(srvx@0.12.4))(devframe@packages+devframe))(devframe@packages+devframe)(vite@8.2.1(@types/node@26.2.0)(esbuild@0.28.0)(jiti@2.7.0)(terser@5.47.1)(tsx@4.23.12)(yaml@2.9.0)) + devframe: + specifier: workspace:* + version: link:../packages/devframe + devDependencies: + '@antfu/eslint-config': + specifier: ^9.3.0 + version: 9.3.0(@typescript-eslint/typescript-estree@8.67.0(supports-color@10.2.2)(typescript@6.0.3))(@typescript-eslint/utils@8.67.0(eslint@10.8.1(jiti@2.7.0)(supports-color@10.2.2))(supports-color@10.2.2)(typescript@6.0.3))(@vue/compiler-sfc@3.5.41)(eslint@10.8.1(jiti@2.7.0)(supports-color@10.2.2))(supports-color@10.2.2)(typescript@6.0.3)(vitest@4.1.10(@types/node@26.2.0)(vite@8.2.1(@types/node@26.2.0)(esbuild@0.28.0)(jiti@2.7.0)(terser@5.47.1)(tsx@4.23.12)(yaml@2.9.0))) + '@playwright/test': + specifier: ^1.62.1 + version: 1.62.1 + '@types/node': + specifier: ^26.2.0 + version: 26.2.0 + '@types/ws': + specifier: ^8.18.1 + version: 8.18.1 + bumpp: + specifier: ^12.2.1 + version: 12.2.1 + eslint: + specifier: ^10.8.1 + version: 10.8.1(jiti@2.7.0)(supports-color@10.2.2) + typescript: + specifier: ^6.0.3 + version: 6.0.3 + vite: + specifier: ^8.2.1 + version: 8.2.1(@types/node@26.2.0)(esbuild@0.28.0)(jiti@2.7.0)(terser@5.47.1)(tsx@4.23.12)(yaml@2.9.0) + vitest: + specifier: ^4.1.10 + version: 4.1.10(@types/node@26.2.0)(vite@8.2.1(@types/node@26.2.0)(esbuild@0.28.0)(jiti@2.7.0)(terser@5.47.1)(tsx@4.23.12)(yaml@2.9.0)) + ws: + specifier: ^8.21.3 + version: 8.21.3 + storybook: dependencies: '@antfu/design': @@ -2775,11 +2821,26 @@ packages: '@colordx/core@5.4.3': resolution: {integrity: sha512-kIxYSfA5T8HXjav55UaaH/o/cKivF6jCCGIb8eqtcsfI46wsvlSiT8jMDyrl779qLec3c2c2oHBZo4oAhvbjrQ==} + '@devframes/hub-ui@0.9.4': + resolution: {integrity: sha512-KKg0w+LHJd6IZOiwQd5oX2URT8xBw/zmyWGCwnAfDjagLPhgYhwWTsviRYsFYl+odsm08IdnKT6tACzNqUlt7A==} + peerDependencies: + '@devframes/hub': 0.9.4 + '@devframes/json-render': 0.9.4 + devframe: workspace:* + peerDependenciesMeta: + '@devframes/json-render': + optional: true + '@devframes/hub@0.7.14': resolution: {integrity: sha512-Ym3YHkBnpdwhPw7y4YgURZYntQPShb9raRfo60D1Yk4jb1OlnL2GGHw6pt4iLZqHL4vp1P4T6YpBFPPVx4G38A==} peerDependencies: devframe: workspace:* + '@devframes/hub@0.9.4': + resolution: {integrity: sha512-Yn4V+HavKm/aTdZOPhT+bD7L1cCt/bYKTmXuh2do1ol6YwHaSl6+C1WZDxthl6HWcevpOEwwBwNa9kZAeMFkOQ==} + peerDependencies: + devframe: workspace:* + '@devframes/json-render@0.7.14': resolution: {integrity: sha512-Bo+IiRpEdceQjLr/tcFNXFYlBkSeUv7PWsFc4Fu5PLUyxnnqciLVyFjZz6IlEtKrBIA667L4QbQe5ZExlPhlPA==} peerDependencies: @@ -2789,6 +2850,30 @@ packages: '@devframes/hub': optional: true + '@devframes/json-render@0.9.4': + resolution: {integrity: sha512-G31AY9evtDehW9OYtmRhkzeFasYGV9XNZ1qZYh5GmiPYY88IZrqSBvyz+tYUca/Wx34JgrwL8vmGRycUuT8haQ==} + peerDependencies: + '@devframes/hub': 0.9.4 + devframe: workspace:* + peerDependenciesMeta: + '@devframes/hub': + optional: true + + '@devframes/vite@0.9.4': + resolution: {integrity: sha512-OYMHXBzBKtPc1rdYIPoYSPdFZL+B52YmjPkM/L6qSUI5AfqdtVyO87ydwgTbWkL8Q2HqUXXiSsR8FUfj6iUIDw==} + peerDependencies: + '@devframes/hub': 0.9.4 + '@devframes/hub-ui': 0.9.4 + devframe: workspace:* + vite: ^7.0.0 || ^8.0.0 + peerDependenciesMeta: + '@devframes/hub': + optional: true + '@devframes/hub-ui': + optional: true + vite: + optional: true + '@discoveryjs/discovery@1.0.0-beta.99': resolution: {integrity: sha512-Dy374TnEbEJpgNDogwHHeY9bMFwnNOvs5V8qGZmMyfn0irGsoKldb+wi6d5xY+9DZ9G3D6WJ9M/jKaB7bjvJqg==} engines: {node: '>=8.0.0'} @@ -11413,6 +11498,13 @@ snapshots: '@colordx/core@5.4.3': {} + '@devframes/hub-ui@0.9.4(@devframes/hub@0.9.4(crossws@0.4.10(srvx@0.12.4))(devframe@packages+devframe))(@devframes/json-render@0.9.4(@devframes/hub@0.9.4(crossws@0.4.10(srvx@0.12.4))(devframe@packages+devframe))(devframe@packages+devframe))(devframe@packages+devframe)': + dependencies: + '@devframes/hub': 0.9.4(crossws@0.4.10(srvx@0.12.4))(devframe@packages+devframe) + devframe: link:packages/devframe + optionalDependencies: + '@devframes/json-render': 0.9.4(@devframes/hub@0.9.4(crossws@0.4.10(srvx@0.12.4))(devframe@packages+devframe))(devframe@packages+devframe) + '@devframes/hub@0.7.14(devframe@packages+devframe)': dependencies: birpc: 4.1.0 @@ -11425,6 +11517,20 @@ snapshots: zigpty: 0.2.1 optional: true + '@devframes/hub@0.9.4(crossws@0.4.10(srvx@0.12.4))(devframe@packages+devframe)': + dependencies: + '@standard-schema/spec': 1.1.0 + destr: 2.0.5 + devframe: link:packages/devframe + h3: 2.0.1-rc.26(crossws@0.4.10(srvx@0.12.4)) + pathe: 2.0.3 + perfect-debounce: 2.1.0 + tinyexec: 1.3.0 + ufo: 1.6.4 + zigpty: 0.2.1 + transitivePeerDependencies: + - crossws + '@devframes/json-render@0.7.14(@devframes/hub@0.7.14(devframe@packages+devframe))(devframe@packages+devframe)': dependencies: '@json-render/core': 0.19.0(zod@4.4.3) @@ -11435,6 +11541,24 @@ snapshots: '@devframes/hub': 0.7.14(devframe@packages+devframe) optional: true + '@devframes/json-render@0.9.4(@devframes/hub@0.9.4(crossws@0.4.10(srvx@0.12.4))(devframe@packages+devframe))(devframe@packages+devframe)': + dependencies: + '@json-render/core': 0.19.0(zod@4.4.3) + devframe: link:packages/devframe + zod: 4.4.3 + optionalDependencies: + '@devframes/hub': 0.9.4(crossws@0.4.10(srvx@0.12.4))(devframe@packages+devframe) + optional: true + + '@devframes/vite@0.9.4(@devframes/hub-ui@0.9.4(@devframes/hub@0.9.4(crossws@0.4.10(srvx@0.12.4))(devframe@packages+devframe))(@devframes/json-render@0.9.4(@devframes/hub@0.9.4(crossws@0.4.10(srvx@0.12.4))(devframe@packages+devframe))(devframe@packages+devframe))(devframe@packages+devframe))(@devframes/hub@0.9.4(crossws@0.4.10(srvx@0.12.4))(devframe@packages+devframe))(devframe@packages+devframe)(vite@8.2.1(@types/node@26.2.0)(esbuild@0.28.0)(jiti@2.7.0)(terser@5.47.1)(tsx@4.23.12)(yaml@2.9.0))': + dependencies: + devframe: link:packages/devframe + pathe: 2.0.3 + optionalDependencies: + '@devframes/hub': 0.9.4(crossws@0.4.10(srvx@0.12.4))(devframe@packages+devframe) + '@devframes/hub-ui': 0.9.4(@devframes/hub@0.9.4(crossws@0.4.10(srvx@0.12.4))(devframe@packages+devframe))(@devframes/json-render@0.9.4(@devframes/hub@0.9.4(crossws@0.4.10(srvx@0.12.4))(devframe@packages+devframe))(devframe@packages+devframe))(devframe@packages+devframe) + vite: 8.2.1(@types/node@26.2.0)(esbuild@0.28.0)(jiti@2.7.0)(terser@5.47.1)(tsx@4.23.12)(yaml@2.9.0) + '@discoveryjs/discovery@1.0.0-beta.99': dependencies: '@discoveryjs/json-ext': 0.6.3 diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index 1c4bc77a..99db0ad7 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -16,6 +16,10 @@ minimumReleaseAgeExclude: - tsdown@0.22.14 - verkit@0.3.0 - structured-clone-es@2.0.1 + - '@devframes/hub-ui@0.9.4' + - '@devframes/hub@0.9.4' + - '@devframes/json-render@0.9.4' + - '@devframes/vite@0.9.4' shamefullyHoist: true shellEmulator: true strictPeerDependencies: false @@ -28,6 +32,7 @@ packages: - plugins/*/assets-pkg - services/* - examples/* + - starter - storybook - docs diff --git a/scripts/sync-starter-version.ts b/scripts/sync-starter-version.ts new file mode 100644 index 00000000..6f2e8976 --- /dev/null +++ b/scripts/sync-starter-version.ts @@ -0,0 +1,36 @@ +import { readFile, writeFile } from 'node:fs/promises' +import path from 'node:path' +import process from 'node:process' +import { fileURLToPath } from 'node:url' + +const rootDir = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..') +const starterPkgPath = path.resolve(rootDir, 'starter/package.json') + +/** + * Rewrites `starter/package.json`'s `devframe`/`@devframes/*` dependency + * ranges to `^` — the starter is a self-contained, copy-paste-ready + * template that pins real versions rather than `catalog:`/`workspace:*`, so + * a repo-wide bump has to touch it explicitly. Called from `bump.config.ts`'s + * `execute` hook so `bumpp -r` keeps it in lockstep automatically. + */ +export async function syncStarterVersion(version: string): Promise { + const raw = await readFile(starterPkgPath, 'utf-8') + const pkg = JSON.parse(raw) + + for (const name of Object.keys(pkg.dependencies ?? {})) { + if (name === 'devframe' || name.startsWith('@devframes/')) + pkg.dependencies[name] = `^${version}` + } + + await writeFile(starterPkgPath, `${JSON.stringify(pkg, null, 2)}\n`) +} + +// Allow standalone invocation: `tsx scripts/sync-starter-version.ts `. +if (import.meta.url === `file://${process.argv[1]}`) { + const version = process.argv[2] + if (!version) { + console.error('Usage: tsx scripts/sync-starter-version.ts ') + process.exit(1) + } + await syncStarterVersion(version) +} diff --git a/scripts/verify-typecheck-coverage.ts b/scripts/verify-typecheck-coverage.ts index 6141adde..6bb03a51 100644 --- a/scripts/verify-typecheck-coverage.ts +++ b/scripts/verify-typecheck-coverage.ts @@ -22,7 +22,7 @@ const rootDir = resolve(dirname(fileURLToPath(import.meta.url)), '..') /** * Workspace globs, mirrored from `pnpm-workspace.yaml`'s `packages:` list. */ -const WORKSPACE_PATTERNS = ['packages/*', 'plugins/*', 'examples/*', 'storybook', 'docs'] +const WORKSPACE_PATTERNS = ['packages/*', 'plugins/*', 'examples/*', 'starter', 'storybook', 'docs'] /** * Packages with a `tsconfig.json` that intentionally don't have a diff --git a/starter/.gitignore b/starter/.gitignore new file mode 100644 index 00000000..8e62df58 --- /dev/null +++ b/starter/.gitignore @@ -0,0 +1,9 @@ +.DS_Store +.turbo +*.log +coverage +dist +node_modules +playwright-report +test-results +temp diff --git a/starter/README.md b/starter/README.md new file mode 100644 index 00000000..e4e32277 --- /dev/null +++ b/starter/README.md @@ -0,0 +1,36 @@ +# devframe-starter + +Self-contained starter for a single [devframe](https://github.com/devframes/devframe) integration: a vanilla-TS Vite client, a CLI dev/build/MCP shell, single + hub playgrounds, and unit + e2e tests. Copy this folder out of the monorepo as the seed for a new devframe - it pins real dependency versions (no `catalog:`/`workspace:*`) so it installs standalone. + +## Run + +```sh +pnpm install +pnpm run build # build the vanilla-TS SPA into dist/client +pnpm run dev # CLI dev server - http://localhost:7391/__devframe-starter/ +pnpm run cli:build # static deploy in ./dist/static +pnpm run play:single # Vite playground: SPA at /, RPC bridge at /__devframe-starter/ +pnpm run play:hub # Vite playground: mounted as a dock inside a devframes hub +pnpm run test # unit tests (vitest) +pnpm run test:e2e # e2e tests (playwright, against the single playground) +pnpm run lint +pnpm run typecheck +``` + +## File map + +| Path | Purpose | +|------|---------| +| `src/devframe.ts` | The single `DevframeDefinition` every surface below consumes. | +| `src/rpc/` | RPC function definitions (`get-info` static, `list-items` query+snapshot) and their namespace declaration. | +| `src/client/` | The vanilla-TS SPA: `index.html`, `main.ts`, `app.ts`, `styles.css`. | +| `src/shared/base-path.ts` | The devframe's base path, shared between the node-side definition and browser-side client entries. | +| `bin.mjs` | `createCac(devframe).parse()` - exposes `dev`, `build`, `mcp`. | +| `playground/single/` | Vite dev-serves the SPA (with HMR) while `devframeViteBridge` answers RPC/discovery at the devframe's own base - see the comment in its `vite.config.ts` for why the two can't share one base. | +| `playground/hub/` | A minimal `@devframes/hub` host that mounts this devframe as an iframe dock (requires `pnpm run build` first). | +| `test/` | Unit tests - RPC functions over a real WebSocket, no browser. | +| `e2e/` | Playwright tests against the single playground, using the checked-in `e2e/fixtures/` directory as a fixed working directory. | + +## Versioning + +Dependencies here are real semver ranges, not the monorepo's pnpm catalog - so this folder is copy-paste ready outside the workspace. When developed in-repo, pnpm links `devframe`/`@devframes/*` to the local workspace packages automatically. See the root `AGENTS.md`'s `starter/` note for how a repo-wide `bumpp -r` release keeps these versions in sync. diff --git a/starter/bin.mjs b/starter/bin.mjs new file mode 100755 index 00000000..d35b5779 --- /dev/null +++ b/starter/bin.mjs @@ -0,0 +1,14 @@ +#!/usr/bin/env node +import process from 'node:process' +import { createCac } from 'devframe/adapters/cac' +import devframe from './src/devframe.ts' + +async function main() { + const cli = createCac(devframe) + await cli.parse() +} + +main().catch((error) => { + console.error(error) + process.exit(1) +}) diff --git a/starter/e2e/app.test.ts b/starter/e2e/app.test.ts new file mode 100644 index 00000000..f9587561 --- /dev/null +++ b/starter/e2e/app.test.ts @@ -0,0 +1,33 @@ +import process from 'node:process' +import { expect, test } from '@playwright/test' + +// Exercises the single playground end to end: the vanilla-TS SPA connects +// over the real WebSocket RPC bridge and renders the `list-items` / +// `get-info` results for the fixed `./fixtures` directory (wired via +// `playwright.config.ts`'s `webServer.env.DEVFRAME_E2E_CWD`). +test('connects and renders the list of items', async ({ page }) => { + await page.goto('/') + + const list = page.locator('ul') + await expect(list).toBeVisible() + + const items = list.locator('li') + await expect(items).toHaveCount(2) + await expect(items.nth(0)).toContainText('dir1') + await expect(items.nth(0)).toContainText('dir') + await expect(items.nth(1)).toContainText('file1.txt') + await expect(items.nth(1)).toContainText('file') + + // The "cwd" / "node" info line renders from the `get-info` RPC call. + await expect(page.locator('.meta code').first()).toContainText(process.version) +}) + +test('refresh re-fetches the list without a page reload', async ({ page }) => { + await page.goto('/') + + const refreshButton = page.getByRole('button', { name: 'Refresh' }) + await expect(refreshButton).toBeEnabled() + await refreshButton.click() + + await expect(page.locator('ul li')).toHaveCount(2) +}) diff --git a/starter/e2e/fixtures/dir1/.gitkeep b/starter/e2e/fixtures/dir1/.gitkeep new file mode 100644 index 00000000..e69de29b diff --git a/starter/e2e/fixtures/file1.txt b/starter/e2e/fixtures/file1.txt new file mode 100644 index 00000000..ce013625 --- /dev/null +++ b/starter/e2e/fixtures/file1.txt @@ -0,0 +1 @@ +hello diff --git a/starter/eslint.config.js b/starter/eslint.config.js new file mode 100644 index 00000000..67174045 --- /dev/null +++ b/starter/eslint.config.js @@ -0,0 +1,19 @@ +// @ts-check +import antfu from '@antfu/eslint-config' + +export default antfu( + { + type: 'app', + pnpm: true, + }, + { + // This starter is a self-contained, copy-paste-ready template — it + // intentionally pins real versions instead of pnpm catalog references + // (see the root AGENTS.md's "starter/" note), so the pnpm plugin's own + // catalog-enforcement rule doesn't apply to its `package.json`. + files: ['package.json'], + rules: { + 'pnpm/json-enforce-catalog': 'off', + }, + }, +) diff --git a/starter/package.json b/starter/package.json new file mode 100644 index 00000000..b62ea313 --- /dev/null +++ b/starter/package.json @@ -0,0 +1,59 @@ +{ + "name": "devframe-starter", + "type": "module", + "version": "0.9.4", + "private": true, + "packageManager": "pnpm@11.22.0", + "description": "Self-contained starter for a single devframe integration: a vanilla-TS Vite client, single + hub playgrounds, and unit + e2e tests.", + "author": "Anthony Fu ", + "license": "MIT", + "homepage": "https://github.com/devframes/devframe/tree/main/starter", + "repository": { + "directory": "starter", + "type": "git", + "url": "git+https://github.com/devframes/devframe.git" + }, + "bugs": "https://github.com/devframes/devframe/issues", + "keywords": [ + "devframe", + "devtools", + "starter", + "template" + ], + "main": "src/devframe.ts", + "bin": { + "devframe-starter": "./bin.mjs" + }, + "scripts": { + "dev": "node bin.mjs", + "build": "vite build --config src/client/vite.config.ts", + "cli:build": "node bin.mjs build --out-dir dist/static", + "play": "vite --config playground/single/vite.config.ts", + "play:single": "vite --config playground/single/vite.config.ts", + "play:hub": "vite --config playground/hub/vite.config.ts", + "lint": "eslint", + "test": "vitest run", + "test:unit": "vitest run", + "test:e2e": "playwright test", + "typecheck": "tsc --noEmit", + "release": "bumpp" + }, + "dependencies": { + "@devframes/hub": "^0.9.4", + "@devframes/hub-ui": "^0.9.4", + "@devframes/vite": "^0.9.4", + "devframe": "^0.9.4" + }, + "devDependencies": { + "@antfu/eslint-config": "^9.3.0", + "@playwright/test": "^1.62.1", + "@types/node": "^26.2.0", + "@types/ws": "^8.18.1", + "bumpp": "^12.2.1", + "eslint": "^10.8.1", + "typescript": "^6.0.3", + "vite": "^8.2.1", + "vitest": "^4.1.10", + "ws": "^8.21.3" + } +} diff --git a/starter/playground/hub/index.html b/starter/playground/hub/index.html new file mode 100644 index 00000000..dfeea4db --- /dev/null +++ b/starter/playground/hub/index.html @@ -0,0 +1,16 @@ + + + + + + Devframe Starter (Hub Playground) + + +

Hub Playground

+

This page is the host app. The devtools ride along:

+
    +
  • the floating dock (bottom right) is /__devframes/embedded.js, injected by the hub plugin
  • +
  • the standalone viewer lives at /__devframes/
  • +
+ + diff --git a/starter/playground/hub/vite.config.ts b/starter/playground/hub/vite.config.ts new file mode 100644 index 00000000..cb6df192 --- /dev/null +++ b/starter/playground/hub/vite.config.ts @@ -0,0 +1,43 @@ +import type { HubDevframeEntry } from '@devframes/hub/initiate' +import { fileURLToPath } from 'node:url' +import { createUi } from '@devframes/hub-ui' +import { viteDevframeHub } from '@devframes/vite/hub' +import { defineConfig } from 'vite' +import devframe from '../../src/devframe.ts' + +// The hub playground: a minimal Vite host that mounts this devframe as a dock +// inside the devframe hub, using the default @devframes/hub-ui. +// +// The hub synthesizes an iframe dock that points to the devframe's built +// SPA (at `dist/client`, which must be built first) and mounts it alongside +// other hub tools. + +// Customize the synthesized iframe dock entry: title and icon. +const entry: HubDevframeEntry = { + devframe, + dock: { + title: 'Starter', + icon: 'ph:rocket-launch-duotone', + }, +} + +export default defineConfig({ + // This config's own directory - `index.html` lives here, not at the cwd + // the `play:hub` script runs from (the starter's package root). + root: fileURLToPath(new URL('.', import.meta.url)), + // Dev tooling reached from arbitrary hostnames (LAN IPs, tunnels): accept + // any Host header and fall back to the next free port when busy. + server: { allowedHosts: true, strictPort: false }, + plugins: [ + viteDevframeHub({ + // This playground demonstrates mounting a hub directly in Vite, not + // recommending it over Vite DevTools - silence the one-time notice. + quiet: true, + devframes: [entry], + // Use the default hub-ui, rebrand it to match the starter's accent color. + ui: createUi({ branding: { primaryColor: '#10b981', productName: 'Devframe Starter' } }), + // Ungated localhost demo - skip the trust handshake. + auth: false, + }), + ], +}) diff --git a/starter/playground/single/index.html b/starter/playground/single/index.html new file mode 100644 index 00000000..4a3f3d22 --- /dev/null +++ b/starter/playground/single/index.html @@ -0,0 +1,12 @@ + + + + + + Devframe Starter (Single Playground) + + +
+ + + diff --git a/starter/playground/single/main.ts b/starter/playground/single/main.ts new file mode 100644 index 00000000..1b414d69 --- /dev/null +++ b/starter/playground/single/main.ts @@ -0,0 +1,21 @@ +import { mount } from '../../src/client/app.ts' +import { BASE_PATH } from '../../src/shared/base-path.ts' +import '../../src/client/styles.css' + +// Mirrors `src/client/main.ts` (the real entry the CLI/build serve), except +// it points `connectDevframe` at the RPC bridge's mount base explicitly: this +// playground serves the SPA from Vite's own root for HMR, while +// `devframeViteBridge` (which claims 100% of requests under its own base - +// it can't share one with Vite's SPA serving) answers at `BASE_PATH`. +const mq = window.matchMedia('(prefers-color-scheme: dark)') +function applyScheme(dark: boolean): void { + document.documentElement.classList.toggle('dark', dark) +} +applyScheme(mq.matches) +mq.addEventListener('change', e => applyScheme(e.matches)) + +const root = document.getElementById('app') +if (!root) + throw new Error('#app mount node missing from index.html') + +void mount(root, { baseURL: BASE_PATH }) diff --git a/starter/playground/single/vite.config.ts b/starter/playground/single/vite.config.ts new file mode 100644 index 00000000..cc2c3676 --- /dev/null +++ b/starter/playground/single/vite.config.ts @@ -0,0 +1,36 @@ +import { fileURLToPath } from 'node:url' +import { devframeViteBridge } from '@devframes/vite/single' +import { defineConfig } from 'vite' +import devframe from '../../src/devframe.ts' + +// The single playground: Vite dev-serves the vanilla-TS SPA (this +// directory's own `index.html`/`main.ts`, at the server root, with full +// HMR) while `devframeViteBridge` answers the RPC/WS/discovery routes at +// the devframe's own base path (`/__devframe-starter/`) - a *different* +// prefix than the SPA's, since the bridge's middleware claims every request +// under its base with no passthrough and so can't share one with Vite's own +// SPA serving. `playground/single/main.ts` points `connectDevframe` at that +// base explicitly instead of relying on `document.baseURI`. +export default defineConfig({ + root: fileURLToPath(new URL('.', import.meta.url)), + server: { + // Explicit IPv4: the default `localhost` host can resolve to the IPv6 + // loopback on some systems, which the pinned `port` below doesn't fix + // by itself (e2e tooling dials `127.0.0.1` directly). + host: '127.0.0.1', + // Dev tooling reached from arbitrary hostnames (LAN IPs, tunnels): + // accept any Host header. + allowedHosts: true, + // Pinned (rather than an auto-picked free port) so `playwright.config.ts` + // can point at a known URL. + port: devframe.cli!.port, + strictPort: true, + }, + plugins: [ + devframeViteBridge(devframe, { + base: devframe.basePath, + // Ungated localhost demo - skip the trust handshake. + auth: false, + }), + ], +}) diff --git a/starter/playwright.config.ts b/starter/playwright.config.ts new file mode 100644 index 00000000..55368cb8 --- /dev/null +++ b/starter/playwright.config.ts @@ -0,0 +1,39 @@ +import process from 'node:process' +import { fileURLToPath } from 'node:url' +import { defineConfig, devices } from '@playwright/test' +import devframe from './src/devframe.ts' + +// Fixed, checked-in fixture dir (not a per-test tmp dir): the dev server +// this config boots is a separate process started before any test runs, so +// its `DEVFRAME_E2E_CWD` has to be set here - a test's `process.env` can't +// reach back into an already-running child process. +const fixtureCwd = fileURLToPath(new URL('./e2e/fixtures', import.meta.url)) +const port = devframe.cli!.port // matches `playground/single/vite.config.ts`'s pinned port. + +export default defineConfig({ + testDir: './e2e', + testIgnore: ['fixtures/**'], + fullyParallel: true, + forbidOnly: !!process.env.CI, + retries: process.env.CI ? 2 : 0, + reporter: process.env.CI ? [['github'], ['html']] : 'list', + use: { + baseURL: `http://127.0.0.1:${port}`, + trace: 'on-first-retry', + }, + projects: [ + { name: 'chromium', use: { ...devices['Desktop Chrome'] } }, + ], + webServer: { + command: 'pnpm run play:single', + env: { DEVFRAME_E2E_CWD: fixtureCwd }, + // The SPA (this playground's own `index.html`) serves from Vite's root - + // see `playground/single/vite.config.ts` for why it can't share the + // RPC bridge's `/__devframe-starter/` base. + url: `http://127.0.0.1:${port}/`, + timeout: 60_000, + reuseExistingServer: !process.env.CI, + stdout: 'pipe', + stderr: 'pipe', + }, +}) diff --git a/starter/src/client/app.ts b/starter/src/client/app.ts new file mode 100644 index 00000000..d75165ca --- /dev/null +++ b/starter/src/client/app.ts @@ -0,0 +1,109 @@ +import type { DevframeScopedClientContext } from 'devframe/client' +import { connectDevframe } from 'devframe/client' + +const NAMESPACE = 'devframe-starter' +type StarterCtx = DevframeScopedClientContext + +interface Item { + name: string + kind: 'dir' | 'file' +} + +function h( + tag: K, + attrs: Partial> = {}, + children: (Node | string)[] = [], +): HTMLElementTagNameMap[K] { + const el = document.createElement(tag) + for (const [k, v] of Object.entries(attrs)) { + if (v != null) + el.setAttribute(k, v) + } + for (const c of children) + el.append(typeof c === 'string' ? document.createTextNode(c) : c) + return el +} + +export interface MountOptions { + /** + * Override where `connectDevframe` looks for `__connection.json`, instead + * of deriving it from `document.baseURI`. The CLI dev server and the + * static build serve this SPA *at* the devframe's own base path, so + * `document.baseURI` already resolves there and this can stay unset; the + * single playground serves it from Vite's own root instead (Vite's SPA + * serving and the RPC bridge can't share one base - see + * `playground/single/vite.config.ts`), so its entry passes this explicitly. + */ + baseURL?: string +} + +/** Boot the SPA into `root`: connect to devframe, then render the list. */ +export async function mount(root: HTMLElement, options: MountOptions = {}): Promise { + root.replaceChildren(h('div', { class: 'connecting' }, ['Connecting to devframe…'])) + + const client = await connectDevframe(options.baseURL ? { baseURL: options.baseURL } : undefined) + const ctx = client.scope(NAMESPACE) as StarterCtx + + const listCard = h('div', { class: 'card' }) + const count = h('span', { class: 'count' }, ['0']) + const refreshBtn = h('button', { type: 'button' }, ['Refresh']) + + async function refresh(): Promise { + refreshBtn.disabled = true + refreshBtn.textContent = 'Loading…' + try { + const items = await ctx.rpc.call('list-items') as Item[] + count.textContent = String(items.length) + renderList(listCard, items) + } + finally { + refreshBtn.disabled = false + refreshBtn.textContent = 'Refresh' + } + } + refreshBtn.addEventListener('click', () => void refresh()) + + const info = await ctx.rpc.call('get-info') as { cwd: string, node: string } + + root.replaceChildren( + h('div', { class: 'app' }, [ + h('header', { class: 'nav' }, [ + h('span', { class: 'brand' }, [h('span', { class: 'dot' }), 'Devframe Starter']), + h('span', { class: 'spacer' }), + h('small', { class: 'meta' }, [ + 'node ', + h('code', {}, [info.node]), + ' · backend ', + h('code', {}, [ctx.base.connectionMeta.backend]), + ]), + ]), + h('main', {}, [ + h('div', { class: 'row' }, [ + h('h2', {}, ['Items']), + count, + h('span', { class: 'spacer' }), + refreshBtn, + ]), + listCard, + h('p', { class: 'meta' }, ['cwd: ', h('code', {}, [info.cwd])]), + ]), + ]), + ) + + await refresh() +} + +function renderList(card: HTMLElement, items: Item[]): void { + if (items.length === 0) { + card.replaceChildren(h('p', { class: 'empty' }, ['Nothing in the working directory.'])) + return + } + card.replaceChildren( + h('ul', {}, items.map(it => + h('li', {}, [ + it.name, + h('span', { class: 'spacer' }), + h('span', { class: 'kind' }, [it.kind]), + ]))), + ) +} diff --git a/starter/src/client/index.html b/starter/src/client/index.html new file mode 100644 index 00000000..54d49435 --- /dev/null +++ b/starter/src/client/index.html @@ -0,0 +1,13 @@ + + + + + + + Devframe Starter + + +
+ + + diff --git a/starter/src/client/main.ts b/starter/src/client/main.ts new file mode 100644 index 00000000..67156e3e --- /dev/null +++ b/starter/src/client/main.ts @@ -0,0 +1,16 @@ +import { mount } from './app.ts' +import './styles.css' + +// Mirror the OS color scheme onto so the CSS `.dark` variables apply. +const mq = window.matchMedia('(prefers-color-scheme: dark)') +function applyScheme(dark: boolean): void { + document.documentElement.classList.toggle('dark', dark) +} +applyScheme(mq.matches) +mq.addEventListener('change', e => applyScheme(e.matches)) + +const root = document.getElementById('app') +if (!root) + throw new Error('#app mount node missing from index.html') + +void mount(root) diff --git a/starter/src/client/shims.d.ts b/starter/src/client/shims.d.ts new file mode 100644 index 00000000..7c6b5f61 --- /dev/null +++ b/starter/src/client/shims.d.ts @@ -0,0 +1,2 @@ +// Side-effect style CSS import used by the SPA entry. +declare module '*.css' {} diff --git a/starter/src/client/styles.css b/starter/src/client/styles.css new file mode 100644 index 00000000..011f90e6 --- /dev/null +++ b/starter/src/client/styles.css @@ -0,0 +1,168 @@ +:root { + color-scheme: light dark; + --bg: #ffffff; + --fg: #1a1a1a; + --muted: #6b7280; + --border: #e5e7eb; + --accent: #10b981; + --card: #f9fafb; + --hover: #f3f4f6; +} + +html.dark { + --bg: #0b0f13; + --fg: #e5e7eb; + --muted: #9ca3af; + --border: #1f2937; + --accent: #34d399; + --card: #111827; + --hover: #1f2937; +} + +* { + box-sizing: border-box; +} + +body { + margin: 0; + font-family: system-ui, -apple-system, "Segoe UI", Roboto, sans-serif; + background: var(--bg); + color: var(--fg); +} + +.app { + min-height: 100vh; + display: flex; + flex-direction: column; +} + +.nav { + display: flex; + align-items: center; + gap: 0.5rem; + padding: 0.75rem 1rem; + border-bottom: 1px solid var(--border); +} + +.brand { + display: flex; + align-items: center; + gap: 0.5rem; + font-weight: 600; +} + +.dot { + width: 0.6rem; + height: 0.6rem; + border-radius: 999px; + background: var(--accent); +} + +.spacer { + flex: 1; +} + +.meta { + font-family: ui-monospace, "SF Mono", Menlo, monospace; + font-size: 0.75rem; + color: var(--muted); +} + +.meta code { + color: var(--fg); +} + +main { + flex: 1; + width: 100%; + max-width: 48rem; + margin: 0 auto; + padding: 1rem; + display: flex; + flex-direction: column; + gap: 1rem; +} + +.row { + display: flex; + align-items: center; + gap: 0.5rem; +} + +h2 { + font-size: 1rem; + margin: 0; +} + +.count { + font-family: ui-monospace, monospace; + font-size: 0.75rem; + padding: 0.1rem 0.4rem; + border-radius: 0.35rem; + background: var(--card); + color: var(--muted); +} + +button { + font: inherit; + cursor: pointer; + padding: 0.35rem 0.7rem; + border-radius: 0.4rem; + border: 1px solid var(--border); + background: var(--card); + color: var(--fg); +} + +button:disabled { + opacity: 0.6; + cursor: default; +} + +.card { + border: 1px solid var(--border); + border-radius: 0.5rem; + overflow: hidden; +} + +ul { + list-style: none; + margin: 0; + padding: 0; +} + +li { + display: flex; + align-items: center; + gap: 0.5rem; + padding: 0.45rem 0.75rem; + border-bottom: 1px solid var(--border); + font-family: ui-monospace, monospace; + font-size: 0.85rem; +} + +li:last-child { + border-bottom: none; +} + +li:hover { + background: var(--hover); +} + +.kind { + font-size: 0.7rem; + color: var(--muted); +} + +.empty { + padding: 2.5rem 0.75rem; + text-align: center; + color: var(--muted); + font-size: 0.85rem; +} + +.connecting { + display: grid; + place-items: center; + min-height: 100vh; + color: var(--muted); +} diff --git a/starter/src/client/vite.config.ts b/starter/src/client/vite.config.ts new file mode 100644 index 00000000..bef65829 --- /dev/null +++ b/starter/src/client/vite.config.ts @@ -0,0 +1,14 @@ +import { fileURLToPath } from 'node:url' +import { defineConfig } from 'vite' + +// Builds the standalone SPA into `dist/client` (the definition's `cli.distDir`). +// `base: './'` keeps every asset URL relative so the same bundle works under +// any mount path - the CLI static build, the single playground, or a hub dock. +export default defineConfig({ + base: './', + root: fileURLToPath(new URL('.', import.meta.url)), + build: { + outDir: fileURLToPath(new URL('../../dist/client', import.meta.url)), + emptyOutDir: true, + }, +}) diff --git a/starter/src/devframe.ts b/starter/src/devframe.ts new file mode 100644 index 00000000..528d8609 --- /dev/null +++ b/starter/src/devframe.ts @@ -0,0 +1,40 @@ +import { fileURLToPath } from 'node:url' +import { defineDevframe } from 'devframe' +import pkg from '../package.json' with { type: 'json' } +import { NAMESPACE, serverFunctions } from './rpc/index.ts' +import { BASE_PATH } from './shared/base-path.ts' + +const distDir = fileURLToPath(new URL('../dist/client', import.meta.url)) + +/** + * The single `DevframeDefinition` every surface consumes: the CLI + * (`bin.mjs`), the single playground, and the hub playground all import + * this one object. + */ +export default defineDevframe({ + id: 'devframe-starter', + name: 'Devframe Starter', + version: pkg.version, + packageName: pkg.name, + importMetaUrl: import.meta.url, + homepage: pkg.homepage, + description: pkg.description, + icon: 'ph:rocket-launch-duotone', + basePath: BASE_PATH, + cli: { + command: 'devframe-starter', + port: 7391, + distDir, + // Single-user localhost demo - skip the trust handshake so the served + // SPA can call RPC without an OTP round-trip. + auth: false, + // Serve the agent (MCP) surface over the dev server's `/__mcp` route. + mcp: true, + }, + setup(ctx) { + // A scoped context auto-namespaces every registered id with `NAMESPACE:`. + const my = ctx.scope(NAMESPACE) + for (const fn of serverFunctions) + my.rpc.register(fn) + }, +}) diff --git a/starter/src/rpc/functions/get-info.ts b/starter/src/rpc/functions/get-info.ts new file mode 100644 index 00000000..87cc8d39 --- /dev/null +++ b/starter/src/rpc/functions/get-info.ts @@ -0,0 +1,19 @@ +import process from 'node:process' +import { defineRpcFunction } from 'devframe' + +/** + * A `static` RPC: its result can be baked into a static build (no live + * server needed). Scoped registration namespaces it to + * `devframe-starter:get-info` at runtime. + */ +export const getInfo = defineRpcFunction({ + name: 'get-info', + type: 'static', + jsonSerializable: true, + setup: ctx => ({ + handler: () => ({ + cwd: process.env.DEVFRAME_E2E_CWD || ctx.cwd, + node: process.version, + }), + }), +}) diff --git a/starter/src/rpc/functions/list-items.ts b/starter/src/rpc/functions/list-items.ts new file mode 100644 index 00000000..403261f6 --- /dev/null +++ b/starter/src/rpc/functions/list-items.ts @@ -0,0 +1,25 @@ +import { readdir } from 'node:fs/promises' +import process from 'node:process' +import { defineRpcFunction } from 'devframe' + +/** + * A `query` RPC with `snapshot: true`: live over WebSocket in dev, and its + * dump is baked into a static build so the SPA keeps working with no server. + * Lists the top-level entries of the working directory. + */ +export const listItems = defineRpcFunction({ + name: 'list-items', + type: 'query', + jsonSerializable: true, + snapshot: true, + setup: ctx => ({ + handler: async () => { + const cwd = process.env.DEVFRAME_E2E_CWD || ctx.cwd + const entries = await readdir(cwd, { withFileTypes: true }) + return entries + .filter(e => !e.name.startsWith('.')) + .map(e => ({ name: e.name, kind: e.isDirectory() ? 'dir' as const : 'file' as const })) + .sort((a, b) => a.name.localeCompare(b.name)) + }, + }), +}) diff --git a/starter/src/rpc/index.ts b/starter/src/rpc/index.ts new file mode 100644 index 00000000..8790f48b --- /dev/null +++ b/starter/src/rpc/index.ts @@ -0,0 +1,15 @@ +import type { RpcDefinitionsToFunctionsWithNamespace } from 'devframe/rpc' +import { getInfo } from './functions/get-info.ts' +import { listItems } from './functions/list-items.ts' + +export const NAMESPACE = 'devframe-starter' + +export const serverFunctions = [getInfo, listItems] as const + +declare module 'devframe' { + // Functions are defined with bare names and registered through a scoped + // context, so the registry keys are namespaced to match the runtime ids + // (`devframe-starter:get-info`, `devframe-starter:list-items`). + interface DevframeRpcServerFunctions + extends RpcDefinitionsToFunctionsWithNamespace {} +} diff --git a/starter/src/shared/base-path.ts b/starter/src/shared/base-path.ts new file mode 100644 index 00000000..90045cd5 --- /dev/null +++ b/starter/src/shared/base-path.ts @@ -0,0 +1,6 @@ +// Shared between the node-side devframe definition and browser-side client +// entries - a plain string constant with no node/browser-specific imports, +// so it's safe to bundle into either. +// +// Colon-free so it stays a valid `/` segment when mounted in a hub. +export const BASE_PATH = '/__devframe-starter/' diff --git a/starter/test/rpc.test.ts b/starter/test/rpc.test.ts new file mode 100644 index 00000000..1ffacbb5 --- /dev/null +++ b/starter/test/rpc.test.ts @@ -0,0 +1,70 @@ +import { mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises' +import os from 'node:os' +import path from 'node:path' +import process from 'node:process' +import { initDevframe } from 'devframe/initiate' +import { createRpcClient } from 'devframe/rpc/client' +import { createWsRpcChannel } from 'devframe/rpc/transports/ws-client' +import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest' +import { WebSocket } from 'ws' +import devframe from '../src/devframe.ts' + +// The public `initDevframe` handler used by every hosted adapter (see +// `@devframes/vite/single`) - a side-car WS server on a free port, no SPA +// (`distDir: false`), so this test exercises the RPC functions over the real +// wire without booting the CLI dev server or building the client first. +vi.stubGlobal('WebSocket', WebSocket) + +describe('rpc functions', () => { + let tmpDir: string + let instance: ReturnType + let rpc: ReturnType> + + beforeAll(async () => { + tmpDir = await mkdtemp(path.join(os.tmpdir(), 'devframe-starter-test-')) + await writeFile(path.join(tmpDir, 'file1.txt'), '') + await writeFile(path.join(tmpDir, 'file2.txt'), '') + await mkdir(path.join(tmpDir, 'dir1')) + // The RPC functions fall back to this env var over `ctx.cwd` so tests + // control the working directory without touching the real `process.cwd()`. + process.env.DEVFRAME_E2E_CWD = tmpDir + + instance = initDevframe(devframe, { + base: devframe.basePath!, + distDir: false, + // Explicit IPv4: `host: 'localhost'` (the default) can resolve to the + // IPv6 loopback on some systems, while the client below dials `127.0.0.1` + // directly - an address-family mismatch that reads as a silent hang. + host: '127.0.0.1', + ws: { sidecar: true }, + auth: false, + }) + await instance.ready + + // A side-car binds its WS endpoint at `/` (default `__ws`), so + // `connectionMeta().websocket` is `{ port, path }` here - not a bare port. + const wsMeta = instance.connectionMeta().websocket as { port: number, path: string } + const channel = createWsRpcChannel({ url: `ws://127.0.0.1:${wsMeta.port}/${wsMeta.path}` }) + rpc = createRpcClient({}, { channel }) + }) + + afterAll(async () => { + await instance.close() + await rm(tmpDir, { recursive: true, force: true }) + delete process.env.DEVFRAME_E2E_CWD + }) + + it('get-info returns node version and cwd', async () => { + const info = await rpc.$call('devframe-starter:get-info') + expect(info).toEqual({ node: process.version, cwd: tmpDir }) + }) + + it('list-items lists files and directories, sorted, dotfiles excluded', async () => { + const items = await rpc.$call('devframe-starter:list-items') + expect(items).toEqual([ + { name: 'dir1', kind: 'dir' }, + { name: 'file1.txt', kind: 'file' }, + { name: 'file2.txt', kind: 'file' }, + ]) + }) +}) diff --git a/starter/tsconfig.json b/starter/tsconfig.json new file mode 100644 index 00000000..f82c3375 --- /dev/null +++ b/starter/tsconfig.json @@ -0,0 +1,27 @@ +{ + "compilerOptions": { + "target": "ESNext", + "jsx": "preserve", + "lib": ["ESNext", "DOM", "DOM.Iterable"], + "module": "ESNext", + "moduleResolution": "Bundler", + "resolveJsonModule": true, + "types": ["node", "vite/client"], + "allowImportingTsExtensions": true, + "strict": true, + "strictNullChecks": true, + "noEmit": true, + "esModuleInterop": true, + "verbatimModuleSyntax": true, + "skipLibCheck": true + }, + "include": [ + "src", + "playground", + "test", + "e2e", + "scripts", + "bin.mjs", + "*.config.ts" + ] +} diff --git a/starter/vitest.config.ts b/starter/vitest.config.ts new file mode 100644 index 00000000..2051bd24 --- /dev/null +++ b/starter/vitest.config.ts @@ -0,0 +1,10 @@ +import { defineConfig } from 'vitest/config' + +export default defineConfig({ + test: { + environment: 'node', + include: ['test/**/*.test.ts'], + // Don't run e2e tests here - they have their own config and script. + exclude: ['e2e/**'], + }, +}) diff --git a/turbo.json b/turbo.json index 39859e00..3b09ceef 100644 --- a/turbo.json +++ b/turbo.json @@ -147,6 +147,11 @@ "dependsOn": ["devframe#build"], "outputs": ["dist/**"] }, + "devframe-starter#build": { + "outputLogs": "new-only", + "dependsOn": ["devframe#build"], + "outputs": ["dist/**"] + }, "streaming-chat-example#build": { "outputLogs": "new-only", "dependsOn": ["devframe#build"], @@ -173,6 +178,12 @@ "env": ["DEVFRAME_E2E_CWD"], "outputs": ["dist/static/**"] }, + "devframe-starter#cli:build": { + "outputLogs": "new-only", + "dependsOn": ["devframe-starter#build"], + "env": ["DEVFRAME_E2E_CWD"], + "outputs": ["dist/static/**"] + }, "streaming-chat-example#cli:build": { "outputLogs": "new-only", "dependsOn": ["streaming-chat-example#build"], diff --git a/vitest.config.ts b/vitest.config.ts index 05861af3..4faf5a7b 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -35,6 +35,7 @@ export default defineConfig({ 'examples/hub-next', 'packages/next', 'packages/vite', + 'starter', { test: { name: 'tests', From eddc277f2f05e5f4eadc62b82457d6f8e95f6622 Mon Sep 17 00:00:00 2001 From: "Anthony Fu (via agent)" Date: Thu, 20 Aug 2026 08:23:14 +0000 Subject: [PATCH 2/3] fix(starter): gate RPC auth by default instead of trusting every connection `auth: false` was set on every real surface (the CLI's cli.auth, and both Vite playgrounds) - each one auto-trusted any connection that could reach its port, with no interactive handshake at all. That's the framework's documented opt-out for a tool that's genuinely single-user and loopback-only, not a sane starter default: a copy-pasted template should teach the secure-by-default posture, not silently disable it everywhere. Leave `auth` unset on `src/devframe.ts`'s `cli` and both playground configs, so they fall through to devframe's interactive OTP gate (the same default every hosted adapter already ships). A developer who wants to skip the prompt for a one-off loopback session now reaches for the CLI's existing `--no-auth` flag per invocation instead of a baked-in opt-out. The single playground's e2e suite still needs to drive the bridge without solving an interactive code, so `playwright.config.ts`'s `webServer.env` sets an explicit `DEVFRAME_E2E` flag that the playground config checks to disable auth *only* for that automated run - a plain `pnpm run play:single` stays gated. The unit test's own ephemeral, loopback-only `initDevframe` instance keeps `auth: false` outright (it's a private test fixture torn down in `afterAll`, not a listening surface anyone else can reach) - now with a comment explaining why that one is fine. This PR was created with the help of an agent. --- starter/README.md | 2 ++ starter/playground/hub/vite.config.ts | 8 ++++++-- starter/playground/single/vite.config.ts | 12 ++++++++++-- starter/playwright.config.ts | 9 ++++++++- starter/src/devframe.ts | 12 +++++++++--- starter/test/rpc.test.ts | 4 ++++ 6 files changed, 39 insertions(+), 8 deletions(-) diff --git a/starter/README.md b/starter/README.md index e4e32277..49df23fc 100644 --- a/starter/README.md +++ b/starter/README.md @@ -17,6 +17,8 @@ pnpm run lint pnpm run typecheck ``` +`pnpm run dev` and both playgrounds gate by default: opening the printed URL walks you through devframe's interactive OTP handshake (a 6-digit code) before the SPA can call RPC. That's intentional - see the `auth` comments in `src/devframe.ts` and `playground/*/vite.config.ts` before reaching for `auth: false`, which trusts every connection that can reach the port. For a one-off loopback-only session, pass `--no-auth` to the CLI instead (`pnpm run dev -- --no-auth`). + ## File map | Path | Purpose | diff --git a/starter/playground/hub/vite.config.ts b/starter/playground/hub/vite.config.ts index cb6df192..890df1d0 100644 --- a/starter/playground/hub/vite.config.ts +++ b/starter/playground/hub/vite.config.ts @@ -36,8 +36,12 @@ export default defineConfig({ devframes: [entry], // Use the default hub-ui, rebrand it to match the starter's accent color. ui: createUi({ branding: { primaryColor: '#10b981', productName: 'Devframe Starter' } }), - // Ungated localhost demo - skip the trust handshake. - auth: false, + // `auth` is left unset: gated by default (devframe's interactive + // OTP - a 6-digit code printed to the terminal, entered once in the + // reference UI's authorization view). `auth: false` would trust + // every connection that can reach this port instead; only reach for + // it on a tool that's genuinely single-user and loopback-only. See + // docs/guide/security.md. }), ], }) diff --git a/starter/playground/single/vite.config.ts b/starter/playground/single/vite.config.ts index cc2c3676..2e62c0e4 100644 --- a/starter/playground/single/vite.config.ts +++ b/starter/playground/single/vite.config.ts @@ -1,3 +1,4 @@ +import process from 'node:process' import { fileURLToPath } from 'node:url' import { devframeViteBridge } from '@devframes/vite/single' import { defineConfig } from 'vite' @@ -29,8 +30,15 @@ export default defineConfig({ plugins: [ devframeViteBridge(devframe, { base: devframe.basePath, - // Ungated localhost demo - skip the trust handshake. - auth: false, + // Gated by default (devframe's interactive OTP) when unset, same as + // `bin.mjs dev` - see the comment on `cli` in `src/devframe.ts`. + // `DEVFRAME_E2E` is set only by `playwright.config.ts`'s + // `webServer.env`, so the automated e2e suite can drive the bridge + // without solving the OTP prompt; a plain `pnpm run play:single` + // still gates. Don't widen this to a bare `auth: false` - that + // trusts every connection that can reach the port, not just this + // test harness. See docs/guide/security.md. + auth: process.env.DEVFRAME_E2E ? false : undefined, }), ], }) diff --git a/starter/playwright.config.ts b/starter/playwright.config.ts index 55368cb8..11dead74 100644 --- a/starter/playwright.config.ts +++ b/starter/playwright.config.ts @@ -26,7 +26,14 @@ export default defineConfig({ ], webServer: { command: 'pnpm run play:single', - env: { DEVFRAME_E2E_CWD: fixtureCwd }, + env: { + DEVFRAME_E2E_CWD: fixtureCwd, + // Tells the bridge to skip the interactive OTP handshake - see the + // `auth` comment in `playground/single/vite.config.ts`. Real usage + // (a human running `pnpm run play:single`) never sets this and stays + // gated. + DEVFRAME_E2E: '1', + }, // The SPA (this playground's own `index.html`) serves from Vite's root - // see `playground/single/vite.config.ts` for why it can't share the // RPC bridge's `/__devframe-starter/` base. diff --git a/starter/src/devframe.ts b/starter/src/devframe.ts index 528d8609..eefa6da0 100644 --- a/starter/src/devframe.ts +++ b/starter/src/devframe.ts @@ -25,9 +25,15 @@ export default defineDevframe({ command: 'devframe-starter', port: 7391, distDir, - // Single-user localhost demo - skip the trust handshake so the served - // SPA can call RPC without an OTP round-trip. - auth: false, + // `auth` is deliberately left unset: gated by default (devframe's + // interactive OTP handshake - a 6-digit code printed to the terminal + // that trusts the browser before it can call any RPC function). + // `auth: false` would trust *any* connection that can reach the port + // instead - see docs/guide/security.md before reaching for it. A + // developer who wants to skip the prompt for a one-off loopback-only + // session can pass `--no-auth` per run (`devframe-starter --no-auth`) + // rather than baking the opt-out into the definition. + // // Serve the agent (MCP) surface over the dev server's `/__mcp` route. mcp: true, }, diff --git a/starter/test/rpc.test.ts b/starter/test/rpc.test.ts index 1ffacbb5..4727d987 100644 --- a/starter/test/rpc.test.ts +++ b/starter/test/rpc.test.ts @@ -37,6 +37,10 @@ describe('rpc functions', () => { // directly - an address-family mismatch that reads as a silent hang. host: '127.0.0.1', ws: { sidecar: true }, + // Fine here (unlike the "real" surfaces in `src/devframe.ts` and + // `playground/`): this instance is a private, ephemeral test fixture + // - bound to loopback, torn down in `afterAll`, and never reachable + // by anything but this test process. auth: false, }) await instance.ready From ccd5add3ef6cc090c921fb071e12d83e230cbcb7 Mon Sep 17 00:00:00 2001 From: "Anthony Fu (via agent)" Date: Fri, 21 Aug 2026 05:09:49 +0000 Subject: [PATCH 3/3] refactor(starter): simplify - one RPC function, no unused MCP route Merge the two demo RPC functions (get-info static, list-items query+snapshot) into a single get-state query+snapshot returning { cwd, node, items } in one round trip. Cuts an RPC file, a namespace entry, a client fetch, and a test, without losing the pattern this starter exists to demonstrate. Also: - Drop `cli.mcp: true` from the devframe definition - nothing in the starter exercised the MCP route, so it was dead surface. - Drop the redundant `play` (duplicate of `play:single`) and `test:unit` (duplicate of `test`) package.json scripts. - Rename test/rpc.test.ts -> test/get-state.test.ts to match the single function it now covers. This PR was created with the help of an agent. --- starter/README.md | 2 +- starter/e2e/app.test.ts | 8 ++--- starter/package.json | 2 -- starter/src/client/app.ts | 27 ++++++++-------- starter/src/devframe.ts | 3 -- starter/src/rpc/functions/get-info.ts | 19 ------------ .../functions/{list-items.ts => get-state.ts} | 23 +++++++++++--- starter/src/rpc/index.ts | 9 +++--- .../test/{rpc.test.ts => get-state.test.ts} | 31 +++++++++---------- 9 files changed, 55 insertions(+), 69 deletions(-) delete mode 100644 starter/src/rpc/functions/get-info.ts rename starter/src/rpc/functions/{list-items.ts => get-state.ts} (60%) rename starter/test/{rpc.test.ts => get-state.test.ts} (75%) diff --git a/starter/README.md b/starter/README.md index 49df23fc..8f8e2e40 100644 --- a/starter/README.md +++ b/starter/README.md @@ -24,7 +24,7 @@ pnpm run typecheck | Path | Purpose | |------|---------| | `src/devframe.ts` | The single `DevframeDefinition` every surface below consumes. | -| `src/rpc/` | RPC function definitions (`get-info` static, `list-items` query+snapshot) and their namespace declaration. | +| `src/rpc/` | The one RPC function (`get-state` - a query+snapshot returning runtime info and a directory listing) and its namespace declaration. | | `src/client/` | The vanilla-TS SPA: `index.html`, `main.ts`, `app.ts`, `styles.css`. | | `src/shared/base-path.ts` | The devframe's base path, shared between the node-side definition and browser-side client entries. | | `bin.mjs` | `createCac(devframe).parse()` - exposes `dev`, `build`, `mcp`. | diff --git a/starter/e2e/app.test.ts b/starter/e2e/app.test.ts index f9587561..02679005 100644 --- a/starter/e2e/app.test.ts +++ b/starter/e2e/app.test.ts @@ -2,9 +2,9 @@ import process from 'node:process' import { expect, test } from '@playwright/test' // Exercises the single playground end to end: the vanilla-TS SPA connects -// over the real WebSocket RPC bridge and renders the `list-items` / -// `get-info` results for the fixed `./fixtures` directory (wired via -// `playwright.config.ts`'s `webServer.env.DEVFRAME_E2E_CWD`). +// over the real WebSocket RPC bridge and renders the `get-state` result for +// the fixed `./fixtures` directory (wired via `playwright.config.ts`'s +// `webServer.env.DEVFRAME_E2E_CWD`). test('connects and renders the list of items', async ({ page }) => { await page.goto('/') @@ -18,7 +18,7 @@ test('connects and renders the list of items', async ({ page }) => { await expect(items.nth(1)).toContainText('file1.txt') await expect(items.nth(1)).toContainText('file') - // The "cwd" / "node" info line renders from the `get-info` RPC call. + // The "node" info line renders from the same `get-state` call. await expect(page.locator('.meta code').first()).toContainText(process.version) }) diff --git a/starter/package.json b/starter/package.json index b62ea313..00f20024 100644 --- a/starter/package.json +++ b/starter/package.json @@ -28,12 +28,10 @@ "dev": "node bin.mjs", "build": "vite build --config src/client/vite.config.ts", "cli:build": "node bin.mjs build --out-dir dist/static", - "play": "vite --config playground/single/vite.config.ts", "play:single": "vite --config playground/single/vite.config.ts", "play:hub": "vite --config playground/hub/vite.config.ts", "lint": "eslint", "test": "vitest run", - "test:unit": "vitest run", "test:e2e": "playwright test", "typecheck": "tsc --noEmit", "release": "bumpp" diff --git a/starter/src/client/app.ts b/starter/src/client/app.ts index d75165ca..a505c41d 100644 --- a/starter/src/client/app.ts +++ b/starter/src/client/app.ts @@ -1,14 +1,10 @@ import type { DevframeScopedClientContext } from 'devframe/client' +import type { StarterItem, StarterState } from '../rpc/functions/get-state.ts' import { connectDevframe } from 'devframe/client' const NAMESPACE = 'devframe-starter' type StarterCtx = DevframeScopedClientContext -interface Item { - name: string - kind: 'dir' | 'file' -} - function h( tag: K, attrs: Partial> = {}, @@ -37,7 +33,7 @@ export interface MountOptions { baseURL?: string } -/** Boot the SPA into `root`: connect to devframe, then render the list. */ +/** Boot the SPA into `root`: connect to devframe, then render its state. */ export async function mount(root: HTMLElement, options: MountOptions = {}): Promise { root.replaceChildren(h('div', { class: 'connecting' }, ['Connecting to devframe…'])) @@ -46,15 +42,20 @@ export async function mount(root: HTMLElement, options: MountOptions = {}): Prom const listCard = h('div', { class: 'card' }) const count = h('span', { class: 'count' }, ['0']) + const nodeCode = h('code', {}, ['…']) + const cwdCode = h('code', {}, ['…']) const refreshBtn = h('button', { type: 'button' }, ['Refresh']) async function refresh(): Promise { refreshBtn.disabled = true refreshBtn.textContent = 'Loading…' try { - const items = await ctx.rpc.call('list-items') as Item[] - count.textContent = String(items.length) - renderList(listCard, items) + // The one RPC round trip this SPA makes. + const state = await ctx.rpc.call('get-state') as StarterState + count.textContent = String(state.items.length) + nodeCode.textContent = state.node + cwdCode.textContent = state.cwd + renderList(listCard, state.items) } finally { refreshBtn.disabled = false @@ -63,8 +64,6 @@ export async function mount(root: HTMLElement, options: MountOptions = {}): Prom } refreshBtn.addEventListener('click', () => void refresh()) - const info = await ctx.rpc.call('get-info') as { cwd: string, node: string } - root.replaceChildren( h('div', { class: 'app' }, [ h('header', { class: 'nav' }, [ @@ -72,7 +71,7 @@ export async function mount(root: HTMLElement, options: MountOptions = {}): Prom h('span', { class: 'spacer' }), h('small', { class: 'meta' }, [ 'node ', - h('code', {}, [info.node]), + nodeCode, ' · backend ', h('code', {}, [ctx.base.connectionMeta.backend]), ]), @@ -85,7 +84,7 @@ export async function mount(root: HTMLElement, options: MountOptions = {}): Prom refreshBtn, ]), listCard, - h('p', { class: 'meta' }, ['cwd: ', h('code', {}, [info.cwd])]), + h('p', { class: 'meta' }, ['cwd: ', cwdCode]), ]), ]), ) @@ -93,7 +92,7 @@ export async function mount(root: HTMLElement, options: MountOptions = {}): Prom await refresh() } -function renderList(card: HTMLElement, items: Item[]): void { +function renderList(card: HTMLElement, items: StarterItem[]): void { if (items.length === 0) { card.replaceChildren(h('p', { class: 'empty' }, ['Nothing in the working directory.'])) return diff --git a/starter/src/devframe.ts b/starter/src/devframe.ts index eefa6da0..a2085dce 100644 --- a/starter/src/devframe.ts +++ b/starter/src/devframe.ts @@ -33,9 +33,6 @@ export default defineDevframe({ // developer who wants to skip the prompt for a one-off loopback-only // session can pass `--no-auth` per run (`devframe-starter --no-auth`) // rather than baking the opt-out into the definition. - // - // Serve the agent (MCP) surface over the dev server's `/__mcp` route. - mcp: true, }, setup(ctx) { // A scoped context auto-namespaces every registered id with `NAMESPACE:`. diff --git a/starter/src/rpc/functions/get-info.ts b/starter/src/rpc/functions/get-info.ts deleted file mode 100644 index 87cc8d39..00000000 --- a/starter/src/rpc/functions/get-info.ts +++ /dev/null @@ -1,19 +0,0 @@ -import process from 'node:process' -import { defineRpcFunction } from 'devframe' - -/** - * A `static` RPC: its result can be baked into a static build (no live - * server needed). Scoped registration namespaces it to - * `devframe-starter:get-info` at runtime. - */ -export const getInfo = defineRpcFunction({ - name: 'get-info', - type: 'static', - jsonSerializable: true, - setup: ctx => ({ - handler: () => ({ - cwd: process.env.DEVFRAME_E2E_CWD || ctx.cwd, - node: process.version, - }), - }), -}) diff --git a/starter/src/rpc/functions/list-items.ts b/starter/src/rpc/functions/get-state.ts similarity index 60% rename from starter/src/rpc/functions/list-items.ts rename to starter/src/rpc/functions/get-state.ts index 403261f6..bfdf3856 100644 --- a/starter/src/rpc/functions/list-items.ts +++ b/starter/src/rpc/functions/get-state.ts @@ -2,24 +2,37 @@ import { readdir } from 'node:fs/promises' import process from 'node:process' import { defineRpcFunction } from 'devframe' +export interface StarterItem { + name: string + kind: 'dir' | 'file' +} + +export interface StarterState { + cwd: string + node: string + items: StarterItem[] +} + /** * A `query` RPC with `snapshot: true`: live over WebSocket in dev, and its * dump is baked into a static build so the SPA keeps working with no server. - * Lists the top-level entries of the working directory. + * The one round trip the client makes - runtime info plus the top-level + * entries of the working directory. */ -export const listItems = defineRpcFunction({ - name: 'list-items', +export const getState = defineRpcFunction({ + name: 'get-state', type: 'query', jsonSerializable: true, snapshot: true, setup: ctx => ({ - handler: async () => { + handler: async (): Promise => { const cwd = process.env.DEVFRAME_E2E_CWD || ctx.cwd const entries = await readdir(cwd, { withFileTypes: true }) - return entries + const items = entries .filter(e => !e.name.startsWith('.')) .map(e => ({ name: e.name, kind: e.isDirectory() ? 'dir' as const : 'file' as const })) .sort((a, b) => a.name.localeCompare(b.name)) + return { cwd, node: process.version, items } }, }), }) diff --git a/starter/src/rpc/index.ts b/starter/src/rpc/index.ts index 8790f48b..e82d21ce 100644 --- a/starter/src/rpc/index.ts +++ b/starter/src/rpc/index.ts @@ -1,15 +1,14 @@ import type { RpcDefinitionsToFunctionsWithNamespace } from 'devframe/rpc' -import { getInfo } from './functions/get-info.ts' -import { listItems } from './functions/list-items.ts' +import { getState } from './functions/get-state.ts' export const NAMESPACE = 'devframe-starter' -export const serverFunctions = [getInfo, listItems] as const +export const serverFunctions = [getState] as const declare module 'devframe' { // Functions are defined with bare names and registered through a scoped - // context, so the registry keys are namespaced to match the runtime ids - // (`devframe-starter:get-info`, `devframe-starter:list-items`). + // context, so the registry key is namespaced to match the runtime id + // (`devframe-starter:get-state`). interface DevframeRpcServerFunctions extends RpcDefinitionsToFunctionsWithNamespace {} } diff --git a/starter/test/rpc.test.ts b/starter/test/get-state.test.ts similarity index 75% rename from starter/test/rpc.test.ts rename to starter/test/get-state.test.ts index 4727d987..d18f0e0e 100644 --- a/starter/test/rpc.test.ts +++ b/starter/test/get-state.test.ts @@ -11,11 +11,11 @@ import devframe from '../src/devframe.ts' // The public `initDevframe` handler used by every hosted adapter (see // `@devframes/vite/single`) - a side-car WS server on a free port, no SPA -// (`distDir: false`), so this test exercises the RPC functions over the real -// wire without booting the CLI dev server or building the client first. +// (`distDir: false`), so this test exercises `get-state` over the real wire +// without booting the CLI dev server or building the client first. vi.stubGlobal('WebSocket', WebSocket) -describe('rpc functions', () => { +describe('get-state', () => { let tmpDir: string let instance: ReturnType let rpc: ReturnType> @@ -25,7 +25,7 @@ describe('rpc functions', () => { await writeFile(path.join(tmpDir, 'file1.txt'), '') await writeFile(path.join(tmpDir, 'file2.txt'), '') await mkdir(path.join(tmpDir, 'dir1')) - // The RPC functions fall back to this env var over `ctx.cwd` so tests + // `get-state` falls back to this env var over `ctx.cwd` so tests // control the working directory without touching the real `process.cwd()`. process.env.DEVFRAME_E2E_CWD = tmpDir @@ -58,17 +58,16 @@ describe('rpc functions', () => { delete process.env.DEVFRAME_E2E_CWD }) - it('get-info returns node version and cwd', async () => { - const info = await rpc.$call('devframe-starter:get-info') - expect(info).toEqual({ node: process.version, cwd: tmpDir }) - }) - - it('list-items lists files and directories, sorted, dotfiles excluded', async () => { - const items = await rpc.$call('devframe-starter:list-items') - expect(items).toEqual([ - { name: 'dir1', kind: 'dir' }, - { name: 'file1.txt', kind: 'file' }, - { name: 'file2.txt', kind: 'file' }, - ]) + it('returns runtime info and the working directory listing, sorted with dotfiles excluded', async () => { + const state = await rpc.$call('devframe-starter:get-state') + expect(state).toEqual({ + node: process.version, + cwd: tmpDir, + items: [ + { name: 'dir1', kind: 'dir' }, + { name: 'file1.txt', kind: 'file' }, + { name: 'file2.txt', kind: 'file' }, + ], + }) }) })