@volter/world-runtime 2.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (164) hide show
  1. package/LICENSE +202 -0
  2. package/dist/known-external-services.json +1108 -0
  3. package/dist/src/ancestry.d.ts +2 -0
  4. package/dist/src/ancestry.js +42 -0
  5. package/dist/src/app-url.d.ts +47 -0
  6. package/dist/src/app-url.js +239 -0
  7. package/dist/src/attach.d.ts +48 -0
  8. package/dist/src/attach.js +87 -0
  9. package/dist/src/branch.d.ts +20 -0
  10. package/dist/src/branch.js +65 -0
  11. package/dist/src/browser-proxy-cli.d.ts +2 -0
  12. package/dist/src/browser-proxy-cli.js +41 -0
  13. package/dist/src/ca-trust.d.ts +5 -0
  14. package/dist/src/ca-trust.js +64 -0
  15. package/dist/src/catalog.d.ts +31 -0
  16. package/dist/src/catalog.js +148 -0
  17. package/dist/src/changeset.d.ts +142 -0
  18. package/dist/src/changeset.js +570 -0
  19. package/dist/src/cli.d.ts +2 -0
  20. package/dist/src/cli.js +1262 -0
  21. package/dist/src/command-lifetime.d.ts +15 -0
  22. package/dist/src/command-lifetime.js +98 -0
  23. package/dist/src/configs.d.ts +18 -0
  24. package/dist/src/configs.js +119 -0
  25. package/dist/src/console-apart.d.ts +38 -0
  26. package/dist/src/console-apart.js +107 -0
  27. package/dist/src/consumers.d.ts +46 -0
  28. package/dist/src/consumers.js +200 -0
  29. package/dist/src/covers.d.ts +183 -0
  30. package/dist/src/covers.js +800 -0
  31. package/dist/src/fixture-env.d.ts +42 -0
  32. package/dist/src/fixture-env.js +221 -0
  33. package/dist/src/host-cli.d.ts +2 -0
  34. package/dist/src/host-cli.js +92 -0
  35. package/dist/src/host-fault-fixture.d.ts +32 -0
  36. package/dist/src/host-fault-fixture.js +100 -0
  37. package/dist/src/host-worker.d.ts +1 -0
  38. package/dist/src/host-worker.js +23 -0
  39. package/dist/src/host.d.ts +38 -0
  40. package/dist/src/host.js +135 -0
  41. package/dist/src/index.d.ts +48 -0
  42. package/dist/src/index.js +35 -0
  43. package/dist/src/infra-cli.d.ts +2 -0
  44. package/dist/src/infra-cli.js +136 -0
  45. package/dist/src/init.d.ts +227 -0
  46. package/dist/src/init.js +1117 -0
  47. package/dist/src/inject-map.d.ts +34 -0
  48. package/dist/src/inject-map.js +56 -0
  49. package/dist/src/lifecycle-record.d.ts +47 -0
  50. package/dist/src/lifecycle-record.js +196 -0
  51. package/dist/src/origin.d.ts +31 -0
  52. package/dist/src/origin.js +139 -0
  53. package/dist/src/pack-facts.d.ts +75 -0
  54. package/dist/src/pack-facts.js +98 -0
  55. package/dist/src/pglite-backing.d.ts +21 -0
  56. package/dist/src/pglite-backing.js +158 -0
  57. package/dist/src/pglite-host.mjs +147 -0
  58. package/dist/src/placeholder.d.ts +20 -0
  59. package/dist/src/placeholder.js +100 -0
  60. package/dist/src/prerequisites.d.ts +21 -0
  61. package/dist/src/prerequisites.js +49 -0
  62. package/dist/src/process-groups.d.ts +4 -0
  63. package/dist/src/process-groups.js +49 -0
  64. package/dist/src/project-inspect.d.ts +109 -0
  65. package/dist/src/project-inspect.js +827 -0
  66. package/dist/src/proxy-daemon.d.ts +2 -0
  67. package/dist/src/proxy-daemon.js +18 -0
  68. package/dist/src/redirect-proxy.d.ts +105 -0
  69. package/dist/src/redirect-proxy.js +665 -0
  70. package/dist/src/reflect.d.ts +74 -0
  71. package/dist/src/reflect.js +392 -0
  72. package/dist/src/resources.d.ts +26 -0
  73. package/dist/src/resources.js +22 -0
  74. package/dist/src/root.d.ts +114 -0
  75. package/dist/src/root.js +312 -0
  76. package/dist/src/run-task-worker.d.ts +1 -0
  77. package/dist/src/run-task-worker.js +38 -0
  78. package/dist/src/run-task.d.ts +18 -0
  79. package/dist/src/run-task.js +48 -0
  80. package/dist/src/runtime-test-support.d.ts +59 -0
  81. package/dist/src/runtime-test-support.js +205 -0
  82. package/dist/src/runtime.d.ts +256 -0
  83. package/dist/src/runtime.js +3502 -0
  84. package/dist/src/schema.d.ts +449 -0
  85. package/dist/src/schema.js +605 -0
  86. package/dist/src/serve.d.ts +30 -0
  87. package/dist/src/serve.js +82 -0
  88. package/dist/src/served-world.d.ts +194 -0
  89. package/dist/src/served-world.js +986 -0
  90. package/dist/src/service-exit.d.ts +46 -0
  91. package/dist/src/service-exit.js +195 -0
  92. package/dist/src/service-recorder.d.ts +1 -0
  93. package/dist/src/service-recorder.js +121 -0
  94. package/dist/src/sibling.d.ts +1 -0
  95. package/dist/src/sibling.js +9 -0
  96. package/dist/src/signals.d.ts +1 -0
  97. package/dist/src/signals.js +11 -0
  98. package/dist/src/storage-capacity.d.ts +8 -0
  99. package/dist/src/storage-capacity.js +61 -0
  100. package/dist/src/tail.d.ts +30 -0
  101. package/dist/src/tail.js +160 -0
  102. package/dist/src/tcp-port.d.ts +2 -0
  103. package/dist/src/tcp-port.js +36 -0
  104. package/dist/src/up-task-worker.d.ts +1 -0
  105. package/dist/src/up-task-worker.js +61 -0
  106. package/dist/src/up-task.d.ts +17 -0
  107. package/dist/src/up-task.js +49 -0
  108. package/dist/src/websocket-relay.d.ts +3 -0
  109. package/dist/src/websocket-relay.js +40 -0
  110. package/known-external-services.json +1108 -0
  111. package/package.json +83 -0
  112. package/src/ancestry.ts +36 -0
  113. package/src/app-url.ts +253 -0
  114. package/src/attach.ts +117 -0
  115. package/src/branch.ts +63 -0
  116. package/src/browser-proxy-cli.ts +44 -0
  117. package/src/ca-trust.ts +57 -0
  118. package/src/catalog.ts +156 -0
  119. package/src/changeset.ts +627 -0
  120. package/src/cli.ts +1111 -0
  121. package/src/command-lifetime.ts +79 -0
  122. package/src/configs.ts +110 -0
  123. package/src/console-apart.ts +90 -0
  124. package/src/consumers.ts +185 -0
  125. package/src/covers.ts +934 -0
  126. package/src/fixture-env.ts +230 -0
  127. package/src/host-cli.ts +90 -0
  128. package/src/host-worker.ts +23 -0
  129. package/src/host.ts +169 -0
  130. package/src/index.ts +171 -0
  131. package/src/infra-cli.ts +133 -0
  132. package/src/init.ts +1316 -0
  133. package/src/inject-map.ts +72 -0
  134. package/src/lifecycle-record.ts +168 -0
  135. package/src/origin.ts +134 -0
  136. package/src/pack-facts.ts +128 -0
  137. package/src/pglite-backing.ts +141 -0
  138. package/src/pglite-host.mjs +147 -0
  139. package/src/placeholder.ts +89 -0
  140. package/src/prerequisites.ts +66 -0
  141. package/src/process-groups.ts +33 -0
  142. package/src/project-inspect.ts +770 -0
  143. package/src/proxy-daemon.ts +21 -0
  144. package/src/redirect-proxy.ts +684 -0
  145. package/src/reflect.ts +440 -0
  146. package/src/resources.ts +22 -0
  147. package/src/root.ts +290 -0
  148. package/src/run-task-worker.ts +27 -0
  149. package/src/run-task.ts +44 -0
  150. package/src/runtime-test-support.ts +208 -0
  151. package/src/runtime.ts +3357 -0
  152. package/src/schema.ts +922 -0
  153. package/src/serve.ts +102 -0
  154. package/src/served-world.ts +812 -0
  155. package/src/service-exit.ts +175 -0
  156. package/src/service-recorder.ts +89 -0
  157. package/src/sibling.ts +10 -0
  158. package/src/signals.ts +10 -0
  159. package/src/storage-capacity.ts +60 -0
  160. package/src/tail.ts +205 -0
  161. package/src/tcp-port.ts +35 -0
  162. package/src/up-task-worker.ts +40 -0
  163. package/src/up-task.ts +45 -0
  164. package/src/websocket-relay.ts +32 -0
@@ -0,0 +1,15 @@
1
+ import type { ChildProcess } from 'node:child_process';
2
+ export declare const COMMAND_GRACE_MS = 5000;
3
+ export declare const commandProcessGroup: boolean;
4
+ export declare class CommandRetirementError extends Error {
5
+ }
6
+ /** The child owns a private POSIX group. A bounded retirement requires both closed output and
7
+ * a vanished group. Unknown retirement keeps the caller's ownership record and reservation. */
8
+ export declare function commandLifetime(child: ChildProcess, abort?: AbortSignal, preserveOnSuccess?: boolean): {
9
+ cancel: (value: NodeJS.Signals) => void;
10
+ readonly forwarded: NodeJS.Signals | undefined;
11
+ finish(): Promise<{
12
+ code: number | null;
13
+ signal: NodeJS.Signals | null;
14
+ }>;
15
+ };
@@ -0,0 +1,98 @@
1
+ import { offSignal } from "./signals.js";
2
+ import { survivingOwnedGroups } from "./process-groups.js";
3
+ export const COMMAND_GRACE_MS = 5_000;
4
+ export const commandProcessGroup = process.platform !== 'win32';
5
+ export class CommandRetirementError extends Error {
6
+ }
7
+ /** The child owns a private POSIX group. A bounded retirement requires both closed output and
8
+ * a vanished group. Unknown retirement keeps the caller's ownership record and reservation. */
9
+ export function commandLifetime(child, abort, preserveOnSuccess = false) {
10
+ let forwarded;
11
+ let code = null;
12
+ let exitSignal = null;
13
+ let exited = false;
14
+ let closed = false;
15
+ let deadline = 0;
16
+ let escalated = false;
17
+ let timer;
18
+ let resolveFinished;
19
+ let rejectFinished;
20
+ const finished = new Promise((resolve, reject) => { resolveFinished = resolve; rejectFinished = reject; });
21
+ finished.catch(() => { });
22
+ const signal = (value) => {
23
+ if (!child.pid)
24
+ return;
25
+ try {
26
+ if (commandProcessGroup)
27
+ process.kill(-child.pid, value);
28
+ else
29
+ child.kill(value);
30
+ }
31
+ catch { /* retirement is proved below, never inferred from signal delivery */ }
32
+ };
33
+ const alive = () => {
34
+ if (!child.pid)
35
+ return false;
36
+ return survivingOwnedGroups([child.pid]).length > 0;
37
+ };
38
+ const detach = () => {
39
+ if (timer)
40
+ clearInterval(timer);
41
+ offSignal('SIGINT', onInt);
42
+ offSignal('SIGTERM', onTerm);
43
+ abort?.removeEventListener('abort', onAbort);
44
+ };
45
+ const check = () => {
46
+ if (exited && closed && ((preserveOnSuccess && code === 0 && !forwarded) || !alive())) {
47
+ detach();
48
+ resolveFinished();
49
+ return;
50
+ }
51
+ if (deadline && Date.now() >= deadline) {
52
+ if (!escalated) {
53
+ escalated = true;
54
+ signal('SIGKILL');
55
+ deadline = Date.now() + 1_000;
56
+ }
57
+ else {
58
+ detach();
59
+ child.stdout?.destroy();
60
+ child.stderr?.destroy();
61
+ rejectFinished(new CommandRetirementError('Command retirement could not be confirmed; ownership and cleanup evidence retained'));
62
+ }
63
+ }
64
+ };
65
+ const watch = () => {
66
+ if (!deadline)
67
+ deadline = Date.now() + COMMAND_GRACE_MS;
68
+ timer ??= setInterval(check, 20);
69
+ check();
70
+ };
71
+ const cancel = (value) => { forwarded ??= value; signal(value); watch(); };
72
+ const onInt = () => cancel('SIGINT');
73
+ const onTerm = () => cancel('SIGTERM');
74
+ const onAbort = () => cancel(abort?.reason === 'SIGINT' ? 'SIGINT' : 'SIGTERM');
75
+ process.on('SIGINT', onInt);
76
+ process.on('SIGTERM', onTerm);
77
+ abort?.addEventListener('abort', onAbort, { once: true });
78
+ if (abort?.aborted)
79
+ onAbort();
80
+ child.once('exit', (value, signalValue) => {
81
+ code = value;
82
+ exitSignal = signalValue;
83
+ exited = true;
84
+ if (!(preserveOnSuccess && code === 0 && !forwarded))
85
+ signal('SIGTERM');
86
+ watch();
87
+ });
88
+ child.once('error', () => { if (!child.pid) {
89
+ exited = true;
90
+ watch();
91
+ } });
92
+ child.once('close', () => { closed = true; check(); });
93
+ return {
94
+ cancel,
95
+ get forwarded() { return forwarded; },
96
+ async finish() { await finished; return { code, signal: exitSignal }; },
97
+ };
98
+ }
@@ -0,0 +1,18 @@
1
+ import { type WorldConfig } from './schema.js';
2
+ export declare function resolveConfigPath(config: string, root?: string): string;
3
+ export declare function loadWorldConfig(config: string, root?: string): {
4
+ path: string;
5
+ config: WorldConfig;
6
+ };
7
+ /** Persist an already-normalized config without changing its document format. */
8
+ export declare function writeWorldConfig(path: string, config: WorldConfig): void;
9
+ export type WorldConfigMigration = {
10
+ path: string;
11
+ backup: string;
12
+ from: 1;
13
+ to: 2;
14
+ moved: string[];
15
+ dropped: string[];
16
+ };
17
+ /** Explicit, backed-up, atomic format-1 → format-2 migration. Ordinary reads never call this. */
18
+ export declare function migrateWorldConfig(config: string, root?: string): WorldConfigMigration;
@@ -0,0 +1,119 @@
1
+ import { copyFileSync, constants, existsSync, readFileSync, renameSync, writeFileSync } from 'node:fs';
2
+ import { basename, dirname, resolve } from 'node:path';
3
+ import { getActiveWorldStore } from '@volter/world-core';
4
+ import { assertWorldConfig, LEGACY_WORLD_SELECTION, worldConfigDocument } from "./schema.js";
5
+ export function resolveConfigPath(config, root = process.cwd()) {
6
+ const candidates = config.includes('/') || config.endsWith('.json')
7
+ ? [resolve(root, config)]
8
+ : [
9
+ resolve(root, 'worlds/configs', `${config}.json`),
10
+ resolve(root, 'worlds/configs', config, 'world.config.json'),
11
+ resolve(root, `${config}.world.config.json`),
12
+ ];
13
+ const found = candidates.find((candidate) => getActiveWorldStore().exists(candidate));
14
+ if (!found) {
15
+ throw new Error(`World config not found: ${config}\nTried:\n${candidates.map((candidate) => ` ${candidate}`).join('\n')}`);
16
+ }
17
+ return found;
18
+ }
19
+ export function loadWorldConfig(config, root = process.cwd()) {
20
+ const path = resolveConfigPath(config, root);
21
+ const parsed = JSON.parse(getActiveWorldStore().read(path) ?? '');
22
+ return { path, config: assertWorldConfig(parsed, path) };
23
+ }
24
+ /** Persist an already-normalized config without changing its document format. */
25
+ export function writeWorldConfig(path, config) {
26
+ const document = config.manifestVersion === 2
27
+ ? worldConfigDocument(config)
28
+ : Object.fromEntries(Object.entries(config).filter(([key]) => key !== 'manifestVersion' && key !== 'selection'));
29
+ getActiveWorldStore().write(path, `${JSON.stringify(document, null, 2)}\n`);
30
+ }
31
+ const LEGACY_TOP_LEVEL = new Set(['id', 'description', 'bare', 'remotes', 'resources', 'isolation', 'env', 'stripEnv', 'services', 'share', 'actors', 'fixtures', 'catalog', 'network']);
32
+ const LEGACY_SERVICE = new Set(['id', 'type', 'package', 'command', 'args', 'cwd', 'port', 'portReason', 'env', 'controlPlane', 'injectEnv', 'injectEnvTemplates', 'root', 'rootArg', 'portArg', 'cliRedirect', 'ready', 'preload', 'external', 'colocate', 'version']);
33
+ const LEGACY_NESTED = {
34
+ bare: new Set(['name']), resources: new Set(['memoryMiB', 'writableStorageMiB']), catalog: new Set(['sha', 'protocol']),
35
+ root: new Set(['url', 'deploy', 'refresh', 'scope']), refresh: new Set(['every', 'webhook']),
36
+ external: new Set(['up', 'status', 'down', 'discover', 'readyWhen']), discover: new Set(['as', 'source', 'jsonPath', 'pattern']),
37
+ ready: new Set(['command', 'args', 'httpUrl', 'stdoutMatch', 'timeoutMs', 'intervalMs']), colocate: new Set(['module', 'export', 'scenarioPath']),
38
+ share: new Set(['provider', 'command', 'args', 'ephemeral', 'services']), shareService: new Set(['id', 'verifyPath']),
39
+ };
40
+ function unknownKeys(value, known, allowComments = false) {
41
+ return Object.keys(value).filter((key) => !known.has(key) && !(allowComments && key.startsWith('//')));
42
+ }
43
+ function rejectUnknown(value, known, what, path) {
44
+ if (!value || typeof value !== 'object' || Array.isArray(value))
45
+ return;
46
+ const unknown = unknownKeys(value, known);
47
+ if (unknown.length)
48
+ throw new Error(`Cannot migrate unknown ${what} field(s) ${unknown.join(', ')} in ${path}`);
49
+ }
50
+ /** Explicit, backed-up, atomic format-1 → format-2 migration. Ordinary reads never call this. */
51
+ export function migrateWorldConfig(config, root = process.cwd()) {
52
+ const path = resolveConfigPath(config, root);
53
+ const text = readFileSync(path, 'utf8');
54
+ const raw = JSON.parse(text);
55
+ if (!raw || typeof raw !== 'object' || Array.isArray(raw))
56
+ throw new Error(`World config must be an object: ${path}`);
57
+ if ('schemaVersion' in raw)
58
+ throw new Error(`World config is already versioned; migration only accepts legacy format 1: ${path}`);
59
+ const topUnknown = unknownKeys(raw, LEGACY_TOP_LEVEL, true);
60
+ if (topUnknown.length)
61
+ throw new Error(`Cannot migrate unknown legacy field(s) ${topUnknown.join(', ')} in ${path}`);
62
+ const legacy = raw;
63
+ rejectUnknown(legacy.bare, LEGACY_NESTED.bare, 'bare', path);
64
+ rejectUnknown(legacy.resources, LEGACY_NESTED.resources, 'resources', path);
65
+ rejectUnknown(legacy.catalog, LEGACY_NESTED.catalog, 'catalog', path);
66
+ rejectUnknown(legacy.share, LEGACY_NESTED.share, 'share', path);
67
+ if (legacy.share && typeof legacy.share === 'object' && !Array.isArray(legacy.share)) {
68
+ const targets = legacy.share.services;
69
+ if (Array.isArray(targets))
70
+ for (const target of targets)
71
+ rejectUnknown(target, LEGACY_NESTED.shareService, 'share service', path);
72
+ }
73
+ const rawServices = raw.services;
74
+ if (Array.isArray(rawServices))
75
+ for (const service of rawServices) {
76
+ if (!service || typeof service !== 'object' || Array.isArray(service))
77
+ continue;
78
+ const legacyService = service;
79
+ const serviceUnknown = unknownKeys(legacyService, LEGACY_SERVICE, true);
80
+ if (serviceUnknown.length)
81
+ throw new Error(`Cannot migrate unknown service field(s) ${serviceUnknown.join(', ')} in ${path}`);
82
+ rejectUnknown(legacyService.root, LEGACY_NESTED.root, 'service root', path);
83
+ if (legacyService.root && typeof legacyService.root === 'object' && !Array.isArray(legacyService.root))
84
+ rejectUnknown(legacyService.root.refresh, LEGACY_NESTED.refresh, 'service root.refresh', path);
85
+ rejectUnknown(legacyService.external, LEGACY_NESTED.external, 'service external', path);
86
+ if (legacyService.external && typeof legacyService.external === 'object' && !Array.isArray(legacyService.external)) {
87
+ const external = legacyService.external;
88
+ if (Array.isArray(external.discover))
89
+ for (const mapping of external.discover)
90
+ rejectUnknown(mapping, LEGACY_NESTED.discover, 'service external.discover', path);
91
+ rejectUnknown(external.readyWhen, LEGACY_NESTED.ready, 'service external.readyWhen', path);
92
+ }
93
+ rejectUnknown(legacyService.ready, LEGACY_NESTED.ready, 'service ready', path);
94
+ rejectUnknown(legacyService.colocate, LEGACY_NESTED.colocate, 'service colocate', path);
95
+ }
96
+ const normalized = assertWorldConfig(raw, path);
97
+ const document = worldConfigDocument(normalized, LEGACY_WORLD_SELECTION);
98
+ const backup = `${path}.v1.bak`;
99
+ copyFileSync(path, backup, constants.COPYFILE_EXCL);
100
+ const temporary = resolve(dirname(path), `.${basename(path)}.migrate-${process.pid}`);
101
+ try {
102
+ writeFileSync(temporary, `${JSON.stringify(document, null, 2)}\n`, { flag: 'wx' });
103
+ assertWorldConfig(JSON.parse(readFileSync(temporary, 'utf8')), temporary);
104
+ renameSync(temporary, path);
105
+ }
106
+ catch (error) {
107
+ try {
108
+ if (existsSync(temporary))
109
+ renameSync(temporary, `${temporary}.failed`);
110
+ }
111
+ catch { /* retain primary error */ }
112
+ throw error;
113
+ }
114
+ return {
115
+ path, backup, from: 1, to: 2,
116
+ moved: ['id→metadata.id', 'description→metadata.description', 'env/stripEnv→runtime.environment', 'isolation→runtime.isolation', 'bare/share→serving', 'actors/fixtures→scenario', 'catalog→provenance.catalog', 'service fields→typed service sections'],
117
+ dropped: normalized.resources === undefined ? [] : ['resources (deprecated; retained in backup)'],
118
+ };
119
+ }
@@ -0,0 +1,38 @@
1
+ import { type HttpServer } from '@volter/world-core';
2
+ /** What a console offers a host: its shell and assets under `base`, told where the Worlds are served. */
3
+ export type ConsoleMount = {
4
+ handle: (request: Request, base: string, opts?: {
5
+ worlds?: string;
6
+ names?: string[];
7
+ }) => Promise<Response | null>;
8
+ };
9
+ export declare const CONSOLE_BASE = "/-/console";
10
+ /** Serve `mount` on its own listener; doors under `/-/` go to `upstream` (the Worlds' origin). */
11
+ /** `upstream`: where this listener reaches the host's doors (its own address); `worlds`: the origin the
12
+ * person's browser reaches the Worlds at (an advertised URL), else `upstream`. */
13
+ export declare function serveConsoleApart(mount: ConsoleMount, opts: {
14
+ upstream: string;
15
+ worlds?: string;
16
+ names?: string[];
17
+ hostname: string;
18
+ port?: number;
19
+ }): Promise<{
20
+ url: string;
21
+ server: HttpServer;
22
+ }>;
23
+ /** The console for a World served elsewhere (a hosted World), on this machine. Two local listeners, as
24
+ * `volter world serve` has: the console on one origin, its doors forwarded to the host; and the Worlds'
25
+ * origin on the next port, forwarding everything (a twin's mirror, the session door) to the host, so
26
+ * the console and the pages twins serve are same-site as they are locally, and still two origins. A
27
+ * host keeps its tenants' names to itself, so the World is named by the URL: `<origin>/<org>/<world>`. */
28
+ export declare function serveConsoleFor(target: string, opts?: {
29
+ hostname?: string;
30
+ port?: number;
31
+ }): Promise<{
32
+ url: string;
33
+ worlds: string;
34
+ servers: HttpServer[];
35
+ }>;
36
+ /** The host's old console address (`/-/console/…`, a bookmark or a portal's link) sent on to the console's
37
+ * own listener, path kept; the browser carries any `#token=` fragment across the redirect. */
38
+ export declare function consoleRedirect(request: Request, consoleOrigin: string | null): Response | null;
@@ -0,0 +1,107 @@
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 mirror bundle, a tunnel's visitor pages,
3
+ // an app's own HTML), and a script there could read any storage of that origin. So a host serves the
4
+ // console on a listener of its own: the console's shell and bundle, and the host's `/-/` doors
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 twin's mirror) on the Worlds' origin, which it is told.
7
+ import { serveHttp } from '@volter/world-core';
8
+ export const CONSOLE_BASE = '/-/console';
9
+ /** Serve `mount` on its own listener; doors under `/-/` go to `upstream` (the Worlds' origin). */
10
+ /** `upstream`: where this listener reaches the host's doors (its own address); `worlds`: the origin the
11
+ * person's browser reaches the Worlds at (an advertised URL), else `upstream`. */
12
+ export async function serveConsoleApart(mount, opts) {
13
+ const upstream = opts.upstream.replace(/\/+$/, '');
14
+ const worlds = (opts.worlds ?? upstream).replace(/\/+$/, '');
15
+ const server = await serveHttp({
16
+ hostname: opts.hostname, port: opts.port ?? 0, twinRequestJournal: false,
17
+ async fetch(request) {
18
+ const url = new URL(request.url);
19
+ if (url.pathname === '/' || url.pathname === CONSOLE_BASE)
20
+ return Response.redirect(`${url.origin}${CONSOLE_BASE}/`, 302);
21
+ const answered = await mount.handle(request, CONSOLE_BASE, { worlds, ...(opts.names?.length ? { names: opts.names } : {}) });
22
+ if (answered)
23
+ return answered;
24
+ if (!url.pathname.startsWith('/-/') || url.pathname.startsWith(`${CONSOLE_BASE}/`))
25
+ return Response.json({ error: 'the console serves the console and the host\'s doors only' }, { status: 404 });
26
+ // a door, forwarded: the caller's token header rides along; no cookie goes up (this origin holds
27
+ // none) and none comes back (a World's session belongs to the Worlds' origin, set there)
28
+ const headers = new Headers(request.headers);
29
+ for (const h of ['host', 'cookie', 'origin', 'referer'])
30
+ headers.delete(h);
31
+ const body = request.method === 'GET' || request.method === 'HEAD' ? undefined : await request.arrayBuffer();
32
+ const answer = await fetch(`${upstream}${url.pathname}${url.search}`, { method: request.method, headers, ...(body === undefined ? {} : { body }), redirect: 'manual' });
33
+ const out = new Headers(answer.headers);
34
+ out.delete('set-cookie');
35
+ out.delete('content-encoding');
36
+ out.delete('content-length');
37
+ return new Response(answer.body, { status: answer.status, headers: out });
38
+ },
39
+ });
40
+ return { url: `http://${opts.hostname === '0.0.0.0' ? '127.0.0.1' : opts.hostname}:${server.port}`, server };
41
+ }
42
+ /** The console for a World served elsewhere (a hosted World), on this machine. Two local listeners, as
43
+ * `volter world serve` has: the console on one origin, its doors forwarded to the host; and the Worlds'
44
+ * origin on the next port, forwarding everything (a twin's mirror, the session door) to the host, so
45
+ * the console and the pages twins serve are same-site as they are locally, and still two origins. A
46
+ * host keeps its tenants' names to itself, so the World is named by the URL: `<origin>/<org>/<world>`. */
47
+ export async function serveConsoleFor(target, opts = {}) {
48
+ let at;
49
+ try {
50
+ at = new URL(target);
51
+ }
52
+ catch {
53
+ throw new Error(`volter world console: ${target} is not a URL (https://<host>/<org>/<world>)`);
54
+ }
55
+ if (at.protocol !== 'https:' && at.protocol !== 'http:')
56
+ throw new Error(`volter world console: ${target} is not an http(s) URL`);
57
+ const origin = at.origin;
58
+ const segments = at.pathname.split('/').filter((s) => s !== '');
59
+ const names = segments.length >= 2 ? [`${segments[0]}/${segments[1]}`] : [];
60
+ let mount;
61
+ try {
62
+ mount = (await import('@volter/world-console')).createConsole();
63
+ }
64
+ catch (error) {
65
+ if (error instanceof Error && /Cannot find (module|package)/.test(error.message))
66
+ throw new Error('the console is not installed: add @volter/world-console beside @volter/world');
67
+ throw error;
68
+ }
69
+ const hostname = opts.hostname ?? '127.0.0.1';
70
+ const worldsServer = await serveHttp({
71
+ hostname, port: opts.port !== undefined && opts.port !== 0 ? opts.port + 1 : 0, twinRequestJournal: false,
72
+ async fetch(request) {
73
+ const url = new URL(request.url);
74
+ const headers = new Headers(request.headers);
75
+ for (const h of ['host', 'origin', 'referer'])
76
+ headers.delete(h);
77
+ const body = request.method === 'GET' || request.method === 'HEAD' ? undefined : await request.arrayBuffer();
78
+ const answer = await fetch(`${origin}${url.pathname}${url.search}`, { method: request.method, headers, ...(body === undefined ? {} : { body }), redirect: 'manual' });
79
+ const out = new Headers();
80
+ for (const [k, v] of answer.headers)
81
+ if (!['content-encoding', 'content-length', 'set-cookie'].includes(k))
82
+ out.append(k, v);
83
+ // the World session's cookie, kept on this plain-http loopback origin (a Secure cookie is https-only)
84
+ for (const c of answer.headers.getSetCookie())
85
+ out.append('set-cookie', c.replace(/;\s*Secure/i, ''));
86
+ // the host's own redirects (the session door's, a twin root's to its mirror) stay on this origin
87
+ const location = out.get('location');
88
+ if (location?.startsWith(origin))
89
+ out.set('location', `${url.origin}${location.slice(origin.length)}`);
90
+ return new Response(answer.body, { status: answer.status, headers: out });
91
+ },
92
+ });
93
+ const worlds = `http://${hostname}:${worldsServer.port}`;
94
+ const consoleServer = await serveConsoleApart(mount, { upstream: origin, worlds, names, hostname, ...(opts.port !== undefined ? { port: opts.port } : {}) });
95
+ return { url: consoleServer.url, worlds, servers: [consoleServer.server, worldsServer] };
96
+ }
97
+ /** The host's old console address (`/-/console/…`, a bookmark or a portal's link) sent on to the console's
98
+ * own listener, path kept; the browser carries any `#token=` fragment across the redirect. */
99
+ export function consoleRedirect(request, consoleOrigin) {
100
+ const url = new URL(request.url);
101
+ if (!consoleOrigin || (url.pathname !== CONSOLE_BASE && !url.pathname.startsWith(`${CONSOLE_BASE}/`)))
102
+ return null;
103
+ // a console named at this very origin (a misconfigured --console-url) would redirect to itself forever
104
+ if (new URL(consoleOrigin).origin === url.origin)
105
+ return null;
106
+ return Response.redirect(`${consoleOrigin.replace(/\/+$/, '')}${url.pathname}${url.search}`, 302);
107
+ }
@@ -0,0 +1,46 @@
1
+ export type WorldConsumer = {
2
+ id: string;
3
+ instanceCreatedAt: string;
4
+ kind: 'attach' | 'run';
5
+ owner?: string;
6
+ uid?: number;
7
+ hostname: string;
8
+ runnerPid: number;
9
+ consumerPid?: number;
10
+ retirementUncertain?: boolean;
11
+ /** Published before spawn; only confirmed completion removes this command's record. */
12
+ completionRequired?: boolean;
13
+ startedAt: string;
14
+ heartbeatAt: string;
15
+ };
16
+ export declare const CONSUMER_HEARTBEAT_MS = 5000;
17
+ export declare function checkedOwner(owner: string | undefined): string | undefined;
18
+ /** Called under the World's lifecycle lock, before starting the command. No argv or env is stored. */
19
+ export declare function createConsumer(root: string, name: string, instanceCreatedAt: string, kind: WorldConsumer['kind'], owner?: string, options?: {
20
+ completionRequired?: boolean;
21
+ }): WorldConsumer;
22
+ export declare function refreshConsumer(root: string, name: string, record: WorldConsumer): void;
23
+ export declare function releaseConsumer(root: string, name: string, record: WorldConsumer): void;
24
+ /** Inspection never deletes records or interprets incomplete coverage as no consumers. */
25
+ export declare function worldConsumers(root: string, name: string, instanceCreatedAt?: string): {
26
+ consumers: (WorldConsumer & {
27
+ state: "active" | "uncertain" | "exited";
28
+ })[];
29
+ warnings: string[];
30
+ };
31
+ /** Delete one consumer record. Only `retireWorldConsumers` calls it, under the World's lifecycle
32
+ * lock, after proving the command and its process group are gone. */
33
+ export declare function removeConsumerRecord(root: string, name: string, id: string): void;
34
+ /** Host-clock observations, not the world's simulated vendor clock or proof of idleness. */
35
+ export declare function worldUsage(root: string, name: string, attached?: {
36
+ consumers: (WorldConsumer & {
37
+ state: "active" | "uncertain" | "exited";
38
+ })[];
39
+ warnings: string[];
40
+ }): {
41
+ createdAt: string | null;
42
+ lastUsedAt: string;
43
+ lastUseSource: "run" | "attach";
44
+ coverage: "partial";
45
+ warnings: string[];
46
+ };
@@ -0,0 +1,200 @@
1
+ import { randomUUID } from 'node:crypto';
2
+ import { existsSync, mkdirSync, readFileSync, readdirSync, renameSync, rmSync, writeFileSync } from 'node:fs';
3
+ import { hostname } from 'node:os';
4
+ import { join } from 'node:path';
5
+ import { stateDirName, withFileLock } from '@volter/world-core';
6
+ export const CONSUMER_HEARTBEAT_MS = 5000;
7
+ const STALE_MS = 30_000;
8
+ export function checkedOwner(owner) {
9
+ if (owner !== undefined && (!owner.trim() || owner.length > 120 || /[\x00-\x1f\x7f]/.test(owner))) {
10
+ throw new Error('World owner must be a nonempty label of at most 120 characters, without control characters');
11
+ }
12
+ return owner;
13
+ }
14
+ function consumerDir(root, name) {
15
+ return join(root, stateDirName(), 'worlds', name, 'consumers');
16
+ }
17
+ function writeAtomic(path, value) {
18
+ const temp = `${path}.${randomUUID()}.tmp`;
19
+ try {
20
+ writeFileSync(temp, `${JSON.stringify(value)}\n`, { mode: 0o600, flag: 'wx' });
21
+ renameSync(temp, path);
22
+ }
23
+ finally {
24
+ rmSync(temp, { force: true });
25
+ }
26
+ }
27
+ /** Called under the World's lifecycle lock, before starting the command. No argv or env is stored. */
28
+ export function createConsumer(root, name, instanceCreatedAt, kind, owner, options = {}) {
29
+ const now = new Date().toISOString();
30
+ const record = {
31
+ id: randomUUID(), instanceCreatedAt, kind, owner: checkedOwner(owner),
32
+ ...(process.getuid ? { uid: process.getuid() } : {}),
33
+ hostname: hostname(), runnerPid: process.pid, startedAt: now, heartbeatAt: now,
34
+ ...(options.completionRequired ? { completionRequired: true } : {}),
35
+ };
36
+ const dir = consumerDir(root, name);
37
+ mkdirSync(dir, { recursive: true, mode: 0o700 });
38
+ writeAtomic(join(dir, `${record.id}.json`), record);
39
+ return record;
40
+ }
41
+ export function refreshConsumer(root, name, record) {
42
+ const dir = consumerDir(root, name);
43
+ const path = join(dir, `${record.id}.json`);
44
+ if (!existsSync(path))
45
+ return; // teardown may already have removed this generation
46
+ const instance = JSON.parse(readFileSync(join(dir, '..', 'instance.json'), 'utf8'));
47
+ if (instance.createdAt !== record.instanceCreatedAt)
48
+ return;
49
+ const next = { ...record, heartbeatAt: new Date().toISOString() };
50
+ writeAtomic(path, next);
51
+ }
52
+ export function releaseConsumer(root, name, record) {
53
+ const dir = consumerDir(root, name);
54
+ try {
55
+ if (record.consumerPid && existsSync(join(dir, `${record.id}.json`))) {
56
+ const path = join(dir, '..', 'last-use.json');
57
+ withFileLock(`${path}.lock`, () => {
58
+ const instance = JSON.parse(readFileSync(join(dir, '..', 'instance.json'), 'utf8'));
59
+ if (instance.createdAt !== record.instanceCreatedAt)
60
+ return;
61
+ // Timestamp inside the lock: two commands finishing together cannot overwrite a
62
+ // newer observation with a timestamp captured before waiting for this lock.
63
+ let at = new Date().toISOString();
64
+ try {
65
+ const previous = JSON.parse(readFileSync(path, 'utf8'));
66
+ if (previous.instanceCreatedAt === record.instanceCreatedAt && previous.source === 'attach'
67
+ && typeof previous.at === 'string' && Date.parse(previous.at) > Date.parse(at))
68
+ at = previous.at;
69
+ }
70
+ catch { /* missing/corrupt advisory data can be replaced by a known observation */ }
71
+ writeAtomic(path, { instanceCreatedAt: record.instanceCreatedAt, at, source: 'attach' });
72
+ });
73
+ }
74
+ }
75
+ catch {
76
+ // Activity is advisory; a telemetry failure must not replace the command's exit status.
77
+ console.warn(`[volter-world] WARNING: could not retain last observed command use for ${name}; usage metadata may be incomplete.`);
78
+ }
79
+ finally {
80
+ rmSync(join(dir, `${record.id}.json`), { force: true });
81
+ }
82
+ }
83
+ function alive(pid) {
84
+ if (!pid)
85
+ return false;
86
+ try {
87
+ process.kill(pid, 0);
88
+ return true;
89
+ }
90
+ catch (error) {
91
+ return error.code !== 'ESRCH';
92
+ }
93
+ }
94
+ /** Inspection never deletes records or interprets incomplete coverage as no consumers. */
95
+ export function worldConsumers(root, name, instanceCreatedAt) {
96
+ const consumers = [];
97
+ const warnings = [];
98
+ const dir = consumerDir(root, name);
99
+ if (!existsSync(dir))
100
+ return { consumers, warnings };
101
+ if (instanceCreatedAt === undefined) {
102
+ try {
103
+ instanceCreatedAt = JSON.parse(readFileSync(join(dir, '..', 'instance.json'), 'utf8')).createdAt;
104
+ }
105
+ catch {
106
+ warnings.push('World instance unreadable; consumer ownership is uncertain.');
107
+ }
108
+ }
109
+ let files;
110
+ try {
111
+ files = readdirSync(dir).sort();
112
+ }
113
+ catch {
114
+ return { consumers, warnings: [...warnings, 'Consumer directory unreadable; consumer coverage is incomplete.'] };
115
+ }
116
+ for (const file of files) {
117
+ if (!file.endsWith('.json'))
118
+ continue;
119
+ try {
120
+ const r = JSON.parse(readFileSync(join(dir, file), 'utf8'));
121
+ if (!r || !/^[a-f0-9-]{36}$/.test(r.id) || file !== `${r.id}.json`
122
+ || !['attach', 'run'].includes(r.kind) || typeof r.hostname !== 'string'
123
+ || !Number.isInteger(r.runnerPid) || r.runnerPid <= 0
124
+ || (r.consumerPid !== undefined && (!Number.isInteger(r.consumerPid) || r.consumerPid <= 0))
125
+ || (r.retirementUncertain !== undefined && typeof r.retirementUncertain !== 'boolean')
126
+ || (r.completionRequired !== undefined && typeof r.completionRequired !== 'boolean')
127
+ || (r.uid !== undefined && (!Number.isSafeInteger(r.uid) || r.uid < 0))
128
+ || !Number.isFinite(Date.parse(r.instanceCreatedAt)) || !Number.isFinite(Date.parse(r.startedAt))
129
+ || !Number.isFinite(Date.parse(r.heartbeatAt)))
130
+ throw new Error('invalid');
131
+ checkedOwner(r.owner);
132
+ const age = Date.now() - Date.parse(r.heartbeatAt);
133
+ const sameInstance = instanceCreatedAt === r.instanceCreatedAt;
134
+ const state = r.retirementUncertain || r.hostname !== hostname() || !sameInstance ? 'uncertain'
135
+ : !alive(r.runnerPid) && r.consumerPid === undefined ? 'uncertain'
136
+ : !alive(r.runnerPid) && !alive(r.consumerPid) ? (r.completionRequired ? 'uncertain' : 'exited')
137
+ : alive(r.runnerPid) && age >= -STALE_MS && age <= STALE_MS ? 'active' : 'uncertain';
138
+ // Explicit fields only: malformed records must never leak arbitrary metadata into reports.
139
+ consumers.push({ id: r.id, instanceCreatedAt: r.instanceCreatedAt, kind: r.kind, owner: r.owner,
140
+ uid: r.uid, hostname: r.hostname, runnerPid: r.runnerPid, consumerPid: r.consumerPid,
141
+ ...(r.retirementUncertain ? { retirementUncertain: true } : {}),
142
+ ...(r.completionRequired ? { completionRequired: true } : {}),
143
+ startedAt: r.startedAt, heartbeatAt: r.heartbeatAt, state });
144
+ }
145
+ catch {
146
+ warnings.push('Unreadable consumer record; consumer coverage is incomplete.');
147
+ }
148
+ }
149
+ return { consumers, warnings };
150
+ }
151
+ /** Delete one consumer record. Only `retireWorldConsumers` calls it, under the World's lifecycle
152
+ * lock, after proving the command and its process group are gone. */
153
+ export function removeConsumerRecord(root, name, id) {
154
+ if (!/^[a-f0-9-]{36}$/.test(id))
155
+ throw new Error('Invalid consumer id');
156
+ rmSync(join(consumerDir(root, name), `${id}.json`), { force: true });
157
+ }
158
+ /** Host-clock observations, not the world's simulated vendor clock or proof of idleness. */
159
+ export function worldUsage(root, name, attached = worldConsumers(root, name)) {
160
+ const warnings = [...attached.warnings];
161
+ let createdAt = null;
162
+ const observations = [];
163
+ const add = (at, source) => {
164
+ if (typeof at === 'string' && Number.isFinite(Date.parse(at)))
165
+ observations.push({ at, source });
166
+ };
167
+ const dir = join(consumerDir(root, name), '..');
168
+ try {
169
+ const instance = JSON.parse(readFileSync(join(dir, 'instance.json'), 'utf8'));
170
+ if (typeof instance.createdAt !== 'string' || !Number.isFinite(Date.parse(instance.createdAt)))
171
+ throw new Error('invalid');
172
+ createdAt = instance.createdAt;
173
+ const runPath = join(dir, 'foreground-run.json');
174
+ const run = existsSync(runPath) ? JSON.parse(readFileSync(runPath, 'utf8')) : instance.lastRun;
175
+ if (run && ((Number.isInteger(run.consumerPid) && run.consumerPid > 0)
176
+ || (run.state === 'completed' && !run.error))) {
177
+ add(run.startedAt, 'run');
178
+ if (run.state === 'completed')
179
+ add(run.finishedAt, 'run');
180
+ }
181
+ const path = join(dir, 'last-use.json');
182
+ if (existsSync(path)) {
183
+ const last = JSON.parse(readFileSync(path, 'utf8'));
184
+ if (last.instanceCreatedAt !== createdAt || last.source !== 'attach'
185
+ || typeof last.at !== 'string' || !Number.isFinite(Date.parse(last.at)))
186
+ throw new Error('invalid');
187
+ add(last.at, 'attach');
188
+ }
189
+ }
190
+ catch {
191
+ warnings.push('Usage metadata unreadable; last observed use may be incomplete.');
192
+ }
193
+ for (const consumer of attached.consumers) {
194
+ if (consumer.instanceCreatedAt === createdAt && consumer.consumerPid)
195
+ add(consumer.heartbeatAt, 'attach');
196
+ }
197
+ observations.sort((a, b) => Date.parse(b.at) - Date.parse(a.at));
198
+ return { createdAt, lastUsedAt: observations[0]?.at ?? null, lastUseSource: observations[0]?.source ?? null,
199
+ coverage: 'partial', warnings };
200
+ }