@volter/world-runtime 2.0.13 → 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.
@@ -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.13",
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",
@@ -68,7 +68,7 @@
68
68
  },
69
69
  "peerDependencies": {
70
70
  "@volter/world-core": "2.0.8",
71
- "@volter/world-console": "2.0.7"
71
+ "@volter/world-console": "2.0.8"
72
72
  },
73
73
  "dependencies": {
74
74
  "@electric-sql/pglite": "0.5.8",
@@ -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> => {