@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 { InlineCode, Runtime } from "aws-cdk-lib/aws-lambda";
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
- import { logger } from "../runtime/dt-logger-default.js";
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
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@digitraffic/common",
3
- "version": "2026.4.20-2",
3
+ "version": "2026.4.21-1",
4
4
  "private": false,
5
5
  "description": "",
6
6
  "repository": {