@atolljs/islands 0.1.0
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 +346 -0
- package/package.json +29 -0
- package/src/app.ts +81 -0
- package/src/callbackProps.ts +75 -0
- package/src/index.ts +40 -0
- package/src/island.ts +905 -0
- package/src/memory.ts +30 -0
- package/src/ops.ts +215 -0
- package/src/react-reconciler.d.ts +18 -0
- package/src/worker/defineWorkers.ts +666 -0
- package/src/worker/dom/css.ts +152 -0
- package/src/worker/dom/document.ts +589 -0
- package/src/worker/dom/element.ts +656 -0
- package/src/worker/dom/html.ts +107 -0
- package/src/worker/dom/node.ts +481 -0
- package/src/worker/dom/selectors.ts +108 -0
- package/src/worker/dom/window.ts +528 -0
- package/src/worker/hostConfig.ts +446 -0
- package/src/worker/index.ts +78 -0
- package/src/worker/instance.ts +317 -0
- package/src/worker/proxyDom.ts +28 -0
- package/src/worker/reactInstance.ts +74 -0
- package/src/worker/slot.tsx +18 -0
package/README.md
ADDED
|
@@ -0,0 +1,346 @@
|
|
|
1
|
+
# @atolljs/islands
|
|
2
|
+
|
|
3
|
+
Framework islands inside Atoll workers — opt-in DOM rendering off the main
|
|
4
|
+
thread. The vocabulary:
|
|
5
|
+
|
|
6
|
+
- **Atoll** — the fabric: worker pools, contracts, shared memory, task dispatch
|
|
7
|
+
(`@atolljs/core`).
|
|
8
|
+
- **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.
|
|
11
|
+
- **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.
|
|
18
|
+
|
|
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.
|
|
26
|
+
|
|
27
|
+
## Quickstart
|
|
28
|
+
|
|
29
|
+
```ts
|
|
30
|
+
// render.worker.ts — the whole worker entry
|
|
31
|
+
import { definePolyWorker } from '@atolljs/islands/worker';
|
|
32
|
+
|
|
33
|
+
export const renderWorker = definePolyWorker({
|
|
34
|
+
apps: {
|
|
35
|
+
dashboard: DashboardApp, // a React component
|
|
36
|
+
vanilla: { imperative: (doc, props) => { ... } } // or pure proxy-DOM code
|
|
37
|
+
},
|
|
38
|
+
});
|
|
39
|
+
```
|
|
40
|
+
|
|
41
|
+
```ts
|
|
42
|
+
// main thread — one call: `worker` builds an island-owned client internally
|
|
43
|
+
import { mountIsland } from '@atolljs/islands';
|
|
44
|
+
|
|
45
|
+
const island = await mountIsland({
|
|
46
|
+
worker: () => new Worker(new URL('./render.worker.ts', import.meta.url), { type: 'module' }),
|
|
47
|
+
el: document.getElementById('island')!,
|
|
48
|
+
app: 'dashboard',
|
|
49
|
+
props: { ... },
|
|
50
|
+
onEvent: (name, payload) => { ... }, // island → shell emit() channel
|
|
51
|
+
slots: { preview: (el) => mountCanvas(el) }, // transclusion holes
|
|
52
|
+
});
|
|
53
|
+
island.updateProps({ ... });
|
|
54
|
+
island.destroy();
|
|
55
|
+
```
|
|
56
|
+
|
|
57
|
+
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:
|
|
98
|
+
|
|
99
|
+
```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'
|
|
104
|
+
export const worker = defineVuePolyWorker({ apps: { counter: Counter } });
|
|
105
|
+
```
|
|
106
|
+
|
|
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.
|
|
140
|
+
|
|
141
|
+
## Writing a worker renderer
|
|
142
|
+
|
|
143
|
+
A renderer adapter is a `RenderedIslandApp` — one method:
|
|
144
|
+
|
|
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
|
+
```
|
|
151
|
+
|
|
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.
|
|
161
|
+
|
|
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.
|
|
172
|
+
|
|
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.
|
|
177
|
+
|
|
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.
|
|
183
|
+
|
|
184
|
+
## Testing islands in-process
|
|
185
|
+
|
|
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:
|
|
189
|
+
|
|
190
|
+
```ts
|
|
191
|
+
import { InProcessWorker } from '@atolljs/core/testing/inProcessWorker';
|
|
192
|
+
vi.stubGlobal('Worker', InProcessWorker);
|
|
193
|
+
InProcessWorker.handlerModules = [() => import('./my.worker')];
|
|
194
|
+
|
|
195
|
+
const island = await mountIsland({ worker: () => new Worker(url, { type: 'module' }), ... });
|
|
196
|
+
```
|
|
197
|
+
|
|
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.
|
package/package.json
ADDED
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@atolljs/islands",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Atoll islands — MonoWorker/PolyWorker hosts rendering framework islands (React/Vue/Svelte/Solid/Angular) over a proxy-DOM op protocol",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"exports": {
|
|
7
|
+
".": "./src/index.ts",
|
|
8
|
+
"./worker": "./src/worker/index.ts"
|
|
9
|
+
},
|
|
10
|
+
"files": [
|
|
11
|
+
"src"
|
|
12
|
+
],
|
|
13
|
+
"publishConfig": {
|
|
14
|
+
"access": "public"
|
|
15
|
+
},
|
|
16
|
+
"dependencies": {
|
|
17
|
+
"htmlparser2": "^12.0.0"
|
|
18
|
+
},
|
|
19
|
+
"peerDependencies": {
|
|
20
|
+
"@atolljs/core": "0.1.0",
|
|
21
|
+
"react": "^19.3.0",
|
|
22
|
+
"react-reconciler": "^0.34.0"
|
|
23
|
+
},
|
|
24
|
+
"repository": {
|
|
25
|
+
"type": "git",
|
|
26
|
+
"url": "https://github.com/jwhenry3/atolljs",
|
|
27
|
+
"directory": "packages/islands"
|
|
28
|
+
}
|
|
29
|
+
}
|
package/src/app.ts
ADDED
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Component-reference app handles — `islandApp(name, app)` stamps an app
|
|
3
|
+
* (React component or `{ imperative }` def) with its registry name so the
|
|
4
|
+
* shell can mount by reference: `<Island app={ChartsApp} props={…}/>` gets
|
|
5
|
+
* prop inference from the component's own signature while the wire still
|
|
6
|
+
* carries a string instance key.
|
|
7
|
+
*
|
|
8
|
+
* Why a stamp instead of `Component.name`: bundlers mangle function names
|
|
9
|
+
* in production builds, so `ChartsApp` → 'charts' can't be derived reliably.
|
|
10
|
+
* The stamp is a plain data property — stable through minification.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
/** The data property `islandApp` stamps — read by islandAppNameOf. */
|
|
14
|
+
const ISLAND_APP_NAME = 'islandAppName';
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Stamp an app with its registry name. The stamped value IS the app — use
|
|
18
|
+
* it directly as the `apps` map value in `definePolyWorker` and as the
|
|
19
|
+
* `app` prop of `<Island/>`.
|
|
20
|
+
*
|
|
21
|
+
* ```ts
|
|
22
|
+
* // worker/apps.tsx
|
|
23
|
+
* export const ChartsApp = islandApp('charts', function ChartsApp(props: ChartsProps) { ... });
|
|
24
|
+
* export const mapApp = islandApp('map', { imperative: buildMap });
|
|
25
|
+
* // worker entry: apps: { charts: ChartsApp, map: mapApp }
|
|
26
|
+
* // shell: <Island app={ChartsApp} props={{ width: 520 }} />
|
|
27
|
+
* ```
|
|
28
|
+
*/
|
|
29
|
+
export const islandApp = <A>(name: string, app: A): A & { readonly islandAppName: string } => {
|
|
30
|
+
(app as Record<string, unknown>)[ISLAND_APP_NAME] = name;
|
|
31
|
+
return app as A & { readonly islandAppName: string };
|
|
32
|
+
};
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Resolve a mountable app reference to its wire name:
|
|
36
|
+
* string → itself; stamped app → its `islandAppName`; otherwise falls back
|
|
37
|
+
* to displayName/function name (dev-mode convenience — minification may
|
|
38
|
+
* strip it, which is what the stamp exists for). `undefined` when nothing
|
|
39
|
+
* derivable remains.
|
|
40
|
+
*/
|
|
41
|
+
export const islandAppNameOf = (app: unknown): string | undefined => {
|
|
42
|
+
if (typeof app === 'string') return app === '' ? undefined : app;
|
|
43
|
+
if (app === null || (typeof app !== 'function' && typeof app !== 'object')) return undefined;
|
|
44
|
+
const stamped = (app as unknown as Record<string, unknown>)[ISLAND_APP_NAME];
|
|
45
|
+
if (typeof stamped === 'string' && stamped !== '') return stamped;
|
|
46
|
+
if (typeof app === 'function') {
|
|
47
|
+
const named = app as { displayName?: string; name?: string };
|
|
48
|
+
return named.displayName ?? (named.name !== '' ? named.name : undefined);
|
|
49
|
+
}
|
|
50
|
+
const imp = (app as { imperative?: unknown }).imperative;
|
|
51
|
+
if (typeof imp === 'function') {
|
|
52
|
+
const stampedImp = (imp as unknown as Record<string, unknown>)[ISLAND_APP_NAME];
|
|
53
|
+
if (typeof stampedImp === 'string' && stampedImp !== '') return stampedImp;
|
|
54
|
+
return imp.name !== '' ? imp.name : undefined;
|
|
55
|
+
}
|
|
56
|
+
return undefined;
|
|
57
|
+
};
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* The props an app reference infers: a React component's own props, an
|
|
61
|
+
* imperative def's props parameter, `Record<string, unknown>` for plain
|
|
62
|
+
* string names (the un-typed mount path). Interfaces without index
|
|
63
|
+
* signatures still infer here — serialization happens at mount.
|
|
64
|
+
*/
|
|
65
|
+
export type IslandAppProps<A> = A extends {
|
|
66
|
+
imperative: (doc: never, props: infer P) => void;
|
|
67
|
+
}
|
|
68
|
+
? P
|
|
69
|
+
: A extends (props: infer P) => unknown
|
|
70
|
+
? P
|
|
71
|
+
: Record<string, unknown>;
|
|
72
|
+
|
|
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.
|
|
78
|
+
*/
|
|
79
|
+
export type IslandAppLike =
|
|
80
|
+
| ((props: never) => unknown)
|
|
81
|
+
| { imperative: (doc: never, props: never) => void };
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Callback-prop marshalling — props cross postMessage as plain data, so a
|
|
3
|
+
* function prop can't travel as-is. `callbackProp(fn)` marks a shell-side
|
|
4
|
+
* function; mount/updateProps marshal markers into `{ __cb: id }` handles
|
|
5
|
+
* (registered in the island's callback table); the worker deserializes
|
|
6
|
+
* them into callables that push an `emit` op back on a reserved name, and
|
|
7
|
+
* the driver dispatches those emits to the table instead of `onEvent`.
|
|
8
|
+
*
|
|
9
|
+
* Fire-and-forget by design: the worker can't await a shell function — the
|
|
10
|
+
* call returns `undefined` in-worker. A callback invoked from a dispatched
|
|
11
|
+
* event lands on the shell in the same postMessage round trip; invoked from
|
|
12
|
+
* a timer/continuation it still routes correctly (instance captured at mount).
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
/** Reserved emit-op name — payload `{ id, args }`, consumed by the driver. */
|
|
16
|
+
export const CALLBACK_EVENT = '__island_cb__';
|
|
17
|
+
const CALLBACK_WIRE = '__cb';
|
|
18
|
+
|
|
19
|
+
/** Marker identity — a WeakSet so a crafted `{ __islandCb }` can't collide. */
|
|
20
|
+
const markers = new WeakMap<object, (...args: unknown[]) => void>();
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Mark a shell function so it can travel as a prop: `mountIsland({ props:
|
|
24
|
+
* { onSave: callbackProp(fn) } })`. In the worker the prop arrives as a
|
|
25
|
+
* callable — invoking it fires `fn(...args)` on the shell asynchronously.
|
|
26
|
+
* The callable returns `undefined` (fire-and-forget — no await channel).
|
|
27
|
+
*/
|
|
28
|
+
export function callbackProp<Args extends unknown[]>(
|
|
29
|
+
fn: (...args: Args) => void,
|
|
30
|
+
): (...args: Args) => void {
|
|
31
|
+
const marker = { fn: fn as (...args: unknown[]) => void };
|
|
32
|
+
markers.set(marker, marker.fn);
|
|
33
|
+
return marker as unknown as (...args: Args) => void;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
const isPlainObj = (v: unknown): v is Record<string, unknown> => {
|
|
37
|
+
if (typeof v !== 'object' || v === null) return false;
|
|
38
|
+
const p = Object.getPrototypeOf(v);
|
|
39
|
+
return p === Object.prototype || p === null;
|
|
40
|
+
};
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Shell side: walk props replacing `callbackProp` markers with `{ __cb: id }`
|
|
44
|
+
* wire handles — `register` assigns each function a table id. Runs before
|
|
45
|
+
* the structured-clone preflight (markers hold a function — uncloneable).
|
|
46
|
+
*/
|
|
47
|
+
export function marshalCallbackProps(
|
|
48
|
+
value: unknown,
|
|
49
|
+
register: (fn: (...args: unknown[]) => void) => number,
|
|
50
|
+
): unknown {
|
|
51
|
+
const fn = markers.get(value as object);
|
|
52
|
+
if (fn !== undefined) return { [CALLBACK_WIRE]: register(fn) };
|
|
53
|
+
if (Array.isArray(value)) return value.map((v) => marshalCallbackProps(v, register));
|
|
54
|
+
if (!isPlainObj(value)) return value;
|
|
55
|
+
const out: Record<string, unknown> = {};
|
|
56
|
+
for (const [k, v] of Object.entries(value)) out[k] = marshalCallbackProps(v, register);
|
|
57
|
+
return out;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* Worker side: walk received props replacing `{ __cb: id }` handles with
|
|
62
|
+
* callables — `make` wraps an id into the emit-backed caller.
|
|
63
|
+
*/
|
|
64
|
+
export function unmarshalCallbackProps(
|
|
65
|
+
value: unknown,
|
|
66
|
+
make: (id: number) => (...args: unknown[]) => void,
|
|
67
|
+
): unknown {
|
|
68
|
+
if (Array.isArray(value)) return value.map((v) => unmarshalCallbackProps(v, make));
|
|
69
|
+
if (!isPlainObj(value)) return value;
|
|
70
|
+
const id = value[CALLBACK_WIRE];
|
|
71
|
+
if (typeof id === 'number' && Object.keys(value).length === 1) return make(id);
|
|
72
|
+
const out: Record<string, unknown> = {};
|
|
73
|
+
for (const [k, v] of Object.entries(value)) out[k] = unmarshalCallbackProps(v, make);
|
|
74
|
+
return out;
|
|
75
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The main-thread surface of @atolljs/islands — React trees (or
|
|
3
|
+
* imperative proxy-DOM apps) rendered inside Web Workers, replayed onto
|
|
4
|
+
* real DOM here as a serialized op stream. There is no React on this side.
|
|
5
|
+
*
|
|
6
|
+
* const island = await mountIsland({
|
|
7
|
+
* client: connectIslandWorker({
|
|
8
|
+
* worker: () => new Worker(new URL('./render.worker.ts', import.meta.url), { type: 'module' }),
|
|
9
|
+
* }),
|
|
10
|
+
* el: document.getElementById('island')!,
|
|
11
|
+
* app: 'controls',
|
|
12
|
+
* });
|
|
13
|
+
*/
|
|
14
|
+
export { connectIslandWorker, mountIsland } from './island';
|
|
15
|
+
export type {
|
|
16
|
+
ConnectIslandWorkerConfig,
|
|
17
|
+
IslandClient,
|
|
18
|
+
IslandHandle,
|
|
19
|
+
IslandWorkerDefinition,
|
|
20
|
+
IslandWorkerOptions,
|
|
21
|
+
Mode,
|
|
22
|
+
MountIslandOptions,
|
|
23
|
+
} from './island';
|
|
24
|
+
|
|
25
|
+
export { makeDoorbell } from './memory';
|
|
26
|
+
export type { DoorbellSpec } from './memory';
|
|
27
|
+
|
|
28
|
+
export { islandApp, islandAppNameOf } from './app';
|
|
29
|
+
export type { IslandAppLike, IslandAppProps } from './app';
|
|
30
|
+
|
|
31
|
+
export { callbackProp } from './callbackProps';
|
|
32
|
+
|
|
33
|
+
export { isEventRef } from './ops';
|
|
34
|
+
export type {
|
|
35
|
+
EventPayload,
|
|
36
|
+
EventRef,
|
|
37
|
+
IslandWorkerMethods,
|
|
38
|
+
Op,
|
|
39
|
+
WireProps,
|
|
40
|
+
} from './ops';
|