@volter/world-runtime 2.0.14 → 2.0.16
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/served-world.js +60 -16
- package/dist/src/world-view.js +2 -0
- package/package.json +5 -5
- package/src/branch.ts +6 -3
- package/src/served-world.ts +40 -12
- package/src/world-view.ts +2 -0
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)})`);
|
package/dist/src/served-world.js
CHANGED
|
@@ -156,7 +156,8 @@ export async function mountWorld(name, opts = {}) {
|
|
|
156
156
|
const world = new WorldDoors(name, worldRoot, configRef, served, host, held('token') ?? mintToken('tok_'), held('token.read') ?? mintToken('tok_r_'));
|
|
157
157
|
const keep = () => { writeSecret(join(worldRoot, stateDirName(), 'token'), world.token); writeSecret(join(worldRoot, stateDirName(), 'token.read'), world.readToken); };
|
|
158
158
|
let timers = [];
|
|
159
|
-
|
|
159
|
+
let stopping = null;
|
|
160
|
+
const mounted = {
|
|
160
161
|
name, served, root: worldRoot,
|
|
161
162
|
get token() { return world.token; }, get readToken() { return world.readToken; },
|
|
162
163
|
twins: () => world.twins(),
|
|
@@ -166,24 +167,67 @@ export async function mountWorld(name, opts = {}) {
|
|
|
166
167
|
keep();
|
|
167
168
|
const port = Number(new URL(url).port) || 0;
|
|
168
169
|
writeFileSync(record, `${JSON.stringify({ name: served, url, base: `${url}/${served}`, port, pid: process.pid, startedAt: new Date().toISOString() }, null, 2)}\n`);
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
materializeRoots(loadWorldConfig(configRef, worldRoot).config, booted.dirs.data, worldRoot);
|
|
173
|
-
timers = await scheduleRefreshes(name, worldRoot, booted);
|
|
174
|
-
},
|
|
175
|
-
async stop() {
|
|
176
|
-
for (const t of timers)
|
|
177
|
-
clearInterval(t);
|
|
170
|
+
// a boot that fails leaves nothing claimed: no serve record naming this process (a later mount of this World would
|
|
171
|
+
// refuse as "already served"), and no twins it started; twins another owner runs were never ours to stop
|
|
172
|
+
let booted = null;
|
|
178
173
|
try {
|
|
179
|
-
|
|
174
|
+
booted = await upWorld(configRef, { name, root: worldRoot, keepState: true });
|
|
175
|
+
instance = booted;
|
|
176
|
+
// a root set in the config while the world was stopped reaches the twins' state now, where the deploy reads it
|
|
177
|
+
materializeRoots(loadWorldConfig(configRef, worldRoot).config, booted.dirs.data, worldRoot);
|
|
178
|
+
timers = await scheduleRefreshes(name, worldRoot, booted);
|
|
180
179
|
}
|
|
181
|
-
catch {
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
180
|
+
catch (error) {
|
|
181
|
+
for (const t of timers)
|
|
182
|
+
clearInterval(t);
|
|
183
|
+
timers = [];
|
|
184
|
+
if (booted) {
|
|
185
|
+
try {
|
|
186
|
+
await downWorld(name, worldRoot);
|
|
187
|
+
}
|
|
188
|
+
catch { /* the boot's own error is the one reported */ }
|
|
189
|
+
}
|
|
190
|
+
try {
|
|
191
|
+
rmSync(record, { force: true });
|
|
192
|
+
}
|
|
193
|
+
catch { /* already gone */ }
|
|
194
|
+
throw error;
|
|
195
|
+
}
|
|
196
|
+
},
|
|
197
|
+
stop() {
|
|
198
|
+
// one stop at a time: a second caller (the view shutting down while a switch stops this World) shares the first's
|
|
199
|
+
stopping ??= stopOnce().finally(() => { stopping = null; });
|
|
200
|
+
return stopping;
|
|
185
201
|
},
|
|
186
202
|
};
|
|
203
|
+
async function stopOnce() {
|
|
204
|
+
// refreshes stop first, so none writes into the World while its twins stop; the serve record goes only once they are
|
|
205
|
+
// down. A stop that fails keeps the record (a checkout under the serve still refuses) and re-arms the refreshes:
|
|
206
|
+
// refused before anything stopped, the World is served as it was; failed part way, its record names what remains
|
|
207
|
+
for (const t of timers)
|
|
208
|
+
clearInterval(t);
|
|
209
|
+
timers = [];
|
|
210
|
+
let result;
|
|
211
|
+
try {
|
|
212
|
+
result = await downWorld(name, worldRoot);
|
|
213
|
+
if (result.externalErrors.length)
|
|
214
|
+
throw new Error(`World "${name}" shutdown incomplete; instance and reservation retained: ${result.externalErrors.join('; ')}`);
|
|
215
|
+
}
|
|
216
|
+
catch (error) {
|
|
217
|
+
if (instance) {
|
|
218
|
+
try {
|
|
219
|
+
timers = await scheduleRefreshes(name, worldRoot, instance);
|
|
220
|
+
}
|
|
221
|
+
catch { /* the stop's error is the one reported */ }
|
|
222
|
+
}
|
|
223
|
+
throw error;
|
|
224
|
+
}
|
|
225
|
+
try {
|
|
226
|
+
rmSync(record, { force: true });
|
|
227
|
+
}
|
|
228
|
+
catch { /* already gone */ }
|
|
229
|
+
}
|
|
230
|
+
return mounted;
|
|
187
231
|
}
|
|
188
232
|
/** A local World's mirrors: each pack's builders, found once per process, and each client built once. */
|
|
189
233
|
const localMirrors = new Map();
|
|
@@ -1438,7 +1482,7 @@ export class WorldDoors {
|
|
|
1438
1482
|
named = listWorlds(this.worldRoot).map((w) => ({ name: w.name, running: w.running }));
|
|
1439
1483
|
}
|
|
1440
1484
|
catch { /* a World with no local branches */ }
|
|
1441
|
-
return Response.json({ world: this.served, branches: await branches.list(), checkedOut: this.name, named });
|
|
1485
|
+
return Response.json({ world: this.served, branches: await branches.list(), checkedOut: this.name, named, canServe: this.host.serveBranch !== undefined });
|
|
1442
1486
|
}
|
|
1443
1487
|
// SERVE ANOTHER NAMED BRANCH here (`POST branches/<branch>/serve`): only who may write the World switches what it serves
|
|
1444
1488
|
if (rest.length === 2 && rest[1] === 'serve' && request.method === 'POST') {
|
package/dist/src/world-view.js
CHANGED
|
@@ -55,6 +55,7 @@ export async function serveWorldView(name, opts = {}) {
|
|
|
55
55
|
switching = true;
|
|
56
56
|
const from = world.name;
|
|
57
57
|
try {
|
|
58
|
+
// a stop that is refused or fails leaves this branch served as it was (served-world.ts stop): the switch just fails
|
|
58
59
|
await world.stop();
|
|
59
60
|
let next;
|
|
60
61
|
try {
|
|
@@ -62,6 +63,7 @@ export async function serveWorldView(name, opts = {}) {
|
|
|
62
63
|
await next.boot(url);
|
|
63
64
|
}
|
|
64
65
|
catch (error) {
|
|
66
|
+
// the target's failed boot released its claim (served-world.ts boot), so the branch it replaced mounts again
|
|
65
67
|
const back = await mountWorld(from, mountOpts);
|
|
66
68
|
await back.boot(url);
|
|
67
69
|
worlds.set(back.served, back);
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@volter/world-runtime",
|
|
3
|
-
"version": "2.0.
|
|
3
|
+
"version": "2.0.16",
|
|
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.10",
|
|
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.10",
|
|
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.10"
|
|
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/served-world.ts
CHANGED
|
@@ -161,7 +161,8 @@ export async function mountWorld(name: string, opts: { /** the platforms whose p
|
|
|
161
161
|
const world = new WorldDoors(name, worldRoot, configRef, served, host, held('token') ?? mintToken('tok_'), held('token.read') ?? mintToken('tok_r_'));
|
|
162
162
|
const keep = (): void => { writeSecret(join(worldRoot, stateDirName(), 'token'), world.token); writeSecret(join(worldRoot, stateDirName(), 'token.read'), world.readToken); };
|
|
163
163
|
let timers: ReturnType<typeof setInterval>[] = [];
|
|
164
|
-
|
|
164
|
+
let stopping: Promise<void> | null = null;
|
|
165
|
+
const mounted: MountedWorld = {
|
|
165
166
|
name, served, root: worldRoot,
|
|
166
167
|
get token() { return world.token; }, get readToken() { return world.readToken; },
|
|
167
168
|
twins: () => world.twins(),
|
|
@@ -171,19 +172,46 @@ export async function mountWorld(name: string, opts: { /** the platforms whose p
|
|
|
171
172
|
keep();
|
|
172
173
|
const port = Number(new URL(url).port) || 0;
|
|
173
174
|
writeFileSync(record, `${JSON.stringify({ name: served, url, base: `${url}/${served}`, port, pid: process.pid, startedAt: new Date().toISOString() }, null, 2)}\n`);
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
175
|
+
// a boot that fails leaves nothing claimed: no serve record naming this process (a later mount of this World would
|
|
176
|
+
// refuse as "already served"), and no twins it started; twins another owner runs were never ours to stop
|
|
177
|
+
let booted: WorldInstance | null = null;
|
|
178
|
+
try {
|
|
179
|
+
booted = await upWorld(configRef, { name, root: worldRoot, keepState: true });
|
|
180
|
+
instance = booted;
|
|
181
|
+
// a root set in the config while the world was stopped reaches the twins' state now, where the deploy reads it
|
|
182
|
+
materializeRoots(loadWorldConfig(configRef, worldRoot).config, booted.dirs.data, worldRoot);
|
|
183
|
+
timers = await scheduleRefreshes(name, worldRoot, booted);
|
|
184
|
+
} catch (error) {
|
|
185
|
+
for (const t of timers) clearInterval(t);
|
|
186
|
+
timers = [];
|
|
187
|
+
if (booted) { try { await downWorld(name, worldRoot); } catch { /* the boot's own error is the one reported */ } }
|
|
188
|
+
try { rmSync(record, { force: true }); } catch { /* already gone */ }
|
|
189
|
+
throw error;
|
|
190
|
+
}
|
|
179
191
|
},
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
if (result.externalErrors.length) throw new Error(`World "${name}" shutdown incomplete; instance and reservation retained: ${result.externalErrors.join('; ')}`);
|
|
192
|
+
stop() {
|
|
193
|
+
// one stop at a time: a second caller (the view shutting down while a switch stops this World) shares the first's
|
|
194
|
+
stopping ??= stopOnce().finally(() => { stopping = null; });
|
|
195
|
+
return stopping;
|
|
185
196
|
},
|
|
186
197
|
};
|
|
198
|
+
async function stopOnce(): Promise<void> {
|
|
199
|
+
// refreshes stop first, so none writes into the World while its twins stop; the serve record goes only once they are
|
|
200
|
+
// down. A stop that fails keeps the record (a checkout under the serve still refuses) and re-arms the refreshes:
|
|
201
|
+
// refused before anything stopped, the World is served as it was; failed part way, its record names what remains
|
|
202
|
+
for (const t of timers) clearInterval(t);
|
|
203
|
+
timers = [];
|
|
204
|
+
let result: Awaited<ReturnType<typeof downWorld>>;
|
|
205
|
+
try {
|
|
206
|
+
result = await downWorld(name, worldRoot);
|
|
207
|
+
if (result.externalErrors.length) throw new Error(`World "${name}" shutdown incomplete; instance and reservation retained: ${result.externalErrors.join('; ')}`);
|
|
208
|
+
} catch (error) {
|
|
209
|
+
if (instance) { try { timers = await scheduleRefreshes(name, worldRoot, instance); } catch { /* the stop's error is the one reported */ } }
|
|
210
|
+
throw error;
|
|
211
|
+
}
|
|
212
|
+
try { rmSync(record, { force: true }); } catch { /* already gone */ }
|
|
213
|
+
}
|
|
214
|
+
return mounted;
|
|
187
215
|
}
|
|
188
216
|
|
|
189
217
|
/** A local World's mirrors: each pack's builders, found once per process, and each client built once. */
|
|
@@ -1234,7 +1262,7 @@ export class WorldDoors {
|
|
|
1234
1262
|
// `checkedOut` is the one this serve holds, so a page never names the World for the branch it shows
|
|
1235
1263
|
let named: Array<{ name: string; running: boolean }> = [];
|
|
1236
1264
|
try { named = listWorlds(this.worldRoot).map((w) => ({ name: w.name, running: w.running })); } catch { /* a World with no local branches */ }
|
|
1237
|
-
return Response.json({ world: this.served, branches: await branches.list(), checkedOut: this.name, named });
|
|
1265
|
+
return Response.json({ world: this.served, branches: await branches.list(), checkedOut: this.name, named, canServe: this.host.serveBranch !== undefined });
|
|
1238
1266
|
}
|
|
1239
1267
|
// SERVE ANOTHER NAMED BRANCH here (`POST branches/<branch>/serve`): only who may write the World switches what it serves
|
|
1240
1268
|
if (rest.length === 2 && rest[1] === 'serve' && request.method === 'POST') {
|
package/src/world-view.ts
CHANGED
|
@@ -55,12 +55,14 @@ export async function serveWorldView(name: string, opts: { root?: string; port?:
|
|
|
55
55
|
switching = true;
|
|
56
56
|
const from = world.name;
|
|
57
57
|
try {
|
|
58
|
+
// a stop that is refused or fails leaves this branch served as it was (served-world.ts stop): the switch just fails
|
|
58
59
|
await world.stop();
|
|
59
60
|
let next: MountedWorld;
|
|
60
61
|
try {
|
|
61
62
|
next = await mountWorld(branch, mountOpts);
|
|
62
63
|
await next.boot(url);
|
|
63
64
|
} catch (error) {
|
|
65
|
+
// the target's failed boot released its claim (served-world.ts boot), so the branch it replaced mounts again
|
|
64
66
|
const back = await mountWorld(from, mountOpts);
|
|
65
67
|
await back.boot(url);
|
|
66
68
|
worlds.set(back.served, back); world = back;
|