@mosetta/build 0.0.0-stage → 0.0.10

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/src/build.mjs ADDED
@@ -0,0 +1,576 @@
1
+ import { crypto, path } from './nodeApi.mjs';
2
+ import { packageDirOf } from './packageMeta.mjs';
3
+ import { discoverRoots, discoverModulesIn, discoverServerModulesIn, discoverRouterModulesIn, discoverPrefabsIn, discoverPagesIn, readConfig } from './discovery.mjs';
4
+ import { buildModule, buildLoaderProlog } from './buildModules.mjs';
5
+ import { fullTileName } from './moduleName.mjs';
6
+ import { buildServerModule, buildSsrModule } from './buildServer.mjs';
7
+ import { buildDependency } from './buildDependencies.mjs';
8
+ import { resolveSpecifier, dependencyFullId } from './specifiers.mjs';
9
+ import { cjsNamedExports } from './cjsExports.mjs';
10
+ import { processCss } from './css.mjs';
11
+ import { INERT_BUILD_CONFIG } from './buildConfig.mjs';
12
+ import { formatBytes } from './format.mjs';
13
+ import { mtimeStamp } from './artifactCache.mjs';
14
+ import { describeSettings, SETTINGS_SUFFIX } from './settings.mjs';
15
+
16
+ // entry barrel `.`: exposes the build itself and the size formatter for the host's report
17
+ export { formatBytes };
18
+ export { INERT_BUILD_CONFIG } from './buildConfig.mjs';
19
+
20
+ // core + the named-register extra: artifacts are NAMED System.register('<full-id>', …) bundles
21
+ // (SSR epic stage 1) — the extra links a URL load to its name (one instance both ways).
22
+ // Read THROUGH THE PORT and lazily: importing the build module must not touch the disk by itself.
23
+ let systemjsCache = null;
24
+ function readSystemjs(fileSystem, resolver) {
25
+ if (!systemjsCache) {
26
+ systemjsCache = `${fileSystem.readFile(resolver.resolveFramework('systemjs/dist/system.js'), 'utf8')}\n${
27
+ fileSystem.readFile(resolver.resolveFramework('systemjs/dist/extras/named-register.js'), 'utf8')}`;
28
+ }
29
+ return systemjsCache;
30
+ }
31
+
32
+ // Distributive layout: the in-memory FS (and any disk mirror) is split into two top-level
33
+ // namespaces so nothing leaks across the trust boundary on deploy:
34
+ // public/ — client-facing static (modules + co-located loaders, deps, prefabs, pages, manifests).
35
+ // Its CONTENTS are what a CDN serves at its root; the dev provider serves it at `/`.
36
+ // private/ — server-only artifacts (server-action monoliths, later server info). Never public;
37
+ // the render worker reaches it via the private channel (dev `/private`, prod S3).
38
+ // The internal structure inside `public/` is unchanged — only the top-level folder is new.
39
+ const PUB = 'public';
40
+
41
+ // Snapshot of the artifact tree — IN MEMORY ONLY. Disk mirroring is deliberately ABSENT here:
42
+ // the build writes nothing, the snapshot is returned by value, and what to do with it is the
43
+ // caller's decision. This is not tidiness but a property: the port stays read-only, and the
44
+ // build isolate has no write capability at all.
45
+ class OutputFS {
46
+ constructor() {
47
+ this.files = new Map(); // relPath -> content
48
+ }
49
+ set(rel, content) {
50
+ this.files.set(rel, content);
51
+ }
52
+ get(rel) {
53
+ return this.files.get(rel);
54
+ }
55
+ }
56
+
57
+ /**
58
+ * Full build of a distributive into an in-memory artifact-tree snapshot.
59
+ * Returns { fs, prefabs, pages, watchFiles, watchDirs, report }.
60
+ *
61
+ * The function READS through the port and mutates nothing: no writes, no execution
62
+ * (`mosetta.build.mjs` arrives already loaded — `buildConfig`; the blocks' settings schemas are run
63
+ * by `evaluate`, the host's, when it gives one). `fileSystem`, `engine`, `log`, `frameworkDir`
64
+ * have no defaults on purpose: the environment binding is supplied from outside, otherwise a
65
+ * default would quietly pull node back into portable code.
66
+ *
67
+ * `released` — the ids already in the catalog the build goes into (a platform builds into its
68
+ * own): a library root whose distribution is there and a published package that is there are not
69
+ * built again. Absent — everything is built, as a local build does.
70
+ */
71
+ export async function build(root, { mode = 'development', log, fileSystem, resolver, engine, frameworkDir, buildConfig = INERT_BUILD_CONFIG, cache = null, evaluate = null, released = null }) {
72
+ const out = new OutputFS();
73
+ const watchFiles = new Set();
74
+ const addWatch = (arr) => arr.forEach((f) => watchFiles.add(f));
75
+ // The cross-iteration artifact cache (`artifactCache.mjs`) is an ENVIRONMENT concern, like the
76
+ // port and the engine: the long-lived dev server passes one, a one-shot prod build passes
77
+ // nothing and builds everything.
78
+ const memo = cache
79
+ ? (key, produce, filesOf) => cache.memo(key, produce, filesOf)
80
+ : (key, produce) => produce();
81
+
82
+ // Sourcemap rule: a JS artifact's map lives in the PRIVATE counterpart of its store
83
+ // at the same relative path (public/ → private/, content-public/ → content-private/); the public
84
+ // js carries NO sourceMappingURL — the pairing is convention, the dev server surfaces it via the
85
+ // SourceMap response header. Artifacts already in a private zone keep the map NEXT to them, with
86
+ // the co-located comment (nothing leaks — the whole zone is behind the trust boundary).
87
+ const MAP_ZONES = [['public/', 'private/'], ['content-public/', 'content-private/']];
88
+ // the bundlers stamp their own chunk-named comment — normalize it away, writeJs owns the linkage
89
+ const stripMapComment = (code) => code.replace(/\n?\/\/# sourceMappingURL=\S*\s*$/, '\n');
90
+ const writeJs = (rel, code, map) => {
91
+ if (!map) {
92
+ out.set(rel, code);
93
+ return;
94
+ }
95
+ const zone = MAP_ZONES.find(([pub]) => rel.startsWith(pub));
96
+ if (zone) {
97
+ out.set(rel, stripMapComment(code));
98
+ out.set(`${zone[1]}${rel.slice(zone[0].length)}.map`, map);
99
+ } else {
100
+ out.set(rel, `${stripMapComment(code)}//# sourceMappingURL=${path.basename(rel)}.map\n`);
101
+ out.set(`${rel}.map`, map);
102
+ }
103
+ };
104
+
105
+ // SRI hash of the artifact's SERVED bytes: computed after emission (writeJs strips the map
106
+ // comment for the public zone — we hash exactly what goes over the wire)
107
+ const sriOf = (rel) => `sha256-${crypto.createHash('sha256').update(out.get(rel)).digest('base64')}`;
108
+
109
+ // 1) distributive roots (app + libraries; devLibraries only outside production) → modules
110
+ const roots = discoverRoots(fileSystem, root, { resolver, includeDev: mode !== 'production', log });
111
+ // ALREADY RELEASED where this build goes (`released` — the ids of the target's catalog): a library
112
+ // whose distribution is there at this version is there whole — its blocks, facets, server modules
113
+ // and prefabs — and is not built again. The app itself always builds.
114
+ const releasedRoot = (r) => r.dir !== root && !!released?.has(fullTileName({ namespace: r.namespace, type: 'distribution', name: r.namespace, version: r.version }));
115
+ const builtRoots = roots.filter((r) => !releasedRoot(r));
116
+ const mods = builtRoots.flatMap((r) => discoverModulesIn(fileSystem, r));
117
+ log(`mosetta: ${roots.length} root(s)${builtRoots.length < roots.length ? ` (${roots.length - builtRoots.length} released — not built)` : ''}, ${mods.length} client tile side(s)`);
118
+ // composition inputs are watched too: mosetta.json (library list), package.json (version feeds
119
+ // full-ids) of the app and every root, plus the app's content config.json (languages, namespace) —
120
+ // a checkout or a hand edit that changes ANY of them must trigger a rescan-rebuild, not require
121
+ // a dev-server restart (chokidar accepts not-yet-existing paths and fires on creation)
122
+ for (const dir of new Set([root, ...roots.map((r) => r.dir)])) {
123
+ watchFiles.add(path.join(dir, 'mosetta.json'));
124
+ watchFiles.add(path.join(dir, 'package.json'));
125
+ }
126
+ watchFiles.add(path.join(root, 'config.json'));
127
+
128
+ // build-extension hooks: mosetta.build.mjs of the APP root, fresh per build() run
129
+ // (mtime cache-bust) — an edit of the config is a composition change like any other
130
+ if (buildConfig.file) watchFiles.add(buildConfig.file);
131
+ // the data-level knob rides along: extra asset extensions (a custom asset type in
132
+ // the simple case needs no plugin at all)
133
+ const assetExtensions = readConfig(fileSystem, root).assetExtensions ?? [];
134
+
135
+ // Artifact-cache EPOCH: everything that changes the build as a whole rather than through
136
+ // individual graph files — mode, config hooks, the package catalog (a reinstall changes
137
+ // resolution without touching sources) and the composition inputs (mosetta.json/package.json of
138
+ // the roots, config.json, mosetta.build.mjs itself). An epoch change wipes the cache entirely;
139
+ // within an epoch artifacts live by the mtime of their graphs.
140
+ cache?.epoch([mode, buildConfig?.stamp ?? 'none', resolver.stamp, mtimeStamp(fileSystem, [...watchFiles])].join('|'));
141
+
142
+ // the loader for script facets: systemjs + named-register + resolver prolog,
143
+ // co-located into EVERY script facet's folder — a System.register entry is not executable
144
+ // without the loader, and the module folder gives it a version-qualified (immutable) URL.
145
+ let loaderJs = null;
146
+ if (mods.some((m) => m.type === 'script')) {
147
+ const prolog = await buildLoaderProlog(fileSystem, { resolver, mode, buildConfig, engine, sourceBase: root });
148
+ addWatch(prolog.watchFiles);
149
+ loaderJs = `${readSystemjs(fileSystem, resolver)}\n${prolog.jsCode}`;
150
+ }
151
+
152
+ // catalog of LOGICAL artifacts for the report (renderBuildReport): distributive → module → facets
153
+ const catalog = new Map(); // ns -> { version, modules: Map(name -> [{facet, bytes, info}]) }
154
+ const record = ({ namespace, version, name }, facet, bytes, info = '') => {
155
+ const dist = catalog.get(namespace) ?? { version, modules: new Map() };
156
+ catalog.set(namespace, dist);
157
+ const facets = dist.modules.get(name) ?? [];
158
+ dist.modules.set(name, facets);
159
+ facets.push({ facet, bytes, info });
160
+ };
161
+
162
+ // dependency css: one shared UNscoped artifact per closure (identity — the dependency-artifact
163
+ // formula); delivery goes through the catalog graph
164
+ const sharedCssArtifacts = new Map(); // cssFull -> {file, specifier, version}
165
+ const entityDescriptors = []; // descriptors of PACKAGE entities (deps + css) for the aggregate
166
+
167
+ const moduleResults = [];
168
+ let skippedSettings = 0;
169
+ for (const m of mods) {
170
+ const cached = await memo(`module:${fullTileName(m)}`,
171
+ () => buildModule(fileSystem, m, { resolver, mode, buildConfig, engine, sourceBase: root, assetExtensions }));
172
+ // the descriptor below gets AUGMENTED (edges to css entities, integrity) — the cached object
173
+ // must stay untouched, otherwise edits would accumulate from iteration to iteration
174
+ const r = { ...cached, descriptor: { ...cached.descriptor, dependencies: [...cached.descriptor.dependencies] } };
175
+ moduleResults.push(r);
176
+ addWatch(r.watchFiles);
177
+ for (const s of r.sharedCss) {
178
+ const cssFull = dependencyFullId(s.specifier, s.version);
179
+ if (!sharedCssArtifacts.has(cssFull)) sharedCssArtifacts.set(cssFull, s);
180
+ // a css dependency is a graph node: an edge in the importer's descriptor
181
+ if (!r.descriptor.dependencies.includes(cssFull)) r.descriptor.dependencies.push(cssFull);
182
+ }
183
+ writeJs(`${PUB}/modules/${r.full}/${r.full}.js`, r.jsCode, r.map);
184
+ r.descriptor.integrity = sriOf(`${PUB}/modules/${r.full}/${r.full}.js`);
185
+ // a block's ICON: `<name>.icon.svg` beside its module — shipped in the module's folder under a
186
+ // name of its content (a new icon is a new name: no cache keeps the old one) and named by the
187
+ // descriptor; the pickers show it
188
+ if (m.type === 'component') {
189
+ const iconFile = path.join(path.dirname(m.file), `${m.name}.icon.svg`);
190
+ watchFiles.add(iconFile);
191
+ if (fileSystem.exists(iconFile)) {
192
+ const icon = fileSystem.readFile(iconFile);
193
+ r.descriptor.icon = `${r.full}.icon.${contentHash(icon)}.svg`;
194
+ out.set(`${PUB}/modules/${r.full}/${r.descriptor.icon}`, icon);
195
+ }
196
+ // its SETTINGS: `<name>.settings.ts` beside it — what the block's node keeps, as JSON Schema
197
+ // in the descriptor (settings.mjs); run by the host, skipped where the host runs nothing
198
+ const settingsFile = path.join(path.dirname(m.file), `${m.name}${SETTINGS_SUFFIX}`);
199
+ watchFiles.add(settingsFile);
200
+ if (fileSystem.exists(settingsFile)) {
201
+ if (evaluate) {
202
+ const described = await memo(`settings:${r.full}`,
203
+ () => describeSettings(fileSystem, settingsFile, { resolver, engine, evaluate, frameworkDir, sourceBase: root }));
204
+ addWatch(described.watchFiles);
205
+ r.descriptor.settings = described.schema;
206
+ } else {
207
+ skippedSettings++;
208
+ }
209
+ }
210
+ }
211
+ out.set(`${PUB}/modules/${r.full}/${r.full}.json`, JSON.stringify(r.descriptor, null, 2));
212
+ if (r.css) out.set(`${PUB}/modules/${r.full}/${r.full}.css`, r.css);
213
+ if (m.type === 'script') out.set(`${PUB}/modules/${r.full}/system.js`, loaderJs);
214
+ let assetBytes = 0;
215
+ for (const [outName, srcAbs] of r.assets) {
216
+ const content = fileSystem.readFile(srcAbs);
217
+ out.set(`${PUB}/modules/${r.full}/${outName}`, content);
218
+ assetBytes += content.length;
219
+ }
220
+ record(m, m.type, r.jsCode.length, r.assets.size ? `${r.assets.size} asset(s) ${formatBytes(assetBytes)}` : '');
221
+ // css gets its own row right under its facet (and honestly counts into the distributive total)
222
+ if (r.css) record(m, 'css', r.css.length);
223
+ }
224
+ if (skippedSettings) log(`mosetta: ${skippedSettings} settings schema(s) not read — this environment runs no code at build time`);
225
+
226
+ // 2) — the ROOT-LEVEL runtime files (bootstrap.js/system.js and their content-public copies)
227
+ // are GONE: the loader ships co-located with script facets (§ above), the
228
+ // bootstrap logic lives in @mosetta/runtime shipped as an ordinary dependency artifact.
229
+
230
+ // 3) dependency closure — keyed by version-qualified full-id. Specifiers resolve
231
+ // FROM THE IMPORTER (node semantics): a module's externals from its package, a
232
+ // dependency's transitives from its own .pnpm neighbourhood — the app manifest no longer
233
+ // carries foreign transitives (scheduler/dockview/zod-style leaks). Dedup builds by full-id,
234
+ // so distinct versions of one lib produce distinct artifacts. No import map is emitted: the
235
+ // runtime resolver derives each URL by formula (modules/<full-id>/<full-id>.js — flat layout).
236
+ const seen = new Set(); // full-ids already built
237
+ const queued = new Set(); // importerDir|specifier pairs already enqueued
238
+ const queue = []; // [specifier, importerDir]
239
+ const enqueue = (specs, importerDir) => specs.forEach((s) => {
240
+ const key = `${importerDir}|${s}`;
241
+ if (!queued.has(key)) {
242
+ queued.add(key);
243
+ queue.push([s, importerDir]);
244
+ }
245
+ });
246
+ for (let i = 0; i < mods.length; i++) enqueue(moduleResults[i].rawImports, path.dirname(mods[i].file));
247
+
248
+ let depCount = 0;
249
+ let releasedDeps = 0;
250
+ const depRows = []; // {specifier, version, bytes} — for the dependency-artifact report
251
+ while (queue.length) {
252
+ const [specifier, importerDir] = queue.shift();
253
+ const resolved = resolveSpecifier(fileSystem, resolver, specifier, importerDir, log);
254
+ if (!resolved) continue;
255
+ const { full, resolvedFile, kind, version } = resolved;
256
+ if (seen.has(full)) continue;
257
+ seen.add(full);
258
+ // a PUBLISHED package already released where this build goes is there with everything it
259
+ // imports (released together with it): neither it nor its closure is built again. A package of
260
+ // the distribution's own sources always is — new bytes under its version must be seen
261
+ if (released?.has(full) && fileSystem.realPath(resolvedFile).split(/[\\/]/).includes('node_modules')) {
262
+ releasedDeps++;
263
+ continue;
264
+ }
265
+
266
+ // a dependency artifact is cached together with its cjs-export scan: both read the same
267
+ // package files and only change together with them
268
+ const res = await memo(`dep:${full}`, async () => {
269
+ const namedExports = kind === 'builtin' ? null : await cjsNamedExports(fileSystem, resolver, resolvedFile, importerDir);
270
+ return buildDependency(fileSystem, { resolver, specifier, resolvedFile, full, namedExports, mode, buildConfig, engine, sourceBase: root });
271
+ });
272
+ addWatch(res.watchFiles);
273
+ writeJs(`${PUB}/modules/${full}/${full}.js`, res.jsCode, res.map);
274
+ const depVersion = version;
275
+ const depDescriptor = {
276
+ id: full,
277
+ reference: { type: 'package', specifier, version: depVersion },
278
+ assets: [],
279
+ dependencies: res.imports,
280
+ integrity: sriOf(`${PUB}/modules/${full}/${full}.js`),
281
+ // built from the distribution's OWN sources (a workspace package), not a published one: new
282
+ // bytes under its version are a change that needs a new version — a published package's
283
+ // bytes may differ by build tooling alone, and its first deployed copy stands (deploy.mjs)
284
+ ...(fileSystem.realPath(resolvedFile).split(/[\\/]/).includes('node_modules') ? {} : { local: true }),
285
+ };
286
+ out.set(`${PUB}/modules/${full}/${full}.json`, JSON.stringify(depDescriptor, null, 2));
287
+ entityDescriptors.push(depDescriptor);
288
+ depRows.push({ specifier, version: depVersion, bytes: res.jsCode.length });
289
+ // this artifact's own imports resolve from ITS package next
290
+ enqueue(res.rawImports, packageDirOf(fileSystem, resolvedFile) ?? path.dirname(resolvedFile));
291
+ depCount++;
292
+ }
293
+
294
+ // 3b) css entities: a shared UNscoped css artifact is a full catalog entity
295
+ // (folder: css + url() assets + descriptor, no JS facet)
296
+ for (const [cssFull, s] of sharedCssArtifacts) {
297
+ if (released?.has(cssFull)) continue; // released with the package it comes from
298
+ // a dependency's css entity is the file itself plus its url() assets; there is no graph here,
299
+ // so we assemble the watched set ourselves (otherwise editing an image would not change its
300
+ // hash-based name)
301
+ const { css, assets: cssAssets } = await memo(`css:${cssFull}`,
302
+ async () => {
303
+ const r = await processCss({ fileSystem, cssFiles: [s.file], scopeClass: null, engine, mode });
304
+ return { ...r, watchFiles: [s.file, ...r.assets.values()] };
305
+ });
306
+ out.set(`${PUB}/modules/${cssFull}/${cssFull}.css`, css);
307
+ for (const [outName, srcAbs] of cssAssets) {
308
+ out.set(`${PUB}/modules/${cssFull}/${outName}`, fileSystem.readFile(srcAbs));
309
+ }
310
+ const cssDescriptor = {
311
+ id: cssFull,
312
+ // type 'css' (not 'package'): the entity has no JS facet — preloader/linking tell these apart
313
+ reference: { type: 'css', specifier: s.specifier, version: s.version },
314
+ assets: [`${cssFull}.css`, ...cssAssets.keys()],
315
+ dependencies: [],
316
+ integrity: sriOf(`${PUB}/modules/${cssFull}/${cssFull}.css`),
317
+ };
318
+ out.set(`${PUB}/modules/${cssFull}/${cssFull}.json`, JSON.stringify(cssDescriptor, null, 2));
319
+ entityDescriptors.push(cssDescriptor);
320
+ watchFiles.add(s.file);
321
+ depRows.push({ specifier: `${s.specifier} (css)`, version: s.version, bytes: css.length });
322
+ }
323
+
324
+ // 3c) DISTRIBUTIONS name themselves: one entity per root that brings blocks or prefabs — its
325
+ // title, description, icon, cover and where its source lives. The descriptor is the root's
326
+ // mosetta.json; the description falls back to package.json's, the source to its `repository`
327
+ // (url + directory). Files ship in the entity's folder under names of their content.
328
+ for (const r of builtRoots) {
329
+ // the APP's own prefabs are its site's content (the picker's "prefabs of this site"), not a
330
+ // distribution: the app counts only for blocks of its own
331
+ const brings = mods.some((m) => m.namespace === r.namespace)
332
+ || (r.dir !== root && discoverPrefabsIn(fileSystem, r.dir, () => {}).length > 0);
333
+ if (!brings) continue;
334
+ const descriptor = distributionDescriptor(fileSystem, r, (key, file) => {
335
+ watchFiles.add(file);
336
+ if (!fileSystem.exists(file)) {
337
+ log(` ! ${r.namespace}: its ${key} ${path.relative(r.dir, file)} is not there — skipped`);
338
+ return null;
339
+ }
340
+ const bytes = fileSystem.readFile(file);
341
+ const name = `${key}.${contentHash(bytes)}${path.extname(file)}`;
342
+ out.set(`${PUB}/modules/${fullTileName({ namespace: r.namespace, type: 'distribution', name: r.namespace, version: r.version })}/${name}`, bytes);
343
+ return name;
344
+ });
345
+ out.set(`${PUB}/modules/${descriptor.id}/${descriptor.id}.json`, JSON.stringify(descriptor, null, 2));
346
+ entityDescriptors.push(descriptor);
347
+ }
348
+
349
+ // 4) manifests + prefabs (namespaces.json is dead — namespaces derive from the catalog).
350
+ // scripts.json/styles.json/dependencies.json are dead — their roles are carried by the catalog
351
+ // below. The unified entity catalog: an aggregate of ALL public descriptors of the provider —
352
+ // modules + package entities (deps, css). Replaces scripts.json / styles.json /
353
+ // dependencies.json: a script facet's presence = a `_t_script_` entity in the catalog, css
354
+ // lives in the entity's assets, the graph is the descriptor edges. One fetch for
355
+ // shell/runner/editor.
356
+ // prefabs = reusable templates (all roots); pages = routed/servable trees (app root only — they are
357
+ // the application's pages, not a library's). Both stored per-file; the render path reads PAGES.
358
+ // a prefab is filed under its AUTHOR — the root's namespace — so two libraries may name theirs
359
+ // alike; a page embeds one as `_ns_<namespace>_t_prefab_n_<name>_v_live`
360
+ const prefabs = builtRoots.flatMap((r) => discoverPrefabsIn(fileSystem, r.dir, log).map((p) => ({ ...p, namespace: r.namespace, library: r.dir !== root, version: r.version })));
361
+ // a library's prefab is LIVE — taken at its current form wherever it is placed — and it names the
362
+ // blocks of the distributions built with it at the versions they are built at now, whatever
363
+ // version its file was written at
364
+ const builtVersions = new Map(roots.map((r) => [r.namespace, r.version]));
365
+ for (const p of prefabs) out.set(`${PUB}/prefabs/${p.namespace}/${p.name}.json`, JSON.stringify(atBuiltVersions(p.json, builtVersions), null, 2));
366
+ // …and the catalog names it, so the editor can offer it beside its distribution's blocks
367
+ for (const p of prefabs.filter((q) => q.library)) {
368
+ entityDescriptors.push({
369
+ id: fullTileName({ namespace: p.namespace, type: 'prefab', name: p.name, version: p.version }),
370
+ reference: { namespace: p.namespace, name: p.name, type: 'prefab', version: p.version },
371
+ assets: [],
372
+ dependencies: [],
373
+ });
374
+ }
375
+
376
+ const entityCatalog = [...moduleResults.map((r) => r.descriptor), ...entityDescriptors];
377
+ out.set(`${PUB}/modules.json`, JSON.stringify(entityCatalog, null, 2));
378
+ const pages = discoverPagesIn(fileSystem, root, log);
379
+ for (const pg of pages) out.set(`${PUB}/pages/${pg.name}.json`, JSON.stringify(pg.json, null, 2));
380
+
381
+ // 5) server-action modules → PRIVATE monoliths: one self-contained ESM per module, run
382
+ // in a Worker Loader isolate; not client artifacts, not split into deps. Legacy @mosetta/* server
383
+ // modules can't bundle for Web API yet (node builtins) → each is warn-skipped (not silently dropped)
384
+ // until the framework is consolidated into @mosetta/server-action.
385
+ //
386
+ // PRIVATE CATALOG: private entities carry the same descriptors as public ones:
387
+ // id + reference + assets + dependencies + integrity, a per-entity json next to the monolith
388
+ // plus the private/modules.json aggregate. This gives the runner the isolate's file list and an
389
+ // integrity check on delivery, and gives future action assets (wasm build engines) a place to be
390
+ // declared. dependencies is always [] — a monolith is self-contained.
391
+ const privateDescriptors = [];
392
+ const recordPrivate = (full, reference, extra = {}, moreAssets = []) => {
393
+ const base = `private/modules/${full}`;
394
+ const descriptor = {
395
+ id: full,
396
+ reference,
397
+ assets: [...moreAssets, ...(out.get(`${base}/${full}.js.map`) ? [`${full}.js.map`] : [])],
398
+ dependencies: [],
399
+ integrity: sriOf(`${base}/${full}.js`),
400
+ ...extra,
401
+ };
402
+ out.set(`${base}/${full}.json`, JSON.stringify(descriptor, null, 2));
403
+ privateDescriptors.push(descriptor);
404
+ };
405
+ const serverMods = builtRoots.flatMap((r) => discoverServerModulesIn(fileSystem, r));
406
+ let serverCount = 0;
407
+ for (const sm of serverMods) {
408
+ try {
409
+ const s = await memo(`server:${fullTileName(sm)}`,
410
+ () => buildServerModule(fileSystem, sm, { resolver, mode, buildConfig, engine, sourceBase: root, frameworkDir }));
411
+ addWatch(s.watchFiles);
412
+ writeJs(`private/modules/${s.full}/${s.full}.js`, s.jsCode, s.map);
413
+ // the facet's wasm imports travel as assets next to the monolith: the runner will declare
414
+ // them as bundle modules under the same relative names
415
+ for (const [assetName, srcFile] of s.wasmAssets) {
416
+ out.set(`private/modules/${s.full}/${assetName}`, fileSystem.readFile(srcFile));
417
+ }
418
+ recordPrivate(s.full, { namespace: sm.namespace, name: sm.name, type: 'server', version: sm.version },
419
+ {}, [...s.wasmAssets.keys()]);
420
+ record(sm, 'server', s.jsCode.length, 'private'
421
+ + (s.wasmAssets.size ? `, ${s.wasmAssets.size} wasm asset(s)` : ''));
422
+ serverCount++;
423
+ } catch (e) {
424
+ log(` ! server side ${sm.name}@${sm.namespace} not bundled (${String(e.message || e)})`
425
+ + ` — skipped; needs @mosetta/server-action Web-API port`);
426
+ }
427
+ }
428
+ // no glue artifacts anymore: the UNIVERSAL glue is embedded in the runner and
429
+ // composed with every isolate — monoliths carry their aspects themselves.
430
+
431
+ // 6) there is NO router (ADR-0053): the runner dispatches by path and the site isolate routes —
432
+ // an authored `.router.ts` facet is a mistake to name, not a file to ignore
433
+ const routerFacets = roots.flatMap((r) => discoverRouterModulesIn(fileSystem, r));
434
+ if (routerFacets.length) {
435
+ throw new Error(`mosetta: router sides are no longer built — the site line routes (${routerFacets.map((rm) => path.relative(root, rm.file)).join(', ')})`);
436
+ }
437
+
438
+ // 7) ssr facet per ROOT-CAPABLE module (the shell is a degenerate SSR case): the
439
+ // coder's <name>.ssr.tsx or the generated react default → private/modules/<full-id>/ —
440
+ // what the site isolate of its engine runs. Root-capable = component facet + script facet in one module
441
+ // (the script facet marks root-capability, the default ssr grain is keyed off it;
442
+ // script-only modules are pure early scripts — nothing to render).
443
+ let ssrCount = 0;
444
+ const scriptDirs = new Set(mods.filter((m) => m.type === 'script').map((m) => path.dirname(m.file)));
445
+ const ssrRoots = mods.filter((m) => m.type === 'component' && scriptDirs.has(path.dirname(m.file)));
446
+ for (const am of ssrRoots) {
447
+ const s = await memo(`ssr:${fullTileName(am)}`,
448
+ () => buildSsrModule(fileSystem, am, { resolver, mode, buildConfig, engine, sourceBase: root, frameworkDir }));
449
+ if (s.watchFile) watchFiles.add(s.watchFile);
450
+ addWatch(s.watchFiles);
451
+ writeJs(`private/modules/${s.full}/${s.full}.js`, s.jsCode, s.map);
452
+ recordPrivate(s.full, { namespace: am.namespace, name: am.name, type: 'ssr', version: am.version });
453
+ record(am, 'ssr', s.jsCode.length, s.watchFile ? 'private' : 'private, generated default');
454
+ ssrCount++;
455
+ }
456
+
457
+ // the private-zone aggregate is the twin of public/modules.json: one fetch
458
+ // for the runner — composition, integrity, well-known pointers
459
+ out.set('private/modules.json', JSON.stringify(privateDescriptors, null, 2));
460
+
461
+ log(`mosetta: ${moduleResults.length} tile side(s), ${depCount} package tile(s)${releasedDeps ? ` (${releasedDeps} released — not built)` : ''}, ${prefabs.length} prefab(s), ${pages.length} page(s)`
462
+ + (serverCount ? `, ${serverCount} server monolith(s)` : '')
463
+ + `, ${ssrCount} ssr`);
464
+ const watchDirs = [
465
+ ...roots.flatMap((r) => [path.join(r.dir, 'tiles'), path.join(r.dir, 'prefabs')]),
466
+ path.join(root, 'pages'),
467
+ ];
468
+ const report = {
469
+ distributions: [...catalog].map(([namespace, d]) => ({
470
+ namespace,
471
+ version: d.version,
472
+ modules: [...d.modules].map(([name, facets]) => ({ name, facets })),
473
+ })),
474
+ dependencies: depRows,
475
+ };
476
+ return { fs: out, prefabs, pages, watchFiles: [...watchFiles], watchDirs, report };
477
+ }
478
+
479
+ /** a short name of some bytes' content — what makes a shipped file's name change with it */
480
+ function contentHash(bytes) {
481
+ return crypto.createHash('sha256').update(bytes).digest('hex').slice(0, 10);
482
+ }
483
+
484
+ /** a GitHub-like repository field (`git+https://github.com/o/r.git` + directory) as a link to the folder */
485
+ export function sourceLinkOf(repository) {
486
+ const url = typeof repository === 'string' ? repository : repository?.url;
487
+ if (typeof url !== 'string') return null;
488
+ const base = url.replace(/^git\+/, '').replace(/\.git$/, '').replace(/^git@github\.com:/, 'https://github.com/');
489
+ if (!/^https?:\/\//.test(base)) return null;
490
+ const directory = typeof repository === 'object' && typeof repository.directory === 'string' ? repository.directory.replace(/^\/+|\/+$/g, '') : '';
491
+ return directory ? `${base}/tree/HEAD/${directory}` : base;
492
+ }
493
+
494
+ /**
495
+ * A distribution's catalog entity: what it calls itself, from its mosetta.json (title,
496
+ * description, icon, cover, source) with package.json behind the description and the source.
497
+ * `ship(key, file)` puts a file into the entity's folder and answers its shipped name (null: none).
498
+ */
499
+ export function distributionDescriptor(fileSystem, root, ship) {
500
+ const cfg = readConfig(fileSystem, root.dir);
501
+ let pkg = {};
502
+ try {
503
+ pkg = fileSystem.readJson(path.join(root.dir, 'package.json'));
504
+ } catch { /* a root without package.json names no description or source */ }
505
+ const text = (value) => (typeof value === 'string' && value.trim() ? value.trim() : undefined);
506
+ const files = {};
507
+ for (const key of ['icon', 'cover']) {
508
+ if (typeof cfg[key] !== 'string') continue;
509
+ const name = ship(key, path.join(root.dir, cfg[key]));
510
+ if (name) files[key] = name;
511
+ }
512
+ const descriptor = {
513
+ id: fullTileName({ namespace: root.namespace, type: 'distribution', name: root.namespace, version: root.version }),
514
+ reference: { namespace: root.namespace, name: root.namespace, type: 'distribution', version: root.version },
515
+ assets: [],
516
+ dependencies: [],
517
+ title: text(cfg.title) ?? root.namespace,
518
+ // a tool's own distribution (the editor): carries no blocks for authors — no card, no switch
519
+ ...(cfg.internal === true ? { internal: true } : {}),
520
+ // the distribution others are built on (the UI kit): always connected, no switch
521
+ ...(cfg.base === true ? { base: true } : {}),
522
+ ...(text(cfg.description) ?? text(pkg.description) ? { description: text(cfg.description) ?? text(pkg.description) } : {}),
523
+ ...(text(cfg.source) ?? sourceLinkOf(pkg.repository) ? { source: text(cfg.source) ?? sourceLinkOf(pkg.repository) } : {}),
524
+ ...(changesOf(fileSystem, root) ? { changes: changesOf(fileSystem, root) } : {}),
525
+ ...files,
526
+ };
527
+ return descriptor;
528
+ }
529
+
530
+ /** a module reference with a version (not a live prefab): its namespace, the rest, its version */
531
+ const VERSIONED_REFERENCE_RE = /^_ns_(.+?)(_t_[a-z][A-Za-z0-9]*_n_.+)_v_(\d+(?:_\d+)*)$/;
532
+
533
+ /**
534
+ * A library prefab's tree with every block of a distribution built with it at that distribution's
535
+ * version now (`versions`: namespace → X.Y.Z) — a prefab written when its kit was 0.0.1 names the
536
+ * kit's blocks at 0.0.1, and is shipped live with the kit at 0.0.2. Other references stay.
537
+ */
538
+ export function atBuiltVersions(json, versions) {
539
+ const visit = (node) => {
540
+ if (!node || typeof node !== 'object') return node;
541
+ const match = typeof node.reference === 'string' ? VERSIONED_REFERENCE_RE.exec(node.reference) : null;
542
+ const version = match ? versions.get(match[1]) : undefined;
543
+ const reference = version ? `_ns_${match[1]}${match[2]}_v_${version.replace(/[^a-zA-Z0-9]/g, '_')}` : node.reference;
544
+ return {
545
+ ...node,
546
+ ...(reference !== node.reference ? { reference } : {}),
547
+ ...(Array.isArray(node.children) ? { children: node.children.map(visit) } : {}),
548
+ };
549
+ };
550
+ return json && typeof json === 'object' && json.root ? { ...json, root: visit(json.root) } : json;
551
+ }
552
+
553
+ /** the most a version's notes may say in the catalog — what a window has room for */
554
+ const MAX_CHANGES = 4000;
555
+
556
+ /**
557
+ * What this version says it changed: its section of the distribution's CHANGELOG.md — the lines
558
+ * under a heading naming the version (`## 1.2.0`, as changesets writes it), up to the next heading
559
+ * of that level. Nothing for a distribution without one, or a version it does not name.
560
+ */
561
+ export function changesOf(fileSystem, root) {
562
+ const file = path.join(root.dir, 'CHANGELOG.md');
563
+ if (!fileSystem.exists(file)) return undefined;
564
+ const lines = fileSystem.readFile(file, 'utf8').split(/\r?\n/);
565
+ const at = lines.findIndex((line) => new RegExp(`^(#{1,3})\\s*\\[?v?${root.version.replace(/\./g, '\\.')}\\]?(\\s|$)`).test(line));
566
+ if (at < 0) return undefined;
567
+ const level = /^#+/.exec(lines[at])[0].length;
568
+ const section = [];
569
+ for (const line of lines.slice(at + 1)) {
570
+ const heading = /^(#+)\s/.exec(line);
571
+ if (heading && heading[1].length <= level) break;
572
+ section.push(line);
573
+ }
574
+ const notes = section.join('\n').trim();
575
+ return notes ? notes.slice(0, MAX_CHANGES) : undefined;
576
+ }
@@ -0,0 +1,22 @@
1
+ // mosetta.build.mjs: the app's build-extension hooks. mosetta.json stays pure data —
2
+ // build CODE lives in this co-located convention file. Loaded per build() run with an mtime
3
+ // cache-bust (the ESM import cache is permanent, a re-import needs a fresh URL), so a dev-server
4
+ // rebuild picks up config edits without a restart. Missing file → inert config.
5
+
6
+ /** Inert form: `mosetta.build.mjs` is absent or the caller did not load it. */
7
+ export const INERT_BUILD_CONFIG = Object.freeze({
8
+ file: null, stamp: 'none', clientPlugins: null, serverEsbuild: null, rollupOptions: null,
9
+ });
10
+
11
+
12
+ // The protocol invariant a user plugin must not break: a client artifact is a
13
+ // NAMED System.register — the runtime links artifacts by these string names. Checked after every
14
+ // generate; quote-agnostic (production minify normalizes quotes).
15
+ export function assertNamedRegister(code, full, buildConfig) {
16
+ const re = new RegExp(`System\\.register\\(\\s*["']${full.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}["']`);
17
+ if (re.test(code)) return;
18
+ const hint = buildConfig?.file && buildConfig.stamp !== 'none'
19
+ ? ` — check your plugins in ${buildConfig.file} (reproduce without them first)`
20
+ : '';
21
+ throw new Error(`artifact ${full} lost its named System.register form${hint}`);
22
+ }