@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
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Gustavo Salomé
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 ADDED
@@ -0,0 +1,323 @@
1
+ # @gusnips/server
2
+
3
+ One error shape, one response envelope, and one function that turns a thrown thing into an HTTP
4
+ answer. No framework at the root: it runs in a Cloudflare Worker, in a Bun or Node server, in a
5
+ queue consumer, and in an MCP tool handler.
6
+
7
+ ```bash
8
+ bun add @gusnips/server @gusnips/http
9
+ ```
10
+
11
+ `@gusnips/http` is a required peer: it declares the envelope, and your API and your browser
12
+ client both import it, so there is one declaration of the wire contract and not two.
13
+
14
+ ```ts
15
+ import { ok } from "@gusnips/server";
16
+
17
+ ok({ id: 1 });
18
+ // → { status: 200, body: { data: { id: 1 } } }
19
+ ```
20
+
21
+ Every 2xx body is `{ data }`. Every refusal is `{ error: { code, message, messageKey?, params?,
22
+ details? } }`. That is the envelope `@gusnips/http` declares and a browser client parses, so the
23
+ two ends of one request never disagree about the shape.
24
+
25
+ **Import `ApiError`, `ApiSuccess` and `PaginationMeta` from `@gusnips/http`, not from here.**
26
+ This package does not re-export them, on purpose: two names for one type is how a version skew
27
+ becomes invisible. You already have the import — the contract is the package your client reads
28
+ it from too.
29
+
30
+ ## Answering a request
31
+
32
+ `ok`, `created`, `paginated` and `noContent` return a plain `{ status, body }`. Hand it to
33
+ whatever is holding the connection.
34
+
35
+ ```ts
36
+ import { paginated } from "@gusnips/server";
37
+
38
+ paginated(rows, { total: 128, limit: 20, offset: 100 });
39
+ // → { status: 200, body: { data: rows, meta: { total: 128, limit: 20, offset: 100, hasMore: true } } }
40
+ ```
41
+
42
+ `hasMore` is computed from the rows you actually returned, not from `limit`, so a page cut short
43
+ by a filter still answers honestly.
44
+
45
+ ## Refusing a request
46
+
47
+ Declare your own codes and what status each one answers with. The `satisfies` is the line that
48
+ matters: adding a code without a status becomes a build error instead of a route answering 500
49
+ for a refusal it knew how to explain.
50
+
51
+ ```ts
52
+ import { createAppError } from "@gusnips/server";
53
+
54
+ export const ERROR_STATUS = {
55
+ VALIDATION_ERROR: 400,
56
+ UNAUTHORIZED: 401,
57
+ NOT_FOUND: 404,
58
+ RATE_LIMIT_EXCEEDED: 429,
59
+ INTERNAL_ERROR: 500,
60
+ } as const satisfies Record<ErrorCode, number>;
61
+
62
+ const appError = createAppError<typeof ERROR_STATUS, MessageKey>(ERROR_STATUS);
63
+
64
+ export const errors = {
65
+ notFound: (what = "Resource") => appError("NOT_FOUND", `${what} not found`),
66
+ rateLimit: (retryAfterSecs: number) =>
67
+ appError("RATE_LIMIT_EXCEEDED", "Too many requests", { retryAfterSecs }),
68
+ };
69
+ ```
70
+
71
+ Then throw one, anywhere:
72
+
73
+ ```ts
74
+ throw errors.notFound("Workspace");
75
+ ```
76
+
77
+ `AppError` has no `toJSON()`. `JSON.stringify` calls a value's own `toJSON()` **before** it calls
78
+ the replacer, so a class that defines one hands a logger whatever that method returns instead of
79
+ the error: `logger.error("failed", { error: err })` writes `{"error":{"error":{code,message}}}`
80
+ with no stack and no cause. Seven backends on this stack define one and all seven log exactly
81
+ that.
82
+
83
+ `createLogger` recovers the error anyway — the replacer reads it back off the holder — so your
84
+ own classes are safe either way. `AppError` still does not define one, because the wire body
85
+ belongs to `errorResponse`, where the mask lives: one function owns the shape a client sees, and
86
+ the error stays an error.
87
+
88
+ The map has to be `as const`, or every value reads as `number` and the rule below cannot see a 429. A map without it is refused, with the instruction in the compiler's message.
89
+
90
+ ### A 429 says how it clears
91
+
92
+ `RATE_LIMIT_EXCEEDED` maps to 429, so `retryAfterSecs` is a **required argument**: the seconds
93
+ until the caller may retry, or `null` for a refusal that waiting cannot fix. Leave it out and
94
+ the code does not compile.
95
+
96
+ ```ts
97
+ rateLimit: (retryAfterSecs: number) =>
98
+ appError("RATE_LIMIT_EXCEEDED", "Too many requests", { retryAfterSecs }),
99
+
100
+ concurrency: (limit: number) =>
101
+ // A slot frees when somebody else's job finishes. There is no wait to state.
102
+ appError("QUOTA_EXCEEDED", `${limit} jobs already running`, { retryAfterSecs: null }),
103
+ ```
104
+
105
+ This is the rule with the best bug-per-line ratio in the whole extraction. One backend writes a
106
+ `resetAt` ISO date that no HTTP client parses, and then keeps a hand-written list of "codes that
107
+ do not clear by waiting" in its browser app to compensate — its own comment says that is why the
108
+ list exists. Another backend needs no list, because every 429 it sends states its wait. A third
109
+ raises a spent daily cap with no wait at all, on a code its client reads as transient, so the
110
+ browser retries a limit that clears at midnight — twice, immediately, and says the same thing
111
+ three times to a limiter that is already counting.
112
+
113
+ Asking each refusal how it clears answers the question those lists were guessing at, and it
114
+ answers it in the one place that knows: where the refusal is raised. A code cannot know — the
115
+ same `QUOTA_EXCEEDED` can be a month that clears in days or a slot that clears in two seconds.
116
+
117
+ Of 40 places that raise a 429 in the fleet this came from, 34 already state a wait. Of the six
118
+ left, two genuinely cannot: a concurrency slot frees when another job finishes, and a cap on
119
+ live objects clears by archiving one, never by waiting. `null` is for those. An omission is
120
+ invisible in a diff; a `null` is a claim somebody has to read.
121
+
122
+ `errorResponse` renders a number as the standard `Retry-After` header **and** folds it into
123
+ `details`, so an HTTP client, a proxy and your own SDK all learn the same wait from one value. A
124
+ `null` is folded in without a header, because a `Retry-After` that names no time is worse than
125
+ none.
126
+
127
+ Two edges worth knowing:
128
+
129
+ - The obligation follows the code's whole status set. A code narrowed to a union that _could_ be
130
+ the 429 owes the wait too — `appError(code, msg)` where `code` is
131
+ `"NOT_FOUND" | "RATE_LIMIT_EXCEEDED"` does not compile without one.
132
+ - **Only the obligation is 429-only.** Any code may state a wait, and a number renders
133
+ `Retry-After` at any status. Reach for it when ONE of your codes is raised in two senses —
134
+ one that clears on its own and one that does not. A `SERVICE_UNAVAILABLE` meaning "not
135
+ configured on this deployment" and one meaning "did not answer just now" are the same code and
136
+ the same status, so a client cannot separate them; the raiser can, with a number or an
137
+ explicit `null`.
138
+
139
+ `new AppError(429, …)` skips the rule, because the rule lives on the factory: only the factory
140
+ knows your map. That is the reason to prefer `createAppError`.
141
+
142
+ ## Turning a throw into an answer
143
+
144
+ ```ts
145
+ import { createErrorResponse } from "@gusnips/server";
146
+
147
+ export const errorResponse = createErrorResponse<ErrorCode, MessageKey>({
148
+ internal: { code: "INTERNAL_ERROR", message: "Something on our side failed" },
149
+ validation: { code: "VALIDATION_ERROR", message: "The request could not be read" },
150
+ });
151
+
152
+ errorResponse(err);
153
+ // → { status, body, headers, kind }
154
+ ```
155
+
156
+ Every throw lands in one of four arms:
157
+
158
+ | What was thrown | Answer | `kind` |
159
+ | ---------------------------- | -------------------------------------------- | ------------ |
160
+ | a validation failure | 400, with the field path and the failed rule | `client` |
161
+ | an `AppError` under 500 | itself | `client` |
162
+ | an `AppError` of 500 or more | itself, or the masked body | `server` |
163
+ | anything else | a generic 500 | `unexpected` |
164
+
165
+ `kind` is the one thing you cannot read off the status. A 500 you raised and a `TypeError` that
166
+ escaped are both 500s, and only the second one means nobody is watching a log for it — which is
167
+ the branch where an alert belongs.
168
+
169
+ Bind it once, at the edge of your app, and import that one function everywhere else. A tool
170
+ handler and a worker's health port then cannot disagree with the API about what a refusal looks
171
+ like. They did, in the fleet this came from: a worker's health route answered a caught Redis
172
+ message on a 503 while the API next door masked exactly that.
173
+
174
+ ### What reaches the client, and what does not
175
+
176
+ **A validation failure ships the field path, the failed rule, and — for a range — the bound it
177
+ failed against. Nothing else.** Handing your validator's issues straight through ships back the
178
+ caller's own key names, the enum's allowed values, the validator's English sentence and the
179
+ expected type. The bound is the exception and it belongs to the caller: it is your published
180
+ contract, and a "too big" without it costs somebody a bisect to rediscover a number your docs
181
+ already state.
182
+
183
+ Validation is recognized by shape, not by an import, so your validator does not become this
184
+ package's dependency. Measured against zod 3.25, 4.4 and 4.5.
185
+
186
+ **A 5xx keeps its message unless its code is masked.** By default only `INTERNAL_ERROR` is,
187
+ because that is the code you raise when something unexpected broke, so its message may carry
188
+ internals. The others were written for the client, and flattening a `GATEWAY_ERROR` into a
189
+ generic 500 takes away the one thing that tells a developer whether to retry.
190
+
191
+ > That default is safe because of your call sites, not because of this code. It holds only while
192
+ > every unmasked 5xx is handed a message somebody wrote for a reader. If your data layer
193
+ > interpolates the driver's error into what it raises, use `maskAll` and let `expose` opt the
194
+ > authored sentences back in.
195
+
196
+ `maskDetails` is a separate knob, because it is a separate decision. Some backends put a
197
+ readiness report in a 503's `details` — which dependency is down, for the deploy gate and for a
198
+ human at 3am — and need it on the wire. Others record caught error text there, and must never
199
+ send it. Both are right about their own repo.
200
+
201
+ **When a message is masked, the status is not.** A status comes from your own map and discloses
202
+ nothing, and it is the last thing telling a client whether waiting can help.
203
+
204
+ **An unexpected throw never reaches the client.** Log it with its `cause` and its stack; answer
205
+ the generic 500.
206
+
207
+ ## Logging a failure
208
+
209
+ `createLogger` writes one JSON line per event to stdout, and nothing else. Twelve backends were
210
+ read for this and not one installs a logging library, so this ships no transports, no file
211
+ rotation and no extra levels — every one of them runs under something that already owns stdout.
212
+
213
+ ```ts
214
+ import { createLogger } from "@gusnips/server";
215
+
216
+ const logger = createLogger({ level: process.env.LOG_LEVEL });
217
+
218
+ logger.error("charge failed", { orderId, error: err }); // the RAW error, never String(err)
219
+ ```
220
+
221
+ **Pass the error itself.** `message` and `stack` are non-enumerable, so a plain
222
+ `JSON.stringify(err)` is `{}` — which is how a logger ends up printing nothing about the failure
223
+ it was called to report. The serializer adds them, follows the `cause` chain and an
224
+ `AggregateError`'s `errors`, and collapses a circular reference instead of crashing the log call.
225
+
226
+ **What it keeps off an error is an allow-list**, and that is the one thing here that exists
227
+ because of an incident rather than because of duplication. An SDK hangs its own INPUTS off the
228
+ error it throws: a payment vendor's signature-verification error carries the unparsed webhook
229
+ body and the signature, a Redis client puts the AUTH password in `command.args`, and a Postgres
230
+ `DatabaseError` carries statement text with its literals in it. A loop over own properties copies
231
+ all of that, and a webhook route is unauthenticated by definition — so anyone on the internet
232
+ could choose what went into the log. The list admits 4 of that payment error's 25 properties, and
233
+ it covers the `cause` chain, including a link that is not an `Error`.
234
+
235
+ `level` is an argument rather than a `process.env` read, and that is the boundary the package is
236
+ built on: a Cloudflare Worker has no `process` at all, so a module-scope read makes a package
237
+ Node-only by accident. A Worker passes `env.LOG_LEVEL` from its handler argument. An unrecognized
238
+ level throws at construction, because a box running at the wrong level is discovered during the
239
+ incident it was meant to explain.
240
+
241
+ ## Hono
242
+
243
+ `@gusnips/server/hono` is the only part that knows a framework, which is why it is a subpath:
244
+ `hono` is an optional peer and nothing in the root entry imports it. Needs `hono >= 4.9.9` —
245
+ before that, `routePath(c, -1)` silently ignores the `-1` and the request line names the wrong
246
+ route.
247
+
248
+ ```ts
249
+ import { errorBoundary, errorHandler, notFoundHandler, requestLogger } from "@gusnips/server/hono";
250
+
251
+ app.use(requestLogger({ logger })); // first, so it times and sees everything under it
252
+ app.use(errorBoundary); // right after
253
+ app.onError(errorHandler({ errorResponse, logger }));
254
+ app.notFound(notFoundHandler(errorResponse(errors.notFound("Route"))));
255
+ ```
256
+
257
+ **`errorBoundary` is not optional.** Hono hands `onError` only what is `instanceof Error`.
258
+ Anything else is rethrown past every layer and escapes as an unhandled rejection: no answer, a
259
+ dropped connection, and a browser that reports it as a CORS failure — which sends whoever reads
260
+ it to the wrong layer entirely. A PostgREST client rejects with plain objects, so this is not
261
+ hypothetical. The boundary wraps one in an `Error` and keeps the original as `cause`.
262
+
263
+ **The request line names the route TEMPLATE, never the path.** A path is what puts a customer's
264
+ document number in a log and in whatever reads that log afterwards. `/health` is skipped with
265
+ everything under it, because every deploy polls it in a loop; a throw is logged anyway.
266
+
267
+ The request id goes back on `X-Request-ID`, on every answer including `onError`'s and
268
+ `notFound`'s. A caller's own id is echoed only if it is 64 characters of `A-Z a-z 0-9 . _ -`,
269
+ so the id in a line is always either the caller's or ours. Cross-origin, list that header in your
270
+ CORS `exposeHeaders` or the browser hides it from the page.
271
+
272
+ ### The guard check
273
+
274
+ ```ts
275
+ import { assertEveryRouteGuarded, guard, underAny } from "@gusnips/server/hono";
276
+
277
+ export const requireUser = guard(async (c, next) => { … }); // mark it where it is defined
278
+
279
+ // in a test
280
+ assertEveryRouteGuarded(buildApp(), { isPublic: underAny(PUBLIC_PREFIXES) });
281
+ ```
282
+
283
+ It walks every registered route through Hono's **own matcher** and fails naming each endpoint no
284
+ guard runs in front of. The matcher is the point: a `use` registered AFTER its `route` never runs
285
+ — the handler answers and the guard silently does not fire. A route whose guard did not fire is
286
+ indistinguishable from one with no guard, and comparing pattern lists cannot tell you which you
287
+ have.
288
+
289
+ Pass the app's own public rule, never a second list kept for the test — an exemption list nothing
290
+ else reads is the next thing to drift. It also fails a guard that runs in front of nothing, and
291
+ an app with no endpoints, so the check cannot pass by asking nothing.
292
+
293
+ ## What this package does not ship
294
+
295
+ Each of these was measured, not assumed.
296
+
297
+ - **Your error codes.** Six backends' factory tables hold 46 distinct names and exactly nine
298
+ appear in all six. The codes are an API's vocabulary. This package ships the shape, the wire
299
+ format and the mask; the names stay with the product that speaks them.
300
+ - **The code→status map, and a list of HTTP statuses.** Both are one object literal in your repo,
301
+ and writing them there is what makes `satisfies` catch a code you forgot. A helper wrapping
302
+ them would add a call and subtract nothing.
303
+ - **A `messageKey` catalog.** The server owns the condition and the `params`; the client owns the
304
+ prose.
305
+ - **A logging library, a transport, or an alerting client.** The measured gap between 60 lines
306
+ of `console.log(JSON.stringify(...))` and a real logging library is the error serializer, and
307
+ the standard one ships the same copy-loop this package exists to remove — so `createLogger` is
308
+ those 60 lines with the serializer fixed, and nothing else. `errorResponse` returns `kind` and
309
+ `errorHandler` takes `onUnexpected`, so alerting is yours to route.
310
+ - **A styled error page.**
311
+
312
+ ## Rules it will not let you break
313
+
314
+ - A 429 raised with no wait does not compile.
315
+ - A code→status map that is not `as const` is refused.
316
+ - A code outside your map, or a message key outside your union, does not compile.
317
+
318
+ ## Develop
319
+
320
+ ```bash
321
+ bun install
322
+ cd server && bun run test
323
+ ```
@@ -0,0 +1,190 @@
1
+ /**
2
+ * The one error a route throws, and the one function that turns any thrown value into words.
3
+ *
4
+ * Extracted from six backends whose copies of this file are byte-identical in the parts that
5
+ * matter: the envelope builder in four of them, `toMessage()` in six. Where they differ, the
6
+ * version carrying the production reason won — every comment below names a failure somebody
7
+ * shipped.
8
+ *
9
+ * Nothing here knows about the wire. That is deliberate and it is the fix to a live bug; see
10
+ * the note on {@link AppError}.
11
+ */
12
+ export interface AppErrorOptions<Key extends string = string> {
13
+ /** What makes the refusal ACTIONABLE: the plan that lifts a 402, the scope of a quota. */
14
+ details?: unknown;
15
+ /**
16
+ * The stable key a client localizes. Type it against your own closed list of keys, so a
17
+ * typo'd or stale key is a compile error at the emit site rather than a raw dotted string
18
+ * in front of a reader. The WIRE type stays `string`, so an older client tolerates a key
19
+ * from a newer server and degrades to `message`.
20
+ */
21
+ messageKey?: Key;
22
+ /** Interpolation values for `messageKey`. */
23
+ params?: Record<string, string | number>;
24
+ /**
25
+ * How the refusal clears: seconds until the caller may retry, or `null` for a refusal that
26
+ * waiting cannot fix.
27
+ *
28
+ * Set it HERE rather than hand-rolling it into `details`. `errorResponse` renders a number as
29
+ * the standard `Retry-After` header AND folds it into `details`, so an HTTP client, a proxy
30
+ * and your own SDK all learn the same wait from one value; `null` is folded in without a
31
+ * header, because a `Retry-After` that states no time is worse than none.
32
+ */
33
+ retryAfterSecs?: number | null;
34
+ /**
35
+ * The `message` was authored for the client — a deployment fact like "payments are not set
36
+ * up here", a named dependency that is down — so a 5xx keeps it instead of the generic
37
+ * sentence. Never set it on a message built from a caught error: that is where driver text
38
+ * lives, and one donor's whole masking policy exists because its repository layer
39
+ * interpolates the driver's message into every failure it raises.
40
+ */
41
+ expose?: boolean;
42
+ cause?: unknown;
43
+ }
44
+ /**
45
+ * The one error type routes throw; {@link errorResponse} formats the envelope.
46
+ *
47
+ * `Code` is your product's error-code union and `Key` its message-key union. Neither is
48
+ * shipped here: across six donors the factory tables hold 46 distinct code names and exactly
49
+ * nine appear in all six. The codes are an API's vocabulary. What this package ships is the
50
+ * shape, the wire format and the mask.
51
+ *
52
+ * Prefer {@link createAppError} over `new AppError(…)`: it reads the status off your own
53
+ * code→status map, so no call site names a status and a code added without one is a build
54
+ * error.
55
+ *
56
+ * Constructing one directly is the one path where a 429 with no wait is still representable:
57
+ * the rule lives on the factory, because only the factory knows the map. That is the reason to
58
+ * prefer it, not a style note.
59
+ *
60
+ * **There is no `toJSON()`, on purpose**, and the reason is not the one first written here.
61
+ * `JSON.stringify` calls a value's own `toJSON()` BEFORE the replacer, so an error class that
62
+ * defines one hands a logger whatever that method returns instead of the error. Seven backends
63
+ * define one, and every one of them logs `{"error":{"error":{code,message}}}` — doubly nested,
64
+ * no `stack`, no `cause` — from a line that still looks like a log line.
65
+ *
66
+ * This file used to say a logger cannot fix that from its side. It can, and ours does: the
67
+ * replacer is called with the HOLDER as `this`, whose own property is still the untouched error
68
+ * (see `errorReplacer`). What remains true is the design: the wire body is built by
69
+ * `errorResponse`, where the mask lives anyway, so one function owns the shape a client sees —
70
+ * and this stays an ordinary Error to anything that serializes it.
71
+ */
72
+ export declare class AppError<Code extends string = string, Key extends string = string> extends Error {
73
+ readonly statusCode: number;
74
+ readonly code: Code;
75
+ readonly details?: unknown;
76
+ readonly messageKey?: Key;
77
+ readonly params?: Record<string, string | number>;
78
+ readonly retryAfterSecs?: number | null;
79
+ readonly expose: boolean;
80
+ constructor(statusCode: number, code: Code, message: string, opts?: AppErrorOptions<Key>);
81
+ }
82
+ /**
83
+ * A map is widened to `Record<string, number>` unless it is declared `as const`, and a widened
84
+ * map cannot tell a 429 from a 404 — so the rule below would silently stop applying. Refusing
85
+ * the map is loud; accepting it with the guard switched off is the failure this package spends
86
+ * a paragraph on everywhere else.
87
+ *
88
+ * It catches the HALF-widened map too, which is the realistic way this happens: one status read
89
+ * from config turns `404 | number` into `number`, and the whole map loses its literals. The
90
+ * property name is what the compiler prints, so it is plain ASCII — an arrow there comes out as
91
+ * `\u2192` in the diagnostic, and the message is the entire point of the trick.
92
+ */
93
+ type LiteralStatuses<S> = number extends S[keyof S] ? {
94
+ "declare your code-to-status map as const": never;
95
+ } : S;
96
+ /**
97
+ * Extra options a code's status makes mandatory.
98
+ *
99
+ * **A 429 states its own wait.** This is the highest-value line extracted from the whole
100
+ * reading. One donor writes a `resetAt` ISO date that no HTTP client parses, and then needs a
101
+ * hand-maintained list of "codes that do not clear by waiting" in its browser app to
102
+ * compensate — its own comment says so. Another donor needs no such list, because every 429 it
103
+ * sends states its wait, and a stated wait answers the question the list was guessing at. A
104
+ * third raises a spent DAILY cap with no wait at all, on a code its client treats as transient,
105
+ * so the browser retries a limit that clears at midnight — twice, immediately.
106
+ *
107
+ * Making it a required argument deletes that list from three repos and makes the retry bug
108
+ * unrepresentable. Measured against the fleet it came from: of 40 places that raise a 429,
109
+ * **34 already state a wait**, so the rule costs six edits in six repos.
110
+ *
111
+ * It is `[429] extends [Status]`, not `Status extends 429`, because the second form distributes:
112
+ * a code narrowed to a UNION — off a lookup table, a switch, a value read from the wire —
113
+ * produced a union of argument tuples, one of which had the options optional, and an empty
114
+ * argument list satisfied it. The obligation vanished on exactly the shape that is hardest to
115
+ * read. The tuples stop the distribution, and they ask the better question: does this code's
116
+ * status set INCLUDE 429. A widened `number` then requires the wait everywhere rather than
117
+ * nowhere, which is the safe direction to fail.
118
+ *
119
+ * Deliberately not extended, and the same counting method is what settled each one. Three
120
+ * statuses, one method, three different answers — which is the strongest thing that can be said
121
+ * for the method:
122
+ *
123
+ * - **429 — obligation.** 34 of 40 raises already state a wait, so the required argument mostly
124
+ * records a decision somebody had already made, and each of the six exceptions is
125
+ * interesting.
126
+ * - **503 — capability, not obligation.** Only 16 of 94 raises of a 502/503/504 factory state
127
+ * one. Most are "the database is unreachable" or "payments are not configured here", which
128
+ * have no wait to state, so a rule would buy 78 `null`s and teach people to type one without
129
+ * reading — and a client cannot tell a considered `null` from a reflex one. The raiser who
130
+ * knows is rare, and that is exactly the shape where a capability beats an obligation.
131
+ * - **402 — nothing to add.** Of 13 raises across five repos, **none** states a wait. That is
132
+ * what was measured, and it is all that was: it says no raiser in these repos claims a 402
133
+ * clears by waiting, not that none ever could. A card retry window or a transfer clearing
134
+ * overnight would be a real one — and it can say so, because the wait is available at every
135
+ * status. The day one turns up it is a finding rather than a contradiction.
136
+ *
137
+ * So the capability is on every code: a number renders `Retry-After` at any status, and an
138
+ * explicit `null` says "durable". The residual gap it closes is narrower than "503s need
139
+ * waits" — it is ONE code raised in two senses, durable and transient, indistinguishable in the
140
+ * envelope. The raiser that answers closes it for its own code, and nobody else is nagged.
141
+ *
142
+ * `null` is the other half, and the six are what proved it necessary. Two of them cannot state
143
+ * a wait truthfully: a concurrency slot frees when somebody else's job finishes, and a cap on
144
+ * live objects clears by archiving one, never by waiting at all. A required `number` would have
145
+ * forced both to invent a number. `null` says "waiting cannot fix this" — which is the very
146
+ * question the code lists were guessing at, answered by the one place that knows: the raiser.
147
+ * An omission is invisible in a diff; a `null` is a claim somebody has to read.
148
+ */
149
+ type RequiredOptions<Status, Key extends string> = [429] extends [Status] ? [opts: AppErrorOptions<Key> & {
150
+ retryAfterSecs: number | null;
151
+ }] : [opts?: AppErrorOptions<Key>];
152
+ /**
153
+ * Binds your code→status map, and returns the factory your `errors.*` table calls.
154
+ *
155
+ * ```ts
156
+ * const ERROR_STATUS = {
157
+ * NOT_FOUND: 404,
158
+ * RATE_LIMIT_EXCEEDED: 429,
159
+ * } as const satisfies Record<ErrorCode, number>;
160
+ *
161
+ * const appError = createAppError<typeof ERROR_STATUS, MessageKey>(ERROR_STATUS);
162
+ *
163
+ * export const errors = {
164
+ * notFound: (what = "Resource") => appError("NOT_FOUND", `${what} not found`),
165
+ * rateLimit: (retryAfterSecs: number) =>
166
+ * appError("RATE_LIMIT_EXCEEDED", "Too many requests", { retryAfterSecs }),
167
+ * };
168
+ * ```
169
+ *
170
+ * The `satisfies` on your map is what makes a code with no status a build error — one line,
171
+ * in your repo, and the only version of this that cannot drift. Three of the five newest
172
+ * donors pass the status at every call site instead, which compiles no matter what.
173
+ */
174
+ export declare function createAppError<S extends Record<string, number>, Key extends string = string>(statusOf: S & LiteralStatuses<S>): <C extends keyof S & string>(code: C, message: string, ...opts: RequiredOptions<S[C], Key>) => AppError<C, Key>;
175
+ /**
176
+ * Turn any thrown value into a string.
177
+ *
178
+ * The single home for the `err instanceof Error ? err.message : String(err)` idiom, which is
179
+ * wrong twice over and shipped that way in six repos:
180
+ *
181
+ * 1. A data layer rejects with a PLAIN OBJECT — `{code, message, hint}` is what PostgREST and
182
+ * several drivers throw — so the useful text is in `message` and `String()` never reads it.
183
+ * Six donors fixed this half.
184
+ * 2. An object with no string `message` still flattens to `"[object Object]"`, which is the
185
+ * real failure masked by a useless string. One donor fixed that half and named it exactly:
186
+ * *"masking the real failure."* Its version is the one here.
187
+ */
188
+ export declare function toMessage(err: unknown): string;
189
+ export {};
190
+ //# sourceMappingURL=errors.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"errors.d.ts","sourceRoot":"","sources":["../src/errors.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;GAUG;AAIH,MAAM,WAAW,eAAe,CAAC,GAAG,SAAS,MAAM,GAAG,MAAM;IAC1D,0FAA0F;IAC1F,OAAO,CAAC,EAAE,OAAO,CAAC;IAClB;;;;;OAKG;IACH,UAAU,CAAC,EAAE,GAAG,CAAC;IACjB,6CAA6C;IAC7C,MAAM,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,GAAG,MAAM,CAAC,CAAC;IACzC;;;;;;;;OAQG;IACH,cAAc,CAAC,EAAE,MAAM,GAAG,IAAI,CAAC;IAC/B;;;;;;OAMG;IACH,MAAM,CAAC,EAAE,OAAO,CAAC;IACjB,KAAK,CAAC,EAAE,OAAO,CAAC;CACjB;AAED;;;;;;;;;;;;;;;;;;;;;;;;;;;GA2BG;AACH,qBAAa,QAAQ,CAAC,IAAI,SAAS,MAAM,GAAG,MAAM,EAAE,GAAG,SAAS,MAAM,GAAG,MAAM,CAAE,SAAQ,KAAK;IAC5F,SAAgB,UAAU,EAAE,MAAM,CAAC;IACnC,SAAgB,IAAI,EAAE,IAAI,CAAC;IAC3B,SAAgB,OAAO,CAAC,EAAE,OAAO,CAAC;IAClC,SAAgB,UAAU,CAAC,EAAE,GAAG,CAAC;IACjC,SAAgB,MAAM,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,GAAG,MAAM,CAAC,CAAC;IACzD,SAAgB,cAAc,CAAC,EAAE,MAAM,GAAG,IAAI,CAAC;IAC/C,SAAgB,MAAM,EAAE,OAAO,CAAC;gBAEpB,UAAU,EAAE,MAAM,EAAE,IAAI,EAAE,IAAI,EAAE,OAAO,EAAE,MAAM,EAAE,IAAI,GAAE,eAAe,CAAC,GAAG,CAAM;CAW7F;AAED;;;;;;;;;;GAUG;AACH,KAAK,eAAe,CAAC,CAAC,IAAI,MAAM,SAAS,CAAC,CAAC,MAAM,CAAC,CAAC,GAC/C;IAAE,0CAA0C,EAAE,KAAK,CAAA;CAAE,GACrD,CAAC,CAAC;AAEN;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAoDG;AACH,KAAK,eAAe,CAAC,MAAM,EAAE,GAAG,SAAS,MAAM,IAAI,CAAC,GAAG,CAAC,SAAS,CAAC,MAAM,CAAC,GACrE,CAAC,IAAI,EAAE,eAAe,CAAC,GAAG,CAAC,GAAG;IAAE,cAAc,EAAE,MAAM,GAAG,IAAI,CAAA;CAAE,CAAC,GAChE,CAAC,IAAI,CAAC,EAAE,eAAe,CAAC,GAAG,CAAC,CAAC,CAAC;AAElC;;;;;;;;;;;;;;;;;;;;;GAqBG;AACH,wBAAgB,cAAc,CAAC,CAAC,SAAS,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,EAAE,GAAG,SAAS,MAAM,GAAG,MAAM,EAC1F,QAAQ,EAAE,CAAC,GAAG,eAAe,CAAC,CAAC,CAAC,GAC/B,CAAC,CAAC,SAAS,MAAM,CAAC,GAAG,MAAM,EAC5B,IAAI,EAAE,CAAC,EACP,OAAO,EAAE,MAAM,EACf,GAAG,IAAI,EAAE,eAAe,CAAC,CAAC,CAAC,CAAC,CAAC,EAAE,GAAG,CAAC,KAChC,QAAQ,CAAC,CAAC,EAAE,GAAG,CAAC,CAKpB;AA2BD;;;;;;;;;;;;GAYG;AACH,wBAAgB,SAAS,CAAC,GAAG,EAAE,OAAO,GAAG,MAAM,CAqB9C"}