@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.
- package/dist/src/branch.js +8 -4
- package/dist/src/redirect-proxy.js +6 -2
- package/dist/src/served-world.d.ts +5 -1
- package/dist/src/served-world.js +50 -6
- package/dist/src/world-view.js +55 -3
- package/package.json +5 -5
- package/src/branch.ts +6 -3
- package/src/redirect-proxy.ts +5 -1
- package/src/served-world.ts +33 -7
- package/src/world-view.ts +37 -3
package/dist/src/branch.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
|
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) => {
|
|
@@ -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
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
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));
|
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,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
|
-
|
|
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
|
-
|
|
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.
|
|
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.
|
|
71
|
-
"@volter/world-console": "2.0.
|
|
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.
|
|
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.
|
|
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
|
-
|
|
59
|
-
|
|
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
|
}
|
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) => {
|
|
@@ -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
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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> => {
|