@webpieces/http-server 0.4.741 → 0.4.742

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.741",
3
+ "version": "0.4.742",
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.741",
26
- "@webpieces/core-util": "0.4.741",
27
- "@webpieces/gcp-identity": "0.4.741",
28
- "@webpieces/http-routing": "0.4.741",
25
+ "@webpieces/core-context": "0.4.742",
26
+ "@webpieces/core-util": "0.4.742",
27
+ "@webpieces/gcp-identity": "0.4.742",
28
+ "@webpieces/http-routing": "0.4.742",
29
29
  "cors": "2.8.5",
30
30
  "express": "5.1.0",
31
31
  "inversify": "7.10.4"
@@ -84,6 +84,23 @@ export declare class ExpressWrapper {
84
84
  execute(req: Request, res: Response, next: NextFunction): Promise<void>;
85
85
  executeTryCatch(req: Request, res: Response, next: NextFunction): Promise<void>;
86
86
  executeImpl(req: Request, res: Response, next: NextFunction): Promise<void>;
87
+ /**
88
+ * Read and parse the request body: the DTO the controller receives, plus the verbatim
89
+ * {@link RawRequest} when the route asked for one.
90
+ *
91
+ * The PARSER is chosen by the `@Endpoint` annotation (`this.formPost`), NOT by the request
92
+ * Content-Type header — the annotation is the single source of truth.
93
+ */
94
+ private parseBody;
95
+ /**
96
+ * JSON (the default, SYMMETRIC with the client's JSON.stringify). A non-JSON body is a CLIENT
97
+ * error → 400, not the raw 500 an unguarded JSON.parse would throw.
98
+ *
99
+ * On a raw-body (webhook) route the failure is HELD, not thrown: AuthFilter must answer 401 to an
100
+ * unauthenticated caller rather than 400, because "your JSON was bad" tells that caller it got
101
+ * past auth. Everywhere else, fail now.
102
+ */
103
+ private parseJson;
87
104
  /**
88
105
  * Read HTTP headers from Express request.
89
106
  * Returns Map of header name (lowercase) -> array of values.
@@ -127,28 +144,47 @@ export declare class ExpressWrapper {
127
144
  */
128
145
  private readRequestBody;
129
146
  /**
130
- * Handle errors - translate to JSON ProtocolError (SYMMETRIC with ClientErrorTranslator).
147
+ * Turn a thrown value into the response the caller sees — the SERVER half of webpieces' symmetric
148
+ * error handling (the CLIENT half is `ClientErrorTranslator.translateError`, and the two speak the
149
+ * same {@link HttpResponseDto}).
150
+ *
131
151
  * PUBLIC so wrapExpress can call it for symmetric error handling.
132
- * Maps HttpError subclasses to appropriate HTTP status codes and ProtocolError response.
133
152
  *
134
- * Maps all HttpError types (must match ClientErrorTranslator's built-in status mapping):
135
- * - HttpUserError → 266 (message + subType + errorCode)
136
- * - HttpBadRequestError → 400 (generic message + field, guiAlertMessage)
137
- * - HttpUnauthorizedError → 401 (generic message + subType)
138
- * - HttpForbiddenError → 403 (generic message)
139
- * - HttpNotFoundError → 404 (generic message)
140
- * - HttpTimeoutError → 408 (generic message)
141
- * - HttpTooManyRequestsError → 429 (generic message)
142
- * - HttpInternalServerError → 500 (generic message)
143
- * - HttpBadGatewayError → 502 (generic message)
144
- * - HttpServiceUnavailableError → 503 (generic message)
145
- * - HttpGatewayTimeoutError → 504 (generic message)
146
- * - HttpVendorError → 598 (generic message + waitSeconds)
153
+ * Two sources, in this order:
154
+ * 1. the app's {@link ErrorTranslators}, if it claims the error — it owns the ENTIRE response;
155
+ * 2. else {@link HttpErrorWireMapper.toResponse}, the webpieces default, which maps every
156
+ * `HttpError` subclass to its status and a CALLER-SAFE body (only `HttpUserError`'s message
157
+ * is written for a human, so only it goes out verbatim; everything else sends the generic
158
+ * reason phrase and logs the real one) and turns anything else into a generic 500.
147
159
  *
148
- * "generic message" is the point of {@link HttpErrorWireMapper}: ONLY `HttpUserError`'s message
149
- * was written for a human to read, so only it is copied to the response body. Every other type
150
- * sends the standard reason phrase for its status and logs the real message. Read that class's
151
- * doc for the full rule, including why `subType` is kept and `name` is not.
160
+ * Nothing status-specific is decided here any more — read `HttpErrorWireMapper` for the full rule
161
+ * and the list of statuses, which must stay in step with `ClientErrorTranslator`'s built-in
162
+ * mapping.
152
163
  */
153
164
  handleError(res: Response, error: unknown): void;
165
+ /**
166
+ * Write an {@link HttpResponseDto} to express — the ONE place a response DTO becomes bytes, used
167
+ * by the app's translators and by webpieces' own default alike.
168
+ *
169
+ * `append`, not `setHeader`, because the DTO's headers are a LIST: HTTP permits repeats
170
+ * (`Set-Cookie` is the everyday one) and `setHeader` would keep only the last of them. That list
171
+ * shape is the whole reason the DTO does not use a Map.
172
+ *
173
+ * `Content-Type: application/json` is INFRASTRUCTURE here, not app policy: webpieces is the one
174
+ * doing the `JSON.stringify`, so it states what it wrote. Express would otherwise default a
175
+ * string body to `text/html`, which is wrong for every response this framework sends. A
176
+ * translator that genuinely wants another type says so in its own header list and wins.
177
+ */
178
+ private send;
179
+ /**
180
+ * Put the transaction id on EVERY response — success and error, webpieces' default body and an
181
+ * app's own. It is INFRASTRUCTURE, not app policy: an app that overrides what an error looks like
182
+ * must not thereby lose the header its support desk quotes back. That is why this lives here and
183
+ * not in {@link HttpErrorWireMapper.toResponse} or in an app's translators.
184
+ *
185
+ * Silently absent when there is no id to send, which is exactly the accepted known issue recorded
186
+ * at step 0 of {@link executeImpl}: a malformed or oversize body fails before `fillFromRequest`
187
+ * mints one.
188
+ */
189
+ private stampTransactionId;
154
190
  }
@@ -4,9 +4,6 @@ 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
6
  const HttpErrorWireMapper_1 = require("./HttpErrorWireMapper");
7
- // The logging backend prepends this logger name to every line, so messages below carry NO
8
- // "[ExpressWrapper]" literal of their own — that would print the name twice.
9
- const log = core_util_1.LogManager.getLogger('ExpressWrapper');
10
7
  /**
11
8
  * The cap on an inbound body, in bytes. Reading stops and the request is refused the moment a body
12
9
  * crosses it — the bytes already read are dropped, nothing further is buffered.
@@ -26,6 +23,26 @@ const log = core_util_1.LogManager.getLogger('ExpressWrapper');
26
23
  * decide where an app declares it (per-route, most likely, since that is the granularity the need has).
27
24
  */
28
25
  exports.MAX_BODY_BYTES = 10 * 1024 * 1024;
26
+ /**
27
+ * What {@link ExpressWrapper.parseBody} produced: the DTO the controller receives, the verbatim
28
+ * {@link RawRequest} when the route asked for one, and the held parse failure on a raw-body route.
29
+ *
30
+ * Data-only, so a class rather than an inline object literal, per the webpieces guidelines.
31
+ */
32
+ class ParsedBody {
33
+ requestDto;
34
+ raw;
35
+ parseError;
36
+ constructor(
37
+ // webpieces-disable no-any-unknown -- request/response DTOs are erased at the routing boundary
38
+ requestDto, raw,
39
+ /** Set only on a raw-body route, where the failure is HELD for AuthFilter instead of thrown. */
40
+ parseError) {
41
+ this.requestDto = requestDto;
42
+ this.raw = raw;
43
+ this.parseError = parseError;
44
+ }
45
+ }
29
46
  class ExpressWrapper {
30
47
  clientMethod;
31
48
  path;
@@ -94,59 +111,101 @@ class ExpressWrapper {
94
111
  }
95
112
  }
96
113
  async executeImpl(req, res, next) {
97
- // 1. Parse the request body. The PARSER is chosen by the @Endpoint annotation (this.formPost),
98
- // NOT the request Content-Type header — the annotation is the single source of truth.
99
- // webpieces-disable no-any-unknown -- request/response DTOs are erased at the routing boundary
100
- let requestDto = {};
101
- let raw;
102
- if (['POST', 'PUT', 'PATCH'].includes(req.method)) {
103
- // Read BYTES, not text. Concatenating per-chunk toString() corrupted any multi-byte
104
- // character that straddled a chunk boundary — invisible on small bodies, and fatal for a
105
- // signature computed over the bytes.
106
- const bodyBytes = await this.readRequestBody(req);
107
- const bodyText = bodyBytes.toString('utf8');
108
- let parseError;
109
- if (this.formPost) {
110
- // application/x-www-form-urlencoded → flat key→value. URLSearchParams is lenient
111
- // (never throws) — right for EXTERNAL webhooks (e.g. Twilio) that post form-encoded.
112
- requestDto = Object.fromEntries(new URLSearchParams(bodyText));
113
- }
114
- else {
115
- // JSON (default, SYMMETRIC with the client's JSON.stringify). A non-JSON body is a
116
- // CLIENT error → 400, not the raw 500 an unguarded JSON.parse would throw.
117
- // eslint-disable-next-line @webpieces/no-unmanaged-exceptions -- translate parse failure to a 400 HttpError
118
- try {
119
- requestDto = bodyText ? JSON.parse(bodyText) : {};
120
- }
121
- catch (err) {
122
- const error = (0, core_util_1.toError)(err);
123
- // On a raw-body (webhook) route the failure is HELD, not thrown: AuthFilter must
124
- // answer 401 to an unauthenticated caller rather than 400, because "your JSON was
125
- // bad" tells that caller it got past auth. Everywhere else, fail now as before.
126
- if (!this.rawBody) {
127
- throw new core_util_1.HttpBadRequestError('Request body is not valid JSON', undefined, undefined, error);
128
- }
129
- parseError = error;
130
- }
131
- }
132
- if (this.rawBody) {
133
- raw = new core_context_1.RawRequest(this.absoluteUrl(req), bodyBytes, req.socket?.remoteAddress, parseError);
134
- }
135
- }
136
- // 2. Translate express's request into the transport-neutral HttpRequest webpieces speaks.
137
- const httpRequest = this.toWebpiecesRequest(req, raw);
138
- // 3. Publish the transport-neutral HttpRequest, then move its headers into the context and
114
+ // 0. PUBLISH the transport-neutral request BEFORE anything that can throw.
115
+ //
116
+ // This is the ordering bug issue #862 was filed for. An app's ErrorTranslators.toWire has
117
+ // to be able to tell WHICH request it is answering for — the surface, the route, the
118
+ // method — and the only place that lives is RequestContext.getRequest(). Publishing it at
119
+ // step 3 (below) meant a body that failed to parse at step 1 reached handleError with an
120
+ // EMPTY scope, so a translator asked "is this my surface?" could only answer "I don't
121
+ // know" and step aside. The translator was never too late; the context it needs was.
122
+ //
123
+ // No `raw` yet — the bytes have not been read. Step 3 republishes WITH them, below the
124
+ // same request scope and still above the filter chain, so @AuthWebhook signature
125
+ // verification sees exactly what it saw before.
126
+ //
127
+ // KNOWN ISSUE, ACCEPTED AND NOT FIXED (issue #862): this publishes the request but does
128
+ // NOT mint the transaction id — that is `fillFromRequest`'s job and it stays at step 3,
129
+ // below the body read. So a caller whose body is malformed or oversize, and who sent no
130
+ // x-request-id of its own, gets an error response with NO transaction id to quote at
131
+ // support. Moving the whole fill up here would drag the raw-body republish into every
132
+ // route, which is the complexity this change deliberately declines. Pinned by a test so a
133
+ // future change to it is a decision rather than an accident.
134
+ core_context_1.RequestContext.setRequest(this.toWebpiecesRequest(req));
135
+ // 1. Parse the request body (see parseBody: the PARSER is chosen by the @Endpoint
136
+ // annotation, never by the request Content-Type header).
137
+ const parsed = await this.parseBody(req);
138
+ // 2. Translate express's request into the transport-neutral HttpRequest webpieces speaks —
139
+ // now WITH the raw bytes, when the route asked for them.
140
+ const httpRequest = this.toWebpiecesRequest(req, parsed.raw);
141
+ // 3. Re-publish the transport-neutral HttpRequest, then move its headers into the context and
139
142
  // mint a request id if the caller sent none. BOTH happen above the api boundary, because
140
143
  // http-routing requires an already-established, already-filled request scope — it never
141
144
  // builds one for you. This is the "translation layer" every transport must provide.
142
145
  this.headers.fillFromRequest(httpRequest);
143
146
  // 4. Invoke the api CLIENT method — the SAME proxy tests use. Its filter chain + controller
144
147
  // run here, reading the context filled above; the chain never touches express `req`.
145
- const result = await this.clientMethod(requestDto);
148
+ const result = await this.clientMethod(parsed.requestDto);
146
149
  // 5. Serialize the response DTO to JSON (SYMMETRIC with client's response.json())
147
150
  const responseJson = JSON.stringify(result);
151
+ this.stampTransactionId(res);
148
152
  res.status(200).setHeader('Content-Type', 'application/json').send(responseJson);
149
153
  }
154
+ /**
155
+ * Read and parse the request body: the DTO the controller receives, plus the verbatim
156
+ * {@link RawRequest} when the route asked for one.
157
+ *
158
+ * The PARSER is chosen by the `@Endpoint` annotation (`this.formPost`), NOT by the request
159
+ * Content-Type header — the annotation is the single source of truth.
160
+ */
161
+ async parseBody(req) {
162
+ if (!['POST', 'PUT', 'PATCH'].includes(req.method)) {
163
+ return new ParsedBody({}, undefined);
164
+ }
165
+ // Read BYTES, not text. Concatenating per-chunk toString() corrupted any multi-byte
166
+ // character that straddled a chunk boundary — invisible on small bodies, and fatal for a
167
+ // signature computed over the bytes.
168
+ const bodyBytes = await this.readRequestBody(req);
169
+ const bodyText = bodyBytes.toString('utf8');
170
+ // webpieces-disable no-any-unknown -- request/response DTOs are erased at the routing boundary
171
+ let requestDto;
172
+ let parseError;
173
+ if (this.formPost) {
174
+ // application/x-www-form-urlencoded → flat key→value. URLSearchParams is lenient
175
+ // (never throws) — right for EXTERNAL webhooks (e.g. Twilio) that post form-encoded.
176
+ requestDto = Object.fromEntries(new URLSearchParams(bodyText));
177
+ }
178
+ else {
179
+ const json = this.parseJson(bodyText);
180
+ requestDto = json.requestDto;
181
+ parseError = json.parseError;
182
+ }
183
+ const raw = this.rawBody
184
+ ? new core_context_1.RawRequest(this.absoluteUrl(req), bodyBytes, req.socket?.remoteAddress, parseError)
185
+ : undefined;
186
+ return new ParsedBody(requestDto, raw);
187
+ }
188
+ /**
189
+ * JSON (the default, SYMMETRIC with the client's JSON.stringify). A non-JSON body is a CLIENT
190
+ * error → 400, not the raw 500 an unguarded JSON.parse would throw.
191
+ *
192
+ * On a raw-body (webhook) route the failure is HELD, not thrown: AuthFilter must answer 401 to an
193
+ * unauthenticated caller rather than 400, because "your JSON was bad" tells that caller it got
194
+ * past auth. Everywhere else, fail now.
195
+ */
196
+ parseJson(bodyText) {
197
+ // eslint-disable-next-line @webpieces/no-unmanaged-exceptions -- translate parse failure to a 400 HttpError
198
+ try {
199
+ return new ParsedBody(bodyText ? JSON.parse(bodyText) : {}, undefined);
200
+ }
201
+ catch (err) {
202
+ const error = (0, core_util_1.toError)(err);
203
+ if (!this.rawBody) {
204
+ throw new core_util_1.HttpBadRequestError('Request body is not valid JSON', undefined, undefined, error);
205
+ }
206
+ return new ParsedBody({}, undefined, error);
207
+ }
208
+ }
150
209
  /**
151
210
  * Read HTTP headers from Express request.
152
211
  * Returns Map of header name (lowercase) -> array of values.
@@ -241,65 +300,84 @@ class ExpressWrapper {
241
300
  });
242
301
  }
243
302
  /**
244
- * Handle errors - translate to JSON ProtocolError (SYMMETRIC with ClientErrorTranslator).
303
+ * Turn a thrown value into the response the caller sees — the SERVER half of webpieces' symmetric
304
+ * error handling (the CLIENT half is `ClientErrorTranslator.translateError`, and the two speak the
305
+ * same {@link HttpResponseDto}).
306
+ *
245
307
  * PUBLIC so wrapExpress can call it for symmetric error handling.
246
- * Maps HttpError subclasses to appropriate HTTP status codes and ProtocolError response.
247
308
  *
248
- * Maps all HttpError types (must match ClientErrorTranslator's built-in status mapping):
249
- * - HttpUserError → 266 (message + subType + errorCode)
250
- * - HttpBadRequestError → 400 (generic message + field, guiAlertMessage)
251
- * - HttpUnauthorizedError → 401 (generic message + subType)
252
- * - HttpForbiddenError → 403 (generic message)
253
- * - HttpNotFoundError → 404 (generic message)
254
- * - HttpTimeoutError → 408 (generic message)
255
- * - HttpTooManyRequestsError → 429 (generic message)
256
- * - HttpInternalServerError → 500 (generic message)
257
- * - HttpBadGatewayError → 502 (generic message)
258
- * - HttpServiceUnavailableError → 503 (generic message)
259
- * - HttpGatewayTimeoutError → 504 (generic message)
260
- * - HttpVendorError → 598 (generic message + waitSeconds)
309
+ * Two sources, in this order:
310
+ * 1. the app's {@link ErrorTranslators}, if it claims the error — it owns the ENTIRE response;
311
+ * 2. else {@link HttpErrorWireMapper.toResponse}, the webpieces default, which maps every
312
+ * `HttpError` subclass to its status and a CALLER-SAFE body (only `HttpUserError`'s message
313
+ * is written for a human, so only it goes out verbatim; everything else sends the generic
314
+ * reason phrase and logs the real one) and turns anything else into a generic 500.
261
315
  *
262
- * "generic message" is the point of {@link HttpErrorWireMapper}: ONLY `HttpUserError`'s message
263
- * was written for a human to read, so only it is copied to the response body. Every other type
264
- * sends the standard reason phrase for its status and logs the real message. Read that class's
265
- * doc for the full rule, including why `subType` is kept and `name` is not.
316
+ * Nothing status-specific is decided here any more — read `HttpErrorWireMapper` for the full rule
317
+ * and the list of statuses, which must stay in step with `ClientErrorTranslator`'s built-in
318
+ * mapping.
266
319
  */
267
320
  // webpieces-disable no-any-unknown -- a thrown error is genuinely unknown until narrowed below
268
321
  handleError(res, error) {
269
322
  if (res.headersSent) {
270
323
  return;
271
324
  }
272
- // App-registered translations win, so an app can serialize its OWN error types (e.g. a
273
- // custom 460) AND override built-ins. `undefined` means "not mine" — fall through to the
274
- // built-in instanceof-HttpError ladder below, which stays the generic default. Symmetric
275
- // with the client's ClientErrorTranslator, which consults tryTranslateFromWire() first.
276
- if (error instanceof Error) {
277
- const wire = core_util_1.ClientRegistry.tryTranslateToWire(error);
278
- if (wire !== undefined) {
279
- res.status(wire.statusCode)
280
- .setHeader('Content-Type', 'application/json')
281
- .send(JSON.stringify(wire.protocolError));
282
- return;
283
- }
325
+ // The app's ErrorTranslators own the WHOLE response — status code, reason phrase, headers
326
+ // and body — so an app can publish its own envelope AND its own `Retry-After` /
327
+ // `WWW-Authenticate` / trace header / cookie, which the old (statusCode, protocolError) pair
328
+ // could not express at all. `undefined` means "not mine": webpieces' default answers
329
+ // instead. Symmetric with the client's ClientErrorTranslator, which consults
330
+ // tryTranslateFromWire() first, over the identical HttpResponseDto shape.
331
+ const appResponse = error instanceof Error
332
+ ? core_util_1.ClientRegistry.tryTranslateToWire(error)
333
+ : undefined;
334
+ this.send(res, appResponse ?? this.errorWireMapper.toResponse(error));
335
+ }
336
+ /**
337
+ * Write an {@link HttpResponseDto} to express — the ONE place a response DTO becomes bytes, used
338
+ * by the app's translators and by webpieces' own default alike.
339
+ *
340
+ * `append`, not `setHeader`, because the DTO's headers are a LIST: HTTP permits repeats
341
+ * (`Set-Cookie` is the everyday one) and `setHeader` would keep only the last of them. That list
342
+ * shape is the whole reason the DTO does not use a Map.
343
+ *
344
+ * `Content-Type: application/json` is INFRASTRUCTURE here, not app policy: webpieces is the one
345
+ * doing the `JSON.stringify`, so it states what it wrote. Express would otherwise default a
346
+ * string body to `text/html`, which is wrong for every response this framework sends. A
347
+ * translator that genuinely wants another type says so in its own header list and wins.
348
+ */
349
+ send(res, response) {
350
+ this.stampTransactionId(res);
351
+ const declaresContentType = response.headers.some((header) => header.name.toLowerCase() === 'content-type');
352
+ if (!declaresContentType) {
353
+ res.setHeader('Content-Type', 'application/json');
284
354
  }
285
- if (error instanceof core_util_1.HttpError) {
286
- // The mapper decides what the caller may see AND logs everything it withholds — see
287
- // HttpErrorWireMapper. Nothing type-specific is decided here any more.
288
- const protocolError = this.errorWireMapper.toWire(error);
289
- // Serialize ProtocolError to JSON (SYMMETRIC with client)
290
- const responseJson = JSON.stringify(protocolError);
291
- res.status(error.code).setHeader('Content-Type', 'application/json').send(responseJson);
355
+ for (const header of response.headers) {
356
+ res.append(header.name, header.value);
292
357
  }
293
- else {
294
- // Unknown error - 500. This branch was ALREADY generic, which is what made the old
295
- // HttpError branch's verbatim `error.message` so obviously backwards: an unexpected crash
296
- // leaked nothing while a deliberate HttpInternalServerError leaked everything.
297
- const err = (0, core_util_1.toError)(error);
298
- const protocolError = new core_util_1.ProtocolError();
299
- protocolError.message = 'Internal Server Error';
300
- log.error('Unexpected error:', err);
301
- const responseJson = JSON.stringify(protocolError);
302
- res.status(500).setHeader('Content-Type', 'application/json').send(responseJson);
358
+ // express writes the reason phrase from `statusMessage`, so a translator that says
359
+ // `new HttpResponseStatus(460, 'Order Not Found')` gets 'Order Not Found' on the status line
360
+ // rather than node's blank default for an unregistered code.
361
+ res.statusMessage = response.status.reason;
362
+ res.status(response.status.code).send(JSON.stringify(response.body));
363
+ }
364
+ /**
365
+ * Put the transaction id on EVERY response — success and error, webpieces' default body and an
366
+ * app's own. It is INFRASTRUCTURE, not app policy: an app that overrides what an error looks like
367
+ * must not thereby lose the header its support desk quotes back. That is why this lives here and
368
+ * not in {@link HttpErrorWireMapper.toResponse} or in an app's translators.
369
+ *
370
+ * Silently absent when there is no id to send, which is exactly the accepted known issue recorded
371
+ * at step 0 of {@link executeImpl}: a malformed or oversize body fails before `fillFromRequest`
372
+ * mints one.
373
+ */
374
+ stampTransactionId(res) {
375
+ if (!core_context_1.RequestContext.isActive()) {
376
+ return;
377
+ }
378
+ const txId = core_context_1.RequestContext.getUntrusted(core_util_1.WebpiecesCoreHeaders.REQUEST_ID);
379
+ if (txId) {
380
+ res.setHeader(core_util_1.WebpiecesCoreHeaders.REQUEST_ID.httpHeader, txId);
303
381
  }
304
382
  }
305
383
  }
@@ -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 ProtocolError,\n ClientRegistry,\n HttpError,\n HttpBadRequestError,\n toError,\n LogManager,\n} from '@webpieces/core-util';\nimport { RequestContext, HttpRequest, RawRequest, RequestContextHeaders } from '@webpieces/core-context';\nimport { HttpErrorWireMapper } from './HttpErrorWireMapper';\n\n// The logging backend prepends this logger name to every line, so messages below carry NO\n// \"[ExpressWrapper]\" literal of their own — that would print the name twice.\nconst log = LogManager.getLogger('ExpressWrapper');\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\nexport class ExpressWrapper {\n /**\n * Decides what an outside caller is allowed to see of a thrown {@link HttpError}. Stateless —\n * one instance per wrapper is fine, and the class doc there is where the \"only HttpUserError's\n * message goes on the wire\" rule is stated.\n */\n private readonly errorWireMapper = new HttpErrorWireMapper();\n\n constructor(\n // webpieces-disable no-any-unknown -- request/response DTOs are erased at the routing boundary\n private clientMethod: (requestDto: 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 @AuthWebhook 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 ) {\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 // 1. Parse the request body. The PARSER is chosen by the @Endpoint annotation (this.formPost),\n // NOT the request Content-Type header — the annotation is the single source of truth.\n // webpieces-disable no-any-unknown -- request/response DTOs are erased at the routing boundary\n let requestDto: unknown = {};\n let raw: RawRequest | undefined;\n if (['POST', 'PUT', 'PATCH'].includes(req.method)) {\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.readRequestBody(req);\n const bodyText = bodyBytes.toString('utf8');\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 = Object.fromEntries(new URLSearchParams(bodyText));\n } else {\n // JSON (default, SYMMETRIC with the client's JSON.stringify). A non-JSON body is a\n // CLIENT error → 400, not the raw 500 an unguarded JSON.parse would throw.\n // eslint-disable-next-line @webpieces/no-unmanaged-exceptions -- translate parse failure to a 400 HttpError\n try {\n requestDto = bodyText ? JSON.parse(bodyText) : {};\n } catch (err: unknown) {\n const error = toError(err);\n // On a raw-body (webhook) route the failure is HELD, not thrown: AuthFilter must\n // answer 401 to an unauthenticated caller rather than 400, because \"your JSON was\n // bad\" tells that caller it got past auth. Everywhere else, fail now as before.\n if (!this.rawBody) {\n throw new HttpBadRequestError('Request body is not valid JSON', undefined, undefined, error);\n }\n parseError = error;\n }\n }\n if (this.rawBody) {\n raw = new RawRequest(this.absoluteUrl(req), bodyBytes, req.socket?.remoteAddress, parseError);\n }\n }\n\n // 2. Translate express's request into the transport-neutral HttpRequest webpieces speaks.\n const httpRequest = this.toWebpiecesRequest(req, raw);\n\n // 3. 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 result = await this.clientMethod(requestDto);\n\n // 5. Serialize the response DTO to JSON (SYMMETRIC with client's response.json())\n const responseJson = JSON.stringify(result);\n res.status(200).setHeader('Content-Type', 'application/json').send(responseJson);\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(req.method, this.path, this.readExpressHeaders(req), raw);\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 * Read the raw request body as BYTES (we parse manually rather than mounting express.json()).\n *\n * Bytes, not a growing string: a per-chunk `toString()` splits any multi-byte character that\n * straddles a chunk boundary into two replacement characters, so the body a webhook hook verified\n * would not be the body the vendor signed.\n *\n * REFUSES a body over {@link maxBodyBytes} the moment it crosses the line — the chunks read so far\n * are dropped and the stream is destroyed, so an oversize body is never fully buffered. It answers\n * 400 rather than 401 even on a webhook route, unavoidably: there is no way to authenticate a\n * caller whose request we are refusing to finish reading, and that ordering is the point.\n */\n private async readRequestBody(req: Request): Promise<Buffer> {\n return new Promise((resolve: (body: Buffer) => void, reject: (err: Error) => void) => {\n let chunks: Buffer[] = [];\n let size = 0;\n // A socket emits Buffers; a stream someone put in string mode (or a test's Readable.from)\n // emits strings. Normalize to bytes ONCE, here, so everything downstream counts and\n // concatenates the same units.\n req.on('data', (data: Buffer | string) => {\n const chunk = Buffer.isBuffer(data) ? data : Buffer.from(data, 'utf8');\n size += chunk.length;\n if (size > this.maxBodyBytes) {\n chunks = [];\n req.destroy();\n reject(new HttpBadRequestError(\n `Request body exceeds the ${this.maxBodyBytes} byte limit`,\n ));\n return;\n }\n chunks.push(chunk);\n });\n req.on('end', () => {\n resolve(Buffer.concat(chunks));\n });\n req.on('error', (err: Error) => {\n reject(err);\n });\n });\n }\n\n /**\n * Handle errors - translate to JSON ProtocolError (SYMMETRIC with ClientErrorTranslator).\n * PUBLIC so wrapExpress can call it for symmetric error handling.\n * Maps HttpError subclasses to appropriate HTTP status codes and ProtocolError response.\n *\n * Maps all HttpError types (must match ClientErrorTranslator's built-in status mapping):\n * - HttpUserError → 266 (message + subType + errorCode)\n * - HttpBadRequestError → 400 (generic message + field, guiAlertMessage)\n * - HttpUnauthorizedError → 401 (generic message + subType)\n * - HttpForbiddenError → 403 (generic message)\n * - HttpNotFoundError → 404 (generic message)\n * - HttpTimeoutError → 408 (generic message)\n * - HttpTooManyRequestsError → 429 (generic message)\n * - HttpInternalServerError → 500 (generic message)\n * - HttpBadGatewayError → 502 (generic message)\n * - HttpServiceUnavailableError → 503 (generic message)\n * - HttpGatewayTimeoutError → 504 (generic message)\n * - HttpVendorError → 598 (generic message + waitSeconds)\n *\n * \"generic message\" is the point of {@link HttpErrorWireMapper}: ONLY `HttpUserError`'s message\n * was written for a human to read, so only it is copied to the response body. Every other type\n * sends the standard reason phrase for its status and logs the real message. Read that class's\n * doc for the full rule, including why `subType` is kept and `name` is not.\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 // App-registered translations win, so an app can serialize its OWN error types (e.g. a\n // custom 460) AND override built-ins. `undefined` means \"not mine\" — fall through to the\n // built-in instanceof-HttpError ladder below, which stays the generic default. Symmetric\n // with the client's ClientErrorTranslator, which consults tryTranslateFromWire() first.\n if (error instanceof Error) {\n const wire = ClientRegistry.tryTranslateToWire(error);\n if (wire !== undefined) {\n res.status(wire.statusCode)\n .setHeader('Content-Type', 'application/json')\n .send(JSON.stringify(wire.protocolError));\n return;\n }\n }\n\n if (error instanceof HttpError) {\n // The mapper decides what the caller may see AND logs everything it withholds — see\n // HttpErrorWireMapper. Nothing type-specific is decided here any more.\n const protocolError = this.errorWireMapper.toWire(error);\n\n // Serialize ProtocolError to JSON (SYMMETRIC with client)\n const responseJson = JSON.stringify(protocolError);\n res.status(error.code).setHeader('Content-Type', 'application/json').send(responseJson);\n } else {\n // Unknown error - 500. This branch was ALREADY generic, which is what made the old\n // HttpError branch's verbatim `error.message` so obviously backwards: an unexpected crash\n // leaked nothing while a deliberate HttpInternalServerError leaked everything.\n const err = toError(error);\n const protocolError = new ProtocolError();\n protocolError.message = 'Internal Server Error';\n log.error('Unexpected error:', err);\n const responseJson = JSON.stringify(protocolError);\n res.status(500).setHeader('Content-Type', 'application/json').send(responseJson);\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 HttpBadRequestError,\n HttpHeader,\n HttpResponseDto,\n toError,\n WebpiecesCoreHeaders,\n} from '@webpieces/core-util';\nimport { RequestContext, HttpRequest, RawRequest, RequestContextHeaders } from '@webpieces/core-context';\nimport { HttpErrorWireMapper } from './HttpErrorWireMapper';\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 HttpError}. Stateless —\n * one instance per wrapper is fine, and the class doc there is where the \"only HttpUserError's\n * message goes on the wire\" rule is stated.\n */\n private readonly errorWireMapper = new HttpErrorWireMapper();\n\n constructor(\n // webpieces-disable no-any-unknown -- request/response DTOs are erased at the routing boundary\n private clientMethod: (requestDto: 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 @AuthWebhook 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 ) {\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 @AuthWebhook 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 result = await this.clientMethod(parsed.requestDto);\n\n // 5. Serialize the response DTO to JSON (SYMMETRIC with client's response.json())\n const responseJson = JSON.stringify(result);\n this.stampTransactionId(res);\n res.status(200).setHeader('Content-Type', 'application/json').send(responseJson);\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.readRequestBody(req);\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 = Object.fromEntries(new URLSearchParams(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(this.absoluteUrl(req), bodyBytes, req.socket?.remoteAddress, parseError)\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 a 400 HttpError\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 HttpBadRequestError('Request body is not valid JSON', undefined, undefined, error);\n }\n return new ParsedBody({}, undefined, error);\n }\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(req.method, this.path, this.readExpressHeaders(req), raw);\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 * Read the raw request body as BYTES (we parse manually rather than mounting express.json()).\n *\n * Bytes, not a growing string: a per-chunk `toString()` splits any multi-byte character that\n * straddles a chunk boundary into two replacement characters, so the body a webhook hook verified\n * would not be the body the vendor signed.\n *\n * REFUSES a body over {@link maxBodyBytes} the moment it crosses the line — the chunks read so far\n * are dropped and the stream is destroyed, so an oversize body is never fully buffered. It answers\n * 400 rather than 401 even on a webhook route, unavoidably: there is no way to authenticate a\n * caller whose request we are refusing to finish reading, and that ordering is the point.\n */\n private async readRequestBody(req: Request): Promise<Buffer> {\n return new Promise((resolve: (body: Buffer) => void, reject: (err: Error) => void) => {\n let chunks: Buffer[] = [];\n let size = 0;\n // A socket emits Buffers; a stream someone put in string mode (or a test's Readable.from)\n // emits strings. Normalize to bytes ONCE, here, so everything downstream counts and\n // concatenates the same units.\n req.on('data', (data: Buffer | string) => {\n const chunk = Buffer.isBuffer(data) ? data : Buffer.from(data, 'utf8');\n size += chunk.length;\n if (size > this.maxBodyBytes) {\n chunks = [];\n req.destroy();\n reject(new HttpBadRequestError(\n `Request body exceeds the ${this.maxBodyBytes} byte limit`,\n ));\n return;\n }\n chunks.push(chunk);\n });\n req.on('end', () => {\n resolve(Buffer.concat(chunks));\n });\n req.on('error', (err: Error) => {\n reject(err);\n });\n });\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 HttpErrorWireMapper.toResponse}, the webpieces default, which maps every\n * `HttpError` subclass to its status and a CALLER-SAFE body (only `HttpUserError`'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 `HttpErrorWireMapper` 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 = error instanceof Error\n ? ClientRegistry.tryTranslateToWire(error)\n : 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) {\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 res.status(response.status.code).send(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 HttpErrorWireMapper.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,4 +1,4 @@
1
- import { ProtocolError, HttpError } from '@webpieces/core-util';
1
+ import { HttpResponseDto } from '@webpieces/core-util';
2
2
  /**
3
3
  * Turns a thrown {@link HttpError} into the {@link ProtocolError} that goes on the wire — and, just
4
4
  * as importantly, decides what does NOT go on the wire.
@@ -44,25 +44,63 @@ import { ProtocolError, HttpError } from '@webpieces/core-util';
44
44
  * the server's internals for zero caller benefit, so it is not sent. It is logged instead.
45
45
  *
46
46
  * An app that WANTS a different wire shape has an explicit opt-out on both sides:
47
- * `ClientRegistry.addErrorTranslation()`. `ExpressWrapper.handleError` consults
47
+ * `ClientRegistry.setErrorTranslators()`. `ExpressWrapper.handleError` consults
48
48
  * `tryTranslateToWire()` BEFORE reaching this class, and whatever that returns is sent verbatim —
49
49
  * that is the app's own deliberate choice about what it publishes.
50
+ *
51
+ * # This class IS the webpieces default, and it is DELEGABLE
52
+ *
53
+ * {@link toResponse} produces the same {@link HttpResponseDto} an app's `ErrorTranslators.toWire`
54
+ * produces, so an app that wants "webpieces' answer, plus one header" or "webpieces' answer for
55
+ * everything except MY type" WRAPS this instead of reimplementing it. That is not hypothetical:
56
+ * a consumer's envelope class carried a status-to-message table with the comment "copied verbatim
57
+ * from HttpErrorWireMapper's own table". Copying framework internals is the symptom of a missing
58
+ * export, so the class and {@link genericMessage} are both public and both exported from this
59
+ * package's barrel.
50
60
  */
51
61
  export declare class HttpErrorWireMapper {
52
62
  /**
53
63
  * status code → the generic, caller-safe text sent in its place. These are the standard HTTP
54
- * reason phrases (plus 598, webpieces' own vendor code), so a caller reading the body learns
55
- * exactly what the status line already told it and nothing more.
64
+ * reason phrases (plus 266 and 598, webpieces' own user/vendor codes), so a caller reading the
65
+ * body learns exactly what the status line already told it and nothing more.
66
+ *
67
+ * The SAME string is the response's reason phrase in {@link toResponse}. One table, because a
68
+ * status line that disagrees with the body it introduces helps nobody.
56
69
  */
57
70
  private readonly genericMessages;
58
71
  /**
59
- * Build the wire body for `error`, and LOG the operator-facing detail that is being withheld from
72
+ * Build the wire BODY for `error`, and LOG the operator-facing detail that is being withheld from
60
73
  * it. Both halves happen here on purpose: the log line is the only remaining place the real
61
74
  * message exists, so it must never be optional or skippable.
75
+ *
76
+ * PRIVATE, deliberately. {@link toResponse} is the one public spelling of "webpieces' default
77
+ * answer" — a public body-only producer alongside it would be a second spelling of the same
78
+ * decision, and the caller who picked it would then hand-wrap a status and headers around the
79
+ * body, re-implementing exactly what `toResponse` exists to hand them.
80
+ */
81
+ private toWire;
82
+ /**
83
+ * Build the WHOLE default response for a thrown value — status code, reason phrase, headers and
84
+ * body. This is webpieces' own answer expressed in the SAME {@link HttpResponseDto} an app's
85
+ * `ErrorTranslators.toWire` returns, which is what makes the default delegable: an app wraps it
86
+ * and edits the parts it cares about.
87
+ *
88
+ * Takes `unknown`, not `HttpError`, because the non-HttpError case is part of the default and an
89
+ * app delegating here must get it too: an unexpected throw is a GENERIC 500 whose real message
90
+ * goes to the log only.
91
+ *
92
+ * Note what is NOT here: the `Content-Type` and the transaction-id response header. Both are
93
+ * INFRASTRUCTURE that `ExpressWrapper` writes for every response — success and error, default and
94
+ * app-translated alike — so an app that overrides the error body never has to remember to
95
+ * re-emit them (and, for the content type, can still override it by naming it in its own header
96
+ * list).
97
+ */
98
+ toResponse(error: unknown): HttpResponseDto;
99
+ /**
100
+ * The generic text for a status, or a code-free fallback for an app's own custom status. PUBLIC
101
+ * so an app can reuse the table rather than copy it — see the class doc.
62
102
  */
63
- toWire(error: HttpError): ProtocolError;
64
- /** The generic text for a status, or a code-free fallback for an app's own custom status. */
65
- private genericMessage;
103
+ genericMessage(code: number): string;
66
104
  /**
67
105
  * The full operator-facing string. `name` and `subType` are in here because `name` used to be
68
106
  * visible ONLY on the wire — removing it from the body without adding it to the log would lose it
@@ -50,17 +50,31 @@ const log = core_util_1.LogManager.getLogger('HttpErrorWireMapper');
50
50
  * the server's internals for zero caller benefit, so it is not sent. It is logged instead.
51
51
  *
52
52
  * An app that WANTS a different wire shape has an explicit opt-out on both sides:
53
- * `ClientRegistry.addErrorTranslation()`. `ExpressWrapper.handleError` consults
53
+ * `ClientRegistry.setErrorTranslators()`. `ExpressWrapper.handleError` consults
54
54
  * `tryTranslateToWire()` BEFORE reaching this class, and whatever that returns is sent verbatim —
55
55
  * that is the app's own deliberate choice about what it publishes.
56
+ *
57
+ * # This class IS the webpieces default, and it is DELEGABLE
58
+ *
59
+ * {@link toResponse} produces the same {@link HttpResponseDto} an app's `ErrorTranslators.toWire`
60
+ * produces, so an app that wants "webpieces' answer, plus one header" or "webpieces' answer for
61
+ * everything except MY type" WRAPS this instead of reimplementing it. That is not hypothetical:
62
+ * a consumer's envelope class carried a status-to-message table with the comment "copied verbatim
63
+ * from HttpErrorWireMapper's own table". Copying framework internals is the symptom of a missing
64
+ * export, so the class and {@link genericMessage} are both public and both exported from this
65
+ * package's barrel.
56
66
  */
57
67
  class HttpErrorWireMapper {
58
68
  /**
59
69
  * status code → the generic, caller-safe text sent in its place. These are the standard HTTP
60
- * reason phrases (plus 598, webpieces' own vendor code), so a caller reading the body learns
61
- * exactly what the status line already told it and nothing more.
70
+ * reason phrases (plus 266 and 598, webpieces' own user/vendor codes), so a caller reading the
71
+ * body learns exactly what the status line already told it and nothing more.
72
+ *
73
+ * The SAME string is the response's reason phrase in {@link toResponse}. One table, because a
74
+ * status line that disagrees with the body it introduces helps nobody.
62
75
  */
63
76
  genericMessages = new Map([
77
+ [266, 'User Error'],
64
78
  [400, 'Bad Request'],
65
79
  [401, 'Unauthorized'],
66
80
  [403, 'Forbidden'],
@@ -74,9 +88,14 @@ class HttpErrorWireMapper {
74
88
  [598, 'Vendor Error'],
75
89
  ]);
76
90
  /**
77
- * Build the wire body for `error`, and LOG the operator-facing detail that is being withheld from
91
+ * Build the wire BODY for `error`, and LOG the operator-facing detail that is being withheld from
78
92
  * it. Both halves happen here on purpose: the log line is the only remaining place the real
79
93
  * message exists, so it must never be optional or skippable.
94
+ *
95
+ * PRIVATE, deliberately. {@link toResponse} is the one public spelling of "webpieces' default
96
+ * answer" — a public body-only producer alongside it would be a second spelling of the same
97
+ * decision, and the caller who picked it would then hand-wrap a status and headers around the
98
+ * body, re-implementing exactly what `toResponse` exists to hand them.
80
99
  */
81
100
  toWire(error) {
82
101
  const protocolError = new core_util_1.ProtocolError();
@@ -94,7 +113,38 @@ class HttpErrorWireMapper {
94
113
  this.addContractFields(error, protocolError);
95
114
  return protocolError;
96
115
  }
97
- /** The generic text for a status, or a code-free fallback for an app's own custom status. */
116
+ /**
117
+ * Build the WHOLE default response for a thrown value — status code, reason phrase, headers and
118
+ * body. This is webpieces' own answer expressed in the SAME {@link HttpResponseDto} an app's
119
+ * `ErrorTranslators.toWire` returns, which is what makes the default delegable: an app wraps it
120
+ * and edits the parts it cares about.
121
+ *
122
+ * Takes `unknown`, not `HttpError`, because the non-HttpError case is part of the default and an
123
+ * app delegating here must get it too: an unexpected throw is a GENERIC 500 whose real message
124
+ * goes to the log only.
125
+ *
126
+ * Note what is NOT here: the `Content-Type` and the transaction-id response header. Both are
127
+ * INFRASTRUCTURE that `ExpressWrapper` writes for every response — success and error, default and
128
+ * app-translated alike — so an app that overrides the error body never has to remember to
129
+ * re-emit them (and, for the content type, can still override it by naming it in its own header
130
+ * list).
131
+ */
132
+ // webpieces-disable no-any-unknown -- a thrown value is genuinely unknown until narrowed below
133
+ toResponse(error) {
134
+ if (error instanceof core_util_1.HttpError) {
135
+ return new core_util_1.HttpResponseDto(new core_util_1.HttpResponseStatus(error.code, this.genericMessage(error.code)), [], this.toWire(error));
136
+ }
137
+ // Already generic before this class existed, and deliberately so: an unexpected crash must
138
+ // leak nothing. The real error goes to the log.
139
+ log.error('Unexpected error:', (0, core_util_1.toError)(error));
140
+ const protocolError = new core_util_1.ProtocolError();
141
+ protocolError.message = 'Internal Server Error';
142
+ return new core_util_1.HttpResponseDto(new core_util_1.HttpResponseStatus(500, 'Internal Server Error'), [], protocolError);
143
+ }
144
+ /**
145
+ * The generic text for a status, or a code-free fallback for an app's own custom status. PUBLIC
146
+ * so an app can reuse the table rather than copy it — see the class doc.
147
+ */
98
148
  genericMessage(code) {
99
149
  return this.genericMessages.get(code) ?? 'Request Failed';
100
150
  }
@@ -1 +1 @@
1
- {"version":3,"file":"HttpErrorWireMapper.js","sourceRoot":"","sources":["../../../../../packages/http/http-server/src/HttpErrorWireMapper.ts"],"names":[],"mappings":"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","sourcesContent":["import {\n ProtocolError,\n HttpError,\n HttpBadRequestError,\n HttpVendorError,\n HttpUserError,\n HttpNotFoundError,\n HttpTimeoutError,\n HttpUnauthorizedError,\n HttpForbiddenError,\n HttpInternalServerError,\n HttpBadGatewayError,\n HttpGatewayTimeoutError,\n LogManager,\n} from '@webpieces/core-util';\n\n// The logging backend prepends this logger name to every line, so messages below carry NO\n// \"[HttpErrorWireMapper]\" literal of their own — that would print the name twice.\nconst log = LogManager.getLogger('HttpErrorWireMapper');\n\n/**\n * Turns a thrown {@link HttpError} into the {@link ProtocolError} that goes on the wire — and, just\n * as importantly, decides what does NOT go on the wire.\n *\n * # The rule: only {@link HttpUserError}'s `message` is caller-facing\n *\n * `Error.message` is an OPERATOR-facing field everywhere else in this framework. It is written for\n * whoever reads the logs, and it routinely quotes internal detail: a downstream service url, an HTTP\n * method and content-type, a body snippet, a table name, an internal id. `http-client-node` builds\n * exactly such a message when a downstream dependency answers a 4xx — `ResponseBodyReader`'s\n * foreign-body description names the url it called and embeds the html it got back — and that error\n * arrives here as an `HttpInternalServerError`. Copying `error.message` onto the response body handed\n * every one of those strings to an external, possibly partner-facing consumer.\n *\n * {@link HttpUserError} is the ONE type whose message was written for a human to read. That is what\n * it is FOR: it is deliberately a 266 (a 2xx code, so it is not lumped in with failures), it carries\n * an `errorCode` the caller branches on, and an app throws it on purpose to say something like\n * \"Email already exists\". Its message goes out verbatim.\n *\n * Every other subclass sends a GENERIC, status-appropriate message (see {@link genericMessage}); the\n * real message goes to the LOG only. The behaviour used to be exactly backwards — an unexpected crash\n * was safely generic while a DELIBERATE `HttpInternalServerError` shipped its full message outward.\n *\n * # What still goes out, and why\n *\n * - `errorCode` ({@link HttpUserError}) and `waitSeconds` ({@link HttpVendorError}) are structured\n * CONTRACT data the client is meant to branch on, not prose. They carry no internal detail.\n * - `field` and `guiAlertMessage` ({@link HttpBadRequestError}) stay. `guiMessage` exists precisely\n * to be the human-safe half of a bad request — its existence is the admission that `message` is the\n * operator-facing half — and a form field name is not internal detail. The `message` itself is\n * genericised like every other non-user error.\n * - `subType` stays. It is NOT derived from a class name: it is an explicit constructor argument an\n * app passes on purpose (`WRONG_LOGIN`, `NOT_APPROVED`, `EMAIL_NOT_CONFIRMED`, … from\n * `core-util/src/http/errors.ts`), which makes it structured contract data of the same kind as\n * `errorCode`. `ClientErrorTranslator` reads it back when reconstructing `HttpUnauthorizedError`\n * and the generic `HttpError`, so dropping it would break that reconstruction for the one case —\n * login failure reasons — where the caller genuinely has to branch on WHY.\n * - `name` is GONE from this ladder. Nothing in `ClientErrorTranslator` ever read it, so it is not\n * contract data. For a built-in it was a constant string 1:1 with the status code (`'BadRequest'`,\n * `'InternalServerError'`), i.e. it told the caller nothing the status had not already told it —\n * and for any SUBCLASS reaching this ladder it was literally an internal class name\n * (`'EndpointNotFoundError'`, or whatever an app happened to name its type). That is a free read of\n * the server's internals for zero caller benefit, so it is not sent. It is logged instead.\n *\n * An app that WANTS a different wire shape has an explicit opt-out on both sides:\n * `ClientRegistry.addErrorTranslation()`. `ExpressWrapper.handleError` consults\n * `tryTranslateToWire()` BEFORE reaching this class, and whatever that returns is sent verbatim —\n * that is the app's own deliberate choice about what it publishes.\n */\nexport class HttpErrorWireMapper {\n /**\n * status code → the generic, caller-safe text sent in its place. These are the standard HTTP\n * reason phrases (plus 598, webpieces' own vendor code), so a caller reading the body learns\n * exactly what the status line already told it and nothing more.\n */\n private readonly genericMessages: Map<number, string> = new Map<number, string>([\n [400, 'Bad Request'],\n [401, 'Unauthorized'],\n [403, 'Forbidden'],\n [404, 'Not Found'],\n [408, 'Request Timeout'],\n [429, 'Too Many Requests'],\n [500, 'Internal Server Error'],\n [502, 'Bad Gateway'],\n [503, 'Service Unavailable'],\n [504, 'Gateway Timeout'],\n [598, 'Vendor Error'],\n ]);\n\n /**\n * Build the wire body for `error`, and LOG the operator-facing detail that is being withheld from\n * it. Both halves happen here on purpose: the log line is the only remaining place the real\n * message exists, so it must never be optional or skippable.\n */\n public toWire(error: HttpError): ProtocolError {\n const protocolError = new ProtocolError();\n\n // The ONE type whose message was written for a human to read — see the class doc.\n if (error instanceof HttpUserError) {\n log.info(`User Error: ${this.operatorDetail(error)}`);\n protocolError.message = error.message;\n protocolError.subType = error.subType;\n protocolError.errorCode = error.errorCode;\n return protocolError;\n }\n\n protocolError.message = this.genericMessage(error.code);\n protocolError.subType = error.subType;\n this.logOperatorDetail(error);\n this.addContractFields(error, protocolError);\n return protocolError;\n }\n\n /** The generic text for a status, or a code-free fallback for an app's own custom status. */\n private genericMessage(code: number): string {\n return this.genericMessages.get(code) ?? 'Request Failed';\n }\n\n /**\n * The full operator-facing string. `name` and `subType` are in here because `name` used to be\n * visible ONLY on the wire — removing it from the body without adding it to the log would lose it\n * entirely.\n */\n private operatorDetail(error: HttpError): string {\n const cause = error.httpCause === undefined ? '' : ` cause=${error.httpCause.message}`;\n return `[name=${error.name} subType=${error.subType ?? 'none'}] ${error.message}${cause}`;\n }\n\n /**\n * Log at the level that matches who is at fault: `info` where the CALLER made a mistake (a 4xx is\n * the server behaving correctly), `error` where the server or a dependency is broken.\n */\n private logOperatorDetail(error: HttpError): void {\n const detail = this.operatorDetail(error);\n if (error instanceof HttpBadRequestError) {\n log.info(`Bad Request: ${detail}`);\n } else if (error instanceof HttpNotFoundError) {\n log.info(`Not Found: ${detail}`);\n } else if (error instanceof HttpTimeoutError) {\n log.error(`Timeout Error: ${detail}`);\n } else if (error instanceof HttpVendorError) {\n log.error(`Vendor Error: ${detail}`);\n } else if (error instanceof HttpUnauthorizedError) {\n log.info(`Unauthorized: ${detail}`);\n } else if (error instanceof HttpForbiddenError) {\n log.info(`Forbidden: ${detail}`);\n } else if (error instanceof HttpInternalServerError) {\n log.error(`Internal Server Error: ${detail}`);\n } else if (error instanceof HttpBadGatewayError) {\n log.error(`Bad Gateway: ${detail}`);\n } else if (error instanceof HttpGatewayTimeoutError) {\n log.error(`Gateway Timeout: ${detail}`);\n } else {\n log.info(`Generic HttpError: ${detail}`);\n }\n }\n\n /**\n * The structured, caller-facing fields — the ones a client BRANCHES on rather than displays as\n * server prose. MUST match ClientErrorTranslator's built-in status mapping.\n */\n private addContractFields(error: HttpError, protocolError: ProtocolError): void {\n if (error instanceof HttpBadRequestError) {\n protocolError.field = error.field;\n // The human-safe half of a bad request. `error.message` is the operator half and is NOT\n // sent — the generic 'Bad Request' went out above instead.\n protocolError.guiAlertMessage = error.guiMessage;\n } else if (error instanceof HttpVendorError) {\n protocolError.waitSeconds = error.waitSeconds;\n }\n }\n}\n"]}
1
+ {"version":3,"file":"HttpErrorWireMapper.js","sourceRoot":"","sources":["../../../../../packages/http/http-server/src/HttpErrorWireMapper.ts"],"names":[],"mappings":"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","sourcesContent":["import {\n ProtocolError,\n HttpError,\n HttpBadRequestError,\n HttpVendorError,\n HttpUserError,\n HttpNotFoundError,\n HttpTimeoutError,\n HttpUnauthorizedError,\n HttpForbiddenError,\n HttpInternalServerError,\n HttpBadGatewayError,\n HttpGatewayTimeoutError,\n HttpResponseDto,\n HttpResponseStatus,\n LogManager,\n toError,\n} from '@webpieces/core-util';\n\n// The logging backend prepends this logger name to every line, so messages below carry NO\n// \"[HttpErrorWireMapper]\" literal of their own — that would print the name twice.\nconst log = LogManager.getLogger('HttpErrorWireMapper');\n\n/**\n * Turns a thrown {@link HttpError} into the {@link ProtocolError} that goes on the wire — and, just\n * as importantly, decides what does NOT go on the wire.\n *\n * # The rule: only {@link HttpUserError}'s `message` is caller-facing\n *\n * `Error.message` is an OPERATOR-facing field everywhere else in this framework. It is written for\n * whoever reads the logs, and it routinely quotes internal detail: a downstream service url, an HTTP\n * method and content-type, a body snippet, a table name, an internal id. `http-client-node` builds\n * exactly such a message when a downstream dependency answers a 4xx — `ResponseBodyReader`'s\n * foreign-body description names the url it called and embeds the html it got back — and that error\n * arrives here as an `HttpInternalServerError`. Copying `error.message` onto the response body handed\n * every one of those strings to an external, possibly partner-facing consumer.\n *\n * {@link HttpUserError} is the ONE type whose message was written for a human to read. That is what\n * it is FOR: it is deliberately a 266 (a 2xx code, so it is not lumped in with failures), it carries\n * an `errorCode` the caller branches on, and an app throws it on purpose to say something like\n * \"Email already exists\". Its message goes out verbatim.\n *\n * Every other subclass sends a GENERIC, status-appropriate message (see {@link genericMessage}); the\n * real message goes to the LOG only. The behaviour used to be exactly backwards — an unexpected crash\n * was safely generic while a DELIBERATE `HttpInternalServerError` shipped its full message outward.\n *\n * # What still goes out, and why\n *\n * - `errorCode` ({@link HttpUserError}) and `waitSeconds` ({@link HttpVendorError}) are structured\n * CONTRACT data the client is meant to branch on, not prose. They carry no internal detail.\n * - `field` and `guiAlertMessage` ({@link HttpBadRequestError}) stay. `guiMessage` exists precisely\n * to be the human-safe half of a bad request — its existence is the admission that `message` is the\n * operator-facing half — and a form field name is not internal detail. The `message` itself is\n * genericised like every other non-user error.\n * - `subType` stays. It is NOT derived from a class name: it is an explicit constructor argument an\n * app passes on purpose (`WRONG_LOGIN`, `NOT_APPROVED`, `EMAIL_NOT_CONFIRMED`, … from\n * `core-util/src/http/errors.ts`), which makes it structured contract data of the same kind as\n * `errorCode`. `ClientErrorTranslator` reads it back when reconstructing `HttpUnauthorizedError`\n * and the generic `HttpError`, so dropping it would break that reconstruction for the one case —\n * login failure reasons — where the caller genuinely has to branch on WHY.\n * - `name` is GONE from this ladder. Nothing in `ClientErrorTranslator` ever read it, so it is not\n * contract data. For a built-in it was a constant string 1:1 with the status code (`'BadRequest'`,\n * `'InternalServerError'`), i.e. it told the caller nothing the status had not already told it —\n * and for any SUBCLASS reaching this ladder it was literally an internal class name\n * (`'EndpointNotFoundError'`, or whatever an app happened to name its type). That is a free read of\n * the server's internals for zero caller benefit, so it is not sent. It is logged instead.\n *\n * An app that WANTS a different wire shape has an explicit opt-out on both sides:\n * `ClientRegistry.setErrorTranslators()`. `ExpressWrapper.handleError` consults\n * `tryTranslateToWire()` BEFORE reaching this class, and whatever that returns is sent verbatim —\n * that is the app's own deliberate choice about what it publishes.\n *\n * # This class IS the webpieces default, and it is DELEGABLE\n *\n * {@link toResponse} produces the same {@link HttpResponseDto} an app's `ErrorTranslators.toWire`\n * produces, so an app that wants \"webpieces' answer, plus one header\" or \"webpieces' answer for\n * everything except MY type\" WRAPS this instead of reimplementing it. That is not hypothetical:\n * a consumer's envelope class carried a status-to-message table with the comment \"copied verbatim\n * from HttpErrorWireMapper's own table\". Copying framework internals is the symptom of a missing\n * export, so the class and {@link genericMessage} are both public and both exported from this\n * package's barrel.\n */\nexport class HttpErrorWireMapper {\n /**\n * status code → the generic, caller-safe text sent in its place. These are the standard HTTP\n * reason phrases (plus 266 and 598, webpieces' own user/vendor codes), so a caller reading the\n * body learns exactly what the status line already told it and nothing more.\n *\n * The SAME string is the response's reason phrase in {@link toResponse}. One table, because a\n * status line that disagrees with the body it introduces helps nobody.\n */\n private readonly genericMessages: Map<number, string> = new Map<number, string>([\n [266, 'User Error'],\n [400, 'Bad Request'],\n [401, 'Unauthorized'],\n [403, 'Forbidden'],\n [404, 'Not Found'],\n [408, 'Request Timeout'],\n [429, 'Too Many Requests'],\n [500, 'Internal Server Error'],\n [502, 'Bad Gateway'],\n [503, 'Service Unavailable'],\n [504, 'Gateway Timeout'],\n [598, 'Vendor Error'],\n ]);\n\n /**\n * Build the wire BODY for `error`, and LOG the operator-facing detail that is being withheld from\n * it. Both halves happen here on purpose: the log line is the only remaining place the real\n * message exists, so it must never be optional or skippable.\n *\n * PRIVATE, deliberately. {@link toResponse} is the one public spelling of \"webpieces' default\n * answer\" — a public body-only producer alongside it would be a second spelling of the same\n * decision, and the caller who picked it would then hand-wrap a status and headers around the\n * body, re-implementing exactly what `toResponse` exists to hand them.\n */\n private toWire(error: HttpError): ProtocolError {\n const protocolError = new ProtocolError();\n\n // The ONE type whose message was written for a human to read — see the class doc.\n if (error instanceof HttpUserError) {\n log.info(`User Error: ${this.operatorDetail(error)}`);\n protocolError.message = error.message;\n protocolError.subType = error.subType;\n protocolError.errorCode = error.errorCode;\n return protocolError;\n }\n\n protocolError.message = this.genericMessage(error.code);\n protocolError.subType = error.subType;\n this.logOperatorDetail(error);\n this.addContractFields(error, protocolError);\n return protocolError;\n }\n\n /**\n * Build the WHOLE default response for a thrown value — status code, reason phrase, headers and\n * body. This is webpieces' own answer expressed in the SAME {@link HttpResponseDto} an app's\n * `ErrorTranslators.toWire` returns, which is what makes the default delegable: an app wraps it\n * and edits the parts it cares about.\n *\n * Takes `unknown`, not `HttpError`, because the non-HttpError case is part of the default and an\n * app delegating here must get it too: an unexpected throw is a GENERIC 500 whose real message\n * goes to the log only.\n *\n * Note what is NOT here: the `Content-Type` and the transaction-id response header. Both are\n * INFRASTRUCTURE that `ExpressWrapper` writes for every response — success and error, default and\n * app-translated alike — so an app that overrides the error body never has to remember to\n * re-emit them (and, for the content type, can still override it by naming it in its own header\n * list).\n */\n // webpieces-disable no-any-unknown -- a thrown value is genuinely unknown until narrowed below\n public toResponse(error: unknown): HttpResponseDto {\n if (error instanceof HttpError) {\n return new HttpResponseDto(\n new HttpResponseStatus(error.code, this.genericMessage(error.code)),\n [],\n this.toWire(error),\n );\n }\n\n // Already generic before this class existed, and deliberately so: an unexpected crash must\n // leak nothing. The real error goes to the log.\n log.error('Unexpected error:', toError(error));\n const protocolError = new ProtocolError();\n protocolError.message = 'Internal Server Error';\n return new HttpResponseDto(\n new HttpResponseStatus(500, 'Internal Server Error'),\n [],\n protocolError,\n );\n }\n\n /**\n * The generic text for a status, or a code-free fallback for an app's own custom status. PUBLIC\n * so an app can reuse the table rather than copy it — see the class doc.\n */\n public genericMessage(code: number): string {\n return this.genericMessages.get(code) ?? 'Request Failed';\n }\n\n /**\n * The full operator-facing string. `name` and `subType` are in here because `name` used to be\n * visible ONLY on the wire — removing it from the body without adding it to the log would lose it\n * entirely.\n */\n private operatorDetail(error: HttpError): string {\n const cause = error.httpCause === undefined ? '' : ` cause=${error.httpCause.message}`;\n return `[name=${error.name} subType=${error.subType ?? 'none'}] ${error.message}${cause}`;\n }\n\n /**\n * Log at the level that matches who is at fault: `info` where the CALLER made a mistake (a 4xx is\n * the server behaving correctly), `error` where the server or a dependency is broken.\n */\n private logOperatorDetail(error: HttpError): void {\n const detail = this.operatorDetail(error);\n if (error instanceof HttpBadRequestError) {\n log.info(`Bad Request: ${detail}`);\n } else if (error instanceof HttpNotFoundError) {\n log.info(`Not Found: ${detail}`);\n } else if (error instanceof HttpTimeoutError) {\n log.error(`Timeout Error: ${detail}`);\n } else if (error instanceof HttpVendorError) {\n log.error(`Vendor Error: ${detail}`);\n } else if (error instanceof HttpUnauthorizedError) {\n log.info(`Unauthorized: ${detail}`);\n } else if (error instanceof HttpForbiddenError) {\n log.info(`Forbidden: ${detail}`);\n } else if (error instanceof HttpInternalServerError) {\n log.error(`Internal Server Error: ${detail}`);\n } else if (error instanceof HttpBadGatewayError) {\n log.error(`Bad Gateway: ${detail}`);\n } else if (error instanceof HttpGatewayTimeoutError) {\n log.error(`Gateway Timeout: ${detail}`);\n } else {\n log.info(`Generic HttpError: ${detail}`);\n }\n }\n\n /**\n * The structured, caller-facing fields — the ones a client BRANCHES on rather than displays as\n * server prose. MUST match ClientErrorTranslator's built-in status mapping.\n */\n private addContractFields(error: HttpError, protocolError: ProtocolError): void {\n if (error instanceof HttpBadRequestError) {\n protocolError.field = error.field;\n // The human-safe half of a bad request. `error.message` is the operator half and is NOT\n // sent — the generic 'Bad Request' went out above instead.\n protocolError.guiAlertMessage = error.guiMessage;\n } else if (error instanceof HttpVendorError) {\n protocolError.waitSeconds = error.waitSeconds;\n }\n }\n}\n"]}
package/src/index.d.ts CHANGED
@@ -1,6 +1,7 @@
1
1
  export { WebpiecesExpressRouter } from './WebpiecesExpressRouter';
2
2
  export { WebpiecesMiddleware } from './WebpiecesMiddleware';
3
3
  export { RecordingFilter } from './filters/RecordingFilter';
4
+ export { HttpErrorWireMapper } from './HttpErrorWireMapper';
4
5
  export { TestCaseRecorderImpl } from './recorder/TestCaseRecorderImpl';
5
6
  export { SpecGenerator } from './recorder/SpecGenerator';
6
7
  export { recordable } from './recorder/recordable';
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.RecordingFilter = 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.HttpErrorWireMapper = exports.RecordingFilter = 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; } });
@@ -11,6 +11,11 @@ Object.defineProperty(exports, "WebpiecesMiddleware", { enumerable: true, get: f
11
11
  // exported here. Remove any `router.addFilter(new FilterDefinition(1800, LogApiFilter, '*'))`.
12
12
  var RecordingFilter_1 = require("./filters/RecordingFilter");
13
13
  Object.defineProperty(exports, "RecordingFilter", { enumerable: true, get: function () { return RecordingFilter_1.RecordingFilter; } });
14
+ // The webpieces DEFAULT error response, exported as a DELEGABLE object: it returns the same
15
+ // HttpResponseDto an app's ErrorTranslators.toWire returns, so an app wraps it ("webpieces' answer,
16
+ // plus one header") rather than copying its status-to-message table.
17
+ var HttpErrorWireMapper_1 = require("./HttpErrorWireMapper");
18
+ Object.defineProperty(exports, "HttpErrorWireMapper", { enumerable: true, get: function () { return HttpErrorWireMapper_1.HttpErrorWireMapper; } });
14
19
  // Test-case recording (contract lives in @webpieces/core-util)
15
20
  var TestCaseRecorderImpl_1 = require("./recorder/TestCaseRecorderImpl");
16
21
  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;AAC5B,gGAAgG;AAChG,+FAA+F;AAC/F,+FAA+F;AAC/F,6DAA4D;AAAnD,kHAAA,eAAe,OAAA;AAExB,+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// 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// 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;AAC5B,gGAAgG;AAChG,+FAA+F;AAC/F,+FAA+F;AAC/F,6DAA4D;AAAnD,kHAAA,eAAe,OAAA;AAExB,4FAA4F;AAC5F,oGAAoG;AACpG,qEAAqE;AACrE,6DAA4D;AAAnD,0HAAA,mBAAmB,OAAA;AAE5B,+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// 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 { HttpErrorWireMapper } from './HttpErrorWireMapper';\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"]}