@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,215 @@
1
+ import { Hono } from "hono";
2
+ import { describe, expect, it, vi } from "vitest";
3
+ import { AppError, createAppError, createErrorResponse, createLogger } from "../index.ts";
4
+ import { errorBoundary, errorHandler, notFoundHandler } from "./errors.ts";
5
+ import { requestLogger, type RequestVariables } from "./request-logger.ts";
6
+
7
+ type ErrorCode = "NOT_FOUND" | "RATE_LIMIT_EXCEEDED" | "UNAVAILABLE" | "INTERNAL_ERROR" | "ODD";
8
+
9
+ const appError = createAppError({
10
+ NOT_FOUND: 404,
11
+ RATE_LIMIT_EXCEEDED: 429,
12
+ UNAVAILABLE: 503,
13
+ INTERNAL_ERROR: 500,
14
+ } as const);
15
+
16
+ const errorResponse = createErrorResponse<ErrorCode, string>({
17
+ internal: { code: "INTERNAL_ERROR", message: "Something on our side failed" },
18
+ validation: { code: "INTERNAL_ERROR", message: "unused here" },
19
+ });
20
+
21
+ function setup({ boundary = true } = {}) {
22
+ const lines: Array<Record<string, unknown>> = [];
23
+ const logger = createLogger({
24
+ level: "debug",
25
+ write: (line) => lines.push(JSON.parse(line) as Record<string, unknown>),
26
+ });
27
+ const onUnexpected = vi.fn();
28
+ const app = new Hono<{ Variables: RequestVariables<ErrorCode> }>();
29
+ app.use(requestLogger({ logger }));
30
+ if (boundary) app.use(errorBoundary);
31
+ app.onError(errorHandler({ errorResponse, logger, onUnexpected }));
32
+ app.notFound(notFoundHandler(errorResponse(appError("NOT_FOUND", "Route not found"))));
33
+ return { app, lines, onUnexpected };
34
+ }
35
+
36
+ describe("errorBoundary", () => {
37
+ const rejection = { code: "PGRST116", message: "no rows returned" };
38
+
39
+ it("turns a thrown plain object into an answer, where Hono alone answers nothing", async () => {
40
+ const bare = setup({ boundary: false });
41
+ bare.app.get("/rows", () => {
42
+ throw rejection;
43
+ });
44
+ await expect(bare.app.request("/rows")).rejects.toBe(rejection);
45
+
46
+ const { app } = setup();
47
+ app.get("/rows", () => {
48
+ throw rejection;
49
+ });
50
+ const res = await app.request("/rows");
51
+
52
+ expect(res.status).toBe(500);
53
+ expect(await res.json()).toEqual({
54
+ error: { code: "INTERNAL_ERROR", message: "Something on our side failed" },
55
+ });
56
+ });
57
+
58
+ it("keeps the original as the cause, and its message as the message", async () => {
59
+ const { app, lines } = setup();
60
+ app.get("/rows", () => {
61
+ throw rejection;
62
+ });
63
+
64
+ await app.request("/rows");
65
+
66
+ const failed = lines.find((line) => line.message === "request failed");
67
+ expect(failed?.error).toMatchObject({ message: "no rows returned", cause: rejection });
68
+ });
69
+
70
+ it("hands a real Error through as the same instance", async () => {
71
+ const thrown = new TypeError("x is undefined");
72
+ const { app, onUnexpected } = setup();
73
+ app.get("/", () => {
74
+ throw thrown;
75
+ });
76
+
77
+ await app.request("/");
78
+
79
+ expect(onUnexpected.mock.calls[0]![0]).toBe(thrown);
80
+ });
81
+ });
82
+
83
+ describe("errorHandler", () => {
84
+ it("answers a refusal with its status, body and headers, and names it on the request line", async () => {
85
+ const { app, lines, onUnexpected } = setup();
86
+ app.get("/send", () => {
87
+ throw appError("RATE_LIMIT_EXCEEDED", "Too many requests", { retryAfterSecs: 30 });
88
+ });
89
+
90
+ const res = await app.request("/send");
91
+
92
+ expect(res.status).toBe(429);
93
+ expect(res.headers.get("Retry-After")).toBe("30");
94
+ expect(await res.json()).toMatchObject({ error: { code: "RATE_LIMIT_EXCEEDED" } });
95
+ expect(lines).toEqual([
96
+ expect.objectContaining({
97
+ message: "request",
98
+ status: 429,
99
+ errorCode: "RATE_LIMIT_EXCEEDED",
100
+ }),
101
+ ]);
102
+ expect(onUnexpected).not.toHaveBeenCalled();
103
+ });
104
+
105
+ it("logs a 5xx it was handed on purpose, without calling it unexpected", async () => {
106
+ const { app, lines, onUnexpected } = setup();
107
+ app.get("/", () => {
108
+ throw appError("UNAVAILABLE", "The database is unreachable");
109
+ });
110
+
111
+ const res = await app.request("/");
112
+
113
+ expect(res.status).toBe(503);
114
+ expect(lines[0]).toMatchObject({ level: "error", message: "request failed", kind: "server" });
115
+ expect(onUnexpected).not.toHaveBeenCalled();
116
+ });
117
+
118
+ it("logs an escaped throw with its stack, alerts on it, and answers the generic 500", async () => {
119
+ const { app, lines, onUnexpected } = setup();
120
+ app.get("/", () => {
121
+ throw new TypeError("x is undefined");
122
+ });
123
+
124
+ const res = await app.request("/");
125
+
126
+ expect(res.status).toBe(500);
127
+ expect(await res.json()).toMatchObject({ error: { code: "INTERNAL_ERROR" } });
128
+ expect(lines[0]).toMatchObject({ level: "error", kind: "unexpected" });
129
+ expect(lines[0]!.error).toMatchObject({ name: "TypeError", message: "x is undefined" });
130
+ expect(String((lines[0]!.error as { stack: string }).stack)).toContain("TypeError");
131
+ expect(lines[1]).toMatchObject({
132
+ message: "request",
133
+ status: 500,
134
+ errorCode: "INTERNAL_ERROR",
135
+ });
136
+ expect(onUnexpected).toHaveBeenCalledOnce();
137
+ });
138
+
139
+ it("carries the request id on the error answer, the same one the lines carry", async () => {
140
+ // The browser client reads the id off this header into the error it raises, which is what
141
+ // lets an error screen show a reference someone can quote.
142
+ const { app, lines } = setup();
143
+ app.get("/", () => {
144
+ throw new TypeError("x is undefined");
145
+ });
146
+
147
+ const res = await app.request("/", { headers: { "X-Request-ID": "trace-1" } });
148
+
149
+ expect(res.headers.get("X-Request-ID")).toBe("trace-1");
150
+ expect(lines.map((line) => line.requestId)).toEqual(["trace-1", "trace-1"]);
151
+ });
152
+
153
+ it.each([
154
+ ["a success", 200],
155
+ ["one that cannot carry a body", 204],
156
+ ["one outside HTTP", 700],
157
+ ])("treats an error raised with %s status as the bug it is", async (_, status) => {
158
+ // Hono hands an Error thrown out of onError back to onError, one level up. So refusing the
159
+ // status is enough to land it in the unexpected arm, where somebody is told.
160
+ const { app, lines, onUnexpected } = setup();
161
+ app.get("/", () => {
162
+ throw new AppError(status, "ODD", "a status nobody should raise");
163
+ });
164
+
165
+ const res = await app.request("/");
166
+
167
+ expect(res.status).toBe(500);
168
+ expect(await res.json()).toMatchObject({ error: { code: "INTERNAL_ERROR" } });
169
+ expect(onUnexpected).toHaveBeenCalledOnce();
170
+ expect(lines.find((line) => line.kind === "unexpected")?.error).toMatchObject({
171
+ name: "RangeError",
172
+ message: `An error answered ${status}.`,
173
+ });
174
+ });
175
+ });
176
+
177
+ describe("notFoundHandler", () => {
178
+ it("answers the refusal it was given, with the request id, and names it on the request line", async () => {
179
+ const { app, lines } = setup();
180
+
181
+ const res = await app.request("/nothing/here");
182
+
183
+ expect(res.status).toBe(404);
184
+ expect(res.headers.get("X-Request-ID")).toBeTruthy();
185
+ expect(await res.json()).toEqual({ error: { code: "NOT_FOUND", message: "Route not found" } });
186
+ expect(lines[0]).toMatchObject({ status: 404, errorCode: "NOT_FOUND" });
187
+ });
188
+ });
189
+
190
+ describe("an app's own env", () => {
191
+ it("takes all four pieces without a cast", async () => {
192
+ // The shape an adopter writes: bindings, variables of its own, and `errorCode` typed to its
193
+ // own code union rather than to `string`.
194
+ type AppEnv = {
195
+ Bindings: { DATABASE_URL: string };
196
+ Variables: { requestId: string; errorCode: ErrorCode | null; userId: string };
197
+ };
198
+ const logger = createLogger({ level: "silent" });
199
+ const app = new Hono<AppEnv>();
200
+ app.use(requestLogger({ logger }));
201
+ app.use(errorBoundary);
202
+ app.onError(errorHandler({ errorResponse, logger, onUnexpected: (_err, c) => c.req.method }));
203
+ app.notFound(notFoundHandler(errorResponse(appError("NOT_FOUND", "Route not found"))));
204
+ app.get("/", (c) => c.text(c.get("requestId")));
205
+
206
+ expect((await app.request("/")).status).toBe(200);
207
+ });
208
+
209
+ it("refuses an errorResponse that answers codes the app's errorCode cannot hold", () => {
210
+ const logger = createLogger({ level: "silent" });
211
+ const narrow = new Hono<{ Variables: RequestVariables<"NOT_FOUND"> }>();
212
+ // @ts-expect-error — this errorResponse also answers INTERNAL_ERROR, among others.
213
+ narrow.onError(errorHandler({ errorResponse, logger }));
214
+ });
215
+ });
@@ -0,0 +1,86 @@
1
+ import type { Context, ErrorHandler, MiddlewareHandler, NotFoundHandler } from "hono";
2
+ import type { ContentfulStatusCode } from "hono/utils/http-status";
3
+ import { toMessage } from "../errors.ts";
4
+ import type { Logger } from "../logger/index.ts";
5
+ import type { ErrorAnswer } from "../responses.ts";
6
+ import type { RequestVariables } from "./request-logger.ts";
7
+
8
+ /**
9
+ * The app's own env, taken from the `onError` / `notFound` call it is passed to, so its bindings
10
+ * and variables fit as they are. The code type comes from its `errorCode`, which is what stops
11
+ * `errorResponse` from answering a code that slot cannot hold.
12
+ */
13
+ type ErrorEnv = { Variables: RequestVariables };
14
+ type CodeOf<E extends ErrorEnv> = NonNullable<E["Variables"]["errorCode"]>;
15
+
16
+ /**
17
+ * Turns a thrown non-`Error` into an `Error`, so it reaches `onError`.
18
+ *
19
+ * Hono hands `onError` only what is `instanceof Error`. Anything else is rethrown past every
20
+ * layer and escapes as an unhandled rejection: no answer, a dropped connection, and a browser that
21
+ * reports it as a CORS failure — which sends whoever reads it to the wrong layer. A PostgREST
22
+ * client rejects with plain objects, so this is not hypothetical. The original rides as `cause`.
23
+ *
24
+ * Mount it right after `requestLogger`.
25
+ */
26
+ export const errorBoundary: MiddlewareHandler = async (_c, next) => {
27
+ try {
28
+ await next();
29
+ } catch (err) {
30
+ if (err instanceof Error) throw err;
31
+ throw new Error(toMessage(err), { cause: err });
32
+ }
33
+ };
34
+
35
+ export interface ErrorHandlerOptions<E extends ErrorEnv> {
36
+ /** Your bound `createErrorResponse(...)`. */
37
+ errorResponse: (err: unknown) => ErrorAnswer<CodeOf<E>>;
38
+ logger: Logger;
39
+ /**
40
+ * Called for a throw nobody raised on purpose, which is where an alert belongs. It runs inside
41
+ * `onError`, so it must not throw, and anything slow should be sent without being awaited.
42
+ */
43
+ onUnexpected?: (err: unknown, c: Context<E>) => void;
44
+ }
45
+
46
+ /**
47
+ * `app.onError(errorHandler({ errorResponse, logger }))`.
48
+ *
49
+ * A refusal under 500 writes no line of its own: its code rides on the request line, because one
50
+ * line per wrong password buries the failures that need a human. A 5xx and an escaped throw are
51
+ * logged with the raw error, stack and cause included.
52
+ */
53
+ export function errorHandler<E extends ErrorEnv>({
54
+ errorResponse,
55
+ logger,
56
+ onUnexpected,
57
+ }: ErrorHandlerOptions<E>): ErrorHandler<E> {
58
+ return (err, c) => {
59
+ const answer = errorResponse(err);
60
+ if (answer.kind !== "client")
61
+ logger.error("request failed", {
62
+ requestId: c.get("requestId"),
63
+ kind: answer.kind,
64
+ error: err,
65
+ });
66
+ if (answer.kind === "unexpected") onUnexpected?.(err, c);
67
+ return respond(c, answer);
68
+ };
69
+ }
70
+
71
+ /** `app.notFound(notFoundHandler(errorResponse(errors.notFound("Route"))))`. */
72
+ export const notFoundHandler =
73
+ <E extends ErrorEnv>(answer: ErrorAnswer<CodeOf<E>>): NotFoundHandler<E> =>
74
+ (c) =>
75
+ respond(c, answer);
76
+
77
+ function respond<E extends ErrorEnv>(c: Context<E>, answer: ErrorAnswer<CodeOf<E>>) {
78
+ // An error answered as a 2xx reads as success to every client. Thrown instead, it comes back
79
+ // through onError as the unexpected throw it is: the generic 500, logged, and alerted on.
80
+ if (!isErrorStatus(answer.status)) throw new RangeError(`An error answered ${answer.status}.`);
81
+ c.set("errorCode", answer.body.error.code);
82
+ return c.json(answer.body, answer.status, answer.headers);
83
+ }
84
+
85
+ const isErrorStatus = (status: number): status is ContentfulStatusCode =>
86
+ status >= 400 && status <= 599;
@@ -0,0 +1,234 @@
1
+ import { Hono, type MiddlewareHandler } from "hono";
2
+ import { describe, expect, it } from "vitest";
3
+ import { assertEveryRouteGuarded, guard, underAny } from "./guards.ts";
4
+
5
+ const requireAdmin = guard(async (c, next) => {
6
+ if (c.req.header("Authorization") !== "Bearer admin") return c.json({}, 401);
7
+ return next();
8
+ });
9
+
10
+ const logRequest: MiddlewareHandler = async (_c, next) => {
11
+ await next();
12
+ };
13
+
14
+ const ok = (c: { text: (body: string) => Response }) => c.text("ok");
15
+
16
+ function check(app: Hono, isPublic: (path: string) => boolean = () => false) {
17
+ return () => assertEveryRouteGuarded(app, { isPublic });
18
+ }
19
+
20
+ describe("assertEveryRouteGuarded", () => {
21
+ it("passes an app whose guard is mounted before its routes", () => {
22
+ const app = new Hono();
23
+ app.use(logRequest);
24
+ app.use("/admin/*", requireAdmin);
25
+ app.get("/admin/users", ok);
26
+ app.post("/admin/users/:id/ban", ok);
27
+
28
+ expect(check(app)).not.toThrow();
29
+ });
30
+
31
+ it("fails a guard registered after its route, which matches it and never runs", async () => {
32
+ // Compare pattern lists and this app is guarded: `/admin/*` covers `/admin/users`. Ask the
33
+ // matcher and the handler comes first, answers, and the guard never runs.
34
+ const app = new Hono();
35
+ app.get("/admin/users", ok);
36
+ app.use("/admin/*", requireAdmin);
37
+
38
+ expect((await app.request("/admin/users")).status).toBe(200);
39
+ expect(check(app)).toThrow(/endpoint\(s\) answer with no guard[^]*\n {2}GET \/admin\/users\n/);
40
+ });
41
+
42
+ it("fails a router mounted above the guard loop", async () => {
43
+ // One backend's gate test built a second app from its guard list plus a catch-all, so it never
44
+ // saw the real mount order, and passed while the real app's router, mounted above the loop,
45
+ // answered with no token at all. Only the real app, asked, has the order.
46
+ const patterns = ["/feedback", "/admin/*"];
47
+ const feedback = new Hono();
48
+ feedback.get("/", ok);
49
+ feedback.post("/", ok);
50
+ feedback.get("/mine", ok);
51
+ const app = new Hono();
52
+ app.route("/feedback", feedback);
53
+ for (const pattern of patterns) app.use(pattern, requireAdmin);
54
+ app.get("/admin/users", ok);
55
+
56
+ expect((await app.request("/feedback/mine")).status).toBe(200);
57
+ expect(check(app)).toThrow(
58
+ /3 endpoint\(s\)[^]*\n {2}GET \/feedback\n {2}GET \/feedback\/mine\n {2}POST \/feedback\n/,
59
+ );
60
+ });
61
+
62
+ it("fails the route under an exact guard pattern, which only ever covered itself", () => {
63
+ // Hono reads `use("/feedback")` as that one path. The sub-route under it is what shipped public.
64
+ const feedback = new Hono();
65
+ feedback.get("/", ok);
66
+ feedback.get("/mine", ok);
67
+ const app = new Hono();
68
+ app.use("/feedback", requireAdmin);
69
+ app.route("/feedback", feedback);
70
+
71
+ expect(check(app)).toThrow(/1 endpoint\(s\)[^]*\n {2}GET \/feedback\/mine\n/);
72
+ });
73
+
74
+ it("fails a guard that runs in front of no endpoint, which is what a router that mounted nothing leaves", () => {
75
+ // A stand-in dependency's router registers nothing, so its routes drop out of the check, and
76
+ // the check passes by having nothing to ask. The guard mounted over it is the trace left.
77
+ const app = new Hono();
78
+ app.use("/admin/*", requireAdmin);
79
+ app.route("/admin", new Hono());
80
+ app.get("/health", ok);
81
+
82
+ expect(check(app, (path) => path === "/health")).toThrow(
83
+ /1 guard\(s\) run in front of no endpoint:\n {2}ALL \/admin\/\*\n/,
84
+ );
85
+ });
86
+
87
+ it("counts a guard in front of a route the app calls public, which the guard itself lets by", () => {
88
+ // A guard that waves one public read through is still a guard with a route behind it.
89
+ const app = new Hono();
90
+ app.use("/sources/*", requireAdmin);
91
+ app.get("/sources/catalog", ok);
92
+
93
+ expect(check(app, (path) => path === "/sources/catalog")).not.toThrow();
94
+ });
95
+
96
+ it("fails an app with no endpoints at all, rather than passing it", () => {
97
+ expect(check(new Hono())).toThrow(/no endpoints/);
98
+ });
99
+
100
+ it("fails a route with nothing in front of it but middleware that is not a guard", () => {
101
+ const app = new Hono();
102
+ app.use(logRequest);
103
+ app.use("/admin/*", requireAdmin);
104
+ app.get("/admin/users", ok);
105
+ app.delete("/users/:id", ok);
106
+
107
+ expect(check(app)).toThrow(/\n {2}DELETE \/users\/:id\n/);
108
+ expect(check(app)).not.toThrow(/\/admin\/users/);
109
+ });
110
+
111
+ it("names every unguarded endpoint once, sorted", () => {
112
+ const app = new Hono();
113
+ app.post("/b", ok);
114
+ app.get("/a", ok);
115
+ app.get("/a", ok);
116
+
117
+ expect(check(app)).toThrow(/2 endpoint\(s\)[^]*\n {2}GET \/a\n {2}POST \/b\n/);
118
+ });
119
+
120
+ it("skips what the app's own rule calls public, asked with the path a request would carry", () => {
121
+ const app = new Hono();
122
+ app.post("/webhooks/pay", ok);
123
+ app.get("/docs/:page", ok);
124
+ app.get("/reports/:id{[0-9]+}", ok);
125
+ const asked: string[] = [];
126
+ const isPublic = (path: string) => {
127
+ asked.push(path);
128
+ return path.startsWith("/webhooks/") || path.startsWith("/docs/");
129
+ };
130
+
131
+ expect(check(app, isPublic)).toThrow(/1 endpoint\(s\)[^]*\n {2}GET \/reports\/:id/);
132
+ expect(asked).toEqual(["/webhooks/pay", "/docs/probe", "/reports/1"]);
133
+ });
134
+
135
+ it("counts a guard passed inline, ahead of the handler", () => {
136
+ const app = new Hono();
137
+ app.get("/me", requireAdmin, ok);
138
+
139
+ expect(check(app)).not.toThrow();
140
+ });
141
+
142
+ it("checks an all() endpoint under every method, since every method reaches it", () => {
143
+ // A protocol endpoint taking any method is written `app.all("/mcp", handle)`. A guard mounted
144
+ // for POST alone leaves every other method reaching the handler bare.
145
+ const guardedForAll = new Hono();
146
+ guardedForAll.use("/mcp", requireAdmin);
147
+ guardedForAll.all("/mcp", ok);
148
+
149
+ const guardedForPost = new Hono();
150
+ guardedForPost.post("/mcp", requireAdmin);
151
+ guardedForPost.all("/mcp", ok);
152
+
153
+ expect(check(guardedForAll)).not.toThrow();
154
+ expect(check(guardedForPost)).toThrow(/\n {2}ALL \/mcp\n/);
155
+ });
156
+
157
+ it("sees through a sub-app, including one with its own onError", () => {
158
+ // A sub-app with its own onError has every handler wrapped in a two-argument function, so an
159
+ // endpoint in it reads as middleware unless the wrapper is looked through.
160
+ const admin = new Hono();
161
+ admin.onError((_err, c) => c.text("admin failed", 500));
162
+ admin.get("/users", ok);
163
+
164
+ const bare = new Hono();
165
+ bare.route("/admin", admin);
166
+ expect(check(bare)).toThrow(/\n {2}GET \/admin\/users\n/);
167
+
168
+ const guardedInside = new Hono();
169
+ guardedInside.use(requireAdmin);
170
+ guardedInside.onError((_err, c) => c.text("admin failed", 500));
171
+ guardedInside.get("/users", ok);
172
+ const app = new Hono();
173
+ app.route("/admin", guardedInside);
174
+ expect(check(app)).not.toThrow();
175
+ });
176
+
177
+ it("fails a guard mounted in the parent after the sub-app it was meant for", () => {
178
+ const admin = new Hono();
179
+ admin.get("/users", ok);
180
+ const app = new Hono();
181
+ app.route("/admin", admin);
182
+ app.use("/admin/*", requireAdmin);
183
+
184
+ expect(check(app)).toThrow(/\n {2}GET \/admin\/users\n/);
185
+ });
186
+
187
+ it("probes a numeric parameter pattern with a number", () => {
188
+ const app = new Hono();
189
+ app.use("/forecasts/*", requireAdmin);
190
+ app.get("/forecasts/:id{[0-9]+}/watch", ok);
191
+
192
+ expect(check(app)).not.toThrow();
193
+ });
194
+
195
+ it("reports a path it cannot build a request for, rather than passing it", () => {
196
+ const app = new Hono();
197
+ app.use("/tags/*", requireAdmin);
198
+ app.get("/tags/:name{[a-z]+}", ok);
199
+
200
+ expect(check(app)).toThrow(
201
+ /GET \/tags\/:name\{\[a-z\]\+\} \(could not be probed at \/tags\/1\)/,
202
+ );
203
+ });
204
+
205
+ it("probes a wildcard endpoint", () => {
206
+ const app = new Hono();
207
+ app.get("/static/*", ok);
208
+ app.get("*", ok);
209
+
210
+ expect(check(app, underAny(["/static"]))).toThrow(/\n {2}GET \/\*\n/);
211
+ expect(check(app, underAny(["/static"]))).not.toThrow(/static/);
212
+ });
213
+ });
214
+
215
+ describe("underAny", () => {
216
+ it("reads every entry as a prefix, whichever pattern form it is written in", () => {
217
+ const under = underAny(["/feedback", "/admin/*"]);
218
+
219
+ for (const path of ["/feedback", "/feedback/mine", "/admin", "/admin/users/1"])
220
+ expect(under(path), path).toBe(true);
221
+ for (const path of ["/feedbacks", "/adminx", "/", "/other/feedback"])
222
+ expect(under(path), path).toBe(false);
223
+ });
224
+ });
225
+
226
+ describe("guard", () => {
227
+ it("returns the same function, so it can wrap one where it is defined", () => {
228
+ const middleware: MiddlewareHandler = async (_c, next) => {
229
+ await next();
230
+ };
231
+
232
+ expect(guard(middleware)).toBe(middleware);
233
+ });
234
+ });
@@ -0,0 +1,107 @@
1
+ import type { Hono, MiddlewareHandler } from "hono";
2
+ import { METHODS } from "hono/router";
3
+ import type { RouterRoute } from "hono/types";
4
+ import { findTargetHandler, isMiddleware } from "hono/utils/handler";
5
+
6
+ const GUARD = Symbol.for("@gusnips/server/hono:guard");
7
+
8
+ /**
9
+ * Marks a middleware as one that decides who may pass, for `assertEveryRouteGuarded`. Returns the
10
+ * same function. Mark it where it is defined, so every place it is mounted counts.
11
+ *
12
+ * export const requireAdmin = guard(async (c, next) => { … });
13
+ */
14
+ export function guard<H extends MiddlewareHandler>(middleware: H): H {
15
+ Object.defineProperty(middleware, GUARD, { value: true });
16
+ return middleware;
17
+ }
18
+
19
+ export interface GuardCheckOptions {
20
+ /**
21
+ * The app's own rule for what anyone may call, asked with the path a request would carry:
22
+ * `/people/:id` is asked as `/people/probe`, `/reports/:id{[0-9]+}` as `/reports/1`. Pass the
23
+ * predicate or the list the app itself uses, never a second one kept for the test: an exemption
24
+ * list nothing else reads is the next thing to drift. `underAny(list)` reads a list as prefixes.
25
+ */
26
+ isPublic: (path: string) => boolean;
27
+ }
28
+
29
+ /**
30
+ * "Under one of these", reading each entry as a prefix whichever pattern form it holds:
31
+ * `/feedback` and `/feedback/*` both cover `/feedback` and `/feedback/mine`, and neither covers
32
+ * `/feedbacks`. Hono reads `use("/feedback")` as that one path, which is how the route under it
33
+ * once shipped public while the list said it was guarded.
34
+ */
35
+ export function underAny(prefixes: readonly string[]): (path: string) => boolean {
36
+ const bases = prefixes.map((prefix) => prefix.replace(/\/\*$/, ""));
37
+ return (path) => bases.some((base) => path === base || path.startsWith(`${base}/`));
38
+ }
39
+
40
+ const isGuard = (handler: RouterRoute["handler"]) => GUARD in findTargetHandler(handler);
41
+
42
+ /**
43
+ * Throws, naming every endpoint no guard runs in front of, and every guard that runs in front of
44
+ * nothing. Build the real app, and call it in a test once every route is registered: the first
45
+ * match freezes Hono's router.
46
+ *
47
+ * It asks Hono's own router what would run for each of the app's real routes, and in what order,
48
+ * rather than comparing patterns. A pattern list cannot see order, and order is the whole bug: a
49
+ * `use()` registered after its route matches that route and never runs for it, because the
50
+ * handler answers first. Only the matcher tells a guard that never fires from one that does.
51
+ *
52
+ * A guard in front of nothing is the trace a router leaves when it mounted no routes, which is
53
+ * what a stand-in dependency's router does in a test: its routes drop out of the check, and the
54
+ * check would pass by having nothing to ask. A guard inside that router drops out with it, so a
55
+ * stand-in that yields a router should throw when Hono reads its `routes`.
56
+ *
57
+ * An endpoint is what Hono's own route inspector calls one, a handler taking fewer than two
58
+ * arguments. So a `(c, next)` handler that answers is not checked, and a path whose parameter
59
+ * pattern rejects the probe is reported as unprobeable rather than passed.
60
+ */
61
+ export function assertEveryRouteGuarded(
62
+ app: Pick<Hono, "routes" | "router">,
63
+ { isPublic }: GuardCheckOptions,
64
+ ): void {
65
+ const endpoints = app.routes.filter((route) => !isMiddleware(findTargetHandler(route.handler)));
66
+ if (endpoints.length === 0)
67
+ throw new Error(
68
+ "The app has no endpoints to check, so the check would pass by asking nothing.",
69
+ );
70
+ const idle = new Set(app.routes.filter((route) => isGuard(route.handler)));
71
+ const unguarded = new Set<string>();
72
+ for (const route of endpoints) {
73
+ const { method, path } = route;
74
+ const probe = path.replace(/:\w+\{[^}]*\}\??/g, "1").replace(/:\w+\??|\*/g, "probe");
75
+ const open = isPublic(probe);
76
+ for (const each of method === "ALL" ? METHODS.map((m) => m.toUpperCase()) : [method]) {
77
+ const [matched] = app.router.match(each, probe);
78
+ const at = matched.findIndex(([[, candidate]]) => candidate === route);
79
+ const ahead = matched.slice(0, Math.max(at, 0)).filter(([[handler]]) => isGuard(handler));
80
+ for (const [[, guardRoute]] of ahead) idle.delete(guardRoute);
81
+ if (open) continue;
82
+ if (at === -1) unguarded.add(`${method} ${path} (could not be probed at ${probe})`);
83
+ else if (ahead.length === 0) unguarded.add(`${method} ${path}`);
84
+ }
85
+ }
86
+ if (unguarded.size === 0 && idle.size === 0) return;
87
+ const list = (lines: Iterable<string>) =>
88
+ [...new Set(lines)]
89
+ .sort()
90
+ .map((line) => ` ${line}\n`)
91
+ .join("");
92
+ const idleLines = [...idle].map((route) => `${route.method} ${route.path}`);
93
+ throw new Error(
94
+ (unguarded.size > 0
95
+ ? `${String(unguarded.size)} endpoint(s) answer with no guard running in front of them:\n` +
96
+ list(unguarded)
97
+ : "") +
98
+ (idle.size > 0
99
+ ? `${String(new Set(idleLines).size)} guard(s) run in front of no endpoint:\n` +
100
+ list(idleLines)
101
+ : "") +
102
+ "A guard counts when it is wrapped in guard() and registered BEFORE its routes: a use() " +
103
+ "added after them never runs for them. A guard in front of nothing sits after its routes, " +
104
+ "or over a router that mounted none. Move it up, build that router for real, or have " +
105
+ "isPublic say so if anyone may call the path.",
106
+ );
107
+ }
@@ -0,0 +1,17 @@
1
+ /**
2
+ * `@gusnips/server/hono`: the package mounted on a Hono app.
3
+ *
4
+ * app.use(requestLogger({ logger }));
5
+ * app.use(errorBoundary);
6
+ * app.onError(errorHandler({ errorResponse, logger }));
7
+ * app.notFound(notFoundHandler(errorResponse(errors.notFound("Route"))));
8
+ *
9
+ * and, in a test of the real app, `assertEveryRouteGuarded(app, { isPublic })`, with the rule the
10
+ * app itself uses for what anyone may call.
11
+ */
12
+ export { errorBoundary, errorHandler, notFoundHandler } from "./errors.ts";
13
+ export type { ErrorHandlerOptions } from "./errors.ts";
14
+ export { assertEveryRouteGuarded, guard, underAny } from "./guards.ts";
15
+ export type { GuardCheckOptions } from "./guards.ts";
16
+ export { requestLogger } from "./request-logger.ts";
17
+ export type { RequestLoggerOptions, RequestVariables } from "./request-logger.ts";