@salesforce/mrt-utilities 0.0.1 → 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +49 -45
- package/dist/cjs/index.d.ts +4 -0
- package/dist/cjs/index.js +10 -0
- package/dist/cjs/index.js.map +1 -0
- package/dist/cjs/metrics/index.d.ts +1 -0
- package/dist/cjs/metrics/index.js +7 -0
- package/dist/cjs/metrics/index.js.map +1 -0
- package/dist/cjs/metrics/metrics-sender.d.ts +136 -0
- package/dist/cjs/metrics/metrics-sender.js +240 -0
- package/dist/cjs/metrics/metrics-sender.js.map +1 -0
- package/dist/cjs/middleware/data-store.d.ts +56 -0
- package/dist/cjs/middleware/data-store.js +124 -0
- package/dist/cjs/middleware/data-store.js.map +1 -0
- package/dist/cjs/middleware/index.d.ts +3 -0
- package/dist/cjs/middleware/index.js +8 -0
- package/dist/cjs/middleware/index.js.map +1 -0
- package/dist/cjs/middleware/middleware.d.ts +126 -0
- package/dist/cjs/middleware/middleware.js +411 -0
- package/dist/cjs/middleware/middleware.js.map +1 -0
- package/dist/cjs/package.json +1 -0
- package/dist/cjs/streaming/create-lambda-adapter.d.ts +68 -0
- package/dist/cjs/streaming/create-lambda-adapter.js +797 -0
- package/dist/cjs/streaming/create-lambda-adapter.js.map +1 -0
- package/dist/cjs/streaming/index.d.ts +1 -0
- package/dist/cjs/streaming/index.js +7 -0
- package/dist/cjs/streaming/index.js.map +1 -0
- package/dist/cjs/utils/configure-proxying.d.ts +160 -0
- package/dist/cjs/utils/configure-proxying.js +204 -0
- package/dist/cjs/utils/configure-proxying.js.map +1 -0
- package/dist/cjs/utils/ssr-proxying.d.ts +300 -0
- package/dist/cjs/utils/ssr-proxying.js +713 -0
- package/dist/cjs/utils/ssr-proxying.js.map +1 -0
- package/dist/cjs/utils/utils.d.ts +27 -0
- package/dist/cjs/utils/utils.js +44 -0
- package/dist/cjs/utils/utils.js.map +1 -0
- package/dist/esm/index.d.ts +4 -0
- package/dist/esm/index.js +10 -0
- package/dist/esm/index.js.map +1 -0
- package/dist/esm/metrics/index.d.ts +1 -0
- package/dist/esm/metrics/index.js +7 -0
- package/dist/esm/metrics/index.js.map +1 -0
- package/dist/esm/metrics/metrics-sender.d.ts +136 -0
- package/dist/esm/metrics/metrics-sender.js +240 -0
- package/dist/esm/metrics/metrics-sender.js.map +1 -0
- package/dist/esm/middleware/data-store.d.ts +56 -0
- package/dist/esm/middleware/data-store.js +124 -0
- package/dist/esm/middleware/data-store.js.map +1 -0
- package/dist/esm/middleware/index.d.ts +3 -0
- package/dist/esm/middleware/index.js +9 -0
- package/dist/esm/middleware/index.js.map +1 -0
- package/dist/esm/middleware/middleware.d.ts +126 -0
- package/dist/esm/middleware/middleware.js +411 -0
- package/dist/esm/middleware/middleware.js.map +1 -0
- package/dist/esm/streaming/create-lambda-adapter.d.ts +68 -0
- package/dist/esm/streaming/create-lambda-adapter.js +797 -0
- package/dist/esm/streaming/create-lambda-adapter.js.map +1 -0
- package/dist/esm/streaming/index.d.ts +1 -0
- package/dist/esm/streaming/index.js +7 -0
- package/dist/esm/streaming/index.js.map +1 -0
- package/dist/esm/utils/configure-proxying.d.ts +160 -0
- package/dist/esm/utils/configure-proxying.js +204 -0
- package/dist/esm/utils/configure-proxying.js.map +1 -0
- package/dist/esm/utils/ssr-proxying.d.ts +300 -0
- package/dist/esm/utils/ssr-proxying.js +713 -0
- package/dist/esm/utils/ssr-proxying.js.map +1 -0
- package/dist/esm/utils/utils.d.ts +27 -0
- package/dist/esm/utils/utils.js +44 -0
- package/dist/esm/utils/utils.js.map +1 -0
- package/package.json +129 -7
|
@@ -0,0 +1,797 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* Copyright (c) 2025, Salesforce, Inc.
|
|
3
|
+
* SPDX-License-Identifier: Apache-2
|
|
4
|
+
* For full license text, see the license.txt file in the repo root or http://www.apache.org/licenses/LICENSE-2.0
|
|
5
|
+
*/
|
|
6
|
+
import { ServerResponse } from 'http';
|
|
7
|
+
import { pipeline } from 'node:stream/promises';
|
|
8
|
+
import zlib from 'node:zlib';
|
|
9
|
+
import Negotiator from 'negotiator';
|
|
10
|
+
import compressible from 'compressible';
|
|
11
|
+
import { ServerlessRequest } from '@h4ad/serverless-adapter';
|
|
12
|
+
/**
|
|
13
|
+
* Header keys to copy from the request to the response.
|
|
14
|
+
* These headers are typically used for tracing, correlation, or other request/response matching purposes.
|
|
15
|
+
*/
|
|
16
|
+
const REQUEST_HEADERS_TO_COPY = ['x-correlation-id'];
|
|
17
|
+
// Check if zstd compression is available (Node.js v22.15.0+)
|
|
18
|
+
let createZstdCompress;
|
|
19
|
+
try {
|
|
20
|
+
// Try to import createZstdCompress - it may not exist in older Node.js versions
|
|
21
|
+
if (typeof zlib.createZstdCompress === 'function') {
|
|
22
|
+
createZstdCompress = zlib.createZstdCompress;
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
catch {
|
|
26
|
+
// zstd not available
|
|
27
|
+
}
|
|
28
|
+
/**
|
|
29
|
+
* Creates a Lambda Adapter that wraps an Express app and supports response streaming
|
|
30
|
+
* for API Gateway v1 proxy integration using AWS Lambda response streaming
|
|
31
|
+
*
|
|
32
|
+
* @param app - Express application instance
|
|
33
|
+
* @param responseStream - AWS Lambda response stream
|
|
34
|
+
* @param compressionConfig - Optional compression configuration
|
|
35
|
+
* @returns Lambda handler function
|
|
36
|
+
*/
|
|
37
|
+
export function createStreamingLambdaAdapter(app, responseStream, compressionConfig = { enabled: true }) {
|
|
38
|
+
const handler = async (event, context) => {
|
|
39
|
+
try {
|
|
40
|
+
await streamResponse(event, responseStream, context, app, compressionConfig);
|
|
41
|
+
}
|
|
42
|
+
catch (error) {
|
|
43
|
+
console.error('Error in streaming handler:', error);
|
|
44
|
+
const isStreamOpen = responseStream && responseStream.writable && !responseStream.destroyed && !responseStream.writableEnded;
|
|
45
|
+
if (isStreamOpen && typeof responseStream.write === 'function') {
|
|
46
|
+
const errorMessage = error instanceof Error ? error.message : String(error);
|
|
47
|
+
responseStream.write(`HTTP/1.1 500 Internal Server Error\r\n\r\nInternal Server Error: ${errorMessage}`);
|
|
48
|
+
}
|
|
49
|
+
else {
|
|
50
|
+
console.error('[error handler] Cannot write error - stream is closed');
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
finally {
|
|
54
|
+
const isStreamOpen = responseStream && responseStream.writable && !responseStream.destroyed && !responseStream.writableEnded;
|
|
55
|
+
if (isStreamOpen && typeof responseStream.end === 'function') {
|
|
56
|
+
responseStream.end();
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
};
|
|
60
|
+
return handler;
|
|
61
|
+
}
|
|
62
|
+
/**
|
|
63
|
+
* Streams the response from Express app using AWS Lambda HttpResponseStream
|
|
64
|
+
*/
|
|
65
|
+
async function streamResponse(event, responseStream, context, app, compressionConfig) {
|
|
66
|
+
// Convert API Gateway event to Express-compatible request
|
|
67
|
+
const expressRequest = createExpressRequest(event, context);
|
|
68
|
+
const expressResponse = createExpressResponse(responseStream, event, context, expressRequest, compressionConfig);
|
|
69
|
+
// Process the request through Express app
|
|
70
|
+
return new Promise((resolve, reject) => {
|
|
71
|
+
let resolved = false;
|
|
72
|
+
const resolveOnce = () => {
|
|
73
|
+
if (!resolved) {
|
|
74
|
+
resolved = true;
|
|
75
|
+
resolve();
|
|
76
|
+
}
|
|
77
|
+
};
|
|
78
|
+
const rejectOnce = (err) => {
|
|
79
|
+
if (!resolved) {
|
|
80
|
+
resolved = true;
|
|
81
|
+
reject(err);
|
|
82
|
+
}
|
|
83
|
+
};
|
|
84
|
+
// Handle response finish
|
|
85
|
+
expressResponse.once('finish', () => {
|
|
86
|
+
resolveOnce();
|
|
87
|
+
});
|
|
88
|
+
// Handle response errors
|
|
89
|
+
expressResponse.once('error', (err) => {
|
|
90
|
+
rejectOnce(err);
|
|
91
|
+
});
|
|
92
|
+
try {
|
|
93
|
+
app(expressRequest, expressResponse, (err) => {
|
|
94
|
+
if (err) {
|
|
95
|
+
console.error('Express app error:', err);
|
|
96
|
+
rejectOnce(err);
|
|
97
|
+
}
|
|
98
|
+
else {
|
|
99
|
+
// If response has finished, resolveOnce will be called by the finish event
|
|
100
|
+
// Otherwise, resolve after a short delay to allow async operations
|
|
101
|
+
if (expressResponse.finished) {
|
|
102
|
+
resolveOnce();
|
|
103
|
+
}
|
|
104
|
+
else {
|
|
105
|
+
// Wait a bit for the response to finish
|
|
106
|
+
setTimeout(() => {
|
|
107
|
+
resolveOnce();
|
|
108
|
+
}, 10);
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
});
|
|
112
|
+
}
|
|
113
|
+
catch (error) {
|
|
114
|
+
console.error('Error in streamResponse:', error);
|
|
115
|
+
rejectOnce(error);
|
|
116
|
+
}
|
|
117
|
+
});
|
|
118
|
+
}
|
|
119
|
+
/**
|
|
120
|
+
* Builds a full URL path with query string from API Gateway event
|
|
121
|
+
* Merges multiValueQueryStringParameters and queryStringParameters
|
|
122
|
+
*/
|
|
123
|
+
const getPathFromEvent = (event) => {
|
|
124
|
+
const path = event.path;
|
|
125
|
+
// Start with multi-value query parameters (already arrays), filtering out undefined values
|
|
126
|
+
const mergedParams = {};
|
|
127
|
+
if (event.multiValueQueryStringParameters) {
|
|
128
|
+
for (const [key, values] of Object.entries(event.multiValueQueryStringParameters)) {
|
|
129
|
+
if (values) {
|
|
130
|
+
mergedParams[key] = [...values];
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
// Merge in single-value query parameters, converting to arrays
|
|
135
|
+
if (event.queryStringParameters) {
|
|
136
|
+
for (const [key, value] of Object.entries(event.queryStringParameters)) {
|
|
137
|
+
if (value === undefined)
|
|
138
|
+
continue;
|
|
139
|
+
// Add to existing array or create new one
|
|
140
|
+
if (mergedParams[key]) {
|
|
141
|
+
if (!mergedParams[key].includes(value)) {
|
|
142
|
+
mergedParams[key].push(value);
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
else {
|
|
146
|
+
mergedParams[key] = [value];
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
// Build query string
|
|
151
|
+
const searchParams = new URLSearchParams();
|
|
152
|
+
for (const [key, values] of Object.entries(mergedParams)) {
|
|
153
|
+
for (const value of values) {
|
|
154
|
+
searchParams.append(key, value);
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
const queryString = searchParams.toString();
|
|
158
|
+
return queryString ? `${path}?${queryString}` : path;
|
|
159
|
+
};
|
|
160
|
+
/**
|
|
161
|
+
* Converts API Gateway event to Express-compatible request object
|
|
162
|
+
* Creates a proper IncomingMessage-like object with stream properties
|
|
163
|
+
*/
|
|
164
|
+
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
|
165
|
+
export function createExpressRequest(event, context) {
|
|
166
|
+
const { httpMethod, headers, multiValueHeaders, body, isBase64Encoded, requestContext } = event;
|
|
167
|
+
const remoteAddress = requestContext?.identity?.sourceIp ?? undefined;
|
|
168
|
+
const bodyEncoding = isBase64Encoded ? 'base64' : 'utf-8';
|
|
169
|
+
const requestBody = body ? Buffer.from(body, bodyEncoding) : undefined;
|
|
170
|
+
// Normalize headers to lowercase keys for case-insensitive lookup
|
|
171
|
+
const normalizedHeaders = {};
|
|
172
|
+
if (headers) {
|
|
173
|
+
for (const [key, value] of Object.entries(headers)) {
|
|
174
|
+
const normalizedKey = key.toLowerCase();
|
|
175
|
+
// If value is an array, take the first one; otherwise use the value
|
|
176
|
+
if (value === undefined)
|
|
177
|
+
continue;
|
|
178
|
+
normalizedHeaders[normalizedKey] = value;
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
for (const multiValueHeaderKey of Object.keys(multiValueHeaders || {})) {
|
|
182
|
+
const value = multiValueHeaders[multiValueHeaderKey];
|
|
183
|
+
if (!value || value.length <= 1)
|
|
184
|
+
continue;
|
|
185
|
+
normalizedHeaders[multiValueHeaderKey] = value.join(',');
|
|
186
|
+
}
|
|
187
|
+
const request = new ServerlessRequest({
|
|
188
|
+
method: httpMethod,
|
|
189
|
+
url: getPathFromEvent(event),
|
|
190
|
+
headers: normalizedHeaders,
|
|
191
|
+
body: requestBody,
|
|
192
|
+
remoteAddress,
|
|
193
|
+
});
|
|
194
|
+
// Add Express-specific properties that aren't part of IncomingMessage
|
|
195
|
+
// IncomingMessage doesn't have query, params, etc. - these are added by Express
|
|
196
|
+
const req = request;
|
|
197
|
+
// Express-like methods
|
|
198
|
+
Object.defineProperty(req, 'get', {
|
|
199
|
+
value(headerName) {
|
|
200
|
+
return this.headers[headerName.toLowerCase()];
|
|
201
|
+
},
|
|
202
|
+
writable: true,
|
|
203
|
+
enumerable: true,
|
|
204
|
+
configurable: true,
|
|
205
|
+
});
|
|
206
|
+
Object.defineProperty(req, 'header', {
|
|
207
|
+
value(headerName) {
|
|
208
|
+
return this.get(headerName);
|
|
209
|
+
},
|
|
210
|
+
writable: true,
|
|
211
|
+
enumerable: true,
|
|
212
|
+
configurable: true,
|
|
213
|
+
});
|
|
214
|
+
return req;
|
|
215
|
+
}
|
|
216
|
+
/**
|
|
217
|
+
* Checks if a content type is compressible using the compressible package
|
|
218
|
+
*
|
|
219
|
+
* @param contentType - The content type to check (e.g., 'text/html', 'application/json')
|
|
220
|
+
* @returns true if the content type is compressible, false otherwise
|
|
221
|
+
*/
|
|
222
|
+
function isCompressible(contentType) {
|
|
223
|
+
if (!contentType) {
|
|
224
|
+
return false;
|
|
225
|
+
}
|
|
226
|
+
return !!compressible(contentType);
|
|
227
|
+
}
|
|
228
|
+
const isNullOrUndefined = (value) => value == null;
|
|
229
|
+
/**
|
|
230
|
+
* Determines the best encoding based on Accept-Encoding header using the negotiator package
|
|
231
|
+
* Prefers encodings in order: br (brotli), zstd (if available), gzip, deflate
|
|
232
|
+
*
|
|
233
|
+
* @param acceptEncoding - The Accept-Encoding header value from the request
|
|
234
|
+
* @param compressionConfig - Optional compression configuration
|
|
235
|
+
* @returns The best available encoding or null if none are supported
|
|
236
|
+
*/
|
|
237
|
+
function getBestEncoding(acceptEncoding, compressionConfig) {
|
|
238
|
+
// If compression is explicitly disabled, return null
|
|
239
|
+
if (compressionConfig?.enabled === false) {
|
|
240
|
+
return null;
|
|
241
|
+
}
|
|
242
|
+
// If override encoding is provided, use it regardless of Accept-Encoding header
|
|
243
|
+
if (compressionConfig?.encoding) {
|
|
244
|
+
return compressionConfig.encoding;
|
|
245
|
+
}
|
|
246
|
+
if (!acceptEncoding) {
|
|
247
|
+
return null;
|
|
248
|
+
}
|
|
249
|
+
const negotiator = new Negotiator({ headers: { 'accept-encoding': acceptEncoding } });
|
|
250
|
+
// Build available encodings list based on what's supported
|
|
251
|
+
// Order of preference: br (brotli), zstd (if available), gzip, deflate
|
|
252
|
+
const availableEncodings = ['br', 'gzip', 'deflate'];
|
|
253
|
+
if (createZstdCompress) {
|
|
254
|
+
availableEncodings.push('zstd');
|
|
255
|
+
}
|
|
256
|
+
const bestEncoding = negotiator.encoding(availableEncodings);
|
|
257
|
+
return bestEncoding || null;
|
|
258
|
+
}
|
|
259
|
+
/**
|
|
260
|
+
* Creates a compression stream based on the encoding type
|
|
261
|
+
*
|
|
262
|
+
* @param encoding - The encoding type ('br', 'zstd', 'gzip', or 'deflate')
|
|
263
|
+
* @param compressionConfig - The compression configuration options
|
|
264
|
+
* @returns A compression stream (BrotliCompress, ZstdCompress, Gzip, or Deflate)
|
|
265
|
+
* @throws Error if the encoding is not supported
|
|
266
|
+
*/
|
|
267
|
+
function createCompressionStream(encoding, compressionConfig) {
|
|
268
|
+
const options = compressionConfig?.options || undefined;
|
|
269
|
+
switch (encoding) {
|
|
270
|
+
case 'br':
|
|
271
|
+
return zlib.createBrotliCompress(options);
|
|
272
|
+
case 'zstd':
|
|
273
|
+
if (!createZstdCompress) {
|
|
274
|
+
throw new Error('zstd compression is not available in this Node.js version (requires v22.15.0+)');
|
|
275
|
+
}
|
|
276
|
+
return createZstdCompress(options);
|
|
277
|
+
case 'gzip':
|
|
278
|
+
return zlib.createGzip(options);
|
|
279
|
+
case 'deflate':
|
|
280
|
+
return zlib.createDeflate(options);
|
|
281
|
+
default:
|
|
282
|
+
throw new Error(`Unsupported encoding: ${encoding}`);
|
|
283
|
+
}
|
|
284
|
+
}
|
|
285
|
+
/**
|
|
286
|
+
* Creates Express-compatible response object that properly extends ServerResponse
|
|
287
|
+
*
|
|
288
|
+
* This function creates a response object that:
|
|
289
|
+
* - Supports AWS Lambda response streaming via HttpResponseStream
|
|
290
|
+
* - Automatically compresses responses based on Accept-Encoding header
|
|
291
|
+
* - Uses negotiator to select the best available encoding (br, zstd if available, gzip, deflate)
|
|
292
|
+
* - Uses compressible package to determine if content should be compressed
|
|
293
|
+
*
|
|
294
|
+
* Compression flow:
|
|
295
|
+
* 1. Check Accept-Encoding header and select best encoding
|
|
296
|
+
* 2. Create HttpResponseStream with metadata
|
|
297
|
+
* 3. If compression is applicable, create compression stream and pipe to httpResponseStream
|
|
298
|
+
* 4. Write data to compression stream (or httpResponseStream if no compression)
|
|
299
|
+
* 5. End compression stream, which automatically ends httpResponseStream
|
|
300
|
+
*
|
|
301
|
+
* @param responseStream - The AWS Lambda response stream
|
|
302
|
+
* @param method - The HTTP method (GET, POST, etc.)
|
|
303
|
+
* @param request - Optional Express request object (used to check Accept-Encoding header)
|
|
304
|
+
* @returns Express-compatible response object
|
|
305
|
+
*/
|
|
306
|
+
export function createExpressResponse(responseStream, event, context, request, compressionConfig) {
|
|
307
|
+
const method = event.httpMethod;
|
|
308
|
+
let statusCode = 200;
|
|
309
|
+
let statusMessage = undefined;
|
|
310
|
+
const headers = {};
|
|
311
|
+
let responseStarted = false;
|
|
312
|
+
let httpResponseStream = null;
|
|
313
|
+
// Determine if compression should be used based on Accept-Encoding header
|
|
314
|
+
const acceptEncoding = request?.get('accept-encoding') || 'identity';
|
|
315
|
+
const selectedEncoding = getBestEncoding(acceptEncoding, compressionConfig) || 'identity';
|
|
316
|
+
let compressionStream = null;
|
|
317
|
+
let shouldCompress = false;
|
|
318
|
+
let compressionInitialized = false;
|
|
319
|
+
// Helper function to check if stream is still writable
|
|
320
|
+
const isStreamOpen = () => {
|
|
321
|
+
const streamToCheck = compressionStream || httpResponseStream || responseStream;
|
|
322
|
+
return streamToCheck && streamToCheck.writable && !streamToCheck.destroyed && !streamToCheck.writableEnded;
|
|
323
|
+
};
|
|
324
|
+
/**
|
|
325
|
+
* Initializes compression stream and pipes it to httpResponseStream
|
|
326
|
+
* This must be called after httpResponseStream is created
|
|
327
|
+
*
|
|
328
|
+
* @param httpResponseStream - The HttpResponseStream to pipe compressed data to
|
|
329
|
+
* @param selectedEncoding - The encoding to use (br, gzip, or deflate)
|
|
330
|
+
*/
|
|
331
|
+
const initializeCompression = () => {
|
|
332
|
+
if (!httpResponseStream || compressionInitialized || !selectedEncoding) {
|
|
333
|
+
return;
|
|
334
|
+
}
|
|
335
|
+
try {
|
|
336
|
+
// Create compression stream based on selected encoding
|
|
337
|
+
compressionStream = createCompressionStream(selectedEncoding, compressionConfig);
|
|
338
|
+
// Set up error handling for compression stream
|
|
339
|
+
compressionStream.on('error', (error) => {
|
|
340
|
+
console.error('Compression stream error:', error);
|
|
341
|
+
shouldCompress = false;
|
|
342
|
+
});
|
|
343
|
+
// Pipe compression stream to httpResponseStream
|
|
344
|
+
// The { end: true } option ensures httpResponseStream is ended when compressionStream ends
|
|
345
|
+
compressionStream.pipe(httpResponseStream, { end: true });
|
|
346
|
+
shouldCompress = true;
|
|
347
|
+
compressionInitialized = true;
|
|
348
|
+
}
|
|
349
|
+
catch (error) {
|
|
350
|
+
console.error('Error setting up compression:', error);
|
|
351
|
+
shouldCompress = false;
|
|
352
|
+
compressionStream = null;
|
|
353
|
+
}
|
|
354
|
+
};
|
|
355
|
+
/**
|
|
356
|
+
* Writes a chunk to the appropriate stream (compression stream if enabled, otherwise httpResponseStream)
|
|
357
|
+
*
|
|
358
|
+
* @param chunk - The data chunk to write
|
|
359
|
+
* @returns true if the chunk was written successfully, false otherwise
|
|
360
|
+
*/
|
|
361
|
+
const writeChunk = (chunk) => {
|
|
362
|
+
// Don't write null, undefined
|
|
363
|
+
if (isNullOrUndefined(chunk) || !isStreamOpen()) {
|
|
364
|
+
return false;
|
|
365
|
+
}
|
|
366
|
+
try {
|
|
367
|
+
if (shouldCompress && compressionStream && compressionStream.writable) {
|
|
368
|
+
// Write to compression stream, which will compress and pipe to httpResponseStream
|
|
369
|
+
return compressionStream.write(chunk);
|
|
370
|
+
}
|
|
371
|
+
else if (httpResponseStream && httpResponseStream.writable) {
|
|
372
|
+
// No compression, write directly to httpResponseStream
|
|
373
|
+
return httpResponseStream.write(chunk);
|
|
374
|
+
}
|
|
375
|
+
return false;
|
|
376
|
+
}
|
|
377
|
+
catch (error) {
|
|
378
|
+
console.error('Error writing chunk:', error);
|
|
379
|
+
return false;
|
|
380
|
+
}
|
|
381
|
+
};
|
|
382
|
+
const isCompressionEnabled = (contentTypeStr) => {
|
|
383
|
+
const enabled = compressionConfig?.enabled ?? true;
|
|
384
|
+
return !!(selectedEncoding && selectedEncoding !== 'identity' && isCompressible(contentTypeStr) && enabled);
|
|
385
|
+
};
|
|
386
|
+
const getContentType = (response) => {
|
|
387
|
+
const contentType = response.getHeader('content-type');
|
|
388
|
+
if (Array.isArray(contentType)) {
|
|
389
|
+
return contentType.join(',');
|
|
390
|
+
}
|
|
391
|
+
else if (typeof contentType === 'number') {
|
|
392
|
+
return String(contentType);
|
|
393
|
+
}
|
|
394
|
+
return contentType;
|
|
395
|
+
};
|
|
396
|
+
/**
|
|
397
|
+
* Initializes the response by:
|
|
398
|
+
* 1. Collecting headers
|
|
399
|
+
* 2. Determining if compression should be used
|
|
400
|
+
* 3. Creating HttpResponseStream with metadata
|
|
401
|
+
* 4. Setting up compression stream if needed
|
|
402
|
+
*
|
|
403
|
+
* This must be called before any data is written to the response.
|
|
404
|
+
*
|
|
405
|
+
* @param response - The Express response object
|
|
406
|
+
*/
|
|
407
|
+
const initializeResponse = (response) => {
|
|
408
|
+
if (responseStarted) {
|
|
409
|
+
return;
|
|
410
|
+
}
|
|
411
|
+
if (!isStreamOpen()) {
|
|
412
|
+
console.error('Cannot initialize response - stream is closed');
|
|
413
|
+
return;
|
|
414
|
+
}
|
|
415
|
+
// Collect all current headers from the response
|
|
416
|
+
const currentHeaders = response.getHeaders();
|
|
417
|
+
Object.assign(headers, currentHeaders);
|
|
418
|
+
for (const header of REQUEST_HEADERS_TO_COPY) {
|
|
419
|
+
const value = request?.get(header);
|
|
420
|
+
if (value) {
|
|
421
|
+
headers[header] = value;
|
|
422
|
+
}
|
|
423
|
+
}
|
|
424
|
+
const contentType = getContentType(response);
|
|
425
|
+
if (isCompressionEnabled(contentType)) {
|
|
426
|
+
headers['content-encoding'] = selectedEncoding;
|
|
427
|
+
response.setHeader('Content-Encoding', selectedEncoding);
|
|
428
|
+
}
|
|
429
|
+
// Remove Content-Length header when compression is enabled since the length will change
|
|
430
|
+
delete headers['content-length'];
|
|
431
|
+
response.removeHeader('content-length');
|
|
432
|
+
// Create HttpResponseStream with metadata
|
|
433
|
+
// This writes the HTTP status and headers to the stream
|
|
434
|
+
const metadata = {
|
|
435
|
+
statusCode,
|
|
436
|
+
headers,
|
|
437
|
+
};
|
|
438
|
+
const cookies = metadata.headers['set-cookie'];
|
|
439
|
+
if (cookies) {
|
|
440
|
+
metadata.cookies = Array.isArray(cookies) ? cookies : [cookies];
|
|
441
|
+
delete metadata.headers['set-cookie'];
|
|
442
|
+
}
|
|
443
|
+
metadata.headers = convertHeaders(metadata.headers);
|
|
444
|
+
httpResponseStream = awslambda.HttpResponseStream.from(responseStream, metadata);
|
|
445
|
+
// Set up compression stream if compression is enabled
|
|
446
|
+
// The compression stream pipes to httpResponseStream, which pipes to responseStream
|
|
447
|
+
// 'identity' means no encoding, so we should not initialize compression for it
|
|
448
|
+
if (isCompressionEnabled(contentType)) {
|
|
449
|
+
initializeCompression();
|
|
450
|
+
}
|
|
451
|
+
responseStarted = true;
|
|
452
|
+
};
|
|
453
|
+
// Helper function to convert headers to the expected format
|
|
454
|
+
const convertHeaders = (headersToConvert) => {
|
|
455
|
+
const converted = {};
|
|
456
|
+
for (const [key, value] of Object.entries(headersToConvert)) {
|
|
457
|
+
if (value !== undefined) {
|
|
458
|
+
if (Array.isArray(value)) {
|
|
459
|
+
converted[key] = value.join(',');
|
|
460
|
+
}
|
|
461
|
+
else if (typeof value === 'number') {
|
|
462
|
+
converted[key] = String(value);
|
|
463
|
+
}
|
|
464
|
+
else {
|
|
465
|
+
converted[key] = value;
|
|
466
|
+
}
|
|
467
|
+
}
|
|
468
|
+
}
|
|
469
|
+
return converted;
|
|
470
|
+
};
|
|
471
|
+
/**
|
|
472
|
+
* Pipes data from the compression stream (if enabled) or httpResponseStream to a destination
|
|
473
|
+
* Note: This is a simplified implementation for API compatibility
|
|
474
|
+
*
|
|
475
|
+
* @param destination - The destination stream to pipe to
|
|
476
|
+
* @returns true if the pipe operation was successful, false otherwise
|
|
477
|
+
*/
|
|
478
|
+
const pipeToDestination = async (destination) => {
|
|
479
|
+
if (!isStreamOpen()) {
|
|
480
|
+
console.error('[pipeToDestination] Cannot pipe - stream is closed');
|
|
481
|
+
return false;
|
|
482
|
+
}
|
|
483
|
+
// Note: pipeToDestination is called from res.pipe() which already calls initializeResponse
|
|
484
|
+
// So compression should already be initialized if needed
|
|
485
|
+
try {
|
|
486
|
+
// Pipe from compression stream if available, otherwise from httpResponseStream
|
|
487
|
+
const sourceStream = compressionStream || httpResponseStream;
|
|
488
|
+
if (!sourceStream) {
|
|
489
|
+
console.error('[pipeToDestination] No source stream available');
|
|
490
|
+
return false;
|
|
491
|
+
}
|
|
492
|
+
// @ts-expect-error - Pipeline expects Readable, but compression streams are Transform streams
|
|
493
|
+
await pipeline(sourceStream, destination);
|
|
494
|
+
return true;
|
|
495
|
+
}
|
|
496
|
+
catch (error) {
|
|
497
|
+
console.error('[pipeToDestination] Pipeline error:', error);
|
|
498
|
+
return false;
|
|
499
|
+
}
|
|
500
|
+
};
|
|
501
|
+
// @ts-expect-error - ServerResponse constructor expects IncomingMessage, but we're creating a minimal mock
|
|
502
|
+
const res = new ServerResponse({
|
|
503
|
+
method,
|
|
504
|
+
});
|
|
505
|
+
// Override statusMessage property to track custom status messages
|
|
506
|
+
Object.defineProperty(res, 'statusMessage', {
|
|
507
|
+
get() {
|
|
508
|
+
return statusMessage;
|
|
509
|
+
},
|
|
510
|
+
set(value) {
|
|
511
|
+
statusMessage = value;
|
|
512
|
+
},
|
|
513
|
+
enumerable: true,
|
|
514
|
+
configurable: true,
|
|
515
|
+
});
|
|
516
|
+
// Override headersSent property to track when headers are sent
|
|
517
|
+
// This is readonly in the parent class, so we override it to be writable
|
|
518
|
+
Object.defineProperty(res, 'headersSent', {
|
|
519
|
+
get() {
|
|
520
|
+
return responseStarted;
|
|
521
|
+
},
|
|
522
|
+
enumerable: true,
|
|
523
|
+
configurable: true,
|
|
524
|
+
});
|
|
525
|
+
// Override the core streaming methods to work with AWS Lambda Response Streaming
|
|
526
|
+
// @ts-expect-error - Type signature doesn't match ServerResponse.writeHead exactly, but our implementation is compatible
|
|
527
|
+
res.writeHead = function (code, reasonPhrase, headerObj) {
|
|
528
|
+
if (typeof reasonPhrase === 'object') {
|
|
529
|
+
headerObj = reasonPhrase;
|
|
530
|
+
reasonPhrase = undefined;
|
|
531
|
+
}
|
|
532
|
+
statusCode = code || statusCode;
|
|
533
|
+
this.statusCode = statusCode;
|
|
534
|
+
// Set statusMessage if provided
|
|
535
|
+
if (reasonPhrase) {
|
|
536
|
+
statusMessage = reasonPhrase;
|
|
537
|
+
}
|
|
538
|
+
if (headerObj) {
|
|
539
|
+
Object.assign(headers, headerObj);
|
|
540
|
+
for (const [key, value] of Object.entries(headerObj)) {
|
|
541
|
+
if (value !== undefined) {
|
|
542
|
+
this.setHeader(key, value);
|
|
543
|
+
}
|
|
544
|
+
}
|
|
545
|
+
}
|
|
546
|
+
// Collect all current headers
|
|
547
|
+
const currentHeaders = this.getHeaders();
|
|
548
|
+
Object.assign(headers, currentHeaders);
|
|
549
|
+
initializeResponse(this);
|
|
550
|
+
return this;
|
|
551
|
+
};
|
|
552
|
+
res.write = function (chunk) {
|
|
553
|
+
if (!isStreamOpen()) {
|
|
554
|
+
console.error(`Cannot write - stream is closed`);
|
|
555
|
+
return false;
|
|
556
|
+
}
|
|
557
|
+
initializeResponse(this);
|
|
558
|
+
if (!isNullOrUndefined(chunk)) {
|
|
559
|
+
return writeChunk(chunk);
|
|
560
|
+
}
|
|
561
|
+
return true; // ServerResponse.write returns boolean
|
|
562
|
+
};
|
|
563
|
+
const _flush = () => {
|
|
564
|
+
if (shouldCompress &&
|
|
565
|
+
compressionStream &&
|
|
566
|
+
compressionStream.writable &&
|
|
567
|
+
typeof compressionStream.flush === 'function') {
|
|
568
|
+
compressionStream.flush();
|
|
569
|
+
}
|
|
570
|
+
else if (httpResponseStream &&
|
|
571
|
+
httpResponseStream.writable &&
|
|
572
|
+
// @ts-expect-error - flush doesn't exist on Writable, but we're adding it
|
|
573
|
+
typeof httpResponseStream.flush === 'function') {
|
|
574
|
+
// @ts-expect-error - flush doesn't exist on Writable, but we're adding it
|
|
575
|
+
httpResponseStream.flush();
|
|
576
|
+
}
|
|
577
|
+
};
|
|
578
|
+
/**
|
|
579
|
+
* Ends the appropriate stream(s) and emits the finish event
|
|
580
|
+
* If compression is enabled, ends the compression stream which will automatically
|
|
581
|
+
* end httpResponseStream due to the pipe with { end: true }
|
|
582
|
+
*
|
|
583
|
+
* @param response - The Express response object to emit finish event on
|
|
584
|
+
*/
|
|
585
|
+
const endStream = (response) => {
|
|
586
|
+
if (shouldCompress && compressionStream) {
|
|
587
|
+
try {
|
|
588
|
+
// Flush compression stream to ensure all buffered data is written
|
|
589
|
+
_flush();
|
|
590
|
+
// End compression stream - this will automatically end httpResponseStream
|
|
591
|
+
// due to the pipe with { end: true } option
|
|
592
|
+
compressionStream.end(() => {
|
|
593
|
+
response.finished = true;
|
|
594
|
+
response.emit('finish');
|
|
595
|
+
});
|
|
596
|
+
}
|
|
597
|
+
catch (error) {
|
|
598
|
+
console.error(`Error ending compression stream:`, error);
|
|
599
|
+
// Still emit finish even if there was an error
|
|
600
|
+
response.finished = true;
|
|
601
|
+
response.emit('finish');
|
|
602
|
+
}
|
|
603
|
+
}
|
|
604
|
+
else if (httpResponseStream && httpResponseStream.writable) {
|
|
605
|
+
// No compression, end httpResponseStream directly
|
|
606
|
+
try {
|
|
607
|
+
_flush();
|
|
608
|
+
httpResponseStream.end(() => {
|
|
609
|
+
response.finished = true;
|
|
610
|
+
response.emit('finish');
|
|
611
|
+
});
|
|
612
|
+
}
|
|
613
|
+
catch (error) {
|
|
614
|
+
console.error(`Error ending httpResponseStream:`, error);
|
|
615
|
+
response.finished = true;
|
|
616
|
+
response.emit('finish');
|
|
617
|
+
}
|
|
618
|
+
}
|
|
619
|
+
else {
|
|
620
|
+
console.error(`Cannot call end() - stream is closed`);
|
|
621
|
+
// Still emit finish to prevent hanging
|
|
622
|
+
response.finished = true;
|
|
623
|
+
response.emit('finish');
|
|
624
|
+
}
|
|
625
|
+
};
|
|
626
|
+
// @ts-expect-error - Type signature doesn't match ServerResponse.end exactly, but our implementation is compatible
|
|
627
|
+
res.end = function (chunk) {
|
|
628
|
+
if (!isStreamOpen()) {
|
|
629
|
+
console.error(`Cannot end - stream is already closed`);
|
|
630
|
+
return this;
|
|
631
|
+
}
|
|
632
|
+
initializeResponse(this);
|
|
633
|
+
// Chunks can be falsy ('', 0, etc.) but not null or undefined
|
|
634
|
+
if (!isNullOrUndefined(chunk)) {
|
|
635
|
+
const result = writeChunk(chunk);
|
|
636
|
+
if (!result) {
|
|
637
|
+
// Backpressure - wait for drain event before ending
|
|
638
|
+
const streamToWait = compressionStream || httpResponseStream;
|
|
639
|
+
if (streamToWait) {
|
|
640
|
+
streamToWait.once('drain', () => {
|
|
641
|
+
endStream(this);
|
|
642
|
+
});
|
|
643
|
+
}
|
|
644
|
+
else {
|
|
645
|
+
endStream(this);
|
|
646
|
+
}
|
|
647
|
+
return this;
|
|
648
|
+
}
|
|
649
|
+
}
|
|
650
|
+
// End the stream(s) and emit finish event
|
|
651
|
+
endStream(this);
|
|
652
|
+
return this;
|
|
653
|
+
};
|
|
654
|
+
// Add Express-specific methods that aren't in ServerResponse
|
|
655
|
+
res.status = function (code, message) {
|
|
656
|
+
this.statusCode = code;
|
|
657
|
+
statusCode = code;
|
|
658
|
+
if (message !== undefined) {
|
|
659
|
+
statusMessage = message;
|
|
660
|
+
}
|
|
661
|
+
return this;
|
|
662
|
+
};
|
|
663
|
+
res.set = function (field, value) {
|
|
664
|
+
if (typeof field === 'object') {
|
|
665
|
+
for (const [key, val] of Object.entries(field)) {
|
|
666
|
+
if (val !== undefined) {
|
|
667
|
+
this.setHeader(key, val);
|
|
668
|
+
}
|
|
669
|
+
}
|
|
670
|
+
}
|
|
671
|
+
else {
|
|
672
|
+
if (value !== undefined) {
|
|
673
|
+
this.setHeader(field, value);
|
|
674
|
+
}
|
|
675
|
+
}
|
|
676
|
+
return this;
|
|
677
|
+
};
|
|
678
|
+
// @ts-expect-error - Type signature doesn't match ExpressResponse.append exactly, but our implementation is compatible
|
|
679
|
+
res.append = function (field, value) {
|
|
680
|
+
const prevValue = this.getHeader(field);
|
|
681
|
+
if (prevValue) {
|
|
682
|
+
// If header already exists, append the value
|
|
683
|
+
if (Array.isArray(prevValue)) {
|
|
684
|
+
this.setHeader(field, prevValue.concat(value));
|
|
685
|
+
}
|
|
686
|
+
else if (Array.isArray(value)) {
|
|
687
|
+
this.setHeader(field, [prevValue].concat(value));
|
|
688
|
+
}
|
|
689
|
+
else {
|
|
690
|
+
this.setHeader(field, [prevValue, value]);
|
|
691
|
+
}
|
|
692
|
+
}
|
|
693
|
+
else {
|
|
694
|
+
// If header doesn't exist, just set it
|
|
695
|
+
this.setHeader(field, value);
|
|
696
|
+
}
|
|
697
|
+
return this;
|
|
698
|
+
};
|
|
699
|
+
res.flushHeaders = function () {
|
|
700
|
+
if (!responseStarted) {
|
|
701
|
+
if (!isStreamOpen()) {
|
|
702
|
+
console.error('[res.flushHeaders] Cannot flush headers - stream is closed');
|
|
703
|
+
return this;
|
|
704
|
+
}
|
|
705
|
+
// Collect all current headers and send them
|
|
706
|
+
// getHeaders() returns all headers from ServerResponse, including those set via set()/setHeader()
|
|
707
|
+
// This is the source of truth for all headers
|
|
708
|
+
const currentHeaders = this.getHeaders();
|
|
709
|
+
// Merge with local headers variable to include any headers set via writeHead
|
|
710
|
+
// currentHeaders takes precedence as it's the authoritative source from ServerResponse
|
|
711
|
+
Object.assign(headers, currentHeaders);
|
|
712
|
+
}
|
|
713
|
+
initializeResponse(this);
|
|
714
|
+
return this;
|
|
715
|
+
};
|
|
716
|
+
res.json = function (obj) {
|
|
717
|
+
res.setHeader('Content-Type', 'application/json');
|
|
718
|
+
this.end(JSON.stringify(obj));
|
|
719
|
+
return this;
|
|
720
|
+
};
|
|
721
|
+
res.send = function (body) {
|
|
722
|
+
if (typeof body === 'object' && body !== null) {
|
|
723
|
+
return this.json(body);
|
|
724
|
+
}
|
|
725
|
+
// Convert non-string values to string
|
|
726
|
+
const bodyString = typeof body === 'string' ? body : String(body);
|
|
727
|
+
this.end(bodyString);
|
|
728
|
+
return this;
|
|
729
|
+
};
|
|
730
|
+
// @ts-expect-error - Type signature doesn't match ExpressResponse.redirect exactly, but our implementation is compatible
|
|
731
|
+
res.redirect = function (url) {
|
|
732
|
+
this.status(302);
|
|
733
|
+
this.setHeader('Location', url);
|
|
734
|
+
this.end();
|
|
735
|
+
return this;
|
|
736
|
+
};
|
|
737
|
+
// Add flush method for streaming responses (important for RSC)
|
|
738
|
+
// @ts-expect-error - flush doesn't exist on ExpressResponse type, but we're adding it
|
|
739
|
+
res.flush = function () {
|
|
740
|
+
if (!isStreamOpen()) {
|
|
741
|
+
console.error(`Cannot flush - stream is closed`);
|
|
742
|
+
return this;
|
|
743
|
+
}
|
|
744
|
+
initializeResponse(this);
|
|
745
|
+
// Flush the compression stream if it exists and supports it
|
|
746
|
+
// This ensures any buffered compressed data is written immediately
|
|
747
|
+
try {
|
|
748
|
+
_flush();
|
|
749
|
+
}
|
|
750
|
+
catch (error) {
|
|
751
|
+
console.error(`Error flushing:`, error);
|
|
752
|
+
}
|
|
753
|
+
return this;
|
|
754
|
+
};
|
|
755
|
+
// Track piped destinations for unpipe support
|
|
756
|
+
const pipedDestinations = new Set();
|
|
757
|
+
// Add pipe method for streaming responses (commonly used in Express)
|
|
758
|
+
// @ts-expect-error - Type signature doesn't match ExpressResponse.pipe exactly, but our implementation is compatible
|
|
759
|
+
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
|
760
|
+
res.pipe = function (destination, options) {
|
|
761
|
+
if (!isStreamOpen()) {
|
|
762
|
+
console.error('[res.pipe] Cannot pipe - stream is closed');
|
|
763
|
+
return destination;
|
|
764
|
+
}
|
|
765
|
+
initializeResponse(this);
|
|
766
|
+
// Track the destination for unpipe support
|
|
767
|
+
pipedDestinations.add(destination);
|
|
768
|
+
// Use actual Node.js pipeline for pipe operations
|
|
769
|
+
pipeToDestination(destination)
|
|
770
|
+
.then(() => {
|
|
771
|
+
pipedDestinations.delete(destination);
|
|
772
|
+
})
|
|
773
|
+
.catch((error) => {
|
|
774
|
+
console.error('[res.pipe] Pipeline error:', error);
|
|
775
|
+
pipedDestinations.delete(destination);
|
|
776
|
+
});
|
|
777
|
+
return destination;
|
|
778
|
+
};
|
|
779
|
+
// Add unpipe method to remove pipe destinations
|
|
780
|
+
// @ts-expect-error - unpipe doesn't exist on ExpressResponse type, but we're adding it
|
|
781
|
+
res.unpipe = function (destination) {
|
|
782
|
+
if (destination) {
|
|
783
|
+
pipedDestinations.delete(destination);
|
|
784
|
+
// In a real implementation, you'd need to handle unpipe more carefully
|
|
785
|
+
// For now, we just track it
|
|
786
|
+
}
|
|
787
|
+
else {
|
|
788
|
+
// Unpipe all destinations
|
|
789
|
+
pipedDestinations.clear();
|
|
790
|
+
}
|
|
791
|
+
return this;
|
|
792
|
+
};
|
|
793
|
+
// Make response flushable (flag used by some frameworks)
|
|
794
|
+
res.flushable = true;
|
|
795
|
+
return res;
|
|
796
|
+
}
|
|
797
|
+
//# sourceMappingURL=create-lambda-adapter.js.map
|