unnbound-events 1.0.15 → 1.0.16
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 +89 -0
- package/dist/lib/client.js +161 -2
- package/dist/lib/types.d.ts +7 -0
- package/package.json +6 -2
package/README.md
CHANGED
|
@@ -156,6 +156,75 @@ interface QueueEnvelope {
|
|
|
156
156
|
}
|
|
157
157
|
```
|
|
158
158
|
|
|
159
|
+
## S3 Payload Offloading for Large Messages
|
|
160
|
+
|
|
161
|
+
The SDK supports automatic handling of large payloads (>256KB) stored in S3, which is necessary since SQS has a 256KB message size limit.
|
|
162
|
+
|
|
163
|
+
### Configuration
|
|
164
|
+
|
|
165
|
+
Configure S3 support when starting the client:
|
|
166
|
+
|
|
167
|
+
```ts
|
|
168
|
+
await client.start({
|
|
169
|
+
sqs: { queueUrl: 'https://sqs.us-west-2.amazonaws.com/123456789012/my-queue' },
|
|
170
|
+
s3: {
|
|
171
|
+
bucketName: 'my-payload-bucket',
|
|
172
|
+
region: 'us-west-2',
|
|
173
|
+
},
|
|
174
|
+
});
|
|
175
|
+
```
|
|
176
|
+
|
|
177
|
+
Or with `sqsListen()`:
|
|
178
|
+
|
|
179
|
+
```ts
|
|
180
|
+
await client.sqsListen({
|
|
181
|
+
queueUrl: 'https://sqs.us-west-2.amazonaws.com/123456789012/my-queue',
|
|
182
|
+
s3: {
|
|
183
|
+
bucketName: 'my-payload-bucket',
|
|
184
|
+
region: 'us-west-2',
|
|
185
|
+
},
|
|
186
|
+
});
|
|
187
|
+
```
|
|
188
|
+
|
|
189
|
+
### Supported Formats
|
|
190
|
+
|
|
191
|
+
The SDK supports two S3 payload formats:
|
|
192
|
+
|
|
193
|
+
1. **ExtendedSQSClient Pointer Format** (AWS Extended Client Library compatible):
|
|
194
|
+
```json
|
|
195
|
+
["software.amazon.payloadoffloading.PayloadS3Pointer", {
|
|
196
|
+
"s3BucketName": "bucket-name",
|
|
197
|
+
"s3Key": "key-path"
|
|
198
|
+
}]
|
|
199
|
+
```
|
|
200
|
+
|
|
201
|
+
2. **Envelope-based S3 Payloads** (Unnbound format):
|
|
202
|
+
```json
|
|
203
|
+
{
|
|
204
|
+
"payload": {
|
|
205
|
+
"type": "s3-single",
|
|
206
|
+
"size": 512000,
|
|
207
|
+
"compressed": true,
|
|
208
|
+
"compressionType": "gzip",
|
|
209
|
+
"location": {
|
|
210
|
+
"bucket": "bucket-name",
|
|
211
|
+
"key": "key-path",
|
|
212
|
+
"region": "us-west-2",
|
|
213
|
+
"versionId": "optional-version-id"
|
|
214
|
+
}
|
|
215
|
+
},
|
|
216
|
+
"request": { ... }
|
|
217
|
+
}
|
|
218
|
+
```
|
|
219
|
+
|
|
220
|
+
### Compression Support
|
|
221
|
+
|
|
222
|
+
The SDK automatically decompresses payloads with:
|
|
223
|
+
- `brotli` compression
|
|
224
|
+
- `gzip` compression
|
|
225
|
+
|
|
226
|
+
When the `compressed` flag is `true` and `compressionType` is specified, the SDK will automatically decompress the payload after fetching from S3.
|
|
227
|
+
|
|
159
228
|
## Environment Variables
|
|
160
229
|
|
|
161
230
|
### SQS Configuration
|
|
@@ -184,6 +253,26 @@ export UNNBOUND_SQS_QUEUE_URL="https://sqs.us-west-2.amazonaws.com/123456789012/
|
|
|
184
253
|
export AWS_REGION="us-west-2"
|
|
185
254
|
```
|
|
186
255
|
|
|
256
|
+
### S3 Configuration
|
|
257
|
+
|
|
258
|
+
For S3 payload support, configure:
|
|
259
|
+
|
|
260
|
+
**Required:**
|
|
261
|
+
|
|
262
|
+
- `UNNBOUND_S3_BUCKET` or `S3_BUCKET` - S3 bucket name for payload storage
|
|
263
|
+
|
|
264
|
+
**Optional:**
|
|
265
|
+
|
|
266
|
+
- `AWS_REGION` - AWS region (defaults to `us-east-1`)
|
|
267
|
+
- `AWS_S3_ENDPOINT` - Custom S3 endpoint (for LocalStack, etc.)
|
|
268
|
+
|
|
269
|
+
**Example:**
|
|
270
|
+
|
|
271
|
+
```bash
|
|
272
|
+
export UNNBOUND_S3_BUCKET="my-payload-bucket"
|
|
273
|
+
export AWS_REGION="us-west-2"
|
|
274
|
+
```
|
|
275
|
+
|
|
187
276
|
### AWS Credentials
|
|
188
277
|
|
|
189
278
|
When running in ECS with a Task Role, no additional credentials are needed. The AWS SDK will automatically use the ECS Task Role credentials.
|
package/dist/lib/client.js
CHANGED
|
@@ -46,9 +46,91 @@ const shouldIgnorePath = (path, patterns) => {
|
|
|
46
46
|
};
|
|
47
47
|
exports.shouldIgnorePath = shouldIgnorePath;
|
|
48
48
|
exports.defaultIgnoreTraceRoutes = ['/health', '/healthcheck'];
|
|
49
|
+
// Helper function to create S3 client
|
|
50
|
+
function createS3Client(options) {
|
|
51
|
+
const awsS3 = (() => {
|
|
52
|
+
try {
|
|
53
|
+
return require('@aws-sdk/client-s3');
|
|
54
|
+
}
|
|
55
|
+
catch {
|
|
56
|
+
return null;
|
|
57
|
+
}
|
|
58
|
+
})();
|
|
59
|
+
if (!awsS3) {
|
|
60
|
+
throw new Error('@aws-sdk/client-s3 is required for S3 payload support');
|
|
61
|
+
}
|
|
62
|
+
const env = globalThis.process
|
|
63
|
+
?.env ?? {};
|
|
64
|
+
const region = options.region || env.AWS_REGION || 'us-east-1';
|
|
65
|
+
const endpoint = options.endpoint || env.AWS_S3_ENDPOINT || env.AWS_ENDPOINT_URL || undefined;
|
|
66
|
+
return new awsS3.S3Client({ region, endpoint });
|
|
67
|
+
}
|
|
68
|
+
// Helper function to fetch payload from S3
|
|
69
|
+
async function fetchS3Payload(s3Client, bucket, key, versionId) {
|
|
70
|
+
const awsS3 = require('@aws-sdk/client-s3');
|
|
71
|
+
const params = {
|
|
72
|
+
Bucket: bucket,
|
|
73
|
+
Key: key,
|
|
74
|
+
};
|
|
75
|
+
if (versionId) {
|
|
76
|
+
params.VersionId = versionId;
|
|
77
|
+
}
|
|
78
|
+
try {
|
|
79
|
+
const response = await s3Client.send(new awsS3.GetObjectCommand(params));
|
|
80
|
+
const body = response.Body;
|
|
81
|
+
// Convert stream to string
|
|
82
|
+
const chunks = [];
|
|
83
|
+
for await (const chunk of body) {
|
|
84
|
+
chunks.push(chunk);
|
|
85
|
+
}
|
|
86
|
+
const buffer = Buffer.concat(chunks);
|
|
87
|
+
return buffer.toString('utf-8');
|
|
88
|
+
}
|
|
89
|
+
catch (error) {
|
|
90
|
+
const err = error instanceof Error ? error : new Error(String(error));
|
|
91
|
+
unnbound_logger_sdk_1.logger.error({ err, bucket, key }, 'Failed to fetch S3 payload');
|
|
92
|
+
throw new Error(`Failed to fetch S3 payload: ${err.message}`);
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
// Helper function to decompress payload
|
|
96
|
+
async function decompressPayload(compressed, compressionType) {
|
|
97
|
+
const zlib = (() => {
|
|
98
|
+
try {
|
|
99
|
+
return require('zlib');
|
|
100
|
+
}
|
|
101
|
+
catch {
|
|
102
|
+
return null;
|
|
103
|
+
}
|
|
104
|
+
})();
|
|
105
|
+
if (!zlib) {
|
|
106
|
+
throw new Error('zlib is required for decompression');
|
|
107
|
+
}
|
|
108
|
+
return new Promise((resolve, reject) => {
|
|
109
|
+
const buffer = Buffer.from(compressed);
|
|
110
|
+
const callback = (err, result) => {
|
|
111
|
+
if (err) {
|
|
112
|
+
reject(err);
|
|
113
|
+
}
|
|
114
|
+
else {
|
|
115
|
+
resolve(result.toString('utf-8'));
|
|
116
|
+
}
|
|
117
|
+
};
|
|
118
|
+
if (compressionType === 'brotli') {
|
|
119
|
+
zlib.brotliDecompress(buffer, callback);
|
|
120
|
+
}
|
|
121
|
+
else if (compressionType === 'gzip') {
|
|
122
|
+
zlib.gunzip(buffer, callback);
|
|
123
|
+
}
|
|
124
|
+
else {
|
|
125
|
+
reject(new Error(`Unsupported compression type: ${compressionType}`));
|
|
126
|
+
}
|
|
127
|
+
});
|
|
128
|
+
}
|
|
49
129
|
function createEventsClient(ignoreTraceRoutes = exports.defaultIgnoreTraceRoutes) {
|
|
50
130
|
const routes = [];
|
|
51
131
|
const middlewares = [];
|
|
132
|
+
let s3Client = null;
|
|
133
|
+
let s3Options = null;
|
|
52
134
|
routes.push({
|
|
53
135
|
method: 'GET',
|
|
54
136
|
matcher: '/healthcheck',
|
|
@@ -265,7 +347,59 @@ function createEventsClient(ignoreTraceRoutes = exports.defaultIgnoreTraceRoutes
|
|
|
265
347
|
const failures = [];
|
|
266
348
|
await Promise.all(event.Records.map(async (record) => {
|
|
267
349
|
try {
|
|
268
|
-
|
|
350
|
+
// Check if this is an S3 pointer message (ExtendedSQSClient format)
|
|
351
|
+
let envelope;
|
|
352
|
+
let recordBody = record.body;
|
|
353
|
+
try {
|
|
354
|
+
const parsed = JSON.parse(recordBody);
|
|
355
|
+
// Detect S3 pointer format: ["software.amazon.payloadoffloading.PayloadS3Pointer", {...}]
|
|
356
|
+
if (Array.isArray(parsed) && parsed.length === 2 &&
|
|
357
|
+
parsed[0] === 'software.amazon.payloadoffloading.PayloadS3Pointer') {
|
|
358
|
+
const pointer = parsed[1];
|
|
359
|
+
if (!s3Client) {
|
|
360
|
+
throw new Error('S3 client not initialized but S3 pointer message received');
|
|
361
|
+
}
|
|
362
|
+
unnbound_logger_sdk_1.logger.info({ bucket: pointer.s3BucketName, key: pointer.s3Key }, 'Fetching S3 pointer payload');
|
|
363
|
+
recordBody = await fetchS3Payload(s3Client, pointer.s3BucketName, pointer.s3Key);
|
|
364
|
+
envelope = JSON.parse(recordBody);
|
|
365
|
+
}
|
|
366
|
+
else {
|
|
367
|
+
envelope = parsed;
|
|
368
|
+
}
|
|
369
|
+
}
|
|
370
|
+
catch (error) {
|
|
371
|
+
const err = error instanceof Error ? error : new Error(String(error));
|
|
372
|
+
unnbound_logger_sdk_1.logger.error({ err }, 'Failed to parse SQS record body');
|
|
373
|
+
throw error;
|
|
374
|
+
}
|
|
375
|
+
// Check if envelope payload is stored in S3
|
|
376
|
+
if (envelope.payload.type === 's3-single' || envelope.payload.type === 's3-multipart') {
|
|
377
|
+
if (!envelope.payload.location) {
|
|
378
|
+
throw new Error(`Payload type is ${envelope.payload.type} but no location specified`);
|
|
379
|
+
}
|
|
380
|
+
if (!s3Client) {
|
|
381
|
+
throw new Error('S3 client not initialized but S3 payload message received');
|
|
382
|
+
}
|
|
383
|
+
unnbound_logger_sdk_1.logger.info({
|
|
384
|
+
type: envelope.payload.type,
|
|
385
|
+
bucket: envelope.payload.location.bucket,
|
|
386
|
+
key: envelope.payload.location.key
|
|
387
|
+
}, 'Fetching S3 envelope payload');
|
|
388
|
+
let payloadData = await fetchS3Payload(s3Client, envelope.payload.location.bucket, envelope.payload.location.key, envelope.payload.location.versionId);
|
|
389
|
+
// Handle decompression if needed
|
|
390
|
+
if (envelope.payload.compressed && envelope.payload.compressionType) {
|
|
391
|
+
unnbound_logger_sdk_1.logger.info({ compressionType: envelope.payload.compressionType }, 'Decompressing payload');
|
|
392
|
+
payloadData = await decompressPayload(new TextEncoder().encode(payloadData), envelope.payload.compressionType);
|
|
393
|
+
}
|
|
394
|
+
// Parse the payload and set it as the request body
|
|
395
|
+
try {
|
|
396
|
+
envelope.request.body = JSON.parse(payloadData);
|
|
397
|
+
}
|
|
398
|
+
catch {
|
|
399
|
+
envelope.request.body = payloadData;
|
|
400
|
+
}
|
|
401
|
+
}
|
|
402
|
+
// Continue with standard envelope processing
|
|
269
403
|
const headerMessageId = getHeaderValue(envelope.request.headers, MESSAGE_HEADER_KEY);
|
|
270
404
|
const envelopeMessageId = typeof envelope.metadata?.messageId === 'string'
|
|
271
405
|
? envelope.metadata.messageId
|
|
@@ -332,6 +466,27 @@ function createEventsClient(ignoreTraceRoutes = exports.defaultIgnoreTraceRoutes
|
|
|
332
466
|
};
|
|
333
467
|
},
|
|
334
468
|
sqsListen(options) {
|
|
469
|
+
// Initialize S3 client if options provided
|
|
470
|
+
if (options?.s3) {
|
|
471
|
+
s3Options = options.s3;
|
|
472
|
+
const env = globalThis
|
|
473
|
+
.process?.env ?? {};
|
|
474
|
+
const bucketName = options.s3.bucketName || env.UNNBOUND_S3_BUCKET || env.S3_BUCKET;
|
|
475
|
+
if (!bucketName) {
|
|
476
|
+
unnbound_logger_sdk_1.logger.warn('S3 options provided but no bucket name specified. S3 payload support disabled.');
|
|
477
|
+
}
|
|
478
|
+
else {
|
|
479
|
+
try {
|
|
480
|
+
s3Client = createS3Client({ ...options.s3, bucketName });
|
|
481
|
+
unnbound_logger_sdk_1.logger.info({ bucket: bucketName }, 'S3 client initialized for payload offloading');
|
|
482
|
+
}
|
|
483
|
+
catch (error) {
|
|
484
|
+
const err = error instanceof Error ? error : new Error(String(error));
|
|
485
|
+
unnbound_logger_sdk_1.logger.error({ err }, 'Failed to initialize S3 client');
|
|
486
|
+
throw error;
|
|
487
|
+
}
|
|
488
|
+
}
|
|
489
|
+
}
|
|
335
490
|
const awsSqs = (() => {
|
|
336
491
|
try {
|
|
337
492
|
return require('@aws-sdk/client-sqs');
|
|
@@ -425,6 +580,10 @@ function createEventsClient(ignoreTraceRoutes = exports.defaultIgnoreTraceRoutes
|
|
|
425
580
|
// Default to starting both HTTP and SQS if no options provided
|
|
426
581
|
const httpOptions = options?.http ?? { port: 3000 };
|
|
427
582
|
const sqsOptions = options?.sqs ?? {};
|
|
583
|
+
// Pass S3 options through to SQS listener if provided
|
|
584
|
+
const sqsListenOptions = options?.s3
|
|
585
|
+
? { ...sqsOptions, s3: options.s3 }
|
|
586
|
+
: sqsOptions;
|
|
428
587
|
// Start HTTP server
|
|
429
588
|
let httpServer;
|
|
430
589
|
try {
|
|
@@ -436,7 +595,7 @@ function createEventsClient(ignoreTraceRoutes = exports.defaultIgnoreTraceRoutes
|
|
|
436
595
|
// Start SQS listener
|
|
437
596
|
let sqsListener;
|
|
438
597
|
try {
|
|
439
|
-
sqsListener = await this.sqsListen(
|
|
598
|
+
sqsListener = await this.sqsListen(sqsListenOptions);
|
|
440
599
|
}
|
|
441
600
|
catch (error) {
|
|
442
601
|
unnbound_logger_sdk_1.logger.warn({ error }, 'Failed to start SQS listener, continuing without it');
|
package/dist/lib/types.d.ts
CHANGED
|
@@ -32,6 +32,11 @@ export type SqsRecord = {
|
|
|
32
32
|
export interface SqsBatchEvent {
|
|
33
33
|
Records: SqsRecord[];
|
|
34
34
|
}
|
|
35
|
+
export interface S3PayloadOptions {
|
|
36
|
+
bucketName?: string;
|
|
37
|
+
region?: string;
|
|
38
|
+
endpoint?: string;
|
|
39
|
+
}
|
|
35
40
|
export interface StartOptions {
|
|
36
41
|
http?: {
|
|
37
42
|
port?: number;
|
|
@@ -43,6 +48,7 @@ export interface StartOptions {
|
|
|
43
48
|
maxMessages?: number;
|
|
44
49
|
visibilityTimeoutSeconds?: number;
|
|
45
50
|
};
|
|
51
|
+
s3?: S3PayloadOptions;
|
|
46
52
|
}
|
|
47
53
|
export interface EventsClient {
|
|
48
54
|
on: (method: HttpMethod, matcher: RouteMatcher, handler: RouteHandler<any, any>) => void;
|
|
@@ -76,6 +82,7 @@ export interface EventsClient {
|
|
|
76
82
|
waitTimeSeconds?: number;
|
|
77
83
|
maxMessages?: number;
|
|
78
84
|
visibilityTimeoutSeconds?: number;
|
|
85
|
+
s3?: S3PayloadOptions;
|
|
79
86
|
}) => Promise<{
|
|
80
87
|
stop: () => Promise<void>;
|
|
81
88
|
}>;
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "unnbound-events",
|
|
3
3
|
"description": "Unified events SDK to handle HTTP routes and SQS messages with a single routing API.",
|
|
4
|
-
"version": "1.0.
|
|
4
|
+
"version": "1.0.16",
|
|
5
5
|
"main": "dist/index.js",
|
|
6
6
|
"types": "dist/index.d.ts",
|
|
7
7
|
"scripts": {
|
|
@@ -36,11 +36,15 @@
|
|
|
36
36
|
"unnbound-logger-sdk": "^3.0.22"
|
|
37
37
|
},
|
|
38
38
|
"peerDependencies": {
|
|
39
|
-
"express": "^4.0.0 || ^5.0.0"
|
|
39
|
+
"express": "^4.0.0 || ^5.0.0",
|
|
40
|
+
"@aws-sdk/client-s3": "^3.0.0"
|
|
40
41
|
},
|
|
41
42
|
"peerDependenciesMeta": {
|
|
42
43
|
"express": {
|
|
43
44
|
"optional": true
|
|
45
|
+
},
|
|
46
|
+
"@aws-sdk/client-s3": {
|
|
47
|
+
"optional": true
|
|
44
48
|
}
|
|
45
49
|
},
|
|
46
50
|
"devDependencies": {
|