@webpieces/http-server 0.4.788 → 0.4.789

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@webpieces/http-server",
3
- "version": "0.4.788",
3
+ "version": "0.4.789",
4
4
  "description": "WebPieces server with filter chain and dependency injection",
5
5
  "type": "commonjs",
6
6
  "main": "./src/index.js",
@@ -22,10 +22,10 @@
22
22
  "access": "public"
23
23
  },
24
24
  "dependencies": {
25
- "@webpieces/core-context": "0.4.788",
26
- "@webpieces/core-util": "0.4.788",
27
- "@webpieces/gcp-identity": "0.4.788",
28
- "@webpieces/http-routing": "0.4.788",
25
+ "@webpieces/core-context": "0.4.789",
26
+ "@webpieces/core-util": "0.4.789",
27
+ "@webpieces/gcp-identity": "0.4.789",
28
+ "@webpieces/http-routing": "0.4.789",
29
29
  "cors": "2.8.5",
30
30
  "express": "5.1.0",
31
31
  "inversify": "7.10.4"
@@ -13,21 +13,37 @@ import { HttpResponseDto } from '@webpieces/core-util';
13
13
  * Chosen per router with `WebpiecesExpressRouter.setEndUserStatus`.
14
14
  */
15
15
  export type EndUserStatus = 'gui' | 'edge';
16
- /** HTTP adapter for the transport-neutral API error taxonomy. */
16
+ /**
17
+ * HTTP adapter for the transport-neutral API error taxonomy, and the webpieces DEFAULT
18
+ * {@link ErrorTranslators} half on the server: an error in, the exact HTTP response out.
19
+ *
20
+ * An app that registers its own translator declines by DELEGATING here —
21
+ * `new ApiErrorHttpMapper('gui').toResponse(error)`, naming the mode its router uses — so "no
22
+ * translator registered" and "a translator that does not claim this error" produce byte-identical
23
+ * responses.
24
+ */
17
25
  export declare class ApiErrorHttpMapper {
18
26
  private readonly endUserStatus;
27
+ /**
28
+ * @param endUserStatus - REQUIRED, with no default. A router configured with
29
+ * `setEndUserStatus('edge')` must pass `'edge'` here too when it delegates from its own
30
+ * translator, and a default would make `new ApiErrorHttpMapper()` publish 266 for a
31
+ * delegated end-user error on an edge router with nothing to grep for — a widening that is an
32
+ * ABSENCE rather than a token (`.claude/rules/no-backwards-compat.md`, shim shape #5). Saying
33
+ * the mode out loud keeps the mismatch a thing you can see at the call site.
34
+ */
19
35
  constructor(endUserStatus: EndUserStatus);
20
36
  private readonly boundary;
21
37
  private readonly genericMessages;
22
38
  /**
23
- * Unknown throws and caller-local failures answer as a 500 implementation failure at this owning
24
- * API boundary. The thrown error itself is never replaced: it is what gets logged and classified.
25
- */
26
- toResponse(thrown: unknown): HttpResponseDto;
27
- /**
28
- * The protocol status; only an end-user error in `'edge'` mode departs from the shared mapping.
29
- * Anything the boundary does not classify as an API outcome is this server's own bug: 500.
39
+ * The payload IS the answer. {@link ApiErrorBoundary.encode} has already applied both shared
40
+ * rules — a caller-local `ApiConnectionError` (and every non-`ApiError`) publishes as
41
+ * `implementation`, and only an `ApiEndUserError`'s own message is disclosed — and the payload
42
+ * carries everything HTTP needs after that: `kind`, `statusCode`, `edgeHttpStatus` and
43
+ * `retryAfterSeconds`. So there is no `instanceof` here and nothing re-derived from the raw
44
+ * error; unknown throws and caller-local failures come out as a 500 implementation failure
45
+ * because that is what the payload says.
30
46
  */
31
- private statusFor;
47
+ toResponse(error: Error): HttpResponseDto;
32
48
  genericMessage(code: number): string;
33
49
  }
@@ -2,14 +2,29 @@
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
3
  exports.ApiErrorHttpMapper = void 0;
4
4
  const core_util_1 = require("@webpieces/core-util");
5
- const log = core_util_1.LogManager.getLogger('ApiErrorHttpMapper');
6
- /** HTTP adapter for the transport-neutral API error taxonomy. */
5
+ /**
6
+ * HTTP adapter for the transport-neutral API error taxonomy, and the webpieces DEFAULT
7
+ * {@link ErrorTranslators} half on the server: an error in, the exact HTTP response out.
8
+ *
9
+ * An app that registers its own translator declines by DELEGATING here —
10
+ * `new ApiErrorHttpMapper('gui').toResponse(error)`, naming the mode its router uses — so "no
11
+ * translator registered" and "a translator that does not claim this error" produce byte-identical
12
+ * responses.
13
+ */
7
14
  class ApiErrorHttpMapper {
8
15
  endUserStatus;
16
+ /**
17
+ * @param endUserStatus - REQUIRED, with no default. A router configured with
18
+ * `setEndUserStatus('edge')` must pass `'edge'` here too when it delegates from its own
19
+ * translator, and a default would make `new ApiErrorHttpMapper()` publish 266 for a
20
+ * delegated end-user error on an edge router with nothing to grep for — a widening that is an
21
+ * ABSENCE rather than a token (`.claude/rules/no-backwards-compat.md`, shim shape #5). Saying
22
+ * the mode out loud keeps the mismatch a thing you can see at the call site.
23
+ */
9
24
  constructor(endUserStatus) {
10
25
  this.endUserStatus = endUserStatus;
11
26
  }
12
- boundary = new core_util_1.ApiErrorBoundary(log);
27
+ boundary = new core_util_1.ApiErrorBoundary();
13
28
  genericMessages = new Map([
14
29
  [266, 'End User Error'],
15
30
  [400, 'Bad Request'],
@@ -29,31 +44,26 @@ class ApiErrorHttpMapper {
29
44
  [504, 'Gateway Timeout'],
30
45
  ]);
31
46
  /**
32
- * Unknown throws and caller-local failures answer as a 500 implementation failure at this owning
33
- * API boundary. The thrown error itself is never replaced: it is what gets logged and classified.
47
+ * The payload IS the answer. {@link ApiErrorBoundary.encode} has already applied both shared
48
+ * rules — a caller-local `ApiConnectionError` (and every non-`ApiError`) publishes as
49
+ * `implementation`, and only an `ApiEndUserError`'s own message is disclosed — and the payload
50
+ * carries everything HTTP needs after that: `kind`, `statusCode`, `edgeHttpStatus` and
51
+ * `retryAfterSeconds`. So there is no `instanceof` here and nothing re-derived from the raw
52
+ * error; unknown throws and caller-local failures come out as a 500 implementation failure
53
+ * because that is what the payload says.
34
54
  */
35
- // webpieces-disable no-any-unknown -- thrown values are unknown until classified at this boundary
36
- toResponse(thrown) {
37
- const status = this.statusFor(thrown);
38
- this.boundary.logOperatorDetail(thrown);
39
- const headers = thrown instanceof core_util_1.ApiDependencyBackoffError
40
- ? [new core_util_1.HttpHeader('retry-after', String(thrown.retryAfterSeconds))]
55
+ toResponse(error) {
56
+ const payload = this.boundary.encode(error);
57
+ // The boundary never publishes kind 'connection' — that is the whole first rule above — so
58
+ // this is the published subset ApiErrorHttpStatus.codeFor accepts.
59
+ const kind = payload.kind;
60
+ const status = this.endUserStatus === 'edge' && kind === 'end-user'
61
+ ? (payload.edgeHttpStatus ?? 400)
62
+ : core_util_1.ApiErrorHttpStatus.codeFor(kind, payload.statusCode);
63
+ const headers = payload.retryAfterSeconds !== undefined
64
+ ? [new core_util_1.HttpHeader('retry-after', String(payload.retryAfterSeconds))]
41
65
  : [];
42
- return new core_util_1.HttpResponseDto(new core_util_1.HttpResponseStatus(status, this.genericMessage(status)), headers, this.boundary.encode(thrown));
43
- }
44
- /**
45
- * The protocol status; only an end-user error in `'edge'` mode departs from the shared mapping.
46
- * Anything the boundary does not classify as an API outcome is this server's own bug: 500.
47
- */
48
- // webpieces-disable no-any-unknown -- thrown values are unknown until classified at this boundary
49
- statusFor(thrown) {
50
- const error = this.boundary.apiOutcome(thrown);
51
- if (!error)
52
- return 500;
53
- if (this.endUserStatus === 'edge' && error instanceof core_util_1.ApiEndUserError) {
54
- return error.edgeHttpStatus ?? 400;
55
- }
56
- return core_util_1.ApiErrorHttpStatus.code(error);
66
+ return new core_util_1.HttpResponseDto(new core_util_1.HttpResponseStatus(status, this.genericMessage(status)), headers, payload);
57
67
  }
58
68
  genericMessage(code) {
59
69
  return this.genericMessages.get(code) ?? 'Request Failed';
@@ -1 +1 @@
1
- {"version":3,"file":"ApiErrorHttpMapper.js","sourceRoot":"","sources":["../../../../../packages/http/http-server/src/ApiErrorHttpMapper.ts"],"names":[],"mappings":";;;AAAA,oDAS8B;AAE9B,MAAM,GAAG,GAAG,sBAAU,CAAC,SAAS,CAAC,oBAAoB,CAAC,CAAC;AAiBvD,iEAAiE;AACjE,MAAa,kBAAkB;IACE;IAA7B,YAA6B,aAA4B;QAA5B,kBAAa,GAAb,aAAa,CAAe;IAAG,CAAC;IAE5C,QAAQ,GAAG,IAAI,4BAAgB,CAAC,GAAG,CAAC,CAAC;IACrC,eAAe,GAAwB,IAAI,GAAG,CAAiB;QAC5E,CAAC,GAAG,EAAE,gBAAgB,CAAC;QACvB,CAAC,GAAG,EAAE,aAAa,CAAC;QACpB,CAAC,GAAG,EAAE,cAAc,CAAC;QACrB,CAAC,GAAG,EAAE,WAAW,CAAC;QAClB,CAAC,GAAG,EAAE,WAAW,CAAC;QAClB,CAAC,GAAG,EAAE,iBAAiB,CAAC;QACxB,CAAC,GAAG,EAAE,UAAU,CAAC;QACjB,CAAC,GAAG,EAAE,qBAAqB,CAAC;QAC5B,CAAC,GAAG,EAAE,wBAAwB,CAAC;QAC/B,CAAC,GAAG,EAAE,uBAAuB,CAAC;QAC9B,CAAC,GAAG,EAAE,mBAAmB,CAAC;QAC1B,CAAC,GAAG,EAAE,uBAAuB,CAAC;QAC9B,CAAC,GAAG,EAAE,iBAAiB,CAAC;QACxB,CAAC,GAAG,EAAE,aAAa,CAAC;QACpB,CAAC,GAAG,EAAE,qBAAqB,CAAC;QAC5B,CAAC,GAAG,EAAE,iBAAiB,CAAC;KAC3B,CAAC,CAAC;IAEH;;;OAGG;IACH,kGAAkG;IAC3F,UAAU,CAAC,MAAe;QAC7B,MAAM,MAAM,GAAG,IAAI,CAAC,SAAS,CAAC,MAAM,CAAC,CAAC;QACtC,IAAI,CAAC,QAAQ,CAAC,iBAAiB,CAAC,MAAM,CAAC,CAAC;QACxC,MAAM,OAAO,GACT,MAAM,YAAY,qCAAyB;YACvC,CAAC,CAAC,CAAC,IAAI,sBAAU,CAAC,aAAa,EAAE,MAAM,CAAC,MAAM,CAAC,iBAAiB,CAAC,CAAC,CAAC;YACnE,CAAC,CAAC,EAAE,CAAC;QACb,OAAO,IAAI,2BAAe,CACtB,IAAI,8BAAkB,CAAC,MAAM,EAAE,IAAI,CAAC,cAAc,CAAC,MAAM,CAAC,CAAC,EAC3D,OAAO,EACP,IAAI,CAAC,QAAQ,CAAC,MAAM,CAAC,MAAM,CAAC,CAC/B,CAAC;IACN,CAAC;IAED;;;OAGG;IACH,kGAAkG;IAC1F,SAAS,CAAC,MAAe;QAC7B,MAAM,KAAK,GAAG,IAAI,CAAC,QAAQ,CAAC,UAAU,CAAC,MAAM,CAAC,CAAC;QAC/C,IAAI,CAAC,KAAK;YAAE,OAAO,GAAG,CAAC;QACvB,IAAI,IAAI,CAAC,aAAa,KAAK,MAAM,IAAI,KAAK,YAAY,2BAAe,EAAE,CAAC;YACpE,OAAO,KAAK,CAAC,cAAc,IAAI,GAAG,CAAC;QACvC,CAAC;QACD,OAAO,8BAAkB,CAAC,IAAI,CAAC,KAAK,CAAC,CAAC;IAC1C,CAAC;IAEM,cAAc,CAAC,IAAY;QAC9B,OAAO,IAAI,CAAC,eAAe,CAAC,GAAG,CAAC,IAAI,CAAC,IAAI,gBAAgB,CAAC;IAC9D,CAAC;CACJ;AA3DD,gDA2DC","sourcesContent":["import {\n ApiDependencyBackoffError,\n ApiEndUserError,\n ApiErrorBoundary,\n ApiErrorHttpStatus,\n HttpHeader,\n HttpResponseDto,\n HttpResponseStatus,\n LogManager,\n} from '@webpieces/core-util';\n\nconst log = LogManager.getLogger('ApiErrorHttpMapper');\n\n/**\n * How a server answers an {@link ApiEndUserError}.\n *\n * - `'gui'` (the default): 266. The call succeeded; the GUI (or any webpieces client, including the\n * next server-to-server hop) decodes the error and shows its message. `edgeHttpStatus` is ignored,\n * and still travels in the body so a downstream edge can use it.\n * - `'edge'`: a partner/public REST edge whose published contract promises real 4xx statuses. The\n * response carries `edgeHttpStatus`, or 400 when the thrower set none (still a caller-fixable\n * end-user outcome). Only for the OUTERMOST server: a webpieces client receiving an end-user body at\n * anything other than 266 treats it as a mismatched response, so internal hops stay `'gui'`.\n *\n * Chosen per router with `WebpiecesExpressRouter.setEndUserStatus`.\n */\nexport type EndUserStatus = 'gui' | 'edge';\n\n/** HTTP adapter for the transport-neutral API error taxonomy. */\nexport class ApiErrorHttpMapper {\n constructor(private readonly endUserStatus: EndUserStatus) {}\n\n private readonly boundary = new ApiErrorBoundary(log);\n private readonly genericMessages: Map<number, string> = new Map<number, string>([\n [266, 'End User Error'],\n [400, 'Bad Request'],\n [401, 'Unauthorized'],\n [403, 'Forbidden'],\n [404, 'Not Found'],\n [408, 'Request Timeout'],\n [409, 'Conflict'],\n [412, 'Precondition Failed'],\n [415, 'Unsupported Media Type'],\n [422, 'Unprocessable Content'],\n [429, 'Too Many Requests'],\n [500, 'Internal Server Error'],\n [501, 'Not Implemented'],\n [502, 'Bad Gateway'],\n [503, 'Service Unavailable'],\n [504, 'Gateway Timeout'],\n ]);\n\n /**\n * Unknown throws and caller-local failures answer as a 500 implementation failure at this owning\n * API boundary. The thrown error itself is never replaced: it is what gets logged and classified.\n */\n // webpieces-disable no-any-unknown -- thrown values are unknown until classified at this boundary\n public toResponse(thrown: unknown): HttpResponseDto {\n const status = this.statusFor(thrown);\n this.boundary.logOperatorDetail(thrown);\n const headers =\n thrown instanceof ApiDependencyBackoffError\n ? [new HttpHeader('retry-after', String(thrown.retryAfterSeconds))]\n : [];\n return new HttpResponseDto(\n new HttpResponseStatus(status, this.genericMessage(status)),\n headers,\n this.boundary.encode(thrown),\n );\n }\n\n /**\n * The protocol status; only an end-user error in `'edge'` mode departs from the shared mapping.\n * Anything the boundary does not classify as an API outcome is this server's own bug: 500.\n */\n // webpieces-disable no-any-unknown -- thrown values are unknown until classified at this boundary\n private statusFor(thrown: unknown): number {\n const error = this.boundary.apiOutcome(thrown);\n if (!error) return 500;\n if (this.endUserStatus === 'edge' && error instanceof ApiEndUserError) {\n return error.edgeHttpStatus ?? 400;\n }\n return ApiErrorHttpStatus.code(error);\n }\n\n public genericMessage(code: number): string {\n return this.genericMessages.get(code) ?? 'Request Failed';\n }\n}\n"]}
1
+ {"version":3,"file":"ApiErrorHttpMapper.js","sourceRoot":"","sources":["../../../../../packages/http/http-server/src/ApiErrorHttpMapper.ts"],"names":[],"mappings":";;;AAAA,oDAO8B;AAiB9B;;;;;;;;GAQG;AACH,MAAa,kBAAkB;IASE;IAR7B;;;;;;;OAOG;IACH,YAA6B,aAA4B;QAA5B,kBAAa,GAAb,aAAa,CAAe;IAAG,CAAC;IAE5C,QAAQ,GAAG,IAAI,4BAAgB,EAAE,CAAC;IAClC,eAAe,GAAwB,IAAI,GAAG,CAAiB;QAC5E,CAAC,GAAG,EAAE,gBAAgB,CAAC;QACvB,CAAC,GAAG,EAAE,aAAa,CAAC;QACpB,CAAC,GAAG,EAAE,cAAc,CAAC;QACrB,CAAC,GAAG,EAAE,WAAW,CAAC;QAClB,CAAC,GAAG,EAAE,WAAW,CAAC;QAClB,CAAC,GAAG,EAAE,iBAAiB,CAAC;QACxB,CAAC,GAAG,EAAE,UAAU,CAAC;QACjB,CAAC,GAAG,EAAE,qBAAqB,CAAC;QAC5B,CAAC,GAAG,EAAE,wBAAwB,CAAC;QAC/B,CAAC,GAAG,EAAE,uBAAuB,CAAC;QAC9B,CAAC,GAAG,EAAE,mBAAmB,CAAC;QAC1B,CAAC,GAAG,EAAE,uBAAuB,CAAC;QAC9B,CAAC,GAAG,EAAE,iBAAiB,CAAC;QACxB,CAAC,GAAG,EAAE,aAAa,CAAC;QACpB,CAAC,GAAG,EAAE,qBAAqB,CAAC;QAC5B,CAAC,GAAG,EAAE,iBAAiB,CAAC;KAC3B,CAAC,CAAC;IAEH;;;;;;;;OAQG;IACI,UAAU,CAAC,KAAY;QAC1B,MAAM,OAAO,GAAG,IAAI,CAAC,QAAQ,CAAC,MAAM,CAAC,KAAK,CAAC,CAAC;QAC5C,2FAA2F;QAC3F,mEAAmE;QACnE,MAAM,IAAI,GAAG,OAAO,CAAC,IAAqB,CAAC;QAC3C,MAAM,MAAM,GACR,IAAI,CAAC,aAAa,KAAK,MAAM,IAAI,IAAI,KAAK,UAAU;YAChD,CAAC,CAAC,CAAC,OAAO,CAAC,cAAc,IAAI,GAAG,CAAC;YACjC,CAAC,CAAC,8BAAkB,CAAC,OAAO,CAAC,IAAI,EAAE,OAAO,CAAC,UAAU,CAAC,CAAC;QAC/D,MAAM,OAAO,GACT,OAAO,CAAC,iBAAiB,KAAK,SAAS;YACnC,CAAC,CAAC,CAAC,IAAI,sBAAU,CAAC,aAAa,EAAE,MAAM,CAAC,OAAO,CAAC,iBAAiB,CAAC,CAAC,CAAC;YACpE,CAAC,CAAC,EAAE,CAAC;QACb,OAAO,IAAI,2BAAe,CACtB,IAAI,8BAAkB,CAAC,MAAM,EAAE,IAAI,CAAC,cAAc,CAAC,MAAM,CAAC,CAAC,EAC3D,OAAO,EACP,OAAO,CACV,CAAC;IACN,CAAC;IAEM,cAAc,CAAC,IAAY;QAC9B,OAAO,IAAI,CAAC,eAAe,CAAC,GAAG,CAAC,IAAI,CAAC,IAAI,gBAAgB,CAAC;IAC9D,CAAC;CACJ;AA/DD,gDA+DC","sourcesContent":["import {\n ApiErrorBoundary,\n ApiErrorHttpStatus,\n HttpHeader,\n HttpResponseDto,\n HttpResponseStatus,\n PublishedKind,\n} from '@webpieces/core-util';\n\n/**\n * How a server answers an {@link ApiEndUserError}.\n *\n * - `'gui'` (the default): 266. The call succeeded; the GUI (or any webpieces client, including the\n * next server-to-server hop) decodes the error and shows its message. `edgeHttpStatus` is ignored,\n * and still travels in the body so a downstream edge can use it.\n * - `'edge'`: a partner/public REST edge whose published contract promises real 4xx statuses. The\n * response carries `edgeHttpStatus`, or 400 when the thrower set none (still a caller-fixable\n * end-user outcome). Only for the OUTERMOST server: a webpieces client receiving an end-user body at\n * anything other than 266 treats it as a mismatched response, so internal hops stay `'gui'`.\n *\n * Chosen per router with `WebpiecesExpressRouter.setEndUserStatus`.\n */\nexport type EndUserStatus = 'gui' | 'edge';\n\n/**\n * HTTP adapter for the transport-neutral API error taxonomy, and the webpieces DEFAULT\n * {@link ErrorTranslators} half on the server: an error in, the exact HTTP response out.\n *\n * An app that registers its own translator declines by DELEGATING here —\n * `new ApiErrorHttpMapper('gui').toResponse(error)`, naming the mode its router uses — so \"no\n * translator registered\" and \"a translator that does not claim this error\" produce byte-identical\n * responses.\n */\nexport class ApiErrorHttpMapper {\n /**\n * @param endUserStatus - REQUIRED, with no default. A router configured with\n * `setEndUserStatus('edge')` must pass `'edge'` here too when it delegates from its own\n * translator, and a default would make `new ApiErrorHttpMapper()` publish 266 for a\n * delegated end-user error on an edge router with nothing to grep for — a widening that is an\n * ABSENCE rather than a token (`.claude/rules/no-backwards-compat.md`, shim shape #5). Saying\n * the mode out loud keeps the mismatch a thing you can see at the call site.\n */\n constructor(private readonly endUserStatus: EndUserStatus) {}\n\n private readonly boundary = new ApiErrorBoundary();\n private readonly genericMessages: Map<number, string> = new Map<number, string>([\n [266, 'End User Error'],\n [400, 'Bad Request'],\n [401, 'Unauthorized'],\n [403, 'Forbidden'],\n [404, 'Not Found'],\n [408, 'Request Timeout'],\n [409, 'Conflict'],\n [412, 'Precondition Failed'],\n [415, 'Unsupported Media Type'],\n [422, 'Unprocessable Content'],\n [429, 'Too Many Requests'],\n [500, 'Internal Server Error'],\n [501, 'Not Implemented'],\n [502, 'Bad Gateway'],\n [503, 'Service Unavailable'],\n [504, 'Gateway Timeout'],\n ]);\n\n /**\n * The payload IS the answer. {@link ApiErrorBoundary.encode} has already applied both shared\n * rules — a caller-local `ApiConnectionError` (and every non-`ApiError`) publishes as\n * `implementation`, and only an `ApiEndUserError`'s own message is disclosed — and the payload\n * carries everything HTTP needs after that: `kind`, `statusCode`, `edgeHttpStatus` and\n * `retryAfterSeconds`. So there is no `instanceof` here and nothing re-derived from the raw\n * error; unknown throws and caller-local failures come out as a 500 implementation failure\n * because that is what the payload says.\n */\n public toResponse(error: Error): HttpResponseDto {\n const payload = this.boundary.encode(error);\n // The boundary never publishes kind 'connection' — that is the whole first rule above — so\n // this is the published subset ApiErrorHttpStatus.codeFor accepts.\n const kind = payload.kind as PublishedKind;\n const status =\n this.endUserStatus === 'edge' && kind === 'end-user'\n ? (payload.edgeHttpStatus ?? 400)\n : ApiErrorHttpStatus.codeFor(kind, payload.statusCode);\n const headers =\n payload.retryAfterSeconds !== undefined\n ? [new HttpHeader('retry-after', String(payload.retryAfterSeconds))]\n : [];\n return new HttpResponseDto(\n new HttpResponseStatus(status, this.genericMessage(status)),\n headers,\n payload,\n );\n }\n\n public genericMessage(code: number): string {\n return this.genericMessages.get(code) ?? 'Request Failed';\n }\n}\n"]}
@@ -0,0 +1,25 @@
1
+ import { Response } from 'express';
2
+ import { HttpResponseDto } from '@webpieces/core-util';
3
+ /**
4
+ * The ONE place an {@link HttpResponseDto} becomes bytes on an express response.
5
+ *
6
+ * Every webpieces surface that owns a socket writes through this: `ExpressWrapper.handleError` for
7
+ * the ordinary API path (webpieces' own default AND an app's `ErrorTranslators`), and
8
+ * `WpMcpServer`'s pre-SDK boundary, which used to hand-roll `res.status(...).json(...)` and so got
9
+ * none of the rules below. Producing a VALUE and handing it here is what makes those two paths the
10
+ * same path.
11
+ *
12
+ * `append`, not `setHeader`, because the DTO's headers are a LIST: HTTP permits repeats
13
+ * (`Set-Cookie` is the everyday one) and `setHeader` would keep only the last of them. That list
14
+ * shape is the whole reason the DTO does not use a Map.
15
+ *
16
+ * `Content-Type: application/json` is INFRASTRUCTURE here, not app policy: webpieces is the one
17
+ * doing the `JSON.stringify`, so it states what it wrote. Express would otherwise default a string
18
+ * body to `text/html`, which is wrong for every response this framework sends. A translator that
19
+ * genuinely wants another type says so in its own header list and wins.
20
+ */
21
+ export declare class ExpressResponseWriter {
22
+ write(res: Response, response: HttpResponseDto): void;
23
+ /** JSON by default; explicit text content types write the caller's string verbatim. */
24
+ private responsePayload;
25
+ }
@@ -0,0 +1,50 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.ExpressResponseWriter = void 0;
4
+ /**
5
+ * The ONE place an {@link HttpResponseDto} becomes bytes on an express response.
6
+ *
7
+ * Every webpieces surface that owns a socket writes through this: `ExpressWrapper.handleError` for
8
+ * the ordinary API path (webpieces' own default AND an app's `ErrorTranslators`), and
9
+ * `WpMcpServer`'s pre-SDK boundary, which used to hand-roll `res.status(...).json(...)` and so got
10
+ * none of the rules below. Producing a VALUE and handing it here is what makes those two paths the
11
+ * same path.
12
+ *
13
+ * `append`, not `setHeader`, because the DTO's headers are a LIST: HTTP permits repeats
14
+ * (`Set-Cookie` is the everyday one) and `setHeader` would keep only the last of them. That list
15
+ * shape is the whole reason the DTO does not use a Map.
16
+ *
17
+ * `Content-Type: application/json` is INFRASTRUCTURE here, not app policy: webpieces is the one
18
+ * doing the `JSON.stringify`, so it states what it wrote. Express would otherwise default a string
19
+ * body to `text/html`, which is wrong for every response this framework sends. A translator that
20
+ * genuinely wants another type says so in its own header list and wins.
21
+ */
22
+ class ExpressResponseWriter {
23
+ write(res, response) {
24
+ const declaresContentType = response.headers.some((header) => header.name.toLowerCase() === 'content-type');
25
+ if (!declaresContentType && response.body !== undefined) {
26
+ res.setHeader('Content-Type', 'application/json');
27
+ }
28
+ for (const header of response.headers) {
29
+ res.append(header.name, header.value);
30
+ }
31
+ // express writes the reason phrase from `statusMessage`, so a translator that says
32
+ // `new HttpResponseStatus(460, 'Order Not Found')` gets 'Order Not Found' on the status line
33
+ // rather than node's blank default for an unregistered code.
34
+ res.statusMessage = response.status.reason;
35
+ const payload = response.body === undefined
36
+ ? undefined
37
+ : this.responsePayload(response, declaresContentType);
38
+ res.status(response.status.code).send(payload);
39
+ }
40
+ /** JSON by default; explicit text content types write the caller's string verbatim. */
41
+ responsePayload(response, declaresContentType) {
42
+ const contentType = response.headers.find((header) => header.name.toLowerCase() === 'content-type')?.value;
43
+ if (declaresContentType && contentType !== undefined && !/json/i.test(contentType)) {
44
+ return typeof response.body === 'string' ? response.body : String(response.body);
45
+ }
46
+ return JSON.stringify(response.body) ?? '';
47
+ }
48
+ }
49
+ exports.ExpressResponseWriter = ExpressResponseWriter;
50
+ //# sourceMappingURL=ExpressResponseWriter.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"ExpressResponseWriter.js","sourceRoot":"","sources":["../../../../../packages/http/http-server/src/ExpressResponseWriter.ts"],"names":[],"mappings":";;;AAGA;;;;;;;;;;;;;;;;;GAiBG;AACH,MAAa,qBAAqB;IAC9B,KAAK,CAAC,GAAa,EAAE,QAAyB;QAC1C,MAAM,mBAAmB,GAAG,QAAQ,CAAC,OAAO,CAAC,IAAI,CAC7C,CAAC,MAAkB,EAAE,EAAE,CAAC,MAAM,CAAC,IAAI,CAAC,WAAW,EAAE,KAAK,cAAc,CACvE,CAAC;QACF,IAAI,CAAC,mBAAmB,IAAI,QAAQ,CAAC,IAAI,KAAK,SAAS,EAAE,CAAC;YACtD,GAAG,CAAC,SAAS,CAAC,cAAc,EAAE,kBAAkB,CAAC,CAAC;QACtD,CAAC;QACD,KAAK,MAAM,MAAM,IAAI,QAAQ,CAAC,OAAO,EAAE,CAAC;YACpC,GAAG,CAAC,MAAM,CAAC,MAAM,CAAC,IAAI,EAAE,MAAM,CAAC,KAAK,CAAC,CAAC;QAC1C,CAAC;QACD,mFAAmF;QACnF,6FAA6F;QAC7F,6DAA6D;QAC7D,GAAG,CAAC,aAAa,GAAG,QAAQ,CAAC,MAAM,CAAC,MAAM,CAAC;QAC3C,MAAM,OAAO,GACT,QAAQ,CAAC,IAAI,KAAK,SAAS;YACvB,CAAC,CAAC,SAAS;YACX,CAAC,CAAC,IAAI,CAAC,eAAe,CAAC,QAAQ,EAAE,mBAAmB,CAAC,CAAC;QAC9D,GAAG,CAAC,MAAM,CAAC,QAAQ,CAAC,MAAM,CAAC,IAAI,CAAC,CAAC,IAAI,CAAC,OAAO,CAAC,CAAC;IACnD,CAAC;IAED,uFAAuF;IAC/E,eAAe,CAAC,QAAyB,EAAE,mBAA4B;QAC3E,MAAM,WAAW,GAAG,QAAQ,CAAC,OAAO,CAAC,IAAI,CACrC,CAAC,MAAkB,EAAE,EAAE,CAAC,MAAM,CAAC,IAAI,CAAC,WAAW,EAAE,KAAK,cAAc,CACvE,EAAE,KAAK,CAAC;QACT,IAAI,mBAAmB,IAAI,WAAW,KAAK,SAAS,IAAI,CAAC,OAAO,CAAC,IAAI,CAAC,WAAW,CAAC,EAAE,CAAC;YACjF,OAAO,OAAO,QAAQ,CAAC,IAAI,KAAK,QAAQ,CAAC,CAAC,CAAC,QAAQ,CAAC,IAAI,CAAC,CAAC,CAAC,MAAM,CAAC,QAAQ,CAAC,IAAI,CAAC,CAAC;QACrF,CAAC;QACD,OAAO,IAAI,CAAC,SAAS,CAAC,QAAQ,CAAC,IAAI,CAAC,IAAI,EAAE,CAAC;IAC/C,CAAC;CACJ;AAhCD,sDAgCC","sourcesContent":["import { Response } from 'express';\nimport { HttpHeader, HttpResponseDto } from '@webpieces/core-util';\n\n/**\n * The ONE place an {@link HttpResponseDto} becomes bytes on an express response.\n *\n * Every webpieces surface that owns a socket writes through this: `ExpressWrapper.handleError` for\n * the ordinary API path (webpieces' own default AND an app's `ErrorTranslators`), and\n * `WpMcpServer`'s pre-SDK boundary, which used to hand-roll `res.status(...).json(...)` and so got\n * none of the rules below. Producing a VALUE and handing it here is what makes those two paths the\n * same path.\n *\n * `append`, not `setHeader`, because the DTO's headers are a LIST: HTTP permits repeats\n * (`Set-Cookie` is the everyday one) and `setHeader` would keep only the last of them. That list\n * shape is the whole reason the DTO does not use a Map.\n *\n * `Content-Type: application/json` is INFRASTRUCTURE here, not app policy: webpieces is the one\n * doing the `JSON.stringify`, so it states what it wrote. Express would otherwise default a string\n * body to `text/html`, which is wrong for every response this framework sends. A translator that\n * genuinely wants another type says so in its own header list and wins.\n */\nexport class ExpressResponseWriter {\n write(res: Response, response: HttpResponseDto): void {\n const declaresContentType = response.headers.some(\n (header: HttpHeader) => header.name.toLowerCase() === 'content-type',\n );\n if (!declaresContentType && response.body !== undefined) {\n res.setHeader('Content-Type', 'application/json');\n }\n for (const header of response.headers) {\n res.append(header.name, header.value);\n }\n // express writes the reason phrase from `statusMessage`, so a translator that says\n // `new HttpResponseStatus(460, 'Order Not Found')` gets 'Order Not Found' on the status line\n // rather than node's blank default for an unregistered code.\n res.statusMessage = response.status.reason;\n const payload =\n response.body === undefined\n ? undefined\n : this.responsePayload(response, declaresContentType);\n res.status(response.status.code).send(payload);\n }\n\n /** JSON by default; explicit text content types write the caller's string verbatim. */\n private responsePayload(response: HttpResponseDto, declaresContentType: boolean): string {\n const contentType = response.headers.find(\n (header: HttpHeader) => header.name.toLowerCase() === 'content-type',\n )?.value;\n if (declaresContentType && contentType !== undefined && !/json/i.test(contentType)) {\n return typeof response.body === 'string' ? response.body : String(response.body);\n }\n return JSON.stringify(response.body) ?? '';\n }\n}\n"]}
@@ -65,6 +65,7 @@ export declare class ExpressWrapper {
65
65
  * message goes on the wire" rule is stated.
66
66
  */
67
67
  private readonly errorWireMapper;
68
+ private readonly responseWriter;
68
69
  constructor(clientMethod: (...args: unknown[]) => Promise<unknown>, path: string,
69
70
  /** Owns the wire<->context transfer, both directions. Stateless framework singleton. */
70
71
  headers: RequestContextHeaders,
@@ -180,23 +181,13 @@ export declare class ExpressWrapper {
180
181
  * and the list of statuses, which must stay in step with `ClientErrorTranslator`'s built-in
181
182
  * mapping.
182
183
  */
183
- handleError(res: Response, error: unknown): void;
184
+ handleError(res: Response, thrown: unknown): void;
184
185
  /**
185
- * Write an {@link HttpResponseDto} to express — the ONE place a response DTO becomes bytes, used
186
- * by the app's translators and by webpieces' own default alike.
187
- *
188
- * `append`, not `setHeader`, because the DTO's headers are a LIST: HTTP permits repeats
189
- * (`Set-Cookie` is the everyday one) and `setHeader` would keep only the last of them. That list
190
- * shape is the whole reason the DTO does not use a Map.
191
- *
192
- * `Content-Type: application/json` is INFRASTRUCTURE here, not app policy: webpieces is the one
193
- * doing the `JSON.stringify`, so it states what it wrote. Express would otherwise default a
194
- * string body to `text/html`, which is wrong for every response this framework sends. A
195
- * translator that genuinely wants another type says so in its own header list and wins.
186
+ * Stamp the transaction id and hand the DTO to the shared {@link ExpressResponseWriter} — the
187
+ * ONE place a response DTO becomes bytes, used by the app's translators, by webpieces' own
188
+ * default, and by the MCP pre-SDK boundary alike.
196
189
  */
197
190
  private send;
198
- /** JSON by default; explicit text content types write the caller's string verbatim. */
199
- private responsePayload;
200
191
  /**
201
192
  * Put the transaction id on EVERY response — success and error, webpieces' default body and an
202
193
  * app's own. It is INFRASTRUCTURE, not app policy: an app that overrides what an error looks like
@@ -3,6 +3,7 @@ Object.defineProperty(exports, "__esModule", { value: true });
3
3
  exports.ExpressWrapper = exports.MAX_BODY_BYTES = void 0;
4
4
  const core_util_1 = require("@webpieces/core-util");
5
5
  const core_context_1 = require("@webpieces/core-context");
6
+ const ExpressResponseWriter_1 = require("./ExpressResponseWriter");
6
7
  const ApiErrorHttpMapper_1 = require("./ApiErrorHttpMapper");
7
8
  const StreamBodyReader_1 = require("./body/StreamBodyReader");
8
9
  /**
@@ -59,6 +60,7 @@ class ExpressWrapper {
59
60
  * message goes on the wire" rule is stated.
60
61
  */
61
62
  errorWireMapper;
63
+ responseWriter = new ExpressResponseWriter_1.ExpressResponseWriter();
62
64
  constructor(
63
65
  // webpieces-disable no-any-unknown -- request/response DTOs are erased at the routing boundary
64
66
  clientMethod, path,
@@ -353,58 +355,30 @@ class ExpressWrapper {
353
355
  * and the list of statuses, which must stay in step with `ClientErrorTranslator`'s built-in
354
356
  * mapping.
355
357
  */
356
- // webpieces-disable no-any-unknown -- a thrown error is genuinely unknown until narrowed below
357
- handleError(res, error) {
358
+ // webpieces-disable no-any-unknown -- a thrown value is genuinely unknown until toError narrows it
359
+ handleError(res, thrown) {
358
360
  if (res.headersSent) {
359
361
  return;
360
362
  }
363
+ // ONE narrowing, at the top, and every layer below it is honest about holding an `Error`.
364
+ const error = (0, core_util_1.toError)(thrown);
361
365
  // The app's ErrorTranslators own the WHOLE response — status code, reason phrase, headers
362
366
  // and body — so an app can publish its own envelope AND its own `Retry-After` /
363
367
  // `WWW-Authenticate` / trace header / cookie, which the old (statusCode, protocolError) pair
364
- // could not express at all. `undefined` means "not mine": webpieces' default answers
365
- // instead. Symmetric with the client's ClientErrorTranslator, which consults
366
- // tryTranslateFromWire() first, over the identical HttpResponseDto shape.
367
- const appResponse = error instanceof Error ? core_util_1.ClientRegistry.tryTranslateToWire(error) : undefined;
368
- this.send(res, appResponse ?? this.errorWireMapper.toResponse(error));
368
+ // could not express at all. A registered translator REPLACES this default and declines by
369
+ // delegating back to it, so there is no per-error "not mine" branch here: the only question
370
+ // is whether this process installed one.
371
+ const translators = core_util_1.ClientRegistry.getErrorTranslators();
372
+ this.send(res, translators ? translators.toWire(error) : this.errorWireMapper.toResponse(error));
369
373
  }
370
374
  /**
371
- * Write an {@link HttpResponseDto} to express — the ONE place a response DTO becomes bytes, used
372
- * by the app's translators and by webpieces' own default alike.
373
- *
374
- * `append`, not `setHeader`, because the DTO's headers are a LIST: HTTP permits repeats
375
- * (`Set-Cookie` is the everyday one) and `setHeader` would keep only the last of them. That list
376
- * shape is the whole reason the DTO does not use a Map.
377
- *
378
- * `Content-Type: application/json` is INFRASTRUCTURE here, not app policy: webpieces is the one
379
- * doing the `JSON.stringify`, so it states what it wrote. Express would otherwise default a
380
- * string body to `text/html`, which is wrong for every response this framework sends. A
381
- * translator that genuinely wants another type says so in its own header list and wins.
375
+ * Stamp the transaction id and hand the DTO to the shared {@link ExpressResponseWriter} — the
376
+ * ONE place a response DTO becomes bytes, used by the app's translators, by webpieces' own
377
+ * default, and by the MCP pre-SDK boundary alike.
382
378
  */
383
379
  send(res, response) {
384
380
  this.stampTransactionId(res);
385
- const declaresContentType = response.headers.some((header) => header.name.toLowerCase() === 'content-type');
386
- if (!declaresContentType && response.body !== undefined) {
387
- res.setHeader('Content-Type', 'application/json');
388
- }
389
- for (const header of response.headers) {
390
- res.append(header.name, header.value);
391
- }
392
- // express writes the reason phrase from `statusMessage`, so a translator that says
393
- // `new HttpResponseStatus(460, 'Order Not Found')` gets 'Order Not Found' on the status line
394
- // rather than node's blank default for an unregistered code.
395
- res.statusMessage = response.status.reason;
396
- const payload = response.body === undefined
397
- ? undefined
398
- : this.responsePayload(response, declaresContentType);
399
- res.status(response.status.code).send(payload);
400
- }
401
- /** JSON by default; explicit text content types write the caller's string verbatim. */
402
- responsePayload(response, declaresContentType) {
403
- const contentType = response.headers.find((header) => header.name.toLowerCase() === 'content-type')?.value;
404
- if (declaresContentType && contentType !== undefined && !/json/i.test(contentType)) {
405
- return typeof response.body === 'string' ? response.body : String(response.body);
406
- }
407
- return JSON.stringify(response.body) ?? '';
381
+ this.responseWriter.write(res, response);
408
382
  }
409
383
  /**
410
384
  * Put the transaction id on EVERY response — success and error, webpieces' default body and an
@@ -1 +1 @@
1
- {"version":3,"file":"ExpressWrapper.js","sourceRoot":"","sources":["../../../../../packages/http/http-server/src/ExpressWrapper.ts"],"names":[],"mappings":"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","sourcesContent":["import { Request, Response, NextFunction } from 'express';\nimport {\n ClientRegistry,\n ApiBadRequestError,\n HttpHeader,\n HttpContractMapper,\n HttpResponseDto,\n HttpResponseStatus,\n RouteMetadata,\n ApiImplementationError,\n toError,\n WebpiecesCoreHeaders,\n} from '@webpieces/core-util';\nimport {\n RequestContext,\n HttpRequest,\n RawRequest,\n RequestContextHeaders,\n} from '@webpieces/core-context';\nimport { ApiErrorHttpMapper, EndUserStatus } from './ApiErrorHttpMapper';\nimport { RequestBodyReader } from './body/RequestBodyReader';\nimport { StreamBodyReader } from './body/StreamBodyReader';\n\n/**\n * The cap on an inbound body, in bytes. Reading stops and the request is refused the moment a body\n * crosses it — the bytes already read are dropped, nothing further is buffered.\n *\n * There was NO limit at all before, which was a latent memory DoS on every route and an outright one\n * on a webhook route: `{ rawBody: true }` retains what it reads, and a webhook url is public by\n * construction, so the endpoint most likely to be flooded was also the one that held on to the flood.\n * 10 MiB is comfortably above any api DTO and well under Cloud Run's own 32 MiB request limit.\n *\n * Read this as FRAMEWORK-FIXED, because today an app has no knob for it. The only seam that accepts a\n * different number is the {@link ExpressWrapper} constructor parameter, and nothing production reaches\n * it: `WebpiecesMiddleware.createExpressWrapper` forwards no such argument, and neither\n * `ExpressWrapper` nor this constant is exported from this package's barrel (`src/index.ts`), so the\n * only caller that can vary the cap is a spec inside this package. An app that legitimately needs to\n * accept a larger body therefore cannot unblock itself and has to open an issue. Making it tunable is a\n * code change, not a config one — a follow-up has to thread a value through `createExpressWrapper` and\n * decide where an app declares it (per-route, most likely, since that is the granularity the need has).\n */\nexport const MAX_BODY_BYTES = 10 * 1024 * 1024;\n\n/**\n * What {@link ExpressWrapper.parseBody} produced: the DTO the controller receives, the verbatim\n * {@link RawRequest} when the route asked for one, and the held parse failure on a raw-body route.\n *\n * Data-only, so a class rather than an inline object literal, per the webpieces guidelines.\n */\nclass ParsedBody {\n constructor(\n // webpieces-disable no-any-unknown -- request/response DTOs are erased at the routing boundary\n public readonly requestDto: unknown,\n public readonly raw: RawRequest | undefined,\n /** Set only on a raw-body route, where the failure is HELD for AuthFilter instead of thrown. */\n public readonly parseError?: Error,\n ) {}\n}\n\nexport class ExpressWrapper {\n /**\n * Decides what an outside caller is allowed to see of a thrown {@link API error}. Stateless —\n * one instance per wrapper is fine, and the class doc there is where the \"only ApiEndUserError's\n * message goes on the wire\" rule is stated.\n */\n private readonly errorWireMapper: ApiErrorHttpMapper;\n\n constructor(\n // webpieces-disable no-any-unknown -- request/response DTOs are erased at the routing boundary\n private clientMethod: (...args: unknown[]) => Promise<unknown>,\n private path: string,\n /** Owns the wire<->context transfer, both directions. Stateless framework singleton. */\n private headers: RequestContextHeaders,\n /**\n * True for an @Endpoint(..., { formPost: true }) route: parse the body as\n * application/x-www-form-urlencoded (flat) instead of JSON. Driven by the ANNOTATION, not\n * the request Content-Type header — the annotation is the single source of truth.\n */\n private formPost: boolean = false,\n /**\n * True for an @Endpoint(..., { rawBody: true }) route: RETAIN the verbatim bytes + the\n * absolute url on the published {@link HttpRequest}, so an @WpAuthWebhook hook can verify a\n * vendor signature over what the sender actually transmitted. Also switches the JSON parse\n * failure from \"throw now\" to \"hold it for AuthFilter\" — see {@link RawRequest.bodyParseError}.\n */\n private rawBody: boolean = false,\n /**\n * The inbound body cap for this route. See {@link MAX_BODY_BYTES}.\n *\n * No production caller passes this — `WebpiecesMiddleware.createExpressWrapper` builds every\n * wrapper without it, so every live route runs on the default. The parameter exists so the\n * refusal path can be tested against a small cap instead of a 10 MiB fixture, and the specs in\n * this package are its only callers; it is not an app-facing tuning point, since the class is\n * not exported from `src/index.ts`. If a route ever needs a different cap, thread it through\n * `createExpressWrapper` rather than reaching around the middleware to construct a wrapper.\n */\n private maxBodyBytes: number = MAX_BODY_BYTES,\n /** Exact shared contract route; omitted only by focused legacy wrapper unit tests. */\n private readonly routeMeta?: RouteMetadata,\n /**\n * Where the body bytes come from. The default reads the request stream; a host that parses\n * the body before webpieces runs (Cloud Functions gen2) needs `PreConsumedBodyReader`,\n * chosen via `WebpiecesExpressRouter.setBodyReader`. See {@link RequestBodyReader}.\n */\n private readonly bodyReader: RequestBodyReader = new StreamBodyReader(),\n /**\n * How an `ApiEndUserError` is answered: 266 for a GUI edge, `edgeHttpStatus` (or 400) for a\n * partner-facing API edge. Chosen via `WebpiecesExpressRouter.setEndUserStatus`; see\n * {@link EndUserStatus}.\n */\n endUserStatus: EndUserStatus = 'gui',\n ) {\n this.errorWireMapper = new ApiErrorHttpMapper(endUserStatus);\n }\n\n public async execute(req: Request, res: Response, next: NextFunction): Promise<void> {\n // MOVED: Wrap entire request in RequestContext.run()\n // This establishes AsyncLocalStorage context for the request\n await RequestContext.run(async () => {\n await this.executeTryCatch(req, res, next);\n });\n }\n\n public async executeTryCatch(req: Request, res: Response, next: NextFunction): Promise<void> {\n // eslint-disable-next-line @webpieces/no-unmanaged-exceptions -- ExpressWrapper catches errors to translate to HTTP responses\n try {\n await this.executeImpl(req, res, next);\n } catch (err: unknown) {\n const error = toError(err);\n // 5. Handle errors\n this.handleError(res, error);\n }\n }\n\n public async executeImpl(req: Request, res: Response, next: NextFunction): Promise<void> {\n // 0. PUBLISH the transport-neutral request BEFORE anything that can throw.\n //\n // This is the ordering bug issue #862 was filed for. An app's ErrorTranslators.toWire has\n // to be able to tell WHICH request it is answering for — the surface, the route, the\n // method — and the only place that lives is RequestContext.getRequest(). Publishing it at\n // step 3 (below) meant a body that failed to parse at step 1 reached handleError with an\n // EMPTY scope, so a translator asked \"is this my surface?\" could only answer \"I don't\n // know\" and step aside. The translator was never too late; the context it needs was.\n //\n // No `raw` yet — the bytes have not been read. Step 3 republishes WITH them, below the\n // same request scope and still above the filter chain, so @WpAuthWebhook signature\n // verification sees exactly what it saw before.\n //\n // KNOWN ISSUE, ACCEPTED AND NOT FIXED (issue #862): this publishes the request but does\n // NOT mint the transaction id — that is `fillFromRequest`'s job and it stays at step 3,\n // below the body read. So a caller whose body is malformed or oversize, and who sent no\n // x-request-id of its own, gets an error response with NO transaction id to quote at\n // support. Moving the whole fill up here would drag the raw-body republish into every\n // route, which is the complexity this change deliberately declines. Pinned by a test so a\n // future change to it is a decision rather than an accident.\n RequestContext.setRequest(this.toWebpiecesRequest(req));\n\n // 1. Parse the request body (see parseBody: the PARSER is chosen by the @Endpoint\n // annotation, never by the request Content-Type header).\n const parsed = await this.parseBody(req);\n\n // 2. Translate express's request into the transport-neutral HttpRequest webpieces speaks —\n // now WITH the raw bytes, when the route asked for them.\n const httpRequest = this.toWebpiecesRequest(req, parsed.raw);\n\n // 3. Re-publish the transport-neutral HttpRequest, then move its headers into the context and\n // mint a request id if the caller sent none. BOTH happen above the api boundary, because\n // http-routing requires an already-established, already-filled request scope — it never\n // builds one for you. This is the \"translation layer\" every transport must provide.\n this.headers.fillFromRequest(httpRequest);\n\n // 4. Invoke the api CLIENT method — the SAME proxy tests use. Its filter chain + controller\n // run here, reading the context filled above; the chain never touches express `req`.\n const args = this.routeMeta\n ? HttpContractMapper.fromWire(\n this.routeMeta.parameterBindings,\n this.routeMeta.bodyParameterIndex,\n parsed.requestDto,\n this.pathValues(req),\n this.queryValues(req),\n )\n : [parsed.requestDto];\n const result = await this.clientMethod(...args);\n\n // 5. The contract chooses body-only 200 JSON or caller-owned status/headers/body.\n if (this.routeMeta?.responseType === 'full') {\n if (!(result instanceof HttpResponseDto)) {\n throw new ApiImplementationError(\n `${this.routeMeta.apiName}.${this.routeMeta.methodName} declares responseType:'full' ` +\n `but returned ${result instanceof Object ? result.constructor.name : typeof result}; ` +\n `return HttpResponseDto.`,\n );\n }\n this.send(res, result);\n return;\n }\n if (result instanceof HttpResponseDto) {\n throw new ApiImplementationError(\n `${this.routeMeta?.apiName ?? 'API'}.${this.routeMeta?.methodName ?? 'method'} returned ` +\n `HttpResponseDto but its @Endpoint does not declare responseType:'full'.`,\n );\n }\n this.send(res, new HttpResponseDto(new HttpResponseStatus(200, 'OK'), [], result));\n }\n\n /**\n * Read and parse the request body: the DTO the controller receives, plus the verbatim\n * {@link RawRequest} when the route asked for one.\n *\n * The PARSER is chosen by the `@Endpoint` annotation (`this.formPost`), NOT by the request\n * Content-Type header — the annotation is the single source of truth.\n */\n private async parseBody(req: Request): Promise<ParsedBody> {\n if (!['POST', 'PUT', 'PATCH'].includes(req.method)) {\n return new ParsedBody({}, undefined);\n }\n\n // Read BYTES, not text. Concatenating per-chunk toString() corrupted any multi-byte\n // character that straddled a chunk boundary — invisible on small bodies, and fatal for a\n // signature computed over the bytes.\n const bodyBytes = await this.bodyReader.read(req, this.maxBodyBytes);\n const bodyText = bodyBytes.toString('utf8');\n // webpieces-disable no-any-unknown -- request/response DTOs are erased at the routing boundary\n let requestDto: unknown;\n let parseError: Error | undefined;\n if (this.formPost) {\n // application/x-www-form-urlencoded → flat key→value. URLSearchParams is lenient\n // (never throws) — right for EXTERNAL webhooks (e.g. Twilio) that post form-encoded.\n requestDto = this.formBody(bodyText);\n } else {\n const json = this.parseJson(bodyText);\n requestDto = json.requestDto;\n parseError = json.parseError;\n }\n\n const raw = this.rawBody\n ? new RawRequest(\n this.absoluteUrl(req),\n bodyBytes,\n req.socket?.remoteAddress,\n parseError,\n )\n : undefined;\n return new ParsedBody(requestDto, raw);\n }\n\n /**\n * JSON (the default, SYMMETRIC with the client's JSON.stringify). A non-JSON body is a CLIENT\n * error → 400, not the raw 500 an unguarded JSON.parse would throw.\n *\n * On a raw-body (webhook) route the failure is HELD, not thrown: AuthFilter must answer 401 to an\n * unauthenticated caller rather than 400, because \"your JSON was bad\" tells that caller it got\n * past auth. Everywhere else, fail now.\n */\n private parseJson(bodyText: string): ParsedBody {\n // eslint-disable-next-line @webpieces/no-unmanaged-exceptions -- translate parse failure to an ApiBadRequestError\n try {\n return new ParsedBody(bodyText ? JSON.parse(bodyText) : {}, undefined);\n } catch (err: unknown) {\n const error = toError(err);\n if (!this.rawBody) {\n throw new ApiBadRequestError(\n 'Request body is not valid JSON',\n undefined,\n undefined,\n error,\n );\n }\n return new ParsedBody({}, undefined, error);\n }\n }\n\n /** Preserve repeated form keys as arrays while keeping the historical flat scalar shape. */\n private formBody(bodyText: string): Record<string, string | string[]> {\n const result: Record<string, string | string[]> = {};\n new URLSearchParams(bodyText).forEach((value: string, key: string) => {\n const existing = result[key];\n if (existing === undefined) result[key] = value;\n else if (Array.isArray(existing)) existing.push(value);\n else result[key] = [existing, value];\n });\n return result;\n }\n\n /** Express has already URL-decoded route placeholders by the time a handler runs. */\n private pathValues(req: Request): Map<string, string | readonly string[]> {\n const values = new Map<string, string | readonly string[]>();\n const params = req.params ?? {};\n for (const name of Object.keys(params)) {\n values.set(name, params[name]);\n }\n return values;\n }\n\n /** Parse the original query string so repeated keys survive instead of being collapsed. */\n private queryValues(req: Request): Map<string, string | readonly string[]> {\n const values = new Map<string, string | readonly string[]>();\n const rawUrl = req.originalUrl ?? req.url ?? '';\n const query = rawUrl.includes('?') ? rawUrl.slice(rawUrl.indexOf('?') + 1) : '';\n new URLSearchParams(query).forEach((value: string, name: string) => {\n const existing = values.get(name);\n if (existing === undefined) values.set(name, value);\n else if (typeof existing === 'string') values.set(name, [existing, value]);\n else values.set(name, [...existing, value]);\n });\n return values;\n }\n\n /**\n * Read HTTP headers from Express request.\n * Returns Map of header name (lowercase) -> array of values.\n *\n * HTTP spec allows multiple values for same header name.\n */\n /**\n * express Request -> webpieces {@link HttpRequest}. THE translation layer: below this line the\n * filter chain and controllers never see express, which is what lets the same chain run\n * in-process with no transport at all.\n */\n private toWebpiecesRequest(req: Request, raw?: RawRequest): HttpRequest {\n return new HttpRequest(\n req.method,\n req.path ?? this.path,\n this.readExpressHeaders(req),\n raw,\n );\n }\n\n /**\n * The absolute url AS THE SENDER ADDRESSED IT — the string a vendor like Twilio signed.\n *\n * `x-forwarded-proto` / `x-forwarded-host` WIN when present, because behind a TLS-terminating\n * proxy (Cloud Run, any load balancer) express's own view is wrong in both halves: `req.protocol`\n * reads `http` and the Host header is the internal one, while the vendor signed the public\n * `https://...` url the customer configured. Reconstructing naively therefore fails 100% of the\n * time in production and works 100% of the time locally — the worst possible pairing, so this is\n * stated here and pinned by a test rather than left to each app.\n *\n * These headers are attacker-controllable when nothing strips them, and that is ACCEPTABLE here\n * precisely because of what the value is used for: a forged url produces a signature that does not\n * verify, i.e. a 401. It grants nothing. (It is used for verification only — never for a redirect.)\n */\n private absoluteUrl(req: Request): string {\n const forwardedProto = req.headers['x-forwarded-proto'];\n const forwardedHost = req.headers['x-forwarded-host'];\n // A proxy chain sends a comma-separated list; the FIRST entry is the original client's hop.\n const proto = this.firstForwarded(forwardedProto) ?? req.protocol;\n const host = this.firstForwarded(forwardedHost) ?? req.get('host') ?? '';\n return `${proto}://${host}${req.originalUrl ?? req.url ?? this.path}`;\n }\n\n private firstForwarded(value: string | string[] | undefined): string | undefined {\n const raw = Array.isArray(value) ? value[0] : value;\n const first = raw?.split(',')[0]?.trim();\n return first === undefined || first === '' ? undefined : first;\n }\n\n private readExpressHeaders(req: Request): Map<string, string[]> {\n const headers = new Map<string, string[]>();\n\n // Express stores headers in req.headers as Record<string, string | string[]>\n for (const [name, value] of Object.entries(req.headers)) {\n const lowerName = name.toLowerCase();\n\n if (typeof value === 'string') {\n headers.set(lowerName, [value]);\n } else if (Array.isArray(value)) {\n headers.set(lowerName, value);\n }\n }\n\n return headers;\n }\n\n /**\n * Turn a thrown value into the response the caller sees — the SERVER half of webpieces' symmetric\n * error handling (the CLIENT half is `ClientErrorTranslator.translateError`, and the two speak the\n * same {@link HttpResponseDto}).\n *\n * PUBLIC so wrapExpress can call it for symmetric error handling.\n *\n * Two sources, in this order:\n * 1. the app's {@link ErrorTranslators}, if it claims the error — it owns the ENTIRE response;\n * 2. else {@link ApiErrorHttpMapper.toResponse}, the webpieces default, which maps every\n * `ApiError` subclass to its status (an `ApiEndUserError` per this wrapper's\n * {@link EndUserStatus}) and a CALLER-SAFE body (only `ApiEndUserError`'s message\n * is written for a human, so only it goes out verbatim; everything else sends the generic\n * reason phrase and logs the real one) and turns anything else into a generic 500.\n *\n * Nothing status-specific is decided here any more — read `ApiErrorHttpMapper` for the full rule\n * and the list of statuses, which must stay in step with `ClientErrorTranslator`'s built-in\n * mapping.\n */\n // webpieces-disable no-any-unknown -- a thrown error is genuinely unknown until narrowed below\n public handleError(res: Response, error: unknown): void {\n if (res.headersSent) {\n return;\n }\n\n // The app's ErrorTranslators own the WHOLE response — status code, reason phrase, headers\n // and body — so an app can publish its own envelope AND its own `Retry-After` /\n // `WWW-Authenticate` / trace header / cookie, which the old (statusCode, protocolError) pair\n // could not express at all. `undefined` means \"not mine\": webpieces' default answers\n // instead. Symmetric with the client's ClientErrorTranslator, which consults\n // tryTranslateFromWire() first, over the identical HttpResponseDto shape.\n const appResponse =\n error instanceof Error ? ClientRegistry.tryTranslateToWire(error) : undefined;\n\n this.send(res, appResponse ?? this.errorWireMapper.toResponse(error));\n }\n\n /**\n * Write an {@link HttpResponseDto} to express — the ONE place a response DTO becomes bytes, used\n * by the app's translators and by webpieces' own default alike.\n *\n * `append`, not `setHeader`, because the DTO's headers are a LIST: HTTP permits repeats\n * (`Set-Cookie` is the everyday one) and `setHeader` would keep only the last of them. That list\n * shape is the whole reason the DTO does not use a Map.\n *\n * `Content-Type: application/json` is INFRASTRUCTURE here, not app policy: webpieces is the one\n * doing the `JSON.stringify`, so it states what it wrote. Express would otherwise default a\n * string body to `text/html`, which is wrong for every response this framework sends. A\n * translator that genuinely wants another type says so in its own header list and wins.\n */\n private send(res: Response, response: HttpResponseDto): void {\n this.stampTransactionId(res);\n const declaresContentType = response.headers.some(\n (header: HttpHeader) => header.name.toLowerCase() === 'content-type',\n );\n if (!declaresContentType && response.body !== undefined) {\n res.setHeader('Content-Type', 'application/json');\n }\n for (const header of response.headers) {\n res.append(header.name, header.value);\n }\n // express writes the reason phrase from `statusMessage`, so a translator that says\n // `new HttpResponseStatus(460, 'Order Not Found')` gets 'Order Not Found' on the status line\n // rather than node's blank default for an unregistered code.\n res.statusMessage = response.status.reason;\n const payload =\n response.body === undefined\n ? undefined\n : this.responsePayload(response, declaresContentType);\n res.status(response.status.code).send(payload);\n }\n\n /** JSON by default; explicit text content types write the caller's string verbatim. */\n private responsePayload(response: HttpResponseDto, declaresContentType: boolean): string {\n const contentType = response.headers.find(\n (header: HttpHeader) => header.name.toLowerCase() === 'content-type',\n )?.value;\n if (declaresContentType && contentType !== undefined && !/json/i.test(contentType)) {\n return typeof response.body === 'string' ? response.body : String(response.body);\n }\n return JSON.stringify(response.body) ?? '';\n }\n\n /**\n * Put the transaction id on EVERY response — success and error, webpieces' default body and an\n * app's own. It is INFRASTRUCTURE, not app policy: an app that overrides what an error looks like\n * must not thereby lose the header its support desk quotes back. That is why this lives here and\n * not in {@link ApiErrorHttpMapper.toResponse} or in an app's translators.\n *\n * Silently absent when there is no id to send, which is exactly the accepted known issue recorded\n * at step 0 of {@link executeImpl}: a malformed or oversize body fails before `fillFromRequest`\n * mints one.\n */\n private stampTransactionId(res: Response): void {\n if (!RequestContext.isActive()) {\n return;\n }\n const txId = RequestContext.getUntrusted(WebpiecesCoreHeaders.REQUEST_ID);\n if (txId) {\n res.setHeader(WebpiecesCoreHeaders.REQUEST_ID.httpHeader!, txId);\n }\n }\n}\n"]}
1
+ {"version":3,"file":"ExpressWrapper.js","sourceRoot":"","sources":["../../../../../packages/http/http-server/src/ExpressWrapper.ts"],"names":[],"mappings":"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","sourcesContent":["import { Request, Response, NextFunction } from 'express';\nimport {\n ClientRegistry,\n ApiBadRequestError,\n HttpContractMapper,\n HttpResponseDto,\n HttpResponseStatus,\n RouteMetadata,\n ApiImplementationError,\n toError,\n WebpiecesCoreHeaders,\n} from '@webpieces/core-util';\nimport {\n RequestContext,\n HttpRequest,\n RawRequest,\n RequestContextHeaders,\n} from '@webpieces/core-context';\nimport { ExpressResponseWriter } from './ExpressResponseWriter';\nimport { ApiErrorHttpMapper, EndUserStatus } from './ApiErrorHttpMapper';\nimport { RequestBodyReader } from './body/RequestBodyReader';\nimport { StreamBodyReader } from './body/StreamBodyReader';\n\n/**\n * The cap on an inbound body, in bytes. Reading stops and the request is refused the moment a body\n * crosses it — the bytes already read are dropped, nothing further is buffered.\n *\n * There was NO limit at all before, which was a latent memory DoS on every route and an outright one\n * on a webhook route: `{ rawBody: true }` retains what it reads, and a webhook url is public by\n * construction, so the endpoint most likely to be flooded was also the one that held on to the flood.\n * 10 MiB is comfortably above any api DTO and well under Cloud Run's own 32 MiB request limit.\n *\n * Read this as FRAMEWORK-FIXED, because today an app has no knob for it. The only seam that accepts a\n * different number is the {@link ExpressWrapper} constructor parameter, and nothing production reaches\n * it: `WebpiecesMiddleware.createExpressWrapper` forwards no such argument, and neither\n * `ExpressWrapper` nor this constant is exported from this package's barrel (`src/index.ts`), so the\n * only caller that can vary the cap is a spec inside this package. An app that legitimately needs to\n * accept a larger body therefore cannot unblock itself and has to open an issue. Making it tunable is a\n * code change, not a config one — a follow-up has to thread a value through `createExpressWrapper` and\n * decide where an app declares it (per-route, most likely, since that is the granularity the need has).\n */\nexport const MAX_BODY_BYTES = 10 * 1024 * 1024;\n\n/**\n * What {@link ExpressWrapper.parseBody} produced: the DTO the controller receives, the verbatim\n * {@link RawRequest} when the route asked for one, and the held parse failure on a raw-body route.\n *\n * Data-only, so a class rather than an inline object literal, per the webpieces guidelines.\n */\nclass ParsedBody {\n constructor(\n // webpieces-disable no-any-unknown -- request/response DTOs are erased at the routing boundary\n public readonly requestDto: unknown,\n public readonly raw: RawRequest | undefined,\n /** Set only on a raw-body route, where the failure is HELD for AuthFilter instead of thrown. */\n public readonly parseError?: Error,\n ) {}\n}\n\nexport class ExpressWrapper {\n /**\n * Decides what an outside caller is allowed to see of a thrown {@link API error}. Stateless —\n * one instance per wrapper is fine, and the class doc there is where the \"only ApiEndUserError's\n * message goes on the wire\" rule is stated.\n */\n private readonly errorWireMapper: ApiErrorHttpMapper;\n private readonly responseWriter = new ExpressResponseWriter();\n\n constructor(\n // webpieces-disable no-any-unknown -- request/response DTOs are erased at the routing boundary\n private clientMethod: (...args: unknown[]) => Promise<unknown>,\n private path: string,\n /** Owns the wire<->context transfer, both directions. Stateless framework singleton. */\n private headers: RequestContextHeaders,\n /**\n * True for an @Endpoint(..., { formPost: true }) route: parse the body as\n * application/x-www-form-urlencoded (flat) instead of JSON. Driven by the ANNOTATION, not\n * the request Content-Type header — the annotation is the single source of truth.\n */\n private formPost: boolean = false,\n /**\n * True for an @Endpoint(..., { rawBody: true }) route: RETAIN the verbatim bytes + the\n * absolute url on the published {@link HttpRequest}, so an @WpAuthWebhook hook can verify a\n * vendor signature over what the sender actually transmitted. Also switches the JSON parse\n * failure from \"throw now\" to \"hold it for AuthFilter\" — see {@link RawRequest.bodyParseError}.\n */\n private rawBody: boolean = false,\n /**\n * The inbound body cap for this route. See {@link MAX_BODY_BYTES}.\n *\n * No production caller passes this — `WebpiecesMiddleware.createExpressWrapper` builds every\n * wrapper without it, so every live route runs on the default. The parameter exists so the\n * refusal path can be tested against a small cap instead of a 10 MiB fixture, and the specs in\n * this package are its only callers; it is not an app-facing tuning point, since the class is\n * not exported from `src/index.ts`. If a route ever needs a different cap, thread it through\n * `createExpressWrapper` rather than reaching around the middleware to construct a wrapper.\n */\n private maxBodyBytes: number = MAX_BODY_BYTES,\n /** Exact shared contract route; omitted only by focused legacy wrapper unit tests. */\n private readonly routeMeta?: RouteMetadata,\n /**\n * Where the body bytes come from. The default reads the request stream; a host that parses\n * the body before webpieces runs (Cloud Functions gen2) needs `PreConsumedBodyReader`,\n * chosen via `WebpiecesExpressRouter.setBodyReader`. See {@link RequestBodyReader}.\n */\n private readonly bodyReader: RequestBodyReader = new StreamBodyReader(),\n /**\n * How an `ApiEndUserError` is answered: 266 for a GUI edge, `edgeHttpStatus` (or 400) for a\n * partner-facing API edge. Chosen via `WebpiecesExpressRouter.setEndUserStatus`; see\n * {@link EndUserStatus}.\n */\n endUserStatus: EndUserStatus = 'gui',\n ) {\n this.errorWireMapper = new ApiErrorHttpMapper(endUserStatus);\n }\n\n public async execute(req: Request, res: Response, next: NextFunction): Promise<void> {\n // MOVED: Wrap entire request in RequestContext.run()\n // This establishes AsyncLocalStorage context for the request\n await RequestContext.run(async () => {\n await this.executeTryCatch(req, res, next);\n });\n }\n\n public async executeTryCatch(req: Request, res: Response, next: NextFunction): Promise<void> {\n // eslint-disable-next-line @webpieces/no-unmanaged-exceptions -- ExpressWrapper catches errors to translate to HTTP responses\n try {\n await this.executeImpl(req, res, next);\n } catch (err: unknown) {\n const error = toError(err);\n // 5. Handle errors\n this.handleError(res, error);\n }\n }\n\n public async executeImpl(req: Request, res: Response, next: NextFunction): Promise<void> {\n // 0. PUBLISH the transport-neutral request BEFORE anything that can throw.\n //\n // This is the ordering bug issue #862 was filed for. An app's ErrorTranslators.toWire has\n // to be able to tell WHICH request it is answering for — the surface, the route, the\n // method — and the only place that lives is RequestContext.getRequest(). Publishing it at\n // step 3 (below) meant a body that failed to parse at step 1 reached handleError with an\n // EMPTY scope, so a translator asked \"is this my surface?\" could only answer \"I don't\n // know\" and step aside. The translator was never too late; the context it needs was.\n //\n // No `raw` yet — the bytes have not been read. Step 3 republishes WITH them, below the\n // same request scope and still above the filter chain, so @WpAuthWebhook signature\n // verification sees exactly what it saw before.\n //\n // KNOWN ISSUE, ACCEPTED AND NOT FIXED (issue #862): this publishes the request but does\n // NOT mint the transaction id — that is `fillFromRequest`'s job and it stays at step 3,\n // below the body read. So a caller whose body is malformed or oversize, and who sent no\n // x-request-id of its own, gets an error response with NO transaction id to quote at\n // support. Moving the whole fill up here would drag the raw-body republish into every\n // route, which is the complexity this change deliberately declines. Pinned by a test so a\n // future change to it is a decision rather than an accident.\n RequestContext.setRequest(this.toWebpiecesRequest(req));\n\n // 1. Parse the request body (see parseBody: the PARSER is chosen by the @Endpoint\n // annotation, never by the request Content-Type header).\n const parsed = await this.parseBody(req);\n\n // 2. Translate express's request into the transport-neutral HttpRequest webpieces speaks —\n // now WITH the raw bytes, when the route asked for them.\n const httpRequest = this.toWebpiecesRequest(req, parsed.raw);\n\n // 3. Re-publish the transport-neutral HttpRequest, then move its headers into the context and\n // mint a request id if the caller sent none. BOTH happen above the api boundary, because\n // http-routing requires an already-established, already-filled request scope — it never\n // builds one for you. This is the \"translation layer\" every transport must provide.\n this.headers.fillFromRequest(httpRequest);\n\n // 4. Invoke the api CLIENT method — the SAME proxy tests use. Its filter chain + controller\n // run here, reading the context filled above; the chain never touches express `req`.\n const args = this.routeMeta\n ? HttpContractMapper.fromWire(\n this.routeMeta.parameterBindings,\n this.routeMeta.bodyParameterIndex,\n parsed.requestDto,\n this.pathValues(req),\n this.queryValues(req),\n )\n : [parsed.requestDto];\n const result = await this.clientMethod(...args);\n\n // 5. The contract chooses body-only 200 JSON or caller-owned status/headers/body.\n if (this.routeMeta?.responseType === 'full') {\n if (!(result instanceof HttpResponseDto)) {\n throw new ApiImplementationError(\n `${this.routeMeta.apiName}.${this.routeMeta.methodName} declares responseType:'full' ` +\n `but returned ${result instanceof Object ? result.constructor.name : typeof result}; ` +\n `return HttpResponseDto.`,\n );\n }\n this.send(res, result);\n return;\n }\n if (result instanceof HttpResponseDto) {\n throw new ApiImplementationError(\n `${this.routeMeta?.apiName ?? 'API'}.${this.routeMeta?.methodName ?? 'method'} returned ` +\n `HttpResponseDto but its @Endpoint does not declare responseType:'full'.`,\n );\n }\n this.send(res, new HttpResponseDto(new HttpResponseStatus(200, 'OK'), [], result));\n }\n\n /**\n * Read and parse the request body: the DTO the controller receives, plus the verbatim\n * {@link RawRequest} when the route asked for one.\n *\n * The PARSER is chosen by the `@Endpoint` annotation (`this.formPost`), NOT by the request\n * Content-Type header — the annotation is the single source of truth.\n */\n private async parseBody(req: Request): Promise<ParsedBody> {\n if (!['POST', 'PUT', 'PATCH'].includes(req.method)) {\n return new ParsedBody({}, undefined);\n }\n\n // Read BYTES, not text. Concatenating per-chunk toString() corrupted any multi-byte\n // character that straddled a chunk boundary — invisible on small bodies, and fatal for a\n // signature computed over the bytes.\n const bodyBytes = await this.bodyReader.read(req, this.maxBodyBytes);\n const bodyText = bodyBytes.toString('utf8');\n // webpieces-disable no-any-unknown -- request/response DTOs are erased at the routing boundary\n let requestDto: unknown;\n let parseError: Error | undefined;\n if (this.formPost) {\n // application/x-www-form-urlencoded → flat key→value. URLSearchParams is lenient\n // (never throws) — right for EXTERNAL webhooks (e.g. Twilio) that post form-encoded.\n requestDto = this.formBody(bodyText);\n } else {\n const json = this.parseJson(bodyText);\n requestDto = json.requestDto;\n parseError = json.parseError;\n }\n\n const raw = this.rawBody\n ? new RawRequest(\n this.absoluteUrl(req),\n bodyBytes,\n req.socket?.remoteAddress,\n parseError,\n )\n : undefined;\n return new ParsedBody(requestDto, raw);\n }\n\n /**\n * JSON (the default, SYMMETRIC with the client's JSON.stringify). A non-JSON body is a CLIENT\n * error → 400, not the raw 500 an unguarded JSON.parse would throw.\n *\n * On a raw-body (webhook) route the failure is HELD, not thrown: AuthFilter must answer 401 to an\n * unauthenticated caller rather than 400, because \"your JSON was bad\" tells that caller it got\n * past auth. Everywhere else, fail now.\n */\n private parseJson(bodyText: string): ParsedBody {\n // eslint-disable-next-line @webpieces/no-unmanaged-exceptions -- translate parse failure to an ApiBadRequestError\n try {\n return new ParsedBody(bodyText ? JSON.parse(bodyText) : {}, undefined);\n } catch (err: unknown) {\n const error = toError(err);\n if (!this.rawBody) {\n throw new ApiBadRequestError(\n 'Request body is not valid JSON',\n undefined,\n undefined,\n error,\n );\n }\n return new ParsedBody({}, undefined, error);\n }\n }\n\n /** Preserve repeated form keys as arrays while keeping the historical flat scalar shape. */\n private formBody(bodyText: string): Record<string, string | string[]> {\n const result: Record<string, string | string[]> = {};\n new URLSearchParams(bodyText).forEach((value: string, key: string) => {\n const existing = result[key];\n if (existing === undefined) result[key] = value;\n else if (Array.isArray(existing)) existing.push(value);\n else result[key] = [existing, value];\n });\n return result;\n }\n\n /** Express has already URL-decoded route placeholders by the time a handler runs. */\n private pathValues(req: Request): Map<string, string | readonly string[]> {\n const values = new Map<string, string | readonly string[]>();\n const params = req.params ?? {};\n for (const name of Object.keys(params)) {\n values.set(name, params[name]);\n }\n return values;\n }\n\n /** Parse the original query string so repeated keys survive instead of being collapsed. */\n private queryValues(req: Request): Map<string, string | readonly string[]> {\n const values = new Map<string, string | readonly string[]>();\n const rawUrl = req.originalUrl ?? req.url ?? '';\n const query = rawUrl.includes('?') ? rawUrl.slice(rawUrl.indexOf('?') + 1) : '';\n new URLSearchParams(query).forEach((value: string, name: string) => {\n const existing = values.get(name);\n if (existing === undefined) values.set(name, value);\n else if (typeof existing === 'string') values.set(name, [existing, value]);\n else values.set(name, [...existing, value]);\n });\n return values;\n }\n\n /**\n * Read HTTP headers from Express request.\n * Returns Map of header name (lowercase) -> array of values.\n *\n * HTTP spec allows multiple values for same header name.\n */\n /**\n * express Request -> webpieces {@link HttpRequest}. THE translation layer: below this line the\n * filter chain and controllers never see express, which is what lets the same chain run\n * in-process with no transport at all.\n */\n private toWebpiecesRequest(req: Request, raw?: RawRequest): HttpRequest {\n return new HttpRequest(\n req.method,\n req.path ?? this.path,\n this.readExpressHeaders(req),\n raw,\n );\n }\n\n /**\n * The absolute url AS THE SENDER ADDRESSED IT — the string a vendor like Twilio signed.\n *\n * `x-forwarded-proto` / `x-forwarded-host` WIN when present, because behind a TLS-terminating\n * proxy (Cloud Run, any load balancer) express's own view is wrong in both halves: `req.protocol`\n * reads `http` and the Host header is the internal one, while the vendor signed the public\n * `https://...` url the customer configured. Reconstructing naively therefore fails 100% of the\n * time in production and works 100% of the time locally — the worst possible pairing, so this is\n * stated here and pinned by a test rather than left to each app.\n *\n * These headers are attacker-controllable when nothing strips them, and that is ACCEPTABLE here\n * precisely because of what the value is used for: a forged url produces a signature that does not\n * verify, i.e. a 401. It grants nothing. (It is used for verification only — never for a redirect.)\n */\n private absoluteUrl(req: Request): string {\n const forwardedProto = req.headers['x-forwarded-proto'];\n const forwardedHost = req.headers['x-forwarded-host'];\n // A proxy chain sends a comma-separated list; the FIRST entry is the original client's hop.\n const proto = this.firstForwarded(forwardedProto) ?? req.protocol;\n const host = this.firstForwarded(forwardedHost) ?? req.get('host') ?? '';\n return `${proto}://${host}${req.originalUrl ?? req.url ?? this.path}`;\n }\n\n private firstForwarded(value: string | string[] | undefined): string | undefined {\n const raw = Array.isArray(value) ? value[0] : value;\n const first = raw?.split(',')[0]?.trim();\n return first === undefined || first === '' ? undefined : first;\n }\n\n private readExpressHeaders(req: Request): Map<string, string[]> {\n const headers = new Map<string, string[]>();\n\n // Express stores headers in req.headers as Record<string, string | string[]>\n for (const [name, value] of Object.entries(req.headers)) {\n const lowerName = name.toLowerCase();\n\n if (typeof value === 'string') {\n headers.set(lowerName, [value]);\n } else if (Array.isArray(value)) {\n headers.set(lowerName, value);\n }\n }\n\n return headers;\n }\n\n /**\n * Turn a thrown value into the response the caller sees — the SERVER half of webpieces' symmetric\n * error handling (the CLIENT half is `ClientErrorTranslator.translateError`, and the two speak the\n * same {@link HttpResponseDto}).\n *\n * PUBLIC so wrapExpress can call it for symmetric error handling.\n *\n * Two sources, in this order:\n * 1. the app's {@link ErrorTranslators}, if it claims the error — it owns the ENTIRE response;\n * 2. else {@link ApiErrorHttpMapper.toResponse}, the webpieces default, which maps every\n * `ApiError` subclass to its status (an `ApiEndUserError` per this wrapper's\n * {@link EndUserStatus}) and a CALLER-SAFE body (only `ApiEndUserError`'s message\n * is written for a human, so only it goes out verbatim; everything else sends the generic\n * reason phrase and logs the real one) and turns anything else into a generic 500.\n *\n * Nothing status-specific is decided here any more — read `ApiErrorHttpMapper` for the full rule\n * and the list of statuses, which must stay in step with `ClientErrorTranslator`'s built-in\n * mapping.\n */\n // webpieces-disable no-any-unknown -- a thrown value is genuinely unknown until toError narrows it\n public handleError(res: Response, thrown: unknown): void {\n if (res.headersSent) {\n return;\n }\n\n // ONE narrowing, at the top, and every layer below it is honest about holding an `Error`.\n const error = toError(thrown);\n // The app's ErrorTranslators own the WHOLE response — status code, reason phrase, headers\n // and body — so an app can publish its own envelope AND its own `Retry-After` /\n // `WWW-Authenticate` / trace header / cookie, which the old (statusCode, protocolError) pair\n // could not express at all. A registered translator REPLACES this default and declines by\n // delegating back to it, so there is no per-error \"not mine\" branch here: the only question\n // is whether this process installed one.\n const translators = ClientRegistry.getErrorTranslators();\n this.send(\n res,\n translators ? translators.toWire(error) : this.errorWireMapper.toResponse(error),\n );\n }\n\n /**\n * Stamp the transaction id and hand the DTO to the shared {@link ExpressResponseWriter} — the\n * ONE place a response DTO becomes bytes, used by the app's translators, by webpieces' own\n * default, and by the MCP pre-SDK boundary alike.\n */\n private send(res: Response, response: HttpResponseDto): void {\n this.stampTransactionId(res);\n this.responseWriter.write(res, response);\n }\n\n /**\n * Put the transaction id on EVERY response — success and error, webpieces' default body and an\n * app's own. It is INFRASTRUCTURE, not app policy: an app that overrides what an error looks like\n * must not thereby lose the header its support desk quotes back. That is why this lives here and\n * not in {@link ApiErrorHttpMapper.toResponse} or in an app's translators.\n *\n * Silently absent when there is no id to send, which is exactly the accepted known issue recorded\n * at step 0 of {@link executeImpl}: a malformed or oversize body fails before `fillFromRequest`\n * mints one.\n */\n private stampTransactionId(res: Response): void {\n if (!RequestContext.isActive()) {\n return;\n }\n const txId = RequestContext.getUntrusted(WebpiecesCoreHeaders.REQUEST_ID);\n if (txId) {\n res.setHeader(WebpiecesCoreHeaders.REQUEST_ID.httpHeader!, txId);\n }\n }\n}\n"]}
package/src/index.d.ts CHANGED
@@ -5,6 +5,7 @@ export { StreamBodyReader } from './body/StreamBodyReader';
5
5
  export { PreConsumedBodyReader } from './body/PreConsumedBodyReader';
6
6
  export { RecordingFilter } from './filters/RecordingFilter';
7
7
  export { ApiErrorHttpMapper } from './ApiErrorHttpMapper';
8
+ export { ExpressResponseWriter } from './ExpressResponseWriter';
8
9
  export type { EndUserStatus } from './ApiErrorHttpMapper';
9
10
  export { TestCaseRecorderImpl } from './recorder/TestCaseRecorderImpl';
10
11
  export { SpecGenerator } from './recorder/SpecGenerator';
package/src/index.js CHANGED
@@ -1,6 +1,6 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
- exports.SharedSecrets = exports.AuthenticatedCaller = exports.AuthConfig = exports.HttpRequest = exports.ApiClient = exports.FilterDefinition = exports.FilterMatcher = exports.MethodMeta = exports.RouteHandler = exports.HeaderRegistry = exports.WebpiecesCoreHeaders = exports.recordable = exports.SpecGenerator = exports.TestCaseRecorderImpl = exports.ApiErrorHttpMapper = exports.RecordingFilter = exports.PreConsumedBodyReader = exports.StreamBodyReader = exports.WebpiecesMiddleware = exports.WebpiecesExpressRouter = void 0;
3
+ exports.SharedSecrets = exports.AuthenticatedCaller = exports.AuthConfig = exports.HttpRequest = exports.ApiClient = exports.FilterDefinition = exports.FilterMatcher = exports.MethodMeta = exports.RouteHandler = exports.HeaderRegistry = exports.WebpiecesCoreHeaders = exports.recordable = exports.SpecGenerator = exports.TestCaseRecorderImpl = exports.ExpressResponseWriter = exports.ApiErrorHttpMapper = exports.RecordingFilter = exports.PreConsumedBodyReader = exports.StreamBodyReader = exports.WebpiecesMiddleware = exports.WebpiecesExpressRouter = void 0;
4
4
  // Express adapter (the only place express lifecycle lives) over the node-only ApiFactory
5
5
  var WebpiecesExpressRouter_1 = require("./WebpiecesExpressRouter");
6
6
  Object.defineProperty(exports, "WebpiecesExpressRouter", { enumerable: true, get: function () { return WebpiecesExpressRouter_1.WebpiecesExpressRouter; } });
@@ -20,6 +20,8 @@ Object.defineProperty(exports, "RecordingFilter", { enumerable: true, get: funct
20
20
  // plus one header") rather than copying its status-to-message table.
21
21
  var ApiErrorHttpMapper_1 = require("./ApiErrorHttpMapper");
22
22
  Object.defineProperty(exports, "ApiErrorHttpMapper", { enumerable: true, get: function () { return ApiErrorHttpMapper_1.ApiErrorHttpMapper; } });
23
+ var ExpressResponseWriter_1 = require("./ExpressResponseWriter");
24
+ Object.defineProperty(exports, "ExpressResponseWriter", { enumerable: true, get: function () { return ExpressResponseWriter_1.ExpressResponseWriter; } });
23
25
  // Test-case recording (contract lives in @webpieces/core-util)
24
26
  var TestCaseRecorderImpl_1 = require("./recorder/TestCaseRecorderImpl");
25
27
  Object.defineProperty(exports, "TestCaseRecorderImpl", { enumerable: true, get: function () { return TestCaseRecorderImpl_1.TestCaseRecorderImpl; } });
package/src/index.js.map CHANGED
@@ -1 +1 @@
1
- {"version":3,"file":"index.js","sourceRoot":"","sources":["../../../../../packages/http/http-server/src/index.ts"],"names":[],"mappings":";;;AAAA,yFAAyF;AACzF,mEAAkE;AAAzD,gIAAA,sBAAsB,OAAA;AAC/B,6DAA4D;AAAnD,0HAAA,mBAAmB,OAAA;AAI5B,4DAA2D;AAAlD,oHAAA,gBAAgB,OAAA;AACzB,sEAAqE;AAA5D,8HAAA,qBAAqB,OAAA;AAC9B,gGAAgG;AAChG,+FAA+F;AAC/F,+FAA+F;AAC/F,6DAA4D;AAAnD,kHAAA,eAAe,OAAA;AAExB,4FAA4F;AAC5F,oGAAoG;AACpG,qEAAqE;AACrE,2DAA0D;AAAjD,wHAAA,kBAAkB,OAAA;AAK3B,+DAA+D;AAC/D,wEAAuE;AAA9D,4HAAA,oBAAoB,OAAA;AAC7B,0DAAyD;AAAhD,8GAAA,aAAa,OAAA;AACtB,oDAAmD;AAA1C,wGAAA,UAAU,OAAA;AAEnB,mEAAmE;AACnE,uEAAsE;AAA7D,4HAAA,oBAAoB,OAAA;AAC7B,kDAAsD;AAA7C,2GAAA,cAAc,OAAA;AAEvB,gEAAgE;AAChE,wDAYiC;AAX7B,4GAAA,YAAY,OAAA;AACZ,0GAAA,UAAU,OAAA;AAEV,6GAAA,aAAa,OAAA;AACb,gHAAA,gBAAgB,OAAA;AAEhB,yGAAA,SAAS,OAAA;AACT,2GAAA,WAAW,OAAA;AACX,0GAAA,UAAU,OAAA;AACV,mHAAA,mBAAmB,OAAA;AACnB,6GAAA,aAAa,OAAA","sourcesContent":["// Express adapter (the only place express lifecycle lives) over the node-only ApiFactory\nexport { WebpiecesExpressRouter } from './WebpiecesExpressRouter';\nexport { WebpiecesMiddleware } from './WebpiecesMiddleware';\n// How routes read the body bytes: stream (default) or a host's pre-consumed req.rawBody (issue #937).\n// Chosen via WebpiecesExpressRouter.setBodyReader(...) before bindExpress(app).\nexport { RequestBodyReader } from './body/RequestBodyReader';\nexport { StreamBodyReader } from './body/StreamBodyReader';\nexport { PreConsumedBodyReader } from './body/PreConsumedBodyReader';\n// LogApiFilter is now a FIXED framework filter auto-installed by WebpiecesRouter (outermost, at\n// priority 1,000,000, above AuthFilter). Apps must NOT install it themselves — it is no longer\n// exported here. Remove any `router.addFilter(new FilterDefinition(1800, LogApiFilter, '*'))`.\nexport { RecordingFilter } from './filters/RecordingFilter';\n\n// The webpieces DEFAULT error response, exported as a DELEGABLE object: it returns the same\n// HttpResponseDto an app's ErrorTranslators.toWire returns, so an app wraps it (\"webpieces' answer,\n// plus one header\") rather than copying its status-to-message table.\nexport { ApiErrorHttpMapper } from './ApiErrorHttpMapper';\n// How ApiEndUserError is answered: 'gui' (266, default) or 'edge' (its edgeHttpStatus, else 400).\n// Chosen via WebpiecesExpressRouter.setEndUserStatus(...) before bindExpress(app) (issue #948).\nexport type { EndUserStatus } from './ApiErrorHttpMapper';\n\n// Test-case recording (contract lives in @webpieces/core-util)\nexport { TestCaseRecorderImpl } from './recorder/TestCaseRecorderImpl';\nexport { SpecGenerator } from './recorder/SpecGenerator';\nexport { recordable } from './recorder/recordable';\n\n// Context keys + registry (the global magic-context header system)\nexport { WebpiecesCoreHeaders } from './headers/WebpiecesCoreHeaders';\nexport { HeaderRegistry } from '@webpieces/core-util';\n\n// Re-export from http-routing for one-import adapter ergonomics\nexport {\n RouteHandler,\n MethodMeta,\n HttpFilter,\n FilterMatcher,\n FilterDefinition,\n ApiFactory,\n ApiClient,\n HttpRequest,\n AuthConfig,\n AuthenticatedCaller,\n SharedSecrets,\n} from '@webpieces/http-routing';\n// ExpressRouteHandler now lives in http-server (express adapter), not node-only http-routing\nexport { ExpressRouteHandler } from './WebpiecesMiddleware';\n"]}
1
+ {"version":3,"file":"index.js","sourceRoot":"","sources":["../../../../../packages/http/http-server/src/index.ts"],"names":[],"mappings":";;;AAAA,yFAAyF;AACzF,mEAAkE;AAAzD,gIAAA,sBAAsB,OAAA;AAC/B,6DAA4D;AAAnD,0HAAA,mBAAmB,OAAA;AAI5B,4DAA2D;AAAlD,oHAAA,gBAAgB,OAAA;AACzB,sEAAqE;AAA5D,8HAAA,qBAAqB,OAAA;AAC9B,gGAAgG;AAChG,+FAA+F;AAC/F,+FAA+F;AAC/F,6DAA4D;AAAnD,kHAAA,eAAe,OAAA;AAExB,4FAA4F;AAC5F,oGAAoG;AACpG,qEAAqE;AACrE,2DAA0D;AAAjD,wHAAA,kBAAkB,OAAA;AAC3B,iEAAgE;AAAvD,8HAAA,qBAAqB,OAAA;AAK9B,+DAA+D;AAC/D,wEAAuE;AAA9D,4HAAA,oBAAoB,OAAA;AAC7B,0DAAyD;AAAhD,8GAAA,aAAa,OAAA;AACtB,oDAAmD;AAA1C,wGAAA,UAAU,OAAA;AAEnB,mEAAmE;AACnE,uEAAsE;AAA7D,4HAAA,oBAAoB,OAAA;AAC7B,kDAAsD;AAA7C,2GAAA,cAAc,OAAA;AAEvB,gEAAgE;AAChE,wDAYiC;AAX7B,4GAAA,YAAY,OAAA;AACZ,0GAAA,UAAU,OAAA;AAEV,6GAAA,aAAa,OAAA;AACb,gHAAA,gBAAgB,OAAA;AAEhB,yGAAA,SAAS,OAAA;AACT,2GAAA,WAAW,OAAA;AACX,0GAAA,UAAU,OAAA;AACV,mHAAA,mBAAmB,OAAA;AACnB,6GAAA,aAAa,OAAA","sourcesContent":["// Express adapter (the only place express lifecycle lives) over the node-only ApiFactory\nexport { WebpiecesExpressRouter } from './WebpiecesExpressRouter';\nexport { WebpiecesMiddleware } from './WebpiecesMiddleware';\n// How routes read the body bytes: stream (default) or a host's pre-consumed req.rawBody (issue #937).\n// Chosen via WebpiecesExpressRouter.setBodyReader(...) before bindExpress(app).\nexport { RequestBodyReader } from './body/RequestBodyReader';\nexport { StreamBodyReader } from './body/StreamBodyReader';\nexport { PreConsumedBodyReader } from './body/PreConsumedBodyReader';\n// LogApiFilter is now a FIXED framework filter auto-installed by WebpiecesRouter (outermost, at\n// priority 1,000,000, above AuthFilter). Apps must NOT install it themselves — it is no longer\n// exported here. Remove any `router.addFilter(new FilterDefinition(1800, LogApiFilter, '*'))`.\nexport { RecordingFilter } from './filters/RecordingFilter';\n\n// The webpieces DEFAULT error response, exported as a DELEGABLE object: it returns the same\n// HttpResponseDto an app's ErrorTranslators.toWire returns, so an app wraps it (\"webpieces' answer,\n// plus one header\") rather than copying its status-to-message table.\nexport { ApiErrorHttpMapper } from './ApiErrorHttpMapper';\nexport { ExpressResponseWriter } from './ExpressResponseWriter';\n// How ApiEndUserError is answered: 'gui' (266, default) or 'edge' (its edgeHttpStatus, else 400).\n// Chosen via WebpiecesExpressRouter.setEndUserStatus(...) before bindExpress(app) (issue #948).\nexport type { EndUserStatus } from './ApiErrorHttpMapper';\n\n// Test-case recording (contract lives in @webpieces/core-util)\nexport { TestCaseRecorderImpl } from './recorder/TestCaseRecorderImpl';\nexport { SpecGenerator } from './recorder/SpecGenerator';\nexport { recordable } from './recorder/recordable';\n\n// Context keys + registry (the global magic-context header system)\nexport { WebpiecesCoreHeaders } from './headers/WebpiecesCoreHeaders';\nexport { HeaderRegistry } from '@webpieces/core-util';\n\n// Re-export from http-routing for one-import adapter ergonomics\nexport {\n RouteHandler,\n MethodMeta,\n HttpFilter,\n FilterMatcher,\n FilterDefinition,\n ApiFactory,\n ApiClient,\n HttpRequest,\n AuthConfig,\n AuthenticatedCaller,\n SharedSecrets,\n} from '@webpieces/http-routing';\n// ExpressRouteHandler now lives in http-server (express adapter), not node-only http-routing\nexport { ExpressRouteHandler } from './WebpiecesMiddleware';\n"]}