@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.
- package/LICENSE +21 -0
- package/README.md +323 -0
- package/dist/errors.d.ts +190 -0
- package/dist/errors.d.ts.map +1 -0
- package/dist/errors.js +154 -0
- package/dist/errors.js.map +1 -0
- package/dist/hono/errors.d.ts +46 -0
- package/dist/hono/errors.d.ts.map +1 -0
- package/dist/hono/errors.js +54 -0
- package/dist/hono/errors.js.map +1 -0
- package/dist/hono/guards.d.ts +45 -0
- package/dist/hono/guards.d.ts.map +1 -0
- package/dist/hono/guards.js +88 -0
- package/dist/hono/guards.js.map +1 -0
- package/dist/hono/index.d.ts +18 -0
- package/dist/hono/index.d.ts.map +1 -0
- package/dist/hono/index.js +15 -0
- package/dist/hono/index.js.map +1 -0
- package/dist/hono/request-logger.d.ts +31 -0
- package/dist/hono/request-logger.d.ts.map +1 -0
- package/dist/hono/request-logger.js +57 -0
- package/dist/hono/request-logger.js.map +1 -0
- package/dist/index.d.ts +7 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +4 -0
- package/dist/index.js.map +1 -0
- package/dist/logger/index.d.ts +44 -0
- package/dist/logger/index.d.ts.map +1 -0
- package/dist/logger/index.js +74 -0
- package/dist/logger/index.js.map +1 -0
- package/dist/logger/serialize.d.ts +68 -0
- package/dist/logger/serialize.d.ts.map +1 -0
- package/dist/logger/serialize.js +203 -0
- package/dist/logger/serialize.js.map +1 -0
- package/dist/responses.d.ts +107 -0
- package/dist/responses.d.ts.map +1 -0
- package/dist/responses.js +183 -0
- package/dist/responses.js.map +1 -0
- package/package.json +79 -0
- package/src/errors.test.ts +93 -0
- package/src/errors.ts +264 -0
- package/src/errors.types.test.ts +96 -0
- package/src/hono/errors.test.ts +215 -0
- package/src/hono/errors.ts +86 -0
- package/src/hono/guards.test.ts +234 -0
- package/src/hono/guards.ts +107 -0
- package/src/hono/index.ts +17 -0
- package/src/hono/request-logger.test.ts +200 -0
- package/src/hono/request-logger.ts +77 -0
- package/src/index.ts +6 -0
- package/src/logger/index.test.ts +137 -0
- package/src/logger/index.ts +112 -0
- package/src/logger/serialize.test.ts +300 -0
- package/src/logger/serialize.ts +202 -0
- package/src/readme.test.ts +132 -0
- package/src/responses.test.ts +291 -0
- 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
|
+
```
|
package/dist/errors.d.ts
ADDED
|
@@ -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"}
|