@mosetta/cli 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/src/deploy.mjs ADDED
@@ -0,0 +1,154 @@
1
+ import fs from 'node:fs';
2
+ import path from 'node:path';
3
+ import { createRequire } from 'node:module';
4
+ import { contentTypeOf, release, ReleaseRefused } from '@mosetta/build/release';
5
+ import { importServer } from './serverPackage.mjs';
6
+ import { sectionLog } from './log.mjs';
7
+
8
+ const require = createRequire(import.meta.url);
9
+
10
+ // `mosetta deploy`: the built distribution goes to the project's stores by the provider
11
+ // registry — public code to the CDN store, private code to the private store, and (on request)
12
+ // the content seed to the content store. The registry is the same file the Worker gets as its
13
+ // PROVIDERS secret: one description of the project's stores for both sides. No platform API is
14
+ // involved — every store is reached through its provider handle, so a self-hosted S3 deploys the
15
+ // same way as R2. The code goes up by the release rules every publisher runs (@mosetta/build/release):
16
+ // a deployed version never changes, the catalogs only grow, and two deploys at once cannot drop
17
+ // each other's entries.
18
+
19
+ function walk(dir, base = dir, out = []) {
20
+ if (!fs.existsSync(dir)) return out;
21
+ for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
22
+ const p = path.join(dir, entry.name);
23
+ if (entry.isDirectory()) walk(p, base, out);
24
+ else if (entry.isFile()) out.push({ file: p, key: path.relative(base, p).split(path.sep).join('/') });
25
+ }
26
+ return out;
27
+ }
28
+
29
+ /** the built distribution as a release reads it: both catalogs, every key, the bytes by key */
30
+ export function builtDistribution(dist) {
31
+ const side = (name) => {
32
+ const files = walk(path.join(dist, name));
33
+ return { catalog: JSON.parse(fs.readFileSync(path.join(dist, name, 'modules.json'), 'utf8')), keys: files.map((f) => f.key) };
34
+ };
35
+ const pub = side('public');
36
+ const priv = side('private');
37
+ return {
38
+ catalogs: { public: pub.catalog, private: priv.catalog },
39
+ files: { public: pub.keys, private: priv.keys },
40
+ async read(name, key) {
41
+ const file = path.join(dist, name, ...key.split('/'));
42
+ return fs.existsSync(file) ? new Uint8Array(fs.readFileSync(file)) : null;
43
+ },
44
+ };
45
+ }
46
+
47
+ const CONCURRENCY = 8;
48
+
49
+ async function uploadAll(handle, files, { log, existing = null, label }) {
50
+ let done = 0;
51
+ let skipped = 0;
52
+ const queue = [...files];
53
+ const worker = async () => {
54
+ for (let item = queue.shift(); item; item = queue.shift()) {
55
+ if (existing?.has(item.key)) {
56
+ skipped++;
57
+ continue;
58
+ }
59
+ const res = await handle.put(item.key, new Uint8Array(fs.readFileSync(item.file)), { contentType: contentTypeOf(item.key) });
60
+ if (!res.ok) throw new Error(`${label}: put ${item.key} failed (${res.status} ${(await res.text()).slice(0, 200)})`);
61
+ await res.arrayBuffer();
62
+ done++;
63
+ }
64
+ };
65
+ await Promise.all(Array.from({ length: Math.min(CONCURRENCY, files.length) }, worker));
66
+ log(`${label}: ${done} object(s) uploaded${skipped ? `, ${skipped} kept (already present)` : ''}`);
67
+ return { done, skipped };
68
+ }
69
+
70
+ /**
71
+ * @param root app root (holds dist/ after `mosetta build`, and pages/ prefabs/ config.json)
72
+ * @param registryFile the provider registry JSON (default <root>/.mosetta/providers.json)
73
+ * @param content also seed the content store (keys already present are kept unless forceContent)
74
+ */
75
+ export async function deploy(root, { registryFile, content = false, forceContent = false, contentOnly = false, dryRun = false, log = sectionLog('deploy') } = {}) {
76
+ const file = registryFile ?? path.join(root, '.mosetta', 'providers.json');
77
+ if (!fs.existsSync(file)) throw new Error(`deploy: registry not found at ${file} (copy providers.example.json and fill in the credentials)`);
78
+ const dist = path.join(root, 'dist');
79
+ // the content seed alone: the code is released elsewhere (`mosetta push`), the registry may name
80
+ // no code store this machine can write
81
+ if (!contentOnly && !fs.existsSync(path.join(dist, 'public'))) throw new Error(`deploy: no ${dist}/public — run \`mosetta build\` first`);
82
+
83
+ // @mosetta/stores is TS source: bundled for node exactly like the server package
84
+ const { module: provider, cleanup } = await importServer(require.resolve('@mosetta/stores'), { platform: 'node' });
85
+ try {
86
+ const providers = provider.parseStoreConnectors(JSON.parse(fs.readFileSync(file, 'utf8')));
87
+ if (!providers.length) throw new Error(`deploy: ${file} holds no valid store connector`);
88
+ const manager = new provider.StoreConnectors(providers);
89
+ const need = (store) => {
90
+ const handle = manager.pick(store, 'put');
91
+ if (!handle) throw new Error(`deploy: the registry has no "${store}" store connector that can put`);
92
+ return handle;
93
+ };
94
+
95
+ let summary = {};
96
+ if (!contentOnly) {
97
+ try {
98
+ summary = await release(
99
+ { public: need('public-code'), private: need('private-code') },
100
+ builtDistribution(dist),
101
+ { label: path.basename(file), dryRun, log },
102
+ );
103
+ } catch (e) {
104
+ // a refusal is an answer: said as it is, without the stack
105
+ if (e instanceof ReleaseRefused) throw new Error(e.message);
106
+ throw e;
107
+ }
108
+ }
109
+ if (dryRun) return summary;
110
+
111
+ if (content || contentOnly) {
112
+ const handle = need('content');
113
+ const files = [
114
+ ...walk(path.join(root, 'pages'), root),
115
+ ...walk(path.join(root, 'prefabs'), root),
116
+ ...(fs.existsSync(path.join(root, 'config.json')) ? [{ file: path.join(root, 'config.json'), key: 'config.json' }] : []),
117
+ ];
118
+ // the seed never overwrites what editors have written: present keys are kept
119
+ const existing = forceContent ? null : new Set((await handle.list('')).map((o) => o.key));
120
+ summary.content = await uploadAll(handle, files, { log, existing, label: 'content' });
121
+ // the route table is a LISTING of publish markers (pages/published/<pattern>~<name>~<rev>):
122
+ // the dev store derives it from the metas, a real store needs the markers put — one per
123
+ // live page of the seed
124
+ // …but only for pages this run actually seeded: a page the store already holds has moved
125
+ // on (published from the workbench) — re-putting its seed marker would resurrect an old route
126
+ const markers = [];
127
+ for (const dir of fs.existsSync(path.join(root, 'pages')) ? fs.readdirSync(path.join(root, 'pages')) : []) {
128
+ const metaFile = path.join(root, 'pages', dir, 'page.json');
129
+ if (!fs.existsSync(metaFile)) continue;
130
+ if (existing?.has(`pages/${dir}/page.json`)) continue;
131
+ const meta = JSON.parse(fs.readFileSync(metaFile, 'utf8'));
132
+ if (!Number.isInteger(meta.live)) continue;
133
+ const docFile = path.join(root, 'pages', dir, 'versions', `${meta.live}.json`);
134
+ if (!fs.existsSync(docFile)) continue;
135
+ const { pattern } = JSON.parse(fs.readFileSync(docFile, 'utf8'));
136
+ if (typeof pattern !== 'string') continue;
137
+ markers.push(`pages/published/${encodeURIComponent(pattern)}~${dir}~${meta.live}`);
138
+ }
139
+ let put = 0;
140
+ for (const key of markers) {
141
+ if (existing?.has(key)) continue;
142
+ const res = await handle.put(key, '{}', { contentType: contentTypeOf('marker.json') });
143
+ if (!res.ok) throw new Error(`content: marker ${key} failed (${res.status})`);
144
+ await res.arrayBuffer();
145
+ put++;
146
+ }
147
+ log(`content: ${put} publish marker(s) put${markers.length - put ? `, ${markers.length - put} kept` : ''}`);
148
+ summary.content.markers = put;
149
+ }
150
+ return summary;
151
+ } finally {
152
+ cleanup();
153
+ }
154
+ }
package/src/dev.mjs ADDED
@@ -0,0 +1,145 @@
1
+ import fs from 'node:fs';
2
+ import os from 'node:os';
3
+ import path from 'node:path';
4
+ import { startDevBuilder } from './devBuilder.mjs';
5
+ import { buildRenderWorker } from './buildWorker.mjs';
6
+ import { resolveWorkerdBin, writeWorkerdConfig, spawnWorkerd, killWorkerd, probePort } from './workerd.mjs';
7
+ import { createStaticDir } from './devStaticDir.mjs';
8
+ import { loadDevKeys, devRegistry } from './devKeys.mjs';
9
+ import { startTypecheckWatch } from './typecheck.mjs';
10
+ import { sectionLog, silentLog, renderBuildReport } from './log.mjs';
11
+
12
+ // Dev orchestration: ONE process owns the network.
13
+ //
14
+ // workerd (:port) — render, static assets (disk service over the generations dir), the content
15
+ // channel (writable disk services on pages/ and prefabs/) and SSE reload
16
+ // node — builds, watches files, writes generations. OPENS NO SOCKETS.
17
+ //
18
+ // The dev environment is exposed via a tunnel (cloudflared/ngrok) so code on the developer's
19
+ // machine joins the prod app as a regular provider — a single address gets tunneled. There used
20
+ // to be two ports, then node stayed on loopback only for disk writes; now the worker can write
21
+ // too (`disk (writable = true)`), so the node server is gone entirely.
22
+ //
23
+ // Coordination between them is FILE-based, in the generations dir: `lock` (build in progress),
24
+ // `current` (generation pointer), `reload` (counter, bumped only on CODE edits). The worker polls
25
+ // them, node only writes — there is no back channel.
26
+ export async function startDev({
27
+ root, port, write = false, typecheck = true, quiet = false, contentRoot,
28
+ fileSystem, engine, frameworkDir,
29
+ }) {
30
+ // NO port default here: 3000 is the DEVELOPER's port, and the human-facing default lives in
31
+ // the CLI bin alone. Every other caller — tests, harnesses, tooling — states its port, and
32
+ // takes a dynamic one (freePort). A library default would let any of them land on 3000 by
33
+ // simply forgetting the field.
34
+ if (!Number.isInteger(port)) throw new Error('startDev: an explicit port is required (the CLI default lives in bin/mosetta.mjs)');
35
+ // quiet (tests): mute summaries — [assets], static counters, the dev address; warnings
36
+ // ([cli] warnings, [workerd], [esbuild]) stay loud
37
+ const clog = sectionLog('cli');
38
+ // port busy — almost always a workerd copy that survived a previous run. Saying so directly
39
+ // is cheaper than leaving the user with a bare "readiness timed out"
40
+ if (await probePort(port)) {
41
+ const stale = await fetch(`http://127.0.0.1:${port}/__mosetta_ping__`).then((r) => r.status === 204).catch(() => false);
42
+ throw new Error(stale
43
+ ? `port ${port} is taken by a previous mosetta dev process — close it (or pick another: --port <n>)`
44
+ : `port ${port} is taken by another process — pick a free one: --port <n>`);
45
+ }
46
+ const plog = quiet ? silentLog : sectionLog('tiles');
47
+ const origin = `http://127.0.0.1:${port}`;
48
+ const appRoot = fs.realpathSync(root);
49
+ // where content writes land: the app's source folders by default; tests substitute a temp
50
+ // dir so writes don't mutate the fixture
51
+ const contentSource = contentRoot ? fs.realpathSync(contentRoot) : appRoot;
52
+ const staticDir = fs.realpathSync(fs.mkdtempSync(path.join(os.tmpdir(), 'mosetta-static-')));
53
+ const store = createStaticDir(staticDir);
54
+ // content bindings point at specific folders — they must exist even if the app has not created them yet
55
+ // media: pictures uploaded from the workbench — the dev's stand-in for the CDN folder
56
+ for (const dir of ['pages', 'prefabs', 'media']) fs.mkdirSync(path.join(contentSource, dir), { recursive: true });
57
+
58
+ const builder = await startDevBuilder({
59
+ root,
60
+ write,
61
+ quiet,
62
+ fileSystem,
63
+ engine,
64
+ frameworkDir,
65
+ // lock for the whole rebuild: while the file exists the worker serves neither static nor render
66
+ onRebuildStart: () => store.lock(),
67
+ onRebuild: (state, { codeChange }) => {
68
+ const { written, linked } = store.publish(state.fs.files);
69
+ if (codeChange) store.bumpReload(); // content edits come from the editor — do not reload it
70
+ plog(`static: ${written} written, ${linked} linked`);
71
+ },
72
+ // ALWAYS: the lock is released here, and a build error goes into status — the overlay shows it
73
+ onRebuildEnd: ({ error }) => {
74
+ store.report('build', error);
75
+ store.unlock();
76
+ },
77
+ });
78
+
79
+ // live typecheck companion: types never gate the dev loop — errors land in this log as [types]
80
+ // and INTO STATUS, where the overlay picks them up (typecheck runs its own cycle, decoupled from builds)
81
+ const tsWatch = typecheck ? startTypecheckWatch({ root, onResult: (errors) => store.report('typescript', errors) }) : null;
82
+
83
+ // initial tree — before the worker starts: it resolves its directory at startup
84
+ const first = store.publish(builder.getState().fs.files);
85
+ plog(`static: ${first.written} written`);
86
+ const report = renderBuildReport(builder.getState().report);
87
+ if (report && !quiet) sectionLog('assets')(report);
88
+
89
+ // dev is closed by default: keys always exist, the credentials registry is seeded into env
90
+ const keys = loadDevKeys(appRoot, clog);
91
+ const registry = devRegistry(origin, keys);
92
+
93
+ const workerCode = await buildRenderWorker({ mode: 'development', dev: true });
94
+ const { dir, configPath } = writeWorkerdConfig({
95
+ workerCode,
96
+ port,
97
+ // the provider for both browser and runner is the worker ITSELF (one origin, no cross-origin)
98
+ providerOrigin: `${origin}/`,
99
+ staticDir,
100
+ appRoot,
101
+ contentRoot: contentSource,
102
+ providersJson: JSON.stringify(registry),
103
+ });
104
+
105
+ const binPath = resolveWorkerdBin();
106
+ let child;
107
+ const cleanup = () => {
108
+ tsWatch?.stop();
109
+ builder.close();
110
+ fs.rmSync(dir, { recursive: true, force: true });
111
+ fs.rmSync(staticDir, { recursive: true, force: true });
112
+ };
113
+ try {
114
+ child = await spawnWorkerd({ binPath, configPath, port, log: sectionLog('workerd') });
115
+ } catch (e) {
116
+ cleanup();
117
+ throw e;
118
+ }
119
+
120
+ if (!quiet) clog(`dev: ${origin} (single port: render, static, content; node holds no sockets)`);
121
+
122
+ let down = false;
123
+ // GUARANTEED kill: SIGTERM → wait → SIGKILL. A surviving workerd holds the port and the next
124
+ // `mosetta dev` fails to start; so exiting waits for the child to actually die.
125
+ const shutdown = async () => {
126
+ if (down) return;
127
+ down = true;
128
+ const dead = await killWorkerd(child);
129
+ if (!dead) clog(`workerd (pid ${child.pid}) did not exit — port ${port} may stay taken`);
130
+ cleanup();
131
+ };
132
+ const bye = () => { shutdown().finally(() => process.exit(0)); };
133
+ process.on('SIGINT', bye);
134
+ process.on('SIGTERM', bye);
135
+ // last safety net: synchronous SIGKILL on process exit — this also covers crashes where the
136
+ // async shutdown never gets to finish
137
+ process.on('exit', () => {
138
+ try { if (child.exitCode === null && child.signalCode === null) process.kill(child.pid, 'SIGKILL'); } catch { /* ignore */ }
139
+ });
140
+ child.on('exit', (code, signal) => {
141
+ if (!down) clog(`workerd exited (code=${code} signal=${signal})`);
142
+ });
143
+
144
+ return { builder, child, dir, origin, staticDir, keys, registry, shutdown };
145
+ }
@@ -0,0 +1,116 @@
1
+ import path from 'node:path';
2
+ import chokidar from 'chokidar';
3
+ import { build } from '@mosetta/build';
4
+ import { PackagesResolver } from '@mosetta/build/packagesResolver';
5
+ import { ArtifactCache } from '@mosetta/build/artifactCache';
6
+ import { scanCatalog } from '@mosetta/build/scanner';
7
+ import { loadBuildConfig, mirrorArtifacts } from './buildConfigLoader.mjs';
8
+ import { frameworkResolutions } from './scanner.mjs';
9
+ import { sectionLog, silentLog } from './log.mjs';
10
+ import { evaluateModule } from './evaluate.mjs';
11
+
12
+ /**
13
+ * Dev build loop: build → watch files → rebuild.
14
+ *
15
+ * It used to live inside the dev provider (`staticServer.mjs`), but serving is now workerd's
16
+ * job and node remains the builder-watcher. So the loop is a thing of its own: `dev.mjs` takes
17
+ * it with no server at all, the test provider — together with its own server.
18
+ *
19
+ * Hooks: `onRebuildStart` — before the build (whoever serves the code takes the lock),
20
+ * `onRebuild(state, { codeChange })` — after a successful one, `onRebuildEnd({ ok, error })` —
21
+ * ALWAYS, in `finally`. The last one guarantees the lock gets released: a failed build must not
22
+ * hang dev on the worker's timeout, and its text must reach status (the error overlay).
23
+ *
24
+ * `codeChange` distinguishes CODE edits from CONTENT edits: content is written by the editor
25
+ * itself, and reloading its browser would kill the editing session.
26
+ */
27
+ export async function startDevBuilder({
28
+ root, write = false, quiet = false, fileSystem, engine, frameworkDir,
29
+ onRebuildStart, onRebuild, onRebuildEnd,
30
+ }) {
31
+ // quiet mutes summaries ([tiles] chatter); build errors go via [esbuild] and stay loud
32
+ const plog = quiet ? silentLog : sectionLog('tiles');
33
+ // Artifact cache across iterations: the process is long-lived, and editing one file must not
34
+ // rebuild every artifact. Validity is by mtime of each artifact's graph.
35
+ const cache = new ArtifactCache(fileSystem);
36
+
37
+ // the build writes nothing: mirroring into dist for inspection is the host's business
38
+ const runBuild = async () => {
39
+ const buildConfig = await loadBuildConfig(root);
40
+ // the package catalog is rescanned ON EVERY iteration: a `pnpm add` mid dev-session is
41
+ // picked up by the next rebuild without a restart
42
+ const resolver = new PackagesResolver({
43
+ fileSystem,
44
+ // dev sees devDependencies (devLibraries live there), but a seed import from devDeps gets
45
+ // an immediate warning — a prod build will not allow it
46
+ catalog: { ...scanCatalog({ fileSystem, root, frameworkDir, includeDev: true, log: plog }), framework: frameworkResolutions() },
47
+ });
48
+ const s = await build(root, { mode: 'development', fileSystem, resolver, engine, frameworkDir, buildConfig, log: plog, cache, evaluate: evaluateModule });
49
+ const { hits, misses } = cache.take();
50
+ if (hits) plog(`cache: ${hits} file(s) reused, ${misses} rebuilt`);
51
+ if (write) mirrorArtifacts(s.fs.files, path.join(root, 'dist'));
52
+ return s;
53
+ };
54
+
55
+ let state = await runBuild();
56
+
57
+ // The watch set follows the LAST SUCCESSFUL build, not the initial one: a branch checkout or
58
+ // a mosetta.json edit can add roots and pull sources from previously unknown places.
59
+ let watched = new Set([...(state.watchDirs || []), ...state.watchFiles]);
60
+ const watcher = chokidar.watch([...watched], { ignoreInitial: true });
61
+ const resyncWatcher = () => {
62
+ const next = new Set([...(state.watchDirs || []), ...state.watchFiles]);
63
+ for (const t of next) if (!watched.has(t)) watcher.add(t);
64
+ for (const t of watched) if (!next.has(t)) watcher.unwatch(t);
65
+ watched = next;
66
+ };
67
+
68
+ let building = false;
69
+ let pending = false;
70
+ let codeChanged = false; // accumulates across coalesced rebuilds
71
+ const rebuild = async (isCodeChange = false) => {
72
+ codeChanged = codeChanged || isCodeChange;
73
+ if (building) { pending = true; return; }
74
+ building = true;
75
+ const codeChange = codeChanged;
76
+ codeChanged = false;
77
+ let error = null;
78
+ try {
79
+ if (onRebuildStart) await onRebuildStart();
80
+ state = await runBuild();
81
+ resyncWatcher();
82
+ if (onRebuild) await onRebuild(state, { codeChange });
83
+ plog(`rebuilt${codeChange ? '' : ' (content change — no browser reload)'}`);
84
+ } catch (e) {
85
+ // the last intact state keeps being served — a broken tree mid-checkout is fixed by the
86
+ // very next event
87
+ error = String((e && e.stack) || (e && e.message) || e);
88
+ sectionLog('esbuild')(error);
89
+ } finally {
90
+ // ALWAYS: the lock is released here, so a failed build does not hang serving
91
+ if (onRebuildEnd) {
92
+ try {
93
+ await onRebuildEnd({ ok: error === null, error });
94
+ } catch { /* status is auxiliary — it must not crash the loop */ }
95
+ }
96
+ building = false;
97
+ if (pending) { pending = false; rebuild(); }
98
+ }
99
+ };
100
+
101
+ // Content (page/prefab revisions and drafts) is written by the editor itself — its edits rebuild
102
+ // SILENTLY. The folders count too: the first draft of an entity creates its `drafts/` folder,
103
+ // and a reload for that would throw the author's workbench away mid-edit.
104
+ const isContentPath = (file) => !!file && /(^|[/\\])(pages|prefabs)([/\\]|$)/.test(file)
105
+ && !/\.(?:[cm]?[jt]sx?|css)$/.test(file);
106
+ watcher.on('all', (event, file) => rebuild(!isContentPath(file)));
107
+ // a warning, not a summary — loud even under quiet
108
+ watcher.on('error', (e) => sectionLog('tiles')(`watcher error: ${e.message}`));
109
+
110
+ return {
111
+ getState: () => state,
112
+ watcher,
113
+ rebuild,
114
+ close: () => watcher.close(),
115
+ };
116
+ }
@@ -0,0 +1,71 @@
1
+ import fs from 'node:fs';
2
+ import path from 'node:path';
3
+ import crypto from 'node:crypto';
4
+
5
+ /**
6
+ * Closed-dev keys. There is no "open mode": keys always exist — dev itself gets them for free
7
+ * (this module generates and seeds them), so there is no reason to weaken the model.
8
+ *
9
+ * Two pairs: READ (distributable — handed to the tunnel consumer and shipped into the isolate
10
+ * projection) and WRITE (never leaves the machine; enters an isolate only via a
11
+ * `contentWriters` grant). Keys live in `.mosetta/devKeys.json` at the app root and survive
12
+ * restarts — the tunnel consumer never needs re-keying. The directory must be in `.gitignore` —
13
+ * the secret must not reach the repository; the CLI warns about it.
14
+ */
15
+ export function loadDevKeys(appRoot, log) {
16
+ const dir = path.join(appRoot, '.mosetta');
17
+ const file = path.join(dir, 'devKeys.json');
18
+ let keys = null;
19
+ try {
20
+ keys = JSON.parse(fs.readFileSync(file, 'utf8'));
21
+ } catch { /* missing or corrupt file — regenerated below */ }
22
+ if (!keys?.read?.accessKeyId || !keys?.read?.secretAccessKey || !keys?.write?.accessKeyId || !keys?.write?.secretAccessKey) {
23
+ const pair = (prefix) => ({
24
+ accessKeyId: `${prefix}${crypto.randomBytes(6).toString('hex').toUpperCase()}`,
25
+ secretAccessKey: crypto.randomBytes(32).toString('hex'),
26
+ });
27
+ keys = { read: pair('DEVR'), write: pair('DEVW') };
28
+ fs.mkdirSync(dir, { recursive: true });
29
+ fs.writeFileSync(file, JSON.stringify(keys, null, 2));
30
+ }
31
+ if (!isGitignored(appRoot) && log) log(`warning: add \`.mosetta/\` to .gitignore — it holds dev keys (${file})`);
32
+ return keys;
33
+ }
34
+
35
+ // the rule may live in a .gitignore at any level up to the git root (monorepo) — walk upward
36
+ function isGitignored(appRoot) {
37
+ let dir = appRoot;
38
+ for (let depth = 0; depth < 8; depth++) {
39
+ const gitignore = path.join(dir, '.gitignore');
40
+ if (fs.existsSync(gitignore) && fs.readFileSync(gitignore, 'utf8').split('\n')
41
+ .some((line) => line.trim().replace(/^\*\*\//, '').replace(/\/$/, '') === '.mosetta')) {
42
+ return true;
43
+ }
44
+ if (fs.existsSync(path.join(dir, '.git'))) return false;
45
+ const parent = path.dirname(dir);
46
+ if (parent === dir) return false;
47
+ dir = parent;
48
+ }
49
+ return false;
50
+ }
51
+
52
+ /**
53
+ * Closed-dev registry: credentials of self entries double as the KEY TABLE of the fake S3, and
54
+ * an entry's `intent` is the ACL (read: get/conditionalGet/list; read-write: + put/delete).
55
+ * Order matters for `pick`: the content read entry before rw (reads get the read key), the
56
+ * private-code web connector before s3 (same URLs as the CDN form).
57
+ */
58
+ export function devRegistry(origin, keys) {
59
+ const base = origin.replace(/\/+$/, '');
60
+ const endpoint = `${base}/__mosetta_s3__/`;
61
+ return [
62
+ { name: 'dev', store: 'content', intent: 'read', connector: { transport: 's3', endpoint, bucket: 'content', credentials: keys.read } },
63
+ { name: 'dev', store: 'content', intent: 'read-write', connector: { transport: 's3', endpoint, bucket: 'content', credentials: keys.write } },
64
+ { name: 'dev', store: 'public-code', intent: 'read', connector: { transport: 'web', url: `${base}/` } },
65
+ // uploads (media/…) go through the S3 form of the public store with the WRITE key; the web
66
+ // entry above stays first — reading and the public base keep their plain URLs
67
+ { name: 'dev', store: 'public-code', intent: 'read-write', connector: { transport: 's3', endpoint, bucket: 'public-code', credentials: keys.write } },
68
+ { name: 'dev', store: 'private-code', intent: 'read', connector: { transport: 'web', url: `${base}/private/` } },
69
+ { name: 'dev', store: 'private-code', intent: 'read', connector: { transport: 's3', endpoint, bucket: 'private-code', credentials: keys.read } },
70
+ ];
71
+ }
@@ -0,0 +1,112 @@
1
+ import fs from 'node:fs';
2
+ import path from 'node:path';
3
+ import crypto from 'node:crypto';
4
+
5
+ /**
6
+ * The directory workerd's `disk` service reads, plus the dev-build status.
7
+ *
8
+ * <dir>/current/… ← artifacts being served right now
9
+ * <dir>/next/… ← the next build while it is being written
10
+ * <dir>/lock ← exists while a rebuild is in progress (the worker polls it)
11
+ * <dir>/dev.json ← status: whether the build succeeded, build and typecheck errors
12
+ *
13
+ * WRITES ARE ALL-OR-NOTHING. A new build is written into `next`, and only once fully assembled
14
+ * the directories swap via two `rename`s (metadata operations, instant). A failed or interrupted
15
+ * build leaves no half-written tree: the previous one keeps being served.
16
+ *
17
+ * Unchanged files move over as HARD LINKS from the current tree — on APFS/ext4 that is metadata,
18
+ * no disk rewrite. With the artifact cache, editing one file = one real write; the other 165 are
19
+ * links. Side effect: a link keeps the old mtime, so the worker-synthesized ETag of unchanged
20
+ * artifacts stays the same and 304 keeps working.
21
+ *
22
+ * The lock is released GUARANTEED — the caller does it in `finally` (see dev.mjs): dev must not
23
+ * hang because of a failed build, and waiting out the worker's five-second timeout is pointless.
24
+ */
25
+
26
+ const hashOf = (bytes) => crypto.createHash('sha1').update(bytes).digest('hex');
27
+
28
+ export function createStaticDir(dir) {
29
+ fs.rmSync(dir, { recursive: true, force: true });
30
+ fs.mkdirSync(dir, { recursive: true });
31
+ const lockFile = path.join(dir, 'lock');
32
+ const statusFile = path.join(dir, 'dev.json');
33
+ const currentDir = path.join(dir, 'current');
34
+ const nextDir = path.join(dir, 'next');
35
+ const oldDir = path.join(dir, 'old');
36
+ let hashes = new Map(); // path → sha1 of the current tree (for hard links)
37
+ let reloads = 0;
38
+ let status = { ok: true, build: null, typescript: null };
39
+
40
+ const writeStatus = () => {
41
+ // `stamp` is a fingerprint of the error TEXT: the overlay uses it to remember it was
42
+ // dismissed on this exact problem, and shows up again when the problem changes
43
+ const ok = !status.build && !status.typescript;
44
+ const stamp = ok ? '' : hashOf(`${status.build ?? ''}\u0000${status.typescript ?? ''}`).slice(0, 12);
45
+ const body = JSON.stringify({ ...status, ok, stamp }, null, 2);
46
+ const tmp = `${statusFile}.tmp`;
47
+ fs.writeFileSync(tmp, body);
48
+ fs.renameSync(tmp, statusFile); // a reader must never see half a file
49
+ };
50
+ writeStatus();
51
+
52
+ return {
53
+ dir,
54
+
55
+ /** node takes the lock and node releases it — the worker only polls the file */
56
+ lock() {
57
+ fs.writeFileSync(lockFile, String(Date.now()));
58
+ },
59
+
60
+ unlock() {
61
+ fs.rmSync(lockFile, { force: true });
62
+ },
63
+
64
+ /**
65
+ * Reload counter: bumped ONLY on code edits. The worker holds the SSE connections and
66
+ * watches this file — no back channel from node is needed (content edits come from the
67
+ * editor itself, and a reload would kill its session).
68
+ */
69
+ bumpReload() {
70
+ reloads += 1;
71
+ fs.writeFileSync(path.join(dir, 'reload'), String(reloads));
72
+ },
73
+
74
+ /** build/typecheck error (or its absence) goes to dev.json, where the overlay picks it up */
75
+ report(section, message) {
76
+ status = { ...status, [section]: message || null };
77
+ writeStatus();
78
+ },
79
+
80
+ /** files: Map(path → string|bytes) — the build artifact tree as-is */
81
+ publish(files) {
82
+ fs.rmSync(nextDir, { recursive: true, force: true });
83
+ const nextHashes = new Map();
84
+ let written = 0;
85
+ let linked = 0;
86
+
87
+ for (const [rel, content] of files) {
88
+ const bytes = Buffer.isBuffer(content) ? content : Buffer.from(content);
89
+ const hash = hashOf(bytes);
90
+ nextHashes.set(rel, hash);
91
+ const target = path.join(nextDir, rel);
92
+ fs.mkdirSync(path.dirname(target), { recursive: true });
93
+ if (hashes.get(rel) === hash) {
94
+ try {
95
+ fs.linkSync(path.join(currentDir, rel), target);
96
+ linked += 1;
97
+ continue;
98
+ } catch { /* link failed (race, foreign FS) — just write the file */ }
99
+ }
100
+ fs.writeFileSync(target, bytes);
101
+ written += 1;
102
+ }
103
+
104
+ // the swap is the LAST action: before it the whole previous tree is served, after — the new one
105
+ if (fs.existsSync(currentDir)) fs.renameSync(currentDir, oldDir);
106
+ fs.renameSync(nextDir, currentDir);
107
+ fs.rmSync(oldDir, { recursive: true, force: true });
108
+ hashes = nextHashes;
109
+ return { written, linked };
110
+ },
111
+ };
112
+ }
@@ -0,0 +1,46 @@
1
+ import fs from 'node:fs';
2
+ import path from 'node:path';
3
+ import { pathToFileURL } from 'node:url';
4
+ import { createWasmEngine } from './wasmEngine.mjs';
5
+
6
+ /**
7
+ * Build engine: ONE for all environments — wasm (the parity policy).
8
+ *
9
+ * The framework deliberately ships no native binaries: they bring a class of problems we do not
10
+ * have to own — per-OS platform builds (and their differences), optional dependencies and
11
+ * postinstall in CI, plus the need to differentially verify that two engines produce the same
12
+ * bytes. Wasm behaves identically on the developer's machine, in the CI pipeline and in the
13
+ * isolate; everything else in the build is plain JS.
14
+ *
15
+ * `mosetta.engine.mjs` is an escape hatch for a developer who values speed more: a default export
16
+ * of a factory returning `{ rollup, transform, bundle }` (e.g. native rollup+esbuild). The rules
17
+ * of the game transfer to it: artifacts must keep the protocol shape (checked by
18
+ * `assertNamedRegister`), and parity with the isolate build is its responsibility.
19
+ * The hook is NOT applied IN THE ISOLATE: building the hook itself needs an engine — chicken and
20
+ * egg; there the engine arrives as the declared wasm modules of the set.
21
+ */
22
+
23
+ const ENGINE_FILE = 'mosetta.engine.mjs';
24
+ const REQUIRED = ['rollup', 'transform', 'bundle'];
25
+
26
+ export async function loadEngine(root, { mode = 'development', log = () => {} } = {}) {
27
+ const file = path.join(root, ENGINE_FILE);
28
+ let mtimeMs;
29
+ try {
30
+ mtimeMs = fs.statSync(file).mtimeMs;
31
+ } catch {
32
+ return createWasmEngine(); // default: wasm — the same set that ships into the isolate
33
+ }
34
+ // cache-bust by mtime: the ESM import cache is permanent, a hook edit must be picked up without a restart
35
+ const ns = await import(`${pathToFileURL(file).href}?v=${mtimeMs}`);
36
+ if (typeof ns.default !== 'function') {
37
+ throw new Error(`${ENGINE_FILE} must default-export a factory returning { rollup, transform, bundle }`);
38
+ }
39
+ const engine = await ns.default({ mode });
40
+ const missing = REQUIRED.filter((k) => typeof engine?.[k] !== 'function');
41
+ if (missing.length) {
42
+ throw new Error(`${ENGINE_FILE}: the engine must provide functions ${REQUIRED.join(', ')} (missing: ${missing.join(', ')})`);
43
+ }
44
+ log(`engine: ${ENGINE_FILE} — custom build engine; parity with the platform's build is on you`);
45
+ return engine;
46
+ }
@@ -0,0 +1,18 @@
1
+ import fs from 'node:fs';
2
+ import os from 'node:os';
3
+ import path from 'node:path';
4
+ import { pathToFileURL } from 'node:url';
5
+
6
+ // The build's `evaluate` capability, in node: a self-contained ESM module the build bundled (a
7
+ // block's settings schema with what turns it into JSON Schema) run in this process, its exports
8
+ // handed back. The build itself runs nothing — this is the host's to give.
9
+ export async function evaluateModule(jsCode) {
10
+ const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'mosetta-evaluate-'));
11
+ const file = path.join(dir, 'module.mjs');
12
+ fs.writeFileSync(file, jsCode);
13
+ try {
14
+ return { ...(await import(pathToFileURL(file).href)) };
15
+ } finally {
16
+ fs.rmSync(dir, { recursive: true, force: true });
17
+ }
18
+ }