@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 @@
|
|
|
1
|
+
{"version":3,"file":"responses.js","sourceRoot":"","sources":["../src/responses.ts"],"names":[],"mappings":"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"}
|
package/package.json
ADDED
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@gusnips/server",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "The layer under a TypeScript backend: one error shape, one response envelope, one retry rule, one logger.",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"license": "MIT",
|
|
7
|
+
"author": "Gustavo Salomé",
|
|
8
|
+
"homepage": "https://github.com/gusnips/serverkit/tree/main/server#readme",
|
|
9
|
+
"bugs": {
|
|
10
|
+
"url": "https://github.com/gusnips/serverkit/issues"
|
|
11
|
+
},
|
|
12
|
+
"exports": {
|
|
13
|
+
".": {
|
|
14
|
+
"types": "./dist/index.d.ts",
|
|
15
|
+
"default": "./dist/index.js"
|
|
16
|
+
},
|
|
17
|
+
"./hono": {
|
|
18
|
+
"types": "./dist/hono/index.d.ts",
|
|
19
|
+
"default": "./dist/hono/index.js"
|
|
20
|
+
}
|
|
21
|
+
},
|
|
22
|
+
"types": "./dist/index.d.ts",
|
|
23
|
+
"files": [
|
|
24
|
+
"dist",
|
|
25
|
+
"src",
|
|
26
|
+
"README.md",
|
|
27
|
+
"LICENSE"
|
|
28
|
+
],
|
|
29
|
+
"sideEffects": false,
|
|
30
|
+
"engines": {
|
|
31
|
+
"node": ">=22"
|
|
32
|
+
},
|
|
33
|
+
"publishConfig": {
|
|
34
|
+
"access": "public"
|
|
35
|
+
},
|
|
36
|
+
"keywords": [
|
|
37
|
+
"backend",
|
|
38
|
+
"hono",
|
|
39
|
+
"errors",
|
|
40
|
+
"logging",
|
|
41
|
+
"retry",
|
|
42
|
+
"typescript"
|
|
43
|
+
],
|
|
44
|
+
"scripts": {
|
|
45
|
+
"build": "rm -rf dist && tsc -p tsconfig.build.json",
|
|
46
|
+
"typecheck": "tsc --noEmit && tsc --noEmit -p tsconfig.node.json",
|
|
47
|
+
"test": "vitest run",
|
|
48
|
+
"test:watch": "vitest",
|
|
49
|
+
"lint": "eslint src",
|
|
50
|
+
"purity": "bun run scripts/check-purity.ts",
|
|
51
|
+
"sync:docs": "cp ../LICENSE .",
|
|
52
|
+
"prepublishOnly": "bun run lint && bun run typecheck && bun run purity && bun run test && bun run build && bun run sync:docs",
|
|
53
|
+
"release:patch": "bun pm version patch && bun publish --access public",
|
|
54
|
+
"release:minor": "bun pm version minor && bun publish --access public",
|
|
55
|
+
"release:major": "bun pm version major && bun publish --access public"
|
|
56
|
+
},
|
|
57
|
+
"peerDependencies": {
|
|
58
|
+
"@gusnips/http": ">=0.1 <1",
|
|
59
|
+
"hono": ">=4.9.9 <5"
|
|
60
|
+
},
|
|
61
|
+
"peerDependenciesMeta": {
|
|
62
|
+
"hono": {
|
|
63
|
+
"optional": true
|
|
64
|
+
}
|
|
65
|
+
},
|
|
66
|
+
"devDependencies": {
|
|
67
|
+
"@gusnips/http": ">=0.1 <1",
|
|
68
|
+
"@types/node": "^24.12.0",
|
|
69
|
+
"hono": "^4.10.6",
|
|
70
|
+
"typescript": "^5.9.3",
|
|
71
|
+
"vitest": "^4.1.2",
|
|
72
|
+
"zod": "^4.3.6"
|
|
73
|
+
},
|
|
74
|
+
"repository": {
|
|
75
|
+
"type": "git",
|
|
76
|
+
"url": "git+https://github.com/gusnips/serverkit.git",
|
|
77
|
+
"directory": "server"
|
|
78
|
+
}
|
|
79
|
+
}
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest";
|
|
2
|
+
import { AppError, toMessage } from "./errors.ts";
|
|
3
|
+
|
|
4
|
+
describe("toMessage", () => {
|
|
5
|
+
it("reads an Error's message", () => {
|
|
6
|
+
expect(toMessage(new Error("boom"))).toBe("boom");
|
|
7
|
+
});
|
|
8
|
+
|
|
9
|
+
it("reads a string message off a plain object", () => {
|
|
10
|
+
expect(toMessage({ code: "23505", message: "duplicate key value" })).toBe(
|
|
11
|
+
"duplicate key value",
|
|
12
|
+
);
|
|
13
|
+
});
|
|
14
|
+
|
|
15
|
+
it("returns a string as itself", () => {
|
|
16
|
+
expect(toMessage("just a string")).toBe("just a string");
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
it("serializes a plain object with no message rather than flattening it", () => {
|
|
20
|
+
const out = toMessage({ code: "PGRST205", hint: "run the migration" });
|
|
21
|
+
expect(out).not.toBe("[object Object]");
|
|
22
|
+
expect(out).toContain("PGRST205");
|
|
23
|
+
expect(out).toContain("run the migration");
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
it("keeps a messageless object's diagnostic and drops the inputs hung beside it", () => {
|
|
27
|
+
// The same allow-list the log serializer runs, and for the same reason one step later: this
|
|
28
|
+
// string becomes an Error's message in `errorBoundary`, and a message is printed. An SDK that
|
|
29
|
+
// rejects with the request it sent would otherwise put that request into the log through here.
|
|
30
|
+
const out = toMessage({ code: "PGRST301", payload: '{"card":"4242424242424242"}' });
|
|
31
|
+
|
|
32
|
+
expect(out).toContain("PGRST301");
|
|
33
|
+
expect(out).not.toContain("4242");
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
it("names the keys of an object with nothing printable, rather than printing nothing", () => {
|
|
37
|
+
// `{}` would be the "[object Object]" failure again in a new spelling: a useless string where
|
|
38
|
+
// the real failure was. The key names come from the SDK, never from a caller, so they are the
|
|
39
|
+
// one part of an unprintable object that identifies it.
|
|
40
|
+
expect(toMessage({ payload: "the whole request body", header: "t=1,v1=deadbeef" })).toBe(
|
|
41
|
+
"An object was thrown with no message. Its keys: payload, header.",
|
|
42
|
+
);
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
it("survives a circular object", () => {
|
|
46
|
+
const cycle: Record<string, unknown> = { code: "E_CYCLE" };
|
|
47
|
+
cycle.self = cycle;
|
|
48
|
+
expect(() => toMessage(cycle)).not.toThrow();
|
|
49
|
+
expect(toMessage(cycle)).toContain("E_CYCLE");
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
it("falls back to String() for a primitive", () => {
|
|
53
|
+
expect(toMessage(null)).toBe("null");
|
|
54
|
+
expect(toMessage(42)).toBe("42");
|
|
55
|
+
});
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
describe("an AppError is an ordinary Error to anything that serializes it", () => {
|
|
59
|
+
/**
|
|
60
|
+
* `JSON.stringify` calls a value's own `toJSON()` BEFORE it calls the replacer, so a class
|
|
61
|
+
* that defines one never reaches a logger's Error branch at all. Seven backends define
|
|
62
|
+
* `toJSON()` on this class, so `logger.error("x", { error: appErr })` writes a doubly-nested
|
|
63
|
+
* envelope with no `stack` and no `cause` — live in all seven, and invisible because the line
|
|
64
|
+
* still looks like a log line. The wire body is built by `errorResponse`; the error stays an
|
|
65
|
+
* error.
|
|
66
|
+
*/
|
|
67
|
+
function serializeLikeALogger(value: unknown): { json: string; sawError: boolean } {
|
|
68
|
+
let sawError = false;
|
|
69
|
+
const json = JSON.stringify(value, (_key, val: unknown) => {
|
|
70
|
+
if (val instanceof Error) {
|
|
71
|
+
sawError = true;
|
|
72
|
+
return { name: val.name, message: val.message, stack: val.stack, cause: val.cause };
|
|
73
|
+
}
|
|
74
|
+
return val;
|
|
75
|
+
});
|
|
76
|
+
return { json, sawError };
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
it("reaches the replacer's Error branch, with its stack and its cause", () => {
|
|
80
|
+
const cause = new Error("connection refused");
|
|
81
|
+
const err = new AppError(500, "INTERNAL_ERROR", "the write failed", { cause });
|
|
82
|
+
const { json, sawError } = serializeLikeALogger({ error: err });
|
|
83
|
+
expect(sawError).toBe(true);
|
|
84
|
+
expect(json).toContain("the write failed");
|
|
85
|
+
expect(json).toContain("connection refused");
|
|
86
|
+
expect(json).toContain('"stack"');
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
it("defines no toJSON, so nothing can shadow that branch", () => {
|
|
90
|
+
const err = new AppError(500, "INTERNAL_ERROR", "boom");
|
|
91
|
+
expect("toJSON" in err).toBe(false);
|
|
92
|
+
});
|
|
93
|
+
});
|
package/src/errors.ts
ADDED
|
@@ -0,0 +1,264 @@
|
|
|
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
|
+
|
|
13
|
+
import { narrowErrorLike } from "./logger/serialize.ts";
|
|
14
|
+
|
|
15
|
+
export interface AppErrorOptions<Key extends string = string> {
|
|
16
|
+
/** What makes the refusal ACTIONABLE: the plan that lifts a 402, the scope of a quota. */
|
|
17
|
+
details?: unknown;
|
|
18
|
+
/**
|
|
19
|
+
* The stable key a client localizes. Type it against your own closed list of keys, so a
|
|
20
|
+
* typo'd or stale key is a compile error at the emit site rather than a raw dotted string
|
|
21
|
+
* in front of a reader. The WIRE type stays `string`, so an older client tolerates a key
|
|
22
|
+
* from a newer server and degrades to `message`.
|
|
23
|
+
*/
|
|
24
|
+
messageKey?: Key;
|
|
25
|
+
/** Interpolation values for `messageKey`. */
|
|
26
|
+
params?: Record<string, string | number>;
|
|
27
|
+
/**
|
|
28
|
+
* How the refusal clears: seconds until the caller may retry, or `null` for a refusal that
|
|
29
|
+
* waiting cannot fix.
|
|
30
|
+
*
|
|
31
|
+
* Set it HERE rather than hand-rolling it into `details`. `errorResponse` renders a number as
|
|
32
|
+
* the standard `Retry-After` header AND folds it into `details`, so an HTTP client, a proxy
|
|
33
|
+
* and your own SDK all learn the same wait from one value; `null` is folded in without a
|
|
34
|
+
* header, because a `Retry-After` that states no time is worse than none.
|
|
35
|
+
*/
|
|
36
|
+
retryAfterSecs?: number | null;
|
|
37
|
+
/**
|
|
38
|
+
* The `message` was authored for the client — a deployment fact like "payments are not set
|
|
39
|
+
* up here", a named dependency that is down — so a 5xx keeps it instead of the generic
|
|
40
|
+
* sentence. Never set it on a message built from a caught error: that is where driver text
|
|
41
|
+
* lives, and one donor's whole masking policy exists because its repository layer
|
|
42
|
+
* interpolates the driver's message into every failure it raises.
|
|
43
|
+
*/
|
|
44
|
+
expose?: boolean;
|
|
45
|
+
cause?: unknown;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* The one error type routes throw; {@link errorResponse} formats the envelope.
|
|
50
|
+
*
|
|
51
|
+
* `Code` is your product's error-code union and `Key` its message-key union. Neither is
|
|
52
|
+
* shipped here: across six donors the factory tables hold 46 distinct code names and exactly
|
|
53
|
+
* nine appear in all six. The codes are an API's vocabulary. What this package ships is the
|
|
54
|
+
* shape, the wire format and the mask.
|
|
55
|
+
*
|
|
56
|
+
* Prefer {@link createAppError} over `new AppError(…)`: it reads the status off your own
|
|
57
|
+
* code→status map, so no call site names a status and a code added without one is a build
|
|
58
|
+
* error.
|
|
59
|
+
*
|
|
60
|
+
* Constructing one directly is the one path where a 429 with no wait is still representable:
|
|
61
|
+
* the rule lives on the factory, because only the factory knows the map. That is the reason to
|
|
62
|
+
* prefer it, not a style note.
|
|
63
|
+
*
|
|
64
|
+
* **There is no `toJSON()`, on purpose**, and the reason is not the one first written here.
|
|
65
|
+
* `JSON.stringify` calls a value's own `toJSON()` BEFORE the replacer, so an error class that
|
|
66
|
+
* defines one hands a logger whatever that method returns instead of the error. Seven backends
|
|
67
|
+
* define one, and every one of them logs `{"error":{"error":{code,message}}}` — doubly nested,
|
|
68
|
+
* no `stack`, no `cause` — from a line that still looks like a log line.
|
|
69
|
+
*
|
|
70
|
+
* This file used to say a logger cannot fix that from its side. It can, and ours does: the
|
|
71
|
+
* replacer is called with the HOLDER as `this`, whose own property is still the untouched error
|
|
72
|
+
* (see `errorReplacer`). What remains true is the design: the wire body is built by
|
|
73
|
+
* `errorResponse`, where the mask lives anyway, so one function owns the shape a client sees —
|
|
74
|
+
* and this stays an ordinary Error to anything that serializes it.
|
|
75
|
+
*/
|
|
76
|
+
export class AppError<Code extends string = string, Key extends string = string> extends Error {
|
|
77
|
+
public readonly statusCode: number;
|
|
78
|
+
public readonly code: Code;
|
|
79
|
+
public readonly details?: unknown;
|
|
80
|
+
public readonly messageKey?: Key;
|
|
81
|
+
public readonly params?: Record<string, string | number>;
|
|
82
|
+
public readonly retryAfterSecs?: number | null;
|
|
83
|
+
public readonly expose: boolean;
|
|
84
|
+
|
|
85
|
+
constructor(statusCode: number, code: Code, message: string, opts: AppErrorOptions<Key> = {}) {
|
|
86
|
+
super(message, { cause: opts.cause });
|
|
87
|
+
this.name = "AppError";
|
|
88
|
+
this.statusCode = statusCode;
|
|
89
|
+
this.code = code;
|
|
90
|
+
this.details = opts.details;
|
|
91
|
+
this.messageKey = opts.messageKey;
|
|
92
|
+
this.params = opts.params;
|
|
93
|
+
this.retryAfterSecs = opts.retryAfterSecs;
|
|
94
|
+
this.expose = opts.expose ?? false;
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* A map is widened to `Record<string, number>` unless it is declared `as const`, and a widened
|
|
100
|
+
* map cannot tell a 429 from a 404 — so the rule below would silently stop applying. Refusing
|
|
101
|
+
* the map is loud; accepting it with the guard switched off is the failure this package spends
|
|
102
|
+
* a paragraph on everywhere else.
|
|
103
|
+
*
|
|
104
|
+
* It catches the HALF-widened map too, which is the realistic way this happens: one status read
|
|
105
|
+
* from config turns `404 | number` into `number`, and the whole map loses its literals. The
|
|
106
|
+
* property name is what the compiler prints, so it is plain ASCII — an arrow there comes out as
|
|
107
|
+
* `\u2192` in the diagnostic, and the message is the entire point of the trick.
|
|
108
|
+
*/
|
|
109
|
+
type LiteralStatuses<S> = number extends S[keyof S]
|
|
110
|
+
? { "declare your code-to-status map as const": never }
|
|
111
|
+
: S;
|
|
112
|
+
|
|
113
|
+
/**
|
|
114
|
+
* Extra options a code's status makes mandatory.
|
|
115
|
+
*
|
|
116
|
+
* **A 429 states its own wait.** This is the highest-value line extracted from the whole
|
|
117
|
+
* reading. One donor writes a `resetAt` ISO date that no HTTP client parses, and then needs a
|
|
118
|
+
* hand-maintained list of "codes that do not clear by waiting" in its browser app to
|
|
119
|
+
* compensate — its own comment says so. Another donor needs no such list, because every 429 it
|
|
120
|
+
* sends states its wait, and a stated wait answers the question the list was guessing at. A
|
|
121
|
+
* third raises a spent DAILY cap with no wait at all, on a code its client treats as transient,
|
|
122
|
+
* so the browser retries a limit that clears at midnight — twice, immediately.
|
|
123
|
+
*
|
|
124
|
+
* Making it a required argument deletes that list from three repos and makes the retry bug
|
|
125
|
+
* unrepresentable. Measured against the fleet it came from: of 40 places that raise a 429,
|
|
126
|
+
* **34 already state a wait**, so the rule costs six edits in six repos.
|
|
127
|
+
*
|
|
128
|
+
* It is `[429] extends [Status]`, not `Status extends 429`, because the second form distributes:
|
|
129
|
+
* a code narrowed to a UNION — off a lookup table, a switch, a value read from the wire —
|
|
130
|
+
* produced a union of argument tuples, one of which had the options optional, and an empty
|
|
131
|
+
* argument list satisfied it. The obligation vanished on exactly the shape that is hardest to
|
|
132
|
+
* read. The tuples stop the distribution, and they ask the better question: does this code's
|
|
133
|
+
* status set INCLUDE 429. A widened `number` then requires the wait everywhere rather than
|
|
134
|
+
* nowhere, which is the safe direction to fail.
|
|
135
|
+
*
|
|
136
|
+
* Deliberately not extended, and the same counting method is what settled each one. Three
|
|
137
|
+
* statuses, one method, three different answers — which is the strongest thing that can be said
|
|
138
|
+
* for the method:
|
|
139
|
+
*
|
|
140
|
+
* - **429 — obligation.** 34 of 40 raises already state a wait, so the required argument mostly
|
|
141
|
+
* records a decision somebody had already made, and each of the six exceptions is
|
|
142
|
+
* interesting.
|
|
143
|
+
* - **503 — capability, not obligation.** Only 16 of 94 raises of a 502/503/504 factory state
|
|
144
|
+
* one. Most are "the database is unreachable" or "payments are not configured here", which
|
|
145
|
+
* have no wait to state, so a rule would buy 78 `null`s and teach people to type one without
|
|
146
|
+
* reading — and a client cannot tell a considered `null` from a reflex one. The raiser who
|
|
147
|
+
* knows is rare, and that is exactly the shape where a capability beats an obligation.
|
|
148
|
+
* - **402 — nothing to add.** Of 13 raises across five repos, **none** states a wait. That is
|
|
149
|
+
* what was measured, and it is all that was: it says no raiser in these repos claims a 402
|
|
150
|
+
* clears by waiting, not that none ever could. A card retry window or a transfer clearing
|
|
151
|
+
* overnight would be a real one — and it can say so, because the wait is available at every
|
|
152
|
+
* status. The day one turns up it is a finding rather than a contradiction.
|
|
153
|
+
*
|
|
154
|
+
* So the capability is on every code: a number renders `Retry-After` at any status, and an
|
|
155
|
+
* explicit `null` says "durable". The residual gap it closes is narrower than "503s need
|
|
156
|
+
* waits" — it is ONE code raised in two senses, durable and transient, indistinguishable in the
|
|
157
|
+
* envelope. The raiser that answers closes it for its own code, and nobody else is nagged.
|
|
158
|
+
*
|
|
159
|
+
* `null` is the other half, and the six are what proved it necessary. Two of them cannot state
|
|
160
|
+
* a wait truthfully: a concurrency slot frees when somebody else's job finishes, and a cap on
|
|
161
|
+
* live objects clears by archiving one, never by waiting at all. A required `number` would have
|
|
162
|
+
* forced both to invent a number. `null` says "waiting cannot fix this" — which is the very
|
|
163
|
+
* question the code lists were guessing at, answered by the one place that knows: the raiser.
|
|
164
|
+
* An omission is invisible in a diff; a `null` is a claim somebody has to read.
|
|
165
|
+
*/
|
|
166
|
+
type RequiredOptions<Status, Key extends string> = [429] extends [Status]
|
|
167
|
+
? [opts: AppErrorOptions<Key> & { retryAfterSecs: number | null }]
|
|
168
|
+
: [opts?: AppErrorOptions<Key>];
|
|
169
|
+
|
|
170
|
+
/**
|
|
171
|
+
* Binds your code→status map, and returns the factory your `errors.*` table calls.
|
|
172
|
+
*
|
|
173
|
+
* ```ts
|
|
174
|
+
* const ERROR_STATUS = {
|
|
175
|
+
* NOT_FOUND: 404,
|
|
176
|
+
* RATE_LIMIT_EXCEEDED: 429,
|
|
177
|
+
* } as const satisfies Record<ErrorCode, number>;
|
|
178
|
+
*
|
|
179
|
+
* const appError = createAppError<typeof ERROR_STATUS, MessageKey>(ERROR_STATUS);
|
|
180
|
+
*
|
|
181
|
+
* export const errors = {
|
|
182
|
+
* notFound: (what = "Resource") => appError("NOT_FOUND", `${what} not found`),
|
|
183
|
+
* rateLimit: (retryAfterSecs: number) =>
|
|
184
|
+
* appError("RATE_LIMIT_EXCEEDED", "Too many requests", { retryAfterSecs }),
|
|
185
|
+
* };
|
|
186
|
+
* ```
|
|
187
|
+
*
|
|
188
|
+
* The `satisfies` on your map is what makes a code with no status a build error — one line,
|
|
189
|
+
* in your repo, and the only version of this that cannot drift. Three of the five newest
|
|
190
|
+
* donors pass the status at every call site instead, which compiles no matter what.
|
|
191
|
+
*/
|
|
192
|
+
export function createAppError<S extends Record<string, number>, Key extends string = string>(
|
|
193
|
+
statusOf: S & LiteralStatuses<S>,
|
|
194
|
+
): <C extends keyof S & string>(
|
|
195
|
+
code: C,
|
|
196
|
+
message: string,
|
|
197
|
+
...opts: RequiredOptions<S[C], Key>
|
|
198
|
+
) => AppError<C, Key> {
|
|
199
|
+
return (code, message, ...opts) =>
|
|
200
|
+
// A code with no status is a build error at your `satisfies`. One reaching here anyway —
|
|
201
|
+
// a map assembled at runtime, a code narrowed off the wire — is our bug, not the caller's.
|
|
202
|
+
new AppError(statusOf[code] ?? 500, code, message, opts[0]);
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
/**
|
|
206
|
+
* `JSON.stringify` that never throws and never answers `"[object Object]"`.
|
|
207
|
+
*
|
|
208
|
+
* Private on purpose: it exists for {@link toMessage}'s last branch. A logger wants a richer
|
|
209
|
+
* one (an allow-list over an error's own fields), which is a different function.
|
|
210
|
+
*/
|
|
211
|
+
function safeStringify(value: unknown): string {
|
|
212
|
+
const seen = new WeakSet<object>();
|
|
213
|
+
try {
|
|
214
|
+
return (
|
|
215
|
+
JSON.stringify(value, (_key, val: unknown) => {
|
|
216
|
+
if (val instanceof Error) return { name: val.name, message: val.message };
|
|
217
|
+
if (typeof val === "bigint") return val.toString();
|
|
218
|
+
if (typeof val === "object" && val !== null) {
|
|
219
|
+
if (seen.has(val)) return "[Circular]";
|
|
220
|
+
seen.add(val);
|
|
221
|
+
}
|
|
222
|
+
return val;
|
|
223
|
+
}) ?? "null"
|
|
224
|
+
);
|
|
225
|
+
} catch {
|
|
226
|
+
return "[unserializable]";
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
/**
|
|
231
|
+
* Turn any thrown value into a string.
|
|
232
|
+
*
|
|
233
|
+
* The single home for the `err instanceof Error ? err.message : String(err)` idiom, which is
|
|
234
|
+
* wrong twice over and shipped that way in six repos:
|
|
235
|
+
*
|
|
236
|
+
* 1. A data layer rejects with a PLAIN OBJECT — `{code, message, hint}` is what PostgREST and
|
|
237
|
+
* several drivers throw — so the useful text is in `message` and `String()` never reads it.
|
|
238
|
+
* Six donors fixed this half.
|
|
239
|
+
* 2. An object with no string `message` still flattens to `"[object Object]"`, which is the
|
|
240
|
+
* real failure masked by a useless string. One donor fixed that half and named it exactly:
|
|
241
|
+
* *"masking the real failure."* Its version is the one here.
|
|
242
|
+
*/
|
|
243
|
+
export function toMessage(err: unknown): string {
|
|
244
|
+
if (err instanceof Error) return err.message;
|
|
245
|
+
if (typeof err === "string") return err;
|
|
246
|
+
if (typeof err === "object" && err !== null) {
|
|
247
|
+
const { message } = err as { message?: unknown };
|
|
248
|
+
if (typeof message === "string") return message;
|
|
249
|
+
// With no message to read, the object itself has to say what failed — and it cannot be
|
|
250
|
+
// printed whole. This string becomes an Error's `message` in `errorBoundary`, and a message
|
|
251
|
+
// is printed, so an SDK that rejects with the request it sent would put that request in the
|
|
252
|
+
// log through here. Same allow-list as the log serializer, one step earlier.
|
|
253
|
+
const kept = narrowErrorLike(err);
|
|
254
|
+
if (Object.keys(kept).length > 0) return safeStringify(kept);
|
|
255
|
+
// Nothing printable left. `{}` would be the "[object Object]" failure again in a new
|
|
256
|
+
// spelling: a useless string standing where the real failure was. The key names come from
|
|
257
|
+
// whoever threw, never from a caller, so they are the part that still identifies it.
|
|
258
|
+
const keys = Object.keys(err);
|
|
259
|
+
return keys.length > 0
|
|
260
|
+
? `An object was thrown with no message. Its keys: ${keys.join(", ")}.`
|
|
261
|
+
: "An object was thrown with no message and no fields.";
|
|
262
|
+
}
|
|
263
|
+
return String(err);
|
|
264
|
+
}
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Type-level guards. Most of this file never runs: `@ts-expect-error` fails
|
|
3
|
+
* `bun run typecheck` if the line under it ever starts compiling, which is the only way to
|
|
4
|
+
* pin a rule whose whole value is that a call site cannot be written.
|
|
5
|
+
*
|
|
6
|
+
* Every guarded statement is kept SHORT on purpose. `@ts-expect-error` binds to the next
|
|
7
|
+
* physical line, and the formatter decides where that is — wrap the call and the directive
|
|
8
|
+
* lands on `expect(() =>` , which has no error, while the real one moves out of its reach.
|
|
9
|
+
*/
|
|
10
|
+
import { describe, expect, it } from "vitest";
|
|
11
|
+
import { type AppErrorOptions, createAppError } from "./errors.ts";
|
|
12
|
+
|
|
13
|
+
const ERROR_STATUS = {
|
|
14
|
+
NOT_FOUND: 404,
|
|
15
|
+
RATE_LIMIT_EXCEEDED: 429,
|
|
16
|
+
QUOTA_EXCEEDED: 429,
|
|
17
|
+
INTERNAL_ERROR: 500,
|
|
18
|
+
} as const satisfies Record<string, number>;
|
|
19
|
+
|
|
20
|
+
type MessageKey = "serverErrors.notFound" | "serverErrors.rateLimit";
|
|
21
|
+
|
|
22
|
+
const appError = createAppError<typeof ERROR_STATUS, MessageKey>(ERROR_STATUS);
|
|
23
|
+
const spent = { details: { scope: "month" } };
|
|
24
|
+
|
|
25
|
+
// Annotated, not inferred: a code narrowed off a lookup table or a switch has a UNION type,
|
|
26
|
+
// which is the shape the rule used to lose.
|
|
27
|
+
const mixedCode: "NOT_FOUND" | "RATE_LIMIT_EXCEEDED" = "RATE_LIMIT_EXCEEDED";
|
|
28
|
+
const safeCodes: "NOT_FOUND" | "INTERNAL_ERROR" = "NOT_FOUND";
|
|
29
|
+
const fromConfig: number = 429;
|
|
30
|
+
|
|
31
|
+
describe("a 429 states its own wait", () => {
|
|
32
|
+
it("reads the status off the map, and keeps the wait", () => {
|
|
33
|
+
const err = appError("RATE_LIMIT_EXCEEDED", "Too many requests", { retryAfterSecs: 30 });
|
|
34
|
+
expect(err.statusCode).toBe(429);
|
|
35
|
+
expect(err.retryAfterSecs).toBe(30);
|
|
36
|
+
// The two honest answers, and only these two: a number, or a deliberate `null`.
|
|
37
|
+
const durable = appError("QUOTA_EXCEEDED", "Wait for a job to finish", {
|
|
38
|
+
retryAfterSecs: null,
|
|
39
|
+
});
|
|
40
|
+
expect(durable.retryAfterSecs).toBeNull();
|
|
41
|
+
// A union whose status set includes 429 owes the wait too, and one that cannot be a 429
|
|
42
|
+
// does not. Both directions, because only the pair proves the tuple wrappers.
|
|
43
|
+
expect(appError(mixedCode, "slow", { retryAfterSecs: 5 }).message).toBe("slow");
|
|
44
|
+
expect(appError(safeCodes, "gone").message).toBe("gone");
|
|
45
|
+
// Any status may state a wait voluntarily — a 503 that knows its own expiry, say. Only the
|
|
46
|
+
// OBLIGATION is 429-only.
|
|
47
|
+
const configured = appError("INTERNAL_ERROR", "Not configured here", { retryAfterSecs: null });
|
|
48
|
+
expect(configured.retryAfterSecs).toBeNull();
|
|
49
|
+
expect(appError("NOT_FOUND", "Workspace not found").statusCode).toBe(404);
|
|
50
|
+
expect(appError("INTERNAL_ERROR", "boom").statusCode).toBe(500);
|
|
51
|
+
});
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
/** Never called. Every line here is an assertion about what does not compile. */
|
|
55
|
+
function refusals(): void {
|
|
56
|
+
// @ts-expect-error — RATE_LIMIT_EXCEEDED maps to 429, so it has to say how it clears:
|
|
57
|
+
// a number of seconds, or `null` for a refusal waiting cannot fix.
|
|
58
|
+
appError("RATE_LIMIT_EXCEEDED", "Too many requests");
|
|
59
|
+
// @ts-expect-error — options are there, the wait is not.
|
|
60
|
+
appError("QUOTA_EXCEEDED", "Spent", spent);
|
|
61
|
+
// @ts-expect-error — "TEAPOT" is not in the map.
|
|
62
|
+
appError("TEAPOT", "no");
|
|
63
|
+
// @ts-expect-error — "serverErrors.typo" is not a MessageKey.
|
|
64
|
+
appError("NOT_FOUND", "no", { messageKey: "serverErrors.typo" });
|
|
65
|
+
|
|
66
|
+
// The three ways a forgotten argument could have passed for a deliberate `null`. The second
|
|
67
|
+
// is the one that matters: it is verbatim how three donors write their own `rateLimit`
|
|
68
|
+
// factory, so it is the shape a migration reaches for first.
|
|
69
|
+
// @ts-expect-error — an explicit `undefined` is not an answer.
|
|
70
|
+
appError("RATE_LIMIT_EXCEEDED", "x", { retryAfterSecs: undefined });
|
|
71
|
+
const forward = (message: string, opts?: AppErrorOptions<MessageKey>) =>
|
|
72
|
+
// @ts-expect-error — forwarding a caller's optional opts cannot satisfy it either.
|
|
73
|
+
appError("RATE_LIMIT_EXCEEDED", message, { ...opts });
|
|
74
|
+
void forward;
|
|
75
|
+
const partial: { retryAfterSecs?: number } = {};
|
|
76
|
+
// @ts-expect-error — nor can spreading an object whose wait is optional.
|
|
77
|
+
appError("RATE_LIMIT_EXCEEDED", "x", { ...partial });
|
|
78
|
+
|
|
79
|
+
// A code narrowed to a UNION used to drop the obligation: the conditional distributed, one
|
|
80
|
+
// arm had the options optional, and an empty argument list satisfied it. That is the shape a
|
|
81
|
+
// code read off a lookup table or a switch actually has — the hardest one to eyeball.
|
|
82
|
+
// @ts-expect-error — the union's status set includes 429, so the wait is still owed.
|
|
83
|
+
appError(mixedCode, "slow");
|
|
84
|
+
|
|
85
|
+
const widened: Record<string, number> = { RATE_LIMIT_EXCEEDED: 429 };
|
|
86
|
+
// @ts-expect-error — without `as const` every value is `number`, so the rule above would
|
|
87
|
+
// compile away silently. Refusing the map is the loud version.
|
|
88
|
+
createAppError(widened);
|
|
89
|
+
|
|
90
|
+
// The realistic way a map widens: ONE status read from config. `404 | number` absorbs to
|
|
91
|
+
// `number`, and the whole map loses its literals.
|
|
92
|
+
const half = { NOT_FOUND: 404, RATE_LIMIT_EXCEEDED: fromConfig } as const;
|
|
93
|
+
// @ts-expect-error — half-widened is widened.
|
|
94
|
+
createAppError<typeof half>(half);
|
|
95
|
+
}
|
|
96
|
+
void refusals;
|