@volter/world-runtime 3.0.9 → 3.0.11
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/served-world.d.ts +20 -1
- package/dist/src/served-world.js +203 -3
- package/dist/src/world-view.js +4 -2
- package/package.json +4 -4
- package/src/served-world.ts +164 -5
- package/src/world-view.ts +4 -2
|
@@ -35,6 +35,8 @@ export type ServedWorld = {
|
|
|
35
35
|
export declare function sessionsFile(worldRoot: string): string;
|
|
36
36
|
/** Where a World keeps its named keys (their hashes, never a key): `.volter/keys.json`. */
|
|
37
37
|
export declare function keysFile(worldRoot: string): string;
|
|
38
|
+
/** Where a World keeps its boards as its people arranged them (what each hides or includes, and its frames' places): `.volter/board.json`. */
|
|
39
|
+
export declare function boardFile(worldRoot: string): string;
|
|
38
40
|
/** The served name: `bare.name` (`acme/team`), else `<id>/<id>`. */
|
|
39
41
|
export declare function servedName(worldRoot: string, configRef: string): string;
|
|
40
42
|
/** A world MOUNTED for serving: its doors, its tokens, and a boot that writes the serve record once the
|
|
@@ -63,7 +65,8 @@ export declare function mountWorld(name: string, opts?: {
|
|
|
63
65
|
passIssuers?: TrustedIssuer[];
|
|
64
66
|
root?: string; /** the host's branches of this World, by its served name */
|
|
65
67
|
branches?: (served: string) => BranchDoors | undefined; /** the origin a browser reaches this World at, when the host gives each World one */
|
|
66
|
-
browserOrigin?: (served: string) => string | null; /**
|
|
68
|
+
browserOrigin?: (served: string) => string | null; /** DoorHost.sitesOrigin */
|
|
69
|
+
sitesOrigin?: (served: string) => string | null; /** served on this machine's loopback: its own pages need no token (DoorHost.localTrust) */
|
|
67
70
|
localTrust?: boolean; /** the person signed in on this machine: its own page acts as them (DoorHost.localPerson) */
|
|
68
71
|
localPerson?: string; /** DoorHost.serveBranch */
|
|
69
72
|
serveBranch?: (branch: string) => Promise<void>;
|
|
@@ -118,6 +121,9 @@ export type DoorHost = {
|
|
|
118
121
|
* host gives each World one: its session lives only there (docs/contributing/architecture.md,
|
|
119
122
|
* "Viewing a World"). Null, or absent, on a host whose Worlds share one origin. */
|
|
120
123
|
browserOrigin?: () => string | null;
|
|
124
|
+
/** The origin under which this host routes the hostnames the World's twins answer, each at `<hostname>.<origin's host>`
|
|
125
|
+
* (siteOfHost); none where it routes no sites (behind a proxy, on a hosted World). */
|
|
126
|
+
sitesOrigin?: () => string | null;
|
|
121
127
|
/** The origin another World this host serves (a branch of this one) is reached at, where it has one of its own. */
|
|
122
128
|
originOf?: (served: string) => string | null;
|
|
123
129
|
/** This World is served on this machine's loopback, for the person at it: a page of this World, reached by a
|
|
@@ -350,6 +356,19 @@ export declare class WorldDoors {
|
|
|
350
356
|
private branchesDoor;
|
|
351
357
|
/** The newest entry's instant across the World's twins (ms), or null when it has none. */
|
|
352
358
|
private newestEntry;
|
|
359
|
+
/** THE BOARD: every twin's frames as its pack lays them out (`GET /twin/board`), each where this World shows it (a
|
|
360
|
+
* hostname a twin answers as a site at its own origin, a vendor page under the twin's place, or nowhere: a frame no
|
|
361
|
+
* screen draws yet) and the changes not yet cut on the records it covers, counted by the request that made them, with
|
|
362
|
+
* when the first was made; and the boards as this World's people arranged them. A frame's changes themselves are read
|
|
363
|
+
* one frame at a time (`board/changes`). */
|
|
364
|
+
private board;
|
|
365
|
+
/** One frame's changes not yet cut, newest first: each the writes one request made, as words read them (the operation,
|
|
366
|
+
* the record, its few naming fields), the frame named `<vendor>:<frame id>`. */
|
|
367
|
+
private boardChanges;
|
|
368
|
+
/** Every twin's frames, where each opens here, and each with the changes not yet cut that are its own. */
|
|
369
|
+
private boardFrames;
|
|
370
|
+
/** The boards as the World keeps them, and the version a save names: the default board when none was ever kept. */
|
|
371
|
+
private savedBoards;
|
|
353
372
|
/** THE MAP: every twin and what it holds, by resource type, and the workspaces it shows. */
|
|
354
373
|
private map;
|
|
355
374
|
/** THE TIMELINE: the entries of every twin's log merged, newest first, by each entry's instant, then
|
package/dist/src/served-world.js
CHANGED
|
@@ -87,6 +87,59 @@ export function readServeRecord(worldRoot) {
|
|
|
87
87
|
export function sessionsFile(worldRoot) { return join(worldRoot, stateDirName(), 'sessions.json'); }
|
|
88
88
|
/** Where a World keeps its named keys (their hashes, never a key): `.volter/keys.json`. */
|
|
89
89
|
export function keysFile(worldRoot) { return join(worldRoot, stateDirName(), 'keys.json'); }
|
|
90
|
+
/** Where a World keeps its boards as its people arranged them (what each hides or includes, and its frames' places): `.volter/board.json`. */
|
|
91
|
+
export function boardFile(worldRoot) { return join(worldRoot, stateDirName(), 'board.json'); }
|
|
92
|
+
const BOARD_KEY = /^[a-z0-9-]+:[^\s]{1,300}$/;
|
|
93
|
+
/** The largest board document a World keeps. */
|
|
94
|
+
const BOARD_BYTES = 256 * 1024;
|
|
95
|
+
/** A board document from a request, or the reason it is refused: every field of its type, nothing else. */
|
|
96
|
+
/** A kept board document's version: its bytes' digest, or `none` before one is kept. */
|
|
97
|
+
const boardVersion = (text) => (text === null ? 'none' : createHash('sha256').update(text).digest('hex').slice(0, 16));
|
|
98
|
+
/** A change's act: the request that made it (its writes share one correlation id), else the write itself. */
|
|
99
|
+
const actOf = (c) => (typeof c.action.correlationId === 'string' ? c.action.correlationId : c.action.id);
|
|
100
|
+
function boardsOf(body) {
|
|
101
|
+
const list = body?.boards;
|
|
102
|
+
if (!Array.isArray(list) || list.length < 1 || list.length > 50)
|
|
103
|
+
return 'boards: a list of 1-50 boards';
|
|
104
|
+
const ids = new Set();
|
|
105
|
+
for (const b of list) {
|
|
106
|
+
if (!b || typeof b !== 'object' || Object.keys(b).some((k) => !['id', 'name', 'include', 'hidden', 'placed', 'sections'].includes(k)))
|
|
107
|
+
return 'a board has only id, name, include, hidden, placed and sections';
|
|
108
|
+
if (typeof b.id !== 'string' || !/^[a-z0-9-]{1,64}$/.test(b.id) || ids.has(b.id))
|
|
109
|
+
return 'a board id is unique, a-z 0-9 -';
|
|
110
|
+
ids.add(b.id);
|
|
111
|
+
if (typeof b.name !== 'string' || !b.name.trim() || b.name.length > 80)
|
|
112
|
+
return 'a board name is 1-80 characters';
|
|
113
|
+
for (const k of ['include', 'hidden'])
|
|
114
|
+
if (b[k] !== undefined && (!Array.isArray(b[k]) || b[k].length > 2000 || !b[k].every((x) => typeof x === 'string' && BOARD_KEY.test(x))))
|
|
115
|
+
return `${k}: frame and section names`;
|
|
116
|
+
if (b.placed !== undefined) {
|
|
117
|
+
const placed = b.placed;
|
|
118
|
+
if (!placed || typeof placed !== 'object' || Array.isArray(placed) || Object.keys(placed).length > 2000)
|
|
119
|
+
return 'placed: a frame name to its place';
|
|
120
|
+
for (const [k, v] of Object.entries(placed))
|
|
121
|
+
if (!BOARD_KEY.test(k) || !v || typeof v !== 'object' || Object.keys(v).join() !== 'x,y' || !Object.values(v).every((n) => typeof n === 'number' && Number.isFinite(n)))
|
|
122
|
+
return 'placed: { x, y } by frame name';
|
|
123
|
+
}
|
|
124
|
+
if (b.sections !== undefined) {
|
|
125
|
+
const sections = b.sections;
|
|
126
|
+
if (!sections || typeof sections !== 'object' || Array.isArray(sections) || Object.keys(sections).length > 500)
|
|
127
|
+
return 'sections: a section name to its region';
|
|
128
|
+
for (const [k, v] of Object.entries(sections)) {
|
|
129
|
+
const r = v;
|
|
130
|
+
// a section is a pack's group (`<vendor>:section:<id>`) or one people drew (`user:<id>`), on the board's plane
|
|
131
|
+
if (!BOARD_KEY.test(k) || !(/^user:[a-z0-9]{1,32}$/.test(k) || /^[a-z0-9-]+:section:/.test(k)) || !r || typeof r !== 'object' || Object.keys(r).some((f) => !['x', 'y', 'w', 'h', 'name', 'gone'].includes(f))
|
|
132
|
+
|| !['x', 'y'].every((f) => typeof r[f] === 'number' && Math.abs(r[f]) <= 1e7) || !['w', 'h'].every((f) => typeof r[f] === 'number' && r[f] >= 0 && r[f] <= 1e6)
|
|
133
|
+
|| (r.gone !== true && (r.w < 100 || r.h < 100))
|
|
134
|
+
|| (r.name !== undefined && (typeof r.name !== 'string' || !r.name.trim() || r.name.length > 80)) || (r.gone !== undefined && r.gone !== true))
|
|
135
|
+
return 'sections: { x, y, w, h, name?, gone? } by section name';
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
if (list[0].include !== undefined)
|
|
140
|
+
return 'the first board is everything: it includes no list';
|
|
141
|
+
return list;
|
|
142
|
+
}
|
|
90
143
|
const keyHash = (key) => createHash('sha256').update(key).digest('hex');
|
|
91
144
|
function mintToken(prefix) { return `${prefix}${Buffer.from(crypto.getRandomValues(new Uint8Array(24))).toString('base64url')}`; }
|
|
92
145
|
function writeSecret(path, value) { mkdirSync(dirname(path), { recursive: true }); writeFileSync(path, `${value}\n`, { mode: 0o600 }); chmodSync(path, 0o600); }
|
|
@@ -135,6 +188,7 @@ export async function mountWorld(name, opts = {}) {
|
|
|
135
188
|
clockPath: () => clockFile(worldRoot, name),
|
|
136
189
|
...(opts.branches?.(served) ? { branches: opts.branches(served) } : {}),
|
|
137
190
|
...(opts.browserOrigin ? { browserOrigin: () => opts.browserOrigin(served), originOf: (other) => opts.browserOrigin(other) } : {}),
|
|
191
|
+
...(opts.sitesOrigin ? { sitesOrigin: () => opts.sitesOrigin(served) } : {}),
|
|
138
192
|
...(opts.localTrust ? { localTrust: () => true, ...(opts.localPerson ? { localPerson: () => opts.localPerson } : {}) } : {}),
|
|
139
193
|
...(opts.serveBranch ? { serveBranch: opts.serveBranch } : {}),
|
|
140
194
|
...(opts.passIssuers?.length ? { passIssuers: () => opts.passIssuers } : {}),
|
|
@@ -795,6 +849,11 @@ export class WorldDoors {
|
|
|
795
849
|
}
|
|
796
850
|
return new Response(request.method === 'HEAD' ? null : answer.body, { status: answer.status, statusText: answer.statusText, headers: out });
|
|
797
851
|
}
|
|
852
|
+
// a person's browser is told in words (often the site is not there yet, or was not in a copy of the World as it was)
|
|
853
|
+
if ((request.headers.get('accept') ?? '').includes('text/html')) {
|
|
854
|
+
const shown = host.replace(/[&<>"']/g, (c) => `&#${c.charCodeAt(0)};`);
|
|
855
|
+
return new Response(`<!doctype html><meta charset="utf-8"><title>${shown}</title><body style="font:16px system-ui;margin:3em;color:#555"><p>Nothing is served at <b>${shown}</b> in this World.</p></body>`, { status: 404, headers: { 'content-type': 'text/html; charset=utf-8' } });
|
|
856
|
+
}
|
|
798
857
|
return Response.json({ error: `no twin of ${this.served} answers ${host}` }, { status: 404 });
|
|
799
858
|
}
|
|
800
859
|
async handle(request) {
|
|
@@ -943,9 +1002,9 @@ export class WorldDoors {
|
|
|
943
1002
|
headers.set('x-forwarded-proto', url.protocol.replace(':', ''));
|
|
944
1003
|
// where this World shows the hostnames its twins answer, for a page that links one (twinSiteUrl): under its own
|
|
945
1004
|
// origin on this machine (siteOfHost); a World without one shows none
|
|
946
|
-
const
|
|
947
|
-
const sites =
|
|
948
|
-
if (sites
|
|
1005
|
+
const at = this.host.sitesOrigin?.() ?? null;
|
|
1006
|
+
const sites = at ? new URL(at) : null;
|
|
1007
|
+
if (sites)
|
|
949
1008
|
headers.set(TWIN_SITES_HEADER, `${sites.protocol}//{host}.${sites.host}`);
|
|
950
1009
|
else
|
|
951
1010
|
headers.delete(TWIN_SITES_HEADER);
|
|
@@ -1181,6 +1240,52 @@ export class WorldDoors {
|
|
|
1181
1240
|
}
|
|
1182
1241
|
if (kind === 'map' && !name && request.method === 'GET')
|
|
1183
1242
|
return Response.json(await this.map());
|
|
1243
|
+
if (kind === 'board' && !name && request.method === 'GET')
|
|
1244
|
+
return Response.json(await this.board(url));
|
|
1245
|
+
if (kind === 'board' && name === 'changes' && !verb && request.method === 'GET')
|
|
1246
|
+
return await this.boardChanges(url);
|
|
1247
|
+
if (kind === 'board' && !name && request.method === 'PUT') {
|
|
1248
|
+
if (!/^application\/json\b/i.test(request.headers.get('content-type') ?? ''))
|
|
1249
|
+
return Response.json({ error: 'the board is sent as application/json' }, { status: 415 });
|
|
1250
|
+
if (Number(request.headers.get('content-length') ?? '0') > BOARD_BYTES)
|
|
1251
|
+
return Response.json({ error: `the board is at most ${BOARD_BYTES} bytes` }, { status: 413 });
|
|
1252
|
+
// read as it arrives, never past the cap (a chunked body names no length)
|
|
1253
|
+
const chunks = [];
|
|
1254
|
+
let size = 0;
|
|
1255
|
+
const reader = request.body?.getReader();
|
|
1256
|
+
for (let part = await reader?.read(); part && !part.done; part = await reader.read()) {
|
|
1257
|
+
size += part.value.byteLength;
|
|
1258
|
+
if (size > BOARD_BYTES) {
|
|
1259
|
+
await reader.cancel();
|
|
1260
|
+
return Response.json({ error: `the board is at most ${BOARD_BYTES} bytes` }, { status: 413 });
|
|
1261
|
+
}
|
|
1262
|
+
chunks.push(part.value);
|
|
1263
|
+
}
|
|
1264
|
+
const text = Buffer.concat(chunks).toString('utf8');
|
|
1265
|
+
let body;
|
|
1266
|
+
try {
|
|
1267
|
+
body = JSON.parse(text);
|
|
1268
|
+
}
|
|
1269
|
+
catch {
|
|
1270
|
+
return Response.json({ error: 'the board is JSON' }, { status: 400 });
|
|
1271
|
+
}
|
|
1272
|
+
// a save names the version it was made from: one made from a version another save has since replaced is refused,
|
|
1273
|
+
// with the boards as they now stand, never written over them
|
|
1274
|
+
return this.serialized('board', async () => {
|
|
1275
|
+
const held = this.savedBoards();
|
|
1276
|
+
const from = body?.version;
|
|
1277
|
+
if (typeof from !== 'string')
|
|
1278
|
+
return Response.json({ error: 'a board save names the version it was made from (the version GET answered)', ...held }, { status: 409 });
|
|
1279
|
+
if (from !== held.version)
|
|
1280
|
+
return Response.json({ error: 'the board changed since this was made from it', ...held }, { status: 409 });
|
|
1281
|
+
const boards = boardsOf({ boards: body.boards });
|
|
1282
|
+
if (typeof boards === 'string')
|
|
1283
|
+
return Response.json({ error: boards }, { status: 400 });
|
|
1284
|
+
const kept = `${JSON.stringify({ boards })}\n`;
|
|
1285
|
+
getActiveWorldStore().write(boardFile(this.worldRoot), kept);
|
|
1286
|
+
return Response.json({ world: this.served, boards, version: boardVersion(kept) });
|
|
1287
|
+
});
|
|
1288
|
+
}
|
|
1184
1289
|
if (kind === 'timeline' && !name && request.method === 'GET')
|
|
1185
1290
|
return this.timeline(url);
|
|
1186
1291
|
if (kind === 'diff' && !name && request.method === 'GET') {
|
|
@@ -1794,6 +1899,101 @@ export class WorldDoors {
|
|
|
1794
1899
|
}
|
|
1795
1900
|
return newest;
|
|
1796
1901
|
}
|
|
1902
|
+
/** THE BOARD: every twin's frames as its pack lays them out (`GET /twin/board`), each where this World shows it (a
|
|
1903
|
+
* hostname a twin answers as a site at its own origin, a vendor page under the twin's place, or nowhere: a frame no
|
|
1904
|
+
* screen draws yet) and the changes not yet cut on the records it covers, counted by the request that made them, with
|
|
1905
|
+
* when the first was made; and the boards as this World's people arranged them. A frame's changes themselves are read
|
|
1906
|
+
* one frame at a time (`board/changes`). */
|
|
1907
|
+
async board(url) {
|
|
1908
|
+
const { frames, delta } = await this.boardFrames(url);
|
|
1909
|
+
// the changes are counted from the World's base: what was last cut or received, and when
|
|
1910
|
+
return { world: this.served, base: { id: delta.base.id, at: delta.base.createdAt ?? null }, frames: frames.map(({ frame, mine }) => ({ ...frame, changes: new Set(mine.map(actOf)).size, since: mine.map((c) => c.action.occurredAt).sort()[0] ?? null })), ...this.savedBoards() };
|
|
1911
|
+
}
|
|
1912
|
+
/** One frame's changes not yet cut, newest first: each the writes one request made, as words read them (the operation,
|
|
1913
|
+
* the record, its few naming fields), the frame named `<vendor>:<frame id>`. */
|
|
1914
|
+
async boardChanges(url) {
|
|
1915
|
+
const key = url.searchParams.get('frame') ?? '';
|
|
1916
|
+
if (!key)
|
|
1917
|
+
return Response.json({ error: 'board/changes?frame=<vendor>:<frame id>' }, { status: 400 });
|
|
1918
|
+
const found = (await this.boardFrames(url)).frames.find(({ frame }) => `${frame.vendor}:${frame.id}` === key);
|
|
1919
|
+
if (!found)
|
|
1920
|
+
return Response.json({ error: `no frame ${key} on ${this.served}'s board` }, { status: 404 });
|
|
1921
|
+
const acts = new Map();
|
|
1922
|
+
for (const c of found.mine) {
|
|
1923
|
+
const act = acts.get(actOf(c)) ?? { at: c.action.occurredAt, writes: 0, entries: [] };
|
|
1924
|
+
if (c.action.occurredAt > act.at)
|
|
1925
|
+
act.at = c.action.occurredAt;
|
|
1926
|
+
act.writes += 1;
|
|
1927
|
+
const named = Object.fromEntries(['title', 'name', 'hostname', 'email', 'text', 'subject', 'summary', 'description'].flatMap((k) => { const v = c.action.fields?.[k]; return typeof v === 'string' ? [[k, v.slice(0, 120)]] : []; }));
|
|
1928
|
+
if (act.entries.length < 50)
|
|
1929
|
+
act.entries.push({ op: c.action.op, ...(c.action.operation ? { operation: c.action.operation } : {}), subject: c.action.subject, fields: named });
|
|
1930
|
+
acts.set(actOf(c), act);
|
|
1931
|
+
}
|
|
1932
|
+
const all = [...acts.values()].sort((a, b) => b.at.localeCompare(a.at));
|
|
1933
|
+
return Response.json({ frame: key, total: all.length, since: found.mine.map((c) => c.action.occurredAt).sort()[0] ?? null, acts: all.slice(0, 100) });
|
|
1934
|
+
}
|
|
1935
|
+
/** Every twin's frames, where each opens here, and each with the changes not yet cut that are its own. */
|
|
1936
|
+
async boardFrames(url) {
|
|
1937
|
+
const claimed = new Map((await this.claimedHosts(url.origin)).map(({ vendor, hosts }) => [vendor, new Set(hosts)]));
|
|
1938
|
+
const routed = this.host.sitesOrigin?.() ?? null;
|
|
1939
|
+
const sites = routed ? new URL(routed) : null;
|
|
1940
|
+
const delta = diffWorld(this.name, { root: this.worldRoot });
|
|
1941
|
+
const received = new Set(listWorldChangesets({ root: this.worldRoot, world: this.name }).filter((l) => l.changeset.pushedBy).flatMap((l) => l.changeset.actions.map((a) => a.action.id)));
|
|
1942
|
+
const changes = delta.actions.filter((a) => !received.has(a.action.id));
|
|
1943
|
+
const frames = [];
|
|
1944
|
+
for (const vendor of Object.keys(this.up().services)) {
|
|
1945
|
+
const twin = this.host.twinFetch(vendor);
|
|
1946
|
+
let listed = [];
|
|
1947
|
+
try {
|
|
1948
|
+
const answer = twin ? await twin(new Request('http://world.local/twin/board', { headers: { [READ_ONLY_HEADER]: '1' } })) : null;
|
|
1949
|
+
listed = answer?.ok ? ((await answer.json()).frames ?? []) : [];
|
|
1950
|
+
}
|
|
1951
|
+
catch {
|
|
1952
|
+
listed = [];
|
|
1953
|
+
}
|
|
1954
|
+
// a pack that lays out no board yet keeps its vendor's home on it (its root workspace, else its first unparameterized
|
|
1955
|
+
// one), whose changes are all of the vendor's
|
|
1956
|
+
if (!listed.length) {
|
|
1957
|
+
const screens = await this.screensOf(vendor);
|
|
1958
|
+
const home = screens.find((w) => w.path.replace(/\/+$/, '') === '') ?? screens.find((w) => !/[{<]/.test(w.path));
|
|
1959
|
+
if (home)
|
|
1960
|
+
frames.push({ frame: { vendor, id: `home:${home.id}`, kind: 'home', title: vendor, section: { id: 'home', title: 'Home' }, url: home.path, at: { screen: home.path.startsWith('/') ? home.path : `/${home.path}` } }, mine: changes.filter((c) => c.service === vendor) });
|
|
1961
|
+
}
|
|
1962
|
+
for (const f of listed) {
|
|
1963
|
+
let at = null;
|
|
1964
|
+
// why a frame opens nowhere: its twin draws no page for it yet, or this host routes no sites
|
|
1965
|
+
let unseen;
|
|
1966
|
+
try {
|
|
1967
|
+
const u = new URL(f.url);
|
|
1968
|
+
if (claimed.get(vendor)?.has(u.hostname.toLowerCase()))
|
|
1969
|
+
at = sites ? { site: `${sites.protocol}//${u.hostname.toLowerCase()}.${sites.host}${u.pathname}${u.search}` } : null;
|
|
1970
|
+
else if (f.drawn)
|
|
1971
|
+
at = { screen: `${u.pathname}${u.search}` };
|
|
1972
|
+
if (!at)
|
|
1973
|
+
unseen = claimed.get(vendor)?.has(u.hostname.toLowerCase()) ? 'sites-not-routed' : 'not-drawn';
|
|
1974
|
+
}
|
|
1975
|
+
catch {
|
|
1976
|
+
at = null;
|
|
1977
|
+
unseen = 'not-drawn';
|
|
1978
|
+
}
|
|
1979
|
+
const covered = new Set(f.covers.map((c) => `${c.type}:${c.id}`));
|
|
1980
|
+
frames.push({ frame: { vendor, id: f.id, kind: f.kind, title: f.title, section: f.section, url: f.url, at, ...(unseen ? { unseen } : {}) }, mine: changes.filter((x) => x.service === vendor && covered.has(`${x.action.subject.type}:${x.action.subject.id}`)) });
|
|
1981
|
+
}
|
|
1982
|
+
}
|
|
1983
|
+
return { delta, frames };
|
|
1984
|
+
}
|
|
1985
|
+
/** The boards as the World keeps them, and the version a save names: the default board when none was ever kept. */
|
|
1986
|
+
savedBoards() {
|
|
1987
|
+
const text = getActiveWorldStore().read(boardFile(this.worldRoot));
|
|
1988
|
+
let boards = [{ id: 'everything', name: 'Everything' }];
|
|
1989
|
+
try {
|
|
1990
|
+
const kept = boardsOf(JSON.parse(text ?? 'null'));
|
|
1991
|
+
if (typeof kept !== 'string')
|
|
1992
|
+
boards = kept;
|
|
1993
|
+
}
|
|
1994
|
+
catch { /* never arranged */ }
|
|
1995
|
+
return { boards, version: boardVersion(text) };
|
|
1996
|
+
}
|
|
1797
1997
|
/** THE MAP: every twin and what it holds, by resource type, and the workspaces it shows. */
|
|
1798
1998
|
async map() {
|
|
1799
1999
|
const twins = [];
|
package/dist/src/world-view.js
CHANGED
|
@@ -48,7 +48,9 @@ export async function serveWorldView(name, opts = {}) {
|
|
|
48
48
|
// (the first World mounted is the World; every later one is a branch of it)
|
|
49
49
|
let base = null;
|
|
50
50
|
const browserOrigin = origins ? (served) => (proxied && (base === null || served === base) ? proxied.origin : port ? localWorldOrigin(served, port) : null) : undefined;
|
|
51
|
-
|
|
51
|
+
// sites are routed at each World's own *.localhost origin, never behind the proxy
|
|
52
|
+
const sitesOrigin = origins && !proxied ? (served) => (port ? localWorldOrigin(served, port) : null) : undefined;
|
|
53
|
+
const mountAt = (at) => mountWorld(loadWorldConfig(join(at, stateDirName(), 'world.json'), at).config.id, { root: at, branches: (served) => branches.doorsFor(served), ...(browserOrigin ? { browserOrigin } : {}), ...(sitesOrigin ? { sitesOrigin } : {}), ...(local ? { localTrust: true, ...(opts.localPerson ? { localPerson: opts.localPerson } : {}) } : {}) });
|
|
52
54
|
const branches = new LocalBranches({ dir: join(root, stateDirName(), 'branches'), origin: () => loopback, worlds, mount: mountAt, ...(opts.announce ? { announce: opts.announce } : {}) });
|
|
53
55
|
// THE WORLD'S OWN BRANCHES, one served at a time: serving another stops this one's twins and starts that one's under the
|
|
54
56
|
// same served name (its tokens and sessions are the World's, so the page stays open); `.volter/current` follows, as
|
|
@@ -90,7 +92,7 @@ export async function serveWorldView(name, opts = {}) {
|
|
|
90
92
|
switching = false;
|
|
91
93
|
}
|
|
92
94
|
};
|
|
93
|
-
const mountOpts = { root, branches: (served) => branches.doorsFor(served), ...(browserOrigin ? { browserOrigin } : {}), ...(local ? { localTrust: true, ...(opts.localPerson ? { localPerson: opts.localPerson } : {}) } : {}), serveBranch };
|
|
95
|
+
const mountOpts = { root, branches: (served) => branches.doorsFor(served), ...(browserOrigin ? { browserOrigin } : {}), ...(sitesOrigin ? { sitesOrigin } : {}), ...(local ? { localTrust: true, ...(opts.localPerson ? { localPerson: opts.localPerson } : {}) } : {}), serveBranch };
|
|
94
96
|
let world = await mountWorld(name, mountOpts);
|
|
95
97
|
base = world.served;
|
|
96
98
|
worlds.set(world.served, world);
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@volter/world-runtime",
|
|
3
|
-
"version": "3.0.
|
|
3
|
+
"version": "3.0.11",
|
|
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.11",
|
|
72
|
+
"@volter/world-console": "3.0.6"
|
|
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.11",
|
|
79
79
|
"pg-gateway": "0.3.0-beta.4",
|
|
80
80
|
"smol-toml": "^1.8.0",
|
|
81
81
|
"yaml": "2.9.0"
|
package/src/served-world.ts
CHANGED
|
@@ -26,7 +26,7 @@ import { getActiveWorldStore, withHistoryLocks, captureHistory, historyAtInstant
|
|
|
26
26
|
// to `.volter/token` and `.volter/token.read` (0600).
|
|
27
27
|
// One writer per world by construction: the doors run in this process, serialized per twin.
|
|
28
28
|
import { chmodSync, existsSync, mkdirSync, readdirSync, readFileSync, writeFileSync , rmSync} from 'node:fs';
|
|
29
|
-
import { CALLER_HEADER, callerForHeader, newTraceparent, parseTraceparent, traceparentForDelivery, readTwinRequestJournalFrom, type TwinRequestJournalCursor, assertSafeChangesetName, blobDigest, destinationOf, getActiveBlobStore, journalTwinRequest, loadCheck, twinRequestJournalFailures, openSealedAt, readTwinRequestJournal, serveHttp, twinRequestCredentials, TWIN_PREFIX_HEADER, TWIN_SITES_HEADER, validateRemoteOrigin, type SealedCredential, type TwinStreamConnection, type TwinStreamSink } from '@volter/world-core/runtime';
|
|
29
|
+
import { CALLER_HEADER, callerForHeader, newTraceparent, parseTraceparent, traceparentForDelivery, readTwinRequestJournalFrom, type TwinRequestJournalCursor, assertSafeChangesetName, blobDigest, destinationOf, getActiveBlobStore, journalTwinRequest, loadCheck, twinRequestJournalFailures, openSealedAt, readTwinRequestJournal, serveHttp, twinRequestCredentials, TWIN_PREFIX_HEADER, TWIN_SITES_HEADER, validateRemoteOrigin, type SealedCredential, type TwinStreamConnection, type TwinStreamSink, type BoardFrame } from '@volter/world-core/runtime';
|
|
30
30
|
import { APP_READ_ENDPOINT_ENV } from './init.ts';
|
|
31
31
|
import { packFacts } from './pack-facts.ts';
|
|
32
32
|
import { grantOf, presentedSecrets, sameSecret, spentPasses, verifyPass, type TrustedIssuer, type WorldCredentials } from '@volter/world-access';
|
|
@@ -80,6 +80,50 @@ export type ServedWorld = { url: string; base: string; name: string; port: numbe
|
|
|
80
80
|
export function sessionsFile(worldRoot: string): string { return join(worldRoot, stateDirName(), 'sessions.json'); }
|
|
81
81
|
/** Where a World keeps its named keys (their hashes, never a key): `.volter/keys.json`. */
|
|
82
82
|
export function keysFile(worldRoot: string): string { return join(worldRoot, stateDirName(), 'keys.json'); }
|
|
83
|
+
/** Where a World keeps its boards as its people arranged them (what each hides or includes, and its frames' places): `.volter/board.json`. */
|
|
84
|
+
export function boardFile(worldRoot: string): string { return join(worldRoot, stateDirName(), 'board.json'); }
|
|
85
|
+
/** A board as the World keeps it: a frame or a section is named `<vendor>:<frame id>` or `<vendor>:section:<section id>`.
|
|
86
|
+
* The first board is everything the packs lay out; another holds what it includes, a section with its frames to come. */
|
|
87
|
+
type SavedBoard = { id: string; name: string; include?: string[]; hidden?: string[]; placed?: Record<string, { x: number; y: number }>; /** each section's region and name, by a pack's group or `user:<id>`; a pack's group removed is `gone` */ sections?: Record<string, { x: number; y: number; w: number; h: number; name?: string; gone?: true }> };
|
|
88
|
+
const BOARD_KEY = /^[a-z0-9-]+:[^\s]{1,300}$/;
|
|
89
|
+
/** The largest board document a World keeps. */
|
|
90
|
+
const BOARD_BYTES = 256 * 1024;
|
|
91
|
+
/** A board document from a request, or the reason it is refused: every field of its type, nothing else. */
|
|
92
|
+
/** A kept board document's version: its bytes' digest, or `none` before one is kept. */
|
|
93
|
+
const boardVersion = (text: string | null): string => (text === null ? 'none' : createHash('sha256').update(text).digest('hex').slice(0, 16));
|
|
94
|
+
/** A change's act: the request that made it (its writes share one correlation id), else the write itself. */
|
|
95
|
+
const actOf = (c: { action: { id: string; correlationId?: unknown } }): string => (typeof c.action.correlationId === 'string' ? c.action.correlationId : c.action.id);
|
|
96
|
+
function boardsOf(body: unknown): SavedBoard[] | string {
|
|
97
|
+
const list = (body as { boards?: unknown } | null)?.boards;
|
|
98
|
+
if (!Array.isArray(list) || list.length < 1 || list.length > 50) return 'boards: a list of 1-50 boards';
|
|
99
|
+
const ids = new Set<string>();
|
|
100
|
+
for (const b of list as Array<Record<string, unknown>>) {
|
|
101
|
+
if (!b || typeof b !== 'object' || Object.keys(b).some((k) => !['id', 'name', 'include', 'hidden', 'placed', 'sections'].includes(k))) return 'a board has only id, name, include, hidden, placed and sections';
|
|
102
|
+
if (typeof b.id !== 'string' || !/^[a-z0-9-]{1,64}$/.test(b.id) || ids.has(b.id)) return 'a board id is unique, a-z 0-9 -';
|
|
103
|
+
ids.add(b.id);
|
|
104
|
+
if (typeof b.name !== 'string' || !b.name.trim() || b.name.length > 80) return 'a board name is 1-80 characters';
|
|
105
|
+
for (const k of ['include', 'hidden'] as const) if (b[k] !== undefined && (!Array.isArray(b[k]) || (b[k] as unknown[]).length > 2000 || !(b[k] as unknown[]).every((x) => typeof x === 'string' && BOARD_KEY.test(x)))) return `${k}: frame and section names`;
|
|
106
|
+
if (b.placed !== undefined) {
|
|
107
|
+
const placed = b.placed as Record<string, unknown>;
|
|
108
|
+
if (!placed || typeof placed !== 'object' || Array.isArray(placed) || Object.keys(placed).length > 2000) return 'placed: a frame name to its place';
|
|
109
|
+
for (const [k, v] of Object.entries(placed)) if (!BOARD_KEY.test(k) || !v || typeof v !== 'object' || Object.keys(v).join() !== 'x,y' || !Object.values(v).every((n) => typeof n === 'number' && Number.isFinite(n))) return 'placed: { x, y } by frame name';
|
|
110
|
+
}
|
|
111
|
+
if (b.sections !== undefined) {
|
|
112
|
+
const sections = b.sections as Record<string, unknown>;
|
|
113
|
+
if (!sections || typeof sections !== 'object' || Array.isArray(sections) || Object.keys(sections).length > 500) return 'sections: a section name to its region';
|
|
114
|
+
for (const [k, v] of Object.entries(sections)) {
|
|
115
|
+
const r = v as Record<string, unknown> | null;
|
|
116
|
+
// a section is a pack's group (`<vendor>:section:<id>`) or one people drew (`user:<id>`), on the board's plane
|
|
117
|
+
if (!BOARD_KEY.test(k) || !(/^user:[a-z0-9]{1,32}$/.test(k) || /^[a-z0-9-]+:section:/.test(k)) || !r || typeof r !== 'object' || Object.keys(r).some((f) => !['x', 'y', 'w', 'h', 'name', 'gone'].includes(f))
|
|
118
|
+
|| !['x', 'y'].every((f) => typeof r[f] === 'number' && Math.abs(r[f] as number) <= 1e7) || !['w', 'h'].every((f) => typeof r[f] === 'number' && (r[f] as number) >= 0 && (r[f] as number) <= 1e6)
|
|
119
|
+
|| (r.gone !== true && ((r.w as number) < 100 || (r.h as number) < 100))
|
|
120
|
+
|| (r.name !== undefined && (typeof r.name !== 'string' || !r.name.trim() || r.name.length > 80)) || (r.gone !== undefined && r.gone !== true)) return 'sections: { x, y, w, h, name?, gone? } by section name';
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
if ((list[0] as SavedBoard).include !== undefined) return 'the first board is everything: it includes no list';
|
|
125
|
+
return list as SavedBoard[];
|
|
126
|
+
}
|
|
83
127
|
/** A named key as the World keeps it: the SHA-256 of the key, what it may do, and when it was made and last used. */
|
|
84
128
|
type HeldKey = { id: string; name: string; scope: 'read' | 'write'; hash: string; createdAt: string; lastUsedAt?: string;
|
|
85
129
|
/** who made it: the World's token, or a person's session (the subject a pass named, and its platform) */
|
|
@@ -119,7 +163,7 @@ export type MountedWorld = { name: string; served: string; root: string; readonl
|
|
|
119
163
|
/** A browser session a World keeps: its scope, when it ends, and the person a pass named, when one did. */
|
|
120
164
|
type HeldSession = { scope: 'read' | 'write'; until: number; /** opened by this machine's own page (localSession) */ local?: true; 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 };
|
|
121
165
|
|
|
122
|
-
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; /** the person signed in on this machine: its own page acts as them (DoorHost.localPerson) */ localPerson?: string; /** DoorHost.serveBranch */ serveBranch?: (branch: string) => Promise<void> } = {}): Promise<MountedWorld> {
|
|
166
|
+
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; /** DoorHost.sitesOrigin */ sitesOrigin?: (served: string) => string | null; /** served on this machine's loopback: its own pages need no token (DoorHost.localTrust) */ localTrust?: boolean; /** the person signed in on this machine: its own page acts as them (DoorHost.localPerson) */ localPerson?: string; /** DoorHost.serveBranch */ serveBranch?: (branch: string) => Promise<void> } = {}): Promise<MountedWorld> {
|
|
123
167
|
const worldRoot = resolve(opts.root ?? process.cwd());
|
|
124
168
|
const configRef = configRefFor(worldRoot, name);
|
|
125
169
|
if (loadWorldConfig(configRef, worldRoot).config.resources) {
|
|
@@ -147,6 +191,7 @@ export async function mountWorld(name: string, opts: { /** the platforms whose p
|
|
|
147
191
|
clockPath: () => clockFile(worldRoot, name),
|
|
148
192
|
...(opts.branches?.(served) ? { branches: opts.branches(served)! } : {}),
|
|
149
193
|
...(opts.browserOrigin ? { browserOrigin: () => opts.browserOrigin!(served), originOf: (other: string) => opts.browserOrigin!(other) } : {}),
|
|
194
|
+
...(opts.sitesOrigin ? { sitesOrigin: () => opts.sitesOrigin!(served) } : {}),
|
|
150
195
|
...(opts.localTrust ? { localTrust: () => true, ...(opts.localPerson ? { localPerson: () => opts.localPerson } : {}) } : {}),
|
|
151
196
|
...(opts.serveBranch ? { serveBranch: opts.serveBranch } : {}),
|
|
152
197
|
...(opts.passIssuers?.length ? { passIssuers: () => opts.passIssuers! } : {}),
|
|
@@ -298,6 +343,9 @@ export type DoorHost = {
|
|
|
298
343
|
* host gives each World one: its session lives only there (docs/contributing/architecture.md,
|
|
299
344
|
* "Viewing a World"). Null, or absent, on a host whose Worlds share one origin. */
|
|
300
345
|
browserOrigin?: () => string | null;
|
|
346
|
+
/** The origin under which this host routes the hostnames the World's twins answer, each at `<hostname>.<origin's host>`
|
|
347
|
+
* (siteOfHost); none where it routes no sites (behind a proxy, on a hosted World). */
|
|
348
|
+
sitesOrigin?: () => string | null;
|
|
301
349
|
/** The origin another World this host serves (a branch of this one) is reached at, where it has one of its own. */
|
|
302
350
|
originOf?: (served: string) => string | null;
|
|
303
351
|
/** This World is served on this machine's loopback, for the person at it: a page of this World, reached by a
|
|
@@ -706,6 +754,11 @@ export class WorldDoors {
|
|
|
706
754
|
if (location) { try { const to = new URL(location, `https://${host}`); if (to.hostname === host) out.set('location', `${to.pathname}${to.search}${to.hash}`); } catch { /* left as answered */ } }
|
|
707
755
|
return new Response(request.method === 'HEAD' ? null : answer.body, { status: answer.status, statusText: answer.statusText, headers: out });
|
|
708
756
|
}
|
|
757
|
+
// a person's browser is told in words (often the site is not there yet, or was not in a copy of the World as it was)
|
|
758
|
+
if ((request.headers.get('accept') ?? '').includes('text/html')) {
|
|
759
|
+
const shown = host.replace(/[&<>"']/g, (c) => `&#${c.charCodeAt(0)};`);
|
|
760
|
+
return new Response(`<!doctype html><meta charset="utf-8"><title>${shown}</title><body style="font:16px system-ui;margin:3em;color:#555"><p>Nothing is served at <b>${shown}</b> in this World.</p></body>`, { status: 404, headers: { 'content-type': 'text/html; charset=utf-8' } });
|
|
761
|
+
}
|
|
709
762
|
return Response.json({ error: `no twin of ${this.served} answers ${host}` }, { status: 404 });
|
|
710
763
|
}
|
|
711
764
|
|
|
@@ -829,9 +882,9 @@ export class WorldDoors {
|
|
|
829
882
|
headers.set('x-forwarded-host', url.host); headers.set('x-forwarded-proto', url.protocol.replace(':', ''));
|
|
830
883
|
// where this World shows the hostnames its twins answer, for a page that links one (twinSiteUrl): under its own
|
|
831
884
|
// origin on this machine (siteOfHost); a World without one shows none
|
|
832
|
-
const
|
|
833
|
-
const sites =
|
|
834
|
-
if (sites
|
|
885
|
+
const at = this.host.sitesOrigin?.() ?? null;
|
|
886
|
+
const sites = at ? new URL(at) : null;
|
|
887
|
+
if (sites) headers.set(TWIN_SITES_HEADER, `${sites.protocol}//{host}.${sites.host}`); else headers.delete(TWIN_SITES_HEADER);
|
|
835
888
|
const body = request.method === 'GET' || request.method === 'HEAD' ? undefined : await request.arrayBuffer();
|
|
836
889
|
const answer = await twin(new Request(`${url.origin}/${tail.join('/')}${url.search}`, { method: request.method, headers, ...(body === undefined ? {} : { body }), redirect: 'manual' }));
|
|
837
890
|
// an upgrade the twin answered (a tunnel's control or visitor socket) goes back as it is
|
|
@@ -1017,6 +1070,36 @@ export class WorldDoors {
|
|
|
1017
1070
|
return Response.json({ world: this.served, at: new Date(Date.parse(instant)).toISOString(), views });
|
|
1018
1071
|
}
|
|
1019
1072
|
if (kind === 'map' && !name && request.method === 'GET') return Response.json(await this.map());
|
|
1073
|
+
if (kind === 'board' && !name && request.method === 'GET') return Response.json(await this.board(url));
|
|
1074
|
+
if (kind === 'board' && name === 'changes' && !verb && request.method === 'GET') return await this.boardChanges(url);
|
|
1075
|
+
if (kind === 'board' && !name && request.method === 'PUT') {
|
|
1076
|
+
if (!/^application\/json\b/i.test(request.headers.get('content-type') ?? '')) return Response.json({ error: 'the board is sent as application/json' }, { status: 415 });
|
|
1077
|
+
if (Number(request.headers.get('content-length') ?? '0') > BOARD_BYTES) return Response.json({ error: `the board is at most ${BOARD_BYTES} bytes` }, { status: 413 });
|
|
1078
|
+
// read as it arrives, never past the cap (a chunked body names no length)
|
|
1079
|
+
const chunks: Uint8Array[] = []; let size = 0;
|
|
1080
|
+
const reader = request.body?.getReader();
|
|
1081
|
+
for (let part = await reader?.read(); part && !part.done; part = await reader!.read()) {
|
|
1082
|
+
size += part.value.byteLength;
|
|
1083
|
+
if (size > BOARD_BYTES) { await reader!.cancel(); return Response.json({ error: `the board is at most ${BOARD_BYTES} bytes` }, { status: 413 }); }
|
|
1084
|
+
chunks.push(part.value);
|
|
1085
|
+
}
|
|
1086
|
+
const text = Buffer.concat(chunks).toString('utf8');
|
|
1087
|
+
let body: unknown;
|
|
1088
|
+
try { body = JSON.parse(text); } catch { return Response.json({ error: 'the board is JSON' }, { status: 400 }); }
|
|
1089
|
+
// a save names the version it was made from: one made from a version another save has since replaced is refused,
|
|
1090
|
+
// with the boards as they now stand, never written over them
|
|
1091
|
+
return this.serialized('board', async () => {
|
|
1092
|
+
const held = this.savedBoards();
|
|
1093
|
+
const from = (body as { version?: unknown } | null)?.version;
|
|
1094
|
+
if (typeof from !== 'string') return Response.json({ error: 'a board save names the version it was made from (the version GET answered)', ...held }, { status: 409 });
|
|
1095
|
+
if (from !== held.version) return Response.json({ error: 'the board changed since this was made from it', ...held }, { status: 409 });
|
|
1096
|
+
const boards = boardsOf({ boards: (body as { boards?: unknown }).boards });
|
|
1097
|
+
if (typeof boards === 'string') return Response.json({ error: boards }, { status: 400 });
|
|
1098
|
+
const kept = `${JSON.stringify({ boards })}\n`;
|
|
1099
|
+
getActiveWorldStore().write(boardFile(this.worldRoot), kept);
|
|
1100
|
+
return Response.json({ world: this.served, boards, version: boardVersion(kept) });
|
|
1101
|
+
});
|
|
1102
|
+
}
|
|
1020
1103
|
if (kind === 'timeline' && !name && request.method === 'GET') return this.timeline(url);
|
|
1021
1104
|
if (kind === 'diff' && !name && request.method === 'GET') {
|
|
1022
1105
|
const delta = diffWorld(this.name, { root: this.worldRoot });
|
|
@@ -1500,6 +1583,82 @@ export class WorldDoors {
|
|
|
1500
1583
|
return newest;
|
|
1501
1584
|
}
|
|
1502
1585
|
|
|
1586
|
+
/** THE BOARD: every twin's frames as its pack lays them out (`GET /twin/board`), each where this World shows it (a
|
|
1587
|
+
* hostname a twin answers as a site at its own origin, a vendor page under the twin's place, or nowhere: a frame no
|
|
1588
|
+
* screen draws yet) and the changes not yet cut on the records it covers, counted by the request that made them, with
|
|
1589
|
+
* when the first was made; and the boards as this World's people arranged them. A frame's changes themselves are read
|
|
1590
|
+
* one frame at a time (`board/changes`). */
|
|
1591
|
+
private async board(url: URL): Promise<unknown> {
|
|
1592
|
+
const { frames, delta } = await this.boardFrames(url);
|
|
1593
|
+
// the changes are counted from the World's base: what was last cut or received, and when
|
|
1594
|
+
return { world: this.served, base: { id: delta.base.id, at: delta.base.createdAt ?? null }, frames: frames.map(({ frame, mine }) => ({ ...frame, changes: new Set(mine.map(actOf)).size, since: mine.map((c) => c.action.occurredAt).sort()[0] ?? null })), ...this.savedBoards() };
|
|
1595
|
+
}
|
|
1596
|
+
/** One frame's changes not yet cut, newest first: each the writes one request made, as words read them (the operation,
|
|
1597
|
+
* the record, its few naming fields), the frame named `<vendor>:<frame id>`. */
|
|
1598
|
+
private async boardChanges(url: URL): Promise<Response> {
|
|
1599
|
+
const key = url.searchParams.get('frame') ?? '';
|
|
1600
|
+
if (!key) return Response.json({ error: 'board/changes?frame=<vendor>:<frame id>' }, { status: 400 });
|
|
1601
|
+
const found = (await this.boardFrames(url)).frames.find(({ frame }) => `${frame.vendor}:${frame.id}` === key);
|
|
1602
|
+
if (!found) return Response.json({ error: `no frame ${key} on ${this.served}'s board` }, { status: 404 });
|
|
1603
|
+
const acts = new Map<string, { at: string; writes: number; entries: Array<{ op: string; operation?: string; subject: { type: string; id: string }; fields: Record<string, string> }> }>();
|
|
1604
|
+
for (const c of found.mine) {
|
|
1605
|
+
const act = acts.get(actOf(c)) ?? { at: c.action.occurredAt, writes: 0, entries: [] };
|
|
1606
|
+
if (c.action.occurredAt > act.at) act.at = c.action.occurredAt;
|
|
1607
|
+
act.writes += 1;
|
|
1608
|
+
const named = Object.fromEntries(['title', 'name', 'hostname', 'email', 'text', 'subject', 'summary', 'description'].flatMap((k) => { const v = (c.action.fields as Record<string, unknown> | undefined)?.[k]; return typeof v === 'string' ? [[k, v.slice(0, 120)]] : []; }));
|
|
1609
|
+
if (act.entries.length < 50) act.entries.push({ op: c.action.op, ...(c.action.operation ? { operation: c.action.operation } : {}), subject: c.action.subject, fields: named });
|
|
1610
|
+
acts.set(actOf(c), act);
|
|
1611
|
+
}
|
|
1612
|
+
const all = [...acts.values()].sort((a, b) => b.at.localeCompare(a.at));
|
|
1613
|
+
return Response.json({ frame: key, total: all.length, since: found.mine.map((c) => c.action.occurredAt).sort()[0] ?? null, acts: all.slice(0, 100) });
|
|
1614
|
+
}
|
|
1615
|
+
/** Every twin's frames, where each opens here, and each with the changes not yet cut that are its own. */
|
|
1616
|
+
private async boardFrames(url: URL): Promise<{ delta: ReturnType<typeof diffWorld>; frames: Array<{ frame: Record<string, unknown> & { vendor: string; id: string }; mine: Array<ReturnType<typeof diffWorld>['actions'][number]> }> }> {
|
|
1617
|
+
const claimed = new Map((await this.claimedHosts(url.origin)).map(({ vendor, hosts }) => [vendor, new Set(hosts)]));
|
|
1618
|
+
const routed = this.host.sitesOrigin?.() ?? null;
|
|
1619
|
+
const sites = routed ? new URL(routed) : null;
|
|
1620
|
+
const delta = diffWorld(this.name, { root: this.worldRoot });
|
|
1621
|
+
const received = new Set(listWorldChangesets({ root: this.worldRoot, world: this.name }).filter((l) => l.changeset.pushedBy).flatMap((l) => l.changeset.actions.map((a) => a.action.id)));
|
|
1622
|
+
const changes = delta.actions.filter((a) => !received.has(a.action.id));
|
|
1623
|
+
const frames: Array<{ frame: Record<string, unknown> & { vendor: string; id: string }; mine: typeof changes }> = [];
|
|
1624
|
+
for (const vendor of Object.keys(this.up().services)) {
|
|
1625
|
+
const twin = this.host.twinFetch(vendor);
|
|
1626
|
+
let listed: BoardFrame[] = [];
|
|
1627
|
+
try {
|
|
1628
|
+
const answer = twin ? await twin(new Request('http://world.local/twin/board', { headers: { [READ_ONLY_HEADER]: '1' } })) : null;
|
|
1629
|
+
listed = answer?.ok ? (((await answer.json()) as { frames?: BoardFrame[] }).frames ?? []) : [];
|
|
1630
|
+
} catch { listed = []; }
|
|
1631
|
+
// a pack that lays out no board yet keeps its vendor's home on it (its root workspace, else its first unparameterized
|
|
1632
|
+
// one), whose changes are all of the vendor's
|
|
1633
|
+
if (!listed.length) {
|
|
1634
|
+
const screens = await this.screensOf(vendor);
|
|
1635
|
+
const home = screens.find((w) => w.path.replace(/\/+$/, '') === '') ?? screens.find((w) => !/[{<]/.test(w.path));
|
|
1636
|
+
if (home) frames.push({ frame: { vendor, id: `home:${home.id}`, kind: 'home', title: vendor, section: { id: 'home', title: 'Home' }, url: home.path, at: { screen: home.path.startsWith('/') ? home.path : `/${home.path}` } }, mine: changes.filter((c) => c.service === vendor) });
|
|
1637
|
+
}
|
|
1638
|
+
for (const f of listed) {
|
|
1639
|
+
let at: { site: string } | { screen: string } | null = null;
|
|
1640
|
+
// why a frame opens nowhere: its twin draws no page for it yet, or this host routes no sites
|
|
1641
|
+
let unseen: 'not-drawn' | 'sites-not-routed' | undefined;
|
|
1642
|
+
try {
|
|
1643
|
+
const u = new URL(f.url);
|
|
1644
|
+
if (claimed.get(vendor)?.has(u.hostname.toLowerCase())) at = sites ? { site: `${sites.protocol}//${u.hostname.toLowerCase()}.${sites.host}${u.pathname}${u.search}` } : null;
|
|
1645
|
+
else if (f.drawn) at = { screen: `${u.pathname}${u.search}` };
|
|
1646
|
+
if (!at) unseen = claimed.get(vendor)?.has(u.hostname.toLowerCase()) ? 'sites-not-routed' : 'not-drawn';
|
|
1647
|
+
} catch { at = null; unseen = 'not-drawn'; }
|
|
1648
|
+
const covered = new Set(f.covers.map((c) => `${c.type}:${c.id}`));
|
|
1649
|
+
frames.push({ frame: { vendor, id: f.id, kind: f.kind, title: f.title, section: f.section, url: f.url, at, ...(unseen ? { unseen } : {}) }, mine: changes.filter((x) => x.service === vendor && covered.has(`${x.action.subject.type}:${x.action.subject.id}`)) });
|
|
1650
|
+
}
|
|
1651
|
+
}
|
|
1652
|
+
return { delta, frames };
|
|
1653
|
+
}
|
|
1654
|
+
/** The boards as the World keeps them, and the version a save names: the default board when none was ever kept. */
|
|
1655
|
+
private savedBoards(): { boards: SavedBoard[]; version: string } {
|
|
1656
|
+
const text = getActiveWorldStore().read(boardFile(this.worldRoot));
|
|
1657
|
+
let boards: SavedBoard[] = [{ id: 'everything', name: 'Everything' }];
|
|
1658
|
+
try { const kept = boardsOf(JSON.parse(text ?? 'null')); if (typeof kept !== 'string') boards = kept; } catch { /* never arranged */ }
|
|
1659
|
+
return { boards, version: boardVersion(text) };
|
|
1660
|
+
}
|
|
1661
|
+
|
|
1503
1662
|
/** THE MAP: every twin and what it holds, by resource type, and the workspaces it shows. */
|
|
1504
1663
|
private async map(): Promise<unknown> {
|
|
1505
1664
|
const twins: unknown[] = [];
|
package/src/world-view.ts
CHANGED
|
@@ -49,7 +49,9 @@ export async function serveWorldView(name: string, opts: { /** the person signed
|
|
|
49
49
|
// (the first World mounted is the World; every later one is a branch of it)
|
|
50
50
|
let base: string | null = null;
|
|
51
51
|
const browserOrigin = origins ? (served: string) => (proxied && (base === null || served === base) ? proxied.origin : port ? localWorldOrigin(served, port) : null) : undefined;
|
|
52
|
-
|
|
52
|
+
// sites are routed at each World's own *.localhost origin, never behind the proxy
|
|
53
|
+
const sitesOrigin = origins && !proxied ? (served: string) => (port ? localWorldOrigin(served, port) : null) : undefined;
|
|
54
|
+
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 } : {}), ...(sitesOrigin ? { sitesOrigin } : {}), ...(local ? { localTrust: true, ...(opts.localPerson ? { localPerson: opts.localPerson } : {}) } : {}) });
|
|
53
55
|
const branches: LocalBranches = new LocalBranches({ dir: join(root, stateDirName(), 'branches'), origin: () => loopback, worlds, mount: mountAt, ...(opts.announce ? { announce: opts.announce } : {}) });
|
|
54
56
|
// THE WORLD'S OWN BRANCHES, one served at a time: serving another stops this one's twins and starts that one's under the
|
|
55
57
|
// same served name (its tokens and sessions are the World's, so the page stays open); `.volter/current` follows, as
|
|
@@ -80,7 +82,7 @@ export async function serveWorldView(name: string, opts: { /** the person signed
|
|
|
80
82
|
if (branch === loadWorldConfig(join(root, stateDirName(), 'world.json'), root).config.id) rmSync(current, { force: true }); else writeFileSync(current, `${branch}\n`);
|
|
81
83
|
} finally { switching = false; }
|
|
82
84
|
};
|
|
83
|
-
const mountOpts = { root, branches: (served: string) => branches.doorsFor(served), ...(browserOrigin ? { browserOrigin } : {}), ...(local ? { localTrust: true, ...(opts.localPerson ? { localPerson: opts.localPerson } : {}) } : {}), serveBranch };
|
|
85
|
+
const mountOpts = { root, branches: (served: string) => branches.doorsFor(served), ...(browserOrigin ? { browserOrigin } : {}), ...(sitesOrigin ? { sitesOrigin } : {}), ...(local ? { localTrust: true, ...(opts.localPerson ? { localPerson: opts.localPerson } : {}) } : {}), serveBranch };
|
|
84
86
|
let world = await mountWorld(name, mountOpts);
|
|
85
87
|
base = world.served;
|
|
86
88
|
worlds.set(world.served, world);
|