@atolljs/islands 0.1.0 → 0.1.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -1,38 +1,49 @@
1
1
  # @atolljs/islands
2
2
 
3
3
  Framework islands inside Atoll workers — opt-in DOM rendering off the main
4
- thread. The vocabulary:
4
+ thread. A real framework renderer commits against a DOM-free host surface in
5
+ the worker; every commit serializes to an op stream the main thread replays as
6
+ DOM mutations.
7
+
8
+ **[Atoll — package documentation](https://jwhenry3.github.io/atolljs/consumer/#/quickstart)**
9
+
10
+ The vocabulary:
5
11
 
6
- - **Atoll** — the fabric: worker pools, contracts, shared memory, task dispatch
7
- (`@atolljs/core`).
8
12
  - **PolyWorker** — one worker hosting a REGISTRY of islands
9
- (`definePolyWorker({ apps })`). The bundle-optimization shape: several
10
- islands share one module graph, one framework runtime, one op pump.
13
+ (`definePolyWorker({ apps })`). Several islands share one module graph, one
14
+ framework runtime, one op pump — the bundle-optimization shape.
11
15
  - **MonoWorker** — one worker pinned to a single island app
12
- (`defineMonoWorker(app)`). The isolation shape: own bundle, own failure
13
- domain, nothing reachable outside it.
14
- - **Island** — the framework pillar and the mounted unit: `*-island` packages
15
- are the per-framework worker renderers; `mountIsland()` mounts one instance
16
- of a registered app. Each mounted instance gets a `app@N` **instance key**
17
- that scopes its op queue, document, and events.
16
+ (`defineMonoWorker(app)`). Own bundle, own failure domain — the isolation
17
+ shape.
18
+ - **Island** — one mounted instance of a registered app. Each gets a `app@N`
19
+ **instance key** that scopes its op queue, document, and events.
20
+
21
+ ## Install
18
22
 
19
- Inside the worker, a real `react-reconciler@0.34` (React islands) or the
20
- per-framework renderer (Vue/Svelte/Solid/Angular islands) commits against a
21
- DOM-free host surface; every commit serializes to an op stream the main
22
- thread replays as DOM mutations. Imperative (non-framework) apps get a proxy
23
- `document` whose mutations emit the same ops — plus `installDomShim(doc)`,
24
- which sets `globalThis.document`/`window` so real DOM-dependent libraries
25
- run unmodified inside an instance.
23
+ ```bash
24
+ npm install @atolljs/core @atolljs/islands # framework-neutral core
25
+ npm install @atolljs/react-island # + a shell binding for your framework
26
+ ```
27
+
28
+ Shell bindings (main-thread components + per-framework worker renderers):
29
+ [`@atolljs/react-island`](../react-island),
30
+ [`@atolljs/vue-island`](../vue-island),
31
+ [`@atolljs/svelte-island`](../svelte-island),
32
+ [`@atolljs/solid-island`](../solid-island),
33
+ [`@atolljs/angular-island`](../angular-island).
34
+ A registry worker can mix apps from several frameworks — plus imperative
35
+ proxy-DOM apps — freely.
26
36
 
27
37
  ## Quickstart
28
38
 
29
39
  ```ts
30
40
  // render.worker.ts — the whole worker entry
31
41
  import { definePolyWorker } from '@atolljs/islands/worker';
42
+ import { reactIslandApp } from '@atolljs/react-island/worker';
32
43
 
33
44
  export const renderWorker = definePolyWorker({
34
45
  apps: {
35
- dashboard: DashboardApp, // a React component
46
+ dashboard: reactIslandApp(DashboardApp), // a React component
36
47
  vanilla: { imperative: (doc, props) => { ... } } // or pure proxy-DOM code
37
48
  },
38
49
  });
@@ -55,137 +66,69 @@ island.destroy();
55
66
  ```
56
67
 
57
68
  Pass `client: connectIslandWorker({ worker })` instead of `worker` when
58
- several islands should share one worker (multi-island-per-worker — the
59
- client is released when its last island destroys).
60
-
61
- ## Worker entries per bundler
62
-
63
- The `worker` option is a factory — the bundler must see the worker script as
64
- an entry, which is where setups diverge:
65
-
66
- - **Vite / webpack 5**: `() => new Worker(new URL('./x.worker.ts', import.meta.url), { type: 'module' })` — the `new URL` literal is what the bundler detects; don't hoist or compute it.
67
- - **Vite alternative**: `import XWorker from './x.worker.ts?worker'` then `worker: () => new XWorker()`.
68
- - **esbuild / others**: the URL pattern needs the bundler's worker handling (esbuild bundles it only in `format: 'esm'` builds); otherwise compile the worker entry separately and pass `worker: new URL('/assets/x.worker.js', import.meta.url)`-style resolved URLs.
69
-
70
- A path the bundler didn't resolve shows up as a `mountIsland` `mountTimeout`
71
- rejection naming the unanswered entry — not a silent hang.
72
-
73
- ## React shells: `@atolljs/react-island`
74
-
75
- When the *shell* itself is a React app, the companion package
76
- [`@atolljs/react-island`](../react-island) wraps these calls in
77
- components — `<Island/>` for the declarative `mountIsland`, and the
78
- `islandComponent`/`lazyIsland` proxies that make a worker app look and type
79
- like a local component (Suspense on the module load, `fallback` prop on the
80
- mount). This package stays React-free on the shell side by design — the
81
- component layer lives there.
82
-
83
- App contracts still live here: `islandApp(name, app)` stamps an app with its
84
- registry name (a data property — minification-proof, unlike `fn.name`) so a
85
- shell-side component reference resolves to the wire key, and
86
- `definePolyWorker` warns if a stamp and its registry key drift apart.
87
- `IslandAppProps<A>` infers a reference's props type from its signature.
88
-
89
- ## Framework worker renderers
90
-
91
- The third app kind: `RenderedIslandApp` — `{ mount({instance, doc, props}) →
92
- { update?, dispose? } }`. The app renders into the instance's proxy document
93
- with its own native renderer; every mutation emits the same op stream, and
94
- `update`/`dispose` give the framework fine-grained `updateProps` and clean
95
- teardown instead of the imperative clear-and-rebuild. Framework bindings
96
- ship as `*-island` packages — each re-exports `definePolyWorker`/`emit`
97
- so the worker entry needs no direct islands import:
69
+ several islands should share one worker (the client is released when its last
70
+ island destroys). Framework apps ship via the `*-island` packages — each
71
+ re-exports `definePolyWorker`/`emit` so the worker entry needs no direct
72
+ islands import:
98
73
 
99
74
  ```ts
100
- // vue: import { defineVuePolyWorker } from '@atolljs/vue-island/worker'
101
- // svelte: import { defineSveltePolyWorker } from '@atolljs/svelte-island/worker'
102
- // solid: import { defineSolidPolyWorker } from '@atolljs/solid-island/worker'
103
- // angular: import { defineAngularPolyWorker } from '@atolljs/angular-island/worker'
75
+ import { defineVuePolyWorker } from '@atolljs/vue-island/worker';
104
76
  export const worker = defineVuePolyWorker({ apps: { counter: Counter } });
77
+ // also: defineReactPolyWorker, defineSveltePolyWorker,
78
+ // defineSolidPolyWorker, defineAngularPolyWorker
105
79
  ```
106
80
 
107
- | Package | Worker renderer | updateProps | Notes |
108
- |---|---|---|---|
109
- | `@atolljs/vue-island` | Vue `createRenderer` → proxy DOM | fine-grained (`cloneVNode` + `render`) | `.once`/`.passive`/`.capture` modifiers ride the wire; out-of-task commits arrive on `flush()`/doorbell |
110
- | `@atolljs/svelte-island` | Svelte 5 `mount()` into `doc.body`, `$state` props box | fine-grained | Compiled components only (vite-plugin-svelte); `emit`/`runInInstance` re-exported |
111
- | `@atolljs/solid-island` | `solid-js/universal` `createRenderer` | fine-grained (per-key signal props) | **Pin the client build** — `worker`/`node` resolve conditions pick the SSR build; the adapter probes and throws if it happens anyway. JSX needs `babel-preset-solid` `{generate:'universal'}` (see its README) |
112
- | `@atolljs/angular-island` | `RendererFactory2`/`Renderer2` → proxy DOM + `createComponent`, zoneless | `setInput` + manual CD | JIT components need `import '@angular/compiler'` in the worker entry (missing it throws a named error); AOT/`ɵcmp` components need nothing |
113
-
114
- All four speak the same protocol — a registry worker can mix React,
115
- Vue, Svelte, Solid, Angular, and imperative apps freely.
116
-
117
- ## Bundle composition — what's in a worker bundle
118
-
119
- The `/worker` entry is framework-neutral: `definePolyWorker`, the proxy
120
- DOM, the op protocol, event dispatch, `emit`/`callbackProp`, and instance
121
- lifecycle. Framework runtimes arrive only through the binding you import:
122
-
123
- - **React islands** — `react` + `react-reconciler` + the host config live in
124
- `worker/reactInstance.ts`, reached via `await import()` the first time a
125
- registered app resolves to a React component. A worker whose registry
126
- holds only Vue/Svelte/Solid/Angular/imperative apps never loads it —
127
- bundlers put it in a lazily-fetched chunk (or drop it entirely when the
128
- worker entry can't reach it statically).
129
- - **Vue/Svelte/Solid/Angular islands** — each `-island` package statically
130
- imports only its own framework. A `defineVuePolyWorker` entry bundles
131
- islands core + Vue, and no other framework.
132
- - **Imperative-only** — islands core alone (plus `htmlparser2`, which
133
- the proxy DOM's `innerHTML`/`insertStaticContent` need).
134
-
135
- Two caveats: the `Slot` helper is a React component (its JSX pulls in
136
- `react/jsx-runtime` — a few KB, tree-shaken when unused), and
137
- `definePolyWorker`'s mount task is `async` because a React first-mount
138
- awaits the runtime chunk — callers already `await` it, so this is only a
139
- typing-level detail.
81
+ Stamp apps with `islandApp(name, app)` so a shell-side component reference
82
+ resolves to its registry key — the stamp is a data property, minification-proof
83
+ (unlike `fn.name`).
140
84
 
141
- ## Writing a worker renderer
142
-
143
- A renderer adapter is a `RenderedIslandApp` — one method:
85
+ ## Worker entries per bundler
144
86
 
145
- ```ts
146
- interface RenderedIslandApp {
147
- mount(ctx: { instance: string; doc: ProxyDocument; props: Record<string, unknown> }):
148
- { update?(props): void; dispose?(): void } | void;
149
- }
150
- ```
87
+ The `worker` option is a factory — the bundler must see the worker script as
88
+ an entry:
151
89
 
152
- `mount` runs inside the instance's scope; everything the framework writes into
153
- `ctx.doc` serializes to the op stream. The proxy-DOM surface a renderer can
154
- rely on: `createElement`/`createElementNS`/`createTextNode`/`createComment`,
155
- `insertBefore`/`appendChild`/`removeChild` (fragments splice their children
156
- in), `setAttribute`/`setAttributeNS`, `textContent`, `innerHTML`,
157
- `cloneNode`, `template.content`, and `addEventListener` (`{once, passive,
158
- capture}` options cross the wire). Comments are real anchors — frameworks
159
- placing branch markers (Vue `v-if`, Svelte `{#if}`) should use
160
- `doc.createComment`, not empty text nodes.
90
+ - **Vite / webpack 5**: `() => new Worker(new URL('./x.worker.ts', import.meta.url), { type: 'module' })` — the `new URL` literal is what the bundler detects; don't hoist or compute it.
91
+ - **Vite alternative**: `import XWorker from './x.worker.ts?worker'` then `worker: () => new XWorker()`.
92
+ - **esbuild / others**: the URL pattern needs the bundler's worker handling; otherwise compile the worker entry separately and pass a resolved URL.
161
93
 
162
- Instance discipline: `mount` itself is instance-scoped, but framework schedulers
163
- that flush *after* the task (Vue's microtask queue, Svelte's tick) have no
164
- active instance — resolve via `getActiveInstance() || getLastActiveInstance()` and
165
- prefer the captured `ctx.instance`/`ctx.doc` over ambient lookup. Work the
166
- renderer kicks off from timers or promise continuations must re-enter with
167
- `runInInstance(instance, fn)` — with several islands on one shared client the
168
- last-active fallback can resolve the *sibling* instance, so treat ambient
169
- resolution as a convenience, not a contract. After mutations made outside a
170
- dispatch task, `bumpOpsVersion()` rings the push-mode doorbell so the
171
- driver flushes the queued ops.
94
+ A path the bundler didn't resolve surfaces as a `mountIsland` `mountTimeout`
95
+ rejection naming the unanswered entry — not a silent hang.
172
96
 
173
- `update(props)` receives the newly serialized props — patch fine-grained.
174
- Omit it and `updateProps` degrades to imperative semantics (dispose +
175
- fresh document + remount). `dispose()` runs inside the instance before its
176
- proxy document is torn down — unmount framework roots, stop effects.
97
+ ## Island rules
177
98
 
178
- Events: worker-side `addEventListener` becomes a `listen` op; a dispatch
179
- round-trips back as a instance-scoped task whose payload carries `target`/
180
- `currentTarget` as proxy nodes and stamps `e.target.value`/`checked`.
181
- `preventDefault` can never work — the real event already dispatched on the
182
- main thread — so renderers should not promise it.
99
+ - **Pool size is pinned to 1.** One tree lives in one worker's memory — scale
100
+ out with more islands, not wider pools.
101
+ - **Instance keys** are `app` or `app@N`; `mountIsland` mints them and the
102
+ same app can mount in many islands at once.
103
+ - **Clients can be shared.** Several `mountIsland`s into one
104
+ `connectIslandWorker` co-locate their instances in one worker; `destroy()`
105
+ unmounts just that instance and the worker terminates when its last island
106
+ leaves.
107
+ - **Async commits flush themselves.** Push mode (the default) subscribes the
108
+ shared-memory doorbell — `useEffect` commits, timers, and scheduler
109
+ post-task work arrive with no ritual. `mountIsland({ mode: 'poll' })` needs
110
+ no `SharedArrayBuffer`, so no COOP/COEP cross-origin isolation.
111
+ - **`emit(name, payload)`** is the island→shell channel;
112
+ **`callbackProp(fn)`** is the shell→worker half — pass it in props and the
113
+ worker receives a callable (fire-and-forget).
114
+ - **Slots** — a worker-side `<Slot name="x"/>` (React islands:
115
+ `@atolljs/react-island/worker`) renders a `data-atoll-slot` leaf the shell
116
+ fills with real main-thread DOM.
117
+ - **The proxy DOM is write-path-plus-container-geometry.** Shadow-tree reads
118
+ work; only the island container is measured (pushed via `ResizeObserver`).
119
+ Everything else returns honest 0/empty.
120
+ - **Worker-initiated DOM work outside a task** must re-enter via
121
+ `runInInstance(instance, fn)` + `bumpOpsVersion()` — ambient
122
+ `document`/`window` resolution is a heuristic, not a contract.
123
+ - **`preventDefault` can never work** — the real event already dispatched on
124
+ the main thread. Keep high-frequency input (`pointermove`, per-keystroke)
125
+ on the main thread; slots exist for exactly this.
183
126
 
184
127
  ## Testing islands in-process
185
128
 
186
- `@atolljs/core/testing/inProcessWorker` ships a `Worker` test double
187
- that runs the whole protocol in-process — real task registry, real op
188
- stream, real shared-memory binding; only the thread boundary is faked:
129
+ `@atolljs/core/testing/inProcessWorker` ships a `Worker` test double that runs
130
+ the whole protocol in-process — real task registry, real op stream, real
131
+ shared-memory binding; only the thread boundary is faked:
189
132
 
190
133
  ```ts
191
134
  import { InProcessWorker } from '@atolljs/core/testing/inProcessWorker';
@@ -195,152 +138,18 @@ InProcessWorker.handlerModules = [() => import('./my.worker')];
195
138
  const island = await mountIsland({ worker: () => new Worker(url, { type: 'module' }), ... });
196
139
  ```
197
140
 
198
- Entry modules load lazily on first pool init (like a real worker script —
199
- and even for doorbell-free, message-only pools); a module that throws fires
200
- an `error` event and the worker drops subsequent tasks, so mount failures
201
- reject instead of hanging. `InProcessWorker.created` tracks spawned
202
- instances; `flushObservers()` settles microtasks + the shared-memory
203
- observer loop.
204
-
205
- ## Island rules
206
-
207
- - **poolSize is pinned to 1.** One tree lives in one worker's memory —
208
- scale out with more islands (`connectIslandWorker` per island), not wider pools.
209
- - **Two worker entries.** `definePolyWorker({ apps })` is a registry —
210
- one script serves many named apps. `defineMonoWorker(app)` is the 1:1
211
- form — one script, one app, bundled with only that app's dependencies and
212
- mounted namelessly (`mountIsland({ client, el })`, no `app`). A
213
- single-registered-app worker resolves its sole app regardless of the
214
- requested name.
215
- - **Instance keys** are `app` or `app@N`; `mountIsland` mints them, the same app
216
- can mount in many islands at once, and `mount`/`updateProps`/`dispatch`/
217
- `flush`/`unmount`/`whoami` all take the instance first.
218
- - **Clients can be shared.** Several `mountIsland`s into ONE
219
- `connectIslandWorker` co-locate their instances in one worker (multi-island-
220
- per-worker). `destroy()` unmounts just that instance via `unmount`; the
221
- worker terminates when its last island leaves.
222
- - **Async commits flush themselves.** Push mode (the default) subscribes the
223
- shared-memory doorbell when the mount handshake lands — `useEffect` commits,
224
- timers, and framework schedulers' post-task work arrive with no
225
- `setMode`/`flush` ritual. `mountIsland({ mode: 'poll' })` drains on a 50ms
226
- interval instead, and with the `worker` shorthand builds a doorbell-free
227
- client — no SharedArrayBuffer, so no COOP/COEP cross-origin isolation
228
- (`connectIslandWorker({ doorbell: false })` does the same for shared
229
- clients). `handle.setMode` still switches modes after mount.
230
- - **`mountTimeout`** (default 15s, `0` disables) bounds the mount handshake —
231
- a worker entry that loads but never answers rejects with a named error
232
- instead of pending forever; hard failures (module-load errors, crashes)
233
- reject immediately regardless. A failed mount releases the instance and
234
- terminates an island-owned client.
235
- - **`e.target.value` / `e.target.checked` work in handlers** — the dispatched
236
- form state is stamped onto the proxy event target, so uncontrolled-input
237
- handlers read the DOM idiom naturally.
238
- - **`emit(name, payload)`** is the island→shell channel — call inside handlers
239
- or commit-phase effects while a task holds the instance. From a library
240
- callback that fires on a timer or promise (no instance active), wrap it:
241
- `runInInstance(instance, () => { emit(...); bumpOpsVersion(); })` — Leaflet's
242
- `zoomend` in the demo does exactly this.
243
- - **`callbackProp(fn)`** is the shell→worker half: pass it in props
244
- (`props: { onSave: callbackProp(fn) }`) and the worker receives a callable
245
- — invoking it fires `fn(...args)` on the shell, marshalled through the
246
- emit channel. Fire-and-forget: the callable returns `undefined` (there's
247
- no await channel), and calls made outside a dispatch task flush via the
248
- doorbell. Markers work nested inside prop objects/arrays too.
249
- - **Slots** — `<Slot name="x"/>` renders a leaf `data-atoll-slot` element whose
250
- contents the shell fills with real main-thread DOM.
251
- - **The proxy DOM is write-path-plus-container-geometry.** Shadow-tree reads
252
- work (children, querySelector, innerHTML); so does ONE measured box — the
253
- driver's `ResizeObserver` pushes the island container's size into the instance
254
- (`setSize`), and `doc.body`/`documentElement`/elements marked
255
- `doc.markContainer(el)` report it from `clientWidth`/`offsetWidth`/
256
- `getBoundingClientRect`. Everything else returns honest 0/empty and warns
257
- once. `doc.onResize(cb)` re-fires on each push (Leaflet uses it for
258
- `map.invalidateSize()`).
259
- - **Event payloads are spec-shaped for pointer events.** clientX/Y, screenX/Y,
260
- button, `which` (button+1), modifiers, wheel deltaX/Y/deltaMode, pointerType,
261
- scrollTop, and `targetId` (resolved to a proxy node as `payload.target`) all
262
- cross. Pointer-family events always carry *numeric* coords — absent fields
263
- normalize to 0 rather than leaking `undefined` into library math. At dispatch
264
- time `target`/`currentTarget` materialize as proxy nodes (`currentTarget` =
265
- the node the handler was attached to, the instance root for id-0 listeners) so
266
- library middleware can read them — geometry still returns honest zeros.
267
- - **`installDomShim(doc)`** puts the proxy doc on `globalThis.document` plus a
268
- `window` facade — `innerHTML` parses via htmlparser2, `document`/`window`
269
- listeners land on the island container (id 0) so delegation works,
270
- `window.innerWidth/innerHeight` reflect the pushed size, and
271
- `globalThis.Element` becomes `ProxyElement` for `instanceof` checks.
272
- `globalThis.addEventListener` and `self` are never touched — the pool's
273
- message channel lives there. `uninstall()` (or `doc.dispose()`) restores the
274
- globals.
275
- - **Instance-aware globals.** `definePolyWorker` installs a dispatcher so
276
- `document`/`window`/`Element` are *accessors*, not fixed globals: inside a
277
- instance task they resolve that instance's document (its facade, `ProxyElement`),
278
- outside tasks they resolve the sole or last-active instance's, and explicit
279
- assignments (`installDomShim`, test harnesses) take precedence over implicit
280
- resolution. React instances get a working `document`/`window` for free —
281
- libraries that read them during render or in deferred callbacks just work.
282
- On the main-thread driver, element checks are structural (`nodeType`), never
283
- `instanceof Element` — the same global may be a proxy in in-process setups.
284
- Last-active resolution is a heuristic: on a shared client whose islands run
285
- interleaved async work it can resolve the *sibling* instance — the contract
286
- for worker-initiated work is `runInInstance` (see "Writing a worker
287
- renderer").
288
- - **SVG + portals + library refs.** Ops carry a namespace (`create` gets `ns`)
289
- and host context tracks `<svg>`/`<foreignObject>` boundaries — the driver
290
- uses `createElementNS`, so `<svg>` trees land correctly, including through
291
- portals (the container's namespace propagates). `getPublicInstance` exposes
292
- `ProxyElement` facades, which are valid react-dom `createPortal` containers —
293
- this is what makes recharts' tooltip/legend portals work unmodified.
294
-
295
- ## Real libraries — what works
296
-
297
- **Imperative libraries.** The `map` island in `examples/react-dom-worker` runs
298
- **Leaflet 1.9, unmodified from npm**, entirely worker-side: `installDomShim(doc)`
299
- then `await import('leaflet')` (dynamic import is required — Leaflet reads
300
- `document`/`window` at module scope for Browser detection). Verified working:
301
- tile `<img>` ops, divIcon markers, controls, attribution, drag-pan
302
- (document-level listeners registered mid-gesture), wheel zoom, delegated
303
- marker clicks, and `doc.onResize` → `invalidateSize()`.
304
-
305
- **React libraries.** The `charts` island runs **recharts 3.x, unmodified**, as
306
- an ordinary React tree in the worker: `ComposedChart` with grid/axes/tooltip/
307
- legend/bar/line, `onClick` handlers, and `emit` — verified end-to-end with a
308
- bar-click round-trip. It exercises the general machinery above: namespaced
309
- SVG ops, `ProxyElement` refs as portal targets (tooltip/legend render via
310
- react-dom `createPortal`), instance-resolved `document`/`window` for its
311
- selector/`getComputedStyle` calls, and `currentTarget` synthesis for its
312
- mouse middleware. Use fixed chart dimensions — `ResponsiveContainer` has no
313
- layout to observe — and prefer `isAnimationActive={false}` to skip
314
- measurement-feedback render passes that converge but cost op volume.
315
-
316
- Known limits for DOM-heavy libraries:
317
-
318
- - **Geometry is one box.** Only the island container is measured (pushed);
319
- libraries that measure arbitrary elements still get 0 — SVG-specific reads
320
- (`getBBox`, `getTotalLength`, `getComputedTextLength`) too, which is why
321
- recharts text truncation/animation sizing degrades to stub values.
322
- - **`preventDefault`/`stopPropagation` are no-ops** — the real event already
323
- dispatched on the main thread; the worker can't cancel it.
324
- - **Async-library callbacks that mutate DOM or emit outside a task** must
325
- re-enter via `runInInstance(instance, fn)` — timers and promise continuations
326
- have no active instance.
327
- - **`getContext('2d'/'webgl')` is out of scope** for the op protocol —
328
- canvas-based libraries belong on `OffscreenCanvas`, which is a different
329
- transport.
330
-
331
- ## Workload fit
332
-
333
- Every event costs one postMessage round trip — `pointermove`, per-keystroke
334
- input, and scroll handlers re-render on worker latency, and `preventDefault`
335
- can't work because the real event already dispatched. Keep high-frequency
336
- input on the main thread (slots exist for exactly this) and put coarse
337
- interactions — clicks, toggles, form submits — behind the island. On the
338
- other side of the ledger, main-thread replay scales with op *count*, not
339
- tree size: fine-grained updates replay in ~1ms while whole-tree rebuilds
340
- cost an order of magnitude more per frame. `mountIsland`'s `onOps` hook
341
- reports the worker/main split if you want to measure a workload before
342
- committing it to an island.
343
-
344
- Peer deps `react`/`react-reconciler` are required even for imperative-only
345
- consumers — the package *is* the React-rendering pattern; tree-shaking drops
346
- the reconciler if you never call `definePolyWorker` with React apps.
141
+ ## Documentation
142
+
143
+ - [Atoll — package documentation](https://jwhenry3.github.io/atolljs/consumer/#/quickstart)
144
+ - [Worker islands](https://jwhenry3.github.io/atolljs/consumer/#/islands) —
145
+ mountIsland options, `IslandHandle`, transport modes
146
+ - [Writing island apps](https://jwhenry3.github.io/atolljs/consumer/#/island-apps) —
147
+ emit/callbackProp/slots, instance discipline
148
+ - In-repo internals: [`docs/islands.md`](../../docs/islands.md),
149
+ [`docs/islands-worker.md`](../../docs/islands-worker.md),
150
+ [`docs/islands-frameworks.md`](../../docs/islands-frameworks.md) — proxy-DOM
151
+ surface, worker renderers, bundle composition, real-library limits
152
+
153
+ `@atolljs/islands` itself is framework-free — `react`/`react-reconciler` are
154
+ peer deps of `@atolljs/react-island` only (its `/worker` entry is the React
155
+ renderer), so imperative-only and non-React consumers install zero React.
package/package.json CHANGED
@@ -1,7 +1,8 @@
1
1
  {
2
2
  "name": "@atolljs/islands",
3
- "version": "0.1.0",
3
+ "version": "0.1.1",
4
4
  "description": "Atoll islands — MonoWorker/PolyWorker hosts rendering framework islands (React/Vue/Svelte/Solid/Angular) over a proxy-DOM op protocol",
5
+ "homepage": "https://jwhenry3.github.io/atolljs/consumer/#/quickstart",
5
6
  "type": "module",
6
7
  "exports": {
7
8
  ".": "./src/index.ts",
@@ -11,19 +12,21 @@
11
12
  "src"
12
13
  ],
13
14
  "publishConfig": {
14
- "access": "public"
15
+ "access": "public",
16
+ "registry": "https://registry.npmjs.org"
15
17
  },
16
18
  "dependencies": {
17
19
  "htmlparser2": "^12.0.0"
18
20
  },
19
21
  "peerDependencies": {
20
- "@atolljs/core": "0.1.0",
21
- "react": "^19.3.0",
22
- "react-reconciler": "^0.34.0"
22
+ "@atolljs/core": "0.1.1"
23
23
  },
24
24
  "repository": {
25
25
  "type": "git",
26
26
  "url": "https://github.com/jwhenry3/atolljs",
27
27
  "directory": "packages/islands"
28
+ },
29
+ "bugs": {
30
+ "url": "https://github.com/jwhenry3/atolljs/issues"
28
31
  }
29
32
  }
package/src/app.ts CHANGED
@@ -71,11 +71,13 @@ export type IslandAppProps<A> = A extends {
71
71
  : Record<string, unknown>;
72
72
 
73
73
  /**
74
- * The structural shape of a mountable app — `never` params so ANY component
75
- * or imperative def is assignable. Kept worker-free: importing the real
76
- * `IslandApp` union from `worker/definePolyWorker` would drag worker code
77
- * into shell bundles.
74
+ * The structural shape of a mountable app — `never` params so ANY component,
75
+ * imperative def, or framework-adapter output (`{ mount }` — what
76
+ * `reactIslandApp`/`vueIslandApp`/`reactIsland` produce) is assignable.
77
+ * Kept worker-free: importing the real `IslandApp` union from
78
+ * `worker/defineWorkers` would drag worker code into shell bundles.
78
79
  */
79
80
  export type IslandAppLike =
80
81
  | ((props: never) => unknown)
81
- | { imperative: (doc: never, props: never) => void };
82
+ | { imperative: (doc: never, props: never) => void }
83
+ | { mount: (ctx: never) => unknown };
package/src/metrics.ts ADDED
@@ -0,0 +1,79 @@
1
+ /**
2
+ * Internal bookkeeping for measuring the island engine's own cost, separate
3
+ * from application/render work. Disabled by default — the flag check is one
4
+ * predictable branch per chokepoint, so production mounts pay nothing.
5
+ *
6
+ * The one subtlety is re-entrancy: a proxy-DOM method like `el.appendChild`
7
+ * calls `pushOp`, which is itself instrumented. Timing every frame would
8
+ * double-count, so `enter`/`exit` share a depth counter — only the
9
+ * outermost instrumented frame accumulates. The residual
10
+ * (`workerMs − recordMs`) is then application/adapter time.
11
+ *
12
+ * The bench spec additionally calls {@link instrumentPrototype} on the
13
+ * worker-side dom/* classes so imperative-app shadow-tree maintenance is
14
+ * captured too; framework adapters reach the same sinks directly.
15
+ */
16
+ export const proxyMetrics = {
17
+ enabled: false,
18
+ /** Frames currently inside instrumented proxy code (re-entrancy guard). */
19
+ depth: 0,
20
+ /** ms inside outermost instrumented frames since reset(). */
21
+ recordMs: 0,
22
+ /** Ops pushed since reset() — a count, free to keep. */
23
+ opsPushed: 0,
24
+ reset(): void {
25
+ this.depth = 0;
26
+ this.recordMs = 0;
27
+ this.opsPushed = 0;
28
+ },
29
+ };
30
+
31
+ /**
32
+ * Enter an instrumented frame — returns a start timestamp for the outermost
33
+ * frame, or -1 when nested (in which case exit records nothing).
34
+ */
35
+ export const enterProxyFrame = (): number => {
36
+ proxyMetrics.depth++;
37
+ return proxyMetrics.depth === 1 ? performance.now() : -1;
38
+ };
39
+
40
+ /** Pair with {@link enterProxyFrame} — accumulates only for t0 >= 0. */
41
+ export const exitProxyFrame = (t0: number): void => {
42
+ proxyMetrics.depth--;
43
+ if (t0 >= 0) proxyMetrics.recordMs += performance.now() - t0;
44
+ };
45
+
46
+ /**
47
+ * Timing wrapper for a function — no-ops to a direct call while metrics are
48
+ * disabled. Used to wrap dom/* prototype methods from the bench.
49
+ */
50
+ const wrapTimed = <A extends unknown[], R>(fn: (...args: A) => R) =>
51
+ function (this: unknown, ...args: A): R {
52
+ if (!proxyMetrics.enabled) return fn.apply(this, args);
53
+ const t0 = enterProxyFrame();
54
+ try {
55
+ return fn.apply(this, args);
56
+ } finally {
57
+ exitProxyFrame(t0);
58
+ }
59
+ };
60
+
61
+ /**
62
+ * Wrap every method/getter/setter on a class prototype with timing. Live
63
+ * for the rest of the process — intended for benchmark instrumentation,
64
+ * not production.
65
+ */
66
+ export function instrumentPrototype(proto: object): void {
67
+ for (const [name, desc] of Object.entries(
68
+ Object.getOwnPropertyDescriptors(proto),
69
+ )) {
70
+ if (name === 'constructor') continue;
71
+ const wrapped: PropertyDescriptor = { ...desc };
72
+ if (typeof desc.value === 'function') {
73
+ wrapped.value = wrapTimed(desc.value as (...args: unknown[]) => unknown);
74
+ }
75
+ if (desc.get) wrapped.get = wrapTimed(desc.get);
76
+ if (desc.set) wrapped.set = wrapTimed(desc.set);
77
+ Object.defineProperty(proto, name, wrapped);
78
+ }
79
+ }