@volter/world-runtime 2.0.13 → 2.0.15

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.
@@ -55,22 +55,26 @@ export async function branchWorld(base, name, options = {}) {
55
55
  catch {
56
56
  return false;
57
57
  } })();
58
+ const began = Date.now();
58
59
  let instance;
59
60
  try {
60
61
  instance = await upWorld(existsSync(from.configPath) ? from.configPath : from.config, { name, root, mode: from.mode, envFile });
61
62
  }
62
63
  catch (error) {
63
- let recorded = false;
64
+ // only a record made during this call is ours to remove, and it is removed by its own createdAt, as the fork's
65
+ // cleanup below does: a concurrent `branch` of the same name that recorded first keeps its World
66
+ let recorded = null;
64
67
  if (!existed) {
65
68
  try {
66
- statusWorld(name, root);
67
- recorded = true;
69
+ const made = statusWorld(name, root).createdAt;
70
+ if (Date.parse(made) >= began)
71
+ recorded = made;
68
72
  }
69
73
  catch { /* the boot recorded nothing */ }
70
74
  }
71
75
  if (recorded) {
72
76
  try {
73
- await downWorld(name, root, { purge: true });
77
+ await downWorld(name, root, { purge: true, expectedCreatedAt: recorded });
74
78
  }
75
79
  catch (cleanup) {
76
80
  throw new Error(`${error instanceof Error ? error.message : String(error)} (and the half-made branch ${name} could not be removed: ${cleanup instanceof Error ? cleanup.message : String(cleanup)})`);
@@ -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) => {
@@ -165,11 +166,32 @@ export async function mountWorld(name, opts = {}) {
165
166
  keep();
166
167
  const port = Number(new URL(url).port) || 0;
167
168
  writeFileSync(record, `${JSON.stringify({ name: served, url, base: `${url}/${served}`, port, pid: process.pid, startedAt: new Date().toISOString() }, null, 2)}\n`);
168
- const booted = await upWorld(configRef, { name, root: worldRoot, keepState: true });
169
- instance = booted;
170
- // a root set in the config while the world was stopped reaches the twins' state now, where the deploy reads it
171
- materializeRoots(loadWorldConfig(configRef, worldRoot).config, booted.dirs.data, worldRoot);
172
- timers = await scheduleRefreshes(name, worldRoot, booted);
169
+ // a boot that fails leaves nothing claimed: no serve record naming this process (a later mount of this World would
170
+ // refuse as "already served"), and no twins it started; twins another owner runs were never ours to stop
171
+ let booted = null;
172
+ try {
173
+ booted = await upWorld(configRef, { name, root: worldRoot, keepState: true });
174
+ instance = booted;
175
+ // a root set in the config while the world was stopped reaches the twins' state now, where the deploy reads it
176
+ materializeRoots(loadWorldConfig(configRef, worldRoot).config, booted.dirs.data, worldRoot);
177
+ timers = await scheduleRefreshes(name, worldRoot, booted);
178
+ }
179
+ catch (error) {
180
+ for (const t of timers)
181
+ clearInterval(t);
182
+ timers = [];
183
+ if (booted) {
184
+ try {
185
+ await downWorld(name, worldRoot);
186
+ }
187
+ catch { /* the boot's own error is the one reported */ }
188
+ }
189
+ try {
190
+ rmSync(record, { force: true });
191
+ }
192
+ catch { /* already gone */ }
193
+ throw error;
194
+ }
173
195
  },
174
196
  async stop() {
175
197
  for (const t of timers)
@@ -1437,7 +1459,29 @@ export class WorldDoors {
1437
1459
  named = listWorlds(this.worldRoot).map((w) => ({ name: w.name, running: w.running }));
1438
1460
  }
1439
1461
  catch { /* a World with no local branches */ }
1440
- return Response.json({ world: this.served, branches: await branches.list(), checkedOut: this.name, named });
1462
+ return Response.json({ world: this.served, branches: await branches.list(), checkedOut: this.name, named, canServe: this.host.serveBranch !== undefined });
1463
+ }
1464
+ // SERVE ANOTHER NAMED BRANCH here (`POST branches/<branch>/serve`): only who may write the World switches what it serves
1465
+ if (rest.length === 2 && rest[1] === 'serve' && request.method === 'POST') {
1466
+ if (!this.host.serveBranch)
1467
+ return Response.json({ error: `this host serves ${this.served} on one branch` }, { status: 404 });
1468
+ if (this.credential(request).scope !== 'write')
1469
+ return Response.json({ error: 'serving another branch needs the World\'s write access' }, { status: 403 });
1470
+ const target = decodeURIComponent(rest[0]);
1471
+ let known = false;
1472
+ try {
1473
+ known = listWorlds(this.worldRoot).some((w) => w.name === target);
1474
+ }
1475
+ catch { /* no local branches */ }
1476
+ if (!known)
1477
+ return Response.json({ error: `no branch ${target} in this World` }, { status: 404 });
1478
+ try {
1479
+ await this.host.serveBranch(target);
1480
+ }
1481
+ catch (error) {
1482
+ return Response.json({ error: error instanceof Error ? error.message : String(error) }, { status: 409 });
1483
+ }
1484
+ return Response.json({ world: this.served, checkedOut: target });
1441
1485
  }
1442
1486
  if (rest.length === 0 && request.method === 'POST') {
1443
1487
  const body = (await request.json().catch(() => null));
@@ -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,57 @@ 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
+ // a stop that fails part way brings this branch back as it was before the error is reported
59
+ try {
60
+ await world.stop();
61
+ }
62
+ catch (error) {
63
+ try {
64
+ await world.boot(url);
65
+ }
66
+ catch { /* the stop's own error is the one reported */ }
67
+ throw error;
68
+ }
69
+ let next;
70
+ try {
71
+ next = await mountWorld(branch, mountOpts);
72
+ await next.boot(url);
73
+ }
74
+ catch (error) {
75
+ // the target's failed boot released its claim (served-world.ts boot), so the branch it replaced mounts again
76
+ const back = await mountWorld(from, mountOpts);
77
+ await back.boot(url);
78
+ worlds.set(back.served, back);
79
+ world = back;
80
+ throw error;
81
+ }
82
+ worlds.set(next.served, next);
83
+ world = next;
84
+ const current = join(root, stateDirName(), 'current');
85
+ if (branch === loadWorldConfig(join(root, stateDirName(), 'world.json'), root).config.id)
86
+ rmSync(current, { force: true });
87
+ else
88
+ writeFileSync(current, `${branch}\n`);
89
+ }
90
+ finally {
91
+ switching = false;
92
+ }
93
+ };
94
+ const mountOpts = { root, branches: (served) => branches.doorsFor(served), ...(browserOrigin ? { browserOrigin } : {}), ...(local ? { localTrust: true } : {}), serveBranch };
95
+ let world = await mountWorld(name, mountOpts);
46
96
  worlds.set(world.served, world);
47
97
  const server = await serveHttp({
48
98
  // a pushed blob (a release video) is one PUT: bodies up to 1 GiB, as `serve` takes them
@@ -73,6 +123,8 @@ export async function serveWorldView(name, opts = {}) {
73
123
  return answered;
74
124
  }
75
125
  const m = /^\/(?:-\/)?([^/]+)\/([^/]+)/.exec(path);
126
+ if (switching && m && `${m[1]}/${m[2]}` === world.served)
127
+ return Response.json({ error: `${world.served} is switching branches` }, { status: 503, headers: { 'retry-after': '2' } });
76
128
  const target = m ? worlds.get(`${m[1]}/${m[2]}`) : undefined;
77
129
  if (!target)
78
130
  return Response.json({ error: `no world at ${path}: this view serves ${[...worlds.keys()].join(', ')}` }, { status: 404 });
@@ -80,7 +132,7 @@ export async function serveWorldView(name, opts = {}) {
80
132
  },
81
133
  });
82
134
  port = server.port ?? 0;
83
- const url = `http://${host}:${server.port}`;
135
+ url = `http://${host}:${server.port}`;
84
136
  loopback = `http://${host === '0.0.0.0' || host === '::' ? '127.0.0.1' : host}:${server.port}`;
85
137
  // a branch outlives the view that made it until its time runs out: stopping stops its compute only
86
138
  const stop = async () => {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@volter/world-runtime",
3
- "version": "2.0.13",
3
+ "version": "2.0.15",
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.8",
71
- "@volter/world-console": "2.0.7"
70
+ "@volter/world-core": "2.0.9",
71
+ "@volter/world-console": "2.0.9"
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.8",
77
+ "@volter/world-core": "2.0.9",
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.8"
87
+ "@volter/twin-mongodb": "0.1.9"
88
88
  }
89
89
  }
package/src/branch.ts CHANGED
@@ -51,14 +51,17 @@ export async function branchWorld(base: string, name: string, options: BranchOpt
51
51
  // a branch that fails to boot (a twin whose mounted version breaks the config's pin) leaves no half-made branch behind:
52
52
  // its record without a fork would check out later as an empty World
53
53
  const existed = (() => { try { statusWorld(name, root); return true; } catch { return false; } })();
54
+ const began = Date.now();
54
55
  let instance: WorldInstance;
55
56
  try {
56
57
  instance = await upWorld(existsSync(from.configPath) ? from.configPath : from.config, { name, root, mode: from.mode, envFile });
57
58
  } catch (error) {
58
- let recorded = false;
59
- if (!existed) { try { statusWorld(name, root); recorded = true; } catch { /* the boot recorded nothing */ } }
59
+ // only a record made during this call is ours to remove, and it is removed by its own createdAt, as the fork's
60
+ // cleanup below does: a concurrent `branch` of the same name that recorded first keeps its World
61
+ let recorded: string | null = null;
62
+ if (!existed) { try { const made = statusWorld(name, root).createdAt; if (Date.parse(made) >= began) recorded = made; } catch { /* the boot recorded nothing */ } }
60
63
  if (recorded) {
61
- try { await downWorld(name, root, { purge: true }); } catch (cleanup) {
64
+ try { await downWorld(name, root, { purge: true, expectedCreatedAt: recorded }); } catch (cleanup) {
62
65
  throw new Error(`${error instanceof Error ? error.message : String(error)} (and the half-made branch ${name} could not be removed: ${cleanup instanceof Error ? cleanup.message : String(cleanup)})`);
63
66
  }
64
67
  }
@@ -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) => {
@@ -170,11 +171,22 @@ export async function mountWorld(name: string, opts: { /** the platforms whose p
170
171
  keep();
171
172
  const port = Number(new URL(url).port) || 0;
172
173
  writeFileSync(record, `${JSON.stringify({ name: served, url, base: `${url}/${served}`, port, pid: process.pid, startedAt: new Date().toISOString() }, null, 2)}\n`);
173
- const booted = await upWorld(configRef, { name, root: worldRoot, keepState: true });
174
- instance = booted;
175
- // a root set in the config while the world was stopped reaches the twins' state now, where the deploy reads it
176
- materializeRoots(loadWorldConfig(configRef, worldRoot).config, booted.dirs.data, worldRoot);
177
- timers = await scheduleRefreshes(name, worldRoot, booted);
174
+ // a boot that fails leaves nothing claimed: no serve record naming this process (a later mount of this World would
175
+ // refuse as "already served"), and no twins it started; twins another owner runs were never ours to stop
176
+ let booted: WorldInstance | null = null;
177
+ try {
178
+ booted = await upWorld(configRef, { name, root: worldRoot, keepState: true });
179
+ instance = booted;
180
+ // a root set in the config while the world was stopped reaches the twins' state now, where the deploy reads it
181
+ materializeRoots(loadWorldConfig(configRef, worldRoot).config, booted.dirs.data, worldRoot);
182
+ timers = await scheduleRefreshes(name, worldRoot, booted);
183
+ } catch (error) {
184
+ for (const t of timers) clearInterval(t);
185
+ timers = [];
186
+ if (booted) { try { await downWorld(name, worldRoot); } catch { /* the boot's own error is the one reported */ } }
187
+ try { rmSync(record, { force: true }); } catch { /* already gone */ }
188
+ throw error;
189
+ }
178
190
  },
179
191
  async stop() {
180
192
  for (const t of timers) clearInterval(t);
@@ -304,6 +316,9 @@ export type DoorHost = {
304
316
  /** This World's branches, where the host can make them (a branch is another World, so making one
305
317
  * is the host's act); absent, the branches doors answer 404. */
306
318
  branches?: BranchDoors;
319
+ /** Serve another of the World's own named branches in this one's place (`volter world view` does; a host that
320
+ * serves one branch leaves it absent): this branch's twins stop, that one's start, under the same served name. */
321
+ serveBranch?: (branch: string) => Promise<void>;
307
322
  };
308
323
 
309
324
  /** A branch as its parent lists it. */
@@ -1230,7 +1245,18 @@ export class WorldDoors {
1230
1245
  // `checkedOut` is the one this serve holds, so a page never names the World for the branch it shows
1231
1246
  let named: Array<{ name: string; running: boolean }> = [];
1232
1247
  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 });
1248
+ return Response.json({ world: this.served, branches: await branches.list(), checkedOut: this.name, named, canServe: this.host.serveBranch !== undefined });
1249
+ }
1250
+ // SERVE ANOTHER NAMED BRANCH here (`POST branches/<branch>/serve`): only who may write the World switches what it serves
1251
+ if (rest.length === 2 && rest[1] === 'serve' && request.method === 'POST') {
1252
+ if (!this.host.serveBranch) return Response.json({ error: `this host serves ${this.served} on one branch` }, { status: 404 });
1253
+ if (this.credential(request).scope !== 'write') return Response.json({ error: 'serving another branch needs the World\'s write access' }, { status: 403 });
1254
+ const target = decodeURIComponent(rest[0]!);
1255
+ let known = false;
1256
+ try { known = listWorlds(this.worldRoot).some((w) => w.name === target); } catch { /* no local branches */ }
1257
+ if (!known) return Response.json({ error: `no branch ${target} in this World` }, { status: 404 });
1258
+ try { await this.host.serveBranch(target); } catch (error) { return Response.json({ error: error instanceof Error ? error.message : String(error) }, { status: 409 }); }
1259
+ return Response.json({ world: this.served, checkedOut: target });
1234
1260
  }
1235
1261
  if (rest.length === 0 && request.method === 'POST') {
1236
1262
  const body = (await request.json().catch(() => null)) as { at?: { instant?: unknown }; ttl?: unknown; live?: unknown; label?: unknown } | null;
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,40 @@ 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
+ // a stop that fails part way brings this branch back as it was before the error is reported
59
+ try { await world.stop(); } catch (error) {
60
+ try { await world.boot(url); } catch { /* the stop's own error is the one reported */ }
61
+ throw error;
62
+ }
63
+ let next: MountedWorld;
64
+ try {
65
+ next = await mountWorld(branch, mountOpts);
66
+ await next.boot(url);
67
+ } catch (error) {
68
+ // the target's failed boot released its claim (served-world.ts boot), so the branch it replaced mounts again
69
+ const back = await mountWorld(from, mountOpts);
70
+ await back.boot(url);
71
+ worlds.set(back.served, back); world = back;
72
+ throw error;
73
+ }
74
+ worlds.set(next.served, next); world = next;
75
+ const current = join(root, stateDirName(), 'current');
76
+ if (branch === loadWorldConfig(join(root, stateDirName(), 'world.json'), root).config.id) rmSync(current, { force: true }); else writeFileSync(current, `${branch}\n`);
77
+ } finally { switching = false; }
78
+ };
79
+ const mountOpts = { root, branches: (served: string) => branches.doorsFor(served), ...(browserOrigin ? { browserOrigin } : {}), ...(local ? { localTrust: true } : {}), serveBranch };
80
+ let world = await mountWorld(name, mountOpts);
48
81
  worlds.set(world.served, world);
49
82
  const server = await serveHttp({
50
83
  // a pushed blob (a release video) is one PUT: bodies up to 1 GiB, as `serve` takes them
@@ -69,13 +102,14 @@ export async function serveWorldView(name: string, opts: { root?: string; port?:
69
102
  if (answered) return answered;
70
103
  }
71
104
  const m = /^\/(?:-\/)?([^/]+)\/([^/]+)/.exec(path);
105
+ 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
106
  const target = m ? worlds.get(`${m[1]}/${m[2]}`) : undefined;
73
107
  if (!target) return Response.json({ error: `no world at ${path}: this view serves ${[...worlds.keys()].join(', ')}` }, { status: 404 });
74
108
  return target.handle(request);
75
109
  },
76
110
  });
77
111
  port = server.port ?? 0;
78
- const url = `http://${host}:${server.port}`;
112
+ url = `http://${host}:${server.port}`;
79
113
  loopback = `http://${host === '0.0.0.0' || host === '::' ? '127.0.0.1' : host}:${server.port}`;
80
114
  // a branch outlives the view that made it until its time runs out: stopping stops its compute only
81
115
  const stop = async (): Promise<void> => {