@volter/world-runtime 3.0.10 → 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 +8 -2
- package/dist/src/served-world.js +62 -10
- package/package.json +4 -4
- package/src/served-world.ts +56 -11
|
@@ -358,9 +358,15 @@ export declare class WorldDoors {
|
|
|
358
358
|
private newestEntry;
|
|
359
359
|
/** THE BOARD: every twin's frames as its pack lays them out (`GET /twin/board`), each where this World shows it (a
|
|
360
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
|
|
362
|
-
* the boards as this World's people arranged them.
|
|
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`). */
|
|
363
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;
|
|
364
370
|
/** The boards as the World keeps them, and the version a save names: the default board when none was ever kept. */
|
|
365
371
|
private savedBoards;
|
|
366
372
|
/** THE MAP: every twin and what it holds, by resource type, and the workspaces it shows. */
|
package/dist/src/served-world.js
CHANGED
|
@@ -95,14 +95,16 @@ const BOARD_BYTES = 256 * 1024;
|
|
|
95
95
|
/** A board document from a request, or the reason it is refused: every field of its type, nothing else. */
|
|
96
96
|
/** A kept board document's version: its bytes' digest, or `none` before one is kept. */
|
|
97
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);
|
|
98
100
|
function boardsOf(body) {
|
|
99
101
|
const list = body?.boards;
|
|
100
102
|
if (!Array.isArray(list) || list.length < 1 || list.length > 50)
|
|
101
103
|
return 'boards: a list of 1-50 boards';
|
|
102
104
|
const ids = new Set();
|
|
103
105
|
for (const b of list) {
|
|
104
|
-
if (!b || typeof b !== 'object' || Object.keys(b).some((k) => !['id', 'name', 'include', 'hidden', 'placed'].includes(k)))
|
|
105
|
-
return 'a board has only id, name, include, hidden and
|
|
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';
|
|
106
108
|
if (typeof b.id !== 'string' || !/^[a-z0-9-]{1,64}$/.test(b.id) || ids.has(b.id))
|
|
107
109
|
return 'a board id is unique, a-z 0-9 -';
|
|
108
110
|
ids.add(b.id);
|
|
@@ -119,6 +121,20 @@ function boardsOf(body) {
|
|
|
119
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)))
|
|
120
122
|
return 'placed: { x, y } by frame name';
|
|
121
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
|
+
}
|
|
122
138
|
}
|
|
123
139
|
if (list[0].include !== undefined)
|
|
124
140
|
return 'the first board is everything: it includes no list';
|
|
@@ -833,6 +849,11 @@ export class WorldDoors {
|
|
|
833
849
|
}
|
|
834
850
|
return new Response(request.method === 'HEAD' ? null : answer.body, { status: answer.status, statusText: answer.statusText, headers: out });
|
|
835
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
|
+
}
|
|
836
857
|
return Response.json({ error: `no twin of ${this.served} answers ${host}` }, { status: 404 });
|
|
837
858
|
}
|
|
838
859
|
async handle(request) {
|
|
@@ -1221,6 +1242,8 @@ export class WorldDoors {
|
|
|
1221
1242
|
return Response.json(await this.map());
|
|
1222
1243
|
if (kind === 'board' && !name && request.method === 'GET')
|
|
1223
1244
|
return Response.json(await this.board(url));
|
|
1245
|
+
if (kind === 'board' && name === 'changes' && !verb && request.method === 'GET')
|
|
1246
|
+
return await this.boardChanges(url);
|
|
1224
1247
|
if (kind === 'board' && !name && request.method === 'PUT') {
|
|
1225
1248
|
if (!/^application\/json\b/i.test(request.headers.get('content-type') ?? ''))
|
|
1226
1249
|
return Response.json({ error: 'the board is sent as application/json' }, { status: 415 });
|
|
@@ -1878,9 +1901,39 @@ export class WorldDoors {
|
|
|
1878
1901
|
}
|
|
1879
1902
|
/** THE BOARD: every twin's frames as its pack lays them out (`GET /twin/board`), each where this World shows it (a
|
|
1880
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
|
|
1881
|
-
* screen draws yet) and the changes not yet cut on the records it covers, counted by the request that made them
|
|
1882
|
-
* the boards as this World's people arranged them.
|
|
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`). */
|
|
1883
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) {
|
|
1884
1937
|
const claimed = new Map((await this.claimedHosts(url.origin)).map(({ vendor, hosts }) => [vendor, new Set(hosts)]));
|
|
1885
1938
|
const routed = this.host.sitesOrigin?.() ?? null;
|
|
1886
1939
|
const sites = routed ? new URL(routed) : null;
|
|
@@ -1898,12 +1951,13 @@ export class WorldDoors {
|
|
|
1898
1951
|
catch {
|
|
1899
1952
|
listed = [];
|
|
1900
1953
|
}
|
|
1901
|
-
// a pack that lays out no board yet keeps its vendor's home on it
|
|
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
|
|
1902
1956
|
if (!listed.length) {
|
|
1903
1957
|
const screens = await this.screensOf(vendor);
|
|
1904
1958
|
const home = screens.find((w) => w.path.replace(/\/+$/, '') === '') ?? screens.find((w) => !/[{<]/.test(w.path));
|
|
1905
1959
|
if (home)
|
|
1906
|
-
frames.push({ 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}` },
|
|
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) });
|
|
1907
1961
|
}
|
|
1908
1962
|
for (const f of listed) {
|
|
1909
1963
|
let at = null;
|
|
@@ -1923,12 +1977,10 @@ export class WorldDoors {
|
|
|
1923
1977
|
unseen = 'not-drawn';
|
|
1924
1978
|
}
|
|
1925
1979
|
const covered = new Set(f.covers.map((c) => `${c.type}:${c.id}`));
|
|
1926
|
-
|
|
1927
|
-
const acts = new Set(changes.filter((c) => c.service === vendor && covered.has(`${c.action.subject.type}:${c.action.subject.id}`)).map((c) => (typeof c.action.correlationId === 'string' ? c.action.correlationId : undefined) ?? c.action.id));
|
|
1928
|
-
frames.push({ vendor, id: f.id, kind: f.kind, title: f.title, section: f.section, url: f.url, at, ...(unseen ? { unseen } : {}), changes: acts.size });
|
|
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}`)) });
|
|
1929
1981
|
}
|
|
1930
1982
|
}
|
|
1931
|
-
return {
|
|
1983
|
+
return { delta, frames };
|
|
1932
1984
|
}
|
|
1933
1985
|
/** The boards as the World keeps them, and the version a save names: the default board when none was ever kept. */
|
|
1934
1986
|
savedBoards() {
|
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
|
@@ -84,19 +84,21 @@ export function keysFile(worldRoot: string): string { return join(worldRoot, sta
|
|
|
84
84
|
export function boardFile(worldRoot: string): string { return join(worldRoot, stateDirName(), 'board.json'); }
|
|
85
85
|
/** A board as the World keeps it: a frame or a section is named `<vendor>:<frame id>` or `<vendor>:section:<section id>`.
|
|
86
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 }> };
|
|
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
88
|
const BOARD_KEY = /^[a-z0-9-]+:[^\s]{1,300}$/;
|
|
89
89
|
/** The largest board document a World keeps. */
|
|
90
90
|
const BOARD_BYTES = 256 * 1024;
|
|
91
91
|
/** A board document from a request, or the reason it is refused: every field of its type, nothing else. */
|
|
92
92
|
/** A kept board document's version: its bytes' digest, or `none` before one is kept. */
|
|
93
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);
|
|
94
96
|
function boardsOf(body: unknown): SavedBoard[] | string {
|
|
95
97
|
const list = (body as { boards?: unknown } | null)?.boards;
|
|
96
98
|
if (!Array.isArray(list) || list.length < 1 || list.length > 50) return 'boards: a list of 1-50 boards';
|
|
97
99
|
const ids = new Set<string>();
|
|
98
100
|
for (const b of list as Array<Record<string, unknown>>) {
|
|
99
|
-
if (!b || typeof b !== 'object' || Object.keys(b).some((k) => !['id', 'name', 'include', 'hidden', 'placed'].includes(k))) return 'a board has only id, name, include, hidden and
|
|
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';
|
|
100
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 -';
|
|
101
103
|
ids.add(b.id);
|
|
102
104
|
if (typeof b.name !== 'string' || !b.name.trim() || b.name.length > 80) return 'a board name is 1-80 characters';
|
|
@@ -106,6 +108,18 @@ function boardsOf(body: unknown): SavedBoard[] | string {
|
|
|
106
108
|
if (!placed || typeof placed !== 'object' || Array.isArray(placed) || Object.keys(placed).length > 2000) return 'placed: a frame name to its place';
|
|
107
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';
|
|
108
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
|
+
}
|
|
109
123
|
}
|
|
110
124
|
if ((list[0] as SavedBoard).include !== undefined) return 'the first board is everything: it includes no list';
|
|
111
125
|
return list as SavedBoard[];
|
|
@@ -740,6 +754,11 @@ export class WorldDoors {
|
|
|
740
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 */ } }
|
|
741
755
|
return new Response(request.method === 'HEAD' ? null : answer.body, { status: answer.status, statusText: answer.statusText, headers: out });
|
|
742
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
|
+
}
|
|
743
762
|
return Response.json({ error: `no twin of ${this.served} answers ${host}` }, { status: 404 });
|
|
744
763
|
}
|
|
745
764
|
|
|
@@ -1052,6 +1071,7 @@ export class WorldDoors {
|
|
|
1052
1071
|
}
|
|
1053
1072
|
if (kind === 'map' && !name && request.method === 'GET') return Response.json(await this.map());
|
|
1054
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);
|
|
1055
1075
|
if (kind === 'board' && !name && request.method === 'PUT') {
|
|
1056
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 });
|
|
1057
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 });
|
|
@@ -1565,16 +1585,42 @@ export class WorldDoors {
|
|
|
1565
1585
|
|
|
1566
1586
|
/** THE BOARD: every twin's frames as its pack lays them out (`GET /twin/board`), each where this World shows it (a
|
|
1567
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
|
|
1568
|
-
* screen draws yet) and the changes not yet cut on the records it covers, counted by the request that made them
|
|
1569
|
-
* the boards as this World's people arranged them.
|
|
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`). */
|
|
1570
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]> }> }> {
|
|
1571
1617
|
const claimed = new Map((await this.claimedHosts(url.origin)).map(({ vendor, hosts }) => [vendor, new Set(hosts)]));
|
|
1572
1618
|
const routed = this.host.sitesOrigin?.() ?? null;
|
|
1573
1619
|
const sites = routed ? new URL(routed) : null;
|
|
1574
1620
|
const delta = diffWorld(this.name, { root: this.worldRoot });
|
|
1575
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)));
|
|
1576
1622
|
const changes = delta.actions.filter((a) => !received.has(a.action.id));
|
|
1577
|
-
const frames: unknown
|
|
1623
|
+
const frames: Array<{ frame: Record<string, unknown> & { vendor: string; id: string }; mine: typeof changes }> = [];
|
|
1578
1624
|
for (const vendor of Object.keys(this.up().services)) {
|
|
1579
1625
|
const twin = this.host.twinFetch(vendor);
|
|
1580
1626
|
let listed: BoardFrame[] = [];
|
|
@@ -1582,11 +1628,12 @@ export class WorldDoors {
|
|
|
1582
1628
|
const answer = twin ? await twin(new Request('http://world.local/twin/board', { headers: { [READ_ONLY_HEADER]: '1' } })) : null;
|
|
1583
1629
|
listed = answer?.ok ? (((await answer.json()) as { frames?: BoardFrame[] }).frames ?? []) : [];
|
|
1584
1630
|
} catch { listed = []; }
|
|
1585
|
-
// a pack that lays out no board yet keeps its vendor's home on it
|
|
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
|
|
1586
1633
|
if (!listed.length) {
|
|
1587
1634
|
const screens = await this.screensOf(vendor);
|
|
1588
1635
|
const home = screens.find((w) => w.path.replace(/\/+$/, '') === '') ?? screens.find((w) => !/[{<]/.test(w.path));
|
|
1589
|
-
if (home) frames.push({ 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}` },
|
|
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) });
|
|
1590
1637
|
}
|
|
1591
1638
|
for (const f of listed) {
|
|
1592
1639
|
let at: { site: string } | { screen: string } | null = null;
|
|
@@ -1599,12 +1646,10 @@ export class WorldDoors {
|
|
|
1599
1646
|
if (!at) unseen = claimed.get(vendor)?.has(u.hostname.toLowerCase()) ? 'sites-not-routed' : 'not-drawn';
|
|
1600
1647
|
} catch { at = null; unseen = 'not-drawn'; }
|
|
1601
1648
|
const covered = new Set(f.covers.map((c) => `${c.type}:${c.id}`));
|
|
1602
|
-
|
|
1603
|
-
const acts = new Set(changes.filter((c) => c.service === vendor && covered.has(`${c.action.subject.type}:${c.action.subject.id}`)).map((c) => (typeof c.action.correlationId === 'string' ? c.action.correlationId : undefined) ?? c.action.id));
|
|
1604
|
-
frames.push({ vendor, id: f.id, kind: f.kind, title: f.title, section: f.section, url: f.url, at, ...(unseen ? { unseen } : {}), changes: acts.size });
|
|
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}`)) });
|
|
1605
1650
|
}
|
|
1606
1651
|
}
|
|
1607
|
-
return {
|
|
1652
|
+
return { delta, frames };
|
|
1608
1653
|
}
|
|
1609
1654
|
/** The boards as the World keeps them, and the version a save names: the default board when none was ever kept. */
|
|
1610
1655
|
private savedBoards(): { boards: SavedBoard[]; version: string } {
|