@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
|
@@ -0,0 +1,300 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest";
|
|
2
|
+
import { errorReplacer } from "./serialize.ts";
|
|
3
|
+
|
|
4
|
+
/** Serialize a log entry the way the logger does, and read the result back. */
|
|
5
|
+
function entryFor(meta: Record<string, unknown>): Record<string, unknown> {
|
|
6
|
+
return JSON.parse(JSON.stringify(meta, errorReplacer())) as Record<string, unknown>;
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
/** Serialize one error under an `error` key and hand back what that key became. */
|
|
10
|
+
function serialized(err: unknown): Record<string, unknown> {
|
|
11
|
+
return entryFor({ error: err }).error as Record<string, unknown>;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
describe("what an Error contributes to a log line", () => {
|
|
15
|
+
it("drops the webhook body and the signature a payment SDK hangs off its own error", () => {
|
|
16
|
+
// The real shape, measured off the pinned SDK rather than imagined: a payment vendor's
|
|
17
|
+
// signature-verification error has 25 own enumerable properties, and two of them are the
|
|
18
|
+
// request the caller sent us — `payload` is the entire unparsed webhook body and `header` is
|
|
19
|
+
// the signature it was checked against. The webhook route is unauthenticated by definition,
|
|
20
|
+
// so the loop that copied all 25 let anyone on the internet choose what went into the log.
|
|
21
|
+
// Named here for its shape rather than its origin; the field names ARE the evidence.
|
|
22
|
+
const err = Object.assign(new Error("No signatures found matching the expected signature"), {
|
|
23
|
+
type: "SignatureVerificationError",
|
|
24
|
+
raw: { message: "No signatures found matching the expected signature" },
|
|
25
|
+
rawType: undefined,
|
|
26
|
+
detail: undefined,
|
|
27
|
+
headers: { "connected-account": "acct_live_1", "idempotency-key": "idem_1" },
|
|
28
|
+
requestId: "req_abc123",
|
|
29
|
+
statusCode: 400,
|
|
30
|
+
userMessage: undefined,
|
|
31
|
+
advice_code: undefined,
|
|
32
|
+
charge: "ch_1",
|
|
33
|
+
code: undefined,
|
|
34
|
+
decline_code: undefined,
|
|
35
|
+
doc_url: undefined,
|
|
36
|
+
network_advice_code: undefined,
|
|
37
|
+
network_decline_code: undefined,
|
|
38
|
+
param: undefined,
|
|
39
|
+
payment_intent: { id: "pi_1", client_secret: "pi_1_secret_LEAKED" },
|
|
40
|
+
payment_method: { id: "pm_1", card: { last4: "4242", exp_year: 2031 } },
|
|
41
|
+
payment_method_type: "card",
|
|
42
|
+
request_log_url: "https://dashboard.example.com/logs/req_abc123",
|
|
43
|
+
setup_intent: undefined,
|
|
44
|
+
source: undefined,
|
|
45
|
+
user_message: undefined,
|
|
46
|
+
header: "t=1700000000,v1=deadbeefdeadbeef",
|
|
47
|
+
payload: '{"id":"evt_1","data":{"object":{"customer_email":"someone@example.com"}}}',
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
const line = JSON.stringify(serialized(err));
|
|
51
|
+
|
|
52
|
+
expect(line).not.toContain("someone@example.com");
|
|
53
|
+
expect(line).not.toContain("pi_1_secret_LEAKED");
|
|
54
|
+
expect(line).not.toContain("4242");
|
|
55
|
+
expect(line).not.toContain("acct_live_1");
|
|
56
|
+
// toEqual, not a key check: the point is that nothing ELSE came along either.
|
|
57
|
+
expect(serialized(err)).toEqual({
|
|
58
|
+
name: "Error",
|
|
59
|
+
message: "No signatures found matching the expected signature",
|
|
60
|
+
// `type` earns its place here: this vendor never sets `name`, so without it every one of
|
|
61
|
+
// its failures reads as a bare "Error" in the log.
|
|
62
|
+
type: "SignatureVerificationError",
|
|
63
|
+
statusCode: 400,
|
|
64
|
+
stack: err.stack,
|
|
65
|
+
});
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
it("drops the password a Redis client hangs off a failed AUTH", () => {
|
|
69
|
+
// A Redis client attaches `command = { name, args }` to a server-returned error, and an AUTH
|
|
70
|
+
// failure routes through that assignment — so the line an operator reads to find out why
|
|
71
|
+
// Redis is refusing them carried the credential that was refused.
|
|
72
|
+
const err = Object.assign(new Error("WRONGPASS invalid username-password pair"), {
|
|
73
|
+
command: { name: "auth", args: ["default", "hunter2-the-real-password"] },
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
expect(JSON.stringify(serialized(err))).not.toContain("hunter2");
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
it("drops the statement text Postgres hangs off a constraint violation", () => {
|
|
80
|
+
// Reproduced against Postgres 18: a CHECK violation raised inside a PL/pgSQL function sets
|
|
81
|
+
// `where` to the failing statement, literals included. pg is in every backend here, so this
|
|
82
|
+
// is the copy-loop's third vendor and the only one that is not optional.
|
|
83
|
+
const err = Object.assign(new Error("new row violates check constraint"), {
|
|
84
|
+
code: "23514",
|
|
85
|
+
severity: "ERROR",
|
|
86
|
+
constraint: "probe_card_ck",
|
|
87
|
+
schema: "public",
|
|
88
|
+
table: "probe_t",
|
|
89
|
+
where:
|
|
90
|
+
'SQL statement "INSERT INTO probe_t (email, card) ' +
|
|
91
|
+
"VALUES ('someone@example.com', '4242424242424242')\"\nPL/pgSQL function probe_fn() line 3",
|
|
92
|
+
internalQuery: "INSERT INTO probe_t (email, card) VALUES ('someone@example.com', '4242…')",
|
|
93
|
+
routine: "ExecConstraints",
|
|
94
|
+
file: "execMain.c",
|
|
95
|
+
line: "2081",
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
const line = serialized(err);
|
|
99
|
+
|
|
100
|
+
expect(JSON.stringify(line)).not.toContain("someone@example.com");
|
|
101
|
+
expect(JSON.stringify(line)).not.toContain("4242");
|
|
102
|
+
expect(line).toMatchObject({ code: "23514", severity: "ERROR", constraint: "probe_card_ck" });
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
it("keeps the Postgres DETAIL that names the key, and drops the one that is the whole row", () => {
|
|
106
|
+
// Two message forms, one field. A unique violation names only the key columns, which is the
|
|
107
|
+
// diagnostic this field is kept for. A CHECK or NOT NULL violation writes the ENTIRE failing
|
|
108
|
+
// row — every column, whatever the table holds. Both strings are verbatim from Postgres 18.
|
|
109
|
+
const unique = Object.assign(new Error("duplicate key value violates unique constraint"), {
|
|
110
|
+
code: "23505",
|
|
111
|
+
detail: "Key (slug)=(demo) already exists.",
|
|
112
|
+
constraint: "boards_slug_key",
|
|
113
|
+
});
|
|
114
|
+
expect(serialized(unique).detail).toBe("Key (slug)=(demo) already exists.");
|
|
115
|
+
|
|
116
|
+
const check = Object.assign(new Error("new row violates check constraint"), {
|
|
117
|
+
code: "23514",
|
|
118
|
+
detail: "Failing row contains (someone@example.com, 4242424242424242).",
|
|
119
|
+
});
|
|
120
|
+
const line = serialized(check);
|
|
121
|
+
expect(JSON.stringify(line)).not.toContain("someone@example.com");
|
|
122
|
+
// Not silence: the reader is told a DETAIL existed and why it is not here.
|
|
123
|
+
expect(String(line.detail)).toContain("row omitted");
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
it("drops the rejected input a zod 3 validation error echoes back", () => {
|
|
127
|
+
// Measured: in zod 3 `issues` is an own enumerable property and an issue can carry the value
|
|
128
|
+
// that was rejected (`received: "…"`), so the copy loop wrote caller input into the log. In
|
|
129
|
+
// zod 4 it is non-enumerable and never rode along at all — so dropping it costs nothing on
|
|
130
|
+
// the version this fleet runs, and is a fix on the version it does not.
|
|
131
|
+
const err = Object.assign(new Error("invalid input"), {
|
|
132
|
+
name: "ZodError",
|
|
133
|
+
issues: [{ code: "invalid_enum_value", path: ["role"], received: "SUPER-SECRET-VALUE" }],
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
expect(JSON.stringify(serialized(err))).not.toContain("SUPER-SECRET-VALUE");
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
it("keeps what a Postgres failure has to say", () => {
|
|
140
|
+
// Why the extras ride along at all: without `code` a unique violation is indistinguishable
|
|
141
|
+
// from a syntax error, and `constraint` is what names which uniqueness was violated.
|
|
142
|
+
const err = Object.assign(new Error("duplicate key value violates unique constraint"), {
|
|
143
|
+
code: "23505",
|
|
144
|
+
detail: "Key (slug)=(demo) already exists.",
|
|
145
|
+
hint: "Pick another slug.",
|
|
146
|
+
constraint: "workspaces_slug_key",
|
|
147
|
+
severity: "ERROR",
|
|
148
|
+
});
|
|
149
|
+
|
|
150
|
+
expect(serialized(err)).toMatchObject({
|
|
151
|
+
code: "23505",
|
|
152
|
+
detail: "Key (slug)=(demo) already exists.",
|
|
153
|
+
hint: "Pick another slug.",
|
|
154
|
+
constraint: "workspaces_slug_key",
|
|
155
|
+
severity: "ERROR",
|
|
156
|
+
});
|
|
157
|
+
});
|
|
158
|
+
|
|
159
|
+
it("keeps the fields our own error classes carry", () => {
|
|
160
|
+
const err = Object.assign(new Error("too many requests"), {
|
|
161
|
+
code: "QUOTA_EXCEEDED",
|
|
162
|
+
statusCode: 429,
|
|
163
|
+
status: 429,
|
|
164
|
+
messageKey: "errors.QUOTA_EXCEEDED",
|
|
165
|
+
params: { plan: "free" },
|
|
166
|
+
retryAfterSecs: 60,
|
|
167
|
+
retryAfter: 60,
|
|
168
|
+
kind: "rate-limit",
|
|
169
|
+
retryable: false,
|
|
170
|
+
});
|
|
171
|
+
|
|
172
|
+
expect(serialized(err)).toMatchObject({
|
|
173
|
+
code: "QUOTA_EXCEEDED",
|
|
174
|
+
statusCode: 429,
|
|
175
|
+
status: 429,
|
|
176
|
+
messageKey: "errors.QUOTA_EXCEEDED",
|
|
177
|
+
params: { plan: "free" },
|
|
178
|
+
retryAfterSecs: 60,
|
|
179
|
+
retryAfter: 60,
|
|
180
|
+
kind: "rate-limit",
|
|
181
|
+
retryable: false,
|
|
182
|
+
});
|
|
183
|
+
});
|
|
184
|
+
|
|
185
|
+
it("follows the cause chain, and allow-lists every link of it", () => {
|
|
186
|
+
// `cause` is non-enumerable, so it is invisible to the loop above and has to be added by
|
|
187
|
+
// hand. Each link goes back through the replacer, so a vendor error buried three deep is
|
|
188
|
+
// filtered the same as one at the top.
|
|
189
|
+
const root = Object.assign(new Error("WRONGPASS invalid username-password pair"), {
|
|
190
|
+
code: "WRONGPASS",
|
|
191
|
+
command: { name: "auth", args: ["default", "hunter2-the-real-password"] },
|
|
192
|
+
});
|
|
193
|
+
const middle = new Error("cache unavailable", { cause: root });
|
|
194
|
+
const top = new Error("could not load the board", { cause: middle });
|
|
195
|
+
|
|
196
|
+
const line = serialized(top);
|
|
197
|
+
const cause = line.cause as Record<string, unknown>;
|
|
198
|
+
const deeper = cause.cause as Record<string, unknown>;
|
|
199
|
+
|
|
200
|
+
expect(cause.message).toBe("cache unavailable");
|
|
201
|
+
expect(deeper.code).toBe("WRONGPASS");
|
|
202
|
+
expect(JSON.stringify(line)).not.toContain("hunter2");
|
|
203
|
+
});
|
|
204
|
+
|
|
205
|
+
it("narrows a cause that is not an Error, which is the shape this package itself creates", () => {
|
|
206
|
+
// `errorBoundary` wraps every non-Error throw as `new Error(toMessage(err), { cause: err })`,
|
|
207
|
+
// so in a Hono app the cause slot is exactly where an SDK's own rejection object lands — and a
|
|
208
|
+
// PostgREST client rejecting with a plain object is why that wrapper exists. Until this, the
|
|
209
|
+
// allow-list stopped at the Error: the wrapper was filtered, the object one level under it was
|
|
210
|
+
// copied whole, and the line reads as though the list had run.
|
|
211
|
+
const rejection = {
|
|
212
|
+
message: "invalid signature",
|
|
213
|
+
code: "PGRST301",
|
|
214
|
+
payload: '{"card":"4242424242424242"}',
|
|
215
|
+
header: "t=1,v1=deadbeef",
|
|
216
|
+
};
|
|
217
|
+
|
|
218
|
+
const line = serialized(new Error("invalid signature", { cause: rejection }));
|
|
219
|
+
|
|
220
|
+
expect(line.cause).toEqual({ message: "invalid signature", code: "PGRST301" });
|
|
221
|
+
expect(JSON.stringify(line)).not.toContain("4242");
|
|
222
|
+
});
|
|
223
|
+
|
|
224
|
+
it("survives a chain of plain-object causes that holds itself", () => {
|
|
225
|
+
// The Error branch has had this guard since it was written; the narrowing builds a new object
|
|
226
|
+
// and so needs its own, or a self-referencing rejection recurses until the stack ends — inside
|
|
227
|
+
// the log call, which is the one place that must never take the process down.
|
|
228
|
+
const inner: Record<string, unknown> = { code: "E_LOOP" };
|
|
229
|
+
inner.cause = inner;
|
|
230
|
+
|
|
231
|
+
const line = serialized(new Error("looped", { cause: inner }));
|
|
232
|
+
const cause = line.cause as Record<string, unknown>;
|
|
233
|
+
|
|
234
|
+
expect(cause.code).toBe("E_LOOP");
|
|
235
|
+
expect(cause.cause).toBe("[Circular]");
|
|
236
|
+
});
|
|
237
|
+
|
|
238
|
+
it("keeps the stack of an error class that defines toJSON, which stringify calls first", () => {
|
|
239
|
+
// `JSON.stringify` calls a value's own `toJSON()` BEFORE the replacer, so an error class that
|
|
240
|
+
// defines one never reached the Error branch at all: the line got whatever that method
|
|
241
|
+
// returns, which is shaped for the WIRE, and lost the stack and the cause. Seven backends
|
|
242
|
+
// define one on their error class, so `logger.error("x", { error: appErr })` wrote
|
|
243
|
+
// `{"error":{"error":{…}}}` — double-nested, no stack. Invisible, because it still looks
|
|
244
|
+
// like a log line.
|
|
245
|
+
class WireError extends Error {
|
|
246
|
+
code = "NOT_FOUND";
|
|
247
|
+
toJSON() {
|
|
248
|
+
return { error: { code: this.code, message: this.message } };
|
|
249
|
+
}
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
const line = serialized(new WireError("Workspace not found"));
|
|
253
|
+
|
|
254
|
+
expect(line).toMatchObject({ message: "Workspace not found", code: "NOT_FOUND" });
|
|
255
|
+
expect(String(line.stack)).toContain("Workspace not found");
|
|
256
|
+
expect(line).not.toHaveProperty("error");
|
|
257
|
+
});
|
|
258
|
+
|
|
259
|
+
it("shows what an AggregateError aggregated", () => {
|
|
260
|
+
// `errors` is non-enumerable too, so without this line the whole log entry for a failed
|
|
261
|
+
// Promise.any is the word "all failed". Each sub-error is allow-listed like any other.
|
|
262
|
+
const agg = new AggregateError(
|
|
263
|
+
[
|
|
264
|
+
Object.assign(new Error("primary refused"), { code: "ECONNREFUSED", port: 6379 }),
|
|
265
|
+
Object.assign(new Error("replica refused"), { code: "ECONNREFUSED" }),
|
|
266
|
+
],
|
|
267
|
+
"every cache endpoint refused",
|
|
268
|
+
);
|
|
269
|
+
|
|
270
|
+
const line = serialized(agg);
|
|
271
|
+
const errors = line.errors as Array<Record<string, unknown>>;
|
|
272
|
+
|
|
273
|
+
expect(line.message).toBe("every cache endpoint refused");
|
|
274
|
+
expect(errors).toHaveLength(2);
|
|
275
|
+
expect(errors[0]).toMatchObject({ message: "primary refused", code: "ECONNREFUSED" });
|
|
276
|
+
// `port` is not on the list: nothing in twelve backends reads it off a caught error.
|
|
277
|
+
expect(errors[0]).not.toHaveProperty("port");
|
|
278
|
+
});
|
|
279
|
+
|
|
280
|
+
it("survives a circular reference instead of crashing the log call", () => {
|
|
281
|
+
// Both shapes: an error that is its own cause, and a plain object that holds itself. A
|
|
282
|
+
// JSON.stringify with neither guard throws, inside the log call, which is how a logger takes
|
|
283
|
+
// down the process it was reporting on.
|
|
284
|
+
const err = new Error("cycle");
|
|
285
|
+
err.cause = err;
|
|
286
|
+
const job: Record<string, unknown> = { id: "job_1" };
|
|
287
|
+
job.parent = job;
|
|
288
|
+
|
|
289
|
+
const entry = entryFor({ error: err, job });
|
|
290
|
+
|
|
291
|
+
expect((entry.error as Record<string, unknown>).cause).toBe("[Circular]");
|
|
292
|
+
expect((entry.job as Record<string, unknown>).parent).toBe("[Circular]");
|
|
293
|
+
});
|
|
294
|
+
|
|
295
|
+
it("stringifies a bigint instead of throwing", () => {
|
|
296
|
+
// JSON.stringify refuses a bigint outright ("Do not know how to serialize a BigInt"), so
|
|
297
|
+
// one row count off a driver that returns them would end the log call.
|
|
298
|
+
expect(entryFor({ rows: 9007199254740993n }).rows).toBe("9007199254740993");
|
|
299
|
+
});
|
|
300
|
+
});
|
|
@@ -0,0 +1,202 @@
|
|
|
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
|
+
/**
|
|
12
|
+
* The own enumerable properties a log line keeps off an Error. An ALLOW-list, not a deny-list,
|
|
13
|
+
* because an SDK hangs its own INPUTS off the error it throws, and the list of words vendors use
|
|
14
|
+
* for that is open-ended:
|
|
15
|
+
*
|
|
16
|
+
* - **A payment SDK.** Its signature-verification error carries `payload` — the entire unparsed
|
|
17
|
+
* webhook body — and `header`, the signature it was checked against. Measured on the pinned
|
|
18
|
+
* version: that error has **25 own enumerable properties** and this list admits **4** of them
|
|
19
|
+
* (`type`, `code`, `detail`, `statusCode`). Among the 21 dropped are `raw` (the whole error body)
|
|
20
|
+
* and `headers`. A webhook route is unauthenticated by definition, so the old loop let anyone on
|
|
21
|
+
* the internet write content of their choosing into the log by POSTing a junk signature.
|
|
22
|
+
* - **A Redis client.** A server-returned error carries `command = { name, args }`. On an AUTH
|
|
23
|
+
* failure those args are the password — written to stdout at the exact moment an operator is
|
|
24
|
+
* reading the log to find out why Redis is refusing them.
|
|
25
|
+
* - **Postgres.** A `DatabaseError` carries `where` and `internalQuery`, which hold **statement
|
|
26
|
+
* text with its literals in it**. Reproduced against Postgres 18: a CHECK violation raised inside
|
|
27
|
+
* a PL/pgSQL function set `where` to the failing INSERT, e-mail address and card number included.
|
|
28
|
+
* pg is in every backend here, so this one was never vendor-specific.
|
|
29
|
+
*
|
|
30
|
+
* A deny-list would have had to know `payload`, `header`, `raw`, `command`, `where` and the next
|
|
31
|
+
* vendor's word for it, and it learns each one from an incident. This list only has to know ours.
|
|
32
|
+
*
|
|
33
|
+
* **Add a key when our own code reads it off a caught error.** Two on it have no reader and say so
|
|
34
|
+
* below; everything else was measured across twelve backends.
|
|
35
|
+
*/
|
|
36
|
+
const KEPT_ERROR_FIELDS: ReadonlySet<string> = new Set([
|
|
37
|
+
// Postgres and PostgREST diagnostics. `detail` is the pg driver's spelling, `details`
|
|
38
|
+
// PostgREST's. `hint` has no reader anywhere in the fleet — it is kept because the donor
|
|
39
|
+
// comment promises it by name and a Postgres HINT never carries a value, only a suggestion.
|
|
40
|
+
"code",
|
|
41
|
+
"detail",
|
|
42
|
+
"details",
|
|
43
|
+
"hint",
|
|
44
|
+
"constraint",
|
|
45
|
+
"severity",
|
|
46
|
+
// Our own error classes, and the classified failures the modules throw.
|
|
47
|
+
"statusCode",
|
|
48
|
+
"status",
|
|
49
|
+
"messageKey",
|
|
50
|
+
"params",
|
|
51
|
+
"retryAfterSecs",
|
|
52
|
+
// The same number under an SDK's spelling. Our house word is `retryAfterSecs`; one SDK in the
|
|
53
|
+
// fleet says `retryAfter`, and two call sites read it off the caught error — so without this a
|
|
54
|
+
// rate-limit line says it was rate-limited and not for how long.
|
|
55
|
+
"retryAfter",
|
|
56
|
+
"kind",
|
|
57
|
+
"retryable",
|
|
58
|
+
// Which vendor error this was. One vendor sets `type` and never sets `name`, so without this
|
|
59
|
+
// every failure from it logs as a bare "Error". Read off the SDK, not guessed.
|
|
60
|
+
"type",
|
|
61
|
+
]);
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* Postgres writes the **entire failing row** into DETAIL for a CHECK or NOT NULL violation —
|
|
65
|
+
* every column of it, whatever that table happens to hold. Measured against Postgres 18:
|
|
66
|
+
*
|
|
67
|
+
* detail: "Failing row contains (someone@example.com, 4242424242424242)."
|
|
68
|
+
*
|
|
69
|
+
* That is the one value-level rule in this file, and it is here rather than in a redaction pass
|
|
70
|
+
* because it is not a pattern over arbitrary text: it is one exact message form, and Postgres is
|
|
71
|
+
* the only thing that writes it. The unique-violation form — `Key (slug)=(demo) already exists.` —
|
|
72
|
+
* names only the key columns, which is the diagnostic this field is kept for, and survives.
|
|
73
|
+
*/
|
|
74
|
+
const PG_FAILING_ROW = "Failing row contains (";
|
|
75
|
+
const OMITTED_ROW = "[row omitted: Postgres DETAIL for this error is the whole failing row]";
|
|
76
|
+
|
|
77
|
+
function keptValue(key: string, value: unknown): unknown {
|
|
78
|
+
if ((key === "detail" || key === "details") && typeof value === "string") {
|
|
79
|
+
return value.startsWith(PG_FAILING_ROW) ? OMITTED_ROW : value;
|
|
80
|
+
}
|
|
81
|
+
return value;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* A thrown value that is NOT an Error, narrowed to what a log line may print off it.
|
|
86
|
+
*
|
|
87
|
+
* The allow-list above is written against Errors, and until this the narrowing stopped there: an
|
|
88
|
+
* Error was filtered and whatever sat in its `cause` was copied whole. That gap is not theoretical
|
|
89
|
+
* here, it is this package's own doing — `errorBoundary` turns every non-Error throw into
|
|
90
|
+
* `new Error(toMessage(err), { cause: err })`, because Hono's `onError` never sees a non-Error and
|
|
91
|
+
* a PostgREST client rejects with plain objects. So in a Hono app the cause slot is precisely where
|
|
92
|
+
* a vendor's rejection object ends up, and a leak there reads as if the list had run.
|
|
93
|
+
*
|
|
94
|
+
* `cause` means "the error this one came from", so whatever sits in it is in the error slot and
|
|
95
|
+
* gets the same treatment. `name` and `message` come along because a rejection object usually
|
|
96
|
+
* carries them and a line with neither says nothing at all.
|
|
97
|
+
*
|
|
98
|
+
* Deliberate state it does NOT keep: context an app attaches on purpose. That belongs in the
|
|
99
|
+
* logger's `meta`, which is untouched — `cause` is not the place for it, and one incident of a
|
|
100
|
+
* vendor's request body in the log outweighs a field nobody put there deliberately.
|
|
101
|
+
*/
|
|
102
|
+
export function narrowErrorLike(value: object): Record<string, unknown> {
|
|
103
|
+
return narrow(value, new WeakSet<object>());
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
function narrow(value: object, seen: WeakSet<object>): Record<string, unknown> {
|
|
107
|
+
seen.add(value);
|
|
108
|
+
const out: Record<string, unknown> = {};
|
|
109
|
+
const { name, message, cause } = value as {
|
|
110
|
+
name?: unknown;
|
|
111
|
+
message?: unknown;
|
|
112
|
+
cause?: unknown;
|
|
113
|
+
};
|
|
114
|
+
if (typeof name === "string") out.name = name;
|
|
115
|
+
if (typeof message === "string") out.message = message;
|
|
116
|
+
for (const [k, v] of Object.entries(value)) {
|
|
117
|
+
if (KEPT_ERROR_FIELDS.has(k)) out[k] = keptValue(k, v);
|
|
118
|
+
}
|
|
119
|
+
if (cause !== undefined) out.cause = narrowCause(cause, seen);
|
|
120
|
+
return out;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/**
|
|
124
|
+
* An Error cause goes back unchanged, because `JSON.stringify` walks it into the replacer's own
|
|
125
|
+
* Error branch. Anything that is not an object is a string or a number, which is its own value.
|
|
126
|
+
*/
|
|
127
|
+
function narrowCause(cause: unknown, seen: WeakSet<object>): unknown {
|
|
128
|
+
if (cause instanceof Error || typeof cause !== "object" || cause === null) return cause;
|
|
129
|
+
// The recursion builds new objects, so the replacer's `seen` cannot see this chain: a rejection
|
|
130
|
+
// that holds itself would recurse until the stack ends, inside the one call that must never take
|
|
131
|
+
// the process down.
|
|
132
|
+
return seen.has(cause) ? "[Circular]" : narrow(cause, seen);
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/**
|
|
136
|
+
* A `JSON.stringify` replacer that keeps log lines useful and crash-proof:
|
|
137
|
+
*
|
|
138
|
+
* - Errors serialize to a readable object. `message` and `stack` are non-enumerable, so a plain
|
|
139
|
+
* `JSON.stringify(err)` is `{}` — which is how a logger ends up printing nothing about the
|
|
140
|
+
* failure it was called to report. They are added explicitly, and the allow-listed extras ride
|
|
141
|
+
* along beside them.
|
|
142
|
+
* - A nested `cause` is followed, and so is an `AggregateError`'s `errors`. Both are
|
|
143
|
+
* non-enumerable, so both are invisible to the loop above; without this line "all attempts
|
|
144
|
+
* failed" is the whole log entry. Each one goes back through this replacer, so the allow-list
|
|
145
|
+
* covers the chain, not just the top — and a cause that is not an Error is narrowed here
|
|
146
|
+
* instead, by {@link narrowErrorLike}, because the replacer's Error branch would never see it.
|
|
147
|
+
* - bigints stringify instead of throwing.
|
|
148
|
+
* - Circular references collapse to "[Circular]" instead of crashing the log call.
|
|
149
|
+
*
|
|
150
|
+
* Paired with callers passing the RAW error rather than `String(err)`, this is why a log line
|
|
151
|
+
* never reads "[object Object]".
|
|
152
|
+
*
|
|
153
|
+
* One ordering fact decides what this replacer would otherwise see: `JSON.stringify` calls a
|
|
154
|
+
* value's own `toJSON()` **before** the replacer, so an error class that defines one arrives here
|
|
155
|
+
* already turned into whatever that method returns — the stack and the cause gone, and the result
|
|
156
|
+
* usually shaped for the WIRE, because that is what an error's `toJSON()` is for. Seven backends
|
|
157
|
+
* on this stack define one, and `logger.error("x", { error: appErr })` wrote
|
|
158
|
+
* `{"error":{"error":{…}}}` in every one of them: double-nested, no stack, no cause, and
|
|
159
|
+
* invisible, because the line still looks like a log line.
|
|
160
|
+
*
|
|
161
|
+
* The original is still there. `JSON.stringify` calls the replacer with the HOLDER as `this`, and
|
|
162
|
+
* the holder's own property is the untouched value — so `this[key]` recovers the Error that
|
|
163
|
+
* `toJSON()` replaced. That is why this is a `function` and not an arrow.
|
|
164
|
+
*
|
|
165
|
+
* What a log line keeps off an Error is this file's decision, not the error's: an error class is
|
|
166
|
+
* free to define the body it sends a client, and the log still gets name, message, stack, cause
|
|
167
|
+
* and the allow-list.
|
|
168
|
+
*
|
|
169
|
+
* A new replacer per log line, because the `seen` set must not outlive one entry.
|
|
170
|
+
*/
|
|
171
|
+
export function errorReplacer(): (this: unknown, key: string, value: unknown) => unknown {
|
|
172
|
+
const seen = new WeakSet<object>();
|
|
173
|
+
return function (key, value) {
|
|
174
|
+
const held =
|
|
175
|
+
typeof this === "object" && this !== null
|
|
176
|
+
? (this as Record<string, unknown>)[key]
|
|
177
|
+
: undefined;
|
|
178
|
+
if (held instanceof Error) value = held;
|
|
179
|
+
if (typeof value === "bigint") return value.toString();
|
|
180
|
+
if (value instanceof Error) {
|
|
181
|
+
if (seen.has(value)) return "[Circular]";
|
|
182
|
+
seen.add(value);
|
|
183
|
+
const out: Record<string, unknown> = { name: value.name, message: value.message };
|
|
184
|
+
for (const [k, v] of Object.entries(value)) {
|
|
185
|
+
if (KEPT_ERROR_FIELDS.has(k)) out[k] = keptValue(k, v);
|
|
186
|
+
}
|
|
187
|
+
const { cause } = value;
|
|
188
|
+
if (cause !== undefined) out.cause = narrowCause(cause, seen);
|
|
189
|
+
if (value instanceof AggregateError) out.errors = value.errors;
|
|
190
|
+
if (value.stack) out.stack = value.stack;
|
|
191
|
+
return out;
|
|
192
|
+
}
|
|
193
|
+
if (typeof value === "object" && value !== null) {
|
|
194
|
+
if (seen.has(value)) return "[Circular]";
|
|
195
|
+
seen.add(value);
|
|
196
|
+
}
|
|
197
|
+
return value;
|
|
198
|
+
};
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
/** The keys an Error may contribute to a log line, beside `name`, `message`, `stack` and `cause`. */
|
|
202
|
+
export const keptErrorFields: ReadonlySet<string> = KEPT_ERROR_FIELDS;
|
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The README's examples, compiled and run.
|
|
3
|
+
*
|
|
4
|
+
* A snippet nobody executes rots quietly, and this one is the first thing an adopter copies.
|
|
5
|
+
* Every literal below is what the README prints beside the call.
|
|
6
|
+
*/
|
|
7
|
+
import { describe, expect, it } from "vitest";
|
|
8
|
+
import { Hono } from "hono";
|
|
9
|
+
import { createAppError, createErrorResponse, createLogger, ok, paginated } from "./index.ts";
|
|
10
|
+
import {
|
|
11
|
+
assertEveryRouteGuarded,
|
|
12
|
+
errorBoundary,
|
|
13
|
+
errorHandler,
|
|
14
|
+
guard,
|
|
15
|
+
notFoundHandler,
|
|
16
|
+
requestLogger,
|
|
17
|
+
underAny,
|
|
18
|
+
} from "./hono/index.ts";
|
|
19
|
+
import type { RequestVariables } from "./hono/index.ts";
|
|
20
|
+
|
|
21
|
+
type ErrorCode =
|
|
22
|
+
"VALIDATION_ERROR" | "UNAUTHORIZED" | "NOT_FOUND" | "RATE_LIMIT_EXCEEDED" | "INTERNAL_ERROR";
|
|
23
|
+
type MessageKey = "serverErrors.notFound";
|
|
24
|
+
|
|
25
|
+
const ERROR_STATUS = {
|
|
26
|
+
VALIDATION_ERROR: 400,
|
|
27
|
+
UNAUTHORIZED: 401,
|
|
28
|
+
NOT_FOUND: 404,
|
|
29
|
+
RATE_LIMIT_EXCEEDED: 429,
|
|
30
|
+
INTERNAL_ERROR: 500,
|
|
31
|
+
} as const satisfies Record<ErrorCode, number>;
|
|
32
|
+
|
|
33
|
+
const appError = createAppError<typeof ERROR_STATUS, MessageKey>(ERROR_STATUS);
|
|
34
|
+
|
|
35
|
+
const errors = {
|
|
36
|
+
notFound: (what = "Resource") => appError("NOT_FOUND", `${what} not found`),
|
|
37
|
+
rateLimit: (retryAfterSecs: number) =>
|
|
38
|
+
appError("RATE_LIMIT_EXCEEDED", "Too many requests", { retryAfterSecs }),
|
|
39
|
+
};
|
|
40
|
+
|
|
41
|
+
const errorResponse = createErrorResponse<ErrorCode, MessageKey>({
|
|
42
|
+
internal: { code: "INTERNAL_ERROR", message: "Something on our side failed" },
|
|
43
|
+
validation: { code: "VALIDATION_ERROR", message: "The request could not be read" },
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
describe("the README", () => {
|
|
47
|
+
it("prints what ok() returns", () => {
|
|
48
|
+
expect(ok({ id: 1 })).toEqual({ status: 200, body: { data: { id: 1 } } });
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
it("prints what paginated() returns", () => {
|
|
52
|
+
const rows = [1, 2];
|
|
53
|
+
expect(paginated(rows, { total: 128, limit: 20, offset: 100 })).toEqual({
|
|
54
|
+
status: 200,
|
|
55
|
+
body: { data: rows, meta: { total: 128, limit: 20, offset: 100, hasMore: true } },
|
|
56
|
+
});
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
it("throws and answers the way the two snippets say", () => {
|
|
60
|
+
expect(errorResponse(errors.notFound("Workspace"))).toEqual({
|
|
61
|
+
status: 404,
|
|
62
|
+
body: { error: { code: "NOT_FOUND", message: "Workspace not found" } },
|
|
63
|
+
headers: {},
|
|
64
|
+
kind: "client",
|
|
65
|
+
});
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
it("puts a stated wait in the header and in details, from one value", () => {
|
|
69
|
+
const answer = errorResponse(errors.rateLimit(45));
|
|
70
|
+
expect(answer.headers["Retry-After"]).toBe("45");
|
|
71
|
+
expect(answer.body.error.details).toEqual({ retryAfterSecs: 45 });
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
it("logs the raw error and keeps the vendor's own input out of the line", async () => {
|
|
75
|
+
// The README says to pass the error itself, and says the allow-list covers the cause chain
|
|
76
|
+
// including a link that is not an Error. Both are one call here, because an adopter copies
|
|
77
|
+
// the line and gets both or neither.
|
|
78
|
+
const lines: string[] = [];
|
|
79
|
+
const logger = createLogger({ write: (line) => lines.push(line) });
|
|
80
|
+
const rejected = { message: "invalid signature", code: "PGRST301", payload: "the whole body" };
|
|
81
|
+
|
|
82
|
+
logger.error("charge failed", {
|
|
83
|
+
orderId: "ord_1",
|
|
84
|
+
error: new Error("invalid signature", { cause: rejected }),
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
expect(lines).toHaveLength(1);
|
|
88
|
+
expect(lines[0]).not.toContain("the whole body");
|
|
89
|
+
const entry = JSON.parse(String(lines[0])) as { error: { cause: unknown; stack: string } };
|
|
90
|
+
expect(entry.error.cause).toEqual({ message: "invalid signature", code: "PGRST301" });
|
|
91
|
+
expect(entry.error.stack).toContain("Error: invalid signature");
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
it("mounts the four Hono pieces in the order the snippet mounts them", async () => {
|
|
95
|
+
// Mounted exactly as the README prints it, then made to throw a NON-Error — which is the
|
|
96
|
+
// reason the README calls errorBoundary not optional. Without it Hono never reaches onError
|
|
97
|
+
// and the request ends with no answer at all.
|
|
98
|
+
const logger = createLogger({ level: "silent" });
|
|
99
|
+
const app = new Hono<{ Variables: RequestVariables<ErrorCode> }>();
|
|
100
|
+
app.use(requestLogger({ logger }));
|
|
101
|
+
app.use(errorBoundary);
|
|
102
|
+
app.onError(errorHandler({ errorResponse, logger }));
|
|
103
|
+
app.notFound(notFoundHandler(errorResponse(errors.notFound("Route"))));
|
|
104
|
+
// A rejection with a plain object, which is the case the boundary exists for.
|
|
105
|
+
app.get("/boom", () => Promise.reject({ code: "PGRST301", payload: "the whole body" }));
|
|
106
|
+
|
|
107
|
+
const boom = await app.request("/boom");
|
|
108
|
+
const missing = await app.request("/nope");
|
|
109
|
+
|
|
110
|
+
expect(boom.status).toBe(500);
|
|
111
|
+
expect(await boom.text()).not.toContain("the whole body");
|
|
112
|
+
expect(boom.headers.get("X-Request-ID")).toMatch(/^[A-Za-z0-9._-]{1,64}$/);
|
|
113
|
+
expect(missing.status).toBe(404);
|
|
114
|
+
expect(await missing.json()).toEqual({
|
|
115
|
+
error: { code: "NOT_FOUND", message: "Route not found" },
|
|
116
|
+
});
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
it("fails the guard check on the route the README says it names", () => {
|
|
120
|
+
// The snippet's whole claim: the matcher is asked, so a `use` registered AFTER its route is
|
|
121
|
+
// reported even though the pattern list would look complete.
|
|
122
|
+
const requireUser = guard(async (_c, next) => next());
|
|
123
|
+
const app = new Hono();
|
|
124
|
+
app.get("/public/status", (c) => c.text("ok"));
|
|
125
|
+
app.get("/private/thing", (c) => c.text("secret"));
|
|
126
|
+
app.use("/private/*", requireUser); // too late: registered after the route it guards
|
|
127
|
+
|
|
128
|
+
expect(() => assertEveryRouteGuarded(app, { isPublic: underAny(["/public"]) })).toThrow(
|
|
129
|
+
/\/private\/thing/,
|
|
130
|
+
);
|
|
131
|
+
});
|
|
132
|
+
});
|