@mosetta/cli 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 +142 -0
- package/LICENSE +21 -0
- package/README.md +21 -2
- package/bin/mosetta.mjs +141 -0
- package/package.json +21 -3
- package/src/buildArchivePacker.mjs +144 -0
- package/src/buildConfigLoader.mjs +48 -0
- package/src/buildWorker.mjs +11 -0
- package/src/deploy.mjs +154 -0
- package/src/dev.mjs +145 -0
- package/src/devBuilder.mjs +116 -0
- package/src/devKeys.mjs +71 -0
- package/src/devStaticDir.mjs +112 -0
- package/src/engineLoader.mjs +46 -0
- package/src/evaluate.mjs +18 -0
- package/src/host/nodeFileSystem.mjs +3 -0
- package/src/log.mjs +114 -0
- package/src/login.mjs +136 -0
- package/src/push.mjs +99 -0
- package/src/scanner.mjs +6 -0
- package/src/serverPackage.mjs +75 -0
- package/src/staticBuild.mjs +100 -0
- package/src/typecheck.mjs +107 -0
- package/src/version.mjs +66 -0
- package/src/wasmEngine.mjs +55 -0
- package/src/workerd.mjs +172 -0
package/src/version.mjs
ADDED
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
import fs from 'node:fs';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import { createRequire } from 'node:module';
|
|
4
|
+
import { sectionLog } from './log.mjs';
|
|
5
|
+
|
|
6
|
+
// `mosetta version <patch|minor|major> [--package <namespace | package name>]`: a deployed
|
|
7
|
+
// version never changes (deploy.mjs refuses new bytes under it), so a change ships under a new
|
|
8
|
+
// one. This raises it where it lives — the `version` of the distribution's package.json — and
|
|
9
|
+
// nothing else: the build names every module after it.
|
|
10
|
+
|
|
11
|
+
const LEVELS = ['patch', 'minor', 'major'];
|
|
12
|
+
|
|
13
|
+
/** the next X.Y.Z at `level` */
|
|
14
|
+
export function nextVersion(version, level) {
|
|
15
|
+
const match = /^(\d+)\.(\d+)\.(\d+)$/.exec(version ?? '');
|
|
16
|
+
if (!match) throw new Error(`version "${version}" is not X.Y.Z`);
|
|
17
|
+
if (!LEVELS.includes(level)) throw new Error(`"${level}" is not one of ${LEVELS.join(', ')}`);
|
|
18
|
+
const [major, minor, patch] = match.slice(1).map(Number);
|
|
19
|
+
if (level === 'major') return `${major + 1}.0.0`;
|
|
20
|
+
if (level === 'minor') return `${major}.${minor + 1}.0`;
|
|
21
|
+
return `${major}.${minor}.${patch + 1}`;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
const readJson = (file) => JSON.parse(fs.readFileSync(file, 'utf8'));
|
|
25
|
+
|
|
26
|
+
/** the directory of a package resolved from the app's root — walked up from its entry */
|
|
27
|
+
function packageDirOfName(root, name) {
|
|
28
|
+
const requireFromRoot = createRequire(path.join(root, 'package.json'));
|
|
29
|
+
let dir = path.dirname(fs.realpathSync(requireFromRoot.resolve(name)));
|
|
30
|
+
for (;;) {
|
|
31
|
+
const file = path.join(dir, 'package.json');
|
|
32
|
+
if (fs.existsSync(file) && readJson(file).name === name) return dir;
|
|
33
|
+
const parent = path.dirname(dir);
|
|
34
|
+
if (parent === dir) throw new Error(`package ${name}: its package.json was not found`);
|
|
35
|
+
dir = parent;
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* The directory of what `target` names: a package name (`@acme/ui`), a tile palette's brand
|
|
41
|
+
* (`acme-ui` — the app itself or one of its libraries), or — nothing named — the app itself.
|
|
42
|
+
*/
|
|
43
|
+
export function distributionDir(root, target) {
|
|
44
|
+
if (!target) return root;
|
|
45
|
+
if (target.startsWith('@') || target.includes('/')) return packageDirOfName(root, target);
|
|
46
|
+
const config = readJson(path.join(root, 'mosetta.json'));
|
|
47
|
+
if (config.brand === target) return root;
|
|
48
|
+
for (const library of [...(config.libraries ?? []), ...(config.devLibraries ?? [])]) {
|
|
49
|
+
const dir = packageDirOfName(root, library);
|
|
50
|
+
const own = path.join(dir, 'mosetta.json');
|
|
51
|
+
if (fs.existsSync(own) && readJson(own).brand === target) return dir;
|
|
52
|
+
}
|
|
53
|
+
throw new Error(`no tile palette of ${root} has the brand "${target}"`);
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/** raise the version of `target` at `level`; the package.json keeps its own formatting */
|
|
57
|
+
export function raiseVersion(root, { target, level, log = sectionLog('version') } = {}) {
|
|
58
|
+
const dir = distributionDir(root, target);
|
|
59
|
+
const file = path.join(dir, 'package.json');
|
|
60
|
+
const text = fs.readFileSync(file, 'utf8');
|
|
61
|
+
const { name, version } = JSON.parse(text);
|
|
62
|
+
const next = nextVersion(version, level);
|
|
63
|
+
fs.writeFileSync(file, text.replace(/("version"\s*:\s*")[^"]*(")/, `$1${next}$2`));
|
|
64
|
+
log(`${name}: ${version} → ${next}`);
|
|
65
|
+
return { name, from: version, to: next, dir };
|
|
66
|
+
}
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
import fs from 'node:fs';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* The ONLY build engine of the framework: `@rollup/browser` for the module graph +
|
|
5
|
+
* `esbuild-wasm` for transpilation and private monoliths. The same set ships into the isolate,
|
|
6
|
+
* so dev, CI and the platform build the graph with the same code.
|
|
7
|
+
*
|
|
8
|
+
* We deliberately have no native binaries: per-OS platform builds (and their differences),
|
|
9
|
+
* optional dependencies in CI and differential "two engines produce the same bytes" checks are
|
|
10
|
+
* a class of problems that simply never arises with a single wasm engine. Extra speed — the
|
|
11
|
+
* `mosetta.engine.mjs` hook (engineLoader.mjs); the rules of the game then shift to the developer.
|
|
12
|
+
*
|
|
13
|
+
* IMPORTANT about the FS: a wasm build has no FS of ITS OWN, it takes the host's syscall shim —
|
|
14
|
+
* in node that is the real `fs` (the engine reads the disk!), in workerd — nothing. So "wasm
|
|
15
|
+
* engine" does not guarantee portability by itself; the gate is the virtual world (memory FS).
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
// `@rollup/browser` targets the browser and pulls its wasm via `fetch` from `import.meta.url`.
|
|
19
|
+
// In node the file:// scheme has no fetch. We install a NARROW shim: only file://*.wasm is read
|
|
20
|
+
// from disk, everything else delegates to the real fetch. The isolate does not need this shim —
|
|
21
|
+
// wasm arrives there as a declared module (workerd forbids runtime wasm compilation).
|
|
22
|
+
let fetchPatched = false;
|
|
23
|
+
function installFileWasmFetch() {
|
|
24
|
+
if (fetchPatched) return;
|
|
25
|
+
fetchPatched = true;
|
|
26
|
+
const original = globalThis.fetch;
|
|
27
|
+
globalThis.fetch = async (input, init) => {
|
|
28
|
+
const url = String(input?.url ?? input);
|
|
29
|
+
if (url.startsWith('file://') && url.endsWith('.wasm')) {
|
|
30
|
+
return new Response(fs.readFileSync(new URL(url)), { headers: { 'content-type': 'application/wasm' } });
|
|
31
|
+
}
|
|
32
|
+
return original(input, init);
|
|
33
|
+
};
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
// esbuild-wasm initializes EXACTLY ONCE per process — a second call throws
|
|
37
|
+
let engine = null;
|
|
38
|
+
export function createWasmEngine() {
|
|
39
|
+
if (!engine) engine = build();
|
|
40
|
+
return engine;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
async function build() {
|
|
44
|
+
installFileWasmFetch();
|
|
45
|
+
const [{ rollup }, esbuild] = await Promise.all([
|
|
46
|
+
import('@rollup/browser'),
|
|
47
|
+
import('esbuild-wasm'),
|
|
48
|
+
]);
|
|
49
|
+
await esbuild.initialize({ worker: false });
|
|
50
|
+
return {
|
|
51
|
+
rollup,
|
|
52
|
+
transform: (code, options) => esbuild.transform(code, options),
|
|
53
|
+
bundle: (options) => esbuild.build(options),
|
|
54
|
+
};
|
|
55
|
+
}
|
package/src/workerd.mjs
ADDED
|
@@ -0,0 +1,172 @@
|
|
|
1
|
+
import fs from 'node:fs';
|
|
2
|
+
import os from 'node:os';
|
|
3
|
+
import path from 'node:path';
|
|
4
|
+
import net from 'node:net';
|
|
5
|
+
import { spawn } from 'node:child_process';
|
|
6
|
+
import { createRequire } from 'node:module';
|
|
7
|
+
|
|
8
|
+
const require = createRequire(import.meta.url);
|
|
9
|
+
|
|
10
|
+
// Locate the workerd binary. The `workerd` npm package's default export is the path to the platform
|
|
11
|
+
// binary (like esbuild); fall back to its bin shim, then PATH.
|
|
12
|
+
export function resolveWorkerdBin() {
|
|
13
|
+
try {
|
|
14
|
+
const p = require('workerd');
|
|
15
|
+
if (typeof p === 'string') return p;
|
|
16
|
+
if (p && typeof p.default === 'string') return p.default;
|
|
17
|
+
} catch { /* not installed yet */ }
|
|
18
|
+
try {
|
|
19
|
+
return require.resolve('workerd/bin/workerd');
|
|
20
|
+
} catch { /* ignore */ }
|
|
21
|
+
return 'workerd'; // assume on PATH
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
// Write the bundled worker + a .capnp config into a fresh temp dir. The worker fetches artifacts/pages
|
|
25
|
+
// from the static provider over HTTP, so it needs outbound to localhost (private/local) — granted via a
|
|
26
|
+
// permissive `internet` network service set as globalOutbound. PROVIDER_ORIGIN comes in as a text binding.
|
|
27
|
+
// ctx.exports (loopback RPC bindings) became a default in workerd on this date; before it, the
|
|
28
|
+
// flag is required — and naming it on a later date is an error
|
|
29
|
+
const CTX_EXPORTS_DEFAULT_SINCE = '2025-11-17';
|
|
30
|
+
|
|
31
|
+
export function writeWorkerdConfig({
|
|
32
|
+
workerCode, port, providerOrigin, compatibilityDate = '2024-11-01',
|
|
33
|
+
staticDir = null, appRoot = null, contentRoot = null, providersJson = null,
|
|
34
|
+
}) {
|
|
35
|
+
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'mosetta-workerd-'));
|
|
36
|
+
fs.writeFileSync(path.join(dir, 'worker.js'), workerCode);
|
|
37
|
+
|
|
38
|
+
// ONE PORT in dev: workerd serves static itself, so a single address is exposed outward
|
|
39
|
+
// (cloudflared/ngrok tunnel). The byte source is a DIRECTORY ON DISK written by node: the
|
|
40
|
+
// `disk` service reads it per request — no separate store or upload protocol is needed.
|
|
41
|
+
// Generations and the lock file live there too.
|
|
42
|
+
// Write boundaries are held by the CONFIG: the worker sees exactly the app's `pages/` and
|
|
43
|
+
// `prefabs/` (writable) and the root (read-only — the runtime `config.json` lives there).
|
|
44
|
+
// Writing into source code is physically impossible for it.
|
|
45
|
+
const devServices = staticDir
|
|
46
|
+
? `
|
|
47
|
+
(name = "files", disk = (path = ${JSON.stringify(staticDir)}, writable = false)),`
|
|
48
|
+
+ (appRoot ? `
|
|
49
|
+
(name = "pages", disk = (path = ${JSON.stringify(`${contentRoot ?? appRoot}/pages`)}, writable = true)),
|
|
50
|
+
(name = "prefabs", disk = (path = ${JSON.stringify(`${contentRoot ?? appRoot}/prefabs`)}, writable = true)),
|
|
51
|
+
(name = "media", disk = (path = ${JSON.stringify(`${contentRoot ?? appRoot}/media`)}, writable = true)),
|
|
52
|
+
(name = "app", disk = (path = ${JSON.stringify(appRoot)}, writable = false)),
|
|
53
|
+
(name = "secrets", disk = (path = ${JSON.stringify(path.join(appRoot, '.mosetta'))}, writable = false)),` : '')
|
|
54
|
+
: '';
|
|
55
|
+
const devBindings = staticDir
|
|
56
|
+
? `
|
|
57
|
+
(name = "FILES", service = "files"),`
|
|
58
|
+
+ (appRoot ? `
|
|
59
|
+
(name = "PAGES", service = "pages"),
|
|
60
|
+
(name = "PREFABS", service = "prefabs"),
|
|
61
|
+
(name = "MEDIA", service = "media"),
|
|
62
|
+
(name = "APP", service = "app"),
|
|
63
|
+
# the project's dev secrets: .mosetta/secrets.json, read by the DevSecrets entrypoint only
|
|
64
|
+
(name = "DEV_SECRETS", service = "secrets"),` : '')
|
|
65
|
+
: '';
|
|
66
|
+
|
|
67
|
+
const capnp = `using Workerd = import "/workerd/workerd.capnp";
|
|
68
|
+
|
|
69
|
+
const config :Workerd.Config = (
|
|
70
|
+
services = [
|
|
71
|
+
(name = "main", worker = .mainWorker),
|
|
72
|
+
(name = "internet", network = (allow = ["public", "private", "local"])),${devServices}
|
|
73
|
+
],
|
|
74
|
+
sockets = [
|
|
75
|
+
(name = "http", address = "127.0.0.1:${port}", http = (), service = "main"),
|
|
76
|
+
],
|
|
77
|
+
);
|
|
78
|
+
|
|
79
|
+
const mainWorker :Workerd.Worker = (
|
|
80
|
+
modules = [
|
|
81
|
+
(name = "worker.js", esModule = embed "worker.js"),
|
|
82
|
+
],
|
|
83
|
+
compatibilityDate = "${compatibilityDate}",${compatibilityDate < CTX_EXPORTS_DEFAULT_SINCE ? `
|
|
84
|
+
# ctx.exports: the store capability stubs isolates get instead of keys (default since ${CTX_EXPORTS_DEFAULT_SINCE})
|
|
85
|
+
compatibilityFlags = ["enable_ctx_exports"],` : ''}
|
|
86
|
+
globalOutbound = "internet",
|
|
87
|
+
bindings = [
|
|
88
|
+
# the app's provider when no trusted-layer headers arrive (dev default)
|
|
89
|
+
(name = "DEFAULT_PROVIDER_ORIGIN", text = ${JSON.stringify(providerOrigin)}),${providersJson ? `
|
|
90
|
+
# EXPLICIT provider registry with credentials: the runner registry + the dev-S3 key table
|
|
91
|
+
(name = "PROVIDERS", text = ${JSON.stringify(providersJson)}),` : ''}
|
|
92
|
+
# Worker Loader: the thin runner composes EVERY isolate with it. Requires --experimental.
|
|
93
|
+
(name = "LOADER", workerLoader = ()),${devBindings}
|
|
94
|
+
],
|
|
95
|
+
);
|
|
96
|
+
`;
|
|
97
|
+
const configPath = path.join(dir, 'config.capnp');
|
|
98
|
+
fs.writeFileSync(configPath, capnp);
|
|
99
|
+
return { dir, configPath };
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* Is the port free. Before startup this matters more than it seems: a workerd copy that survived
|
|
104
|
+
* a previous run holds the address, the new one silently fails to come up — and a "readiness
|
|
105
|
+
* timed out" message points the wrong way.
|
|
106
|
+
*/
|
|
107
|
+
export function probePort(port, host = '127.0.0.1') {
|
|
108
|
+
return new Promise((resolve) => {
|
|
109
|
+
const socket = net.connect({ port, host });
|
|
110
|
+
const done = (busy) => {
|
|
111
|
+
socket.destroy();
|
|
112
|
+
resolve(busy);
|
|
113
|
+
};
|
|
114
|
+
socket.once('connect', () => done(true));
|
|
115
|
+
socket.once('error', () => done(false));
|
|
116
|
+
socket.setTimeout(500, () => done(false));
|
|
117
|
+
});
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/**
|
|
121
|
+
* Kill the workerd child for sure: politely first (SIGTERM), then SIGKILL, and wait for the
|
|
122
|
+
* actual exit. Otherwise a surviving copy occupies the port and the next `mosetta dev` cannot
|
|
123
|
+
* start (workerd does not always die instantly on SIGTERM, and its inherited stdio pipes also
|
|
124
|
+
* keep the parent alive).
|
|
125
|
+
*/
|
|
126
|
+
export async function killWorkerd(child, { graceMs = 1500, forceMs = 1500 } = {}) {
|
|
127
|
+
if (!child || child.exitCode !== null || child.signalCode !== null) return true;
|
|
128
|
+
const exited = (ms) => new Promise((resolve) => {
|
|
129
|
+
if (child.exitCode !== null || child.signalCode !== null) return resolve(true);
|
|
130
|
+
const timer = setTimeout(() => resolve(false), ms);
|
|
131
|
+
child.once('exit', () => {
|
|
132
|
+
clearTimeout(timer);
|
|
133
|
+
resolve(true);
|
|
134
|
+
});
|
|
135
|
+
});
|
|
136
|
+
try { child.kill('SIGTERM'); } catch { /* already dead */ }
|
|
137
|
+
if (await exited(graceMs)) return true;
|
|
138
|
+
try { child.kill('SIGKILL'); } catch { /* already dead */ }
|
|
139
|
+
return exited(forceMs);
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
// Spawn `workerd serve config.capnp` and wait until it answers HTTP.
|
|
143
|
+
// `log` (optional) wraps workerd output into the section log; without it — inherit as before.
|
|
144
|
+
export async function spawnWorkerd({ binPath, configPath, port, timeoutMs = 15000, log = null }) {
|
|
145
|
+
// --experimental enables the Worker Loader binding (server-action isolates).
|
|
146
|
+
const stdio = log ? ['ignore', 'pipe', 'pipe'] : ['ignore', 'inherit', 'inherit'];
|
|
147
|
+
const child = spawn(binPath, ['serve', configPath, '--experimental'], { stdio });
|
|
148
|
+
if (log) {
|
|
149
|
+
const forward = (chunk) => {
|
|
150
|
+
const text = String(chunk).replace(/\n+$/, '');
|
|
151
|
+
if (text.trim()) log(text);
|
|
152
|
+
};
|
|
153
|
+
child.stdout.on('data', forward);
|
|
154
|
+
child.stderr.on('data', forward);
|
|
155
|
+
}
|
|
156
|
+
let exited = null;
|
|
157
|
+
child.on('exit', (code, signal) => { exited = { code, signal }; });
|
|
158
|
+
|
|
159
|
+
const deadline = Date.now() + timeoutMs;
|
|
160
|
+
const url = `http://127.0.0.1:${port}/__mosetta_ping__`;
|
|
161
|
+
while (Date.now() < deadline) {
|
|
162
|
+
if (exited) throw new Error(`workerd exited early (code=${exited.code} signal=${exited.signal})`);
|
|
163
|
+
try {
|
|
164
|
+
await fetch(url); // any HTTP response (incl. 404) means the socket is up
|
|
165
|
+
return child;
|
|
166
|
+
} catch {
|
|
167
|
+
await new Promise((r) => setTimeout(r, 100));
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
try { child.kill(); } catch { /* ignore */ }
|
|
171
|
+
throw new Error(`workerd did not become ready on :${port} within ${timeoutMs}ms`);
|
|
172
|
+
}
|