@volter/world-runtime 2.0.55 → 3.0.1
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 +5 -4
- package/dist/src/actions-runner-cli.d.ts +2 -0
- package/dist/src/actions-runner-cli.js +25 -0
- package/dist/src/actions-runner.d.ts +18 -0
- package/dist/src/actions-runner.js +281 -0
- package/dist/src/ancestry.js +1 -1
- package/dist/src/app-url.js +1 -1
- package/dist/src/backings.d.ts +29 -0
- package/dist/src/backings.js +45 -0
- package/dist/src/branch.js +3 -2
- package/dist/src/browser-proxy-cli.js +1 -1
- package/dist/src/catalog-index.d.ts +38 -0
- package/dist/src/catalog-index.js +37 -0
- package/dist/src/catalog.d.ts +16 -3
- package/dist/src/catalog.js +69 -26
- package/dist/src/changeset.d.ts +1 -1
- package/dist/src/changeset.js +9 -11
- package/dist/src/cli-args.d.ts +4 -0
- package/dist/src/cli-args.js +18 -0
- package/dist/src/cli-commands.d.ts +1 -0
- package/dist/src/cli-commands.js +1216 -0
- package/dist/src/cli.js +8 -1300
- package/dist/src/configs.js +1 -1
- package/dist/src/console-apart.d.ts +2 -2
- package/dist/src/console-apart.js +5 -5
- package/dist/src/consumers.js +1 -1
- package/dist/src/covers.d.ts +1 -1
- package/dist/src/covers.js +94 -30
- package/dist/src/env-output.d.ts +1 -1
- package/dist/src/env-output.js +2 -2
- package/dist/src/fixture-env.js +18 -26
- package/dist/src/host-cli.js +4 -2
- package/dist/src/host-fault-fixture.js +6 -0
- package/dist/src/host-ready.d.ts +8 -2
- package/dist/src/host-ready.js +16 -4
- package/dist/src/host-worker.js +6 -1
- package/dist/src/host.d.ts +2 -0
- package/dist/src/host.js +9 -1
- package/dist/src/index.d.ts +2 -2
- package/dist/src/index.js +2 -2
- package/dist/src/init.d.ts +33 -23
- package/dist/src/init.js +228 -96
- package/dist/src/lifecycle-record.js +2 -2
- package/dist/src/local-branches.js +1 -1
- package/dist/src/origin.d.ts +1 -1
- package/dist/src/origin.js +12 -10
- package/dist/src/pack-facts.d.ts +30 -6
- package/dist/src/pack-facts.js +13 -3
- package/dist/src/pglite-backing.js +2 -1
- package/dist/src/placeholder.d.ts +1 -1
- package/dist/src/placeholder.js +12 -4
- package/dist/src/project-inspect.d.ts +1 -1
- package/dist/src/project-inspect.js +2 -2
- package/dist/src/redirect-proxy.d.ts +5 -0
- package/dist/src/redirect-proxy.js +8 -1
- package/dist/src/redis-backing.js +4 -2
- package/dist/src/reflect.js +1 -1
- package/dist/src/root.d.ts +11 -33
- package/dist/src/root.js +28 -59
- package/dist/src/runtime-test-support.js +1 -1
- package/dist/src/runtime.d.ts +7 -1
- package/dist/src/runtime.js +317 -53
- package/dist/src/schema.d.ts +27 -5
- package/dist/src/schema.js +13 -12
- package/dist/src/served-world.d.ts +11 -35
- package/dist/src/served-world.js +54 -141
- package/dist/src/tail.js +1 -1
- package/dist/src/up-cli.d.ts +3 -0
- package/dist/src/up-cli.js +55 -0
- package/dist/src/websocket-relay.d.ts +1 -1
- package/dist/src/world-origins.d.ts +1 -1
- package/dist/src/world-origins.js +1 -1
- package/dist/src/world-view.d.ts +1 -1
- package/dist/src/world-view.js +2 -2
- package/known-external-services.json +5 -4
- package/package.json +11 -11
- package/src/actions-runner-cli.ts +24 -0
- package/src/actions-runner.ts +244 -0
- package/src/ancestry.ts +1 -1
- package/src/app-url.ts +1 -1
- package/src/backings.ts +55 -0
- package/src/branch.ts +3 -2
- package/src/browser-proxy-cli.ts +1 -1
- package/src/catalog-index.ts +44 -0
- package/src/catalog.ts +67 -24
- package/src/changeset.ts +10 -13
- package/src/cli-args.ts +17 -0
- package/src/cli-commands.ts +1070 -0
- package/src/cli.ts +9 -1139
- package/src/configs.ts +1 -1
- package/src/console-apart.ts +5 -5
- package/src/consumers.ts +1 -1
- package/src/covers.ts +83 -30
- package/src/env-output.ts +2 -2
- package/src/fixture-env.ts +18 -26
- package/src/host-cli.ts +4 -2
- package/src/host-ready.ts +13 -4
- package/src/host-worker.ts +6 -2
- package/src/host.ts +11 -2
- package/src/index.ts +2 -2
- package/src/init.ts +222 -101
- package/src/lifecycle-record.ts +2 -2
- package/src/local-branches.ts +1 -1
- package/src/origin.ts +12 -10
- package/src/pack-facts.ts +22 -6
- package/src/pglite-backing.ts +2 -1
- package/src/placeholder.ts +13 -4
- package/src/project-inspect.ts +2 -2
- package/src/redirect-proxy.ts +9 -1
- package/src/redis-backing.ts +4 -2
- package/src/reflect.ts +1 -1
- package/src/root.ts +33 -65
- package/src/runtime-test-support.ts +1 -1
- package/src/runtime.ts +261 -55
- package/src/schema.ts +26 -13
- package/src/served-world.ts +50 -130
- package/src/tail.ts +1 -1
- package/src/up-cli.ts +55 -0
- package/src/websocket-relay.ts +1 -1
- package/src/world-origins.ts +1 -1
- package/src/world-view.ts +3 -3
package/src/configs.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { copyFileSync, constants, existsSync, readFileSync, renameSync, writeFileSync } from 'node:fs';
|
|
2
2
|
import { basename, dirname, join, resolve } from 'node:path';
|
|
3
|
-
import { getActiveWorldStore, stateDirName } from '@volter/world-core';
|
|
3
|
+
import { getActiveWorldStore, stateDirName } from '@volter/world-core/runtime';
|
|
4
4
|
import { assertWorldConfig, LEGACY_WORLD_SELECTION, worldConfigDocument, type WorldChangesetRules, type WorldConfig } from './schema.ts';
|
|
5
5
|
|
|
6
6
|
export function resolveConfigPath(config: string, root = process.cwd()): string {
|
package/src/console-apart.ts
CHANGED
|
@@ -1,11 +1,11 @@
|
|
|
1
1
|
// THE CONSOLE ON AN ORIGIN OF ITS OWN. The console keeps the token a person types in its tab's
|
|
2
|
-
// storage; the Worlds' origin serves what twins serve (a
|
|
2
|
+
// storage; the Worlds' origin serves what twins serve (a vendor's screens, a tunnel's visitor pages,
|
|
3
3
|
// an app's own HTML), and a script there could read any storage of that origin. So a host serves the
|
|
4
4
|
// console on a listener of its own: the console's shell and bundle, and the host's `/-/` doors
|
|
5
5
|
// forwarded to the host — never a twin's path, so no twin-served script ever runs on this origin.
|
|
6
|
-
// The console opens a World's pages (a
|
|
6
|
+
// The console opens a World's pages (a vendor's screens) on the Worlds' origin, which it is told.
|
|
7
7
|
|
|
8
|
-
import { serveHttp, type HttpServer } from '@volter/world-core';
|
|
8
|
+
import { serveHttp, type HttpServer } from '@volter/world-core/runtime';
|
|
9
9
|
|
|
10
10
|
/** What a console offers a host: its shell and assets under `base`, told where the Worlds are served. */
|
|
11
11
|
export type ConsoleMount = { handle: (request: Request, base: string, opts?: { worlds?: string; names?: string[]; worldOrigin?: boolean }) => Promise<Response | null> };
|
|
@@ -40,7 +40,7 @@ export async function serveConsoleApart(mount: ConsoleMount, opts: { upstream: s
|
|
|
40
40
|
|
|
41
41
|
/** The console for a World served elsewhere (a hosted World), on this machine. Two local listeners, as
|
|
42
42
|
* `volter world serve` has: the console on one origin, its doors forwarded to the host; and the Worlds'
|
|
43
|
-
* origin on the next port, forwarding everything (a
|
|
43
|
+
* origin on the next port, forwarding everything (a vendor's screens, the session door) to the host, so
|
|
44
44
|
* the console and the pages twins serve are same-site as they are locally, and still two origins. A
|
|
45
45
|
* host keeps its tenants' names to itself, so the World is named by the URL: `<origin>/<org>/<world>`. */
|
|
46
46
|
export async function serveConsoleFor(target: string, opts: { hostname?: string; port?: number } = {}): Promise<{ url: string; worlds: string; servers: HttpServer[] }> {
|
|
@@ -71,7 +71,7 @@ export async function serveConsoleFor(target: string, opts: { hostname?: string;
|
|
|
71
71
|
const out = new Headers(); for (const [k, v] of answer.headers) if (!['content-encoding', 'content-length', 'set-cookie'].includes(k)) out.append(k, v);
|
|
72
72
|
// the World session's cookie, kept on this plain-http loopback origin (a Secure cookie is https-only)
|
|
73
73
|
for (const c of answer.headers.getSetCookie()) out.append('set-cookie', c.replace(/;\s*Secure/i, ''));
|
|
74
|
-
// the host's own redirects (the session door's
|
|
74
|
+
// the host's own redirects (the session door's) stay on this origin
|
|
75
75
|
const location = out.get('location');
|
|
76
76
|
if (location?.startsWith(origin)) out.set('location', `${url.origin}${location.slice(origin.length)}`);
|
|
77
77
|
const csp = out.get('content-security-policy');
|
package/src/consumers.ts
CHANGED
|
@@ -2,7 +2,7 @@ import { randomUUID } from 'node:crypto';
|
|
|
2
2
|
import { existsSync, mkdirSync, readFileSync, readdirSync, renameSync, rmSync, writeFileSync } from 'node:fs';
|
|
3
3
|
import { hostname } from 'node:os';
|
|
4
4
|
import { join } from 'node:path';
|
|
5
|
-
import { stateDirName, withFileLock } from '@volter/world-core';
|
|
5
|
+
import { stateDirName, withFileLock } from '@volter/world-core/runtime';
|
|
6
6
|
|
|
7
7
|
export type WorldConsumer = {
|
|
8
8
|
id: string;
|
package/src/covers.ts
CHANGED
|
@@ -22,15 +22,16 @@
|
|
|
22
22
|
// unknown-sdk → exit 1 by default (`--allow-unknown` downgrades to a warning:
|
|
23
23
|
// unknowns still print, but stop failing the proof).
|
|
24
24
|
|
|
25
|
-
import { readFileSync } from 'node:fs';
|
|
26
|
-
import { dirname, join, resolve } from 'node:path';
|
|
25
|
+
import { existsSync, readFileSync } from 'node:fs';
|
|
26
|
+
import { dirname, join, relative, resolve } from 'node:path';
|
|
27
27
|
import { fileURLToPath } from 'node:url';
|
|
28
|
+
import { vendorOfPackModule } from '@volter/world-core/runtime';
|
|
28
29
|
import { loadWorldConfig } from './configs.ts';
|
|
29
30
|
import { injectableVendorKeys, loadInject, twinUrlVendorFor } from './inject-map.ts';
|
|
30
|
-
import { connectionProtocol, projectConnections, projectDependencies, projectEnvNames, projectFetchUrls, projectNpmRegistries, PYPI_TWINS, SDK_TWINS } from './project-inspect.ts';
|
|
31
|
+
import { connectionProtocol, projectConnections, projectDependencies, projectManifestDirs, projectEnvNames, projectFetchUrls, projectNpmRegistries, PYPI_TWINS, SDK_TWINS } from './project-inspect.ts';
|
|
31
32
|
import { overlayCoversMaps, packFacts } from './pack-facts.ts';
|
|
32
33
|
import { statusWorld } from './runtime.ts';
|
|
33
|
-
import { worldSelectionIncludes, type WorldSelection, type WorldServiceConfig, type WorldServiceInstance, type WorldUsage } from './schema.ts';
|
|
34
|
+
import { ISSUE_ENV_PREFIX, worldSelectionIncludes, type WorldSelection, type WorldServiceConfig, type WorldServiceInstance, type WorldUsage } from './schema.ts';
|
|
34
35
|
|
|
35
36
|
export type CoverageStatus = 'covered' | 'missing' | 'uninterceptable' | 'unknown-sdk' | 'acknowledged' | 'excluded';
|
|
36
37
|
|
|
@@ -114,7 +115,7 @@ export function injectorEnvNameForKey(key: string): string {
|
|
|
114
115
|
/** Vendor-shaped env var detection (the secondary signal). The var must end in a
|
|
115
116
|
* credential/endpoint suffix; the remaining stem (framework prefixes stripped,
|
|
116
117
|
* normalized) is looked up here. */
|
|
117
|
-
// Exported for scripts/packless-claims.
|
|
118
|
+
// Exported for scripts/packless-claims.ts, which checks the packless registry against
|
|
118
119
|
// the OVERLAID map (legacy entries + descriptor adoption.envStems in one view) — importing
|
|
119
120
|
// the real map is unbreakable by formatting, unlike the source-parsing it replaced (§9
|
|
120
121
|
// skeptic M3, 2026-08-31: a reflow or quote-style change silently emptied the parsed set).
|
|
@@ -163,6 +164,22 @@ function envVendorStem(name: string, suffix: RegExp): string | null {
|
|
|
163
164
|
return normalizeId(stripped.slice(0, match.index));
|
|
164
165
|
}
|
|
165
166
|
|
|
167
|
+
/** The vendor a credential-shaped env name's stem names: the stem left once the suffix is stripped, or else the longest
|
|
168
|
+
* declared stem the name begins with (googleoauth's GOOGLE_CLIENT for GOOGLE_CLIENT_ID, whose `_CLIENT_ID` suffix
|
|
169
|
+
* leaves only GOOGLE). */
|
|
170
|
+
function envStemVendor(name: string, suffix: RegExp): string | undefined {
|
|
171
|
+
const stem = envVendorStem(name, suffix);
|
|
172
|
+
if (stem === null) return undefined;
|
|
173
|
+
if (ENV_STEM_VENDORS[stem]) return ENV_STEM_VENDORS[stem];
|
|
174
|
+
const words = name.replace(ENV_FRAMEWORK_PREFIX, '').split('_');
|
|
175
|
+
// a declared stem of more than one word: a one-word stem (X's `X`) would claim every name it begins
|
|
176
|
+
for (let k = words.length - 1; k >= 2; k--) {
|
|
177
|
+
const vendor = ENV_STEM_VENDORS[normalizeId(words.slice(0, k).join('_'))];
|
|
178
|
+
if (vendor) return vendor;
|
|
179
|
+
}
|
|
180
|
+
return undefined;
|
|
181
|
+
}
|
|
182
|
+
|
|
166
183
|
/** Is this env NAME shaped like a CREDENTIAL/endpoint at all (any broad vendor suffix)? The
|
|
167
184
|
* question `init` asks before deciding whether a value in a repo's `.env.example` may be copied
|
|
168
185
|
* verbatim: `APP_NAME` may, `ANYTHING_SECRET` may not — a committed example file is exactly where
|
|
@@ -170,7 +187,7 @@ function envVendorStem(name: string, suffix: RegExp): string | null {
|
|
|
170
187
|
* ALWAYS replaced by a fake, whether or not its stem maps to a vendor this repo twins. Exported
|
|
171
188
|
* from here so the emission path and the proof read ONE definition of "credential-shaped". */
|
|
172
189
|
export function isCredentialShapedEnvName(name: string): boolean {
|
|
173
|
-
return ENV_SUFFIX_BROAD.test(name.replace(ENV_FRAMEWORK_PREFIX, ''));
|
|
190
|
+
return name === 'GOOGLE_APPLICATION_CREDENTIALS' || ENV_SUFFIX_BROAD.test(name.replace(ENV_FRAMEWORK_PREFIX, ''));
|
|
174
191
|
}
|
|
175
192
|
|
|
176
193
|
/** The vendor a credential-shaped env NAME betrays, or null. Same broad-suffix route the
|
|
@@ -188,8 +205,7 @@ export function envNameVendor(name: string): string | null {
|
|
|
188
205
|
// So: this asks "is this name a VENDOR CREDENTIAL that must be faked?" (a hostname is not), while
|
|
189
206
|
// `detectRepoVendors` asks "which vendors does this repo talk to?" (the mail path certainly is
|
|
190
207
|
// one). Same string, two honest answers.
|
|
191
|
-
|
|
192
|
-
return stem === null ? null : ENV_STEM_VENDORS[stem] ?? null;
|
|
208
|
+
return envStemVendor(name, ENV_SUFFIX_BROAD) ?? null;
|
|
193
209
|
}
|
|
194
210
|
|
|
195
211
|
/** SMTP-shaped env names — the raw-protocol mail path (nodemailer et al.) the HTTP-vendor
|
|
@@ -220,7 +236,7 @@ function isSmtpEnvSignal(name: string): boolean {
|
|
|
220
236
|
const SDK_SCOPE_VENDORS: Record<string, string> = {
|
|
221
237
|
// (empty — every entry has moved onto its pack descriptor. See the note below.)
|
|
222
238
|
};
|
|
223
|
-
// descriptor-first migration (
|
|
239
|
+
// descriptor-first migration (architecture.md, "The descriptor"), COMPLETE for these three tables: every pack declares its own
|
|
224
240
|
// scopes / env stems / world ids (`adoption` on its descriptor, compiled into the committed
|
|
225
241
|
// pack-facts artifact), and all three literals above are now empty — they are populated entirely
|
|
226
242
|
// by the overlay below. They are kept as the overlay's targets, not as homes: the object identity
|
|
@@ -258,7 +274,7 @@ export function isMappedScopeSibling(name: string): boolean {
|
|
|
258
274
|
* `'acknowledged'` was adjudicated acceptable and auto-acknowledges with its recorded
|
|
259
275
|
* reason); `notExternal` suppresses vendor-adjacent glue (protocol/type/UI helpers, auth
|
|
260
276
|
* libraries); `patterns` are the external-shape heuristics. AN ENTRY DIES THE DAY ITS PACK
|
|
261
|
-
* IS BORN — scripts/packless-claims.
|
|
277
|
+
* IS BORN — scripts/packless-claims.ts enforces disjointness with pack adoption and
|
|
262
278
|
* SDK_TWINS (the `@planetscale/database`/twin#255 lesson, mechanized). Lazy + cached like
|
|
263
279
|
* pack-facts.ts's loader — though note this module ALREADY does module-scope I/O via the
|
|
264
280
|
* overlayCoversMaps call below, so laziness here is consistency, not a load-bearing
|
|
@@ -365,10 +381,11 @@ function servicePackNames(service: { package?: string; command?: string | string
|
|
|
365
381
|
...(service.colocate?.module ? [service.colocate.module] : []),
|
|
366
382
|
];
|
|
367
383
|
return commandParts.flatMap((part) => {
|
|
368
|
-
const fromPath = part.match(/
|
|
384
|
+
const fromPath = part.match(/(?:^|[/\\])twin-packs-p[23][/\\]([a-z0-9-]+)[/\\]/);
|
|
369
385
|
const fromBin = part.match(/^world-([a-z0-9-]+)$/);
|
|
370
386
|
const fromPkg = part.match(/(?:^|[/\\])@volter\/twin-([a-z0-9-]+)(?:[/\\]|$)/);
|
|
371
|
-
|
|
387
|
+
const fromManifest = /\.(?:[cm]?js|tsx?)$/.test(part) ? vendorOfPackModule(part) : undefined;
|
|
388
|
+
return [fromManifest, fromPath?.[1], fromBin?.[1], fromPkg?.[1]].filter((name): name is string => name !== undefined);
|
|
372
389
|
});
|
|
373
390
|
}
|
|
374
391
|
|
|
@@ -389,14 +406,10 @@ function serviceConnections(
|
|
|
389
406
|
const values = instance ? service.env : config?.injectEnvTemplates ?? {};
|
|
390
407
|
for (const [name, value] of Object.entries(values)) {
|
|
391
408
|
const protocol = connectionProtocol(value);
|
|
392
|
-
const command = [...(Array.isArray(service.command) ? service.command : []), ...('args' in service ? service.args ?? [] : [])];
|
|
393
409
|
// The pack-transport veto is about NATIVE schemes (a mysql:// template on an HTTP twin); an
|
|
394
410
|
// http(s) endpoint template on any twin is that twin's own HTTP door.
|
|
395
|
-
const incompatiblePack = protocol !== null && facts.some((fact) => {
|
|
396
|
-
|
|
397
|
-
return fact.transport !== 'raw-tcp' || !Object.values(fact.endpointEnv?.templates ?? {})
|
|
398
|
-
.some((template) => connectionProtocol(template) === protocol && protocol !== null);
|
|
399
|
-
});
|
|
411
|
+
const incompatiblePack = protocol !== null && facts.some((fact) => fact.transport !== 'raw-tcp' || !Object.values(fact.endpointEnv?.templates ?? {})
|
|
412
|
+
.some((template) => connectionProtocol(template) === protocol && protocol !== null));
|
|
400
413
|
let routed = false;
|
|
401
414
|
if (protocol !== null && !incompatiblePack) {
|
|
402
415
|
if (instance) {
|
|
@@ -425,7 +438,7 @@ function serviceConnections(
|
|
|
425
438
|
bindings.set(name, { protocol: template.includes('${url}') || /^https?:/.test(template) ? 'http' : null, label: `${service.id} (${name}; cliRedirect)` });
|
|
426
439
|
}
|
|
427
440
|
for (const mapping of config?.external?.discover ?? []) {
|
|
428
|
-
bindings.set(mapping.as, { protocol: null, label: `${service.id} (${mapping.as}; discover after up)` });
|
|
441
|
+
bindings.set(mapping.as, { protocol: mapping.protocol ?? null, label: `${service.id} (${mapping.as}; discover after up)` });
|
|
429
442
|
}
|
|
430
443
|
}
|
|
431
444
|
}
|
|
@@ -483,11 +496,7 @@ function addServiceIds(
|
|
|
483
496
|
.some(([name, value]) => httpNames.has(name) && (/^(?:https?|wss?):/.test(value) || value === '${url}'));
|
|
484
497
|
const nativeOnly = packNames.length === 0 && endpointValues.some((value) => connectionProtocol(value) !== null) &&
|
|
485
498
|
!vendorHttpEndpoint;
|
|
486
|
-
const
|
|
487
|
-
const mode = packFacts()[name]?.nativeTransport;
|
|
488
|
-
return mode && [...(Array.isArray(service.command) ? service.command : []), ...(service.args ?? [])].includes(mode.flag);
|
|
489
|
-
});
|
|
490
|
-
const interceptable = !nativeOnly && !nativeMode && (injectorReads || appConfigured || hostMatched);
|
|
499
|
+
const interceptable = !nativeOnly && (injectorReads || appConfigured || hostMatched);
|
|
491
500
|
// twin + external services cover their vendor by identity; a plain 'process'
|
|
492
501
|
// service only counts via an explicit twin marker (injectEnv/*_TWIN_URL, pack path).
|
|
493
502
|
if (type === 'twin' || type === 'external') {
|
|
@@ -524,7 +533,13 @@ export function worldTwinInventory(world: string, root: string): WorldTwinInvent
|
|
|
524
533
|
const { path, config } = loadWorldConfig(world, root);
|
|
525
534
|
const ids = new Map<string, WorldTwinId>();
|
|
526
535
|
for (const service of config.services as WorldServiceConfig[]) addServiceIds(ids, service);
|
|
527
|
-
|
|
536
|
+
const connections = serviceConnections(config.services);
|
|
537
|
+
// a name the World's env leaves to a twin's credential door (`$issue:<service>`) is that twin's, whatever scheme the
|
|
538
|
+
// issued value has: the twin that issues it serves it
|
|
539
|
+
for (const [name, value] of Object.entries(config.env ?? {})) {
|
|
540
|
+
if (value.startsWith(ISSUE_ENV_PREFIX)) connections.set(name, { protocol: 'issued', label: `${value.slice(ISSUE_ENV_PREFIX.length)} (${name}; issued)` });
|
|
541
|
+
}
|
|
542
|
+
return { source: 'config', path, ids, connections, selection: config.selection, declared: declaredTwin(config.services) };
|
|
528
543
|
} catch (error) {
|
|
529
544
|
const configError = error instanceof Error ? error.message : String(error);
|
|
530
545
|
throw new Error(
|
|
@@ -645,8 +660,7 @@ export function detectRepoVendors(repoPath: string): RepoVendorSignals {
|
|
|
645
660
|
detected.set('smtp', [...(detected.get('smtp') ?? []), `env: ${name}`]);
|
|
646
661
|
continue;
|
|
647
662
|
}
|
|
648
|
-
const
|
|
649
|
-
const vendor = stem === null ? undefined : ENV_STEM_VENDORS[stem];
|
|
663
|
+
const vendor = envStemVendor(name, ENV_SUFFIX_BROAD);
|
|
650
664
|
if (vendor) detected.set(vendor, [...(detected.get(vendor) ?? []), `env: ${name}`]);
|
|
651
665
|
}
|
|
652
666
|
|
|
@@ -693,6 +707,44 @@ export function detectRepoVendors(repoPath: string): RepoVendorSignals {
|
|
|
693
707
|
}
|
|
694
708
|
}
|
|
695
709
|
|
|
710
|
+
// a platform's config file that betrays the vendor (a vercel.json declaring crons: the platform calls the app's routes)
|
|
711
|
+
for (const [vendor, facts] of Object.entries(packFacts())) {
|
|
712
|
+
for (const config of facts.adoption?.configFiles ?? []) {
|
|
713
|
+
for (const dir of projectManifestDirs(repo)) {
|
|
714
|
+
const path = join(dir, config.file);
|
|
715
|
+
if (!existsSync(path)) continue;
|
|
716
|
+
let parsed: unknown;
|
|
717
|
+
try { parsed = JSON.parse(readFileSync(path, 'utf8')); } catch { continue; }
|
|
718
|
+
if (config.when !== undefined && !(parsed && typeof parsed === 'object' && config.when in parsed)) continue;
|
|
719
|
+
const where = relative(repo, path) || config.file;
|
|
720
|
+
const evidence = config.usage === 'application' ? `config: ${where}${config.when ? ` (${config.when})` : ''}` : `${config.usage}-tool: config: ${where}`;
|
|
721
|
+
detected.set(vendor, [...(detected.get(vendor) ?? []), evidence]);
|
|
722
|
+
}
|
|
723
|
+
}
|
|
724
|
+
}
|
|
725
|
+
|
|
726
|
+
// Exact names a descriptor's credential door fills are evidence too (ADC names a file, not a _KEY scalar), when the
|
|
727
|
+
// name names the vendor: its first word is the first word of one of the vendor's env stems (GOOGLE_APPLICATION_CREDENTIALS
|
|
728
|
+
// for GOOGLE_OAUTH), or it holds one of the stems whole (SUPERCODE_TUNNEL_SERVER_URL for TUNNEL). A generic name a door
|
|
729
|
+
// also fills (DATABASE_URL, NODE_AUTH_TOKEN) is every vendor's and so no vendor's evidence; a name that already is the
|
|
730
|
+
// vendor's evidence is not counted twice.
|
|
731
|
+
for (const [vendor, facts] of Object.entries(packFacts())) {
|
|
732
|
+
const stems = facts.adoption?.envStems ?? [];
|
|
733
|
+
const words = new Set(stems.map((stem) => stem.split('_')[0]));
|
|
734
|
+
const names = (name: string): boolean => {
|
|
735
|
+
const bare = name.replace(ENV_FRAMEWORK_PREFIX, '');
|
|
736
|
+
return words.has(bare.split('_')[0]!) || stems.some((stem) => `_${bare}_`.includes(`_${stem}_`));
|
|
737
|
+
};
|
|
738
|
+
for (const name of Object.keys(facts.credentialDoor?.fill ?? {})) {
|
|
739
|
+
if (!envNames.includes(name) || !names(name)) continue;
|
|
740
|
+
if (!(detected.get(vendor) ?? []).includes(`env: ${name}`)) detected.set(vendor, [...(detected.get(vendor) ?? []), `env: ${name} (credential issuer)`]);
|
|
741
|
+
}
|
|
742
|
+
}
|
|
743
|
+
// Expand the declared issuer relation transitively, once per vendor; cycles cannot keep detection running.
|
|
744
|
+
for (const vendor of detected.keys()) {
|
|
745
|
+
const issuer = packFacts()[vendor]?.credentialIssuer;
|
|
746
|
+
if (issuer && !detected.has(issuer)) detected.set(issuer, [`credential issuer: ${vendor}`]);
|
|
747
|
+
}
|
|
696
748
|
// unknown external-service-shaped signals (no vendor mapping). A dep from a scope with a
|
|
697
749
|
// twin-mapped sibling counts even when its own name is not service-shaped — the Dub run's
|
|
698
750
|
// @upstash/redis + @upstash/ratelimit disappeared exactly here while @upstash/qstash mapped.
|
|
@@ -713,7 +765,7 @@ export function detectRepoVendors(repoPath: string): RepoVendorSignals {
|
|
|
713
765
|
if (/_TWIN_URL$/.test(name) || /^VOLTER_/.test(name)) continue;
|
|
714
766
|
if (isSmtpEnvSignal(name)) continue; // now a DETECTED vendor (above), no longer an unknown
|
|
715
767
|
const stem = envVendorStem(name, ENV_SUFFIX_STRICT);
|
|
716
|
-
if (stem === null ||
|
|
768
|
+
if (stem === null || envStemVendor(name, ENV_SUFFIX_STRICT) || ENV_STEM_IGNORE.has(stem)) continue;
|
|
717
769
|
// The registry's notExternal suppressions apply to ENV STEMS too — the census adjudicated
|
|
718
770
|
// 40 app-internal stems ("names no vendor") that this loop kept flagging because only the
|
|
719
771
|
// dep-side checks consulted the registry (§9 round two H4, 2026-08-31: 17 of 18 formally
|
|
@@ -849,9 +901,10 @@ export function coverWorld(world: string, repoPath: string, options: CoverageOpt
|
|
|
849
901
|
// An HTTP-valued endpoint variable the world injects from a twin (its endpoint env or a
|
|
850
902
|
// template) is routed to that twin; only an HTTP URL nobody in the world supplies stays unknown.
|
|
851
903
|
const injectedHttp = requirement.protocol === null && binding?.protocol === 'http';
|
|
852
|
-
const
|
|
904
|
+
const issued = binding?.protocol === 'issued';
|
|
905
|
+
const unresolved = !injectedHttp && !issued && (requirement.protocol === null || requirement.env === undefined || (binding !== undefined && binding.protocol === null));
|
|
853
906
|
let status: CoverageStatus = unresolved ? 'unknown-sdk'
|
|
854
|
-
: injectedHttp || binding?.protocol === requirement.protocol ? 'covered' : 'missing';
|
|
907
|
+
: injectedHttp || issued || binding?.protocol === requirement.protocol ? 'covered' : 'missing';
|
|
855
908
|
if (status !== 'covered' && acknowledge[vendor] !== undefined) {
|
|
856
909
|
status = 'acknowledged';
|
|
857
910
|
acknowledged.push({ vendor, reason: acknowledge[vendor]! });
|
package/src/env-output.ts
CHANGED
|
@@ -17,9 +17,9 @@ function inspect(path: string): Buffer | undefined {
|
|
|
17
17
|
}
|
|
18
18
|
|
|
19
19
|
/** Refuse a custody/input file before any World service or lifecycle mutation starts. */
|
|
20
|
-
export function claimEnvOutput(path: string, backupDir: string,
|
|
20
|
+
export function claimEnvOutput(path: string, backupDir: string, priors: PriorOutput[] = [], force = false): EnvOutputClaim {
|
|
21
21
|
const contents = inspect(path);
|
|
22
|
-
if (contents !== undefined && !force && !(prior
|
|
22
|
+
if (contents !== undefined && !force && !priors.some((prior) => prior.path === path && Buffer.from(prior.contents).equals(contents))) {
|
|
23
23
|
throw new Error(`World refuses existing --env-out file it does not own: ${path}. Choose a new output path; never use a custody file. --force explicitly replaces a file with a private backup.`);
|
|
24
24
|
}
|
|
25
25
|
if (force && contents !== undefined) {
|
package/src/fixture-env.ts
CHANGED
|
@@ -149,28 +149,20 @@ export function isGoogleServiceAccountEnvName(name: string): boolean {
|
|
|
149
149
|
* hostname and sends the request to the local twin, while the slug remains unmistakably fake.
|
|
150
150
|
*
|
|
151
151
|
* Like CREDENTIAL_SHAPES below, this is duplicated kernel data with an explicit canonical source:
|
|
152
|
-
* world-runtime must not import a pack, and the tests pin every value against the injector's
|
|
153
|
-
*
|
|
152
|
+
* world-runtime must not import a pack, and the tests pin every value against the injector's host
|
|
153
|
+
* rules (the pack's facts) 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
155
|
// 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: '
|
|
156
|
+
{ match: /(^|_)KV_REST_API_URL$/, value: 'https://twin-fake.upstash.io', source: 'the upstash pack\'s host rules (.upstash.io); lukevella/rallly KV_REST_API_URL' },
|
|
160
157
|
{
|
|
161
158
|
match: /(^|_)UPSTASH_REDIS_REST_URL$/,
|
|
162
159
|
value: 'https://twin-fake.upstash.io',
|
|
163
|
-
source: '
|
|
164
|
-
},
|
|
165
|
-
{
|
|
166
|
-
match: /(^|_)UPSTASH_VECTOR_REST_URL$/,
|
|
167
|
-
value: 'https://twin-fake-us1-vector.upstash.io',
|
|
168
|
-
source: 'packages/world-core/inject.cjs VENDOR_HOSTS.upstashvector',
|
|
160
|
+
source: 'the upstash pack\'s host rules (.upstash.io)',
|
|
169
161
|
},
|
|
170
162
|
{
|
|
171
163
|
match: /(^|_)TINYBIRD_API_URL$/,
|
|
172
164
|
value: 'https://api.tinybird.co',
|
|
173
|
-
source: '
|
|
165
|
+
source: 'the tinybird pack\'s host rules (api.tinybird.co)',
|
|
174
166
|
},
|
|
175
167
|
];
|
|
176
168
|
|
|
@@ -190,19 +182,20 @@ const CREDENTIAL_SHAPES: Array<{ match: RegExp; value: (name: string) => string;
|
|
|
190
182
|
// application id, so a structurally wrong fake breaks before any call reaches the twin.
|
|
191
183
|
{ match: /(^|_)DISCORD_[A-Z0-9_]*(TOKEN|SECRET)$/, value: () => `${btoa('1'.repeat(18)).replace(/=+$/, '')}.Gtwinf.${'t'.repeat(38)}`, source: 'a Discord bot token: three dot-separated base64 segments, the first the application id' },
|
|
192
184
|
// resend-twin.ts enforces bearer auth of the form `re_<...>` whenever a key is presented.
|
|
193
|
-
{ match: /(^|_)RESEND_[A-Z0-9_]*(KEY|TOKEN|SECRET)$/, value: (name) => `re_twinfake_${name.toLowerCase().replace(/_/g, '')}`, source: '
|
|
185
|
+
{ match: /(^|_)RESEND_[A-Z0-9_]*(KEY|TOKEN|SECRET)$/, value: (name) => `re_twinfake_${name.toLowerCase().replace(/_/g, '')}`, source: 'earlier @volter/twin-resend resend-twin.ts' },
|
|
194
186
|
// The vercel twin answers only a bearer it minted, its bootstrap token first (`vercel.platform.token_identity`); any
|
|
195
187
|
// other value is Vercel's 403 "invalid token", so every call an app makes to its Vercel project fails.
|
|
196
|
-
{ match: /(^|_)VERCEL_[A-Z0-9_]*(TOKEN|API_KEY)$/, value: () => 'vercel_twin_bootstrap_token', source: '
|
|
188
|
+
{ match: /(^|_)VERCEL_[A-Z0-9_]*(TOKEN|API_KEY)$/, value: () => 'vercel_twin_bootstrap_token', source: 'earlier @volter/twin-vercel vercel-runtime.ts VERCEL_TWIN_TOKEN' },
|
|
197
189
|
// The tremendous twin takes a key of the shape Tremendous issues, `TEST_` for the sandbox organization at
|
|
198
190
|
// testflight.tremendous.com and `PROD_` for production (manifest.ts `auth.keyFormat`); any other value is Tremendous's
|
|
199
191
|
// 401. The `TEST_` prefix is what selects the sandbox host in clients that branch on it (dub's
|
|
200
192
|
// lib/tremendous/configuration.ts).
|
|
201
|
-
{ match: /(^|_)TREMENDOUS_[A-Z0-9_]*(KEY|TOKEN)$/, value: () => 'TEST_twin_sandbox_api_key', source: '
|
|
202
|
-
// Clerk: the SECRET key is
|
|
193
|
+
{ match: /(^|_)TREMENDOUS_[A-Z0-9_]*(KEY|TOKEN)$/, value: () => 'TEST_twin_sandbox_api_key', source: 'earlier @volter/twin-tremendous manifest.ts auth.keyFormat' },
|
|
194
|
+
// Clerk: the SECRET key is the instance's own (the clerk twin takes this one and the keys its API Keys door makes,
|
|
195
|
+
// earlier @volter/twin-clerk server.ts CLERK_TWIN_SECRET_KEY), and the PUBLISHABLE key is parsed by
|
|
203
196
|
// clerk-js — `pk_test_<base64(frontendApiHost$)>` names the frontend-API host the browser calls.
|
|
204
197
|
// The world issues one naming the dev-instance family the clerk pack CLAIMS by suffix
|
|
205
|
-
// (`.clerk.accounts.dev`,
|
|
198
|
+
// (`.clerk.accounts.dev`, earlier @volter/twin-clerk index.ts hosts), so the boundary — the injector
|
|
206
199
|
// server-side, the browser proxy browser-side — resolves it to the twin, and an app CSP that
|
|
207
200
|
// allowlists that host stays legal (peak drive 2026-08-26).
|
|
208
201
|
// Stripe's keys carry their kind in their prefix, and applications check it before calling (Cal.com's Stripe app
|
|
@@ -210,15 +203,14 @@ const CREDENTIAL_SHAPES: Array<{ match: RegExp; value: (name: string) => string;
|
|
|
210
203
|
{ match: /(^|_)STRIPE_[A-Z0-9_]*WEBHOOK_SECRET$/, value: () => 'whsec_twinfake0000000000000000000000000000', source: 'https://docs.stripe.com/webhooks#verify-events' },
|
|
211
204
|
{ match: /(^|_)STRIPE_[A-Z0-9_]*(SECRET|PRIVATE|API)(_API)?_KEY$/, value: () => 'sk_test_twinfake000000000000000000000000000000', source: 'https://docs.stripe.com/keys (sk_test_)' },
|
|
212
205
|
{ match: /(^|_)STRIPE_[A-Z0-9_]*(PUBLISHABLE|PUBLIC)(_API)?_KEY$/, value: () => 'pk_test_twinfake000000000000000000000000000000', source: 'https://docs.stripe.com/keys (pk_test_)' },
|
|
213
|
-
// The slack twin answers a bearer that is none of Slack's token kinds with `invalid_auth`, as Slack does; any `xoxb-`
|
|
214
|
-
// acts as the workspace bot, and an app-level token is `xapp-`.
|
|
215
|
-
{ match: /(^|_)SLACK_[A-Z0-9_]*APP_TOKEN$/, value: () => 'xapp-1-twinfake', source: 'packages/twin/slack/src/slack-server.ts (bearer /^(xox|xapp-)/)' },
|
|
216
|
-
{ match: /(^|_)SLACK_([A-Z0-9_]*_)?BOT_TOKEN$|(^|_)SLACK_TOKEN$/, value: () => 'xoxb-twinfake', source: 'packages/twin/slack/src/slack-server.ts (bearer /^(xox|xapp-)/); GET /twin identity (any other xoxb-* is the workspace bot)' },
|
|
217
206
|
// tavily-auth.ts and firecrawl-auth.ts refuse a key without the vendor's prefix with the vendor's 401
|
|
218
|
-
{ match: /(^|_)TAVILY_[A-Z0-9_]*(KEY|TOKEN)$/, value: () => 'tvly-twinfake00000000000000000000', source: '
|
|
219
|
-
{ match: /(^|_)FIRECRAWL_[A-Z0-9_]*(KEY|TOKEN)$/, value: () => 'fc-twinfake00000000000000000000000', source: '
|
|
220
|
-
|
|
221
|
-
|
|
207
|
+
{ match: /(^|_)TAVILY_[A-Z0-9_]*(KEY|TOKEN)$/, value: () => 'tvly-twinfake00000000000000000000', source: 'earlier @volter/twin-tavily tavily-auth.ts' },
|
|
208
|
+
{ match: /(^|_)FIRECRAWL_[A-Z0-9_]*(KEY|TOKEN)$/, value: () => 'fc-twinfake00000000000000000000000', source: 'earlier @volter/twin-firecrawl firecrawl-auth.ts FIRECRAWL_KEY_PREFIX' },
|
|
209
|
+
// a Clerk webhook's signing secret is Svix's (`whsec_` and base64), which the app's verifier decodes; the World registers
|
|
210
|
+
// its endpoint with the same secret (the Clerk twin's webhooks door takes `signing_secret`)
|
|
211
|
+
{ match: /(^|_)CLERK_WEBHOOK(_SIGNING)?_SECRET$/, value: () => `whsec_${Buffer.from('twin-fake-clerk-webhook-secret!!').toString('base64')}`, source: 'https://clerk.com/docs/webhooks/sync-data (CLERK_WEBHOOK_SIGNING_SECRET, whsec_); https://docs.svix.com/receiving/verifying-payloads/how-manual' },
|
|
212
|
+
{ match: /(^|_)CLERK_[A-Z0-9_]*SECRET[A-Z0-9_]*$/, value: () => 'sk_test_twinfake000000000000000000000000000000', source: 'earlier @volter/twin-clerk manifest.ts' },
|
|
213
|
+
{ match: /(^|_)CLERK_[A-Z0-9_]*PUBLISHABLE[A-Z0-9_]*$/, value: () => `pk_test_${Buffer.from('twin.clerk.accounts.dev$').toString('base64')}`, source: 'earlier @volter/twin-clerk index.ts' },
|
|
222
214
|
];
|
|
223
215
|
|
|
224
216
|
/** Keys the APPLICATION parses before it can use them: a fake that is not the shape its own code decodes throws at the
|
package/src/host-cli.ts
CHANGED
|
@@ -33,11 +33,13 @@ for (let i = 0; i < argv.length; i++) {
|
|
|
33
33
|
const scenarioPath = flags.find((f) => f.startsWith('scenario='))?.slice('scenario='.length);
|
|
34
34
|
// the World's managed database a pack declaring `managedDatabase` is bound to (runtime.ts, managedDatabaseFor)
|
|
35
35
|
const database = flags.find((f) => f.startsWith('database='))?.slice('database='.length);
|
|
36
|
+
// the twin's side ports the runtime allocated (TwinPack.sidePorts): `side.<factory option>=<port>`
|
|
37
|
+
const sideOptions = Object.fromEntries(flags.filter((f) => /^side\.[A-Za-z0-9]+=\d+$/.test(f)).map((f) => { const [k, v] = f.slice('side.'.length).split('='); return [k!, Number(v)]; }));
|
|
36
38
|
if (!id || !module || !exportName || !port || !root) {
|
|
37
|
-
process.stderr.write(`bad --spec "${raw}" (want id|module|export|port|root[|ro][|scenario=<handlers file>][|database=<url>])\n`);
|
|
39
|
+
process.stderr.write(`bad --spec "${raw}" (want id|module|export|port|root[|ro][|scenario=<handlers file>][|database=<url>][|side.<option>=<port>…])\n`);
|
|
38
40
|
process.exit(2);
|
|
39
41
|
}
|
|
40
|
-
specs.push({ id, module, export: exportName, port: Number(port), root, readOnly: ro === 'ro', ...(scenarioPath ? { scenarioPath } : {}), ...(database ? { database } : {}) });
|
|
42
|
+
specs.push({ id, module, export: exportName, port: Number(port), root, readOnly: ro === 'ro', ...(scenarioPath ? { scenarioPath } : {}), ...(database ? { database } : {}), ...(Object.keys(sideOptions).length ? { sideOptions } : {}) });
|
|
41
43
|
}
|
|
42
44
|
if (specs.length === 0) { process.stderr.write('no --spec given\n'); process.exit(2); }
|
|
43
45
|
|
package/src/host-ready.ts
CHANGED
|
@@ -10,9 +10,16 @@ export function publishHostReady(path: string, boot: string, ports: Record<strin
|
|
|
10
10
|
renameSync(temporary, path);
|
|
11
11
|
}
|
|
12
12
|
|
|
13
|
-
/**
|
|
14
|
-
|
|
15
|
-
|
|
13
|
+
/**
|
|
14
|
+
* Receipt admission is cancellation-aware; stale or malformed state is never
|
|
15
|
+
* readiness. `timeoutMs` bounds a host that stops making progress, not a slow
|
|
16
|
+
* one: each change in `progress` (the host log's size, which grows with every
|
|
17
|
+
* twin it starts) begins the bound again, so a host still starting its twins
|
|
18
|
+
* on a busy machine is waited for, and one silent for `timeoutMs` is not.
|
|
19
|
+
*/
|
|
20
|
+
export async function waitForHostReady(path: string, boot: string, ports: ReadonlyMap<string, number>, timeoutMs: number, abort: AbortSignal, progress: () => number = () => 0): Promise<void> {
|
|
21
|
+
let started = Date.now();
|
|
22
|
+
let seen = progress();
|
|
16
23
|
for (;;) {
|
|
17
24
|
abort.throwIfAborted();
|
|
18
25
|
let bytes: Buffer | undefined;
|
|
@@ -30,7 +37,9 @@ export async function waitForHostReady(path: string, boot: string, ports: Readon
|
|
|
30
37
|
}
|
|
31
38
|
return;
|
|
32
39
|
}
|
|
33
|
-
|
|
40
|
+
const now = progress();
|
|
41
|
+
if (now !== seen) { seen = now; started = Date.now(); }
|
|
42
|
+
if (Date.now() - started >= timeoutMs) throw new Error(`The co-located host made no progress for ${timeoutMs} ms before completing every declared start.`);
|
|
34
43
|
await new Promise<void>((resolve, reject) => {
|
|
35
44
|
const cancel = (): void => { clearTimeout(timer); abort.removeEventListener('abort', cancel); reject(abort.reason); };
|
|
36
45
|
const timer = setTimeout(() => { abort.removeEventListener('abort', cancel); resolve(); }, Math.min(25, Math.max(1, timeoutMs - (Date.now() - started))));
|
package/src/host-worker.ts
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { getPack, registerPack, type TwinPack } from '@volter/world-core/runtime';
|
|
1
2
|
// Worker entry for 'worker' isolation: one Worker thread per twin. It owns its event
|
|
2
3
|
// loop + heap, so a CPU hog / leak / hard crash here is contained to this thread — the
|
|
3
4
|
// host does NOT restart it: it gives up on the worker (fires a `gaveup` event → doctor
|
|
@@ -9,16 +10,19 @@ import { importModule } from './import-module.ts';
|
|
|
9
10
|
const spec = workerData as ColocatedTwinSpec;
|
|
10
11
|
|
|
11
12
|
type TwinServer = { port: number; stop: () => void | Promise<void> };
|
|
12
|
-
type TwinServerFactory = (opts: { port?: number; root?: string; readOnly?: boolean; database?: string }) => TwinServer | Promise<TwinServer>;
|
|
13
|
+
type TwinServerFactory = (opts: { port?: number; root?: string; readOnly?: boolean; database?: string; [side: string]: unknown }) => TwinServer | Promise<TwinServer>;
|
|
13
14
|
|
|
14
15
|
const mod = await importModule(spec.module);
|
|
16
|
+
const descriptor = mod.pack as TwinPack | undefined ?? getPack(spec.id);
|
|
17
|
+
if (!descriptor || descriptor.vendor !== spec.id) throw new Error(`Twin ${spec.id}: the loaded module declares no matching pack descriptor`);
|
|
18
|
+
registerPack(descriptor);
|
|
15
19
|
const factory = mod[spec.export] as TwinServerFactory | undefined;
|
|
16
20
|
if (typeof factory !== 'function') {
|
|
17
21
|
throw new Error(`Twin "${spec.id}": ${spec.module} has no factory export "${spec.export}"`);
|
|
18
22
|
}
|
|
19
23
|
|
|
20
24
|
// Awaited: an async factory (fly) must post `ready` only once its listener is bound.
|
|
21
|
-
const server = await factory({ port: spec.port, root: spec.root, readOnly: spec.readOnly, ...(spec.database !== undefined ? { database: spec.database } : {}) });
|
|
25
|
+
const server = await factory({ ...(spec.sideOptions ?? {}), port: spec.port, root: spec.root, readOnly: spec.readOnly, ...(spec.database !== undefined ? { database: spec.database } : {}) });
|
|
22
26
|
parentPort?.postMessage({ type: 'ready', port: server.port });
|
|
23
27
|
|
|
24
28
|
// The Bun.serve listener keeps this worker's event loop alive; nothing else to do.
|
package/src/host.ts
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { getPack, registerPack, type TwinPack } from '@volter/world-core/runtime';
|
|
1
2
|
// Co-located twin host — run many twins in ONE process instead of one OS process
|
|
2
3
|
// per twin. The twin core is a pure, transport-agnostic handler (see a pack's
|
|
3
4
|
// `handle<Vendor>TwinRequest`), so nothing requires a process per twin; this host
|
|
@@ -41,6 +42,8 @@ export type ColocatedTwinSpec = {
|
|
|
41
42
|
/** The World's managed database a pack declaring `managedDatabase` serves over (runtime.ts, managedDatabaseFor):
|
|
42
43
|
* the colocated form of the spawn path's `serve <arg> <url>`. */
|
|
43
44
|
database?: string;
|
|
45
|
+
/** The twin's side ports the runtime allocated (TwinPack.sidePorts), as the factory's options (`inspectPort`). */
|
|
46
|
+
sideOptions?: Record<string, number>;
|
|
44
47
|
};
|
|
45
48
|
|
|
46
49
|
export type HostIsolation = 'shared' | 'worker';
|
|
@@ -59,10 +62,13 @@ export type ColocatedHost = {
|
|
|
59
62
|
// made host.stop() throw `s.stop is not a function`, turning every SIGTERM into downWorld's
|
|
60
63
|
// SIGKILL escalation for the whole world).
|
|
61
64
|
type TwinServer = { port: number; stop: () => void | Promise<void> };
|
|
62
|
-
type TwinServerFactory = (opts: { port?: number; root?: string; readOnly?: boolean; scenarioPath?: string; database?: string }) => TwinServer | Promise<TwinServer>;
|
|
65
|
+
type TwinServerFactory = (opts: { port?: number; root?: string; readOnly?: boolean; scenarioPath?: string; database?: string; [side: string]: unknown }) => TwinServer | Promise<TwinServer>;
|
|
63
66
|
|
|
64
67
|
async function loadFactory(spec: ColocatedTwinSpec): Promise<TwinServerFactory> {
|
|
65
68
|
const mod = await importModule(spec.module);
|
|
69
|
+
const descriptor = mod.pack as TwinPack | undefined ?? getPack(spec.id);
|
|
70
|
+
if (!descriptor || descriptor.vendor !== spec.id) throw new Error(`Twin ${spec.id}: the loaded module declares no matching pack descriptor`);
|
|
71
|
+
registerPack(descriptor);
|
|
66
72
|
const factory = mod[spec.export];
|
|
67
73
|
if (typeof factory !== 'function') {
|
|
68
74
|
throw new Error(`Twin "${spec.id}": ${spec.module} has no factory export "${spec.export}"`);
|
|
@@ -122,13 +128,16 @@ export async function startColocatedHost(
|
|
|
122
128
|
try {
|
|
123
129
|
await parallelStartup(specs, async (spec) => {
|
|
124
130
|
const factory = await loadFactory(spec);
|
|
125
|
-
const server = await factory({ port: spec.port, root: spec.root, readOnly: spec.readOnly, ...(spec.scenarioPath !== undefined ? { scenarioPath: spec.scenarioPath } : {}), ...(spec.database !== undefined ? { database: spec.database } : {}) });
|
|
131
|
+
const server = await factory({ ...(spec.sideOptions ?? {}), port: spec.port, root: spec.root, readOnly: spec.readOnly, ...(spec.scenarioPath !== undefined ? { scenarioPath: spec.scenarioPath } : {}), ...(spec.database !== undefined ? { database: spec.database } : {}) });
|
|
126
132
|
// Record the handle before validating it, so rollback also owns a bad factory result.
|
|
127
133
|
servers.push(server);
|
|
128
134
|
if (!Number.isInteger(server.port) || server.port < 1 || server.port > 65535 || (spec.port !== 0 && server.port !== spec.port)) {
|
|
129
135
|
throw new Error(`Twin "${spec.id}" bound port ${server.port}, requested ${spec.port}.`);
|
|
130
136
|
}
|
|
131
137
|
ports[spec.id] = server.port;
|
|
138
|
+
// Each start is progress the runtime's readiness wait counts (host-ready.ts); the line says when,
|
|
139
|
+
// in milliseconds since the host process began, since the log's own lines carry no time.
|
|
140
|
+
onEvent({ twin: spec.id, kind: 'started', detail: `port ${server.port} at ${Math.round(performance.now())} ms` });
|
|
132
141
|
});
|
|
133
142
|
} catch (error) {
|
|
134
143
|
const cleanup = await stop();
|
package/src/index.ts
CHANGED
|
@@ -99,9 +99,9 @@ export type { CoverageReport, CoverageRow, CoverageStatus, CoverageOptions, Repo
|
|
|
99
99
|
// world config + env file OUTSIDE the repo (fixture-env), and prove the emission with `covers`.
|
|
100
100
|
export { formatInitReport, initWorld, packCatalog, planWorldInit, renderEnvFile, writeWorldInit } from './init.ts';
|
|
101
101
|
export { STATE_GITIGNORE } from './init.ts';
|
|
102
|
-
export { clockFile } from './runtime.ts';
|
|
102
|
+
export { clockFile, instanceDir } from './runtime.ts';
|
|
103
103
|
export { checkoutCatalog, findInstalledPackage, installedCatalog, resolveCatalog, resolveServicePackage, twinPackageName, TWIN_PACKAGE_PREFIX, type Catalog, type CatalogKind, packCli, packEntry } from './catalog.ts';
|
|
104
|
-
export { MINT_ENV } from './schema.ts';
|
|
104
|
+
export { ISSUE_ENV_PREFIX, MINT_ENV } from './schema.ts';
|
|
105
105
|
export type {
|
|
106
106
|
EnvDisposition,
|
|
107
107
|
InitEnvRow,
|