@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.
- package/dist/src/redirect-proxy.js +6 -2
- package/dist/src/served-world.d.ts +5 -1
- package/dist/src/served-world.js +23 -0
- package/dist/src/world-view.js +44 -3
- package/package.json +2 -2
- package/src/redirect-proxy.ts +5 -1
- package/src/served-world.ts +16 -1
- package/src/world-view.ts +32 -3
|
@@ -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
|
|
355
|
-
|
|
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 = {
|
package/dist/src/served-world.js
CHANGED
|
@@ -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;
|
package/dist/src/world-view.js
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.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
|
-
|
|
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
|
-
|
|
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.
|
|
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.
|
|
71
|
+
"@volter/world-console": "2.0.8"
|
|
72
72
|
},
|
|
73
73
|
"dependencies": {
|
|
74
74
|
"@electric-sql/pglite": "0.5.8",
|
package/src/redirect-proxy.ts
CHANGED
|
@@ -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
|
|
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') {
|
package/src/served-world.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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> => {
|