@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/CHANGELOG.md ADDED
@@ -0,0 +1,160 @@
1
+ # @mosetta/build
2
+
3
+ ## 0.0.10
4
+
5
+ ### Patch Changes
6
+
7
+ - 95b93d5: The build's node host is an entry of the build: `@mosetta/build/node` gives the real file system,
8
+ the framework's files resolved from the installed build, and `buildDirectory(root, …)` — the build
9
+ of a palette that lies on disk with its dependencies installed, by the build installed for it. The
10
+ CLI builds with it, and so does a platform's build container: a palette is built by the framework
11
+ version its lock names. `systemjs`, whose files the build mixes into a page, is a dependency of the
12
+ build now, not of the CLI.
13
+ - @mosetta/router@0.0.10
14
+ - @mosetta/runtime@0.0.10
15
+ - @mosetta/server@0.0.10
16
+ - @mosetta/server-action@0.0.10
17
+ - @mosetta/tar@0.0.10
18
+
19
+ ## 0.0.9
20
+
21
+ ### Patch Changes
22
+
23
+ - Updated dependencies
24
+ - @mosetta/runtime@0.0.9
25
+ - @mosetta/server@0.0.9
26
+ - @mosetta/server-action@0.0.9
27
+ - @mosetta/router@0.0.9
28
+ - @mosetta/tar@0.0.9
29
+
30
+ ## 0.0.8
31
+
32
+ ### Patch Changes
33
+
34
+ - A project's own tile palette. The registry keeps the palettes a project's site takes tiles from;
35
+ the factory reads each one's tile host after the platform's, and takes a tile of a host other than
36
+ the primary only with its integrity. The rule a site's tile hosts merge by lives in
37
+ `@mosetta/core` (`mergeTileHosts`), so the editor offers the blocks of every tile host of the site
38
+ by the same rule (`TILE_HOSTS_TOKEN` in `@mosetta/server-action`). The platform's API publishes
39
+ into `palettes/<brand>` for the members of the project of that namespace, with the palette's stores
40
+ granted to that one request. `mosetta build --released <descriptor>` leaves out what a platform
41
+ already holds; `checkPaletteBuild` says what a palette may hold. The CLI's and the build's output
42
+ speaks the vocabulary: tile palettes, tiles, package tiles.
43
+ - Updated dependencies
44
+ - @mosetta/server@0.0.8
45
+ - @mosetta/server-action@0.0.8
46
+ - @mosetta/runtime@0.0.8
47
+ - @mosetta/router@0.0.8
48
+ - @mosetta/tar@0.0.8
49
+
50
+ ## 0.0.7
51
+
52
+ ### Patch Changes
53
+
54
+ - One vocabulary: code is tiles, content is blueprints, running requests is the factory, and a tile and a
55
+ blueprint made in the factory give a mosaic. A tile is a folder under `tiles/` (was `modules/`) whose
56
+ files are its sides, each defined with `define<Side>Side` — `defineComponentSide`, `defineEditorSide`,
57
+ `defineFallbackSide`, `defineRootComponentSide`, `defineServerSide`, `defineSsrSide` (were
58
+ `define…Module`); `useTile` (was `useModule`), `markInternalTile`, `BlueprintRoot`,
59
+ `BlueprintNodeRenderer`. `mosetta.json` names the tile `brand` (was `namespace`; a config that still
60
+ says `namespace` is refused with the word to use). The store connectors' package is `@mosetta/stores`
61
+ (was `@mosetta/provider`): `IStoreConnector`, `StoreConnectors`, `tileHostsOf`. The server's runner
62
+ is the factory gate (`@mosetta/server/factoryGate`, was `/runner`). Nothing on the wire moves: tile
63
+ ids keep `_ns_`, a build keeps `modules/<id>/`, stored JSON keeps its fields.
64
+ - Updated dependencies
65
+ - @mosetta/server-action@0.0.7
66
+ - @mosetta/runtime@0.0.7
67
+ - @mosetta/server@0.0.7
68
+ - @mosetta/router@0.0.7
69
+ - @mosetta/tar@0.0.7
70
+
71
+ ## 0.0.6
72
+
73
+ ### Patch Changes
74
+
75
+ - A site's code may come from several sources — the host's stores, a project's own distributions, another vendor's. A
76
+ store of the registry names its source (`source`; none — the host's), and the runner reads every source's public and
77
+ private catalogs into one: a namespace belongs to the first source holding it, a package to the first that has it
78
+ (other bytes under its id are said, the first stands). Every entity is read from the source its catalog entry names —
79
+ blocks, packages and singletons alike, by the runner, the site's renderer and the browser: the page envelope carries a
80
+ `providers` table (key → base) and the catalog slice the key of each entity. The page no longer says where its code
81
+ lives: the node's `provider` is gone. A server module runs only when a private catalog of the site names it; a site
82
+ whose catalogs were never read runs none (503). A build into a target leaves what the target already holds: released
83
+ library roots and published packages are not built again.
84
+ - Updated dependencies
85
+ - @mosetta/server@0.0.6
86
+ - @mosetta/runtime@0.0.6
87
+ - @mosetta/router@0.0.6
88
+ - @mosetta/server-action@0.0.6
89
+ - @mosetta/tar@0.0.6
90
+
91
+ ## 0.0.5
92
+
93
+ ### Patch Changes
94
+
95
+ - 92417dc: Who may call a server action is the action's to say: every action has an `authorize` (abstract — an action without it
96
+ does not compile), run after the input is validated and before the action; refused, the call is a 401 when nobody is
97
+ vouched for and a 403 otherwise. It decides by the input, the request and the caller — what the host vouches for, in
98
+ the host's own words: the framework passes the identity header's JSON object through whole and knows no roles. The
99
+ mosetta platform says `{ via, login, email?, roles? }` (`roles: ['editor']` for the site's editors, on server actions);
100
+ `mosetta dev` vouches for the developer at the machine as an editor. `<name>.public.ts` and the runner's check of it
101
+ are gone: the platform's gate no longer decides for actions, and only keeps a visitor's calls within bounds.
102
+ - Updated dependencies [92417dc]
103
+ - @mosetta/server-action@0.0.5
104
+ - @mosetta/server@0.0.5
105
+ - @mosetta/router@0.0.5
106
+ - @mosetta/runtime@0.0.5
107
+ - @mosetta/tar@0.0.5
108
+
109
+ ## 0.0.4
110
+
111
+ ### Patch Changes
112
+
113
+ - 7577aa3: A server module named `<name>.public.ts` answers visitors of the site, not only its editors: it is built, named and
114
+ called like any server module, and its private descriptor says `public`. A host lets a non-editor's call on as a
115
+ visitor's — with no identity, limited per site, module and address, a form's size at most — and the runner answers it
116
+ from a public module only. Opening or closing a module under a version already released is refused.
117
+ - Updated dependencies [7577aa3]
118
+ - Updated dependencies [7577aa3]
119
+ - @mosetta/server-action@0.0.4
120
+ - @mosetta/server@0.0.4
121
+ - @mosetta/router@0.0.4
122
+ - @mosetta/runtime@0.0.4
123
+ - @mosetta/tar@0.0.4
124
+
125
+ ## 0.0.3
126
+
127
+ ### Patch Changes
128
+
129
+ - 1330d5d: Code is published THROUGH a platform: `mosetta push` describes the build (both catalogs, every file with its SHA-256,
130
+ the commit it comes from), asks the platform's API for a plan and sends the files it asks for — no store key on the
131
+ publisher's side. The release rules check a fresh module's bytes against the catalog entry that vouches for them, on
132
+ either path. A runner can grant stores to server actions only (found by name), never read for its own modules — how
133
+ the platform's API writes code stores whose keys stay with its host; the host names the publish target in a header of
134
+ its own, beside the caller's identity.
135
+ - 886c43a: The release rules live in `@mosetta/build/release`, portable, for every publisher: `mosetta deploy` runs them against a
136
+ provider registry, a platform can run them on its side. A release writes each catalog over exactly the copy it planned
137
+ from and a module file only where none is, so two deploys into one store at once can no longer drop each other's
138
+ entries — the one overtaken plans again. A provider write takes a condition (`ifMatch`, `ifNoneMatch: '*'`), answered
139
+ 412 when someone else got there first.
140
+ - Updated dependencies [1330d5d]
141
+ - Updated dependencies [842344f]
142
+ - @mosetta/server@0.0.3
143
+ - @mosetta/server-action@0.0.3
144
+ - @mosetta/router@0.0.3
145
+ - @mosetta/runtime@0.0.3
146
+ - @mosetta/tar@0.0.3
147
+
148
+ ## 0.0.2
149
+
150
+ ### Patch Changes
151
+
152
+ - The first versioned release. A deployed version never changes, and the stores keep every version
153
+ deployed. A page runs one copy of each framework package — the newest its blocks were built with,
154
+ or the version its root pins; the Framework distribution's updater lifts a page to its framework.
155
+ - Updated dependencies
156
+ - @mosetta/server@0.0.2
157
+ - @mosetta/router@0.0.2
158
+ - @mosetta/runtime@0.0.2
159
+ - @mosetta/server-action@0.0.2
160
+ - @mosetta/tar@0.0.2
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 mosetta contributors
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md CHANGED
@@ -1,3 +1,15 @@
1
- # Temporary Holding Version
1
+ # @mosetta/build
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ The core of the tile forge: builds a tile palette — its tile side artifacts, their dependency
4
+ closure, the tile host descriptor (`modules.json`) — and holds the release rules every publisher
5
+ runs. Environment-agnostic: the file system and the bundling engine are supplied from outside, so
6
+ the same build runs in node (`@mosetta/cli`), in CI or in a platform's build container.
7
+
8
+ ## Entry points
9
+
10
+ | Entry | What it holds |
11
+ |---|---|
12
+ | `@mosetta/build` | `build(root, options)` |
13
+ | `@mosetta/build/release` | the release rules: a released version never changes, descriptors only grow, a descriptor is written over exactly the copy the plan was made from; `planRelease`, `release`, the push manifest, `checkPaletteBuild` |
14
+ | `@mosetta/build/archive` | a build from a packed archive of sources, lock and packages |
15
+ | `@mosetta/build/fileSystem`, `./scanner`, `./packagesResolver`, `./artifactCache` | the ports and helpers a host builds with |
package/package.json CHANGED
@@ -1,6 +1,39 @@
1
1
  {
2
2
  "name": "@mosetta/build",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
3
+ "version": "0.0.10",
4
+ "license": "MIT",
5
+ "description": "The core of the tile forge: builds a tile palette — its tile side artifacts, their dependency closure, the tile host descriptor. Environment-agnostic — the file system and the bundling engine are supplied from outside.",
6
+ "type": "module",
7
+ "exports": {
8
+ ".": "./src/build.mjs",
9
+ "./artifactCache": "./src/artifactCache.mjs",
10
+ "./fileSystem": "./src/fileSystem.mjs",
11
+ "./packagesResolver": "./src/packagesResolver.mjs",
12
+ "./scanner": "./src/scanner.mjs",
13
+ "./archive": "./src/archive.mjs",
14
+ "./release": "./src/release.mjs",
15
+ "./node": "./src/node.mjs"
16
+ },
17
+ "dependencies": {
18
+ "@pnpm/dependency-path": "^1000.0.9",
19
+ "@rollup/plugin-commonjs": "^28.0.9",
20
+ "@rollup/plugin-replace": "^6.0.3",
21
+ "cjs-module-lexer": "^2.2.0",
22
+ "postcss": "^8.5.15",
23
+ "postcss-value-parser": "^4.2.0",
24
+ "react": "*",
25
+ "react-dom": "*",
26
+ "resolve.exports": "^2.0.3",
27
+ "scheduler": "*",
28
+ "systemjs": "^6.15.1",
29
+ "yaml": "^2.9.0",
30
+ "@mosetta/router": "0.0.10",
31
+ "@mosetta/runtime": "0.0.10",
32
+ "@mosetta/server": "0.0.10",
33
+ "@mosetta/tar": "0.0.10",
34
+ "@mosetta/server-action": "0.0.10"
35
+ },
36
+ "publishConfig": {
37
+ "access": "public"
38
+ }
6
39
  }
@@ -0,0 +1,140 @@
1
+ import { path } from './nodeApi.mjs';
2
+ import { parseTar, gunzip } from '@mosetta/tar';
3
+ import { MemoryFileSystem } from './memoryFileSystem.mjs';
4
+ import { PackagesResolver } from './packagesResolver.mjs';
5
+ import { scanCatalog, scanToolingCatalog, BUILD_CONFIG_FILE } from './scanner.mjs';
6
+ import { build, INERT_BUILD_CONFIG } from './build.mjs';
7
+ import { bundleBuildConfig } from './configBundle.mjs';
8
+
9
+ export { CONFIG_MODULE, bundleBuildConfig, buildConfigFrom } from './configBundle.mjs';
10
+ export { BUILD_CONFIG_FILE } from './scanner.mjs';
11
+
12
+ /**
13
+ * The build-action body — ONE BINARY tar container, no text encoding:
14
+ *
15
+ * scan.json — manifest: { root, frameworkDir, framework, mode, unpack: [{entry,dir,strip}] }
16
+ * sources.tgz — distributive sources (+ its lock) — the form GitHub serves them in
17
+ * packages/*.tgz — archives of the closure's packages — the form the registry serves them in
18
+ * meta.tgz — package.json/locks of the walk-up contexts (ancestors of the root)
19
+ *
20
+ * The outer container is NOT compressed (its entries are already gzip) — the platform
21
+ * concatenates ready-made archives without repacking anything. Each entry unpacks into the
22
+ * memory FS at its `dir` (virtual paths mirror the layout relative to the lock — the graph's
23
+ * relative paths match the disk, so the artifacts are byte-identical). `strip` cuts the top
24
+ * name segments (a GitHub archive's `<repo>-<sha>/` folder, an npm package's `package/`).
25
+ *
26
+ * From there it is exactly the CLI pipeline: scan (lock + sources, already INSIDE the isolate) →
27
+ * catalog-backed resolver → build(). `mosetta.build.mjs` is NOT executed here by construction
28
+ * (executing a foreign file is an environment capability): the config is always INERT, and a
29
+ * distributive with node plugins is built the traditional way.
30
+ */
31
+
32
+ export { MemoryFileSystem } from './memoryFileSystem.mjs';
33
+ export { parseTar, writeTar, gzip, gunzip } from '@mosetta/tar';
34
+
35
+ export const SCAN_ENTRY = 'scan.json';
36
+
37
+ const stripSegments = (name, n) => name.split('/').slice(n).join('/');
38
+
39
+ /** Unpack the container: { manifest, fileSystem } — a memory FS with the build's virtual world. */
40
+ export async function unpackArchive(bytes) {
41
+ const outer = parseTar(bytes instanceof Uint8Array ? bytes : new Uint8Array(bytes));
42
+ const byName = new Map(outer.map((e) => [e.name, e]));
43
+ const scanEntry = byName.get(SCAN_ENTRY);
44
+ if (!scanEntry) throw new Error(`container has no ${SCAN_ENTRY} — this is not a build-action body`);
45
+ const manifest = JSON.parse(new TextDecoder().decode(scanEntry.data));
46
+ const fileSystem = new MemoryFileSystem();
47
+ for (const u of manifest.unpack ?? []) {
48
+ const entry = byName.get(u.entry);
49
+ if (!entry) throw new Error(`manifest declares ${u.entry}, but the container has no such entry`);
50
+ for (const f of parseTar(await gunzip(entry.data))) {
51
+ const rel = stripSegments(f.name, u.strip ?? 0);
52
+ if (rel) fileSystem.set(path.join(u.dir, rel), f.data);
53
+ }
54
+ }
55
+ return { manifest, fileSystem };
56
+ }
57
+
58
+ /**
59
+ * RECOMPOSITION prep, the framework half: if the distributive has a build config — bundle it
60
+ * into a module and collect its wasm assets, so the caller (the action) only has to pack this
61
+ * into the response to the runner. The config is NOT executed here.
62
+ *
63
+ * The config's closure is the TOOLING one (`scanToolingCatalog`): devDependencies are allowed,
64
+ * because build tooling is exactly what they contain; the product catalog does not see it.
65
+ * Returns `{ code, wasm: [{ name, bytes }] }`, or null when there is no config.
66
+ */
67
+ export async function prepareConfigRecompose({ manifest, fileSystem, engine }) {
68
+ if (!fileSystem.exists(path.join(manifest.root, BUILD_CONFIG_FILE))) return null;
69
+ const resolver = new PackagesResolver({
70
+ fileSystem,
71
+ catalog: {
72
+ ...scanToolingCatalog({ fileSystem, root: manifest.root, frameworkDir: manifest.frameworkDir }),
73
+ framework: manifest.framework ?? {},
74
+ },
75
+ });
76
+ const code = await bundleBuildConfig(fileSystem, resolver, {
77
+ engine, root: manifest.root, frameworkDir: manifest.frameworkDir,
78
+ });
79
+ // the config's wasm is declared as DATA (`buildConfigWasm` in mosetta.json): phase 1 does not
80
+ // execute the config and has no other way to learn its needs
81
+ const wasm = [];
82
+ let cfg = null;
83
+ try {
84
+ cfg = fileSystem.readJson(path.join(manifest.root, 'mosetta.json'));
85
+ } catch { /* no mosetta.json — no knob either */ }
86
+ for (const [name, specifier] of Object.entries(cfg?.buildConfigWasm ?? {})) {
87
+ wasm.push({ name, bytes: fileSystem.readFile(resolver.resolve(specifier, manifest.root, null)) });
88
+ }
89
+ return { code, wasm };
90
+ }
91
+
92
+ /**
93
+ * The action's full cycle: container → memory FS → scan → build(). Returns build()'s result.
94
+ *
95
+ * `released` — the ids the target's catalog already holds: released libraries and published
96
+ * packages there are not built again (`build`).
97
+ *
98
+ * `evaluate` — the host's way to run a module the build bundled (a block's settings schema); absent
99
+ * (an isolate), the schemas are skipped with a line in the log, as in any build without it.
100
+ *
101
+ * `buildConfig` — the tenant's hooks, ALREADY made executable through recomposition (the config
102
+ * module was added by the runner into the isolate's bundle, the action imported it and passed it
103
+ * here). If the config EXISTS in the sources but no hooks were passed, a build without them
104
+ * would produce artifacts different from local ones, so the refusal is LOUD: the caller must
105
+ * either go through recomposition or build the traditional way.
106
+ */
107
+ export async function buildFromArchive(bytes, options) {
108
+ return buildUnpacked(await unpackArchive(bytes), options);
109
+ }
110
+
111
+ /** The same build over a container already unpacked (`unpackArchive`) — a host that looked inside first does not unpack twice. */
112
+ export async function buildUnpacked({ manifest, fileSystem }, { engine, log = () => {}, buildConfig = null, evaluate = undefined, released = null }) {
113
+ if (!buildConfig && fileSystem.exists(path.join(manifest.root, BUILD_CONFIG_FILE))) {
114
+ throw new Error('the tile palette uses mosetta.build.mjs (build hooks), but no executable hooks '
115
+ + 'were passed: go through recomposition or build the traditional way (CLI)');
116
+ }
117
+ const mode = manifest.mode === 'production' ? 'production' : 'development';
118
+ const resolver = new PackagesResolver({
119
+ fileSystem,
120
+ catalog: {
121
+ ...scanCatalog({
122
+ fileSystem,
123
+ root: manifest.root,
124
+ frameworkDir: manifest.frameworkDir,
125
+ includeDev: mode !== 'production',
126
+ log,
127
+ }),
128
+ framework: manifest.framework ?? {},
129
+ },
130
+ });
131
+ return build(manifest.root, {
132
+ mode, log, fileSystem, resolver, engine,
133
+ frameworkDir: manifest.frameworkDir,
134
+ buildConfig: buildConfig ?? INERT_BUILD_CONFIG,
135
+ // a host that may run the code it built (a container, node) describes the blocks' settings
136
+ ...(evaluate ? { evaluate } : {}),
137
+ // what the target already holds is not built again
138
+ ...(released ? { released } : {}),
139
+ });
140
+ }
@@ -0,0 +1,78 @@
1
+ /**
2
+ * Artifact cache ACROSS build iterations (dev server).
3
+ *
4
+ * The artifact model could not fit this better: every artifact is built by its own graph, is
5
+ * self-contained and links at runtime by name — so editing one file must rebuild only the
6
+ * artifacts whose graphs include it, not all 44. Before the cache, every save in dev rebuilt
7
+ * EVERYTHING, including dependency artifacts whose sources live in node_modules and could not
8
+ * have changed.
9
+ *
10
+ * The validity key is the mtime of the artifact's source graph (`watchFiles`, collected by the
11
+ * port plugins). Such a key behaves correctly in a monorepo by itself — both ours and a
12
+ * consumer's: graph paths are dereferenced (`realPath`), so a workspace package is seen as the
13
+ * real file (`packages/react/src/...`), not a symlink inside node_modules, and editing it
14
+ * honestly invalidates everyone who pulled it in. A missing file yields `-1` — also a
15
+ * divergence, also a rebuild.
16
+ *
17
+ * What graph mtime does NOT catch is whatever changes the build as a whole rather than through
18
+ * files: mode, `mosetta.build.mjs` hooks, the package catalog (a reinstall/`pnpm add` changes
19
+ * resolution while sources do not) and the composition inputs (`mosetta.json`, the roots'
20
+ * `package.json`, `config.json`). That is the "epoch": changing it wipes the cache entirely.
21
+ * Cheaper and more reliable than weaving all of that into every key.
22
+ *
23
+ * The cache is SUPPLIED FROM OUTSIDE (the build has no environment defaults) — the long-lived
24
+ * dev server creates it. A one-shot prod build passes no cache: every build there is the first
25
+ * and the last.
26
+ */
27
+
28
+ export const mtimeStamp = (fileSystem, files) =>
29
+ files.map((f) => `${f}:${fileSystem.mtimeMs(f) ?? -1}`).join('|');
30
+
31
+ export class ArtifactCache {
32
+ #fileSystem;
33
+ #entries = new Map(); // key -> { value, files, stamp }
34
+ #epoch = null;
35
+
36
+ constructor(fileSystem) {
37
+ this.#fileSystem = fileSystem;
38
+ this.hits = 0;
39
+ this.misses = 0;
40
+ }
41
+
42
+ /**
43
+ * Declare the build epoch. If it changed, the whole cache is invalid.
44
+ */
45
+ epoch(stamp) {
46
+ if (stamp === this.#epoch) return;
47
+ this.#epoch = stamp;
48
+ this.#entries.clear();
49
+ }
50
+
51
+ /**
52
+ * Serve the artifact from cache if no file of its graph changed; otherwise build and remember.
53
+ * `filesOf` — how to extract the graph from the result (default `watchFiles`).
54
+ */
55
+ async memo(key, produce, filesOf = (v) => v.watchFiles ?? []) {
56
+ const hit = this.#entries.get(key);
57
+ if (hit && mtimeStamp(this.#fileSystem, hit.files) === hit.stamp) {
58
+ this.hits++;
59
+ return hit.value;
60
+ }
61
+ const value = await produce();
62
+ const files = [...new Set(filesOf(value))];
63
+ this.#entries.set(key, { value, files, stamp: mtimeStamp(this.#fileSystem, files) });
64
+ this.misses++;
65
+ return value;
66
+ }
67
+
68
+ /**
69
+ * Counters for the LAST iteration — the dev server prints them to the log so that "only what
70
+ * changed got rebuilt" is visible, not assumed.
71
+ */
72
+ take() {
73
+ const stats = { hits: this.hits, misses: this.misses, size: this.#entries.size };
74
+ this.hits = 0;
75
+ this.misses = 0;
76
+ return stats;
77
+ }
78
+ }
@@ -0,0 +1,90 @@
1
+ import { path } from './nodeApi.mjs';
2
+ import { packageDirOf } from './packageMeta.mjs';
3
+
4
+ /**
5
+ * Legacy `browser` field in package.json.
6
+ *
7
+ * This is NOT an `exports` condition but an older, entirely different mechanism — a substitution
8
+ * map inside the package:
9
+ *
10
+ * "browser": { "./lib/node.js": "./lib/browser.js", "crypto": false }
11
+ *
12
+ * The value `false` means "this does not exist in the browser, substitute a stub". There is also
13
+ * a string form (`"browser": "./dist/browser.js"`) — an alternative package ENTRY; it applies
14
+ * only when there is no `exports` map, because `exports` is more modern and overrides the
15
+ * legacy fields.
16
+ *
17
+ * This used to be handled by `@rollup/plugin-node-resolve({ browser: true })`. After moving
18
+ * resolution onto the port the mechanism got lost: nothing in the current closure uses it
19
+ * (otherwise the artifact comparison would not have matched), but on a new dependency it would
20
+ * misfire as quietly as zod's conditional `exports` did.
21
+ *
22
+ * Lives OUTSIDE the port: it only needs `readJson` and the upward walk (`packageMeta`), so it
23
+ * works with any port implementation.
24
+ */
25
+
26
+ /** Stub specifier: `"…": false` in the map. The plugin serves an empty module for it. */
27
+ export const BROWSER_EMPTY = '\0mosetta-browser-empty';
28
+
29
+ const EXTENSIONS = ['', '.mjs', '.js', '.ts', '.tsx', '.json'];
30
+
31
+ function readPackage(fileSystem, dir) {
32
+ try {
33
+ return fileSystem.readJson(path.join(dir, 'package.json'));
34
+ } catch {
35
+ return null;
36
+ }
37
+ }
38
+
39
+ // map keys are written inconsistently: './lib/x.js', 'lib/x.js', './lib/x' — normalize both sides
40
+ function lookup(map, relative) {
41
+ const candidates = [relative, relative.replace(/^\.\//, '')];
42
+ for (const base of candidates) {
43
+ for (const ext of EXTENSIONS) {
44
+ const withExt = base.endsWith(ext) ? base : base + ext;
45
+ for (const key of [withExt, `./${withExt.replace(/^\.\//, '')}`]) {
46
+ if (Object.hasOwn(map, key)) return map[key];
47
+ }
48
+ }
49
+ // and the reverse case: the map has the path WITHOUT an extension, we have it with one
50
+ const stripped = base.replace(/\.(mjs|js|ts|tsx|json)$/, '');
51
+ for (const key of [stripped, `./${stripped.replace(/^\.\//, '')}`]) {
52
+ if (Object.hasOwn(map, key)) return map[key];
53
+ }
54
+ }
55
+ return undefined;
56
+ }
57
+
58
+ /**
59
+ * Apply the owning package's `browser` map to an already resolved ABSOLUTE path.
60
+ * Returns the path (possibly substituted), or `BROWSER_EMPTY` for `false`.
61
+ * Without the `browser` condition in the set — identity.
62
+ */
63
+ export function applyBrowserField(fileSystem, file, conditions) {
64
+ if (!conditions?.includes('browser')) return file;
65
+ const dir = packageDirOf(fileSystem, file);
66
+ if (!dir) return file;
67
+ const pkg = readPackage(fileSystem, dir);
68
+ const map = pkg?.browser;
69
+ if (!map || typeof map !== 'object') return file;
70
+ const relative = `./${path.relative(dir, file).split(path.sep).join('/')}`;
71
+ const hit = lookup(map, relative);
72
+ if (hit === undefined) return file;
73
+ if (hit === false) return BROWSER_EMPTY;
74
+ return path.resolve(dir, hit);
75
+ }
76
+
77
+ /**
78
+ * The string form — an alternative package entry. Applies only to the ROOT specifier
79
+ * (`'lodash'` but not `'lodash/get'`) and only when there is no `exports` map.
80
+ */
81
+ export function applyBrowserEntry(fileSystem, specifier, file, conditions) {
82
+ if (!conditions?.includes('browser')) return file;
83
+ if (specifier.startsWith('.') || path.isAbsolute(specifier)) return file;
84
+ const dir = packageDirOf(fileSystem, file);
85
+ if (!dir) return file;
86
+ const pkg = readPackage(fileSystem, dir);
87
+ if (!pkg || pkg.exports || typeof pkg.browser !== 'string') return file;
88
+ if (pkg.name && specifier !== pkg.name) return file; // subpath — the root entry does not apply
89
+ return path.resolve(dir, pkg.browser);
90
+ }