@mosetta/runtime 0.0.0-stage → 0.0.9
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/LICENSE +21 -0
- package/README.md +15 -2
- package/package.json +26 -3
- package/src/assetRuntime.js +13 -0
- package/src/bootstrap.ts +363 -0
- package/src/client/appContainer.ts +86 -0
- package/src/client/client.ts +12 -0
- package/src/client/getIsClient.ts +3 -0
- package/src/client/headWriter.ts +85 -0
- package/src/client/pageManager.ts +55 -0
- package/src/client/refs.ts +319 -0
- package/src/client/router.ts +453 -0
- package/src/client/serverRender.ts +12 -0
- package/src/client/tile.ts +7 -0
- package/src/client/tokens.ts +70 -0
- package/src/domEvents.ts +118 -0
- package/src/naming.d.ts +6 -0
- package/src/naming.js +25 -0
- package/src/resolver.d.ts +29 -0
- package/src/resolver.js +137 -0
- package/src/tileLoader.js +42 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 mosetta contributors
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
CHANGED
|
@@ -1,3 +1,16 @@
|
|
|
1
|
-
#
|
|
1
|
+
# @mosetta/runtime
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
The browser runtime of mosetta: `start()` reads the page's envelope, installs the version resolver
|
|
4
|
+
on SystemJS (one copy of each singleton package per page, every tile at the base of the tile host
|
|
5
|
+
its descriptor names), loads the page's tiles and their stylesheets, and hands the tree to the
|
|
6
|
+
renderer. Framework-agnostic — the React mount lives in `@mosetta/react/start`.
|
|
7
|
+
|
|
8
|
+
| Entry | What it holds |
|
|
9
|
+
|---|---|
|
|
10
|
+
| `@mosetta/runtime/bootstrap` | `start()` and the resolver prolog |
|
|
11
|
+
| `@mosetta/runtime/resolver` | the version resolver |
|
|
12
|
+
| `@mosetta/runtime/tile-loader` | `createTileLoader` |
|
|
13
|
+
| `@mosetta/runtime/naming` | full tile names |
|
|
14
|
+
| `@mosetta/runtime/client`, `./dom` | the browser's helpers |
|
|
15
|
+
|
|
16
|
+
It ships to the browser as an ordinary package tile.
|
package/package.json
CHANGED
|
@@ -1,6 +1,29 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@mosetta/runtime",
|
|
3
|
-
"version": "0.0.
|
|
4
|
-
"
|
|
5
|
-
"description": "
|
|
3
|
+
"version": "0.0.9",
|
|
4
|
+
"license": "MIT",
|
|
5
|
+
"description": "mosetta browser runtime: start() (resolver prolog, tree loading, holder), version resolver, safe tile loader, asset runtime. Framework-agnostic — the react mount (startAndMount) lives in @mosetta/react/start. Ships as an ordinary package tile; the root export is the component-facing asset surface.",
|
|
6
|
+
"type": "module",
|
|
7
|
+
"files": [
|
|
8
|
+
"src"
|
|
9
|
+
],
|
|
10
|
+
"exports": {
|
|
11
|
+
".": "./src/assetRuntime.js",
|
|
12
|
+
"./bootstrap": "./src/bootstrap.ts",
|
|
13
|
+
"./client": "./src/client/client.ts",
|
|
14
|
+
"./dom": "./src/domEvents.ts",
|
|
15
|
+
"./resolver": "./src/resolver.js",
|
|
16
|
+
"./tile-loader": "./src/tileLoader.js",
|
|
17
|
+
"./naming": "./src/naming.js"
|
|
18
|
+
},
|
|
19
|
+
"dependencies": {
|
|
20
|
+
"@mosetta/core": "0.0.9",
|
|
21
|
+
"@mosetta/di": "0.0.9"
|
|
22
|
+
},
|
|
23
|
+
"publishConfig": {
|
|
24
|
+
"access": "public"
|
|
25
|
+
},
|
|
26
|
+
"scripts": {
|
|
27
|
+
"typecheck": "tsc --noEmit"
|
|
28
|
+
}
|
|
6
29
|
}
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
// The component-facing surface of @mosetta/runtime (the package ROOT export): built into the
|
|
2
|
+
// dependency closure as a System artifact; asset imports resolve to `__asset(rel, import.meta.url)`.
|
|
3
|
+
// Default: resolve the co-located asset against the module's own URL (relocatable, no baked origin).
|
|
4
|
+
// Override app-wide via setAssetResolver (e.g. point assets at a CDN / a provider / transform).
|
|
5
|
+
let resolver = (rel, base) => new URL(rel, base).href;
|
|
6
|
+
|
|
7
|
+
export function setAssetResolver(fn) {
|
|
8
|
+
resolver = fn;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export function __asset(rel, base) {
|
|
12
|
+
return resolver(rel, base);
|
|
13
|
+
}
|
package/src/bootstrap.ts
ADDED
|
@@ -0,0 +1,363 @@
|
|
|
1
|
+
// mosetta bootstrap — the browser start of a page. Deliberately
|
|
2
|
+
// package-agnostic — it knows nothing about react/di/etc. It only: (1) installs the dependency
|
|
3
|
+
// resolve hook from the app node's version settings — an IDEMPOTENT prolog, because
|
|
4
|
+
// the loader artifact runs it too, (2) System.imports every module referenced by
|
|
5
|
+
// the embedded page tree — each from ITS OWN provider (multi-provider), and (3) RETURNS
|
|
6
|
+
// `{ rootEl, tree, holder }` — mounting is the caller's business (`@mosetta/react/start`
|
|
7
|
+
// for the react default; a custom script facet brings its own).
|
|
8
|
+
//
|
|
9
|
+
// The HOST DISTRIBUTIVE ROOT is data: the shell stamps `hostBase` into the pageContent envelope
|
|
10
|
+
// at render time (the STORED doc stays environment-agnostic). Absent → resolved relative to the
|
|
11
|
+
// page's own URL ('.'), which covers SSG/static hosting where the page sits at the provider root.
|
|
12
|
+
import { installResolver, versionedUrl } from './resolver.js';
|
|
13
|
+
import { createTileLoader } from './tileLoader.js';
|
|
14
|
+
|
|
15
|
+
const HOLDER = '__mosettaModules__';
|
|
16
|
+
const PAGE_BOOT = '__mosettaPageBoot__';
|
|
17
|
+
const STARTED = '__mosettaStarted__';
|
|
18
|
+
|
|
19
|
+
interface IPageNode {
|
|
20
|
+
reference?: string;
|
|
21
|
+
lazy?: boolean;
|
|
22
|
+
children?: IPageNode[];
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export interface ITileDescriptor {
|
|
26
|
+
id?: string;
|
|
27
|
+
assets?: string[];
|
|
28
|
+
dependencies?: string[];
|
|
29
|
+
/** SRI of the entity's main artifact — also its content address (versionedUrl) */
|
|
30
|
+
integrity?: string;
|
|
31
|
+
/** the key of the code source it lives with (the page's `providers` names its base); absent — the host */
|
|
32
|
+
provider?: string;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export interface IMosaicEnvelope extends IPageNode {
|
|
36
|
+
fallback?: string;
|
|
37
|
+
/** the page's code sources other than the host: key → public base (stamped by the shell) */
|
|
38
|
+
providers?: Record<string, string>;
|
|
39
|
+
settings?: { versions?: unknown };
|
|
40
|
+
// stamped by the SSR shell at render time; absent in SSG (relative hosting)
|
|
41
|
+
hostBase?: string;
|
|
42
|
+
// slice of the unified catalog: entities of the page's transitive closure — from it the
|
|
43
|
+
// runtime handles loading/css with the same structure the server shell uses
|
|
44
|
+
catalog?: Record<string, ITileDescriptor>;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export interface ILoadedTile {
|
|
48
|
+
reference: string;
|
|
49
|
+
module?: { Component?: unknown } & Record<string, unknown>;
|
|
50
|
+
error?: Error;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export interface ITilesHolder {
|
|
54
|
+
getModule(ref: string): ILoadedTile | undefined;
|
|
55
|
+
loadModule(ref: string, providerBase?: string): Promise<ILoadedTile>;
|
|
56
|
+
/**
|
|
57
|
+
* Make the realm ready for ANOTHER page's tree (a client-side move, ADR-0051): its catalog
|
|
58
|
+
* slice joins the realm's (content addresses and SRI for its artifacts, like the first page's),
|
|
59
|
+
* the css of its eager closure is injected and AWAITED, every eager module and the fallback are
|
|
60
|
+
* loaded. After it, swapping the tree in renders without a missing module.
|
|
61
|
+
*/
|
|
62
|
+
prepare(tree: IMosaicEnvelope): Promise<void>;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
export interface IStartResult {
|
|
66
|
+
rootEl: HTMLElement;
|
|
67
|
+
tree: IMosaicEnvelope;
|
|
68
|
+
holder: ITilesHolder;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
const withSlash = (u: string): string => (u.endsWith('/') ? u : u + '/');
|
|
72
|
+
|
|
73
|
+
/** a page's `providers` table (key → base) into the realm's, every base absolute */
|
|
74
|
+
function addSources(into: Record<string, string>, table: Record<string, string> | undefined, base: string): void {
|
|
75
|
+
for (const [key, at] of Object.entries(table ?? {})) {
|
|
76
|
+
if (typeof at === 'string' && at) into[key] = withSlash(new URL(at, base).href);
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* The resolver PROLOG: read the `#pageContent` envelope, derive the host base and
|
|
82
|
+
* install the version resolver on the global System. Idempotent — the loader artifact executes it
|
|
83
|
+
* before any entry resolves, and `start()` reuses the cached result: several script facets on one
|
|
84
|
+
* page bootstrap once. `#pageContent` sits ABOVE the loader in head, so it is always parsed by
|
|
85
|
+
* the time any caller runs.
|
|
86
|
+
*/
|
|
87
|
+
export function installResolverFromPage(): { tree: IMosaicEnvelope; base: string; catalog: Record<string, ITileDescriptor>; sources: Record<string, string> } {
|
|
88
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
89
|
+
const g = globalThis as any;
|
|
90
|
+
if (g[PAGE_BOOT]) return g[PAGE_BOOT];
|
|
91
|
+
const el = document.getElementById('pageContent');
|
|
92
|
+
if (!el) throw new Error('[mosetta] #pageContent not found');
|
|
93
|
+
const tree = JSON.parse(el.textContent ?? '') as IMosaicEnvelope;
|
|
94
|
+
|
|
95
|
+
// host distributive root: stamped by the shell (`hostBase`), or the page's own directory —
|
|
96
|
+
// SSG serves the page at the provider root, so '.' resolves to it (|| — an empty stamp means
|
|
97
|
+
// "relative hosting" too)
|
|
98
|
+
const base = withSlash(new URL(tree.hostBase || '.', location.href).href);
|
|
99
|
+
|
|
100
|
+
// version alignment (singleton/pin) is DATA from the app node's settings — not baked in here.
|
|
101
|
+
// Artifact urls are content-addressed by the page's catalog slice (see versionedUrl): the same
|
|
102
|
+
// hashes the shell put on its preload links, so a preload and its use are one address. The
|
|
103
|
+
// slice is the REALM's catalog from here on: a later page's slice joins it (prepare), and the
|
|
104
|
+
// resolver reads it live
|
|
105
|
+
const catalog: Record<string, ITileDescriptor> = { ...(tree.catalog ?? {}) };
|
|
106
|
+
// the page's code sources other than the host (ADR-0070): an entity of the catalog that names one
|
|
107
|
+
// is read from its base — blocks, packages and singletons alike; later pages add theirs (prepare)
|
|
108
|
+
const sources: Record<string, string> = {};
|
|
109
|
+
addSources(sources, tree.providers, base);
|
|
110
|
+
// the catalog's word on where an entity lives: the base of the source its entry names, the
|
|
111
|
+
// host's when the entry names none (a host package imported by another source's block lies with
|
|
112
|
+
// the host — a tile palette is built over the host's tiles). An id the catalog does not know —
|
|
113
|
+
// undefined: the resolver co-locates it with its importer
|
|
114
|
+
const baseOf = (id: string): string | undefined => {
|
|
115
|
+
const entry = catalog[id];
|
|
116
|
+
if (!entry) return undefined;
|
|
117
|
+
return entry.provider ? sources[entry.provider] : base;
|
|
118
|
+
};
|
|
119
|
+
installResolver(g.System, { base, config: tree.settings && tree.settings.versions, integrityOf: (id: string) => catalog[id]?.integrity, baseOf });
|
|
120
|
+
g[PAGE_BOOT] = { tree, base, catalog, sources };
|
|
121
|
+
return g[PAGE_BOOT];
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
// Walk the tree → fill `acc` Map(reference → base url to ask from) for ALL nodes (so the holder can
|
|
125
|
+
// load any of them later), and `lazy` Set of refs that should NOT be preloaded. The page never says
|
|
126
|
+
// where its code lives: the base asked from is the host's, and the catalog names another source's
|
|
127
|
+
// (sourceBaseOf). `lazy: true` on a node marks it and its whole subtree as load-on-demand. Deduped by
|
|
128
|
+
// reference (first occurrence wins).
|
|
129
|
+
function collect(node: IPageNode, base: string, acc: Map<string, string>, lazy: Set<string>, inheritedLazy: boolean): void {
|
|
130
|
+
if (!node || !node.reference) return;
|
|
131
|
+
const nodeLazy = inheritedLazy || node.lazy === true;
|
|
132
|
+
if (!acc.has(node.reference)) {
|
|
133
|
+
acc.set(node.reference, base);
|
|
134
|
+
if (nodeLazy) lazy.add(node.reference);
|
|
135
|
+
}
|
|
136
|
+
for (const child of node.children || []) collect(child, base, acc, lazy, nodeLazy);
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
async function run(): Promise<IStartResult> {
|
|
140
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
141
|
+
const System = (globalThis as any).System;
|
|
142
|
+
const { tree, base, catalog: realmCatalog, sources } = installResolverFromPage();
|
|
143
|
+
|
|
144
|
+
// every reference the realm has seen → its provider base; lazy refs are not preloaded
|
|
145
|
+
const refBases = new Map<string, string>();
|
|
146
|
+
|
|
147
|
+
// Unified catalog: the page-closure slice arrives IN THE ENVELOPE (#pageContent) — there are
|
|
148
|
+
// no manifest fetches anymore. Entities outside the slice (editor insertion, lazy from a
|
|
149
|
+
// foreign provider) are fetched on demand via the co-located `<id>.json` descriptor
|
|
150
|
+
// (immutable, cacheable) with recursion over its edges. Css injection is part of the module
|
|
151
|
+
// LOAD path.
|
|
152
|
+
const catalog = new Map<string, ITileDescriptor | null>(Object.entries(realmCatalog));
|
|
153
|
+
const DEP_PREFIX = '_ns_package_t_package_n_';
|
|
154
|
+
// an entity lives with the code source its catalog entry names, the host when it names none; an
|
|
155
|
+
// entity outside the catalog — with the base it was asked from
|
|
156
|
+
const sourceBaseOf = (id: string, pb: string): string => {
|
|
157
|
+
const entry = catalog.get(id) ?? realmCatalog[id];
|
|
158
|
+
if (!entry) return pb;
|
|
159
|
+
return (entry.provider ? sources[entry.provider] : base) || pb;
|
|
160
|
+
};
|
|
161
|
+
const entityFileUrl = (id: string, file: string, pb: string): string => new URL('modules/' + id + '/' + file, sourceBaseOf(id, pb)).href; // flat layout
|
|
162
|
+
// an artifact of the realm's catalog is addressed by its content, exactly as the shell addressed
|
|
163
|
+
// it; an entity outside it (an editor's insertion) keeps its plain address — one url per
|
|
164
|
+
// entity in a realm, whichever way it is reached
|
|
165
|
+
const sliceIntegrity = (id: string): string | undefined => realmCatalog[id]?.integrity;
|
|
166
|
+
const artifactUrl = (id: string, file: string, pb: string): string => versionedUrl(entityFileUrl(id, file, pb), sliceIntegrity(id));
|
|
167
|
+
const fetched = new Map<string, Promise<ITileDescriptor | null>>();
|
|
168
|
+
const entityOf = (id: string, pb: string): Promise<ITileDescriptor | null> => {
|
|
169
|
+
if (catalog.has(id)) return Promise.resolve(catalog.get(id) ?? null);
|
|
170
|
+
if (!fetched.has(id)) {
|
|
171
|
+
fetched.set(id, fetch(entityFileUrl(id, id + '.json', pb))
|
|
172
|
+
.then((r) => (r.ok ? (r.json() as Promise<ITileDescriptor>) : null))
|
|
173
|
+
.catch(() => null));
|
|
174
|
+
}
|
|
175
|
+
return fetched.get(id)!;
|
|
176
|
+
};
|
|
177
|
+
// transitive closure of an entity over descriptor edges (BFS, id → entity|null and the base it
|
|
178
|
+
// was asked from); a dependency the catalog does not place is asked from where its importer lies
|
|
179
|
+
const closureOf = async (root: string, pb: string): Promise<Map<string, { entity: ITileDescriptor | null; at: string }>> => {
|
|
180
|
+
const seen = new Map<string, { entity: ITileDescriptor | null; at: string }>();
|
|
181
|
+
const queue: Array<[string, string]> = [[root, pb]];
|
|
182
|
+
while (queue.length) {
|
|
183
|
+
const [id, at] = queue.shift()!;
|
|
184
|
+
if (seen.has(id)) continue;
|
|
185
|
+
const entity = await entityOf(id, at);
|
|
186
|
+
seen.set(id, { entity, at });
|
|
187
|
+
for (const dep of entity?.dependencies ?? []) queue.push([dep, sourceBaseOf(id, at)]);
|
|
188
|
+
}
|
|
189
|
+
return seen;
|
|
190
|
+
};
|
|
191
|
+
// A stylesheet IN PLACE: applied, or given up on. A module must not render before its css is —
|
|
192
|
+
// the shell declares the first page's (render-blocking in the head), but a module loaded later
|
|
193
|
+
// (the editor's tree and canvas, a hosted form, a lazy block) rendered raw for as long as its css
|
|
194
|
+
// took to arrive: unstyled buttons, icons blown up to the width of the screen. A sheet that
|
|
195
|
+
// fails, or never answers, does not hold the page hostage — it is given up on after a while.
|
|
196
|
+
const STYLESHEET_PATIENCE_MS = 5000;
|
|
197
|
+
const sheetReady = (link: HTMLLinkElement): Promise<void> => {
|
|
198
|
+
if (link.sheet) return Promise.resolve(); // applied already (a cross-origin sheet still has one)
|
|
199
|
+
return new Promise<void>((resolve) => {
|
|
200
|
+
let timer: ReturnType<typeof setTimeout> | undefined;
|
|
201
|
+
const done = (): void => {
|
|
202
|
+
clearTimeout(timer);
|
|
203
|
+
link.removeEventListener('load', done);
|
|
204
|
+
link.removeEventListener('error', done);
|
|
205
|
+
resolve();
|
|
206
|
+
};
|
|
207
|
+
timer = setTimeout(done, STYLESHEET_PATIENCE_MS);
|
|
208
|
+
link.addEventListener('load', done);
|
|
209
|
+
link.addEventListener('error', done);
|
|
210
|
+
});
|
|
211
|
+
};
|
|
212
|
+
// the SHELL declares the css of the whole non-lazy tree — compare by the
|
|
213
|
+
// RESOLVED url (the DOM `href` property is absolute even for relative SSG attributes). A sheet the
|
|
214
|
+
// shell declared is not waited for: it was render-blocking in the head, so it is applied — or it
|
|
215
|
+
// failed, and a failed one never says so again (waiting would hold every boot for the patience).
|
|
216
|
+
// A sheet THIS loader put in is waited for by everyone who needs it.
|
|
217
|
+
const injected = new Map<string, Promise<void>>();
|
|
218
|
+
const injectCssOnce = (href: string): Promise<void> => {
|
|
219
|
+
const ours = injected.get(href);
|
|
220
|
+
if (ours) return ours;
|
|
221
|
+
for (const existing of document.querySelectorAll('link[rel="stylesheet"]')) {
|
|
222
|
+
if ((existing as HTMLLinkElement).href === href) return Promise.resolve();
|
|
223
|
+
}
|
|
224
|
+
const link = document.createElement('link');
|
|
225
|
+
link.rel = 'stylesheet';
|
|
226
|
+
link.href = href;
|
|
227
|
+
const ready = sheetReady(link);
|
|
228
|
+
injected.set(href, ready);
|
|
229
|
+
document.head.appendChild(link);
|
|
230
|
+
return ready;
|
|
231
|
+
};
|
|
232
|
+
// one promise per module: whoever asks again waits for the same sheets
|
|
233
|
+
const cssReady = new Map<string, Promise<void>>();
|
|
234
|
+
const ensureCss = (ref: string, pb: string): Promise<void> => {
|
|
235
|
+
const known = cssReady.get(ref);
|
|
236
|
+
if (known) return known;
|
|
237
|
+
const ready = (async () => {
|
|
238
|
+
const closure = await closureOf(ref, pb);
|
|
239
|
+
// cascade order: dependency-entity css before the module's own (the module overrides) —
|
|
240
|
+
// the links go into the head in that order, and are waited for together
|
|
241
|
+
const sheets: Promise<void>[] = [];
|
|
242
|
+
const inject = (pkgLayer: boolean): void => {
|
|
243
|
+
for (const [id, { entity, at }] of closure) {
|
|
244
|
+
if (id.startsWith(DEP_PREFIX) !== pkgLayer) continue;
|
|
245
|
+
for (const a of entity?.assets ?? []) {
|
|
246
|
+
if (a.endsWith('.css')) sheets.push(injectCssOnce(artifactUrl(id, a, at)));
|
|
247
|
+
}
|
|
248
|
+
}
|
|
249
|
+
};
|
|
250
|
+
inject(true);
|
|
251
|
+
inject(false);
|
|
252
|
+
await Promise.all(sheets);
|
|
253
|
+
})().catch(() => undefined); // a closure that cannot be read styles nothing — the module still loads
|
|
254
|
+
cssReady.set(ref, ready);
|
|
255
|
+
return ready;
|
|
256
|
+
};
|
|
257
|
+
// load every referenced module from ITS provider via the SAFE loader. load() never rejects, so
|
|
258
|
+
// Promise.all can't blow up — a failed module lands as { error } inside its ILoadedTile and is isolated
|
|
259
|
+
// later by the React error boundary (→ fallback for that subtree only).
|
|
260
|
+
const loader = createTileLoader(System, (ref: string, pb: string) => artifactUrl(ref, ref + '.js', pb));
|
|
261
|
+
// What the React layer may see: a module HANDED OVER — loaded, with its stylesheets in place. The
|
|
262
|
+
// loader holds a module the moment its code is in, and a render in between (a Suspense boundary
|
|
263
|
+
// retried because a neighbour's sheets arrived first) found it there and drew it raw: the
|
|
264
|
+
// editor's canvas, with its icons the width of the screen, while its sheet was still on the way.
|
|
265
|
+
const handedOver = new Set<string>();
|
|
266
|
+
|
|
267
|
+
// A page's tree made ready in this realm: the first one at boot, every later one on a
|
|
268
|
+
// client-side move (ADR-0051). Its catalog slice joins the realm's; the css of its eager
|
|
269
|
+
// closure is injected and awaited (the shell declared the first page's — a later page's must
|
|
270
|
+
// be in place before its nodes render); every eager module and the fallback are loaded, a
|
|
271
|
+
// module that failed before is tried again. Lazy nodes load on demand (useTile → Suspense).
|
|
272
|
+
const prepare = async (page: IMosaicEnvelope): Promise<void> => {
|
|
273
|
+
addSources(sources, page.providers, base);
|
|
274
|
+
for (const [id, e] of Object.entries(page.catalog ?? {})) {
|
|
275
|
+
realmCatalog[id] = e;
|
|
276
|
+
catalog.set(id, e);
|
|
277
|
+
}
|
|
278
|
+
const bases = new Map<string, string>();
|
|
279
|
+
const lazy = new Set<string>();
|
|
280
|
+
collect(page, base, bases, lazy, false);
|
|
281
|
+
if (page.fallback && !bases.has(page.fallback)) bases.set(page.fallback, base);
|
|
282
|
+
for (const [ref, pb] of bases) if (!refBases.has(ref)) refBases.set(ref, pb);
|
|
283
|
+
const eager = [...bases].filter(([ref]) => !lazy.has(ref));
|
|
284
|
+
await Promise.all(eager.map(([ref, pb]) => ensureCss(ref, pb)));
|
|
285
|
+
for (const [ref] of eager) loader.forget(ref);
|
|
286
|
+
await Promise.all(eager.map(([ref, pb]) => loader.load(ref, pb)));
|
|
287
|
+
for (const [ref] of eager) handedOver.add(ref); // their css was awaited above
|
|
288
|
+
};
|
|
289
|
+
await prepare(tree);
|
|
290
|
+
|
|
291
|
+
// holder for the React layer — same ILoadedTile shape; loadModule enables later dynamic/lazy loads.
|
|
292
|
+
// A module loaded so is READY only with its stylesheets in place: the code and the css are
|
|
293
|
+
// fetched side by side, and the module is handed over once both are — getModule does not show
|
|
294
|
+
// it before (its Suspense boundary, or whoever awaits it, waits that long — never renders it
|
|
295
|
+
// raw). One promise per module while it is on its way: Suspense gets the same one every render.
|
|
296
|
+
const providerOf = (ref: string): string => refBases.get(ref) || base;
|
|
297
|
+
const handing = new Map<string, Promise<ILoadedTile>>();
|
|
298
|
+
const holder: ITilesHolder = {
|
|
299
|
+
getModule: (ref: string): ILoadedTile | undefined => (handedOver.has(ref) ? loader.get(ref) : undefined),
|
|
300
|
+
loadModule: (ref: string, pb?: string): Promise<ILoadedTile> => {
|
|
301
|
+
const ready = handedOver.has(ref) ? loader.get(ref) : undefined;
|
|
302
|
+
if (ready) return Promise.resolve(ready);
|
|
303
|
+
const pending = handing.get(ref);
|
|
304
|
+
if (pending) return pending;
|
|
305
|
+
const target = pb || providerOf(ref);
|
|
306
|
+
const handover = Promise.all([ensureCss(ref, target), loader.load(ref, target)]).then(([, loaded]) => {
|
|
307
|
+
handedOver.add(ref);
|
|
308
|
+
handing.delete(ref);
|
|
309
|
+
return loaded;
|
|
310
|
+
});
|
|
311
|
+
handing.set(ref, handover);
|
|
312
|
+
return handover;
|
|
313
|
+
},
|
|
314
|
+
prepare,
|
|
315
|
+
};
|
|
316
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
317
|
+
(globalThis as any)[HOLDER] = holder;
|
|
318
|
+
|
|
319
|
+
// the root module is CRITICAL — its failure is fatal (nothing to mount); content failures stay
|
|
320
|
+
// isolated. Its SHAPE (the Component export) is the mounting caller's contract, not ours.
|
|
321
|
+
const rootModule = tree.reference ? holder.getModule(tree.reference) : undefined;
|
|
322
|
+
if (!rootModule || rootModule.error || !rootModule.module) {
|
|
323
|
+
throw new Error('[mosetta] root module failed: '
|
|
324
|
+
+ (rootModule && rootModule.error ? rootModule.error.message : 'missing from the page tree'));
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
// #root sits at the END of body — a script-facet entry may execute before the parser got there
|
|
328
|
+
// in exotic hosts (a spike showed DOMContentLoaded-timing in browsers); the
|
|
329
|
+
// module preloads above already ran in parallel with parsing, only the mount target waits.
|
|
330
|
+
if (document.readyState === 'loading') {
|
|
331
|
+
await new Promise((r) => document.addEventListener('DOMContentLoaded', r, { once: true }));
|
|
332
|
+
}
|
|
333
|
+
const rootEl = document.getElementById('root');
|
|
334
|
+
if (!rootEl) throw new Error('[mosetta] #root not found');
|
|
335
|
+
rootEl.setAttribute('data-modules-holder', HOLDER);
|
|
336
|
+
|
|
337
|
+
return { rootEl, tree, holder };
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
// Fatal errors are handled HERE (log + visible message) so callers stay one-liners; a custom
|
|
341
|
+
// script facet wanting different handling wraps start() itself.
|
|
342
|
+
function reportFatal(e: unknown): void {
|
|
343
|
+
console.error('[mosetta] runtime failed', e);
|
|
344
|
+
const root = document.getElementById('root');
|
|
345
|
+
if (root) root.textContent = '[mosetta] runtime failed: ' + String((e as Error)?.message ?? e);
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
/**
|
|
349
|
+
* Boot the page: resolver prolog, tree module preloads, css, holder. Resolves to
|
|
350
|
+
* `{ rootEl, tree, holder }` for the mounting caller, or `null` after a reported fatal.
|
|
351
|
+
* Idempotent — several script facets on one page share a single boot.
|
|
352
|
+
*/
|
|
353
|
+
export function start(): Promise<IStartResult | null> {
|
|
354
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
355
|
+
const g = globalThis as any;
|
|
356
|
+
if (!g[STARTED]) {
|
|
357
|
+
g[STARTED] = run().catch((e: unknown) => {
|
|
358
|
+
reportFatal(e);
|
|
359
|
+
return null;
|
|
360
|
+
});
|
|
361
|
+
}
|
|
362
|
+
return g[STARTED];
|
|
363
|
+
}
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
import { DiContainer } from '@mosetta/di';
|
|
2
|
+
import type { IBlueprintNode, IBlueprint } from '@mosetta/core';
|
|
3
|
+
import { DefaultPageManager, PAGE_MANAGER_TOKEN } from './pageManager';
|
|
4
|
+
import { MemoryRefViews, REF_VIEWS_TOKEN } from './refs';
|
|
5
|
+
import {
|
|
6
|
+
FULL_APP_PREVIEW,
|
|
7
|
+
ITilesHolder,
|
|
8
|
+
IMountPreview,
|
|
9
|
+
TILES_HOLDER_TOKEN,
|
|
10
|
+
PARENT_DI_TOKEN,
|
|
11
|
+
PREVIEW_TYPE_TOKEN,
|
|
12
|
+
SSR_RENDER_CHANNEL_TOKEN,
|
|
13
|
+
} from './tokens';
|
|
14
|
+
import { DomHeadWriter, HEAD_WRITER_TOKEN, IHeadWriter, ISsrRenderChannel } from './headWriter';
|
|
15
|
+
import { browserRouterHost, ClientRouter, InertRouter, ROUTER_TOKEN } from './router';
|
|
16
|
+
|
|
17
|
+
// The WELL-KNOWN global the modules holder lives under. The browser bootstrap
|
|
18
|
+
// (@mosetta/runtime) publishes it, the server ssr module publishes its isolate twin — the app
|
|
19
|
+
// container reads it by name in both worlds (an app Component renders on the server too, so it
|
|
20
|
+
// cannot go through DOM attributes; SSR epic).
|
|
21
|
+
export const MODULES_HOLDER_GLOBAL = '__mosettaModules__';
|
|
22
|
+
|
|
23
|
+
interface IBrowserModulesHolder extends Record<string, ITilesHolder> {
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
// read the well-known holder global by name — works in the browser AND in the ssr isolate
|
|
27
|
+
function extractClientModulesHolder(name: string): ITilesHolder {
|
|
28
|
+
const globalHolder = globalThis as unknown as IBrowserModulesHolder;
|
|
29
|
+
|
|
30
|
+
return globalHolder[name];
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export interface ICreateAppContainerOptions {
|
|
34
|
+
moduleTreeNode: IBlueprintNode;
|
|
35
|
+
/** the container of the application that MOUNTED this one (IMountOptions.parentDi) */
|
|
36
|
+
parentDi?: DiContainer;
|
|
37
|
+
/** the host's word that this application is a specimen (IMountOptions.preview) */
|
|
38
|
+
preview?: IMountPreview;
|
|
39
|
+
// present ONLY during a server render — the ssr module threads its per-request head channel
|
|
40
|
+
// through the Component prop; pass the WHOLE props object so it rides along
|
|
41
|
+
ssrChannel?: ISsrRenderChannel;
|
|
42
|
+
/**
|
|
43
|
+
* Where the tree's head writes go, when the default is wrong. A page writes into its own
|
|
44
|
+
* document; a HOST that renders someone else's module inside ITS document (the editor
|
|
45
|
+
* previewing a block) must not let those writes reach its own head — see NullHeadWriter.
|
|
46
|
+
*/
|
|
47
|
+
headWriter?: IHeadWriter;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
// The BASE registrations every app root needs (modules holder + page manager). Created INSIDE the
|
|
51
|
+
// app's Component via `useState(() => createAppContainer(...))` — a fresh container per mount,
|
|
52
|
+
// which on the server means per request/render: nothing leaks between requests of a cached
|
|
53
|
+
// isolate. App-specific services (e.g. registerServerActionClient) are added by the app right
|
|
54
|
+
// after this call, in the same initializer. MUST stay pure: React StrictMode double-invokes
|
|
55
|
+
// useState initializers in dev.
|
|
56
|
+
export function createAppContainer({ moduleTreeNode, ssrChannel, headWriter, parentDi, preview }: ICreateAppContainerOptions): DiContainer {
|
|
57
|
+
const di = new DiContainer();
|
|
58
|
+
di.registerValueOfToken(TILES_HOLDER_TOKEN, extractClientModulesHolder(MODULES_HOLDER_GLOBAL));
|
|
59
|
+
// page manager → usePage()/the fallback path (ComponentErrorHandler reads page.fallback)
|
|
60
|
+
di.registerValueOfToken(PAGE_MANAGER_TOKEN, new DefaultPageManager(moduleTreeNode as IBlueprint));
|
|
61
|
+
// head writing: what the host asked for, else the server channel when rendering server-side,
|
|
62
|
+
// else the DOM twin
|
|
63
|
+
di.registerValueOfToken(HEAD_WRITER_TOKEN, headWriter ?? ssrChannel ?? new DomHeadWriter());
|
|
64
|
+
// the channel as plain DATA — lazily-wired slices read it to pick their env
|
|
65
|
+
di.registerValueOfToken(SSR_RENDER_CHANNEL_TOKEN, ssrChannel ?? null);
|
|
66
|
+
// the parent application's container, whole (the Unity-editor door; null for a page's own boot)
|
|
67
|
+
di.registerValueOfToken(PARENT_DI_TOKEN, parentDi ?? null);
|
|
68
|
+
// the editor's views of reference providers — empty unless an editor's bridge fills them
|
|
69
|
+
di.registerValueOfToken(REF_VIEWS_TOKEN, new MemoryRefViews());
|
|
70
|
+
// previewness is a container fact (PREVIEW_TYPE_TOKEN): the mount option wins — it speaks
|
|
71
|
+
// about THIS mounted specimen — the realm mark covers a whole preview iframe, the rest is live
|
|
72
|
+
const realmPreview = typeof window !== 'undefined'
|
|
73
|
+
&& (window as { __mosettaPreview?: boolean }).__mosettaPreview === true;
|
|
74
|
+
di.registerValueOfToken(PREVIEW_TYPE_TOKEN, preview?.type ?? (realmPreview ? FULL_APP_PREVIEW : null));
|
|
75
|
+
// the facet-declared environment of a specimen: services that arrange the component's world
|
|
76
|
+
if (preview?.services?.length) di.registerServices(...preview.services);
|
|
77
|
+
// the client router (ADR-0051): alive for the page's OWN boot in a browser — not on the server,
|
|
78
|
+
// not in a preview realm (the agent lives in the tree, a move would unmount it), not in a
|
|
79
|
+
// hosted mount (a panel is not the page). Listening starts when the root mounts (attach)
|
|
80
|
+
const ownBoot = typeof window !== 'undefined' && !ssrChannel && !parentDi && !preview && !realmPreview;
|
|
81
|
+
const holder = di.getByToken(TILES_HOLDER_TOKEN);
|
|
82
|
+
di.registerValueOfToken(ROUTER_TOKEN, ownBoot && holder?.prepare
|
|
83
|
+
? new ClientRouter(browserRouterHost(), di.getByToken(PAGE_MANAGER_TOKEN), holder, di.getByToken(HEAD_WRITER_TOKEN))
|
|
84
|
+
: new InertRouter());
|
|
85
|
+
return di;
|
|
86
|
+
}
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
// browser-protocol layer (framework-agnostic half of the former @mosetta/client).
|
|
2
|
+
// Lived in @mosetta/core until 2026-07: DI tokens belong next to the runtime that publishes
|
|
3
|
+
// their values — moving them here broke the core↔di dependency cycle (core keeps pure models).
|
|
4
|
+
export * from './tile';
|
|
5
|
+
export * from './tokens';
|
|
6
|
+
export * from './pageManager';
|
|
7
|
+
export * from './headWriter';
|
|
8
|
+
export * from './appContainer';
|
|
9
|
+
export * from './serverRender';
|
|
10
|
+
export * from './getIsClient';
|
|
11
|
+
export * from './refs';
|
|
12
|
+
export * from './router';
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
import { SingleDiToken } from '@mosetta/di';
|
|
2
|
+
|
|
3
|
+
// The head-writing channel — ONE API, two worlds:
|
|
4
|
+
// - during a server render the app receives the ssr module's per-request HeadChannel as the
|
|
5
|
+
// `ssrChannel` prop: writes are collected and shipped by HeadTags once the body settles;
|
|
6
|
+
// - in the browser there is no channel prop and writes upsert document.head directly (head is
|
|
7
|
+
// NOT part of the hydrated tree — hydrateRoot holds #root only, so no mismatch is possible).
|
|
8
|
+
|
|
9
|
+
export interface IHeadWriter {
|
|
10
|
+
/** key = tag identity: dedup, last write wins; `html` is RAW inner html — the CALLER escapes */
|
|
11
|
+
addToHead(key: string, tag: string, attributes: Record<string, string>, html?: string): void;
|
|
12
|
+
/** the tag under this key leaves the head — a client-side move sweeps what the next page has no use for */
|
|
13
|
+
removeFromHead?(key: string): void;
|
|
14
|
+
/** every key written so far (a render channel has no use for this; the DOM twin answers) */
|
|
15
|
+
headKeys?(): string[];
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
// what the server threads through the Component prop (the ssr module wraps its per-request
|
|
19
|
+
// HeadChannel); the action counter and requestOrigin feed the server-actions slice
|
|
20
|
+
export interface ISsrRenderChannel extends IHeadWriter {
|
|
21
|
+
beginAction(): void;
|
|
22
|
+
completeAction(): void;
|
|
23
|
+
/**
|
|
24
|
+
* What of a node's settings ships to the browser in the page envelope (useShippedSettings).
|
|
25
|
+
* Keyed by node id, last write wins — a component renders more than once while actions
|
|
26
|
+
* resolve. Optional: an older engine simply ships every node as it is.
|
|
27
|
+
*/
|
|
28
|
+
shipSettings?(nodeId: string, settings: Record<string, unknown>): void;
|
|
29
|
+
// origin of the ORIGINAL request (the runner) — server-executed actions target the same
|
|
30
|
+
// HTTP endpoint the browser uses, but the isolate needs it absolute
|
|
31
|
+
readonly requestOrigin?: string;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export const HEAD_WRITER_TOKEN = new SingleDiToken<IHeadWriter>('HEAD_WRITER_TOKEN');
|
|
35
|
+
|
|
36
|
+
// the server HeadTags render stamps the SAME key attribute on its tags, so a client re-write
|
|
37
|
+
// REPLACES the server-rendered tag instead of duplicating it
|
|
38
|
+
export const HEAD_KEY_ATTRIBUTE = 'data-mosetta-head-key';
|
|
39
|
+
|
|
40
|
+
// Well-known FRAMEWORK head keys — module writes through useAddToHead are IGNORED (+warn).
|
|
41
|
+
// The action cache script is written by the ssr recorder and read by hydration seeding; a
|
|
42
|
+
// module upsert under the same key would clobber the shipped cache.
|
|
43
|
+
export const ACTION_CACHE_HEAD_KEY = 'mosetta-action-cache';
|
|
44
|
+
export const RESERVED_HEAD_KEYS: ReadonlySet<string> = new Set([ACTION_CACHE_HEAD_KEY]);
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Writes nothing. For a host that renders SOMEONE ELSE'S module inside its own document — the
|
|
48
|
+
* editor previewing a block from the catalog — where a `useAddToHead` in that module would
|
|
49
|
+
* otherwise retitle the host's page or overwrite its meta.
|
|
50
|
+
*/
|
|
51
|
+
export class NullHeadWriter implements IHeadWriter {
|
|
52
|
+
addToHead(): void {
|
|
53
|
+
// deliberate: the module renders, its head writes are dropped
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export class DomHeadWriter implements IHeadWriter {
|
|
58
|
+
addToHead(key: string, tag: string, attributes: Record<string, string>, html?: string): void {
|
|
59
|
+
if (typeof document === 'undefined') return;
|
|
60
|
+
const next = document.createElement(tag);
|
|
61
|
+
next.setAttribute(HEAD_KEY_ATTRIBUTE, key);
|
|
62
|
+
for (const [name, value] of Object.entries(attributes)) next.setAttribute(name, value);
|
|
63
|
+
if (html !== undefined) next.innerHTML = html;
|
|
64
|
+
const existing = this.find(key);
|
|
65
|
+
if (existing) existing.replaceWith(next);
|
|
66
|
+
else document.head.appendChild(next);
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
removeFromHead(key: string): void {
|
|
70
|
+
this.find(key)?.remove();
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
headKeys(): string[] {
|
|
74
|
+
if (typeof document === 'undefined') return [];
|
|
75
|
+
return Array.from(document.head.querySelectorAll(`[${HEAD_KEY_ATTRIBUTE}]`))
|
|
76
|
+
.map((el) => el.getAttribute(HEAD_KEY_ATTRIBUTE))
|
|
77
|
+
.filter((key): key is string => key !== null);
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
private find(key: string): Element | undefined {
|
|
81
|
+
if (typeof document === 'undefined') return undefined;
|
|
82
|
+
return Array.from(document.head.querySelectorAll(`[${HEAD_KEY_ATTRIBUTE}]`))
|
|
83
|
+
.find((el) => el.getAttribute(HEAD_KEY_ATTRIBUTE) === key);
|
|
84
|
+
}
|
|
85
|
+
}
|