@shirudo/base-error
Version:
A cross-environment base error class for TypeScript applications, designed for seamless use across Node.js, browsers, and edge runtimes.
628 lines (617 loc) • 22.8 kB
JavaScript
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;
}
export { LocalizedMessageSet, PROBLEM_DETAILS_JSON, PublicErrorCatalog, definePublicErrors, localize, project, projectWithDescriptor, resolveUserMessage, toProblem };
//# sourceMappingURL=index.js.map
//# sourceMappingURL=index.js.map