@webpieces/http-client-core 0.4.699 → 0.4.701

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.699",
3
+ "version": "0.4.701",
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.699"
24
+ "@webpieces/core-util": "0.4.701"
25
25
  }
26
26
  }
@@ -0,0 +1,71 @@
1
+ import { Filter } from '@webpieces/core-util';
2
+ import { ClientRequest } from './ClientRequest';
3
+ /**
4
+ * An OUTBOUND filter — the client-side counterpart of the server's `HttpFilter`, and the same
5
+ * `Filter` abstraction from @webpieces/core-util pointed the other way:
6
+ *
7
+ * - server: `Filter<MethodMeta, WpResponse<unknown>>` wraps the controller invocation
8
+ * - client: `Filter<ClientRequest, Response>` wraps the send
9
+ *
10
+ * `REQ` is the mutable {@link ClientRequest}, so a filter can re-point the URL, add headers, or
11
+ * replace the serialized body. `RESP` is the platform `Response`, so a filter can read the status
12
+ * and headers of what came back, short-circuit without sending at all, or invoke the rest of the
13
+ * chain more than once (which is how a redirect is followed under policy).
14
+ *
15
+ * ## One rule: do not consume the body
16
+ *
17
+ * A `Response` body can be read exactly once, and the response body is read AFTER the chain by the
18
+ * engine that turns it into the caller's DTO or typed error. A filter that calls `.json()` or
19
+ * `.text()` on the response it is passing back breaks the call. Read `response.status` and
20
+ * `response.headers` freely; `response.clone()` first if you genuinely need the bytes.
21
+ */
22
+ export type ClientFilter = Filter<ClientRequest, Response>;
23
+ /**
24
+ * ONE registered client filter and the priority it runs at — the client-side twin of the server's
25
+ * `FilterDefinition`, and it carries a priority for the same reason: priority belongs to the
26
+ * REGISTRATION, never to the filter, so the same filter class can sit at different depths in two
27
+ * different clients.
28
+ *
29
+ * Highest priority runs OUTERMOST (first in, last out), matching `FilterMatcher`'s ordering, so a
30
+ * filter with a higher number wraps everything below it.
31
+ *
32
+ * ## Priority orders APP filters against each other, and nothing else
33
+ *
34
+ * The framework's own built-ins (the SSRF guard, the outbound credential minter) are not in this
35
+ * ordering at all: `ProxyClient.initRoutes` appends them BENEATH every app filter, whatever numbers
36
+ * the app chose. So there is no priority — not `Number.MAX_SAFE_INTEGER` — that gets an app filter
37
+ * underneath them, which is the point. The guard must judge, and the minter must sign for, the URL
38
+ * that is actually about to be fetched; a filter that could run below them would be able to move
39
+ * the request after both had spoken.
40
+ *
41
+
42
+ * ## Two deliberate differences from the server's FilterDefinition
43
+ *
44
+ * 1. It holds an INSTANCE, not a DI class token. Server filters are resolved from the container by
45
+ * the router; a client filter is constructed at the `createRpcClient` call site, which is code
46
+ * already inside a DI module and already holding whatever the filter needs (a signing key, a
47
+ * clock). Adding a container round-trip would buy nothing and would make the filter's collaborators
48
+ * invisible at the one place a reader looks.
49
+ * 2. There is no filepath pattern. The server matches filters to controllers because ONE router
50
+ * serves many controllers; a client is bound to exactly ONE contract, so there is nothing to
51
+ * match against and a pattern would always be a no-op.
52
+ */
53
+ export declare class ClientFilterDefinition {
54
+ /** Higher runs OUTERMOST, among THIS client's app filters. See the class doc. */
55
+ readonly priority: number;
56
+ readonly filter: ClientFilter;
57
+ constructor(
58
+ /** Higher runs OUTERMOST, among THIS client's app filters. See the class doc. */
59
+ priority: number, filter: ClientFilter);
60
+ }
61
+ /**
62
+ * The app filters ONE client installs — a NON-EMPTY list, which is the whole point of the type.
63
+ *
64
+ * `createRpcClient`'s filters argument is optional and typed as this, so "this client has no app
65
+ * filters" has exactly ONE spelling: omit the argument. `[]` would be a second way to say the
66
+ * identical thing, so it is a COMPILE error rather than a discouraged-but-accepted alternative —
67
+ * the same device `JwtRoles`'s `roles` uses, and for the same reason (see
68
+ * `.claude/review/backwards-compatibility.md` shim shape #1: delete the bad case from the type
69
+ * instead of documenting a preference). Pinned in `CreateRpcClientCompileAssertions.ts`.
70
+ */
71
+ export type ClientFilters = readonly [ClientFilterDefinition, ...ClientFilterDefinition[]];
@@ -0,0 +1,45 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.ClientFilterDefinition = void 0;
4
+ /**
5
+ * ONE registered client filter and the priority it runs at — the client-side twin of the server's
6
+ * `FilterDefinition`, and it carries a priority for the same reason: priority belongs to the
7
+ * REGISTRATION, never to the filter, so the same filter class can sit at different depths in two
8
+ * different clients.
9
+ *
10
+ * Highest priority runs OUTERMOST (first in, last out), matching `FilterMatcher`'s ordering, so a
11
+ * filter with a higher number wraps everything below it.
12
+ *
13
+ * ## Priority orders APP filters against each other, and nothing else
14
+ *
15
+ * The framework's own built-ins (the SSRF guard, the outbound credential minter) are not in this
16
+ * ordering at all: `ProxyClient.initRoutes` appends them BENEATH every app filter, whatever numbers
17
+ * the app chose. So there is no priority — not `Number.MAX_SAFE_INTEGER` — that gets an app filter
18
+ * underneath them, which is the point. The guard must judge, and the minter must sign for, the URL
19
+ * that is actually about to be fetched; a filter that could run below them would be able to move
20
+ * the request after both had spoken.
21
+ *
22
+
23
+ * ## Two deliberate differences from the server's FilterDefinition
24
+ *
25
+ * 1. It holds an INSTANCE, not a DI class token. Server filters are resolved from the container by
26
+ * the router; a client filter is constructed at the `createRpcClient` call site, which is code
27
+ * already inside a DI module and already holding whatever the filter needs (a signing key, a
28
+ * clock). Adding a container round-trip would buy nothing and would make the filter's collaborators
29
+ * invisible at the one place a reader looks.
30
+ * 2. There is no filepath pattern. The server matches filters to controllers because ONE router
31
+ * serves many controllers; a client is bound to exactly ONE contract, so there is nothing to
32
+ * match against and a pattern would always be a no-op.
33
+ */
34
+ class ClientFilterDefinition {
35
+ priority;
36
+ filter;
37
+ constructor(
38
+ /** Higher runs OUTERMOST, among THIS client's app filters. See the class doc. */
39
+ priority, filter) {
40
+ this.priority = priority;
41
+ this.filter = filter;
42
+ }
43
+ }
44
+ exports.ClientFilterDefinition = ClientFilterDefinition;
45
+ //# sourceMappingURL=ClientFilter.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"ClientFilter.js","sourceRoot":"","sources":["../../../../../packages/http/http-client-core/src/ClientFilter.ts"],"names":[],"mappings":";;;AAwBA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GA6BG;AACH,MAAa,sBAAsB;IAGX;IACA;IAHpB;IACI,iFAAiF;IACjE,QAAgB,EAChB,MAAoB;QADpB,aAAQ,GAAR,QAAQ,CAAQ;QAChB,WAAM,GAAN,MAAM,CAAc;IACrC,CAAC;CACP;AAND,wDAMC","sourcesContent":["import { Filter } from '@webpieces/core-util';\nimport { ClientRequest } from './ClientRequest';\n\n/**\n * An OUTBOUND filter — the client-side counterpart of the server's `HttpFilter`, and the same\n * `Filter` abstraction from @webpieces/core-util pointed the other way:\n *\n * - server: `Filter<MethodMeta, WpResponse<unknown>>` wraps the controller invocation\n * - client: `Filter<ClientRequest, Response>` wraps the send\n *\n * `REQ` is the mutable {@link ClientRequest}, so a filter can re-point the URL, add headers, or\n * replace the serialized body. `RESP` is the platform `Response`, so a filter can read the status\n * and headers of what came back, short-circuit without sending at all, or invoke the rest of the\n * chain more than once (which is how a redirect is followed under policy).\n *\n * ## One rule: do not consume the body\n *\n * A `Response` body can be read exactly once, and the response body is read AFTER the chain by the\n * engine that turns it into the caller's DTO or typed error. A filter that calls `.json()` or\n * `.text()` on the response it is passing back breaks the call. Read `response.status` and\n * `response.headers` freely; `response.clone()` first if you genuinely need the bytes.\n */\nexport type ClientFilter = Filter<ClientRequest, Response>;\n\n/**\n * ONE registered client filter and the priority it runs at — the client-side twin of the server's\n * `FilterDefinition`, and it carries a priority for the same reason: priority belongs to the\n * REGISTRATION, never to the filter, so the same filter class can sit at different depths in two\n * different clients.\n *\n * Highest priority runs OUTERMOST (first in, last out), matching `FilterMatcher`'s ordering, so a\n * filter with a higher number wraps everything below it.\n *\n * ## Priority orders APP filters against each other, and nothing else\n *\n * The framework's own built-ins (the SSRF guard, the outbound credential minter) are not in this\n * ordering at all: `ProxyClient.initRoutes` appends them BENEATH every app filter, whatever numbers\n * the app chose. So there is no priority — not `Number.MAX_SAFE_INTEGER` — that gets an app filter\n * underneath them, which is the point. The guard must judge, and the minter must sign for, the URL\n * that is actually about to be fetched; a filter that could run below them would be able to move\n * the request after both had spoken.\n *\n\n * ## Two deliberate differences from the server's FilterDefinition\n *\n * 1. It holds an INSTANCE, not a DI class token. Server filters are resolved from the container by\n * the router; a client filter is constructed at the `createRpcClient` call site, which is code\n * already inside a DI module and already holding whatever the filter needs (a signing key, a\n * clock). Adding a container round-trip would buy nothing and would make the filter's collaborators\n * invisible at the one place a reader looks.\n * 2. There is no filepath pattern. The server matches filters to controllers because ONE router\n * serves many controllers; a client is bound to exactly ONE contract, so there is nothing to\n * match against and a pattern would always be a no-op.\n */\nexport class ClientFilterDefinition {\n constructor(\n /** Higher runs OUTERMOST, among THIS client's app filters. See the class doc. */\n public readonly priority: number,\n public readonly filter: ClientFilter,\n ) {}\n}\n\n/**\n * The app filters ONE client installs — a NON-EMPTY list, which is the whole point of the type.\n *\n * `createRpcClient`'s filters argument is optional and typed as this, so \"this client has no app\n * filters\" has exactly ONE spelling: omit the argument. `[]` would be a second way to say the\n * identical thing, so it is a COMPILE error rather than a discouraged-but-accepted alternative —\n * the same device `JwtRoles`'s `roles` uses, and for the same reason (see\n * `.claude/review/backwards-compatibility.md` shim shape #1: delete the bad case from the type\n * instead of documenting a preference). Pinned in `CreateRpcClientCompileAssertions.ts`.\n */\nexport type ClientFilters = readonly [ClientFilterDefinition, ...ClientFilterDefinition[]];\n"]}
@@ -0,0 +1,114 @@
1
+ import { RouteMetadata } from '@webpieces/core-util';
2
+ /**
3
+ * The OUTBOUND request as the client filter chain sees it — the client-side twin of the server's
4
+ * `MethodMeta`, and the `REQ` of every `Filter<ClientRequest, Response>`.
5
+ *
6
+ * MUTABLE on purpose, and that is the whole point of the chain: a filter re-points the URL, adds a
7
+ * header, or replaces the serialized bytes, and whatever this object holds when the chain bottoms
8
+ * out is EXACTLY what goes on the wire.
9
+ *
10
+ * ## Why the URL is behind mutators instead of two public fields
11
+ *
12
+ * A call has both a base URL (the host we are talking to — the OIDC audience, the thing an SSRF
13
+ * policy judges) and a full URL (base + this route's path, or an absolute Location we were
14
+ * redirected to). Exposing both as writable fields makes it possible for them to disagree, and a
15
+ * base URL that disagrees with the URL actually fetched is precisely the shape of an SSRF bypass.
16
+ * So both are private, and the only two ways to move this request are {@link pointAtBaseUrl} and
17
+ * {@link followRedirectTo}, each of which sets BOTH consistently.
18
+ *
19
+ * ## Signing
20
+ *
21
+ * {@link body} holds the serialized bytes, not the DTO — serialization happens BEFORE the chain
22
+ * runs. A signing filter computes its HMAC over `request.body` and adds a header, and the transport
23
+ * sends `request.body` verbatim, so the bytes signed and the bytes sent cannot differ. That was
24
+ * impossible while a client owned serialization internally with no seam, which is why outbound
25
+ * webhook senders were forced back to hand-rolling `JSON.stringify` and a raw HTTP library — a
26
+ * library that re-serializes internally would sign one byte sequence and send another.
27
+ */
28
+ export declare class ClientRequest {
29
+ /** The route being called — its path, http method, and auth mode. */
30
+ readonly route: RouteMetadata;
31
+ /** The API contract's class name, e.g. 'PartnerWebhookApi'. For messages and logs. */
32
+ readonly contractName: string;
33
+ /** Outbound headers. Mutable: a filter adds its signature/idempotency/tracing headers here. */
34
+ readonly headers: Map<string, string>;
35
+ /** The EXACT serialized request body, or undefined for a call with no argument. */
36
+ body: string | undefined;
37
+ /**
38
+ * The request DTO the caller passed, BEFORE serialization. Read-only, and deliberately not
39
+ * the thing that gets sent: a filter that wants to change what goes on the wire changes
40
+ * {@link body}, so there is never a question of which of the two won.
41
+ */
42
+ readonly requestDto: unknown;
43
+ /** The base URL half of {@link url} — the host, without this route's path. */
44
+ private currentBaseUrl;
45
+ /** The absolute URL this call will actually be sent to. */
46
+ private currentUrl;
47
+ /**
48
+ * FALSE while this request still points where the client's own configuration put it; TRUE the
49
+ * moment anything moved it — {@link pointAtBaseUrl} or {@link followRedirectTo}.
50
+ *
51
+ * This flag is what makes the SSRF guard automatic AND free. A URL that came out of
52
+ * `ClientRegistry` is an address WE chose, so judging it would mean resolving DNS on every
53
+ * internal RPC in order to re-derive a fact we already know. A URL a filter substituted is not:
54
+ * it arrived from outside the call — a partner-editable row, an OAuth callback, a `Location`
55
+ * header — and that is precisely the input an SSRF policy exists to judge.
56
+ *
57
+ * So the trigger is the ACT of re-pointing rather than a per-client setting, which means no app
58
+ * can forget to switch the guard on and none can switch it off by omitting an argument.
59
+ */
60
+ private rePointed;
61
+ /**
62
+ * Whether the transport may follow a 3xx itself. The SSRF guard sets this FALSE so it can read
63
+ * the `Location`, judge it under the same policy as the original URL, and only then re-invoke
64
+ * the chain — a partner URL that 302s must not be able to bounce our POST at an internal
65
+ * address.
66
+ */
67
+ followRedirects: boolean;
68
+ constructor(
69
+ /** The route being called — its path, http method, and auth mode. */
70
+ route: RouteMetadata,
71
+ /** The API contract's class name, e.g. 'PartnerWebhookApi'. For messages and logs. */
72
+ contractName: string, baseUrl: string,
73
+ /** Outbound headers. Mutable: a filter adds its signature/idempotency/tracing headers here. */
74
+ headers: Map<string, string>,
75
+ /** The EXACT serialized request body, or undefined for a call with no argument. */
76
+ body: string | undefined,
77
+ /**
78
+ * The request DTO the caller passed, BEFORE serialization. Read-only, and deliberately not
79
+ * the thing that gets sent: a filter that wants to change what goes on the wire changes
80
+ * {@link body}, so there is never a question of which of the two won.
81
+ */
82
+ requestDto: unknown);
83
+ /** The host this call is currently addressed to, with no path. */
84
+ get baseUrl(): string;
85
+ /** The absolute URL this call will be sent to. */
86
+ get url(): string;
87
+ /**
88
+ * TRUE once anything has moved this request off the address the client resolved for itself —
89
+ * i.e. once the destination is DATA rather than a peer we named. See {@link rePointed}.
90
+ */
91
+ get destinationCameFromData(): boolean;
92
+ /**
93
+ * Re-point this ONE call at another host, keeping this route's path. `ContextBaseUrlFilter`
94
+ * calls this with the URL a partner registered; nothing here is remembered by the client, so
95
+ * the next call through the same client starts from its configured host again.
96
+ *
97
+ * It also flips {@link destinationCameFromData}, which is what arms the SSRF guard sitting
98
+ * below every app filter. That coupling is deliberate and lives HERE rather than in the guard:
99
+ * a filter cannot move the request without saying so, because moving it is only possible
100
+ * through this method.
101
+ */
102
+ pointAtBaseUrl(baseUrl: string): void;
103
+ /**
104
+ * Follow a redirect to an ABSOLUTE url. The base URL becomes that url's origin, so a policy
105
+ * that judges hosts judges the host we are actually about to talk to. Flips
106
+ * {@link destinationCameFromData} for the same reason {@link pointAtBaseUrl} does: a `Location`
107
+ * header is the far end's data, not an address we chose.
108
+ *
109
+ * @throws TypeError if `absoluteUrl` is not a parseable absolute URL.
110
+ */
111
+ followRedirectTo(absoluteUrl: string): void;
112
+ /** The headers in the shape the transport wants. */
113
+ headersAsRecord(): Record<string, string>;
114
+ }
@@ -0,0 +1,138 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.ClientRequest = void 0;
4
+ /**
5
+ * The OUTBOUND request as the client filter chain sees it — the client-side twin of the server's
6
+ * `MethodMeta`, and the `REQ` of every `Filter<ClientRequest, Response>`.
7
+ *
8
+ * MUTABLE on purpose, and that is the whole point of the chain: a filter re-points the URL, adds a
9
+ * header, or replaces the serialized bytes, and whatever this object holds when the chain bottoms
10
+ * out is EXACTLY what goes on the wire.
11
+ *
12
+ * ## Why the URL is behind mutators instead of two public fields
13
+ *
14
+ * A call has both a base URL (the host we are talking to — the OIDC audience, the thing an SSRF
15
+ * policy judges) and a full URL (base + this route's path, or an absolute Location we were
16
+ * redirected to). Exposing both as writable fields makes it possible for them to disagree, and a
17
+ * base URL that disagrees with the URL actually fetched is precisely the shape of an SSRF bypass.
18
+ * So both are private, and the only two ways to move this request are {@link pointAtBaseUrl} and
19
+ * {@link followRedirectTo}, each of which sets BOTH consistently.
20
+ *
21
+ * ## Signing
22
+ *
23
+ * {@link body} holds the serialized bytes, not the DTO — serialization happens BEFORE the chain
24
+ * runs. A signing filter computes its HMAC over `request.body` and adds a header, and the transport
25
+ * sends `request.body` verbatim, so the bytes signed and the bytes sent cannot differ. That was
26
+ * impossible while a client owned serialization internally with no seam, which is why outbound
27
+ * webhook senders were forced back to hand-rolling `JSON.stringify` and a raw HTTP library — a
28
+ * library that re-serializes internally would sign one byte sequence and send another.
29
+ */
30
+ class ClientRequest {
31
+ route;
32
+ contractName;
33
+ headers;
34
+ body;
35
+ requestDto;
36
+ /** The base URL half of {@link url} — the host, without this route's path. */
37
+ currentBaseUrl;
38
+ /** The absolute URL this call will actually be sent to. */
39
+ currentUrl;
40
+ /**
41
+ * FALSE while this request still points where the client's own configuration put it; TRUE the
42
+ * moment anything moved it — {@link pointAtBaseUrl} or {@link followRedirectTo}.
43
+ *
44
+ * This flag is what makes the SSRF guard automatic AND free. A URL that came out of
45
+ * `ClientRegistry` is an address WE chose, so judging it would mean resolving DNS on every
46
+ * internal RPC in order to re-derive a fact we already know. A URL a filter substituted is not:
47
+ * it arrived from outside the call — a partner-editable row, an OAuth callback, a `Location`
48
+ * header — and that is precisely the input an SSRF policy exists to judge.
49
+ *
50
+ * So the trigger is the ACT of re-pointing rather than a per-client setting, which means no app
51
+ * can forget to switch the guard on and none can switch it off by omitting an argument.
52
+ */
53
+ rePointed = false;
54
+ /**
55
+ * Whether the transport may follow a 3xx itself. The SSRF guard sets this FALSE so it can read
56
+ * the `Location`, judge it under the same policy as the original URL, and only then re-invoke
57
+ * the chain — a partner URL that 302s must not be able to bounce our POST at an internal
58
+ * address.
59
+ */
60
+ followRedirects = true;
61
+ constructor(
62
+ /** The route being called — its path, http method, and auth mode. */
63
+ route,
64
+ /** The API contract's class name, e.g. 'PartnerWebhookApi'. For messages and logs. */
65
+ contractName, baseUrl,
66
+ /** Outbound headers. Mutable: a filter adds its signature/idempotency/tracing headers here. */
67
+ headers,
68
+ /** The EXACT serialized request body, or undefined for a call with no argument. */
69
+ body,
70
+ /**
71
+ * The request DTO the caller passed, BEFORE serialization. Read-only, and deliberately not
72
+ * the thing that gets sent: a filter that wants to change what goes on the wire changes
73
+ * {@link body}, so there is never a question of which of the two won.
74
+ */
75
+ // webpieces-disable no-any-unknown -- the request DTO's type is erased at the proxy boundary
76
+ requestDto) {
77
+ this.route = route;
78
+ this.contractName = contractName;
79
+ this.headers = headers;
80
+ this.body = body;
81
+ this.requestDto = requestDto;
82
+ this.currentBaseUrl = baseUrl;
83
+ this.currentUrl = `${baseUrl}${route.path}`;
84
+ }
85
+ /** The host this call is currently addressed to, with no path. */
86
+ get baseUrl() {
87
+ return this.currentBaseUrl;
88
+ }
89
+ /** The absolute URL this call will be sent to. */
90
+ get url() {
91
+ return this.currentUrl;
92
+ }
93
+ /**
94
+ * TRUE once anything has moved this request off the address the client resolved for itself —
95
+ * i.e. once the destination is DATA rather than a peer we named. See {@link rePointed}.
96
+ */
97
+ get destinationCameFromData() {
98
+ return this.rePointed;
99
+ }
100
+ /**
101
+ * Re-point this ONE call at another host, keeping this route's path. `ContextBaseUrlFilter`
102
+ * calls this with the URL a partner registered; nothing here is remembered by the client, so
103
+ * the next call through the same client starts from its configured host again.
104
+ *
105
+ * It also flips {@link destinationCameFromData}, which is what arms the SSRF guard sitting
106
+ * below every app filter. That coupling is deliberate and lives HERE rather than in the guard:
107
+ * a filter cannot move the request without saying so, because moving it is only possible
108
+ * through this method.
109
+ */
110
+ pointAtBaseUrl(baseUrl) {
111
+ this.currentBaseUrl = baseUrl;
112
+ this.currentUrl = `${baseUrl}${this.route.path}`;
113
+ this.rePointed = true;
114
+ }
115
+ /**
116
+ * Follow a redirect to an ABSOLUTE url. The base URL becomes that url's origin, so a policy
117
+ * that judges hosts judges the host we are actually about to talk to. Flips
118
+ * {@link destinationCameFromData} for the same reason {@link pointAtBaseUrl} does: a `Location`
119
+ * header is the far end's data, not an address we chose.
120
+ *
121
+ * @throws TypeError if `absoluteUrl` is not a parseable absolute URL.
122
+ */
123
+ followRedirectTo(absoluteUrl) {
124
+ this.currentBaseUrl = new URL(absoluteUrl).origin;
125
+ this.currentUrl = absoluteUrl;
126
+ this.rePointed = true;
127
+ }
128
+ /** The headers in the shape the transport wants. */
129
+ headersAsRecord() {
130
+ const record = {};
131
+ for (const entry of this.headers.entries()) {
132
+ record[entry[0]] = entry[1];
133
+ }
134
+ return record;
135
+ }
136
+ }
137
+ exports.ClientRequest = ClientRequest;
138
+ //# sourceMappingURL=ClientRequest.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"ClientRequest.js","sourceRoot":"","sources":["../../../../../packages/http/http-client-core/src/ClientRequest.ts"],"names":[],"mappings":";;;AAEA;;;;;;;;;;;;;;;;;;;;;;;;;GAyBG;AACH,MAAa,aAAa;IAgCF;IAEA;IAGA;IAET;IAOS;IA7CpB,8EAA8E;IACtE,cAAc,CAAS;IAE/B,2DAA2D;IACnD,UAAU,CAAS;IAE3B;;;;;;;;;;;;OAYG;IACK,SAAS,GAAG,KAAK,CAAC;IAE1B;;;;;OAKG;IACH,eAAe,GAAG,IAAI,CAAC;IAEvB;IACI,qEAAqE;IACrD,KAAoB;IACpC,sFAAsF;IACtE,YAAoB,EACpC,OAAe;IACf,+FAA+F;IAC/E,OAA4B;IAC5C,mFAAmF;IAC5E,IAAwB;IAC/B;;;;OAIG;IACH,6FAA6F;IAC7E,UAAmB;QAdnB,UAAK,GAAL,KAAK,CAAe;QAEpB,iBAAY,GAAZ,YAAY,CAAQ;QAGpB,YAAO,GAAP,OAAO,CAAqB;QAErC,SAAI,GAAJ,IAAI,CAAoB;QAOf,eAAU,GAAV,UAAU,CAAS;QAEnC,IAAI,CAAC,cAAc,GAAG,OAAO,CAAC;QAC9B,IAAI,CAAC,UAAU,GAAG,GAAG,OAAO,GAAG,KAAK,CAAC,IAAI,EAAE,CAAC;IAChD,CAAC;IAED,kEAAkE;IAClE,IAAI,OAAO;QACP,OAAO,IAAI,CAAC,cAAc,CAAC;IAC/B,CAAC;IAED,kDAAkD;IAClD,IAAI,GAAG;QACH,OAAO,IAAI,CAAC,UAAU,CAAC;IAC3B,CAAC;IAED;;;OAGG;IACH,IAAI,uBAAuB;QACvB,OAAO,IAAI,CAAC,SAAS,CAAC;IAC1B,CAAC;IAED;;;;;;;;;OASG;IACH,cAAc,CAAC,OAAe;QAC1B,IAAI,CAAC,cAAc,GAAG,OAAO,CAAC;QAC9B,IAAI,CAAC,UAAU,GAAG,GAAG,OAAO,GAAG,IAAI,CAAC,KAAK,CAAC,IAAI,EAAE,CAAC;QACjD,IAAI,CAAC,SAAS,GAAG,IAAI,CAAC;IAC1B,CAAC;IAED;;;;;;;OAOG;IACH,gBAAgB,CAAC,WAAmB;QAChC,IAAI,CAAC,cAAc,GAAG,IAAI,GAAG,CAAC,WAAW,CAAC,CAAC,MAAM,CAAC;QAClD,IAAI,CAAC,UAAU,GAAG,WAAW,CAAC;QAC9B,IAAI,CAAC,SAAS,GAAG,IAAI,CAAC;IAC1B,CAAC;IAED,oDAAoD;IACpD,eAAe;QACX,MAAM,MAAM,GAA2B,EAAE,CAAC;QAC1C,KAAK,MAAM,KAAK,IAAI,IAAI,CAAC,OAAO,CAAC,OAAO,EAAE,EAAE,CAAC;YACzC,MAAM,CAAC,KAAK,CAAC,CAAC,CAAC,CAAC,GAAG,KAAK,CAAC,CAAC,CAAC,CAAC;QAChC,CAAC;QACD,OAAO,MAAM,CAAC;IAClB,CAAC;CACJ;AA5GD,sCA4GC","sourcesContent":["import { RouteMetadata } from '@webpieces/core-util';\n\n/**\n * The OUTBOUND request as the client filter chain sees it — the client-side twin of the server's\n * `MethodMeta`, and the `REQ` of every `Filter<ClientRequest, Response>`.\n *\n * MUTABLE on purpose, and that is the whole point of the chain: a filter re-points the URL, adds a\n * header, or replaces the serialized bytes, and whatever this object holds when the chain bottoms\n * out is EXACTLY what goes on the wire.\n *\n * ## Why the URL is behind mutators instead of two public fields\n *\n * A call has both a base URL (the host we are talking to — the OIDC audience, the thing an SSRF\n * policy judges) and a full URL (base + this route's path, or an absolute Location we were\n * redirected to). Exposing both as writable fields makes it possible for them to disagree, and a\n * base URL that disagrees with the URL actually fetched is precisely the shape of an SSRF bypass.\n * So both are private, and the only two ways to move this request are {@link pointAtBaseUrl} and\n * {@link followRedirectTo}, each of which sets BOTH consistently.\n *\n * ## Signing\n *\n * {@link body} holds the serialized bytes, not the DTO — serialization happens BEFORE the chain\n * runs. A signing filter computes its HMAC over `request.body` and adds a header, and the transport\n * sends `request.body` verbatim, so the bytes signed and the bytes sent cannot differ. That was\n * impossible while a client owned serialization internally with no seam, which is why outbound\n * webhook senders were forced back to hand-rolling `JSON.stringify` and a raw HTTP library — a\n * library that re-serializes internally would sign one byte sequence and send another.\n */\nexport class ClientRequest {\n /** The base URL half of {@link url} — the host, without this route's path. */\n private currentBaseUrl: string;\n\n /** The absolute URL this call will actually be sent to. */\n private currentUrl: string;\n\n /**\n * FALSE while this request still points where the client's own configuration put it; TRUE the\n * moment anything moved it — {@link pointAtBaseUrl} or {@link followRedirectTo}.\n *\n * This flag is what makes the SSRF guard automatic AND free. A URL that came out of\n * `ClientRegistry` is an address WE chose, so judging it would mean resolving DNS on every\n * internal RPC in order to re-derive a fact we already know. A URL a filter substituted is not:\n * it arrived from outside the call — a partner-editable row, an OAuth callback, a `Location`\n * header — and that is precisely the input an SSRF policy exists to judge.\n *\n * So the trigger is the ACT of re-pointing rather than a per-client setting, which means no app\n * can forget to switch the guard on and none can switch it off by omitting an argument.\n */\n private rePointed = false;\n\n /**\n * Whether the transport may follow a 3xx itself. The SSRF guard sets this FALSE so it can read\n * the `Location`, judge it under the same policy as the original URL, and only then re-invoke\n * the chain — a partner URL that 302s must not be able to bounce our POST at an internal\n * address.\n */\n followRedirects = true;\n\n constructor(\n /** The route being called — its path, http method, and auth mode. */\n public readonly route: RouteMetadata,\n /** The API contract's class name, e.g. 'PartnerWebhookApi'. For messages and logs. */\n public readonly contractName: string,\n baseUrl: string,\n /** Outbound headers. Mutable: a filter adds its signature/idempotency/tracing headers here. */\n public readonly headers: Map<string, string>,\n /** The EXACT serialized request body, or undefined for a call with no argument. */\n public body: string | undefined,\n /**\n * The request DTO the caller passed, BEFORE serialization. Read-only, and deliberately not\n * the thing that gets sent: a filter that wants to change what goes on the wire changes\n * {@link body}, so there is never a question of which of the two won.\n */\n // webpieces-disable no-any-unknown -- the request DTO's type is erased at the proxy boundary\n public readonly requestDto: unknown,\n ) {\n this.currentBaseUrl = baseUrl;\n this.currentUrl = `${baseUrl}${route.path}`;\n }\n\n /** The host this call is currently addressed to, with no path. */\n get baseUrl(): string {\n return this.currentBaseUrl;\n }\n\n /** The absolute URL this call will be sent to. */\n get url(): string {\n return this.currentUrl;\n }\n\n /**\n * TRUE once anything has moved this request off the address the client resolved for itself —\n * i.e. once the destination is DATA rather than a peer we named. See {@link rePointed}.\n */\n get destinationCameFromData(): boolean {\n return this.rePointed;\n }\n\n /**\n * Re-point this ONE call at another host, keeping this route's path. `ContextBaseUrlFilter`\n * calls this with the URL a partner registered; nothing here is remembered by the client, so\n * the next call through the same client starts from its configured host again.\n *\n * It also flips {@link destinationCameFromData}, which is what arms the SSRF guard sitting\n * below every app filter. That coupling is deliberate and lives HERE rather than in the guard:\n * a filter cannot move the request without saying so, because moving it is only possible\n * through this method.\n */\n pointAtBaseUrl(baseUrl: string): void {\n this.currentBaseUrl = baseUrl;\n this.currentUrl = `${baseUrl}${this.route.path}`;\n this.rePointed = true;\n }\n\n /**\n * Follow a redirect to an ABSOLUTE url. The base URL becomes that url's origin, so a policy\n * that judges hosts judges the host we are actually about to talk to. Flips\n * {@link destinationCameFromData} for the same reason {@link pointAtBaseUrl} does: a `Location`\n * header is the far end's data, not an address we chose.\n *\n * @throws TypeError if `absoluteUrl` is not a parseable absolute URL.\n */\n followRedirectTo(absoluteUrl: string): void {\n this.currentBaseUrl = new URL(absoluteUrl).origin;\n this.currentUrl = absoluteUrl;\n this.rePointed = true;\n }\n\n /** The headers in the shape the transport wants. */\n headersAsRecord(): Record<string, string> {\n const record: Record<string, string> = {};\n for (const entry of this.headers.entries()) {\n record[entry[0]] = entry[1];\n }\n return record;\n }\n}\n"]}
@@ -1,5 +1,6 @@
1
1
  import { AuthMeta, DestinationTrust, RouteMetadata, LogApiCallImpl } from '@webpieces/core-util';
2
2
  import { ApiPrototype } from './ApiPrototype';
3
+ import { ClientFilterDefinition } from './ClientFilter';
3
4
  import { RequestOutcome } from './RequestOutcome';
4
5
  import { TranslatedFailure } from './TranslatedFailure';
5
6
  /**
@@ -26,6 +27,20 @@ export declare abstract class ProxyClient {
26
27
  protected readonly logApiCall: LogApiCallImpl;
27
28
  private routeMap;
28
29
  private apiName;
30
+ /**
31
+ * The OUTBOUND filter chain, built once at bind time from {@link clientFilters} and reused for
32
+ * every call. Built once rather than per call because a filter is STATELESS by contract (the
33
+ * per-call state is the {@link ClientRequest} the chain is handed), exactly as on the server.
34
+ */
35
+ private chain;
36
+ /**
37
+ * The app's own filters, as handed to `createRpcClient`. Set by {@link initRoutes} BEFORE it
38
+ * calls {@link clientFilters}, so an environment's built-ins may read the app's intent off them
39
+ * — @webpieces/http-client-node takes the SSRF policy from an installed `ContextBaseUrlFilter`
40
+ * that way, which keeps the one legitimate relaxation at the same construction site as the
41
+ * decision to be re-pointable at all.
42
+ */
43
+ protected appFilters: ClientFilterDefinition[];
29
44
  private readonly networkRejectClassifier;
30
45
  private readonly bodyReader;
31
46
  constructor(logApiCall?: LogApiCallImpl);
@@ -47,12 +62,6 @@ export declare abstract class ProxyClient {
47
62
  * and this abstract member is left unimplemented.
48
63
  */
49
64
  protected abstract outboundContextHeaders(destination: DestinationTrust): Map<string, string>;
50
- /**
51
- * Attach the endpoint's outbound credential. Service-to-service auth (@AuthOidc bearer,
52
- * @AuthSharedSecret value) is a SERVER concept; a browser has neither a minter nor a Secrets
53
- * store, so it attaches nothing and its user JWT simply travels as a transferred context key.
54
- */
55
- protected attachOutboundAuth(_route: RouteMetadata, _baseUrl: string, _httpHeaders: Record<string, string>): Promise<void>;
56
65
  /**
57
66
  * Run the call. The default just logs it. Test-case RECORDING is a server concept, so
58
67
  * NodeProxyClient overrides this to capture the call when a recorder is in the context.
@@ -66,6 +75,19 @@ export declare abstract class ProxyClient {
66
75
  * first call in production. The default accepts everything.
67
76
  */
68
77
  protected assertEndpointSupported(_authMeta: AuthMeta | undefined, _methodName: string): void;
78
+ /**
79
+ * The FRAMEWORK filters this environment installs on every client it builds, BENEATH whatever
80
+ * the app passed to `createRpcClient`. The default installs none, so the browser runs the exact
81
+ * code path it ran before the chain existed.
82
+ *
83
+ * "Beneath" is not a priority — see {@link initRoutes}. These are the filters that must judge
84
+ * and sign what is ACTUALLY about to be sent, so no app priority may be allowed to get under
85
+ * them: @webpieces/http-client-node installs its SSRF guard and its outbound-auth minter here,
86
+ * and both would be defeated by an app filter that re-pointed the URL below them. Neither
87
+ * concept can live in this class, because reading a RequestContext, resolving DNS and minting
88
+ * an OIDC token are all things a browser bundle must never contain.
89
+ */
90
+ protected clientFilters(): ClientFilterDefinition[];
69
91
  /**
70
92
  * Adapt a translated downstream failure into the error THIS environment's caller should see.
71
93
  *
@@ -118,10 +140,12 @@ export declare abstract class ProxyClient {
118
140
  * build the route map once. Each subclass's `init(api, config)` stores its own config, then
119
141
  * calls this.
120
142
  *
143
+ * @param appFilters the app's OUTBOUND filters for this client, from `createRpcClient`. They are
144
+ * merged with {@link clientFilters} and sorted by priority, highest OUTERMOST.
121
145
  * @throws Error if the prototype lacks @ApiPath, or declares an endpoint this environment
122
146
  * cannot satisfy (see {@link assertEndpointSupported}).
123
147
  */
124
- protected initRoutes(apiPrototype: ApiPrototype<object>): void;
148
+ protected initRoutes(apiPrototype: ApiPrototype<object>, appFilters: ClientFilterDefinition[]): void;
125
149
  /** The contract's class name, for logs and recordings. */
126
150
  protected contractName(): string;
127
151
  /** Check if a route exists for the given method name. */
@@ -155,6 +179,18 @@ export declare abstract class ProxyClient {
155
179
  * though the caller sees an exception.
156
180
  */
157
181
  private executeFetch;
182
+ /**
183
+ * ONE transmission — the bottom of the filter chain, and the only place `fetch` is called.
184
+ *
185
+ * Everything it sends comes off the {@link ClientRequest} as the chain left it, so a filter's
186
+ * edits to the url, the headers or the serialized body are exactly what goes on the wire. It may
187
+ * run more than once for a single RPC when a filter follows a redirect.
188
+ *
189
+ * A network reject (offline, DNS, CORS preflight) is classified into a typed OfflineError here (a
190
+ * genuine bug passes through untouched) so that filters above see the same typed error the caller
191
+ * will, rather than a raw platform reject.
192
+ */
193
+ private sendOnce;
158
194
  /**
159
195
  * Read a 2xx body, reporting the END marker on both outcomes.
160
196
  *
@@ -2,6 +2,7 @@
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
3
  exports.ProxyClient = void 0;
4
4
  const core_util_1 = require("@webpieces/core-util");
5
+ const ClientRequest_1 = require("./ClientRequest");
5
6
  const ClientErrorTranslator_1 = require("./ClientErrorTranslator");
6
7
  const RequestOutcome_1 = require("./RequestOutcome");
7
8
  const ResponseBodyReader_1 = require("./ResponseBodyReader");
@@ -30,6 +31,20 @@ class ProxyClient {
30
31
  // Assigned by initRoutes(), which every subclass's init() calls immediately after construction.
31
32
  routeMap;
32
33
  apiName;
34
+ /**
35
+ * The OUTBOUND filter chain, built once at bind time from {@link clientFilters} and reused for
36
+ * every call. Built once rather than per call because a filter is STATELESS by contract (the
37
+ * per-call state is the {@link ClientRequest} the chain is handed), exactly as on the server.
38
+ */
39
+ chain;
40
+ /**
41
+ * The app's own filters, as handed to `createRpcClient`. Set by {@link initRoutes} BEFORE it
42
+ * calls {@link clientFilters}, so an environment's built-ins may read the app's intent off them
43
+ * — @webpieces/http-client-node takes the SSRF policy from an installed `ContextBaseUrlFilter`
44
+ * that way, which keeps the one legitimate relaxation at the same construction site as the
45
+ * decision to be re-pointable at all.
46
+ */
47
+ appFilters = [];
33
48
  // Stateless + dependency-free, so the browser bundle keeps no DI on the fetch path.
34
49
  networkRejectClassifier = new core_util_1.NetworkRejectClassifier();
35
50
  // Same shape and same reason: stateless, so it is constructed here rather than injected.
@@ -37,12 +52,6 @@ class ProxyClient {
37
52
  constructor(logApiCall = core_util_1.LogApiCall) {
38
53
  this.logApiCall = logApiCall;
39
54
  }
40
- /**
41
- * Attach the endpoint's outbound credential. Service-to-service auth (@AuthOidc bearer,
42
- * @AuthSharedSecret value) is a SERVER concept; a browser has neither a minter nor a Secrets
43
- * store, so it attaches nothing and its user JWT simply travels as a transferred context key.
44
- */
45
- async attachOutboundAuth(_route, _baseUrl, _httpHeaders) { }
46
55
  /**
47
56
  * Run the call. The default just logs it. Test-case RECORDING is a server concept, so
48
57
  * NodeProxyClient overrides this to capture the call when a recorder is in the context.
@@ -64,6 +73,21 @@ class ProxyClient {
64
73
  * first call in production. The default accepts everything.
65
74
  */
66
75
  assertEndpointSupported(_authMeta, _methodName) { }
76
+ /**
77
+ * The FRAMEWORK filters this environment installs on every client it builds, BENEATH whatever
78
+ * the app passed to `createRpcClient`. The default installs none, so the browser runs the exact
79
+ * code path it ran before the chain existed.
80
+ *
81
+ * "Beneath" is not a priority — see {@link initRoutes}. These are the filters that must judge
82
+ * and sign what is ACTUALLY about to be sent, so no app priority may be allowed to get under
83
+ * them: @webpieces/http-client-node installs its SSRF guard and its outbound-auth minter here,
84
+ * and both would be defeated by an app filter that re-pointed the URL below them. Neither
85
+ * concept can live in this class, because reading a RequestContext, resolving DNS and minting
86
+ * an OIDC token are all things a browser bundle must never contain.
87
+ */
88
+ clientFilters() {
89
+ return [];
90
+ }
67
91
  /**
68
92
  * Fires immediately BEFORE `fetch`, once per RPC — the progress "start marker". Symmetric with
69
93
  * {@link onRequestEnd}: every start is followed by exactly one end, on every path, so a listener
@@ -91,10 +115,13 @@ class ProxyClient {
91
115
  * build the route map once. Each subclass's `init(api, config)` stores its own config, then
92
116
  * calls this.
93
117
  *
118
+ * @param appFilters the app's OUTBOUND filters for this client, from `createRpcClient`. They are
119
+ * merged with {@link clientFilters} and sorted by priority, highest OUTERMOST.
94
120
  * @throws Error if the prototype lacks @ApiPath, or declares an endpoint this environment
95
121
  * cannot satisfy (see {@link assertEndpointSupported}).
96
122
  */
97
- initRoutes(apiPrototype) {
123
+ initRoutes(apiPrototype, appFilters) {
124
+ this.appFilters = appFilters;
98
125
  if (!(0, core_util_1.isApiPath)(apiPrototype)) {
99
126
  const className = apiPrototype.name || 'Unknown';
100
127
  throw new Error(`Class ${className} must be decorated with @ApiPath()`);
@@ -113,6 +140,18 @@ class ProxyClient {
113
140
  const formPost = (0, core_util_1.isFormPost)(apiPrototype, methodName);
114
141
  this.routeMap.set(methodName, new core_util_1.RouteMetadata('POST', fullPath, methodName, this.apiName, authMeta, undefined, formPost, (0, core_util_1.getMaskSpec)(apiPrototype, methodName), (0, core_util_1.isRawBody)(apiPrototype, methodName)));
115
142
  }
143
+ // APP filters first (highest priority OUTERMOST, matching the server's FilterMatcher), then
144
+ // the framework built-ins, ALWAYS innermost. Two separate sorts rather than one over the
145
+ // union, deliberately: an app priority orders app filters against each other and nothing
146
+ // else, so no number an app can type — however large — gets underneath the SSRF guard or the
147
+ // credential minter. A single sorted list would make "displace the guard" a matter of typing
148
+ // a bigger integer, and a security control an app can outrank by accident is not a control.
149
+ //
150
+ // Sorted here, once, so FilterChain itself never sorts — priority lives on the DEFINITION,
151
+ // not on the filter.
152
+ const byPriority = (a, b) => b.priority - a.priority;
153
+ const ordered = [...[...this.appFilters].sort(byPriority), ...[...this.clientFilters()].sort(byPriority)];
154
+ this.chain = new core_util_1.FilterChain(ordered.map((definition) => definition.filter));
116
155
  }
117
156
  /** The contract's class name, for logs and recordings. */
118
157
  contractName() {
@@ -159,12 +198,11 @@ class ProxyClient {
159
198
  `holding that api key can call it, and the header carrying it is the app's ApiKeyHook's choice, ` +
160
199
  `so a webpieces client has no credential to send.`);
161
200
  }
162
- // @AuthWebhook: verified by the VENDOR's signature over the request, which nothing here can produce.
163
- if (authMode?.kind === 'webhook') {
164
- throw new Error(`${this.apiName}.${route.methodName} is @AuthWebhook('${authMode.name}') — only ` +
165
- `${authMode.name} can call it, because only ${authMode.name} can produce the signature ` +
166
- `its WebhookAuthCallback verifies. It is not callable from a webpieces client.`);
167
- }
201
+ // @AuthWebhook is DELIBERATELY absent from this list. It used to be here, on the assumption
202
+ // that the vendor is always somebody else — but `@AuthWebhook(name)` names a signing SCHEME,
203
+ // not a direction, and for an OUTBOUND partner webhook WE are the vendor. The environment's
204
+ // outbound-auth filter asks its bound signer to produce the signature, which is the exact
205
+ // mirror of the inbound WebhookAuthCallback that verifies one.
168
206
  }
169
207
  /**
170
208
  * Make an HTTP request based on route metadata and arguments.
@@ -176,33 +214,34 @@ class ProxyClient {
176
214
  this.refuseEndpointNoClientCanCall(route);
177
215
  // Resolved per call (memoized underneath on a server), so building a client stayed synchronous.
178
216
  const baseUrl = await this.resolveBaseUrl();
179
- const url = `${baseUrl}${route.path}`;
180
- const httpHeaders = {
181
- 'Content-Type': 'application/json',
182
- };
217
+ const httpHeaders = new Map([['Content-Type', 'application/json']]);
183
218
  // Transferred context, request-id chained. The server impl throws here when there is no
184
219
  // active RequestContext — an outbound call with no trace is a bug, not a default. The
185
220
  // destination's own auth mode decides whether trusted keys are part of that set.
186
221
  const contextHeaders = this.outboundContextHeaders(core_util_1.DestinationTrust.forAuthMode(route.authMeta?.mode));
187
222
  for (const entry of contextHeaders.entries()) {
188
- httpHeaders[entry[0]] = entry[1];
223
+ httpHeaders.set(entry[0], entry[1]);
189
224
  }
190
- await this.attachOutboundAuth(route, baseUrl, httpHeaders);
191
- const options = {
192
- method: route.httpMethod,
193
- headers: httpHeaders,
194
- };
195
- // POST body is the first argument as JSON
225
+ // NOTHING mints a credential here. The endpoint's outbound auth is a FILTER, sitting at the
226
+ // very bottom of the chain, because the URL at this point is only where the call STARTS: an
227
+ // app filter above may re-point it, and an OIDC token whose audience is the pre-filter URL
228
+ // is a token for the wrong peer. The minter has to run last, against the settled
229
+ // destination — see the environment's `clientFilters()`.
230
+ //
231
+ // POST body is the first argument as JSON. Serialized HERE, before the filter chain, so a
232
+ // filter that signs the request signs the exact bytes {@link sendOnce} will transmit.
196
233
  // webpieces-disable no-any-unknown -- the request DTO's type is erased at the proxy boundary
197
234
  let requestDto;
235
+ let body;
198
236
  if (args.length > 0) {
199
237
  requestDto = args[0];
200
- options.body = JSON.stringify(requestDto);
238
+ body = JSON.stringify(requestDto);
201
239
  }
202
- // Wrap fetch in a method for LogApiCall.execute
240
+ const request = new ClientRequest_1.ClientRequest(route, this.apiName, baseUrl, httpHeaders, body, requestDto);
241
+ // Wrap the send in a method for LogApiCall.execute
203
242
  // webpieces-disable no-any-unknown -- the response DTO's type is erased at the proxy boundary
204
243
  const method = async () => {
205
- return this.executeFetch(url, options, route);
244
+ return this.executeFetch(request);
206
245
  };
207
246
  return await this.execute(route, requestDto, method);
208
247
  }
@@ -215,24 +254,26 @@ class ProxyClient {
215
254
  * though the caller sees an exception.
216
255
  */
217
256
  // webpieces-disable no-any-unknown -- the response DTO's type is erased at the proxy boundary
218
- async executeFetch(url, options, route) {
257
+ async executeFetch(request) {
258
+ const route = request.route;
219
259
  this.onRequestStart(route);
220
- // A network reject (offline, DNS, CORS preflight) means no Response ever existed, so there is
221
- // no status and no headers to report — only status 0 and the failure itself. toNetworkError
222
- // turns that reject into a typed OfflineError (a genuine bug passes through untouched), and we
223
- // classify BEFORE onRequestEnd so a lifecycle listener sees the SAME typed error the caller will.
260
+ // The START marker fires ONCE per RPC even though a filter may send more than once (the SSRF
261
+ // guard re-invokes the chain to follow a validated redirect) — start and end still pair up
262
+ // exactly, which is what lets a listener drive a progress counter.
224
263
  let response;
225
- // webpieces-disable no-unmanaged-exceptions -- translate a network reject into a lifecycle END, then rethrow
264
+ // webpieces-disable no-unmanaged-exceptions -- translate a send failure into a lifecycle END, then rethrow
226
265
  // eslint-disable-next-line @webpieces/no-unmanaged-exceptions
227
266
  try {
228
- // webpieces-disable no-fetch -- this IS the generated-client implementation the rule points everyone to
229
- response = await fetch(url, options);
267
+ response = await this.chain.execute(request, () => this.sendOnce(request));
230
268
  }
231
269
  catch (err) {
270
+ // No Response ever existed — a network reject already classified by sendOnce, or a filter
271
+ // that refused to send at all (an SSRF policy rejecting a partner's URL). Either way there
272
+ // is no status and no headers to report, only status 0 and the failure itself, and the
273
+ // lifecycle listener must see the SAME error the caller is about to.
232
274
  const error = (0, core_util_1.toError)(err);
233
- const networkError = this.networkRejectClassifier.toNetworkError(error, url);
234
- this.onRequestEnd(route, new RequestOutcome_1.RequestOutcome(false, 0, undefined, networkError));
235
- throw networkError;
275
+ this.onRequestEnd(route, new RequestOutcome_1.RequestOutcome(false, 0, undefined, error));
276
+ throw error;
236
277
  }
237
278
  const callId = `${this.apiName}.${route.methodName}`;
238
279
  if (response.ok) {
@@ -240,6 +281,37 @@ class ProxyClient {
240
281
  }
241
282
  throw await this.endWithTypedFailure(response, route, callId);
242
283
  }
284
+ /**
285
+ * ONE transmission — the bottom of the filter chain, and the only place `fetch` is called.
286
+ *
287
+ * Everything it sends comes off the {@link ClientRequest} as the chain left it, so a filter's
288
+ * edits to the url, the headers or the serialized body are exactly what goes on the wire. It may
289
+ * run more than once for a single RPC when a filter follows a redirect.
290
+ *
291
+ * A network reject (offline, DNS, CORS preflight) is classified into a typed OfflineError here (a
292
+ * genuine bug passes through untouched) so that filters above see the same typed error the caller
293
+ * will, rather than a raw platform reject.
294
+ */
295
+ async sendOnce(request) {
296
+ const options = {
297
+ method: request.route.httpMethod,
298
+ headers: request.headersAsRecord(),
299
+ redirect: request.followRedirects ? 'follow' : 'manual',
300
+ };
301
+ if (request.body !== undefined) {
302
+ options.body = request.body;
303
+ }
304
+ // webpieces-disable no-unmanaged-exceptions -- classify a network reject, then rethrow it typed
305
+ // eslint-disable-next-line @webpieces/no-unmanaged-exceptions
306
+ try {
307
+ // webpieces-disable no-fetch -- this IS the generated-client implementation the rule points everyone to
308
+ return await fetch(request.url, options);
309
+ }
310
+ catch (err) {
311
+ const error = (0, core_util_1.toError)(err);
312
+ throw this.networkRejectClassifier.toNetworkError(error, request.url);
313
+ }
314
+ }
243
315
  /**
244
316
  * Read a 2xx body, reporting the END marker on both outcomes.
245
317
  *
@@ -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 isRawBody,\n getMaskSpec,\n AuthMeta,\n DestinationTrust,\n RouteMetadata,\n LogApiCall,\n LogApiCallImpl,\n ApiMethodInfo,\n toError,\n NetworkRejectClassifier,\n} from '@webpieces/core-util';\nimport { ApiPrototype } from './ApiPrototype';\nimport { ClientErrorTranslator } from './ClientErrorTranslator';\nimport { RequestOutcome } from './RequestOutcome';\nimport { ResponseBodyReader } from './ResponseBodyReader';\nimport { TranslatedFailure } from './TranslatedFailure';\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 // Stateless + dependency-free, so the browser bundle keeps no DI on the fetch path.\n private readonly networkRejectClassifier = new NetworkRejectClassifier();\n\n // Same shape and same reason: stateless, so it is constructed here rather than injected.\n private readonly bodyReader = new ResponseBodyReader();\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 /**\n * Context headers to put on the wire. Server reads RequestContext; browser reads its store.\n *\n * `destination` is derived from THIS route's auth mode and decides whether TRUSTED context keys\n * (`x-user-id`, `x-org-id`, `x-webpieces-roles`) may ride along — see {@link DestinationTrust}.\n * It is a required argument on purpose: a defaulted \"send everything\" would put the permissive\n * answer one keystroke away and make the safe one opt-in.\n *\n * RENAMED from `outboundHeaders()` in the same change that added `destination`, and the rename IS\n * the migration. TypeScript accepts an override that declares FEWER parameters than its base, so a\n * downstream `protected override outboundHeaders(): Map<string, string>` would have kept compiling\n * and silently ignored the gate — the permissive behaviour surviving as a second spelling. Against\n * the NEW name that subclass fails twice over: `override` names a member the base no longer has,\n * and this abstract member is left unimplemented.\n */\n protected abstract outboundContextHeaders(destination: DestinationTrust): 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, undefined, route.mask);\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 * Adapt a translated downstream failure into the error THIS environment's caller should see.\n *\n * THE INVARIANT, and the reason this hook exists at all:\n *\n * A status received from a downstream dependency describes OUR request to it. It is never the\n * status we return to OUR caller. The server that answered 404 is correct; the server that\n * asked for a route that does not exist is broken, and must say so as a 500.\n *\n * That invariant reads differently in the two environments, which is exactly why the ISOMORPHIC\n * {@link ClientErrorTranslator} cannot settle it:\n * - BROWSER: the client IS the end user's agent, so the downstream IS the answer. Pass it through\n * unchanged.\n * - NODE: server-to-server. A 4xx from a dependency is a caller-side defect (wrong path, wrong\n * base URL, an undeployed dependency, bad service credentials), so the caller owns it as a 500.\n *\n * ABSTRACT, not a defaulted pass-through, for the same reason\n * {@link outboundContextHeaders} takes a required `destination`: a permissive default puts the\n * wrong answer one keystroke away. A new environment subclass must SAY which of the two it is,\n * and there are exactly two subclasses in the repo, so the compile error is the migration.\n *\n * @param failure - the translated error, its provenance (app-registered vs built-in), and the\n * downstream status\n * @param callId - `ApiName.methodName`, so a rewritten message can still name the call\n */\n protected abstract adaptDownstreamFailure(failure: TranslatedFailure, callId: string): Error;\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(\n 'POST', fullPath, methodName, this.apiName, authMeta, undefined, formPost,\n getMaskSpec(apiPrototype, methodName), isRawBody(apiPrototype, methodName),\n ),\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 * FAIL FAST, PER METHOD, at call time: some endpoints exist for a caller that is not us, and this\n * proxy could only ever build a request they are obliged to reject. Refusing here rather than at\n * bind time means an api that MIXES such endpoints with normal ones still yields a working client\n * for the normal ones; only calling the un-callable method throws.\n *\n * @throws Error naming the endpoint, what it declared, and who its real caller is.\n */\n private refuseEndpointNoClientCanCall(route: RouteMetadata): void {\n // formPost exists ONLY for EXTERNAL inbound webhooks (e.g. Twilio is the caller). This proxy\n // JSON.stringifies the body, so calling one would silently send a wrong-encoded body.\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 const authMode = route.authMeta?.mode;\n // @AuthApiKey: the credential is a CUSTOMER-held key, and the header carrying it is the app's\n // ApiKeyHook's choice, so this client has nothing to send and the call is a guaranteed 401.\n if (authMode?.kind === 'apikey') {\n throw new Error(\n `${this.apiName}.${route.methodName} is @AuthApiKey('${authMode.regime}') — only the partner ` +\n `holding that api key can call it, and the header carrying it is the app's ApiKeyHook's choice, ` +\n `so a webpieces client has no credential to send.`,\n );\n }\n // @AuthWebhook: verified by the VENDOR's signature over the request, which nothing here can produce.\n if (authMode?.kind === 'webhook') {\n throw new Error(\n `${this.apiName}.${route.methodName} is @AuthWebhook('${authMode.name}') — only ` +\n `${authMode.name} can call it, because only ${authMode.name} can produce the signature ` +\n `its WebhookAuthCallback verifies. It is not callable from a webpieces client.`,\n );\n }\n }\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 this.refuseEndpointNoClientCanCall(route);\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. The\n // destination's own auth mode decides whether trusted keys are part of that set.\n const contextHeaders = this.outboundContextHeaders(DestinationTrust.forAuthMode(route.authMeta?.mode));\n for (const entry of contextHeaders.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. toNetworkError\n // turns that reject into a typed OfflineError (a genuine bug passes through untouched), and we\n // classify BEFORE onRequestEnd so a lifecycle listener sees the SAME typed error the caller will.\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 const networkError = this.networkRejectClassifier.toNetworkError(error, url);\n this.onRequestEnd(route, new RequestOutcome(false, 0, undefined, networkError));\n throw networkError;\n }\n\n const callId = `${this.apiName}.${route.methodName}`;\n if (response.ok) {\n return this.readSuccessBody(response, route, callId);\n }\n throw await this.endWithTypedFailure(response, route, callId);\n }\n\n /**\n * Read a 2xx body, reporting the END marker on both outcomes.\n *\n * The content-type gate is the same one the error path uses: a 2xx that is not JSON (a proxy's\n * captive-portal page, an SPA index.html served by a misrouted CDN) is reported for WHAT ARRIVED,\n * instead of `SyntaxError: Unexpected token '<'`, which names nothing a reader can act on.\n */\n // webpieces-disable no-any-unknown -- the response DTO's type is erased at the proxy boundary\n private async readSuccessBody(response: Response, route: RouteMetadata, callId: string): Promise<unknown> {\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 if (!this.bodyReader.isJson(response)) {\n throw new Error(this.bodyReader.describeForeignBody(response, callId, await response.text()));\n }\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 /**\n * Turn a non-2xx response into the error the caller will see, firing the END marker first — so a\n * listener always gets its stop marker even though the caller sees an exception. RETURNS the\n * error rather than throwing it, which keeps the one `throw` visible at the call site.\n *\n * The headers still reach the seam here, so a version (or any future) header is observed even on\n * error responses.\n *\n * The body is read through {@link ResponseBodyReader}, which parses ONLY a body whose\n * content-type says it is JSON. An infra 502/503/504 (load balancer, proxy, cold start on a\n * scale-to-zero backend) serves HTML, and parsing that used to throw `SyntaxError: Unexpected\n * token '<'` — discarding the status, so the caller could not tell a booting server from a broken\n * client. It now becomes a synthesized ProtocolError translated BY STATUS, i.e. a real\n * `HttpBadGatewayError` / `HttpServiceUnavailableError` / `HttpGatewayTimeoutError`.\n *\n * The try/catch stays, for a NARROWER job than before: a body that DECLARED json and was\n * malformed still throws (that one is a genuine server bug), and the END marker must fire for it\n * too — an unreported end leaves the app's progress bar spinning forever.\n *\n * `translated` is what ClientErrorTranslator picked, and translateError RETURNS a\n * {@link TranslatedFailure} — so nothing in this seam is ever `unknown`.\n *\n * The translated failure then goes through {@link adaptDownstreamFailure}, which is where the two\n * environments part company (browser rethrows it, node turns a downstream 4xx into its own 500).\n *\n * The RequestOutcome reported to {@link onRequestEnd} carries the POST-adapt error, deliberately:\n * a lifecycle listener must see the SAME error the caller sees, or a progress bar / error toast\n * says 404 while the thrown exception says 500. That is the identical rule the network-reject path\n * already follows (it classifies BEFORE onRequestEnd for exactly this reason). The pre-adapt error\n * is not lost — it is the adapted error's `httpCause`.\n */\n private async endWithTypedFailure(response: Response, route: RouteMetadata, callId: string): Promise<Error> {\n let translated: TranslatedFailure;\n // webpieces-disable no-unmanaged-exceptions -- a malformed JSON error body must still report the END marker\n // eslint-disable-next-line @webpieces/no-unmanaged-exceptions\n try {\n const protocolError = await this.bodyReader.readErrorBody(response, callId);\n translated = ClientErrorTranslator.translateError(response, protocolError);\n } catch (err: unknown) {\n const error = toError(err);\n // The response CLAIMED JSON and was not parseable — report that failure as the outcome.\n // It never reaches adaptDownstreamFailure: there is no translated status to adapt, and a\n // body that broke its own content-type promise is already a defect, not a status answer.\n this.onRequestEnd(route, new RequestOutcome(false, response.status, response.headers, error));\n return error;\n }\n\n const adapted = this.adaptDownstreamFailure(translated, callId);\n this.onRequestEnd(route, new RequestOutcome(false, response.status, response.headers, adapted));\n return adapted;\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 isRawBody,\n getMaskSpec,\n AuthMeta,\n DestinationTrust,\n RouteMetadata,\n LogApiCall,\n LogApiCallImpl,\n ApiMethodInfo,\n toError,\n NetworkRejectClassifier,\n FilterChain,\n} from '@webpieces/core-util';\nimport { ApiPrototype } from './ApiPrototype';\nimport { ClientFilterDefinition } from './ClientFilter';\nimport { ClientRequest } from './ClientRequest';\nimport { ClientErrorTranslator } from './ClientErrorTranslator';\nimport { RequestOutcome } from './RequestOutcome';\nimport { ResponseBodyReader } from './ResponseBodyReader';\nimport { TranslatedFailure } from './TranslatedFailure';\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 /**\n * The OUTBOUND filter chain, built once at bind time from {@link clientFilters} and reused for\n * every call. Built once rather than per call because a filter is STATELESS by contract (the\n * per-call state is the {@link ClientRequest} the chain is handed), exactly as on the server.\n */\n private chain!: FilterChain<ClientRequest, Response>;\n\n /**\n * The app's own filters, as handed to `createRpcClient`. Set by {@link initRoutes} BEFORE it\n * calls {@link clientFilters}, so an environment's built-ins may read the app's intent off them\n * — @webpieces/http-client-node takes the SSRF policy from an installed `ContextBaseUrlFilter`\n * that way, which keeps the one legitimate relaxation at the same construction site as the\n * decision to be re-pointable at all.\n */\n protected appFilters: ClientFilterDefinition[] = [];\n\n // Stateless + dependency-free, so the browser bundle keeps no DI on the fetch path.\n private readonly networkRejectClassifier = new NetworkRejectClassifier();\n\n // Same shape and same reason: stateless, so it is constructed here rather than injected.\n private readonly bodyReader = new ResponseBodyReader();\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 /**\n * Context headers to put on the wire. Server reads RequestContext; browser reads its store.\n *\n * `destination` is derived from THIS route's auth mode and decides whether TRUSTED context keys\n * (`x-user-id`, `x-org-id`, `x-webpieces-roles`) may ride along — see {@link DestinationTrust}.\n * It is a required argument on purpose: a defaulted \"send everything\" would put the permissive\n * answer one keystroke away and make the safe one opt-in.\n *\n * RENAMED from `outboundHeaders()` in the same change that added `destination`, and the rename IS\n * the migration. TypeScript accepts an override that declares FEWER parameters than its base, so a\n * downstream `protected override outboundHeaders(): Map<string, string>` would have kept compiling\n * and silently ignored the gate — the permissive behaviour surviving as a second spelling. Against\n * the NEW name that subclass fails twice over: `override` names a member the base no longer has,\n * and this abstract member is left unimplemented.\n */\n protected abstract outboundContextHeaders(destination: DestinationTrust): Map<string, string>;\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, undefined, route.mask);\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 * The FRAMEWORK filters this environment installs on every client it builds, BENEATH whatever\n * the app passed to `createRpcClient`. The default installs none, so the browser runs the exact\n * code path it ran before the chain existed.\n *\n * \"Beneath\" is not a priority — see {@link initRoutes}. These are the filters that must judge\n * and sign what is ACTUALLY about to be sent, so no app priority may be allowed to get under\n * them: @webpieces/http-client-node installs its SSRF guard and its outbound-auth minter here,\n * and both would be defeated by an app filter that re-pointed the URL below them. Neither\n * concept can live in this class, because reading a RequestContext, resolving DNS and minting\n * an OIDC token are all things a browser bundle must never contain.\n */\n protected clientFilters(): ClientFilterDefinition[] {\n return [];\n }\n\n /**\n * Adapt a translated downstream failure into the error THIS environment's caller should see.\n *\n * THE INVARIANT, and the reason this hook exists at all:\n *\n * A status received from a downstream dependency describes OUR request to it. It is never the\n * status we return to OUR caller. The server that answered 404 is correct; the server that\n * asked for a route that does not exist is broken, and must say so as a 500.\n *\n * That invariant reads differently in the two environments, which is exactly why the ISOMORPHIC\n * {@link ClientErrorTranslator} cannot settle it:\n * - BROWSER: the client IS the end user's agent, so the downstream IS the answer. Pass it through\n * unchanged.\n * - NODE: server-to-server. A 4xx from a dependency is a caller-side defect (wrong path, wrong\n * base URL, an undeployed dependency, bad service credentials), so the caller owns it as a 500.\n *\n * ABSTRACT, not a defaulted pass-through, for the same reason\n * {@link outboundContextHeaders} takes a required `destination`: a permissive default puts the\n * wrong answer one keystroke away. A new environment subclass must SAY which of the two it is,\n * and there are exactly two subclasses in the repo, so the compile error is the migration.\n *\n * @param failure - the translated error, its provenance (app-registered vs built-in), and the\n * downstream status\n * @param callId - `ApiName.methodName`, so a rewritten message can still name the call\n */\n protected abstract adaptDownstreamFailure(failure: TranslatedFailure, callId: string): Error;\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 * @param appFilters the app's OUTBOUND filters for this client, from `createRpcClient`. They are\n * merged with {@link clientFilters} and sorted by priority, highest OUTERMOST.\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>, appFilters: ClientFilterDefinition[]): void {\n this.appFilters = appFilters;\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(\n 'POST', fullPath, methodName, this.apiName, authMeta, undefined, formPost,\n getMaskSpec(apiPrototype, methodName), isRawBody(apiPrototype, methodName),\n ),\n );\n }\n\n // APP filters first (highest priority OUTERMOST, matching the server's FilterMatcher), then\n // the framework built-ins, ALWAYS innermost. Two separate sorts rather than one over the\n // union, deliberately: an app priority orders app filters against each other and nothing\n // else, so no number an app can type — however large — gets underneath the SSRF guard or the\n // credential minter. A single sorted list would make \"displace the guard\" a matter of typing\n // a bigger integer, and a security control an app can outrank by accident is not a control.\n //\n // Sorted here, once, so FilterChain itself never sorts — priority lives on the DEFINITION,\n // not on the filter.\n const byPriority = (a: ClientFilterDefinition, b: ClientFilterDefinition): number =>\n b.priority - a.priority;\n const ordered = [...[...this.appFilters].sort(byPriority), ...[...this.clientFilters()].sort(byPriority)];\n this.chain = new FilterChain<ClientRequest, Response>(\n ordered.map((definition: ClientFilterDefinition) => definition.filter),\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 * FAIL FAST, PER METHOD, at call time: some endpoints exist for a caller that is not us, and this\n * proxy could only ever build a request they are obliged to reject. Refusing here rather than at\n * bind time means an api that MIXES such endpoints with normal ones still yields a working client\n * for the normal ones; only calling the un-callable method throws.\n *\n * @throws Error naming the endpoint, what it declared, and who its real caller is.\n */\n private refuseEndpointNoClientCanCall(route: RouteMetadata): void {\n // formPost exists ONLY for EXTERNAL inbound webhooks (e.g. Twilio is the caller). This proxy\n // JSON.stringifies the body, so calling one would silently send a wrong-encoded body.\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 const authMode = route.authMeta?.mode;\n // @AuthApiKey: the credential is a CUSTOMER-held key, and the header carrying it is the app's\n // ApiKeyHook's choice, so this client has nothing to send and the call is a guaranteed 401.\n if (authMode?.kind === 'apikey') {\n throw new Error(\n `${this.apiName}.${route.methodName} is @AuthApiKey('${authMode.regime}') — only the partner ` +\n `holding that api key can call it, and the header carrying it is the app's ApiKeyHook's choice, ` +\n `so a webpieces client has no credential to send.`,\n );\n }\n // @AuthWebhook is DELIBERATELY absent from this list. It used to be here, on the assumption\n // that the vendor is always somebody else — but `@AuthWebhook(name)` names a signing SCHEME,\n // not a direction, and for an OUTBOUND partner webhook WE are the vendor. The environment's\n // outbound-auth filter asks its bound signer to produce the signature, which is the exact\n // mirror of the inbound WebhookAuthCallback that verifies one.\n }\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 this.refuseEndpointNoClientCanCall(route);\n // Resolved per call (memoized underneath on a server), so building a client stayed synchronous.\n const baseUrl = await this.resolveBaseUrl();\n\n const httpHeaders = new Map<string, string>([['Content-Type', 'application/json']]);\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. The\n // destination's own auth mode decides whether trusted keys are part of that set.\n const contextHeaders = this.outboundContextHeaders(DestinationTrust.forAuthMode(route.authMeta?.mode));\n for (const entry of contextHeaders.entries()) {\n httpHeaders.set(entry[0], entry[1]);\n }\n\n // NOTHING mints a credential here. The endpoint's outbound auth is a FILTER, sitting at the\n // very bottom of the chain, because the URL at this point is only where the call STARTS: an\n // app filter above may re-point it, and an OIDC token whose audience is the pre-filter URL\n // is a token for the wrong peer. The minter has to run last, against the settled\n // destination — see the environment's `clientFilters()`.\n //\n // POST body is the first argument as JSON. Serialized HERE, before the filter chain, so a\n // filter that signs the request signs the exact bytes {@link sendOnce} will transmit.\n // webpieces-disable no-any-unknown -- the request DTO's type is erased at the proxy boundary\n let requestDto: unknown;\n let body: string | undefined;\n if (args.length > 0) {\n requestDto = args[0];\n body = JSON.stringify(requestDto);\n }\n\n const request = new ClientRequest(route, this.apiName, baseUrl, httpHeaders, body, requestDto);\n\n // Wrap the send 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(request);\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(request: ClientRequest): Promise<unknown> {\n const route = request.route;\n this.onRequestStart(route);\n\n // The START marker fires ONCE per RPC even though a filter may send more than once (the SSRF\n // guard re-invokes the chain to follow a validated redirect) — start and end still pair up\n // exactly, which is what lets a listener drive a progress counter.\n let response: Response;\n // webpieces-disable no-unmanaged-exceptions -- translate a send failure into a lifecycle END, then rethrow\n // eslint-disable-next-line @webpieces/no-unmanaged-exceptions\n try {\n response = await this.chain.execute(request, () => this.sendOnce(request));\n } catch (err: unknown) {\n // No Response ever existed — a network reject already classified by sendOnce, or a filter\n // that refused to send at all (an SSRF policy rejecting a partner's URL). Either way there\n // is no status and no headers to report, only status 0 and the failure itself, and the\n // lifecycle listener must see the SAME error the caller is about to.\n const error = toError(err);\n this.onRequestEnd(route, new RequestOutcome(false, 0, undefined, error));\n throw error;\n }\n\n const callId = `${this.apiName}.${route.methodName}`;\n if (response.ok) {\n return this.readSuccessBody(response, route, callId);\n }\n throw await this.endWithTypedFailure(response, route, callId);\n }\n\n /**\n * ONE transmission — the bottom of the filter chain, and the only place `fetch` is called.\n *\n * Everything it sends comes off the {@link ClientRequest} as the chain left it, so a filter's\n * edits to the url, the headers or the serialized body are exactly what goes on the wire. It may\n * run more than once for a single RPC when a filter follows a redirect.\n *\n * A network reject (offline, DNS, CORS preflight) is classified into a typed OfflineError here (a\n * genuine bug passes through untouched) so that filters above see the same typed error the caller\n * will, rather than a raw platform reject.\n */\n private async sendOnce(request: ClientRequest): Promise<Response> {\n const options: RequestInit = {\n method: request.route.httpMethod,\n headers: request.headersAsRecord(),\n redirect: request.followRedirects ? 'follow' : 'manual',\n };\n if (request.body !== undefined) {\n options.body = request.body;\n }\n // webpieces-disable no-unmanaged-exceptions -- classify a network reject, then rethrow it typed\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 return await fetch(request.url, options);\n } catch (err: unknown) {\n const error = toError(err);\n throw this.networkRejectClassifier.toNetworkError(error, request.url);\n }\n }\n\n /**\n * Read a 2xx body, reporting the END marker on both outcomes.\n *\n * The content-type gate is the same one the error path uses: a 2xx that is not JSON (a proxy's\n * captive-portal page, an SPA index.html served by a misrouted CDN) is reported for WHAT ARRIVED,\n * instead of `SyntaxError: Unexpected token '<'`, which names nothing a reader can act on.\n */\n // webpieces-disable no-any-unknown -- the response DTO's type is erased at the proxy boundary\n private async readSuccessBody(response: Response, route: RouteMetadata, callId: string): Promise<unknown> {\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 if (!this.bodyReader.isJson(response)) {\n throw new Error(this.bodyReader.describeForeignBody(response, callId, await response.text()));\n }\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 /**\n * Turn a non-2xx response into the error the caller will see, firing the END marker first — so a\n * listener always gets its stop marker even though the caller sees an exception. RETURNS the\n * error rather than throwing it, which keeps the one `throw` visible at the call site.\n *\n * The headers still reach the seam here, so a version (or any future) header is observed even on\n * error responses.\n *\n * The body is read through {@link ResponseBodyReader}, which parses ONLY a body whose\n * content-type says it is JSON. An infra 502/503/504 (load balancer, proxy, cold start on a\n * scale-to-zero backend) serves HTML, and parsing that used to throw `SyntaxError: Unexpected\n * token '<'` — discarding the status, so the caller could not tell a booting server from a broken\n * client. It now becomes a synthesized ProtocolError translated BY STATUS, i.e. a real\n * `HttpBadGatewayError` / `HttpServiceUnavailableError` / `HttpGatewayTimeoutError`.\n *\n * The try/catch stays, for a NARROWER job than before: a body that DECLARED json and was\n * malformed still throws (that one is a genuine server bug), and the END marker must fire for it\n * too — an unreported end leaves the app's progress bar spinning forever.\n *\n * `translated` is what ClientErrorTranslator picked, and translateError RETURNS a\n * {@link TranslatedFailure} — so nothing in this seam is ever `unknown`.\n *\n * The translated failure then goes through {@link adaptDownstreamFailure}, which is where the two\n * environments part company (browser rethrows it, node turns a downstream 4xx into its own 500).\n *\n * The RequestOutcome reported to {@link onRequestEnd} carries the POST-adapt error, deliberately:\n * a lifecycle listener must see the SAME error the caller sees, or a progress bar / error toast\n * says 404 while the thrown exception says 500. That is the identical rule the network-reject path\n * already follows (it classifies BEFORE onRequestEnd for exactly this reason). The pre-adapt error\n * is not lost — it is the adapted error's `httpCause`.\n */\n private async endWithTypedFailure(response: Response, route: RouteMetadata, callId: string): Promise<Error> {\n let translated: TranslatedFailure;\n // webpieces-disable no-unmanaged-exceptions -- a malformed JSON error body must still report the END marker\n // eslint-disable-next-line @webpieces/no-unmanaged-exceptions\n try {\n const protocolError = await this.bodyReader.readErrorBody(response, callId);\n translated = ClientErrorTranslator.translateError(response, protocolError);\n } catch (err: unknown) {\n const error = toError(err);\n // The response CLAIMED JSON and was not parseable — report that failure as the outcome.\n // It never reaches adaptDownstreamFailure: there is no translated status to adapt, and a\n // body that broke its own content-type promise is already a defect, not a status answer.\n this.onRequestEnd(route, new RequestOutcome(false, response.status, response.headers, error));\n return error;\n }\n\n const adapted = this.adaptDownstreamFailure(translated, callId);\n this.onRequestEnd(route, new RequestOutcome(false, response.status, response.headers, adapted));\n return adapted;\n }\n}\n"]}
package/src/index.d.ts CHANGED
@@ -31,3 +31,6 @@ export { buildClientProxy } from './buildClientProxy';
31
31
  export { ClientErrorTranslator } from './ClientErrorTranslator';
32
32
  export { TranslatedFailure } from './TranslatedFailure';
33
33
  export { ResponseBodyReader } from './ResponseBodyReader';
34
+ export { ClientRequest } from './ClientRequest';
35
+ export { ClientFilterDefinition } from './ClientFilter';
36
+ export type { ClientFilter, ClientFilters } from './ClientFilter';
package/src/index.js CHANGED
@@ -26,7 +26,7 @@
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.ResponseBodyReader = exports.TranslatedFailure = exports.ClientErrorTranslator = exports.buildClientProxy = exports.RequestOutcome = exports.ProxyClient = void 0;
29
+ exports.ClientFilterDefinition = exports.ClientRequest = exports.ResponseBodyReader = exports.TranslatedFailure = 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
32
  var RequestOutcome_1 = require("./RequestOutcome");
@@ -39,4 +39,11 @@ var TranslatedFailure_1 = require("./TranslatedFailure");
39
39
  Object.defineProperty(exports, "TranslatedFailure", { enumerable: true, get: function () { return TranslatedFailure_1.TranslatedFailure; } });
40
40
  var ResponseBodyReader_1 = require("./ResponseBodyReader");
41
41
  Object.defineProperty(exports, "ResponseBodyReader", { enumerable: true, get: function () { return ResponseBodyReader_1.ResponseBodyReader; } });
42
+ // The OUTBOUND filter chain: the mutable request a filter edits, and one registration of a filter
43
+ // at a priority. The `Filter`/`Service`/`FilterChain` abstraction itself lives in
44
+ // @webpieces/core-util, shared with the server's inbound chain.
45
+ var ClientRequest_1 = require("./ClientRequest");
46
+ Object.defineProperty(exports, "ClientRequest", { enumerable: true, get: function () { return ClientRequest_1.ClientRequest; } });
47
+ var ClientFilter_1 = require("./ClientFilter");
48
+ Object.defineProperty(exports, "ClientFilterDefinition", { enumerable: true, get: function () { return ClientFilter_1.ClientFilterDefinition; } });
42
49
  //# sourceMappingURL=index.js.map
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;AACpB,mDAAkD;AAAzC,gHAAA,cAAc,OAAA;AAEvB,uDAAsD;AAA7C,oHAAA,gBAAgB,OAAA;AACzB,iEAAgE;AAAvD,8HAAA,qBAAqB,OAAA;AAC9B,yDAAwD;AAA/C,sHAAA,iBAAiB,OAAA;AAC1B,2DAA0D;AAAjD,wHAAA,kBAAkB,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';\nexport { TranslatedFailure } from './TranslatedFailure';\nexport { ResponseBodyReader } from './ResponseBodyReader';\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;AAC9B,yDAAwD;AAA/C,sHAAA,iBAAiB,OAAA;AAC1B,2DAA0D;AAAjD,wHAAA,kBAAkB,OAAA;AAC3B,kGAAkG;AAClG,kFAAkF;AAClF,gEAAgE;AAChE,iDAAgD;AAAvC,8GAAA,aAAa,OAAA;AACtB,+CAAwD;AAA/C,sHAAA,sBAAsB,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';\nexport { TranslatedFailure } from './TranslatedFailure';\nexport { ResponseBodyReader } from './ResponseBodyReader';\n// The OUTBOUND filter chain: the mutable request a filter edits, and one registration of a filter\n// at a priority. The `Filter`/`Service`/`FilterChain` abstraction itself lives in\n// @webpieces/core-util, shared with the server's inbound chain.\nexport { ClientRequest } from './ClientRequest';\nexport { ClientFilterDefinition } from './ClientFilter';\nexport type { ClientFilter, ClientFilters } from './ClientFilter';\n"]}