@volter/world-runtime 2.0.0
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/LICENSE +202 -0
- package/dist/known-external-services.json +1108 -0
- package/dist/src/ancestry.d.ts +2 -0
- package/dist/src/ancestry.js +42 -0
- package/dist/src/app-url.d.ts +47 -0
- package/dist/src/app-url.js +239 -0
- package/dist/src/attach.d.ts +48 -0
- package/dist/src/attach.js +87 -0
- package/dist/src/branch.d.ts +20 -0
- package/dist/src/branch.js +65 -0
- package/dist/src/browser-proxy-cli.d.ts +2 -0
- package/dist/src/browser-proxy-cli.js +41 -0
- package/dist/src/ca-trust.d.ts +5 -0
- package/dist/src/ca-trust.js +64 -0
- package/dist/src/catalog.d.ts +31 -0
- package/dist/src/catalog.js +148 -0
- package/dist/src/changeset.d.ts +142 -0
- package/dist/src/changeset.js +570 -0
- package/dist/src/cli.d.ts +2 -0
- package/dist/src/cli.js +1262 -0
- package/dist/src/command-lifetime.d.ts +15 -0
- package/dist/src/command-lifetime.js +98 -0
- package/dist/src/configs.d.ts +18 -0
- package/dist/src/configs.js +119 -0
- package/dist/src/console-apart.d.ts +38 -0
- package/dist/src/console-apart.js +107 -0
- package/dist/src/consumers.d.ts +46 -0
- package/dist/src/consumers.js +200 -0
- package/dist/src/covers.d.ts +183 -0
- package/dist/src/covers.js +800 -0
- package/dist/src/fixture-env.d.ts +42 -0
- package/dist/src/fixture-env.js +221 -0
- package/dist/src/host-cli.d.ts +2 -0
- package/dist/src/host-cli.js +92 -0
- package/dist/src/host-fault-fixture.d.ts +32 -0
- package/dist/src/host-fault-fixture.js +100 -0
- package/dist/src/host-worker.d.ts +1 -0
- package/dist/src/host-worker.js +23 -0
- package/dist/src/host.d.ts +38 -0
- package/dist/src/host.js +135 -0
- package/dist/src/index.d.ts +48 -0
- package/dist/src/index.js +35 -0
- package/dist/src/infra-cli.d.ts +2 -0
- package/dist/src/infra-cli.js +136 -0
- package/dist/src/init.d.ts +227 -0
- package/dist/src/init.js +1117 -0
- package/dist/src/inject-map.d.ts +34 -0
- package/dist/src/inject-map.js +56 -0
- package/dist/src/lifecycle-record.d.ts +47 -0
- package/dist/src/lifecycle-record.js +196 -0
- package/dist/src/origin.d.ts +31 -0
- package/dist/src/origin.js +139 -0
- package/dist/src/pack-facts.d.ts +75 -0
- package/dist/src/pack-facts.js +98 -0
- package/dist/src/pglite-backing.d.ts +21 -0
- package/dist/src/pglite-backing.js +158 -0
- package/dist/src/pglite-host.mjs +147 -0
- package/dist/src/placeholder.d.ts +20 -0
- package/dist/src/placeholder.js +100 -0
- package/dist/src/prerequisites.d.ts +21 -0
- package/dist/src/prerequisites.js +49 -0
- package/dist/src/process-groups.d.ts +4 -0
- package/dist/src/process-groups.js +49 -0
- package/dist/src/project-inspect.d.ts +109 -0
- package/dist/src/project-inspect.js +827 -0
- package/dist/src/proxy-daemon.d.ts +2 -0
- package/dist/src/proxy-daemon.js +18 -0
- package/dist/src/redirect-proxy.d.ts +105 -0
- package/dist/src/redirect-proxy.js +665 -0
- package/dist/src/reflect.d.ts +74 -0
- package/dist/src/reflect.js +392 -0
- package/dist/src/resources.d.ts +26 -0
- package/dist/src/resources.js +22 -0
- package/dist/src/root.d.ts +114 -0
- package/dist/src/root.js +312 -0
- package/dist/src/run-task-worker.d.ts +1 -0
- package/dist/src/run-task-worker.js +38 -0
- package/dist/src/run-task.d.ts +18 -0
- package/dist/src/run-task.js +48 -0
- package/dist/src/runtime-test-support.d.ts +59 -0
- package/dist/src/runtime-test-support.js +205 -0
- package/dist/src/runtime.d.ts +256 -0
- package/dist/src/runtime.js +3502 -0
- package/dist/src/schema.d.ts +449 -0
- package/dist/src/schema.js +605 -0
- package/dist/src/serve.d.ts +30 -0
- package/dist/src/serve.js +82 -0
- package/dist/src/served-world.d.ts +194 -0
- package/dist/src/served-world.js +986 -0
- package/dist/src/service-exit.d.ts +46 -0
- package/dist/src/service-exit.js +195 -0
- package/dist/src/service-recorder.d.ts +1 -0
- package/dist/src/service-recorder.js +121 -0
- package/dist/src/sibling.d.ts +1 -0
- package/dist/src/sibling.js +9 -0
- package/dist/src/signals.d.ts +1 -0
- package/dist/src/signals.js +11 -0
- package/dist/src/storage-capacity.d.ts +8 -0
- package/dist/src/storage-capacity.js +61 -0
- package/dist/src/tail.d.ts +30 -0
- package/dist/src/tail.js +160 -0
- package/dist/src/tcp-port.d.ts +2 -0
- package/dist/src/tcp-port.js +36 -0
- package/dist/src/up-task-worker.d.ts +1 -0
- package/dist/src/up-task-worker.js +61 -0
- package/dist/src/up-task.d.ts +17 -0
- package/dist/src/up-task.js +49 -0
- package/dist/src/websocket-relay.d.ts +3 -0
- package/dist/src/websocket-relay.js +40 -0
- package/known-external-services.json +1108 -0
- package/package.json +83 -0
- package/src/ancestry.ts +36 -0
- package/src/app-url.ts +253 -0
- package/src/attach.ts +117 -0
- package/src/branch.ts +63 -0
- package/src/browser-proxy-cli.ts +44 -0
- package/src/ca-trust.ts +57 -0
- package/src/catalog.ts +156 -0
- package/src/changeset.ts +627 -0
- package/src/cli.ts +1111 -0
- package/src/command-lifetime.ts +79 -0
- package/src/configs.ts +110 -0
- package/src/console-apart.ts +90 -0
- package/src/consumers.ts +185 -0
- package/src/covers.ts +934 -0
- package/src/fixture-env.ts +230 -0
- package/src/host-cli.ts +90 -0
- package/src/host-worker.ts +23 -0
- package/src/host.ts +169 -0
- package/src/index.ts +171 -0
- package/src/infra-cli.ts +133 -0
- package/src/init.ts +1316 -0
- package/src/inject-map.ts +72 -0
- package/src/lifecycle-record.ts +168 -0
- package/src/origin.ts +134 -0
- package/src/pack-facts.ts +128 -0
- package/src/pglite-backing.ts +141 -0
- package/src/pglite-host.mjs +147 -0
- package/src/placeholder.ts +89 -0
- package/src/prerequisites.ts +66 -0
- package/src/process-groups.ts +33 -0
- package/src/project-inspect.ts +770 -0
- package/src/proxy-daemon.ts +21 -0
- package/src/redirect-proxy.ts +684 -0
- package/src/reflect.ts +440 -0
- package/src/resources.ts +22 -0
- package/src/root.ts +290 -0
- package/src/run-task-worker.ts +27 -0
- package/src/run-task.ts +44 -0
- package/src/runtime-test-support.ts +208 -0
- package/src/runtime.ts +3357 -0
- package/src/schema.ts +922 -0
- package/src/serve.ts +102 -0
- package/src/served-world.ts +812 -0
- package/src/service-exit.ts +175 -0
- package/src/service-recorder.ts +89 -0
- package/src/sibling.ts +10 -0
- package/src/signals.ts +10 -0
- package/src/storage-capacity.ts +60 -0
- package/src/tail.ts +205 -0
- package/src/tcp-port.ts +35 -0
- package/src/up-task-worker.ts +40 -0
- package/src/up-task.ts +45 -0
- package/src/websocket-relay.ts +32 -0
package/package.json
ADDED
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@volter/world-runtime",
|
|
3
|
+
"version": "2.0.0",
|
|
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
|
+
"keywords": [
|
|
6
|
+
"twin",
|
|
7
|
+
"world",
|
|
8
|
+
"local",
|
|
9
|
+
"fixtures",
|
|
10
|
+
"testing",
|
|
11
|
+
"sdk",
|
|
12
|
+
"api",
|
|
13
|
+
"localstack"
|
|
14
|
+
],
|
|
15
|
+
"author": "Volter (https://github.com/volter-ai)",
|
|
16
|
+
"license": "Apache-2.0",
|
|
17
|
+
"publishConfig": {
|
|
18
|
+
"access": "public"
|
|
19
|
+
},
|
|
20
|
+
"files": [
|
|
21
|
+
"src",
|
|
22
|
+
"known-external-services.json",
|
|
23
|
+
"client",
|
|
24
|
+
"test-fixtures",
|
|
25
|
+
"README.md",
|
|
26
|
+
"LICENSE",
|
|
27
|
+
"!test-fixtures/*.SOURCE.md",
|
|
28
|
+
"!src/host-fault-fixture.ts",
|
|
29
|
+
"!**/*.test.ts",
|
|
30
|
+
"!**/*.test.tsx",
|
|
31
|
+
"dist"
|
|
32
|
+
],
|
|
33
|
+
"repository": {
|
|
34
|
+
"type": "git",
|
|
35
|
+
"url": "git+https://github.com/volter-ai/twin.git",
|
|
36
|
+
"directory": "packages/world-runtime"
|
|
37
|
+
},
|
|
38
|
+
"homepage": "https://github.com/volter-ai/twin/tree/main/packages/world-runtime#readme",
|
|
39
|
+
"type": "module",
|
|
40
|
+
"exports": {
|
|
41
|
+
".": {
|
|
42
|
+
"types": "./dist/src/index.d.ts",
|
|
43
|
+
"default": "./dist/src/index.js"
|
|
44
|
+
}
|
|
45
|
+
},
|
|
46
|
+
"bin": {
|
|
47
|
+
"volter-world": "dist/src/cli.js",
|
|
48
|
+
"volter-world-browser-proxy": "dist/src/browser-proxy-cli.js",
|
|
49
|
+
"volter-world-host": "dist/src/host-cli.js",
|
|
50
|
+
"volter-world-infra": "dist/src/infra-cli.js"
|
|
51
|
+
},
|
|
52
|
+
"scripts": {
|
|
53
|
+
"test": "bun test src/*.test.ts",
|
|
54
|
+
"typecheck": "tsc --noEmit",
|
|
55
|
+
"build": "node ../../scripts/publish/build.mjs",
|
|
56
|
+
"prepack": "node ../../scripts/publish/prepare-publish.mjs prepack",
|
|
57
|
+
"postpack": "node ../../scripts/publish/prepare-publish.mjs postpack"
|
|
58
|
+
},
|
|
59
|
+
"devDependencies": {
|
|
60
|
+
"@types/bun": "^1.2.20",
|
|
61
|
+
"@types/node": "^24.0.0",
|
|
62
|
+
"pg": "8.16.3",
|
|
63
|
+
"typescript": "^5.9.0"
|
|
64
|
+
},
|
|
65
|
+
"engines": {
|
|
66
|
+
"node": ">=22.3"
|
|
67
|
+
},
|
|
68
|
+
"peerDependencies": {
|
|
69
|
+
"@volter/world-core": "2.0.0",
|
|
70
|
+
"@volter/world-console": "2.0.0"
|
|
71
|
+
},
|
|
72
|
+
"dependencies": {
|
|
73
|
+
"@electric-sql/pglite": "0.5.8",
|
|
74
|
+
"@volter/world-core": "2.0.0",
|
|
75
|
+
"pg-gateway": "0.3.0-beta.4",
|
|
76
|
+
"smol-toml": "^1.8.0"
|
|
77
|
+
},
|
|
78
|
+
"peerDependenciesMeta": {
|
|
79
|
+
"@volter/world-console": {
|
|
80
|
+
"optional": true
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
}
|
package/src/ancestry.ts
ADDED
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
// Check discoverable legacy pointers before removing retained World storage. New pointers
|
|
2
|
+
// carry parent-side pins, including clones across checkouts, without a global World registry.
|
|
3
|
+
import { existsSync, readdirSync } from 'node:fs';
|
|
4
|
+
import { join, relative, resolve, sep } from 'node:path';
|
|
5
|
+
import { assertStateRemovable, readBranchMeta, stateDirName, withAncestryLock, withStateRemoval, getActiveWorldStore, worldPaths, worldStateRoot } from '@volter/world-core';
|
|
6
|
+
|
|
7
|
+
function checkWorldAncestry(root: string, path: string): void {
|
|
8
|
+
const base = join(root, stateDirName(), 'worlds');
|
|
9
|
+
if (!existsSync(base)) return;
|
|
10
|
+
for (const world of readdirSync(base, { withFileTypes: true })) {
|
|
11
|
+
if (!world.isDirectory() || world.name.startsWith('.')) continue;
|
|
12
|
+
const data = join(base, world.name, 'data');
|
|
13
|
+
if (!existsSync(data)) continue;
|
|
14
|
+
for (const service of readdirSync(data, { withFileTypes: true })) {
|
|
15
|
+
if (!service.isDirectory()) continue;
|
|
16
|
+
const control = join(data, service.name); const states = worldStateRoot(control);
|
|
17
|
+
if (!existsSync(states)) continue;
|
|
18
|
+
for (const state of readdirSync(states, { withFileTypes: true })) {
|
|
19
|
+
if (!state.isDirectory()) continue;
|
|
20
|
+
const meta = readBranchMeta(state.name, control);
|
|
21
|
+
if (!meta?.parent || /^[a-z][a-z0-9+.-]*:\/\//i.test(meta.parent.at)) continue;
|
|
22
|
+
const parent = worldPaths(state.name, meta.parent.at).dir;
|
|
23
|
+
const under = (target: string) => { const rel = relative(getActiveWorldStore().canonicalPath?.(path) ?? resolve(path), getActiveWorldStore().canonicalPath?.(target) ?? resolve(target)); return rel === '' || (rel !== '..' && !rel.startsWith(`..${sep}`) && !rel.startsWith(sep)); };
|
|
24
|
+
if (under(parent) && !under(control)) throw new Error(`Retained branch parent: dependent World ${world.name} (${control}). Remove dependent branches first; down without purge can still stop compute.`);
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export function assertWorldStateRemovable(root: string, path: string): void {
|
|
31
|
+
withAncestryLock(() => { checkWorldAncestry(root, path); assertStateRemovable(path); });
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export function removeWorldState<T>(root: string, path: string, remove: () => T): T {
|
|
35
|
+
return withStateRemoval(path, remove, () => checkWorldAncestry(root, path));
|
|
36
|
+
}
|
package/src/app-url.ts
ADDED
|
@@ -0,0 +1,253 @@
|
|
|
1
|
+
// `volter-world app-url` — the recorded, queryable APP endpoint of a world instance.
|
|
2
|
+
//
|
|
3
|
+
// Worlds own the lifecycle of SERVICES; the app under test is usually booted OUTSIDE the world by
|
|
4
|
+
// the caller (the attach pattern), so nothing in the instance knows where the app listens — every
|
|
5
|
+
// consumer (a browser step, a webhook target, a teammate's curl) re-derives it by folklore. The
|
|
6
|
+
// subject pilots hit exactly this: the world's URLs are one `volter-world url` away, the APP's URL
|
|
7
|
+
// is tribal knowledge. This verb makes the app endpoint a first-class instance record:
|
|
8
|
+
//
|
|
9
|
+
// • the BOOTER writes it once, as the LAST boot step — `--set <url>`, or `--detect <pid|port>`
|
|
10
|
+
// to read it off the already-listening process instead of typing it;
|
|
11
|
+
// • any consumer reads it back (`volter-world app-url <world> [--json]`);
|
|
12
|
+
// • an UNSET read fails LOUDLY with the registration recipe — never a guess, never a default.
|
|
13
|
+
//
|
|
14
|
+
// A world that boots the app itself declares an `app` service (`type: 'process'`) — that existing
|
|
15
|
+
// convention needs no registration: the read falls back to the `app` service's assigned URL.
|
|
16
|
+
//
|
|
17
|
+
// RUNTIME MINIMALISM HOLDS: this is a record, not orchestration. The runtime never boots, probes,
|
|
18
|
+
// supervises, or health-checks the app — `--detect` inspects the caller-named pid/port exactly
|
|
19
|
+
// once, at the caller's request, and stores what it saw. The record lives in the instance dir, so
|
|
20
|
+
// a re-`up` (which wipes the dir) or `down --purge` clears it: a recorded URL never outlives the
|
|
21
|
+
// instance it described.
|
|
22
|
+
import { existsSync, readFileSync, writeFileSync } from 'node:fs';
|
|
23
|
+
import { join } from 'node:path';
|
|
24
|
+
import { spawnSync } from 'node:child_process';
|
|
25
|
+
import net from 'node:net';
|
|
26
|
+
import { instanceDir, statusWorld } from './runtime.ts';
|
|
27
|
+
import { loadInject } from './inject-map.ts';
|
|
28
|
+
import { packFacts } from './pack-facts.ts';
|
|
29
|
+
|
|
30
|
+
/** Who answered: an explicit `--set`, a `--detect` inspection, or the world's own `app` service. */
|
|
31
|
+
export type AppUrlSource = 'set' | 'detect' | 'service';
|
|
32
|
+
|
|
33
|
+
export type AppUrlRecord = {
|
|
34
|
+
world: string;
|
|
35
|
+
url: string;
|
|
36
|
+
via: AppUrlSource;
|
|
37
|
+
/** ISO timestamp of the registration (absent for the `app`-service fallback — nothing was recorded) */
|
|
38
|
+
recordedAt?: string;
|
|
39
|
+
/** how the URL was obtained, for the human reading `--json` output later */
|
|
40
|
+
detail?: string;
|
|
41
|
+
/** the application's own production hostnames (`--host`), routed to `url` inside the World as DNS routes them to
|
|
42
|
+
* its host in production: the injector, the socket backstop and the redirect proxy read them from this record */
|
|
43
|
+
hosts?: string[];
|
|
44
|
+
};
|
|
45
|
+
|
|
46
|
+
export function appUrlFile(root: string, name: string): string {
|
|
47
|
+
return join(instanceDir(root, name), 'app-url.json');
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function assertHttpUrl(url: string): URL {
|
|
51
|
+
let parsed: URL;
|
|
52
|
+
try {
|
|
53
|
+
parsed = new URL(url);
|
|
54
|
+
} catch {
|
|
55
|
+
throw new Error(`volter-world app-url: "${url}" is not a URL (want e.g. http://127.0.0.1:3000)`);
|
|
56
|
+
}
|
|
57
|
+
if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') {
|
|
58
|
+
throw new Error(`volter-world app-url: "${url}" must be http(s) — an app endpoint consumers can fetch`);
|
|
59
|
+
}
|
|
60
|
+
return parsed;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/** Record `url` as the app endpoint of the RUNNING/booted instance `name`. The instance must
|
|
64
|
+
* exist (a record for a world that was never upped would describe nothing); the app itself is
|
|
65
|
+
* deliberately NOT probed — the booter declaring "this is where I put the app" is the truth. */
|
|
66
|
+
export function setAppUrl(name: string, url: string, options: { root?: string; via?: Exclude<AppUrlSource, 'service'>; detail?: string } = {}): AppUrlRecord {
|
|
67
|
+
const root = options.root ?? process.cwd();
|
|
68
|
+
const status = statusWorld(name, root); // throws "World instance not found" — the loud path
|
|
69
|
+
assertHttpUrl(url);
|
|
70
|
+
// a new URL keeps the hosts recorded for the app (they name the app, not where it listens)
|
|
71
|
+
const hosts = existsSync(appUrlFile(root, name)) ? (JSON.parse(readFileSync(appUrlFile(root, name), 'utf8')) as AppUrlRecord).hosts : undefined;
|
|
72
|
+
const record: AppUrlRecord = {
|
|
73
|
+
world: status.name,
|
|
74
|
+
url,
|
|
75
|
+
via: options.via ?? 'set',
|
|
76
|
+
recordedAt: new Date().toISOString(),
|
|
77
|
+
...(options.detail === undefined ? {} : { detail: options.detail }),
|
|
78
|
+
...(hosts?.length ? { hosts } : {}),
|
|
79
|
+
};
|
|
80
|
+
writeFileSync(appUrlFile(root, name), `${JSON.stringify(record, null, 2)}\n`);
|
|
81
|
+
return record;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/** A hostname the app may answer as: a DNS name (not an address, not loopback) that no twin serves, and not the
|
|
85
|
+
* host of this World's own origin or of a twin it runs (their requests carry the World key). */
|
|
86
|
+
function assertAppHost(host: string, world: string, root: string): string {
|
|
87
|
+
const name = host.trim().toLowerCase().replace(/\.$/, '');
|
|
88
|
+
// a wildcard (`*.dub.link`) stands for every name below its parent, as a DNS wildcard record does; its parent has two
|
|
89
|
+
// labels at least (never a bare TLD). Whether the parent is a public suffix (co.uk, github.io) is the operator's word:
|
|
90
|
+
// a World keeps no public suffix list
|
|
91
|
+
const wildcard = name.startsWith('*.');
|
|
92
|
+
const bare = wildcard ? name.slice(2) : name;
|
|
93
|
+
if (!/^(?=.{1,253}$)([a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?\.)+[a-z][a-z0-9-]{0,61}[a-z0-9]$/.test(bare) || net.isIP(bare) || bare === 'localhost' || bare.endsWith('.localhost')) {
|
|
94
|
+
throw new Error(`volter-world app-url: --host "${host}" is not a DNS hostname the app answers as (e.g. app.example.com, or *.example.com)`);
|
|
95
|
+
}
|
|
96
|
+
const inject = loadInject();
|
|
97
|
+
let vendor = Object.keys(inject.VENDOR_HOSTS).find((v) => inject.VENDOR_HOSTS[v]!(bare));
|
|
98
|
+
if (!vendor && wildcard) {
|
|
99
|
+
// a wildcard is refused when any name a vendor's rule can give its twin lies under it, or it lies under a vendor's
|
|
100
|
+
// host suffix: the rules' names, read from the descriptors (a host, a suffix, a pattern's literal tail) and from
|
|
101
|
+
// the hand table's predicates (their quoted names)
|
|
102
|
+
const names = vendorNames(inject.VENDOR_HOSTS);
|
|
103
|
+
const dotted = `.${bare}`;
|
|
104
|
+
vendor = names.find((n) => n.name === bare || n.name.endsWith(dotted) || (n.suffix && dotted.endsWith(n.name.startsWith('.') ? n.name : `.${n.name}`)))?.vendor;
|
|
105
|
+
}
|
|
106
|
+
if (vendor) throw new Error(`volter-world app-url: --host ${name} is ${vendor}'s host, which its twin serves; an app cannot answer as it`);
|
|
107
|
+
const status = statusWorld(world, root);
|
|
108
|
+
const own = [status.env.VOLTER_WORLD ?? '', ...Object.values(status.services).map((service) => service.url ?? '')]
|
|
109
|
+
.flatMap((url) => { try { return [new URL(url).hostname.toLowerCase()]; } catch { return []; } });
|
|
110
|
+
if (own.some((h) => h === name || (wildcard && h.endsWith(`.${bare}`)))) throw new Error(`volter-world app-url: --host ${name} is this World's own origin or a twin's; an app cannot answer as it`);
|
|
111
|
+
return name;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/** Every hostname the vendor table can name, with its vendor: a descriptor rule's host, suffix, or a host pattern's
|
|
115
|
+
* literal tail (a suffix), and each quoted name in a hand-table predicate (a leading dot marks a suffix). */
|
|
116
|
+
function vendorNames(table: Record<string, (host: string, pathname?: string) => boolean>): Array<{ vendor: string; name: string; suffix: boolean }> {
|
|
117
|
+
const out: Array<{ vendor: string; name: string; suffix: boolean }> = [];
|
|
118
|
+
for (const [vendor, facts] of Object.entries(packFacts())) {
|
|
119
|
+
for (const rule of facts.hosts ?? []) {
|
|
120
|
+
if (rule.exclude) continue;
|
|
121
|
+
if (rule.host) out.push({ vendor: rule.key ?? vendor, name: rule.host.toLowerCase(), suffix: false });
|
|
122
|
+
if (rule.suffix) out.push({ vendor: rule.key ?? vendor, name: rule.suffix.toLowerCase().replace(/^-/, '.'), suffix: true });
|
|
123
|
+
if (rule.hostPattern) {
|
|
124
|
+
// the literal tail, and each alternative of a trailing group joined to what precedes it
|
|
125
|
+
// (`\.(amazonaws\.com|api\.aws)$` names both)
|
|
126
|
+
const src = rule.hostPattern.replace(/\\\./g, '.').replace(/\$$/, '');
|
|
127
|
+
const group = /([a-z0-9.-]*)\((?:\?:)?([a-z0-9.|-]+)\)$/i.exec(src);
|
|
128
|
+
const tails = group ? group[2]!.split('|').map((alt) => `${group[1]}${alt}`) : [/[a-z0-9.-]+$/i.exec(src)?.[0] ?? ''];
|
|
129
|
+
for (const tail of tails) if (tail.includes('.')) out.push({ vendor: rule.key ?? vendor, name: tail.toLowerCase(), suffix: true });
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
for (const [vendor, predicate] of Object.entries(table)) {
|
|
134
|
+
for (const m of String(predicate).matchAll(/['"`](\.?[a-z0-9-]+(?:\.[a-z0-9-]+)+)['"`]/gi)) out.push({ vendor, name: m[1]!.toLowerCase(), suffix: m[1]!.startsWith('.') });
|
|
135
|
+
}
|
|
136
|
+
return out;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/** Record `hosts` as the app's own production hostnames, beside its recorded URL (or its `app` service's). */
|
|
140
|
+
export function addAppHosts(name: string, hosts: string[], options: { root?: string } = {}): AppUrlRecord {
|
|
141
|
+
const root = options.root ?? process.cwd();
|
|
142
|
+
const current = readAppUrl(name, { root });
|
|
143
|
+
if (!current) throw new Error(appUrlUnsetMessage(name));
|
|
144
|
+
const all = [...new Set([...(current.hosts ?? []), ...hosts.map((host) => assertAppHost(host, name, root))])].sort();
|
|
145
|
+
// hosts beside the World's own `app` service keep no URL: they follow the service wherever a boot puts it
|
|
146
|
+
const record: AppUrlRecord = current.via === 'service' ? { world: current.world, via: 'service', hosts: all } as AppUrlRecord : { ...current, hosts: all };
|
|
147
|
+
writeFileSync(appUrlFile(root, name), `${JSON.stringify(record, null, 2)}\n`);
|
|
148
|
+
return current.via === 'service' ? { ...current, hosts: all } : record;
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
/** The listening TCP ports of a live pid, via lsof (macOS + Linux). */
|
|
152
|
+
function listeningTcpPorts(pid: number): number[] {
|
|
153
|
+
const result = spawnSync('lsof', ['-a', '-p', String(pid), '-iTCP', '-sTCP:LISTEN', '-P', '-n', '-Fn'], { encoding: 'utf8' });
|
|
154
|
+
if (result.error) {
|
|
155
|
+
throw new Error(`volter-world app-url: \`lsof\` is unavailable (${result.error.message}) — pass the port, or --set the URL directly`);
|
|
156
|
+
}
|
|
157
|
+
const ports = new Set<number>();
|
|
158
|
+
for (const line of (result.stdout ?? '').split('\n')) {
|
|
159
|
+
if (!line.startsWith('n')) continue;
|
|
160
|
+
const port = Number(line.slice(line.lastIndexOf(':') + 1));
|
|
161
|
+
if (Number.isInteger(port) && port > 0 && port <= 65535) ports.add(port);
|
|
162
|
+
}
|
|
163
|
+
return [...ports].sort((a, b) => a - b);
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
function pidIsLive(pid: number): boolean {
|
|
167
|
+
try {
|
|
168
|
+
process.kill(pid, 0);
|
|
169
|
+
return true;
|
|
170
|
+
} catch (error) {
|
|
171
|
+
return (error as NodeJS.ErrnoException).code === 'EPERM';
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
/** One TCP connection attempt to 127.0.0.1:<port> — is anything listening RIGHT NOW? */
|
|
176
|
+
function tcpListening(port: number, timeoutMs = 1500): Promise<boolean> {
|
|
177
|
+
return new Promise((resolvePromise) => {
|
|
178
|
+
const socket = net.connect({ port, host: '127.0.0.1' });
|
|
179
|
+
const done = (ok: boolean): void => {
|
|
180
|
+
socket.destroy();
|
|
181
|
+
resolvePromise(ok);
|
|
182
|
+
};
|
|
183
|
+
socket.once('connect', () => done(true));
|
|
184
|
+
socket.once('error', () => done(false));
|
|
185
|
+
socket.setTimeout(timeoutMs, () => done(false));
|
|
186
|
+
});
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
/**
|
|
190
|
+
*`--detect <pid|port>`: resolve the app URL off what is ALREADY listening and record it.
|
|
191
|
+
* Disambiguation (both are just numbers): a live pid with listening ports wins — its single
|
|
192
|
+
* listening port is the app; several listening ports is a loud error (name the port instead).
|
|
193
|
+
* Otherwise the number is tried as a loopback port and must actually be listening. Detection
|
|
194
|
+
* that finds nothing refuses loudly rather than recording a URL nothing serves.
|
|
195
|
+
*/
|
|
196
|
+
export async function detectAppUrl(name: string, target: string, options: { root?: string } = {}): Promise<AppUrlRecord> {
|
|
197
|
+
const numeric = Number(target);
|
|
198
|
+
if (!Number.isInteger(numeric) || numeric <= 0) {
|
|
199
|
+
throw new Error(`volter-world app-url: --detect wants a pid or a port, got "${target}"`);
|
|
200
|
+
}
|
|
201
|
+
if (pidIsLive(numeric)) {
|
|
202
|
+
const ports = listeningTcpPorts(numeric);
|
|
203
|
+
if (ports.length === 1) {
|
|
204
|
+
return setAppUrl(name, `http://127.0.0.1:${ports[0]}`, { ...options, via: 'detect', detail: `detected from pid ${numeric} (listening on ${ports[0]})` });
|
|
205
|
+
}
|
|
206
|
+
if (ports.length > 1) {
|
|
207
|
+
throw new Error(
|
|
208
|
+
`volter-world app-url: pid ${numeric} is listening on ${ports.length} ports (${ports.join(', ')}) — `
|
|
209
|
+
+ `ambiguous. Detect the app's port directly (--detect <port>) or record it explicitly (--set <url>).`,
|
|
210
|
+
);
|
|
211
|
+
}
|
|
212
|
+
// a live pid with NO listeners: fall through — the number was probably meant as a port
|
|
213
|
+
}
|
|
214
|
+
if (numeric <= 65535 && await tcpListening(numeric)) {
|
|
215
|
+
return setAppUrl(name, `http://127.0.0.1:${numeric}`, { ...options, via: 'detect', detail: `port ${numeric} is listening on 127.0.0.1` });
|
|
216
|
+
}
|
|
217
|
+
throw new Error(
|
|
218
|
+
`volter-world app-url: nothing to detect from "${target}" — it is not a live pid with a listening TCP port, `
|
|
219
|
+
+ `and nothing is listening on 127.0.0.1:${target}. Boot the app first, then register its URL (--set <url> once it listens).`,
|
|
220
|
+
);
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
/**
|
|
224
|
+
* The recorded app URL of `name`, or the `app` service's assigned URL when nothing was recorded
|
|
225
|
+
* (the world-boots-the-app convention), or null. Null is the CALLER's loud-error cue — the CLI
|
|
226
|
+
* turns it into exit 1 with the registration recipe; a library consumer decides for itself.
|
|
227
|
+
*/
|
|
228
|
+
export function readAppUrl(name: string, options: { root?: string } = {}): AppUrlRecord | null {
|
|
229
|
+
const root = options.root ?? process.cwd();
|
|
230
|
+
const status = statusWorld(name, root); // loud when the world does not exist at all
|
|
231
|
+
const file = appUrlFile(root, name);
|
|
232
|
+
const app = status.services['app'];
|
|
233
|
+
if (existsSync(file)) {
|
|
234
|
+
const record = JSON.parse(readFileSync(file, 'utf8')) as AppUrlRecord;
|
|
235
|
+
// hosts recorded beside the World's own `app` service: its URL is this boot's
|
|
236
|
+
if (!record.url && app?.url !== undefined) return { world: status.name, url: app.url, via: 'service', detail: 'the world\'s own "app" service', ...(record.hosts ? { hosts: record.hosts } : {}) };
|
|
237
|
+
if (record.url) return record;
|
|
238
|
+
}
|
|
239
|
+
if (app?.url !== undefined) {
|
|
240
|
+
return { world: status.name, url: app.url, via: 'service', detail: 'the world\'s own "app" service (nothing was recorded)' };
|
|
241
|
+
}
|
|
242
|
+
return null;
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
/** The message the CLI prints when no app URL is known — the registration recipe, not a guess. */
|
|
246
|
+
export function appUrlUnsetMessage(name: string): string {
|
|
247
|
+
return (
|
|
248
|
+
`World ${name} has no recorded app URL.\n`
|
|
249
|
+
+ `If the app is booted OUTSIDE the world (the attach pattern), the booter registers it as the LAST boot step:\n`
|
|
250
|
+
+ ` volter-world app-url ${name} --set http://127.0.0.1:<port> # or: --detect <pid|port>\n`
|
|
251
|
+
+ `A world that boots the app itself can declare an "app" service (type "process") — its URL answers automatically.`
|
|
252
|
+
);
|
|
253
|
+
}
|
package/src/attach.ts
ADDED
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
// The attach surface of the two-part model (docs/guides/route-a-cli-through-the-world.md): resolving a world
|
|
2
|
+
// reference and deriving the world manifest — the single document a world
|
|
3
|
+
// publishes and every attachment mechanism consumes. `world.env` /
|
|
4
|
+
// `instance.json` remain the local renderings; this module derives the manifest
|
|
5
|
+
// FROM them (it invents nothing). Attaching itself stays thin: `--via env`
|
|
6
|
+
// delegates to runWithWorldEnv, `--via direct` prints the manifest. Reflect and
|
|
7
|
+
// remote refs are proposals (docs/guides/route-a-cli-through-the-world.md) and are refused loudly, never
|
|
8
|
+
// silently degraded.
|
|
9
|
+
import { existsSync, readFileSync } from 'node:fs';
|
|
10
|
+
import { dirname, join, resolve } from 'node:path';
|
|
11
|
+
import { activeVendorMap } from './redirect-proxy.ts';
|
|
12
|
+
import { statusWorld } from './runtime.ts';
|
|
13
|
+
|
|
14
|
+
export const WORLD_REF_FILE = '.volter-world';
|
|
15
|
+
|
|
16
|
+
export type WorldRefSource = 'explicit' | 'env' | 'file';
|
|
17
|
+
|
|
18
|
+
export interface ResolvedWorldRef {
|
|
19
|
+
ref: string;
|
|
20
|
+
source: WorldRefSource;
|
|
21
|
+
/** set when source === 'file' — the .volter-world file that supplied the ref */
|
|
22
|
+
file?: string;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/** A world reference is remote when it is a URL; every other ref is a local world name. */
|
|
26
|
+
export function isRemoteWorldRef(ref: string): boolean {
|
|
27
|
+
return /^https?:\/\//i.test(ref);
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/** Find the nearest .volter-world file from `cwd` upward. First non-empty,
|
|
31
|
+
* non-comment line is the ref (à la .nvmrc). */
|
|
32
|
+
export function findWorldRefFile(cwd: string): { file: string; ref: string } | undefined {
|
|
33
|
+
let dir = resolve(cwd);
|
|
34
|
+
for (;;) {
|
|
35
|
+
const candidate = join(dir, WORLD_REF_FILE);
|
|
36
|
+
if (existsSync(candidate)) {
|
|
37
|
+
const line = readFileSync(candidate, 'utf8')
|
|
38
|
+
.split('\n')
|
|
39
|
+
.map((entry) => entry.trim())
|
|
40
|
+
.find((entry) => entry.length > 0 && !entry.startsWith('#'));
|
|
41
|
+
if (line) return { file: candidate, ref: line };
|
|
42
|
+
}
|
|
43
|
+
const parent = dirname(dir);
|
|
44
|
+
if (parent === dir) return undefined;
|
|
45
|
+
dir = parent;
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/** Resolution order (docs/guides/route-a-cli-through-the-world.md): explicit argument → VOLTER_WORLD env var →
|
|
50
|
+
* nearest committed .volter-world file. No silent machine-default fallback:
|
|
51
|
+
* a missing ref is an explicit, actionable error. */
|
|
52
|
+
export function resolveWorldRef(
|
|
53
|
+
explicit: string | undefined,
|
|
54
|
+
options: { cwd?: string; env?: Record<string, string | undefined> } = {},
|
|
55
|
+
): ResolvedWorldRef {
|
|
56
|
+
if (explicit !== undefined && explicit.length > 0) return { ref: explicit, source: 'explicit' };
|
|
57
|
+
const env = options.env ?? process.env;
|
|
58
|
+
const fromEnv = env.VOLTER_WORLD;
|
|
59
|
+
if (fromEnv !== undefined && fromEnv.trim().length > 0) return { ref: fromEnv.trim(), source: 'env' };
|
|
60
|
+
const fromFile = findWorldRefFile(options.cwd ?? process.cwd());
|
|
61
|
+
if (fromFile) return { ref: fromFile.ref, source: 'file', file: fromFile.file };
|
|
62
|
+
throw new Error(
|
|
63
|
+
`no world reference: pass one (volter-world attach <world> …), set VOLTER_WORLD, or commit a ${WORLD_REF_FILE} file`,
|
|
64
|
+
);
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/** The manifest (docs/guides/route-a-cli-through-the-world.md): what a world publishes to its attachers. */
|
|
68
|
+
export interface WorldManifest {
|
|
69
|
+
name: string;
|
|
70
|
+
/** vendor host → twin endpoint, exactly the map the injector/proxy redirect */
|
|
71
|
+
vendors: Record<string, string>;
|
|
72
|
+
/** session CA (PEM) when the world has one, else null */
|
|
73
|
+
ca: string | null;
|
|
74
|
+
/** attachment traffic proxy when present, else null */
|
|
75
|
+
proxy: string | null;
|
|
76
|
+
/** suggested fake credentials — the world env minus runtime internals */
|
|
77
|
+
env: Record<string, string>;
|
|
78
|
+
/** a hosted world's byte-stream doors, by service id: the WebSocket that carries the protocol and the
|
|
79
|
+
* env its client reads, `${host}`/`${port}` being the attacher's loopback bridge
|
|
80
|
+
* (@volter/world-core/stream-bridge) */
|
|
81
|
+
streams?: Record<string, { protocol: string; url: string; env: Record<string, string> }>;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
// Internal runtime vars are plumbing, not suggested credentials. CA-trust
|
|
85
|
+
// bundle vars are local renderings of `ca` (they hold machine-local paths), so
|
|
86
|
+
// a manifest never carries them — an attacher re-renders trust from `ca`.
|
|
87
|
+
const MANIFEST_ENV_EXCLUDED =
|
|
88
|
+
/^(volter_world_|node_options$|twin_inject$|no_proxy$|https?_proxy$|node_extra_ca_certs$|ssl_cert_file$|(aws|curl|requests)_ca_bundle$)/i;
|
|
89
|
+
|
|
90
|
+
/** Derive the manifest from a world's generated env map (the local rendering).
|
|
91
|
+
* Pure — the CA is inlined only when its file is readable. */
|
|
92
|
+
export function deriveWorldManifest(name: string, worldEnv: Record<string, string | undefined>): WorldManifest {
|
|
93
|
+
const vendors = activeVendorMap(worldEnv);
|
|
94
|
+
const caPath = worldEnv.VOLTER_WORLD_CA;
|
|
95
|
+
let ca: string | null = null;
|
|
96
|
+
if (caPath && existsSync(caPath)) {
|
|
97
|
+
try {
|
|
98
|
+
ca = readFileSync(caPath, 'utf8');
|
|
99
|
+
} catch {
|
|
100
|
+
ca = null;
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
const env: Record<string, string> = {};
|
|
104
|
+
for (const [key, value] of Object.entries(worldEnv)) {
|
|
105
|
+
if (value === undefined) continue;
|
|
106
|
+
if (MANIFEST_ENV_EXCLUDED.test(key)) continue;
|
|
107
|
+
if (/_TWIN_URL$/.test(key)) continue; // rendered as `vendors`, not credentials
|
|
108
|
+
env[key] = value;
|
|
109
|
+
}
|
|
110
|
+
return { name, vendors, ca, proxy: worldEnv.VOLTER_WORLD_PROXY ?? null, env };
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/** The manifest of a local world instance (name resolved against `root` like every other verb). */
|
|
114
|
+
export function worldManifest(name: string, root = process.cwd()): WorldManifest {
|
|
115
|
+
const status = statusWorld(name, root);
|
|
116
|
+
return deriveWorldManifest(name, status.env);
|
|
117
|
+
}
|
package/src/branch.ts
ADDED
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
// `volter-world branch` and `checkout` — a branch is a base position in the mirror plus its own
|
|
2
|
+
// log of changes, and a world is a branch with compute attached (docs/concepts/the-model.md).
|
|
3
|
+
// `branch <world> <name>` boots a new world from the base's config and forks every twin's mirror
|
|
4
|
+
// into it (core's `forkTwin`: a POINTER to the base at its current position — the base's whole
|
|
5
|
+
// history, its own entries included, is the branch's parent; nothing is copied). `checkout <name>`
|
|
6
|
+
// brings a stopped world back up from its own instance record, state intact.
|
|
7
|
+
import { existsSync, readdirSync } from 'node:fs';
|
|
8
|
+
import { dirname, join, resolve } from 'node:path';
|
|
9
|
+
import { captureHistory, historyAtInstant, forkTwin, stateGeneration, withAncestryLock, worldPaths, worldStateRoot } from '@volter/world-core';
|
|
10
|
+
import { downWorld, saveWorldInstance, statusWorld, upWorld } from './runtime.ts';
|
|
11
|
+
import type { WorldInstance } from './schema.ts';
|
|
12
|
+
|
|
13
|
+
export type BranchOptions = { root?: string; envFile?: string; now?: Date; /** branch from a point in the base's history: an instant, or a position per twin (contract "Just like Neon", 3) */ at?: { instant?: string; positions?: Record<string, number>; views?: Record<string, string> } };
|
|
14
|
+
|
|
15
|
+
function stateServices(controlRoot: string): string[] {
|
|
16
|
+
const stateRoot = worldStateRoot(controlRoot);
|
|
17
|
+
if (!existsSync(stateRoot)) return [];
|
|
18
|
+
return readdirSync(stateRoot, { withFileTypes: true }).filter((e) => e.isDirectory() && ['events.jsonl', 'actions.jsonl', 'branch.json'].some((f) => existsSync(join(stateRoot, e.name, f)))).map((e) => e.name).sort();
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/** A new world over the base's mirrors. */
|
|
22
|
+
export async function branchWorld(base: string, name: string, options: BranchOptions = {}): Promise<{ instance: WorldInstance; forked: Record<string, string[]> }> {
|
|
23
|
+
const root = resolve(options.root ?? process.cwd());
|
|
24
|
+
const from = statusWorld(base, root);
|
|
25
|
+
if (name === base) throw new Error(`volter-world branch: the branch needs a name other than the base's ("${base}")`);
|
|
26
|
+
const envFile = options.envFile ?? join(dirname(from.envFile), `${name}.world.env`);
|
|
27
|
+
const at = (options.now ?? new Date()).toISOString();
|
|
28
|
+
// Capture source identity before awaiting child compute. A concurrent reset may win;
|
|
29
|
+
// in that case forking fails and the task-owned child is torn down, never an empty success.
|
|
30
|
+
const source = withAncestryLock(() => Object.keys(from.services).sort().map(service => {
|
|
31
|
+
const fromRoot = join(from.dirs.data, service);
|
|
32
|
+
return { service, fromRoot, states: stateServices(fromRoot).map(state => {
|
|
33
|
+
let view = options.at?.views?.[service] ?? options.at?.views?.[state] ?? captureHistory(state, fromRoot).view;
|
|
34
|
+
const timed = options.at?.instant ? historyAtInstant(state, options.at.instant, fromRoot, view) : undefined;
|
|
35
|
+
if (timed) view = timed.view;
|
|
36
|
+
const position = options.at?.positions?.[service] ?? options.at?.positions?.[state] ?? timed?.position;
|
|
37
|
+
return { state, view, position, generation: stateGeneration(worldPaths(state, fromRoot).dir) };
|
|
38
|
+
}) };
|
|
39
|
+
}));
|
|
40
|
+
const instance = await upWorld(existsSync(from.configPath) ? from.configPath : from.config, { name, root, mode: from.mode, envFile });
|
|
41
|
+
const forked: Record<string, string[]> = {};
|
|
42
|
+
try {
|
|
43
|
+
if (from.origin) { instance.origin = { ...from.origin }; saveWorldInstance(instance); }
|
|
44
|
+
for (const { service, fromRoot, states } of source) {
|
|
45
|
+
const toRoot = join(instance.dirs.data, service);
|
|
46
|
+
for (const { state, generation, view, position: pos } of states) {
|
|
47
|
+
forkTwin({ service: state, toRoot, fromRoot, view, expectedGeneration: generation, occurredAt: at, ...(pos !== undefined ? { at: pos } : {}) });
|
|
48
|
+
}
|
|
49
|
+
if (states.length) forked[service] = states.map(s => s.state);
|
|
50
|
+
}
|
|
51
|
+
} catch (error) {
|
|
52
|
+
await downWorld(name, root, { purge: true, expectedCreatedAt: instance.createdAt });
|
|
53
|
+
throw error;
|
|
54
|
+
}
|
|
55
|
+
return { instance, forked };
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/** A stopped world back up, from its own record. */
|
|
59
|
+
export async function checkoutWorld(name: string, options: { root?: string } = {}): Promise<WorldInstance> {
|
|
60
|
+
const root = resolve(options.root ?? process.cwd());
|
|
61
|
+
const was = statusWorld(name, root);
|
|
62
|
+
return upWorld(existsSync(was.configPath) ? was.configPath : was.config, { name, root, mode: was.mode, envFile: was.envFile, keepState: true });
|
|
63
|
+
}
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// @ts-nocheck
|
|
3
|
+
import { keepProcessAlive } from '@volter/world-core/lifecycle';
|
|
4
|
+
// Browser proxy launcher for fullstack app worlds. This is APP/WORLD orchestration glue
|
|
5
|
+
// (it wires specific app vendors + app URLs from env), so it lives in @volter/world-runtime,
|
|
6
|
+
// NOT in the vendor-agnostic kernel — control-plane must never name a vendor (A2). The kernel
|
|
7
|
+
// only exports the vendor-INDEPENDENT `createTwinProxy` utility; choosing which vendors a given
|
|
8
|
+
// app world proxies is the orchestrator's job.
|
|
9
|
+
import { createTwinProxy } from '@volter/world-core';
|
|
10
|
+
|
|
11
|
+
const port = Number(process.env.PORT || 0);
|
|
12
|
+
const target = process.env.TARGET_URL;
|
|
13
|
+
if (!target) throw new Error('TARGET_URL is required');
|
|
14
|
+
|
|
15
|
+
const map = {};
|
|
16
|
+
if (process.env.CLERK_TWIN_URL) {
|
|
17
|
+
map.clerk = {
|
|
18
|
+
origin: process.env.CLERK_TWIN_URL,
|
|
19
|
+
apiPathPrefix: '/v1/',
|
|
20
|
+
loaderHost: 'https://api.clerk.com',
|
|
21
|
+
};
|
|
22
|
+
map.clerkLoader = {
|
|
23
|
+
origin: process.env.CLERK_TWIN_URL,
|
|
24
|
+
apiPathPrefix: '/npm/',
|
|
25
|
+
};
|
|
26
|
+
}
|
|
27
|
+
if (process.env.RUNHUMAN_API_URL || process.env.API_URL) {
|
|
28
|
+
map.runhumanApi = {
|
|
29
|
+
origin: process.env.RUNHUMAN_API_URL || process.env.API_URL,
|
|
30
|
+
apiPathPrefix: '/api',
|
|
31
|
+
};
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
const redirectOrigins = [
|
|
35
|
+
process.env.RUNHUMAN_WEB_URL,
|
|
36
|
+
process.env.RUNHUMAN_LEARN_URL,
|
|
37
|
+
process.env.RUNHUMAN_STATIC_URL,
|
|
38
|
+
process.env.RUNHUMAN_API_URL,
|
|
39
|
+
process.env.API_URL,
|
|
40
|
+
].filter(Boolean);
|
|
41
|
+
|
|
42
|
+
const proxy = createTwinProxy({ target, map, redirectOrigins, port });
|
|
43
|
+
process.stdout.write(`[browser-proxy] http://127.0.0.1:${proxy.port} -> ${target} (${Object.keys(map).join(', ') || 'no browser vendors'})\n`);
|
|
44
|
+
await keepProcessAlive();
|
package/src/ca-trust.ts
ADDED
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
import { createHash, randomUUID, X509Certificate } from 'node:crypto';
|
|
2
|
+
import { existsSync, readFileSync, writeFileSync, renameSync, rmSync } from 'node:fs';
|
|
3
|
+
import { dirname, join } from 'node:path';
|
|
4
|
+
import * as tls from 'node:tls';
|
|
5
|
+
|
|
6
|
+
export class CaTrustError extends Error {}
|
|
7
|
+
export type CaTrustInputs = NodeJS.ProcessEnv | NodeJS.ProcessEnv[];
|
|
8
|
+
|
|
9
|
+
/** Per-client trust, materialized only when a caller attaches. Never modify the signing CA. */
|
|
10
|
+
export function sessionTrustEnv(caCertPath: string, inputs: CaTrustInputs = process.env): Record<string, string> {
|
|
11
|
+
const callers = Array.isArray(inputs) ? inputs : [inputs];
|
|
12
|
+
const files = new Map<string, string>();
|
|
13
|
+
const read = (path: string): string => {
|
|
14
|
+
if (files.has(path)) return files.get(path)!;
|
|
15
|
+
try {
|
|
16
|
+
const contents = readFileSync(path, 'utf8');
|
|
17
|
+
const pattern = /-----BEGIN CERTIFICATE-----[\s\S]*?-----END CERTIFICATE-----/g;
|
|
18
|
+
const certificates = contents.match(pattern) ?? [];
|
|
19
|
+
if (!certificates.length || /-----BEGIN|-----END/.test(contents.replace(pattern, ''))) throw new Error('expected complete PEM certificates');
|
|
20
|
+
const pem = certificates.map(cert => new X509Certificate(cert).toString().trim()).join('\n');
|
|
21
|
+
files.set(path, pem);
|
|
22
|
+
return pem;
|
|
23
|
+
}
|
|
24
|
+
catch (error) { throw new CaTrustError(`Cannot read configured CA file ${path}: ${String(error)}`); }
|
|
25
|
+
};
|
|
26
|
+
const session = read(caCertPath);
|
|
27
|
+
const extra = callers.flatMap(caller => caller.NODE_EXTRA_CA_CERTS ? [read(caller.NODE_EXTRA_CA_CERTS)] : []).join('\n');
|
|
28
|
+
// getCACertificates is newer than our minimum Node version; bundled roots also cover callers
|
|
29
|
+
// whose current OpenSSL defaults have already been replaced by a session-only CA file.
|
|
30
|
+
const defaults = typeof tls.getCACertificates === 'function' ? tls.getCACertificates('default') : [];
|
|
31
|
+
const publicRoots = [...new Set([...tls.rootCertificates, ...defaults].map(cert => new X509Certificate(cert).toString().trim()))].join('\n');
|
|
32
|
+
const bundle = (contents: string): string => {
|
|
33
|
+
const certificates = contents.match(/-----BEGIN CERTIFICATE-----[\s\S]*?-----END CERTIFICATE-----/g) ?? [];
|
|
34
|
+
const pem = `${[...new Set(certificates.map(cert => cert.trim()))].sort().join('\n')}\n`;
|
|
35
|
+
const path = join(dirname(caCertPath), `trust-${createHash('sha256').update(pem).digest('hex')}.pem`);
|
|
36
|
+
const temporary = `${path}.${randomUUID()}.tmp`;
|
|
37
|
+
try {
|
|
38
|
+
if (!existsSync(path) || readFileSync(path, 'utf8') !== pem) {
|
|
39
|
+
writeFileSync(temporary, pem, { flag: 'wx', mode: 0o600 });
|
|
40
|
+
renameSync(temporary, path);
|
|
41
|
+
}
|
|
42
|
+
} catch (error) {
|
|
43
|
+
try { rmSync(temporary, { force: true }); }
|
|
44
|
+
catch (cleanupError) { throw new CaTrustError(`Cannot materialize session trust bundle: ${String(error)}; cleanup: ${String(cleanupError)}`); }
|
|
45
|
+
throw new CaTrustError(`Cannot materialize session trust bundle: ${String(error)}`);
|
|
46
|
+
}
|
|
47
|
+
return path;
|
|
48
|
+
};
|
|
49
|
+
const env: Record<string, string> = {
|
|
50
|
+
NODE_EXTRA_CA_CERTS: extra ? bundle(`${extra}\n${session}`) : caCertPath,
|
|
51
|
+
};
|
|
52
|
+
for (const key of ['SSL_CERT_FILE', 'REQUESTS_CA_BUNDLE', 'CURL_CA_BUNDLE', 'AWS_CA_BUNDLE', 'GIT_SSL_CAINFO']) {
|
|
53
|
+
const configured = callers.flatMap(caller => caller[key] ? [read(caller[key]!)] : []).join('\n');
|
|
54
|
+
env[key] = bundle(`${publicRoots}\n${extra}\n${configured}\n${session}`);
|
|
55
|
+
}
|
|
56
|
+
return env;
|
|
57
|
+
}
|