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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
File renamed without changes.
File renamed without changes.
282 changes: 282 additions & 0 deletions docs/content/1.guide/2.project-structure.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,282 @@
---
title: 'Project Structure'
description: 'A recommended folder layout for a devframe package — one definition, a node/client split, a namespaced RPC registry, and a mount-path-portable SPA that ships as its own assets package.'
---

A recommended folder layout for a devframe package. One [`defineDevframe`](/guide/devframe-definition) call drives every surface — a standalone CLI, a Vite host, a hub dock — so the layout's whole job is to keep the **node** side and the **browser** side apart, expose each through its own package subpath, and let the SPA travel to any mount path.

The [`starter/`](https://github.com/devframes/devframe/tree/main/starter) template is the smallest expression of this shape; the eight [built-in plugins](/plugins) are the fullest. This page distills the convention they share.

## The shape at a glance

```
my-plugin/
├── bin.mjs # CLI entry: createCac(createMyDevframe()).parse()
├── package.json # exports map, bin, files
├── tsdown.config.ts # node + client library build
├── tsconfig.json # extends the repo base
├── uno.config.ts # SPA styling (design system)
├── src/
│ ├── index.ts # create<X>Devframe factory (default export)
│ ├── cli.ts # create<X>Cli — wraps the factory in createCac
│ ├── node/
│ │ └── index.ts # setup<X>(ctx) — registers RPC on a context
│ ├── rpc/
│ │ ├── index.ts # serverFunctions[] + `declare module 'devframe'`
│ │ └── functions/
│ │ └── *.ts # one defineRpcFunction per file
│ ├── client/
│ │ └── index.ts # thin connect<X>() browser helper
│ ├── shared/ # serializable types shared node ↔ browser
│ ├── diagnostics.ts # coded warnings/errors (node-side)
│ └── spa/ # the UI (Vue / Solid / Preact / React / vanilla)
│ ├── index.html
│ ├── main.ts # render entry; applies `.dark` from OS pref
│ ├── App.vue # (or app.tsx, …)
│ └── vite.config.ts # base: './', builds into the assets package
└── assets-pkg/ # sibling, lockstep-versioned prebuilt SPA package
└── package.json # @scope/my-plugin--assets
```

Not every part is mandatory. A minimal tool collapses to `src/index.ts` + one RPC file + one SPA (as in the starter); a rich tool adds an in-page agent, a query engine, or a data-source registry. The **seams** below stay the same regardless of size.

## The node / client seam

The single most important rule: **node-only code and browser-only code never share a module graph.** A `node:fs` import that leaks into the SPA bundle breaks the build; a DOM reference that leaks into the server breaks at runtime. Enforce the split with folders and with separate build entries.

| Folder | Runs in | May import |
|--------|---------|------------|
| `src/index.ts`, `src/node/`, `src/rpc/`, `src/cli.ts`, `src/diagnostics.ts` | Node | `node:*`, `devframe`, server deps |
| `src/client/`, `src/spa/` | Browser | `devframe/client`, DOM, UI framework |
| `src/shared/`, `src/types.ts` | Both | nothing environment-specific (plain serializable types/constants) |

`tsdown` compiles the two sides as **separate rolldown graphs** so a stray node import can never reach the client bundle:

```ts [tsdown.config.ts]
import { defineConfig } from 'tsdown'

const tsconfig = '../../tsconfig.base.json'

// Browser-loaded entry — isolated graph.
const clientEntries = { 'client/index': 'src/client/index.ts' }

// Node-side entries — definition, CLI, setup module, RPC registry.
const serverEntries = {
'index': 'src/index.ts',
'cli': 'src/cli.ts',
'node/index': 'src/node/index.ts',
'rpc/index': 'src/rpc/index.ts',
}

export default defineConfig([
{ clean: true, platform: 'browser', tsconfig, dts: false, outExtensions: () => ({ js: '.mjs' }), entry: clientEntries },
{ clean: false, platform: 'node', tsconfig, dts: false, entry: serverEntries },
// One combined dts pass so the `declare module 'devframe'` RPC augmentation resolves once.
{ clean: false, platform: 'neutral', tsconfig, dts: { emitDtsOnly: true }, outExtensions: () => ({ dts: '.d.mts' }), entry: { ...clientEntries, ...serverEntries } },
])
```

The SPA under `src/spa/` is a Vite app and builds separately (`vite build --config src/spa/vite.config.ts`), not through `tsdown`.

## The definition factory — `src/index.ts`

A plugin's default export is its **factory**, never a pre-built instance — so importing the module costs nothing until a consumer calls it with their own options ([why](https://github.com/devframes/devframe/blob/main/AGENTS.md)):

```ts [src/index.ts]
import type { DevframeDefinition, RemoteAssets } from 'devframe'
import { defineDevframe } from 'devframe'
import pkg from '../package.json' with { type: 'json' }
import { setupMyPlugin } from './node/index'

const DEFAULT_ID = 'my-plugin'

// The SPA ships in the sibling assets package (see below), served on demand.
const distDir: RemoteAssets = { package: `${pkg.name}--assets`, version: pkg.version }

export interface MyPluginOptions {
id?: string
name?: string
icon?: string
basePath?: string
port?: number
}

export function createMyPluginDevframe(options: MyPluginOptions = {}): DevframeDefinition {
const id = options.id ?? DEFAULT_ID
return defineDevframe({
id,
name: options.name ?? 'My Plugin',
version: pkg.version,
packageName: pkg.name,
importMetaUrl: import.meta.url,
homepage: pkg.homepage,
description: pkg.description,
icon: options.icon ?? 'ph:gauge-duotone',
basePath: options.basePath,
cli: { command: id, port: options.port ?? 9000, distDir },
setup(ctx) {
setupMyPlugin(ctx)
},
})
}

export default createMyPluginDevframe
```

Metadata comes from `package.json` (one source of truth for version/name/homepage/description). `importMetaUrl: import.meta.url` is what lets the definition resolve its own dependencies and remote assets — always pass it.

`setup` stays thin: it delegates to a `setup<X>(ctx)` function in `src/node/` so host adapters that wire their own context can reuse it.

```ts [src/node/index.ts]
import type { DevframeNodeContext } from 'devframe'
import { serverFunctions } from '../rpc/index'

export function setupMyPlugin(ctx: DevframeNodeContext): void {
for (const fn of serverFunctions)
ctx.rpc.register(fn)
}
```

## The RPC registry — `src/rpc/`

One `defineRpcFunction` per file under `src/rpc/functions/`, collected in `src/rpc/index.ts`, which also augments the `devframe` module so every registered id is typed:

```ts [src/rpc/index.ts]
import type { RpcDefinitionsToFunctions } from 'devframe/rpc'
import { getState } from './functions/get-state'
import { listItems } from './functions/list-items'

export const serverFunctions = [getState, listItems] as const

declare module 'devframe' {
interface DevframeRpcServerFunctions extends RpcDefinitionsToFunctions<typeof serverFunctions> {}
}
```

Namespace every RPC id with the plugin's slug — `defineRpcFunction({ name: 'devframes:plugin:my-plugin:get-state', … })` — matching the `@devframes/plugin-<slug>` package name. Inside `setup` you can instead take a scoped context (`ctx.scope('my-plugin')`) so bare names auto-prefix; see [Scoped Context](/guide/scoped-context). See [RPC](/guide/rpc) for the function types.

## The browser helper — `src/client/index.ts`

A thin, typed wrapper over [`connectDevframe`](/guide/client), published as the `./client` subpath. The SPA derives its base from `document.baseURI`, so no options are needed in the common case:

```ts [src/client/index.ts]
import type { DevframeRpcClient, DevframeRpcClientOptions } from 'devframe/client'
import { connectDevframe } from 'devframe/client'

export type { DevframeRpcClient }
export type { MyState } from '../shared/types' // re-export the serializable types

export function connectMyPlugin(options?: DevframeRpcClientOptions): Promise<DevframeRpcClient> {
return connectDevframe(options)
}
```

## The SPA — `src/spa/`

The UI is a standalone Vite app. Pick any framework — the built-ins use Vue, Solid, React (Next), and Svelte; the starter uses vanilla TypeScript. Two rules make it portable:

1. **`base: './'`** — relative asset URLs so the same build serves under `/`, `/__my-plugin/`, or any hub mount path. Discover the runtime base from `document.baseURI`; never hardcode a mount path or inject one at build time.
2. **Style with the shared [design system](https://github.com/antfu/design)** via `uno.config.ts` (`mergeConfigs([designConfig, …])`), so the surface looks like one product across frameworks.

```ts [src/spa/vite.config.ts]
import { fileURLToPath } from 'node:url'
import { devframeVite } from '@devframes/vite/single'
import vue from '@vitejs/plugin-vue'
import UnoCSS from 'unocss/vite'
import { defineConfig } from 'vite'
import createMyPluginDevframe from '../index'

export default defineConfig({
base: './',
root: fileURLToPath(new URL('.', import.meta.url)),
plugins: [
vue(),
UnoCSS(),
// Bridge dev-mode RPC/WS so the SPA has a live backend while you iterate.
devframeVite(createMyPluginDevframe(), { bridge: true, base: '/' }),
],
build: {
// Emit into the sibling assets package rather than this slim node package.
outDir: fileURLToPath(new URL('../../assets-pkg/dist', import.meta.url)),
emptyOutDir: true,
},
})
```

The `main` entry applies dark mode from the OS preference and mounts the app; see any plugin's `src/spa/main.ts`.

## Shipping the SPA as an assets package

The built-in plugins keep the node package slim and publish the compiled SPA as a **sibling, lockstep-versioned package** named `<pkg>--assets`. The definition points `clientAssets`/`distDir` at it as `RemoteAssets` (`{ package, version }`), and `importMetaUrl` lets a locally installed copy be served with zero network — otherwise the assets stream on demand through devframe's caching back-proxy.

```
my-plugin/
├── package.json # @scope/my-plugin (node code)
└── assets-pkg/
└── package.json # @scope/my-plugin--assets (prebuilt SPA, files: ["dist"])
```

The assets package is otherwise empty — its `build` just delegates to the plugin's SPA build. This split is optional: a self-contained tool can instead point `clientAssets` at a local `dist/client` directory resolved via `fileURLToPath(new URL('../dist/client', import.meta.url))`, which is what the [starter](https://github.com/devframes/devframe/tree/main/starter) does.

## The CLI entry — `bin.mjs` + `src/cli.ts`

Standalone tools ship a `bin`. `src/cli.ts` wraps the factory in [`createCac`](/guide/standalone-cli); `bin.mjs` is the three-line executable:

```ts [src/cli.ts]
import { createCac } from 'devframe/adapters/cac'
import createMyPluginDevframe from './index'

export function createMyPluginCli() {
return createCac(createMyPluginDevframe())
}
```

```js [bin.mjs]
#!/usr/bin/env node
import { createMyPluginCli } from './dist/cli.mjs'

Check failure on line 236 in docs/content/1.guide/2.project-structure.md

View workflow job for this annotation

GitHub Actions / unit-test / lint

Do not import modules in `dist` folder, got ./dist/cli.mjs

createMyPluginCli().parse()
```

This gives `dev`, `build`, and `mcp` subcommands for free.

## The package manifest

The `exports` map mirrors the build entries — one subpath per seam — and `files` ships only `bin.mjs` and `dist`:

```jsonc [package.json]
{
"name": "@devframes/plugin-my-plugin",
"type": "module",
"exports": {
".": "./dist/index.mjs", // the factory

Check failure on line 252 in docs/content/1.guide/2.project-structure.md

View workflow job for this annotation

GitHub Actions / unit-test / lint

Multiple spaces found before '// the factory'
"./client": "./dist/client/index.mjs", // browser helper
"./cli": "./dist/cli.mjs", // createCac wrapper

Check failure on line 254 in docs/content/1.guide/2.project-structure.md

View workflow job for this annotation

GitHub Actions / unit-test / lint

Multiple spaces found before '// createCac w...'
"./node": "./dist/node/index.mjs", // setup(ctx) for host adapters
"./package.json": "./package.json"
},
"types": "./dist/index.d.mts",
"bin": { "devframe-my-plugin": "./bin.mjs" },
"files": ["bin.mjs", "dist"],
"sideEffects": false
}
```

## Optional pieces

Reach for these only when the tool needs them — the built-ins show each in context:

- **`src/inject/`** — an **in-page agent** the hub imports into the host page as a dock [client script](/guide/client-context) (the [a11y](/plugins/a11y) scanner, the [data-inspector](/plugins/data-inspector) collector). Builds separately with Vite into `dist/inject`.
- **`src/shared/`** — protocol types and constants shared across the node/client seam (the a11y `protocol.ts`). Keep these free of environment-specific imports so either side can bundle them.
- **`src/engine/`, `src/registry/`** — domain subsystems that deserve their own module (the data-inspector's jora query engine and data-source registry).
- **`src/diagnostics.ts`** — [structured diagnostics](/guide/diagnostics) for any node-side warning or error, via `defineDiagnostics` from `devframe/utils/nostics`. Node-side only; browser code keeps using `console`/`throw`.
- **`playground/`** — Vite hosts that dev-serve the SPA against a live backend, one per consumption scope (a `single` bridge and a `hub` dock), as in the starter.
- **`test/` and `e2e/`** — `vitest` specs that boot the devframe in-process and exercise RPC over a real WebSocket, plus Playwright tests against a playground.

## What's next

- [Devframe Definition](/guide/devframe-definition) — every field of `defineDevframe`
- [RPC](/guide/rpc) — the function types you register
- [Client Assets](/guide/client-assets) — local dirs vs remote assets packages
- [Standalone CLI](/guide/standalone-cli) — the `createCac` command shell
- [Built-in Plugins](/plugins) — the layout applied at full scale
File renamed without changes.
File renamed without changes.
1 change: 1 addition & 0 deletions docs/content/1.guide/index.md
Original file line number Diff line number Diff line change
Expand Up @@ -170,6 +170,7 @@ The CLI adapter serves the SPA at `/`; embedded in a host (`vite`, `embedded`) t
## What's next

- [Devframe Definition](/guide/devframe-definition) — `defineDevframe` and `DevframeNodeContext`
- [Project Structure](/guide/project-structure) — a recommended folder layout for a devframe package
- [The Standard Handler](/adapters/initiate) — mount into any host
- [Adapters](/adapters) — convenience entry points
- [Hub](/guide/hub) — compose many devframes
Loading