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 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.
@@ -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
- const envelope = JSON.parse(record.body);
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(sqsOptions);
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');
@@ -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.15",
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": {