diff --git a/.changeset/mrt-utilities-express-4-support.md b/.changeset/mrt-utilities-express-4-support.md new file mode 100644 index 00000000..eb28d690 --- /dev/null +++ b/.changeset/mrt-utilities-express-4-support.md @@ -0,0 +1,5 @@ +--- +'@salesforce/mrt-utilities': patch +--- + +Add Express 4 support, improve middleware error handling, and add dual-version Express test coverage. diff --git a/packages/mrt-utilities/package.json b/packages/mrt-utilities/package.json index 8e77baf8..af25126d 100644 --- a/packages/mrt-utilities/package.json +++ b/packages/mrt-utilities/package.json @@ -115,6 +115,7 @@ "eslint-plugin-header": "catalog:", "eslint-plugin-prettier": "catalog:", "express": "5.1.0", + "express4": "npm:express@4.21.2", "mocha": "catalog:", "prettier": "catalog:", "shx": "catalog:", @@ -124,7 +125,7 @@ "typescript-eslint": "catalog:" }, "peerDependencies": { - "express": "5.1.0" + "express": "^4.0.0 || ^5.0.0" }, "engines": { "node": ">=22.16.0" diff --git a/packages/mrt-utilities/src/middleware/middleware.ts b/packages/mrt-utilities/src/middleware/middleware.ts index e027e586..b8fc3448 100644 --- a/packages/mrt-utilities/src/middleware/middleware.ts +++ b/packages/mrt-utilities/src/middleware/middleware.ts @@ -272,7 +272,7 @@ export const createMRTRequestProcessorMiddleware = ( 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) => { + const ssrRequestProcessorMiddleware = (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 @@ -282,17 +282,21 @@ export const createMRTRequestProcessorMiddleware = ( 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(); + // Apply custom query parameter parsing and forward errors via next() + processIncomingRequest(req, res) + .then(() => { + // 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(); + }) + .catch((err) => { + next(err); + }); }; return ssrRequestProcessorMiddleware; diff --git a/packages/mrt-utilities/test/helpers/express-versions.ts b/packages/mrt-utilities/test/helpers/express-versions.ts new file mode 100644 index 00000000..819e890e --- /dev/null +++ b/packages/mrt-utilities/test/helpers/express-versions.ts @@ -0,0 +1,23 @@ +/* + * 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 express5 from 'express'; +import {createRequire} from 'node:module'; + +/* + * Load Express v4 via an npm alias so tests can run against v4 and v5 + * in the same process without altering production imports. + */ +const require = createRequire(import.meta.url); +const express4 = require('express4') as typeof express5; + +/* + * Export both versions for parameterized test suites. + */ +export const expressVersions = [ + {label: 'express4', express: express4}, + {label: 'express5', express: express5}, +]; diff --git a/packages/mrt-utilities/test/middleware.test.ts b/packages/mrt-utilities/test/middleware.test.ts index 6a3c1f7e..11597622 100644 --- a/packages/mrt-utilities/test/middleware.test.ts +++ b/packages/mrt-utilities/test/middleware.test.ts @@ -4,11 +4,14 @@ * 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 type {Application, Request, Response, NextFunction, RequestHandler} from 'express'; import fs from 'fs'; import path from 'path'; +import http from 'node:http'; +import os from 'node:os'; import {expect} from 'chai'; import sinon from 'sinon'; +import {expressVersions} from './helpers/express-versions.js'; import { createMRTRequestProcessorMiddleware, createMRTProxyMiddlewares, @@ -25,251 +28,441 @@ interface MockResponse extends Partial { 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([]); +const startServer = async (app: Application): Promise<{server: http.Server; baseUrl: string}> => { + return new Promise((resolve) => { + const server = app.listen(0, '127.0.0.1', () => { + const address = server.address(); + const port = typeof address === 'string' ? 0 : address?.port; + resolve({server, baseUrl: `http://127.0.0.1:${port}`}); }); }); - - 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; - }); +}; + +const requestJson = async ( + baseUrl: string, + requestPath: string, + options?: {headers?: Record}, +): Promise<{status: number; headers: http.IncomingHttpHeaders; body: unknown}> => { + return new Promise((resolve, reject) => { + const url = new URL(requestPath, baseUrl); + const req = http.request( + url, + { + method: 'GET', + headers: options?.headers, + }, + (res) => { + let data = ''; + res.setEncoding('utf8'); + res.on('data', (chunk) => { + data += chunk; + }); + res.on('end', () => { + const parsed = data ? JSON.parse(data) : null; + resolve({status: res.statusCode ?? 0, headers: res.headers, body: parsed}); + }); + }, + ); + req.on('error', reject); + req.end(); }); - - 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); +}; + +const requestText = async ( + baseUrl: string, + requestPath: string, +): Promise<{status: number; headers: http.IncomingHttpHeaders; body: string}> => { + return new Promise((resolve, reject) => { + const url = new URL(requestPath, baseUrl); + const req = http.request(url, {method: 'GET'}, (res) => { + const chunks: Buffer[] = []; + res.on('data', (chunk: Buffer) => { + chunks.push(chunk); + }); + res.on('end', () => { + resolve({ + status: res.statusCode ?? 0, + headers: res.headers, + body: Buffer.concat(chunks).toString('utf-8'), + }); + }); }); + req.on('error', reject); + req.end(); }); +}; - 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; +describe('middleware', () => { + expressVersions.forEach(({label, express}) => { + describe(`middleware (${label})`, () => { + let mockRequest: Partial; + let mockResponse: MockResponse; + let mockNext: NextFunction & sinon.SinonStub; + let expressLib: (typeof expressVersions)[number]['express']; + + beforeEach(() => { + expressLib = express; + mockRequest = { + originalUrl: '/test', + method: 'GET', + headers: {}, + query: {}, + app: {set: sinon.stub()} as unknown as 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 new Promise((resolve) => { + mockNext.callsFake(() => resolve()); + 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 new Promise((resolve) => { + mockNext.callsFake(() => resolve()); + 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 new Promise((resolve) => { + mockNext.callsFake(() => resolve()); + 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 new Promise((resolve) => { + mockNext.callsFake(() => resolve()); + 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; + }); + + it('forwards request processor errors via next', async () => { + const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'mrt-processor-')); + const processorPath = path.join(tempDir, 'processor.mjs'); + fs.writeFileSync( + processorPath, + "export function processRequest() { throw new Error('boom'); }", + 'utf-8', + ); + + const middleware = createMRTRequestProcessorMiddleware(processorPath, []); + + const testRequest = { + ...mockRequest, + path: '/test', + } as Request; + + let nextStub: sinon.SinonStub | undefined; + await new Promise((resolve) => { + nextStub = sinon.stub().callsFake(() => { + resolve(); + }); + middleware(testRequest, mockResponse as Response, nextStub as unknown as NextFunction); + }); + + if (!nextStub) { + throw new Error('Expected next to be called'); + } + expect(nextStub.calledOnce).to.be.true; + expect(nextStub.getCall(0).args[0]).to.be.instanceOf(Error); + }); + }); + + describe('createMRTProxyMiddlewares', () => { + const mockProxyFn = sinon.stub() as unknown as 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', () => { + const result = createMRTStaticAssetServingMiddleware('/static'); + + expect(result).to.be.a('function'); + }); + }); + + 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; + }); + }); + + describe('express integration', () => { + it('updates query from x-mobify-querystring header', async () => { + const app = expressLib(); + app.use(createMRTRequestProcessorMiddleware(undefined, [])); + app.get('/test', (req, res) => { + res.json({ + originalUrl: req.originalUrl, + query: req.query, + header: req.headers[X_MOBIFY_QUERYSTRING], + }); + }); + + const {server, baseUrl} = await startServer(app); + try { + const response = await requestJson(baseUrl, '/test?foo=1', { + headers: { + [X_MOBIFY_QUERYSTRING]: 'bar=2', + }, + }); + + expect(response.status).to.equal(200); + const body = response.body as {originalUrl: string; query: Record; header?: string}; + expect(body.originalUrl).to.equal('/test?bar=2'); + expect(body.query).to.deep.equal({bar: '2'}); + expect(body.header).to.be.undefined; + } finally { + server.close(); + } + }); + + it('cleanup middleware updates query when request processor is not used', async () => { + const app = expressLib(); + app.use(createMRTCleanUpMiddleware()); + app.get('/test', (req, res) => { + res.json({ + originalUrl: req.originalUrl, + query: req.query, + header: req.headers[X_MOBIFY_QUERYSTRING], + }); + }); + + const {server, baseUrl} = await startServer(app); + try { + const response = await requestJson(baseUrl, '/test?foo=1', { + headers: { + [X_MOBIFY_QUERYSTRING]: 'bar=2', + }, + }); + + expect(response.status).to.equal(200); + const body = response.body as {originalUrl: string; query: Record; header?: string}; + expect(body.originalUrl).to.equal('/test?bar=2'); + expect(body.query).to.deep.equal({bar: '2'}); + expect(body.header).to.be.undefined; + } finally { + server.close(); + } + }); + + it('serves static assets with MRT headers', async () => { + const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'mrt-static-')); + const filePath = path.join(tempDir, 'test.txt'); + fs.writeFileSync(filePath, 'hello', 'utf-8'); + + const app = expressLib(); + app.use('/static', createMRTStaticAssetServingMiddleware(tempDir)); + + const {server, baseUrl} = await startServer(app); + try { + const response = await requestText(baseUrl, '/static/test.txt'); + + expect(response.status).to.equal(200); + expect(response.body).to.equal('hello'); + expect(response.headers['cache-control']).to.equal('max-age=0, nocache, nostore, must-revalidate'); + expect(response.headers['content-type']).to.include('text/plain'); + expect(response.headers.etag).to.exist; + } finally { + server.close(); + } + }); + }); }); }); }); diff --git a/packages/mrt-utilities/test/streaming/create-lambda-adapter.test.ts b/packages/mrt-utilities/test/streaming/create-lambda-adapter.test.ts index b83acd8c..9f20efc7 100644 --- a/packages/mrt-utilities/test/streaming/create-lambda-adapter.test.ts +++ b/packages/mrt-utilities/test/streaming/create-lambda-adapter.test.ts @@ -7,9 +7,10 @@ import type {APIGatewayProxyEvent, Context} from 'aws-lambda'; import {PassThrough, type Writable} from 'stream'; import {EventEmitter} from 'events'; -import express, {type Express} from 'express'; +import type {Express} from 'express'; import {expect} from 'chai'; import sinon from 'sinon'; +import {expressVersions} from '../helpers/express-versions.js'; import { createStreamingLambdaAdapter, createExpressRequest, @@ -230,26 +231,28 @@ function createMockContext(overrides?: Partial): 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); - }); + expressVersions.forEach(({label, express}) => { + describe(`create-lambda-adapter (${label})`, () => { + 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(); - }); + afterEach(() => { + mockHttpResponseStream.from.resetHistory(); + }); - describe('createStreamingLambdaAdapter', () => { - it('should create a handler function', () => { - const handler = createStreamingLambdaAdapter(mockApp, mockResponseStream); - expect(typeof handler).to.equal('function'); - }); + 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); @@ -355,18 +358,18 @@ describe('create-lambda-adapter', () => { }); }); - 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 - }); + 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'); @@ -583,18 +586,18 @@ describe('create-lambda-adapter', () => { }); }); - 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; - }); + 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'}); @@ -1544,8 +1547,8 @@ describe('create-lambda-adapter', () => { }); }); - describe('createExpressRequest', () => { - describe('multiValueHeaders processing', () => { + describe('createExpressRequest', () => { + describe('multiValueHeaders processing', () => { it('should handle multiValueHeaders with length > 1', () => { const event: APIGatewayProxyEvent = { httpMethod: 'GET', @@ -1781,8 +1784,8 @@ describe('create-lambda-adapter', () => { }); }); - describe('Edge cases and error handling', () => { - describe('initializeResponse edge cases', () => { + 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; @@ -2528,5 +2531,7 @@ describe('create-lambda-adapter', () => { expect(result).to.be.true; }); }); + }); + }); }); }); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index f79ef034..0b1e194a 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -692,6 +692,9 @@ importers: express: specifier: 5.1.0 version: 5.1.0 + express4: + specifier: npm:express@4.21.2 + version: express@4.21.2 mocha: specifier: 'catalog:' version: 10.8.2 @@ -3822,6 +3825,10 @@ packages: '@vueuse/shared@12.8.2': resolution: {integrity: sha512-dznP38YzxZoNloI0qpEfpkms8knDtaoQ6Y/sfS0L7Yki4zh40LFHEhur0odJC6xTHG5dxWVPiUWBXn+wCG2s5w==} + accepts@1.3.8: + resolution: {integrity: sha512-PYAthTa2m2VKxuvSD3DPC/Gy+U+sOA1LAuT8mkmRuvw+NACSaeXEQ+NHcVF7rONl6qcaxV3Uuemwawk+7+SJLw==} + engines: {node: '>= 0.6'} + accepts@2.0.0: resolution: {integrity: sha512-5cvg6CtKwfgdmVqY1WIiXKc3Q1bkRqGLi+2W/6ao+6Y7gu/RCwRuAhGEzh5B4KlszSuTLgZYuqFqo5bImjNKng==} engines: {node: '>= 0.6'} @@ -3936,6 +3943,9 @@ packages: resolution: {integrity: sha512-LHE+8BuR7RYGDKvnrmcuSq3tDcKv9OFEXQt/HpbZhY7V6h0zlUXutnAD82GiFx9rdieCMjkvtcsPqBwgUl1Iiw==} engines: {node: '>= 0.4'} + array-flatten@1.1.1: + resolution: {integrity: sha512-PCVAQswWemu6UdxsDFFX/+gVeYqKAod3D3UVm91jHwynguOwAvYPhx8nNlM++NqRcK6CxxpUafjmhIdKiHibqg==} + array-includes@3.1.9: resolution: {integrity: sha512-FmeCCAenzH0KH381SPT5FZmiA/TmpndpcaShhfgEN9eCVjnFBqq3l1xrI42y8+PPLI6hypzou4GXw00WHmPBLQ==} engines: {node: '>= 0.4'} @@ -4077,6 +4087,10 @@ packages: bl@4.1.0: resolution: {integrity: sha512-1W07cM9gS6DcLperZfFSj+bWLtaPGSOHWhPiGzXmvVJbRLdG82sH/Kn8EtW1VqWVA54AKf2h5k5BbnIbwF3h6w==} + body-parser@1.20.3: + resolution: {integrity: sha512-7rAxByjUMqQ3/bHJy7D6OGXvx/MMc4IqBn/X0fcM1QUcAItpZrBEYhWGem+tzXH90c+G01ypMcYJBO9Y30203g==} + engines: {node: '>= 0.8', npm: 1.2.8000 || >= 1.4.16} + body-parser@2.2.1: resolution: {integrity: sha512-nfDwkulwiZYQIGwxdy0RUmowMhKcFVcYXUU7m4QlKYim1rUtg83xm2yjZ40QjDuc291AJjjeSc9b++AWHSgSHw==} engines: {node: '>=18'} @@ -4388,6 +4402,10 @@ packages: resolution: {integrity: sha512-kRGRZw3bLlFISDBgwTSA1TMBFN6J6GWDeubmDE3AF+3+yXL8hTWv8r5rkLbqYXY4RjPk/EzHnClI3zQf1cFmHA==} engines: {node: '>= 0.6'} + content-disposition@0.5.4: + resolution: {integrity: sha512-FveZTNuGw04cxlAiWbzi6zTAL/lhehaWbTtgluJh4/E95DqMwTmha3KZN1aAWA8cFIhHzMZUvLevkw5Rqk+tSQ==} + engines: {node: '>= 0.6'} + content-disposition@1.0.1: resolution: {integrity: sha512-oIXISMynqSqm241k6kcQ5UwttDILMK4BiurCfGEREw6+X9jkkpEe5T9FZaApyLGGOnFuyMWZpdolTXMtvEJ08Q==} engines: {node: '>=18'} @@ -4402,10 +4420,17 @@ packages: convert-source-map@2.0.0: resolution: {integrity: sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==} + cookie-signature@1.0.6: + resolution: {integrity: sha512-QADzlaHc8icV8I7vbaJXJwod9HWYp8uCqf1xa4OfNu1T7JVxQIrUgOWtHdNDtPiywmFbiS12VjotIXLrKM3orQ==} + cookie-signature@1.2.2: resolution: {integrity: sha512-D76uU73ulSXrD1UXF4KE2TMxVVwhsnCgfAyTg9k8P6KGZjlXKrOLe4dJQKI3Bxi5wjesZoFXJWElNWBjPZMbhg==} engines: {node: '>=6.6.0'} + cookie@0.7.1: + resolution: {integrity: sha512-6DnInpx7SJ2AK3+CTUE/ZM0vWTUboZCegxhC2xiIydHR9jNuTAASBrfEpHhiGOZw/nX51bHt6YQl8jsGo4y/0w==} + engines: {node: '>= 0.6'} + cookie@0.7.2: resolution: {integrity: sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==} engines: {node: '>= 0.6'} @@ -4467,6 +4492,14 @@ packages: dateformat@4.6.3: resolution: {integrity: sha512-2P0p0pFGzHS5EMnhdxQi7aJN+iMheud0UhG4dlE1DLAlvL8JHjJJTX/CSm4JXwV0Ka5nGk3zC5mcb5bUQUxxMA==} + debug@2.6.9: + resolution: {integrity: sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==} + peerDependencies: + supports-color: '*' + peerDependenciesMeta: + supports-color: + optional: true + debug@3.2.7: resolution: {integrity: sha512-CFjzYYAi4ThfiQvizrFQevTTXHtnCqWfe7x1AhgEscTz6ZbLbfoLRLPugTQyBth6f8ZERVUSyWHFD/7Wu4t1XQ==} peerDependencies: @@ -4539,6 +4572,10 @@ packages: resolution: {integrity: sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==} engines: {node: '>=6'} + destroy@1.2.0: + resolution: {integrity: sha512-2sJGJTaXIIaR1w4iJSNoN0hnMY7Gpc/n8D4qSCJw8QqFWXf7cuAgnEHxBpweaVcPevC2l3KpjYCx3NypQQgaJg==} + engines: {node: '>= 0.8', npm: 1.2.8000 || >= 1.4.16} + detect-indent@6.1.0: resolution: {integrity: sha512-reYkTUJAZb9gUuZ2RvVCNhVHdg62RHnJ7WJl8ftMi4diZ6NWlciOzQN88pUhSELEwflJht4oQDv0F0BMlwaYtA==} engines: {node: '>=8'} @@ -4648,6 +4685,10 @@ packages: emojilib@2.4.0: resolution: {integrity: sha512-5U0rVMU5Y2n2+ykNLQqMoqklN9ICBT/KsvC1Gz6vqHbz2AXXGkG+Pm5rMWk/8Vjrr/mY9985Hi8DYzn1F09Nyw==} + encodeurl@1.0.2: + resolution: {integrity: sha512-TPJXq8JqFaVYm2CWmPvnP2Iyo4ZSM7/QKcSmuMLDObfpH5fi7RUGmd/rTDf+rut/saiDiQEeVTNgAmJEdAOx0w==} + engines: {node: '>= 0.8'} + encodeurl@2.0.0: resolution: {integrity: sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==} engines: {node: '>= 0.8'} @@ -4991,6 +5032,10 @@ packages: peerDependencies: express: '>= 4.11' + express@4.21.2: + resolution: {integrity: sha512-28HqgMZAmih1Czt9ny7qr6ek2qddF4FclbMzwhCREB6OFfH+rXAnuNCwo1/wFvrtbgsQDb4kSbX9de9lFbrXnA==} + engines: {node: '>= 0.10.0'} + express@5.1.0: resolution: {integrity: sha512-DT9ck5YIRU+8GYzzU5kT3eHGA5iL+1Zd0EutOmTE9Dtk+Tvuzd23VBU+ec7HPNSTxXYO55gPV/hq4pSBJDjFpA==} engines: {node: '>= 18'} @@ -5082,6 +5127,10 @@ packages: resolution: {integrity: sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==} engines: {node: '>=8'} + finalhandler@1.3.1: + resolution: {integrity: sha512-6BN9trH7bp3qvnrRyzsBz+g3lZxTNZTbVO2EV1CS0WIcDbawYVdYvGflME/9QP0h0pYlCDBCTjYa9nZzMDpyxQ==} + engines: {node: '>= 0.8'} + finalhandler@2.1.1: resolution: {integrity: sha512-S8KoZgRZN+a5rNwqTxlZZePjT/4cnm0ROV70LedRHZ0p8u9fRID0hJUZQpkKLzro8LfmC8sx23bY6tVNxv8pQA==} engines: {node: '>= 18.0.0'} @@ -5144,6 +5193,10 @@ packages: resolution: {integrity: sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==} engines: {node: '>= 0.6'} + fresh@0.5.2: + resolution: {integrity: sha512-zJ2mQYM18rEFOudeV4GShTGIQ7RbzA7ozbU9I/XBpm7kqgMywgmylMwXHxZJmkVoYkna9d2pVXVXPdYTP9ej8Q==} + engines: {node: '>= 0.6'} + fresh@2.0.0: resolution: {integrity: sha512-Rx/WycZ60HOaqLKAi6cHRKKI7zxWbJ31MhntmtwMoaTeF7XFH9hhBp8vITaMidfljRQ6eYWCKkaTK+ykVJHP2A==} engines: {node: '>= 0.8'} @@ -5412,6 +5465,10 @@ packages: resolution: {integrity: sha512-ahwimsC23ICE4kPl9xTBjKB4inbRaeLyZeRunC/1Jy/Z6X8tv22MEAjK+KBOMSVLaqXPTTmd8638waVIKLGx2w==} engines: {node: '>=8.0.0'} + http-errors@2.0.0: + resolution: {integrity: sha512-FtwrG/euBzaEjYeRqOgly7G0qviiXoJWnvEH2Z1plBdXgbyjv34pHTSb9zoeHMyDy33+DWy5Wt9Wo+TURtOYSQ==} + engines: {node: '>= 0.8'} + http-errors@2.0.1: resolution: {integrity: sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==} engines: {node: '>= 0.8'} @@ -5452,6 +5509,10 @@ packages: typescript: optional: true + iconv-lite@0.4.24: + resolution: {integrity: sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA==} + engines: {node: '>=0.10.0'} + iconv-lite@0.6.3: resolution: {integrity: sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==} engines: {node: '>=0.10.0'} @@ -6014,10 +6075,17 @@ packages: mdurl@2.0.0: resolution: {integrity: sha512-Lf+9+2r+Tdp5wXDXC4PcIBjTDtq4UKjCPMQhKIuzpJNW0b96kVqSwW0bT7FhRSfmAiFYgP+SCRvdrDozfh0U5w==} + media-typer@0.3.0: + resolution: {integrity: sha512-dq+qelQ9akHpcOl/gUVRTxVIOkAJ1wR3QAvb4RsVjS8oVoFjDGTc679wJYmUmknUF5HwMLOgb5O+a3KxfWapPQ==} + engines: {node: '>= 0.6'} + media-typer@1.1.0: resolution: {integrity: sha512-aisnrDP4GNe06UcKFnV5bfMNPBUw4jsLGaWwWfnH3v02GnBuXX2MCVn5RbrWo0j3pczUilYblq7fQ7Nw2t5XKw==} engines: {node: '>= 0.8'} + merge-descriptors@1.0.3: + resolution: {integrity: sha512-gaNvAS7TZ897/rVaZ0nMtAyxNyi/pdbjbAwUpFQpN70GqnVfOiXpeUUMKRBmzXaSQ8DdTX4/0ms62r2K+hE6mQ==} + merge-descriptors@2.0.0: resolution: {integrity: sha512-Snk314V5ayFLhp3fkUREub6WtjBfPdCPY1Ln8/8munuLuiYhsABgBVWsozAG+MWMbVEvcdcpbi9R7ww22l9Q3g==} engines: {node: '>=18'} @@ -6026,6 +6094,10 @@ packages: resolution: {integrity: sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==} engines: {node: '>= 8'} + methods@1.1.2: + resolution: {integrity: sha512-iclAHeNqNm68zFtnZ0e+1L2yUIdvzNoauKU4WBA3VvH/vPFieF7qfRlwUZU+DA9P9bPXIS90ulxoUoCH23sV2w==} + engines: {node: '>= 0.6'} + micromark-util-character@2.1.1: resolution: {integrity: sha512-wv8tdUTJ3thSFFFJKtpYKOYiGP2+v96Hvk4Tu8KpCAsTMs6yi+nVmGh1syvSCsaxz45J6Jbw+9DD6g97+NV67Q==} @@ -6049,6 +6121,10 @@ packages: resolution: {integrity: sha512-BHJ/EKruNIqJf/QahvxwQZXKygOQ256myeN/Ew+THcAa5q+PjyTTMMeNQC4DZw5AwfvelsUrA6B67NKMqXDbzQ==} engines: {node: '>= 0.6'} + mime-db@1.52.0: + resolution: {integrity: sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==} + engines: {node: '>= 0.6'} + mime-db@1.54.0: resolution: {integrity: sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ==} engines: {node: '>= 0.6'} @@ -6057,6 +6133,10 @@ packages: resolution: {integrity: sha512-lc/aahn+t4/SWV/qcmumYjymLsWfN3ELhpmVuUFjgsORruuZPVSwAQryq+HHGvO/SI2KVX26bx+En+zhM8g8hQ==} engines: {node: '>= 0.6'} + mime-types@2.1.35: + resolution: {integrity: sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==} + engines: {node: '>= 0.6'} + mime-types@3.0.1: resolution: {integrity: sha512-xRc4oEhT6eaBpU1XF7AjpOFD+xQmXNB5OVKwp4tqCuBpHLS/ZbBDrc07mYTDqVMg6PfxUjjNp85O6Cd2Z/5HWA==} engines: {node: '>= 0.6'} @@ -6145,6 +6225,9 @@ packages: resolution: {integrity: sha512-tzzskb3bG8LvYGFF/mDTpq3jpI6Q9wc3LEmBaghu+DdCssd1FakN7Bc0hVNmEyGq1bq3RgfkCb3cmQLpNPOroA==} engines: {node: '>=4'} + ms@2.0.0: + resolution: {integrity: sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==} + ms@2.1.3: resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==} @@ -6192,6 +6275,10 @@ packages: resolution: {integrity: sha512-kKHJhxwpR/Okycz4HhQKKlhWe4ASEfPgkSWNmKFHd7+ezuQlxkA5cM3+XkBPvm1gmHen3w53qsYAv+8GwRrBlg==} engines: {node: '>=18'} + negotiator@0.6.3: + resolution: {integrity: sha512-+EUsqGPLsM+j/zdChZjsnX51g4XrHFOIXwfnCVPGlQk/k5giakcKsuxCObBRu6DSm9opw/O6slWbJdghQM4bBg==} + engines: {node: '>= 0.6'} + negotiator@1.0.0: resolution: {integrity: sha512-8Ofs/AUQh8MaEcrlq5xOX0CQ9ypTF5dl78mjlMNfOK08fzpgTHQRQPBxcPlEtIw0yRpws+Zo/3r+5WRby7u3Gg==} engines: {node: '>= 0.6'} @@ -6577,6 +6664,9 @@ packages: resolution: {integrity: sha512-3O/iVVsJAPsOnpwWIeD+d6z/7PmqApyQePUtCndjatj/9I5LylHvt5qluFaBT3I5h3r1ejfR056c+FCv+NnNXg==} engines: {node: 18 || 20 || >=22} + path-to-regexp@0.1.12: + resolution: {integrity: sha512-RA1GjUVMnvYFxuqovrEqZoxxW5NUZqbwKtYz/Tt7nXerk0LbLblQmrsgdeOxV5SFHf0UDggjS/bSeOZwt1pmEQ==} + path-to-regexp@3.3.0: resolution: {integrity: sha512-qyCH421YQPS2WFDxDjftfc1ZR5WKQzVzqsp4n9M2kQhVOo/ByahFoUNJfl58kOcEGfQ//7weFTDhm+ss8Ecxgw==} @@ -6760,6 +6850,10 @@ packages: resolution: {integrity: sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg==} engines: {node: '>= 0.6'} + raw-body@2.5.2: + resolution: {integrity: sha512-8zGqypfENjCIqGhgXToC8aB2r7YrBX+AQAfIPs/Mlk+BtPTztOvTS01NRW/3Eh60J+a48lt8qsCzirQ6loCVfA==} + engines: {node: '>= 0.8'} + raw-body@3.0.2: resolution: {integrity: sha512-K5zQjDllxWkf7Z5xJdV0/B0WTNqx6vxG70zJE4N0kBs4LovmEYWJzQGxC9bS9RAKu3bgM40lrd5zoLJ12MQ5BA==} engines: {node: '>= 0.10'} @@ -7026,6 +7120,10 @@ packages: engines: {node: '>=10'} hasBin: true + send@0.19.0: + resolution: {integrity: sha512-dW41u5VfLXu8SJh5bwRmyYUbAoSB3c9uQh6L8h/KtsFREPWpbX1lrljJo186Jc4nmci/sGUZ9a0a0J2zgfq2hw==} + engines: {node: '>= 0.8.0'} + send@1.2.1: resolution: {integrity: sha512-1gnZf7DFcoIcajTjTwjwuDjzuz4PPcY2StKPlsGAQ1+YH20IRVrBaXSWmdjowTJ6u8Rc01PoYOGHXfP1mYcZNQ==} engines: {node: '>= 18'} @@ -7039,6 +7137,10 @@ packages: serve-handler@6.1.6: resolution: {integrity: sha512-x5RL9Y2p5+Sh3D38Fh9i/iQ5ZK+e4xuXRd/pGbM4D13tgo/MGwbttUk8emytcr1YYzBYs+apnUngBDFYfpjPuQ==} + serve-static@1.16.2: + resolution: {integrity: sha512-VqpjJZKadQB/PEbEwvFdO43Ax5dFBZ2UECszz8bQ7pi7wt//PWe1P6MN7eCnjsatYtBT6EuiClbjSWP2WrIoTw==} + engines: {node: '>= 0.8.0'} + serve-static@2.2.1: resolution: {integrity: sha512-xRXBn0pPqQTVQiC8wyQrKs2MOlX24zQ0POGaj0kultvoOCstBQM5yvOhAVSUwOMjQtTvsPWoNCHfPGwaaQJhTw==} engines: {node: '>= 18'} @@ -7195,6 +7297,10 @@ packages: stack-chain@1.3.7: resolution: {integrity: sha512-D8cWtWVdIe/jBA7v5p5Hwl5yOSOrmZPWDPe2KxQ5UAGD+nxbxU0lKXA4h85Ta6+qgdKVL3vUxsbIZjc1kBG7ug==} + statuses@2.0.1: + resolution: {integrity: sha512-RwNA9Z/7PrK06rYLIzFMlaF+l73iwpzsqRIFgbMLbTcLD6cOao82TaWefPXQvB2fOC4AjuYSEndS7N/mTCbkdQ==} + engines: {node: '>= 0.8'} + statuses@2.0.2: resolution: {integrity: sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==} engines: {node: '>= 0.8'} @@ -7507,6 +7613,10 @@ packages: resolution: {integrity: sha512-VCn+LMHbd4t6sF3wfU/+HKT63C9OoyrSIf4b+vtWHpt2U7/4InZG467YDNMFMR70DdHjAdpPWmw2lzRdg0Xqqg==} engines: {node: '>=20'} + type-is@1.6.18: + resolution: {integrity: sha512-TkRKr9sUTxEH8MdfuCSP7VizJyzRNMjj2J2do2Jr3Kym598JVdEksuzPQCnlFPW4ky9Q+iA+ma9BGm06XQBy8g==} + engines: {node: '>= 0.6'} + type-is@2.0.1: resolution: {integrity: sha512-OZs6gsjF4vMp32qrCbiVSkrFmXtG/AZhY3t0iAMrMBiAZyV9oALtXO8hsrHbMXF9x6L3grlFuwW2oAz7cav+Gw==} engines: {node: '>= 0.6'} @@ -7663,6 +7773,10 @@ packages: util-deprecate@1.0.2: resolution: {integrity: sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==} + utils-merge@1.0.1: + resolution: {integrity: sha512-pMZTvIkT1d+TFGvDOqodOclx0QWkkgi6Tdoa8gC8ffGAAqz9pzPTZWAybbsHHoED/ztMtkv/VoYTYyShUn81hA==} + engines: {node: '>= 0.4.0'} + uuid@8.3.2: resolution: {integrity: sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg==} hasBin: true @@ -12301,6 +12415,11 @@ snapshots: transitivePeerDependencies: - typescript + accepts@1.3.8: + dependencies: + mime-types: 2.1.35 + negotiator: 0.6.3 + accepts@2.0.0: dependencies: mime-types: 3.0.1 @@ -12422,6 +12541,8 @@ snapshots: call-bound: 1.0.4 is-array-buffer: 3.0.5 + array-flatten@1.1.1: {} + array-includes@3.1.9: dependencies: call-bind: 1.0.8 @@ -12571,6 +12692,23 @@ snapshots: readable-stream: 3.6.2 optional: true + body-parser@1.20.3: + dependencies: + bytes: 3.1.2 + content-type: 1.0.5 + debug: 2.6.9 + depd: 2.0.0 + destroy: 1.2.0 + http-errors: 2.0.0 + iconv-lite: 0.4.24 + on-finished: 2.4.1 + qs: 6.14.1 + raw-body: 2.5.2 + type-is: 1.6.18 + unpipe: 1.0.0 + transitivePeerDependencies: + - supports-color + body-parser@2.2.1: dependencies: bytes: 3.1.2 @@ -12945,6 +13083,10 @@ snapshots: content-disposition@0.5.2: {} + content-disposition@0.5.4: + dependencies: + safe-buffer: 5.2.1 + content-disposition@1.0.1: {} content-type@1.0.5: {} @@ -12956,8 +13098,12 @@ snapshots: convert-source-map@2.0.0: {} + cookie-signature@1.0.6: {} + cookie-signature@1.2.2: {} + cookie@0.7.1: {} + cookie@0.7.2: {} cookie@1.1.1: {} @@ -13021,6 +13167,10 @@ snapshots: dateformat@4.6.3: {} + debug@2.6.9: + dependencies: + ms: 2.0.0 + debug@3.2.7: dependencies: ms: 2.1.3 @@ -13081,6 +13231,8 @@ snapshots: dequal@2.0.3: {} + destroy@1.2.0: {} + detect-indent@6.1.0: {} detect-indent@7.0.2: {} @@ -13179,6 +13331,8 @@ snapshots: emojilib@2.4.0: {} + encodeurl@1.0.2: {} + encodeurl@2.0.0: {} encoding-sniffer@0.2.1: @@ -13704,6 +13858,42 @@ snapshots: express: 5.2.1 ip-address: 10.0.1 + express@4.21.2: + dependencies: + accepts: 1.3.8 + array-flatten: 1.1.1 + body-parser: 1.20.3 + content-disposition: 0.5.4 + content-type: 1.0.5 + cookie: 0.7.1 + cookie-signature: 1.0.6 + debug: 2.6.9 + depd: 2.0.0 + encodeurl: 2.0.0 + escape-html: 1.0.3 + etag: 1.8.1 + finalhandler: 1.3.1 + fresh: 0.5.2 + http-errors: 2.0.0 + merge-descriptors: 1.0.3 + methods: 1.1.2 + on-finished: 2.4.1 + parseurl: 1.3.3 + path-to-regexp: 0.1.12 + proxy-addr: 2.0.7 + qs: 6.14.1 + range-parser: 1.2.1 + safe-buffer: 5.2.1 + send: 0.19.0 + serve-static: 1.16.2 + setprototypeof: 1.2.0 + statuses: 2.0.1 + type-is: 1.6.18 + utils-merge: 1.0.1 + vary: 1.1.2 + transitivePeerDependencies: + - supports-color + express@5.1.0: dependencies: accepts: 2.0.0 @@ -13844,6 +14034,18 @@ snapshots: dependencies: to-regex-range: 5.0.1 + finalhandler@1.3.1: + dependencies: + debug: 2.6.9 + encodeurl: 2.0.0 + escape-html: 1.0.3 + on-finished: 2.4.1 + parseurl: 1.3.3 + statuses: 2.0.1 + unpipe: 1.0.0 + transitivePeerDependencies: + - supports-color + finalhandler@2.1.1: dependencies: debug: 4.4.3(supports-color@10.2.2) @@ -13911,6 +14113,8 @@ snapshots: forwarded@0.2.0: {} + fresh@0.5.2: {} + fresh@2.0.0: {} fs-constants@1.0.0: @@ -14213,6 +14417,14 @@ snapshots: transitivePeerDependencies: - supports-color + http-errors@2.0.0: + dependencies: + depd: 2.0.0 + inherits: 2.0.4 + setprototypeof: 1.2.0 + statuses: 2.0.1 + toidentifier: 1.0.1 + http-errors@2.0.1: dependencies: depd: 2.0.0 @@ -14269,6 +14481,10 @@ snapshots: optionalDependencies: typescript: 5.9.3 + iconv-lite@0.4.24: + dependencies: + safer-buffer: 2.1.2 + iconv-lite@0.6.3: dependencies: safer-buffer: 2.1.2 @@ -14787,12 +15003,18 @@ snapshots: mdurl@2.0.0: {} + media-typer@0.3.0: {} + media-typer@1.1.0: {} + merge-descriptors@1.0.3: {} + merge-descriptors@2.0.0: {} merge2@1.4.1: {} + methods@1.1.2: {} + micromark-util-character@2.1.1: dependencies: micromark-util-symbol: 2.0.1 @@ -14817,12 +15039,18 @@ snapshots: mime-db@1.33.0: {} + mime-db@1.52.0: {} + mime-db@1.54.0: {} mime-types@2.1.18: dependencies: mime-db: 1.33.0 + mime-types@2.1.35: + dependencies: + mime-db: 1.52.0 + mime-types@3.0.1: dependencies: mime-db: 1.54.0 @@ -14929,6 +15157,8 @@ snapshots: mri@1.2.0: {} + ms@2.0.0: {} + ms@2.1.3: {} msw@2.12.4(@types/node@22.19.0)(typescript@5.9.3): @@ -14979,6 +15209,8 @@ snapshots: natural-orderby@5.0.0: {} + negotiator@0.6.3: {} + negotiator@1.0.0: {} no-case@3.0.4: @@ -15349,6 +15581,8 @@ snapshots: lru-cache: 11.2.2 minipass: 7.1.3 + path-to-regexp@0.1.12: {} + path-to-regexp@3.3.0: {} path-to-regexp@6.3.0: {} @@ -15522,6 +15756,13 @@ snapshots: range-parser@1.2.1: {} + raw-body@2.5.2: + dependencies: + bytes: 3.1.2 + http-errors: 2.0.0 + iconv-lite: 0.4.24 + unpipe: 1.0.0 + raw-body@3.0.2: dependencies: bytes: 3.1.2 @@ -15835,6 +16076,24 @@ snapshots: semver@7.7.3: {} + send@0.19.0: + dependencies: + debug: 2.6.9 + depd: 2.0.0 + destroy: 1.2.0 + encodeurl: 1.0.2 + escape-html: 1.0.3 + etag: 1.8.1 + fresh: 0.5.2 + http-errors: 2.0.0 + mime: 1.6.0 + ms: 2.1.3 + on-finished: 2.4.1 + range-parser: 1.2.1 + statuses: 2.0.1 + transitivePeerDependencies: + - supports-color + send@1.2.1: dependencies: debug: 4.4.3(supports-color@10.2.2) @@ -15871,6 +16130,15 @@ snapshots: path-to-regexp: 3.3.0 range-parser: 1.2.0 + serve-static@1.16.2: + dependencies: + encodeurl: 2.0.0 + escape-html: 1.0.3 + parseurl: 1.3.3 + send: 0.19.0 + transitivePeerDependencies: + - supports-color + serve-static@2.2.1: dependencies: encodeurl: 2.0.0 @@ -16069,6 +16337,8 @@ snapshots: stack-chain@1.3.7: {} + statuses@2.0.1: {} + statuses@2.0.2: {} stdin-discarder@0.2.2: {} @@ -16414,6 +16684,11 @@ snapshots: dependencies: tagged-tag: 1.0.0 + type-is@1.6.18: + dependencies: + media-typer: 0.3.0 + mime-types: 2.1.35 + type-is@2.0.1: dependencies: content-type: 1.0.5 @@ -16598,6 +16873,8 @@ snapshots: util-deprecate@1.0.2: {} + utils-merge@1.0.1: {} + uuid@8.3.2: {} v8-compile-cache-lib@3.0.1: {}