@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
|
@@ -59,7 +59,7 @@ const record = (entry, then) => {
|
|
|
59
59
|
process.stdout.write(`${logEnd().atLineStart ? '' : '\n'}${line}`, () => then?.());
|
|
60
60
|
};
|
|
61
61
|
const started = Date.now();
|
|
62
|
-
const child = spawn(command, args, { stdio: 'inherit', env });
|
|
62
|
+
const child = spawn(command, args, { windowsHide: true, stdio: 'inherit', env });
|
|
63
63
|
if (child.pid !== undefined)
|
|
64
64
|
record({ event: 'world.service.started', pid: child.pid, parentPid: process.pid });
|
|
65
65
|
/** The log's last `bytes` (from a line start) as the rotation `<log>.1`: however far a runaway writer overshot the cap,
|
|
@@ -16,7 +16,7 @@ export function storagePoolResolver(onFailure) {
|
|
|
16
16
|
let pool;
|
|
17
17
|
if (process.platform === 'darwin') {
|
|
18
18
|
stage = 'volume lookup';
|
|
19
|
-
const df = execFileSync('/bin/df', ['-P', root], { encoding: 'utf8', timeout: 3000, killSignal: 'SIGKILL', stdio: ['ignore', 'pipe', 'ignore'] });
|
|
19
|
+
const df = execFileSync('/bin/df', ['-P', root], { windowsHide: true, encoding: 'utf8', timeout: 3000, killSignal: 'SIGKILL', stdio: ['ignore', 'pipe', 'ignore'] });
|
|
20
20
|
const disk = df.trim().split('\n').at(-1)?.trim().split(/\s+/)[0];
|
|
21
21
|
if (disk && /^\/dev\/disk\d+(?:s\d+)*$/.test(disk)) {
|
|
22
22
|
stage = 'volume metadata';
|
|
@@ -51,7 +51,7 @@ export function storagePoolResolver(onFailure) {
|
|
|
51
51
|
};
|
|
52
52
|
}
|
|
53
53
|
function diskInfo(disk) {
|
|
54
|
-
return execFileSync('/usr/sbin/diskutil', ['info', '-plist', disk], {
|
|
54
|
+
return execFileSync('/usr/sbin/diskutil', ['info', '-plist', disk], { windowsHide: true,
|
|
55
55
|
encoding: 'utf8', timeout: 3000, killSignal: 'SIGKILL', stdio: ['ignore', 'pipe', 'ignore'],
|
|
56
56
|
});
|
|
57
57
|
}
|
package/dist/src/up-task.js
CHANGED
|
@@ -8,7 +8,7 @@ import { siblingScript } from "./sibling.js";
|
|
|
8
8
|
* is not the caller's to keep, so the boot completes and its outcome is written to the lifecycle log. */
|
|
9
9
|
export function superviseWorldUp(configId, options) {
|
|
10
10
|
return new Promise((done, reject) => {
|
|
11
|
-
const child = spawn(process.execPath, [siblingScript(import.meta.url, 'up-task-worker')], {
|
|
11
|
+
const child = spawn(process.execPath, [siblingScript(import.meta.url, 'up-task-worker')], { windowsHide: true,
|
|
12
12
|
cwd: process.cwd(), env: process.env, detached: process.platform !== 'win32',
|
|
13
13
|
stdio: ['ignore', 'inherit', 'inherit', 'ipc'], serialization: 'json',
|
|
14
14
|
});
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
export declare const LOCAL_ORIGIN_BASE = "localhost";
|
|
2
|
+
/** The origin a browser reaches `served` at on a local host listening on `port`, or null when its
|
|
3
|
+
* names make no label. */
|
|
4
|
+
export declare function localWorldOrigin(served: string, port: number, base?: string): string | null;
|
|
5
|
+
/** Which World a request's host names: undefined for a host that is not a World's origin (the host's
|
|
6
|
+
* own, path-addressed), null for a World-origin-shaped host naming no World served here. */
|
|
7
|
+
export declare function worldOfHost(hostname: string, served: Iterable<string>, base?: string): string | null | undefined;
|
|
8
|
+
/** Whether a path under the console's base is a door of a World served here: a World whose org shares the console's
|
|
9
|
+
* name (`console/<world>`) has its doors at `/-/console/<world>/…`, and they are the World's, never the console's. */
|
|
10
|
+
export declare function worldDoorUnderConsole(path: string, served: (name: string) => boolean, consoleBase: string): boolean;
|
|
11
|
+
/** Whether a path on a World's origin belongs to that World (its wire, its doors, its mirrors) or to
|
|
12
|
+
* what every origin carries (`/-/ping`, the console): a World's origin serves no other World. */
|
|
13
|
+
export declare function pathOfWorld(path: string, served: string, consoleBase: string): boolean;
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
// WORLD ORIGINS for a local host (docs/contributing/architecture.md, "Viewing a World"): each World a
|
|
2
|
+
// browser steps into is reached at an origin of its own, `http://<world>--<org>.localhost:<port>`, so no
|
|
3
|
+
// World's page shares an origin with another World's session. Browsers and curl resolve `*.localhost`
|
|
4
|
+
// to loopback. Callers that present a token keep path addressing on the host's own origin.
|
|
5
|
+
import { worldOriginLabel } from "./served-world.js";
|
|
6
|
+
export const LOCAL_ORIGIN_BASE = 'localhost';
|
|
7
|
+
/** The origin a browser reaches `served` at on a local host listening on `port`, or null when its
|
|
8
|
+
* names make no label. */
|
|
9
|
+
export function localWorldOrigin(served, port, base = LOCAL_ORIGIN_BASE) {
|
|
10
|
+
const label = worldOriginLabel(served);
|
|
11
|
+
return label ? `http://${label}.${base}:${port}` : null;
|
|
12
|
+
}
|
|
13
|
+
/** Which World a request's host names: undefined for a host that is not a World's origin (the host's
|
|
14
|
+
* own, path-addressed), null for a World-origin-shaped host naming no World served here. */
|
|
15
|
+
export function worldOfHost(hostname, served, base = LOCAL_ORIGIN_BASE) {
|
|
16
|
+
const suffix = `.${base}`;
|
|
17
|
+
if (!hostname.endsWith(suffix))
|
|
18
|
+
return undefined;
|
|
19
|
+
const label = hostname.slice(0, -suffix.length);
|
|
20
|
+
for (const name of served)
|
|
21
|
+
if (worldOriginLabel(name) === label)
|
|
22
|
+
return name;
|
|
23
|
+
return null;
|
|
24
|
+
}
|
|
25
|
+
/** Whether a path under the console's base is a door of a World served here: a World whose org shares the console's
|
|
26
|
+
* name (`console/<world>`) has its doors at `/-/console/<world>/…`, and they are the World's, never the console's. */
|
|
27
|
+
export function worldDoorUnderConsole(path, served, consoleBase) {
|
|
28
|
+
if (path !== consoleBase && !path.startsWith(`${consoleBase}/`))
|
|
29
|
+
return false;
|
|
30
|
+
const m = /^\/-\/([^/]+)\/([^/]+)/.exec(path);
|
|
31
|
+
return Boolean(m && served(`${m[1]}/${m[2]}`));
|
|
32
|
+
}
|
|
33
|
+
/** Whether a path on a World's origin belongs to that World (its wire, its doors, its mirrors) or to
|
|
34
|
+
* what every origin carries (`/-/ping`, the console): a World's origin serves no other World. */
|
|
35
|
+
export function pathOfWorld(path, served, consoleBase) {
|
|
36
|
+
return path === '/-/ping' || path === consoleBase || path.startsWith(`${consoleBase}/`) || path.startsWith(`/${served}/`) || path.startsWith(`/-/${served}/`) || path === `/${served}` || path === `/-/${served}`;
|
|
37
|
+
}
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
/** A console as a host mounts it (`@volter/world-console`): answer a request under `base`, or null. */
|
|
2
|
+
export type ViewConsole = {
|
|
3
|
+
handle: (request: Request, base: string, opts?: {
|
|
4
|
+
worldOrigin?: boolean;
|
|
5
|
+
}) => Promise<Response | null>;
|
|
6
|
+
};
|
|
7
|
+
export declare const VIEW_CONSOLE_BASE = "/-/console";
|
|
8
|
+
export type WorldView = {
|
|
9
|
+
url: string;
|
|
10
|
+
base: string;
|
|
11
|
+
served: string;
|
|
12
|
+
token: string;
|
|
13
|
+
readToken: string;
|
|
14
|
+
console: string | null; /** the World's own browser origin, when it has one */
|
|
15
|
+
origin: string | null;
|
|
16
|
+
stop: () => Promise<void>;
|
|
17
|
+
};
|
|
18
|
+
export declare function serveWorldView(name: string, opts?: {
|
|
19
|
+
root?: string;
|
|
20
|
+
port?: number;
|
|
21
|
+
host?: string;
|
|
22
|
+
console?: ViewConsole;
|
|
23
|
+
announce?: (line: string) => void; /** each World a browser steps into at an origin of its own (on by default on loopback) */
|
|
24
|
+
origins?: boolean;
|
|
25
|
+
}): Promise<WorldView>;
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
// THE LOCAL VIEW: one World served for a person to step into — its doors, its mirrors, its branches
|
|
2
|
+
// ("as of" views) and the console, on one origin (docs/contributing/architecture.md, "Viewing a
|
|
3
|
+
// World"). `volter world view` is this; the runtime's `up` gains no step. A host that serves many
|
|
4
|
+
// Worlds (world-host) answers the same doors; this serves one, and the branches made of it.
|
|
5
|
+
import { existsSync, readdirSync } from 'node:fs';
|
|
6
|
+
import { loadWorldConfig } from "./configs.js";
|
|
7
|
+
import { join, resolve } from 'node:path';
|
|
8
|
+
import { serveHttp, stateDirName } from '@volter/world-core';
|
|
9
|
+
import { LocalBranches } from "./local-branches.js";
|
|
10
|
+
import { mountWorld } from "./served-world.js";
|
|
11
|
+
import { localWorldOrigin, pathOfWorld, worldDoorUnderConsole, worldOfHost } from "./world-origins.js";
|
|
12
|
+
export const VIEW_CONSOLE_BASE = '/-/console';
|
|
13
|
+
/** The branch Worlds already under `dir` (`<dir>/<org>/<world>/.volter/world.json`). */
|
|
14
|
+
function branchRoots(dir) {
|
|
15
|
+
if (!existsSync(dir))
|
|
16
|
+
return [];
|
|
17
|
+
const out = [];
|
|
18
|
+
for (const org of readdirSync(dir, { withFileTypes: true }).filter((e) => e.isDirectory())) {
|
|
19
|
+
for (const world of readdirSync(join(dir, org.name), { withFileTypes: true }).filter((e) => e.isDirectory())) {
|
|
20
|
+
const root = join(dir, org.name, world.name);
|
|
21
|
+
if (existsSync(join(root, stateDirName(), 'world.json')))
|
|
22
|
+
out.push(root);
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
return out.sort();
|
|
26
|
+
}
|
|
27
|
+
export async function serveWorldView(name, opts = {}) {
|
|
28
|
+
const root = resolve(opts.root ?? process.cwd());
|
|
29
|
+
const host = opts.host ?? '127.0.0.1';
|
|
30
|
+
const worlds = new Map();
|
|
31
|
+
let loopback = '';
|
|
32
|
+
let port = 0;
|
|
33
|
+
const loopbackBind = host === '127.0.0.1' || host === 'localhost' || host === '::1';
|
|
34
|
+
// each World's browser session on an origin of its own, where the view listens on loopback (*.localhost)
|
|
35
|
+
const origins = opts.origins ?? loopbackBind;
|
|
36
|
+
// bound to loopback with an origin per World, the view is for the person at this machine: its pages need no token
|
|
37
|
+
// (served-world.ts localSession). Without origins every World's pages share one, and none is handed a session.
|
|
38
|
+
const local = loopbackBind && origins;
|
|
39
|
+
// a non-loopback --host turns origins off by default: say so (the World and its branches then share one origin)
|
|
40
|
+
if (opts.origins === undefined && !origins)
|
|
41
|
+
opts.announce?.(`origins off (bound to ${host}): the World and its branches share one origin, where a browser session is only path-scoped; bind loopback for an origin per World`);
|
|
42
|
+
const browserOrigin = origins ? (served) => (port ? localWorldOrigin(served, port) : null) : undefined;
|
|
43
|
+
const mountAt = (at) => mountWorld(loadWorldConfig(join(at, stateDirName(), 'world.json'), at).config.id, { root: at, branches: (served) => branches.doorsFor(served), ...(browserOrigin ? { browserOrigin } : {}), ...(local ? { localTrust: true } : {}) });
|
|
44
|
+
const branches = new LocalBranches({ dir: join(root, stateDirName(), 'branches'), origin: () => loopback, worlds, mount: mountAt, ...(opts.announce ? { announce: opts.announce } : {}) });
|
|
45
|
+
const world = await mountWorld(name, { root, branches: (served) => branches.doorsFor(served), ...(browserOrigin ? { browserOrigin } : {}), ...(local ? { localTrust: true } : {}) });
|
|
46
|
+
worlds.set(world.served, world);
|
|
47
|
+
const server = await serveHttp({
|
|
48
|
+
// a pushed blob (a release video) is one PUT: bodies up to 1 GiB, as `serve` takes them
|
|
49
|
+
hostname: host, port: opts.port ?? 0, maxRequestBodySize: 1024 * 1024 * 1024,
|
|
50
|
+
// this front serves a World and its branches; each World's own twins journal their requests
|
|
51
|
+
twinRequestJournal: false,
|
|
52
|
+
async fetch(request) {
|
|
53
|
+
const at = new URL(request.url);
|
|
54
|
+
const path = at.pathname;
|
|
55
|
+
// a World's own origin serves that World (and the ping and the console) and no other
|
|
56
|
+
const own = origins ? worldOfHost(at.hostname, worlds.keys()) : undefined;
|
|
57
|
+
if (own === null)
|
|
58
|
+
return Response.json({ error: `no world at ${at.host}` }, { status: 404 });
|
|
59
|
+
// the bare address, typed into a browser, is the World's page: at its own origin when it has one
|
|
60
|
+
if (opts.console && path === '/' && request.method === 'GET' && (request.headers.get('accept') ?? '').includes('text/html')) {
|
|
61
|
+
const served = own ?? world.served;
|
|
62
|
+
const page = `${VIEW_CONSOLE_BASE}/${served}`;
|
|
63
|
+
return Response.redirect(own ? `${at.origin}${page}` : `${browserOrigin?.(served) ?? at.origin}${page}`, 302);
|
|
64
|
+
}
|
|
65
|
+
if (own && !pathOfWorld(path, own, VIEW_CONSOLE_BASE))
|
|
66
|
+
return Response.json({ error: `this origin serves ${own} only` }, { status: 404 });
|
|
67
|
+
if (path === '/-/ping')
|
|
68
|
+
return Response.json({ ok: true, worlds: own ? [own] : [...worlds.keys()], ...(opts.console ? { console: `${VIEW_CONSOLE_BASE}/` } : {}) });
|
|
69
|
+
if (opts.console && (path === VIEW_CONSOLE_BASE || path.startsWith(`${VIEW_CONSOLE_BASE}/`)) && !worldDoorUnderConsole(path, (n) => worlds.has(n), VIEW_CONSOLE_BASE)) {
|
|
70
|
+
// on a World's own origin the console keeps no token script can read (its session is the credential)
|
|
71
|
+
const answered = await opts.console.handle(request, VIEW_CONSOLE_BASE, own ? { worldOrigin: true } : undefined);
|
|
72
|
+
if (answered)
|
|
73
|
+
return answered;
|
|
74
|
+
}
|
|
75
|
+
const m = /^\/(?:-\/)?([^/]+)\/([^/]+)/.exec(path);
|
|
76
|
+
const target = m ? worlds.get(`${m[1]}/${m[2]}`) : undefined;
|
|
77
|
+
if (!target)
|
|
78
|
+
return Response.json({ error: `no world at ${path}: this view serves ${[...worlds.keys()].join(', ')}` }, { status: 404 });
|
|
79
|
+
return target.handle(request);
|
|
80
|
+
},
|
|
81
|
+
});
|
|
82
|
+
port = server.port ?? 0;
|
|
83
|
+
const url = `http://${host}:${server.port}`;
|
|
84
|
+
loopback = `http://${host === '0.0.0.0' || host === '::' ? '127.0.0.1' : host}:${server.port}`;
|
|
85
|
+
// a branch outlives the view that made it until its time runs out: stopping stops its compute only
|
|
86
|
+
const stop = async () => {
|
|
87
|
+
branches.stop();
|
|
88
|
+
await server.stop(true);
|
|
89
|
+
for (const w of [...worlds.values()].reverse())
|
|
90
|
+
await w.stop();
|
|
91
|
+
};
|
|
92
|
+
try {
|
|
93
|
+
await world.boot(url);
|
|
94
|
+
// branches an earlier view made: mounted again (the sweep then removes those whose time ran out)
|
|
95
|
+
for (const at of branchRoots(join(root, stateDirName(), 'branches'))) {
|
|
96
|
+
const b = await mountAt(at);
|
|
97
|
+
await b.boot(url);
|
|
98
|
+
worlds.set(b.served, b);
|
|
99
|
+
}
|
|
100
|
+
await branches.resume();
|
|
101
|
+
}
|
|
102
|
+
catch (error) {
|
|
103
|
+
await stop().catch(() => undefined);
|
|
104
|
+
throw error;
|
|
105
|
+
}
|
|
106
|
+
// the console opens on the World's own origin, where its session lives
|
|
107
|
+
const consoleUrl = opts.console ? `${browserOrigin?.(world.served) ?? url}${VIEW_CONSOLE_BASE}/${world.served}` : null;
|
|
108
|
+
opts.announce?.(`viewing ${world.served} ${url}/${world.served}${consoleUrl ? `\nconsole ${consoleUrl}` : ''}`);
|
|
109
|
+
return { url, base: `${url}/${world.served}`, served: world.served, get token() { return world.token; }, get readToken() { return world.readToken; }, console: consoleUrl, origin: browserOrigin?.(world.served) ?? null, stop };
|
|
110
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@volter/world-runtime",
|
|
3
|
-
"version": "2.0.
|
|
3
|
+
"version": "2.0.3",
|
|
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",
|
|
@@ -67,13 +67,14 @@
|
|
|
67
67
|
"node": ">=22.3"
|
|
68
68
|
},
|
|
69
69
|
"peerDependencies": {
|
|
70
|
-
"@volter/world-core": "2.0.
|
|
71
|
-
"@volter/world-console": "2.0.
|
|
70
|
+
"@volter/world-core": "2.0.2",
|
|
71
|
+
"@volter/world-console": "2.0.3"
|
|
72
72
|
},
|
|
73
73
|
"dependencies": {
|
|
74
74
|
"@electric-sql/pglite": "0.5.8",
|
|
75
75
|
"@electric-sql/pglite-pgvector": "0.0.9",
|
|
76
|
-
"@volter/world-
|
|
76
|
+
"@volter/world-access": "2.0.1",
|
|
77
|
+
"@volter/world-core": "2.0.2",
|
|
77
78
|
"pg-gateway": "0.3.0-beta.4",
|
|
78
79
|
"smol-toml": "^1.8.0"
|
|
79
80
|
},
|
|
@@ -83,6 +84,6 @@
|
|
|
83
84
|
}
|
|
84
85
|
},
|
|
85
86
|
"optionalDependencies": {
|
|
86
|
-
"@volter/twin-mongodb": "0.1.
|
|
87
|
+
"@volter/twin-mongodb": "0.1.2"
|
|
87
88
|
}
|
|
88
89
|
}
|
package/src/app-url.ts
CHANGED
|
@@ -150,7 +150,7 @@ export function addAppHosts(name: string, hosts: string[], options: { root?: str
|
|
|
150
150
|
|
|
151
151
|
/** The listening TCP ports of a live pid, via lsof (macOS + Linux). */
|
|
152
152
|
function listeningTcpPorts(pid: number): number[] {
|
|
153
|
-
const result = spawnSync('lsof', ['-a', '-p', String(pid), '-iTCP', '-sTCP:LISTEN', '-P', '-n', '-Fn'], { encoding: 'utf8' });
|
|
153
|
+
const result = spawnSync('lsof', ['-a', '-p', String(pid), '-iTCP', '-sTCP:LISTEN', '-P', '-n', '-Fn'], { windowsHide: true, encoding: 'utf8' });
|
|
154
154
|
if (result.error) {
|
|
155
155
|
throw new Error(`volter-world app-url: \`lsof\` is unavailable (${result.error.message}) — pass the port, or --set the URL directly`);
|
|
156
156
|
}
|
package/src/catalog.ts
CHANGED
|
@@ -115,7 +115,7 @@ export function checkoutCatalog(root: string): Catalog {
|
|
|
115
115
|
packDir: (vendor) => (isPackDir(join(packsDir, vendor)) ? join(packsDir, vendor) : undefined),
|
|
116
116
|
version: (vendor) => packageVersion(join(packsDir, vendor)),
|
|
117
117
|
sha: () => {
|
|
118
|
-
const proc = spawnSync('git', ['-C', root, 'rev-parse', 'HEAD'], { encoding: 'utf8' });
|
|
118
|
+
const proc = spawnSync('git', ['-C', root, 'rev-parse', 'HEAD'], { windowsHide: true, encoding: 'utf8' });
|
|
119
119
|
const sha = proc.status === 0 ? proc.stdout.trim() : '';
|
|
120
120
|
return /^[0-9a-f]{40}$/.test(sha) ? sha : 'unknown';
|
|
121
121
|
},
|
package/src/cli.ts
CHANGED
|
@@ -860,7 +860,7 @@ async function main(): Promise<void> {
|
|
|
860
860
|
// VOLTER_WORLD names the World to the injector: a request addressed to its paths carries its key
|
|
861
861
|
const commandEnv: Record<string, string | undefined> = { ...process.env, ...env, ...bridged.env, VOLTER_WORLD: resolved.ref };
|
|
862
862
|
commandEnv.VOLTER_TWIN_INJECT_QUIET = head.includes('--verbose') ? '0' : commandEnv.VOLTER_TWIN_INJECT_QUIET ?? '1';
|
|
863
|
-
const child = spawn(command[0]!, command.slice(1), { env: commandEnv, stdio: 'inherit' });
|
|
863
|
+
const child = spawn(command[0]!, command.slice(1), { windowsHide: true, env: commandEnv, stdio: 'inherit' });
|
|
864
864
|
const code = await new Promise<number>((resolveExit) => { child.on('exit', (status) => resolveExit(status ?? 1)); child.on('error', () => resolveExit(127)); });
|
|
865
865
|
// a client that wrote and exited at once still has bytes on their way through a bridge
|
|
866
866
|
await drainStreams();
|
|
@@ -884,7 +884,7 @@ async function main(): Promise<void> {
|
|
|
884
884
|
const compose = splitDockerComposeArgs(command);
|
|
885
885
|
if (compose) {
|
|
886
886
|
// the consumer's own service names, from its own files, through its own docker
|
|
887
|
-
const listed = spawnSync(compose.head[0]!, [...compose.head.slice(1), ...compose.composeFlags, 'config', '--services'], { env, stdio: ['ignore', 'pipe', 'pipe'] });
|
|
887
|
+
const listed = spawnSync(compose.head[0]!, [...compose.head.slice(1), ...compose.composeFlags, 'config', '--services'], { windowsHide: true, env, stdio: ['ignore', 'pipe', 'pipe'] });
|
|
888
888
|
if (listed.status !== 0) throw new Error(`volter-world attach --via reflect: docker compose config --services failed:\n${listed.stderr.toString().trim()}`);
|
|
889
889
|
const services = listed.stdout.toString().split('\n').map((s) => s.trim()).filter(Boolean);
|
|
890
890
|
const overridePath = join(instanceDir(attachRoot, resolved.ref), 'reflect-compose.override.yml');
|
|
@@ -892,7 +892,7 @@ async function main(): Promise<void> {
|
|
|
892
892
|
toRun = dockerComposeWithOverride(command, overridePath);
|
|
893
893
|
process.stderr.write(`volter-world attach --via reflect: ${services.length} service(s) attached — dns ${manifest.resolverIp}:53, front ${manifest.targetIp}:443, CA ${ATTACHED_CA_PATH}\n`);
|
|
894
894
|
}
|
|
895
|
-
const result = spawnSync(toRun[0]!, toRun.slice(1), { env, stdio: 'inherit' });
|
|
895
|
+
const result = spawnSync(toRun[0]!, toRun.slice(1), { windowsHide: true, env, stdio: 'inherit' });
|
|
896
896
|
process.exit(result.status ?? 1);
|
|
897
897
|
}
|
|
898
898
|
if (via !== 'env') {
|
package/src/console-apart.ts
CHANGED
|
@@ -8,7 +8,7 @@
|
|
|
8
8
|
import { serveHttp, type HttpServer } from '@volter/world-core';
|
|
9
9
|
|
|
10
10
|
/** What a console offers a host: its shell and assets under `base`, told where the Worlds are served. */
|
|
11
|
-
export type ConsoleMount = { handle: (request: Request, base: string, opts?: { worlds?: string; names?: string[] }) => Promise<Response | null> };
|
|
11
|
+
export type ConsoleMount = { handle: (request: Request, base: string, opts?: { worlds?: string; names?: string[]; worldOrigin?: boolean }) => Promise<Response | null> };
|
|
12
12
|
export const CONSOLE_BASE = '/-/console';
|
|
13
13
|
|
|
14
14
|
/** Serve `mount` on its own listener; doors under `/-/` go to `upstream` (the Worlds' origin). */
|
|
@@ -57,6 +57,9 @@ export async function serveConsoleFor(target: string, opts: { hostname?: string;
|
|
|
57
57
|
throw error;
|
|
58
58
|
}
|
|
59
59
|
const hostname = opts.hostname ?? '127.0.0.1';
|
|
60
|
+
// the console's own origin, once it listens: a World that lets only its own origin frame its pages lets this
|
|
61
|
+
// console frame them too, as they are framed where it serves them itself
|
|
62
|
+
let consoleOrigin = '';
|
|
60
63
|
const worldsServer = await serveHttp({
|
|
61
64
|
hostname, port: opts.port !== undefined && opts.port !== 0 ? opts.port + 1 : 0, twinRequestJournal: false,
|
|
62
65
|
async fetch(request) {
|
|
@@ -71,11 +74,14 @@ export async function serveConsoleFor(target: string, opts: { hostname?: string;
|
|
|
71
74
|
// the host's own redirects (the session door's, a twin root's to its mirror) stay on this origin
|
|
72
75
|
const location = out.get('location');
|
|
73
76
|
if (location?.startsWith(origin)) out.set('location', `${url.origin}${location.slice(origin.length)}`);
|
|
77
|
+
const csp = out.get('content-security-policy');
|
|
78
|
+
if (csp && consoleOrigin) out.set('content-security-policy', csp.replace(/frame-ancestors ([^;]*)/, (_all, allowed: string) => (/'none'/.test(allowed) ? `frame-ancestors ${allowed}` : `frame-ancestors ${allowed} ${consoleOrigin}`)));
|
|
74
79
|
return new Response(answer.body, { status: answer.status, headers: out });
|
|
75
80
|
},
|
|
76
81
|
});
|
|
77
82
|
const worlds = `http://${hostname}:${worldsServer.port}`;
|
|
78
83
|
const consoleServer = await serveConsoleApart(mount, { upstream: origin, worlds, names, hostname, ...(opts.port !== undefined ? { port: opts.port } : {}) });
|
|
84
|
+
consoleOrigin = new URL(consoleServer.url).origin;
|
|
79
85
|
return { url: consoleServer.url, worlds, servers: [consoleServer.server, worldsServer] };
|
|
80
86
|
}
|
|
81
87
|
|
package/src/covers.ts
CHANGED
|
@@ -602,8 +602,10 @@ function usesOf(signals: Map<string, string[]>): Map<string, Array<{ usage: Worl
|
|
|
602
602
|
export function detectRepoVendors(repoPath: string): RepoVendorSignals {
|
|
603
603
|
const repo = resolve(repoPath);
|
|
604
604
|
// the platform's own packages — the twins an app installed, the command, the runtime — are
|
|
605
|
-
// never a vendor signal: an app that installed @volter/twin-slack did not gain a Slack dependency
|
|
606
|
-
|
|
605
|
+
// never a vendor signal: an app that installed @volter/twin-slack did not gain a Slack dependency.
|
|
606
|
+
// A Volter package a pack names as its vendor's SDK is one (`@volter/identity`, the Volter identity
|
|
607
|
+
// service's client, is the volteridentity twin's), as any vendor's SDK is.
|
|
608
|
+
const dependencies = projectDependencies(repo).filter((dep) => Object.hasOwn(SDK_TWINS, dep) || (!dep.startsWith('@volter/') && !dep.startsWith('pypi:volter')));
|
|
607
609
|
const envNames = projectEnvNames(repo);
|
|
608
610
|
const npmRegistries = projectNpmRegistries(repo);
|
|
609
611
|
|
package/src/host-worker.ts
CHANGED
|
@@ -4,13 +4,14 @@
|
|
|
4
4
|
// red) without disturbing sibling twins. Posts {type:'ready'} once listening.
|
|
5
5
|
import { parentPort, workerData } from 'node:worker_threads';
|
|
6
6
|
import type { ColocatedTwinSpec } from './host.ts';
|
|
7
|
+
import { importModule } from './import-module.ts';
|
|
7
8
|
|
|
8
9
|
const spec = workerData as ColocatedTwinSpec;
|
|
9
10
|
|
|
10
11
|
type TwinServer = { port: number; stop: () => void | Promise<void> };
|
|
11
12
|
type TwinServerFactory = (opts: { port?: number; root?: string; readOnly?: boolean }) => TwinServer | Promise<TwinServer>;
|
|
12
13
|
|
|
13
|
-
const mod =
|
|
14
|
+
const mod = await importModule(spec.module);
|
|
14
15
|
const factory = mod[spec.export] as TwinServerFactory | undefined;
|
|
15
16
|
if (typeof factory !== 'function') {
|
|
16
17
|
throw new Error(`Twin "${spec.id}": ${spec.module} has no factory export "${spec.export}"`);
|
package/src/host.ts
CHANGED
|
@@ -21,6 +21,7 @@
|
|
|
21
21
|
// world config that drives it) stays vendor-agnostic — exactly like `bin` spawning.
|
|
22
22
|
import { Worker } from 'node:worker_threads';
|
|
23
23
|
import { siblingScript } from './sibling.ts';
|
|
24
|
+
import { importModule } from './import-module.ts';
|
|
24
25
|
import { pathToFileURL } from 'node:url';
|
|
25
26
|
|
|
26
27
|
/** One twin to mount in the host. `module`/`export` name a `({port,root,readOnly}) => {port,stop}`
|
|
@@ -57,7 +58,7 @@ type TwinServer = { port: number; stop: () => void | Promise<void> };
|
|
|
57
58
|
type TwinServerFactory = (opts: { port?: number; root?: string; readOnly?: boolean; scenarioPath?: string }) => TwinServer | Promise<TwinServer>;
|
|
58
59
|
|
|
59
60
|
async function loadFactory(spec: ColocatedTwinSpec): Promise<TwinServerFactory> {
|
|
60
|
-
const mod =
|
|
61
|
+
const mod = await importModule(spec.module);
|
|
61
62
|
const factory = mod[spec.export];
|
|
62
63
|
if (typeof factory !== 'function') {
|
|
63
64
|
throw new Error(`Twin "${spec.id}": ${spec.module} has no factory export "${spec.export}"`);
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
// import() of what a World names a module by: a package name, a file URL, or a file path. Node's ESM loader takes an
|
|
2
|
+
// absolute path only as a file:// URL — on Windows `C:\…` reads as a URL with the scheme `c:` and is refused
|
|
3
|
+
// (ERR_UNSUPPORTED_ESM_URL_SCHEME); Bun takes either. A path becomes its file URL here; anything else is passed on.
|
|
4
|
+
import { isAbsolute } from 'node:path';
|
|
5
|
+
import { pathToFileURL } from 'node:url';
|
|
6
|
+
|
|
7
|
+
export function importModule<T = Record<string, unknown>>(specifier: string): Promise<T> {
|
|
8
|
+
return import(isAbsolute(specifier) ? pathToFileURL(specifier).href : specifier) as Promise<T>;
|
|
9
|
+
}
|
package/src/index.ts
CHANGED
|
@@ -163,8 +163,13 @@ export { branchWorld, checkoutWorld } from './branch.ts';
|
|
|
163
163
|
export type { BranchOptions } from './branch.ts';
|
|
164
164
|
export { deployWorld, refreshTwin, adaptersFor, credentialPath, credentialPayloadFrom, deployTwin, loadWorldChecks, materializeRoots, rootForControlRoot, sealTwinCredential, sealedCredentialInfo, setTwinRoot, userKekPath } from './root.ts';
|
|
165
165
|
export type { DeployTwinOutcome, MaterializedRoot } from './root.ts';
|
|
166
|
-
export { landReceipts, mountWorld, readServeRecord, refreshSchedule, servedName, serveWorld, TOKEN_HEADER, WorldDoors } from './served-world.ts';
|
|
167
|
-
export type { DoorHost, MountedWorld, ServeRecord, WorldLayout } from './served-world.ts';
|
|
166
|
+
export { landReceipts, mountWorld, READ_ONLY_HEADER, worldOriginLabel, readServeRecord, refreshSchedule, servedName, serveWorld, sessionsFile, keysFile, TOKEN_HEADER, WorldDoors } from './served-world.ts';
|
|
167
|
+
export type { BranchDoors, BranchRow, DoorHost, MountedWorld, ServeRecord, WorldLayout } from './served-world.ts';
|
|
168
|
+
export { LocalBranches } from './local-branches.ts';
|
|
169
|
+
export { serveWorldView, VIEW_CONSOLE_BASE } from './world-view.ts';
|
|
170
|
+
export { LOCAL_ORIGIN_BASE, localWorldOrigin, pathOfWorld, worldDoorUnderConsole, worldOfHost } from './world-origins.ts';
|
|
171
|
+
export type { ViewConsole, WorldView } from './world-view.ts';
|
|
172
|
+
export type { LocalBranchesOptions } from './local-branches.ts';
|
|
168
173
|
export type { ServedWorld } from './served-world.ts';
|
|
169
174
|
export { CONSOLE_BASE, consoleRedirect, serveConsoleApart, serveConsoleFor, type ConsoleMount } from './console-apart.ts';
|
|
170
175
|
|
package/src/infra-cli.ts
CHANGED
|
@@ -26,7 +26,7 @@ if (!existsSync(definition)) {
|
|
|
26
26
|
}
|
|
27
27
|
|
|
28
28
|
const base = ['compose', '-f', definition];
|
|
29
|
-
const run = (args: string[]) => spawnSync('docker', [...base, ...args], {
|
|
29
|
+
const run = (args: string[]) => spawnSync('docker', [...base, ...args], { windowsHide: true,
|
|
30
30
|
encoding: 'utf8',
|
|
31
31
|
env: { ...process.env, VOLTER_WORLD_DATA: worldData },
|
|
32
32
|
timeout: 120_000,
|
|
@@ -60,7 +60,7 @@ function selectBacking(): 'docker' | 'pglite' {
|
|
|
60
60
|
process.stderr.write(`managed infrastructure: VOLTER_WORLD_INFRA_BACKING must be docker or pglite (got ${JSON.stringify(forced)})\n`);
|
|
61
61
|
process.exit(2);
|
|
62
62
|
}
|
|
63
|
-
const probe = spawnSync('docker', ['info', '--format', '{{.ServerVersion}}'], { encoding: 'utf8', timeout: 10_000 });
|
|
63
|
+
const probe = spawnSync('docker', ['info', '--format', '{{.ServerVersion}}'], { windowsHide: true, encoding: 'utf8', timeout: 10_000 });
|
|
64
64
|
if (probe.status === 0) return 'docker';
|
|
65
65
|
// Absence of a container runtime (no binary, no daemon) is a capability
|
|
66
66
|
// difference: swap backings. A PRESENT runtime failing on resources stays
|
package/src/init.ts
CHANGED
|
@@ -831,6 +831,25 @@ export function planWorldInit(name: string, repoPath: string, options: InitOptio
|
|
|
831
831
|
const mintedEnv: string[] = [];
|
|
832
832
|
const infraSignals = new Map<string, string[]>();
|
|
833
833
|
const infraEntries: Array<{ name: string; source: string; kind: string }> = [];
|
|
834
|
+
// SMTP's host and port under the names this app declares: apps name them their own way (twenty's EMAIL_SMTP_HOST/
|
|
835
|
+
// EMAIL_SMTP_PORT beside nodemailer's SMTP_HOST), and a raw-TCP twin is reached only through the env the app reads,
|
|
836
|
+
// so every declared `…SMTP_HOST` / `…SMTP_PORT` points at the smtp twin (twenty lane: its mail went to the log). A World
|
|
837
|
+
// regenerated with --force keeps its saved smtp service as it was: remove it from the config to pick these names up
|
|
838
|
+
const smtp = services.find((service) => service.id === 'smtp' && service.type === 'twin');
|
|
839
|
+
if (smtp) {
|
|
840
|
+
const templates = { ...(smtp.injectEnvTemplates ?? {}) };
|
|
841
|
+
// not the app's outbound mail: a server it listens on (INBOUND_) or a local catcher it runs (MAILHOG_, MAILPIT_)
|
|
842
|
+
const notOutbound = /^(?:INBOUND|INCOMING|MAILHOG|MAILPIT|MAILCATCHER|MAILDEV)_/;
|
|
843
|
+
for (const { name } of repoEnvEntries(repo)) {
|
|
844
|
+
if (name in templates || notOutbound.test(name)) continue;
|
|
845
|
+
if (/^(?:[A-Z0-9]+_)*SMTP_HOST$/.test(name)) templates[name] = '${host}';
|
|
846
|
+
else if (/^(?:[A-Z0-9]+_)*SMTP_PORT$/.test(name)) templates[name] = '${port}';
|
|
847
|
+
// the twin speaks plain SMTP on its port: a secure/TLS flag beside an injected host would make the app open
|
|
848
|
+
// implicit TLS to it and fail, so the flag says plain
|
|
849
|
+
else if (/^(?:[A-Z0-9]+_)*SMTP_(?:SECURE|TLS|SSL)$/.test(name)) templates[name] = 'false';
|
|
850
|
+
}
|
|
851
|
+
smtp.injectEnvTemplates = templates;
|
|
852
|
+
}
|
|
834
853
|
// Every name a twin service above injects at boot (its endpoint env and templates).
|
|
835
854
|
const worldInjected = new Set([
|
|
836
855
|
...(existing?.services ?? []),
|
|
@@ -1164,6 +1183,9 @@ current
|
|
|
1164
1183
|
*.env
|
|
1165
1184
|
credentials/
|
|
1166
1185
|
token
|
|
1186
|
+
token.read
|
|
1187
|
+
sessions.json
|
|
1188
|
+
keys.json
|
|
1167
1189
|
`;
|
|
1168
1190
|
|
|
1169
1191
|
export function writeWorldInit(plan: InitPlan, options: { force?: boolean } = {}): void {
|