@feasibleone/blong-gogo 1.38.1 → 1.40.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 +35 -0
- package/package.json +6 -6
- package/src/ConfigRuntime.ts +8 -1
- package/src/Gateway.ts +65 -11
- package/src/GatewayCodec.ts +60 -1
- package/src/Log.ts +83 -25
- package/src/Registry.ts +22 -0
- package/src/Remote.ts +63 -5
- package/src/ResolutionK8s.ts +71 -0
- package/src/RpcClient.ts +11 -1
- package/src/SemanticLog.test.ts +14 -4
- package/src/SemanticLog.ts +22 -2
- package/src/SystemDebug.test.ts +83 -0
- package/src/SystemDebug.ts +29 -2
- package/src/acl.test.ts +74 -0
- package/src/adapter/schema/knex/types.ts +18 -0
- package/src/adapter/server/acl.ts +6 -1
- package/src/adapter/server/k8s.test.ts +189 -0
- package/src/adapter/server/k8s.ts +540 -172
- package/src/adapter/server/kafka.ts +100 -47
- package/src/adapter/server/knex-connection.test.ts +79 -0
- package/src/adapter/server/knex.ts +70 -12
- package/src/adapter/server/pathRefresh.test.ts +26 -0
- package/src/adapter/server/pathRefresh.ts +30 -6
- package/src/busGateway.ts +41 -5
- package/src/chain.allure.test.ts +55 -0
- package/src/chain.ts +82 -6
- package/src/cliHelp.ts +7 -7
- package/src/codec/adapter/mle/ready.ts +22 -1
- package/src/codec/server.ts +5 -0
- package/src/devKeys.ts +2 -2
- package/src/exit.intent.test.ts +2 -2
- package/src/frameworkRealms.test.ts +75 -7
- package/src/gatewayKeys.test.ts +104 -0
- package/src/gatewayKeys.ts +68 -0
- package/src/grant.ts +8 -7
- package/src/intentsExclusion.test.ts +71 -0
- package/src/jwt.ts +2 -6
- package/src/lib.test.ts +46 -1
- package/src/lib.ts +64 -15
- package/src/load.ts +225 -10
- package/src/mle.test.ts +124 -0
- package/src/mle.ts +13 -0
- package/src/oidc.ts +39 -5
- package/src/pino-pretty.test.ts +81 -0
- package/src/pino-pretty.ts +119 -16
- package/src/releaseRc.test.ts +92 -0
- package/src/remoteCanSkip.test.ts +76 -0
- package/src/runServer.test.ts +34 -2
- package/src/runServer.ts +10 -2
- package/src/semanticRecord.ts +20 -8
- package/src/skipGateway.test.ts +112 -0
- package/src/static.test.ts +57 -0
- package/src/static.ts +13 -1
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,40 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## [1.40.0](https://github.com/feasibleone/blong/compare/blong-gogo-v1.39.0...blong-gogo-v1.40.0) (2026-10-13)
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
### Features
|
|
7
|
+
|
|
8
|
+
* blong-kustomize improvements ([0a07e50](https://github.com/feasibleone/blong/commit/0a07e50275208a3fd8a23476e0abe9334ec3822d))
|
|
9
|
+
* blong-kustomize operator UI ([2599dab](https://github.com/feasibleone/blong/commit/2599dab9c5ac734c9949f9af9b11fcb181eb8c7f))
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
### Bug Fixes
|
|
13
|
+
|
|
14
|
+
* flaky tests ([226d4a6](https://github.com/feasibleone/blong/commit/226d4a6c1bb912e2fff0919bda975ba487d3e060))
|
|
15
|
+
|
|
16
|
+
## [1.39.0](https://github.com/feasibleone/blong/compare/blong-gogo-v1.38.1...blong-gogo-v1.39.0) (2026-10-11)
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
### Features
|
|
20
|
+
|
|
21
|
+
* blong-kustomize ([0ad6915](https://github.com/feasibleone/blong/commit/0ad6915af0d3c300a8741a509232aec7649999a1))
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
### Bug Fixes
|
|
25
|
+
|
|
26
|
+
* address CI issues ([83b8957](https://github.com/feasibleone/blong/commit/83b895773bb020e1e1424513ecffd3e15b13dec6))
|
|
27
|
+
* address CI issues ([d23b652](https://github.com/feasibleone/blong/commit/d23b652b79eebfbaf46716ee4d7e7381730b384e))
|
|
28
|
+
* address CI issues ([10cc9bf](https://github.com/feasibleone/blong/commit/10cc9bfe68f3f8bc40dba97eb627b303e1fe64b5))
|
|
29
|
+
* address CI issues ([a2ff403](https://github.com/feasibleone/blong/commit/a2ff4035086ecb726d9df08798dae4a5556af7dd))
|
|
30
|
+
* address CI issues ([5ce588b](https://github.com/feasibleone/blong/commit/5ce588b763bee75afc2fca603998252d41443da5))
|
|
31
|
+
* address CI issues ([d32fbb1](https://github.com/feasibleone/blong/commit/d32fbb10cc945db43eb492606792f63bfb848ae6))
|
|
32
|
+
* address CI issues ([bea0f62](https://github.com/feasibleone/blong/commit/bea0f62aba2dcef95cc4363b02c4ee3e05c620b1))
|
|
33
|
+
* address CI issues, improve snapshots ([a86310a](https://github.com/feasibleone/blong/commit/a86310a8317e28e610a33072a483afc83b4b5bd2))
|
|
34
|
+
* address latency issue ([7504310](https://github.com/feasibleone/blong/commit/750431029b7345100e4124675f36404b6e1693fa))
|
|
35
|
+
* kustomize layout ([1e0eb50](https://github.com/feasibleone/blong/commit/1e0eb506617862db0e5e19f33d18ee0121c6aa42))
|
|
36
|
+
* test ACL with cucumber ([3a84be9](https://github.com/feasibleone/blong/commit/3a84be99a1544dd7ae599cdd976191cf21cabba9))
|
|
37
|
+
|
|
3
38
|
## [1.38.1](https://github.com/feasibleone/blong/compare/blong-gogo-v1.38.0...blong-gogo-v1.38.1) (2026-10-01)
|
|
4
39
|
|
|
5
40
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@feasibleone/blong-gogo",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.40.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/blong-template": "1.0.0",
|
|
41
42
|
"@feasibleone/semantic-log": "^1.0.0",
|
|
42
43
|
"@keycloak/keycloak-admin-client": "^26.6.2",
|
|
43
44
|
"@kubernetes/client-node": "^1.4.0",
|
|
@@ -79,10 +80,10 @@
|
|
|
79
80
|
"ut-function.interpolate": "1.1.3",
|
|
80
81
|
"ut-function.merge": "^1.5.6",
|
|
81
82
|
"uuid": "^13.0.2",
|
|
82
|
-
"yaml": "^2.8.3"
|
|
83
|
-
"@feasibleone/blong-template": "1.0.0"
|
|
83
|
+
"yaml": "^2.8.3"
|
|
84
84
|
},
|
|
85
85
|
"devDependencies": {
|
|
86
|
+
"@feasibleone/blong-dev": "1.10.0",
|
|
86
87
|
"@rushstack/heft": "^1.2.6",
|
|
87
88
|
"@rushstack/heft-lint-plugin": "^1.2.6",
|
|
88
89
|
"@rushstack/heft-typescript-plugin": "^1.3.1",
|
|
@@ -91,14 +92,13 @@
|
|
|
91
92
|
"playwright": "^1.63.0",
|
|
92
93
|
"tap": "^21.6.3",
|
|
93
94
|
"typescript": "^6.0.3",
|
|
94
|
-
"vite": "^8.2.1"
|
|
95
|
-
"@feasibleone/blong-dev": "1.8.0"
|
|
95
|
+
"vite": "^8.2.1"
|
|
96
96
|
},
|
|
97
97
|
"scripts": {
|
|
98
98
|
"browser-check": "node scripts/browser-compat-check.mjs",
|
|
99
99
|
"build": "true",
|
|
100
100
|
"ci-coverage": "./run-coverage.sh",
|
|
101
|
-
"ci-lint": "blong-dev lint",
|
|
101
|
+
"ci-lint": "blong-dev lint && node docker/manifests.mjs --check",
|
|
102
102
|
"ci-publish": "node ../../common/scripts/install-run-rush-pnpm.js publish --access public --provenance",
|
|
103
103
|
"ci-test": "blong-dev test",
|
|
104
104
|
"test": "blong-dev test"
|
package/src/ConfigRuntime.ts
CHANGED
|
@@ -314,7 +314,14 @@ export default class ConfigRuntime implements IConfigRuntime {
|
|
|
314
314
|
}
|
|
315
315
|
|
|
316
316
|
private mergeConfigs(blongConfig: object): object {
|
|
317
|
-
|
|
317
|
+
// The intents travel into `blong-config`, which reads them out of `env`. They decide which
|
|
318
|
+
// rc files a process reads at all — the *trailing* one is the suffix both source names are
|
|
319
|
+
// built from, the framework's existing convention — so a config step that cannot see them
|
|
320
|
+
// leaves every process reading the `dev` file whatever it was asked to be (Phase 15 E).
|
|
321
|
+
const intents = (this.#baseConfig as {configNames?: string[]}).configNames;
|
|
322
|
+
const loaded = loadBlong(
|
|
323
|
+
intents?.length ? {env: intents.join(','), ...blongConfig} : blongConfig,
|
|
324
|
+
);
|
|
318
325
|
return merge({}, this.#baseConfig, ...this.#configs, loaded);
|
|
319
326
|
}
|
|
320
327
|
|
package/src/Gateway.ts
CHANGED
|
@@ -28,6 +28,7 @@ import {v4} from 'uuid';
|
|
|
28
28
|
|
|
29
29
|
import {TRACE_HEADER, withoutCapabilities} from '@feasibleone/semantic-log/capability';
|
|
30
30
|
import {CALLS_CAPABILITY, GRANT_HEADER} from './callTrace.ts';
|
|
31
|
+
import {GENERATED_KEY_SPEC, GENERATED_PAIR_WARNING} from './gatewayKeys.ts';
|
|
31
32
|
import {grantedCalls, grantKeyFrom} from './grant.ts';
|
|
32
33
|
import {
|
|
33
34
|
callsFor,
|
|
@@ -66,6 +67,17 @@ type GatewayRequest = FastifyRequest;
|
|
|
66
67
|
|
|
67
68
|
const errorMap: IErrorMap = {
|
|
68
69
|
'gateway.jwtMissingHeader': {message: 'Missing bearer authorization header', statusCode: 401},
|
|
70
|
+
// The method-level refusal of RBAC. Typed because a test that probes a denial
|
|
71
|
+
// on purpose declares it in `expect`, and the demotion matches on the type —
|
|
72
|
+
// an untyped `Error` is always logged at error level.
|
|
73
|
+
'gateway.notAllowed': {
|
|
74
|
+
message: 'Authorization denied: method "{methodName}" not allowed',
|
|
75
|
+
statusCode: 403,
|
|
76
|
+
},
|
|
77
|
+
'gateway.noActions': {
|
|
78
|
+
message: 'Authorization denied: no actions resolved',
|
|
79
|
+
statusCode: 403,
|
|
80
|
+
},
|
|
69
81
|
};
|
|
70
82
|
|
|
71
83
|
declare module 'fastify' {
|
|
@@ -234,6 +246,21 @@ export default class Gateway extends Internal implements IGateway {
|
|
|
234
246
|
};
|
|
235
247
|
});
|
|
236
248
|
}
|
|
249
|
+
|
|
250
|
+
/**
|
|
251
|
+
* The browser build this process was configured to serve, as the merge resolved it.
|
|
252
|
+
*
|
|
253
|
+
* `config()` is protected on purpose: that subtree carries the key pair (`sign`, `encrypt`), the
|
|
254
|
+
* exposure D-448 keeps shut. *Which* static root arrived is a different question, and one a test
|
|
255
|
+
* could not ask anywhere — the plugin's own behaviour is covered (`static.test.ts`: it serves a
|
|
256
|
+
* bundle and tolerates a missing one), while nothing could see that a process's own block reaches
|
|
257
|
+
* this port at all. That leg is what a suite's `release` block naming the artifact's browser build
|
|
258
|
+
* depends on, and its failure is silent: the plugin is registered only when the config is there,
|
|
259
|
+
* so `/s` answers nothing while every other surface works (T-246).
|
|
260
|
+
*/
|
|
261
|
+
public staticRoot(): string | undefined {
|
|
262
|
+
return this.#config.static?.root;
|
|
263
|
+
}
|
|
237
264
|
#errorFields: [string, unknown][] = [];
|
|
238
265
|
#plugins: {plugin: unknown; options: unknown}[] = [];
|
|
239
266
|
#platform: IPlatformApi;
|
|
@@ -264,16 +291,22 @@ export default class Gateway extends Internal implements IGateway {
|
|
|
264
291
|
this.#platform = platform!;
|
|
265
292
|
|
|
266
293
|
this.merge(this.#config, config);
|
|
267
|
-
this
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
)
|
|
294
|
+
// A pair reaches this config as ordinary configuration — the deployment's rc file
|
|
295
|
+
// (`gateway.sign` / `gateway.encrypt` under the pod's home), the `dev` block's committed
|
|
296
|
+
// literals, or a `--gateway.sign=…` flag — and a process given none generates its own. That
|
|
297
|
+
// last case is worth a warning rather than silence: two replicas of one suite would then
|
|
298
|
+
// mint tokens the other cannot verify, and the symptom is a rejected session somewhere else,
|
|
299
|
+
// long after the cause. A deployment that mounts the file never sees this line (D-441).
|
|
300
|
+
const generated = !this.#config.sign || !this.#config.encrypt;
|
|
301
|
+
this.#config.sign ||= GENERATED_KEY_SPEC.sign as IConfig['sign'];
|
|
302
|
+
this.#config.encrypt ||= GENERATED_KEY_SPEC.encrypt as IConfig['encrypt'];
|
|
303
|
+
if (generated) {
|
|
304
|
+
// The loader resolves the pair for every process that serves a gateway
|
|
305
|
+
// (`resolveGatewayPair`), so a missing half here means a gateway built *without* one — a
|
|
306
|
+
// component constructed directly by a test. Kept as the last resort it always was, with
|
|
307
|
+
// the same descriptors and the same sentence the loader uses.
|
|
308
|
+
this.#log?.logger('warn', {name: 'gateway'})?.warn?.({}, GENERATED_PAIR_WARNING);
|
|
309
|
+
}
|
|
277
310
|
this.#errorFields = Object.entries({
|
|
278
311
|
type: true,
|
|
279
312
|
message: true,
|
|
@@ -441,6 +474,15 @@ export default class Gateway extends Internal implements IGateway {
|
|
|
441
474
|
id: Type.Optional(Type.Union([Type.String(), Type.Number()])),
|
|
442
475
|
method: isWildcard ? Type.String() : Type.Literal(method),
|
|
443
476
|
params: value.params,
|
|
477
|
+
// A caller may declare the errors it expects. The
|
|
478
|
+
// gateway only *honours* it when configured to
|
|
479
|
+
// (`expectedErrors`), but the field has to survive
|
|
480
|
+
// validation for the body to reach the route at all —
|
|
481
|
+
// without it the declaration was silently dropped and
|
|
482
|
+
// every expected failure was logged as a surprise.
|
|
483
|
+
expect: Type.Optional(
|
|
484
|
+
Type.Union([Type.String(), Type.Array(Type.String())]),
|
|
485
|
+
),
|
|
444
486
|
}),
|
|
445
487
|
}
|
|
446
488
|
: undefined),
|
|
@@ -749,7 +791,19 @@ export default class Gateway extends Internal implements IGateway {
|
|
|
749
791
|
|
|
750
792
|
this.#server.setErrorHandler(
|
|
751
793
|
(error: Record<string, unknown>, request: FastifyRequest, reply: FastifyReply) => {
|
|
752
|
-
|
|
794
|
+
// A hook that refuses before the route runs (authentication, RBAC)
|
|
795
|
+
// never reaches the handler that resolves `expect` for `$meta`, so
|
|
796
|
+
// the declaration is read from the request body here: the caller
|
|
797
|
+
// asked for this failure and the error stream is for the ones it did
|
|
798
|
+
// not ask for.
|
|
799
|
+
const expected = this.#config.expectedErrors
|
|
800
|
+
? (request.body as {expect?: string | string[]} | undefined)?.expect
|
|
801
|
+
: undefined;
|
|
802
|
+
if (isExpectedError(error.type as string | undefined, expected)) {
|
|
803
|
+
request.log.debug?.({err: error}, 'gateway expected error');
|
|
804
|
+
} else {
|
|
805
|
+
request.log.error({err: error}, 'gateway unhandled error');
|
|
806
|
+
}
|
|
753
807
|
return reply.status((error as {statusCode?: number}).statusCode || 500).send({
|
|
754
808
|
jsonrpc: '2.0',
|
|
755
809
|
id: (request.body as {id?: unknown})?.id,
|
package/src/GatewayCodec.ts
CHANGED
|
@@ -39,6 +39,28 @@ export interface IGatewayCodec {
|
|
|
39
39
|
export interface IConfig {
|
|
40
40
|
client?: {tls?: {ca?: string | string[]; key?: string; cert?: string; crl?: string}};
|
|
41
41
|
gateway?: object;
|
|
42
|
+
/**
|
|
43
|
+
* Whether a method must *not* be carried by the gateway this config names: `true` for every method,
|
|
44
|
+
* `false` for none, or a regular-expression *source* matched against the method name for the ones
|
|
45
|
+
* that belong here.
|
|
46
|
+
*
|
|
47
|
+
* The routing decision is made here — a gateway is where an *outside* caller reaches a process, and
|
|
48
|
+
* `gateway()` is what says whether a call is handed to one — so this is where a method that has to
|
|
49
|
+
* be answered at home is kept out of it. The name the framework needs it for is its own session
|
|
50
|
+
* check: `identity.checkInternal` is asked by the process that verified the token, from inside that
|
|
51
|
+
* verification, and a realm that answers its own login is one process. Routed through a gateway the
|
|
52
|
+
* call reached the gateway itself, was refused for a missing bearer and came back as a null the
|
|
53
|
+
* session step destructured (F-497) — a hardcoded name here until this setting replaced it, because
|
|
54
|
+
* a deployment that routes namespaces through a gateway is the one that knows which of its own
|
|
55
|
+
* methods have to stay home.
|
|
56
|
+
*
|
|
57
|
+
* A skipped method falls back to the ordinary path, and what that resolves to is the resolver's
|
|
58
|
+
* answer: `remote.canSkipSocket` decides whether the process answers it in-process or sends it to
|
|
59
|
+
* the namespace. The two settings compose rather than overlap — this one says "not through the
|
|
60
|
+
* gateway", that one says "answered here" — which is why the resolver asks its question only after
|
|
61
|
+
* this one has let the call through.
|
|
62
|
+
*/
|
|
63
|
+
skipGateway?: boolean | string;
|
|
42
64
|
protocol?: Protocol;
|
|
43
65
|
host?: string;
|
|
44
66
|
port?: string;
|
|
@@ -56,6 +78,14 @@ export default class GatewayCodecImpl implements IGatewayCodec {
|
|
|
56
78
|
#config: IConfig;
|
|
57
79
|
#tlsClient: object;
|
|
58
80
|
#resolution: IResolution;
|
|
81
|
+
/**
|
|
82
|
+
* The `skipGateway` source as it was compiled, and the pattern — `null` for a source that did not
|
|
83
|
+
* compile.
|
|
84
|
+
*
|
|
85
|
+
* Kept beside the source for the reason the resolver keeps its own: `gateway()` runs for every
|
|
86
|
+
* outgoing call, and compiling the same expression per call is work that only the first call needs.
|
|
87
|
+
*/
|
|
88
|
+
#skipGateway: {source: boolean | string; pattern: RegExp | null} | undefined;
|
|
59
89
|
|
|
60
90
|
public verify!: IGatewayCodec['verify'];
|
|
61
91
|
|
|
@@ -170,8 +200,37 @@ export default class GatewayCodecImpl implements IGatewayCodec {
|
|
|
170
200
|
this.verify = verify as IGatewayCodec['verify'];
|
|
171
201
|
}
|
|
172
202
|
|
|
203
|
+
/**
|
|
204
|
+
* Whether this call is one the gateway must not carry ({@link IConfig.skipGateway}).
|
|
205
|
+
*
|
|
206
|
+
* A source that does not compile is treated as nothing matching rather than as every method: a typo
|
|
207
|
+
* in a config must not decide that no call is ever routed, and the alternative — refusing to start —
|
|
208
|
+
* would take a deployment down over a string. Nothing is logged here because this class has no
|
|
209
|
+
* logger: it is handed one by the caller that owns the logs, and the silent treatment is the same
|
|
210
|
+
* answer `Remote.canAnswerLocally` reports through its own.
|
|
211
|
+
*/
|
|
212
|
+
private _skipsGateway(methodName: string): boolean {
|
|
213
|
+
const {skipGateway} = this.#config;
|
|
214
|
+
if (skipGateway === true) return true;
|
|
215
|
+
if (typeof skipGateway !== 'string') return false;
|
|
216
|
+
if (this.#skipGateway?.source !== skipGateway) {
|
|
217
|
+
let pattern: RegExp | null = null;
|
|
218
|
+
try {
|
|
219
|
+
pattern = new RegExp(skipGateway);
|
|
220
|
+
} catch {
|
|
221
|
+
pattern = null;
|
|
222
|
+
}
|
|
223
|
+
this.#skipGateway = {source: skipGateway, pattern};
|
|
224
|
+
}
|
|
225
|
+
return this.#skipGateway.pattern?.test(methodName) ?? false;
|
|
226
|
+
}
|
|
227
|
+
|
|
173
228
|
public gateway($meta: IMeta, methodName: string = $meta.method!): object | void {
|
|
174
|
-
|
|
229
|
+
// A skipped method does not reach a gateway at all, not even one the caller asked for in
|
|
230
|
+
// `$meta.gateway`: the setting says this process answers the call, and what answers it is the
|
|
231
|
+
// resolver's business (see `IConfig.skipGateway`).
|
|
232
|
+
if (this._skipsGateway(methodName)) return undefined;
|
|
233
|
+
if (this.#config.gateway) {
|
|
175
234
|
const gw = this.#config.gateway as Record<string, unknown>;
|
|
176
235
|
const [prefix, method] = methodName.split('/');
|
|
177
236
|
if (method) {
|
package/src/Log.ts
CHANGED
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
import {withProgress} from '@feasibleone/blong-lib';
|
|
2
2
|
import {Internal, type ICallLog, type ILog, type ILogger} from '@feasibleone/blong/types';
|
|
3
3
|
import {createCallChannel} from '@feasibleone/semantic-log/capability';
|
|
4
|
-
import {
|
|
4
|
+
import {callerSite, messageMatches} from '@feasibleone/semantic-log/emitter';
|
|
5
|
+
import {pino, type Logger, type LoggerOptions, type TransportSingleOptions} from 'pino';
|
|
5
6
|
import {monotonicFactory} from 'ulidx';
|
|
6
7
|
import {callRecord, configureCallTrace, type CallTraceConfig} from './callTrace.ts';
|
|
7
8
|
import type {CacacheTransportOptions} from './pino-cacache.js';
|
|
@@ -18,6 +19,23 @@ export interface LogConfig extends LoggerOptions {
|
|
|
18
19
|
impl?: string;
|
|
19
20
|
/** The semantic implementation's store. Not a pino option; stripped below. */
|
|
20
21
|
cache?: unknown;
|
|
22
|
+
/**
|
|
23
|
+
* Put the `semlog://` reference group at the end of a human line — the
|
|
24
|
+
* pretty printer renders it, and this implementation has no other way to
|
|
25
|
+
* show one. Not a pino option; stripped below. On by default, off in a
|
|
26
|
+
* released process (`log.refs: false`), whose stdout nobody resolves a
|
|
27
|
+
* reference from.
|
|
28
|
+
*/
|
|
29
|
+
refs?: boolean;
|
|
30
|
+
/**
|
|
31
|
+
* The messages whose caller is worth naming — `log.callSite`.
|
|
32
|
+
*
|
|
33
|
+
* Each entry is a regular expression source, so a substring (`unauthorized`, `adapter.ready`)
|
|
34
|
+
* is written as one and `['']` names the caller of every record. The semantic implementation
|
|
35
|
+
* reads the same list, so one entry places an unexplained line whichever logger a process
|
|
36
|
+
* chose, and nothing is captured for a message that matches none of the patterns.
|
|
37
|
+
*/
|
|
38
|
+
callSite?: string | string[];
|
|
21
39
|
/**
|
|
22
40
|
* The call channel: whether a flow's calls are recorded, and whether they
|
|
23
41
|
* are shown.
|
|
@@ -34,6 +52,14 @@ export interface LogConfig extends LoggerOptions {
|
|
|
34
52
|
|
|
35
53
|
const ulid = monotonicFactory();
|
|
36
54
|
|
|
55
|
+
/**
|
|
56
|
+
* The files a pino record's stack runs through before it reaches the call site.
|
|
57
|
+
*
|
|
58
|
+
* This module's own frames — the hook that asks for the site, since it runs inside pino's write
|
|
59
|
+
* path — and pino's, which are between that hook and the caller. Everything else is the call site.
|
|
60
|
+
*/
|
|
61
|
+
const PINO_FRAME = /[\\/](?:Log|semantic-log|pino|thread-stream)[\\/.]/;
|
|
62
|
+
|
|
37
63
|
const ignoreArgPatterns = [
|
|
38
64
|
'--tls-cipher-list=',
|
|
39
65
|
'--v8-pool-size=',
|
|
@@ -73,39 +99,37 @@ const WORKER_OPTS = {
|
|
|
73
99
|
),
|
|
74
100
|
};
|
|
75
101
|
|
|
76
|
-
const PRETTY_TRANSPORT = {
|
|
77
|
-
target: './pino-pretty.mjs',
|
|
102
|
+
const PRETTY_TRANSPORT = (refs?: boolean): TransportSingleOptions<Record<string, unknown>> => ({
|
|
103
|
+
target: './pino-pretty.mjs' as const,
|
|
78
104
|
worker: WORKER_OPTS,
|
|
79
105
|
options: {
|
|
80
106
|
singleLine: true,
|
|
81
107
|
colorizeObjects: true,
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
'req',
|
|
90
|
-
'res',
|
|
91
|
-
'config',
|
|
92
|
-
'configBase',
|
|
93
|
-
'id',
|
|
94
|
-
].join(','),
|
|
108
|
+
// The reference group is the printer's to render, so the switch travels
|
|
109
|
+
// with the transport the process chose.
|
|
110
|
+
refs,
|
|
111
|
+
// No `ignore` here: what the header already printed is the printer's own knowledge, and it
|
|
112
|
+
// lives beside the header that prints it (`pino-pretty.ts` → `ALREADY_IN_HEADER`). It used to
|
|
113
|
+
// be a list in this file, and an entry per envelope member meant the emptied
|
|
114
|
+
// `{"$meta":{}}` was printed instead of the two members it had dropped.
|
|
95
115
|
},
|
|
96
|
-
};
|
|
116
|
+
});
|
|
97
117
|
|
|
98
118
|
export default class Log extends Internal implements ILog {
|
|
99
119
|
#logger: Logger;
|
|
100
120
|
#config: LogConfig = {
|
|
101
121
|
level: 'info',
|
|
102
|
-
transport: PRETTY_TRANSPORT,
|
|
122
|
+
transport: PRETTY_TRANSPORT(),
|
|
103
123
|
};
|
|
104
124
|
|
|
105
125
|
public constructor(config: LogConfig) {
|
|
106
126
|
super();
|
|
107
127
|
this.merge(this.#config, config);
|
|
108
128
|
configureCallTrace(this.#config.calls);
|
|
129
|
+
// The printer renders the reference group, so the switch has to be known
|
|
130
|
+
// before the transport is built — the default above is there for a caller
|
|
131
|
+
// that never merges a config at all.
|
|
132
|
+
this.#config.transport = PRETTY_TRANSPORT(this.#config.refs);
|
|
109
133
|
|
|
110
134
|
// Inject a monotonic ULID `id` into every log entry before it reaches any transport
|
|
111
135
|
|
|
@@ -115,7 +139,7 @@ export default class Log extends Internal implements ILog {
|
|
|
115
139
|
const cacacheOptions = this.#config.cacache;
|
|
116
140
|
this.#config.transport = {
|
|
117
141
|
targets: [
|
|
118
|
-
PRETTY_TRANSPORT,
|
|
142
|
+
PRETTY_TRANSPORT(this.#config.refs),
|
|
119
143
|
{
|
|
120
144
|
target: './pino-cacache.mjs',
|
|
121
145
|
worker: WORKER_OPTS,
|
|
@@ -134,6 +158,8 @@ export default class Log extends Internal implements ILog {
|
|
|
134
158
|
cacache: _cacacheConfig,
|
|
135
159
|
impl: _impl,
|
|
136
160
|
cache: _semanticCache,
|
|
161
|
+
refs: _refs,
|
|
162
|
+
callSite: _callSite,
|
|
137
163
|
calls: _calls,
|
|
138
164
|
...pinoConfig
|
|
139
165
|
} = this.#config;
|
|
@@ -158,6 +184,38 @@ export default class Log extends Internal implements ILog {
|
|
|
158
184
|
bindings: object,
|
|
159
185
|
): ReturnType<ILog['logger']> {
|
|
160
186
|
const child = this.#logger.child(bindings, {level});
|
|
187
|
+
// The caller of the messages `log.callSite` names. pino has no hook that sees a message:
|
|
188
|
+
// `logMethod` is not inherited by a child, and a formatter is handed the fields without
|
|
189
|
+
// the text — so the match is made here, where the framework builds the logger it hands
|
|
190
|
+
// out, and every component's logger is covered by construction.
|
|
191
|
+
const locations =
|
|
192
|
+
typeof this.#config.callSite === 'string'
|
|
193
|
+
? [this.#config.callSite]
|
|
194
|
+
: this.#config.callSite;
|
|
195
|
+
const bind = <A extends unknown[]>(
|
|
196
|
+
method: (...args: A) => void,
|
|
197
|
+
): ((...args: A) => void) => {
|
|
198
|
+
if (!locations?.length) return method;
|
|
199
|
+
return (...args: A) => {
|
|
200
|
+
const call = [...args] as unknown[];
|
|
201
|
+
// The message is whichever argument is text: pino's order is `(bag, message)` or
|
|
202
|
+
// `(message, values)`, and the text is the part both of those have.
|
|
203
|
+
const message = [...call].reverse().find(arg => typeof arg === 'string');
|
|
204
|
+
if (message !== undefined && messageMatches(locations, message)) {
|
|
205
|
+
// The site has to sit where pino looks for fields, which is the *first*
|
|
206
|
+
// argument: an object after the message is a printf value, and pino drops one
|
|
207
|
+
// the message has no placeholder left for. A call that already leads with a
|
|
208
|
+
// bag keeps it, and the site joins it.
|
|
209
|
+
const leading = call[0];
|
|
210
|
+
if (leading && typeof leading === 'object' && !Array.isArray(leading)) {
|
|
211
|
+
call[0] = {...(leading as object), site: callerSite(PINO_FRAME)};
|
|
212
|
+
} else {
|
|
213
|
+
call.unshift({site: callerSite(PINO_FRAME)});
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
return (method as (...args: unknown[]) => void)(...call);
|
|
217
|
+
};
|
|
218
|
+
};
|
|
161
219
|
const result: ILogger = {
|
|
162
220
|
trace: undefined,
|
|
163
221
|
debug: undefined,
|
|
@@ -168,17 +226,17 @@ export default class Log extends Internal implements ILog {
|
|
|
168
226
|
};
|
|
169
227
|
switch (level) {
|
|
170
228
|
case 'trace':
|
|
171
|
-
result.trace = child.trace.bind(child);
|
|
229
|
+
result.trace = bind(child.trace.bind(child));
|
|
172
230
|
case 'debug':
|
|
173
|
-
result.debug = child.debug.bind(child);
|
|
231
|
+
result.debug = bind(child.debug.bind(child));
|
|
174
232
|
case 'info':
|
|
175
|
-
result.info = child.info.bind(child);
|
|
233
|
+
result.info = bind(child.info.bind(child));
|
|
176
234
|
case 'warn':
|
|
177
|
-
result.warn = child.warn.bind(child);
|
|
235
|
+
result.warn = bind(child.warn.bind(child));
|
|
178
236
|
case 'error':
|
|
179
|
-
result.error = child.error.bind(child);
|
|
237
|
+
result.error = bind(child.error.bind(child));
|
|
180
238
|
case 'fatal':
|
|
181
|
-
result.fatal = child.fatal.bind(child);
|
|
239
|
+
result.fatal = bind(child.fatal.bind(child));
|
|
182
240
|
}
|
|
183
241
|
return {
|
|
184
242
|
...result,
|
package/src/Registry.ts
CHANGED
|
@@ -87,6 +87,14 @@ export default class Registry extends Internal implements IRegistry {
|
|
|
87
87
|
#apiSchema: IApiSchema;
|
|
88
88
|
#platform: IPlatformApi;
|
|
89
89
|
#config: IConfig = {};
|
|
90
|
+
/**
|
|
91
|
+
* What each realm declares about itself, keyed by realm name.
|
|
92
|
+
*
|
|
93
|
+
* Handed over by {@link setRealmConfig} rather than read from this component's config, because
|
|
94
|
+
* a realm loads *after* the config of the realm whose child it is has merged: a copy taken while
|
|
95
|
+
* any config is built holds the realms loaded up to that moment and nothing more (D-433).
|
|
96
|
+
*/
|
|
97
|
+
#realmConfig: Record<string, unknown> = {};
|
|
90
98
|
#attachCheckpoint?: (meta: IMeta) => void;
|
|
91
99
|
|
|
92
100
|
#manifest: IManifest | undefined;
|
|
@@ -518,9 +526,23 @@ export default class Registry extends Internal implements IRegistry {
|
|
|
518
526
|
folders: disk?.folders ?? [],
|
|
519
527
|
files: disk?.files ?? [],
|
|
520
528
|
layerFiles: disk?.layerFiles ?? [],
|
|
529
|
+
// What each realm declares about itself, keyed by realm name: the loader records the
|
|
530
|
+
// slice and hands the map here, so a planner reads a realm's own `k8sRealmRole` rather
|
|
531
|
+
// than inferring it from the ports the realm happens to own (D-433).
|
|
532
|
+
realmConfig: this.#realmConfig,
|
|
521
533
|
};
|
|
522
534
|
}
|
|
523
535
|
|
|
536
|
+
/**
|
|
537
|
+
* Publish the per-realm config map.
|
|
538
|
+
*
|
|
539
|
+
* The map is taken by reference, and the loader keeps adding to it as realms load, so a caller
|
|
540
|
+
* that asks later sees the realms that were not loaded yet when the registry was created.
|
|
541
|
+
*/
|
|
542
|
+
public setRealmConfig(realmConfig: Record<string, unknown>): void {
|
|
543
|
+
this.#realmConfig = realmConfig;
|
|
544
|
+
}
|
|
545
|
+
|
|
524
546
|
public async stop(): Promise<IRegistry> {
|
|
525
547
|
if (this.#stopped) return this;
|
|
526
548
|
await this.#watch?.stop();
|
package/src/Remote.ts
CHANGED
|
@@ -24,7 +24,18 @@ const errorMap: IErrorMap = {
|
|
|
24
24
|
|
|
25
25
|
export default class Remote extends Internal implements IRemote {
|
|
26
26
|
#config: {
|
|
27
|
-
|
|
27
|
+
/**
|
|
28
|
+
* Whether a call may be answered in this process instead of through the transport: `true` for
|
|
29
|
+
* every method, `false` for none, or a regular-expression *source* matched against the method
|
|
30
|
+
* name for the ones that belong here ({@link canAnswerLocally}).
|
|
31
|
+
*
|
|
32
|
+
* A configured gateway still wins, and deliberately: a method a gateway is declared for is not
|
|
33
|
+
* this process's to answer, and a deployment that wants an exception says so on the other side
|
|
34
|
+
* — `GatewayCodec`'s `skipGateway` keeps the method out of the gateway, and this is what the
|
|
35
|
+
* fallback then resolves by. Asking the same question in two places would make the two settings
|
|
36
|
+
* disagreeable; asking it here only when nothing else claims the call keeps them composable.
|
|
37
|
+
*/
|
|
38
|
+
canSkipSocket: boolean | string;
|
|
28
39
|
requireMeta: 'trace' | 'debug' | 'warn' | 'error' | 'fatal' | 'info' | true;
|
|
29
40
|
} = {
|
|
30
41
|
canSkipSocket: false,
|
|
@@ -36,12 +47,21 @@ export default class Remote extends Internal implements IRemote {
|
|
|
36
47
|
#errors: Errors<typeof errorMap>;
|
|
37
48
|
#local: ILocal;
|
|
38
49
|
#platform: IPlatformApi;
|
|
50
|
+
/**
|
|
51
|
+
* The compiled pattern of each `canSkipSocket` source seen, `null` for one that does not compile.
|
|
52
|
+
*
|
|
53
|
+
* Per source rather than one slot, because a reload replaces the config and a process may see more
|
|
54
|
+
* than one value in its life; the map is small enough that keeping the earlier ones costs nothing.
|
|
55
|
+
*/
|
|
56
|
+
#skipPatterns = new Map<string, RegExp | null>();
|
|
39
57
|
|
|
40
58
|
#brokerRequest: RemoteMethod;
|
|
41
59
|
#brokerPublish: RemoteMethod;
|
|
42
60
|
|
|
43
61
|
public constructor(
|
|
44
|
-
|
|
62
|
+
// Declared rather than read off the caller's own interface, because a type that names only
|
|
63
|
+
// `logLevel` refuses the value the loader actually passes — `canSkipSocket` among them.
|
|
64
|
+
config: {logLevel?: Parameters<ILog['logger']>[0]; canSkipSocket?: boolean | string},
|
|
45
65
|
{
|
|
46
66
|
log,
|
|
47
67
|
error,
|
|
@@ -202,9 +222,10 @@ export default class Remote extends Internal implements IRemote {
|
|
|
202
222
|
if (!fn) {
|
|
203
223
|
// don't try skipping socket if there is a gateway configured
|
|
204
224
|
if (methodName && !this.gateway?.($applyMeta, methodName)) {
|
|
205
|
-
if (this
|
|
225
|
+
if (this.canAnswerLocally(methodName))
|
|
226
|
+
fn = this._findMethod(methodName, methodType);
|
|
206
227
|
if (fn) unpack = true;
|
|
207
|
-
else if (
|
|
228
|
+
else if (this.canAnswerLocally(methodName)) {
|
|
208
229
|
// Visibility: under canSkipSocket every call is expected
|
|
209
230
|
// to resolve locally. A missing method (e.g. a derived
|
|
210
231
|
// handler name that doesn't match a registered file —
|
|
@@ -368,7 +389,7 @@ export default class Remote extends Internal implements IRemote {
|
|
|
368
389
|
mtid = $meta.mtid;
|
|
369
390
|
if (mtid === 'discard') return true;
|
|
370
391
|
const handler =
|
|
371
|
-
this
|
|
392
|
+
this.canAnswerLocally($meta.method) &&
|
|
372
393
|
this._findMethod($meta.method!, mtid === 'request' ? 'request' : 'publish');
|
|
373
394
|
if (handler) {
|
|
374
395
|
return Promise.resolve(handler(...params));
|
|
@@ -392,6 +413,43 @@ export default class Remote extends Internal implements IRemote {
|
|
|
392
413
|
return this._getPath(name).split('.').pop()!;
|
|
393
414
|
}
|
|
394
415
|
|
|
416
|
+
/**
|
|
417
|
+
* Whether a method may be answered in this process rather than through the transport.
|
|
418
|
+
*
|
|
419
|
+
* `true` and `false` are the two ends — a process that carries everything answers everything, one
|
|
420
|
+
* that carries only its own realm answers nothing — and the string is the middle: a
|
|
421
|
+
* regular-expression *source*, read against the method name, for a process that serves some of what
|
|
422
|
+
* it can reach and has to send the rest out. The pattern is compiled once per value, because a
|
|
423
|
+
* reload replaces the config.
|
|
424
|
+
*
|
|
425
|
+
* A source that does not compile is not a reason to refuse every call: it is reported once and
|
|
426
|
+
* treated as nothing matching, which leaves the process dialling as it would have with `false`.
|
|
427
|
+
*
|
|
428
|
+
* The caller decides *when* to ask ({@link _getMethod} asks only where no gateway claims the call),
|
|
429
|
+
* so nothing here knows about gateways: this is the local half of the question and the routing half
|
|
430
|
+
* lives in the codec that actually routes.
|
|
431
|
+
*/
|
|
432
|
+
protected canAnswerLocally(methodName: string | undefined): boolean {
|
|
433
|
+
const {canSkipSocket} = this.#config;
|
|
434
|
+
if (canSkipSocket === true) return true;
|
|
435
|
+
if (typeof canSkipSocket !== 'string' || !methodName) return false;
|
|
436
|
+
const cached = this.#skipPatterns.get(canSkipSocket);
|
|
437
|
+
if (cached !== undefined) return cached ? cached.test(methodName) : false;
|
|
438
|
+
try {
|
|
439
|
+
const pattern = new RegExp(canSkipSocket);
|
|
440
|
+
this.#skipPatterns.set(canSkipSocket, pattern);
|
|
441
|
+
return pattern.test(methodName);
|
|
442
|
+
} catch (error) {
|
|
443
|
+
this.#skipPatterns.set(canSkipSocket, null);
|
|
444
|
+
this.log?.warn?.({
|
|
445
|
+
$meta: {mtid: 'event', method: 'remote.canSkipSocket'},
|
|
446
|
+
message: `canSkipSocket is not a regular expression: ${canSkipSocket}`,
|
|
447
|
+
error,
|
|
448
|
+
});
|
|
449
|
+
return false;
|
|
450
|
+
}
|
|
451
|
+
}
|
|
452
|
+
|
|
395
453
|
private _findMethod(methodName: string, type: 'request' | 'publish'): RemoteMethod {
|
|
396
454
|
methodName = this._getPath(methodName);
|
|
397
455
|
const key = ['ports', methodName, type].join('.');
|