@gusnips/server 0.1.0 → 0.1.1
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/README.md +11 -1
- package/dist/hono/request-logger.d.ts +17 -5
- package/dist/hono/request-logger.d.ts.map +1 -1
- package/dist/hono/request-logger.js +14 -2
- package/dist/hono/request-logger.js.map +1 -1
- package/dist/index.d.ts +2 -2
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +1 -1
- package/dist/index.js.map +1 -1
- package/dist/logger/serialize.d.ts +7 -2
- package/dist/logger/serialize.d.ts.map +1 -1
- package/dist/logger/serialize.js +13 -2
- package/dist/logger/serialize.js.map +1 -1
- package/dist/responses.d.ts +44 -0
- package/dist/responses.d.ts.map +1 -1
- package/dist/responses.js +28 -8
- package/dist/responses.js.map +1 -1
- package/package.json +1 -1
- package/src/hono/request-logger.test.ts +75 -3
- package/src/hono/request-logger.ts +33 -5
- package/src/index.ts +14 -2
- package/src/logger/serialize.test.ts +22 -0
- package/src/logger/serialize.ts +12 -2
- package/src/responses.test.ts +38 -0
- package/src/responses.ts +50 -12
package/README.md
CHANGED
|
@@ -181,7 +181,9 @@ contract, and a "too big" without it costs somebody a bisect to rediscover a num
|
|
|
181
181
|
already state.
|
|
182
182
|
|
|
183
183
|
Validation is recognized by shape, not by an import, so your validator does not become this
|
|
184
|
-
package's dependency. Measured against zod 3.25, 4.4 and 4.5.
|
|
184
|
+
package's dependency. Measured against zod 3.25, 4.4 and 4.5. A tool or queue consumer can use
|
|
185
|
+
`validationIssues(error)` from the root package to apply the same allow-list without building an
|
|
186
|
+
HTTP answer.
|
|
185
187
|
|
|
186
188
|
**A 5xx keeps its message unless its code is masked.** By default only `INTERNAL_ERROR` is,
|
|
187
189
|
because that is the code you raise when something unexpected broke, so its message may carry
|
|
@@ -264,6 +266,14 @@ hypothetical. The boundary wraps one in an `Error` and keeps the original as `ca
|
|
|
264
266
|
document number in a log and in whatever reads that log afterwards. `/health` is skipped with
|
|
265
267
|
everything under it, because every deploy polls it in a loop; a throw is logged anyway.
|
|
266
268
|
|
|
269
|
+
Add safe product metadata with `requestLogger<AppEnv>({ logger, fields: (c) => ({ … }) })`.
|
|
270
|
+
`fields` runs after the response exists, so it can read values a handler set and `c.res`; return only
|
|
271
|
+
bounded, sanitized values, never a raw path, query, header set, body or authentication object. Your
|
|
272
|
+
fields are written first, so they cannot replace the canonical request id, method, route, status,
|
|
273
|
+
duration or error code. A hook that throws is caught: the line is written with
|
|
274
|
+
`requestFieldsFailed: true` instead of your fields. A value whose own `toJSON` throws is not — that
|
|
275
|
+
one loses the whole line, request id included — so return plain data, not live objects.
|
|
276
|
+
|
|
267
277
|
The request id goes back on `X-Request-ID`, on every answer including `onError`'s and
|
|
268
278
|
`notFound`'s. A caller's own id is echoed only if it is 64 characters of `A-Z a-z 0-9 . _ -`,
|
|
269
279
|
so the id in a line is always either the caller's or ours. Cross-origin, list that header in your
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import type { MiddlewareHandler } from "hono";
|
|
1
|
+
import type { Context, MiddlewareHandler } from "hono";
|
|
2
2
|
import type { Logger } from "../logger/index.ts";
|
|
3
3
|
/** The two variables this adapter writes. Put them in your app's `Variables`. */
|
|
4
4
|
export interface RequestVariables<Code extends string = string> {
|
|
@@ -6,8 +6,21 @@ export interface RequestVariables<Code extends string = string> {
|
|
|
6
6
|
/** The code of the refusal this request got, for the request line. `null` until one happens. */
|
|
7
7
|
errorCode: Code | null;
|
|
8
8
|
}
|
|
9
|
-
|
|
9
|
+
type RequestLoggerEnv = {
|
|
10
|
+
Variables: RequestVariables;
|
|
11
|
+
};
|
|
12
|
+
export interface RequestLoggerOptions<E extends RequestLoggerEnv = RequestLoggerEnv> {
|
|
10
13
|
logger: Logger;
|
|
14
|
+
/**
|
|
15
|
+
* Sanitized product fields to add to the request line. Runs after the response exists, so it can
|
|
16
|
+
* read downstream variables and `c.res`. Keep caller-controlled values bounded; never return a
|
|
17
|
+
* raw path, query, header set, body or authentication object.
|
|
18
|
+
*
|
|
19
|
+
* Return plain data, not live objects: a throw from this hook is caught, but a value whose own
|
|
20
|
+
* `toJSON` throws is caught by the logger instead, which costs the whole line rather than the
|
|
21
|
+
* field.
|
|
22
|
+
*/
|
|
23
|
+
fields?: (c: Context<E>) => Record<string, unknown>;
|
|
11
24
|
/**
|
|
12
25
|
* Paths answered but never logged, each with everything under it: `/health` covers
|
|
13
26
|
* `/health/db` and not `/healthz`. Defaults to `["/health"]`. A throw is logged anyway.
|
|
@@ -25,7 +38,6 @@ export interface RequestLoggerOptions {
|
|
|
25
38
|
* answer including `onError`'s and `notFound`'s. Cross-origin, list that header in your CORS
|
|
26
39
|
* `exposeHeaders` or the browser hides it from the page.
|
|
27
40
|
*/
|
|
28
|
-
export declare function requestLogger({ logger, skipPaths, }: RequestLoggerOptions): MiddlewareHandler<
|
|
29
|
-
|
|
30
|
-
}>;
|
|
41
|
+
export declare function requestLogger<E extends RequestLoggerEnv = RequestLoggerEnv>({ logger, fields, skipPaths, }: RequestLoggerOptions<E>): MiddlewareHandler<E>;
|
|
42
|
+
export {};
|
|
31
43
|
//# sourceMappingURL=request-logger.d.ts.map
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"request-logger.d.ts","sourceRoot":"","sources":["../../src/hono/request-logger.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,iBAAiB,EAAE,MAAM,MAAM,CAAC;
|
|
1
|
+
{"version":3,"file":"request-logger.d.ts","sourceRoot":"","sources":["../../src/hono/request-logger.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,OAAO,EAAE,iBAAiB,EAAE,MAAM,MAAM,CAAC;AAEvD,OAAO,KAAK,EAAE,MAAM,EAAE,MAAM,oBAAoB,CAAC;AAEjD,iFAAiF;AACjF,MAAM,WAAW,gBAAgB,CAAC,IAAI,SAAS,MAAM,GAAG,MAAM;IAC5D,SAAS,EAAE,MAAM,CAAC;IAClB,gGAAgG;IAChG,SAAS,EAAE,IAAI,GAAG,IAAI,CAAC;CACxB;AAED,KAAK,gBAAgB,GAAG;IAAE,SAAS,EAAE,gBAAgB,CAAA;CAAE,CAAC;AAExD,MAAM,WAAW,oBAAoB,CAAC,CAAC,SAAS,gBAAgB,GAAG,gBAAgB;IACjF,MAAM,EAAE,MAAM,CAAC;IACf;;;;;;;;OAQG;IACH,MAAM,CAAC,EAAE,CAAC,CAAC,EAAE,OAAO,CAAC,CAAC,CAAC,KAAK,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAC;IACpD;;;OAGG;IACH,SAAS,CAAC,EAAE,SAAS,MAAM,EAAE,CAAC;CAC/B;AAuBD;;;;;;;;;;GAUG;AACH,wBAAgB,aAAa,CAAC,CAAC,SAAS,gBAAgB,GAAG,gBAAgB,EAAE,EAC3E,MAAM,EACN,MAAM,EACN,SAAuB,GACxB,EAAE,oBAAoB,CAAC,CAAC,CAAC,GAAG,iBAAiB,CAAC,CAAC,CAAC,CAoChD"}
|
|
@@ -5,6 +5,18 @@ import { routePath } from "hono/route";
|
|
|
5
5
|
* trimmed, so the id in the log is always either the caller's or ours.
|
|
6
6
|
*/
|
|
7
7
|
const REQUEST_ID = /^[A-Za-z0-9._-]{1,64}$/;
|
|
8
|
+
function collectFields(fields, c) {
|
|
9
|
+
if (fields === undefined)
|
|
10
|
+
return {};
|
|
11
|
+
try {
|
|
12
|
+
// Materialize here too: a throwing getter is just as capable of losing the request line as a
|
|
13
|
+
// throwing callback. Logging metadata must never change the response it describes.
|
|
14
|
+
return { ...fields(c) };
|
|
15
|
+
}
|
|
16
|
+
catch {
|
|
17
|
+
return { requestFieldsFailed: true };
|
|
18
|
+
}
|
|
19
|
+
}
|
|
8
20
|
/**
|
|
9
21
|
* One line per request, and the request id.
|
|
10
22
|
*
|
|
@@ -16,7 +28,7 @@ const REQUEST_ID = /^[A-Za-z0-9._-]{1,64}$/;
|
|
|
16
28
|
* answer including `onError`'s and `notFound`'s. Cross-origin, list that header in your CORS
|
|
17
29
|
* `exposeHeaders` or the browser hides it from the page.
|
|
18
30
|
*/
|
|
19
|
-
export function requestLogger({ logger, skipPaths = ["/health"], }) {
|
|
31
|
+
export function requestLogger({ logger, fields, skipPaths = ["/health"], }) {
|
|
20
32
|
return async (c, next) => {
|
|
21
33
|
const supplied = c.req.header("X-Request-ID");
|
|
22
34
|
const requestId = supplied && REQUEST_ID.test(supplied) ? supplied : crypto.randomUUID();
|
|
@@ -51,7 +63,7 @@ export function requestLogger({ logger, skipPaths = ["/health"], }) {
|
|
|
51
63
|
c.header("X-Request-ID", requestId);
|
|
52
64
|
const skipped = skipPaths.some((skip) => path === skip || path.startsWith(`${skip}/`));
|
|
53
65
|
if (method !== "OPTIONS" && !skipped)
|
|
54
|
-
logger.info("request", line(c.res.status));
|
|
66
|
+
logger.info("request", { ...collectFields(fields, c), ...line(c.res.status) });
|
|
55
67
|
};
|
|
56
68
|
}
|
|
57
69
|
//# sourceMappingURL=request-logger.js.map
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"request-logger.js","sourceRoot":"","sources":["../../src/hono/request-logger.ts"],"names":[],"mappings":"AACA,OAAO,EAAE,SAAS,EAAE,MAAM,YAAY,CAAC;
|
|
1
|
+
{"version":3,"file":"request-logger.js","sourceRoot":"","sources":["../../src/hono/request-logger.ts"],"names":[],"mappings":"AACA,OAAO,EAAE,SAAS,EAAE,MAAM,YAAY,CAAC;AA+BvC;;;;GAIG;AACH,MAAM,UAAU,GAAG,wBAAwB,CAAC;AAE5C,SAAS,aAAa,CACpB,MAAyC,EACzC,CAAa;IAEb,IAAI,MAAM,KAAK,SAAS;QAAE,OAAO,EAAE,CAAC;IACpC,IAAI,CAAC;QACH,6FAA6F;QAC7F,mFAAmF;QACnF,OAAO,EAAE,GAAG,MAAM,CAAC,CAAC,CAAC,EAAE,CAAC;IAC1B,CAAC;IAAC,MAAM,CAAC;QACP,OAAO,EAAE,mBAAmB,EAAE,IAAI,EAAE,CAAC;IACvC,CAAC;AACH,CAAC;AAED;;;;;;;;;;GAUG;AACH,MAAM,UAAU,aAAa,CAAgD,EAC3E,MAAM,EACN,MAAM,EACN,SAAS,GAAG,CAAC,SAAS,CAAC,GACC;IACxB,OAAO,KAAK,EAAE,CAAC,EAAE,IAAI,EAAE,EAAE;QACvB,MAAM,QAAQ,GAAG,CAAC,CAAC,GAAG,CAAC,MAAM,CAAC,cAAc,CAAC,CAAC;QAC9C,MAAM,SAAS,GAAG,QAAQ,IAAI,UAAU,CAAC,IAAI,CAAC,QAAQ,CAAC,CAAC,CAAC,CAAC,QAAQ,CAAC,CAAC,CAAC,MAAM,CAAC,UAAU,EAAE,CAAC;QACzF,CAAC,CAAC,GAAG,CAAC,WAAW,EAAE,SAAS,CAAC,CAAC;QAC9B,CAAC,CAAC,GAAG,CAAC,WAAW,EAAE,IAAI,CAAC,CAAC;QACzB,MAAM,EAAE,MAAM,EAAE,IAAI,EAAE,GAAG,CAAC,CAAC,GAAG,CAAC;QAC/B,MAAM,KAAK,GAAG,IAAI,CAAC,GAAG,EAAE,CAAC;QACzB,+FAA+F;QAC/F,0FAA0F;QAC1F,4FAA4F;QAC5F,uDAAuD;QACvD,MAAM,IAAI,GAAG,CAAC,MAAc,EAAE,EAAE,CAAC,CAAC;YAChC,SAAS;YACT,MAAM;YACN,KAAK,EAAE,SAAS,CAAC,CAAC,EAAE,CAAC,CAAC,CAAC;YACvB,MAAM;YACN,EAAE,EAAE,IAAI,CAAC,GAAG,EAAE,GAAG,KAAK;YACtB,SAAS,EAAE,CAAC,CAAC,GAAG,CAAC,WAAW,CAAC,IAAI,SAAS;SAC3C,CAAC,CAAC;QACH,IAAI,CAAC;YACH,MAAM,IAAI,EAAE,CAAC;QACf,CAAC;QAAC,OAAO,GAAG,EAAE,CAAC;YACb,4FAA4F;YAC5F,0FAA0F;YAC1F,oFAAoF;YACpF,MAAM,CAAC,KAAK,CAAC,SAAS,EAAE,EAAE,GAAG,IAAI,CAAC,GAAG,CAAC,EAAE,KAAK,EAAE,GAAG,EAAE,CAAC,CAAC;YACtD,MAAM,GAAG,CAAC;QACZ,CAAC;QACD,8FAA8F;QAC9F,sCAAsC;QACtC,CAAC,CAAC,MAAM,CAAC,cAAc,EAAE,SAAS,CAAC,CAAC;QACpC,MAAM,OAAO,GAAG,SAAS,CAAC,IAAI,CAAC,CAAC,IAAI,EAAE,EAAE,CAAC,IAAI,KAAK,IAAI,IAAI,IAAI,CAAC,UAAU,CAAC,GAAG,IAAI,GAAG,CAAC,CAAC,CAAC;QACvF,IAAI,MAAM,KAAK,SAAS,IAAI,CAAC,OAAO;YAClC,MAAM,CAAC,IAAI,CAAC,SAAS,EAAE,EAAE,GAAG,aAAa,CAAC,MAAM,EAAE,CAAC,CAAC,EAAE,GAAG,IAAI,CAAC,CAAC,CAAC,GAAG,CAAC,MAAM,CAAC,EAAE,CAAC,CAAC;IACnF,CAAC,CAAC;AACJ,CAAC"}
|
package/dist/index.d.ts
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
export { AppError, createAppError, toMessage } from "./errors.ts";
|
|
2
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";
|
|
3
|
+
export { created, createErrorResponse, noContent, ok, paginated, validationIssues, } from "./responses.ts";
|
|
4
|
+
export type { CannedError, ErrorAnswer, ErrorResponseOptions, ValidationIssue, } from "./responses.ts";
|
|
5
5
|
export { createLogger, errorReplacer, keptErrorFields } from "./logger/index.ts";
|
|
6
6
|
export type { Logger, LoggerOptions, LogLevel, LogThreshold } from "./logger/index.ts";
|
|
7
7
|
//# sourceMappingURL=index.d.ts.map
|
package/dist/index.d.ts.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,QAAQ,EAAE,cAAc,EAAE,SAAS,EAAE,MAAM,aAAa,CAAC;AAClE,YAAY,EAAE,eAAe,EAAE,MAAM,aAAa,CAAC;AACnD,OAAO,
|
|
1
|
+
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,QAAQ,EAAE,cAAc,EAAE,SAAS,EAAE,MAAM,aAAa,CAAC;AAClE,YAAY,EAAE,eAAe,EAAE,MAAM,aAAa,CAAC;AACnD,OAAO,EACL,OAAO,EACP,mBAAmB,EACnB,SAAS,EACT,EAAE,EACF,SAAS,EACT,gBAAgB,GACjB,MAAM,gBAAgB,CAAC;AACxB,YAAY,EACV,WAAW,EACX,WAAW,EACX,oBAAoB,EACpB,eAAe,GAChB,MAAM,gBAAgB,CAAC;AACxB,OAAO,EAAE,YAAY,EAAE,aAAa,EAAE,eAAe,EAAE,MAAM,mBAAmB,CAAC;AACjF,YAAY,EAAE,MAAM,EAAE,aAAa,EAAE,QAAQ,EAAE,YAAY,EAAE,MAAM,mBAAmB,CAAC"}
|
package/dist/index.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
1
|
export { AppError, createAppError, toMessage } from "./errors.js";
|
|
2
|
-
export { created, createErrorResponse, noContent, ok, paginated } from "./responses.js";
|
|
2
|
+
export { created, createErrorResponse, noContent, ok, paginated, validationIssues, } from "./responses.js";
|
|
3
3
|
export { createLogger, errorReplacer, keptErrorFields } from "./logger/index.js";
|
|
4
4
|
//# sourceMappingURL=index.js.map
|
package/dist/index.js.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"index.js","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,QAAQ,EAAE,cAAc,EAAE,SAAS,EAAE,MAAM,aAAa,CAAC;AAElE,OAAO,
|
|
1
|
+
{"version":3,"file":"index.js","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,QAAQ,EAAE,cAAc,EAAE,SAAS,EAAE,MAAM,aAAa,CAAC;AAElE,OAAO,EACL,OAAO,EACP,mBAAmB,EACnB,SAAS,EACT,EAAE,EACF,SAAS,EACT,gBAAgB,GACjB,MAAM,gBAAgB,CAAC;AAOxB,OAAO,EAAE,YAAY,EAAE,aAAa,EAAE,eAAe,EAAE,MAAM,mBAAmB,CAAC"}
|
|
@@ -17,8 +17,10 @@
|
|
|
17
17
|
* a PostgREST client rejects with plain objects. So in a Hono app the cause slot is precisely where
|
|
18
18
|
* a vendor's rejection object ends up, and a leak there reads as if the list had run.
|
|
19
19
|
*
|
|
20
|
-
*
|
|
21
|
-
*
|
|
20
|
+
* A plain object passed directly as `meta.error` is the other door. Hono wraps it, but a worker,
|
|
21
|
+
* a fire-and-forget catch or a database client outside Hono does not. `error` is the raw-error slot
|
|
22
|
+
* the logger documents, so it gets the same treatment as `cause`; an ordinary metadata object under
|
|
23
|
+
* any other key stays untouched. `name` and `message` come along because a rejection object usually
|
|
22
24
|
* carries them and a line with neither says nothing at all.
|
|
23
25
|
*
|
|
24
26
|
* Deliberate state it does NOT keep: context an app attaches on purpose. That belongs in the
|
|
@@ -33,6 +35,9 @@ export declare function narrowErrorLike(value: object): Record<string, unknown>;
|
|
|
33
35
|
* `JSON.stringify(err)` is `{}` — which is how a logger ends up printing nothing about the
|
|
34
36
|
* failure it was called to report. They are added explicitly, and the allow-listed extras ride
|
|
35
37
|
* along beside them.
|
|
38
|
+
* - A plain object in the root `error` slot is narrowed through the same allow-list. Hono's
|
|
39
|
+
* boundary turns one into an Error cause, but workers and swallowed catches log it directly.
|
|
40
|
+
* Other metadata objects stay untouched.
|
|
36
41
|
* - A nested `cause` is followed, and so is an `AggregateError`'s `errors`. Both are
|
|
37
42
|
* non-enumerable, so both are invisible to the loop above; without this line "all attempts
|
|
38
43
|
* failed" is the whole log entry. Each one goes back through this replacer, so the allow-list
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"serialize.d.ts","sourceRoot":"","sources":["../../src/logger/serialize.ts"],"names":[],"mappings":"AAAA;;;;;;;;GAQG;AA2EH
|
|
1
|
+
{"version":3,"file":"serialize.d.ts","sourceRoot":"","sources":["../../src/logger/serialize.ts"],"names":[],"mappings":"AAAA;;;;;;;;GAQG;AA2EH;;;;;;;;;;;;;;;;;;;GAmBG;AACH,wBAAgB,eAAe,CAAC,KAAK,EAAE,MAAM,GAAG,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAEtE;AA+BD;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAsCG;AACH,wBAAgB,aAAa,IAAI,CAAC,IAAI,EAAE,OAAO,EAAE,GAAG,EAAE,MAAM,EAAE,KAAK,EAAE,OAAO,KAAK,OAAO,CAiCvF;AAED,qGAAqG;AACrG,eAAO,MAAM,eAAe,EAAE,WAAW,CAAC,MAAM,CAAqB,CAAC"}
|
package/dist/logger/serialize.js
CHANGED
|
@@ -87,8 +87,10 @@ function keptValue(key, value) {
|
|
|
87
87
|
* a PostgREST client rejects with plain objects. So in a Hono app the cause slot is precisely where
|
|
88
88
|
* a vendor's rejection object ends up, and a leak there reads as if the list had run.
|
|
89
89
|
*
|
|
90
|
-
*
|
|
91
|
-
*
|
|
90
|
+
* A plain object passed directly as `meta.error` is the other door. Hono wraps it, but a worker,
|
|
91
|
+
* a fire-and-forget catch or a database client outside Hono does not. `error` is the raw-error slot
|
|
92
|
+
* the logger documents, so it gets the same treatment as `cause`; an ordinary metadata object under
|
|
93
|
+
* any other key stays untouched. `name` and `message` come along because a rejection object usually
|
|
92
94
|
* carries them and a line with neither says nothing at all.
|
|
93
95
|
*
|
|
94
96
|
* Deliberate state it does NOT keep: context an app attaches on purpose. That belongs in the
|
|
@@ -133,6 +135,9 @@ function narrowCause(cause, seen) {
|
|
|
133
135
|
* `JSON.stringify(err)` is `{}` — which is how a logger ends up printing nothing about the
|
|
134
136
|
* failure it was called to report. They are added explicitly, and the allow-listed extras ride
|
|
135
137
|
* along beside them.
|
|
138
|
+
* - A plain object in the root `error` slot is narrowed through the same allow-list. Hono's
|
|
139
|
+
* boundary turns one into an Error cause, but workers and swallowed catches log it directly.
|
|
140
|
+
* Other metadata objects stay untouched.
|
|
136
141
|
* - A nested `cause` is followed, and so is an `AggregateError`'s `errors`. Both are
|
|
137
142
|
* non-enumerable, so both are invisible to the loop above; without this line "all attempts
|
|
138
143
|
* failed" is the whole log entry. Each one goes back through this replacer, so the allow-list
|
|
@@ -164,12 +169,18 @@ function narrowCause(cause, seen) {
|
|
|
164
169
|
*/
|
|
165
170
|
export function errorReplacer() {
|
|
166
171
|
const seen = new WeakSet();
|
|
172
|
+
let root;
|
|
167
173
|
return function (key, value) {
|
|
168
174
|
const held = typeof this === "object" && this !== null
|
|
169
175
|
? this[key]
|
|
170
176
|
: undefined;
|
|
177
|
+
if (key === "" && typeof held === "object" && held !== null)
|
|
178
|
+
root = held;
|
|
171
179
|
if (held instanceof Error)
|
|
172
180
|
value = held;
|
|
181
|
+
else if (this === root && key === "error" && typeof held === "object" && held !== null) {
|
|
182
|
+
return seen.has(held) ? "[Circular]" : narrow(held, seen);
|
|
183
|
+
}
|
|
173
184
|
if (typeof value === "bigint")
|
|
174
185
|
return value.toString();
|
|
175
186
|
if (value instanceof Error) {
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"serialize.js","sourceRoot":"","sources":["../../src/logger/serialize.ts"],"names":[],"mappings":"AAAA;;;;;;;;GAQG;AAEH;;;;;;;;;;;;;;;;;;;;;;;;GAwBG;AACH,MAAM,iBAAiB,GAAwB,IAAI,GAAG,CAAC;IACrD,sFAAsF;IACtF,yFAAyF;IACzF,4FAA4F;IAC5F,MAAM;IACN,QAAQ;IACR,SAAS;IACT,MAAM;IACN,YAAY;IACZ,UAAU;IACV,wEAAwE;IACxE,YAAY;IACZ,QAAQ;IACR,YAAY;IACZ,QAAQ;IACR,gBAAgB;IAChB,8FAA8F;IAC9F,+FAA+F;IAC/F,iEAAiE;IACjE,YAAY;IACZ,MAAM;IACN,WAAW;IACX,6FAA6F;IAC7F,+EAA+E;IAC/E,MAAM;CACP,CAAC,CAAC;AAEH;;;;;;;;;;GAUG;AACH,MAAM,cAAc,GAAG,wBAAwB,CAAC;AAChD,MAAM,WAAW,GAAG,wEAAwE,CAAC;AAE7F,SAAS,SAAS,CAAC,GAAW,EAAE,KAAc;IAC5C,IAAI,CAAC,GAAG,KAAK,QAAQ,IAAI,GAAG,KAAK,SAAS,CAAC,IAAI,OAAO,KAAK,KAAK,QAAQ,EAAE,CAAC;QACzE,OAAO,KAAK,CAAC,UAAU,CAAC,cAAc,CAAC,CAAC,CAAC,CAAC,WAAW,CAAC,CAAC,CAAC,KAAK,CAAC;IAChE,CAAC;IACD,OAAO,KAAK,CAAC;AACf,CAAC;AAED
|
|
1
|
+
{"version":3,"file":"serialize.js","sourceRoot":"","sources":["../../src/logger/serialize.ts"],"names":[],"mappings":"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"}
|
package/dist/responses.d.ts
CHANGED
|
@@ -88,6 +88,49 @@ export interface ErrorResponseOptions<Code extends string, Key extends string> {
|
|
|
88
88
|
*/
|
|
89
89
|
maskDetails?: boolean;
|
|
90
90
|
}
|
|
91
|
+
/**
|
|
92
|
+
* What an issue MIGHT carry — every field optional, because the gate below proves only that
|
|
93
|
+
* `issues` is an array and nothing at all about an element. Typing the element as certain is
|
|
94
|
+
* what turned this projection into a throw: `path.map` on an issue that arrived without one.
|
|
95
|
+
*/
|
|
96
|
+
interface RawIssue {
|
|
97
|
+
readonly path?: unknown;
|
|
98
|
+
readonly code?: unknown;
|
|
99
|
+
readonly maximum?: unknown;
|
|
100
|
+
readonly minimum?: unknown;
|
|
101
|
+
}
|
|
102
|
+
/** One rejected field: enough to fix the call, and nothing about the schema. */
|
|
103
|
+
export interface ValidationIssue {
|
|
104
|
+
/** The field that failed, as the caller spelled it. */
|
|
105
|
+
path: (string | number)[];
|
|
106
|
+
/** The rule that rejected it, such as `too_big`. */
|
|
107
|
+
code: string;
|
|
108
|
+
/** The numeric bound, when the rule has one. */
|
|
109
|
+
maximum?: number;
|
|
110
|
+
minimum?: number;
|
|
111
|
+
}
|
|
112
|
+
/**
|
|
113
|
+
* The field path, the rule it failed, and — for a range — the BOUND it failed against.
|
|
114
|
+
*
|
|
115
|
+
* Never the rejected value, and never the schema's internals. All six donors carry a version of
|
|
116
|
+
* that comment; what none of them carries is the proof, so here it is: handing the validator's
|
|
117
|
+
* issues straight to the client ships back the caller's own key names (`keys`), the enum's
|
|
118
|
+
* allowed values (`values`), the validator's English sentence and the expected type
|
|
119
|
+
* (`origin`) — four disclosures from one convenience, and two repos in the fleet do it today.
|
|
120
|
+
* An audit note written against an older validator looks for `received`, which the current one
|
|
121
|
+
* no longer emits; the projection is an allow-list precisely so a rename cannot reopen this.
|
|
122
|
+
*
|
|
123
|
+
* The bound is the exception, and it belongs to the caller: it is the published contract, and
|
|
124
|
+
* a `too_big` without it costs somebody a bisect to rediscover a number our own docs state.
|
|
125
|
+
*
|
|
126
|
+
* Total on purpose: this runs inside the function that turns a thrown thing into an answer, and
|
|
127
|
+
* it is advertised to the queue and tool doors, where an issue list has crossed a serialization
|
|
128
|
+
* hop. An allow-list that throws on a malformed issue sends its caller back to shipping the
|
|
129
|
+
* validator's issues raw, which is the disclosure it exists to prevent.
|
|
130
|
+
*/
|
|
131
|
+
export declare function validationIssues(error: {
|
|
132
|
+
readonly issues: readonly RawIssue[];
|
|
133
|
+
}): ValidationIssue[];
|
|
91
134
|
/**
|
|
92
135
|
* Binds the mask policy and the two canned bodies, and returns the function that answers.
|
|
93
136
|
*
|
|
@@ -104,4 +147,5 @@ export interface ErrorResponseOptions<Code extends string, Key extends string> {
|
|
|
104
147
|
* ```
|
|
105
148
|
*/
|
|
106
149
|
export declare function createErrorResponse<Code extends string = string, Key extends string = string>(opts: ErrorResponseOptions<Code, Key>): (err: unknown) => ErrorAnswer<Code>;
|
|
150
|
+
export {};
|
|
107
151
|
//# sourceMappingURL=responses.d.ts.map
|
package/dist/responses.d.ts.map
CHANGED
|
@@ -1 +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;
|
|
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;AAuDD;;;;GAIG;AACH,UAAU,QAAQ;IAChB,QAAQ,CAAC,IAAI,CAAC,EAAE,OAAO,CAAC;IACxB,QAAQ,CAAC,IAAI,CAAC,EAAE,OAAO,CAAC;IACxB,QAAQ,CAAC,OAAO,CAAC,EAAE,OAAO,CAAC;IAC3B,QAAQ,CAAC,OAAO,CAAC,EAAE,OAAO,CAAC;CAC5B;AAED,gFAAgF;AAChF,MAAM,WAAW,eAAe;IAC9B,uDAAuD;IACvD,IAAI,EAAE,CAAC,MAAM,GAAG,MAAM,CAAC,EAAE,CAAC;IAC1B,oDAAoD;IACpD,IAAI,EAAE,MAAM,CAAC;IACb,gDAAgD;IAChD,OAAO,CAAC,EAAE,MAAM,CAAC;IACjB,OAAO,CAAC,EAAE,MAAM,CAAC;CAClB;AA6BD;;;;;;;;;;;;;;;;;;GAkBG;AACH,wBAAgB,gBAAgB,CAAC,KAAK,EAAE;IACtC,QAAQ,CAAC,MAAM,EAAE,SAAS,QAAQ,EAAE,CAAC;CACtC,GAAG,eAAe,EAAE,CAUpB;AAaD;;;;;;;;;;;;;;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"}
|
package/dist/responses.js
CHANGED
|
@@ -87,6 +87,18 @@ function zodIssues(err) {
|
|
|
87
87
|
return null;
|
|
88
88
|
return issues;
|
|
89
89
|
}
|
|
90
|
+
/**
|
|
91
|
+
* One path segment, as something that survives `JSON.stringify`.
|
|
92
|
+
*
|
|
93
|
+
* A symbol keyed a field the caller cannot name back at us, so its description is the only
|
|
94
|
+
* useful thing in it — and an unnamed symbol has none, which is an empty segment rather than
|
|
95
|
+
* the `null` that `JSON.stringify` would otherwise write.
|
|
96
|
+
*/
|
|
97
|
+
function pathSegment(segment) {
|
|
98
|
+
if (typeof segment === "symbol")
|
|
99
|
+
return segment.description ?? "";
|
|
100
|
+
return typeof segment === "number" ? segment : String(segment);
|
|
101
|
+
}
|
|
90
102
|
/**
|
|
91
103
|
* The field path, the rule it failed, and — for a range — the BOUND it failed against.
|
|
92
104
|
*
|
|
@@ -100,14 +112,22 @@ function zodIssues(err) {
|
|
|
100
112
|
*
|
|
101
113
|
* The bound is the exception, and it belongs to the caller: it is the published contract, and
|
|
102
114
|
* a `too_big` without it costs somebody a bisect to rediscover a number our own docs state.
|
|
115
|
+
*
|
|
116
|
+
* Total on purpose: this runs inside the function that turns a thrown thing into an answer, and
|
|
117
|
+
* it is advertised to the queue and tool doors, where an issue list has crossed a serialization
|
|
118
|
+
* hop. An allow-list that throws on a malformed issue sends its caller back to shipping the
|
|
119
|
+
* validator's issues raw, which is the disclosure it exists to prevent.
|
|
103
120
|
*/
|
|
104
|
-
function
|
|
105
|
-
return issues.map((issue) =>
|
|
106
|
-
path
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
121
|
+
export function validationIssues(error) {
|
|
122
|
+
return error.issues.map((issue) => {
|
|
123
|
+
const { path, code, maximum, minimum } = issue ?? {};
|
|
124
|
+
return {
|
|
125
|
+
path: Array.isArray(path) ? path.map(pathSegment) : [],
|
|
126
|
+
code: typeof code === "string" ? code : "",
|
|
127
|
+
...(typeof maximum === "number" && { maximum }),
|
|
128
|
+
...(typeof minimum === "number" && { minimum }),
|
|
129
|
+
};
|
|
130
|
+
});
|
|
111
131
|
}
|
|
112
132
|
/**
|
|
113
133
|
* `AppError` is generic over the product's own code union, and no runtime check can verify
|
|
@@ -140,7 +160,7 @@ export function createErrorResponse(opts) {
|
|
|
140
160
|
if (issues !== null) {
|
|
141
161
|
return {
|
|
142
162
|
status: 400,
|
|
143
|
-
body: envelope(opts.validation,
|
|
163
|
+
body: envelope(opts.validation, validationIssues({ issues })),
|
|
144
164
|
headers: {},
|
|
145
165
|
kind: "client",
|
|
146
166
|
};
|
package/dist/responses.js.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"responses.js","sourceRoot":"","sources":["../src/responses.ts"],"names":[],"mappings":"AAaA,OAAO,EAAE,QAAQ,EAAE,MAAM,aAAa,CAAC;AAEvC,4FAA4F;AAC5F,MAAM,UAAU,EAAE,CAAwB,IAAO,EAAE,IAAQ;IACzD,MAAM,IAAI,GAAqB,IAAI,KAAK,SAAS,CAAC,CAAC,CAAC,EAAE,IAAI,EAAE,CAAC,CAAC,CAAC,EAAE,IAAI,EAAE,IAAI,EAAE,CAAC;IAC9E,OAAO,EAAE,MAAM,EAAE,GAAY,EAAE,IAAI,EAAE,CAAC;AACxC,CAAC;AAED,MAAM,UAAU,OAAO,CAAI,IAAO;IAChC,MAAM,IAAI,GAAkB,EAAE,IAAI,EAAE,CAAC;IACrC,OAAO,EAAE,MAAM,EAAE,GAAY,EAAE,IAAI,EAAE,CAAC;AACxC,CAAC;AAED;;;;GAIG;AACH,MAAM,UAAU,SAAS,CAAI,IAAS,EAAE,IAAqC;IAC3E,MAAM,IAAI,GAAoB;QAC5B,IAAI,EAAE,IAAI;QACV,IAAI,EAAE,EAAE,GAAG,IAAI,EAAE,OAAO,EAAE,IAAI,CAAC,MAAM,GAAG,IAAI,CAAC,MAAM,GAAG,IAAI,CAAC,KAAK,EAAE;KACnE,CAAC;IACF,OAAO,EAAE,MAAM,EAAE,GAAY,EAAE,IAAI,EAAE,CAAC;AACxC,CAAC;AAED,MAAM,UAAU,SAAS;IACvB,OAAO,EAAE,MAAM,EAAE,GAAY,EAAE,IAAI,EAAE,IAAI,EAAE,CAAC;AAC9C,CAAC;AA4DD;;;;;;;;;GASG;AACH,SAAS,gBAAgB,CAAC,GAAa;IACrC,IAAI,GAAG,CAAC,cAAc,KAAK,SAAS;QAAE,OAAO,GAAG,CAAC,OAAO,CAAC;IACzD,MAAM,OAAO,GACX,GAAG,CAAC,OAAO,KAAK,SAAS;QACzB,CAAC,OAAO,GAAG,CAAC,OAAO,KAAK,QAAQ,IAAI,GAAG,CAAC,OAAO,KAAK,IAAI,IAAI,CAAC,KAAK,CAAC,OAAO,CAAC,GAAG,CAAC,OAAO,CAAC,CAAC,CAAC;IAC3F,IAAI,CAAC,OAAO;QAAE,OAAO,GAAG,CAAC,OAAO,CAAC;IACjC,OAAO,EAAE,GAAG,GAAG,CAAC,OAAO,EAAE,cAAc,EAAE,GAAG,CAAC,cAAc,EAAE,CAAC;AAChE,CAAC;AAED;;;;;;GAMG;AACH,SAAS,YAAY,CAAsB,GAAmB;IAC5D,MAAM,OAAO,GAAG,gBAAgB,CAAC,GAAG,CAAC,CAAC;IACtC,OAAO;QACL,KAAK,EAAE;YACL,IAAI,EAAE,GAAG,CAAC,IAAI;YACd,OAAO,EAAE,GAAG,CAAC,OAAO;YACpB,GAAG,CAAC,GAAG,CAAC,UAAU,KAAK,SAAS,IAAI,EAAE,UAAU,EAAE,GAAG,CAAC,UAAU,EAAE,CAAC;YACnE,GAAG,CAAC,GAAG,CAAC,MAAM,KAAK,SAAS,IAAI,EAAE,MAAM,EAAE,GAAG,CAAC,MAAM,EAAE,CAAC;YACvD,GAAG,CAAC,OAAO,KAAK,SAAS,IAAI,EAAE,OAAO,EAAE,CAAC;SAC1C;KACF,CAAC;AACJ,CAAC;AAED,SAAS,QAAQ,CACf,MAA8B,EAC9B,OAAiB;IAEjB,OAAO;QACL,KAAK,EAAE;YACL,IAAI,EAAE,MAAM,CAAC,IAAI;YACjB,OAAO,EAAE,MAAM,CAAC,OAAO;YACvB,GAAG,CAAC,MAAM,CAAC,UAAU,KAAK,SAAS,IAAI,EAAE,UAAU,EAAE,MAAM,CAAC,UAAU,EAAE,CAAC;YACzE,GAAG,CAAC,OAAO,KAAK,SAAS,IAAI,EAAE,OAAO,EAAE,CAAC;SAC1C;KACF,CAAC;AACJ,CAAC;
|
|
1
|
+
{"version":3,"file":"responses.js","sourceRoot":"","sources":["../src/responses.ts"],"names":[],"mappings":"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"}
|
package/package.json
CHANGED
|
@@ -7,20 +7,92 @@ import {
|
|
|
7
7
|
type RequestVariables,
|
|
8
8
|
} from "./request-logger.ts";
|
|
9
9
|
|
|
10
|
-
function setup
|
|
10
|
+
function setup<E extends { Variables: RequestVariables } = { Variables: RequestVariables }>(
|
|
11
|
+
options: Omit<RequestLoggerOptions<E>, "logger"> = {},
|
|
12
|
+
) {
|
|
11
13
|
const lines: Array<Record<string, unknown>> = [];
|
|
12
14
|
const logger = createLogger({
|
|
13
15
|
level: "debug",
|
|
14
16
|
write: (line) => lines.push(JSON.parse(line) as Record<string, unknown>),
|
|
15
17
|
});
|
|
16
|
-
const app = new Hono<
|
|
17
|
-
app.use(requestLogger({ logger, ...options }));
|
|
18
|
+
const app = new Hono<E>();
|
|
19
|
+
app.use(requestLogger<E>({ logger, ...options }));
|
|
18
20
|
return { app, lines };
|
|
19
21
|
}
|
|
20
22
|
|
|
21
23
|
const UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/;
|
|
22
24
|
|
|
25
|
+
type AppEnv = {
|
|
26
|
+
Variables: RequestVariables<"DENIED"> & { actorId: string };
|
|
27
|
+
};
|
|
28
|
+
|
|
23
29
|
describe("the request line", () => {
|
|
30
|
+
it("adds typed adopter fields after the response exists", async () => {
|
|
31
|
+
const { app, lines } = setup<AppEnv>({
|
|
32
|
+
fields: (c) => ({ actorId: c.get("actorId"), responseStatus: c.res.status }),
|
|
33
|
+
});
|
|
34
|
+
app.post("/items", (c) => {
|
|
35
|
+
c.set("actorId", "user_1");
|
|
36
|
+
return c.text("created", 201);
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
await app.request("/items", { method: "POST" });
|
|
40
|
+
|
|
41
|
+
expect(lines[0]).toMatchObject({ actorId: "user_1", responseStatus: 201 });
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
it("keeps every canonical request field when adopter fields use the same names", async () => {
|
|
45
|
+
const { app, lines } = setup<AppEnv>({
|
|
46
|
+
fields: () => ({
|
|
47
|
+
actorId: "user_1",
|
|
48
|
+
requestId: "wrong",
|
|
49
|
+
method: "DELETE",
|
|
50
|
+
route: "/wrong",
|
|
51
|
+
status: 599,
|
|
52
|
+
ms: -1,
|
|
53
|
+
errorCode: "WRONG",
|
|
54
|
+
}),
|
|
55
|
+
});
|
|
56
|
+
app.post("/items/:id", (c) => {
|
|
57
|
+
c.set("errorCode", "DENIED");
|
|
58
|
+
return c.text("no", 403);
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
await app.request("/items/secret", {
|
|
62
|
+
method: "POST",
|
|
63
|
+
headers: { "X-Request-ID": "trace_1" },
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
expect(lines[0]).toMatchObject({
|
|
67
|
+
actorId: "user_1",
|
|
68
|
+
requestId: "trace_1",
|
|
69
|
+
method: "POST",
|
|
70
|
+
route: "/items/:id",
|
|
71
|
+
status: 403,
|
|
72
|
+
errorCode: "DENIED",
|
|
73
|
+
});
|
|
74
|
+
expect(lines[0]!.ms).not.toBe(-1);
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
it("does not let a broken adopter field hook lose the response or its request line", async () => {
|
|
78
|
+
const { app, lines } = setup<AppEnv>({
|
|
79
|
+
fields: () => {
|
|
80
|
+
throw new Error("field hook broke");
|
|
81
|
+
},
|
|
82
|
+
});
|
|
83
|
+
app.get("/items", (c) => c.text("ok"));
|
|
84
|
+
|
|
85
|
+
const response = await app.request("/items");
|
|
86
|
+
|
|
87
|
+
expect(response.status).toBe(200);
|
|
88
|
+
expect(lines[0]).toMatchObject({
|
|
89
|
+
requestFieldsFailed: true,
|
|
90
|
+
method: "GET",
|
|
91
|
+
route: "/items",
|
|
92
|
+
status: 200,
|
|
93
|
+
});
|
|
94
|
+
});
|
|
95
|
+
|
|
24
96
|
it("names the route template, never the path", async () => {
|
|
25
97
|
// A path carries whatever the caller put in it. In one backend that was a customer's national
|
|
26
98
|
// id number, and the request line carried it into the log and on into an analytics event.
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import type { MiddlewareHandler } from "hono";
|
|
1
|
+
import type { Context, MiddlewareHandler } from "hono";
|
|
2
2
|
import { routePath } from "hono/route";
|
|
3
3
|
import type { Logger } from "../logger/index.ts";
|
|
4
4
|
|
|
@@ -9,8 +9,20 @@ export interface RequestVariables<Code extends string = string> {
|
|
|
9
9
|
errorCode: Code | null;
|
|
10
10
|
}
|
|
11
11
|
|
|
12
|
-
|
|
12
|
+
type RequestLoggerEnv = { Variables: RequestVariables };
|
|
13
|
+
|
|
14
|
+
export interface RequestLoggerOptions<E extends RequestLoggerEnv = RequestLoggerEnv> {
|
|
13
15
|
logger: Logger;
|
|
16
|
+
/**
|
|
17
|
+
* Sanitized product fields to add to the request line. Runs after the response exists, so it can
|
|
18
|
+
* read downstream variables and `c.res`. Keep caller-controlled values bounded; never return a
|
|
19
|
+
* raw path, query, header set, body or authentication object.
|
|
20
|
+
*
|
|
21
|
+
* Return plain data, not live objects: a throw from this hook is caught, but a value whose own
|
|
22
|
+
* `toJSON` throws is caught by the logger instead, which costs the whole line rather than the
|
|
23
|
+
* field.
|
|
24
|
+
*/
|
|
25
|
+
fields?: (c: Context<E>) => Record<string, unknown>;
|
|
14
26
|
/**
|
|
15
27
|
* Paths answered but never logged, each with everything under it: `/health` covers
|
|
16
28
|
* `/health/db` and not `/healthz`. Defaults to `["/health"]`. A throw is logged anyway.
|
|
@@ -25,6 +37,20 @@ export interface RequestLoggerOptions {
|
|
|
25
37
|
*/
|
|
26
38
|
const REQUEST_ID = /^[A-Za-z0-9._-]{1,64}$/;
|
|
27
39
|
|
|
40
|
+
function collectFields<E extends RequestLoggerEnv>(
|
|
41
|
+
fields: RequestLoggerOptions<E>["fields"],
|
|
42
|
+
c: Context<E>,
|
|
43
|
+
): Record<string, unknown> {
|
|
44
|
+
if (fields === undefined) return {};
|
|
45
|
+
try {
|
|
46
|
+
// Materialize here too: a throwing getter is just as capable of losing the request line as a
|
|
47
|
+
// throwing callback. Logging metadata must never change the response it describes.
|
|
48
|
+
return { ...fields(c) };
|
|
49
|
+
} catch {
|
|
50
|
+
return { requestFieldsFailed: true };
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
|
|
28
54
|
/**
|
|
29
55
|
* One line per request, and the request id.
|
|
30
56
|
*
|
|
@@ -36,10 +62,11 @@ const REQUEST_ID = /^[A-Za-z0-9._-]{1,64}$/;
|
|
|
36
62
|
* answer including `onError`'s and `notFound`'s. Cross-origin, list that header in your CORS
|
|
37
63
|
* `exposeHeaders` or the browser hides it from the page.
|
|
38
64
|
*/
|
|
39
|
-
export function requestLogger({
|
|
65
|
+
export function requestLogger<E extends RequestLoggerEnv = RequestLoggerEnv>({
|
|
40
66
|
logger,
|
|
67
|
+
fields,
|
|
41
68
|
skipPaths = ["/health"],
|
|
42
|
-
}: RequestLoggerOptions): MiddlewareHandler<
|
|
69
|
+
}: RequestLoggerOptions<E>): MiddlewareHandler<E> {
|
|
43
70
|
return async (c, next) => {
|
|
44
71
|
const supplied = c.req.header("X-Request-ID");
|
|
45
72
|
const requestId = supplied && REQUEST_ID.test(supplied) ? supplied : crypto.randomUUID();
|
|
@@ -72,6 +99,7 @@ export function requestLogger({
|
|
|
72
99
|
// returns a Response it built itself.
|
|
73
100
|
c.header("X-Request-ID", requestId);
|
|
74
101
|
const skipped = skipPaths.some((skip) => path === skip || path.startsWith(`${skip}/`));
|
|
75
|
-
if (method !== "OPTIONS" && !skipped)
|
|
102
|
+
if (method !== "OPTIONS" && !skipped)
|
|
103
|
+
logger.info("request", { ...collectFields(fields, c), ...line(c.res.status) });
|
|
76
104
|
};
|
|
77
105
|
}
|
package/src/index.ts
CHANGED
|
@@ -1,6 +1,18 @@
|
|
|
1
1
|
export { AppError, createAppError, toMessage } from "./errors.ts";
|
|
2
2
|
export type { AppErrorOptions } from "./errors.ts";
|
|
3
|
-
export {
|
|
4
|
-
|
|
3
|
+
export {
|
|
4
|
+
created,
|
|
5
|
+
createErrorResponse,
|
|
6
|
+
noContent,
|
|
7
|
+
ok,
|
|
8
|
+
paginated,
|
|
9
|
+
validationIssues,
|
|
10
|
+
} from "./responses.ts";
|
|
11
|
+
export type {
|
|
12
|
+
CannedError,
|
|
13
|
+
ErrorAnswer,
|
|
14
|
+
ErrorResponseOptions,
|
|
15
|
+
ValidationIssue,
|
|
16
|
+
} from "./responses.ts";
|
|
5
17
|
export { createLogger, errorReplacer, keptErrorFields } from "./logger/index.ts";
|
|
6
18
|
export type { Logger, LoggerOptions, LogLevel, LogThreshold } from "./logger/index.ts";
|
|
@@ -221,6 +221,28 @@ describe("what an Error contributes to a log line", () => {
|
|
|
221
221
|
expect(JSON.stringify(line)).not.toContain("4242");
|
|
222
222
|
});
|
|
223
223
|
|
|
224
|
+
it("allow-lists a thrown plain object passed directly as the error", () => {
|
|
225
|
+
// Hono wraps a non-Error throw as a cause, but workers, fire-and-forget catches and a database
|
|
226
|
+
// client's `{ code, message, details }` rejection reach the logger directly. `error` is the
|
|
227
|
+
// raw-error slot the logger documents; an ordinary metadata object under another key stays
|
|
228
|
+
// ordinary metadata.
|
|
229
|
+
const rejection = {
|
|
230
|
+
message: "insert failed",
|
|
231
|
+
code: "23514",
|
|
232
|
+
detail: "Failing row contains (someone@example.com, 4242424242424242).",
|
|
233
|
+
payload: '{"customer_email":"someone@example.com"}',
|
|
234
|
+
};
|
|
235
|
+
|
|
236
|
+
const line = serialized(rejection);
|
|
237
|
+
|
|
238
|
+
expect(line).toEqual({
|
|
239
|
+
message: "insert failed",
|
|
240
|
+
code: "23514",
|
|
241
|
+
detail: "[row omitted: Postgres DETAIL for this error is the whole failing row]",
|
|
242
|
+
});
|
|
243
|
+
expect(entryFor({ context: rejection }).context).toEqual(rejection);
|
|
244
|
+
});
|
|
245
|
+
|
|
224
246
|
it("survives a chain of plain-object causes that holds itself", () => {
|
|
225
247
|
// The Error branch has had this guard since it was written; the narrowing builds a new object
|
|
226
248
|
// and so needs its own, or a self-referencing rejection recurses until the stack ends — inside
|
package/src/logger/serialize.ts
CHANGED
|
@@ -91,8 +91,10 @@ function keptValue(key: string, value: unknown): unknown {
|
|
|
91
91
|
* a PostgREST client rejects with plain objects. So in a Hono app the cause slot is precisely where
|
|
92
92
|
* a vendor's rejection object ends up, and a leak there reads as if the list had run.
|
|
93
93
|
*
|
|
94
|
-
*
|
|
95
|
-
*
|
|
94
|
+
* A plain object passed directly as `meta.error` is the other door. Hono wraps it, but a worker,
|
|
95
|
+
* a fire-and-forget catch or a database client outside Hono does not. `error` is the raw-error slot
|
|
96
|
+
* the logger documents, so it gets the same treatment as `cause`; an ordinary metadata object under
|
|
97
|
+
* any other key stays untouched. `name` and `message` come along because a rejection object usually
|
|
96
98
|
* carries them and a line with neither says nothing at all.
|
|
97
99
|
*
|
|
98
100
|
* Deliberate state it does NOT keep: context an app attaches on purpose. That belongs in the
|
|
@@ -139,6 +141,9 @@ function narrowCause(cause: unknown, seen: WeakSet<object>): unknown {
|
|
|
139
141
|
* `JSON.stringify(err)` is `{}` — which is how a logger ends up printing nothing about the
|
|
140
142
|
* failure it was called to report. They are added explicitly, and the allow-listed extras ride
|
|
141
143
|
* along beside them.
|
|
144
|
+
* - A plain object in the root `error` slot is narrowed through the same allow-list. Hono's
|
|
145
|
+
* boundary turns one into an Error cause, but workers and swallowed catches log it directly.
|
|
146
|
+
* Other metadata objects stay untouched.
|
|
142
147
|
* - A nested `cause` is followed, and so is an `AggregateError`'s `errors`. Both are
|
|
143
148
|
* non-enumerable, so both are invisible to the loop above; without this line "all attempts
|
|
144
149
|
* failed" is the whole log entry. Each one goes back through this replacer, so the allow-list
|
|
@@ -170,12 +175,17 @@ function narrowCause(cause: unknown, seen: WeakSet<object>): unknown {
|
|
|
170
175
|
*/
|
|
171
176
|
export function errorReplacer(): (this: unknown, key: string, value: unknown) => unknown {
|
|
172
177
|
const seen = new WeakSet<object>();
|
|
178
|
+
let root: object | undefined;
|
|
173
179
|
return function (key, value) {
|
|
174
180
|
const held =
|
|
175
181
|
typeof this === "object" && this !== null
|
|
176
182
|
? (this as Record<string, unknown>)[key]
|
|
177
183
|
: undefined;
|
|
184
|
+
if (key === "" && typeof held === "object" && held !== null) root = held;
|
|
178
185
|
if (held instanceof Error) value = held;
|
|
186
|
+
else if (this === root && key === "error" && typeof held === "object" && held !== null) {
|
|
187
|
+
return seen.has(held) ? "[Circular]" : narrow(held, seen);
|
|
188
|
+
}
|
|
179
189
|
if (typeof value === "bigint") return value.toString();
|
|
180
190
|
if (value instanceof Error) {
|
|
181
191
|
if (seen.has(value)) return "[Circular]";
|
package/src/responses.test.ts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { describe, expect, it } from "vitest";
|
|
2
2
|
import { z } from "zod";
|
|
3
|
+
import { type ValidationIssue, validationIssues } from "./index.ts";
|
|
3
4
|
import { AppError, createAppError } from "./errors.ts";
|
|
4
5
|
import { created, createErrorResponse, noContent, ok, paginated } from "./responses.ts";
|
|
5
6
|
|
|
@@ -110,6 +111,43 @@ describe("a validation failure is a 400 that reflects nothing back", () => {
|
|
|
110
111
|
.object({ days: z.enum(["7", "30"]), timeoutMs: z.number().max(30_000) })
|
|
111
112
|
.strict();
|
|
112
113
|
|
|
114
|
+
it("exports the safe issue projection for every door that validates input", () => {
|
|
115
|
+
const validationError = {
|
|
116
|
+
issues: [
|
|
117
|
+
{
|
|
118
|
+
path: ["items", 0, Symbol("field"), Symbol()],
|
|
119
|
+
code: "too_big",
|
|
120
|
+
maximum: 10,
|
|
121
|
+
minimum: 1,
|
|
122
|
+
received: "secret",
|
|
123
|
+
values: ["private", "schema"],
|
|
124
|
+
message: "Expected a private schema value",
|
|
125
|
+
},
|
|
126
|
+
{ path: ["amount"], code: "too_big", maximum: 10n, minimum: "1" },
|
|
127
|
+
],
|
|
128
|
+
} as const;
|
|
129
|
+
const projected: ValidationIssue[] = validationIssues(validationError);
|
|
130
|
+
|
|
131
|
+
expect(projected).toEqual([
|
|
132
|
+
{ path: ["items", 0, "field", ""], code: "too_big", maximum: 10, minimum: 1 },
|
|
133
|
+
{ path: ["amount"], code: "too_big" },
|
|
134
|
+
]);
|
|
135
|
+
expect(() => JSON.stringify(projected)).not.toThrow();
|
|
136
|
+
expect(JSON.stringify(projected)).not.toContain("secret");
|
|
137
|
+
expect(JSON.stringify(projected)).not.toContain("private");
|
|
138
|
+
});
|
|
139
|
+
|
|
140
|
+
it("answers rather than throws when an issue arrives without a usable path", () => {
|
|
141
|
+
// The gate admits anything named ZodError with an array of issues, so an element that lost a
|
|
142
|
+
// field crossing a queue or a tool boundary reaches the projection. Throwing here throws
|
|
143
|
+
// inside the function whose whole job is to turn a thrown thing into an answer.
|
|
144
|
+
for (const issue of [{ code: "custom" }, null, { path: "amount", code: "too_big" }]) {
|
|
145
|
+
const answer = errorResponse({ name: "ZodError", issues: [issue] });
|
|
146
|
+
expect(answer.status).toBe(400);
|
|
147
|
+
expect(answer.body.error.details).toEqual([{ path: [], code: issue?.code ?? "" }]);
|
|
148
|
+
}
|
|
149
|
+
});
|
|
150
|
+
|
|
113
151
|
it("answers 400 with the path and the rule", () => {
|
|
114
152
|
const parsed = schema.safeParse({ days: "90", timeoutMs: 1 });
|
|
115
153
|
const answer = errorResponse(parsed.error);
|
package/src/responses.ts
CHANGED
|
@@ -152,11 +152,27 @@ function envelope<Code extends string, Key extends string>(
|
|
|
152
152
|
};
|
|
153
153
|
}
|
|
154
154
|
|
|
155
|
+
/**
|
|
156
|
+
* What an issue MIGHT carry — every field optional, because the gate below proves only that
|
|
157
|
+
* `issues` is an array and nothing at all about an element. Typing the element as certain is
|
|
158
|
+
* what turned this projection into a throw: `path.map` on an issue that arrived without one.
|
|
159
|
+
*/
|
|
155
160
|
interface RawIssue {
|
|
156
|
-
path?: unknown;
|
|
157
|
-
code?: unknown;
|
|
158
|
-
maximum?: unknown;
|
|
159
|
-
minimum?: unknown;
|
|
161
|
+
readonly path?: unknown;
|
|
162
|
+
readonly code?: unknown;
|
|
163
|
+
readonly maximum?: unknown;
|
|
164
|
+
readonly minimum?: unknown;
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
/** One rejected field: enough to fix the call, and nothing about the schema. */
|
|
168
|
+
export interface ValidationIssue {
|
|
169
|
+
/** The field that failed, as the caller spelled it. */
|
|
170
|
+
path: (string | number)[];
|
|
171
|
+
/** The rule that rejected it, such as `too_big`. */
|
|
172
|
+
code: string;
|
|
173
|
+
/** The numeric bound, when the rule has one. */
|
|
174
|
+
maximum?: number;
|
|
175
|
+
minimum?: number;
|
|
160
176
|
}
|
|
161
177
|
|
|
162
178
|
/**
|
|
@@ -174,6 +190,18 @@ function zodIssues(err: unknown): RawIssue[] | null {
|
|
|
174
190
|
return issues as RawIssue[];
|
|
175
191
|
}
|
|
176
192
|
|
|
193
|
+
/**
|
|
194
|
+
* One path segment, as something that survives `JSON.stringify`.
|
|
195
|
+
*
|
|
196
|
+
* A symbol keyed a field the caller cannot name back at us, so its description is the only
|
|
197
|
+
* useful thing in it — and an unnamed symbol has none, which is an empty segment rather than
|
|
198
|
+
* the `null` that `JSON.stringify` would otherwise write.
|
|
199
|
+
*/
|
|
200
|
+
function pathSegment(segment: unknown): string | number {
|
|
201
|
+
if (typeof segment === "symbol") return segment.description ?? "";
|
|
202
|
+
return typeof segment === "number" ? segment : String(segment);
|
|
203
|
+
}
|
|
204
|
+
|
|
177
205
|
/**
|
|
178
206
|
* The field path, the rule it failed, and — for a range — the BOUND it failed against.
|
|
179
207
|
*
|
|
@@ -187,14 +215,24 @@ function zodIssues(err: unknown): RawIssue[] | null {
|
|
|
187
215
|
*
|
|
188
216
|
* The bound is the exception, and it belongs to the caller: it is the published contract, and
|
|
189
217
|
* a `too_big` without it costs somebody a bisect to rediscover a number our own docs state.
|
|
218
|
+
*
|
|
219
|
+
* Total on purpose: this runs inside the function that turns a thrown thing into an answer, and
|
|
220
|
+
* it is advertised to the queue and tool doors, where an issue list has crossed a serialization
|
|
221
|
+
* hop. An allow-list that throws on a malformed issue sends its caller back to shipping the
|
|
222
|
+
* validator's issues raw, which is the disclosure it exists to prevent.
|
|
190
223
|
*/
|
|
191
|
-
function
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
224
|
+
export function validationIssues(error: {
|
|
225
|
+
readonly issues: readonly RawIssue[];
|
|
226
|
+
}): ValidationIssue[] {
|
|
227
|
+
return error.issues.map((issue): ValidationIssue => {
|
|
228
|
+
const { path, code, maximum, minimum } = issue ?? {};
|
|
229
|
+
return {
|
|
230
|
+
path: Array.isArray(path) ? path.map(pathSegment) : [],
|
|
231
|
+
code: typeof code === "string" ? code : "",
|
|
232
|
+
...(typeof maximum === "number" && { maximum }),
|
|
233
|
+
...(typeof minimum === "number" && { minimum }),
|
|
234
|
+
};
|
|
235
|
+
});
|
|
198
236
|
}
|
|
199
237
|
|
|
200
238
|
/**
|
|
@@ -233,7 +271,7 @@ export function createErrorResponse<Code extends string = string, Key extends st
|
|
|
233
271
|
if (issues !== null) {
|
|
234
272
|
return {
|
|
235
273
|
status: 400,
|
|
236
|
-
body: envelope(opts.validation,
|
|
274
|
+
body: envelope(opts.validation, validationIssues({ issues })),
|
|
237
275
|
headers: {},
|
|
238
276
|
kind: "client",
|
|
239
277
|
};
|