@atolljs/islands 0.1.13 → 0.1.15
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/dist/boundary.d.ts +57 -0
- package/dist/{chunk-TX4FAHRB.js → chunk-P3G2GL3T.js} +171 -0
- package/dist/index.d.ts +2 -0
- package/dist/index.js +3 -1
- package/dist/island.d.ts +13 -0
- package/dist/worker/defineWorkers.d.ts +17 -0
- package/dist/worker/index.d.ts +1 -1
- package/dist/worker/index.js +18 -7
- package/package.json +2 -2
- package/src/boundary.ts +304 -0
- package/src/index.ts +11 -0
- package/src/island.ts +22 -0
- package/src/worker/defineWorkers.ts +35 -5
- package/src/worker/index.ts +1 -1
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
import type { IslandHandle, MountIslandOptions } from './island.js';
|
|
2
|
+
/** What a boundary render fn may return — applied via el.replaceChildren/append. */
|
|
3
|
+
export type BoundaryContent = Node | string | Array<Node | string> | null | undefined;
|
|
4
|
+
/** Where the failure happened — 'mount' (handshake), 'runtime' (worker crash). */
|
|
5
|
+
export type IslandBoundaryPhase = 'mount' | 'runtime';
|
|
6
|
+
export interface IslandBoundaryContext {
|
|
7
|
+
/** The mount's app key. */
|
|
8
|
+
app: string;
|
|
9
|
+
/** The island's container element — the boundary's render target. */
|
|
10
|
+
el: HTMLElement;
|
|
11
|
+
/** 1-based mount attempt counter — increments across retries. */
|
|
12
|
+
attempt: number;
|
|
13
|
+
}
|
|
14
|
+
export interface IslandBoundaryErrorContext extends IslandBoundaryContext {
|
|
15
|
+
/** The caught failure (mount rejection, crash error). */
|
|
16
|
+
error: unknown;
|
|
17
|
+
phase: IslandBoundaryPhase;
|
|
18
|
+
/** Manual remount — wire it to a retry button in the error render. */
|
|
19
|
+
retry(): void;
|
|
20
|
+
}
|
|
21
|
+
export interface IslandBoundary {
|
|
22
|
+
/** Rendered while the first mount attempt is in flight. */
|
|
23
|
+
loading?(ctx: IslandBoundaryContext): BoundaryContent;
|
|
24
|
+
/** Rendered when the island fails — mount rejection or runtime crash. */
|
|
25
|
+
error?(ctx: IslandBoundaryErrorContext): BoundaryContent;
|
|
26
|
+
/** Fires on every boundary-entering failure — logging/telemetry. */
|
|
27
|
+
onError?(error: unknown, phase: IslandBoundaryPhase): void;
|
|
28
|
+
/**
|
|
29
|
+
* Auto-remount policy: a number is the max retry count (delay grows
|
|
30
|
+
* ~250ms per attempt, capped at 2s); a function maps (attempt, error)
|
|
31
|
+
* to a delay in ms, `false`/`undefined` to stop. Omit for no auto-retry.
|
|
32
|
+
*/
|
|
33
|
+
retry?: number | ((attempt: number, error: unknown) => number | false | undefined | void);
|
|
34
|
+
/**
|
|
35
|
+
* What the error state does to the container: 'clear' (default) replaces
|
|
36
|
+
* its contents with the fallback; 'preserve' appends the fallback over
|
|
37
|
+
* the stale last frame on runtime crashes (mount failures always clear —
|
|
38
|
+
* there is no frame to preserve).
|
|
39
|
+
*/
|
|
40
|
+
domOnError?: 'clear' | 'preserve';
|
|
41
|
+
}
|
|
42
|
+
export type IslandBoundaryState = 'loading' | 'mounted' | 'error';
|
|
43
|
+
/**
|
|
44
|
+
* The boundary-aware handle: a normal IslandHandle when mounted, safe
|
|
45
|
+
* no-ops while failed (updateProps stashes into the next attempt's props),
|
|
46
|
+
* plus the boundary's own state.
|
|
47
|
+
*/
|
|
48
|
+
export interface IslandBoundaryHandle extends IslandHandle {
|
|
49
|
+
readonly state: IslandBoundaryState;
|
|
50
|
+
/** The error that put the boundary in 'error' state (undefined otherwise). */
|
|
51
|
+
readonly error: unknown;
|
|
52
|
+
/** Remount now — same as ctx.retry() handed to the error render fn. */
|
|
53
|
+
retry(): void;
|
|
54
|
+
}
|
|
55
|
+
export declare function mountIslandWithBoundary(opts: MountIslandOptions & {
|
|
56
|
+
boundary: IslandBoundary;
|
|
57
|
+
}): Promise<IslandBoundaryHandle>;
|
|
@@ -450,6 +450,7 @@ async function mountIsland(opts) {
|
|
|
450
450
|
app: givenApp,
|
|
451
451
|
props: givenProps,
|
|
452
452
|
framework,
|
|
453
|
+
boundary,
|
|
453
454
|
...workerConfig
|
|
454
455
|
} = opts;
|
|
455
456
|
if (isProxyMountTarget(target)) {
|
|
@@ -461,6 +462,9 @@ async function mountIsland(opts) {
|
|
|
461
462
|
return subMounter(opts);
|
|
462
463
|
}
|
|
463
464
|
const el = target;
|
|
465
|
+
if (boundary !== void 0) {
|
|
466
|
+
return mountIslandWithBoundary(opts);
|
|
467
|
+
}
|
|
464
468
|
const cbTable = /* @__PURE__ */ new Map();
|
|
465
469
|
let cbSeq = 0;
|
|
466
470
|
const registerCb = (fn) => {
|
|
@@ -1118,6 +1122,172 @@ async function mountIsland(opts) {
|
|
|
1118
1122
|
return handle;
|
|
1119
1123
|
}
|
|
1120
1124
|
|
|
1125
|
+
// src/boundary.ts
|
|
1126
|
+
async function mountIslandWithBoundary(opts) {
|
|
1127
|
+
const {
|
|
1128
|
+
boundary,
|
|
1129
|
+
el: target,
|
|
1130
|
+
client: givenClient,
|
|
1131
|
+
worker,
|
|
1132
|
+
app: givenApp,
|
|
1133
|
+
props: givenProps,
|
|
1134
|
+
mode: initialMode,
|
|
1135
|
+
...mountOpts
|
|
1136
|
+
} = opts;
|
|
1137
|
+
const client = givenClient ?? connectIslandWorker({
|
|
1138
|
+
name: givenApp,
|
|
1139
|
+
...mountOpts,
|
|
1140
|
+
worker,
|
|
1141
|
+
doorbell: initialMode !== "poll"
|
|
1142
|
+
});
|
|
1143
|
+
const app = givenApp ?? "main";
|
|
1144
|
+
const el = target;
|
|
1145
|
+
let state = "loading";
|
|
1146
|
+
let lastError;
|
|
1147
|
+
let current = null;
|
|
1148
|
+
let attempt = 0;
|
|
1149
|
+
let destroyed = false;
|
|
1150
|
+
let retryTimer = null;
|
|
1151
|
+
let pendingProps = givenProps;
|
|
1152
|
+
let pendingMode = initialMode;
|
|
1153
|
+
const setContent = (content, how) => {
|
|
1154
|
+
if (content === null || content === void 0) {
|
|
1155
|
+
if (how === "replace") el.replaceChildren();
|
|
1156
|
+
return;
|
|
1157
|
+
}
|
|
1158
|
+
const parts = (Array.isArray(content) ? content : [content]).filter(
|
|
1159
|
+
(p) => p !== null && p !== void 0
|
|
1160
|
+
);
|
|
1161
|
+
if (how === "replace") el.replaceChildren(...parts);
|
|
1162
|
+
else el.append(...parts);
|
|
1163
|
+
};
|
|
1164
|
+
const scheduleRetry = (error) => {
|
|
1165
|
+
const policy = boundary.retry;
|
|
1166
|
+
if (policy === void 0 || retryTimer !== null) return;
|
|
1167
|
+
const delay = typeof policy === "number" ? attempt <= policy ? Math.min(250 * attempt, 2e3) : false : policy(attempt, error);
|
|
1168
|
+
if (delay === false || delay === void 0 || typeof delay !== "number") return;
|
|
1169
|
+
retryTimer = setTimeout(() => {
|
|
1170
|
+
retryTimer = null;
|
|
1171
|
+
void tryMount();
|
|
1172
|
+
}, Math.max(0, delay));
|
|
1173
|
+
};
|
|
1174
|
+
const fail = (error, phase) => {
|
|
1175
|
+
if (destroyed) return;
|
|
1176
|
+
state = "error";
|
|
1177
|
+
lastError = error;
|
|
1178
|
+
boundary.onError?.(error, phase);
|
|
1179
|
+
const content = boundary.error?.({
|
|
1180
|
+
app,
|
|
1181
|
+
el,
|
|
1182
|
+
attempt,
|
|
1183
|
+
error,
|
|
1184
|
+
phase,
|
|
1185
|
+
retry: manualRetry
|
|
1186
|
+
});
|
|
1187
|
+
if (boundary.domOnError === "preserve" && phase === "runtime") {
|
|
1188
|
+
setContent(content, "append");
|
|
1189
|
+
} else {
|
|
1190
|
+
setContent(content, "replace");
|
|
1191
|
+
}
|
|
1192
|
+
scheduleRetry(error);
|
|
1193
|
+
};
|
|
1194
|
+
const tryMount = async () => {
|
|
1195
|
+
if (destroyed) return;
|
|
1196
|
+
attempt++;
|
|
1197
|
+
el.replaceChildren();
|
|
1198
|
+
try {
|
|
1199
|
+
const handle2 = await mountIsland({
|
|
1200
|
+
...mountOpts,
|
|
1201
|
+
client,
|
|
1202
|
+
el,
|
|
1203
|
+
app: givenApp,
|
|
1204
|
+
props: pendingProps,
|
|
1205
|
+
mode: pendingMode
|
|
1206
|
+
});
|
|
1207
|
+
if (destroyed) {
|
|
1208
|
+
handle2.destroy();
|
|
1209
|
+
return;
|
|
1210
|
+
}
|
|
1211
|
+
current = handle2;
|
|
1212
|
+
state = "mounted";
|
|
1213
|
+
} catch (err) {
|
|
1214
|
+
fail(err, "mount");
|
|
1215
|
+
}
|
|
1216
|
+
};
|
|
1217
|
+
const manualRetry = () => {
|
|
1218
|
+
if (destroyed) return;
|
|
1219
|
+
if (retryTimer !== null) {
|
|
1220
|
+
clearTimeout(retryTimer);
|
|
1221
|
+
retryTimer = null;
|
|
1222
|
+
}
|
|
1223
|
+
void tryMount();
|
|
1224
|
+
};
|
|
1225
|
+
const offLifecycle = client.onWorkerEvent?.((event) => {
|
|
1226
|
+
if (destroyed || state === "error" || event.type !== "error") return;
|
|
1227
|
+
const stale = current;
|
|
1228
|
+
current = null;
|
|
1229
|
+
stale?.destroy();
|
|
1230
|
+
fail(
|
|
1231
|
+
new Error(
|
|
1232
|
+
`[island ${app}] worker crashed` + (event.message !== void 0 ? `: ${event.message}` : "")
|
|
1233
|
+
),
|
|
1234
|
+
"runtime"
|
|
1235
|
+
);
|
|
1236
|
+
});
|
|
1237
|
+
const handle = {
|
|
1238
|
+
get app() {
|
|
1239
|
+
return current?.app ?? app;
|
|
1240
|
+
},
|
|
1241
|
+
get pid() {
|
|
1242
|
+
return current?.pid ?? "unmounted";
|
|
1243
|
+
},
|
|
1244
|
+
get instance() {
|
|
1245
|
+
return current?.instance ?? `${app}@?`;
|
|
1246
|
+
},
|
|
1247
|
+
get mode() {
|
|
1248
|
+
return current?.mode ?? pendingMode ?? "push";
|
|
1249
|
+
},
|
|
1250
|
+
get opsApplied() {
|
|
1251
|
+
return current?.opsApplied ?? 0;
|
|
1252
|
+
},
|
|
1253
|
+
get flushCalls() {
|
|
1254
|
+
return current?.flushCalls ?? 0;
|
|
1255
|
+
},
|
|
1256
|
+
get state() {
|
|
1257
|
+
return state;
|
|
1258
|
+
},
|
|
1259
|
+
get error() {
|
|
1260
|
+
return lastError;
|
|
1261
|
+
},
|
|
1262
|
+
retry: manualRetry,
|
|
1263
|
+
setMode: (mode) => {
|
|
1264
|
+
pendingMode = mode;
|
|
1265
|
+
current?.setMode(mode);
|
|
1266
|
+
},
|
|
1267
|
+
updateProps: async (next) => {
|
|
1268
|
+
pendingProps = next;
|
|
1269
|
+
await current?.updateProps(next);
|
|
1270
|
+
},
|
|
1271
|
+
flush: async () => {
|
|
1272
|
+
await current?.flush();
|
|
1273
|
+
},
|
|
1274
|
+
destroy: () => {
|
|
1275
|
+
if (destroyed) return;
|
|
1276
|
+
destroyed = true;
|
|
1277
|
+
if (retryTimer !== null) {
|
|
1278
|
+
clearTimeout(retryTimer);
|
|
1279
|
+
retryTimer = null;
|
|
1280
|
+
}
|
|
1281
|
+
offLifecycle?.();
|
|
1282
|
+
current?.destroy();
|
|
1283
|
+
current = null;
|
|
1284
|
+
}
|
|
1285
|
+
};
|
|
1286
|
+
setContent(boundary.loading?.({ app, el, attempt: 1 }), "replace");
|
|
1287
|
+
await tryMount();
|
|
1288
|
+
return handle;
|
|
1289
|
+
}
|
|
1290
|
+
|
|
1121
1291
|
// src/contract.ts
|
|
1122
1292
|
var CONTRACT_MARK = "islandContract";
|
|
1123
1293
|
var APP_CONTRACT = "appContract";
|
|
@@ -1171,6 +1341,7 @@ export {
|
|
|
1171
1341
|
marshalCallbackProps,
|
|
1172
1342
|
unmarshalCallbackProps,
|
|
1173
1343
|
propsPreview,
|
|
1344
|
+
mountIslandWithBoundary,
|
|
1174
1345
|
isKeepAliveClient,
|
|
1175
1346
|
connectIslandWorker,
|
|
1176
1347
|
assertCloneableProps,
|
package/dist/index.d.ts
CHANGED
|
@@ -13,6 +13,8 @@
|
|
|
13
13
|
*/
|
|
14
14
|
export { connectIslandWorker, isKeepAliveClient, mountIsland } from './island.js';
|
|
15
15
|
export type { ConnectIslandWorkerConfig, IslandClient, IslandHandle, IslandWorkerDefinition, IslandWorkerOptions, Mode, MountIslandOptions, } from './island.js';
|
|
16
|
+
export { mountIslandWithBoundary } from './boundary.js';
|
|
17
|
+
export type { BoundaryContent, IslandBoundary, IslandBoundaryContext, IslandBoundaryErrorContext, IslandBoundaryHandle, IslandBoundaryPhase, IslandBoundaryState, } from './boundary.js';
|
|
16
18
|
export { makeDoorbell } from './memory.js';
|
|
17
19
|
export type { DoorbellSpec } from './memory.js';
|
|
18
20
|
export { islandApp, islandAppNameOf } from './app.js';
|
package/dist/index.js
CHANGED
|
@@ -11,8 +11,9 @@ import {
|
|
|
11
11
|
islandAppNameOf,
|
|
12
12
|
makeDoorbell,
|
|
13
13
|
mountIsland,
|
|
14
|
+
mountIslandWithBoundary,
|
|
14
15
|
withContract
|
|
15
|
-
} from "./chunk-
|
|
16
|
+
} from "./chunk-P3G2GL3T.js";
|
|
16
17
|
export {
|
|
17
18
|
callbackProp,
|
|
18
19
|
connectIslandWorker,
|
|
@@ -26,5 +27,6 @@ export {
|
|
|
26
27
|
islandAppNameOf,
|
|
27
28
|
makeDoorbell,
|
|
28
29
|
mountIsland,
|
|
30
|
+
mountIslandWithBoundary,
|
|
29
31
|
withContract
|
|
30
32
|
};
|
package/dist/island.d.ts
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import type { ConnectWorkerConfig, SharedSpec, WorkerClient, WorkerDefinition } from '@atolljs/core';
|
|
2
2
|
import { type DoorbellSpec } from './memory.js';
|
|
3
3
|
import { type IslandWorkerMethods, type Op } from './ops.js';
|
|
4
|
+
import type { IslandBoundary, IslandBoundaryHandle } from './boundary.js';
|
|
4
5
|
import type { ProxyElement } from './worker/dom/element.js';
|
|
5
6
|
export type Mode = 'push' | 'poll';
|
|
6
7
|
/** The worker definition every `definePolyWorker` call produces. */
|
|
@@ -117,6 +118,15 @@ interface MountIslandBaseOptions {
|
|
|
117
118
|
* client warm for this island's teardown.
|
|
118
119
|
*/
|
|
119
120
|
keepAlive?: boolean;
|
|
121
|
+
/**
|
|
122
|
+
* Error boundary — `loading`/`error` state render functions plus a retry
|
|
123
|
+
* policy, covering mount rejection, mountTimeout, and runtime worker
|
|
124
|
+
* crashes (detected via the client's lifecycle events). When set, the
|
|
125
|
+
* mount resolves to an `IslandBoundaryHandle` after the first attempt
|
|
126
|
+
* settles — errors render the fallback instead of rejecting. Ignored on
|
|
127
|
+
* nested (proxy-element) mounts. See boundary.ts / docs error-boundaries.
|
|
128
|
+
*/
|
|
129
|
+
boundary?: IslandBoundary;
|
|
120
130
|
}
|
|
121
131
|
/**
|
|
122
132
|
* The mount needs one worker-side endpoint: either a `client` a
|
|
@@ -171,5 +181,8 @@ export declare const assertCloneableProps: (props: Record<string, unknown>, cont
|
|
|
171
181
|
* unit semantics when replaying style values into proxy elements. */
|
|
172
182
|
export declare const cssStyleValue: (key: string, value: unknown) => string;
|
|
173
183
|
export declare const registerSubIslandMounter: (fn: (opts: MountIslandOptions) => Promise<IslandHandle>) => void;
|
|
184
|
+
export declare function mountIsland(opts: MountIslandOptions & {
|
|
185
|
+
boundary: IslandBoundary;
|
|
186
|
+
}): Promise<IslandBoundaryHandle>;
|
|
174
187
|
export declare function mountIsland(opts: MountIslandOptions): Promise<IslandHandle>;
|
|
175
188
|
export {};
|
|
@@ -142,6 +142,16 @@ export interface PolyWorkerRegistry {
|
|
|
142
142
|
/** Name → island app registry. The shell's `mountIsland({ app: name })`
|
|
143
143
|
* picks one per island. */
|
|
144
144
|
apps: Record<string, IslandApp>;
|
|
145
|
+
/**
|
|
146
|
+
* Lazy app loader — consulted by `mount` when the requested registry name
|
|
147
|
+
* isn't present yet. Returning an app registers it under that name (so a
|
|
148
|
+
* second mount is a registry hit); returning `undefined` lets the mount
|
|
149
|
+
* fail as unknown. Federation hosts pass a remote-module importer here —
|
|
150
|
+
* see `@atolljs/federation`. When set, the single-registered-app
|
|
151
|
+
* name-blind fallback is DISABLED: a loader host resolves every mount by
|
|
152
|
+
* its exact registry key, never by "the only app I have".
|
|
153
|
+
*/
|
|
154
|
+
loadApp?: (name: string) => IslandApp | undefined | Promise<IslandApp | undefined>;
|
|
145
155
|
/**
|
|
146
156
|
* The doorbell contract the worker exposes — defaults to the package's
|
|
147
157
|
* `renderMemory`. Rarely needed: pass a different doorbell-SPEC instance
|
|
@@ -185,6 +195,13 @@ interface RenderedInstance extends InstanceBase {
|
|
|
185
195
|
}
|
|
186
196
|
/** A mounted instance — one per island worker in production. */
|
|
187
197
|
export type Instance = ImperativeInstance | RenderedInstance;
|
|
198
|
+
/**
|
|
199
|
+
* Register an app into the module-level registry after the definer ran —
|
|
200
|
+
* the manual counterpart to `loadApp` (warm-up/preload outside a mount).
|
|
201
|
+
* Registers under `name` exactly; the mount-time name-blind fallback only
|
|
202
|
+
* applies to single-registry workers without a loader.
|
|
203
|
+
*/
|
|
204
|
+
export declare const registerIslandApp: (name: string, app: IslandApp) => void;
|
|
188
205
|
/**
|
|
189
206
|
* PolyWorker — one worker script serving a REGISTRY of islands:
|
|
190
207
|
* `mountIsland({ app: name })` picks one per island, and every island in
|
package/dist/worker/index.d.ts
CHANGED
|
@@ -14,7 +14,7 @@
|
|
|
14
14
|
* Framework-neutral by construction: this entry imports no framework — React's
|
|
15
15
|
* reconciler adapter lives in `@atolljs/react-island/worker`.
|
|
16
16
|
*/
|
|
17
|
-
export { definePolyWorker, defineMonoWorker } from './defineWorkers.js';
|
|
17
|
+
export { definePolyWorker, defineMonoWorker, registerIslandApp } from './defineWorkers.js';
|
|
18
18
|
export type { ImperativeIslandApp, Instance, IslandApp, PolyWorkerRegistry, RenderContext, RenderedHandle, RenderedIslandApp, } from './defineWorkers.js';
|
|
19
19
|
export { islandApp, islandAppNameOf } from '../app.js';
|
|
20
20
|
export { defineIslandContract, isIslandContract, withContract, contractOf } from '../contract.js';
|
package/dist/worker/index.js
CHANGED
|
@@ -17,7 +17,7 @@ import {
|
|
|
17
17
|
renderMemory,
|
|
18
18
|
unmarshalCallbackProps,
|
|
19
19
|
withContract
|
|
20
|
-
} from "../chunk-
|
|
20
|
+
} from "../chunk-P3G2GL3T.js";
|
|
21
21
|
|
|
22
22
|
// src/worker/defineWorkers.ts
|
|
23
23
|
import { defineWorker } from "@atolljs/core";
|
|
@@ -2321,7 +2321,7 @@ var isImperativeInstance = (r) => r.imperative !== void 0;
|
|
|
2321
2321
|
var isRenderedInstance = (r) => r.rendered !== void 0;
|
|
2322
2322
|
var mounts = /* @__PURE__ */ new Map();
|
|
2323
2323
|
var APP_REGISTRY = /* @__PURE__ */ new Map();
|
|
2324
|
-
var
|
|
2324
|
+
var registerIslandApp = (name, app) => {
|
|
2325
2325
|
APP_REGISTRY.set(name, app);
|
|
2326
2326
|
};
|
|
2327
2327
|
var resolveApp = (name) => APP_REGISTRY.get(name) ?? (APP_REGISTRY.size === 1 ? APP_REGISTRY.values().next().value : void 0);
|
|
@@ -2332,7 +2332,7 @@ var appNameOf = (instance) => {
|
|
|
2332
2332
|
const tilde = name.lastIndexOf("~");
|
|
2333
2333
|
return tilde === -1 ? name : name.slice(tilde + 1);
|
|
2334
2334
|
};
|
|
2335
|
-
function createIslandRuntime(sharedMemory = renderMemory) {
|
|
2335
|
+
function createIslandRuntime(sharedMemory = renderMemory, loadApp) {
|
|
2336
2336
|
setDoorbellContract(sharedMemory);
|
|
2337
2337
|
installInstanceDispatcher();
|
|
2338
2338
|
const parseContractProps = (contract, key, props) => {
|
|
@@ -2423,7 +2423,17 @@ function createIslandRuntime(sharedMemory = renderMemory) {
|
|
|
2423
2423
|
*/
|
|
2424
2424
|
async mount(instance, props = {}) {
|
|
2425
2425
|
props = unmarshalCallbackProps(props, callbackFactory(instance));
|
|
2426
|
-
const
|
|
2426
|
+
const appName = appNameOf(instance);
|
|
2427
|
+
let App = APP_REGISTRY.get(appName);
|
|
2428
|
+
if (App === void 0 && loadApp !== void 0) {
|
|
2429
|
+
const loaded = await loadApp(appName);
|
|
2430
|
+
if (loaded !== void 0) {
|
|
2431
|
+
registerIslandApp(appName, loaded);
|
|
2432
|
+
App = loaded;
|
|
2433
|
+
}
|
|
2434
|
+
} else if (App === void 0) {
|
|
2435
|
+
App = resolveApp(appName);
|
|
2436
|
+
}
|
|
2427
2437
|
if (App === void 0) {
|
|
2428
2438
|
throw new Error(
|
|
2429
2439
|
`mount: unknown app "${instance}" \u2014 registry has: ${[...APP_REGISTRY.keys()].join(", ")}`
|
|
@@ -2603,13 +2613,13 @@ function definePolyWorker(registry, options) {
|
|
|
2603
2613
|
`[definePolyWorker] app registered as "${key}" but stamped "${stamped}" \u2014 component-reference mounts resolve "${stamped}" and will fail. Fix the key or the stamp.`
|
|
2604
2614
|
);
|
|
2605
2615
|
}
|
|
2606
|
-
|
|
2616
|
+
registerIslandApp(key, app);
|
|
2607
2617
|
}
|
|
2608
|
-
return createIslandRuntime(registry.sharedMemory ?? options?.sharedMemory);
|
|
2618
|
+
return createIslandRuntime(registry.sharedMemory ?? options?.sharedMemory, registry.loadApp);
|
|
2609
2619
|
}
|
|
2610
2620
|
function defineMonoWorker(app, options) {
|
|
2611
2621
|
const stamp = app.islandAppName;
|
|
2612
|
-
|
|
2622
|
+
registerIslandApp(typeof stamp === "string" && stamp !== "" ? stamp : "main", app);
|
|
2613
2623
|
return createIslandRuntime(options?.sharedMemory);
|
|
2614
2624
|
}
|
|
2615
2625
|
|
|
@@ -3147,6 +3157,7 @@ export {
|
|
|
3147
3157
|
proxyInstanceStats,
|
|
3148
3158
|
pushOp,
|
|
3149
3159
|
registerHandler,
|
|
3160
|
+
registerIslandApp,
|
|
3150
3161
|
renderMemory,
|
|
3151
3162
|
runInInstance,
|
|
3152
3163
|
serializeProps,
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@atolljs/islands",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.15",
|
|
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",
|
|
@@ -29,7 +29,7 @@
|
|
|
29
29
|
"htmlparser2": "^12.0.0"
|
|
30
30
|
},
|
|
31
31
|
"peerDependencies": {
|
|
32
|
-
"@atolljs/core": "0.1.
|
|
32
|
+
"@atolljs/core": "^0.1.15"
|
|
33
33
|
},
|
|
34
34
|
"repository": {
|
|
35
35
|
"type": "git",
|
package/src/boundary.ts
ADDED
|
@@ -0,0 +1,304 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Island error boundaries — a configurable safety net around every
|
|
3
|
+
* breakable point in a mount's lifecycle:
|
|
4
|
+
*
|
|
5
|
+
* mountIsland({
|
|
6
|
+
* worker, el, app,
|
|
7
|
+
* boundary: {
|
|
8
|
+
* loading: () => spinnerEl, // render fn per state
|
|
9
|
+
* error: ({ error, retry }) => errorEl, // …incl. the retry hook
|
|
10
|
+
* retry: 3, // auto-remount policy
|
|
11
|
+
* onError: (err, phase) => report(err, phase), // telemetry hook
|
|
12
|
+
* },
|
|
13
|
+
* })
|
|
14
|
+
*
|
|
15
|
+
* Breakable points covered:
|
|
16
|
+
* - MOUNT: worker entry fails to load, mountTimeout, contract prop
|
|
17
|
+
* rejection, federation `loadApp` failures (bad remote URL, unsatisfied
|
|
18
|
+
* `requires`) — the mount handshake rejects, the boundary renders the
|
|
19
|
+
* error state instead of propagating an unhandled rejection.
|
|
20
|
+
* - RUNTIME: the worker crashes mid-life. The client's onWorkerEvent
|
|
21
|
+
* 'error' transition detects it (in-flight tasks already rejected as
|
|
22
|
+
* WorkerCrashedError), the stale driver tears down, and the boundary
|
|
23
|
+
* enters the error state — `retry()`/auto-retry remounts into the
|
|
24
|
+
* respawned worker.
|
|
25
|
+
*
|
|
26
|
+
* Render functions are framework-free: they return `Node | string` (or an
|
|
27
|
+
* array), applied to the island's container element. Loading replaces the
|
|
28
|
+
* container's contents; the error state replaces them by default —
|
|
29
|
+
* `domOnError: 'preserve'` appends the fallback over the stale last frame
|
|
30
|
+
* instead (crash-overlay style).
|
|
31
|
+
*
|
|
32
|
+
* The returned handle is an IslandHandle plus `state`/`error`/`retry()` —
|
|
33
|
+
* it resolves after the FIRST attempt settles (mounted or error state),
|
|
34
|
+
* so callers never see an unhandled rejection, and a later retry() can
|
|
35
|
+
* still produce a working island under the same handle.
|
|
36
|
+
*/
|
|
37
|
+
import { connectIslandWorker, mountIsland } from './island';
|
|
38
|
+
import type {
|
|
39
|
+
ConnectIslandWorkerConfig,
|
|
40
|
+
IslandClient,
|
|
41
|
+
IslandHandle,
|
|
42
|
+
Mode,
|
|
43
|
+
MountIslandOptions,
|
|
44
|
+
} from './island';
|
|
45
|
+
|
|
46
|
+
/** What a boundary render fn may return — applied via el.replaceChildren/append. */
|
|
47
|
+
export type BoundaryContent = Node | string | Array<Node | string> | null | undefined;
|
|
48
|
+
|
|
49
|
+
/** Where the failure happened — 'mount' (handshake), 'runtime' (worker crash). */
|
|
50
|
+
export type IslandBoundaryPhase = 'mount' | 'runtime';
|
|
51
|
+
|
|
52
|
+
export interface IslandBoundaryContext {
|
|
53
|
+
/** The mount's app key. */
|
|
54
|
+
app: string;
|
|
55
|
+
/** The island's container element — the boundary's render target. */
|
|
56
|
+
el: HTMLElement;
|
|
57
|
+
/** 1-based mount attempt counter — increments across retries. */
|
|
58
|
+
attempt: number;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
export interface IslandBoundaryErrorContext extends IslandBoundaryContext {
|
|
62
|
+
/** The caught failure (mount rejection, crash error). */
|
|
63
|
+
error: unknown;
|
|
64
|
+
phase: IslandBoundaryPhase;
|
|
65
|
+
/** Manual remount — wire it to a retry button in the error render. */
|
|
66
|
+
retry(): void;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
export interface IslandBoundary {
|
|
70
|
+
/** Rendered while the first mount attempt is in flight. */
|
|
71
|
+
loading?(ctx: IslandBoundaryContext): BoundaryContent;
|
|
72
|
+
/** Rendered when the island fails — mount rejection or runtime crash. */
|
|
73
|
+
error?(ctx: IslandBoundaryErrorContext): BoundaryContent;
|
|
74
|
+
/** Fires on every boundary-entering failure — logging/telemetry. */
|
|
75
|
+
onError?(error: unknown, phase: IslandBoundaryPhase): void;
|
|
76
|
+
/**
|
|
77
|
+
* Auto-remount policy: a number is the max retry count (delay grows
|
|
78
|
+
* ~250ms per attempt, capped at 2s); a function maps (attempt, error)
|
|
79
|
+
* to a delay in ms, `false`/`undefined` to stop. Omit for no auto-retry.
|
|
80
|
+
*/
|
|
81
|
+
retry?: number | ((attempt: number, error: unknown) => number | false | undefined | void);
|
|
82
|
+
/**
|
|
83
|
+
* What the error state does to the container: 'clear' (default) replaces
|
|
84
|
+
* its contents with the fallback; 'preserve' appends the fallback over
|
|
85
|
+
* the stale last frame on runtime crashes (mount failures always clear —
|
|
86
|
+
* there is no frame to preserve).
|
|
87
|
+
*/
|
|
88
|
+
domOnError?: 'clear' | 'preserve';
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
export type IslandBoundaryState = 'loading' | 'mounted' | 'error';
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* The boundary-aware handle: a normal IslandHandle when mounted, safe
|
|
95
|
+
* no-ops while failed (updateProps stashes into the next attempt's props),
|
|
96
|
+
* plus the boundary's own state.
|
|
97
|
+
*/
|
|
98
|
+
export interface IslandBoundaryHandle extends IslandHandle {
|
|
99
|
+
readonly state: IslandBoundaryState;
|
|
100
|
+
/** The error that put the boundary in 'error' state (undefined otherwise). */
|
|
101
|
+
readonly error: unknown;
|
|
102
|
+
/** Remount now — same as ctx.retry() handed to the error render fn. */
|
|
103
|
+
retry(): void;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
export async function mountIslandWithBoundary(
|
|
107
|
+
opts: MountIslandOptions & { boundary: IslandBoundary },
|
|
108
|
+
): Promise<IslandBoundaryHandle> {
|
|
109
|
+
const {
|
|
110
|
+
boundary,
|
|
111
|
+
el: target,
|
|
112
|
+
client: givenClient,
|
|
113
|
+
worker,
|
|
114
|
+
app: givenApp,
|
|
115
|
+
props: givenProps,
|
|
116
|
+
mode: initialMode,
|
|
117
|
+
...mountOpts
|
|
118
|
+
} = opts;
|
|
119
|
+
// The boundary owns the client when the caller used the `worker`
|
|
120
|
+
// shorthand — it needs the lifecycle subscription (and clientMounts
|
|
121
|
+
// accounting keeps last-island-destroy → terminate semantics identical).
|
|
122
|
+
const client: IslandClient =
|
|
123
|
+
givenClient ??
|
|
124
|
+
connectIslandWorker({
|
|
125
|
+
name: givenApp,
|
|
126
|
+
...mountOpts,
|
|
127
|
+
worker,
|
|
128
|
+
doorbell: initialMode !== 'poll',
|
|
129
|
+
} as ConnectIslandWorkerConfig);
|
|
130
|
+
const app = givenApp ?? 'main';
|
|
131
|
+
// mountIsland only routes here for REAL mount targets — proxy elements
|
|
132
|
+
// take the nested path before the boundary check.
|
|
133
|
+
const el = target as HTMLElement;
|
|
134
|
+
|
|
135
|
+
let state: IslandBoundaryState = 'loading';
|
|
136
|
+
let lastError: unknown;
|
|
137
|
+
let current: IslandHandle | null = null;
|
|
138
|
+
let attempt = 0;
|
|
139
|
+
let destroyed = false;
|
|
140
|
+
let retryTimer: ReturnType<typeof setTimeout> | null = null;
|
|
141
|
+
/** Latest props — updateProps while failed stashes here for the remount. */
|
|
142
|
+
let pendingProps = givenProps;
|
|
143
|
+
let pendingMode: Mode | undefined = initialMode;
|
|
144
|
+
|
|
145
|
+
const setContent = (content: BoundaryContent, how: 'replace' | 'append'): void => {
|
|
146
|
+
if (content === null || content === undefined) {
|
|
147
|
+
if (how === 'replace') el.replaceChildren();
|
|
148
|
+
return;
|
|
149
|
+
}
|
|
150
|
+
const parts = (Array.isArray(content) ? content : [content]).filter(
|
|
151
|
+
(p): p is Node | string => p !== null && p !== undefined,
|
|
152
|
+
);
|
|
153
|
+
if (how === 'replace') el.replaceChildren(...parts);
|
|
154
|
+
else el.append(...parts);
|
|
155
|
+
};
|
|
156
|
+
|
|
157
|
+
const scheduleRetry = (error: unknown): void => {
|
|
158
|
+
const policy = boundary.retry;
|
|
159
|
+
if (policy === undefined || retryTimer !== null) return;
|
|
160
|
+
const delay =
|
|
161
|
+
typeof policy === 'number'
|
|
162
|
+
? attempt <= policy
|
|
163
|
+
? Math.min(250 * attempt, 2000)
|
|
164
|
+
: false
|
|
165
|
+
: policy(attempt, error);
|
|
166
|
+
if (delay === false || delay === undefined || typeof delay !== 'number') return;
|
|
167
|
+
retryTimer = setTimeout(() => {
|
|
168
|
+
retryTimer = null;
|
|
169
|
+
void tryMount();
|
|
170
|
+
}, Math.max(0, delay));
|
|
171
|
+
};
|
|
172
|
+
|
|
173
|
+
const fail = (error: unknown, phase: IslandBoundaryPhase): void => {
|
|
174
|
+
if (destroyed) return;
|
|
175
|
+
state = 'error';
|
|
176
|
+
lastError = error;
|
|
177
|
+
boundary.onError?.(error, phase);
|
|
178
|
+
const content = boundary.error?.({
|
|
179
|
+
app,
|
|
180
|
+
el,
|
|
181
|
+
attempt,
|
|
182
|
+
error,
|
|
183
|
+
phase,
|
|
184
|
+
retry: manualRetry,
|
|
185
|
+
});
|
|
186
|
+
// Runtime 'preserve' paints over the stale frame; everything else
|
|
187
|
+
// replaces (mount failures never produced a frame worth keeping).
|
|
188
|
+
if (boundary.domOnError === 'preserve' && phase === 'runtime') {
|
|
189
|
+
setContent(content, 'append');
|
|
190
|
+
} else {
|
|
191
|
+
setContent(content, 'replace');
|
|
192
|
+
}
|
|
193
|
+
scheduleRetry(error);
|
|
194
|
+
};
|
|
195
|
+
|
|
196
|
+
const tryMount = async (): Promise<void> => {
|
|
197
|
+
if (destroyed) return;
|
|
198
|
+
attempt++;
|
|
199
|
+
// The worker's mount batch assumes an empty container — stale frames,
|
|
200
|
+
// loading content, and error fallbacks all clear before it replays.
|
|
201
|
+
el.replaceChildren();
|
|
202
|
+
try {
|
|
203
|
+
const handle = await mountIsland({
|
|
204
|
+
...mountOpts,
|
|
205
|
+
client,
|
|
206
|
+
el,
|
|
207
|
+
app: givenApp,
|
|
208
|
+
props: pendingProps,
|
|
209
|
+
mode: pendingMode,
|
|
210
|
+
} as MountIslandOptions);
|
|
211
|
+
if (destroyed) {
|
|
212
|
+
handle.destroy();
|
|
213
|
+
return;
|
|
214
|
+
}
|
|
215
|
+
current = handle;
|
|
216
|
+
state = 'mounted';
|
|
217
|
+
} catch (err) {
|
|
218
|
+
fail(err, 'mount');
|
|
219
|
+
}
|
|
220
|
+
};
|
|
221
|
+
|
|
222
|
+
const manualRetry = (): void => {
|
|
223
|
+
if (destroyed) return;
|
|
224
|
+
if (retryTimer !== null) {
|
|
225
|
+
clearTimeout(retryTimer);
|
|
226
|
+
retryTimer = null;
|
|
227
|
+
}
|
|
228
|
+
void tryMount();
|
|
229
|
+
};
|
|
230
|
+
|
|
231
|
+
// Worker-death detection: the client's lifecycle events fire on the
|
|
232
|
+
// runner's 'error' transition — every instance on this client just died.
|
|
233
|
+
// Shared clients notify each island's boundary individually.
|
|
234
|
+
const offLifecycle = client.onWorkerEvent?.((event) => {
|
|
235
|
+
if (destroyed || state === 'error' || event.type !== 'error') return;
|
|
236
|
+
const stale = current;
|
|
237
|
+
current = null;
|
|
238
|
+
// Tear the dead driver down — stops doorbell/poll/RO timers and
|
|
239
|
+
// releases instance state (its unmount task rejects, which destroy
|
|
240
|
+
// already swallows; a last-island terminate races the runner's own
|
|
241
|
+
// respawn harmlessly).
|
|
242
|
+
stale?.destroy();
|
|
243
|
+
fail(
|
|
244
|
+
new Error(
|
|
245
|
+
`[island ${app}] worker crashed` + (event.message !== undefined ? `: ${event.message}` : ''),
|
|
246
|
+
),
|
|
247
|
+
'runtime',
|
|
248
|
+
);
|
|
249
|
+
});
|
|
250
|
+
|
|
251
|
+
const handle: IslandBoundaryHandle = {
|
|
252
|
+
get app() {
|
|
253
|
+
return current?.app ?? app;
|
|
254
|
+
},
|
|
255
|
+
get pid() {
|
|
256
|
+
return current?.pid ?? 'unmounted';
|
|
257
|
+
},
|
|
258
|
+
get instance() {
|
|
259
|
+
return current?.instance ?? `${app}@?`;
|
|
260
|
+
},
|
|
261
|
+
get mode() {
|
|
262
|
+
return current?.mode ?? pendingMode ?? 'push';
|
|
263
|
+
},
|
|
264
|
+
get opsApplied() {
|
|
265
|
+
return current?.opsApplied ?? 0;
|
|
266
|
+
},
|
|
267
|
+
get flushCalls() {
|
|
268
|
+
return current?.flushCalls ?? 0;
|
|
269
|
+
},
|
|
270
|
+
get state() {
|
|
271
|
+
return state;
|
|
272
|
+
},
|
|
273
|
+
get error() {
|
|
274
|
+
return lastError;
|
|
275
|
+
},
|
|
276
|
+
retry: manualRetry,
|
|
277
|
+
setMode: (mode) => {
|
|
278
|
+
pendingMode = mode;
|
|
279
|
+
current?.setMode(mode);
|
|
280
|
+
},
|
|
281
|
+
updateProps: async (next) => {
|
|
282
|
+
pendingProps = next;
|
|
283
|
+
await current?.updateProps(next);
|
|
284
|
+
},
|
|
285
|
+
flush: async () => {
|
|
286
|
+
await current?.flush();
|
|
287
|
+
},
|
|
288
|
+
destroy: () => {
|
|
289
|
+
if (destroyed) return;
|
|
290
|
+
destroyed = true;
|
|
291
|
+
if (retryTimer !== null) {
|
|
292
|
+
clearTimeout(retryTimer);
|
|
293
|
+
retryTimer = null;
|
|
294
|
+
}
|
|
295
|
+
offLifecycle?.();
|
|
296
|
+
current?.destroy();
|
|
297
|
+
current = null;
|
|
298
|
+
},
|
|
299
|
+
};
|
|
300
|
+
|
|
301
|
+
setContent(boundary.loading?.({ app, el, attempt: 1 }), 'replace');
|
|
302
|
+
await tryMount();
|
|
303
|
+
return handle;
|
|
304
|
+
}
|
package/src/index.ts
CHANGED
|
@@ -22,6 +22,17 @@ export type {
|
|
|
22
22
|
MountIslandOptions,
|
|
23
23
|
} from './island';
|
|
24
24
|
|
|
25
|
+
export { mountIslandWithBoundary } from './boundary';
|
|
26
|
+
export type {
|
|
27
|
+
BoundaryContent,
|
|
28
|
+
IslandBoundary,
|
|
29
|
+
IslandBoundaryContext,
|
|
30
|
+
IslandBoundaryErrorContext,
|
|
31
|
+
IslandBoundaryHandle,
|
|
32
|
+
IslandBoundaryPhase,
|
|
33
|
+
IslandBoundaryState,
|
|
34
|
+
} from './boundary';
|
|
35
|
+
|
|
25
36
|
export { makeDoorbell } from './memory';
|
|
26
37
|
export type { DoorbellSpec } from './memory';
|
|
27
38
|
|
package/src/island.ts
CHANGED
|
@@ -44,6 +44,8 @@ import {
|
|
|
44
44
|
import { marshalCallbackProps, CALLBACK_EVENT } from './callbackProps';
|
|
45
45
|
import { trackIslandForDevtools } from './devtoolsCommands';
|
|
46
46
|
import { propsPreview } from './devtoolsPreview';
|
|
47
|
+
import { mountIslandWithBoundary } from './boundary';
|
|
48
|
+
import type { IslandBoundary, IslandBoundaryHandle } from './boundary';
|
|
47
49
|
import type { ProxyElement } from './worker/dom/element';
|
|
48
50
|
|
|
49
51
|
export type Mode = 'push' | 'poll';
|
|
@@ -189,6 +191,15 @@ interface MountIslandBaseOptions {
|
|
|
189
191
|
* client warm for this island's teardown.
|
|
190
192
|
*/
|
|
191
193
|
keepAlive?: boolean;
|
|
194
|
+
/**
|
|
195
|
+
* Error boundary — `loading`/`error` state render functions plus a retry
|
|
196
|
+
* policy, covering mount rejection, mountTimeout, and runtime worker
|
|
197
|
+
* crashes (detected via the client's lifecycle events). When set, the
|
|
198
|
+
* mount resolves to an `IslandBoundaryHandle` after the first attempt
|
|
199
|
+
* settles — errors render the fallback instead of rejecting. Ignored on
|
|
200
|
+
* nested (proxy-element) mounts. See boundary.ts / docs error-boundaries.
|
|
201
|
+
*/
|
|
202
|
+
boundary?: IslandBoundary;
|
|
192
203
|
}
|
|
193
204
|
|
|
194
205
|
/**
|
|
@@ -471,10 +482,15 @@ export const registerSubIslandMounter = (
|
|
|
471
482
|
const isProxyMountTarget = (el: unknown): el is ProxyElement =>
|
|
472
483
|
typeof (el as { doc?: { instance?: unknown } } | null)?.doc?.instance === 'string';
|
|
473
484
|
|
|
485
|
+
export function mountIsland(
|
|
486
|
+
opts: MountIslandOptions & { boundary: IslandBoundary },
|
|
487
|
+
): Promise<IslandBoundaryHandle>;
|
|
488
|
+
export function mountIsland(opts: MountIslandOptions): Promise<IslandHandle>;
|
|
474
489
|
export async function mountIsland(opts: MountIslandOptions): Promise<IslandHandle> {
|
|
475
490
|
const {
|
|
476
491
|
el: target, onEvent, onActivity, onOps, slots, mode: initialMode, mountTimeout,
|
|
477
492
|
client: givenClient, worker, app: givenApp, props: givenProps, framework,
|
|
493
|
+
boundary,
|
|
478
494
|
...workerConfig
|
|
479
495
|
} = opts;
|
|
480
496
|
if (isProxyMountTarget(target)) {
|
|
@@ -489,6 +505,12 @@ export async function mountIsland(opts: MountIslandOptions): Promise<IslandHandl
|
|
|
489
505
|
// Real DOM from here on (the proxy path delegated above) — the declared
|
|
490
506
|
// type keeps closures honest; the guard did the proving.
|
|
491
507
|
const el = target as HTMLElement;
|
|
508
|
+
if (boundary !== undefined) {
|
|
509
|
+
// Error-boundary path — mount/handshake failures and runtime worker
|
|
510
|
+
// crashes render state fallbacks instead of rejecting; the returned
|
|
511
|
+
// handle wraps the eventual island. Nested mounts ignore the option.
|
|
512
|
+
return mountIslandWithBoundary(opts as MountIslandOptions & { boundary: IslandBoundary });
|
|
513
|
+
}
|
|
492
514
|
// Shell→worker callables: callbackProp() markers marshal to {__cb:id}
|
|
493
515
|
// wire handles; the island's table keeps the real functions and the
|
|
494
516
|
// driver's emit-case routes worker-side invocations back here.
|
|
@@ -170,6 +170,16 @@ export interface PolyWorkerRegistry {
|
|
|
170
170
|
/** Name → island app registry. The shell's `mountIsland({ app: name })`
|
|
171
171
|
* picks one per island. */
|
|
172
172
|
apps: Record<string, IslandApp>;
|
|
173
|
+
/**
|
|
174
|
+
* Lazy app loader — consulted by `mount` when the requested registry name
|
|
175
|
+
* isn't present yet. Returning an app registers it under that name (so a
|
|
176
|
+
* second mount is a registry hit); returning `undefined` lets the mount
|
|
177
|
+
* fail as unknown. Federation hosts pass a remote-module importer here —
|
|
178
|
+
* see `@atolljs/federation`. When set, the single-registered-app
|
|
179
|
+
* name-blind fallback is DISABLED: a loader host resolves every mount by
|
|
180
|
+
* its exact registry key, never by "the only app I have".
|
|
181
|
+
*/
|
|
182
|
+
loadApp?: (name: string) => IslandApp | undefined | Promise<IslandApp | undefined>;
|
|
173
183
|
/**
|
|
174
184
|
* The doorbell contract the worker exposes — defaults to the package's
|
|
175
185
|
* `renderMemory`. Rarely needed: pass a different doorbell-SPEC instance
|
|
@@ -259,7 +269,13 @@ const mounts = new Map<string, Instance>();
|
|
|
259
269
|
*/
|
|
260
270
|
const APP_REGISTRY = new Map<string, IslandApp>();
|
|
261
271
|
|
|
262
|
-
|
|
272
|
+
/**
|
|
273
|
+
* Register an app into the module-level registry after the definer ran —
|
|
274
|
+
* the manual counterpart to `loadApp` (warm-up/preload outside a mount).
|
|
275
|
+
* Registers under `name` exactly; the mount-time name-blind fallback only
|
|
276
|
+
* applies to single-registry workers without a loader.
|
|
277
|
+
*/
|
|
278
|
+
export const registerIslandApp = (name: string, app: IslandApp): void => {
|
|
263
279
|
APP_REGISTRY.set(name, app);
|
|
264
280
|
};
|
|
265
281
|
|
|
@@ -292,6 +308,7 @@ const appNameOf = (instance: string): string => {
|
|
|
292
308
|
*/
|
|
293
309
|
function createIslandRuntime(
|
|
294
310
|
sharedMemory: SharedMemory<DoorbellSpec> = renderMemory,
|
|
311
|
+
loadApp?: PolyWorkerRegistry['loadApp'],
|
|
295
312
|
): WorkerDefinition<DoorbellSpec, IslandWorkerMethods> {
|
|
296
313
|
// Point bumpOpsVersion at the declared contract (it's the same object as
|
|
297
314
|
// renderMemory unless a custom doorbell instance was passed).
|
|
@@ -435,7 +452,20 @@ function createIslandRuntime(
|
|
|
435
452
|
// marshalled function (fire-and-forget, doorbell-rung for out-of-
|
|
436
453
|
// task callers like timers/promise continuations).
|
|
437
454
|
props = unmarshalCallbackProps(props, callbackFactory(instance)) as Record<string, unknown>;
|
|
438
|
-
const
|
|
455
|
+
const appName = appNameOf(instance);
|
|
456
|
+
let App = APP_REGISTRY.get(appName);
|
|
457
|
+
if (App === undefined && loadApp !== undefined) {
|
|
458
|
+
// Lazy registration — the loader imports a remote module (federation)
|
|
459
|
+
// or otherwise supplies the app; a defined result registers under
|
|
460
|
+
// the requested name so subsequent mounts resolve directly.
|
|
461
|
+
const loaded = await loadApp(appName);
|
|
462
|
+
if (loaded !== undefined) {
|
|
463
|
+
registerIslandApp(appName, loaded);
|
|
464
|
+
App = loaded;
|
|
465
|
+
}
|
|
466
|
+
} else if (App === undefined) {
|
|
467
|
+
App = resolveApp(appName); // sole-app fallback (no loader present)
|
|
468
|
+
}
|
|
439
469
|
if (App === undefined) {
|
|
440
470
|
throw new Error(
|
|
441
471
|
`mount: unknown app "${instance}" — registry has: ${[...APP_REGISTRY.keys()].join(', ')}`,
|
|
@@ -684,9 +714,9 @@ export function definePolyWorker(
|
|
|
684
714
|
`component-reference mounts resolve "${stamped}" and will fail. Fix the key or the stamp.`,
|
|
685
715
|
);
|
|
686
716
|
}
|
|
687
|
-
|
|
717
|
+
registerIslandApp(key, app);
|
|
688
718
|
}
|
|
689
|
-
return createIslandRuntime(registry.sharedMemory ?? options?.sharedMemory);
|
|
719
|
+
return createIslandRuntime(registry.sharedMemory ?? options?.sharedMemory, registry.loadApp);
|
|
690
720
|
}
|
|
691
721
|
|
|
692
722
|
/**
|
|
@@ -700,6 +730,6 @@ export function defineMonoWorker(
|
|
|
700
730
|
options?: { sharedMemory?: SharedMemory<DoorbellSpec> },
|
|
701
731
|
): WorkerDefinition<DoorbellSpec, IslandWorkerMethods> {
|
|
702
732
|
const stamp = (app as { islandAppName?: unknown }).islandAppName;
|
|
703
|
-
|
|
733
|
+
registerIslandApp(typeof stamp === 'string' && stamp !== '' ? stamp : 'main', app);
|
|
704
734
|
return createIslandRuntime(options?.sharedMemory);
|
|
705
735
|
}
|
package/src/worker/index.ts
CHANGED
|
@@ -14,7 +14,7 @@
|
|
|
14
14
|
* Framework-neutral by construction: this entry imports no framework — React's
|
|
15
15
|
* reconciler adapter lives in `@atolljs/react-island/worker`.
|
|
16
16
|
*/
|
|
17
|
-
export { definePolyWorker, defineMonoWorker } from './defineWorkers';
|
|
17
|
+
export { definePolyWorker, defineMonoWorker, registerIslandApp } from './defineWorkers';
|
|
18
18
|
export type {
|
|
19
19
|
ImperativeIslandApp,
|
|
20
20
|
Instance,
|