@preact-mvc/atoll-island 0.0.0-stage → 0.0.0
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 +32 -4
- package/src/adapter.tsx +258 -0
- package/src/index.ts +49 -0
- package/src/shell.tsx +280 -0
- package/src/worker.ts +66 -0
- package/README.md +0 -3
package/package.json
CHANGED
|
@@ -1,6 +1,34 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@preact-mvc/atoll-island",
|
|
3
|
-
"version": "0.0.0
|
|
4
|
-
"
|
|
5
|
-
"
|
|
6
|
-
|
|
3
|
+
"version": "0.0.0",
|
|
4
|
+
"description": "preact-mvc island adapter: <Island/> shell components + Preact renderer for @preact-mvc/atoll worker islands",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"exports": {
|
|
7
|
+
".": "./src/index.ts",
|
|
8
|
+
"./worker": "./src/worker.ts",
|
|
9
|
+
"./package.json": "./package.json",
|
|
10
|
+
"./*": "./src/*"
|
|
11
|
+
},
|
|
12
|
+
"files": [
|
|
13
|
+
"src",
|
|
14
|
+
"!src/**/*.test.ts",
|
|
15
|
+
"!src/**/*.test.tsx"
|
|
16
|
+
],
|
|
17
|
+
"scripts": {
|
|
18
|
+
"typecheck": "tsc --noEmit"
|
|
19
|
+
},
|
|
20
|
+
"dependencies": {
|
|
21
|
+
"@preact-mvc/atoll": "*",
|
|
22
|
+
"@preact-mvc/core": "*",
|
|
23
|
+
"preact": "^10.24.0"
|
|
24
|
+
},
|
|
25
|
+
"devDependencies": {
|
|
26
|
+
"@atolljs/core": "0.1.11",
|
|
27
|
+
"@atolljs/islands": "0.1.11"
|
|
28
|
+
},
|
|
29
|
+
"publishConfig": {
|
|
30
|
+
"access": "public",
|
|
31
|
+
"registry": "https://registry.npmjs.org"
|
|
32
|
+
},
|
|
33
|
+
"license": "MIT"
|
|
34
|
+
}
|
package/src/adapter.tsx
ADDED
|
@@ -0,0 +1,258 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The framework renderer adapter — our `*-island` package's renderer half
|
|
3
|
+
* (docs/atoll/islands-frameworks.md). `viewIslandApp(Component)` wraps a
|
|
4
|
+
* `View` class or function component as a `RenderedIslandApp`; each mount
|
|
5
|
+
* renders into the instance's proxy document via Preact, so every DOM
|
|
6
|
+
* mutation serializes to ops.
|
|
7
|
+
*
|
|
8
|
+
* `defineViewPolyWorker`/`defineViewMonoWorker` are the single-framework
|
|
9
|
+
* convenience definers — they wrap plain components automatically:
|
|
10
|
+
*
|
|
11
|
+
* // render.worker.ts
|
|
12
|
+
* export const worker = defineViewPolyWorker({
|
|
13
|
+
* apps: { counter: CounterView, clock: ClockView },
|
|
14
|
+
* });
|
|
15
|
+
*
|
|
16
|
+
* DI inside islands: the app gets a fresh root injector per instance —
|
|
17
|
+
* worker-side injectables (stores, controllers) live in the worker, and
|
|
18
|
+
* `@Component({ providers/imports/controllers })` metadata works unchanged
|
|
19
|
+
* because the View host machinery runs in the worker too.
|
|
20
|
+
*/
|
|
21
|
+
import { h, options, render } from 'preact';
|
|
22
|
+
import type { ComponentType, VNode } from 'preact';
|
|
23
|
+
import { Injector, InjectorContext, type Provider } from '@preact-mvc/core';
|
|
24
|
+
import {
|
|
25
|
+
defineMonoWorker,
|
|
26
|
+
definePolyWorker,
|
|
27
|
+
islandApp,
|
|
28
|
+
islandAppNameOf,
|
|
29
|
+
enterInstance,
|
|
30
|
+
exitInstance,
|
|
31
|
+
getActiveInstance,
|
|
32
|
+
withContract,
|
|
33
|
+
ProxyElement,
|
|
34
|
+
type ImperativeIslandApp,
|
|
35
|
+
type IslandContract,
|
|
36
|
+
type IslandWorkerDef,
|
|
37
|
+
type RenderContext,
|
|
38
|
+
type RenderedHandle,
|
|
39
|
+
type RenderedIslandApp,
|
|
40
|
+
} from '@preact-mvc/atoll/worker';
|
|
41
|
+
|
|
42
|
+
type AnyComponent = ComponentType<Record<string, unknown>>;
|
|
43
|
+
|
|
44
|
+
const ISLAND_KEY = '__islandInstance';
|
|
45
|
+
const PREV_KEY = '__islandPrevScope';
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Instance-scoped rendering for OUT-OF-TASK renders. Mount/updateProps run
|
|
49
|
+
* inside `runInInstance`, but signal-driven re-renders are deferred by
|
|
50
|
+
* Preact to a microtask with no active scope — ambient `document` would
|
|
51
|
+
* resolve via the last-active heuristic and could create nodes on a SIBLING
|
|
52
|
+
* island's document (in shared-client/in-process topologies).
|
|
53
|
+
*
|
|
54
|
+
* Fix: every root vnode is stamped `__islandInstance`; `options.__r` walks
|
|
55
|
+
* the `__` parent chain before each component render and enters that
|
|
56
|
+
* instance's scope; `options.diffed` restores. Deterministic, and strictly
|
|
57
|
+
* better than the ambient heuristic.
|
|
58
|
+
*/
|
|
59
|
+
let renderScopeInstalled = false;
|
|
60
|
+
function installRenderScope(): void {
|
|
61
|
+
if (renderScopeInstalled) return;
|
|
62
|
+
renderScopeInstalled = true;
|
|
63
|
+
type IVNode = VNode & { [ISLAND_KEY]?: string; [PREV_KEY]?: string | null; __?: IVNode | null };
|
|
64
|
+
const opts = options as unknown as {
|
|
65
|
+
__r?: (v: VNode) => void;
|
|
66
|
+
diffed?: (v: VNode) => void;
|
|
67
|
+
};
|
|
68
|
+
const prevR = opts.__r;
|
|
69
|
+
const prevD = opts.diffed;
|
|
70
|
+
opts.__r = (vnode: VNode) => {
|
|
71
|
+
let v = vnode as IVNode | null;
|
|
72
|
+
let key: string | undefined;
|
|
73
|
+
while (v) {
|
|
74
|
+
if (v[ISLAND_KEY]) {
|
|
75
|
+
key = v[ISLAND_KEY];
|
|
76
|
+
break;
|
|
77
|
+
}
|
|
78
|
+
v = v.__ ?? null;
|
|
79
|
+
}
|
|
80
|
+
if (key && key !== getActiveInstance())
|
|
81
|
+
(vnode as IVNode)[PREV_KEY] = enterInstance(key);
|
|
82
|
+
prevR?.(vnode);
|
|
83
|
+
};
|
|
84
|
+
opts.diffed = (vnode: VNode) => {
|
|
85
|
+
prevD?.(vnode);
|
|
86
|
+
const v = vnode as IVNode;
|
|
87
|
+
if (PREV_KEY in v) {
|
|
88
|
+
exitInstance(v[PREV_KEY] ?? null);
|
|
89
|
+
delete v[PREV_KEY];
|
|
90
|
+
}
|
|
91
|
+
};
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* Event-name bridge for the proxy DOM. Preact decides the DOM event name
|
|
96
|
+
* via `'onclick' in el`: real elements report the lowercase handler props
|
|
97
|
+
* as present, so `onClick` registers `addEventListener('click', …)` and
|
|
98
|
+
* keys `dom._listeners['click']`. The proxy element has no `on*` props —
|
|
99
|
+
* the probe fails and Preact uses the prop-sliced name 'Click' for BOTH.
|
|
100
|
+
* The driver then listens for a type real DOM never dispatches (no events
|
|
101
|
+
* arrive), and `eventProxy` looks its dict up under 'Click' anyway.
|
|
102
|
+
*
|
|
103
|
+
* The bridge normalizes at the proxy boundary: the `listen` op carries
|
|
104
|
+
* the real DOM type ('click' — driver attaches a working listener) while
|
|
105
|
+
* the wrapped handler re-tags the enriched payload with the prop-sliced
|
|
106
|
+
* name ('Click') so Preact's `eventProxy` still finds its dict entry.
|
|
107
|
+
* All-caps ('DOMContentLoaded') and lowercase types pass through. The
|
|
108
|
+
* wrapped-handler map keeps removeEventListener able to detach the same
|
|
109
|
+
* registration.
|
|
110
|
+
*/
|
|
111
|
+
type EventFn = (this: unknown, e: unknown) => void;
|
|
112
|
+
let eventBridgeInstalled = false;
|
|
113
|
+
function installEventNameBridge(): void {
|
|
114
|
+
if (eventBridgeInstalled) return;
|
|
115
|
+
eventBridgeInstalled = true;
|
|
116
|
+
/** element → original fn → prop-sliced type → wrapped fn */
|
|
117
|
+
const bridged = new WeakMap<object, Map<EventFn, Map<string, EventFn>>>();
|
|
118
|
+
|
|
119
|
+
// Prop-sliced PascalCase → the real lowercase DOM type. Only the
|
|
120
|
+
// PascalCase shape is rewritten: 'MouseLeave' → 'mouseleave',
|
|
121
|
+
// 'DblClick' → 'dblclick'. All-caps ('DOMContentLoaded'), lowercase
|
|
122
|
+
// ('click'), and camelCase custom types pass through — lowercasing
|
|
123
|
+
// 'DOMContentLoaded' would break it the other way.
|
|
124
|
+
const decap = (type: string): string =>
|
|
125
|
+
type.length > 1 &&
|
|
126
|
+
type[0] === type[0]!.toUpperCase() &&
|
|
127
|
+
type[1] === type[1]!.toLowerCase()
|
|
128
|
+
? type.toLowerCase()
|
|
129
|
+
: type;
|
|
130
|
+
|
|
131
|
+
type ListenerMethods = {
|
|
132
|
+
addEventListener: (this: object, type: string, fn: EventFn, opts?: unknown) => void;
|
|
133
|
+
removeEventListener: (this: object, type: string, fn: EventFn, opts?: unknown) => void;
|
|
134
|
+
};
|
|
135
|
+
const proto = ProxyElement.prototype as unknown as ListenerMethods;
|
|
136
|
+
const origAdd = proto.addEventListener;
|
|
137
|
+
const origRemove = proto.removeEventListener;
|
|
138
|
+
|
|
139
|
+
proto.addEventListener = function (
|
|
140
|
+
this: object,
|
|
141
|
+
type: string,
|
|
142
|
+
fn: EventFn,
|
|
143
|
+
opts?: unknown,
|
|
144
|
+
): void {
|
|
145
|
+
const dom = decap(type);
|
|
146
|
+
let effective = fn;
|
|
147
|
+
if (dom !== type) {
|
|
148
|
+
effective = function (this: unknown, e: unknown): void {
|
|
149
|
+
fn.call(this, { ...(e as Record<string, unknown>), type });
|
|
150
|
+
};
|
|
151
|
+
let byFn = bridged.get(this);
|
|
152
|
+
if (byFn === undefined) bridged.set(this, (byFn = new Map()));
|
|
153
|
+
let byType = byFn.get(fn);
|
|
154
|
+
if (byType === undefined) byFn.set(fn, (byType = new Map()));
|
|
155
|
+
byType.set(type, effective);
|
|
156
|
+
}
|
|
157
|
+
origAdd.call(this, dom, effective, opts);
|
|
158
|
+
};
|
|
159
|
+
|
|
160
|
+
proto.removeEventListener = function (
|
|
161
|
+
this: object,
|
|
162
|
+
type: string,
|
|
163
|
+
fn: EventFn,
|
|
164
|
+
opts?: unknown,
|
|
165
|
+
): void {
|
|
166
|
+
const effective = bridged.get(this)?.get(fn)?.get(type) ?? fn;
|
|
167
|
+
origRemove.call(this, decap(type), effective, opts);
|
|
168
|
+
};
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
export interface ViewIslandAppOptions {
|
|
172
|
+
/** Providers for the island's root injector — the island-side equivalent
|
|
173
|
+
* of `bootstrap(AppModule, providers)`. */
|
|
174
|
+
providers?: Provider[];
|
|
175
|
+
/** Attach a `defineIslandContract` contract: mount/updateProps props are
|
|
176
|
+
* validated against its schema, and declared `emit` payloads are
|
|
177
|
+
* checked worker-side. */
|
|
178
|
+
contract?: IslandContract;
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
/** Wrap a View class or function component as a `RenderedIslandApp`. */
|
|
182
|
+
export function viewIslandApp(App: AnyComponent, opts?: ViewIslandAppOptions): RenderedIslandApp {
|
|
183
|
+
installRenderScope();
|
|
184
|
+
installEventNameBridge();
|
|
185
|
+
const app: RenderedIslandApp = {
|
|
186
|
+
renderer: 'preact-mvc',
|
|
187
|
+
mount({ instance, doc, props }: RenderContext): RenderedHandle {
|
|
188
|
+
const inj = new Injector(undefined, `island ${instance}`);
|
|
189
|
+
for (const p of opts?.providers ?? []) inj.provide(p);
|
|
190
|
+
let current = props;
|
|
191
|
+
const renderRoot = (p: Record<string, unknown>): void => {
|
|
192
|
+
current = p;
|
|
193
|
+
const vnode = h(InjectorContext.Provider, { value: inj }, h(App, p));
|
|
194
|
+
(vnode as unknown as Record<string, unknown>)[ISLAND_KEY] = instance;
|
|
195
|
+
render(vnode, doc.body as never);
|
|
196
|
+
};
|
|
197
|
+
renderRoot(props);
|
|
198
|
+
return {
|
|
199
|
+
update: renderRoot,
|
|
200
|
+
// The dispatch task's sync lane: run the event handler, then
|
|
201
|
+
// re-render synchronously (Preact's render() IS the flush — signal
|
|
202
|
+
// writes would otherwise defer to a microtask and their ops would
|
|
203
|
+
// miss this task's batch, surfacing a poll later).
|
|
204
|
+
sync: (fn: () => void) => {
|
|
205
|
+
fn();
|
|
206
|
+
renderRoot(current);
|
|
207
|
+
},
|
|
208
|
+
dispose: () => {
|
|
209
|
+
try {
|
|
210
|
+
render(null, doc.body as never);
|
|
211
|
+
} finally {
|
|
212
|
+
inj.deactivate();
|
|
213
|
+
}
|
|
214
|
+
},
|
|
215
|
+
};
|
|
216
|
+
},
|
|
217
|
+
};
|
|
218
|
+
const stamp = islandAppNameOf(App);
|
|
219
|
+
if (stamp !== undefined) (app as { islandAppName?: string }).islandAppName = stamp;
|
|
220
|
+
return opts?.contract !== undefined ? withContract(opts.contract, app) : app;
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
/** Stamp + wrap in one step — `viewIsland('counter', CounterView)` yields
|
|
224
|
+
* both the registry value and the name the shell mounts by. */
|
|
225
|
+
export const viewIsland = (
|
|
226
|
+
name: string,
|
|
227
|
+
App: AnyComponent,
|
|
228
|
+
opts?: ViewIslandAppOptions,
|
|
229
|
+
): RenderedIslandApp & { readonly islandAppName: string } =>
|
|
230
|
+
islandApp(name, viewIslandApp(App, opts));
|
|
231
|
+
|
|
232
|
+
export interface ViewPolyWorkerRegistry {
|
|
233
|
+
/** Name → component (or `{ imperative }` app / pre-wrapped app). Plain
|
|
234
|
+
* components pass through `viewIslandApp`. */
|
|
235
|
+
apps: Record<string, AnyComponent | ImperativeIslandApp | RenderedIslandApp>;
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
/** `definePolyWorker` for framework views — maps each component in the
|
|
239
|
+
* registry through `viewIslandApp` and delegates. */
|
|
240
|
+
export function defineViewPolyWorker(registry: ViewPolyWorkerRegistry): IslandWorkerDef {
|
|
241
|
+
installRenderScope();
|
|
242
|
+
const apps: Record<string, ImperativeIslandApp | RenderedIslandApp> = {};
|
|
243
|
+
for (const [key, app] of Object.entries(registry.apps)) {
|
|
244
|
+
apps[key] = typeof app === 'function' ? viewIslandApp(app) : app;
|
|
245
|
+
}
|
|
246
|
+
return definePolyWorker({ apps });
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
/** `defineMonoWorker` for a single framework view — the isolated-bundle
|
|
250
|
+
* host shape (own worker, own failure domain). */
|
|
251
|
+
export function defineViewMonoWorker(
|
|
252
|
+
app: AnyComponent | ImperativeIslandApp | RenderedIslandApp,
|
|
253
|
+
opts?: ViewIslandAppOptions,
|
|
254
|
+
): IslandWorkerDef {
|
|
255
|
+
installRenderScope();
|
|
256
|
+
const resolved = typeof app === 'function' ? viewIslandApp(app, opts) : app;
|
|
257
|
+
return defineMonoWorker(resolved);
|
|
258
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `@preact-mvc/atoll-island` — the `*-island` package's shell surface
|
|
3
|
+
* (docs/atoll/islands-frameworks.md): mount a worker-hosted island tree as
|
|
4
|
+
* an ordinary element in a preact-mvc app. The worker-side renderer
|
|
5
|
+
* (viewIslandApp, defineViewPolyWorker) lives in the `./worker` entry.
|
|
6
|
+
*
|
|
7
|
+
* The facade pattern: a contract module (`defineIslandContract`) names the
|
|
8
|
+
* app, declares the wire schemas, and carries the worker factory — the
|
|
9
|
+
* view imports the contract only and mounts `islandComponent(contract)`
|
|
10
|
+
* with no worker plumbing at the call site.
|
|
11
|
+
*
|
|
12
|
+
* The driver/mount API is re-exported from `@preact-mvc/atoll` so shell
|
|
13
|
+
* code only needs this one package.
|
|
14
|
+
*/
|
|
15
|
+
export { Island, islandComponent, lazyIsland } from './shell';
|
|
16
|
+
export type {
|
|
17
|
+
IslandAppRef,
|
|
18
|
+
IslandFacadeProps,
|
|
19
|
+
IslandProps,
|
|
20
|
+
IslandShellProps,
|
|
21
|
+
} from './shell';
|
|
22
|
+
export {
|
|
23
|
+
callbackProp,
|
|
24
|
+
connectIslandWorker,
|
|
25
|
+
contractOf,
|
|
26
|
+
contractWorkerOf,
|
|
27
|
+
defineIslandContract,
|
|
28
|
+
isIslandContract,
|
|
29
|
+
islandApp,
|
|
30
|
+
islandAppNameOf,
|
|
31
|
+
mountIsland,
|
|
32
|
+
withContract,
|
|
33
|
+
z,
|
|
34
|
+
} from '@preact-mvc/atoll';
|
|
35
|
+
export type {
|
|
36
|
+
ConnectOptions,
|
|
37
|
+
IslandAppProps,
|
|
38
|
+
IslandClient,
|
|
39
|
+
IslandContract,
|
|
40
|
+
IslandContractEventHandler,
|
|
41
|
+
IslandContractEvents,
|
|
42
|
+
IslandContractProps,
|
|
43
|
+
IslandHandle,
|
|
44
|
+
IslandWorkerOptions,
|
|
45
|
+
Mode,
|
|
46
|
+
MountOptions,
|
|
47
|
+
Schema,
|
|
48
|
+
WorkerLike,
|
|
49
|
+
} from '@preact-mvc/atoll';
|
package/src/shell.tsx
ADDED
|
@@ -0,0 +1,280 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The shell surface — our `*-island` package's main-thread half
|
|
3
|
+
* (docs/atoll/islands-frameworks.md): mount a worker-hosted tree as an
|
|
4
|
+
* ordinary element in a framework app.
|
|
5
|
+
*
|
|
6
|
+
* <Island worker={entry} app="counter" props={{ start: 5 }}
|
|
7
|
+
* onEvent={(name, p) => ...} />
|
|
8
|
+
*
|
|
9
|
+
* const Counter = islandComponent(counterContract); // the facade:
|
|
10
|
+
* <Counter start={5} onEvent={...} /> // no worker prop —
|
|
11
|
+
* the contract carries it
|
|
12
|
+
*
|
|
13
|
+
* const Lazy = lazyIsland(() => import('./counter.contract'));
|
|
14
|
+
*
|
|
15
|
+
* These are plain function components — hooks are legal here (only `View`
|
|
16
|
+
* classes forbid them). `worker`/`client`/`app` are mount identity: change
|
|
17
|
+
* them with a `key`.
|
|
18
|
+
*/
|
|
19
|
+
import { h } from 'preact';
|
|
20
|
+
import { useEffect, useRef, useState } from 'preact/hooks';
|
|
21
|
+
import type { ComponentChildren, FunctionComponent } from 'preact';
|
|
22
|
+
import {
|
|
23
|
+
connectIslandWorker,
|
|
24
|
+
contractWorkerOf,
|
|
25
|
+
islandAppNameOf,
|
|
26
|
+
isIslandContract,
|
|
27
|
+
mountIsland,
|
|
28
|
+
} from '@preact-mvc/atoll';
|
|
29
|
+
import type {
|
|
30
|
+
IslandAppProps,
|
|
31
|
+
IslandClient,
|
|
32
|
+
IslandContract,
|
|
33
|
+
IslandContractEventHandler,
|
|
34
|
+
IslandContractProps,
|
|
35
|
+
IslandHandle,
|
|
36
|
+
IslandWorkerOptions,
|
|
37
|
+
Mode,
|
|
38
|
+
WorkerLike,
|
|
39
|
+
} from '@preact-mvc/atoll';
|
|
40
|
+
|
|
41
|
+
/** Props consumed by a proxy component itself — everything else forwards to
|
|
42
|
+
* the island as its props. */
|
|
43
|
+
export interface IslandShellProps {
|
|
44
|
+
/** Bundler-detectable factory `() => new Worker(new URL(...))` (or a URL
|
|
45
|
+
* / spawned worker). Optional when `app` is a contract carrying its own
|
|
46
|
+
* worker — the contract module is then the whole connection. */
|
|
47
|
+
worker?: WorkerLike | (() => WorkerLike) | URL;
|
|
48
|
+
/** A shared `connectIslandWorker` client — this mount adds an instance. */
|
|
49
|
+
client?: IslandClient;
|
|
50
|
+
/** Client options for `worker`-shorthand mounts (taskTimeout, respawn,
|
|
51
|
+
* lazy, doorbell). `doorbell: true` needs cross-origin isolation. */
|
|
52
|
+
workerOptions?: IslandWorkerOptions & { doorbell?: boolean };
|
|
53
|
+
/** 'push' rides the doorbell SAB (COOP/COEP); 'poll' (default) is
|
|
54
|
+
* message-only — no headers needed. */
|
|
55
|
+
mode?: Mode;
|
|
56
|
+
/** Devtools renderer tag. */
|
|
57
|
+
framework?: string;
|
|
58
|
+
/** Receives every worker-side `emit(name, payload)`. */
|
|
59
|
+
onEvent?: (name: string, payload: unknown) => void;
|
|
60
|
+
/** Fired after each applied op batch. */
|
|
61
|
+
onActivity?: () => void;
|
|
62
|
+
/** The mounted handle (pid, updateProps, flush, setMode…) once ready. */
|
|
63
|
+
onReady?: (island: IslandHandle) => void;
|
|
64
|
+
/** Mount/update errors surface here instead of an unhandled rejection. */
|
|
65
|
+
onError?: (err: unknown) => void;
|
|
66
|
+
/** `data-atoll-slot` transclusion holes: worker owns the box, shell owns
|
|
67
|
+
* the contents. */
|
|
68
|
+
slots?: Record<string, (el: HTMLElement | null) => void>;
|
|
69
|
+
/** Rendered while the worker mounts (lazy facades). */
|
|
70
|
+
fallback?: ComponentChildren;
|
|
71
|
+
className?: string;
|
|
72
|
+
style?: string | Record<string, string | number>;
|
|
73
|
+
mountTimeout?: number;
|
|
74
|
+
children?: ComponentChildren; // reserved for future slot sugar
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
export interface IslandProps extends IslandShellProps {
|
|
78
|
+
/** Registry key, stamped app (`islandApp`/`viewIsland`), or contract —
|
|
79
|
+
* resolved through `islandAppNameOf`. */
|
|
80
|
+
app?: unknown;
|
|
81
|
+
/** Structured-cloneable props (functions via `callbackProp`). */
|
|
82
|
+
props?: Record<string, unknown>;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
export const Island: FunctionComponent<IslandProps> = (p) => {
|
|
86
|
+
const ref = useRef<HTMLDivElement>(null);
|
|
87
|
+
const handle = useRef<IslandHandle | null>(null);
|
|
88
|
+
const [failed, setFailed] = useState<Error | null>(null);
|
|
89
|
+
|
|
90
|
+
// 'main' mounts a mono worker's sole app namelessly; registries error
|
|
91
|
+
// with the key list if 'main' isn't a real key.
|
|
92
|
+
const appName = p.app === undefined ? 'main' : islandAppNameOf(p.app);
|
|
93
|
+
|
|
94
|
+
// Mount once per app — worker/client/app are identity; change via `key`.
|
|
95
|
+
useEffect(() => {
|
|
96
|
+
if (appName === undefined) {
|
|
97
|
+
setFailed(
|
|
98
|
+
new Error(
|
|
99
|
+
'<Island> could not resolve an app name — pass the registry key ' +
|
|
100
|
+
'or stamp the app with islandApp(name, app)',
|
|
101
|
+
),
|
|
102
|
+
);
|
|
103
|
+
return;
|
|
104
|
+
}
|
|
105
|
+
const mode = p.mode ?? 'poll';
|
|
106
|
+
// A contract `app` can carry its own worker factory — the contract
|
|
107
|
+
// module is then the whole connection, call sites pass no worker.
|
|
108
|
+
const resolvedWorker = p.worker ?? contractWorkerOf(p.app);
|
|
109
|
+
const client =
|
|
110
|
+
p.client ??
|
|
111
|
+
(resolvedWorker !== undefined
|
|
112
|
+
? connectIslandWorker({
|
|
113
|
+
name: appName,
|
|
114
|
+
worker: resolvedWorker,
|
|
115
|
+
doorbell: mode !== 'poll',
|
|
116
|
+
...p.workerOptions,
|
|
117
|
+
})
|
|
118
|
+
: undefined);
|
|
119
|
+
if (client === undefined) {
|
|
120
|
+
setFailed(new Error('<Island> requires either `worker`, `client`, or a contract carrying `worker`'));
|
|
121
|
+
return;
|
|
122
|
+
}
|
|
123
|
+
let cancelled = false;
|
|
124
|
+
mountIsland({
|
|
125
|
+
el: ref.current!,
|
|
126
|
+
client,
|
|
127
|
+
app: appName,
|
|
128
|
+
props: p.props,
|
|
129
|
+
onEvent: p.onEvent,
|
|
130
|
+
onActivity: p.onActivity,
|
|
131
|
+
slots: p.slots,
|
|
132
|
+
mode,
|
|
133
|
+
framework: p.framework,
|
|
134
|
+
mountTimeout: p.mountTimeout,
|
|
135
|
+
})
|
|
136
|
+
.then((h) => {
|
|
137
|
+
if (cancelled) h.destroy();
|
|
138
|
+
else {
|
|
139
|
+
handle.current = h;
|
|
140
|
+
p.onReady?.(h);
|
|
141
|
+
}
|
|
142
|
+
})
|
|
143
|
+
.catch((err) => {
|
|
144
|
+
if (cancelled) return;
|
|
145
|
+
if (p.onError) p.onError(err);
|
|
146
|
+
else setFailed(err instanceof Error ? err : new Error(String(err)));
|
|
147
|
+
});
|
|
148
|
+
return () => {
|
|
149
|
+
cancelled = true;
|
|
150
|
+
handle.current?.destroy();
|
|
151
|
+
handle.current = null;
|
|
152
|
+
};
|
|
153
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
154
|
+
}, [appName, p.client]);
|
|
155
|
+
|
|
156
|
+
// Prop updates flow through the handle — deduped by wire-serialized
|
|
157
|
+
// identity; a repeat send would be a wasted round-trip.
|
|
158
|
+
const propsJson = JSON.stringify(p.props ?? {});
|
|
159
|
+
useEffect(() => {
|
|
160
|
+
handle.current?.updateProps(p.props ?? {});
|
|
161
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
162
|
+
}, [propsJson]);
|
|
163
|
+
|
|
164
|
+
if (failed) {
|
|
165
|
+
return (
|
|
166
|
+
<div class="island-error" style={{ border: '1px solid #c00', padding: '0.5em' }}>
|
|
167
|
+
Island mount failed: {failed.message}
|
|
168
|
+
</div>
|
|
169
|
+
);
|
|
170
|
+
}
|
|
171
|
+
return <div ref={ref} class={p.className} style={p.style as string} />;
|
|
172
|
+
};
|
|
173
|
+
|
|
174
|
+
/** What `app` accepts on a facade: the registry name, or a reference to
|
|
175
|
+
* resolve (component, stamped app, contract). */
|
|
176
|
+
export type IslandAppRef<A> = A | string;
|
|
177
|
+
|
|
178
|
+
export type IslandFacadeProps<A> = Omit<IslandShellProps, 'onEvent'> &
|
|
179
|
+
IslandAppProps<A> & {
|
|
180
|
+
props?: Record<string, unknown>;
|
|
181
|
+
onEvent?: (name: string, payload: unknown) => void;
|
|
182
|
+
};
|
|
183
|
+
|
|
184
|
+
/** Props consumed by a facade component itself — everything else forwards
|
|
185
|
+
* to the island as its props. */
|
|
186
|
+
const SHELL_PROP_KEYS: ReadonlySet<string> = new Set([
|
|
187
|
+
'worker',
|
|
188
|
+
'client',
|
|
189
|
+
'workerOptions',
|
|
190
|
+
'mode',
|
|
191
|
+
'framework',
|
|
192
|
+
'onEvent',
|
|
193
|
+
'onActivity',
|
|
194
|
+
'onReady',
|
|
195
|
+
'onError',
|
|
196
|
+
'slots',
|
|
197
|
+
'fallback',
|
|
198
|
+
'className',
|
|
199
|
+
'style',
|
|
200
|
+
'mountTimeout',
|
|
201
|
+
'children',
|
|
202
|
+
]);
|
|
203
|
+
|
|
204
|
+
const splitProxyProps = (
|
|
205
|
+
raw: Record<string, unknown>,
|
|
206
|
+
): { shell: IslandShellProps; islandProps: Record<string, unknown> } => {
|
|
207
|
+
const shell: Record<string, unknown> = {};
|
|
208
|
+
const islandProps: Record<string, unknown> = {};
|
|
209
|
+
for (const [key, value] of Object.entries(raw)) {
|
|
210
|
+
if (SHELL_PROP_KEYS.has(key)) shell[key] = value;
|
|
211
|
+
else islandProps[key] = value;
|
|
212
|
+
}
|
|
213
|
+
return { shell: shell as IslandShellProps, islandProps };
|
|
214
|
+
};
|
|
215
|
+
|
|
216
|
+
/**
|
|
217
|
+
* `islandComponent(contract)` — the cross-framework facade: the component's
|
|
218
|
+
* props are the island's props (from the contract's schema), `onEvent`
|
|
219
|
+
* narrows to the declared event vocabulary, and the contract's own worker
|
|
220
|
+
* factory means call sites pass NO worker/client at all. The shell imports
|
|
221
|
+
* the contract module only — never the worker entry or the island's code.
|
|
222
|
+
*
|
|
223
|
+
* `islandComponent<P>('charts')` — the string-keyed form for registries
|
|
224
|
+
* whose workers the shell already knows.
|
|
225
|
+
*/
|
|
226
|
+
export function islandComponent<C extends IslandContract>(
|
|
227
|
+
app: C,
|
|
228
|
+
): FunctionComponent<
|
|
229
|
+
IslandContractProps<C> &
|
|
230
|
+
Omit<IslandShellProps, 'onEvent'> & {
|
|
231
|
+
props?: Record<string, unknown>;
|
|
232
|
+
onEvent?: IslandContractEventHandler<C>;
|
|
233
|
+
}
|
|
234
|
+
>;
|
|
235
|
+
export function islandComponent<A>(app: A | string): FunctionComponent<IslandFacadeProps<A>>;
|
|
236
|
+
export function islandComponent(app: unknown): FunctionComponent<Record<string, unknown>> {
|
|
237
|
+
const Facade: FunctionComponent<Record<string, unknown>> = ({ props, ...raw }) => {
|
|
238
|
+
const { shell, islandProps } = splitProxyProps(raw as Record<string, unknown>);
|
|
239
|
+
return <Island app={app} props={{ ...islandProps, ...(props ?? {}) }} {...shell} />;
|
|
240
|
+
};
|
|
241
|
+
Facade.displayName = `Island(${islandAppNameOf(app) ?? 'main'})`;
|
|
242
|
+
return Facade as FunctionComponent<Record<string, unknown>>;
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
/**
|
|
246
|
+
* `lazyIsland(() => import('./counter.contract'))` — the module must export
|
|
247
|
+
* a contract (`defineIslandContract` — app + worker factory in one object),
|
|
248
|
+
* a stamped app (`viewIsland('counter', CounterView)`), or a default export
|
|
249
|
+
* resolvable by `islandAppNameOf`. A contract's own worker factory is used
|
|
250
|
+
* when no `worker` prop is passed.
|
|
251
|
+
*/
|
|
252
|
+
export function lazyIsland<A>(
|
|
253
|
+
load: () => Promise<Record<string, unknown>>,
|
|
254
|
+
): FunctionComponent<IslandFacadeProps<A>> {
|
|
255
|
+
const Lazy: FunctionComponent<IslandFacadeProps<A>> = (p) => {
|
|
256
|
+
const [app, setApp] = useState<unknown>(null);
|
|
257
|
+
useEffect(() => {
|
|
258
|
+
let live = true;
|
|
259
|
+
void load().then((mod) => {
|
|
260
|
+
if (!live) return;
|
|
261
|
+
const unwrapped = 'default' in mod ? mod.default : mod;
|
|
262
|
+
const resolved =
|
|
263
|
+
isIslandContract(unwrapped) ? unwrapped
|
|
264
|
+
: Object.values(mod).find((v) => isIslandContract(v)) ??
|
|
265
|
+
Object.values(mod).find(
|
|
266
|
+
(v) => v && typeof v === 'object' && 'islandAppName' in v,
|
|
267
|
+
) ??
|
|
268
|
+
unwrapped;
|
|
269
|
+
setApp(() => resolved);
|
|
270
|
+
});
|
|
271
|
+
return () => {
|
|
272
|
+
live = false;
|
|
273
|
+
};
|
|
274
|
+
}, []);
|
|
275
|
+
if (!app)
|
|
276
|
+
return <div class={p.className} style={p.style as string}>{p.fallback}</div>;
|
|
277
|
+
return <Island {...(p as IslandProps)} app={app} />;
|
|
278
|
+
};
|
|
279
|
+
return Lazy;
|
|
280
|
+
}
|
package/src/worker.ts
ADDED
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `@preact-mvc/atoll-island/worker` — the `*-island` package's worker
|
|
3
|
+
* renderer (docs/atoll/islands-frameworks.md): render View classes and
|
|
4
|
+
* function components into the instance's proxy document via Preact.
|
|
5
|
+
*
|
|
6
|
+
* // render.worker.ts
|
|
7
|
+
* export const worker = defineViewPolyWorker({
|
|
8
|
+
* apps: { counter: CounterView, clock: ClockView },
|
|
9
|
+
* });
|
|
10
|
+
*
|
|
11
|
+
* The framework-free worker API is re-exported from
|
|
12
|
+
* `@preact-mvc/atoll/worker` so a worker entry needs no direct atoll-core
|
|
13
|
+
* import (same convention as Atoll's `*-island` packages).
|
|
14
|
+
*/
|
|
15
|
+
export {
|
|
16
|
+
viewIslandApp,
|
|
17
|
+
viewIsland,
|
|
18
|
+
defineViewPolyWorker,
|
|
19
|
+
defineViewMonoWorker,
|
|
20
|
+
} from './adapter';
|
|
21
|
+
export type { ViewIslandAppOptions, ViewPolyWorkerRegistry } from './adapter';
|
|
22
|
+
/**
|
|
23
|
+
* The mount components on the worker side — same surface as the shell
|
|
24
|
+
* entry (Atoll's `react-island/worker` convention): inside a worker-
|
|
25
|
+
* rendered tree the component's div is a ProxyElement, `mountIsland`
|
|
26
|
+
* takes the nested path, and the sub-island's ops tunnel up the parent
|
|
27
|
+
* instance's op stream. `connectIslandWorker` builds a shared sub-worker
|
|
28
|
+
* client; `emit` in a nested `onEvent` relays events to the page.
|
|
29
|
+
*/
|
|
30
|
+
export { Island, islandComponent, lazyIsland } from './shell';
|
|
31
|
+
export type {
|
|
32
|
+
IslandProps,
|
|
33
|
+
IslandShellProps,
|
|
34
|
+
IslandAppRef,
|
|
35
|
+
IslandFacadeProps,
|
|
36
|
+
} from './shell';
|
|
37
|
+
export { connectIslandWorker, mountIsland, callbackProp } from '@preact-mvc/atoll/worker';
|
|
38
|
+
export {
|
|
39
|
+
contractOf,
|
|
40
|
+
defineIslandContract,
|
|
41
|
+
definePolyWorker,
|
|
42
|
+
defineMonoWorker,
|
|
43
|
+
emit,
|
|
44
|
+
installDomShim,
|
|
45
|
+
isIslandContract,
|
|
46
|
+
runInInstance,
|
|
47
|
+
enterInstance,
|
|
48
|
+
exitInstance,
|
|
49
|
+
getActiveInstance,
|
|
50
|
+
getLastActiveInstance,
|
|
51
|
+
islandApp,
|
|
52
|
+
islandAppNameOf,
|
|
53
|
+
withContract,
|
|
54
|
+
ProxyElement,
|
|
55
|
+
} from '@preact-mvc/atoll/worker';
|
|
56
|
+
export type {
|
|
57
|
+
EventPayload,
|
|
58
|
+
ImperativeIslandApp,
|
|
59
|
+
IslandContract,
|
|
60
|
+
RenderedIslandApp,
|
|
61
|
+
RenderContext,
|
|
62
|
+
RenderedHandle,
|
|
63
|
+
IslandApp,
|
|
64
|
+
IslandWorkerDef,
|
|
65
|
+
ProxyDocument,
|
|
66
|
+
} from '@preact-mvc/atoll/worker';
|
package/README.md
DELETED