@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.
@@ -0,0 +1,528 @@
1
+ import { parseDocument, ElementType } from 'htmlparser2';
2
+ import {
3
+ allocId,
4
+ bumpOpsVersion,
5
+ getActiveInstance,
6
+ getLastActiveInstance,
7
+ getInstanceSize,
8
+ instances,
9
+ markInstanceActive,
10
+ pushOp,
11
+ registerHandler,
12
+ unregisterHandler,
13
+ } from '../instance';
14
+ import type { ElementInstance, HostInstance, TextInstance } from '../instance';
15
+ import type { EventPayload, Op } from '../../ops';
16
+
17
+ import {
18
+ activeInstanceDoc,
19
+ ambientDoc,
20
+ createProxyDocument,
21
+ docForInstance,
22
+ type InternalDocument,
23
+ type ProxyDocument,
24
+ } from './document';
25
+ import { ProxyElement } from './element';
26
+ import {
27
+ ProxyNode,
28
+ ProxyText,
29
+ ProxyComment,
30
+ listenerOptsForWire,
31
+ normalizeListenerOptions,
32
+ type ProxyEventHandler,
33
+ } from './node';
34
+
35
+ /* ── The global shim ───────────────────────────────────────────────────── */
36
+
37
+ /** What installDomShim puts on globalThis.window — a facade OBJECT, not
38
+ * globalThis itself (the pool's message channel lives on self). */
39
+ export interface WindowShim {
40
+ document: ProxyDocument;
41
+ navigator: { userAgent: string };
42
+ location: { href: string; reload(): void; assign(url: string): void; replace(url: string): void };
43
+ innerWidth: number;
44
+ innerHeight: number;
45
+ devicePixelRatio: number;
46
+ requestAnimationFrame(cb: (time: number) => void): number;
47
+ cancelAnimationFrame(id?: number): void;
48
+ setTimeout(
49
+ callback: (...args: unknown[]) => void,
50
+ delay?: number,
51
+ ...args: unknown[]
52
+ ): number;
53
+ clearTimeout(id?: number): void;
54
+ setInterval(
55
+ callback: (...args: unknown[]) => void,
56
+ delay?: number,
57
+ ...args: unknown[]
58
+ ): number;
59
+ clearInterval(id?: number): void;
60
+ getComputedStyle(el: unknown): Record<string, never>;
61
+ /** Media queries can't be answered worker-side — always `matches: false`. */
62
+ matchMedia(query: string): MediaQueryList;
63
+ addEventListener(
64
+ type: string,
65
+ fn: ProxyEventHandler,
66
+ options?: boolean | AddEventListenerOptions,
67
+ ): void;
68
+ removeEventListener(
69
+ type: string,
70
+ fn: ProxyEventHandler,
71
+ options?: boolean | EventListenerOptions,
72
+ ): void;
73
+ /** Scrolling is main-thread business — no-ops so window.scrollTo(x,y)
74
+ * calls in libraries don't throw. */
75
+ scrollTo(x?: number | ScrollToOptions, y?: number): void;
76
+ scrollBy(x?: number | ScrollToOptions, y?: number): void;
77
+ scroll(x?: number | ScrollToOptions, y?: number): void;
78
+ }
79
+
80
+ /**
81
+ * The uninstall currently returned by the latest installDomShim — chained
82
+ * so installing a fresh document's shim first unwinds the previous one.
83
+ */
84
+ let activeShimUninstall: (() => void) | null = null;
85
+
86
+ export interface WindowFacadeBundle {
87
+ facade: WindowShim;
88
+ /** Detach every surviving window listener — ops and handler-table entries. */
89
+ teardown(): void;
90
+ }
91
+
92
+ /**
93
+ * Build the `window` facade for one instance's document — shared by
94
+ * installDomShim (imperative apps) and the instance dispatcher (React mounts
95
+ * get a window the first time library code reads one). The bundle caches
96
+ * on the document so both paths hand out the SAME facade — a listener
97
+ * registered through `window` must land in the same table whichever global
98
+ * routed the call.
99
+ */
100
+ const buildWindowFacade = (internal: InternalDocument): WindowFacadeBundle => {
101
+ const g = globalThis as Record<string, unknown>;
102
+
103
+ /** type → registered listener entries. Window listeners get their own
104
+ * table (a lib removing a document listener must not detach its window
105
+ * twin), but land on the same id-0 container as document listeners. */
106
+ const windowListeners = new Map<
107
+ string,
108
+ Array<{ fn: ProxyEventHandler; hid: number; capture: boolean }>
109
+ >();
110
+
111
+ const pushDocOp = (op: Op): void => {
112
+ pushOp(internal.instance, op);
113
+ if (getActiveInstance() !== internal.instance) bumpOpsVersion();
114
+ };
115
+
116
+ const windowRemoveEventListener = (
117
+ type: string,
118
+ fn: ProxyEventHandler,
119
+ options?: boolean | EventListenerOptions,
120
+ ): void => {
121
+ internal._assertAlive();
122
+ const capture = normalizeListenerOptions(options).capture;
123
+ const table = windowListeners.get(type);
124
+ const index = table?.findIndex((l) => l.fn === fn && l.capture === capture) ?? -1;
125
+ if (index === -1 || table === undefined) return;
126
+ const [entry] = table.splice(index, 1);
127
+ unregisterHandler(entry.hid);
128
+ internal._handlerIds.delete(entry.hid);
129
+ pushDocOp({
130
+ t: 'unlisten',
131
+ id: 0,
132
+ type,
133
+ handler: entry.hid,
134
+ opts: entry.capture ? { capture: true } : undefined,
135
+ });
136
+ };
137
+
138
+ const windowAddEventListener = (
139
+ type: string,
140
+ fn: ProxyEventHandler,
141
+ options?: boolean | AddEventListenerOptions,
142
+ ): void => {
143
+ internal._assertAlive();
144
+ const opts = normalizeListenerOptions(options);
145
+ let table = windowListeners.get(type);
146
+ if (table === undefined) windowListeners.set(type, (table = []));
147
+ // real DOM dedupes identical (type, listener, capture) triples
148
+ if (table.some((l) => l.fn === fn && l.capture === opts.capture)) return;
149
+ const hid = registerHandler(
150
+ (p) => {
151
+ try {
152
+ fn.call(facade, internal._enrichEvent(p as EventPayload, facade));
153
+ } finally {
154
+ if (opts.once) windowRemoveEventListener(type, fn, { capture: opts.capture });
155
+ }
156
+ },
157
+ internal.instance,
158
+ 0, // window-level listener → target id 0 is the island root
159
+ );
160
+ table.push({ fn, hid, capture: opts.capture });
161
+ internal._handlerIds.add(hid);
162
+ pushDocOp({ t: 'listen', id: 0, type, handler: hid, opts: listenerOptsForWire(options) });
163
+ };
164
+
165
+ const raf = g.requestAnimationFrame as ((cb: (time: number) => void) => number) | undefined;
166
+ const caf = g.cancelAnimationFrame as ((id: number) => void) | undefined;
167
+ const globalSetTimeout = g.setTimeout as typeof setTimeout;
168
+ const globalSetInterval = g.setInterval as typeof setInterval;
169
+ const globalClearTimeout = g.clearTimeout as typeof clearTimeout;
170
+ const globalClearInterval = g.clearInterval as typeof clearInterval;
171
+
172
+ // A instance owns the timers/rAFs it schedules through this facade; teardown
173
+ // cancels them so deferred library work (Leaflet drag inertia, scroll-zoom
174
+ // debounces, etc.) doesn't outlive the document and mutate a dead or
175
+ // foreign ambient DOM. We wrap callbacks so completed ones drop their id.
176
+ const timeouts = new Set<number>();
177
+ const intervals = new Set<number>();
178
+ const rafs = new Set<number>();
179
+
180
+ const facade: WindowShim = {
181
+ document: internal,
182
+ navigator: { userAgent: 'atoll-islands' },
183
+ location: {
184
+ href: 'about:blank',
185
+ reload() {},
186
+ assign(_url: string) {},
187
+ replace(_url: string) {},
188
+ },
189
+ get innerWidth(): number {
190
+ // Fed by the pushed container size — the viewport the island lives in.
191
+ return getInstanceSize(internal.instance)?.w ?? 0;
192
+ },
193
+ get innerHeight(): number {
194
+ return getInstanceSize(internal.instance)?.h ?? 0;
195
+ },
196
+ devicePixelRatio: 1,
197
+ // Workers normally have no rAF — pass through when one exists (in-process
198
+ // test), degrade to a 16ms timer otherwise. Schedule through the host
199
+ // timers but keep track of the handles so teardown can cancel pending
200
+ // callbacks (Leaflet's drag/zoom animations, etc.).
201
+ requestAnimationFrame(cb: (time: number) => void): number {
202
+ let id: number;
203
+ const wrapped = (time: number): void => {
204
+ rafs.delete(id);
205
+ cb(time);
206
+ };
207
+ id = typeof raf === 'function'
208
+ ? raf.call(globalThis, wrapped)
209
+ : (globalSetTimeout((stamp: number) => wrapped(stamp), 16, Date.now()) as unknown as number);
210
+ rafs.add(id);
211
+ return id;
212
+ },
213
+ cancelAnimationFrame(id?: number): void {
214
+ if (id === undefined) return;
215
+ rafs.delete(id);
216
+ if (typeof caf === 'function') caf.call(globalThis, id);
217
+ else globalClearTimeout(id);
218
+ },
219
+ setTimeout(...args: Parameters<typeof setTimeout>): number {
220
+ const [cb, delay, ...rest] = args as [
221
+ cb: (...a: unknown[]) => void,
222
+ delay?: number,
223
+ ...rest: unknown[],
224
+ ];
225
+ let id: number;
226
+ const wrapped = (...a: unknown[]): void => {
227
+ timeouts.delete(id);
228
+ cb(...a);
229
+ };
230
+ id = globalSetTimeout(wrapped, delay, ...rest) as unknown as number;
231
+ timeouts.add(id);
232
+ return id;
233
+ },
234
+ clearTimeout(id?: number): void {
235
+ if (id === undefined) return;
236
+ timeouts.delete(id);
237
+ globalClearTimeout(id);
238
+ },
239
+ setInterval(...args: Parameters<typeof setInterval>): number {
240
+ const [cb, delay, ...rest] = args as [
241
+ cb: (...a: unknown[]) => void,
242
+ delay?: number,
243
+ ...rest: unknown[],
244
+ ];
245
+ const id = globalSetInterval(cb, delay, ...rest) as unknown as number;
246
+ intervals.add(id);
247
+ return id;
248
+ },
249
+ clearInterval(id?: number): void {
250
+ if (id === undefined) return;
251
+ intervals.delete(id);
252
+ globalClearInterval(id);
253
+ },
254
+ // No measurement channel — an empty declaration, same honesty rule as
255
+ // doc.getComputedStyle (which warns once); the facade stays silent.
256
+ getComputedStyle: () => ({}),
257
+ // No preference/quiz surface exists worker-side — media queries never
258
+ // match. The shape is complete enough for feature-detection code.
259
+ matchMedia: (query: string) =>
260
+ ({
261
+ matches: false,
262
+ media: query,
263
+ onchange: null,
264
+ addListener: () => {},
265
+ removeListener: () => {},
266
+ addEventListener: () => {},
267
+ removeEventListener: () => {},
268
+ dispatchEvent: () => false,
269
+ }) as unknown as MediaQueryList,
270
+ addEventListener: windowAddEventListener,
271
+ removeEventListener: windowRemoveEventListener,
272
+ // No scroll surface exists worker-side — libraries call these during
273
+ // gesture handlers (Leaflet's Keyboard._onMouseDown scrolls the page
274
+ // back); honest no-ops.
275
+ scrollTo: () => {},
276
+ scrollBy: () => {},
277
+ scroll: () => {},
278
+ };
279
+
280
+ const teardown = (): void => {
281
+ for (const id of rafs) {
282
+ if (typeof caf === 'function') caf.call(globalThis, id);
283
+ else globalClearTimeout(id);
284
+ }
285
+ rafs.clear();
286
+ for (const id of timeouts) globalClearTimeout(id);
287
+ timeouts.clear();
288
+ for (const id of intervals) globalClearInterval(id);
289
+ intervals.clear();
290
+ for (const [type, table] of windowListeners) {
291
+ for (const entry of table) {
292
+ unregisterHandler(entry.hid);
293
+ internal._handlerIds.delete(entry.hid);
294
+ if (!internal._disposed) {
295
+ pushOp(internal.instance, { t: 'unlisten', id: 0, type, handler: entry.hid });
296
+ }
297
+ }
298
+ table.length = 0;
299
+ }
300
+ };
301
+
302
+ return { facade, teardown };
303
+ };
304
+
305
+ /** The document's window facade, built once and shared by every path. */
306
+ const windowFacadeFor = (internal: InternalDocument): WindowFacadeBundle => {
307
+ if (internal._windowBundle === undefined) {
308
+ internal._windowBundle = buildWindowFacade(internal);
309
+ }
310
+ return internal._windowBundle;
311
+ };
312
+
313
+ let instanceDispatcherInstalled = false;
314
+
315
+ /**
316
+ * Define `document`/`window`/`Element` as GLOBAL GETTERS that resolve to
317
+ * the current instance's proxy document — the mechanism that lets React-island
318
+ * libraries (recharts reading `window.getComputedStyle`, `document.body`)
319
+ * work without their app ever touching a doc, and that routes imperative
320
+ * library calls correctly when several mounts share one module (the
321
+ * in-process test layout).
322
+ *
323
+ * Resolution (docForCurrentContext): the active instance's doc inside tasks;
324
+ * the single registered doc when only one instance exists — every real island
325
+ * worker, so timer/promise callbacks from libraries still hit their own
326
+ * document; the most recent instance's doc as a final multi-instance fallback;
327
+ * the pre-install global otherwise (a real document in happy-dom tests —
328
+ * transparent to shell-side code, which never runs inside a instance task).
329
+ */
330
+ /**
331
+ * Stand-ins for bare-global feature probes framework code runs inside a
332
+ * instance — a real worker has no `HTMLMediaElement` constructor or
333
+ * `customElements` registry, so `x instanceof HTMLMediaElement` /
334
+ * `customElements.get(...)` would ReferenceError. The class never matches
335
+ * an instanceof; the registry reports nothing defined. A host-provided
336
+ * global (happy-dom) wins over the stub whenever one exists.
337
+ */
338
+ const HTML_MEDIA_STUB = class HTMLMediaElement {};
339
+ const CUSTOM_ELEMENTS_STUB = {
340
+ get: () => undefined,
341
+ define: () => {},
342
+ upgrade: () => {},
343
+ // Honest answer for "will this element ever upgrade" — it won't.
344
+ whenDefined: () => new Promise<never>(() => {}),
345
+ };
346
+
347
+ export function installInstanceDispatcher(): void {
348
+ if (instanceDispatcherInstalled) return;
349
+ instanceDispatcherInstalled = true;
350
+ const g = globalThis as Record<string, unknown>;
351
+ const prevDocument = g.document;
352
+ const prevWindow = g.window;
353
+ const prevElement = g.Element;
354
+ const prevNode = g.Node;
355
+ const prevText = g.Text;
356
+ const prevComment = g.Comment;
357
+ const prevMedia = g.HTMLMediaElement;
358
+ const prevCustomElements = g.customElements;
359
+ // Explicit assignments override the fallback (never the in-instance doc) —
360
+ // keeps pre-dispatcher semantics for out-of-instance code, and lets test
361
+ // harnesses/suites restore globals by plain assignment.
362
+ let docOverride: unknown;
363
+ let winOverride: unknown;
364
+ let elOverride: unknown;
365
+
366
+ Object.defineProperty(g, 'document', {
367
+ configurable: true,
368
+ enumerable: true,
369
+ get: () => activeInstanceDoc() ?? docOverride ?? ambientDoc() ?? prevDocument,
370
+ set: (v) => {
371
+ docOverride = v;
372
+ },
373
+ });
374
+ Object.defineProperty(g, 'window', {
375
+ configurable: true,
376
+ enumerable: true,
377
+ get: () => {
378
+ const doc = activeInstanceDoc();
379
+ if (doc !== undefined) return windowFacadeFor(doc).facade;
380
+ if (winOverride !== undefined) return winOverride;
381
+ const ambient = ambientDoc();
382
+ return ambient !== undefined ? windowFacadeFor(ambient).facade : prevWindow;
383
+ },
384
+ set: (v) => {
385
+ winOverride = v;
386
+ },
387
+ });
388
+ // Workers lack the Element constructor — `x instanceof Element` inside a
389
+ // library would ReferenceError. Point it at ProxyElement while a instance
390
+ // doc is resolvable; real HTMLElement checks never run worker-side.
391
+ Object.defineProperty(g, 'Element', {
392
+ configurable: true,
393
+ enumerable: true,
394
+ get: () => {
395
+ if (activeInstanceDoc() !== undefined) return ProxyElement;
396
+ if (elOverride !== undefined) return elOverride;
397
+ return ambientDoc() !== undefined ? ProxyElement : prevElement;
398
+ },
399
+ set: (v) => {
400
+ elOverride = v;
401
+ },
402
+ });
403
+
404
+ /**
405
+ * Class globals — same resolution shape as `Element` above (in-instance →
406
+ * proxy class/stub; else explicit override; else ambient → proxy/stub;
407
+ * else the captured pre-dispatcher value). Frameworks cache prototype
408
+ * getters (`Node.prototype.firstChild`) and do `x instanceof Comment`
409
+ * before touching the document, so these must BE the proxy classes —
410
+ * not dispatchers that return different objects per instance.
411
+ */
412
+ const installClassGlobal = (name: string, instanceValue: unknown, prev: unknown): void => {
413
+ let override: unknown;
414
+ Object.defineProperty(g, name, {
415
+ configurable: true,
416
+ enumerable: true,
417
+ get: () => {
418
+ if (activeInstanceDoc() !== undefined) return instanceValue;
419
+ if (override !== undefined) return override;
420
+ return ambientDoc() !== undefined ? instanceValue : prev;
421
+ },
422
+ set: (v) => {
423
+ override = v;
424
+ },
425
+ });
426
+ };
427
+
428
+ installClassGlobal('Node', ProxyNode, prevNode);
429
+ installClassGlobal('Text', ProxyText, prevText);
430
+ installClassGlobal('Comment', ProxyComment, prevComment);
431
+ installClassGlobal(
432
+ 'HTMLMediaElement',
433
+ typeof prevMedia === 'function' ? prevMedia : HTML_MEDIA_STUB,
434
+ prevMedia,
435
+ );
436
+ installClassGlobal(
437
+ 'customElements',
438
+ prevCustomElements !== undefined ? prevCustomElements : CUSTOM_ELEMENTS_STUB,
439
+ prevCustomElements,
440
+ );
441
+ }
442
+
443
+ /**
444
+ * Install this document as the instance's DOM surface — the entry point for
445
+ * running real DOM-dependent libraries unmodified inside an island instance:
446
+ *
447
+ * const doc = createProxyDocument(instance);
448
+ * installDomShim(doc);
449
+ * SomeVendorLib.mount(doc.body); // uses document./window./innerHTML…
450
+ *
451
+ * Under the instance dispatcher (installed here automatically, and by
452
+ * definePolyWorker for every island worker) `globalThis.document` and
453
+ * `globalThis.window` already resolve to this document while its instance is
454
+ * active — so this call's real work is building the window facade
455
+ * (navigator/location stubs, timers + rAF passthrough, innerWidth/Height
456
+ * from the pushed container size, empty getComputedStyle, never-matching
457
+ * matchMedia, addEventListener wired to `listen` ops on id 0) and exposing
458
+ * it as `document.defaultView`.
459
+ *
460
+ * It also pins the CLASS globals (`Node`/`Text`/`Comment` → the proxy
461
+ * classes; `HTMLMediaElement`/`customElements` → never-match stubs) for
462
+ * framework feature probes — the instance dispatcher resolves them
463
+ * ambiently anyway, so the assignment makes this document's install
464
+ * explicit and restores the previous values on uninstall.
465
+ *
466
+ * What does NOT get touched — deliberately:
467
+ * - `globalThis.addEventListener`/`removeEventListener` and `self` — the
468
+ * pool's `self.onmessage` channel lives there; `window` is a facade
469
+ * object, not the global.
470
+ * - Any other global (history, fetch, localStorage…) — libraries touching
471
+ * them fail loudly, which is the honest answer.
472
+ *
473
+ * Returns `uninstall()` tearing down the facade's listeners. Also runs
474
+ * automatically on `doc.dispose()` (the updateProps rebuild path) and is
475
+ * superseded by a later installDomShim call — the last install wins.
476
+ */
477
+ export function installDomShim(doc: ProxyDocument): () => void {
478
+ const internal = doc as InternalDocument;
479
+ installInstanceDispatcher();
480
+ const g = globalThis as Record<string, unknown>;
481
+ activeShimUninstall?.();
482
+ activeShimUninstall = null;
483
+
484
+ // Capture the ambient state BEFORE this doc claims it — uninstall puts
485
+ // it all back, so chained installs restore true originals.
486
+ const prevInstance = getLastActiveInstance();
487
+ const prevDocument = g.document;
488
+ const prevWindow = g.window;
489
+ const prevElement = g.Element;
490
+ const prevNode = g.Node;
491
+ const prevText = g.Text;
492
+ const prevComment = g.Comment;
493
+ const prevMedia = g.HTMLMediaElement;
494
+ const prevCustomElements = g.customElements;
495
+
496
+ // The installed document becomes the ambient one for out-of-instance
497
+ // readers: the explicit assignment wins the dispatcher's resolution,
498
+ // and the instance is marked for implicit resolution paths.
499
+ markInstanceActive(internal.instance);
500
+ const bundle = windowFacadeFor(internal);
501
+ g.document = doc;
502
+ g.window = bundle.facade;
503
+ g.Element = ProxyElement;
504
+ g.Node = ProxyNode;
505
+ g.Text = ProxyText;
506
+ g.Comment = ProxyComment;
507
+ if (typeof g.HTMLMediaElement !== 'function') g.HTMLMediaElement = HTML_MEDIA_STUB;
508
+ if (g.customElements === undefined) g.customElements = CUSTOM_ELEMENTS_STUB;
509
+
510
+ const uninstall = (): void => {
511
+ bundle.teardown();
512
+ internal._windowBundle = undefined;
513
+ g.document = prevDocument;
514
+ g.window = prevWindow;
515
+ g.Element = prevElement;
516
+ g.Node = prevNode;
517
+ g.Text = prevText;
518
+ g.Comment = prevComment;
519
+ g.HTMLMediaElement = prevMedia;
520
+ g.customElements = prevCustomElements;
521
+ markInstanceActive(prevInstance);
522
+ if (activeShimUninstall === uninstall) activeShimUninstall = null;
523
+ };
524
+
525
+ internal._uninstallShim = uninstall;
526
+ activeShimUninstall = uninstall;
527
+ return uninstall;
528
+ }