@volter/world-runtime 2.0.0 → 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.
- package/dist/known-external-services.json +0 -8
- package/dist/src/app-url.js +1 -1
- package/dist/src/branch.js +79 -2
- package/dist/src/catalog.js +1 -1
- package/dist/src/cli.js +50 -22
- package/dist/src/console-apart.d.ts +1 -0
- package/dist/src/console-apart.js +7 -0
- package/dist/src/covers.js +4 -2
- package/dist/src/fixture-env.d.ts +3 -0
- package/dist/src/fixture-env.js +24 -0
- package/dist/src/host-worker.js +2 -9
- package/dist/src/host.js +2 -9
- package/dist/src/import-module.d.ts +1 -0
- package/dist/src/import-module.js +16 -0
- package/dist/src/index.d.ts +7 -2
- package/dist/src/index.js +4 -1
- package/dist/src/infra-cli.js +61 -12
- package/dist/src/init.d.ts +1 -1
- package/dist/src/init.js +34 -5
- package/dist/src/local-branches.d.ts +37 -0
- package/dist/src/local-branches.js +193 -0
- package/dist/src/pglite-backing.d.ts +8 -0
- package/dist/src/pglite-backing.js +121 -35
- package/dist/src/pglite-host.mjs +520 -14
- package/dist/src/prerequisites.js +1 -1
- package/dist/src/process-groups.js +1 -1
- package/dist/src/redirect-proxy.d.ts +1 -1
- package/dist/src/redirect-proxy.js +4 -4
- package/dist/src/redis-backing.d.ts +8 -0
- package/dist/src/redis-backing.js +120 -0
- package/dist/src/root.d.ts +23 -0
- package/dist/src/root.js +22 -10
- package/dist/src/run-task.js +1 -1
- package/dist/src/runtime.d.ts +1 -0
- package/dist/src/runtime.js +37 -20
- package/dist/src/schema.d.ts +5 -0
- package/dist/src/schema.js +10 -1
- package/dist/src/served-world.d.ts +196 -9
- package/dist/src/served-world.js +847 -103
- package/dist/src/service-recorder.js +1 -1
- package/dist/src/storage-capacity.js +2 -2
- package/dist/src/up-task.js +1 -1
- package/dist/src/world-origins.d.ts +13 -0
- package/dist/src/world-origins.js +37 -0
- package/dist/src/world-view.d.ts +25 -0
- package/dist/src/world-view.js +110 -0
- package/known-external-services.json +0 -8
- package/package.json +10 -4
- package/src/app-url.ts +1 -1
- package/src/branch.ts +66 -2
- package/src/catalog.ts +1 -1
- package/src/cli.ts +43 -21
- package/src/console-apart.ts +7 -1
- package/src/covers.ts +4 -2
- package/src/fixture-env.ts +25 -0
- package/src/host-worker.ts +2 -1
- package/src/host.ts +2 -1
- package/src/import-module.ts +9 -0
- package/src/index.ts +7 -2
- package/src/infra-cli.ts +56 -12
- package/src/init.ts +34 -5
- package/src/local-branches.ts +173 -0
- package/src/pglite-backing.ts +112 -36
- package/src/pglite-host.mjs +520 -14
- package/src/prerequisites.ts +1 -1
- package/src/process-groups.ts +1 -1
- package/src/redirect-proxy.ts +4 -4
- package/src/redis-backing.ts +107 -0
- package/src/root.ts +27 -2
- package/src/run-task.ts +1 -1
- package/src/runtime.ts +38 -20
- package/src/schema.ts +11 -1
- package/src/served-world.ts +762 -85
- package/src/service-recorder.ts +1 -1
- package/src/storage-capacity.ts +2 -2
- package/src/up-task.ts +1 -1
- package/src/world-origins.ts +38 -0
- package/src/world-view.ts +100 -0
package/src/fixture-env.ts
CHANGED
|
@@ -152,6 +152,11 @@ export function isGoogleServiceAccountEnvName(name: string): boolean {
|
|
|
152
152
|
* world-runtime must not import a pack, and the tests pin every value against the injector's actual
|
|
153
153
|
* VENDOR_HOSTS predicate so either side changing makes the contract fail loudly. */
|
|
154
154
|
const ENDPOINT_SHAPES: Array<{ match: RegExp; value: string; source: string }> = [
|
|
155
|
+
// the vendor's own API URL, which the World routes to its twin: an app that reads a base-URL override (empty in its
|
|
156
|
+
// example, the vendor's URL as its default) gets the vendor's URL, never an unreachable fake
|
|
157
|
+
{ match: /^DYNADOT_BASE_URL$/, value: 'https://api.dynadot.com/api3.json', source: 'packages/twin/dynadot/src/index.ts hosts (api.dynadot.com); dubinc/dub apps/web/lib/dynadot/client.ts default' },
|
|
158
|
+
// Vercel KV's REST URL (the Upstash Redis REST API under Vercel's names): an upstash host the twin serves
|
|
159
|
+
{ match: /(^|_)KV_REST_API_URL$/, value: 'https://twin-fake.upstash.io', source: 'packages/world-core inject VENDOR_HOSTS.upstash; lukevella/rallly KV_REST_API_URL' },
|
|
155
160
|
{
|
|
156
161
|
match: /(^|_)UPSTASH_REDIS_REST_URL$/,
|
|
157
162
|
value: 'https://twin-fake.upstash.io',
|
|
@@ -200,6 +205,14 @@ const CREDENTIAL_SHAPES: Array<{ match: RegExp; value: (name: string) => string;
|
|
|
200
205
|
// (`.clerk.accounts.dev`, packages/twin/clerk/src/index.ts hosts), so the boundary — the injector
|
|
201
206
|
// server-side, the browser proxy browser-side — resolves it to the twin, and an app CSP that
|
|
202
207
|
// allowlists that host stays legal (peak drive 2026-08-26).
|
|
208
|
+
// Stripe's keys carry their kind in their prefix, and applications check it before calling (Cal.com's Stripe app
|
|
209
|
+
// refuses a key that is not `sk_…`/`pk_…`); a webhook signing secret is `whsec_…`. Test mode, as a World is.
|
|
210
|
+
{ match: /(^|_)STRIPE_[A-Z0-9_]*WEBHOOK_SECRET$/, value: () => 'whsec_twinfake0000000000000000000000000000', source: 'https://docs.stripe.com/webhooks#verify-events' },
|
|
211
|
+
{ match: /(^|_)STRIPE_[A-Z0-9_]*(SECRET|PRIVATE|API)(_API)?_KEY$/, value: () => 'sk_test_twinfake000000000000000000000000000000', source: 'https://docs.stripe.com/keys (sk_test_)' },
|
|
212
|
+
{ match: /(^|_)STRIPE_[A-Z0-9_]*(PUBLISHABLE|PUBLIC)(_API)?_KEY$/, value: () => 'pk_test_twinfake000000000000000000000000000000', source: 'https://docs.stripe.com/keys (pk_test_)' },
|
|
213
|
+
// tavily-auth.ts and firecrawl-auth.ts refuse a key without the vendor's prefix with the vendor's 401
|
|
214
|
+
{ match: /(^|_)TAVILY_[A-Z0-9_]*(KEY|TOKEN)$/, value: () => 'tvly-twinfake00000000000000000000', source: 'packages/twin/tavily/src/tavily-auth.ts' },
|
|
215
|
+
{ match: /(^|_)FIRECRAWL_[A-Z0-9_]*(KEY|TOKEN)$/, value: () => 'fc-twinfake00000000000000000000000', source: 'packages/twin/firecrawl/src/firecrawl-auth.ts FIRECRAWL_KEY_PREFIX' },
|
|
203
216
|
{ match: /(^|_)CLERK_[A-Z0-9_]*SECRET[A-Z0-9_]*$/, value: () => 'sk_test_twinfake000000000000000000000000000000', source: 'packages/twin/clerk/src/clerk-twin.ts' },
|
|
204
217
|
{ match: /(^|_)CLERK_[A-Z0-9_]*PUBLISHABLE[A-Z0-9_]*$/, value: () => `pk_test_${Buffer.from('twin.clerk.accounts.dev$').toString('base64')}`, source: 'packages/twin/clerk/src/index.ts' },
|
|
205
218
|
];
|
|
@@ -211,8 +224,20 @@ const APP_KEY_SHAPES: Array<{ match: RegExp; value: () => string; source: string
|
|
|
211
224
|
// AES-256-GCM: the key base64-decodes to 32 bytes ("ENCRYPTION_KEY must be 32 bytes (base64-encoded)"); the 32 bytes
|
|
212
225
|
// spell out that they are fake
|
|
213
226
|
{ match: /^ENCRYPTION_KEY$/, value: () => Buffer.from('twin-fake-encryption-key-32bytes').toString('base64'), source: "dubinc/dub apps/web/lib/encryption.ts" },
|
|
227
|
+
// AES-256-CBC as hex: the key is 32 bytes (64 hex characters) and the IV 16 (32); LibreChat refuses to boot on any
|
|
228
|
+
// other shape; the bytes spell out that they are fake
|
|
229
|
+
// AES-256 over the key's latin1 bytes: exactly 32 characters (Cal.com's symmetricEncrypt)
|
|
230
|
+
{ match: /^CALENDSO_ENCRYPTION_KEY$/, value: () => 'twin-fake-calendso-key-32-chars!', source: 'calcom/cal.diy packages/lib/crypto.ts' },
|
|
231
|
+
{ match: /^CREDS_KEY$/, value: () => Buffer.from('twin-fake-creds-key-of-32-bytes!').toString('hex'), source: 'LibreChat-AI/LibreChat api/server/utils/crypto.js' },
|
|
232
|
+
{ match: /^CREDS_IV$/, value: () => Buffer.from('twin-fake-iv-16b').toString('hex'), source: 'LibreChat-AI/LibreChat api/server/utils/crypto.js' },
|
|
214
233
|
];
|
|
215
234
|
|
|
235
|
+
/** Whether `name` is a key an application parses (APP_KEY_SHAPES): a secret init fakes in its shape, whatever the
|
|
236
|
+
* example leaves it (LibreChat's CREDS_IV is empty in its example and not credential-shaped by name). */
|
|
237
|
+
export function isAppKeyName(name: string): boolean {
|
|
238
|
+
return APP_KEY_SHAPES.some((shape) => shape.match.test(name));
|
|
239
|
+
}
|
|
240
|
+
|
|
216
241
|
export function fakeEnvValue(name: string): string {
|
|
217
242
|
if (isGoogleOAuthClientEnvName(name)) return fakeGoogleOAuthClientJson();
|
|
218
243
|
for (const shape of APP_KEY_SHAPES) if (shape.match.test(name)) return shape.value();
|
package/src/host-worker.ts
CHANGED
|
@@ -4,13 +4,14 @@
|
|
|
4
4
|
// red) without disturbing sibling twins. Posts {type:'ready'} once listening.
|
|
5
5
|
import { parentPort, workerData } from 'node:worker_threads';
|
|
6
6
|
import type { ColocatedTwinSpec } from './host.ts';
|
|
7
|
+
import { importModule } from './import-module.ts';
|
|
7
8
|
|
|
8
9
|
const spec = workerData as ColocatedTwinSpec;
|
|
9
10
|
|
|
10
11
|
type TwinServer = { port: number; stop: () => void | Promise<void> };
|
|
11
12
|
type TwinServerFactory = (opts: { port?: number; root?: string; readOnly?: boolean }) => TwinServer | Promise<TwinServer>;
|
|
12
13
|
|
|
13
|
-
const mod =
|
|
14
|
+
const mod = await importModule(spec.module);
|
|
14
15
|
const factory = mod[spec.export] as TwinServerFactory | undefined;
|
|
15
16
|
if (typeof factory !== 'function') {
|
|
16
17
|
throw new Error(`Twin "${spec.id}": ${spec.module} has no factory export "${spec.export}"`);
|
package/src/host.ts
CHANGED
|
@@ -21,6 +21,7 @@
|
|
|
21
21
|
// world config that drives it) stays vendor-agnostic — exactly like `bin` spawning.
|
|
22
22
|
import { Worker } from 'node:worker_threads';
|
|
23
23
|
import { siblingScript } from './sibling.ts';
|
|
24
|
+
import { importModule } from './import-module.ts';
|
|
24
25
|
import { pathToFileURL } from 'node:url';
|
|
25
26
|
|
|
26
27
|
/** One twin to mount in the host. `module`/`export` name a `({port,root,readOnly}) => {port,stop}`
|
|
@@ -57,7 +58,7 @@ type TwinServer = { port: number; stop: () => void | Promise<void> };
|
|
|
57
58
|
type TwinServerFactory = (opts: { port?: number; root?: string; readOnly?: boolean; scenarioPath?: string }) => TwinServer | Promise<TwinServer>;
|
|
58
59
|
|
|
59
60
|
async function loadFactory(spec: ColocatedTwinSpec): Promise<TwinServerFactory> {
|
|
60
|
-
const mod =
|
|
61
|
+
const mod = await importModule(spec.module);
|
|
61
62
|
const factory = mod[spec.export];
|
|
62
63
|
if (typeof factory !== 'function') {
|
|
63
64
|
throw new Error(`Twin "${spec.id}": ${spec.module} has no factory export "${spec.export}"`);
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
// import() of what a World names a module by: a package name, a file URL, or a file path. Node's ESM loader takes an
|
|
2
|
+
// absolute path only as a file:// URL — on Windows `C:\…` reads as a URL with the scheme `c:` and is refused
|
|
3
|
+
// (ERR_UNSUPPORTED_ESM_URL_SCHEME); Bun takes either. A path becomes its file URL here; anything else is passed on.
|
|
4
|
+
import { isAbsolute } from 'node:path';
|
|
5
|
+
import { pathToFileURL } from 'node:url';
|
|
6
|
+
|
|
7
|
+
export function importModule<T = Record<string, unknown>>(specifier: string): Promise<T> {
|
|
8
|
+
return import(isAbsolute(specifier) ? pathToFileURL(specifier).href : specifier) as Promise<T>;
|
|
9
|
+
}
|
package/src/index.ts
CHANGED
|
@@ -163,8 +163,13 @@ export { branchWorld, checkoutWorld } from './branch.ts';
|
|
|
163
163
|
export type { BranchOptions } from './branch.ts';
|
|
164
164
|
export { deployWorld, refreshTwin, adaptersFor, credentialPath, credentialPayloadFrom, deployTwin, loadWorldChecks, materializeRoots, rootForControlRoot, sealTwinCredential, sealedCredentialInfo, setTwinRoot, userKekPath } from './root.ts';
|
|
165
165
|
export type { DeployTwinOutcome, MaterializedRoot } from './root.ts';
|
|
166
|
-
export { landReceipts, mountWorld, readServeRecord, refreshSchedule, servedName, serveWorld, TOKEN_HEADER, WorldDoors } from './served-world.ts';
|
|
167
|
-
export type { DoorHost, MountedWorld, ServeRecord, WorldLayout } from './served-world.ts';
|
|
166
|
+
export { landReceipts, mountWorld, READ_ONLY_HEADER, worldOriginLabel, readServeRecord, refreshSchedule, servedName, serveWorld, sessionsFile, keysFile, TOKEN_HEADER, WorldDoors } from './served-world.ts';
|
|
167
|
+
export type { BranchDoors, BranchRow, DoorHost, MountedWorld, ServeRecord, WorldLayout } from './served-world.ts';
|
|
168
|
+
export { LocalBranches } from './local-branches.ts';
|
|
169
|
+
export { serveWorldView, VIEW_CONSOLE_BASE } from './world-view.ts';
|
|
170
|
+
export { LOCAL_ORIGIN_BASE, localWorldOrigin, pathOfWorld, worldDoorUnderConsole, worldOfHost } from './world-origins.ts';
|
|
171
|
+
export type { ViewConsole, WorldView } from './world-view.ts';
|
|
172
|
+
export type { LocalBranchesOptions } from './local-branches.ts';
|
|
168
173
|
export type { ServedWorld } from './served-world.ts';
|
|
169
174
|
export { CONSOLE_BASE, consoleRedirect, serveConsoleApart, serveConsoleFor, type ConsoleMount } from './console-apart.ts';
|
|
170
175
|
|
package/src/infra-cli.ts
CHANGED
|
@@ -4,6 +4,7 @@
|
|
|
4
4
|
import { existsSync, readFileSync } from 'node:fs';
|
|
5
5
|
import { dirname, join } from 'node:path';
|
|
6
6
|
import { spawnSync } from 'node:child_process';
|
|
7
|
+
import { fileURLToPath } from 'node:url';
|
|
7
8
|
import { infraConnections, parseInfraDefinition } from './pglite-backing.ts';
|
|
8
9
|
|
|
9
10
|
const phase = process.argv[2];
|
|
@@ -25,7 +26,7 @@ if (!existsSync(definition)) {
|
|
|
25
26
|
}
|
|
26
27
|
|
|
27
28
|
const base = ['compose', '-f', definition];
|
|
28
|
-
const run = (args: string[]) => spawnSync('docker', [...base, ...args], {
|
|
29
|
+
const run = (args: string[]) => spawnSync('docker', [...base, ...args], { windowsHide: true,
|
|
29
30
|
encoding: 'utf8',
|
|
30
31
|
env: { ...process.env, VOLTER_WORLD_DATA: worldData },
|
|
31
32
|
timeout: 120_000,
|
|
@@ -48,8 +49,9 @@ const fail = (result: ReturnType<typeof run>): never => {
|
|
|
48
49
|
// runtime answers it is private and chosen here, per machine, at each phase:
|
|
49
50
|
// 1. VOLTER_WORLD_INFRA_BACKING=docker|pglite — explicit, for tests/operators;
|
|
50
51
|
// 2. a working container runtime — the compose path, byte-identical to before;
|
|
51
|
-
// 3. no container runtime + a postgres
|
|
52
|
-
// (pglite-
|
|
52
|
+
// 3. no container runtime + a definition of postgres and/or mongodb services —
|
|
53
|
+
// the containerless backing (pglite-backing.ts: PGlite for postgres, the
|
|
54
|
+
// MongoDB twin for mongodb), announced loudly;
|
|
53
55
|
// 4. otherwise the honest refusal naming what this machine cannot serve.
|
|
54
56
|
function selectBacking(): 'docker' | 'pglite' {
|
|
55
57
|
const forced = process.env.VOLTER_WORLD_INFRA_BACKING;
|
|
@@ -58,7 +60,7 @@ function selectBacking(): 'docker' | 'pglite' {
|
|
|
58
60
|
process.stderr.write(`managed infrastructure: VOLTER_WORLD_INFRA_BACKING must be docker or pglite (got ${JSON.stringify(forced)})\n`);
|
|
59
61
|
process.exit(2);
|
|
60
62
|
}
|
|
61
|
-
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 });
|
|
62
64
|
if (probe.status === 0) return 'docker';
|
|
63
65
|
// Absence of a container runtime (no binary, no daemon) is a capability
|
|
64
66
|
// difference: swap backings. A PRESENT runtime failing on resources stays
|
|
@@ -71,7 +73,7 @@ function selectBacking(): 'docker' | 'pglite' {
|
|
|
71
73
|
|
|
72
74
|
async function runPglite(): Promise<never> {
|
|
73
75
|
const { unsupportedKinds, pgliteUp, pgliteStatus, pgliteDown } = await import('./pglite-backing.ts');
|
|
74
|
-
const services = parseInfraDefinition(readFileSync(definition, 'utf8'));
|
|
76
|
+
const services = parseInfraDefinition(readFileSync(definition, 'utf8')).filter((s) => composed === null || composed.includes(s.kind));
|
|
75
77
|
if (services.length === 0) {
|
|
76
78
|
process.stderr.write('managed infrastructure: the declared definition names no services\n');
|
|
77
79
|
process.exit(1);
|
|
@@ -82,7 +84,7 @@ async function runPglite(): Promise<never> {
|
|
|
82
84
|
process.exit(1);
|
|
83
85
|
}
|
|
84
86
|
if (phase === 'up') {
|
|
85
|
-
process.stdout.write('managed infrastructure backing:
|
|
87
|
+
process.stdout.write('managed infrastructure backing: containerless (no container runtime)\n');
|
|
86
88
|
try {
|
|
87
89
|
await pgliteUp(services, worldData!);
|
|
88
90
|
} catch (error) {
|
|
@@ -98,7 +100,7 @@ async function runPglite(): Promise<never> {
|
|
|
98
100
|
process.stderr.write(`managed infrastructure status failed: ${status.ready}/${services.length} declared services are ready\n`);
|
|
99
101
|
process.exit(1);
|
|
100
102
|
}
|
|
101
|
-
process.stdout.write(`${JSON.stringify({ ok: true, services:
|
|
103
|
+
process.stdout.write(`${JSON.stringify({ ok: true, services: declared.length, connections: infraConnections(declared, process.env) })}\n`);
|
|
102
104
|
process.exit(0);
|
|
103
105
|
}
|
|
104
106
|
await pgliteDown(services, worldData!);
|
|
@@ -106,12 +108,54 @@ async function runPglite(): Promise<never> {
|
|
|
106
108
|
process.exit(0);
|
|
107
109
|
}
|
|
108
110
|
|
|
111
|
+
// ---- redis: the twin, containerless ----------------------------------------
|
|
112
|
+
// A redis service is served by the redis twin (redis-backing.ts) on every machine, container runtime or not,
|
|
113
|
+
// unless the operator forces the container backing; the other services take the backing chosen above. Where the
|
|
114
|
+
// twin is not installed (@volter/twin-redis is not this runtime's dependency), redis goes to that backing too:
|
|
115
|
+
// the container where there is one, else the backing's refusal naming redis.
|
|
116
|
+
const declared = parseInfraDefinition(readFileSync(definition, 'utf8'));
|
|
117
|
+
const { redisTwinCli } = await import('./redis-backing.ts');
|
|
118
|
+
const twinCli = redisTwinCli([dirname(fileURLToPath(import.meta.url)), dirname(worldConfig)]);
|
|
119
|
+
const twinned = process.env.VOLTER_WORLD_INFRA_BACKING === 'docker' || twinCli === undefined ? [] : declared.filter((s) => s.kind === 'redis');
|
|
120
|
+
/** The services the backing below answers for: every one, or those the twin does not serve (by compose service name). */
|
|
121
|
+
const composed = twinned.length === 0 ? null : declared.filter((s) => s.kind !== 'redis').map((s) => s.kind);
|
|
122
|
+
if (twinned.length > 0) await runRedisTwin();
|
|
123
|
+
|
|
124
|
+
async function runRedisTwin(): Promise<void> {
|
|
125
|
+
const { redisTwinDown, redisTwinStatus, redisTwinUp } = await import('./redis-backing.ts');
|
|
126
|
+
const cli = twinCli!;
|
|
127
|
+
if (phase === 'up') {
|
|
128
|
+
process.stdout.write('managed infrastructure backing: redis twin (containerless)\n');
|
|
129
|
+
try {
|
|
130
|
+
await redisTwinUp(twinned, worldData!, cli);
|
|
131
|
+
} catch (error) {
|
|
132
|
+
process.stderr.write(`managed infrastructure up failed: ${String((error as Error).message ?? error)}\n`);
|
|
133
|
+
process.exit(1);
|
|
134
|
+
}
|
|
135
|
+
} else if (phase === 'status') {
|
|
136
|
+
const ready = await redisTwinStatus(twinned, worldData!);
|
|
137
|
+
if (ready !== twinned.length) {
|
|
138
|
+
process.stderr.write(`managed infrastructure status failed: ${ready}/${declared.length} declared services are ready\n`);
|
|
139
|
+
process.exit(1);
|
|
140
|
+
}
|
|
141
|
+
} else {
|
|
142
|
+
await redisTwinDown(twinned, worldData!);
|
|
143
|
+
}
|
|
144
|
+
if (composed!.length === 0) {
|
|
145
|
+
if (phase === 'up') process.stdout.write('managed infrastructure ready\n');
|
|
146
|
+
else if (phase === 'status') process.stdout.write(`${JSON.stringify({ ok: true, services: declared.length, connections: infraConnections(declared, process.env) })}\n`);
|
|
147
|
+
else process.stdout.write('managed infrastructure stopped\n');
|
|
148
|
+
process.exit(0);
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
|
|
109
152
|
if (selectBacking() === 'pglite') {
|
|
110
153
|
await runPglite();
|
|
111
154
|
}
|
|
112
155
|
|
|
156
|
+
const only = composed ?? [];
|
|
113
157
|
if (phase === 'up') {
|
|
114
|
-
const result = run(['up', '-d', '--wait']);
|
|
158
|
+
const result = run(['up', '-d', '--wait', ...only]);
|
|
115
159
|
if (result.status !== 0) fail(result);
|
|
116
160
|
process.stdout.write('managed infrastructure ready\n');
|
|
117
161
|
} else if (phase === 'status') {
|
|
@@ -119,13 +163,13 @@ if (phase === 'up') {
|
|
|
119
163
|
if (expected.status !== 0) fail(expected);
|
|
120
164
|
const running = run(['ps', '--status', 'running', '--services']);
|
|
121
165
|
if (running.status !== 0) fail(running);
|
|
122
|
-
const expectedNames = expected.stdout.split(/\s+/u).filter(Boolean).sort();
|
|
123
|
-
const runningNames = running.stdout.split(/\s+/u).filter(Boolean).sort();
|
|
166
|
+
const expectedNames = expected.stdout.split(/\s+/u).filter(Boolean).filter((n) => composed === null || composed.includes(n)).sort();
|
|
167
|
+
const runningNames = running.stdout.split(/\s+/u).filter(Boolean).filter((n) => composed === null || composed.includes(n)).sort();
|
|
124
168
|
if (expectedNames.length === 0 || expectedNames.join('\0') !== runningNames.join('\0')) {
|
|
125
|
-
process.stderr.write(`managed infrastructure status failed: ${runningNames.length}/${expectedNames.length} declared services are ready\n`);
|
|
169
|
+
process.stderr.write(`managed infrastructure status failed: ${runningNames.length + twinned.length}/${expectedNames.length + twinned.length} declared services are ready\n`);
|
|
126
170
|
process.exit(1);
|
|
127
171
|
}
|
|
128
|
-
process.stdout.write(`${JSON.stringify({ ok: true, services: expectedNames.length, connections: infraConnections(
|
|
172
|
+
process.stdout.write(`${JSON.stringify({ ok: true, services: expectedNames.length + twinned.length, connections: infraConnections(declared, process.env) })}\n`);
|
|
129
173
|
} else {
|
|
130
174
|
const result = run(['down', '--remove-orphans']);
|
|
131
175
|
if (result.status !== 0) fail(result);
|
package/src/init.ts
CHANGED
|
@@ -47,7 +47,7 @@ import {
|
|
|
47
47
|
registryAcknowledgedReason,
|
|
48
48
|
type CoverageReport,
|
|
49
49
|
} from './covers.ts';
|
|
50
|
-
import { fakeEnvValue, isGoogleOAuthClientEnvName, isGoogleServiceAccountEnvName } from './fixture-env.ts';
|
|
50
|
+
import { fakeEnvValue, isAppKeyName, isGoogleOAuthClientEnvName, isGoogleServiceAccountEnvName } from './fixture-env.ts';
|
|
51
51
|
import { resolveCatalog, twinPackageName, type Catalog } from './catalog.ts';
|
|
52
52
|
import { projectEnvReads, projectManifestDirs } from './project-inspect.ts';
|
|
53
53
|
import { overlayEndpointEnv, packFacts } from './pack-facts.ts';
|
|
@@ -300,6 +300,20 @@ export const APP_READ_ENDPOINT_ENV: Record<string, { injectEnv?: string; injectE
|
|
|
300
300
|
+ 'sends there. Read the mail back over the twin-only inspect sidecar: `world-smtp serve --inspect-port N`, then '
|
|
301
301
|
+ 'GET http://127.0.0.1:N/twin/messages/latest.',
|
|
302
302
|
},
|
|
303
|
+
// RAW PROTOCOL, the gRPC kind. The Temporal SDKs dial the frontend address they are given
|
|
304
|
+
// (`Connection.connect({ address })`, `NativeConnection.connect({ address })`), conventionally read
|
|
305
|
+
// from TEMPORAL_ADDRESS — Postiz's temporal.module.ts, the SDK samples and the Temporal CLI all read
|
|
306
|
+
// that name — and speak gRPC over HTTP/2, which the http/fetch injector never sees. The value is an
|
|
307
|
+
// address (host:port), not a URL, which is why this lives here as a template and not as the
|
|
308
|
+
// descriptor's `endpointEnv` (whose `name` receives the twin's URL).
|
|
309
|
+
temporal: {
|
|
310
|
+
injectEnvTemplates: {
|
|
311
|
+
TEMPORAL_ADDRESS: '${host}:${port}',
|
|
312
|
+
},
|
|
313
|
+
note: 'no injector entry and none possible: the Temporal SDKs speak gRPC to the address they are configured with. '
|
|
314
|
+
+ 'TEMPORAL_ADDRESS=<host>:<port> points them at the twin (namespace `default` exists); an app that sets '
|
|
315
|
+
+ 'TEMPORAL_API_KEY turns TLS on in the SDK, which this loopback frontend does not speak — leave it unset.',
|
|
316
|
+
},
|
|
303
317
|
};
|
|
304
318
|
// descriptor-first migration (adding-a-twin.md §3): packs now declare their endpoint-env wiring (with its grounding
|
|
305
319
|
// note) on the descriptor (`endpointEnv` on TwinPack); the table above shrinks toward empty as
|
|
@@ -442,7 +456,7 @@ const INFRA_PLACEHOLDER: Record<string, string> = {
|
|
|
442
456
|
//
|
|
443
457
|
// The subject pilots (dub, cal.com) put the number on it: world-side setup is seconds, and the
|
|
444
458
|
// minutes go to the app side — most avoidably, to hand-writing infrastructure for the Postgres/
|
|
445
|
-
// MySQL/Redis the repo signalled. For the kinds below, `init` upgrades the placeholder to a
|
|
459
|
+
// MySQL/Redis/MongoDB the repo signalled. For the kinds below, `init` upgrades the placeholder to a
|
|
446
460
|
// World-managed service and private definition with env URLs already pointing at it. The helper
|
|
447
461
|
// owns its implementation behind the declared service boundary. Kinds without a recipe keep the
|
|
448
462
|
// placeholder + `//infra` stub.
|
|
@@ -479,6 +493,15 @@ const COMPOSE_INFRA: Record<string, {
|
|
|
479
493
|
volumePath: '/data',
|
|
480
494
|
healthcheck: () => ['CMD', 'redis-cli', 'ping'],
|
|
481
495
|
},
|
|
496
|
+
// Without a container runtime the containerless backing serves this kind with the MongoDB twin
|
|
497
|
+
// (@volter/twin-mongodb), at the same loopback port and URL.
|
|
498
|
+
mongodb: {
|
|
499
|
+
image: 'mongo:7',
|
|
500
|
+
containerPort: 27017,
|
|
501
|
+
memoryMiB: 1024,
|
|
502
|
+
volumePath: '/data/db',
|
|
503
|
+
healthcheck: () => ['CMD', 'mongosh', '--quiet', '--eval', "db.adminCommand('ping').ok"],
|
|
504
|
+
},
|
|
482
505
|
};
|
|
483
506
|
|
|
484
507
|
/** FNV-1a 32-bit — a tiny, dependency-free stable string hash for port derivation. */
|
|
@@ -525,9 +548,12 @@ function composeService(name: string, kind: string, signals: string[], taken: Se
|
|
|
525
548
|
const recipe = COMPOSE_INFRA[kind]!;
|
|
526
549
|
const hostPort = composePort(name, kind, taken);
|
|
527
550
|
const ident = composeIdent(name);
|
|
551
|
+
// redis and mongodb run without authentication (no credentials in their images' env), so their URLs carry none
|
|
528
552
|
const url = kind === 'redis'
|
|
529
553
|
? `redis://127.0.0.1:${hostPort}`
|
|
530
|
-
:
|
|
554
|
+
: kind === 'mongodb'
|
|
555
|
+
? `mongodb://127.0.0.1:${hostPort}/${ident}`
|
|
556
|
+
: `${kind}://${composeUser(name, kind)}:${ident}@127.0.0.1:${hostPort}/${ident}`;
|
|
531
557
|
return {
|
|
532
558
|
kind,
|
|
533
559
|
image: recipe.image,
|
|
@@ -540,7 +566,7 @@ function composeService(name: string, kind: string, signals: string[], taken: Se
|
|
|
540
566
|
};
|
|
541
567
|
}
|
|
542
568
|
|
|
543
|
-
/** The declared env of one compose service (empty for redis). */
|
|
569
|
+
/** The declared env of one compose service (empty for redis and mongodb). */
|
|
544
570
|
function composeEnvironment(name: string, kind: string): Array<[string, string]> {
|
|
545
571
|
const ident = composeIdent(name);
|
|
546
572
|
if (kind === 'postgres') {
|
|
@@ -858,7 +884,7 @@ export function planWorldInit(name: string, repoPath: string, options: InitOptio
|
|
|
858
884
|
// RULE 3: credential-shaped means faked, even when no vendor claims the stem. That covers
|
|
859
885
|
// app-local secrets (JWT_SECRET, ENCRYPTION_KEY) and untwinned vendors alike — and it is the
|
|
860
886
|
// reason a live key committed to `.env.example` can never reach the emitted world.
|
|
861
|
-
if (isCredentialShapedEnvName(name)) {
|
|
887
|
+
if (isCredentialShapedEnvName(name) || isAppKeyName(name)) {
|
|
862
888
|
env[name] = fakeEnvValue(name);
|
|
863
889
|
envRows.push({ name, disposition: 'faked', source, reason: 'credential-shaped name — the example value is never copied' });
|
|
864
890
|
continue;
|
|
@@ -1138,6 +1164,9 @@ current
|
|
|
1138
1164
|
*.env
|
|
1139
1165
|
credentials/
|
|
1140
1166
|
token
|
|
1167
|
+
token.read
|
|
1168
|
+
sessions.json
|
|
1169
|
+
keys.json
|
|
1141
1170
|
`;
|
|
1142
1171
|
|
|
1143
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
|
+
}
|