@middy/util 7.2.2 → 7.3.0

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 (3) hide show
  1. package/index.d.ts +28 -0
  2. package/index.js +138 -17
  3. package/package.json +2 -5
package/index.d.ts CHANGED
@@ -21,6 +21,7 @@ export interface Options<Client, ClientOptions> {
21
21
  cacheKey?: string;
22
22
  cacheExpiry?: number;
23
23
  cacheKeyExpiry?: Record<string, number>;
24
+ cacheMaxSize?: number;
24
25
  setToContext?: boolean;
25
26
  }
26
27
 
@@ -157,6 +158,8 @@ declare function jsonSafeStringify(
157
158
  space?: string | number,
158
159
  ): string | unknown;
159
160
 
161
+ declare const jsonContentTypePattern: RegExp;
162
+
160
163
  declare function decodeBody(event: {
161
164
  body?: string | null;
162
165
  isBase64Encoded?: boolean;
@@ -181,3 +184,28 @@ declare function lambdaContext(
181
184
  ): unknown;
182
185
 
183
186
  declare const httpErrorCodes: Record<number, string>;
187
+
188
+ export type OptionSchemaRule =
189
+ | "string"
190
+ | "number"
191
+ | "boolean"
192
+ | "function"
193
+ | "object"
194
+ | "array"
195
+ | "string?"
196
+ | "number?"
197
+ | "boolean?"
198
+ | "function?"
199
+ | "object?"
200
+ | "array?"
201
+ | ((value: unknown) => boolean);
202
+
203
+ export type OptionSchema = Record<string, OptionSchemaRule>;
204
+
205
+ export declare function validateOptions(
206
+ packageName: string,
207
+ schema: OptionSchema,
208
+ options?: Record<string, unknown>,
209
+ ): void;
210
+
211
+ export declare const awsClientOptionSchema: OptionSchema;
package/index.js CHANGED
@@ -1,5 +1,75 @@
1
1
  // Copyright 2017 - 2026 will Farrell, Luciano Mammino, and Middy contributors.
2
2
  // SPDX-License-Identifier: MIT
3
+
4
+ // Option validation helper.
5
+ // Schema values:
6
+ // 'string' | 'number' | 'boolean' | 'function' | 'object' | 'array'
7
+ // Trailing '?' marks the field as optional (may be undefined).
8
+ // (value) => boolean predicate — only called when value is not undefined
9
+ // (i.e. predicates treat the field as optional by design).
10
+ // Keys in `options` that are not in `schema` throw, catching typos.
11
+ const validateOptionsTypeCheckers = {
12
+ string: (v) => typeof v === "string",
13
+ number: (v) => typeof v === "number" && !Number.isNaN(v),
14
+ boolean: (v) => typeof v === "boolean",
15
+ function: (v) => typeof v === "function",
16
+ object: (v) => v !== null && typeof v === "object" && !Array.isArray(v),
17
+ array: (v) => Array.isArray(v),
18
+ };
19
+
20
+ // Shared schema for middlewares that wrap an AWS SDK client. Spread into
21
+ // package-specific schemas: `{ ...awsClientOptionSchema, extraField: 'type?' }`.
22
+ export const awsClientOptionSchema = {
23
+ AwsClient: "function?",
24
+ awsClientOptions: "object?",
25
+ awsClientAssumeRole: "string?",
26
+ awsClientCapture: "function?",
27
+ fetchData: "object?",
28
+ disablePrefetch: "boolean?",
29
+ cacheKey: "string?",
30
+ cacheKeyExpiry: "object?",
31
+ cacheExpiry: (v) => typeof v === "number" && v >= -1,
32
+ cacheMaxSize: (v) => Number.isInteger(v) && v >= 1,
33
+ setToContext: "boolean?",
34
+ };
35
+
36
+ export const validateOptions = (packageName, schema, options = {}) => {
37
+ const fail = (message) => {
38
+ throw new TypeError(message, { cause: { package: packageName } });
39
+ };
40
+ if (
41
+ options === null ||
42
+ typeof options !== "object" ||
43
+ Array.isArray(options)
44
+ ) {
45
+ fail("options must be an object");
46
+ }
47
+ for (const key of Object.keys(options)) {
48
+ if (!Object.hasOwn(schema, key)) {
49
+ fail(`Unknown option '${key}'`);
50
+ }
51
+ }
52
+ for (const key of Object.keys(schema)) {
53
+ const rule = schema[key];
54
+ const value = options[key];
55
+ if (typeof rule === "function") {
56
+ if (value !== undefined && !rule(value)) {
57
+ fail(`Invalid option '${key}'`);
58
+ }
59
+ continue;
60
+ }
61
+ const optional = rule.endsWith("?");
62
+ const type = optional ? rule.slice(0, -1) : rule;
63
+ const checker = validateOptionsTypeCheckers[type];
64
+ if (!checker) fail(`Unknown schema type '${type}' for option '${key}'`);
65
+ if (value === undefined) {
66
+ if (!optional) fail(`Missing required option '${key}' (${type})`);
67
+ continue;
68
+ }
69
+ if (!checker(value)) fail(`Option '${key}' must be ${type}`);
70
+ }
71
+ };
72
+
3
73
  export const createPrefetchClient = (options) => {
4
74
  const { awsClientOptions } = options;
5
75
  const client = new options.AwsClient(awsClientOptions);
@@ -139,14 +209,34 @@ export const sanitizeKey = (key) => {
139
209
  };
140
210
 
141
211
  // fetch Cache
142
- const cache = Object.create(null); // key: { value:{fetchKey:Promise}, expiry }
212
+ // Map keyed by cacheKey; value shape: { value:{fetchKey:Promise}, expiry, refresh?, modified? }
213
+ // Map chosen over plain object so deletion is O(1), frees the key slot, and
214
+ // avoids the `delete` operator (biome's performance/noDelete rule).
215
+ const cache = new Map();
216
+ const defaultCacheMaxSize = 128;
217
+
218
+ const validateCacheExpiry = (cacheExpiry) => {
219
+ if (
220
+ typeof cacheExpiry === "number" &&
221
+ cacheExpiry < -1 &&
222
+ !Number.isNaN(cacheExpiry)
223
+ ) {
224
+ throw new Error(
225
+ `Invalid cacheExpiry value: ${cacheExpiry}. Must be -1 (infinite), 0 (disabled), or a positive number (ms duration or unix timestamp)`,
226
+ { cause: { package: "@middy/util" } },
227
+ );
228
+ }
229
+ };
230
+
143
231
  export const processCache = (
144
232
  options,
145
233
  middlewareFetch = () => undefined,
146
234
  middlewareFetchRequest = {},
147
235
  ) => {
148
- let { cacheKey, cacheKeyExpiry, cacheExpiry } = options;
236
+ let { cacheKey, cacheKeyExpiry, cacheExpiry, cacheMaxSize } = options;
237
+ cacheMaxSize ??= defaultCacheMaxSize;
149
238
  cacheExpiry = cacheKeyExpiry?.[cacheKey] ?? cacheExpiry;
239
+ validateCacheExpiry(cacheExpiry);
150
240
  const now = Date.now();
151
241
  if (cacheExpiry) {
152
242
  const cached = getCache(cacheKey);
@@ -156,27 +246,36 @@ export const processCache = (
156
246
  if (cached.modified) {
157
247
  const value = middlewareFetch(middlewareFetchRequest, cached.value);
158
248
  Object.assign(cached.value, value);
159
- cache[cacheKey] = { value: cached.value, expiry: cached.expiry };
160
- return cache[cacheKey];
249
+ const entry = { value: cached.value, expiry: cached.expiry };
250
+ cache.set(cacheKey, entry);
251
+ return entry;
161
252
  }
162
253
  cached.cache = true;
163
254
  return cached;
164
255
  }
165
256
  }
166
257
  const value = middlewareFetch(middlewareFetchRequest);
167
- // secrets-manager can override to unix timestamp
258
+ // cacheExpiry semantics:
259
+ // >86400000 (24h): treated as unix timestamp (ms)
260
+ // >0 && <=86400000: treated as duration (ms) from now
261
+ // -1: infinite cache (never expires)
262
+ // 0/undefined/null: no caching
168
263
  const expiry = cacheExpiry > 86400000 ? cacheExpiry : now + cacheExpiry;
169
264
  const duration = cacheExpiry > 86400000 ? cacheExpiry - now : cacheExpiry;
170
265
  if (cacheExpiry) {
266
+ clearTimeout(cache.get(cacheKey)?.refresh);
267
+ // .unref() so a pending refresh timer does not keep the Lambda event
268
+ // loop alive (relevant under `callbackWaitsForEmptyEventLoop: false`).
171
269
  const refresh =
172
270
  duration > 0
173
271
  ? setTimeout(
174
272
  () =>
175
273
  processCache(options, middlewareFetch, middlewareFetchRequest),
176
274
  duration,
177
- )
275
+ ).unref()
178
276
  : undefined;
179
- cache[cacheKey] = { value, expiry, refresh };
277
+ cache.set(cacheKey, { value, expiry, refresh });
278
+ evictCache(cacheMaxSize);
180
279
  }
181
280
  return { value, expiry };
182
281
  };
@@ -189,27 +288,43 @@ export const catchInvalidSignatureException = (e, client, command) => {
189
288
  };
190
289
 
191
290
  export const getCache = (key) => {
192
- if (!cache[key]) return {};
193
- return cache[key];
291
+ return cache.get(key) ?? {};
194
292
  };
195
293
 
196
294
  // Used to remove parts of a cache
197
295
  export const modifyCache = (cacheKey, value) => {
198
- if (!cache[cacheKey]) return;
199
- clearTimeout(cache[cacheKey].refresh);
200
- cache[cacheKey].value = value;
201
- cache[cacheKey].modified = true;
296
+ const entry = cache.get(cacheKey);
297
+ if (!entry) return;
298
+ clearTimeout(entry.refresh);
299
+ entry.value = value;
300
+ entry.modified = true;
301
+ };
302
+
303
+ const evictCache = (maxSize) => {
304
+ if (cache.size <= maxSize) return;
305
+ let oldestKey = null;
306
+ let oldestExpiry = Infinity;
307
+ for (const [key, entry] of cache) {
308
+ if (entry && entry.expiry < oldestExpiry) {
309
+ oldestExpiry = entry.expiry;
310
+ oldestKey = key;
311
+ }
312
+ }
313
+ if (oldestKey !== null) {
314
+ clearTimeout(cache.get(oldestKey)?.refresh);
315
+ cache.delete(oldestKey);
316
+ }
202
317
  };
203
318
 
204
319
  export const clearCache = (inputKeys = null) => {
205
320
  let keys = inputKeys;
206
- keys ??= Object.keys(cache);
321
+ keys ??= [...cache.keys()];
207
322
  if (!Array.isArray(keys)) {
208
323
  keys = [keys];
209
324
  }
210
325
  for (const cacheKey of keys) {
211
- clearTimeout(cache[cacheKey]?.refresh);
212
- cache[cacheKey] = undefined;
326
+ clearTimeout(cache.get(cacheKey)?.refresh);
327
+ cache.delete(cacheKey);
213
328
  }
214
329
  };
215
330
 
@@ -269,6 +384,9 @@ export const jsonSafeStringify = (value, replacer, space) => {
269
384
  }
270
385
  };
271
386
 
387
+ export const jsonContentTypePattern =
388
+ /^application\/([a-z0-9.+-]+\+)?json(;|$)/i;
389
+
272
390
  export const decodeBody = (event) => {
273
391
  const { body, isBase64Encoded } = event;
274
392
  if (typeof body === "undefined" || body === null) return body;
@@ -304,7 +422,10 @@ export class HttpError extends Error {
304
422
  message ??= httpErrorCodes[code];
305
423
  super(message, options);
306
424
 
307
- const name = httpErrorCodes[code].replace(createErrorRegexp, "");
425
+ const name = (httpErrorCodes[code] ?? "Unknown").replace(
426
+ createErrorRegexp,
427
+ "",
428
+ );
308
429
  this.name = !name.endsWith("Error") ? `${name}Error` : name;
309
430
 
310
431
  this.status = this.statusCode = code; // setting `status` for backwards compatibility w/ `http-errors`
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@middy/util",
3
- "version": "7.2.2",
3
+ "version": "7.3.0",
4
4
  "description": "🛵 The stylish Node.js middleware engine for AWS Lambda (util package)",
5
5
  "type": "module",
6
6
  "engines": {
@@ -17,9 +17,6 @@
17
17
  "import": {
18
18
  "types": "./index.d.ts",
19
19
  "default": "./index.js"
20
- },
21
- "require": {
22
- "default": "./index.js"
23
20
  }
24
21
  }
25
22
  },
@@ -60,7 +57,7 @@
60
57
  },
61
58
  "devDependencies": {
62
59
  "@aws-sdk/client-ssm": "^3.0.0",
63
- "@middy/core": "7.2.2",
60
+ "@middy/core": "7.3.0",
64
61
  "@types/aws-lambda": "^8.0.0",
65
62
  "@types/node": "^22.0.0",
66
63
  "aws-xray-sdk": "^3.3.3"