serverless-event-orchestrator 1.0.1

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.
Files changed (81) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +377 -0
  3. package/dist/dispatcher.d.ts +18 -0
  4. package/dist/dispatcher.d.ts.map +1 -0
  5. package/dist/dispatcher.js +345 -0
  6. package/dist/dispatcher.js.map +1 -0
  7. package/dist/http/body-parser.d.ts +27 -0
  8. package/dist/http/body-parser.d.ts.map +1 -0
  9. package/dist/http/body-parser.js +56 -0
  10. package/dist/http/body-parser.js.map +1 -0
  11. package/dist/http/cors.d.ts +32 -0
  12. package/dist/http/cors.d.ts.map +1 -0
  13. package/dist/http/cors.js +69 -0
  14. package/dist/http/cors.js.map +1 -0
  15. package/dist/http/index.d.ts +4 -0
  16. package/dist/http/index.d.ts.map +1 -0
  17. package/dist/http/index.js +20 -0
  18. package/dist/http/index.js.map +1 -0
  19. package/dist/http/response.d.ts +104 -0
  20. package/dist/http/response.d.ts.map +1 -0
  21. package/dist/http/response.js +164 -0
  22. package/dist/http/response.js.map +1 -0
  23. package/dist/identity/extractor.d.ts +39 -0
  24. package/dist/identity/extractor.d.ts.map +1 -0
  25. package/dist/identity/extractor.js +88 -0
  26. package/dist/identity/extractor.js.map +1 -0
  27. package/dist/identity/index.d.ts +2 -0
  28. package/dist/identity/index.d.ts.map +1 -0
  29. package/dist/identity/index.js +18 -0
  30. package/dist/identity/index.js.map +1 -0
  31. package/dist/index.d.ts +17 -0
  32. package/dist/index.d.ts.map +1 -0
  33. package/dist/index.js +62 -0
  34. package/dist/index.js.map +1 -0
  35. package/dist/types/event-type.enum.d.ts +20 -0
  36. package/dist/types/event-type.enum.d.ts.map +1 -0
  37. package/dist/types/event-type.enum.js +25 -0
  38. package/dist/types/event-type.enum.js.map +1 -0
  39. package/dist/types/index.d.ts +3 -0
  40. package/dist/types/index.d.ts.map +1 -0
  41. package/dist/types/index.js +19 -0
  42. package/dist/types/index.js.map +1 -0
  43. package/dist/types/routes.d.ts +163 -0
  44. package/dist/types/routes.d.ts.map +1 -0
  45. package/dist/types/routes.js +3 -0
  46. package/dist/types/routes.js.map +1 -0
  47. package/dist/utils/headers.d.ts +28 -0
  48. package/dist/utils/headers.d.ts.map +1 -0
  49. package/dist/utils/headers.js +61 -0
  50. package/dist/utils/headers.js.map +1 -0
  51. package/dist/utils/index.d.ts +3 -0
  52. package/dist/utils/index.d.ts.map +1 -0
  53. package/dist/utils/index.js +19 -0
  54. package/dist/utils/index.js.map +1 -0
  55. package/dist/utils/path-matcher.d.ts +33 -0
  56. package/dist/utils/path-matcher.d.ts.map +1 -0
  57. package/dist/utils/path-matcher.js +74 -0
  58. package/dist/utils/path-matcher.js.map +1 -0
  59. package/jest.config.js +32 -0
  60. package/package.json +68 -0
  61. package/src/dispatcher.ts +415 -0
  62. package/src/http/body-parser.ts +60 -0
  63. package/src/http/cors.ts +76 -0
  64. package/src/http/index.ts +3 -0
  65. package/src/http/response.ts +194 -0
  66. package/src/identity/extractor.ts +89 -0
  67. package/src/identity/index.ts +1 -0
  68. package/src/index.ts +92 -0
  69. package/src/types/event-type.enum.ts +20 -0
  70. package/src/types/index.ts +2 -0
  71. package/src/types/routes.ts +182 -0
  72. package/src/utils/headers.ts +72 -0
  73. package/src/utils/index.ts +2 -0
  74. package/src/utils/path-matcher.ts +79 -0
  75. package/tests/cors.test.ts +133 -0
  76. package/tests/dispatcher.test.ts +425 -0
  77. package/tests/headers.test.ts +99 -0
  78. package/tests/identity.test.ts +171 -0
  79. package/tests/path-matcher.test.ts +102 -0
  80. package/tests/response.test.ts +155 -0
  81. package/tsconfig.json +24 -0
@@ -0,0 +1,415 @@
1
+ import { EventType, RouteSegment } from './types/event-type.enum.js';
2
+ import {
3
+ DispatchRoutes,
4
+ HttpMethod,
5
+ HttpRouter,
6
+ SegmentedHttpRouter,
7
+ AdvancedSegmentedRouter,
8
+ SegmentConfig,
9
+ RouteConfig,
10
+ RouteMatch,
11
+ NormalizedEvent,
12
+ OrchestratorConfig,
13
+ MiddlewareFn,
14
+ } from './types/routes.js';
15
+ import { matchPath, normalizePath } from './utils/path-matcher.js';
16
+ import { normalizeHeaders } from './utils/headers.js';
17
+ import { parseJsonBody, parseQueryParams } from './http/body-parser.js';
18
+ import { extractIdentity, validateIssuer } from './identity/extractor.js';
19
+ import { forbiddenResponse, badRequestResponse, notFoundResponse } from './http/response.js';
20
+
21
+ /**
22
+ * Detects the type of AWS event
23
+ */
24
+ export function detectEventType(event: any): EventType {
25
+ if (event.source === 'EVENT_BRIDGE') return EventType.EventBridge;
26
+ if (event.requestContext && event.httpMethod) return EventType.ApiGateway;
27
+ if (event.Records && Array.isArray(event.Records) && event.Records[0]?.eventSource === 'aws:sqs') return EventType.Sqs;
28
+ if (event.awsRequestId) return EventType.Lambda;
29
+ return EventType.Unknown;
30
+ }
31
+
32
+ /**
33
+ * Checks if a router is segmented (has public/private/backoffice/internal keys)
34
+ */
35
+ function isSegmentedRouter(router: any): router is SegmentedHttpRouter | AdvancedSegmentedRouter {
36
+ if (!router || typeof router !== 'object') return false;
37
+ const segmentKeys = ['public', 'private', 'backoffice', 'internal'];
38
+ const routerKeys = Object.keys(router);
39
+ return routerKeys.some(key => segmentKeys.includes(key));
40
+ }
41
+
42
+ /**
43
+ * Checks if a segment config has middleware
44
+ */
45
+ function isSegmentConfig(config: any): config is SegmentConfig {
46
+ return config && typeof config === 'object' && 'routes' in config;
47
+ }
48
+
49
+ /**
50
+ * Gets the HttpRouter from a segment (handles both simple and advanced config)
51
+ */
52
+ function getRouterFromSegment(segment: HttpRouter | SegmentConfig | undefined): HttpRouter | undefined {
53
+ if (!segment) return undefined;
54
+ if (isSegmentConfig(segment)) return segment.routes;
55
+ return segment;
56
+ }
57
+
58
+ /**
59
+ * Gets middleware from a segment config
60
+ */
61
+ function getMiddlewareFromSegment(segment: HttpRouter | SegmentConfig | undefined): MiddlewareFn[] {
62
+ if (!segment) return [];
63
+ if (isSegmentConfig(segment)) return segment.middleware ?? [];
64
+ return [];
65
+ }
66
+
67
+
68
+ /**
69
+ * Finds a matching route using pattern for lookup and actualPath for params extraction
70
+ */
71
+ function findRouteInRouterWithActualPath(
72
+ router: HttpRouter | undefined,
73
+ method: HttpMethod,
74
+ routePattern: string,
75
+ actualPath: string
76
+ ): { config: RouteConfig; params: Record<string, string> } | null {
77
+ if (!router) return null;
78
+
79
+ const methodRoutes = router[method];
80
+ if (!methodRoutes) return null;
81
+
82
+ const normalizedPattern = normalizePath(routePattern);
83
+ const normalizedActualPath = normalizePath(actualPath);
84
+
85
+ // First, try exact match with the pattern
86
+ if (methodRoutes[normalizedPattern]) {
87
+ // Extract params from the actual path using the pattern
88
+ const params = matchPath(normalizedPattern, normalizedActualPath) ?? {};
89
+ return { config: methodRoutes[normalizedPattern], params };
90
+ }
91
+
92
+ // Then, try pattern matching
93
+ for (const [pattern, config] of Object.entries(methodRoutes)) {
94
+ const params = matchPath(pattern, normalizedActualPath);
95
+ if (params !== null) {
96
+ return { config, params };
97
+ }
98
+ }
99
+
100
+ return null;
101
+ }
102
+
103
+ /**
104
+ * Finds a route across all segments with actual path for params
105
+ */
106
+ function findRouteInSegmentsWithActualPath(
107
+ router: SegmentedHttpRouter | AdvancedSegmentedRouter,
108
+ method: HttpMethod,
109
+ routePattern: string,
110
+ actualPath: string
111
+ ): RouteMatch | null {
112
+ const segments: RouteSegment[] = [
113
+ RouteSegment.Public,
114
+ RouteSegment.Private,
115
+ RouteSegment.Backoffice,
116
+ RouteSegment.Internal,
117
+ ];
118
+
119
+ for (const segment of segments) {
120
+ const segmentRouter = router[segment];
121
+ const httpRouter = getRouterFromSegment(segmentRouter);
122
+ const result = findRouteInRouterWithActualPath(httpRouter, method, routePattern, actualPath);
123
+
124
+ if (result) {
125
+ return {
126
+ handler: result.config.handler,
127
+ params: result.params,
128
+ segment,
129
+ middleware: getMiddlewareFromSegment(segmentRouter),
130
+ config: result.config,
131
+ };
132
+ }
133
+ }
134
+
135
+ return null;
136
+ }
137
+
138
+
139
+ /**
140
+ * Normalizes an API Gateway event into a standard format
141
+ */
142
+ function normalizeApiGatewayEvent(event: any, segment: RouteSegment, params: Record<string, string>): NormalizedEvent {
143
+ const identity = extractIdentity(event);
144
+
145
+ return {
146
+ eventRaw: event,
147
+ eventType: EventType.ApiGateway,
148
+ payload: {
149
+ body: parseJsonBody(event.body, event.isBase64Encoded),
150
+ pathParameters: { ...event.pathParameters, ...params },
151
+ queryStringParameters: parseQueryParams(event.queryStringParameters),
152
+ headers: normalizeHeaders(event.headers),
153
+ },
154
+ params,
155
+ context: {
156
+ segment,
157
+ identity,
158
+ requestId: event.requestContext?.requestId,
159
+ },
160
+ };
161
+ }
162
+
163
+ /**
164
+ * Normalizes an EventBridge event
165
+ */
166
+ function normalizeEventBridgeEvent(event: any): NormalizedEvent {
167
+ return {
168
+ eventRaw: event,
169
+ eventType: EventType.EventBridge,
170
+ payload: {
171
+ body: event.detail,
172
+ },
173
+ params: {},
174
+ context: {
175
+ segment: RouteSegment.Internal,
176
+ requestId: event.id,
177
+ },
178
+ };
179
+ }
180
+
181
+ /**
182
+ * Normalizes an SQS event
183
+ */
184
+ function normalizeSqsEvent(event: any): NormalizedEvent {
185
+ const record = event.Records[0];
186
+ let body: Record<string, any> = {};
187
+
188
+ try {
189
+ body = JSON.parse(record.body) as Record<string, any>;
190
+ } catch {
191
+ body = { rawBody: record.body };
192
+ }
193
+
194
+ return {
195
+ eventRaw: event,
196
+ eventType: EventType.Sqs,
197
+ payload: {
198
+ body,
199
+ },
200
+ params: {},
201
+ context: {
202
+ segment: RouteSegment.Internal,
203
+ requestId: record.messageId,
204
+ },
205
+ };
206
+ }
207
+
208
+ /**
209
+ * Normalizes a Lambda invocation event
210
+ */
211
+ function normalizeLambdaEvent(event: any): NormalizedEvent {
212
+ return {
213
+ eventRaw: event,
214
+ eventType: EventType.Lambda,
215
+ payload: {
216
+ body: event,
217
+ },
218
+ params: {},
219
+ context: {
220
+ segment: RouteSegment.Internal,
221
+ },
222
+ };
223
+ }
224
+
225
+ /**
226
+ * Validates User Pool for a segment
227
+ */
228
+ function validateSegmentUserPool(
229
+ normalized: NormalizedEvent,
230
+ segment: RouteSegment,
231
+ config: OrchestratorConfig
232
+ ): boolean {
233
+ // Public routes don't require validation
234
+ if (segment === RouteSegment.Public) return true;
235
+
236
+ // If no user pool config, skip validation
237
+ const expectedUserPoolId = config.userPools?.[segment];
238
+ if (!expectedUserPoolId) return true;
239
+
240
+ // Validate issuer matches expected user pool
241
+ return validateIssuer(normalized.context.identity, expectedUserPoolId);
242
+ }
243
+
244
+ /**
245
+ * Executes middleware chain
246
+ */
247
+ async function executeMiddleware(
248
+ middleware: MiddlewareFn[],
249
+ event: NormalizedEvent
250
+ ): Promise<NormalizedEvent> {
251
+ let currentEvent = event;
252
+
253
+ for (const mw of middleware) {
254
+ const result = await mw(currentEvent);
255
+ if (result) {
256
+ currentEvent = result;
257
+ }
258
+ }
259
+
260
+ return currentEvent;
261
+ }
262
+
263
+ /**
264
+ * Main dispatch function with all improvements
265
+ */
266
+ export async function dispatchEvent(
267
+ event: any,
268
+ routes: DispatchRoutes,
269
+ config: OrchestratorConfig = {}
270
+ ): Promise<any> {
271
+ const debug = config.debug ?? false;
272
+
273
+ if (debug) {
274
+ console.log('[SEO] Event received:', JSON.stringify(event, null, 2));
275
+ }
276
+
277
+ const type = detectEventType(event);
278
+
279
+ if (debug) {
280
+ console.log('[SEO] Event type:', type);
281
+ }
282
+
283
+ // Handle API Gateway events
284
+ if (type === EventType.ApiGateway) {
285
+ const method = event.httpMethod?.toLowerCase() as HttpMethod;
286
+ const routePattern = event.resource || event.path;
287
+ const actualPath = event.path || event.resource;
288
+
289
+ if (debug) {
290
+ console.log('[SEO] Method:', method, 'Path:', routePattern, 'Actual:', actualPath);
291
+ }
292
+
293
+ const apiRoutes = routes.apigateway;
294
+ if (!apiRoutes) {
295
+ return config.responses?.notFound?.() ?? notFoundResponse('No API routes configured');
296
+ }
297
+
298
+ let routeMatch: RouteMatch | null = null;
299
+
300
+ // Use routePattern for finding routes, but extract params from actualPath
301
+ if (isSegmentedRouter(apiRoutes)) {
302
+ routeMatch = findRouteInSegmentsWithActualPath(apiRoutes, method, routePattern, actualPath);
303
+ } else {
304
+ // Flat router - treat as public
305
+ const result = findRouteInRouterWithActualPath(apiRoutes as HttpRouter, method, routePattern, actualPath);
306
+ if (result) {
307
+ routeMatch = {
308
+ handler: result.config.handler,
309
+ params: result.params,
310
+ segment: RouteSegment.Public,
311
+ middleware: [],
312
+ config: result.config,
313
+ };
314
+ }
315
+ }
316
+
317
+ if (!routeMatch) {
318
+ if (debug) {
319
+ console.log('[SEO] No route found for:', method, routePattern);
320
+ }
321
+ return config.responses?.notFound?.() ?? notFoundResponse(`Route not found: ${method.toUpperCase()} ${routePattern}`);
322
+ }
323
+
324
+ if (debug) {
325
+ console.log('[SEO] Route matched:', routeMatch.segment, 'Params:', routeMatch.params);
326
+ }
327
+
328
+ // Normalize event
329
+ let normalized = normalizeApiGatewayEvent(event, routeMatch.segment, routeMatch.params);
330
+
331
+ // Validate User Pool
332
+ if (!validateSegmentUserPool(normalized, routeMatch.segment, config)) {
333
+ if (debug) {
334
+ console.log('[SEO] User Pool validation failed for segment:', routeMatch.segment);
335
+ }
336
+ return config.responses?.forbidden?.() ?? forbiddenResponse('Access denied: Invalid token issuer');
337
+ }
338
+
339
+ // Execute global middleware
340
+ if (config.globalMiddleware?.length) {
341
+ normalized = await executeMiddleware(config.globalMiddleware, normalized);
342
+ }
343
+
344
+ // Execute segment middleware
345
+ if (routeMatch.middleware?.length) {
346
+ normalized = await executeMiddleware(routeMatch.middleware, normalized);
347
+ }
348
+
349
+ // Execute handler
350
+ return routeMatch.handler(normalized);
351
+ }
352
+
353
+ // Handle EventBridge events
354
+ if (type === EventType.EventBridge) {
355
+ const operationName = event.detail?.operationName;
356
+ const handler = operationName
357
+ ? routes.eventbridge?.[operationName] ?? routes.eventbridge?.default
358
+ : routes.eventbridge?.default;
359
+
360
+ if (!handler) {
361
+ if (debug) {
362
+ console.log('[SEO] No EventBridge handler for:', operationName);
363
+ }
364
+ return { statusCode: 404, body: 'EventBridge handler not found' };
365
+ }
366
+
367
+ const normalized = normalizeEventBridgeEvent(event);
368
+ return handler(normalized);
369
+ }
370
+
371
+ // Handle SQS events
372
+ if (type === EventType.Sqs) {
373
+ const queueArn = event.Records[0]?.eventSourceARN;
374
+ const queueName = queueArn?.split(':').pop();
375
+ const handler = routes.sqs?.[queueName] ?? routes.sqs?.default;
376
+
377
+ if (!handler) {
378
+ if (debug) {
379
+ console.log('[SEO] No SQS handler for queue:', queueName);
380
+ }
381
+ return { statusCode: 404, body: 'SQS handler not found' };
382
+ }
383
+
384
+ const normalized = normalizeSqsEvent(event);
385
+ return handler(normalized);
386
+ }
387
+
388
+ // Handle Lambda invocation
389
+ if (type === EventType.Lambda) {
390
+ const handler = routes.lambda?.default;
391
+
392
+ if (!handler) {
393
+ return { statusCode: 404, body: 'Lambda handler not found' };
394
+ }
395
+
396
+ const normalized = normalizeLambdaEvent(event);
397
+ return handler(normalized);
398
+ }
399
+
400
+ // Unknown event type
401
+ if (debug) {
402
+ console.log('[SEO] Unknown event type');
403
+ }
404
+ return config.responses?.badRequest?.('Unknown event type') ?? badRequestResponse('Unknown event type');
405
+ }
406
+
407
+ /**
408
+ * Creates an orchestrator instance with pre-configured options
409
+ */
410
+ export function createOrchestrator(config: OrchestratorConfig = {}) {
411
+ return {
412
+ dispatch: (event: any, routes: DispatchRoutes) => dispatchEvent(event, routes, config),
413
+ config,
414
+ };
415
+ }
@@ -0,0 +1,60 @@
1
+ /**
2
+ * Safe JSON body parsing utilities
3
+ */
4
+
5
+ /**
6
+ * Safely parses a JSON body string
7
+ * @param body - Raw body string from event
8
+ * @param isBase64Encoded - Whether the body is base64 encoded
9
+ * @returns Parsed object or empty object on error
10
+ */
11
+ export function parseJsonBody(body: string | null | undefined, isBase64Encoded?: boolean): Record<string, any> {
12
+ if (!body) return {};
13
+
14
+ try {
15
+ let decodedBody = body;
16
+
17
+ if (isBase64Encoded) {
18
+ decodedBody = Buffer.from(body, 'base64').toString('utf-8');
19
+ }
20
+
21
+ const parsed = JSON.parse(decodedBody) as Record<string, any>;
22
+ return parsed ?? {};
23
+ } catch {
24
+ return {};
25
+ }
26
+ }
27
+
28
+ /**
29
+ * Safely parses query string parameters
30
+ * Handles multi-value parameters
31
+ * @param params - Query string parameters object
32
+ * @returns Normalized parameters
33
+ */
34
+ export function parseQueryParams(
35
+ params: Record<string, string | undefined> | null | undefined
36
+ ): Record<string, string> {
37
+ if (!params) return {};
38
+
39
+ const result: Record<string, string> = {};
40
+
41
+ for (const [key, value] of Object.entries(params)) {
42
+ if (value !== undefined) {
43
+ result[key] = value;
44
+ }
45
+ }
46
+
47
+ return result;
48
+ }
49
+
50
+ /**
51
+ * Middleware-style body parser that can be applied to events
52
+ */
53
+ export function withJsonBodyParser<T extends { body?: string; isBase64Encoded?: boolean }>(
54
+ event: T
55
+ ): T & { parsedBody: Record<string, any> } {
56
+ return {
57
+ ...event,
58
+ parsedBody: parseJsonBody(event.body, event.isBase64Encoded),
59
+ };
60
+ }
@@ -0,0 +1,76 @@
1
+ import { CorsConfig } from '../types/routes.js';
2
+ import { getCorsHeaders } from '../utils/headers.js';
3
+ import { HttpResponse } from './response.js';
4
+
5
+ /**
6
+ * CORS handling utilities
7
+ */
8
+
9
+ /**
10
+ * Checks if a request is a CORS preflight request
11
+ * @param event - Raw API Gateway event
12
+ * @returns True if this is an OPTIONS preflight request
13
+ */
14
+ export function isPreflightRequest(event: any): boolean {
15
+ return event.httpMethod?.toUpperCase() === 'OPTIONS';
16
+ }
17
+
18
+ /**
19
+ * Creates a preflight response with CORS headers
20
+ * @param config - CORS configuration
21
+ * @returns HTTP response for preflight
22
+ */
23
+ export function createPreflightResponse(config?: CorsConfig | boolean): HttpResponse {
24
+ const corsConfig = config === true ? undefined : (config === false ? undefined : config);
25
+
26
+ return {
27
+ statusCode: 204,
28
+ body: '',
29
+ headers: getCorsHeaders(corsConfig),
30
+ };
31
+ }
32
+
33
+ /**
34
+ * Applies CORS headers to an existing response
35
+ * @param response - Original response
36
+ * @param config - CORS configuration
37
+ * @returns Response with CORS headers
38
+ */
39
+ export function applyCorsHeaders(response: HttpResponse, config?: CorsConfig | boolean): HttpResponse {
40
+ if (config === false) return response;
41
+
42
+ const corsConfig = config === true ? undefined : config;
43
+ const corsHeaders = getCorsHeaders(corsConfig);
44
+
45
+ return {
46
+ ...response,
47
+ headers: {
48
+ ...corsHeaders,
49
+ ...response.headers,
50
+ },
51
+ };
52
+ }
53
+
54
+ /**
55
+ * Middleware that handles CORS for a handler
56
+ * @param handler - Original handler function
57
+ * @param config - CORS configuration
58
+ * @returns Wrapped handler with CORS support
59
+ */
60
+ export function withCors<T extends (...args: any[]) => Promise<HttpResponse>>(
61
+ handler: T,
62
+ config?: CorsConfig | boolean
63
+ ): T {
64
+ return (async (...args: Parameters<T>): Promise<HttpResponse> => {
65
+ const event = args[0];
66
+
67
+ // Handle preflight requests
68
+ if (isPreflightRequest(event?.eventRaw ?? event)) {
69
+ return createPreflightResponse(config);
70
+ }
71
+
72
+ // Execute handler and apply CORS headers
73
+ const response = await handler(...args);
74
+ return applyCorsHeaders(response, config);
75
+ }) as T;
76
+ }
@@ -0,0 +1,3 @@
1
+ export * from './response.js';
2
+ export * from './body-parser.js';
3
+ export * from './cors.js';