@volter/world-runtime 3.0.38 → 3.0.39
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 +3 -10
- package/dist/src/actions-runner-cli.js +3 -3
- package/dist/src/actions-runner.js +2 -1
- package/dist/src/app-url.js +2 -1
- package/dist/src/backings.d.ts +10 -1
- package/dist/src/backings.js +66 -7
- package/dist/src/branch.js +85 -52
- package/dist/src/browser-routes.cjs +59 -0
- package/dist/src/ca-trust.js +4 -3
- package/dist/src/changeset.js +4 -3
- package/dist/src/cli-commands.js +25 -9
- package/dist/src/cli.js +2 -1
- package/dist/src/command-lifetime.d.mts +12 -0
- package/dist/src/command-lifetime.d.ts +1 -15
- package/dist/src/command-lifetime.js +1 -98
- package/dist/src/command-lifetime.mjs +90 -0
- package/dist/src/configs.js +1 -1
- package/dist/src/console-apart.js +3 -2
- package/dist/src/covers.d.ts +3 -2
- package/dist/src/covers.js +95 -18
- package/dist/src/error-message.d.mts +1 -0
- package/dist/src/error-message.d.ts +1 -0
- package/dist/src/error-message.js +2 -0
- package/dist/src/error-message.mjs +10 -0
- package/dist/src/guest-command.d.mts +4 -0
- package/dist/src/guest-command.mjs +26 -0
- package/dist/src/guest-control.d.mts +1 -0
- package/dist/src/guest-control.mjs +17 -0
- package/dist/src/guest-entry.mjs +146 -0
- package/dist/src/guest-processes.d.mts +9 -0
- package/dist/src/guest-processes.mjs +47 -0
- package/dist/src/guest-runner.d.ts +6 -0
- package/dist/src/guest-runner.js +567 -0
- package/dist/src/host.js +5 -4
- package/dist/src/index.d.ts +1 -0
- package/dist/src/index.js +1 -0
- package/dist/src/infra-cli.js +4 -0
- package/dist/src/infra.js +133 -23
- package/dist/src/init.js +60 -23
- package/dist/src/lifecycle-record.d.ts +3 -1
- package/dist/src/lifecycle-record.js +29 -6
- package/dist/src/livekit-backing.mjs +37 -0
- package/dist/src/local-branches.js +11 -3
- package/dist/src/mongodb-backing.d.ts +7 -0
- package/dist/src/mongodb-backing.js +58 -0
- package/dist/src/native-postgres-backing.d.ts +4 -0
- package/dist/src/native-postgres-backing.js +404 -0
- package/dist/src/origin.js +2 -1
- package/dist/src/owned-path.d.ts +4 -0
- package/dist/src/owned-path.js +37 -0
- package/dist/src/pack-facts.d.ts +1 -0
- package/dist/src/pglite-backing.d.ts +4 -13
- package/dist/src/pglite-backing.js +198 -106
- package/dist/src/pglite-host.mjs +47 -17
- package/dist/src/placeholder.d.ts +3 -2
- package/dist/src/placeholder.js +9 -4
- package/dist/src/process-groups.d.mts +2 -0
- package/dist/src/process-groups.d.ts +1 -4
- package/dist/src/process-groups.js +1 -49
- package/dist/src/process-groups.mjs +59 -0
- package/dist/src/process-receipt.d.mts +6 -0
- package/dist/src/process-receipt.d.ts +11 -0
- package/dist/src/process-receipt.js +3 -0
- package/dist/src/process-receipt.mjs +45 -0
- package/dist/src/process-signal.d.mts +9 -0
- package/dist/src/process-signal.mjs +92 -0
- package/dist/src/project-inspect.d.ts +1 -1
- package/dist/src/project-inspect.js +4 -2
- package/dist/src/proxy-daemon.js +2 -1
- package/dist/src/redirect-proxy.js +186 -19
- package/dist/src/redis-backing.js +99 -35
- package/dist/src/reflect.js +2 -1
- package/dist/src/root.js +7 -6
- package/dist/src/run-task-worker.js +2 -1
- package/dist/src/runtime.d.ts +24 -7
- package/dist/src/runtime.js +703 -286
- package/dist/src/schema.d.ts +12 -1
- package/dist/src/schema.js +20 -5
- package/dist/src/served-world.d.ts +1 -0
- package/dist/src/served-world.js +26 -14
- package/dist/src/service-recorder.js +8 -3
- package/dist/src/signals.d.mts +1 -0
- package/dist/src/signals.d.ts +1 -1
- package/dist/src/signals.js +1 -11
- package/dist/src/signals.mjs +10 -0
- package/dist/src/tail.d.ts +2 -0
- package/dist/src/tail.js +9 -2
- package/dist/src/tcp-port.js +27 -13
- package/dist/src/temporal-backing.mjs +28 -0
- package/dist/src/up-cli.js +8 -3
- package/dist/src/up-task-worker.js +2 -1
- package/dist/src/websocket-relay.d.ts +1 -1
- package/dist/src/websocket-relay.js +23 -10
- package/dist/src/world-view.js +2 -2
- package/known-external-services.json +3 -10
- package/package.json +12 -7
- package/src/actions-runner-cli.ts +3 -3
- package/src/actions-runner.ts +2 -1
- package/src/app-url.ts +2 -1
- package/src/backings.ts +65 -7
- package/src/branch.ts +73 -34
- package/src/browser-routes.cjs +59 -0
- package/src/ca-trust.ts +4 -3
- package/src/changeset.ts +4 -3
- package/src/cli-commands.ts +21 -9
- package/src/cli.ts +2 -1
- package/src/command-lifetime.d.mts +12 -0
- package/src/command-lifetime.mjs +90 -0
- package/src/command-lifetime.ts +1 -79
- package/src/configs.ts +1 -1
- package/src/console-apart.ts +2 -1
- package/src/covers.ts +97 -19
- package/src/error-message.d.mts +1 -0
- package/src/error-message.mjs +10 -0
- package/src/error-message.ts +2 -0
- package/src/guest-command.d.mts +4 -0
- package/src/guest-command.mjs +26 -0
- package/src/guest-control.d.mts +1 -0
- package/src/guest-control.mjs +17 -0
- package/src/guest-entry.mjs +146 -0
- package/src/guest-processes.d.mts +9 -0
- package/src/guest-processes.mjs +47 -0
- package/src/guest-runner.ts +414 -0
- package/src/host.ts +5 -4
- package/src/index.ts +1 -0
- package/src/infra-cli.ts +4 -0
- package/src/infra.ts +103 -22
- package/src/init.ts +55 -22
- package/src/lifecycle-record.ts +21 -6
- package/src/livekit-backing.mjs +37 -0
- package/src/local-branches.ts +6 -3
- package/src/mongodb-backing.ts +55 -0
- package/src/native-postgres-backing.ts +329 -0
- package/src/origin.ts +2 -1
- package/src/owned-path.ts +32 -0
- package/src/pack-facts.ts +1 -0
- package/src/pglite-backing.ts +155 -79
- package/src/pglite-host.mjs +47 -17
- package/src/placeholder.ts +10 -5
- package/src/process-groups.d.mts +2 -0
- package/src/process-groups.mjs +59 -0
- package/src/process-groups.ts +1 -33
- package/src/process-receipt.d.mts +6 -0
- package/src/process-receipt.mjs +45 -0
- package/src/process-receipt.ts +5 -0
- package/src/process-signal.d.mts +9 -0
- package/src/process-signal.mjs +92 -0
- package/src/project-inspect.ts +4 -2
- package/src/proxy-daemon.ts +2 -1
- package/src/redirect-proxy.ts +153 -18
- package/src/redis-backing.ts +74 -25
- package/src/reflect.ts +2 -1
- package/src/root.ts +7 -6
- package/src/run-task-worker.ts +2 -1
- package/src/runtime.ts +596 -265
- package/src/schema.ts +30 -6
- package/src/served-world.ts +21 -15
- package/src/service-recorder.ts +4 -2
- package/src/signals.d.mts +1 -0
- package/src/signals.mjs +10 -0
- package/src/signals.ts +1 -10
- package/src/tail.ts +10 -3
- package/src/tcp-port.ts +24 -13
- package/src/temporal-backing.mjs +28 -0
- package/src/up-cli.ts +6 -3
- package/src/up-task-worker.ts +2 -1
- package/src/websocket-relay.ts +18 -11
- package/src/world-view.ts +2 -2
package/src/schema.ts
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { errorMessage } from './error-message.ts';
|
|
1
2
|
import { normalizeWorldNetwork, type WorldNetwork } from '@volter/world-core/network-policy';
|
|
2
3
|
|
|
3
4
|
export type WorldServiceType = 'twin' | 'process' | 'external';
|
|
@@ -188,6 +189,8 @@ export type WorldServiceConfig = {
|
|
|
188
189
|
* to loopback services directly while presenting a disguised world to the app/user.
|
|
189
190
|
*/
|
|
190
191
|
controlPlane?: boolean;
|
|
192
|
+
/** Refuse branching before child compute when this service cannot carry its state to a branch. */
|
|
193
|
+
branchState?: 'unsupported';
|
|
191
194
|
injectEnv?: string;
|
|
192
195
|
/**
|
|
193
196
|
* Additional env vars exported into the generated world env after this service starts.
|
|
@@ -259,7 +262,7 @@ export type WorldConfig = {
|
|
|
259
262
|
id: string;
|
|
260
263
|
/** How changesets are made and readied here; omitted: one approval, a summary required. */
|
|
261
264
|
changesets?: WorldChangesetRules;
|
|
262
|
-
/**
|
|
265
|
+
/** HTTPS origin/exact-path/subtree grants with declared phase and explicit build passthrough; [] closes real egress. */
|
|
263
266
|
network?: WorldNetwork;
|
|
264
267
|
/** Internal normalized source format and saved discovery selection. */
|
|
265
268
|
manifestVersion?: 1 | 2;
|
|
@@ -297,6 +300,8 @@ export type WorldConfig = {
|
|
|
297
300
|
};
|
|
298
301
|
|
|
299
302
|
export type WorldServiceInstance = {
|
|
303
|
+
/** Explicit boot permission survives config edits and missing-field refusals. */
|
|
304
|
+
branchState?: 'supported' | 'unsupported';
|
|
300
305
|
id: string;
|
|
301
306
|
type: WorldServiceType;
|
|
302
307
|
command: string[];
|
|
@@ -392,6 +397,8 @@ export type WorldRunRecord = WorldRunOutcome | {
|
|
|
392
397
|
|
|
393
398
|
export type WorldInstance = {
|
|
394
399
|
name: string;
|
|
400
|
+
/** Unique boot identity, independent of the World clock; ordinary reading completes older records. */
|
|
401
|
+
instance: string;
|
|
395
402
|
/** Snapshot of the selection in force when this instance booted. */
|
|
396
403
|
selection?: WorldSelection;
|
|
397
404
|
config: string;
|
|
@@ -406,6 +413,10 @@ export type WorldInstance = {
|
|
|
406
413
|
};
|
|
407
414
|
services: Record<string, WorldServiceInstance>;
|
|
408
415
|
env: Record<string, string>;
|
|
416
|
+
/** Always written at boot; optional only so cleanup can read incomplete legacy records. */
|
|
417
|
+
networkCeiling?: WorldNetwork | 'unrestricted';
|
|
418
|
+
bootServices?: string[];
|
|
419
|
+
bootOwner?: string | null;
|
|
409
420
|
envFile: string;
|
|
410
421
|
pidsFile: string;
|
|
411
422
|
actors?: Record<string, unknown>;
|
|
@@ -423,6 +434,14 @@ export type WorldInstance = {
|
|
|
423
434
|
packFacts?: string[];
|
|
424
435
|
};
|
|
425
436
|
|
|
437
|
+
export function isEnvironmentName(value: unknown): value is string {
|
|
438
|
+
return typeof value === 'string' && /^[A-Za-z_][A-Za-z0-9_]*$/.test(value);
|
|
439
|
+
}
|
|
440
|
+
|
|
441
|
+
function environmentMap(value: unknown): boolean {
|
|
442
|
+
return record(value) && Object.entries(value).every(([name, entry]) => isEnvironmentName(name) && typeof entry === 'string');
|
|
443
|
+
}
|
|
444
|
+
|
|
426
445
|
function assertStringArray(value: unknown, what: string, path: string): asserts value is string[] {
|
|
427
446
|
if (!Array.isArray(value) || value.length === 0 || value.some((item) => typeof item !== 'string')) {
|
|
428
447
|
throw new Error(`${what} must be a non-empty string array in ${path}`);
|
|
@@ -433,7 +452,7 @@ function assertValidRegex(pattern: string, what: string, path: string): void {
|
|
|
433
452
|
try {
|
|
434
453
|
new RegExp(pattern);
|
|
435
454
|
} catch (error) {
|
|
436
|
-
throw new Error(`${what} has an invalid regex (${
|
|
455
|
+
throw new Error(`${what} has an invalid regex (${errorMessage(error)}) in ${path}`);
|
|
437
456
|
}
|
|
438
457
|
}
|
|
439
458
|
|
|
@@ -530,7 +549,7 @@ function assertLegacyWorldConfig(value: unknown, path: string): WorldConfig {
|
|
|
530
549
|
}
|
|
531
550
|
if (config.network !== undefined) config.network = normalizeWorldNetwork(config.network);
|
|
532
551
|
if (config.description !== undefined && typeof config.description !== 'string') throw new Error(`World config description must be a string in ${path}`);
|
|
533
|
-
if (config.env !== undefined &&
|
|
552
|
+
if (config.env !== undefined && !environmentMap(config.env)) throw new Error(`World config env must be a string→string object in ${path}`);
|
|
534
553
|
if (config.env?.VOLTER_WORLD_NETWORK_POLICY !== undefined) throw new Error(`World network permission belongs in runtime.network, not environment values in ${path}`);
|
|
535
554
|
for (const key of ['actors', 'fixtures'] as const) if (config[key] !== undefined && !record(config[key])) throw new Error(`World config ${key} must be an object in ${path}`);
|
|
536
555
|
if (config.bare !== undefined && (typeof config.bare !== 'object' || typeof (config.bare as { name?: unknown }).name !== 'string' || !/^[a-z0-9_-]+\/[a-z0-9_-]+$/i.test((config.bare as { name: string }).name))) {
|
|
@@ -568,7 +587,10 @@ function assertLegacyWorldConfig(value: unknown, path: string): WorldConfig {
|
|
|
568
587
|
for (const service of config.services as Partial<WorldServiceConfig>[]) {
|
|
569
588
|
if (!service || typeof service !== 'object') throw new Error(`Invalid service in ${path}`);
|
|
570
589
|
if (!service.id || typeof service.id !== 'string') throw new Error(`Service must define string id in ${path}`);
|
|
590
|
+
if (service.branchState !== undefined && service.branchState !== 'unsupported') throw new Error(`Service ${service.id} branchState must be unsupported in ${path}`);
|
|
571
591
|
const exports = [service.injectEnv, ...Object.keys(service.injectEnvTemplates ?? {}), ...Object.keys(service.cliRedirect ?? {}), ...(Array.isArray(service.external?.discover) ? service.external.discover.map(entry => entry?.as) : [])];
|
|
592
|
+
if (exports.some(name => name !== undefined && !isEnvironmentName(name))) throw new Error(`Service ${service.id} bindings must name environment variables in ${path}`);
|
|
593
|
+
if (service.issues !== undefined && (!record(service.issues) || typeof service.issues.path !== 'string' || !record(service.issues.body) || !environmentMap(service.issues.fill))) throw new Error(`Service ${service.id} issues must define path, body and string→string fill in ${path}`);
|
|
572
594
|
if (service.env?.VOLTER_WORLD_NETWORK_POLICY !== undefined || exports.includes('VOLTER_WORLD_NETWORK_POLICY')) throw new Error(`Service ${service.id} cannot replace World network permission in ${path}`);
|
|
573
595
|
if (!/^[a-z0-9][a-z0-9._-]*$/.test(service.id)) throw new Error(`Invalid service id "${service.id}" in ${path}`);
|
|
574
596
|
if (ids.has(service.id)) throw new Error(`Duplicate service id "${service.id}" in ${path}`);
|
|
@@ -638,7 +660,7 @@ function assertLegacyWorldConfig(value: unknown, path: string): WorldConfig {
|
|
|
638
660
|
}
|
|
639
661
|
}
|
|
640
662
|
if (service.version !== undefined && !(typeof service.version === 'string' && /^\^?\d+\.\d+(\.\d+)?$/.test(service.version))) throw new Error(`Service "${service.id}" version must pin the twin package as "major.minor.patch" or "^major.minor" in ${path}`);
|
|
641
|
-
if (service.env !== undefined &&
|
|
663
|
+
if (service.env !== undefined && !environmentMap(service.env)) throw new Error(`Service "${service.id}" env must be a string→string object in ${path}`);
|
|
642
664
|
if (service.injectEnv !== undefined && typeof service.injectEnv !== 'string') {
|
|
643
665
|
throw new Error(`Service "${service.id}" injectEnv must be a string in ${path}`);
|
|
644
666
|
}
|
|
@@ -743,7 +765,7 @@ function serviceV2ToLegacy(value: unknown, path: string): WorldServiceConfig {
|
|
|
743
765
|
const bindings = value.bindings === undefined ? {} : value.bindings;
|
|
744
766
|
if (!record(source) || !record(execution) || !record(endpoint) || !record(bindings)) throw new Error(`Service "${String(value.id ?? '')}" sections must be objects in ${path}`);
|
|
745
767
|
assertKeys(source, ['package', 'version'], 'service.source', path);
|
|
746
|
-
assertKeys(execution, ['process', 'colocate', 'lifecycle', 'cwd', 'environment', 'preload', 'controlPlane'], 'service.execution', path);
|
|
768
|
+
assertKeys(execution, ['process', 'colocate', 'lifecycle', 'cwd', 'environment', 'preload', 'controlPlane', 'branchState'], 'service.execution', path);
|
|
747
769
|
assertKeys(endpoint, ['port', 'portReason', 'ready'], 'service.endpoint', path);
|
|
748
770
|
assertKeys(bindings, ['injectEnv', 'injectEnvTemplates', 'cliRedirect', 'discover'], 'service.bindings', path);
|
|
749
771
|
const process = execution.process === undefined ? {} : execution.process;
|
|
@@ -795,6 +817,7 @@ function serviceV2ToLegacy(value: unknown, path: string): WorldServiceConfig {
|
|
|
795
817
|
...(environment.values === undefined ? {} : { env: environment.values as Record<string, string> }),
|
|
796
818
|
...(execution.preload === undefined ? {} : { preload: execution.preload as string[] }),
|
|
797
819
|
...(execution.controlPlane === undefined ? {} : { controlPlane: execution.controlPlane as boolean }),
|
|
820
|
+
...(execution.branchState === undefined ? {} : { branchState: execution.branchState as WorldServiceConfig['branchState'] }),
|
|
798
821
|
...(endpoint.port === undefined ? {} : { port: endpoint.port as number | 'auto' }),
|
|
799
822
|
...(endpoint.portReason === undefined ? {} : { portReason: endpoint.portReason as string }),
|
|
800
823
|
...(endpoint.ready === undefined ? {} : { ready: endpoint.ready as WorldServiceConfig['ready'] }),
|
|
@@ -829,7 +852,7 @@ function normalizeV2(value: Record<string, unknown>, path: string): WorldConfig
|
|
|
829
852
|
const environment = runtime.environment ?? {};
|
|
830
853
|
if (!record(environment)) throw new Error(`runtime.environment must be an object in ${path}`);
|
|
831
854
|
assertKeys(environment, ['values', 'strip'], 'runtime.environment', path);
|
|
832
|
-
if (environment.values !== undefined &&
|
|
855
|
+
if (environment.values !== undefined && !environmentMap(environment.values)) throw new Error(`runtime.environment.values must be a string→string object in ${path}`);
|
|
833
856
|
if (scenario.actors !== undefined && !record(scenario.actors)) throw new Error(`scenario.actors must be an object in ${path}`);
|
|
834
857
|
if (scenario.fixtures !== undefined && !record(scenario.fixtures)) throw new Error(`scenario.fixtures must be an object in ${path}`);
|
|
835
858
|
const mode = serving.mode ?? 'app';
|
|
@@ -897,6 +920,7 @@ function serviceToV2(service: WorldServiceConfig): Record<string, unknown> {
|
|
|
897
920
|
...(service.env === undefined ? {} : { environment: { values: service.env } }),
|
|
898
921
|
...(service.preload === undefined ? {} : { preload: service.preload }),
|
|
899
922
|
...(service.controlPlane === undefined ? {} : { controlPlane: service.controlPlane }),
|
|
923
|
+
...(service.branchState === undefined ? {} : { branchState: service.branchState }),
|
|
900
924
|
};
|
|
901
925
|
const endpoint = {
|
|
902
926
|
...(service.port === undefined ? {} : { port: service.port }),
|
package/src/served-world.ts
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { errorMessage } from './error-message.ts';
|
|
1
2
|
import { boardFrameKey, getActiveWorldStore, withHistoryLocks, captureHistory, historyAtInstant, historyDigest, historyEntries, historyLength, readHistoryView, worldPaths, type HistoryReference } from '@volter/world-core/runtime';
|
|
2
3
|
// A SERVED WORLD (docs/reference/http-api.md#a-served-world): `volter world serve` puts a world at
|
|
3
4
|
// `http://<host>:<port>/<org>/<world>/`. Each twin's vendor API is under its vendor id — the
|
|
@@ -176,7 +177,7 @@ function configRefFor(worldRoot: string, name: string): string {
|
|
|
176
177
|
/** A world mounted by a host. `token`/`readToken` read the CURRENT tokens (rotate changes them);
|
|
177
178
|
* `twins()` names the world's twins once booted; `rotate()` mints both tokens anew — the old die
|
|
178
179
|
* with the call — and keeps them beside the world like `boot` does. */
|
|
179
|
-
export type MountedWorld = { name: string; served: string; root: string; readonly token: string; readonly readToken: string; twins: () => string[]; rotate: () => { token: string; readToken: string }; handle: (request: Request) => Promise<Response>; /** a hostname this World's twins answer, as a browser reads it (siteOfHost) */ site: (request: Request, host: string) => Promise<Response>; /** WorldDoors.pagesPass */ pagesPass: (scope: 'read' | 'write', land: string, grant?: { who?: string; framer?: string; asker?: string }) => string | null; branchBoard: (frame?: string) => Promise<Response>; boot: (url: string) => Promise<void>; stop: () => Promise<void> };
|
|
180
|
+
export type MountedWorld = { maxRequestBodySize: number; name: string; served: string; root: string; readonly token: string; readonly readToken: string; twins: () => string[]; rotate: () => { token: string; readToken: string }; handle: (request: Request) => Promise<Response>; /** a hostname this World's twins answer, as a browser reads it (siteOfHost) */ site: (request: Request, host: string) => Promise<Response>; /** WorldDoors.pagesPass */ pagesPass: (scope: 'read' | 'write', land: string, grant?: { who?: string; framer?: string; asker?: string }) => string | null; branchBoard: (frame?: string) => Promise<Response>; boot: (url: string) => Promise<void>; stop: () => Promise<void> };
|
|
180
181
|
/** A browser session a World keeps: its scope, when it ends, and the person a pass named, when one did. */
|
|
181
182
|
/** What a content-plane pass, and the session it opens, carry: its scope; the control session that asked (the content
|
|
182
183
|
* session ends with it; none when the World's token asked); the person; the console that frames it; and whether it
|
|
@@ -235,6 +236,7 @@ export async function mountWorld(name: string, opts: { /** the platforms whose p
|
|
|
235
236
|
let stopping: Promise<void> | null = null;
|
|
236
237
|
const mounted: MountedWorld = {
|
|
237
238
|
name, served, root: worldRoot,
|
|
239
|
+
maxRequestBodySize: Math.max(1024 * 1024 * 1024, ...loadWorldConfig(configRef, worldRoot).config.services.map((service) => packFacts()[service.package?.replace(/^@volter\/twin-/, '') ?? service.id]?.maxRequestBodySize ?? 0)),
|
|
238
240
|
get token() { return world.token; }, get readToken() { return world.readToken; },
|
|
239
241
|
twins: () => world.twins(),
|
|
240
242
|
rotate: () => { world.retoken(mintToken('tok_'), mintToken('tok_r_')); keep(); return { token: world.token, readToken: world.readToken }; },
|
|
@@ -303,9 +305,9 @@ export async function serveWorld(name: string, opts: { root?: string; port?: num
|
|
|
303
305
|
// stale server on the port must never be mistaken for this one. Until the world is up the
|
|
304
306
|
// doors answer 503.
|
|
305
307
|
let consoleOrigin: string | null = null;
|
|
306
|
-
//
|
|
308
|
+
// Keep the 1 GiB front baseline unless a served twin declares a larger transport cap.
|
|
307
309
|
// a front for the whole World: its twins and links keep their own journals (architecture: a multiplexing front journals nothing)
|
|
308
|
-
const server = await serveHttp({ hostname: host, port: opts.port ?? 0, maxRequestBodySize:
|
|
310
|
+
const server = await serveHttp({ hostname: host, port: opts.port ?? 0, maxRequestBodySize: mounted.maxRequestBodySize, twinRequestJournal: false, fetch: (request) => consoleRedirect(request, consoleOrigin) ?? mounted.handle(request) });
|
|
309
311
|
const url = `http://${host}:${server.port}`;
|
|
310
312
|
try { await mounted.boot(url); } catch (error) { await server.stop(true); throw error; }
|
|
311
313
|
// the console, when it is installed beside the World, on an origin of its own (console-apart.ts)
|
|
@@ -330,7 +332,10 @@ export async function serveWorld(name: string, opts: { root?: string; port?: num
|
|
|
330
332
|
opts.announce?.({ name: mounted.served, base: `${url}/${mounted.served}`, token: mounted.token, readToken: mounted.readToken, console: consoleUrl });
|
|
331
333
|
} catch (error) {
|
|
332
334
|
// Boot succeeded: this call owns the World, but cannot return its stop handle.
|
|
333
|
-
|
|
335
|
+
const errors: unknown[] = [error];
|
|
336
|
+
try { await stopAll(); } catch (cleanup) { errors.push(cleanup); }
|
|
337
|
+
try { await mounted.stop(); } catch (cleanup) { errors.push(cleanup); }
|
|
338
|
+
if (errors.length > 1) throw new AggregateError(errors, 'World announcement and host cleanup failed');
|
|
334
339
|
throw error;
|
|
335
340
|
}
|
|
336
341
|
return { url, base: `${url}/${mounted.served}`, name: mounted.served, port: server.port ?? 0, token: mounted.token, readToken: mounted.readToken, console: consoleUrl, stop: async () => { await stopAll(); await mounted.stop(); } };
|
|
@@ -581,6 +586,7 @@ export class WorldDoors {
|
|
|
581
586
|
}
|
|
582
587
|
if (door === 'open.js' && request.method === 'GET') {
|
|
583
588
|
const script = `(() => {
|
|
589
|
+
const errorMessage = (${errorMessage.toString()});
|
|
584
590
|
const world = document.body.dataset.world;
|
|
585
591
|
const carried = new URLSearchParams(location.hash.slice(1));
|
|
586
592
|
history.replaceState(null, '', location.pathname);
|
|
@@ -598,7 +604,7 @@ export class WorldDoors {
|
|
|
598
604
|
if (!r.ok) throw new Error(opened.error || 'This World did not open.');
|
|
599
605
|
location.replace(to);
|
|
600
606
|
})
|
|
601
|
-
.catch((e) => { document.body.textContent = e
|
|
607
|
+
.catch((e) => { document.body.textContent = errorMessage(e) || 'This World did not answer.'; tell({ error: document.body.textContent }); });
|
|
602
608
|
})();
|
|
603
609
|
`;
|
|
604
610
|
return new Response(script, { headers: { 'content-type': 'text/javascript; charset=utf-8', 'cache-control': 'no-store' } });
|
|
@@ -729,7 +735,7 @@ export class WorldDoors {
|
|
|
729
735
|
// a session a revoked key opened ends with it
|
|
730
736
|
if (Object.values(this.sessions()).some((x) => x.key !== undefined && ids.has(x.key))) this.keepSessions(Object.fromEntries(Object.entries(this.sessions()).filter(([, x]) => x.key === undefined || !ids.has(x.key))));
|
|
731
737
|
// the keys are gone whatever a removal answers; a branch that stays is said, not hidden (its parent key no longer opens this World)
|
|
732
|
-
for (const k of [...gone, ...also]) if (k.branch) await this.host.branches?.remove(k.branch).catch((error: unknown) => { console.error(`${this.served}: branch ${k.branch} stays after its key was revoked: ${
|
|
738
|
+
for (const k of [...gone, ...also]) if (k.branch) await this.host.branches?.remove(k.branch).catch((error: unknown) => { console.error(`${this.served}: branch ${k.branch} stays after its key was revoked: ${errorMessage(error)}`); return false; });
|
|
733
739
|
return [...ids];
|
|
734
740
|
}
|
|
735
741
|
private async keysDoor(request: Request, id: string | undefined, scope: 'read' | 'write', via: 'token' | 'key' | 'session' | null, session?: string): Promise<Response> {
|
|
@@ -1196,7 +1202,7 @@ export class WorldDoors {
|
|
|
1196
1202
|
}
|
|
1197
1203
|
return Response.json({ error: `not this world: serving /${this.served}/` }, { status: 404 });
|
|
1198
1204
|
} catch (error) {
|
|
1199
|
-
return Response.json({ error:
|
|
1205
|
+
return Response.json({ error: errorMessage(error) }, { status: 500 });
|
|
1200
1206
|
}
|
|
1201
1207
|
}
|
|
1202
1208
|
|
|
@@ -1370,7 +1376,7 @@ export class WorldDoors {
|
|
|
1370
1376
|
const base = validateRemoteOrigin(origin);
|
|
1371
1377
|
// opened for this link's origin only: a credential sealed for another destination, or for none, refuses
|
|
1372
1378
|
let credential: Awaited<ReturnType<typeof openSealedAt>>;
|
|
1373
|
-
try { credential = await openSealedAt(credentialPath(this.worldRoot, name), name, destinationOf(base.origin)); } catch (error) { return Response.json({ error: (error
|
|
1379
|
+
try { credential = await openSealedAt(credentialPath(this.worldRoot, name), name, destinationOf(base.origin)); } catch (error) { return Response.json({ error: errorMessage(error) }, { status: 409 }); }
|
|
1374
1380
|
if (credential === null) return Response.json({ error: `the ${name} link has no credential sealed` }, { status: 409 });
|
|
1375
1381
|
const target = new URL(`${base.origin}/${rest}${url.search}`);
|
|
1376
1382
|
if (target.origin !== base.origin) return Response.json({ error: 'the path left the link origin' }, { status: 400 });
|
|
@@ -1730,7 +1736,7 @@ export class WorldDoors {
|
|
|
1730
1736
|
if (kind === 'links' && name && request.method === 'PUT') {
|
|
1731
1737
|
if (!/^[a-z0-9][a-z0-9_-]{0,63}$/.test(name) || this.up().services[name]) return Response.json({ error: 'a link is named [a-z0-9_-] and is not a twin of this World' }, { status: 400 });
|
|
1732
1738
|
const body = (await request.json()) as { origin?: string };
|
|
1733
|
-
try { validateRemoteOrigin(body.origin ?? ''); } catch (error) { return Response.json({ error:
|
|
1739
|
+
try { validateRemoteOrigin(body.origin ?? ''); } catch (error) { return Response.json({ error: errorMessage(error) }, { status: 400 }); }
|
|
1734
1740
|
getActiveWorldStore().write(this.linksPath(), `${JSON.stringify({ ...this.links(), [name]: { origin: body.origin! } }, null, 2)}\n`);
|
|
1735
1741
|
return Response.json({ link: name, origin: body.origin });
|
|
1736
1742
|
}
|
|
@@ -1751,7 +1757,7 @@ export class WorldDoors {
|
|
|
1751
1757
|
if (source.length > MAX_BODY) return Response.json({ error: 'too large' }, { status: 413 });
|
|
1752
1758
|
const path = join(dir, `${name}.js`);
|
|
1753
1759
|
store.write(path, source);
|
|
1754
|
-
try { await loadCheck(path, name); } catch (error) { store.remove(path); return Response.json({ error: `not a check: ${
|
|
1760
|
+
try { await loadCheck(path, name); } catch (error) { store.remove(path); return Response.json({ error: `not a check: ${errorMessage(error)}` }, { status: 400 }); }
|
|
1755
1761
|
return Response.json({ check: name });
|
|
1756
1762
|
}
|
|
1757
1763
|
if (name && request.method === 'DELETE') { for (const ext of ['js', 'mjs', 'ts']) store.remove(join(dir, `${name}.${ext}`)); return Response.json({ removed: name }); }
|
|
@@ -1821,7 +1827,7 @@ export class WorldDoors {
|
|
|
1821
1827
|
if (scope !== 'write') return Response.json({ error: 'the rules are changed with write access' }, { status: 403 });
|
|
1822
1828
|
if (via === 'key') return Response.json({ error: 'a key changes no rules: a person sets them, or the World\'s token' }, { status: 403 });
|
|
1823
1829
|
const body = (await request.json().catch(() => null)) as { changesets?: unknown } | null;
|
|
1824
|
-
try { assertChangesetRules(body?.changesets, path); } catch (error) { return Response.json({ error: error
|
|
1830
|
+
try { assertChangesetRules(body?.changesets, path); } catch (error) { return Response.json({ error: errorMessage(error).replace(` in ${path}`, '') }, { status: 400 }); }
|
|
1825
1831
|
const { config } = loadWorldConfig(path, this.worldRoot);
|
|
1826
1832
|
const rules = body!.changesets as WorldChangesetRules;
|
|
1827
1833
|
const next = { ...config, changesets: { ...(rules.approvals === undefined ? {} : { approvals: rules.approvals }), ...(rules.summary === undefined ? {} : { summary: rules.summary }) } };
|
|
@@ -2089,7 +2095,7 @@ export class WorldDoors {
|
|
|
2089
2095
|
let known = false;
|
|
2090
2096
|
try { known = listWorlds(this.worldRoot).some((w) => w.name === target); } catch { /* no local branches */ }
|
|
2091
2097
|
if (!known) return Response.json({ error: `no branch ${target} in this World` }, { status: 404 });
|
|
2092
|
-
try { await this.host.serveBranch(target); } catch (error) { return Response.json({ error:
|
|
2098
|
+
try { await this.host.serveBranch(target); } catch (error) { return Response.json({ error: errorMessage(error) }, { status: 409 }); }
|
|
2093
2099
|
return Response.json({ world: this.served, checkedOut: target });
|
|
2094
2100
|
}
|
|
2095
2101
|
if (rest.length === 0 && request.method === 'POST') {
|
|
@@ -2134,7 +2140,7 @@ export class WorldDoors {
|
|
|
2134
2140
|
return Response.json({ ...made, ...where }, { status: 201 });
|
|
2135
2141
|
} catch (error) {
|
|
2136
2142
|
this.keepKeys(this.keys().filter((k) => k.id !== keyId)); // no branch, no key
|
|
2137
|
-
return Response.json({ error:
|
|
2143
|
+
return Response.json({ error: errorMessage(error) }, { status: 409 });
|
|
2138
2144
|
}
|
|
2139
2145
|
}
|
|
2140
2146
|
if (rest.length === 2 && request.method === 'DELETE') {
|
|
@@ -2416,7 +2422,7 @@ export class WorldDoors {
|
|
|
2416
2422
|
const kept = ((): Changeset | null => { try { return JSON.parse(getActiveWorldStore().read(join(worldChangesetsDir(this.name, this.worldRoot), `${changeset.name}.json`)) ?? 'null') as Changeset | null; } catch { return null; } })();
|
|
2417
2423
|
return Response.json({ world: this.served, changeset: changeset.name, receipts, position, snapshots, ...(kept?.owner ? { owner: kept.owner } : {}), ...(kept?.pushedBy ? { pushedBy: kept.pushedBy } : {}) });
|
|
2418
2424
|
} catch (error) {
|
|
2419
|
-
return Response.json({ error:
|
|
2425
|
+
return Response.json({ error: errorMessage(error) }, { status: 409 });
|
|
2420
2426
|
}
|
|
2421
2427
|
}
|
|
2422
2428
|
}
|
|
@@ -2529,7 +2535,7 @@ export async function refreshSchedule(worldRoot: string, layout: WorldLayout, lo
|
|
|
2529
2535
|
export async function scheduleRefreshes(name: string, worldRoot: string, instance: WorldInstance, log: (line: string) => void = (l) => process.stderr.write(`${l}\n`)): Promise<ReturnType<typeof setInterval>[]> {
|
|
2530
2536
|
const timers: ReturnType<typeof setInterval>[] = [];
|
|
2531
2537
|
for (const { vendor, controlRoot, root, everyMs } of await refreshSchedule(worldRoot, instance, log)) {
|
|
2532
|
-
const timer = setInterval(() => { refreshTwin({ worldRoot, vendor, controlRoot, root, force: true }).then((r) => { if (!r.refreshed) log(`${vendor}: not refreshed: ${r.reason}`); }, (e: unknown) => log(`${vendor}: refresh failed: ${
|
|
2538
|
+
const timer = setInterval(() => { refreshTwin({ worldRoot, vendor, controlRoot, root, force: true }).then((r) => { if (!r.refreshed) log(`${vendor}: not refreshed: ${r.reason}`); }, (e: unknown) => log(`${vendor}: refresh failed: ${errorMessage(e)}`)); }, everyMs);
|
|
2533
2539
|
timer.unref?.();
|
|
2534
2540
|
timers.push(timer);
|
|
2535
2541
|
}
|
package/src/service-recorder.ts
CHANGED
|
@@ -15,6 +15,8 @@
|
|
|
15
15
|
// service's own lines carry none: lines between two marks were written between those times;
|
|
16
16
|
// - forwards TERM/INT/HUP to the service and exits with its status (128+n for a signal).
|
|
17
17
|
// The World's NODE_OPTIONS (the injector) is for the service, not this parent: it arrives renamed and is restored.
|
|
18
|
+
import { signalChild } from './process-signal.mjs';
|
|
19
|
+
import { errorMessage } from './error-message.ts';
|
|
18
20
|
import { spawn } from 'node:child_process';
|
|
19
21
|
import { constants } from 'node:os';
|
|
20
22
|
import { appendFileSync, closeSync, fstatSync, openSync, readSync, statSync, truncateSync, writeFileSync } from 'node:fs';
|
|
@@ -73,7 +75,7 @@ const watch = setInterval(() => {
|
|
|
73
75
|
const statSize = (): number => { try { return logPath ? statSync(logPath).size : 0; } catch { return 0; } };
|
|
74
76
|
|
|
75
77
|
let forwarded: string | null = null;
|
|
76
|
-
for (const signal of ['SIGTERM', 'SIGINT', 'SIGHUP'] as const) process.on(signal, () => { forwarded = forwarded || signal; try { child.
|
|
78
|
+
for (const signal of ['SIGTERM', 'SIGINT', 'SIGHUP'] as const) process.on(signal, () => { forwarded = forwarded || signal; try { if (child.pid) signalChild(child, 'SIGTERM'); } catch (error) { process.stderr.write(`${errorMessage(error)}\n`); } });
|
|
77
79
|
let ended = false;
|
|
78
80
|
const done = (status: number, end: Record<string, unknown>): void => {
|
|
79
81
|
if (ended) return;
|
|
@@ -82,7 +84,7 @@ const done = (status: number, end: Record<string, unknown>): void => {
|
|
|
82
84
|
record({ event: 'world.service.exited', status, ...end, pid: child.pid, uptimeMs: Date.now() - started, ...(forwarded ? { stopRequested: forwarded } : {}) }, () => process.exit(status));
|
|
83
85
|
};
|
|
84
86
|
child.once('error', (error: Error) => {
|
|
85
|
-
const message =
|
|
87
|
+
const message = errorMessage(error);
|
|
86
88
|
process.stderr.write(`${message}\n`);
|
|
87
89
|
done(127, { code: null, signal: null, error: message });
|
|
88
90
|
});
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export function offSignal(signal: NodeJS.Signals, listener: (...args: never[]) => void): void;
|
package/src/signals.mjs
ADDED
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
// Removing a signal listener without removing the others. Under Bun (measured on 1.3.10),
|
|
2
|
+
// `process.off('SIGINT', fn)` uninstalls the process's OS handler even while other listeners remain
|
|
3
|
+
// (`listenerCount` still answers 1): the next SIGINT then takes the default action and kills the
|
|
4
|
+
// process, so a caller's own handler (a harness's teardown) never runs. Re-registering the remaining
|
|
5
|
+
// listeners reinstalls the handler. Node keeps it either way.
|
|
6
|
+
export function offSignal(signal, listener) {
|
|
7
|
+
const rest = process.listeners(signal).filter((l) => l !== listener);
|
|
8
|
+
process.removeAllListeners(signal);
|
|
9
|
+
for (const l of rest) process.on(signal, l);
|
|
10
|
+
}
|
package/src/signals.ts
CHANGED
|
@@ -1,10 +1 @@
|
|
|
1
|
-
|
|
2
|
-
// `process.off('SIGINT', fn)` uninstalls the process's OS handler even while other listeners remain
|
|
3
|
-
// (`listenerCount` still answers 1): the next SIGINT then takes the default action and kills the
|
|
4
|
-
// process, so a caller's own handler (a harness's teardown) never runs. Re-registering the remaining
|
|
5
|
-
// listeners reinstalls the handler. Node keeps it either way.
|
|
6
|
-
export function offSignal(signal: NodeJS.Signals, listener: (...args: never[]) => void): void {
|
|
7
|
-
const rest = process.listeners(signal).filter((l) => l !== listener);
|
|
8
|
-
process.removeAllListeners(signal);
|
|
9
|
-
for (const l of rest) process.on(signal, l as (...args: unknown[]) => void);
|
|
10
|
-
}
|
|
1
|
+
export { offSignal } from './signals.mjs';
|
package/src/tail.ts
CHANGED
|
@@ -30,7 +30,7 @@ type TailRecord = Record<string, unknown> & {
|
|
|
30
30
|
subject?: { type?: string; id?: string };
|
|
31
31
|
};
|
|
32
32
|
|
|
33
|
-
type TailEntry = { service: string; record: TailRecord; source: 'actions' | 'requests' };
|
|
33
|
+
type TailEntry = { service: string; record: TailRecord; source: 'actions' | 'requests' | 'logs' };
|
|
34
34
|
|
|
35
35
|
export type TailWorldOptions = {
|
|
36
36
|
root?: string;
|
|
@@ -45,6 +45,8 @@ export type TailWorldOptions = {
|
|
|
45
45
|
* status}` rows; world-core/src/serve.ts). Reads are otherwise invisible here: an
|
|
46
46
|
* empty-catalog GET that 404s leaves no trace in an actions-only tail. */
|
|
47
47
|
requests?: boolean;
|
|
48
|
+
/** Also read existing service process logs verbatim. These are vendor output, not shape-only journals. */
|
|
49
|
+
logs?: boolean;
|
|
48
50
|
/** Poll interval while following (ledger appends are cross-process, so this polls rather than
|
|
49
51
|
* fs.watch — watch descriptors can't see a file that doesn't exist yet). */
|
|
50
52
|
pollMs?: number;
|
|
@@ -129,8 +131,9 @@ function entryInstant(entry: TailEntry): string | undefined {
|
|
|
129
131
|
}
|
|
130
132
|
|
|
131
133
|
function formatEntry(entry: TailEntry, json: boolean): string {
|
|
132
|
-
if (json) return JSON.stringify({ ...entry.record, service: entry.service });
|
|
134
|
+
if (json) return JSON.stringify({ ...entry.record, service: entry.service, ...(entry.source === 'logs' ? { source: 'logs' } : {}) });
|
|
133
135
|
const { record } = entry;
|
|
136
|
+
if (entry.source === 'logs') return `${entry.service} stdout ${record.message}`;
|
|
134
137
|
if (entry.source === 'requests') {
|
|
135
138
|
// a served request, not an action: `HH:MM:SS.mmm <service> <METHOD> <path> <status> in <ms>ms`, and —
|
|
136
139
|
// when the caller presented one — which credential arrived, named and fingerprinted. The
|
|
@@ -204,6 +207,10 @@ export async function tailWorldActions(name: string, options: TailWorldOptions =
|
|
|
204
207
|
}
|
|
205
208
|
}
|
|
206
209
|
}
|
|
210
|
+
const log = instance.services[service]?.log;
|
|
211
|
+
if (options.logs && log) {
|
|
212
|
+
for (const line of readNewLines(log, offsets)) entries.push({ service, record: { message: line }, source: 'logs' });
|
|
213
|
+
}
|
|
207
214
|
}
|
|
208
215
|
return entries.sort(byOccurredAt);
|
|
209
216
|
};
|
|
@@ -215,7 +222,7 @@ export async function tailWorldActions(name: string, options: TailWorldOptions =
|
|
|
215
222
|
emit(collect());
|
|
216
223
|
for (const service of services) {
|
|
217
224
|
if (links.includes(service) && !instance.services[service]) continue; // a link records requests, never actions
|
|
218
|
-
if (serviceLedgers(instance.dirs.data, service).length === 0) {
|
|
225
|
+
if (serviceLedgers(instance.dirs.data, service).length === 0 && !(options.logs && instance.services[service]?.log)) {
|
|
219
226
|
notice(`World ${name}: service "${service}" has no action ledger yet${follow
|
|
220
227
|
? ' — watching; drive the app (or a vendor SDK) through the world to record actions'
|
|
221
228
|
: '; drive the app (or a vendor SDK) through the world to record actions, then tail again'}`);
|
package/src/tcp-port.ts
CHANGED
|
@@ -17,19 +17,30 @@ export async function allocatePort(excluded: Set<number> = new Set()): Promise<n
|
|
|
17
17
|
throw new Error('Could not allocate a TCP port after 32 attempts');
|
|
18
18
|
}
|
|
19
19
|
|
|
20
|
-
function probePort(): Promise<number> {
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
20
|
+
async function probePort(): Promise<number> {
|
|
21
|
+
const ipv4 = createServer();
|
|
22
|
+
const ipv6 = createServer();
|
|
23
|
+
try {
|
|
24
24
|
// Match serveHttp's default scope; a loopback-only probe can miss a wildcard listener.
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
server.close(() => reject(new Error('Could not allocate a TCP port')));
|
|
29
|
-
return;
|
|
30
|
-
}
|
|
31
|
-
const port = address.port;
|
|
32
|
-
server.close((error) => error ? reject(error) : resolvePort(port));
|
|
25
|
+
await new Promise<void>((resolve, reject) => {
|
|
26
|
+
ipv4.once('error', reject);
|
|
27
|
+
ipv4.listen(0, '0.0.0.0', resolve);
|
|
33
28
|
});
|
|
34
|
-
|
|
29
|
+
const address = ipv4.address();
|
|
30
|
+
if (!address || typeof address === 'string') throw new Error('Could not allocate a TCP port');
|
|
31
|
+
// Hold the IPv4 probe while checking the same port on IPv6, without a dual-stack bind.
|
|
32
|
+
await new Promise<void>((resolve, reject) => {
|
|
33
|
+
ipv6.once('error', (error: NodeJS.ErrnoException) => {
|
|
34
|
+
if (['EAFNOSUPPORT', 'EADDRNOTAVAIL', 'EPROTONOSUPPORT'].includes(error.code ?? '')) resolve();
|
|
35
|
+
else reject(error);
|
|
36
|
+
});
|
|
37
|
+
ipv6.listen({ port: address.port, host: '::', ipv6Only: true }, resolve);
|
|
38
|
+
});
|
|
39
|
+
return address.port;
|
|
40
|
+
} finally {
|
|
41
|
+
await Promise.all([ipv6, ipv4].filter((server) => server.listening).map((server) =>
|
|
42
|
+
new Promise<void>((resolve, reject) => {
|
|
43
|
+
server.close((error) => error ? reject(error) : resolve());
|
|
44
|
+
})));
|
|
45
|
+
}
|
|
35
46
|
}
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import { signalChild } from './process-signal.mjs';
|
|
2
|
+
import { errorMessage } from './error-message.mjs';
|
|
3
|
+
import { spawn } from 'node:child_process';
|
|
4
|
+
import { join } from 'node:path';
|
|
5
|
+
|
|
6
|
+
if (!process.env.VOLTER_WORLD) throw new Error('Temporal backing must run as a World service');
|
|
7
|
+
const port = Number(process.env.PORT);
|
|
8
|
+
if (!Number.isInteger(port) || port < 1 || port > 65535) throw new Error('Missing allocated service port');
|
|
9
|
+
const health = process.argv.includes('--health');
|
|
10
|
+
const root = process.argv[process.argv.indexOf('--root') + 1];
|
|
11
|
+
if (!health && (!process.argv.includes('--root') || !root)) throw new Error('Missing World service data directory');
|
|
12
|
+
// Source: https://github.com/temporalio/cli/releases/tag/v1.9.1 (server start-dev --help).
|
|
13
|
+
// SQLite pragma DSNs: https://github.com/temporalio/temporal/blob/v1.32.0/common/persistence/sql/sqlplugin/sqlite/plugin.go
|
|
14
|
+
// The runtime creates --root; SQLite's file and sidecars stay in that owned directory.
|
|
15
|
+
const args = health
|
|
16
|
+
? ['--address', `127.0.0.1:${port}`, 'operator', 'cluster', 'health']
|
|
17
|
+
: ['server', 'start-dev', '--ip', '127.0.0.1', '--port', String(port), '--headless',
|
|
18
|
+
'--db-filename', join(root, 'temporal.sqlite'), '--sqlite-pragma', 'synchronous=FULL'];
|
|
19
|
+
const child = spawn('temporal', args, { stdio: 'inherit', env: process.env });
|
|
20
|
+
let stopping = false;
|
|
21
|
+
const stop = () => {
|
|
22
|
+
stopping = true;
|
|
23
|
+
try { if (child.pid) signalChild(child, 'SIGTERM'); }
|
|
24
|
+
catch (error) { console.error(errorMessage(error)); process.exitCode = 1; }
|
|
25
|
+
};
|
|
26
|
+
process.once('SIGINT', stop); process.once('SIGTERM', stop);
|
|
27
|
+
child.once('error', error => { console.error(errorMessage(error)); process.exitCode = 1; });
|
|
28
|
+
child.once('exit', (code, signal) => { process.exitCode = stopping ? 0 : code ?? (signal ? 1 : 0); });
|
package/src/up-cli.ts
CHANGED
|
@@ -23,11 +23,14 @@ export async function upCommand(subject: string | undefined, rest: string[], roo
|
|
|
23
23
|
// browser tab's image, comes up over its data instead of starting empty, as a fresh `up` does)
|
|
24
24
|
const keepState = rest.includes('--keep-state');
|
|
25
25
|
const instance = await superviseWorldUp(subject, { name, root, mode, envFile, forceEnvOutput: rest.includes('--force'), owner: ownerOption(rest), ...(isolation ? { isolation } : {}), ...(keepState ? { keepState: true } : {}) });
|
|
26
|
-
|
|
26
|
+
if (rest.includes('--json')) process.stdout.write(JSON.stringify(instance, null, 2) + '\n');
|
|
27
|
+
else printReady(instance);
|
|
27
28
|
}
|
|
28
29
|
|
|
29
30
|
export function printReady(instance: Awaited<ReturnType<typeof upWorld>>): void {
|
|
30
31
|
process.stdout.write(`World ${instance.name} is ready (${instance.config})
|
|
32
|
+
Instance: ${instance.instance}
|
|
33
|
+
Created: ${instance.createdAt}
|
|
31
34
|
|
|
32
35
|
Services:
|
|
33
36
|
${Object.values(instance.services).map((service) => ` ${service.id}: ${service.url ?? '(no listener — a World output)'}`).join('\n')}
|
|
@@ -43,11 +46,11 @@ GET <url>/twin; a scripted twin's GET <url>/twin/scenario lists handlers + misse
|
|
|
43
46
|
Env:
|
|
44
47
|
${instance.envFile}
|
|
45
48
|
|
|
46
|
-
|
|
49
|
+
Record:
|
|
47
50
|
${instance.dirs.instance}/instance.json
|
|
48
51
|
|
|
49
52
|
Stop:
|
|
50
|
-
volter-world down ${instance.name}
|
|
53
|
+
volter-world down ${instance.name} --if-instance ${instance.instance}
|
|
51
54
|
|
|
52
55
|
For a bounded job, use volter-world run ... -- <command> to tear down automatically on exit.
|
|
53
56
|
Persistent up does not expire or infer that your sessions are idle.
|
package/src/up-task-worker.ts
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { recordWorldLifecycleEvent } from './lifecycle-record.ts';
|
|
2
2
|
import { upWorld } from './runtime.ts';
|
|
3
3
|
import { loadWorldConfig } from './configs.ts';
|
|
4
|
+
import { errorMessage } from './error-message.ts';
|
|
4
5
|
|
|
5
6
|
const worldNameOf = (configId: string, root: string): string => { try { return loadWorldConfig(configId, root).config.id; } catch { return configId; } };
|
|
6
7
|
import type { UpTaskReply, UpTaskRequest } from './up-task.ts';
|
|
@@ -25,7 +26,7 @@ process.on('message', async message => {
|
|
|
25
26
|
started = true;
|
|
26
27
|
let reply: UpTaskReply;
|
|
27
28
|
try { reply = { instance: await upWorld(request.configId, request.options, abort.signal) }; }
|
|
28
|
-
catch (error) { reply = { error:
|
|
29
|
+
catch (error) { reply = { error: errorMessage(error) }; }
|
|
29
30
|
if (callerLost && 'instance' in reply) {
|
|
30
31
|
try { recordWorldLifecycleEvent(reply.instance.root, reply.instance.name, 'boot completed after its caller went away; the World is up'); } catch { /* the World is up regardless */ }
|
|
31
32
|
}
|
package/src/websocket-relay.ts
CHANGED
|
@@ -1,32 +1,39 @@
|
|
|
1
|
-
import type
|
|
1
|
+
import { connectWebSocket, type ServeHttpOptions } from '@volter/world-core/runtime';
|
|
2
2
|
|
|
3
3
|
/** A message relay for the World's TLS fronts. Transport state is connection-local. */
|
|
4
4
|
export function websocketRelay(targetFor: (request: Request) => string | undefined): NonNullable<ServeHttpOptions['upgrade']> {
|
|
5
5
|
type Peer = Parameters<NonNullable<ServeHttpOptions['upgrade']>['open']>[0];
|
|
6
|
-
const sessions = new WeakMap<Peer, { upstream
|
|
6
|
+
const sessions = new WeakMap<Peer, { upstream?: WebSocket; pending: Array<string | Uint8Array> }>();
|
|
7
7
|
return {
|
|
8
8
|
accepts: request => targetFor(request) !== undefined,
|
|
9
9
|
open(peer, request) {
|
|
10
10
|
const target = targetFor(request);
|
|
11
11
|
if (!target) { peer.close(1011, 'no twin for request'); return; }
|
|
12
|
-
const
|
|
13
|
-
|
|
14
|
-
|
|
12
|
+
const headers = Object.fromEntries(request.headers);
|
|
13
|
+
const protocols = (headers['sec-websocket-protocol'] ?? '').split(',').map(p => p.trim()).filter(Boolean);
|
|
14
|
+
headers['x-volter-twin-original-host'] ??= new URL(request.url).host;
|
|
15
|
+
for (const name of ['host', 'connection', 'upgrade', 'sec-websocket-key', 'sec-websocket-version', 'sec-websocket-extensions', 'sec-websocket-protocol', 'proxy-connection']) delete headers[name];
|
|
16
|
+
const session: { upstream?: WebSocket; pending: Array<string | Uint8Array> } = { pending: [] };
|
|
15
17
|
sessions.set(peer, session);
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
18
|
+
void connectWebSocket(target, headers, protocols).then(upstream => {
|
|
19
|
+
if (sessions.get(peer) !== session) { upstream.onerror = () => {}; upstream.close(); return; }
|
|
20
|
+
session.upstream = upstream;
|
|
21
|
+
upstream.binaryType = 'arraybuffer';
|
|
22
|
+
upstream.onopen = () => { for (const message of session.pending) upstream.send(message); session.pending.length = 0; };
|
|
23
|
+
upstream.onmessage = event => { peer.send(typeof event.data === 'string' ? event.data : new Uint8Array(event.data as ArrayBuffer)); };
|
|
24
|
+
upstream.onclose = event => { peer.close(event.code === 1006 ? 1011 : event.code, event.reason); };
|
|
25
|
+
upstream.onerror = () => { peer.close(1011, 'twin websocket error'); };
|
|
26
|
+
}).catch(() => peer.close(1011, 'twin websocket connection failed'));
|
|
20
27
|
},
|
|
21
28
|
message(peer, data) {
|
|
22
29
|
const session = sessions.get(peer);
|
|
23
30
|
if (!session) return;
|
|
24
|
-
if (session.upstream
|
|
31
|
+
if (session.upstream?.readyState === WebSocket.OPEN) session.upstream.send(data); else session.pending.push(data);
|
|
25
32
|
},
|
|
26
33
|
close(peer) {
|
|
27
34
|
const session = sessions.get(peer);
|
|
28
35
|
sessions.delete(peer);
|
|
29
|
-
if (session) { session.pending.length = 0; session.upstream
|
|
36
|
+
if (session) { session.pending.length = 0; session.upstream?.close(); }
|
|
30
37
|
},
|
|
31
38
|
};
|
|
32
39
|
}
|
package/src/world-view.ts
CHANGED
|
@@ -94,8 +94,8 @@ export async function serveWorldView(name: string, opts: { /** the person signed
|
|
|
94
94
|
base = world.served;
|
|
95
95
|
worlds.set(world.served, world);
|
|
96
96
|
const server = await serveHttp({
|
|
97
|
-
//
|
|
98
|
-
hostname: host, port: opts.port ?? 0, maxRequestBodySize:
|
|
97
|
+
// The front retains its 1 GiB baseline and honors the served twins' declared caps.
|
|
98
|
+
hostname: host, port: opts.port ?? 0, maxRequestBodySize: world.maxRequestBodySize,
|
|
99
99
|
// this front serves a World and its branches; each World's own twins journal their requests
|
|
100
100
|
twinRequestJournal: false,
|
|
101
101
|
async fetch(incoming) {
|