@volter/world-core 2.0.20 → 2.0.22
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/inject.cjs +18 -5
- package/dist/network-policy.cjs +4 -2
- package/dist/src/actions.js +7 -4
- package/dist/src/index.d.ts +2 -2
- package/dist/src/index.js +2 -2
- package/dist/src/request-scope.d.ts +2 -0
- package/dist/src/request-scope.js +8 -1
- package/dist/src/serve.d.ts +29 -0
- package/dist/src/serve.js +61 -0
- package/dist/src/trace-context.d.ts +3 -0
- package/dist/src/trace-context.js +11 -0
- package/inject.cjs +18 -5
- package/network-policy.cjs +4 -2
- package/package.json +1 -1
- package/src/actions.ts +7 -4
- package/src/index.ts +4 -1
- package/src/request-scope.ts +9 -1
- package/src/serve.ts +58 -0
- package/src/trace-context.ts +9 -0
package/dist/inject.cjs
CHANGED
|
@@ -728,8 +728,9 @@ function socketTargetHosts(args, isTls) {
|
|
|
728
728
|
return hosts;
|
|
729
729
|
}
|
|
730
730
|
if (typeof first === 'string' && !/^\d+$/.test(first)) return hosts; // a unix socket path
|
|
731
|
-
hosts.push(typeof second === 'string' ? second : 'localhost');
|
|
732
731
|
const options = typeof second === 'object' && second !== null ? second : typeof third === 'object' && third !== null ? third : null;
|
|
732
|
+
// connect(port, host) names the host; connect(port, { host }) puts it in the options, which Node merges
|
|
733
|
+
hosts.push(typeof second === 'string' ? second : typeof options?.host === 'string' ? options.host : 'localhost');
|
|
733
734
|
if (isTls && options && options.servername) hosts.push(options.servername);
|
|
734
735
|
return hosts;
|
|
735
736
|
}
|
|
@@ -811,7 +812,15 @@ function patchedPromisesLookup(original) {
|
|
|
811
812
|
};
|
|
812
813
|
}
|
|
813
814
|
|
|
814
|
-
|
|
815
|
+
/** The port a net/tls connect names (`connect(port, host)`, `connect({ port, host })`), or undefined. */
|
|
816
|
+
function socketTargetPort(args) {
|
|
817
|
+
const [first] = args;
|
|
818
|
+
const raw = first !== null && typeof first === 'object' ? first.port : first;
|
|
819
|
+
const port = Number(raw);
|
|
820
|
+
return Number.isInteger(port) && port > 0 && port < 65536 ? port : undefined;
|
|
821
|
+
}
|
|
822
|
+
|
|
823
|
+
function socketRefusal(hostname, port, isTls) {
|
|
815
824
|
const host = String(hostname || '').toLowerCase();
|
|
816
825
|
if (host === WORLD_ADDRESS[4] || host === WORLD_ADDRESS[6]) {
|
|
817
826
|
return `[twin-inject] refused a socket to ${host}, the address this World's DNS gives the names it routes: `
|
|
@@ -831,8 +840,11 @@ function socketRefusal(hostname) {
|
|
|
831
840
|
return `[twin-inject] refused a raw socket to ${host}, which this world routes to the application (volter-world app-url --host): `
|
|
832
841
|
+ 'the client opened its own connection instead of going through http/https or fetch, so it could not be routed.';
|
|
833
842
|
}
|
|
834
|
-
|
|
835
|
-
|
|
843
|
+
// the destination as an origin, its port and its protocol included: a policy that lists https://host allows TLS to
|
|
844
|
+
// host:443, never another port or a plaintext connection to it
|
|
845
|
+
const authority = `${isIP(host) === 6 ? `[${host}]` : host}${port !== undefined && !(isTls && port === 443) ? `:${port}` : ''}`;
|
|
846
|
+
const url = (() => { try { return new URL(`${isTls ? 'https' : 'http'}://${authority}/`); } catch { return null; } })();
|
|
847
|
+
if (url && shouldBlockUntwinned(url)) return `[twin-inject] blocked untwinned external socket to ${authority}`;
|
|
836
848
|
return null;
|
|
837
849
|
}
|
|
838
850
|
|
|
@@ -844,8 +856,9 @@ function refusedSocket(message) {
|
|
|
844
856
|
|
|
845
857
|
function patchedConnect(originalConnect, isTls) {
|
|
846
858
|
return function patchedSocketConnect(...args) {
|
|
859
|
+
const port = socketTargetPort(args);
|
|
847
860
|
for (const host of socketTargetHosts(args, isTls)) {
|
|
848
|
-
const refusal = socketRefusal(host);
|
|
861
|
+
const refusal = socketRefusal(host, port, isTls);
|
|
849
862
|
if (refusal) return refusedSocket(refusal);
|
|
850
863
|
}
|
|
851
864
|
return originalConnect.apply(this, args);
|
package/dist/network-policy.cjs
CHANGED
|
@@ -66,9 +66,11 @@ function isWorldInternalHost(hostname) {
|
|
|
66
66
|
const v4 = /^(\d+)\.(\d+)\.(\d+)\.(\d+)$/.exec(h);
|
|
67
67
|
if (v4) {
|
|
68
68
|
const a = Number(v4[1]); const b = Number(v4[2]);
|
|
69
|
-
|
|
69
|
+
// not link-local 169.254/16: it holds a cloud's instance metadata (169.254.169.254), the box's credentials
|
|
70
|
+
return a === 127 || a === 10 || (a === 172 && b >= 16 && b <= 31) || (a === 192 && b === 168);
|
|
70
71
|
}
|
|
71
|
-
|
|
72
|
+
// nor fe80::/10, nor a cloud's IPv6 metadata address (fd00:ec2::254)
|
|
73
|
+
return h.includes(':') && (h.startsWith('fc') || h.startsWith('fd')) && h !== 'fd00:ec2::254';
|
|
72
74
|
}
|
|
73
75
|
|
|
74
76
|
/**
|
package/dist/src/actions.js
CHANGED
|
@@ -19,9 +19,9 @@ import { canonicalJson, subjectKey } from "./hash.js";
|
|
|
19
19
|
import { appendDurable, eventsLockPath, ownerStoreRoots, projectionLockPath, twinLog, withFileLock, withoutIdentityClaim, worldPaths } from "./storage.js";
|
|
20
20
|
import { landAsPlaceholder, placeholderPullActive } from "./placeholder-remote.js";
|
|
21
21
|
import { aliasesFrom, dropCheckpoint, landedCopy, landedIds, parentEntries, readTree, treeStamp } from "./log.js";
|
|
22
|
-
import { refuseReadOnlyWrite } from "./request-scope.js";
|
|
22
|
+
import { inVendorMove, refuseReadOnlyWrite } from "./request-scope.js";
|
|
23
23
|
import { getActiveWorldStore } from "./world-store.js";
|
|
24
|
-
import { currentTraceparent } from "./trace-context.js";
|
|
24
|
+
import { currentTraceparent, traceparentForDelivery } from "./trace-context.js";
|
|
25
25
|
export class TwinActionPreconditionError extends Error {
|
|
26
26
|
actionId;
|
|
27
27
|
failed;
|
|
@@ -46,8 +46,11 @@ function withCorrelationId(action) {
|
|
|
46
46
|
// into the handler's async context) is the correlation when the caller supplies none — the
|
|
47
47
|
// join from a request on the wire to the action rows it caused, with no pack involved.
|
|
48
48
|
const correlated = action.correlationId ? action : { ...action, correlationId: currentCorrelationId() ?? randomUUID() };
|
|
49
|
-
// the request's W3C trace context (trace-context.ts) is stamped the same way, from the handler's async context
|
|
50
|
-
|
|
49
|
+
// the request's W3C trace context (trace-context.ts) is stamped the same way, from the handler's async context. What the
|
|
50
|
+
// vendor makes on its own while a request is served (its actor is the system's, or a vendor move) is a span of its own
|
|
51
|
+
// in that trace, so it is never joined to the request's caller (served-world's timeline), who did not make it
|
|
52
|
+
const vendors = action.actor?.kind === 'system' || inVendorMove();
|
|
53
|
+
const traceparent = correlated.traceparent === undefined ? (vendors ? traceparentForDelivery() : currentTraceparent()) : undefined;
|
|
51
54
|
return traceparent ? { ...correlated, traceparent } : correlated;
|
|
52
55
|
}
|
|
53
56
|
/** Subjects a `set` action touches: its own subject, every projection resource, and every
|
package/dist/src/index.d.ts
CHANGED
|
@@ -6,7 +6,7 @@ export { WORLD_ENV_NAMES_ENV, worldEnvValue } from './world-env.js';
|
|
|
6
6
|
export { createTwinFetchFromHandler, TWIN_PREFIX_HEADER, TWIN_REQUEST_SCOPES, twinPublicBase, withRequestScopes } from './twin-fetch.js';
|
|
7
7
|
export { isReadOnlyRequest, READ_ONLY_REQUEST_HEADER, ReadOnlyRequestError, runAsReadOnlyRequest, runAsVendorMove, writesRefused } from './request-scope.js';
|
|
8
8
|
export { compileSurface, createDerivedFetch, matchOperation } from './derived.js';
|
|
9
|
-
export { currentTraceparent, deliveryTraceHeaders, parseTraceparent, runWithRequestTrace, runWithTraceparent, TRACEPARENT_HEADER, traceparentForDelivery, validTraceparent } from './trace-context.js';
|
|
9
|
+
export { currentTraceparent, deliveryTraceHeaders, newTraceparent, parseTraceparent, runWithRequestTrace, runWithTraceparent, TRACEPARENT_HEADER, traceparentForDelivery, validTraceparent } from './trace-context.js';
|
|
10
10
|
export type { Traceparent } from './trace-context.js';
|
|
11
11
|
export type { DerivedCall, DerivedCoreOutcome, DerivedFetch, DerivedFetchOptions, DerivedHandler, DerivedOperation, DerivedOwner, DerivedSurface } from './derived.js';
|
|
12
12
|
export { bindSemantics, coreFor, crossCutting, derivedRequestScopes, observeTransitions, resourcesOfType as twinResourcesOfType, semanticsContext, stateOf, transitionFor, parseBracketForm, readParams, render as renderDerived, serveCore, sse, vendorError } from './derived-core.js';
|
|
@@ -33,7 +33,7 @@ export { SqlWorldStore } from './world-store-sql.js';
|
|
|
33
33
|
export type { SqlExec } from './world-store-sql.js';
|
|
34
34
|
export { FsBlobStore, MemoryBlobStore, blobDigest, getActiveBlobStore, setActiveBlobStore, withBlobStore, readBlobRange, } from './blob-store.js';
|
|
35
35
|
export type { BlobStore } from './blob-store.js';
|
|
36
|
-
export { applyTwinWrite, applyTwinWriteAtomic, createTwinServer, journalTwinRequest, twinRequestJournalFailures, twinRequestCredentials, readTwinRequestJournal, resolveTwinRead, twinRequestJournalEnabled, twinRequestJournalPath, twinResources, } from './serve.js';
|
|
36
|
+
export { applyTwinWrite, applyTwinWriteAtomic, createTwinServer, CALLER_HEADER, journalTwinRequest, twinRequestJournalFailures, twinRequestCredentials, readTwinRequestJournal, resolveTwinRead, twinRequestJournalEnabled, twinRequestJournalPath, readTwinRequestJournalFrom, type TwinRequestJournalCursor, twinResources, } from './serve.js';
|
|
37
37
|
export type { AtomicTwinWriteDecision, TwinRequestJournalEntry, TwinResource, TwinWriteInput, TwinWriteResult, } from './serve.js';
|
|
38
38
|
export { createTwinProxy } from './proxy.js';
|
|
39
39
|
export type { TwinProxy, TwinProxyOptions, VendorRoute } from './proxy.js';
|
package/dist/src/index.js
CHANGED
|
@@ -17,7 +17,7 @@ export { WORLD_ENV_NAMES_ENV, worldEnvValue } from "./world-env.js";
|
|
|
17
17
|
export { createTwinFetchFromHandler, TWIN_PREFIX_HEADER, TWIN_REQUEST_SCOPES, twinPublicBase, withRequestScopes } from "./twin-fetch.js";
|
|
18
18
|
export { isReadOnlyRequest, READ_ONLY_REQUEST_HEADER, ReadOnlyRequestError, runAsReadOnlyRequest, runAsVendorMove, writesRefused } from "./request-scope.js";
|
|
19
19
|
export { compileSurface, createDerivedFetch, matchOperation } from "./derived.js";
|
|
20
|
-
export { currentTraceparent, deliveryTraceHeaders, parseTraceparent, runWithRequestTrace, runWithTraceparent, TRACEPARENT_HEADER, traceparentForDelivery, validTraceparent } from "./trace-context.js";
|
|
20
|
+
export { currentTraceparent, deliveryTraceHeaders, newTraceparent, parseTraceparent, runWithRequestTrace, runWithTraceparent, TRACEPARENT_HEADER, traceparentForDelivery, validTraceparent } from "./trace-context.js";
|
|
21
21
|
export { bindSemantics, coreFor, crossCutting, derivedRequestScopes, observeTransitions, resourcesOfType as twinResourcesOfType, semanticsContext, stateOf, transitionFor, parseBracketForm, readParams, render as renderDerived, serveCore, sse, vendorError } from "./derived-core.js";
|
|
22
22
|
export { emitTwinEvent, eventSubscriptionMatches, listEmittable, runEmitCli } from "./emit.js";
|
|
23
23
|
// The vendor-agnostic CLIENT-SIDE RATE BUDGET — the fail-closed backstop a pack's guarded client
|
|
@@ -55,7 +55,7 @@ export { SqlWorldStore } from "./world-store-sql.js";
|
|
|
55
55
|
// The blob seam (runtime contract R11): byte storage behind byte-carrying handlers, so a
|
|
56
56
|
// serverless namespace puts bytes in object storage while local worlds keep today's layout.
|
|
57
57
|
export { FsBlobStore, MemoryBlobStore, blobDigest, getActiveBlobStore, setActiveBlobStore, withBlobStore, readBlobRange, } from "./blob-store.js";
|
|
58
|
-
export { applyTwinWrite, applyTwinWriteAtomic, createTwinServer, journalTwinRequest, twinRequestJournalFailures, twinRequestCredentials, readTwinRequestJournal, resolveTwinRead, twinRequestJournalEnabled, twinRequestJournalPath, twinResources, } from "./serve.js";
|
|
58
|
+
export { applyTwinWrite, applyTwinWriteAtomic, createTwinServer, CALLER_HEADER, journalTwinRequest, twinRequestJournalFailures, twinRequestCredentials, readTwinRequestJournal, resolveTwinRead, twinRequestJournalEnabled, twinRequestJournalPath, readTwinRequestJournalFrom, twinResources, } from "./serve.js";
|
|
59
59
|
export { createTwinProxy } from "./proxy.js";
|
|
60
60
|
export { forkTwin, isFork, readForkMeta, } from "./fork.js";
|
|
61
61
|
export { appendAction, appendTransactionCommit, checkPrecondition, confirmAction, listActions, listTransactionCommits, pendingActions, pushablePendingActions, isTwinBookkeeping, pendingTransactionCommits, projectResources, projectOwnerResources, OwnerStoreAmbiguousError, revertAction, TwinActionPreconditionError, subjectAliases, resolveSubjectId, runWithCorrelationId, currentCorrelationId } from "./actions.js";
|
|
@@ -21,6 +21,8 @@ export declare function runAsReadOnlyRequest<T>(fn: () => T | Promise<T>): Promi
|
|
|
21
21
|
/** Run `fn` as the VENDOR's own move (a pack's catch-up): its writes land even under a read-only
|
|
22
22
|
* request. Outside one it changes nothing. */
|
|
23
23
|
export declare function runAsVendorMove<T>(fn: () => T): T;
|
|
24
|
+
/** Whether this runs as the vendor's own move (runAsVendorMove). */
|
|
25
|
+
export declare function inVendorMove(): boolean;
|
|
24
26
|
/** Whether a write attempted here would be refused. */
|
|
25
27
|
export declare function writesRefused(): boolean;
|
|
26
28
|
/** The write seam's check: throws (and records) the refusal when a write attempted here is a
|
|
@@ -14,6 +14,7 @@
|
|
|
14
14
|
// Created on first use, never at import: a browser bundle of a mirror client carries the kernel and
|
|
15
15
|
// has no AsyncLocalStorage (see serve.ts identitySlot).
|
|
16
16
|
import { AsyncLocalStorage } from 'node:async_hooks';
|
|
17
|
+
import { runWithTraceparent, traceparentForDelivery } from "./trace-context.js";
|
|
17
18
|
/** The request header that marks a request read-only. Its one value is `1`. */
|
|
18
19
|
export const READ_ONLY_REQUEST_HEADER = 'x-volter-read-only';
|
|
19
20
|
/** Whether a request carries the read-only marker. */
|
|
@@ -52,8 +53,14 @@ export async function runAsReadOnlyRequest(fn) {
|
|
|
52
53
|
* request. Outside one it changes nothing. */
|
|
53
54
|
export function runAsVendorMove(fn) {
|
|
54
55
|
const current = writeScope().getStore();
|
|
55
|
-
|
|
56
|
+
// the vendor's move is its own span of the request's trace (a delivery's shape): what it writes is joined to the
|
|
57
|
+
// trace, never to the request's caller, who did not make it
|
|
58
|
+
const own = traceparentForDelivery();
|
|
59
|
+
const run = own ? () => runWithTraceparent(own, fn) : fn;
|
|
60
|
+
return current ? writeScope().run({ ...current, vendorMove: true }, run) : run();
|
|
56
61
|
}
|
|
62
|
+
/** Whether this runs as the vendor's own move (runAsVendorMove). */
|
|
63
|
+
export function inVendorMove() { return writeScopeStore?.getStore()?.vendorMove === true; }
|
|
57
64
|
/** Whether a write attempted here would be refused. */
|
|
58
65
|
export function writesRefused() {
|
|
59
66
|
const scope = writeScopeStore?.getStore();
|
package/dist/src/serve.d.ts
CHANGED
|
@@ -15,6 +15,10 @@ export type TwinCredentialShape = {
|
|
|
15
15
|
/** the name arrived with an EMPTY value — the "the SDK sent the header but no key" case. */
|
|
16
16
|
empty?: true;
|
|
17
17
|
};
|
|
18
|
+
/** The header a World puts on a request it forwards to a twin: who made it (`key:<name>`, `person:<who>`, `token`,
|
|
19
|
+
* `token:read`). The World replaces any a caller sent; the serve seam records it in the twin's request journal and
|
|
20
|
+
* removes it before the twin's handler. A twin reached on its own port, not through a World, records what it was sent. */
|
|
21
|
+
export declare const CALLER_HEADER = "x-volter-caller";
|
|
18
22
|
export type TwinRequestJournalEntry = {
|
|
19
23
|
at?: string;
|
|
20
24
|
method: string;
|
|
@@ -24,6 +28,14 @@ export type TwinRequestJournalEntry = {
|
|
|
24
28
|
ms?: number;
|
|
25
29
|
/** credential-looking headers/query params that arrived, named + fingerprinted, never quoted. */
|
|
26
30
|
credentials?: TwinCredentialShape[];
|
|
31
|
+
/** who made it, as the World named its caller (CALLER_HEADER: `key:<name>`, `person:<who>`, `token`); absent for a
|
|
32
|
+
* request that did not come through a World */
|
|
33
|
+
caller?: string;
|
|
34
|
+
/** the request's trace-id and parent-id (its `traceparent`). The World mints the parent-id of every request it
|
|
35
|
+
* forwards, and the request's log entries carry it, so an entry is joined to its caller by `span`, which no caller
|
|
36
|
+
* chooses (a trace-id is the caller's and spans requests) */
|
|
37
|
+
trace?: string;
|
|
38
|
+
span?: string;
|
|
27
39
|
/** `request`: written BEFORE the request is served (an audit device's request entry, so a request whose
|
|
28
40
|
* audit cannot be written is refused); its outcome is the entry without a phase written after. */
|
|
29
41
|
phase?: 'request';
|
|
@@ -36,6 +48,23 @@ export declare function readTwinRequestJournal(service: string, root?: string, o
|
|
|
36
48
|
/** Journal lines this process failed to keep, since it started, for one service's journal under `root`
|
|
37
49
|
* (a World's request report sums its own twins and links). */
|
|
38
50
|
export declare function twinRequestJournalFailures(service: string, root?: string): number;
|
|
51
|
+
/** Where a reader of a journal stopped: the newest closed segment it had seen and the bytes of the live file it read. */
|
|
52
|
+
export type TwinRequestJournalCursor = {
|
|
53
|
+
segment: number;
|
|
54
|
+
offset: number;
|
|
55
|
+
};
|
|
56
|
+
/** The journal's lines after `cursor` (all of them without one), and the cursor to read on from. A rotation closes
|
|
57
|
+
* the live file into the next segment as it stood, so a reader that stopped in the live file goes on in that
|
|
58
|
+
* segment at its offset, then any later segments, and the next read takes the live file from its start; a live file shorter than where
|
|
59
|
+
* the reader stopped (read between a rotation's two writes) is read again from its start, its lines repeated rather
|
|
60
|
+
* than lost. Only whole lines are read (a line being appended is read next time). Without a cursor, the newest
|
|
61
|
+
* `segments` closed segments are read (all of them when omitted), then the live file. */
|
|
62
|
+
export declare function readTwinRequestJournalFrom(service: string, root: string | undefined, cursor: TwinRequestJournalCursor | null, options?: {
|
|
63
|
+
segments?: number;
|
|
64
|
+
}): {
|
|
65
|
+
entries: TwinRequestJournalEntry[];
|
|
66
|
+
cursor: TwinRequestJournalCursor;
|
|
67
|
+
};
|
|
39
68
|
/** Non-reversible, stable fingerprint. 64 bits of sha256 — enough that two distinct credentials
|
|
40
69
|
* never collide in a journal, far too little to walk back to a real key. */
|
|
41
70
|
export declare function credentialFingerprint(value: string): string;
|
package/dist/src/serve.js
CHANGED
|
@@ -18,6 +18,11 @@ import { hashFieldValue } from "./hash.js";
|
|
|
18
18
|
import { appendActionIfAbsent, appendActionOccurrence, decideAndAppendAction, projectResources } from "./actions.js";
|
|
19
19
|
import { observeWorldPaths, worldPaths } from "./storage.js";
|
|
20
20
|
import { getActiveWorldStore } from "./world-store.js";
|
|
21
|
+
import { parseTraceparent } from "./trace-context.js";
|
|
22
|
+
/** The header a World puts on a request it forwards to a twin: who made it (`key:<name>`, `person:<who>`, `token`,
|
|
23
|
+
* `token:read`). The World replaces any a caller sent; the serve seam records it in the twin's request journal and
|
|
24
|
+
* removes it before the twin's handler. A twin reached on its own port, not through a World, records what it was sent. */
|
|
25
|
+
export const CALLER_HEADER = 'x-volter-caller';
|
|
21
26
|
/** The journal's closed segments, oldest first: `requests.jsonl.<n>` beside the live file. */
|
|
22
27
|
function journalSegments(path) {
|
|
23
28
|
const base = path.slice(path.lastIndexOf('/') + 1);
|
|
@@ -70,6 +75,49 @@ export function twinRequestJournalFailures(service, root) {
|
|
|
70
75
|
return 0;
|
|
71
76
|
}
|
|
72
77
|
}
|
|
78
|
+
/** The journal's lines after `cursor` (all of them without one), and the cursor to read on from. A rotation closes
|
|
79
|
+
* the live file into the next segment as it stood, so a reader that stopped in the live file goes on in that
|
|
80
|
+
* segment at its offset, then any later segments, and the next read takes the live file from its start; a live file shorter than where
|
|
81
|
+
* the reader stopped (read between a rotation's two writes) is read again from its start, its lines repeated rather
|
|
82
|
+
* than lost. Only whole lines are read (a line being appended is read next time). Without a cursor, the newest
|
|
83
|
+
* `segments` closed segments are read (all of them when omitted), then the live file. */
|
|
84
|
+
export function readTwinRequestJournalFrom(service, root, cursor, options = {}) {
|
|
85
|
+
const path = twinRequestJournalPath(service, root);
|
|
86
|
+
const store = getActiveWorldStore();
|
|
87
|
+
const segments = journalSegments(path).map((file) => Number(file.slice(path.length + 1)));
|
|
88
|
+
const newest = segments[segments.length - 1] ?? 0;
|
|
89
|
+
const entries = [];
|
|
90
|
+
const take = (file, start, whole) => {
|
|
91
|
+
const text = store.readRange ? store.readRange(file, start) : ((t) => (t === null ? null : Buffer.from(t, 'utf8').subarray(start).toString('utf8')))(store.read(file));
|
|
92
|
+
if (!text)
|
|
93
|
+
return start;
|
|
94
|
+
const end = whole ? text.length : text.lastIndexOf('\n') + 1;
|
|
95
|
+
for (const line of text.slice(0, end).split('\n')) {
|
|
96
|
+
if (!line)
|
|
97
|
+
continue;
|
|
98
|
+
try {
|
|
99
|
+
entries.push(JSON.parse(line));
|
|
100
|
+
}
|
|
101
|
+
catch { /* a torn line */ }
|
|
102
|
+
}
|
|
103
|
+
return start + Buffer.byteLength(text.slice(0, end), 'utf8');
|
|
104
|
+
};
|
|
105
|
+
if (cursor === null) {
|
|
106
|
+
for (const n of options.segments === undefined ? segments : segments.slice(-options.segments))
|
|
107
|
+
take(`${path}.${n}`, 0, true);
|
|
108
|
+
return { entries, cursor: { segment: newest, offset: take(path, 0, false) } };
|
|
109
|
+
}
|
|
110
|
+
if (newest === cursor.segment) {
|
|
111
|
+
const from = (store.stat(path)?.size ?? 0) < cursor.offset ? 0 : cursor.offset;
|
|
112
|
+
return { entries, cursor: { segment: newest, offset: take(path, from, false) } };
|
|
113
|
+
}
|
|
114
|
+
for (const n of segments)
|
|
115
|
+
if (n > cursor.segment)
|
|
116
|
+
take(`${path}.${n}`, n === cursor.segment + 1 ? cursor.offset : 0, true);
|
|
117
|
+
// the live file is read next time, from its start: read now, it may be the one the rotation has not emptied yet,
|
|
118
|
+
// and an offset into it would land inside the new one
|
|
119
|
+
return { entries, cursor: { segment: newest, offset: 0 } };
|
|
120
|
+
}
|
|
73
121
|
/** ~1 MB per segment: tens of thousands of entries (a shape line is ~80 bytes), and a report over a
|
|
74
122
|
* recent period reads only the segments that reach into it. */
|
|
75
123
|
const REQUEST_JOURNAL_MAX_BYTES = 1_000_000;
|
|
@@ -155,6 +203,9 @@ export function journalTwinRequest(service, entry, root) {
|
|
|
155
203
|
const line = JSON.stringify({
|
|
156
204
|
at: entry.at ?? new Date().toISOString(),
|
|
157
205
|
...(entry.phase ? { phase: entry.phase } : {}),
|
|
206
|
+
...(entry.caller ? { caller: entry.caller.slice(0, 120) } : {}),
|
|
207
|
+
...(entry.trace && /^[0-9a-f]{32}$/.test(entry.trace) ? { trace: entry.trace } : {}),
|
|
208
|
+
...(entry.span && /^[0-9a-f]{16}$/.test(entry.span) ? { span: entry.span } : {}),
|
|
158
209
|
method: entry.method.toUpperCase(),
|
|
159
210
|
path: entry.path.split('?')[0], // the query STRING never lands; its credential params do, named + fingerprinted
|
|
160
211
|
status: entry.status,
|
|
@@ -286,6 +337,13 @@ function journalingFetch(config, callSiteVendor) {
|
|
|
286
337
|
let identity = declared;
|
|
287
338
|
const inner = config.fetch;
|
|
288
339
|
const journaling = async function (request, server) {
|
|
340
|
+
// who made it is read here and goes no further: no twin's handler (a tunnel's, a proxy's) hands it on
|
|
341
|
+
const caller = request.headers.get(CALLER_HEADER) ?? undefined;
|
|
342
|
+
if (caller !== undefined) {
|
|
343
|
+
const headers = new Headers(request.headers);
|
|
344
|
+
headers.delete(CALLER_HEADER);
|
|
345
|
+
request = new Request(request, { headers });
|
|
346
|
+
}
|
|
289
347
|
// The World's own boot probe (serve-http.ts, WORLD_BOOT_PATH) is not the app's traffic: never journaled.
|
|
290
348
|
if (!twinRequestJournalEnabled() || new URL(request.url).pathname === WORLD_BOOT_PATH)
|
|
291
349
|
return inner.call(this, request, server);
|
|
@@ -308,12 +366,15 @@ function journalingFetch(config, callSiteVendor) {
|
|
|
308
366
|
const resolved = identity ?? fallbackIdentity(callSiteVendor);
|
|
309
367
|
if (resolved) {
|
|
310
368
|
const url = new URL(request.url);
|
|
369
|
+
const parent = parseTraceparent(request.headers.get('traceparent'));
|
|
311
370
|
journalTwinRequest(resolved.service, {
|
|
312
371
|
method: request.method,
|
|
313
372
|
path: url.pathname,
|
|
314
373
|
status,
|
|
315
374
|
ms: Date.now() - startedAt,
|
|
316
375
|
credentials: twinRequestCredentials(request.headers, url),
|
|
376
|
+
...(caller ? { caller } : {}),
|
|
377
|
+
...(parent ? { trace: parent.traceId, span: parent.parentId } : {}),
|
|
317
378
|
}, resolved.root);
|
|
318
379
|
}
|
|
319
380
|
}
|
|
@@ -16,6 +16,9 @@ export declare function runWithTraceparent<T>(traceparent: string | undefined, f
|
|
|
16
16
|
export declare function currentTraceparent(): string | undefined;
|
|
17
17
|
/** Run `fn` inside the trace context `request` carries (its `traceparent` header), if any. */
|
|
18
18
|
export declare function runWithRequestTrace<T>(request: Request, fn: () => T): T;
|
|
19
|
+
/** A new trace's `traceparent` (version 00, a fresh trace-id and parent-id, sampled): for a request that carried none,
|
|
20
|
+
* so what it causes can be told apart and joined to it. */
|
|
21
|
+
export declare function newTraceparent(): string;
|
|
19
22
|
/**
|
|
20
23
|
* The `traceparent` an outbound delivery carries: a CHILD of its cause — the same trace-id and
|
|
21
24
|
* flags, a new parent-id — so the receiving handler continues the cause's trace. The cause is the
|
|
@@ -51,6 +51,17 @@ export function currentTraceparent() {
|
|
|
51
51
|
export function runWithRequestTrace(request, fn) {
|
|
52
52
|
return runWithTraceparent(request.headers.get(TRACEPARENT_HEADER) ?? undefined, fn);
|
|
53
53
|
}
|
|
54
|
+
/** A new trace's `traceparent` (version 00, a fresh trace-id and parent-id, sampled): for a request that carried none,
|
|
55
|
+
* so what it causes can be told apart and joined to it. */
|
|
56
|
+
export function newTraceparent() {
|
|
57
|
+
const bytes = new Uint8Array(16);
|
|
58
|
+
let trace = '';
|
|
59
|
+
do {
|
|
60
|
+
globalThis.crypto.getRandomValues(bytes);
|
|
61
|
+
trace = Array.from(bytes, (b) => b.toString(16).padStart(2, '0')).join('');
|
|
62
|
+
} while (/^0+$/.test(trace));
|
|
63
|
+
return `00-${trace}-${newSpanId()}-01`;
|
|
64
|
+
}
|
|
54
65
|
function newSpanId() {
|
|
55
66
|
const bytes = new Uint8Array(8);
|
|
56
67
|
for (;;) {
|
package/inject.cjs
CHANGED
|
@@ -728,8 +728,9 @@ function socketTargetHosts(args, isTls) {
|
|
|
728
728
|
return hosts;
|
|
729
729
|
}
|
|
730
730
|
if (typeof first === 'string' && !/^\d+$/.test(first)) return hosts; // a unix socket path
|
|
731
|
-
hosts.push(typeof second === 'string' ? second : 'localhost');
|
|
732
731
|
const options = typeof second === 'object' && second !== null ? second : typeof third === 'object' && third !== null ? third : null;
|
|
732
|
+
// connect(port, host) names the host; connect(port, { host }) puts it in the options, which Node merges
|
|
733
|
+
hosts.push(typeof second === 'string' ? second : typeof options?.host === 'string' ? options.host : 'localhost');
|
|
733
734
|
if (isTls && options && options.servername) hosts.push(options.servername);
|
|
734
735
|
return hosts;
|
|
735
736
|
}
|
|
@@ -811,7 +812,15 @@ function patchedPromisesLookup(original) {
|
|
|
811
812
|
};
|
|
812
813
|
}
|
|
813
814
|
|
|
814
|
-
|
|
815
|
+
/** The port a net/tls connect names (`connect(port, host)`, `connect({ port, host })`), or undefined. */
|
|
816
|
+
function socketTargetPort(args) {
|
|
817
|
+
const [first] = args;
|
|
818
|
+
const raw = first !== null && typeof first === 'object' ? first.port : first;
|
|
819
|
+
const port = Number(raw);
|
|
820
|
+
return Number.isInteger(port) && port > 0 && port < 65536 ? port : undefined;
|
|
821
|
+
}
|
|
822
|
+
|
|
823
|
+
function socketRefusal(hostname, port, isTls) {
|
|
815
824
|
const host = String(hostname || '').toLowerCase();
|
|
816
825
|
if (host === WORLD_ADDRESS[4] || host === WORLD_ADDRESS[6]) {
|
|
817
826
|
return `[twin-inject] refused a socket to ${host}, the address this World's DNS gives the names it routes: `
|
|
@@ -831,8 +840,11 @@ function socketRefusal(hostname) {
|
|
|
831
840
|
return `[twin-inject] refused a raw socket to ${host}, which this world routes to the application (volter-world app-url --host): `
|
|
832
841
|
+ 'the client opened its own connection instead of going through http/https or fetch, so it could not be routed.';
|
|
833
842
|
}
|
|
834
|
-
|
|
835
|
-
|
|
843
|
+
// the destination as an origin, its port and its protocol included: a policy that lists https://host allows TLS to
|
|
844
|
+
// host:443, never another port or a plaintext connection to it
|
|
845
|
+
const authority = `${isIP(host) === 6 ? `[${host}]` : host}${port !== undefined && !(isTls && port === 443) ? `:${port}` : ''}`;
|
|
846
|
+
const url = (() => { try { return new URL(`${isTls ? 'https' : 'http'}://${authority}/`); } catch { return null; } })();
|
|
847
|
+
if (url && shouldBlockUntwinned(url)) return `[twin-inject] blocked untwinned external socket to ${authority}`;
|
|
836
848
|
return null;
|
|
837
849
|
}
|
|
838
850
|
|
|
@@ -844,8 +856,9 @@ function refusedSocket(message) {
|
|
|
844
856
|
|
|
845
857
|
function patchedConnect(originalConnect, isTls) {
|
|
846
858
|
return function patchedSocketConnect(...args) {
|
|
859
|
+
const port = socketTargetPort(args);
|
|
847
860
|
for (const host of socketTargetHosts(args, isTls)) {
|
|
848
|
-
const refusal = socketRefusal(host);
|
|
861
|
+
const refusal = socketRefusal(host, port, isTls);
|
|
849
862
|
if (refusal) return refusedSocket(refusal);
|
|
850
863
|
}
|
|
851
864
|
return originalConnect.apply(this, args);
|
package/network-policy.cjs
CHANGED
|
@@ -66,9 +66,11 @@ function isWorldInternalHost(hostname) {
|
|
|
66
66
|
const v4 = /^(\d+)\.(\d+)\.(\d+)\.(\d+)$/.exec(h);
|
|
67
67
|
if (v4) {
|
|
68
68
|
const a = Number(v4[1]); const b = Number(v4[2]);
|
|
69
|
-
|
|
69
|
+
// not link-local 169.254/16: it holds a cloud's instance metadata (169.254.169.254), the box's credentials
|
|
70
|
+
return a === 127 || a === 10 || (a === 172 && b >= 16 && b <= 31) || (a === 192 && b === 168);
|
|
70
71
|
}
|
|
71
|
-
|
|
72
|
+
// nor fe80::/10, nor a cloud's IPv6 metadata address (fd00:ec2::254)
|
|
73
|
+
return h.includes(':') && (h.startsWith('fc') || h.startsWith('fd')) && h !== 'fd00:ec2::254';
|
|
72
74
|
}
|
|
73
75
|
|
|
74
76
|
/**
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@volter/world-core",
|
|
3
|
-
"version": "2.0.
|
|
3
|
+
"version": "2.0.22",
|
|
4
4
|
"description": "The kernel of Volter World: one log per twin, branches as pointers, checkpoints, the fold that keeps a twin current, the head that performs a write against the vendor, references, and the git plane. A twin package builds on it; the runtime serves it.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"twin",
|
package/src/actions.ts
CHANGED
|
@@ -21,9 +21,9 @@ import { appendDurable, eventsLockPath, ownerStoreRoots, projectionLockPath, twi
|
|
|
21
21
|
import { landAsPlaceholder, placeholderPullActive } from './placeholder-remote.ts';
|
|
22
22
|
import { aliasesFrom, dropCheckpoint, landedCopy, landedIds, parentEntries, readTree, treeStamp, type Receipt } from './log.ts';
|
|
23
23
|
import { WorldServiceEventSchema } from './schemas.ts';
|
|
24
|
-
import { refuseReadOnlyWrite } from './request-scope.ts';
|
|
24
|
+
import { inVendorMove, refuseReadOnlyWrite } from './request-scope.ts';
|
|
25
25
|
import { getActiveWorldStore } from './world-store.ts';
|
|
26
|
-
import { currentTraceparent } from './trace-context.ts';
|
|
26
|
+
import { currentTraceparent, traceparentForDelivery } from './trace-context.ts';
|
|
27
27
|
import type { WorldServiceEvent } from './types.ts';
|
|
28
28
|
import type { TwinResource } from './serve.ts';
|
|
29
29
|
|
|
@@ -135,8 +135,11 @@ function withCorrelationId(action: TwinAction): TwinAction {
|
|
|
135
135
|
// into the handler's async context) is the correlation when the caller supplies none — the
|
|
136
136
|
// join from a request on the wire to the action rows it caused, with no pack involved.
|
|
137
137
|
const correlated = action.correlationId ? action : { ...action, correlationId: currentCorrelationId() ?? randomUUID() };
|
|
138
|
-
// the request's W3C trace context (trace-context.ts) is stamped the same way, from the handler's async context
|
|
139
|
-
|
|
138
|
+
// the request's W3C trace context (trace-context.ts) is stamped the same way, from the handler's async context. What the
|
|
139
|
+
// vendor makes on its own while a request is served (its actor is the system's, or a vendor move) is a span of its own
|
|
140
|
+
// in that trace, so it is never joined to the request's caller (served-world's timeline), who did not make it
|
|
141
|
+
const vendors = action.actor?.kind === 'system' || inVendorMove();
|
|
142
|
+
const traceparent = correlated.traceparent === undefined ? (vendors ? traceparentForDelivery() : currentTraceparent()) : undefined;
|
|
140
143
|
return traceparent ? { ...correlated, traceparent } : correlated;
|
|
141
144
|
}
|
|
142
145
|
|
package/src/index.ts
CHANGED
|
@@ -18,7 +18,7 @@ export { WORLD_ENV_NAMES_ENV, worldEnvValue } from './world-env.ts';
|
|
|
18
18
|
export { createTwinFetchFromHandler, TWIN_PREFIX_HEADER, TWIN_REQUEST_SCOPES, twinPublicBase, withRequestScopes } from './twin-fetch.ts';
|
|
19
19
|
export { isReadOnlyRequest, READ_ONLY_REQUEST_HEADER, ReadOnlyRequestError, runAsReadOnlyRequest, runAsVendorMove, writesRefused } from './request-scope.ts';
|
|
20
20
|
export { compileSurface, createDerivedFetch, matchOperation } from './derived.ts';
|
|
21
|
-
export { currentTraceparent, deliveryTraceHeaders, parseTraceparent, runWithRequestTrace, runWithTraceparent, TRACEPARENT_HEADER, traceparentForDelivery, validTraceparent } from './trace-context.ts';
|
|
21
|
+
export { currentTraceparent, deliveryTraceHeaders, newTraceparent, parseTraceparent, runWithRequestTrace, runWithTraceparent, TRACEPARENT_HEADER, traceparentForDelivery, validTraceparent } from './trace-context.ts';
|
|
22
22
|
export type { Traceparent } from './trace-context.ts';
|
|
23
23
|
export type { DerivedCall, DerivedCoreOutcome, DerivedFetch, DerivedFetchOptions, DerivedHandler, DerivedOperation, DerivedOwner, DerivedSurface } from './derived.ts';
|
|
24
24
|
export { bindSemantics, coreFor, crossCutting, derivedRequestScopes, observeTransitions, resourcesOfType as twinResourcesOfType, semanticsContext, stateOf, transitionFor, parseBracketForm, readParams, render as renderDerived, serveCore, sse, vendorError } from './derived-core.ts';
|
|
@@ -161,6 +161,7 @@ export {
|
|
|
161
161
|
applyTwinWrite,
|
|
162
162
|
applyTwinWriteAtomic,
|
|
163
163
|
createTwinServer,
|
|
164
|
+
CALLER_HEADER,
|
|
164
165
|
journalTwinRequest,
|
|
165
166
|
twinRequestJournalFailures,
|
|
166
167
|
twinRequestCredentials,
|
|
@@ -168,6 +169,8 @@ export {
|
|
|
168
169
|
resolveTwinRead,
|
|
169
170
|
twinRequestJournalEnabled,
|
|
170
171
|
twinRequestJournalPath,
|
|
172
|
+
readTwinRequestJournalFrom,
|
|
173
|
+
type TwinRequestJournalCursor,
|
|
171
174
|
twinResources,
|
|
172
175
|
} from './serve.ts';
|
|
173
176
|
export type {
|
package/src/request-scope.ts
CHANGED
|
@@ -14,6 +14,7 @@
|
|
|
14
14
|
// Created on first use, never at import: a browser bundle of a mirror client carries the kernel and
|
|
15
15
|
// has no AsyncLocalStorage (see serve.ts identitySlot).
|
|
16
16
|
import { AsyncLocalStorage } from 'node:async_hooks';
|
|
17
|
+
import { runWithTraceparent, traceparentForDelivery } from './trace-context.ts';
|
|
17
18
|
|
|
18
19
|
/** The request header that marks a request read-only. Its one value is `1`. */
|
|
19
20
|
export const READ_ONLY_REQUEST_HEADER = 'x-volter-read-only';
|
|
@@ -55,9 +56,16 @@ export async function runAsReadOnlyRequest<T>(fn: () => T | Promise<T>): Promise
|
|
|
55
56
|
* request. Outside one it changes nothing. */
|
|
56
57
|
export function runAsVendorMove<T>(fn: () => T): T {
|
|
57
58
|
const current = writeScope().getStore();
|
|
58
|
-
|
|
59
|
+
// the vendor's move is its own span of the request's trace (a delivery's shape): what it writes is joined to the
|
|
60
|
+
// trace, never to the request's caller, who did not make it
|
|
61
|
+
const own = traceparentForDelivery();
|
|
62
|
+
const run = own ? () => runWithTraceparent(own, fn) : fn;
|
|
63
|
+
return current ? writeScope().run({ ...current, vendorMove: true }, run) : run();
|
|
59
64
|
}
|
|
60
65
|
|
|
66
|
+
/** Whether this runs as the vendor's own move (runAsVendorMove). */
|
|
67
|
+
export function inVendorMove(): boolean { return writeScopeStore?.getStore()?.vendorMove === true; }
|
|
68
|
+
|
|
61
69
|
/** Whether a write attempted here would be refused. */
|
|
62
70
|
export function writesRefused(): boolean {
|
|
63
71
|
const scope = writeScopeStore?.getStore();
|
package/src/serve.ts
CHANGED
|
@@ -20,6 +20,7 @@ import { appendActionIfAbsent, appendActionOccurrence, decideAndAppendAction, pr
|
|
|
20
20
|
import type { ActionProjection, TwinAction, TwinActionPrecondition } from './actions.ts';
|
|
21
21
|
import { observeWorldPaths, worldPaths } from './storage.ts';
|
|
22
22
|
import { getActiveWorldStore } from './world-store.ts';
|
|
23
|
+
import { parseTraceparent } from './trace-context.ts';
|
|
23
24
|
|
|
24
25
|
// ── the request journal: the INSPECT half of the read path ─────────────────────────────────────
|
|
25
26
|
// `actions.jsonl` records MUTATIONS only, so a twin's READS are invisible to `volter-world tail`
|
|
@@ -75,6 +76,11 @@ export type TwinCredentialShape = {
|
|
|
75
76
|
empty?: true;
|
|
76
77
|
};
|
|
77
78
|
|
|
79
|
+
/** The header a World puts on a request it forwards to a twin: who made it (`key:<name>`, `person:<who>`, `token`,
|
|
80
|
+
* `token:read`). The World replaces any a caller sent; the serve seam records it in the twin's request journal and
|
|
81
|
+
* removes it before the twin's handler. A twin reached on its own port, not through a World, records what it was sent. */
|
|
82
|
+
export const CALLER_HEADER = 'x-volter-caller';
|
|
83
|
+
|
|
78
84
|
export type TwinRequestJournalEntry = {
|
|
79
85
|
at?: string;
|
|
80
86
|
method: string;
|
|
@@ -84,6 +90,14 @@ export type TwinRequestJournalEntry = {
|
|
|
84
90
|
ms?: number;
|
|
85
91
|
/** credential-looking headers/query params that arrived, named + fingerprinted, never quoted. */
|
|
86
92
|
credentials?: TwinCredentialShape[];
|
|
93
|
+
/** who made it, as the World named its caller (CALLER_HEADER: `key:<name>`, `person:<who>`, `token`); absent for a
|
|
94
|
+
* request that did not come through a World */
|
|
95
|
+
caller?: string;
|
|
96
|
+
/** the request's trace-id and parent-id (its `traceparent`). The World mints the parent-id of every request it
|
|
97
|
+
* forwards, and the request's log entries carry it, so an entry is joined to its caller by `span`, which no caller
|
|
98
|
+
* chooses (a trace-id is the caller's and spans requests) */
|
|
99
|
+
trace?: string;
|
|
100
|
+
span?: string;
|
|
87
101
|
/** `request`: written BEFORE the request is served (an audit device's request entry, so a request whose
|
|
88
102
|
* audit cannot be written is refused); its outcome is the entry without a phase written after. */
|
|
89
103
|
phase?: 'request';
|
|
@@ -130,6 +144,41 @@ export function twinRequestJournalFailures(service: string, root?: string): numb
|
|
|
130
144
|
}
|
|
131
145
|
|
|
132
146
|
|
|
147
|
+
/** Where a reader of a journal stopped: the newest closed segment it had seen and the bytes of the live file it read. */
|
|
148
|
+
export type TwinRequestJournalCursor = { segment: number; offset: number };
|
|
149
|
+
|
|
150
|
+
/** The journal's lines after `cursor` (all of them without one), and the cursor to read on from. A rotation closes
|
|
151
|
+
* the live file into the next segment as it stood, so a reader that stopped in the live file goes on in that
|
|
152
|
+
* segment at its offset, then any later segments, and the next read takes the live file from its start; a live file shorter than where
|
|
153
|
+
* the reader stopped (read between a rotation's two writes) is read again from its start, its lines repeated rather
|
|
154
|
+
* than lost. Only whole lines are read (a line being appended is read next time). Without a cursor, the newest
|
|
155
|
+
* `segments` closed segments are read (all of them when omitted), then the live file. */
|
|
156
|
+
export function readTwinRequestJournalFrom(service: string, root: string | undefined, cursor: TwinRequestJournalCursor | null, options: { segments?: number } = {}): { entries: TwinRequestJournalEntry[]; cursor: TwinRequestJournalCursor } {
|
|
157
|
+
const path = twinRequestJournalPath(service, root); const store = getActiveWorldStore();
|
|
158
|
+
const segments = journalSegments(path).map((file) => Number(file.slice(path.length + 1)));
|
|
159
|
+
const newest = segments[segments.length - 1] ?? 0;
|
|
160
|
+
const entries: TwinRequestJournalEntry[] = [];
|
|
161
|
+
const take = (file: string, start: number, whole: boolean): number => {
|
|
162
|
+
const text = store.readRange ? store.readRange(file, start) : ((t) => (t === null ? null : Buffer.from(t, 'utf8').subarray(start).toString('utf8')))(store.read(file));
|
|
163
|
+
if (!text) return start;
|
|
164
|
+
const end = whole ? text.length : text.lastIndexOf('\n') + 1;
|
|
165
|
+
for (const line of text.slice(0, end).split('\n')) { if (!line) continue; try { entries.push(JSON.parse(line) as TwinRequestJournalEntry); } catch { /* a torn line */ } }
|
|
166
|
+
return start + Buffer.byteLength(text.slice(0, end), 'utf8');
|
|
167
|
+
};
|
|
168
|
+
if (cursor === null) {
|
|
169
|
+
for (const n of options.segments === undefined ? segments : segments.slice(-options.segments)) take(`${path}.${n}`, 0, true);
|
|
170
|
+
return { entries, cursor: { segment: newest, offset: take(path, 0, false) } };
|
|
171
|
+
}
|
|
172
|
+
if (newest === cursor.segment) {
|
|
173
|
+
const from = (store.stat(path)?.size ?? 0) < cursor.offset ? 0 : cursor.offset;
|
|
174
|
+
return { entries, cursor: { segment: newest, offset: take(path, from, false) } };
|
|
175
|
+
}
|
|
176
|
+
for (const n of segments) if (n > cursor.segment) take(`${path}.${n}`, n === cursor.segment + 1 ? cursor.offset : 0, true);
|
|
177
|
+
// the live file is read next time, from its start: read now, it may be the one the rotation has not emptied yet,
|
|
178
|
+
// and an offset into it would land inside the new one
|
|
179
|
+
return { entries, cursor: { segment: newest, offset: 0 } };
|
|
180
|
+
}
|
|
181
|
+
|
|
133
182
|
/** ~1 MB per segment: tens of thousands of entries (a shape line is ~80 bytes), and a report over a
|
|
134
183
|
* recent period reads only the segments that reach into it. */
|
|
135
184
|
const REQUEST_JOURNAL_MAX_BYTES = 1_000_000;
|
|
@@ -212,6 +261,9 @@ export function journalTwinRequest(service: string, entry: TwinRequestJournalEnt
|
|
|
212
261
|
const line = JSON.stringify({
|
|
213
262
|
at: entry.at ?? new Date().toISOString(),
|
|
214
263
|
...(entry.phase ? { phase: entry.phase } : {}),
|
|
264
|
+
...(entry.caller ? { caller: entry.caller.slice(0, 120) } : {}),
|
|
265
|
+
...(entry.trace && /^[0-9a-f]{32}$/.test(entry.trace) ? { trace: entry.trace } : {}),
|
|
266
|
+
...(entry.span && /^[0-9a-f]{16}$/.test(entry.span) ? { span: entry.span } : {}),
|
|
215
267
|
method: entry.method.toUpperCase(),
|
|
216
268
|
path: entry.path.split('?')[0], // the query STRING never lands; its credential params do, named + fingerprinted
|
|
217
269
|
status: entry.status,
|
|
@@ -349,6 +401,9 @@ function journalingFetch(config: Record<string | symbol, unknown> & { fetch: (..
|
|
|
349
401
|
let identity: TwinJournalIdentity | undefined = declared;
|
|
350
402
|
const inner = config.fetch as (this: unknown, request: Request, server: unknown) => unknown;
|
|
351
403
|
const journaling = async function (this: unknown, request: Request, server: unknown) {
|
|
404
|
+
// who made it is read here and goes no further: no twin's handler (a tunnel's, a proxy's) hands it on
|
|
405
|
+
const caller = request.headers.get(CALLER_HEADER) ?? undefined;
|
|
406
|
+
if (caller !== undefined) { const headers = new Headers(request.headers); headers.delete(CALLER_HEADER); request = new Request(request, { headers }); }
|
|
352
407
|
// The World's own boot probe (serve-http.ts, WORLD_BOOT_PATH) is not the app's traffic: never journaled.
|
|
353
408
|
if (!twinRequestJournalEnabled() || new URL(request.url).pathname === WORLD_BOOT_PATH) return inner.call(this, request, server);
|
|
354
409
|
const slot: { identity?: TwinJournalIdentity } = {};
|
|
@@ -366,12 +421,15 @@ function journalingFetch(config: Record<string | symbol, unknown> & { fetch: (..
|
|
|
366
421
|
const resolved = identity ?? fallbackIdentity(callSiteVendor);
|
|
367
422
|
if (resolved) {
|
|
368
423
|
const url = new URL(request.url);
|
|
424
|
+
const parent = parseTraceparent(request.headers.get('traceparent'));
|
|
369
425
|
journalTwinRequest(resolved.service, {
|
|
370
426
|
method: request.method,
|
|
371
427
|
path: url.pathname,
|
|
372
428
|
status,
|
|
373
429
|
ms: Date.now() - startedAt,
|
|
374
430
|
credentials: twinRequestCredentials(request.headers, url),
|
|
431
|
+
...(caller ? { caller } : {}),
|
|
432
|
+
...(parent ? { trace: parent.traceId, span: parent.parentId } : {}),
|
|
375
433
|
}, resolved.root);
|
|
376
434
|
}
|
|
377
435
|
}
|
package/src/trace-context.ts
CHANGED
|
@@ -59,6 +59,15 @@ export function runWithRequestTrace<T>(request: Request, fn: () => T): T {
|
|
|
59
59
|
return runWithTraceparent(request.headers.get(TRACEPARENT_HEADER) ?? undefined, fn);
|
|
60
60
|
}
|
|
61
61
|
|
|
62
|
+
/** A new trace's `traceparent` (version 00, a fresh trace-id and parent-id, sampled): for a request that carried none,
|
|
63
|
+
* so what it causes can be told apart and joined to it. */
|
|
64
|
+
export function newTraceparent(): string {
|
|
65
|
+
const bytes = new Uint8Array(16);
|
|
66
|
+
let trace = '';
|
|
67
|
+
do { globalThis.crypto.getRandomValues(bytes); trace = Array.from(bytes, (b) => b.toString(16).padStart(2, '0')).join(''); } while (/^0+$/.test(trace));
|
|
68
|
+
return `00-${trace}-${newSpanId()}-01`;
|
|
69
|
+
}
|
|
70
|
+
|
|
62
71
|
function newSpanId(): string {
|
|
63
72
|
const bytes = new Uint8Array(8);
|
|
64
73
|
for (;;) {
|