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