@atolljs/islands 0.1.5 → 0.1.7

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@atolljs/islands",
3
- "version": "0.1.5",
3
+ "version": "0.1.7",
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.5"
22
+ "@atolljs/core": "0.1.7"
23
23
  },
24
24
  "repository": {
25
25
  "type": "git",
package/src/app.ts CHANGED
@@ -10,6 +10,9 @@
10
10
  * The stamp is a plain data property — stable through minification.
11
11
  */
12
12
 
13
+ import { isIslandContract } from './contract';
14
+ import type { IslandContract } from './contract';
15
+
13
16
  /** The data property `islandApp` stamps — read by islandAppNameOf. */
14
17
  const ISLAND_APP_NAME = 'islandAppName';
15
18
 
@@ -41,6 +44,9 @@ export const islandApp = <A>(name: string, app: A): A & { readonly islandAppName
41
44
  export const islandAppNameOf = (app: unknown): string | undefined => {
42
45
  if (typeof app === 'string') return app === '' ? undefined : app;
43
46
  if (app === null || (typeof app !== 'function' && typeof app !== 'object')) return undefined;
47
+ // A contract's registry key IS its `app` field — checked before the
48
+ // function/imperative fallbacks so `app={checkoutContract}` resolves.
49
+ if (isIslandContract(app)) return app.app === '' ? undefined : app.app;
44
50
  const stamped = (app as unknown as Record<string, unknown>)[ISLAND_APP_NAME];
45
51
  if (typeof stamped === 'string' && stamped !== '') return stamped;
46
52
  if (typeof app === 'function') {
@@ -62,13 +68,15 @@ export const islandAppNameOf = (app: unknown): string | undefined => {
62
68
  * string names (the un-typed mount path). Interfaces without index
63
69
  * signatures still infer here — serialization happens at mount.
64
70
  */
65
- export type IslandAppProps<A> = A extends {
66
- imperative: (doc: never, props: infer P) => void;
67
- }
71
+ export type IslandAppProps<A> = A extends IslandContract<infer P, infer _E>
68
72
  ? P
69
- : A extends (props: infer P) => unknown
73
+ : A extends {
74
+ imperative: (doc: never, props: infer P) => void;
75
+ }
70
76
  ? P
71
- : Record<string, unknown>;
77
+ : A extends (props: infer P) => unknown
78
+ ? P
79
+ : Record<string, unknown>;
72
80
 
73
81
  /**
74
82
  * The structural shape of a mountable app — `never` params so ANY component,
@@ -80,4 +88,5 @@ export type IslandAppProps<A> = A extends {
80
88
  export type IslandAppLike =
81
89
  | ((props: never) => unknown)
82
90
  | { imperative: (doc: never, props: never) => void }
83
- | { mount: (ctx: never) => unknown };
91
+ | { mount: (ctx: never) => unknown }
92
+ | IslandContract;
@@ -0,0 +1,136 @@
1
+ /**
2
+ * Island contracts — the framework-free seam between a micro-frontend's
3
+ * worker and any shell.
4
+ *
5
+ * The contract module is the one file BOTH sides import: it names the
6
+ * registry app, declares the props/events wire shape as `Schema`s (the
7
+ * vendored `z` from `@atolljs/core`, consumer-supplied zod, or any
8
+ * `{ parse }` validator), and can carry the worker factory so
9
+ * `lazyIsland(() => import('./x.contract'))` is a self-contained split
10
+ * point. No framework types cross it — a React shell mounting an Angular
11
+ * app needs only this file.
12
+ *
13
+ * ```ts
14
+ * // checkout.contract.ts — published by the MFE, imported by both threads
15
+ * import { z } from '@atolljs/core';
16
+ * import { defineIslandContract } from '@atolljs/islands';
17
+ *
18
+ * export const checkout = defineIslandContract({
19
+ * app: 'checkout',
20
+ * props: z.object({ label: z.string().optional() }),
21
+ * events: { paid: z.object({ total: z.number() }) },
22
+ * worker: () => new Worker(new URL('./checkout.worker.ts', import.meta.url), { type: 'module' }),
23
+ * });
24
+ * ```
25
+ *
26
+ * Enforcement is worker-side: mount/updateProps parse props AFTER
27
+ * `unmarshalCallbackProps` restores `callbackProp` callables (declare those
28
+ * members `z.callback<Fn>()`), and `emit` parses payloads for declared
29
+ * event names. Undeclared event names pass through — a contract describes
30
+ * the wire, it doesn't fence it (a newer worker emitting an event an older
31
+ * shell doesn't know is normal evolution; a mismatched payload on a KNOWN
32
+ * event is the violation).
33
+ */
34
+ import type { Schema } from '@atolljs/core';
35
+
36
+ /** The brand property `defineIslandContract` stamps — a data property,
37
+ * like `islandAppName`, so it survives minification and `{...spread}`. */
38
+ const CONTRACT_MARK = 'islandContract';
39
+ /** The property `withContract` stamps on an app def — read at mount. */
40
+ const APP_CONTRACT = 'appContract';
41
+
42
+ /**
43
+ * A mountable app contract: the registry key, the prop/event wire schemas,
44
+ * and optionally the worker factory. `P`/`E` are the shell-facing types —
45
+ * they ride the contract as pure type information, so consumers of a
46
+ * framework-rendered island (an Angular component, a Vue SFC) get typed
47
+ * props and narrowed `onEvent` without ever importing that framework.
48
+ */
49
+ export interface IslandContract<
50
+ P = Record<string, unknown>,
51
+ E extends Record<string, unknown> = Record<string, unknown>,
52
+ > {
53
+ readonly islandContract: true;
54
+ /** Registry key — the name `mountIsland({ app })` resolves to. */
55
+ readonly app: string;
56
+ /** Prop wire schema — enforced worker-side at mount and updateProps. */
57
+ readonly props?: Schema<P>;
58
+ /** Declared emit vocabulary — declared names' payloads parse at emit(). */
59
+ readonly events?: { readonly [K in keyof E & string]: Schema<E[K]> };
60
+ /** The worker factory — optional; when present the contract object itself
61
+ * is a lazy contract module (`{ app, worker }`). */
62
+ readonly worker?: (() => Worker) | URL;
63
+ }
64
+
65
+ export function defineIslandContract<
66
+ P = Record<string, unknown>,
67
+ E extends Record<string, unknown> = Record<string, unknown>,
68
+ >(contract: {
69
+ app: string;
70
+ props?: Schema<P>;
71
+ events?: { [K in keyof E & string]: Schema<E[K]> };
72
+ worker?: (() => Worker) | URL;
73
+ }): IslandContract<P, E> {
74
+ return { ...contract, [CONTRACT_MARK]: true as const };
75
+ }
76
+
77
+ /** Structural contract check — the brand, not the shape, so a hand-rolled
78
+ * `{ app, props }` without `defineIslandContract` stays an app reference. */
79
+ export const isIslandContract = (value: unknown): value is IslandContract =>
80
+ typeof value === 'object' &&
81
+ value !== null &&
82
+ (value as Record<string, unknown>)[CONTRACT_MARK] === true;
83
+
84
+ /** The props a contract admits — `IslandContract<P>` → `P`. */
85
+ export type IslandContractProps<C> = C extends IslandContract<infer P, infer _E>
86
+ ? P
87
+ : Record<string, unknown>;
88
+
89
+ /** The event vocabulary a contract declares — name → payload map. */
90
+ export type IslandContractEvents<C> = C extends IslandContract<infer _P, infer E>
91
+ ? E
92
+ : Record<string, unknown>;
93
+
94
+ /**
95
+ * The `onEvent` signature a contract implies — `(name, payload)` with the
96
+ * payload narrowed per event name. Framework-free sibling of
97
+ * `@atolljs/angular-island`'s `IslandEventHandler<C>`.
98
+ */
99
+ export type IslandContractEventHandler<C> = <
100
+ K extends keyof IslandContractEvents<C> & string,
101
+ >(
102
+ name: K,
103
+ payload: IslandContractEvents<C>[K],
104
+ ) => void;
105
+
106
+ /**
107
+ * `withContract(contract, app)` — attach a contract to a registry app
108
+ * (imperative def or any framework adapter's `RenderedIslandApp`). The
109
+ * stamp rides the app object like `islandAppName`; the worker reads it at
110
+ * mount/updateProps for prop validation and hands it to `emit` for
111
+ * declared-payload checks.
112
+ *
113
+ * apps: {
114
+ * checkout: withContract(checkout, angularIslandApp(CheckoutComponent)),
115
+ * }
116
+ */
117
+ export function withContract<C extends IslandContract, A>(contract: C, app: A): A {
118
+ (app as Record<string, unknown>)[APP_CONTRACT] = contract;
119
+ return app;
120
+ }
121
+
122
+ /** Read the contract a `withContract`-stamped app carries (undefined when
123
+ * unstamped — most registry entries). */
124
+ export const contractOf = (app: unknown): IslandContract | undefined => {
125
+ if (typeof app !== 'object' && typeof app !== 'function') return undefined;
126
+ if (app === null) return undefined;
127
+ const c = (app as Record<string, unknown>)[APP_CONTRACT];
128
+ return isIslandContract(c) ? c : undefined;
129
+ };
130
+
131
+ /** The worker factory a contract-carrying `app` reference supplies —
132
+ * mount seams fall back to it when no `worker`/`client` was passed, so
133
+ * `<Island app={contract}/>` and `islandComponent(contract)` are fully
134
+ * self-contained. */
135
+ export const contractWorkerOf = (app: unknown): (() => Worker) | URL | undefined =>
136
+ isIslandContract(app) ? app.worker : undefined;
package/src/index.ts CHANGED
@@ -28,6 +28,20 @@ export type { DoorbellSpec } from './memory';
28
28
  export { islandApp, islandAppNameOf } from './app';
29
29
  export type { IslandAppLike, IslandAppProps } from './app';
30
30
 
31
+ export {
32
+ defineIslandContract,
33
+ isIslandContract,
34
+ withContract,
35
+ contractOf,
36
+ contractWorkerOf,
37
+ } from './contract';
38
+ export type {
39
+ IslandContract,
40
+ IslandContractEventHandler,
41
+ IslandContractEvents,
42
+ IslandContractProps,
43
+ } from './contract';
44
+
31
45
  export { callbackProp } from './callbackProps';
32
46
 
33
47
  export { isEventRef } from './ops';
package/src/island.ts CHANGED
@@ -14,7 +14,7 @@
14
14
  * is least-busy round-robin, so a second worker would receive dispatches for
15
15
  * a tree it doesn't hold (sticky routing is future work).
16
16
  */
17
- import { connectWorker, observe } from '@atolljs/core';
17
+ import { connectWorker, emitDevtools, observe } from '@atolljs/core';
18
18
  import type {
19
19
  ConnectWorkerConfig,
20
20
  SharedSpec,
@@ -121,6 +121,13 @@ interface MountIslandBaseOptions {
121
121
  * cross-origin isolation requirement. Switch later with `handle.setMode`.
122
122
  */
123
123
  mode?: Mode;
124
+ /**
125
+ * Optional renderer/framework tag — pure observability metadata forwarded
126
+ * to devtools on `island:mount` ('react', 'vue', 'svelte', …). Framework
127
+ * shell bindings can set it automatically; direct mountIsland callers
128
+ * declare it themselves.
129
+ */
130
+ framework?: string;
124
131
  /**
125
132
  * Milliseconds the mount handshake (mount + whoami) may take before the
126
133
  * returned promise rejects — default 15_000, `0` disables. A worker entry
@@ -336,7 +343,7 @@ const cssStyleValue = (key: string, value: unknown): string => {
336
343
  export async function mountIsland(opts: MountIslandOptions): Promise<IslandHandle> {
337
344
  const {
338
345
  el, onEvent, onActivity, onOps, slots, mode: initialMode, mountTimeout,
339
- client: givenClient, worker, app: givenApp, props: givenProps,
346
+ client: givenClient, worker, app: givenApp, props: givenProps, framework,
340
347
  ...workerConfig
341
348
  } = opts;
342
349
  // Shell→worker callables: callbackProp() markers marshal to {__cb:id}
@@ -442,9 +449,8 @@ export async function mountIsland(opts: MountIslandOptions): Promise<IslandHandl
442
449
  );
443
450
  // The whole point: an event = one postMessage round-trip. The worker
444
451
  // re-renders, we apply whatever ops come back.
445
- void client
446
- .dispatch(handlerId, payload)
447
- .then(applyOps)
452
+ void islandCall('dispatch', client.dispatch(handlerId, payload))
453
+ .then((ops) => applyOps(ops, 'dispatch'))
448
454
  .catch((err) => console.error(`[island ${instance}] dispatch failed`, err));
449
455
  };
450
456
  }
@@ -771,24 +777,53 @@ export async function mountIsland(opts: MountIslandOptions): Promise<IslandHandl
771
777
  break;
772
778
  }
773
779
  // Not a DOM mutation — the island→shell channel.
780
+ emitDevtools({ type: 'island:event', instance, name: op.name });
774
781
  onEvent?.(op.name, op.payload);
775
782
  break;
776
783
  }
777
784
  }
778
785
  }
779
786
 
780
- function applyOps(ops: Op[]): void {
787
+ function applyOps(ops: Op[], via: 'mount' | 'updateProps' | 'dispatch' | 'setSize' | 'flush'): void {
781
788
  opsApplied += ops.length;
782
789
  onActivity?.();
783
- const t0 = onOps !== undefined ? performance.now() : 0;
790
+ const t0 = performance.now();
784
791
  for (const op of ops) applyOp(op);
785
- onOps?.(ops, performance.now() - t0);
792
+ const replayMs = performance.now() - t0;
793
+ emitDevtools({ type: 'island:ops', instance, via, count: ops.length, replayMs });
794
+ onOps?.(ops, replayMs);
786
795
  }
787
796
 
797
+ /** Times a main→worker round-trip for the devtools inspector. */
798
+ const islandCall = <T>(
799
+ method: 'mount' | 'whoami' | 'dispatch' | 'setSize' | 'updateProps' | 'flush' | 'unmount',
800
+ p: Promise<T>,
801
+ ): Promise<T> => {
802
+ const t0 = performance.now();
803
+ return p.then(
804
+ (r) => {
805
+ emitDevtools({
806
+ type: 'island:task', instance, method,
807
+ ms: performance.now() - t0,
808
+ ops: Array.isArray(r) ? r.length : undefined,
809
+ });
810
+ return r;
811
+ },
812
+ (err) => {
813
+ emitDevtools({
814
+ type: 'island:task', instance, method,
815
+ ms: performance.now() - t0,
816
+ error: err instanceof Error ? err.message : String(err),
817
+ });
818
+ throw err;
819
+ },
820
+ );
821
+ };
822
+
788
823
  const doFlush = (): void => {
789
824
  if (destroyed) return;
790
825
  flushCalls++;
791
- void client.flush(instance).then(applyOps);
826
+ void islandCall('flush', client.flush(instance)).then((ops) => applyOps(ops, 'flush'));
792
827
  };
793
828
 
794
829
  /* ── Transport: push (shared-memory doorbell) vs poll ─────────────────── */
@@ -842,15 +877,16 @@ export async function mountIsland(opts: MountIslandOptions): Promise<IslandHandl
842
877
 
843
878
  let pid: string;
844
879
  try {
845
- applyOps(await raced(client.mount(instance, props)));
846
- pid = await raced(client.whoami(instance));
880
+ applyOps(await raced(islandCall('mount', client.mount(instance, props))), 'mount');
881
+ pid = await raced(islandCall('whoami', client.whoami(instance)));
882
+ emitDevtools({ type: 'island:mount', instance, app, pid, poolId: client.pool?.poolId, framework });
847
883
  } catch (err) {
848
884
  // The instance may exist worker-side with ops we never applied — release it.
849
885
  // An island-owned client also terminates (stops a crash→respawn loop on
850
886
  // a permanently-broken entry); a shared client just drops the instance.
851
887
  clientMounts.set(client, (clientMounts.get(client) ?? 1) - 1);
852
888
  if (givenClient === undefined) client.terminate();
853
- else void client.unmount(instance).catch(() => {});
889
+ else void islandCall('unmount', client.unmount(instance)).catch(() => {});
854
890
  throw err;
855
891
  }
856
892
 
@@ -879,7 +915,7 @@ export async function mountIsland(opts: MountIslandOptions): Promise<IslandHandl
879
915
  if (w === lastW && h === lastH) return;
880
916
  lastW = w;
881
917
  lastH = h;
882
- void client.setSize(instance, w, h).then(applyOps);
918
+ void islandCall('setSize', client.setSize(instance, w, h)).then((ops) => applyOps(ops, 'setSize'));
883
919
  };
884
920
  const resizeObserver =
885
921
  typeof ResizeObserver === 'function'
@@ -918,13 +954,14 @@ export async function mountIsland(opts: MountIslandOptions): Promise<IslandHandl
918
954
  updateProps: async (next: Record<string, unknown>) => {
919
955
  const marshalled = marshalCallbackProps(next, registerCb) as Record<string, unknown>;
920
956
  assertCloneableProps(marshalled, `updateProps(${app})`);
921
- applyOps(await client.updateProps(instance, marshalled));
957
+ applyOps(await islandCall('updateProps', client.updateProps(instance, marshalled)), 'updateProps');
922
958
  },
923
959
  flush: async () => {
924
- applyOps(await client.flush(instance));
960
+ applyOps(await islandCall('flush', client.flush(instance)), 'flush');
925
961
  },
926
962
  destroy: () => {
927
963
  destroyed = true;
964
+ emitDevtools({ type: 'island:unmount', instance });
928
965
  unsubscribe?.();
929
966
  resizeObserver?.disconnect();
930
967
  if (pollTimer !== null) clearInterval(pollTimer);
@@ -936,7 +973,7 @@ export async function mountIsland(opts: MountIslandOptions): Promise<IslandHandl
936
973
  // detach ops are irrelevant if our element is already gone, so the
937
974
  // return batch is dropped. Best-effort: the instance map and its
938
975
  // handlers release regardless.
939
- void client.unmount(instance).catch(() => {});
976
+ void islandCall('unmount', client.unmount(instance)).catch(() => {});
940
977
  } else {
941
978
  // Last island — still run the instance's teardown first so the app's
942
979
  // dispose hook cancels deferred work (library timers, animation
@@ -944,8 +981,7 @@ export async function mountIsland(opts: MountIslandOptions): Promise<IslandHandl
944
981
  // kill the thread before unmount lands — equally fine, process
945
982
  // death is teardown — but in-process workers need this or the
946
983
  // instance zombies on and its pending callbacks mutate foreign docs.
947
- void client
948
- .unmount(instance)
984
+ void islandCall('unmount', client.unmount(instance))
949
985
  .catch(() => {})
950
986
  .finally(() => {
951
987
  // A remount may have registered while teardown was in flight
@@ -58,6 +58,7 @@
58
58
  import { defineWorker } from '@atolljs/core';
59
59
  import type { SharedMemory, WorkerDefinition } from '@atolljs/core';
60
60
  import { islandAppNameOf } from '../app';
61
+ import { contractOf, type IslandContract } from '../contract';
61
62
  import { renderMemory, type DoorbellSpec } from '../memory';
62
63
  import { CALLBACK_EVENT, unmarshalCallbackProps } from '../callbackProps';
63
64
  import {
@@ -76,6 +77,7 @@ import {
76
77
  pushOp,
77
78
  runInInstance,
78
79
  setDoorbellContract,
80
+ setInstanceContract,
79
81
  setInstanceSize,
80
82
  takeOps,
81
83
  } from './instance';
@@ -198,6 +200,9 @@ interface InstanceBase {
198
200
  * distinct render instance (in production: a distinct worker). Stable across
199
201
  * remounts of the same instance key. */
200
202
  pid: string;
203
+ /** The `withContract`-stamped contract — drives props validation at
204
+ * mount/updateProps; mirrored into instanceContracts for `emit`. */
205
+ contract?: IslandContract;
201
206
  }
202
207
 
203
208
  interface ImperativeInstance extends InstanceBase {
@@ -286,17 +291,43 @@ function createIslandRuntime(
286
291
  // installDomShim, which builds on this.
287
292
  installInstanceDispatcher();
288
293
 
294
+ /**
295
+ * Contract enforcement — a `withContract`-stamped app's prop schema parses
296
+ * the unmarshalled props (callbackProp callables already restored, so
297
+ * `z.callback()` members see functions). parse() also strips undeclared
298
+ * keys, making the contract the authoritative wire shape. Throws —
299
+ * mount/updateProps reject, which mountIsland surfaces as a named error.
300
+ */
301
+ const parseContractProps = (
302
+ contract: IslandContract | undefined,
303
+ key: string,
304
+ props: Record<string, unknown>,
305
+ ): Record<string, unknown> => {
306
+ if (contract?.props === undefined) return props;
307
+ try {
308
+ return contract.props.parse(props) as Record<string, unknown>;
309
+ } catch (err) {
310
+ throw new Error(
311
+ `[island "${contract.app}"] props for "${key}" rejected by contract: ` +
312
+ (err instanceof Error ? err.message : String(err)),
313
+ );
314
+ }
315
+ };
316
+
289
317
  // Container creation is per-instance, not module-level, so a second mount()
290
318
  // can't collide with the first instance's tree. Imperative mounts skip the
291
319
  // renderer entirely — their "host environment" is the proxy DOM, and
292
320
  // build() emits ops itself.
293
321
  function createInstance(key: string, pid: string): Instance {
294
322
  const app = resolveApp(appNameOf(key));
323
+ const contract = contractOf(app);
324
+ setInstanceContract(key, contract);
295
325
  if (isRendered(app)) {
296
326
  return {
297
327
  key,
298
328
  app: appNameOf(key),
299
329
  pid,
330
+ contract,
300
331
  rendered: { app, handle: undefined, props: {} },
301
332
  };
302
333
  }
@@ -305,6 +336,7 @@ function createIslandRuntime(
305
336
  key,
306
337
  app: appNameOf(key),
307
338
  pid,
339
+ contract,
308
340
  imperative: {
309
341
  build: app.imperative,
310
342
  dispose: app.dispose,
@@ -397,6 +429,7 @@ function createIslandRuntime(
397
429
  `mount: unknown app "${instance}" — registry has: ${[...APP_REGISTRY.keys()].join(', ')}`,
398
430
  );
399
431
  }
432
+ props = parseContractProps(contractOf(App), instance, props);
400
433
 
401
434
  const mounted = mounts.get(instance);
402
435
  // Remount — same clear+rebuild semantics as updateProps; the instance
@@ -445,6 +478,7 @@ function createIslandRuntime(
445
478
  if (mounted === undefined) {
446
479
  throw new Error(`updateProps: "${instance}" is not mounted in this worker — mount() first`);
447
480
  }
481
+ props = parseContractProps(mounted.contract, instance, props);
448
482
  // Imperative mounts have no diffing — updateProps REBUILDS: clear the
449
483
  // root and re-run build(props) on a fresh proxy document. Documented
450
484
  // as the honest semantics; fine for widgets, not for huge trees.
@@ -540,6 +574,7 @@ function createIslandRuntime(
540
574
  const mounted = mounts.get(instance);
541
575
  if (mounted === undefined) return [];
542
576
  mounts.delete(instance);
577
+ setInstanceContract(instance, undefined);
543
578
  return runInInstance(instance, () => {
544
579
  if (isImperativeInstance(mounted)) {
545
580
  // The app's dispose hook cancels deferred work (library timers,
@@ -27,6 +27,14 @@ export type {
27
27
 
28
28
  export { islandApp, islandAppNameOf } from '../app';
29
29
 
30
+ export { defineIslandContract, isIslandContract, withContract, contractOf } from '../contract';
31
+ export type {
32
+ IslandContract,
33
+ IslandContractEventHandler,
34
+ IslandContractEvents,
35
+ IslandContractProps,
36
+ } from '../contract';
37
+
30
38
  export {
31
39
  allocId,
32
40
  bumpOpsVersion,
@@ -15,6 +15,7 @@
15
15
 
16
16
  import { renderMemory } from '../memory';
17
17
  import { enterProxyFrame, exitProxyFrame, proxyMetrics } from '../metrics';
18
+ import type { IslandContract } from '../contract';
18
19
  import type { Op, WireProps } from '../ops';
19
20
 
20
21
  /**
@@ -95,6 +96,21 @@ const handlers = new Map<number, HandlerEntry>();
95
96
  let nextId = 1;
96
97
  let nextHandlerId = 1;
97
98
 
99
+ /**
100
+ * instance key → the `withContract`-attached contract (undefined for
101
+ * unstamped apps — most mounts). `emit` validates declared event payloads
102
+ * against it; defineWorkers registers it at instance create and clears it
103
+ * at unmount.
104
+ */
105
+ const instanceContracts = new Map<string, IslandContract>();
106
+ export const setInstanceContract = (
107
+ instance: string,
108
+ contract: IslandContract | undefined,
109
+ ): void => {
110
+ if (contract === undefined) instanceContracts.delete(instance);
111
+ else instanceContracts.set(instance, contract);
112
+ };
113
+
98
114
  /** Allocate an instance id — the shared counter React and the proxy DOM both draw from. */
99
115
  export const allocId = (): number => nextId++;
100
116
 
@@ -285,6 +301,20 @@ export const unregisterHandler = (id: number): void => {
285
301
  */
286
302
  export const emit = (name: string, payload?: unknown): void => {
287
303
  timed(() => {
304
+ // Contract enforcement: a declared event's payload parses before it
305
+ // crosses — undeclared names pass through (the contract describes the
306
+ // wire; it doesn't fence forward-compatible additions).
307
+ const schema = instanceContracts.get(activeInstance)?.events?.[name];
308
+ if (schema !== undefined) {
309
+ try {
310
+ payload = schema.parse(payload);
311
+ } catch (err) {
312
+ throw new Error(
313
+ `emit("${name}") rejected by the island contract for "${activeInstance}": ` +
314
+ (err instanceof Error ? err.message : String(err)),
315
+ );
316
+ }
317
+ }
288
318
  pushOp(activeInstance, { t: 'emit', name, payload });
289
319
  });
290
320
  };