@rdlabo/workers-hono-kit 0.4.2 → 0.5.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.
@@ -0,0 +1,29 @@
1
+ import type { Context, Env } from 'hono';
2
+ import type { ErrorReporter, NestErrorHandlerOptions } from './nest-error.js';
3
+ /** Nest QueryFailedExceptionFilter が返す `{ statusCode, message }` 形(error フィールド無し)。 */
4
+ export interface ClassifiedDbError {
5
+ statusCode: 400 | 500;
6
+ message: string;
7
+ }
8
+ /** mysql2 / Drizzle 由来の DB エラーを HTTP 応答用に分類する。非 DB エラーは null。 */
9
+ export type QueryFailedClassifier = (err: unknown) => ClassifiedDbError | null;
10
+ /**
11
+ * 分類済み DB エラーをログし、500 のみ {@link ErrorReporter} へ通報する。
12
+ * 400 はビジネスエラー扱い(warn ログのみ、Sentry 不要)。
13
+ */
14
+ export declare function reportClassifiedDbError(err: unknown, classified: ClassifiedDbError, reportError?: ErrorReporter, requestId?: string): void;
15
+ export interface QueryFailedNestErrorHandlerOptions<E extends Env = Env> extends NestErrorHandlerOptions<E> {
16
+ /** アプリ固有の分類(parity-critical な日本語メッセージ等は consumer 側で定義)。 */
17
+ classify: QueryFailedClassifier;
18
+ }
19
+ /**
20
+ * QueryFailedExceptionFilter → Nest 既定 exception filter の合成 onError。
21
+ *
22
+ * @remarks
23
+ * classify が non-null のときは parity 用 body を返しつつログ(+ 500 は onUnhandledError)を残す。
24
+ * 非 DB エラーは {@link createNestErrorHandler} に委譲する。
25
+ *
26
+ * `Sentry.withSentry` だけでは onError 握りエラーは capture されないため、
27
+ * `onUnhandledError: (err, c) => container.reportError?.(err, { requestId: c.get('requestId') })` を必ず配線する。
28
+ */
29
+ export declare function createQueryFailedNestErrorHandler<E extends Env = Env>(options: QueryFailedNestErrorHandlerOptions<E>): (err: Error, c: Context<E>) => Response;
@@ -0,0 +1,43 @@
1
+ import { logMysqlDriverError } from './mysql-driver-error.js';
2
+ import { createNestErrorHandler } from './nest-error.js';
3
+ /**
4
+ * 分類済み DB エラーをログし、500 のみ {@link ErrorReporter} へ通報する。
5
+ * 400 はビジネスエラー扱い(warn ログのみ、Sentry 不要)。
6
+ */
7
+ export function reportClassifiedDbError(err, classified, reportError, requestId) {
8
+ logMysqlDriverError(err, classified.statusCode);
9
+ if (classified.statusCode === 500) {
10
+ reportError?.(err, { requestId });
11
+ }
12
+ }
13
+ /**
14
+ * QueryFailedExceptionFilter → Nest 既定 exception filter の合成 onError。
15
+ *
16
+ * @remarks
17
+ * classify が non-null のときは parity 用 body を返しつつログ(+ 500 は onUnhandledError)を残す。
18
+ * 非 DB エラーは {@link createNestErrorHandler} に委譲する。
19
+ *
20
+ * `Sentry.withSentry` だけでは onError 握りエラーは capture されないため、
21
+ * `onUnhandledError: (err, c) => container.reportError?.(err, { requestId: c.get('requestId') })` を必ず配線する。
22
+ */
23
+ export function createQueryFailedNestErrorHandler(options) {
24
+ const { classify, ...nestOptions } = options;
25
+ const nestErrorHandler = createNestErrorHandler(nestOptions);
26
+ const { onUnhandledError } = nestOptions;
27
+ return (err, c) => {
28
+ const classified = classify(err);
29
+ if (classified) {
30
+ logMysqlDriverError(err, classified.statusCode);
31
+ if (classified.statusCode === 500) {
32
+ try {
33
+ onUnhandledError?.(err, c);
34
+ }
35
+ catch {
36
+ // Reporting must never change the error response.
37
+ }
38
+ }
39
+ return c.json({ statusCode: classified.statusCode, message: classified.message }, classified.statusCode);
40
+ }
41
+ return nestErrorHandler(err, c);
42
+ };
43
+ }
package/dist/index.d.ts CHANGED
@@ -15,6 +15,8 @@ export type { ValidateOptions, ValidationTarget, ZodErrorLike, SentryLike, Sentr
15
15
  export { zNum, zNumNullable, zNumOptional, zNumWithDefault } from './middleware/zod-coerce.js';
16
16
  export { createAuthMiddleware } from './middleware/auth.js';
17
17
  export type { AuthMiddlewareOptions } from './middleware/auth.js';
18
+ export { perfLog } from './middleware/perf-log.js';
19
+ export type { PerfLogOptions, AnalyticsEngineDatasetLike } from './middleware/perf-log.js';
18
20
  export { getUserProtocol } from './http/user-protocol.js';
19
21
  export type { IUserProtocol } from './http/user-protocol.js';
20
22
  export { getAppInfo } from './http/app-info.js';
@@ -24,6 +26,10 @@ export type { AppEnv } from './http/app-env.js';
24
26
  export { HttpStatus } from './http/http-status.js';
25
27
  export { createNestErrorHandler, nestNotFoundHandler, NEST_REASON_PHRASES } from './http/nest-error.js';
26
28
  export type { NestErrorHandlerOptions, ErrorReportContext, ErrorReporter } from './http/nest-error.js';
29
+ export { findMysqlDriverError, logMysqlDriverError } from './http/mysql-driver-error.js';
30
+ export type { MysqlDriverErrorLike } from './http/mysql-driver-error.js';
31
+ export { createQueryFailedNestErrorHandler, reportClassifiedDbError, } from './http/query-failed-error.js';
32
+ export type { ClassifiedDbError, QueryFailedClassifier, QueryFailedNestErrorHandlerOptions, } from './http/query-failed-error.js';
27
33
  export { normalizeTrailingSlash } from './http/trailing-slash.js';
28
34
  export type { ExecutionContextLike } from './http/execution-context.js';
29
35
  export { KVCache } from './cache/kv-cache.js';
package/dist/index.js CHANGED
@@ -14,12 +14,15 @@ export { finalizeResponse } from './middleware/finalize-response.js';
14
14
  export { validate, createSentryValidate } from './middleware/validation.js';
15
15
  export { zNum, zNumNullable, zNumOptional, zNumWithDefault } from './middleware/zod-coerce.js';
16
16
  export { createAuthMiddleware } from './middleware/auth.js';
17
+ export { perfLog } from './middleware/perf-log.js';
17
18
  // http
18
19
  export { getUserProtocol } from './http/user-protocol.js';
19
20
  export { getAppInfo } from './http/app-info.js';
20
21
  export { resolveAppEnv, isProductionEnv } from './http/app-env.js';
21
22
  export { HttpStatus } from './http/http-status.js';
22
23
  export { createNestErrorHandler, nestNotFoundHandler, NEST_REASON_PHRASES } from './http/nest-error.js';
24
+ export { findMysqlDriverError, logMysqlDriverError } from './http/mysql-driver-error.js';
25
+ export { createQueryFailedNestErrorHandler, reportClassifiedDbError, } from './http/query-failed-error.js';
23
26
  export { normalizeTrailingSlash } from './http/trailing-slash.js';
24
27
  // cache
25
28
  export { KVCache } from './cache/kv-cache.js';
@@ -0,0 +1,77 @@
1
+ import type { MiddlewareHandler } from 'hono';
2
+ /**
3
+ * Minimal shape of a Workers Analytics Engine dataset binding.
4
+ *
5
+ * Declared locally so consumers are not forced to depend on `@cloudflare/workers-types`. Only the
6
+ * single write operation the emitter needs is modeled. A real `AnalyticsEngineDataset` binding is
7
+ * assignable. Writes are non-blocking and add no latency to the request.
8
+ *
9
+ * @see https://developers.cloudflare.com/analytics/analytics-engine/
10
+ */
11
+ export interface AnalyticsEngineDatasetLike {
12
+ writeDataPoint(event: {
13
+ doubles?: number[];
14
+ blobs?: (string | null)[];
15
+ indexes?: string[];
16
+ }): void;
17
+ }
18
+ /** Options for {@link perfLog}. Both sinks are optional; enable either or both. */
19
+ export interface PerfLogOptions {
20
+ /**
21
+ * When `true`, emit one `console.log(JSON.stringify({ perf }))` per request. With
22
+ * `[observability] enabled = true` these lines are captured by **Workers Logs** (retained up to
23
+ * 7 days, queryable via the dashboard Query Builder or the Observability REST API) — no live
24
+ * `wrangler tail` needed, which is what makes this practical for low-traffic Workers.
25
+ *
26
+ * Explicit wins over the `PERF_LOG` env fallback in both directions: `true` forces on, `false` forces
27
+ * off (even when `PERF_LOG === '1'`), `undefined` (default) defers to `PERF_LOG`.
28
+ */
29
+ console?: boolean;
30
+ /**
31
+ * When provided, write one data point per request to a **Workers Analytics Engine** dataset. Query
32
+ * percentiles by route/colo with the SQL API (≈90-day retention). Non-blocking. Layout:
33
+ * `doubles = [t_app_ms, cold(0|1), status]`, `blobs = [path, colo, method]`, `indexes = [path]`.
34
+ */
35
+ dataset?: AnalyticsEngineDatasetLike;
36
+ /**
37
+ * In-code sampling in `[0, 1]` (default `1` = every request; values are clamped to the range).
38
+ * Thins **Analytics Engine writes only** — Workers Logs volume is controlled separately by the
39
+ * observability `head_sampling_rate`. Low-traffic Workers should leave it at `1`.
40
+ */
41
+ sampleRate?: number;
42
+ }
43
+ /**
44
+ * Create a Hono middleware that records a per-request latency data point and emits it to Workers
45
+ * Logs (`console`) and/or Workers Analytics Engine (`dataset`).
46
+ *
47
+ * Register it first (`app.use('*', perfLog(...))`) so `t_app` covers the whole in-app path. Route
48
+ * grouping uses the matched route pattern (e.g. `/user/:id`) rather than the raw path so ids do not
49
+ * explode cardinality; unmatched requests collapse to `(unmatched)`. Colo comes from `request.cf.colo`;
50
+ * cold/warm from an isolate-scoped flag. See {@link PerfLogOptions} for the two sinks and the note
51
+ * above for exactly what `t_app` includes (it depends on where secrets/DB-connect are wired).
52
+ *
53
+ * Two wiring styles, both A/B-capable (Workers Logs and/or Analytics Engine):
54
+ *
55
+ * @example Bare, when the app is served with `app.fetch(req, env, ctx)` — reads `PERF` (Analytics
56
+ * Engine binding) and `PERF_LOG === '1'` (Workers Logs) straight off `c.env`:
57
+ * ```ts
58
+ * app.use('*', perfLog());
59
+ * ```
60
+ *
61
+ * @example Explicit, when the app is built without Hono `env` (e.g. `createApp(container).fetch(req)`)
62
+ * — thread the bindings in:
63
+ * ```ts
64
+ * app.use('*', perfLog({ console: env.PERF_LOG === '1', dataset: env.PERF }));
65
+ * ```
66
+ *
67
+ * @example Query Analytics Engine (SQL API), handler p50/p90 by route and colo:
68
+ * ```sql
69
+ * SELECT blob1 AS path, blob2 AS colo,
70
+ * quantileWeighted(0.5)(double1, _sample_interval) AS p50,
71
+ * quantileWeighted(0.9)(double1, _sample_interval) AS p90,
72
+ * sum(_sample_interval) AS n
73
+ * FROM your_dataset WHERE timestamp > now() - INTERVAL '7' DAY
74
+ * GROUP BY path, colo ORDER BY n DESC
75
+ * ```
76
+ */
77
+ export declare function perfLog(options?: PerfLogOptions): MiddlewareHandler;
@@ -0,0 +1,97 @@
1
+ // `t_app` = wall time inside the Hono app (this middleware wrapping `next()`), i.e. everything from
2
+ // the middlewares/auth guards through the route handler. On a DB-round-trip-bound Worker it is
3
+ // dominated by database round trips, so it is the signal that exposes edge↔origin distance (Smart
4
+ // Placement) and serial-await fan-out.
5
+ //
6
+ // SCOPE DEPENDS ON WIRING. Whatever runs *inside* the app is included, whatever runs in `fetch`
7
+ // *before* the app is not. If the app builds its container/secrets/DB connection as an in-app
8
+ // middleware (e.g. secrets fetch + Hyperdrive connect inside `createApp`), those costs land in the
9
+ // cold `t_app`; if the container is built in `fetch` before `createApp(...).fetch(req)`, they do not.
10
+ // So the cold-row `t_app` is NOT directly comparable across repos wired differently — document the
11
+ // wiring per repo, or add a scope label, before comparing cold numbers.
12
+ //
13
+ // Note: on production Workers `Date.now()` only advances at I/O boundaries (Spectre mitigation), so
14
+ // `t_app` ≈ I/O wait, not CPU time — which is what you want for round-trip-bound analysis.
15
+ //
16
+ // Module scope: survives for the isolate's lifetime, so the first request after a cold start reports
17
+ // `cold: true` and later requests `cold: false`. Caveat: requests that arrive concurrently right after
18
+ // a cold start are labelled warm (only the very first flips the flag) even though they pay cold-init
19
+ // waits — a minor warm-side contamination, negligible at the low request rates this targets.
20
+ let isolateWarm = false;
21
+ /**
22
+ * Create a Hono middleware that records a per-request latency data point and emits it to Workers
23
+ * Logs (`console`) and/or Workers Analytics Engine (`dataset`).
24
+ *
25
+ * Register it first (`app.use('*', perfLog(...))`) so `t_app` covers the whole in-app path. Route
26
+ * grouping uses the matched route pattern (e.g. `/user/:id`) rather than the raw path so ids do not
27
+ * explode cardinality; unmatched requests collapse to `(unmatched)`. Colo comes from `request.cf.colo`;
28
+ * cold/warm from an isolate-scoped flag. See {@link PerfLogOptions} for the two sinks and the note
29
+ * above for exactly what `t_app` includes (it depends on where secrets/DB-connect are wired).
30
+ *
31
+ * Two wiring styles, both A/B-capable (Workers Logs and/or Analytics Engine):
32
+ *
33
+ * @example Bare, when the app is served with `app.fetch(req, env, ctx)` — reads `PERF` (Analytics
34
+ * Engine binding) and `PERF_LOG === '1'` (Workers Logs) straight off `c.env`:
35
+ * ```ts
36
+ * app.use('*', perfLog());
37
+ * ```
38
+ *
39
+ * @example Explicit, when the app is built without Hono `env` (e.g. `createApp(container).fetch(req)`)
40
+ * — thread the bindings in:
41
+ * ```ts
42
+ * app.use('*', perfLog({ console: env.PERF_LOG === '1', dataset: env.PERF }));
43
+ * ```
44
+ *
45
+ * @example Query Analytics Engine (SQL API), handler p50/p90 by route and colo:
46
+ * ```sql
47
+ * SELECT blob1 AS path, blob2 AS colo,
48
+ * quantileWeighted(0.5)(double1, _sample_interval) AS p50,
49
+ * quantileWeighted(0.9)(double1, _sample_interval) AS p90,
50
+ * sum(_sample_interval) AS n
51
+ * FROM your_dataset WHERE timestamp > now() - INTERVAL '7' DAY
52
+ * GROUP BY path, colo ORDER BY n DESC
53
+ * ```
54
+ */
55
+ export function perfLog(options = {}) {
56
+ const { console: toConsole, dataset, sampleRate = 1 } = options;
57
+ const rate = Math.min(1, Math.max(0, sampleRate)); // clamp so out-of-range values can't invert sampling
58
+ return async (c, next) => {
59
+ const cold = !isolateWarm;
60
+ isolateWarm = true;
61
+ const start = Date.now();
62
+ await next();
63
+ const tApp = Date.now() - start;
64
+ // Resolve sinks. Both are independently overridable: an explicit option wins, otherwise fall back
65
+ // to bindings on `c.env` (populated when the app is served with `app.fetch(req, env, ctx)`), so a
66
+ // bare `perfLog()` still works. `PERF` = Analytics Engine dataset binding; `PERF_LOG === '1'` turns
67
+ // on Workers Logs. `console: false` explicitly disables Workers Logs even when `PERF_LOG` is set.
68
+ const envBindings = c.env;
69
+ const sink = dataset ?? envBindings?.PERF;
70
+ const emitConsole = toConsole !== undefined ? toConsole : envBindings?.PERF_LOG === '1';
71
+ if (!sink && !emitConsole) {
72
+ return;
73
+ }
74
+ // Matched route pattern keeps cardinality low (`/user/:id`, not `/user/4821`). Use the deprecated
75
+ // `c.req.routePath` (not `routePath(c)` from `hono/route`) so the middleware works across the whole
76
+ // `hono` peer range `^4.6.0` — `hono/route` only exists on newer hono and would break the floor.
77
+ // Unmatched requests (404 / bot scans) collapse to a single label so they cannot explode cardinality.
78
+ // eslint-disable-next-line @typescript-eslint/no-deprecated -- routePath(c) needs hono ≥4.8; peer floor is 4.6
79
+ const matched = c.req.routePath;
80
+ const path = matched && matched !== '/*' ? matched : '(unmatched)';
81
+ const colo = c.req.raw.cf?.colo ?? '-';
82
+ const method = c.req.method;
83
+ const status = c.res.status;
84
+ // In-code sampling thins Analytics Engine writes only; Workers Logs volume is controlled separately
85
+ // by the observability `head_sampling_rate`. Low-traffic Workers should leave `sampleRate` at 1.
86
+ if (sink && (rate >= 1 || Math.random() < rate)) {
87
+ sink.writeDataPoint({
88
+ doubles: [tApp, cold ? 1 : 0, status],
89
+ blobs: [path, colo, method],
90
+ indexes: [path],
91
+ });
92
+ }
93
+ if (emitConsole) {
94
+ console.log(JSON.stringify({ perf: { cold, colo, method, path, status, t_app: tApp } }));
95
+ }
96
+ };
97
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@rdlabo/workers-hono-kit",
3
- "version": "0.4.2",
3
+ "version": "0.5.0",
4
4
  "private": false,
5
5
  "publishConfig": {
6
6
  "access": "public"
@@ -2,14 +2,15 @@
2
2
  // Record the Drizzle baseline (0000) as *already applied* on an existing (brownfield) MySQL DB,
3
3
  // without executing its CREATE TABLE statements. One-time per environment.
4
4
  //
5
- // なぜ: 現行サービスの DB は先にスキーマが在るため、introspect 由来の baseline 0000 を `db:migrate`
6
- // で流すと衝突する。代わりに `__drizzle_migrations` marker 1 行入れ、以後 `db:migrate` when の
7
- // 大きい 0001+ だけを適用するようにする(新規/テスト DB marker 無しでフルチェーン=挙動不変)。
5
+ // Why: an in-production DB already has its schema, so running the introspect-derived baseline 0000 via
6
+ // `db:migrate` collides. Instead we insert a single marker row into `__drizzle_migrations` so that
7
+ // subsequent `db:migrate` runs apply only the later 0001+ (larger `when`). A fresh / test DB has no
8
+ // marker, so the full chain runs (behavior unchanged).
8
9
  //
9
- // 実行基盤: VPC 内(AWS CodeBuild 等)から RDS へ直 TCP。Hyperdrive Workers 専用で使えない。
10
- // creds envCodeBuild では Secrets Manager → env に注入):
10
+ // Where it runs: direct TCP from inside a VPC (e.g. AWS CodeBuild) to RDS. Hyperdrive is Workers-only
11
+ // and cannot be used here. Credentials come from env (on CodeBuild, Secrets Manager → injected into env):
11
12
  // DB_HOST / DB_PORT / DB_USER / DB_PASSWORD / DB_NAME
12
- // migrations フォルダ: 既定 ./drizzle(--migrations <dir> または MIGRATIONS_DIR で上書き)。
13
+ // Migrations folder: defaults to ./drizzle (override with --migrations <dir> or MIGRATIONS_DIR).
13
14
  //
14
15
  // usage:
15
16
  // npx workers-hono-kit-db-baseline [--migrations ./drizzle]
@@ -23,9 +24,9 @@ function arg(name) {
23
24
  }
24
25
 
25
26
  const migrationsFolder = arg('migrations') ?? process.env.MIGRATIONS_DIR ?? './drizzle';
26
- // db:migratehonoDrizzleConfig)と同じ DB_SECRET 解釈を共有する。CI/本番は AWS Secrets Manager の
27
- // RDS マネージド secret DB_SECRET に渡す運用(不正/欠損は resolveDbSecret throw)。未設定時は
28
- // 従来の個別 DB_* env にフォールバック。
27
+ // Shares the same DB_SECRET handling as db:migrate (honoDrizzleConfig). CI/production passes an AWS
28
+ // Secrets Manager RDS managed secret via DB_SECRET (invalid/missing resolveDbSecret throws). When
29
+ // unset, it falls back to the individual DB_* env vars.
29
30
  const secret = resolveDbSecret();
30
31
  const conn = secret
31
32
  ? {