@webpieces/http-client-core 0.4.392 → 0.4.394

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@webpieces/http-client-core",
3
- "version": "0.4.392",
3
+ "version": "0.4.394",
4
4
  "description": "Isomorphic core of the webpieces HTTP client: the decorator-driven ProxyClient, error translation, and the Proxy trap shared by http-client-node and http-client-browser",
5
5
  "type": "commonjs",
6
6
  "main": "./src/index.js",
@@ -21,6 +21,6 @@
21
21
  "access": "public"
22
22
  },
23
23
  "dependencies": {
24
- "@webpieces/core-util": "0.4.392"
24
+ "@webpieces/core-util": "0.4.394"
25
25
  }
26
26
  }
@@ -1,5 +1,6 @@
1
1
  import { AuthMeta, RouteMetadata, LogApiCallImpl } from '@webpieces/core-util';
2
2
  import { ApiPrototype } from './ApiPrototype';
3
+ import { RequestOutcome } from './RequestOutcome';
3
4
  /**
4
5
  * ProxyClient - the HTTP call engine behind one API contract's client proxy.
5
6
  *
@@ -49,13 +50,26 @@ export declare abstract class ProxyClient {
49
50
  */
50
51
  protected assertEndpointSupported(_authMeta: AuthMeta | undefined, _methodName: string): void;
51
52
  /**
52
- * Observe the response headers after each call, before the body is consumed. Symmetric with the
53
- * outbound {@link outboundHeaders} hook: this is the ONLY place the `fetch` Response — and thus
54
- * its `Headers` — exists, so an app that needs to read a response header (e.g. a server-version
55
- * stamp for client↔server version matching) overrides this in its subclass. The default ignores
56
- * them, so every existing subclass is unaffected.
53
+ * Fires immediately BEFORE `fetch`, once per RPC — the progress "start marker". Symmetric with
54
+ * {@link onRequestEnd}: every start is followed by exactly one end, on every path, so a listener
55
+ * can drive a counter (bar on / bar off) without leaking a permanently-spinning bar.
56
+ *
57
+ * The default is a no-op, so every existing subclass is unaffected.
58
+ */
59
+ protected onRequestStart(_route: RouteMetadata): void;
60
+ /**
61
+ * Fires exactly ONCE after the call settles, on EVERY path (2xx, HTTP error, network reject) —
62
+ * the "stop marker", carrying how it settled.
63
+ *
64
+ * Subsumes the older header-only hook: this is the ONLY place the `fetch` Response — and thus its
65
+ * `Headers` — exists, so an app that needs to read a response header (e.g. a server-version stamp
66
+ * for client↔server version matching) reads `outcome.headers`, still BEFORE the body is consumed
67
+ * and on both the ok and error paths. `outcome.ok`/`outcome.error` add the success-or-error
68
+ * signal the header-only seam could not give.
69
+ *
70
+ * The default is a no-op, so every existing subclass is unaffected.
57
71
  */
58
- protected onResponseHeaders(_route: RouteMetadata, _headers: Headers): void;
72
+ protected onRequestEnd(_route: RouteMetadata, _outcome: RequestOutcome): void;
59
73
  /**
60
74
  * Bind this client to one API contract: read @ApiPath/@Endpoint/@Auth* off the prototype and
61
75
  * build the route map once. Each subclass's `init(api, config)` stores its own config, then
@@ -82,6 +96,11 @@ export declare abstract class ProxyClient {
82
96
  makeRequest(route: RouteMetadata, args: any[]): Promise<any>;
83
97
  /**
84
98
  * Execute the fetch request and handle response.
99
+ *
100
+ * Brackets the call with the lifecycle seam: {@link onRequestStart} once before `fetch`, then
101
+ * {@link onRequestEnd} exactly once on each of the three ways a call can settle. The end hook
102
+ * fires BEFORE the throw on both failure paths, so a listener always sees the stop marker even
103
+ * though the caller sees an exception.
85
104
  */
86
105
  private executeFetch;
87
106
  }
@@ -3,6 +3,7 @@ Object.defineProperty(exports, "__esModule", { value: true });
3
3
  exports.ProxyClient = void 0;
4
4
  const core_util_1 = require("@webpieces/core-util");
5
5
  const ClientErrorTranslator_1 = require("./ClientErrorTranslator");
6
+ const RequestOutcome_1 = require("./RequestOutcome");
6
7
  /**
7
8
  * ProxyClient - the HTTP call engine behind one API contract's client proxy.
8
9
  *
@@ -59,13 +60,26 @@ class ProxyClient {
59
60
  */
60
61
  assertEndpointSupported(_authMeta, _methodName) { }
61
62
  /**
62
- * Observe the response headers after each call, before the body is consumed. Symmetric with the
63
- * outbound {@link outboundHeaders} hook: this is the ONLY place the `fetch` Response — and thus
64
- * its `Headers` — exists, so an app that needs to read a response header (e.g. a server-version
65
- * stamp for client↔server version matching) overrides this in its subclass. The default ignores
66
- * them, so every existing subclass is unaffected.
63
+ * Fires immediately BEFORE `fetch`, once per RPC — the progress "start marker". Symmetric with
64
+ * {@link onRequestEnd}: every start is followed by exactly one end, on every path, so a listener
65
+ * can drive a counter (bar on / bar off) without leaking a permanently-spinning bar.
66
+ *
67
+ * The default is a no-op, so every existing subclass is unaffected.
68
+ */
69
+ onRequestStart(_route) { }
70
+ /**
71
+ * Fires exactly ONCE after the call settles, on EVERY path (2xx, HTTP error, network reject) —
72
+ * the "stop marker", carrying how it settled.
73
+ *
74
+ * Subsumes the older header-only hook: this is the ONLY place the `fetch` Response — and thus its
75
+ * `Headers` — exists, so an app that needs to read a response header (e.g. a server-version stamp
76
+ * for client↔server version matching) reads `outcome.headers`, still BEFORE the body is consumed
77
+ * and on both the ok and error paths. `outcome.ok`/`outcome.error` add the success-or-error
78
+ * signal the header-only seam could not give.
79
+ *
80
+ * The default is a no-op, so every existing subclass is unaffected.
67
81
  */
68
- onResponseHeaders(_route, _headers) { }
82
+ onRequestEnd(_route, _outcome) { }
69
83
  // ---------------------------------------------------------------- contract binding
70
84
  /**
71
85
  * Bind this client to one API contract: read @ApiPath/@Endpoint/@Auth* off the prototype and
@@ -166,21 +180,71 @@ class ProxyClient {
166
180
  }
167
181
  /**
168
182
  * Execute the fetch request and handle response.
183
+ *
184
+ * Brackets the call with the lifecycle seam: {@link onRequestStart} once before `fetch`, then
185
+ * {@link onRequestEnd} exactly once on each of the three ways a call can settle. The end hook
186
+ * fires BEFORE the throw on both failure paths, so a listener always sees the stop marker even
187
+ * though the caller sees an exception.
169
188
  */
170
189
  // webpieces-disable no-any-unknown -- the response DTO's type is erased at the proxy boundary
171
190
  async executeFetch(url, options, route) {
172
- // webpieces-disable no-fetch -- this IS the generated-client implementation the rule points everyone to
173
- const response = await fetch(url, options);
174
- // Inbound seam — invoked before the body is consumed, on BOTH the ok and error paths, so
175
- // version (and any future) headers are observed even on error responses.
176
- this.onResponseHeaders(route, response.headers);
191
+ this.onRequestStart(route);
192
+ // A network reject (offline, DNS, CORS preflight) means no Response ever existed, so there is
193
+ // no status and no headers to report — only status 0 and the failure itself.
194
+ let response;
195
+ // webpieces-disable no-unmanaged-exceptions -- translate a network reject into a lifecycle END, then rethrow
196
+ // eslint-disable-next-line @webpieces/no-unmanaged-exceptions
197
+ try {
198
+ // webpieces-disable no-fetch -- this IS the generated-client implementation the rule points everyone to
199
+ response = await fetch(url, options);
200
+ }
201
+ catch (err) {
202
+ const error = (0, core_util_1.toError)(err);
203
+ this.onRequestEnd(route, new RequestOutcome_1.RequestOutcome(false, 0, undefined, error));
204
+ throw error;
205
+ }
177
206
  if (response.ok) {
178
- return await response.json();
207
+ // webpieces-disable no-unmanaged-exceptions -- a malformed 2xx body must still report the END marker
208
+ // eslint-disable-next-line @webpieces/no-unmanaged-exceptions
209
+ try {
210
+ const body = await response.json();
211
+ this.onRequestEnd(route, new RequestOutcome_1.RequestOutcome(true, response.status, response.headers));
212
+ return body;
213
+ }
214
+ catch (err) {
215
+ const error = (0, core_util_1.toError)(err);
216
+ this.onRequestEnd(route, new RequestOutcome_1.RequestOutcome(false, response.status, response.headers, error));
217
+ throw error;
218
+ }
179
219
  }
180
220
  // Handle errors (non-2xx responses): parse ProtocolError from the response body and
181
- // reconstruct the appropriate HttpError subclass.
182
- const protocolError = (await response.json());
183
- throw ClientErrorTranslator_1.ClientErrorTranslator.translateError(response, protocolError);
221
+ // reconstruct the appropriate HttpError subclass. The headers still reach the seam here, so
222
+ // a version (or any future) header is observed even on error responses.
223
+ //
224
+ // The parse is GUARDED because a non-2xx body is not always ours: an infra 502/504 (load
225
+ // balancer, proxy) returns HTML, and `response.json()` throws on it. Unguarded, the END
226
+ // marker would never fire for exactly the 5xx case the seam exists to catch — the caller
227
+ // would see the throw while the app's progress bar span never closed. Either way the caller
228
+ // still gets the same failure it always got; only the lifecycle report is new.
229
+ //
230
+ // `translated` is the HttpError subclass ClientErrorTranslator picked, and translateError
231
+ // RETURNS Error — so nothing in this seam is ever `unknown`.
232
+ let translated;
233
+ // webpieces-disable no-unmanaged-exceptions -- a non-JSON error body must still report the END marker
234
+ // eslint-disable-next-line @webpieces/no-unmanaged-exceptions
235
+ try {
236
+ const protocolError = (await response.json());
237
+ translated = ClientErrorTranslator_1.ClientErrorTranslator.translateError(response, protocolError);
238
+ }
239
+ catch (err) {
240
+ const error = (0, core_util_1.toError)(err);
241
+ // The error body was not ours (e.g. an infra 502 serving HTML), so there was no
242
+ // ProtocolError to translate — report the parse failure itself as the outcome.
243
+ this.onRequestEnd(route, new RequestOutcome_1.RequestOutcome(false, response.status, response.headers, error));
244
+ throw error;
245
+ }
246
+ this.onRequestEnd(route, new RequestOutcome_1.RequestOutcome(false, response.status, response.headers, translated));
247
+ throw translated;
184
248
  }
185
249
  }
186
250
  exports.ProxyClient = ProxyClient;
@@ -1 +1 @@
1
- {"version":3,"file":"ProxyClient.js","sourceRoot":"","sources":["../../../../../packages/http/http-client-core/src/ProxyClient.ts"],"names":[],"mappings":"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","sourcesContent":["import {\n isApiPath,\n getApiPath,\n getEndpoints,\n getAuthMeta,\n isFormPost,\n AuthMeta,\n RouteMetadata,\n ProtocolError,\n LogApiCall,\n LogApiCallImpl,\n ApiMethodInfo,\n} from '@webpieces/core-util';\nimport { ApiPrototype } from './ApiPrototype';\nimport { ClientErrorTranslator } from './ClientErrorTranslator';\n\n/**\n * ProxyClient - the HTTP call engine behind one API contract's client proxy.\n *\n * Contains ONLY what a browser can run: the route map built from the contract's decorators, URL\n * assembly, `fetch`, error translation, and logging. It holds no context object, no credentials,\n * and no recorder — it ASKS ITSELF for those through the hooks below, and each subclass answers\n * from its own environment.\n *\n * That is why the class is abstract rather than parameterized by a collaborator: a shared\n * header-provider seam would drag Node's AsyncLocalStorage vocabulary into a browser bundle and the\n * browser's store vocabulary into a server, and neither has any use for the other.\n *\n * NodeProxyClient (@webpieces/http-client-node) -> RequestContext, Secrets, mintIdToken, recording\n * BrowserProxyClient (@webpieces/http-client-browser) -> an app-held store, no credentials, no recording\n *\n * TWO-PHASE: collaborators arrive on the subclass constructor (so a DI container can supply them),\n * while the per-client state — which contract, which target — arrives on the subclass's `init`,\n * which calls {@link initRoutes}. That is what lets a factory hold a `Provider<ProxyClient>` and\n * hand out a fresh, independently-configured client per contract.\n */\nexport abstract class ProxyClient {\n // Assigned by initRoutes(), which every subclass's init() calls immediately after construction.\n private routeMap!: Map<string, RouteMetadata>;\n private apiName!: string;\n\n constructor(protected readonly logApiCall: LogApiCallImpl = LogApiCall) {}\n\n // ---------------------------------------------------------------- environment hooks\n\n /** The callee's base URL. Async because a server may derive it from container metadata. */\n protected abstract resolveBaseUrl(): Promise<string>;\n\n /** Context headers to put on the wire. Server reads RequestContext; browser reads its store. */\n protected abstract outboundHeaders(): Map<string, string>;\n\n /**\n * Attach the endpoint's outbound credential. Service-to-service auth (@AuthOidc bearer,\n * @AuthSharedSecret value) is a SERVER concept; a browser has neither a minter nor a Secrets\n * store, so it attaches nothing and its user JWT simply travels as a transferred context key.\n */\n protected async attachOutboundAuth(\n _route: RouteMetadata,\n _baseUrl: string,\n _httpHeaders: Record<string, string>,\n ): Promise<void> {}\n\n /**\n * Run the call. The default just logs it. Test-case RECORDING is a server concept, so\n * NodeProxyClient overrides this to capture the call when a recorder is in the context.\n *\n * Context fields are NOT passed in: a logging backend stamps them onto every record itself.\n */\n // webpieces-disable no-any-unknown -- DTO types are erased at the proxy boundary\n protected async execute(\n route: RouteMetadata,\n requestDto: unknown,\n // webpieces-disable no-any-unknown -- DTO types are erased at the proxy boundary\n method: () => Promise<unknown>,\n // webpieces-disable no-any-unknown -- DTO types are erased at the proxy boundary\n ): Promise<unknown> {\n // apiClass = the CONTRACT name (this.apiName, e.g. 'SaveApi') so this client log line MATCHES\n // the server's for the same call. A client has no impl class, so controllerName is omitted.\n const info = new ApiMethodInfo('client', this.apiName, route.methodName);\n return this.logApiCall.execute(info, requestDto, method);\n }\n\n /**\n * Reject, at bind time, an endpoint this environment cannot satisfy — e.g. a browser cannot\n * mint the OIDC token an @AuthOidc endpoint demands. Surfacing it here beats failing on the\n * first call in production. The default accepts everything.\n */\n protected assertEndpointSupported(_authMeta: AuthMeta | undefined, _methodName: string): void {}\n\n /**\n * Observe the response headers after each call, before the body is consumed. Symmetric with the\n * outbound {@link outboundHeaders} hook: this is the ONLY place the `fetch` Response — and thus\n * its `Headers` — exists, so an app that needs to read a response header (e.g. a server-version\n * stamp for client↔server version matching) overrides this in its subclass. The default ignores\n * them, so every existing subclass is unaffected.\n */\n protected onResponseHeaders(_route: RouteMetadata, _headers: Headers): void {}\n\n // ---------------------------------------------------------------- contract binding\n\n /**\n * Bind this client to one API contract: read @ApiPath/@Endpoint/@Auth* off the prototype and\n * build the route map once. Each subclass's `init(api, config)` stores its own config, then\n * calls this.\n *\n * @throws Error if the prototype lacks @ApiPath, or declares an endpoint this environment\n * cannot satisfy (see {@link assertEndpointSupported}).\n */\n protected initRoutes(apiPrototype: ApiPrototype<object>): void {\n if (!isApiPath(apiPrototype)) {\n const className = apiPrototype.name || 'Unknown';\n throw new Error(`Class ${className} must be decorated with @ApiPath()`);\n }\n\n const basePath = getApiPath(apiPrototype)!;\n const endpoints = getEndpoints(apiPrototype) || {};\n\n // apiName as the class name so client logs read \"SaveApi.save\", not \"undefined.save\"\n this.apiName = apiPrototype.name || 'UnknownApi';\n\n this.routeMap = new Map<string, RouteMetadata>();\n for (const [methodName, endpointPath] of Object.entries(endpoints)) {\n const fullPath = basePath + endpointPath;\n // Capture the endpoint's auth mode so the client can mint delivery auth per\n // @AuthOidc / @AuthSharedSecret, exactly as the server verifies it.\n const authMeta = getAuthMeta(apiPrototype, methodName);\n this.assertEndpointSupported(authMeta, methodName);\n const formPost = isFormPost(apiPrototype, methodName);\n this.routeMap.set(\n methodName,\n new RouteMetadata('POST', fullPath, methodName, this.apiName, authMeta, undefined, formPost),\n );\n }\n }\n\n /** The contract's class name, for logs and recordings. */\n protected contractName(): string {\n return this.apiName;\n }\n\n /** Check if a route exists for the given method name. */\n hasRoute(methodName: string): boolean {\n return this.routeMap.has(methodName);\n }\n\n /**\n * Get route metadata for a method name.\n * @throws Error if no route found\n */\n getRoute(methodName: string): RouteMetadata {\n const route = this.routeMap.get(methodName);\n if (!route) {\n throw new Error(`No route found for method ${methodName}`);\n }\n return route;\n }\n\n // ---------------------------------------------------------------- the call\n\n /**\n * Make an HTTP request based on route metadata and arguments.\n *\n * All endpoints are POST-only. The request body is the first argument.\n */\n // webpieces-disable no-any-unknown -- proxy method: the request DTO (args) + response are erased at the client boundary\n async makeRequest(route: RouteMetadata, args: any[]): Promise<any> {\n // FAIL FAST: formPost endpoints exist ONLY for EXTERNAL inbound webhooks (e.g. Twilio is the\n // caller — there is no webpieces client for them). This proxy JSON.stringifies the body, so\n // calling one would silently send a wrong-encoded body. Refuse it here — PER METHOD, at call\n // time — so an API mixing normal + formPost endpoints still gives a working client for the\n // normal ones; only calling the formPost method throws.\n if (route.formPost) {\n throw new Error(\n `${this.apiName}.${route.methodName} is @Endpoint(..., { formPost: true }) — the ` +\n `webpieces client does not support calling form-encoded endpoints yet. formPost is ` +\n `for EXTERNAL inbound webhooks (e.g. Twilio) only. If this endpoint needs a ` +\n `service-to-service client, set formPost:false (or remove it) so it uses JSON.`,\n );\n }\n // Resolved per call (memoized underneath on a server), so building a client stayed synchronous.\n const baseUrl = await this.resolveBaseUrl();\n const url = `${baseUrl}${route.path}`;\n\n const httpHeaders: Record<string, string> = {\n 'Content-Type': 'application/json',\n };\n\n // Transferred context, request-id chained. The server impl throws here when there is no\n // active RequestContext — an outbound call with no trace is a bug, not a default.\n const outboundHeaders = this.outboundHeaders();\n for (const entry of outboundHeaders.entries()) {\n httpHeaders[entry[0]] = entry[1];\n }\n\n await this.attachOutboundAuth(route, baseUrl, httpHeaders);\n\n const options: RequestInit = {\n method: route.httpMethod,\n headers: httpHeaders,\n };\n\n // POST body is the first argument as JSON\n // webpieces-disable no-any-unknown -- the request DTO's type is erased at the proxy boundary\n let requestDto: unknown;\n if (args.length > 0) {\n requestDto = args[0];\n options.body = JSON.stringify(requestDto);\n }\n\n // Wrap fetch in a method for LogApiCall.execute\n // webpieces-disable no-any-unknown -- the response DTO's type is erased at the proxy boundary\n const method = async (): Promise<unknown> => {\n return this.executeFetch(url, options, route);\n };\n\n return await this.execute(route, requestDto, method);\n }\n\n /**\n * Execute the fetch request and handle response.\n */\n // webpieces-disable no-any-unknown -- the response DTO's type is erased at the proxy boundary\n private async executeFetch(url: string, options: RequestInit, route: RouteMetadata): Promise<unknown> {\n // webpieces-disable no-fetch -- this IS the generated-client implementation the rule points everyone to\n const response = await fetch(url, options);\n\n // Inbound seam — invoked before the body is consumed, on BOTH the ok and error paths, so\n // version (and any future) headers are observed even on error responses.\n this.onResponseHeaders(route, response.headers);\n\n if (response.ok) {\n return await response.json();\n }\n\n // Handle errors (non-2xx responses): parse ProtocolError from the response body and\n // reconstruct the appropriate HttpError subclass.\n const protocolError = (await response.json()) as ProtocolError;\n throw ClientErrorTranslator.translateError(response, protocolError);\n }\n}\n"]}
1
+ {"version":3,"file":"ProxyClient.js","sourceRoot":"","sources":["../../../../../packages/http/http-client-core/src/ProxyClient.ts"],"names":[],"mappings":"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","sourcesContent":["import {\n isApiPath,\n getApiPath,\n getEndpoints,\n getAuthMeta,\n isFormPost,\n AuthMeta,\n RouteMetadata,\n ProtocolError,\n LogApiCall,\n LogApiCallImpl,\n ApiMethodInfo,\n toError,\n} from '@webpieces/core-util';\nimport { ApiPrototype } from './ApiPrototype';\nimport { ClientErrorTranslator } from './ClientErrorTranslator';\nimport { RequestOutcome } from './RequestOutcome';\n\n/**\n * ProxyClient - the HTTP call engine behind one API contract's client proxy.\n *\n * Contains ONLY what a browser can run: the route map built from the contract's decorators, URL\n * assembly, `fetch`, error translation, and logging. It holds no context object, no credentials,\n * and no recorder — it ASKS ITSELF for those through the hooks below, and each subclass answers\n * from its own environment.\n *\n * That is why the class is abstract rather than parameterized by a collaborator: a shared\n * header-provider seam would drag Node's AsyncLocalStorage vocabulary into a browser bundle and the\n * browser's store vocabulary into a server, and neither has any use for the other.\n *\n * NodeProxyClient (@webpieces/http-client-node) -> RequestContext, Secrets, mintIdToken, recording\n * BrowserProxyClient (@webpieces/http-client-browser) -> an app-held store, no credentials, no recording\n *\n * TWO-PHASE: collaborators arrive on the subclass constructor (so a DI container can supply them),\n * while the per-client state — which contract, which target — arrives on the subclass's `init`,\n * which calls {@link initRoutes}. That is what lets a factory hold a `Provider<ProxyClient>` and\n * hand out a fresh, independently-configured client per contract.\n */\nexport abstract class ProxyClient {\n // Assigned by initRoutes(), which every subclass's init() calls immediately after construction.\n private routeMap!: Map<string, RouteMetadata>;\n private apiName!: string;\n\n constructor(protected readonly logApiCall: LogApiCallImpl = LogApiCall) {}\n\n // ---------------------------------------------------------------- environment hooks\n\n /** The callee's base URL. Async because a server may derive it from container metadata. */\n protected abstract resolveBaseUrl(): Promise<string>;\n\n /** Context headers to put on the wire. Server reads RequestContext; browser reads its store. */\n protected abstract outboundHeaders(): Map<string, string>;\n\n /**\n * Attach the endpoint's outbound credential. Service-to-service auth (@AuthOidc bearer,\n * @AuthSharedSecret value) is a SERVER concept; a browser has neither a minter nor a Secrets\n * store, so it attaches nothing and its user JWT simply travels as a transferred context key.\n */\n protected async attachOutboundAuth(\n _route: RouteMetadata,\n _baseUrl: string,\n _httpHeaders: Record<string, string>,\n ): Promise<void> {}\n\n /**\n * Run the call. The default just logs it. Test-case RECORDING is a server concept, so\n * NodeProxyClient overrides this to capture the call when a recorder is in the context.\n *\n * Context fields are NOT passed in: a logging backend stamps them onto every record itself.\n */\n // webpieces-disable no-any-unknown -- DTO types are erased at the proxy boundary\n protected async execute(\n route: RouteMetadata,\n requestDto: unknown,\n // webpieces-disable no-any-unknown -- DTO types are erased at the proxy boundary\n method: () => Promise<unknown>,\n // webpieces-disable no-any-unknown -- DTO types are erased at the proxy boundary\n ): Promise<unknown> {\n // apiClass = the CONTRACT name (this.apiName, e.g. 'SaveApi') so this client log line MATCHES\n // the server's for the same call. A client has no impl class, so controllerName is omitted.\n const info = new ApiMethodInfo('client', this.apiName, route.methodName);\n return this.logApiCall.execute(info, requestDto, method);\n }\n\n /**\n * Reject, at bind time, an endpoint this environment cannot satisfy — e.g. a browser cannot\n * mint the OIDC token an @AuthOidc endpoint demands. Surfacing it here beats failing on the\n * first call in production. The default accepts everything.\n */\n protected assertEndpointSupported(_authMeta: AuthMeta | undefined, _methodName: string): void {}\n\n /**\n * Fires immediately BEFORE `fetch`, once per RPC — the progress \"start marker\". Symmetric with\n * {@link onRequestEnd}: every start is followed by exactly one end, on every path, so a listener\n * can drive a counter (bar on / bar off) without leaking a permanently-spinning bar.\n *\n * The default is a no-op, so every existing subclass is unaffected.\n */\n protected onRequestStart(_route: RouteMetadata): void {}\n\n /**\n * Fires exactly ONCE after the call settles, on EVERY path (2xx, HTTP error, network reject) —\n * the \"stop marker\", carrying how it settled.\n *\n * Subsumes the older header-only hook: this is the ONLY place the `fetch` Response — and thus its\n * `Headers` — exists, so an app that needs to read a response header (e.g. a server-version stamp\n * for client↔server version matching) reads `outcome.headers`, still BEFORE the body is consumed\n * and on both the ok and error paths. `outcome.ok`/`outcome.error` add the success-or-error\n * signal the header-only seam could not give.\n *\n * The default is a no-op, so every existing subclass is unaffected.\n */\n protected onRequestEnd(_route: RouteMetadata, _outcome: RequestOutcome): void {}\n\n // ---------------------------------------------------------------- contract binding\n\n /**\n * Bind this client to one API contract: read @ApiPath/@Endpoint/@Auth* off the prototype and\n * build the route map once. Each subclass's `init(api, config)` stores its own config, then\n * calls this.\n *\n * @throws Error if the prototype lacks @ApiPath, or declares an endpoint this environment\n * cannot satisfy (see {@link assertEndpointSupported}).\n */\n protected initRoutes(apiPrototype: ApiPrototype<object>): void {\n if (!isApiPath(apiPrototype)) {\n const className = apiPrototype.name || 'Unknown';\n throw new Error(`Class ${className} must be decorated with @ApiPath()`);\n }\n\n const basePath = getApiPath(apiPrototype)!;\n const endpoints = getEndpoints(apiPrototype) || {};\n\n // apiName as the class name so client logs read \"SaveApi.save\", not \"undefined.save\"\n this.apiName = apiPrototype.name || 'UnknownApi';\n\n this.routeMap = new Map<string, RouteMetadata>();\n for (const [methodName, endpointPath] of Object.entries(endpoints)) {\n const fullPath = basePath + endpointPath;\n // Capture the endpoint's auth mode so the client can mint delivery auth per\n // @AuthOidc / @AuthSharedSecret, exactly as the server verifies it.\n const authMeta = getAuthMeta(apiPrototype, methodName);\n this.assertEndpointSupported(authMeta, methodName);\n const formPost = isFormPost(apiPrototype, methodName);\n this.routeMap.set(\n methodName,\n new RouteMetadata('POST', fullPath, methodName, this.apiName, authMeta, undefined, formPost),\n );\n }\n }\n\n /** The contract's class name, for logs and recordings. */\n protected contractName(): string {\n return this.apiName;\n }\n\n /** Check if a route exists for the given method name. */\n hasRoute(methodName: string): boolean {\n return this.routeMap.has(methodName);\n }\n\n /**\n * Get route metadata for a method name.\n * @throws Error if no route found\n */\n getRoute(methodName: string): RouteMetadata {\n const route = this.routeMap.get(methodName);\n if (!route) {\n throw new Error(`No route found for method ${methodName}`);\n }\n return route;\n }\n\n // ---------------------------------------------------------------- the call\n\n /**\n * Make an HTTP request based on route metadata and arguments.\n *\n * All endpoints are POST-only. The request body is the first argument.\n */\n // webpieces-disable no-any-unknown -- proxy method: the request DTO (args) + response are erased at the client boundary\n async makeRequest(route: RouteMetadata, args: any[]): Promise<any> {\n // FAIL FAST: formPost endpoints exist ONLY for EXTERNAL inbound webhooks (e.g. Twilio is the\n // caller — there is no webpieces client for them). This proxy JSON.stringifies the body, so\n // calling one would silently send a wrong-encoded body. Refuse it here — PER METHOD, at call\n // time — so an API mixing normal + formPost endpoints still gives a working client for the\n // normal ones; only calling the formPost method throws.\n if (route.formPost) {\n throw new Error(\n `${this.apiName}.${route.methodName} is @Endpoint(..., { formPost: true }) — the ` +\n `webpieces client does not support calling form-encoded endpoints yet. formPost is ` +\n `for EXTERNAL inbound webhooks (e.g. Twilio) only. If this endpoint needs a ` +\n `service-to-service client, set formPost:false (or remove it) so it uses JSON.`,\n );\n }\n // Resolved per call (memoized underneath on a server), so building a client stayed synchronous.\n const baseUrl = await this.resolveBaseUrl();\n const url = `${baseUrl}${route.path}`;\n\n const httpHeaders: Record<string, string> = {\n 'Content-Type': 'application/json',\n };\n\n // Transferred context, request-id chained. The server impl throws here when there is no\n // active RequestContext — an outbound call with no trace is a bug, not a default.\n const outboundHeaders = this.outboundHeaders();\n for (const entry of outboundHeaders.entries()) {\n httpHeaders[entry[0]] = entry[1];\n }\n\n await this.attachOutboundAuth(route, baseUrl, httpHeaders);\n\n const options: RequestInit = {\n method: route.httpMethod,\n headers: httpHeaders,\n };\n\n // POST body is the first argument as JSON\n // webpieces-disable no-any-unknown -- the request DTO's type is erased at the proxy boundary\n let requestDto: unknown;\n if (args.length > 0) {\n requestDto = args[0];\n options.body = JSON.stringify(requestDto);\n }\n\n // Wrap fetch in a method for LogApiCall.execute\n // webpieces-disable no-any-unknown -- the response DTO's type is erased at the proxy boundary\n const method = async (): Promise<unknown> => {\n return this.executeFetch(url, options, route);\n };\n\n return await this.execute(route, requestDto, method);\n }\n\n /**\n * Execute the fetch request and handle response.\n *\n * Brackets the call with the lifecycle seam: {@link onRequestStart} once before `fetch`, then\n * {@link onRequestEnd} exactly once on each of the three ways a call can settle. The end hook\n * fires BEFORE the throw on both failure paths, so a listener always sees the stop marker even\n * though the caller sees an exception.\n */\n // webpieces-disable no-any-unknown -- the response DTO's type is erased at the proxy boundary\n private async executeFetch(url: string, options: RequestInit, route: RouteMetadata): Promise<unknown> {\n this.onRequestStart(route);\n\n // A network reject (offline, DNS, CORS preflight) means no Response ever existed, so there is\n // no status and no headers to report — only status 0 and the failure itself.\n let response: Response;\n // webpieces-disable no-unmanaged-exceptions -- translate a network reject into a lifecycle END, then rethrow\n // eslint-disable-next-line @webpieces/no-unmanaged-exceptions\n try {\n // webpieces-disable no-fetch -- this IS the generated-client implementation the rule points everyone to\n response = await fetch(url, options);\n } catch (err: unknown) {\n const error = toError(err);\n this.onRequestEnd(route, new RequestOutcome(false, 0, undefined, error));\n throw error;\n }\n\n if (response.ok) {\n // webpieces-disable no-unmanaged-exceptions -- a malformed 2xx body must still report the END marker\n // eslint-disable-next-line @webpieces/no-unmanaged-exceptions\n try {\n const body = await response.json();\n this.onRequestEnd(route, new RequestOutcome(true, response.status, response.headers));\n return body;\n } catch (err: unknown) {\n const error = toError(err);\n this.onRequestEnd(route, new RequestOutcome(false, response.status, response.headers, error));\n throw error;\n }\n }\n\n // Handle errors (non-2xx responses): parse ProtocolError from the response body and\n // reconstruct the appropriate HttpError subclass. The headers still reach the seam here, so\n // a version (or any future) header is observed even on error responses.\n //\n // The parse is GUARDED because a non-2xx body is not always ours: an infra 502/504 (load\n // balancer, proxy) returns HTML, and `response.json()` throws on it. Unguarded, the END\n // marker would never fire for exactly the 5xx case the seam exists to catch — the caller\n // would see the throw while the app's progress bar span never closed. Either way the caller\n // still gets the same failure it always got; only the lifecycle report is new.\n //\n // `translated` is the HttpError subclass ClientErrorTranslator picked, and translateError\n // RETURNS Error — so nothing in this seam is ever `unknown`.\n let translated: Error;\n // webpieces-disable no-unmanaged-exceptions -- a non-JSON error body must still report the END marker\n // eslint-disable-next-line @webpieces/no-unmanaged-exceptions\n try {\n const protocolError = (await response.json()) as ProtocolError;\n translated = ClientErrorTranslator.translateError(response, protocolError);\n } catch (err: unknown) {\n const error = toError(err);\n // The error body was not ours (e.g. an infra 502 serving HTML), so there was no\n // ProtocolError to translate — report the parse failure itself as the outcome.\n this.onRequestEnd(route, new RequestOutcome(false, response.status, response.headers, error));\n throw error;\n }\n\n this.onRequestEnd(route, new RequestOutcome(false, response.status, response.headers, translated));\n throw translated;\n }\n}\n"]}
@@ -0,0 +1,52 @@
1
+ /**
2
+ * How one RPC call SETTLED — the payload of {@link ProxyClient.onRequestEnd}.
3
+ *
4
+ * DATA ONLY (no behavior), so it is a class with an explicit constructor rather than an interface:
5
+ * each of the three settle paths in `ProxyClient.executeFetch` constructs it by name, and a reader
6
+ * can see at the call site which path produced which shape.
7
+ *
8
+ * The three shapes, one per path:
9
+ * - 2xx `new RequestOutcome(true, status, headers)` — no error
10
+ * - HTTP error `new RequestOutcome(false, status, headers, error)` — the translated HttpError
11
+ * - network reject `new RequestOutcome(false, 0, undefined, error)` — no Response ever existed
12
+ *
13
+ * A fourth path exists but is not a fourth SHAPE: a body that fails to parse (an infra 502 serving
14
+ * HTML) settles as an HTTP error carrying the parse failure.
15
+ */
16
+ export declare class RequestOutcome {
17
+ /** `response.ok` — true only on a 2xx. */
18
+ readonly ok: boolean;
19
+ /** The HTTP status; 0 when `fetch` itself rejected (network / offline), where there is no status. */
20
+ readonly status: number;
21
+ /**
22
+ * The Response headers, present whenever an HTTP Response existed (ok OR error) and absent
23
+ * only on a network reject. Read BEFORE the body is consumed, which is what lets an app pull
24
+ * a server-version stamp off an error response.
25
+ */
26
+ readonly headers?: Headers | undefined;
27
+ /**
28
+ * Set on every non-success path: the `HttpError` subclass `ClientErrorTranslator` picked for
29
+ * a non-2xx, or the network/parse failure normalized through `toError`. Always a real
30
+ * `Error` — never `unknown`, because nothing here is untyped: `translateError` RETURNS
31
+ * `Error`, and every rejection reaching this class has been through `toError`.
32
+ */
33
+ readonly error?: Error | undefined;
34
+ constructor(
35
+ /** `response.ok` — true only on a 2xx. */
36
+ ok: boolean,
37
+ /** The HTTP status; 0 when `fetch` itself rejected (network / offline), where there is no status. */
38
+ status: number,
39
+ /**
40
+ * The Response headers, present whenever an HTTP Response existed (ok OR error) and absent
41
+ * only on a network reject. Read BEFORE the body is consumed, which is what lets an app pull
42
+ * a server-version stamp off an error response.
43
+ */
44
+ headers?: Headers | undefined,
45
+ /**
46
+ * Set on every non-success path: the `HttpError` subclass `ClientErrorTranslator` picked for
47
+ * a non-2xx, or the network/parse failure normalized through `toError`. Always a real
48
+ * `Error` — never `unknown`, because nothing here is untyped: `translateError` RETURNS
49
+ * `Error`, and every rejection reaching this class has been through `toError`.
50
+ */
51
+ error?: Error | undefined);
52
+ }
@@ -0,0 +1,49 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.RequestOutcome = void 0;
4
+ /**
5
+ * How one RPC call SETTLED — the payload of {@link ProxyClient.onRequestEnd}.
6
+ *
7
+ * DATA ONLY (no behavior), so it is a class with an explicit constructor rather than an interface:
8
+ * each of the three settle paths in `ProxyClient.executeFetch` constructs it by name, and a reader
9
+ * can see at the call site which path produced which shape.
10
+ *
11
+ * The three shapes, one per path:
12
+ * - 2xx `new RequestOutcome(true, status, headers)` — no error
13
+ * - HTTP error `new RequestOutcome(false, status, headers, error)` — the translated HttpError
14
+ * - network reject `new RequestOutcome(false, 0, undefined, error)` — no Response ever existed
15
+ *
16
+ * A fourth path exists but is not a fourth SHAPE: a body that fails to parse (an infra 502 serving
17
+ * HTML) settles as an HTTP error carrying the parse failure.
18
+ */
19
+ class RequestOutcome {
20
+ ok;
21
+ status;
22
+ headers;
23
+ error;
24
+ constructor(
25
+ /** `response.ok` — true only on a 2xx. */
26
+ ok,
27
+ /** The HTTP status; 0 when `fetch` itself rejected (network / offline), where there is no status. */
28
+ status,
29
+ /**
30
+ * The Response headers, present whenever an HTTP Response existed (ok OR error) and absent
31
+ * only on a network reject. Read BEFORE the body is consumed, which is what lets an app pull
32
+ * a server-version stamp off an error response.
33
+ */
34
+ headers,
35
+ /**
36
+ * Set on every non-success path: the `HttpError` subclass `ClientErrorTranslator` picked for
37
+ * a non-2xx, or the network/parse failure normalized through `toError`. Always a real
38
+ * `Error` — never `unknown`, because nothing here is untyped: `translateError` RETURNS
39
+ * `Error`, and every rejection reaching this class has been through `toError`.
40
+ */
41
+ error) {
42
+ this.ok = ok;
43
+ this.status = status;
44
+ this.headers = headers;
45
+ this.error = error;
46
+ }
47
+ }
48
+ exports.RequestOutcome = RequestOutcome;
49
+ //# sourceMappingURL=RequestOutcome.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"RequestOutcome.js","sourceRoot":"","sources":["../../../../../packages/http/http-client-core/src/RequestOutcome.ts"],"names":[],"mappings":";;;AAAA;;;;;;;;;;;;;;GAcG;AACH,MAAa,cAAc;IAGH;IAEA;IAMA;IAOA;IAjBpB;IACI,0CAA0C;IAC1B,EAAW;IAC3B,qGAAqG;IACrF,MAAc;IAC9B;;;;OAIG;IACa,OAAiB;IACjC;;;;;OAKG;IACa,KAAa;QAfb,OAAE,GAAF,EAAE,CAAS;QAEX,WAAM,GAAN,MAAM,CAAQ;QAMd,YAAO,GAAP,OAAO,CAAU;QAOjB,UAAK,GAAL,KAAK,CAAQ;IAC9B,CAAC;CACP;AApBD,wCAoBC","sourcesContent":["/**\n * How one RPC call SETTLED — the payload of {@link ProxyClient.onRequestEnd}.\n *\n * DATA ONLY (no behavior), so it is a class with an explicit constructor rather than an interface:\n * each of the three settle paths in `ProxyClient.executeFetch` constructs it by name, and a reader\n * can see at the call site which path produced which shape.\n *\n * The three shapes, one per path:\n * - 2xx `new RequestOutcome(true, status, headers)` — no error\n * - HTTP error `new RequestOutcome(false, status, headers, error)` — the translated HttpError\n * - network reject `new RequestOutcome(false, 0, undefined, error)` — no Response ever existed\n *\n * A fourth path exists but is not a fourth SHAPE: a body that fails to parse (an infra 502 serving\n * HTML) settles as an HTTP error carrying the parse failure.\n */\nexport class RequestOutcome {\n constructor(\n /** `response.ok` — true only on a 2xx. */\n public readonly ok: boolean,\n /** The HTTP status; 0 when `fetch` itself rejected (network / offline), where there is no status. */\n public readonly status: number,\n /**\n * The Response headers, present whenever an HTTP Response existed (ok OR error) and absent\n * only on a network reject. Read BEFORE the body is consumed, which is what lets an app pull\n * a server-version stamp off an error response.\n */\n public readonly headers?: Headers,\n /**\n * Set on every non-success path: the `HttpError` subclass `ClientErrorTranslator` picked for\n * a non-2xx, or the network/parse failure normalized through `toError`. Always a real\n * `Error` — never `unknown`, because nothing here is untyped: `translateError` RETURNS\n * `Error`, and every rejection reaching this class has been through `toError`.\n */\n public readonly error?: Error,\n ) {}\n}\n"]}
package/src/index.d.ts CHANGED
@@ -25,6 +25,7 @@
25
25
  * a browser bundle, and nothing browser-only (a ContextReader store) reaches a server.
26
26
  */
27
27
  export { ProxyClient } from './ProxyClient';
28
+ export { RequestOutcome } from './RequestOutcome';
28
29
  export type { ApiPrototype } from './ApiPrototype';
29
30
  export { buildClientProxy } from './buildClientProxy';
30
31
  export { ClientErrorTranslator } from './ClientErrorTranslator';
package/src/index.js CHANGED
@@ -26,9 +26,11 @@
26
26
  * a browser bundle, and nothing browser-only (a ContextReader store) reaches a server.
27
27
  */
28
28
  Object.defineProperty(exports, "__esModule", { value: true });
29
- exports.ClientErrorTranslator = exports.buildClientProxy = exports.ProxyClient = void 0;
29
+ exports.ClientErrorTranslator = exports.buildClientProxy = exports.RequestOutcome = exports.ProxyClient = void 0;
30
30
  var ProxyClient_1 = require("./ProxyClient");
31
31
  Object.defineProperty(exports, "ProxyClient", { enumerable: true, get: function () { return ProxyClient_1.ProxyClient; } });
32
+ var RequestOutcome_1 = require("./RequestOutcome");
33
+ Object.defineProperty(exports, "RequestOutcome", { enumerable: true, get: function () { return RequestOutcome_1.RequestOutcome; } });
32
34
  var buildClientProxy_1 = require("./buildClientProxy");
33
35
  Object.defineProperty(exports, "buildClientProxy", { enumerable: true, get: function () { return buildClientProxy_1.buildClientProxy; } });
34
36
  var ClientErrorTranslator_1 = require("./ClientErrorTranslator");
package/src/index.js.map CHANGED
@@ -1 +1 @@
1
- {"version":3,"file":"index.js","sourceRoot":"","sources":["../../../../../packages/http/http-client-core/src/index.ts"],"names":[],"mappings":";AAAA;;;;;;;;;;;;;;;;;;;;;;;;;GAyBG;;;AAEH,6CAA4C;AAAnC,0GAAA,WAAW,OAAA;AAEpB,uDAAsD;AAA7C,oHAAA,gBAAgB,OAAA;AACzB,iEAAgE;AAAvD,8HAAA,qBAAqB,OAAA","sourcesContent":["/**\n * @webpieces/http-client-core\n *\n * The ISOMORPHIC core of the webpieces HTTP client — everything that reads an API contract's\n * decorators and turns a method call into an HTTP request, with no opinion about where the\n * magic context comes from or whether a DI container exists.\n *\n * You almost certainly want one of its two environment packages instead:\n * - Server: @webpieces/http-client-node (inversify-wired, reads RequestContext, mints OIDC)\n * - Browser: @webpieces/http-client-browser (no DI — React or Angular, app-managed context store)\n *\n * Architecture:\n * ```\n * http-api (defines the contract)\n * ^\n * +-- http-routing (server: contract -> handlers)\n * +-- http-client-core (contract -> HTTP requests) <- YOU ARE HERE\n * +-- http-client-node (RequestContext + Secrets + OIDC + inversify factory)\n * +-- http-client-browser (app-held store + plain factory, no DI)\n * ```\n *\n * There is no context/credential/recording seam here at all: ProxyClient is ABSTRACT and asks its\n * subclass for the base URL, the context headers, the log map, the outbound credential, and the\n * recorder. Nothing server-only (RequestContext, Secrets, mintIdToken, TestCaseRecorder) can reach\n * a browser bundle, and nothing browser-only (a ContextReader store) reaches a server.\n */\n\nexport { ProxyClient } from './ProxyClient';\nexport type { ApiPrototype } from './ApiPrototype';\nexport { buildClientProxy } from './buildClientProxy';\nexport { ClientErrorTranslator } from './ClientErrorTranslator';\n"]}
1
+ {"version":3,"file":"index.js","sourceRoot":"","sources":["../../../../../packages/http/http-client-core/src/index.ts"],"names":[],"mappings":";AAAA;;;;;;;;;;;;;;;;;;;;;;;;;GAyBG;;;AAEH,6CAA4C;AAAnC,0GAAA,WAAW,OAAA;AACpB,mDAAkD;AAAzC,gHAAA,cAAc,OAAA;AAEvB,uDAAsD;AAA7C,oHAAA,gBAAgB,OAAA;AACzB,iEAAgE;AAAvD,8HAAA,qBAAqB,OAAA","sourcesContent":["/**\n * @webpieces/http-client-core\n *\n * The ISOMORPHIC core of the webpieces HTTP client — everything that reads an API contract's\n * decorators and turns a method call into an HTTP request, with no opinion about where the\n * magic context comes from or whether a DI container exists.\n *\n * You almost certainly want one of its two environment packages instead:\n * - Server: @webpieces/http-client-node (inversify-wired, reads RequestContext, mints OIDC)\n * - Browser: @webpieces/http-client-browser (no DI — React or Angular, app-managed context store)\n *\n * Architecture:\n * ```\n * http-api (defines the contract)\n * ^\n * +-- http-routing (server: contract -> handlers)\n * +-- http-client-core (contract -> HTTP requests) <- YOU ARE HERE\n * +-- http-client-node (RequestContext + Secrets + OIDC + inversify factory)\n * +-- http-client-browser (app-held store + plain factory, no DI)\n * ```\n *\n * There is no context/credential/recording seam here at all: ProxyClient is ABSTRACT and asks its\n * subclass for the base URL, the context headers, the log map, the outbound credential, and the\n * recorder. Nothing server-only (RequestContext, Secrets, mintIdToken, TestCaseRecorder) can reach\n * a browser bundle, and nothing browser-only (a ContextReader store) reaches a server.\n */\n\nexport { ProxyClient } from './ProxyClient';\nexport { RequestOutcome } from './RequestOutcome';\nexport type { ApiPrototype } from './ApiPrototype';\nexport { buildClientProxy } from './buildClientProxy';\nexport { ClientErrorTranslator } from './ClientErrorTranslator';\n"]}