feedBack/docs/plugin-modules.md
Byron Gamatos 950e348357
Some checks failed
ship-ci / ci (push) Has been cancelled
R0: module-migration rails (src/ serving, live-edit cache, scriptType loading, governance) (#812)
Host enablement for the plugin ES-module migration: sandboxed /api/plugins/{id}/src/ serving, no-cache+weak-ETag/304 live-edit caching on src/+screen.js+assets, scriptType:module loader injection + scriptType/minHost manifest passthrough; constitution v1.2.0 + module playbook + signed size-exemptions register + maintainer/CI-only ESLint gate; rerunnable perf-baseline harness. Reviewed by Codex (local), Copilot, and CodeRabbit.
2026-07-08 10:14:40 +02:00

104 lines
5.6 KiB
Markdown

# Plugin ES-module migration playbook
How to move a plugin off a single global-scope `screen.js` IIFE onto a native
ES-module graph — **no build step, no framework, no bundler**. This is the
mechanism the monolith-killing refactor uses; the host rails for it shipped in
R0 (see `.specify/memory/constitution.md` Principle II + the "Module load
contract" in Operating Constraints).
## The shape
```
my-plugin/
plugin.json + "scriptType": "module" ← opt in
screen.js import './src/main.js'; ← the entire file
src/
state.js (0) module state + accessors
util/… (1) pure helpers — real-import testable
…/… (2..4) model → render/audio/io → input
globals.js (5) THE ONLY file that writes window.*
main.js (5) boot: wire modules, register screen:changed
assets/… worklets / WASM / images (unchanged, served as today)
```
`screen.js` becomes a one-line static `import`. The host injects it as
`<script type="module">`, whose load event fires **only after the whole
static-import graph fetches and evaluates** — so the loader's
completion-by-`onload` + `_loadingPluginId` window + `playSong` wrapper-chain
order are all preserved. (A classic IIFE that fired a fire-and-forget
`import()` would break that contract — don't do that; use `scriptType:"module"`.)
## Non-negotiable rules
1. **Source-served, no build.** Modules are plain source files fetched from
`/api/plugins/<id>/src/<path>`. No bundler, transpiler, or TypeScript.
2. **Layering points downward** — `state → util → commands/model →
render/audio/io → input → globals/main`. A lint check (`import-x/no-cycle`)
enforces acyclicity; extract bottom-up so each move only imports
already-extracted layers.
3. **`globals.js` is the only writer of `window.*`.** The deliberate global
surface shrinks to one auditable file; everything else is module-scoped.
4. **Import-time purity.** `node --test` runs a module's top-level code on
import, so a module you want to unit-test must be side-effect-free at import:
no `document` / `window` / `localStorage` at module top level — lift init
into an exported `init()` called by `main.js`. (Constitution Principle V's
"no implicit IO at import time", applied to the frontend.) Tests are `.mjs`
and use real `import`, retiring the regex/`extractFunction` harness.
5. **Assets resolve via `import.meta.url`.** `document.currentScript` is `null`
inside a module. `assets/` lives at the plugin root, so a `src/` module must
climb out of `src/`: from `src/main.js`, `new URL('../assets/x.js',
import.meta.url)` (deeper modules need more `../`). Simpler and
depth-independent: the absolute route `/api/plugins/<id>/assets/x.js`.
Worklets run in a *separate* module graph (`AudioWorkletGlobalScope`) and
cannot share modules with `src/`.
6. **Re-init comes from `screen:changed`, not re-execution.** The host loads
`screen.js` once per version and `showScreen` re-injects nothing, so module
top-level code does **not** re-run when the user re-enters the screen at the
same version. Keep per-visit setup/teardown in a `window.feedBack.on(
'screen:changed', …)` handler — exactly as classic plugins (tuner,
minigames) already do. Do not rely on the IIFE re-running.
7. **Inline `onclick=` keeps working** during migration via `globals.js` (which
keeps every referenced symbol on `window`); retire inline handlers to
module-side `addEventListener` opportunistically, never as a blocking step.
## The live-edit loop
The host serves `screen.js`, `src/**`, and `assets/**` with
`Cache-Control: no-cache` + a weak `ETag` and honors `If-None-Match``304`.
So: edit a `src/` file → **refresh the browser** → the edited module returns
`200` and reloads while every unchanged module `304`s. There is no hot-reload;
the loop is edit → refresh → see change, exactly as before. The `?v=<version>`
query on `screen.js` is the legacy version buster; it does **not** propagate
into the `src/` graph and does not need to — ETag/mtime is the correctness
authority for the whole graph.
## Host-version floor (`minHost`)
A migrated plugin *requires* a host new enough to serve `src/` and inject
`type=module`. Declare the floor with `"minHost": "X.Y.Z"` in `plugin.json`.
(R0 plumbs the field through `/api/plugins`; enforcement — refuse-with-message
on an older host — is deferred, so bundled plugins are unaffected. Community
plugins should state the floor and not migrate below it.)
## Migration mechanics
- **Move-only PRs.** One slice extracts one module: cut code, add
imports/exports, update `globals.js` — zero behavior change. Behavior fixes
are separate PRs. (Init-lifts for import purity are the one non-pure move —
budget them.)
- **Bottom-up, layer by layer.** Within a layer, independent modules are
independent PRs (a DAG, not a chain); use a git worktree per branch.
- Tests move with their subject and convert to real `.mjs` imports in the same
PR (assertions unchanged).
- Size norm: no source file over **1,500 lines**; legitimate exceptions
(hot renderers, etc.) go in the signed register at `docs/size-exemptions.md`.
## Verifying a migration
`node --test <plugin>/tests/*.mjs`; load the plugin on the `:8000` testbed and
confirm it boots (`<script type=module>` in DevTools, the `src/` graph in
Network); edit a `src/` file → refresh → change visible (`200` on the edited
file, `304` on the rest); leave and re-enter the screen at the same version →
it re-inits via `screen:changed`. The R1 pilots (stems, then studio) certify
this end-to-end before the flagship repos migrate.