@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/CHANGELOG.md +126 -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 +45 -0
- package/src/log.mjs +114 -0
- package/src/login.mjs +136 -0
- package/src/push.mjs +99 -0
- package/src/scanner.mjs +26 -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
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
import fs from 'node:fs';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import { spawn } from 'node:child_process';
|
|
4
|
+
import { createRequire } from 'node:module';
|
|
5
|
+
import { sectionLog } from './log.mjs';
|
|
6
|
+
|
|
7
|
+
// App-level typecheck: the build pipeline itself never checks types (esbuild strips them
|
|
8
|
+
// blindly), so the CLI runs the CONSUMER'S OWN tsc against the app root — @mosetta/core ships
|
|
9
|
+
// `typescript` as a dependency, so any mosetta closure has a binary to resolve. Both entry
|
|
10
|
+
// points are safe-to-lose: no tsconfig / no typescript / checker crash → one notice line and
|
|
11
|
+
// the dev/build flow continues exactly as before the feature existed.
|
|
12
|
+
|
|
13
|
+
function typecheckTargets(root) {
|
|
14
|
+
const tsconfig = path.join(root, 'tsconfig.json');
|
|
15
|
+
if (!fs.existsSync(tsconfig)) return { tsconfig: null, tscBin: null };
|
|
16
|
+
try {
|
|
17
|
+
const req = createRequire(path.join(root, 'package.json'));
|
|
18
|
+
const pkgPath = req.resolve('typescript/package.json');
|
|
19
|
+
return { tsconfig, tscBin: path.join(path.dirname(pkgPath), 'lib', 'tsc.js') };
|
|
20
|
+
} catch {
|
|
21
|
+
return { tsconfig, tscBin: null };
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
// tsc output line-by-line into the section log (headers/coloring live in the logger)
|
|
26
|
+
function forwardTo(log) {
|
|
27
|
+
return (chunk) => {
|
|
28
|
+
const text = String(chunk).replace(/\n+$/, '');
|
|
29
|
+
if (text.trim()) log(text);
|
|
30
|
+
};
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/** blocking gate for `mosetta build` — resolves false ONLY on real type errors */
|
|
34
|
+
export function runTypecheck({ root, log = sectionLog('typescript') } = {}) {
|
|
35
|
+
const { tsconfig, tscBin } = typecheckTargets(root);
|
|
36
|
+
if (!tsconfig) {
|
|
37
|
+
log('no tsconfig.json at the app root — typecheck skipped');
|
|
38
|
+
return Promise.resolve(true);
|
|
39
|
+
}
|
|
40
|
+
if (!tscBin) {
|
|
41
|
+
log('typescript is not resolvable from the app root — typecheck skipped');
|
|
42
|
+
return Promise.resolve(true);
|
|
43
|
+
}
|
|
44
|
+
return new Promise((resolve) => {
|
|
45
|
+
const child = spawn(
|
|
46
|
+
process.execPath,
|
|
47
|
+
[tscBin, '--noEmit', '-p', root, '--pretty', 'false'],
|
|
48
|
+
{ stdio: ['ignore', 'pipe', 'pipe'] },
|
|
49
|
+
);
|
|
50
|
+
const forward = forwardTo(log);
|
|
51
|
+
child.stdout.on('data', forward);
|
|
52
|
+
child.stderr.on('data', forward);
|
|
53
|
+
child.on('error', () => resolve(true)); // checker failed to run ≠ type errors
|
|
54
|
+
child.on('exit', (code) => resolve(code === 0));
|
|
55
|
+
});
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* Dev companion (the fork-ts-checker idea without the plugin bridge): tsc has its own watcher
|
|
60
|
+
* and incremental checker, so a plain `tsc --watch` child next to the dev server is enough —
|
|
61
|
+
* it never blocks or slows rebuilds, its findings go to the dev log under the section header.
|
|
62
|
+
*/
|
|
63
|
+
export function startTypecheckWatch({ root, log = sectionLog('typescript'), onResult } = {}) {
|
|
64
|
+
const { tsconfig, tscBin } = typecheckTargets(root);
|
|
65
|
+
if (!tsconfig || !tscBin) return null;
|
|
66
|
+
const child = spawn(
|
|
67
|
+
process.execPath,
|
|
68
|
+
[tscBin, '--noEmit', '-p', root, '--watch', '--preserveWatchOutput', '--pretty', 'false'],
|
|
69
|
+
{ stdio: ['ignore', 'pipe', 'pipe'] },
|
|
70
|
+
);
|
|
71
|
+
let stopped = false;
|
|
72
|
+
const forward = forwardTo(log);
|
|
73
|
+
// `tsc --watch` has no "run started" marker, it just writes to the stream. But its END is
|
|
74
|
+
// explicit — the "Found N errors" line. So we buffer the stream up to that line and report
|
|
75
|
+
// on it: no errors → the status section clears, errors → the accumulated text goes there.
|
|
76
|
+
const DONE_RE = /Found (\d+) errors?/;
|
|
77
|
+
let buffer = '';
|
|
78
|
+
const collect = (chunk) => {
|
|
79
|
+
forward(chunk);
|
|
80
|
+
if (!onResult) return;
|
|
81
|
+
buffer += String(chunk);
|
|
82
|
+
const done = DONE_RE.exec(buffer);
|
|
83
|
+
if (!done) {
|
|
84
|
+
if (buffer.length > 200_000) buffer = buffer.slice(-100_000); // in case the stream never emits the marker
|
|
85
|
+
return;
|
|
86
|
+
}
|
|
87
|
+
const errors = Number(done[1]) > 0
|
|
88
|
+
// messages are lines like `file(1,2): error TS…`; watcher noise stays out of status
|
|
89
|
+
? buffer.split('\n').filter((l) => l.includes('error TS')).join('\n').trim()
|
|
90
|
+
: null;
|
|
91
|
+
onResult(errors || null);
|
|
92
|
+
buffer = '';
|
|
93
|
+
};
|
|
94
|
+
child.stdout.on('data', collect);
|
|
95
|
+
child.stderr.on('data', collect);
|
|
96
|
+
child.on('error', () => { /* safe-to-lose: dev runs without live typecheck */ });
|
|
97
|
+
child.on('exit', (code) => {
|
|
98
|
+
if (!stopped) log(`checker exited (code=${code}) — dev continues without live typecheck`);
|
|
99
|
+
});
|
|
100
|
+
return {
|
|
101
|
+
child,
|
|
102
|
+
stop() {
|
|
103
|
+
stopped = true;
|
|
104
|
+
try { child.kill(); } catch { /* ignore */ }
|
|
105
|
+
},
|
|
106
|
+
};
|
|
107
|
+
}
|
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
|
+
}
|