@volter/world-runtime 3.0.100 → 3.0.102
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/dist/src/cli-commands.js +1 -1
- package/dist/src/configs.js +1 -1
- package/dist/src/mongodb-backing.d.ts +3 -2
- package/dist/src/mongodb-backing.js +16 -11
- package/dist/src/pglite-backing.js +57 -16
- package/dist/src/pglite-host.mjs +12 -1
- package/dist/src/process-signal.mjs +13 -4
- package/dist/src/redis-backing.js +31 -3
- package/dist/src/runtime.js +77 -112
- package/dist/src/schema.d.ts +4 -6
- package/dist/src/schema.js +4 -10
- package/dist/src/service-exit.d.ts +0 -3
- package/dist/src/service-exit.js +0 -5
- package/dist/src/up-cli.js +11 -0
- package/package.json +4 -4
- package/src/cli-commands.ts +1 -1
- package/src/configs.ts +1 -1
- package/src/mongodb-backing.ts +10 -8
- package/src/pglite-backing.ts +48 -16
- package/src/pglite-host.mjs +12 -1
- package/src/process-signal.mjs +13 -4
- package/src/redis-backing.ts +25 -3
- package/src/runtime.ts +72 -98
- package/src/schema.ts +8 -15
- package/src/service-exit.ts +0 -6
- package/src/up-cli.ts +10 -0
package/dist/src/runtime.js
CHANGED
|
@@ -26,7 +26,7 @@ import { packFacts } from "./pack-facts.js";
|
|
|
26
26
|
import { assertOwnedRealPath, ownedPath } from "./owned-path.js";
|
|
27
27
|
import { isEnvironmentName } from "./schema.js";
|
|
28
28
|
import { infraConnections, parseInfraDefinition } from "./pglite-backing.js";
|
|
29
|
-
import { describeServiceEnd, readServiceEnd
|
|
29
|
+
import { describeServiceEnd, readServiceEnd } from "./service-exit.js";
|
|
30
30
|
import { Socket } from 'node:net';
|
|
31
31
|
import { getActiveWorldStore, withFileLock, stateDirName, PROTOCOL_VERSION, PROTOCOL_MAJOR, protocolStanding, WORLD_BOOT_PATH, WORLD_DATA_ENV, WORLD_ENV_NAMES_ENV } from '@volter/world-core/runtime';
|
|
32
32
|
import { loadWorldConfig } from "./configs.js";
|
|
@@ -57,25 +57,6 @@ function retainSpawn(child) {
|
|
|
57
57
|
function heldStarts() {
|
|
58
58
|
return Object.fromEntries([...spawnedIdentities].map(([pid, identity]) => [pid, identity.start]));
|
|
59
59
|
}
|
|
60
|
-
/** The latest `limit` bytes of a service log, reaching into its one rotation (`<log>.1`) when the current file is
|
|
61
|
-
* shorter: a readiness line printed just before a rotation is still found, and a poll never reads 64 MiB files. */
|
|
62
|
-
function readServiceText(log, limit = 4 * 1024 * 1024) {
|
|
63
|
-
const tailOfFile = (path, bytes) => {
|
|
64
|
-
if (bytes <= 0 || !existsSync(path))
|
|
65
|
-
return '';
|
|
66
|
-
const fd = openSync(path, 'r');
|
|
67
|
-
try {
|
|
68
|
-
const size = fstatSync(fd).size, start = Math.max(0, size - bytes), buffer = Buffer.alloc(size - start);
|
|
69
|
-
readSync(fd, buffer, 0, buffer.length, start);
|
|
70
|
-
return buffer.toString('utf8');
|
|
71
|
-
}
|
|
72
|
-
finally {
|
|
73
|
-
closeSync(fd);
|
|
74
|
-
}
|
|
75
|
-
};
|
|
76
|
-
const current = tailOfFile(log, limit);
|
|
77
|
-
return `${tailOfFile(`${log}.1`, limit - Buffer.byteLength(current))}${current}`;
|
|
78
|
-
}
|
|
79
60
|
/** The World's events file: every service's start and end, and the World's own stops, one JSON line each. */
|
|
80
61
|
export function worldEventsPath(logs) {
|
|
81
62
|
return join(logs, 'events.jsonl');
|
|
@@ -136,46 +117,25 @@ async function untilReadyOrEnded(wait, ended, boot, what, log) {
|
|
|
136
117
|
});
|
|
137
118
|
});
|
|
138
119
|
}
|
|
139
|
-
/** That the process answering a service's port is the service
|
|
140
|
-
*
|
|
141
|
-
*
|
|
142
|
-
*
|
|
143
|
-
*
|
|
144
|
-
*
|
|
145
|
-
|
|
146
|
-
* to another of this boot's port-0 listeners, that server is the service just started. A settle standing for several
|
|
147
|
-
* ports runs in full unless an all-started receipt permits each port's own early identity confirmation.
|
|
148
|
-
* Programs that are not twins are never asked. A shared settle keeps legacy co-located ports within one guard. */
|
|
149
|
-
async function confirmOwnPort(port, boot, ended, twin, what, log, early = false, sharedSettle) {
|
|
150
|
-
// A twin is asked at once, raced with the settle: a server slow to answer costs no more than the settle did.
|
|
120
|
+
/** That the process answering a service's port is the service, decided by an answer and never by a timed settle
|
|
121
|
+
* (architecture D9). A World twin answers WORLD_BOOT_PATH with the boot it belongs to: this boot's id is the proof; another
|
|
122
|
+
* boot's is a leftover World on the port, refused by name. A program that answers no boot identity (one that is not a
|
|
123
|
+
* twin) is the service when it is still running once its port answers: another process holding the port makes the
|
|
124
|
+
* service's own bind fail, which ends it, and its end is named. A co-located host's twins are vouched for by its
|
|
125
|
+
* all-started receipt first (ADR 0012, ADR 0014). */
|
|
126
|
+
async function confirmOwnPort(port, boot, ended, twin, what, log) {
|
|
151
127
|
boot.signal.throwIfAborted();
|
|
152
|
-
const
|
|
153
|
-
const own = early && twin ? answer.then((a) => (a && a.boot === boot.bootId ? 'own' : new Promise(() => undefined))) : new Promise(() => undefined);
|
|
154
|
-
// `ended` absent: the process was already settled (a co-located host settles once for all its twins).
|
|
155
|
-
if (ended) {
|
|
156
|
-
let timer;
|
|
157
|
-
let settled;
|
|
158
|
-
try {
|
|
159
|
-
const settle = sharedSettle ?? new Promise((resolveAlive) => { timer = setTimeout(() => resolveAlive('alive'), 300); });
|
|
160
|
-
settled = await Promise.race([own, ended.then(() => 'ended'), settle]);
|
|
161
|
-
}
|
|
162
|
-
finally {
|
|
163
|
-
if (timer !== undefined)
|
|
164
|
-
clearTimeout(timer);
|
|
165
|
-
}
|
|
166
|
-
boot.signal.throwIfAborted();
|
|
167
|
-
if (settled === 'own')
|
|
168
|
-
return;
|
|
169
|
-
if (settled === 'ended')
|
|
170
|
-
throw new Error(`${what} ended right after its port first answered, so another process probably holds the port: the service ${endedAccount(log)}\nLog: ${log}`);
|
|
171
|
-
}
|
|
172
|
-
if (!twin)
|
|
173
|
-
return;
|
|
174
|
-
const answered = await answer;
|
|
128
|
+
const answered = twin ? await worldBootOf(port, boot.signal) : null;
|
|
175
129
|
boot.signal.throwIfAborted();
|
|
176
|
-
if (answered && typeof answered.boot === 'string'
|
|
130
|
+
if (answered && typeof answered.boot === 'string') {
|
|
131
|
+
if (answered.boot === boot.bootId)
|
|
132
|
+
return;
|
|
177
133
|
throw new Error(`${what}: its port is answered by a twin of another World boot (${answered.boot}), not this one; a leftover World holds the port. Stop it (\`volter-world list\`, then \`down\`) and retry.\nLog: ${log}`);
|
|
178
134
|
}
|
|
135
|
+
// Whether the service has already ended, read now: one turn of the event loop, not a wait for it to fail.
|
|
136
|
+
const endedNow = ended ? await Promise.race([ended.then(() => true), new Promise((resolveNow) => setImmediate(() => resolveNow(false)))]) : false;
|
|
137
|
+
if (endedNow)
|
|
138
|
+
throw new Error(`${what} ended right after its port first answered, so another process probably holds the port: the service ${endedAccount(log)}\nLog: ${log}`);
|
|
179
139
|
}
|
|
180
140
|
/** What the server on a loopback port says its World boot is (WORLD_BOOT_PATH), or null. A direct loopback request:
|
|
181
141
|
* never through a proxy an activated shell's environment names. */
|
|
@@ -1237,7 +1197,7 @@ async function runOwnInfrastructureUp(command, serviceId, env, logPath, abort, p
|
|
|
1237
1197
|
}
|
|
1238
1198
|
/** Resolve `${url}`/`${host}`/`${port}` in a readiness probe's httpUrl against an owned loopback
|
|
1239
1199
|
* service's allocated port (process/twin readiness — the port is auto-assigned, so it can't be a
|
|
1240
|
-
* literal in the config).
|
|
1200
|
+
* literal in the config). A command probe is returned unchanged. */
|
|
1241
1201
|
function resolveReadyProbe(ready, svc) {
|
|
1242
1202
|
if (ready.httpUrl === undefined)
|
|
1243
1203
|
return ready;
|
|
@@ -1247,7 +1207,7 @@ function resolveReadyProbe(ready, svc) {
|
|
|
1247
1207
|
.split('${port}').join(String(svc.port));
|
|
1248
1208
|
return { ...ready, httpUrl };
|
|
1249
1209
|
}
|
|
1250
|
-
async function awaitReadiness(ready,
|
|
1210
|
+
async function awaitReadiness(ready, cwd, serviceId, env = process.env, abort) {
|
|
1251
1211
|
if (!ready)
|
|
1252
1212
|
return 'no readiness declared';
|
|
1253
1213
|
const timeoutMs = ready.timeoutMs ?? 60_000;
|
|
@@ -1283,12 +1243,6 @@ async function awaitReadiness(ready, logPath, cwd, serviceId, env = process.env,
|
|
|
1283
1243
|
lastError = errorMessage(error);
|
|
1284
1244
|
}
|
|
1285
1245
|
}
|
|
1286
|
-
else if (ready.stdoutMatch !== undefined) {
|
|
1287
|
-
const text = serviceOutput(readServiceText(logPath));
|
|
1288
|
-
if (new RegExp(ready.stdoutMatch).test(text))
|
|
1289
|
-
return passed(`the log matched /${ready.stdoutMatch}/`);
|
|
1290
|
-
lastError = `pattern /${ready.stdoutMatch}/ not yet in service log`;
|
|
1291
|
-
}
|
|
1292
1246
|
// A service that is nearly ready is seen at once; one that is slow is asked at most every intervalMs.
|
|
1293
1247
|
await sleep(Math.min(intervalMs, 25 * 2 ** attempt));
|
|
1294
1248
|
attempt += 1;
|
|
@@ -1329,7 +1283,7 @@ async function startExternalService(service, paths, worldEnv, stripEnv, boot) {
|
|
|
1329
1283
|
const upOutput = await runExternalUp(external.up, cwd, service.id, env, log, boot?.signal, boot?.progress);
|
|
1330
1284
|
boot?.progress(`${service.id}: up returned`);
|
|
1331
1285
|
// 2. readiness — Docker-backed externals take seconds to boot.
|
|
1332
|
-
const readiness = await awaitReadiness(external.readyWhen,
|
|
1286
|
+
const readiness = await awaitReadiness(external.readyWhen, cwd, service.id, env, boot?.signal);
|
|
1333
1287
|
boot?.progress(`${service.id}: ready (${readiness})`);
|
|
1334
1288
|
// 3. status — discover connection info (optional; may reuse up output instead).
|
|
1335
1289
|
let statusOutput = '';
|
|
@@ -1465,7 +1419,7 @@ async function startService(config, service, worldEnv, paths, allocatedPorts, bo
|
|
|
1465
1419
|
throw new Error(`Service "${service.id}" failed to start: ${errorMessage(error)}\nLog: ${log}`);
|
|
1466
1420
|
});
|
|
1467
1421
|
boot.progress(`${service.id}: port ${port} answers`);
|
|
1468
|
-
await confirmOwnPort(port, boot, ended, (service.type ?? 'twin') === 'twin', `Service "${service.id}"`, log
|
|
1422
|
+
await confirmOwnPort(port, boot, ended, (service.type ?? 'twin') === 'twin', `Service "${service.id}"`, log).catch(async (error) => {
|
|
1469
1423
|
if (undeadPids([child.pid]).length)
|
|
1470
1424
|
recordWorldStop([log], 'world.service.stop', 'up (port not its own)', 'SIGTERM');
|
|
1471
1425
|
try {
|
|
@@ -1481,10 +1435,10 @@ async function startService(config, service, worldEnv, paths, allocatedPorts, bo
|
|
|
1481
1435
|
const host = '127.0.0.1';
|
|
1482
1436
|
const url = `http://${host}:${port}`;
|
|
1483
1437
|
boot.progress(`${service.id}: port ${port} is its own`);
|
|
1484
|
-
// Optional readiness probe beyond TCP (httpUrl/command
|
|
1438
|
+
// Optional readiness probe beyond TCP (httpUrl/command): a process can bind its port
|
|
1485
1439
|
// before it is actually serving, so this makes `up` wait until the service truly responds.
|
|
1486
1440
|
if (service.ready && service.ready !== 'tcp') {
|
|
1487
|
-
const readiness = await untilReadyOrEnded((abort) => awaitReadiness(resolveReadyProbe(service.ready, { host, port, url }),
|
|
1441
|
+
const readiness = await untilReadyOrEnded((abort) => awaitReadiness(resolveReadyProbe(service.ready, { host, port, url }), cwd, service.id, env, abort), ended, boot, 'the service', log).catch(async (error) => {
|
|
1488
1442
|
if (undeadPids([child.pid]).length)
|
|
1489
1443
|
recordWorldStop([log], 'world.service.stop', 'up (startup failed)', 'SIGTERM');
|
|
1490
1444
|
try {
|
|
@@ -1960,11 +1914,10 @@ async function startColocatedServices(config, colocated, isolation, worldEnv, pa
|
|
|
1960
1914
|
// The host's receipt (this boot, every requested port) is published only
|
|
1961
1915
|
// after every factory bound its own port; a port another process held fails
|
|
1962
1916
|
// its bind and leaves no receipt. The receipt is the host's ownership proof
|
|
1963
|
-
// for each port
|
|
1964
|
-
//
|
|
1965
|
-
const hostSettle = Promise.resolve('alive');
|
|
1917
|
+
// for each port (ADR 0014). A port that answers with another boot's identity
|
|
1918
|
+
// is still refused.
|
|
1966
1919
|
await parallelStartup(allocated, async ({ service, port }) => {
|
|
1967
|
-
await confirmOwnPort(port, boot, ended, true, `Co-located twin "${service.id}"`, log
|
|
1920
|
+
await confirmOwnPort(port, boot, ended, true, `Co-located twin "${service.id}"`, log).catch(async (error) => {
|
|
1968
1921
|
if (undeadPids([child.pid]).length)
|
|
1969
1922
|
recordWorldStop([log], 'world.service.stop', 'up (port not its own)', 'SIGTERM');
|
|
1970
1923
|
try {
|
|
@@ -3103,7 +3056,7 @@ function canonicalDottedIpv4(host) {
|
|
|
3103
3056
|
function parseCustomShareOrigin(handshake) {
|
|
3104
3057
|
const raw = /^https?:\/\/(\[[0-9a-f:.]+\]|[a-z0-9.-]+)(?::([0-9]{1,5}))?\/?$/i.exec(handshake);
|
|
3105
3058
|
if (!raw) {
|
|
3106
|
-
throw new Error(`
|
|
3059
|
+
throw new Error(`The URL a custom tunnel wrote to VOLTER_SHARE_URL_FILE must be a strict http(s) origin without path, query, fragment, or backslashes: ${handshake}`);
|
|
3107
3060
|
}
|
|
3108
3061
|
const rawHost = raw[1];
|
|
3109
3062
|
const rawPort = raw[2];
|
|
@@ -3112,24 +3065,27 @@ function parseCustomShareOrigin(handshake) {
|
|
|
3112
3065
|
parsed = new URL(handshake);
|
|
3113
3066
|
}
|
|
3114
3067
|
catch {
|
|
3115
|
-
throw new Error(`
|
|
3068
|
+
throw new Error(`A custom tunnel wrote an invalid URL to VOLTER_SHARE_URL_FILE: ${handshake}`);
|
|
3116
3069
|
}
|
|
3117
3070
|
const normalizedHostname = parsed.hostname.replace(/^\[|\]$/g, '').toLowerCase();
|
|
3118
3071
|
if (!rawHost.startsWith('[') && isIP(normalizedHostname) === 4
|
|
3119
3072
|
&& (!canonicalDottedIpv4(rawHost) || rawHost !== normalizedHostname)) {
|
|
3120
|
-
throw new Error(`
|
|
3073
|
+
throw new Error(`The URL a custom tunnel wrote to VOLTER_SHARE_URL_FILE must use canonical dotted-decimal IPv4 syntax: ${handshake}`);
|
|
3121
3074
|
}
|
|
3122
3075
|
if (rawPort && rawPort.length > 1 && rawPort.startsWith('0')) {
|
|
3123
|
-
throw new Error(`
|
|
3076
|
+
throw new Error(`The URL a custom tunnel wrote to VOLTER_SHARE_URL_FILE must use canonical decimal port syntax: ${handshake}`);
|
|
3124
3077
|
}
|
|
3125
3078
|
if ((parsed.protocol !== 'http:' && parsed.protocol !== 'https:') || parsed.username || parsed.password
|
|
3126
3079
|
|| parsed.pathname !== '/' || parsed.search || parsed.hash) {
|
|
3127
|
-
throw new Error(`
|
|
3080
|
+
throw new Error(`The URL a custom tunnel wrote to VOLTER_SHARE_URL_FILE must be a credential-free http(s) origin: ${handshake}`);
|
|
3128
3081
|
}
|
|
3129
3082
|
return parsed.origin;
|
|
3130
3083
|
}
|
|
3131
|
-
|
|
3132
|
-
|
|
3084
|
+
/** The tunnel's public URL, from the tunnel's own answer and never its output (architecture D9). cloudflared's metrics
|
|
3085
|
+
* server answers GET /quicktunnel with {"hostname"} once the quick tunnel is registered (empty before); a custom command
|
|
3086
|
+
* writes its public URL to VOLTER_SHARE_URL_FILE, ending the line when it is complete, so a half-written URL is never
|
|
3087
|
+
* read. The process's log is for people. */
|
|
3088
|
+
async function waitForPublicUrl(child, source, logPath, timeoutMs) {
|
|
3133
3089
|
let exited = false;
|
|
3134
3090
|
let exitSummary = '';
|
|
3135
3091
|
child.once('exit', (code, signal) => {
|
|
@@ -3142,26 +3098,35 @@ async function waitForPublicUrl(child, logPath, timeoutMs, requireTryCloudflare)
|
|
|
3142
3098
|
});
|
|
3143
3099
|
const started = Date.now();
|
|
3144
3100
|
while (Date.now() - started < timeoutMs) {
|
|
3145
|
-
|
|
3146
|
-
|
|
3147
|
-
|
|
3148
|
-
const
|
|
3149
|
-
if (
|
|
3101
|
+
if ('metricsPort' in source) {
|
|
3102
|
+
try {
|
|
3103
|
+
const answer = await fetch(`http://127.0.0.1:${source.metricsPort}/quicktunnel`, { signal: AbortSignal.timeout(1000) });
|
|
3104
|
+
const hostname = answer.ok ? (await answer.json()).hostname : undefined;
|
|
3105
|
+
if (typeof hostname === 'string' && hostname) {
|
|
3106
|
+
const parsed = parseCloudflareQuickPublicUrl(`https://${hostname}`);
|
|
3107
|
+
if (!parsed)
|
|
3108
|
+
throw new Error(`cloudflared answered /quicktunnel with ${JSON.stringify(hostname)}, not a trycloudflare.com hostname. Log: ${logPath}`);
|
|
3150
3109
|
return parsed;
|
|
3110
|
+
}
|
|
3111
|
+
}
|
|
3112
|
+
catch (error) {
|
|
3113
|
+
if (error instanceof Error && error.message.startsWith('cloudflared answered'))
|
|
3114
|
+
throw error;
|
|
3115
|
+
// its metrics server not listening yet: asked again
|
|
3151
3116
|
}
|
|
3152
3117
|
}
|
|
3153
3118
|
else {
|
|
3154
|
-
const
|
|
3155
|
-
if (
|
|
3156
|
-
return parseCustomShareOrigin(
|
|
3119
|
+
const text = existsSync(source.urlFile) ? readFileSync(source.urlFile, 'utf8') : '';
|
|
3120
|
+
if (text.endsWith('\n'))
|
|
3121
|
+
return parseCustomShareOrigin(text.trim());
|
|
3157
3122
|
}
|
|
3158
3123
|
if (exited)
|
|
3159
|
-
throw new Error(`Tunnel process exited before
|
|
3160
|
-
await new Promise((resolveWait) => setTimeout(resolveWait,
|
|
3124
|
+
throw new Error(`Tunnel process exited before answering its public URL (${exitSummary}). Log: ${logPath}`);
|
|
3125
|
+
await new Promise((resolveWait) => setTimeout(resolveWait, 100));
|
|
3161
3126
|
}
|
|
3162
|
-
throw new Error(
|
|
3163
|
-
? `
|
|
3164
|
-
: `Custom tunnel did not
|
|
3127
|
+
throw new Error('metricsPort' in source
|
|
3128
|
+
? `cloudflared's metrics server did not answer a trycloudflare.com hostname at /quicktunnel within ${timeoutMs}ms. Log: ${logPath}`
|
|
3129
|
+
: `Custom tunnel did not write its public URL, newline-terminated, to VOLTER_SHARE_URL_FILE within ${timeoutMs}ms. Log: ${logPath}`);
|
|
3165
3130
|
}
|
|
3166
3131
|
function sleep(ms) {
|
|
3167
3132
|
return new Promise((resolveWait) => setTimeout(resolveWait, ms));
|
|
@@ -3331,12 +3296,19 @@ async function shareWorldReserved(name, options) {
|
|
|
3331
3296
|
}
|
|
3332
3297
|
const tunnel = shareCommand(options, service.url);
|
|
3333
3298
|
const log = ownedPath(instance.dirs.logs, `${serviceId}.tunnel.log`);
|
|
3334
|
-
//
|
|
3335
|
-
//
|
|
3299
|
+
// The public URL is the tunnel's own answer, never its output (architecture D9): cloudflared's metrics server
|
|
3300
|
+
// answers /quicktunnel with the hostname it registered; a custom command writes its URL to VOLTER_SHARE_URL_FILE.
|
|
3301
|
+
// Each spawned tunnel owns one generation of both, so a repeated share never takes the previous process's URL.
|
|
3302
|
+
const source = tunnel.provider === 'cloudflare-quick'
|
|
3303
|
+
? { metricsPort: await allocatePort(new Set()) }
|
|
3304
|
+
: { urlFile: ownedPath(instance.dirs.logs, `${serviceId}.tunnel.url`) };
|
|
3305
|
+
if ('urlFile' in source)
|
|
3306
|
+
rmSync(source.urlFile, { force: true });
|
|
3307
|
+
const args = 'metricsPort' in source ? ['tunnel', '--metrics', `127.0.0.1:${source.metricsPort}`, ...tunnel.args.slice(1)] : tunnel.args;
|
|
3336
3308
|
const out = openPrivateLog(log, 'w');
|
|
3337
|
-
const child = spawn(tunnel.command,
|
|
3309
|
+
const child = spawn(tunnel.command, args, { windowsHide: true,
|
|
3338
3310
|
cwd: root,
|
|
3339
|
-
env: withoutVaultSignIn(process.env),
|
|
3311
|
+
env: { ...withoutVaultSignIn(process.env), ...('urlFile' in source ? { VOLTER_SHARE_URL_FILE: source.urlFile } : {}) },
|
|
3340
3312
|
detached: true,
|
|
3341
3313
|
stdio: ['ignore', out, out],
|
|
3342
3314
|
});
|
|
@@ -3352,7 +3324,7 @@ async function shareWorldReserved(name, options) {
|
|
|
3352
3324
|
if (!identity?.start)
|
|
3353
3325
|
throw new Error('Tunnel birth capture failed; retiring held child');
|
|
3354
3326
|
identities[identity.pid] = identity.start;
|
|
3355
|
-
publicUrl = await waitForPublicUrl(child, log, options.timeoutMs ?? 20_000
|
|
3327
|
+
publicUrl = await waitForPublicUrl(child, source, log, options.timeoutMs ?? 20_000);
|
|
3356
3328
|
if (options.verifyPath !== false) {
|
|
3357
3329
|
publicVerification = await verifyPublicUrl(publicUrl, options.verifyPath ?? '/health', options.timeoutMs ?? 90_000);
|
|
3358
3330
|
}
|
|
@@ -3371,7 +3343,7 @@ async function shareWorldReserved(name, options) {
|
|
|
3371
3343
|
provider: tunnel.provider,
|
|
3372
3344
|
pid: child.pid ?? 0,
|
|
3373
3345
|
log,
|
|
3374
|
-
command: [tunnel.command, ...
|
|
3346
|
+
command: [tunnel.command, ...args],
|
|
3375
3347
|
startedAt: new Date().toISOString(),
|
|
3376
3348
|
};
|
|
3377
3349
|
writePidsFromInstance(nextInstance, child.pid ? [child.pid] : []);
|
|
@@ -3503,8 +3475,8 @@ const DOCTOR_EXTERNAL_PROBE_TIMEOUT_MS = 3_000;
|
|
|
3503
3475
|
/** Run an external service's declared readiness probe exactly ONCE (unlike `awaitReadiness`, which
|
|
3504
3476
|
* polls until it passes or times out — appropriate at boot, wrong for a doctor health check, which
|
|
3505
3477
|
* must report the CURRENT state, not wait for it to become healthy). Reuses the same probe shapes
|
|
3506
|
-
* (`command`/`httpUrl
|
|
3507
|
-
async function probeExternalOnce(ready, cwd, env
|
|
3478
|
+
* (`command`/`httpUrl`) declared in config for the boot-time `readyWhen`. */
|
|
3479
|
+
async function probeExternalOnce(ready, cwd, env) {
|
|
3508
3480
|
if (ready.command !== undefined) {
|
|
3509
3481
|
if (!commandExists(ready.command)) {
|
|
3510
3482
|
return { ok: false, message: `readiness command \`${ready.command}\` not found on PATH` };
|
|
@@ -3529,13 +3501,7 @@ async function probeExternalOnce(ready, cwd, env, logPath) {
|
|
|
3529
3501
|
return { ok: false, message: `probe failed: ${errorMessage(error)}` };
|
|
3530
3502
|
}
|
|
3531
3503
|
}
|
|
3532
|
-
|
|
3533
|
-
const text = serviceOutput(readServiceText(logPath));
|
|
3534
|
-
if (new RegExp(ready.stdoutMatch).test(text))
|
|
3535
|
-
return { ok: true, message: 'probe ok (stdoutMatch)' };
|
|
3536
|
-
return { ok: false, message: `probe pattern /${ready.stdoutMatch}/ not found in log` };
|
|
3537
|
-
}
|
|
3538
|
-
return { ok: false, message: 'malformed readiness probe (no command/httpUrl/stdoutMatch)' };
|
|
3504
|
+
return { ok: false, message: 'malformed readiness probe (no command/httpUrl)' };
|
|
3539
3505
|
}
|
|
3540
3506
|
/** A stopped service's own account for doctor: how it ended, its last output, and where its whole log is. */
|
|
3541
3507
|
function stoppedServiceReport(pid, log) {
|
|
@@ -3599,7 +3565,7 @@ export async function doctorWorld(name, options = {}) {
|
|
|
3599
3565
|
const probeCwd = service.external?.cwd ?? root;
|
|
3600
3566
|
const probeEnv = { ...admittedEnv(stripEnvOf(instance.env ?? {})), ...(instance.env ?? {}), ...(service.external?.discoveredEnv ?? {}) };
|
|
3601
3567
|
if (configuredExternal?.readyWhen) {
|
|
3602
|
-
const probe = await probeExternalOnce(configuredExternal.readyWhen, probeCwd, probeEnv
|
|
3568
|
+
const probe = await probeExternalOnce(configuredExternal.readyWhen, probeCwd, probeEnv);
|
|
3603
3569
|
checks.push({ id: `service:${service.id}:external`, ok: probe.ok, message: `${discoveredMessage}; ${probe.message}` });
|
|
3604
3570
|
}
|
|
3605
3571
|
else if (configuredExternal?.status) {
|
|
@@ -3637,12 +3603,11 @@ export async function doctorWorld(name, options = {}) {
|
|
|
3637
3603
|
checks.push({ id: `service:${service.id}:tcp`, ok: true, message: service.url });
|
|
3638
3604
|
// A port that accepts is not a service that answers (a stuck event loop still completes the handshake): the
|
|
3639
3605
|
// readiness probe the service declared is run again, as it was at boot.
|
|
3640
|
-
//
|
|
3641
|
-
// in the service's own cwd and with its PORT, as at boot.
|
|
3606
|
+
// The probe is re-run in the service's own cwd and with its PORT, as at boot.
|
|
3642
3607
|
const configured = externalProbeConfig?.services.find((candidate) => candidate.id === service.id);
|
|
3643
3608
|
const declared = configured?.ready;
|
|
3644
|
-
if (pidLive && declared && declared !== 'tcp'
|
|
3645
|
-
const probe = await probeExternalOnce(resolveReadyProbe(declared, { host: '127.0.0.1', port: service.port, url: service.url }), configured?.cwd ? resolve(root, configured.cwd) : root, { ...admittedEnv(stripEnvOf(instance.env ?? {})), ...(instance.env ?? {}), ...(configured?.env ?? {}), PORT: String(service.port) }
|
|
3609
|
+
if (pidLive && declared && declared !== 'tcp') {
|
|
3610
|
+
const probe = await probeExternalOnce(resolveReadyProbe(declared, { host: '127.0.0.1', port: service.port, url: service.url }), configured?.cwd ? resolve(root, configured.cwd) : root, { ...admittedEnv(stripEnvOf(instance.env ?? {})), ...(instance.env ?? {}), ...(configured?.env ?? {}), PORT: String(service.port) });
|
|
3646
3611
|
checks.push({ id: `service:${service.id}:ready`, ok: probe.ok,
|
|
3647
3612
|
message: probe.ok ? probe.message : `its port accepts but its declared readiness probe fails (${probe.message}): the service is up but not answering\n log: ${service.log}` });
|
|
3648
3613
|
}
|
package/dist/src/schema.d.ts
CHANGED
|
@@ -118,16 +118,15 @@ export type WorldExternalDiscover = {
|
|
|
118
118
|
pattern?: string;
|
|
119
119
|
};
|
|
120
120
|
/**
|
|
121
|
-
* Optional readiness probe, polled after `up` until it passes or `timeoutMs` elapses
|
|
122
|
-
*
|
|
123
|
-
* - `
|
|
124
|
-
* - `
|
|
121
|
+
* Optional readiness probe, polled after `up` until it passes or `timeoutMs` elapses: the service answering its own
|
|
122
|
+
* protocol (architecture D9). Exactly one of:
|
|
123
|
+
* - `command`/`args`: a command that asks the service and exits 0 when it answers,
|
|
124
|
+
* - `httpUrl`: an HTTP(S) URL that must return a 2xx/3xx response.
|
|
125
125
|
*/
|
|
126
126
|
export type WorldExternalReadyWhen = {
|
|
127
127
|
command?: string;
|
|
128
128
|
args?: string[];
|
|
129
129
|
httpUrl?: string;
|
|
130
|
-
stdoutMatch?: string;
|
|
131
130
|
timeoutMs?: number;
|
|
132
131
|
intervalMs?: number;
|
|
133
132
|
};
|
|
@@ -252,7 +251,6 @@ export type WorldServiceConfig = {
|
|
|
252
251
|
* service truly serves, so `up` returning means "ready" (no post-up re-polling needed):
|
|
253
252
|
* - `{ httpUrl }` — poll until 2xx/3xx. `${url}`/`${host}`/`${port}` resolve to the allocated port.
|
|
254
253
|
* - `{ command, args }` — poll until the command exits 0 (runs with the service env, incl. `PORT`).
|
|
255
|
-
* - `{ stdoutMatch }` — poll until a regex matches the service's stdout/stderr log.
|
|
256
254
|
* 'external' services use `external.readyWhen` instead.
|
|
257
255
|
*/
|
|
258
256
|
ready?: 'tcp' | WorldExternalReadyWhen;
|
package/dist/src/schema.js
CHANGED
|
@@ -31,17 +31,16 @@ function assertValidRegex(pattern, what, path) {
|
|
|
31
31
|
}
|
|
32
32
|
}
|
|
33
33
|
/** Shared validator for a readiness probe — used by both `external.readyWhen` and a process/twin
|
|
34
|
-
* service's `ready` object. Exactly one of command/httpUrl
|
|
35
|
-
* stdoutMatch regex must compile (so a typo fails at config time, not mid-boot). */
|
|
34
|
+
* service's `ready` object. Exactly one of command/httpUrl, with correct types. */
|
|
36
35
|
function assertReadyWhen(ready, what, path) {
|
|
37
36
|
if (!ready || typeof ready !== 'object' || Array.isArray(ready)) {
|
|
38
37
|
throw new Error(`${what} must be an object in ${path}`);
|
|
39
38
|
}
|
|
40
|
-
assertKeys(ready, ['command', 'args', 'httpUrl', '
|
|
39
|
+
assertKeys(ready, ['command', 'args', 'httpUrl', 'timeoutMs', 'intervalMs'], what, path);
|
|
41
40
|
const r = ready;
|
|
42
|
-
const probes = [r.command !== undefined, r.httpUrl !== undefined
|
|
41
|
+
const probes = [r.command !== undefined, r.httpUrl !== undefined].filter(Boolean).length;
|
|
43
42
|
if (probes !== 1) {
|
|
44
|
-
throw new Error(`${what} must set exactly one of command/httpUrl
|
|
43
|
+
throw new Error(`${what} must set exactly one of command/httpUrl in ${path}`);
|
|
45
44
|
}
|
|
46
45
|
if (r.command !== undefined && typeof r.command !== 'string')
|
|
47
46
|
throw new Error(`${what}.command must be a string in ${path}`);
|
|
@@ -51,11 +50,6 @@ function assertReadyWhen(ready, what, path) {
|
|
|
51
50
|
throw new Error(`${what}.args requires command in ${path}`);
|
|
52
51
|
if (r.httpUrl !== undefined && typeof r.httpUrl !== 'string')
|
|
53
52
|
throw new Error(`${what}.httpUrl must be a string in ${path}`);
|
|
54
|
-
if (r.stdoutMatch !== undefined) {
|
|
55
|
-
if (typeof r.stdoutMatch !== 'string')
|
|
56
|
-
throw new Error(`${what}.stdoutMatch must be a string in ${path}`);
|
|
57
|
-
assertValidRegex(r.stdoutMatch, `${what}.stdoutMatch`, path);
|
|
58
|
-
}
|
|
59
53
|
for (const key of ['timeoutMs', 'intervalMs']) {
|
|
60
54
|
if (r[key] !== undefined && (!Number.isInteger(r[key]) || r[key] <= 0))
|
|
61
55
|
throw new Error(`${what}.${key} must be a positive integer in ${path}`);
|
|
@@ -24,9 +24,6 @@ export type ServiceEnd = {
|
|
|
24
24
|
/** The service's own last lines of output in its last run, oldest first. */
|
|
25
25
|
lastOutput: string[];
|
|
26
26
|
};
|
|
27
|
-
/** A log's text as the service wrote it, without the recorder's records: what a `stdoutMatch` readiness pattern is
|
|
28
|
-
* matched against. */
|
|
29
|
-
export declare function serviceOutput(text: string): string;
|
|
30
27
|
/** The last run's end in a service log: its start, its exit record (absent while it runs, or when its parent was killed
|
|
31
28
|
* with it), and the output it wrote last. Undefined for a log the parent never wrote to (a co-located twin's host log),
|
|
32
29
|
* which has no runs to tell apart. A run is bounded by its start record or, for a spawn that failed before starting
|
package/dist/src/service-exit.js
CHANGED
|
@@ -74,11 +74,6 @@ function split(line) {
|
|
|
74
74
|
return { output: line };
|
|
75
75
|
}
|
|
76
76
|
}
|
|
77
|
-
/** A log's text as the service wrote it, without the recorder's records: what a `stdoutMatch` readiness pattern is
|
|
78
|
-
* matched against. */
|
|
79
|
-
export function serviceOutput(text) {
|
|
80
|
-
return text.includes(MARK) ? text.split('\n').map((line) => split(line).output).join('\n') : text;
|
|
81
|
-
}
|
|
82
77
|
/** The last run's end in a service log: its start, its exit record (absent while it runs, or when its parent was killed
|
|
83
78
|
* with it), and the output it wrote last. Undefined for a log the parent never wrote to (a co-located twin's host log),
|
|
84
79
|
* which has no runs to tell apart. A run is bounded by its start record or, for a spawn that failed before starting
|
package/dist/src/up-cli.js
CHANGED
|
@@ -23,7 +23,18 @@ export async function upCommand(subject, rest, root) {
|
|
|
23
23
|
// --keep-state: boot over the state the World's directory already holds (a World carried somewhere else, e.g. a
|
|
24
24
|
// browser tab's image, comes up over its data instead of starting empty, as a fresh `up` does)
|
|
25
25
|
const keepState = rest.includes('--keep-state');
|
|
26
|
+
// --app-url: where the booter serves the app, recorded as `volter-world app-url --set` records it, once the World is
|
|
27
|
+
// up and in this process: a booter that knows the address before the app listens (a browser tab's preview origin)
|
|
28
|
+
// saves a whole command between up and the app's start.
|
|
29
|
+
const appUrl = optionValue(rest, '--app-url') || undefined;
|
|
30
|
+
if (appUrl !== undefined && !(URL.canParse(appUrl) && /^https?:$/.test(new URL(appUrl).protocol)))
|
|
31
|
+
throw new Error(`volter-world up: --app-url "${appUrl}" must be an http(s) URL`);
|
|
26
32
|
const instance = await superviseWorldUp(subject, { name, root, mode, envFile, forceEnvOutput: rest.includes('--force'), owner: ownerOption(rest), ...(isolation ? { isolation } : {}), ...(keepState ? { keepState: true } : {}) });
|
|
33
|
+
if (appUrl !== undefined) {
|
|
34
|
+
const { setAppUrl } = await import("./app-url.js");
|
|
35
|
+
setAppUrl(instance.name, appUrl, { root });
|
|
36
|
+
process.stderr.write(`volter-world up ${instance.name}: app url recorded: ${appUrl}\n`);
|
|
37
|
+
}
|
|
27
38
|
if (rest.includes('--json'))
|
|
28
39
|
process.stdout.write(JSON.stringify(instance, null, 2) + '\n');
|
|
29
40
|
else
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@volter/world-runtime",
|
|
3
|
-
"version": "3.0.
|
|
3
|
+
"version": "3.0.102",
|
|
4
4
|
"description": "World configs for twins: boot named local runtimes, allocate ports, generate world.env/instance.json, and run apps against fake-key twin worlds.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"twin",
|
|
@@ -69,14 +69,14 @@
|
|
|
69
69
|
"node": ">=22.3"
|
|
70
70
|
},
|
|
71
71
|
"peerDependencies": {
|
|
72
|
-
"@volter/world-core": "3.0.
|
|
73
|
-
"@volter/world-console": "3.0.
|
|
72
|
+
"@volter/world-core": "3.0.102",
|
|
73
|
+
"@volter/world-console": "3.0.97"
|
|
74
74
|
},
|
|
75
75
|
"dependencies": {
|
|
76
76
|
"@electric-sql/pglite": "0.5.8",
|
|
77
77
|
"@electric-sql/pglite-pgvector": "0.0.9",
|
|
78
78
|
"@volter/world-access": "3.0.1",
|
|
79
|
-
"@volter/world-core": "3.0.
|
|
79
|
+
"@volter/world-core": "3.0.102",
|
|
80
80
|
"mongodb": "~6.20.0",
|
|
81
81
|
"pg-gateway": "0.3.0-beta.4",
|
|
82
82
|
"smol-toml": "^1.8.0",
|
package/src/cli-commands.ts
CHANGED
|
@@ -61,7 +61,7 @@ function printHelp(): void {
|
|
|
61
61
|
# emission. Exit 0 = ready to \`up\`; exit 1 = the honest worklist.
|
|
62
62
|
# Deterministic: same repo + same pack catalog => identical bytes.
|
|
63
63
|
# init DIAGNOSES; it never boots — it prints the exact next commands
|
|
64
|
-
volter-world up <config> --env-out=<path> [--owner <label>] [--name <name>] [--mode local|share|sealed] [--isolation process|colocated|worker] [--keep-state] [--json] [--root <repo>]
|
|
64
|
+
volter-world up <config> --env-out=<path> [--owner <label>] [--name <name>] [--mode local|share|sealed] [--isolation process|colocated|worker] [--keep-state] [--app-url <url>] [--json] [--root <repo>]
|
|
65
65
|
volter-world outdated <world> [--json] — each mounted twin: pinned / mounted / catalog-current version and protocol standing (R17)
|
|
66
66
|
volter-world audit <world> [--json] — each mounted twin's grade, as the catalog records it (R17)
|
|
67
67
|
volter-world down <name> [--grace-ms <ms>] [--purge] [--if-instance <token>] [--root <repo>] # SIGTERM, then SIGKILL survivors after the grace (default 5000ms)
|
package/src/configs.ts
CHANGED
|
@@ -38,7 +38,7 @@ const LEGACY_NESTED = {
|
|
|
38
38
|
bare: new Set(['name']), resources: new Set(['memoryMiB', 'writableStorageMiB']), catalog: new Set(['sha', 'protocol']),
|
|
39
39
|
root: new Set(['url', 'deploy', 'refresh', 'scope']), refresh: new Set(['every', 'webhook']),
|
|
40
40
|
external: new Set(['up', 'status', 'down', 'discover', 'readyWhen']), discover: new Set(['as', 'source', 'jsonPath', 'pattern']),
|
|
41
|
-
ready: new Set(['command', 'args', 'httpUrl', '
|
|
41
|
+
ready: new Set(['command', 'args', 'httpUrl', 'timeoutMs', 'intervalMs']), colocate: new Set(['module', 'export', 'scenarioPath']),
|
|
42
42
|
share: new Set(['provider', 'command', 'args', 'ephemeral', 'services']), shareService: new Set(['id', 'verifyPath']),
|
|
43
43
|
} as const;
|
|
44
44
|
|
package/src/mongodb-backing.ts
CHANGED
|
@@ -8,14 +8,16 @@ export function mongodbArgv(service: InfraService, data: string): string[] {
|
|
|
8
8
|
...(service.replicaSet ? ['--replSet', service.replicaSet] : [])];
|
|
9
9
|
}
|
|
10
10
|
|
|
11
|
-
/** MongoDB's
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
11
|
+
/** Whether the server answering MongoDB's protocol on the port is this child: its own serverStatus names its pid. A
|
|
12
|
+
* listener that is not answering yet, or another process's server on the port, is not. */
|
|
13
|
+
export async function mongodbServes(service: InfraService, pid: number): Promise<boolean> {
|
|
14
|
+
const { MongoClient } = await import('mongodb');
|
|
15
|
+
const client = new MongoClient(`mongodb://127.0.0.1:${service.hostPort}`, { directConnection: true, timeoutMS: 1000, serverSelectionTimeoutMS: 1000 });
|
|
16
|
+
try {
|
|
17
|
+
await client.connect();
|
|
18
|
+
const status = await client.db('admin').command({ serverStatus: 1 });
|
|
19
|
+
return Number(status.pid) === pid;
|
|
20
|
+
} catch { return false; } finally { await client.close().catch(() => undefined); }
|
|
19
21
|
}
|
|
20
22
|
|
|
21
23
|
/** Ping the server; initialize only an explicitly declared one-member set and await its primary. */
|