@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/src/island.ts CHANGED
@@ -1,20 +1,30 @@
1
1
  /**
2
2
  * Per-island mounter — the main-thread half of one React tree in one worker.
3
3
  *
4
- * `mountIsland` mounts one island scoped so several can coexist on one page:
5
- * each island gets its own `connectWorker` client (own pool of ONE worker,
6
- * own doorbell buffer via `makeDoorbell()`), its own nodes/props/listeners
7
- * maps (op ids are only unique within a worker — sharing maps between
8
- * islands would corrupt them), and its own `onEvent` sink for `emit` ops.
4
+ * `mountIsland` mounts one island scoped so several can coexist on one page.
5
+ * A `worker:` mount builds its own client (one dedicated worker, own
6
+ * doorbell buffer via `makeDoorbell()`), i.e. spawns a worker; a `client:`
7
+ * mount adds an instance to that client's existing worker instead. Either
8
+ * way each island gets its own nodes/props/listeners maps (op ids are only
9
+ * unique within a worker — sharing maps between islands would corrupt them),
10
+ * and its own `onEvent` sink for `emit` ops.
9
11
  *
10
12
  * There is no React on this thread — the driver below just replays ops.
11
13
  *
12
- * poolSize: 1 is REQUIRED per island — the reconciled tree lives in one
13
- * worker's memory. A real pool can't serve islands today anyway: task routing
14
- * is least-busy round-robin, so a second worker would receive dispatches for
15
- * a tree it doesn't hold (sticky routing is future work).
14
+ * One worker per island client is REQUIRED — the reconciled tree lives in
15
+ * one worker's memory. A pool can't serve islands: task routing is
16
+ * least-busy, so a second worker would receive dispatches for a tree it
17
+ * doesn't hold. Island clients therefore run on `workers: 1`, a dedicated
18
+ * worker with no pool.
16
19
  */
17
- import { connectWorker, observe } from '@atolljs/core';
20
+ import {
21
+ connectWorker,
22
+ devtoolsEnabled,
23
+ emitDevtools,
24
+ estimateCloneBytes,
25
+ observe,
26
+ previewValue,
27
+ } from '@atolljs/core';
18
28
  import type {
19
29
  ConnectWorkerConfig,
20
30
  SharedSpec,
@@ -31,27 +41,31 @@ import {
31
41
  } from './ops';
32
42
 
33
43
  import { marshalCallbackProps, CALLBACK_EVENT } from './callbackProps';
44
+ import { trackIslandForDevtools } from './devtoolsCommands';
45
+ import { propsPreview } from './devtoolsPreview';
46
+ import type { ProxyElement } from './worker/dom/element';
34
47
 
35
48
  export type Mode = 'push' | 'poll';
36
49
 
37
50
  /** The worker definition every `definePolyWorker` call produces. */
38
51
  export type IslandWorkerDefinition = WorkerDefinition<DoorbellSpec, IslandWorkerMethods>;
39
52
 
40
- /** One island client: one pool, one worker, one doorbell buffer. */
53
+ /** One island client: one dedicated worker, one doorbell buffer. */
41
54
  export type IslandClient = WorkerClient<IslandWorkerDefinition, DoorbellSpec>;
42
55
 
43
56
  /**
44
- * Islands are microfrontend containers: each owns ONE worker holding ONE
45
- * reconciled tree, so pooling is disabled by construction — the type omits
46
- * `poolSize`/`worker`/`sharedMemory` (all island-internal) and the literal
47
- * below pins `poolSize: 1` after the spread, so a wider pool can't sneak
57
+ * An island client is ONE worker holding its islands' trees (one per mount
58
+ * on it), so pooling is disabled by construction — the type omits
59
+ * `workers`/`poolSize`/`worker`/`sharedMemory` (all island-internal) and the
60
+ * literal below pins `workers: 1` after the spread, so a pool can't sneak
48
61
  * through a cast either. `worker` is supplied per call — the package can't
49
62
  * know where the consumer's worker entry lives — everything else
50
- * (concurrency, taskTimeout, respawn, lazy…) still passes through.
63
+ * (taskTimeout, respawn, lazy…) still passes through. The pool-only
64
+ * `concurrency`/`maxQueue` are accepted but have no effect.
51
65
  */
52
66
  export type IslandWorkerOptions = Omit<
53
67
  ConnectWorkerConfig<SharedSpec>,
54
- 'sharedMemory' | 'worker' | 'poolSize'
68
+ 'sharedMemory' | 'worker' | 'workers' | 'poolSize'
55
69
  >;
56
70
 
57
71
  export interface ConnectIslandWorkerConfig extends IslandWorkerOptions {
@@ -64,13 +78,13 @@ export interface ConnectIslandWorkerConfig extends IslandWorkerOptions {
64
78
  /**
65
79
  * Default true — the client carries a SharedArrayBuffer doorbell for
66
80
  * push-mode flush (requires COOP/COEP cross-origin isolation). Pass
67
- * `false` for a message-only pool: `setMode('push')` falls back to
81
+ * `false` for a message-only client: `setMode('push')` falls back to
68
82
  * polling and the page needs no special headers.
69
83
  */
70
84
  doorbell?: boolean;
71
85
  }
72
86
 
73
- /** One island client: one pool, one worker, one doorbell buffer. */
87
+ /** One island client: one dedicated worker, one doorbell buffer. */
74
88
  export const connectIslandWorker = <W extends IslandWorkerDefinition = IslandWorkerDefinition>({
75
89
  worker,
76
90
  doorbell,
@@ -80,12 +94,17 @@ export const connectIslandWorker = <W extends IslandWorkerDefinition = IslandWor
80
94
  ...options,
81
95
  sharedMemory: doorbell === false ? undefined : makeDoorbell(),
82
96
  worker,
83
- poolSize: 1,
97
+ workers: 1,
84
98
  });
85
99
 
86
100
  interface MountIslandBaseOptions {
87
- /** Container element the island's ops are applied into. */
88
- el: HTMLElement;
101
+ /**
102
+ * Container element the island's ops are applied into — a real element on
103
+ * the shell side, or a `ProxyElement` inside a worker-rendered island
104
+ * (nested mount: the sub-island's ops replay into the parent instance's
105
+ * shadow tree and tunnel upward through its op stream). Required.
106
+ */
107
+ el: HTMLElement | ProxyElement;
89
108
  /**
90
109
  * Registry app name — a key of the `apps` map passed to
91
110
  * `definePolyWorker`. Optional against a instance worker
@@ -103,6 +122,10 @@ interface MountIslandBaseOptions {
103
122
  * events, zero wire). Called again with `null` when the worker removes or
104
123
  * renames the element, so the shell can tear down. The element itself
105
124
  * stays worker-owned — its box/layout props apply normally.
125
+ *
126
+ * Nested mounts (a ProxyElement `el`) receive the anchor's ProxyElement
127
+ * instead, and only claim names present in this object (`name in slots`);
128
+ * unclaimed slot names bubble up to the outer island's `slots`.
106
129
  */
107
130
  slots?: Record<string, (el: HTMLElement | null) => void>;
108
131
  /** Fired after each applied op batch — the shell uses it for stats. */
@@ -121,6 +144,14 @@ interface MountIslandBaseOptions {
121
144
  * cross-origin isolation requirement. Switch later with `handle.setMode`.
122
145
  */
123
146
  mode?: Mode;
147
+ /**
148
+ * Optional renderer/framework tag — pure observability metadata forwarded
149
+ * to devtools on `island:mount` ('react', 'vue', 'svelte', …). When
150
+ * omitted (and devtools is on), the driver asks the worker: package
151
+ * adapters report their renderer, imperative apps report none and the
152
+ * map draws them as plain workers. An explicit tag always wins.
153
+ */
154
+ framework?: string;
124
155
  /**
125
156
  * Milliseconds the mount handshake (mount + whoami) may take before the
126
157
  * returned promise rejects — default 15_000, `0` disables. A worker entry
@@ -148,6 +179,11 @@ export interface IslandHandle {
148
179
  readonly app: string;
149
180
  /** The worker-side instance's random id — proof each island is a distinct worker. */
150
181
  readonly pid: string;
182
+ /**
183
+ * The wire instance key — `app@N` for a top-level island, `parent~app@N`
184
+ * when mounted into a proxy element inside a worker island.
185
+ */
186
+ readonly instance: string;
151
187
  readonly mode: Mode;
152
188
  opsApplied: number;
153
189
  flushCalls: number;
@@ -240,6 +276,37 @@ function buildEventPayload(
240
276
  };
241
277
  }
242
278
 
279
+ /**
280
+ * User Timing entry for one main-thread op replay (devtools only). The
281
+ * `detail.devtools` track-entry extension puts it on an 'atoll › islands'
282
+ * track in Chrome's Performance panel; other engines keep a plain measure.
283
+ * Same shape as core's task measures (`src/userTiming.ts`), including the
284
+ * immediate clear: per-batch entries would otherwise grow the buffer.
285
+ */
286
+ const measureReplay = (instance: string, via: string, count: number, start: number, end: number): void => {
287
+ if (typeof performance?.measure !== 'function') return;
288
+ const name = `atoll replay ${instance}`;
289
+ try {
290
+ performance.measure(name, {
291
+ start,
292
+ end,
293
+ detail: {
294
+ devtools: {
295
+ dataType: 'track-entry',
296
+ trackGroup: 'atoll',
297
+ track: 'islands',
298
+ color: 'primary',
299
+ tooltipText: `${instance} · ${via} · ${count} ops`,
300
+ properties: [['via', via], ['ops', count]],
301
+ },
302
+ },
303
+ });
304
+ performance.clearMeasures?.(name);
305
+ } catch {
306
+ /* no User Timing L3 — skip */
307
+ }
308
+ };
309
+
243
310
  /** Per-island instance counter — see the instance key note in mountIsland. */
244
311
  let islandSeq = 0;
245
312
 
@@ -289,7 +356,7 @@ const uncloneablePath = (value: unknown, path: string): string => {
289
356
  return path;
290
357
  };
291
358
 
292
- const assertCloneableProps = (props: Record<string, unknown>, context: string): void => {
359
+ export const assertCloneableProps = (props: Record<string, unknown>, context: string): void => {
293
360
  if (canClone(props)) return;
294
361
  const path = uncloneablePath(props, '');
295
362
  throw new Error(
@@ -320,8 +387,10 @@ const UNITLESS_CSS = new Set([
320
387
  ]);
321
388
 
322
389
  /** Coerce a wire style value to a CSS string — px-suffixes bare numbers
323
- * on non-unitless keys (custom properties pass verbatim). */
324
- const cssStyleValue = (key: string, value: unknown): string => {
390
+ * on non-unitless keys (custom properties pass verbatim). Exported for the
391
+ * worker-side nested driver (worker/subIsland.ts), which applies the same
392
+ * unit semantics when replaying style values into proxy elements. */
393
+ export const cssStyleValue = (key: string, value: unknown): string => {
325
394
  if (
326
395
  typeof value === 'number' &&
327
396
  value !== 0 &&
@@ -333,12 +402,48 @@ const cssStyleValue = (key: string, value: unknown): string => {
333
402
  return String(value);
334
403
  };
335
404
 
405
+ /**
406
+ * The worker-side mounter — registered by `@atolljs/islands/worker`
407
+ * (`worker/subIsland.ts`) so `mountIsland` serves BOTH mount targets with
408
+ * one signature: a proxy `el` (inside a worker-rendered island) takes the
409
+ * nested path, which replays the sub-worker's ops into the parent
410
+ * instance's shadow tree. Kept behind a registration slot rather than an
411
+ * import so the main-thread bundle never loads the proxy DOM.
412
+ */
413
+ let subMounter: ((opts: MountIslandOptions) => Promise<IslandHandle>) | null = null;
414
+ export const registerSubIslandMounter = (
415
+ fn: (opts: MountIslandOptions) => Promise<IslandHandle>,
416
+ ): void => {
417
+ subMounter = fn;
418
+ };
419
+
420
+ /**
421
+ * Structural discriminant — a proxy element's `doc.instance` is the parent
422
+ * instance key, a real element has no `doc` property at all. Type beats
423
+ * environment sniffing: in-process harnesses run worker and shell in one
424
+ * module graph where globals lie, but the mount target never does.
425
+ */
426
+ const isProxyMountTarget = (el: unknown): el is ProxyElement =>
427
+ typeof (el as { doc?: { instance?: unknown } } | null)?.doc?.instance === 'string';
428
+
336
429
  export async function mountIsland(opts: MountIslandOptions): Promise<IslandHandle> {
337
430
  const {
338
- el, onEvent, onActivity, onOps, slots, mode: initialMode, mountTimeout,
339
- client: givenClient, worker, app: givenApp, props: givenProps,
431
+ el: target, onEvent, onActivity, onOps, slots, mode: initialMode, mountTimeout,
432
+ client: givenClient, worker, app: givenApp, props: givenProps, framework,
340
433
  ...workerConfig
341
434
  } = opts;
435
+ if (isProxyMountTarget(target)) {
436
+ if (subMounter === null) {
437
+ throw new Error(
438
+ 'mountIsland: a proxy-element mount target means a nested island — ' +
439
+ 'the worker entry (`@atolljs/islands/worker`) must be loaded to register the sub-mounter.',
440
+ );
441
+ }
442
+ return subMounter(opts);
443
+ }
444
+ // Real DOM from here on (the proxy path delegated above) — the declared
445
+ // type keeps closures honest; the guard did the proving.
446
+ const el = target as HTMLElement;
342
447
  // Shell→worker callables: callbackProp() markers marshal to {__cb:id}
343
448
  // wire handles; the island's table keeps the real functions and the
344
449
  // driver's emit-case routes worker-side invocations back here.
@@ -349,6 +454,8 @@ export async function mountIsland(opts: MountIslandOptions): Promise<IslandHandl
349
454
  return cbSeq;
350
455
  };
351
456
  const props = marshalCallbackProps(givenProps ?? {}, registerCb) as Record<string, unknown>;
457
+ /** The props as the caller last gave them — devtools previews/commands read these. */
458
+ let currentProps: Record<string, unknown> = givenProps ?? {};
352
459
  if (givenClient === undefined && worker === undefined) {
353
460
  throw new Error(
354
461
  'mountIsland: pass `worker` (a `() => new Worker(...)`/URL entry — this call builds the ' +
@@ -358,6 +465,7 @@ export async function mountIsland(opts: MountIslandOptions): Promise<IslandHandl
358
465
  const client: IslandClient =
359
466
  givenClient ??
360
467
  connectIslandWorker({
468
+ name: givenApp,
361
469
  ...workerConfig,
362
470
  worker,
363
471
  // Poll-mode shorthand islands don't need SharedArrayBuffer at all —
@@ -442,9 +550,8 @@ export async function mountIsland(opts: MountIslandOptions): Promise<IslandHandl
442
550
  );
443
551
  // The whole point: an event = one postMessage round-trip. The worker
444
552
  // re-renders, we apply whatever ops come back.
445
- void client
446
- .dispatch(handlerId, payload)
447
- .then(applyOps)
553
+ void islandCall('dispatch', client.dispatch(handlerId, payload))
554
+ .then((ops) => applyOps(ops, 'dispatch'))
448
555
  .catch((err) => console.error(`[island ${instance}] dispatch failed`, err));
449
556
  };
450
557
  }
@@ -771,24 +878,62 @@ export async function mountIsland(opts: MountIslandOptions): Promise<IslandHandl
771
878
  break;
772
879
  }
773
880
  // Not a DOM mutation — the island→shell channel.
881
+ if (devtoolsEnabled()) {
882
+ emitDevtools({ type: 'island:event', instance, name: op.name, payload: previewValue(op.payload, 1024) });
883
+ }
774
884
  onEvent?.(op.name, op.payload);
775
885
  break;
776
886
  }
777
887
  }
778
888
  }
779
889
 
780
- function applyOps(ops: Op[]): void {
890
+ function applyOps(ops: Op[], via: 'mount' | 'updateProps' | 'dispatch' | 'setSize' | 'flush'): void {
781
891
  opsApplied += ops.length;
782
892
  onActivity?.();
783
- const t0 = onOps !== undefined ? performance.now() : 0;
893
+ const t0 = performance.now();
784
894
  for (const op of ops) applyOp(op);
785
- onOps?.(ops, performance.now() - t0);
895
+ const t1 = performance.now();
896
+ const replayMs = t1 - t0;
897
+ if (devtoolsEnabled()) {
898
+ emitDevtools({
899
+ type: 'island:ops', instance, via, count: ops.length, replayMs,
900
+ bytes: estimateCloneBytes(ops),
901
+ });
902
+ measureReplay(instance, via, ops.length, t0, t1);
903
+ }
904
+ onOps?.(ops, replayMs);
786
905
  }
787
906
 
907
+ /** Times a main→worker round-trip for the devtools inspector. */
908
+ const islandCall = <T>(
909
+ method: 'mount' | 'whoami' | 'dispatch' | 'setSize' | 'updateProps' | 'flush' | 'unmount',
910
+ p: Promise<T>,
911
+ ): Promise<T> => {
912
+ const t0 = performance.now();
913
+ return p.then(
914
+ (r) => {
915
+ emitDevtools({
916
+ type: 'island:task', instance, method,
917
+ ms: performance.now() - t0,
918
+ ops: Array.isArray(r) ? r.length : undefined,
919
+ });
920
+ return r;
921
+ },
922
+ (err) => {
923
+ emitDevtools({
924
+ type: 'island:task', instance, method,
925
+ ms: performance.now() - t0,
926
+ error: err instanceof Error ? err.message : String(err),
927
+ });
928
+ throw err;
929
+ },
930
+ );
931
+ };
932
+
788
933
  const doFlush = (): void => {
789
934
  if (destroyed) return;
790
935
  flushCalls++;
791
- void client.flush(instance).then(applyOps);
936
+ void islandCall('flush', client.flush(instance)).then((ops) => applyOps(ops, 'flush'));
792
937
  };
793
938
 
794
939
  /* ── Transport: push (shared-memory doorbell) vs poll ─────────────────── */
@@ -842,15 +987,21 @@ export async function mountIsland(opts: MountIslandOptions): Promise<IslandHandl
842
987
 
843
988
  let pid: string;
844
989
  try {
845
- applyOps(await raced(client.mount(instance, props)));
846
- pid = await raced(client.whoami(instance));
990
+ applyOps(await raced(islandCall('mount', client.mount(instance, props))), 'mount');
991
+ pid = await raced(islandCall('whoami', client.whoami(instance)));
992
+ if (devtoolsEnabled()) {
993
+ // an explicit tag wins; otherwise ask the worker which renderer the app uses
994
+ const fw = framework ?? (await client.renderer(instance).catch(() => null)) ?? undefined;
995
+ emitDevtools({ type: 'island:mount', instance, app, pid, poolId: client.pool?.poolId, framework: fw });
996
+ emitDevtools({ type: 'island:props', instance, props: propsPreview(currentProps) });
997
+ }
847
998
  } catch (err) {
848
999
  // The instance may exist worker-side with ops we never applied — release it.
849
1000
  // An island-owned client also terminates (stops a crash→respawn loop on
850
1001
  // a permanently-broken entry); a shared client just drops the instance.
851
1002
  clientMounts.set(client, (clientMounts.get(client) ?? 1) - 1);
852
1003
  if (givenClient === undefined) client.terminate();
853
- else void client.unmount(instance).catch(() => {});
1004
+ else void islandCall('unmount', client.unmount(instance)).catch(() => {});
854
1005
  throw err;
855
1006
  }
856
1007
 
@@ -879,7 +1030,7 @@ export async function mountIsland(opts: MountIslandOptions): Promise<IslandHandl
879
1030
  if (w === lastW && h === lastH) return;
880
1031
  lastW = w;
881
1032
  lastH = h;
882
- void client.setSize(instance, w, h).then(applyOps);
1033
+ void islandCall('setSize', client.setSize(instance, w, h)).then((ops) => applyOps(ops, 'setSize'));
883
1034
  };
884
1035
  const resizeObserver =
885
1036
  typeof ResizeObserver === 'function'
@@ -905,6 +1056,7 @@ export async function mountIsland(opts: MountIslandOptions): Promise<IslandHandl
905
1056
  const handle: IslandHandle = {
906
1057
  app,
907
1058
  pid,
1059
+ instance,
908
1060
  get mode() {
909
1061
  return mode;
910
1062
  },
@@ -918,13 +1070,18 @@ export async function mountIsland(opts: MountIslandOptions): Promise<IslandHandl
918
1070
  updateProps: async (next: Record<string, unknown>) => {
919
1071
  const marshalled = marshalCallbackProps(next, registerCb) as Record<string, unknown>;
920
1072
  assertCloneableProps(marshalled, `updateProps(${app})`);
921
- applyOps(await client.updateProps(instance, marshalled));
1073
+ applyOps(await islandCall('updateProps', client.updateProps(instance, marshalled)), 'updateProps');
1074
+ currentProps = next;
1075
+ if (devtoolsEnabled()) emitDevtools({ type: 'island:props', instance, props: propsPreview(next) });
922
1076
  },
923
1077
  flush: async () => {
924
- applyOps(await client.flush(instance));
1078
+ applyOps(await islandCall('flush', client.flush(instance)), 'flush');
925
1079
  },
926
1080
  destroy: () => {
927
1081
  destroyed = true;
1082
+ untrackDevtools?.();
1083
+ untrackDevtools = null;
1084
+ emitDevtools({ type: 'island:unmount', instance });
928
1085
  unsubscribe?.();
929
1086
  resizeObserver?.disconnect();
930
1087
  if (pollTimer !== null) clearInterval(pollTimer);
@@ -936,7 +1093,7 @@ export async function mountIsland(opts: MountIslandOptions): Promise<IslandHandl
936
1093
  // detach ops are irrelevant if our element is already gone, so the
937
1094
  // return batch is dropped. Best-effort: the instance map and its
938
1095
  // handlers release regardless.
939
- void client.unmount(instance).catch(() => {});
1096
+ void islandCall('unmount', client.unmount(instance)).catch(() => {});
940
1097
  } else {
941
1098
  // Last island — still run the instance's teardown first so the app's
942
1099
  // dispose hook cancels deferred work (library timers, animation
@@ -944,8 +1101,7 @@ export async function mountIsland(opts: MountIslandOptions): Promise<IslandHandl
944
1101
  // kill the thread before unmount lands — equally fine, process
945
1102
  // death is teardown — but in-process workers need this or the
946
1103
  // instance zombies on and its pending callbacks mutate foreign docs.
947
- void client
948
- .unmount(instance)
1104
+ void islandCall('unmount', client.unmount(instance))
949
1105
  .catch(() => {})
950
1106
  .finally(() => {
951
1107
  // A remount may have registered while teardown was in flight
@@ -956,5 +1112,9 @@ export async function mountIsland(opts: MountIslandOptions): Promise<IslandHandl
956
1112
  }
957
1113
  },
958
1114
  };
1115
+ // Dashboard commands (`island.*`) — registered only while devtools is on.
1116
+ let untrackDevtools: (() => void) | null = devtoolsEnabled()
1117
+ ? trackIslandForDevtools({ handle, el, props: () => currentProps })
1118
+ : null;
959
1119
  return handle;
960
1120
  }
package/src/ops.ts CHANGED
@@ -212,4 +212,10 @@ export type IslandWorkerMethods = {
212
212
  unmount(instance: string): Op[];
213
213
  /** The mounted instance's random id — the island's "worker pid" badge. */
214
214
  whoami(instance: string): string;
215
+ /**
216
+ * The instance's renderer tag ('react', 'vue', …) from its adapter; null
217
+ * for imperative apps. Read by the driver only for devtools, and only
218
+ * when the mount declared no `framework`.
219
+ */
220
+ renderer(instance: string): string | null;
215
221
  };
@@ -153,6 +153,13 @@ export interface RenderedHandle {
153
153
  */
154
154
  export interface RenderedIslandApp {
155
155
  mount(ctx: RenderContext): RenderedHandle | void;
156
+ /**
157
+ * Renderer tag ('react', 'vue', 'svelte', 'solid', 'angular'): set by the
158
+ * package adapters, reported by the `renderer` task so devtools can mark
159
+ * an island whose mount declared no `framework`. Hand-written adapters
160
+ * may set it; imperative apps have none.
161
+ */
162
+ renderer?: string;
156
163
  }
157
164
  /** The apps an island can mount — keyed by the name the shell passes to mount(). */
158
165
  export type IslandApp = ImperativeIslandApp | RenderedIslandApp;
@@ -266,10 +273,13 @@ const resolveApp = (name: string): IslandApp | undefined =>
266
273
 
267
274
  const newPid = (): string => `w-${Math.random().toString(36).slice(2, 8)}`;
268
275
 
269
- /** 'controls' or 'data-table@7' → 'data-table' — registry name part of a instance key. */
276
+ /** 'controls' or 'data-table@7' → 'data-table' — registry name part of a instance key.
277
+ * Nested keys ('host@1~counter@1', one '~' per level) resolve the last segment. */
270
278
  const appNameOf = (instance: string): string => {
271
279
  const at = instance.lastIndexOf('@');
272
- return at === -1 ? instance : instance.slice(0, at);
280
+ const name = at === -1 ? instance : instance.slice(0, at);
281
+ const tilde = name.lastIndexOf('~');
282
+ return tilde === -1 ? name : name.slice(tilde + 1);
273
283
  };
274
284
 
275
285
  /**
@@ -617,6 +627,12 @@ function createIslandRuntime(
617
627
  whoami(instance: string): string {
618
628
  return mounts.get(instance)?.pid ?? 'unmounted';
619
629
  },
630
+
631
+ /** The instance's renderer tag (an adapter's `renderer`); null for imperative or unknown. */
632
+ renderer(instance: string): string | null {
633
+ const mounted = mounts.get(instance);
634
+ return (mounted && isRenderedInstance(mounted) && mounted.rendered.app.renderer) || null;
635
+ },
620
636
  },
621
637
  });
622
638
  }
@@ -47,6 +47,7 @@ export {
47
47
  instances,
48
48
  newElement,
49
49
  newText,
50
+ proxyInstanceStats,
50
51
  pushOp,
51
52
  registerHandler,
52
53
  ROOT_CONTAINER,
@@ -85,3 +86,19 @@ export type { EventPayload, IslandWorkerMethods, Op, WireProps } from '../ops';
85
86
  export { isEventRef } from '../ops';
86
87
  export { renderMemory } from '../memory';
87
88
  export type { DoorbellSpec } from '../memory';
89
+
90
+ export { mountSubIsland } from './subIsland';
91
+ export type { MountSubIslandOptions, SubIslandHandle } from './subIsland';
92
+
93
+ // Nested-island clients: `connectIslandWorker` is thread-agnostic — a worker
94
+ // shell calls it to spawn a sub-worker. `mountIsland` is the ONE surface:
95
+ // a proxy `el` (inside a worker-rendered tree) delegates to the registered
96
+ // sub-mounter in ./subIsland; a real `el` drives the main-thread applier.
97
+ export { connectIslandWorker, mountIsland } from '../island';
98
+ export type {
99
+ ConnectIslandWorkerConfig,
100
+ IslandClient,
101
+ IslandHandle,
102
+ Mode,
103
+ MountIslandOptions,
104
+ } from '../island';
@@ -237,10 +237,14 @@ export const proxyInstanceStats = (): {
237
237
  liveNodes: number;
238
238
  handlers: number;
239
239
  queuedOps: number;
240
+ queuedByInstance: Record<string, number>;
240
241
  } => ({
241
242
  liveNodes: instances.size,
242
243
  handlers: handlers.size,
243
244
  queuedOps: [...opsByInstance.values()].reduce((a, q) => a + q.length, 0),
245
+ queuedByInstance: Object.fromEntries(
246
+ [...opsByInstance.entries()].map(([k, q]) => [k, q.length]),
247
+ ),
244
248
  });
245
249
 
246
250
  export const getHandler = (id: number): HandlerEntry | undefined => handlers.get(id);