@middy/util
Version:
🛵 The stylish Node.js middleware engine for AWS Lambda (util package)
794 lines (740 loc) • 24.7 kB
JavaScript
// Copyright 2017 - 2026 will Farrell, Luciano Mammino, and Middy contributors.
// SPDX-License-Identifier: MIT
// Option validation helper.
// Schema values:
// 'string' | 'number' | 'integer' | 'boolean' | 'function' | 'object' | 'array'
// Trailing '?' marks the field as optional (may be undefined).
// (value) => boolean predicate - only called when value is not undefined
// (i.e. predicates treat the field as optional by design).
// { type: 'array' | 'array?', items: <itemSchema> }
// `items` is applied to each array element. It can be a type string,
// a predicate function, or a plain object treated as a per-element
// object schema (validated recursively with the same rules).
// { type: '<type>' | '<type>?', minimum?, maximum?, exclusiveMinimum?,
// exclusiveMaximum?, multipleOf?, minLength?, maxLength?, pattern? }
// Numeric: `minimum`/`maximum` (inclusive), `exclusiveMinimum`/
// `exclusiveMaximum` (exclusive), `multipleOf` (number/integer).
// String: `minLength`/`maxLength` (string length), `pattern` (regex
// source string per JSON Schema).
// { type: 'object' | 'object?', properties?: {...}, additionalProperties?: <rule> }
// `properties` validates known keys with the flat-schema form.
// `additionalProperties` validates every other key's value against the
// given rule (string, predicate, or nested object schema). Without it,
// unknown keys throw.
// { enum: [...values], type?: '<type>' | '<type>?' }
// Value must strict-equal one of the listed values. Optional by default;
// combine with `type` to require a specific type and/or presence.
// Keys in `options` (or nested objects) that are not in `schema` throw,
// catching typos.
const name = "util";
const pkg = `@middy/${name}`;
const validateOptionsTypeCheckers = {
string: (v) => typeof v === "string",
number: (v) => typeof v === "number" && !Number.isNaN(v),
integer: (v) => Number.isInteger(v),
boolean: (v) => typeof v === "boolean",
function: (v) => typeof v === "function",
object: (v) => v !== null && typeof v === "object" && !Array.isArray(v),
array: (v) => Array.isArray(v),
};
const isPlainObject = (v) =>
v !== null && typeof v === "object" && !Array.isArray(v);
const checkSchemaObject = (schema, options, path, fail) => {
if (!isPlainObject(options)) {
fail(
path ? `Option '${path}' must be object` : "options must be an object",
);
}
for (const key of Object.keys(options)) {
if (!Object.hasOwn(schema, key)) {
fail(`Unknown option '${path ? `${path}.${key}` : key}'`);
}
}
for (const key of Object.keys(schema)) {
const childPath = path ? `${path}.${key}` : key;
checkRule(schema[key], options[key], childPath, fail);
}
};
// Returns true if type check passed (and value is defined), false if the
// caller should stop validating (value was undefined and optional).
const checkTypeSpec = (rawType, value, path, fail) => {
const optional = rawType.endsWith("?");
const type = optional ? rawType.slice(0, -1) : rawType;
const checker = validateOptionsTypeCheckers[type];
if (!checker) fail(`Unknown schema type '${type}' for option '${path}'`);
if (value === undefined) {
if (!optional) fail(`Missing required option '${path}' (${type})`);
return false;
}
if (!checker(value)) fail(`Option '${path}' must be ${type}`);
return true;
};
// Plain object with no rule-marker key (`type`, `enum`, `oneOf`, `allOf`,
// `const`, `instanceof`) is a flat object schema; anything else is a rule.
// Used when dispatching `items` and `additionalProperties`.
const checkNestedRule = (rule, value, path, fail) => {
if (
isPlainObject(rule) &&
typeof rule.type !== "string" &&
!Array.isArray(rule.enum) &&
!Array.isArray(rule.oneOf) &&
!Array.isArray(rule.allOf) &&
!Object.hasOwn(rule, "const") &&
typeof rule.instanceof !== "string"
) {
checkSchemaObject(rule, value, path, fail);
} else {
checkRule(rule, value, path, fail);
}
};
const childPathOf = (path, key) => (path ? `${path}.${key}` : key);
// Stable JSON form: recursively sorts object keys, skips function-typed
// values. Used for `uniqueItems` so items that differ only by handler
// identity or key ordering collide.
const stableStringify = (value) => {
if (value === null || typeof value !== "object") return JSON.stringify(value);
if (Array.isArray(value)) {
return `[${value.map(stableStringify).join(",")}]`;
}
const keys = Object.keys(value)
.filter((k) => typeof value[k] !== "function")
.sort();
return `{${keys.map((k) => `${JSON.stringify(k)}:${stableStringify(value[k])}`).join(",")}}`;
};
const resolveInstance = (name) => {
const ctor = globalThis[name];
if (typeof ctor !== "function") {
throw new Error(`Unknown 'instanceof' class '${name}'`);
}
return ctor;
};
const checkRule = (rule, value, path, fail) => {
if (typeof rule === "function") {
if (value !== undefined && !rule(value)) {
fail(`Invalid option '${path}'`);
}
return;
}
if (typeof rule === "string") {
checkTypeSpec(rule, value, path, fail);
return;
}
if (isPlainObject(rule) && Object.hasOwn(rule, "const")) {
if (value === undefined) return;
if (value !== rule.const) {
fail(`Option '${path}' must equal ${JSON.stringify(rule.const)}`);
}
return;
}
if (isPlainObject(rule) && Array.isArray(rule.allOf)) {
if (value === undefined) return;
for (const sub of rule.allOf) {
checkRule(sub, value, path, fail);
}
return;
}
if (isPlainObject(rule) && Array.isArray(rule.oneOf)) {
if (value === undefined) return;
let matches = 0;
for (const sub of rule.oneOf) {
try {
checkRule(sub, value, path, (msg) => {
throw new TypeError(msg);
});
matches++;
} catch {}
}
if (matches !== 1) {
fail(`Option '${path}' must match exactly one schema in oneOf`);
}
return;
}
if (isPlainObject(rule) && typeof rule.instanceof === "string") {
if (value === undefined) return;
const ctor = resolveInstance(rule.instanceof);
if (!(value instanceof ctor)) {
fail(`Option '${path}' must be instanceof ${rule.instanceof}`);
}
return;
}
if (isPlainObject(rule) && Array.isArray(rule.enum)) {
if (typeof rule.type === "string") {
if (!checkTypeSpec(rule.type, value, path, fail)) return;
} else if (value === undefined) {
return;
}
if (!rule.enum.includes(value)) {
fail(`Option '${path}' must be one of ${JSON.stringify(rule.enum)}`);
}
return;
}
if (isPlainObject(rule) && typeof rule.type === "string") {
const {
type: rawType,
items,
uniqueItems,
properties,
required,
additionalProperties,
minimum,
maximum,
exclusiveMinimum,
exclusiveMaximum,
multipleOf,
pattern,
minLength,
maxLength,
} = rule;
if (!checkTypeSpec(rawType, value, path, fail)) return;
const type = rawType.endsWith("?") ? rawType.slice(0, -1) : rawType;
if (minimum !== undefined && value < minimum) {
fail(`Option '${path}' must be >= ${minimum}`);
}
if (maximum !== undefined && value > maximum) {
fail(`Option '${path}' must be <= ${maximum}`);
}
if (exclusiveMinimum !== undefined && value <= exclusiveMinimum) {
fail(`Option '${path}' must be > ${exclusiveMinimum}`);
}
if (exclusiveMaximum !== undefined && value >= exclusiveMaximum) {
fail(`Option '${path}' must be < ${exclusiveMaximum}`);
}
if (multipleOf !== undefined) {
const quotient = value / multipleOf;
if (!Number.isFinite(quotient) || Math.floor(quotient) !== quotient) {
fail(`Option '${path}' must be a multiple of ${multipleOf}`);
}
}
if (pattern !== undefined && value.match(pattern) === null) {
fail(`Option '${path}' must match pattern ${pattern}`);
}
if (minLength !== undefined && value.length < minLength) {
fail(`Option '${path}' must have length >= ${minLength}`);
}
if (maxLength !== undefined && value.length > maxLength) {
fail(`Option '${path}' must have length <= ${maxLength}`);
}
if (type === "array" && items !== undefined) {
for (let i = 0; i < value.length; i++) {
checkNestedRule(items, value[i], `${path}[${i}]`, fail);
}
}
if (type === "array" && uniqueItems === true) {
const seen = new Set();
for (let i = 0; i < value.length; i++) {
const key = stableStringify(value[i]);
if (seen.has(key)) {
fail(`Duplicate item at '${path}[${i}]'`);
}
seen.add(key);
}
}
if (type === "object" && Array.isArray(required)) {
for (const key of required) {
if (value[key] === undefined) {
fail(`Missing required option '${childPathOf(path, key)}'`);
}
}
}
if (
type === "object" &&
(properties || additionalProperties !== undefined)
) {
for (const key of Object.keys(value)) {
if (properties && Object.hasOwn(properties, key)) continue;
if (
additionalProperties === undefined ||
additionalProperties === false
) {
fail(`Unknown option '${childPathOf(path, key)}'`);
}
if (additionalProperties === true) continue;
checkNestedRule(
additionalProperties,
value[key],
childPathOf(path, key),
fail,
);
}
if (properties) {
for (const key of Object.keys(properties)) {
if (value[key] === undefined) continue;
checkRule(properties[key], value[key], childPathOf(path, key), fail);
}
}
}
return;
}
fail(`Invalid schema for option '${path}'`);
};
const isJsonSchemaForm = (schema) =>
isPlainObject(schema) &&
schema.type === "object" &&
(Object.hasOwn(schema, "properties") ||
Object.hasOwn(schema, "required") ||
Object.hasOwn(schema, "additionalProperties"));
export const validateOptions = (packageName, schema, options = {}) => {
const fail = (message) => {
throw new TypeError(message, { cause: { package: packageName } });
};
if (isJsonSchemaForm(schema)) {
checkRule(schema, options, "", fail);
} else {
checkSchemaObject(schema, options, "", fail);
}
return options;
};
export const createPrefetchClient = (options) => {
const { awsClientOptions } = options;
const client = new options.AwsClient(awsClientOptions);
// AWS XRay
if (options.awsClientCapture) {
if (options.disablePrefetch) {
return options.awsClientCapture(client);
}
console.warn("Unable to apply X-Ray outside of handler invocation scope.");
}
return client;
};
export const createClient = async (options, request) => {
let awsClientCredentials = {};
// Role Credentials
if (options.awsClientAssumeRole) {
if (!request) {
throw new Error("Request required when assuming role", {
cause: { package: pkg },
});
}
awsClientCredentials = await getInternal(
{ credentials: options.awsClientAssumeRole },
request,
);
}
awsClientCredentials = {
...awsClientCredentials,
...options.awsClientOptions,
};
return createPrefetchClient({
...options,
awsClientOptions: awsClientCredentials,
});
};
export const canPrefetch = (options = {}) => {
return !options.awsClientAssumeRole && !options.disablePrefetch;
};
const safeGet = (obj, key) =>
obj != null && Object.hasOwn(obj, key) ? obj[key] : undefined;
// Internal Context
export const getInternal = async (variables, request) => {
if (!variables || !request) return Object.create(null);
let keys = [];
let values = [];
if (variables === true) {
keys = values = Object.keys(request.internal);
} else if (typeof variables === "string") {
keys = values = [variables];
} else if (Array.isArray(variables)) {
keys = values = variables;
} else if (typeof variables === "object") {
keys = Object.keys(variables);
values = Object.values(variables);
}
// Fast synchronous path: when all internal values are already resolved
// (warm/cached invocations), skip all Promise machinery entirely
let allSync = true;
const syncResults = new Array(values.length);
for (let i = 0; i < values.length; i++) {
const internalKey = values[i];
const dotIndex = internalKey.indexOf(".");
const rootKey =
dotIndex === -1 ? internalKey : internalKey.substring(0, dotIndex);
let value = request.internal[rootKey];
if (isPromise(value)) {
allSync = false;
break;
}
if (dotIndex !== -1) {
for (const part of internalKey.substring(dotIndex + 1).split(".")) {
value = safeGet(value, part);
}
}
syncResults[i] = value;
}
if (allSync) {
const obj = Object.create(null);
for (let i = 0; i < keys.length; i++) {
obj[sanitizeKey(keys[i])] = syncResults[i];
}
return obj;
}
// Async fallback: for cold/first invocations with pending promises
const promises = [];
for (const internalKey of values) {
// 'internal.key.sub_value' -> { [key]: internal.key.sub_value }
const pathOptionKey = internalKey.split(".");
const rootOptionKey = pathOptionKey.shift();
let valuePromise = request.internal[rootOptionKey];
if (!isPromise(valuePromise)) {
valuePromise = Promise.resolve(valuePromise);
}
promises.push(
valuePromise.then((value) => pathOptionKey.reduce(safeGet, value)),
);
}
// ensure promise has resolved by the time it's needed
// If one of the promises throws it will bubble up to @middy/core
values = await Promise.allSettled(promises);
const obj = Object.create(null);
let errors;
for (let i = 0; i < keys.length; i++) {
if (values[i].status === "rejected") {
errors ??= [];
errors.push(values[i].reason);
} else {
obj[sanitizeKey(keys[i])] = values[i].value;
}
}
if (errors) {
throw new Error("Failed to resolve internal values", {
cause: { package: pkg, data: errors },
});
}
return obj;
};
const isPromise = (promise) => typeof promise?.then === "function";
const sanitizeKeyPrefixLeadingNumber = /^([0-9])/;
const sanitizeKeyRemoveDisallowedChar = /[^a-zA-Z0-9]+/g;
export const sanitizeKey = (key) => {
return key
.replace(sanitizeKeyPrefixLeadingNumber, "_$1")
.replace(sanitizeKeyRemoveDisallowedChar, "_");
};
// setToContext fast-path
//
// Many middlewares (kms/ssm/secrets-manager/dynamodb/s3/sts/…) follow the
// same pattern: after `processCache` resolves `value`, when `setToContext`
// is set they re-`await getInternal(fetchDataKeys, request)` solely to copy
// the same values to `request.context` under sanitized key names. On warm
// invocations every entry in `value` is already resolved, so the extra
// await + per-key regex + null-prototype-object allocation in `getInternal`
// is dead work.
//
// `buildSetToContextSpec(options)` is called once at factory time and
// returns either `null` (when `setToContext` is false) or the precomputed
// `[[originalKey, sanitizedKey], …]` pairs.
//
// `assignSetToContext(spec, value, request)` is called once per invocation.
// Returns `undefined` synchronously when all entries are resolved (the
// common warm path), or a Promise when at least one is still pending. The
// caller should `if (p) await p` so the sync path keeps zero microtask hops.
export const buildSetToContextSpec = (options) =>
options.setToContext
? Object.keys(options.fetchData).map((k) => [k, sanitizeKey(k)])
: null;
export const assignSetToContext = (spec, value, request) => {
for (let i = 0; i < spec.length; i++) {
const v = value[spec[i][0]];
if (v !== null && typeof v?.then === "function") {
// Cold path: at least one value still pending; defer to
// `getInternal` for the standard await+sanitize+assign flow.
const keys = new Array(spec.length);
for (let j = 0; j < spec.length; j++) keys[j] = spec[j][0];
return getInternal(keys, request).then((data) => {
Object.assign(request.context, data);
});
}
}
const ctx = request.context;
for (let i = 0; i < spec.length; i++) {
ctx[spec[i][1]] = value[spec[i][0]];
}
};
// fetch Cache
// Map keyed by cacheKey; value shape: { value:{fetchKey:Promise}, expiry, refresh?, modified? }
// Map chosen over plain object so deletion is O(1), frees the key slot, and
// avoids the `delete` operator (biome's performance/noDelete rule).
const cache = new Map();
const defaultCacheMaxSize = 128;
const validateCacheExpiry = (cacheExpiry) => {
if (
typeof cacheExpiry === "number" &&
cacheExpiry < -1 &&
!Number.isNaN(cacheExpiry)
) {
throw new Error(
`Invalid cacheExpiry value: ${cacheExpiry}. Must be -1 (infinite), 0 (disabled), or a positive number (ms duration or unix timestamp)`,
{ cause: { package: pkg } },
);
}
};
export const processCache = (
options,
middlewareFetch = () => undefined,
middlewareFetchRequest = {},
) => {
let { cacheKey, cacheKeyExpiry, cacheExpiry, cacheMaxSize } = options;
cacheMaxSize ??= defaultCacheMaxSize;
cacheExpiry = cacheKeyExpiry?.[cacheKey] ?? cacheExpiry;
validateCacheExpiry(cacheExpiry);
const now = Date.now();
if (cacheExpiry) {
const cached = getCache(cacheKey);
const unexpired = cached.expiry && (cacheExpiry < 0 || cached.expiry > now);
if (unexpired) {
if (cached.modified) {
const value = middlewareFetch(middlewareFetchRequest, cached.value);
Object.assign(cached.value, value);
const entry = { value: cached.value, expiry: cached.expiry };
cache.set(cacheKey, entry);
return entry;
}
cached.cache = true;
return cached;
}
}
const value = middlewareFetch(middlewareFetchRequest);
// cacheExpiry semantics:
// >86400000 (24h): treated as unix timestamp (ms)
// >0 && <=86400000: treated as duration (ms) from now
// -1: infinite cache (never expires)
// 0/undefined/null: no caching
const expiry = cacheExpiry > 86400000 ? cacheExpiry : now + cacheExpiry;
const duration = cacheExpiry > 86400000 ? cacheExpiry - now : cacheExpiry;
if (cacheExpiry) {
clearTimeout(cache.get(cacheKey)?.refresh);
// .unref() so a pending refresh timer does not keep the Lambda event
// loop alive (relevant under `callbackWaitsForEmptyEventLoop: false`).
const refresh =
duration > 0
? setTimeout(
() =>
processCache(options, middlewareFetch, middlewareFetchRequest),
duration,
).unref()
: undefined;
cache.set(cacheKey, { value, expiry, refresh });
evictCache(cacheMaxSize);
}
return { value, expiry };
};
export const catchInvalidSignatureException = (e, client, command) => {
if (e.__type === "InvalidSignatureException") {
return client.send(command);
}
throw e;
};
export const getCache = (key) => {
return cache.get(key) ?? {};
};
// Used to remove parts of a cache
export const modifyCache = (cacheKey, value) => {
const entry = cache.get(cacheKey);
if (!entry) return;
clearTimeout(entry.refresh);
entry.value = value;
entry.modified = true;
};
const evictCache = (maxSize) => {
if (cache.size <= maxSize) return;
let oldestKey = null;
let oldestExpiry = Infinity;
for (const [key, entry] of cache) {
if (entry && entry.expiry < oldestExpiry) {
oldestExpiry = entry.expiry;
oldestKey = key;
}
}
if (oldestKey !== null) {
clearTimeout(cache.get(oldestKey)?.refresh);
cache.delete(oldestKey);
}
};
export const clearCache = (inputKeys = null) => {
let keys = inputKeys;
keys ??= [...cache.keys()];
if (!Array.isArray(keys)) {
keys = [keys];
}
for (const cacheKey of keys) {
clearTimeout(cache.get(cacheKey)?.refresh);
cache.delete(cacheKey);
}
};
// context
// https://docs.aws.amazon.com/lambda/latest/dg/nodejs-context.html
export const lambdaContextKeys = [
"functionName",
"functionVersion",
"invokedFunctionArn",
"memoryLimitInMB",
"awsRequestId",
"logGroupName",
"logStreamName",
"identity",
"clientContext",
"callbackWaitsForEmptyEventLoop",
];
export const executionContextKeys = ["tenantId"];
export const isExecutionModeDurable = (context) => {
// using `context instanceof DurableContextImpl` would be better
// but would require an extra dependency
return context.constructor.name === "DurableContextImpl";
};
export const executionContext = (request, key, context) => {
if (isExecutionModeDurable(context)) {
return request.context.executionContext[key];
}
return request.context[key];
};
export const lambdaContext = (request, key, context) => {
if (isExecutionModeDurable(context)) {
return request.context.lambdaContext[key];
}
return request.context[key];
};
export const jsonSafeParse = (text, reviver) => {
if (typeof text !== "string") return text;
const firstChar = text[0];
if (firstChar !== "{" && firstChar !== "[" && firstChar !== '"') return text;
try {
return JSON.parse(text, reviver);
} catch {
return text;
}
};
// Cheap structural-JSON heuristic: returns true if `text` starts with `{`
// or `[`, indicating a JSON object/array body. Use as a Content-Type
// guard where:
// - `{...}` / `[...]` → `application/json`
// - everything else → `text/plain`
// Deliberately excludes leading `"`: a JSON string `"hi"` parses to a JS
// string, which callers consistently treat as `text/plain`. Avoids running
// a full JSON.parse just to inspect the result's type.
export const isJsonStructured = (text) => {
if (typeof text !== "string") return false;
const c = text.charCodeAt(0);
return c === 123 || c === 91; // 123='{' 91='['
};
export const jsonSafeStringify = (value, replacer, space) => {
try {
return JSON.stringify(value, replacer, space);
} catch {
return value;
}
};
export const jsonContentTypePattern =
/^application\/([a-z0-9.+-]+\+)?json(;|$)/i;
// Decode a request body, transparently handling base64-encoded payloads.
// Takes `body` and `isBase64Encoded` directly so callers (which already
// destructure them from `request.event`) don't pay for a second destructure
// inside this helper. Returns `body` unchanged when it's nullish so callers
// can decide whether absence is an error.
export const decodeBody = (body, isBase64Encoded) => {
if (body == null) return body;
return isBase64Encoded ? Buffer.from(body, "base64").toString() : body;
};
export const normalizeHttpResponse = (request) => {
let { response } = request;
if (typeof response === "undefined") {
response = {};
} else if (
typeof response?.statusCode === "undefined" &&
typeof response?.body === "undefined" &&
typeof response?.headers === "undefined"
) {
response = { statusCode: 200, body: response };
}
response.statusCode ??= 500;
response.headers ??= {};
request.response = response;
return response;
};
const createErrorRegexp = /[^a-zA-Z]/g;
export class HttpError extends Error {
constructor(code, optionalMessage, optionalOptions = {}) {
let message = optionalMessage;
let options = optionalOptions;
if (message && typeof message !== "string") {
options = message;
message = undefined;
}
message ??= httpErrorCodes[code];
super(message, options);
const name = (httpErrorCodes[code] ?? "Unknown").replace(
createErrorRegexp,
"",
);
this.name = !name.endsWith("Error") ? `${name}Error` : name;
this.status = this.statusCode = code; // setting `status` for backwards compatibility w/ `http-errors`
this.expose = options.expose ?? code < 500;
}
}
export const createError = (code, message, properties = {}) => {
return new HttpError(code, message, properties);
};
export const httpErrorCodes = {
100: "Continue",
101: "Switching Protocols",
102: "Processing",
103: "Early Hints",
200: "OK",
201: "Created",
202: "Accepted",
203: "Non-Authoritative Information",
204: "No Content",
205: "Reset Content",
206: "Partial Content",
207: "Multi-Status",
208: "Already Reported",
226: "IM Used",
300: "Multiple Choices",
301: "Moved Permanently",
302: "Found",
303: "See Other",
304: "Not Modified",
305: "Use Proxy",
306: "(Unused)",
307: "Temporary Redirect",
308: "Permanent Redirect",
400: "Bad Request",
401: "Unauthorized",
402: "Payment Required",
403: "Forbidden",
404: "Not Found",
405: "Method Not Allowed",
406: "Not Acceptable",
407: "Proxy Authentication Required",
408: "Request Timeout",
409: "Conflict",
410: "Gone",
411: "Length Required",
412: "Precondition Failed",
413: "Payload Too Large",
414: "URI Too Long",
415: "Unsupported Media Type",
416: "Range Not Satisfiable",
417: "Expectation Failed",
418: "I'm a teapot",
421: "Misdirected Request",
422: "Unprocessable Entity",
423: "Locked",
424: "Failed Dependency",
425: "Unordered Collection",
426: "Upgrade Required",
428: "Precondition Required",
429: "Too Many Requests",
431: "Request Header Fields Too Large",
451: "Unavailable For Legal Reasons",
500: "Internal Server Error",
501: "Not Implemented",
502: "Bad Gateway",
503: "Service Unavailable",
504: "Gateway Timeout",
505: "HTTP Version Not Supported",
506: "Variant Also Negotiates",
507: "Insufficient Storage",
508: "Loop Detected",
509: "Bandwidth Limit Exceeded",
510: "Not Extended",
511: "Network Authentication Required",
};