@stone-js/aws-lambda-http-adapter 0.3.0 → 0.8.0

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/dist/index.js CHANGED
@@ -3,6 +3,7 @@ import { IncomingHttpEvent, HTTP_INTERNAL_SERVER_ERROR, BinaryFileResponse, Outg
3
3
  import mime from 'mime';
4
4
  import accepts from 'accepts';
5
5
  import statuses from 'statuses';
6
+ import { cloneValue, deepMerge } from '@stone-js/config';
6
7
  import { getString } from '@stone-js/env';
7
8
  import { File } from '@stone-js/filesystem';
8
9
  import proxyAddr from 'proxy-addr';
@@ -71,23 +72,40 @@ class RawHttpResponseWrapper {
71
72
  * ```
72
73
  */
73
74
  respond() {
74
- return {
75
+ const headers = new Headers(this.options.headers ?? {});
76
+ const setCookies = this.extractSetCookies(headers);
77
+ headers.delete('set-cookie');
78
+ const response = {
75
79
  ...this.options,
76
80
  statusCode: this.options.statusCode ?? 500,
77
- headers: this.normalizeHeaders(this.options.headers)
81
+ headers: Object.fromEntries(headers)
78
82
  };
83
+ // Emit multiple Set-Cookie correctly per trigger: v2/Function URLs use the `cookies` field,
84
+ // v1/ALB use `multiValueHeaders`. Folding them into a single comma-joined header (the old
85
+ // behaviour) is unparseable by browsers and leaks a cookie's attributes into the next.
86
+ if (setCookies.length > 0) {
87
+ if (this.options.version === 'v2') {
88
+ response.cookies = setCookies;
89
+ }
90
+ else {
91
+ response.multiValueHeaders = { ...this.options.multiValueHeaders, 'set-cookie': setCookies };
92
+ }
93
+ }
94
+ return response;
79
95
  }
80
96
  /**
81
- * Normalizes the headers to a consistent format.
82
- *
83
- * Converts Headers or Record<string, string> to a normalized Record<string, string>
84
- * with all keys in lowercase.
97
+ * Extract all `Set-Cookie` values from a Headers instance, tolerant of runtimes without
98
+ * `getSetCookie()`.
85
99
  *
86
- * @param headers - The headers to normalize.
87
- * @returns A normalized record of headers.
100
+ * @param headers - The response headers.
101
+ * @returns The raw `Set-Cookie` strings.
88
102
  */
89
- normalizeHeaders(headers) {
90
- return Object.fromEntries(new Headers(headers ?? {}));
103
+ extractSetCookies(headers) {
104
+ if (typeof headers.getSetCookie === 'function') {
105
+ return headers.getSetCookie();
106
+ }
107
+ const raw = headers.get('set-cookie');
108
+ return raw !== null && raw.length > 0 ? [raw] : [];
91
109
  }
92
110
  }
93
111
 
@@ -180,7 +198,7 @@ class AwsLambdaHttpAdapter extends Adapter {
180
198
  */
181
199
  async onStart() {
182
200
  if (typeof window === 'object') {
183
- throw new AwsLambdaHttpAdapterError('This `AWSLambdaAdapter` must be used only in AWS Lambda context.');
201
+ throw new AwsLambdaHttpAdapterError('This `AwsLambdaHttpAdapter` must be used only in AWS Lambda context.');
184
202
  }
185
203
  await this.executeHooks('onStart');
186
204
  }
@@ -243,9 +261,15 @@ class AwsLambdaHttpErrorHandler {
243
261
  */
244
262
  handle(error, context) {
245
263
  this.logger.error(error.message, { error });
246
- const statusCode = error.cause?.status ?? HTTP_INTERNAL_SERVER_ERROR;
247
- const type = accepts(context.rawEvent).type(['json', 'html']);
248
- const contentType = mime.getType(type !== false ? type : 'txt') ?? context.rawEvent.headers['content-type'] ?? 'text/plain';
264
+ // http-core's HttpError carries `statusCode` on the error itself; fall back to a cause and
265
+ // finally to 500. (Reading only `cause.status` turned every typed HTTP error into a 500.)
266
+ const statusCode = error.statusCode ??
267
+ error.cause?.statusCode ??
268
+ error.cause?.status ??
269
+ HTTP_INTERNAL_SERVER_ERROR;
270
+ const requestHeaders = context.rawEvent.headers ?? {};
271
+ const type = accepts({ headers: requestHeaders }).type(['json', 'html']);
272
+ const contentType = mime.getType(type !== false ? type : 'txt') ?? requestHeaders['content-type'] ?? 'text/plain';
249
273
  const headers = new Headers({ 'Content-Type': contentType });
250
274
  return context
251
275
  .rawResponseBuilder
@@ -264,6 +288,171 @@ class AwsLambdaHttpErrorHandler {
264
288
  */
265
289
  const AWS_LAMBDA_HTTP_PLATFORM = 'aws_lambda_http';
266
290
 
291
+ /**
292
+ * Detect which AWS HTTP trigger produced the event.
293
+ *
294
+ * @param event - The raw Lambda event.
295
+ * @returns The trigger family.
296
+ */
297
+ function detectEventVersion(event) {
298
+ const requestContext = event.requestContext;
299
+ if (requestContext?.elb !== undefined) {
300
+ return 'alb';
301
+ }
302
+ if (event.version === '2.0' || requestContext?.http !== undefined) {
303
+ return 'v2';
304
+ }
305
+ return 'v1';
306
+ }
307
+ /**
308
+ * Normalize headers to lower-cased keys, merging single and multi-value headers.
309
+ *
310
+ * @param event - The raw Lambda event.
311
+ * @returns Lower-cased headers with multi-value entries joined by `, `.
312
+ */
313
+ function normalizeHeaders(event) {
314
+ const out = {};
315
+ const multi = event.multiValueHeaders;
316
+ if (multi !== null && multi !== undefined) {
317
+ for (const [name, values] of Object.entries(multi)) {
318
+ if (Array.isArray(values)) {
319
+ out[name.toLowerCase()] = values.join(', ');
320
+ }
321
+ }
322
+ }
323
+ const single = event.headers;
324
+ if (single !== null && single !== undefined) {
325
+ for (const [name, value] of Object.entries(single)) {
326
+ if (value !== undefined) {
327
+ out[name.toLowerCase()] = value;
328
+ }
329
+ }
330
+ }
331
+ return out;
332
+ }
333
+ /**
334
+ * Build the raw query string from whichever representation the trigger provides.
335
+ *
336
+ * v2 gives the fidelity-preserving `rawQueryString`. v1/ALB give an object (single value) and
337
+ * optionally `multiValueQueryStringParameters` (preferred, preserves repeated keys). Values are
338
+ * URL-encoded so repeated/array values survive.
339
+ *
340
+ * @param event - The raw Lambda event.
341
+ * @param version - The detected trigger family.
342
+ * @returns The raw query string (no leading `?`).
343
+ */
344
+ function buildRawQueryString(event, version) {
345
+ if (version === 'v2' && typeof event.rawQueryString === 'string') {
346
+ return event.rawQueryString;
347
+ }
348
+ const multi = event.multiValueQueryStringParameters;
349
+ if (multi !== null && multi !== undefined) {
350
+ const params = new URLSearchParams();
351
+ for (const [key, values] of Object.entries(multi)) {
352
+ for (const value of values ?? []) {
353
+ params.append(key, value);
354
+ }
355
+ }
356
+ return params.toString();
357
+ }
358
+ const single = event.queryStringParameters;
359
+ if (single !== null && single !== undefined) {
360
+ const params = new URLSearchParams();
361
+ for (const [key, value] of Object.entries(single)) {
362
+ if (value !== undefined) {
363
+ params.append(key, value);
364
+ }
365
+ }
366
+ return params.toString();
367
+ }
368
+ return '';
369
+ }
370
+ /**
371
+ * Collect the raw cookie strings, regardless of trigger.
372
+ *
373
+ * v2 delivers `event.cookies: string[]`. v1/ALB deliver a single `Cookie` header (already merged
374
+ * into {@link NormalizedHttpEvent.headers}).
375
+ *
376
+ * @param event - The raw Lambda event.
377
+ * @param headers - The normalized headers.
378
+ * @returns The raw cookie strings.
379
+ */
380
+ function collectCookies(event, headers) {
381
+ if (Array.isArray(event.cookies)) {
382
+ return event.cookies;
383
+ }
384
+ const header = headers.cookie;
385
+ return typeof header === 'string' && header.length > 0 ? header.split(/; */) : [];
386
+ }
387
+ /**
388
+ * Resolve the client source IP across triggers.
389
+ *
390
+ * @param event - The raw Lambda event.
391
+ * @param version - The detected trigger family.
392
+ * @param headers - The normalized headers.
393
+ * @returns The source IP (empty string if unknown).
394
+ */
395
+ function resolveSourceIp(event, version, headers) {
396
+ const requestContext = event.requestContext;
397
+ if (version === 'v2') {
398
+ return String(requestContext?.http?.sourceIp ?? '');
399
+ }
400
+ if (version === 'v1') {
401
+ return String(requestContext?.identity?.sourceIp ?? '');
402
+ }
403
+ // ALB carries no identity; the client IP is the first hop in X-Forwarded-For.
404
+ return (headers['x-forwarded-for'] ?? '').split(',')[0].trim();
405
+ }
406
+ /**
407
+ * Normalize any AWS HTTP Lambda event into the canonical {@link NormalizedHttpEvent}.
408
+ *
409
+ * @param event - The raw Lambda event.
410
+ * @returns The normalized request.
411
+ */
412
+ function normalizeHttpEvent(event) {
413
+ const version = detectEventVersion(event);
414
+ const headers = normalizeHeaders(event);
415
+ const requestContext = event.requestContext;
416
+ const method = String((version === 'v2' ? requestContext?.http?.method : undefined) ??
417
+ event.httpMethod ??
418
+ requestContext?.httpMethod ??
419
+ requestContext?.http?.method ??
420
+ 'GET').toUpperCase();
421
+ const path = String((version === 'v2' ? (event.rawPath ?? requestContext?.http?.path) : undefined) ??
422
+ event.path ??
423
+ event.rawPath ??
424
+ '/');
425
+ return {
426
+ version,
427
+ method,
428
+ path,
429
+ headers,
430
+ rawQueryString: buildRawQueryString(event, version),
431
+ cookies: collectCookies(event, headers),
432
+ sourceIp: resolveSourceIp(event, version, headers),
433
+ isBase64Encoded: event.isBase64Encoded === true,
434
+ body: typeof event.body === 'string' ? event.body : undefined
435
+ };
436
+ }
437
+ /**
438
+ * Extract the raw request body exactly as received, decoding base64 to a Buffer when needed.
439
+ *
440
+ * This is what the adapter exposes as `metadata.rawBody`: the untouched payload the client sent,
441
+ * available to consumers even when no body-parsing middleware is installed. Binary payloads are
442
+ * returned as a `Buffer` (never a lossy UTF-8 round-trip); text payloads as a string. A
443
+ * non-string, non-base64 body (e.g. a pre-parsed object from a test/custom integration) is
444
+ * returned as-is.
445
+ *
446
+ * @param event - The raw Lambda event.
447
+ * @returns The raw body as a Buffer, string, the original value, or undefined.
448
+ */
449
+ function getRawBody(event) {
450
+ if (typeof event.body === 'string') {
451
+ return event.isBase64Encoded === true ? Buffer.from(event.body, 'base64') : event.body;
452
+ }
453
+ return event.body;
454
+ }
455
+
267
456
  /**
268
457
  * Adapter resolver for AWS Lambda HTTP adapter.
269
458
  *
@@ -276,154 +465,6 @@ const awsLambdaHttpAdapterResolver = (blueprint) => {
276
465
  return AwsLambdaHttpAdapter.create(blueprint);
277
466
  };
278
467
 
279
- function getDefaultExportFromCjs (x) {
280
- return x && x.__esModule && Object.prototype.hasOwnProperty.call(x, 'default') ? x['default'] : x;
281
- }
282
-
283
- var cjs;
284
- var hasRequiredCjs;
285
-
286
- function requireCjs () {
287
- if (hasRequiredCjs) return cjs;
288
- hasRequiredCjs = 1;
289
-
290
- var isMergeableObject = function isMergeableObject(value) {
291
- return isNonNullObject(value)
292
- && !isSpecial(value)
293
- };
294
-
295
- function isNonNullObject(value) {
296
- return !!value && typeof value === 'object'
297
- }
298
-
299
- function isSpecial(value) {
300
- var stringValue = Object.prototype.toString.call(value);
301
-
302
- return stringValue === '[object RegExp]'
303
- || stringValue === '[object Date]'
304
- || isReactElement(value)
305
- }
306
-
307
- // see https://github.com/facebook/react/blob/b5ac963fb791d1298e7f396236383bc955f916c1/src/isomorphic/classic/element/ReactElement.js#L21-L25
308
- var canUseSymbol = typeof Symbol === 'function' && Symbol.for;
309
- var REACT_ELEMENT_TYPE = canUseSymbol ? Symbol.for('react.element') : 0xeac7;
310
-
311
- function isReactElement(value) {
312
- return value.$$typeof === REACT_ELEMENT_TYPE
313
- }
314
-
315
- function emptyTarget(val) {
316
- return Array.isArray(val) ? [] : {}
317
- }
318
-
319
- function cloneUnlessOtherwiseSpecified(value, options) {
320
- return (options.clone !== false && options.isMergeableObject(value))
321
- ? deepmerge(emptyTarget(value), value, options)
322
- : value
323
- }
324
-
325
- function defaultArrayMerge(target, source, options) {
326
- return target.concat(source).map(function(element) {
327
- return cloneUnlessOtherwiseSpecified(element, options)
328
- })
329
- }
330
-
331
- function getMergeFunction(key, options) {
332
- if (!options.customMerge) {
333
- return deepmerge
334
- }
335
- var customMerge = options.customMerge(key);
336
- return typeof customMerge === 'function' ? customMerge : deepmerge
337
- }
338
-
339
- function getEnumerableOwnPropertySymbols(target) {
340
- return Object.getOwnPropertySymbols
341
- ? Object.getOwnPropertySymbols(target).filter(function(symbol) {
342
- return Object.propertyIsEnumerable.call(target, symbol)
343
- })
344
- : []
345
- }
346
-
347
- function getKeys(target) {
348
- return Object.keys(target).concat(getEnumerableOwnPropertySymbols(target))
349
- }
350
-
351
- function propertyIsOnObject(object, property) {
352
- try {
353
- return property in object
354
- } catch(_) {
355
- return false
356
- }
357
- }
358
-
359
- // Protects from prototype poisoning and unexpected merging up the prototype chain.
360
- function propertyIsUnsafe(target, key) {
361
- return propertyIsOnObject(target, key) // Properties are safe to merge if they don't exist in the target yet,
362
- && !(Object.hasOwnProperty.call(target, key) // unsafe if they exist up the prototype chain,
363
- && Object.propertyIsEnumerable.call(target, key)) // and also unsafe if they're nonenumerable.
364
- }
365
-
366
- function mergeObject(target, source, options) {
367
- var destination = {};
368
- if (options.isMergeableObject(target)) {
369
- getKeys(target).forEach(function(key) {
370
- destination[key] = cloneUnlessOtherwiseSpecified(target[key], options);
371
- });
372
- }
373
- getKeys(source).forEach(function(key) {
374
- if (propertyIsUnsafe(target, key)) {
375
- return
376
- }
377
-
378
- if (propertyIsOnObject(target, key) && options.isMergeableObject(source[key])) {
379
- destination[key] = getMergeFunction(key, options)(target[key], source[key], options);
380
- } else {
381
- destination[key] = cloneUnlessOtherwiseSpecified(source[key], options);
382
- }
383
- });
384
- return destination
385
- }
386
-
387
- function deepmerge(target, source, options) {
388
- options = options || {};
389
- options.arrayMerge = options.arrayMerge || defaultArrayMerge;
390
- options.isMergeableObject = options.isMergeableObject || isMergeableObject;
391
- // cloneUnlessOtherwiseSpecified is added to `options` so that custom arrayMerge()
392
- // implementations can use it. The caller may not replace it.
393
- options.cloneUnlessOtherwiseSpecified = cloneUnlessOtherwiseSpecified;
394
-
395
- var sourceIsArray = Array.isArray(source);
396
- var targetIsArray = Array.isArray(target);
397
- var sourceAndTargetTypesMatch = sourceIsArray === targetIsArray;
398
-
399
- if (!sourceAndTargetTypesMatch) {
400
- return cloneUnlessOtherwiseSpecified(source, options)
401
- } else if (sourceIsArray) {
402
- return options.arrayMerge(target, source, options)
403
- } else {
404
- return mergeObject(target, source, options)
405
- }
406
- }
407
-
408
- deepmerge.all = function deepmergeAll(array, options) {
409
- if (!Array.isArray(array)) {
410
- throw new Error('first argument should be an array')
411
- }
412
-
413
- return array.reduce(function(prev, next) {
414
- return deepmerge(prev, next, options)
415
- }, {})
416
- };
417
-
418
- var deepmerge_1 = deepmerge;
419
-
420
- cjs = deepmerge_1;
421
- return cjs;
422
- }
423
-
424
- var cjsExports = requireCjs();
425
- var deepmerge = /*@__PURE__*/getDefaultExportFromCjs(cjsExports);
426
-
427
468
  /**
428
469
  * Middleware to dynamically set response resolver for adapter.
429
470
  *
@@ -459,8 +500,11 @@ const metaAdapterBlueprintMiddleware = [
459
500
  /**
460
501
  * Middleware for handling incoming events and transforming them into Stone.js events.
461
502
  *
462
- * This class processes incoming HTTP requests, extracting relevant data such as URL, IP addresses,
463
- * headers, cookies, and more, and forwards them to the next middleware in the pipeline.
503
+ * It first normalizes the raw AWS event (API Gateway v1/v2, ALB, Function URLs) into a single
504
+ * canonical shape, then extracts URL, IP addresses, headers, cookies, query and the raw body,
505
+ * so the pipeline never has to reason about which trigger fired. The untouched request body is
506
+ * always exposed as `metadata.rawBody` — even when no body-parsing middleware is installed — so
507
+ * consumers can read the original payload (e.g. to verify a webhook signature).
464
508
  */
465
509
  class IncomingEventMiddleware {
466
510
  /**
@@ -470,7 +514,7 @@ class IncomingEventMiddleware {
470
514
  /**
471
515
  * Create an IncomingEventMiddleware instance.
472
516
  *
473
- * @param {blueprint} options - Options containing the blueprint for resolving configuration and dependencies.
517
+ * @param options - Options containing the blueprint for resolving configuration and dependencies.
474
518
  */
475
519
  constructor({ blueprint }) {
476
520
  this.blueprint = blueprint;
@@ -487,23 +531,23 @@ class IncomingEventMiddleware {
487
531
  if ((context.rawEvent === undefined) || ((context.incomingEventBuilder?.add) === undefined)) {
488
532
  throw new AwsLambdaHttpAdapterError('The context is missing required components.');
489
533
  }
534
+ const event = normalizeHttpEvent(context.rawEvent);
490
535
  const proxyOptions = this.getProxyOptions();
491
536
  const cookieOptions = this.getCookieOptions();
492
- const url = this.extractUrl(context.rawEvent, proxyOptions);
493
- const ipAddresses = this.extractIpAddresses(context.rawEvent, proxyOptions);
494
537
  context
495
538
  .incomingEventBuilder
496
- .add('url', url)
497
- .add('ips', ipAddresses)
539
+ .add('url', this.extractUrl(event, proxyOptions))
540
+ .add('ips', this.extractIpAddresses(event, proxyOptions))
498
541
  .add('source', this.getSource(context))
499
- .add('headers', context.rawEvent.headers)
500
- // If not defined by other middleware
501
- // In fullstack forms, the method is spoofed and sent as a hidden field
502
- .addIf('method', this.getMethod(context.rawEvent))
503
- .add('queryString', context.rawEvent.queryStringParameters)
504
- .add('protocol', this.getProtocol(context.rawEvent, proxyOptions))
505
- .add('cookies', CookieCollection.create(context.rawEvent.headers.cookie, cookieOptions, this.getCookieSecret()))
506
- .add('ip', proxyAddr(this.toNodeMessage(context.rawEvent), isIpTrusted(proxyOptions.trustedIp, proxyOptions.untrustedIp)));
542
+ .add('headers', event.headers)
543
+ // Expose the untouched request body so any consumer can read it, regardless of parsing.
544
+ .add('metadata', { rawBody: getRawBody(context.rawEvent) })
545
+ // If not defined by other middleware; in fullstack forms the method is spoofed via a field.
546
+ .addIf('method', event.method)
547
+ .add('queryString', event.rawQueryString)
548
+ .add('protocol', this.getProtocol(event, proxyOptions))
549
+ .add('cookies', CookieCollection.create(event.cookies.join('; '), cookieOptions, this.getCookieSecret()))
550
+ .add('ip', proxyAddr(this.toNodeMessage(event), isIpTrusted(proxyOptions.trustedIp, proxyOptions.untrustedIp)));
507
551
  return await next(context);
508
552
  }
509
553
  /**
@@ -519,18 +563,6 @@ class IncomingEventMiddleware {
519
563
  rawContext: context.executionContext
520
564
  };
521
565
  }
522
- /**
523
- * Extracts the HTTP method from the incoming rawEvent.
524
- *
525
- * @param rawEvent - The incoming rawEvent.
526
- * @returns The HTTP method string.
527
- */
528
- getMethod(rawEvent) {
529
- return rawEvent.httpMethod ??
530
- rawEvent.requestContext?.httpMethod ??
531
- rawEvent.requestContext?.http?.method ??
532
- 'GET';
533
- }
534
566
  /**
535
567
  * Extracts proxy-related options from the blueprint.
536
568
  *
@@ -538,7 +570,7 @@ class IncomingEventMiddleware {
538
570
  */
539
571
  getProxyOptions() {
540
572
  const defaultProxyOptions = { trusted: [], trustedIp: [], untrustedIp: [] };
541
- const proxyOptions = this.blueprint.get('stone.http.proxies', defaultProxyOptions);
573
+ const proxyOptions = { ...this.blueprint.get('stone.http.proxies', defaultProxyOptions) };
542
574
  proxyOptions.trusted = this.blueprint.get('stone.http.hosts.trusted', []);
543
575
  return proxyOptions;
544
576
  }
@@ -559,58 +591,58 @@ class IncomingEventMiddleware {
559
591
  return this.blueprint.get('stone.http.cookie.secret', this.blueprint.get('stone.secret', ''));
560
592
  }
561
593
  /**
562
- * Extracts and parses the URL from the incoming rawEvent.
594
+ * Extracts and parses the URL (including the query string) from the normalized event.
563
595
  *
564
- * @param rawEvent - The incoming HTTP rawEvent.
596
+ * @param event - The normalized HTTP event.
565
597
  * @param options - Proxy options.
566
598
  * @returns The parsed URL object.
567
599
  */
568
- extractUrl(rawEvent, options) {
569
- const hostname = getHostname(this.getRemoteAddress(rawEvent), rawEvent.headers, options);
570
- const proto = getProtocol(this.getRemoteAddress(rawEvent), rawEvent.headers, true, options);
571
- return new URL(rawEvent.path ?? rawEvent.rawPath ?? '', `${String(proto)}://${String(hostname)}`);
600
+ extractUrl(event, options) {
601
+ const hostname = getHostname(event.sourceIp, event.headers, options);
602
+ const proto = getProtocol(event.sourceIp, event.headers, true, options);
603
+ const pathWithQuery = event.rawQueryString.length > 0 ? `${event.path}?${event.rawQueryString}` : event.path;
604
+ return new URL(pathWithQuery, `${String(proto)}://${String(hostname)}`);
572
605
  }
573
606
  /**
574
- * Extracts a list of IP addresses from the incoming rawEvent.
607
+ * Extracts a list of IP addresses from the normalized event.
575
608
  *
576
- * @param rawEvent - The incoming HTTP rawEvent.
609
+ * @param event - The normalized HTTP event.
577
610
  * @param options - Proxy options.
578
611
  * @returns An array of IP addresses.
579
612
  */
580
- extractIpAddresses(rawEvent, options) {
613
+ extractIpAddresses(event, options) {
581
614
  const isTrusted = isIpTrusted(options.trustedIp, options.untrustedIp);
582
- return proxyAddr.all(this.toNodeMessage(rawEvent), isTrusted).slice(1).reverse();
615
+ return proxyAddr.all(this.toNodeMessage(event), isTrusted).slice(1).reverse();
583
616
  }
584
617
  /**
585
- * Converts the incoming rawEvent to a Node.js IncomingMessage.
618
+ * Converts the normalized event to a minimal Node.js IncomingMessage for `proxy-addr`.
619
+ *
620
+ * All standard forwarding headers are forwarded (not just `x-forwarded-for`) so proxy-addr can
621
+ * honour the deployment's trust configuration.
586
622
  *
587
- * @param rawEvent - The incoming rawEvent.
623
+ * @param event - The normalized HTTP event.
588
624
  * @returns The converted IncomingMessage.
589
625
  */
590
- toNodeMessage(rawEvent) {
626
+ toNodeMessage(event) {
591
627
  return {
592
- connection: { remoteAddress: this.getRemoteAddress(rawEvent) },
593
- headers: { 'x-forwarded-for': rawEvent.headers['x-forwarded-for'] ?? rawEvent.headers['X-Forwarded-For'] }
628
+ connection: { remoteAddress: event.sourceIp },
629
+ socket: { remoteAddress: event.sourceIp },
630
+ headers: {
631
+ forwarded: event.headers.forwarded,
632
+ 'x-real-ip': event.headers['x-real-ip'],
633
+ 'x-forwarded-for': event.headers['x-forwarded-for'] ?? event.sourceIp
634
+ }
594
635
  };
595
636
  }
596
637
  /**
597
- * Determines the protocol from the incoming rawEvent.
638
+ * Determines the protocol from the normalized event.
598
639
  *
599
- * @param rawEvent - The incoming rawEvent.
640
+ * @param event - The normalized HTTP event.
600
641
  * @param options - Proxy options.
601
642
  * @returns The protocol string.
602
643
  */
603
- getProtocol(rawEvent, options) {
604
- return getProtocol(this.getRemoteAddress(rawEvent), rawEvent.headers, true, options);
605
- }
606
- /**
607
- * Retrieves the remote address from the incoming rawEvent.
608
- * This method is used as a fallback when the remote address is not found in the rawEvent.
609
- * @param rawEvent - The incoming rawEvent.
610
- * @returns The remote address string.
611
- */
612
- getRemoteAddress(rawEvent) {
613
- return rawEvent.requestContext?.http?.sourceIp ?? rawEvent.requestContext?.identity?.sourceIp ?? '';
644
+ getProtocol(event, options) {
645
+ return getProtocol(event.sourceIp, event.headers, true, options);
614
646
  }
615
647
  }
616
648
  /**
@@ -640,6 +672,7 @@ class ServerResponseMiddleware {
640
672
  }
641
673
  rawResponseBuilder
642
674
  .add('headers', context.outgoingResponse.headers)
675
+ .add('version', detectEventVersion(context.rawEvent))
643
676
  .add('statusCode', context.outgoingResponse.statusCode ?? 500)
644
677
  .add('statusMessage', context.outgoingResponse.statusMessage ?? statuses.message[context.outgoingResponse.statusCode ?? 500]);
645
678
  if (!context.incomingEvent.isMethod('HEAD')) {
@@ -655,8 +688,7 @@ class ServerResponseMiddleware {
655
688
  : context.outgoingResponse.content;
656
689
  rawResponseBuilder
657
690
  .add('body', content)
658
- .add('isBase64Encoded', isBuffer)
659
- .add('charset', context.outgoingResponse.charset);
691
+ .add('isBase64Encoded', isBuffer);
660
692
  }
661
693
  }
662
694
  return rawResponseBuilder;
@@ -729,12 +761,14 @@ const awsLambdaHttpAdapterBlueprint = {
729
761
  */
730
762
  const AwsLambdaHttp = (options = {}) => {
731
763
  return classDecoratorLegacyWrapper((target, context) => {
732
- if (awsLambdaHttpAdapterBlueprint.stone?.adapters?.[0] !== undefined) {
733
- // Merge provided options with the default AWS Lambda HTTP adapter blueprint.
734
- awsLambdaHttpAdapterBlueprint.stone.adapters[0] = deepmerge(awsLambdaHttpAdapterBlueprint.stone.adapters[0], options);
764
+ // Clone the module-level default before merging so decorating a class never mutates the shared
765
+ // singleton (which would leak options across classes and tests). cloneValue recreates plain
766
+ // objects/arrays while keeping functions and class references intact.
767
+ const blueprint = cloneValue(awsLambdaHttpAdapterBlueprint);
768
+ if (blueprint.stone?.adapters?.[0] !== undefined) {
769
+ blueprint.stone.adapters[0] = deepMerge(blueprint.stone.adapters[0], options);
735
770
  }
736
- // Add the modified blueprint to the target class.
737
- addBlueprint(target, context, awsLambdaHttpAdapterBlueprint);
771
+ addBlueprint(target, context, blueprint);
738
772
  });
739
773
  };
740
774
 
@@ -773,16 +807,33 @@ class BodyEventMiddleware {
773
807
  }
774
808
  if (!isMultipart(this.toNodeMessage(context.rawEvent))) {
775
809
  const body = this.getBody(this.toNodeMessage(context.rawEvent), context.rawEvent);
776
- const method = body.$method$;
810
+ const method = this.extractSpoofedMethod(body);
777
811
  context
778
812
  .incomingEventBuilder
779
813
  .add('body', body)
780
- .add('metadata', body);
781
- // In fullstack forms, the method is spoofed and sent as a hidden field
814
+ // Keep the untouched payload available even after parsing (webhook signatures, etc.).
815
+ .add('metadata', { rawBody: getRawBody(context.rawEvent) });
816
+ // In fullstack forms, the method is spoofed and sent as a hidden field.
782
817
  isNotEmpty(method) && context.incomingEventBuilder.add('method', method);
783
818
  }
784
819
  return await next(context);
785
820
  }
821
+ /**
822
+ * Extract a spoofed HTTP method from a parsed body, supporting both JSON objects and
823
+ * urlencoded bodies (whose fields live on a `URLSearchParams`, not as own properties).
824
+ *
825
+ * @param body - The parsed body.
826
+ * @returns The spoofed method, or undefined.
827
+ */
828
+ extractSpoofedMethod(body) {
829
+ if (body instanceof URLSearchParams) {
830
+ return body.get('$method$') ?? undefined;
831
+ }
832
+ if (typeof body === 'object' && body !== null) {
833
+ return body.$method$;
834
+ }
835
+ return undefined;
836
+ }
786
837
  /**
787
838
  * Convert the raw event into a Node.js IncomingMessage.
788
839
  *
@@ -790,11 +841,12 @@ class BodyEventMiddleware {
790
841
  * @returns The converted IncomingMessage.
791
842
  */
792
843
  toNodeMessage(rawEvent) {
844
+ const headers = normalizeHeaders(rawEvent);
793
845
  return {
794
846
  headers: {
795
- 'content-type': rawEvent.headers['content-type'] ?? rawEvent.headers['Content-Type'],
796
- 'content-length': rawEvent.headers['content-length'] ?? rawEvent.headers['Content-Length'],
797
- 'transfer-encoding': rawEvent.headers['transfer-encoding'] ?? rawEvent.headers['Transfer-Encoding']
847
+ 'content-type': headers['content-type'],
848
+ 'content-length': headers['content-length'],
849
+ 'transfer-encoding': headers['transfer-encoding']
798
850
  }
799
851
  };
800
852
  }
@@ -814,50 +866,54 @@ class BodyEventMiddleware {
814
866
  const limit = bytes.parse(rawLimit) ?? 100000;
815
867
  const encoding = getCharset(message, defaultCharset);
816
868
  const type = typeIs(message, ['urlencoded', 'json', 'text', 'bin']) ?? defaultType;
817
- const rawBodyContent = this.getNormalizedRawBody(rawEvent, encoding);
818
- if (Buffer.byteLength(rawBodyContent, encoding) > limit) {
869
+ // Decode ONCE to a Buffer. Binary payloads must never round-trip through a lossy UTF-8 string
870
+ // (which corrupts any non-UTF-8 byte); the limit is measured on the true byte length.
871
+ const rawBuffer = this.getRawBuffer(rawEvent, encoding);
872
+ if (rawBuffer.byteLength > limit) {
819
873
  throw new AwsLambdaHttpAdapterError('Body payload exceeds configured limit.');
820
874
  }
821
- return this.parseBodyContent(type, rawBodyContent, encoding);
875
+ return this.parseBodyContent(type, rawBuffer, encoding);
822
876
  }
823
877
  /**
824
- * Get the normalized raw body from the event.
878
+ * Decode the request body into a Buffer, honouring base64 encoding.
825
879
  *
826
880
  * @param rawEvent - The raw event containing the body.
827
- * @param encoding - The encoding to use for the body.
828
- * @returns The normalized body as a string.
881
+ * @param encoding - The charset for a plain-text (non-base64) body.
882
+ * @returns The body as a Buffer.
829
883
  */
830
- getNormalizedRawBody(rawEvent, encoding) {
884
+ getRawBuffer(rawEvent, encoding) {
831
885
  if (typeof rawEvent.body === 'string') {
832
886
  return rawEvent.isBase64Encoded === true
833
- ? Buffer.from(rawEvent.body, 'base64').toString(encoding)
834
- : rawEvent.body;
887
+ ? Buffer.from(rawEvent.body, 'base64')
888
+ : Buffer.from(rawEvent.body, encoding);
835
889
  }
836
890
  if (typeof rawEvent.body === 'object' && rawEvent.body !== null) {
837
- return JSON.stringify(rawEvent.body);
891
+ return Buffer.from(JSON.stringify(rawEvent.body), encoding);
838
892
  }
839
- return '';
893
+ return Buffer.alloc(0);
840
894
  }
841
895
  /**
842
896
  * Parse the body content based on the specified type and encoding.
843
897
  *
844
898
  * @param type - The content type of the body.
845
- * @param body - The raw body content as a string.
899
+ * @param buffer - The raw body content as a Buffer.
846
900
  * @param encoding - The encoding of the body content.
847
901
  * @returns The parsed body content as an object, string, or Buffer.
848
902
  * @throws {AwsLambdaHttpAdapterError} If parsing fails.
849
903
  */
850
- parseBodyContent(type, body, encoding) {
904
+ parseBodyContent(type, buffer, encoding) {
851
905
  try {
852
906
  switch (type) {
853
- case 'json':
854
- return isNotEmpty(body) ? JSON.parse(body) : {};
907
+ case 'json': {
908
+ const text = buffer.toString(encoding);
909
+ return isNotEmpty(text) ? JSON.parse(text) : {};
910
+ }
855
911
  case 'text':
856
- return body;
912
+ return buffer.toString(encoding);
857
913
  case 'urlencoded':
858
- return new URLSearchParams(body);
914
+ return new URLSearchParams(buffer.toString(encoding));
859
915
  case 'bin':
860
- return Buffer.from(body, encoding);
916
+ return buffer; // Return the raw bytes untouched — no lossy re-encoding.
861
917
  default:
862
918
  return {};
863
919
  }
@@ -910,8 +966,10 @@ class FilesEventMiddleware {
910
966
  context
911
967
  .incomingEventBuilder
912
968
  .add('files', response.files)
913
- .add('body', response.fields);
914
- // In fullstack forms, the method is spoofed and sent as a hidden field
969
+ .add('body', response.fields)
970
+ // Keep the untouched multipart payload available (webhook signatures, re-streaming, etc.).
971
+ .add('metadata', { rawBody: getRawBody(context.rawEvent) });
972
+ // In fullstack forms, the method is spoofed and sent as a hidden field.
915
973
  isNotEmpty(method) && context.incomingEventBuilder.add('method', method);
916
974
  }
917
975
  return await next(context);
@@ -923,6 +981,7 @@ class FilesEventMiddleware {
923
981
  * @returns The normalized event.
924
982
  */
925
983
  normalizeEvent(rawEvent) {
984
+ const headers = normalizeHeaders(rawEvent);
926
985
  let body = rawEvent.body;
927
986
  if (typeof rawEvent.body === 'string') {
928
987
  body = rawEvent.isBase64Encoded === true
@@ -932,9 +991,9 @@ class FilesEventMiddleware {
932
991
  return {
933
992
  body,
934
993
  headers: {
935
- 'content-type': rawEvent.headers['content-type'] ?? rawEvent.headers['Content-Type'],
936
- 'content-length': rawEvent.headers['content-length'] ?? rawEvent.headers['Content-Length'],
937
- 'transfer-encoding': rawEvent.headers['transfer-encoding'] ?? rawEvent.headers['Transfer-Encoding']
994
+ 'content-type': headers['content-type'],
995
+ 'content-length': headers['content-length'],
996
+ 'transfer-encoding': headers['transfer-encoding']
938
997
  }
939
998
  };
940
999
  }
@@ -944,4 +1003,4 @@ class FilesEventMiddleware {
944
1003
  */
945
1004
  const MetaFilesEventMiddleware = { module: FilesEventMiddleware, isClass: true };
946
1005
 
947
- export { AWS_LAMBDA_HTTP_PLATFORM, AwsLambdaHttp, AwsLambdaHttpAdapter, AwsLambdaHttpAdapterError, AwsLambdaHttpErrorHandler, BodyEventMiddleware, FilesEventMiddleware, IncomingEventMiddleware, MetaBodyEventMiddleware, MetaFilesEventMiddleware, MetaIncomingEventMiddleware, MetaServerResponseMiddleware, RawHttpResponseWrapper, ServerResponseMiddleware, SetAwsLambdaHttpResponseResolverMiddleware, awsLambdaHttpAdapterBlueprint, awsLambdaHttpAdapterResolver, metaAdapterBlueprintMiddleware };
1006
+ export { AWS_LAMBDA_HTTP_PLATFORM, AwsLambdaHttp, AwsLambdaHttpAdapter, AwsLambdaHttpAdapterError, AwsLambdaHttpErrorHandler, BodyEventMiddleware, FilesEventMiddleware, IncomingEventMiddleware, MetaBodyEventMiddleware, MetaFilesEventMiddleware, MetaIncomingEventMiddleware, MetaServerResponseMiddleware, RawHttpResponseWrapper, ServerResponseMiddleware, SetAwsLambdaHttpResponseResolverMiddleware, awsLambdaHttpAdapterBlueprint, awsLambdaHttpAdapterResolver, buildRawQueryString, collectCookies, detectEventVersion, getRawBody, metaAdapterBlueprintMiddleware, normalizeHeaders, normalizeHttpEvent, resolveSourceIp };