Skip to content

Commit 12f053b

Browse files
committed
build a generalizable setup for async sqs handling
1 parent 838c8fb commit 12f053b

File tree

7 files changed

+417
-37
lines changed

7 files changed

+417
-37
lines changed

src/api/build.js

Lines changed: 45 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -1,39 +1,55 @@
11
import esbuild from "esbuild";
22
import { resolve } from "path";
33

4+
5+
const commonParams = {
6+
bundle: true,
7+
format: "esm",
8+
minify: true,
9+
outExtension: { ".js": ".mjs" },
10+
loader: {
11+
".png": "file",
12+
".pkpass": "file",
13+
".json": "file",
14+
}, // File loaders
15+
target: "es2022", // Target ES2022
16+
sourcemap: false,
17+
platform: "node",
18+
external: ["aws-sdk", "moment-timezone", "passkit-generator", "fastify"],
19+
alias: {
20+
'moment-timezone': resolve(process.cwd(), '../../node_modules/moment-timezone/builds/moment-timezone-with-data-10-year-range.js')
21+
},
22+
banner: {
23+
js: `
24+
import path from 'path';
25+
import { fileURLToPath } from 'url';
26+
import { createRequire as topLevelCreateRequire } from 'module';
27+
const require = topLevelCreateRequire(import.meta.url);
28+
const __filename = fileURLToPath(import.meta.url);
29+
const __dirname = path.dirname(__filename);
30+
`.trim(),
31+
}, // Banner for compatibility with CommonJS
32+
}
433
esbuild
534
.build({
6-
entryPoints: ["api/lambda.js"], // Entry file
7-
bundle: true,
8-
format: "esm",
9-
minify: true,
35+
...commonParams,
36+
entryPoints: ["api/index.js"],
1037
outdir: "../../dist/lambda/",
11-
outExtension: { ".js": ".mjs" },
12-
loader: {
13-
".png": "file",
14-
".pkpass": "file",
15-
".json": "file",
16-
}, // File loaders
17-
target: "es2022", // Target ES2022
18-
sourcemap: false,
19-
platform: "node",
20-
external: ["aws-sdk", "moment-timezone", "passkit-generator", "fastify"],
21-
alias: {
22-
'moment-timezone': resolve(process.cwd(), '../../node_modules/moment-timezone/builds/moment-timezone-with-data-10-year-range.js')
23-
},
24-
banner: {
25-
js: `
26-
import path from 'path';
27-
import { fileURLToPath } from 'url';
28-
import { createRequire as topLevelCreateRequire } from 'module';
29-
const require = topLevelCreateRequire(import.meta.url);
30-
const __filename = fileURLToPath(import.meta.url);
31-
const __dirname = path.dirname(__filename);
32-
`.trim(),
33-
}, // Banner for compatibility with CommonJS
3438
})
35-
.then(() => console.log("Build completed successfully!"))
39+
.then(() => console.log("API server build completed successfully!"))
40+
.catch((error) => {
41+
console.error("API server build failed:", error);
42+
process.exit(1);
43+
});
44+
45+
esbuild
46+
.build({
47+
...commonParams,
48+
entryPoints: ["api/sqs/index.js"],
49+
outdir: "../../dist/sqsConsumer/",
50+
})
51+
.then(() => console.log("SQS consumer build completed successfully!"))
3652
.catch((error) => {
37-
console.error("Build failed:", error);
53+
console.error("SQS consumer build failed:", error);
3854
process.exit(1);
3955
});

src/api/package.json

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,9 @@
2525
"@fastify/aws-lambda": "^5.0.0",
2626
"@fastify/caching": "^9.0.1",
2727
"@fastify/cors": "^10.0.1",
28+
"@middy/core": "^6.0.0",
29+
"@middy/event-normalizer": "^6.0.0",
30+
"@middy/sqs-partial-batch-failure": "^6.0.0",
2831
"@touch4it/ical-timezones": "^1.9.0",
2932
"base64-arraybuffer": "^1.0.2",
3033
"discord.js": "^14.15.3",
@@ -39,13 +42,15 @@
3942
"moment-timezone": "^0.5.45",
4043
"node-cache": "^5.1.2",
4144
"passkit-generator": "^3.3.1",
45+
"pino": "^9.6.0",
4246
"pluralize": "^8.0.0",
4347
"zod": "^3.23.8",
4448
"zod-to-json-schema": "^3.23.2",
4549
"zod-validation-error": "^3.3.1"
4650
},
4751
"devDependencies": {
4852
"@tsconfig/node22": "^22.0.0",
53+
"@types/aws-lambda": "^8.10.147",
4954
"nodemon": "^3.1.9"
5055
}
5156
}

src/api/sqs/handlers.ts

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
import { AvailableSQSFunctions } from "common/types/sqsMessage.js";
2+
import { SQSHandlerFunction } from "./index.js";
3+
4+
export const emailMembershipPassHandler: SQSHandlerFunction<
5+
AvailableSQSFunctions.EmailMembershipPass
6+
> = async (payload, metadata, logger) => {
7+
logger.error("Not implemented yet!");
8+
return;
9+
};
10+
11+
export const pingHandler: SQSHandlerFunction<
12+
AvailableSQSFunctions.Ping
13+
> = async (payload, metadata, logger) => {
14+
logger.error("Not implemented yet!");
15+
return;
16+
};

src/api/sqs/index.ts

Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,60 @@
1+
import middy from "@middy/core";
2+
import eventNormalizerMiddleware from "@middy/event-normalizer";
3+
import sqsPartialBatchFailure from "@middy/sqs-partial-batch-failure";
4+
import { Context, SQSEvent } from "aws-lambda";
5+
import {
6+
parseSQSPayload,
7+
SQSPayload,
8+
sqsPayloadSchemas,
9+
AvailableSQSFunctions,
10+
SQSMessageMetadata,
11+
} from "../../common/types/sqsMessage.js";
12+
import { logger } from "./logger.js";
13+
import { z, ZodError } from "zod";
14+
import pino from "pino";
15+
import { emailMembershipPassHandler, pingHandler } from "./handlers.js";
16+
17+
export type SQSFunctionPayloadTypes = {
18+
[K in keyof typeof sqsPayloadSchemas]: SQSHandlerFunction<K>;
19+
};
20+
21+
export type SQSHandlerFunction<T extends AvailableSQSFunctions> = (
22+
payload: z.infer<(typeof sqsPayloadSchemas)[T]>["payload"],
23+
metadata: SQSMessageMetadata,
24+
logger: pino.Logger,
25+
) => Promise<void>;
26+
27+
const handlers: SQSFunctionPayloadTypes = {
28+
[AvailableSQSFunctions.EmailMembershipPass]: emailMembershipPassHandler,
29+
[AvailableSQSFunctions.Ping]: pingHandler,
30+
};
31+
32+
export const handler = middy()
33+
.use(eventNormalizerMiddleware())
34+
.use(sqsPartialBatchFailure())
35+
.handler((event: SQSEvent, context: Context, { signal }) => {
36+
const recordsPromises = event.Records.map(async (record, index) => {
37+
try {
38+
let parsedBody = parseSQSPayload(record.body);
39+
if (parsedBody instanceof ZodError) {
40+
logger.error(
41+
{ sqsMessageId: record.messageId },
42+
parsedBody.toString(),
43+
);
44+
}
45+
parsedBody = parsedBody as SQSPayload;
46+
const childLogger = logger.child({
47+
sqsMessageId: record.messageId,
48+
metadata: parsedBody.metadata,
49+
});
50+
childLogger.info("Processing started.");
51+
const func = handlers[parsedBody.function] as SQSHandlerFunction<
52+
typeof parsedBody.function
53+
>;
54+
return func(parsedBody.payload, parsedBody.metadata, childLogger);
55+
} catch (e: any) {
56+
logger.error({ sqsMessageId: record.messageId }, e.toString());
57+
}
58+
});
59+
return Promise.allSettled(recordsPromises);
60+
});

src/api/sqs/logger.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
import { pino } from "pino";
2+
export const logger = pino();

src/common/types/sqsMessage.ts

Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,58 @@
1+
import { z, ZodError, ZodType } from "zod";
2+
3+
export enum AvailableSQSFunctions {
4+
Ping = "pong",
5+
EmailMembershipPass = "emailMembershipPass",
6+
}
7+
8+
const sqsMessageMetadataSchema = z.object({
9+
taskId: z.string().min(1),
10+
reqId: z.string().min(1),
11+
initiator: z.string().min(1),
12+
});
13+
14+
export type SQSMessageMetadata = z.infer<typeof sqsMessageMetadataSchema>;
15+
16+
const baseSchema = z.object({
17+
metadata: sqsMessageMetadataSchema,
18+
});
19+
20+
const createSQSSchema = <T extends AvailableSQSFunctions, P extends ZodType<any>>(
21+
func: T,
22+
payloadSchema: P
23+
) =>
24+
baseSchema.extend({
25+
function: z.literal(func),
26+
payload: payloadSchema,
27+
});
28+
29+
export const sqsPayloadSchemas = {
30+
[AvailableSQSFunctions.Ping]: createSQSSchema(AvailableSQSFunctions.Ping, z.object({})),
31+
[AvailableSQSFunctions.EmailMembershipPass]: createSQSSchema(
32+
AvailableSQSFunctions.EmailMembershipPass,
33+
z.object({ email: z.string().email() })
34+
),
35+
} as const;
36+
37+
export const sqsPayloadSchema = z.discriminatedUnion(
38+
"function",
39+
[
40+
sqsPayloadSchemas[AvailableSQSFunctions.Ping],
41+
sqsPayloadSchemas[AvailableSQSFunctions.EmailMembershipPass],
42+
] as const
43+
);
44+
45+
export type SQSPayload = z.infer<typeof sqsPayloadSchema>;
46+
47+
export type SQSFunctionPayloadTypes = {
48+
[K in keyof typeof sqsPayloadSchemas]: z.infer<(typeof sqsPayloadSchemas)[K]>;
49+
};
50+
51+
export function parseSQSPayload(json: unknown): SQSPayload | ZodError {
52+
const parsed = sqsPayloadSchema.safeParse(json);
53+
if (parsed.success) {
54+
return parsed.data;
55+
} else {
56+
return parsed.error;
57+
}
58+
}

0 commit comments

Comments
 (0)