Skip to content
Merged
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
8 changes: 8 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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/<id>/…` 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

Expand Down
92 changes: 87 additions & 5 deletions spec/best-practices.md
Original file line number Diff line number Diff line change
Expand Up @@ -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/<id>/lib/util.js`) returns 404; the same file under `assets/`
(`/api/plugins/<id>/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/<id>/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 `<script>` injection works for non-module
helpers that attach to `window`.

### 29. Load each split file exactly once

The Host may re-run your `screen.js` mid-session (rule 12), and your own screen may re-mount — so any
runtime loading must be **idempotent**. De-dupe it, but keep the cache **on `window`** (a module-local
`Set` is wiped when `screen.js` re-runs, so it wouldn't actually prevent a re-load), cache the
**in-flight promise** so concurrent callers share one load, and **drop the entry on failure** so a
transient error can be retried:

```js
const _loading = (window.__myPluginScripts ||= new Map()); // survives screen.js re-run
function loadScriptOnce(src) {
let p = _loading.get(src);
if (p) return p; // resolved or in-flight — reuse
p = new Promise((res, rej) => {
const s = document.createElement('script'); // classic; attaches to window
s.src = src; s.onload = res; s.onerror = rej;
document.body.appendChild(s);
}).catch(err => { _loading.delete(src); throw err; }); // allow retry after a failure
_loading.set(src, p);
return p;
}
```

---

## Shipping & good citizenship

### 27. Fail soft, log clearly
### 30. Fail soft, log clearly

- Use `context["log"]` (server) so your messages land in the Host log under your plugin's
namespace.
Expand All @@ -408,25 +482,25 @@ abandonment/cleanup for that hop.
- If a surface can't initialise, degrade to a reduced-but-working state rather than taking the
whole plugin down.

### 28. Degrade gracefully across Host versions
### 31. Degrade gracefully across Host versions

A plugin may run on a Host older than the one you developed against. Don't assume a `context` key
or a client runtime API exists without a documented Host version guaranteeing it. If an optional
surface isn't supported, your plugin's other surfaces must still work.

### 29. Only declare capabilities you actually implement
### 32. Only declare capabilities you actually implement

`capabilities` and `standards` wire you into cross-plugin pipelines (diagnostics, capability
inspection). Declaring a capability you don't service registers a phantom participant and breaks
the pipeline. If you don't participate, omit both keys entirely.

### 30. Mind the security boundary
### 33. Mind the security boundary

Your `routes` run arbitrary Python in the server process and your `script` runs in the app's
renderer. Validate every route input, don't shell out on user data, and don't reach outside your
plugin directory. Users installing your plugin are trusting it like an app extension — earn it.

### 31. Ship a README and a changelog
### 34. Ship a README and a changelog

A plugin folder should carry a short `README.md` (what it does, which Host version it targets) and
note changes per version. It costs little and saves every future reader — including you.
Expand Down Expand Up @@ -483,6 +557,14 @@ note changes per version. It costs little and saves every future reader — incl
- [ ] Standalone by default (`usesPlayer` only if the game drives the highway); results reported via
the SDK (host owns scoring/persistence); assumes a single active session.

**Split client code (if `screen.js` isn't a single bundle):**

- [ ] Extra JS lives under `assets/` (or a `routes.py`-served dir), not the plugin root, and is
referenced by absolute `/api/plugins/<id>/…` URLs.
- [ ] Split files share state via a bracket-keyed `window["<id>"]` namespace (classic script — no
`import`/`export` or top-level `await` in `screen.js`).
- [ ] Runtime loads are de-duped so re-hydration doesn't load them twice.

**Capabilities & shipping:**

- [ ] Capabilities/standards declared only if actually implemented; cross-plugin calls go through
Expand Down
Loading