@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 +2 -2
- package/src/app.ts +15 -6
- package/src/contract.ts +136 -0
- package/src/index.ts +14 -0
- package/src/island.ts +54 -18
- package/src/worker/defineWorkers.ts +35 -0
- package/src/worker/index.ts +8 -0
- package/src/worker/instance.ts +30 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@atolljs/islands",
|
|
3
|
-
"version": "0.1.
|
|
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.
|
|
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
|
|
73
|
+
: A extends {
|
|
74
|
+
imperative: (doc: never, props: infer P) => void;
|
|
75
|
+
}
|
|
70
76
|
? P
|
|
71
|
-
:
|
|
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;
|
package/src/contract.ts
ADDED
|
@@ -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
|
-
.
|
|
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 =
|
|
790
|
+
const t0 = performance.now();
|
|
784
791
|
for (const op of ops) applyOp(op);
|
|
785
|
-
|
|
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,
|
package/src/worker/index.ts
CHANGED
|
@@ -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,
|
package/src/worker/instance.ts
CHANGED
|
@@ -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
|
};
|