@volter/world-runtime 2.0.1 → 2.0.3
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/app-url.js +1 -1
- package/dist/src/catalog.js +1 -1
- package/dist/src/cli.js +3 -3
- package/dist/src/console-apart.d.ts +1 -0
- package/dist/src/console-apart.js +7 -0
- package/dist/src/covers.js +4 -2
- package/dist/src/host-worker.js +2 -9
- package/dist/src/host.js +2 -9
- package/dist/src/import-module.d.ts +1 -0
- package/dist/src/import-module.js +16 -0
- package/dist/src/index.d.ts +7 -2
- package/dist/src/index.js +4 -1
- package/dist/src/infra-cli.js +2 -2
- package/dist/src/init.d.ts +1 -1
- package/dist/src/init.js +26 -0
- package/dist/src/local-branches.d.ts +37 -0
- package/dist/src/local-branches.js +193 -0
- package/dist/src/pglite-backing.js +8 -5
- package/dist/src/prerequisites.js +1 -1
- package/dist/src/process-groups.js +1 -1
- package/dist/src/redirect-proxy.js +2 -2
- package/dist/src/root.d.ts +23 -0
- package/dist/src/root.js +22 -10
- package/dist/src/run-task.js +1 -1
- package/dist/src/runtime.js +15 -15
- package/dist/src/schema.d.ts +5 -0
- package/dist/src/schema.js +10 -1
- package/dist/src/served-world.d.ts +196 -9
- package/dist/src/served-world.js +847 -103
- package/dist/src/service-recorder.js +1 -1
- package/dist/src/storage-capacity.js +2 -2
- package/dist/src/up-task.js +1 -1
- package/dist/src/world-origins.d.ts +13 -0
- package/dist/src/world-origins.js +37 -0
- package/dist/src/world-view.d.ts +25 -0
- package/dist/src/world-view.js +110 -0
- package/package.json +6 -5
- package/src/app-url.ts +1 -1
- package/src/catalog.ts +1 -1
- package/src/cli.ts +3 -3
- package/src/console-apart.ts +7 -1
- package/src/covers.ts +4 -2
- package/src/host-worker.ts +2 -1
- package/src/host.ts +2 -1
- package/src/import-module.ts +9 -0
- package/src/index.ts +7 -2
- package/src/infra-cli.ts +2 -2
- package/src/init.ts +22 -0
- package/src/local-branches.ts +173 -0
- package/src/pglite-backing.ts +8 -5
- package/src/prerequisites.ts +1 -1
- package/src/process-groups.ts +1 -1
- package/src/redirect-proxy.ts +2 -2
- package/src/root.ts +27 -2
- package/src/run-task.ts +1 -1
- package/src/runtime.ts +15 -15
- package/src/schema.ts +11 -1
- package/src/served-world.ts +762 -85
- package/src/service-recorder.ts +1 -1
- package/src/storage-capacity.ts +2 -2
- package/src/up-task.ts +1 -1
- package/src/world-origins.ts +38 -0
- package/src/world-view.ts +100 -0
|
@@ -0,0 +1,173 @@
|
|
|
1
|
+
// LOCAL BRANCHES: the branches a local host makes of the Worlds it serves (docs/contributing/architecture.md,
|
|
2
|
+
// "Viewing a World"). A branch is another World: the parent's config under a new name, cloned from the
|
|
3
|
+
// parent through its own doors (its history cut at an instant, when one is asked), its clock frozen at
|
|
4
|
+
// that instant so no twin's catch-up walks it forward, and removed when its time runs out. world-host
|
|
5
|
+
// and `volter world view` both make them with this; a hosted World's supervisor makes its own.
|
|
6
|
+
import { existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
|
|
7
|
+
import { dirname, join } from 'node:path';
|
|
8
|
+
import { stateDirName, withStateRemoval } from '@volter/world-core';
|
|
9
|
+
import { clockFile } from './runtime.ts';
|
|
10
|
+
import { loadWorldConfig } from './configs.ts';
|
|
11
|
+
import { TOKEN_HEADER, type BranchDoors, type BranchRow, type MountedWorld } from './served-world.ts';
|
|
12
|
+
|
|
13
|
+
/** What a branch records beside itself: whose branch it is, as of when, and until when. */
|
|
14
|
+
type BranchRecord = { from: string; at: { instant?: string; live?: boolean; label?: string } | null; createdAt: string; expiresAt: string | null };
|
|
15
|
+
const RECORD = 'branch.json';
|
|
16
|
+
const SWEEP_MS = 60_000;
|
|
17
|
+
|
|
18
|
+
/** A World's manifest under another name, in the manifest's own format: format 2 names a World in
|
|
19
|
+
* `metadata.id` and serves it bare at `serving.name`; format 1 in `id` and `bare.name`. */
|
|
20
|
+
function renamed(config: Record<string, unknown>, id: string, name: string): Record<string, unknown> {
|
|
21
|
+
if (config.schemaVersion === 2) {
|
|
22
|
+
return { ...config, metadata: { ...(config.metadata as Record<string, unknown> | undefined), id }, serving: { ...(config.serving as Record<string, unknown> | undefined), mode: 'bare', name } };
|
|
23
|
+
}
|
|
24
|
+
return { ...config, id, bare: { ...(config.bare as Record<string, unknown> | undefined), name } };
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export type LocalBranchesOptions = {
|
|
28
|
+
/** where branches live: `<dir>/<org>/<world>/` */
|
|
29
|
+
dir: string;
|
|
30
|
+
/** the URL a branch reaches its parent at (the host's own listener, on loopback) */
|
|
31
|
+
origin: () => string;
|
|
32
|
+
/** the Worlds the host serves, by served name; branches join it while they live */
|
|
33
|
+
worlds: Map<string, MountedWorld>;
|
|
34
|
+
/** mount a World at a root, with its branches doors wired */
|
|
35
|
+
mount: (root: string) => Promise<MountedWorld>;
|
|
36
|
+
announce?: (line: string) => void;
|
|
37
|
+
};
|
|
38
|
+
|
|
39
|
+
export class LocalBranches {
|
|
40
|
+
private sweeper: ReturnType<typeof setInterval> | null = null;
|
|
41
|
+
constructor(private readonly o: LocalBranchesOptions) {}
|
|
42
|
+
|
|
43
|
+
private record(world: MountedWorld): BranchRecord | null {
|
|
44
|
+
try { return JSON.parse(readFileSync(join(world.root, stateDirName(), RECORD), 'utf8')) as BranchRecord; } catch { return null; }
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** The branches of `parent` this host holds. */
|
|
48
|
+
list(parent: string): BranchRow[] {
|
|
49
|
+
const rows: BranchRow[] = [];
|
|
50
|
+
for (const [name, world] of this.o.worlds) {
|
|
51
|
+
const r = this.record(world);
|
|
52
|
+
if (r?.from === parent) rows.push({ name, from: r.from, at: r.at, createdAt: r.createdAt, expiresAt: r.expiresAt });
|
|
53
|
+
}
|
|
54
|
+
return rows.sort((a, b) => b.createdAt.localeCompare(a.createdAt));
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/** Branches being made, by parent: a parent is not removed mid-clone. */
|
|
58
|
+
private readonly making = new Map<string, number>();
|
|
59
|
+
hasBranches(parent: string): boolean { return (this.making.get(parent) ?? 0) > 0 || this.list(parent).length > 0; }
|
|
60
|
+
|
|
61
|
+
/** A World's branches doors, answered by this host. */
|
|
62
|
+
doorsFor(parent: string): BranchDoors {
|
|
63
|
+
return {
|
|
64
|
+
list: async () => this.list(parent),
|
|
65
|
+
create: (at, ttl, _origin, parentKey) => this.create(parent, at, ttl, parentKey),
|
|
66
|
+
remove: async (name) => { const w = this.o.worlds.get(name); if (!w || this.record(w)?.from !== parent) return false; await this.remove(name); return true; },
|
|
67
|
+
};
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
private async create(parent: string, at: { instant?: string; live?: boolean; label?: string }, ttlSeconds: number | null, parentKey?: string): Promise<{ name: string; token: string; readToken: string; expiresAt: string | null }> {
|
|
71
|
+
this.making.set(parent, (this.making.get(parent) ?? 0) + 1);
|
|
72
|
+
try { return await this.make(parent, at, ttlSeconds, parentKey); } finally {
|
|
73
|
+
const left = (this.making.get(parent) ?? 1) - 1;
|
|
74
|
+
if (left > 0) this.making.set(parent, left); else this.making.delete(parent);
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
private async make(parent: string, at: { instant?: string; live?: boolean; label?: string }, ttlSeconds: number | null, parentKey?: string): Promise<{ name: string; token: string; readToken: string; expiresAt: string | null }> {
|
|
78
|
+
const from = this.o.worlds.get(parent);
|
|
79
|
+
if (!from) throw new Error(`no world ${parent} here`);
|
|
80
|
+
const [org, world] = parent.split('/') as [string, string];
|
|
81
|
+
const stamp = at.instant ? at.instant.replace(/[-:]/g, '').replace(/\.\d+Z$|Z$/, '').toLowerCase().slice(0, 13) : 'now';
|
|
82
|
+
// a labelled branch (a pull request's preview) is named for its label; the random tail keeps a replacement distinct
|
|
83
|
+
// a name already taken (a 4-hex tail's rare clash) is passed over for a fresh tail, never made over
|
|
84
|
+
const free = (): { id: string; name: string; root: string } | null => {
|
|
85
|
+
for (let tries = 0; tries < 8; tries++) {
|
|
86
|
+
const suffix = `${at.label ? `-${at.label}` : `-at-${stamp}`}-${Buffer.from(crypto.getRandomValues(new Uint8Array(2))).toString('hex')}`;
|
|
87
|
+
const id = `${world.slice(0, 64 - suffix.length)}${suffix}`;
|
|
88
|
+
const name = `${org}/${id}`; const root = join(this.o.dir, org, id);
|
|
89
|
+
if (!this.o.worlds.has(name) && !existsSync(root)) return { id, name, root };
|
|
90
|
+
}
|
|
91
|
+
return null;
|
|
92
|
+
};
|
|
93
|
+
const picked = free();
|
|
94
|
+
if (!picked) throw new Error(`no free name for a branch of ${parent}`);
|
|
95
|
+
const { id, name, root } = picked;
|
|
96
|
+
// the parent's config under the branch's own name: the same twins, served at the branch's place
|
|
97
|
+
mkdirSync(join(root, stateDirName()), { recursive: true });
|
|
98
|
+
writeFileSync(join(root, stateDirName(), 'world.json'), `${JSON.stringify(renamed(JSON.parse(readFileSync(join(from.root, stateDirName(), 'world.json'), 'utf8')) as Record<string, unknown>, id, name), null, 2)}\n`);
|
|
99
|
+
let mounted: MountedWorld | undefined;
|
|
100
|
+
try {
|
|
101
|
+
mounted = await this.o.mount(root);
|
|
102
|
+
await mounted.boot(this.o.origin());
|
|
103
|
+
this.o.worlds.set(name, mounted);
|
|
104
|
+
const origin = this.o.origin();
|
|
105
|
+
// each twin's history cut at the instant, by the parent's own history door
|
|
106
|
+
let views: unknown;
|
|
107
|
+
if (at.instant) {
|
|
108
|
+
const cut = await from.handle(new Request(`${origin}/-/${parent}/history?at=${encodeURIComponent(at.instant)}`, { headers: { [TOKEN_HEADER]: from.token } }));
|
|
109
|
+
if (!cut.ok) throw new Error(`the history of ${parent} at ${at.instant}: ${await cut.text()}`);
|
|
110
|
+
views = ((await cut.json()) as { views: unknown }).views;
|
|
111
|
+
}
|
|
112
|
+
const cloned = await mounted.handle(new Request(`${origin}/-/${name}/origin`, { method: 'PUT', headers: { [TOKEN_HEADER]: mounted.token, 'content-type': 'application/json' }, body: JSON.stringify({ url: `${origin}/${parent}`, token: parentKey ?? from.token, ...(views ? { views } : {}) }) })); // the branch's own key to its parent (the parent's doors make it), never the parent's token
|
|
113
|
+
if (!cloned.ok) throw new Error(`cloning ${parent}: ${await cloned.text()}`);
|
|
114
|
+
// the branch's clock stands at its instant, else at the parent's: no twin's catch-up walks it on
|
|
115
|
+
// (a parent whose clock was never set: the branch's stands at now, frozen there, as http-api says);
|
|
116
|
+
// a live branch (a pull request's preview) keeps the parent's time: real time, or its simulated clock
|
|
117
|
+
const clock = at.instant ?? this.frozenClock(from) ?? (at.live ? null : new Date().toISOString());
|
|
118
|
+
if (clock) { const file = clockFile(root, id); mkdirSync(dirname(file), { recursive: true }); writeFileSync(file, `${clock}\n`); }
|
|
119
|
+
const createdAt = new Date().toISOString();
|
|
120
|
+
const expiresAt = ttlSeconds === null ? null : new Date(Date.now() + ttlSeconds * 1000).toISOString();
|
|
121
|
+
writeFileSync(join(root, stateDirName(), RECORD), `${JSON.stringify({ from: parent, at: at.instant ? { instant: at.instant } : null, createdAt, expiresAt } satisfies BranchRecord, null, 2)}\n`);
|
|
122
|
+
this.o.announce?.(`branch ${name} of ${parent}${at.instant ? ` as of ${at.instant}` : ''}${expiresAt ? `, until ${expiresAt}` : ''}`);
|
|
123
|
+
return { name, token: mounted.token, readToken: mounted.readToken, expiresAt };
|
|
124
|
+
} catch (error) {
|
|
125
|
+
this.o.worlds.delete(name);
|
|
126
|
+
try { await mounted?.stop(); } catch { /* reported below */ }
|
|
127
|
+
withStateRemoval(root, () => rmSync(root, { recursive: true, force: true }));
|
|
128
|
+
throw error;
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/** The parent's frozen clock, when it has one. */
|
|
133
|
+
private frozenClock(world: MountedWorld): string | null {
|
|
134
|
+
try {
|
|
135
|
+
const held = readFileSync(clockFile(world.root, loadWorldConfig(join(world.root, stateDirName(), 'world.json'), world.root).config.id), 'utf8').trim();
|
|
136
|
+
return Number.isNaN(Date.parse(held)) ? null : held;
|
|
137
|
+
} catch { return null; }
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/** Stop a branch and remove its World (a branch's own branches first). */
|
|
141
|
+
async remove(name: string): Promise<void> {
|
|
142
|
+
const world = this.o.worlds.get(name);
|
|
143
|
+
if (!world) return;
|
|
144
|
+
for (const child of this.list(name)) await this.remove(child.name);
|
|
145
|
+
await world.stop();
|
|
146
|
+
withStateRemoval(world.root, () => rmSync(world.root, { recursive: true, force: true }));
|
|
147
|
+
this.o.worlds.delete(name);
|
|
148
|
+
this.o.announce?.(`removed ${name}`);
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
/** Remove the branches whose time ran out, now and every minute while the host serves. */
|
|
152
|
+
async resume(): Promise<void> {
|
|
153
|
+
await this.sweep();
|
|
154
|
+
this.sweeper = setInterval(() => { void this.sweep(); }, SWEEP_MS);
|
|
155
|
+
this.sweeper.unref?.();
|
|
156
|
+
}
|
|
157
|
+
/** One sweep at a time: a slow one never overlaps the next. */
|
|
158
|
+
private sweeping = false;
|
|
159
|
+
private async sweep(): Promise<void> {
|
|
160
|
+
if (this.sweeping) return;
|
|
161
|
+
this.sweeping = true;
|
|
162
|
+
try {
|
|
163
|
+
const now = Date.now();
|
|
164
|
+
for (const [name, world] of [...this.o.worlds]) {
|
|
165
|
+
const r = this.record(world);
|
|
166
|
+
if (r?.expiresAt && Date.parse(r.expiresAt) <= now && this.o.worlds.has(name)) {
|
|
167
|
+
try { await this.remove(name); } catch (error) { this.o.announce?.(`branch ${name}: removal failed: ${error instanceof Error ? error.message : String(error)}`); }
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
} finally { this.sweeping = false; }
|
|
171
|
+
}
|
|
172
|
+
stop(): void { if (this.sweeper) clearInterval(this.sweeper); this.sweeper = null; }
|
|
173
|
+
}
|
package/src/pglite-backing.ts
CHANGED
|
@@ -62,8 +62,10 @@ export function infraConnections(services: InfraService[], env: Record<string, s
|
|
|
62
62
|
|
|
63
63
|
// this module's directory under Node as under Bun: `volter-world-infra` is a node-shebang bin, where
|
|
64
64
|
// `import.meta.dir` (Bun's) is undefined
|
|
65
|
-
|
|
66
|
-
|
|
65
|
+
// lazy: a module that builds a URL from import.meta.url when it loads fails to load in workerd, which bundles this
|
|
66
|
+
// module into the hosted World (it never runs these services there)
|
|
67
|
+
const here = (): string => fileURLToPath(new URL('.', import.meta.url));
|
|
68
|
+
const hostScript = (): string => join(here(), 'pglite-host.mjs');
|
|
67
69
|
const READY_TIMEOUT_MS = 60_000;
|
|
68
70
|
const MONGODB_TWIN = '@volter/twin-mongodb';
|
|
69
71
|
|
|
@@ -76,7 +78,7 @@ function pidPath(dataDir: string, kind: string): string {
|
|
|
76
78
|
|
|
77
79
|
/** The MongoDB twin's cli: the package installed above the World's config (an app repo), else the one
|
|
78
80
|
* this runtime was installed with (its dependency, or the checkout's workspace link). */
|
|
79
|
-
export function mongodbTwinCli(worldConfig?: string, froms: string[] = [...(worldConfig ? [dirname(worldConfig)] : []),
|
|
81
|
+
export function mongodbTwinCli(worldConfig?: string, froms: string[] = [...(worldConfig ? [dirname(worldConfig)] : []), here()]): string {
|
|
80
82
|
for (const from of froms) {
|
|
81
83
|
const dir = findInstalledPackage(from, MONGODB_TWIN);
|
|
82
84
|
const cli = dir === undefined ? undefined : packCli(dir);
|
|
@@ -89,7 +91,7 @@ export function mongodbTwinCli(worldConfig?: string, froms: string[] = [...(worl
|
|
|
89
91
|
* process's own runtime; a checkout's is TypeScript whose kernel needs more than Node's type
|
|
90
92
|
* stripping, so it runs under Bun (this process's, or the one on PATH) — the repository's toolchain —
|
|
91
93
|
* and without Bun it is refused by name rather than started to fail. */
|
|
92
|
-
export function twinRunner(cli: string, bunOnPath: () => boolean = () => spawnSync('bun', ['--version'], { stdio: 'ignore', timeout: 10_000 }).status === 0, underBun = typeof (globalThis as { Bun?: unknown }).Bun !== 'undefined'): string {
|
|
94
|
+
export function twinRunner(cli: string, bunOnPath: () => boolean = () => spawnSync('bun', ['--version'], { stdio: 'ignore', timeout: 10_000, windowsHide: true }).status === 0, underBun = typeof (globalThis as { Bun?: unknown }).Bun !== 'undefined'): string {
|
|
93
95
|
if (!cli.endsWith('.ts') || underBun) return process.execPath;
|
|
94
96
|
if (bunOnPath()) return 'bun';
|
|
95
97
|
throw new Error(`a checkout's MongoDB twin runs under bun, which is not on PATH (${cli} is TypeScript that Node cannot run)`);
|
|
@@ -101,7 +103,7 @@ function hostArgv(service: InfraService, serviceData: string): string[] {
|
|
|
101
103
|
const cli = mongodbTwinCli(process.env.VOLTER_WORLD_CONFIG);
|
|
102
104
|
return [twinRunner(cli), cli, 'serve', '--port', String(service.hostPort), '--root', serviceData];
|
|
103
105
|
}
|
|
104
|
-
return ['node',
|
|
106
|
+
return ['node', hostScript(), '--port', String(service.hostPort), '--data', serviceData];
|
|
105
107
|
}
|
|
106
108
|
|
|
107
109
|
function alive(pid: number): boolean {
|
|
@@ -160,6 +162,7 @@ export async function pgliteUp(services: InfraService[], dataDir: string): Promi
|
|
|
160
162
|
// a real fd, not a pipe: the host must outlive this process untethered
|
|
161
163
|
const logFd = openSync(log, 'a');
|
|
162
164
|
const child = spawn(command!, args, {
|
|
165
|
+
windowsHide: true,
|
|
163
166
|
detached: true,
|
|
164
167
|
stdio: ['ignore', logFd, logFd],
|
|
165
168
|
});
|
package/src/prerequisites.ts
CHANGED
|
@@ -15,7 +15,7 @@ type RunResult = { status: number | null; stdout: string; stderr: string };
|
|
|
15
15
|
type Runner = (cmd: string, args: string[]) => RunResult;
|
|
16
16
|
|
|
17
17
|
const realRunner: Runner = (cmd, args) => {
|
|
18
|
-
const result = spawnSync(cmd, args, { encoding: 'utf8' });
|
|
18
|
+
const result = spawnSync(cmd, args, { windowsHide: true, encoding: 'utf8' });
|
|
19
19
|
return {
|
|
20
20
|
status: result.status,
|
|
21
21
|
stdout: result.stdout || '',
|
package/src/process-groups.ts
CHANGED
|
@@ -13,7 +13,7 @@ export function survivingOwnedGroups(pids: number[]): number[] {
|
|
|
13
13
|
}
|
|
14
14
|
});
|
|
15
15
|
if (!candidates.length || process.platform === 'win32') return candidates;
|
|
16
|
-
const result = spawnSync('ps', ['-axo', 'pid=,pgid=,stat='], {
|
|
16
|
+
const result = spawnSync('ps', ['-axo', 'pid=,pgid=,stat='], { windowsHide: true,
|
|
17
17
|
encoding: 'utf8', timeout: 1000, killSignal: 'SIGKILL', maxBuffer: 4 * 1024 * 1024,
|
|
18
18
|
});
|
|
19
19
|
if (result.status !== 0 || result.error) return candidates;
|
package/src/redirect-proxy.ts
CHANGED
|
@@ -192,12 +192,12 @@ export function opensslAvailable(): boolean {
|
|
|
192
192
|
// process start rather than a same-process `process.env.PATH` mutation made mid-test. Never set
|
|
193
193
|
// this env var in real usage.
|
|
194
194
|
if (process.env.VOLTER_TEST_NO_OPENSSL === '1') return false;
|
|
195
|
-
const r = spawnSync('openssl', ['version'], { stdio: 'ignore' });
|
|
195
|
+
const r = spawnSync('openssl', ['version'], { windowsHide: true, stdio: 'ignore' });
|
|
196
196
|
return r.status === 0;
|
|
197
197
|
}
|
|
198
198
|
|
|
199
199
|
function runOpenssl(args: string[]): string {
|
|
200
|
-
const r = spawnSync('openssl', args, { encoding: 'utf8' });
|
|
200
|
+
const r = spawnSync('openssl', args, { windowsHide: true, encoding: 'utf8' });
|
|
201
201
|
if (r.status !== 0) throw new Error(`openssl ${args[0]} failed: ${(r.stderr || r.stdout || '').trim()}`);
|
|
202
202
|
return r.stdout;
|
|
203
203
|
}
|
package/src/root.ts
CHANGED
|
@@ -16,6 +16,7 @@ import {
|
|
|
16
16
|
import { loadWorldConfig, writeWorldConfig } from './configs.ts';
|
|
17
17
|
import { changesetReadiness, collectObservations, type Observation, foldObservations, loadChecks, openRootCredential, performEntries, rootCustody, protocolStanding, userKek, type DeployReport, type ObserveReport } from '@volter/world-core';
|
|
18
18
|
import { findWorldChangeset, listWorldChangesets } from './changeset.ts';
|
|
19
|
+
import { importModule } from './import-module.ts';
|
|
19
20
|
import { statusWorld } from './runtime.ts';
|
|
20
21
|
import { findInstalledPackage, twinPackageName } from './catalog.ts';
|
|
21
22
|
import type { WorldConfig } from './schema.ts';
|
|
@@ -120,7 +121,7 @@ async function packModule(vendor: string, from: string): Promise<Record<string,
|
|
|
120
121
|
const registered = getPack(vendor);
|
|
121
122
|
if (registered) return { pack: registered };
|
|
122
123
|
const entry = packEntryPath(vendor, from);
|
|
123
|
-
return entry ?
|
|
124
|
+
return entry ? await importModule(entry) : null;
|
|
124
125
|
}
|
|
125
126
|
/** The pack's module itself, installed beside the World or in this checkout: its every export (a
|
|
126
127
|
* mirror's shell and client), where the registry holds only the descriptor. */
|
|
@@ -137,8 +138,32 @@ export async function packMirrorExports(vendor: string, from: string): Promise<R
|
|
|
137
138
|
if (!entry) return null;
|
|
138
139
|
const dir = dirname(entry);
|
|
139
140
|
const file = existsSync(dir) ? readdirSync(dir).find((f) => /-mirror-ui\.(ts|js)$/.test(f) && !f.includes('.test.')) : undefined;
|
|
140
|
-
return (
|
|
141
|
+
return importModule(file ? join(dir, file) : entry);
|
|
141
142
|
}
|
|
143
|
+
/** A pack's mirror as the pack builds it (`<vendor>MirrorHtml`, `build<Vendor>MirrorClient`,
|
|
144
|
+
* `<vendor>MirrorStyles`), found the way a World finds the pack; null when the pack has none. The
|
|
145
|
+
* client builds at serve under Bun and is served prebuilt under Node, by the pack's own builder. */
|
|
146
|
+
export type PackMirror = { html: () => string; client: () => Promise<string>; styles?: () => Promise<string>; /** the account and a credential its screens sign in with (`<vendor>MirrorSignIn`): the account found in the twin's
|
|
147
|
+
* state at `root`, the credential minted for it through the twin's own doors (`twin`), never a stored one read back */
|
|
148
|
+
signIn?: (as: string, ctx: MirrorSignInContext) => Promise<{ account: string; token: string } | null> | { account: string; token: string } | null };
|
|
149
|
+
/** What a pack's `<vendor>MirrorSignIn` is given: the twin's state root (to find the account) and the twin's own doors
|
|
150
|
+
* (to mint a credential for it, as the World's seed does through `/_twin/…`). */
|
|
151
|
+
export type MirrorSignInContext = { root: string; twin: (path: string, init?: RequestInit) => Promise<Response> };
|
|
152
|
+
export async function packMirror(vendor: string, from: string): Promise<PackMirror | null> {
|
|
153
|
+
const installed = findInstalledPackage(from, twinPackageName(vendor));
|
|
154
|
+
const dir = installed ?? resolve(import.meta.dir, '..', '..', 'twin', vendor);
|
|
155
|
+
// the pack's mirror module, as the hosted build reads it (its index need not re-export every builder)
|
|
156
|
+
const src = join(dir, 'src');
|
|
157
|
+
const file = existsSync(src) ? readdirSync(src).find((f) => f.endsWith('-mirror-ui.ts')) : undefined;
|
|
158
|
+
const entry = file ? join(src, file) : installed ? packEntry(installed) : join(src, 'index.ts');
|
|
159
|
+
if (!existsSync(entry)) return null;
|
|
160
|
+
const mod = await importModule(entry);
|
|
161
|
+
const pick = (re: RegExp): ((...a: unknown[]) => unknown) | undefined => { const k = Object.keys(mod).find((key) => re.test(key) && typeof mod[key] === 'function'); return k ? mod[k] as (...a: unknown[]) => unknown : undefined; };
|
|
162
|
+
const html = pick(/^\w+MirrorHtml$/); const client = pick(/^build\w+MirrorClient$/); const styles = pick(/^\w+MirrorStyles$/); const signIn = pick(/^\w+MirrorSignIn$/);
|
|
163
|
+
if (!html || !client) return null;
|
|
164
|
+
return { html: html as () => string, client: client as () => Promise<string>, ...(styles ? { styles: styles as () => Promise<string> } : {}), ...(signIn ? { signIn: signIn as PackMirror['signIn'] } : {}) };
|
|
165
|
+
}
|
|
166
|
+
|
|
142
167
|
/** The real state system's adapters for a vendor: registered with the pack (protocol 2), else the
|
|
143
168
|
* pack's anchored exports (protocol 1: `perform<Name>Action`, `sync<Name>FromRemote`). */
|
|
144
169
|
export type IngestAdapter = (request: Request, ctx: { root?: string; secret?: string }) => Response | Promise<Response>;
|
package/src/run-task.ts
CHANGED
|
@@ -13,7 +13,7 @@ export type RunTaskReply = { result: RunTaskResult } | { error: string };
|
|
|
13
13
|
* The task process is outside the caller's POSIX process group and owns boot through teardown. */
|
|
14
14
|
export function superviseWorldRun(configId: string, command: string[], options: RunWorldOptions): Promise<RunTaskResult> {
|
|
15
15
|
return new Promise((done, reject) => {
|
|
16
|
-
const child = spawn(process.execPath, [siblingScript(import.meta.url, 'run-task-worker')], {
|
|
16
|
+
const child = spawn(process.execPath, [siblingScript(import.meta.url, 'run-task-worker')], { windowsHide: true,
|
|
17
17
|
cwd: process.cwd(), env: process.env, detached: process.platform !== 'win32',
|
|
18
18
|
stdio: ['inherit', 'inherit', 'inherit', 'ipc'], serialization: 'json',
|
|
19
19
|
});
|
package/src/runtime.ts
CHANGED
|
@@ -63,7 +63,7 @@ function spawnRecorded(serviceId: string, command: string, args: string[], cwd:
|
|
|
63
63
|
const recorderEnv: NodeJS.ProcessEnv = { ...env, VOLTER_WORLD_SERVICE_ID: serviceId, VOLTER_WORLD_SERVICE_LOG: log,
|
|
64
64
|
VOLTER_WORLD_SERVICE_LOG_DIR: logDir, VOLTER_WORLD_EVENTS: worldEventsPath(dirname(log)) };
|
|
65
65
|
if (recorderEnv.NODE_OPTIONS !== undefined) { recorderEnv.VOLTER_WORLD_SERVICE_NODE_OPTIONS = recorderEnv.NODE_OPTIONS; delete recorderEnv.NODE_OPTIONS; }
|
|
66
|
-
const child = spawn(process.execPath, [siblingScript(import.meta.url, 'service-recorder'), command, ...args], {
|
|
66
|
+
const child = spawn(process.execPath, [siblingScript(import.meta.url, 'service-recorder'), command, ...args], { windowsHide: true,
|
|
67
67
|
cwd,
|
|
68
68
|
env: recorderEnv,
|
|
69
69
|
detached: true,
|
|
@@ -662,7 +662,7 @@ function processStarts(pids: number[]): Map<number, string> {
|
|
|
662
662
|
}
|
|
663
663
|
return starts;
|
|
664
664
|
}
|
|
665
|
-
const result = spawnSync('ps', ['-o', 'pid=,lstart=', '-p', pids.join(',')], { encoding: 'utf8', timeout: 1000, killSignal: 'SIGKILL', env: { ...process.env, LC_ALL: 'C', LANG: 'C', TZ: 'UTC' } });
|
|
665
|
+
const result = spawnSync('ps', ['-o', 'pid=,lstart=', '-p', pids.join(',')], { windowsHide: true, encoding: 'utf8', timeout: 1000, killSignal: 'SIGKILL', env: { ...process.env, LC_ALL: 'C', LANG: 'C', TZ: 'UTC' } });
|
|
666
666
|
for (const line of (result.stdout ?? '').split('\n')) {
|
|
667
667
|
const match = /^\s*(\d+)\s+(.+?)\s*$/.exec(line);
|
|
668
668
|
if (match) starts.set(Number(match[1]), match[2]!);
|
|
@@ -802,7 +802,7 @@ async function runExternalCommand(
|
|
|
802
802
|
if (!bin) throw new Error(`External service "${serviceId}": ${phase} command is empty`);
|
|
803
803
|
if (!commandExists(bin)) throw new Error(`External service "${serviceId}": \`${bin}\` not found on PATH (the ${phase} command's tool is not installed)`);
|
|
804
804
|
abort?.throwIfAborted();
|
|
805
|
-
const child = spawn(bin, args, { cwd, env, detached: commandProcessGroup, stdio: ['ignore', 'pipe', 'pipe'] });
|
|
805
|
+
const child = spawn(bin, args, { windowsHide: true, cwd, env, detached: commandProcessGroup, stdio: ['ignore', 'pipe', 'pipe'] });
|
|
806
806
|
const lifetime = commandLifetime(child, abort, true);
|
|
807
807
|
let stdout = ''; let stderr = ''; let failure: Error | undefined;
|
|
808
808
|
const collect = (chunk: Buffer, out: boolean) => {
|
|
@@ -843,7 +843,7 @@ async function runExternalUp(
|
|
|
843
843
|
const chunks: string[] = [];
|
|
844
844
|
try {
|
|
845
845
|
abort?.throwIfAborted();
|
|
846
|
-
const child = spawn(bin, args, { cwd, env, detached: commandProcessGroup, stdio: ['ignore', 'pipe', 'pipe'] });
|
|
846
|
+
const child = spawn(bin, args, { windowsHide: true, cwd, env, detached: commandProcessGroup, stdio: ['ignore', 'pipe', 'pipe'] });
|
|
847
847
|
const lifetime = commandLifetime(child, abort, true);
|
|
848
848
|
let failure: unknown;
|
|
849
849
|
child.on('error', error => { failure = error; });
|
|
@@ -2208,11 +2208,11 @@ export function ownCommand(command: string[]): string[] {
|
|
|
2208
2208
|
}
|
|
2209
2209
|
|
|
2210
2210
|
function onPath(command: string): boolean {
|
|
2211
|
-
return spawnSync('which', [command], { stdio: 'ignore', env: process.env }).status === 0;
|
|
2211
|
+
return spawnSync('which', [command], { windowsHide: true, stdio: 'ignore', env: process.env }).status === 0;
|
|
2212
2212
|
}
|
|
2213
2213
|
|
|
2214
2214
|
function resolveWithPublicDns(hostname: string): string | undefined {
|
|
2215
|
-
const result = spawnSync('dig', ['+short', '@1.1.1.1', hostname, 'A'], {
|
|
2215
|
+
const result = spawnSync('dig', ['+short', '@1.1.1.1', hostname, 'A'], { windowsHide: true,
|
|
2216
2216
|
encoding: 'utf8',
|
|
2217
2217
|
});
|
|
2218
2218
|
if (result.status !== 0) return undefined;
|
|
@@ -2252,7 +2252,7 @@ async function verifyPublicUrl(
|
|
|
2252
2252
|
|| (isIP(normalizedHostname) === 4 && normalizedHostname.split('.')[0] === '127');
|
|
2253
2253
|
if (local) {
|
|
2254
2254
|
const target = publicHealthUrl(publicUrl, path);
|
|
2255
|
-
const result = spawnSync('curl', ['--noproxy', '*', '-sS', '-o', '-', '-w', '\n%{http_code}', '--max-time', String(Math.max(1, Math.ceil(timeoutMs / 1000))), target], { encoding: 'utf8' });
|
|
2255
|
+
const result = spawnSync('curl', ['--noproxy', '*', '-sS', '-o', '-', '-w', '\n%{http_code}', '--max-time', String(Math.max(1, Math.ceil(timeoutMs / 1000))), target], { windowsHide: true, encoding: 'utf8' });
|
|
2256
2256
|
const separator = result.stdout.lastIndexOf('\n');
|
|
2257
2257
|
const body = separator >= 0 ? result.stdout.slice(0, separator).trim() : result.stdout.trim();
|
|
2258
2258
|
const status = Number(separator >= 0 ? result.stdout.slice(separator + 1).trim() : '0');
|
|
@@ -2286,7 +2286,7 @@ async function verifyPublicUrl(
|
|
|
2286
2286
|
`${hostname}:443:${resolvedIp}`,
|
|
2287
2287
|
target,
|
|
2288
2288
|
],
|
|
2289
|
-
{ encoding: 'utf8' },
|
|
2289
|
+
{ windowsHide: true, encoding: 'utf8' },
|
|
2290
2290
|
);
|
|
2291
2291
|
if (result.status === 0) {
|
|
2292
2292
|
const output = result.stdout;
|
|
@@ -2348,7 +2348,7 @@ async function shareWorldReserved(name: string, options: ShareWorldOptions): Pro
|
|
|
2348
2348
|
// Each spawned tunnel owns one log generation. Truncating before spawn prevents a repeated
|
|
2349
2349
|
// share from accepting the previous process's URL handshake as the new process's endpoint.
|
|
2350
2350
|
const out = openPrivateLog(log, 'w');
|
|
2351
|
-
const child = spawn(tunnel.command, tunnel.args, {
|
|
2351
|
+
const child = spawn(tunnel.command, tunnel.args, { windowsHide: true,
|
|
2352
2352
|
cwd: root,
|
|
2353
2353
|
env: process.env,
|
|
2354
2354
|
detached: true,
|
|
@@ -2523,7 +2523,7 @@ async function probeExternalOnce(
|
|
|
2523
2523
|
return { ok: false, message: `readiness command \`${ready.command}\` not found on PATH` };
|
|
2524
2524
|
}
|
|
2525
2525
|
const [probeBin, ...probeArgs] = ownCommand([ready.command, ...(ready.args ?? [])]);
|
|
2526
|
-
const result = spawnSync(probeBin!, probeArgs, {
|
|
2526
|
+
const result = spawnSync(probeBin!, probeArgs, { windowsHide: true,
|
|
2527
2527
|
cwd, encoding: 'utf8', env, timeout: DOCTOR_EXTERNAL_PROBE_TIMEOUT_MS, maxBuffer: 64 * 1024 * 1024,
|
|
2528
2528
|
});
|
|
2529
2529
|
if (result.status === 0) return { ok: true, message: 'probe ok (command)' };
|
|
@@ -2878,7 +2878,7 @@ export function runWithWorldEnv(name: string, command: string[], root = process.
|
|
|
2878
2878
|
if (command.length === 0) throw new Error('Missing command after --');
|
|
2879
2879
|
const attached = worldAttachedCommandEnv(name, root, options.verbose);
|
|
2880
2880
|
return new Promise((resolveExit) => {
|
|
2881
|
-
const child = spawn(command[0]!, command.slice(1), {
|
|
2881
|
+
const child = spawn(command[0]!, command.slice(1), { windowsHide: true,
|
|
2882
2882
|
cwd: resolve(options.cwd ?? root),
|
|
2883
2883
|
env: attached.env,
|
|
2884
2884
|
stdio: 'inherit',
|
|
@@ -2951,7 +2951,7 @@ export async function attachWorld(name: string, command: string[], root = proces
|
|
|
2951
2951
|
let retired = true;
|
|
2952
2952
|
try {
|
|
2953
2953
|
return await new Promise<number>((done, reject) => {
|
|
2954
|
-
const child = spawn(command[0]!, command.slice(1), { cwd: resolve(options.cwd ?? root), env: attached.env, stdio: 'inherit', detached: commandProcessGroup });
|
|
2954
|
+
const child = spawn(command[0]!, command.slice(1), { windowsHide: true, cwd: resolve(options.cwd ?? root), env: attached.env, stdio: 'inherit', detached: commandProcessGroup });
|
|
2955
2955
|
const lifetime = commandLifetime(child);
|
|
2956
2956
|
let failure: unknown;
|
|
2957
2957
|
record.consumerPid = child.pid;
|
|
@@ -3032,7 +3032,7 @@ async function runWithWorldEnvLogged(name: string, command: string[], root: stri
|
|
|
3032
3032
|
let spawnError: string | undefined;
|
|
3033
3033
|
let child;
|
|
3034
3034
|
try {
|
|
3035
|
-
child = spawn(command[0]!, command.slice(1), {
|
|
3035
|
+
child = spawn(command[0]!, command.slice(1), { windowsHide: true,
|
|
3036
3036
|
cwd: resolve(options.cwd ?? root),
|
|
3037
3037
|
env: attached.env,
|
|
3038
3038
|
stdio: ['inherit', 'pipe', 'pipe'],
|
|
@@ -3114,7 +3114,7 @@ export async function shellWorld(name: string, root = process.cwd()): Promise<nu
|
|
|
3114
3114
|
process.stderr.write(`world '${name}' active in a subshell — vendor calls hit the twins (env-only; install openssl for ambient https redirect). type 'exit' to leave.\n`);
|
|
3115
3115
|
}
|
|
3116
3116
|
try {
|
|
3117
|
-
const result = spawnSync(shell, ['-i'], { cwd, env, stdio: 'inherit' });
|
|
3117
|
+
const result = spawnSync(shell, ['-i'], { windowsHide: true, cwd, env, stdio: 'inherit' });
|
|
3118
3118
|
return result.status ?? 0;
|
|
3119
3119
|
} finally {
|
|
3120
3120
|
if (proxy) {
|
|
@@ -3207,7 +3207,7 @@ function startProxyDaemonOwned(name: string, root: string, caCertPath: string, e
|
|
|
3207
3207
|
mkdirSync(dirname(proxyLogPath(root, name)), { recursive: true, mode: 0o700 });
|
|
3208
3208
|
const proxyOut = openPrivateLog(proxyLogPath(root, name), 'a');
|
|
3209
3209
|
try {
|
|
3210
|
-
child = spawn(process.execPath, [proxyDaemonEntry(), name, '--root', root, ...(envFile ? [`--env-file=${envFile}`] : [])], {
|
|
3210
|
+
child = spawn(process.execPath, [proxyDaemonEntry(), name, '--root', root, ...(envFile ? [`--env-file=${envFile}`] : [])], { windowsHide: true,
|
|
3211
3211
|
cwd: root, env: process.env, detached: true, stdio: ['ignore', proxyOut, proxyOut],
|
|
3212
3212
|
});
|
|
3213
3213
|
} finally { closeSync(proxyOut); }
|
package/src/schema.ts
CHANGED
|
@@ -173,6 +173,9 @@ export type WorldServiceConfig = {
|
|
|
173
173
|
* to loopback services directly while presenting a disguised world to the app/user.
|
|
174
174
|
*/
|
|
175
175
|
controlPlane?: boolean;
|
|
176
|
+
/** The account a vendor's own screens (its mirror) open signed in as: its handle, username or email, which the pack
|
|
177
|
+
* resolves against the accounts this World holds (`<vendor>MirrorSignIn`). Nothing secret is written here. */
|
|
178
|
+
signIn?: { as: string };
|
|
176
179
|
injectEnv?: string;
|
|
177
180
|
/**
|
|
178
181
|
* Additional env vars exported into the generated world env after this service starts.
|
|
@@ -704,7 +707,7 @@ export function assertWorldSelection(value: unknown, path: string): asserts valu
|
|
|
704
707
|
|
|
705
708
|
function serviceV2ToLegacy(value: unknown, path: string): WorldServiceConfig {
|
|
706
709
|
if (!record(value)) throw new Error(`World service must be an object in ${path}`);
|
|
707
|
-
assertKeys(value, ['id', 'type', 'description', 'source', 'execution', 'endpoint', 'bindings', 'root'], `Service ${String(value.id ?? '')}`, path, true);
|
|
710
|
+
assertKeys(value, ['id', 'type', 'description', 'source', 'execution', 'endpoint', 'bindings', 'root', 'signIn'], `Service ${String(value.id ?? '')}`, path, true);
|
|
708
711
|
const source = value.source === undefined ? {} : value.source;
|
|
709
712
|
const execution = value.execution === undefined ? {} : value.execution;
|
|
710
713
|
const endpoint = value.endpoint === undefined ? {} : value.endpoint;
|
|
@@ -721,6 +724,11 @@ function serviceV2ToLegacy(value: unknown, path: string): WorldServiceConfig {
|
|
|
721
724
|
assertKeys(environment, ['values'], 'service.execution.environment', path);
|
|
722
725
|
if (typeof value.id !== 'string' || value.id === '') throw new Error(`World service must define string id in ${path}`);
|
|
723
726
|
if (value.description !== undefined && typeof value.description !== 'string') throw new Error(`Service "${value.id}" description must be a string in ${path}`);
|
|
727
|
+
if (value.signIn !== undefined) {
|
|
728
|
+
if (!record(value.signIn)) throw new Error(`Service "${value.id}" signIn must be an object in ${path}`);
|
|
729
|
+
assertKeys(value.signIn, ['as'], `Service "${value.id}" signIn`, path);
|
|
730
|
+
if (typeof value.signIn.as !== 'string' || value.signIn.as.trim() === '') throw new Error(`Service "${value.id}" signIn.as must name an account (a handle, username or email) in ${path}`);
|
|
731
|
+
}
|
|
724
732
|
if (value.description !== undefined && value['//'] !== undefined) throw new Error(`Service "${value.id}" must not set both description and // in ${path}`);
|
|
725
733
|
if (execution.cwd !== undefined && typeof execution.cwd !== 'string') throw new Error(`Service "${value.id}" execution.cwd must be a string in ${path}`);
|
|
726
734
|
if (execution.controlPlane !== undefined && typeof execution.controlPlane !== 'boolean') throw new Error(`Service "${value.id}" execution.controlPlane must be a boolean in ${path}`);
|
|
@@ -770,6 +778,7 @@ function serviceV2ToLegacy(value: unknown, path: string): WorldServiceConfig {
|
|
|
770
778
|
...(bindings.injectEnvTemplates === undefined ? {} : { injectEnvTemplates: bindings.injectEnvTemplates as Record<string, string> }),
|
|
771
779
|
...(bindings.cliRedirect === undefined ? {} : { cliRedirect: bindings.cliRedirect as Record<string, string> }),
|
|
772
780
|
...(value.root === undefined ? {} : { root: value.root as WorldServiceConfig['root'] }),
|
|
781
|
+
...(value.signIn === undefined ? {} : { signIn: { as: (value.signIn as { as: string }).as } }),
|
|
773
782
|
...(external === undefined ? {} : { external }),
|
|
774
783
|
};
|
|
775
784
|
if (type !== 'external' && bindings.discover !== undefined) throw new Error(`Service "${legacy.id}" bindings.discover is only valid for external services in ${path}`);
|
|
@@ -889,6 +898,7 @@ function serviceToV2(service: WorldServiceConfig): Record<string, unknown> {
|
|
|
889
898
|
...(Object.keys(endpoint).length ? { endpoint } : {}),
|
|
890
899
|
...(Object.keys(bindings).length ? { bindings } : {}),
|
|
891
900
|
...(service.root === undefined ? {} : { root: service.root }),
|
|
901
|
+
...(service.signIn === undefined ? {} : { signIn: service.signIn }),
|
|
892
902
|
};
|
|
893
903
|
}
|
|
894
904
|
|