UNPKG

@shirudo/base-error

Version:

A cross-environment base error class for TypeScript applications, designed for seamless use across Node.js, browsers, and edge runtimes.

638 lines (626 loc) 23 kB
'use strict'; var __typeError = (msg) => { throw TypeError(msg); }; var __accessCheck = (obj, member, msg) => member.has(obj) || __typeError("Cannot " + msg); var __privateGet = (obj, member, getter) => (__accessCheck(obj, member, "read from private field"), getter ? getter.call(obj) : member.get(obj)); var __privateAdd = (obj, member, value) => member.has(obj) ? __typeError("Cannot add the same private member more than once") : member instanceof WeakSet ? member.add(obj) : member.set(obj, value); var __privateSet = (obj, member, value, setter) => (__accessCheck(obj, member, "write to private field"), member.set(obj, value), value); var __privateMethod = (obj, member, method) => (__accessCheck(obj, member, "access private method"), method); // src/public-error/locale.ts function canonicalizeLocale(tag) { try { return Intl.getCanonicalLocales(tag)[0]; } catch { return void 0; } } function truncationChain(canonicalTag) { let parts = canonicalTag.split("-"); const chain = []; while (parts.length > 0) { chain.push(parts.join("-")); parts = parts.slice(0, -1); if (parts.length > 0 && parts[parts.length - 1].length === 1) { parts = parts.slice(0, -1); } } return chain; } // src/public-error/LocalizedMessageSet.ts var _messages; var LocalizedMessageSet = class { constructor(options) { __privateAdd(this, _messages); const baseLocale = canonicalizeOrThrow(options.baseLocale, "baseLocale"); const canonical = /* @__PURE__ */ new Map(); const originalKeyFor = /* @__PURE__ */ new Map(); for (const [rawKey, text] of Object.entries(options.messages)) { const key = canonicalizeOrThrow(rawKey, `messages key "${rawKey}"`); const prior = originalKeyFor.get(key); if (prior !== void 0) { throw new Error( `LocalizedMessageSet: keys "${prior}" and "${rawKey}" both canonicalize to "${key}".` ); } if (text.trim().length === 0) { throw new Error( `LocalizedMessageSet: message for "${rawKey}" is empty or whitespace-only.` ); } originalKeyFor.set(key, rawKey); canonical.set(key, text); } if (!canonical.has(baseLocale)) { throw new Error( `LocalizedMessageSet: no message for baseLocale "${baseLocale}".` ); } this.baseLocale = baseLocale; __privateSet(this, _messages, canonical); } /** * Whether an exact (canonical) entry exists for `locale`. No parent fallback. * An invalid tag is a miss. */ has(locale) { const key = canonicalizeLocale(locale); return key !== void 0 && __privateGet(this, _messages).has(key); } /** * The exact (canonical) message for `locale`, or `undefined`. No parent * fallback. An invalid tag yields `undefined`. */ get(locale) { const key = canonicalizeLocale(locale); return key === void 0 ? void 0 : __privateGet(this, _messages).get(key); } /** * Fast-path lookup for a key that is **already canonical**. Skips * canonicalization, so the caller is responsible for passing a canonical tag * (a non-canonical spelling misses). Used by the resolver, which already * canonicalizes once and walks canonical truncation tags; prefer {@link get} * for untrusted input. */ getCanonical(canonicalLocale) { return __privateGet(this, _messages).get(canonicalLocale); } /** A copy of the entries as `[canonicalLocale, message]` pairs. */ entries() { return [...__privateGet(this, _messages).entries()]; } }; _messages = new WeakMap(); function canonicalizeOrThrow(tag, label) { const canonical = canonicalizeLocale(tag); if (canonical === void 0) { throw new Error( `LocalizedMessageSet: invalid locale tag for ${label}: "${tag}".` ); } return canonical; } // src/public-error/LocaleResolver.ts function resolveUserMessage(set, options) { const locales = options?.locales ?? []; const seen = /* @__PURE__ */ new Set(); for (const [index, raw] of locales.entries()) { const canonical = canonicalizeLocale(raw); if (canonical === void 0) continue; for (const [depth, tag] of truncationChain(canonical).entries()) { if (seen.has(tag)) continue; seen.add(tag); const message = set.getCanonical(tag); if (message !== void 0) { return { locale: tag, message, matchedPreferenceIndex: index, match: depth === 0 ? "exact" : "parent" }; } } } const baseMessage = set.getCanonical(set.baseLocale); if (baseMessage !== void 0) { return { locale: set.baseLocale, message: baseMessage, match: "base" }; } throw new Error( `LocaleResolver: LocalizedMessageSet has no entry for its baseLocale "${set.baseLocale}".` ); } // src/utils/error-resolution.ts function readErrorCode(error) { if (typeof error === "object" && error !== null && "code" in error) { try { const code = error.code; if (typeof code === "string") return code; } catch { return void 0; } } return void 0; } function resolveByCodeThenPredicate(error, byCode, predicates) { const code = readErrorCode(error); if (code !== void 0) { const value = byCode.get(code); if (value !== void 0) { return { found: true, via: "code", value, matcherThrew: false }; } } let matcherThrew = false; for (const { match, value } of predicates) { try { if (match(error)) { return { found: true, via: "predicate", value, matcherThrew }; } } catch { matcherThrew = true; } } return { found: false, matcherThrew }; } // src/utils/problem-validation.ts var PROBLEM_DETAILS_JSON = "application/problem+json"; function isHttpStatusCode(value) { return typeof value === "number" && Number.isInteger(value) && value >= 100 && value <= 599; } function isNonEmptyString(value) { return typeof value === "string" && value.length > 0; } function isRetryAfterSeconds(value) { return typeof value === "number" && Number.isInteger(value) && value >= 0; } // src/public-error/PublicErrorCatalog.ts var _byCode, _predicates, _transportByPublicCode, _messagesByPublicCode, _onProject, _categories, _PublicErrorCatalog_instances, index_fn; var PublicErrorCatalog = class { constructor(options) { __privateAdd(this, _PublicErrorCatalog_instances); __privateAdd(this, _byCode, /* @__PURE__ */ new Map()); __privateAdd(this, _predicates, []); __privateAdd(this, _transportByPublicCode, /* @__PURE__ */ new Map()); __privateAdd(this, _messagesByPublicCode, /* @__PURE__ */ new Map()); __privateAdd(this, _onProject); __privateAdd(this, _categories); this.fallback = options.fallback; __privateSet(this, _onProject, options.onProject); __privateSet(this, _categories, options.categories !== void 0 ? new Set(options.categories) : void 0); __privateMethod(this, _PublicErrorCatalog_instances, index_fn).call(this, this.fallback); if (__privateGet(this, _categories) !== void 0 && this.fallback.category === void 0) { throw new Error( "PublicErrorCatalog: the fallback must declare a category when categories are declared; it is the bucket a client uses for codes it does not recognize." ); } } /** * Invokes the configured {@link OnProject} observer, swallowing any error so * telemetry can never break projection totality. Called by `project`. */ observeProjection(error, view, outcome) { if (__privateGet(this, _onProject) === void 0) return; try { __privateGet(this, _onProject).call(this, error, view, outcome); } catch { } } /** * Registers a descriptor keyed by an exact internal error `code`. Returns a * catalog widened with the new public code, so a chain of registrations * accumulates the public-code union for end-to-end typing. */ registerByCode(code, descriptor) { if (__privateGet(this, _byCode).has(code)) { throw new Error( `PublicErrorCatalog: code "${code}" is already registered.` ); } __privateGet(this, _byCode).set(code, descriptor); __privateMethod(this, _PublicErrorCatalog_instances, index_fn).call(this, descriptor); return this; } /** Registers a descriptor guarded by a type-guard matcher, tried after code matches. */ register(entry) { __privateGet(this, _predicates).push({ match: entry.match, value: entry.descriptor }); __privateMethod(this, _PublicErrorCatalog_instances, index_fn).call(this, entry.descriptor); return this; } /** Resolves the descriptor for `error`, or a miss. */ resolve(error) { const resolution = resolveByCodeThenPredicate( error, __privateGet(this, _byCode), __privateGet(this, _predicates) ); return resolution.found ? { found: true, via: resolution.via, descriptor: resolution.value, matcherThrew: resolution.matcherThrew } : { found: false, matcherThrew: resolution.matcherThrew }; } /** * The static wire metadata (status/type/title) for a registered public code, * or `undefined` if the code is not registered (including the fallback, which * is indexed at construction). An unknown code is a foreign/stale view that * must not be paired with this catalog's fallback status; the caller decides. */ transportFor(publicCode) { return __privateGet(this, _transportByPublicCode).get(publicCode); } /** The localized messages registered for a public code, if any. */ messagesFor(publicCode) { return __privateGet(this, _messagesByPublicCode).get(publicCode); } /** * Asserts that every code in `knownCodes` has a `registerByCode` descriptor. * An opt-in completeness check for the consumer's composition root. */ assertCoverage(knownCodes) { const missing = knownCodes.filter((code) => !__privateGet(this, _byCode).has(code)); if (missing.length > 0) { throw new Error( `PublicErrorCatalog: no descriptor registered for code(s): ${missing.join(", ")}.` ); } } }; _byCode = new WeakMap(); _predicates = new WeakMap(); _transportByPublicCode = new WeakMap(); _messagesByPublicCode = new WeakMap(); _onProject = new WeakMap(); _categories = new WeakMap(); _PublicErrorCatalog_instances = new WeakSet(); index_fn = function(descriptor) { if (!isNonEmptyString(descriptor.publicCode)) { throw new Error( "PublicErrorCatalog: descriptor has an empty or invalid publicCode." ); } if (!isHttpStatusCode(descriptor.status)) { throw new Error( `PublicErrorCatalog: descriptor "${descriptor.publicCode}" has an invalid status; expected an integer in [100, 599], got ${String(descriptor.status)}.` ); } if (descriptor.type !== void 0 && !isNonEmptyString(descriptor.type)) { throw new Error( `PublicErrorCatalog: descriptor "${descriptor.publicCode}" has an empty type.` ); } if (descriptor.category !== void 0) { if (!isNonEmptyString(descriptor.category)) { throw new Error( `PublicErrorCatalog: descriptor "${descriptor.publicCode}" has an empty category.` ); } if (__privateGet(this, _categories) !== void 0 && !__privateGet(this, _categories).has(descriptor.category)) { throw new Error( `PublicErrorCatalog: descriptor "${descriptor.publicCode}" uses category "${descriptor.category}" not in the declared categories.` ); } } const transport = { status: descriptor.status, ...descriptor.type !== void 0 && { type: descriptor.type }, ...descriptor.title !== void 0 && { title: descriptor.title }, ...descriptor.category !== void 0 && { category: descriptor.category } }; const prior = __privateGet(this, _transportByPublicCode).get(descriptor.publicCode); if (prior !== void 0 && (prior.status !== transport.status || prior.type !== transport.type || prior.title !== transport.title || prior.category !== transport.category)) { throw new Error( `PublicErrorCatalog: publicCode "${descriptor.publicCode}" mapped to conflicting transport (status, type, title, or category).` ); } __privateGet(this, _transportByPublicCode).set(descriptor.publicCode, transport); if (descriptor.userMessages !== void 0) { const priorMessages = __privateGet(this, _messagesByPublicCode).get( descriptor.publicCode ); if (priorMessages !== void 0 && !sameMessages(priorMessages, descriptor.userMessages)) { throw new Error( `PublicErrorCatalog: publicCode "${descriptor.publicCode}" mapped to conflicting userMessages.` ); } __privateGet(this, _messagesByPublicCode).set( descriptor.publicCode, descriptor.userMessages ); } }; function sameMessages(a, b) { if (a === b) return true; if (a.baseLocale !== b.baseLocale) return false; const aEntries = a.entries(); const bEntries = b.entries(); if (aEntries.length !== bEntries.length) return false; const bByLocale = new Map(bEntries); for (const [locale, message] of aEntries) { if (bByLocale.get(locale) !== message) return false; } return true; } function definePublicErrors(options) { return new PublicErrorCatalog(options); } // src/public-error/project.ts function project(catalog, error) { const resolution = catalog.resolve(error); const descriptor = resolution.found ? resolution.descriptor : catalog.fallback; const { view, projection } = projectCore(descriptor, error); const outcome = resolution.found ? { kind: "matched", via: resolution.via, projection } : { kind: "fallback", reason: resolution.matcherThrew ? "matcher_failed" : "no_match", projection }; catalog.observeProjection(error, view, outcome); return view; } function projectWithDescriptor(descriptor, error) { return projectCore(descriptor, error).view; } function projectCore(descriptor, error) { const retryable = resolveRetryable(descriptor, error); const retryAfter = resolveRetryAfter(descriptor, error); let failed = false; const onThrow = () => { failed = true; }; const details = descriptor.projectDetails === void 0 ? void 0 : safeCall(descriptor.projectDetails, error, onThrow); const fields = descriptor.projectFields === void 0 ? void 0 : safeFields(descriptor.projectFields, error, onThrow); const hasProjector = descriptor.projectDetails !== void 0 || descriptor.projectFields !== void 0; const projection = !hasProjector ? "none" : failed ? "failed" : "succeeded"; const view = Object.freeze({ code: descriptor.publicCode, ...descriptor.category !== void 0 && { category: descriptor.category }, ...retryable !== void 0 && { retryable }, ...retryAfter !== void 0 && { retryAfter }, ...details !== void 0 && { details }, ...fields !== void 0 && fields.length > 0 && { fields } }); return { view, projection }; } function resolveRetryable(descriptor, error) { if (descriptor.projectRetryable !== void 0) { try { const projected = descriptor.projectRetryable(error); if (typeof projected === "boolean") return projected; } catch { } } return descriptor.retryable; } function resolveRetryAfter(descriptor, error) { if (descriptor.projectRetryAfter === void 0) return void 0; try { const seconds = descriptor.projectRetryAfter(error); return isRetryAfterSeconds(seconds) ? seconds : void 0; } catch { return void 0; } } function safeCall(fn, error, onThrow) { try { return fn(error); } catch { onThrow(); return void 0; } } function safeFields(fn, error, onThrow) { try { const fields = fn(error); if (!Array.isArray(fields) || !fields.every(isFieldFault)) { onThrow(); return void 0; } return Object.freeze( fields.map( (fault) => Object.freeze({ field: fault.field, code: fault.code }) ) ); } catch { onThrow(); return void 0; } } function isFieldFault(value) { return typeof value === "object" && value !== null && typeof value.field === "string" && typeof value.code === "string"; } // src/public-error/localize.ts function localize(view, messages, options) { if (messages == null) { throw new TypeError( "localize: a LocalizedMessageSet is required. catalog.messagesFor(code) returns undefined for a public code with no userMessages; guard it and send the message-free view, or pass a fallback set." ); } const resolved = resolveUserMessage(messages, options); return Object.freeze({ ...view, message: resolved.message, locale: resolved.locale }); } // src/utils/json-safe.ts function isPlainObject(value) { if (typeof value !== "object" || value === null || Array.isArray(value)) { return false; } const prototype = Object.getPrototypeOf(value); return prototype === Object.prototype || prototype === null; } var MAX_CLONE_NODES = 1e5; function cloneJsonSafe(value) { return cloneInto(value, /* @__PURE__ */ new Set(), { nodes: 0 }); } function cloneInto(value, seen, state) { if (++state.nodes > MAX_CLONE_NODES) { throw new Error("value is not JSON-safe"); } if (value === null || typeof value === "string" || typeof value === "boolean") { return value; } if (typeof value === "number") { if (Number.isFinite(value)) return value; throw new Error("value is not JSON-safe"); } if (typeof value !== "object" || seen.has(value)) { throw new Error("value is not JSON-safe"); } seen.add(value); try { if (Array.isArray(value)) { for (let index = 0; index < value.length; index++) { if (!Object.prototype.hasOwnProperty.call(value, index)) { throw new Error("value is not JSON-safe"); } } return Object.freeze( value.map((item) => cloneInto(item, seen, state)) ); } if (!isPlainObject(value) || Object.getOwnPropertySymbols(value).length > 0) { throw new Error("value is not JSON-safe"); } const clone = /* @__PURE__ */ Object.create(null); for (const [key, item] of Object.entries(value)) { clone[key] = cloneInto(item, seen, state); } return Object.freeze(clone); } finally { seen.delete(value); } } // src/public-error/toProblem.ts var RESERVED_BODY_FIELDS = [ "type", "title", "status", "detail", "instance", "code", "category", "retryable", "retryAfter", "fields", "details" ]; var FORBIDDEN_EXTENSION_KEYS = /* @__PURE__ */ new Set([ ...RESERVED_BODY_FIELDS, "__proto__", "constructor", "prototype" ]); function toProblem(source, view, context) { if (!isNonEmptyString(view.code)) { throw new Error("toProblem: view.code must be a non-empty string."); } const transport = source instanceof PublicErrorCatalog ? transportOrThrow(source, view.code) : assertValidTransport(source); const localized = hasMessage(view) ? view : void 0; const omitted = []; const title = localized !== void 0 ? localized.message : transport.title; const retryAfter = isRetryAfterSeconds(context?.retryAfter) ? context.retryAfter : isRetryAfterSeconds(view.retryAfter) ? view.retryAfter : void 0; const details = jsonSafeOrOmit(view.details, "details", omitted); const rawFields = view.fields !== void 0 && view.fields.length > 0 ? view.fields : void 0; const fields = jsonSafeOrOmit(rawFields, "fields", omitted); const extensions = safeExtensions(context?.extensions, omitted); const body = Object.freeze( Object.assign(/* @__PURE__ */ Object.create(null), { // Extensions first: the reserved members below always win a collision. ...extensions, ...transport.type !== void 0 && { type: transport.type }, // typeof guard, not `!== undefined`: a cast/JSON-revived non-string // descriptor or transport `title` must not reach the wire body, the same // wire-safety the detail/instance guards below enforce. ...typeof title === "string" && { title }, status: transport.status, // The TS type already constrains these to strings; the runtime guard keeps // an untyped caller (an `as` cast, a value from JSON.parse) from writing a // non-string, non-RFC-9457 value straight onto the wire body. ...typeof context?.detail === "string" && { detail: context.detail }, ...typeof context?.instance === "string" && { instance: context.instance }, code: view.code, ...typeof view.category === "string" && { category: view.category }, ...typeof view.retryable === "boolean" && { retryable: view.retryable }, ...retryAfter !== void 0 && { retryAfter }, ...fields !== void 0 && { fields }, ...details !== void 0 && { details } }) ); const headers = Object.freeze({ "content-type": PROBLEM_DETAILS_JSON, ...localized !== void 0 && { "content-language": localized.locale }, ...retryAfter !== void 0 && { "retry-after": String(retryAfter) } }); const outcome = Object.freeze({ omitted: Object.freeze(omitted) }); return Object.freeze({ status: transport.status, headers, body, outcome }); } function jsonSafeOrOmit(value, member, omitted) { if (value === void 0) return void 0; try { return cloneJsonSafe(value); } catch { omitted.push(member); return void 0; } } function safeExtensions(raw, omitted) { if (raw === void 0) return void 0; try { if (typeof raw !== "object" || raw === null || Array.isArray(raw) || Reflect.ownKeys(raw).some( (key) => typeof key !== "string" || FORBIDDEN_EXTENSION_KEYS.has(key) )) { throw new Error("invalid extensions"); } return cloneJsonSafe(raw); } catch { omitted.push("extensions"); return void 0; } } function hasMessage(view) { const partial = view; return typeof partial.message === "string" && typeof partial.locale === "string"; } function transportOrThrow(catalog, publicCode) { const transport = catalog.transportFor(publicCode); if (transport === void 0) { throw new Error( `toProblem: public code "${publicCode}" is not registered in this catalog; pass an explicit transport for a foreign view.` ); } return transport; } function assertValidTransport(transport) { if (!isHttpStatusCode(transport.status)) { throw new Error( `toProblem: invalid transport status; expected an integer in [100, 599], got ${String(transport.status)}.` ); } if (transport.type !== void 0 && !isNonEmptyString(transport.type)) { throw new Error( "toProblem: invalid transport type; expected a non-empty string." ); } return transport; } exports.LocalizedMessageSet = LocalizedMessageSet; exports.PROBLEM_DETAILS_JSON = PROBLEM_DETAILS_JSON; exports.PublicErrorCatalog = PublicErrorCatalog; exports.definePublicErrors = definePublicErrors; exports.localize = localize; exports.project = project; exports.projectWithDescriptor = projectWithDescriptor; exports.resolveUserMessage = resolveUserMessage; exports.toProblem = toProblem; //# sourceMappingURL=index.cjs.map //# sourceMappingURL=index.cjs.map