@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.
Files changed (54) hide show
  1. package/CHANGELOG.md +35 -0
  2. package/package.json +6 -6
  3. package/src/ConfigRuntime.ts +8 -1
  4. package/src/Gateway.ts +65 -11
  5. package/src/GatewayCodec.ts +60 -1
  6. package/src/Log.ts +83 -25
  7. package/src/Registry.ts +22 -0
  8. package/src/Remote.ts +63 -5
  9. package/src/ResolutionK8s.ts +71 -0
  10. package/src/RpcClient.ts +11 -1
  11. package/src/SemanticLog.test.ts +14 -4
  12. package/src/SemanticLog.ts +22 -2
  13. package/src/SystemDebug.test.ts +83 -0
  14. package/src/SystemDebug.ts +29 -2
  15. package/src/acl.test.ts +74 -0
  16. package/src/adapter/schema/knex/types.ts +18 -0
  17. package/src/adapter/server/acl.ts +6 -1
  18. package/src/adapter/server/k8s.test.ts +189 -0
  19. package/src/adapter/server/k8s.ts +540 -172
  20. package/src/adapter/server/kafka.ts +100 -47
  21. package/src/adapter/server/knex-connection.test.ts +79 -0
  22. package/src/adapter/server/knex.ts +70 -12
  23. package/src/adapter/server/pathRefresh.test.ts +26 -0
  24. package/src/adapter/server/pathRefresh.ts +30 -6
  25. package/src/busGateway.ts +41 -5
  26. package/src/chain.allure.test.ts +55 -0
  27. package/src/chain.ts +82 -6
  28. package/src/cliHelp.ts +7 -7
  29. package/src/codec/adapter/mle/ready.ts +22 -1
  30. package/src/codec/server.ts +5 -0
  31. package/src/devKeys.ts +2 -2
  32. package/src/exit.intent.test.ts +2 -2
  33. package/src/frameworkRealms.test.ts +75 -7
  34. package/src/gatewayKeys.test.ts +104 -0
  35. package/src/gatewayKeys.ts +68 -0
  36. package/src/grant.ts +8 -7
  37. package/src/intentsExclusion.test.ts +71 -0
  38. package/src/jwt.ts +2 -6
  39. package/src/lib.test.ts +46 -1
  40. package/src/lib.ts +64 -15
  41. package/src/load.ts +225 -10
  42. package/src/mle.test.ts +124 -0
  43. package/src/mle.ts +13 -0
  44. package/src/oidc.ts +39 -5
  45. package/src/pino-pretty.test.ts +81 -0
  46. package/src/pino-pretty.ts +119 -16
  47. package/src/releaseRc.test.ts +92 -0
  48. package/src/remoteCanSkip.test.ts +76 -0
  49. package/src/runServer.test.ts +34 -2
  50. package/src/runServer.ts +10 -2
  51. package/src/semanticRecord.ts +20 -8
  52. package/src/skipGateway.test.ts +112 -0
  53. package/src/static.test.ts +57 -0
  54. 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.38.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"
@@ -314,7 +314,14 @@ export default class ConfigRuntime implements IConfigRuntime {
314
314
  }
315
315
 
316
316
  private mergeConfigs(blongConfig: object): object {
317
- const loaded = loadBlong(blongConfig);
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.#config.sign ||= (
268
- process.env.GATEWAY_SIGN_KEY
269
- ? {env: 'GATEWAY_SIGN_KEY'}
270
- : {generate: {alg: 'ES384', crv: 'P-384', use: 'sig'}}
271
- ) as IConfig['sign'];
272
- this.#config.encrypt ||= (
273
- process.env.GATEWAY_ENCRYPT_KEY
274
- ? {env: 'GATEWAY_ENCRYPT_KEY'}
275
- : {generate: {alg: 'ECDH-ES+A256KW', crv: 'P-384', use: 'enc'}}
276
- ) as IConfig['encrypt'];
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
- request.log.error({err: error}, 'gateway unhandled error');
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,
@@ -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
- if (this.#config.gateway && methodName !== 'identity.checkInternal') {
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 {pino, type Logger, type LoggerOptions} from 'pino';
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
- ignore: [
83
- 'context',
84
- 'prefix',
85
- 'pid',
86
- 'hostname',
87
- '$meta.mtid',
88
- '$meta.method',
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
- canSkipSocket: boolean;
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
- config: {logLevel?: Parameters<ILog['logger']>[0]},
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.#config.canSkipSocket) fn = this._findMethod(methodName, methodType);
225
+ if (this.canAnswerLocally(methodName))
226
+ fn = this._findMethod(methodName, methodType);
206
227
  if (fn) unpack = true;
207
- else if (methodName && this.#config.canSkipSocket) {
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.#config.canSkipSocket &&
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('.');