@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 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';