@webpieces/core-util 0.4.787 → 0.4.789

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.
@@ -1,5 +1,4 @@
1
1
  import { ErrorTranslators } from './ErrorTranslators';
2
- import { HttpResponseDto } from './HttpResponseDto';
3
2
  import { FailureClassifier } from './FailureClassifier';
4
3
  import { ApiMethodInfo } from './ApiMethodInfo';
5
4
  /**
@@ -124,17 +123,14 @@ export declare class ClientRegistry {
124
123
  */
125
124
  static setErrorTranslators(translators: ErrorTranslators): void;
126
125
  /**
127
- * exception → the WHOLE response (SERVER side), or `undefined` when no translators are installed
128
- * or the installed ones do not claim `error` — the caller then falls through to the webpieces
129
- * default.
130
- */
131
- static tryTranslateToWire(error: Error): HttpResponseDto | undefined;
132
- /**
133
- * the WHOLE response → exception (CLIENT side), or `undefined` when no translators are installed
134
- * or the installed ones do not claim `response` — the caller then falls through to the built-in
135
- * webpieces status-to-type mapping.
126
+ * The app's installed {@link ErrorTranslators}, or `undefined` when this process installed none.
127
+ *
128
+ * The `undefined` here is "nobody registered", which is a fact about the PROCESS — it is not a
129
+ * per-error "not mine". A registered translator answers EVERY error and EVERY response, declining
130
+ * by delegating to the webpieces default (see {@link ErrorTranslators}), so a caller asks this
131
+ * question exactly once and then makes one unconditional call.
136
132
  */
137
- static tryTranslateFromWire(response: HttpResponseDto): Error | undefined;
133
+ static getErrorTranslators(): ErrorTranslators | undefined;
138
134
  /**
139
135
  * Set the app/company DEFAULT failure classifier — ONE per process, covering the server router and
140
136
  * all internal clients (it branches on {@link ApiMethodInfo.side}). Call ONCE at startup, on the
@@ -158,22 +158,16 @@ class ClientRegistry {
158
158
  ClientRegistry.errorTranslators = translators;
159
159
  }
160
160
  /**
161
- * exception → the WHOLE response (SERVER side), or `undefined` when no translators are installed
162
- * or the installed ones do not claim `error` — the caller then falls through to the webpieces
163
- * default.
164
- */
165
- // webpieces-disable no-function-outside-class -- static global singleton (like HeaderRegistry/LogManager); populated once at startup, never DI-injected
166
- static tryTranslateToWire(error) {
167
- return ClientRegistry.errorTranslators?.toWire(error);
168
- }
169
- /**
170
- * the WHOLE response → exception (CLIENT side), or `undefined` when no translators are installed
171
- * or the installed ones do not claim `response` — the caller then falls through to the built-in
172
- * webpieces status-to-type mapping.
161
+ * The app's installed {@link ErrorTranslators}, or `undefined` when this process installed none.
162
+ *
163
+ * The `undefined` here is "nobody registered", which is a fact about the PROCESS — it is not a
164
+ * per-error "not mine". A registered translator answers EVERY error and EVERY response, declining
165
+ * by delegating to the webpieces default (see {@link ErrorTranslators}), so a caller asks this
166
+ * question exactly once and then makes one unconditional call.
173
167
  */
174
168
  // webpieces-disable no-function-outside-class -- static global singleton (like HeaderRegistry/LogManager); populated once at startup, never DI-injected
175
- static tryTranslateFromWire(response) {
176
- return ClientRegistry.errorTranslators?.fromWire(response);
169
+ static getErrorTranslators() {
170
+ return ClientRegistry.errorTranslators;
177
171
  }
178
172
  /**
179
173
  * Set the app/company DEFAULT failure classifier — ONE per process, covering the server router and
@@ -1 +1 @@
1
- {"version":3,"file":"ClientRegistry.js","sourceRoot":"","sources":["../../../../../../packages/core/core-util/src/http/ClientRegistry.ts"],"names":[],"mappings":"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","sourcesContent":["import { ErrorTranslators } from './ErrorTranslators';\nimport { HttpResponseDto } from './HttpResponseDto';\nimport { FailureClassifier } from './FailureClassifier';\nimport { WEBPIECES_DEFAULT_FAILURE_CLASSIFIER } from './WebpiecesDefaultFailureClassifier';\nimport { ApiMethodInfo } from './ApiMethodInfo';\n\n/**\n * Derives a base URL for a service name that has NO registered mapping — the pluggable half of\n * {@link ClientRegistry} resolution. One per environment, installed once at startup:\n *\n * - `gcpCloudRunDeriver()` (@webpieces/gcp-identity) — the Cloud Run formula, read from the metadata\n * server. For code running ON GCP.\n * - `gcpCloudRunDeriver(new GcpCloudRunTarget(projectNumber, region))` — the SAME formula with the\n * values supplied from config, for code running OFF GCP that still calls Cloud Run (a CLI, CI).\n * - {@link templateDeriver} (this package, browser-safe) — pure string substitution, for AWS or\n * anything else with predictable DNS.\n * - none at all — a browser goes same-origin, and localhost/tests hand-register their mappings.\n */\nexport type ServiceUrlDeriver = (svcName: string) => Promise<string>;\n\n/**\n * ClientRegistry - the ONE place a `svcName` becomes a base URL, for every outbound client in every\n * environment. A client is built once but a URL is per-environment, so the URL belongs here rather\n * than on the client: clients carry ONLY a svcName.\n *\n * Resolution is one precedence chain, identical in the browser, in node, and in Cloud Tasks:\n *\n * 1. a registered mapping wins — the localhost port table, AWS, an external API, another region\n * or project, a host that is not Cloud Run at all. Populated at startup from per-env config.\n * 2. else the installed {@link ServiceUrlDeriver}, if any — GCP's built-in, a DNS template, or\n * your own. OPTIONAL on purpose: localhost is inherently a TABLE (helper-fsdb -> :8401,\n * helper-portal -> :8201 have per-service ports), so explicit mappings must stay sufficient on\n * their own.\n * 3. else the caller's fallback: the BROWSER goes relative (same origin — see\n * {@link ClientRegistry.tryResolve}), while node THROWS (see {@link ClientRegistry.resolve}),\n * because a server has no \"own origin\" and an unresolvable peer is a setup bug.\n *\n * Configured like {@link HeaderRegistry} / LogManager — populated once at startup, then globally\n * accessible with NO DI wiring. It is browser-safe (no `process.env`, no node-only deps), which is\n * why it lives in core-util rather than gcp-identity.\n *\n * A `svcName` should be the nx MODULE name of the service you are calling — that is the one name\n * `architecture:validate-runtime-architecture` can verify actually exists, so a typo or a rename\n * fails the build instead of failing in production. When the DEPLOYED name differs (an environment\n * prefix like `tf-`, or a Cloud Run service named unlike its module), translate it in ONE place —\n * the deriver below, or a `serviceName` alias in the module's project.json — never at the call site.\n *\n * ```ts\n * // startup, from the current environment's config:\n * ClientRegistry.addMapping('server2', 8202); // -> http://localhost:8202\n * ClientRegistry.addUrlMapping('email', 'https://email.other-region.example');\n * ClientRegistry.setDeriver(gcpCloudRunDeriver()); // everything else, on GCP\n * ```\n */\nexport class ClientRegistry {\n /** svcName -> resolved base URL. Process-global; populated at startup per environment. */\n private static readonly mappings = new Map<string, string>();\n\n /** The fallback for svcNames with no mapping. Undefined = no derivation in this environment. */\n private static deriver: ServiceUrlDeriver | undefined;\n\n /**\n * The app's ONE {@link ErrorTranslators}, consulted BEFORE webpieces' built-in error mapping in\n * BOTH directions. Process-global, set once at startup on the SERVER and in the BROWSER — the\n * same no-DI pattern as {@link ClientRegistry.mappings} above.\n *\n * ONE, not a list, on purpose: an app that has several layers of error policy composes them\n * INSIDE its own `toWire`, where the precedence is written down, instead of leaving it implicit\n * in the order two unrelated startup paths happened to register.\n */\n private static errorTranslators: ErrorTranslators | undefined;\n\n /**\n * The app/company DEFAULT {@link FailureClassifier} — ONE per process, reads {@link ApiMethodInfo.side}\n * so a single strategy covers the server router AND all internal clients. Undefined = use the\n * webpieces built-in ({@link WEBPIECES_DEFAULT_FAILURE_CLASSIFIER}). Populated once at startup on the\n * SERVER and in the BROWSER — same no-DI pattern as {@link ClientRegistry.mappings}.\n */\n private static appDefaultFailureClassifier: FailureClassifier | undefined;\n\n /**\n * Per-EXTERNAL-client {@link FailureClassifier}s, keyed by {@link ApiMethodInfo.apiClass}\n * ('FirestoreAdminClient', 'ClaudeApi', ...). Consulted BEFORE the default tier, so an external\n * client overrides the default for its own apiClass only. Internal clients / the server register\n * nothing here and fall through to the default. See {@link ClientRegistry.classifyFailure}.\n */\n private static readonly failureClassifiersByApiClass = new Map<string, FailureClassifier>();\n\n /** Map a service name to `http://localhost:<port>`. */\n // webpieces-disable no-function-outside-class -- static global singleton (like HeaderRegistry/LogManager); populated once at startup, never DI-injected\n static addMapping(svcName: string, port: number): void {\n ClientRegistry.mappings.set(svcName, `http://localhost:${port}`);\n }\n\n /**\n * Map a service name to an explicit base URL (any host / any environment).\n *\n * The EMPTY STRING is a legal, meaningful mapping: it makes the service relative, i.e.\n * same-origin, because the client builds its URL as `${baseUrl}${route.path}`.\n */\n // webpieces-disable no-function-outside-class -- static global singleton (like HeaderRegistry/LogManager); populated once at startup, never DI-injected\n static addUrlMapping(svcName: string, url: string): void {\n ClientRegistry.mappings.set(svcName, url);\n }\n\n /**\n * Install the environment's {@link ServiceUrlDeriver} — how to resolve a svcName that has NO\n * mapping. Optional: an environment that registers every svcName it calls needs none.\n */\n // webpieces-disable no-function-outside-class -- static global singleton (like HeaderRegistry/LogManager); populated once at startup, never DI-injected\n static setDeriver(fn: ServiceUrlDeriver): void {\n ClientRegistry.deriver = fn;\n }\n\n /** The registered override for `svcName`, or undefined if none. Registry only — no derivation. */\n // webpieces-disable no-function-outside-class -- static global singleton (like HeaderRegistry/LogManager); populated once at startup, never DI-injected\n static tryLookup(svcName: string): string | undefined {\n return ClientRegistry.mappings.get(svcName);\n }\n\n /**\n * Resolve `svcName` to its registered base URL. THROWS if the service was never registered.\n * Registry only — it does NOT consult the deriver; prefer {@link ClientRegistry.resolve}.\n */\n // webpieces-disable no-function-outside-class -- static global singleton (like HeaderRegistry/LogManager); populated once at startup, never DI-injected\n static lookup(svcName: string): string {\n const url = ClientRegistry.tryLookup(svcName);\n if (url === undefined) {\n throw new Error(\n `No URL registered for service \"${svcName}\". Register it at startup: ` +\n `ClientRegistry.addMapping(svcName, port) for a localhost port, or ` +\n `ClientRegistry.addUrlMapping(svcName, url) for an explicit URL.`,\n );\n }\n return url;\n }\n\n /**\n * Steps 1 + 2 of the chain: the registered mapping, else the installed deriver, else undefined.\n * Non-throwing — the BROWSER uses this and treats undefined as \"\" (relative → same origin),\n * which is what a browser app calling the backend it was served from wants by default.\n *\n * Note the `!== undefined` guard: an empty-string mapping is a legal answer (\"this service is\n * same-origin\"), so it must NOT fall through to derivation.\n */\n // webpieces-disable no-function-outside-class -- static global singleton (like HeaderRegistry/LogManager); populated once at startup, never DI-injected\n static tryResolve(svcName: string): Promise<string | undefined> {\n const override = ClientRegistry.tryLookup(svcName);\n if (override !== undefined) {\n return Promise.resolve(override);\n }\n if (!ClientRegistry.deriver) {\n return Promise.resolve(undefined);\n }\n return ClientRegistry.deriver(svcName);\n }\n\n /**\n * The full chain, with node's fallback: mapping, else deriver, else THROW. A server has no \"own\n * origin\" to go relative to, so an unresolvable peer is a setup bug and must fail loudly — and\n * say how to fix it.\n */\n // webpieces-disable no-function-outside-class -- static global singleton (like HeaderRegistry/LogManager); populated once at startup, never DI-injected\n static async resolve(svcName: string): Promise<string> {\n const url = await ClientRegistry.tryResolve(svcName);\n if (url === undefined) {\n throw new Error(\n `No URL for service \"${svcName}\".\\n` +\n ` - localhost/AWS: ClientRegistry.addMapping('${svcName}', 8401)\\n` +\n ` or ClientRegistry.addUrlMapping('${svcName}', 'https://...')\\n` +\n ` - GCP: install a deriver — ClientRegistry.setDeriver(gcpCloudRunDeriver())\\n` +\n ` - deployed name differs from the module name (e.g. a 'tf-' prefix)? Translate it ONCE\\n` +\n ` in the deriver — setDeriver(s => gcpCloudRunDeriver()('tf-' + s)) — so every call\\n` +\n ` site keeps naming the MODULE, which architecture:validate-runtime-architecture checks`,\n );\n }\n return url;\n }\n\n /**\n * Install the app's {@link ErrorTranslators} — the ONE symmetric owner of error translation for\n * this process, server side AND every client side. Consulted BEFORE webpieces' built-in mapping\n * in both directions. Call ONCE at startup, on the server AND in the browser, mirroring\n * {@link ClientRegistry.addMapping}.\n *\n * `set`, not `add`: see the field doc above for why a registry LIST is not wanted.\n */\n // webpieces-disable no-function-outside-class -- static global singleton (like HeaderRegistry/LogManager); populated once at startup, never DI-injected\n static setErrorTranslators(translators: ErrorTranslators): void {\n ClientRegistry.errorTranslators = translators;\n }\n\n /**\n * exception → the WHOLE response (SERVER side), or `undefined` when no translators are installed\n * or the installed ones do not claim `error` — the caller then falls through to the webpieces\n * default.\n */\n // webpieces-disable no-function-outside-class -- static global singleton (like HeaderRegistry/LogManager); populated once at startup, never DI-injected\n static tryTranslateToWire(error: Error): HttpResponseDto | undefined {\n return ClientRegistry.errorTranslators?.toWire(error);\n }\n\n /**\n * the WHOLE response → exception (CLIENT side), or `undefined` when no translators are installed\n * or the installed ones do not claim `response` — the caller then falls through to the built-in\n * webpieces status-to-type mapping.\n */\n // webpieces-disable no-function-outside-class -- static global singleton (like HeaderRegistry/LogManager); populated once at startup, never DI-injected\n static tryTranslateFromWire(response: HttpResponseDto): Error | undefined {\n return ClientRegistry.errorTranslators?.fromWire(response);\n }\n\n /**\n * Set the app/company DEFAULT failure classifier — ONE per process, covering the server router and\n * all internal clients (it branches on {@link ApiMethodInfo.side}). Call ONCE at startup, on the\n * server AND in the browser. Overrides the webpieces built-in for calls no per-apiClass classifier\n * claims. See {@link FailureClassifier}.\n */\n // webpieces-disable no-function-outside-class -- static global singleton (like HeaderRegistry/LogManager); populated once at startup, never DI-injected\n static setDefaultFailureClassifier(classifier: FailureClassifier): void {\n ClientRegistry.appDefaultFailureClassifier = classifier;\n }\n\n /**\n * Register a per-EXTERNAL-client failure classifier, keyed by {@link ApiMethodInfo.apiClass}\n * (e.g. 'FirestoreAdminClient'). Consulted BEFORE the default tier for calls with that apiClass.\n * Call ONCE at startup. See {@link FailureClassifier}.\n */\n // webpieces-disable no-function-outside-class -- static global singleton (like HeaderRegistry/LogManager); populated once at startup, never DI-injected\n static addFailureClassifier(apiClass: string, classifier: FailureClassifier): void {\n ClientRegistry.failureClassifiersByApiClass.set(apiClass, classifier);\n }\n\n /**\n * Is this thrown API-call error a real FAILURE (true) or an expected non-failure (false)?\n * Resolved most-specific-first, and ALWAYS definitive (returns a boolean):\n * 1. the per-apiClass classifier, if one is registered for `methodInfo.apiClass` — unless it defers;\n * 2. else the app default classifier, if one was set — unless it defers;\n * 3. else the webpieces built-in ({@link WEBPIECES_DEFAULT_FAILURE_CLASSIFIER}), which never defers.\n * A `?? true` backstop keeps an unexpectedly-deferring built-in fail-safe (treat as failure).\n */\n // webpieces-disable no-function-outside-class -- static global singleton (like HeaderRegistry/LogManager); populated once at startup, never DI-injected\n static classifyFailure(error: Error, methodInfo: ApiMethodInfo): boolean {\n const perClient = ClientRegistry.failureClassifiersByApiClass.get(methodInfo.apiClass);\n if (perClient !== undefined) {\n const verdict = perClient.isFailure(error, methodInfo);\n if (verdict !== undefined) {\n return verdict;\n }\n }\n if (ClientRegistry.appDefaultFailureClassifier !== undefined) {\n const verdict = ClientRegistry.appDefaultFailureClassifier.isFailure(error, methodInfo);\n if (verdict !== undefined) {\n return verdict;\n }\n }\n return WEBPIECES_DEFAULT_FAILURE_CLASSIFIER.isFailure(error, methodInfo) ?? true;\n }\n\n /**\n * Reset mappings, the deriver, the error translators, AND failure classifiers. For tests, so\n * the process-globals do not leak across specs.\n */\n // webpieces-disable no-function-outside-class -- static global singleton (like HeaderRegistry/LogManager); populated once at startup, never DI-injected\n static clear(): void {\n ClientRegistry.mappings.clear();\n ClientRegistry.deriver = undefined;\n ClientRegistry.errorTranslators = undefined;\n ClientRegistry.appDefaultFailureClassifier = undefined;\n ClientRegistry.failureClassifiersByApiClass.clear();\n }\n}\n"]}
1
+ {"version":3,"file":"ClientRegistry.js","sourceRoot":"","sources":["../../../../../../packages/core/core-util/src/http/ClientRegistry.ts"],"names":[],"mappings":"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","sourcesContent":["import { ErrorTranslators } from './ErrorTranslators';\nimport { FailureClassifier } from './FailureClassifier';\nimport { WEBPIECES_DEFAULT_FAILURE_CLASSIFIER } from './WebpiecesDefaultFailureClassifier';\nimport { ApiMethodInfo } from './ApiMethodInfo';\n\n/**\n * Derives a base URL for a service name that has NO registered mapping — the pluggable half of\n * {@link ClientRegistry} resolution. One per environment, installed once at startup:\n *\n * - `gcpCloudRunDeriver()` (@webpieces/gcp-identity) — the Cloud Run formula, read from the metadata\n * server. For code running ON GCP.\n * - `gcpCloudRunDeriver(new GcpCloudRunTarget(projectNumber, region))` — the SAME formula with the\n * values supplied from config, for code running OFF GCP that still calls Cloud Run (a CLI, CI).\n * - {@link templateDeriver} (this package, browser-safe) — pure string substitution, for AWS or\n * anything else with predictable DNS.\n * - none at all — a browser goes same-origin, and localhost/tests hand-register their mappings.\n */\nexport type ServiceUrlDeriver = (svcName: string) => Promise<string>;\n\n/**\n * ClientRegistry - the ONE place a `svcName` becomes a base URL, for every outbound client in every\n * environment. A client is built once but a URL is per-environment, so the URL belongs here rather\n * than on the client: clients carry ONLY a svcName.\n *\n * Resolution is one precedence chain, identical in the browser, in node, and in Cloud Tasks:\n *\n * 1. a registered mapping wins — the localhost port table, AWS, an external API, another region\n * or project, a host that is not Cloud Run at all. Populated at startup from per-env config.\n * 2. else the installed {@link ServiceUrlDeriver}, if any — GCP's built-in, a DNS template, or\n * your own. OPTIONAL on purpose: localhost is inherently a TABLE (helper-fsdb -> :8401,\n * helper-portal -> :8201 have per-service ports), so explicit mappings must stay sufficient on\n * their own.\n * 3. else the caller's fallback: the BROWSER goes relative (same origin — see\n * {@link ClientRegistry.tryResolve}), while node THROWS (see {@link ClientRegistry.resolve}),\n * because a server has no \"own origin\" and an unresolvable peer is a setup bug.\n *\n * Configured like {@link HeaderRegistry} / LogManager — populated once at startup, then globally\n * accessible with NO DI wiring. It is browser-safe (no `process.env`, no node-only deps), which is\n * why it lives in core-util rather than gcp-identity.\n *\n * A `svcName` should be the nx MODULE name of the service you are calling — that is the one name\n * `architecture:validate-runtime-architecture` can verify actually exists, so a typo or a rename\n * fails the build instead of failing in production. When the DEPLOYED name differs (an environment\n * prefix like `tf-`, or a Cloud Run service named unlike its module), translate it in ONE place —\n * the deriver below, or a `serviceName` alias in the module's project.json — never at the call site.\n *\n * ```ts\n * // startup, from the current environment's config:\n * ClientRegistry.addMapping('server2', 8202); // -> http://localhost:8202\n * ClientRegistry.addUrlMapping('email', 'https://email.other-region.example');\n * ClientRegistry.setDeriver(gcpCloudRunDeriver()); // everything else, on GCP\n * ```\n */\nexport class ClientRegistry {\n /** svcName -> resolved base URL. Process-global; populated at startup per environment. */\n private static readonly mappings = new Map<string, string>();\n\n /** The fallback for svcNames with no mapping. Undefined = no derivation in this environment. */\n private static deriver: ServiceUrlDeriver | undefined;\n\n /**\n * The app's ONE {@link ErrorTranslators}, consulted BEFORE webpieces' built-in error mapping in\n * BOTH directions. Process-global, set once at startup on the SERVER and in the BROWSER — the\n * same no-DI pattern as {@link ClientRegistry.mappings} above.\n *\n * ONE, not a list, on purpose: an app that has several layers of error policy composes them\n * INSIDE its own `toWire`, where the precedence is written down, instead of leaving it implicit\n * in the order two unrelated startup paths happened to register.\n */\n private static errorTranslators: ErrorTranslators | undefined;\n\n /**\n * The app/company DEFAULT {@link FailureClassifier} — ONE per process, reads {@link ApiMethodInfo.side}\n * so a single strategy covers the server router AND all internal clients. Undefined = use the\n * webpieces built-in ({@link WEBPIECES_DEFAULT_FAILURE_CLASSIFIER}). Populated once at startup on the\n * SERVER and in the BROWSER — same no-DI pattern as {@link ClientRegistry.mappings}.\n */\n private static appDefaultFailureClassifier: FailureClassifier | undefined;\n\n /**\n * Per-EXTERNAL-client {@link FailureClassifier}s, keyed by {@link ApiMethodInfo.apiClass}\n * ('FirestoreAdminClient', 'ClaudeApi', ...). Consulted BEFORE the default tier, so an external\n * client overrides the default for its own apiClass only. Internal clients / the server register\n * nothing here and fall through to the default. See {@link ClientRegistry.classifyFailure}.\n */\n private static readonly failureClassifiersByApiClass = new Map<string, FailureClassifier>();\n\n /** Map a service name to `http://localhost:<port>`. */\n // webpieces-disable no-function-outside-class -- static global singleton (like HeaderRegistry/LogManager); populated once at startup, never DI-injected\n static addMapping(svcName: string, port: number): void {\n ClientRegistry.mappings.set(svcName, `http://localhost:${port}`);\n }\n\n /**\n * Map a service name to an explicit base URL (any host / any environment).\n *\n * The EMPTY STRING is a legal, meaningful mapping: it makes the service relative, i.e.\n * same-origin, because the client builds its URL as `${baseUrl}${route.path}`.\n */\n // webpieces-disable no-function-outside-class -- static global singleton (like HeaderRegistry/LogManager); populated once at startup, never DI-injected\n static addUrlMapping(svcName: string, url: string): void {\n ClientRegistry.mappings.set(svcName, url);\n }\n\n /**\n * Install the environment's {@link ServiceUrlDeriver} — how to resolve a svcName that has NO\n * mapping. Optional: an environment that registers every svcName it calls needs none.\n */\n // webpieces-disable no-function-outside-class -- static global singleton (like HeaderRegistry/LogManager); populated once at startup, never DI-injected\n static setDeriver(fn: ServiceUrlDeriver): void {\n ClientRegistry.deriver = fn;\n }\n\n /** The registered override for `svcName`, or undefined if none. Registry only — no derivation. */\n // webpieces-disable no-function-outside-class -- static global singleton (like HeaderRegistry/LogManager); populated once at startup, never DI-injected\n static tryLookup(svcName: string): string | undefined {\n return ClientRegistry.mappings.get(svcName);\n }\n\n /**\n * Resolve `svcName` to its registered base URL. THROWS if the service was never registered.\n * Registry only — it does NOT consult the deriver; prefer {@link ClientRegistry.resolve}.\n */\n // webpieces-disable no-function-outside-class -- static global singleton (like HeaderRegistry/LogManager); populated once at startup, never DI-injected\n static lookup(svcName: string): string {\n const url = ClientRegistry.tryLookup(svcName);\n if (url === undefined) {\n throw new Error(\n `No URL registered for service \"${svcName}\". Register it at startup: ` +\n `ClientRegistry.addMapping(svcName, port) for a localhost port, or ` +\n `ClientRegistry.addUrlMapping(svcName, url) for an explicit URL.`,\n );\n }\n return url;\n }\n\n /**\n * Steps 1 + 2 of the chain: the registered mapping, else the installed deriver, else undefined.\n * Non-throwing — the BROWSER uses this and treats undefined as \"\" (relative → same origin),\n * which is what a browser app calling the backend it was served from wants by default.\n *\n * Note the `!== undefined` guard: an empty-string mapping is a legal answer (\"this service is\n * same-origin\"), so it must NOT fall through to derivation.\n */\n // webpieces-disable no-function-outside-class -- static global singleton (like HeaderRegistry/LogManager); populated once at startup, never DI-injected\n static tryResolve(svcName: string): Promise<string | undefined> {\n const override = ClientRegistry.tryLookup(svcName);\n if (override !== undefined) {\n return Promise.resolve(override);\n }\n if (!ClientRegistry.deriver) {\n return Promise.resolve(undefined);\n }\n return ClientRegistry.deriver(svcName);\n }\n\n /**\n * The full chain, with node's fallback: mapping, else deriver, else THROW. A server has no \"own\n * origin\" to go relative to, so an unresolvable peer is a setup bug and must fail loudly — and\n * say how to fix it.\n */\n // webpieces-disable no-function-outside-class -- static global singleton (like HeaderRegistry/LogManager); populated once at startup, never DI-injected\n static async resolve(svcName: string): Promise<string> {\n const url = await ClientRegistry.tryResolve(svcName);\n if (url === undefined) {\n throw new Error(\n `No URL for service \"${svcName}\".\\n` +\n ` - localhost/AWS: ClientRegistry.addMapping('${svcName}', 8401)\\n` +\n ` or ClientRegistry.addUrlMapping('${svcName}', 'https://...')\\n` +\n ` - GCP: install a deriver — ClientRegistry.setDeriver(gcpCloudRunDeriver())\\n` +\n ` - deployed name differs from the module name (e.g. a 'tf-' prefix)? Translate it ONCE\\n` +\n ` in the deriver — setDeriver(s => gcpCloudRunDeriver()('tf-' + s)) — so every call\\n` +\n ` site keeps naming the MODULE, which architecture:validate-runtime-architecture checks`,\n );\n }\n return url;\n }\n\n /**\n * Install the app's {@link ErrorTranslators} — the ONE symmetric owner of error translation for\n * this process, server side AND every client side. Consulted BEFORE webpieces' built-in mapping\n * in both directions. Call ONCE at startup, on the server AND in the browser, mirroring\n * {@link ClientRegistry.addMapping}.\n *\n * `set`, not `add`: see the field doc above for why a registry LIST is not wanted.\n */\n // webpieces-disable no-function-outside-class -- static global singleton (like HeaderRegistry/LogManager); populated once at startup, never DI-injected\n static setErrorTranslators(translators: ErrorTranslators): void {\n ClientRegistry.errorTranslators = translators;\n }\n\n /**\n * The app's installed {@link ErrorTranslators}, or `undefined` when this process installed none.\n *\n * The `undefined` here is \"nobody registered\", which is a fact about the PROCESS — it is not a\n * per-error \"not mine\". A registered translator answers EVERY error and EVERY response, declining\n * by delegating to the webpieces default (see {@link ErrorTranslators}), so a caller asks this\n * question exactly once and then makes one unconditional call.\n */\n // webpieces-disable no-function-outside-class -- static global singleton (like HeaderRegistry/LogManager); populated once at startup, never DI-injected\n static getErrorTranslators(): ErrorTranslators | undefined {\n return ClientRegistry.errorTranslators;\n }\n\n /**\n * Set the app/company DEFAULT failure classifier — ONE per process, covering the server router and\n * all internal clients (it branches on {@link ApiMethodInfo.side}). Call ONCE at startup, on the\n * server AND in the browser. Overrides the webpieces built-in for calls no per-apiClass classifier\n * claims. See {@link FailureClassifier}.\n */\n // webpieces-disable no-function-outside-class -- static global singleton (like HeaderRegistry/LogManager); populated once at startup, never DI-injected\n static setDefaultFailureClassifier(classifier: FailureClassifier): void {\n ClientRegistry.appDefaultFailureClassifier = classifier;\n }\n\n /**\n * Register a per-EXTERNAL-client failure classifier, keyed by {@link ApiMethodInfo.apiClass}\n * (e.g. 'FirestoreAdminClient'). Consulted BEFORE the default tier for calls with that apiClass.\n * Call ONCE at startup. See {@link FailureClassifier}.\n */\n // webpieces-disable no-function-outside-class -- static global singleton (like HeaderRegistry/LogManager); populated once at startup, never DI-injected\n static addFailureClassifier(apiClass: string, classifier: FailureClassifier): void {\n ClientRegistry.failureClassifiersByApiClass.set(apiClass, classifier);\n }\n\n /**\n * Is this thrown API-call error a real FAILURE (true) or an expected non-failure (false)?\n * Resolved most-specific-first, and ALWAYS definitive (returns a boolean):\n * 1. the per-apiClass classifier, if one is registered for `methodInfo.apiClass` — unless it defers;\n * 2. else the app default classifier, if one was set — unless it defers;\n * 3. else the webpieces built-in ({@link WEBPIECES_DEFAULT_FAILURE_CLASSIFIER}), which never defers.\n * A `?? true` backstop keeps an unexpectedly-deferring built-in fail-safe (treat as failure).\n */\n // webpieces-disable no-function-outside-class -- static global singleton (like HeaderRegistry/LogManager); populated once at startup, never DI-injected\n static classifyFailure(error: Error, methodInfo: ApiMethodInfo): boolean {\n const perClient = ClientRegistry.failureClassifiersByApiClass.get(methodInfo.apiClass);\n if (perClient !== undefined) {\n const verdict = perClient.isFailure(error, methodInfo);\n if (verdict !== undefined) {\n return verdict;\n }\n }\n if (ClientRegistry.appDefaultFailureClassifier !== undefined) {\n const verdict = ClientRegistry.appDefaultFailureClassifier.isFailure(error, methodInfo);\n if (verdict !== undefined) {\n return verdict;\n }\n }\n return WEBPIECES_DEFAULT_FAILURE_CLASSIFIER.isFailure(error, methodInfo) ?? true;\n }\n\n /**\n * Reset mappings, the deriver, the error translators, AND failure classifiers. For tests, so\n * the process-globals do not leak across specs.\n */\n // webpieces-disable no-function-outside-class -- static global singleton (like HeaderRegistry/LogManager); populated once at startup, never DI-injected\n static clear(): void {\n ClientRegistry.mappings.clear();\n ClientRegistry.deriver = undefined;\n ClientRegistry.errorTranslators = undefined;\n ClientRegistry.appDefaultFailureClassifier = undefined;\n ClientRegistry.failureClassifiersByApiClass.clear();\n }\n}\n"]}
@@ -22,12 +22,33 @@ import { HttpResponseDto } from './HttpResponseDto';
22
22
  * own trace id, `Retry-After`, `WWW-Authenticate` or a cookie on an error response. A translator now
23
23
  * returns the status code, the reason phrase, the header LIST and the body — everything.
24
24
  *
25
- * # `undefined` means "not mine"
25
+ * # `toWire` REPLACES the default; it declines by DELEGATING to it
26
26
  *
27
- * Either method returns `undefined` to step aside and let the webpieces default answer. There is
28
- * exactly ONE registered ErrorTranslators per process (a `set`, not an `add` to a list): precedence
29
- * between an app's own layers is the APP's to compose explicitly inside its `toWire`, rather than
30
- * something hidden in registration order inside the framework.
27
+ * `toWire` never returns `undefined`. An error in, the exact wire shape out — the same contract every
28
+ * other outbound boundary in the framework has. A registered translator is THE server-side
29
+ * translator for this process, so "not mine" is spelled by calling the webpieces default, which is a
30
+ * public class it already has: `new ApiErrorHttpMapper('gui').toResponse(error)`
31
+ * (`@webpieces/http-server`) — pass `'edge'` instead when this router was configured with
32
+ * `setEndUserStatus('edge')`, so a delegated end-user error keeps that router's published status.
33
+ *
34
+ * That is one call, it is visible at the call site, and it composes: an app that wants to DECORATE
35
+ * the default rather than replace it does so by calling it and editing the result. A `next` parameter
36
+ * was considered and rejected — it buys nothing this does not, and it puts the framework back in the
37
+ * business of sequencing somebody else's precedence.
38
+ *
39
+ * # `fromWire` KEEPS `undefined`, and it is not the same question
40
+ *
41
+ * On the CLIENT half, `undefined` is not "fall back" — it is PROVENANCE. `TranslatedFailure`
42
+ * carries `appRegistered`, and `NodeProxyClient.adaptDownstreamFailure` reads it to decide whether a
43
+ * downstream 4xx is relayed as the app's own typed error or wrapped as this service's 500. An
44
+ * `ApiNotFoundError` the built-in 404 branch produced and one an app CLAIMED are identical as
45
+ * values and mean opposite things, so the claim has to be expressible. Returning
46
+ * `ClientErrorTranslator.builtInError(response)` instead would silently assert the app claimed every
47
+ * status it has an opinion about, and the server-to-server 4xx-to-500 invariant would stop firing.
48
+ *
49
+ * There is exactly ONE registered ErrorTranslators per process (a `set`, not an `add` to a list):
50
+ * precedence between an app's own layers is the APP's to compose explicitly inside its `toWire`,
51
+ * rather than something hidden in registration order inside the framework.
31
52
  *
32
53
  * # Not covered: 404 / unknown route
33
54
  *
@@ -43,9 +64,11 @@ import { HttpResponseDto } from './HttpResponseDto';
43
64
  * }
44
65
  *
45
66
  * export class OrderErrorTranslators implements ErrorTranslators {
46
- * toWire(error: Error): HttpResponseDto | undefined {
67
+ * private readonly fallback = new ApiErrorHttpMapper('gui');
68
+ *
69
+ * toWire(error: Error): HttpResponseDto {
47
70
  * if (!(error instanceof OrderNotFoundError)) {
48
- * return undefined; // not mine -> webpieces default
71
+ * return this.fallback.toResponse(error); // not mine -> webpieces default
49
72
  * }
50
73
  * const body = new OrderErrorPayload(error.message, 'ORDER_NOT_FOUND');
51
74
  * return new HttpResponseDto(
@@ -57,7 +80,7 @@ import { HttpResponseDto } from './HttpResponseDto';
57
80
  *
58
81
  * fromWire(response: HttpResponseDto): Error | undefined {
59
82
  * if (response.status.code !== 460) {
60
- * return undefined; // not mine -> webpieces default
83
+ * return undefined; // not claimed -> webpieces' built-in mapping
61
84
  * }
62
85
  * return new OrderNotFoundError(String(response.body));
63
86
  * }
@@ -69,14 +92,15 @@ import { HttpResponseDto } from './HttpResponseDto';
69
92
  */
70
93
  export interface ErrorTranslators {
71
94
  /**
72
- * SERVER: exception -> the ENTIRE response (status code, reason phrase, headers, body).
73
- * `undefined` => this translator does not claim `error`; webpieces' default answers instead.
95
+ * SERVER: exception -> the ENTIRE response (status code, reason phrase, headers, body). Always a
96
+ * response; decline by returning the webpieces default's own (`ApiErrorHttpMapper.toResponse`).
74
97
  */
75
- toWire(error: Error): HttpResponseDto | undefined;
98
+ toWire(error: Error): HttpResponseDto;
76
99
  /**
77
100
  * CLIENT: the ENTIRE response -> a typed exception. Receives the SAME shape `toWire` produces,
78
- * normalised from whichever transport read it. `undefined` => not claimed; webpieces' built-in
79
- * status-to-type mapping answers instead.
101
+ * normalised from whichever transport read it. `undefined` => this app does not CLAIM this
102
+ * status; webpieces' built-in status-to-type mapping answers instead, and the failure is marked
103
+ * as not app-registered (see the class doc — that flag is what an environment hook acts on).
80
104
  */
81
105
  fromWire(response: HttpResponseDto): Error | undefined;
82
106
  }
@@ -1 +1 @@
1
- {"version":3,"file":"ErrorTranslators.js","sourceRoot":"","sources":["../../../../../../packages/core/core-util/src/http/ErrorTranslators.ts"],"names":[],"mappings":"","sourcesContent":["import { HttpResponseDto } from './HttpResponseDto';\n\n/**\n * ErrorTranslators - ONE symmetric place an app owns error translation, in BOTH directions, over the\n * WHOLE response.\n *\n * An app implements this ONCE and registers it ONCE per process via\n * {@link ClientRegistry.setErrorTranslators} — on the server AND in the browser. Its `toWire` runs on\n * the SERVER (`ExpressWrapper.handleError`) and its `fromWire` runs on every CLIENT in that process\n * (`ClientErrorTranslator.translateError`, shared by `http-client-node` and `http-client-browser`).\n * The payoff is type symmetry across the wire: the server throws `OrderNotFoundError` and the caller\n * CATCHES `OrderNotFoundError`, instead of decoding a status code by hand at every call site.\n *\n * # Both halves speak {@link HttpResponseDto}, and that is the point\n *\n * `toWire` PRODUCES exactly what `fromWire` CONSUMES. Reading the two methods against each other in\n * one file is what makes a mistake visible, which is why this is one object rather than two\n * separately-registered functions.\n *\n * # The whole response, not a status plus a body\n *\n * The previous contract could express only `(statusCode, protocolError)`, so an app could not put its\n * own trace id, `Retry-After`, `WWW-Authenticate` or a cookie on an error response. A translator now\n * returns the status code, the reason phrase, the header LIST and the body — everything.\n *\n * # `undefined` means \"not mine\"\n *\n * Either method returns `undefined` to step aside and let the webpieces default answer. There is\n * exactly ONE registered ErrorTranslators per process (a `set`, not an `add` to a list): precedence\n * between an app's own layers is the APP's to compose explicitly inside its `toWire`, rather than\n * something hidden in registration order inside the framework.\n *\n * # Not covered: 404 / unknown route\n *\n * A request that matches no route has no route context and never reaches this seam. That is\n * deliberate and out of scope.\n *\n * This is a business-logic contract (methods, not data), so it is an interface per the webpieces\n * guidelines.\n *\n * ```ts\n * class OrderErrorPayload {\n * constructor(public message: string, public errorCode: string) {}\n * }\n *\n * export class OrderErrorTranslators implements ErrorTranslators {\n * toWire(error: Error): HttpResponseDto | undefined {\n * if (!(error instanceof OrderNotFoundError)) {\n * return undefined; // not mine -> webpieces default\n * }\n * const body = new OrderErrorPayload(error.message, 'ORDER_NOT_FOUND');\n * return new HttpResponseDto(\n * new HttpResponseStatus(460, 'Order Not Found'),\n * [new HttpHeader('x-order-trace', error.traceId)],\n * body,\n * );\n * }\n *\n * fromWire(response: HttpResponseDto): Error | undefined {\n * if (response.status.code !== 460) {\n * return undefined; // not mine -> webpieces default\n * }\n * return new OrderNotFoundError(String(response.body));\n * }\n * }\n *\n * // startup, ONCE per process (server AND browser)\n * ClientRegistry.setErrorTranslators(new OrderErrorTranslators());\n * ```\n */\nexport interface ErrorTranslators {\n /**\n * SERVER: exception -> the ENTIRE response (status code, reason phrase, headers, body).\n * `undefined` => this translator does not claim `error`; webpieces' default answers instead.\n */\n toWire(error: Error): HttpResponseDto | undefined;\n\n /**\n * CLIENT: the ENTIRE response -> a typed exception. Receives the SAME shape `toWire` produces,\n * normalised from whichever transport read it. `undefined` => not claimed; webpieces' built-in\n * status-to-type mapping answers instead.\n */\n fromWire(response: HttpResponseDto): Error | undefined;\n}\n"]}
1
+ {"version":3,"file":"ErrorTranslators.js","sourceRoot":"","sources":["../../../../../../packages/core/core-util/src/http/ErrorTranslators.ts"],"names":[],"mappings":"","sourcesContent":["import { HttpResponseDto } from './HttpResponseDto';\n\n/**\n * ErrorTranslators - ONE symmetric place an app owns error translation, in BOTH directions, over the\n * WHOLE response.\n *\n * An app implements this ONCE and registers it ONCE per process via\n * {@link ClientRegistry.setErrorTranslators} — on the server AND in the browser. Its `toWire` runs on\n * the SERVER (`ExpressWrapper.handleError`) and its `fromWire` runs on every CLIENT in that process\n * (`ClientErrorTranslator.translateError`, shared by `http-client-node` and `http-client-browser`).\n * The payoff is type symmetry across the wire: the server throws `OrderNotFoundError` and the caller\n * CATCHES `OrderNotFoundError`, instead of decoding a status code by hand at every call site.\n *\n * # Both halves speak {@link HttpResponseDto}, and that is the point\n *\n * `toWire` PRODUCES exactly what `fromWire` CONSUMES. Reading the two methods against each other in\n * one file is what makes a mistake visible, which is why this is one object rather than two\n * separately-registered functions.\n *\n * # The whole response, not a status plus a body\n *\n * The previous contract could express only `(statusCode, protocolError)`, so an app could not put its\n * own trace id, `Retry-After`, `WWW-Authenticate` or a cookie on an error response. A translator now\n * returns the status code, the reason phrase, the header LIST and the body — everything.\n *\n * # `toWire` REPLACES the default; it declines by DELEGATING to it\n *\n * `toWire` never returns `undefined`. An error in, the exact wire shape out — the same contract every\n * other outbound boundary in the framework has. A registered translator is THE server-side\n * translator for this process, so \"not mine\" is spelled by calling the webpieces default, which is a\n * public class it already has: `new ApiErrorHttpMapper('gui').toResponse(error)`\n * (`@webpieces/http-server`) — pass `'edge'` instead when this router was configured with\n * `setEndUserStatus('edge')`, so a delegated end-user error keeps that router's published status.\n *\n * That is one call, it is visible at the call site, and it composes: an app that wants to DECORATE\n * the default rather than replace it does so by calling it and editing the result. A `next` parameter\n * was considered and rejected — it buys nothing this does not, and it puts the framework back in the\n * business of sequencing somebody else's precedence.\n *\n * # `fromWire` KEEPS `undefined`, and it is not the same question\n *\n * On the CLIENT half, `undefined` is not \"fall back\" — it is PROVENANCE. `TranslatedFailure`\n * carries `appRegistered`, and `NodeProxyClient.adaptDownstreamFailure` reads it to decide whether a\n * downstream 4xx is relayed as the app's own typed error or wrapped as this service's 500. An\n * `ApiNotFoundError` the built-in 404 branch produced and one an app CLAIMED are identical as\n * values and mean opposite things, so the claim has to be expressible. Returning\n * `ClientErrorTranslator.builtInError(response)` instead would silently assert the app claimed every\n * status it has an opinion about, and the server-to-server 4xx-to-500 invariant would stop firing.\n *\n * There is exactly ONE registered ErrorTranslators per process (a `set`, not an `add` to a list):\n * precedence between an app's own layers is the APP's to compose explicitly inside its `toWire`,\n * rather than something hidden in registration order inside the framework.\n *\n * # Not covered: 404 / unknown route\n *\n * A request that matches no route has no route context and never reaches this seam. That is\n * deliberate and out of scope.\n *\n * This is a business-logic contract (methods, not data), so it is an interface per the webpieces\n * guidelines.\n *\n * ```ts\n * class OrderErrorPayload {\n * constructor(public message: string, public errorCode: string) {}\n * }\n *\n * export class OrderErrorTranslators implements ErrorTranslators {\n * private readonly fallback = new ApiErrorHttpMapper('gui');\n *\n * toWire(error: Error): HttpResponseDto {\n * if (!(error instanceof OrderNotFoundError)) {\n * return this.fallback.toResponse(error); // not mine -> webpieces default\n * }\n * const body = new OrderErrorPayload(error.message, 'ORDER_NOT_FOUND');\n * return new HttpResponseDto(\n * new HttpResponseStatus(460, 'Order Not Found'),\n * [new HttpHeader('x-order-trace', error.traceId)],\n * body,\n * );\n * }\n *\n * fromWire(response: HttpResponseDto): Error | undefined {\n * if (response.status.code !== 460) {\n * return undefined; // not claimed -> webpieces' built-in mapping\n * }\n * return new OrderNotFoundError(String(response.body));\n * }\n * }\n *\n * // startup, ONCE per process (server AND browser)\n * ClientRegistry.setErrorTranslators(new OrderErrorTranslators());\n * ```\n */\nexport interface ErrorTranslators {\n /**\n * SERVER: exception -> the ENTIRE response (status code, reason phrase, headers, body). Always a\n * response; decline by returning the webpieces default's own (`ApiErrorHttpMapper.toResponse`).\n */\n toWire(error: Error): HttpResponseDto;\n\n /**\n * CLIENT: the ENTIRE response -> a typed exception. Receives the SAME shape `toWire` produces,\n * normalised from whichever transport read it. `undefined` => this app does not CLAIM this\n * status; webpieces' built-in status-to-type mapping answers instead, and the failure is marked\n * as not app-registered (see the class doc — that flag is what an environment hook acts on).\n */\n fromWire(response: HttpResponseDto): Error | undefined;\n}\n"]}
@@ -28,7 +28,7 @@ import { ApiCallContext } from './ApiCallContext';
28
28
  * Logging format patterns:
29
29
  * - [API-{side}-req] ClassName.methodName request={...}
30
30
  * - [API-{side}-resp-SUCCESS] ClassName.methodName response={...}
31
- * - [API-{side}-resp-OTHER] ClassName.methodName errorType={...} (user errors)
31
+ * - [API-{side}-resp-OTHER] ClassName.methodName errorType={...} error={...} (user errors)
32
32
  * - [API-{side}-resp-FAIL] ClassName.methodName error={...} (server errors)
33
33
  */
34
34
  export declare class LogApiCallImpl {
@@ -40,7 +40,7 @@ const log = LogManager_1.LogManager.getLogger(ApiCallLogName_1.LOG_API_CALL_LOGG
40
40
  * Logging format patterns:
41
41
  * - [API-{side}-req] ClassName.methodName request={...}
42
42
  * - [API-{side}-resp-SUCCESS] ClassName.methodName response={...}
43
- * - [API-{side}-resp-OTHER] ClassName.methodName errorType={...} (user errors)
43
+ * - [API-{side}-resp-OTHER] ClassName.methodName errorType={...} error={...} (user errors)
44
44
  * - [API-{side}-resp-FAIL] ClassName.methodName error={...} (server errors)
45
45
  */
46
46
  class LogApiCallImpl {
@@ -183,13 +183,17 @@ class LogApiCallImpl {
183
183
  const side = methodInfo.side;
184
184
  const id = `${methodInfo.apiClass}.${methodInfo.methodName}`;
185
185
  const errorType = error.constructor.name;
186
+ // The MESSAGE is on BOTH branches, and that is load-bearing: this is the ONLY operator line
187
+ // per failure now that `ApiErrorBoundary` writes none. An operator error message is never
188
+ // published to a caller (see `ApiErrorCodec.publicMessage`), so the log is the one place it
189
+ // exists at all — dropping it on the caller-error branch would lose it outright.
186
190
  // Pluggable classification (ClientRegistry): a per-apiClass EXTERNAL-client classifier wins,
187
191
  // else the app default, else the webpieces built-in — which is side-dependent (a 4xx the SERVER
188
192
  // raised is a handled non-failure; the same 4xx a CLIENT receives means its call FAILED; 266 is
189
193
  // never a failure either side). `isUser` = "treat as non-failure (OTHER / result:'success')".
190
194
  const isUser = !ClientRegistry_1.ClientRegistry.classifyFailure(error, methodInfo);
191
195
  stamp(new ApiCallInfo_1.ApiCallInfo(methodInfo, 'response', isUser ? 'success' : 'failure', durationMs, requestSize), () => isUser
192
- ? log.warn(`[API-${side}-resp-OTHER] ${id} errorType=${errorType}`)
196
+ ? log.warn(`[API-${side}-resp-OTHER] ${id} errorType=${errorType} error=${error.message}`)
193
197
  : log.error(`[API-${side}-resp-FAIL] ${id} errorType=${errorType} error=${error.message}`));
194
198
  }
195
199
  /**
@@ -1 +1 @@
1
- {"version":3,"file":"LogApiCall.js","sourceRoot":"","sources":["../../../../../../packages/core/core-util/src/http/LogApiCall.ts"],"names":[],"mappings":"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","sourcesContent":["import { toError } from '../lib/errorUtils';\nimport { LogManager } from '../logging/LogManager';\nimport { ApiCallInfo } from './ApiCallInfo';\nimport { ApiMethodInfo } from './ApiMethodInfo';\nimport { ApiCallContext } from './ApiCallContext';\nimport { WebpiecesCoreHeaders } from './WebpiecesCoreHeaders';\nimport { LOG_API_CALL_LOGGER_NAME } from './ApiCallLogName';\nimport { ClientRegistry } from './ClientRegistry';\nimport { WEBPIECES_DEFAULT_FAILURE_CLASSIFIER } from './WebpiecesDefaultFailureClassifier';\n\n// The console backends special-case THIS logger name into a self-describing [API.{side}.{phase}]\n// bracket (see ApiCallLogName) — so the name here and the name they match are the one constant.\nconst log = LogManager.getLogger(LOG_API_CALL_LOGGER_NAME);\n\n/**\n * LogApiCallImpl - Generic API call logging utility, used by BOTH server-side (LogApiFilter) and\n * client-side (ProxyClient) for one consistent logging shape across the framework.\n *\n * TWO things happen around each call:\n * 1. Text lines are emitted (the human-readable `[API-...]` patterns below).\n * 2. A structured {@link ApiCallInfo} tag is stamped into the ambient request context via the\n * {@link ApiCallContext} seam, so EVERY log line emitted during the call (not just the\n * req/resp lines) inherits a filterable `api` object — surfacing in GCP as\n * `jsonPayload.api.{method.{side,apiClass,methodName,controllerName},type,result}`.\n *\n * BROWSER-SAFE: this lives in core-util and runs in the browser bundle (via ProxyClient →\n * BrowserProxyClient), so it MUST NOT import `RequestContext` (Node async_hooks, and a circular dep).\n * It stamps through the {@link ApiCallContext} seam instead, and takes that seam as a REQUIRED\n * CONSTRUCTOR ARGUMENT — there is no process-global holder to install and none to forget. Each\n * environment-specific package constructs its own:\n *\n * LogApiFilter (@webpieces/http-routing) -> new LogApiCallImpl(new RequestContextApiCallContext())\n * NodeProxyClient (@webpieces/http-client-node) -> new LogApiCallImpl(new RequestContextApiCallContext())\n * TaskProxyClient (@webpieces/cloudtasks-client) -> new LogApiCallImpl(new RequestContextApiCallContext())\n * BrowserProxyClient (@webpieces/http-client-browser) -> new LogApiCallImpl(new BrowserApiCallContext())\n *\n * NOT a singleton, deliberately: a shared instance would need a shared context, which is the global\n * this constructor replaced. Construct one where you know which environment you are in.\n *\n * Logging format patterns:\n * - [API-{side}-req] ClassName.methodName request={...}\n * - [API-{side}-resp-SUCCESS] ClassName.methodName response={...}\n * - [API-{side}-resp-OTHER] ClassName.methodName errorType={...} (user errors)\n * - [API-{side}-resp-FAIL] ClassName.methodName error={...} (server errors)\n */\nexport class LogApiCallImpl {\n /**\n * @param ctx - the environment's {@link ApiCallContext}. REQUIRED, with no default: that is what\n * turns \"nobody bootstrapped the context\" into a compile error instead of a throw on the first\n * real call in production.\n */\n constructor(private readonly ctx: ApiCallContext) {}\n\n /**\n * Execute an API call with logging + `api` context-tagging around it.\n *\n * @param methodInfo - The transport-neutral call identity (side, apiClass, methodName,\n * controllerName?). `apiClass` is what matches a client call to its server handler in the logs.\n * @param requestDto - The request DTO (external multi-param callers synthesize a small object)\n * @param method - The method to execute\n *\n * Correlation fields (requestId, tenantId, ...) are NOT stamped here — a logging BACKEND owns that,\n * reading RequestContext on every record. What IS stamped here is the per-call `api` tag, and only\n * for the SYNCHRONOUS span of each log line: set → log → remove. Because the tag is never held across\n * `await method(...)`, a concurrent browser call (single-threaded, one global slot) can never clobber\n * it. Cost: only the `[API-*]` req/resp lines carry `api`, not lines emitted mid-call — which is\n * exactly what the GCP filters (`jsonPayload.api.*`) want.\n */\n public async execute<Q, R>(\n methodInfo: ApiMethodInfo,\n requestDto: Q,\n method: (dto: Q) => Promise<R>,\n responseCount?: (response: R) => number | undefined,\n ): Promise<R> {\n const ctx = this.activeContext();\n const key = WebpiecesCoreHeaders.API_CALL_INFO;\n const side = methodInfo.side;\n const id = `${methodInfo.apiClass}.${methodInfo.methodName}`;\n // set → emit → remove, as ONE synchronous span: the tag is live only while the logger reads it,\n // never across an await, so a single browser global slot can never be clobbered by a concurrent call.\n const stamp = (info: ApiCallInfo, emit: () => void): void => {\n ctx.set(key, info);\n // webpieces-disable no-unmanaged-exceptions -- cleanup must run when a logging backend throws\n try {\n emit();\n } finally {\n ctx.remove(key);\n }\n };\n\n // Stringify ONCE and reuse for both the log text and the size — a second JSON.stringify of a\n // large DTO purely to measure it would double the cost of the thing we are measuring.\n // Only take the field-masking hit when this call declared sensitive fields; otherwise the plain\n // JSON.stringify fast path, unchanged for every existing caller.\n const requestBody = this.serialize(requestDto, methodInfo);\n const requestSize = this.byteSize(requestBody);\n // Declared out here so the catch below can read it too. Reassigned just before the call, so\n // the number times ONLY the call and not our own request-logging.\n let startMs = Date.now();\n\n // eslint-disable-next-line @webpieces/no-unmanaged-exceptions -- LogApiCall logs errors before re-throwing to caller\n try {\n stamp(new ApiCallInfo(methodInfo, 'request', undefined, undefined, requestSize), () =>\n log.info(`[API-${side}-req] ${id} request=${requestBody}`),\n );\n\n if (!requestDto) throw new Error(`Request cannot be null and was from ${id}`);\n\n startMs = Date.now();\n const response = await method(requestDto);\n const durationMs = Date.now() - startMs;\n\n const responseBody = this.serialize(response, methodInfo);\n const count = this.selectResponseCount(response, responseCount, side, id);\n stamp(\n new ApiCallInfo(\n methodInfo,\n 'response',\n 'success',\n durationMs,\n requestSize,\n this.byteSize(responseBody),\n count,\n ),\n () => log.info(`[API-${side}-resp-SUCCESS] ${id} response=${responseBody}`),\n );\n\n return response;\n } catch (err: unknown) {\n const error = toError(err);\n // Duration comes off the SAME start as the success path, so a slow failure (a timeout, a\n // hung dependency) reports its real cost rather than nothing.\n this.logFailure(error, methodInfo, Date.now() - startMs, requestSize, stamp);\n throw err;\n }\n }\n\n /**\n * Evaluate an opt-in logical-item count without allowing observability code to affect the call.\n * Warnings deliberately contain neither request nor response bodies (nor the invalid value).\n */\n private selectResponseCount<R>(\n response: R,\n selector: ((response: R) => number | undefined) | undefined,\n side: string,\n id: string,\n ): number | undefined {\n if (!selector) {\n return undefined;\n }\n\n // eslint-disable-next-line @webpieces/no-unmanaged-exceptions -- an observability callback must not fail the API call\n try {\n const count = selector(response);\n if (\n count === undefined ||\n (Number.isFinite(count) && Number.isInteger(count) && count >= 0)\n ) {\n return count;\n }\n this.warnInvalidResponseCount(side, id, 'returned an invalid value');\n } catch (err: unknown) {\n const error = toError(err);\n void error;\n this.warnInvalidResponseCount(side, id, 'threw');\n }\n return undefined;\n }\n\n private warnInvalidResponseCount(side: string, id: string, reason: string): void {\n // eslint-disable-next-line @webpieces/no-unmanaged-exceptions -- a warning backend must not replace a successful API response\n try {\n log.warn(\n `[API-${side}-resp-COUNT-WARN] ${id} responseCount selector ${reason}; omitting responseCount`,\n );\n } catch (err: unknown) {\n const error = toError(err);\n void error;\n // The API response is authoritative; observability failures are intentionally ignored here.\n }\n }\n\n /**\n * Serialize a DTO for the LOG LINE ONLY. With no mask on the call, this is a plain JSON.stringify\n * (byte-for-byte the old behavior, no walk) so existing callers pay nothing. With a mask, it runs\n * {@link MaskSpec.stringify}, which produces a masked STRING without ever mutating the DTO — so the\n * object handed to the transport, and thus the value ON THE WIRE, is unchanged.\n */\n private serialize(\n // webpieces-disable no-any-unknown -- DTO types are erased at the api/proxy boundary (matches execute)\n dto: unknown,\n methodInfo: ApiMethodInfo,\n ): string | undefined {\n return methodInfo.mask ? methodInfo.mask.stringify(dto) : JSON.stringify(dto);\n }\n\n /**\n * The ApiCallContext to stamp into. It cannot be MISSING (it is a constructor argument), but it\n * can be INACTIVE — a Node context used outside any `RequestContext.run(...)` scope. That throws:\n * an api call with nowhere to tag is a bug.\n */\n private activeContext(): ApiCallContext {\n const ctx = this.ctx;\n if (!ctx.isActive()) {\n throw new Error(\n 'LogApiCall requires an ACTIVE ApiCallContext. On a Node server, run inside a ' +\n 'RequestContext.run(...) scope — a server filter opens one per request, and a ' +\n 'non-webpieces host must open one around the work that calls a webpieces client. ' +\n '(A BrowserApiCallContext is always active, so this can only be the Node side.)',\n );\n }\n return ctx;\n }\n\n /**\n * Tag + log a thrown call. There is no responseSize — a throw produced no response body to measure.\n */\n private logFailure(\n error: Error,\n methodInfo: ApiMethodInfo,\n durationMs: number,\n requestSize: number | undefined,\n stamp: (info: ApiCallInfo, emit: () => void) => void,\n ): void {\n const side = methodInfo.side;\n const id = `${methodInfo.apiClass}.${methodInfo.methodName}`;\n const errorType = error.constructor.name;\n // Pluggable classification (ClientRegistry): a per-apiClass EXTERNAL-client classifier wins,\n // else the app default, else the webpieces built-in — which is side-dependent (a 4xx the SERVER\n // raised is a handled non-failure; the same 4xx a CLIENT receives means its call FAILED; 266 is\n // never a failure either side). `isUser` = \"treat as non-failure (OTHER / result:'success')\".\n const isUser = !ClientRegistry.classifyFailure(error, methodInfo);\n\n stamp(\n new ApiCallInfo(\n methodInfo,\n 'response',\n isUser ? 'success' : 'failure',\n durationMs,\n requestSize,\n ),\n () =>\n isUser\n ? log.warn(`[API-${side}-resp-OTHER] ${id} errorType=${errorType}`)\n : log.error(\n `[API-${side}-resp-FAIL] ${id} errorType=${errorType} error=${error.message}`,\n ),\n );\n }\n\n /**\n * UTF-8 byte size without platform-specific encoding globals: LogApiCall runs in the\n * browser bundle. Undefined in, undefined out — a `Promise<void>` method has no body to measure,\n * and a 0 there would be a lie (JSON.stringify(undefined) returns undefined, not '').\n */\n private byteSize(serialized: string | undefined): number | undefined {\n if (serialized === undefined) {\n return undefined;\n }\n let bytes = 0;\n for (const character of serialized) {\n const code = character.codePointAt(0)!;\n bytes += code <= 0x7f ? 1 : code <= 0x7ff ? 2 : code <= 0xffff ? 3 : 4;\n }\n return bytes;\n }\n\n /**\n * Is this error a NON-failure for HEALTH/METRICS — the process working CORRECTLY (log OTHER, api\n * result:'success') — rather than a real failure to surface (log FAIL, result:'failure')?\n *\n * BACK-COMPAT SHIM: the canonical logic now lives in {@link WebpiecesDefaultFailureClassifier}\n * (the webpieces built-in tier), and the LIVE classification path is\n * {@link ClientRegistry.classifyFailure} (per-apiClass → app default → built-in). This method\n * delegates to the built-in so existing callers/tests keep the exact old behavior; it does NOT\n * consult registered classifiers. `apiClass`/`methodName` are irrelevant to the built-in (it reads\n * only `side`), hence the empty strings.\n *\n * @param error - The already-normalized error (callers pass toError(err), never a raw catch value)\n * @param server - True when this side is the SERVER handling an inbound call; false for a CLIENT's outbound call\n * @returns true if this should be treated as a non-failure (OTHER / result:'success')\n */\n isUserError(error: Error, server: boolean): boolean {\n return !WEBPIECES_DEFAULT_FAILURE_CLASSIFIER.isFailure(\n error,\n new ApiMethodInfo(server ? 'server' : 'client', '', ''),\n );\n }\n}\n"]}
1
+ {"version":3,"file":"LogApiCall.js","sourceRoot":"","sources":["../../../../../../packages/core/core-util/src/http/LogApiCall.ts"],"names":[],"mappings":"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","sourcesContent":["import { toError } from '../lib/errorUtils';\nimport { LogManager } from '../logging/LogManager';\nimport { ApiCallInfo } from './ApiCallInfo';\nimport { ApiMethodInfo } from './ApiMethodInfo';\nimport { ApiCallContext } from './ApiCallContext';\nimport { WebpiecesCoreHeaders } from './WebpiecesCoreHeaders';\nimport { LOG_API_CALL_LOGGER_NAME } from './ApiCallLogName';\nimport { ClientRegistry } from './ClientRegistry';\nimport { WEBPIECES_DEFAULT_FAILURE_CLASSIFIER } from './WebpiecesDefaultFailureClassifier';\n\n// The console backends special-case THIS logger name into a self-describing [API.{side}.{phase}]\n// bracket (see ApiCallLogName) — so the name here and the name they match are the one constant.\nconst log = LogManager.getLogger(LOG_API_CALL_LOGGER_NAME);\n\n/**\n * LogApiCallImpl - Generic API call logging utility, used by BOTH server-side (LogApiFilter) and\n * client-side (ProxyClient) for one consistent logging shape across the framework.\n *\n * TWO things happen around each call:\n * 1. Text lines are emitted (the human-readable `[API-...]` patterns below).\n * 2. A structured {@link ApiCallInfo} tag is stamped into the ambient request context via the\n * {@link ApiCallContext} seam, so EVERY log line emitted during the call (not just the\n * req/resp lines) inherits a filterable `api` object — surfacing in GCP as\n * `jsonPayload.api.{method.{side,apiClass,methodName,controllerName},type,result}`.\n *\n * BROWSER-SAFE: this lives in core-util and runs in the browser bundle (via ProxyClient →\n * BrowserProxyClient), so it MUST NOT import `RequestContext` (Node async_hooks, and a circular dep).\n * It stamps through the {@link ApiCallContext} seam instead, and takes that seam as a REQUIRED\n * CONSTRUCTOR ARGUMENT — there is no process-global holder to install and none to forget. Each\n * environment-specific package constructs its own:\n *\n * LogApiFilter (@webpieces/http-routing) -> new LogApiCallImpl(new RequestContextApiCallContext())\n * NodeProxyClient (@webpieces/http-client-node) -> new LogApiCallImpl(new RequestContextApiCallContext())\n * TaskProxyClient (@webpieces/cloudtasks-client) -> new LogApiCallImpl(new RequestContextApiCallContext())\n * BrowserProxyClient (@webpieces/http-client-browser) -> new LogApiCallImpl(new BrowserApiCallContext())\n *\n * NOT a singleton, deliberately: a shared instance would need a shared context, which is the global\n * this constructor replaced. Construct one where you know which environment you are in.\n *\n * Logging format patterns:\n * - [API-{side}-req] ClassName.methodName request={...}\n * - [API-{side}-resp-SUCCESS] ClassName.methodName response={...}\n * - [API-{side}-resp-OTHER] ClassName.methodName errorType={...} error={...} (user errors)\n * - [API-{side}-resp-FAIL] ClassName.methodName error={...} (server errors)\n */\nexport class LogApiCallImpl {\n /**\n * @param ctx - the environment's {@link ApiCallContext}. REQUIRED, with no default: that is what\n * turns \"nobody bootstrapped the context\" into a compile error instead of a throw on the first\n * real call in production.\n */\n constructor(private readonly ctx: ApiCallContext) {}\n\n /**\n * Execute an API call with logging + `api` context-tagging around it.\n *\n * @param methodInfo - The transport-neutral call identity (side, apiClass, methodName,\n * controllerName?). `apiClass` is what matches a client call to its server handler in the logs.\n * @param requestDto - The request DTO (external multi-param callers synthesize a small object)\n * @param method - The method to execute\n *\n * Correlation fields (requestId, tenantId, ...) are NOT stamped here — a logging BACKEND owns that,\n * reading RequestContext on every record. What IS stamped here is the per-call `api` tag, and only\n * for the SYNCHRONOUS span of each log line: set → log → remove. Because the tag is never held across\n * `await method(...)`, a concurrent browser call (single-threaded, one global slot) can never clobber\n * it. Cost: only the `[API-*]` req/resp lines carry `api`, not lines emitted mid-call — which is\n * exactly what the GCP filters (`jsonPayload.api.*`) want.\n */\n public async execute<Q, R>(\n methodInfo: ApiMethodInfo,\n requestDto: Q,\n method: (dto: Q) => Promise<R>,\n responseCount?: (response: R) => number | undefined,\n ): Promise<R> {\n const ctx = this.activeContext();\n const key = WebpiecesCoreHeaders.API_CALL_INFO;\n const side = methodInfo.side;\n const id = `${methodInfo.apiClass}.${methodInfo.methodName}`;\n // set → emit → remove, as ONE synchronous span: the tag is live only while the logger reads it,\n // never across an await, so a single browser global slot can never be clobbered by a concurrent call.\n const stamp = (info: ApiCallInfo, emit: () => void): void => {\n ctx.set(key, info);\n // webpieces-disable no-unmanaged-exceptions -- cleanup must run when a logging backend throws\n try {\n emit();\n } finally {\n ctx.remove(key);\n }\n };\n\n // Stringify ONCE and reuse for both the log text and the size — a second JSON.stringify of a\n // large DTO purely to measure it would double the cost of the thing we are measuring.\n // Only take the field-masking hit when this call declared sensitive fields; otherwise the plain\n // JSON.stringify fast path, unchanged for every existing caller.\n const requestBody = this.serialize(requestDto, methodInfo);\n const requestSize = this.byteSize(requestBody);\n // Declared out here so the catch below can read it too. Reassigned just before the call, so\n // the number times ONLY the call and not our own request-logging.\n let startMs = Date.now();\n\n // eslint-disable-next-line @webpieces/no-unmanaged-exceptions -- LogApiCall logs errors before re-throwing to caller\n try {\n stamp(new ApiCallInfo(methodInfo, 'request', undefined, undefined, requestSize), () =>\n log.info(`[API-${side}-req] ${id} request=${requestBody}`),\n );\n\n if (!requestDto) throw new Error(`Request cannot be null and was from ${id}`);\n\n startMs = Date.now();\n const response = await method(requestDto);\n const durationMs = Date.now() - startMs;\n\n const responseBody = this.serialize(response, methodInfo);\n const count = this.selectResponseCount(response, responseCount, side, id);\n stamp(\n new ApiCallInfo(\n methodInfo,\n 'response',\n 'success',\n durationMs,\n requestSize,\n this.byteSize(responseBody),\n count,\n ),\n () => log.info(`[API-${side}-resp-SUCCESS] ${id} response=${responseBody}`),\n );\n\n return response;\n } catch (err: unknown) {\n const error = toError(err);\n // Duration comes off the SAME start as the success path, so a slow failure (a timeout, a\n // hung dependency) reports its real cost rather than nothing.\n this.logFailure(error, methodInfo, Date.now() - startMs, requestSize, stamp);\n throw err;\n }\n }\n\n /**\n * Evaluate an opt-in logical-item count without allowing observability code to affect the call.\n * Warnings deliberately contain neither request nor response bodies (nor the invalid value).\n */\n private selectResponseCount<R>(\n response: R,\n selector: ((response: R) => number | undefined) | undefined,\n side: string,\n id: string,\n ): number | undefined {\n if (!selector) {\n return undefined;\n }\n\n // eslint-disable-next-line @webpieces/no-unmanaged-exceptions -- an observability callback must not fail the API call\n try {\n const count = selector(response);\n if (\n count === undefined ||\n (Number.isFinite(count) && Number.isInteger(count) && count >= 0)\n ) {\n return count;\n }\n this.warnInvalidResponseCount(side, id, 'returned an invalid value');\n } catch (err: unknown) {\n const error = toError(err);\n void error;\n this.warnInvalidResponseCount(side, id, 'threw');\n }\n return undefined;\n }\n\n private warnInvalidResponseCount(side: string, id: string, reason: string): void {\n // eslint-disable-next-line @webpieces/no-unmanaged-exceptions -- a warning backend must not replace a successful API response\n try {\n log.warn(\n `[API-${side}-resp-COUNT-WARN] ${id} responseCount selector ${reason}; omitting responseCount`,\n );\n } catch (err: unknown) {\n const error = toError(err);\n void error;\n // The API response is authoritative; observability failures are intentionally ignored here.\n }\n }\n\n /**\n * Serialize a DTO for the LOG LINE ONLY. With no mask on the call, this is a plain JSON.stringify\n * (byte-for-byte the old behavior, no walk) so existing callers pay nothing. With a mask, it runs\n * {@link MaskSpec.stringify}, which produces a masked STRING without ever mutating the DTO — so the\n * object handed to the transport, and thus the value ON THE WIRE, is unchanged.\n */\n private serialize(\n // webpieces-disable no-any-unknown -- DTO types are erased at the api/proxy boundary (matches execute)\n dto: unknown,\n methodInfo: ApiMethodInfo,\n ): string | undefined {\n return methodInfo.mask ? methodInfo.mask.stringify(dto) : JSON.stringify(dto);\n }\n\n /**\n * The ApiCallContext to stamp into. It cannot be MISSING (it is a constructor argument), but it\n * can be INACTIVE — a Node context used outside any `RequestContext.run(...)` scope. That throws:\n * an api call with nowhere to tag is a bug.\n */\n private activeContext(): ApiCallContext {\n const ctx = this.ctx;\n if (!ctx.isActive()) {\n throw new Error(\n 'LogApiCall requires an ACTIVE ApiCallContext. On a Node server, run inside a ' +\n 'RequestContext.run(...) scope — a server filter opens one per request, and a ' +\n 'non-webpieces host must open one around the work that calls a webpieces client. ' +\n '(A BrowserApiCallContext is always active, so this can only be the Node side.)',\n );\n }\n return ctx;\n }\n\n /**\n * Tag + log a thrown call. There is no responseSize — a throw produced no response body to measure.\n */\n private logFailure(\n error: Error,\n methodInfo: ApiMethodInfo,\n durationMs: number,\n requestSize: number | undefined,\n stamp: (info: ApiCallInfo, emit: () => void) => void,\n ): void {\n const side = methodInfo.side;\n const id = `${methodInfo.apiClass}.${methodInfo.methodName}`;\n const errorType = error.constructor.name;\n // The MESSAGE is on BOTH branches, and that is load-bearing: this is the ONLY operator line\n // per failure now that `ApiErrorBoundary` writes none. An operator error message is never\n // published to a caller (see `ApiErrorCodec.publicMessage`), so the log is the one place it\n // exists at all — dropping it on the caller-error branch would lose it outright.\n // Pluggable classification (ClientRegistry): a per-apiClass EXTERNAL-client classifier wins,\n // else the app default, else the webpieces built-in — which is side-dependent (a 4xx the SERVER\n // raised is a handled non-failure; the same 4xx a CLIENT receives means its call FAILED; 266 is\n // never a failure either side). `isUser` = \"treat as non-failure (OTHER / result:'success')\".\n const isUser = !ClientRegistry.classifyFailure(error, methodInfo);\n\n stamp(\n new ApiCallInfo(\n methodInfo,\n 'response',\n isUser ? 'success' : 'failure',\n durationMs,\n requestSize,\n ),\n () =>\n isUser\n ? log.warn(\n `[API-${side}-resp-OTHER] ${id} errorType=${errorType} error=${error.message}`,\n )\n : log.error(\n `[API-${side}-resp-FAIL] ${id} errorType=${errorType} error=${error.message}`,\n ),\n );\n }\n\n /**\n * UTF-8 byte size without platform-specific encoding globals: LogApiCall runs in the\n * browser bundle. Undefined in, undefined out — a `Promise<void>` method has no body to measure,\n * and a 0 there would be a lie (JSON.stringify(undefined) returns undefined, not '').\n */\n private byteSize(serialized: string | undefined): number | undefined {\n if (serialized === undefined) {\n return undefined;\n }\n let bytes = 0;\n for (const character of serialized) {\n const code = character.codePointAt(0)!;\n bytes += code <= 0x7f ? 1 : code <= 0x7ff ? 2 : code <= 0xffff ? 3 : 4;\n }\n return bytes;\n }\n\n /**\n * Is this error a NON-failure for HEALTH/METRICS — the process working CORRECTLY (log OTHER, api\n * result:'success') — rather than a real failure to surface (log FAIL, result:'failure')?\n *\n * BACK-COMPAT SHIM: the canonical logic now lives in {@link WebpiecesDefaultFailureClassifier}\n * (the webpieces built-in tier), and the LIVE classification path is\n * {@link ClientRegistry.classifyFailure} (per-apiClass → app default → built-in). This method\n * delegates to the built-in so existing callers/tests keep the exact old behavior; it does NOT\n * consult registered classifiers. `apiClass`/`methodName` are irrelevant to the built-in (it reads\n * only `side`), hence the empty strings.\n *\n * @param error - The already-normalized error (callers pass toError(err), never a raw catch value)\n * @param server - True when this side is the SERVER handling an inbound call; false for a CLIENT's outbound call\n * @returns true if this should be treated as a non-failure (OTHER / result:'success')\n */\n isUserError(error: Error, server: boolean): boolean {\n return !WEBPIECES_DEFAULT_FAILURE_CLASSIFIER.isFailure(\n error,\n new ApiMethodInfo(server ? 'server' : 'client', '', ''),\n );\n }\n}\n"]}
@@ -1,5 +1,5 @@
1
1
  import 'reflect-metadata';
2
- import { ApiError, ApiConnectionError } from '../errors/ApiError';
2
+ import { ApiConnectionError } from '../errors/ApiError';
3
3
  import { ApiErrorPayload } from '../errors/ApiErrorCodec';
4
4
  import { ApiJsonSchema, DtoClass, DtoValue } from '../mcp/DtoSchema';
5
5
  /** Stable application-level key for relating stream events and failures. */
@@ -15,14 +15,40 @@ export interface StreamFailureOptions {
15
15
  /** Server-to-client half of a typed stream. Every write is an explicit backpressure boundary. */
16
16
  export interface ResponseStream<T> {
17
17
  event(value: T, correlation?: StreamCorrelation): Promise<void>;
18
- fail(error: ApiError, correlation?: StreamCorrelation, options?: StreamFailureOptions): Promise<void>;
18
+ /**
19
+ * Publish a failure on this stream. Takes `Error`, the parent of everything: `ApiError` is a
20
+ * CONVENIENCE taxonomy webpieces ships so the common cases are easy, never something the
21
+ * framework may demand from an application. An app's own `MyLibError extends Error` is
22
+ * published as kind `implementation` with generic text, exactly as on every other boundary.
23
+ *
24
+ * This is the FAITHFUL {@link ApiErrorCodec} encode, NOT `ApiErrorBoundary.encode`, and that is
25
+ * deliberate: a stream failure is delivered IN BAND to a peer that is reading the stream, so a
26
+ * {@link StreamTransportError} (an `ApiConnectionError`) must arrive as kind `connection` for
27
+ * the peer to reconstruct it as the transport failure it is. Republishing it as
28
+ * `implementation` — right at an HTTP/MCP edge, where the caller is asking us to do work — would
29
+ * tell the peer the wrong thing about a stream it is holding one end of.
30
+ */
31
+ fail(error: Error, correlation?: StreamCorrelation, options?: StreamFailureOptions): Promise<void>;
19
32
  complete(): Promise<void>;
20
33
  onCancel(handler: (reason?: unknown) => void | Promise<void>): void;
21
34
  }
22
35
  /** Client-to-server half. Cancellation exists immediately and is independent of writer readiness. */
23
36
  export interface RequestStream<T> {
24
37
  event(value: T, correlation?: StreamCorrelation): Promise<void>;
25
- fail(error: ApiError, correlation?: StreamCorrelation, options?: StreamFailureOptions): Promise<void>;
38
+ /**
39
+ * Publish a failure on this stream. Takes `Error`, the parent of everything: `ApiError` is a
40
+ * CONVENIENCE taxonomy webpieces ships so the common cases are easy, never something the
41
+ * framework may demand from an application. An app's own `MyLibError extends Error` is
42
+ * published as kind `implementation` with generic text, exactly as on every other boundary.
43
+ *
44
+ * This is the FAITHFUL {@link ApiErrorCodec} encode, NOT `ApiErrorBoundary.encode`, and that is
45
+ * deliberate: a stream failure is delivered IN BAND to a peer that is reading the stream, so a
46
+ * {@link StreamTransportError} (an `ApiConnectionError`) must arrive as kind `connection` for
47
+ * the peer to reconstruct it as the transport failure it is. Republishing it as
48
+ * `implementation` — right at an HTTP/MCP edge, where the caller is asking us to do work — would
49
+ * tell the peer the wrong thing about a stream it is holding one end of.
50
+ */
51
+ fail(error: Error, correlation?: StreamCorrelation, options?: StreamFailureOptions): Promise<void>;
26
52
  complete(): Promise<void>;
27
53
  cancel(reason?: unknown): Promise<void>;
28
54
  }
@@ -73,7 +99,7 @@ export declare class StreamWriter<T> implements RequestStream<T>, ResponseStream
73
99
  private readonly cancelHandlers;
74
100
  constructor(sink: (envelope: StreamEnvelope<T>) => Promise<void>, validateEvent?: ((value: T) => void) | undefined, allowNonTerminalFailures?: boolean);
75
101
  event(value: T, correlation?: StreamCorrelation): Promise<void>;
76
- fail(error: ApiError, correlation?: StreamCorrelation, options?: StreamFailureOptions): Promise<void>;
102
+ fail(error: Error, correlation?: StreamCorrelation, options?: StreamFailureOptions): Promise<void>;
77
103
  complete(): Promise<void>;
78
104
  onCancel(handler: (reason?: unknown) => void | Promise<void>): void;
79
105
  cancel(reason?: unknown): Promise<void>;
@@ -1 +1 @@
1
- {"version":3,"file":"StreamingContract.js","sourceRoot":"","sources":["../../../../../../packages/core/core-util/src/http/StreamingContract.ts"],"names":[],"mappings":"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","sourcesContent":["import 'reflect-metadata';\nimport { ApiError, ApiConnectionError } from '../errors/ApiError';\nimport { ApiErrorCodec, ApiErrorPayload } from '../errors/ApiErrorCodec';\nimport { ApiJsonSchema, DtoClass, DtoSchemaBuilder, DtoValue } from '../mcp/DtoSchema';\nimport { toError } from '../lib/errorUtils';\nimport { METADATA_KEYS } from './decorators';\n\n/** Stable application-level key for relating stream events and failures. */\nexport class StreamCorrelation {\n constructor(\n public readonly key: string | number,\n public readonly requestId?: string,\n ) {}\n}\n\nexport interface StreamFailureOptions {\n /** Defaults to true. Wire adapters may promote false to true when continuation is unsafe. */\n terminal?: boolean;\n}\n\n/** Server-to-client half of a typed stream. Every write is an explicit backpressure boundary. */\nexport interface ResponseStream<T> {\n event(value: T, correlation?: StreamCorrelation): Promise<void>;\n fail(\n error: ApiError,\n correlation?: StreamCorrelation,\n options?: StreamFailureOptions,\n ): Promise<void>;\n complete(): Promise<void>;\n // webpieces-disable no-any-unknown -- cancellation reasons are deliberately transport-neutral\n onCancel(handler: (reason?: unknown) => void | Promise<void>): void;\n}\n\n/** Client-to-server half. Cancellation exists immediately and is independent of writer readiness. */\nexport interface RequestStream<T> {\n event(value: T, correlation?: StreamCorrelation): Promise<void>;\n fail(\n error: ApiError,\n correlation?: StreamCorrelation,\n options?: StreamFailureOptions,\n ): Promise<void>;\n complete(): Promise<void>;\n // webpieces-disable no-any-unknown -- cancellation reasons are deliberately transport-neutral\n cancel(reason?: unknown): Promise<void>;\n}\n\nexport type StreamEnvelopeKind = 'event' | 'failure' | 'complete';\n\n/** Transport-neutral record used by NDJSON and SSE adapters. */\nexport class StreamEnvelope<T = DtoValue> {\n constructor(\n public readonly kind: StreamEnvelopeKind,\n public readonly value?: T,\n public readonly error?: ApiErrorPayload,\n public readonly correlation?: StreamCorrelation,\n public readonly terminal?: boolean,\n ) {}\n}\n\n/** Local failure where no legal in-band envelope could be sent or decoded. */\nexport class StreamTransportError extends ApiConnectionError {\n constructor(\n message: string,\n public readonly correlation?: StreamCorrelation,\n public readonly requestId?: string,\n cause?: Error,\n ) {\n super(message, cause);\n }\n}\n\n/** Runtime schema and wire-policy metadata for one streaming method. */\nexport class StreamingEndpointMetadata {\n readonly requestSchema: ApiJsonSchema;\n readonly responseSchema: ApiJsonSchema;\n\n constructor(\n public readonly requestEventClass: DtoClass,\n public readonly responseEventClass: DtoClass,\n /** Generic NDJSON/SSE supports non-terminal failures in both directions. */\n public readonly supportsNonTerminalFailures = true,\n ) {\n const schemas = new DtoSchemaBuilder();\n this.requestSchema = schemas.build(requestEventClass);\n this.responseSchema = schemas.build(responseEventClass);\n }\n}\n\n/** Marks `(ResponseStream<ResponseEvent>) => Promise<RequestStream<RequestEvent>>`. */\n// webpieces-disable no-function-outside-class -- decorator factory\nexport function WpStream(\n requestEvent: () => DtoClass,\n responseEvent: () => DtoClass,\n): MethodDecorator {\n return (target: object, propertyKey: string | symbol): void => {\n const apiClass = target.constructor;\n const methods: Record<string, StreamingEndpointMetadata> =\n Reflect.getMetadata(METADATA_KEYS.STREAM_ENDPOINTS, apiClass) ?? {};\n methods[String(propertyKey)] = new StreamingEndpointMetadata(\n requestEvent(),\n responseEvent(),\n );\n Reflect.defineMetadata(METADATA_KEYS.STREAM_ENDPOINTS, methods, apiClass);\n };\n}\n\n// webpieces-disable no-function-outside-class -- metadata reader paired with WpStream\nexport function getStreamingEndpoint(\n apiClass: Function,\n methodName: string,\n): StreamingEndpointMetadata | undefined {\n const methods: Record<string, StreamingEndpointMetadata> =\n Reflect.getMetadata(METADATA_KEYS.STREAM_ENDPOINTS, apiClass) ?? {};\n return methods[methodName];\n}\n\n/** Validate a typed event at every adapter boundary, with one consistent diagnostic. */\nexport class StreamEventValidator {\n private readonly schemas = new DtoSchemaBuilder();\n\n // webpieces-disable no-any-unknown -- DTO validation is the runtime narrowing boundary\n validate(dtoClass: DtoClass, value: unknown, direction: 'request' | 'response'): void {\n const failure = this.schemas.validate(dtoClass, value as DtoValue);\n if (failure)\n throw new StreamTransportError(`Invalid ${direction} stream event: ${failure.message}`);\n }\n}\n\n/** Awaiting the sink acknowledges the write, so adapters cannot outrun their consumer. */\nexport class StreamWriter<T> implements RequestStream<T>, ResponseStream<T> {\n private terminal = false;\n private cancelled = false;\n private pending: Promise<void> = Promise.resolve();\n // webpieces-disable no-any-unknown -- cancellation reasons are deliberately transport-neutral\n private readonly cancelHandlers: Array<(reason?: unknown) => void | Promise<void>> = [];\n\n constructor(\n private readonly sink: (envelope: StreamEnvelope<T>) => Promise<void>,\n private readonly validateEvent?: (value: T) => void,\n private readonly allowNonTerminalFailures = true,\n ) {}\n\n async event(value: T, correlation?: StreamCorrelation): Promise<void> {\n this.requireWritable();\n this.validateEvent?.(value);\n await this.enqueue(new StreamEnvelope('event', value, undefined, correlation));\n }\n\n async fail(\n error: ApiError,\n correlation?: StreamCorrelation,\n options?: StreamFailureOptions,\n ): Promise<void> {\n this.requireWritable();\n const terminal = (options?.terminal ?? true) || !this.allowNonTerminalFailures;\n if (terminal) this.terminal = true;\n await this.enqueue(\n new StreamEnvelope<T>(\n 'failure',\n undefined,\n ApiErrorCodec.encode(error),\n correlation,\n terminal,\n ),\n );\n }\n\n async complete(): Promise<void> {\n this.requireWritable();\n this.terminal = true;\n await this.enqueue(new StreamEnvelope<T>('complete'));\n }\n\n // webpieces-disable no-any-unknown -- cancellation reasons are deliberately transport-neutral\n onCancel(handler: (reason?: unknown) => void | Promise<void>): void {\n this.cancelHandlers.push(handler);\n }\n\n // webpieces-disable no-any-unknown -- cancellation reasons are deliberately transport-neutral\n async cancel(reason?: unknown): Promise<void> {\n if (this.cancelled || this.terminal) return;\n this.cancelled = true;\n this.terminal = true;\n for (const handler of this.cancelHandlers) await handler(reason);\n }\n\n private requireWritable(): void {\n if (this.terminal || this.cancelled)\n throw new StreamTransportError('Cannot write after stream termination.');\n }\n\n private enqueue(envelope: StreamEnvelope<T>): Promise<void> {\n const delivery = this.pending.then(async () => this.sink(envelope));\n this.pending = delivery.catch(() => undefined);\n // webpieces-disable no-any-unknown -- transport boundary normalizes arbitrary sink failures\n return delivery.catch((err: unknown) => {\n this.terminal = true;\n const error = toError(err);\n if (error instanceof StreamTransportError) throw error;\n throw new StreamTransportError(\n 'Stream transport could not acknowledge a write.',\n envelope.correlation,\n envelope.correlation?.requestId,\n error,\n );\n });\n }\n}\n"]}
1
+ {"version":3,"file":"StreamingContract.js","sourceRoot":"","sources":["../../../../../../packages/core/core-util/src/http/StreamingContract.ts"],"names":[],"mappings":";;;AAoHA,4BAcC;AAGD,oDAOC;AA5ID,4BAA0B;AAC1B,iDAAwD;AACxD,2DAAyE;AACzE,gDAAuF;AACvF,kDAA4C;AAC5C,6CAA6C;AAE7C,4EAA4E;AAC5E,MAAa,iBAAiB;IAEN;IACA;IAFpB,YACoB,GAAoB,EACpB,SAAkB;QADlB,QAAG,GAAH,GAAG,CAAiB;QACpB,cAAS,GAAT,SAAS,CAAS;IACnC,CAAC;CACP;AALD,8CAKC;AA6DD,gEAAgE;AAChE,MAAa,cAAc;IAEH;IACA;IACA;IACA;IACA;IALpB,YACoB,IAAwB,EACxB,KAAS,EACT,KAAuB,EACvB,WAA+B,EAC/B,QAAkB;QAJlB,SAAI,GAAJ,IAAI,CAAoB;QACxB,UAAK,GAAL,KAAK,CAAI;QACT,UAAK,GAAL,KAAK,CAAkB;QACvB,gBAAW,GAAX,WAAW,CAAoB;QAC/B,aAAQ,GAAR,QAAQ,CAAU;IACnC,CAAC;CACP;AARD,wCAQC;AAED,8EAA8E;AAC9E,MAAa,oBAAqB,SAAQ,6BAAkB;IAGpC;IACA;IAHpB,YACI,OAAe,EACC,WAA+B,EAC/B,SAAkB,EAClC,KAAa;QAEb,KAAK,CAAC,OAAO,EAAE,KAAK,CAAC,CAAC;QAJN,gBAAW,GAAX,WAAW,CAAoB;QAC/B,cAAS,GAAT,SAAS,CAAS;IAItC,CAAC;CACJ;AATD,oDASC;AAED,wEAAwE;AACxE,MAAa,yBAAyB;IAKd;IACA;IAEA;IAPX,aAAa,CAAgB;IAC7B,cAAc,CAAgB;IAEvC,YACoB,iBAA2B,EAC3B,kBAA4B;IAC5C,4EAA4E;IAC5D,8BAA8B,IAAI;QAHlC,sBAAiB,GAAjB,iBAAiB,CAAU;QAC3B,uBAAkB,GAAlB,kBAAkB,CAAU;QAE5B,gCAA2B,GAA3B,2BAA2B,CAAO;QAElD,MAAM,OAAO,GAAG,IAAI,4BAAgB,EAAE,CAAC;QACvC,IAAI,CAAC,aAAa,GAAG,OAAO,CAAC,KAAK,CAAC,iBAAiB,CAAC,CAAC;QACtD,IAAI,CAAC,cAAc,GAAG,OAAO,CAAC,KAAK,CAAC,kBAAkB,CAAC,CAAC;IAC5D,CAAC;CACJ;AAdD,8DAcC;AAED,uFAAuF;AACvF,mEAAmE;AACnE,SAAgB,QAAQ,CACpB,YAA4B,EAC5B,aAA6B;IAE7B,OAAO,CAAC,MAAc,EAAE,WAA4B,EAAQ,EAAE;QAC1D,MAAM,QAAQ,GAAG,MAAM,CAAC,WAAW,CAAC;QACpC,MAAM,OAAO,GACT,OAAO,CAAC,WAAW,CAAC,0BAAa,CAAC,gBAAgB,EAAE,QAAQ,CAAC,IAAI,EAAE,CAAC;QACxE,OAAO,CAAC,MAAM,CAAC,WAAW,CAAC,CAAC,GAAG,IAAI,yBAAyB,CACxD,YAAY,EAAE,EACd,aAAa,EAAE,CAClB,CAAC;QACF,OAAO,CAAC,cAAc,CAAC,0BAAa,CAAC,gBAAgB,EAAE,OAAO,EAAE,QAAQ,CAAC,CAAC;IAC9E,CAAC,CAAC;AACN,CAAC;AAED,sFAAsF;AACtF,SAAgB,oBAAoB,CAChC,QAAkB,EAClB,UAAkB;IAElB,MAAM,OAAO,GACT,OAAO,CAAC,WAAW,CAAC,0BAAa,CAAC,gBAAgB,EAAE,QAAQ,CAAC,IAAI,EAAE,CAAC;IACxE,OAAO,OAAO,CAAC,UAAU,CAAC,CAAC;AAC/B,CAAC;AAED,wFAAwF;AACxF,MAAa,oBAAoB;IACZ,OAAO,GAAG,IAAI,4BAAgB,EAAE,CAAC;IAElD,uFAAuF;IACvF,QAAQ,CAAC,QAAkB,EAAE,KAAc,EAAE,SAAiC;QAC1E,MAAM,OAAO,GAAG,IAAI,CAAC,OAAO,CAAC,QAAQ,CAAC,QAAQ,EAAE,KAAiB,CAAC,CAAC;QACnE,IAAI,OAAO;YACP,MAAM,IAAI,oBAAoB,CAAC,WAAW,SAAS,kBAAkB,OAAO,CAAC,OAAO,EAAE,CAAC,CAAC;IAChG,CAAC;CACJ;AATD,oDASC;AAED,0FAA0F;AAC1F,MAAa,YAAY;IAQA;IACA;IACA;IATb,QAAQ,GAAG,KAAK,CAAC;IACjB,SAAS,GAAG,KAAK,CAAC;IAClB,OAAO,GAAkB,OAAO,CAAC,OAAO,EAAE,CAAC;IACnD,8FAA8F;IAC7E,cAAc,GAAsD,EAAE,CAAC;IAExF,YACqB,IAAoD,EACpD,aAAkC,EAClC,2BAA2B,IAAI;QAF/B,SAAI,GAAJ,IAAI,CAAgD;QACpD,kBAAa,GAAb,aAAa,CAAqB;QAClC,6BAAwB,GAAxB,wBAAwB,CAAO;IACjD,CAAC;IAEJ,KAAK,CAAC,KAAK,CAAC,KAAQ,EAAE,WAA+B;QACjD,IAAI,CAAC,eAAe,EAAE,CAAC;QACvB,IAAI,CAAC,aAAa,EAAE,CAAC,KAAK,CAAC,CAAC;QAC5B,MAAM,IAAI,CAAC,OAAO,CAAC,IAAI,cAAc,CAAC,OAAO,EAAE,KAAK,EAAE,SAAS,EAAE,WAAW,CAAC,CAAC,CAAC;IACnF,CAAC;IAED,KAAK,CAAC,IAAI,CACN,KAAY,EACZ,WAA+B,EAC/B,OAA8B;QAE9B,IAAI,CAAC,eAAe,EAAE,CAAC;QACvB,MAAM,QAAQ,GAAG,CAAC,OAAO,EAAE,QAAQ,IAAI,IAAI,CAAC,IAAI,CAAC,IAAI,CAAC,wBAAwB,CAAC;QAC/E,IAAI,QAAQ;YAAE,IAAI,CAAC,QAAQ,GAAG,IAAI,CAAC;QACnC,MAAM,IAAI,CAAC,OAAO,CACd,IAAI,cAAc,CACd,SAAS,EACT,SAAS,EACT,6BAAa,CAAC,MAAM,CAAC,KAAK,CAAC,EAC3B,WAAW,EACX,QAAQ,CACX,CACJ,CAAC;IACN,CAAC;IAED,KAAK,CAAC,QAAQ;QACV,IAAI,CAAC,eAAe,EAAE,CAAC;QACvB,IAAI,CAAC,QAAQ,GAAG,IAAI,CAAC;QACrB,MAAM,IAAI,CAAC,OAAO,CAAC,IAAI,cAAc,CAAI,UAAU,CAAC,CAAC,CAAC;IAC1D,CAAC;IAED,8FAA8F;IAC9F,QAAQ,CAAC,OAAmD;QACxD,IAAI,CAAC,cAAc,CAAC,IAAI,CAAC,OAAO,CAAC,CAAC;IACtC,CAAC;IAED,8FAA8F;IAC9F,KAAK,CAAC,MAAM,CAAC,MAAgB;QACzB,IAAI,IAAI,CAAC,SAAS,IAAI,IAAI,CAAC,QAAQ;YAAE,OAAO;QAC5C,IAAI,CAAC,SAAS,GAAG,IAAI,CAAC;QACtB,IAAI,CAAC,QAAQ,GAAG,IAAI,CAAC;QACrB,KAAK,MAAM,OAAO,IAAI,IAAI,CAAC,cAAc;YAAE,MAAM,OAAO,CAAC,MAAM,CAAC,CAAC;IACrE,CAAC;IAEO,eAAe;QACnB,IAAI,IAAI,CAAC,QAAQ,IAAI,IAAI,CAAC,SAAS;YAC/B,MAAM,IAAI,oBAAoB,CAAC,wCAAwC,CAAC,CAAC;IACjF,CAAC;IAEO,OAAO,CAAC,QAA2B;QACvC,MAAM,QAAQ,GAAG,IAAI,CAAC,OAAO,CAAC,IAAI,CAAC,KAAK,IAAI,EAAE,CAAC,IAAI,CAAC,IAAI,CAAC,QAAQ,CAAC,CAAC,CAAC;QACpE,IAAI,CAAC,OAAO,GAAG,QAAQ,CAAC,KAAK,CAAC,GAAG,EAAE,CAAC,SAAS,CAAC,CAAC;QAC/C,4FAA4F;QAC5F,OAAO,QAAQ,CAAC,KAAK,CAAC,CAAC,GAAY,EAAE,EAAE;YACnC,IAAI,CAAC,QAAQ,GAAG,IAAI,CAAC;YACrB,MAAM,KAAK,GAAG,IAAA,oBAAO,EAAC,GAAG,CAAC,CAAC;YAC3B,IAAI,KAAK,YAAY,oBAAoB;gBAAE,MAAM,KAAK,CAAC;YACvD,MAAM,IAAI,oBAAoB,CAC1B,iDAAiD,EACjD,QAAQ,CAAC,WAAW,EACpB,QAAQ,CAAC,WAAW,EAAE,SAAS,EAC/B,KAAK,CACR,CAAC;QACN,CAAC,CAAC,CAAC;IACP,CAAC;CACJ;AA9ED,oCA8EC","sourcesContent":["import 'reflect-metadata';\nimport { ApiConnectionError } from '../errors/ApiError';\nimport { ApiErrorCodec, ApiErrorPayload } from '../errors/ApiErrorCodec';\nimport { ApiJsonSchema, DtoClass, DtoSchemaBuilder, DtoValue } from '../mcp/DtoSchema';\nimport { toError } from '../lib/errorUtils';\nimport { METADATA_KEYS } from './decorators';\n\n/** Stable application-level key for relating stream events and failures. */\nexport class StreamCorrelation {\n constructor(\n public readonly key: string | number,\n public readonly requestId?: string,\n ) {}\n}\n\nexport interface StreamFailureOptions {\n /** Defaults to true. Wire adapters may promote false to true when continuation is unsafe. */\n terminal?: boolean;\n}\n\n/** Server-to-client half of a typed stream. Every write is an explicit backpressure boundary. */\nexport interface ResponseStream<T> {\n event(value: T, correlation?: StreamCorrelation): Promise<void>;\n /**\n * Publish a failure on this stream. Takes `Error`, the parent of everything: `ApiError` is a\n * CONVENIENCE taxonomy webpieces ships so the common cases are easy, never something the\n * framework may demand from an application. An app's own `MyLibError extends Error` is\n * published as kind `implementation` with generic text, exactly as on every other boundary.\n *\n * This is the FAITHFUL {@link ApiErrorCodec} encode, NOT `ApiErrorBoundary.encode`, and that is\n * deliberate: a stream failure is delivered IN BAND to a peer that is reading the stream, so a\n * {@link StreamTransportError} (an `ApiConnectionError`) must arrive as kind `connection` for\n * the peer to reconstruct it as the transport failure it is. Republishing it as\n * `implementation` — right at an HTTP/MCP edge, where the caller is asking us to do work — would\n * tell the peer the wrong thing about a stream it is holding one end of.\n */\n fail(\n error: Error,\n correlation?: StreamCorrelation,\n options?: StreamFailureOptions,\n ): Promise<void>;\n complete(): Promise<void>;\n // webpieces-disable no-any-unknown -- cancellation reasons are deliberately transport-neutral\n onCancel(handler: (reason?: unknown) => void | Promise<void>): void;\n}\n\n/** Client-to-server half. Cancellation exists immediately and is independent of writer readiness. */\nexport interface RequestStream<T> {\n event(value: T, correlation?: StreamCorrelation): Promise<void>;\n /**\n * Publish a failure on this stream. Takes `Error`, the parent of everything: `ApiError` is a\n * CONVENIENCE taxonomy webpieces ships so the common cases are easy, never something the\n * framework may demand from an application. An app's own `MyLibError extends Error` is\n * published as kind `implementation` with generic text, exactly as on every other boundary.\n *\n * This is the FAITHFUL {@link ApiErrorCodec} encode, NOT `ApiErrorBoundary.encode`, and that is\n * deliberate: a stream failure is delivered IN BAND to a peer that is reading the stream, so a\n * {@link StreamTransportError} (an `ApiConnectionError`) must arrive as kind `connection` for\n * the peer to reconstruct it as the transport failure it is. Republishing it as\n * `implementation` — right at an HTTP/MCP edge, where the caller is asking us to do work — would\n * tell the peer the wrong thing about a stream it is holding one end of.\n */\n fail(\n error: Error,\n correlation?: StreamCorrelation,\n options?: StreamFailureOptions,\n ): Promise<void>;\n complete(): Promise<void>;\n // webpieces-disable no-any-unknown -- cancellation reasons are deliberately transport-neutral\n cancel(reason?: unknown): Promise<void>;\n}\n\nexport type StreamEnvelopeKind = 'event' | 'failure' | 'complete';\n\n/** Transport-neutral record used by NDJSON and SSE adapters. */\nexport class StreamEnvelope<T = DtoValue> {\n constructor(\n public readonly kind: StreamEnvelopeKind,\n public readonly value?: T,\n public readonly error?: ApiErrorPayload,\n public readonly correlation?: StreamCorrelation,\n public readonly terminal?: boolean,\n ) {}\n}\n\n/** Local failure where no legal in-band envelope could be sent or decoded. */\nexport class StreamTransportError extends ApiConnectionError {\n constructor(\n message: string,\n public readonly correlation?: StreamCorrelation,\n public readonly requestId?: string,\n cause?: Error,\n ) {\n super(message, cause);\n }\n}\n\n/** Runtime schema and wire-policy metadata for one streaming method. */\nexport class StreamingEndpointMetadata {\n readonly requestSchema: ApiJsonSchema;\n readonly responseSchema: ApiJsonSchema;\n\n constructor(\n public readonly requestEventClass: DtoClass,\n public readonly responseEventClass: DtoClass,\n /** Generic NDJSON/SSE supports non-terminal failures in both directions. */\n public readonly supportsNonTerminalFailures = true,\n ) {\n const schemas = new DtoSchemaBuilder();\n this.requestSchema = schemas.build(requestEventClass);\n this.responseSchema = schemas.build(responseEventClass);\n }\n}\n\n/** Marks `(ResponseStream<ResponseEvent>) => Promise<RequestStream<RequestEvent>>`. */\n// webpieces-disable no-function-outside-class -- decorator factory\nexport function WpStream(\n requestEvent: () => DtoClass,\n responseEvent: () => DtoClass,\n): MethodDecorator {\n return (target: object, propertyKey: string | symbol): void => {\n const apiClass = target.constructor;\n const methods: Record<string, StreamingEndpointMetadata> =\n Reflect.getMetadata(METADATA_KEYS.STREAM_ENDPOINTS, apiClass) ?? {};\n methods[String(propertyKey)] = new StreamingEndpointMetadata(\n requestEvent(),\n responseEvent(),\n );\n Reflect.defineMetadata(METADATA_KEYS.STREAM_ENDPOINTS, methods, apiClass);\n };\n}\n\n// webpieces-disable no-function-outside-class -- metadata reader paired with WpStream\nexport function getStreamingEndpoint(\n apiClass: Function,\n methodName: string,\n): StreamingEndpointMetadata | undefined {\n const methods: Record<string, StreamingEndpointMetadata> =\n Reflect.getMetadata(METADATA_KEYS.STREAM_ENDPOINTS, apiClass) ?? {};\n return methods[methodName];\n}\n\n/** Validate a typed event at every adapter boundary, with one consistent diagnostic. */\nexport class StreamEventValidator {\n private readonly schemas = new DtoSchemaBuilder();\n\n // webpieces-disable no-any-unknown -- DTO validation is the runtime narrowing boundary\n validate(dtoClass: DtoClass, value: unknown, direction: 'request' | 'response'): void {\n const failure = this.schemas.validate(dtoClass, value as DtoValue);\n if (failure)\n throw new StreamTransportError(`Invalid ${direction} stream event: ${failure.message}`);\n }\n}\n\n/** Awaiting the sink acknowledges the write, so adapters cannot outrun their consumer. */\nexport class StreamWriter<T> implements RequestStream<T>, ResponseStream<T> {\n private terminal = false;\n private cancelled = false;\n private pending: Promise<void> = Promise.resolve();\n // webpieces-disable no-any-unknown -- cancellation reasons are deliberately transport-neutral\n private readonly cancelHandlers: Array<(reason?: unknown) => void | Promise<void>> = [];\n\n constructor(\n private readonly sink: (envelope: StreamEnvelope<T>) => Promise<void>,\n private readonly validateEvent?: (value: T) => void,\n private readonly allowNonTerminalFailures = true,\n ) {}\n\n async event(value: T, correlation?: StreamCorrelation): Promise<void> {\n this.requireWritable();\n this.validateEvent?.(value);\n await this.enqueue(new StreamEnvelope('event', value, undefined, correlation));\n }\n\n async fail(\n error: Error,\n correlation?: StreamCorrelation,\n options?: StreamFailureOptions,\n ): Promise<void> {\n this.requireWritable();\n const terminal = (options?.terminal ?? true) || !this.allowNonTerminalFailures;\n if (terminal) this.terminal = true;\n await this.enqueue(\n new StreamEnvelope<T>(\n 'failure',\n undefined,\n ApiErrorCodec.encode(error),\n correlation,\n terminal,\n ),\n );\n }\n\n async complete(): Promise<void> {\n this.requireWritable();\n this.terminal = true;\n await this.enqueue(new StreamEnvelope<T>('complete'));\n }\n\n // webpieces-disable no-any-unknown -- cancellation reasons are deliberately transport-neutral\n onCancel(handler: (reason?: unknown) => void | Promise<void>): void {\n this.cancelHandlers.push(handler);\n }\n\n // webpieces-disable no-any-unknown -- cancellation reasons are deliberately transport-neutral\n async cancel(reason?: unknown): Promise<void> {\n if (this.cancelled || this.terminal) return;\n this.cancelled = true;\n this.terminal = true;\n for (const handler of this.cancelHandlers) await handler(reason);\n }\n\n private requireWritable(): void {\n if (this.terminal || this.cancelled)\n throw new StreamTransportError('Cannot write after stream termination.');\n }\n\n private enqueue(envelope: StreamEnvelope<T>): Promise<void> {\n const delivery = this.pending.then(async () => this.sink(envelope));\n this.pending = delivery.catch(() => undefined);\n // webpieces-disable no-any-unknown -- transport boundary normalizes arbitrary sink failures\n return delivery.catch((err: unknown) => {\n this.terminal = true;\n const error = toError(err);\n if (error instanceof StreamTransportError) throw error;\n throw new StreamTransportError(\n 'Stream transport could not acknowledge a write.',\n envelope.correlation,\n envelope.correlation?.requestId,\n error,\n );\n });\n }\n}\n"]}
package/src/index.d.ts CHANGED
@@ -84,3 +84,4 @@ export { FilterChain } from './filters/FilterChain';
84
84
  export { ApiError, ApiNotFoundError, ApiDependencyError, ApiUnavailableError, ApiDependencyTimeoutError, ApiImplementationError, ApiRateLimitedError, ApiForbiddenError, ApiRequestTimeoutError, ApiEndUserError, ApiBadRequestError, ApiUnauthorizedError, ApiDependencyBackoffError, ApiConnectionError, ApiEndpointNotFoundError, ApiConflictError, ApiUnprocessableError, ApiPreconditionFailedError, ApiUnsupportedMediaTypeError, ApiNotImplementedError, ApiCodedError, ApiErrorCodec, ApiErrorPayload, ApiErrorBoundary, } from './errors';
85
85
  export type { ApiErrorKind, ApiStatusCode, EdgeHttpStatus } from './errors';
86
86
  export { ApiErrorHttpStatus } from './http/ApiErrorHttpStatus';
87
+ export type { PublishedKind, PublishedApiError } from './http/ApiErrorHttpStatus';