@yedoma-labs/ysuly-problem 0.0.0-stage → 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 yedoma-labs
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md CHANGED
@@ -1,3 +1,161 @@
1
- # Temporary Holding Version
1
+ # @yedoma-labs/ysuly-problem
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ Type-safe [RFC 9457](https://www.rfc-editor.org/rfc/rfc9457) Problem Details for TypeScript, with a mapping from arbitrary thrown errors that never leaks internals. Zero runtime dependencies. Uses only Web-standard APIs (`Response`, `Headers`, `JSON`), so it runs on Node 20+, Bun, Deno and edge runtimes.
4
+
5
+ ## The problem
6
+
7
+ API error responses are inconsistent. One endpoint returns plain text, another `{ error: "..." }`, a third `{ message, stack }`. The last shape is the dangerous one: stack traces, internal file paths and SQL error messages end up in the browser.
8
+
9
+ RFC 9457 (which obsoletes RFC 7807) defines a standard shape for error responses: `application/problem+json` with `type`, `title`, `status`, `detail` and `instance`. This library gives you:
10
+
11
+ - **Builders** that validate and sanitize problem documents.
12
+ - **A safe mapping** from any thrown value to a problem. Unknown errors become a generic 500. Nothing from the error object is copied unless you explicitly allow it.
13
+ - **Framework-free helpers.** `toResponse` returns a standard `Response`. There are no imports from Express, Hono, Fastify or similar. Adapters are one-line functions you write yourself (see below).
14
+ - **Client helpers** to recognize and parse problem responses.
15
+
16
+ ## Install
17
+
18
+ ```sh
19
+ pnpm add @yedoma-labs/ysuly-problem
20
+ ```
21
+
22
+ ## Quickstart
23
+
24
+ ```ts
25
+ import { defineProblems, fromError, problem, toResponse } from "@yedoma-labs/ysuly-problem";
26
+
27
+ // A problem you construct yourself
28
+ const notEnough = problem({
29
+ type: "https://example.com/problems/insufficient-funds",
30
+ title: "Insufficient funds",
31
+ status: 422,
32
+ detail: "The account balance is lower than the requested amount.",
33
+ instance: "/transfers/97",
34
+ extensions: { balance: 30, requested: 50 },
35
+ });
36
+
37
+ // Known errors, mapped by their `code` property
38
+ const problems = defineProblems({
39
+ NOT_FOUND: {
40
+ type: "https://example.com/problems/not-found",
41
+ title: "Not found",
42
+ status: 404,
43
+ detail: "The requested resource does not exist.",
44
+ },
45
+ });
46
+
47
+ // Any thrown value: `error.message` and `error.stack` are never copied by default
48
+ export async function handle(request: Request): Promise<Response> {
49
+ try {
50
+ return await route(request); // your routing
51
+ } catch (error) {
52
+ return toResponse(
53
+ fromError(error, {
54
+ registry: problems,
55
+ instanceFromRequestId: request.headers.get("x-request-id") ?? undefined,
56
+ }),
57
+ );
58
+ }
59
+ }
60
+ ```
61
+
62
+ `toResponse` returns a `Response` with `Content-Type: application/problem+json; charset=utf-8` and a JSON body.
63
+
64
+ ## Framework adapters
65
+
66
+ The library has no framework imports. An adapter is a few lines in your own code. For Express, inside an error middleware `(err, req, res, next)`:
67
+
68
+ ```ts
69
+ const p = fromError(err, { registry: problems });
70
+ res.status(p.status).type("application/problem+json").json(p);
71
+ ```
72
+
73
+ For Web-standard runtimes, `toResponse` is the adapter.
74
+
75
+ ## API
76
+
77
+ ### `problem(input)`
78
+
79
+ Builds a validated `ProblemDetails`. Invalid input throws a `ProblemError` with `code === "INVALID_PROBLEM"` and an `issues` array.
80
+
81
+ - `status`: integer from 100 to 599 (required).
82
+ - `title`: required, sanitized, at most 200 characters.
83
+ - `type`: URI reference, defaults to `"about:blank"`.
84
+ - `detail`: optional, sanitized, at most 1000 characters.
85
+ - `instance`: optional URI reference.
86
+ - `extensions`: optional object. Keys `type`, `title`, `status`, `detail` and `instance` are rejected. Values must be JSON-serializable. Extensions are flattened into the output.
87
+
88
+ Text fields lose control characters, line breaks and tabs become single spaces, and over-long text is cut at a code point boundary with an ellipsis.
89
+
90
+ ### `toResponse(problem, { headers? })`
91
+
92
+ Returns a `Response`. The problem is validated and sanitized again. Custom headers are merged, but `Content-Type` is always the problem media type. Statuses that cannot carry a body (1xx, 204, 205, 304) throw `ProblemError`.
93
+
94
+ ### `fromError(error, options?)`
95
+
96
+ Maps any value to a `ProblemDetails`. Options:
97
+
98
+ - `registry`: a `defineProblems` result, or a `Map` whose string keys match `error.code` and whose class keys match with `instanceof`. The first matching entry wins.
99
+ - `expose(error) => boolean`: opt-in for unknown errors. Only when this returns `true` is `error.message` copied into `detail`, sanitized and truncated.
100
+ - `fallback`: template for unknown errors. Default: `{ status: 500, title: "Internal Server Error" }`.
101
+ - `instanceFromRequestId`: sets `instance` to `urn:request:<id>`.
102
+
103
+ ### `defineProblems(templates, { by? })`
104
+
105
+ Defines named problem templates and validates them immediately. Each key is matched against `error.code` (default), against the error's constructor name (`by: "className"`), or against either (`by: "both"`). Matching never looks at `message`.
106
+
107
+ ### `isProblemDetails(value)`
108
+
109
+ Type guard. `title` must be a non-empty string, `status` an integer from 100 to 599, and the optional members must have the right types.
110
+
111
+ ### `parseProblem(response)`
112
+
113
+ Returns `Promise<ProblemDetails | undefined>`. It returns a problem only if the media type is `application/problem+json` or `application/json` and the body is a valid problem. Bodies over 64 KiB, malformed JSON and read errors return `undefined`. The function never throws. It consumes the response body, so call `response.clone()` first if you need the body later.
114
+
115
+ ```ts
116
+ const response = await fetch("/api/transfers/97", { method: "POST", body });
117
+ if (!response.ok) {
118
+ const problem = await parseProblem(response);
119
+ console.error(problem?.title ?? response.statusText);
120
+ }
121
+ ```
122
+
123
+ ## Security rules
124
+
125
+ 1. **Unknown errors are opaque.** Without a registry match and without `expose`, the response contains only the fallback status and title. No `message`, `stack`, `cause`, `name` or file paths.
126
+ 2. **Registry matches use only the template.** The title, status, type and detail come from the template. `error.message` is never used for a matched error, even with `expose`.
127
+ 3. **Exposure is an explicit opt-in per error.** Only `expose(error) === true` copies a message, and it is sanitized and truncated first.
128
+ 4. **Matching is explicit.** Registry keys are matched against `code` or the constructor name, never against the message text.
129
+ 5. **Errors from `problem()` carry no input values.** `ProblemError.message` lists the problems, not the offending data.
130
+ 6. **Reserved members cannot be overridden through extensions.** `__proto__` is rejected as well.
131
+ 7. **Client parsing is bounded.** `parseProblem` reads at most 64 KiB and never throws.
132
+
133
+ Limits of the library: it cannot tell which messages are sensitive. If you pass `expose: () => true`, every message reaches the client, so do that only for error types you have reviewed. Extensions are sent exactly as you give them. Do not put secrets in them.
134
+
135
+ ## Comparison with http-problem-details
136
+
137
+ [`http-problem-details`](https://www.npmjs.com/package/http-problem-details) is an existing small package for the same standard. This library is intentionally slimmer and its main difference is the default error mapping:
138
+
139
+ - Unknown errors become a generic 500 by default. Messages are copied only after an explicit `expose` decision.
140
+ - Registry templates are validated when they are defined. Matching is explicit (`code`, class name, or both), never by message text.
141
+ - Only Web-standard APIs are used, with zero runtime dependencies.
142
+
143
+ I have not audited the full feature set of `http-problem-details` for this comparison. Check its API against your needs before choosing one.
144
+
145
+ ## Runtime support
146
+
147
+ Node 20 and newer, Bun, Deno and edge runtimes that provide `Response`, `Headers`, `ReadableStream` and `TextDecoder`.
148
+
149
+ ## Development
150
+
151
+ ```sh
152
+ pnpm install
153
+ pnpm lint
154
+ pnpm typecheck
155
+ pnpm test
156
+ pnpm build
157
+ ```
158
+
159
+ ## License
160
+
161
+ MIT
package/dist/index.cjs ADDED
@@ -0,0 +1,351 @@
1
+ Object.defineProperty(exports, Symbol.toStringTag, { value: "Module" });
2
+ // src/sanitize.ts
3
+ const WHITESPACE_RUNS = /\s+/g;
4
+ const CONTROL_CHARS = /[\u0000-\u001f\u007f-\u009f]/g;
5
+ /**
6
+ * Makes free text safe for a human-readable problem field: every whitespace run
7
+ * (tabs, line breaks, Unicode line separators) becomes one space, other control
8
+ * characters are removed, surrounding whitespace is trimmed and the result is
9
+ * cut to `maxLength` code points.
10
+ */
11
+ function sanitizeText(value, maxLength) {
12
+ return truncate(value.replace(WHITESPACE_RUNS, " ").replace(CONTROL_CHARS, "").trim(), maxLength);
13
+ }
14
+ /** Cuts a string to at most `maxLength` code points, ending with an ellipsis if cut. */
15
+ function truncate(value, maxLength) {
16
+ if (value.length <= maxLength) return value;
17
+ const chars = Array.from(value);
18
+ if (chars.length <= maxLength) return value;
19
+ return `${chars.slice(0, maxLength - 1).join("")}…`;
20
+ }
21
+ // src/problem.ts
22
+ /** Content-Type for RFC 9457 problem documents. */
23
+ const PROBLEM_CONTENT_TYPE = "application/problem+json; charset=utf-8";
24
+ /** Default problem `type` as defined by RFC 9457. */
25
+ const DEFAULT_PROBLEM_TYPE = "about:blank";
26
+ const TITLE_MAX_LENGTH = 200;
27
+ const DETAIL_MAX_LENGTH = 1e3;
28
+ const URI_MAX_LENGTH = 2048;
29
+ /** Members defined by RFC 9457. They may not be used as extension keys. */
30
+ const RESERVED_MEMBERS = /* @__PURE__ */ new Set([
31
+ "type",
32
+ "title",
33
+ "status",
34
+ "detail",
35
+ "instance"
36
+ ]);
37
+ /** Thrown when problem details cannot be built or are invalid. Carries no input values. */
38
+ var ProblemError = class extends Error {
39
+ code = "INVALID_PROBLEM";
40
+ issues;
41
+ constructor(issues) {
42
+ super(`Invalid problem details: ${issues.join("; ")}`);
43
+ this.name = "ProblemError";
44
+ this.issues = issues;
45
+ }
46
+ };
47
+ /**
48
+ * Builds a validated problem document. Text fields are sanitized and length
49
+ * limited; invalid input throws a `ProblemError` with code `INVALID_PROBLEM`.
50
+ */
51
+ function problem(input) {
52
+ return normalizeProblem(input);
53
+ }
54
+ /** @internal Validates and normalizes a problem input. Shared by all helpers. */
55
+ function normalizeProblem(input) {
56
+ const issues = [];
57
+ if (!isValidStatus(input.status)) issues.push("status must be an integer between 100 and 599");
58
+ const title = typeof input.title === "string" ? sanitizeText(input.title, 200) : "";
59
+ if (title === "") issues.push("title must be a non-empty string");
60
+ const type = input.type === void 0 ? DEFAULT_PROBLEM_TYPE : uriReference("type", input.type, issues);
61
+ const instance = input.instance === void 0 ? void 0 : uriReference("instance", input.instance, issues);
62
+ const detail = optionalText("detail", input.detail, DETAIL_MAX_LENGTH, issues);
63
+ const extensions = {};
64
+ if (input.extensions !== void 0) {
65
+ if (!isRecord(input.extensions)) issues.push("extensions must be a plain object");
66
+ 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`);
67
+ else if (key === "__proto__") issues.push("extension key \"__proto__\" is not allowed");
68
+ else if (!isJsonSerializable(value)) issues.push(`extension "${key}" must be JSON-serializable`);
69
+ else extensions[key] = value;
70
+ }
71
+ if (issues.length > 0) throw new ProblemError(issues);
72
+ const result = {
73
+ type,
74
+ title,
75
+ status: input.status
76
+ };
77
+ if (detail !== void 0) result.detail = detail;
78
+ if (instance !== void 0) result.instance = instance;
79
+ for (const [key, value] of Object.entries(extensions)) result[key] = value;
80
+ return result;
81
+ }
82
+ /** @internal */
83
+ function isRecord(value) {
84
+ return typeof value === "object" && value !== null && !Array.isArray(value);
85
+ }
86
+ /** @internal */
87
+ function isValidStatus(value) {
88
+ return typeof value === "number" && Number.isInteger(value) && value >= 100 && value <= 599;
89
+ }
90
+ const URI_REFERENCE_FORBIDDEN = /[\s\u0000-\u001f\u007f]/;
91
+ function uriReference(name, value, issues) {
92
+ if (typeof value !== "string" || value.length === 0 || value.length > URI_MAX_LENGTH || URI_REFERENCE_FORBIDDEN.test(value)) {
93
+ issues.push(`${name} must be a non-empty URI reference without whitespace or control characters (max ${URI_MAX_LENGTH} characters)`);
94
+ return "";
95
+ }
96
+ return value;
97
+ }
98
+ function optionalText(name, value, maxLength, issues) {
99
+ if (value === void 0) return;
100
+ if (typeof value !== "string") {
101
+ issues.push(`${name} must be a string`);
102
+ return;
103
+ }
104
+ const text = sanitizeText(value, maxLength);
105
+ return text === "" ? void 0 : text;
106
+ }
107
+ function isJsonSerializable(value) {
108
+ try {
109
+ return JSON.stringify(value) !== void 0;
110
+ } catch {
111
+ return false;
112
+ }
113
+ }
114
+ // src/client.ts
115
+ /** Largest problem body that `parseProblem` will read. */
116
+ const PARSE_MAX_BYTES = 65536;
117
+ /**
118
+ * Type guard for problem documents: `title` is a non-empty string, `status` is
119
+ * an integer between 100 and 599, and `type`, `detail`, `instance` are strings
120
+ * when present. Extension members are not inspected.
121
+ */
122
+ function isProblemDetails(value) {
123
+ if (!isRecord(value)) return false;
124
+ const { type, title, status, detail, instance } = value;
125
+ 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");
126
+ }
127
+ /**
128
+ * Reads a problem document from a `Response`. Returns `undefined` unless the
129
+ * media type is `application/problem+json` or `application/json` and the body is
130
+ * a valid problem. Bodies over 64 KiB, malformed JSON and read errors also
131
+ * yield `undefined`; this function never throws.
132
+ *
133
+ * The response body is consumed. Call `response.clone()` first if it is needed later.
134
+ */
135
+ async function parseProblem(response) {
136
+ try {
137
+ const mediaType = response.headers.get("content-type")?.split(";")[0]?.trim().toLowerCase();
138
+ if (mediaType !== "application/problem+json" && mediaType !== "application/json") return;
139
+ const declared = Number(response.headers.get("content-length"));
140
+ if (Number.isFinite(declared) && declared > PARSE_MAX_BYTES) {
141
+ await response.body?.cancel();
142
+ return;
143
+ }
144
+ const bytes = await readLimited(response.body, PARSE_MAX_BYTES);
145
+ if (bytes === void 0) return;
146
+ const parsed = JSON.parse(new TextDecoder().decode(bytes));
147
+ return isProblemDetails(parsed) ? parsed : void 0;
148
+ } catch {
149
+ return;
150
+ }
151
+ }
152
+ /** Reads a stream up to `limit` bytes. Resolves to `undefined` if the limit is exceeded. */
153
+ async function readLimited(body, limit) {
154
+ if (body === null) return;
155
+ const reader = body.getReader();
156
+ const chunks = [];
157
+ let total = 0;
158
+ for (;;) {
159
+ const { done, value } = await reader.read();
160
+ if (done) break;
161
+ total += value.byteLength;
162
+ if (total > limit) {
163
+ await reader.cancel();
164
+ return;
165
+ }
166
+ chunks.push(value);
167
+ }
168
+ const bytes = new Uint8Array(total);
169
+ let offset = 0;
170
+ for (const chunk of chunks) {
171
+ bytes.set(chunk, offset);
172
+ offset += chunk.byteLength;
173
+ }
174
+ return bytes;
175
+ }
176
+ // src/from-error.ts
177
+ const DEFAULT_FALLBACK = {
178
+ status: 500,
179
+ title: "Internal Server Error"
180
+ };
181
+ const REQUEST_ID_MAX_LENGTH = 128;
182
+ /**
183
+ * Maps any thrown value to a problem document without leaking internals.
184
+ *
185
+ * Unknown errors produce only the fallback (by default status 500 and a generic
186
+ * title). Their `message`, `stack`, `cause` and `name` are never included unless
187
+ * `expose(error)` returns `true`.
188
+ */
189
+ function fromError(error, options = {}) {
190
+ const instance = instanceFor(options.instanceFromRequestId);
191
+ const matched = findTemplate(error, options.registry);
192
+ if (matched !== void 0) return normalizeProblem({
193
+ type: matched.type,
194
+ title: matched.title,
195
+ status: matched.status,
196
+ detail: matched.detail,
197
+ instance
198
+ });
199
+ const fallback = options.fallback ?? DEFAULT_FALLBACK;
200
+ const exposed = options.expose !== void 0 && isExposed(options.expose, error);
201
+ return normalizeProblem({
202
+ type: fallback.type,
203
+ title: fallback.title,
204
+ status: fallback.status,
205
+ detail: exposed ? messageOf(error) : fallback.detail,
206
+ instance
207
+ });
208
+ }
209
+ function instanceFor(requestId) {
210
+ if (requestId === void 0 || requestId === "") return;
211
+ try {
212
+ return `urn:request:${encodeURIComponent(truncate(requestId, REQUEST_ID_MAX_LENGTH))}`;
213
+ } catch {
214
+ return;
215
+ }
216
+ }
217
+ function findTemplate(error, registry) {
218
+ if (registry === void 0) return;
219
+ try {
220
+ if (isMap(registry)) {
221
+ for (const [matcher, template] of registry) if (typeof matcher === "string" ? readProperty(error, "code") === matcher : error instanceof matcher) return template;
222
+ return;
223
+ }
224
+ const { by, templates } = registry;
225
+ for (const [key, template] of Object.entries(templates)) {
226
+ const byCode = by !== "className" && readProperty(error, "code") === key;
227
+ const byClassName = by !== "code" && constructorName(error) === key;
228
+ if (byCode || byClassName) return template;
229
+ }
230
+ return;
231
+ } catch {
232
+ return;
233
+ }
234
+ }
235
+ function isMap(registry) {
236
+ return registry instanceof Map;
237
+ }
238
+ function isExposed(expose, error) {
239
+ try {
240
+ return expose(error) === true;
241
+ } catch {
242
+ return false;
243
+ }
244
+ }
245
+ function messageOf(error) {
246
+ try {
247
+ const message = readProperty(error, "message");
248
+ return typeof message === "string" ? message : void 0;
249
+ } catch {
250
+ return;
251
+ }
252
+ }
253
+ function constructorName(value) {
254
+ const ctor = readProperty(value, "constructor");
255
+ return typeof ctor === "function" ? ctor.name : void 0;
256
+ }
257
+ function readProperty(value, key) {
258
+ if (typeof value !== "object" && typeof value !== "function" || value === null) return;
259
+ return value[key];
260
+ }
261
+ // src/registry.ts
262
+ const MATCH_MODES = /* @__PURE__ */ new Set([
263
+ "code",
264
+ "className",
265
+ "both"
266
+ ]);
267
+ /**
268
+ * Defines named problem templates for `fromError`. Every template is validated
269
+ * immediately, so invalid definitions fail at startup instead of per request.
270
+ *
271
+ * @example
272
+ * const problems = defineProblems({
273
+ * NotFound: { status: 404, title: "Not found", type: "https://example.com/problems/not-found" },
274
+ * });
275
+ * fromError(error, { registry: problems });
276
+ */
277
+ function defineProblems(templates, options = {}) {
278
+ const by = options.by ?? "code";
279
+ const issues = [];
280
+ if (!MATCH_MODES.has(by)) issues.push("by must be \"code\", \"className\" or \"both\"");
281
+ for (const [key, template] of Object.entries(templates)) {
282
+ if (key === "") {
283
+ issues.push("template keys must be non-empty");
284
+ continue;
285
+ }
286
+ try {
287
+ normalizeProblem({
288
+ ...template,
289
+ extensions: void 0
290
+ });
291
+ } catch (error) {
292
+ if (error instanceof ProblemError) issues.push(`${key}: ${error.issues.join("; ")}`);
293
+ else throw error;
294
+ }
295
+ }
296
+ if (issues.length > 0) throw new ProblemError(issues);
297
+ return Object.freeze({
298
+ templates: Object.freeze({ ...templates }),
299
+ by
300
+ });
301
+ }
302
+ // src/response.ts
303
+ /** Statuses that cannot carry a body, or that the Response constructor rejects. */
304
+ const BODYLESS_OR_UNSUPPORTED_STATUSES = /* @__PURE__ */ new Set([
305
+ 101,
306
+ 103,
307
+ 204,
308
+ 205,
309
+ 304
310
+ ]);
311
+ /**
312
+ * Creates a Web-standard `Response` carrying the problem as
313
+ * `application/problem+json; charset=utf-8`. The problem is validated and
314
+ * sanitized again before it is serialized.
315
+ */
316
+ function toResponse(problem, options = {}) {
317
+ const normalized = normalizeProblem(toProblemInput(problem));
318
+ if (normalized.status < 200 || BODYLESS_OR_UNSUPPORTED_STATUSES.has(normalized.status)) throw new ProblemError([`status ${normalized.status} cannot be sent with a problem body (use 200-599, except 204, 205 and 304)`]);
319
+ const headers = new Headers(options.headers);
320
+ headers.set("content-type", PROBLEM_CONTENT_TYPE);
321
+ return new Response(JSON.stringify(normalized), {
322
+ status: normalized.status,
323
+ headers
324
+ });
325
+ }
326
+ /** Converts the flattened wire shape into the input shape used for validation. */
327
+ function toProblemInput(value) {
328
+ if (typeof value !== "object" || value === null || Array.isArray(value)) throw new ProblemError(["problem must be an object"]);
329
+ const { type, title, status, detail, instance, ...extensions } = value;
330
+ return {
331
+ type,
332
+ title,
333
+ status,
334
+ detail,
335
+ instance,
336
+ extensions
337
+ };
338
+ }
339
+ exports.DEFAULT_PROBLEM_TYPE = DEFAULT_PROBLEM_TYPE;
340
+ exports.DETAIL_MAX_LENGTH = DETAIL_MAX_LENGTH;
341
+ exports.PROBLEM_CONTENT_TYPE = PROBLEM_CONTENT_TYPE;
342
+ exports.ProblemError = ProblemError;
343
+ exports.TITLE_MAX_LENGTH = TITLE_MAX_LENGTH;
344
+ exports.defineProblems = defineProblems;
345
+ exports.fromError = fromError;
346
+ exports.isProblemDetails = isProblemDetails;
347
+ exports.parseProblem = parseProblem;
348
+ exports.problem = problem;
349
+ exports.toResponse = toResponse;
350
+
351
+ //# sourceMappingURL=index.cjs.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"index.cjs","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/** 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 { 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} 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\tnormalizeProblem,\n\tPROBLEM_CONTENT_TYPE,\n\ttype ProblemDetails,\n\tProblemError,\n\ttype ProblemInput,\n} from \"./problem.js\";\n\n/** Statuses that cannot carry a body, or that the Response constructor rejects. */\nconst BODYLESS_OR_UNSUPPORTED_STATUSES: ReadonlySet<number> = new Set([101, 103, 204, 205, 304]);\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 (normalized.status < 200 || BODYLESS_OR_UNSUPPORTED_STATUSES.has(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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"}
@@ -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
@@ -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
+ /** Members defined by RFC 9457. They may not be used as extension keys. */
29
+ const RESERVED_MEMBERS = /* @__PURE__ */ new Set([
30
+ "type",
31
+ "title",
32
+ "status",
33
+ "detail",
34
+ "instance"
35
+ ]);
36
+ /** Thrown when problem details cannot be built or are invalid. Carries no input values. */
37
+ var ProblemError = class extends Error {
38
+ code = "INVALID_PROBLEM";
39
+ issues;
40
+ constructor(issues) {
41
+ super(`Invalid problem details: ${issues.join("; ")}`);
42
+ this.name = "ProblemError";
43
+ this.issues = issues;
44
+ }
45
+ };
46
+ /**
47
+ * Builds a validated problem document. Text fields are sanitized and length
48
+ * limited; invalid input throws a `ProblemError` with code `INVALID_PROBLEM`.
49
+ */
50
+ function problem(input) {
51
+ return normalizeProblem(input);
52
+ }
53
+ /** @internal Validates and normalizes a problem input. Shared by all helpers. */
54
+ function normalizeProblem(input) {
55
+ const issues = [];
56
+ if (!isValidStatus(input.status)) issues.push("status must be an integer between 100 and 599");
57
+ const title = typeof input.title === "string" ? sanitizeText(input.title, 200) : "";
58
+ if (title === "") issues.push("title must be a non-empty string");
59
+ const type = input.type === void 0 ? DEFAULT_PROBLEM_TYPE : uriReference("type", input.type, issues);
60
+ const instance = input.instance === void 0 ? void 0 : uriReference("instance", input.instance, issues);
61
+ const detail = optionalText("detail", input.detail, DETAIL_MAX_LENGTH, issues);
62
+ const extensions = {};
63
+ if (input.extensions !== void 0) {
64
+ if (!isRecord(input.extensions)) issues.push("extensions must be a plain object");
65
+ 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`);
66
+ else if (key === "__proto__") issues.push("extension key \"__proto__\" is not allowed");
67
+ else if (!isJsonSerializable(value)) issues.push(`extension "${key}" must be JSON-serializable`);
68
+ else extensions[key] = value;
69
+ }
70
+ if (issues.length > 0) throw new ProblemError(issues);
71
+ const result = {
72
+ type,
73
+ title,
74
+ status: input.status
75
+ };
76
+ if (detail !== void 0) result.detail = detail;
77
+ if (instance !== void 0) result.instance = instance;
78
+ for (const [key, value] of Object.entries(extensions)) result[key] = value;
79
+ return result;
80
+ }
81
+ /** @internal */
82
+ function isRecord(value) {
83
+ return typeof value === "object" && value !== null && !Array.isArray(value);
84
+ }
85
+ /** @internal */
86
+ function isValidStatus(value) {
87
+ return typeof value === "number" && Number.isInteger(value) && value >= 100 && value <= 599;
88
+ }
89
+ const URI_REFERENCE_FORBIDDEN = /[\s\u0000-\u001f\u007f]/;
90
+ function uriReference(name, value, issues) {
91
+ if (typeof value !== "string" || value.length === 0 || value.length > URI_MAX_LENGTH || URI_REFERENCE_FORBIDDEN.test(value)) {
92
+ issues.push(`${name} must be a non-empty URI reference without whitespace or control characters (max ${URI_MAX_LENGTH} characters)`);
93
+ return "";
94
+ }
95
+ return value;
96
+ }
97
+ function optionalText(name, value, maxLength, issues) {
98
+ if (value === void 0) return;
99
+ if (typeof value !== "string") {
100
+ issues.push(`${name} must be a string`);
101
+ return;
102
+ }
103
+ const text = sanitizeText(value, maxLength);
104
+ return text === "" ? void 0 : text;
105
+ }
106
+ function isJsonSerializable(value) {
107
+ try {
108
+ return JSON.stringify(value) !== void 0;
109
+ } catch {
110
+ return false;
111
+ }
112
+ }
113
+ // src/client.ts
114
+ /** Largest problem body that `parseProblem` will read. */
115
+ const PARSE_MAX_BYTES = 65536;
116
+ /**
117
+ * Type guard for problem documents: `title` is a non-empty string, `status` is
118
+ * an integer between 100 and 599, and `type`, `detail`, `instance` are strings
119
+ * when present. Extension members are not inspected.
120
+ */
121
+ function isProblemDetails(value) {
122
+ if (!isRecord(value)) return false;
123
+ const { type, title, status, detail, instance } = value;
124
+ 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");
125
+ }
126
+ /**
127
+ * Reads a problem document from a `Response`. Returns `undefined` unless the
128
+ * media type is `application/problem+json` or `application/json` and the body is
129
+ * a valid problem. Bodies over 64 KiB, malformed JSON and read errors also
130
+ * yield `undefined`; this function never throws.
131
+ *
132
+ * The response body is consumed. Call `response.clone()` first if it is needed later.
133
+ */
134
+ async function parseProblem(response) {
135
+ try {
136
+ const mediaType = response.headers.get("content-type")?.split(";")[0]?.trim().toLowerCase();
137
+ if (mediaType !== "application/problem+json" && mediaType !== "application/json") return;
138
+ const declared = Number(response.headers.get("content-length"));
139
+ if (Number.isFinite(declared) && declared > PARSE_MAX_BYTES) {
140
+ await response.body?.cancel();
141
+ return;
142
+ }
143
+ const bytes = await readLimited(response.body, PARSE_MAX_BYTES);
144
+ if (bytes === void 0) return;
145
+ const parsed = JSON.parse(new TextDecoder().decode(bytes));
146
+ return isProblemDetails(parsed) ? parsed : void 0;
147
+ } catch {
148
+ return;
149
+ }
150
+ }
151
+ /** Reads a stream up to `limit` bytes. Resolves to `undefined` if the limit is exceeded. */
152
+ async function readLimited(body, limit) {
153
+ if (body === null) return;
154
+ const reader = body.getReader();
155
+ const chunks = [];
156
+ let total = 0;
157
+ for (;;) {
158
+ const { done, value } = await reader.read();
159
+ if (done) break;
160
+ total += value.byteLength;
161
+ if (total > limit) {
162
+ await reader.cancel();
163
+ return;
164
+ }
165
+ chunks.push(value);
166
+ }
167
+ const bytes = new Uint8Array(total);
168
+ let offset = 0;
169
+ for (const chunk of chunks) {
170
+ bytes.set(chunk, offset);
171
+ offset += chunk.byteLength;
172
+ }
173
+ return bytes;
174
+ }
175
+ // src/from-error.ts
176
+ const DEFAULT_FALLBACK = {
177
+ status: 500,
178
+ title: "Internal Server Error"
179
+ };
180
+ const REQUEST_ID_MAX_LENGTH = 128;
181
+ /**
182
+ * Maps any thrown value to a problem document without leaking internals.
183
+ *
184
+ * Unknown errors produce only the fallback (by default status 500 and a generic
185
+ * title). Their `message`, `stack`, `cause` and `name` are never included unless
186
+ * `expose(error)` returns `true`.
187
+ */
188
+ function fromError(error, options = {}) {
189
+ const instance = instanceFor(options.instanceFromRequestId);
190
+ const matched = findTemplate(error, options.registry);
191
+ if (matched !== void 0) return normalizeProblem({
192
+ type: matched.type,
193
+ title: matched.title,
194
+ status: matched.status,
195
+ detail: matched.detail,
196
+ instance
197
+ });
198
+ const fallback = options.fallback ?? DEFAULT_FALLBACK;
199
+ const exposed = options.expose !== void 0 && isExposed(options.expose, error);
200
+ return normalizeProblem({
201
+ type: fallback.type,
202
+ title: fallback.title,
203
+ status: fallback.status,
204
+ detail: exposed ? messageOf(error) : fallback.detail,
205
+ instance
206
+ });
207
+ }
208
+ function instanceFor(requestId) {
209
+ if (requestId === void 0 || requestId === "") return;
210
+ try {
211
+ return `urn:request:${encodeURIComponent(truncate(requestId, REQUEST_ID_MAX_LENGTH))}`;
212
+ } catch {
213
+ return;
214
+ }
215
+ }
216
+ function findTemplate(error, registry) {
217
+ if (registry === void 0) return;
218
+ try {
219
+ if (isMap(registry)) {
220
+ for (const [matcher, template] of registry) if (typeof matcher === "string" ? readProperty(error, "code") === matcher : error instanceof matcher) return template;
221
+ return;
222
+ }
223
+ const { by, templates } = registry;
224
+ for (const [key, template] of Object.entries(templates)) {
225
+ const byCode = by !== "className" && readProperty(error, "code") === key;
226
+ const byClassName = by !== "code" && constructorName(error) === key;
227
+ if (byCode || byClassName) return template;
228
+ }
229
+ return;
230
+ } catch {
231
+ return;
232
+ }
233
+ }
234
+ function isMap(registry) {
235
+ return registry instanceof Map;
236
+ }
237
+ function isExposed(expose, error) {
238
+ try {
239
+ return expose(error) === true;
240
+ } catch {
241
+ return false;
242
+ }
243
+ }
244
+ function messageOf(error) {
245
+ try {
246
+ const message = readProperty(error, "message");
247
+ return typeof message === "string" ? message : void 0;
248
+ } catch {
249
+ return;
250
+ }
251
+ }
252
+ function constructorName(value) {
253
+ const ctor = readProperty(value, "constructor");
254
+ return typeof ctor === "function" ? ctor.name : void 0;
255
+ }
256
+ function readProperty(value, key) {
257
+ if (typeof value !== "object" && typeof value !== "function" || value === null) return;
258
+ return value[key];
259
+ }
260
+ // src/registry.ts
261
+ const MATCH_MODES = /* @__PURE__ */ new Set([
262
+ "code",
263
+ "className",
264
+ "both"
265
+ ]);
266
+ /**
267
+ * Defines named problem templates for `fromError`. Every template is validated
268
+ * immediately, so invalid definitions fail at startup instead of per request.
269
+ *
270
+ * @example
271
+ * const problems = defineProblems({
272
+ * NotFound: { status: 404, title: "Not found", type: "https://example.com/problems/not-found" },
273
+ * });
274
+ * fromError(error, { registry: problems });
275
+ */
276
+ function defineProblems(templates, options = {}) {
277
+ const by = options.by ?? "code";
278
+ const issues = [];
279
+ if (!MATCH_MODES.has(by)) issues.push("by must be \"code\", \"className\" or \"both\"");
280
+ for (const [key, template] of Object.entries(templates)) {
281
+ if (key === "") {
282
+ issues.push("template keys must be non-empty");
283
+ continue;
284
+ }
285
+ try {
286
+ normalizeProblem({
287
+ ...template,
288
+ extensions: void 0
289
+ });
290
+ } catch (error) {
291
+ if (error instanceof ProblemError) issues.push(`${key}: ${error.issues.join("; ")}`);
292
+ else throw error;
293
+ }
294
+ }
295
+ if (issues.length > 0) throw new ProblemError(issues);
296
+ return Object.freeze({
297
+ templates: Object.freeze({ ...templates }),
298
+ by
299
+ });
300
+ }
301
+ // src/response.ts
302
+ /** Statuses that cannot carry a body, or that the Response constructor rejects. */
303
+ const BODYLESS_OR_UNSUPPORTED_STATUSES = /* @__PURE__ */ new Set([
304
+ 101,
305
+ 103,
306
+ 204,
307
+ 205,
308
+ 304
309
+ ]);
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 (normalized.status < 200 || BODYLESS_OR_UNSUPPORTED_STATUSES.has(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/** 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 { 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} 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\tnormalizeProblem,\n\tPROBLEM_CONTENT_TYPE,\n\ttype ProblemDetails,\n\tProblemError,\n\ttype ProblemInput,\n} from \"./problem.js\";\n\n/** Statuses that cannot carry a body, or that the Response constructor rejects. */\nconst BODYLESS_OR_UNSUPPORTED_STATUSES: ReadonlySet<number> = new Set([101, 103, 204, 205, 304]);\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 (normalized.status < 200 || BODYLESS_OR_UNSUPPORTED_STATUSES.has(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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"}
package/package.json CHANGED
@@ -1,6 +1,86 @@
1
1
  {
2
- "name": "@yedoma-labs/ysuly-problem",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
6
- }
2
+ "name": "@yedoma-labs/ysuly-problem",
3
+ "version": "1.0.0",
4
+ "description": "Type-safe RFC 9457 Problem Details with safe error mapping, zero dependencies",
5
+ "type": "module",
6
+ "main": "./dist/index.cjs",
7
+ "module": "./dist/index.js",
8
+ "types": "./dist/index.d.ts",
9
+ "exports": {
10
+ ".": {
11
+ "import": {
12
+ "types": "./dist/index.d.ts",
13
+ "default": "./dist/index.js"
14
+ },
15
+ "require": {
16
+ "types": "./dist/index.d.cts",
17
+ "default": "./dist/index.cjs"
18
+ }
19
+ }
20
+ },
21
+ "files": [
22
+ "dist"
23
+ ],
24
+ "engines": {
25
+ "node": ">=20"
26
+ },
27
+ "packageManager": "pnpm@12.15.0",
28
+ "scripts": {
29
+ "build": "tsdown",
30
+ "test": "vitest run",
31
+ "test:watch": "vitest",
32
+ "test:coverage": "vitest run --coverage",
33
+ "lint": "biome check src tests",
34
+ "lint:fix": "biome check --write src tests",
35
+ "format": "biome format --write src tests",
36
+ "typecheck": "tsc --noEmit",
37
+ "prepublishOnly": "pnpm run build"
38
+ },
39
+ "keywords": [
40
+ "problem-details",
41
+ "rfc9457",
42
+ "rfc7807",
43
+ "problem+json",
44
+ "error-handling",
45
+ "api",
46
+ "typescript",
47
+ "zero-dependency"
48
+ ],
49
+ "repository": {
50
+ "type": "git",
51
+ "url": "https://github.com/yedoma-labs/ysuly-problem"
52
+ },
53
+ "author": "",
54
+ "license": "MIT",
55
+ "simple-git-hooks": {
56
+ "pre-commit": "pnpm exec lint-staged",
57
+ "pre-push": "pnpm run typecheck && pnpm run test",
58
+ "commit-msg": "pnpm exec commitlint --edit $1"
59
+ },
60
+ "lint-staged": {
61
+ "*.ts": [
62
+ "biome check --write"
63
+ ]
64
+ },
65
+ "devDependencies": {
66
+ "@biomejs/biome": "2.6.3",
67
+ "@commitlint/cli": "^21.2.4",
68
+ "@commitlint/config-conventional": "^21.2.4",
69
+ "@semantic-release/changelog": "^7.0.0",
70
+ "@semantic-release/commit-analyzer": "^13.0.1",
71
+ "@semantic-release/exec": "^7.1.0",
72
+ "@semantic-release/git": "^11.0.1",
73
+ "@semantic-release/github": "^12.0.10",
74
+ "@semantic-release/npm": "^13.2.0",
75
+ "@semantic-release/release-notes-generator": "^14.1.1",
76
+ "@types/node": "26.6.5",
77
+ "@typescript/typescript6": "^6.0.2",
78
+ "@vitest/coverage-v8": "^5.0.5",
79
+ "lint-staged": "^17.6.0",
80
+ "semantic-release": "^25.0.9",
81
+ "simple-git-hooks": "^2.14.0",
82
+ "tsdown": "^0.23.2",
83
+ "typescript": "7.0.2",
84
+ "vitest": "5.0.5"
85
+ }
86
+ }