@gusnips/server 0.1.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.
Files changed (57) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +323 -0
  3. package/dist/errors.d.ts +190 -0
  4. package/dist/errors.d.ts.map +1 -0
  5. package/dist/errors.js +154 -0
  6. package/dist/errors.js.map +1 -0
  7. package/dist/hono/errors.d.ts +46 -0
  8. package/dist/hono/errors.d.ts.map +1 -0
  9. package/dist/hono/errors.js +54 -0
  10. package/dist/hono/errors.js.map +1 -0
  11. package/dist/hono/guards.d.ts +45 -0
  12. package/dist/hono/guards.d.ts.map +1 -0
  13. package/dist/hono/guards.js +88 -0
  14. package/dist/hono/guards.js.map +1 -0
  15. package/dist/hono/index.d.ts +18 -0
  16. package/dist/hono/index.d.ts.map +1 -0
  17. package/dist/hono/index.js +15 -0
  18. package/dist/hono/index.js.map +1 -0
  19. package/dist/hono/request-logger.d.ts +31 -0
  20. package/dist/hono/request-logger.d.ts.map +1 -0
  21. package/dist/hono/request-logger.js +57 -0
  22. package/dist/hono/request-logger.js.map +1 -0
  23. package/dist/index.d.ts +7 -0
  24. package/dist/index.d.ts.map +1 -0
  25. package/dist/index.js +4 -0
  26. package/dist/index.js.map +1 -0
  27. package/dist/logger/index.d.ts +44 -0
  28. package/dist/logger/index.d.ts.map +1 -0
  29. package/dist/logger/index.js +74 -0
  30. package/dist/logger/index.js.map +1 -0
  31. package/dist/logger/serialize.d.ts +68 -0
  32. package/dist/logger/serialize.d.ts.map +1 -0
  33. package/dist/logger/serialize.js +203 -0
  34. package/dist/logger/serialize.js.map +1 -0
  35. package/dist/responses.d.ts +107 -0
  36. package/dist/responses.d.ts.map +1 -0
  37. package/dist/responses.js +183 -0
  38. package/dist/responses.js.map +1 -0
  39. package/package.json +79 -0
  40. package/src/errors.test.ts +93 -0
  41. package/src/errors.ts +264 -0
  42. package/src/errors.types.test.ts +96 -0
  43. package/src/hono/errors.test.ts +215 -0
  44. package/src/hono/errors.ts +86 -0
  45. package/src/hono/guards.test.ts +234 -0
  46. package/src/hono/guards.ts +107 -0
  47. package/src/hono/index.ts +17 -0
  48. package/src/hono/request-logger.test.ts +200 -0
  49. package/src/hono/request-logger.ts +77 -0
  50. package/src/index.ts +6 -0
  51. package/src/logger/index.test.ts +137 -0
  52. package/src/logger/index.ts +112 -0
  53. package/src/logger/serialize.test.ts +300 -0
  54. package/src/logger/serialize.ts +202 -0
  55. package/src/readme.test.ts +132 -0
  56. package/src/responses.test.ts +291 -0
  57. package/src/responses.ts +277 -0
@@ -0,0 +1,74 @@
1
+ /**
2
+ * One JSON line per event, on stdout, and nothing else.
3
+ *
4
+ * Twelve backends were read for this and none of them installs a logging library. The measured
5
+ * gap between 60 lines of `console.log(JSON.stringify(...))` and a real one is not levels,
6
+ * transports or child loggers — it is the error serializer, and the standard one ships the same
7
+ * copy-loop this package exists to remove. So the package ships no logging library, no transports,
8
+ * no file rotation and no extra levels. Every backend here runs under a process manager or a
9
+ * container that already owns stdout; nothing in twelve repos writes a log file.
10
+ */
11
+ import { errorReplacer } from "./serialize.js";
12
+ const LEVELS = { debug: 10, info: 20, warn: 30, error: 40 };
13
+ const SILENT = Number.POSITIVE_INFINITY;
14
+ function resolveThreshold(level) {
15
+ if (level === undefined || level === "")
16
+ return LEVELS.info;
17
+ if (level === "silent")
18
+ return SILENT;
19
+ const known = LEVELS[level];
20
+ if (known !== undefined)
21
+ return known;
22
+ // Loud, at construction, rather than silently falling back to "info": a box running at the
23
+ // wrong level is discovered during the incident it was meant to explain. This is called once
24
+ // at boot, so a typo fails the deploy instead of the 3am read.
25
+ throw new Error(`Unknown log level ${JSON.stringify(level)}. ` +
26
+ `Use one of: debug, info, warn, error, silent — or leave it unset for info.`);
27
+ }
28
+ function consoleWrite(line, level) {
29
+ if (level === "error")
30
+ console.error(line);
31
+ else if (level === "warn")
32
+ console.warn(line);
33
+ else
34
+ console.log(line);
35
+ }
36
+ /**
37
+ * Build a logger. Call it once, at boot, and pass the result to everything that logs.
38
+ *
39
+ * const logger = createLogger({ level: process.env.LOG_LEVEL });
40
+ * logger.info("server started", { port: 3000 });
41
+ * logger.error("charge failed", { orderId, error: err }); // the RAW error, never String(err)
42
+ */
43
+ export function createLogger(options = {}) {
44
+ const threshold = resolveThreshold(options.level);
45
+ const write = options.write ?? consoleWrite;
46
+ function emit(level, message, meta) {
47
+ if (LEVELS[level] < threshold)
48
+ return;
49
+ // Canonical fields last, so they win: a meta `message`, `level` or `time` must never replace
50
+ // the line's own label, severity or timestamp. One backend re-implemented this logger inline
51
+ // with the order inverted, and a `meta.message` silently became the line.
52
+ const time = new Date().toISOString();
53
+ const entry = { ...meta, level, time, message };
54
+ let line;
55
+ try {
56
+ line = JSON.stringify(entry, errorReplacer());
57
+ }
58
+ catch {
59
+ // A logger must never take down the process it is reporting on. Reachable through a
60
+ // throwing getter or a throwing `toJSON()` on something in `meta` — and the flag is there
61
+ // so a reader knows a line was lost rather than never written.
62
+ line = JSON.stringify({ level, time, message, logSerializationFailed: true });
63
+ }
64
+ write(line, level);
65
+ }
66
+ return {
67
+ debug: (message, meta) => emit("debug", message, meta),
68
+ info: (message, meta) => emit("info", message, meta),
69
+ warn: (message, meta) => emit("warn", message, meta),
70
+ error: (message, meta) => emit("error", message, meta),
71
+ };
72
+ }
73
+ export { errorReplacer, keptErrorFields } from "./serialize.js";
74
+ //# sourceMappingURL=index.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"index.js","sourceRoot":"","sources":["../../src/logger/index.ts"],"names":[],"mappings":"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"}
@@ -0,0 +1,68 @@
1
+ /**
2
+ * The error serializer. This is the one file in `@gusnips/server` that exists because of an
3
+ * incident rather than because of duplication.
4
+ *
5
+ * Six backends shipped the same replacer, and it copied **every own enumerable property** off a
6
+ * caught Error into the log line. The comment beside the loop said it was there to pick up a
7
+ * Postgres `code`/`detail`/`hint`. What it actually picked up was whatever the SDK that threw had
8
+ * hung on the error — which for two vendors in use is the request the caller sent us.
9
+ */
10
+ /**
11
+ * A thrown value that is NOT an Error, narrowed to what a log line may print off it.
12
+ *
13
+ * The allow-list above is written against Errors, and until this the narrowing stopped there: an
14
+ * Error was filtered and whatever sat in its `cause` was copied whole. That gap is not theoretical
15
+ * here, it is this package's own doing — `errorBoundary` turns every non-Error throw into
16
+ * `new Error(toMessage(err), { cause: err })`, because Hono's `onError` never sees a non-Error and
17
+ * a PostgREST client rejects with plain objects. So in a Hono app the cause slot is precisely where
18
+ * a vendor's rejection object ends up, and a leak there reads as if the list had run.
19
+ *
20
+ * `cause` means "the error this one came from", so whatever sits in it is in the error slot and
21
+ * gets the same treatment. `name` and `message` come along because a rejection object usually
22
+ * carries them and a line with neither says nothing at all.
23
+ *
24
+ * Deliberate state it does NOT keep: context an app attaches on purpose. That belongs in the
25
+ * logger's `meta`, which is untouched — `cause` is not the place for it, and one incident of a
26
+ * vendor's request body in the log outweighs a field nobody put there deliberately.
27
+ */
28
+ export declare function narrowErrorLike(value: object): Record<string, unknown>;
29
+ /**
30
+ * A `JSON.stringify` replacer that keeps log lines useful and crash-proof:
31
+ *
32
+ * - Errors serialize to a readable object. `message` and `stack` are non-enumerable, so a plain
33
+ * `JSON.stringify(err)` is `{}` — which is how a logger ends up printing nothing about the
34
+ * failure it was called to report. They are added explicitly, and the allow-listed extras ride
35
+ * along beside them.
36
+ * - A nested `cause` is followed, and so is an `AggregateError`'s `errors`. Both are
37
+ * non-enumerable, so both are invisible to the loop above; without this line "all attempts
38
+ * failed" is the whole log entry. Each one goes back through this replacer, so the allow-list
39
+ * covers the chain, not just the top — and a cause that is not an Error is narrowed here
40
+ * instead, by {@link narrowErrorLike}, because the replacer's Error branch would never see it.
41
+ * - bigints stringify instead of throwing.
42
+ * - Circular references collapse to "[Circular]" instead of crashing the log call.
43
+ *
44
+ * Paired with callers passing the RAW error rather than `String(err)`, this is why a log line
45
+ * never reads "[object Object]".
46
+ *
47
+ * One ordering fact decides what this replacer would otherwise see: `JSON.stringify` calls a
48
+ * value's own `toJSON()` **before** the replacer, so an error class that defines one arrives here
49
+ * already turned into whatever that method returns — the stack and the cause gone, and the result
50
+ * usually shaped for the WIRE, because that is what an error's `toJSON()` is for. Seven backends
51
+ * on this stack define one, and `logger.error("x", { error: appErr })` wrote
52
+ * `{"error":{"error":{…}}}` in every one of them: double-nested, no stack, no cause, and
53
+ * invisible, because the line still looks like a log line.
54
+ *
55
+ * The original is still there. `JSON.stringify` calls the replacer with the HOLDER as `this`, and
56
+ * the holder's own property is the untouched value — so `this[key]` recovers the Error that
57
+ * `toJSON()` replaced. That is why this is a `function` and not an arrow.
58
+ *
59
+ * What a log line keeps off an Error is this file's decision, not the error's: an error class is
60
+ * free to define the body it sends a client, and the log still gets name, message, stack, cause
61
+ * and the allow-list.
62
+ *
63
+ * A new replacer per log line, because the `seen` set must not outlive one entry.
64
+ */
65
+ export declare function errorReplacer(): (this: unknown, key: string, value: unknown) => unknown;
66
+ /** The keys an Error may contribute to a log line, beside `name`, `message`, `stack` and `cause`. */
67
+ export declare const keptErrorFields: ReadonlySet<string>;
68
+ //# sourceMappingURL=serialize.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"serialize.d.ts","sourceRoot":"","sources":["../../src/logger/serialize.ts"],"names":[],"mappings":"AAAA;;;;;;;;GAQG;AA2EH;;;;;;;;;;;;;;;;;GAiBG;AACH,wBAAgB,eAAe,CAAC,KAAK,EAAE,MAAM,GAAG,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAEtE;AA+BD;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAmCG;AACH,wBAAgB,aAAa,IAAI,CAAC,IAAI,EAAE,OAAO,EAAE,GAAG,EAAE,MAAM,EAAE,KAAK,EAAE,OAAO,KAAK,OAAO,CA4BvF;AAED,qGAAqG;AACrG,eAAO,MAAM,eAAe,EAAE,WAAW,CAAC,MAAM,CAAqB,CAAC"}
@@ -0,0 +1,203 @@
1
+ /**
2
+ * The error serializer. This is the one file in `@gusnips/server` that exists because of an
3
+ * incident rather than because of duplication.
4
+ *
5
+ * Six backends shipped the same replacer, and it copied **every own enumerable property** off a
6
+ * caught Error into the log line. The comment beside the loop said it was there to pick up a
7
+ * Postgres `code`/`detail`/`hint`. What it actually picked up was whatever the SDK that threw had
8
+ * hung on the error — which for two vendors in use is the request the caller sent us.
9
+ */
10
+ /**
11
+ * The own enumerable properties a log line keeps off an Error. An ALLOW-list, not a deny-list,
12
+ * because an SDK hangs its own INPUTS off the error it throws, and the list of words vendors use
13
+ * for that is open-ended:
14
+ *
15
+ * - **A payment SDK.** Its signature-verification error carries `payload` — the entire unparsed
16
+ * webhook body — and `header`, the signature it was checked against. Measured on the pinned
17
+ * version: that error has **25 own enumerable properties** and this list admits **4** of them
18
+ * (`type`, `code`, `detail`, `statusCode`). Among the 21 dropped are `raw` (the whole error body)
19
+ * and `headers`. A webhook route is unauthenticated by definition, so the old loop let anyone on
20
+ * the internet write content of their choosing into the log by POSTing a junk signature.
21
+ * - **A Redis client.** A server-returned error carries `command = { name, args }`. On an AUTH
22
+ * failure those args are the password — written to stdout at the exact moment an operator is
23
+ * reading the log to find out why Redis is refusing them.
24
+ * - **Postgres.** A `DatabaseError` carries `where` and `internalQuery`, which hold **statement
25
+ * text with its literals in it**. Reproduced against Postgres 18: a CHECK violation raised inside
26
+ * a PL/pgSQL function set `where` to the failing INSERT, e-mail address and card number included.
27
+ * pg is in every backend here, so this one was never vendor-specific.
28
+ *
29
+ * A deny-list would have had to know `payload`, `header`, `raw`, `command`, `where` and the next
30
+ * vendor's word for it, and it learns each one from an incident. This list only has to know ours.
31
+ *
32
+ * **Add a key when our own code reads it off a caught error.** Two on it have no reader and say so
33
+ * below; everything else was measured across twelve backends.
34
+ */
35
+ const KEPT_ERROR_FIELDS = new Set([
36
+ // Postgres and PostgREST diagnostics. `detail` is the pg driver's spelling, `details`
37
+ // PostgREST's. `hint` has no reader anywhere in the fleet — it is kept because the donor
38
+ // comment promises it by name and a Postgres HINT never carries a value, only a suggestion.
39
+ "code",
40
+ "detail",
41
+ "details",
42
+ "hint",
43
+ "constraint",
44
+ "severity",
45
+ // Our own error classes, and the classified failures the modules throw.
46
+ "statusCode",
47
+ "status",
48
+ "messageKey",
49
+ "params",
50
+ "retryAfterSecs",
51
+ // The same number under an SDK's spelling. Our house word is `retryAfterSecs`; one SDK in the
52
+ // fleet says `retryAfter`, and two call sites read it off the caught error — so without this a
53
+ // rate-limit line says it was rate-limited and not for how long.
54
+ "retryAfter",
55
+ "kind",
56
+ "retryable",
57
+ // Which vendor error this was. One vendor sets `type` and never sets `name`, so without this
58
+ // every failure from it logs as a bare "Error". Read off the SDK, not guessed.
59
+ "type",
60
+ ]);
61
+ /**
62
+ * Postgres writes the **entire failing row** into DETAIL for a CHECK or NOT NULL violation —
63
+ * every column of it, whatever that table happens to hold. Measured against Postgres 18:
64
+ *
65
+ * detail: "Failing row contains (someone@example.com, 4242424242424242)."
66
+ *
67
+ * That is the one value-level rule in this file, and it is here rather than in a redaction pass
68
+ * because it is not a pattern over arbitrary text: it is one exact message form, and Postgres is
69
+ * the only thing that writes it. The unique-violation form — `Key (slug)=(demo) already exists.` —
70
+ * names only the key columns, which is the diagnostic this field is kept for, and survives.
71
+ */
72
+ const PG_FAILING_ROW = "Failing row contains (";
73
+ const OMITTED_ROW = "[row omitted: Postgres DETAIL for this error is the whole failing row]";
74
+ function keptValue(key, value) {
75
+ if ((key === "detail" || key === "details") && typeof value === "string") {
76
+ return value.startsWith(PG_FAILING_ROW) ? OMITTED_ROW : value;
77
+ }
78
+ return value;
79
+ }
80
+ /**
81
+ * A thrown value that is NOT an Error, narrowed to what a log line may print off it.
82
+ *
83
+ * The allow-list above is written against Errors, and until this the narrowing stopped there: an
84
+ * Error was filtered and whatever sat in its `cause` was copied whole. That gap is not theoretical
85
+ * here, it is this package's own doing — `errorBoundary` turns every non-Error throw into
86
+ * `new Error(toMessage(err), { cause: err })`, because Hono's `onError` never sees a non-Error and
87
+ * a PostgREST client rejects with plain objects. So in a Hono app the cause slot is precisely where
88
+ * a vendor's rejection object ends up, and a leak there reads as if the list had run.
89
+ *
90
+ * `cause` means "the error this one came from", so whatever sits in it is in the error slot and
91
+ * gets the same treatment. `name` and `message` come along because a rejection object usually
92
+ * carries them and a line with neither says nothing at all.
93
+ *
94
+ * Deliberate state it does NOT keep: context an app attaches on purpose. That belongs in the
95
+ * logger's `meta`, which is untouched — `cause` is not the place for it, and one incident of a
96
+ * vendor's request body in the log outweighs a field nobody put there deliberately.
97
+ */
98
+ export function narrowErrorLike(value) {
99
+ return narrow(value, new WeakSet());
100
+ }
101
+ function narrow(value, seen) {
102
+ seen.add(value);
103
+ const out = {};
104
+ const { name, message, cause } = value;
105
+ if (typeof name === "string")
106
+ out.name = name;
107
+ if (typeof message === "string")
108
+ out.message = message;
109
+ for (const [k, v] of Object.entries(value)) {
110
+ if (KEPT_ERROR_FIELDS.has(k))
111
+ out[k] = keptValue(k, v);
112
+ }
113
+ if (cause !== undefined)
114
+ out.cause = narrowCause(cause, seen);
115
+ return out;
116
+ }
117
+ /**
118
+ * An Error cause goes back unchanged, because `JSON.stringify` walks it into the replacer's own
119
+ * Error branch. Anything that is not an object is a string or a number, which is its own value.
120
+ */
121
+ function narrowCause(cause, seen) {
122
+ if (cause instanceof Error || typeof cause !== "object" || cause === null)
123
+ return cause;
124
+ // The recursion builds new objects, so the replacer's `seen` cannot see this chain: a rejection
125
+ // that holds itself would recurse until the stack ends, inside the one call that must never take
126
+ // the process down.
127
+ return seen.has(cause) ? "[Circular]" : narrow(cause, seen);
128
+ }
129
+ /**
130
+ * A `JSON.stringify` replacer that keeps log lines useful and crash-proof:
131
+ *
132
+ * - Errors serialize to a readable object. `message` and `stack` are non-enumerable, so a plain
133
+ * `JSON.stringify(err)` is `{}` — which is how a logger ends up printing nothing about the
134
+ * failure it was called to report. They are added explicitly, and the allow-listed extras ride
135
+ * along beside them.
136
+ * - A nested `cause` is followed, and so is an `AggregateError`'s `errors`. Both are
137
+ * non-enumerable, so both are invisible to the loop above; without this line "all attempts
138
+ * failed" is the whole log entry. Each one goes back through this replacer, so the allow-list
139
+ * covers the chain, not just the top — and a cause that is not an Error is narrowed here
140
+ * instead, by {@link narrowErrorLike}, because the replacer's Error branch would never see it.
141
+ * - bigints stringify instead of throwing.
142
+ * - Circular references collapse to "[Circular]" instead of crashing the log call.
143
+ *
144
+ * Paired with callers passing the RAW error rather than `String(err)`, this is why a log line
145
+ * never reads "[object Object]".
146
+ *
147
+ * One ordering fact decides what this replacer would otherwise see: `JSON.stringify` calls a
148
+ * value's own `toJSON()` **before** the replacer, so an error class that defines one arrives here
149
+ * already turned into whatever that method returns — the stack and the cause gone, and the result
150
+ * usually shaped for the WIRE, because that is what an error's `toJSON()` is for. Seven backends
151
+ * on this stack define one, and `logger.error("x", { error: appErr })` wrote
152
+ * `{"error":{"error":{…}}}` in every one of them: double-nested, no stack, no cause, and
153
+ * invisible, because the line still looks like a log line.
154
+ *
155
+ * The original is still there. `JSON.stringify` calls the replacer with the HOLDER as `this`, and
156
+ * the holder's own property is the untouched value — so `this[key]` recovers the Error that
157
+ * `toJSON()` replaced. That is why this is a `function` and not an arrow.
158
+ *
159
+ * What a log line keeps off an Error is this file's decision, not the error's: an error class is
160
+ * free to define the body it sends a client, and the log still gets name, message, stack, cause
161
+ * and the allow-list.
162
+ *
163
+ * A new replacer per log line, because the `seen` set must not outlive one entry.
164
+ */
165
+ export function errorReplacer() {
166
+ const seen = new WeakSet();
167
+ return function (key, value) {
168
+ const held = typeof this === "object" && this !== null
169
+ ? this[key]
170
+ : undefined;
171
+ if (held instanceof Error)
172
+ value = held;
173
+ if (typeof value === "bigint")
174
+ return value.toString();
175
+ if (value instanceof Error) {
176
+ if (seen.has(value))
177
+ return "[Circular]";
178
+ seen.add(value);
179
+ const out = { name: value.name, message: value.message };
180
+ for (const [k, v] of Object.entries(value)) {
181
+ if (KEPT_ERROR_FIELDS.has(k))
182
+ out[k] = keptValue(k, v);
183
+ }
184
+ const { cause } = value;
185
+ if (cause !== undefined)
186
+ out.cause = narrowCause(cause, seen);
187
+ if (value instanceof AggregateError)
188
+ out.errors = value.errors;
189
+ if (value.stack)
190
+ out.stack = value.stack;
191
+ return out;
192
+ }
193
+ if (typeof value === "object" && value !== null) {
194
+ if (seen.has(value))
195
+ return "[Circular]";
196
+ seen.add(value);
197
+ }
198
+ return value;
199
+ };
200
+ }
201
+ /** The keys an Error may contribute to a log line, beside `name`, `message`, `stack` and `cause`. */
202
+ export const keptErrorFields = KEPT_ERROR_FIELDS;
203
+ //# sourceMappingURL=serialize.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"serialize.js","sourceRoot":"","sources":["../../src/logger/serialize.ts"],"names":[],"mappings":"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"}
@@ -0,0 +1,107 @@
1
+ /**
2
+ * How a backend answers: the success envelope, and the one function that turns a thrown thing
3
+ * into an HTTP answer.
4
+ *
5
+ * Nothing here knows about a framework, and that is a measurement rather than a preference.
6
+ * Five backends put this logic inside one `app.onError`, and then **three of them re-derived
7
+ * the same two arms inside an MCP tool wrapper** so an agent would get a real refusal instead
8
+ * of "an unexpected error occurred". A fourth re-derived the error body inside a background
9
+ * worker's health handler and got it wrong, answering a caught Redis message on a 503 — the
10
+ * API next door masks exactly that. A `Context`-shaped function would serve one of those four
11
+ * callers. The framework adapter is eight lines and lives in `/hono`.
12
+ */
13
+ import type { ApiError, ApiSuccess, PaginationMeta } from "@gusnips/http";
14
+ /** Every 2xx body is `{ data }`, or `{ data, meta }` where a route has counts to report. */
15
+ export declare function ok<T, M = PaginationMeta>(data: T, meta?: M): {
16
+ status: 200;
17
+ body: ApiSuccess<T, M>;
18
+ };
19
+ export declare function created<T>(data: T): {
20
+ status: 201;
21
+ body: ApiSuccess<T, PaginationMeta>;
22
+ };
23
+ /**
24
+ * `hasMore` is computed from the page that was actually returned, not from `limit`: a page cut
25
+ * short by a filter still has to answer the question honestly. Identical, to the field, in five
26
+ * donors.
27
+ */
28
+ export declare function paginated<T>(rows: T[], meta: Omit<PaginationMeta, "hasMore">): {
29
+ status: 200;
30
+ body: ApiSuccess<T[], PaginationMeta>;
31
+ };
32
+ export declare function noContent(): {
33
+ status: 204;
34
+ body: null;
35
+ };
36
+ export interface ErrorAnswer<Code extends string = string> {
37
+ status: number;
38
+ body: ApiError<Code>;
39
+ /** `Retry-After` when the refusal states a wait; `WWW-Authenticate` on a 401. */
40
+ headers: Record<string, string>;
41
+ /**
42
+ * What kind of failure this was, which is the one thing a caller cannot work out from the
43
+ * status. A 500 raised on purpose and a `TypeError` that escaped are both 500s, and only the
44
+ * second one means nobody is watching a log for it — every donor fires its admin alert on
45
+ * exactly that branch.
46
+ */
47
+ kind: "client" | "server" | "unexpected";
48
+ }
49
+ /** The envelope a masked 5xx, an unexpected throw, or a refused body is answered with. */
50
+ export interface CannedError<Code extends string, Key extends string> {
51
+ code: Code;
52
+ /** English, for logs, `curl` and agents. A client localizes from `messageKey`. */
53
+ message: string;
54
+ messageKey?: Key;
55
+ }
56
+ export interface ErrorResponseOptions<Code extends string, Key extends string> {
57
+ /** Answers a masked 5xx and anything that escaped. */
58
+ internal: CannedError<Code, Key>;
59
+ /** Answers a validation failure. */
60
+ validation: CannedError<Code, Key>;
61
+ /**
62
+ * The 5xx codes whose message is replaced. Defaults to `["INTERNAL_ERROR"]`, which is what
63
+ * four of the five newest donors do, and their reason is worth keeping: flattening a
64
+ * `GATEWAY_ERROR` or a `SERVICE_UNAVAILABLE` into a generic 500 "would take away the one
65
+ * thing that tells a developer whether to retry."
66
+ *
67
+ * **That default is safe because of your call sites, not because of this code.** It holds
68
+ * only while every non-masked 5xx is handed a message somebody wrote for the client. A repo
69
+ * whose repository layer interpolates the driver's error into the message it raises — one
70
+ * donor's does, deliberately, so that duplicate-key heuristics keep working — wants
71
+ * `maskAll` instead.
72
+ */
73
+ maskedCodes?: readonly Code[];
74
+ /** Replace every 5xx message, and let `expose` be what opts an authored sentence back in. */
75
+ maskAll?: boolean;
76
+ /**
77
+ * Drop `details` from a 5xx whose message you did NOT mask — a separate knob because it is a
78
+ * separate decision. The newest donors put a readiness report in a 503's details, naming
79
+ * which dependency is down so a deploy gate and a human at 3am can both read it; another
80
+ * donor's details are where caught error text is recorded, and must never go out. Both are
81
+ * right about their own repo, which is why this is not folded into the mask above. (A masked
82
+ * 5xx drops its details on its own: the body is built fresh from `internal`.)
83
+ *
84
+ * The sharpest reason to turn it on is a caught driver error handed straight to `details`.
85
+ * On a CHECK or NOT NULL violation Postgres writes the ENTIRE failing row into its `detail`
86
+ * field — `Failing row contains (someone@example.com, 4242…)`, every column, values
87
+ * included. Measured against a real server, not assumed.
88
+ */
89
+ maskDetails?: boolean;
90
+ }
91
+ /**
92
+ * Binds the mask policy and the two canned bodies, and returns the function that answers.
93
+ *
94
+ * Bound once, at the app's edge, because the alternative is what the reading found: four places
95
+ * in one fleet deciding the mask separately, and the one furthest from the API getting it
96
+ * wrong. Every other door — a tool wrapper, a worker's health port — imports the same bound
97
+ * function and cannot disagree with the API about what a refusal looks like.
98
+ *
99
+ * ```ts
100
+ * export const errorResponse = createErrorResponse<ErrorCode, MessageKey>({
101
+ * internal: { code: "INTERNAL_ERROR", message: "Something on our side failed" },
102
+ * validation: { code: "VALIDATION_ERROR", message: "The request could not be read" },
103
+ * });
104
+ * ```
105
+ */
106
+ export declare function createErrorResponse<Code extends string = string, Key extends string = string>(opts: ErrorResponseOptions<Code, Key>): (err: unknown) => ErrorAnswer<Code>;
107
+ //# sourceMappingURL=responses.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"responses.d.ts","sourceRoot":"","sources":["../src/responses.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;GAWG;AACH,OAAO,KAAK,EAAE,QAAQ,EAAE,UAAU,EAAE,cAAc,EAAE,MAAM,eAAe,CAAC;AAG1E,4FAA4F;AAC5F,wBAAgB,EAAE,CAAC,CAAC,EAAE,CAAC,GAAG,cAAc,EAAE,IAAI,EAAE,CAAC,EAAE,IAAI,CAAC,EAAE,CAAC;;;EAG1D;AAED,wBAAgB,OAAO,CAAC,CAAC,EAAE,IAAI,EAAE,CAAC;;;EAGjC;AAED;;;;GAIG;AACH,wBAAgB,SAAS,CAAC,CAAC,EAAE,IAAI,EAAE,CAAC,EAAE,EAAE,IAAI,EAAE,IAAI,CAAC,cAAc,EAAE,SAAS,CAAC;;;EAM5E;AAED,wBAAgB,SAAS;;;EAExB;AAED,MAAM,WAAW,WAAW,CAAC,IAAI,SAAS,MAAM,GAAG,MAAM;IACvD,MAAM,EAAE,MAAM,CAAC;IACf,IAAI,EAAE,QAAQ,CAAC,IAAI,CAAC,CAAC;IACrB,iFAAiF;IACjF,OAAO,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAAC;IAChC;;;;;OAKG;IACH,IAAI,EAAE,QAAQ,GAAG,QAAQ,GAAG,YAAY,CAAC;CAC1C;AAED,0FAA0F;AAC1F,MAAM,WAAW,WAAW,CAAC,IAAI,SAAS,MAAM,EAAE,GAAG,SAAS,MAAM;IAClE,IAAI,EAAE,IAAI,CAAC;IACX,kFAAkF;IAClF,OAAO,EAAE,MAAM,CAAC;IAChB,UAAU,CAAC,EAAE,GAAG,CAAC;CAClB;AAED,MAAM,WAAW,oBAAoB,CAAC,IAAI,SAAS,MAAM,EAAE,GAAG,SAAS,MAAM;IAC3E,sDAAsD;IACtD,QAAQ,EAAE,WAAW,CAAC,IAAI,EAAE,GAAG,CAAC,CAAC;IACjC,oCAAoC;IACpC,UAAU,EAAE,WAAW,CAAC,IAAI,EAAE,GAAG,CAAC,CAAC;IACnC;;;;;;;;;;;OAWG;IACH,WAAW,CAAC,EAAE,SAAS,IAAI,EAAE,CAAC;IAC9B,6FAA6F;IAC7F,OAAO,CAAC,EAAE,OAAO,CAAC;IAClB;;;;;;;;;;;;OAYG;IACH,WAAW,CAAC,EAAE,OAAO,CAAC;CACvB;AA+GD;;;;;;;;;;;;;;GAcG;AACH,wBAAgB,mBAAmB,CAAC,IAAI,SAAS,MAAM,GAAG,MAAM,EAAE,GAAG,SAAS,MAAM,GAAG,MAAM,EAC3F,IAAI,EAAE,oBAAoB,CAAC,IAAI,EAAE,GAAG,CAAC,IAIP,KAAK,OAAO,KAAG,WAAW,CAAC,IAAI,CAAC,CA8C/D"}
@@ -0,0 +1,183 @@
1
+ import { AppError } from "./errors.js";
2
+ /** Every 2xx body is `{ data }`, or `{ data, meta }` where a route has counts to report. */
3
+ export function ok(data, meta) {
4
+ const body = meta === undefined ? { data } : { data, meta };
5
+ return { status: 200, body };
6
+ }
7
+ export function created(data) {
8
+ const body = { data };
9
+ return { status: 201, body };
10
+ }
11
+ /**
12
+ * `hasMore` is computed from the page that was actually returned, not from `limit`: a page cut
13
+ * short by a filter still has to answer the question honestly. Identical, to the field, in five
14
+ * donors.
15
+ */
16
+ export function paginated(rows, meta) {
17
+ const body = {
18
+ data: rows,
19
+ meta: { ...meta, hasMore: meta.offset + rows.length < meta.total },
20
+ };
21
+ return { status: 200, body };
22
+ }
23
+ export function noContent() {
24
+ return { status: 204, body: null };
25
+ }
26
+ /**
27
+ * `retryAfterSecs` also rides inside `details`, because that is where clients already look —
28
+ * an explicit `null` included, since "waiting cannot fix this" is an answer a client needs and
29
+ * the only alternative is the hand-written code list this replaces.
30
+ *
31
+ * Only an object `details` can carry it. Spreading an ARRAY — a list of validation issues —
32
+ * turns it into `{"0": …}` and breaks every client that parses it; spreading a STRING turns it
33
+ * into one key per character. Anything that is not a plain object is handed back untouched, and
34
+ * the header still tells that caller when to come back.
35
+ */
36
+ function detailsWithRetry(err) {
37
+ if (err.retryAfterSecs === undefined)
38
+ return err.details;
39
+ const carries = err.details === undefined ||
40
+ (typeof err.details === "object" && err.details !== null && !Array.isArray(err.details));
41
+ if (!carries)
42
+ return err.details;
43
+ return { ...err.details, retryAfterSecs: err.retryAfterSecs };
44
+ }
45
+ /**
46
+ * The wire body for an error somebody raised on purpose.
47
+ *
48
+ * It is a function rather than a method on `AppError`, and that is a fix rather than a style
49
+ * choice — see the note on the class. It also keeps the envelope in one file with the mask,
50
+ * instead of in two.
51
+ */
52
+ function appErrorBody(err) {
53
+ const details = detailsWithRetry(err);
54
+ return {
55
+ error: {
56
+ code: err.code,
57
+ message: err.message,
58
+ ...(err.messageKey !== undefined && { messageKey: err.messageKey }),
59
+ ...(err.params !== undefined && { params: err.params }),
60
+ ...(details !== undefined && { details }),
61
+ },
62
+ };
63
+ }
64
+ function envelope(canned, details) {
65
+ return {
66
+ error: {
67
+ code: canned.code,
68
+ message: canned.message,
69
+ ...(canned.messageKey !== undefined && { messageKey: canned.messageKey }),
70
+ ...(details !== undefined && { details }),
71
+ },
72
+ };
73
+ }
74
+ /**
75
+ * Recognize a validation failure without importing the validator.
76
+ *
77
+ * `name === "ZodError"` plus an `issues` array holds for zod 3.25, 4.4 and 4.5 — measured, all
78
+ * three, because this package must not make an adopter's validator its own dependency. It also
79
+ * accepts an issue list that arrived some other way, which is what a second door (an MCP tool,
80
+ * a queue consumer) needs.
81
+ */
82
+ function zodIssues(err) {
83
+ if (typeof err !== "object" || err === null)
84
+ return null;
85
+ const { name, issues } = err;
86
+ if (name !== "ZodError" || !Array.isArray(issues))
87
+ return null;
88
+ return issues;
89
+ }
90
+ /**
91
+ * The field path, the rule it failed, and — for a range — the BOUND it failed against.
92
+ *
93
+ * Never the rejected value, and never the schema's internals. All six donors carry a version of
94
+ * that comment; what none of them carries is the proof, so here it is: handing the validator's
95
+ * issues straight to the client ships back the caller's own key names (`keys`), the enum's
96
+ * allowed values (`values`), the validator's English sentence and the expected type
97
+ * (`origin`) — four disclosures from one convenience, and two repos in the fleet do it today.
98
+ * An audit note written against an older validator looks for `received`, which the current one
99
+ * no longer emits; the projection is an allow-list precisely so a rename cannot reopen this.
100
+ *
101
+ * The bound is the exception, and it belongs to the caller: it is the published contract, and
102
+ * a `too_big` without it costs somebody a bisect to rediscover a number our own docs state.
103
+ */
104
+ function safeIssues(issues) {
105
+ return issues.map((issue) => ({
106
+ path: issue.path,
107
+ code: issue.code,
108
+ ...(typeof issue.maximum === "number" && { maximum: issue.maximum }),
109
+ ...(typeof issue.minimum === "number" && { minimum: issue.minimum }),
110
+ }));
111
+ }
112
+ /**
113
+ * `AppError` is generic over the product's own code union, and no runtime check can verify
114
+ * membership. The predicate asserts what `createAppError` guarantees: every `AppError` in this
115
+ * app was built from the map whose keys are `Code`.
116
+ */
117
+ function isAppError(err) {
118
+ return err instanceof AppError;
119
+ }
120
+ const DEFAULT_MASKED = ["INTERNAL_ERROR"];
121
+ /**
122
+ * Binds the mask policy and the two canned bodies, and returns the function that answers.
123
+ *
124
+ * Bound once, at the app's edge, because the alternative is what the reading found: four places
125
+ * in one fleet deciding the mask separately, and the one furthest from the API getting it
126
+ * wrong. Every other door — a tool wrapper, a worker's health port — imports the same bound
127
+ * function and cannot disagree with the API about what a refusal looks like.
128
+ *
129
+ * ```ts
130
+ * export const errorResponse = createErrorResponse<ErrorCode, MessageKey>({
131
+ * internal: { code: "INTERNAL_ERROR", message: "Something on our side failed" },
132
+ * validation: { code: "VALIDATION_ERROR", message: "The request could not be read" },
133
+ * });
134
+ * ```
135
+ */
136
+ export function createErrorResponse(opts) {
137
+ const maskedCodes = opts.maskedCodes ?? DEFAULT_MASKED;
138
+ return function errorResponse(err) {
139
+ const issues = zodIssues(err);
140
+ if (issues !== null) {
141
+ return {
142
+ status: 400,
143
+ body: envelope(opts.validation, safeIssues(issues)),
144
+ headers: {},
145
+ kind: "client",
146
+ };
147
+ }
148
+ if (isAppError(err)) {
149
+ const headers = {};
150
+ // The standard header, not just our envelope: every HTTP client, proxy and SDK already
151
+ // knows how to wait on `Retry-After`, and none of them knows `details.retryAfterSecs`.
152
+ // A number only — a refusal that waiting cannot fix says so in the body, because
153
+ // `Retry-After: null` is a header that states a wait and names no time.
154
+ if (typeof err.retryAfterSecs === "number") {
155
+ headers["Retry-After"] = String(err.retryAfterSecs);
156
+ }
157
+ // RFC 6750 §3: a 401 names the scheme it wants. Without it a 401 is a closed door with no
158
+ // handle — which is what an agent, with no human to ask, is left holding.
159
+ if (err.statusCode === 401)
160
+ headers["WWW-Authenticate"] = "Bearer";
161
+ if (err.statusCode < 500)
162
+ return { status: err.statusCode, body: appErrorBody(err), headers, kind: "client" };
163
+ const hide = !err.expose && (opts.maskAll === true || maskedCodes.includes(err.code));
164
+ if (hide) {
165
+ // The message is replaced; the STATUS is not. A status is chosen by our own map and
166
+ // discloses nothing, while it is the only thing left telling a client whether waiting
167
+ // can help — collapsing a masked 502 into a 500 throws that away for no gain. One
168
+ // donor does collapse it, and never noticed because it masks only the code that is
169
+ // already a 500.
170
+ return { status: err.statusCode, body: envelope(opts.internal), headers, kind: "server" };
171
+ }
172
+ const body = appErrorBody(err);
173
+ if (opts.maskDetails === true)
174
+ delete body.error.details;
175
+ return { status: err.statusCode, body, headers, kind: "server" };
176
+ }
177
+ // Nothing raised this on purpose, so nothing in it was written for a reader. Whatever it
178
+ // says stays in the log: a background worker in the fleet answers `toMessage(err)` on its
179
+ // health port today, which is a driver's sentence on the wire.
180
+ return { status: 500, body: envelope(opts.internal), headers: {}, kind: "unexpected" };
181
+ };
182
+ }
183
+ //# sourceMappingURL=responses.js.map