@volter/world-runtime 2.0.1 → 2.0.2

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.
Files changed (63) hide show
  1. package/dist/src/app-url.js +1 -1
  2. package/dist/src/catalog.js +1 -1
  3. package/dist/src/cli.js +3 -3
  4. package/dist/src/console-apart.d.ts +1 -0
  5. package/dist/src/console-apart.js +7 -0
  6. package/dist/src/covers.js +4 -2
  7. package/dist/src/host-worker.js +2 -9
  8. package/dist/src/host.js +2 -9
  9. package/dist/src/import-module.d.ts +1 -0
  10. package/dist/src/import-module.js +16 -0
  11. package/dist/src/index.d.ts +7 -2
  12. package/dist/src/index.js +4 -1
  13. package/dist/src/infra-cli.js +2 -2
  14. package/dist/src/init.d.ts +1 -1
  15. package/dist/src/init.js +3 -0
  16. package/dist/src/local-branches.d.ts +37 -0
  17. package/dist/src/local-branches.js +193 -0
  18. package/dist/src/pglite-backing.js +8 -5
  19. package/dist/src/prerequisites.js +1 -1
  20. package/dist/src/process-groups.js +1 -1
  21. package/dist/src/redirect-proxy.js +2 -2
  22. package/dist/src/root.d.ts +23 -0
  23. package/dist/src/root.js +22 -10
  24. package/dist/src/run-task.js +1 -1
  25. package/dist/src/runtime.js +15 -15
  26. package/dist/src/schema.d.ts +5 -0
  27. package/dist/src/schema.js +10 -1
  28. package/dist/src/served-world.d.ts +196 -9
  29. package/dist/src/served-world.js +847 -103
  30. package/dist/src/service-recorder.js +1 -1
  31. package/dist/src/storage-capacity.js +2 -2
  32. package/dist/src/up-task.js +1 -1
  33. package/dist/src/world-origins.d.ts +13 -0
  34. package/dist/src/world-origins.js +37 -0
  35. package/dist/src/world-view.d.ts +25 -0
  36. package/dist/src/world-view.js +110 -0
  37. package/package.json +6 -5
  38. package/src/app-url.ts +1 -1
  39. package/src/catalog.ts +1 -1
  40. package/src/cli.ts +3 -3
  41. package/src/console-apart.ts +7 -1
  42. package/src/covers.ts +4 -2
  43. package/src/host-worker.ts +2 -1
  44. package/src/host.ts +2 -1
  45. package/src/import-module.ts +9 -0
  46. package/src/index.ts +7 -2
  47. package/src/infra-cli.ts +2 -2
  48. package/src/init.ts +3 -0
  49. package/src/local-branches.ts +173 -0
  50. package/src/pglite-backing.ts +8 -5
  51. package/src/prerequisites.ts +1 -1
  52. package/src/process-groups.ts +1 -1
  53. package/src/redirect-proxy.ts +2 -2
  54. package/src/root.ts +27 -2
  55. package/src/run-task.ts +1 -1
  56. package/src/runtime.ts +15 -15
  57. package/src/schema.ts +11 -1
  58. package/src/served-world.ts +762 -85
  59. package/src/service-recorder.ts +1 -1
  60. package/src/storage-capacity.ts +2 -2
  61. package/src/up-task.ts +1 -1
  62. package/src/world-origins.ts +38 -0
  63. 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
  }
@@ -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.1",
3
+ "version": "2.0.2",
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.1",
71
- "@volter/world-console": "2.0.1"
70
+ "@volter/world-core": "2.0.2",
71
+ "@volter/world-console": "2.0.2"
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-core": "2.0.1",
76
+ "@volter/world-access": "2.0.0",
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.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') {
@@ -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
- const dependencies = projectDependencies(repo).filter((dep) => !dep.startsWith('@volter/') && !dep.startsWith('pypi:volter'));
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
 
@@ -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 = (await import(spec.module)) as Record<string, unknown>;
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 = (await import(spec.module)) as Record<string, unknown>;
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
@@ -1164,6 +1164,9 @@ current
1164
1164
  *.env
1165
1165
  credentials/
1166
1166
  token
1167
+ token.read
1168
+ sessions.json
1169
+ keys.json
1167
1170
  `;
1168
1171
 
1169
1172
  export function writeWorldInit(plan: InitPlan, options: { force?: boolean } = {}): void {
@@ -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
+ }