@feasibleone/blong-gogo 1.33.0 → 1.35.0
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/CHANGELOG.md +24 -0
- package/bin/blong.ts +13 -0
- package/package.json +5 -3
- package/src/AdapterBase.ts +54 -2
- package/src/BrowserLog.ts +16 -2
- package/src/Gateway.ts +175 -3
- package/src/Log.ts +73 -3
- package/src/Registry.ts +7 -0
- package/src/RpcServer.ts +14 -3
- package/src/SemanticLog.test.ts +509 -0
- package/src/SemanticLog.ts +264 -0
- package/src/Watch.ts +69 -21
- package/src/callTrace.test.ts +80 -0
- package/src/callTrace.ts +126 -0
- package/src/cliHelp.ts +2 -0
- package/src/devKeys.ts +38 -0
- package/src/exit.intent.test.ts +21 -6
- package/src/frameworkRealms.test.ts +177 -0
- package/src/grant.test.ts +168 -0
- package/src/grant.ts +273 -0
- package/src/handlerProxy.ts +72 -3
- package/src/layerProxy.ts +9 -1
- package/src/lib.ts +69 -0
- package/src/load.ts +410 -34
- package/src/loadServer.ts +7 -0
- package/src/orchestrator/common/dispatch.ts +30 -6
- package/src/pino-pretty.ts +1 -1
- package/src/semanticContext.test.ts +322 -0
- package/src/semanticContext.ts +351 -0
- package/src/semanticRecord.ts +54 -0
- package/template/index.browser.ts +4 -0
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,29 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## [1.35.0](https://github.com/feasibleone/blong/compare/blong-gogo-v1.34.0...blong-gogo-v1.35.0) (2026-09-21)
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
### Features
|
|
7
|
+
|
|
8
|
+
* semantic log integration ([b1c0d4a](https://github.com/feasibleone/blong/commit/b1c0d4ab32fb54cda54d20deeaf9162b7c4a3db4))
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
### Bug Fixes
|
|
12
|
+
|
|
13
|
+
* build ([18ea6c6](https://github.com/feasibleone/blong/commit/18ea6c6c737e70ebb8715eba8bd19c7f459c2c41))
|
|
14
|
+
* decouple sequence diagrams from services ([9bc672a](https://github.com/feasibleone/blong/commit/9bc672a184ed07582452ede3a556aaf3ed0726d9))
|
|
15
|
+
* load ([492a0cf](https://github.com/feasibleone/blong/commit/492a0cf317e57cfea618337c8c2c572c220d8b30))
|
|
16
|
+
* MermaidRenderer loop ([afd21eb](https://github.com/feasibleone/blong/commit/afd21ebe87aa3e90c14a2b676414c55a38415744))
|
|
17
|
+
* sequence diagram leg capturing logic ([65f51cf](https://github.com/feasibleone/blong/commit/65f51cf98f4691caead56d9e4d22200fe40cc5bf))
|
|
18
|
+
* test failures ([6aad7e4](https://github.com/feasibleone/blong/commit/6aad7e444238b7ad9801de4017eaedadb55f16cc))
|
|
19
|
+
|
|
20
|
+
## [1.34.0](https://github.com/feasibleone/blong/compare/blong-gogo-v1.33.0...blong-gogo-v1.34.0) (2026-09-16)
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
### Features
|
|
24
|
+
|
|
25
|
+
* ACL ([0278431](https://github.com/feasibleone/blong/commit/027843153849ece048be0e61de51594f917a6277))
|
|
26
|
+
|
|
3
27
|
## [1.33.0](https://github.com/feasibleone/blong/compare/blong-gogo-v1.32.0...blong-gogo-v1.33.0) (2026-09-15)
|
|
4
28
|
|
|
5
29
|
|
package/bin/blong.ts
CHANGED
|
@@ -55,6 +55,19 @@ if (isRealmCreate) {
|
|
|
55
55
|
process.chdir(resolve(process.cwd(), realmName));
|
|
56
56
|
}
|
|
57
57
|
|
|
58
|
+
// `blong grant <capability> [--ttl=15m] [--key=<jwk|ENV_VAR>]` — mint a
|
|
59
|
+
// short-lived capability token (see `src/grant.ts`). Like `blong realm`, this
|
|
60
|
+
// stays within the intent model rather than becoming a subcommand tree: `grant`
|
|
61
|
+
// is only a command when it is the first positional and no positional names an
|
|
62
|
+
// existing path (a suite is never run by a `grant` argument in practice, and
|
|
63
|
+
// `existsSync` is the same guard the realm-create branch uses).
|
|
64
|
+
const isGrantCommand = positionals[0] === 'grant' && !positionals.some(p => existsSync(resolve(p)));
|
|
65
|
+
if (isGrantCommand) {
|
|
66
|
+
const {runGrantCommand} = await import('../src/grant.ts');
|
|
67
|
+
await runGrantCommand(positionals, rawArgv);
|
|
68
|
+
process.exit(0);
|
|
69
|
+
}
|
|
70
|
+
|
|
58
71
|
const parsed = minimist(process.argv.slice(2)) as {_: string[]};
|
|
59
72
|
// The first positional arg is an optional file/folder target; the rest are intents.
|
|
60
73
|
const [maybeTarget, ...rest] = parsed._;
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@feasibleone/blong-gogo",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.35.0",
|
|
4
4
|
"repository": {
|
|
5
5
|
"url": "git+https://github.com/feasibleone/blong.git"
|
|
6
6
|
},
|
|
@@ -38,6 +38,7 @@
|
|
|
38
38
|
"@feasibleone/blong-lib": "^1.0.0",
|
|
39
39
|
"@feasibleone/blong-mle": "^1.0.0",
|
|
40
40
|
"@feasibleone/blong-mock": "^1.0.0",
|
|
41
|
+
"@feasibleone/semantic-log": "^1.0.0",
|
|
41
42
|
"@keycloak/keycloak-admin-client": "^26.6.2",
|
|
42
43
|
"@kubernetes/client-node": "^1.4.0",
|
|
43
44
|
"@octokit/rest": "^22.0.1",
|
|
@@ -90,7 +91,7 @@
|
|
|
90
91
|
"tap": "^21.6.3",
|
|
91
92
|
"typescript": "^6.0.3",
|
|
92
93
|
"vite": "^8.2.1",
|
|
93
|
-
"@feasibleone/blong-dev": "1.
|
|
94
|
+
"@feasibleone/blong-dev": "1.5.0"
|
|
94
95
|
},
|
|
95
96
|
"scripts": {
|
|
96
97
|
"browser-check": "node scripts/browser-compat-check.mjs",
|
|
@@ -98,6 +99,7 @@
|
|
|
98
99
|
"ci-coverage": "./run-coverage.sh",
|
|
99
100
|
"ci-lint": "blong-dev lint",
|
|
100
101
|
"ci-publish": "node ../../common/scripts/install-run-rush-pnpm.js publish --access public --provenance",
|
|
101
|
-
"ci-test": "blong-dev test"
|
|
102
|
+
"ci-test": "blong-dev test",
|
|
103
|
+
"test": "blong-dev test"
|
|
102
104
|
}
|
|
103
105
|
}
|
package/src/AdapterBase.ts
CHANGED
|
@@ -13,8 +13,9 @@ import type net from 'node:net';
|
|
|
13
13
|
import PQueue from 'p-queue';
|
|
14
14
|
|
|
15
15
|
import ConfigRuntime from './ConfigRuntime.ts';
|
|
16
|
-
import {isExpectedError} from './lib.ts';
|
|
16
|
+
import {isExpectedError, portLogCalls} from './lib.ts';
|
|
17
17
|
import loop from './loop.ts';
|
|
18
|
+
import {adoptInbound, inboundIdentities, isFlowId} from './semanticContext.ts';
|
|
18
19
|
|
|
19
20
|
const errorMap: IErrorMap = {
|
|
20
21
|
'adapter.configValidation': 'Adapter config validation:\r\n{message}',
|
|
@@ -290,6 +291,26 @@ export class AdapterBase<T, C extends IContext> implements AdapterHandlerContext
|
|
|
290
291
|
|
|
291
292
|
async drain(): Promise<void> {}
|
|
292
293
|
|
|
294
|
+
/**
|
|
295
|
+
* Every handler registered for a method, in load order.
|
|
296
|
+
*
|
|
297
|
+
* `findHandler` answers with the one function the proxy will call: the newest
|
|
298
|
+
* group shadows the earlier ones, which is deliberate — it is how a handler
|
|
299
|
+
* reaches its super. The shadowed implementations stay in `importedMap`, and
|
|
300
|
+
* this walks them. It exists for an entry point whose answer is a *composition*
|
|
301
|
+
* of what every provider says rather than a choice between them (the portal
|
|
302
|
+
* configuration is the first), where shadowing is the wrong default.
|
|
303
|
+
*/
|
|
304
|
+
findHandlers(methodName: string): unknown[] {
|
|
305
|
+
const method = this.methodPath(this._methodId(methodName));
|
|
306
|
+
const handlers: unknown[] = [];
|
|
307
|
+
this.importedMap?.forEach(imported => {
|
|
308
|
+
if (Object.prototype.hasOwnProperty.call(imported, method))
|
|
309
|
+
handlers.push(imported[method]);
|
|
310
|
+
});
|
|
311
|
+
return handlers;
|
|
312
|
+
}
|
|
313
|
+
|
|
293
314
|
findHandler(methodName: string): unknown {
|
|
294
315
|
methodName = this.methodPath(this._methodId(methodName));
|
|
295
316
|
return this.imported[methodName];
|
|
@@ -350,7 +371,38 @@ export class AdapterBase<T, C extends IContext> implements AdapterHandlerContext
|
|
|
350
371
|
const method = ($meta && $meta.method) || 'exec';
|
|
351
372
|
const handler = this.findHandler(method) || this.imported['exec'];
|
|
352
373
|
if (handler instanceof Function) {
|
|
353
|
-
|
|
374
|
+
const run = () => handler.apply(this, params);
|
|
375
|
+
// A call the caller declared is answered by writing one record here, inside the
|
|
376
|
+
// leg that arrived: that is what turns the caller's declaration into a receipt
|
|
377
|
+
// the ledger can match, and it happens only when a flow was traced at all, so an
|
|
378
|
+
// untraced request still pays nothing.
|
|
379
|
+
const answered = (): unknown => {
|
|
380
|
+
const inbound = inboundIdentities($meta as IMeta);
|
|
381
|
+
if (isFlowId(inbound.flow) && inbound.leg !== undefined) {
|
|
382
|
+
// The receipt is the receiver's record for the leg it was handed. It carries
|
|
383
|
+
// no declared target - the caller named the ends - so the ledger can tell a
|
|
384
|
+
// receipt from a declaration by the leg alone, in one process or many.
|
|
385
|
+
portLogCalls(this)?.received(inbound.leg);
|
|
386
|
+
}
|
|
387
|
+
return run();
|
|
388
|
+
};
|
|
389
|
+
// The callee adopts the identity its caller declared, so the records its
|
|
390
|
+
// handler writes belong to the caller's execution and its own calls are
|
|
391
|
+
// declared against the same one. Only adopt, never mint: the two places
|
|
392
|
+
// that mint a flow are the external request and the cross-process call,
|
|
393
|
+
// and a port that invented one would fill the ledger with runs nobody
|
|
394
|
+
// asked for.
|
|
395
|
+
//
|
|
396
|
+
// Measured (T-115): the identity *is* on `$meta` here — the gateway route
|
|
397
|
+
// publishes it before it makes the local call — while the callee's handler
|
|
398
|
+
// otherwise runs in whatever context its transport scheduled it in, where
|
|
399
|
+
// `declareCall` finds no flow. What this does *not* do is make a quiet call
|
|
400
|
+
// visible: a recorded call is a record written inside it, and the ports that
|
|
401
|
+
// make calls in a served request log nothing at that moment. The leg is
|
|
402
|
+
// bound correctly; whether it can be seen depends on the callee logging.
|
|
403
|
+
return isFlowId(inboundIdentities($meta as IMeta).flow)
|
|
404
|
+
? adoptInbound($meta as IMeta, method, answered)
|
|
405
|
+
: run();
|
|
354
406
|
} else {
|
|
355
407
|
throw this.errors['adapter.methodNotFound']({params: {method}});
|
|
356
408
|
}
|
package/src/BrowserLog.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
|
-
import {Internal, type ILog, type ILogger} from '@feasibleone/blong/types';
|
|
2
1
|
import {withProgress} from '@feasibleone/blong-lib';
|
|
2
|
+
import {Internal, type ILog, type ILogger} from '@feasibleone/blong/types';
|
|
3
3
|
import type {Level, Logger as PinoLogger} from 'pino';
|
|
4
4
|
|
|
5
5
|
// ── level constants (bunyan numeric levels) ──────────────────────────────────
|
|
@@ -160,6 +160,17 @@ function createLogger(
|
|
|
160
160
|
};
|
|
161
161
|
}
|
|
162
162
|
|
|
163
|
+
/**
|
|
164
|
+
* The browser's console logger, and the reason it has no call channel.
|
|
165
|
+
*
|
|
166
|
+
* A capability is carried in semantic-log's ambient scope, which is
|
|
167
|
+
* `AsyncLocalStorage` — Node-only and deliberately kept out of the page — and a
|
|
168
|
+
* page has no retention store and no cluster sink either, so a call record it
|
|
169
|
+
* could write would exist only to be printed among the messages a developer is
|
|
170
|
+
* already reading. `ILog.calls` is therefore absent here, and the framework's
|
|
171
|
+
* call sites fall silent: the same answer the server gives for a flow that opted
|
|
172
|
+
* out, for a better reason.
|
|
173
|
+
*/
|
|
163
174
|
export default class BrowserLog extends Internal implements ILog {
|
|
164
175
|
#config: IBrowserLogConfig = {level: 'info', logByLevel: false};
|
|
165
176
|
#logger: ISimpleLogger;
|
|
@@ -184,7 +195,10 @@ export default class BrowserLog extends Internal implements ILog {
|
|
|
184
195
|
) as unknown as PinoLogger<T>;
|
|
185
196
|
}
|
|
186
197
|
|
|
187
|
-
public logger(
|
|
198
|
+
public logger(
|
|
199
|
+
level: Level | 'silent' = this.#config.level ?? 'info',
|
|
200
|
+
bindings: object,
|
|
201
|
+
): ILogger {
|
|
188
202
|
const child = this.#logger.child(bindings as Record<string, unknown>, {level});
|
|
189
203
|
const result: ILogger = {
|
|
190
204
|
trace: undefined,
|
package/src/Gateway.ts
CHANGED
|
@@ -2,6 +2,7 @@ import type {
|
|
|
2
2
|
ApiSchema,
|
|
3
3
|
Errors,
|
|
4
4
|
GatewaySchema,
|
|
5
|
+
ICallLog,
|
|
5
6
|
IErrorFactory,
|
|
6
7
|
IErrorMap,
|
|
7
8
|
IGateway,
|
|
@@ -20,12 +21,24 @@ import {Type, type TSchema} from 'typebox';
|
|
|
20
21
|
import Value from 'typebox/value';
|
|
21
22
|
import {v4} from 'uuid';
|
|
22
23
|
|
|
24
|
+
import {TRACE_HEADER, withoutCapabilities} from '@feasibleone/semantic-log/capability';
|
|
25
|
+
import {CALLS_CAPABILITY, GRANT_HEADER} from './callTrace.ts';
|
|
26
|
+
import {grantedCalls, grantKeyFrom} from './grant.ts';
|
|
27
|
+
import {
|
|
28
|
+
callsFor,
|
|
29
|
+
declareCall,
|
|
30
|
+
enterCapability,
|
|
31
|
+
enterRequestFlow,
|
|
32
|
+
runInFlow,
|
|
33
|
+
} from './semanticContext.ts';
|
|
34
|
+
|
|
23
35
|
import {type TypeBoxTypeProvider} from '@fastify/type-provider-typebox';
|
|
24
36
|
import type {IResolution} from './Resolution.ts';
|
|
25
37
|
import type {IRpcClient} from './RpcClient.ts';
|
|
26
38
|
import jwt from './jwt.ts';
|
|
27
39
|
import {isExpectedError, methodId, methodParts, snakeToCamel} from './lib.ts';
|
|
28
40
|
import type {IConfig as IConfigMLE} from './mle.ts';
|
|
41
|
+
import isPublic from './public.ts';
|
|
29
42
|
import swagger from './swagger.ts';
|
|
30
43
|
|
|
31
44
|
const osName: string = [os.type(), os.platform(), os.release()].join(':');
|
|
@@ -169,6 +182,8 @@ export default class Gateway extends Internal implements IGateway {
|
|
|
169
182
|
#resolution: IResolution;
|
|
170
183
|
#log: ILog;
|
|
171
184
|
#logger: ReturnType<ILog['child']> | undefined;
|
|
185
|
+
/** The gateway's own call channel: the framework's record of the calls it makes. */
|
|
186
|
+
#calls: ICallLog | undefined;
|
|
172
187
|
#config: IConfig = {
|
|
173
188
|
host: '0.0.0.0',
|
|
174
189
|
port: 8080,
|
|
@@ -285,6 +300,13 @@ export default class Gateway extends Internal implements IGateway {
|
|
|
285
300
|
return (
|
|
286
301
|
[
|
|
287
302
|
['x-request-id'],
|
|
303
|
+
// The framework's own identity header (see `semanticContext.ts`). It
|
|
304
|
+
// travels in the body as well, but a request that never reached the
|
|
305
|
+
// route handler — the one the hooks rejected — is only readable from
|
|
306
|
+
// here, and this is what lets it keep the flow it was given. The
|
|
307
|
+
// capabilities decided for the flow travel inside it, in its `cap`
|
|
308
|
+
// field, which the earliest hook has already rewritten.
|
|
309
|
+
['x-semantic-trace'],
|
|
288
310
|
['x-b3-traceid', () => v4().replace(/-/g, '')],
|
|
289
311
|
['x-b3-spanid'],
|
|
290
312
|
['x-b3-parentspanid'],
|
|
@@ -531,13 +553,23 @@ export default class Gateway extends Internal implements IGateway {
|
|
|
531
553
|
if (!('result' in value)) {
|
|
532
554
|
const req = this.#local.get(reqName);
|
|
533
555
|
if (!req) return notfound();
|
|
534
|
-
|
|
556
|
+
// The outermost entry mints the flow: everything this
|
|
557
|
+
// request does downstream joins it, and the method it
|
|
558
|
+
// was addressed to is the flow's kind.
|
|
559
|
+
const [result, resultMeta] =
|
|
560
|
+
(await runInFlow(meta as IMeta, methodName, () =>
|
|
561
|
+
req.method(params, meta),
|
|
562
|
+
)) ?? null;
|
|
535
563
|
this._applyMeta(reply, resultMeta as {httpResponse?: unknown});
|
|
536
564
|
return result;
|
|
537
565
|
} else if (id == null) {
|
|
538
566
|
const pub = this.#local.get(pubName);
|
|
539
567
|
if (!pub) return notfound();
|
|
540
|
-
|
|
568
|
+
// A notification is an entry of its own, so it starts a
|
|
569
|
+
// flow rather than joining one nobody sent.
|
|
570
|
+
runInFlow(meta as IMeta, methodName, () =>
|
|
571
|
+
pub.method(params, meta),
|
|
572
|
+
).catch(() => {});
|
|
541
573
|
return {
|
|
542
574
|
jsonrpc: '2.0',
|
|
543
575
|
result: true,
|
|
@@ -545,7 +577,67 @@ export default class Gateway extends Internal implements IGateway {
|
|
|
545
577
|
} else {
|
|
546
578
|
const req = this.#local.get(reqName);
|
|
547
579
|
if (!req) return notfound();
|
|
548
|
-
|
|
580
|
+
// The same entry as the branch above, and it is the ordinary
|
|
581
|
+
// request/response path — which is why it matters most: the
|
|
582
|
+
// `onRequest` hook enters the flow with `enterWith`, and that
|
|
583
|
+
// does not reach a handler the router schedules from its own
|
|
584
|
+
// async resource, so for a while only the *wildcard* branch
|
|
585
|
+
// ran inside a flow. Every call the handler made was then
|
|
586
|
+
// declared in no flow at all, which is why no served request
|
|
587
|
+
// ever produced a leg while tap runs did. `runInFlow` adopts
|
|
588
|
+
// the identity the hook published when the meta carries one,
|
|
589
|
+
// so this does not mint a second flow for the same request.
|
|
590
|
+
const [result, resultMeta] =
|
|
591
|
+
(await runInFlow(meta as IMeta, methodName, () =>
|
|
592
|
+
// Declare the hop this route is making. The caller is
|
|
593
|
+
// **`public`** - the surface that answered the request,
|
|
594
|
+
// named apart from the namespace it calls. The gateway realm
|
|
595
|
+
// is a namespace called `gateway`, so a leg whose caller was
|
|
596
|
+
// also called `gateway` drew the public surface and that
|
|
597
|
+
// namespace as one participant, and a realm answering a call
|
|
598
|
+
// to its own namespace read as a self-call. A leg id names a
|
|
599
|
+
// unit, and the API surface is a unit of its own - not the
|
|
600
|
+
// process either: one process hosts a whole suite, so naming
|
|
601
|
+
// the process here would draw every flow of a monolith as the
|
|
602
|
+
// same participant.
|
|
603
|
+
// `methodName` is already the dotted wire name the leg
|
|
604
|
+
// grammar wants, so the leg names the method the request
|
|
605
|
+
// asked for. The callee's receipt then carries the same leg,
|
|
606
|
+
// which is what makes the drawing a call rather than a
|
|
607
|
+
// participation.
|
|
608
|
+
declareCall(
|
|
609
|
+
'public',
|
|
610
|
+
methodName,
|
|
611
|
+
() => {
|
|
612
|
+
// The caller's own records, written inside the leg it
|
|
613
|
+
// just declared: the callee answers with a receipt for
|
|
614
|
+
// the same leg, and a call is finished when it answers -
|
|
615
|
+
// so the start, the end and the failure all belong here.
|
|
616
|
+
// They go through the call channel rather than the level
|
|
617
|
+
// logger: a call is either wanted for the whole flow or
|
|
618
|
+
// it is not, and a request that records nothing pays for
|
|
619
|
+
// no record at all.
|
|
620
|
+
const calls = this.#calls;
|
|
621
|
+
calls?.start(methodName);
|
|
622
|
+
const answered = req.method(params, meta);
|
|
623
|
+
if (answered instanceof Promise) {
|
|
624
|
+
return Promise.resolve(answered).then(
|
|
625
|
+
value => {
|
|
626
|
+
calls?.end(methodName);
|
|
627
|
+
return value;
|
|
628
|
+
},
|
|
629
|
+
error => {
|
|
630
|
+
calls?.error(methodName, error);
|
|
631
|
+
throw error;
|
|
632
|
+
},
|
|
633
|
+
);
|
|
634
|
+
}
|
|
635
|
+
calls?.end(methodName);
|
|
636
|
+
return answered;
|
|
637
|
+
},
|
|
638
|
+
meta as IMeta,
|
|
639
|
+
),
|
|
640
|
+
)) ?? null;
|
|
549
641
|
this._applyMeta(reply, resultMeta as {httpResponse?: unknown});
|
|
550
642
|
return {
|
|
551
643
|
jsonrpc: '2.0',
|
|
@@ -602,10 +694,21 @@ export default class Gateway extends Internal implements IGateway {
|
|
|
602
694
|
public async start(): Promise<IGateway> {
|
|
603
695
|
const old = this.#server;
|
|
604
696
|
this.#logger = this.#log.child({name: 'gateway'}, {level: this.#config.logLevel});
|
|
697
|
+
// The call channel of *this* component, so its records carry the component the
|
|
698
|
+
// way its level records do (`logger()` binds the name). Taken once, next to
|
|
699
|
+
// the logger it belongs to.
|
|
700
|
+
this.#calls = this.#log.logger(this.#config.logLevel ?? 'info', {name: 'gateway'}).calls;
|
|
605
701
|
try {
|
|
606
702
|
this.#server = fastify({
|
|
607
703
|
loggerInstance: this.#logger,
|
|
608
704
|
forceCloseConnections: true,
|
|
705
|
+
// fastify's own request logging is off, and the framework's records
|
|
706
|
+
// replace it: it writes the whole request at `info` — headers, the
|
|
707
|
+
// `Authorization` bearer among them — and a completion line, which is
|
|
708
|
+
// both more than a reader needs and less than the flow records say.
|
|
709
|
+
// Nothing else depends on those two lines: the request has a flow, the
|
|
710
|
+
// flow has its calls, and both are recorded where they belong.
|
|
711
|
+
disableRequestLogging: true,
|
|
609
712
|
ajv: {
|
|
610
713
|
customOptions: {
|
|
611
714
|
allErrors: true,
|
|
@@ -646,6 +749,68 @@ export default class Gateway extends Internal implements IGateway {
|
|
|
646
749
|
});
|
|
647
750
|
},
|
|
648
751
|
);
|
|
752
|
+
// The entry of a request is its earliest hook, not its route handler: the
|
|
753
|
+
// authentication and metering hooks run in between, and a call they reject
|
|
754
|
+
// has no route to be wrapped by, so its records would name no flow at all
|
|
755
|
+
// (T-105). The identity is published back onto the request headers, which is
|
|
756
|
+
// where the route handler reads it from when it adopts this same flow.
|
|
757
|
+
//
|
|
758
|
+
// Declared with the three-argument callback shape on purpose. Fastify picks the
|
|
759
|
+
// hook's style from its arity, and a plain function of one argument is awaited
|
|
760
|
+
// nowhere: every request then stopped in this hook, which looked for a day like a
|
|
761
|
+
// slow database rather than a signature - the MLE readiness probe timed out at
|
|
762
|
+
// startup and every realm's tap run failed with a different symptom each time.
|
|
763
|
+
//
|
|
764
|
+
// The metadata endpoints and the static assets are not calls: they are the
|
|
765
|
+
// framework's own plumbing (the MLE key endpoint, the API documentation, what a
|
|
766
|
+
// browser fetches), and minting an execution per probe filled the observed kinds
|
|
767
|
+
// with names like `login..well-known.mle` - a deployment's health checks read as
|
|
768
|
+
// traffic in an observation of its business.
|
|
769
|
+
this.#server.addHook(
|
|
770
|
+
'onRequest',
|
|
771
|
+
async (request: FastifyRequest, _reply: FastifyReply): Promise<void> => {
|
|
772
|
+
const pathname = new URL(request.url, 'http://localhost').pathname;
|
|
773
|
+
if (isPublic(pathname) && pathname.includes('/.well-known/')) {
|
|
774
|
+
return;
|
|
775
|
+
}
|
|
776
|
+
// Only what the earliest hook can see: the route handler knows the
|
|
777
|
+
// real method and re-enters this flow with it, so a path that does
|
|
778
|
+
// not name one is left unlabelled rather than guessed at.
|
|
779
|
+
const kind = pathname.startsWith('/rpc/')
|
|
780
|
+
? pathname.slice(5).split('/').filter(Boolean).join('.')
|
|
781
|
+
: '';
|
|
782
|
+
// A capability decides what this process may cost itself, so an
|
|
783
|
+
// outside caller does not get to set one: the field is stripped
|
|
784
|
+
// from the identity it arrived with, and the decision is made here
|
|
785
|
+
// and published in its place. The rest of the identity is adopted
|
|
786
|
+
// exactly as it arrived.
|
|
787
|
+
const identity = withoutCapabilities(
|
|
788
|
+
request.headers[TRACE_HEADER] as string | undefined,
|
|
789
|
+
);
|
|
790
|
+
if (identity.length > 0) {
|
|
791
|
+
request.headers[TRACE_HEADER] = identity;
|
|
792
|
+
} else {
|
|
793
|
+
delete request.headers[TRACE_HEADER];
|
|
794
|
+
}
|
|
795
|
+
// The grant, if one arrived, is verified here and now — once per
|
|
796
|
+
// request, at the earliest point there is — and what travels on is
|
|
797
|
+
// the capability it decided, not the token.
|
|
798
|
+
const grantedCallsToRecord = await grantedCalls(
|
|
799
|
+
request.headers[GRANT_HEADER] as string | undefined,
|
|
800
|
+
grantKeyFrom(this.#config),
|
|
801
|
+
);
|
|
802
|
+
// A grant wins over the configuration; with none, the configuration
|
|
803
|
+
// answers and the answer is published for the process that serves
|
|
804
|
+
// the call. Either way the flow is decided here, before the hooks
|
|
805
|
+
// that may reject the request: a flow the entry point refused to
|
|
806
|
+
// record is not recorded by the service it reaches.
|
|
807
|
+
enterCapability(
|
|
808
|
+
CALLS_CAPABILITY,
|
|
809
|
+
grantedCallsToRecord || callsFor({forward: request.headers} as IMeta, kind),
|
|
810
|
+
);
|
|
811
|
+
Object.assign(request.headers, enterRequestFlow(request.headers, kind));
|
|
812
|
+
},
|
|
813
|
+
);
|
|
649
814
|
await this.#server.register(jwt, {
|
|
650
815
|
cache: this.#config.jwt?.cache,
|
|
651
816
|
verify: (token: string, options: {nonce?: string; audience: string}) =>
|
|
@@ -704,6 +869,13 @@ export default class Gateway extends Internal implements IGateway {
|
|
|
704
869
|
}
|
|
705
870
|
|
|
706
871
|
public async stop(): Promise<IGateway> {
|
|
872
|
+
// fastify's close() waits on the socket layer, and keep-alive connections made by a
|
|
873
|
+
// browser backend (or a leftover probe) hold it open indefinitely: the run ends with
|
|
874
|
+
// a tap "timeout!" against a live server long after every test passed, because the
|
|
875
|
+
// process never exits. Close the connections first so shutting down actually
|
|
876
|
+
// shuts down.
|
|
877
|
+
this.#server?.server.closeIdleConnections?.();
|
|
878
|
+
this.#server?.server.closeAllConnections?.();
|
|
707
879
|
await this.#server?.close();
|
|
708
880
|
this.#server = null;
|
|
709
881
|
return this;
|
package/src/Log.ts
CHANGED
|
@@ -1,12 +1,33 @@
|
|
|
1
1
|
import {withProgress} from '@feasibleone/blong-lib';
|
|
2
|
-
import {Internal, type ILog, type ILogger} from '@feasibleone/blong/types';
|
|
2
|
+
import {Internal, type ICallLog, type ILog, type ILogger} from '@feasibleone/blong/types';
|
|
3
|
+
import {createCallChannel} from '@feasibleone/semantic-log/capability';
|
|
3
4
|
import {pino, type Logger, type LoggerOptions} from 'pino';
|
|
4
5
|
import {monotonicFactory} from 'ulidx';
|
|
6
|
+
import {callRecord, configureCallTrace, type CallTraceConfig} from './callTrace.ts';
|
|
5
7
|
import type {CacacheTransportOptions} from './pino-cacache.js';
|
|
6
8
|
|
|
7
9
|
// echo -e "\u001B]8;;https://google.com\u001B\\Кликни тук\u001B]8;;\e\\"
|
|
8
10
|
|
|
9
11
|
export interface LogConfig extends LoggerOptions {
|
|
12
|
+
/**
|
|
13
|
+
* Which implementation is active. This class serves every value except
|
|
14
|
+
* `semantic`, so it never acts on the key — it is declared because the merged
|
|
15
|
+
* `log` config is handed to whichever implementation the loader chose, and
|
|
16
|
+
* stripped before the rest reaches pino.
|
|
17
|
+
*/
|
|
18
|
+
impl?: string;
|
|
19
|
+
/** The semantic implementation's store. Not a pino option; stripped below. */
|
|
20
|
+
cache?: unknown;
|
|
21
|
+
/**
|
|
22
|
+
* The call channel: whether a flow's calls are recorded, and whether they
|
|
23
|
+
* are shown.
|
|
24
|
+
*
|
|
25
|
+
* `stdout` is the whole switch under this implementation, because pino has
|
|
26
|
+
* no retention store and no cluster sink - a record it is not printing does
|
|
27
|
+
* not exist. The framework's `enabled`/`off` decision still applies first,
|
|
28
|
+
* so a flow that opted out records nothing whichever logger runs.
|
|
29
|
+
*/
|
|
30
|
+
calls?: CallTraceConfig;
|
|
10
31
|
/** When provided, log entries are cached to disk via cacache for later inspection. */
|
|
11
32
|
cacache?: CacacheTransportOptions;
|
|
12
33
|
}
|
|
@@ -84,6 +105,7 @@ export default class Log extends Internal implements ILog {
|
|
|
84
105
|
public constructor(config: LogConfig) {
|
|
85
106
|
super();
|
|
86
107
|
this.merge(this.#config, config);
|
|
108
|
+
configureCallTrace(this.#config.calls);
|
|
87
109
|
|
|
88
110
|
// Inject a monotonic ULID `id` into every log entry before it reaches any transport
|
|
89
111
|
|
|
@@ -103,11 +125,30 @@ export default class Log extends Internal implements ILog {
|
|
|
103
125
|
};
|
|
104
126
|
}
|
|
105
127
|
|
|
106
|
-
// Remove the
|
|
107
|
-
|
|
128
|
+
// Remove the framework's own keys before passing the rest to pino — none
|
|
129
|
+
// of them is a pino option. `impl` chose this implementation (reaching
|
|
130
|
+
// here means it is not `semantic`) and `cache` configures the semantic
|
|
131
|
+
// implementation's store; `cacache` is this implementation's transport
|
|
132
|
+
// and is passed to it separately, through the transport targets below.
|
|
133
|
+
const {
|
|
134
|
+
cacache: _cacacheConfig,
|
|
135
|
+
impl: _impl,
|
|
136
|
+
cache: _semanticCache,
|
|
137
|
+
calls: _calls,
|
|
138
|
+
...pinoConfig
|
|
139
|
+
} = this.#config;
|
|
108
140
|
this.#logger = pino(pinoConfig);
|
|
109
141
|
}
|
|
110
142
|
|
|
143
|
+
/**
|
|
144
|
+
* Attach the call channel's scope.
|
|
145
|
+
*
|
|
146
|
+
* Nothing to attach: the ambient scope a capability is carried in is
|
|
147
|
+
* semantic-log's own (`AsyncLocalStorage`), reached by the vocabulary the
|
|
148
|
+
* server bootstrap attaches, and the channel reads it directly.
|
|
149
|
+
*/
|
|
150
|
+
public async init(): Promise<void> {}
|
|
151
|
+
|
|
111
152
|
public child<T extends string>(...params: Parameters<Logger<never>['child']>): Logger<T> {
|
|
112
153
|
return this.#logger.child(...params) as Logger<T>;
|
|
113
154
|
}
|
|
@@ -142,6 +183,35 @@ export default class Log extends Internal implements ILog {
|
|
|
142
183
|
return {
|
|
143
184
|
...result,
|
|
144
185
|
progress: (label, promise, options) => withProgress(result, label, promise, options),
|
|
186
|
+
calls: this.#callsChannel(child),
|
|
145
187
|
};
|
|
146
188
|
}
|
|
189
|
+
|
|
190
|
+
/**
|
|
191
|
+
* The framework's own account of the calls made through this log.
|
|
192
|
+
*
|
|
193
|
+
* Written through the logger it is asked of, so a component's call records carry
|
|
194
|
+
* the component — the same `name` binding every other record from it does. Under
|
|
195
|
+
* pino the destination *is* the level: there is nothing to store, so a call
|
|
196
|
+
* record that is not printed has nowhere else to go. Whether the flow records at
|
|
197
|
+
* all is the capability's decision, asked before the writer is called, and it is
|
|
198
|
+
* the same one the semantic implementation asks.
|
|
199
|
+
*/
|
|
200
|
+
#callsChannel(child: Logger): ICallLog {
|
|
201
|
+
return createCallChannel(({leg, phase, message, error, fields}) => {
|
|
202
|
+
child.info(
|
|
203
|
+
{
|
|
204
|
+
...callRecord(leg, phase),
|
|
205
|
+
...(error === undefined ? {} : {err: error}),
|
|
206
|
+
...fields,
|
|
207
|
+
},
|
|
208
|
+
message,
|
|
209
|
+
);
|
|
210
|
+
});
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
/** The call channel, as a component that holds the log rather than a logger reaches it. */
|
|
214
|
+
public get calls(): ICallLog {
|
|
215
|
+
return this.#callsChannel(this.#logger);
|
|
216
|
+
}
|
|
147
217
|
}
|
package/src/Registry.ts
CHANGED
|
@@ -530,6 +530,13 @@ export default class Registry extends Internal implements IRegistry {
|
|
|
530
530
|
);
|
|
531
531
|
this.#stopped = true;
|
|
532
532
|
this.#logger = undefined;
|
|
533
|
+
// The log implementation can hold something the process will not exit without: the
|
|
534
|
+
// semantic log's in-process cluster service listens on a socket (9455 by default), and
|
|
535
|
+
// a run that closes every other server still hangs on it — a tap suite that passed
|
|
536
|
+
// every test then reports "timeout!" against a live server. Stopping it here is what
|
|
537
|
+
// makes `stop()` mean "nothing of this platform is still running". Optional because
|
|
538
|
+
// the plain log implementation has nothing to stop.
|
|
539
|
+
await (this.#log as {stop?: () => Promise<void>})?.stop?.();
|
|
533
540
|
return this;
|
|
534
541
|
}
|
|
535
542
|
|
package/src/RpcServer.ts
CHANGED
|
@@ -3,6 +3,7 @@ import {Internal, type ILog, type IManifest, type IMeta, type IRpcServer} from '
|
|
|
3
3
|
import fastify, {type FastifyReply, type FastifyRequest, type RouteOptions} from 'fastify';
|
|
4
4
|
|
|
5
5
|
import type {IResolution} from './Resolution.ts';
|
|
6
|
+
import {adoptInbound} from './semanticContext.ts';
|
|
6
7
|
|
|
7
8
|
interface IConfig {
|
|
8
9
|
port: number;
|
|
@@ -70,9 +71,15 @@ export default class RpcServer extends Internal implements IRpcServer {
|
|
|
70
71
|
opcode: method.split('.').pop(),
|
|
71
72
|
};
|
|
72
73
|
attachCheckpoint?.(newMeta as IMeta);
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
74
|
+
// An inbound call joins the flow it was made in when it carries one,
|
|
75
|
+
// and is an outermost entry when it does not: a peer that sends no
|
|
76
|
+
// identity gets a flow of its own rather than a continuation of a flow
|
|
77
|
+
// nobody named.
|
|
78
|
+
const result = await adoptInbound(newMeta as IMeta, method, () =>
|
|
79
|
+
(callback as (...args: unknown[]) => Promise<unknown>).apply(object, [
|
|
80
|
+
...params,
|
|
81
|
+
newMeta,
|
|
82
|
+
]),
|
|
76
83
|
);
|
|
77
84
|
return {
|
|
78
85
|
jsonrpc: '2.0',
|
|
@@ -161,6 +168,10 @@ export default class RpcServer extends Internal implements IRpcServer {
|
|
|
161
168
|
}
|
|
162
169
|
|
|
163
170
|
public async stop(): Promise<IRpcServer> {
|
|
171
|
+
// See Gateway.stop: leftover keep-alive connections would hold the process open and
|
|
172
|
+
// turn a green run into a tap "timeout!".
|
|
173
|
+
this.#server.server.closeIdleConnections?.();
|
|
174
|
+
this.#server.server.closeAllConnections?.();
|
|
164
175
|
await this.#server.close();
|
|
165
176
|
return this;
|
|
166
177
|
}
|