@volter/world-runtime 3.0.30 → 3.0.32
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 +3 -0
- package/dist/src/browsers.d.ts +29 -0
- package/dist/src/browsers.js +180 -0
- package/dist/src/index.d.ts +1 -0
- package/dist/src/index.js +1 -0
- package/dist/src/init.d.ts +1 -1
- package/dist/src/init.js +36 -4
- package/dist/src/local-branches.js +5 -1
- package/dist/src/pack-facts.d.ts +4 -0
- package/dist/src/pack-facts.js +14 -0
- package/dist/src/placeholder.js +16 -2
- package/dist/src/redirect-proxy.js +3 -3
- package/dist/src/reflect.js +1 -2
- package/dist/src/runtime.js +9 -2
- package/dist/src/served-world.d.ts +12 -9
- package/dist/src/served-world.js +406 -189
- package/dist/src/world-origins.js +1 -1
- package/dist/src/world-view.js +5 -1
- package/package.json +4 -4
- package/src/branch.ts +3 -0
- package/src/browsers.ts +146 -0
- package/src/index.ts +2 -0
- package/src/init.ts +36 -4
- package/src/local-branches.ts +5 -1
- package/src/pack-facts.ts +16 -0
- package/src/placeholder.ts +16 -2
- package/src/redirect-proxy.ts +3 -3
- package/src/reflect.ts +1 -2
- package/src/runtime.ts +7 -2
- package/src/served-world.ts +323 -166
- package/src/world-origins.ts +1 -1
- package/src/world-view.ts +5 -1
|
@@ -69,7 +69,7 @@ export function siteOfHost(hostname, served, base = LOCAL_ORIGIN_BASE) {
|
|
|
69
69
|
if (!label || !rest.endsWith(`.${label}`))
|
|
70
70
|
continue;
|
|
71
71
|
const site = rest.slice(0, -(label.length + 1));
|
|
72
|
-
if (site.includes('.'))
|
|
72
|
+
if (site.includes('.') || site.includes('--'))
|
|
73
73
|
return { world: name, site };
|
|
74
74
|
}
|
|
75
75
|
return undefined;
|
package/dist/src/world-view.js
CHANGED
|
@@ -3,6 +3,8 @@
|
|
|
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
5
|
import { existsSync, readdirSync, rmSync, writeFileSync } from 'node:fs';
|
|
6
|
+
import { materializeBrowserFiles } from "./browsers.js";
|
|
7
|
+
import { statusWorld } from "./runtime.js";
|
|
6
8
|
import { loadWorldConfig } from "./configs.js";
|
|
7
9
|
import { join, resolve } from 'node:path';
|
|
8
10
|
import { serveHttp, stateDirName } from '@volter/world-core/runtime';
|
|
@@ -90,6 +92,8 @@ export async function serveWorldView(name, opts = {}) {
|
|
|
90
92
|
rmSync(current, { force: true });
|
|
91
93
|
else
|
|
92
94
|
writeFileSync(current, `${branch}\n`);
|
|
95
|
+
const selected = statusWorld(branch, root);
|
|
96
|
+
materializeBrowserFiles(selected.dirs.data, root, branch, selected.config);
|
|
93
97
|
}
|
|
94
98
|
finally {
|
|
95
99
|
switching = false;
|
|
@@ -118,7 +122,7 @@ export async function serveWorldView(name, opts = {}) {
|
|
|
118
122
|
// a site the World's twins answer, under the World's local origin (`<hostname>.<world>--<org>.localhost`)
|
|
119
123
|
const site = origins && !proxied ? siteOfHost(at.hostname, worlds.keys()) : undefined;
|
|
120
124
|
if (site)
|
|
121
|
-
return worlds.get(site.world).site(request, site.site);
|
|
125
|
+
return !site.site.includes('.') && site.site.includes('--') ? worlds.get(site.world).handle(request) : worlds.get(site.world).site(request, site.site);
|
|
122
126
|
// a World's content plane serves that World's twins' pages and nothing else of this view's (no console)
|
|
123
127
|
const pagesFor = origins && !proxied ? pagesOfHost(at.hostname, worlds.keys()) : undefined;
|
|
124
128
|
if (pagesFor)
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@volter/world-runtime",
|
|
3
|
-
"version": "3.0.
|
|
3
|
+
"version": "3.0.32",
|
|
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,14 +68,14 @@
|
|
|
68
68
|
"node": ">=22.3"
|
|
69
69
|
},
|
|
70
70
|
"peerDependencies": {
|
|
71
|
-
"@volter/world-core": "3.0.
|
|
72
|
-
"@volter/world-console": "3.0.
|
|
71
|
+
"@volter/world-core": "3.0.32",
|
|
72
|
+
"@volter/world-console": "3.0.27"
|
|
73
73
|
},
|
|
74
74
|
"dependencies": {
|
|
75
75
|
"@electric-sql/pglite": "0.5.8",
|
|
76
76
|
"@electric-sql/pglite-pgvector": "0.0.9",
|
|
77
77
|
"@volter/world-access": "3.0.1",
|
|
78
|
-
"@volter/world-core": "3.0.
|
|
78
|
+
"@volter/world-core": "3.0.32",
|
|
79
79
|
"pg-gateway": "0.3.0-beta.4",
|
|
80
80
|
"smol-toml": "^1.8.0",
|
|
81
81
|
"yaml": "2.9.0"
|
package/src/branch.ts
CHANGED
|
@@ -10,6 +10,7 @@ import { cpSync, existsSync, readdirSync, readFileSync, rmSync } from 'node:fs';
|
|
|
10
10
|
import { dirname, join, resolve } from 'node:path';
|
|
11
11
|
import { fileURLToPath } from 'node:url';
|
|
12
12
|
import { captureHistory, historyAtInstant, forkTwin, stateGeneration, withHistoryLocks, worldPaths, worldStateRoot } from '@volter/world-core/runtime';
|
|
13
|
+
import { inheritWorldBrowsers, materializeBrowserFiles } from './browsers.ts';
|
|
13
14
|
import { downWorld, saveWorldInstance, statusWorld, upWorld } from './runtime.ts';
|
|
14
15
|
import type { WorldInstance } from './schema.ts';
|
|
15
16
|
import { parseInfraDefinition, pgliteDown, pgliteUp } from './pglite-backing.ts';
|
|
@@ -80,6 +81,8 @@ export async function branchWorld(base: string, name: string, options: BranchOpt
|
|
|
80
81
|
}
|
|
81
82
|
if (states.length) forked[service] = states.map(s => s.state);
|
|
82
83
|
}
|
|
84
|
+
inheritWorldBrowsers(from.dirs.data, instance.dirs.data);
|
|
85
|
+
materializeBrowserFiles(instance.dirs.data, root, name, instance.config);
|
|
83
86
|
await branchManagedDatabases(from, instance, databases);
|
|
84
87
|
} catch (error) {
|
|
85
88
|
await downWorld(name, root, { purge: true, expectedCreatedAt: instance.createdAt });
|
package/src/browsers.ts
ADDED
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
import { assertBrowserName, validateBrowserState, type BrowserState } from '@volter/world-core';
|
|
2
|
+
import { getActiveWorldStore, stateDirName, type WorldStore } from '@volter/world-core/runtime';
|
|
3
|
+
import { createHash, randomUUID } from 'node:crypto';
|
|
4
|
+
import { dirname, join } from 'node:path';
|
|
5
|
+
|
|
6
|
+
export type WorldBrowser = { name: string; state: BrowserState; version: string };
|
|
7
|
+
/** Instance bookkeeping, alongside the clock; retained at down, discarded at reset. */
|
|
8
|
+
export const browsersDir = (data: string): string => join(dirname(data), 'browser-records');
|
|
9
|
+
const path = (data: string, name: string): string => { assertBrowserName(name); return join(browsersDir(data), `${name}.json`); };
|
|
10
|
+
const records = new WeakMap<WorldStore, Map<string, { version: string; text: string; record: WorldBrowser }>>();
|
|
11
|
+
export function worldBrowser(data: string, name: string): WorldBrowser | null {
|
|
12
|
+
const store = getActiveWorldStore();
|
|
13
|
+
const file = path(data, name);
|
|
14
|
+
const cache = records.get(store) ?? new Map<string, { version: string; text: string; record: WorldBrowser }>();
|
|
15
|
+
records.set(store, cache);
|
|
16
|
+
const text = store.read(file);
|
|
17
|
+
if (text === null) { cache.delete(file); return null; }
|
|
18
|
+
const held = cache.get(file);
|
|
19
|
+
if (held?.text === text) return held.record;
|
|
20
|
+
const record = JSON.parse(text) as WorldBrowser;
|
|
21
|
+
if (record.name !== name || typeof record.version !== 'string') throw new Error(`invalid browser record: ${name}`);
|
|
22
|
+
if (held?.version === record.version) { held.text = text; return held.record; }
|
|
23
|
+
validateBrowserState(record.state);
|
|
24
|
+
const freeze = (value: unknown): void => {
|
|
25
|
+
if (!value || typeof value !== 'object') return;
|
|
26
|
+
for (const item of Object.values(value)) freeze(item);
|
|
27
|
+
Object.freeze(value);
|
|
28
|
+
};
|
|
29
|
+
freeze(record);
|
|
30
|
+
cache.set(file, { version: record.version, text, record });
|
|
31
|
+
return record;
|
|
32
|
+
}
|
|
33
|
+
export function worldBrowsers(data: string): WorldBrowser[] {
|
|
34
|
+
return getActiveWorldStore().list(browsersDir(data)).filter(f => f.endsWith('.json')).sort().map(f => worldBrowser(data, f.slice(0, -5))!);
|
|
35
|
+
}
|
|
36
|
+
export function putWorldBrowser(data: string, name: string, state: BrowserState): WorldBrowser {
|
|
37
|
+
const record = { name, state: validateBrowserState(state), version: randomUUID() };
|
|
38
|
+
getActiveWorldStore().writeAtomic(path(data, name), `${JSON.stringify(record)}\n`, { secret: true });
|
|
39
|
+
retireBrowserLoads(data, name, record);
|
|
40
|
+
return record;
|
|
41
|
+
}
|
|
42
|
+
export function deleteWorldBrowser(data: string, name: string): void {
|
|
43
|
+
const store = getActiveWorldStore();
|
|
44
|
+
store.remove(path(data, name));
|
|
45
|
+
records.get(store)?.delete(path(data, name));
|
|
46
|
+
retireBrowserLoads(data, name);
|
|
47
|
+
}
|
|
48
|
+
export function inheritWorldBrowsers(fromData: string, toData: string): void {
|
|
49
|
+
const store = getActiveWorldStore();
|
|
50
|
+
for (const b of worldBrowsers(fromData)) store.writeAtomic(path(toData, b.name), `${JSON.stringify(b)}\n`, { secret: true });
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/** Derived files under the World state directory, written only by its current branch. */
|
|
54
|
+
export function materializeBrowserFiles(data: string, root: string, name: string, main = name): void {
|
|
55
|
+
const store = getActiveWorldStore();
|
|
56
|
+
const stateDir = join(root, stateDirName());
|
|
57
|
+
const config = store.read(join(stateDir, 'world.json'));
|
|
58
|
+
if (config) main = (JSON.parse(config) as { id: string }).id;
|
|
59
|
+
if ((store.read(join(stateDir, 'current'))?.trim() || main) !== name) return;
|
|
60
|
+
const rows = worldBrowsers(data);
|
|
61
|
+
const dir = join(stateDir, 'browsers');
|
|
62
|
+
const index = join(dir, '.source.json');
|
|
63
|
+
if (!rows.length && store.read(index) === null) return;
|
|
64
|
+
if (rows.length) {
|
|
65
|
+
const ignore = join(stateDir, '.gitignore');
|
|
66
|
+
const held = store.read(ignore) ?? '';
|
|
67
|
+
if (!held.split('\n').includes('browsers/')) store.write(ignore, `${held.replace(/\n?$/, '\n')}browsers/\n`);
|
|
68
|
+
}
|
|
69
|
+
store.withLock(join(dir, '.lock'), () => {
|
|
70
|
+
let previous: { data: string; names: string[] } | null = null;
|
|
71
|
+
const text = store.read(index);
|
|
72
|
+
if (text) previous = JSON.parse(text) as { data: string; names: string[] };
|
|
73
|
+
for (const name of previous?.names ?? []) { assertBrowserName(name); if (!rows.some(b => b.name === name)) store.remove(join(dir, `${name}.json`)); }
|
|
74
|
+
for (const b of rows) store.writeAtomic(join(dir, `${b.name}.json`), `${JSON.stringify(b.state)}\n`, { secret: true });
|
|
75
|
+
if (rows.length) store.writeAtomic(index, `${JSON.stringify({ data, names: rows.map(b => b.name) })}\n`, { secret: true }); else store.remove(index);
|
|
76
|
+
});
|
|
77
|
+
}
|
|
78
|
+
export function clearBrowserFiles(data: string, root: string): void {
|
|
79
|
+
const store = getActiveWorldStore();
|
|
80
|
+
const dir = join(root, stateDirName(), 'browsers');
|
|
81
|
+
const index = join(dir, '.source.json');
|
|
82
|
+
if (store.read(index) === null) return;
|
|
83
|
+
store.withLock(join(dir, '.lock'), () => {
|
|
84
|
+
const text = store.read(index);
|
|
85
|
+
if (!text) return;
|
|
86
|
+
const held = JSON.parse(text) as { data: string; names: string[] };
|
|
87
|
+
if (held.data !== data) return;
|
|
88
|
+
for (const name of held.names) { assertBrowserName(name); store.remove(join(dir, `${name}.json`)); }
|
|
89
|
+
store.remove(index);
|
|
90
|
+
});
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/** Private load-marker history; the cookie carries only an opaque id, never vendor state. */
|
|
94
|
+
export type BrowserLoad = { name: string; site: string; version: string; cookies: Array<{ name: string; path: string }> };
|
|
95
|
+
export const browserLoadId = (version: string, site: string): string => createHash('sha256').update(`${version}\0${site}`).digest('hex');
|
|
96
|
+
const loadPath = (data: string, id: string): string => join(dirname(data), 'browser-loads', `${id}.json`);
|
|
97
|
+
export function browserLoad(data: string, id: string | undefined): BrowserLoad | null {
|
|
98
|
+
if (!id || !/^[a-f0-9]{64}$/.test(id)) return null;
|
|
99
|
+
const text = getActiveWorldStore().read(loadPath(data, id));
|
|
100
|
+
return text === null ? null : JSON.parse(text) as BrowserLoad;
|
|
101
|
+
}
|
|
102
|
+
export function putBrowserLoad(data: string, id: string, load: BrowserLoad): void {
|
|
103
|
+
if (!/^[a-f0-9]{64}$/.test(id)) throw new Error('invalid browser load marker');
|
|
104
|
+
const held = browserLoad(data, id);
|
|
105
|
+
const cookies = new Map<string, { name: string; path: string }>();
|
|
106
|
+
if (held?.name === load.name && held.site === load.site && held.version === load.version) {
|
|
107
|
+
for (const cookie of held.cookies) cookies.set(JSON.stringify([cookie.name, cookie.path]), cookie);
|
|
108
|
+
}
|
|
109
|
+
for (const cookie of load.cookies) cookies.set(JSON.stringify([cookie.name, cookie.path]), cookie);
|
|
110
|
+
getActiveWorldStore().writeAtomic(loadPath(data, id), `${JSON.stringify({ ...load, cookies: [...cookies.values()] })}\n`, { secret: true });
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/** Retire old ids; cookie paths survive loads and deletion until carried into the name's next version. */
|
|
114
|
+
function retireBrowserLoads(data: string, name: string, current?: WorldBrowser): void {
|
|
115
|
+
const store = getActiveWorldStore();
|
|
116
|
+
const dir = join(dirname(data), 'browser-loads');
|
|
117
|
+
const retiredFile = join(dir, `retired-${name}.json`);
|
|
118
|
+
const prior = new Map<string, BrowserLoad>();
|
|
119
|
+
const keep = (load: BrowserLoad): void => {
|
|
120
|
+
const cookies = new Map<string, { name: string; path: string }>();
|
|
121
|
+
for (const cookie of [...(prior.get(load.site)?.cookies ?? []), ...load.cookies]) {
|
|
122
|
+
cookies.set(JSON.stringify([cookie.name, cookie.path]), cookie);
|
|
123
|
+
}
|
|
124
|
+
prior.set(load.site, { ...load, cookies: [...cookies.values()] });
|
|
125
|
+
};
|
|
126
|
+
const retired = store.read(retiredFile);
|
|
127
|
+
if (retired !== null) for (const load of JSON.parse(retired) as BrowserLoad[]) keep(load);
|
|
128
|
+
const removed: string[] = [];
|
|
129
|
+
for (const file of store.list(dir)) {
|
|
130
|
+
if (!/^[a-f0-9]{64}\.json$/.test(file)) continue;
|
|
131
|
+
const load = browserLoad(data, file.slice(0, -5));
|
|
132
|
+
if (!load || load.name !== name || load.version === current?.version) continue;
|
|
133
|
+
keep(load);
|
|
134
|
+
removed.push(file);
|
|
135
|
+
}
|
|
136
|
+
// Preserve paths before removing the version records that supplied them.
|
|
137
|
+
if (current) {
|
|
138
|
+
for (const load of prior.values()) putBrowserLoad(data, browserLoadId(current.version, load.site), {
|
|
139
|
+
...load, version: current.version,
|
|
140
|
+
});
|
|
141
|
+
store.remove(retiredFile);
|
|
142
|
+
} else if (prior.size) {
|
|
143
|
+
store.writeAtomic(retiredFile, `${JSON.stringify([...prior.values()])}\n`, { secret: true });
|
|
144
|
+
}
|
|
145
|
+
for (const file of removed) store.remove(join(dir, file));
|
|
146
|
+
}
|
package/src/index.ts
CHANGED
|
@@ -177,3 +177,5 @@ export { CONSOLE_BASE, consoleRedirect, serveConsoleApart, serveConsoleFor, type
|
|
|
177
177
|
export { assertWorldStateRemovable } from './ancestry.ts';
|
|
178
178
|
|
|
179
179
|
export { withoutVaultSignIn } from './runtime.ts';
|
|
180
|
+
|
|
181
|
+
export { materializeBrowserFiles, browsersDir, worldBrowser, worldBrowsers, putWorldBrowser, deleteWorldBrowser, inheritWorldBrowsers, type WorldBrowser } from './browsers.ts';
|
package/src/init.ts
CHANGED
|
@@ -1273,6 +1273,7 @@ token
|
|
|
1273
1273
|
token.read
|
|
1274
1274
|
sessions.json
|
|
1275
1275
|
pages-sessions.json
|
|
1276
|
+
browsers/
|
|
1276
1277
|
keys.json
|
|
1277
1278
|
`;
|
|
1278
1279
|
|
|
@@ -1325,17 +1326,48 @@ export function seedCallsRunner(vendor: string): string {
|
|
|
1325
1326
|
return `// ${vendor}'s default data, as its twin's pack declares it (./${vendor}.data.ts: calls to the vendor's own
|
|
1326
1327
|
// operations), made against the twin in order. Ownership transferred on copy: both files are yours — edit or delete.
|
|
1327
1328
|
import { seed as calls } from './${vendor}.data.ts';
|
|
1329
|
+
import * as defaults from './${vendor}.data.ts';
|
|
1330
|
+
import type { SeedBrowser } from '@volter/world-core/browser';
|
|
1328
1331
|
|
|
1329
1332
|
export async function seed(): Promise<void> {
|
|
1330
1333
|
// a twin with a root is the vendor's real account: its default data is not written (VOLTER_SEED_SKIP)
|
|
1331
1334
|
if ((process.env.VOLTER_SEED_SKIP ?? '').split(',').includes('${vendor}')) return;
|
|
1332
1335
|
const base = process.env.${stem}_TWIN_URL;
|
|
1333
1336
|
if (!base) throw new Error('${stem}_TWIN_URL is not set — bring the world up and run the seed with its env (volter-world attach/run)');
|
|
1334
|
-
|
|
1335
|
-
|
|
1337
|
+
const clients = new Map<string, SeedBrowser>();
|
|
1338
|
+
const snapshots = new Map<string, Awaited<ReturnType<SeedBrowser['state']>>>();
|
|
1339
|
+
let browserHelper: typeof import('@volter/world-core/browser') | undefined;
|
|
1340
|
+
const browserCalls = ('browserSeed' in defaults ? defaults.browserSeed as typeof calls : undefined) ?? [];
|
|
1341
|
+
for (const call of [...calls, ...browserCalls]) {
|
|
1342
|
+
if (call.browser) {
|
|
1343
|
+
if (!snapshots.has(call.browser)) {
|
|
1344
|
+
const door = process.env.VOLTER_WORLD_BROWSER_DOOR;
|
|
1345
|
+
const token = process.env.VOLTER_WORLD_BROWSER_TOKEN;
|
|
1346
|
+
if (!door || !token) throw new Error('browser defaults run in a World seed with its browsers door and credential');
|
|
1347
|
+
const held = await fetch(\`\${door}/\${encodeURIComponent(call.browser)}\`, { headers: { 'x-volter-token': token } });
|
|
1348
|
+
if (!held.ok && held.status !== 404) throw new Error(\`browser \${call.browser}: \${held.status} \${await held.text()}\`);
|
|
1349
|
+
snapshots.set(call.browser, held.ok ? await held.json() as Awaited<ReturnType<SeedBrowser['state']>> : { cookies: [], origins: [] });
|
|
1350
|
+
}
|
|
1351
|
+
browserHelper ??= await import('@volter/world-core/browser').catch(error => {
|
|
1352
|
+
throw new Error('Install @volter/world-core 3.0.31 or later in this project to seed browsers.', { cause: error });
|
|
1353
|
+
});
|
|
1354
|
+
const initial = snapshots.get(call.browser)!;
|
|
1355
|
+
const hostname = new URL(call.path).hostname;
|
|
1356
|
+
if (initial.cookies.some(cookie => browserHelper!.cookieMatchesHost(cookie, hostname))
|
|
1357
|
+
|| initial.origins.some(origin => new URL(origin.origin).hostname === hostname)) continue;
|
|
1358
|
+
}
|
|
1359
|
+
const headers = { ...(call.body !== undefined ? { 'content-type': 'application/json' } : {}), ...(call.headers ?? {}) };
|
|
1360
|
+
const form = /^application\\/x-www-form-urlencoded\\b/i.test(new Headers(headers).get('content-type') ?? '');
|
|
1361
|
+
const body = call.body === undefined ? undefined : form
|
|
1362
|
+
? new URLSearchParams(Object.entries(call.body as Record<string, unknown>).map(([key, value]) => [key, String(value)]))
|
|
1363
|
+
: JSON.stringify(call.body);
|
|
1364
|
+
if (call.browser && !clients.has(call.browser)) {
|
|
1365
|
+
clients.set(call.browser, await browserHelper!.browser(call.browser));
|
|
1366
|
+
}
|
|
1367
|
+
const client = call.browser ? clients.get(call.browser)! : null;
|
|
1368
|
+
const res = await (client ? client.fetch : fetch)(client ? call.path : \`\${base.replace(/\\/$/, '')}\${call.path}\`, {
|
|
1336
1369
|
method: call.method,
|
|
1337
|
-
headers
|
|
1338
|
-
...(call.body !== undefined ? { body: JSON.stringify(call.body) } : {}),
|
|
1370
|
+
headers, ...(body === undefined ? {} : { body }),
|
|
1339
1371
|
});
|
|
1340
1372
|
if (!res.ok) throw new Error(\`${vendor} seed: \${call.method} \${call.path}: \${res.status} \${await res.text()}\`);
|
|
1341
1373
|
}
|
package/src/local-branches.ts
CHANGED
|
@@ -6,7 +6,8 @@
|
|
|
6
6
|
import { cpSync, existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
|
|
7
7
|
import { dirname, join } from 'node:path';
|
|
8
8
|
import { stateDirName, withStateRemoval } from '@volter/world-core/runtime';
|
|
9
|
-
import {
|
|
9
|
+
import { inheritWorldBrowsers, materializeBrowserFiles } from './browsers.ts';
|
|
10
|
+
import { clockFile, statusWorld } from './runtime.ts';
|
|
10
11
|
import { loadWorldConfig } from './configs.ts';
|
|
11
12
|
import { TOKEN_HEADER, type BranchDoors, type BranchRow, type MountedWorld } from './served-world.ts';
|
|
12
13
|
|
|
@@ -160,6 +161,9 @@ export class LocalBranches {
|
|
|
160
161
|
// a live branch (a pull request's preview) keeps the parent's time: real time, or its simulated clock
|
|
161
162
|
const clock = at.instant ?? this.frozenClock(from) ?? (at.live ? null : new Date().toISOString());
|
|
162
163
|
if (clock) { const file = clockFile(root, id); mkdirSync(dirname(file), { recursive: true }); writeFileSync(file, `${clock}\n`); }
|
|
164
|
+
const child = statusWorld(id, root);
|
|
165
|
+
inheritWorldBrowsers(statusWorld(world, from.root).dirs.data, child.dirs.data);
|
|
166
|
+
materializeBrowserFiles(child.dirs.data, root, id, child.config);
|
|
163
167
|
const createdAt = new Date().toISOString();
|
|
164
168
|
const expiresAt = ttlSeconds === null ? null : new Date(Date.now() + ttlSeconds * 1000).toISOString();
|
|
165
169
|
// what it was made as (a pull request's preview is labelled and may be live)
|
package/src/pack-facts.ts
CHANGED
|
@@ -8,6 +8,7 @@
|
|
|
8
8
|
// object it always read — and a fact declared in BOTH places throws: dual declaration is the
|
|
9
9
|
// drift this migration exists to end, so it is loud everywhere, not just in a test.
|
|
10
10
|
|
|
11
|
+
import { createHostTable, type HostPredicate } from '@volter/world-core/host-routing';
|
|
11
12
|
import factsDocument from '@volter/world-core/generated/pack-facts.json' with { type: 'json' };
|
|
12
13
|
|
|
13
14
|
export type PackAdoption = { sdks?: string[]; pypi?: string[]; scopes?: string[]; envStems?: string[]; worldIds?: string[]; tools?: Array<{ package: string; usage: 'build' | 'deployment' }>; configFiles?: Array<{ file: string; usage: 'application' | 'build' | 'deployment'; when?: string }> };
|
|
@@ -146,3 +147,18 @@ export function overlayEndpointEnv(
|
|
|
146
147
|
};
|
|
147
148
|
}
|
|
148
149
|
}
|
|
150
|
+
|
|
151
|
+
/** Facts supplied by resolved packs, compiled after the built-in facts in a routing table. */
|
|
152
|
+
export function overlaidPackVendors(): string[] { return Object.keys(overlaid); }
|
|
153
|
+
|
|
154
|
+
/** One routing table per immutable merged-facts version, built only for browser routing. */
|
|
155
|
+
const hostTables = new WeakMap<Record<string, PackFacts>, Record<string, HostPredicate>>();
|
|
156
|
+
export function packHostRules(): Record<string, HostPredicate> {
|
|
157
|
+
const facts = packFacts();
|
|
158
|
+
let rules = hostTables.get(facts);
|
|
159
|
+
if (!rules) {
|
|
160
|
+
rules = createHostTable(facts, overlaidPackVendors()).hosts;
|
|
161
|
+
hostTables.set(facts, rules);
|
|
162
|
+
}
|
|
163
|
+
return rules;
|
|
164
|
+
}
|
package/src/placeholder.ts
CHANGED
|
@@ -7,9 +7,11 @@
|
|
|
7
7
|
// keep is a fork, taken before.
|
|
8
8
|
import { materializeRoots } from './root.ts';
|
|
9
9
|
import { loadWorldConfig } from './configs.ts';
|
|
10
|
+
import { WorldDoors, servedName } from './served-world.ts';
|
|
11
|
+
import { randomUUID } from 'node:crypto';
|
|
10
12
|
import { existsSync, readdirSync } from 'node:fs';
|
|
11
13
|
import { dirname, join, resolve } from 'node:path';
|
|
12
|
-
import { beginPlaceholderPull, endPlaceholderPull, listEvents, PLACEHOLDER_REMOTE, worldStateRoot } from '@volter/world-core/runtime';
|
|
14
|
+
import { serveHttp, beginPlaceholderPull, endPlaceholderPull, listEvents, PLACEHOLDER_REMOTE, worldStateRoot } from '@volter/world-core/runtime';
|
|
13
15
|
import { downWorld, runWithWorldEnv, saveWorldInstance, statusWorld, upWorld } from './runtime.ts';
|
|
14
16
|
import type { WorldInstance } from './schema.ts';
|
|
15
17
|
|
|
@@ -55,7 +57,18 @@ export async function seedWorld(name: string, options: SeedWorldOptions = {}): P
|
|
|
55
57
|
const at = new Date().toISOString();
|
|
56
58
|
for (const service of services) beginPlaceholderPull(service, join(instance.dirs.data, service), at);
|
|
57
59
|
let exitCode = 1;
|
|
60
|
+
const served = servedName(root, instance.configPath);
|
|
61
|
+
const token = `tok_seed_${randomUUID()}`;
|
|
62
|
+
const doors = new WorldDoors(name, root, instance.configPath, served, {
|
|
63
|
+
ready: () => true, layout: () => instance, reload: () => statusWorld(name, root), twinFetch: () => null,
|
|
64
|
+
}, token, `tok_seed_read_${randomUUID()}`);
|
|
65
|
+
let doorServer: Awaited<ReturnType<typeof serveHttp>> | undefined;
|
|
58
66
|
try {
|
|
67
|
+
doorServer = await serveHttp({ twinRequestJournal: false, maxRequestBodySize: 4 * 1024 * 1024, fetch: request => {
|
|
68
|
+
const path = new URL(request.url).pathname;
|
|
69
|
+
return path === `/-/${served}/browsers` || path.startsWith(`/-/${served}/browsers/`)
|
|
70
|
+
? doors.handle(request) : Response.json({ error: 'the seed loader serves browser state only' }, { status: 404 });
|
|
71
|
+
} });
|
|
59
72
|
// The runtime that is running runs the seed: Bun, or Node with its TypeScript type stripping
|
|
60
73
|
// (`--experimental-strip-types`, accepted from Node 22.6 and on by default since 23.6). Never
|
|
61
74
|
// `bun` by name: the published runtime supports Node alone, and the tab's engine has no Bun.
|
|
@@ -64,8 +77,9 @@ export async function seedWorld(name: string, options: SeedWorldOptions = {}): P
|
|
|
64
77
|
if (major < 22 || (major === 22 && minor < 6)) throw new Error(`${entry} is TypeScript: seeding it under Node needs Node 22.6 or newer (type stripping), or Bun; this is Node ${process.versions.node}`);
|
|
65
78
|
}
|
|
66
79
|
const runner = typeof Bun !== 'undefined' ? ['bun', entry] : [process.execPath, '--experimental-strip-types', '--no-warnings', entry];
|
|
67
|
-
exitCode = await runWithWorldEnv(name, runner, root, { cwd: options.cwd ?? dirname(entry), env: { VOLTER_SEED_SKIP: [...rooted].sort().join(',') } });
|
|
80
|
+
exitCode = await runWithWorldEnv(name, runner, root, { cwd: options.cwd ?? dirname(entry), env: { VOLTER_SEED_SKIP: [...rooted].sort().join(','), VOLTER_WORLD_BROWSER_DOOR: `${doorServer.url.origin}/-/${served}/browsers`, VOLTER_WORLD_BROWSER_TOKEN: token } });
|
|
68
81
|
} finally {
|
|
82
|
+
await doorServer?.stop();
|
|
69
83
|
for (const service of services) endPlaceholderPull(service, join(instance.dirs.data, service));
|
|
70
84
|
}
|
|
71
85
|
const observed: Record<string, number> = {};
|
package/src/redirect-proxy.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { serveHttp, type HttpServer } from '@volter/world-core/runtime';
|
|
2
2
|
import { worldNetworkPolicyFromEnv, allowsWorldNetworkEgress } from '@volter/world-core/network-policy';
|
|
3
3
|
import { websocketRelay } from './websocket-relay.ts';
|
|
4
4
|
// The ambient TLS redirect proxy — the substance of "any CLI, zero config" (WORLD_ACTIVATE.md §"How
|
|
@@ -376,7 +376,7 @@ export async function startRedirectProxy(options: RedirectProxyOptions): Promise
|
|
|
376
376
|
const origin = new URL(twin.origin);
|
|
377
377
|
|
|
378
378
|
const headers = new Headers(req.headers);
|
|
379
|
-
for (const h of [
|
|
379
|
+
for (const h of ['proxy-connection', 'accept-encoding', 'connection', 'x-volter-world-browser']) headers.delete(h);
|
|
380
380
|
// the application is not a twin: it gets no World key, keeps its Host and hears it was reached over TLS
|
|
381
381
|
if (twin.vendor === 'app') {
|
|
382
382
|
headers.delete('x-twins-key');
|
|
@@ -509,9 +509,9 @@ export async function startRedirectProxy(options: RedirectProxyOptions): Promise
|
|
|
509
509
|
outHeaders['x-volter-twin-original-host'] = headers.host ?? origin.host;
|
|
510
510
|
outHeaders.host = origin.host;
|
|
511
511
|
}
|
|
512
|
-
delete outHeaders[BROWSER_SESSION_HEADER];
|
|
513
512
|
delete outHeaders['proxy-connection'];
|
|
514
513
|
delete outHeaders['accept-encoding'];
|
|
514
|
+
delete outHeaders['x-volter-world-browser'];
|
|
515
515
|
upstream.write(`${method} ${path} ${version}\r\n`);
|
|
516
516
|
for (const [name, value] of Object.entries(outHeaders)) upstream.write(`${name}: ${value}\r\n`);
|
|
517
517
|
upstream.write('\r\n');
|
package/src/reflect.ts
CHANGED
|
@@ -1,4 +1,3 @@
|
|
|
1
|
-
import { BROWSER_SESSION_HEADER } from '@volter/world-core/runtime';
|
|
2
1
|
import { serveHttp, type HttpServer } from '@volter/world-core/runtime';
|
|
3
2
|
import { websocketRelay } from './websocket-relay.ts';
|
|
4
3
|
import { sessionTrustEnv } from './ca-trust.ts';
|
|
@@ -161,7 +160,7 @@ export async function startReflectFront(options: ReflectFrontOptions): Promise<R
|
|
|
161
160
|
try { origin = new URL(target.origin); } catch { return new Response('bad twin origin', { status: 502 }); }
|
|
162
161
|
|
|
163
162
|
const headers = new Headers(req.headers);
|
|
164
|
-
headers.delete(
|
|
163
|
+
headers.delete('x-volter-world-browser');
|
|
165
164
|
headers.delete('proxy-connection');
|
|
166
165
|
headers.delete('accept-encoding');
|
|
167
166
|
// the consumer spoke TLS to the front; a twin that answers with an absolute URL (Slack's Socket Mode link)
|
package/src/runtime.ts
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { materializeBrowserFiles, clearBrowserFiles } from './browsers.ts';
|
|
1
2
|
import { claimEnvOutput, publishEnvOutput } from './env-output.ts';
|
|
2
3
|
import { offSignal } from './signals.ts';
|
|
3
4
|
import { parallelStartup } from './startup.ts';
|
|
@@ -1741,7 +1742,7 @@ export async function upWorld(configId: string, options: UpWorldOptions = {}, ab
|
|
|
1741
1742
|
if (existsSync(logs)) { rmSync(join(logs, 'previous'), { recursive: true, force: true }); renameSync(logs, previousLogs); }
|
|
1742
1743
|
try {
|
|
1743
1744
|
if (options.keepState) {
|
|
1744
|
-
const KEEP = ['data', 'marks', 'changesets', 'episodes'];
|
|
1745
|
+
const KEEP = ['data', 'marks', 'changesets', 'episodes', 'browser-records', 'browser-loads'];
|
|
1745
1746
|
for (const entry of existsSync(instance) ? readdirSync(instance) : []) if (!KEEP.includes(entry)) rmSync(join(instance, entry), { recursive: true, force: true });
|
|
1746
1747
|
} else removeWorldState(root, instance, () => rmSync(instance, { recursive: true, force: true }));
|
|
1747
1748
|
} catch (error) {
|
|
@@ -1930,6 +1931,7 @@ export async function upWorld(configId: string, options: UpWorldOptions = {}, ab
|
|
|
1930
1931
|
// Config env and discovered credentials: a file this writes is owner-only; a file the caller already made keeps
|
|
1931
1932
|
// the mode the caller chose (a shared env file may be read under another uid).
|
|
1932
1933
|
publishEnvOutput(envOutputClaim, envFileContents(env));
|
|
1934
|
+
materializeBrowserFiles(data, root, name, loaded.config.id);
|
|
1933
1935
|
writePidsFromInstance(worldInstance, Object.values(worldInstance.services).map((service) => service.pid).filter((pid) => pid > 0));
|
|
1934
1936
|
saveWorldInstance(worldInstance);
|
|
1935
1937
|
const servicePids = [...new Set(Object.values(services).map((service) => service.pid).filter((pid) => pid > 0))];
|
|
@@ -2286,7 +2288,10 @@ async function downWorldClaimed(
|
|
|
2286
2288
|
})}\n`);
|
|
2287
2289
|
}
|
|
2288
2290
|
releaseWorldLifecycle(resolvedRoot, name);
|
|
2289
|
-
|
|
2291
|
+
if (!options.purge) return result;
|
|
2292
|
+
const purged = purgeInstanceDir(resolvedRoot, name);
|
|
2293
|
+
if (purged && instance) clearBrowserFiles(instance.dirs.data, resolvedRoot);
|
|
2294
|
+
return { ...result, purged };
|
|
2290
2295
|
}));
|
|
2291
2296
|
}
|
|
2292
2297
|
|