@volter/world-runtime 2.0.11 → 2.0.13

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.
@@ -11,6 +11,8 @@ export type AppUrlRecord = {
11
11
  /** the application's own production hostnames (`--host`), routed to `url` inside the World as DNS routes them to
12
12
  * its host in production: the injector, the socket backstop and the redirect proxy read them from this record */
13
13
  hosts?: string[];
14
+ /** the application's name in the World (`--app`); absent: `app`, the one a command names without it */
15
+ app?: string;
14
16
  };
15
17
  export declare function appUrlFile(root: string, name: string): string;
16
18
  /** Record `url` as the app endpoint of the RUNNING/booted instance `name`. The instance must
@@ -20,10 +22,12 @@ export declare function setAppUrl(name: string, url: string, options?: {
20
22
  root?: string;
21
23
  via?: Exclude<AppUrlSource, 'service'>;
22
24
  detail?: string;
25
+ app?: string;
23
26
  }): AppUrlRecord;
24
27
  /** Record `hosts` as the app's own production hostnames, beside its recorded URL (or its `app` service's). */
25
28
  export declare function addAppHosts(name: string, hosts: string[], options?: {
26
29
  root?: string;
30
+ app?: string;
27
31
  }): AppUrlRecord;
28
32
  /**
29
33
  *`--detect <pid|port>`: resolve the app URL off what is ALREADY listening and record it.
@@ -34,6 +38,7 @@ export declare function addAppHosts(name: string, hosts: string[], options?: {
34
38
  */
35
39
  export declare function detectAppUrl(name: string, target: string, options?: {
36
40
  root?: string;
41
+ app?: string;
37
42
  }): Promise<AppUrlRecord>;
38
43
  /**
39
44
  * The recorded app URL of `name`, or the `app` service's assigned URL when nothing was recorded
@@ -42,6 +47,7 @@ export declare function detectAppUrl(name: string, target: string, options?: {
42
47
  */
43
48
  export declare function readAppUrl(name: string, options?: {
44
49
  root?: string;
50
+ app?: string;
45
51
  }): AppUrlRecord | null;
46
52
  /** The message the CLI prints when no app URL is known — the registration recipe, not a guess. */
47
- export declare function appUrlUnsetMessage(name: string): string;
53
+ export declare function appUrlUnsetMessage(name: string, app?: string): string;
@@ -19,13 +19,53 @@
19
19
  // once, at the caller's request, and stores what it saw. The record lives in the instance dir, so
20
20
  // a re-`up` (which wipes the dir) or `down --purge` clears it: a recorded URL never outlives the
21
21
  // instance it described.
22
- import { existsSync, readFileSync, writeFileSync } from 'node:fs';
22
+ import { existsSync, readFileSync, renameSync, writeFileSync } from 'node:fs';
23
+ import { withFileLock } from '@volter/world-core';
23
24
  import { join } from 'node:path';
24
25
  import { spawnSync } from 'node:child_process';
25
26
  import net from 'node:net';
26
27
  import { instanceDir, statusWorld } from "./runtime.js";
27
28
  import { loadInject } from "./inject-map.js";
28
29
  import { packFacts } from "./pack-facts.js";
30
+ const APP_NAME = /^[a-z0-9][a-z0-9-]{0,62}$/;
31
+ const DEFAULT_APP = 'app';
32
+ function appName(app) {
33
+ const name = app ?? DEFAULT_APP;
34
+ if (!APP_NAME.test(name))
35
+ throw new Error(`volter-world app-url: --app "${name}" is not an application name (lower-case letters, digits and -)`);
36
+ return name;
37
+ }
38
+ function readFile(root, name) {
39
+ const file = appUrlFile(root, name);
40
+ return existsSync(file) ? JSON.parse(readFileSync(file, 'utf8')) : {};
41
+ }
42
+ function entryOf(file, app) {
43
+ if (app !== DEFAULT_APP)
44
+ return file.apps && Object.hasOwn(file.apps, app) ? file.apps[app] : undefined;
45
+ const { apps: _others, ...own } = file;
46
+ return own.url !== undefined || own.hosts !== undefined ? own : undefined;
47
+ }
48
+ /** Write one application's entry, keeping every other application's: under the record's lock (several booters record
49
+ * their applications at once), replaced whole so a reader never meets half a file. */
50
+ function writeEntry(root, name, app, build) {
51
+ // the whole read, check, merge and write under one lock (withFileLock is not reentrant: nothing inside takes it)
52
+ return withFileLock(`${appUrlFile(root, name)}.lock`, () => {
53
+ const file = readFile(root, name);
54
+ const { entry, result } = build(file);
55
+ writeEntryLocked(root, name, app, file, entry);
56
+ return result;
57
+ });
58
+ }
59
+ function writeEntryLocked(root, name, app, file, entry) {
60
+ const { app: _name, ...stored } = entry;
61
+ const next = app === DEFAULT_APP
62
+ ? { ...stored, ...(file.apps ? { apps: file.apps } : {}) }
63
+ : { ...Object.fromEntries(Object.entries(file).filter(([k]) => k !== 'apps')), apps: { ...(file.apps ?? {}), [app]: stored } };
64
+ const target = appUrlFile(root, name);
65
+ const temp = `${target}.${process.pid}.tmp`;
66
+ writeFileSync(temp, `${JSON.stringify(next, null, 2)}\n`);
67
+ renameSync(temp, target);
68
+ }
29
69
  export function appUrlFile(root, name) {
30
70
  return join(instanceDir(root, name), 'app-url.json');
31
71
  }
@@ -47,24 +87,27 @@ function assertHttpUrl(url) {
47
87
  * deliberately NOT probed — the booter declaring "this is where I put the app" is the truth. */
48
88
  export function setAppUrl(name, url, options = {}) {
49
89
  const root = options.root ?? process.cwd();
90
+ const app = appName(options.app);
50
91
  const status = statusWorld(name, root); // throws "World instance not found" — the loud path
51
92
  assertHttpUrl(url);
52
- // a new URL keeps the hosts recorded for the app (they name the app, not where it listens)
53
- const hosts = existsSync(appUrlFile(root, name)) ? JSON.parse(readFileSync(appUrlFile(root, name), 'utf8')).hosts : undefined;
54
- const record = {
55
- world: status.name,
56
- url,
57
- via: options.via ?? 'set',
58
- recordedAt: new Date().toISOString(),
59
- ...(options.detail === undefined ? {} : { detail: options.detail }),
60
- ...(hosts?.length ? { hosts } : {}),
61
- };
62
- writeFileSync(appUrlFile(root, name), `${JSON.stringify(record, null, 2)}\n`);
63
- return record;
64
- }
65
- /** A hostname the app may answer as: a DNS name (not an address, not loopback) that no twin serves, and not the
66
- * host of this World's own origin or of a twin it runs (their requests carry the World key). */
67
- function assertAppHost(host, world, root) {
93
+ return writeEntry(root, name, app, (file) => {
94
+ // a new URL keeps the hosts recorded for the app (they name the app, not where it listens)
95
+ const hosts = entryOf(file, app)?.hosts;
96
+ const record = {
97
+ world: status.name,
98
+ url,
99
+ via: options.via ?? 'set',
100
+ recordedAt: new Date().toISOString(),
101
+ ...(options.detail === undefined ? {} : { detail: options.detail }),
102
+ ...(hosts?.length ? { hosts } : {}),
103
+ };
104
+ return { entry: record, result: app === DEFAULT_APP ? record : { ...record, app } };
105
+ });
106
+ }
107
+ /** A hostname the app may answer as: a DNS name (not an address, not loopback) that no twin the World runs serves,
108
+ * not the host of this World's own origin or of a twin it runs (their requests carry the World key), and not one
109
+ * another of its applications answers. A vendor the World runs no twin of can be the application itself. */
110
+ function assertAppHost(host, world, root, app, file) {
68
111
  const name = host.trim().toLowerCase().replace(/\.$/, '');
69
112
  // a wildcard (`*.dub.link`) stands for every name below its parent, as a DNS wildcard record does; its parent has two
70
113
  // labels at least (never a bare TLD). Whether the parent is a public suffix (co.uk, github.io) is the operator's word:
@@ -75,18 +118,22 @@ function assertAppHost(host, world, root) {
75
118
  throw new Error(`volter-world app-url: --host "${host}" is not a DNS hostname the app answers as (e.g. app.example.com, or *.example.com)`);
76
119
  }
77
120
  const inject = loadInject();
78
- let vendor = Object.keys(inject.VENDOR_HOSTS).find((v) => inject.VENDOR_HOSTS[v](bare));
121
+ const status = statusWorld(world, root);
122
+ // the twins the World runs, by vendor key, as its injector reads them from the World env (a service's id is not its
123
+ // vendor key: the aws service serves s3, dynamodb and others; a stripe twin may be a service named payments)
124
+ const twins = inject.readMap(status.env);
125
+ const runs = (vendor) => Object.hasOwn(twins, vendor);
126
+ let vendor = Object.keys(inject.VENDOR_HOSTS).find((v) => runs(v) && inject.VENDOR_HOSTS[v](bare));
79
127
  if (!vendor && wildcard) {
80
128
  // a wildcard is refused when any name a vendor's rule can give its twin lies under it, or it lies under a vendor's
81
129
  // host suffix: the rules' names, read from the descriptors (a host, a suffix, a pattern's literal tail) and from
82
130
  // the hand table's predicates (their quoted names)
83
131
  const names = vendorNames(inject.VENDOR_HOSTS);
84
132
  const dotted = `.${bare}`;
85
- vendor = names.find((n) => n.name === bare || n.name.endsWith(dotted) || (n.suffix && dotted.endsWith(n.name.startsWith('.') ? n.name : `.${n.name}`)))?.vendor;
133
+ vendor = names.find((n) => runs(n.vendor) && (n.name === bare || n.name.endsWith(dotted) || (n.suffix && dotted.endsWith(n.name.startsWith('.') ? n.name : `.${n.name}`))))?.vendor;
86
134
  }
87
135
  if (vendor)
88
- throw new Error(`volter-world app-url: --host ${name} is ${vendor}'s host, which its twin serves; an app cannot answer as it`);
89
- const status = statusWorld(world, root);
136
+ throw new Error(`volter-world app-url: --host ${name} is ${vendor}'s host, which its twin serves in this World; an app cannot answer as it while it runs`);
90
137
  const own = [status.env.VOLTER_WORLD ?? '', ...Object.values(status.services).map((service) => service.url ?? '')]
91
138
  .flatMap((url) => { try {
92
139
  return [new URL(url).hostname.toLowerCase()];
@@ -96,6 +143,17 @@ function assertAppHost(host, world, root) {
96
143
  } });
97
144
  if (own.some((h) => h === name || (wildcard && h.endsWith(`.${bare}`))))
98
145
  throw new Error(`volter-world app-url: --host ${name} is this World's own origin or a twin's; an app cannot answer as it`);
146
+ // one application per hostname: a name, or a wildcard over it, another application already answers
147
+ for (const other of [DEFAULT_APP, ...Object.keys(file.apps ?? {})]) {
148
+ if (other === app)
149
+ continue;
150
+ const held = entryOf(file, other)?.hosts ?? [];
151
+ // a wildcard answers every name below its parent and never the parent, as a DNS wildcard does, so `*.example.org`
152
+ // and `example.org` are two applications' without overlap
153
+ const clash = held.find((h) => h === name || (h.startsWith('*.') && name.endsWith(h.slice(1)) && name !== h) || (wildcard && h.endsWith(`.${bare}`)));
154
+ if (clash)
155
+ throw new Error(`volter-world app-url: --host ${name} overlaps ${clash}, which the application "${other}" answers; a hostname belongs to one application`);
156
+ }
99
157
  return name;
100
158
  }
101
159
  /** Every hostname the vendor table can name, with its vendor: a descriptor rule's host, suffix, or a host pattern's
@@ -131,14 +189,20 @@ function vendorNames(table) {
131
189
  /** Record `hosts` as the app's own production hostnames, beside its recorded URL (or its `app` service's). */
132
190
  export function addAppHosts(name, hosts, options = {}) {
133
191
  const root = options.root ?? process.cwd();
134
- const current = readAppUrl(name, { root });
135
- if (!current)
136
- throw new Error(appUrlUnsetMessage(name));
137
- const all = [...new Set([...(current.hosts ?? []), ...hosts.map((host) => assertAppHost(host, name, root))])].sort();
138
- // hosts beside the World's own `app` service keep no URL: they follow the service wherever a boot puts it
139
- const record = current.via === 'service' ? { world: current.world, via: 'service', hosts: all } : { ...current, hosts: all };
140
- writeFileSync(appUrlFile(root, name), `${JSON.stringify(record, null, 2)}\n`);
141
- return current.via === 'service' ? { ...current, hosts: all } : record;
192
+ const app = appName(options.app);
193
+ const status = statusWorld(name, root);
194
+ return writeEntry(root, name, app, (file) => {
195
+ // checked against the record as it stands under the lock, so two booters never both take one hostname
196
+ const current = appUrlOf(file, status, app);
197
+ if (!current)
198
+ throw new Error(appUrlUnsetMessage(name, app));
199
+ const all = [...new Set([...(current.hosts ?? []), ...hosts.map((host) => assertAppHost(host, name, root, app, file))])].sort();
200
+ // hosts beside the World's own service of the application's name keep no URL: they follow the service wherever a
201
+ // boot puts it
202
+ const { app: _name, ...stored } = current;
203
+ const entry = current.via === 'service' ? { world: current.world, via: 'service', hosts: all } : { ...stored, hosts: all };
204
+ return { entry, result: { ...current, hosts: all } };
205
+ });
142
206
  }
143
207
  /** The listening TCP ports of a live pid, via lsof (macOS + Linux). */
144
208
  function listeningTcpPorts(pid) {
@@ -214,24 +278,33 @@ export async function detectAppUrl(name, target, options = {}) {
214
278
  */
215
279
  export function readAppUrl(name, options = {}) {
216
280
  const root = options.root ?? process.cwd();
281
+ const app = appName(options.app);
217
282
  const status = statusWorld(name, root); // loud when the world does not exist at all
218
- const file = appUrlFile(root, name);
219
- const app = status.services['app'];
220
- if (existsSync(file)) {
221
- const record = JSON.parse(readFileSync(file, 'utf8'));
222
- // hosts recorded beside the World's own `app` service: its URL is this boot's
223
- if (!record.url && app?.url !== undefined)
224
- return { world: status.name, url: app.url, via: 'service', detail: 'the world\'s own "app" service', ...(record.hosts ? { hosts: record.hosts } : {}) };
283
+ return appUrlOf(readFile(root, name), status, app);
284
+ }
285
+ /** One application's URL from the record `file` and the instance `status`: the recorded URL, or the World's service
286
+ * of the application's name. */
287
+ function appUrlOf(file, status, app) {
288
+ const named = app === DEFAULT_APP ? {} : { app };
289
+ const service = status.services[app];
290
+ const record = entryOf(file, app);
291
+ if (record) {
292
+ // hosts recorded beside the World's own service of the application's name: its URL is this boot's
293
+ if (!record.url && service?.url !== undefined)
294
+ return { world: status.name, url: service.url, via: 'service', detail: `the world's own "${app}" service`, ...(record.hosts ? { hosts: record.hosts } : {}), ...named };
225
295
  if (record.url)
226
- return record;
296
+ return { ...record, world: status.name, ...named };
227
297
  }
228
- if (app?.url !== undefined) {
229
- return { world: status.name, url: app.url, via: 'service', detail: 'the world\'s own "app" service (nothing was recorded)' };
298
+ if (service?.url !== undefined) {
299
+ return { world: status.name, url: service.url, via: 'service', detail: `the world's own "${app}" service (nothing was recorded)`, ...named };
230
300
  }
231
301
  return null;
232
302
  }
233
303
  /** The message the CLI prints when no app URL is known — the registration recipe, not a guess. */
234
- export function appUrlUnsetMessage(name) {
304
+ export function appUrlUnsetMessage(name, app = DEFAULT_APP) {
305
+ if (app !== DEFAULT_APP) {
306
+ return `World ${name} has no URL for the application "${app}": record it with \`volter-world app-url ${name} --app ${app} --set http://127.0.0.1:<port>\`, or declare a service "${app}" in the World's config.`;
307
+ }
235
308
  return (`World ${name} has no recorded app URL.\n`
236
309
  + `If the app is booted OUTSIDE the world (the attach pattern), the booter registers it as the LAST boot step:\n`
237
310
  + ` volter-world app-url ${name} --set http://127.0.0.1:<port> # or: --detect <pid|port>\n`
package/dist/src/cli.js CHANGED
@@ -99,7 +99,7 @@ function printHelp() {
99
99
  volter-world urls <name> [--json] [--root <repo>]
100
100
  volter-world url <name> [service] [--root <repo>] # the clean base URL of one running service (no world.env parsing);
101
101
  # without a service: every service, one \`<id> <url>\` per line
102
- volter-world app-url <world> [--set <url>|--detect <pid|port>] [--host <name>...] [--json] [--root <repo>]
102
+ volter-world app-url <world> [--app <name>] [--set <url>|--detect <pid|port>] [--host <name>...] [--json] [--root <repo>]
103
103
  # the recorded APP endpoint of the instance — the attach pattern's
104
104
  # missing output. The BOOTER records it as the last boot step
105
105
  # (--set, or --detect an already-listening pid/port); consumers
@@ -678,22 +678,26 @@ async function main() {
678
678
  if (set && detect)
679
679
  throw new Error('volter-world app-url: pass --set OR --detect, not both');
680
680
  const json = rest.includes('--json');
681
+ // --app: which of the World's applications (each with its URL and hostnames); without it, `app`
682
+ const app = rest.some((arg) => arg === '--app' || arg.startsWith('--app=')) ? optionValue(rest, '--app') : undefined;
683
+ if (app === '' || app?.startsWith('--'))
684
+ throw new Error('volter-world app-url: --app needs an application name (--app <name>)');
681
685
  // --host (repeatable): a hostname the app answers as in production, routed to it inside the World
682
686
  const hosts = rest.flatMap((arg, index) => (arg === '--host' ? [rest[index + 1] ?? ''] : []));
683
687
  if (set || detect || hosts.length) {
684
- let record = set ? setAppUrl(subject, set, { root }) : detect ? await detectAppUrl(subject, detect, { root }) : undefined;
688
+ let record = set ? setAppUrl(subject, set, { root, app }) : detect ? await detectAppUrl(subject, detect, { root, app }) : undefined;
685
689
  if (hosts.length)
686
- record = addAppHosts(subject, hosts, { root });
690
+ record = addAppHosts(subject, hosts, { root, app });
687
691
  if (json)
688
692
  process.stdout.write(`${JSON.stringify(record, null, 2)}\n`);
689
693
  else
690
694
  process.stdout.write(`Recorded app URL for world ${subject}: ${record.url}${record.detail ? ` (${record.detail})` : ''}${record.hosts?.length ? `; answering as ${record.hosts.join(', ')}` : ''}\n`);
691
695
  return;
692
696
  }
693
- const record = readAppUrl(subject, { root });
697
+ const record = readAppUrl(subject, { root, app });
694
698
  if (record === null) {
695
699
  // LOUD when unset — a consumer must never mistake "nobody registered it" for an endpoint.
696
- process.stderr.write(`${appUrlUnsetMessage(subject)}\n`);
700
+ process.stderr.write(`${appUrlUnsetMessage(subject, app)}\n`);
697
701
  if (json)
698
702
  process.stdout.write(`${JSON.stringify({ world: subject, url: null }, null, 2)}\n`);
699
703
  process.exit(1);
@@ -37,7 +37,7 @@ import { fetchFromOrigin } from "./origin.js";
37
37
  import { CONSOLE_BASE, consoleRedirect, serveConsoleApart } from "./console-apart.js";
38
38
  import { loadWorldConfig } from "./configs.js";
39
39
  import { adaptersFor, credentialPath, packMirror, credentialPayloadFrom, deployTwin, deployWorld, loadWorldChecks, twinSigningSecret, materializeRoots, refreshPosture, refreshTwin, rootForControlRoot, sealTwinCredential, sealedCredentialInfo, setTwinRoot, spanMs } from "./root.js";
40
- import { clockFile, downWorld, statusWorld, upWorld } from "./runtime.js";
40
+ import { clockFile, downWorld, listWorlds, statusWorld, upWorld } from "./runtime.js";
41
41
  export const TOKEN_HEADER = 'x-volter-token';
42
42
  /** The header the injector carries a hosted World's token in (inject.cjs, VOLTER_TWINS_KEY). */
43
43
  export const TWINS_KEY_HEADER = 'x-twins-key';
@@ -1429,8 +1429,16 @@ export class WorldDoors {
1429
1429
  const branches = this.host.branches;
1430
1430
  if (!branches)
1431
1431
  return Response.json({ error: `this host makes no branches of ${this.served}` }, { status: 404 });
1432
- if (rest.length === 0 && request.method === 'GET')
1433
- return Response.json({ world: this.served, branches: await branches.list() });
1432
+ if (rest.length === 0 && request.method === 'GET') {
1433
+ // beside the host's snapshots, the World's own NAMED branches (`volter world branch`): one runs at a time, and
1434
+ // `checkedOut` is the one this serve holds, so a page never names the World for the branch it shows
1435
+ let named = [];
1436
+ try {
1437
+ named = listWorlds(this.worldRoot).map((w) => ({ name: w.name, running: w.running }));
1438
+ }
1439
+ catch { /* a World with no local branches */ }
1440
+ return Response.json({ world: this.served, branches: await branches.list(), checkedOut: this.name, named });
1441
+ }
1434
1442
  if (rest.length === 0 && request.method === 'POST') {
1435
1443
  const body = (await request.json().catch(() => null));
1436
1444
  const instant = body?.at?.instant;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@volter/world-runtime",
3
- "version": "2.0.11",
3
+ "version": "2.0.13",
4
4
  "description": "World configs for twins: boot named local runtimes, allocate ports, generate world.env/instance.json, and run apps against fake-key twin worlds.",
5
5
  "keywords": [
6
6
  "twin",
@@ -67,14 +67,14 @@
67
67
  "node": ">=22.3"
68
68
  },
69
69
  "peerDependencies": {
70
- "@volter/world-core": "2.0.7",
71
- "@volter/world-console": "2.0.6"
70
+ "@volter/world-core": "2.0.8",
71
+ "@volter/world-console": "2.0.7"
72
72
  },
73
73
  "dependencies": {
74
74
  "@electric-sql/pglite": "0.5.8",
75
75
  "@electric-sql/pglite-pgvector": "0.0.9",
76
76
  "@volter/world-access": "2.0.1",
77
- "@volter/world-core": "2.0.7",
77
+ "@volter/world-core": "2.0.8",
78
78
  "pg-gateway": "0.3.0-beta.4",
79
79
  "smol-toml": "^1.8.0"
80
80
  },
@@ -84,6 +84,6 @@
84
84
  }
85
85
  },
86
86
  "optionalDependencies": {
87
- "@volter/twin-mongodb": "0.1.7"
87
+ "@volter/twin-mongodb": "0.1.8"
88
88
  }
89
89
  }
package/src/app-url.ts CHANGED
@@ -19,7 +19,8 @@
19
19
  // once, at the caller's request, and stores what it saw. The record lives in the instance dir, so
20
20
  // a re-`up` (which wipes the dir) or `down --purge` clears it: a recorded URL never outlives the
21
21
  // instance it described.
22
- import { existsSync, readFileSync, writeFileSync } from 'node:fs';
22
+ import { existsSync, readFileSync, renameSync, writeFileSync } from 'node:fs';
23
+ import { withFileLock } from '@volter/world-core';
23
24
  import { join } from 'node:path';
24
25
  import { spawnSync } from 'node:child_process';
25
26
  import net from 'node:net';
@@ -41,8 +42,56 @@ export type AppUrlRecord = {
41
42
  /** the application's own production hostnames (`--host`), routed to `url` inside the World as DNS routes them to
42
43
  * its host in production: the injector, the socket backstop and the redirect proxy read them from this record */
43
44
  hosts?: string[];
45
+ /** the application's name in the World (`--app`); absent: `app`, the one a command names without it */
46
+ app?: string;
44
47
  };
45
48
 
49
+ /** `app-url.json`: the application named `app` at the top level (the record's original shape), the others by name. */
50
+ type AppUrlFile = Partial<AppUrlRecord> & { apps?: Record<string, Partial<AppUrlRecord>> };
51
+
52
+ const APP_NAME = /^[a-z0-9][a-z0-9-]{0,62}$/;
53
+ const DEFAULT_APP = 'app';
54
+
55
+ function appName(app: string | undefined): string {
56
+ const name = app ?? DEFAULT_APP;
57
+ if (!APP_NAME.test(name)) throw new Error(`volter-world app-url: --app "${name}" is not an application name (lower-case letters, digits and -)`);
58
+ return name;
59
+ }
60
+
61
+ function readFile(root: string, name: string): AppUrlFile {
62
+ const file = appUrlFile(root, name);
63
+ return existsSync(file) ? JSON.parse(readFileSync(file, 'utf8')) as AppUrlFile : {};
64
+ }
65
+
66
+ function entryOf(file: AppUrlFile, app: string): Partial<AppUrlRecord> | undefined {
67
+ if (app !== DEFAULT_APP) return file.apps && Object.hasOwn(file.apps, app) ? file.apps[app] : undefined;
68
+ const { apps: _others, ...own } = file;
69
+ return own.url !== undefined || own.hosts !== undefined ? own : undefined;
70
+ }
71
+
72
+ /** Write one application's entry, keeping every other application's: under the record's lock (several booters record
73
+ * their applications at once), replaced whole so a reader never meets half a file. */
74
+ function writeEntry<T>(root: string, name: string, app: string, build: (file: AppUrlFile) => { entry: Partial<AppUrlRecord>; result: T }): T {
75
+ // the whole read, check, merge and write under one lock (withFileLock is not reentrant: nothing inside takes it)
76
+ return withFileLock(`${appUrlFile(root, name)}.lock`, () => {
77
+ const file = readFile(root, name);
78
+ const { entry, result } = build(file);
79
+ writeEntryLocked(root, name, app, file, entry);
80
+ return result;
81
+ });
82
+ }
83
+
84
+ function writeEntryLocked(root: string, name: string, app: string, file: AppUrlFile, entry: Partial<AppUrlRecord>): void {
85
+ const { app: _name, ...stored } = entry;
86
+ const next: AppUrlFile = app === DEFAULT_APP
87
+ ? { ...stored, ...(file.apps ? { apps: file.apps } : {}) }
88
+ : { ...Object.fromEntries(Object.entries(file).filter(([k]) => k !== 'apps')), apps: { ...(file.apps ?? {}), [app]: stored } };
89
+ const target = appUrlFile(root, name);
90
+ const temp = `${target}.${process.pid}.tmp`;
91
+ writeFileSync(temp, `${JSON.stringify(next, null, 2)}\n`);
92
+ renameSync(temp, target);
93
+ }
94
+
46
95
  export function appUrlFile(root: string, name: string): string {
47
96
  return join(instanceDir(root, name), 'app-url.json');
48
97
  }
@@ -63,27 +112,30 @@ function assertHttpUrl(url: string): URL {
63
112
  /** Record `url` as the app endpoint of the RUNNING/booted instance `name`. The instance must
64
113
  * exist (a record for a world that was never upped would describe nothing); the app itself is
65
114
  * deliberately NOT probed — the booter declaring "this is where I put the app" is the truth. */
66
- export function setAppUrl(name: string, url: string, options: { root?: string; via?: Exclude<AppUrlSource, 'service'>; detail?: string } = {}): AppUrlRecord {
115
+ export function setAppUrl(name: string, url: string, options: { root?: string; via?: Exclude<AppUrlSource, 'service'>; detail?: string; app?: string } = {}): AppUrlRecord {
67
116
  const root = options.root ?? process.cwd();
117
+ const app = appName(options.app);
68
118
  const status = statusWorld(name, root); // throws "World instance not found" — the loud path
69
119
  assertHttpUrl(url);
70
- // a new URL keeps the hosts recorded for the app (they name the app, not where it listens)
71
- const hosts = existsSync(appUrlFile(root, name)) ? (JSON.parse(readFileSync(appUrlFile(root, name), 'utf8')) as AppUrlRecord).hosts : undefined;
72
- const record: AppUrlRecord = {
73
- world: status.name,
74
- url,
75
- via: options.via ?? 'set',
76
- recordedAt: new Date().toISOString(),
77
- ...(options.detail === undefined ? {} : { detail: options.detail }),
78
- ...(hosts?.length ? { hosts } : {}),
79
- };
80
- writeFileSync(appUrlFile(root, name), `${JSON.stringify(record, null, 2)}\n`);
81
- return record;
82
- }
83
-
84
- /** A hostname the app may answer as: a DNS name (not an address, not loopback) that no twin serves, and not the
85
- * host of this World's own origin or of a twin it runs (their requests carry the World key). */
86
- function assertAppHost(host: string, world: string, root: string): string {
120
+ return writeEntry(root, name, app, (file) => {
121
+ // a new URL keeps the hosts recorded for the app (they name the app, not where it listens)
122
+ const hosts = entryOf(file, app)?.hosts;
123
+ const record: AppUrlRecord = {
124
+ world: status.name,
125
+ url,
126
+ via: options.via ?? 'set',
127
+ recordedAt: new Date().toISOString(),
128
+ ...(options.detail === undefined ? {} : { detail: options.detail }),
129
+ ...(hosts?.length ? { hosts } : {}),
130
+ };
131
+ return { entry: record, result: app === DEFAULT_APP ? record : { ...record, app } };
132
+ });
133
+ }
134
+
135
+ /** A hostname the app may answer as: a DNS name (not an address, not loopback) that no twin the World runs serves,
136
+ * not the host of this World's own origin or of a twin it runs (their requests carry the World key), and not one
137
+ * another of its applications answers. A vendor the World runs no twin of can be the application itself. */
138
+ function assertAppHost(host: string, world: string, root: string, app: string, file: AppUrlFile): string {
87
139
  const name = host.trim().toLowerCase().replace(/\.$/, '');
88
140
  // a wildcard (`*.dub.link`) stands for every name below its parent, as a DNS wildcard record does; its parent has two
89
141
  // labels at least (never a bare TLD). Whether the parent is a public suffix (co.uk, github.io) is the operator's word:
@@ -94,20 +146,33 @@ function assertAppHost(host: string, world: string, root: string): string {
94
146
  throw new Error(`volter-world app-url: --host "${host}" is not a DNS hostname the app answers as (e.g. app.example.com, or *.example.com)`);
95
147
  }
96
148
  const inject = loadInject();
97
- let vendor = Object.keys(inject.VENDOR_HOSTS).find((v) => inject.VENDOR_HOSTS[v]!(bare));
149
+ const status = statusWorld(world, root);
150
+ // the twins the World runs, by vendor key, as its injector reads them from the World env (a service's id is not its
151
+ // vendor key: the aws service serves s3, dynamodb and others; a stripe twin may be a service named payments)
152
+ const twins = inject.readMap(status.env);
153
+ const runs = (vendor: string) => Object.hasOwn(twins, vendor);
154
+ let vendor = Object.keys(inject.VENDOR_HOSTS).find((v) => runs(v) && inject.VENDOR_HOSTS[v]!(bare));
98
155
  if (!vendor && wildcard) {
99
156
  // a wildcard is refused when any name a vendor's rule can give its twin lies under it, or it lies under a vendor's
100
157
  // host suffix: the rules' names, read from the descriptors (a host, a suffix, a pattern's literal tail) and from
101
158
  // the hand table's predicates (their quoted names)
102
159
  const names = vendorNames(inject.VENDOR_HOSTS);
103
160
  const dotted = `.${bare}`;
104
- vendor = names.find((n) => n.name === bare || n.name.endsWith(dotted) || (n.suffix && dotted.endsWith(n.name.startsWith('.') ? n.name : `.${n.name}`)))?.vendor;
161
+ vendor = names.find((n) => runs(n.vendor) && (n.name === bare || n.name.endsWith(dotted) || (n.suffix && dotted.endsWith(n.name.startsWith('.') ? n.name : `.${n.name}`))))?.vendor;
105
162
  }
106
- if (vendor) throw new Error(`volter-world app-url: --host ${name} is ${vendor}'s host, which its twin serves; an app cannot answer as it`);
107
- const status = statusWorld(world, root);
163
+ if (vendor) throw new Error(`volter-world app-url: --host ${name} is ${vendor}'s host, which its twin serves in this World; an app cannot answer as it while it runs`);
108
164
  const own = [status.env.VOLTER_WORLD ?? '', ...Object.values(status.services).map((service) => service.url ?? '')]
109
165
  .flatMap((url) => { try { return [new URL(url).hostname.toLowerCase()]; } catch { return []; } });
110
166
  if (own.some((h) => h === name || (wildcard && h.endsWith(`.${bare}`)))) throw new Error(`volter-world app-url: --host ${name} is this World's own origin or a twin's; an app cannot answer as it`);
167
+ // one application per hostname: a name, or a wildcard over it, another application already answers
168
+ for (const other of [DEFAULT_APP, ...Object.keys(file.apps ?? {})]) {
169
+ if (other === app) continue;
170
+ const held = entryOf(file, other)?.hosts ?? [];
171
+ // a wildcard answers every name below its parent and never the parent, as a DNS wildcard does, so `*.example.org`
172
+ // and `example.org` are two applications' without overlap
173
+ const clash = held.find((h) => h === name || (h.startsWith('*.') && name.endsWith(h.slice(1)) && name !== h) || (wildcard && h.endsWith(`.${bare}`)));
174
+ if (clash) throw new Error(`volter-world app-url: --host ${name} overlaps ${clash}, which the application "${other}" answers; a hostname belongs to one application`);
175
+ }
111
176
  return name;
112
177
  }
113
178
 
@@ -137,15 +202,21 @@ function vendorNames(table: Record<string, (host: string, pathname?: string) =>
137
202
  }
138
203
 
139
204
  /** Record `hosts` as the app's own production hostnames, beside its recorded URL (or its `app` service's). */
140
- export function addAppHosts(name: string, hosts: string[], options: { root?: string } = {}): AppUrlRecord {
205
+ export function addAppHosts(name: string, hosts: string[], options: { root?: string; app?: string } = {}): AppUrlRecord {
141
206
  const root = options.root ?? process.cwd();
142
- const current = readAppUrl(name, { root });
143
- if (!current) throw new Error(appUrlUnsetMessage(name));
144
- const all = [...new Set([...(current.hosts ?? []), ...hosts.map((host) => assertAppHost(host, name, root))])].sort();
145
- // hosts beside the World's own `app` service keep no URL: they follow the service wherever a boot puts it
146
- const record: AppUrlRecord = current.via === 'service' ? { world: current.world, via: 'service', hosts: all } as AppUrlRecord : { ...current, hosts: all };
147
- writeFileSync(appUrlFile(root, name), `${JSON.stringify(record, null, 2)}\n`);
148
- return current.via === 'service' ? { ...current, hosts: all } : record;
207
+ const app = appName(options.app);
208
+ const status = statusWorld(name, root);
209
+ return writeEntry(root, name, app, (file) => {
210
+ // checked against the record as it stands under the lock, so two booters never both take one hostname
211
+ const current = appUrlOf(file, status, app);
212
+ if (!current) throw new Error(appUrlUnsetMessage(name, app));
213
+ const all = [...new Set([...(current.hosts ?? []), ...hosts.map((host) => assertAppHost(host, name, root, app, file))])].sort();
214
+ // hosts beside the World's own service of the application's name keep no URL: they follow the service wherever a
215
+ // boot puts it
216
+ const { app: _name, ...stored } = current;
217
+ const entry = current.via === 'service' ? { world: current.world, via: 'service', hosts: all } as AppUrlRecord : { ...stored, hosts: all };
218
+ return { entry, result: { ...current, hosts: all } };
219
+ });
149
220
  }
150
221
 
151
222
  /** The listening TCP ports of a live pid, via lsof (macOS + Linux). */
@@ -193,7 +264,7 @@ function tcpListening(port: number, timeoutMs = 1500): Promise<boolean> {
193
264
  * Otherwise the number is tried as a loopback port and must actually be listening. Detection
194
265
  * that finds nothing refuses loudly rather than recording a URL nothing serves.
195
266
  */
196
- export async function detectAppUrl(name: string, target: string, options: { root?: string } = {}): Promise<AppUrlRecord> {
267
+ export async function detectAppUrl(name: string, target: string, options: { root?: string; app?: string } = {}): Promise<AppUrlRecord> {
197
268
  const numeric = Number(target);
198
269
  if (!Number.isInteger(numeric) || numeric <= 0) {
199
270
  throw new Error(`volter-world app-url: --detect wants a pid or a port, got "${target}"`);
@@ -225,25 +296,35 @@ export async function detectAppUrl(name: string, target: string, options: { root
225
296
  * (the world-boots-the-app convention), or null. Null is the CALLER's loud-error cue — the CLI
226
297
  * turns it into exit 1 with the registration recipe; a library consumer decides for itself.
227
298
  */
228
- export function readAppUrl(name: string, options: { root?: string } = {}): AppUrlRecord | null {
299
+ export function readAppUrl(name: string, options: { root?: string; app?: string } = {}): AppUrlRecord | null {
229
300
  const root = options.root ?? process.cwd();
301
+ const app = appName(options.app);
230
302
  const status = statusWorld(name, root); // loud when the world does not exist at all
231
- const file = appUrlFile(root, name);
232
- const app = status.services['app'];
233
- if (existsSync(file)) {
234
- const record = JSON.parse(readFileSync(file, 'utf8')) as AppUrlRecord;
235
- // hosts recorded beside the World's own `app` service: its URL is this boot's
236
- if (!record.url && app?.url !== undefined) return { world: status.name, url: app.url, via: 'service', detail: 'the world\'s own "app" service', ...(record.hosts ? { hosts: record.hosts } : {}) };
237
- if (record.url) return record;
303
+ return appUrlOf(readFile(root, name), status, app);
304
+ }
305
+
306
+ /** One application's URL from the record `file` and the instance `status`: the recorded URL, or the World's service
307
+ * of the application's name. */
308
+ function appUrlOf(file: AppUrlFile, status: { name: string; services: Record<string, { url?: string }> }, app: string): AppUrlRecord | null {
309
+ const named = app === DEFAULT_APP ? {} : { app };
310
+ const service = status.services[app];
311
+ const record = entryOf(file, app);
312
+ if (record) {
313
+ // hosts recorded beside the World's own service of the application's name: its URL is this boot's
314
+ if (!record.url && service?.url !== undefined) return { world: status.name, url: service.url, via: 'service', detail: `the world's own "${app}" service`, ...(record.hosts ? { hosts: record.hosts } : {}), ...named };
315
+ if (record.url) return { ...(record as AppUrlRecord), world: status.name, ...named };
238
316
  }
239
- if (app?.url !== undefined) {
240
- return { world: status.name, url: app.url, via: 'service', detail: 'the world\'s own "app" service (nothing was recorded)' };
317
+ if (service?.url !== undefined) {
318
+ return { world: status.name, url: service.url, via: 'service', detail: `the world's own "${app}" service (nothing was recorded)`, ...named };
241
319
  }
242
320
  return null;
243
321
  }
244
322
 
245
323
  /** The message the CLI prints when no app URL is known — the registration recipe, not a guess. */
246
- export function appUrlUnsetMessage(name: string): string {
324
+ export function appUrlUnsetMessage(name: string, app = DEFAULT_APP): string {
325
+ if (app !== DEFAULT_APP) {
326
+ return `World ${name} has no URL for the application "${app}": record it with \`volter-world app-url ${name} --app ${app} --set http://127.0.0.1:<port>\`, or declare a service "${app}" in the World's config.`;
327
+ }
247
328
  return (
248
329
  `World ${name} has no recorded app URL.\n`
249
330
  + `If the app is booted OUTSIDE the world (the attach pattern), the booter registers it as the LAST boot step:\n`
package/src/cli.ts CHANGED
@@ -100,7 +100,7 @@ function printHelp(): void {
100
100
  volter-world urls <name> [--json] [--root <repo>]
101
101
  volter-world url <name> [service] [--root <repo>] # the clean base URL of one running service (no world.env parsing);
102
102
  # without a service: every service, one \`<id> <url>\` per line
103
- volter-world app-url <world> [--set <url>|--detect <pid|port>] [--host <name>...] [--json] [--root <repo>]
103
+ volter-world app-url <world> [--app <name>] [--set <url>|--detect <pid|port>] [--host <name>...] [--json] [--root <repo>]
104
104
  # the recorded APP endpoint of the instance — the attach pattern's
105
105
  # missing output. The BOOTER records it as the last boot step
106
106
  # (--set, or --detect an already-listening pid/port); consumers
@@ -608,19 +608,22 @@ async function main(): Promise<void> {
608
608
  const detect = optionValue(rest, '--detect');
609
609
  if (set && detect) throw new Error('volter-world app-url: pass --set OR --detect, not both');
610
610
  const json = rest.includes('--json');
611
+ // --app: which of the World's applications (each with its URL and hostnames); without it, `app`
612
+ const app = rest.some((arg) => arg === '--app' || arg.startsWith('--app=')) ? optionValue(rest, '--app') : undefined;
613
+ if (app === '' || app?.startsWith('--')) throw new Error('volter-world app-url: --app needs an application name (--app <name>)');
611
614
  // --host (repeatable): a hostname the app answers as in production, routed to it inside the World
612
615
  const hosts = rest.flatMap((arg, index) => (arg === '--host' ? [rest[index + 1] ?? ''] : []));
613
616
  if (set || detect || hosts.length) {
614
- let record = set ? setAppUrl(subject, set, { root }) : detect ? await detectAppUrl(subject, detect, { root }) : undefined;
615
- if (hosts.length) record = addAppHosts(subject, hosts, { root });
617
+ let record = set ? setAppUrl(subject, set, { root, app }) : detect ? await detectAppUrl(subject, detect, { root, app }) : undefined;
618
+ if (hosts.length) record = addAppHosts(subject, hosts, { root, app });
616
619
  if (json) process.stdout.write(`${JSON.stringify(record, null, 2)}\n`);
617
620
  else process.stdout.write(`Recorded app URL for world ${subject}: ${record!.url}${record!.detail ? ` (${record!.detail})` : ''}${record!.hosts?.length ? `; answering as ${record!.hosts.join(', ')}` : ''}\n`);
618
621
  return;
619
622
  }
620
- const record = readAppUrl(subject, { root });
623
+ const record = readAppUrl(subject, { root, app });
621
624
  if (record === null) {
622
625
  // LOUD when unset — a consumer must never mistake "nobody registered it" for an endpoint.
623
- process.stderr.write(`${appUrlUnsetMessage(subject)}\n`);
626
+ process.stderr.write(`${appUrlUnsetMessage(subject, app)}\n`);
624
627
  if (json) process.stdout.write(`${JSON.stringify({ world: subject, url: null }, null, 2)}\n`);
625
628
  process.exit(1);
626
629
  }
@@ -40,7 +40,7 @@ import { fetchFromOrigin } from './origin.ts';
40
40
  import { CONSOLE_BASE, consoleRedirect, serveConsoleApart, type ConsoleMount } from './console-apart.ts';
41
41
  import { loadWorldConfig } from './configs.ts';
42
42
  import { adaptersFor, credentialPath, packMirror, type PackMirror, credentialPayloadFrom, deployTwin, deployWorld, loadWorldChecks, twinSigningSecret, materializeRoots, refreshPosture, refreshTwin, rootForControlRoot, sealTwinCredential, sealedCredentialInfo, setTwinRoot, spanMs } from './root.ts';
43
- import { clockFile, downWorld, statusWorld, upWorld } from './runtime.ts';
43
+ import { clockFile, downWorld, listWorlds, statusWorld, upWorld } from './runtime.ts';
44
44
  import type { WorldInstance } from './schema.ts';
45
45
 
46
46
  export const TOKEN_HEADER = 'x-volter-token';
@@ -1225,7 +1225,13 @@ export class WorldDoors {
1225
1225
  private async branchesDoor(request: Request, url: URL, rest: string[], via: 'token' | 'key' | 'session' | null): Promise<Response> {
1226
1226
  const branches = this.host.branches;
1227
1227
  if (!branches) return Response.json({ error: `this host makes no branches of ${this.served}` }, { status: 404 });
1228
- if (rest.length === 0 && request.method === 'GET') return Response.json({ world: this.served, branches: await branches.list() });
1228
+ if (rest.length === 0 && request.method === 'GET') {
1229
+ // beside the host's snapshots, the World's own NAMED branches (`volter world branch`): one runs at a time, and
1230
+ // `checkedOut` is the one this serve holds, so a page never names the World for the branch it shows
1231
+ let named: Array<{ name: string; running: boolean }> = [];
1232
+ try { named = listWorlds(this.worldRoot).map((w) => ({ name: w.name, running: w.running })); } catch { /* a World with no local branches */ }
1233
+ return Response.json({ world: this.served, branches: await branches.list(), checkedOut: this.name, named });
1234
+ }
1229
1235
  if (rest.length === 0 && request.method === 'POST') {
1230
1236
  const body = (await request.json().catch(() => null)) as { at?: { instant?: unknown }; ttl?: unknown; live?: unknown; label?: unknown } | null;
1231
1237
  const instant = body?.at?.instant;