@webpieces/core-util 0.4.956 → 0.4.958

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (94) hide show
  1. package/package.json +1 -1
  2. package/src/http/ApiCallInfo.d.ts +71 -11
  3. package/src/http/ApiCallInfo.js +81 -7
  4. package/src/http/ApiCallInfo.js.map +1 -1
  5. package/src/http/ApiMethodInfo.d.ts +14 -0
  6. package/src/http/ApiMethodInfo.js +3 -0
  7. package/src/http/ApiMethodInfo.js.map +1 -1
  8. package/src/http/CallRegistry.d.ts +5 -10
  9. package/src/http/CallRegistry.js +21 -43
  10. package/src/http/CallRegistry.js.map +1 -1
  11. package/src/http/CallRegistryCompileAssertions.js +1 -1
  12. package/src/http/CallRegistryCompileAssertions.js.map +1 -1
  13. package/src/http/CallScope.d.ts +33 -0
  14. package/src/http/CallScope.js +70 -0
  15. package/src/http/CallScope.js.map +1 -0
  16. package/src/http/CallStrategy.d.ts +13 -1
  17. package/src/http/CallStrategy.js +4 -1
  18. package/src/http/CallStrategy.js.map +1 -1
  19. package/src/http/ClientRegistry.d.ts +54 -58
  20. package/src/http/ClientRegistry.js +72 -73
  21. package/src/http/ClientRegistry.js.map +1 -1
  22. package/src/http/ContextMgr.js +12 -2
  23. package/src/http/ContextMgr.js.map +1 -1
  24. package/src/http/DeletedAuthCompileAssertions.js +3 -1
  25. package/src/http/DeletedAuthCompileAssertions.js.map +1 -1
  26. package/src/http/DestinationTrust.d.ts +33 -4
  27. package/src/http/DestinationTrust.js +54 -11
  28. package/src/http/DestinationTrust.js.map +1 -1
  29. package/src/http/EndpointCompileAssertions.js +12 -6
  30. package/src/http/EndpointCompileAssertions.js.map +1 -1
  31. package/src/http/ErrorClassifier.d.ts +20 -27
  32. package/src/http/ErrorClassifier.js +1 -15
  33. package/src/http/ErrorClassifier.js.map +1 -1
  34. package/src/http/ErrorLadderCompileAssertions.d.ts +1 -0
  35. package/src/http/ErrorLadderCompileAssertions.js +42 -0
  36. package/src/http/ErrorLadderCompileAssertions.js.map +1 -0
  37. package/src/http/ErrorTranslator.d.ts +8 -4
  38. package/src/http/ErrorTranslator.js.map +1 -1
  39. package/src/http/ExternalContextKeys.d.ts +31 -0
  40. package/src/http/ExternalContextKeys.js +77 -0
  41. package/src/http/ExternalContextKeys.js.map +1 -0
  42. package/src/http/HttpContract.d.ts +45 -7
  43. package/src/http/HttpContract.js +84 -6
  44. package/src/http/HttpContract.js.map +1 -1
  45. package/src/http/HttpEndpointOptions.d.ts +22 -3
  46. package/src/http/HttpEndpointOptions.js +8 -1
  47. package/src/http/HttpEndpointOptions.js.map +1 -1
  48. package/src/http/LogApiCall.d.ts +4 -1
  49. package/src/http/LogApiCall.js +9 -7
  50. package/src/http/LogApiCall.js.map +1 -1
  51. package/src/http/QueryParams.d.ts +47 -0
  52. package/src/http/QueryParams.js +89 -0
  53. package/src/http/QueryParams.js.map +1 -0
  54. package/src/http/RetryStrategy.js +1 -1
  55. package/src/http/RetryStrategy.js.map +1 -1
  56. package/src/http/RouteMetadata.d.ts +16 -2
  57. package/src/http/RouteMetadata.js +19 -3
  58. package/src/http/RouteMetadata.js.map +1 -1
  59. package/src/http/RouteMetadataFactory.d.ts +25 -0
  60. package/src/http/RouteMetadataFactory.js +97 -12
  61. package/src/http/RouteMetadataFactory.js.map +1 -1
  62. package/src/http/StreamErrorControl.d.ts +9 -1
  63. package/src/http/StreamErrorControl.js +14 -3
  64. package/src/http/StreamErrorControl.js.map +1 -1
  65. package/src/http/WebpiecesDefaultErrorClassifier.d.ts +2 -2
  66. package/src/http/WebpiecesDefaultErrorClassifier.js +2 -2
  67. package/src/http/WebpiecesDefaultErrorClassifier.js.map +1 -1
  68. package/src/http/WebpiecesDefaultErrorTranslator.d.ts +2 -1
  69. package/src/http/WebpiecesDefaultErrorTranslator.js +2 -1
  70. package/src/http/WebpiecesDefaultErrorTranslator.js.map +1 -1
  71. package/src/http/WpAuthApiKeyCompileAssertions.js +2 -2
  72. package/src/http/WpAuthApiKeyCompileAssertions.js.map +1 -1
  73. package/src/http/api-kind.d.ts +3 -2
  74. package/src/http/api-kind.js +5 -4
  75. package/src/http/api-kind.js.map +1 -1
  76. package/src/http/auth-mode.d.ts +8 -2
  77. package/src/http/auth-mode.js +11 -5
  78. package/src/http/auth-mode.js.map +1 -1
  79. package/src/http/decorators.d.ts +39 -21
  80. package/src/http/decorators.js +69 -37
  81. package/src/http/decorators.js.map +1 -1
  82. package/src/http/external-caller.d.ts +12 -17
  83. package/src/http/external-caller.js +12 -17
  84. package/src/http/external-caller.js.map +1 -1
  85. package/src/http/http-parameter-decorators.d.ts +17 -3
  86. package/src/http/http-parameter-decorators.js +27 -6
  87. package/src/http/http-parameter-decorators.js.map +1 -1
  88. package/src/index.d.ts +14 -9
  89. package/src/index.js +32 -13
  90. package/src/index.js.map +1 -1
  91. package/src/ipc/IpcConnection.js +1 -1
  92. package/src/ipc/IpcConnection.js.map +1 -1
  93. package/src/ipc/IpcLogging.js +1 -1
  94. package/src/ipc/IpcLogging.js.map +1 -1
@@ -1,6 +1,6 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
- exports.WRITE = exports.WRITE_IDEMPOTENT = exports.READ = void 0;
3
+ exports.NONE = exports.WRITE = exports.WRITE_IDEMPOTENT = exports.READ = void 0;
4
4
  /** Nominal backing keeps raw string literals out of endpoint declarations. */
5
5
  var EndpointOperationValue;
6
6
  (function (EndpointOperationValue) {
@@ -12,4 +12,11 @@ var EndpointOperationValue;
12
12
  exports.READ = EndpointOperationValue.READ;
13
13
  exports.WRITE_IDEMPOTENT = EndpointOperationValue.WRITE_IDEMPOTENT;
14
14
  exports.WRITE = EndpointOperationValue.WRITE;
15
+ /** Nominal backing for {@link EndpointOptions.responseBody}. */
16
+ var ResponseBodyValue;
17
+ (function (ResponseBodyValue) {
18
+ ResponseBodyValue["NONE"] = "none";
19
+ })(ResponseBodyValue || (ResponseBodyValue = {}));
20
+ /** `@Endpoint(..., { responseBody: NONE })` — the success response carries 0 bytes. */
21
+ exports.NONE = ResponseBodyValue.NONE;
15
22
  //# sourceMappingURL=HttpEndpointOptions.js.map
@@ -1 +1 @@
1
- {"version":3,"file":"HttpEndpointOptions.js","sourceRoot":"","sources":["../../../../../../packages/core/core-util/src/http/HttpEndpointOptions.ts"],"names":[],"mappings":";;;AAGA,8EAA8E;AAC9E,IAAK,sBAIJ;AAJD,WAAK,sBAAsB;IACvB,uCAAa,CAAA;IACb,+DAAqC,CAAA;IACrC,yCAAe,CAAA;AACnB,CAAC,EAJI,sBAAsB,KAAtB,sBAAsB,QAI1B;AAED,wDAAwD;AAC3C,QAAA,IAAI,GAAG,sBAAsB,CAAC,IAAI,CAAC;AACnC,QAAA,gBAAgB,GAAG,sBAAsB,CAAC,gBAAgB,CAAC;AAC3D,QAAA,KAAK,GAAG,sBAAsB,CAAC,KAAK,CAAC","sourcesContent":["import { ExternalSystemKind } from './external-caller';\nimport { EndpointResponseType } from './HttpContract';\n\n/** Nominal backing keeps raw string literals out of endpoint declarations. */\nenum EndpointOperationValue {\n READ = 'read',\n WRITE_IDEMPOTENT = 'write-idempotent',\n WRITE = 'write',\n}\n\n/** Short, statically importable decorator arguments. */\nexport const READ = EndpointOperationValue.READ;\nexport const WRITE_IDEMPOTENT = EndpointOperationValue.WRITE_IDEMPOTENT;\nexport const WRITE = EndpointOperationValue.WRITE;\n\n/** The side-effect contract callers use to decide whether repeating an operation is safe. */\nexport type EndpointOperation = typeof READ | typeof WRITE_IDEMPOTENT | typeof WRITE;\n\n/** Options for one `@Endpoint`, stored parallel to the method-to-path metadata. */\nexport interface EndpointOptions {\n /** Return only a JSON body (default), or the full transport-neutral status/header/body value. */\n responseType?: EndpointResponseType;\n /**\n * Parse the request body as application/x-www-form-urlencoded instead of JSON. The request\n * DTO must be flat; urlencoded has no nesting. Default false = JSON.\n */\n formPost?: boolean;\n /**\n * Retain the verbatim request bytes and absolute URL for a `webhook(...)` callback to verify.\n * This is retention, not new buffering: the Express adapter already accumulates the body.\n *\n * `webhook(...)` requires this option at wiring time. It combines with `formPost` for vendors\n * that sign flat form bodies, e.g. `{ formPost: true, rawBody: true }`.\n */\n rawBody?: boolean;\n /**\n * The app should show NO progress UI for this call — it is not something the USER is waiting on\n * (a background download, a log shipper, a heartbeat, a telemetry flush).\n *\n * webpieces draws no progress bar itself; it DECLARES the fact and carries it on\n * `RouteMetadata.hideProgress`, which an app's `RequestLifecycleListener` reads to keep the call\n * out of its bar. WHY a declaration on the endpoint: the alternative is the app matching path\n * strings in its listener, which silently drifts the first time a route is renamed. The ROUTE is\n * the stable fact, so the decision belongs to the route's author.\n *\n * Independent of {@link noLogging} and {@link allowUpgradeInFlight}: a background download hides\n * its progress yet still wants its `[API-*]` lines (exactly what you need when a sync is slow)\n * and still must finish before an app upgrades under it.\n *\n * Default false: absent means the call drives the app's progress UI, like any ordinary route.\n * `grep -rn hideProgress` lists every endpoint the user is not shown waiting on.\n */\n hideProgress?: boolean;\n /**\n * webpieces writes NO `[API-*-req]` / `[API-*-resp-*]` line for this call, on either side —\n * {@link LogApiCallImpl} reads it via `ApiMethodInfo.noLogging`, handed in by both the client\n * proxy and the server's `LogApiFilter` — and stamps no per-call `api` tag.\n *\n * WHY a declaration and not a suppression switch: shipping a log over a logged transport logs\n * about shipping logs — the line describing the call IS the thing being shipped, and emitting it\n * feeds the next batch its own payload. The two mechanisms that do not know the endpoint both\n * failed in prod (see #976) — a wall-clock \"we are shipping\" boolean swallowed unrelated lines\n * including an error alarm, and a logger-name allowlist was incomplete by construction the moment\n * anyone added a logger, producing a steady-state 4-lines-per-second request loop on an idle page.\n * The ENDPOINT is the only stable, declarative fact, so it is where the decision belongs.\n *\n * Rides the route metadata for the same reason {@link formPost} and {@link rawBody} do: the\n * consumer branches on the ROUTE, without knowing the apiClass/methodName.\n *\n * Default false: absent means the ordinary, fully-logged route. The quiet path is the one you\n * have to say out loud, so `grep -rn noLogging` lists every endpoint webpieces stops logging.\n */\n noLogging?: boolean;\n /**\n * An app MAY upgrade (reload onto a newer deployed bundle) while this call is in flight, and the\n * call neither blocks nor arms that upgrade.\n *\n * By default every RPC participates in the app's upgrade decision: the app must not upgrade while\n * it is in flight, and it counts toward the \"finished with a SUCCESS result\" boundary at which an\n * app typically arms its seamless upgrade. That default is the safe one — reloading under an\n * in-flight write or download loses it. A call nobody asked for, and that is harmless to cut off\n * (a log shipper, a heartbeat), declares `true` so it can neither hold an upgrade hostage nor be\n * the success that triggers one.\n *\n * WHY separate from {@link hideProgress}: apps commonly arm an upgrade off their progress bar's\n * request group, so hiding a call from the bar used to drop it out of the upgrade decision as a\n * side effect. A hidden background download must still block an upgrade until it succeeds; only\n * this flag, said out loud, lets a call stop participating.\n *\n * webpieces only declares and carries it (`RouteMetadata.allowUpgradeInFlight`); the app's\n * `RequestLifecycleListener` / upgrade logic decides what to do with it. Default false.\n * `grep -rn allowUpgradeInFlight` lists every endpoint an upgrade may cut off.\n *\n * THE SERVER'S HALF of the upgrade story is `ApiClientTooOldError` (HTTP 426,\n * `errorCode: 'client-too-old'`). This flag is the CLIENT deciding WHEN it may upgrade; that\n * error is a SERVER saying the client MUST. Who throws it: a server gate that compares the build\n * the caller declared (a header, a version in the request) against the minimum it still\n * supports. Receivers decode it back into `ApiClientTooOldError` on every hop, server-to-server\n * and browser, HTTP and IPC, and treat it as an upgrade, never as a bug: the app's upgrade logic\n * reloads or installs the new build, typically honouring this flag for the calls still in flight.\n */\n allowUpgradeInFlight?: boolean;\n /**\n * This route is NOT part of the CUSTOMER-facing contract. `public-openapi.json` — the document a\n * customer gets, and the only one a publish path uploads — contains no path, no operation, no\n * schema and no prose for it. It is not \"rendered with a flag set\", it is absent.\n *\n * It is still in `full-private-openapi.json`, and still in `mcp-openapi.json` when it carries\n * `@WpMcpTool`. The reason it is withheld belongs in the method's JSDoc, which is where every\n * other piece of its documentation already lives.\n *\n * WHY A BOOLEAN HERE IS NOT SHIM SHAPE #5, given that `@ApiType` exists precisely to avoid a\n * falsy default meaning \"publish\": the two operate at different levels and the default here is\n * NOT the permissive one. A contract reaches customers only by declaring\n * `@ApiType(..., EXTERNAL_CUSTOMER)` — one deliberate token per contract — and `hidden` only ever\n * SUBTRACTS from what that token already granted. Omitting it on a contract nobody published to\n * customers publishes nothing. The per-method case it exists for is real and cannot be said at\n * class level: a NEW endpoint on an already-published API, built on main before it is announced.\n *\n * Default false, and `grep -rn \"hidden: true\"` lists every method withheld from customers.\n *\n * HIDING IS A DOCUMENTATION DECISION AND NEVER AN ACCESS-CONTROL ONE. The route is served exactly\n * as before and is still reachable by anything holding its credential — including an agent, if it\n * also carries `@WpMcpTool`. Declare its authorization as loudly as a published one.\n */\n hidden?: boolean;\n /**\n * This operation may touch systems OUTSIDE this service — a payment processor, a partner's API,\n * the physical world. It feeds MCP's `openWorldHint`, and it is rendered as a sentence in the\n * operation's description so a human integrating against the document sees it too.\n *\n * DEFAULT FALSE, deliberately unlike the MCP specification's own `true` default. MCP defaults to\n * \"open world\" because it cannot know; webpieces CAN know, because the author of the endpoint is\n * right here. Defaulting to `true` would make the interesting case the silent one and the boring\n * case the one you have to type, which is backwards — and an agent told that every endpoint might\n * reach the outside world has been told nothing. `grep -rn \"openWorld: true\"` lists the ones that\n * genuinely do.\n */\n openWorld?: boolean;\n}\n\n/** An external endpoint must also identify the outside system that calls it. */\nexport interface ExternalEndpointOptions extends EndpointOptions {\n /** The outside system (`'twilio'`) — graph identity, not display text. */\n calledBy: string;\n /** What that caller is; picks its graph shape. Defaults to `'saas'`. */\n callerKind?: ExternalSystemKind;\n}\n"]}
1
+ {"version":3,"file":"HttpEndpointOptions.js","sourceRoot":"","sources":["../../../../../../packages/core/core-util/src/http/HttpEndpointOptions.ts"],"names":[],"mappings":";;;AAGA,8EAA8E;AAC9E,IAAK,sBAIJ;AAJD,WAAK,sBAAsB;IACvB,uCAAa,CAAA;IACb,+DAAqC,CAAA;IACrC,yCAAe,CAAA;AACnB,CAAC,EAJI,sBAAsB,KAAtB,sBAAsB,QAI1B;AAED,wDAAwD;AAC3C,QAAA,IAAI,GAAG,sBAAsB,CAAC,IAAI,CAAC;AACnC,QAAA,gBAAgB,GAAG,sBAAsB,CAAC,gBAAgB,CAAC;AAC3D,QAAA,KAAK,GAAG,sBAAsB,CAAC,KAAK,CAAC;AAKlD,gEAAgE;AAChE,IAAK,iBAEJ;AAFD,WAAK,iBAAiB;IAClB,kCAAa,CAAA;AACjB,CAAC,EAFI,iBAAiB,KAAjB,iBAAiB,QAErB;AAED,uFAAuF;AAC1E,QAAA,IAAI,GAAG,iBAAiB,CAAC,IAAI,CAAC","sourcesContent":["import { ExternalSystemKind } from './external-caller';\nimport { EndpointResponseType } from './HttpContract';\n\n/** Nominal backing keeps raw string literals out of endpoint declarations. */\nenum EndpointOperationValue {\n READ = 'read',\n WRITE_IDEMPOTENT = 'write-idempotent',\n WRITE = 'write',\n}\n\n/** Short, statically importable decorator arguments. */\nexport const READ = EndpointOperationValue.READ;\nexport const WRITE_IDEMPOTENT = EndpointOperationValue.WRITE_IDEMPOTENT;\nexport const WRITE = EndpointOperationValue.WRITE;\n\n/** The side-effect contract callers use to decide whether repeating an operation is safe. */\nexport type EndpointOperation = typeof READ | typeof WRITE_IDEMPOTENT | typeof WRITE;\n\n/** Nominal backing for {@link EndpointOptions.responseBody}. */\nenum ResponseBodyValue {\n NONE = 'none',\n}\n\n/** `@Endpoint(..., { responseBody: NONE })` — the success response carries 0 bytes. */\nexport const NONE = ResponseBodyValue.NONE;\n\n/** The one response-body declaration an endpoint can make; absent means a JSON body. */\nexport type ResponseBodyKind = typeof NONE;\n\n/** Options for one `@Endpoint`, stored parallel to the method-to-path metadata. */\nexport interface EndpointOptions {\n /** Return only a JSON body (default), or the full transport-neutral status/header/body value. */\n responseType?: EndpointResponseType;\n /**\n * `NONE`: this method returns `Promise<void>` and its success response is 0 BYTES. Our server\n * answers `204 No Content` with no body and no Content-Type, and every client (node, browser,\n * in-process) resolves `undefined` — while still ACCEPTING `{}`, an empty `200` or a `204`,\n * because a vendor whose spec documents no body may still send one.\n *\n * WHY a declaration: `design:returntype` of an async method is just `Promise`, so the runtime\n * cannot see `void`. The architecture scan checks this option agrees with the declared\n * `Promise<void>` in both directions, so it cannot drift from the signature.\n */\n responseBody?: ResponseBodyKind;\n /**\n * Parse the request body as application/x-www-form-urlencoded instead of JSON. The request\n * DTO must be flat; urlencoded has no nesting. Default false = JSON.\n */\n formPost?: boolean;\n /**\n * Retain the verbatim request bytes and absolute URL for a `vendorAuth(...)` hook to verify.\n * This is retention, not new buffering: the Express adapter already accumulates the body.\n *\n * A served `vendorAuth(...)` endpoint requires this option at wiring time. It combines with `formPost` for vendors\n * that sign flat form bodies, e.g. `{ formPost: true, rawBody: true }`.\n */\n rawBody?: boolean;\n /**\n * The app should show NO progress UI for this call — it is not something the USER is waiting on\n * (a background download, a log shipper, a heartbeat, a telemetry flush).\n *\n * webpieces draws no progress bar itself; it DECLARES the fact and carries it on\n * `RouteMetadata.hideProgress`, which an app's `RequestLifecycleListener` reads to keep the call\n * out of its bar. WHY a declaration on the endpoint: the alternative is the app matching path\n * strings in its listener, which silently drifts the first time a route is renamed. The ROUTE is\n * the stable fact, so the decision belongs to the route's author.\n *\n * Independent of {@link noLogging} and {@link allowUpgradeInFlight}: a background download hides\n * its progress yet still wants its `[API-*]` lines (exactly what you need when a sync is slow)\n * and still must finish before an app upgrades under it.\n *\n * Default false: absent means the call drives the app's progress UI, like any ordinary route.\n * `grep -rn hideProgress` lists every endpoint the user is not shown waiting on.\n */\n hideProgress?: boolean;\n /**\n * webpieces writes NO `[API-*-req]` / `[API-*-resp-*]` line for this call, on either side —\n * {@link LogApiCallImpl} reads it via `ApiMethodInfo.noLogging`, handed in by both the client\n * proxy and the server's `LogApiFilter` — and stamps no per-call `api` tag.\n *\n * WHY a declaration and not a suppression switch: shipping a log over a logged transport logs\n * about shipping logs — the line describing the call IS the thing being shipped, and emitting it\n * feeds the next batch its own payload. The two mechanisms that do not know the endpoint both\n * failed in prod (see #976) — a wall-clock \"we are shipping\" boolean swallowed unrelated lines\n * including an error alarm, and a logger-name allowlist was incomplete by construction the moment\n * anyone added a logger, producing a steady-state 4-lines-per-second request loop on an idle page.\n * The ENDPOINT is the only stable, declarative fact, so it is where the decision belongs.\n *\n * Rides the route metadata for the same reason {@link formPost} and {@link rawBody} do: the\n * consumer branches on the ROUTE, without knowing the apiClass/methodName.\n *\n * Default false: absent means the ordinary, fully-logged route. The quiet path is the one you\n * have to say out loud, so `grep -rn noLogging` lists every endpoint webpieces stops logging.\n */\n noLogging?: boolean;\n /**\n * An app MAY upgrade (reload onto a newer deployed bundle) while this call is in flight, and the\n * call neither blocks nor arms that upgrade.\n *\n * By default every RPC participates in the app's upgrade decision: the app must not upgrade while\n * it is in flight, and it counts toward the \"finished with a SUCCESS result\" boundary at which an\n * app typically arms its seamless upgrade. That default is the safe one — reloading under an\n * in-flight write or download loses it. A call nobody asked for, and that is harmless to cut off\n * (a log shipper, a heartbeat), declares `true` so it can neither hold an upgrade hostage nor be\n * the success that triggers one.\n *\n * WHY separate from {@link hideProgress}: apps commonly arm an upgrade off their progress bar's\n * request group, so hiding a call from the bar used to drop it out of the upgrade decision as a\n * side effect. A hidden background download must still block an upgrade until it succeeds; only\n * this flag, said out loud, lets a call stop participating.\n *\n * webpieces only declares and carries it (`RouteMetadata.allowUpgradeInFlight`); the app's\n * `RequestLifecycleListener` / upgrade logic decides what to do with it. Default false.\n * `grep -rn allowUpgradeInFlight` lists every endpoint an upgrade may cut off.\n *\n * THE SERVER'S HALF of the upgrade story is `ApiClientTooOldError` (HTTP 426,\n * `errorCode: 'client-too-old'`). This flag is the CLIENT deciding WHEN it may upgrade; that\n * error is a SERVER saying the client MUST. Who throws it: a server gate that compares the build\n * the caller declared (a header, a version in the request) against the minimum it still\n * supports. Receivers decode it back into `ApiClientTooOldError` on every hop, server-to-server\n * and browser, HTTP and IPC, and treat it as an upgrade, never as a bug: the app's upgrade logic\n * reloads or installs the new build, typically honouring this flag for the calls still in flight.\n */\n allowUpgradeInFlight?: boolean;\n /**\n * This route is NOT part of the CUSTOMER-facing contract. `public-openapi.json` — the document a\n * customer gets, and the only one a publish path uploads — contains no path, no operation, no\n * schema and no prose for it. It is not \"rendered with a flag set\", it is absent.\n *\n * It is still in `full-private-openapi.json`, and still in `mcp-openapi.json` when it carries\n * `@WpMcpTool`. The reason it is withheld belongs in the method's JSDoc, which is where every\n * other piece of its documentation already lives.\n *\n * WHY A BOOLEAN HERE IS NOT SHIM SHAPE #5, given that `@ApiType` exists precisely to avoid a\n * falsy default meaning \"publish\": the two operate at different levels and the default here is\n * NOT the permissive one. A contract reaches customers only by declaring\n * `@ApiType(..., EXTERNAL_CUSTOMER)` — one deliberate token per contract — and `hidden` only ever\n * SUBTRACTS from what that token already granted. Omitting it on a contract nobody published to\n * customers publishes nothing. The per-method case it exists for is real and cannot be said at\n * class level: a NEW endpoint on an already-published API, built on main before it is announced.\n *\n * Default false, and `grep -rn \"hidden: true\"` lists every method withheld from customers.\n *\n * HIDING IS A DOCUMENTATION DECISION AND NEVER AN ACCESS-CONTROL ONE. The route is served exactly\n * as before and is still reachable by anything holding its credential — including an agent, if it\n * also carries `@WpMcpTool`. Declare its authorization as loudly as a published one.\n */\n hidden?: boolean;\n /**\n * This operation may touch systems OUTSIDE this service — a payment processor, a partner's API,\n * the physical world. It feeds MCP's `openWorldHint`, and it is rendered as a sentence in the\n * operation's description so a human integrating against the document sees it too.\n *\n * DEFAULT FALSE, deliberately unlike the MCP specification's own `true` default. MCP defaults to\n * \"open world\" because it cannot know; webpieces CAN know, because the author of the endpoint is\n * right here. Defaulting to `true` would make the interesting case the silent one and the boring\n * case the one you have to type, which is backwards — and an agent told that every endpoint might\n * reach the outside world has been told nothing. `grep -rn \"openWorld: true\"` lists the ones that\n * genuinely do.\n */\n openWorld?: boolean;\n}\n\n/** An EXTERNAL_INBOUND endpoint must also identify the outside system that calls it. */\nexport interface ExternalEndpointOptions extends EndpointOptions {\n /** The outside system (`'twilio'`) — graph identity, not display text. */\n calledBy: string;\n /** What that caller is; picks its graph shape. Defaults to `'saas'`. */\n callerKind?: ExternalSystemKind;\n}\n"]}
@@ -1,3 +1,4 @@
1
+ import { HttpCallFactsHolder } from './ApiCallInfo';
1
2
  import { ApiMethodInfo } from './ApiMethodInfo';
2
3
  import { ApiCallContext } from './ApiCallContext';
3
4
  /**
@@ -130,7 +131,9 @@ export declare class LogApiCallImpl {
130
131
  * it. Cost: only the `[API-*]` req/resp lines carry `api`, not lines emitted mid-call — which is
131
132
  * exactly what the GCP filters (`jsonPayload.api.*`) want.
132
133
  */
133
- execute<Q, R>(methodInfo: ApiMethodInfo, requestDto: Q, method: (dto: Q) => Promise<R>, responseCount?: (response: R) => number | undefined): Promise<R>;
134
+ execute<Q, R>(methodInfo: ApiMethodInfo, requestDto: Q, method: (dto: Q) => Promise<R>, responseCount?: (response: R) => number | undefined,
135
+ /** The HTTP client's per-call slot — see {@link HttpCallFactsHolder}. Absent everywhere else. */
136
+ http?: HttpCallFactsHolder): Promise<R>;
134
137
  /**
135
138
  * Build the tag-and-emit span used around every `[API-*]` line.
136
139
  *
@@ -189,7 +189,9 @@ class LogApiCallImpl {
189
189
  * it. Cost: only the `[API-*]` req/resp lines carry `api`, not lines emitted mid-call — which is
190
190
  * exactly what the GCP filters (`jsonPayload.api.*`) want.
191
191
  */
192
- async execute(methodInfo, requestDto, method, responseCount) {
192
+ async execute(methodInfo, requestDto, method, responseCount,
193
+ /** The HTTP client's per-call slot — see {@link HttpCallFactsHolder}. Absent everywhere else. */
194
+ http) {
193
195
  if (methodInfo.noLogging) {
194
196
  return this.executeUnlogged(methodInfo, requestDto, method);
195
197
  }
@@ -218,14 +220,14 @@ class LogApiCallImpl {
218
220
  const durationMs = Date.now() - startMs;
219
221
  const responseBody = this.serialize(response, methodInfo);
220
222
  const count = this.selectResponseCount(response, responseCount, side, id);
221
- stamp(new ApiCallInfo_1.ApiCallInfo(methodInfo, 'response', 'success', durationMs, requestSize, this.byteSize(responseBody), count), () => log.info(`[API-${side}-resp-SUCCESS] ${id} response=${LogApiCall.truncateForLog(responseBody, maxBodyChars)}`));
223
+ stamp(new ApiCallInfo_1.ApiCallInfo(methodInfo, 'response', 'success', durationMs, requestSize, this.byteSize(responseBody), count, http?.facts), () => log.info(`[API-${side}-resp-SUCCESS] ${id} response=${LogApiCall.truncateForLog(responseBody, maxBodyChars)}`));
222
224
  return response;
223
225
  }
224
226
  catch (err) {
225
227
  const error = (0, errorUtils_1.toError)(err);
226
228
  // Duration comes off the SAME start as the success path, so a slow failure (a timeout, a
227
229
  // hung dependency) reports its real cost rather than nothing.
228
- this.logFailure(error, methodInfo, Date.now() - startMs, requestSize, stamp);
230
+ this.logFailure(error, methodInfo, Date.now() - startMs, requestSize, stamp, http);
229
231
  throw err;
230
232
  }
231
233
  }
@@ -337,7 +339,7 @@ class LogApiCallImpl {
337
339
  /**
338
340
  * Tag + log a thrown call. There is no responseSize — a throw produced no response body to measure.
339
341
  */
340
- logFailure(error, methodInfo, durationMs, requestSize, stamp) {
342
+ logFailure(error, methodInfo, durationMs, requestSize, stamp, http) {
341
343
  const side = methodInfo.side;
342
344
  const id = `${methodInfo.apiClass}.${methodInfo.methodName}`;
343
345
  const errorType = error.constructor.name;
@@ -345,14 +347,14 @@ class LogApiCallImpl {
345
347
  // per failure now that `ApiErrorBoundary` writes none. An operator error message is never
346
348
  // published to a caller (see `ApiErrorCodec.publicMessage`), so the log is the one place it
347
349
  // exists at all — dropping it on the caller-error branch would lose it outright.
348
- // Pluggable classification (ClientRegistry): a per-apiClass EXTERNAL-client classifier wins,
349
- // else the app default, else the webpieces built-in — which is side-dependent (a 4xx the SERVER
350
+ // Pluggable classification (ClientRegistry): the CallScope ladder of the call's contract class
351
+ // (method, then API, then ALL), else the webpieces built-in — which is side-dependent (a 4xx the SERVER
350
352
  // raised is a handled non-failure; the same 4xx a CLIENT receives means its call FAILED; 266 is
351
353
  // never a failure either side). This line is FINAL BY POSITION: it only sees what escaped every
352
354
  // retry, so a TRANSIENT_FAILURE here was never retried (no strategy, or a WRITE) or gave up —
353
355
  // it logs FAIL exactly like a FAILURE. Only EXPECTED is "OTHER / result:'success'".
354
356
  const isUser = ClientRegistry_1.ClientRegistry.classify(error, methodInfo) === ErrorClassifier_1.CallOutcome.EXPECTED;
355
- stamp(new ApiCallInfo_1.ApiCallInfo(methodInfo, 'response', isUser ? 'success' : 'failure', durationMs, requestSize), () => isUser
357
+ stamp(new ApiCallInfo_1.ApiCallInfo(methodInfo, 'response', isUser ? 'success' : 'failure', durationMs, requestSize, undefined, undefined, http?.facts), () => isUser
356
358
  ? log.warn(`[API-${side}-resp-OTHER] ${id} errorType=${errorType} error=${error.message}`)
357
359
  : log.error(`[API-${side}-resp-FAIL] ${id} errorType=${errorType} error=${error.message}`));
358
360
  }
@@ -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 { CallOutcome } from './ErrorClassifier';\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/** The slice of node's `process.versions` that runtime detection reads (core-util has no node types). */\nclass NodeVersionsProbe {\n node?: string;\n}\n\n/** The slice of node's `process` that runtime detection reads. */\nclass NodeProcessProbe {\n versions?: NodeVersionsProbe;\n}\n\n/** `globalThis`, as seen by runtime detection: a node `process` may or may not exist. */\nclass NodeGlobalProbe {\n process?: NodeProcessProbe;\n}\n\n/**\n * The startup configuration for {@link LogApiCall.configure}.\n *\n * A class rather than an object literal (CLAUDE.md: data-only structures are classes), so the one call\n * site reads `LogApiCall.configure(new LogApiCallConfig(2000))`.\n */\nexport class LogApiCallConfig {\n /**\n * @param maxLoggedBodyChars - the most characters of a serialized request/response body that one\n * `[API-*]` line may carry. `undefined` keeps the runtime default\n * ({@link LogApiCall.BROWSER_DEFAULT_MAX_BODY_CHARS} / {@link LogApiCall.NODE_DEFAULT_MAX_BODY_CHARS});\n * `0` or `Infinity` mean NO cap, for local debugging.\n */\n constructor(readonly maxLoggedBodyChars?: number) {}\n}\n\n/**\n * Process-wide settings for the `[API-*]` lines {@link LogApiCallImpl} writes — today, how much of a\n * body one line may carry (#1300).\n *\n * WHY a cap: the size of a body is already on the same line as a structured tag\n * (`api.requestSize` / `api.responseSize` / `api.responseCount`, computed on the FULL body), so the body\n * text adds no measurement, only bytes. Unbounded, it blew a consumer's 4,000-char browser message\n * trimmer (a Sentry report per line) and on a server it risks Cloud Logging's 256 KB per-entry limit,\n * where an oversize entry is LOST, not shortened.\n *\n * WHY the default depends on the RUNTIME, not on `methodInfo.side`: a node server is also a CLIENT when\n * it calls another service, and those lines go to Cloud Logging too. The runtime is detected ONCE, at\n * module load, by `process.versions.node` only — never `typeof window`, which React Native defines and\n * some SSR setups fake.\n *\n * A static, process-wide setting in the style of {@link ClientRegistry} / `HeaderRegistry`: set once at\n * startup (an app's `main.ts`, a server's bootstrap), never DI-injected, because the browser-side\n * {@link LogApiCallImpl} is constructed by a transport with no DI container.\n */\nexport class LogApiCall {\n /**\n * Browser and React Native default: well under a 4,000-char shipped-log message cap, leaving room\n * for the `[API-…] Class.method response=` prefix and the truncation marker.\n */\n static readonly BROWSER_DEFAULT_MAX_BODY_CHARS = 2000;\n\n /**\n * Node default: even at a worst-case 4 UTF-8 bytes per char this is ~200 KB, under Cloud Logging's\n * 256 KB per-entry limit with room for the entry's other fields.\n */\n static readonly NODE_DEFAULT_MAX_BODY_CHARS = 50000;\n\n /** The default for the runtime this module was loaded in, decided once. */\n static readonly RUNTIME_DEFAULT_MAX_BODY_CHARS = LogApiCall.defaultMaxBodyChars(\n LogApiCall.isNodeRuntime(),\n );\n\n private static configuredMaxBodyChars: number | undefined;\n\n /**\n * Override the runtime default, once at startup. A config whose `maxLoggedBodyChars` is\n * `undefined` restores the runtime default; `0` or `Infinity` turn the cap off.\n */\n // webpieces-disable no-function-outside-class -- static global singleton (like HeaderRegistry/ClientRegistry); set once at startup, never DI-injected\n static configure(config: LogApiCallConfig): void {\n const max = config.maxLoggedBodyChars;\n if (max !== undefined && (Number.isNaN(max) || max < 0)) {\n throw new Error(\n `LogApiCall.configure: maxLoggedBodyChars must be a non-negative number, 0 or Infinity (no cap), or undefined (runtime default); got ${max}`,\n );\n }\n LogApiCall.configuredMaxBodyChars = max;\n }\n\n /** The cap in force: the configured value, else the runtime default. 0 or Infinity mean no cap. */\n // webpieces-disable no-function-outside-class -- static global singleton (like HeaderRegistry/ClientRegistry); set once at startup, never DI-injected\n static maxLoggedBodyChars(): number {\n return LogApiCall.configuredMaxBodyChars ?? LogApiCall.RUNTIME_DEFAULT_MAX_BODY_CHARS;\n }\n\n /**\n * The default for a runtime: {@link NODE_DEFAULT_MAX_BODY_CHARS} on node, else\n * {@link BROWSER_DEFAULT_MAX_BODY_CHARS} (browser and React Native alike).\n */\n // webpieces-disable no-function-outside-class -- pure static helper of the static LogApiCall settings\n static defaultMaxBodyChars(isNode: boolean): number {\n return isNode\n ? LogApiCall.NODE_DEFAULT_MAX_BODY_CHARS\n : LogApiCall.BROWSER_DEFAULT_MAX_BODY_CHARS;\n }\n\n /**\n * True only when a node `process` with `versions.node` exists. Read through `globalThis` because\n * this browser-safe package compiles with no node types.\n */\n // webpieces-disable no-function-outside-class -- pure static helper of the static LogApiCall settings\n static isNodeRuntime(): boolean {\n const g = globalThis as NodeGlobalProbe;\n return typeof g.process !== 'undefined' && !!g.process.versions?.node;\n }\n\n /**\n * Cut a serialized body to its first `max` chars and append\n * `…[truncated: logged <kept> of <total> chars]`. Run on the ALREADY-MASKED string, so a cut can\n * never reveal a masked field. Never splits a surrogate pair: when the cut would land between the\n * two halves of one code point, that code point is dropped whole. A body within the cap, an\n * undefined body, and a cap of 0 / Infinity are returned unchanged.\n */\n // webpieces-disable no-function-outside-class -- pure static helper of the static LogApiCall settings\n static truncateForLog(body: string | undefined, max: number): string | undefined {\n if (body === undefined || max === 0 || !Number.isFinite(max) || body.length <= max) {\n return body;\n }\n let end = Math.floor(max);\n const last = body.charCodeAt(end - 1);\n if (last >= 0xd800 && last <= 0xdbff) {\n end -= 1; // a high surrogate whose low half would be cut off — drop the pair\n }\n return `${body.slice(0, end)}…[truncated: logged ${end} of ${body.length} chars]`;\n }\n}\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 * (request and response bodies are capped at {@link LogApiCall.maxLoggedBodyChars} chars, #1300)\n * - [API-{side}-resp-SUCCESS] ClassName.methodName response={...}\n * - [API-{side}-resp-OTHER] ClassName.methodName errorType={...} error={...} (CallOutcome.EXPECTED)\n * - [API-{side}-resp-FAIL] ClassName.methodName error={...} (TRANSIENT_FAILURE or FAILURE — final by position; each retried attempt is logged by CallRegistry as [API-client-attempt-TRANSIENT])\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 if (methodInfo.noLogging) {\n return this.executeUnlogged(methodInfo, requestDto, method);\n }\n const side = methodInfo.side;\n const id = `${methodInfo.apiClass}.${methodInfo.methodName}`;\n const stamp = this.stamper(this.activeContext());\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 // The size above is on the FULL body; only the log TEXT is capped (#1300), and only after\n // serialize() has masked it.\n const maxBodyChars = LogApiCall.maxLoggedBodyChars();\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(\n `[API-${side}-req] ${id} request=${LogApiCall.truncateForLog(requestBody, maxBodyChars)}`,\n ),\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 () =>\n log.info(\n `[API-${side}-resp-SUCCESS] ${id} response=${LogApiCall.truncateForLog(responseBody, maxBodyChars)}`,\n ),\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 * Build the tag-and-emit span used around every `[API-*]` line.\n *\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\n * call. `ctx` is passed in (rather than read from `this`) so the caller has already proven it is\n * ACTIVE before anything is stamped.\n */\n private stamper(ctx: ApiCallContext): (info: ApiCallInfo, emit: () => void) => void {\n const key = WebpiecesCoreHeaders.API_CALL_INFO;\n return (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\n /**\n * The `@Endpoint(..., { noLogging: true })` path: run the call and say NOTHING about it.\n *\n * No `[API-*]` line, no {@link ApiCallInfo} stamp, and no serialization of either DTO — a\n * noLogging route is a log shipper or a log-ingest endpoint, so a line describing the call IS the\n * thing being shipped, and emitting it feeds the next batch its own payload (#976).\n *\n * It deliberately does NOT call {@link activeContext}: that guard exists because a call with\n * nowhere to STAMP a tag is a bug, and this path stamps nothing. A noLogging call is therefore\n * legal outside a RequestContext scope, which is exactly where a flusher tends to run.\n *\n * The null-request check is kept, because it is a contract error and not observability — a\n * noLogging route deserves the same error as every other route, and the throw propagates to the caller\n * unlogged here, exactly as the logged path re-throws.\n *\n * `responseCount` is not taken: it feeds only the `api.responseCount` tag, which is not emitted.\n */\n private async executeUnlogged<Q, R>(\n methodInfo: ApiMethodInfo,\n requestDto: Q,\n method: (dto: Q) => Promise<R>,\n ): Promise<R> {\n if (!requestDto) {\n throw new Error(\n `Request cannot be null and was from ${methodInfo.apiClass}.${methodInfo.methodName}`,\n );\n }\n return method(requestDto);\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). This line is FINAL BY POSITION: it only sees what escaped every\n // retry, so a TRANSIENT_FAILURE here was never retried (no strategy, or a WRITE) or gave up —\n // it logs FAIL exactly like a FAILURE. Only EXPECTED is \"OTHER / result:'success'\".\n const isUser = ClientRegistry.classify(error, methodInfo) === CallOutcome.EXPECTED;\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"]}
1
+ {"version":3,"file":"LogApiCall.js","sourceRoot":"","sources":["../../../../../../packages/core/core-util/src/http/LogApiCall.ts"],"names":[],"mappings":";;;AAAA,kDAA4C;AAC5C,sDAAmD;AACnD,+CAAiE;AAGjE,iEAA8D;AAC9D,qDAA4D;AAC5D,qDAAkD;AAClD,uDAAgD;AAEhD,iGAAiG;AACjG,gGAAgG;AAChG,MAAM,GAAG,GAAG,uBAAU,CAAC,SAAS,CAAC,yCAAwB,CAAC,CAAC;AAE3D,yGAAyG;AACzG,MAAM,iBAAiB;IACnB,IAAI,CAAU;CACjB;AAED,kEAAkE;AAClE,MAAM,gBAAgB;IAClB,QAAQ,CAAqB;CAChC;AAED,yFAAyF;AACzF,MAAM,eAAe;IACjB,OAAO,CAAoB;CAC9B;AAED;;;;;GAKG;AACH,MAAa,gBAAgB;IAOJ;IANrB;;;;;OAKG;IACH,YAAqB,kBAA2B;QAA3B,uBAAkB,GAAlB,kBAAkB,CAAS;IAAG,CAAC;CACvD;AARD,4CAQC;AAED;;;;;;;;;;;;;;;;;;GAkBG;AACH,MAAa,UAAU;IACnB;;;OAGG;IACH,MAAM,CAAU,8BAA8B,GAAG,IAAI,CAAC;IAEtD;;;OAGG;IACH,MAAM,CAAU,2BAA2B,GAAG,KAAK,CAAC;IAEpD,2EAA2E;IAC3E,MAAM,CAAU,8BAA8B,GAAG,UAAU,CAAC,mBAAmB,CAC3E,UAAU,CAAC,aAAa,EAAE,CAC7B,CAAC;IAEM,MAAM,CAAC,sBAAsB,CAAqB;IAE1D;;;OAGG;IACH,sJAAsJ;IACtJ,MAAM,CAAC,SAAS,CAAC,MAAwB;QACrC,MAAM,GAAG,GAAG,MAAM,CAAC,kBAAkB,CAAC;QACtC,IAAI,GAAG,KAAK,SAAS,IAAI,CAAC,MAAM,CAAC,KAAK,CAAC,GAAG,CAAC,IAAI,GAAG,GAAG,CAAC,CAAC,EAAE,CAAC;YACtD,MAAM,IAAI,KAAK,CACX,uIAAuI,GAAG,EAAE,CAC/I,CAAC;QACN,CAAC;QACD,UAAU,CAAC,sBAAsB,GAAG,GAAG,CAAC;IAC5C,CAAC;IAED,mGAAmG;IACnG,sJAAsJ;IACtJ,MAAM,CAAC,kBAAkB;QACrB,OAAO,UAAU,CAAC,sBAAsB,IAAI,UAAU,CAAC,8BAA8B,CAAC;IAC1F,CAAC;IAED;;;OAGG;IACH,sGAAsG;IACtG,MAAM,CAAC,mBAAmB,CAAC,MAAe;QACtC,OAAO,MAAM;YACT,CAAC,CAAC,UAAU,CAAC,2BAA2B;YACxC,CAAC,CAAC,UAAU,CAAC,8BAA8B,CAAC;IACpD,CAAC;IAED;;;OAGG;IACH,sGAAsG;IACtG,MAAM,CAAC,aAAa;QAChB,MAAM,CAAC,GAAG,UAA6B,CAAC;QACxC,OAAO,OAAO,CAAC,CAAC,OAAO,KAAK,WAAW,IAAI,CAAC,CAAC,CAAC,CAAC,OAAO,CAAC,QAAQ,EAAE,IAAI,CAAC;IAC1E,CAAC;IAED;;;;;;OAMG;IACH,sGAAsG;IACtG,MAAM,CAAC,cAAc,CAAC,IAAwB,EAAE,GAAW;QACvD,IAAI,IAAI,KAAK,SAAS,IAAI,GAAG,KAAK,CAAC,IAAI,CAAC,MAAM,CAAC,QAAQ,CAAC,GAAG,CAAC,IAAI,IAAI,CAAC,MAAM,IAAI,GAAG,EAAE,CAAC;YACjF,OAAO,IAAI,CAAC;QAChB,CAAC;QACD,IAAI,GAAG,GAAG,IAAI,CAAC,KAAK,CAAC,GAAG,CAAC,CAAC;QAC1B,MAAM,IAAI,GAAG,IAAI,CAAC,UAAU,CAAC,GAAG,GAAG,CAAC,CAAC,CAAC;QACtC,IAAI,IAAI,IAAI,MAAM,IAAI,IAAI,IAAI,MAAM,EAAE,CAAC;YACnC,GAAG,IAAI,CAAC,CAAC,CAAC,mEAAmE;QACjF,CAAC;QACD,OAAO,GAAG,IAAI,CAAC,KAAK,CAAC,CAAC,EAAE,GAAG,CAAC,uBAAuB,GAAG,OAAO,IAAI,CAAC,MAAM,SAAS,CAAC;IACtF,CAAC;;AAhFL,gCAiFC;AAED;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GA+BG;AACH,MAAa,cAAc;IAMM;IAL7B;;;;OAIG;IACH,YAA6B,GAAmB;QAAnB,QAAG,GAAH,GAAG,CAAgB;IAAG,CAAC;IAEpD;;;;;;;;;;;;;;OAcG;IACI,KAAK,CAAC,OAAO,CAChB,UAAyB,EACzB,UAAa,EACb,MAA8B,EAC9B,aAAmD;IACnD,iGAAiG;IACjG,IAA0B;QAE1B,IAAI,UAAU,CAAC,SAAS,EAAE,CAAC;YACvB,OAAO,IAAI,CAAC,eAAe,CAAC,UAAU,EAAE,UAAU,EAAE,MAAM,CAAC,CAAC;QAChE,CAAC;QACD,MAAM,IAAI,GAAG,UAAU,CAAC,IAAI,CAAC;QAC7B,MAAM,EAAE,GAAG,GAAG,UAAU,CAAC,QAAQ,IAAI,UAAU,CAAC,UAAU,EAAE,CAAC;QAC7D,MAAM,KAAK,GAAG,IAAI,CAAC,OAAO,CAAC,IAAI,CAAC,aAAa,EAAE,CAAC,CAAC;QAEjD,6FAA6F;QAC7F,sFAAsF;QACtF,gGAAgG;QAChG,iEAAiE;QACjE,MAAM,WAAW,GAAG,IAAI,CAAC,SAAS,CAAC,UAAU,EAAE,UAAU,CAAC,CAAC;QAC3D,MAAM,WAAW,GAAG,IAAI,CAAC,QAAQ,CAAC,WAAW,CAAC,CAAC;QAC/C,0FAA0F;QAC1F,6BAA6B;QAC7B,MAAM,YAAY,GAAG,UAAU,CAAC,kBAAkB,EAAE,CAAC;QACrD,4FAA4F;QAC5F,kEAAkE;QAClE,IAAI,OAAO,GAAG,IAAI,CAAC,GAAG,EAAE,CAAC;QAEzB,qHAAqH;QACrH,IAAI,CAAC;YACD,KAAK,CAAC,IAAI,yBAAW,CAAC,UAAU,EAAE,SAAS,EAAE,SAAS,EAAE,SAAS,EAAE,WAAW,CAAC,EAAE,GAAG,EAAE,CAClF,GAAG,CAAC,IAAI,CACJ,QAAQ,IAAI,SAAS,EAAE,YAAY,UAAU,CAAC,cAAc,CAAC,WAAW,EAAE,YAAY,CAAC,EAAE,CAC5F,CACJ,CAAC;YAEF,IAAI,CAAC,UAAU;gBAAE,MAAM,IAAI,KAAK,CAAC,uCAAuC,EAAE,EAAE,CAAC,CAAC;YAE9E,OAAO,GAAG,IAAI,CAAC,GAAG,EAAE,CAAC;YACrB,MAAM,QAAQ,GAAG,MAAM,MAAM,CAAC,UAAU,CAAC,CAAC;YAC1C,MAAM,UAAU,GAAG,IAAI,CAAC,GAAG,EAAE,GAAG,OAAO,CAAC;YAExC,MAAM,YAAY,GAAG,IAAI,CAAC,SAAS,CAAC,QAAQ,EAAE,UAAU,CAAC,CAAC;YAC1D,MAAM,KAAK,GAAG,IAAI,CAAC,mBAAmB,CAAC,QAAQ,EAAE,aAAa,EAAE,IAAI,EAAE,EAAE,CAAC,CAAC;YAC1E,KAAK,CACD,IAAI,yBAAW,CACX,UAAU,EACV,UAAU,EACV,SAAS,EACT,UAAU,EACV,WAAW,EACX,IAAI,CAAC,QAAQ,CAAC,YAAY,CAAC,EAC3B,KAAK,EACL,IAAI,EAAE,KAAK,CACd,EACD,GAAG,EAAE,CACD,GAAG,CAAC,IAAI,CACJ,QAAQ,IAAI,kBAAkB,EAAE,aAAa,UAAU,CAAC,cAAc,CAAC,YAAY,EAAE,YAAY,CAAC,EAAE,CACvG,CACR,CAAC;YAEF,OAAO,QAAQ,CAAC;QACpB,CAAC;QAAC,OAAO,GAAY,EAAE,CAAC;YACpB,MAAM,KAAK,GAAG,IAAA,oBAAO,EAAC,GAAG,CAAC,CAAC;YAC3B,yFAAyF;YACzF,8DAA8D;YAC9D,IAAI,CAAC,UAAU,CAAC,KAAK,EAAE,UAAU,EAAE,IAAI,CAAC,GAAG,EAAE,GAAG,OAAO,EAAE,WAAW,EAAE,KAAK,EAAE,IAAI,CAAC,CAAC;YACnF,MAAM,GAAG,CAAC;QACd,CAAC;IACL,CAAC;IAED;;;;;;;OAOG;IACK,OAAO,CAAC,GAAmB;QAC/B,MAAM,GAAG,GAAG,2CAAoB,CAAC,aAAa,CAAC;QAC/C,OAAO,CAAC,IAAiB,EAAE,IAAgB,EAAQ,EAAE;YACjD,GAAG,CAAC,GAAG,CAAC,GAAG,EAAE,IAAI,CAAC,CAAC;YACnB,8FAA8F;YAC9F,IAAI,CAAC;gBACD,IAAI,EAAE,CAAC;YACX,CAAC;oBAAS,CAAC;gBACP,GAAG,CAAC,MAAM,CAAC,GAAG,CAAC,CAAC;YACpB,CAAC;QACL,CAAC,CAAC;IACN,CAAC;IAED;;;;;;;;;;;;;;;;OAgBG;IACK,KAAK,CAAC,eAAe,CACzB,UAAyB,EACzB,UAAa,EACb,MAA8B;QAE9B,IAAI,CAAC,UAAU,EAAE,CAAC;YACd,MAAM,IAAI,KAAK,CACX,uCAAuC,UAAU,CAAC,QAAQ,IAAI,UAAU,CAAC,UAAU,EAAE,CACxF,CAAC;QACN,CAAC;QACD,OAAO,MAAM,CAAC,UAAU,CAAC,CAAC;IAC9B,CAAC;IAED;;;OAGG;IACK,mBAAmB,CACvB,QAAW,EACX,QAA2D,EAC3D,IAAY,EACZ,EAAU;QAEV,IAAI,CAAC,QAAQ,EAAE,CAAC;YACZ,OAAO,SAAS,CAAC;QACrB,CAAC;QAED,sHAAsH;QACtH,IAAI,CAAC;YACD,MAAM,KAAK,GAAG,QAAQ,CAAC,QAAQ,CAAC,CAAC;YACjC,IACI,KAAK,KAAK,SAAS;gBACnB,CAAC,MAAM,CAAC,QAAQ,CAAC,KAAK,CAAC,IAAI,MAAM,CAAC,SAAS,CAAC,KAAK,CAAC,IAAI,KAAK,IAAI,CAAC,CAAC,EACnE,CAAC;gBACC,OAAO,KAAK,CAAC;YACjB,CAAC;YACD,IAAI,CAAC,wBAAwB,CAAC,IAAI,EAAE,EAAE,EAAE,2BAA2B,CAAC,CAAC;QACzE,CAAC;QAAC,OAAO,GAAY,EAAE,CAAC;YACpB,MAAM,KAAK,GAAG,IAAA,oBAAO,EAAC,GAAG,CAAC,CAAC;YAC3B,KAAK,KAAK,CAAC;YACX,IAAI,CAAC,wBAAwB,CAAC,IAAI,EAAE,EAAE,EAAE,OAAO,CAAC,CAAC;QACrD,CAAC;QACD,OAAO,SAAS,CAAC;IACrB,CAAC;IAEO,wBAAwB,CAAC,IAAY,EAAE,EAAU,EAAE,MAAc;QACrE,8HAA8H;QAC9H,IAAI,CAAC;YACD,GAAG,CAAC,IAAI,CACJ,QAAQ,IAAI,qBAAqB,EAAE,2BAA2B,MAAM,0BAA0B,CACjG,CAAC;QACN,CAAC;QAAC,OAAO,GAAY,EAAE,CAAC;YACpB,MAAM,KAAK,GAAG,IAAA,oBAAO,EAAC,GAAG,CAAC,CAAC;YAC3B,KAAK,KAAK,CAAC;YACX,4FAA4F;QAChG,CAAC;IACL,CAAC;IAED;;;;;OAKG;IACK,SAAS;IACb,uGAAuG;IACvG,GAAY,EACZ,UAAyB;QAEzB,OAAO,UAAU,CAAC,IAAI,CAAC,CAAC,CAAC,UAAU,CAAC,IAAI,CAAC,SAAS,CAAC,GAAG,CAAC,CAAC,CAAC,CAAC,IAAI,CAAC,SAAS,CAAC,GAAG,CAAC,CAAC;IAClF,CAAC;IAED;;;;OAIG;IACK,aAAa;QACjB,MAAM,GAAG,GAAG,IAAI,CAAC,GAAG,CAAC;QACrB,IAAI,CAAC,GAAG,CAAC,QAAQ,EAAE,EAAE,CAAC;YAClB,MAAM,IAAI,KAAK,CACX,+EAA+E;gBAC3E,+EAA+E;gBAC/E,kFAAkF;gBAClF,gFAAgF,CACvF,CAAC;QACN,CAAC;QACD,OAAO,GAAG,CAAC;IACf,CAAC;IAED;;OAEG;IACK,UAAU,CACd,KAAY,EACZ,UAAyB,EACzB,UAAkB,EAClB,WAA+B,EAC/B,KAAoD,EACpD,IAAqC;QAErC,MAAM,IAAI,GAAG,UAAU,CAAC,IAAI,CAAC;QAC7B,MAAM,EAAE,GAAG,GAAG,UAAU,CAAC,QAAQ,IAAI,UAAU,CAAC,UAAU,EAAE,CAAC;QAC7D,MAAM,SAAS,GAAG,KAAK,CAAC,WAAW,CAAC,IAAI,CAAC;QACzC,4FAA4F;QAC5F,0FAA0F;QAC1F,4FAA4F;QAC5F,iFAAiF;QACjF,+FAA+F;QAC/F,wGAAwG;QACxG,gGAAgG;QAChG,gGAAgG;QAChG,8FAA8F;QAC9F,oFAAoF;QACpF,MAAM,MAAM,GAAG,+BAAc,CAAC,QAAQ,CAAC,KAAK,EAAE,UAAU,CAAC,KAAK,6BAAW,CAAC,QAAQ,CAAC;QAEnF,KAAK,CACD,IAAI,yBAAW,CACX,UAAU,EACV,UAAU,EACV,MAAM,CAAC,CAAC,CAAC,SAAS,CAAC,CAAC,CAAC,SAAS,EAC9B,UAAU,EACV,WAAW,EACX,SAAS,EACT,SAAS,EACT,IAAI,EAAE,KAAK,CACd,EACD,GAAG,EAAE,CACD,MAAM;YACF,CAAC,CAAC,GAAG,CAAC,IAAI,CACJ,QAAQ,IAAI,gBAAgB,EAAE,cAAc,SAAS,UAAU,KAAK,CAAC,OAAO,EAAE,CACjF;YACH,CAAC,CAAC,GAAG,CAAC,KAAK,CACL,QAAQ,IAAI,eAAe,EAAE,cAAc,SAAS,UAAU,KAAK,CAAC,OAAO,EAAE,CAChF,CACd,CAAC;IACN,CAAC;IAED;;;;OAIG;IACK,QAAQ,CAAC,UAA8B;QAC3C,IAAI,UAAU,KAAK,SAAS,EAAE,CAAC;YAC3B,OAAO,SAAS,CAAC;QACrB,CAAC;QACD,IAAI,KAAK,GAAG,CAAC,CAAC;QACd,KAAK,MAAM,SAAS,IAAI,UAAU,EAAE,CAAC;YACjC,MAAM,IAAI,GAAG,SAAS,CAAC,WAAW,CAAC,CAAC,CAAE,CAAC;YACvC,KAAK,IAAI,IAAI,IAAI,IAAI,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC,IAAI,IAAI,KAAK,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC,IAAI,IAAI,MAAM,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC;QAC3E,CAAC;QACD,OAAO,KAAK,CAAC;IACjB,CAAC;CACJ;AA9RD,wCA8RC","sourcesContent":["import { toError } from '../lib/errorUtils';\nimport { LogManager } from '../logging/LogManager';\nimport { ApiCallInfo, HttpCallFactsHolder } 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 { CallOutcome } from './ErrorClassifier';\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/** The slice of node's `process.versions` that runtime detection reads (core-util has no node types). */\nclass NodeVersionsProbe {\n node?: string;\n}\n\n/** The slice of node's `process` that runtime detection reads. */\nclass NodeProcessProbe {\n versions?: NodeVersionsProbe;\n}\n\n/** `globalThis`, as seen by runtime detection: a node `process` may or may not exist. */\nclass NodeGlobalProbe {\n process?: NodeProcessProbe;\n}\n\n/**\n * The startup configuration for {@link LogApiCall.configure}.\n *\n * A class rather than an object literal (CLAUDE.md: data-only structures are classes), so the one call\n * site reads `LogApiCall.configure(new LogApiCallConfig(2000))`.\n */\nexport class LogApiCallConfig {\n /**\n * @param maxLoggedBodyChars - the most characters of a serialized request/response body that one\n * `[API-*]` line may carry. `undefined` keeps the runtime default\n * ({@link LogApiCall.BROWSER_DEFAULT_MAX_BODY_CHARS} / {@link LogApiCall.NODE_DEFAULT_MAX_BODY_CHARS});\n * `0` or `Infinity` mean NO cap, for local debugging.\n */\n constructor(readonly maxLoggedBodyChars?: number) {}\n}\n\n/**\n * Process-wide settings for the `[API-*]` lines {@link LogApiCallImpl} writes — today, how much of a\n * body one line may carry (#1300).\n *\n * WHY a cap: the size of a body is already on the same line as a structured tag\n * (`api.requestSize` / `api.responseSize` / `api.responseCount`, computed on the FULL body), so the body\n * text adds no measurement, only bytes. Unbounded, it blew a consumer's 4,000-char browser message\n * trimmer (a Sentry report per line) and on a server it risks Cloud Logging's 256 KB per-entry limit,\n * where an oversize entry is LOST, not shortened.\n *\n * WHY the default depends on the RUNTIME, not on `methodInfo.side`: a node server is also a CLIENT when\n * it calls another service, and those lines go to Cloud Logging too. The runtime is detected ONCE, at\n * module load, by `process.versions.node` only — never `typeof window`, which React Native defines and\n * some SSR setups fake.\n *\n * A static, process-wide setting in the style of {@link ClientRegistry} / `HeaderRegistry`: set once at\n * startup (an app's `main.ts`, a server's bootstrap), never DI-injected, because the browser-side\n * {@link LogApiCallImpl} is constructed by a transport with no DI container.\n */\nexport class LogApiCall {\n /**\n * Browser and React Native default: well under a 4,000-char shipped-log message cap, leaving room\n * for the `[API-…] Class.method response=` prefix and the truncation marker.\n */\n static readonly BROWSER_DEFAULT_MAX_BODY_CHARS = 2000;\n\n /**\n * Node default: even at a worst-case 4 UTF-8 bytes per char this is ~200 KB, under Cloud Logging's\n * 256 KB per-entry limit with room for the entry's other fields.\n */\n static readonly NODE_DEFAULT_MAX_BODY_CHARS = 50000;\n\n /** The default for the runtime this module was loaded in, decided once. */\n static readonly RUNTIME_DEFAULT_MAX_BODY_CHARS = LogApiCall.defaultMaxBodyChars(\n LogApiCall.isNodeRuntime(),\n );\n\n private static configuredMaxBodyChars: number | undefined;\n\n /**\n * Override the runtime default, once at startup. A config whose `maxLoggedBodyChars` is\n * `undefined` restores the runtime default; `0` or `Infinity` turn the cap off.\n */\n // webpieces-disable no-function-outside-class -- static global singleton (like HeaderRegistry/ClientRegistry); set once at startup, never DI-injected\n static configure(config: LogApiCallConfig): void {\n const max = config.maxLoggedBodyChars;\n if (max !== undefined && (Number.isNaN(max) || max < 0)) {\n throw new Error(\n `LogApiCall.configure: maxLoggedBodyChars must be a non-negative number, 0 or Infinity (no cap), or undefined (runtime default); got ${max}`,\n );\n }\n LogApiCall.configuredMaxBodyChars = max;\n }\n\n /** The cap in force: the configured value, else the runtime default. 0 or Infinity mean no cap. */\n // webpieces-disable no-function-outside-class -- static global singleton (like HeaderRegistry/ClientRegistry); set once at startup, never DI-injected\n static maxLoggedBodyChars(): number {\n return LogApiCall.configuredMaxBodyChars ?? LogApiCall.RUNTIME_DEFAULT_MAX_BODY_CHARS;\n }\n\n /**\n * The default for a runtime: {@link NODE_DEFAULT_MAX_BODY_CHARS} on node, else\n * {@link BROWSER_DEFAULT_MAX_BODY_CHARS} (browser and React Native alike).\n */\n // webpieces-disable no-function-outside-class -- pure static helper of the static LogApiCall settings\n static defaultMaxBodyChars(isNode: boolean): number {\n return isNode\n ? LogApiCall.NODE_DEFAULT_MAX_BODY_CHARS\n : LogApiCall.BROWSER_DEFAULT_MAX_BODY_CHARS;\n }\n\n /**\n * True only when a node `process` with `versions.node` exists. Read through `globalThis` because\n * this browser-safe package compiles with no node types.\n */\n // webpieces-disable no-function-outside-class -- pure static helper of the static LogApiCall settings\n static isNodeRuntime(): boolean {\n const g = globalThis as NodeGlobalProbe;\n return typeof g.process !== 'undefined' && !!g.process.versions?.node;\n }\n\n /**\n * Cut a serialized body to its first `max` chars and append\n * `…[truncated: logged <kept> of <total> chars]`. Run on the ALREADY-MASKED string, so a cut can\n * never reveal a masked field. Never splits a surrogate pair: when the cut would land between the\n * two halves of one code point, that code point is dropped whole. A body within the cap, an\n * undefined body, and a cap of 0 / Infinity are returned unchanged.\n */\n // webpieces-disable no-function-outside-class -- pure static helper of the static LogApiCall settings\n static truncateForLog(body: string | undefined, max: number): string | undefined {\n if (body === undefined || max === 0 || !Number.isFinite(max) || body.length <= max) {\n return body;\n }\n let end = Math.floor(max);\n const last = body.charCodeAt(end - 1);\n if (last >= 0xd800 && last <= 0xdbff) {\n end -= 1; // a high surrogate whose low half would be cut off — drop the pair\n }\n return `${body.slice(0, end)}…[truncated: logged ${end} of ${body.length} chars]`;\n }\n}\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 * (request and response bodies are capped at {@link LogApiCall.maxLoggedBodyChars} chars, #1300)\n * - [API-{side}-resp-SUCCESS] ClassName.methodName response={...}\n * - [API-{side}-resp-OTHER] ClassName.methodName errorType={...} error={...} (CallOutcome.EXPECTED)\n * - [API-{side}-resp-FAIL] ClassName.methodName error={...} (TRANSIENT_FAILURE or FAILURE — final by position; each retried attempt is logged by CallRegistry as [API-client-attempt-TRANSIENT])\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 /** The HTTP client's per-call slot — see {@link HttpCallFactsHolder}. Absent everywhere else. */\n http?: HttpCallFactsHolder,\n ): Promise<R> {\n if (methodInfo.noLogging) {\n return this.executeUnlogged(methodInfo, requestDto, method);\n }\n const side = methodInfo.side;\n const id = `${methodInfo.apiClass}.${methodInfo.methodName}`;\n const stamp = this.stamper(this.activeContext());\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 // The size above is on the FULL body; only the log TEXT is capped (#1300), and only after\n // serialize() has masked it.\n const maxBodyChars = LogApiCall.maxLoggedBodyChars();\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(\n `[API-${side}-req] ${id} request=${LogApiCall.truncateForLog(requestBody, maxBodyChars)}`,\n ),\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 http?.facts,\n ),\n () =>\n log.info(\n `[API-${side}-resp-SUCCESS] ${id} response=${LogApiCall.truncateForLog(responseBody, maxBodyChars)}`,\n ),\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, http);\n throw err;\n }\n }\n\n /**\n * Build the tag-and-emit span used around every `[API-*]` line.\n *\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\n * call. `ctx` is passed in (rather than read from `this`) so the caller has already proven it is\n * ACTIVE before anything is stamped.\n */\n private stamper(ctx: ApiCallContext): (info: ApiCallInfo, emit: () => void) => void {\n const key = WebpiecesCoreHeaders.API_CALL_INFO;\n return (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\n /**\n * The `@Endpoint(..., { noLogging: true })` path: run the call and say NOTHING about it.\n *\n * No `[API-*]` line, no {@link ApiCallInfo} stamp, and no serialization of either DTO — a\n * noLogging route is a log shipper or a log-ingest endpoint, so a line describing the call IS the\n * thing being shipped, and emitting it feeds the next batch its own payload (#976).\n *\n * It deliberately does NOT call {@link activeContext}: that guard exists because a call with\n * nowhere to STAMP a tag is a bug, and this path stamps nothing. A noLogging call is therefore\n * legal outside a RequestContext scope, which is exactly where a flusher tends to run.\n *\n * The null-request check is kept, because it is a contract error and not observability — a\n * noLogging route deserves the same error as every other route, and the throw propagates to the caller\n * unlogged here, exactly as the logged path re-throws.\n *\n * `responseCount` is not taken: it feeds only the `api.responseCount` tag, which is not emitted.\n */\n private async executeUnlogged<Q, R>(\n methodInfo: ApiMethodInfo,\n requestDto: Q,\n method: (dto: Q) => Promise<R>,\n ): Promise<R> {\n if (!requestDto) {\n throw new Error(\n `Request cannot be null and was from ${methodInfo.apiClass}.${methodInfo.methodName}`,\n );\n }\n return method(requestDto);\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 http: HttpCallFactsHolder | undefined,\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): the CallScope ladder of the call's contract class\n // (method, then API, then ALL), 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). This line is FINAL BY POSITION: it only sees what escaped every\n // retry, so a TRANSIENT_FAILURE here was never retried (no strategy, or a WRITE) or gave up —\n // it logs FAIL exactly like a FAILURE. Only EXPECTED is \"OTHER / result:'success'\".\n const isUser = ClientRegistry.classify(error, methodInfo) === CallOutcome.EXPECTED;\n\n stamp(\n new ApiCallInfo(\n methodInfo,\n 'response',\n isUser ? 'success' : 'failure',\n durationMs,\n requestSize,\n undefined,\n undefined,\n http?.facts,\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"]}
@@ -0,0 +1,47 @@
1
+ /** One query value as a caller may supply it. Sent as its string form. */
2
+ export type QueryParamValue = string | number | boolean;
3
+ /**
4
+ * A free-form query string: a multimap of key -> values, after Java webpieces'
5
+ * `RouterRequest.queryParams` (`Map<String, List<String>>`). It is the ONE spelling of query
6
+ * parameters on an `EXTERNAL_OUTBOUND` contract, through `@QueryString() query: QueryParams` — a
7
+ * vendor's query grammar is the vendor's (a vendor may document `filter` as one string yet repeat it on
8
+ * the wire), so the contract does not pretend to type it. Our own contracts keep the typed
9
+ * `@QueryParam('name')` instead.
10
+ *
11
+ * ```ts
12
+ * getItems(@PathParam('mId') mId: string, @QueryString() query: QueryParams): Promise<ItemsDto>
13
+ * ```
14
+ *
15
+ * ```ts
16
+ * vendorApi.getItems(mId, new QueryParams().add('filter', 'a').add('filter', 'b').set('limit', 100));
17
+ * // -> ?filter=a&filter=b&limit=100
18
+ * ```
19
+ *
20
+ * Keys keep their insertion order, and so do a key's values, so the wire is deterministic.
21
+ */
22
+ export declare class QueryParams {
23
+ private readonly values;
24
+ /** Append one value to `key` — call it again to REPEAT the key (`a=1&a=2`). */
25
+ add(key: string, value: QueryParamValue): QueryParams;
26
+ /** Replace every value of `key` with exactly this one. */
27
+ set(key: string, value: QueryParamValue): QueryParams;
28
+ /** Every value of `key`, in order; empty when the key is absent. */
29
+ getAll(key: string): string[];
30
+ /**
31
+ * The ONE value of `key`, or undefined when it is absent.
32
+ * @throws ApiBadRequestError (400) when the key is repeated: a single value was expected.
33
+ */
34
+ getSingle(key: string): string | undefined;
35
+ /**
36
+ * {@link getSingle}, as a finite number.
37
+ * @throws ApiBadRequestError (400) when the value is repeated or not a number.
38
+ */
39
+ getNumber(key: string): number | undefined;
40
+ /**
41
+ * {@link getSingle}, as `true` / `false`.
42
+ * @throws ApiBadRequestError (400) when the value is repeated or not 'true' / 'false'.
43
+ */
44
+ getBoolean(key: string): boolean | undefined;
45
+ /** Every key, in insertion order. */
46
+ keys(): string[];
47
+ }
@@ -0,0 +1,89 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.QueryParams = void 0;
4
+ const ApiError_1 = require("../errors/ApiError");
5
+ /**
6
+ * A free-form query string: a multimap of key -> values, after Java webpieces'
7
+ * `RouterRequest.queryParams` (`Map<String, List<String>>`). It is the ONE spelling of query
8
+ * parameters on an `EXTERNAL_OUTBOUND` contract, through `@QueryString() query: QueryParams` — a
9
+ * vendor's query grammar is the vendor's (a vendor may document `filter` as one string yet repeat it on
10
+ * the wire), so the contract does not pretend to type it. Our own contracts keep the typed
11
+ * `@QueryParam('name')` instead.
12
+ *
13
+ * ```ts
14
+ * getItems(@PathParam('mId') mId: string, @QueryString() query: QueryParams): Promise<ItemsDto>
15
+ * ```
16
+ *
17
+ * ```ts
18
+ * vendorApi.getItems(mId, new QueryParams().add('filter', 'a').add('filter', 'b').set('limit', 100));
19
+ * // -> ?filter=a&filter=b&limit=100
20
+ * ```
21
+ *
22
+ * Keys keep their insertion order, and so do a key's values, so the wire is deterministic.
23
+ */
24
+ class QueryParams {
25
+ values = new Map();
26
+ /** Append one value to `key` — call it again to REPEAT the key (`a=1&a=2`). */
27
+ add(key, value) {
28
+ const existing = this.values.get(key) ?? [];
29
+ existing.push(String(value));
30
+ this.values.set(key, existing);
31
+ return this;
32
+ }
33
+ /** Replace every value of `key` with exactly this one. */
34
+ set(key, value) {
35
+ this.values.set(key, [String(value)]);
36
+ return this;
37
+ }
38
+ /** Every value of `key`, in order; empty when the key is absent. */
39
+ getAll(key) {
40
+ return [...(this.values.get(key) ?? [])];
41
+ }
42
+ /**
43
+ * The ONE value of `key`, or undefined when it is absent.
44
+ * @throws ApiBadRequestError (400) when the key is repeated: a single value was expected.
45
+ */
46
+ getSingle(key) {
47
+ const all = this.values.get(key);
48
+ if (all === undefined || all.length === 0)
49
+ return undefined;
50
+ if (all.length > 1) {
51
+ throw new ApiError_1.ApiBadRequestError(`Query parameter '${key}' was repeated but a single value is expected.`);
52
+ }
53
+ return all[0];
54
+ }
55
+ /**
56
+ * {@link getSingle}, as a finite number.
57
+ * @throws ApiBadRequestError (400) when the value is repeated or not a number.
58
+ */
59
+ getNumber(key) {
60
+ const value = this.getSingle(key);
61
+ if (value === undefined)
62
+ return undefined;
63
+ const parsed = Number(value);
64
+ if (value.trim() === '' || !Number.isFinite(parsed)) {
65
+ throw new ApiError_1.ApiBadRequestError(`Query parameter '${key}' must be a number.`);
66
+ }
67
+ return parsed;
68
+ }
69
+ /**
70
+ * {@link getSingle}, as `true` / `false`.
71
+ * @throws ApiBadRequestError (400) when the value is repeated or not 'true' / 'false'.
72
+ */
73
+ getBoolean(key) {
74
+ const value = this.getSingle(key);
75
+ if (value === undefined)
76
+ return undefined;
77
+ if (value === 'true')
78
+ return true;
79
+ if (value === 'false')
80
+ return false;
81
+ throw new ApiError_1.ApiBadRequestError(`Query parameter '${key}' must be 'true' or 'false'.`);
82
+ }
83
+ /** Every key, in insertion order. */
84
+ keys() {
85
+ return [...this.values.keys()];
86
+ }
87
+ }
88
+ exports.QueryParams = QueryParams;
89
+ //# sourceMappingURL=QueryParams.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"QueryParams.js","sourceRoot":"","sources":["../../../../../../packages/core/core-util/src/http/QueryParams.ts"],"names":[],"mappings":";;;AAAA,iDAAwD;AAKxD;;;;;;;;;;;;;;;;;;GAkBG;AACH,MAAa,WAAW;IACH,MAAM,GAAG,IAAI,GAAG,EAAoB,CAAC;IAEtD,+EAA+E;IAC/E,GAAG,CAAC,GAAW,EAAE,KAAsB;QACnC,MAAM,QAAQ,GAAG,IAAI,CAAC,MAAM,CAAC,GAAG,CAAC,GAAG,CAAC,IAAI,EAAE,CAAC;QAC5C,QAAQ,CAAC,IAAI,CAAC,MAAM,CAAC,KAAK,CAAC,CAAC,CAAC;QAC7B,IAAI,CAAC,MAAM,CAAC,GAAG,CAAC,GAAG,EAAE,QAAQ,CAAC,CAAC;QAC/B,OAAO,IAAI,CAAC;IAChB,CAAC;IAED,0DAA0D;IAC1D,GAAG,CAAC,GAAW,EAAE,KAAsB;QACnC,IAAI,CAAC,MAAM,CAAC,GAAG,CAAC,GAAG,EAAE,CAAC,MAAM,CAAC,KAAK,CAAC,CAAC,CAAC,CAAC;QACtC,OAAO,IAAI,CAAC;IAChB,CAAC;IAED,oEAAoE;IACpE,MAAM,CAAC,GAAW;QACd,OAAO,CAAC,GAAG,CAAC,IAAI,CAAC,MAAM,CAAC,GAAG,CAAC,GAAG,CAAC,IAAI,EAAE,CAAC,CAAC,CAAC;IAC7C,CAAC;IAED;;;OAGG;IACH,SAAS,CAAC,GAAW;QACjB,MAAM,GAAG,GAAG,IAAI,CAAC,MAAM,CAAC,GAAG,CAAC,GAAG,CAAC,CAAC;QACjC,IAAI,GAAG,KAAK,SAAS,IAAI,GAAG,CAAC,MAAM,KAAK,CAAC;YAAE,OAAO,SAAS,CAAC;QAC5D,IAAI,GAAG,CAAC,MAAM,GAAG,CAAC,EAAE,CAAC;YACjB,MAAM,IAAI,6BAAkB,CAAC,oBAAoB,GAAG,gDAAgD,CAAC,CAAC;QAC1G,CAAC;QACD,OAAO,GAAG,CAAC,CAAC,CAAC,CAAC;IAClB,CAAC;IAED;;;OAGG;IACH,SAAS,CAAC,GAAW;QACjB,MAAM,KAAK,GAAG,IAAI,CAAC,SAAS,CAAC,GAAG,CAAC,CAAC;QAClC,IAAI,KAAK,KAAK,SAAS;YAAE,OAAO,SAAS,CAAC;QAC1C,MAAM,MAAM,GAAG,MAAM,CAAC,KAAK,CAAC,CAAC;QAC7B,IAAI,KAAK,CAAC,IAAI,EAAE,KAAK,EAAE,IAAI,CAAC,MAAM,CAAC,QAAQ,CAAC,MAAM,CAAC,EAAE,CAAC;YAClD,MAAM,IAAI,6BAAkB,CAAC,oBAAoB,GAAG,qBAAqB,CAAC,CAAC;QAC/E,CAAC;QACD,OAAO,MAAM,CAAC;IAClB,CAAC;IAED;;;OAGG;IACH,UAAU,CAAC,GAAW;QAClB,MAAM,KAAK,GAAG,IAAI,CAAC,SAAS,CAAC,GAAG,CAAC,CAAC;QAClC,IAAI,KAAK,KAAK,SAAS;YAAE,OAAO,SAAS,CAAC;QAC1C,IAAI,KAAK,KAAK,MAAM;YAAE,OAAO,IAAI,CAAC;QAClC,IAAI,KAAK,KAAK,OAAO;YAAE,OAAO,KAAK,CAAC;QACpC,MAAM,IAAI,6BAAkB,CAAC,oBAAoB,GAAG,8BAA8B,CAAC,CAAC;IACxF,CAAC;IAED,qCAAqC;IACrC,IAAI;QACA,OAAO,CAAC,GAAG,IAAI,CAAC,MAAM,CAAC,IAAI,EAAE,CAAC,CAAC;IACnC,CAAC;CACJ;AAjED,kCAiEC","sourcesContent":["import { ApiBadRequestError } from '../errors/ApiError';\n\n/** One query value as a caller may supply it. Sent as its string form. */\nexport type QueryParamValue = string | number | boolean;\n\n/**\n * A free-form query string: a multimap of key -> values, after Java webpieces'\n * `RouterRequest.queryParams` (`Map<String, List<String>>`). It is the ONE spelling of query\n * parameters on an `EXTERNAL_OUTBOUND` contract, through `@QueryString() query: QueryParams` — a\n * vendor's query grammar is the vendor's (a vendor may document `filter` as one string yet repeat it on\n * the wire), so the contract does not pretend to type it. Our own contracts keep the typed\n * `@QueryParam('name')` instead.\n *\n * ```ts\n * getItems(@PathParam('mId') mId: string, @QueryString() query: QueryParams): Promise<ItemsDto>\n * ```\n *\n * ```ts\n * vendorApi.getItems(mId, new QueryParams().add('filter', 'a').add('filter', 'b').set('limit', 100));\n * // -> ?filter=a&filter=b&limit=100\n * ```\n *\n * Keys keep their insertion order, and so do a key's values, so the wire is deterministic.\n */\nexport class QueryParams {\n private readonly values = new Map<string, string[]>();\n\n /** Append one value to `key` — call it again to REPEAT the key (`a=1&a=2`). */\n add(key: string, value: QueryParamValue): QueryParams {\n const existing = this.values.get(key) ?? [];\n existing.push(String(value));\n this.values.set(key, existing);\n return this;\n }\n\n /** Replace every value of `key` with exactly this one. */\n set(key: string, value: QueryParamValue): QueryParams {\n this.values.set(key, [String(value)]);\n return this;\n }\n\n /** Every value of `key`, in order; empty when the key is absent. */\n getAll(key: string): string[] {\n return [...(this.values.get(key) ?? [])];\n }\n\n /**\n * The ONE value of `key`, or undefined when it is absent.\n * @throws ApiBadRequestError (400) when the key is repeated: a single value was expected.\n */\n getSingle(key: string): string | undefined {\n const all = this.values.get(key);\n if (all === undefined || all.length === 0) return undefined;\n if (all.length > 1) {\n throw new ApiBadRequestError(`Query parameter '${key}' was repeated but a single value is expected.`);\n }\n return all[0];\n }\n\n /**\n * {@link getSingle}, as a finite number.\n * @throws ApiBadRequestError (400) when the value is repeated or not a number.\n */\n getNumber(key: string): number | undefined {\n const value = this.getSingle(key);\n if (value === undefined) return undefined;\n const parsed = Number(value);\n if (value.trim() === '' || !Number.isFinite(parsed)) {\n throw new ApiBadRequestError(`Query parameter '${key}' must be a number.`);\n }\n return parsed;\n }\n\n /**\n * {@link getSingle}, as `true` / `false`.\n * @throws ApiBadRequestError (400) when the value is repeated or not 'true' / 'false'.\n */\n getBoolean(key: string): boolean | undefined {\n const value = this.getSingle(key);\n if (value === undefined) return undefined;\n if (value === 'true') return true;\n if (value === 'false') return false;\n throw new ApiBadRequestError(`Query parameter '${key}' must be 'true' or 'false'.`);\n }\n\n /** Every key, in insertion order. */\n keys(): string[] {\n return [...this.values.keys()];\n }\n}\n"]}
@@ -53,7 +53,7 @@ class RetryStrategy {
53
53
  }
54
54
  // webpieces-disable no-any-unknown -- registry strategies span heterogeneous response DTOs
55
55
  async execute(call, ctx) {
56
- const methodInfo = new ApiMethodInfo_1.ApiMethodInfo('client', ctx.apiName, ctx.methodName);
56
+ const methodInfo = new ApiMethodInfo_1.ApiMethodInfo('client', ctx.apiName, ctx.methodName, ctx.contract);
57
57
  let delayMs = this.initialDelayMs;
58
58
  for (let attempt = 1;; attempt++) {
59
59
  // webpieces-disable no-unmanaged-exceptions -- a strategy decides per failure whether to retry
@@ -1 +1 @@
1
- {"version":3,"file":"RetryStrategy.js","sourceRoot":"","sources":["../../../../../../packages/core/core-util/src/http/RetryStrategy.ts"],"names":[],"mappings":"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","sourcesContent":["import { Attempt, CallContext, CallStrategy } from './CallStrategy';\nimport { CallDeadline } from './CallDeadline';\nimport { ClientRegistry } from './ClientRegistry';\nimport { CallOutcome } from './ErrorClassifier';\nimport { ApiMethodInfo } from './ApiMethodInfo';\nimport { WRITE } from './HttpEndpointOptions';\nimport { ApiRetriesExhaustedError } from '../errors/ApiError';\nimport { toError } from '../lib/errorUtils';\n\n/**\n * THE webpieces retry strategy, shared by every transport — register its {@link RetryStrategy.run}\n * with `CallRegistry.setStrategy(...)`, or hand it to a `TransportDefault`.\n *\n * Two questions, two owners, and it asks both:\n *\n * - **the ERROR** — `ClientRegistry.classify(error, …)`. Only `CallOutcome.TRANSIENT_FAILURE` is\n * retried; an EXPECTED or FAILURE outcome is a definitive answer and is rethrown unchanged.\n * - **the ENDPOINT** — `CallContext.operation`. A `WRITE` is NEVER retried, even when this strategy is\n * registered at ALL: a transient error on a non-idempotent write may already have happened.\n *\n * Each attempt gets its own `attemptTimeoutMs` deadline. Between attempts it waits\n * `initialDelayMs`, then multiplies by `delayMultiplier`, capped at `maxDelayMs`.\n *\n * When it RETRIED and ran out, it throws {@link ApiRetriesExhaustedError} (`cause` = the last\n * attempt's error), which is classified FAILURE so no layer above retries again. A call that made a\n * single attempt — `maxRetries` 0, a `WRITE`, a non-transient error — rethrows its own error unchanged.\n */\nexport class RetryStrategy {\n /** The {@link CallStrategy} to register. A bound field, so `strategy.run` can be passed around. */\n // webpieces-disable no-any-unknown -- registry strategies span heterogeneous response DTOs\n readonly run: CallStrategy<unknown>;\n\n constructor(\n readonly attemptTimeoutMs: number,\n readonly maxRetries: number,\n readonly initialDelayMs: number,\n readonly delayMultiplier: number,\n readonly maxDelayMs: number,\n ) {\n CallDeadline.validate(attemptTimeoutMs);\n RetryStrategy.requireWhole('maxRetries', maxRetries);\n RetryStrategy.requireWhole('initialDelayMs', initialDelayMs);\n RetryStrategy.requireWhole('maxDelayMs', maxDelayMs);\n if (!Number.isFinite(delayMultiplier) || delayMultiplier < 1) {\n throw new RangeError('delayMultiplier must be finite and at least 1');\n }\n // webpieces-disable no-any-unknown -- registry strategies span heterogeneous response DTOs\n this.run = (call: Attempt<unknown>, ctx: CallContext): Promise<unknown> => this.execute(call, ctx);\n }\n\n // webpieces-disable no-any-unknown -- registry strategies span heterogeneous response DTOs\n private async execute(call: Attempt<unknown>, ctx: CallContext): Promise<unknown> {\n const methodInfo = new ApiMethodInfo('client', ctx.apiName, ctx.methodName);\n let delayMs = this.initialDelayMs;\n for (let attempt = 1; ; attempt++) {\n // webpieces-disable no-unmanaged-exceptions -- a strategy decides per failure whether to retry\n // eslint-disable-next-line @webpieces/no-unmanaged-exceptions\n try {\n return await call(this.attemptTimeoutMs);\n } catch (err: unknown) {\n const error = toError(err);\n const retryable =\n ctx.operation !== WRITE &&\n ClientRegistry.classify(error, methodInfo) === CallOutcome.TRANSIENT_FAILURE;\n if (!retryable) {\n // eslint-disable-next-line @webpieces/no-unmanaged-exceptions -- a definitive answer is rethrown unchanged\n throw err;\n }\n if (attempt > this.maxRetries) {\n if (attempt === 1) {\n // eslint-disable-next-line @webpieces/no-unmanaged-exceptions -- never retried, so nothing was exhausted\n throw err;\n }\n throw new ApiRetriesExhaustedError(attempt, error);\n }\n await RetryStrategy.sleep(delayMs);\n delayMs = Math.min(delayMs * this.delayMultiplier, this.maxDelayMs);\n }\n }\n }\n\n // webpieces-disable no-function-outside-class -- constructor argument guard\n private static requireWhole(name: string, value: number): void {\n if (!Number.isInteger(value) || value < 0) {\n throw new RangeError(`${name} must be a non-negative integer`);\n }\n }\n\n // webpieces-disable no-function-outside-class -- backoff timer\n private static sleep(ms: number): Promise<void> {\n return new Promise<void>((resolve: () => void) => setTimeout(resolve, ms));\n }\n}\n"]}
1
+ {"version":3,"file":"RetryStrategy.js","sourceRoot":"","sources":["../../../../../../packages/core/core-util/src/http/RetryStrategy.ts"],"names":[],"mappings":"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","sourcesContent":["import { Attempt, CallContext, CallStrategy } from './CallStrategy';\nimport { CallDeadline } from './CallDeadline';\nimport { ClientRegistry } from './ClientRegistry';\nimport { CallOutcome } from './ErrorClassifier';\nimport { ApiMethodInfo } from './ApiMethodInfo';\nimport { WRITE } from './HttpEndpointOptions';\nimport { ApiRetriesExhaustedError } from '../errors/ApiError';\nimport { toError } from '../lib/errorUtils';\n\n/**\n * THE webpieces retry strategy, shared by every transport — register its {@link RetryStrategy.run}\n * with `CallRegistry.setStrategy(...)`, or hand it to a `TransportDefault`.\n *\n * Two questions, two owners, and it asks both:\n *\n * - **the ERROR** — `ClientRegistry.classify(error, …)`. Only `CallOutcome.TRANSIENT_FAILURE` is\n * retried; an EXPECTED or FAILURE outcome is a definitive answer and is rethrown unchanged.\n * - **the ENDPOINT** — `CallContext.operation`. A `WRITE` is NEVER retried, even when this strategy is\n * registered at ALL: a transient error on a non-idempotent write may already have happened.\n *\n * Each attempt gets its own `attemptTimeoutMs` deadline. Between attempts it waits\n * `initialDelayMs`, then multiplies by `delayMultiplier`, capped at `maxDelayMs`.\n *\n * When it RETRIED and ran out, it throws {@link ApiRetriesExhaustedError} (`cause` = the last\n * attempt's error), which is classified FAILURE so no layer above retries again. A call that made a\n * single attempt — `maxRetries` 0, a `WRITE`, a non-transient error — rethrows its own error unchanged.\n */\nexport class RetryStrategy {\n /** The {@link CallStrategy} to register. A bound field, so `strategy.run` can be passed around. */\n // webpieces-disable no-any-unknown -- registry strategies span heterogeneous response DTOs\n readonly run: CallStrategy<unknown>;\n\n constructor(\n readonly attemptTimeoutMs: number,\n readonly maxRetries: number,\n readonly initialDelayMs: number,\n readonly delayMultiplier: number,\n readonly maxDelayMs: number,\n ) {\n CallDeadline.validate(attemptTimeoutMs);\n RetryStrategy.requireWhole('maxRetries', maxRetries);\n RetryStrategy.requireWhole('initialDelayMs', initialDelayMs);\n RetryStrategy.requireWhole('maxDelayMs', maxDelayMs);\n if (!Number.isFinite(delayMultiplier) || delayMultiplier < 1) {\n throw new RangeError('delayMultiplier must be finite and at least 1');\n }\n // webpieces-disable no-any-unknown -- registry strategies span heterogeneous response DTOs\n this.run = (call: Attempt<unknown>, ctx: CallContext): Promise<unknown> => this.execute(call, ctx);\n }\n\n // webpieces-disable no-any-unknown -- registry strategies span heterogeneous response DTOs\n private async execute(call: Attempt<unknown>, ctx: CallContext): Promise<unknown> {\n const methodInfo = new ApiMethodInfo(\n 'client',\n ctx.apiName,\n ctx.methodName,\n ctx.contract,\n );\n let delayMs = this.initialDelayMs;\n for (let attempt = 1; ; attempt++) {\n // webpieces-disable no-unmanaged-exceptions -- a strategy decides per failure whether to retry\n // eslint-disable-next-line @webpieces/no-unmanaged-exceptions\n try {\n return await call(this.attemptTimeoutMs);\n } catch (err: unknown) {\n const error = toError(err);\n const retryable =\n ctx.operation !== WRITE &&\n ClientRegistry.classify(error, methodInfo) === CallOutcome.TRANSIENT_FAILURE;\n if (!retryable) {\n // eslint-disable-next-line @webpieces/no-unmanaged-exceptions -- a definitive answer is rethrown unchanged\n throw err;\n }\n if (attempt > this.maxRetries) {\n if (attempt === 1) {\n // eslint-disable-next-line @webpieces/no-unmanaged-exceptions -- never retried, so nothing was exhausted\n throw err;\n }\n throw new ApiRetriesExhaustedError(attempt, error);\n }\n await RetryStrategy.sleep(delayMs);\n delayMs = Math.min(delayMs * this.delayMultiplier, this.maxDelayMs);\n }\n }\n }\n\n // webpieces-disable no-function-outside-class -- constructor argument guard\n private static requireWhole(name: string, value: number): void {\n if (!Number.isInteger(value) || value < 0) {\n throw new RangeError(`${name} must be a non-negative integer`);\n }\n }\n\n // webpieces-disable no-function-outside-class -- backoff timer\n private static sleep(ms: number): Promise<void> {\n return new Promise<void>((resolve: () => void) => setTimeout(resolve, ms));\n }\n}\n"]}
@@ -4,6 +4,7 @@ import { EndpointResponseType, HttpParameterBinding } from './HttpContract';
4
4
  import { StreamingEndpointMetadata } from './StreamingContract';
5
5
  import { EndpointOperation } from './HttpEndpointOptions';
6
6
  import { CallMode } from '../errors/CallMode';
7
+ import { AnyContextKey } from '../ContextKey';
7
8
  /**
8
9
  * Route metadata stored per-method at runtime.
9
10
  * Used internally by http-routing and http-client as the runtime representation
@@ -27,6 +28,13 @@ export declare class RouteMetadata {
27
28
  apiClass?: Function;
28
29
  authorization?: import('./authorization').AuthorizationRequirement<unknown>;
29
30
  localOnly: boolean;
31
+ /**
32
+ * True when the contract is `EXTERNAL_OUTBOUND`: an outside system's API. Its client sends ONLY
33
+ * {@link externalContextKeys} and believes no response header (`DestinationTrust.forRoute`).
34
+ */
35
+ externalOutbound: boolean;
36
+ /** `@ExternalContextKeys(...)` resolved for this method — class-level keys plus the method's own. */
37
+ externalContextKeys: readonly AnyContextKey[];
30
38
  /** Clients clone metadata per call so a concrete winning mode governs both trust directions. */
31
39
  withSelectedAuth(method: import('./auth-mode').AuthMode): RouteMetadata;
32
40
  /**
@@ -62,7 +70,7 @@ export declare class RouteMetadata {
62
70
  readonly mask?: MaskSpec;
63
71
  /**
64
72
  * True when @Endpoint(..., { rawBody: true }): the transport must retain the verbatim bytes +
65
- * absolute url for the `webhook(...)` hook to verify a vendor signature over. Rides the route
73
+ * absolute url for `VendorAuthHook.verify` to check a vendor credential over. Rides the route
66
74
  * metadata for the same reason {@link formPost} does — the transport adapter decides how to read
67
75
  * the body from the ROUTE, without knowing the apiClass/methodName.
68
76
  */
@@ -89,6 +97,12 @@ export declare class RouteMetadata {
89
97
  * upgrade while the call is in flight, and its success counts toward arming one.
90
98
  */
91
99
  readonly allowUpgradeInFlight: boolean;
100
+ /**
101
+ * True when @Endpoint(..., { responseBody: NONE }): the method returns `Promise<void>` and the
102
+ * success response is 0 bytes. The server answers 204 with no body; every client accepts `{}`,
103
+ * an empty 200 or a 204 and resolves `undefined`. Default false = a JSON body.
104
+ */
105
+ readonly noResponseBody: boolean;
92
106
  constructor(httpMethod: string, path: string, methodName: string,
93
107
  /** Explicit side-effect semantics; deliberately independent of the HTTP verb. */
94
108
  operation: EndpointOperation, controllerClassName?: string, authMeta?: AuthMeta, apiName?: string, formPost?: boolean, mask?: MaskSpec, rawBody?: boolean,
@@ -99,5 +113,5 @@ export declare class RouteMetadata {
99
113
  /** Whether clients receive only the body or the complete status/header/body response. */
100
114
  responseType?: EndpointResponseType,
101
115
  /** Present only for `(ResponseStream) => Promise<RequestStream>` contracts. */
102
- streaming?: StreamingEndpointMetadata | undefined, hideProgress?: boolean, noLogging?: boolean, allowUpgradeInFlight?: boolean);
116
+ streaming?: StreamingEndpointMetadata | undefined, hideProgress?: boolean, noLogging?: boolean, allowUpgradeInFlight?: boolean, noResponseBody?: boolean);
103
117
  }
@@ -22,6 +22,13 @@ class RouteMetadata {
22
22
  // webpieces-disable no-any-unknown -- erased application policy is validated by AuthorizationHook at server startup
23
23
  authorization;
24
24
  localOnly = false;
25
+ /**
26
+ * True when the contract is `EXTERNAL_OUTBOUND`: an outside system's API. Its client sends ONLY
27
+ * {@link externalContextKeys} and believes no response header (`DestinationTrust.forRoute`).
28
+ */
29
+ externalOutbound = false;
30
+ /** `@ExternalContextKeys(...)` resolved for this method — class-level keys plus the method's own. */
31
+ externalContextKeys = [];
25
32
  /** Clients clone metadata per call so a concrete winning mode governs both trust directions. */
26
33
  withSelectedAuth(method) {
27
34
  if (!this.authMeta?.methods.includes(method))
@@ -44,10 +51,12 @@ class RouteMetadata {
44
51
  withCallMode(mode) {
45
52
  if (mode === CallMode_1.CallMode.FOREGROUND)
46
53
  return this;
47
- const route = new RouteMetadata(this.httpMethod, this.path, this.methodName, this.operation, this.controllerClassName, this.authMeta, this.apiName, this.formPost, this.mask, this.rawBody, this.parameterBindings, this.bodyParameterIndex, this.responseType, this.streaming, true, this.noLogging, this.allowUpgradeInFlight);
54
+ const route = new RouteMetadata(this.httpMethod, this.path, this.methodName, this.operation, this.controllerClassName, this.authMeta, this.apiName, this.formPost, this.mask, this.rawBody, this.parameterBindings, this.bodyParameterIndex, this.responseType, this.streaming, true, this.noLogging, this.allowUpgradeInFlight, this.noResponseBody);
48
55
  route.apiClass = this.apiClass;
49
56
  route.authorization = this.authorization;
50
57
  route.localOnly = this.localOnly;
58
+ route.externalOutbound = this.externalOutbound;
59
+ route.externalContextKeys = this.externalContextKeys;
51
60
  route.callMode = mode;
52
61
  return route;
53
62
  }
@@ -72,7 +81,7 @@ class RouteMetadata {
72
81
  mask;
73
82
  /**
74
83
  * True when @Endpoint(..., { rawBody: true }): the transport must retain the verbatim bytes +
75
- * absolute url for the `webhook(...)` hook to verify a vendor signature over. Rides the route
84
+ * absolute url for `VendorAuthHook.verify` to check a vendor credential over. Rides the route
76
85
  * metadata for the same reason {@link formPost} does — the transport adapter decides how to read
77
86
  * the body from the ROUTE, without knowing the apiClass/methodName.
78
87
  */
@@ -99,6 +108,12 @@ class RouteMetadata {
99
108
  * upgrade while the call is in flight, and its success counts toward arming one.
100
109
  */
101
110
  allowUpgradeInFlight;
111
+ /**
112
+ * True when @Endpoint(..., { responseBody: NONE }): the method returns `Promise<void>` and the
113
+ * success response is 0 bytes. The server answers 204 with no body; every client accepts `{}`,
114
+ * an empty 200 or a 204 and resolves `undefined`. Default false = a JSON body.
115
+ */
116
+ noResponseBody;
102
117
  constructor(httpMethod, path, methodName,
103
118
  /** Explicit side-effect semantics; deliberately independent of the HTTP verb. */
104
119
  operation, controllerClassName, authMeta, apiName, formPost = false, mask, rawBody = false,
@@ -109,7 +124,7 @@ class RouteMetadata {
109
124
  /** Whether clients receive only the body or the complete status/header/body response. */
110
125
  responseType = 'body',
111
126
  /** Present only for `(ResponseStream) => Promise<RequestStream>` contracts. */
112
- streaming, hideProgress = false, noLogging = false, allowUpgradeInFlight = false) {
127
+ streaming, hideProgress = false, noLogging = false, allowUpgradeInFlight = false, noResponseBody = false) {
113
128
  this.operation = operation;
114
129
  this.parameterBindings = parameterBindings;
115
130
  this.bodyParameterIndex = bodyParameterIndex;
@@ -131,6 +146,7 @@ class RouteMetadata {
131
146
  this.hideProgress = hideProgress;
132
147
  this.noLogging = noLogging;
133
148
  this.allowUpgradeInFlight = allowUpgradeInFlight;
149
+ this.noResponseBody = noResponseBody;
134
150
  }
135
151
  }
136
152
  exports.RouteMetadata = RouteMetadata;