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
17 changes: 17 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,23 @@ for how the document, manifest, and per-plugin versions relate.

## [Unreleased]

### Added
- **Module `script` entries.** §3 (Anatomy), §4.1/§4.3, new §6.8 (Splitting client code), and
`schemas/plugin.schema.json` document the optional **`scriptType`** manifest key: `"module"` loads
a plugin's `script` as an ES module (`<script type="module">`), letting the entry use top-level
`import` / `export` and `import.meta` and split its client code across a served **`src/`** module
tree; absent or `"classic"` is the unchanged classic-script behaviour. The optional **`minHost`**
key (minimum Host version) is documented alongside it. Purely additive — every existing manifest
still validates, and a Host that ignores `scriptType` keeps loading `script` as a classic script.

### Changed
- Best-practices "Organizing client code across files" (rules 27–29) rewritten around the new
paradigm: an ES-module entry (`scriptType: "module"` + a `src/` tree with real `import`/`export`)
is now the recommended way to split client code, with the classic single-`screen.js` bundle /
`window`-shared runtime split kept as the fallback for older Hosts. Corrects the prior text, which
stated `screen.js` is *always* a classic script and that only `assets/` is servable — both no
longer true once `scriptType: "module"` is set.

## [1.0.0] - 2026-07-06

First stable release of the feedBack plugin specification. Marks the normative spec and its
Expand Down
10 changes: 9 additions & 1 deletion schemas/plugin.schema.json
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,15 @@
},
"script": {
"type": "string",
"description": "Client JS module, path relative to the plugin directory."
"description": "Client JS entry, path relative to the plugin directory."
},
"scriptType": {
"type": "string",
"description": "How the Host loads `script`. \"module\" loads it as an ES module (<script type=\"module\">), enabling top-level import/export, import.meta, and a src/ module tree; absent or \"classic\" loads it as a classic script. Not enumerated as a hard constraint — any unrecognised value is treated as classic. See spec §4.3 and §6.8."
},
"minHost": {
"type": "string",
"description": "Minimum Host (core) version the plugin requires, semver RECOMMENDED. Advisory in the current Host (surfaced, not yet enforced). See spec §4.3."
},
"screen": {
"type": "string",
Expand Down
135 changes: 82 additions & 53 deletions spec/best-practices.md
Original file line number Diff line number Diff line change
Expand Up @@ -399,60 +399,85 @@ 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:
A plugin declares a single `script` entry (`screen.js`), but that doesn't force you into one giant
file. You can split your client code two ways, depending on how the Host loads your entry: as an **ES
module** (recommended, `scriptType: "module"`) or as a **classic script** (the default, and the only
option on older Hosts). The mechanics below are the current Host contract.

### 27. Split with an ES-module entry (`scriptType: "module"`)

Set `"scriptType": "module"` in `plugin.json` and make your entry a one-line re-export of a `src/`
tree — the Host then loads `screen.js` as `<script type="module">`, so real `import` / `export` work:

```jsonc
// plugin.json
{ "id": "my-plugin", "script": "screen.js", "scriptType": "module", "minHost": "0.3.0-alpha.1" }
```

```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
// screen.js — the entire entry
import './src/main.js';
```

```text
my-plugin/
├── screen.js # one-line module entry
├── src/ # served by the Host; imports resolve here
│ ├── main.js # import { x } from './util.js';
│ └── util.js # export function x() { … }
└── assets/ # worklets, wasm, css, images
```

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`.
The Host serves the `src/` tree (alongside `assets/`), so **relative** imports between your modules
resolve naturally — no hardcoded `/api/plugins/<id>/…` URLs. Modules share state through ES exports,
not `window`. Resolve non-JS assets against the module URL rather than a hardcoded base:

```js
// src/audio.js — loads a worklet that lives in ../assets/
const url = new URL('../assets/worklet.js', import.meta.url).href;
await audioCtx.audioWorklet.addModule(url);
```

Declare **`minHost`** (the minimum core version you need): an older Host ignores `scriptType` and
loads your entry as a classic script, where top-level `import` throws — `minHost` lets the Host
surface that instead of silently mounting a broken screen. Re-hydration idempotence (rule 12) still
applies: guard your global side effects, since the Host may re-mount your screen.

### 28. On an older Host: bundle to one classic `screen.js`, or split via `window`

Without `scriptType: "module"` — or on a Host too old to honour it — `screen.js` runs as a **classic
script**: no top-level `import` / `export`, no `import.meta`. Two options:

- **Bundle.** If you have build tooling, author many source files and bundle them into the one
`screen.js` you ship. The Host loads exactly one script, so a bundle sidesteps every runtime-split
concern below. (feedBack ships no bundler and serves plugin JS verbatim — this is your own build
step, the simplest path to a non-monolithic classic plugin.)
- **Split at runtime.** Put split-out `.js` (and any `.css`, workers, `.wasm`) under **`assets/`**,
which the Host serves path-traversal-guarded with the correct MIME type. Reference them by an
**absolute** URL (a relative `import('./part.js')` from a classic script resolves against the app
root, not your script, and silently 404s) — but **derive that URL from your own script's served URL
rather than hardcoding a route**: asset URLs are Host-assigned (spec §6.7), so build the base once
from `document.currentScript` at top level. Classic split files share state through **`window`**,
namespaced under your `id` with **bracket notation** (an `id` may contain `-`):
`(window.__feedBackPlugins ||= {})['my-plugin']`, never `window.my-plugin`.

```js
// Derive the asset base from this script's own served URL — don't hardcode the route
// (asset URLs are Host-assigned, spec §6.7). Capture it at top level, while
// document.currentScript is still your screen.js.
const ASSET_BASE = new URL('assets/', document.currentScript.src).href;
import(ASSET_BASE + 'lib/util.js').then(util => { /* ES module served from assets/ */ });
loadScriptOnce(ASSET_BASE + 'lib/legacy.js'); // classic, window-attaching helper (rule 29)
```

### 29. Load each split file exactly once
### 29. Load each classic 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:
When you split a **classic** entry at runtime (rule 28), the Host may re-run `screen.js` mid-session
(rule 12) and your screen may re-mount — so runtime loading must be **idempotent**. (A module entry
needs none of this: the browser evaluates each statically-imported module once and dedupes it for
you.) De-dupe with a cache kept **on `window`** (a module-local `Set` is wiped when `screen.js`
re-runs), 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
Expand Down Expand Up @@ -925,11 +950,15 @@ note changes per version. It costs little and saves every future reader — incl

**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.
- [ ] Module mode (`scriptType: "module"`): `screen.js` is a one-line `import './src/main.js';`,
modules live under `src/` and share state via `import`/`export`, assets resolve via
`import.meta.url`, and `minHost` is declared.
- [ ] Classic mode (no `scriptType`): extra JS lives under `assets/` (or a `routes.py`-served dir),
referenced by an absolute URL derived from the Host-served base (§6.7 — e.g. from
`document.currentScript`), not a hardcoded route, and split files share state via a
bracket-keyed `window["<id>"]` namespace (no `import`/`export` or top-level `await`).
- [ ] Split pieces load exactly once, so re-hydration doesn't load them twice (classic runtime
splits need an explicit `window`-kept de-dupe cache; a module graph dedupes itself).

**Integrating with the app:**

Expand Down
58 changes: 53 additions & 5 deletions spec/plugin-spec-v1.md
Original file line number Diff line number Diff line change
Expand Up @@ -65,7 +65,9 @@ A plugin is a directory whose name equals its `id`:
```text
tuner/ # directory name == manifest "id"
├── plugin.json # REQUIRED — the manifest
├── screen.js # client script (manifest "script")
├── screen.js # client script entry (manifest "script")
├── src/ # OPTIONAL — an ES-module tree the entry imports (see §6.8)
│ └── main.js
├── settings.html # settings panel markup (manifest "settings.html")
├── routes.py # server routes (manifest "routes")
├── assets/
Expand All @@ -77,6 +79,11 @@ Only `plugin.json` is REQUIRED. Every other file exists **because the manifest p
a file present but not referenced from the manifest is ignored by the Host (though it MAY still
be served as a static asset — see [§6.7](#67-static-assets)).

The `script` entry MAY be a single self-contained file, or — when the manifest sets
`"scriptType": "module"` (see [§4.3](#43-client-surface-keys--script-screen-styles)) — a small ES
module that imports the plugin's client code from a **`src/` module tree**. The Host serves that
tree so the entry's relative imports resolve; see [§6.8](#68-splitting-client-code-across-modules).

File and directory names inside a plugin SHOULD be lowercase with `-` or `_` separators. The
directory name (the `id`) MUST match `^[a-z0-9][a-z0-9_-]*$` (see [§4.2](#42-id)).

Expand All @@ -100,7 +107,9 @@ The machine-readable schema is [`schemas/plugin.schema.json`](../schemas/plugin.
| `version` | string | No | Plugin version (semver RECOMMENDED). |
| `description` | string | No | One-line description for listings. |
| `type` | string | No | Plugin kind, e.g. `"visualization"`. |
| `script` | string | No | Client JS module, relative path (e.g. `screen.js`). |
| `script` | string | No | Client JS entry, relative path (e.g. `screen.js`). |
| `scriptType` | string | No | How the Host loads `script`: `"module"` loads it as an ES module; absent or `"classic"` loads it as a classic script (see [§4.3](#43-client-surface-keys--script-screen-styles)). |
| `minHost` | string | No | Minimum Host (core) version the plugin requires (semver). Advisory in the current Host. |
| `screen` | string | No | Client screen markup, relative path (e.g. `screen.html`). |
| `styles` | string | No | CSS file, relative path (e.g. `assets/plugin.css`). |
| `settings` | object | No | Settings-panel declaration (see [§4.4](#44-settings)). |
Expand Down Expand Up @@ -146,6 +155,20 @@ plugin directory. See [§6](#6-client-surface) for how they are served.
A plugin with none of these contributes no client screen (it may still contribute `routes`,
`settings`, or `capabilities`).

**Classic vs. module `script` (`scriptType`).** By default the Host loads `script` as a *classic*
browser script that runs in the global scope. When the manifest sets `"scriptType": "module"`, the
Host loads `script` as an ES module (`<script type="module">`) instead. A module entry MAY use
top-level `import` / `export` and `import.meta`, and MAY split the plugin's client code across a
tree of sibling modules it imports — conventionally under `src/`, with a one-line
`screen.js` of `import './src/main.js';`. The Host serves that tree so those imports resolve; see
[§6.8](#68-splitting-client-code-across-modules). A classic entry cannot use top-level `import` /
`export` and shares state across split files through `window` instead.

`scriptType` is additive: an older Host that does not recognise it loads `script` as a classic
script (where a module entry's top-level `import` fails). A plugin that requires module loading
therefore SHOULD declare the minimum Host version it needs via `minHost`, so a Host too old to load
it as a module can surface that rather than silently loading a broken screen.

### 4.4. `settings`

`settings` is an object declaring the plugin's settings panel and its persisted files:
Expand Down Expand Up @@ -252,10 +275,13 @@ For each ready plugin that declares a `screen`, the Host:
1. creates a container element it owns — currently a `<div class="screen">` with a deterministic,
`id`-derived identifier (of the form `plugin-<id>`) — and inserts it into the app shell;
2. sets that container's markup from the plugin's `screen` file;
3. loads the plugin's `script` and executes it once.
3. loads the plugin's `script` — as a classic script, or as an ES module when the manifest sets
`scriptType: "module"` ([§4.3](#43-client-surface-keys--script-screen-styles)) — and executes it
once.

There is **no Host-invoked entry point**: the Host does not call a `mount()`, `init()`, or
`render()` export. A plugin's `script` is a self-executing module that runs on load, wires up its
There is **no Host-invoked entry point** in either case: the Host does not call a `mount()`,
`init()`, or `render()` export. A plugin's `script` is a self-executing unit that runs on load
(a module entry runs once its static-import graph has evaluated), wires up its
own behaviour, and finds its own DOM by the identifiers the plugin authored inside its `screen`
markup. A plugin therefore SHOULD namespace those identifiers under its `id` so they don't collide
with the Host's or another plugin's — every plugin shares one document.
Expand Down Expand Up @@ -357,6 +383,28 @@ The Host MAY serve files inside a plugin directory as static assets (images, aud
plugin MUST NOT rely on any file *outside* its own directory being served, and MUST NOT assume a
particular absolute URL — asset URLs are Host-assigned relative to the plugin.

### 6.8. Splitting client code across modules

A plugin whose `script` declares `"scriptType": "module"` ([§4.3](#43-client-surface-keys--script-screen-styles))
MAY split its client code across several ES modules instead of shipping one file. The established
layout is a one-line entry (`screen.js` → `import './src/main.js';`) over a **`src/` tree** of
modules that `import` / `export` from one another.

For those imports to resolve in the browser, the Host serves the `src/` tree in addition to
`assets/` ([§6.7](#67-static-assets)): the entry's relative `import './src/…'` and any imports
between `src/` modules are fetched from the plugin, path-traversal-guarded the same way assets are.
A module resolves its own non-JS assets (worklets, wasm, CSS) against its module URL — e.g.
`new URL('../assets/worklet.js', import.meta.url)` — rather than a hardcoded absolute path.

This is a Host capability, not a bundler: the Host serves the module files verbatim and the browser
resolves the graph. The **exact URL layout** by which `src/` is served is Host-provided and versioned
by the Host (like the rest of [§6.3](#63-the-client-runtime-surface)); what this specification pins is
that a module entry's own-directory relative imports are served, and that the re-hydration idempotence
requirement of [§6.1](#61-screen-mount-lifecycle) applies to a module entry exactly as to a classic one.

A plugin that does not set `scriptType: "module"` keeps the classic single-`script` contract; serving
of a `src/` tree is meaningful only for a module entry.

---

## 7. Server surface
Expand Down
Loading