|
| 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