@gusnips/server 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (57) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +323 -0
  3. package/dist/errors.d.ts +190 -0
  4. package/dist/errors.d.ts.map +1 -0
  5. package/dist/errors.js +154 -0
  6. package/dist/errors.js.map +1 -0
  7. package/dist/hono/errors.d.ts +46 -0
  8. package/dist/hono/errors.d.ts.map +1 -0
  9. package/dist/hono/errors.js +54 -0
  10. package/dist/hono/errors.js.map +1 -0
  11. package/dist/hono/guards.d.ts +45 -0
  12. package/dist/hono/guards.d.ts.map +1 -0
  13. package/dist/hono/guards.js +88 -0
  14. package/dist/hono/guards.js.map +1 -0
  15. package/dist/hono/index.d.ts +18 -0
  16. package/dist/hono/index.d.ts.map +1 -0
  17. package/dist/hono/index.js +15 -0
  18. package/dist/hono/index.js.map +1 -0
  19. package/dist/hono/request-logger.d.ts +31 -0
  20. package/dist/hono/request-logger.d.ts.map +1 -0
  21. package/dist/hono/request-logger.js +57 -0
  22. package/dist/hono/request-logger.js.map +1 -0
  23. package/dist/index.d.ts +7 -0
  24. package/dist/index.d.ts.map +1 -0
  25. package/dist/index.js +4 -0
  26. package/dist/index.js.map +1 -0
  27. package/dist/logger/index.d.ts +44 -0
  28. package/dist/logger/index.d.ts.map +1 -0
  29. package/dist/logger/index.js +74 -0
  30. package/dist/logger/index.js.map +1 -0
  31. package/dist/logger/serialize.d.ts +68 -0
  32. package/dist/logger/serialize.d.ts.map +1 -0
  33. package/dist/logger/serialize.js +203 -0
  34. package/dist/logger/serialize.js.map +1 -0
  35. package/dist/responses.d.ts +107 -0
  36. package/dist/responses.d.ts.map +1 -0
  37. package/dist/responses.js +183 -0
  38. package/dist/responses.js.map +1 -0
  39. package/package.json +79 -0
  40. package/src/errors.test.ts +93 -0
  41. package/src/errors.ts +264 -0
  42. package/src/errors.types.test.ts +96 -0
  43. package/src/hono/errors.test.ts +215 -0
  44. package/src/hono/errors.ts +86 -0
  45. package/src/hono/guards.test.ts +234 -0
  46. package/src/hono/guards.ts +107 -0
  47. package/src/hono/index.ts +17 -0
  48. package/src/hono/request-logger.test.ts +200 -0
  49. package/src/hono/request-logger.ts +77 -0
  50. package/src/index.ts +6 -0
  51. package/src/logger/index.test.ts +137 -0
  52. package/src/logger/index.ts +112 -0
  53. package/src/logger/serialize.test.ts +300 -0
  54. package/src/logger/serialize.ts +202 -0
  55. package/src/readme.test.ts +132 -0
  56. package/src/responses.test.ts +291 -0
  57. package/src/responses.ts +277 -0
@@ -0,0 +1,200 @@
1
+ import { Hono } from "hono";
2
+ import { describe, expect, it } from "vitest";
3
+ import { createLogger } from "../logger/index.ts";
4
+ import {
5
+ requestLogger,
6
+ type RequestLoggerOptions,
7
+ type RequestVariables,
8
+ } from "./request-logger.ts";
9
+
10
+ function setup(options: Omit<RequestLoggerOptions, "logger"> = {}) {
11
+ const lines: Array<Record<string, unknown>> = [];
12
+ const logger = createLogger({
13
+ level: "debug",
14
+ write: (line) => lines.push(JSON.parse(line) as Record<string, unknown>),
15
+ });
16
+ const app = new Hono<{ Variables: RequestVariables }>();
17
+ app.use(requestLogger({ logger, ...options }));
18
+ return { app, lines };
19
+ }
20
+
21
+ const UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/;
22
+
23
+ describe("the request line", () => {
24
+ it("names the route template, never the path", async () => {
25
+ // A path carries whatever the caller put in it. In one backend that was a customer's national
26
+ // id number, and the request line carried it into the log and on into an analytics event.
27
+ const { app, lines } = setup();
28
+ app.get("/people/:taxId/export", (c) => c.text("ok"));
29
+
30
+ await app.request("/people/12345678900/export");
31
+
32
+ expect(lines).toHaveLength(1);
33
+ expect(lines[0]).toMatchObject({ method: "GET", route: "/people/:taxId/export", status: 200 });
34
+ expect(JSON.stringify(lines)).not.toContain("12345678900");
35
+ });
36
+
37
+ it("names the route a refused request was headed for, not the middleware that refused it", async () => {
38
+ // The last matched route, not the deepest one that ran: a 401 from an auth middleware is
39
+ // filed under the endpoint it protected, which is the thing anyone reading the log asks about.
40
+ const { app, lines } = setup();
41
+ app.use("/admin/*", (c) => Promise.resolve(c.json({}, 401)));
42
+ app.get("/admin/users/:id", (c) => c.text("ok"));
43
+
44
+ await app.request("/admin/users/7");
45
+
46
+ expect(lines[0]).toMatchObject({ route: "/admin/users/:id", status: 401 });
47
+ });
48
+
49
+ it("does not write an unmatched path either", async () => {
50
+ const { app, lines } = setup();
51
+
52
+ await app.request("/wp-admin/12345678900.php");
53
+
54
+ expect(lines[0]).toMatchObject({ status: 404 });
55
+ expect(JSON.stringify(lines)).not.toContain("12345678900");
56
+ });
57
+
58
+ it("carries the refusal's code, rather than a second line of its own", async () => {
59
+ // A wrong code, a spent quota and a scanner's junk key are all routine. One warn per event
60
+ // would bury the failures that need a human, so the code rides on the line that exists anyway.
61
+ const { app, lines } = setup();
62
+ app.get("/verify", (c) => {
63
+ c.set("errorCode", "CODE_MISMATCH");
64
+ return c.json({}, 400);
65
+ });
66
+ app.get("/fine", (c) => c.text("ok"));
67
+
68
+ await app.request("/verify");
69
+ await app.request("/fine");
70
+
71
+ expect(lines).toHaveLength(2);
72
+ expect(lines[0]).toMatchObject({ level: "info", status: 400, errorCode: "CODE_MISMATCH" });
73
+ expect(lines[1]).not.toHaveProperty("errorCode");
74
+ });
75
+
76
+ it("starts errorCode at null, as its type says, rather than undefined", async () => {
77
+ const { app } = setup();
78
+ app.get("/", (c) => c.json({ errorCode: c.get("errorCode") }));
79
+
80
+ expect(await (await app.request("/")).json()).toEqual({ errorCode: null });
81
+ });
82
+
83
+ it("is still written for a request whose throw escaped every handler", async () => {
84
+ // Hono hands onError only an `Error`. A plain object — which is what a PostgREST client
85
+ // rejects with — is rethrown past every layer, and without a catch here the request that
86
+ // most needed a line is the one that never gets one.
87
+ const { app, lines } = setup();
88
+ app.get("/rows", () => {
89
+ throw { code: "PGRST116", message: "no rows" };
90
+ });
91
+
92
+ await expect(app.request("/rows")).rejects.toMatchObject({ code: "PGRST116" });
93
+
94
+ expect(lines).toHaveLength(1);
95
+ expect(lines[0]).toMatchObject({ level: "error", route: "/rows", status: 500 });
96
+ expect(lines[0]!.error).toMatchObject({ code: "PGRST116" });
97
+ });
98
+ });
99
+
100
+ describe("what is not logged", () => {
101
+ it("skips /health and anything under it, but not a path that only starts with the word", async () => {
102
+ const { app, lines } = setup();
103
+ for (const path of ["/health", "/health/db", "/healthz"]) app.get(path, (c) => c.text("ok"));
104
+
105
+ for (const path of ["/health", "/health/db", "/healthz"]) await app.request(path);
106
+
107
+ expect(lines.map((line) => line.route)).toEqual(["/healthz"]);
108
+ });
109
+
110
+ it("skips a preflight", async () => {
111
+ const { app, lines } = setup();
112
+ app.options("/items", (c) => c.body(null, 204));
113
+
114
+ await app.request("/items", { method: "OPTIONS" });
115
+
116
+ expect(lines).toEqual([]);
117
+ });
118
+
119
+ it("takes your own list in place of /health", async () => {
120
+ const { app, lines } = setup({ skipPaths: ["/ready"] });
121
+ app.get("/ready", (c) => c.text("ok"));
122
+ app.get("/health", (c) => c.text("ok"));
123
+
124
+ await app.request("/ready");
125
+ await app.request("/health");
126
+
127
+ expect(lines.map((line) => line.route)).toEqual(["/health"]);
128
+ });
129
+
130
+ it("still logs a skipped path that threw", async () => {
131
+ // Skipping is about volume. A health check that throws is not routine.
132
+ const { app, lines } = setup();
133
+ app.get("/health", () => {
134
+ throw { message: "pool exhausted" };
135
+ });
136
+
137
+ await expect(app.request("/health")).rejects.toMatchObject({ message: "pool exhausted" });
138
+
139
+ expect(lines[0]).toMatchObject({ level: "error", status: 500 });
140
+ });
141
+ });
142
+
143
+ describe("the request id", () => {
144
+ it("echoes a well-formed id the caller sent, and logs it", async () => {
145
+ const { app, lines } = setup();
146
+ app.get("/", (c) => c.text(c.get("requestId")));
147
+
148
+ const res = await app.request("/", { headers: { "X-Request-ID": "trace-2f9A_b.7" } });
149
+
150
+ expect(res.headers.get("X-Request-ID")).toBe("trace-2f9A_b.7");
151
+ expect(await res.text()).toBe("trace-2f9A_b.7");
152
+ expect(lines[0]).toMatchObject({ requestId: "trace-2f9A_b.7" });
153
+ });
154
+
155
+ it.each([
156
+ ["longer than 64 characters", "a".repeat(65)],
157
+ ["eight kilobytes long", "a".repeat(8192)],
158
+ ["carrying a space", "trace 1"],
159
+ ["carrying JSON", '{"admin":true}'],
160
+ ["carrying a character outside ASCII", "trace-ï"],
161
+ ])("replaces one %s with a fresh id, rather than echoing it", async (_, supplied) => {
162
+ const { app, lines } = setup();
163
+ app.get("/", (c) => c.text("ok"));
164
+
165
+ const res = await app.request("/", { headers: { "X-Request-ID": supplied } });
166
+
167
+ expect(res.headers.get("X-Request-ID")).toMatch(UUID);
168
+ expect(lines[0]!.requestId).toBe(res.headers.get("X-Request-ID"));
169
+ expect(JSON.stringify(lines)).not.toContain(supplied);
170
+ });
171
+
172
+ it("mints one when the caller sent none", async () => {
173
+ const { app } = setup();
174
+ app.get("/", (c) => c.text("ok"));
175
+
176
+ const res = await app.request("/");
177
+
178
+ expect(res.headers.get("X-Request-ID")).toMatch(UUID);
179
+ });
180
+
181
+ it("is on the answer even when the handler built its own Response", async () => {
182
+ // A header set before `next()` lives on a draft that Hono drops when a handler returns a
183
+ // Response it built itself, unless something else happened to materialize the draft first.
184
+ const { app } = setup();
185
+ app.get("/raw", () => new Response("ok"));
186
+
187
+ const res = await app.request("/raw");
188
+
189
+ expect(res.headers.get("X-Request-ID")).toMatch(UUID);
190
+ });
191
+
192
+ it("is on a skipped path's answer too", async () => {
193
+ const { app } = setup();
194
+ app.get("/health", (c) => c.text("ok"));
195
+
196
+ const res = await app.request("/health");
197
+
198
+ expect(res.headers.get("X-Request-ID")).toMatch(UUID);
199
+ });
200
+ });
@@ -0,0 +1,77 @@
1
+ import type { MiddlewareHandler } from "hono";
2
+ import { routePath } from "hono/route";
3
+ import type { Logger } from "../logger/index.ts";
4
+
5
+ /** The two variables this adapter writes. Put them in your app's `Variables`. */
6
+ export interface RequestVariables<Code extends string = string> {
7
+ requestId: string;
8
+ /** The code of the refusal this request got, for the request line. `null` until one happens. */
9
+ errorCode: Code | null;
10
+ }
11
+
12
+ export interface RequestLoggerOptions {
13
+ logger: Logger;
14
+ /**
15
+ * Paths answered but never logged, each with everything under it: `/health` covers
16
+ * `/health/db` and not `/healthz`. Defaults to `["/health"]`. A throw is logged anyway.
17
+ */
18
+ skipPaths?: readonly string[];
19
+ }
20
+
21
+ /**
22
+ * A caller's id is echoed back and written into every line, so it is kept only in a shape that
23
+ * cannot carry anything else: 64 characters of `A-Z a-z 0-9 . _ -`. Anything else is replaced, not
24
+ * trimmed, so the id in the log is always either the caller's or ours.
25
+ */
26
+ const REQUEST_ID = /^[A-Za-z0-9._-]{1,64}$/;
27
+
28
+ /**
29
+ * One line per request, and the request id.
30
+ *
31
+ * Mount it first, so it times and sees everything under it:
32
+ *
33
+ * app.use(requestLogger({ logger }));
34
+ *
35
+ * The id goes back on `X-Request-ID`, which is where the browser client reads it from, on every
36
+ * answer including `onError`'s and `notFound`'s. Cross-origin, list that header in your CORS
37
+ * `exposeHeaders` or the browser hides it from the page.
38
+ */
39
+ export function requestLogger({
40
+ logger,
41
+ skipPaths = ["/health"],
42
+ }: RequestLoggerOptions): MiddlewareHandler<{ Variables: RequestVariables }> {
43
+ return async (c, next) => {
44
+ const supplied = c.req.header("X-Request-ID");
45
+ const requestId = supplied && REQUEST_ID.test(supplied) ? supplied : crypto.randomUUID();
46
+ c.set("requestId", requestId);
47
+ c.set("errorCode", null);
48
+ const { method, path } = c.req;
49
+ const start = Date.now();
50
+ // The route TEMPLATE, never the path: a path carries whatever the caller put in it, and in one
51
+ // backend that was a national id number, which the request line carried into an analytics
52
+ // event. The last matched route rather than the deepest one that ran, so a 401 from an auth
53
+ // middleware is filed under the endpoint it protected.
54
+ const line = (status: number) => ({
55
+ requestId,
56
+ method,
57
+ route: routePath(c, -1),
58
+ status,
59
+ ms: Date.now() - start,
60
+ errorCode: c.get("errorCode") ?? undefined,
61
+ });
62
+ try {
63
+ await next();
64
+ } catch (err) {
65
+ // Only what Hono would not hand to onError gets here: a non-`Error` with no `errorBoundary`
66
+ // above it, or a throw from onError itself. Nothing answered it, so the runtime will, and
67
+ // the request that most needs a line is the one that would otherwise never get one.
68
+ logger.error("request", { ...line(500), error: err });
69
+ throw err;
70
+ }
71
+ // After `next()`, not before: a header set earlier lives on a draft Hono drops when a handler
72
+ // returns a Response it built itself.
73
+ c.header("X-Request-ID", requestId);
74
+ const skipped = skipPaths.some((skip) => path === skip || path.startsWith(`${skip}/`));
75
+ if (method !== "OPTIONS" && !skipped) logger.info("request", line(c.res.status));
76
+ };
77
+ }
package/src/index.ts ADDED
@@ -0,0 +1,6 @@
1
+ export { AppError, createAppError, toMessage } from "./errors.ts";
2
+ export type { AppErrorOptions } from "./errors.ts";
3
+ export { created, createErrorResponse, noContent, ok, paginated } from "./responses.ts";
4
+ export type { CannedError, ErrorAnswer, ErrorResponseOptions } from "./responses.ts";
5
+ export { createLogger, errorReplacer, keptErrorFields } from "./logger/index.ts";
6
+ export type { Logger, LoggerOptions, LogLevel, LogThreshold } from "./logger/index.ts";
@@ -0,0 +1,137 @@
1
+ import { afterEach, describe, expect, it, vi } from "vitest";
2
+ import { createLogger, type LogLevel } from "./index.ts";
3
+
4
+ function capture() {
5
+ const lines: Array<{ level: LogLevel; entry: Record<string, unknown> }> = [];
6
+ const write = (line: string, level: LogLevel) =>
7
+ lines.push({ level, entry: JSON.parse(line) as Record<string, unknown> });
8
+ return { lines, write };
9
+ }
10
+
11
+ afterEach(() => {
12
+ vi.restoreAllMocks();
13
+ });
14
+
15
+ describe("the log line", () => {
16
+ it("is one JSON object with level, time and message on it", () => {
17
+ const { lines, write } = capture();
18
+ createLogger({ write }).info("server started", { port: 3000 });
19
+
20
+ expect(lines).toHaveLength(1);
21
+ expect(lines[0]!.entry).toMatchObject({ level: "info", message: "server started", port: 3000 });
22
+ expect(new Date(String(lines[0]!.entry.time)).toISOString()).toBe(lines[0]!.entry.time);
23
+ });
24
+
25
+ it("lets the canonical fields win over a meta key of the same name", () => {
26
+ // A `meta.message` must never become the line's label. One backend re-implemented this
27
+ // logger inline with the spread the other way round, and a meta `message` silently replaced
28
+ // the line it was attached to.
29
+ const { lines, write } = capture();
30
+ createLogger({ write }).warn("quota check failed", {
31
+ message: "the caller's own text",
32
+ level: "debug",
33
+ time: "not a time",
34
+ });
35
+
36
+ expect(lines[0]!.entry).toMatchObject({
37
+ level: "warn",
38
+ message: "quota check failed",
39
+ });
40
+ expect(lines[0]!.entry.time).not.toBe("not a time");
41
+ });
42
+ });
43
+
44
+ describe("a logger that cannot serialize a line", () => {
45
+ it("writes a flagged line instead of throwing", () => {
46
+ // A logger must never take down the process it was called to report on. Reachable through a
47
+ // throwing getter or a throwing `toJSON()` on anything in `meta` — and the flag is there so
48
+ // a reader knows a line was lost rather than never written.
49
+ const hostile = {
50
+ toJSON() {
51
+ throw new Error("no");
52
+ },
53
+ };
54
+
55
+ const { lines, write } = capture();
56
+ createLogger({ write }).error("charge failed", { hostile });
57
+
58
+ expect(lines).toHaveLength(1);
59
+ expect(lines[0]!.entry).toMatchObject({
60
+ level: "error",
61
+ message: "charge failed",
62
+ logSerializationFailed: true,
63
+ });
64
+ });
65
+ });
66
+
67
+ describe("the level", () => {
68
+ it("defaults to info, so debug is dropped and warn is kept", () => {
69
+ const { lines, write } = capture();
70
+ const logger = createLogger({ write });
71
+ logger.debug("noise");
72
+ logger.info("kept");
73
+ logger.warn("kept");
74
+ logger.error("kept");
75
+
76
+ expect(lines.map((l) => l.level)).toEqual(["info", "warn", "error"]);
77
+ });
78
+
79
+ it("comes from the caller, never from the environment", () => {
80
+ // The whole reason this is an argument: `.` has to import cleanly inside a Cloudflare
81
+ // Worker, which has no `process` at all. A module-scope `process.env.LOG_LEVEL` read makes
82
+ // the package Node-only by accident, and nothing in a type or a test would say so.
83
+ const { lines, write } = capture();
84
+ createLogger({ level: "debug", write }).debug("now visible");
85
+
86
+ expect(lines).toHaveLength(1);
87
+ });
88
+
89
+ it("writes nothing at all when it is silent", () => {
90
+ // What a test suite sets, so an injected failure does not print a real-looking error line
91
+ // and bury the one real failure. It is a threshold, not a level: nothing logs AT silent.
92
+ const { lines, write } = capture();
93
+ const logger = createLogger({ level: "silent", write });
94
+ logger.debug("x");
95
+ logger.error("x");
96
+
97
+ expect(lines).toHaveLength(0);
98
+ });
99
+
100
+ it("treats an unset or empty level as info", () => {
101
+ // `process.env.LOG_LEVEL` is `undefined` when unset and "" when set to nothing, and both
102
+ // mean the same thing to the person who typed them.
103
+ const { lines, write } = capture();
104
+ createLogger({ level: undefined, write }).info("a");
105
+ createLogger({ level: "", write }).info("b");
106
+
107
+ expect(lines).toHaveLength(2);
108
+ });
109
+
110
+ it("refuses an unknown level at boot, and names the ones that work", () => {
111
+ // Loud here rather than a silent fall back to info: a box running at the wrong level is
112
+ // discovered during the incident it was supposed to explain. This runs once, at boot, so a
113
+ // typo fails the deploy instead of the 3am read.
114
+ expect(() => createLogger({ level: "verbose" })).toThrow(/verbose/);
115
+ expect(() => createLogger({ level: "verbose" })).toThrow(/debug, info, warn, error, silent/);
116
+ });
117
+ });
118
+
119
+ describe("where a line goes", () => {
120
+ it("sends error and warn to stderr and the rest to stdout", () => {
121
+ // The process manager reading these keeps stdout and stderr apart, and a crash that only
122
+ // reaches stderr is the thing the structured log exists to replace.
123
+ const log = vi.spyOn(console, "log").mockImplementation(() => {});
124
+ const warn = vi.spyOn(console, "warn").mockImplementation(() => {});
125
+ const error = vi.spyOn(console, "error").mockImplementation(() => {});
126
+
127
+ const logger = createLogger({ level: "debug" });
128
+ logger.debug("a");
129
+ logger.info("b");
130
+ logger.warn("c");
131
+ logger.error("d");
132
+
133
+ expect(log).toHaveBeenCalledTimes(2);
134
+ expect(warn).toHaveBeenCalledTimes(1);
135
+ expect(error).toHaveBeenCalledTimes(1);
136
+ });
137
+ });
@@ -0,0 +1,112 @@
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.ts";
12
+
13
+ export type LogLevel = "debug" | "info" | "warn" | "error";
14
+
15
+ /** A threshold, which is a level or "silent". "silent" is not a level: nothing logs AT it. */
16
+ export type LogThreshold = LogLevel | "silent";
17
+
18
+ export interface Logger {
19
+ debug(message: string, meta?: Record<string, unknown>): void;
20
+ info(message: string, meta?: Record<string, unknown>): void;
21
+ warn(message: string, meta?: Record<string, unknown>): void;
22
+ error(message: string, meta?: Record<string, unknown>): void;
23
+ }
24
+
25
+ export interface LoggerOptions {
26
+ /**
27
+ * The lowest level that gets written. Defaults to "info".
28
+ *
29
+ * It is an argument rather than a `process.env.LOG_LEVEL` read, and that is the boundary this
30
+ * package is built on: a Cloudflare Worker has no `process` at all, so a module-scope read makes
31
+ * the package Node-only by accident. The adopter writes the read:
32
+ *
33
+ * export const logger = createLogger({ level: process.env.LOG_LEVEL });
34
+ *
35
+ * and a Worker writes `createLogger({ level: env.LOG_LEVEL })` from its handler argument.
36
+ *
37
+ * Typed `string` on purpose, so passing `process.env.LOG_LEVEL` needs no cast. An unrecognized
38
+ * value throws — see `resolveThreshold`.
39
+ */
40
+ level?: string | undefined;
41
+ /**
42
+ * Where a finished line goes. Defaults to the console, `error`/`warn` to stderr.
43
+ *
44
+ * This is a seam, not a transport — the package ships none. It exists because one backend in
45
+ * the fleet needs the line twice (stdout and a batched exporter it flushes on SIGTERM), and
46
+ * because without it every test of anything that logs has to monkey-patch a global.
47
+ */
48
+ write?: (line: string, level: LogLevel) => void;
49
+ }
50
+
51
+ const LEVELS: Record<LogLevel, number> = { debug: 10, info: 20, warn: 30, error: 40 };
52
+ const SILENT = Number.POSITIVE_INFINITY;
53
+
54
+ function resolveThreshold(level: string | undefined): number {
55
+ if (level === undefined || level === "") return LEVELS.info;
56
+ if (level === "silent") return SILENT;
57
+ const known = LEVELS[level as LogLevel];
58
+ if (known !== undefined) return known;
59
+ // Loud, at construction, rather than silently falling back to "info": a box running at the
60
+ // wrong level is discovered during the incident it was meant to explain. This is called once
61
+ // at boot, so a typo fails the deploy instead of the 3am read.
62
+ throw new Error(
63
+ `Unknown log level ${JSON.stringify(level)}. ` +
64
+ `Use one of: debug, info, warn, error, silent — or leave it unset for info.`,
65
+ );
66
+ }
67
+
68
+ function consoleWrite(line: string, level: LogLevel): void {
69
+ if (level === "error") console.error(line);
70
+ else if (level === "warn") console.warn(line);
71
+ else console.log(line);
72
+ }
73
+
74
+ /**
75
+ * Build a logger. Call it once, at boot, and pass the result to everything that logs.
76
+ *
77
+ * const logger = createLogger({ level: process.env.LOG_LEVEL });
78
+ * logger.info("server started", { port: 3000 });
79
+ * logger.error("charge failed", { orderId, error: err }); // the RAW error, never String(err)
80
+ */
81
+ export function createLogger(options: LoggerOptions = {}): Logger {
82
+ const threshold = resolveThreshold(options.level);
83
+ const write = options.write ?? consoleWrite;
84
+
85
+ function emit(level: LogLevel, message: string, meta?: Record<string, unknown>): void {
86
+ if (LEVELS[level] < threshold) return;
87
+ // Canonical fields last, so they win: a meta `message`, `level` or `time` must never replace
88
+ // the line's own label, severity or timestamp. One backend re-implemented this logger inline
89
+ // with the order inverted, and a `meta.message` silently became the line.
90
+ const time = new Date().toISOString();
91
+ const entry = { ...meta, level, time, message };
92
+ let line: string;
93
+ try {
94
+ line = JSON.stringify(entry, errorReplacer());
95
+ } catch {
96
+ // A logger must never take down the process it is reporting on. Reachable through a
97
+ // throwing getter or a throwing `toJSON()` on something in `meta` — and the flag is there
98
+ // so a reader knows a line was lost rather than never written.
99
+ line = JSON.stringify({ level, time, message, logSerializationFailed: true });
100
+ }
101
+ write(line, level);
102
+ }
103
+
104
+ return {
105
+ debug: (message, meta) => emit("debug", message, meta),
106
+ info: (message, meta) => emit("info", message, meta),
107
+ warn: (message, meta) => emit("warn", message, meta),
108
+ error: (message, meta) => emit("error", message, meta),
109
+ };
110
+ }
111
+
112
+ export { errorReplacer, keptErrorFields } from "./serialize.ts";