@volter/world-runtime 2.0.31 → 2.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/cli.js +3 -3
- package/dist/src/served-world.d.ts +3 -2
- package/dist/src/served-world.js +33 -10
- package/dist/src/tail.d.ts +3 -3
- package/dist/src/tail.js +16 -3
- package/package.json +5 -5
- package/src/cli.ts +3 -3
- package/src/served-world.ts +30 -9
- package/src/tail.ts +14 -6
package/dist/src/cli.js
CHANGED
|
@@ -111,10 +111,10 @@ function printHelp() {
|
|
|
111
111
|
# merged, occurredAt-ordered feed of the twins' action ledgers — one
|
|
112
112
|
# \`HH:MM:SS.mmm <service> <operation> <subjectId>\` line per action;
|
|
113
113
|
# follows until Ctrl-C (--no-follow: dump and exit; --json: raw JSONL;
|
|
114
|
-
# --requests: also merge the
|
|
114
|
+
# --requests: also merge the request journals — reads/404s,
|
|
115
115
|
# plus WHICH credential arrived in WHICH header/query param, named +
|
|
116
|
-
# fingerprinted, never the value —
|
|
117
|
-
# VOLTER_TWIN_REQUEST_JOURNAL=
|
|
116
|
+
# fingerprinted, never the value — kept by every twin and link
|
|
117
|
+
# unless VOLTER_TWIN_REQUEST_JOURNAL=0)
|
|
118
118
|
volter-world seed <world> [--entry <file>] [--cwd <dir>] [--json] [--root <repo>]
|
|
119
119
|
# pull from the placeholder remote: the seed entry runs with every twin's
|
|
120
120
|
# window open, so the defaults land as observed state (diff empty, nothing pending)
|
|
@@ -314,10 +314,11 @@ export declare class WorldDoors {
|
|
|
314
314
|
origin: string;
|
|
315
315
|
}>;
|
|
316
316
|
private link;
|
|
317
|
-
/** Forward to the link's origin: the path under the link, the caller's headers with
|
|
318
|
-
*
|
|
317
|
+
/** Forward to the link's origin: the path under the link, the caller's headers with every World
|
|
318
|
+
* credential removed and the sealed credential's headers replacing any of the same name. The vendor's
|
|
319
319
|
* answer (status, headers, body) is streamed back as it arrives. */
|
|
320
320
|
private forward;
|
|
321
|
+
private forwardSealed;
|
|
321
322
|
/** The instant of the twin's last completed refresh (its marker's `observedAt`), when it has a root. */
|
|
322
323
|
private observedAt;
|
|
323
324
|
/** The world's own doors under `/-/<org>/<world>/`. */
|
package/dist/src/served-world.js
CHANGED
|
@@ -26,7 +26,7 @@ import { getActiveWorldStore, withAncestryLock, 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, readFileSync, writeFileSync, rmSync } from 'node:fs';
|
|
29
|
-
import { assertSafeChangesetName, blobDigest, destinationOf, openSealedAt, getActiveBlobStore, loadCheck, mirrorShellUnder, readTwinRequestJournal, serveHttp, TWIN_PREFIX_HEADER, validateRemoteOrigin } from '@volter/world-core';
|
|
29
|
+
import { assertSafeChangesetName, blobDigest, destinationOf, openSealedAt, getActiveBlobStore, journalTwinRequest, loadCheck, mirrorShellUnder, readTwinRequestJournal, serveHttp, twinRequestCredentials, TWIN_PREFIX_HEADER, validateRemoteOrigin } from '@volter/world-core';
|
|
30
30
|
import { APP_READ_ENDPOINT_ENV } from "./init.js";
|
|
31
31
|
import { grantOf, presentedSecrets, sameSecret, spentPasses, verifyPass } from '@volter/world-access';
|
|
32
32
|
import { createHash } from 'node:crypto';
|
|
@@ -278,7 +278,8 @@ export async function serveWorld(name, opts = {}) {
|
|
|
278
278
|
// doors answer 503.
|
|
279
279
|
let consoleOrigin = null;
|
|
280
280
|
// a pushed blob (a release video) is one PUT: bodies up to 1 GiB, past a vendor's own media limits
|
|
281
|
-
|
|
281
|
+
// a front for the whole World: its twins and links keep their own journals (architecture: a multiplexing front journals nothing)
|
|
282
|
+
const server = await serveHttp({ hostname: host, port: opts.port ?? 0, maxRequestBodySize: 1024 * 1024 * 1024, twinRequestJournal: false, fetch: (request) => consoleRedirect(request, consoleOrigin) ?? mounted.handle(request) });
|
|
282
283
|
const url = `http://${host}:${server.port}`;
|
|
283
284
|
try {
|
|
284
285
|
await mounted.boot(url);
|
|
@@ -850,7 +851,7 @@ export class WorldDoors {
|
|
|
850
851
|
return foreign;
|
|
851
852
|
const reads = request.method === 'GET' || request.method === 'HEAD';
|
|
852
853
|
// A LINK: a path of this World that forwards to the vendor itself with the credential sealed beside
|
|
853
|
-
// the World (the World holds the key; its callers hold the World's token).
|
|
854
|
+
// the World (the World holds the key; its callers hold the World's token). Journaled as a twin's request is.
|
|
854
855
|
// a person's browser opening a page at the twin's place (a navigation, or the frame the console shows it in)
|
|
855
856
|
const page = request.method === 'GET' && ['document', 'iframe'].includes(request.headers.get('sec-fetch-dest') ?? '');
|
|
856
857
|
const link = this.link(vendor);
|
|
@@ -934,10 +935,34 @@ export class WorldDoors {
|
|
|
934
935
|
return held === null ? {} : JSON.parse(held);
|
|
935
936
|
}
|
|
936
937
|
link(name) { return this.links()[name] ?? null; }
|
|
937
|
-
/** Forward to the link's origin: the path under the link, the caller's headers with
|
|
938
|
-
*
|
|
938
|
+
/** Forward to the link's origin: the path under the link, the caller's headers with every World
|
|
939
|
+
* credential removed and the sealed credential's headers replacing any of the same name. The vendor's
|
|
939
940
|
* answer (status, headers, body) is streamed back as it arrives. */
|
|
940
941
|
async forward(request, url, rest, name, origin) {
|
|
942
|
+
// what leaves for the vendor before the seal is applied: no World token in any header it can arrive
|
|
943
|
+
// under (an SDK's own `Authorization: Bearer` included), no cookie, no routing headers of this World
|
|
944
|
+
const caller = new Headers(request.headers);
|
|
945
|
+
for (const h of [TOKEN_HEADER, TWINS_KEY_HEADER, TWIN_PREFIX_HEADER, 'host', 'x-forwarded-host', 'x-forwarded-proto', 'cookie'])
|
|
946
|
+
caller.delete(h);
|
|
947
|
+
// a secret this World minted (`tok_…`, live, revoked or expired) or one that opens it never leaves
|
|
948
|
+
const authorization = caller.get('authorization');
|
|
949
|
+
const offered = authorization ? presentedSecrets(new Headers({ authorization }), { token: TOKEN_HEADER, twinsKey: TWINS_KEY_HEADER }) : [];
|
|
950
|
+
if (offered.some((p) => p.startsWith('tok_')) || (offered.length && grantOf(offered, null, this.issued())))
|
|
951
|
+
caller.delete('authorization');
|
|
952
|
+
// an opened seal keeps the audit a twin keeps: the same line in the same journal under the link's
|
|
953
|
+
// name, with the credential shape of what the caller sent on
|
|
954
|
+
const startedAt = Date.now();
|
|
955
|
+
let status = 500;
|
|
956
|
+
try {
|
|
957
|
+
const answer = await this.forwardSealed(request, caller, url, rest, name, origin);
|
|
958
|
+
status = answer.status;
|
|
959
|
+
return answer;
|
|
960
|
+
}
|
|
961
|
+
finally {
|
|
962
|
+
journalTwinRequest(name, { method: request.method, path: `/${rest}`, status, ms: Date.now() - startedAt, credentials: twinRequestCredentials(caller, url) }, this.controlRoot(name));
|
|
963
|
+
}
|
|
964
|
+
}
|
|
965
|
+
async forwardSealed(request, caller, url, rest, name, origin) {
|
|
941
966
|
const base = validateRemoteOrigin(origin);
|
|
942
967
|
// opened for this link's origin only: a credential sealed for another destination, or for none, refuses
|
|
943
968
|
let credential;
|
|
@@ -952,9 +977,7 @@ export class WorldDoors {
|
|
|
952
977
|
const target = new URL(`${base.origin}/${rest}${url.search}`);
|
|
953
978
|
if (target.origin !== base.origin)
|
|
954
979
|
return Response.json({ error: 'the path left the link origin' }, { status: 400 });
|
|
955
|
-
const headers = new Headers(
|
|
956
|
-
for (const h of [TOKEN_HEADER, TWINS_KEY_HEADER, TWIN_PREFIX_HEADER, 'host', 'x-forwarded-host', 'x-forwarded-proto', 'cookie'])
|
|
957
|
-
headers.delete(h);
|
|
980
|
+
const headers = new Headers(caller);
|
|
958
981
|
for (const [k, v] of Object.entries(credential.headers))
|
|
959
982
|
headers.set(k, v);
|
|
960
983
|
const body = request.method === 'GET' || request.method === 'HEAD' ? undefined : await request.arrayBuffer();
|
|
@@ -1300,11 +1323,11 @@ export class WorldDoors {
|
|
|
1300
1323
|
}
|
|
1301
1324
|
if (kind === 'requests' && !name && request.method === 'GET') {
|
|
1302
1325
|
// the world's request report (layer 8: Supabase's API report): the kernel's request journal per twin, summed over a period —
|
|
1303
|
-
// requests by twin and status class, the response time's p50/p95, the top routes
|
|
1326
|
+
// requests by twin (and by link, journaled the same way) and status class, the response time's p50/p95, the top routes
|
|
1304
1327
|
const sinceMs = Number(url.searchParams.get('hours') ?? '24') * 3_600_000;
|
|
1305
1328
|
const since = Date.now() - sinceMs;
|
|
1306
1329
|
const twins = [];
|
|
1307
|
-
for (const vendor of Object.keys(this.up().services)) {
|
|
1330
|
+
for (const vendor of [...new Set([...Object.keys(this.up().services), ...Object.keys(this.links())])]) {
|
|
1308
1331
|
const entries = readTwinRequestJournal(vendor, this.controlRoot(vendor)).filter((e) => !e.at || Date.parse(e.at) >= since);
|
|
1309
1332
|
const byStatus = {};
|
|
1310
1333
|
const routes = new Map();
|
package/dist/src/tail.d.ts
CHANGED
|
@@ -6,9 +6,9 @@ export type TailWorldOptions = {
|
|
|
6
6
|
follow?: boolean;
|
|
7
7
|
/** Raw JSONL passthrough with the world service id injected as `service` (default: human lines). */
|
|
8
8
|
json?: boolean;
|
|
9
|
-
/** Also merge each service's
|
|
10
|
-
*
|
|
11
|
-
* status}` rows;
|
|
9
|
+
/** Also merge each service's REQUEST journal into the feed (`requests.jsonl`, kept by every
|
|
10
|
+
* twin and link unless VOLTER_TWIN_REQUEST_JOURNAL=0 — shape-only `{at, method, path,
|
|
11
|
+
* status}` rows; world-core/src/serve.ts). Reads are otherwise invisible here: an
|
|
12
12
|
* empty-catalog GET that 404s leaves no trace in an actions-only tail. */
|
|
13
13
|
requests?: boolean;
|
|
14
14
|
/** Poll interval while following (ledger appends are cross-process, so this polls rather than
|
package/dist/src/tail.js
CHANGED
|
@@ -6,7 +6,7 @@
|
|
|
6
6
|
// this just watches what the twins already record. A ledger dir may not exist until a service
|
|
7
7
|
// records its first action, so discovery re-runs on every poll and a missing ledger is a
|
|
8
8
|
// notice, never an error.
|
|
9
|
-
import { closeSync, existsSync, openSync, readSync, readdirSync, statSync } from 'node:fs';
|
|
9
|
+
import { closeSync, existsSync, openSync, readFileSync, readSync, readdirSync, statSync } from 'node:fs';
|
|
10
10
|
import { join } from 'node:path';
|
|
11
11
|
import { stateDirName } from '@volter/world-core';
|
|
12
12
|
import { statusWorld } from "./runtime.js";
|
|
@@ -22,6 +22,15 @@ function serviceLedgers(dataDir, service, file = 'actions.jsonl') {
|
|
|
22
22
|
.map((entry) => join(stateRoot, entry.name, file))
|
|
23
23
|
.sort();
|
|
24
24
|
}
|
|
25
|
+
/** The World's links by name (`<root>/<state-dir>/links.json`, served-world.ts). */
|
|
26
|
+
function linkNames(root) {
|
|
27
|
+
try {
|
|
28
|
+
return Object.keys(JSON.parse(readFileSync(join(root, stateDirName(), 'links.json'), 'utf8')));
|
|
29
|
+
}
|
|
30
|
+
catch {
|
|
31
|
+
return [];
|
|
32
|
+
}
|
|
33
|
+
}
|
|
25
34
|
/** Read the complete lines appended to `path` since the recorded offset, advancing the offset
|
|
26
35
|
* only past the last newline — a half-written trailing line stays unconsumed until the writer
|
|
27
36
|
* finishes it, so a poll can never emit a torn row. */
|
|
@@ -105,9 +114,11 @@ function sleep(ms, signal) {
|
|
|
105
114
|
export async function tailWorldActions(name, options = {}) {
|
|
106
115
|
const root = options.root ?? process.cwd();
|
|
107
116
|
const instance = statusWorld(name, root);
|
|
108
|
-
|
|
117
|
+
// a link (an opened seal) journals its requests as a twin does, under its own name
|
|
118
|
+
const links = linkNames(root);
|
|
119
|
+
const known = [...new Set([...Object.keys(instance.services), ...links])];
|
|
109
120
|
for (const service of options.services ?? []) {
|
|
110
|
-
if (!
|
|
121
|
+
if (!known.includes(service)) {
|
|
111
122
|
throw new Error(`World ${name} has no service "${service}"; it has: ${known.join(', ')}`);
|
|
112
123
|
}
|
|
113
124
|
}
|
|
@@ -143,6 +154,8 @@ export async function tailWorldActions(name, options = {}) {
|
|
|
143
154
|
};
|
|
144
155
|
emit(collect());
|
|
145
156
|
for (const service of services) {
|
|
157
|
+
if (links.includes(service) && !instance.services[service])
|
|
158
|
+
continue; // a link records requests, never actions
|
|
146
159
|
if (serviceLedgers(instance.dirs.data, service).length === 0) {
|
|
147
160
|
notice(`World ${name}: service "${service}" has no action ledger yet${follow
|
|
148
161
|
? ' — watching; drive the app (or a vendor SDK) through the world to record actions'
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@volter/world-runtime",
|
|
3
|
-
"version": "2.0.
|
|
3
|
+
"version": "2.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",
|
|
@@ -67,14 +67,14 @@
|
|
|
67
67
|
"node": ">=22.3"
|
|
68
68
|
},
|
|
69
69
|
"peerDependencies": {
|
|
70
|
-
"@volter/world-core": "2.0.
|
|
71
|
-
"@volter/world-console": "2.0.
|
|
70
|
+
"@volter/world-core": "2.0.19",
|
|
71
|
+
"@volter/world-console": "2.0.11"
|
|
72
72
|
},
|
|
73
73
|
"dependencies": {
|
|
74
74
|
"@electric-sql/pglite": "0.5.8",
|
|
75
75
|
"@electric-sql/pglite-pgvector": "0.0.9",
|
|
76
76
|
"@volter/world-access": "2.0.2",
|
|
77
|
-
"@volter/world-core": "2.0.
|
|
77
|
+
"@volter/world-core": "2.0.19",
|
|
78
78
|
"pg-gateway": "0.3.0-beta.4",
|
|
79
79
|
"smol-toml": "^1.8.0"
|
|
80
80
|
},
|
|
@@ -84,6 +84,6 @@
|
|
|
84
84
|
}
|
|
85
85
|
},
|
|
86
86
|
"optionalDependencies": {
|
|
87
|
-
"@volter/twin-mongodb": "0.1.
|
|
87
|
+
"@volter/twin-mongodb": "0.1.19"
|
|
88
88
|
}
|
|
89
89
|
}
|
package/src/cli.ts
CHANGED
|
@@ -112,10 +112,10 @@ function printHelp(): void {
|
|
|
112
112
|
# merged, occurredAt-ordered feed of the twins' action ledgers — one
|
|
113
113
|
# \`HH:MM:SS.mmm <service> <operation> <subjectId>\` line per action;
|
|
114
114
|
# follows until Ctrl-C (--no-follow: dump and exit; --json: raw JSONL;
|
|
115
|
-
# --requests: also merge the
|
|
115
|
+
# --requests: also merge the request journals — reads/404s,
|
|
116
116
|
# plus WHICH credential arrived in WHICH header/query param, named +
|
|
117
|
-
# fingerprinted, never the value —
|
|
118
|
-
# VOLTER_TWIN_REQUEST_JOURNAL=
|
|
117
|
+
# fingerprinted, never the value — kept by every twin and link
|
|
118
|
+
# unless VOLTER_TWIN_REQUEST_JOURNAL=0)
|
|
119
119
|
volter-world seed <world> [--entry <file>] [--cwd <dir>] [--json] [--root <repo>]
|
|
120
120
|
# pull from the placeholder remote: the seed entry runs with every twin's
|
|
121
121
|
# window open, so the defaults land as observed state (diff empty, nothing pending)
|
package/src/served-world.ts
CHANGED
|
@@ -26,7 +26,7 @@ import { getActiveWorldStore, withAncestryLock, 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 { assertSafeChangesetName, blobDigest, destinationOf, openSealedAt, getActiveBlobStore, loadCheck, mirrorShellUnder, readTwinRequestJournal, serveHttp, TWIN_PREFIX_HEADER, validateRemoteOrigin, type SealedCredential, type TwinStreamConnection, type TwinStreamSink } from '@volter/world-core';
|
|
29
|
+
import { assertSafeChangesetName, blobDigest, destinationOf, openSealedAt, getActiveBlobStore, journalTwinRequest, loadCheck, mirrorShellUnder, readTwinRequestJournal, serveHttp, twinRequestCredentials, TWIN_PREFIX_HEADER, validateRemoteOrigin, type SealedCredential, type TwinStreamConnection, type TwinStreamSink } from '@volter/world-core';
|
|
30
30
|
import { APP_READ_ENDPOINT_ENV } from './init.ts';
|
|
31
31
|
import { grantOf, presentedSecrets, sameSecret, spentPasses, verifyPass, type TrustedIssuer, type WorldCredentials } from '@volter/world-access';
|
|
32
32
|
import { createHash } from 'node:crypto';
|
|
@@ -252,7 +252,8 @@ export async function serveWorld(name: string, opts: { root?: string; port?: num
|
|
|
252
252
|
// doors answer 503.
|
|
253
253
|
let consoleOrigin: string | null = null;
|
|
254
254
|
// a pushed blob (a release video) is one PUT: bodies up to 1 GiB, past a vendor's own media limits
|
|
255
|
-
|
|
255
|
+
// a front for the whole World: its twins and links keep their own journals (architecture: a multiplexing front journals nothing)
|
|
256
|
+
const server = await serveHttp({ hostname: host, port: opts.port ?? 0, maxRequestBodySize: 1024 * 1024 * 1024, twinRequestJournal: false, fetch: (request) => consoleRedirect(request, consoleOrigin) ?? mounted.handle(request) });
|
|
256
257
|
const url = `http://${host}:${server.port}`;
|
|
257
258
|
try { await mounted.boot(url); } catch (error) { await server.stop(true); throw error; }
|
|
258
259
|
// the console, when it is installed beside the World, on an origin of its own (console-apart.ts)
|
|
@@ -765,7 +766,7 @@ export class WorldDoors {
|
|
|
765
766
|
if (foreign) return foreign;
|
|
766
767
|
const reads = request.method === 'GET' || request.method === 'HEAD';
|
|
767
768
|
// A LINK: a path of this World that forwards to the vendor itself with the credential sealed beside
|
|
768
|
-
// the World (the World holds the key; its callers hold the World's token).
|
|
769
|
+
// the World (the World holds the key; its callers hold the World's token). Journaled as a twin's request is.
|
|
769
770
|
// a person's browser opening a page at the twin's place (a navigation, or the frame the console shows it in)
|
|
770
771
|
const page = request.method === 'GET' && ['document', 'iframe'].includes(request.headers.get('sec-fetch-dest') ?? '');
|
|
771
772
|
const link = this.link(vendor!);
|
|
@@ -835,10 +836,31 @@ export class WorldDoors {
|
|
|
835
836
|
}
|
|
836
837
|
private link(name: string): { origin: string } | null { return this.links()[name] ?? null; }
|
|
837
838
|
|
|
838
|
-
/** Forward to the link's origin: the path under the link, the caller's headers with
|
|
839
|
-
*
|
|
839
|
+
/** Forward to the link's origin: the path under the link, the caller's headers with every World
|
|
840
|
+
* credential removed and the sealed credential's headers replacing any of the same name. The vendor's
|
|
840
841
|
* answer (status, headers, body) is streamed back as it arrives. */
|
|
841
842
|
private async forward(request: Request, url: URL, rest: string, name: string, origin: string): Promise<Response> {
|
|
843
|
+
// what leaves for the vendor before the seal is applied: no World token in any header it can arrive
|
|
844
|
+
// under (an SDK's own `Authorization: Bearer` included), no cookie, no routing headers of this World
|
|
845
|
+
const caller = new Headers(request.headers);
|
|
846
|
+
for (const h of [TOKEN_HEADER, TWINS_KEY_HEADER, TWIN_PREFIX_HEADER, 'host', 'x-forwarded-host', 'x-forwarded-proto', 'cookie']) caller.delete(h);
|
|
847
|
+
// a secret this World minted (`tok_…`, live, revoked or expired) or one that opens it never leaves
|
|
848
|
+
const authorization = caller.get('authorization');
|
|
849
|
+
const offered = authorization ? presentedSecrets(new Headers({ authorization }), { token: TOKEN_HEADER, twinsKey: TWINS_KEY_HEADER }) : [];
|
|
850
|
+
if (offered.some((p) => p.startsWith('tok_')) || (offered.length && grantOf(offered, null, this.issued()))) caller.delete('authorization');
|
|
851
|
+
// an opened seal keeps the audit a twin keeps: the same line in the same journal under the link's
|
|
852
|
+
// name, with the credential shape of what the caller sent on
|
|
853
|
+
const startedAt = Date.now(); let status = 500;
|
|
854
|
+
try {
|
|
855
|
+
const answer = await this.forwardSealed(request, caller, url, rest, name, origin);
|
|
856
|
+
status = answer.status;
|
|
857
|
+
return answer;
|
|
858
|
+
} finally {
|
|
859
|
+
journalTwinRequest(name, { method: request.method, path: `/${rest}`, status, ms: Date.now() - startedAt, credentials: twinRequestCredentials(caller, url) }, this.controlRoot(name));
|
|
860
|
+
}
|
|
861
|
+
}
|
|
862
|
+
|
|
863
|
+
private async forwardSealed(request: Request, caller: Headers, url: URL, rest: string, name: string, origin: string): Promise<Response> {
|
|
842
864
|
const base = validateRemoteOrigin(origin);
|
|
843
865
|
// opened for this link's origin only: a credential sealed for another destination, or for none, refuses
|
|
844
866
|
let credential: Awaited<ReturnType<typeof openSealedAt>>;
|
|
@@ -846,8 +868,7 @@ export class WorldDoors {
|
|
|
846
868
|
if (credential === null) return Response.json({ error: `the ${name} link has no credential sealed` }, { status: 409 });
|
|
847
869
|
const target = new URL(`${base.origin}/${rest}${url.search}`);
|
|
848
870
|
if (target.origin !== base.origin) return Response.json({ error: 'the path left the link origin' }, { status: 400 });
|
|
849
|
-
const headers = new Headers(
|
|
850
|
-
for (const h of [TOKEN_HEADER, TWINS_KEY_HEADER, TWIN_PREFIX_HEADER, 'host', 'x-forwarded-host', 'x-forwarded-proto', 'cookie']) headers.delete(h);
|
|
871
|
+
const headers = new Headers(caller);
|
|
851
872
|
for (const [k, v] of Object.entries(credential.headers)) headers.set(k, v);
|
|
852
873
|
const body = request.method === 'GET' || request.method === 'HEAD' ? undefined : await request.arrayBuffer();
|
|
853
874
|
const controller = new AbortController();
|
|
@@ -1120,10 +1141,10 @@ export class WorldDoors {
|
|
|
1120
1141
|
}
|
|
1121
1142
|
if (kind === 'requests' && !name && request.method === 'GET') {
|
|
1122
1143
|
// the world's request report (layer 8: Supabase's API report): the kernel's request journal per twin, summed over a period —
|
|
1123
|
-
// requests by twin and status class, the response time's p50/p95, the top routes
|
|
1144
|
+
// requests by twin (and by link, journaled the same way) and status class, the response time's p50/p95, the top routes
|
|
1124
1145
|
const sinceMs = Number(url.searchParams.get('hours') ?? '24') * 3_600_000; const since = Date.now() - sinceMs;
|
|
1125
1146
|
const twins: Array<{ twin: string; requests: number; byStatus: Record<string, number>; p50Ms: number | null; p95Ms: number | null; topRoutes: Array<{ route: string; requests: number }> }> = [];
|
|
1126
|
-
for (const vendor of Object.keys(this.up().services)) {
|
|
1147
|
+
for (const vendor of [...new Set([...Object.keys(this.up().services), ...Object.keys(this.links())])]) {
|
|
1127
1148
|
const entries = readTwinRequestJournal(vendor, this.controlRoot(vendor)).filter((e) => !e.at || Date.parse(e.at) >= since);
|
|
1128
1149
|
const byStatus: Record<string, number> = {}; const routes = new Map<string, number>(); const times: number[] = [];
|
|
1129
1150
|
for (const e of entries) { const cls = `${String(e.status)[0]}xx`; byStatus[cls] = (byStatus[cls] ?? 0) + 1; const route = `${e.method} ${e.path.replace(/\/[0-9]+(?=\/|$)/g, '/{id}')}`; routes.set(route, (routes.get(route) ?? 0) + 1); if (typeof e.ms === 'number') times.push(e.ms); }
|
package/src/tail.ts
CHANGED
|
@@ -6,7 +6,7 @@
|
|
|
6
6
|
// this just watches what the twins already record. A ledger dir may not exist until a service
|
|
7
7
|
// records its first action, so discovery re-runs on every poll and a missing ledger is a
|
|
8
8
|
// notice, never an error.
|
|
9
|
-
import { closeSync, existsSync, openSync, readSync, readdirSync, statSync } from 'node:fs';
|
|
9
|
+
import { closeSync, existsSync, openSync, readFileSync, readSync, readdirSync, statSync } from 'node:fs';
|
|
10
10
|
import { join } from 'node:path';
|
|
11
11
|
import { stateDirName } from '@volter/world-core';
|
|
12
12
|
import { statusWorld } from './runtime.ts';
|
|
@@ -40,9 +40,9 @@ export type TailWorldOptions = {
|
|
|
40
40
|
follow?: boolean;
|
|
41
41
|
/** Raw JSONL passthrough with the world service id injected as `service` (default: human lines). */
|
|
42
42
|
json?: boolean;
|
|
43
|
-
/** Also merge each service's
|
|
44
|
-
*
|
|
45
|
-
* status}` rows;
|
|
43
|
+
/** Also merge each service's REQUEST journal into the feed (`requests.jsonl`, kept by every
|
|
44
|
+
* twin and link unless VOLTER_TWIN_REQUEST_JOURNAL=0 — shape-only `{at, method, path,
|
|
45
|
+
* status}` rows; world-core/src/serve.ts). Reads are otherwise invisible here: an
|
|
46
46
|
* empty-catalog GET that 404s leaves no trace in an actions-only tail. */
|
|
47
47
|
requests?: boolean;
|
|
48
48
|
/** Poll interval while following (ledger appends are cross-process, so this polls rather than
|
|
@@ -68,6 +68,11 @@ function serviceLedgers(dataDir: string, service: string, file: 'actions.jsonl'
|
|
|
68
68
|
.sort();
|
|
69
69
|
}
|
|
70
70
|
|
|
71
|
+
/** The World's links by name (`<root>/<state-dir>/links.json`, served-world.ts). */
|
|
72
|
+
function linkNames(root: string): string[] {
|
|
73
|
+
try { return Object.keys(JSON.parse(readFileSync(join(root, stateDirName(), 'links.json'), 'utf8')) as Record<string, unknown>); } catch { return []; }
|
|
74
|
+
}
|
|
75
|
+
|
|
71
76
|
/** Read the complete lines appended to `path` since the recorded offset, advancing the offset
|
|
72
77
|
* only past the last newline — a half-written trailing line stays unconsumed until the writer
|
|
73
78
|
* finishes it, so a poll can never emit a torn row. */
|
|
@@ -150,9 +155,11 @@ function sleep(ms: number, signal?: AbortSignal): Promise<void> {
|
|
|
150
155
|
export async function tailWorldActions(name: string, options: TailWorldOptions = {}): Promise<void> {
|
|
151
156
|
const root = options.root ?? process.cwd();
|
|
152
157
|
const instance = statusWorld(name, root);
|
|
153
|
-
|
|
158
|
+
// a link (an opened seal) journals its requests as a twin does, under its own name
|
|
159
|
+
const links = linkNames(root);
|
|
160
|
+
const known = [...new Set([...Object.keys(instance.services), ...links])];
|
|
154
161
|
for (const service of options.services ?? []) {
|
|
155
|
-
if (!
|
|
162
|
+
if (!known.includes(service)) {
|
|
156
163
|
throw new Error(`World ${name} has no service "${service}"; it has: ${known.join(', ')}`);
|
|
157
164
|
}
|
|
158
165
|
}
|
|
@@ -189,6 +196,7 @@ export async function tailWorldActions(name: string, options: TailWorldOptions =
|
|
|
189
196
|
|
|
190
197
|
emit(collect());
|
|
191
198
|
for (const service of services) {
|
|
199
|
+
if (links.includes(service) && !instance.services[service]) continue; // a link records requests, never actions
|
|
192
200
|
if (serviceLedgers(instance.dirs.data, service).length === 0) {
|
|
193
201
|
notice(`World ${name}: service "${service}" has no action ledger yet${follow
|
|
194
202
|
? ' — watching; drive the app (or a vendor SDK) through the world to record actions'
|