@volter/world-runtime 2.0.12 → 2.0.14

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);
@@ -351,8 +351,12 @@ export async function startRedirectProxy(options) {
351
351
  for (const [k, v] of Object.entries(loadInject().appForwardHeaders(new URL(`https://${req.headers.get('host') ?? vendorHost}`))))
352
352
  headers.set(k, v);
353
353
  }
354
- else if (env.VOLTER_TWINS_KEY)
355
- headers.set('x-twins-key', env.VOLTER_TWINS_KEY);
354
+ else {
355
+ if (env.VOLTER_TWINS_KEY)
356
+ headers.set('x-twins-key', env.VOLTER_TWINS_KEY);
357
+ // the caller reached the vendor's host over TLS, which ends here: the twin renders its own links with https
358
+ headers.set('x-forwarded-proto', 'https');
359
+ }
356
360
  // Hosted namespaces need their transport Host; direct twins can use
357
361
  // the original Host for vendor semantics (for example S3 buckets).
358
362
  if (origin.pathname.replace(/\/$/, '') && twin.vendor !== 'app') {
@@ -63,7 +63,8 @@ export declare function mountWorld(name: string, opts?: {
63
63
  root?: string; /** the host's branches of this World, by its served name */
64
64
  branches?: (served: string) => BranchDoors | undefined; /** the origin a browser reaches this World at, when the host gives each World one */
65
65
  browserOrigin?: (served: string) => string | null; /** served on this machine's loopback: its own pages need no token (DoorHost.localTrust) */
66
- localTrust?: boolean;
66
+ localTrust?: boolean; /** DoorHost.serveBranch */
67
+ serveBranch?: (branch: string) => Promise<void>;
67
68
  }): Promise<MountedWorld>;
68
69
  /** Boot the world (state kept) and serve it on its own port. Announces and returns after boot; `stop` downs the world. */
69
70
  export declare function serveWorld(name: string, opts?: {
@@ -143,6 +144,9 @@ export type DoorHost = {
143
144
  /** This World's branches, where the host can make them (a branch is another World, so making one
144
145
  * is the host's act); absent, the branches doors answer 404. */
145
146
  branches?: BranchDoors;
147
+ /** Serve another of the World's own named branches in this one's place (`volter world view` does; a host that
148
+ * serves one branch leaves it absent): this branch's twins stop, that one's start, under the same served name. */
149
+ serveBranch?: (branch: string) => Promise<void>;
146
150
  };
147
151
  /** A branch as its parent lists it. */
148
152
  export type BranchRow = {
@@ -143,6 +143,7 @@ export async function mountWorld(name, opts = {}) {
143
143
  ...(opts.branches?.(served) ? { branches: opts.branches(served) } : {}),
144
144
  ...(opts.browserOrigin ? { browserOrigin: () => opts.browserOrigin(served), originOf: (other) => opts.browserOrigin(other) } : {}),
145
145
  ...(opts.localTrust ? { localTrust: () => true } : {}),
146
+ ...(opts.serveBranch ? { serveBranch: opts.serveBranch } : {}),
146
147
  ...(opts.passIssuers?.length ? { passIssuers: () => opts.passIssuers } : {}),
147
148
  // a local twin is its own process: the wire forwards to the port it listens on
148
149
  twinFetch: (vendor) => {
@@ -1439,6 +1440,28 @@ export class WorldDoors {
1439
1440
  catch { /* a World with no local branches */ }
1440
1441
  return Response.json({ world: this.served, branches: await branches.list(), checkedOut: this.name, named });
1441
1442
  }
1443
+ // SERVE ANOTHER NAMED BRANCH here (`POST branches/<branch>/serve`): only who may write the World switches what it serves
1444
+ if (rest.length === 2 && rest[1] === 'serve' && request.method === 'POST') {
1445
+ if (!this.host.serveBranch)
1446
+ return Response.json({ error: `this host serves ${this.served} on one branch` }, { status: 404 });
1447
+ if (this.credential(request).scope !== 'write')
1448
+ return Response.json({ error: 'serving another branch needs the World\'s write access' }, { status: 403 });
1449
+ const target = decodeURIComponent(rest[0]);
1450
+ let known = false;
1451
+ try {
1452
+ known = listWorlds(this.worldRoot).some((w) => w.name === target);
1453
+ }
1454
+ catch { /* no local branches */ }
1455
+ if (!known)
1456
+ return Response.json({ error: `no branch ${target} in this World` }, { status: 404 });
1457
+ try {
1458
+ await this.host.serveBranch(target);
1459
+ }
1460
+ catch (error) {
1461
+ return Response.json({ error: error instanceof Error ? error.message : String(error) }, { status: 409 });
1462
+ }
1463
+ return Response.json({ world: this.served, checkedOut: target });
1464
+ }
1442
1465
  if (rest.length === 0 && request.method === 'POST') {
1443
1466
  const body = (await request.json().catch(() => null));
1444
1467
  const instant = body?.at?.instant;
@@ -2,7 +2,7 @@
2
2
  // ("as of" views) and the console, on one origin (docs/contributing/architecture.md, "Viewing a
3
3
  // World"). `volter world view` is this; the runtime's `up` gains no step. A host that serves many
4
4
  // Worlds (world-host) answers the same doors; this serves one, and the branches made of it.
5
- import { existsSync, readdirSync } from 'node:fs';
5
+ import { existsSync, readdirSync, rmSync, writeFileSync } from 'node:fs';
6
6
  import { loadWorldConfig } from "./configs.js";
7
7
  import { join, resolve } from 'node:path';
8
8
  import { serveHttp, stateDirName } from '@volter/world-core';
@@ -42,7 +42,46 @@ export async function serveWorldView(name, opts = {}) {
42
42
  const browserOrigin = origins ? (served) => (port ? localWorldOrigin(served, port) : null) : undefined;
43
43
  const mountAt = (at) => mountWorld(loadWorldConfig(join(at, stateDirName(), 'world.json'), at).config.id, { root: at, branches: (served) => branches.doorsFor(served), ...(browserOrigin ? { browserOrigin } : {}), ...(local ? { localTrust: true } : {}) });
44
44
  const branches = new LocalBranches({ dir: join(root, stateDirName(), 'branches'), origin: () => loopback, worlds, mount: mountAt, ...(opts.announce ? { announce: opts.announce } : {}) });
45
- const world = await mountWorld(name, { root, branches: (served) => branches.doorsFor(served), ...(browserOrigin ? { browserOrigin } : {}), ...(local ? { localTrust: true } : {}) });
45
+ // THE WORLD'S OWN BRANCHES, one served at a time: serving another stops this one's twins and starts that one's under the
46
+ // same served name (its tokens and sessions are the World's, so the page stays open); `.volter/current` follows, as
47
+ // \`volter world checkout\` sets it. While it switches, the World's doors answer 503.
48
+ let url = '';
49
+ let switching = false;
50
+ const serveBranch = async (branch) => {
51
+ if (switching)
52
+ throw new Error('already switching branches');
53
+ if (branch === world.name)
54
+ return;
55
+ switching = true;
56
+ const from = world.name;
57
+ try {
58
+ await world.stop();
59
+ let next;
60
+ try {
61
+ next = await mountWorld(branch, mountOpts);
62
+ await next.boot(url);
63
+ }
64
+ catch (error) {
65
+ const back = await mountWorld(from, mountOpts);
66
+ await back.boot(url);
67
+ worlds.set(back.served, back);
68
+ world = back;
69
+ throw error;
70
+ }
71
+ worlds.set(next.served, next);
72
+ world = next;
73
+ const current = join(root, stateDirName(), 'current');
74
+ if (branch === loadWorldConfig(join(root, stateDirName(), 'world.json'), root).config.id)
75
+ rmSync(current, { force: true });
76
+ else
77
+ writeFileSync(current, `${branch}\n`);
78
+ }
79
+ finally {
80
+ switching = false;
81
+ }
82
+ };
83
+ const mountOpts = { root, branches: (served) => branches.doorsFor(served), ...(browserOrigin ? { browserOrigin } : {}), ...(local ? { localTrust: true } : {}), serveBranch };
84
+ let world = await mountWorld(name, mountOpts);
46
85
  worlds.set(world.served, world);
47
86
  const server = await serveHttp({
48
87
  // a pushed blob (a release video) is one PUT: bodies up to 1 GiB, as `serve` takes them
@@ -73,6 +112,8 @@ export async function serveWorldView(name, opts = {}) {
73
112
  return answered;
74
113
  }
75
114
  const m = /^\/(?:-\/)?([^/]+)\/([^/]+)/.exec(path);
115
+ if (switching && m && `${m[1]}/${m[2]}` === world.served)
116
+ return Response.json({ error: `${world.served} is switching branches` }, { status: 503, headers: { 'retry-after': '2' } });
76
117
  const target = m ? worlds.get(`${m[1]}/${m[2]}`) : undefined;
77
118
  if (!target)
78
119
  return Response.json({ error: `no world at ${path}: this view serves ${[...worlds.keys()].join(', ')}` }, { status: 404 });
@@ -80,7 +121,7 @@ export async function serveWorldView(name, opts = {}) {
80
121
  },
81
122
  });
82
123
  port = server.port ?? 0;
83
- const url = `http://${host}:${server.port}`;
124
+ url = `http://${host}:${server.port}`;
84
125
  loopback = `http://${host === '0.0.0.0' || host === '::' ? '127.0.0.1' : host}:${server.port}`;
85
126
  // a branch outlives the view that made it until its time runs out: stopping stops its compute only
86
127
  const stop = async () => {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@volter/world-runtime",
3
- "version": "2.0.12",
3
+ "version": "2.0.14",
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.7"
70
+ "@volter/world-core": "2.0.8",
71
+ "@volter/world-console": "2.0.8"
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
  }
@@ -373,7 +373,11 @@ export async function startRedirectProxy(options: RedirectProxyOptions): Promise
373
373
  if (twin.vendor === 'app') {
374
374
  headers.delete('x-twins-key');
375
375
  for (const [k, v] of Object.entries(loadInject().appForwardHeaders(new URL(`https://${req.headers.get('host') ?? vendorHost}`)))) headers.set(k, v);
376
- } else if (env.VOLTER_TWINS_KEY) headers.set('x-twins-key', env.VOLTER_TWINS_KEY);
376
+ } else {
377
+ if (env.VOLTER_TWINS_KEY) headers.set('x-twins-key', env.VOLTER_TWINS_KEY);
378
+ // the caller reached the vendor's host over TLS, which ends here: the twin renders its own links with https
379
+ headers.set('x-forwarded-proto', 'https');
380
+ }
377
381
  // Hosted namespaces need their transport Host; direct twins can use
378
382
  // the original Host for vendor semantics (for example S3 buckets).
379
383
  if (origin.pathname.replace(/\/$/, '') && twin.vendor !== 'app') {
@@ -116,7 +116,7 @@ export type MountedWorld = { name: string; served: string; root: string; readonl
116
116
  /** A browser session a World keeps: its scope, when it ends, and the person a pass named, when one did. */
117
117
  type HeldSession = { scope: 'read' | 'write'; until: number; who?: string; /** the platform whose pass opened it */ issuer?: string; /** the person's subject there */ sub?: string; /** the read key a shared link opened it with: it lasts only while that key does */ key?: string };
118
118
 
119
- export async function mountWorld(name: string, opts: { /** the platforms whose passes open this World */ passIssuers?: TrustedIssuer[]; root?: string; /** the host's branches of this World, by its served name */ branches?: (served: string) => BranchDoors | undefined; /** the origin a browser reaches this World at, when the host gives each World one */ browserOrigin?: (served: string) => string | null; /** served on this machine's loopback: its own pages need no token (DoorHost.localTrust) */ localTrust?: boolean } = {}): Promise<MountedWorld> {
119
+ export async function mountWorld(name: string, opts: { /** the platforms whose passes open this World */ passIssuers?: TrustedIssuer[]; root?: string; /** the host's branches of this World, by its served name */ branches?: (served: string) => BranchDoors | undefined; /** the origin a browser reaches this World at, when the host gives each World one */ browserOrigin?: (served: string) => string | null; /** served on this machine's loopback: its own pages need no token (DoorHost.localTrust) */ localTrust?: boolean; /** DoorHost.serveBranch */ serveBranch?: (branch: string) => Promise<void> } = {}): Promise<MountedWorld> {
120
120
  const worldRoot = resolve(opts.root ?? process.cwd());
121
121
  const configRef = configRefFor(worldRoot, name);
122
122
  if (loadWorldConfig(configRef, worldRoot).config.resources) {
@@ -149,6 +149,7 @@ export async function mountWorld(name: string, opts: { /** the platforms whose p
149
149
  ...(opts.branches?.(served) ? { branches: opts.branches(served)! } : {}),
150
150
  ...(opts.browserOrigin ? { browserOrigin: () => opts.browserOrigin!(served), originOf: (other: string) => opts.browserOrigin!(other) } : {}),
151
151
  ...(opts.localTrust ? { localTrust: () => true } : {}),
152
+ ...(opts.serveBranch ? { serveBranch: opts.serveBranch } : {}),
152
153
  ...(opts.passIssuers?.length ? { passIssuers: () => opts.passIssuers! } : {}),
153
154
  // a local twin is its own process: the wire forwards to the port it listens on
154
155
  twinFetch: (vendor) => {
@@ -304,6 +305,9 @@ export type DoorHost = {
304
305
  /** This World's branches, where the host can make them (a branch is another World, so making one
305
306
  * is the host's act); absent, the branches doors answer 404. */
306
307
  branches?: BranchDoors;
308
+ /** Serve another of the World's own named branches in this one's place (`volter world view` does; a host that
309
+ * serves one branch leaves it absent): this branch's twins stop, that one's start, under the same served name. */
310
+ serveBranch?: (branch: string) => Promise<void>;
307
311
  };
308
312
 
309
313
  /** A branch as its parent lists it. */
@@ -1232,6 +1236,17 @@ export class WorldDoors {
1232
1236
  try { named = listWorlds(this.worldRoot).map((w) => ({ name: w.name, running: w.running })); } catch { /* a World with no local branches */ }
1233
1237
  return Response.json({ world: this.served, branches: await branches.list(), checkedOut: this.name, named });
1234
1238
  }
1239
+ // SERVE ANOTHER NAMED BRANCH here (`POST branches/<branch>/serve`): only who may write the World switches what it serves
1240
+ if (rest.length === 2 && rest[1] === 'serve' && request.method === 'POST') {
1241
+ if (!this.host.serveBranch) return Response.json({ error: `this host serves ${this.served} on one branch` }, { status: 404 });
1242
+ if (this.credential(request).scope !== 'write') return Response.json({ error: 'serving another branch needs the World\'s write access' }, { status: 403 });
1243
+ const target = decodeURIComponent(rest[0]!);
1244
+ let known = false;
1245
+ try { known = listWorlds(this.worldRoot).some((w) => w.name === target); } catch { /* no local branches */ }
1246
+ if (!known) return Response.json({ error: `no branch ${target} in this World` }, { status: 404 });
1247
+ try { await this.host.serveBranch(target); } catch (error) { return Response.json({ error: error instanceof Error ? error.message : String(error) }, { status: 409 }); }
1248
+ return Response.json({ world: this.served, checkedOut: target });
1249
+ }
1235
1250
  if (rest.length === 0 && request.method === 'POST') {
1236
1251
  const body = (await request.json().catch(() => null)) as { at?: { instant?: unknown }; ttl?: unknown; live?: unknown; label?: unknown } | null;
1237
1252
  const instant = body?.at?.instant;
package/src/world-view.ts CHANGED
@@ -2,7 +2,7 @@
2
2
  // ("as of" views) and the console, on one origin (docs/contributing/architecture.md, "Viewing a
3
3
  // World"). `volter world view` is this; the runtime's `up` gains no step. A host that serves many
4
4
  // Worlds (world-host) answers the same doors; this serves one, and the branches made of it.
5
- import { existsSync, readdirSync } from 'node:fs';
5
+ import { existsSync, readdirSync, rmSync, writeFileSync } from 'node:fs';
6
6
  import { loadWorldConfig } from './configs.ts';
7
7
  import { join, resolve } from 'node:path';
8
8
  import { serveHttp, stateDirName } from '@volter/world-core';
@@ -44,7 +44,35 @@ export async function serveWorldView(name: string, opts: { root?: string; port?:
44
44
  const browserOrigin = origins ? (served: string) => (port ? localWorldOrigin(served, port) : null) : undefined;
45
45
  const mountAt = (at: string): Promise<MountedWorld> => mountWorld(loadWorldConfig(join(at, stateDirName(), 'world.json'), at).config.id, { root: at, branches: (served) => branches.doorsFor(served), ...(browserOrigin ? { browserOrigin } : {}), ...(local ? { localTrust: true } : {}) });
46
46
  const branches: LocalBranches = new LocalBranches({ dir: join(root, stateDirName(), 'branches'), origin: () => loopback, worlds, mount: mountAt, ...(opts.announce ? { announce: opts.announce } : {}) });
47
- const world = await mountWorld(name, { root, branches: (served) => branches.doorsFor(served), ...(browserOrigin ? { browserOrigin } : {}), ...(local ? { localTrust: true } : {}) });
47
+ // THE WORLD'S OWN BRANCHES, one served at a time: serving another stops this one's twins and starts that one's under the
48
+ // same served name (its tokens and sessions are the World's, so the page stays open); `.volter/current` follows, as
49
+ // \`volter world checkout\` sets it. While it switches, the World's doors answer 503.
50
+ let url = '';
51
+ let switching = false;
52
+ const serveBranch = async (branch: string): Promise<void> => {
53
+ if (switching) throw new Error('already switching branches');
54
+ if (branch === world.name) return;
55
+ switching = true;
56
+ const from = world.name;
57
+ try {
58
+ await world.stop();
59
+ let next: MountedWorld;
60
+ try {
61
+ next = await mountWorld(branch, mountOpts);
62
+ await next.boot(url);
63
+ } catch (error) {
64
+ const back = await mountWorld(from, mountOpts);
65
+ await back.boot(url);
66
+ worlds.set(back.served, back); world = back;
67
+ throw error;
68
+ }
69
+ worlds.set(next.served, next); world = next;
70
+ const current = join(root, stateDirName(), 'current');
71
+ if (branch === loadWorldConfig(join(root, stateDirName(), 'world.json'), root).config.id) rmSync(current, { force: true }); else writeFileSync(current, `${branch}\n`);
72
+ } finally { switching = false; }
73
+ };
74
+ const mountOpts = { root, branches: (served: string) => branches.doorsFor(served), ...(browserOrigin ? { browserOrigin } : {}), ...(local ? { localTrust: true } : {}), serveBranch };
75
+ let world = await mountWorld(name, mountOpts);
48
76
  worlds.set(world.served, world);
49
77
  const server = await serveHttp({
50
78
  // a pushed blob (a release video) is one PUT: bodies up to 1 GiB, as `serve` takes them
@@ -69,13 +97,14 @@ export async function serveWorldView(name: string, opts: { root?: string; port?:
69
97
  if (answered) return answered;
70
98
  }
71
99
  const m = /^\/(?:-\/)?([^/]+)\/([^/]+)/.exec(path);
100
+ if (switching && m && `${m[1]}/${m[2]}` === world.served) return Response.json({ error: `${world.served} is switching branches` }, { status: 503, headers: { 'retry-after': '2' } });
72
101
  const target = m ? worlds.get(`${m[1]}/${m[2]}`) : undefined;
73
102
  if (!target) return Response.json({ error: `no world at ${path}: this view serves ${[...worlds.keys()].join(', ')}` }, { status: 404 });
74
103
  return target.handle(request);
75
104
  },
76
105
  });
77
106
  port = server.port ?? 0;
78
- const url = `http://${host}:${server.port}`;
107
+ url = `http://${host}:${server.port}`;
79
108
  loopback = `http://${host === '0.0.0.0' || host === '::' ? '127.0.0.1' : host}:${server.port}`;
80
109
  // a branch outlives the view that made it until its time runs out: stopping stops its compute only
81
110
  const stop = async (): Promise<void> => {