@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 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
- # Temporary Holding Version
1
+ # @mosetta/runtime
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
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.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
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
+ }
@@ -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,3 @@
1
+ export function getIsClient() {
2
+ return typeof window !== 'undefined';
3
+ }
@@ -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
+ }