From 8f31cecbbc4a84ea2e110e50d142e145b0be6eef Mon Sep 17 00:00:00 2001 From: Kieran Haberstock Date: Thu, 26 Feb 2026 11:15:58 -0800 Subject: [PATCH 1/2] Add mrt-utilities --- .../b2c-tooling-sdk/src/plugins/loader.ts | 2 +- packages/mrt-utilities/.c8rc.json | 7 + packages/mrt-utilities/.gitignore | 4 + packages/mrt-utilities/.mocharc.json | 6 + packages/mrt-utilities/README.md | 49 + packages/mrt-utilities/eslint.config.mjs | 45 + packages/mrt-utilities/package.json | 132 + packages/mrt-utilities/src/index.ts | 10 + packages/mrt-utilities/src/metrics/index.ts | 7 + .../src/metrics/metrics-sender.ts | 279 ++ .../src/middleware/data-store.ts | 144 + .../mrt-utilities/src/middleware/index.ts | 9 + .../src/middleware/middleware.ts | 459 +++ .../src/streaming/create-lambda-adapter.ts | 969 +++++++ packages/mrt-utilities/src/streaming/index.ts | 13 + .../src/utils/configure-proxying.ts | 324 +++ .../mrt-utilities/src/utils/ssr-proxying.ts | 928 ++++++ packages/mrt-utilities/src/utils/utils.ts | 47 + .../mrt-utilities/test/data-store.test.ts | 182 ++ .../mrt-utilities/test/metrics-sender.test.ts | 319 +++ .../mrt-utilities/test/middleware.test.ts | 275 ++ .../create-lambda-adapter-compression.test.ts | 956 +++++++ .../streaming/create-lambda-adapter.test.ts | 2532 +++++++++++++++++ packages/mrt-utilities/test/tsconfig.json | 13 + .../test/utils/configure-proxying.test.ts | 237 ++ .../test/utils/ssr-proxying.test.ts | 770 +++++ .../mrt-utilities/test/utils/utils.test.ts | 102 + packages/mrt-utilities/tsconfig.cjs.json | 11 + packages/mrt-utilities/tsconfig.esm.json | 8 + packages/mrt-utilities/tsconfig.json | 10 + pnpm-lock.yaml | 1868 +++++++++++- 31 files changed, 10701 insertions(+), 16 deletions(-) create mode 100644 packages/mrt-utilities/.c8rc.json create mode 100644 packages/mrt-utilities/.gitignore create mode 100644 packages/mrt-utilities/.mocharc.json create mode 100644 packages/mrt-utilities/README.md create mode 100644 packages/mrt-utilities/eslint.config.mjs create mode 100644 packages/mrt-utilities/package.json create mode 100644 packages/mrt-utilities/src/index.ts create mode 100644 packages/mrt-utilities/src/metrics/index.ts create mode 100644 packages/mrt-utilities/src/metrics/metrics-sender.ts create mode 100644 packages/mrt-utilities/src/middleware/data-store.ts create mode 100644 packages/mrt-utilities/src/middleware/index.ts create mode 100644 packages/mrt-utilities/src/middleware/middleware.ts create mode 100644 packages/mrt-utilities/src/streaming/create-lambda-adapter.ts create mode 100644 packages/mrt-utilities/src/streaming/index.ts create mode 100644 packages/mrt-utilities/src/utils/configure-proxying.ts create mode 100644 packages/mrt-utilities/src/utils/ssr-proxying.ts create mode 100644 packages/mrt-utilities/src/utils/utils.ts create mode 100644 packages/mrt-utilities/test/data-store.test.ts create mode 100644 packages/mrt-utilities/test/metrics-sender.test.ts create mode 100644 packages/mrt-utilities/test/middleware.test.ts create mode 100644 packages/mrt-utilities/test/streaming/create-lambda-adapter-compression.test.ts create mode 100644 packages/mrt-utilities/test/streaming/create-lambda-adapter.test.ts create mode 100644 packages/mrt-utilities/test/tsconfig.json create mode 100644 packages/mrt-utilities/test/utils/configure-proxying.test.ts create mode 100644 packages/mrt-utilities/test/utils/ssr-proxying.test.ts create mode 100644 packages/mrt-utilities/test/utils/utils.test.ts create mode 100644 packages/mrt-utilities/tsconfig.cjs.json create mode 100644 packages/mrt-utilities/tsconfig.esm.json create mode 100644 packages/mrt-utilities/tsconfig.json diff --git a/packages/b2c-tooling-sdk/src/plugins/loader.ts b/packages/b2c-tooling-sdk/src/plugins/loader.ts index 406eac27..deab783c 100644 --- a/packages/b2c-tooling-sdk/src/plugins/loader.ts +++ b/packages/b2c-tooling-sdk/src/plugins/loader.ts @@ -55,7 +55,7 @@ export function createHookContext(options: HookContextOptions = {}): HookContext * esbuild transforms `import()` to `require()` in CJS output, which cannot * load ESM plugins. Using `new Function` preserves the native dynamic import. */ -// eslint-disable-next-line @typescript-eslint/no-implied-eval + const dynamicImport = new Function('specifier', 'return import(specifier)') as ( specifier: string, ) => Promise>; diff --git a/packages/mrt-utilities/.c8rc.json b/packages/mrt-utilities/.c8rc.json new file mode 100644 index 00000000..14438df9 --- /dev/null +++ b/packages/mrt-utilities/.c8rc.json @@ -0,0 +1,7 @@ +{ + "all": true, + "src": ["src"], + "exclude": ["test/**", "**/*.d.ts", "**/*.test.ts"], + "reporter": ["text", "text-summary", "html", "lcov"], + "report-dir": "coverage" +} diff --git a/packages/mrt-utilities/.gitignore b/packages/mrt-utilities/.gitignore new file mode 100644 index 00000000..c19bb02d --- /dev/null +++ b/packages/mrt-utilities/.gitignore @@ -0,0 +1,4 @@ +node_modules/ +dist/ +coverage/ +*.tsbuildinfo diff --git a/packages/mrt-utilities/.mocharc.json b/packages/mrt-utilities/.mocharc.json new file mode 100644 index 00000000..1722876a --- /dev/null +++ b/packages/mrt-utilities/.mocharc.json @@ -0,0 +1,6 @@ +{ + "node-option": ["import=tsx", "conditions=development"], + "timeout": 10000, + "recursive": true, + "extension": ["ts"] +} diff --git a/packages/mrt-utilities/README.md b/packages/mrt-utilities/README.md new file mode 100644 index 00000000..6b880770 --- /dev/null +++ b/packages/mrt-utilities/README.md @@ -0,0 +1,49 @@ +# mrt-utilities + +Middleware and utilities to simulate a deployed MRT environment. + +## Usage + +``` +import { + createMRTProxyMiddlewares, + createMRTRequestProcessorMiddleware, + createMRTStaticAssetServingMiddleware, + createMRTCommonMiddleware, + createMRTCleanUpMiddleware, + isLocal, +} from '@salesforce/mrt-utilities'; + + +export const createApp = (): Express => { + const app = express(); + app.disable('x-powered-by'); + + // Top most middleware to set up headers + app.use(createMRTCommonMiddleware()); + + if (isLocal()) { + const requestProcessorPath = 'path/to/request-processor.js'; + const proxyConfigs = [ + { + host: 'https://example.com', + path: 'api', + }, + ]; + app.use(createMRTRequestProcessorMiddleware(requestProcessorPath, proxyConfigs)); + + const mrtProxies = createMRTProxyMiddlewares(proxyConfigs); + mrtProxies.forEach(({ path, fn }) => { + app.use(path, fn); + }); + + const staticAssetDir = 'path/to/static'; + app.use( + `/mobify/bundle/${process.env.BUNDLE_ID || '1'}/static/`, + createMRTStaticAssetServingMiddleware(staticAssetDir) + ); + } + + // Cleans up any remaining headers and sets any remaining values + app.use(createMRTCleanUpMiddleware()); +``` diff --git a/packages/mrt-utilities/eslint.config.mjs b/packages/mrt-utilities/eslint.config.mjs new file mode 100644 index 00000000..d2597173 --- /dev/null +++ b/packages/mrt-utilities/eslint.config.mjs @@ -0,0 +1,45 @@ +/* + * Copyright (c) 2025, Salesforce, Inc. + * SPDX-License-Identifier: Apache-2 + * For full license text, see the license.txt file in the repo root or http://www.apache.org/licenses/LICENSE-2.0 + */ +import {includeIgnoreFile} from '@eslint/compat'; +import headerPlugin from 'eslint-plugin-header'; +import tseslint from 'typescript-eslint'; +import path from 'node:path'; +import {fileURLToPath} from 'node:url'; + +import {copyrightHeader, sharedRules, chaiTestRules, prettierPlugin} from '../../eslint.config.mjs'; + +const gitignorePath = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '.gitignore'); +headerPlugin.rules.header.meta.schema = false; + +export default [ + includeIgnoreFile(gitignorePath), + ...tseslint.configs.recommended, + prettierPlugin, + { + files: ['**/*.ts'], + plugins: { + header: headerPlugin, + }, + languageOptions: { + parserOptions: { + project: true, + tsconfigRootDir: import.meta.dirname, + }, + }, + rules: { + 'header/header': ['error', 'block', copyrightHeader], + ...sharedRules, + }, + }, + { + files: ['test/**/*.ts'], + rules: { + ...chaiTestRules, + // Streaming adapter tests use any for mock streams and event shapes + '@typescript-eslint/no-explicit-any': 'off', + }, + }, +]; diff --git a/packages/mrt-utilities/package.json b/packages/mrt-utilities/package.json new file mode 100644 index 00000000..e6c57fac --- /dev/null +++ b/packages/mrt-utilities/package.json @@ -0,0 +1,132 @@ +{ + "name": "@salesforce/mrt-utilities", + "version": "0.0.1", + "description": "Middleware and utilities to simulate a deployed Managed Runtime environment", + "type": "module", + "author": "Salesforce", + "license": "Apache-2.0", + "repository": "SalesforceCommerceCloud/b2c-developer-tooling", + "keywords": [ + "salesforce", + "commerce-cloud", + "mrt", + "middleware", + "express" + ], + "main": "./dist/cjs/index.js", + "module": "./dist/esm/index.js", + "types": "./dist/esm/index.d.ts", + "exports": { + "./package.json": "./package.json", + ".": { + "development": "./src/index.ts", + "import": { + "types": "./dist/esm/index.d.ts", + "default": "./dist/esm/index.js" + }, + "require": { + "types": "./dist/cjs/index.d.ts", + "default": "./dist/cjs/index.js" + } + }, + "./middleware": { + "development": "./src/middleware/index.ts", + "import": { + "types": "./dist/esm/middleware/index.d.ts", + "default": "./dist/esm/middleware/index.js" + }, + "require": { + "types": "./dist/cjs/middleware/index.d.ts", + "default": "./dist/cjs/middleware/index.js" + } + }, + "./metrics": { + "development": "./src/metrics/index.ts", + "import": { + "types": "./dist/esm/metrics/index.d.ts", + "default": "./dist/esm/metrics/index.js" + }, + "require": { + "types": "./dist/cjs/metrics/index.d.ts", + "default": "./dist/cjs/metrics/index.js" + } + }, + "./streaming": { + "development": "./src/streaming/index.ts", + "import": { + "types": "./dist/esm/streaming/index.d.ts", + "default": "./dist/esm/streaming/index.js" + }, + "require": { + "types": "./dist/cjs/streaming/index.d.ts", + "default": "./dist/cjs/streaming/index.js" + } + } + }, + "files": [ + "dist" + ], + "scripts": { + "build": "pnpm run build:esm && pnpm run build:cjs", + "build:esm": "tsc -p tsconfig.esm.json", + "build:cjs": "tsc -p tsconfig.cjs.json && echo '{\"type\":\"commonjs\"}' > dist/cjs/package.json", + "clean": "shx rm -rf dist", + "lint": "eslint", + "lint:agent": "eslint --quiet", + "typecheck:agent": "tsc --noEmit --pretty false", + "format": "prettier --write .", + "format:check": "prettier --check .", + "pretest": "tsc --noEmit -p test", + "test": "c8 mocha --forbid-only \"test/**/*.test.ts\"", + "test:agent": "mocha --forbid-only --reporter min \"test/**/*.test.ts\"", + "test:watch": "mocha --watch \"test/**/*.test.ts\"" + }, + "dependencies": { + "@aws-sdk/client-cloudwatch": "3.952.0", + "@aws-sdk/client-dynamodb": "3.980.0", + "@aws-sdk/lib-dynamodb": "3.980.0", + "@h4ad/serverless-adapter": "4.4.0", + "change-case": "5.4.4", + "compressible": "2.0.18", + "http-proxy-middleware": "3.0.5", + "mime-types": "3.0.1", + "negotiator": "1.0.0", + "qs": "6.14.0", + "set-cookie-parser": "2.7.1" + }, + "devDependencies": { + "@salesforce/dev-config": "catalog:", + "@serverless/event-mocks": "1.1.1", + "@types/aws-lambda": "8.10.160", + "@types/chai": "catalog:", + "@types/compressible": "2.0.3", + "@types/express": "5.0.3", + "@types/mime-types": "3.0.1", + "@types/mocha": "catalog:", + "@types/negotiator": "0.6.4", + "@types/node": "catalog:", + "@types/qs": "6.14.0", + "@types/set-cookie-parser": "2.4.10", + "@types/sinon": "catalog:", + "c8": "catalog:", + "chai": "catalog:", + "eslint": "catalog:", + "eslint-config-prettier": "catalog:", + "eslint-plugin-header": "catalog:", + "eslint-plugin-prettier": "catalog:", + "express": "5.1.0", + "mocha": "catalog:", + "prettier": "catalog:", + "shx": "catalog:", + "sinon": "catalog:", + "tsx": "catalog:", + "typescript": "catalog:", + "typescript-eslint": "catalog:" + }, + "peerDependencies": { + "express": "5.1.0" + }, + "engines": { + "node": ">=22.16.0" + } +} diff --git a/packages/mrt-utilities/src/index.ts b/packages/mrt-utilities/src/index.ts new file mode 100644 index 00000000..61171589 --- /dev/null +++ b/packages/mrt-utilities/src/index.ts @@ -0,0 +1,10 @@ +/* + * Copyright (c) 2025, Salesforce, Inc. + * SPDX-License-Identifier: Apache-2 + * For full license text, see the license.txt file in the repo root or http://www.apache.org/licenses/LICENSE-2.0 + */ + +export * from './middleware/index.js'; +export * from './metrics/index.js'; +export * from './streaming/index.js'; +export {isLocal} from './utils/utils.js'; diff --git a/packages/mrt-utilities/src/metrics/index.ts b/packages/mrt-utilities/src/metrics/index.ts new file mode 100644 index 00000000..bf36dbc0 --- /dev/null +++ b/packages/mrt-utilities/src/metrics/index.ts @@ -0,0 +1,7 @@ +/* + * Copyright (c) 2025, Salesforce, Inc. + * SPDX-License-Identifier: Apache-2 + * For full license text, see the license.txt file in the repo root or http://www.apache.org/licenses/LICENSE-2.0 + */ + +export * from './metrics-sender.js'; diff --git a/packages/mrt-utilities/src/metrics/metrics-sender.ts b/packages/mrt-utilities/src/metrics/metrics-sender.ts new file mode 100644 index 00000000..9188602a --- /dev/null +++ b/packages/mrt-utilities/src/metrics/metrics-sender.ts @@ -0,0 +1,279 @@ +/* + * Copyright (c) 2025, Salesforce, Inc. + * SPDX-License-Identifier: Apache-2 + * For full license text, see the license.txt file in the repo root or http://www.apache.org/licenses/LICENSE-2.0 + */ + +import {CloudWatchClient, PutMetricDataCommand, type StandardUnit, type MetricDatum} from '@aws-sdk/client-cloudwatch'; +import {isLocal} from '../utils/utils.js'; + +const isRemote = (): boolean => !isLocal(); + +export const DEFAULT_NAMESPACE = 'ssr'; + +export const getDimensions = (): Record => { + return { + Project: process.env.MOBIFY_PROPERTY_ID || 'UNKNOWN', + Target: process.env.DEPLOY_TARGET || 'UNKNOWN', + }; +}; + +/** + * Input metric for sending to CloudWatch. + * + * @property name - The name of the metric (required) + * @property value - The numeric value of the metric (optional, defaults to 0) + * @property timestamp - The timestamp for the metric (optional, defaults to current time) + * @property unit - The unit for the metric (optional, defaults to 'Count') + * @property dimensions - Key-value pairs for metric dimensions (optional) + */ +interface InputMetric { + name: string; + value?: number; + timestamp?: Date; + unit?: string; + dimensions?: Record; +} + +/** + * A class that handles asynchronous sending of CloudWatch metrics. + * + * This class uses a singleton pattern. Use MetricsSender.getSender() + * to get the singleton instance. Metrics can be queued and sent in + * batches, or sent immediately. The class automatically batches metrics + * into groups of 20 (CloudWatch's limit per request). + * + * In local development environments, metrics are queued but not sent + * unless the SEND_CW_METRICS environment variable is set. + */ +export class MetricsSender { + private _CW: CloudWatchClient | null = null; + private _queue: MetricDatum[] = []; + static _override: boolean = false; + private static _instance: MetricsSender | null = null; + + /** @internal Test hook: inject a CloudWatch client for unit tests */ + static _testClient: CloudWatchClient | null = null; + + private constructor() { + // CloudWatch client used to send metrics. For a local dev server, + // this will remain falsy, since a local dev server doesn't actually + // send metrics (unless SEND_CW_METRICS is defined for testing). + this._CW = null; + + // A queue of metrics waiting to be sent. Each is a single + // name/value metric, and they accumulate on this queue + // until batched up into a putMetricData call. + this._queue = []; + } + + /** + * Return the number of metrics waiting to be sent + * @returns {number} + */ + get queueLength(): number { + return this._queue.length; + } + + /** + * Create a CloudWatch AWS SDK client, or return a falsy value + * if this MetricsSender is not actually sending metrics. + * + * The client is only created when running in a remote environment + * (AWS Lambda) or when SEND_CW_METRICS environment variable is set. + * The client is configured with maxAttempts: 1 to prevent retries + * and reduce latency under high load. + * + * @private + * @returns {CloudWatchClient|null} The CloudWatch client, or null if not sending metrics + */ + private _setup(): CloudWatchClient | null { + if (MetricsSender._testClient) { + this._CW = MetricsSender._testClient; + return this._CW; + } + /* istanbul ignore next */ + if (!this._CW && (isRemote() || MetricsSender._override)) { + // The AWS_REGION variable is defined by the Lambda + // environment. + // Setting maxAttempts to 1 will prevent the SDK from retrying. + // This is necessary because under high load, there will be backpressure + // on the Lambda function, and causing severe performance issues (400-500ms latency) + this._CW = new CloudWatchClient({ + region: process.env.AWS_REGION || 'us-east-1', + maxAttempts: 1, + }); + } + return this._CW; + } + + /** + * Convert InputMetric to MetricDatum format + * + * @private + * @param metric - Input metric to convert + * @param defaultTimestamp - Default timestamp to use if not provided + * @returns Converted metric datum + */ + private _convertToMetricDatum(metric: InputMetric, defaultTimestamp: Date): MetricDatum { + const metricData: MetricDatum = { + MetricName: metric.name, + Value: metric.value || 0, + Timestamp: metric.timestamp instanceof Date ? metric.timestamp : defaultTimestamp, + Unit: (metric.unit || 'Count') as StandardUnit, + }; + + if (metric.dimensions) { + const dimensions: Array<{Name: string; Value: string}> = []; + Object.entries(metric.dimensions).forEach(([key, value]) => { + if (value) { + dimensions.push({ + Name: key, + Value: value, + }); + } + }); + if (dimensions.length > 0) { + metricData.Dimensions = dimensions; + } + } + + return metricData; + } + + /** + * Send metrics to CloudWatch using putMetricData. + * + * Errors are caught and logged but not re-thrown. If the client + * is null (local environment without SEND_CW_METRICS), this method + * returns immediately without sending. + * + * @private + * @param cw - CloudWatch client (may be null) + * @param metrics - Array of MetricDatum to send + * @returns Promise that resolves when the send operation completes (or immediately if client is null) + */ + private async _putMetricData(cw: CloudWatchClient | null, metrics: MetricDatum[]): Promise { + /* istanbul ignore next */ + if (!cw) { + return Promise.resolve(); + } + + try { + const command = new PutMetricDataCommand({ + MetricData: metrics, + Namespace: DEFAULT_NAMESPACE, + }); + await cw.send(command); + } catch (err) { + console.warn(`Metrics: error sending data: ${err}`); + } + } + + /** + * Batch and send metrics. Handles batching into groups of 20 (CloudWatch limit) + * and sends them asynchronously (fire and forget). Errors are logged but not raised. + * + * @private + * @param metrics - Array of metrics to send + */ + private _sendBatchedMetrics(metrics: MetricDatum[]): void { + if (metrics.length === 0) { + return; + } + + const cw = this._setup(); + const promises: Promise[] = []; + const batchSize = 20; + + for (let i = 0; i < metrics.length; i += batchSize) { + const batch = metrics.slice(i, i + batchSize); + promises.push(this._putMetricData(cw, batch)); + } + + // Wait for all promises to complete, log any errors but don't raise them + Promise.all(promises).catch( + /* istanbul ignore next */ + (err) => { + console.warn(`Metrics: error during batch send: ${err}`); + }, + ); + } + + /** + * Send any queued metrics. Returns a Promise that resolves immediately + * after starting the send operations (fire and forget). Errors are logged + * but not raised. The queue is cleared before sending begins. + * + * @returns Promise that resolves immediately after starting send operations + */ + flush(): Promise { + const metricsToSend = [...this._queue]; + this._queue = []; + this._sendBatchedMetrics(metricsToSend); + return Promise.resolve(); + } + + /** + * Add one or more custom metric values to the queue of those waiting + * to be sent, or send them immediately. This function supports simple + * name-and-value metrics. It doesn't support more complex CloudWatch types. + * + * A metric is an object with at least 'name' (string) and optionally 'value' + * (number, defaults to 0). It may also optionally include 'timestamp' + * (defaults to the time of the call to send()), and 'unit', which + * must be one of Seconds, Microseconds, Milliseconds, Bytes, Kilobytes, + * Megabytes, Gigabytes, Terabytes, Bits, Kilobits, Megabits, Gigabits, + * Terabits, Percent, Count, Bytes/Second, Kilobytes/Second, + * Megabytes/Second, Gigabytes/Second, Terabytes/Second, + * Bits/Second, Kilobits/Second, Megabits/Second, Gigabits/Second, + * Terabits/Second, Count/Second or None (defaults to 'Count'). + * There may also be a 'dimensions' + * object, which has dimension names as keys and dimension + * values as values. Empty or falsy dimension values are filtered out. + * + * In a local development environment, metrics are queued but not sent + * unless the SEND_CW_METRICS environment variable is set. This allows + * for testing metric sending behavior locally. + * + * The metrics are added to an internal queue so that they can be + * batched up to send more efficiently. They are only sent when + * flush() is called, unless immediate is true. + * + * @private + * @param metrics - Array of InputMetric objects to send + * @param immediate - If true, send metrics immediately instead of queuing (default: false) + */ + send(metrics: InputMetric[], immediate: boolean = false): void { + const now = new Date(); + const metricDataArray: MetricDatum[] = metrics.map((metric) => this._convertToMetricDatum(metric, now)); + + if (immediate) { + // Send immediately without waiting (fire and forget) + this._sendBatchedMetrics(metricDataArray); + } else { + // Add to queue + this._queue.push(...metricDataArray); + } + } + + /** + * Get the singleton MetricsSender instance. + * + * Creates a new instance if one doesn't exist, otherwise returns + * the existing instance. + * + * @returns The singleton MetricsSender instance + */ + static getSender(): MetricsSender { + if (!MetricsSender._instance) { + MetricsSender._instance = new MetricsSender(); + } + return MetricsSender._instance; + } +} + +// Allow the presence of an environment variable to +// enable sending of CloudWatch metrics (for local +// integration testing) +MetricsSender._override = !!process.env.SEND_CW_METRICS; diff --git a/packages/mrt-utilities/src/middleware/data-store.ts b/packages/mrt-utilities/src/middleware/data-store.ts new file mode 100644 index 00000000..9c854c93 --- /dev/null +++ b/packages/mrt-utilities/src/middleware/data-store.ts @@ -0,0 +1,144 @@ +/* + * Copyright (c) 2025, Salesforce, Inc. + * SPDX-License-Identifier: Apache-2 + * For full license text, see the license.txt file in the repo root or http://www.apache.org/licenses/LICENSE-2.0 + */ + +import {DynamoDBClient} from '@aws-sdk/client-dynamodb'; +import {DynamoDBDocumentClient, GetCommand, type GetCommandOutput} from '@aws-sdk/lib-dynamodb'; + +import {logMRTError} from '../utils/utils.js'; + +export class DataStoreNotFoundError extends Error { + constructor(message: string) { + super(message); + this.name = 'DataStoreNotFoundError'; + Object.setPrototypeOf(this, DataStoreNotFoundError.prototype); + } +} + +export class DataStoreServiceError extends Error { + constructor(message: string) { + super(message); + this.name = 'DataStoreServiceError'; + Object.setPrototypeOf(this, DataStoreServiceError.prototype); + } +} + +export class DataStoreUnavailableError extends Error { + constructor(message: string) { + super(message); + this.name = 'DataStoreUnavailableError'; + Object.setPrototypeOf(this, DataStoreUnavailableError.prototype); + } +} + +/** + * A class for reading entries from the data store. + * + * This class uses a singleton pattern. + * Use DataStore.getDataStore() to get the singleton instance. + */ +export class DataStore { + private _tableName: string = ''; + private _ddb: DynamoDBDocumentClient | null = null; + private static _instance: DataStore | null = null; + + /** @internal Test hook: inject a document client for unit tests */ + static _testDocumentClient: DynamoDBDocumentClient | null = null; + /** @internal Test hook: inject logMRTError for unit tests */ + static _testLogMRTError: ((namespace: string, err: unknown, context?: Record) => void) | null = null; + + private constructor() { + // Private constructor for singleton; use DataStore.getDataStore() instead. + } + + /** + * Get or create a DynamoDB document client (for abstraction of attribute values). + * + * @private + * @returns The DynamoDB document client + * @throws {DataStoreUnavailableError} The data store is unavailable + */ + private getClient(): DynamoDBDocumentClient { + if (!this.isDataStoreAvailable()) { + throw new DataStoreUnavailableError('The data store is unavailable.'); + } + + if (DataStore._testDocumentClient) { + this._tableName = `DataAccessLayer-${process.env.AWS_REGION}`; + return DataStore._testDocumentClient; + } + + if (!this._ddb) { + this._tableName = `DataAccessLayer-${process.env.AWS_REGION}`; + this._ddb = DynamoDBDocumentClient.from( + new DynamoDBClient({ + region: process.env.AWS_REGION, + }), + ); + } + + return this._ddb; + } + + /** + * Get or create the singleton DataStore instance. + * + * @returns The singleton DataStore instance + */ + static getDataStore(): DataStore { + if (!DataStore._instance) { + DataStore._instance = new DataStore(); + } + return DataStore._instance; + } + + /** + * Whether the data store can be used in the current environment. + * + * @returns true if the data store is available, false otherwise + */ + isDataStoreAvailable(): boolean { + return Boolean(process.env.AWS_REGION && process.env.MOBIFY_PROPERTY_ID && process.env.DEPLOY_TARGET); + } + + /** + * Fetch an entry from the data store. + * + * @param key The data store entry's key + * @returns An object containing the entry's key and value + * @throws {DataStoreUnavailableError} The data store is unavailable + * @throws {DataStoreNotFoundError} An entry with the given key cannot be found + * @throws {DataStoreServiceError} An internal error occurred + */ + async getEntry(key: string): Promise | undefined> { + if (!this.isDataStoreAvailable()) { + throw new DataStoreUnavailableError('The data store is unavailable.'); + } + + const ddb = this.getClient(); + let response: GetCommandOutput; + try { + response = await ddb.send( + new GetCommand({ + TableName: this._tableName, + Key: { + projectEnvironment: `${process.env.MOBIFY_PROPERTY_ID} ${process.env.DEPLOY_TARGET}`, + key, + }, + }), + ); + } catch (error) { + const logFn = DataStore._testLogMRTError ?? logMRTError; + logFn('data_store', error, {key, tableName: this._tableName}); + throw new DataStoreServiceError('Data store request failed.'); + } + + if (!response.Item?.value) { + throw new DataStoreNotFoundError(`Data store entry '${key}' not found.`); + } + + return {key, value: response.Item.value}; + } +} diff --git a/packages/mrt-utilities/src/middleware/index.ts b/packages/mrt-utilities/src/middleware/index.ts new file mode 100644 index 00000000..bf0717f6 --- /dev/null +++ b/packages/mrt-utilities/src/middleware/index.ts @@ -0,0 +1,9 @@ +/* + * Copyright (c) 2025, Salesforce, Inc. + * SPDX-License-Identifier: Apache-2 + * For full license text, see the license.txt file in the repo root or http://www.apache.org/licenses/LICENSE-2.0 + */ + +export * from './data-store.js'; +export * from './middleware.js'; +export {type ProxyConfig} from '../utils/configure-proxying.js'; diff --git a/packages/mrt-utilities/src/middleware/middleware.ts b/packages/mrt-utilities/src/middleware/middleware.ts new file mode 100644 index 00000000..937baf55 --- /dev/null +++ b/packages/mrt-utilities/src/middleware/middleware.ts @@ -0,0 +1,459 @@ +/* + * Copyright (c) 2025, Salesforce, Inc. + * SPDX-License-Identifier: Apache-2 + * For full license text, see the license.txt file in the repo root or http://www.apache.org/licenses/LICENSE-2.0 + */ + +/** + * @fileoverview MRT (Managed Runtime) Middleware for Express.js applications. + * + * This module provides middleware functions for handling requests in a managed runtime environment, + * including request processing, proxy configuration, static asset serving, and local development + * utilities. It's designed to work with Salesforce Commerce Cloud's managed runtime platform. + * + * @author Salesforce Commerce Cloud + * @version 0.0.1 + */ + +import {Headers} from '../utils/ssr-proxying.js'; +import { + configureProxying, + type ProxyResult, + type ProxyConfig, + type CreateProxyMiddlewareFn, +} from '../utils/configure-proxying.js'; +import express, {type RequestHandler, type Request, type Response, type NextFunction} from 'express'; +import fs from 'fs'; +import path from 'path'; +import mimeTypes from 'mime-types'; +import qs from 'qs'; + +const MOBIFY_PATH = '/mobify'; +const PROXY_PATH_BASE = `${MOBIFY_PATH}/proxy`; +const CACHING_PATH_BASE = `${MOBIFY_PATH}/caching`; +const BUNDLE_PATH_BASE = `${MOBIFY_PATH}/bundle`; +const proxyBasePath = PROXY_PATH_BASE; +const bundleBasePath = BUNDLE_PATH_BASE; +const X_HEADERS_TO_REMOVE_ORIGIN = [ + 'x-api-key', + 'x-apigateway-event', + 'x-apigateway-context', + 'x-mobify-access-key', + 'x-sfdc-access-control', +]; +export const X_MOBIFY_REQUEST_CLASS = 'x-mobify-request-class'; +export const X_MOBIFY_QUERYSTRING = 'x-mobify-querystring'; +export const X_MOBIFY_REQUEST_PROCESSOR_LOCAL = 'x-mobify-rp-local'; +const CONTENT_TYPE = 'content-type'; +const NO_CACHE = 'max-age=0, nocache, nostore, must-revalidate'; + +/** + * Checks if a URL is for a bundle or proxy path that should be skipped by request processing. + * + * @param url - The URL to check + * @returns True if the URL starts with a proxy or bundle base path + * @private + */ +const _isBundleOrProxyPath = (url: string) => { + return url.startsWith(proxyBasePath) || url.startsWith(bundleBasePath); +}; + +/** + * Dynamically imports a request processor module if it exists. + * + * @param requestProcessorPath - The file path to the request processor module + * @returns The default export of the module, or null if the file doesn't exist + * @private + */ +const _getRequestProcessor = async (requestProcessorPath: string | undefined) => { + if (requestProcessorPath && fs.existsSync(requestProcessorPath)) { + const module = await import(requestProcessorPath); + return module; + } + return null; +}; + +/** + * Retrieves request processor parameters from environment variables with defaults. + * + * This function reads environment variables to determine the application hostname, + * deployment target, and environment. It provides sensible defaults for local development. + * + * @returns Object containing appHostname, deployTarget, and environment + * @private + */ +const getRequestProcessorParameters = (): {appHostname: string; deployTarget: string; environment: string} => { + return { + appHostname: process.env.EXTERNAL_DOMAIN_NAME || 'localhost:2401', + deployTarget: process.env.DEPLOY_TARGET || 'local-target', + environment: process.env.ENVIRONMENT || 'development', + }; +}; + +/** + * Updates the request's path and querystring, and parses the query parameters. + * + * This function updates the Express request object's originalUrl and query properties. + * It handles both cases where a querystring is present and where it's not. For Express 5 + * compatibility, it uses Object.defineProperty to update the query object since direct + * modification is no longer allowed. + * + * @param req - Express request object to update + * @param updatedPath - The new path to set + * @param updatedQuerystring - The new querystring (optional, if undefined the querystring is removed) + * @private + */ +const updatePathAndQueryString = (req: Request, updatedPath: string, updatedQuerystring: string | undefined) => { + let newQuery = {}; + if (updatedQuerystring) { + newQuery = qs.parse(updatedQuerystring); + req.originalUrl = `${updatedPath}?${updatedQuerystring}`; + } else { + req.originalUrl = updatedPath; + } + // Express 5 no longer allows direct modification of the query property + Object.defineProperty(req, 'query', { + value: {...newQuery}, + writable: true, + enumerable: true, + configurable: true, + }); +}; + +/** + * Removes internal MRT headers and API Gateway headers from the request. + * + * This function cleans up headers that should not be forwarded to downstream services. + * It removes API Gateway-specific headers and internal MRT headers. When called from + * the cleanup middleware, it also removes the X_MOBIFY_REQUEST_PROCESSOR_LOCAL header + * to indicate that cleanup has been performed. + * + * @param req - Express request object to clean up + * @param cleanupLocalRequestProcessorHeader - If true, removes X_MOBIFY_REQUEST_PROCESSOR_LOCAL header + * @private + */ +const cleanUpHeaders = (req: Request, cleanupLocalRequestProcessorHeader: boolean = false) => { + // If the cleanup is happening in the local request processor + // we don't want to remove the X_MOBIFY_REQUEST_PROCESSOR_LOCAL header + // because we need to not overwrite it in the cleanup middleware + if (cleanupLocalRequestProcessorHeader) { + delete req.headers[X_MOBIFY_REQUEST_PROCESSOR_LOCAL]; + } + X_HEADERS_TO_REMOVE_ORIGIN.forEach((key) => { + delete req.headers[key]; + }); +}; + +/** + * Retrieves and processes the querystring from the x-mobify-querystring header. + * + * This function checks for the x-mobify-querystring header and uses it as the + * definitive querystring if present and non-empty. This header is used in production + * environments to override the URL querystring, but is also handled in local development + * to allow for testing. After processing, the header is removed from the request. + * + * If the header is present but empty, or if it's not present at all, the original + * querystring is returned unchanged. + * + * @param req - Express request object containing the headers + * @param originalQuerystring - The original querystring from the URL (may be undefined) + * @returns The querystring to use (from header if present and non-empty, otherwise original) + * @private + */ +const getMobifyQueryString = (req: Request, originalQuerystring: string | undefined) => { + // If there's an x-querystring header, use that as the definitive + // querystring. This header is used in production, not in local dev, + // but we always handle it here to allow for testing. + let updatedQuerystring = originalQuerystring; + const xQueryString = req.headers[X_MOBIFY_QUERYSTRING]; + if (xQueryString && xQueryString !== '') { + updatedQuerystring = xQueryString as string; + } + delete req.headers[X_MOBIFY_QUERYSTRING]; + return updatedQuerystring; +}; + +/** + * Creates a middleware function that processes incoming requests using a custom request processor. + * + * This middleware handles: + * - Skipping processing for proxy and bundle paths + * - Loading and executing custom request processors + * - Processing custom query strings from headers + * - Removing API Gateway headers + * - Enforcing HTTP method restrictions for root path + * - Updating request paths and query strings when paths change + * + * @param requestProcessorPath - Path to the request processor module file + * @param proxyConfigs - Array of proxy configurations + * @returns Express middleware function + * + * @example + * ```typescript + * const middleware = createMRTRequestProcessorMiddleware( + * '/path/to/processor.js', + * [{ host: 'https://api.example.com', path: 'api' }] + * ); + * app.use(middleware); + * ``` + */ +export const createMRTRequestProcessorMiddleware = ( + requestProcessorPath: string | undefined, + proxyConfigs: ProxyConfig[] | undefined, +): RequestHandler => { + const processIncomingRequest = async (req: Request, res: Response) => { + // If the request is for a proxy or bundle path, do nothing + if (_isBundleOrProxyPath(req.originalUrl)) { + return; + } + + const requestProcessor = await _getRequestProcessor(requestProcessorPath); + const originalQuerystring = req.originalUrl.split('?')[1]; + + // If there's no querystring the value will be undefined + // but TypeScript will complain if we don't explicitly set it to undefined. + let updatedQuerystring = originalQuerystring || undefined; + let updatedPath = req.originalUrl.split('?')[0]; + + updatedQuerystring = getMobifyQueryString(req, updatedQuerystring); + if (requestProcessor) { + // Allow the processor to handle this request. Because this code + // runs only in the local development server, we intentionally do + // not swallow errors - we want them to happen and show up on the + // console because that's how developers can test the processor. + const headers = new Headers(req.headers, 'http'); + + const {appHostname, deployTarget, environment} = getRequestProcessorParameters(); + + const processed = requestProcessor.processRequest({ + headers, + path: req.path, + querystring: updatedQuerystring, + + getRequestClass: () => headers.getHeader(X_MOBIFY_REQUEST_CLASS), + setRequestClass: (value: string) => headers.setHeader(X_MOBIFY_REQUEST_CLASS, value), + + // This matches the set of parameters passed in the + // Lambda@Edge context. + parameters: { + deployTarget, + appHostname, + proxyConfigs: proxyConfigs || [], + environment, + }, + }); + + // Aid debugging by checking the return value + console.assert( + processed && 'path' in processed && 'querystring' in processed, + 'Expected processRequest to return an object with ' + + '"path" and "querystring" properties, ' + + `but got ${JSON.stringify(processed, null, 2)}`, + ); + + // Update the request. + updatedQuerystring = processed.querystring; + updatedPath = processed.path; + + if (headers.modified) { + req.headers = headers.toObject() as Record; + } + } + + // Update the request. + if (updatedQuerystring !== originalQuerystring) { + updatePathAndQueryString(req, updatedPath, updatedQuerystring); + } + + // Get the request class and store it for general use. We + // must do this AFTER the request-processor, because that's + // what may set the request class. + res.locals.requestClass = req.headers[X_MOBIFY_REQUEST_CLASS]; + req.headers[X_MOBIFY_REQUEST_PROCESSOR_LOCAL] = 'true'; // Mark the request as processed by the request processor + }; + + const ssrRequestProcessorMiddleware = async (req: Request, res: Response, next: NextFunction) => { + // If the path is /, we enforce that the only methods + // allowed are GET, HEAD or OPTIONS. This is a restriction + // imposed by API Gateway: we enforce it here so that the + // local dev server has the same behaviour. + if (req.path === '/' && !['GET', 'HEAD', 'OPTIONS'].includes(req.method)) { + res.sendStatus(405); + return; + } + + // Apply custom query parameter parsing. + await processIncomingRequest(req, res); + + // Strip out API Gateway headers from the incoming request. We + // do that now so that the rest of the code don't have to deal + // with these headers, which can be large and may be accidentally + // forwarded to other servers. + cleanUpHeaders(req, false); + + // Hand off to the next middleware + next(); + }; + + return ssrRequestProcessorMiddleware; +}; + +/** + * Creates proxy middleware functions for the specified proxy configurations. + * + * This function creates Express middleware functions that handle proxying requests + * to external services. It can optionally create both regular proxy and caching + * proxy middlewares for each configuration. The app hostname is automatically + * retrieved from environment variables (EXTERNAL_DOMAIN_NAME or defaults to 'localhost:2401'). + * + * @param proxyConfigs - Array of proxy configurations + * @param appProtocol - The protocol to use for the app (defaults to 'http') + * @param includeCaching - Whether to include caching proxy middlewares (defaults to false) + * @returns Array of proxy middleware results with their paths + * + * @example + * ```typescript + * const proxyMiddlewares = createMRTProxyMiddlewares( + * [{ host: 'https://api.example.com', path: 'api' }], + * 'https', + * true // Include caching middlewares + * ); + * + * proxyMiddlewares.forEach(({ fn, path }) => { + * app.use(path, fn); + * }); + * ``` + */ +export const createMRTProxyMiddlewares = ( + proxyConfigs: ProxyConfig[], + appProtocol: string = 'http', + includeCaching: boolean = false, + createProxyFn?: CreateProxyMiddlewareFn, +): ProxyResult[] => { + if (!proxyConfigs) { + return []; + } + const {appHostname} = getRequestProcessorParameters(); + const proxies: ProxyResult[] = configureProxying(proxyConfigs, appHostname, appProtocol, createProxyFn); + const middlewares: ProxyResult[] = []; + proxies.forEach((proxy) => { + const proxyPath = `${PROXY_PATH_BASE}/${proxy.path}`; + const cachingProxyPath = `${CACHING_PATH_BASE}/${proxy.path}`; + middlewares.push({fn: proxy.fn, path: proxyPath}); + if (includeCaching) { + middlewares.push({fn: proxy.fn, path: cachingProxyPath}); + } + }); + return middlewares; +}; + +/** + * Sets appropriate HTTP headers for local asset files. + * + * This function sets content-type, caching, and other headers for static assets + * served from the local filesystem. It uses the file's modification time for + * ETag and Last-Modified headers, and sets no-cache directives for local assets. + * + * @param res - Express response object + * @param assetPath - Path to the asset file + * + * @example + * ```typescript + * app.use('/static', express.static('public', { + * setHeaders: setLocalAssetHeaders + * })); + * ``` + */ +export const setLocalAssetHeaders = (res: Response, assetPath: string) => { + const base = path.basename(assetPath); + const contentType = mimeTypes.lookup(base) || 'application/octet-stream'; + + res.set(CONTENT_TYPE, contentType); + + // Stat the file and return the last-modified Date + // in RFC1123 format. Also use that value as the ETag + // and Last-Modified + const mtime = fs.statSync(assetPath).mtime; + const mtimeRFC1123 = mtime.toUTCString(); + res.set('date', mtimeRFC1123); + res.set('last-modified', mtimeRFC1123); + res.set('etag', mtime.getTime().toString()); + + // We don't cache local bundle assets + res.set('cache-control', NO_CACHE); +}; + +/** + * Creates an Express static middleware configured for MRT asset serving. + * + * This function creates a static file serving middleware with MRT-specific + * configurations including custom header setting and security options. + * + * @param staticAssetDir - Directory path containing static assets + * @returns Express static middleware function + * + * @example + * ```typescript + * const staticMiddleware = createMRTStaticAssetServingMiddleware('/path/to/assets'); + * app.use('/static', staticMiddleware); + * ``` + */ +export const createMRTStaticAssetServingMiddleware = (staticAssetDir: string): RequestHandler => { + return express.static(staticAssetDir, { + dotfiles: 'deny', + setHeaders: setLocalAssetHeaders, + fallthrough: true, + }); +}; + +/** + * Creates a common middleware function that sets the host header based on environment variables. + * + * The host header is set to EXTERNAL_DOMAIN_NAME if available, otherwise defaults to 'localhost:2401'. + * + * @returns Express middleware function + * + * @example + * ```typescript + * const middleware = createMRTCommonMiddleware(); + * app.use(middleware); + * ``` + */ +export const createMRTCommonMiddleware = (): RequestHandler => { + return (req: Request, res: Response, next: NextFunction) => { + req.headers.host = process.env.EXTERNAL_DOMAIN_NAME || 'localhost:2401'; + next(); + }; +}; + +/** + * Creates a cleanup middleware function that removes internal headers and cleans up request state. + * + * This middleware performs cleanup operations on requests: + * - Removes internal MRT headers (X_MOBIFY_REQUEST_PROCESSOR_LOCAL, X_MOBIFY_QUERYSTRING) + * - Removes API Gateway headers that shouldn't be forwarded + * - Optionally updates the path and querystring if the request wasn't processed by the request processor + * + * This middleware should typically be used at the end of the middleware chain to ensure + * all internal headers are removed before the request is handled by the application. + * + * @returns Express middleware function + * + * @example + * ```typescript + * const cleanupMiddleware = createMRTCleanUpMiddleware(); + * app.use(cleanupMiddleware); + * ``` + */ +export const createMRTCleanUpMiddleware = (): RequestHandler => { + return (req: Request, res: Response, next: NextFunction) => { + if (!req.headers[X_MOBIFY_REQUEST_PROCESSOR_LOCAL]) { + const originalQuerystring = req.originalUrl.split('?')[1] || undefined; + const updatedQuerystring = getMobifyQueryString(req, originalQuerystring); + const updatedPath = req.originalUrl.split('?')[0]; + updatePathAndQueryString(req, updatedPath, updatedQuerystring); + } + cleanUpHeaders(req, true); + next(); + }; +}; diff --git a/packages/mrt-utilities/src/streaming/create-lambda-adapter.ts b/packages/mrt-utilities/src/streaming/create-lambda-adapter.ts new file mode 100644 index 00000000..0381b32e --- /dev/null +++ b/packages/mrt-utilities/src/streaming/create-lambda-adapter.ts @@ -0,0 +1,969 @@ +/* + * Copyright (c) 2025, Salesforce, Inc. + * SPDX-License-Identifier: Apache-2 + * For full license text, see the license.txt file in the repo root or http://www.apache.org/licenses/LICENSE-2.0 + */ + +import {ServerResponse} from 'http'; +import {pipeline} from 'node:stream/promises'; +import zlib, { + type BrotliCompress, + type Gzip, + type Deflate, + type ZstdCompress, + type ZlibOptions, + type BrotliOptions, + type ZstdOptions, +} from 'node:zlib'; +import Negotiator from 'negotiator'; +import compressible from 'compressible'; +import type {APIGatewayProxyEvent, Context} from 'aws-lambda'; +import type {Express, Request, Response} from 'express'; +import type {Writable} from 'stream'; +import {ServerlessRequest} from '@h4ad/serverless-adapter'; + +/** + * Header keys to copy from the request to the response. + * These headers are typically used for tracing, correlation, or other request/response matching purposes. + */ +const REQUEST_HEADERS_TO_COPY = ['x-correlation-id'] as const; + +// Check if zstd compression is available (Node.js v22.15.0+) +let createZstdCompress: ((options?: ZstdOptions) => ZstdCompress) | undefined; +try { + // Try to import createZstdCompress - it may not exist in older Node.js versions + if (typeof zlib.createZstdCompress === 'function') { + createZstdCompress = zlib.createZstdCompress; + } +} catch { + // zstd not available +} + +// Declare global awslambda type for AWS Lambda runtime +declare const awslambda: { + HttpResponseStream: { + from( + stream: Writable, + metadata: { + statusCode: number; + headers: Record; + }, + ): Writable; + }; +}; + +interface ExpressRequest extends Request { + apiGateway?: { + event: APIGatewayProxyEvent; + context: Context; + }; +} + +interface ExpressResponse extends Response { + flushable?: boolean; +} + +interface StreamMetadata { + statusCode: number; + headers: Record; + multiValueHeaders?: Record; + cookies?: string[]; +} + +type CompressionStream = BrotliCompress | Gzip | Deflate | ZstdCompress; + +/** + * Configuration options for response compression. + * + * @property enabled - Whether compression is enabled. Set to false to disable compression entirely. + * Defaults to true (compression enabled). + * @property encoding - The compression encoding to use ('br', 'zstd', 'gzip', 'deflate'). + * If not specified, the best encoding will be negotiated based on Accept-Encoding header. + * @property options - Compression library options. This can include any of the options accepted by + * zlib, Brotli, or Zstd, as defined in the zlib library, and will be passed + * directly to the corresponding compression library. + */ +export interface CompressionConfig { + enabled: boolean; + encoding?: 'br' | 'zstd' | 'gzip' | 'deflate'; + options?: ZlibOptions | BrotliOptions | ZstdOptions; +} + +type AsyncHandlerFunction = (event: APIGatewayProxyEvent, context: Context) => Promise; + +/** + * Creates a Lambda Adapter that wraps an Express app and supports response streaming + * for API Gateway v1 proxy integration using AWS Lambda response streaming + * + * @param app - Express application instance + * @param responseStream - AWS Lambda response stream + * @param compressionConfig - Optional compression configuration + * @returns Lambda handler function + */ +export function createStreamingLambdaAdapter( + app: Express, + responseStream: Writable, + compressionConfig: CompressionConfig = {enabled: true}, +): AsyncHandlerFunction { + const handler = async (event: APIGatewayProxyEvent, context: Context): Promise => { + try { + await streamResponse(event, responseStream, context, app, compressionConfig); + } catch (error) { + console.error('Error in streaming handler:', error); + const isStreamOpen = + responseStream && responseStream.writable && !responseStream.destroyed && !responseStream.writableEnded; + + if (isStreamOpen && typeof responseStream.write === 'function') { + const errorMessage = error instanceof Error ? error.message : String(error); + responseStream.write(`HTTP/1.1 500 Internal Server Error\r\n\r\nInternal Server Error: ${errorMessage}`); + } else { + console.error('[error handler] Cannot write error - stream is closed'); + } + } finally { + const isStreamOpen = + responseStream && responseStream.writable && !responseStream.destroyed && !responseStream.writableEnded; + if (isStreamOpen && typeof responseStream.end === 'function') { + responseStream.end(); + } + } + }; + + return handler; +} + +/** + * Streams the response from Express app using AWS Lambda HttpResponseStream + */ +async function streamResponse( + event: APIGatewayProxyEvent, + responseStream: Writable, + context: Context, + app: Express, + compressionConfig?: CompressionConfig, +): Promise { + // Convert API Gateway event to Express-compatible request + const expressRequest = createExpressRequest(event, context); + + const expressResponse = createExpressResponse(responseStream, event, context, expressRequest, compressionConfig); + + // Process the request through Express app + return new Promise((resolve, reject) => { + let resolved = false; + const resolveOnce = () => { + if (!resolved) { + resolved = true; + resolve(); + } + }; + + const rejectOnce = (err: Error) => { + if (!resolved) { + resolved = true; + reject(err); + } + }; + + // Handle response finish + expressResponse.once('finish', () => { + resolveOnce(); + }); + + // Handle response errors + expressResponse.once('error', (err: Error) => { + rejectOnce(err); + }); + + try { + app(expressRequest, expressResponse, (err) => { + if (err) { + console.error('Express app error:', err); + rejectOnce(err); + } else { + // If response has finished, resolveOnce will be called by the finish event + // Otherwise, resolve after a short delay to allow async operations + if (expressResponse.finished) { + resolveOnce(); + } else { + // Wait a bit for the response to finish + setTimeout(() => { + resolveOnce(); + }, 10); + } + } + }); + } catch (error) { + console.error('Error in streamResponse:', error); + rejectOnce(error as Error); + } + }); +} + +/** + * Builds a full URL path with query string from API Gateway event + * Merges multiValueQueryStringParameters and queryStringParameters + */ +const getPathFromEvent = (event: APIGatewayProxyEvent): string => { + const path = event.path; + + // Start with multi-value query parameters (already arrays), filtering out undefined values + const mergedParams: Record = {}; + if (event.multiValueQueryStringParameters) { + for (const [key, values] of Object.entries(event.multiValueQueryStringParameters)) { + if (values) { + mergedParams[key] = [...values]; + } + } + } + + // Merge in single-value query parameters, converting to arrays + if (event.queryStringParameters) { + for (const [key, value] of Object.entries(event.queryStringParameters)) { + if (value === undefined) continue; + + // Add to existing array or create new one + if (mergedParams[key]) { + if (!mergedParams[key].includes(value)) { + mergedParams[key].push(value); + } + } else { + mergedParams[key] = [value]; + } + } + } + + // Build query string + const searchParams = new URLSearchParams(); + for (const [key, values] of Object.entries(mergedParams)) { + for (const value of values) { + searchParams.append(key, value); + } + } + + const queryString = searchParams.toString(); + return queryString ? `${path}?${queryString}` : path; +}; + +/** + * Converts API Gateway event to Express-compatible request object + * Creates a proper IncomingMessage-like object with stream properties + */ +// eslint-disable-next-line @typescript-eslint/no-unused-vars +export function createExpressRequest(event: APIGatewayProxyEvent, context: Context): ExpressRequest { + const {httpMethod, headers, multiValueHeaders, body, isBase64Encoded, requestContext} = event; + + const remoteAddress = requestContext?.identity?.sourceIp ?? undefined; + + const bodyEncoding = isBase64Encoded ? 'base64' : 'utf-8'; + const requestBody: Buffer | undefined = body ? Buffer.from(body, bodyEncoding) : undefined; + + // Normalize headers to lowercase keys for case-insensitive lookup + const normalizedHeaders: Record = {}; + if (headers) { + for (const [key, value] of Object.entries(headers)) { + const normalizedKey = key.toLowerCase(); + // If value is an array, take the first one; otherwise use the value + if (value === undefined) continue; + normalizedHeaders[normalizedKey] = value; + } + } + for (const multiValueHeaderKey of Object.keys(multiValueHeaders || {})) { + const value = multiValueHeaders[multiValueHeaderKey]; + if (!value || value.length <= 1) continue; + normalizedHeaders[multiValueHeaderKey] = value.join(','); + } + + const request = new ServerlessRequest({ + method: httpMethod, + url: getPathFromEvent(event), + headers: normalizedHeaders, + body: requestBody, + remoteAddress, + }); + + // Add Express-specific properties that aren't part of IncomingMessage + // IncomingMessage doesn't have query, params, etc. - these are added by Express + const req = request as unknown as ExpressRequest; + + // Express-like methods + Object.defineProperty(req, 'get', { + value(this: ExpressRequest, headerName: string): string | string[] | undefined { + return this.headers[headerName.toLowerCase()]; + }, + writable: true, + enumerable: true, + configurable: true, + }); + + Object.defineProperty(req, 'header', { + value(this: ExpressRequest, headerName: string): string | string[] | undefined { + return this.get(headerName); + }, + writable: true, + enumerable: true, + configurable: true, + }); + + return req; +} + +/** + * Checks if a content type is compressible using the compressible package + * + * @param contentType - The content type to check (e.g., 'text/html', 'application/json') + * @returns true if the content type is compressible, false otherwise + */ +function isCompressible(contentType: string | undefined): boolean { + if (!contentType) { + return false; + } + + return !!compressible(contentType); +} + +const isNullOrUndefined = (value: unknown): boolean => value == null; + +/** + * Determines the best encoding based on Accept-Encoding header using the negotiator package + * Prefers encodings in order: br (brotli), zstd (if available), gzip, deflate + * + * @param acceptEncoding - The Accept-Encoding header value from the request + * @param compressionConfig - Optional compression configuration + * @returns The best available encoding or null if none are supported + */ +function getBestEncoding( + acceptEncoding: string | string[] | undefined, + compressionConfig?: CompressionConfig, +): string | null { + // If compression is explicitly disabled, return null + if (compressionConfig?.enabled === false) { + return null; + } + + // If override encoding is provided, use it regardless of Accept-Encoding header + if (compressionConfig?.encoding) { + return compressionConfig.encoding; + } + if (!acceptEncoding) { + return null; + } + const negotiator = new Negotiator({headers: {'accept-encoding': acceptEncoding}}); + + // Build available encodings list based on what's supported + // Order of preference: br (brotli), zstd (if available), gzip, deflate + const availableEncodings: string[] = ['br', 'gzip', 'deflate']; + if (createZstdCompress) { + availableEncodings.push('zstd'); + } + + const bestEncoding = negotiator.encoding(availableEncodings); + + return bestEncoding || null; +} + +/** + * Creates a compression stream based on the encoding type + * + * @param encoding - The encoding type ('br', 'zstd', 'gzip', or 'deflate') + * @param compressionConfig - The compression configuration options + * @returns A compression stream (BrotliCompress, ZstdCompress, Gzip, or Deflate) + * @throws Error if the encoding is not supported + */ +function createCompressionStream(encoding: string, compressionConfig?: CompressionConfig): CompressionStream { + const options = compressionConfig?.options || undefined; + switch (encoding) { + case 'br': + return zlib.createBrotliCompress(options as BrotliOptions); + case 'zstd': + if (!createZstdCompress) { + throw new Error('zstd compression is not available in this Node.js version (requires v22.15.0+)'); + } + return createZstdCompress(options as ZstdOptions); + case 'gzip': + return zlib.createGzip(options as ZlibOptions); + case 'deflate': + return zlib.createDeflate(options as ZlibOptions); + default: + throw new Error(`Unsupported encoding: ${encoding}`); + } +} + +/** + * Creates Express-compatible response object that properly extends ServerResponse + * + * This function creates a response object that: + * - Supports AWS Lambda response streaming via HttpResponseStream + * - Automatically compresses responses based on Accept-Encoding header + * - Uses negotiator to select the best available encoding (br, zstd if available, gzip, deflate) + * - Uses compressible package to determine if content should be compressed + * + * Compression flow: + * 1. Check Accept-Encoding header and select best encoding + * 2. Create HttpResponseStream with metadata + * 3. If compression is applicable, create compression stream and pipe to httpResponseStream + * 4. Write data to compression stream (or httpResponseStream if no compression) + * 5. End compression stream, which automatically ends httpResponseStream + * + * @param responseStream - The AWS Lambda response stream + * @param method - The HTTP method (GET, POST, etc.) + * @param request - Optional Express request object (used to check Accept-Encoding header) + * @returns Express-compatible response object + */ +export function createExpressResponse( + responseStream: Writable, + event: APIGatewayProxyEvent, + context: Context, + request?: ExpressRequest, + compressionConfig?: CompressionConfig, +): ExpressResponse { + const method = event.httpMethod; + let statusCode = 200; + let statusMessage: string | undefined = undefined; + const headers: Record = {}; + let responseStarted = false; + let httpResponseStream: Writable | null = null; + + // Determine if compression should be used based on Accept-Encoding header + const acceptEncoding = request?.get('accept-encoding') || 'identity'; + const selectedEncoding = getBestEncoding(acceptEncoding, compressionConfig) || 'identity'; + let compressionStream: CompressionStream | null = null; + let shouldCompress = false; + let compressionInitialized = false; + + // Helper function to check if stream is still writable + const isStreamOpen = (): boolean => { + const streamToCheck = compressionStream || httpResponseStream || responseStream; + return streamToCheck && streamToCheck.writable && !streamToCheck.destroyed && !streamToCheck.writableEnded; + }; + + /** + * Initializes compression stream and pipes it to httpResponseStream + * This must be called after httpResponseStream is created + * + * @param httpResponseStream - The HttpResponseStream to pipe compressed data to + * @param selectedEncoding - The encoding to use (br, gzip, or deflate) + */ + const initializeCompression = (): void => { + if (!httpResponseStream || compressionInitialized || !selectedEncoding) { + return; + } + + try { + // Create compression stream based on selected encoding + compressionStream = createCompressionStream(selectedEncoding, compressionConfig); + + // Set up error handling for compression stream + compressionStream.on('error', (error: Error) => { + console.error('Compression stream error:', error); + shouldCompress = false; + }); + + // Pipe compression stream to httpResponseStream + // The { end: true } option ensures httpResponseStream is ended when compressionStream ends + compressionStream.pipe(httpResponseStream, {end: true}); + + shouldCompress = true; + compressionInitialized = true; + } catch (error) { + console.error('Error setting up compression:', error); + shouldCompress = false; + compressionStream = null; + } + }; + + /** + * Writes a chunk to the appropriate stream (compression stream if enabled, otherwise httpResponseStream) + * + * @param chunk - The data chunk to write + * @returns true if the chunk was written successfully, false otherwise + */ + const writeChunk = (chunk?: string | Buffer | Uint8Array): boolean => { + // Don't write null, undefined + if (isNullOrUndefined(chunk) || !isStreamOpen()) { + return false; + } + + try { + if (shouldCompress && compressionStream && compressionStream.writable) { + // Write to compression stream, which will compress and pipe to httpResponseStream + return compressionStream.write(chunk); + } else if (httpResponseStream && httpResponseStream.writable) { + // No compression, write directly to httpResponseStream + return httpResponseStream.write(chunk); + } + return false; + } catch (error) { + console.error('Error writing chunk:', error); + return false; + } + }; + + const isCompressionEnabled = (contentTypeStr: string | undefined): boolean => { + const enabled = compressionConfig?.enabled ?? true; + return !!(selectedEncoding && selectedEncoding !== 'identity' && isCompressible(contentTypeStr) && enabled); + }; + + const getContentType = (response: ExpressResponse): string | undefined => { + const contentType = response.getHeader('content-type'); + if (Array.isArray(contentType)) { + return contentType.join(','); + } else if (typeof contentType === 'number') { + return String(contentType); + } + return contentType; + }; + + /** + * Initializes the response by: + * 1. Collecting headers + * 2. Determining if compression should be used + * 3. Creating HttpResponseStream with metadata + * 4. Setting up compression stream if needed + * + * This must be called before any data is written to the response. + * + * @param response - The Express response object + */ + const initializeResponse = (response: ExpressResponse): void => { + if (responseStarted) { + return; + } + + if (!isStreamOpen()) { + console.error('Cannot initialize response - stream is closed'); + return; + } + + // Collect all current headers from the response + const currentHeaders = response.getHeaders(); + Object.assign(headers, currentHeaders); + + for (const header of REQUEST_HEADERS_TO_COPY) { + const value = request?.get(header); + if (value) { + headers[header] = value; + } + } + + const contentType = getContentType(response); + + if (isCompressionEnabled(contentType)) { + headers['content-encoding'] = selectedEncoding; + response.setHeader('Content-Encoding', selectedEncoding); + } + // Remove Content-Length header when compression is enabled since the length will change + delete headers['content-length']; + response.removeHeader('content-length'); + + // Create HttpResponseStream with metadata + // This writes the HTTP status and headers to the stream + const metadata: StreamMetadata = { + statusCode, + headers, + }; + + const cookies = metadata.headers['set-cookie']; + if (cookies) { + metadata.cookies = Array.isArray(cookies) ? cookies : [cookies]; + delete metadata.headers['set-cookie']; + } + metadata.headers = convertHeaders(metadata.headers); + + httpResponseStream = awslambda.HttpResponseStream.from(responseStream, metadata); + + // Set up compression stream if compression is enabled + // The compression stream pipes to httpResponseStream, which pipes to responseStream + // 'identity' means no encoding, so we should not initialize compression for it + if (isCompressionEnabled(contentType)) { + initializeCompression(); + } + + responseStarted = true; + }; + + // Helper function to convert headers to the expected format + const convertHeaders = ( + headersToConvert: Record, + ): Record => { + const converted: Record = {}; + for (const [key, value] of Object.entries(headersToConvert)) { + if (value !== undefined) { + if (Array.isArray(value)) { + converted[key] = value.join(','); + } else if (typeof value === 'number') { + converted[key] = String(value); + } else { + converted[key] = value; + } + } + } + return converted; + }; + + /** + * Pipes data from the compression stream (if enabled) or httpResponseStream to a destination + * Note: This is a simplified implementation for API compatibility + * + * @param destination - The destination stream to pipe to + * @returns true if the pipe operation was successful, false otherwise + */ + const pipeToDestination = async (destination: Writable): Promise => { + if (!isStreamOpen()) { + console.error('[pipeToDestination] Cannot pipe - stream is closed'); + return false; + } + + // Note: pipeToDestination is called from res.pipe() which already calls initializeResponse + // So compression should already be initialized if needed + try { + // Pipe from compression stream if available, otherwise from httpResponseStream + const sourceStream = compressionStream || httpResponseStream; + if (!sourceStream) { + console.error('[pipeToDestination] No source stream available'); + return false; + } + + // @ts-expect-error - Pipeline expects Readable, but compression streams are Transform streams + await pipeline(sourceStream, destination); + return true; + } catch (error) { + console.error('[pipeToDestination] Pipeline error:', error); + return false; + } + }; + + // @ts-expect-error - ServerResponse constructor expects IncomingMessage, but we're creating a minimal mock + const res = new ServerResponse({ + method, + }) as ExpressResponse; + + // Override statusMessage property to track custom status messages + Object.defineProperty(res, 'statusMessage', { + get() { + return statusMessage; + }, + set(value: string) { + statusMessage = value; + }, + enumerable: true, + configurable: true, + }); + + // Override headersSent property to track when headers are sent + // This is readonly in the parent class, so we override it to be writable + Object.defineProperty(res, 'headersSent', { + get() { + return responseStarted; + }, + enumerable: true, + configurable: true, + }); + + // Override the core streaming methods to work with AWS Lambda Response Streaming + // @ts-expect-error - Type signature doesn't match ServerResponse.writeHead exactly, but our implementation is compatible + res.writeHead = function ( + code: number, + reasonPhrase?: string | Record, + headerObj?: Record, + ) { + if (typeof reasonPhrase === 'object') { + headerObj = reasonPhrase; + reasonPhrase = undefined; + } + + statusCode = code || statusCode; + this.statusCode = statusCode; + + // Set statusMessage if provided + if (reasonPhrase) { + statusMessage = reasonPhrase; + } + + if (headerObj) { + Object.assign(headers, headerObj); + for (const [key, value] of Object.entries(headerObj)) { + if (value !== undefined) { + this.setHeader(key, value); + } + } + } + + // Collect all current headers + const currentHeaders = this.getHeaders(); + Object.assign(headers, currentHeaders); + + initializeResponse(this); + + return this; + }; + + res.write = function (chunk: string | Buffer | Uint8Array): boolean { + if (!isStreamOpen()) { + console.error(`Cannot write - stream is closed`); + return false; + } + + initializeResponse(this); + + if (!isNullOrUndefined(chunk)) { + return writeChunk(chunk); + } + return true; // ServerResponse.write returns boolean + }; + + const _flush = () => { + if ( + shouldCompress && + compressionStream && + compressionStream.writable && + typeof compressionStream.flush === 'function' + ) { + compressionStream.flush(); + } else if ( + httpResponseStream && + httpResponseStream.writable && + // @ts-expect-error - flush doesn't exist on Writable, but we're adding it + typeof httpResponseStream.flush === 'function' + ) { + // @ts-expect-error - flush doesn't exist on Writable, but we're adding it + httpResponseStream.flush(); + } + }; + + /** + * Ends the appropriate stream(s) and emits the finish event + * If compression is enabled, ends the compression stream which will automatically + * end httpResponseStream due to the pipe with { end: true } + * + * @param response - The Express response object to emit finish event on + */ + const endStream = (response: ExpressResponse): void => { + if (shouldCompress && compressionStream) { + try { + // Flush compression stream to ensure all buffered data is written + _flush(); + // End compression stream - this will automatically end httpResponseStream + // due to the pipe with { end: true } option + compressionStream.end(() => { + response.finished = true; + response.emit('finish'); + }); + } catch (error) { + console.error(`Error ending compression stream:`, error); + // Still emit finish even if there was an error + response.finished = true; + response.emit('finish'); + } + } else if (httpResponseStream && httpResponseStream.writable) { + // No compression, end httpResponseStream directly + try { + _flush(); + httpResponseStream.end(() => { + response.finished = true; + response.emit('finish'); + }); + } catch (error) { + console.error(`Error ending httpResponseStream:`, error); + response.finished = true; + response.emit('finish'); + } + } else { + console.error(`Cannot call end() - stream is closed`); + // Still emit finish to prevent hanging + response.finished = true; + response.emit('finish'); + } + }; + + // @ts-expect-error - Type signature doesn't match ServerResponse.end exactly, but our implementation is compatible + res.end = function (chunk?: string | Buffer | Uint8Array) { + if (!isStreamOpen()) { + console.error(`Cannot end - stream is already closed`); + return this; + } + + initializeResponse(this); + + // Chunks can be falsy ('', 0, etc.) but not null or undefined + if (!isNullOrUndefined(chunk)) { + const result = writeChunk(chunk); + if (!result) { + // Backpressure - wait for drain event before ending + const streamToWait = compressionStream || httpResponseStream; + if (streamToWait) { + streamToWait.once('drain', () => { + endStream(this); + }); + } else { + endStream(this); + } + return this; + } + } + + // End the stream(s) and emit finish event + endStream(this); + return this; + }; + + // Add Express-specific methods that aren't in ServerResponse + res.status = function (code: number, message?: string) { + this.statusCode = code; + statusCode = code; + if (message !== undefined) { + statusMessage = message; + } + return this; + }; + + res.set = function (field: string | Record, value?: string | string[]) { + if (typeof field === 'object') { + for (const [key, val] of Object.entries(field)) { + if (val !== undefined) { + this.setHeader(key, val); + } + } + } else { + if (value !== undefined) { + this.setHeader(field, value); + } + } + return this; + }; + + // @ts-expect-error - Type signature doesn't match ExpressResponse.append exactly, but our implementation is compatible + res.append = function (field: string, value: string | string[]): this { + const prevValue = this.getHeader(field); + + if (prevValue) { + // If header already exists, append the value + if (Array.isArray(prevValue)) { + this.setHeader(field, prevValue.concat(value as string[])); + } else if (Array.isArray(value)) { + this.setHeader(field, [prevValue as string].concat(value)); + } else { + this.setHeader(field, [prevValue as string, value]); + } + } else { + // If header doesn't exist, just set it + this.setHeader(field, value); + } + + return this; + }; + + res.flushHeaders = function () { + if (!responseStarted) { + if (!isStreamOpen()) { + console.error('[res.flushHeaders] Cannot flush headers - stream is closed'); + return this; + } + + // Collect all current headers and send them + // getHeaders() returns all headers from ServerResponse, including those set via set()/setHeader() + // This is the source of truth for all headers + const currentHeaders = this.getHeaders(); + // Merge with local headers variable to include any headers set via writeHead + // currentHeaders takes precedence as it's the authoritative source from ServerResponse + Object.assign(headers, currentHeaders); + } + + initializeResponse(this); + + return this; + }; + + res.json = function (obj: unknown) { + res.setHeader('Content-Type', 'application/json'); + this.end(JSON.stringify(obj)); + return this; + }; + + res.send = function (body: string | object) { + if (typeof body === 'object' && body !== null) { + return this.json(body); + } + // Convert non-string values to string + const bodyString = typeof body === 'string' ? body : String(body); + this.end(bodyString); + return this; + }; + + // @ts-expect-error - Type signature doesn't match ExpressResponse.redirect exactly, but our implementation is compatible + res.redirect = function (url: string) { + this.status(302); + this.setHeader('Location', url); + this.end(); + return this; + }; + + // Add flush method for streaming responses (important for RSC) + // @ts-expect-error - flush doesn't exist on ExpressResponse type, but we're adding it + res.flush = function () { + if (!isStreamOpen()) { + console.error(`Cannot flush - stream is closed`); + return this; + } + + initializeResponse(this); + + // Flush the compression stream if it exists and supports it + // This ensures any buffered compressed data is written immediately + try { + _flush(); + } catch (error) { + console.error(`Error flushing:`, error); + } + + return this; + }; + + // Track piped destinations for unpipe support + const pipedDestinations = new Set(); + + // Add pipe method for streaming responses (commonly used in Express) + // @ts-expect-error - Type signature doesn't match ExpressResponse.pipe exactly, but our implementation is compatible + // eslint-disable-next-line @typescript-eslint/no-unused-vars + res.pipe = function (destination: Writable, options?: {end?: boolean}) { + if (!isStreamOpen()) { + console.error('[res.pipe] Cannot pipe - stream is closed'); + return destination; + } + + initializeResponse(this); + + // Track the destination for unpipe support + pipedDestinations.add(destination); + + // Use actual Node.js pipeline for pipe operations + pipeToDestination(destination) + .then(() => { + pipedDestinations.delete(destination); + }) + .catch((error) => { + console.error('[res.pipe] Pipeline error:', error); + pipedDestinations.delete(destination); + }); + + return destination; + }; + + // Add unpipe method to remove pipe destinations + // @ts-expect-error - unpipe doesn't exist on ExpressResponse type, but we're adding it + res.unpipe = function (destination?: Writable) { + if (destination) { + pipedDestinations.delete(destination); + // In a real implementation, you'd need to handle unpipe more carefully + // For now, we just track it + } else { + // Unpipe all destinations + pipedDestinations.clear(); + } + + return this; + }; + + // Make response flushable (flag used by some frameworks) + res.flushable = true; + + return res; +} diff --git a/packages/mrt-utilities/src/streaming/index.ts b/packages/mrt-utilities/src/streaming/index.ts new file mode 100644 index 00000000..5979a461 --- /dev/null +++ b/packages/mrt-utilities/src/streaming/index.ts @@ -0,0 +1,13 @@ +/* + * Copyright (c) 2025, Salesforce, Inc. + * SPDX-License-Identifier: Apache-2 + * For full license text, see the license.txt file in the repo root or http://www.apache.org/licenses/LICENSE-2.0 + */ + +export const createNullStream = () => {}; +export { + type CompressionConfig, + createExpressRequest, + createExpressResponse, + createStreamingLambdaAdapter, +} from './create-lambda-adapter.js'; diff --git a/packages/mrt-utilities/src/utils/configure-proxying.ts b/packages/mrt-utilities/src/utils/configure-proxying.ts new file mode 100644 index 00000000..aed69637 --- /dev/null +++ b/packages/mrt-utilities/src/utils/configure-proxying.ts @@ -0,0 +1,324 @@ +/* + * Copyright (c) 2025, Salesforce, Inc. + * SPDX-License-Identifier: Apache-2 + * For full license text, see the license.txt file in the repo root or http://www.apache.org/licenses/LICENSE-2.0 + */ + +/** + * @fileoverview Proxy configuration utilities for MRT middleware. + * + * This module provides functions for configuring HTTP proxy middleware, + * including request/response header rewriting and proxy setup for external services. + * It's designed to work with http-proxy-middleware and integrates with the SSR proxying utilities. + * + * @author Salesforce Commerce Cloud + * @version 0.0.1 + */ + +import type {IncomingMessage, ServerResponse, ClientRequest, IncomingHttpHeaders} from 'http'; +import {rewriteProxyRequestHeaders, rewriteProxyResponseHeaders, type HTTPHeaders} from './ssr-proxying.js'; +import {createProxyMiddleware, type Options, type RequestHandler} from 'http-proxy-middleware'; + +/** + * Parameters for applyProxyRequestHeaders function + */ +interface ApplyProxyRequestHeadersParams { + /** The proxy request object from http-proxy-middleware */ + proxyRequest: ClientRequest; + /** The incoming request object */ + incomingRequest: IncomingMessage; + /** Whether this is a caching proxy */ + caching?: boolean; + /** The proxy path being used */ + proxyPath: string; + /** The target host to proxy to */ + targetHost: string; + /** The protocol to use for the target */ + targetProtocol: string; + /** @internal Test hook: override rewrite function */ + rewriteRequestHeaders?: ( + opts: Parameters[0], + ) => ReturnType; +} + +/** + * Parameters for configureProxy function + */ +interface ConfigureProxyParams { + /** The hostname where the Express app is running */ + appHostname: string; + /** The proxy path pattern */ + proxyPath: string; + /** The protocol to use for the target */ + targetProtocol: string; + /** The target host to proxy to */ + targetHost: string; + /** The protocol to use for the app (defaults to https) */ + appProtocol?: string; + /** Whether this is a caching proxy */ + caching?: boolean; +} + +/** + * Optional test hook: provide a custom createProxyMiddleware implementation. + * @internal + */ +export type CreateProxyMiddlewareFn = (config: Options) => RequestHandler; + +/** + * Configuration object for a proxy + */ +export interface ProxyConfig { + /** The target host URL */ + host: string; + /** The proxy path pattern */ + path: string; +} + +/** + * Return type for configureProxy function + */ +export interface ProxyResult { + /** The proxy middleware function */ + fn: RequestHandler; + /** The proxy path pattern */ + path: string; +} + +const stripProxyPathRE = /^\/mobify\/(proxy|caching)\/([^/]+)/; + +/** + * Applies proxy request headers by rewriting and copying headers from the incoming request + * to the proxy request using the SSR proxying utilities. + * + * This function handles header transformation, addition, and removal for proxy requests, + * ensuring that the proxied request has the correct headers for the target service. + * + * @param params - Parameters for applying proxy request headers + * @param params.proxyRequest - The proxy request object from http-proxy-middleware + * @param params.incomingRequest - The incoming request object + * @param params.caching - Whether this is a caching proxy (defaults to false) + * @param params.proxyPath - The proxy path being used + * @param params.targetHost - The target host to proxy to + * @param params.targetProtocol - The protocol to use for the target + * + * @example + * ```typescript + * applyProxyRequestHeaders({ + * proxyRequest: clientRequest, + * incomingRequest: incomingMessage, + * caching: false, + * proxyPath: 'api', + * targetHost: 'api.example.com', + * targetProtocol: 'https' + * }); + * ``` + */ +export const applyProxyRequestHeaders = ({ + proxyRequest, + incomingRequest, + caching = false, + // eslint-disable-next-line @typescript-eslint/no-unused-vars + proxyPath, + targetHost, + targetProtocol, + rewriteRequestHeaders: rewriteFn, +}: ApplyProxyRequestHeadersParams): void => { + const headers = incomingRequest.headers; + + const rewrite = rewriteFn ?? rewriteProxyRequestHeaders; + const newHeaders = rewrite({ + caching, + headers: headers as HTTPHeaders, + headerFormat: 'http', + targetHost, + targetProtocol, + }); + + // Copy any new and updated headers to the proxyRequest + // using setHeader. + Object.entries(newHeaders).forEach( + // setHeader always replaces any current value. + ([key, value]) => proxyRequest.setHeader(key, value as string | number | readonly string[]), + ); + + // Handle deletion of headers. + // Iterate over the keys of incomingRequest.headers - for every + // key, if the value is not present in newHeaders, we remove + // that value from proxyRequest's headers. + Object.keys(headers).forEach((key) => { + // We delete the header on any falsy value, since + // there's no use case where we supply an empty header + // value. + if (!newHeaders[key]) { + proxyRequest.removeHeader(key); + } + }); +}; + +/** + * Configures a single proxy middleware with the specified parameters. + * + * This function creates a complete proxy configuration including request/response + * header rewriting, error handling, and cookie domain rewriting. The configuration + * is designed to match CloudFront behavior for consistency between local development + * and production environments. + * + * @param params - Configuration parameters for the proxy + * @param params.appHostname - The hostname where the Express app is running + * @param params.proxyPath - The proxy path pattern + * @param params.targetProtocol - The protocol to use for the target + * @param params.targetHost - The target host to proxy to + * @param params.appProtocol - The protocol to use for the app (defaults to 'https') + * @param params.caching - Whether this is a caching proxy + * @returns Proxy result containing the middleware function and path + * + * @example + * ```typescript + * const proxy = configureProxy({ + * appHostname: 'localhost:3000', + * proxyPath: 'api', + * targetProtocol: 'https', + * targetHost: 'api.example.com', + * appProtocol: 'https', + * caching: false + * }); + * + * app.use(`/mobify/proxy/${proxy.path}`, proxy.fn); + * ``` + */ +export const configureProxy = ( + { + appHostname, + proxyPath, + targetProtocol, + targetHost, + appProtocol = /* istanbul ignore next */ 'https', + caching, + }: ConfigureProxyParams, + createProxyFn?: CreateProxyMiddlewareFn, +): ProxyResult => { + const createProxy = createProxyFn ?? createProxyMiddleware; + // This configuration must match the behaviour of the proxying + // in CloudFront. + const targetOrigin = `${targetProtocol}://${targetHost}`; + const config = { + // The name of the changeOrigin option is misleading - it configures + // the proxying code in http-proxy to rewrite the Host header (not + // any Origin header) of the outgoing request. The Host header is + // also fixed up in rewriteProxyRequestHeaders, but that + // doesn't work correctly with http-proxy, because the https + // connection to the target is made *before* the request headers + // are modified by the onProxyReq event handler. So we set this + // flag true to get correct behaviour. + changeOrigin: true, + + // Rewrite the domain in set-cookie headers in responses, if it + // matches the targetHost. + cookieDomainRewrite: { + targetHost: appHostname, + }, + + // We don't do cookie *path* rewriting - it's complex. + cookiePathRewrite: false, + + // Neither CloudFront nor the local Express app will follow redirect + // responses to proxy requests. The responses are returned to the + // client. + followRedirects: false, + + onError: (err: Error, req: IncomingMessage, res: ServerResponse) => { + res.writeHead(500, { + 'Content-Type': 'text/plain', + }); + res.end(`Error in proxy request to ${req.url}: ${err}`); + }, + + onProxyReq: (proxyRequest: ClientRequest, incomingRequest: IncomingMessage) => { + applyProxyRequestHeaders({ + proxyRequest, + incomingRequest, + caching, + proxyPath, + targetHost, + targetProtocol, + }); + }, + + onProxyRes: (proxyResponse: IncomingMessage, req: IncomingMessage) => { + const requestUrl = req.url?.replace(stripProxyPathRE, ''); + + // Rewrite key headers + proxyResponse.headers = rewriteProxyResponseHeaders({ + appHostname, + caching: !!caching, + targetHost, + targetProtocol, + appProtocol, + proxyPath, + statusCode: proxyResponse.statusCode, + headers: proxyResponse.headers, + headerFormat: 'http', + requestUrl, + }) as IncomingHttpHeaders; + }, + + // The origin (protocol + host) to which we proxy + target: targetOrigin, + }; + + const proxyFunc = createProxy(config as Options); + return {fn: proxyFunc, path: proxyPath}; +}; + +/** + * Configures multiple proxy middlewares from an array of proxy configurations. + * + * This function processes an array of proxy configurations and creates corresponding + * proxy middleware functions for each one. It automatically determines the target + * protocol from the host URL and creates non-caching proxies by default. + * + * @param proxyConfigs - Array of proxy configurations + * @param appHostname - The hostname where the Express app is running + * @param appProtocol - The protocol to use for the app (defaults to 'https') + * @returns Array of proxy results containing middleware functions and paths + * + * @example + * ```typescript + * const proxyConfigs = [ + * { host: 'https://api.example.com', path: 'api' }, + * { host: 'http://internal.service.com', path: 'internal' } + * ]; + * + * const proxies = configureProxying(proxyConfigs, 'localhost:3000', 'https'); + * + * proxies.forEach(({ fn, path }) => { + * app.use(`/mobify/proxy/${path}`, fn); + * }); + * ``` + */ +export const configureProxying = ( + proxyConfigs: ProxyConfig[], + appHostname: string, + appProtocol: string = 'https', + createProxyFn?: CreateProxyMiddlewareFn, +): ProxyResult[] => { + const proxies: ProxyResult[] = []; + proxyConfigs.forEach((config) => { + const targetProtocol = config.host.startsWith('https://') ? 'https' : 'http'; + const targetHost = config.host.replace(`${targetProtocol}://`, ''); + const proxy = configureProxy( + { + proxyPath: config.path, + targetProtocol, + targetHost, + appProtocol, + appHostname, + caching: false, + }, + createProxyFn, + ); + proxies.push(proxy); + }); + return proxies; +}; diff --git a/packages/mrt-utilities/src/utils/ssr-proxying.ts b/packages/mrt-utilities/src/utils/ssr-proxying.ts new file mode 100644 index 00000000..27050fb4 --- /dev/null +++ b/packages/mrt-utilities/src/utils/ssr-proxying.ts @@ -0,0 +1,928 @@ +/* + * Copyright (c) 2025, Salesforce, Inc. + * SPDX-License-Identifier: Apache-2 + * For full license text, see the license.txt file in the repo root or http://www.apache.org/licenses/LICENSE-2.0 + */ + +/** + * @fileoverview SSR (Server-Side Rendering) Proxying utilities for MRT middleware. + * + * This module provides utilities for handling HTTP headers, cookies, and proxying + * in both Express.js applications and AWS Lambda@Edge functions. It's designed + * to work in multiple contexts while maintaining consistency. + * + * Special requirements: + * - Don't add any functionality in here that is not required by the proxying code + * - Avoid importing any other modules not explicitly used by this code + * - Must work in both Express.js and Lambda@Edge environments + * + * @author Salesforce Commerce Cloud + * @version 0.0.1 + */ + +/* +There are some special requirements for this module, which is used in the +SDK and also in Lambda@Edge functions run by CloudFront. Specifically: +- Don't add any functionality in here that is not required by the +proxying code. +- Avoid importing any other modules not explicitly used by this code +*/ + +import {parse as parseSetCookie} from 'set-cookie-parser'; +import {trainCase} from 'change-case'; +import {URL} from 'url'; +import type {IncomingHttpHeaders} from 'http'; + +const AC_ALLOW_ORIGIN = 'access-control-allow-origin'; +const HOST = 'host'; +const LOCATION = 'location'; +const ORIGIN = 'origin'; +const SET_COOKIE = 'set-cookie'; +const USER_AGENT = 'user-agent'; + +const HEADER_FORMATS = ['http', 'aws'] as const; +export const X_PROXY_REQUEST_URL = 'x-proxy-request-url'; +export const X_MOBIFY_REQUEST_CLASS = 'x-mobify-request-class'; +export const MAX_URL_LENGTH_BYTES = 8192; + +type HeaderFormat = (typeof HEADER_FORMATS)[number]; + +/** + * Represents a parsed cookie object from set-cookie-parser + */ +interface ParsedCookie { + name: string; + value: string; + path?: string; + expires?: Date; + domain?: string; + maxAge?: number; + secure?: boolean; + httpOnly?: boolean; + sameSite?: string; +} + +/** + * Represents a parsed host with its components + */ +export interface ParsedHost { + /** The host (10.10.10.10:port), which includes the port (if any) */ + host: string; + /** The hostname (10.10.10.10), which excludes the port */ + hostname: string; + /** The host's port */ + port?: string; + /** Whether the hostname is an IP or localhost */ + isIPOrLocalhost: boolean; +} + +/** + * Represents a host and port test configuration + */ +interface HostAndPortTest { + type: string | null; + regexp: RegExp; + isIPOrLocalhost: boolean; + hasPort: boolean; +} + +/** + * Represents AWS Lambda Event headers format + */ +interface AWSHeaderValue { + key: string; + value: string; +} + +export type AWSHeaders = Record; + +/** + * Represents HTTP IncomingMessage headers format + */ +export type HTTPHeaders = Record; + +/** + * This class provides a representation of HTTP request or response + * headers, that operates in the same way in multiple contexts + * (i.e. within the Express app as well as the request-processor). + * + * Within a Headers instance, headers are referenced using lower-case + * names. Use getHeader to access the value for a header. If there + * are multiple values, this will return the first value. This class + * internally supports round-trip preservation of multi-value headers, + * but does not yet provide a way to access them. + */ +export class Headers { + private httpFormat: boolean; + private headers: Record; + private _modified: boolean; + + /* + A Lambda@Edge event contains headers in this form: + "headers": { + "host": [ + { + "key": "Host", + "value": "d111111abcdef8.cloudfront.net" + } + ], + "user-agent": [ + { + "key": "User-Agent", + "value": "curl/7.18.1" + } + ] + } + + The http.IncomingMessage format is a simple object: + { + 'user-agent': 'curl/7.22.0', + host: '127.0.0.1:8000' + } + + However, for IncomingMessage: + Duplicates of age, authorization, content-length, content-type, etag, + expires, from, host, if-modified-since, if-unmodified-since, + last-modified, location, max-forwards, proxy-authorization, referer, + retry-after, or user-agent are discarded. + The value for set-cookie is always an array. Duplicates are added to the array. + For all other headers, the values are joined together with ',' + + */ + + /** + * Construct a Headers object from either an AWS Lambda Event headers + * object, or an http.IncomingMessage headers object. + * + * Project code should never need to call this constructor. + * + * @private + * @param headers the input headers + * @param format either 'http' or 'aws' + */ + constructor(headers: AWSHeaders | HTTPHeaders | IncomingHttpHeaders, format: HeaderFormat) { + if (!HEADER_FORMATS.includes(format)) { + throw new Error(`Headers format must be one of ${HEADER_FORMATS.join(', ')}`); + } + this.httpFormat = format === 'http'; + + // Within this class, headers are represented by an object that maps + // header names (lower-case) to arrays of values. + this.headers = {}; + + for (const [key, values] of Object.entries(headers)) { + // For http format, the value will be a comma-separated + // list of values (https://www.w3.org/Protocols/rfc2616/rfc2616-sec4.html#sec4.2) + // For AWS format, the value is an array of key/value objects. + if (this.httpFormat) { + // The Set-Cookie header is always passed to us as an array, + // so we do not split. + if (key === SET_COOKIE) { + this.headers[key] = (values as string[]).slice(); + } else { + this.headers[key] = (values as string).split(/,\s*/).map((value) => value.trim()); + } + } else { + this.headers[key] = (values as AWSHeaderValue[]).map((value) => value.value.trim()); + } + } + + this._modified = false; + } + + /** + * Return true if and only if any set or delete methods were called on + * this instance after construction. This does not actually test if + * the headers values have been changed, just whether any mutating + * methods have been called. + * @returns {Boolean} + */ + get modified(): boolean { + return this._modified; + } + + /** + * Return an array of the header keys (all lower-case) + */ + keys(): string[] { + return Object.keys(this.headers); + } + + /** + * Get the value of the set-cookie header(s), returning an array + * of strings. Always returns an array, even if it's empty. + */ + getSetCookie(): string[] { + return this.headers[SET_COOKIE] || []; + } + + /** + * Set the value of the set-cookie header(s) + * @param values Array of set-cookie header values + */ + setSetCookie(values: string[]): void { + this._modified = true; + + if (!(values && values.length)) { + delete this.headers[SET_COOKIE]; + return; + } + + // Clone the array + this.headers[SET_COOKIE] = values.slice(); + } + + /** + * Return the FIRST value of the header with the given key. + * This is for single-value headers only: Location, Access-Control-*, etc + * If the header is not present, returns undefined. + * @param key header name + */ + getHeader(key: string): string | undefined { + const keyLC = key.toLowerCase(); + const values = this.headers[keyLC]; + if (!values) { + return undefined; + } + return values[0]; + } + + /** + * Set the value of the header with the given key. This is for single- + * value headers only (see getHeader). Setting the value removes ALL other + * values for the given key. + * @param key header name + * @param value header value + */ + setHeader(key: string, value: string): void { + this._modified = true; + const keyLC = key.toLowerCase(); + this.headers[keyLC] = [value]; + } + + /** + * Remove any header with the given key + * @param key header name to remove + */ + deleteHeader(key: string): void { + this._modified = true; + const keyLC = key.toLowerCase(); + delete this.headers[keyLC]; + } + + /** + * Return the headers in AWS (Lambda event) format. + * + * Project code should never need to use this method. + */ + toAWSFormat(): AWSHeaders { + const result: AWSHeaders = {}; + for (const [key, values] of Object.entries(this.headers)) { + // Some customer servers return headers with unusual keys; for + // example, 'cached_response'. Underscores are technically legal + // in header keys, but are unexpected. The problem is that the + // header-case package maps underscore to '-' to get "legal" + // names, which breaks Lambda validation. So if the key + // contains an underscore, we use it as-is. + const finalKey = key.includes('_') ? key : trainCase(key); + result[key] = values.map((value) => ({ + key: finalKey, + value, + })); + } + return result; + } + + /** + * Return the headers in Express (http.IncomingMessage) format. + * + * RFC2616 allows some flexibility in how multiple values are + * combined into a single header value. We separate with ', ' + * rather than just ',' to maintain previous behaviour. + * + * Project code should never need to use this method. + */ + toHTTPFormat(): HTTPHeaders { + const result: HTTPHeaders = {}; + for (const [key, values] of Object.entries(this.headers)) { + // The Set-Cookie header is always returned as an array + if (key === SET_COOKIE) { + result[key] = values.slice(); + } else { + result[key] = values.join(', '); + } + } + return result; + } + + /** + * Return the headers in the same format (aws or http) that was + * used to construct them. + * + * Project code should never need to use this method. + */ + toObject(): AWSHeaders | HTTPHeaders | IncomingHttpHeaders { + return this.httpFormat ? this.toHTTPFormat() : this.toAWSFormat(); + } +} + +const dayNames = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat']; +const monthAbbr = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec']; + +// Return the given number as a 2-digit string with a leading zero +const leadingZero = (n: number): string => { + const s = Number(n).toString(); + return s.length > 1 ? s : `0${s}`; +}; + +/** + * Return the given date as an RFC1123-format string, suitable for + * use in a Set-Cookie or Date header. The result is always in UTC. + * @function + * @param date Date object + * @returns RFC1123 formatted date string + */ +export const rfc1123 = (date: Date): string => { + const time = [ + leadingZero(date.getUTCHours()), + leadingZero(date.getUTCMinutes()), + leadingZero(date.getUTCSeconds()), + ].join(':'); + return [ + `${dayNames[date.getUTCDay()]},`, + leadingZero(date.getUTCDate()), + monthAbbr[date.getUTCMonth()], + date.getUTCFullYear(), + time, + 'GMT', + ].join(' '); +}; + +/** + * Given a cookie object parsed by set-cookie-parser, + * return a set-cookie header value for it. + * @private + */ +export const cookieAsString = (cookie: ParsedCookie): string => { + const elements = [`${cookie.name}=${cookie.value}`]; + if (cookie.path) { + elements.push(`Path=${cookie.path}`); + } + if (cookie.expires) { + // This is a Date object and must be represented as + // an HTTP-date timestamp (RFC1123 format). For example, + // Wed, 24-Oct-2018 00:13:20 GMT + elements.push(`Expires=${rfc1123(cookie.expires)}`); + } + if (cookie.domain) { + elements.push(`Domain=${cookie.domain}`); + } + if (cookie.maxAge) { + elements.push(`Max-Age=${cookie.maxAge}`); + } + if (cookie.secure) { + elements.push('Secure'); + } + if (cookie.httpOnly) { + elements.push('HttpOnly'); + } + if (cookie.sameSite) { + elements.push(`SameSite=${cookie.sameSite}`); + } + return elements.join('; '); +}; + +const IPV6 = 'IPV6'; +const IPV4 = 'IPV4'; +const LOCALHOST = 'localhost'; + +/** + * An array of {type, regexp, isIPOrLocalhost} objects, where the type is used + * to determine how to get a port, regexp will spot an IPv4, IPv6 or hostname, + * and the isIPOrLocalhost flag is true for IP addresses or locahost, false for + * hostnames. The main reason for detecting IP address/localhost is to determine + * whether it can have subdomains. + * @private + */ +const HOST_AND_PORT_TESTS: HostAndPortTest[] = [ + // IPV6 address plus port + { + type: IPV6, + regexp: /^\[([a-fA-F0-9:]+)\]:(\d+)$/, + isIPOrLocalhost: true, + hasPort: true, + }, + // IPV6 address without port + { + type: IPV6, + regexp: /^([a-fA-F0-9:]+)$/, + isIPOrLocalhost: true, + hasPort: false, + }, + // IPV4 address plus port + { + type: IPV4, + regexp: /^(\d+\.\d+\.\d+\.\d+):(\d+)$/, + isIPOrLocalhost: true, + hasPort: true, + }, + // IPV4 address plus without port + { + type: IPV4, + regexp: /^(\d+\.\d+\.\d+\.\d+)$/, + isIPOrLocalhost: true, + hasPort: false, + }, + // localhost plus port + { + type: LOCALHOST, + regexp: /^(localhost):(\d+)$/, + isIPOrLocalhost: true, + hasPort: true, + }, + // localhost without port + { + type: LOCALHOST, + regexp: /^(localhost)$/, + isIPOrLocalhost: true, + hasPort: false, + }, + // hostname plus port + { + type: null, + regexp: /(.+):\d+/, + isIPOrLocalhost: false, // False means the hostname may have a subdomain + hasPort: true, + }, +]; + +/** + * Given a hostname that may be a hostname or ip address optionally + * followed by a port, return an object with 'host' being the ip address, + * the hostname, a port (if there is one), and 'isIPOrLocalhost' true for an ip + * address or localhost, false for a hostname + * @private + * @param host Can be localhost, an IP or domain name + * @return ParsedHost object + */ +export const parseHost = (host: string): ParsedHost => { + for (const test of HOST_AND_PORT_TESTS) { + const match = test.regexp.exec(host); + + if (!match) { + continue; + } + + const result: ParsedHost = { + host, + hostname: match[1], + isIPOrLocalhost: test.isIPOrLocalhost, + }; + + // Split apart and get the hostname and port from the host + if (test.hasPort) { + let parts: string[]; + switch (test.type) { + case IPV6: + // Filter out empty nodes fromt he array. We only care about + // the ones that have values + parts = result.host.split(test.regexp).filter((part) => part.length > 0); + break; + case IPV4: + case LOCALHOST: + default: + parts = result.host.split(':'); + break; + } + [result.hostname, result.port] = parts; + } + + return result; + } + + return { + host, + hostname: host, + isIPOrLocalhost: false, + }; +}; + +// Cookie domain rewrite logic for rewriteSetCookies below +export const rewriteDomain = (domain: string, appHostname: string, targetHost: string): string => { + // Strip any leading dots off the domain and split into elements + const domainElements = domain.split('.').filter((x) => x); + const domainString = domainElements.join('.'); + + // Does the appHostname include a port number? We need a version + // of it without the port (hostname) because set-cookie domains cannot + // include ports. We can't just test for ':' because the host might be an + // ipv6 address. An ipv6 address containing a port contains the + // actual IP surrounded by [] (e.g. [2001:db8::1]:8080) + // RFC3986 + const parsedHost = parseHost(appHostname); + + // If the target host equals or ends with the domainString + // value, then we change the domain to be the appHostname + // (though for localhost, we strip off the port number) + if (targetHost === domainString) { + // Straight replacement + return parsedHost.hostname; + } + + if (!targetHost.endsWith(domainString)) { + // Third-party cookie... leave unchanged + return domain; + } + + // Cookie is set for a subdomain. + if (parsedHost.isIPOrLocalhost) { + // No subdomains for IP addresses or localhost, so return just that domain + return parsedHost.hostname; + } + + // This is tricky... there's no standard way to get the domain for + // a hostname. We use a shortcut - we build up a subdomain based on the + // appHost that has the same number of elements as the cookie domain. + const targetHostElements = targetHost.split('.'); + // Work out how many elements have been removed to form the subdomain + const strippedOff = targetHostElements.length - domainElements.length; + // Strip the same number of elements off the appHost + const appHostnameElements = parsedHost.hostname.split('.'); + return appHostnameElements.slice(strippedOff).join('.'); +}; + +/** + * Parameters for rewriteSetCookies function + */ +interface RewriteSetCookiesParams { + /** the hostname (host+port) under which the Express app is running (e.g. localhost:3443 for a local dev server) */ + appHostname: string; + /** Array of set-cookie header values */ + setCookies: string[]; + /** the target hostname (host+port) */ + targetHost: string; + /** true to log operations */ + logging?: boolean; +} + +/** + * Given a headers object, rewrite any set-cookie headers in it + * so that they apply to the app hostname rather than the target + * hostname. + * + * @private + * @param params Configuration object for rewriting set-cookies + * @returns string[] of rewritten set-cookie header values + */ +export const rewriteSetCookies = ({ + appHostname, + setCookies, + targetHost, + logging = false, +}: RewriteSetCookiesParams): string[] => { + if (!(setCookies && setCookies.length)) { + return []; + } + + // Parse the set-cookie headers into a set of objects + const oldCookies = parseSetCookie(setCookies, {decodeValues: false}); + + // Map the oldCookies array into an array of updated objects + const newCookies = oldCookies.map((cookie) => { + if (cookie.domain) { + const newDomain = rewriteDomain(cookie.domain, appHostname, targetHost); + + /* istanbul ignore next */ + if (logging) { + console.log(`Rewriting proxy response set-cookie header domain from "${cookie.domain}" to "${newDomain}"`); + } + + cookie.domain = newDomain; + } + + return cookie; + }); + + // Convert the cookies back to string values + return newCookies.map(cookieAsString); +}; + +/** + * Parameters for rewriteProxyResponseHeaders function + */ +interface RewriteProxyResponseHeadersParams { + /** the hostname (host+port) under which the Express app is running (e.g. localhost:3443 for a local dev server) */ + appHostname: string; + /** true for a caching proxy, false for a standard proxy */ + caching: boolean; + /** the headers to be rewritten */ + headers: AWSHeaders | HTTPHeaders | IncomingHttpHeaders; + /** 'aws' or 'http' - the format of the 'headers' parameter */ + headerFormat?: HeaderFormat; + /** the path being proxied (e.g. /mobify/proxy/base/) */ + proxyPath: string; + /** the URL from the request that prompted the response. If present, used to set the X-Proxy-Request-Url header. This should be the request URL sent to the target host, not containing any /mobify/proxy/... part. */ + requestUrl?: string; + /** the protocol to use to make requests to the target ('http' or 'https') */ + targetProtocol: string; + /** the target hostname (host+port) */ + targetHost: string; + /** the protocol to use to make requests to the origin ('http' or 'https', defaults to 'https'), use of unencrypted protocol is only allowed in local development */ + appProtocol?: string; + /** true to log operations */ + logging?: boolean; + /** the response status code */ + statusCode?: number; +} + +/** + * Rewrite headers for a proxied response. + * + * 1. If the original domain appears in the + * Access-Control-Allow-Origin header, it's replaced with the + * appOrigin. + * 2. If the response is a 30x redirection and contains a Location + * header on the target host, that header is rewritten to use the + * app host and proxy path. + * + * For a caching proxy, we also remove any Set-Cookie headers - caching + * proxies don't pass Cookie headers for requests and don't allow Set-Cookie + * in responses, so that they may be cached independently of any cookie + * values. + * + * @private + * @param params Configuration object for rewriting proxy response headers + * @returns the modified response headers + */ +export const rewriteProxyResponseHeaders = ({ + appHostname, + caching, + headers, + headerFormat = 'http', + proxyPath, + requestUrl, + statusCode = 200, + targetProtocol, + targetHost, + appProtocol = 'https', + logging = false, +}: RewriteProxyResponseHeadersParams): AWSHeaders | HTTPHeaders | IncomingHttpHeaders => { + const workingHeaders = new Headers( + headers ? ({...headers} as AWSHeaders | HTTPHeaders | IncomingHttpHeaders) : {}, + headerFormat, + ); + + const appOrigin = `${appProtocol}://${appHostname}`; + const targetOrigin = `${targetProtocol}://${targetHost}`; + + // Set the X-Proxy-Request-Url header, if we have the request URL + if (requestUrl) { + // If the requestUrl is just a path, prepend the targetOrigin. + // Including the full URL as a header value risks exceeding limits + // on header value sizes. CloudFront limits URLs to 8192 bytes. + // Even though API Gateway has a header value limit of + // 10240 bytes, we choose to limit the length of the header + // value to 8192 bytes. + const fullRequestUrl = (requestUrl.startsWith('/') ? `${targetOrigin}${requestUrl}` : requestUrl).slice( + 0, + MAX_URL_LENGTH_BYTES, + ); + if (logging) { + console.log(`Setting proxy response ${X_PROXY_REQUEST_URL} header to "${fullRequestUrl}"`); + } + workingHeaders.setHeader(X_PROXY_REQUEST_URL, fullRequestUrl); + } + + // Get a version of the proxyPath that does not end in a slash + /* istanbul ignore next */ + const proxyPathBase = proxyPath.endsWith('/') ? proxyPath.slice(0, -1) : proxyPath; + + const allowOrigin = workingHeaders.getHeader(AC_ALLOW_ORIGIN); + if (logging && allowOrigin) { + console.log(`Header ${AC_ALLOW_ORIGIN} has value "${allowOrigin}"`); + } + if (allowOrigin === targetOrigin) { + /* istanbul ignore else */ + if (logging) { + console.log(`Rewriting proxy response ${AC_ALLOW_ORIGIN} header to "${appOrigin}"`); + } + workingHeaders.setHeader(AC_ALLOW_ORIGIN, appOrigin); + } + + if (caching) { + // For a caching proxy, remove any Set-Cookie headers + workingHeaders.deleteHeader(SET_COOKIE); + } else { + // For a standard proxy, rewrite domains in any set-cookie headers. + const updatedCookies = rewriteSetCookies({ + appHostname, + setCookies: workingHeaders.getSetCookie(), + targetHost, + logging, + }); + workingHeaders.setSetCookie(updatedCookies); + } + + // Handle any redirect + if (statusCode >= 301 && statusCode <= 308) { + if (logging) { + console.log(`Status code is ${statusCode}, checking Location header`); + } + const location = workingHeaders.getHeader(LOCATION); + if (logging) { + console.log(`Location header has value "${location}"`); + } + + /* istanbul ignore else */ + if (location) { + // The Location header is defined as a URL, meaning that it + // can be both protocol- and host-relative, so we expand it + // relative to the targetOrigin. + const locUrl = new URL(location, targetOrigin); + + // If the location header URL is on the targetOrigin, we rewrite it. + if (locUrl.protocol === `${targetProtocol}:` && locUrl.host === targetHost) { + // Rewrite the Location value to map to the proxy path + // on the app host. + locUrl.protocol = appProtocol; + locUrl.host = appHostname; + // Since the proxyPath ends with a slash and locUrl.pathname + // will start with a slash, we need to + locUrl.pathname = proxyPathBase + locUrl.pathname; + const newLocation = locUrl.toString(); + workingHeaders.setHeader(LOCATION, newLocation); + /* istanbul ignore else */ + if (logging) { + console.log(`Rewriting proxy response Location header from "${location}" to "${newLocation}"`); + } + } + } + } + + return workingHeaders.toObject(); +}; + +/** + * List of x- headers that are removed from proxied requests. + * @private + */ +export const X_HEADERS_TO_REMOVE_PROXY: string[] = ['x-mobify-access-key', 'x-sfdc-access-control']; + +/** + * List of x- headers that are removed from origin requests. + * @private + */ +export const X_HEADERS_TO_REMOVE_ORIGIN: string[] = [ + 'x-api-key', + 'x-apigateway-event', + 'x-apigateway-context', + 'x-mobify-access-key', + 'x-sfdc-access-control', +]; + +/** + * X-header key and values to add to proxied requests + * @private + */ +export const X_HEADERS_TO_ADD: Record = { + 'x-mobify': 'true', +}; + +/** + * List of headers that are allowed for a caching proxy request. + * This must match the allowlist that CloudFront uses for a + * CacheBehavior that does not pass cookies and is not configured + * to cache based on headers. + * + * This is a map from lower-case header name to 'true' - we use an object + * to make lookups fast, since this mapping might be used for many requests. + * + * Also see what is configured in the SSR Manager (ssr-infrastructure repo), + * in the CloudFront configuration. This list is a superset of that list, + * since the proxying code must also allow headers that it adds, such as + * Host, Origin, etc. + * + * See https://docs.aws.amazon.com/AmazonCloudFront/latest/DeveloperGuide/header-caching.html + * See https://docs.aws.amazon.com/AmazonCloudFront/latest/DeveloperGuide/Cookies.html + * + * @private + */ +export const ALLOWED_CACHING_PROXY_REQUEST_HEADERS: Record = { + // This is the set of headers allowed for CloudFront + accept: true, + 'accept-charset': true, + 'accept-encoding': true, + 'accept-language': true, + authorization: true, + range: true, + + // These headers must be preserved in the request so that + // header processing works correctly. + host: true, + origin: true, + + // Where CloudFront does the proxying, these headers are + // generated by CloudFront itself. Where the Express app + // does it, we forward them. + 'if-match': true, + 'if-modified-since': true, + 'if-none-match': true, + 'if-range': true, + 'if-unmodified-since': true, +}; + +/** + * Parameters for rewriteProxyRequestHeaders function + */ +interface RewriteProxyRequestHeadersParams { + /** true for a caching proxy, false for a standard proxy */ + caching?: boolean; + /** the headers to be rewritten */ + headers?: AWSHeaders | HTTPHeaders | IncomingHttpHeaders; + /** 'aws' or 'http' - the format of the 'headers' parameter */ + headerFormat?: HeaderFormat; + /** the protocol to use to make requests to the target ('http' or 'https') */ + targetProtocol: string; + /** the target hostname (host+port) */ + targetHost: string; + /** true to log operations */ + logging?: boolean; +} + +/** + * Rewrite headers for a request that is being proxied. + * + * 1. If the request contains a Host header, rewrite it so that the + * value is the target host. + * 2. If the request contains an Origin header, rewrite it so that the + * value is the target host. + * 3. ALL other header values are left unchanged. If they are multi-value + * headers whose values are stored as arrays, the values are left as arrays. + * + * @private + * @param params Configuration object for rewriting proxy request headers + * @returns the modified request headers + */ +export const rewriteProxyRequestHeaders = ({ + caching = false, + headers, + headerFormat = 'http', + targetProtocol, + targetHost, + logging = false, +}: RewriteProxyRequestHeadersParams): AWSHeaders | HTTPHeaders | IncomingHttpHeaders => { + if (!headers) { + return {}; + } + const workingHeaders = new Headers({...headers}, headerFormat); + + // Strip out some specific X-headers + X_HEADERS_TO_REMOVE_PROXY.forEach((key) => workingHeaders.deleteHeader(key)); + + // For a caching proxy, apply special header processing + if (caching) { + // Remove any headers that are not on the allowlist + workingHeaders.keys().forEach((key) => { + if (!ALLOWED_CACHING_PROXY_REQUEST_HEADERS[key]) { + workingHeaders.deleteHeader(key); + } + }); + + // Override user-agent - mimic the behaviour of CloudFront + workingHeaders.setHeader(USER_AGENT, 'Amazon CloudFront'); + } + + // Fix up any Host header. We ignore any current value and + // always replace it with the target host. + // Host: : + const hostHeader = workingHeaders.getHeader(HOST); + if (hostHeader !== targetHost) { + /* istanbul ignore else */ + if (logging) { + console.log(`Rewriting proxy request Host header from "${hostHeader}" to "${targetHost}"`); + } + workingHeaders.setHeader(HOST, targetHost); + } + + // Fix up any Origin header. We ignore any current value and + // always replace it with the targetOrigin + // Origin: "://" [ ":" ] + const originHeader = workingHeaders.getHeader(ORIGIN); + const targetOrigin = `${targetProtocol}://${targetHost}`; + if (originHeader && originHeader !== targetOrigin) { + workingHeaders.setHeader(ORIGIN, targetOrigin); + } + + // Replace some headers with hardwired values + if (workingHeaders.getHeader(USER_AGENT)) { + // Mimic the behaviour of CloudFront + workingHeaders.setHeader(USER_AGENT, 'Amazon CloudFront'); + } + + // Add some specific X-headers + Object.entries(X_HEADERS_TO_ADD).forEach(([key, value]) => { + workingHeaders.setHeader(key, value); + }); + + return workingHeaders.toObject(); +}; diff --git a/packages/mrt-utilities/src/utils/utils.ts b/packages/mrt-utilities/src/utils/utils.ts new file mode 100644 index 00000000..b9ed8cf6 --- /dev/null +++ b/packages/mrt-utilities/src/utils/utils.ts @@ -0,0 +1,47 @@ +/* + * Copyright (c) 2025, Salesforce, Inc. + * SPDX-License-Identifier: Apache-2 + * For full license text, see the license.txt file in the repo root or http://www.apache.org/licenses/LICENSE-2.0 + */ + +/** + * Determines if the application is running in a local development environment. + * + * This function checks for the presence of the AWS_LAMBDA_FUNCTION_NAME environment + * variable to determine if the code is running in AWS Lambda (production) or + * locally (development). + * + * @returns True if running locally, false if running in AWS Lambda + * + * @example + * ```typescript + * if (isLocal()) { + * console.log('Running in development mode'); + * } else { + * console.log('Running in production (AWS Lambda)'); + * } + * ``` + */ +export const isLocal = (): boolean => { + return !Object.prototype.hasOwnProperty.call(process.env, 'AWS_LAMBDA_FUNCTION_NAME'); +}; + +/** + * Log an internal MRT error. + * + * @param namespace Namespace for the error (e.g. data_store, redirect) to facilitate searching + * @param err Error to log + * @param context Optional context to include in the log + */ +export const logMRTError = (namespace: string, err: unknown, context?: Record) => { + const error = err instanceof Error ? err : new Error(String(err)); + console.error( + JSON.stringify({ + [`__MRT__${namespace}`]: 'error', + type: 'MRT_internal', + error: error.message, + stack: error.stack, + ...context, + }), + ); +}; diff --git a/packages/mrt-utilities/test/data-store.test.ts b/packages/mrt-utilities/test/data-store.test.ts new file mode 100644 index 00000000..43914fca --- /dev/null +++ b/packages/mrt-utilities/test/data-store.test.ts @@ -0,0 +1,182 @@ +/* + * Copyright (c) 2025, Salesforce, Inc. + * SPDX-License-Identifier: Apache-2 + * For full license text, see the license.txt file in the repo root or http://www.apache.org/licenses/LICENSE-2.0 + */ + +import {expect} from 'chai'; +import sinon from 'sinon'; +import type {DynamoDBDocumentClient} from '@aws-sdk/lib-dynamodb'; +import { + DataStore, + DataStoreNotFoundError, + DataStoreServiceError, + DataStoreUnavailableError, +} from '@salesforce/mrt-utilities'; + +describe('DataStore', () => { + let mockSend: sinon.SinonStub; + let mockDocumentClient: DynamoDBDocumentClient; + let originalEnv: NodeJS.ProcessEnv; + + beforeEach(() => { + originalEnv = {...process.env}; + (DataStore as unknown as {_instance: DataStore | null})._instance = null; + DataStore._testDocumentClient = null; + DataStore._testLogMRTError = null; + + mockSend = sinon.stub(); + mockDocumentClient = {send: mockSend} as unknown as DynamoDBDocumentClient; + DataStore._testDocumentClient = mockDocumentClient; + + process.env.AWS_REGION = 'ca-central-1'; + process.env.MOBIFY_PROPERTY_ID = 'my-project'; + process.env.DEPLOY_TARGET = 'my-target'; + }); + + afterEach(() => { + process.env = originalEnv; + (DataStore as unknown as {_instance: DataStore | null})._instance = null; + DataStore._testDocumentClient = null; + DataStore._testLogMRTError = null; + sinon.restore(); + }); + + describe('getDataStore', () => { + it('returns singleton instance', () => { + const store1 = DataStore.getDataStore(); + const store2 = DataStore.getDataStore(); + + expect(store1).to.equal(store2); + expect(store1).to.be.an.instanceOf(DataStore); + }); + }); + + describe('isDataStoreAvailable', () => { + it('returns true when all required env vars are set', () => { + const store = DataStore.getDataStore(); + expect(store.isDataStoreAvailable()).to.equal(true); + }); + + for (const envVar of ['AWS_REGION', 'MOBIFY_PROPERTY_ID', 'DEPLOY_TARGET']) { + it(`returns false when ${envVar} is missing`, () => { + delete process.env[envVar]; + + const store = DataStore.getDataStore(); + + expect(store.isDataStoreAvailable()).to.equal(false); + }); + } + }); + + describe('getEntry', () => { + for (const envVar of ['AWS_REGION', 'MOBIFY_PROPERTY_ID', 'DEPLOY_TARGET']) { + it(`throws DataStoreUnavailableError when ${envVar} is missing`, async () => { + delete process.env[envVar]; + + const store = DataStore.getDataStore(); + + try { + await store.getEntry('my-key'); + expect.fail('should have thrown'); + } catch (e) { + expect(e).to.be.an.instanceOf(DataStoreUnavailableError); + expect((e as Error).message).to.include('The data store is unavailable'); + } + }); + } + + const valueCases = [ + {Item: {value: {}}}, + {Item: {value: {theme: 'dark'}}}, + {Item: {value: {nested: {theme: 'light'}}}}, + ]; + for (const mockValue of valueCases) { + it(`returns entry when value exists (${JSON.stringify(mockValue)})`, async () => { + mockSend.resolves(mockValue); + + const store = DataStore.getDataStore(); + const result = await store.getEntry('my-key'); + + expect(result).to.deep.equal({key: 'my-key', value: mockValue.Item!.value}); + expect(mockSend.callCount).to.equal(1); + const sendArg = mockSend.firstCall.args[0]; + expect(sendArg.input).to.deep.include({ + TableName: 'DataAccessLayer-ca-central-1', + Key: { + projectEnvironment: 'my-project my-target', + key: 'my-key', + }, + }); + }); + } + + const notFoundCases = [{}, {Item: {}}, {Item: {key: 'my-key'}}, {Item: {value: null}}, {Item: {value: undefined}}]; + for (const mockValue of notFoundCases) { + it(`throws DataStoreNotFoundError when value not found (${JSON.stringify(mockValue)})`, async () => { + mockSend.resolves(mockValue); + + const store = DataStore.getDataStore(); + + try { + await store.getEntry('my-key'); + expect.fail('should have thrown'); + } catch (e) { + expect(e).to.be.an.instanceOf(DataStoreNotFoundError); + expect((e as Error).message).to.include("Data store entry 'my-key' not found"); + } + }); + } + + it('throws DataStoreServiceError and logs internal error when send throws', async () => { + const dynamoError = new Error('DynamoDB throttled'); + mockSend.rejects(dynamoError); + + const logStub = sinon.stub(); + DataStore._testLogMRTError = logStub; + + const store = DataStore.getDataStore(); + + try { + await store.getEntry('my-key'); + expect.fail('should have thrown'); + } catch (e) { + expect(e).to.be.an.instanceOf(DataStoreServiceError); + expect((e as Error).message).to.include('Data store request failed'); + } + expect( + logStub.calledOnceWith('data_store', dynamoError, { + key: 'my-key', + tableName: 'DataAccessLayer-ca-central-1', + }), + ).to.be.true; + }); + }); +}); + +describe('DataStoreUnavailableError', () => { + it('has correct name and message', () => { + const err = new DataStoreUnavailableError('the data store is unavailable'); + expect(err.name).to.equal('DataStoreUnavailableError'); + expect(err.message).to.equal('the data store is unavailable'); + expect(err).to.be.an.instanceOf(Error); + }); +}); + +describe('DataStoreNotFoundError', () => { + it('has correct name and message', () => { + const err = new DataStoreNotFoundError('entry not found'); + expect(err.name).to.equal('DataStoreNotFoundError'); + expect(err.message).to.equal('entry not found'); + expect(err).to.be.an.instanceOf(Error); + }); +}); + +describe('DataStoreServiceError', () => { + it('has correct name and message', () => { + const err = new DataStoreServiceError('this request failed'); + expect(err.name).to.equal('DataStoreServiceError'); + expect(err.message).to.equal('this request failed'); + expect(err).to.be.an.instanceOf(Error); + }); +}); diff --git a/packages/mrt-utilities/test/metrics-sender.test.ts b/packages/mrt-utilities/test/metrics-sender.test.ts new file mode 100644 index 00000000..24699fdd --- /dev/null +++ b/packages/mrt-utilities/test/metrics-sender.test.ts @@ -0,0 +1,319 @@ +/* + * Copyright (c) 2025, Salesforce, Inc. + * SPDX-License-Identifier: Apache-2 + * For full license text, see the license.txt file in the repo root or http://www.apache.org/licenses/LICENSE-2.0 + */ + +import {expect} from 'chai'; +import sinon from 'sinon'; +import type {CloudWatchClient} from '@aws-sdk/client-cloudwatch'; +import {MetricsSender} from '@salesforce/mrt-utilities'; + +describe('MetricsSender', () => { + let mockSend: sinon.SinonStub; + let mockCloudWatchClient: CloudWatchClient; + let originalEnv: NodeJS.ProcessEnv; + let originalSendCwMetrics: string | undefined; + + beforeEach(() => { + originalEnv = {...process.env}; + originalSendCwMetrics = process.env.SEND_CW_METRICS; + + (MetricsSender as unknown as {_instance: MetricsSender | null})._instance = null; + MetricsSender._override = false; + MetricsSender._testClient = null; + + mockSend = sinon.stub().resolves({}); + mockCloudWatchClient = {send: mockSend} as unknown as CloudWatchClient; + MetricsSender._testClient = mockCloudWatchClient; + + process.env.SEND_CW_METRICS = 'true'; + MetricsSender._override = true; + }); + + afterEach(() => { + process.env = originalEnv; + if (originalSendCwMetrics !== undefined) { + process.env.SEND_CW_METRICS = originalSendCwMetrics; + } else { + delete process.env.SEND_CW_METRICS; + } + + (MetricsSender as unknown as {_instance: MetricsSender | null})._instance = null; + MetricsSender._override = false; + MetricsSender._testClient = null; + sinon.restore(); + }); + + describe('getSender', () => { + it('returns singleton instance', () => { + const instance1 = MetricsSender.getSender(); + const instance2 = MetricsSender.getSender(); + + expect(instance1).to.equal(instance2); + }); + + it('creates new instance if none exists', () => { + const instance = MetricsSender.getSender(); + + expect(instance).to.be.an.instanceOf(MetricsSender); + }); + }); + + describe('queueLength', () => { + it('returns 0 for empty queue', () => { + const sender = MetricsSender.getSender(); + + expect(sender.queueLength).to.equal(0); + }); + + it('returns correct queue length after adding metrics', () => { + const sender = MetricsSender.getSender(); + + sender.send([ + {name: 'metric1', value: 1}, + {name: 'metric2', value: 2}, + ]); + + expect(sender.queueLength).to.equal(2); + }); + }); + + describe('send', () => { + it('queues metrics when immediate is false', () => { + const sender = MetricsSender.getSender(); + + sender.send([{name: 'test-metric', value: 42}]); + + expect(sender.queueLength).to.equal(1); + expect(mockSend.called).to.be.false; + }); + + it('queues metrics by default', () => { + const sender = MetricsSender.getSender(); + + sender.send([{name: 'test-metric', value: 42}]); + + expect(sender.queueLength).to.equal(1); + }); + + it('sends metrics immediately when immediate is true', async () => { + const sender = MetricsSender.getSender(); + + sender.send([{name: 'test-metric', value: 42}], true); + + expect(sender.queueLength).to.equal(0); + + await new Promise((resolve) => setTimeout(resolve, 10)); + + expect(mockSend.called).to.be.true; + }); + + it('converts InputMetric to MetricDatum format', async () => { + const sender = MetricsSender.getSender(); + const timestamp = new Date('2024-01-01T00:00:00Z'); + sender.send( + [ + { + name: 'test-metric', + value: 100, + timestamp, + unit: 'Count', + dimensions: {env: 'test', version: '1.0'}, + }, + ], + true, + ); + + await new Promise((resolve) => setTimeout(resolve, 10)); + + expect(mockSend.called).to.be.true; + const callArg = mockSend.firstCall.args[0]; + expect(callArg.input).to.deep.include({ + MetricData: [ + { + MetricName: 'test-metric', + Value: 100, + Timestamp: timestamp, + Unit: 'Count', + Dimensions: [ + {Name: 'env', Value: 'test'}, + {Name: 'version', Value: '1.0'}, + ], + }, + ], + Namespace: 'ssr', + }); + }); + + it('uses default value of 0 when value is not provided', async () => { + const sender = MetricsSender.getSender(); + + sender.send([{name: 'test-metric'}], true); + + await new Promise((resolve) => setTimeout(resolve, 10)); + + expect(mockSend.called).to.be.true; + const callArg = mockSend.firstCall.args[0]; + expect(callArg.input.MetricData[0].Value).to.equal(0); + }); + + it('uses default unit "Count" when not provided', async () => { + const sender = MetricsSender.getSender(); + + sender.send([{name: 'test-metric', value: 1}], true); + + await new Promise((resolve) => setTimeout(resolve, 10)); + + expect(mockSend.called).to.be.true; + const callArg = mockSend.firstCall.args[0]; + expect(callArg.input.MetricData[0].Unit).to.equal('Count'); + }); + + it('filters out empty dimension values', async () => { + const sender = MetricsSender.getSender(); + + sender.send( + [ + { + name: 'test-metric', + value: 1, + dimensions: { + env: 'test', + empty: '', + nullValue: null as unknown as string, + undefinedValue: undefined as unknown as string, + }, + }, + ], + true, + ); + + await new Promise((resolve) => setTimeout(resolve, 10)); + + expect(mockSend.called).to.be.true; + const callArg = mockSend.firstCall.args[0]; + expect(callArg.input.MetricData[0].Dimensions).to.deep.equal([{Name: 'env', Value: 'test'}]); + }); + + it('handles multiple metrics', () => { + const sender = MetricsSender.getSender(); + + sender.send([ + {name: 'metric1', value: 1}, + {name: 'metric2', value: 2}, + {name: 'metric3', value: 3}, + ]); + + expect(sender.queueLength).to.equal(3); + }); + }); + + describe('flush', () => { + it('returns a Promise', () => { + const sender = MetricsSender.getSender(); + + const result = sender.flush(); + + expect(result).to.be.an.instanceOf(Promise); + }); + + it('clears queue after flush', async () => { + const sender = MetricsSender.getSender(); + + sender.send([ + {name: 'metric1', value: 1}, + {name: 'metric2', value: 2}, + ]); + + expect(sender.queueLength).to.equal(2); + + await sender.flush(); + + expect(sender.queueLength).to.equal(0); + }); + + it('sends queued metrics', async () => { + const sender = MetricsSender.getSender(); + + sender.send([ + {name: 'metric1', value: 1}, + {name: 'metric2', value: 2}, + ]); + + await sender.flush(); + await new Promise((resolve) => setTimeout(resolve, 10)); + + expect(mockSend.called).to.be.true; + }); + + it('handles empty queue', async () => { + const sender = MetricsSender.getSender(); + + await sender.flush(); + }); + }); + + describe('batching', () => { + it('batches metrics into groups of 20', async () => { + const sender = MetricsSender.getSender(); + + const metrics = Array.from({length: 45}, (_, i) => ({ + name: `metric${i}`, + value: i, + })); + + sender.send(metrics, true); + + await new Promise((resolve) => setTimeout(resolve, 50)); + + expect(mockSend.callCount).to.equal(3); + expect(mockSend.firstCall.args[0].input.MetricData).to.have.length(20); + expect(mockSend.secondCall.args[0].input.MetricData).to.have.length(20); + expect(mockSend.thirdCall.args[0].input.MetricData).to.have.length(5); + }); + }); + + describe('error handling', () => { + it('logs errors but does not throw when sending fails', async () => { + const sender = MetricsSender.getSender(); + const consoleWarnStub = sinon.stub(console, 'warn'); + + const error = new Error('CloudWatch error'); + mockSend.rejects(error); + + sender.send([{name: 'test-metric', value: 1}], true); + + await new Promise((resolve) => setTimeout(resolve, 50)); + + expect(consoleWarnStub.calledWith(sinon.match('Metrics: error sending data:'))).to.be.true; + }); + + it('does not throw when flush encounters errors', async () => { + const sender = MetricsSender.getSender(); + sinon.stub(console, 'warn'); + + mockSend.rejects(new Error('CloudWatch error')); + + sender.send([{name: 'test-metric', value: 1}]); + + await sender.flush(); + }); + }); + + describe('_override', () => { + it('respects SEND_CW_METRICS environment variable', () => { + process.env.SEND_CW_METRICS = 'true'; + MetricsSender._override = !!process.env.SEND_CW_METRICS; + + expect(MetricsSender._override).to.equal(true); + }); + + it('is false when SEND_CW_METRICS is not set', () => { + delete process.env.SEND_CW_METRICS; + MetricsSender._override = !!process.env.SEND_CW_METRICS; + + expect(MetricsSender._override).to.equal(false); + }); + }); +}); diff --git a/packages/mrt-utilities/test/middleware.test.ts b/packages/mrt-utilities/test/middleware.test.ts new file mode 100644 index 00000000..6a3c1f7e --- /dev/null +++ b/packages/mrt-utilities/test/middleware.test.ts @@ -0,0 +1,275 @@ +/* + * Copyright (c) 2025, Salesforce, Inc. + * SPDX-License-Identifier: Apache-2 + * For full license text, see the license.txt file in the repo root or http://www.apache.org/licenses/LICENSE-2.0 + */ + +import express, {type Request, type Response, type NextFunction} from 'express'; +import fs from 'fs'; +import path from 'path'; +import {expect} from 'chai'; +import sinon from 'sinon'; +import { + createMRTRequestProcessorMiddleware, + createMRTProxyMiddlewares, + setLocalAssetHeaders, + createMRTStaticAssetServingMiddleware, + createMRTCommonMiddleware, + createMRTCleanUpMiddleware, + X_MOBIFY_REQUEST_PROCESSOR_LOCAL, + X_MOBIFY_QUERYSTRING, +} from '@salesforce/mrt-utilities'; + +interface MockResponse extends Partial { + sendStatus: sinon.SinonStub; + set: sinon.SinonStub; +} + +describe('middleware', () => { + let mockRequest: Partial; + let mockResponse: MockResponse; + let mockNext: NextFunction & sinon.SinonStub; + + beforeEach(() => { + mockRequest = { + originalUrl: '/test', + method: 'GET', + headers: {}, + query: {}, + app: {set: sinon.stub()} as unknown as express.Application, + } as Partial; + + mockResponse = { + sendStatus: sinon.stub(), + redirect: sinon.stub(), + set: sinon.stub(), + locals: {}, + } as unknown as MockResponse; + mockNext = sinon.stub() as NextFunction & sinon.SinonStub; + }); + + afterEach(() => { + sinon.restore(); + }); + + describe('createMRTRequestProcessorMiddleware', () => { + it('creates middleware that processes requests', () => { + const middleware = createMRTRequestProcessorMiddleware('/path/to/processor.js', []); + expect(middleware).to.be.a('function'); + }); + + it('skips processing for proxy or bundle paths', async () => { + const stubExists = sinon.stub(fs, 'existsSync').returns(false); + const middleware = createMRTRequestProcessorMiddleware('/path/to/processor.js', []); + + (mockRequest as Request).originalUrl = '/mobify/proxy/api/test'; + await middleware(mockRequest as Request, mockResponse as Response, mockNext); + + expect(mockNext.calledOnce).to.be.true; + stubExists.restore(); + }); + + it('rejects non-GET/HEAD/OPTIONS requests to root path', async () => { + sinon.stub(fs, 'existsSync').returns(false); + const middleware = createMRTRequestProcessorMiddleware('/path/to/processor.js', []); + + const testRequest = { + ...mockRequest, + path: '/', + method: 'POST', + } as Request; + + await middleware(testRequest, mockResponse as Response, mockNext); + + expect(mockResponse.sendStatus.calledWith(405)).to.be.true; + expect(mockNext.called).to.be.false; + }); + + it('allows GET requests to root path', async () => { + sinon.stub(fs, 'existsSync').returns(false); + const middleware = createMRTRequestProcessorMiddleware('/path/to/processor.js', []); + + const testRequest = { + ...mockRequest, + path: '/', + method: 'GET', + } as Request; + + await middleware(testRequest, mockResponse as Response, mockNext); + + expect(mockResponse.sendStatus.called).to.be.false; + expect(mockNext.calledOnce).to.be.true; + }); + + it('removes API Gateway headers', async () => { + sinon.stub(fs, 'existsSync').returns(false); + const middleware = createMRTRequestProcessorMiddleware('/path/to/processor.js', []); + + (mockRequest as Request).headers = { + 'x-api-key': 'secret', + 'x-mobify-access-key': 'mobify-secret', + 'x-apigateway-event': '{}', + 'x-apigateway-context': '{}', + 'x-sfdc-access-control': 'control', + 'content-type': 'application/json', + }; + + await middleware(mockRequest as Request, mockResponse as Response, mockNext); + + expect((mockRequest as Request).headers['x-api-key']).to.be.undefined; + expect((mockRequest as Request).headers['x-mobify-access-key']).to.be.undefined; + expect((mockRequest as Request).headers['content-type']).to.equal('application/json'); + }); + + it('sets X_MOBIFY_REQUEST_PROCESSOR_LOCAL header after processing', async () => { + sinon.stub(fs, 'existsSync').returns(false); + const middleware = createMRTRequestProcessorMiddleware('/path/to/processor.js', []); + + (mockRequest as Request).headers = {}; + (mockRequest as Request).originalUrl = '/test'; + + await middleware(mockRequest as Request, mockResponse as Response, mockNext); + + expect((mockRequest as Request).headers[X_MOBIFY_REQUEST_PROCESSOR_LOCAL]).to.equal('true'); + expect(mockNext.calledOnce).to.be.true; + }); + }); + + describe('createMRTProxyMiddlewares', () => { + const mockProxyFn = sinon.stub() as unknown as express.RequestHandler & {upgrade: sinon.SinonStub}; + mockProxyFn.upgrade = sinon.stub(); + + it('creates proxy middlewares with createProxyFn', () => { + const createProxyFn = sinon.stub().returns(mockProxyFn); + const proxyConfigs = [{host: 'https://api.example.com', path: 'api'}]; + + const result = createMRTProxyMiddlewares(proxyConfigs, 'https', false, createProxyFn); + + expect(result).to.have.length(1); + expect(result[0].path).to.equal('/mobify/proxy/api'); + expect(result[0].fn).to.equal(mockProxyFn); + }); + + it('includes caching middlewares when requested', () => { + const createProxyFn = sinon.stub().returns(mockProxyFn); + const proxyConfigs = [{host: 'https://api.example.com', path: 'api'}]; + + const result = createMRTProxyMiddlewares(proxyConfigs, 'https', true, createProxyFn); + + expect(result).to.have.length(2); + expect(result[0].path).to.equal('/mobify/proxy/api'); + expect(result[1].path).to.equal('/mobify/caching/api'); + }); + + it('returns empty array for null proxy configs', () => { + const result = createMRTProxyMiddlewares( + null as unknown as import('@salesforce/mrt-utilities').ProxyConfig[], + 'https', + false, + ); + + expect(result).to.deep.equal([]); + }); + }); + + describe('setLocalAssetHeaders', () => { + beforeEach(() => { + sinon.stub(path, 'basename').returns('test.js'); + sinon.stub(fs, 'statSync').returns({ + mtime: new Date('2023-01-01T00:00:00Z'), + } as fs.Stats); + }); + + it('sets correct headers for asset', () => { + setLocalAssetHeaders(mockResponse as Response, '/path/to/test.js'); + + expect((path.basename as sinon.SinonStub).calledWith('/path/to/test.js')).to.be.true; + expect(mockResponse.set.calledWith('content-type', 'text/javascript')).to.be.true; + expect(mockResponse.set.calledWith('etag', '1672531200000')).to.be.true; + }); + }); + + describe('createMRTStaticAssetServingMiddleware', () => { + it('creates express static middleware with correct options', () => { + const mockStaticMiddleware = sinon.stub(); + const staticStub = sinon.stub(express, 'static'); + staticStub.returns(mockStaticMiddleware as unknown as ReturnType); + + const result = createMRTStaticAssetServingMiddleware('/static'); + + expect(staticStub.calledWith('/static', sinon.match.has('dotfiles', 'deny'))).to.be.true; + expect(result as unknown).to.equal(mockStaticMiddleware); + }); + }); + + describe('createMRTCommonMiddleware', () => { + it('creates a middleware function', () => { + const middleware = createMRTCommonMiddleware(); + expect(middleware).to.be.a('function'); + }); + + it('sets host header to EXTERNAL_DOMAIN_NAME when set', () => { + const originalEnv = process.env.EXTERNAL_DOMAIN_NAME; + process.env.EXTERNAL_DOMAIN_NAME = 'external.example.com'; + + const middleware = createMRTCommonMiddleware(); + const testRequest = {...mockRequest, headers: {}} as Request; + + middleware(testRequest, mockResponse as Response, mockNext); + + expect(testRequest.headers!.host).to.equal('external.example.com'); + expect(mockNext.calledOnce).to.be.true; + + if (originalEnv !== undefined) { + process.env.EXTERNAL_DOMAIN_NAME = originalEnv; + } else { + delete process.env.EXTERNAL_DOMAIN_NAME; + } + }); + + it('defaults to localhost:2401 when EXTERNAL_DOMAIN_NAME is not set', () => { + const originalEnv = process.env.EXTERNAL_DOMAIN_NAME; + delete process.env.EXTERNAL_DOMAIN_NAME; + + const middleware = createMRTCommonMiddleware(); + const testRequest = {...mockRequest, headers: {}} as Request; + + middleware(testRequest, mockResponse as Response, mockNext); + + expect(testRequest.headers!.host).to.equal('localhost:2401'); + + if (originalEnv !== undefined) { + process.env.EXTERNAL_DOMAIN_NAME = originalEnv; + } + }); + }); + + describe('createMRTCleanUpMiddleware', () => { + it('creates a middleware function', () => { + const middleware = createMRTCleanUpMiddleware(); + expect(middleware).to.be.a('function'); + }); + + it('removes X_MOBIFY_REQUEST_PROCESSOR_LOCAL header', async () => { + const middleware = createMRTCleanUpMiddleware(); + + (mockRequest as Request).headers = {[X_MOBIFY_REQUEST_PROCESSOR_LOCAL]: 'true'}; + + await middleware(mockRequest as Request, mockResponse as Response, mockNext); + + expect((mockRequest as Request).headers[X_MOBIFY_REQUEST_PROCESSOR_LOCAL]).to.be.undefined; + expect(mockNext.calledOnce).to.be.true; + }); + + it('removes X_MOBIFY_QUERYSTRING header', async () => { + const middleware = createMRTCleanUpMiddleware(); + + (mockRequest as Request).headers = {[X_MOBIFY_QUERYSTRING]: 'test=value'}; + + await middleware(mockRequest as Request, mockResponse as Response, mockNext); + + expect((mockRequest as Request).headers[X_MOBIFY_QUERYSTRING]).to.be.undefined; + expect(mockNext.calledOnce).to.be.true; + }); + }); +}); diff --git a/packages/mrt-utilities/test/streaming/create-lambda-adapter-compression.test.ts b/packages/mrt-utilities/test/streaming/create-lambda-adapter-compression.test.ts new file mode 100644 index 00000000..68b52cd4 --- /dev/null +++ b/packages/mrt-utilities/test/streaming/create-lambda-adapter-compression.test.ts @@ -0,0 +1,956 @@ +/* + * Copyright (c) 2025, Salesforce, Inc. + * SPDX-License-Identifier: Apache-2 + * For full license text, see the license.txt file in the repo root or http://www.apache.org/licenses/LICENSE-2.0 + */ + +import type {APIGatewayProxyEvent, Context} from 'aws-lambda'; +import {PassThrough, type Writable} from 'stream'; +import zlib from 'node:zlib'; +import eventMocks from '@serverless/event-mocks'; +import {expect} from 'chai'; + +// ESM/CJS interop: default may be the function or a namespace with .default +const createEvent = typeof eventMocks === 'function' ? eventMocks : eventMocks.default; +import sinon from 'sinon'; +import {createExpressResponse, createExpressRequest, type CompressionConfig} from '@salesforce/mrt-utilities/streaming'; + +// Mock awslambda global - creates a pass-through stream that stores metadata +const compressionAwslambdaMock = { + HttpResponseStream: { + from: (stream: Writable, metadata: {statusCode: number; headers: Record}) => { + // Store metadata on the original stream for verification + const originalStream = (stream as any).__originalStream || stream; + originalStream.__metadata = metadata; + // Return a pass-through stream that forwards data to the original stream + const passThrough = new PassThrough(); + passThrough.pipe(stream); + return passThrough; + }, + }, +}; + +// Helper to create a real writable stream that collects data +function createCollectingStream(): PassThrough & { + getData: () => Buffer; + getMetadata: () => any; + waitForEnd: () => Promise; +} { + const stream = new PassThrough(); + const chunks: Buffer[] = []; + + // Mark this as the original stream for metadata storage + (stream as any).__originalStream = stream; + + stream.on('data', (chunk: Buffer) => { + chunks.push(chunk); + }); + + return Object.assign(stream, { + getData: () => Buffer.concat(chunks), + getMetadata: () => (stream as any).__metadata, + waitForEnd: () => { + return new Promise((resolve) => { + if (stream.writableEnded) { + resolve(); + } else { + const timeout = setTimeout(resolve, 500); + stream.once('finish', () => { + clearTimeout(timeout); + resolve(); + }); + stream.once('end', () => { + clearTimeout(timeout); + resolve(); + }); + } + }); + }, + }); +} + +// Helper to create a mock API Gateway event using @serverless/event-mocks +function createMockEvent(overrides?: Partial): APIGatewayProxyEvent { + const event = createEvent('aws:apiGateway', { + path: '/test', + httpMethod: 'GET', + ...(overrides as any), + }); + // Ensure body is null if undefined (createEvent may return undefined for body) + if (event.body === undefined) { + event.body = null; + } + // Remove Accept-Encoding header if not explicitly provided in overrides + // (createEvent may add default headers) + if (overrides?.headers?.['Accept-Encoding'] === undefined && overrides?.headers?.['accept-encoding'] === undefined) { + if (event.headers) { + delete event.headers['Accept-Encoding']; + delete event.headers['accept-encoding']; + } + if (event.multiValueHeaders) { + delete event.multiValueHeaders['Accept-Encoding']; + delete event.multiValueHeaders['accept-encoding']; + } + } + return event; +} + +// Helper to create a mock Lambda context +function createMockContext(overrides?: Partial): Context { + return { + callbackWaitsForEmptyEventLoop: false, + functionName: 'test-function', + functionVersion: '$LATEST', + invokedFunctionArn: 'arn:aws:lambda:us-east-1:123456789012:function:test-function', + memoryLimitInMB: '128', + awsRequestId: 'test-request-id', + logGroupName: '/aws/lambda/test-function', + logStreamName: '2023/01/01/[$LATEST]test', + getRemainingTimeInMillis: () => 30000, + done: () => {}, + fail: () => {}, + succeed: () => {}, + ...overrides, + }; +} + +describe('Compression Streaming', () => { + beforeEach(() => { + (globalThis as any).awslambda = compressionAwslambdaMock; + }); + + describe('Gzip compression', () => { + it('should compress text/html content with gzip', async () => { + const stream = createCollectingStream(); + const event = createMockEvent({headers: {'Accept-Encoding': 'gzip'}}); + const context = createMockContext(); + const request = createExpressRequest(event, context); + const response = createExpressResponse(stream, event, context, request); + + response.setHeader('Content-Type', 'text/html'); + const testData = 'This is a test string that should be compressed. '.repeat(100); + response.end(testData); + + // Wait for stream to finish + await stream.waitForEnd(); + await new Promise((resolve) => setTimeout(resolve, 50)); + + const compressedData = stream.getData(); + const metadata = stream.getMetadata(); + + expect(metadata).to.exist; + expect(metadata.headers['content-encoding']).to.equal('gzip'); + expect(compressedData.length).to.be.greaterThan(0); + // Compressed data should typically be smaller than original for repetitive text + expect(compressedData.length).to.be.lessThan(Buffer.from(testData).length); + }); + + it('should compress application/json content with gzip', async () => { + const stream = createCollectingStream(); + const event = createMockEvent({headers: {'Accept-Encoding': 'gzip'}}); + const context = createMockContext(); + const request = createExpressRequest(event, context); + const response = createExpressResponse(stream, event, context, request); + + response.setHeader('Content-Type', 'application/json'); + const testData = JSON.stringify({message: 'test', data: Array(100).fill('x').join('')}); + response.end(testData); + + await new Promise((resolve) => setTimeout(resolve, 50)); + + const compressedData = stream.getData(); + const metadata = stream.getMetadata(); + + expect(metadata.headers['content-encoding']).to.equal('gzip'); + expect(compressedData.length).to.be.greaterThan(0); + expect(compressedData.length).to.be.lessThan(Buffer.from(testData).length); + }); + + it('should compress streaming chunks with gzip', async () => { + const stream = createCollectingStream(); + const event = createMockEvent({headers: {'Accept-Encoding': 'gzip'}}); + const context = createMockContext(); + const request = createExpressRequest(event, context); + const response = createExpressResponse(stream, event, context, request); + + response.setHeader('Content-Type', 'text/plain'); + response.write('chunk1'); + response.write('chunk2'); + response.write('chunk3'); + response.end(); + + await new Promise((resolve) => setTimeout(resolve, 50)); + + const compressedData = stream.getData(); + const metadata = stream.getMetadata(); + + expect(metadata.headers['content-encoding']).to.equal('gzip'); + expect(compressedData.length).to.be.greaterThan(0); + }); + }); + + describe('Deflate compression', () => { + it('should compress content with deflate when deflate is preferred', async () => { + const stream = createCollectingStream(); + const event = createMockEvent({headers: {'Accept-Encoding': 'deflate, gzip'}}); + const context = createMockContext(); + const request = createExpressRequest(event, context); + const response = createExpressResponse(stream, event, context, request); + + response.setHeader('Content-Type', 'text/html'); + const testData = 'This is a test string. '.repeat(50); + response.end(testData); + + await new Promise((resolve) => setTimeout(resolve, 50)); + + const compressedData = stream.getData(); + const metadata = stream.getMetadata(); + + expect(metadata.headers['content-encoding']).to.equal('deflate'); + expect(compressedData.length).to.be.greaterThan(0); + expect(compressedData.length).to.be.lessThan(Buffer.from(testData).length); + }); + }); + + describe('Brotli compression', () => { + it('should compress content with brotli when br is preferred', async () => { + const stream = createCollectingStream(); + const event = createMockEvent({headers: {'Accept-Encoding': 'br, gzip, deflate'}}); + const context = createMockContext(); + const request = createExpressRequest(event, context); + const response = createExpressResponse(stream, event, context, request); + + response.setHeader('Content-Type', 'text/html'); + const testData = 'This is a test string for brotli compression. '.repeat(100); + response.end(testData); + + await new Promise((resolve) => setTimeout(resolve, 50)); + + const compressedData = stream.getData(); + const metadata = stream.getMetadata(); + + expect(metadata.headers['content-encoding']).to.equal('br'); + expect(compressedData.length).to.be.greaterThan(0); + expect(compressedData.length).to.be.lessThan(Buffer.from(testData).length); + }); + + it('should prefer brotli over gzip when both are available', async () => { + const stream = createCollectingStream(); + const event = createMockEvent({headers: {'Accept-Encoding': 'br, gzip'}}); + const context = createMockContext(); + const request = createExpressRequest(event, context); + const response = createExpressResponse(stream, event, context, request); + + response.setHeader('Content-Type', 'application/json'); + const testData = JSON.stringify({data: 'test'.repeat(100)}); + response.end(testData); + + await new Promise((resolve) => setTimeout(resolve, 50)); + + const metadata = stream.getMetadata(); + // Negotiator prefers based on order in Accept-Encoding, but our code prefers br first + // So br should be selected when available + expect(metadata.headers['content-encoding']).to.equal('br'); + }); + }); + + describe('Compressible content types', () => { + it('should compress text/css', async () => { + const stream = createCollectingStream(); + const event = createMockEvent({headers: {'Accept-Encoding': 'gzip'}}); + const context = createMockContext(); + const request = createExpressRequest(event, context); + const response = createExpressResponse(stream, event, context, request); + + response.setHeader('Content-Type', 'text/css'); + const testData = 'body { color: red; } '.repeat(50); + response.end(testData); + + await new Promise((resolve) => setTimeout(resolve, 50)); + + const metadata = stream.getMetadata(); + expect(metadata.headers['content-encoding']).to.equal('gzip'); + }); + + it('should compress application/javascript', async () => { + const stream = createCollectingStream(); + const event = createMockEvent({headers: {'Accept-Encoding': 'gzip'}}); + const context = createMockContext(); + const request = createExpressRequest(event, context); + const response = createExpressResponse(stream, event, context, request); + + response.setHeader('Content-Type', 'application/javascript'); + const testData = 'function test() { return true; } '.repeat(50); + response.end(testData); + + await new Promise((resolve) => setTimeout(resolve, 50)); + + const metadata = stream.getMetadata(); + expect(metadata.headers['content-encoding']).to.equal('gzip'); + }); + + it('should compress text/xml', async () => { + const stream = createCollectingStream(); + const event = createMockEvent({headers: {'Accept-Encoding': 'gzip'}}); + const context = createMockContext(); + const request = createExpressRequest(event, context); + const response = createExpressResponse(stream, event, context, request); + + response.setHeader('Content-Type', 'text/xml'); + const testData = 'test'.repeat(50); + response.end(testData); + + await new Promise((resolve) => setTimeout(resolve, 50)); + + const metadata = stream.getMetadata(); + expect(metadata.headers['content-encoding']).to.equal('gzip'); + }); + + it('should compress image/svg+xml', async () => { + const stream = createCollectingStream(); + const event = createMockEvent({headers: {'Accept-Encoding': 'gzip'}}); + const context = createMockContext(); + const request = createExpressRequest(event, context); + const response = createExpressResponse(stream, event, context, request); + + response.setHeader('Content-Type', 'image/svg+xml'); + const testData = ''.repeat(50); + response.end(testData); + + await new Promise((resolve) => setTimeout(resolve, 50)); + + const metadata = stream.getMetadata(); + expect(metadata.headers['content-encoding']).to.equal('gzip'); + }); + }); + + describe('Non-compressible content types', () => { + it('should not compress image/jpeg', async () => { + const stream = createCollectingStream(); + const event = createMockEvent({headers: {'Accept-Encoding': 'gzip'}}); + const context = createMockContext(); + const request = createExpressRequest(event, context); + const response = createExpressResponse(stream, event, context, request); + + response.setHeader('Content-Type', 'image/jpeg'); + const testData = Buffer.alloc(1000, 0xff); // Mock JPEG data + response.end(testData); + + await new Promise((resolve) => setTimeout(resolve, 50)); + + const metadata = stream.getMetadata(); + expect(metadata.headers['content-encoding']).to.be.undefined; + const data = stream.getData(); + expect(data).to.deep.equal(testData); + }); + + it('should not compress image/png', async () => { + const stream = createCollectingStream(); + const event = createMockEvent({headers: {'Accept-Encoding': 'gzip'}}); + const context = createMockContext(); + const request = createExpressRequest(event, context); + const response = createExpressResponse(stream, event, context, request); + + response.setHeader('Content-Type', 'image/png'); + const testData = Buffer.alloc(1000, 0x89); // Mock PNG data + response.end(testData); + + await new Promise((resolve) => setTimeout(resolve, 50)); + + const metadata = stream.getMetadata(); + expect(metadata.headers['content-encoding']).to.be.undefined; + }); + + it('should not compress video/mp4', async () => { + const stream = createCollectingStream(); + const event = createMockEvent({headers: {'Accept-Encoding': 'gzip'}}); + const context = createMockContext(); + const request = createExpressRequest(event, context); + const response = createExpressResponse(stream, event, context, request); + + response.setHeader('Content-Type', 'video/mp4'); + const testData = Buffer.alloc(1000); + response.end(testData); + + await new Promise((resolve) => setTimeout(resolve, 50)); + + const metadata = stream.getMetadata(); + expect(metadata.headers['content-encoding']).to.be.undefined; + }); + + it('should compress application/octet-stream (compressible package considers it compressible)', async () => { + const stream = createCollectingStream(); + const event = createMockEvent({headers: {'Accept-Encoding': 'gzip'}}); + const context = createMockContext(); + const request = createExpressRequest(event, context); + const response = createExpressResponse(stream, event, context, request); + + response.setHeader('Content-Type', 'application/octet-stream'); + const testData = Buffer.alloc(1000, 0xff); + response.end(testData); + + await new Promise((resolve) => setTimeout(resolve, 50)); + + const metadata = stream.getMetadata(); + // Note: The compressible package considers application/octet-stream as compressible + // because it starts with "application/". This is the package's behavior. + expect(metadata.headers['content-encoding']).to.equal('gzip'); + }); + }); + + describe('No Accept-Encoding header', () => { + it('should not compress when Accept-Encoding is missing', async () => { + const stream = createCollectingStream(); + // Explicitly set headers to empty to ensure no Accept-Encoding header + const event = createMockEvent({headers: {}, multiValueHeaders: {}}); + const context = createMockContext(); + + const request = createExpressRequest(event, context); + const response = createExpressResponse(stream, event, context, request); + + response.setHeader('Content-Type', 'text/html'); + const testData = 'This should not be compressed'; + response.end(testData); + + await new Promise((resolve) => setTimeout(resolve, 50)); + + const metadata = stream.getMetadata(); + expect(metadata.headers['content-encoding']).to.be.undefined; + const data = stream.getData(); + expect(data.toString()).to.equal(testData); + }); + }); + + describe('Content-Length header removal', () => { + it('should remove Content-Length header when compression is enabled', async () => { + const stream = createCollectingStream(); + const event = createMockEvent({headers: {'Accept-Encoding': 'gzip'}}); + const context = createMockContext(); + const request = createExpressRequest(event, context); + const response = createExpressResponse(stream, event, context, request); + + response.setHeader('Content-Type', 'text/html'); + // Don't set Content-Length initially - compression setup should prevent it + const testData = 'test data'; + response.end(testData); + + await new Promise((resolve) => setTimeout(resolve, 50)); + + const metadata = stream.getMetadata(); + expect(metadata.headers['content-encoding']).to.equal('gzip'); + // Content-Length should not be present when compression is used + // (it's removed during compression setup) + expect(metadata.headers['content-length']).to.be.undefined; + }); + }); + + describe('Multiple writes with compression', () => { + it('should compress multiple chunks correctly', async () => { + const stream = createCollectingStream(); + const event = createMockEvent({headers: {'Accept-Encoding': 'gzip'}}); + const context = createMockContext(); + const request = createExpressRequest(event, context); + const response = createExpressResponse(stream, event, context, request); + + response.setHeader('Content-Type', 'text/plain'); + const chunks = ['chunk1', 'chunk2', 'chunk3', 'chunk4', 'chunk5']; + + for (const chunk of chunks) { + response.write(chunk); + } + response.end(); + + await new Promise((resolve) => setTimeout(resolve, 50)); + + const compressedData = stream.getData(); + const metadata = stream.getMetadata(); + + expect(metadata.headers['content-encoding']).to.equal('gzip'); + expect(compressedData.length).to.be.greaterThan(0); + }); + }); + + describe('Encoding negotiation', () => { + it('should handle quality values in Accept-Encoding', async () => { + const stream = createCollectingStream(); + const event = createMockEvent({headers: {'Accept-Encoding': 'gzip;q=0.8, br;q=0.9'}}); + const context = createMockContext(); + const request = createExpressRequest(event, context); + const response = createExpressResponse(stream, event, context, request); + + response.setHeader('Content-Type', 'text/html'); + response.end('test data'); + + await new Promise((resolve) => setTimeout(resolve, 50)); + + const metadata = stream.getMetadata(); + // Should prefer br due to higher quality value + expect(metadata.headers['content-encoding']).to.equal('br'); + }); + + it('should handle wildcard Accept-Encoding', async () => { + const stream = createCollectingStream(); + const event = createMockEvent({headers: {'Accept-Encoding': '*'}}); + const context = createMockContext(); + const request = createExpressRequest(event, context); + const response = createExpressResponse(stream, event, context, request); + + response.setHeader('Content-Type', 'text/html'); + response.end('test data'); + + await new Promise((resolve) => setTimeout(resolve, 50)); + + const metadata = stream.getMetadata(); + // Should use br as it's first in our preference list + expect(metadata.headers['content-encoding']).to.equal('br'); + }); + }); + + describe('Error handling', () => { + it('should handle compression stream errors gracefully', async () => { + const stream = createCollectingStream(); + const event = createMockEvent({headers: {'Accept-Encoding': 'gzip'}}); + const context = createMockContext(); + const request = createExpressRequest(event, context); + const response = createExpressResponse(stream, event, context, request); + + response.setHeader('Content-Type', 'text/html'); + + // Write some data to trigger compression setup + response.write('test'); + + // Simulate an error by destroying the compression stream + // This is a bit tricky since we don't have direct access to the compression stream + // But we can verify the response still works + response.end('more data'); + + await new Promise((resolve) => setTimeout(resolve, 50)); + + // Response should still complete even if compression has issues + const metadata = stream.getMetadata(); + expect(metadata).to.exist; + }); + }); + + describe('Content type with parameters', () => { + it('should handle content type with charset parameter', async () => { + const stream = createCollectingStream(); + const event = createMockEvent({headers: {'Accept-Encoding': 'gzip'}}); + const context = createMockContext(); + const request = createExpressRequest(event, context); + const response = createExpressResponse(stream, event, context, request); + + response.setHeader('Content-Type', 'text/html; charset=utf-8'); + const testData = 'test data'; + response.end(testData); + + await new Promise((resolve) => setTimeout(resolve, 50)); + + const metadata = stream.getMetadata(); + expect(metadata.headers['content-encoding']).to.equal('gzip'); + }); + + it('should handle content type with boundary parameter', async () => { + const stream = createCollectingStream(); + const event = createMockEvent({headers: {'Accept-Encoding': 'gzip'}}); + const context = createMockContext(); + const request = createExpressRequest(event, context); + const response = createExpressResponse(stream, event, context, request); + + response.setHeader('Content-Type', 'multipart/form-data; boundary=----WebKitFormBoundary'); + const testData = 'test data'; + response.end(testData); + + await new Promise((resolve) => setTimeout(resolve, 50)); + + const metadata = stream.getMetadata(); + // multipart/form-data is NOT compressible according to the compressible package + // It doesn't start with text/ or application/ (it's multipart/) + expect(metadata.headers['content-encoding']).to.be.undefined; + }); + }); + + describe('Response methods with compression', () => { + it('should compress when using res.send()', async () => { + const stream = createCollectingStream(); + const event = createMockEvent({headers: {'Accept-Encoding': 'gzip'}}); + const context = createMockContext(); + const request = createExpressRequest(event, context); + const response = createExpressResponse(stream, event, context, request); + + response.setHeader('Content-Type', 'text/html'); + response.send('test data'); + + await new Promise((resolve) => setTimeout(resolve, 50)); + + const metadata = stream.getMetadata(); + expect(metadata.headers['content-encoding']).to.equal('gzip'); + }); + + it('should compress when using res.json()', async () => { + const stream = createCollectingStream(); + const event = createMockEvent({headers: {'Accept-Encoding': 'gzip'}}); + const context = createMockContext(); + const request = createExpressRequest(event, context); + const response = createExpressResponse(stream, event, context, request); + + response.json({message: 'test', data: 'x'.repeat(100)}); + + await new Promise((resolve) => setTimeout(resolve, 50)); + + const metadata = stream.getMetadata(); + expect(metadata.headers['content-encoding']).to.equal('gzip'); + }); + + it('should compress when using writeHead()', async () => { + const stream = createCollectingStream(); + const event = createMockEvent({headers: {'Accept-Encoding': 'gzip'}}); + const context = createMockContext(); + const request = createExpressRequest(event, context); + const response = createExpressResponse(stream, event, context, request); + + response.writeHead(200, {'Content-Type': 'text/html'}); + response.end('test data'); + + await new Promise((resolve) => setTimeout(resolve, 50)); + + const metadata = stream.getMetadata(); + expect(metadata.headers['content-encoding']).to.equal('gzip'); + }); + + it('should compress when using flushHeaders()', async () => { + const stream = createCollectingStream(); + const event = createMockEvent({headers: {'Accept-Encoding': 'gzip'}}); + const context = createMockContext(); + const request = createExpressRequest(event, context); + const response = createExpressResponse(stream, event, context, request); + + response.setHeader('Content-Type', 'text/html'); + response.flushHeaders(); + response.end('test data'); + + await new Promise((resolve) => setTimeout(resolve, 50)); + + const metadata = stream.getMetadata(); + expect(metadata.headers['content-encoding']).to.equal('gzip'); + }); + }); + + describe('CompressionConfig', () => { + describe('Encoding override', () => { + it('should use compressionConfig.encoding to override Accept-Encoding negotiation', async () => { + const stream = createCollectingStream(); + const event = createMockEvent({headers: {'Accept-Encoding': 'gzip'}}); + const context = createMockContext(); + const request = createExpressRequest(event, context); + const compressionConfig: CompressionConfig = { + enabled: true, + encoding: 'br', // But we override to use br + }; + const response = createExpressResponse(stream, event, context, request, compressionConfig); + + response.setHeader('Content-Type', 'text/html'); + response.end('test data'); + + await new Promise((resolve) => setTimeout(resolve, 50)); + + const metadata = stream.getMetadata(); + // Should use br from compressionConfig, not gzip from Accept-Encoding + expect(metadata.headers['content-encoding']).to.equal('br'); + }); + + it('should use compressionConfig.encoding even when client does not support it', async () => { + const stream = createCollectingStream(); + const event = createMockEvent({headers: {'Accept-Encoding': 'gzip'}}); + const context = createMockContext(); + const request = createExpressRequest(event, context); + const compressionConfig: CompressionConfig = { + enabled: true, + encoding: 'deflate', // But we override to use deflate + }; + const response = createExpressResponse(stream, event, context, request, compressionConfig); + + response.setHeader('Content-Type', 'text/html'); + response.end('test data'); + + await new Promise((resolve) => setTimeout(resolve, 50)); + + const metadata = stream.getMetadata(); + // Should use deflate from compressionConfig, not gzip from Accept-Encoding + expect(metadata.headers['content-encoding']).to.equal('deflate'); + }); + + it('should use compressionConfig.encoding when no Accept-Encoding header is present', async () => { + const stream = createCollectingStream(); + const event = createMockEvent(); // No Accept-Encoding header + const context = createMockContext(); + + const request = createExpressRequest(event, context); + const compressionConfig: CompressionConfig = { + enabled: true, + encoding: 'gzip', + }; + const response = createExpressResponse(stream, event, context, request, compressionConfig); + + response.setHeader('Content-Type', 'text/html'); + response.end('test data'); + + await new Promise((resolve) => setTimeout(resolve, 50)); + + const metadata = stream.getMetadata(); + // Should use gzip from compressionConfig even without Accept-Encoding header + expect(metadata.headers['content-encoding']).to.equal('gzip'); + }); + }); + + describe('Compression options', () => { + it('should pass compression options to gzip stream', async () => { + const stream = createCollectingStream(); + const event = createMockEvent({headers: {'Accept-Encoding': 'gzip'}}); + const context = createMockContext(); + const request = createExpressRequest(event, context); + const compressionConfig: CompressionConfig = { + enabled: true, + encoding: 'gzip', + options: { + level: 9, // Maximum compression + }, + }; + + // Spy on createGzip to verify options are passed + const createGzipStub = sinon.stub(zlib, 'createGzip').callThrough(); + + const response = createExpressResponse(stream, event, context, request, compressionConfig); + response.setHeader('Content-Type', 'text/html'); + response.end('test data'); + + await new Promise((resolve) => setTimeout(resolve, 50)); + + // Verify createGzip was called with the options + expect(createGzipStub.calledWith(compressionConfig.options)).to.be.true; + createGzipStub.restore(); + }); + + it('should pass compression options to brotli stream', async () => { + const stream = createCollectingStream(); + const event = createMockEvent({headers: {'Accept-Encoding': 'br'}}); + const context = createMockContext(); + const request = createExpressRequest(event, context); + const compressionConfig: CompressionConfig = { + enabled: true, + encoding: 'br', + options: { + params: { + [zlib.constants.BROTLI_PARAM_QUALITY]: 11, // Maximum quality + }, + }, + }; + + // Spy on createBrotliCompress to verify options are passed + const createBrotliStub = sinon.stub(zlib, 'createBrotliCompress').callThrough(); + + const response = createExpressResponse(stream, event, context, request, compressionConfig); + response.setHeader('Content-Type', 'text/html'); + response.end('test data'); + + await new Promise((resolve) => setTimeout(resolve, 50)); + + // Verify createBrotliCompress was called with the options + expect(createBrotliStub.calledWith(compressionConfig.options)).to.be.true; + createBrotliStub.restore(); + }); + + it('should pass compression options to deflate stream', async () => { + const stream = createCollectingStream(); + const event = createMockEvent({headers: {'Accept-Encoding': 'deflate'}}); + const context = createMockContext(); + const request = createExpressRequest(event, context); + const compressionConfig: CompressionConfig = { + enabled: true, + encoding: 'deflate', + options: { + level: 9, // Maximum compression + }, + }; + + // Spy on createDeflate to verify options are passed + const createDeflateStub = sinon.stub(zlib, 'createDeflate').callThrough(); + + const response = createExpressResponse(stream, event, context, request, compressionConfig); + response.setHeader('Content-Type', 'text/html'); + response.end('test data'); + + await new Promise((resolve) => setTimeout(resolve, 50)); + + // Verify createDeflate was called with the options + expect(createDeflateStub.calledWith(compressionConfig.options)).to.be.true; + createDeflateStub.restore(); + }); + + it('should work with compression options but no encoding override', async () => { + const stream = createCollectingStream(); + const event = createMockEvent({headers: {'Accept-Encoding': 'gzip'}}); + const context = createMockContext(); + const request = createExpressRequest(event, context); + const compressionConfig: CompressionConfig = { + enabled: true, + // No encoding override, should use Accept-Encoding negotiation + options: { + level: 6, + }, + }; + + const createGzipStub = sinon.stub(zlib, 'createGzip').callThrough(); + + const response = createExpressResponse(stream, event, context, request, compressionConfig); + response.setHeader('Content-Type', 'text/html'); + response.end('test data'); + + await new Promise((resolve) => setTimeout(resolve, 50)); + + const metadata = stream.getMetadata(); + // Should still use gzip from Accept-Encoding + expect(metadata.headers['content-encoding']).to.equal('gzip'); + // But with the custom options + expect(createGzipStub.calledWith(compressionConfig.options)).to.be.true; + createGzipStub.restore(); + }); + }); + + describe('CompressionConfig edge cases', () => { + it('should handle undefined compressionConfig', async () => { + const stream = createCollectingStream(); + const event = createMockEvent({headers: {'Accept-Encoding': 'gzip'}}); + const context = createMockContext(); + const request = createExpressRequest(event, context); + const response = createExpressResponse(stream, event, context, request, undefined); + + response.setHeader('Content-Type', 'text/html'); + response.end('test data'); + + await new Promise((resolve) => setTimeout(resolve, 50)); + + const metadata = stream.getMetadata(); + // Should work normally without compressionConfig + expect(metadata.headers['content-encoding']).to.equal('gzip'); + }); + + it('should handle empty compressionConfig', async () => { + const stream = createCollectingStream(); + const event = createMockEvent({headers: {'Accept-Encoding': 'gzip'}}); + const context = createMockContext(); + const request = createExpressRequest(event, context); + const compressionConfig: CompressionConfig = {enabled: true}; + const response = createExpressResponse(stream, event, context, request, compressionConfig); + + response.setHeader('Content-Type', 'text/html'); + response.end('test data'); + + await new Promise((resolve) => setTimeout(resolve, 50)); + + const metadata = stream.getMetadata(); + // Should work normally with empty compressionConfig + expect(metadata.headers['content-encoding']).to.equal('gzip'); + }); + + it('should handle compressionConfig with only options (no encoding)', async () => { + const stream = createCollectingStream(); + const event = createMockEvent({headers: {'Accept-Encoding': 'br'}}); + const context = createMockContext(); + const request = createExpressRequest(event, context); + const compressionConfig: CompressionConfig = { + enabled: true, + options: { + params: { + [zlib.constants.BROTLI_PARAM_QUALITY]: 5, + }, + }, + }; + + const createBrotliStub = sinon.stub(zlib, 'createBrotliCompress').callThrough(); + + const response = createExpressResponse(stream, event, context, request, compressionConfig); + response.setHeader('Content-Type', 'text/html'); + response.end('test data'); + + await new Promise((resolve) => setTimeout(resolve, 50)); + + const metadata = stream.getMetadata(); + // Should use br from Accept-Encoding + expect(metadata.headers['content-encoding']).to.equal('br'); + // But with custom options + expect(createBrotliStub.calledWith(compressionConfig.options)).to.be.true; + createBrotliStub.restore(); + }); + }); + + describe('Disabled compression', () => { + it('should disable compression when enabled is false', async () => { + const stream = createCollectingStream(); + const event = createMockEvent({headers: {'Accept-Encoding': 'gzip, br'}}); + const context = createMockContext(); + const request = createExpressRequest(event, context); + const compressionConfig: CompressionConfig = { + enabled: false, + }; + const response = createExpressResponse(stream, event, context, request, compressionConfig); + + response.setHeader('Content-Type', 'text/html'); + const testData = 'This is a test string that should NOT be compressed. '.repeat(100); + response.end(testData); + + await new Promise((resolve) => setTimeout(resolve, 50)); + + const metadata = stream.getMetadata(); + // Should NOT have content-encoding header + expect(metadata.headers['content-encoding']).to.be.undefined; + + // Verify data is not compressed (should be larger or same size) + const data = stream.getData(); + // The data should be the original text, not compressed + expect(data.toString()).to.include('This is a test string'); + }); + + it('should disable compression even when client supports compression', async () => { + const stream = createCollectingStream(); + const event = createMockEvent({headers: {'Accept-Encoding': 'gzip, br, deflate'}}); + const context = createMockContext(); + const request = createExpressRequest(event, context); + const compressionConfig: CompressionConfig = { + enabled: false, + }; + const response = createExpressResponse(stream, event, context, request, compressionConfig); + + response.setHeader('Content-Type', 'application/json'); + response.end(JSON.stringify({message: 'test', data: 'x'.repeat(1000)})); + + await new Promise((resolve) => setTimeout(resolve, 50)); + + const metadata = stream.getMetadata(); + // Should NOT have content-encoding header + expect(metadata.headers['content-encoding']).to.be.undefined; + }); + + it('should disable compression when enabled is false without Accept-Encoding header', async () => { + const stream = createCollectingStream(); + const event = createMockEvent(); // No Accept-Encoding header + const context = createMockContext(); + + const request = createExpressRequest(event, context); + const compressionConfig: CompressionConfig = { + enabled: false, + }; + const response = createExpressResponse(stream, event, context, request, compressionConfig); + + response.setHeader('Content-Type', 'text/html'); + response.end('test data'); + + await new Promise((resolve) => setTimeout(resolve, 50)); + + const metadata = stream.getMetadata(); + // Should NOT have content-encoding header + expect(metadata.headers['content-encoding']).to.be.undefined; + }); + }); + }); +}); diff --git a/packages/mrt-utilities/test/streaming/create-lambda-adapter.test.ts b/packages/mrt-utilities/test/streaming/create-lambda-adapter.test.ts new file mode 100644 index 00000000..b83acd8c --- /dev/null +++ b/packages/mrt-utilities/test/streaming/create-lambda-adapter.test.ts @@ -0,0 +1,2532 @@ +/* + * Copyright (c) 2025, Salesforce, Inc. + * SPDX-License-Identifier: Apache-2 + * For full license text, see the license.txt file in the repo root or http://www.apache.org/licenses/LICENSE-2.0 + */ + +import type {APIGatewayProxyEvent, Context} from 'aws-lambda'; +import {PassThrough, type Writable} from 'stream'; +import {EventEmitter} from 'events'; +import express, {type Express} from 'express'; +import {expect} from 'chai'; +import sinon from 'sinon'; +import { + createStreamingLambdaAdapter, + createExpressRequest, + createExpressResponse, +} from '@salesforce/mrt-utilities/streaming'; + +// Mock awslambda global +const mockHttpResponseStream = { + from: sinon + .stub() + .callsFake( + (stream: Writable, _metadata: {statusCode: number; headers: Record; cookies?: string[]}) => { + return stream; + }, + ), +}; + +// Mocking global awslambda for testing + +(globalThis as any).awslambda = { + HttpResponseStream: mockHttpResponseStream, +}; + +// Mock stream type with Sinon stubs so assertions (e.g. .called, .calledWith) type-check +type MockWritable = (Writable & EventEmitter) & { + write: sinon.SinonStub; + end: sinon.SinonStub; + destroy: sinon.SinonStub; + flush: sinon.SinonStub; +}; + +// Helper to create a mock Writable stream +function createMockWritable(): MockWritable { + const stream = new EventEmitter() as any; + const chunks: Buffer[] = []; + let ended = false; + let destroyed = false; + + stream.writable = true; + stream.writableEnded = false; + stream.writableFinished = false; + stream.destroyed = false; + + stream.write = sinon.stub().callsFake((chunk: any) => { + if (destroyed || ended) return false; + chunks.push(Buffer.from(chunk)); + return true; + }); + + stream.end = sinon.stub().callsFake((chunk?: any) => { + if (destroyed) return stream; + if (chunk) { + chunks.push(Buffer.from(chunk)); + } + ended = true; + stream.writableEnded = true; + stream.writableFinished = true; + stream.emit('finish'); + return stream; + }); + + stream.destroy = sinon.stub().callsFake(() => { + destroyed = true; + stream.destroyed = true; + stream.writable = false; + stream.emit('close'); + return stream; + }); + + stream.flush = sinon.stub().callsFake(() => { + // Mock flush method + }); + + return stream as MockWritable; +} + +// Helper to create a mock API Gateway event +function createMockEvent(overrides?: Partial): APIGatewayProxyEvent { + return { + httpMethod: 'GET', + path: '/test', + pathParameters: null, + queryStringParameters: null, + headers: { + 'Content-Type': 'application/json', + Host: 'example.com', + }, + multiValueHeaders: {}, + body: null, + isBase64Encoded: false, + requestContext: { + requestId: 'test-request-id', + accountId: '123456789012', + apiId: 'test-api-id', + protocol: 'HTTP/1.1', + httpMethod: 'GET', + path: '/test', + stage: 'test', + requestTime: '09/Apr/2015:12:34:56 +0000', + requestTimeEpoch: 1428582896000, + identity: { + sourceIp: '127.0.0.1', + userAgent: 'test-agent', + accessKey: null, + accountId: null, + apiKey: null, + apiKeyId: null, + caller: null, + cognitoAuthenticationProvider: null, + cognitoAuthenticationType: null, + cognitoIdentityId: null, + cognitoIdentityPoolId: null, + principalOrgId: null, + user: null, + userArn: null, + clientCert: null, + }, + resourceId: 'test-resource-id', + resourcePath: '/test', + }, + ...overrides, + } as APIGatewayProxyEvent; +} + +// Helper to create a mock Lambda context +// Helper to create a collecting stream for compression tests +function createCollectingStream(): PassThrough & { + getData: () => Buffer; + getMetadata: () => any; + waitForEnd: () => Promise; +} { + const stream = new PassThrough(); + const chunks: Buffer[] = []; + + // Mark this as the original stream for metadata storage + (stream as any).__originalStream = stream; + + stream.on('data', (chunk: Buffer) => { + chunks.push(chunk); + }); + + return Object.assign(stream, { + getData: () => Buffer.concat(chunks), + getMetadata: () => (stream as any).__metadata, + waitForEnd: () => { + return new Promise((resolve) => { + if (stream.writableEnded) { + resolve(); + } else { + const timeout = setTimeout(resolve, 500); + stream.once('finish', () => { + clearTimeout(timeout); + resolve(); + }); + stream.once('end', () => { + clearTimeout(timeout); + resolve(); + }); + } + }); + }, + }); +} + +// Helper to create a request with Accept-Encoding header +function createRequestWithEncoding(acceptEncoding: string): ReturnType { + const event: APIGatewayProxyEvent = { + httpMethod: 'GET', + path: '/test', + pathParameters: null, + queryStringParameters: null, + multiValueQueryStringParameters: null, + headers: { + 'Accept-Encoding': acceptEncoding, + }, + multiValueHeaders: {}, + body: null, + isBase64Encoded: false, + requestContext: createMockEvent().requestContext, + resource: '/test', + stageVariables: null, + } as APIGatewayProxyEvent; + + const context: Context = { + callbackWaitsForEmptyEventLoop: false, + functionName: 'test-function', + functionVersion: '$LATEST', + invokedFunctionArn: 'arn:aws:lambda:us-east-1:123456789012:function:test-function', + memoryLimitInMB: '128', + awsRequestId: 'test-request-id', + logGroupName: '/aws/lambda/test-function', + logStreamName: '2023/01/01/[$LATEST]test', + getRemainingTimeInMillis: () => 30000, + done: () => {}, + fail: () => {}, + succeed: () => {}, + }; + + return createExpressRequest(event, context); +} + +function createMockContext(overrides?: Partial): Context { + return { + callbackWaitsForEmptyEventLoop: false, + functionName: 'test-function', + functionVersion: '$LATEST', + invokedFunctionArn: 'arn:aws:lambda:us-east-1:123456789012:function:test-function', + memoryLimitInMB: '128', + awsRequestId: 'test-request-id', + logGroupName: '/aws/lambda/test-function', + logStreamName: '2024/01/01/[$LATEST]test', + getRemainingTimeInMillis: () => 30000, + done: sinon.stub(), + fail: sinon.stub(), + succeed: sinon.stub(), + ...overrides, + } as Context; +} + +describe('create-lambda-adapter', () => { + let mockResponseStream: MockWritable; + let mockApp: Express; + + beforeEach(() => { + (globalThis as any).awslambda = {HttpResponseStream: mockHttpResponseStream}; + mockResponseStream = createMockWritable(); + mockApp = express(); + mockHttpResponseStream.from.resetHistory(); + mockHttpResponseStream.from.callsFake((stream) => stream); + }); + + afterEach(() => { + mockHttpResponseStream.from.resetHistory(); + }); + + describe('createStreamingLambdaAdapter', () => { + it('should create a handler function', () => { + const handler = createStreamingLambdaAdapter(mockApp, mockResponseStream); + expect(typeof handler).to.equal('function'); + }); + + it('should handle successful request', async function () { + this.timeout(10000); + mockApp.get('/test', (req, res) => { + res.status(200).json({message: 'success'}); + }); + + const handler = createStreamingLambdaAdapter(mockApp, mockResponseStream); + const event = createMockEvent({path: '/test'}); + const context = createMockContext(); + + await handler(event, context); + + // Response should have been written and ended + expect(mockResponseStream.write.called).to.be.true; + expect(mockResponseStream.end.called).to.be.true; + }); + + it('should handle errors and write error response', async () => { + // Create an app that throws an error synchronously + mockApp.get('/test', () => { + throw new Error('Test error'); + }); + + const handler = createStreamingLambdaAdapter(mockApp, mockResponseStream); + const event = createMockEvent({path: '/test'}); + const context = createMockContext(); + + await handler(event, context); + + expect(mockResponseStream.write.firstCall.args[0]).to.include('500 Internal Server Error'); + expect(mockResponseStream.end.called).to.be.true; + }); + + it('should handle non-Error objects thrown', async () => { + mockApp.get('/test', () => { + throw new Error('String error'); + }); + + const handler = createStreamingLambdaAdapter(mockApp, mockResponseStream); + const event = createMockEvent({path: '/test'}); + const context = createMockContext(); + + await handler(event, context); + + expect(mockResponseStream.write.firstCall.args[0]).to.include('500 Internal Server Error'); + expect(mockResponseStream.end.called).to.be.true; + }); + + it('should handle closed stream in error handler', async () => { + mockApp.get('/test', () => { + throw new Error('Test error'); + }); + + const closedStream = createMockWritable(); + (closedStream as any).writable = false; + (closedStream as any).destroyed = true; + + const handler = createStreamingLambdaAdapter(mockApp, closedStream); + const event = createMockEvent({path: '/test'}); + const context = createMockContext(); + + await handler(event, context); + + // Should not throw, even with closed stream + expect(closedStream.write.called).to.be.false; + }); + + it('should handle stream without write method', async () => { + mockApp.get('/test', () => { + throw new Error('Test error'); + }); + + const streamWithoutWrite = createMockWritable(); + delete (streamWithoutWrite as any).write; + + const handler = createStreamingLambdaAdapter(mockApp, streamWithoutWrite); + const event = createMockEvent({path: '/test'}); + const context = createMockContext(); + + await handler(event, context); + + // Should not throw + expect(streamWithoutWrite.end.called).to.be.true; + }); + + it('should handle stream without end method in finally', async () => { + mockApp.get('/test', (req, res) => { + res.status(200).send('OK'); + }); + + const streamWithoutEnd = createMockWritable(); + delete (streamWithoutEnd as any).end; + + const handler = createStreamingLambdaAdapter(mockApp, streamWithoutEnd); + const event = createMockEvent({path: '/test'}); + const context = createMockContext(); + + await handler(event, context); + + // Should not throw + expect(streamWithoutEnd.write.called).to.be.true; + }); + }); + + describe('createExpressRequest', () => { + it('should create Express-like request object', () => { + const event = createMockEvent(); + const context = createMockContext(); + const req = createExpressRequest(event, context); + + expect(req.method).to.equal('GET'); + expect(req.url).to.equal('/test'); + expect(req.headers).to.exist; + // ServerlessRequest doesn't expose path, query, params, or apiGateway directly + // These are handled by Express middleware + }); + + it('should decode base64 encoded body', () => { + const body = Buffer.from('test body').toString('base64'); + const event = createMockEvent({ + body, + isBase64Encoded: true, + }); + const context = createMockContext(); + const req = createExpressRequest(event, context); + + // ServerlessRequest returns body as Buffer + expect(Buffer.isBuffer(req.body)).to.equal(true); + expect(req.body.toString('utf-8')).to.equal('test body'); + }); + + it('should handle query string parameters', () => { + const event = createMockEvent({ + queryStringParameters: { + foo: 'bar', + baz: 'qux', + }, + }); + const context = createMockContext(); + const req = createExpressRequest(event, context); + + // Query parameters are in the URL, Express will parse them + expect(req.url).to.include('foo=bar'); + expect(req.url).to.include('baz=qux'); + }); + + it('should handle path parameters', () => { + const event = createMockEvent({ + pathParameters: { + id: '123', + }, + }); + const context = createMockContext(); + const req = createExpressRequest(event, context); + + // Path parameters are handled by Express routing, not directly on request + expect(req.url).to.exist; + }); + + it('should set protocol from X-Forwarded-Proto header', () => { + const event = createMockEvent({ + headers: { + 'X-Forwarded-Proto': 'http', + }, + }); + const context = createMockContext(); + const req = createExpressRequest(event, context); + + // ServerlessRequest doesn't expose protocol directly + // It's available via headers if needed + expect(req.headers['x-forwarded-proto']).to.equal('http'); + }); + + it('should default to https protocol', () => { + const event = createMockEvent({ + headers: {}, + }); + const context = createMockContext(); + const req = createExpressRequest(event, context); + + // ServerlessRequest doesn't expose protocol directly + // Without X-Forwarded-Proto header, protocol is not set + expect(req.headers['x-forwarded-proto']).to.be.undefined; + }); + + it('should set hostname from Host header', () => { + const event = createMockEvent({ + headers: { + Host: 'example.com', + }, + }); + const context = createMockContext(); + const req = createExpressRequest(event, context); + + // ServerlessRequest doesn't expose hostname directly + // It's available via headers + expect(req.headers.host).to.equal('example.com'); + }); + + it('should set IP from X-Forwarded-For header', () => { + const event = createMockEvent({ + headers: { + 'X-Forwarded-For': '192.168.1.1, 10.0.0.1', + }, + }); + const context = createMockContext(); + const req = createExpressRequest(event, context); + + // ServerlessRequest uses remoteAddress, which comes from requestContext.identity.sourceIp + // X-Forwarded-For is in headers but remoteAddress is set from sourceIp + expect(req.headers['x-forwarded-for']).to.equal('192.168.1.1, 10.0.0.1'); + }); + + it('should implement get method for headers', () => { + const event = createMockEvent({ + headers: { + 'Content-Type': 'application/json', + }, + }); + const context = createMockContext(); + const req = createExpressRequest(event, context); + + expect(req.get('Content-Type')).to.equal('application/json'); + expect(req.get('content-type')).to.equal('application/json'); + expect(req.header('Content-Type')).to.equal('application/json'); + }); + + it('should handle missing headers', () => { + const event = createMockEvent({ + headers: null as any, + }); + const context = createMockContext(); + const req = createExpressRequest(event, context); + + expect(req.headers).to.deep.equal({}); + expect(req.get('Content-Type')).to.be.undefined; + }); + + it('should handle empty headers object', () => { + const event = createMockEvent({ + headers: {}, + }); + const context = createMockContext(); + const req = createExpressRequest(event, context); + + expect(req.headers).to.deep.equal({}); + expect(req.get('Any-Header')).to.be.undefined; + }); + + it('should handle headers with array values', () => { + const event = createMockEvent({ + headers: { + 'X-Custom': ['value1', 'value2'] as any, + }, + }); + const context = createMockContext(); + const req = createExpressRequest(event, context); + + // ServerlessRequest stores the value as-is (array) + // The get method returns the header value directly + const value = req.get('X-Custom'); + expect(Array.isArray(value)).to.equal(true); + expect(value).to.deep.equal(['value1', 'value2']); + }); + + it('should handle missing requestContext', () => { + const event = createMockEvent({ + requestContext: null, + } as any); + const context = createMockContext(); + const req = createExpressRequest(event, context); + + // ServerlessRequest uses remoteAddress which defaults to empty string + // We can't directly access it, but the request should still be created + expect(req.method).to.equal('GET'); + }); + + it('should handle missing identity in requestContext', () => { + const event = createMockEvent({ + requestContext: { + identity: null, + } as any, + }); + const context = createMockContext(); + const req = createExpressRequest(event, context); + + // ServerlessRequest uses remoteAddress which defaults to empty string + // We can't directly access it, but the request should still be created + expect(req.method).to.equal('GET'); + }); + + it('should handle empty query string parameters', () => { + const event = createMockEvent({ + queryStringParameters: {}, + }); + const context = createMockContext(); + const req = createExpressRequest(event, context); + + // ServerlessRequest doesn't expose query directly + // Empty query string parameters should not add '?' to URL + expect(req.url).to.equal('/test'); + }); + + it('should handle null body', () => { + const event = createMockEvent({ + body: null, + }); + const context = createMockContext(); + const req = createExpressRequest(event, context); + + // When body is null, requestBody is undefined, so req.body may be undefined + // or ServerlessRequest may convert it to an empty Buffer + expect(req.body === undefined || Buffer.isBuffer(req.body)).to.equal(true); + if (Buffer.isBuffer(req.body)) { + expect(req.body.length).to.equal(0); + } + }); + + it('should handle body without base64 encoding', () => { + const event = createMockEvent({ + body: 'plain text body', + isBase64Encoded: false, + }); + const context = createMockContext(); + const req = createExpressRequest(event, context); + + // ServerlessRequest returns body as Buffer + expect(Buffer.isBuffer(req.body)).to.equal(true); + expect(req.body.toString('utf-8')).to.equal('plain text body'); + }); + }); + + describe('createExpressResponse', () => { + describe('writeHead', () => { + it('should set status code and headers', () => { + const event = createMockEvent({httpMethod: 'GET'}); + const context = createMockContext(); + const res = createExpressResponse(mockResponseStream, event, context); + res.writeHead(200, {'Content-Type': 'text/plain'}); + + expect(res.statusCode).to.equal(200); + expect(mockHttpResponseStream.from.called).to.be.true; + expect(res.headersSent).to.be.true; + }); + + it('should handle status message', () => { + const event = createMockEvent({httpMethod: 'GET'}); + const context = createMockContext(); + const res = createExpressResponse(mockResponseStream, event, context); + res.writeHead(404, 'Not Found'); + + expect(res.statusCode).to.equal(404); + expect(res.statusMessage).to.equal('Not Found'); + }); + + it('should handle object as second parameter', () => { + const event = createMockEvent({httpMethod: 'GET'}); + const context = createMockContext(); + const res = createExpressResponse(mockResponseStream, event, context); + res.writeHead(200, {'Content-Type': 'application/json'}); + + expect(res.statusCode).to.equal(200); + }); + + it('should handle writeHead with only status code', () => { + const event = createMockEvent({httpMethod: 'GET'}); + const context = createMockContext(); + const res = createExpressResponse(mockResponseStream, event, context); + res.writeHead(201); + + expect(res.statusCode).to.equal(201); + expect(mockHttpResponseStream.from.called).to.be.true; + }); + + it('should handle writeHead with status code and status message', () => { + const event = createMockEvent({httpMethod: 'GET'}); + const context = createMockContext(); + const res = createExpressResponse(mockResponseStream, event, context); + res.writeHead(500, 'Internal Server Error', {'X-Custom': 'value'}); + + expect(res.statusCode).to.equal(500); + expect(res.statusMessage).to.equal('Internal Server Error'); + expect(res.getHeader('X-Custom')).to.equal('value'); + }); + + it('should not send headers twice if writeHead called multiple times', () => { + const event = createMockEvent({httpMethod: 'GET'}); + const context = createMockContext(); + const res = createExpressResponse(mockResponseStream, event, context); + res.writeHead(200); + const firstCallCount = mockHttpResponseStream.from.callCount; + res.writeHead(201); + + // Should only call from once (headers already sent) + expect(mockHttpResponseStream.from.callCount).to.equal(firstCallCount); + }); + + it('should handle writeHead with array header values', () => { + const event = createMockEvent({httpMethod: 'GET'}); + const context = createMockContext(); + const res = createExpressResponse(mockResponseStream, event, context); + res.writeHead(200, {'X-Custom': ['value1', 'value2']}); + + expect(res.statusCode).to.equal(200); + expect(mockHttpResponseStream.from.called).to.be.true; + }); + }); + + describe('write', () => { + it('should write chunk to stream', () => { + const event = createMockEvent({httpMethod: 'GET'}); + const context = createMockContext(); + const res = createExpressResponse(mockResponseStream, event, context); + const result = res.write('test'); + + expect(result).to.be.true; + expect(mockResponseStream.write.calledWith('test')).to.be.true; + }); + + it('should auto-send headers on first write', () => { + const event = createMockEvent({httpMethod: 'GET'}); + const context = createMockContext(); + const res = createExpressResponse(mockResponseStream, event, context); + res.write('test'); + + expect(mockHttpResponseStream.from.called).to.be.true; + expect(res.headersSent).to.be.true; + }); + + it('should handle Buffer chunks', () => { + const event = createMockEvent({httpMethod: 'GET'}); + const context = createMockContext(); + const res = createExpressResponse(mockResponseStream, event, context); + const buffer = Buffer.from('test'); + res.write(buffer); + + expect(mockResponseStream.write.calledWith(buffer)).to.be.true; + }); + + it('should handle multiple writes', () => { + const event = createMockEvent({httpMethod: 'GET'}); + const context = createMockContext(); + const res = createExpressResponse(mockResponseStream, event, context); + res.write('chunk1'); + res.write('chunk2'); + res.write('chunk3'); + + expect(mockResponseStream.write.callCount).to.equal(3); + expect(mockResponseStream.write.getCall(1 - 1).args).to.deep.equal(['chunk1']); + expect(mockResponseStream.write.getCall(2 - 1).args).to.deep.equal(['chunk2']); + expect(mockResponseStream.write.getCall(3 - 1).args).to.deep.equal(['chunk3']); + }); + + it('should handle empty string chunk', () => { + const event = createMockEvent({httpMethod: 'GET'}); + const context = createMockContext(); + const res = createExpressResponse(mockResponseStream, event, context); + const result = res.write(''); + + // Empty strings should be written + expect(result).to.be.true; + expect(mockResponseStream.write.calledWith('')).to.be.true; + }); + + it('should handle Uint8Array chunks', () => { + const event = createMockEvent({httpMethod: 'GET'}); + const context = createMockContext(); + const res = createExpressResponse(mockResponseStream, event, context); + const uint8Array = new Uint8Array([1, 2, 3, 4]); + res.write(uint8Array); + + expect(mockResponseStream.write.calledWith(uint8Array)).to.be.true; + }); + + it('should return false if stream write fails', () => { + const failingStream = createMockWritable(); + failingStream.write = sinon.stub().callsFake(() => { + throw new Error('Write failed'); + }); + + const event = createMockEvent({httpMethod: 'GET'}); + const context = createMockContext(); + const res = createExpressResponse(failingStream, event, context); + const result = res.write('test'); + + expect(result).to.be.false; + }); + + it('should handle write after headers are sent', () => { + const event = createMockEvent({httpMethod: 'GET'}); + const context = createMockContext(); + const res = createExpressResponse(mockResponseStream, event, context); + res.writeHead(200); + mockHttpResponseStream.from.resetHistory(); + + res.write('test'); + + // Should still write, but not call from again + expect(mockResponseStream.write.calledWith('test')).to.be.true; + expect(mockHttpResponseStream.from.called).to.be.false; + }); + }); + + describe('end', () => { + it('should end stream', () => { + const event = createMockEvent({httpMethod: 'GET'}); + const context = createMockContext(); + const res = createExpressResponse(mockResponseStream, event, context); + res.end(); + + expect(mockResponseStream.end.called).to.be.true; + expect(res.finished).to.be.true; + }); + + it('should write final chunk before ending', () => { + const event = createMockEvent({httpMethod: 'GET'}); + const context = createMockContext(); + const res = createExpressResponse(mockResponseStream, event, context); + res.end('final'); + + expect(mockResponseStream.write.calledWith('final')).to.be.true; + expect(mockResponseStream.end.called).to.be.true; + }); + + it('should auto-send headers on end', () => { + const event = createMockEvent({httpMethod: 'GET'}); + const context = createMockContext(); + const res = createExpressResponse(mockResponseStream, event, context); + res.end(); + + expect(mockHttpResponseStream.from.called).to.be.true; + expect(res.headersSent).to.be.true; + }); + + it('should emit finish event', () => { + const event = createMockEvent({httpMethod: 'GET'}); + const context = createMockContext(); + const res = createExpressResponse(mockResponseStream, event, context); + const finishSpy = sinon.stub(); + res.on('finish', finishSpy); + res.end(); + + expect(finishSpy.called).to.be.true; + }); + + it('should handle end with Buffer chunk', () => { + const event = createMockEvent({httpMethod: 'GET'}); + const context = createMockContext(); + const res = createExpressResponse(mockResponseStream, event, context); + const buffer = Buffer.from('final'); + res.end(buffer); + + expect(mockResponseStream.write.calledWith(buffer)).to.be.true; + expect(mockResponseStream.end.called).to.be.true; + }); + + it('should handle end with empty string', () => { + const event = createMockEvent({httpMethod: 'GET'}); + const context = createMockContext(); + const res = createExpressResponse(mockResponseStream, event, context); + res.end(''); + + // Empty strings should be written + expect(mockResponseStream.write.calledWith('')).to.be.true; + expect(mockResponseStream.end.called).to.be.true; + }); + + it('should handle end after write', () => { + const event = createMockEvent({httpMethod: 'GET'}); + const context = createMockContext(); + const res = createExpressResponse(mockResponseStream, event, context); + res.write('chunk1'); + res.end('chunk2'); + + expect(mockResponseStream.write.callCount).to.equal(2); + expect(mockResponseStream.end.called).to.be.true; + }); + + it('should handle end error gracefully', () => { + const failingStream = createMockWritable(); + failingStream.end = sinon.stub().callsFake(() => { + throw new Error('End failed'); + }); + + const event = createMockEvent({httpMethod: 'GET'}); + const context = createMockContext(); + const res = createExpressResponse(failingStream, event, context); + const result = res.end(); + + expect(result).to.equal(res); + expect(res.finished).to.be.true; + }); + }); + + describe('status', () => { + it('should set status code', () => { + const event = createMockEvent({httpMethod: 'GET'}); + const context = createMockContext(); + const res = createExpressResponse(mockResponseStream, event, context); + const result = res.status(404); + + expect(res.statusCode).to.equal(404); + expect(result).to.equal(res); + }); + + it('should set status message', () => { + const event = createMockEvent({httpMethod: 'GET'}); + const context = createMockContext(); + const res = createExpressResponse(mockResponseStream, event, context); + // @ts-expect-error - ExpressResponse type doesn't include the message parameter, but our implementation supports it + res.status(404, 'Not Found'); + + expect(res.statusCode).to.equal(404); + expect(res.statusMessage).to.equal('Not Found'); + }); + }); + + describe('set', () => { + it('should set single header', () => { + const event = createMockEvent({httpMethod: 'GET'}); + const context = createMockContext(); + const res = createExpressResponse(mockResponseStream, event, context); + const result = res.set('Content-Type', 'application/json'); + + expect(res.getHeader('Content-Type')).to.equal('application/json'); + expect(result).to.equal(res); + }); + + it('should set multiple headers from object', () => { + const event = createMockEvent({httpMethod: 'GET'}); + const context = createMockContext(); + const res = createExpressResponse(mockResponseStream, event, context); + res.set({ + 'Content-Type': 'application/json', + 'X-Custom': 'value', + }); + + expect(res.getHeader('Content-Type')).to.equal('application/json'); + expect(res.getHeader('X-Custom')).to.equal('value'); + }); + + it('should overwrite existing header', () => { + const event = createMockEvent({httpMethod: 'GET'}); + const context = createMockContext(); + const res = createExpressResponse(mockResponseStream, event, context); + res.set('X-Custom', 'value1'); + res.set('X-Custom', 'value2'); + + expect(res.getHeader('X-Custom')).to.equal('value2'); + }); + + it('should set header with array value', () => { + const event = createMockEvent({httpMethod: 'GET'}); + const context = createMockContext(); + const res = createExpressResponse(mockResponseStream, event, context); + res.set('X-Custom', ['value1', 'value2']); + + expect(res.getHeader('X-Custom')).to.deep.equal(['value1', 'value2']); + }); + + it('should handle setting undefined value', () => { + const event = createMockEvent({httpMethod: 'GET'}); + const context = createMockContext(); + const res = createExpressResponse(mockResponseStream, event, context); + res.set('X-Custom', 'value1'); + res.set('X-Custom', undefined as any); + + // Should not throw + expect(res.getHeader('X-Custom')).to.equal('value1'); + }); + }); + + describe('append', () => { + it('should append to existing header', () => { + const event = createMockEvent({httpMethod: 'GET'}); + const context = createMockContext(); + const res = createExpressResponse(mockResponseStream, event, context); + res.set('X-Custom', 'value1'); + res.append('X-Custom', 'value2'); + + const header = res.getHeader('X-Custom'); + expect(Array.isArray(header)).to.equal(true); + expect(header).to.include('value1'); + expect(header).to.include('value2'); + }); + + it('should set header if it does not exist', () => { + const event = createMockEvent({httpMethod: 'GET'}); + const context = createMockContext(); + const res = createExpressResponse(mockResponseStream, event, context); + res.append('X-Custom', 'value'); + + expect(res.getHeader('X-Custom')).to.equal('value'); + }); + + it('should append to existing array header', () => { + const event = createMockEvent({httpMethod: 'GET'}); + const context = createMockContext(); + const res = createExpressResponse(mockResponseStream, event, context); + res.set('X-Custom', ['value1', 'value2']); + res.append('X-Custom', 'value3'); + + const header = res.getHeader('X-Custom'); + expect(Array.isArray(header)).to.equal(true); + expect(header).to.include('value1'); + expect(header).to.include('value2'); + expect(header).to.include('value3'); + }); + + it('should append array to existing header', () => { + const event = createMockEvent({httpMethod: 'GET'}); + const context = createMockContext(); + const res = createExpressResponse(mockResponseStream, event, context); + res.set('X-Custom', 'value1'); + res.append('X-Custom', ['value2', 'value3']); + + const header = res.getHeader('X-Custom'); + expect(Array.isArray(header)).to.equal(true); + expect(header).to.include('value1'); + expect(header).to.include('value2'); + expect(header).to.include('value3'); + }); + }); + + describe('flushHeaders', () => { + it('should send headers immediately', () => { + const event = createMockEvent({httpMethod: 'GET'}); + const context = createMockContext(); + const res = createExpressResponse(mockResponseStream, event, context); + res.set('Content-Type', 'application/json'); + res.flushHeaders(); + + expect(mockHttpResponseStream.from.called).to.be.true; + expect(res.headersSent).to.be.true; + }); + + it('should not send headers twice', () => { + const event = createMockEvent({httpMethod: 'GET'}); + const context = createMockContext(); + const res = createExpressResponse(mockResponseStream, event, context); + res.flushHeaders(); + const firstCallCount = mockHttpResponseStream.from.callCount; + res.flushHeaders(); + + expect(mockHttpResponseStream.from.callCount).to.equal(firstCallCount); + }); + + it('should include all set headers', () => { + const event = createMockEvent({httpMethod: 'GET'}); + const context = createMockContext(); + const res = createExpressResponse(mockResponseStream, event, context); + res.set('Content-Type', 'application/json'); + res.set('X-Custom', 'value'); + res.status(201); + + // Verify headers are set on the response object + expect(res.getHeader('Content-Type')).to.equal('application/json'); + expect(res.getHeader('X-Custom')).to.equal('value'); + + res.flushHeaders(); + + expect(mockHttpResponseStream.from.called).to.be.true; + const metadata = mockHttpResponseStream.from.getCall(0).args[1]; + expect(metadata.statusCode).to.equal(201); + // Headers should be included in metadata (case-insensitive check) + const headers = metadata.headers; + expect(headers['content-type'] || headers['Content-Type']).to.equal('application/json'); + expect(headers['x-custom'] || headers['X-Custom']).to.equal('value'); + }); + }); + + describe('json', () => { + it('should send JSON response', () => { + const event = createMockEvent({httpMethod: 'GET'}); + const context = createMockContext(); + const res = createExpressResponse(mockResponseStream, event, context); + res.json({message: 'test'}); + + expect(res.getHeader('Content-Type')).to.equal('application/json'); + expect(mockResponseStream.write.calledWith(JSON.stringify({message: 'test'}))).to.be.true; + expect(mockResponseStream.end.called).to.be.true; + }); + + it('should handle complex JSON objects', () => { + const event = createMockEvent({httpMethod: 'GET'}); + const context = createMockContext(); + const res = createExpressResponse(mockResponseStream, event, context); + const complexObj = { + nested: {value: 123}, + array: [1, 2, 3], + string: 'test', + }; + res.json(complexObj); + + expect(mockResponseStream.write.calledWith(JSON.stringify(complexObj))).to.be.true; + }); + + it('should handle null JSON', () => { + const event = createMockEvent({httpMethod: 'GET'}); + const context = createMockContext(); + const res = createExpressResponse(mockResponseStream, event, context); + res.json(null); + + expect(mockResponseStream.write.calledWith('null')).to.be.true; + }); + + it('should handle array JSON', () => { + const event = createMockEvent({httpMethod: 'GET'}); + const context = createMockContext(); + const res = createExpressResponse(mockResponseStream, event, context); + res.json([1, 2, 3]); + + expect(mockResponseStream.write.calledWith(JSON.stringify([1, 2, 3]))).to.be.true; + }); + }); + + describe('send', () => { + it('should send string response', () => { + const event = createMockEvent({httpMethod: 'GET'}); + const context = createMockContext(); + const res = createExpressResponse(mockResponseStream, event, context); + res.send('test'); + + expect(mockResponseStream.write.calledWith('test')).to.be.true; + expect(mockResponseStream.end.called).to.be.true; + }); + + it('should send object as JSON', () => { + const event = createMockEvent({httpMethod: 'GET'}); + const context = createMockContext(); + const res = createExpressResponse(mockResponseStream, event, context); + res.send({message: 'test'}); + + expect(res.getHeader('Content-Type')).to.equal('application/json'); + expect(mockResponseStream.write.calledWith(JSON.stringify({message: 'test'}))).to.be.true; + }); + + it('should send empty string', () => { + const event = createMockEvent({httpMethod: 'GET'}); + const context = createMockContext(); + const res = createExpressResponse(mockResponseStream, event, context); + res.send(''); + + // Empty strings should be written + expect(mockResponseStream.write.calledWith('')).to.be.true; + expect(mockResponseStream.end.called).to.be.true; + }); + + it('should send number as string', () => { + const event = createMockEvent({httpMethod: 'GET'}); + const context = createMockContext(); + const res = createExpressResponse(mockResponseStream, event, context); + // send() converts numbers to strings + res.send(123 as any); + + // Numbers are converted to strings and sent + expect(mockResponseStream.write.calledWith('123')).to.be.true; + expect(mockResponseStream.end.called).to.be.true; + }); + }); + + describe('redirect', () => { + it('should redirect to URL', () => { + const event = createMockEvent({httpMethod: 'GET'}); + const context = createMockContext(); + const res = createExpressResponse(mockResponseStream, event, context); + res.redirect('https://example.com'); + + expect(res.statusCode).to.equal(302); + expect(res.getHeader('Location')).to.equal('https://example.com'); + expect(mockResponseStream.end.called).to.be.true; + }); + + it('should redirect to relative URL', () => { + const event = createMockEvent({httpMethod: 'GET'}); + const context = createMockContext(); + const res = createExpressResponse(mockResponseStream, event, context); + res.redirect('/other/path'); + + expect(res.statusCode).to.equal(302); + expect(res.getHeader('Location')).to.equal('/other/path'); + }); + }); + + describe('headersSent property', () => { + it('should be false initially', () => { + const event = createMockEvent({httpMethod: 'GET'}); + const context = createMockContext(); + const res = createExpressResponse(mockResponseStream, event, context); + expect(res.headersSent).to.be.false; + }); + + it('should be true after writeHead', () => { + const event = createMockEvent({httpMethod: 'GET'}); + const context = createMockContext(); + const res = createExpressResponse(mockResponseStream, event, context); + res.writeHead(200); + expect(res.headersSent).to.be.true; + }); + + it('should be true after write', () => { + const event = createMockEvent({httpMethod: 'GET'}); + const context = createMockContext(); + const res = createExpressResponse(mockResponseStream, event, context); + res.write('test'); + expect(res.headersSent).to.be.true; + }); + + it('should be true after end', () => { + const event = createMockEvent({httpMethod: 'GET'}); + const context = createMockContext(); + const res = createExpressResponse(mockResponseStream, event, context); + res.end(); + expect(res.headersSent).to.be.true; + }); + }); + + describe('flush', () => { + it('should flush stream if supported', () => { + const event = createMockEvent({httpMethod: 'GET'}); + const context = createMockContext(); + const res = createExpressResponse(mockResponseStream, event, context); + // @ts-expect-error - flush doesn't exist on ExpressResponse type, but we're adding it + res.flush(); + + expect((mockResponseStream as any).flush.called).to.be.true; + }); + + it('should auto-send headers on flush', () => { + const event = createMockEvent({httpMethod: 'GET'}); + const context = createMockContext(); + const res = createExpressResponse(mockResponseStream, event, context); + // @ts-expect-error - flush doesn't exist on ExpressResponse type, but we're adding it + res.flush(); + + expect(mockHttpResponseStream.from.called).to.be.true; + expect(res.headersSent).to.be.true; + }); + + it('should handle stream without flush method', () => { + const streamWithoutFlush = createMockWritable(); + delete (streamWithoutFlush as any).flush; + + const event = createMockEvent({httpMethod: 'GET'}); + const context = createMockContext(); + const res = createExpressResponse(streamWithoutFlush, event, context); + // @ts-expect-error - flush doesn't exist on ExpressResponse type, but we're adding it + const result = res.flush(); + + expect(result).to.equal(res); + }); + + it('should handle flush error gracefully', () => { + const failingStream = createMockWritable(); + (failingStream as any).flush = sinon.stub().callsFake(() => { + throw new Error('Flush failed'); + }); + + const event = createMockEvent({httpMethod: 'GET'}); + const context = createMockContext(); + const res = createExpressResponse(failingStream, event, context); + // @ts-expect-error - flush doesn't exist on ExpressResponse type, but we're adding it + const result = res.flush(); + + expect(result).to.equal(res); + }); + }); + + describe('pipe', () => { + it('should pipe to destination', () => { + const destination = createMockWritable(); + const event = createMockEvent({httpMethod: 'GET'}); + const context = createMockContext(); + const res = createExpressResponse(mockResponseStream, event, context); + const result = res.pipe(destination); + + expect(result).to.equal(destination); + }); + + it('should auto-send headers on pipe', () => { + const destination = createMockWritable(); + const event = createMockEvent({httpMethod: 'GET'}); + const context = createMockContext(); + const res = createExpressResponse(mockResponseStream, event, context); + res.pipe(destination); + + expect(mockHttpResponseStream.from.called).to.be.true; + expect(res.headersSent).to.be.true; + }); + + it('should handle pipe with options', () => { + const destination = createMockWritable(); + const event = createMockEvent({httpMethod: 'GET'}); + const context = createMockContext(); + const res = createExpressResponse(mockResponseStream, event, context); + const result = res.pipe(destination, {end: false} as any); + + expect(result).to.equal(destination); + }); + }); + + describe('unpipe', () => { + it('should unpipe specific destination', () => { + const destination = createMockWritable(); + const event = createMockEvent({httpMethod: 'GET'}); + const context = createMockContext(); + const res = createExpressResponse(mockResponseStream, event, context); + res.pipe(destination); + // @ts-expect-error - unpipe doesn't exist on ExpressResponse type, but we're adding it + const result = res.unpipe(destination); + + expect(result).to.equal(res); + }); + + it('should unpipe all destinations', () => { + const destination1 = createMockWritable(); + const destination2 = createMockWritable(); + const event = createMockEvent({httpMethod: 'GET'}); + const context = createMockContext(); + const res = createExpressResponse(mockResponseStream, event, context); + res.pipe(destination1); + res.pipe(destination2); + // @ts-expect-error - unpipe doesn't exist on ExpressResponse type, but we're adding it + const result = res.unpipe(); + + expect(result).to.equal(res); + }); + + it('should handle unpipe when no destinations', () => { + const event = createMockEvent({httpMethod: 'GET'}); + const context = createMockContext(); + const res = createExpressResponse(mockResponseStream, event, context); + // @ts-expect-error - unpipe doesn't exist on ExpressResponse type, but we're adding it + const result = res.unpipe(); + + expect(result).to.equal(res); + }); + }); + + describe('status code handling', () => { + it('should default to 200 status code', () => { + const event = createMockEvent({httpMethod: 'GET'}); + const context = createMockContext(); + const res = createExpressResponse(mockResponseStream, event, context); + expect(res.statusCode).to.equal(200); + }); + + it('should update status code multiple times', () => { + const event = createMockEvent({httpMethod: 'GET'}); + const context = createMockContext(); + const res = createExpressResponse(mockResponseStream, event, context); + res.status(201); + expect(res.statusCode).to.equal(201); + res.status(404); + expect(res.statusCode).to.equal(404); + }); + + it('should preserve status code through writeHead', () => { + const event = createMockEvent({httpMethod: 'GET'}); + const context = createMockContext(); + const res = createExpressResponse(mockResponseStream, event, context); + res.status(201); + res.writeHead(200); + + expect(res.statusCode).to.equal(200); + }); + }); + + describe('header operations', () => { + it('should handle getHeader for non-existent header', () => { + const event = createMockEvent({httpMethod: 'GET'}); + const context = createMockContext(); + const res = createExpressResponse(mockResponseStream, event, context); + expect(res.getHeader('X-Non-Existent')).to.be.undefined; + }); + + it('should handle setHeader with number value', () => { + const event = createMockEvent({httpMethod: 'GET'}); + const context = createMockContext(); + const res = createExpressResponse(mockResponseStream, event, context); + res.setHeader('Content-Length', 123); + + expect(res.getHeader('Content-Length')).to.equal(123); + }); + + it('should handle multiple setHeader calls', () => { + const event = createMockEvent({httpMethod: 'GET'}); + const context = createMockContext(); + const res = createExpressResponse(mockResponseStream, event, context); + res.setHeader('X-Header1', 'value1'); + res.setHeader('X-Header2', 'value2'); + res.setHeader('X-Header3', 'value3'); + + expect(res.getHeader('X-Header1')).to.equal('value1'); + expect(res.getHeader('X-Header2')).to.equal('value2'); + expect(res.getHeader('X-Header3')).to.equal('value3'); + }); + }); + + describe('flushable property', () => { + it('should be set to true', () => { + const event = createMockEvent({httpMethod: 'GET'}); + const context = createMockContext(); + const res = createExpressResponse(mockResponseStream, event, context); + expect(res.flushable).to.be.true; + }); + }); + + describe('multi-value headers', () => { + it('should convert array headers to comma-separated strings in metadata', () => { + const event = createMockEvent({httpMethod: 'GET'}); + const context = createMockContext(); + const res = createExpressResponse(mockResponseStream, event, context); + res.setHeader('X-Multi-Value-Header', ['value1', 'value2', 'value3']); + res.end('test'); + + const metadata = mockHttpResponseStream.from.getCall(0).args[1]; + expect(metadata?.headers['x-multi-value-header']).to.equal('value1,value2,value3'); + }); + + it('should handle single value headers normally', () => { + const event = createMockEvent({httpMethod: 'GET'}); + const context = createMockContext(); + const res = createExpressResponse(mockResponseStream, event, context); + res.setHeader('X-Single-Header', 'value1'); + res.end('test'); + + const metadata = mockHttpResponseStream.from.getCall(0).args[1]; + expect(metadata?.headers['x-single-header']).to.equal('value1'); + }); + }); + + describe('cookies', () => { + it('should extract cookies from set-cookie header and add to metadata', () => { + const event = createMockEvent({httpMethod: 'GET'}); + const context = createMockContext(); + const res = createExpressResponse(mockResponseStream, event, context); + res.setHeader('Set-Cookie', ['cookie1=value1', 'cookie2=value2']); + res.end('test'); + + const metadata = mockHttpResponseStream.from.getCall(0).args[1] as { + cookies?: string[]; + headers: Record; + }; + expect(metadata?.cookies).to.deep.equal(['cookie1=value1', 'cookie2=value2']); + expect(metadata?.headers['set-cookie']).to.be.undefined; + }); + + it('should handle single cookie string', () => { + const event = createMockEvent({httpMethod: 'GET'}); + const context = createMockContext(); + const res = createExpressResponse(mockResponseStream, event, context); + res.setHeader('Set-Cookie', 'cookie1=value1'); + res.end('test'); + + const metadata = mockHttpResponseStream.from.getCall(0).args[1] as {cookies?: string[]}; + expect(metadata?.cookies).to.deep.equal(['cookie1=value1']); + }); + }); + + describe('request header copying', () => { + it('should copy x-correlation-id from request to response headers', () => { + const event = createMockEvent({ + httpMethod: 'GET', + headers: { + 'x-correlation-id': 'test-correlation-123', + }, + }); + const context = createMockContext(); + const req = createExpressRequest(event, context); + const res = createExpressResponse(mockResponseStream, event, context, req); + res.end('test'); + + const metadata = mockHttpResponseStream.from.getCall(0).args[1] as { + headers: Record; + }; + expect(metadata?.headers['x-correlation-id']).to.equal('test-correlation-123'); + }); + + it('should not include x-correlation-id in response headers when not present in request', () => { + const event = createMockEvent({ + httpMethod: 'GET', + headers: {}, + }); + const context = createMockContext(); + const req = createExpressRequest(event, context); + const res = createExpressResponse(mockResponseStream, event, context, req); + res.end('test'); + + const metadata = mockHttpResponseStream.from.getCall(0).args[1] as { + headers: Record; + }; + expect(metadata?.headers['x-correlation-id']).to.be.undefined; + }); + + it('should copy x-correlation-id when using writeHead', () => { + const event = createMockEvent({ + httpMethod: 'GET', + headers: { + 'x-correlation-id': 'correlation-456', + }, + }); + const context = createMockContext(); + const req = createExpressRequest(event, context); + const res = createExpressResponse(mockResponseStream, event, context, req); + res.writeHead(200); + res.end(); + + const metadata = mockHttpResponseStream.from.getCall(0).args[1] as { + headers: Record; + }; + expect(metadata?.headers['x-correlation-id']).to.equal('correlation-456'); + }); + + it('should copy x-correlation-id when using write', () => { + const event = createMockEvent({ + httpMethod: 'GET', + headers: { + 'x-correlation-id': 'correlation-789', + }, + }); + const context = createMockContext(); + const req = createExpressRequest(event, context); + const res = createExpressResponse(mockResponseStream, event, context, req); + res.write('chunk'); + res.end(); + + const metadata = mockHttpResponseStream.from.getCall(0).args[1] as { + headers: Record; + }; + expect(metadata?.headers['x-correlation-id']).to.equal('correlation-789'); + }); + + it('should copy x-correlation-id when using flushHeaders', () => { + const event = createMockEvent({ + httpMethod: 'GET', + headers: { + 'x-correlation-id': 'correlation-flush', + }, + }); + const context = createMockContext(); + const req = createExpressRequest(event, context); + const res = createExpressResponse(mockResponseStream, event, context, req); + res.flushHeaders(); + res.end(); + + const metadata = mockHttpResponseStream.from.getCall(0).args[1] as { + headers: Record; + }; + expect(metadata?.headers['x-correlation-id']).to.equal('correlation-flush'); + }); + + it('should handle x-correlation-id with case-insensitive matching', () => { + const event = createMockEvent({ + httpMethod: 'GET', + headers: { + 'X-Correlation-ID': 'correlation-case-test', + }, + }); + const context = createMockContext(); + const req = createExpressRequest(event, context); + const res = createExpressResponse(mockResponseStream, event, context, req); + res.end('test'); + + const metadata = mockHttpResponseStream.from.getCall(0).args[1] as { + headers: Record; + }; + expect(metadata?.headers['x-correlation-id']).to.equal('correlation-case-test'); + }); + + it('should overwrite x-correlation-id on response with value from request', () => { + const event = createMockEvent({ + httpMethod: 'GET', + headers: { + 'x-correlation-id': 'request-correlation', + }, + }); + const context = createMockContext(); + const req = createExpressRequest(event, context); + const res = createExpressResponse(mockResponseStream, event, context, req); + res.setHeader('x-correlation-id', 'response-correlation'); + res.end('test'); + + const metadata = mockHttpResponseStream.from.getCall(0).args[1] as { + headers: Record; + }; + // Request header should overwrite response header since request headers are copied after + // response headers are collected in initializeResponse + expect(metadata?.headers['x-correlation-id']).to.equal('request-correlation'); + }); + }); + }); + + describe('createExpressRequest', () => { + describe('multiValueHeaders processing', () => { + it('should handle multiValueHeaders with length > 1', () => { + const event: APIGatewayProxyEvent = { + httpMethod: 'GET', + path: '/test', + pathParameters: null, + queryStringParameters: null, + multiValueQueryStringParameters: null, + headers: {}, + multiValueHeaders: { + 'x-custom': ['value1', 'value2', 'value3'], // Use lowercase key + }, + body: null, + isBase64Encoded: false, + requestContext: createMockEvent().requestContext, + resource: '/test', + stageVariables: null, + } as APIGatewayProxyEvent; + + const context = createMockContext(); + const req = createExpressRequest(event, context); + // Should join multi-value headers (key is used as-is from multiValueHeaders) + expect(req.headers['x-custom']).to.equal('value1,value2,value3'); + }); + + it('should skip multiValueHeaders with length <= 1', () => { + const event: APIGatewayProxyEvent = { + httpMethod: 'GET', + path: '/test', + pathParameters: null, + queryStringParameters: null, + multiValueQueryStringParameters: null, + headers: {}, + multiValueHeaders: { + 'X-Custom': ['value1'], // Length is 1, should be skipped + }, + body: null, + isBase64Encoded: false, + requestContext: createMockEvent().requestContext, + resource: '/test', + stageVariables: null, + } as APIGatewayProxyEvent; + + const context = createMockContext(); + const req = createExpressRequest(event, context); + // Should not add header with length <= 1 + expect(req.headers['x-custom']).to.be.undefined; + }); + }); + + describe('query parameter merging', () => { + it('should handle duplicate values in merged query parameters', () => { + const event: APIGatewayProxyEvent = { + httpMethod: 'GET', + path: '/test', + pathParameters: null, + queryStringParameters: { + param1: 'value1', + }, + multiValueQueryStringParameters: { + param1: ['value1', 'value2'], // value1 is duplicate + }, + headers: {}, + multiValueHeaders: {}, + body: null, + isBase64Encoded: false, + requestContext: createMockEvent().requestContext, + resource: '/test', + stageVariables: null, + } as APIGatewayProxyEvent; + + const context = createMockContext(); + const req = createExpressRequest(event, context); + // Should not duplicate value1 + expect(req.url).to.include('param1=value1'); + expect(req.url).to.include('param1=value2'); + }); + + it('should merge single-value and multi-value query parameters', () => { + const event: APIGatewayProxyEvent = { + httpMethod: 'GET', + path: '/test', + pathParameters: null, + queryStringParameters: { + param1: 'value1', + param2: 'value2', + }, + multiValueQueryStringParameters: { + param1: ['value1', 'value3'], + param3: ['value4', 'value5'], + }, + headers: {}, + multiValueHeaders: {}, + body: null, + isBase64Encoded: false, + requestContext: createMockEvent().requestContext, + resource: '/test', + stageVariables: null, + } as APIGatewayProxyEvent; + + const context: Context = { + callbackWaitsForEmptyEventLoop: false, + functionName: 'test-function', + functionVersion: '$LATEST', + invokedFunctionArn: 'arn:aws:lambda:us-east-1:123456789012:function:test-function', + memoryLimitInMB: '128', + awsRequestId: 'test-request-id', + logGroupName: '/aws/lambda/test-function', + logStreamName: '2023/01/01/[$LATEST]test', + getRemainingTimeInMillis: () => 30000, + done: () => {}, + fail: () => {}, + succeed: () => {}, + }; + + const req = createExpressRequest(event, context); + // The URL should contain all query parameters + expect(req.url).to.include('param1'); + expect(req.url).to.include('param2'); + expect(req.url).to.include('param3'); + }); + + it('should handle only single-value query parameters', () => { + const event: APIGatewayProxyEvent = { + httpMethod: 'GET', + path: '/test', + pathParameters: null, + queryStringParameters: { + param1: 'value1', + param2: 'value2', + }, + multiValueQueryStringParameters: null, + headers: {}, + multiValueHeaders: {}, + body: null, + isBase64Encoded: false, + requestContext: createMockEvent().requestContext, + resource: '/test', + stageVariables: null, + } as APIGatewayProxyEvent; + + const context: Context = { + callbackWaitsForEmptyEventLoop: false, + functionName: 'test-function', + functionVersion: '$LATEST', + invokedFunctionArn: 'arn:aws:lambda:us-east-1:123456789012:function:test-function', + memoryLimitInMB: '128', + awsRequestId: 'test-request-id', + logGroupName: '/aws/lambda/test-function', + logStreamName: '2023/01/01/[$LATEST]test', + getRemainingTimeInMillis: () => 30000, + done: () => {}, + fail: () => {}, + succeed: () => {}, + }; + + const req = createExpressRequest(event, context); + expect(req.url).to.include('param1=value1'); + expect(req.url).to.include('param2=value2'); + }); + + it('should handle only multi-value query parameters', () => { + const event: APIGatewayProxyEvent = { + httpMethod: 'GET', + path: '/test', + pathParameters: null, + queryStringParameters: null, + multiValueQueryStringParameters: { + param1: ['value1', 'value2'], + }, + headers: {}, + multiValueHeaders: {}, + body: null, + isBase64Encoded: false, + requestContext: createMockEvent().requestContext, + resource: '/test', + stageVariables: null, + } as APIGatewayProxyEvent; + + const context: Context = { + callbackWaitsForEmptyEventLoop: false, + functionName: 'test-function', + functionVersion: '$LATEST', + invokedFunctionArn: 'arn:aws:lambda:us-east-1:123456789012:function:test-function', + memoryLimitInMB: '128', + awsRequestId: 'test-request-id', + logGroupName: '/aws/lambda/test-function', + logStreamName: '2023/01/01/[$LATEST]test', + getRemainingTimeInMillis: () => 30000, + done: () => {}, + fail: () => {}, + succeed: () => {}, + }; + + const req = createExpressRequest(event, context); + expect(req.url).to.include('param1=value1'); + expect(req.url).to.include('param1=value2'); + }); + + it('should handle path without query parameters', () => { + const event: APIGatewayProxyEvent = { + httpMethod: 'GET', + path: '/test', + pathParameters: null, + queryStringParameters: null, + multiValueQueryStringParameters: null, + headers: {}, + multiValueHeaders: {}, + body: null, + isBase64Encoded: false, + requestContext: createMockEvent().requestContext, + resource: '/test', + stageVariables: null, + } as APIGatewayProxyEvent; + + const context: Context = { + callbackWaitsForEmptyEventLoop: false, + functionName: 'test-function', + functionVersion: '$LATEST', + invokedFunctionArn: 'arn:aws:lambda:us-east-1:123456789012:function:test-function', + memoryLimitInMB: '128', + awsRequestId: 'test-request-id', + logGroupName: '/aws/lambda/test-function', + logStreamName: '2023/01/01/[$LATEST]test', + getRemainingTimeInMillis: () => 30000, + done: () => {}, + fail: () => {}, + succeed: () => {}, + }; + + const req = createExpressRequest(event, context); + expect(req.url).to.equal('/test'); + }); + }); + }); + + describe('Edge cases and error handling', () => { + describe('initializeResponse edge cases', () => { + it('should handle closed stream in initializeResponse', () => { + const closedStream = createMockWritable(); + (closedStream as any).writable = false; + (closedStream as any).destroyed = true; + + const event = createMockEvent({httpMethod: 'GET'}); + const context = createMockContext(); + const res = createExpressResponse(closedStream, event, context); + res.setHeader('Content-Type', 'text/html'); + res.write('test'); // This should trigger initializeResponse + + // Should not throw, even with closed stream + expect(mockHttpResponseStream.from.called).to.be.false; + }); + + it('should handle initializeResponse called multiple times', () => { + const event = createMockEvent({httpMethod: 'GET'}); + const context = createMockContext(); + const res = createExpressResponse(mockResponseStream, event, context); + res.setHeader('Content-Type', 'text/html'); + res.write('test'); + const firstCallCount = mockHttpResponseStream.from.callCount; + res.write('more'); // Second write should not re-initialize + + // Should only initialize once + expect(mockHttpResponseStream.from.callCount).to.equal(firstCallCount); + }); + }); + + describe('convertHeaders edge cases', () => { + it('should handle number header values', () => { + const event = createMockEvent({httpMethod: 'GET'}); + const context = createMockContext(); + const res = createExpressResponse(mockResponseStream, event, context); + res.setHeader('Content-Length', 123); + res.end('test'); + + const metadata = mockHttpResponseStream.from.getCall(0).args[1]; + // Content-Length should be removed for streaming responses + expect(metadata?.headers['content-length']).to.be.undefined; + }); + + it('should handle array header values in convertHeaders', () => { + const event = createMockEvent({httpMethod: 'GET'}); + const context = createMockContext(); + const res = createExpressResponse(mockResponseStream, event, context); + res.setHeader('X-Custom', ['value1', 'value2', 'value3']); + res.end('test'); + + const metadata = mockHttpResponseStream.from.getCall(0).args[1]; + expect(metadata?.headers['x-custom']).to.equal('value1,value2,value3'); + }); + + it('should skip undefined header values', () => { + const event = createMockEvent({httpMethod: 'GET'}); + const context = createMockContext(); + const res = createExpressResponse(mockResponseStream, event, context); + // Set a header then remove it + res.setHeader('X-Test', 'value'); + res.removeHeader('X-Test'); + res.end('test'); + + const metadata = mockHttpResponseStream.from.getCall(0).args[1]; + expect(metadata?.headers['x-test']).to.be.undefined; + }); + + it('should handle string header values', () => { + const event = createMockEvent({httpMethod: 'GET'}); + const context = createMockContext(); + const res = createExpressResponse(mockResponseStream, event, context); + res.setHeader('X-String', 'simple-value'); + res.end('test'); + + const metadata = mockHttpResponseStream.from.getCall(0).args[1]; + expect(metadata?.headers['x-string']).to.equal('simple-value'); + }); + }); + + describe('cookie handling edge cases', () => { + it('should handle single cookie string (not array)', () => { + const event = createMockEvent({httpMethod: 'GET'}); + const context = createMockContext(); + const res = createExpressResponse(mockResponseStream, event, context); + res.setHeader('Set-Cookie', 'single-cookie=value'); + res.end('test'); + + const metadata = mockHttpResponseStream.from.getCall(0).args[1] as { + cookies?: string[]; + headers: Record; + }; + expect(metadata?.cookies).to.deep.equal(['single-cookie=value']); + expect(metadata?.headers['set-cookie']).to.be.undefined; + }); + + it('should handle no cookies', () => { + const event = createMockEvent({httpMethod: 'GET'}); + const context = createMockContext(); + const res = createExpressResponse(mockResponseStream, event, context); + res.end('test'); + + const metadata = mockHttpResponseStream.from.getCall(0).args[1] as { + cookies?: string[]; + }; + expect(metadata?.cookies).to.be.undefined; + }); + }); + + describe('status message handling', () => { + it('should set status message when provided', () => { + const event = createMockEvent({httpMethod: 'GET'}); + const context = createMockContext(); + const res = createExpressResponse(mockResponseStream, event, context); + // @ts-expect-error - status method signature doesn't match ExpressResponse type exactly + res.status(404, 'Not Found'); + res.end('test'); + + expect(res.statusMessage).to.equal('Not Found'); + }); + + it('should not set status message when undefined', () => { + const event = createMockEvent({httpMethod: 'GET'}); + const context = createMockContext(); + const res = createExpressResponse(mockResponseStream, event, context); + res.status(200); + res.end('test'); + + expect(res.statusMessage).to.be.undefined; + }); + }); + + describe('res.set edge cases', () => { + it('should handle res.set with object', () => { + const event = createMockEvent({httpMethod: 'GET'}); + const context = createMockContext(); + const res = createExpressResponse(mockResponseStream, event, context); + res.set({ + 'X-Header1': 'value1', + 'X-Header2': 'value2', + }); + res.end('test'); + + expect(res.getHeader('X-Header1')).to.equal('value1'); + expect(res.getHeader('X-Header2')).to.equal('value2'); + }); + + it('should skip undefined values in res.set object', () => { + const event = createMockEvent({httpMethod: 'GET'}); + const context = createMockContext(); + const res = createExpressResponse(mockResponseStream, event, context); + res.set({ + 'X-Header1': 'value1', + 'X-Header2': undefined, + } as any); + res.end('test'); + + expect(res.getHeader('X-Header1')).to.equal('value1'); + expect(res.getHeader('X-Header2')).to.be.undefined; + }); + + it('should handle res.set with undefined value', () => { + const event = createMockEvent({httpMethod: 'GET'}); + const context = createMockContext(); + const res = createExpressResponse(mockResponseStream, event, context); + res.set('X-Header', undefined as any); + res.end('test'); + + expect(res.getHeader('X-Header')).to.be.undefined; + }); + }); + + describe('res.append edge cases', () => { + it('should append to existing array header', () => { + const event = createMockEvent({httpMethod: 'GET'}); + const context = createMockContext(); + const res = createExpressResponse(mockResponseStream, event, context); + res.setHeader('X-Header', ['value1', 'value2']); + res.append('X-Header', 'value3'); + res.end('test'); + + const header = res.getHeader('X-Header'); + expect(Array.isArray(header)).to.equal(true); + expect(header).to.include('value1'); + expect(header).to.include('value2'); + expect(header).to.include('value3'); + }); + + it('should append array to existing string header', () => { + const event = createMockEvent({httpMethod: 'GET'}); + const context = createMockContext(); + const res = createExpressResponse(mockResponseStream, event, context); + res.setHeader('X-Header', 'value1'); + res.append('X-Header', ['value2', 'value3']); + res.end('test'); + + const header = res.getHeader('X-Header'); + expect(Array.isArray(header)).to.equal(true); + expect(header).to.include('value1'); + expect(header).to.include('value2'); + expect(header).to.include('value3'); + }); + + it('should append string to existing string header', () => { + const event = createMockEvent({httpMethod: 'GET'}); + const context = createMockContext(); + const res = createExpressResponse(mockResponseStream, event, context); + res.setHeader('X-Header', 'value1'); + res.append('X-Header', 'value2'); + res.end('test'); + + const header = res.getHeader('X-Header'); + expect(Array.isArray(header)).to.equal(true); + expect(header).to.include('value1'); + expect(header).to.include('value2'); + }); + + it('should set header if it does not exist in append', () => { + const event = createMockEvent({httpMethod: 'GET'}); + const context = createMockContext(); + const res = createExpressResponse(mockResponseStream, event, context); + res.append('X-Header', 'value1'); + res.end('test'); + + expect(res.getHeader('X-Header')).to.equal('value1'); + }); + }); + + describe('pipeToDestination edge cases', () => { + it('should handle pipeToDestination with closed stream', async () => { + const closedStream = createMockWritable(); + (closedStream as any).writable = false; + const event = createMockEvent({httpMethod: 'GET'}); + const context = createMockContext(); + const res = createExpressResponse(closedStream, event, context); + const destination = createMockWritable(); + + res.pipe(destination); + + // Should handle gracefully + await new Promise((resolve) => setTimeout(resolve, 50)); + expect(destination.write.called).to.be.false; + }); + + it('should handle pipeToDestination with no source stream', async () => { + const event = createMockEvent({httpMethod: 'GET'}); + const context = createMockContext(); + const res = createExpressResponse(mockResponseStream, event, context); + const destination = createMockWritable(); + + // Try to pipe before initialization - this will initialize response + res.pipe(destination); + + await new Promise((resolve) => setTimeout(resolve, 50)); + // Should handle gracefully - httpResponseStream should be created + expect(mockHttpResponseStream.from.called).to.be.true; + }); + + it('should handle pipeToDestination pipeline error', async () => { + const event = createMockEvent({httpMethod: 'GET'}); + const context = createMockContext(); + const res = createExpressResponse(mockResponseStream, event, context); + const destination = createMockWritable(); + // Make destination throw an error + (destination as any).write = () => { + throw new Error('Pipeline error'); + }; + + res.setHeader('Content-Type', 'text/html'); + res.pipe(destination); + + await new Promise((resolve) => setTimeout(resolve, 50)); + // Should handle error gracefully + }); + }); + + describe('res.unpipe edge cases', () => { + it('should unpipe specific destination', () => { + const event = createMockEvent({httpMethod: 'GET'}); + const context = createMockContext(); + const res = createExpressResponse(mockResponseStream, event, context); + const dest1 = createMockWritable(); + const dest2 = createMockWritable(); + + res.pipe(dest1); + res.pipe(dest2); + // @ts-expect-error - unpipe doesn't exist on ExpressResponse type, but we're adding it + res.unpipe(dest1); + + // Should only have dest2 in piped destinations + // @ts-expect-error - unpipe doesn't exist on ExpressResponse type, but we're adding it + res.unpipe(dest2); + }); + + it('should unpipe all destinations', () => { + const event = createMockEvent({httpMethod: 'GET'}); + const context = createMockContext(); + const res = createExpressResponse(mockResponseStream, event, context); + const dest1 = createMockWritable(); + const dest2 = createMockWritable(); + + res.pipe(dest1); + res.pipe(dest2); + // @ts-expect-error - unpipe doesn't exist on ExpressResponse type, but we're adding it + res.unpipe(); + + // All destinations should be removed + }); + }); + + describe('writeChunk edge cases', () => { + it('should handle writeChunk with empty chunk', () => { + const event = createMockEvent({httpMethod: 'GET'}); + const context = createMockContext(); + const res = createExpressResponse(mockResponseStream, event, context); + res.setHeader('Content-Type', 'text/html'); + const result = res.write(''); + + expect(result).to.be.true; + }); + + it('should handle writeChunk with closed stream', () => { + const stream = createCollectingStream(); + const event = createMockEvent({httpMethod: 'GET'}); + const context = createMockContext(); + const response = createExpressResponse(stream, event, context); + + response.setHeader('Content-Type', 'text/html'); + response.write('test'); + // Close the stream + stream.destroy(); + + // Should handle gracefully + const result = response.write('more'); + expect(result).to.be.false; + }); + + it('should handle writeChunk when compression stream is not writable', async () => { + const stream = createCollectingStream(); + const request = createRequestWithEncoding('gzip'); + const event = createMockEvent({httpMethod: 'GET', headers: {'Accept-Encoding': 'gzip'}}); + const context = createMockContext(); + const response = createExpressResponse(stream, event, context, request); + + response.setHeader('Content-Type', 'text/html'); + // First write should succeed + const firstWrite = response.write('test'); + expect(firstWrite).to.be.true; + + // Write more data + response.write('more'); + + // End the response + response.end('done'); + + await stream.waitForEnd(); + await new Promise((resolve) => setTimeout(resolve, 50)); + + // Should have written data + expect(stream.getData().length).to.be.greaterThan(0); + }); + + it('should handle writeChunk when httpResponseStream is not writable', () => { + const stream = createCollectingStream(); + const event = createMockEvent({httpMethod: 'GET'}); + const context = createMockContext(); + const response = createExpressResponse(stream, event, context); + + response.setHeader('Content-Type', 'text/html'); + response.write('test'); + // Destroy stream + stream.destroy(); + + // Should handle gracefully + const result = response.write('more'); + expect(result).to.be.false; + }); + + it('should handle writeChunk when neither compression nor httpResponseStream is available', () => { + const closedStream = createMockWritable(); + (closedStream as any).writable = false; + (closedStream as any).destroyed = true; + const event = createMockEvent({httpMethod: 'GET'}); + const context = createMockContext(); + const response = createExpressResponse(closedStream, event, context); + + response.setHeader('Content-Type', 'text/html'); + // Should return false when stream is closed + const result = response.write('test'); + expect(result).to.be.false; + }); + + it('should handle writeChunk error gracefully', () => { + const event = createMockEvent({httpMethod: 'GET'}); + const context = createMockContext(); + const res = createExpressResponse(mockResponseStream, event, context); + res.setHeader('Content-Type', 'text/html'); + + // Mock write to throw an error + const originalWrite = mockResponseStream.write; + (mockResponseStream as any).write = sinon.stub().callsFake(() => { + throw new Error('Write error'); + }); + + // Should handle error gracefully + const result = res.write('test'); + expect(result).to.be.false; + + // Restore original write + mockResponseStream.write = originalWrite; + }); + }); + + describe('endStream edge cases', () => { + it('should handle endStream with compression stream error', () => { + const stream = createCollectingStream(); + const request = createRequestWithEncoding('gzip'); + const event = createMockEvent({httpMethod: 'GET', headers: {'Accept-Encoding': 'gzip'}}); + const context = createMockContext(); + const response = createExpressResponse(stream, event, context, request); + + response.setHeader('Content-Type', 'text/html'); + response.write('test'); + // End should handle compression stream errors gracefully + response.end('more'); + }); + + it('should handle endStream with httpResponseStream error', () => { + const stream = createCollectingStream(); + const event = createMockEvent({httpMethod: 'GET'}); + const context = createMockContext(); + const response = createExpressResponse(stream, event, context); + + response.setHeader('Content-Type', 'text/html'); + // End should handle httpResponseStream errors gracefully + response.end('test'); + }); + + it('should handle endStream when compression stream is not writable', () => { + const stream = createCollectingStream(); + const request = createRequestWithEncoding('gzip'); + const event = createMockEvent({httpMethod: 'GET', headers: {'Accept-Encoding': 'gzip'}}); + const context = createMockContext(); + const response = createExpressResponse(stream, event, context, request); + + response.setHeader('Content-Type', 'text/html'); + response.write('test'); + // Close the stream before ending + stream.destroy(); + // Should handle gracefully + response.end('more'); + }); + + it('should handle endStream when compression stream has flush method', () => { + const stream = createCollectingStream(); + const request = createRequestWithEncoding('gzip'); + const event = createMockEvent({httpMethod: 'GET', headers: {'Accept-Encoding': 'gzip'}}); + const context = createMockContext(); + const response = createExpressResponse(stream, event, context, request); + + response.setHeader('Content-Type', 'text/html'); + response.write('test'); + // End should call flush if available + response.end('more'); + }); + + it('should handle endStream when compression stream does not have flush method', () => { + const stream = createCollectingStream(); + const request = createRequestWithEncoding('deflate'); + const event = createMockEvent({httpMethod: 'GET', headers: {'Accept-Encoding': 'deflate'}}); + const context = createMockContext(); + const response = createExpressResponse(stream, event, context, request); + + response.setHeader('Content-Type', 'text/html'); + response.write('test'); + // End should work even without flush method + response.end('more'); + }); + }); + + describe('getBestEncoding edge cases', () => { + it('should handle getBestEncoding with compression disabled', () => { + const stream = createCollectingStream(); + const request = createRequestWithEncoding('gzip'); + const event = createMockEvent({httpMethod: 'GET', headers: {'Accept-Encoding': 'gzip'}}); + const context = createMockContext(); + const compressionConfig = { + enabled: false, + }; + const response = createExpressResponse(stream, event, context, request, compressionConfig); + + response.setHeader('Content-Type', 'text/html'); + response.end('test'); + + const metadata = stream.getMetadata(); + expect(metadata?.headers['content-encoding']).to.be.undefined; + }); + + it('should handle getBestEncoding with Accept-Encoding header', () => { + const event: APIGatewayProxyEvent = { + httpMethod: 'GET', + path: '/test', + pathParameters: null, + queryStringParameters: null, + multiValueQueryStringParameters: null, + headers: { + 'Accept-Encoding': 'br, gzip', // Put br first to ensure it's selected + }, + multiValueHeaders: {}, + body: null, + isBase64Encoded: false, + requestContext: createMockEvent().requestContext, + resource: '/test', + stageVariables: null, + } as APIGatewayProxyEvent; + + const context = createMockContext(); + const request = createExpressRequest(event, context); + const response = createExpressResponse(mockResponseStream, event, context, request); + + response.setHeader('Content-Type', 'text/html'); + response.end('test'); + + const metadata = mockHttpResponseStream.from.getCall(0).args[1]; + // Should prefer br over gzip based on preference order + expect(metadata?.headers['content-encoding']).to.equal('br'); + }); + }); + + describe('initializeCompression edge cases', () => { + it('should not initialize compression if already initialized', () => { + const stream = createCollectingStream(); + const request = createRequestWithEncoding('gzip'); + const event = createMockEvent({httpMethod: 'GET', headers: {'Accept-Encoding': 'gzip'}}); + const context = createMockContext(); + const response = createExpressResponse(stream, event, context, request); + + response.setHeader('Content-Type', 'text/html'); + response.write('test'); + // Second write should not re-initialize compression + response.write('more'); + response.end('done'); + }); + + it('should not initialize compression when enabled is false', () => { + const stream = createCollectingStream(); + const request = createRequestWithEncoding('gzip'); + const event = createMockEvent({httpMethod: 'GET', headers: {'Accept-Encoding': 'gzip'}}); + const context = createMockContext(); + const compressionConfig = { + enabled: false, + }; + const response = createExpressResponse(stream, event, context, request, compressionConfig); + + response.setHeader('Content-Type', 'text/html'); + response.end('test'); + + const metadata = stream.getMetadata(); + expect(metadata?.headers['content-encoding']).to.be.undefined; + }); + + it('should handle compression stream creation error', () => { + const stream = createCollectingStream(); + const request = createRequestWithEncoding('invalid-encoding'); + const event = createMockEvent({ + httpMethod: 'GET', + headers: {'Accept-Encoding': 'invalid-encoding'}, + }); + const context = createMockContext(); + // This should not crash, but handle gracefully + // Note: getBestEncoding will return null for invalid encoding + const response = createExpressResponse(stream, event, context, request); + + response.setHeader('Content-Type', 'text/html'); + response.end('test'); + }); + + it('should handle initializeCompression error during stream creation', () => { + const stream = createCollectingStream(); + const request = createRequestWithEncoding('gzip'); + const event = createMockEvent({httpMethod: 'GET', headers: {'Accept-Encoding': 'gzip'}}); + const context = createMockContext(); + const response = createExpressResponse(stream, event, context, request); + + response.setHeader('Content-Type', 'text/html'); + // Compression should initialize successfully + response.write('test'); + response.end('more'); + }); + + it('should handle compression stream error event', () => { + const stream = createCollectingStream(); + const request = createRequestWithEncoding('gzip'); + const event = createMockEvent({httpMethod: 'GET', headers: {'Accept-Encoding': 'gzip'}}); + const context = createMockContext(); + const response = createExpressResponse(stream, event, context, request); + + response.setHeader('Content-Type', 'text/html'); + response.write('test'); + // Compression stream error should be handled gracefully + response.end('more'); + }); + + it('should handle initializeCompression catch block', async () => { + // This is hard to test directly, but we can verify the error handling path exists + // by ensuring compression still works normally + const stream = createCollectingStream(); + + // Override the mock for this test to use the collecting stream mock + const originalFrom = (globalThis as any).awslambda.HttpResponseStream.from; + (globalThis as any).awslambda.HttpResponseStream.from = (s: Writable, m: any) => { + const originalStream = (s as any).__originalStream || s; + originalStream.__metadata = m; + const passThrough = new PassThrough(); + passThrough.pipe(s); + return passThrough; + }; + + const request = createRequestWithEncoding('gzip'); + const event = createMockEvent({httpMethod: 'GET', headers: {'Accept-Encoding': 'gzip'}}); + const context = createMockContext(); + const response = createExpressResponse(stream, event, context, request); + + response.setHeader('Content-Type', 'text/html'); + response.end('test'); + + await stream.waitForEnd(); + await new Promise((resolve) => setTimeout(resolve, 50)); + + // Compression should work normally + const metadata = stream.getMetadata(); + expect(metadata?.headers['content-encoding']).to.equal('gzip'); + + // Restore original mock + (globalThis as any).awslambda.HttpResponseStream.from = originalFrom; + }); + }); + + describe('writeChunk with backpressure', () => { + it('should handle writeChunk returning false (backpressure)', () => { + const stream = createCollectingStream(); + const request = createRequestWithEncoding('gzip'); + const event = createMockEvent({httpMethod: 'GET', headers: {'Accept-Encoding': 'gzip'}}); + const context = createMockContext(); + const response = createExpressResponse(stream, event, context, request); + + response.setHeader('Content-Type', 'text/html'); + // Write should handle backpressure + const result = response.write('test'); + expect(typeof result).to.equal('boolean'); + }); + }); + + describe('res.end with backpressure', () => { + it('should handle res.end with backpressure and wait for drain', () => { + const stream = createCollectingStream(); + const request = createRequestWithEncoding('gzip'); + const event = createMockEvent({httpMethod: 'GET', headers: {'Accept-Encoding': 'gzip'}}); + const context = createMockContext(); + const response = createExpressResponse(stream, event, context, request); + + response.setHeader('Content-Type', 'text/html'); + // End with chunk should handle backpressure + response.end('test'); + }); + + it('should handle res.end without chunk', () => { + const event = createMockEvent({httpMethod: 'GET'}); + const context = createMockContext(); + const res = createExpressResponse(mockResponseStream, event, context); + res.setHeader('Content-Type', 'text/html'); + res.write('test'); + res.end(); // End without chunk + + expect(mockResponseStream.end.called).to.be.true; + }); + + it('should handle res.end with chunk and backpressure - wait for drain', async () => { + const stream = createCollectingStream(); + const request = createRequestWithEncoding('gzip'); + const event = createMockEvent({httpMethod: 'GET', headers: {'Accept-Encoding': 'gzip'}}); + const context = createMockContext(); + const response = createExpressResponse(stream, event, context, request); + response.setHeader('Content-Type', 'text/html'); + + // Create a mock compression stream that returns false on write (backpressure) + // This is tricky to test directly, so we'll just verify the end works + response.write('test'); + response.end('more'); + + await stream.waitForEnd(); + await new Promise((resolve) => setTimeout(resolve, 50)); + + // Should handle backpressure gracefully + expect(stream.getData().length).to.be.greaterThan(0); + }); + + it('should handle res.end with backpressure when no stream to wait for', () => { + const closedStream = createMockWritable(); + (closedStream as any).writable = false; + const event = createMockEvent({httpMethod: 'GET'}); + const context = createMockContext(); + const res = createExpressResponse(closedStream, event, context); + res.setHeader('Content-Type', 'text/html'); + + // Write should fail (stream closed), then end should handle the else branch + res.write('test'); + res.end('more'); + + // Should handle gracefully even when stream is closed + }); + + it('should handle res.end with chunk and no backpressure', () => { + const event = createMockEvent({httpMethod: 'GET'}); + const context = createMockContext(); + const res = createExpressResponse(mockResponseStream, event, context); + res.setHeader('Content-Type', 'text/html'); + + // End with chunk when there's no backpressure + res.end('test'); + + expect(mockResponseStream.write.called).to.be.true; + }); + }); + + describe('res.write edge cases', () => { + it('should handle res.write with Buffer', () => { + const event = createMockEvent({httpMethod: 'GET'}); + const context = createMockContext(); + const res = createExpressResponse(mockResponseStream, event, context); + res.setHeader('Content-Type', 'text/html'); + const result = res.write(Buffer.from('test')); + + expect(result).to.be.true; + }); + + it('should handle res.write with Uint8Array', () => { + const event = createMockEvent({httpMethod: 'GET'}); + const context = createMockContext(); + const res = createExpressResponse(mockResponseStream, event, context); + res.setHeader('Content-Type', 'text/html'); + const uint8Array = new Uint8Array([116, 101, 115, 116]); + const result = res.write(uint8Array); + + expect(result).to.be.true; + }); + }); + }); +}); diff --git a/packages/mrt-utilities/test/tsconfig.json b/packages/mrt-utilities/test/tsconfig.json new file mode 100644 index 00000000..c6e8ed08 --- /dev/null +++ b/packages/mrt-utilities/test/tsconfig.json @@ -0,0 +1,13 @@ +{ + "extends": "../tsconfig.json", + "compilerOptions": { + "noEmit": true, + "rootDir": "..", + "types": ["node", "mocha", "chai"], + "paths": { + "@salesforce/mrt-utilities": ["../src/index.ts"], + "@salesforce/mrt-utilities/streaming": ["../src/streaming/index.ts"] + } + }, + "include": ["./**/*", "../src/**/*"] +} diff --git a/packages/mrt-utilities/test/utils/configure-proxying.test.ts b/packages/mrt-utilities/test/utils/configure-proxying.test.ts new file mode 100644 index 00000000..7bd65b41 --- /dev/null +++ b/packages/mrt-utilities/test/utils/configure-proxying.test.ts @@ -0,0 +1,237 @@ +/* + * Copyright (c) 2025, Salesforce, Inc. + * SPDX-License-Identifier: Apache-2 + * For full license text, see the license.txt file in the repo root or http://www.apache.org/licenses/LICENSE-2.0 + */ + +import type {IncomingMessage, ClientRequest} from 'http'; +import {expect} from 'chai'; +import sinon from 'sinon'; +import { + applyProxyRequestHeaders, + configureProxy, + configureProxying, + type ProxyConfig, + type CreateProxyMiddlewareFn, +} from '../../src/utils/configure-proxying.js'; + +describe('proxying', () => { + beforeEach(() => { + sinon.restore(); + }); + + describe('applyProxyRequestHeaders', () => { + let mockProxyRequest: {setHeader: sinon.SinonStub; removeHeader: sinon.SinonStub}; + let mockIncomingRequest: {headers: Record}; + + beforeEach(() => { + mockProxyRequest = { + setHeader: sinon.stub(), + removeHeader: sinon.stub(), + }; + mockIncomingRequest = { + headers: { + 'content-type': 'application/json', + 'user-agent': 'test-agent', + 'x-custom-header': 'test-value', + }, + }; + }); + + it('applies rewritten headers to proxy request', () => { + const mockRewrittenHeaders = { + 'content-type': 'application/json', + 'user-agent': 'modified-agent', + 'x-new-header': 'new-value', + }; + + applyProxyRequestHeaders({ + proxyRequest: mockProxyRequest as unknown as ClientRequest, + incomingRequest: mockIncomingRequest as unknown as IncomingMessage, + caching: false, + proxyPath: '/test', + targetHost: 'example.com', + targetProtocol: 'https', + rewriteRequestHeaders: () => mockRewrittenHeaders, + }); + + expect(mockProxyRequest.setHeader.calledWith('content-type', 'application/json')).to.be.true; + expect(mockProxyRequest.setHeader.calledWith('user-agent', 'modified-agent')).to.be.true; + expect(mockProxyRequest.setHeader.calledWith('x-new-header', 'new-value')).to.be.true; + }); + + it('removes headers that are not in rewritten headers', () => { + const mockRewrittenHeaders = { + 'content-type': 'application/json', + 'x-new-header': 'new-value', + }; + + applyProxyRequestHeaders({ + proxyRequest: mockProxyRequest as unknown as ClientRequest, + incomingRequest: mockIncomingRequest as unknown as IncomingMessage, + caching: true, + proxyPath: '/test', + targetHost: 'example.com', + targetProtocol: 'http', + rewriteRequestHeaders: () => mockRewrittenHeaders, + }); + + expect(mockProxyRequest.removeHeader.calledWith('user-agent')).to.be.true; + expect(mockProxyRequest.removeHeader.calledWith('x-custom-header')).to.be.true; + }); + + it('handles empty headers object', () => { + mockIncomingRequest.headers = {}; + + applyProxyRequestHeaders({ + proxyRequest: mockProxyRequest as unknown as ClientRequest, + incomingRequest: mockIncomingRequest as unknown as IncomingMessage, + caching: false, + proxyPath: '/test', + targetHost: 'example.com', + targetProtocol: 'https', + rewriteRequestHeaders: () => ({}), + }); + + expect(mockProxyRequest.setHeader.called).to.be.false; + expect(mockProxyRequest.removeHeader.called).to.be.false; + }); + }); + + describe('configureProxy', () => { + let mockProxyMiddleware: sinon.SinonStub; + let createProxyFn: CreateProxyMiddlewareFn & sinon.SinonStub; + + beforeEach(() => { + mockProxyMiddleware = sinon.stub(); + createProxyFn = sinon + .stub() + .returns( + mockProxyMiddleware as unknown as import('http-proxy-middleware').RequestHandler, + ) as CreateProxyMiddlewareFn & sinon.SinonStub; + }); + + it('creates proxy middleware with correct configuration', () => { + const params = { + appHostname: 'localhost:3000', + proxyPath: '/api', + targetProtocol: 'https', + targetHost: 'api.example.com', + appProtocol: 'http', + caching: false, + }; + + const result = configureProxy(params, createProxyFn); + + expect(createProxyFn.calledOnce).to.be.true; + const config = createProxyFn.firstCall.args[0]; + expect(config).to.include({ + changeOrigin: true, + cookiePathRewrite: false, + followRedirects: false, + target: 'https://api.example.com', + }); + expect(config.cookieDomainRewrite).to.deep.equal({targetHost: 'localhost:3000'}); + expect(result).to.deep.equal({ + fn: mockProxyMiddleware, + path: '/api', + }); + }); + + it('handles error in proxy request', () => { + const params = { + appHostname: 'localhost:3000', + proxyPath: '/api', + targetProtocol: 'https', + targetHost: 'api.example.com', + caching: false, + }; + + configureProxy(params, createProxyFn); + + const config = createProxyFn.firstCall.args[0]; + const mockRes = { + writeHead: sinon.stub(), + end: sinon.stub(), + }; + const mockReq = {url: '/api/test'}; + const error = new Error('Connection failed'); + + config.onError(error, mockReq as IncomingMessage, mockRes as unknown as import('http').ServerResponse); + + expect( + mockRes.writeHead.calledWith(500, { + 'Content-Type': 'text/plain', + }), + ).to.be.true; + expect(mockRes.end.calledWith('Error in proxy request to /api/test: Error: Connection failed')).to.be.true; + }); + }); + + describe('configureProxying', () => { + let mockProxyMiddleware: sinon.SinonStub; + let createProxyFn: CreateProxyMiddlewareFn & sinon.SinonStub; + + beforeEach(() => { + mockProxyMiddleware = sinon.stub(); + createProxyFn = sinon + .stub() + .returns( + mockProxyMiddleware as unknown as import('http-proxy-middleware').RequestHandler, + ) as CreateProxyMiddlewareFn & sinon.SinonStub; + }); + + it('configures multiple proxies correctly', () => { + const proxyConfigs: ProxyConfig[] = [ + {host: 'https://api1.example.com', path: '/api1'}, + {host: 'http://api2.example.com', path: '/api2'}, + {host: 'https://api3.example.com', path: '/api3'}, + ]; + + const result = configureProxying(proxyConfigs, 'localhost:3000', 'http', createProxyFn); + + expect(result).to.have.length(3); + expect(result[0]).to.deep.equal({fn: mockProxyMiddleware, path: '/api1'}); + expect(result[1]).to.deep.equal({fn: mockProxyMiddleware, path: '/api2'}); + expect(result[2]).to.deep.equal({fn: mockProxyMiddleware, path: '/api3'}); + expect(createProxyFn.callCount).to.equal(3); + }); + + it('handles empty proxy configs array', () => { + const result = configureProxying([], 'localhost:3000', undefined, createProxyFn); + + expect(result).to.have.length(0); + expect(createProxyFn.called).to.be.false; + }); + + it('correctly parses HTTPS hosts', () => { + const proxyConfigs: ProxyConfig[] = [{host: 'https://secure.example.com', path: '/secure'}]; + + configureProxying(proxyConfigs, 'localhost:3000', undefined, createProxyFn); + + expect(createProxyFn.firstCall.args[0]).to.include({ + target: 'https://secure.example.com', + }); + }); + + it('correctly parses HTTP hosts', () => { + const proxyConfigs: ProxyConfig[] = [{host: 'http://insecure.example.com', path: '/insecure'}]; + + configureProxying(proxyConfigs, 'localhost:3000', undefined, createProxyFn); + + expect(createProxyFn.firstCall.args[0]).to.include({ + target: 'http://insecure.example.com', + }); + }); + + it('handles hosts without protocol (default to http)', () => { + const proxyConfigs: ProxyConfig[] = [{host: 'example.com', path: '/api'}]; + + configureProxying(proxyConfigs, 'localhost:3000', undefined, createProxyFn); + + expect(createProxyFn.firstCall.args[0]).to.include({ + target: 'http://example.com', + }); + }); + }); +}); diff --git a/packages/mrt-utilities/test/utils/ssr-proxying.test.ts b/packages/mrt-utilities/test/utils/ssr-proxying.test.ts new file mode 100644 index 00000000..96c4ef1a --- /dev/null +++ b/packages/mrt-utilities/test/utils/ssr-proxying.test.ts @@ -0,0 +1,770 @@ +/* + * Copyright (c) 2025, Salesforce, Inc. + * SPDX-License-Identifier: Apache-2 + * For full license text, see the license.txt file in the repo root or http://www.apache.org/licenses/LICENSE-2.0 + */ + +import {expect} from 'chai'; +import { + cookieAsString, + parseHost, + Headers, + rewriteProxyRequestHeaders, + rewriteProxyResponseHeaders, + rfc1123, + MAX_URL_LENGTH_BYTES, + ALLOWED_CACHING_PROXY_REQUEST_HEADERS, + type HTTPHeaders, + type AWSHeaders, + type ParsedHost, +} from '../../src/utils/ssr-proxying.js'; + +/** + * Test case interface for rfc1123 tests + */ +interface RFC1123TestCase { + date: Date; + expected: string; +} + +/** + * Test case interface for parseHost tests + */ +interface ParseHostTestCase { + name: string; + host: string; + expected: ParsedHost; +} + +/** + * Test case interface for cookieAsString tests + */ +interface CookieAsStringTestCase { + cookie: { + name: string; + value: string; + secure?: boolean; + httpOnly?: boolean; + sameSite?: string; + domain?: string; + maxAge?: number; + path?: string; + expires?: Date; + }; + expected: string; +} + +/** + * Test case interface for rewriteProxyResponseHeaders tests + */ +interface RewriteProxyResponseHeadersTestCase { + name: string; + appHostname?: string; + targetHost?: string; + statusCode?: number; + input: HTTPHeaders | AWSHeaders; + expected: HTTPHeaders | AWSHeaders; + format?: 'http' | 'aws'; + caching?: boolean; + requestUrl?: string; +} + +/** + * Test case interface for rewriteProxyRequestHeaders tests + */ +interface RewriteProxyRequestHeadersTestCase { + name: string; + targetHost?: string; + input: HTTPHeaders | AWSHeaders; + expected: HTTPHeaders | AWSHeaders; + format?: 'http' | 'aws'; + caching?: boolean; + targetProtocol?: string; + testAllowlist?: boolean; + method?: string; +} + +describe('rfc1123 tests', () => { + const testCases: RFC1123TestCase[] = [ + { + date: new Date('2018-10-24T10:09:08Z'), + expected: 'Wed, 24 Oct 2018 10:09:08 GMT', + }, + { + date: new Date('2018-10-24T23:22:21-01:00'), + expected: 'Thu, 25 Oct 2018 00:22:21 GMT', + }, + ]; + + testCases.forEach((testCase) => + it(`Expecting ${testCase.expected}`, () => { + expect(rfc1123(testCase.date)).to.deep.equal(testCase.expected); + }), + ); +}); + +describe('parseHost tests', () => { + const testCases: ParseHostTestCase[] = [ + { + name: 'localhost with port', + host: 'localhost:8080', + expected: { + host: 'localhost:8080', + hostname: 'localhost', + port: '8080', + isIPOrLocalhost: true, + }, + }, + { + name: 'localhost without port', + host: 'localhost', + expected: { + host: 'localhost', + hostname: 'localhost', + isIPOrLocalhost: true, + }, + }, + { + name: 'single-word host with port', + host: 'xyzzy:8080', + expected: { + host: 'xyzzy:8080', + hostname: 'xyzzy', + port: '8080', + isIPOrLocalhost: false, + }, + }, + { + name: 'single-word host without port', + host: 'doobrie', + expected: { + host: 'doobrie', + hostname: 'doobrie', + isIPOrLocalhost: false, + }, + }, + { + name: 'hostname without port', + host: 'www.customer.com', + expected: { + host: 'www.customer.com', + hostname: 'www.customer.com', + isIPOrLocalhost: false, + }, + }, + { + name: 'hostname with port', + host: 'www.customer.com:1234', + expected: { + host: 'www.customer.com:1234', + hostname: 'www.customer.com', + port: '1234', + isIPOrLocalhost: false, + }, + }, + { + name: 'ipv4 with port', + host: '1.2.3.4:1234', + expected: { + host: '1.2.3.4:1234', + hostname: '1.2.3.4', + port: '1234', + isIPOrLocalhost: true, + }, + }, + { + name: 'ipv4 without port', + host: '192.168.1.128:1234', + expected: { + host: '192.168.1.128:1234', + hostname: '192.168.1.128', + port: '1234', + isIPOrLocalhost: true, + }, + }, + { + name: 'ipv6 with port', + host: '[2001:db8::1]:8080', + expected: { + host: '[2001:db8::1]:8080', + hostname: '2001:db8::1', + port: '8080', + isIPOrLocalhost: true, + }, + }, + { + name: 'ipv6 without port', + host: '2001:db8::1', + expected: { + host: '2001:db8::1', + hostname: '2001:db8::1', + isIPOrLocalhost: true, + }, + }, + { + name: 'ipv4 without port', + host: '2001:db8::1', + expected: { + host: '2001:db8::1', + hostname: '2001:db8::1', + isIPOrLocalhost: true, + }, + }, + ]; + + testCases.forEach((testCase) => + it(`${testCase.name} (${testCase.host})`, () => { + expect(parseHost(testCase.host)).to.deep.equal(testCase.expected); + }), + ); +}); + +describe('cookieAsString tests', () => { + const testCases: CookieAsStringTestCase[] = [ + { + cookie: { + name: 'abc', + value: '123', + }, + expected: 'abc=123', + }, + { + cookie: { + name: 'def', + value: '456', + secure: true, + httpOnly: true, + sameSite: 'lax', + }, + expected: 'def=456; Secure; HttpOnly; SameSite=lax', + }, + { + cookie: { + name: 'abc', + value: '123', + domain: 'mobify.com', + maxAge: 123, + path: '/', + }, + expected: 'abc=123; Path=/; Domain=mobify.com; Max-Age=123', + }, + { + cookie: { + name: 'abc', + value: '123', + expires: new Date('2018-10-24T10:09:08Z'), + }, + expected: 'abc=123; Expires=Wed, 24 Oct 2018 10:09:08 GMT', + }, + ]; + + testCases.forEach((testCase) => + it(`Expecting ${testCase.expected}`, () => { + expect(cookieAsString(testCase.cookie)).to.deep.equal(testCase.expected); + }), + ); +}); + +describe('rewriteProxyResponseHeaders tests', () => { + const tooLongUrlBase = 'https://www.customer.com/xyz?xyz='; + // Because this string is based on tooLongUrlBase, + // it will exceed MAX_URL_LENGTH_BYTES in length. + const tooLongUrl = tooLongUrlBase.padEnd(MAX_URL_LENGTH_BYTES, 'x'); + + const testCases: RewriteProxyResponseHeadersTestCase[] = [ + { + name: 'no changes expected', + appHostname: 'apphost.mobify.com', + targetHost: 'www.customer.com', + statusCode: 200, + input: { + 'access-control-allow-origin': '*', + location: '/xyz', + 'content-type': 'application/octet-stream', + }, + expected: { + 'access-control-allow-origin': '*', + location: '/xyz', + 'content-type': 'application/octet-stream', + }, + }, + { + name: 'no domain rewrite of set-cookie', + appHostname: 'apphost.mobify.com', + targetHost: 'www.customer.com', + statusCode: 200, + input: { + 'set-cookie': 'origin_dc=war; expires=Mon, 01-Oct-2018 00:13:20 GMT; path=/; secure; httpOnly', + }, + expected: { + 'set-cookie': ['origin_dc=war; Path=/; Expires=Mon, 01 Oct 2018 00:13:20 GMT; Secure; HttpOnly'], + }, + }, + { + name: 'rewrite set-cookie', + appHostname: 'apphost.mobify.com', + targetHost: 'www.customer.com', + statusCode: 200, + input: { + 'access-control-allow-origin': '*', + 'set-cookie': [ + 'origin_dc=war; expires=Mon, 01-Oct-2018 00:13:20 GMT; path=/; domain=.www.customer.com', + 'origin_dc=war; expires=Tue, 02-Oct-2018 00:13:20 GMT; path=/; domain=.someone.com', + ], + }, + expected: { + 'access-control-allow-origin': '*', + 'set-cookie': [ + 'origin_dc=war; Path=/; Expires=Mon, 01 Oct 2018 00:13:20 GMT; Domain=apphost.mobify.com', + 'origin_dc=war; Path=/; Expires=Tue, 02 Oct 2018 00:13:20 GMT; Domain=.someone.com', + ], + }, + }, + { + name: 'rewrite set-cookie (AWS format)', + format: 'aws', + appHostname: 'apphost.mobify.com', + targetHost: 'www.customer.com', + statusCode: 200, + input: { + 'set-cookie': [ + { + key: 'Set-Cookie', + value: 'origin_dc=war; expires=Mon, 01-Oct-2018 00:13:20 GMT; path=/; domain=.www.customer.com', + }, + ], + }, + expected: { + 'set-cookie': [ + { + key: 'Set-Cookie', + value: 'origin_dc=war; Path=/; Expires=Mon, 01 Oct 2018 00:13:20 GMT; Domain=apphost.mobify.com', + }, + ], + }, + }, + { + name: 'rewrite set-cookie local', + appHostname: 'localhost:3443', + targetHost: 'www.customer.com', + statusCode: 200, + input: { + 'set-cookie': 'origin_dc=war; expires=Mon, 01-Oct-2018 00:13:20 GMT; path=/; domain=.www.customer.com', + }, + expected: { + 'set-cookie': ['origin_dc=war; Path=/; Expires=Mon, 01 Oct 2018 00:13:20 GMT; Domain=localhost'], + }, + }, + { + name: 'rewrite set-cookie local with subdomain', + appHostname: 'localhost:3443', + targetHost: 'www.customer.com', + statusCode: 200, + input: { + 'set-cookie': 'origin_dc=war; expires=Mon, 01-Oct-2018 00:13:20 GMT; path=/; domain=.customer.com', + }, + expected: { + 'set-cookie': ['origin_dc=war; Path=/; Expires=Mon, 01 Oct 2018 00:13:20 GMT; Domain=localhost'], + }, + }, + { + name: 'rewrite set-cookie third-party', + appHostname: 'localhost:3443', + targetHost: 'www.customer.com', + statusCode: 200, + input: { + 'set-cookie': 'origin_dc=war; expires=Mon, 01-Oct-2018 00:13:20 GMT; path=/; domain=.someone.com', + }, + expected: { + 'set-cookie': ['origin_dc=war; Path=/; Expires=Mon, 01 Oct 2018 00:13:20 GMT; Domain=.someone.com'], + }, + }, + { + name: 'rewrite access-control and set-cookie', + appHostname: 'apphost.mobify.com', + targetHost: 'www.customer.com', + statusCode: 200, + input: { + 'access-control-allow-origin': 'https://www.customer.com', + 'set-cookie': 'origin_dc=war; expires=Mon, 01-Oct-2018 00:13:20 GMT; path=/; domain=.customer.com', + }, + expected: { + 'access-control-allow-origin': 'https://apphost.mobify.com', + 'set-cookie': ['origin_dc=war; Path=/; Expires=Mon, 01 Oct 2018 00:13:20 GMT; Domain=mobify.com'], + }, + }, + { + name: 'remove set-cookie', + caching: true, + appHostname: 'localhost:3443', + targetHost: 'www.customer.com', + statusCode: 200, + input: { + 'set-cookie': 'origin_dc=war; expires=Mon, 01-Oct-2018 00:13:20 GMT; path=/; domain=.www.customer.com', + }, + expected: { + // @ts-expect-error: Testing undefined value + 'set-cookie': undefined, + }, + }, + { + name: 'rewrite location', + appHostname: 'apphost.mobify.com', + targetHost: 'www.customer.com', + statusCode: 301, + input: { + location: 'https://www.customer.com/abc/def', + }, + expected: { + location: 'https://apphost.mobify.com/mobify/proxy/base/abc/def', + }, + }, + { + name: "don't rewrite location", + appHostname: 'apphost.mobify.com', + targetHost: 'www.customer.com', + statusCode: 301, + input: { + location: 'https://www.elsewhere.com/abc/def', + }, + expected: { + location: 'https://www.elsewhere.com/abc/def', + }, + }, + { + name: 'rewrite location (AWS format)', + format: 'aws', + appHostname: 'apphost.mobify.com', + targetHost: 'www.customer.com', + statusCode: 301, + input: { + location: [ + { + key: 'Location', + value: 'https://www.customer.com/abc/def', + }, + ], + }, + expected: { + location: [ + { + key: 'Location', + value: 'https://apphost.mobify.com/mobify/proxy/base/abc/def', + }, + ], + }, + }, + { + name: 'x-proxy-request-url (full URL)', + appHostname: 'apphost.mobify.com', + targetHost: 'www.customer.com', + input: {}, + statusCode: 200, + requestUrl: 'https://www.customer.com/xyz?xyz=123', + expected: { + 'x-proxy-request-url': 'https://www.customer.com/xyz?xyz=123', + }, + }, + { + name: 'x-proxy-request-url (path-only URL)', + appHostname: 'apphost.mobify.com', + targetHost: 'www.customer.com', + input: {}, + statusCode: 200, + requestUrl: '/xyz?abc=def', + expected: { + 'x-proxy-request-url': 'https://www.customer.com/xyz?abc=def', + }, + }, + { + name: 'x-proxy-request-url (long URL)', + appHostname: 'apphost.mobify.com', + targetHost: 'www.customer.com', + statusCode: 200, + input: {}, + requestUrl: tooLongUrl, + expected: { + 'x-proxy-request-url': tooLongUrl.slice(0, MAX_URL_LENGTH_BYTES), + }, + }, + ]; + + testCases.forEach((testCase, testCaseIndex) => + it(testCase.name || `test ${testCaseIndex}`, () => { + const updatedHeaders = rewriteProxyResponseHeaders({ + appHostname: testCase.appHostname || '', + proxyPath: '/mobify/proxy/base/', + statusCode: testCase.statusCode || 200, + headers: testCase.input, + headerFormat: testCase.format || 'http', + caching: !!testCase.caching, + targetProtocol: 'https', + targetHost: testCase.targetHost || '', + logging: true, + requestUrl: testCase.requestUrl, + }); + + Object.entries(testCase.expected).forEach(([key, value]) => { + const actual = updatedHeaders[key]; + expect(actual).to.deep.equal(value); + }); + }), + ); + + it('missing headers', () => { + expect( + rewriteProxyResponseHeaders({ + appHostname: '', + proxyPath: '/mobify/proxy/base', + headers: {}, + caching: false, + targetProtocol: 'https', + targetHost: 'www.customer.com', + }), + ).to.deep.equal({}); + }); + + it('Headers.modified', () => { + const headers = new Headers({a: '1'}, 'http'); + expect(headers.modified).to.equal(false); + headers.setHeader('a', '2'); + expect(headers.modified).to.equal(true); + }); + + it('Headers case handling', () => { + const headers = new Headers({}, 'aws'); + headers.setHeader('cached_response', 'true'); + const result = headers.toObject(); + expect(result.cached_response).to.deep.equal([ + { + key: 'cached_response', + value: 'true', + }, + ]); + }); + + it('bad Headers format', () => { + expect(() => new Headers({}, 'unknown' as 'http')).to.throw(); + }); +}); + +describe('rewriteProxyRequestHeaders tests', () => { + const testCases: RewriteProxyRequestHeadersTestCase[] = [ + { + name: 'no changes expected', + targetHost: 'www.customer.com', + input: { + 'accept-encoding': 'deflate, gzip', + cookie: 'abc=123', + }, + expected: { + 'accept-encoding': 'deflate, gzip', + cookie: 'abc=123', + }, + }, + { + name: 'rewrite host (AWS format)', + format: 'aws', + targetHost: 'www.customer.com', + input: { + host: [ + { + key: 'Host', + value: 'apphost.mobify.com', + }, + ], + }, + expected: { + host: [ + { + key: 'Host', + value: 'www.customer.com', + }, + ], + }, + }, + { + name: 'rewrite origin (AWS format)', + format: 'aws', + targetHost: 'www.customer.com', + targetProtocol: 'http', + input: { + origin: [ + { + key: 'Origin', + value: 'https://apphost.mobify.com', + }, + ], + }, + expected: { + origin: [ + { + key: 'Origin', + value: 'http://www.customer.com', + }, + ], + }, + }, + { + name: 'rewrite host (HTTP format)', + format: 'http', + targetHost: 'www.customer.com', + input: { + host: 'apphost.mobify.com', + }, + expected: { + host: 'www.customer.com', + }, + }, + { + name: 'rewrite origin (HTTP format)', + format: 'http', + targetHost: 'www.customer.com', + targetProtocol: 'http', + input: { + 'accept-encoding': 'deflate, gzip', + cookie: 'abc=123', + origin: 'https://apphost.mobify.com', + }, + expected: { + 'accept-encoding': 'deflate, gzip', + cookie: 'abc=123', + origin: 'http://www.customer.com', + }, + }, + { + name: 'strip out x-mobify-access-key keep apig headers', + targetHost: 'www.customer.com', + input: { + 'accept-encoding': 'deflate, gzip', + cookie: 'abc=123', + 'x-api-key': '1234567890', + 'x-mobify-access-key': 'abcdefghijk', + 'x-apigateway-event': '{}', + 'x-apigateway-context': '{}', + 'x-sfdc-access-control': '123456789', + }, + expected: { + 'accept-encoding': 'deflate, gzip', + cookie: 'abc=123', + 'x-api-key': '1234567890', + // @ts-expect-error: Testing undefined value + 'x-mobify-access-key': undefined, + 'x-apigateway-event': '{}', + 'x-apigateway-context': '{}', + // @ts-expect-error: Testing undefined value + 'x-sfdc-access-control': undefined, + }, + }, + { + name: 'caching-proxy processing GET 1', + caching: true, + targetHost: 'www.customer.com', + testAllowlist: true, + method: 'GET', + input: { + 'accept-encoding': 'deflate, gzip', + authorization: 'abc=123', + connection: 'keep-alive', + date: 'some-date-value', + 'user-agent': 'chrome', + }, + expected: { + authorization: 'abc=123', + // @ts-expect-error: Testing undefined value + connection: undefined, + // @ts-expect-error: Testing undefined value + date: undefined, + host: 'www.customer.com', + origin: 'https://www.customer.com', + 'user-agent': 'Amazon CloudFront', + }, + }, + { + name: 'caching-proxy processing GET 2', + caching: true, + method: 'GET', + input: { + 'accept-encoding': 'deflate', + }, + expected: { + 'accept-encoding': 'deflate', + }, + }, + { + name: 'caching-proxy processing no-op', + caching: true, + targetHost: 'www.customer.com', + method: 'GET', + input: {}, + expected: { + 'user-agent': 'Amazon CloudFront', + }, + }, + { + name: 'add in x-headers', + targetHost: 'www.customer.com', + input: { + 'accept-encoding': 'deflate, gzip', + cookie: 'abc=123', + }, + expected: { + 'accept-encoding': 'deflate, gzip', + cookie: 'abc=123', + 'x-mobify': 'true', + }, + }, + ]; + + testCases.forEach((testCase, testCaseIndex) => + it(testCase.name || `test ${testCaseIndex}`, () => { + const headers = Object.assign({}, testCase.input || {}); + + if (testCase.testAllowlist) { + Object.keys(ALLOWED_CACHING_PROXY_REQUEST_HEADERS).forEach((key) => { + if (!(key in headers)) { + headers[key] = key; + } + }); + } + + const updatedHeaders = rewriteProxyRequestHeaders({ + caching: testCase.caching, + headers, + headerFormat: testCase.format || 'http', + targetProtocol: testCase.targetProtocol || 'https', + targetHost: testCase.targetHost || '', + logging: true, + }); + + const expectedKeys = Object.keys(testCase.expected); + expectedKeys.forEach((key) => { + const value = testCase.expected[key]; + const actual = updatedHeaders[key]; + expect(actual).to.deep.equal(value); + }); + + if (testCase.testAllowlist) { + Object.keys(ALLOWED_CACHING_PROXY_REQUEST_HEADERS).forEach((key) => { + if (expectedKeys.indexOf(key) < 0) { + expect(updatedHeaders[key]).to.deep.equal(headers[key]); + } + }); + } + }), + ); + + it('missing headers', () => { + expect( + rewriteProxyRequestHeaders({ + headers: undefined, + targetProtocol: 'https', + targetHost: 'www.customer.com', + }), + ).to.deep.equal({}); + }); + + it('bad Headers format', () => { + expect(() => new Headers({}, 'unknown' as 'http')).to.throw(); + }); +}); diff --git a/packages/mrt-utilities/test/utils/utils.test.ts b/packages/mrt-utilities/test/utils/utils.test.ts new file mode 100644 index 00000000..4bf03dd1 --- /dev/null +++ b/packages/mrt-utilities/test/utils/utils.test.ts @@ -0,0 +1,102 @@ +/* + * Copyright (c) 2025, Salesforce, Inc. + * SPDX-License-Identifier: Apache-2 + * For full license text, see the license.txt file in the repo root or http://www.apache.org/licenses/LICENSE-2.0 + */ + +import {expect} from 'chai'; +import sinon from 'sinon'; +import {isLocal} from '@salesforce/mrt-utilities'; +import {logMRTError} from '../../src/utils/utils.js'; + +describe('isLocal', () => { + it('returns true when not in AWS Lambda', () => { + const originalEnv = process.env.AWS_LAMBDA_FUNCTION_NAME; + delete process.env.AWS_LAMBDA_FUNCTION_NAME; + + const result = isLocal(); + + expect(result).to.equal(true); + + if (originalEnv !== undefined) { + process.env.AWS_LAMBDA_FUNCTION_NAME = originalEnv; + } + }); + + it('returns false when in AWS Lambda', () => { + const originalEnv = process.env.AWS_LAMBDA_FUNCTION_NAME; + process.env.AWS_LAMBDA_FUNCTION_NAME = 'test-function'; + + const result = isLocal(); + + expect(result).to.equal(false); + + if (originalEnv !== undefined) { + process.env.AWS_LAMBDA_FUNCTION_NAME = originalEnv; + } else { + delete process.env.AWS_LAMBDA_FUNCTION_NAME; + } + }); + + it('returns false when AWS_LAMBDA_FUNCTION_NAME is empty string', () => { + const originalEnv = process.env.AWS_LAMBDA_FUNCTION_NAME; + process.env.AWS_LAMBDA_FUNCTION_NAME = ''; + + const result = isLocal(); + + expect(result).to.equal(false); + + if (originalEnv !== undefined) { + process.env.AWS_LAMBDA_FUNCTION_NAME = originalEnv; + } else { + delete process.env.AWS_LAMBDA_FUNCTION_NAME; + } + }); +}); + +describe('logMRTError', () => { + let consoleErrorStub: sinon.SinonStub; + + beforeEach(() => { + consoleErrorStub = sinon.stub(console, 'error'); + }); + + afterEach(() => { + consoleErrorStub.restore(); + }); + + const getLoggedJson = (): Record => JSON.parse(consoleErrorStub.firstCall.args[0] as string); + + it('logs message and stack when an Error instance is provided', () => { + const err = new Error('something went wrong'); + logMRTError('data_store', err); + + expect(consoleErrorStub.callCount).to.equal(1); + const logged = getLoggedJson(); + expect(logged).to.have.property('__MRT__data_store', 'error'); + expect(logged).to.have.property('type', 'MRT_internal'); + expect(logged).to.have.property('error', 'something went wrong'); + expect(logged).to.have.property('stack', err.stack); + }); + + it('normalizes non-Errors to Errors', () => { + logMRTError('middleware', 'plain string error'); + + expect(consoleErrorStub.callCount).to.equal(1); + const logged = getLoggedJson(); + expect(logged).to.have.property('__MRT__middleware', 'error'); + expect(logged).to.have.property('type', 'MRT_internal'); + expect(logged).to.have.property('error', 'plain string error'); + expect(logged.stack).to.be.ok; + }); + + it('includes context when provided', () => { + const err = new Error('something bad happened'); + logMRTError('data_store', err, {myContext: 'some helpful context'}); + + expect(consoleErrorStub.callCount).to.equal(1); + const logged = getLoggedJson(); + expect(logged).to.have.property('error', 'something bad happened'); + expect(logged).to.have.property('myContext', 'some helpful context'); + }); +}); diff --git a/packages/mrt-utilities/tsconfig.cjs.json b/packages/mrt-utilities/tsconfig.cjs.json new file mode 100644 index 00000000..280ff896 --- /dev/null +++ b/packages/mrt-utilities/tsconfig.cjs.json @@ -0,0 +1,11 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "module": "Node16", + "moduleResolution": "Node16", + "outDir": "dist/cjs", + "rootDir": "src", + "verbatimModuleSyntax": false + }, + "include": ["src/**/*"] +} diff --git a/packages/mrt-utilities/tsconfig.esm.json b/packages/mrt-utilities/tsconfig.esm.json new file mode 100644 index 00000000..1db4f4e9 --- /dev/null +++ b/packages/mrt-utilities/tsconfig.esm.json @@ -0,0 +1,8 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "outDir": "dist/esm", + "rootDir": "src" + }, + "include": ["src/**/*"] +} diff --git a/packages/mrt-utilities/tsconfig.json b/packages/mrt-utilities/tsconfig.json new file mode 100644 index 00000000..2ff87a3b --- /dev/null +++ b/packages/mrt-utilities/tsconfig.json @@ -0,0 +1,10 @@ +{ + "extends": "@salesforce/dev-config/tsconfig-strict-esm", + "compilerOptions": { + "skipLibCheck": true, + "esModuleInterop": true, + "forceConsistentCasingInFileNames": true, + "verbatimModuleSyntax": true + }, + "include": ["src/**/*"] +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 2331b47b..b4c0dddd 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -581,6 +581,124 @@ importers: specifier: 'catalog:' version: 8.54.0(eslint@9.39.1)(typescript@5.9.3) + packages/mrt-utilities: + dependencies: + '@aws-sdk/client-cloudwatch': + specifier: 3.952.0 + version: 3.952.0 + '@aws-sdk/client-dynamodb': + specifier: 3.980.0 + version: 3.980.0 + '@aws-sdk/lib-dynamodb': + specifier: 3.980.0 + version: 3.980.0(@aws-sdk/client-dynamodb@3.980.0) + '@h4ad/serverless-adapter': + specifier: 4.4.0 + version: 4.4.0(@types/aws-lambda@8.10.160)(@types/body-parser@1.19.6)(@types/cors@2.8.19)(@types/express@5.0.3)(body-parser@2.2.1)(cors@2.8.5)(express@5.1.0)(http-errors@2.0.1) + change-case: + specifier: 5.4.4 + version: 5.4.4 + compressible: + specifier: 2.0.18 + version: 2.0.18 + http-proxy-middleware: + specifier: 3.0.5 + version: 3.0.5 + mime-types: + specifier: 3.0.1 + version: 3.0.1 + negotiator: + specifier: 1.0.0 + version: 1.0.0 + qs: + specifier: '>=6.14.1' + version: 6.14.1 + set-cookie-parser: + specifier: 2.7.1 + version: 2.7.1 + devDependencies: + '@salesforce/dev-config': + specifier: 'catalog:' + version: 4.3.2 + '@serverless/event-mocks': + specifier: 1.1.1 + version: 1.1.1 + '@types/aws-lambda': + specifier: 8.10.160 + version: 8.10.160 + '@types/chai': + specifier: 'catalog:' + version: 4.3.20 + '@types/compressible': + specifier: 2.0.3 + version: 2.0.3 + '@types/express': + specifier: 5.0.3 + version: 5.0.3 + '@types/mime-types': + specifier: 3.0.1 + version: 3.0.1 + '@types/mocha': + specifier: 'catalog:' + version: 10.0.10 + '@types/negotiator': + specifier: 0.6.4 + version: 0.6.4 + '@types/node': + specifier: 'catalog:' + version: 22.19.0 + '@types/qs': + specifier: 6.14.0 + version: 6.14.0 + '@types/set-cookie-parser': + specifier: 2.4.10 + version: 2.4.10 + '@types/sinon': + specifier: 'catalog:' + version: 21.0.0 + c8: + specifier: 'catalog:' + version: 10.1.3 + chai: + specifier: 'catalog:' + version: 4.5.0 + eslint: + specifier: 'catalog:' + version: 9.39.1 + eslint-config-prettier: + specifier: 'catalog:' + version: 10.1.8(eslint@9.39.1) + eslint-plugin-header: + specifier: 'catalog:' + version: 3.1.1(eslint@9.39.1) + eslint-plugin-prettier: + specifier: 'catalog:' + version: 5.5.4(eslint-config-prettier@10.1.8(eslint@9.39.1))(eslint@9.39.1)(prettier@3.6.2) + express: + specifier: 5.1.0 + version: 5.1.0 + mocha: + specifier: 'catalog:' + version: 10.8.2 + prettier: + specifier: 'catalog:' + version: 3.6.2 + shx: + specifier: 'catalog:' + version: 0.3.4 + sinon: + specifier: 'catalog:' + version: 21.0.1 + tsx: + specifier: 'catalog:' + version: 4.20.6 + typescript: + specifier: 'catalog:' + version: 5.9.3 + typescript-eslint: + specifier: 'catalog:' + version: 8.54.0(eslint@9.39.1)(typescript@5.9.3) + packages: '@algolia/abtesting@1.10.0': @@ -686,6 +804,14 @@ packages: resolution: {integrity: sha512-JcgMsxlAfl+Hh/z4+2uDqwg6xYuLneHblhHaD1WTfOiNpWLstv4t6JJLw01xiIgC510wj0OeY84mhZor7E2QZA==} engines: {node: '>=18.0.0'} + '@aws-sdk/client-cloudwatch@3.952.0': + resolution: {integrity: sha512-vKR+AzSus8SZBR1wvpzqMZwoLlyA/UVZXPtSQLBP/CcTRKuJSMvIvMXN88U4bPseROrN83UoSQdkmVo93+qQJg==} + engines: {node: '>=18.0.0'} + + '@aws-sdk/client-dynamodb@3.980.0': + resolution: {integrity: sha512-1rGhAx4cHZy3pMB3R3r84qMT5WEvQ6ajr2UksnD48fjQxwaUcpI6NsPvU5j/5BI5LqGiUO6ThOrMwSMm95twQA==} + engines: {node: '>=20.0.0'} + '@aws-sdk/client-s3@3.929.0': resolution: {integrity: sha512-M6G+1CBTowN+m0Jrww5/AXMqlk4nIJqwaa/vOw+EbvLD7ROpBs6bStSai9esP9PkIVW6KMu4zCIgHzKhGa3R2A==} engines: {node: '>=18.0.0'} @@ -694,42 +820,140 @@ packages: resolution: {integrity: sha512-CE1T7PvN2MDRCw96BTUz2Zcnb6Lae3Dl4w3TPB5auBv2sAiIPbQegFUwT2C8teMDGCNXyndzoTvAd4wmO9AcpA==} engines: {node: '>=18.0.0'} + '@aws-sdk/client-sso@3.948.0': + resolution: {integrity: sha512-iWjchXy8bIAVBUsKnbfKYXRwhLgRg3EqCQ5FTr3JbR+QR75rZm4ZOYXlvHGztVTmtAZ+PQVA1Y4zO7v7N87C0A==} + engines: {node: '>=18.0.0'} + + '@aws-sdk/client-sso@3.996.0': + resolution: {integrity: sha512-QzlZozTam0modnGanLjXBHbHC53mMxH/4XmoA9f6ZjPYaGlCcHPYLcslO6w2w68v+F3qN0kxVldUAcL/edtBBA==} + engines: {node: '>=20.0.0'} + '@aws-sdk/core@3.928.0': resolution: {integrity: sha512-e28J2uKjy2uub4u41dNnmzAu0AN3FGB+LRcLN2Qnwl9Oq3kIcByl5sM8ZD+vWpNG+SFUrUasBCq8cMnHxwXZ4w==} engines: {node: '>=18.0.0'} + '@aws-sdk/core@3.947.0': + resolution: {integrity: sha512-Khq4zHhuAkvCFuFbgcy3GrZTzfSX7ZIjIcW1zRDxXRLZKRtuhnZdonqTUfaWi5K42/4OmxkYNpsO7X7trQOeHw==} + engines: {node: '>=18.0.0'} + + '@aws-sdk/core@3.973.12': + resolution: {integrity: sha512-hFiezao0lCEddPhSQEF6vCu+TepUN3edKxWYbswMoH87XpUvHJmFVX5+zttj4qi33saGiuOaJciswWcN6YSA9g==} + engines: {node: '>=20.0.0'} + '@aws-sdk/credential-provider-env@3.928.0': resolution: {integrity: sha512-tB8F9Ti0/NFyFVQX8UQtgRik88evtHpyT6WfXOB4bAY6lEnEHA0ubJZmk9y+aUeoE+OsGLx70dC3JUsiiCPJkQ==} engines: {node: '>=18.0.0'} + '@aws-sdk/credential-provider-env@3.947.0': + resolution: {integrity: sha512-VR2V6dRELmzwAsCpK4GqxUi6UW5WNhAXS9F9AzWi5jvijwJo3nH92YNJUP4quMpgFZxJHEWyXLWgPjh9u0zYOA==} + engines: {node: '>=18.0.0'} + + '@aws-sdk/credential-provider-env@3.972.10': + resolution: {integrity: sha512-YTWjM78Wiqix0Jv/anbq7+COFOFIBBMLZ+JsLKGwbTZNJ2DG4JNBnLVJAWylPOHwurMws9157pqzU8ODrpBOow==} + engines: {node: '>=20.0.0'} + '@aws-sdk/credential-provider-http@3.928.0': resolution: {integrity: sha512-67ynC/8UW9Y8Gn1ZZtC3OgcQDGWrJelHmkbgpmmxYUrzVhp+NINtz3wiTzrrBFhPH/8Uy6BxvhMfXhn0ptcMEQ==} engines: {node: '>=18.0.0'} + '@aws-sdk/credential-provider-http@3.947.0': + resolution: {integrity: sha512-inF09lh9SlHj63Vmr5d+LmwPXZc2IbK8lAruhOr3KLsZAIHEgHgGPXWDC2ukTEMzg0pkexQ6FOhXXad6klK4RA==} + engines: {node: '>=18.0.0'} + + '@aws-sdk/credential-provider-http@3.972.12': + resolution: {integrity: sha512-adDRE3iFrgJJ7XhRHkb6RdFDMrA5x64WAWxygI3F6wND+3v5qQ4Uks12vsnEZgduU/+JQBgFB6L4vfwUS+rpBQ==} + engines: {node: '>=20.0.0'} + '@aws-sdk/credential-provider-ini@3.929.0': resolution: {integrity: sha512-XIzWsJUYeS/DjggHFB53sGGjXdlN/BA6x+Y/JvLbpdkGD2yLISU34/cDPbK/O8BAQCRTCQ69VPa/1AdNgZZRQw==} engines: {node: '>=18.0.0'} + '@aws-sdk/credential-provider-ini@3.952.0': + resolution: {integrity: sha512-N5B15SwzMkZ8/LLopNksTlPEWWZn5tbafZAUfMY5Xde4rSHGWmv5H/ws2M3P8L0X77E2wKnOJsNmu+GsArBreQ==} + engines: {node: '>=18.0.0'} + + '@aws-sdk/credential-provider-ini@3.972.10': + resolution: {integrity: sha512-uAXUMfnQJxJ25qeiX4e3Z36NTm1XT7woajV8BXx2yAUDD4jF6kubqnLEcqtiPzHANxmhta2SXm5PbDwSdhThBw==} + engines: {node: '>=20.0.0'} + + '@aws-sdk/credential-provider-login@3.952.0': + resolution: {integrity: sha512-jL9zc+e+7sZeJrHzYKK9GOjl1Ktinh0ORU3cM2uRBi7fuH/0zV9pdMN8PQnGXz0i4tJaKcZ1lrE4V0V6LB9NQg==} + engines: {node: '>=18.0.0'} + + '@aws-sdk/credential-provider-login@3.972.10': + resolution: {integrity: sha512-7Me+/EkY3kQC1nehBjb9ryc558N+a8R4Dg3rSV3zpiB7iQtvXh4gU3rV14h/dIbn2/VkK9sh55YdXamSjfdb/Q==} + engines: {node: '>=20.0.0'} + '@aws-sdk/credential-provider-node@3.929.0': resolution: {integrity: sha512-GhNZEacpa7fh8GNggshm5S93UK25bCV5aDK8c2vfe7Y3OxBiL89Ox5GUKCu0xIOqiBdfYkI9wvWCFsQRRn7Bjw==} engines: {node: '>=18.0.0'} + '@aws-sdk/credential-provider-node@3.952.0': + resolution: {integrity: sha512-pj7nidLrb3Dz9llcUPh6N0Yv1dBYTS9xJqi8u0kI8D5sn72HJMB+fIOhcDQVXXAw/dpVolOAH9FOAbog5JDAMg==} + engines: {node: '>=18.0.0'} + + '@aws-sdk/credential-provider-node@3.972.11': + resolution: {integrity: sha512-maPmjL7nOT93a1QdSDzdF/qLbI+jit3oslKp7g+pTbASewkSYax7FwboETdKRxufPfCdrsRzMW2pIJ+QA8e+Bg==} + engines: {node: '>=20.0.0'} + '@aws-sdk/credential-provider-process@3.928.0': resolution: {integrity: sha512-XL0juran8yhqwn0mreV+NJeHJOkcRBaExsvVn9fXWW37A4gLh4esSJxM2KbSNh0t+/Bk3ehBI5sL9xad+yRDuw==} engines: {node: '>=18.0.0'} + '@aws-sdk/credential-provider-process@3.947.0': + resolution: {integrity: sha512-WpanFbHe08SP1hAJNeDdBDVz9SGgMu/gc0XJ9u3uNpW99nKZjDpvPRAdW7WLA4K6essMjxWkguIGNOpij6Do2Q==} + engines: {node: '>=18.0.0'} + + '@aws-sdk/credential-provider-process@3.972.10': + resolution: {integrity: sha512-tk/XxFhk37rKviArOIYbJ8crXiN3Mzn7Tb147jH51JTweNgUOwmqN+s027uqc3d8UeAyUcPUH8Bmfj86SzOhBQ==} + engines: {node: '>=20.0.0'} + '@aws-sdk/credential-provider-sso@3.929.0': resolution: {integrity: sha512-aADe6cLo4+9MUOe0GnC5kUn8IduEKnTxqBlsciZOplU0/0+Rdp9rRh/e9ZBskeIXZ33eO2HG+KDAf1lvtPT7dA==} engines: {node: '>=18.0.0'} + '@aws-sdk/credential-provider-sso@3.952.0': + resolution: {integrity: sha512-1CQdP5RzxeXuEfytbAD5TgreY1c9OacjtCdO8+n9m05tpzBABoNBof0hcjzw1dtrWFH7deyUgfwCl1TAN3yBWQ==} + engines: {node: '>=18.0.0'} + + '@aws-sdk/credential-provider-sso@3.972.10': + resolution: {integrity: sha512-tIz/O0yV1s77/FjMTWvvzU2vsztap2POlbetheOyRXq+E3PQtLOzCYopasXP+aeO1oerw3PFd9eycLbiwpgZZA==} + engines: {node: '>=20.0.0'} + '@aws-sdk/credential-provider-web-identity@3.929.0': resolution: {integrity: sha512-L18JtW28xUZVTRHblgqZ8QTVGQfxpMLIuVYgQXrVWiY9Iz9EF4XrfZo3ywCAgqfgLi5pgg3fCxx/pe7uiMOs2w==} engines: {node: '>=18.0.0'} + '@aws-sdk/credential-provider-web-identity@3.952.0': + resolution: {integrity: sha512-5hJbfaZdHDAP8JlwplNbXJAat9Vv7L0AbTZzkbPIgjHhC3vrMf5r3a6I1HWFp5i5pXo7J45xyuf5uQGZJxJlCg==} + engines: {node: '>=18.0.0'} + + '@aws-sdk/credential-provider-web-identity@3.972.10': + resolution: {integrity: sha512-HFlIVx8mm+Au7hkO7Hq/ZkPomjTt26iRj8uWZqEE1cJWMZ2NKvieNiT1ngzWt60Bc2uD51LqQUqiwr5JDgS4iQ==} + engines: {node: '>=20.0.0'} + + '@aws-sdk/dynamodb-codec@3.972.13': + resolution: {integrity: sha512-dPKkCMX5BNnM4SRvX89s0SVxzlnujlLXZmZ9wJei2O6HT+5OQC7QvdfYv/odaTVc/s6MWv4FuoP4K9TTdTB0eg==} + engines: {node: '>=20.0.0'} + + '@aws-sdk/endpoint-cache@3.972.2': + resolution: {integrity: sha512-3L7mwqSLJ6ouZZKtCntoNF0HTYDNs1FDQqkGjoPWXcv1p0gnLotaDmLq1rIDqfu4ucOit0Re3ioLyYDUTpSroA==} + engines: {node: '>=20.0.0'} + + '@aws-sdk/lib-dynamodb@3.980.0': + resolution: {integrity: sha512-rot+9bSIUCjCJCh+BnH++ZYHCEUtTDVKkrBpN+WxbrEEGUvU1RNhkQEPXcLaf57UobRjoTI4m3LNBJCH7E6MCw==} + engines: {node: '>=20.0.0'} + peerDependencies: + '@aws-sdk/client-dynamodb': 3.980.0 + '@aws-sdk/middleware-bucket-endpoint@3.922.0': resolution: {integrity: sha512-Dpr2YeOaLFqt3q1hocwBesynE3x8/dXZqXZRuzSX/9/VQcwYBFChHAm4mTAl4zuvArtDbLrwzWSxmOWYZGtq5w==} engines: {node: '>=18.0.0'} + '@aws-sdk/middleware-endpoint-discovery@3.972.3': + resolution: {integrity: sha512-xAxA8/TOygQmMrzcw9CrlpTHCGWSG/lvzrHCySfSZpDN4/yVSfXO+gUwW9WxeskBmuv9IIFATOVpzc9EzfTZ0Q==} + engines: {node: '>=20.0.0'} + '@aws-sdk/middleware-expect-continue@3.922.0': resolution: {integrity: sha512-xmnLWMtmHJHJBupSWMUEW1gyxuRIeQ1Ov2xa8Tqq77fPr4Ft2AluEwiDMaZIMHoAvpxWKEEt9Si59Li7GIA+bQ==} engines: {node: '>=18.0.0'} @@ -742,6 +966,14 @@ packages: resolution: {integrity: sha512-HPquFgBnq/KqKRVkiuCt97PmWbKtxQ5iUNLEc6FIviqOoZTmaYG3EDsIbuFBz9C4RHJU4FKLmHL2bL3FEId6AA==} engines: {node: '>=18.0.0'} + '@aws-sdk/middleware-host-header@3.936.0': + resolution: {integrity: sha512-tAaObaAnsP1XnLGndfkGWFuzrJYuk9W0b/nLvol66t8FZExIAf/WdkT2NNAWOYxljVs++oHnyHBCxIlaHrzSiw==} + engines: {node: '>=18.0.0'} + + '@aws-sdk/middleware-host-header@3.972.3': + resolution: {integrity: sha512-aknPTb2M+G3s+0qLCx4Li/qGZH8IIYjugHMv15JTYMe6mgZO8VBpYgeGYsNMGCqCZOcWzuf900jFBG5bopfzmA==} + engines: {node: '>=20.0.0'} + '@aws-sdk/middleware-location-constraint@3.922.0': resolution: {integrity: sha512-T4iqd7WQ2DDjCH/0s50mnhdoX+IJns83ZE+3zj9IDlpU0N2aq8R91IG890qTfYkUEdP9yRm0xir/CNed+v6Dew==} engines: {node: '>=18.0.0'} @@ -750,10 +982,26 @@ packages: resolution: {integrity: sha512-AkvYO6b80FBm5/kk2E636zNNcNgjztNNUxpqVx+huyGn9ZqGTzS4kLqW2hO6CBe5APzVtPCtiQsXL24nzuOlAg==} engines: {node: '>=18.0.0'} + '@aws-sdk/middleware-logger@3.936.0': + resolution: {integrity: sha512-aPSJ12d3a3Ea5nyEnLbijCaaYJT2QjQ9iW+zGh5QcZYXmOGWbKVyPSxmVOboZQG+c1M8t6d2O7tqrwzIq8L8qw==} + engines: {node: '>=18.0.0'} + + '@aws-sdk/middleware-logger@3.972.3': + resolution: {integrity: sha512-Ftg09xNNRqaz9QNzlfdQWfpqMCJbsQdnZVJP55jfhbKi1+FTWxGuvfPoBhDHIovqWKjqbuiew3HuhxbJ0+OjgA==} + engines: {node: '>=20.0.0'} + '@aws-sdk/middleware-recursion-detection@3.922.0': resolution: {integrity: sha512-TtSCEDonV/9R0VhVlCpxZbp/9sxQvTTRKzIf8LxW3uXpby6Wl8IxEciBJlxmSkoqxh542WRcko7NYODlvL/gDA==} engines: {node: '>=18.0.0'} + '@aws-sdk/middleware-recursion-detection@3.948.0': + resolution: {integrity: sha512-Qa8Zj+EAqA0VlAVvxpRnpBpIWJI9KUwaioY1vkeNVwXPlNaz9y9zCKVM9iU9OZ5HXpoUg6TnhATAHXHAE8+QsQ==} + engines: {node: '>=18.0.0'} + + '@aws-sdk/middleware-recursion-detection@3.972.3': + resolution: {integrity: sha512-PY57QhzNuXHnwbJgbWYTrqIDHYSeOlhfYERTAuc16LKZpTZRJUjzBFokp9hF7u1fuGeE3D70ERXzdbMBOqQz7Q==} + engines: {node: '>=20.0.0'} + '@aws-sdk/middleware-sdk-s3@3.928.0': resolution: {integrity: sha512-LTkjS6cpJ2PEtsottTKq7JxZV0oH+QJ12P/dGNPZL4URayjEMBVR/dp4zh835X/FPXzijga3sdotlIKzuFy9FA==} engines: {node: '>=18.0.0'} @@ -766,14 +1014,38 @@ packages: resolution: {integrity: sha512-ESvcfLx5PtpdUM3ptCwb80toBTd3y5I4w5jaeOPHihiZr7jkRLE/nsaCKzlqscPs6UQ8xI0maav04JUiTskcHw==} engines: {node: '>=18.0.0'} + '@aws-sdk/middleware-user-agent@3.947.0': + resolution: {integrity: sha512-7rpKV8YNgCP2R4F9RjWZFcD2R+SO/0R4VHIbY9iZJdH2MzzJ8ZG7h8dZ2m8QkQd1fjx4wrFJGGPJUTYXPV3baA==} + engines: {node: '>=18.0.0'} + + '@aws-sdk/middleware-user-agent@3.972.12': + resolution: {integrity: sha512-iv9toQZloEJp+dIuOr+1XWGmBMLU9c2qqNtgscfnEBZnUq3qKdBJHmLTKoq3mkLlV+41GrCWn8LrOunc6OlP6g==} + engines: {node: '>=20.0.0'} + '@aws-sdk/nested-clients@3.929.0': resolution: {integrity: sha512-emR4LTSupxPed1ni0zVxz5msezz/gA1YYXooiW567+NyhvLgSzDvNjK7GPU1waLCj1LrRFe7NkXX1pwa5sPrpw==} engines: {node: '>=18.0.0'} + '@aws-sdk/nested-clients@3.952.0': + resolution: {integrity: sha512-OtuirjxuOqZyDcI0q4WtoyWfkq3nSnbH41JwJQsXJefduWcww1FQe5TL1JfYCU7seUxHzK8rg2nFxUBuqUlZtg==} + engines: {node: '>=18.0.0'} + + '@aws-sdk/nested-clients@3.996.0': + resolution: {integrity: sha512-edZwYLgRI0rZlH9Hru9+JvTsR1OAxuCRGEtJohkZneIJ5JIYzvFoMR1gaASjl1aPKRhjkCv8SSAb7hes5a1GGA==} + engines: {node: '>=20.0.0'} + '@aws-sdk/region-config-resolver@3.925.0': resolution: {integrity: sha512-FOthcdF9oDb1pfQBRCfWPZhJZT5wqpvdAS5aJzB1WDZ+6EuaAhLzLH/fW1slDunIqq1PSQGG3uSnVglVVOvPHQ==} engines: {node: '>=18.0.0'} + '@aws-sdk/region-config-resolver@3.936.0': + resolution: {integrity: sha512-wOKhzzWsshXGduxO4pqSiNyL9oUtk4BEvjWm9aaq6Hmfdoydq6v6t0rAGHWPjFwy9z2haovGRi3C8IxdMB4muw==} + engines: {node: '>=18.0.0'} + + '@aws-sdk/region-config-resolver@3.972.3': + resolution: {integrity: sha512-v4J8qYAWfOMcZ4MJUyatntOicTzEMaU7j3OpkRCGGFSL2NgXQ5VbxauIyORA+pxdKZ0qQG2tCQjQjZDlXEC3Ow==} + engines: {node: '>=20.0.0'} + '@aws-sdk/signature-v4-multi-region@3.928.0': resolution: {integrity: sha512-1+Ic8+MyqQy+OE6QDoQKVCIcSZO+ETmLLLpVS5yu0fihBU85B5HHU7iaKX1qX7lEaGPMpSN/mbHW0VpyQ0Xqaw==} engines: {node: '>=18.0.0'} @@ -782,18 +1054,52 @@ packages: resolution: {integrity: sha512-78kph1R6TVJ53VXDKUmt64HMqWjTECLymJ7kLguz2QJiWh2ZdLvpyYGvaueEwwhisHYBh2qef1tGIf/PpEb8SQ==} engines: {node: '>=18.0.0'} + '@aws-sdk/token-providers@3.952.0': + resolution: {integrity: sha512-IpQVC9WOeXQlCEcFVNXWDIKy92CH1Az37u9K0H3DF/HT56AjhyDVKQQfHUy00nt7bHFe3u0K5+zlwErBeKy5ZA==} + engines: {node: '>=18.0.0'} + + '@aws-sdk/token-providers@3.996.0': + resolution: {integrity: sha512-jzBmlG97hYPdHjFs7G11fBgVArcwUrZX+SbGeQMph7teEWLDqIruKV+N0uzxFJF2GJJJ0UnMaKhv3PcXMltySg==} + engines: {node: '>=20.0.0'} + '@aws-sdk/types@3.922.0': resolution: {integrity: sha512-eLA6XjVobAUAMivvM7DBL79mnHyrm+32TkXNWZua5mnxF+6kQCfblKKJvxMZLGosO53/Ex46ogim8IY5Nbqv2w==} engines: {node: '>=18.0.0'} + '@aws-sdk/types@3.936.0': + resolution: {integrity: sha512-uz0/VlMd2pP5MepdrHizd+T+OKfyK4r3OA9JI+L/lPKg0YFQosdJNCKisr6o70E3dh8iMpFYxF1UN/4uZsyARg==} + engines: {node: '>=18.0.0'} + + '@aws-sdk/types@3.973.1': + resolution: {integrity: sha512-DwHBiMNOB468JiX6+i34c+THsKHErYUdNQ3HexeXZvVn4zouLjgaS4FejiGSi2HyBuzuyHg7SuOPmjSvoU9NRg==} + engines: {node: '>=20.0.0'} + '@aws-sdk/util-arn-parser@3.893.0': resolution: {integrity: sha512-u8H4f2Zsi19DGnwj5FSZzDMhytYF/bCh37vAtBsn3cNDL3YG578X5oc+wSX54pM3tOxS+NY7tvOAo52SW7koUA==} engines: {node: '>=18.0.0'} + '@aws-sdk/util-dynamodb@3.980.0': + resolution: {integrity: sha512-jG/yzr/JLFl7II9TTDWRKJRHThTXYNDYy694bRTj7JCXCU/Gb11ir5fJ7sV6FhlR9LrIaDb7Fft3RifvEnZcSQ==} + engines: {node: '>=20.0.0'} + peerDependencies: + '@aws-sdk/client-dynamodb': 3.980.0 + '@aws-sdk/util-endpoints@3.922.0': resolution: {integrity: sha512-4ZdQCSuNMY8HMlR1YN4MRDdXuKd+uQTeKIr5/pIM+g3TjInZoj8imvXudjcrFGA63UF3t92YVTkBq88mg58RXQ==} engines: {node: '>=18.0.0'} + '@aws-sdk/util-endpoints@3.936.0': + resolution: {integrity: sha512-0Zx3Ntdpu+z9Wlm7JKUBOzS9EunwKAb4KdGUQQxDqh5Lc3ta5uBoub+FgmVuzwnmBu9U1Os8UuwVTH0Lgu+P5w==} + engines: {node: '>=18.0.0'} + + '@aws-sdk/util-endpoints@3.980.0': + resolution: {integrity: sha512-AjKBNEc+rjOZQE1HwcD9aCELqg1GmUj1rtICKuY8cgwB73xJ4U/kNyqKKpN2k9emGqlfDY2D8itIp/vDc6OKpw==} + engines: {node: '>=20.0.0'} + + '@aws-sdk/util-endpoints@3.996.0': + resolution: {integrity: sha512-EhSBGWSGQ6Jcbt6jRyX1/0EV7rf+6RGbIIskN0MTtHk0k8uj5FAa1FZhLf+1ETfnDTy/BT39t5IUOQiZL5X1jQ==} + engines: {node: '>=20.0.0'} + '@aws-sdk/util-locate-window@3.893.0': resolution: {integrity: sha512-T89pFfgat6c8nMmpI8eKjBcDcgJq36+m9oiXbcUzeU55MP9ZuGgBomGjGnHaEyF36jenW9gmg3NfZDm0AO2XPg==} engines: {node: '>=18.0.0'} @@ -801,6 +1107,12 @@ packages: '@aws-sdk/util-user-agent-browser@3.922.0': resolution: {integrity: sha512-qOJAERZ3Plj1st7M4Q5henl5FRpE30uLm6L9edZqZXGR6c7ry9jzexWamWVpQ4H4xVAVmiO9dIEBAfbq4mduOA==} + '@aws-sdk/util-user-agent-browser@3.936.0': + resolution: {integrity: sha512-eZ/XF6NxMtu+iCma58GRNRxSq4lHo6zHQLOZRIeL/ghqYJirqHdenMOwrzPettj60KWlv827RVebP9oNVrwZbw==} + + '@aws-sdk/util-user-agent-browser@3.972.3': + resolution: {integrity: sha512-JurOwkRUcXD/5MTDBcqdyQ9eVedtAsZgw5rBwktsPTN7QtPiS2Ld1jkJepNgYoCufz1Wcut9iup7GJDoIHp8Fw==} + '@aws-sdk/util-user-agent-node@3.928.0': resolution: {integrity: sha512-s0jP67nQLLWVWfBtqTkZUkSWK5e6OI+rs+wFya2h9VLyWBFir17XSDI891s8HZKIVCEl8eBrup+hhywm4nsIAA==} engines: {node: '>=18.0.0'} @@ -810,14 +1122,44 @@ packages: aws-crt: optional: true + '@aws-sdk/util-user-agent-node@3.947.0': + resolution: {integrity: sha512-+vhHoDrdbb+zerV4noQk1DHaUMNzWFWPpPYjVTwW2186k5BEJIecAMChYkghRrBVJ3KPWP1+JnZwOd72F3d4rQ==} + engines: {node: '>=18.0.0'} + peerDependencies: + aws-crt: '>=1.0.0' + peerDependenciesMeta: + aws-crt: + optional: true + + '@aws-sdk/util-user-agent-node@3.972.11': + resolution: {integrity: sha512-pQr35pSZANfUb0mJ9H87pziJQ39jW1D7xFRwh36eWfrEclbKoIqrzpOIVz49o1Jq9ZQzOtjS7rQVvt7V4w5awA==} + engines: {node: '>=20.0.0'} + peerDependencies: + aws-crt: '>=1.0.0' + peerDependenciesMeta: + aws-crt: + optional: true + '@aws-sdk/xml-builder@3.921.0': resolution: {integrity: sha512-LVHg0jgjyicKKvpNIEMXIMr1EBViESxcPkqfOlT+X1FkmUMTNZEEVF18tOJg4m4hV5vxtkWcqtr4IEeWa1C41Q==} engines: {node: '>=18.0.0'} + '@aws-sdk/xml-builder@3.930.0': + resolution: {integrity: sha512-YIfkD17GocxdmlUVc3ia52QhcWuRIUJonbF8A2CYfcWNV3HzvAqpcPeC0bYUhkK+8e8YO1ARnLKZQE0TlwzorA==} + engines: {node: '>=18.0.0'} + + '@aws-sdk/xml-builder@3.972.5': + resolution: {integrity: sha512-mCae5Ys6Qm1LDu0qdGwx2UQ63ONUe+FHw908fJzLDqFKTDBK4LDZUqKWm4OkTCNFq19bftjsBSESIGLD/s3/rA==} + engines: {node: '>=20.0.0'} + '@aws/lambda-invoke-store@0.1.1': resolution: {integrity: sha512-RcLam17LdlbSOSp9VxmUu1eI6Mwxp+OwhD2QhiSNmNCzoDb0EeUXTD2n/WbcnrAYMGlmf05th6QYq23VqvJqpA==} engines: {node: '>=18.0.0'} + '@aws/lambda-invoke-store@0.2.3': + resolution: {integrity: sha512-oLvsaPMTBejkkmHhjf09xTgk71mOqyr/409NKhRIL08If7AhVfUsJhVsx386uJaqNd42v9kWamQ9lFbkoC2dYw==} + engines: {node: '>=18.0.0'} + '@azu/format-text@1.0.2': resolution: {integrity: sha512-Swi4N7Edy1Eqq82GxgEECXSSLyn6GOb5htRFPzBDdUkECGXtlf12ynO5oJSpWKPwCaUssOu7NfhDcCWpIC6Ywg==} @@ -1259,6 +1601,71 @@ packages: '@gerrit0/mini-shiki@3.15.0': resolution: {integrity: sha512-L5IHdZIDa4bG4yJaOzfasOH/o22MCesY0mx+n6VATbaiCtMeR59pdRqYk4bEiQkIHfxsHPNgdi7VJlZb2FhdMQ==} + '@h4ad/serverless-adapter@4.4.0': + resolution: {integrity: sha512-Cj/dBqhOmmzf1ILrXPppEA4e8qWGSm/Mod0uAsftupmMrCGUjvzLq4PBvevnK9ASC5WPbAtnbgkwao+f4tDklw==} + engines: {node: '>=18.0.0'} + peerDependencies: + '@apollo/server': '>= 4.0.0' + '@azure/functions': '>= 2.0.0' + '@deepkit/http': '>= 1.0.1-alpha.94' + '@google-cloud/functions-framework': '>= 3.0.0' + '@hapi/hapi': '>= 21.0.0' + '@trpc/server': '>= 10.0.0' + '@types/aws-lambda': '>= 8.10.92' + '@types/body-parser': '>= 1.19.2' + '@types/cors': '>= 2.8.12' + '@types/express': '>= 4.15.4' + '@types/hapi': '*' + '@types/koa': '>= 2.11.2' + body-parser: '>= 1.20.0' + cors: '>= 2.8.5' + express: '>= 4.15.4' + fastify: '>= 4.0.0' + firebase-admin: '>= 11.0.0' + firebase-functions: '>= 4.0.0' + http-errors: '>= 2.0.0' + koa: '>= 2.5.1' + reflect-metadata: '>= 0.1.13' + peerDependenciesMeta: + '@apollo/server': + optional: true + '@azure/functions': + optional: true + '@deepkit/http': + optional: true + '@google-cloud/functions-framework': + optional: true + '@hapi/hapi': + optional: true + '@trpc/server': + optional: true + '@types/aws-lambda': + optional: true + '@types/express': + optional: true + '@types/hapi': + optional: true + '@types/koa': + optional: true + body-parser: + optional: true + cors: + optional: true + express: + optional: true + fastify: + optional: true + firebase-admin: + optional: true + firebase-functions: + optional: true + http-errors: + optional: true + koa: + optional: true + reflect-metadata: + optional: true + '@hono/node-server@1.19.9': resolution: {integrity: sha512-vHL6w3ecZsky+8P5MD+eFfaGTyCeOHUIFYMGpQGbrBTSmNNoxv0if69rEZ5giu36weC5saFuznL411gRX7bJDw==} engines: {node: '>=18.14.1'} @@ -2575,6 +2982,9 @@ packages: resolution: {integrity: sha512-Nqc90v4lWCXyakD6xNyNACBJNJ0tNCwj2WNk/7ivyacYHxiITVgmLUFXTBOeCdy79iz6HtN9Y31uw/jbLrdOAg==} engines: {node: '>=20.0.0'} + '@serverless/event-mocks@1.1.1': + resolution: {integrity: sha512-YAV5V/y+XIOfd+HEVeXfPWZb8C6QLruFk9tBivoX2roQLWVq145s4uxf8D0QioCueuRzkukHUS4JIj+KVoS34A==} + '@shikijs/core@2.5.0': resolution: {integrity: sha512-uu/8RExTKtavlpH7XqnVYBrfBkUc20ngXiX9NSrBhOVZYv/7XQRKUyhtkeflY5QsxC0GbJThCerruZfsUaSldg==} @@ -2640,6 +3050,10 @@ packages: resolution: {integrity: sha512-j7HwVkBw68YW8UmFRcjZOmssE77Rvk0GWAIN1oFBhsaovQmZWYCIcGa9/pwRB0ExI8Sk9MWNALTjftjHZea7VA==} engines: {node: '>=18.0.0'} + '@smithy/abort-controller@4.2.9': + resolution: {integrity: sha512-6YGSygFmck1vMjzSxbjEPKMm1xWUr2+w+F8kWVc8rqKQYd1C5zZftvxGii4ti4Mh5ulIXZtAUoXS88Hhu6fkjQ==} + engines: {node: '>=18.0.0'} + '@smithy/chunked-blob-reader-native@4.2.1': resolution: {integrity: sha512-lX9Ay+6LisTfpLid2zZtIhSEjHMZoAR5hHCR4H7tBz/Zkfr5ea8RcQ7Tk4mi0P76p4cN+Btz16Ffno7YHpKXnQ==} engines: {node: '>=18.0.0'} @@ -2652,15 +3066,27 @@ packages: resolution: {integrity: sha512-ezHLe1tKLUxDJo2LHtDuEDyWXolw8WGOR92qb4bQdWq/zKenO5BvctZGrVJBK08zjezSk7bmbKFOXIVyChvDLw==} engines: {node: '>=18.0.0'} + '@smithy/config-resolver@4.4.7': + resolution: {integrity: sha512-RISbtc12JKdFRYadt2kW12Cp6XCSU00uFaBZPZqInNVSrRdJFPY/S6nd6/sV7+ySTgGPiKrERtnimEFI6sSweQ==} + engines: {node: '>=18.0.0'} + '@smithy/core@3.18.0': resolution: {integrity: sha512-vGSDXOJFZgOPTatSI1ly7Gwyy/d/R9zh2TO3y0JZ0uut5qQ88p9IaWaZYIWSSqtdekNM4CGok/JppxbAff4KcQ==} engines: {node: '>=18.0.0'} deprecated: Please upgrade your lockfile to use the latest 3.x version of @smithy/core for various fixes, see https://github.com/smithy-lang/smithy-typescript/blob/main/packages/core/CHANGELOG.md + '@smithy/core@3.23.4': + resolution: {integrity: sha512-IH7G3hWxUhd2Z6HtvjZ1EiyDBCRYRr2sngOB9KUWf96XQ8JP2O5ascUH6TouW5YCIMFaVnKADEscM/vUfI3TvA==} + engines: {node: '>=18.0.0'} + '@smithy/credential-provider-imds@4.2.5': resolution: {integrity: sha512-BZwotjoZWn9+36nimwm/OLIcVe+KYRwzMjfhd4QT7QxPm9WY0HiOV8t/Wlh+HVUif0SBVV7ksq8//hPaBC/okQ==} engines: {node: '>=18.0.0'} + '@smithy/credential-provider-imds@4.2.9': + resolution: {integrity: sha512-Jf723a38EGAzWHxJHzb9DtBq7lrvdJlkCAPWQdN/oiznovx5yWXCFCVspzDe8JU6b+k9hJXYB5duFZpb+3mB6Q==} + engines: {node: '>=18.0.0'} + '@smithy/eventstream-codec@4.2.5': resolution: {integrity: sha512-Ogt4Zi9hEbIP17oQMd68qYOHUzmH47UkK7q7Gl55iIm9oKt27MUGrC5JfpMroeHjdkOliOA4Qt3NQ1xMq/nrlA==} engines: {node: '>=18.0.0'} @@ -2681,6 +3107,10 @@ packages: resolution: {integrity: sha512-G9WSqbST45bmIFaeNuP/EnC19Rhp54CcVdX9PDL1zyEB514WsDVXhlyihKlGXnRycmHNmVv88Bvvt4EYxWef/Q==} engines: {node: '>=18.0.0'} + '@smithy/fetch-http-handler@5.3.10': + resolution: {integrity: sha512-qF4EcrEtEf2P6f2kGGuSVe1lan26cn7PsWJBC3vZJ6D16Fm5FSN06udOMVoW6hjzQM3W7VDFwtyUG2szQY50dA==} + engines: {node: '>=18.0.0'} + '@smithy/fetch-http-handler@5.3.6': resolution: {integrity: sha512-3+RG3EA6BBJ/ofZUeTFJA7mHfSYrZtQIrDP9dI8Lf7X6Jbos2jptuLrAAteDiFVrmbEmLSuRG/bUKzfAXk7dhg==} engines: {node: '>=18.0.0'} @@ -2693,6 +3123,10 @@ packages: resolution: {integrity: sha512-DpYX914YOfA3UDT9CN1BM787PcHfWRBB43fFGCYrZFUH0Jv+5t8yYl+Pd5PW4+QzoGEDvn5d5QIO4j2HyYZQSA==} engines: {node: '>=18.0.0'} + '@smithy/hash-node@4.2.9': + resolution: {integrity: sha512-/iSYAwSIA/SAeLga2YEpPLLOmw3n86RW4/bkhxtY1DSTR9z5HGjbYTzPaBKv2m8a4nK1rqZWchhl41qTaqMLbg==} + engines: {node: '>=18.0.0'} + '@smithy/hash-stream-node@4.2.5': resolution: {integrity: sha512-6+do24VnEyvWcGdHXomlpd0m8bfZePpUKBy7m311n+JuRwug8J4dCanJdTymx//8mi0nlkflZBvJe+dEO/O12Q==} engines: {node: '>=18.0.0'} @@ -2701,6 +3135,10 @@ packages: resolution: {integrity: sha512-2L2erASEro1WC5nV+plwIMxrTXpvpfzl4e+Nre6vBVRR2HKeGGcvpJyyL3/PpiSg+cJG2KpTmZmq934Olb6e5A==} engines: {node: '>=18.0.0'} + '@smithy/invalid-dependency@4.2.9': + resolution: {integrity: sha512-J+0rlwWZKgOYugVgRE5VlVz/UFV+6cIpZkmfWBq1ld1x3htKDdHOutYhZTURIvSVztWn0T3aghCdEzGdXXsSMw==} + engines: {node: '>=18.0.0'} + '@smithy/is-array-buffer@2.2.0': resolution: {integrity: sha512-GGP3O9QFD24uGeAXYUjwSTXARoqpZykHadOmA8G5vfJPK0/DC67qa//0qvqrJzL1xc8WQWX7/yc7fwudjPHPhA==} engines: {node: '>=14.0.0'} @@ -2709,22 +3147,46 @@ packages: resolution: {integrity: sha512-DZZZBvC7sjcYh4MazJSGiWMI2L7E0oCiRHREDzIxi/M2LY79/21iXt6aPLHge82wi5LsuRF5A06Ds3+0mlh6CQ==} engines: {node: '>=18.0.0'} + '@smithy/is-array-buffer@4.2.1': + resolution: {integrity: sha512-Yfu664Qbf1B4IYIsYgKoABt010daZjkaCRvdU/sPnZG6TtHOB0md0RjNdLGzxe5UIdn9js4ftPICzmkRa9RJ4Q==} + engines: {node: '>=18.0.0'} + '@smithy/md5-js@4.2.5': resolution: {integrity: sha512-Bt6jpSTMWfjCtC0s79gZ/WZ1w90grfmopVOWqkI2ovhjpD5Q2XRXuecIPB9689L2+cCySMbaXDhBPU56FKNDNg==} engines: {node: '>=18.0.0'} + '@smithy/middleware-compression@4.3.33': + resolution: {integrity: sha512-GRDCjRd5//dK1gYHJKXdiChqW46HwRr+/YMctKL3PtlYOxD0+I0PqgTTKfSvMV7Q4SsEkBYoaF4+kwWDFs/lbg==} + engines: {node: '>=18.0.0'} + '@smithy/middleware-content-length@4.2.5': resolution: {integrity: sha512-Y/RabVa5vbl5FuHYV2vUCwvh/dqzrEY/K2yWPSqvhFUwIY0atLqO4TienjBXakoy4zrKAMCZwg+YEqmH7jaN7A==} engines: {node: '>=18.0.0'} + '@smithy/middleware-content-length@4.2.9': + resolution: {integrity: sha512-9ViCZhFkmLUDyIPeBAsW7h5/Tcix806gWqd/BBqwW6KB8mhgZTTqjRMsyTTmMo2zpF+KckpYQsSiiFrIGHRaFw==} + engines: {node: '>=18.0.0'} + '@smithy/middleware-endpoint@4.3.7': resolution: {integrity: sha512-i8Mi8OuY6Yi82Foe3iu7/yhBj1HBRoOQwBSsUNYglJTNSFaWYTNM2NauBBs/7pq2sqkLRqeUXA3Ogi2utzpUlQ==} engines: {node: '>=18.0.0'} + '@smithy/middleware-endpoint@4.4.18': + resolution: {integrity: sha512-4OS3TP3IWZysT8KlSG/UwfKdelJmuQ2CqVNfrkjm2Rsm146/DuSTfXiD1ulgWpp9L6lJmPYfWTp7/m4b4dQSdQ==} + engines: {node: '>=18.0.0'} + + '@smithy/middleware-retry@4.4.35': + resolution: {integrity: sha512-sz+Th9ofKypOtaboPTcyZtIfCs2LNb84bzxEhPffCElyMorVYDBdeGzxYqSLC6gWaZUqpPSbj5F6TIxYUlSCfQ==} + engines: {node: '>=18.0.0'} + '@smithy/middleware-retry@4.4.7': resolution: {integrity: sha512-E7Vc6WHCHlzDRTx1W0jZ6J1L6ziEV0PIWcUdmfL4y+c8r7WYr6I+LkQudaD8Nfb7C5c4P3SQ972OmXHtv6m/OA==} engines: {node: '>=18.0.0'} + '@smithy/middleware-serde@4.2.10': + resolution: {integrity: sha512-BQsdoi7ma4siJAzD0S6MedNPhiMcTdTLUqEUjrHeT1TJppBKWnwqySg34Oh/uGRhJeBd1sAH2t5tghBvcyD6tw==} + engines: {node: '>=18.0.0'} + '@smithy/middleware-serde@4.2.5': resolution: {integrity: sha512-La1ldWTJTZ5NqQyPqnCNeH9B+zjFhrNoQIL1jTh4zuqXRlmXhxYHhMtI1/92OlnoAtp6JoN7kzuwhWoXrBwPqg==} engines: {node: '>=18.0.0'} @@ -2733,10 +3195,22 @@ packages: resolution: {integrity: sha512-bYrutc+neOyWxtZdbB2USbQttZN0mXaOyYLIsaTbJhFsfpXyGWUxJpEuO1rJ8IIJm2qH4+xJT0mxUSsEDTYwdQ==} engines: {node: '>=18.0.0'} + '@smithy/middleware-stack@4.2.9': + resolution: {integrity: sha512-pid7ksBr7nm0X/3paIlGo9Fh3UK1pQ5yH0007tBmdkVvv+AsBZAOzC2dmLhlzDWKkSB+ZCiiyDArjAW3klkbMg==} + engines: {node: '>=18.0.0'} + '@smithy/node-config-provider@4.3.5': resolution: {integrity: sha512-UTurh1C4qkVCtqggI36DGbLB2Kv8UlcFdMXDcWMbqVY2uRg0XmT9Pb4Vj6oSQ34eizO1fvR0RnFV4Axw4IrrAg==} engines: {node: '>=18.0.0'} + '@smithy/node-config-provider@4.3.9': + resolution: {integrity: sha512-EjdDTVGnnyJ9y8jXIfkF45UUZs21/Pp8xaMTZySLoC0xI3EhY7jq4co3LQnhh/bB6VVamd9ELpYJWLDw2ANhZA==} + engines: {node: '>=18.0.0'} + + '@smithy/node-http-handler@4.4.11': + resolution: {integrity: sha512-kQNJFwzYA9y+Fj3h9t1ToXYOJBobwUVEc6/WX45urJXyErgG0WOsres8Se8BAiFCMe8P06OkzRgakv7bQ5S+6Q==} + engines: {node: '>=18.0.0'} + '@smithy/node-http-handler@4.4.5': resolution: {integrity: sha512-CMnzM9R2WqlqXQGtIlsHMEZfXKJVTIrqCNoSd/QpAyp+Dw0a1Vps13l6ma1fH8g7zSPNsA59B/kWgeylFuA/lw==} engines: {node: '>=18.0.0'} @@ -2745,34 +3219,70 @@ packages: resolution: {integrity: sha512-8iLN1XSE1rl4MuxvQ+5OSk/Zb5El7NJZ1td6Tn+8dQQHIjp59Lwl6bd0+nzw6SKm2wSSriH2v/I9LPzUic7EOg==} engines: {node: '>=18.0.0'} + '@smithy/property-provider@4.2.9': + resolution: {integrity: sha512-ibHwLxq4KlbfueoNxMNrZkG+O7V/5XKrewhDGYn0p9DYKCsdsofuWHKdX3QW4zHlAUfLStqdCUSDi/q/9WSjwA==} + engines: {node: '>=18.0.0'} + '@smithy/protocol-http@5.3.5': resolution: {integrity: sha512-RlaL+sA0LNMp03bf7XPbFmT5gN+w3besXSWMkA8rcmxLSVfiEXElQi4O2IWwPfxzcHkxqrwBFMbngB8yx/RvaQ==} engines: {node: '>=18.0.0'} + '@smithy/protocol-http@5.3.9': + resolution: {integrity: sha512-PRy4yZqsKI3Eab8TLc16Dj2NzC4dnw/8E95+++Jc+wwlkjBpAq3tNLqkLHMmSvDfxKQ+X5PmmCYt+rM/GcMKPA==} + engines: {node: '>=18.0.0'} + '@smithy/querystring-builder@4.2.5': resolution: {integrity: sha512-y98otMI1saoajeik2kLfGyRp11e5U/iJYH/wLCh3aTV/XutbGT9nziKGkgCaMD1ghK7p6htHMm6b6scl9JRUWg==} engines: {node: '>=18.0.0'} + '@smithy/querystring-builder@4.2.9': + resolution: {integrity: sha512-/AIDaq0+ehv+QfeyAjCUFShwHIt+FA1IodsV/2AZE5h4PUZcQYv5sjmy9V67UWfsBoTjOPKUFYSRfGoNW9T2UQ==} + engines: {node: '>=18.0.0'} + '@smithy/querystring-parser@4.2.5': resolution: {integrity: sha512-031WCTdPYgiQRYNPXznHXof2YM0GwL6SeaSyTH/P72M1Vz73TvCNH2Nq8Iu2IEPq9QP2yx0/nrw5YmSeAi/AjQ==} engines: {node: '>=18.0.0'} + '@smithy/querystring-parser@4.2.9': + resolution: {integrity: sha512-kZ9AHhrYTea3UoklXudEnyA4duy9KAWERC28+ft8y8HIhR3yGsjv1PFTgzMpB+5L4tQKXNTwFbVJMeRK20vpHQ==} + engines: {node: '>=18.0.0'} + '@smithy/service-error-classification@4.2.5': resolution: {integrity: sha512-8fEvK+WPE3wUAcDvqDQG1Vk3ANLR8Px979te96m84CbKAjBVf25rPYSzb4xU4hlTyho7VhOGnh5i62D/JVF0JQ==} engines: {node: '>=18.0.0'} + '@smithy/service-error-classification@4.2.9': + resolution: {integrity: sha512-DYYd4xrm9Ozik+ZT4f5ZqSXdzscVHF/tFCzqieIFcLrjRDxWSgRtvtXOohJGoniLfPcBcy5ltR3tp2Lw4/d9ag==} + engines: {node: '>=18.0.0'} + '@smithy/shared-ini-file-loader@4.4.0': resolution: {integrity: sha512-5WmZ5+kJgJDjwXXIzr1vDTG+RhF9wzSODQBfkrQ2VVkYALKGvZX1lgVSxEkgicSAFnFhPj5rudJV0zoinqS0bA==} engines: {node: '>=18.0.0'} + '@smithy/shared-ini-file-loader@4.4.4': + resolution: {integrity: sha512-tA5Cm11BHQCk/67y6VPIWydLh/pMY90jqOEWIr/2VAzTOoDwGpwp0C/AuHBc3/xWSOA5m5PXLN+lIOrsnTm/PQ==} + engines: {node: '>=18.0.0'} + '@smithy/signature-v4@5.3.5': resolution: {integrity: sha512-xSUfMu1FT7ccfSXkoLl/QRQBi2rOvi3tiBZU2Tdy3I6cgvZ6SEi9QNey+lqps/sJRnogIS+lq+B1gxxbra2a/w==} engines: {node: '>=18.0.0'} + '@smithy/signature-v4@5.3.9': + resolution: {integrity: sha512-QZKreDINuWf6KIcUUuurjBJiPPSRpMyU3sFPKk6urNAYcKkXhe6Ma+9MBX9e87yDnZfa/cqNMxobkdi9bpJt1A==} + engines: {node: '>=18.0.0'} + + '@smithy/smithy-client@4.11.7': + resolution: {integrity: sha512-gQP2J3qB/Wmc26gdmB8gA6zq2o2spG5sEU3o7TaTATBJEk29sYGWdEFoGEy91BczSpifTo0DQhVYjZXBEVcrpA==} + engines: {node: '>=18.0.0'} + '@smithy/smithy-client@4.9.3': resolution: {integrity: sha512-8tlueuTgV5n7inQCkhyptrB3jo2AO80uGrps/XTYZivv5MFQKKBj3CIWIGMI2fRY5LEduIiazOhAWdFknY1O9w==} engines: {node: '>=18.0.0'} + '@smithy/types@4.12.1': + resolution: {integrity: sha512-ow30Ze/DD02KH2p0eMyIF2+qJzGyNb0kFrnTRtPpuOkQ4hrgvLdaU4YC6r/K8aOrCML4FH0Cmm0aI4503L1Hwg==} + engines: {node: '>=18.0.0'} + '@smithy/types@4.9.0': resolution: {integrity: sha512-MvUbdnXDTwykR8cB1WZvNNwqoWVaTRA0RLlLmf/cIFNMM2cKWz01X4Ly6SMC4Kks30r8tT3Cty0jmeWfiuyHTA==} engines: {node: '>=18.0.0'} @@ -2781,14 +3291,26 @@ packages: resolution: {integrity: sha512-VaxMGsilqFnK1CeBX+LXnSuaMx4sTL/6znSZh2829txWieazdVxr54HmiyTsIbpOTLcf5nYpq9lpzmwRdxj6rQ==} engines: {node: '>=18.0.0'} + '@smithy/url-parser@4.2.9': + resolution: {integrity: sha512-gYs8FrnwKoIvL+GyPz6VvweCkrXqHeD+KnOAxB+NFy6mLr4l75lFrn3dZ413DG0K2TvFtN7L43x7r8hyyohYdg==} + engines: {node: '>=18.0.0'} + '@smithy/util-base64@4.3.0': resolution: {integrity: sha512-GkXZ59JfyxsIwNTWFnjmFEI8kZpRNIBfxKjv09+nkAWPt/4aGaEWMM04m4sxgNVWkbt2MdSvE3KF/PfX4nFedQ==} engines: {node: '>=18.0.0'} + '@smithy/util-base64@4.3.1': + resolution: {integrity: sha512-BKGuawX4Doq/bI/uEmg+Zyc36rJKWuin3py89PquXBIBqmbnJwBBsmKhdHfNEp0+A4TDgLmT/3MSKZ1SxHcR6w==} + engines: {node: '>=18.0.0'} + '@smithy/util-body-length-browser@4.2.0': resolution: {integrity: sha512-Fkoh/I76szMKJnBXWPdFkQJl2r9SjPt3cMzLdOB6eJ4Pnpas8hVoWPYemX/peO0yrrvldgCUVJqOAjUrOLjbxg==} engines: {node: '>=18.0.0'} + '@smithy/util-body-length-browser@4.2.1': + resolution: {integrity: sha512-SiJeLiozrAoCrgDBUgsVbmqHmMgg/2bA15AzcbcW+zan7SuyAVHN4xTSbq0GlebAIwlcaX32xacnrG488/J/6g==} + engines: {node: '>=18.0.0'} + '@smithy/util-body-length-node@4.2.1': resolution: {integrity: sha512-h53dz/pISVrVrfxV1iqXlx5pRg3V2YWFcSQyPyXZRrZoZj4R4DeWRDo1a7dd3CPTcFi3kE+98tuNyD2axyZReA==} engines: {node: '>=18.0.0'} @@ -2801,14 +3323,30 @@ packages: resolution: {integrity: sha512-kAY9hTKulTNevM2nlRtxAG2FQ3B2OR6QIrPY3zE5LqJy1oxzmgBGsHLWTcNhWXKchgA0WHW+mZkQrng/pgcCew==} engines: {node: '>=18.0.0'} + '@smithy/util-buffer-from@4.2.1': + resolution: {integrity: sha512-/swhmt1qTiVkaejlmMPPDgZhEaWb/HWMGRBheaxwuVkusp/z+ErJyQxO6kaXumOciZSWlmq6Z5mNylCd33X7Ig==} + engines: {node: '>=18.0.0'} + '@smithy/util-config-provider@4.2.0': resolution: {integrity: sha512-YEjpl6XJ36FTKmD+kRJJWYvrHeUvm5ykaUS5xK+6oXffQPHeEM4/nXlZPe+Wu0lsgRUcNZiliYNh/y7q9c2y6Q==} engines: {node: '>=18.0.0'} - '@smithy/util-defaults-mode-browser@4.3.6': + '@smithy/util-config-provider@4.2.1': + resolution: {integrity: sha512-462id/00U8JWFw6qBuTSWfN5TxOHvDu4WliI97qOIOnuC/g+NDAknTU8eoGXEPlLkRVgWEr03jJBLV4o2FL8+A==} + engines: {node: '>=18.0.0'} + + '@smithy/util-defaults-mode-browser@4.3.34': + resolution: {integrity: sha512-m75CH7xaVG8ErlnfXsIBLrgVrApejrvUpohr41CMdeWNcEu/Ouvj9fbNA7oW9Qpr0Awf+BmDRrYx72hEKgY+FQ==} + engines: {node: '>=18.0.0'} + + '@smithy/util-defaults-mode-browser@4.3.6': resolution: {integrity: sha512-kbpuXbEf2YQ9zEE6eeVnUCQWO0e1BjMnKrXL8rfXgiWA0m8/E0leU4oSNzxP04WfCmW8vjEqaDeXWxwE4tpOjQ==} engines: {node: '>=18.0.0'} + '@smithy/util-defaults-mode-node@4.2.37': + resolution: {integrity: sha512-1LcAt0PV1dletxiGwcw2IJ8vLNhfkir02NTi1i/CFCY2ObtM5wDDjn/8V2dbPrbyoh6OTFH+uayI1rSVRBMT3A==} + engines: {node: '>=18.0.0'} + '@smithy/util-defaults-mode-node@4.2.9': resolution: {integrity: sha512-dgyribrVWN5qE5usYJ0m5M93mVM3L3TyBPZWe1Xl6uZlH2gzfQx3dz+ZCdW93lWqdedJRkOecnvbnoEEXRZ5VQ==} engines: {node: '>=18.0.0'} @@ -2817,18 +3355,38 @@ packages: resolution: {integrity: sha512-3O63AAWu2cSNQZp+ayl9I3NapW1p1rR5mlVHcF6hAB1dPZUQFfRPYtplWX/3xrzWthPGj5FqB12taJJCfH6s8A==} engines: {node: '>=18.0.0'} + '@smithy/util-endpoints@3.2.9': + resolution: {integrity: sha512-9FTqTzKxCFelCKdtHb22BTbrLgw7tTI+D6r/Ci/njI0tzqWLQctS0uEDTzraCR5K6IJItfFp1QmESlBytSpRhQ==} + engines: {node: '>=18.0.0'} + '@smithy/util-hex-encoding@4.2.0': resolution: {integrity: sha512-CCQBwJIvXMLKxVbO88IukazJD9a4kQ9ZN7/UMGBjBcJYvatpWk+9g870El4cB8/EJxfe+k+y0GmR9CAzkF+Nbw==} engines: {node: '>=18.0.0'} + '@smithy/util-hex-encoding@4.2.1': + resolution: {integrity: sha512-c1hHtkgAWmE35/50gmdKajgGAKV3ePJ7t6UtEmpfCWJmQE9BQAQPz0URUVI89eSkcDqCtzqllxzG28IQoZPvwA==} + engines: {node: '>=18.0.0'} + '@smithy/util-middleware@4.2.5': resolution: {integrity: sha512-6Y3+rvBF7+PZOc40ybeZMcGln6xJGVeY60E7jy9Mv5iKpMJpHgRE6dKy9ScsVxvfAYuEX4Q9a65DQX90KaQ3bA==} engines: {node: '>=18.0.0'} + '@smithy/util-middleware@4.2.9': + resolution: {integrity: sha512-pfnZneJ1S9X3TRmg2l3pG11Pvx2BW9O3NFhUN30llrK/yUKu8WbqMTx4/CzED+qKBYw0//ntUT00hvmaG+nLgA==} + engines: {node: '>=18.0.0'} + '@smithy/util-retry@4.2.5': resolution: {integrity: sha512-GBj3+EZBbN4NAqJ/7pAhsXdfzdlznOh8PydUijy6FpNIMnHPSMO2/rP4HKu+UFeikJxShERk528oy7GT79YiJg==} engines: {node: '>=18.0.0'} + '@smithy/util-retry@4.2.9': + resolution: {integrity: sha512-79hfhL/oxP40SCXJGfjfE9pjbUVfHhXZFpCWXTHqXSluzaVy7jwWs9Ui7lLbfDBSp+7i+BIwgeVIRerbIRWN6g==} + engines: {node: '>=18.0.0'} + + '@smithy/util-stream@4.5.14': + resolution: {integrity: sha512-IOBEiJTOltSx6MAfwkx/GSVM8/UCJxdtw13haP5OEL543lb1DN6TAypsxv+qcj4l/rKcpapbS6zK9MQGBOhoaA==} + engines: {node: '>=18.0.0'} + '@smithy/util-stream@4.5.6': resolution: {integrity: sha512-qWw/UM59TiaFrPevefOZ8CNBKbYEP6wBAIlLqxn3VAIo9rgnTNc4ASbVrqDmhuwI87usnjhdQrxodzAGFFzbRQ==} engines: {node: '>=18.0.0'} @@ -2837,6 +3395,10 @@ packages: resolution: {integrity: sha512-igZpCKV9+E/Mzrpq6YacdTQ0qTiLm85gD6N/IrmyDvQFA4UnU3d5g3m8tMT/6zG/vVkWSU+VxeUyGonL62DuxA==} engines: {node: '>=18.0.0'} + '@smithy/util-uri-escape@4.2.1': + resolution: {integrity: sha512-YmiUDn2eo2IOiWYYvGQkgX5ZkBSiTQu4FlDo5jNPpAxng2t6Sjb6WutnZV9l6VR4eJul1ABmCrnWBC9hKHQa6Q==} + engines: {node: '>=18.0.0'} + '@smithy/util-utf8@2.3.0': resolution: {integrity: sha512-R8Rdn8Hy72KKcebgLiv8jQcQkXoLMOGGv5uI1/k0l+snqkOzQ1R0ChUBCxWMlBsFMekWjq0wRudIweFs7sKT5A==} engines: {node: '>=14.0.0'} @@ -2845,14 +3407,26 @@ packages: resolution: {integrity: sha512-zBPfuzoI8xyBtR2P6WQj63Rz8i3AmfAaJLuNG8dWsfvPe8lO4aCPYLn879mEgHndZH1zQ2oXmG8O1GGzzaoZiw==} engines: {node: '>=18.0.0'} + '@smithy/util-utf8@4.2.1': + resolution: {integrity: sha512-DSIwNaWtmzrNQHv8g7DBGR9mulSit65KSj5ymGEIAknmIN8IpbZefEep10LaMG/P/xquwbmJ1h9ectz8z6mV6g==} + engines: {node: '>=18.0.0'} + '@smithy/util-waiter@4.2.5': resolution: {integrity: sha512-Dbun99A3InifQdIrsXZ+QLcC0PGBPAdrl4cj1mTgJvyc9N2zf7QSxg8TBkzsCmGJdE3TLbO9ycwpY0EkWahQ/g==} engines: {node: '>=18.0.0'} + '@smithy/util-waiter@4.2.9': + resolution: {integrity: sha512-/PYREwfBaj3fV5V4PfMksYj/WKwrjQ4gW/yo8KLpZSkAdBEkvXd68hovAubrw+n+Q8Rcr9XRn6uzcoQCEhrNFQ==} + engines: {node: '>=18.0.0'} + '@smithy/uuid@1.1.0': resolution: {integrity: sha512-4aUIteuyxtBUhVdiQqcDhKFitwfd9hqoSDYY2KRXiWtgoWJ9Bmise+KfEPDiVHWeJepvF8xJO9/9+WDIciMFFw==} engines: {node: '>=18.0.0'} + '@smithy/uuid@1.1.1': + resolution: {integrity: sha512-dSfDCeihDmZlV2oyr0yWPTUfh07suS+R5OB+FZGiv/hHyK3hrFBW5rR1UYjfa57vBsrP9lciFkRPzebaV1Qujw==} + engines: {node: '>=18.0.0'} + '@stylistic/eslint-plugin@3.1.0': resolution: {integrity: sha512-pA6VOrOqk0+S8toJYhQGv2MWpQQR0QpeUo9AhNkC49Y26nxBQ/nH1rta9bUU1rPw2fJ1zZEMV5oCX5AazT7J2g==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} @@ -2912,24 +3486,51 @@ packages: '@types/archiver@7.0.0': resolution: {integrity: sha512-/3vwGwx9n+mCQdYZ2IKGGHEFL30I96UgBlk8EtRDDFQ9uxM1l4O5Ci6r00EMAkiDaTqD9DQ6nVrWRICnBPtzzg==} + '@types/aws-lambda@8.10.160': + resolution: {integrity: sha512-uoO4QVQNWFPJMh26pXtmtrRfGshPUSpMZGUyUQY20FhfHEElEBOPKgVmFs1z+kbpyBsRs2JnoOPT7++Z4GA9pA==} + + '@types/body-parser@1.19.6': + resolution: {integrity: sha512-HLFeCYgz89uk22N5Qg3dvGvsv46B8GLvKKo1zKG4NybA8U2DiEO3w9lqGg29t/tfLRJpJ6iQxnVw4OnB7MoM9g==} + '@types/cacheable-request@6.0.3': resolution: {integrity: sha512-IQ3EbTzGxIigb1I3qPZc1rWJnH0BmSKv5QYTalEwweFvyBDLSAe24zP0le/hyi7ecGfZVlIVAg4BZqb8WBwKqw==} '@types/chai@4.3.20': resolution: {integrity: sha512-/pC9HAB5I/xMlc5FP77qjCnI16ChlJfW0tGa0IUcFn38VJrTV6DeZ60NU5KZBtaOZqjdpwTWohz5HU1RrhiYxQ==} + '@types/compressible@2.0.3': + resolution: {integrity: sha512-ywiUZGaJqaYMIzAFslaNq3GzaAKT+Hwq0Yh1wULe3HLCdr1+cfd5RQTLQUR7MMbIedlGrotRVcfqiY4R7K9QAQ==} + + '@types/connect@3.4.38': + resolution: {integrity: sha512-K6uROf1LD88uDQqJCktA4yzL1YYAK6NgfsI0v/mTgyPKWsX1CnJ0XPSDhViejru1GcRkLWb8RlzFYJRqGUbaug==} + + '@types/cors@2.8.19': + resolution: {integrity: sha512-mFNylyeyqN93lfe/9CSxOGREz8cpzAhH+E93xJ4xWQf62V8sQ/24reV2nyzUWM6H6Xji+GGHpkbLe7pVoUEskg==} + '@types/ejs@3.1.5': resolution: {integrity: sha512-nv+GSx77ZtXiJzwKdsASqi+YQ5Z7vwHsTP0JY2SiQgjGckkBRKZnk8nIM+7oUZ1VCtuTz0+By4qVR7fqzp/Dfg==} '@types/estree@1.0.8': resolution: {integrity: sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==} + '@types/express-serve-static-core@5.1.1': + resolution: {integrity: sha512-v4zIMr/cX7/d2BpAEX3KNKL/JrT1s43s96lLvvdTmza1oEvDudCqK9aF/djc/SWgy8Yh0h30TZx5VpzqFCxk5A==} + + '@types/express@5.0.3': + resolution: {integrity: sha512-wGA0NX93b19/dZC1J18tKWVIYWyyF2ZjT9vin/NRu0qzzvfVzWjs04iq2rQ3H65vCTQYlRqs3YHfY7zjdV+9Kw==} + '@types/hast@3.0.4': resolution: {integrity: sha512-WPs+bbQw5aCj+x6laNGWLH3wviHtoCv/P3+otBhbOhJgG8qtpdAMlTCxLtsTWA7LH1Oh/bFCHsBn0TPS5m30EQ==} '@types/http-cache-semantics@4.0.4': resolution: {integrity: sha512-1m0bIFVc7eJWyve9S0RnuRgcQqF/Xd5QsUZAZeQFr1Q3/p9JWoQQEqmVy+DPTNpGXwhgIetAoYF8JSc33q29QA==} + '@types/http-errors@2.0.5': + resolution: {integrity: sha512-r8Tayk8HJnX0FztbZN7oVqGccWgw98T/0neJphO91KkmOzug1KkofZURD4UaD5uH8AqcFLfdPErnBod0u71/qg==} + + '@types/http-proxy@1.17.17': + resolution: {integrity: sha512-ED6LB+Z1AVylNTu7hdzuBqOgMnvG/ld6wGCG8wFnAzKX5uyW2K3WD52v0gnLCTK/VLpXtKckgWuyScYK6cSPaw==} + '@types/istanbul-lib-coverage@2.0.6': resolution: {integrity: sha512-2QF/t/auWm0lsy8XtKVPG19v3sSOQlJe/YHZgfjb/KBBHOGSV+J2q/S671rcq9uTBrLAXmZpqJiaQbMT+zNU1w==} @@ -2945,6 +3546,9 @@ packages: '@types/linkify-it@5.0.0': resolution: {integrity: sha512-sVDA58zAw4eWAffKOaQH5/5j3XeayukzDk+ewSsnv3p4yJEZHCCzMDiZM8e0OUrRvmpGZ85jf4yDHkHsgBNr9Q==} + '@types/lodash@4.17.24': + resolution: {integrity: sha512-gIW7lQLZbue7lRSWEFql49QJJWThrTFFeIMJdp3eH4tKoxm1OvEPg02rm4wCCSHS0cL3/Fizimb35b7k8atwsQ==} + '@types/markdown-it@14.1.2': resolution: {integrity: sha512-promo4eFwuiW+TfGxhi+0x3czqTYJkG8qB17ZUJiVF10Xm7NLVRSLUsfRTU/6h1e24VvRnXCx+hG7li58lkzog==} @@ -2954,12 +3558,18 @@ packages: '@types/mdurl@2.0.0': resolution: {integrity: sha512-RGdgjQUZba5p6QEFAVx2OGb8rQDL/cPRG7GiedRzMcJ1tYnUANBncjbSB1NRGwbvjcPeikRABz2nshyPk1bhWg==} + '@types/mime-types@3.0.1': + resolution: {integrity: sha512-xRMsfuQbnRq1Ef+C+RKaENOxXX87Ygl38W1vDfPHRku02TgQr+Qd8iivLtAMcR0KF5/29xlnFihkTlbqFrGOVQ==} + '@types/mocha@10.0.10': resolution: {integrity: sha512-xPyYSz1cMPnJQhl0CLMH68j3gprKZaTjG3s5Vi+fDgx+uhG9NOXwbVt52eFS8ECyXhyKcjDLCBEqBExKuiZb7Q==} '@types/mute-stream@0.0.4': resolution: {integrity: sha512-CPM9nzrCPPJHQNA9keH9CVkVI+WR5kMa+7XEs5jcGQ0VoAGnLv242w8lIVgwAEfmE4oufJRaTc9PNLQl0ioAow==} + '@types/negotiator@0.6.4': + resolution: {integrity: sha512-elf6BsTq+AkyNsb2h5cGNst2Mc7dPliVoAPm1fXglC/BM3f2pFA40BaSSv3E5lyHteEawVKLP+8TwiY1DMNb3A==} + '@types/node@12.20.55': resolution: {integrity: sha512-J8xLz7q2OFulZ2cyGTLE1TbbZcjpno7FaN6zdJNrgAdrJ+DZzh/uFR6YrTb4C+nXakvud8Q4+rbhoIWlYQbUFQ==} @@ -2969,6 +3579,12 @@ packages: '@types/normalize-package-data@2.4.4': resolution: {integrity: sha512-37i+OaWTh9qeK4LSHPsyRC7NahnGotNuZvjLSgcPzblpHB3rrCJxAOgI5gCdKm7coonsaX1Of0ILiTcnZjbfxA==} + '@types/qs@6.14.0': + resolution: {integrity: sha512-eOunJqu0K1923aExK6y8p6fsihYEn/BYuQ4g0CxAAgFc4b/ZLN4CrsRZ55srTdqoiLzU2B2evC+apEIxprEzkQ==} + + '@types/range-parser@1.2.7': + resolution: {integrity: sha512-hKormJbkJqzQGhziax5PItDUTMAM9uE2XXQmM37dyd4hVM+5aVl7oVxMVUiVQn2oCQFN/LKCZdvSM0pFRqbSmQ==} + '@types/readdir-glob@1.1.5': resolution: {integrity: sha512-raiuEPUYqXu+nvtY2Pe8s8FEmZ3x5yAH4VkLdihcPdalvsHltomrRC9BzuStrJ9yk06470hS0Crw0f1pXqD+Hg==} @@ -2978,6 +3594,15 @@ packages: '@types/sarif@2.1.7': resolution: {integrity: sha512-kRz0VEkJqWLf1LLVN4pT1cg1Z9wAuvI6L97V3m2f5B76Tg8d413ddvLBPTEHAZJlnn4XSvu0FkZtViCQGVyrXQ==} + '@types/send@1.2.1': + resolution: {integrity: sha512-arsCikDvlU99zl1g69TcAB3mzZPpxgw0UQnaHeC1Nwb015xp8bknZv5rIfri9xTOcMuaVgvabfIRA7PSZVuZIQ==} + + '@types/serve-static@2.2.0': + resolution: {integrity: sha512-8mam4H1NHLtu7nmtalF7eyBH14QyOASmcxHhSfEoRyr0nP/YdoesEtU+uSRvMe96TW/HPTtkoKqQLl53N7UXMQ==} + + '@types/set-cookie-parser@2.4.10': + resolution: {integrity: sha512-GGmQVGpQWUe5qglJozEjZV/5dyxbOOZ0LHe/lqyWssB88Y4svNfst0uqBVscdDeIKl5Jy5+aPSvy7mI9tYRguw==} + '@types/shimmer@1.2.0': resolution: {integrity: sha512-UE7oxhQLLd9gub6JKIAhDq06T0F6FnztwMNRvYgjeQSBeMc1ZG/tA47EwfduvkuQS8apbkM/lpLpWsaCeYsXVg==} @@ -3877,6 +4502,10 @@ packages: resolution: {integrity: sha512-6FqVXeETqWPoGcfzrXb37E50NP0LXT8kAMu5ooZayhWWdgEY4lBEEcbQNXtkuKQsGduxiIcI4gOTsxTmuq/bSg==} engines: {node: '>= 14'} + compressible@2.0.18: + resolution: {integrity: sha512-AF3r7P5dWxL8MxyITRMlORQNaOA2IkAFaTr4k7BUumjPtRpGDTZpl0Pb1XCO6JeDCBdp126Cgs9sMxqSjgYyRg==} + engines: {node: '>= 0.6'} + concat-map@0.0.1: resolution: {integrity: sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==} @@ -4509,6 +5138,9 @@ packages: resolution: {integrity: sha512-i/2XbnSz/uxRCU6+NdVJgKWDTM427+MqYbkQzD321DuCQJUqOuJKIA0IM2+W2xtYHdKOmZ4dR6fExsd4SXL+WQ==} engines: {node: '>=6'} + eventemitter3@4.0.7: + resolution: {integrity: sha512-8guHBZCwKnFhYdHr2ysuRWErTwhoN2X8XELRlrRwpmfeY2jjuUN4taQMsULKUVo1K4DvZl+0pgfyoysHxvmvEw==} + events-universal@1.0.1: resolution: {integrity: sha512-LUd5euvbMLpwOF8m6ivPCbhQeSiYVNb8Vs0fQ8QjXo0JTkEHpz8pxdQf0gStltaPpw0Cca8b39KxvK9cfKRiAw==} @@ -4538,6 +5170,10 @@ packages: peerDependencies: express: '>= 4.11' + express@5.1.0: + resolution: {integrity: sha512-DT9ck5YIRU+8GYzzU5kT3eHGA5iL+1Zd0EutOmTE9Dtk+Tvuzd23VBU+ec7HPNSTxXYO55gPV/hq4pSBJDjFpA==} + engines: {node: '>= 18'} + express@5.2.1: resolution: {integrity: sha512-hIS4idWWai69NezIdRt2xFVofaF4j+6INOpJlVOLDO8zXGpUVEVzIYk12UUi2JzjEzWL3IOAxcTubgz9Po0yXw==} engines: {node: '>= 18'} @@ -4580,6 +5216,10 @@ packages: resolution: {integrity: sha512-JeaA2Vm9ffQKp9VjvfzObuMCjUYAp5WDYhRYL5LrBPY/jUDlUtOvDfot0vKSkB9tuX885BDHjtw4fZadD95wnA==} hasBin: true + fast-xml-parser@5.3.6: + resolution: {integrity: sha512-QNI3sAvSvaOiaMl8FYU4trnEzCwiRr8XMWgAHzlrWpTSj+QaCSvOf1h82OEP1s4hiAXhnbXSyFWCf4ldZzZRVA==} + hasBin: true + fastest-levenshtein@1.0.16: resolution: {integrity: sha512-eRnCtTTtGZFpQCwhJiUOuxPQWRXVKYDn0b2PeHfXL6/Zi53SLAzAHfVhVWK2AryC/WH05kGfxhFIPvTF0SXQzg==} engines: {node: '>= 4.9.1'} @@ -4611,6 +5251,9 @@ packages: resolution: {integrity: sha512-7yAQpD2UMJzLi1Dqv7qFYnPbaPx7ZfFK6PiIxQ4PfkGPyNyl2Ugx+a/umUonmKqjhM4DnfbMvdX6otXq83soQQ==} engines: {node: ^12.20 || >= 14.13} + fflate@0.8.1: + resolution: {integrity: sha512-/exOvEuc+/iaUm105QIiOt4LpBdMTWsXxqR0HDF35vx3fmaKzw7354gTilCh5rkzEt8WYyG//ku3h3nRmd7CHQ==} + figures@6.1.0: resolution: {integrity: sha512-d+l3qxjSesT4V7v2fh+QnmFnUWv9lSpjarhShNTgBOfA0ttejbQUAlHLitbjkoRiDulW0OPoQPYIGhIC8ohejg==} engines: {node: '>=18'} @@ -4655,6 +5298,15 @@ packages: focus-trap@7.6.6: resolution: {integrity: sha512-v/Z8bvMCajtx4mEXmOo7QEsIzlIOqRXTIwgUfsFOF9gEsespdbD0AkPIka1bSXZ8Y8oZ+2IVDQZePkTfEHZl7Q==} + follow-redirects@1.15.11: + resolution: {integrity: sha512-deG2P0JfjrTxl50XGCDyfI97ZGVCxIpfKYmfyrQ54n5FO/0gfIES8C/Psl6kWVDolizcaaxZJnTS0QSMxvnsBQ==} + engines: {node: '>=4.0'} + peerDependencies: + debug: '*' + peerDependenciesMeta: + debug: + optional: true + for-each@0.3.5: resolution: {integrity: sha512-dKx12eRCVIzqCxFGplyFKJMPvLEWgmNtUrpTiJIR5u97zEhRG8ySrtboPHZXx7daLxQVrl643cTzbab2tkQjxg==} engines: {node: '>= 0.4'} @@ -4972,6 +5624,14 @@ packages: resolution: {integrity: sha512-T1gkAiYYDWYx3V5Bmyu7HcfcvL7mUrTWiM6yOfa3PIphViJ/gFPbvidQ+veqSOHci/PxBcDabeUNCzpOODJZig==} engines: {node: '>= 14'} + http-proxy-middleware@3.0.5: + resolution: {integrity: sha512-GLZZm1X38BPY4lkXA01jhwxvDoOkkXqjgVyUzVxiEK4iuRu03PZoYHhHRwxnfhQMDuaxi3vVri0YgSro/1oWqg==} + engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + + http-proxy@1.18.1: + resolution: {integrity: sha512-7mz/721AbnJwIVbnaSv1Cz3Am0ZLT/UBwkC92VlxhXv/k/BBQfM2fXElQNC27BVGr0uwUpplYPQM9LnaBMR5NQ==} + engines: {node: '>=8.0.0'} + http2-wrapper@1.0.3: resolution: {integrity: sha512-V+23sDMr12Wnz7iTcDeJr3O6AIxlnvT/bmaAAAP/Xda35C90p9599p0F1eHR/N1KILWSoWVAiOMFjBBXaXSMxg==} engines: {node: '>=10.19.0'} @@ -5203,6 +5863,10 @@ packages: resolution: {integrity: sha512-+Pgi+vMuUNkJyExiMBt5IlFoMyKnr5zhJ4Uspz58WOhBF5QoIZkFyNHIbBAtHwzVAgk5RtndVNsDRN61/mmDqg==} engines: {node: '>=12'} + is-plain-object@5.0.0: + resolution: {integrity: sha512-VRSzKkbMm5jMDoKLbltAkFQ5Qr7VDiTFGXxYFXXowVj387GeGNOCsOH6Msy00SGZ3Fp84b1Naa1psqgcCIEP5Q==} + engines: {node: '>=0.10.0'} + is-promise@4.0.0: resolution: {integrity: sha512-hvpoI6korhJMnej285dSg6nu1+e6uxs7zG3BYAm5byqDsgJNWwxzM6z6iZiAgQR4TJ30JmBTOwqZUw3WlyH3AQ==} @@ -5630,6 +6294,10 @@ packages: resolution: {integrity: sha512-lc/aahn+t4/SWV/qcmumYjymLsWfN3ELhpmVuUFjgsORruuZPVSwAQryq+HHGvO/SI2KVX26bx+En+zhM8g8hQ==} engines: {node: '>= 0.6'} + mime-types@3.0.1: + resolution: {integrity: sha512-xRc4oEhT6eaBpU1XF7AjpOFD+xQmXNB5OVKwp4tqCuBpHLS/ZbBDrc07mYTDqVMg6PfxUjjNp85O6Cd2Z/5HWA==} + engines: {node: '>= 0.6'} + mime-types@3.0.2: resolution: {integrity: sha512-Lbgzdk0h4juoQ9fCKXW4by0UJqj+nOOrI9MJ1sSj4nI8aI2eo1qmvQEie4VD1glsS250n15LsWsYtCugiStS5A==} engines: {node: '>=18'} @@ -5690,6 +6358,9 @@ packages: mkdirp-classic@0.5.3: resolution: {integrity: sha512-gKLcREMhtuZRwRAfqP3RFW+TK4JqApVBtOIftVgjuABpAtpxhPGaDcfvbhNvD0B8iD1oUr/txX35NjcaY6Ns/A==} + mnemonist@0.38.3: + resolution: {integrity: sha512-2K9QYubXx/NAjv4VLq1d1Ly8pWNC5L3BrixtdkyTegXWJIqY+zLNDhhX/A+ZwWt70tB1S8H4BE8FLYEFyNoOBw==} + mocha@10.8.2: resolution: {integrity: sha512-VZlYo/WE8t1tstuRmqgeyBgCbJc/lEdopaa+axcKzTBJ+UIdlAB9XnmvTCAH4pwR4ElNInaedhEBmZD8iCSVEg==} engines: {node: '>= 14.0.0'} @@ -5953,6 +6624,9 @@ packages: resolution: {integrity: sha512-gXah6aZrcUxjWg2zR2MwouP2eHlCBzdV4pygudehaKXSGW4v2AsRQUK+lwwXhii6KFZcunEnmSUoYp5CXibxtA==} engines: {node: '>= 0.4'} + obliterator@1.6.1: + resolution: {integrity: sha512-9WXswnqINnnhOG/5SLimUlzuU1hFJUc8zkwyD59Sd+dPOMf05PmnYG/d6Q7HZ+KmgkZJa1PxRso6QdM3sTNHig==} + oclif@4.22.44: resolution: {integrity: sha512-/0xXjF/dt8qN8SuibVTVU/81gOy4nNprSXSFHVWvKm1Ms8EKsCA6C+4XRcRCCMaaE4t2GKjjRpEwqCQKFUtI/Q==} engines: {node: '>=18.0.0'} @@ -6263,6 +6937,7 @@ packages: prebuild-install@7.1.3: resolution: {integrity: sha512-8Mf2cbV7x1cXPUILADGI3wuhfqWvtiLA1iclTDbFRZkgRQS0NqsPZphna9V+HyTEadheuPmjaJMsbzKQFOzLug==} engines: {node: '>=10'} + deprecated: No longer maintained. Please contact the author of the relevant native addon; alternatives are available. hasBin: true prelude-ls@1.2.1: @@ -6530,6 +7205,9 @@ packages: resolution: {integrity: sha512-gAZ+kLqBdHarXB64XpAe2VCjB7rIRv+mU8tfRWziHRJ5umKsIHN2tLLv6EtMw7WCdP19S0ERVMldNvxYCHnhSQ==} engines: {node: '>=8.6.0'} + requires-port@1.0.0: + resolution: {integrity: sha512-KigOCHcocU3XODJxsu8i/j8T9tzT4adHiecwORRQ0ZZFcp7ahwXuRU1m+yuO90C5ZUyGeGfocHDI14M3L3yDAQ==} + resolve-alpn@1.2.1: resolution: {integrity: sha512-0a1F4l73/ZFZOakJnQ3FvkJ2+gSTQWz/r2KE5OdDY0TxPm5h4GkqkWWfM47T7HsbnOtcJVEF4epCVy6u7Q3K+g==} @@ -6676,6 +7354,9 @@ packages: resolution: {integrity: sha512-xRXBn0pPqQTVQiC8wyQrKs2MOlX24zQ0POGaj0kultvoOCstBQM5yvOhAVSUwOMjQtTvsPWoNCHfPGwaaQJhTw==} engines: {node: '>= 18'} + set-cookie-parser@2.7.1: + resolution: {integrity: sha512-IOc8uWeOZgnb3ptbCURJWNjWUPcO3ZnTTdzsurqERrP6nPyv+paC55vJM0LpOlT2ne+Ix+9+CRG1MNLlyZ4GjQ==} + set-function-length@1.2.2: resolution: {integrity: sha512-pgRc4hJ4/sNjWCSS9AmnS40x3bNMDTknHgL5UaMBTMyJnU90EgWh1Rz+MC9eFu4BuN/UwZjKQuY/1v3rM7HMfg==} engines: {node: '>= 0.4'} @@ -7745,7 +8426,7 @@ snapshots: '@aws-crypto/sha256-js': 5.2.0 '@aws-crypto/supports-web-crypto': 5.2.0 '@aws-crypto/util': 5.2.0 - '@aws-sdk/types': 3.922.0 + '@aws-sdk/types': 3.936.0 '@aws-sdk/util-locate-window': 3.893.0 '@smithy/util-utf8': 2.3.0 tslib: 2.8.1 @@ -7753,7 +8434,7 @@ snapshots: '@aws-crypto/sha256-js@5.2.0': dependencies: '@aws-crypto/util': 5.2.0 - '@aws-sdk/types': 3.922.0 + '@aws-sdk/types': 3.936.0 tslib: 2.8.1 '@aws-crypto/supports-web-crypto@5.2.0': @@ -7762,7 +8443,7 @@ snapshots: '@aws-crypto/util@5.2.0': dependencies: - '@aws-sdk/types': 3.922.0 + '@aws-sdk/types': 3.936.0 '@smithy/util-utf8': 2.3.0 tslib: 2.8.1 @@ -7813,6 +8494,99 @@ snapshots: transitivePeerDependencies: - aws-crt + '@aws-sdk/client-cloudwatch@3.952.0': + dependencies: + '@aws-crypto/sha256-browser': 5.2.0 + '@aws-crypto/sha256-js': 5.2.0 + '@aws-sdk/core': 3.947.0 + '@aws-sdk/credential-provider-node': 3.952.0 + '@aws-sdk/middleware-host-header': 3.936.0 + '@aws-sdk/middleware-logger': 3.936.0 + '@aws-sdk/middleware-recursion-detection': 3.948.0 + '@aws-sdk/middleware-user-agent': 3.947.0 + '@aws-sdk/region-config-resolver': 3.936.0 + '@aws-sdk/types': 3.936.0 + '@aws-sdk/util-endpoints': 3.936.0 + '@aws-sdk/util-user-agent-browser': 3.936.0 + '@aws-sdk/util-user-agent-node': 3.947.0 + '@smithy/config-resolver': 4.4.3 + '@smithy/core': 3.23.4 + '@smithy/fetch-http-handler': 5.3.6 + '@smithy/hash-node': 4.2.5 + '@smithy/invalid-dependency': 4.2.5 + '@smithy/middleware-compression': 4.3.33 + '@smithy/middleware-content-length': 4.2.5 + '@smithy/middleware-endpoint': 4.4.18 + '@smithy/middleware-retry': 4.4.35 + '@smithy/middleware-serde': 4.2.10 + '@smithy/middleware-stack': 4.2.5 + '@smithy/node-config-provider': 4.3.5 + '@smithy/node-http-handler': 4.4.5 + '@smithy/protocol-http': 5.3.5 + '@smithy/smithy-client': 4.11.7 + '@smithy/types': 4.9.0 + '@smithy/url-parser': 4.2.5 + '@smithy/util-base64': 4.3.0 + '@smithy/util-body-length-browser': 4.2.0 + '@smithy/util-body-length-node': 4.2.1 + '@smithy/util-defaults-mode-browser': 4.3.34 + '@smithy/util-defaults-mode-node': 4.2.37 + '@smithy/util-endpoints': 3.2.5 + '@smithy/util-middleware': 4.2.5 + '@smithy/util-retry': 4.2.5 + '@smithy/util-utf8': 4.2.0 + '@smithy/util-waiter': 4.2.5 + tslib: 2.8.1 + transitivePeerDependencies: + - aws-crt + + '@aws-sdk/client-dynamodb@3.980.0': + dependencies: + '@aws-crypto/sha256-browser': 5.2.0 + '@aws-crypto/sha256-js': 5.2.0 + '@aws-sdk/core': 3.973.12 + '@aws-sdk/credential-provider-node': 3.972.11 + '@aws-sdk/dynamodb-codec': 3.972.13 + '@aws-sdk/middleware-endpoint-discovery': 3.972.3 + '@aws-sdk/middleware-host-header': 3.972.3 + '@aws-sdk/middleware-logger': 3.972.3 + '@aws-sdk/middleware-recursion-detection': 3.972.3 + '@aws-sdk/middleware-user-agent': 3.972.12 + '@aws-sdk/region-config-resolver': 3.972.3 + '@aws-sdk/types': 3.973.1 + '@aws-sdk/util-endpoints': 3.980.0 + '@aws-sdk/util-user-agent-browser': 3.972.3 + '@aws-sdk/util-user-agent-node': 3.972.11 + '@smithy/config-resolver': 4.4.7 + '@smithy/core': 3.23.4 + '@smithy/fetch-http-handler': 5.3.10 + '@smithy/hash-node': 4.2.9 + '@smithy/invalid-dependency': 4.2.9 + '@smithy/middleware-content-length': 4.2.9 + '@smithy/middleware-endpoint': 4.4.18 + '@smithy/middleware-retry': 4.4.35 + '@smithy/middleware-serde': 4.2.10 + '@smithy/middleware-stack': 4.2.9 + '@smithy/node-config-provider': 4.3.9 + '@smithy/node-http-handler': 4.4.11 + '@smithy/protocol-http': 5.3.9 + '@smithy/smithy-client': 4.11.7 + '@smithy/types': 4.12.1 + '@smithy/url-parser': 4.2.9 + '@smithy/util-base64': 4.3.0 + '@smithy/util-body-length-browser': 4.2.0 + '@smithy/util-body-length-node': 4.2.1 + '@smithy/util-defaults-mode-browser': 4.3.34 + '@smithy/util-defaults-mode-node': 4.2.37 + '@smithy/util-endpoints': 3.2.9 + '@smithy/util-middleware': 4.2.9 + '@smithy/util-retry': 4.2.9 + '@smithy/util-utf8': 4.2.0 + '@smithy/util-waiter': 4.2.9 + tslib: 2.8.1 + transitivePeerDependencies: + - aws-crt + '@aws-sdk/client-s3@3.929.0': dependencies: '@aws-crypto/sha1-browser': 5.2.0 @@ -7918,6 +8692,92 @@ snapshots: transitivePeerDependencies: - aws-crt + '@aws-sdk/client-sso@3.948.0': + dependencies: + '@aws-crypto/sha256-browser': 5.2.0 + '@aws-crypto/sha256-js': 5.2.0 + '@aws-sdk/core': 3.947.0 + '@aws-sdk/middleware-host-header': 3.936.0 + '@aws-sdk/middleware-logger': 3.936.0 + '@aws-sdk/middleware-recursion-detection': 3.948.0 + '@aws-sdk/middleware-user-agent': 3.947.0 + '@aws-sdk/region-config-resolver': 3.936.0 + '@aws-sdk/types': 3.936.0 + '@aws-sdk/util-endpoints': 3.936.0 + '@aws-sdk/util-user-agent-browser': 3.936.0 + '@aws-sdk/util-user-agent-node': 3.947.0 + '@smithy/config-resolver': 4.4.3 + '@smithy/core': 3.23.4 + '@smithy/fetch-http-handler': 5.3.6 + '@smithy/hash-node': 4.2.5 + '@smithy/invalid-dependency': 4.2.5 + '@smithy/middleware-content-length': 4.2.5 + '@smithy/middleware-endpoint': 4.4.18 + '@smithy/middleware-retry': 4.4.35 + '@smithy/middleware-serde': 4.2.10 + '@smithy/middleware-stack': 4.2.5 + '@smithy/node-config-provider': 4.3.5 + '@smithy/node-http-handler': 4.4.5 + '@smithy/protocol-http': 5.3.5 + '@smithy/smithy-client': 4.11.7 + '@smithy/types': 4.9.0 + '@smithy/url-parser': 4.2.5 + '@smithy/util-base64': 4.3.0 + '@smithy/util-body-length-browser': 4.2.0 + '@smithy/util-body-length-node': 4.2.1 + '@smithy/util-defaults-mode-browser': 4.3.34 + '@smithy/util-defaults-mode-node': 4.2.37 + '@smithy/util-endpoints': 3.2.5 + '@smithy/util-middleware': 4.2.5 + '@smithy/util-retry': 4.2.5 + '@smithy/util-utf8': 4.2.0 + tslib: 2.8.1 + transitivePeerDependencies: + - aws-crt + + '@aws-sdk/client-sso@3.996.0': + dependencies: + '@aws-crypto/sha256-browser': 5.2.0 + '@aws-crypto/sha256-js': 5.2.0 + '@aws-sdk/core': 3.973.12 + '@aws-sdk/middleware-host-header': 3.972.3 + '@aws-sdk/middleware-logger': 3.972.3 + '@aws-sdk/middleware-recursion-detection': 3.972.3 + '@aws-sdk/middleware-user-agent': 3.972.12 + '@aws-sdk/region-config-resolver': 3.972.3 + '@aws-sdk/types': 3.973.1 + '@aws-sdk/util-endpoints': 3.996.0 + '@aws-sdk/util-user-agent-browser': 3.972.3 + '@aws-sdk/util-user-agent-node': 3.972.11 + '@smithy/config-resolver': 4.4.7 + '@smithy/core': 3.23.4 + '@smithy/fetch-http-handler': 5.3.10 + '@smithy/hash-node': 4.2.9 + '@smithy/invalid-dependency': 4.2.9 + '@smithy/middleware-content-length': 4.2.9 + '@smithy/middleware-endpoint': 4.4.18 + '@smithy/middleware-retry': 4.4.35 + '@smithy/middleware-serde': 4.2.10 + '@smithy/middleware-stack': 4.2.9 + '@smithy/node-config-provider': 4.3.9 + '@smithy/node-http-handler': 4.4.11 + '@smithy/protocol-http': 5.3.9 + '@smithy/smithy-client': 4.11.7 + '@smithy/types': 4.12.1 + '@smithy/url-parser': 4.2.9 + '@smithy/util-base64': 4.3.0 + '@smithy/util-body-length-browser': 4.2.0 + '@smithy/util-body-length-node': 4.2.1 + '@smithy/util-defaults-mode-browser': 4.3.34 + '@smithy/util-defaults-mode-node': 4.2.37 + '@smithy/util-endpoints': 3.2.9 + '@smithy/util-middleware': 4.2.9 + '@smithy/util-retry': 4.2.9 + '@smithy/util-utf8': 4.2.0 + tslib: 2.8.1 + transitivePeerDependencies: + - aws-crt + '@aws-sdk/core@3.928.0': dependencies: '@aws-sdk/types': 3.922.0 @@ -7934,6 +8794,38 @@ snapshots: '@smithy/util-utf8': 4.2.0 tslib: 2.8.1 + '@aws-sdk/core@3.947.0': + dependencies: + '@aws-sdk/types': 3.936.0 + '@aws-sdk/xml-builder': 3.930.0 + '@smithy/core': 3.23.4 + '@smithy/node-config-provider': 4.3.5 + '@smithy/property-provider': 4.2.5 + '@smithy/protocol-http': 5.3.5 + '@smithy/signature-v4': 5.3.5 + '@smithy/smithy-client': 4.11.7 + '@smithy/types': 4.9.0 + '@smithy/util-base64': 4.3.0 + '@smithy/util-middleware': 4.2.5 + '@smithy/util-utf8': 4.2.0 + tslib: 2.8.1 + + '@aws-sdk/core@3.973.12': + dependencies: + '@aws-sdk/types': 3.973.1 + '@aws-sdk/xml-builder': 3.972.5 + '@smithy/core': 3.23.4 + '@smithy/node-config-provider': 4.3.9 + '@smithy/property-provider': 4.2.9 + '@smithy/protocol-http': 5.3.9 + '@smithy/signature-v4': 5.3.9 + '@smithy/smithy-client': 4.11.7 + '@smithy/types': 4.12.1 + '@smithy/util-base64': 4.3.0 + '@smithy/util-middleware': 4.2.9 + '@smithy/util-utf8': 4.2.0 + tslib: 2.8.1 + '@aws-sdk/credential-provider-env@3.928.0': dependencies: '@aws-sdk/core': 3.928.0 @@ -7942,6 +8834,22 @@ snapshots: '@smithy/types': 4.9.0 tslib: 2.8.1 + '@aws-sdk/credential-provider-env@3.947.0': + dependencies: + '@aws-sdk/core': 3.947.0 + '@aws-sdk/types': 3.936.0 + '@smithy/property-provider': 4.2.5 + '@smithy/types': 4.9.0 + tslib: 2.8.1 + + '@aws-sdk/credential-provider-env@3.972.10': + dependencies: + '@aws-sdk/core': 3.973.12 + '@aws-sdk/types': 3.973.1 + '@smithy/property-provider': 4.2.9 + '@smithy/types': 4.12.1 + tslib: 2.8.1 + '@aws-sdk/credential-provider-http@3.928.0': dependencies: '@aws-sdk/core': 3.928.0 @@ -7955,6 +8863,32 @@ snapshots: '@smithy/util-stream': 4.5.6 tslib: 2.8.1 + '@aws-sdk/credential-provider-http@3.947.0': + dependencies: + '@aws-sdk/core': 3.947.0 + '@aws-sdk/types': 3.936.0 + '@smithy/fetch-http-handler': 5.3.6 + '@smithy/node-http-handler': 4.4.5 + '@smithy/property-provider': 4.2.5 + '@smithy/protocol-http': 5.3.5 + '@smithy/smithy-client': 4.11.7 + '@smithy/types': 4.9.0 + '@smithy/util-stream': 4.5.6 + tslib: 2.8.1 + + '@aws-sdk/credential-provider-http@3.972.12': + dependencies: + '@aws-sdk/core': 3.973.12 + '@aws-sdk/types': 3.973.1 + '@smithy/fetch-http-handler': 5.3.10 + '@smithy/node-http-handler': 4.4.11 + '@smithy/property-provider': 4.2.9 + '@smithy/protocol-http': 5.3.9 + '@smithy/smithy-client': 4.11.7 + '@smithy/types': 4.12.1 + '@smithy/util-stream': 4.5.14 + tslib: 2.8.1 + '@aws-sdk/credential-provider-ini@3.929.0': dependencies: '@aws-sdk/core': 3.928.0 @@ -7973,6 +8907,70 @@ snapshots: transitivePeerDependencies: - aws-crt + '@aws-sdk/credential-provider-ini@3.952.0': + dependencies: + '@aws-sdk/core': 3.947.0 + '@aws-sdk/credential-provider-env': 3.947.0 + '@aws-sdk/credential-provider-http': 3.947.0 + '@aws-sdk/credential-provider-login': 3.952.0 + '@aws-sdk/credential-provider-process': 3.947.0 + '@aws-sdk/credential-provider-sso': 3.952.0 + '@aws-sdk/credential-provider-web-identity': 3.952.0 + '@aws-sdk/nested-clients': 3.952.0 + '@aws-sdk/types': 3.936.0 + '@smithy/credential-provider-imds': 4.2.5 + '@smithy/property-provider': 4.2.5 + '@smithy/shared-ini-file-loader': 4.4.0 + '@smithy/types': 4.9.0 + tslib: 2.8.1 + transitivePeerDependencies: + - aws-crt + + '@aws-sdk/credential-provider-ini@3.972.10': + dependencies: + '@aws-sdk/core': 3.973.12 + '@aws-sdk/credential-provider-env': 3.972.10 + '@aws-sdk/credential-provider-http': 3.972.12 + '@aws-sdk/credential-provider-login': 3.972.10 + '@aws-sdk/credential-provider-process': 3.972.10 + '@aws-sdk/credential-provider-sso': 3.972.10 + '@aws-sdk/credential-provider-web-identity': 3.972.10 + '@aws-sdk/nested-clients': 3.996.0 + '@aws-sdk/types': 3.973.1 + '@smithy/credential-provider-imds': 4.2.9 + '@smithy/property-provider': 4.2.9 + '@smithy/shared-ini-file-loader': 4.4.4 + '@smithy/types': 4.12.1 + tslib: 2.8.1 + transitivePeerDependencies: + - aws-crt + + '@aws-sdk/credential-provider-login@3.952.0': + dependencies: + '@aws-sdk/core': 3.947.0 + '@aws-sdk/nested-clients': 3.952.0 + '@aws-sdk/types': 3.936.0 + '@smithy/property-provider': 4.2.5 + '@smithy/protocol-http': 5.3.5 + '@smithy/shared-ini-file-loader': 4.4.0 + '@smithy/types': 4.9.0 + tslib: 2.8.1 + transitivePeerDependencies: + - aws-crt + + '@aws-sdk/credential-provider-login@3.972.10': + dependencies: + '@aws-sdk/core': 3.973.12 + '@aws-sdk/nested-clients': 3.996.0 + '@aws-sdk/types': 3.973.1 + '@smithy/property-provider': 4.2.9 + '@smithy/protocol-http': 5.3.9 + '@smithy/shared-ini-file-loader': 4.4.4 + '@smithy/types': 4.12.1 + tslib: 2.8.1 + transitivePeerDependencies: + - aws-crt + '@aws-sdk/credential-provider-node@3.929.0': dependencies: '@aws-sdk/credential-provider-env': 3.928.0 @@ -7990,6 +8988,40 @@ snapshots: transitivePeerDependencies: - aws-crt + '@aws-sdk/credential-provider-node@3.952.0': + dependencies: + '@aws-sdk/credential-provider-env': 3.947.0 + '@aws-sdk/credential-provider-http': 3.947.0 + '@aws-sdk/credential-provider-ini': 3.952.0 + '@aws-sdk/credential-provider-process': 3.947.0 + '@aws-sdk/credential-provider-sso': 3.952.0 + '@aws-sdk/credential-provider-web-identity': 3.952.0 + '@aws-sdk/types': 3.936.0 + '@smithy/credential-provider-imds': 4.2.5 + '@smithy/property-provider': 4.2.5 + '@smithy/shared-ini-file-loader': 4.4.0 + '@smithy/types': 4.9.0 + tslib: 2.8.1 + transitivePeerDependencies: + - aws-crt + + '@aws-sdk/credential-provider-node@3.972.11': + dependencies: + '@aws-sdk/credential-provider-env': 3.972.10 + '@aws-sdk/credential-provider-http': 3.972.12 + '@aws-sdk/credential-provider-ini': 3.972.10 + '@aws-sdk/credential-provider-process': 3.972.10 + '@aws-sdk/credential-provider-sso': 3.972.10 + '@aws-sdk/credential-provider-web-identity': 3.972.10 + '@aws-sdk/types': 3.973.1 + '@smithy/credential-provider-imds': 4.2.9 + '@smithy/property-provider': 4.2.9 + '@smithy/shared-ini-file-loader': 4.4.4 + '@smithy/types': 4.12.1 + tslib: 2.8.1 + transitivePeerDependencies: + - aws-crt + '@aws-sdk/credential-provider-process@3.928.0': dependencies: '@aws-sdk/core': 3.928.0 @@ -7999,6 +9031,24 @@ snapshots: '@smithy/types': 4.9.0 tslib: 2.8.1 + '@aws-sdk/credential-provider-process@3.947.0': + dependencies: + '@aws-sdk/core': 3.947.0 + '@aws-sdk/types': 3.936.0 + '@smithy/property-provider': 4.2.5 + '@smithy/shared-ini-file-loader': 4.4.0 + '@smithy/types': 4.9.0 + tslib: 2.8.1 + + '@aws-sdk/credential-provider-process@3.972.10': + dependencies: + '@aws-sdk/core': 3.973.12 + '@aws-sdk/types': 3.973.1 + '@smithy/property-provider': 4.2.9 + '@smithy/shared-ini-file-loader': 4.4.4 + '@smithy/types': 4.12.1 + tslib: 2.8.1 + '@aws-sdk/credential-provider-sso@3.929.0': dependencies: '@aws-sdk/client-sso': 3.929.0 @@ -8012,6 +9062,32 @@ snapshots: transitivePeerDependencies: - aws-crt + '@aws-sdk/credential-provider-sso@3.952.0': + dependencies: + '@aws-sdk/client-sso': 3.948.0 + '@aws-sdk/core': 3.947.0 + '@aws-sdk/token-providers': 3.952.0 + '@aws-sdk/types': 3.936.0 + '@smithy/property-provider': 4.2.5 + '@smithy/shared-ini-file-loader': 4.4.0 + '@smithy/types': 4.9.0 + tslib: 2.8.1 + transitivePeerDependencies: + - aws-crt + + '@aws-sdk/credential-provider-sso@3.972.10': + dependencies: + '@aws-sdk/client-sso': 3.996.0 + '@aws-sdk/core': 3.973.12 + '@aws-sdk/token-providers': 3.996.0 + '@aws-sdk/types': 3.973.1 + '@smithy/property-provider': 4.2.9 + '@smithy/shared-ini-file-loader': 4.4.4 + '@smithy/types': 4.12.1 + tslib: 2.8.1 + transitivePeerDependencies: + - aws-crt + '@aws-sdk/credential-provider-web-identity@3.929.0': dependencies: '@aws-sdk/core': 3.928.0 @@ -8024,6 +9100,54 @@ snapshots: transitivePeerDependencies: - aws-crt + '@aws-sdk/credential-provider-web-identity@3.952.0': + dependencies: + '@aws-sdk/core': 3.947.0 + '@aws-sdk/nested-clients': 3.952.0 + '@aws-sdk/types': 3.936.0 + '@smithy/property-provider': 4.2.5 + '@smithy/shared-ini-file-loader': 4.4.0 + '@smithy/types': 4.9.0 + tslib: 2.8.1 + transitivePeerDependencies: + - aws-crt + + '@aws-sdk/credential-provider-web-identity@3.972.10': + dependencies: + '@aws-sdk/core': 3.973.12 + '@aws-sdk/nested-clients': 3.996.0 + '@aws-sdk/types': 3.973.1 + '@smithy/property-provider': 4.2.9 + '@smithy/shared-ini-file-loader': 4.4.4 + '@smithy/types': 4.12.1 + tslib: 2.8.1 + transitivePeerDependencies: + - aws-crt + + '@aws-sdk/dynamodb-codec@3.972.13': + dependencies: + '@aws-sdk/core': 3.973.12 + '@smithy/core': 3.23.4 + '@smithy/smithy-client': 4.11.7 + '@smithy/types': 4.12.1 + '@smithy/util-base64': 4.3.0 + tslib: 2.8.1 + + '@aws-sdk/endpoint-cache@3.972.2': + dependencies: + mnemonist: 0.38.3 + tslib: 2.8.1 + + '@aws-sdk/lib-dynamodb@3.980.0(@aws-sdk/client-dynamodb@3.980.0)': + dependencies: + '@aws-sdk/client-dynamodb': 3.980.0 + '@aws-sdk/core': 3.973.12 + '@aws-sdk/util-dynamodb': 3.980.0(@aws-sdk/client-dynamodb@3.980.0) + '@smithy/core': 3.23.4 + '@smithy/smithy-client': 4.11.7 + '@smithy/types': 4.12.1 + tslib: 2.8.1 + '@aws-sdk/middleware-bucket-endpoint@3.922.0': dependencies: '@aws-sdk/types': 3.922.0 @@ -8034,6 +9158,15 @@ snapshots: '@smithy/util-config-provider': 4.2.0 tslib: 2.8.1 + '@aws-sdk/middleware-endpoint-discovery@3.972.3': + dependencies: + '@aws-sdk/endpoint-cache': 3.972.2 + '@aws-sdk/types': 3.973.1 + '@smithy/node-config-provider': 4.3.9 + '@smithy/protocol-http': 5.3.9 + '@smithy/types': 4.12.1 + tslib: 2.8.1 + '@aws-sdk/middleware-expect-continue@3.922.0': dependencies: '@aws-sdk/types': 3.922.0 @@ -8064,6 +9197,20 @@ snapshots: '@smithy/types': 4.9.0 tslib: 2.8.1 + '@aws-sdk/middleware-host-header@3.936.0': + dependencies: + '@aws-sdk/types': 3.936.0 + '@smithy/protocol-http': 5.3.5 + '@smithy/types': 4.9.0 + tslib: 2.8.1 + + '@aws-sdk/middleware-host-header@3.972.3': + dependencies: + '@aws-sdk/types': 3.973.1 + '@smithy/protocol-http': 5.3.9 + '@smithy/types': 4.12.1 + tslib: 2.8.1 + '@aws-sdk/middleware-location-constraint@3.922.0': dependencies: '@aws-sdk/types': 3.922.0 @@ -8076,6 +9223,18 @@ snapshots: '@smithy/types': 4.9.0 tslib: 2.8.1 + '@aws-sdk/middleware-logger@3.936.0': + dependencies: + '@aws-sdk/types': 3.936.0 + '@smithy/types': 4.9.0 + tslib: 2.8.1 + + '@aws-sdk/middleware-logger@3.972.3': + dependencies: + '@aws-sdk/types': 3.973.1 + '@smithy/types': 4.12.1 + tslib: 2.8.1 + '@aws-sdk/middleware-recursion-detection@3.922.0': dependencies: '@aws-sdk/types': 3.922.0 @@ -8084,6 +9243,22 @@ snapshots: '@smithy/types': 4.9.0 tslib: 2.8.1 + '@aws-sdk/middleware-recursion-detection@3.948.0': + dependencies: + '@aws-sdk/types': 3.936.0 + '@aws/lambda-invoke-store': 0.2.3 + '@smithy/protocol-http': 5.3.5 + '@smithy/types': 4.9.0 + tslib: 2.8.1 + + '@aws-sdk/middleware-recursion-detection@3.972.3': + dependencies: + '@aws-sdk/types': 3.973.1 + '@aws/lambda-invoke-store': 0.2.3 + '@smithy/protocol-http': 5.3.9 + '@smithy/types': 4.12.1 + tslib: 2.8.1 + '@aws-sdk/middleware-sdk-s3@3.928.0': dependencies: '@aws-sdk/core': 3.928.0 @@ -8117,6 +9292,26 @@ snapshots: '@smithy/types': 4.9.0 tslib: 2.8.1 + '@aws-sdk/middleware-user-agent@3.947.0': + dependencies: + '@aws-sdk/core': 3.947.0 + '@aws-sdk/types': 3.936.0 + '@aws-sdk/util-endpoints': 3.936.0 + '@smithy/core': 3.23.4 + '@smithy/protocol-http': 5.3.5 + '@smithy/types': 4.9.0 + tslib: 2.8.1 + + '@aws-sdk/middleware-user-agent@3.972.12': + dependencies: + '@aws-sdk/core': 3.973.12 + '@aws-sdk/types': 3.973.1 + '@aws-sdk/util-endpoints': 3.996.0 + '@smithy/core': 3.23.4 + '@smithy/protocol-http': 5.3.9 + '@smithy/types': 4.12.1 + tslib: 2.8.1 + '@aws-sdk/nested-clients@3.929.0': dependencies: '@aws-crypto/sha256-browser': 5.2.0 @@ -8160,6 +9355,92 @@ snapshots: transitivePeerDependencies: - aws-crt + '@aws-sdk/nested-clients@3.952.0': + dependencies: + '@aws-crypto/sha256-browser': 5.2.0 + '@aws-crypto/sha256-js': 5.2.0 + '@aws-sdk/core': 3.947.0 + '@aws-sdk/middleware-host-header': 3.936.0 + '@aws-sdk/middleware-logger': 3.936.0 + '@aws-sdk/middleware-recursion-detection': 3.948.0 + '@aws-sdk/middleware-user-agent': 3.947.0 + '@aws-sdk/region-config-resolver': 3.936.0 + '@aws-sdk/types': 3.936.0 + '@aws-sdk/util-endpoints': 3.936.0 + '@aws-sdk/util-user-agent-browser': 3.936.0 + '@aws-sdk/util-user-agent-node': 3.947.0 + '@smithy/config-resolver': 4.4.3 + '@smithy/core': 3.23.4 + '@smithy/fetch-http-handler': 5.3.6 + '@smithy/hash-node': 4.2.5 + '@smithy/invalid-dependency': 4.2.5 + '@smithy/middleware-content-length': 4.2.5 + '@smithy/middleware-endpoint': 4.4.18 + '@smithy/middleware-retry': 4.4.35 + '@smithy/middleware-serde': 4.2.10 + '@smithy/middleware-stack': 4.2.5 + '@smithy/node-config-provider': 4.3.5 + '@smithy/node-http-handler': 4.4.5 + '@smithy/protocol-http': 5.3.5 + '@smithy/smithy-client': 4.11.7 + '@smithy/types': 4.9.0 + '@smithy/url-parser': 4.2.5 + '@smithy/util-base64': 4.3.0 + '@smithy/util-body-length-browser': 4.2.0 + '@smithy/util-body-length-node': 4.2.1 + '@smithy/util-defaults-mode-browser': 4.3.34 + '@smithy/util-defaults-mode-node': 4.2.37 + '@smithy/util-endpoints': 3.2.5 + '@smithy/util-middleware': 4.2.5 + '@smithy/util-retry': 4.2.5 + '@smithy/util-utf8': 4.2.0 + tslib: 2.8.1 + transitivePeerDependencies: + - aws-crt + + '@aws-sdk/nested-clients@3.996.0': + dependencies: + '@aws-crypto/sha256-browser': 5.2.0 + '@aws-crypto/sha256-js': 5.2.0 + '@aws-sdk/core': 3.973.12 + '@aws-sdk/middleware-host-header': 3.972.3 + '@aws-sdk/middleware-logger': 3.972.3 + '@aws-sdk/middleware-recursion-detection': 3.972.3 + '@aws-sdk/middleware-user-agent': 3.972.12 + '@aws-sdk/region-config-resolver': 3.972.3 + '@aws-sdk/types': 3.973.1 + '@aws-sdk/util-endpoints': 3.996.0 + '@aws-sdk/util-user-agent-browser': 3.972.3 + '@aws-sdk/util-user-agent-node': 3.972.11 + '@smithy/config-resolver': 4.4.7 + '@smithy/core': 3.23.4 + '@smithy/fetch-http-handler': 5.3.10 + '@smithy/hash-node': 4.2.9 + '@smithy/invalid-dependency': 4.2.9 + '@smithy/middleware-content-length': 4.2.9 + '@smithy/middleware-endpoint': 4.4.18 + '@smithy/middleware-retry': 4.4.35 + '@smithy/middleware-serde': 4.2.10 + '@smithy/middleware-stack': 4.2.9 + '@smithy/node-config-provider': 4.3.9 + '@smithy/node-http-handler': 4.4.11 + '@smithy/protocol-http': 5.3.9 + '@smithy/smithy-client': 4.11.7 + '@smithy/types': 4.12.1 + '@smithy/url-parser': 4.2.9 + '@smithy/util-base64': 4.3.0 + '@smithy/util-body-length-browser': 4.2.0 + '@smithy/util-body-length-node': 4.2.1 + '@smithy/util-defaults-mode-browser': 4.3.34 + '@smithy/util-defaults-mode-node': 4.2.37 + '@smithy/util-endpoints': 3.2.9 + '@smithy/util-middleware': 4.2.9 + '@smithy/util-retry': 4.2.9 + '@smithy/util-utf8': 4.2.0 + tslib: 2.8.1 + transitivePeerDependencies: + - aws-crt + '@aws-sdk/region-config-resolver@3.925.0': dependencies: '@aws-sdk/types': 3.922.0 @@ -8168,6 +9449,22 @@ snapshots: '@smithy/types': 4.9.0 tslib: 2.8.1 + '@aws-sdk/region-config-resolver@3.936.0': + dependencies: + '@aws-sdk/types': 3.936.0 + '@smithy/config-resolver': 4.4.3 + '@smithy/node-config-provider': 4.3.5 + '@smithy/types': 4.9.0 + tslib: 2.8.1 + + '@aws-sdk/region-config-resolver@3.972.3': + dependencies: + '@aws-sdk/types': 3.973.1 + '@smithy/config-resolver': 4.4.7 + '@smithy/node-config-provider': 4.3.9 + '@smithy/types': 4.12.1 + tslib: 2.8.1 + '@aws-sdk/signature-v4-multi-region@3.928.0': dependencies: '@aws-sdk/middleware-sdk-s3': 3.928.0 @@ -8189,31 +9486,108 @@ snapshots: transitivePeerDependencies: - aws-crt + '@aws-sdk/token-providers@3.952.0': + dependencies: + '@aws-sdk/core': 3.947.0 + '@aws-sdk/nested-clients': 3.952.0 + '@aws-sdk/types': 3.936.0 + '@smithy/property-provider': 4.2.5 + '@smithy/shared-ini-file-loader': 4.4.0 + '@smithy/types': 4.9.0 + tslib: 2.8.1 + transitivePeerDependencies: + - aws-crt + + '@aws-sdk/token-providers@3.996.0': + dependencies: + '@aws-sdk/core': 3.973.12 + '@aws-sdk/nested-clients': 3.996.0 + '@aws-sdk/types': 3.973.1 + '@smithy/property-provider': 4.2.9 + '@smithy/shared-ini-file-loader': 4.4.4 + '@smithy/types': 4.12.1 + tslib: 2.8.1 + transitivePeerDependencies: + - aws-crt + '@aws-sdk/types@3.922.0': dependencies: '@smithy/types': 4.9.0 tslib: 2.8.1 + '@aws-sdk/types@3.936.0': + dependencies: + '@smithy/types': 4.9.0 + tslib: 2.8.1 + + '@aws-sdk/types@3.973.1': + dependencies: + '@smithy/types': 4.12.1 + tslib: 2.8.1 + '@aws-sdk/util-arn-parser@3.893.0': dependencies: tslib: 2.8.1 - '@aws-sdk/util-endpoints@3.922.0': + '@aws-sdk/util-dynamodb@3.980.0(@aws-sdk/client-dynamodb@3.980.0)': + dependencies: + '@aws-sdk/client-dynamodb': 3.980.0 + tslib: 2.8.1 + + '@aws-sdk/util-endpoints@3.922.0': + dependencies: + '@aws-sdk/types': 3.922.0 + '@smithy/types': 4.9.0 + '@smithy/url-parser': 4.2.5 + '@smithy/util-endpoints': 3.2.5 + tslib: 2.8.1 + + '@aws-sdk/util-endpoints@3.936.0': + dependencies: + '@aws-sdk/types': 3.936.0 + '@smithy/types': 4.9.0 + '@smithy/url-parser': 4.2.5 + '@smithy/util-endpoints': 3.2.5 + tslib: 2.8.1 + + '@aws-sdk/util-endpoints@3.980.0': + dependencies: + '@aws-sdk/types': 3.973.1 + '@smithy/types': 4.12.1 + '@smithy/url-parser': 4.2.9 + '@smithy/util-endpoints': 3.2.9 + tslib: 2.8.1 + + '@aws-sdk/util-endpoints@3.996.0': + dependencies: + '@aws-sdk/types': 3.973.1 + '@smithy/types': 4.12.1 + '@smithy/url-parser': 4.2.9 + '@smithy/util-endpoints': 3.2.9 + tslib: 2.8.1 + + '@aws-sdk/util-locate-window@3.893.0': + dependencies: + tslib: 2.8.1 + + '@aws-sdk/util-user-agent-browser@3.922.0': dependencies: '@aws-sdk/types': 3.922.0 '@smithy/types': 4.9.0 - '@smithy/url-parser': 4.2.5 - '@smithy/util-endpoints': 3.2.5 + bowser: 2.12.1 tslib: 2.8.1 - '@aws-sdk/util-locate-window@3.893.0': + '@aws-sdk/util-user-agent-browser@3.936.0': dependencies: + '@aws-sdk/types': 3.936.0 + '@smithy/types': 4.9.0 + bowser: 2.12.1 tslib: 2.8.1 - '@aws-sdk/util-user-agent-browser@3.922.0': + '@aws-sdk/util-user-agent-browser@3.972.3': dependencies: - '@aws-sdk/types': 3.922.0 - '@smithy/types': 4.9.0 + '@aws-sdk/types': 3.973.1 + '@smithy/types': 4.12.1 bowser: 2.12.1 tslib: 2.8.1 @@ -8225,14 +9599,44 @@ snapshots: '@smithy/types': 4.9.0 tslib: 2.8.1 + '@aws-sdk/util-user-agent-node@3.947.0': + dependencies: + '@aws-sdk/middleware-user-agent': 3.947.0 + '@aws-sdk/types': 3.936.0 + '@smithy/node-config-provider': 4.3.5 + '@smithy/types': 4.9.0 + tslib: 2.8.1 + + '@aws-sdk/util-user-agent-node@3.972.11': + dependencies: + '@aws-sdk/middleware-user-agent': 3.972.12 + '@aws-sdk/types': 3.973.1 + '@smithy/node-config-provider': 4.3.9 + '@smithy/types': 4.12.1 + tslib: 2.8.1 + '@aws-sdk/xml-builder@3.921.0': dependencies: '@smithy/types': 4.9.0 fast-xml-parser: 5.3.5 tslib: 2.8.1 + '@aws-sdk/xml-builder@3.930.0': + dependencies: + '@smithy/types': 4.9.0 + fast-xml-parser: 5.3.5 + tslib: 2.8.1 + + '@aws-sdk/xml-builder@3.972.5': + dependencies: + '@smithy/types': 4.12.1 + fast-xml-parser: 5.3.6 + tslib: 2.8.1 + '@aws/lambda-invoke-store@0.1.1': {} + '@aws/lambda-invoke-store@0.2.3': {} + '@azu/format-text@1.0.2': {} '@azu/style-format@1.0.1': @@ -8780,6 +10184,18 @@ snapshots: '@shikijs/types': 3.15.0 '@shikijs/vscode-textmate': 10.0.2 + '@h4ad/serverless-adapter@4.4.0(@types/aws-lambda@8.10.160)(@types/body-parser@1.19.6)(@types/cors@2.8.19)(@types/express@5.0.3)(body-parser@2.2.1)(cors@2.8.5)(express@5.1.0)(http-errors@2.0.1)': + dependencies: + '@types/body-parser': 1.19.6 + '@types/cors': 2.8.19 + optionalDependencies: + '@types/aws-lambda': 8.10.160 + '@types/express': 5.0.3 + body-parser: 2.2.1 + cors: 2.8.5 + express: 5.1.0 + http-errors: 2.0.1 + '@hono/node-server@1.19.9(hono@4.11.9)': dependencies: hono: 4.11.9 @@ -9329,7 +10745,7 @@ snapshots: dependencies: '@modelcontextprotocol/sdk': 1.26.0(zod@3.25.76) cors: 2.8.5 - express: 5.2.1 + express: 5.1.0 shell-quote: 1.8.3 spawn-rx: 5.1.2 ws: 8.18.3 @@ -10152,6 +11568,11 @@ snapshots: '@secretlint/types@10.2.2': {} + '@serverless/event-mocks@1.1.1': + dependencies: + '@types/lodash': 4.17.24 + lodash: 4.17.23 + '@shikijs/core@2.5.0': dependencies: '@shikijs/engine-javascript': 2.5.0 @@ -10236,6 +11657,11 @@ snapshots: '@smithy/types': 4.9.0 tslib: 2.8.1 + '@smithy/abort-controller@4.2.9': + dependencies: + '@smithy/types': 4.12.1 + tslib: 2.8.1 + '@smithy/chunked-blob-reader-native@4.2.1': dependencies: '@smithy/util-base64': 4.3.0 @@ -10254,6 +11680,15 @@ snapshots: '@smithy/util-middleware': 4.2.5 tslib: 2.8.1 + '@smithy/config-resolver@4.4.7': + dependencies: + '@smithy/node-config-provider': 4.3.9 + '@smithy/types': 4.12.1 + '@smithy/util-config-provider': 4.2.1 + '@smithy/util-endpoints': 3.2.9 + '@smithy/util-middleware': 4.2.9 + tslib: 2.8.1 + '@smithy/core@3.18.0': dependencies: '@smithy/middleware-serde': 4.2.5 @@ -10267,6 +11702,19 @@ snapshots: '@smithy/uuid': 1.1.0 tslib: 2.8.1 + '@smithy/core@3.23.4': + dependencies: + '@smithy/middleware-serde': 4.2.10 + '@smithy/protocol-http': 5.3.9 + '@smithy/types': 4.12.1 + '@smithy/util-base64': 4.3.1 + '@smithy/util-body-length-browser': 4.2.1 + '@smithy/util-middleware': 4.2.9 + '@smithy/util-stream': 4.5.14 + '@smithy/util-utf8': 4.2.1 + '@smithy/uuid': 1.1.1 + tslib: 2.8.1 + '@smithy/credential-provider-imds@4.2.5': dependencies: '@smithy/node-config-provider': 4.3.5 @@ -10275,6 +11723,14 @@ snapshots: '@smithy/url-parser': 4.2.5 tslib: 2.8.1 + '@smithy/credential-provider-imds@4.2.9': + dependencies: + '@smithy/node-config-provider': 4.3.9 + '@smithy/property-provider': 4.2.9 + '@smithy/types': 4.12.1 + '@smithy/url-parser': 4.2.9 + tslib: 2.8.1 + '@smithy/eventstream-codec@4.2.5': dependencies: '@aws-crypto/crc32': 5.2.0 @@ -10305,6 +11761,14 @@ snapshots: '@smithy/types': 4.9.0 tslib: 2.8.1 + '@smithy/fetch-http-handler@5.3.10': + dependencies: + '@smithy/protocol-http': 5.3.9 + '@smithy/querystring-builder': 4.2.9 + '@smithy/types': 4.12.1 + '@smithy/util-base64': 4.3.1 + tslib: 2.8.1 + '@smithy/fetch-http-handler@5.3.6': dependencies: '@smithy/protocol-http': 5.3.5 @@ -10327,6 +11791,13 @@ snapshots: '@smithy/util-utf8': 4.2.0 tslib: 2.8.1 + '@smithy/hash-node@4.2.9': + dependencies: + '@smithy/types': 4.12.1 + '@smithy/util-buffer-from': 4.2.1 + '@smithy/util-utf8': 4.2.1 + tslib: 2.8.1 + '@smithy/hash-stream-node@4.2.5': dependencies: '@smithy/types': 4.9.0 @@ -10338,6 +11809,11 @@ snapshots: '@smithy/types': 4.9.0 tslib: 2.8.1 + '@smithy/invalid-dependency@4.2.9': + dependencies: + '@smithy/types': 4.12.1 + tslib: 2.8.1 + '@smithy/is-array-buffer@2.2.0': dependencies: tslib: 2.8.1 @@ -10346,18 +11822,41 @@ snapshots: dependencies: tslib: 2.8.1 + '@smithy/is-array-buffer@4.2.1': + dependencies: + tslib: 2.8.1 + '@smithy/md5-js@4.2.5': dependencies: '@smithy/types': 4.9.0 '@smithy/util-utf8': 4.2.0 tslib: 2.8.1 + '@smithy/middleware-compression@4.3.33': + dependencies: + '@smithy/core': 3.23.4 + '@smithy/is-array-buffer': 4.2.1 + '@smithy/node-config-provider': 4.3.9 + '@smithy/protocol-http': 5.3.9 + '@smithy/types': 4.12.1 + '@smithy/util-config-provider': 4.2.1 + '@smithy/util-middleware': 4.2.9 + '@smithy/util-utf8': 4.2.1 + fflate: 0.8.1 + tslib: 2.8.1 + '@smithy/middleware-content-length@4.2.5': dependencies: '@smithy/protocol-http': 5.3.5 '@smithy/types': 4.9.0 tslib: 2.8.1 + '@smithy/middleware-content-length@4.2.9': + dependencies: + '@smithy/protocol-http': 5.3.9 + '@smithy/types': 4.12.1 + tslib: 2.8.1 + '@smithy/middleware-endpoint@4.3.7': dependencies: '@smithy/core': 3.18.0 @@ -10369,6 +11868,29 @@ snapshots: '@smithy/util-middleware': 4.2.5 tslib: 2.8.1 + '@smithy/middleware-endpoint@4.4.18': + dependencies: + '@smithy/core': 3.23.4 + '@smithy/middleware-serde': 4.2.10 + '@smithy/node-config-provider': 4.3.9 + '@smithy/shared-ini-file-loader': 4.4.4 + '@smithy/types': 4.12.1 + '@smithy/url-parser': 4.2.9 + '@smithy/util-middleware': 4.2.9 + tslib: 2.8.1 + + '@smithy/middleware-retry@4.4.35': + dependencies: + '@smithy/node-config-provider': 4.3.9 + '@smithy/protocol-http': 5.3.9 + '@smithy/service-error-classification': 4.2.9 + '@smithy/smithy-client': 4.11.7 + '@smithy/types': 4.12.1 + '@smithy/util-middleware': 4.2.9 + '@smithy/util-retry': 4.2.9 + '@smithy/uuid': 1.1.1 + tslib: 2.8.1 + '@smithy/middleware-retry@4.4.7': dependencies: '@smithy/node-config-provider': 4.3.5 @@ -10381,6 +11903,12 @@ snapshots: '@smithy/uuid': 1.1.0 tslib: 2.8.1 + '@smithy/middleware-serde@4.2.10': + dependencies: + '@smithy/protocol-http': 5.3.9 + '@smithy/types': 4.12.1 + tslib: 2.8.1 + '@smithy/middleware-serde@4.2.5': dependencies: '@smithy/protocol-http': 5.3.5 @@ -10392,6 +11920,11 @@ snapshots: '@smithy/types': 4.9.0 tslib: 2.8.1 + '@smithy/middleware-stack@4.2.9': + dependencies: + '@smithy/types': 4.12.1 + tslib: 2.8.1 + '@smithy/node-config-provider@4.3.5': dependencies: '@smithy/property-provider': 4.2.5 @@ -10399,6 +11932,21 @@ snapshots: '@smithy/types': 4.9.0 tslib: 2.8.1 + '@smithy/node-config-provider@4.3.9': + dependencies: + '@smithy/property-provider': 4.2.9 + '@smithy/shared-ini-file-loader': 4.4.4 + '@smithy/types': 4.12.1 + tslib: 2.8.1 + + '@smithy/node-http-handler@4.4.11': + dependencies: + '@smithy/abort-controller': 4.2.9 + '@smithy/protocol-http': 5.3.9 + '@smithy/querystring-builder': 4.2.9 + '@smithy/types': 4.12.1 + tslib: 2.8.1 + '@smithy/node-http-handler@4.4.5': dependencies: '@smithy/abort-controller': 4.2.5 @@ -10412,31 +11960,61 @@ snapshots: '@smithy/types': 4.9.0 tslib: 2.8.1 + '@smithy/property-provider@4.2.9': + dependencies: + '@smithy/types': 4.12.1 + tslib: 2.8.1 + '@smithy/protocol-http@5.3.5': dependencies: '@smithy/types': 4.9.0 tslib: 2.8.1 + '@smithy/protocol-http@5.3.9': + dependencies: + '@smithy/types': 4.12.1 + tslib: 2.8.1 + '@smithy/querystring-builder@4.2.5': dependencies: '@smithy/types': 4.9.0 '@smithy/util-uri-escape': 4.2.0 tslib: 2.8.1 + '@smithy/querystring-builder@4.2.9': + dependencies: + '@smithy/types': 4.12.1 + '@smithy/util-uri-escape': 4.2.1 + tslib: 2.8.1 + '@smithy/querystring-parser@4.2.5': dependencies: '@smithy/types': 4.9.0 tslib: 2.8.1 + '@smithy/querystring-parser@4.2.9': + dependencies: + '@smithy/types': 4.12.1 + tslib: 2.8.1 + '@smithy/service-error-classification@4.2.5': dependencies: '@smithy/types': 4.9.0 + '@smithy/service-error-classification@4.2.9': + dependencies: + '@smithy/types': 4.12.1 + '@smithy/shared-ini-file-loader@4.4.0': dependencies: '@smithy/types': 4.9.0 tslib: 2.8.1 + '@smithy/shared-ini-file-loader@4.4.4': + dependencies: + '@smithy/types': 4.12.1 + tslib: 2.8.1 + '@smithy/signature-v4@5.3.5': dependencies: '@smithy/is-array-buffer': 4.2.0 @@ -10448,6 +12026,27 @@ snapshots: '@smithy/util-utf8': 4.2.0 tslib: 2.8.1 + '@smithy/signature-v4@5.3.9': + dependencies: + '@smithy/is-array-buffer': 4.2.1 + '@smithy/protocol-http': 5.3.9 + '@smithy/types': 4.12.1 + '@smithy/util-hex-encoding': 4.2.1 + '@smithy/util-middleware': 4.2.9 + '@smithy/util-uri-escape': 4.2.1 + '@smithy/util-utf8': 4.2.1 + tslib: 2.8.1 + + '@smithy/smithy-client@4.11.7': + dependencies: + '@smithy/core': 3.23.4 + '@smithy/middleware-endpoint': 4.4.18 + '@smithy/middleware-stack': 4.2.9 + '@smithy/protocol-http': 5.3.9 + '@smithy/types': 4.12.1 + '@smithy/util-stream': 4.5.14 + tslib: 2.8.1 + '@smithy/smithy-client@4.9.3': dependencies: '@smithy/core': 3.18.0 @@ -10458,6 +12057,10 @@ snapshots: '@smithy/util-stream': 4.5.6 tslib: 2.8.1 + '@smithy/types@4.12.1': + dependencies: + tslib: 2.8.1 + '@smithy/types@4.9.0': dependencies: tslib: 2.8.1 @@ -10468,16 +12071,32 @@ snapshots: '@smithy/types': 4.9.0 tslib: 2.8.1 + '@smithy/url-parser@4.2.9': + dependencies: + '@smithy/querystring-parser': 4.2.9 + '@smithy/types': 4.12.1 + tslib: 2.8.1 + '@smithy/util-base64@4.3.0': dependencies: '@smithy/util-buffer-from': 4.2.0 '@smithy/util-utf8': 4.2.0 tslib: 2.8.1 + '@smithy/util-base64@4.3.1': + dependencies: + '@smithy/util-buffer-from': 4.2.1 + '@smithy/util-utf8': 4.2.1 + tslib: 2.8.1 + '@smithy/util-body-length-browser@4.2.0': dependencies: tslib: 2.8.1 + '@smithy/util-body-length-browser@4.2.1': + dependencies: + tslib: 2.8.1 + '@smithy/util-body-length-node@4.2.1': dependencies: tslib: 2.8.1 @@ -10492,10 +12111,26 @@ snapshots: '@smithy/is-array-buffer': 4.2.0 tslib: 2.8.1 + '@smithy/util-buffer-from@4.2.1': + dependencies: + '@smithy/is-array-buffer': 4.2.1 + tslib: 2.8.1 + '@smithy/util-config-provider@4.2.0': dependencies: tslib: 2.8.1 + '@smithy/util-config-provider@4.2.1': + dependencies: + tslib: 2.8.1 + + '@smithy/util-defaults-mode-browser@4.3.34': + dependencies: + '@smithy/property-provider': 4.2.9 + '@smithy/smithy-client': 4.11.7 + '@smithy/types': 4.12.1 + tslib: 2.8.1 + '@smithy/util-defaults-mode-browser@4.3.6': dependencies: '@smithy/property-provider': 4.2.5 @@ -10503,6 +12138,16 @@ snapshots: '@smithy/types': 4.9.0 tslib: 2.8.1 + '@smithy/util-defaults-mode-node@4.2.37': + dependencies: + '@smithy/config-resolver': 4.4.7 + '@smithy/credential-provider-imds': 4.2.9 + '@smithy/node-config-provider': 4.3.9 + '@smithy/property-provider': 4.2.9 + '@smithy/smithy-client': 4.11.7 + '@smithy/types': 4.12.1 + tslib: 2.8.1 + '@smithy/util-defaults-mode-node@4.2.9': dependencies: '@smithy/config-resolver': 4.4.3 @@ -10519,21 +12164,53 @@ snapshots: '@smithy/types': 4.9.0 tslib: 2.8.1 + '@smithy/util-endpoints@3.2.9': + dependencies: + '@smithy/node-config-provider': 4.3.9 + '@smithy/types': 4.12.1 + tslib: 2.8.1 + '@smithy/util-hex-encoding@4.2.0': dependencies: tslib: 2.8.1 + '@smithy/util-hex-encoding@4.2.1': + dependencies: + tslib: 2.8.1 + '@smithy/util-middleware@4.2.5': dependencies: '@smithy/types': 4.9.0 tslib: 2.8.1 + '@smithy/util-middleware@4.2.9': + dependencies: + '@smithy/types': 4.12.1 + tslib: 2.8.1 + '@smithy/util-retry@4.2.5': dependencies: '@smithy/service-error-classification': 4.2.5 '@smithy/types': 4.9.0 tslib: 2.8.1 + '@smithy/util-retry@4.2.9': + dependencies: + '@smithy/service-error-classification': 4.2.9 + '@smithy/types': 4.12.1 + tslib: 2.8.1 + + '@smithy/util-stream@4.5.14': + dependencies: + '@smithy/fetch-http-handler': 5.3.10 + '@smithy/node-http-handler': 4.4.11 + '@smithy/types': 4.12.1 + '@smithy/util-base64': 4.3.1 + '@smithy/util-buffer-from': 4.2.1 + '@smithy/util-hex-encoding': 4.2.1 + '@smithy/util-utf8': 4.2.1 + tslib: 2.8.1 + '@smithy/util-stream@4.5.6': dependencies: '@smithy/fetch-http-handler': 5.3.6 @@ -10549,6 +12226,10 @@ snapshots: dependencies: tslib: 2.8.1 + '@smithy/util-uri-escape@4.2.1': + dependencies: + tslib: 2.8.1 + '@smithy/util-utf8@2.3.0': dependencies: '@smithy/util-buffer-from': 2.2.0 @@ -10559,16 +12240,31 @@ snapshots: '@smithy/util-buffer-from': 4.2.0 tslib: 2.8.1 + '@smithy/util-utf8@4.2.1': + dependencies: + '@smithy/util-buffer-from': 4.2.1 + tslib: 2.8.1 + '@smithy/util-waiter@4.2.5': dependencies: '@smithy/abort-controller': 4.2.5 '@smithy/types': 4.9.0 tslib: 2.8.1 + '@smithy/util-waiter@4.2.9': + dependencies: + '@smithy/abort-controller': 4.2.9 + '@smithy/types': 4.12.1 + tslib: 2.8.1 + '@smithy/uuid@1.1.0': dependencies: tslib: 2.8.1 + '@smithy/uuid@1.1.1': + dependencies: + tslib: 2.8.1 + '@stylistic/eslint-plugin@3.1.0(eslint@9.39.1)(typescript@5.9.3)': dependencies: '@typescript-eslint/utils': 8.54.0(eslint@9.39.1)(typescript@5.9.3) @@ -10653,6 +12349,13 @@ snapshots: dependencies: '@types/readdir-glob': 1.1.5 + '@types/aws-lambda@8.10.160': {} + + '@types/body-parser@1.19.6': + dependencies: + '@types/connect': 3.4.38 + '@types/node': 22.19.0 + '@types/cacheable-request@6.0.3': dependencies: '@types/http-cache-semantics': 4.0.4 @@ -10662,16 +12365,45 @@ snapshots: '@types/chai@4.3.20': {} + '@types/compressible@2.0.3': {} + + '@types/connect@3.4.38': + dependencies: + '@types/node': 22.19.0 + + '@types/cors@2.8.19': + dependencies: + '@types/node': 22.19.0 + '@types/ejs@3.1.5': {} '@types/estree@1.0.8': {} + '@types/express-serve-static-core@5.1.1': + dependencies: + '@types/node': 22.19.0 + '@types/qs': 6.14.0 + '@types/range-parser': 1.2.7 + '@types/send': 1.2.1 + + '@types/express@5.0.3': + dependencies: + '@types/body-parser': 1.19.6 + '@types/express-serve-static-core': 5.1.1 + '@types/serve-static': 2.2.0 + '@types/hast@3.0.4': dependencies: '@types/unist': 3.0.3 '@types/http-cache-semantics@4.0.4': {} + '@types/http-errors@2.0.5': {} + + '@types/http-proxy@1.17.17': + dependencies: + '@types/node': 22.19.0 + '@types/istanbul-lib-coverage@2.0.6': {} '@types/json-schema@7.0.15': {} @@ -10684,6 +12416,8 @@ snapshots: '@types/linkify-it@5.0.0': {} + '@types/lodash@4.17.24': {} + '@types/markdown-it@14.1.2': dependencies: '@types/linkify-it': 5.0.0 @@ -10695,12 +12429,16 @@ snapshots: '@types/mdurl@2.0.0': {} + '@types/mime-types@3.0.1': {} + '@types/mocha@10.0.10': {} '@types/mute-stream@0.0.4': dependencies: '@types/node': 22.19.0 + '@types/negotiator@0.6.4': {} + '@types/node@12.20.55': {} '@types/node@22.19.0': @@ -10709,6 +12447,10 @@ snapshots: '@types/normalize-package-data@2.4.4': {} + '@types/qs@6.14.0': {} + + '@types/range-parser@1.2.7': {} + '@types/readdir-glob@1.1.5': dependencies: '@types/node': 22.19.0 @@ -10719,6 +12461,19 @@ snapshots: '@types/sarif@2.1.7': {} + '@types/send@1.2.1': + dependencies: + '@types/node': 22.19.0 + + '@types/serve-static@2.2.0': + dependencies: + '@types/http-errors': 2.0.5 + '@types/node': 22.19.0 + + '@types/set-cookie-parser@2.4.10': + dependencies: + '@types/node': 22.19.0 + '@types/shimmer@1.2.0': {} '@types/sinon@21.0.0': @@ -11112,7 +12867,7 @@ snapshots: accepts@2.0.0: dependencies: - mime-types: 3.0.2 + mime-types: 3.0.1 negotiator: 1.0.0 acorn-import-attributes@1.9.5(acorn@8.15.0): @@ -11737,6 +13492,10 @@ snapshots: normalize-path: 3.0.0 readable-stream: 4.7.0 + compressible@2.0.18: + dependencies: + mime-db: 1.54.0 + concat-map@0.0.1: {} concurrently@9.2.1: @@ -12518,6 +14277,8 @@ snapshots: event-target-shim@5.0.1: {} + eventemitter3@4.0.7: {} + events-universal@1.0.1: dependencies: bare-events: 2.8.2 @@ -12555,6 +14316,38 @@ snapshots: express: 5.2.1 ip-address: 10.0.1 + express@5.1.0: + dependencies: + accepts: 2.0.0 + body-parser: 2.2.1 + content-disposition: 1.0.1 + content-type: 1.0.5 + cookie: 0.7.2 + cookie-signature: 1.2.2 + debug: 4.4.3(supports-color@10.2.2) + encodeurl: 2.0.0 + escape-html: 1.0.3 + etag: 1.8.1 + finalhandler: 2.1.1 + fresh: 2.0.0 + http-errors: 2.0.1 + merge-descriptors: 2.0.0 + mime-types: 3.0.1 + on-finished: 2.4.1 + once: 1.4.0 + parseurl: 1.3.3 + proxy-addr: 2.0.7 + qs: 6.14.1 + range-parser: 1.2.1 + router: 2.2.0 + send: 1.2.1 + serve-static: 2.2.1 + statuses: 2.0.2 + type-is: 2.0.1 + vary: 1.1.2 + transitivePeerDependencies: + - supports-color + express@5.2.1: dependencies: accepts: 2.0.0 @@ -12572,7 +14365,7 @@ snapshots: fresh: 2.0.0 http-errors: 2.0.1 merge-descriptors: 2.0.0 - mime-types: 3.0.2 + mime-types: 3.0.1 on-finished: 2.4.1 once: 1.4.0 parseurl: 1.3.3 @@ -12622,6 +14415,10 @@ snapshots: dependencies: strnum: 2.1.2 + fast-xml-parser@5.3.6: + dependencies: + strnum: 2.1.2 + fastest-levenshtein@1.0.16: {} fastq@1.19.1: @@ -12654,6 +14451,8 @@ snapshots: node-domexception: 1.0.0 web-streams-polyfill: 3.3.3 + fflate@0.8.1: {} + figures@6.1.0: dependencies: is-unicode-supported: 2.1.0 @@ -12708,6 +14507,10 @@ snapshots: dependencies: tabbable: 6.3.0 + follow-redirects@1.15.11(debug@4.4.3): + optionalDependencies: + debug: 4.4.3(supports-color@10.2.2) + for-each@0.3.5: dependencies: is-callable: 1.2.7 @@ -13076,6 +14879,25 @@ snapshots: transitivePeerDependencies: - supports-color + http-proxy-middleware@3.0.5: + dependencies: + '@types/http-proxy': 1.17.17 + debug: 4.4.3(supports-color@10.2.2) + http-proxy: 1.18.1(debug@4.4.3) + is-glob: 4.0.3 + is-plain-object: 5.0.0 + micromatch: 4.0.8 + transitivePeerDependencies: + - supports-color + + http-proxy@1.18.1(debug@4.4.3): + dependencies: + eventemitter3: 4.0.7 + follow-redirects: 1.15.11(debug@4.4.3) + requires-port: 1.0.0 + transitivePeerDependencies: + - debug + http2-wrapper@1.0.3: dependencies: quick-lru: 5.1.1 @@ -13278,6 +15100,8 @@ snapshots: is-plain-obj@4.1.0: {} + is-plain-object@5.0.0: {} + is-promise@4.0.0: {} is-regex@1.2.1: @@ -13692,6 +15516,10 @@ snapshots: dependencies: mime-db: 1.33.0 + mime-types@3.0.1: + dependencies: + mime-db: 1.54.0 + mime-types@3.0.2: dependencies: mime-db: 1.54.0 @@ -13735,6 +15563,10 @@ snapshots: mkdirp-classic@0.5.3: optional: true + mnemonist@0.38.3: + dependencies: + obliterator: 1.6.1 + mocha@10.8.2: dependencies: ansi-colors: 4.1.3 @@ -13972,6 +15804,8 @@ snapshots: define-properties: 1.2.1 es-object-atoms: 1.1.1 + obliterator@1.6.1: {} + oclif@4.22.44(@types/node@22.19.0): dependencies: '@aws-sdk/client-cloudfront': 3.929.0 @@ -14673,6 +16507,8 @@ snapshots: transitivePeerDependencies: - supports-color + requires-port@1.0.0: {} + resolve-alpn@1.2.1: {} resolve-from@4.0.0: {} @@ -14862,6 +16698,8 @@ snapshots: transitivePeerDependencies: - supports-color + set-cookie-parser@2.7.1: {} + set-function-length@1.2.2: dependencies: define-data-property: 1.1.4 @@ -15397,7 +17235,7 @@ snapshots: dependencies: content-type: 1.0.5 media-typer: 1.1.0 - mime-types: 3.0.2 + mime-types: 3.0.1 typed-array-buffer@1.0.3: dependencies: From 97fde367b632bcb84db540db0c7b07337cf0dbdc Mon Sep 17 00:00:00 2001 From: Kieran Haberstock Date: Tue, 3 Mar 2026 12:29:28 -0800 Subject: [PATCH 2/2] Changeset --- .changeset/flat-dogs-stare.md | 5 +++++ packages/mrt-utilities/src/middleware/middleware.ts | 2 +- packages/mrt-utilities/src/streaming/index.ts | 1 - 3 files changed, 6 insertions(+), 2 deletions(-) create mode 100644 .changeset/flat-dogs-stare.md diff --git a/.changeset/flat-dogs-stare.md b/.changeset/flat-dogs-stare.md new file mode 100644 index 00000000..256cbc22 --- /dev/null +++ b/.changeset/flat-dogs-stare.md @@ -0,0 +1,5 @@ +--- +'@salesforce/mrt-utilities': patch +--- + +Initial release diff --git a/packages/mrt-utilities/src/middleware/middleware.ts b/packages/mrt-utilities/src/middleware/middleware.ts index 937baf55..defb08d0 100644 --- a/packages/mrt-utilities/src/middleware/middleware.ts +++ b/packages/mrt-utilities/src/middleware/middleware.ts @@ -402,7 +402,7 @@ export const createMRTStaticAssetServingMiddleware = (staticAssetDir: string): R return express.static(staticAssetDir, { dotfiles: 'deny', setHeaders: setLocalAssetHeaders, - fallthrough: true, + fallthrough: false, }); }; diff --git a/packages/mrt-utilities/src/streaming/index.ts b/packages/mrt-utilities/src/streaming/index.ts index 5979a461..6a34d16c 100644 --- a/packages/mrt-utilities/src/streaming/index.ts +++ b/packages/mrt-utilities/src/streaming/index.ts @@ -4,7 +4,6 @@ * For full license text, see the license.txt file in the repo root or http://www.apache.org/licenses/LICENSE-2.0 */ -export const createNullStream = () => {}; export { type CompressionConfig, createExpressRequest,