@mosetta/build 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.
@@ -0,0 +1,97 @@
1
+ import commonjs from '@rollup/plugin-commonjs';
2
+ import { path } from './nodeApi.mjs';
3
+ import { packageDirOf } from './packageMeta.mjs';
4
+ import { defineEnv } from './plugins/defineEnv.mjs';
5
+ import { portResolve } from './plugins/portResolve.mjs';
6
+ import { resolveSpecifier } from './specifiers.mjs';
7
+ import { assertNamedRegister } from './buildConfig.mjs';
8
+ import { userPluginsFor } from './buildModules.mjs';
9
+ import { sourcemapPathTransformFor } from './sourcemap.mjs';
10
+ import { transformTs, minifyChunk } from './plugins/transform.mjs';
11
+
12
+ const isBareExternal = (id) =>
13
+ !id.startsWith('.') && !path.isAbsolute(id) && !id.startsWith('\0');
14
+
15
+ const FACADE_ID = '\0mosetta-facade';
16
+
17
+ /**
18
+ * Build one external dependency (npm or framework package) into a System artifact.
19
+ * Internal/relative imports are bundled; cross-package bare imports stay external
20
+ * (→ their own artifacts, resolved by the import map).
21
+ * For CJS deps, `namedExports` (enumerated via Node) generates a facade so the artifact
22
+ * exposes real named exports (needed for cross-artifact `import { x } from 'pkg'`).
23
+ * Returns { full, code, imports } where imports = bare deps of this artifact.
24
+ */
25
+ export async function buildDependency(fileSystem, { resolver, specifier, resolvedFile, full, namedExports, mode = 'development', buildConfig = null, engine, sourceBase }) {
26
+ // this dependency's OWN transitives resolve from ITS package — dockview finds
27
+ // dockview-core in its .pnpm neighbourhood, no app-level declarations
28
+ const baseDir = packageDirOf(fileSystem, resolvedFile) ?? path.dirname(resolvedFile);
29
+ // maps are always EXTERNAL (placed into the private counterpart by writeJs); in dev,
30
+ // heavy vendor deps (react) skip map generation entirely — nobody debugs their internals
31
+ const isTS = /\.tsx?$/.test(resolvedFile);
32
+ const sourcemap = mode === 'production' || isTS;
33
+ const useFacade = Array.isArray(namedExports) && namedExports.length > 0;
34
+ const facadeCode = useFacade
35
+ ? `import __cjs from ${JSON.stringify(resolvedFile)};\n`
36
+ + `export default __cjs;\n`
37
+ + `export const { ${namedExports.join(', ')} } = __cjs;\n`
38
+ : null;
39
+
40
+ // the rollupOptions escape hatch transforms the composed INPUT options; user
41
+ // plugins land after the facade virtual entry (it must keep resolving first) and before the
42
+ // framework chain
43
+ const optionsHook = (o) => buildConfig?.rollupOptions?.(o, { target: 'dependency', mode }) ?? o;
44
+ const bundle = await engine.rollup(optionsHook({
45
+ input: useFacade ? FACADE_ID : resolvedFile,
46
+ external: isBareExternal,
47
+ plugins: [
48
+ {
49
+ name: 'mosetta-facade',
50
+ resolveId(id) {
51
+ return id === FACADE_ID ? id : null;
52
+ },
53
+ load(id) {
54
+ return id === FACADE_ID ? facadeCode : null;
55
+ },
56
+ },
57
+ ...userPluginsFor(buildConfig, 'dependency', mode),
58
+ defineEnv(mode),
59
+ portResolve(fileSystem, resolver, baseDir),
60
+ commonjs(),
61
+ transformTs(engine), // .js (CJS deps like react) is handled by @rollup/plugin-commonjs
62
+ ],
63
+ onwarn(w, warn) {
64
+ if (w.code === 'UNRESOLVED_IMPORT' || w.code === 'CIRCULAR_DEPENDENCY') return;
65
+ warn(w);
66
+ },
67
+ }));
68
+
69
+ // rewrite cross-package externals `bare` → version-qualified full-id
70
+ const externalMap = new Map();
71
+ const paths = (id) => {
72
+ const f = resolveSpecifier(fileSystem, resolver, id, baseDir)?.full || id;
73
+ externalMap.set(id, f);
74
+ return f;
75
+ };
76
+ // named register (SSR epic stage 1) — same rationale as buildModules.generateSystem; production
77
+ // minify mangles only artifact-internal scope (linkage names are string literals)
78
+ const { output } = await bundle.generate({
79
+ format: 'system', name: full, exports: 'named', sourcemap, paths,
80
+ sourcemapPathTransform: sourcemapPathTransformFor(sourceBase),
81
+ plugins: mode === 'production' ? [minifyChunk(engine)] : [],
82
+ });
83
+ const watchFiles = bundle.watchFiles.slice();
84
+ await bundle.close();
85
+
86
+ const chunk = output.find((o) => o.type === 'chunk');
87
+ assertNamedRegister(chunk.code, full, buildConfig); // protocol invariant
88
+ const map = sourcemap === true && chunk.map ? chunk.map.toString() : null;
89
+ return {
90
+ full,
91
+ jsCode: chunk.code,
92
+ map,
93
+ imports: [...externalMap.values()], // full-ids
94
+ rawImports: [...externalMap.keys()], // bare specifiers (for closure traversal)
95
+ watchFiles,
96
+ };
97
+ }
@@ -0,0 +1,328 @@
1
+ import commonjs from '@rollup/plugin-commonjs';
2
+ import { path } from './nodeApi.mjs';
3
+ import { packageDirOf } from './packageMeta.mjs';
4
+ import { fullTileName } from './moduleName.mjs';
5
+ import { cssIntercept } from './plugins/cssIntercept.mjs';
6
+ import { assets } from './plugins/assets.mjs';
7
+ import { defineEnv } from './plugins/defineEnv.mjs';
8
+ import { portResolve } from './plugins/portResolve.mjs';
9
+ import { processCss } from './css.mjs';
10
+ import { resolveSpecifier } from './specifiers.mjs';
11
+ import { assertNamedRegister } from './buildConfig.mjs';
12
+ import { sourcemapPathTransformFor } from './sourcemap.mjs';
13
+ import { transformTs, minifyChunk } from './plugins/transform.mjs';
14
+ import { mtimeStamp } from './artifactCache.mjs';
15
+
16
+ // Every bare specifier (npm AND framework packages) is external — they become
17
+ // import-map-resolved System deps. Relative/absolute imports are bundled into the module.
18
+ // css is NEVER external: rollup consults `external` BEFORE plugin resolveId hooks, and a bare
19
+ // css import ('@mosetta/ui/ui.css') must reach cssIntercept, not the dep closure.
20
+ const isExternal = (id) =>
21
+ !id.endsWith('.css') && !id.startsWith('.') && !path.isAbsolute(id) && !id.startsWith('\0');
22
+
23
+ // external maps in EVERY mode: the map is a separate artifact the build places into
24
+ // the store's private counterpart; dev DX comes from the dev server's SourceMap response header
25
+ export const sourcemapFor = () => true;
26
+
27
+ // user hooks: plugins from mosetta.build.mjs land BEFORE the framework ones (React
28
+ // Compiler wants original JSX, an scss plugin must run before cssIntercept), the rollupOptions
29
+ // escape hatch transforms the composed INPUT options (output shape is protocol — not exposed)
30
+ export function userPluginsFor(buildConfig, target, mode) {
31
+ return buildConfig?.clientPlugins?.({ target, mode }) ?? [];
32
+ }
33
+ function applyOptionsHook(buildConfig, options, target, mode) {
34
+ return buildConfig?.rollupOptions?.(options, { target, mode }) ?? options;
35
+ }
36
+
37
+ // production minify is a render-chunk post-pass — mangling touches only artifact-internal scope:
38
+ // the System.register name, dep specifiers and export names are string literals, so the
39
+ // cross-artifact runtime linkage survives untouched
40
+ async function generateSystem({ engine, sourceBase, input, name, plugins, sourcemap = 'inline', resolveExternal, minified = false, buildConfig = null, target = 'module', mode = 'development' }) {
41
+ const bundle = await engine.rollup(applyOptionsHook(buildConfig, {
42
+ input,
43
+ external: isExternal,
44
+ plugins,
45
+ onwarn(w, warn) {
46
+ if (w.code === 'UNRESOLVED_IMPORT' || w.code === 'CIRCULAR_DEPENDENCY') return;
47
+ warn(w);
48
+ },
49
+ }, target, mode));
50
+ // output.paths rewrites every emitted external `bare` → version-qualified full-id,
51
+ // and records the (bare → full) of externals actually rendered into this artifact.
52
+ const externalMap = new Map();
53
+ const paths = (id) => {
54
+ const full = (resolveExternal && resolveExternal(id)) || id;
55
+ externalMap.set(id, full);
56
+ return full;
57
+ };
58
+ // NAMED register (SSR epic stage 1): `System.register('<full-id>', …)` — the artifact is
59
+ // self-identified, executable by the server mini-System AND concatenable into bundles later.
60
+ // The client keeps loading by URL: the named-register extra links the URL load to the name
61
+ // (one instance), so the browser path needs no changes.
62
+ const { output } = await bundle.generate({
63
+ format: 'system', name, exports: 'named', sourcemap, paths,
64
+ sourcemapPathTransform: sourcemapPathTransformFor(sourceBase),
65
+ plugins: minified ? [minifyChunk(engine)] : [],
66
+ });
67
+ const watchFiles = bundle.watchFiles.slice();
68
+ await bundle.close();
69
+ const chunk = output.find((o) => o.type === 'chunk');
70
+ assertNamedRegister(chunk.code, name, buildConfig); // protocol invariant
71
+ // sourcemap:'inline' embeds the map in chunk.code; true → external map returned separately
72
+ const map = sourcemap === true && chunk.map ? chunk.map.toString() : null;
73
+ return {
74
+ code: chunk.code,
75
+ map,
76
+ imports: [...externalMap.values()], // full-ids (the artifact's resolved dependencies)
77
+ rawImports: [...externalMap.keys()], // bare specifiers (for closure traversal)
78
+ watchFiles,
79
+ };
80
+ }
81
+
82
+ /**
83
+ * Build one module to a System artifact. Returns code + descriptor (no disk write).
84
+ */
85
+ export async function buildModule(fileSystem, mod, { resolver, mode = 'development', buildConfig = null, engine, sourceBase, assetExtensions = [] } = {}) {
86
+ const full = fullTileName(mod);
87
+ // externals resolve from the MODULE's own package — the full-id rewritten into the
88
+ // artifact must match what the closure walk resolves and builds from the same context
89
+ const baseDir = path.dirname(mod.file);
90
+ const cssRefs = [];
91
+ const jsAssets = new Map(); // out -> srcAbs (assets imported from JS)
92
+ const { code, map, imports, rawImports, watchFiles } = await generateSystem({
93
+ engine,
94
+ sourceBase,
95
+ input: mod.file,
96
+ name: full,
97
+ plugins: [
98
+ ...userPluginsFor(buildConfig, 'module', mode),
99
+ defineEnv(mode), assets(jsAssets, assetExtensions, fileSystem), cssIntercept(cssRefs, resolver, baseDir), transformTs(engine),
100
+ // LAST: rollup's built-in resolver used to work here, and it runs after all plugins —
101
+ // placing it earlier would intercept css, assets and the transpiler
102
+ portResolve(fileSystem, resolver, baseDir),
103
+ ],
104
+ sourcemap: sourcemapFor(mode),
105
+ resolveExternal: (id) => resolveSpecifier(fileSystem, resolver, id, baseDir)?.full,
106
+ minified: mode === 'production',
107
+ buildConfig,
108
+ target: 'module',
109
+ mode,
110
+ });
111
+
112
+ // css partition: a file from the module's own package is authored styles (a scoped per-module
113
+ // artifact, as before); a file from a FOREIGN package is dependency css and leaves as one
114
+ // shared artifact per closure (emitted by build()). realpath — because of workspace symlinks.
115
+ const ownPkg = packageDirOf(fileSystem, fileSystem.realPath(mod.file));
116
+ const ownCss = [];
117
+ const sharedCss = []; // {file, specifier: '<pkg>/<rel>', version}
118
+ for (const ref of cssRefs) {
119
+ const real = fileSystem.realPath(ref);
120
+ const pkgDir = packageDirOf(fileSystem, real);
121
+ if (!pkgDir || pkgDir === ownPkg) {
122
+ ownCss.push(ref);
123
+ continue;
124
+ }
125
+ const pkg = fileSystem.readJson(path.join(pkgDir, 'package.json'));
126
+ sharedCss.push({
127
+ file: real,
128
+ specifier: `${pkg.name}/${path.relative(pkgDir, real).split(path.sep).join('/')}`,
129
+ version: pkg.version ?? '0.0.0',
130
+ });
131
+ }
132
+
133
+ let css = null;
134
+ const allAssets = new Map(jsAssets);
135
+ if (ownCss.length) {
136
+ const r = await processCss({ fileSystem, cssFiles: ownCss, scopeClass: full, engine, mode });
137
+ css = r.css;
138
+ for (const [out, src] of r.assets) allAssets.set(out, src);
139
+ }
140
+
141
+ // entity descriptor: id + reference + assets (css is injected, the rest is preload) +
142
+ // graph edges; css dependencies are appended by build() (known only after the partition)
143
+ const descriptor = {
144
+ id: full,
145
+ reference: {
146
+ namespace: mod.namespace,
147
+ name: mod.name,
148
+ type: mod.type,
149
+ version: mod.version,
150
+ },
151
+ assets: [...(css ? [`${full}.css`] : []), ...allAssets.keys()],
152
+ dependencies: imports, // version-qualified full-ids (declared intent)
153
+ // A PAGE ROOT is a component facet only by file type: it takes the page's tree as a prop,
154
+ // it is what `page.reference` names, and it can never be someone's child — so the editor
155
+ // must not offer it as a block to place. Read off the import the facet actually made:
156
+ // the binding is emitted as a property access on the imported namespace, which survives
157
+ // minification (property names are not renamed), and merely importing the definer IS the
158
+ // declaration. Absent rather than false — descriptors stay free of noise.
159
+ ...(ROOT_DEFINER_RE.test(code) ? { isRoot: true } : {}),
160
+ // an INTERNAL block (a tool's own — an editor panel, the editor's fallback): placed by the
161
+ // platform's pages, never offered to an author. The same reading, off the marker the facet
162
+ // imported — a wrapper over any definer, so a fallback is marked the way a component is
163
+ ...(INTERNAL_MARK_RE.test(code) ? { internal: true } : {}),
164
+ };
165
+
166
+ return { full, descriptor, jsCode: code, map, css, sharedCss, assets: allAssets, watchFiles, rawImports };
167
+ }
168
+
169
+ /** how `import { defineRootComponentSide } from '@mosetta/react'` looks once bundled */
170
+ const ROOT_DEFINER_RE = /\.defineRootComponentSide\b/;
171
+ /** …and `markInternalTile(...)` — a block the picker leaves out */
172
+ const INTERNAL_MARK_RE = /\.markInternalTile\b/;
173
+
174
+ const VIRTUAL_PROLOG = '\0mosetta-loader-prolog-entry';
175
+
176
+ // identical across modules, roots and rebuilds — built once per mode, but VALIDATED by the
177
+ // mtimes of its own source graph: the dev server process is long-lived, and a checkout (or
178
+ // framework-source edit in this repo) must not keep serving a stale prolog until restart
179
+ let prologCache = null; // { mode, jsCode, watchFiles, stamp }
180
+
181
+ /**
182
+ * The loader PROLOG: the idempotent resolver installation
183
+ * (`installResolverFromPage()`) bundled as a self-contained IIFE. build() concatenates it after
184
+ * `systemjs + named-register` into the `system.js` co-located with every script facet — the
185
+ * prolog runs before any `systemjs-module` entry is processed, so the resolve hook is always
186
+ * installed first.
187
+ */
188
+ export async function buildLoaderProlog(fileSystem, { resolver, mode = 'development', buildConfig = null, engine } = {}) {
189
+ if (prologCache && prologCache.mode === mode
190
+ && prologCache.configStamp === (buildConfig?.stamp ?? 'none')
191
+ && mtimeStamp(fileSystem, prologCache.watchFiles) === prologCache.stamp) return prologCache;
192
+ // livereload is BUILD knowledge: the dev prolog itself listens to the provider's SSE
193
+ // (the dev provider's /__mosetta__/reload endpoint, base — from the envelope), prod is clean
194
+ // by construction; the cross-cutting machinery (runner/env/shells) knows nothing about dev
195
+ // mode. The guard — one subscription per realm (as many prologs as script facets).
196
+ // The page's dev companions: SSE reload and the ERROR OVERLAY. The overlay lives here rather
197
+ // than as a distributive module precisely because it is needed when the build is BROKEN: there
198
+ // are no new artifacts, yet the prolog arrived with the last successful build and keeps
199
+ // working. The status sits next to the artifact tree and survives a build failure.
200
+ // ONE connection per ORIGIN, not per realm. A browser gives an origin about six sockets and
201
+ // SHARES them across tabs; an EventSource holds one of them for its whole life. The editor
202
+ // alone is three realms (the page plus a preview frame per canvas), so two open tabs used to
203
+ // pin six permanent subscriptions and the origin ran dry — a sibling frame's document could
204
+ // then never load (a gray canvas, an eternal spinner). So: whoever wins the web lock talks to
205
+ // the server and fans the news out over a BroadcastChannel; every other realm only listens.
206
+ const livereloadSnippet = mode === 'development'
207
+ ? 'try {\n'
208
+ + ' if (!globalThis.__mosettaReload) {\n'
209
+ + ' globalThis.__mosettaReload = true;\n'
210
+ + ' var show = function (status) {\n'
211
+ + " var id = 'mosetta-dev-overlay';\n"
212
+ + ' var node = document.getElementById(id);\n'
213
+ + ' var broken = status && status.ok === false;\n'
214
+ + ' if (!broken) { if (node) node.remove(); return; }\n'
215
+ + ' if (globalThis.__mosettaOverlayHidden === status.stamp) return;\n'
216
+ + " if (!node) { node = document.createElement('div'); node.id = id; document.body.appendChild(node); }\n"
217
+ + " node.setAttribute('style', 'position:fixed;inset:0;z-index:2147483647;overflow:auto;"
218
+ + "background:rgba(20,20,24,.96);color:#f5f5f5;font:12px/1.5 ui-monospace,monospace;padding:24px');\n"
219
+ + " var parts = [];\n"
220
+ + " if (status.build) parts.push('<b style=\"color:#ff8080\">Build failed</b>\\n\\n' + esc(status.build));\n"
221
+ + " if (status.typescript) parts.push('<b style=\"color:#ffd280\">Type errors</b>\\n\\n' + esc(status.typescript));\n"
222
+ + " node.innerHTML = '<button style=\"position:sticky;top:0;float:right;cursor:pointer\">close</button>"
223
+ + "<pre style=\"white-space:pre-wrap;margin:0\">' + parts.join('\\n\\n') + '</pre>';\n"
224
+ + " node.querySelector('button').onclick = function () {\n"
225
+ + ' globalThis.__mosettaOverlayHidden = status.stamp;\n'
226
+ + ' node.remove();\n'
227
+ + ' };\n'
228
+ + ' };\n'
229
+ + " var esc = function (s) { return String(s).replace(/[&<>]/g, function (c) { return ({ '&': '&amp;', '<': '&lt;', '>': '&gt;' })[c]; }); };\n"
230
+ // the fan-out: news reaches every realm of the origin over the channel, so only the realm
231
+ // holding the lock ever opens a socket. A late-arriving realm asks for the last status
232
+ // instead of fetching it (`ask`), and the leader answers off its own memory
233
+ + " var chan = typeof BroadcastChannel === 'function' ? new BroadcastChannel('mosetta-dev') : null;\n"
234
+ + ' var leading = false;\n'
235
+ + ' var last = null;\n'
236
+ + ' var apply = function (msg) {\n'
237
+ + ' if (msg.reload) { location.reload(); return; }\n'
238
+ + ' show(msg.status);\n'
239
+ + ' };\n'
240
+ + ' var tell = function (msg) {\n'
241
+ + ' if (chan) chan.postMessage({ mosetta: true, reload: msg.reload, status: msg.status });\n'
242
+ + ' apply(msg);\n'
243
+ + ' };\n'
244
+ + ' if (chan) chan.onmessage = function (e) {\n'
245
+ + ' var msg = e.data;\n'
246
+ + ' if (!msg || !msg.mosetta) return;\n'
247
+ + ' if (msg.ask) {\n'
248
+ + ' if (leading && last) chan.postMessage({ mosetta: true, status: last });\n'
249
+ + ' return;\n'
250
+ + ' }\n'
251
+ + ' apply(msg);\n'
252
+ + ' };\n'
253
+ // ONE stream, no polling: the build status rides the same SSE (`event: status`, sent on
254
+ // connect and then on every change), so the browser never spends a second socket — and
255
+ // never learns about a broken build a whole second late either
256
+ + ' var lead = function () {\n'
257
+ + ' leading = true;\n'
258
+ + " var src = new EventSource(new URL('__mosetta__/reload', base).href);\n"
259
+ + ' src.onmessage = function (e) {\n'
260
+ + " if (e.data === 'reload') tell({ reload: true });\n"
261
+ + ' };\n'
262
+ + " src.addEventListener('status', function (e) {\n"
263
+ + ' var status = null;\n'
264
+ + ' try { status = JSON.parse(e.data); } catch (err) { return; }\n'
265
+ + ' last = status;\n'
266
+ + ' tell({ status: status });\n'
267
+ + ' });\n'
268
+ + ' };\n'
269
+ // the lock is held for the life of the realm and released by the browser when the realm
270
+ // dies, so a closed (or reloaded) leader hands the connection to the next realm by itself.
271
+ // Without a channel there is nobody to fan out to, and without locks nobody to elect —
272
+ // then every realm speaks for itself, as it did before
273
+ + " var locks = typeof navigator === 'object' && navigator ? navigator.locks : null;\n"
274
+ + ' if (chan && locks && typeof locks.request === "function") {\n'
275
+ + " locks.request('mosetta-dev-channel', function () {\n"
276
+ + ' lead();\n'
277
+ + ' return new Promise(function () {});\n'
278
+ + ' }).catch(function () { if (!leading) lead(); });\n'
279
+ + ' chan.postMessage({ mosetta: true, ask: true });\n'
280
+ + ' } else {\n'
281
+ + ' lead();\n'
282
+ + ' }\n'
283
+ + ' }\n'
284
+ + '} catch (e) { /* dev-only, optional */ }\n'
285
+ : '';
286
+ const bootstrapEntry = resolver.resolveFramework('@mosetta/runtime/bootstrap');
287
+ const source =
288
+ `import { installResolverFromPage } from ${JSON.stringify(bootstrapEntry)};\n`
289
+ + `const { base } = installResolverFromPage();\n${livereloadSnippet}`;
290
+ const bundle = await engine.rollup({
291
+ input: VIRTUAL_PROLOG,
292
+ plugins: [
293
+ {
294
+ name: 'mosetta-loader-prolog',
295
+ resolveId: (id) => (id === VIRTUAL_PROLOG ? id : null),
296
+ load: (id) => (id === VIRTUAL_PROLOG ? source : null),
297
+ },
298
+ ...userPluginsFor(buildConfig, 'prolog', mode),
299
+ defineEnv(mode),
300
+ portResolve(fileSystem, resolver, path.dirname(bootstrapEntry)),
301
+ commonjs(),
302
+ transformTs(engine),
303
+ ],
304
+ onwarn(w, warn) {
305
+ if (w.code === 'CIRCULAR_DEPENDENCY') return;
306
+ warn(w);
307
+ },
308
+ });
309
+ const { output } = await bundle.generate({
310
+ format: 'iife', name: 'mosettaLoaderProlog', plugins: mode === 'production' ? [minifyChunk(engine)] : [],
311
+ });
312
+ const watchFiles = bundle.watchFiles.slice();
313
+ // the GENERATOR is part of the prolog's graph: the livereload/overlay snippet is authored
314
+ // right here as a string, invisible to rollup — without this line an edit to it would keep
315
+ // serving stale cached prologs (in-memory AND on-disk) until the cache is wiped by hand.
316
+ // Non-file realms (an in-browser build) skip it: a missing path stamps as a constant.
317
+ if (import.meta.url.startsWith('file:')) watchFiles.push(new URL(import.meta.url).pathname);
318
+ await bundle.close();
319
+ prologCache = {
320
+ mode,
321
+ jsCode: output.find((o) => o.type === 'chunk').code,
322
+ watchFiles,
323
+ stamp: mtimeStamp(fileSystem, watchFiles),
324
+ configStamp: buildConfig?.stamp ?? 'none',
325
+ };
326
+ return prologCache;
327
+ }
328
+
@@ -0,0 +1,155 @@
1
+ import { path } from './nodeApi.mjs';
2
+ import { fullTileName } from './moduleName.mjs';
3
+ import { portResolveEsbuild } from './plugins/portResolveEsbuild.mjs';
4
+ import { monolithCompat, monolithCompatOptions } from './plugins/monolithCompat.mjs';
5
+ import { MONOLITH_CONDITIONS } from './conditions.mjs';
6
+ import { mtimeStamp } from './artifactCache.mjs';
7
+
8
+ // Private monoliths are built by esbuild (self-contained ESM, everything inside), but it no
9
+ // longer resolves BY ITSELF — all I/O goes through the port via `portResolveEsbuild`, and the
10
+ // engine itself is injected from outside (`engine.bundle`). `absWorkingDir` is pinned to the
11
+ // distributive root: esbuild bakes the path into module identity (`__commonJS` keys, comments),
12
+ // and without pinning the shape depends on the working directory — engines diverge and the
13
+ // build stops being reproducible.
14
+ // `frameworkDir` — the directory GENERATED facets resolve their imports from (the default
15
+ // the generated ssr default is built from a string source, which imports framework packages,
16
+ // `@mosetta/server` and react). It used to be derived from `import.meta.url` — and that broke
17
+ // exactly like the `fileSystem`/`engine` defaults did: the binding came from the CODE's location
18
+ // instead of being supplied from outside, and did not survive bundling into an isolate.
19
+ const portOptions = (fileSystem, resolver, sourceBase, frameworkDir, wasmAssets = null, watched = null) => ({
20
+ plugins: [
21
+ ...(wasmAssets ? [wasmAssetsPlugin(resolver, frameworkDir, wasmAssets)] : []),
22
+ // workerd compatibility — BEFORE the port resolver: package substitutions and onLoad patches
23
+ // must fire before the port resolves/reads the files itself
24
+ monolithCompat(fileSystem, resolver),
25
+ portResolveEsbuild(fileSystem, resolver, frameworkDir, MONOLITH_CONDITIONS, watched),
26
+ ],
27
+ ...monolithCompatOptions(),
28
+ ...(sourceBase ? { absWorkingDir: sourceBase } : {}),
29
+ });
30
+
31
+ // Wasm assets of a server facet: a `*.wasm` import is NOT bundled — runtime wasm compilation is
32
+ // forbidden in workerd, an engine can only exist as a declared bundle module. The import
33
+ // resolves to a file, the file travels as an asset next to the monolith (and into
34
+ // descriptor.assets — the runner puts it into the bundle as `{ wasm }`), while the code keeps
35
+ // `./<name>.wasm` — exactly the specifier the runner will declare the module under. Placed
36
+ // FIRST in the chain: otherwise the port resolver would resolve .wasm to a path and esbuild
37
+ // would try to read it as js.
38
+ function wasmAssetsPlugin(resolver, frameworkDir, collected) {
39
+ return {
40
+ name: 'mosetta-wasm-assets',
41
+ setup(build) {
42
+ build.onResolve({ filter: /\.wasm$/ }, (args) => {
43
+ const fromDir = args.resolveDir || frameworkDir;
44
+ const file = resolver.resolve(args.path, fromDir, MONOLITH_CONDITIONS);
45
+ const name = file.slice(file.lastIndexOf('/') + 1);
46
+ const known = collected.get(name);
47
+ if (known && known !== file) {
48
+ throw new Error(`wasm assets with the same name: ${name} (${known} and ${file})`);
49
+ }
50
+ collected.set(name, file);
51
+ return { path: `./${name}`, external: true };
52
+ });
53
+ },
54
+ };
55
+ }
56
+
57
+ // server artifacts get EXTERNAL maps in every mode: they live in a private store, so
58
+ // the map sits right next to them (writeJs appends the co-located sourceMappingURL). esbuild with
59
+ // write:false emits two virtual files — pick them apart. `outdir` only shapes the virtual paths.
60
+ const SOURCEMAP = { sourcemap: 'external', outdir: '.' };
61
+ const pickOutputs = (result) => ({
62
+ jsCode: result.outputFiles.find((f) => !f.path.endsWith('.map')).text,
63
+ map: result.outputFiles.find((f) => f.path.endsWith('.map'))?.text ?? null,
64
+ });
65
+
66
+ // Build a server-action module into a SELF-CONTAINED monolith. Unlike client modules
67
+ // (Rollup → SystemJS, deps externalized for singleton sharing), a server module runs alone in a Worker
68
+ // Loader isolate — no sharing concern — so we bundle EVERYTHING in (`external: []`) into one ESM. Web
69
+ // API only (`platform: 'neutral'`) → runs unchanged in the workerd isolate. The coder authors
70
+ // `export default defineServerSide({ services })`; the monolith re-exports `module`,
71
+ // which the static Worker-Loader glue imports.
72
+ // user hook: transforms the composed esbuild options of a server-side monolith build
73
+ function applyServerHook(buildConfig, options, target, mode) {
74
+ return buildConfig?.serverEsbuild?.(options, { target, mode }) ?? options;
75
+ }
76
+
77
+ export async function buildServerModule(fileSystem, { file, name, type, namespace, version }, { resolver, mode = 'development', buildConfig = null, engine, sourceBase, frameworkDir } = {}) {
78
+ const full = fullTileName({ namespace, type, name, version });
79
+ const wasmAssets = new Map(); // asset name -> absolute source file
80
+ const watched = new Set(); // the monolith's source graph — for invalidation and watching
81
+ const result = await engine.bundle(applyServerHook(buildConfig, {
82
+ ...portOptions(fileSystem, resolver, sourceBase, frameworkDir, wasmAssets, watched),
83
+ entryPoints: [file],
84
+ bundle: true,
85
+ format: 'esm',
86
+ platform: 'neutral',
87
+ target: 'es2022',
88
+ write: false,
89
+ minify: mode === 'production',
90
+ logLevel: 'silent', // the caller reports a concise warn-skip on failure; suppress esbuild's own dump
91
+ ...SOURCEMAP,
92
+ tsconfigRaw: { compilerOptions: { experimentalDecorators: true, jsx: 'automatic' } },
93
+ }, 'server', mode));
94
+ return { full, wasmAssets, watchFiles: [...watched], ...pickOutputs(result) };
95
+ }
96
+
97
+ // ---- ssr facet of app modules (the shell is a degenerate SSR case) --------------------------
98
+
99
+ // The DEFAULT ssr grain: generated when the app module has no <name>.ssr.tsx of its own
100
+ // (convention-with-override). A coder's grain replaces it entirely — that
101
+ // is also where another binding plugs in: the definer lives WITH its binding (@mosetta/react/ssr
102
+ // over the server's engine), so a preact page writes preact's.
103
+ //
104
+ // It resolves from the APP's directory, not the framework's, precisely because of that import:
105
+ // which binding renders a page is the APP's choice, and the app is what has it installed.
106
+ const DEFAULT_SSR_SOURCE = `import { defineSsrSide } from '@mosetta/react/ssr';
107
+
108
+ export default defineSsrSide();
109
+ `;
110
+
111
+ // the default bundles react-dom/server — identical across app modules and rebuilds, so memoize
112
+ // (keyed by the build-config stamp too: a serverEsbuild hook edit must not serve a stale default).
113
+ // VALIDATED by its graph's mtime, like the prolog: the dev server lives long, and the default
114
+ // facet is built from the FRAMEWORK's sources — in the monorepo they are hand-edited and must
115
+ // be picked up.
116
+ let defaultSsrCache = null; // { mode, configStamp, jsCode, map, watchFiles, stamp }
117
+
118
+ export async function buildSsrModule(fileSystem, { file, name, namespace, version }, { resolver, mode = 'development', buildConfig = null, engine, sourceBase, frameworkDir } = {}) {
119
+ const full = fullTileName({ namespace, type: 'ssr', name, version });
120
+ const grain = path.join(path.dirname(file), `${name}.ssr.tsx`);
121
+ const custom = fileSystem.exists(grain);
122
+
123
+ const configStamp = buildConfig?.stamp ?? 'none';
124
+ if (!custom && defaultSsrCache && defaultSsrCache.mode === mode && defaultSsrCache.configStamp === configStamp
125
+ && mtimeStamp(fileSystem, defaultSsrCache.watchFiles) === defaultSsrCache.stamp) {
126
+ return {
127
+ full, custom, jsCode: defaultSsrCache.jsCode, map: defaultSsrCache.map,
128
+ watchFiles: defaultSsrCache.watchFiles, watchFile: null,
129
+ };
130
+ }
131
+ const watched = new Set();
132
+ const result = await engine.bundle(applyServerHook(buildConfig, {
133
+ ...portOptions(fileSystem, resolver, sourceBase, frameworkDir, null, watched),
134
+ ...(custom
135
+ ? { entryPoints: [grain] }
136
+ : { stdin: { contents: DEFAULT_SSR_SOURCE, resolveDir: sourceBase ?? frameworkDir, sourcefile: `${name}.ssr.default.ts`, loader: 'ts' } }),
137
+ bundle: true,
138
+ format: 'esm',
139
+ platform: 'neutral',
140
+ target: 'es2022',
141
+ write: false,
142
+ minify: mode === 'production',
143
+ logLevel: 'silent',
144
+ ...SOURCEMAP,
145
+ // react packages branch on it; workerd has no `process`
146
+ define: { ...monolithCompatOptions().define, 'process.env.NODE_ENV': JSON.stringify(mode) },
147
+ tsconfigRaw: { compilerOptions: { jsx: 'automatic' } },
148
+ }, 'ssr', mode));
149
+ const { jsCode, map } = pickOutputs(result);
150
+ const watchFiles = [...watched];
151
+ if (!custom) {
152
+ defaultSsrCache = { mode, configStamp, jsCode, map, watchFiles, stamp: mtimeStamp(fileSystem, watchFiles) };
153
+ }
154
+ return { full, custom, jsCode, map, watchFiles, watchFile: custom ? grain : null };
155
+ }
@@ -0,0 +1,81 @@
1
+ import { path } from './nodeApi.mjs';
2
+ import { parse, init } from 'cjs-module-lexer';
3
+
4
+ // the lexer's wasm is embedded in the package as base64 (no fetch, no file) — init is one-shot and lazy
5
+ let ready = null;
6
+ function ensureLexer() {
7
+ if (!ready) ready = init();
8
+ return ready;
9
+ }
10
+
11
+ const IDENTIFIER = /^[A-Za-z_$][A-Za-z0-9_$]*$/;
12
+ // `default` — arrives separately via the facade; `__esModule` is an interop MARKER, not an
13
+ // export: it is set non-enumerable (`Object.defineProperty`), so the old runtime walk never saw it.
14
+ const NOT_AN_EXPORT = new Set(['default', '__esModule']);
15
+ const LEXABLE_EXT = new Set(['.js', '.cjs']);
16
+ const MAX_REEXPORT_DEPTH = 16;
17
+
18
+ /**
19
+ * Named exports of a CJS module — by STATIC parsing.
20
+ *
21
+ * Needed by the dependency artifact's facade: a cross-artifact
22
+ * `import { createContext } from 'react'` requires the artifact to serve REAL named exports —
23
+ * default-based CJS interop loses them at artifact boundaries.
24
+ *
25
+ * The list used to be obtained by executing the module (`require(file)` + `Object.keys`).
26
+ * Parsing the string instead of executing removes three things at once: running foreign code on
27
+ * the build machine, depending on the node loader, and the last reason to keep `cjsNamedExports`
28
+ * in the port — the function only needs `readFile` and `resolve`, so it lives OUTSIDE the port
29
+ * and works with any implementation of it. The lexer is the same `cjs-module-lexer` node itself
30
+ * uses to determine CJS named exports.
31
+ *
32
+ * Returns string[], or null when this is not parseable CJS (ESM, another extension, a syntax
33
+ * error) — then rollup handles the module natively, as before.
34
+ */
35
+ export async function cjsNamedExports(fileSystem, resolver, file, fromDir) {
36
+ if (!LEXABLE_EXT.has(path.extname(file))) return null;
37
+ await ensureLexer();
38
+
39
+ const collected = new Set();
40
+ const visited = new Set();
41
+
42
+ // `module.exports = require('./cjs/react.production.js')` — the form half of real-world npm
43
+ // lives in: the entry itself exports NOTHING, everything is in the re-export target.
44
+ const walk = (target, dir, depth) => {
45
+ if (depth > MAX_REEXPORT_DEPTH || visited.has(target)) return true;
46
+ visited.add(target);
47
+
48
+ let source;
49
+ try {
50
+ source = fileSystem.readFile(target, 'utf8');
51
+ } catch {
52
+ return false; // file missing / unreadable — treat the parse as failed
53
+ }
54
+
55
+ let parsed;
56
+ try {
57
+ parsed = parse(source, target);
58
+ } catch {
59
+ return false; // ESM syntax or broken CJS — let rollup decide on its own
60
+ }
61
+
62
+ for (const name of parsed.exports) {
63
+ if (!NOT_AN_EXPORT.has(name) && IDENTIFIER.test(name)) collected.add(name);
64
+ }
65
+
66
+ const nextDir = path.dirname(target);
67
+ for (const specifier of parsed.reexports) {
68
+ let resolved;
69
+ try {
70
+ resolved = resolver.resolve(specifier, nextDir);
71
+ } catch {
72
+ continue; // unresolvable re-export (conditional/optional) — simply adds no names
73
+ }
74
+ if (!LEXABLE_EXT.has(path.extname(resolved))) continue;
75
+ if (!walk(resolved, nextDir, depth + 1)) return false;
76
+ }
77
+ return true;
78
+ };
79
+
80
+ return walk(file, fromDir, 0) ? [...collected] : null;
81
+ }