diff --git a/CHANGELOG.md b/CHANGELOG.md index c7d0a42..1603f86 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -67,6 +67,14 @@ for how the document, manifest, and per-plugin versions relate. `start` (double-tap / navigate-away races), standalone-by-default vs `usesPlayer`, and host-owned scoring/persistence (report via the SDK; single active session). Added a Minigames checklist block. Docs only. +- Best-practices guide: added an **"Organizing client code across files"** section for splitting a + plugin's client JS instead of shipping one monolithic `screen.js`, ground-truthed against how the + Host loads and serves plugin JS. Covers bundling to one `screen.js` as the simplest path; + the constraint that `screen.js` is a **classic script** (no `import`/`export`/`import.meta` — split + files share state via `window`); serving extra files from **`assets/`** (the plugin root isn't + servable) referenced by **absolute `/api/plugins//…` URLs** (relative resolves against the + document, not the script); and **idempotent** runtime loading so re-hydration doesn't double-load. + Added a matching checklist block. Docs only. ## [0.1.0] - 2026-07-05 diff --git a/spec/best-practices.md b/spec/best-practices.md index e27f27b..51406c5 100644 --- a/spec/best-practices.md +++ b/spec/best-practices.md @@ -397,9 +397,83 @@ abandonment/cleanup for that hop. --- +## Organizing client code across files + +A plugin declares a single `script` (`screen.js`), but that doesn't force you into one giant file. +You can split your client code — you just have to work within how the Host loads and serves it. +The mechanics below are the current Host contract. + +### 27. Prefer bundling to one `screen.js`; split at runtime only when you must + +If you have any build tooling, author your plugin as many source files and **bundle them into the +one `screen.js` you ship**. The Host loads exactly one script, so a bundle sidesteps every gotcha in +the next two rules — no extra routes, no load-order or idempotency concerns. (feedBack ships no +bundler and serves plugin JS verbatim, so this is your own build step, not a Host feature — but it's +the simplest path to a non-monolithic plugin.) + +If you split at runtime instead, know the constraint that shapes everything else: **`screen.js` runs +as a *classic* script, not an ES module.** Top-level `import` / `export` and `import.meta` do not +work in it. Split files therefore share state through **`window`** (namespaced under your `id`, per +rule 2), not through ES exports — each file attaches what it provides to a per-plugin object and +reads its dependencies from there. Key that object by `id` with **bracket notation**, since an `id` +may contain `-` (which isn't a valid JS identifier): `window['my-plugin']` or a shared +`(window.__feedBackPlugins ||= {})['my-plugin']` — not `window.my-plugin`, which is a syntax error. + +### 28. Serve extra files from `assets/`, and reference them by absolute URL + +The plugin **root is not a servable directory** — only `screen.js`, `screen.html`, `settings.html`, +`tour.json`, and everything under **`assets/`** are served. A helper at your plugin root +(`/api/plugins//lib/util.js`) returns 404; the same file under `assets/` +(`/api/plugins//assets/lib/util.js`) is served by the Host, path-traversal-guarded and with the +correct JavaScript MIME type. So put your split-out `.js` (and any `.css`, workers, or `.wasm`) +under `assets/`. (If you genuinely need a non-`assets/` layout, a `routes.py` can serve your own +sibling directories — but `assets/` is the built-in path and needs no server code.) + +Reference these files by an **absolute** `/api/plugins//assets/…` URL, never a relative one. +Because `screen.js` is a classic script, a relative `import('./part.js')` resolves against the +document's base URL (the app root), not your script — so it silently hits the wrong path. Build a +base constant once and use it everywhere: + +```js +const ASSET_BASE = '/api/plugins/my-plugin/assets/'; // hardcode your id +// screen.js is a classic script, so there's no top-level await — use .then (or an async IIFE): +import(ASSET_BASE + 'lib/util.js').then(util => { /* ES module served from assets/ */ }); +// or a classic, window-attaching helper: +loadScriptOnce(ASSET_BASE + 'lib/legacy.js'); // see rule 29 +``` + +Dynamic `import()` of a real ES module works this way (the module can use `import`/`export` among +*its own* files, addressed by absolute URL); classic `