@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.
- package/CHANGELOG.md +144 -0
- package/LICENSE +21 -0
- package/README.md +14 -2
- package/package.json +34 -3
- package/src/archive.mjs +140 -0
- package/src/artifactCache.mjs +78 -0
- package/src/browserField.mjs +90 -0
- package/src/build.mjs +576 -0
- package/src/buildConfig.mjs +22 -0
- package/src/buildDependencies.mjs +97 -0
- package/src/buildModules.mjs +328 -0
- package/src/buildServer.mjs +155 -0
- package/src/cjsExports.mjs +81 -0
- package/src/conditions.mjs +21 -0
- package/src/configBundle.mjs +59 -0
- package/src/css.mjs +108 -0
- package/src/discovery.mjs +184 -0
- package/src/fileSystem.mjs +69 -0
- package/src/format.mjs +4 -0
- package/src/hash.mjs +13 -0
- package/src/memoryFileSystem.mjs +81 -0
- package/src/moduleName.mjs +11 -0
- package/src/nodeApi.mjs +22 -0
- package/src/packageMeta.mjs +37 -0
- package/src/packagesResolver.mjs +159 -0
- package/src/plugins/assets.mjs +41 -0
- package/src/plugins/cssIntercept.mjs +38 -0
- package/src/plugins/defineEnv.mjs +14 -0
- package/src/plugins/monolithCompat.mjs +84 -0
- package/src/plugins/portResolve.mjs +80 -0
- package/src/plugins/portResolveEsbuild.mjs +103 -0
- package/src/plugins/transform.mjs +56 -0
- package/src/probeFile.mjs +65 -0
- package/src/release.mjs +516 -0
- package/src/scanner.mjs +397 -0
- package/src/settings.mjs +39 -0
- package/src/sourcemap.mjs +26 -0
- package/src/specifiers.mjs +76 -0
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `exports` conditions of the target environments.
|
|
3
|
+
*
|
|
4
|
+
* Conditions are a statement about the TARGET, not a property of the resolver. A dual package's
|
|
5
|
+
* `require` and `import` branches are different files, and if the condition is not set it gets
|
|
6
|
+
* picked as a side effect of what the resolver is written with: `createRequire` always says
|
|
7
|
+
* "I am require, I am node" about itself, even when building code for the browser. The failure
|
|
8
|
+
* is silent — the build passes, things break later.
|
|
9
|
+
*
|
|
10
|
+
* That is why the policy lives here, apart from the mechanics, and is visible as a whole.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
// Client artifacts execute in the browser (SystemJS) AND in ssr isolates (MiniSystem, workerd) —
|
|
14
|
+
// it is the same artifact set. The browser ESM branch suits both environments: it contains no
|
|
15
|
+
// node specifics by construction.
|
|
16
|
+
export const CLIENT_CONDITIONS = ['browser', 'import'];
|
|
17
|
+
|
|
18
|
+
// A private monolith is a self-contained ESM targeting Web API (`platform: 'neutral'`). We do
|
|
19
|
+
// not claim `browser` here: the monolith executes in an isolate, not in a document, and a
|
|
20
|
+
// package's browser branch may pull DOM dependencies.
|
|
21
|
+
export const MONOLITH_CONDITIONS = ['import'];
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
import { path } from './nodeApi.mjs';
|
|
2
|
+
import { portResolveEsbuild } from './plugins/portResolveEsbuild.mjs';
|
|
3
|
+
import { monolithCompat, monolithCompatOptions } from './plugins/monolithCompat.mjs';
|
|
4
|
+
import { MONOLITH_CONDITIONS } from './conditions.mjs';
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Bundling the tenant's `mosetta.build.mjs` FOR PHASE 2 of recomposition.
|
|
8
|
+
*
|
|
9
|
+
* The config is foreign code and is NOT executed here: only bundled into one ESM module with
|
|
10
|
+
* the same compatibility set as the monoliths (builtin shims, node:* external, patches). ONLY
|
|
11
|
+
* the runner's composition makes it executable — the module lands in the phase-2 isolate's
|
|
12
|
+
* bundle under the name CONFIG_MODULE, and the action imports it from the bundle.
|
|
13
|
+
*
|
|
14
|
+
* The config's closure is already in the body: the scanner collects seeds from
|
|
15
|
+
* mosetta.build.mjs itself too (SOURCE_EXTENSIONS includes .mjs), so its dependencies arrive
|
|
16
|
+
* normally. A non-portable config (node natives, unresolvable imports) fails here with a
|
|
17
|
+
* bundling error — it goes into the text of the loud refusal.
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
export const CONFIG_MODULE = 'mosetta.config.js';
|
|
21
|
+
|
|
22
|
+
export async function bundleBuildConfig(fileSystem, resolver, { engine, root, frameworkDir }) {
|
|
23
|
+
const file = path.join(root, 'mosetta.build.mjs');
|
|
24
|
+
const result = await engine.bundle({
|
|
25
|
+
entryPoints: [file],
|
|
26
|
+
bundle: true,
|
|
27
|
+
format: 'esm',
|
|
28
|
+
platform: 'neutral',
|
|
29
|
+
target: 'es2022',
|
|
30
|
+
write: false,
|
|
31
|
+
logLevel: 'silent',
|
|
32
|
+
absWorkingDir: root,
|
|
33
|
+
plugins: [
|
|
34
|
+
monolithCompat(fileSystem, resolver),
|
|
35
|
+
portResolveEsbuild(fileSystem, resolver, frameworkDir, MONOLITH_CONDITIONS),
|
|
36
|
+
],
|
|
37
|
+
...monolithCompatOptions(),
|
|
38
|
+
});
|
|
39
|
+
return result.outputFiles.find((f) => !f.path.endsWith('.map')).text;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
const asFn = (v, name) => {
|
|
43
|
+
if (v === undefined || v === null) return null;
|
|
44
|
+
if (typeof v !== 'function') throw new Error(`mosetta.build.mjs: \`${name}\` must be a function`);
|
|
45
|
+
return v;
|
|
46
|
+
};
|
|
47
|
+
|
|
48
|
+
/** Hooks from an ALREADY IMPORTED (by composition) config module — same shape as the CLI's loadBuildConfig. */
|
|
49
|
+
export function buildConfigFrom(ns) {
|
|
50
|
+
const cfg = ns?.default ?? {};
|
|
51
|
+
if (typeof cfg !== 'object') throw new Error('mosetta.build.mjs must default-export an object of hooks');
|
|
52
|
+
return {
|
|
53
|
+
file: 'mosetta.build.mjs',
|
|
54
|
+
stamp: 'recomposed', // the composition's identity already includes the config module's hash
|
|
55
|
+
clientPlugins: asFn(cfg.clientPlugins, 'clientPlugins'),
|
|
56
|
+
serverEsbuild: asFn(cfg.serverEsbuild, 'serverEsbuild'),
|
|
57
|
+
rollupOptions: asFn(cfg.rollupOptions, 'rollupOptions'),
|
|
58
|
+
};
|
|
59
|
+
}
|
package/src/css.mjs
ADDED
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
import { path } from './nodeApi.mjs';
|
|
2
|
+
import postcss from 'postcss';
|
|
3
|
+
import valueParser from 'postcss-value-parser';
|
|
4
|
+
import { hashedAssetName } from './hash.mjs';
|
|
5
|
+
|
|
6
|
+
// Scope every rule's selectors under `.<scopeClass>` (skip @keyframes steps).
|
|
7
|
+
function scopePlugin(scopeClass) {
|
|
8
|
+
const plugin = () => ({
|
|
9
|
+
postcssPlugin: 'mosetta-scope',
|
|
10
|
+
// Once + walkRules visits each existing rule exactly once (the Rule visitor re-runs on mutation).
|
|
11
|
+
Once(root) {
|
|
12
|
+
root.walkRules((rule) => {
|
|
13
|
+
const parent = rule.parent;
|
|
14
|
+
if (parent && parent.type === 'atrule' && /keyframes$/i.test(parent.name)) return;
|
|
15
|
+
rule.selectors = rule.selectors.map((s) => `.${scopeClass} ${s}`);
|
|
16
|
+
});
|
|
17
|
+
},
|
|
18
|
+
});
|
|
19
|
+
plugin.postcss = true;
|
|
20
|
+
return plugin();
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
// Neutralize global root selectors (html/body/:root/:host) in UNscoped shared css: under the
|
|
24
|
+
// per-module scope these rules were dead (`.<full> body` never matches) — we keep the same
|
|
25
|
+
// semantics deterministically and byte-identically: a dead-class prefix. Disciplined css
|
|
26
|
+
// (.dui-root) never touches this path.
|
|
27
|
+
function neutralizeGlobals() {
|
|
28
|
+
const GLOBAL_ROOT = /^(html|body|:root|:host)(?![\w-])/;
|
|
29
|
+
const plugin = () => ({
|
|
30
|
+
postcssPlugin: 'mosetta-neutralize-globals',
|
|
31
|
+
Once(root) {
|
|
32
|
+
root.walkRules((rule) => {
|
|
33
|
+
const parent = rule.parent;
|
|
34
|
+
if (parent && parent.type === 'atrule' && /keyframes$/i.test(parent.name)) return;
|
|
35
|
+
rule.selectors = rule.selectors.map((s) => (GLOBAL_ROOT.test(s.trim()) ? `.mosetta-neutralized-global ${s}` : s));
|
|
36
|
+
});
|
|
37
|
+
},
|
|
38
|
+
});
|
|
39
|
+
plugin.postcss = true;
|
|
40
|
+
return plugin();
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
// Rewrite relative url(...) refs → ./<hashed>, collecting assets to co-locate next to the stylesheet.
|
|
44
|
+
function urlRewrite(fileSystem, cssDir, assets) {
|
|
45
|
+
const plugin = () => ({
|
|
46
|
+
postcssPlugin: 'mosetta-url-rewrite',
|
|
47
|
+
Declaration(decl) {
|
|
48
|
+
if (!decl.value || !decl.value.includes('url(')) return;
|
|
49
|
+
const parsed = valueParser(decl.value);
|
|
50
|
+
parsed.walk((node) => {
|
|
51
|
+
if (node.type !== 'function' || node.value !== 'url' || !node.nodes[0]) return;
|
|
52
|
+
const urlNode = node.nodes[0];
|
|
53
|
+
const raw = urlNode.value;
|
|
54
|
+
if (!raw || /^(https?:|data:|#|\/)/.test(raw)) return; // skip absolute / data / anchor
|
|
55
|
+
const abs = path.resolve(cssDir, raw);
|
|
56
|
+
if (!fileSystem.exists(abs)) return;
|
|
57
|
+
const buf = fileSystem.readFile(abs);
|
|
58
|
+
const out = hashedAssetName(abs, buf);
|
|
59
|
+
assets.set(out, abs);
|
|
60
|
+
urlNode.value = './' + out;
|
|
61
|
+
});
|
|
62
|
+
decl.value = parsed.toString();
|
|
63
|
+
},
|
|
64
|
+
});
|
|
65
|
+
plugin.postcss = true;
|
|
66
|
+
return plugin();
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* Css minification — a prod post-pass by the SAME engine that minifies js (`minifyChunk`).
|
|
71
|
+
*
|
|
72
|
+
* A dedicated css tool (lightningcss and the like) is deliberately not bundled: the only thing
|
|
73
|
+
* we were missing is compression, and the engine already has it; a full css compiler is +16 MB
|
|
74
|
+
* of wasm in EVERY isolate's bundle (the whole build bundle is 18.9 MB today) for percentage
|
|
75
|
+
* points of compression and browserslist lowering — i.e. for one distributive's configuration.
|
|
76
|
+
* Whoever needs that plugs their own into `mosetta.build.mjs` (the hook path was verified with
|
|
77
|
+
* lightningcss-wasm exactly).
|
|
78
|
+
*
|
|
79
|
+
* `target` is NOT set: we want compression, not syntax rewriting — lowering for old browsers is
|
|
80
|
+
* the tenant's choice, and the build must not silently change css semantics on their behalf.
|
|
81
|
+
*/
|
|
82
|
+
async function minifyCss(engine, css) {
|
|
83
|
+
const r = await engine.transform(css, { loader: 'css', minify: true });
|
|
84
|
+
return r.code;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* Process a module's CSS files: scope selectors with `.<scopeClass>` (dev+prod) and rewrite
|
|
89
|
+
* relative url() to co-located hashed assets. Returns { css, assets: Map(out -> srcAbs) }.
|
|
90
|
+
* scopeClass === null → no scoping (dependency css isolates itself).
|
|
91
|
+
*
|
|
92
|
+
* Minification — production only and AFTER scoping/`url()` rewriting: compression gets exactly
|
|
93
|
+
* the css that would ship in the distributive, with scope prefixes and `./<hashed>` links in place.
|
|
94
|
+
*/
|
|
95
|
+
export async function processCss({ fileSystem, cssFiles, scopeClass, engine, mode = 'development' }) {
|
|
96
|
+
const assets = new Map();
|
|
97
|
+
let combined = '';
|
|
98
|
+
for (const file of cssFiles) {
|
|
99
|
+
const raw = fileSystem.readFile(file, 'utf8');
|
|
100
|
+
const result = await postcss([
|
|
101
|
+
...(scopeClass ? [scopePlugin(scopeClass)] : [neutralizeGlobals()]),
|
|
102
|
+
urlRewrite(fileSystem, path.dirname(file), assets),
|
|
103
|
+
]).process(raw, { from: file });
|
|
104
|
+
combined += result.css + '\n';
|
|
105
|
+
}
|
|
106
|
+
const css = mode === 'production' ? await minifyCss(engine, combined) : combined;
|
|
107
|
+
return { css, assets };
|
|
108
|
+
}
|
|
@@ -0,0 +1,184 @@
|
|
|
1
|
+
import { path } from './nodeApi.mjs';
|
|
2
|
+
import { packageDirOf } from './packageMeta.mjs';
|
|
3
|
+
|
|
4
|
+
// No logger is imported here: the console is a host capability, and discovery is portable.
|
|
5
|
+
// Warnings go into the injected `log`.
|
|
6
|
+
const SILENT = () => {};
|
|
7
|
+
|
|
8
|
+
// Module entry convention: <name>/<name>.<type>.(ts|tsx) (folder name === module name)
|
|
9
|
+
const MODULE_FILE_RE = /^(?<name>[a-z][A-Za-z0-9]*)\.(?<type>[a-z][A-Za-z0-9]*)\.(?:ts|tsx)$/;
|
|
10
|
+
// Content entities (pages/prefabs) follow the same lowerCamel naming (folder-per-entity).
|
|
11
|
+
const ENTITY_NAME_RE = /^[a-z][A-Za-z0-9]*$/;
|
|
12
|
+
|
|
13
|
+
// Module types that are NOT built for the client distributive. `settings` is no module at all: a
|
|
14
|
+
// block's settings schema beside it, read by the build into the block's catalog entry.
|
|
15
|
+
const NON_CLIENT_TYPES = new Set(['server', 'router', 'settings']);
|
|
16
|
+
|
|
17
|
+
// A tile palette's config: `mosetta.json`. Its `brand` is whose tiles these are (ADR-0071) — on the
|
|
18
|
+
// wire it stays the namespace, the `_ns_` of every tile id, so the build carries it as `namespace`.
|
|
19
|
+
export function readConfig(fileSystem, root) {
|
|
20
|
+
const file = path.join(root, 'mosetta.json');
|
|
21
|
+
const cfg = fileSystem.readJson(file);
|
|
22
|
+
if (cfg && typeof cfg === 'object' && 'namespace' in cfg) {
|
|
23
|
+
throw new Error(`${file}: "namespace" is called "brand" now — rename the key`);
|
|
24
|
+
}
|
|
25
|
+
return cfg && typeof cfg === 'object' && typeof cfg.brand === 'string' ? { ...cfg, namespace: cfg.brand } : cfg;
|
|
26
|
+
}
|
|
27
|
+
function readPkg(fileSystem, root) {
|
|
28
|
+
return fileSystem.readJson(path.join(root, 'package.json'));
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/** a distribution's version: plain X.Y.Z — a release is immutable, and its id must name it exactly */
|
|
32
|
+
const RELEASE_VERSION_RE = /^\d+\.\d+\.\d+$/;
|
|
33
|
+
|
|
34
|
+
/** the version a distribution is built under, or a build error saying what it must be */
|
|
35
|
+
export function releaseVersionOf(version, namespace, dir) {
|
|
36
|
+
if (typeof version !== 'string' || !RELEASE_VERSION_RE.test(version)) {
|
|
37
|
+
throw new Error(`tile palette ${namespace} (${dir}): version "${version}" is not X.Y.Z — a deployed version never changes, and its tile ids must name it exactly (pre-releases are not supported)`);
|
|
38
|
+
}
|
|
39
|
+
return version;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Distributive roots = the app itself (if type library) + `libraries` + `devLibraries` (dev only).
|
|
44
|
+
* Each: { dir, namespace, version, isDev }.
|
|
45
|
+
*/
|
|
46
|
+
export function discoverRoots(fileSystem, root, { resolver, includeDev = true, log = SILENT } = {}) {
|
|
47
|
+
const cfg = readConfig(fileSystem, root);
|
|
48
|
+
const pkg = readPkg(fileSystem, root);
|
|
49
|
+
const roots = [];
|
|
50
|
+
|
|
51
|
+
if (cfg.type === 'library') {
|
|
52
|
+
roots.push({ dir: root, namespace: cfg.namespace, version: releaseVersionOf(pkg.version, cfg.namespace, root), isDev: false });
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
const resolveLib = (lib, isDev) => {
|
|
56
|
+
// editor & co. only export '.', so resolve the main entry then walk up to its package.json.
|
|
57
|
+
let mainPath;
|
|
58
|
+
try {
|
|
59
|
+
mainPath = resolver.resolve(lib, root);
|
|
60
|
+
} catch (e) {
|
|
61
|
+
log(` ! library ${lib} not resolved (${e.code || e.message}) — skipped`);
|
|
62
|
+
return null;
|
|
63
|
+
}
|
|
64
|
+
const dir = packageDirOf(fileSystem, mainPath);
|
|
65
|
+
if (!dir) {
|
|
66
|
+
log(` ! library ${lib}: package.json not found — skipped`);
|
|
67
|
+
return null;
|
|
68
|
+
}
|
|
69
|
+
const libCfg = readConfig(fileSystem, dir);
|
|
70
|
+
const libPkg = readPkg(fileSystem, dir);
|
|
71
|
+
return { dir, namespace: libCfg.namespace, version: releaseVersionOf(libPkg.version, libCfg.namespace, dir), isDev };
|
|
72
|
+
};
|
|
73
|
+
|
|
74
|
+
for (const lib of cfg.libraries || []) {
|
|
75
|
+
const r = resolveLib(lib, false);
|
|
76
|
+
if (r) roots.push(r);
|
|
77
|
+
}
|
|
78
|
+
if (includeDev) {
|
|
79
|
+
for (const lib of cfg.devLibraries || []) {
|
|
80
|
+
const r = resolveLib(lib, true);
|
|
81
|
+
if (r) roots.push(r);
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
return roots;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
// Walk <root.dir>/tiles (the tile sources) for entries matching the naming convention whose type passes `keepType`.
|
|
88
|
+
// Returns [{ file, name, type, namespace, version }].
|
|
89
|
+
function collectModules(fileSystem, { dir, namespace, version }, keepType) {
|
|
90
|
+
const modulesDir = path.join(dir, 'tiles');
|
|
91
|
+
const out = [];
|
|
92
|
+
if (!fileSystem.exists(modulesDir)) return out;
|
|
93
|
+
|
|
94
|
+
const walk = (d) => {
|
|
95
|
+
for (const entry of fileSystem.readDir(d)) {
|
|
96
|
+
const p = path.join(d, entry.name);
|
|
97
|
+
if (entry.isDirectory) {
|
|
98
|
+
walk(p);
|
|
99
|
+
continue;
|
|
100
|
+
}
|
|
101
|
+
const m = MODULE_FILE_RE.exec(entry.name);
|
|
102
|
+
if (!m) continue;
|
|
103
|
+
if (path.basename(path.dirname(p)) !== m.groups.name) continue;
|
|
104
|
+
if (!keepType(m.groups.type)) continue;
|
|
105
|
+
out.push({ file: p, name: m.groups.name, type: m.groups.type, namespace, version });
|
|
106
|
+
}
|
|
107
|
+
};
|
|
108
|
+
|
|
109
|
+
walk(modulesDir);
|
|
110
|
+
return out;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/**
|
|
114
|
+
* Discover CLIENT module entries (everything except server-only types → the browser distributive).
|
|
115
|
+
*/
|
|
116
|
+
export function discoverModulesIn(fileSystem, root) {
|
|
117
|
+
return collectModules(fileSystem, root, (type) => !NON_CLIENT_TYPES.has(type));
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/**
|
|
121
|
+
* Discover SERVER-action module entries (`.server.ts`). Built as PRIVATE monoliths,
|
|
122
|
+
* executed in a Worker Loader isolate — not client artifacts.
|
|
123
|
+
*/
|
|
124
|
+
export function discoverServerModulesIn(fileSystem, root) {
|
|
125
|
+
return collectModules(fileSystem, root, (type) => type === 'server');
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/**
|
|
129
|
+
* Discover ROUTER facets (`.router.ts`) — only to refuse them: there is no router any more
|
|
130
|
+
* (ADR-0053), the build names a leftover facet instead of ignoring it.
|
|
131
|
+
*/
|
|
132
|
+
export function discoverRouterModulesIn(fileSystem, root) {
|
|
133
|
+
return collectModules(fileSystem, root, (type) => type === 'router');
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
// Revisioned content entity: `<collection>/<name>/<metaFile>` + `versions/<rev>.json`.
|
|
137
|
+
// Only the LIVE (published) revision is distributed; entities without a live revision (drafts only)
|
|
138
|
+
// are editor-material and never reach the dist. Returns { meta, json } or null.
|
|
139
|
+
function readLiveEntity(fileSystem, collectionDir, name, metaFile, log) {
|
|
140
|
+
let meta;
|
|
141
|
+
try {
|
|
142
|
+
meta = fileSystem.readJson(path.join(collectionDir, name, metaFile));
|
|
143
|
+
} catch {
|
|
144
|
+
return null;
|
|
145
|
+
}
|
|
146
|
+
if (!meta || typeof meta.live !== 'number') return null;
|
|
147
|
+
let json;
|
|
148
|
+
try {
|
|
149
|
+
json = fileSystem.readJson(path.join(collectionDir, name, 'versions', `${meta.live}.json`));
|
|
150
|
+
} catch {
|
|
151
|
+
log(` ! ${name}: live revision ${meta.live} file missing/broken — skipped`);
|
|
152
|
+
return null;
|
|
153
|
+
}
|
|
154
|
+
return { meta, json };
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
function discoverEntitiesIn(fileSystem, dir, collection, metaFile, log) {
|
|
158
|
+
const collectionDir = path.join(dir, collection);
|
|
159
|
+
const out = [];
|
|
160
|
+
if (!fileSystem.exists(collectionDir)) return out;
|
|
161
|
+
for (const entry of fileSystem.readDir(collectionDir)) {
|
|
162
|
+
if (!entry.isDirectory || !ENTITY_NAME_RE.test(entry.name)) continue;
|
|
163
|
+
const live = readLiveEntity(fileSystem, collectionDir, entry.name, metaFile, log);
|
|
164
|
+
if (live) out.push({ name: entry.name, ...live });
|
|
165
|
+
}
|
|
166
|
+
return out;
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
/**
|
|
170
|
+
* Discover prefabs (live revisions) in <dir>/prefabs. Returns [{ name, type, json }] —
|
|
171
|
+
* type comes from the entity meta (it moved out of the file name).
|
|
172
|
+
*/
|
|
173
|
+
export function discoverPrefabsIn(fileSystem, dir, log = SILENT) {
|
|
174
|
+
return discoverEntitiesIn(fileSystem, dir, 'prefabs', 'prefab.json', log)
|
|
175
|
+
.map(({ name, meta, json }) => ({ name, type: meta.type || 'unit', json }));
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
// Pages are a SEPARATE collection from prefabs (prefabs are reusable templates; a page is a routed,
|
|
179
|
+
// servable tree). Folder per page in `<root>/pages/<name>/` (meta + revisions). Edited by
|
|
180
|
+
// the content manager (in dev — coder via the local editor); in prod they live in S3.
|
|
181
|
+
export function discoverPagesIn(fileSystem, dir, log = SILENT) {
|
|
182
|
+
return discoverEntitiesIn(fileSystem, dir, 'pages', 'page.json', log)
|
|
183
|
+
.map(({ name, json }) => ({ name, json }));
|
|
184
|
+
}
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Build environment port: file system, READ-ONLY.
|
|
3
|
+
*
|
|
4
|
+
* The name is literal: the port answers exactly the questions about files. Package resolution
|
|
5
|
+
* is not part of it — that is a separate abstraction (`PackagesResolver`) ON TOP of the port:
|
|
6
|
+
* it needs files, but is itself policy over the package catalog, not an environment capability.
|
|
7
|
+
* Pure math — `node:path` and `node:crypto` — is deliberately outside the port: both modules
|
|
8
|
+
* exist in workerd under `nodejs_compat`, and wrapping them would breed an abstraction with no
|
|
9
|
+
* second implementation.
|
|
10
|
+
*
|
|
11
|
+
* The port ONLY READS: no writes, no execution. This is not tidiness but a property —
|
|
12
|
+
* the build isolate has no capability to change anything; artifacts leave as the return value.
|
|
13
|
+
*
|
|
14
|
+
* The contract is deliberately SYNCHRONOUS on reads: both rollup and postcss call these
|
|
15
|
+
* operations from their synchronous places. A network implementation is not hindered — it
|
|
16
|
+
* arrives with an already-warmed slice (the plan knows the closure in advance) instead of
|
|
17
|
+
* fetching each file at call time.
|
|
18
|
+
*/
|
|
19
|
+
export class FileSystem {
|
|
20
|
+
/** Read a source file. `encoding: 'utf8'` → string, otherwise binary (Uint8Array-compatible). */
|
|
21
|
+
readFile(_file, _encoding = null) {
|
|
22
|
+
throw new Error('FileSystem.readFile is not implemented');
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/** Read + parse JSON. The default is expressed via readFile — implementations need not override. */
|
|
26
|
+
readJson(file) {
|
|
27
|
+
return JSON.parse(this.readFile(file, 'utf8'));
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
exists(_file) {
|
|
31
|
+
throw new Error('FileSystem.exists is not implemented');
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/** Directory listing as [{ name, isDirectory }]. */
|
|
35
|
+
readDir(_dir) {
|
|
36
|
+
throw new Error('FileSystem.readDir is not implemented');
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Is this a directory. Needed by the resolver: `exists` does not tell a file from a directory,
|
|
41
|
+
* and the difference matters — with both `foo.js` and `foo/` present, node picks the FILE.
|
|
42
|
+
* The answer must be SELF-SUFFICIENT, not derived from other calls (spike lesson: on a
|
|
43
|
+
* case-insensitive FS `exists('src/Router')` answers for the `router/` directory, and a
|
|
44
|
+
* derivation from the file map diverges from the truth).
|
|
45
|
+
*/
|
|
46
|
+
isDirectory(dir) {
|
|
47
|
+
try {
|
|
48
|
+
this.readDir(dir);
|
|
49
|
+
return true;
|
|
50
|
+
} catch {
|
|
51
|
+
return false;
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* Canonical path of a file. On node this is symlink dereferencing (pnpm workspace layout);
|
|
57
|
+
* for a graph implementation it is identity: package identity is baked into the id, symlinks
|
|
58
|
+
* do not exist.
|
|
59
|
+
*/
|
|
60
|
+
realPath(_file) {
|
|
61
|
+
throw new Error('FileSystem.realPath is not implemented');
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/** Mtime in milliseconds, or null when the file is missing. Needed by freshness-validated caches. */
|
|
65
|
+
mtimeMs(_file) {
|
|
66
|
+
throw new Error('FileSystem.mtimeMs is not implemented');
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
}
|
package/src/format.mjs
ADDED
package/src/hash.mjs
ADDED
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import { crypto, path } from './nodeApi.mjs';
|
|
2
|
+
|
|
3
|
+
// Short content hash for asset cache-busting (build-time; Node crypto is fine here).
|
|
4
|
+
export function shortHash(buffer) {
|
|
5
|
+
return crypto.createHash('sha1').update(buffer).digest('hex').slice(0, 8);
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
// `image.png` + <buf> -> `image_a1b2c3d4.png`
|
|
9
|
+
export function hashedAssetName(sourcePath, buffer) {
|
|
10
|
+
const ext = path.extname(sourcePath);
|
|
11
|
+
const base = path.basename(sourcePath, ext);
|
|
12
|
+
return `${base}_${shortHash(buffer)}${ext}`;
|
|
13
|
+
}
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
import { path } from './nodeApi.mjs';
|
|
2
|
+
import { FileSystem } from './fileSystem.mjs';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* IN-MEMORY file port (build-action): the unpacked build body — a "path → bytes" map plus a
|
|
6
|
+
* directory index derived on insertion. Case is EXACT (unlike macOS — the `isDirectory` lesson:
|
|
7
|
+
* the port's answers are self-sufficient, nothing to derive). `realPath` is identity (symlinks
|
|
8
|
+
* do not exist in the plan), `mtimeMs` is null (freshness caches have nothing to validate: the
|
|
9
|
+
* plan is immutable).
|
|
10
|
+
*/
|
|
11
|
+
export class MemoryFileSystem extends FileSystem {
|
|
12
|
+
constructor() {
|
|
13
|
+
super();
|
|
14
|
+
this.files = new Map(); // absolute path -> Uint8Array
|
|
15
|
+
this.children = new Map(); // directory -> Map(name -> isDirectory)
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
set(file, bytes) {
|
|
19
|
+
this.files.set(file, bytes);
|
|
20
|
+
let name = path.basename(file);
|
|
21
|
+
let dir = path.dirname(file);
|
|
22
|
+
let isDir = false;
|
|
23
|
+
for (;;) {
|
|
24
|
+
let listing = this.children.get(dir);
|
|
25
|
+
if (!listing) {
|
|
26
|
+
listing = new Map();
|
|
27
|
+
this.children.set(dir, listing);
|
|
28
|
+
}
|
|
29
|
+
const known = listing.has(name);
|
|
30
|
+
listing.set(name, listing.get(name) || isDir);
|
|
31
|
+
if (known) break;
|
|
32
|
+
name = path.basename(dir);
|
|
33
|
+
const parent = path.dirname(dir);
|
|
34
|
+
if (parent === dir) break;
|
|
35
|
+
dir = parent;
|
|
36
|
+
isDir = true;
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
readFile(file, encoding = null) {
|
|
41
|
+
const bytes = this.files.get(file);
|
|
42
|
+
if (bytes === undefined) {
|
|
43
|
+
const e = new Error(`ENOENT: ${file}`);
|
|
44
|
+
e.code = 'ENOENT';
|
|
45
|
+
throw e;
|
|
46
|
+
}
|
|
47
|
+
return encoding ? new TextDecoder().decode(bytes) : bytes;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
exists(file) {
|
|
51
|
+
return this.files.has(file) || this.children.has(file);
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
readDir(dir) {
|
|
55
|
+
const listing = this.children.get(dir);
|
|
56
|
+
if (!listing) {
|
|
57
|
+
const e = new Error(`ENOENT: ${dir} is not a directory`);
|
|
58
|
+
e.code = 'ENOENT';
|
|
59
|
+
throw e;
|
|
60
|
+
}
|
|
61
|
+
return [...listing].map(([name, isDirectory]) => ({ name, isDirectory }));
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
isDirectory(dir) {
|
|
65
|
+
return this.children.has(dir);
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
realPath(file) {
|
|
69
|
+
if (!this.exists(file)) {
|
|
70
|
+
const e = new Error(`ENOENT: ${file}`);
|
|
71
|
+
e.code = 'ENOENT';
|
|
72
|
+
throw e;
|
|
73
|
+
}
|
|
74
|
+
return file;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
mtimeMs() {
|
|
78
|
+
return null;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
}
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
// Canonical module-name encoding: _ns_<namespace>_t_<type>_n_<name>_v_<version>
|
|
2
|
+
// Pure naming primitives live in @mosetta/runtime (browser-safe, shared with the runtime resolver).
|
|
3
|
+
import { normalizeVersion } from '@mosetta/runtime/naming';
|
|
4
|
+
|
|
5
|
+
export { normalizeVersion, dependencyFullId } from '@mosetta/runtime/naming';
|
|
6
|
+
|
|
7
|
+
export const NAME_TOKENS = { ns: '_ns_', t: '_t_', n: '_n_', v: '_v_' };
|
|
8
|
+
|
|
9
|
+
export function fullTileName({ namespace, type, name, version }) {
|
|
10
|
+
return `${NAME_TOKENS.ns}${namespace}${NAME_TOKENS.t}${type}${NAME_TOKENS.n}${name}${NAME_TOKENS.v}${normalizeVersion(version)}`;
|
|
11
|
+
}
|
package/src/nodeApi.mjs
ADDED
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
/* eslint-disable no-restricted-imports -- the ONLY place in the package where `node:*` is allowed */
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import crypto from 'node:crypto';
|
|
4
|
+
import { builtinModules } from 'node:module';
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* The showcase of standard library allowed to the builder.
|
|
8
|
+
*
|
|
9
|
+
* The rest of the package is FORBIDDEN by lint to import `node:*` — otherwise an environment
|
|
10
|
+
* access sneaks in unnoticed and only surfaces in an isolate. Here the exception list is visible
|
|
11
|
+
* in full, and each entry must stay true: these are **pure computations**, not environment
|
|
12
|
+
* capabilities, and all three modules exist in workerd under `nodejs_compat` (verified on
|
|
13
|
+
* `workerd@1.20260719.1`).
|
|
14
|
+
*
|
|
15
|
+
* If something else is ever needed, that is a reason not to add a line here but to ask whether
|
|
16
|
+
* the new need is an ENVIRONMENT capability. Those go into the port (`BuildHost`), not this file.
|
|
17
|
+
*
|
|
18
|
+
* - `path` — string math over paths, does no I/O;
|
|
19
|
+
* - `crypto` — hashes (`sha1` for asset names, `sha256` for SRI), also a pure function of bytes;
|
|
20
|
+
* - `builtinModules` — just a LIST OF NAMES: target-environment policy (the browser has no node builtins).
|
|
21
|
+
*/
|
|
22
|
+
export { path, crypto, builtinModules };
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import { path } from './nodeApi.mjs';
|
|
2
|
+
|
|
3
|
+
// Metadata of the package owning a file — free functions OVER the port (like probeFile):
|
|
4
|
+
// a pure upward walk over files that needs no second implementation, so it is neither a port
|
|
5
|
+
// method nor a resolver method but logic shared by both.
|
|
6
|
+
|
|
7
|
+
const MAX_WALK_UP = 50;
|
|
8
|
+
|
|
9
|
+
/** Directory of the nearest package.json upwards, or null. */
|
|
10
|
+
export function packageDirOf(fileSystem, file) {
|
|
11
|
+
let dir = path.dirname(file);
|
|
12
|
+
for (let i = 0; i < MAX_WALK_UP; i++) {
|
|
13
|
+
if (fileSystem.exists(path.join(dir, 'package.json'))) return dir;
|
|
14
|
+
const parent = path.dirname(dir);
|
|
15
|
+
if (parent === dir) break;
|
|
16
|
+
dir = parent;
|
|
17
|
+
}
|
|
18
|
+
return null;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/** Version from the nearest package.json upwards ('0.0.0' when not found). */
|
|
22
|
+
export function packageVersionOf(fileSystem, file) {
|
|
23
|
+
let dir = path.dirname(file);
|
|
24
|
+
for (let i = 0; i < MAX_WALK_UP; i++) {
|
|
25
|
+
const pj = path.join(dir, 'package.json');
|
|
26
|
+
if (fileSystem.exists(pj)) {
|
|
27
|
+
try {
|
|
28
|
+
const v = fileSystem.readJson(pj).version;
|
|
29
|
+
if (v) return v;
|
|
30
|
+
} catch { /* ignore malformed */ }
|
|
31
|
+
}
|
|
32
|
+
const parent = path.dirname(dir);
|
|
33
|
+
if (parent === dir) break;
|
|
34
|
+
dir = parent;
|
|
35
|
+
}
|
|
36
|
+
return '0.0.0';
|
|
37
|
+
}
|