Skip to content

Commit 7d097a1

Browse files
committed
docs: add Performance Best Practices guide
Documents keeping a tool's install footprint small via the deferred client-assets approach — splitting a plugin into a slim node package plus a lockstep '${name}--assets' package served on demand through the caching CDN back-proxy — with detailed declaration, publishing, resolution, offline, custom-provider, and static-build examples, plus a short section on runtime levers (cheap setup, streaming, serializable shared state).
1 parent 3687968 commit 7d097a1

2 files changed

Lines changed: 181 additions & 0 deletions

File tree

‎docs/.vitepress/config.ts‎

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,12 @@ function guideGroups(prefix: string) {
4141
{ text: 'Deep Linking', link: `${prefix}/guide/deep-linking` },
4242
],
4343
},
44+
{
45+
text: 'Performance',
46+
items: [
47+
{ text: 'Performance Best Practices', link: `${prefix}/guide/performance` },
48+
],
49+
},
4450
{
4551
text: 'JSON-Render',
4652
items: [

‎docs/guide/performance.md‎

Lines changed: 175 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,175 @@
1+
---
2+
outline: deep
3+
---
4+
5+
# Performance Best Practices
6+
7+
A devframe tool has two performance surfaces: the **package a consumer installs** (how many bytes `npm install` pulls) and the **work the node process does at runtime**. This page focuses on the first — keeping the installed footprint small by serving a plugin's browser assets on demand instead of shipping them inside the node package — and closes with the runtime levers.
8+
9+
## Why bundle size matters here
10+
11+
A plugin is two things fused into one package: a small amount of **node code** (RPC handlers, setup, the CLI) and a **prebuilt SPA** (the UI served in an iframe). The SPA dominates. Across the built-in plugins the browser assets are roughly **90% of the published tarball** — the inspector ships ~370 KB of SPA against ~40 KB of node code, and the Git plugin's Next.js export is over 12 MB.
12+
13+
Every consumer pays for those bytes at install time, even when they never open that plugin's panel. Deferring the assets turns that fixed cost into an on-demand one.
14+
15+
## The approach: deferred client assets
16+
17+
Split each plugin into two packages published in lockstep:
18+
19+
- **`@devframes/plugin-<name>`** — the node package. Slim: handlers, setup, CLI, and the small panel client scripts.
20+
- **`@devframes/plugin-<name>--assets`** — the prebuilt SPA, and nothing else.
21+
22+
The node package never bundles the SPA. Instead its `cli.distDir` names the assets package, and devframe's static-serving layer resolves it on demand: from a locally installed copy if present, from a version-locked on-disk cache, or streamed through a CDN back-proxy (jsDelivr by default) that mirrors npm — caching each file as it goes. The first visit to a panel fetches its assets once; every visit after is served from the local cache.
23+
24+
The result: installing `@devframes/plugin-inspect` drops from ~409 KB to ~18 KB. The UI still works with no extra install — it's fetched the first time it's opened.
25+
26+
The companion package's suffix is `--assets` (double dash). It's `assets` rather than `client` because "client" already has a meaning in devframe — `devframe/client` is the RPC client, and a plugin's `./client` export is its panel script — and the double dash separates the generated companion from the plugin's own dash-delimited name segments.
27+
28+
## Declaring it in a plugin
29+
30+
Point `cli.distDir` at a `RemoteAssets` object instead of a local directory. The `package`/`version` are interpolated into the CDN URL and cache path, so they're validated ([`DF0065`](../errors/DF0065)) — `package` must be a valid npm name and `version` an exact semver.
31+
32+
```ts
33+
import type { DevframeDefinition, RemoteAssets } from 'devframe'
34+
import { defineDevframe } from 'devframe'
35+
import pkg from '../package.json' with { type: 'json' }
36+
37+
// The SPA ships in the lockstep `@devframes/plugin-inspect--assets` package.
38+
// `resolveFrom` lets a locally installed copy be served with zero network;
39+
// otherwise assets stream on demand through the caching CDN back-proxy.
40+
const distDir: RemoteAssets = {
41+
package: `${pkg.name}--assets`,
42+
version: pkg.version,
43+
resolveFrom: import.meta.url,
44+
}
45+
46+
export function createInspectDevframe(): DevframeDefinition {
47+
return defineDevframe({
48+
id: 'devframes_plugin_inspect',
49+
name: 'Devframe Inspector',
50+
version: pkg.version,
51+
packageName: pkg.name,
52+
cli: {
53+
command: 'devframes_plugin_inspect',
54+
distDir,
55+
},
56+
setup(ctx) {
57+
// …
58+
},
59+
})
60+
}
61+
```
62+
63+
Anywhere a static mount accepts a dist directory — `cli.distDir`, `hostStatic`, `mountStatic` — also accepts this object, so nothing else in the serving pipeline changes.
64+
65+
### The `RemoteAssets` fields
66+
67+
| Field | Purpose |
68+
|-------|---------|
69+
| `package` | npm package that ships the assets (e.g. `@devframes/plugin-inspect--assets`). |
70+
| `version` | Exact version to serve — usually the node package's own `pkg.version`, so the two stay in lockstep. |
71+
| `resolveFrom` | `import.meta.url` of the declaring module. Enables the zero-network path: a locally installed copy of `package` is resolved from this module's own dependency graph first. Omit it to skip straight to cache + CDN. |
72+
| `path` | Subpath inside the package the assets live under. Defaults to `dist`. |
73+
| `provider` | `'jsdelivr'` (default), `'unpkg'`, or a custom provider for an internal mirror. |
74+
| `offline` | `true` serves only from a locally installed copy or the cache — never the network. |
75+
76+
## Publishing the assets package
77+
78+
The assets package is a nested workspace package with no source of its own — the plugin's existing Vite build simply emits into it.
79+
80+
Point the SPA build's `outDir` at the sibling package:
81+
82+
```ts
83+
// plugins/inspect/src/spa/vite.config.ts
84+
export default defineConfig({
85+
base: './', // keep assets mount-path portable
86+
build: {
87+
// Emit into the sibling assets package instead of the node package.
88+
outDir: fileURLToPath(new URL('../../assets-pkg/dist', import.meta.url)),
89+
emptyOutDir: true,
90+
},
91+
})
92+
```
93+
94+
The assets package ships only that `dist`, and exposes its `package.json` so `resolveFrom` can locate it:
95+
96+
```json
97+
{
98+
"name": "@devframes/plugin-inspect--assets",
99+
"type": "module",
100+
"version": "0.9.0-beta.4",
101+
"description": "Prebuilt browser assets for @devframes/plugin-inspect.",
102+
"exports": {
103+
"./package.json": "./package.json"
104+
},
105+
"files": ["dist"]
106+
}
107+
```
108+
109+
Keep the two versions identical. With a recursive version bump (`bumpp -r`, `changesets`, …) that happens automatically — the assets package is versioned alongside the node package, and `version: pkg.version` in the declaration always points at the matching release.
110+
111+
In a monorepo, add the assets package as a **dev-only** dependency of the plugin:
112+
113+
```jsonc
114+
{
115+
"devDependencies": {
116+
// Workspace link so `resolveFrom` finds the local build during dev —
117+
// NOT a runtime dependency, so published consumers don't auto-install it.
118+
"@devframes/plugin-inspect--assets": "workspace:*"
119+
}
120+
}
121+
```
122+
123+
Making it a `devDependency` (never a `dependency`) is what keeps the assets deferred: a consumer installing the node package gets the slim tarball, and the SPA arrives on demand.
124+
125+
## How resolution works
126+
127+
For each request, the source resolves in order:
128+
129+
1. **Locally installed package** (via `resolveFrom`) — served directly, zero network. This is the monorepo dev path (the workspace link) and the offline path (an explicit `npm install`). A version mismatch warns; a *major* mismatch throws.
130+
2. **On-disk cache** — `.remote-assets/<package>@<version>/` under the project's storage directory. Populated as files stream through.
131+
3. **CDN back-proxy** — each requested file is streamed to the browser and cached on the way past. Exact-version URLs are immutable, so the cache never goes stale.
132+
133+
If none succeed (no install, no cache, and the network is unreachable), an HTML navigation gets a small styled error page pointing at the fix; other requests get a 502. The server keeps running.
134+
135+
## Offline and air-gapped environments
136+
137+
The deferred model is a latency optimization, not a hard network dependency. To run with no network at all, install the assets package explicitly — resolution step 1 then serves it locally and nothing reaches out:
138+
139+
```sh
140+
npm install @devframes/plugin-inspect--assets
141+
```
142+
143+
Pre-seeding the on-disk cache (rsync-ing `.remote-assets/`) works too. For a fully offline default, set `offline: true` on the declaration so the CDN is never contacted.
144+
145+
## Corporate mirrors and custom providers
146+
147+
Behind a proxy that mirrors npm, point `provider` at it. A custom provider supplies the file URL and, optionally, a file listing (used for correct 404s, SPA fallback, and build-time materialization):
148+
149+
```ts
150+
const distDir: RemoteAssets = {
151+
package: '@acme/plugin-foo--assets',
152+
version: pkg.version,
153+
resolveFrom: import.meta.url,
154+
provider: {
155+
fileUrl: (name, version, file) =>
156+
`https://npm.internal.acme.com/${name}@${version}/${file}`,
157+
},
158+
}
159+
```
160+
161+
## Static builds stay self-contained
162+
163+
A static export (`createBuild`) must carry every asset, so a remote source is **materialized** at build time: the full file set is downloaded into the output. A locally installed assets package is copied instead of fetched, so CI that already installs it produces the build with no network.
164+
165+
> [!WARNING]
166+
> The default providers (jsDelivr, unpkg) are third-party CDNs. For environments that must not depend on one, install the `--assets` package (or pre-seed the cache) so resolution never leaves step 1, or point `provider` at an internal mirror.
167+
168+
## Runtime levers
169+
170+
Beyond install size, a few habits keep the running process light:
171+
172+
- **Keep `setup` cheap.** It runs on every server start. Register RPC functions and defer expensive work (indexing, watching, spawning) until a call actually needs it.
173+
- **Stream large payloads.** For growing or large results, use a [streaming channel](./streaming) instead of returning one big value — the client renders incrementally and memory stays bounded.
174+
- **Keep shared state serializable and small.** [Shared state](./shared-state) is synced to every client; store identifiers and let clients fetch detail on demand rather than mirroring large structures.
175+
- **Prefer exact-version, immutable references.** The deferred-assets cache relies on exact versions being immutable; the same principle keeps any cached fetch you add safe to reuse.

0 commit comments

Comments
 (0)