@volter/twin-world 0.1.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/package.json +61 -0
- package/src/browser-proxy-cli.ts +43 -0
- package/src/cli.ts +226 -0
- package/src/configs.ts +24 -0
- package/src/host-cli.ts +86 -0
- package/src/host-worker.ts +22 -0
- package/src/host.ts +142 -0
- package/src/index.ts +42 -0
- package/src/prerequisites.ts +109 -0
- package/src/proxy-daemon.ts +21 -0
- package/src/redirect-proxy.ts +437 -0
- package/src/runtime.ts +1888 -0
- package/src/schema.ts +471 -0
package/src/host.ts
ADDED
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
// Co-located twin host — run many twins in ONE process instead of one OS process
|
|
2
|
+
// per twin. The twin core is a pure, transport-agnostic handler (see a pack's
|
|
3
|
+
// `handle<Vendor>TwinRequest`), so nothing requires a process per twin; this host
|
|
4
|
+
// mounts N of them behind their own ports/URLs (the world env + instance.json are
|
|
5
|
+
// byte-identical to the spawned path — only the process model changes).
|
|
6
|
+
//
|
|
7
|
+
// Isolation is a DIAL, not a binary (the runtime stays minimal — this is still just
|
|
8
|
+
// "bring services up / tear them down", one child process):
|
|
9
|
+
// - 'shared' : all twins share one event loop + heap. Lightest. A thrown request
|
|
10
|
+
// handler is isolated (Bun.serve returns 500; siblings serve on), but a
|
|
11
|
+
// CPU hog / OOM / process.exit in one twin hits all. Default for CI/dev.
|
|
12
|
+
// - 'worker' : one Worker thread per twin → own event loop + own heap, independently
|
|
13
|
+
// isolated. A sibling can spin, leak, or hard-exit without taking the
|
|
14
|
+
// host down — but a crashed twin stays down (doctor red), it is never
|
|
15
|
+
// respawned. Still ONE OS process to the orchestrator.
|
|
16
|
+
// ('process' isolation — one OS process per twin — is the world runtime's existing
|
|
17
|
+
// spawn path; it remains the oracle and the choice for share/sealed/hosted worlds.)
|
|
18
|
+
//
|
|
19
|
+
// ARCHITECTURE: the runtime never imports vendor packs. A twin is named by a module
|
|
20
|
+
// SPECIFIER + export resolved with dynamic import() at boot, so this host (and the
|
|
21
|
+
// world config that drives it) stays vendor-agnostic — exactly like `bin` spawning.
|
|
22
|
+
import { Worker } from 'node:worker_threads';
|
|
23
|
+
|
|
24
|
+
/** One twin to mount in the host. `module`/`export` name a `({port,root,readOnly}) => {port,stop}`
|
|
25
|
+
* factory (every pack ships one, e.g. `createStripeTwinServer`). `module` is anything import()
|
|
26
|
+
* resolves: a package name (`@volter/twin-stripe`) or an absolute file path (tests/fixtures). */
|
|
27
|
+
export type ColocatedTwinSpec = {
|
|
28
|
+
id: string;
|
|
29
|
+
module: string;
|
|
30
|
+
export: string;
|
|
31
|
+
port: number;
|
|
32
|
+
root: string;
|
|
33
|
+
readOnly?: boolean;
|
|
34
|
+
};
|
|
35
|
+
|
|
36
|
+
export type HostIsolation = 'shared' | 'worker';
|
|
37
|
+
|
|
38
|
+
export type ColocatedHost = {
|
|
39
|
+
isolation: HostIsolation;
|
|
40
|
+
/** Resolved listening port per twin id (equals the requested port). */
|
|
41
|
+
ports: Record<string, number>;
|
|
42
|
+
stop: () => Promise<void>;
|
|
43
|
+
};
|
|
44
|
+
|
|
45
|
+
type TwinServer = { port: number; stop: () => void };
|
|
46
|
+
type TwinServerFactory = (opts: { port?: number; root?: string; readOnly?: boolean }) => TwinServer;
|
|
47
|
+
|
|
48
|
+
async function loadFactory(spec: ColocatedTwinSpec): Promise<TwinServerFactory> {
|
|
49
|
+
const mod = (await import(spec.module)) as Record<string, unknown>;
|
|
50
|
+
const factory = mod[spec.export];
|
|
51
|
+
if (typeof factory !== 'function') {
|
|
52
|
+
throw new Error(`Twin "${spec.id}": ${spec.module} has no factory export "${spec.export}"`);
|
|
53
|
+
}
|
|
54
|
+
return factory as TwinServerFactory;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
const workerEntry = new URL('./host-worker.ts', import.meta.url);
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* Start a co-located host. Resolves once every twin is listening. The returned `stop()`
|
|
61
|
+
* tears the whole host down (all servers / all workers).
|
|
62
|
+
*
|
|
63
|
+
* `onEvent` is an optional observability hook (logged by the CLI) — it surfaces a twin
|
|
64
|
+
* worker crashing (and being given up on), and stray rejections swallowed in shared mode,
|
|
65
|
+
* so a degraded twin is never a silent fake-success.
|
|
66
|
+
*/
|
|
67
|
+
export async function startColocatedHost(
|
|
68
|
+
specs: ColocatedTwinSpec[],
|
|
69
|
+
options: { isolation?: HostIsolation; onEvent?: (e: { twin: string; kind: string; detail?: string; exits?: number }) => void } = {},
|
|
70
|
+
): Promise<ColocatedHost> {
|
|
71
|
+
const isolation = options.isolation ?? 'shared';
|
|
72
|
+
const onEvent = options.onEvent ?? (() => {});
|
|
73
|
+
const ports: Record<string, number> = {};
|
|
74
|
+
for (const spec of specs) ports[spec.id] = spec.port;
|
|
75
|
+
|
|
76
|
+
if (isolation === 'shared') {
|
|
77
|
+
// Keep one twin's stray async rejection / uncaught throw from killing every other twin in
|
|
78
|
+
// the shared process. (A thrown *request* handler is already contained by Bun.serve → 500.)
|
|
79
|
+
// We log and attribute rather than exit — state stays consistent because every kernel write
|
|
80
|
+
// is atomic (temp→rename) and root-keyed, so a swallowed error can't corrupt a sibling.
|
|
81
|
+
const onRejection = (reason: unknown) => onEvent({ twin: '(host)', kind: 'unhandledRejection', detail: String(reason) });
|
|
82
|
+
const onUncaught = (err: unknown) => onEvent({ twin: '(host)', kind: 'uncaughtException', detail: String(err) });
|
|
83
|
+
process.on('unhandledRejection', onRejection);
|
|
84
|
+
process.on('uncaughtException', onUncaught);
|
|
85
|
+
|
|
86
|
+
const servers: TwinServer[] = [];
|
|
87
|
+
for (const spec of specs) {
|
|
88
|
+
const factory = await loadFactory(spec);
|
|
89
|
+
servers.push(factory({ port: spec.port, root: spec.root, readOnly: spec.readOnly }));
|
|
90
|
+
}
|
|
91
|
+
return {
|
|
92
|
+
isolation,
|
|
93
|
+
ports,
|
|
94
|
+
stop: async () => {
|
|
95
|
+
process.off('unhandledRejection', onRejection);
|
|
96
|
+
process.off('uncaughtException', onUncaught);
|
|
97
|
+
for (const s of servers) s.stop();
|
|
98
|
+
},
|
|
99
|
+
};
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
// worker isolation — one Worker thread per twin, given up on crash — never respawned.
|
|
103
|
+
const stopping = { value: false };
|
|
104
|
+
const workers = new Map<string, Worker>();
|
|
105
|
+
|
|
106
|
+
const spawnWorker = (spec: ColocatedTwinSpec): Promise<void> =>
|
|
107
|
+
new Promise<void>((resolveReady, rejectReady) => {
|
|
108
|
+
const worker = new Worker(workerEntry, { workerData: spec });
|
|
109
|
+
// Pure observability, symmetric with `gaveup` below — NOT a supervision hook. Fired exactly
|
|
110
|
+
// once per spawnWorker() call (there's no counter/timer/keep-alive here), so it doubles as a
|
|
111
|
+
// direct tamper guard: a stealth respawn re-adding `spawnWorker(spec)` in the exit handler
|
|
112
|
+
// below would emit a 2nd `spawn` for the same twin, which the isolation test asserts against.
|
|
113
|
+
onEvent({ twin: spec.id, kind: 'spawn', detail: 'worker started' });
|
|
114
|
+
let ready = false;
|
|
115
|
+
worker.on('message', (msg: { type?: string }) => {
|
|
116
|
+
if (msg?.type === 'ready') { ready = true; resolveReady(); }
|
|
117
|
+
});
|
|
118
|
+
worker.on('error', (err) => {
|
|
119
|
+
onEvent({ twin: spec.id, kind: 'error', detail: String(err) });
|
|
120
|
+
if (!ready) rejectReady(err);
|
|
121
|
+
});
|
|
122
|
+
worker.on('exit', (code) => {
|
|
123
|
+
workers.delete(spec.id);
|
|
124
|
+
if (stopping.value || code === 0) return;
|
|
125
|
+
// Minimal-primitive doctrine: no keep-alive supervisor. A crashed worker is NOT
|
|
126
|
+
// respawned — record the give-up (host-cli persists it → doctor red) and stay dead.
|
|
127
|
+
onEvent({ twin: spec.id, kind: 'gaveup', detail: `exit code ${code} — not restarted`, exits: 1 });
|
|
128
|
+
});
|
|
129
|
+
workers.set(spec.id, worker);
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
for (const spec of specs) await spawnWorker(spec);
|
|
133
|
+
|
|
134
|
+
return {
|
|
135
|
+
isolation,
|
|
136
|
+
ports,
|
|
137
|
+
stop: async () => {
|
|
138
|
+
stopping.value = true;
|
|
139
|
+
await Promise.all([...workers.values()].map((w) => w.terminate()));
|
|
140
|
+
},
|
|
141
|
+
};
|
|
142
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
export type {
|
|
2
|
+
WorldConfig,
|
|
3
|
+
WorldInstance,
|
|
4
|
+
WorldIsolation,
|
|
5
|
+
WorldMode,
|
|
6
|
+
WorldServiceConfig,
|
|
7
|
+
WorldServiceType,
|
|
8
|
+
WorldExternalServiceConfig,
|
|
9
|
+
WorldExternalDiscover,
|
|
10
|
+
WorldExternalDiscoverSource,
|
|
11
|
+
WorldExternalReadyWhen,
|
|
12
|
+
} from './schema.ts';
|
|
13
|
+
export { loadWorldConfig, resolveConfigPath } from './configs.ts';
|
|
14
|
+
export {
|
|
15
|
+
upWorld,
|
|
16
|
+
downWorld,
|
|
17
|
+
listWorlds,
|
|
18
|
+
statusWorld,
|
|
19
|
+
doctorWorld,
|
|
20
|
+
runWithWorldEnv,
|
|
21
|
+
activateScript,
|
|
22
|
+
shellWorld,
|
|
23
|
+
worldShellEnv,
|
|
24
|
+
ensureWorldProxy,
|
|
25
|
+
runProxyDaemon,
|
|
26
|
+
runWorld,
|
|
27
|
+
shareWorld,
|
|
28
|
+
shareWorldServices,
|
|
29
|
+
unshareWorld,
|
|
30
|
+
urlsWorld,
|
|
31
|
+
} from './runtime.ts';
|
|
32
|
+
export {
|
|
33
|
+
checkDockerRuntime,
|
|
34
|
+
checkPrerequisites,
|
|
35
|
+
formatPrerequisiteChecks,
|
|
36
|
+
} from './prerequisites.ts';
|
|
37
|
+
export type {
|
|
38
|
+
PrerequisiteCheck,
|
|
39
|
+
PrerequisiteId,
|
|
40
|
+
} from './prerequisites.ts';
|
|
41
|
+
export { startColocatedHost } from './host.ts';
|
|
42
|
+
export type { ColocatedTwinSpec, ColocatedHost, HostIsolation } from './host.ts';
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
import { existsSync } from 'node:fs';
|
|
2
|
+
import { homedir } from 'node:os';
|
|
3
|
+
import { join } from 'node:path';
|
|
4
|
+
import { spawnSync } from 'node:child_process';
|
|
5
|
+
|
|
6
|
+
export type PrerequisiteId = 'docker';
|
|
7
|
+
|
|
8
|
+
export type PrerequisiteCheck = {
|
|
9
|
+
id: PrerequisiteId;
|
|
10
|
+
ok: boolean;
|
|
11
|
+
label: string;
|
|
12
|
+
message: string;
|
|
13
|
+
guidance: string[];
|
|
14
|
+
details?: Record<string, string>;
|
|
15
|
+
};
|
|
16
|
+
|
|
17
|
+
type RunResult = { status: number | null; stdout: string; stderr: string };
|
|
18
|
+
type Runner = (cmd: string, args: string[]) => RunResult;
|
|
19
|
+
|
|
20
|
+
const realRunner: Runner = (cmd, args) => {
|
|
21
|
+
const result = spawnSync(cmd, args, { encoding: 'utf8' });
|
|
22
|
+
return {
|
|
23
|
+
status: result.status,
|
|
24
|
+
stdout: result.stdout || '',
|
|
25
|
+
stderr: result.stderr || '',
|
|
26
|
+
};
|
|
27
|
+
};
|
|
28
|
+
|
|
29
|
+
function trim(value: string): string {
|
|
30
|
+
return value.trim();
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function dockerContextHost(runner: Runner): { context?: string; host?: string } {
|
|
34
|
+
const contextResult = runner('docker', ['context', 'show']);
|
|
35
|
+
const context = contextResult.status === 0 ? trim(contextResult.stdout) : undefined;
|
|
36
|
+
if (!context) return {};
|
|
37
|
+
|
|
38
|
+
const inspect = runner('docker', ['context', 'inspect', context]);
|
|
39
|
+
if (inspect.status !== 0 || !inspect.stdout.trim()) return { context };
|
|
40
|
+
try {
|
|
41
|
+
const parsed = JSON.parse(inspect.stdout) as Array<{ Endpoints?: { docker?: { Host?: string } } }>;
|
|
42
|
+
return { context, host: parsed[0]?.Endpoints?.docker?.Host };
|
|
43
|
+
} catch {
|
|
44
|
+
return { context };
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function commonDockerGuidance(details: Record<string, string>): string[] {
|
|
49
|
+
const guidance = [
|
|
50
|
+
'Start the computer-native Docker-compatible runtime before running this world.',
|
|
51
|
+
'If you are an AI agent running this command, it is your job to start that local runtime now; do not hand this back to the user unless the runtime cannot be started from the machine.',
|
|
52
|
+
'Agents should boot the runtime installed on this machine: `colima start` for Colima, open/start Docker Desktop for Docker Desktop, or start the native Docker service on Linux.',
|
|
53
|
+
];
|
|
54
|
+
|
|
55
|
+
if (details.context || details.host) {
|
|
56
|
+
guidance.push(`Active Docker context${details.context ? ` '${details.context}'` : ''}${details.host ? ` points at ${details.host}` : ''}.`);
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
const colimaSocket = join(homedir(), '.colima', 'default', 'docker.sock');
|
|
60
|
+
if (existsSync(colimaSocket)) {
|
|
61
|
+
guidance.push(`A Colima socket exists at ${colimaSocket}; if the Docker context points elsewhere, update the context or export DOCKER_HOST=unix://${colimaSocket}.`);
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
return guidance;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
export function checkDockerRuntime(runner: Runner = realRunner): PrerequisiteCheck {
|
|
68
|
+
const info = runner('docker', ['info']);
|
|
69
|
+
if (info.status === 0) {
|
|
70
|
+
return {
|
|
71
|
+
id: 'docker',
|
|
72
|
+
ok: true,
|
|
73
|
+
label: 'Docker-compatible daemon',
|
|
74
|
+
message: 'Docker-compatible daemon is reachable.',
|
|
75
|
+
guidance: [],
|
|
76
|
+
};
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
const context = dockerContextHost(runner);
|
|
80
|
+
const details: Record<string, string> = {};
|
|
81
|
+
if (context.context) details.context = context.context;
|
|
82
|
+
if (context.host) details.host = context.host;
|
|
83
|
+
if (info.stderr.trim()) details.error = info.stderr.trim();
|
|
84
|
+
|
|
85
|
+
return {
|
|
86
|
+
id: 'docker',
|
|
87
|
+
ok: false,
|
|
88
|
+
label: 'Docker-compatible daemon',
|
|
89
|
+
message: 'Docker-compatible daemon is not reachable.',
|
|
90
|
+
guidance: commonDockerGuidance(details),
|
|
91
|
+
details,
|
|
92
|
+
};
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
export function checkPrerequisites(ids: PrerequisiteId[], runner: Runner = realRunner): PrerequisiteCheck[] {
|
|
96
|
+
return ids.map((id) => {
|
|
97
|
+
if (id === 'docker') return checkDockerRuntime(runner);
|
|
98
|
+
throw new Error(`Unknown prerequisite: ${id}`);
|
|
99
|
+
});
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
export function formatPrerequisiteChecks(checks: PrerequisiteCheck[]): string {
|
|
103
|
+
return checks.flatMap((check) => {
|
|
104
|
+
const status = check.ok ? 'ok' : 'missing';
|
|
105
|
+
const lines = [`${status} ${check.label}: ${check.message}`];
|
|
106
|
+
if (!check.ok) lines.push(...check.guidance.map((line) => ` ${line}`));
|
|
107
|
+
return lines;
|
|
108
|
+
}).join('\n');
|
|
109
|
+
}
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
#!/usr/bin/env bun
|
|
2
|
+
// Detached entry for the ambient TLS redirect proxy. `activate` spawns this as its own process so a
|
|
3
|
+
// long-lived proxy can serve the activated shell(s) for the world's lifetime; it records its
|
|
4
|
+
// pid/url/CA in the instance dir and is stopped by `volter-world down`. Not a user-facing command.
|
|
5
|
+
import { runProxyDaemon } from './runtime.ts';
|
|
6
|
+
|
|
7
|
+
const [name, ...rest] = process.argv.slice(2);
|
|
8
|
+
const rootIndex = rest.indexOf('--root');
|
|
9
|
+
const root = rootIndex >= 0 && rest[rootIndex + 1] ? rest[rootIndex + 1]! : process.cwd();
|
|
10
|
+
const envFileIndex = rest.indexOf('--env-file');
|
|
11
|
+
const envFile = envFileIndex >= 0 && rest[envFileIndex + 1] ? rest[envFileIndex + 1]! : undefined;
|
|
12
|
+
|
|
13
|
+
if (!name) {
|
|
14
|
+
process.stderr.write('proxy-daemon: missing world name\n');
|
|
15
|
+
process.exit(1);
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
runProxyDaemon(name, root, envFile).catch((error) => {
|
|
19
|
+
process.stderr.write(`proxy-daemon: ${error instanceof Error ? error.message : String(error)}\n`);
|
|
20
|
+
process.exit(1);
|
|
21
|
+
});
|