@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/src/memory.ts
ADDED
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import { defineSharedMemory, field } from '@atolljs/core';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* The doorbell layout. One counter the worker bumps after every commit —
|
|
5
|
+
* the main thread observe()s it (Atomics.waitAsync underneath) and flushes
|
|
6
|
+
* the op queue on each tick. Ops themselves still ride postMessage; shared
|
|
7
|
+
* memory only replaces the flush() poll with a push.
|
|
8
|
+
*/
|
|
9
|
+
const doorbellSpec = {
|
|
10
|
+
opsVersion: field.number(),
|
|
11
|
+
};
|
|
12
|
+
|
|
13
|
+
/** The spec `renderMemory`/`makeDoorbell` share — pinned to the doorbell layout. */
|
|
14
|
+
export type DoorbellSpec = typeof doorbellSpec;
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* The worker-side contract. A worker script has ONE module-level instance —
|
|
18
|
+
* the pool's INIT_MEMORY handshake binds it to that worker's own buffer, so
|
|
19
|
+
* every island's worker gets an independent doorbell for free.
|
|
20
|
+
*/
|
|
21
|
+
export const renderMemory = defineSharedMemory(doorbellSpec);
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* The main-thread side needs one contract INSTANCE per island: a contract
|
|
25
|
+
* object can only be bound to one buffer at a time (bind() rewires its
|
|
26
|
+
* connectors), and each island client creates its own pool with its own
|
|
27
|
+
* SharedArrayBuffer. Same spec, separate instance → separate buffer → a
|
|
28
|
+
* doorbell that only rings for that island's worker.
|
|
29
|
+
*/
|
|
30
|
+
export const makeDoorbell = () => defineSharedMemory(doorbellSpec);
|
package/src/ops.ts
ADDED
|
@@ -0,0 +1,215 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The whole protocol between the two threads.
|
|
3
|
+
*
|
|
4
|
+
* The worker runs a real React reconciler whose "host environment" is a set
|
|
5
|
+
* of records in a Map — every render-phase and mutation-phase host-config
|
|
6
|
+
* call appends one of these ops to a queue. Task methods (`mount`,
|
|
7
|
+
* `updateProps`, `dispatch`, `flush`) return the flushed batch, and the main
|
|
8
|
+
* thread's only job is to replay them against the DOM. Ops ride postMessage —
|
|
9
|
+
* no shared memory is involved.
|
|
10
|
+
*
|
|
11
|
+
* ISLANDS: every island owns an independent op stream — instance ids are only
|
|
12
|
+
* unique within one worker, so each island's driver keeps its own node map
|
|
13
|
+
* (see src/island.ts). `emit` is the one op that is NOT a DOM mutation: it is
|
|
14
|
+
* the island→shell event channel — the driver hands `{name, payload}` to the
|
|
15
|
+
* shell-registered `onEvent` callback instead of touching the DOM.
|
|
16
|
+
*/
|
|
17
|
+
export type Op =
|
|
18
|
+
/**
|
|
19
|
+
* createElement-equivalent — `props` is already wire-serialized. `ns` is
|
|
20
|
+
* the element's namespace URI when it isn't HTML ('http://www.w3.org/2000/svg',
|
|
21
|
+
* 'http://www.w3.org/1998/Math/MathML') — the driver calls
|
|
22
|
+
* `createElementNS(ns, type)`. The reconciler's host context tracks it
|
|
23
|
+
* (svg/math enter, foreignObject/desc/title return to HTML), so SVG trees
|
|
24
|
+
* like recharts' arrive correctly namespaced.
|
|
25
|
+
*/
|
|
26
|
+
| { t: 'create'; id: number; type: string; props: WireProps; ns?: string }
|
|
27
|
+
/** createTextNode-equivalent. */
|
|
28
|
+
| { t: 'text'; id: number; text: string }
|
|
29
|
+
/** `parent.insertBefore(child, before ?? null)` — parent 0 is the root container. */
|
|
30
|
+
| { t: 'append'; parent: number; child: number; before?: number }
|
|
31
|
+
/** Detach `child` from wherever it is mounted. */
|
|
32
|
+
| { t: 'remove'; child: number }
|
|
33
|
+
/** Re-serialized full prop set for `id` (main diffs against its last seen set). */
|
|
34
|
+
| { t: 'update'; id: number; props: WireProps }
|
|
35
|
+
/** New text content for a text instance. Also how the proxy DOM writes
|
|
36
|
+
* `element.textContent` — the driver assigns `node.textContent`, which
|
|
37
|
+
* replaces the element's children with a single text node. */
|
|
38
|
+
| { t: 'utext'; id: number; text: string }
|
|
39
|
+
/** Clear the root container (React's clearContainer, or an imperative
|
|
40
|
+
* instance's rebuild). */
|
|
41
|
+
| { t: 'clear' }
|
|
42
|
+
/**
|
|
43
|
+
* Set/remove a single attribute — emitted by the worker-side proxy DOM
|
|
44
|
+
* (`setAttribute`, `id`/`className` setters, `classList`, `dataset`).
|
|
45
|
+
* `value: null` removes the attribute.
|
|
46
|
+
*/
|
|
47
|
+
| { t: 'attr'; id: number; name: string; value: string | null }
|
|
48
|
+
/**
|
|
49
|
+
* Merge inline-style changes — the proxy DOM's `style` proxy re-sends only
|
|
50
|
+
* the changed keys; `''` clears a key (deleteProperty). Two conventions:
|
|
51
|
+
* keys starting `--` are CSS custom properties (driver routes them to
|
|
52
|
+
* `style.setProperty`), and a value ending in `!important` applies with
|
|
53
|
+
* priority (the proxy's `style.setProperty(k, v, 'important')` encodes it
|
|
54
|
+
* this way — there is no separate priority field).
|
|
55
|
+
*/
|
|
56
|
+
| { t: 'style'; id: number; props: Record<string, string> }
|
|
57
|
+
/**
|
|
58
|
+
* Attach a DOM listener for a proxy-DOM `addEventListener` — `handler` is
|
|
59
|
+
* a worker handler-table id (same currency as `__evt` refs); the driver
|
|
60
|
+
* wires it to `client.dispatch(handler, payload)`. `id: 0` targets the
|
|
61
|
+
* island's root container — that's where `document`/`window` listeners
|
|
62
|
+
* land, which makes delegated handlers work. `opts` carries the
|
|
63
|
+
* once/passive/capture flags through to the real addEventListener.
|
|
64
|
+
*/
|
|
65
|
+
| {
|
|
66
|
+
t: 'listen';
|
|
67
|
+
id: number;
|
|
68
|
+
type: string;
|
|
69
|
+
handler: number;
|
|
70
|
+
opts?: { once?: boolean; passive?: boolean; capture?: boolean };
|
|
71
|
+
}
|
|
72
|
+
/** Detach the listener a `listen` op attached (type + handler identify it).
|
|
73
|
+
* `opts.capture` must match the listen's flag for the detach to apply. */
|
|
74
|
+
| {
|
|
75
|
+
t: 'unlisten';
|
|
76
|
+
id: number;
|
|
77
|
+
type: string;
|
|
78
|
+
handler: number;
|
|
79
|
+
opts?: { once?: boolean; passive?: boolean; capture?: boolean };
|
|
80
|
+
}
|
|
81
|
+
/**
|
|
82
|
+
* Island → shell event — worker code calls `emit(name, payload)` inside a
|
|
83
|
+
* handler (or a commit-phase effect); the driver invokes the island's
|
|
84
|
+
* `onEvent(name, payload)` instead of mutating the DOM. Payloads must be
|
|
85
|
+
* structured-cloneable — same rule as props.
|
|
86
|
+
*/
|
|
87
|
+
| { t: 'emit'; name: string; payload?: unknown };
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* Wire-serialized props. Functions named `on*` (onClick, onInput, …) cross as
|
|
91
|
+
* `{ __evt: handlerId }`; the main thread turns that into a DOM listener that
|
|
92
|
+
* dispatches back into the worker. `children` never crosses — tree structure
|
|
93
|
+
* is expressed entirely by append/remove ops.
|
|
94
|
+
*
|
|
95
|
+
* One prop is NOT a normal attribute: `data-atoll-slot` marks a transclusion
|
|
96
|
+
* slot — a leaf element whose box is worker-owned but whose contents the
|
|
97
|
+
* shell mounts main-thread DOM into (the island's `slots` registry). The
|
|
98
|
+
* driver routes it to the slot machinery instead of treating it like any
|
|
99
|
+
* other data attribute.
|
|
100
|
+
*/
|
|
101
|
+
export type WireProps = Record<string, unknown>;
|
|
102
|
+
|
|
103
|
+
/** Marker for a prop that was a function — a handle into the worker's handler table. */
|
|
104
|
+
export interface EventRef {
|
|
105
|
+
__evt: number;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
export const isEventRef = (v: unknown): v is EventRef =>
|
|
109
|
+
typeof v === 'object' && v !== null && '__evt' in v;
|
|
110
|
+
|
|
111
|
+
/**
|
|
112
|
+
* What the main thread sends back when a DOM event fires. Deliberately NOT a
|
|
113
|
+
* SyntheticEvent — just a best-effort handful of fields read off the DOM
|
|
114
|
+
* event and its target. Mouse fields are `undefined` for non-mouse events;
|
|
115
|
+
* `targetId` is only set when the event target is an op-created node (shell-
|
|
116
|
+
* owned slot content has no instance id).
|
|
117
|
+
*/
|
|
118
|
+
export interface EventPayload {
|
|
119
|
+
type: string;
|
|
120
|
+
value?: string;
|
|
121
|
+
checked?: boolean;
|
|
122
|
+
key?: string;
|
|
123
|
+
clientX?: number;
|
|
124
|
+
clientY?: number;
|
|
125
|
+
screenX?: number;
|
|
126
|
+
screenY?: number;
|
|
127
|
+
button?: number;
|
|
128
|
+
/**
|
|
129
|
+
* UIEvent.which — non-standard but set by every browser (1 = left button,
|
|
130
|
+
* 2 = middle, 3 = right). Libraries like Leaflet's Draggable gate on it:
|
|
131
|
+
* `e.which !== 1 && e.button !== 1` must not both hold for a left press.
|
|
132
|
+
*/
|
|
133
|
+
which?: number;
|
|
134
|
+
/** Modifier states for the event (KeyboardEvent/MouseEvent share them). */
|
|
135
|
+
shiftKey?: boolean;
|
|
136
|
+
ctrlKey?: boolean;
|
|
137
|
+
altKey?: boolean;
|
|
138
|
+
metaKey?: boolean;
|
|
139
|
+
/** WheelEvent deltas — only set for `wheel` events (deltaMode is always
|
|
140
|
+
* 0/pixels in practice; reported raw). */
|
|
141
|
+
deltaX?: number;
|
|
142
|
+
deltaY?: number;
|
|
143
|
+
/**
|
|
144
|
+
* WheelEvent.deltaMode (0 = pixels, 1 = lines, 2 = pages). Libraries like
|
|
145
|
+
* Leaflet gate on `e.deltaMode === 0` inside getWheelDelta, so the driver
|
|
146
|
+
* always sends a concrete number — defaulting to 0 when the environment
|
|
147
|
+
* doesn't expose it but deltas exist.
|
|
148
|
+
*/
|
|
149
|
+
deltaMode?: number;
|
|
150
|
+
/** PointerEvent.pointerType ('mouse' | 'touch' | 'pen' | ''). */
|
|
151
|
+
pointerType?: string;
|
|
152
|
+
/** `target.scrollTop` when the target is an element. */
|
|
153
|
+
scrollTop?: number;
|
|
154
|
+
/** The worker instance id of `event.target`, when it maps to one. */
|
|
155
|
+
targetId?: number;
|
|
156
|
+
/**
|
|
157
|
+
* The proxy-DOM node for `targetId` — SYNTHESIZED worker-side by the proxy
|
|
158
|
+
* DOM when it wraps a listener (it never crosses the wire). Lets delegated
|
|
159
|
+
* handlers written for the real DOM — `e.target.closest('.row')`,
|
|
160
|
+
* `e.target.dataset` — run unmodified. Only set when the target maps to a
|
|
161
|
+
* known worker instance; DOM-fidelity only for nodes built through the
|
|
162
|
+
* same proxy document.
|
|
163
|
+
*/
|
|
164
|
+
target?: unknown;
|
|
165
|
+
/**
|
|
166
|
+
* Synthesized NO-OPS — added worker-side by the proxy DOM when it wraps a
|
|
167
|
+
* listener's payload (they never cross the wire). They exist so library
|
|
168
|
+
* code written for real DOM events — `e.preventDefault()`,
|
|
169
|
+
* `e.stopPropagation()` — doesn't crash. They CANNOT cancel anything: the
|
|
170
|
+
* real event already dispatched on the main thread before this payload
|
|
171
|
+
* ever reached the worker. Practical consequence: a `wheel` listener on a
|
|
172
|
+
* scrolling island can't preventDefault — the page scrolls anyway.
|
|
173
|
+
*/
|
|
174
|
+
preventDefault?(): void;
|
|
175
|
+
stopPropagation?(): void;
|
|
176
|
+
stopImmediatePropagation?(): void;
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
/**
|
|
180
|
+
* The task methods every island worker exposes — what `definePolyWorker`
|
|
181
|
+
* registers on the worker side and what `connectIslandWorker`'s typed client
|
|
182
|
+
* calls on the main thread. Every signature leads with the instance key
|
|
183
|
+
* (`'app'` or `'app@instance'`) — `mountIsland` binds it per island.
|
|
184
|
+
*/
|
|
185
|
+
export type IslandWorkerMethods = {
|
|
186
|
+
/** Mount the instance's registry app; returns the initial op batch. Async
|
|
187
|
+
* because a React app lazily imports the reconciler on first mount. */
|
|
188
|
+
mount(instance: string, props?: Record<string, unknown>): Op[] | Promise<Op[]>;
|
|
189
|
+
/** Re-render the instance's root with new serializable props. */
|
|
190
|
+
updateProps(instance: string, props: Record<string, unknown>): Op[];
|
|
191
|
+
/** Invoke the worker handler a `__evt` ref or `listen` op points at. */
|
|
192
|
+
dispatch(handlerId: number, payload: EventPayload): Op[];
|
|
193
|
+
/**
|
|
194
|
+
* Push the island container's measured box into the instance — the ONLY
|
|
195
|
+
* geometry channel that exists. The driver observes the island's `el`
|
|
196
|
+
* with a ResizeObserver and calls this once at mount and on resizes
|
|
197
|
+
* (throttled ~100ms). The instance stores {w,h}; proxy-DOM geometry reads on
|
|
198
|
+
* `doc.body`/`documentElement` and on elements marked via
|
|
199
|
+
* `doc.markContainer(el)` then return it — deeper elements keep the
|
|
200
|
+
* honest 0. Handlers registered via `doc.onResize` re-run on each push;
|
|
201
|
+
* their ops ride back in this method's return batch.
|
|
202
|
+
*/
|
|
203
|
+
setSize(instance: string, width: number, height: number): Op[];
|
|
204
|
+
/** Drain ops committed outside a task (passive effects, timers). */
|
|
205
|
+
flush(instance: string): Op[];
|
|
206
|
+
/**
|
|
207
|
+
* Tear down one instance — unmounts its tree / disposes its proxy document
|
|
208
|
+
* and drops the instance, leaving the worker alive for its other mounts.
|
|
209
|
+
* Returned ops are the detach batch (the driver may skip applying them if
|
|
210
|
+
* the island element is already gone).
|
|
211
|
+
*/
|
|
212
|
+
unmount(instance: string): Op[];
|
|
213
|
+
/** The mounted instance's random id — the island's "worker pid" badge. */
|
|
214
|
+
whoami(instance: string): string;
|
|
215
|
+
};
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* react-reconciler ships no TypeScript types. It is a CommonJS factory —
|
|
3
|
+
* `Reconciler(hostConfig)` returns the reconciler API (createContainer,
|
|
4
|
+
* updateContainer, flushSyncWork, flushSyncFromReconciler, flushPassiveEffects…).
|
|
5
|
+
* The host config surface is documented in hostConfig.ts.
|
|
6
|
+
*/
|
|
7
|
+
declare module 'react-reconciler' {
|
|
8
|
+
const createReconciler: (hostConfig: unknown) => {
|
|
9
|
+
createContainer: (...args: unknown[]) => unknown;
|
|
10
|
+
updateContainer: (...args: unknown[]) => unknown;
|
|
11
|
+
updateContainerSync: (...args: unknown[]) => unknown;
|
|
12
|
+
flushSyncWork: () => unknown;
|
|
13
|
+
flushSyncFromReconciler: <T>(fn: () => T) => T;
|
|
14
|
+
flushPassiveEffects: () => boolean;
|
|
15
|
+
[key: string]: unknown;
|
|
16
|
+
};
|
|
17
|
+
export default createReconciler;
|
|
18
|
+
}
|