@digitraffic/common 2026.4.20-2 → 2026.4.21-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.
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,169 @@
|
|
|
1
|
+
import { Writable } from "node:stream";
|
|
2
|
+
import vm from "node:vm";
|
|
3
|
+
import { describe, expect, test } from "vitest";
|
|
4
|
+
// Import from the internal module directly — these helpers are intentionally
|
|
5
|
+
// not exported from sqs-queue.ts to keep the public API surface clean.
|
|
6
|
+
import { getDlqCode, sanitizeS3BucketName, } from "../../aws/infra/sqs-queue-internal.js";
|
|
7
|
+
import { DtLogger } from "../../aws/runtime/dt-logger.js";
|
|
8
|
+
describe("sqs-queue", () => {
|
|
9
|
+
test("getDlqCode generates valid JavaScript", () => {
|
|
10
|
+
const inlineCode = getDlqCode("test-bucket");
|
|
11
|
+
// InlineCode stores the code in the `code` property
|
|
12
|
+
const code =
|
|
13
|
+
// biome-ignore lint/suspicious/noExplicitAny: accessing internal CDK property for testing
|
|
14
|
+
inlineCode.code ??
|
|
15
|
+
// biome-ignore lint/suspicious/noExplicitAny: accessing internal CDK property for testing
|
|
16
|
+
inlineCode.inlineCode;
|
|
17
|
+
expect(code).toBeDefined();
|
|
18
|
+
expect(typeof code).toBe("string");
|
|
19
|
+
// verify bucket name was substituted
|
|
20
|
+
expect(code).toContain("test-bucket");
|
|
21
|
+
expect(code).not.toContain("__bucketName__");
|
|
22
|
+
expect(code).not.toContain("__upload__");
|
|
23
|
+
expect(code).not.toContain("__handler__");
|
|
24
|
+
// verify it's valid CJS (no import/export statements, uses require/exports)
|
|
25
|
+
expect(code).toContain("require(");
|
|
26
|
+
expect(code).toContain("exports.handler");
|
|
27
|
+
expect(code).not.toMatch(/^import /m);
|
|
28
|
+
// evaluate in a vm context with stubbed require/exports and verify
|
|
29
|
+
// exports.handler is actually defined as a function after execution
|
|
30
|
+
const exports = {};
|
|
31
|
+
const stubRequire = (mod) => {
|
|
32
|
+
if (mod === "@aws-sdk/client-s3") {
|
|
33
|
+
return {
|
|
34
|
+
PutObjectCommand: class {
|
|
35
|
+
},
|
|
36
|
+
S3Client: class {
|
|
37
|
+
},
|
|
38
|
+
};
|
|
39
|
+
}
|
|
40
|
+
throw new Error(`Unexpected require: ${mod}`);
|
|
41
|
+
};
|
|
42
|
+
const context = vm.createContext({
|
|
43
|
+
require: stubRequire,
|
|
44
|
+
exports,
|
|
45
|
+
console,
|
|
46
|
+
process,
|
|
47
|
+
});
|
|
48
|
+
vm.runInContext(code, context);
|
|
49
|
+
// biome-ignore lint/complexity/useLiteralKeys: Record<string, unknown> requires bracket notation for noPropertyAccessFromIndexSignature
|
|
50
|
+
expect(typeof exports["handler"]).toBe("function");
|
|
51
|
+
});
|
|
52
|
+
test("inline logger emits same JSON keys as DtLogger.error", () => {
|
|
53
|
+
// Capture DtLogger.error output
|
|
54
|
+
const dtLoggerLines = [];
|
|
55
|
+
const stream = new Writable({
|
|
56
|
+
write(chunk, _encoding, callback) {
|
|
57
|
+
dtLoggerLines.push(chunk.toString());
|
|
58
|
+
callback();
|
|
59
|
+
},
|
|
60
|
+
});
|
|
61
|
+
const dtLogger = new DtLogger({
|
|
62
|
+
lambdaName: "test-lambda",
|
|
63
|
+
runTime: "test-runtime",
|
|
64
|
+
writeStream: stream,
|
|
65
|
+
});
|
|
66
|
+
dtLogger.error({
|
|
67
|
+
method: "s3.uploadToS3",
|
|
68
|
+
message: "upload failed to bucket test-bucket",
|
|
69
|
+
});
|
|
70
|
+
const dtLoggerOutput = JSON.parse(dtLoggerLines[0]);
|
|
71
|
+
// Capture inline DLQ logger output
|
|
72
|
+
const inlineCode = getDlqCode("test-bucket");
|
|
73
|
+
const code =
|
|
74
|
+
// biome-ignore lint/suspicious/noExplicitAny: accessing internal CDK property for testing
|
|
75
|
+
inlineCode.code ??
|
|
76
|
+
// biome-ignore lint/suspicious/noExplicitAny: accessing internal CDK property for testing
|
|
77
|
+
inlineCode.inlineCode;
|
|
78
|
+
const inlineLoggerLines = [];
|
|
79
|
+
const stubProcess = {
|
|
80
|
+
env: {
|
|
81
|
+
AWS_LAMBDA_FUNCTION_NAME: "test-lambda",
|
|
82
|
+
AWS_EXECUTION_ENV: "test-runtime",
|
|
83
|
+
},
|
|
84
|
+
stdout: {
|
|
85
|
+
write: (line) => {
|
|
86
|
+
inlineLoggerLines.push(line);
|
|
87
|
+
},
|
|
88
|
+
},
|
|
89
|
+
};
|
|
90
|
+
const stubExports = {};
|
|
91
|
+
const stubRequire = (mod) => {
|
|
92
|
+
if (mod === "@aws-sdk/client-s3") {
|
|
93
|
+
return { PutObjectCommand: class {
|
|
94
|
+
}, S3Client: class {
|
|
95
|
+
} };
|
|
96
|
+
}
|
|
97
|
+
throw new Error(`Unexpected require: ${mod}`);
|
|
98
|
+
};
|
|
99
|
+
const context = vm.createContext({
|
|
100
|
+
require: stubRequire,
|
|
101
|
+
exports: stubExports,
|
|
102
|
+
console,
|
|
103
|
+
process: stubProcess,
|
|
104
|
+
JSON,
|
|
105
|
+
});
|
|
106
|
+
vm.runInContext(code, context);
|
|
107
|
+
// Call the inline logger.error with the same input
|
|
108
|
+
vm.runInContext(`logger.error({ method: "s3.uploadToS3", message: "upload failed to bucket test-bucket" })`, context);
|
|
109
|
+
const inlineOutput = JSON.parse(inlineLoggerLines[0]);
|
|
110
|
+
// The inline logger must have the same keys that DtLogger produces
|
|
111
|
+
const dtKeys = new Set(Object.keys(dtLoggerOutput));
|
|
112
|
+
const inlineKeys = new Set(Object.keys(inlineOutput));
|
|
113
|
+
for (const key of dtKeys) {
|
|
114
|
+
expect(inlineKeys, `inline logger missing key "${key}" that DtLogger emits`).toContain(key);
|
|
115
|
+
}
|
|
116
|
+
// Verify key field values match
|
|
117
|
+
// biome-ignore lint/complexity/useLiteralKeys: Record<string, unknown> requires bracket notation for noPropertyAccessFromIndexSignature
|
|
118
|
+
expect(inlineOutput["level"]).toBe(dtLoggerOutput["level"]);
|
|
119
|
+
// biome-ignore lint/complexity/useLiteralKeys: Record<string, unknown> requires bracket notation for noPropertyAccessFromIndexSignature
|
|
120
|
+
expect(inlineOutput["lambdaName"]).toBe(dtLoggerOutput["lambdaName"]);
|
|
121
|
+
// biome-ignore lint/complexity/useLiteralKeys: Record<string, unknown> requires bracket notation for noPropertyAccessFromIndexSignature
|
|
122
|
+
expect(inlineOutput["runtime"]).toBe(dtLoggerOutput["runtime"]);
|
|
123
|
+
// biome-ignore lint/complexity/useLiteralKeys: Record<string, unknown> requires bracket notation for noPropertyAccessFromIndexSignature
|
|
124
|
+
expect(inlineOutput["method"]).toBe(dtLoggerOutput["method"]);
|
|
125
|
+
});
|
|
126
|
+
});
|
|
127
|
+
describe("sanitizeS3BucketName", () => {
|
|
128
|
+
test("returns lowercase alphanumeric name unchanged", () => {
|
|
129
|
+
expect(sanitizeS3BucketName("my-bucket-name")).toBe("my-bucket-name");
|
|
130
|
+
});
|
|
131
|
+
test("replaces invalid characters with hyphens", () => {
|
|
132
|
+
expect(sanitizeS3BucketName("My_Bucket.Name")).toBe("my-bucket-name");
|
|
133
|
+
});
|
|
134
|
+
test("collapses consecutive hyphens", () => {
|
|
135
|
+
expect(sanitizeS3BucketName("my---bucket")).toBe("my-bucket");
|
|
136
|
+
});
|
|
137
|
+
test("trims leading and trailing hyphens", () => {
|
|
138
|
+
expect(sanitizeS3BucketName("-my-bucket-")).toBe("my-bucket");
|
|
139
|
+
expect(sanitizeS3BucketName("__name__")).toBe("name");
|
|
140
|
+
});
|
|
141
|
+
test("short names are not truncated", () => {
|
|
142
|
+
const name = "a".repeat(63);
|
|
143
|
+
expect(sanitizeS3BucketName(name)).toBe(name);
|
|
144
|
+
expect(sanitizeS3BucketName(name)).toHaveLength(63);
|
|
145
|
+
});
|
|
146
|
+
test("long names are truncated with hash suffix", () => {
|
|
147
|
+
const name = "a".repeat(100);
|
|
148
|
+
const result = sanitizeS3BucketName(name);
|
|
149
|
+
expect(result.length).toBeLessThanOrEqual(63);
|
|
150
|
+
expect(result).toMatch(/^[a-z0-9][a-z0-9-]*[a-z0-9]$/);
|
|
151
|
+
});
|
|
152
|
+
test("different long names produce different bucket names", () => {
|
|
153
|
+
const name1 = `${"a".repeat(50)}-stack-one-dlq`;
|
|
154
|
+
const name2 = `${"a".repeat(50)}-stack-two-dlq`;
|
|
155
|
+
const result1 = sanitizeS3BucketName(name1);
|
|
156
|
+
const result2 = sanitizeS3BucketName(name2);
|
|
157
|
+
expect(result1).not.toBe(result2);
|
|
158
|
+
expect(result1.length).toBeLessThanOrEqual(63);
|
|
159
|
+
expect(result2.length).toBeLessThanOrEqual(63);
|
|
160
|
+
});
|
|
161
|
+
test("result never starts or ends with hyphen", () => {
|
|
162
|
+
const longWithHyphens = `-${"a-b".repeat(30)}-`;
|
|
163
|
+
const result = sanitizeS3BucketName(longWithHyphens);
|
|
164
|
+
expect(result).not.toMatch(/^-/);
|
|
165
|
+
expect(result).not.toMatch(/-$/);
|
|
166
|
+
expect(result.length).toBeLessThanOrEqual(63);
|
|
167
|
+
});
|
|
168
|
+
});
|
|
169
|
+
//# sourceMappingURL=sqs-queue.test.js.map
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import { InlineCode } from "aws-cdk-lib/aws-lambda";
|
|
2
|
+
/**
|
|
3
|
+
* Sanitize a string into a valid S3 bucket name.
|
|
4
|
+
* S3 bucket names must be 3–63 characters, lowercase alphanumeric or hyphens,
|
|
5
|
+
* and must start and end with an alphanumeric character.
|
|
6
|
+
* When truncation is needed, a stable SHA-256 hash suffix is appended so that
|
|
7
|
+
* distinct input names don't collide after being cut to 63 characters.
|
|
8
|
+
*/
|
|
9
|
+
export declare function sanitizeS3BucketName(name: string): string;
|
|
10
|
+
export declare function getDlqCode(bucketName: string): InlineCode;
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Internal helpers for DLQ lambda code generation and S3 bucket name sanitization.
|
|
3
|
+
*
|
|
4
|
+
* This module is intentionally separate from sqs-queue.ts so that these functions
|
|
5
|
+
* are NOT part of the public API surface of the package (sqs-queue.ts is listed in
|
|
6
|
+
* package.json "exports", this file is not). Tests import from here directly.
|
|
7
|
+
*/
|
|
8
|
+
import { createHash } from "node:crypto";
|
|
9
|
+
import { PutObjectCommand, S3Client } from "@aws-sdk/client-s3";
|
|
10
|
+
import { InlineCode } from "aws-cdk-lib/aws-lambda";
|
|
11
|
+
import { logger } from "../runtime/dt-logger-default.js";
|
|
12
|
+
// CJS format is intentional: CDK InlineCode creates index.js, which Node.js treats as CJS.
|
|
13
|
+
// ESM would require Code.fromAsset with an .mjs file or a bundled NodejsFunction.
|
|
14
|
+
const DLQ_LAMBDA_CODE = `
|
|
15
|
+
const { PutObjectCommand, S3Client } = require("@aws-sdk/client-s3");
|
|
16
|
+
|
|
17
|
+
// Minimal logger that emits the same JSON schema as DtLogger so that
|
|
18
|
+
// existing log parsing and CloudWatch alarms keep working.
|
|
19
|
+
const lambdaName = process.env.AWS_LAMBDA_FUNCTION_NAME || "unknown lambda name";
|
|
20
|
+
const runtime = process.env.AWS_EXECUTION_ENV || "unknown runtime";
|
|
21
|
+
const logger = {
|
|
22
|
+
error: (msg) => {
|
|
23
|
+
const message = msg.method ? msg.method + " " + (msg.message || "") : msg.message || "";
|
|
24
|
+
process.stdout.write(JSON.stringify({ ...msg, message, level: "ERROR", lambdaName, runtime }) + "\\n");
|
|
25
|
+
}
|
|
26
|
+
};
|
|
27
|
+
|
|
28
|
+
const bucketName = "__bucketName__";
|
|
29
|
+
|
|
30
|
+
__upload__
|
|
31
|
+
|
|
32
|
+
__handler__
|
|
33
|
+
|
|
34
|
+
exports.handler = handler;
|
|
35
|
+
`;
|
|
36
|
+
const S3_BUCKET_NAME_MAX_LENGTH = 63;
|
|
37
|
+
const HASH_SUFFIX_LENGTH = 8;
|
|
38
|
+
/**
|
|
39
|
+
* Sanitize a string into a valid S3 bucket name.
|
|
40
|
+
* S3 bucket names must be 3–63 characters, lowercase alphanumeric or hyphens,
|
|
41
|
+
* and must start and end with an alphanumeric character.
|
|
42
|
+
* When truncation is needed, a stable SHA-256 hash suffix is appended so that
|
|
43
|
+
* distinct input names don't collide after being cut to 63 characters.
|
|
44
|
+
*/
|
|
45
|
+
export function sanitizeS3BucketName(name) {
|
|
46
|
+
const sanitized = name
|
|
47
|
+
.toLowerCase()
|
|
48
|
+
.replace(/[^a-z0-9-]/g, "-")
|
|
49
|
+
.replace(/-+/g, "-")
|
|
50
|
+
.replace(/^-|-$/g, "");
|
|
51
|
+
if (sanitized.length <= S3_BUCKET_NAME_MAX_LENGTH) {
|
|
52
|
+
return sanitized;
|
|
53
|
+
}
|
|
54
|
+
const hash = createHash("sha256")
|
|
55
|
+
.update(name)
|
|
56
|
+
.digest("hex")
|
|
57
|
+
.substring(0, HASH_SUFFIX_LENGTH);
|
|
58
|
+
const maxPrefixLength = S3_BUCKET_NAME_MAX_LENGTH - HASH_SUFFIX_LENGTH - 1;
|
|
59
|
+
const prefix = sanitized.substring(0, maxPrefixLength).replace(/-$/, "");
|
|
60
|
+
return `${prefix}-${hash}`;
|
|
61
|
+
}
|
|
62
|
+
export function getDlqCode(bucketName) {
|
|
63
|
+
const functionBody = DLQ_LAMBDA_CODE.replace("__bucketName__", bucketName)
|
|
64
|
+
.replace("__upload__", uploadToS3.toString())
|
|
65
|
+
.replace("__handler__", createHandler().toString());
|
|
66
|
+
return new InlineCode(functionBody);
|
|
67
|
+
}
|
|
68
|
+
async function uploadToS3(s3, bucketName, body, objectName, cannedAcl, contentType) {
|
|
69
|
+
const command = new PutObjectCommand({
|
|
70
|
+
Bucket: bucketName,
|
|
71
|
+
Key: objectName,
|
|
72
|
+
Body: body,
|
|
73
|
+
ACL: cannedAcl,
|
|
74
|
+
ContentType: contentType,
|
|
75
|
+
});
|
|
76
|
+
try {
|
|
77
|
+
await s3.send(command);
|
|
78
|
+
}
|
|
79
|
+
catch (_error) {
|
|
80
|
+
logger.error({
|
|
81
|
+
method: "s3.uploadToS3",
|
|
82
|
+
message: `upload failed to bucket ${bucketName}`,
|
|
83
|
+
});
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
// bucketName is unused, will be overridden in the actual lambda code below
|
|
87
|
+
const bucketName = "";
|
|
88
|
+
function createHandler() {
|
|
89
|
+
return async function handler(event) {
|
|
90
|
+
const millis = Date.now();
|
|
91
|
+
const s3 = new S3Client({});
|
|
92
|
+
await Promise.all(event.Records.map((e, idx) => {
|
|
93
|
+
return uploadToS3(s3, bucketName, e.body, `dlq-${millis}-${idx}.json`);
|
|
94
|
+
}));
|
|
95
|
+
};
|
|
96
|
+
}
|
|
97
|
+
//# sourceMappingURL=sqs-queue-internal.js.map
|
|
@@ -1,28 +1,17 @@
|
|
|
1
|
-
import { PutObjectCommand, S3Client } from "@aws-sdk/client-s3";
|
|
2
1
|
import { Duration } from "aws-cdk-lib";
|
|
3
2
|
import { ComparisonOperator, TreatMissingData, } from "aws-cdk-lib/aws-cloudwatch";
|
|
4
3
|
import { SnsAction } from "aws-cdk-lib/aws-cloudwatch-actions";
|
|
5
4
|
import { PolicyStatement } from "aws-cdk-lib/aws-iam";
|
|
6
|
-
import {
|
|
5
|
+
import { Runtime } from "aws-cdk-lib/aws-lambda";
|
|
7
6
|
import { SqsEventSource } from "aws-cdk-lib/aws-lambda-event-sources";
|
|
8
7
|
import { BlockPublicAccess, Bucket } from "aws-cdk-lib/aws-s3";
|
|
9
8
|
import { Queue, QueueEncryption } from "aws-cdk-lib/aws-sqs";
|
|
10
|
-
|
|
9
|
+
// getDlqCode and sanitizeS3BucketName live in a separate internal module so they
|
|
10
|
+
// are not part of the public API surface exported from this package.
|
|
11
|
+
// Tests can import them directly from sqs-queue-internal.ts.
|
|
12
|
+
import { getDlqCode, sanitizeS3BucketName } from "./sqs-queue-internal.js";
|
|
11
13
|
import { createLambdaLogGroup } from "./stack/lambda-log-group.js";
|
|
12
14
|
import { MonitoredFunction } from "./stack/monitoredfunction.js";
|
|
13
|
-
const DLQ_LAMBDA_CODE = `
|
|
14
|
-
import type { ObjectCannedACL } from "@aws-sdk/client-s3";
|
|
15
|
-
import { PutObjectCommand, S3Client } from "@aws-sdk/client-s3";
|
|
16
|
-
import { type NodeJsRuntimeStreamingBlobPayloadInputTypes } from "@smithy/types";
|
|
17
|
-
import { logger } from "./dt-logger-default.mjs";
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
const bucketName = "__bucketName__";
|
|
21
|
-
|
|
22
|
-
__upload__
|
|
23
|
-
|
|
24
|
-
exports.handler = async (event) => __handler__
|
|
25
|
-
`;
|
|
26
15
|
/**
|
|
27
16
|
* Construct for creating SQS-queues.
|
|
28
17
|
*
|
|
@@ -66,6 +55,7 @@ export const DigitrafficDLQueue = {
|
|
|
66
55
|
encryption: QueueEncryption.KMS_MANAGED,
|
|
67
56
|
});
|
|
68
57
|
const dlqBucket = new Bucket(stack, `${dlqName}-Bucket`, {
|
|
58
|
+
bucketName: sanitizeS3BucketName(`${stack.stackName}-${dlqName}`),
|
|
69
59
|
blockPublicAccess: BlockPublicAccess.BLOCK_ALL,
|
|
70
60
|
});
|
|
71
61
|
const dlqLogGroup = createLambdaLogGroup({
|
|
@@ -107,39 +97,4 @@ function addDLQAlarm(stack, dlqName, dlq) {
|
|
|
107
97
|
})
|
|
108
98
|
.addAlarmAction(new SnsAction(stack.warningTopic));
|
|
109
99
|
}
|
|
110
|
-
function getDlqCode(Bucket) {
|
|
111
|
-
const functionBody = DLQ_LAMBDA_CODE.replace("__bucketName__", Bucket)
|
|
112
|
-
.replace("__upload__", uploadToS3.toString())
|
|
113
|
-
.replace("__handler__", createHandler().toString().substring(23)); // remove function handler() from signature
|
|
114
|
-
return new InlineCode(functionBody);
|
|
115
|
-
}
|
|
116
|
-
async function uploadToS3(s3, bucketName, body, objectName, cannedAcl, contentType) {
|
|
117
|
-
const command = new PutObjectCommand({
|
|
118
|
-
Bucket: bucketName,
|
|
119
|
-
Key: objectName,
|
|
120
|
-
Body: body,
|
|
121
|
-
ACL: cannedAcl,
|
|
122
|
-
ContentType: contentType,
|
|
123
|
-
});
|
|
124
|
-
try {
|
|
125
|
-
await s3.send(command);
|
|
126
|
-
}
|
|
127
|
-
catch (_error) {
|
|
128
|
-
logger.error({
|
|
129
|
-
method: "s3.uploadToS3",
|
|
130
|
-
message: `upload failed to bucket ${bucketName}`,
|
|
131
|
-
});
|
|
132
|
-
}
|
|
133
|
-
}
|
|
134
|
-
// bucketName is unused, will be overridden in the actual lambda code below
|
|
135
|
-
const bucketName = "";
|
|
136
|
-
function createHandler() {
|
|
137
|
-
return async function handler(event) {
|
|
138
|
-
const millis = Date.now();
|
|
139
|
-
const s3 = new S3Client({});
|
|
140
|
-
await Promise.all(event.Records.map((e, idx) => {
|
|
141
|
-
return uploadToS3(s3, bucketName, e.body, `dlq-${millis}-${idx}.json`);
|
|
142
|
-
}));
|
|
143
|
-
};
|
|
144
|
-
}
|
|
145
100
|
//# sourceMappingURL=sqs-queue.js.map
|