@yedoma-labs/ysuly-problem 0.0.0-stage → 1.0.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.
- package/LICENSE +21 -0
- package/README.md +172 -2
- package/dist/index.cjs +351 -0
- package/dist/index.cjs.map +1 -0
- package/dist/index.d.cts +129 -0
- package/dist/index.d.ts +129 -0
- package/dist/index.js +340 -0
- package/dist/index.js.map +1 -0
- package/package.json +81 -3
package/dist/index.d.cts
ADDED
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
/** Content-Type for RFC 9457 problem documents. */
|
|
2
|
+
export declare const PROBLEM_CONTENT_TYPE = "application/problem+json; charset=utf-8";
|
|
3
|
+
/** Default problem `type` as defined by RFC 9457. */
|
|
4
|
+
export declare const DEFAULT_PROBLEM_TYPE = "about:blank";
|
|
5
|
+
export declare const TITLE_MAX_LENGTH = 200;
|
|
6
|
+
export declare const DETAIL_MAX_LENGTH = 1e3;
|
|
7
|
+
/**
|
|
8
|
+
* An RFC 9457 problem document. Extension members are flattened next to the
|
|
9
|
+
* standard members, as the specification requires.
|
|
10
|
+
*/
|
|
11
|
+
interface ProblemDetails {
|
|
12
|
+
type?: string;
|
|
13
|
+
title: string;
|
|
14
|
+
status: number;
|
|
15
|
+
detail?: string;
|
|
16
|
+
instance?: string;
|
|
17
|
+
[extension: string]: unknown;
|
|
18
|
+
}
|
|
19
|
+
/**
|
|
20
|
+
* Input for `problem()`. Extensions are passed separately so that they can be
|
|
21
|
+
* checked against the reserved members before a document is built.
|
|
22
|
+
*/
|
|
23
|
+
interface ProblemInput {
|
|
24
|
+
type?: string;
|
|
25
|
+
title: string;
|
|
26
|
+
status: number;
|
|
27
|
+
detail?: string;
|
|
28
|
+
instance?: string;
|
|
29
|
+
extensions?: Readonly<Record<string, unknown>>;
|
|
30
|
+
}
|
|
31
|
+
type ProblemErrorCode = "INVALID_PROBLEM";
|
|
32
|
+
/** Thrown when problem details cannot be built or are invalid. Carries no input values. */
|
|
33
|
+
export declare class ProblemError extends Error {
|
|
34
|
+
readonly code: ProblemErrorCode;
|
|
35
|
+
readonly issues: readonly string[];
|
|
36
|
+
constructor(issues: readonly string[]);
|
|
37
|
+
}
|
|
38
|
+
/**
|
|
39
|
+
* Builds a validated problem document. Text fields are sanitized and length
|
|
40
|
+
* limited; invalid input throws a `ProblemError` with code `INVALID_PROBLEM`.
|
|
41
|
+
*/
|
|
42
|
+
export declare function problem(input: ProblemInput): ProblemDetails;
|
|
43
|
+
/**
|
|
44
|
+
* Type guard for problem documents: `title` is a non-empty string, `status` is
|
|
45
|
+
* an integer between 100 and 599, and `type`, `detail`, `instance` are strings
|
|
46
|
+
* when present. Extension members are not inspected.
|
|
47
|
+
*/
|
|
48
|
+
export declare function isProblemDetails(value: unknown): value is ProblemDetails;
|
|
49
|
+
/**
|
|
50
|
+
* Reads a problem document from a `Response`. Returns `undefined` unless the
|
|
51
|
+
* media type is `application/problem+json` or `application/json` and the body is
|
|
52
|
+
* a valid problem. Bodies over 64 KiB, malformed JSON and read errors also
|
|
53
|
+
* yield `undefined`; this function never throws.
|
|
54
|
+
*
|
|
55
|
+
* The response body is consumed. Call `response.clone()` first if it is needed later.
|
|
56
|
+
*/
|
|
57
|
+
export declare function parseProblem(response: Response): Promise<ProblemDetails | undefined>;
|
|
58
|
+
/** A problem definition that can be reused for errors of a known kind. */
|
|
59
|
+
interface ProblemTemplate {
|
|
60
|
+
type?: string;
|
|
61
|
+
title: string;
|
|
62
|
+
status: number;
|
|
63
|
+
detail?: string;
|
|
64
|
+
}
|
|
65
|
+
type ProblemTemplates = Readonly<Record<string, ProblemTemplate>>;
|
|
66
|
+
/**
|
|
67
|
+
* How registry keys are matched against errors:
|
|
68
|
+
* - `code`: `error.code === key` (default, survives minification)
|
|
69
|
+
* - `className`: `error.constructor.name === key`
|
|
70
|
+
* - `both`: either of the two
|
|
71
|
+
*/
|
|
72
|
+
type MatchBy = "code" | "className" | "both";
|
|
73
|
+
interface DefineProblemsOptions {
|
|
74
|
+
by?: MatchBy;
|
|
75
|
+
}
|
|
76
|
+
interface ProblemRegistry<T extends ProblemTemplates = ProblemTemplates> {
|
|
77
|
+
readonly templates: T;
|
|
78
|
+
readonly by: MatchBy;
|
|
79
|
+
}
|
|
80
|
+
/**
|
|
81
|
+
* Defines named problem templates for `fromError`. Every template is validated
|
|
82
|
+
* immediately, so invalid definitions fail at startup instead of per request.
|
|
83
|
+
*
|
|
84
|
+
* @example
|
|
85
|
+
* const problems = defineProblems({
|
|
86
|
+
* NotFound: { status: 404, title: "Not found", type: "https://example.com/problems/not-found" },
|
|
87
|
+
* });
|
|
88
|
+
* fromError(error, { registry: problems });
|
|
89
|
+
*/
|
|
90
|
+
export declare function defineProblems<const T extends ProblemTemplates>(templates: T, options?: DefineProblemsOptions): ProblemRegistry<T>;
|
|
91
|
+
/** A class used as a registry key in `fromError`. Matched with `instanceof`. */
|
|
92
|
+
type ErrorClass = abstract new (...args: never[]) => unknown;
|
|
93
|
+
interface FromErrorOptions {
|
|
94
|
+
/**
|
|
95
|
+
* Opt-in for unknown errors. Only when this returns `true` is the error
|
|
96
|
+
* message copied into `detail` (sanitized and length limited).
|
|
97
|
+
*/
|
|
98
|
+
expose?: (error: unknown) => boolean;
|
|
99
|
+
/**
|
|
100
|
+
* Known errors. A `ProblemRegistry` from `defineProblems`, or a `Map` whose
|
|
101
|
+
* string keys match `error.code` and whose class keys match with `instanceof`.
|
|
102
|
+
* The first matching entry wins. Matched errors never expose their message.
|
|
103
|
+
*/
|
|
104
|
+
registry?: ProblemRegistry | ReadonlyMap<ErrorClass | string, ProblemTemplate>;
|
|
105
|
+
/** Template for unknown errors. Defaults to status 500 and a generic title. */
|
|
106
|
+
fallback?: ProblemTemplate;
|
|
107
|
+
/** Request identifier. When set, `instance` becomes `urn:request:<id>`. */
|
|
108
|
+
instanceFromRequestId?: string;
|
|
109
|
+
}
|
|
110
|
+
/**
|
|
111
|
+
* Maps any thrown value to a problem document without leaking internals.
|
|
112
|
+
*
|
|
113
|
+
* Unknown errors produce only the fallback (by default status 500 and a generic
|
|
114
|
+
* title). Their `message`, `stack`, `cause` and `name` are never included unless
|
|
115
|
+
* `expose(error)` returns `true`.
|
|
116
|
+
*/
|
|
117
|
+
export declare function fromError(error: unknown, options?: FromErrorOptions): ProblemDetails;
|
|
118
|
+
interface ToResponseOptions {
|
|
119
|
+
/** Extra headers. The Content-Type is always set to the problem media type. */
|
|
120
|
+
headers?: HeadersInit;
|
|
121
|
+
}
|
|
122
|
+
/**
|
|
123
|
+
* Creates a Web-standard `Response` carrying the problem as
|
|
124
|
+
* `application/problem+json; charset=utf-8`. The problem is validated and
|
|
125
|
+
* sanitized again before it is serialized.
|
|
126
|
+
*/
|
|
127
|
+
export declare function toResponse(problem: ProblemDetails, options?: ToResponseOptions): Response;
|
|
128
|
+
export type { DefineProblemsOptions, ErrorClass, FromErrorOptions, MatchBy, ProblemDetails, ProblemErrorCode, ProblemInput, ProblemRegistry, ProblemTemplate, ProblemTemplates, ToResponseOptions };
|
|
129
|
+
//# sourceMappingURL=index.d.cts.map
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
/** Content-Type for RFC 9457 problem documents. */
|
|
2
|
+
export declare const PROBLEM_CONTENT_TYPE = "application/problem+json; charset=utf-8";
|
|
3
|
+
/** Default problem `type` as defined by RFC 9457. */
|
|
4
|
+
export declare const DEFAULT_PROBLEM_TYPE = "about:blank";
|
|
5
|
+
export declare const TITLE_MAX_LENGTH = 200;
|
|
6
|
+
export declare const DETAIL_MAX_LENGTH = 1e3;
|
|
7
|
+
/**
|
|
8
|
+
* An RFC 9457 problem document. Extension members are flattened next to the
|
|
9
|
+
* standard members, as the specification requires.
|
|
10
|
+
*/
|
|
11
|
+
interface ProblemDetails {
|
|
12
|
+
type?: string;
|
|
13
|
+
title: string;
|
|
14
|
+
status: number;
|
|
15
|
+
detail?: string;
|
|
16
|
+
instance?: string;
|
|
17
|
+
[extension: string]: unknown;
|
|
18
|
+
}
|
|
19
|
+
/**
|
|
20
|
+
* Input for `problem()`. Extensions are passed separately so that they can be
|
|
21
|
+
* checked against the reserved members before a document is built.
|
|
22
|
+
*/
|
|
23
|
+
interface ProblemInput {
|
|
24
|
+
type?: string;
|
|
25
|
+
title: string;
|
|
26
|
+
status: number;
|
|
27
|
+
detail?: string;
|
|
28
|
+
instance?: string;
|
|
29
|
+
extensions?: Readonly<Record<string, unknown>>;
|
|
30
|
+
}
|
|
31
|
+
type ProblemErrorCode = "INVALID_PROBLEM";
|
|
32
|
+
/** Thrown when problem details cannot be built or are invalid. Carries no input values. */
|
|
33
|
+
export declare class ProblemError extends Error {
|
|
34
|
+
readonly code: ProblemErrorCode;
|
|
35
|
+
readonly issues: readonly string[];
|
|
36
|
+
constructor(issues: readonly string[]);
|
|
37
|
+
}
|
|
38
|
+
/**
|
|
39
|
+
* Builds a validated problem document. Text fields are sanitized and length
|
|
40
|
+
* limited; invalid input throws a `ProblemError` with code `INVALID_PROBLEM`.
|
|
41
|
+
*/
|
|
42
|
+
export declare function problem(input: ProblemInput): ProblemDetails;
|
|
43
|
+
/**
|
|
44
|
+
* Type guard for problem documents: `title` is a non-empty string, `status` is
|
|
45
|
+
* an integer between 100 and 599, and `type`, `detail`, `instance` are strings
|
|
46
|
+
* when present. Extension members are not inspected.
|
|
47
|
+
*/
|
|
48
|
+
export declare function isProblemDetails(value: unknown): value is ProblemDetails;
|
|
49
|
+
/**
|
|
50
|
+
* Reads a problem document from a `Response`. Returns `undefined` unless the
|
|
51
|
+
* media type is `application/problem+json` or `application/json` and the body is
|
|
52
|
+
* a valid problem. Bodies over 64 KiB, malformed JSON and read errors also
|
|
53
|
+
* yield `undefined`; this function never throws.
|
|
54
|
+
*
|
|
55
|
+
* The response body is consumed. Call `response.clone()` first if it is needed later.
|
|
56
|
+
*/
|
|
57
|
+
export declare function parseProblem(response: Response): Promise<ProblemDetails | undefined>;
|
|
58
|
+
/** A problem definition that can be reused for errors of a known kind. */
|
|
59
|
+
interface ProblemTemplate {
|
|
60
|
+
type?: string;
|
|
61
|
+
title: string;
|
|
62
|
+
status: number;
|
|
63
|
+
detail?: string;
|
|
64
|
+
}
|
|
65
|
+
type ProblemTemplates = Readonly<Record<string, ProblemTemplate>>;
|
|
66
|
+
/**
|
|
67
|
+
* How registry keys are matched against errors:
|
|
68
|
+
* - `code`: `error.code === key` (default, survives minification)
|
|
69
|
+
* - `className`: `error.constructor.name === key`
|
|
70
|
+
* - `both`: either of the two
|
|
71
|
+
*/
|
|
72
|
+
type MatchBy = "code" | "className" | "both";
|
|
73
|
+
interface DefineProblemsOptions {
|
|
74
|
+
by?: MatchBy;
|
|
75
|
+
}
|
|
76
|
+
interface ProblemRegistry<T extends ProblemTemplates = ProblemTemplates> {
|
|
77
|
+
readonly templates: T;
|
|
78
|
+
readonly by: MatchBy;
|
|
79
|
+
}
|
|
80
|
+
/**
|
|
81
|
+
* Defines named problem templates for `fromError`. Every template is validated
|
|
82
|
+
* immediately, so invalid definitions fail at startup instead of per request.
|
|
83
|
+
*
|
|
84
|
+
* @example
|
|
85
|
+
* const problems = defineProblems({
|
|
86
|
+
* NotFound: { status: 404, title: "Not found", type: "https://example.com/problems/not-found" },
|
|
87
|
+
* });
|
|
88
|
+
* fromError(error, { registry: problems });
|
|
89
|
+
*/
|
|
90
|
+
export declare function defineProblems<const T extends ProblemTemplates>(templates: T, options?: DefineProblemsOptions): ProblemRegistry<T>;
|
|
91
|
+
/** A class used as a registry key in `fromError`. Matched with `instanceof`. */
|
|
92
|
+
type ErrorClass = abstract new (...args: never[]) => unknown;
|
|
93
|
+
interface FromErrorOptions {
|
|
94
|
+
/**
|
|
95
|
+
* Opt-in for unknown errors. Only when this returns `true` is the error
|
|
96
|
+
* message copied into `detail` (sanitized and length limited).
|
|
97
|
+
*/
|
|
98
|
+
expose?: (error: unknown) => boolean;
|
|
99
|
+
/**
|
|
100
|
+
* Known errors. A `ProblemRegistry` from `defineProblems`, or a `Map` whose
|
|
101
|
+
* string keys match `error.code` and whose class keys match with `instanceof`.
|
|
102
|
+
* The first matching entry wins. Matched errors never expose their message.
|
|
103
|
+
*/
|
|
104
|
+
registry?: ProblemRegistry | ReadonlyMap<ErrorClass | string, ProblemTemplate>;
|
|
105
|
+
/** Template for unknown errors. Defaults to status 500 and a generic title. */
|
|
106
|
+
fallback?: ProblemTemplate;
|
|
107
|
+
/** Request identifier. When set, `instance` becomes `urn:request:<id>`. */
|
|
108
|
+
instanceFromRequestId?: string;
|
|
109
|
+
}
|
|
110
|
+
/**
|
|
111
|
+
* Maps any thrown value to a problem document without leaking internals.
|
|
112
|
+
*
|
|
113
|
+
* Unknown errors produce only the fallback (by default status 500 and a generic
|
|
114
|
+
* title). Their `message`, `stack`, `cause` and `name` are never included unless
|
|
115
|
+
* `expose(error)` returns `true`.
|
|
116
|
+
*/
|
|
117
|
+
export declare function fromError(error: unknown, options?: FromErrorOptions): ProblemDetails;
|
|
118
|
+
interface ToResponseOptions {
|
|
119
|
+
/** Extra headers. The Content-Type is always set to the problem media type. */
|
|
120
|
+
headers?: HeadersInit;
|
|
121
|
+
}
|
|
122
|
+
/**
|
|
123
|
+
* Creates a Web-standard `Response` carrying the problem as
|
|
124
|
+
* `application/problem+json; charset=utf-8`. The problem is validated and
|
|
125
|
+
* sanitized again before it is serialized.
|
|
126
|
+
*/
|
|
127
|
+
export declare function toResponse(problem: ProblemDetails, options?: ToResponseOptions): Response;
|
|
128
|
+
export type { DefineProblemsOptions, ErrorClass, FromErrorOptions, MatchBy, ProblemDetails, ProblemErrorCode, ProblemInput, ProblemRegistry, ProblemTemplate, ProblemTemplates, ToResponseOptions };
|
|
129
|
+
//# sourceMappingURL=index.d.ts.map
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,340 @@
|
|
|
1
|
+
// src/sanitize.ts
|
|
2
|
+
const WHITESPACE_RUNS = /\s+/g;
|
|
3
|
+
const CONTROL_CHARS = /[\u0000-\u001f\u007f-\u009f]/g;
|
|
4
|
+
/**
|
|
5
|
+
* Makes free text safe for a human-readable problem field: every whitespace run
|
|
6
|
+
* (tabs, line breaks, Unicode line separators) becomes one space, other control
|
|
7
|
+
* characters are removed, surrounding whitespace is trimmed and the result is
|
|
8
|
+
* cut to `maxLength` code points.
|
|
9
|
+
*/
|
|
10
|
+
function sanitizeText(value, maxLength) {
|
|
11
|
+
return truncate(value.replace(WHITESPACE_RUNS, " ").replace(CONTROL_CHARS, "").trim(), maxLength);
|
|
12
|
+
}
|
|
13
|
+
/** Cuts a string to at most `maxLength` code points, ending with an ellipsis if cut. */
|
|
14
|
+
function truncate(value, maxLength) {
|
|
15
|
+
if (value.length <= maxLength) return value;
|
|
16
|
+
const chars = Array.from(value);
|
|
17
|
+
if (chars.length <= maxLength) return value;
|
|
18
|
+
return `${chars.slice(0, maxLength - 1).join("")}…`;
|
|
19
|
+
}
|
|
20
|
+
// src/problem.ts
|
|
21
|
+
/** Content-Type for RFC 9457 problem documents. */
|
|
22
|
+
const PROBLEM_CONTENT_TYPE = "application/problem+json; charset=utf-8";
|
|
23
|
+
/** Default problem `type` as defined by RFC 9457. */
|
|
24
|
+
const DEFAULT_PROBLEM_TYPE = "about:blank";
|
|
25
|
+
const TITLE_MAX_LENGTH = 200;
|
|
26
|
+
const DETAIL_MAX_LENGTH = 1e3;
|
|
27
|
+
const URI_MAX_LENGTH = 2048;
|
|
28
|
+
/**
|
|
29
|
+
* Whether a response with this status can carry a problem body: 200-599, except
|
|
30
|
+
* 204, 205 and 304, which must not have a body.
|
|
31
|
+
*/
|
|
32
|
+
function canCarryProblemBody(status) {
|
|
33
|
+
return status >= 200 && status !== 204 && status !== 205 && status !== 304;
|
|
34
|
+
}
|
|
35
|
+
/** Members defined by RFC 9457. They may not be used as extension keys. */
|
|
36
|
+
const RESERVED_MEMBERS = /* @__PURE__ */ new Set([
|
|
37
|
+
"type",
|
|
38
|
+
"title",
|
|
39
|
+
"status",
|
|
40
|
+
"detail",
|
|
41
|
+
"instance"
|
|
42
|
+
]);
|
|
43
|
+
/** Thrown when problem details cannot be built or are invalid. Carries no input values. */
|
|
44
|
+
var ProblemError = class extends Error {
|
|
45
|
+
code = "INVALID_PROBLEM";
|
|
46
|
+
issues;
|
|
47
|
+
constructor(issues) {
|
|
48
|
+
super(`Invalid problem details: ${issues.join("; ")}`);
|
|
49
|
+
this.name = "ProblemError";
|
|
50
|
+
this.issues = issues;
|
|
51
|
+
}
|
|
52
|
+
};
|
|
53
|
+
/**
|
|
54
|
+
* Builds a validated problem document. Text fields are sanitized and length
|
|
55
|
+
* limited; invalid input throws a `ProblemError` with code `INVALID_PROBLEM`.
|
|
56
|
+
*/
|
|
57
|
+
function problem(input) {
|
|
58
|
+
return normalizeProblem(input);
|
|
59
|
+
}
|
|
60
|
+
/** @internal Validates and normalizes a problem input. Shared by all helpers. */
|
|
61
|
+
function normalizeProblem(input) {
|
|
62
|
+
const issues = [];
|
|
63
|
+
if (!isValidStatus(input.status)) issues.push("status must be an integer between 100 and 599");
|
|
64
|
+
const title = typeof input.title === "string" ? sanitizeText(input.title, 200) : "";
|
|
65
|
+
if (title === "") issues.push("title must be a non-empty string");
|
|
66
|
+
const type = input.type === void 0 ? DEFAULT_PROBLEM_TYPE : uriReference("type", input.type, issues);
|
|
67
|
+
const instance = input.instance === void 0 ? void 0 : uriReference("instance", input.instance, issues);
|
|
68
|
+
const detail = optionalText("detail", input.detail, DETAIL_MAX_LENGTH, issues);
|
|
69
|
+
const extensions = {};
|
|
70
|
+
if (input.extensions !== void 0) {
|
|
71
|
+
if (!isRecord(input.extensions)) issues.push("extensions must be a plain object");
|
|
72
|
+
else for (const [key, value] of Object.entries(input.extensions)) if (RESERVED_MEMBERS.has(key)) issues.push(`extension key "${key}" collides with a reserved problem member`);
|
|
73
|
+
else if (key === "__proto__") issues.push("extension key \"__proto__\" is not allowed");
|
|
74
|
+
else if (!isJsonSerializable(value)) issues.push(`extension "${key}" must be JSON-serializable`);
|
|
75
|
+
else extensions[key] = value;
|
|
76
|
+
}
|
|
77
|
+
if (issues.length > 0) throw new ProblemError(issues);
|
|
78
|
+
const result = {
|
|
79
|
+
type,
|
|
80
|
+
title,
|
|
81
|
+
status: input.status
|
|
82
|
+
};
|
|
83
|
+
if (detail !== void 0) result.detail = detail;
|
|
84
|
+
if (instance !== void 0) result.instance = instance;
|
|
85
|
+
for (const [key, value] of Object.entries(extensions)) result[key] = value;
|
|
86
|
+
return result;
|
|
87
|
+
}
|
|
88
|
+
/** @internal */
|
|
89
|
+
function isRecord(value) {
|
|
90
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
91
|
+
}
|
|
92
|
+
/** @internal */
|
|
93
|
+
function isValidStatus(value) {
|
|
94
|
+
return typeof value === "number" && Number.isInteger(value) && value >= 100 && value <= 599;
|
|
95
|
+
}
|
|
96
|
+
const URI_REFERENCE_FORBIDDEN = /[\s\u0000-\u001f\u007f]/;
|
|
97
|
+
function uriReference(name, value, issues) {
|
|
98
|
+
if (typeof value !== "string" || value.length === 0 || value.length > URI_MAX_LENGTH || URI_REFERENCE_FORBIDDEN.test(value)) {
|
|
99
|
+
issues.push(`${name} must be a non-empty URI reference without whitespace or control characters (max ${URI_MAX_LENGTH} characters)`);
|
|
100
|
+
return "";
|
|
101
|
+
}
|
|
102
|
+
return value;
|
|
103
|
+
}
|
|
104
|
+
function optionalText(name, value, maxLength, issues) {
|
|
105
|
+
if (value === void 0) return;
|
|
106
|
+
if (typeof value !== "string") {
|
|
107
|
+
issues.push(`${name} must be a string`);
|
|
108
|
+
return;
|
|
109
|
+
}
|
|
110
|
+
const text = sanitizeText(value, maxLength);
|
|
111
|
+
return text === "" ? void 0 : text;
|
|
112
|
+
}
|
|
113
|
+
function isJsonSerializable(value) {
|
|
114
|
+
try {
|
|
115
|
+
return JSON.stringify(value) !== void 0;
|
|
116
|
+
} catch {
|
|
117
|
+
return false;
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
// src/client.ts
|
|
121
|
+
/** Largest problem body that `parseProblem` will read. */
|
|
122
|
+
const PARSE_MAX_BYTES = 65536;
|
|
123
|
+
/**
|
|
124
|
+
* Type guard for problem documents: `title` is a non-empty string, `status` is
|
|
125
|
+
* an integer between 100 and 599, and `type`, `detail`, `instance` are strings
|
|
126
|
+
* when present. Extension members are not inspected.
|
|
127
|
+
*/
|
|
128
|
+
function isProblemDetails(value) {
|
|
129
|
+
if (!isRecord(value)) return false;
|
|
130
|
+
const { type, title, status, detail, instance } = value;
|
|
131
|
+
return typeof title === "string" && title.length > 0 && isValidStatus(status) && (type === void 0 || typeof type === "string") && (detail === void 0 || typeof detail === "string") && (instance === void 0 || typeof instance === "string");
|
|
132
|
+
}
|
|
133
|
+
/**
|
|
134
|
+
* Reads a problem document from a `Response`. Returns `undefined` unless the
|
|
135
|
+
* media type is `application/problem+json` or `application/json` and the body is
|
|
136
|
+
* a valid problem. Bodies over 64 KiB, malformed JSON and read errors also
|
|
137
|
+
* yield `undefined`; this function never throws.
|
|
138
|
+
*
|
|
139
|
+
* The response body is consumed. Call `response.clone()` first if it is needed later.
|
|
140
|
+
*/
|
|
141
|
+
async function parseProblem(response) {
|
|
142
|
+
try {
|
|
143
|
+
const mediaType = response.headers.get("content-type")?.split(";")[0]?.trim().toLowerCase();
|
|
144
|
+
if (mediaType !== "application/problem+json" && mediaType !== "application/json") return;
|
|
145
|
+
const declared = Number(response.headers.get("content-length"));
|
|
146
|
+
if (Number.isFinite(declared) && declared > PARSE_MAX_BYTES) {
|
|
147
|
+
await response.body?.cancel();
|
|
148
|
+
return;
|
|
149
|
+
}
|
|
150
|
+
const bytes = await readLimited(response.body, PARSE_MAX_BYTES);
|
|
151
|
+
if (bytes === void 0) return;
|
|
152
|
+
const parsed = JSON.parse(new TextDecoder().decode(bytes));
|
|
153
|
+
return isProblemDetails(parsed) ? parsed : void 0;
|
|
154
|
+
} catch {
|
|
155
|
+
return;
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
/** Reads a stream up to `limit` bytes. Resolves to `undefined` if the limit is exceeded. */
|
|
159
|
+
async function readLimited(body, limit) {
|
|
160
|
+
if (body === null) return;
|
|
161
|
+
const reader = body.getReader();
|
|
162
|
+
const chunks = [];
|
|
163
|
+
let total = 0;
|
|
164
|
+
for (;;) {
|
|
165
|
+
const { done, value } = await reader.read();
|
|
166
|
+
if (done) break;
|
|
167
|
+
total += value.byteLength;
|
|
168
|
+
if (total > limit) {
|
|
169
|
+
await reader.cancel();
|
|
170
|
+
return;
|
|
171
|
+
}
|
|
172
|
+
chunks.push(value);
|
|
173
|
+
}
|
|
174
|
+
const bytes = new Uint8Array(total);
|
|
175
|
+
let offset = 0;
|
|
176
|
+
for (const chunk of chunks) {
|
|
177
|
+
bytes.set(chunk, offset);
|
|
178
|
+
offset += chunk.byteLength;
|
|
179
|
+
}
|
|
180
|
+
return bytes;
|
|
181
|
+
}
|
|
182
|
+
// src/from-error.ts
|
|
183
|
+
const DEFAULT_FALLBACK = {
|
|
184
|
+
status: 500,
|
|
185
|
+
title: "Internal Server Error"
|
|
186
|
+
};
|
|
187
|
+
const REQUEST_ID_MAX_LENGTH = 128;
|
|
188
|
+
/**
|
|
189
|
+
* Maps any thrown value to a problem document without leaking internals.
|
|
190
|
+
*
|
|
191
|
+
* Unknown errors produce only the fallback (by default status 500 and a generic
|
|
192
|
+
* title). Their `message`, `stack`, `cause` and `name` are never included unless
|
|
193
|
+
* `expose(error)` returns `true`.
|
|
194
|
+
*/
|
|
195
|
+
function fromError(error, options = {}) {
|
|
196
|
+
const instance = instanceFor(options.instanceFromRequestId);
|
|
197
|
+
const matched = findTemplate(error, options.registry);
|
|
198
|
+
if (matched !== void 0) return normalizeProblem({
|
|
199
|
+
type: matched.type,
|
|
200
|
+
title: matched.title,
|
|
201
|
+
status: matched.status,
|
|
202
|
+
detail: matched.detail,
|
|
203
|
+
instance
|
|
204
|
+
});
|
|
205
|
+
const fallback = options.fallback ?? DEFAULT_FALLBACK;
|
|
206
|
+
const exposed = options.expose !== void 0 && isExposed(options.expose, error);
|
|
207
|
+
return normalizeProblem({
|
|
208
|
+
type: fallback.type,
|
|
209
|
+
title: fallback.title,
|
|
210
|
+
status: fallback.status,
|
|
211
|
+
detail: exposed ? messageOf(error) : fallback.detail,
|
|
212
|
+
instance
|
|
213
|
+
});
|
|
214
|
+
}
|
|
215
|
+
function instanceFor(requestId) {
|
|
216
|
+
if (requestId === void 0 || requestId === "") return;
|
|
217
|
+
try {
|
|
218
|
+
return `urn:request:${encodeURIComponent(truncate(requestId, REQUEST_ID_MAX_LENGTH))}`;
|
|
219
|
+
} catch {
|
|
220
|
+
return;
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
function findTemplate(error, registry) {
|
|
224
|
+
if (registry === void 0) return;
|
|
225
|
+
try {
|
|
226
|
+
if (isMap(registry)) {
|
|
227
|
+
for (const [matcher, template] of registry) if (typeof matcher === "string" ? readProperty(error, "code") === matcher : error instanceof matcher) return template;
|
|
228
|
+
return;
|
|
229
|
+
}
|
|
230
|
+
const { by, templates } = registry;
|
|
231
|
+
for (const [key, template] of Object.entries(templates)) {
|
|
232
|
+
const byCode = by !== "className" && readProperty(error, "code") === key;
|
|
233
|
+
const byClassName = by !== "code" && constructorName(error) === key;
|
|
234
|
+
if (byCode || byClassName) return template;
|
|
235
|
+
}
|
|
236
|
+
return;
|
|
237
|
+
} catch {
|
|
238
|
+
return;
|
|
239
|
+
}
|
|
240
|
+
}
|
|
241
|
+
function isMap(registry) {
|
|
242
|
+
return registry instanceof Map;
|
|
243
|
+
}
|
|
244
|
+
function isExposed(expose, error) {
|
|
245
|
+
try {
|
|
246
|
+
return expose(error) === true;
|
|
247
|
+
} catch {
|
|
248
|
+
return false;
|
|
249
|
+
}
|
|
250
|
+
}
|
|
251
|
+
function messageOf(error) {
|
|
252
|
+
try {
|
|
253
|
+
const message = readProperty(error, "message");
|
|
254
|
+
return typeof message === "string" ? message : void 0;
|
|
255
|
+
} catch {
|
|
256
|
+
return;
|
|
257
|
+
}
|
|
258
|
+
}
|
|
259
|
+
function constructorName(value) {
|
|
260
|
+
const ctor = readProperty(value, "constructor");
|
|
261
|
+
return typeof ctor === "function" ? ctor.name : void 0;
|
|
262
|
+
}
|
|
263
|
+
function readProperty(value, key) {
|
|
264
|
+
if (typeof value !== "object" && typeof value !== "function" || value === null) return;
|
|
265
|
+
return value[key];
|
|
266
|
+
}
|
|
267
|
+
// src/registry.ts
|
|
268
|
+
const MATCH_MODES = /* @__PURE__ */ new Set([
|
|
269
|
+
"code",
|
|
270
|
+
"className",
|
|
271
|
+
"both"
|
|
272
|
+
]);
|
|
273
|
+
/**
|
|
274
|
+
* Defines named problem templates for `fromError`. Every template is validated
|
|
275
|
+
* immediately, so invalid definitions fail at startup instead of per request.
|
|
276
|
+
*
|
|
277
|
+
* @example
|
|
278
|
+
* const problems = defineProblems({
|
|
279
|
+
* NotFound: { status: 404, title: "Not found", type: "https://example.com/problems/not-found" },
|
|
280
|
+
* });
|
|
281
|
+
* fromError(error, { registry: problems });
|
|
282
|
+
*/
|
|
283
|
+
function defineProblems(templates, options = {}) {
|
|
284
|
+
const by = options.by ?? "code";
|
|
285
|
+
const issues = [];
|
|
286
|
+
if (!MATCH_MODES.has(by)) issues.push("by must be \"code\", \"className\" or \"both\"");
|
|
287
|
+
for (const [key, template] of Object.entries(templates)) {
|
|
288
|
+
if (key === "") {
|
|
289
|
+
issues.push("template keys must be non-empty");
|
|
290
|
+
continue;
|
|
291
|
+
}
|
|
292
|
+
try {
|
|
293
|
+
normalizeProblem({
|
|
294
|
+
...template,
|
|
295
|
+
extensions: void 0
|
|
296
|
+
});
|
|
297
|
+
if (!canCarryProblemBody(template.status)) issues.push(`${key}: status ${template.status} cannot carry a problem body`);
|
|
298
|
+
} catch (error) {
|
|
299
|
+
if (error instanceof ProblemError) issues.push(`${key}: ${error.issues.join("; ")}`);
|
|
300
|
+
else throw error;
|
|
301
|
+
}
|
|
302
|
+
}
|
|
303
|
+
if (issues.length > 0) throw new ProblemError(issues);
|
|
304
|
+
return Object.freeze({
|
|
305
|
+
templates: Object.freeze({ ...templates }),
|
|
306
|
+
by
|
|
307
|
+
});
|
|
308
|
+
}
|
|
309
|
+
// src/response.ts
|
|
310
|
+
/**
|
|
311
|
+
* Creates a Web-standard `Response` carrying the problem as
|
|
312
|
+
* `application/problem+json; charset=utf-8`. The problem is validated and
|
|
313
|
+
* sanitized again before it is serialized.
|
|
314
|
+
*/
|
|
315
|
+
function toResponse(problem, options = {}) {
|
|
316
|
+
const normalized = normalizeProblem(toProblemInput(problem));
|
|
317
|
+
if (!canCarryProblemBody(normalized.status)) throw new ProblemError([`status ${normalized.status} cannot be sent with a problem body (use 200-599, except 204, 205 and 304)`]);
|
|
318
|
+
const headers = new Headers(options.headers);
|
|
319
|
+
headers.set("content-type", PROBLEM_CONTENT_TYPE);
|
|
320
|
+
return new Response(JSON.stringify(normalized), {
|
|
321
|
+
status: normalized.status,
|
|
322
|
+
headers
|
|
323
|
+
});
|
|
324
|
+
}
|
|
325
|
+
/** Converts the flattened wire shape into the input shape used for validation. */
|
|
326
|
+
function toProblemInput(value) {
|
|
327
|
+
if (typeof value !== "object" || value === null || Array.isArray(value)) throw new ProblemError(["problem must be an object"]);
|
|
328
|
+
const { type, title, status, detail, instance, ...extensions } = value;
|
|
329
|
+
return {
|
|
330
|
+
type,
|
|
331
|
+
title,
|
|
332
|
+
status,
|
|
333
|
+
detail,
|
|
334
|
+
instance,
|
|
335
|
+
extensions
|
|
336
|
+
};
|
|
337
|
+
}
|
|
338
|
+
export { DEFAULT_PROBLEM_TYPE, DETAIL_MAX_LENGTH, PROBLEM_CONTENT_TYPE, ProblemError, TITLE_MAX_LENGTH, defineProblems, fromError, isProblemDetails, parseProblem, problem, toResponse };
|
|
339
|
+
|
|
340
|
+
//# sourceMappingURL=index.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"index.js","names":[],"sources":["../src/sanitize.ts","../src/problem.ts","../src/client.ts","../src/from-error.ts","../src/registry.ts","../src/response.ts"],"sourcesContent":["const WHITESPACE_RUNS = /\\s+/g;\nconst CONTROL_CHARS = /[\\u0000-\\u001f\\u007f-\\u009f]/g;\n\n/**\n * Makes free text safe for a human-readable problem field: every whitespace run\n * (tabs, line breaks, Unicode line separators) becomes one space, other control\n * characters are removed, surrounding whitespace is trimmed and the result is\n * cut to `maxLength` code points.\n */\nexport function sanitizeText(value: string, maxLength: number): string {\n\tconst cleaned = value.replace(WHITESPACE_RUNS, \" \").replace(CONTROL_CHARS, \"\").trim();\n\treturn truncate(cleaned, maxLength);\n}\n\n/** Cuts a string to at most `maxLength` code points, ending with an ellipsis if cut. */\nexport function truncate(value: string, maxLength: number): string {\n\t// A string never has more code points than code units, so this is a cheap pre-check.\n\tif (value.length <= maxLength) {\n\t\treturn value;\n\t}\n\tconst chars = Array.from(value);\n\tif (chars.length <= maxLength) {\n\t\treturn value;\n\t}\n\treturn `${chars.slice(0, maxLength - 1).join(\"\")}…`;\n}\n","import { sanitizeText } from \"./sanitize.js\";\n\n/** Content-Type for RFC 9457 problem documents. */\nexport const PROBLEM_CONTENT_TYPE = \"application/problem+json; charset=utf-8\";\n\n/** Default problem `type` as defined by RFC 9457. */\nexport const DEFAULT_PROBLEM_TYPE = \"about:blank\";\n\nexport const TITLE_MAX_LENGTH = 200;\nexport const DETAIL_MAX_LENGTH = 1000;\nconst URI_MAX_LENGTH = 2048;\n\n/**\n * Whether a response with this status can carry a problem body: 200-599, except\n * 204, 205 and 304, which must not have a body.\n */\nexport function canCarryProblemBody(status: number): boolean {\n\treturn status >= 200 && status !== 204 && status !== 205 && status !== 304;\n}\n\n/** Members defined by RFC 9457. They may not be used as extension keys. */\nexport const RESERVED_MEMBERS: ReadonlySet<string> = new Set([\n\t\"type\",\n\t\"title\",\n\t\"status\",\n\t\"detail\",\n\t\"instance\",\n]);\n\n/**\n * An RFC 9457 problem document. Extension members are flattened next to the\n * standard members, as the specification requires.\n */\nexport interface ProblemDetails {\n\ttype?: string;\n\ttitle: string;\n\tstatus: number;\n\tdetail?: string;\n\tinstance?: string;\n\t[extension: string]: unknown;\n}\n\n/**\n * Input for `problem()`. Extensions are passed separately so that they can be\n * checked against the reserved members before a document is built.\n */\nexport interface ProblemInput {\n\ttype?: string;\n\ttitle: string;\n\tstatus: number;\n\tdetail?: string;\n\tinstance?: string;\n\textensions?: Readonly<Record<string, unknown>>;\n}\n\nexport type ProblemErrorCode = \"INVALID_PROBLEM\";\n\n/** Thrown when problem details cannot be built or are invalid. Carries no input values. */\nexport class ProblemError extends Error {\n\treadonly code: ProblemErrorCode = \"INVALID_PROBLEM\";\n\treadonly issues: readonly string[];\n\n\tconstructor(issues: readonly string[]) {\n\t\tsuper(`Invalid problem details: ${issues.join(\"; \")}`);\n\t\tthis.name = \"ProblemError\";\n\t\tthis.issues = issues;\n\t}\n}\n\n/**\n * Builds a validated problem document. Text fields are sanitized and length\n * limited; invalid input throws a `ProblemError` with code `INVALID_PROBLEM`.\n */\nexport function problem(input: ProblemInput): ProblemDetails {\n\treturn normalizeProblem(input);\n}\n\n/** @internal Validates and normalizes a problem input. Shared by all helpers. */\nexport function normalizeProblem(input: ProblemInput): ProblemDetails {\n\tconst issues: string[] = [];\n\n\tif (!isValidStatus(input.status)) {\n\t\tissues.push(\"status must be an integer between 100 and 599\");\n\t}\n\n\tconst title = typeof input.title === \"string\" ? sanitizeText(input.title, TITLE_MAX_LENGTH) : \"\";\n\tif (title === \"\") {\n\t\tissues.push(\"title must be a non-empty string\");\n\t}\n\n\tconst type =\n\t\tinput.type === undefined ? DEFAULT_PROBLEM_TYPE : uriReference(\"type\", input.type, issues);\n\tconst instance =\n\t\tinput.instance === undefined ? undefined : uriReference(\"instance\", input.instance, issues);\n\tconst detail = optionalText(\"detail\", input.detail, DETAIL_MAX_LENGTH, issues);\n\n\tconst extensions: Record<string, unknown> = {};\n\tif (input.extensions !== undefined) {\n\t\tif (!isRecord(input.extensions)) {\n\t\t\tissues.push(\"extensions must be a plain object\");\n\t\t} else {\n\t\t\tfor (const [key, value] of Object.entries(input.extensions)) {\n\t\t\t\tif (RESERVED_MEMBERS.has(key)) {\n\t\t\t\t\tissues.push(`extension key \"${key}\" collides with a reserved problem member`);\n\t\t\t\t} else if (key === \"__proto__\") {\n\t\t\t\t\tissues.push('extension key \"__proto__\" is not allowed');\n\t\t\t\t} else if (!isJsonSerializable(value)) {\n\t\t\t\t\tissues.push(`extension \"${key}\" must be JSON-serializable`);\n\t\t\t\t} else {\n\t\t\t\t\textensions[key] = value;\n\t\t\t\t}\n\t\t\t}\n\t\t}\n\t}\n\n\tif (issues.length > 0) {\n\t\tthrow new ProblemError(issues);\n\t}\n\n\tconst result: ProblemDetails = { type, title, status: input.status };\n\tif (detail !== undefined) {\n\t\tresult.detail = detail;\n\t}\n\tif (instance !== undefined) {\n\t\tresult.instance = instance;\n\t}\n\tfor (const [key, value] of Object.entries(extensions)) {\n\t\tresult[key] = value;\n\t}\n\treturn result;\n}\n\n/** @internal */\nexport function isRecord(value: unknown): value is Record<string, unknown> {\n\treturn typeof value === \"object\" && value !== null && !Array.isArray(value);\n}\n\n/** @internal */\nexport function isValidStatus(value: unknown): value is number {\n\treturn typeof value === \"number\" && Number.isInteger(value) && value >= 100 && value <= 599;\n}\n\nconst URI_REFERENCE_FORBIDDEN = /[\\s\\u0000-\\u001f\\u007f]/;\n\nfunction uriReference(name: string, value: unknown, issues: string[]): string {\n\tif (\n\t\ttypeof value !== \"string\" ||\n\t\tvalue.length === 0 ||\n\t\tvalue.length > URI_MAX_LENGTH ||\n\t\tURI_REFERENCE_FORBIDDEN.test(value)\n\t) {\n\t\tissues.push(\n\t\t\t`${name} must be a non-empty URI reference without whitespace or control characters (max ${URI_MAX_LENGTH} characters)`,\n\t\t);\n\t\treturn \"\";\n\t}\n\treturn value;\n}\n\nfunction optionalText(\n\tname: string,\n\tvalue: unknown,\n\tmaxLength: number,\n\tissues: string[],\n): string | undefined {\n\tif (value === undefined) {\n\t\treturn undefined;\n\t}\n\tif (typeof value !== \"string\") {\n\t\tissues.push(`${name} must be a string`);\n\t\treturn undefined;\n\t}\n\tconst text = sanitizeText(value, maxLength);\n\treturn text === \"\" ? undefined : text;\n}\n\nfunction isJsonSerializable(value: unknown): boolean {\n\ttry {\n\t\treturn JSON.stringify(value) !== undefined;\n\t} catch {\n\t\treturn false;\n\t}\n}\n","import { isRecord, isValidStatus, type ProblemDetails } from \"./problem.js\";\n\n/** Largest problem body that `parseProblem` will read. */\nconst PARSE_MAX_BYTES = 64 * 1024;\n\n/**\n * Type guard for problem documents: `title` is a non-empty string, `status` is\n * an integer between 100 and 599, and `type`, `detail`, `instance` are strings\n * when present. Extension members are not inspected.\n */\nexport function isProblemDetails(value: unknown): value is ProblemDetails {\n\tif (!isRecord(value)) {\n\t\treturn false;\n\t}\n\tconst { type, title, status, detail, instance } = value;\n\treturn (\n\t\ttypeof title === \"string\" &&\n\t\ttitle.length > 0 &&\n\t\tisValidStatus(status) &&\n\t\t(type === undefined || typeof type === \"string\") &&\n\t\t(detail === undefined || typeof detail === \"string\") &&\n\t\t(instance === undefined || typeof instance === \"string\")\n\t);\n}\n\n/**\n * Reads a problem document from a `Response`. Returns `undefined` unless the\n * media type is `application/problem+json` or `application/json` and the body is\n * a valid problem. Bodies over 64 KiB, malformed JSON and read errors also\n * yield `undefined`; this function never throws.\n *\n * The response body is consumed. Call `response.clone()` first if it is needed later.\n */\nexport async function parseProblem(response: Response): Promise<ProblemDetails | undefined> {\n\ttry {\n\t\tconst mediaType = response.headers.get(\"content-type\")?.split(\";\")[0]?.trim().toLowerCase();\n\t\tif (mediaType !== \"application/problem+json\" && mediaType !== \"application/json\") {\n\t\t\treturn undefined;\n\t\t}\n\n\t\tconst declared = Number(response.headers.get(\"content-length\"));\n\t\tif (Number.isFinite(declared) && declared > PARSE_MAX_BYTES) {\n\t\t\tawait response.body?.cancel();\n\t\t\treturn undefined;\n\t\t}\n\n\t\tconst bytes = await readLimited(response.body, PARSE_MAX_BYTES);\n\t\tif (bytes === undefined) {\n\t\t\treturn undefined;\n\t\t}\n\n\t\tconst parsed: unknown = JSON.parse(new TextDecoder().decode(bytes));\n\t\treturn isProblemDetails(parsed) ? parsed : undefined;\n\t} catch {\n\t\treturn undefined;\n\t}\n}\n\n/** Reads a stream up to `limit` bytes. Resolves to `undefined` if the limit is exceeded. */\nasync function readLimited(\n\tbody: ReadableStream<Uint8Array> | null,\n\tlimit: number,\n): Promise<Uint8Array | undefined> {\n\tif (body === null) {\n\t\treturn undefined;\n\t}\n\tconst reader = body.getReader();\n\tconst chunks: Uint8Array[] = [];\n\tlet total = 0;\n\tfor (;;) {\n\t\tconst { done, value } = await reader.read();\n\t\tif (done) {\n\t\t\tbreak;\n\t\t}\n\t\ttotal += value.byteLength;\n\t\tif (total > limit) {\n\t\t\tawait reader.cancel();\n\t\t\treturn undefined;\n\t\t}\n\t\tchunks.push(value);\n\t}\n\tconst bytes = new Uint8Array(total);\n\tlet offset = 0;\n\tfor (const chunk of chunks) {\n\t\tbytes.set(chunk, offset);\n\t\toffset += chunk.byteLength;\n\t}\n\treturn bytes;\n}\n","import { normalizeProblem, type ProblemDetails } from \"./problem.js\";\nimport type { ProblemRegistry, ProblemTemplate } from \"./registry.js\";\nimport { truncate } from \"./sanitize.js\";\n\n/** A class used as a registry key in `fromError`. Matched with `instanceof`. */\nexport type ErrorClass = abstract new (...args: never[]) => unknown;\n\nexport interface FromErrorOptions {\n\t/**\n\t * Opt-in for unknown errors. Only when this returns `true` is the error\n\t * message copied into `detail` (sanitized and length limited).\n\t */\n\texpose?: (error: unknown) => boolean;\n\t/**\n\t * Known errors. A `ProblemRegistry` from `defineProblems`, or a `Map` whose\n\t * string keys match `error.code` and whose class keys match with `instanceof`.\n\t * The first matching entry wins. Matched errors never expose their message.\n\t */\n\tregistry?: ProblemRegistry | ReadonlyMap<ErrorClass | string, ProblemTemplate>;\n\t/** Template for unknown errors. Defaults to status 500 and a generic title. */\n\tfallback?: ProblemTemplate;\n\t/** Request identifier. When set, `instance` becomes `urn:request:<id>`. */\n\tinstanceFromRequestId?: string;\n}\n\nconst DEFAULT_FALLBACK: ProblemTemplate = { status: 500, title: \"Internal Server Error\" };\nconst REQUEST_ID_MAX_LENGTH = 128;\n\n/**\n * Maps any thrown value to a problem document without leaking internals.\n *\n * Unknown errors produce only the fallback (by default status 500 and a generic\n * title). Their `message`, `stack`, `cause` and `name` are never included unless\n * `expose(error)` returns `true`.\n */\nexport function fromError(error: unknown, options: FromErrorOptions = {}): ProblemDetails {\n\tconst instance = instanceFor(options.instanceFromRequestId);\n\n\tconst matched = findTemplate(error, options.registry);\n\tif (matched !== undefined) {\n\t\treturn normalizeProblem({\n\t\t\ttype: matched.type,\n\t\t\ttitle: matched.title,\n\t\t\tstatus: matched.status,\n\t\t\tdetail: matched.detail,\n\t\t\tinstance,\n\t\t});\n\t}\n\n\tconst fallback = options.fallback ?? DEFAULT_FALLBACK;\n\tconst exposed = options.expose !== undefined && isExposed(options.expose, error);\n\treturn normalizeProblem({\n\t\ttype: fallback.type,\n\t\ttitle: fallback.title,\n\t\tstatus: fallback.status,\n\t\tdetail: exposed ? messageOf(error) : fallback.detail,\n\t\tinstance,\n\t});\n}\n\nfunction instanceFor(requestId: string | undefined): string | undefined {\n\tif (requestId === undefined || requestId === \"\") {\n\t\treturn undefined;\n\t}\n\ttry {\n\t\treturn `urn:request:${encodeURIComponent(truncate(requestId, REQUEST_ID_MAX_LENGTH))}`;\n\t} catch {\n\t\t// Lone surrogates cannot be encoded; an unusable request id is dropped, not thrown.\n\t\treturn undefined;\n\t}\n}\n\nfunction findTemplate(\n\terror: unknown,\n\tregistry: FromErrorOptions[\"registry\"],\n): ProblemTemplate | undefined {\n\tif (registry === undefined) {\n\t\treturn undefined;\n\t}\n\ttry {\n\t\tif (isMap(registry)) {\n\t\t\tfor (const [matcher, template] of registry) {\n\t\t\t\tconst hit =\n\t\t\t\t\ttypeof matcher === \"string\"\n\t\t\t\t\t\t? readProperty(error, \"code\") === matcher\n\t\t\t\t\t\t: error instanceof matcher;\n\t\t\t\tif (hit) {\n\t\t\t\t\treturn template;\n\t\t\t\t}\n\t\t\t}\n\t\t\treturn undefined;\n\t\t}\n\n\t\tconst { by, templates } = registry;\n\t\tfor (const [key, template] of Object.entries(templates)) {\n\t\t\tconst byCode = by !== \"className\" && readProperty(error, \"code\") === key;\n\t\t\tconst byClassName = by !== \"code\" && constructorName(error) === key;\n\t\t\tif (byCode || byClassName) {\n\t\t\t\treturn template;\n\t\t\t}\n\t\t}\n\t\treturn undefined;\n\t} catch {\n\t\t// A throwing getter or hostile matcher must not change the response shape.\n\t\treturn undefined;\n\t}\n}\n\nfunction isMap(\n\tregistry: NonNullable<FromErrorOptions[\"registry\"]>,\n): registry is ReadonlyMap<ErrorClass | string, ProblemTemplate> {\n\treturn registry instanceof Map;\n}\n\nfunction isExposed(expose: (error: unknown) => boolean, error: unknown): boolean {\n\ttry {\n\t\treturn expose(error) === true;\n\t} catch {\n\t\treturn false;\n\t}\n}\n\nfunction messageOf(error: unknown): string | undefined {\n\ttry {\n\t\tconst message = readProperty(error, \"message\");\n\t\treturn typeof message === \"string\" ? message : undefined;\n\t} catch {\n\t\treturn undefined;\n\t}\n}\n\nfunction constructorName(value: unknown): string | undefined {\n\tconst ctor = readProperty(value, \"constructor\");\n\treturn typeof ctor === \"function\" ? ctor.name : undefined;\n}\n\nfunction readProperty(value: unknown, key: string): unknown {\n\tif ((typeof value !== \"object\" && typeof value !== \"function\") || value === null) {\n\t\treturn undefined;\n\t}\n\treturn (value as Record<string, unknown>)[key];\n}\n","import { canCarryProblemBody, normalizeProblem, ProblemError } from \"./problem.js\";\n\n/** A problem definition that can be reused for errors of a known kind. */\nexport interface ProblemTemplate {\n\ttype?: string;\n\ttitle: string;\n\tstatus: number;\n\tdetail?: string;\n}\n\nexport type ProblemTemplates = Readonly<Record<string, ProblemTemplate>>;\n\n/**\n * How registry keys are matched against errors:\n * - `code`: `error.code === key` (default, survives minification)\n * - `className`: `error.constructor.name === key`\n * - `both`: either of the two\n */\nexport type MatchBy = \"code\" | \"className\" | \"both\";\n\nexport interface DefineProblemsOptions {\n\tby?: MatchBy;\n}\n\nexport interface ProblemRegistry<T extends ProblemTemplates = ProblemTemplates> {\n\treadonly templates: T;\n\treadonly by: MatchBy;\n}\n\nconst MATCH_MODES: ReadonlySet<string> = new Set([\"code\", \"className\", \"both\"]);\n\n/**\n * Defines named problem templates for `fromError`. Every template is validated\n * immediately, so invalid definitions fail at startup instead of per request.\n *\n * @example\n * const problems = defineProblems({\n * NotFound: { status: 404, title: \"Not found\", type: \"https://example.com/problems/not-found\" },\n * });\n * fromError(error, { registry: problems });\n */\nexport function defineProblems<const T extends ProblemTemplates>(\n\ttemplates: T,\n\toptions: DefineProblemsOptions = {},\n): ProblemRegistry<T> {\n\tconst by = options.by ?? \"code\";\n\tconst issues: string[] = [];\n\n\tif (!MATCH_MODES.has(by)) {\n\t\tissues.push('by must be \"code\", \"className\" or \"both\"');\n\t}\n\n\tfor (const [key, template] of Object.entries(templates)) {\n\t\tif (key === \"\") {\n\t\t\tissues.push(\"template keys must be non-empty\");\n\t\t\tcontinue;\n\t\t}\n\t\ttry {\n\t\t\tnormalizeProblem({ ...template, extensions: undefined });\n\t\t\tif (!canCarryProblemBody(template.status)) {\n\t\t\t\tissues.push(`${key}: status ${template.status} cannot carry a problem body`);\n\t\t\t}\n\t\t} catch (error) {\n\t\t\tif (error instanceof ProblemError) {\n\t\t\t\tissues.push(`${key}: ${error.issues.join(\"; \")}`);\n\t\t\t} else {\n\t\t\t\tthrow error;\n\t\t\t}\n\t\t}\n\t}\n\n\tif (issues.length > 0) {\n\t\tthrow new ProblemError(issues);\n\t}\n\n\treturn Object.freeze({ templates: Object.freeze({ ...templates }), by });\n}\n","import {\n\tcanCarryProblemBody,\n\tnormalizeProblem,\n\tPROBLEM_CONTENT_TYPE,\n\ttype ProblemDetails,\n\tProblemError,\n\ttype ProblemInput,\n} from \"./problem.js\";\n\nexport interface ToResponseOptions {\n\t/** Extra headers. The Content-Type is always set to the problem media type. */\n\theaders?: HeadersInit;\n}\n\n/**\n * Creates a Web-standard `Response` carrying the problem as\n * `application/problem+json; charset=utf-8`. The problem is validated and\n * sanitized again before it is serialized.\n */\nexport function toResponse(problem: ProblemDetails, options: ToResponseOptions = {}): Response {\n\tconst normalized = normalizeProblem(toProblemInput(problem));\n\n\tif (!canCarryProblemBody(normalized.status)) {\n\t\tthrow new ProblemError([\n\t\t\t`status ${normalized.status} cannot be sent with a problem body (use 200-599, except 204, 205 and 304)`,\n\t\t]);\n\t}\n\n\tconst headers = new Headers(options.headers);\n\theaders.set(\"content-type\", PROBLEM_CONTENT_TYPE);\n\n\treturn new Response(JSON.stringify(normalized), {\n\t\tstatus: normalized.status,\n\t\theaders,\n\t});\n}\n\n/** Converts the flattened wire shape into the input shape used for validation. */\nfunction toProblemInput(value: ProblemDetails): ProblemInput {\n\tif (typeof value !== \"object\" || value === null || Array.isArray(value)) {\n\t\tthrow new ProblemError([\"problem must be an object\"]);\n\t}\n\tconst { type, title, status, detail, instance, ...extensions } = value;\n\treturn { type, title, status, detail, instance, extensions };\n}\n"],"mappings":"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"}
|