@atolljs/islands 0.1.6 → 0.1.8

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -100,8 +100,9 @@ rejection naming the unanswered entry — not a silent hang.
100
100
 
101
101
  ## Island rules
102
102
 
103
- - **Pool size is pinned to 1.** One tree lives in one worker's memory — scale
104
- out with more islands, not wider pools.
103
+ - **One dedicated worker per client, no pool.** `connectIslandWorker` pins
104
+ `workers: 1`. One tree lives in one worker's memory, so scale out with
105
+ more islands, not wider pools.
105
106
  - **Instance keys** are `app` or `app@N`; `mountIsland` mints them and the
106
107
  same app can mount in many islands at once.
107
108
  - **Clients can be shared.** Several `mountIsland`s into one
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@atolljs/islands",
3
- "version": "0.1.6",
3
+ "version": "0.1.8",
4
4
  "description": "Atoll islands — MonoWorker/PolyWorker hosts rendering framework islands (React/Vue/Svelte/Solid/Angular) over a proxy-DOM op protocol",
5
5
  "homepage": "https://jwhenry3.github.io/atolljs/consumer/quickstart/",
6
6
  "type": "module",
@@ -19,7 +19,7 @@
19
19
  "htmlparser2": "^12.0.0"
20
20
  },
21
21
  "peerDependencies": {
22
- "@atolljs/core": "0.1.6"
22
+ "@atolljs/core": "0.1.8"
23
23
  },
24
24
  "repository": {
25
25
  "type": "git",
@@ -33,6 +33,10 @@ export function callbackProp<Args extends unknown[]>(
33
33
  return marker as unknown as (...args: Args) => void;
34
34
  }
35
35
 
36
+ /** True for a `callbackProp(fn)` marker — devtools previews show it as '[fn]'. */
37
+ export const isCallbackMarker = (v: unknown): boolean =>
38
+ typeof v === 'object' && v !== null && markers.has(v);
39
+
36
40
  const isPlainObj = (v: unknown): v is Record<string, unknown> => {
37
41
  if (typeof v !== 'object' || v === null) return false;
38
42
  const p = Object.getPrototypeOf(v);
@@ -0,0 +1,258 @@
1
+ /**
2
+ * Dashboard commands for main-thread islands (`island.*` on the devtools
3
+ * control channel). `mountIsland` registers each live handle here only
4
+ * while devtools is enabled; `destroy()` drops it. Command names register
5
+ * once and route by the `instance` arg.
6
+ *
7
+ * Only main-thread islands are commandable: a nested island's handle lives
8
+ * in its parent's worker realm, which has no transport. Its DOM still
9
+ * shows up, replayed into the outer island's container, so `island.tree`
10
+ * on the outer instance includes it.
11
+ */
12
+ import { registerDevtoolsCommand, previewValue } from '@atolljs/core';
13
+ import { isCallbackMarker } from './callbackProps';
14
+ import { jsonSafe, propsPreview } from './devtoolsPreview';
15
+ import type { IslandHandle, Mode } from './island';
16
+
17
+ export interface DevtoolsIslandEntry {
18
+ handle: IslandHandle;
19
+ /** The real container element the island replays into. */
20
+ el: HTMLElement;
21
+ /** The props as the caller last gave them (pre-marshal: markers intact). */
22
+ props(): Record<string, unknown>;
23
+ }
24
+
25
+ const islands = new Map<string, DevtoolsIslandEntry>();
26
+ let commandsRegistered = false;
27
+
28
+ /** Track a mounted island for the dashboard; returns the untrack function. */
29
+ export const trackIslandForDevtools = (entry: DevtoolsIslandEntry): (() => void) => {
30
+ ensureIslandCommands();
31
+ const instance = entry.handle.instance;
32
+ islands.set(instance, entry);
33
+ return () => {
34
+ if (islands.get(instance) === entry) islands.delete(instance);
35
+ if (highlighted?.instance === instance) clearHighlight();
36
+ };
37
+ };
38
+
39
+ const entryOf = (args: Record<string, unknown>): DevtoolsIslandEntry => {
40
+ const instance = String(args.instance ?? '');
41
+ const entry = islands.get(instance);
42
+ if (entry) return entry;
43
+ throw new Error(
44
+ instance.includes('~')
45
+ ? `island '${instance}' is nested: its handle lives in the parent worker, so it can't be commanded from the dashboard (inspect the outer island's tree instead)`
46
+ : `island '${instance}' is not mounted on this page's main thread`,
47
+ );
48
+ };
49
+
50
+ /* ── tree ────────────────────────────────────────────────────────────────── */
51
+
52
+ export interface IslandTreeNode {
53
+ /** Child-index path from the container: '0', '0.2', '0.2.s' (shadow root). */
54
+ id: string;
55
+ /** Lowercase tag, or '#text' / '#shadow-root'. */
56
+ tag: string;
57
+ attrs: [string, string][];
58
+ text?: string;
59
+ children: IslandTreeNode[];
60
+ /** Children omitted once the node budget ran out. */
61
+ more?: number;
62
+ }
63
+
64
+ const TEXT_CAP = 200;
65
+ const ATTR_CAP = 200;
66
+
67
+ /** Children the tree shows: elements and non-blank text. Ids index this list. */
68
+ const visibleChildren = (n: Node): Node[] =>
69
+ [...n.childNodes].filter((c) => c.nodeType === 1 || (c.nodeType === 3 && (c.nodeValue ?? '').trim() !== ''));
70
+
71
+ const shadowOf = (n: Node): ShadowRoot | null =>
72
+ n.nodeType === 1 ? ((n as Element).shadowRoot ?? null) : null;
73
+
74
+ const cap = (s: string, n: number) => (s.length > n ? `${s.slice(0, n)}…` : s);
75
+
76
+ const serializeTree = (root: Element, maxNodes: number): { root: IslandTreeNode; count: number; truncated: boolean } => {
77
+ let count = 0;
78
+ let truncated = false;
79
+ const walk = (n: Node, id: string): IslandTreeNode => {
80
+ count++;
81
+ if (n.nodeType === 3) {
82
+ return { id, tag: '#text', attrs: [], text: cap(n.nodeValue ?? '', TEXT_CAP), children: [] };
83
+ }
84
+ const isShadow = n.nodeType === 11;
85
+ const el = n as Element;
86
+ const node: IslandTreeNode = {
87
+ id,
88
+ tag: isShadow ? '#shadow-root' : el.localName ?? el.nodeName.toLowerCase(),
89
+ attrs: isShadow ? [] : [...el.attributes].map((a) => [a.name, cap(a.value, ATTR_CAP)]),
90
+ children: [],
91
+ };
92
+ const kids: [Node, string][] = [];
93
+ const shadow = shadowOf(n);
94
+ if (shadow) kids.push([shadow, `${id}.s`]);
95
+ visibleChildren(n).forEach((c, i) => kids.push([c, `${id}.${i}`]));
96
+ for (let i = 0; i < kids.length; i++) {
97
+ if (count >= maxNodes) {
98
+ node.more = kids.length - i;
99
+ truncated = true;
100
+ break;
101
+ }
102
+ node.children.push(walk(kids[i][0], kids[i][1]));
103
+ }
104
+ return node;
105
+ };
106
+ return { root: walk(root, '0'), count, truncated };
107
+ };
108
+
109
+ /** Resolve a tree id back to its live node (same child filter as serialize). */
110
+ const resolveId = (root: Element, id: string): Node | null => {
111
+ const parts = id.split('.');
112
+ if (parts[0] !== '0') return null;
113
+ let n: Node | null = root;
114
+ for (const p of parts.slice(1)) {
115
+ if (!n) return null;
116
+ n = p === 's' ? shadowOf(n) : (visibleChildren(n)[Number(p)] ?? null);
117
+ }
118
+ return n;
119
+ };
120
+
121
+ /* ── highlight overlay ───────────────────────────────────────────────────── */
122
+
123
+ let overlay: HTMLDivElement | null = null;
124
+ let overlayLabel: HTMLSpanElement | null = null;
125
+ let highlighted: { instance: string } | null = null;
126
+
127
+ const rectOf = (n: Node): DOMRect | null => {
128
+ if (n.nodeType === 1) return (n as Element).getBoundingClientRect();
129
+ if (n.nodeType === 11) return ((n as ShadowRoot).host as Element | undefined)?.getBoundingClientRect() ?? null;
130
+ if (n.nodeType === 3) {
131
+ const range = n.ownerDocument?.createRange();
132
+ if (!range) return null;
133
+ range.selectNodeContents(n);
134
+ return range.getBoundingClientRect();
135
+ }
136
+ return null;
137
+ };
138
+
139
+ const tagOf = (n: Node): string =>
140
+ n.nodeType === 3 ? '#text' : n.nodeType === 11 ? '#shadow-root' : (n as Element).localName;
141
+
142
+ const clearHighlight = (): void => {
143
+ highlighted = null;
144
+ if (overlay) overlay.style.display = 'none';
145
+ };
146
+
147
+ const showHighlight = (instance: string, doc: Document, target: Node): { x: number; y: number; w: number; h: number } | null => {
148
+ const r = rectOf(target);
149
+ if (!r) return null;
150
+ if (!overlay || overlay.ownerDocument !== doc || !overlay.isConnected) {
151
+ overlay?.remove();
152
+ overlay = doc.createElement('div');
153
+ overlay.setAttribute('data-atoll-devtools', 'highlight');
154
+ overlay.style.cssText =
155
+ 'position:fixed;pointer-events:none;z-index:2147483647;box-sizing:border-box;' +
156
+ 'outline:2px solid #58a6ff;background:rgba(88,166,255,.18);display:none';
157
+ overlayLabel = doc.createElement('span');
158
+ overlayLabel.style.cssText =
159
+ 'position:absolute;left:0;white-space:nowrap;font:11px/16px ui-monospace,monospace;' +
160
+ 'padding:0 5px;border-radius:3px;background:#1f6feb;color:#fff';
161
+ overlay.appendChild(overlayLabel);
162
+ (doc.body ?? doc.documentElement).appendChild(overlay);
163
+ }
164
+ overlay.style.left = `${r.left}px`;
165
+ overlay.style.top = `${r.top}px`;
166
+ overlay.style.width = `${r.width}px`;
167
+ overlay.style.height = `${r.height}px`;
168
+ overlay.style.display = 'block';
169
+ if (overlayLabel) {
170
+ overlayLabel.textContent = `${tagOf(target)} ${Math.round(r.width)}×${Math.round(r.height)}`;
171
+ overlayLabel.style.top = r.top >= 18 ? '-18px' : '100%';
172
+ }
173
+ highlighted = { instance };
174
+ return { x: r.left, y: r.top, w: r.width, h: r.height };
175
+ };
176
+
177
+ /* ── props ───────────────────────────────────────────────────────────────── */
178
+
179
+ /**
180
+ * Dashboard props are JSON: a '[fn]' placeholder (what `island.props`
181
+ * shows for a callback) can't carry the function back. Restore it from the
182
+ * current props at the same path so a read-edit-write round trip keeps
183
+ * the island's callbacks wired.
184
+ */
185
+ const restoreFns = (next: unknown, current: unknown): unknown => {
186
+ if (next === '[fn]' && (typeof current === 'function' || isCallbackMarker(current))) return current;
187
+ if (Array.isArray(next)) {
188
+ return next.map((v, i) => restoreFns(v, Array.isArray(current) ? current[i] : undefined));
189
+ }
190
+ if (next !== null && typeof next === 'object') {
191
+ const cur = current !== null && typeof current === 'object' ? (current as Record<string, unknown>) : {};
192
+ const out: Record<string, unknown> = {};
193
+ for (const [k, v] of Object.entries(next)) out[k] = restoreFns(v, cur[k]);
194
+ return out;
195
+ }
196
+ return next;
197
+ };
198
+
199
+ /* ── registration ────────────────────────────────────────────────────────── */
200
+
201
+ const ensureIslandCommands = (): void => {
202
+ if (commandsRegistered) return;
203
+ commandsRegistered = true;
204
+
205
+ registerDevtoolsCommand('island.list', () =>
206
+ [...islands.values()].map(({ handle }) => ({
207
+ instance: handle.instance,
208
+ app: handle.app,
209
+ pid: handle.pid,
210
+ mode: handle.mode,
211
+ })),
212
+ );
213
+
214
+ registerDevtoolsCommand('island.tree', (args) => {
215
+ const { el } = entryOf(args);
216
+ const max = Math.min(Math.max(Number(args.maxNodes) || 3000, 1), 20000);
217
+ return serializeTree(el, max);
218
+ });
219
+
220
+ registerDevtoolsCommand('island.highlight', (args) => {
221
+ const entry = entryOf(args);
222
+ if (args.id === null || args.id === undefined) {
223
+ clearHighlight();
224
+ return { ok: true };
225
+ }
226
+ const target = resolveId(entry.el, String(args.id));
227
+ if (!target) {
228
+ clearHighlight();
229
+ return { ok: false };
230
+ }
231
+ const rect = showHighlight(entry.handle.instance, entry.el.ownerDocument, target);
232
+ return { ok: rect !== null, rect };
233
+ });
234
+
235
+ registerDevtoolsCommand('island.props', (args) => {
236
+ const raw = entryOf(args).props();
237
+ return { props: jsonSafe(raw), preview: propsPreview(raw) };
238
+ });
239
+
240
+ registerDevtoolsCommand('island.updateProps', async (args) => {
241
+ const entry = entryOf(args);
242
+ const next = args.props;
243
+ if (next === null || typeof next !== 'object' || Array.isArray(next)) {
244
+ throw new Error('island.updateProps: `props` must be an object');
245
+ }
246
+ const restored = restoreFns(next, entry.props()) as Record<string, unknown>;
247
+ await entry.handle.updateProps(restored);
248
+ return { ok: true, preview: previewValue(jsonSafe(restored), 4096) };
249
+ });
250
+
251
+ registerDevtoolsCommand('island.setMode', (args) => {
252
+ const { handle } = entryOf(args);
253
+ const mode = args.mode;
254
+ if (mode !== 'push' && mode !== 'poll') throw new Error("island.setMode: mode must be 'push' or 'poll'");
255
+ handle.setMode(mode as Mode);
256
+ return { mode: handle.mode };
257
+ });
258
+ };
@@ -0,0 +1,46 @@
1
+ /**
2
+ * Devtools value helpers shared by both island drivers (main-thread
3
+ * `island.ts`, worker-side `worker/subIsland.ts`). Call only behind
4
+ * `devtoolsEnabled()`: everything here allocates.
5
+ */
6
+ import { previewValue } from '@atolljs/core';
7
+ import { isCallbackMarker } from './callbackProps';
8
+
9
+ /** Node budget for one `jsonSafe` walk: props are small, a runaway graph isn't. */
10
+ const SAFE_BUDGET = 5000;
11
+
12
+ /**
13
+ * JSON-safe deep copy of caller-given props: functions and `callbackProp`
14
+ * markers become '[fn]', bigints `'<n>n'`, binary data a size label,
15
+ * cycles '[cycle]'. The shape the dashboard can display and round-trip.
16
+ */
17
+ export const jsonSafe = (value: unknown): unknown => {
18
+ let nodes = 0;
19
+ const seen = new WeakSet<object>();
20
+ const walk = (v: unknown, depth: number): unknown => {
21
+ if (++nodes > SAFE_BUDGET) return '[…]';
22
+ if (typeof v === 'function' || isCallbackMarker(v)) return '[fn]';
23
+ if (typeof v === 'bigint') return `${v}n`;
24
+ if (typeof v === 'number') return Number.isFinite(v) ? v : String(v);
25
+ if (typeof v !== 'object' || v === null) return v;
26
+ if (v instanceof ArrayBuffer) return `[ArrayBuffer ${v.byteLength}B]`;
27
+ if (ArrayBuffer.isView(v)) return `[${v.constructor.name} ${v.byteLength}B]`;
28
+ if (v instanceof Date) return v.toISOString();
29
+ if (seen.has(v)) return '[cycle]';
30
+ if (depth > 12) return '[…]';
31
+ seen.add(v);
32
+ if (v instanceof Map) return { '[Map]': [...v.entries()].slice(0, 50).map((e) => walk(e, depth + 1)) };
33
+ if (v instanceof Set) return { '[Set]': [...v].slice(0, 50).map((x) => walk(x, depth + 1)) };
34
+ if (Array.isArray(v)) return v.map((x) => walk(x, depth + 1));
35
+ const out: Record<string, unknown> = {};
36
+ for (const k of Object.keys(v)) {
37
+ const x = (v as Record<string, unknown>)[k];
38
+ if (x !== undefined) out[k] = walk(x, depth + 1);
39
+ }
40
+ return out;
41
+ };
42
+ return walk(value, 0);
43
+ };
44
+
45
+ /** `island:props` payload: a bounded preview with callbacks as '[fn]'. */
46
+ export const propsPreview = (props: Record<string, unknown>): string => previewValue(jsonSafe(props), 4096);