@rdlabo/workers-hono-kit 0.5.1 → 0.6.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 CHANGED
@@ -52,7 +52,8 @@ npm install ai ai-gateway-provider # createAiGatewayProvider
52
52
  | --- | --- |
53
53
  | `finalizeResponse()` | Middleware that adds an Express-compatible weak `ETag` and JSON `charset=utf-8`. |
54
54
  | `validate(target, schema, options?)` | Zod validator → NestJS `ValidationPipe`-shaped `400` (`{ statusCode, message[], error }`). `options.onValidationError(err, c)` to report (e.g. Sentry). |
55
- | `createSentryValidate(sentry)` | Returns a `validate` variant that reports validation failures to an injected Sentry-like client (tags + context), avoiding a hard `@sentry/cloudflare` dependency. |
55
+ | `createValidate({ sentry? })` | Bound `validate` factory. Pass `sentry` on Sentry apps; omit for console-only (review, cbs-ai). |
56
+ | `createSentryValidate(sentry)` | **Deprecated** — use `createValidate({ sentry })`. |
56
57
  | `zNum` / `zNumWithDefault` / `zNumOptional` / `zNumNullable` | Number-coercion zod schemas (mirror class-transformer `@Transform`). |
57
58
  | `getAuthenticationSecret<T>(options, secretId)` / `AwsSecretsOptions` | Fetch a secret from AWS Secrets Manager (SigV4 `fetch`, per-isolate cache). |
58
59
  | `getCloudFrontSignedUrl(url, privateKeyPem, keyPairId, dateLessThan)` | CloudFront signed URL (canned policy, RSA-SHA1, URL-safe base64) — Web Crypto reimpl of `@aws-sdk/cloudfront-signer`, byte-identical query order. |
@@ -66,19 +67,24 @@ npm install ai ai-gateway-provider # createAiGatewayProvider
66
67
  | `resolveAppEnv(env)` / `isProductionEnv(env)` / `AppEnv` | Resolve `'development'` / `'production'` from `env.APP_ENV` (defaults to `'production'` for safety). |
67
68
  | `HttpStatus` | HTTP status enum identical to NestJS `@nestjs/common`. |
68
69
  | `createNestErrorHandler(options?)` / `NestErrorHandlerOptions` | `app.onError()` handler that maps a thrown `HTTPException` to the NestJS exception-filter body (`{ statusCode, message, error? }`; `401` omits `error`). Configurable field order, reason phrases, error predicate, and unhandled-error report hook. Unhandled errors log via `console.error` (mysql2 errors include `sqlMessage` / `errno` when detectable). |
69
- | `createQueryFailedNestErrorHandler(options)` / `QueryFailedClassifier` / `ClassifiedDbError` | Compose a NestJS `QueryFailedExceptionFilter` analog **before** `createNestErrorHandler`: consumer supplies `classify(err)` (parity-critical messages stay in the app); classified DB errors log (`warn` for 400, `error` for 500) and trigger `onUnhandledError` on 500 only. |
70
- | `findMysqlDriverError(err)` / `logMysqlDriverError(err, statusCode)` / `reportClassifiedDbError(...)` | Low-level mysql2 driver-error detection (follows `err.cause`), structured logging, and classify-path reporting helpers. |
70
+ | `createAppErrorHandler(options?)` / `CreateAppErrorHandlerOptions` | Standard `app.onError`: {@link createQueryFailedNestErrorHandler} + default {@link classifyGenericMysqlDriverError} + optional `sentry` (Sentry apps), `getReportError` / `reportError` (tests / container), or neither (no external reporting). |
71
+ | `createQueryFailedNestErrorHandler(options)` / `QueryFailedClassifier` / `ClassifiedDbError` | Lower-level compose when you need full control over `classify` + `onUnhandledError` without defaults. |
72
+ | `classifyGenericMysqlDriverError(err)` | Default classifier for apps without Nest filter parity: any mysql2 driver error → `{ statusCode: 500, message: 'Internal server error' }`; non-DB errors → `null`. |
73
+ | `findMysqlDriverError(err)` / `logMysqlDriverError(err, statusCode)` | Low-level mysql2 driver-error detection (follows `err.cause`) and structured logging. For custom classifiers (e.g. odss parity). |
71
74
  | `nestNotFoundHandler(c)` | `app.notFound()` handler with the Express/Nest default `{ message: 'Cannot METHOD path', error, statusCode }` 404 body. |
72
75
  | `normalizeTrailingSlash(request)` | Strip trailing slash(es) from the request URL before routing (Express/Nest parity). Does **not** 301-redirect — preserves POST/PUT/DELETE bodies. |
73
76
  | `NEST_REASON_PHRASES` | `{ 400, 401, 403, 404 }` → NestJS reason phrases. |
74
77
  | `createAuthMiddleware(options)` / `AuthMiddlewareOptions` | Factory for a Firebase-token auth middleware: reads the token header, verifies, resolves the DB user id, and stashes the result on the context. Omit `resolveUserId` for a token-only (login) guard. |
75
78
  | `perfLog(options?)` / `PerfLogOptions` / `AnalyticsEngineDatasetLike` | Middleware that records one per-request latency data point (`t_app`, colo, cold/warm, route, status) and emits it to **Workers Logs** (`console.log`) and/or **Workers Analytics Engine** (`writeDataPoint`). Lets you measure low-traffic Workers without a live `wrangler tail`. |
76
79
  | `ErrorReporter` / `ErrorReportContext` | Types for a `reportError`-style unhandled-error reporter (e.g. wired to Sentry), paired with `createNestErrorHandler`'s `onUnhandledError`. |
80
+ | `createSentryErrorReporter(sentry)` / `SentryExceptionReporterLike` | Build an `ErrorReporter` that forwards to Sentry with an optional `request_id` tag (no hard `@sentry/cloudflare` dependency). |
81
+ | `DeferExecutor` / `defaultDefer` / `createWaitUntilDefer(ctx)` | Fire-and-forget executor for Workers: `defaultDefer` swallows rejections (tests); `createWaitUntilDefer` registers work via `ctx.waitUntil`. |
77
82
  | `createAiGatewayProvider(config)` / `AiGatewayConfig` / `AiGatewayProvider` | Route `@ai-sdk` models through the Cloudflare AI Gateway, via either a Workers `AI` binding or REST credentials (`accountId` / `gateway` / `token`). |
78
83
  | `KVCache` / `KVNamespace` / `KVCacheOptions` | Workers-KV cache-aside helper (key `appName+version+table_type_column`, sha256 for string ids, TTL clamped ≥60s). Set `appName` / `version` per application. |
79
84
  | `createStripeClient(secret, opts?)` / `verifyStripeWebhook(...)` / `CreateStripeClientOptions` | Workers-native Stripe client (fetch transport) + async webhook verification (SubtleCrypto). `apiVersion` optional (pin to a fixed Stripe API version). |
80
85
  | `sendInChunks(queue, messages, options?)` / `QueueLike` / `QueueSendMessage` | Send queue messages in bounded chunks to stay under the Workers subrequest cap per invocation. `options.chunkSize` sets the per-batch size (defaults to and is capped at 100). |
81
86
  | `processBatch(batch, handler, options?)` / `MessageBatchLike` / `QueueMessageLike` / `ProcessBatchOptions` / `ProcessBatchResult` | Process a queue batch with bounded concurrency (consumer-side counterpart to `sendInChunks`). |
87
+ | `createQueueErrorHandler(options)` / `CreateQueueErrorHandlerOptions` | Factory for `processBatch`'s `onError`: logs every failure; optional Sentry capture with queue/message context; optional `maxRetries` gate (report only on final attempt). |
82
88
  | `ExecutionContextLike` | Minimal `waitUntil`-only Workers execution context shape (for `withMysqlConnections` in worker entry modules without importing `./db`). |
83
89
 
84
90
  ### Data layer — `@rdlabo/workers-hono-kit/db`
@@ -284,40 +290,71 @@ body, and `nestNotFoundHandler` gives the Express/Nest default 404. The defaults
284
290
  NestJS canonical shape (`{ statusCode, message, error? }`, `401` omits `error`); the options
285
291
  let you reproduce any byte-for-byte variation an existing API expects.
286
292
 
293
+ #### App entry (fleet standard)
294
+
295
+ Use a **singleton** Hono app and inject the request-scoped container in middleware — do **not**
296
+ call `createApp(container).fetch(...)` on every request (rebuilds the route graph each time).
297
+
287
298
  ```ts
288
- import { createNestErrorHandler, nestNotFoundHandler } from '@rdlabo/workers-hono-kit';
299
+ // worker.ts once per isolate
300
+ const app = createApp();
301
+ export default Sentry.withSentry(/* … */, {
302
+ fetch: (req, env, ctx) => app.fetch(req, env, ctx),
303
+ });
289
304
 
290
- app.notFound(nestNotFoundHandler);
291
- app.onError(createNestErrorHandler());
305
+ // app.ts — fleet-standard onError (Sentry optional)
306
+ import * as Sentry from '@sentry/cloudflare';
307
+ import { createAppErrorHandler } from '@rdlabo/workers-hono-kit';
292
308
 
293
- // Application-specific parity deltas:
294
309
  app.onError(
295
- createNestErrorHandler({
296
- fieldOrder: 'message-first', // emit { message, error, statusCode } instead of statusCode-first
297
- onUnhandledError: (err, c) => container.reportError?.(err, { requestId: c.get('requestId') }),
298
- isHttpError: (e): e is HttpError => e instanceof HttpError, // a custom error class with a `.body` escape hatch
310
+ createAppErrorHandler({
311
+ sentry: Sentry, // omit on repos without Sentry (airlec, review, cbs-ai)
312
+ getReportError: (c) => c.get('container')?.reportError, // tests + scheduled paths
299
313
  }),
300
314
  );
315
+
316
+ // odss-mobile: add classify: classifyQueryFailed (repo parity)
317
+ // winecode: sentry + isHttpError / reasonPhrases in errors.ts (no container middleware)
318
+ // foodlabel: sentry + reportError: container.reportError (per-request container closure)
301
319
  ```
302
320
 
303
- **Important:** `Sentry.withSentry` does **not** capture errors handled by `app.onError`. Wire
304
- `onUnhandledError` `Sentry.captureException` explicitly (mirrors Nest `SentryGlobalFilter`).
321
+ Reference: `winecode/hono` (singleton + container middleware). Legacy repos still using
322
+ per-request `createApp(container)` should migrate to this shape where possible.
305
323
 
306
- Repos with a Nest `QueryFailedExceptionFilter` (e.g. odss-mobile) should use
307
- `createQueryFailedNestErrorHandler` so classified DB errors still log and report to Sentry:
324
+ **Isolate-scoped memo + container runtime** (shared across the fleet):
308
325
 
309
326
  ```ts
310
- import { createQueryFailedNestErrorHandler } from '@rdlabo/workers-hono-kit';
327
+ import { createContainerRuntime, createIsolateMemo } from '@rdlabo/workers-hono-kit';
311
328
 
312
- app.onError(
313
- createQueryFailedNestErrorHandler({
314
- fieldOrder: 'message-first',
315
- classify: classifyQueryFailed, // app-local parity (Japanese messages, errno rules)
316
- onUnhandledError: (err, c) => container.reportError?.(err, { requestId: c.get('requestId') }),
317
- }),
318
- );
329
+ // Secrets / env: cache successes per isolate; rejections are NOT cached (retry on next request).
330
+ const resolveSecrets = createIsolateMemo(async (env: Env) => { /* SM or env vars */ });
331
+
332
+ const { middleware: containerMiddleware, withContainer } = createContainerRuntime<Env, Container>({
333
+ hyperdrives: (env) => ({ primary: env.HYPERDRIVE_PRIMARY, replica: env.HYPERDRIVE_REPLICA }),
334
+ createContainer: async ({ env, executionCtx, primary, replica }) => {
335
+ const secret = await resolveSecrets(env);
336
+ return buildContainer({ /* db from primary/replica, secret, … */ });
337
+ },
338
+ });
339
+ ```
340
+
341
+ Use `withContainer` from `scheduled` / `queue` handlers; use `containerMiddleware` in `createApp`.
342
+
343
+ ```ts
344
+ import { createNestErrorHandler, nestNotFoundHandler } from '@rdlabo/workers-hono-kit';
345
+
346
+ app.notFound(nestNotFoundHandler);
347
+
348
+ // Prefer createAppErrorHandler (see "App entry" above). Lower-level only when needed:
349
+ app.onError(createNestErrorHandler());
319
350
  ```
320
351
 
352
+ **Important:** `Sentry.withSentry` does **not** capture errors handled by `app.onError`. Pass `sentry`
353
+ to `createAppErrorHandler` (or wire `getReportError` / `reportError` for tests and scheduled paths).
354
+
355
+ Repos with a Nest `QueryFailedExceptionFilter` parity layer (e.g. odss-mobile) pass `classify` to
356
+ `createAppErrorHandler` — do not call `createQueryFailedNestErrorHandler` directly unless you need full control.
357
+
321
358
  ### Auth middleware
322
359
 
323
360
  Encodes the shared skeleton (read token header → verify → `getAppInfo` → resolve user id →
@@ -364,7 +401,8 @@ import { perfLog } from '@rdlabo/workers-hono-kit';
364
401
  // dataset binding) and `PERF_LOG === '1'` (Workers Logs) off `c.env`.
365
402
  app.use('*', perfLog());
366
403
 
367
- // B) app built without Hono env (`createApp(container).fetch(req)`): pass bindings explicitly.
404
+ // B) bindings not on Hono env (legacy per-request createApp): pass explicitly — prefer fleet
405
+ // standard singleton app + container middleware so env is always on `c.env`.
368
406
  app.use('*', perfLog({ console: env.PERF_LOG === '1', dataset: env.PERF }));
369
407
  ```
370
408
 
@@ -386,7 +424,7 @@ GROUP BY path, colo ORDER BY p90 DESC
386
424
  ```
387
425
 
388
426
  > **Scope of `t_app`**: it covers everything *inside* the app; work done in `fetch` *before* the app
389
- > (e.g. secrets fetch / DB connect in `createApp(container)` vs. building the container in `fetch`) is
427
+ > (e.g. secrets fetch / DB connect in container middleware vs. building the container in `worker.fetch`) is
390
428
  > not comparable across differently-wired apps. Instrument the `fetch` seam if you need a secrets/connect
391
429
  > cold breakdown. On production Workers `Date.now()` only advances at I/O boundaries, so `t_app` ≈ I/O
392
430
  > wait, not CPU time.
@@ -0,0 +1,35 @@
1
+ /**
2
+ * Isolate-scoped async memoization for container bootstrap (secrets, env resolution, etc.).
3
+ *
4
+ * Successful results are reused across requests in the same isolate. Rejected initializations are
5
+ * **not** cached so a transient failure (e.g. Secrets Manager blip) can be retried on the next call.
6
+ *
7
+ * @remarks
8
+ * The memo is not keyed by argument: the loader runs once per isolate using the arguments from the
9
+ * first successful scheduling attempt (same semantics as hand-rolled `let promise` in Workers apps).
10
+ *
11
+ * @packageDocumentation
12
+ */
13
+ /** Callable memo with an explicit reset for tests and isolate teardown. */
14
+ export interface IsolateMemo<T, TArg> {
15
+ (arg: TArg): Promise<T>;
16
+ reset(): void;
17
+ }
18
+ /**
19
+ * Create an isolate-scoped memoized async resolver.
20
+ *
21
+ * @param loader - async factory invoked on the first call (and again after a rejection / {@link reset}).
22
+ * @returns a function that returns the cached promise, plus {@link IsolateMemo.reset}.
23
+ * @example
24
+ * ```ts
25
+ * const resolveSecrets = createIsolateMemo(async (env: Env) => {
26
+ * const secret = await getAuthenticationSecret(awsOpts(env));
27
+ * return { firebaseSaJson: secret.firebaseProduction };
28
+ * });
29
+ *
30
+ * // first request populates the cache; SM failure is not cached:
31
+ * await resolveSecrets(env).catch(() => undefined);
32
+ * await resolveSecrets(env); // retries SM
33
+ * ```
34
+ */
35
+ export declare function createIsolateMemo<T, TArg>(loader: (arg: TArg) => Promise<T>): IsolateMemo<T, TArg>;
@@ -0,0 +1,45 @@
1
+ /**
2
+ * Isolate-scoped async memoization for container bootstrap (secrets, env resolution, etc.).
3
+ *
4
+ * Successful results are reused across requests in the same isolate. Rejected initializations are
5
+ * **not** cached so a transient failure (e.g. Secrets Manager blip) can be retried on the next call.
6
+ *
7
+ * @remarks
8
+ * The memo is not keyed by argument: the loader runs once per isolate using the arguments from the
9
+ * first successful scheduling attempt (same semantics as hand-rolled `let promise` in Workers apps).
10
+ *
11
+ * @packageDocumentation
12
+ */
13
+ /**
14
+ * Create an isolate-scoped memoized async resolver.
15
+ *
16
+ * @param loader - async factory invoked on the first call (and again after a rejection / {@link reset}).
17
+ * @returns a function that returns the cached promise, plus {@link IsolateMemo.reset}.
18
+ * @example
19
+ * ```ts
20
+ * const resolveSecrets = createIsolateMemo(async (env: Env) => {
21
+ * const secret = await getAuthenticationSecret(awsOpts(env));
22
+ * return { firebaseSaJson: secret.firebaseProduction };
23
+ * });
24
+ *
25
+ * // first request populates the cache; SM failure is not cached:
26
+ * await resolveSecrets(env).catch(() => undefined);
27
+ * await resolveSecrets(env); // retries SM
28
+ * ```
29
+ */
30
+ export function createIsolateMemo(loader) {
31
+ let cached;
32
+ const resolve = (arg) => {
33
+ if (!cached) {
34
+ cached = loader(arg).catch((error) => {
35
+ cached = undefined;
36
+ throw error;
37
+ });
38
+ }
39
+ return cached;
40
+ };
41
+ resolve.reset = () => {
42
+ cached = undefined;
43
+ };
44
+ return resolve;
45
+ }
@@ -0,0 +1,45 @@
1
+ import type { Env, MiddlewareHandler } from 'hono';
2
+ import type { Connection } from 'mysql2/promise';
3
+ import type { HyperdriveLike } from '../db/connection.js';
4
+ import type { ExecutionContextLike } from '../http/execution-context.js';
5
+ /** Inputs available while building a per-request application container. */
6
+ export interface ContainerBuildContext<TEnv extends Env['Bindings']> {
7
+ env: TEnv;
8
+ executionCtx: ExecutionContextLike;
9
+ primary: Connection;
10
+ replica: Connection;
11
+ }
12
+ /** Options for {@link createContainerRuntime}. */
13
+ export interface ContainerRuntimeOptions<TEnv extends Env['Bindings'], TContainer> {
14
+ /** Resolve Hyperdrive bindings from Worker env. */
15
+ hyperdrives: (env: TEnv) => {
16
+ primary: HyperdriveLike;
17
+ replica: HyperdriveLike;
18
+ };
19
+ /** Forwarded to {@link withMysqlConnections} (e.g. `{ dateStrings: true }`). */
20
+ connectionOptions?: Record<string, unknown>;
21
+ /** Build the request-scoped container after primary/replica connections are open. */
22
+ createContainer: (ctx: ContainerBuildContext<TEnv>) => TContainer | Promise<TContainer>;
23
+ }
24
+ /** Pair returned by {@link createContainerRuntime}. */
25
+ export interface ContainerRuntime<TEnv extends Env['Bindings'], TContainer> {
26
+ /** Hono middleware: `c.set('container', …)` then `next()`. Honors test overrides. */
27
+ middleware: (overrides?: {
28
+ container?: TContainer;
29
+ }) => MiddlewareHandler<{
30
+ Bindings: TEnv;
31
+ Variables: {
32
+ container: TContainer;
33
+ };
34
+ }>;
35
+ /** Shared entry for `scheduled` / `queue` handlers (same connection lifecycle as middleware). */
36
+ withContainer: <T>(env: TEnv, executionCtx: ExecutionContextLike, fn: (container: TContainer) => Promise<T>) => Promise<T>;
37
+ }
38
+ /**
39
+ * Standard singleton-app container wiring: per-request Hyperdrive connections + `c.set('container')`.
40
+ *
41
+ * @remarks
42
+ * Isolate-scoped memoization (secrets, env) stays in the app via {@link createIsolateMemo} inside
43
+ * `createContainer` or a helper it calls — this factory only owns the per-request DB lifecycle.
44
+ */
45
+ export declare function createContainerRuntime<TEnv extends Env['Bindings'], TContainer>(options: ContainerRuntimeOptions<TEnv, TContainer>): ContainerRuntime<TEnv, TContainer>;
@@ -0,0 +1,26 @@
1
+ import { withMysqlConnections } from '../db/connection.js';
2
+ /**
3
+ * Standard singleton-app container wiring: per-request Hyperdrive connections + `c.set('container')`.
4
+ *
5
+ * @remarks
6
+ * Isolate-scoped memoization (secrets, env) stays in the app via {@link createIsolateMemo} inside
7
+ * `createContainer` or a helper it calls — this factory only owns the per-request DB lifecycle.
8
+ */
9
+ export function createContainerRuntime(options) {
10
+ const withContainer = async (env, executionCtx, fn) => withMysqlConnections(options.hyperdrives(env), executionCtx, async ({ primary, replica }) => {
11
+ const container = await options.createContainer({ env, executionCtx, primary, replica });
12
+ return fn(container);
13
+ }, options.connectionOptions);
14
+ const middleware = (overrides) => (async (c, next) => {
15
+ if (overrides?.container) {
16
+ c.set('container', overrides.container);
17
+ await next();
18
+ return;
19
+ }
20
+ await withContainer(c.env, c.executionCtx, async (container) => {
21
+ c.set('container', container);
22
+ await next();
23
+ });
24
+ });
25
+ return { middleware, withContainer };
26
+ }
@@ -0,0 +1,28 @@
1
+ import type { Context, Env } from 'hono';
2
+ import type { ErrorReporter, NestErrorHandlerOptions, SentryExceptionReporterLike } from './nest-error.js';
3
+ import type { QueryFailedClassifier } from './query-failed-error.js';
4
+ /**
5
+ * Options for {@link createAppErrorHandler}.
6
+ *
7
+ * @remarks
8
+ * Wires `createQueryFailedNestErrorHandler` with fleet defaults (`fieldOrder: 'message-first'`,
9
+ * {@link classifyGenericMysqlDriverError}) and optional error reporting.
10
+ * Pass `sentry` for Sentry-backed apps; omit it (or pass `undefined`) when not used.
11
+ * `getReportError` / `reportError` override `sentry` (tests, container injection, scheduled paths).
12
+ */
13
+ export interface CreateAppErrorHandlerOptions<E extends Env = Env> extends Omit<NestErrorHandlerOptions<E>, 'onUnhandledError'> {
14
+ /** mysql2 driver error classifier. Defaults to {@link classifyGenericMysqlDriverError}. */
15
+ classify?: QueryFailedClassifier;
16
+ /** Optional Sentry client (`@sentry/cloudflare`). Omitted on repos without Sentry. */
17
+ sentry?: SentryExceptionReporterLike;
18
+ /** Static reporter (tests, worker closure). Takes precedence over {@link sentry}. */
19
+ reportError?: ErrorReporter;
20
+ /** Read reporter from Hono context (e.g. `c.get('container')?.reportError`). Takes precedence over {@link sentry}. */
21
+ getReportError?: (c: Context<E>) => ErrorReporter | undefined;
22
+ /** Override auto-wired reporting (rare; prefer sentry / reportError / getReportError). */
23
+ onUnhandledError?: (err: unknown, c: Context<E>) => void;
24
+ }
25
+ /**
26
+ * Standard `app.onError` factory: QueryFailed filter → Nest default filter, with optional error reporting.
27
+ */
28
+ export declare function createAppErrorHandler<E extends Env = Env>(options?: CreateAppErrorHandlerOptions<E>): (err: Error, c: Context<E, any, {}>) => Response;
@@ -0,0 +1,21 @@
1
+ import { createSentryErrorReporter } from './nest-error.js';
2
+ import { classifyGenericMysqlDriverError, createQueryFailedNestErrorHandler } from './query-failed-error.js';
3
+ /**
4
+ * Standard `app.onError` factory: QueryFailed filter → Nest default filter, with optional error reporting.
5
+ */
6
+ export function createAppErrorHandler(options = {}) {
7
+ const { classify = classifyGenericMysqlDriverError, sentry, reportError, getReportError, onUnhandledError, fieldOrder = 'message-first', ...nestOptions } = options;
8
+ const sentryReporter = sentry ? createSentryErrorReporter(sentry) : undefined;
9
+ const resolvedOnUnhandled = onUnhandledError ??
10
+ ((err, c) => {
11
+ const reporter = getReportError?.(c) ?? reportError ?? sentryReporter;
12
+ const requestId = c.get('requestId');
13
+ reporter?.(err, { requestId });
14
+ });
15
+ return createQueryFailedNestErrorHandler({
16
+ fieldOrder,
17
+ ...nestOptions,
18
+ classify,
19
+ onUnhandledError: resolvedOnUnhandled,
20
+ });
21
+ }
@@ -0,0 +1,21 @@
1
+ import type { ExecutionContextLike } from './execution-context.js';
2
+ /**
3
+ * Fire-and-forget executor: registers a promise without awaiting it in the request path.
4
+ *
5
+ * @remarks
6
+ * On Cloudflare Workers, un-awaited work after the response may be killed unless it is registered
7
+ * via `ctx.waitUntil`. Inject a {@link createWaitUntilDefer} instance from the worker entry;
8
+ * use {@link defaultDefer} in tests and other contexts without an execution context.
9
+ */
10
+ export type DeferExecutor = (promise: Promise<unknown>) => void;
11
+ /**
12
+ * Default defer implementation (NestJS `void promise` equivalent). Swallows rejections.
13
+ * Used when no `ExecutionContext` is available (tests, partial scheduled paths).
14
+ */
15
+ export declare const defaultDefer: DeferExecutor;
16
+ /**
17
+ * Build a {@link DeferExecutor} that keeps the worker alive until `promise` settles.
18
+ *
19
+ * @param ctx - Workers execution context (`waitUntil`).
20
+ */
21
+ export declare function createWaitUntilDefer(ctx: ExecutionContextLike): DeferExecutor;
@@ -0,0 +1,17 @@
1
+ /**
2
+ * Default defer implementation (NestJS `void promise` equivalent). Swallows rejections.
3
+ * Used when no `ExecutionContext` is available (tests, partial scheduled paths).
4
+ */
5
+ export const defaultDefer = (promise) => {
6
+ void promise.catch(() => undefined);
7
+ };
8
+ /**
9
+ * Build a {@link DeferExecutor} that keeps the worker alive until `promise` settles.
10
+ *
11
+ * @param ctx - Workers execution context (`waitUntil`).
12
+ */
13
+ export function createWaitUntilDefer(ctx) {
14
+ return (promise) => {
15
+ ctx.waitUntil(promise.catch(() => undefined));
16
+ };
17
+ }
@@ -27,6 +27,22 @@ export interface ErrorReportContext {
27
27
  * @param context - Optional correlation context for the failing request.
28
28
  */
29
29
  export type ErrorReporter = (error: unknown, context?: ErrorReportContext) => void;
30
+ /**
31
+ * Minimal Sentry-like client for {@link createSentryErrorReporter} and {@link createQueueErrorHandler}.
32
+ *
33
+ * @remarks
34
+ * Declared structurally to avoid a hard dependency on `@sentry/cloudflare`.
35
+ */
36
+ export interface SentryExceptionReporterLike {
37
+ captureException(exception: unknown, captureContext?: {
38
+ tags?: Record<string, string>;
39
+ extra?: Record<string, unknown>;
40
+ }): void;
41
+ }
42
+ /**
43
+ * Build an {@link ErrorReporter} that forwards unhandled errors to Sentry with an optional `request_id` tag.
44
+ */
45
+ export declare function createSentryErrorReporter(sentry: SentryExceptionReporterLike): ErrorReporter;
30
46
  /**
31
47
  * Minimal shape read from a value treated as an HTTP error: its status, message, and optional body.
32
48
  *
@@ -12,6 +12,14 @@ export const NEST_REASON_PHRASES = {
12
12
  403: 'Forbidden',
13
13
  404: 'Not Found',
14
14
  };
15
+ /**
16
+ * Build an {@link ErrorReporter} that forwards unhandled errors to Sentry with an optional `request_id` tag.
17
+ */
18
+ export function createSentryErrorReporter(sentry) {
19
+ return (error, context) => {
20
+ sentry.captureException(error, context?.requestId ? { tags: { request_id: context.requestId } } : undefined);
21
+ };
22
+ }
15
23
  /**
16
24
  * Structurally detect Hono's `HTTPException` without relying on `instanceof`.
17
25
  *
@@ -1,5 +1,5 @@
1
1
  import type { Context, Env } from 'hono';
2
- import type { ErrorReporter, NestErrorHandlerOptions } from './nest-error.js';
2
+ import type { NestErrorHandlerOptions } from './nest-error.js';
3
3
  /** Nest QueryFailedExceptionFilter が返す `{ statusCode, message }` 形(error フィールド無し)。 */
4
4
  export interface ClassifiedDbError {
5
5
  statusCode: 400 | 500;
@@ -8,10 +8,10 @@ export interface ClassifiedDbError {
8
8
  /** mysql2 / Drizzle 由来の DB エラーを HTTP 応答用に分類する。非 DB エラーは null。 */
9
9
  export type QueryFailedClassifier = (err: unknown) => ClassifiedDbError | null;
10
10
  /**
11
- * 分類済み DB エラーをログし、500 のみ {@link ErrorReporter} へ通報する。
12
- * 400 はビジネスエラー扱い(warn ログのみ、Sentry 不要)。
11
+ * Default classifier for apps without a NestJS `QueryFailedExceptionFilter` parity layer.
12
+ * Maps any mysql2 driver error to generic 500 `{ statusCode, message: 'Internal server error' }`.
13
13
  */
14
- export declare function reportClassifiedDbError(err: unknown, classified: ClassifiedDbError, reportError?: ErrorReporter, requestId?: string): void;
14
+ export declare function classifyGenericMysqlDriverError(err: unknown): ClassifiedDbError | null;
15
15
  export interface QueryFailedNestErrorHandlerOptions<E extends Env = Env> extends NestErrorHandlerOptions<E> {
16
16
  /** アプリ固有の分類(parity-critical な日本語メッセージ等は consumer 側で定義)。 */
17
17
  classify: QueryFailedClassifier;
@@ -1,10 +1,19 @@
1
- import { logMysqlDriverError } from './mysql-driver-error.js';
1
+ import { findMysqlDriverError, logMysqlDriverError } from './mysql-driver-error.js';
2
2
  import { createNestErrorHandler } from './nest-error.js';
3
3
  /**
4
- * 分類済み DB エラーをログし、500 のみ {@link ErrorReporter} へ通報する。
5
- * 400 はビジネスエラー扱い(warn ログのみ、Sentry 不要)。
4
+ * Default classifier for apps without a NestJS `QueryFailedExceptionFilter` parity layer.
5
+ * Maps any mysql2 driver error to generic 500 `{ statusCode, message: 'Internal server error' }`.
6
6
  */
7
- export function reportClassifiedDbError(err, classified, reportError, requestId) {
7
+ export function classifyGenericMysqlDriverError(err) {
8
+ if (!findMysqlDriverError(err)) {
9
+ return null;
10
+ }
11
+ return { statusCode: 500, message: 'Internal server error' };
12
+ }
13
+ /**
14
+ * @internal Used by {@link createQueryFailedNestErrorHandler} only.
15
+ */
16
+ function reportClassifiedDbError(err, classified, reportError, requestId) {
8
17
  logMysqlDriverError(err, classified.statusCode);
9
18
  if (classified.statusCode === 500) {
10
19
  reportError?.(err, { requestId });
@@ -27,7 +36,7 @@ export function createQueryFailedNestErrorHandler(options) {
27
36
  return (err, c) => {
28
37
  const classified = classify(err);
29
38
  if (classified) {
30
- logMysqlDriverError(err, classified.statusCode);
39
+ reportClassifiedDbError(err, classified);
31
40
  if (classified.statusCode === 500) {
32
41
  try {
33
42
  onUnhandledError?.(err, c);
package/dist/index.d.ts CHANGED
@@ -10,13 +10,18 @@
10
10
  * @packageDocumentation
11
11
  */
12
12
  export { finalizeResponse } from './middleware/finalize-response.js';
13
- export { validate, createSentryValidate } from './middleware/validation.js';
13
+ export { validate, createValidate } from './middleware/validation.js';
14
+ export { createSentryValidate } from './middleware/validation.js';
14
15
  export type { ValidateOptions, ValidationTarget, ZodErrorLike, SentryLike, SentryScopeLike, } from './middleware/validation.js';
15
16
  export { zNum, zNumNullable, zNumOptional, zNumWithDefault } from './middleware/zod-coerce.js';
16
17
  export { createAuthMiddleware } from './middleware/auth.js';
17
18
  export type { AuthMiddlewareOptions } from './middleware/auth.js';
18
19
  export { perfLog } from './middleware/perf-log.js';
19
20
  export type { PerfLogOptions, AnalyticsEngineDatasetLike } from './middleware/perf-log.js';
21
+ export { createIsolateMemo } from './container/isolate-memo.js';
22
+ export type { IsolateMemo } from './container/isolate-memo.js';
23
+ export { createContainerRuntime } from './container/middleware.js';
24
+ export type { ContainerBuildContext, ContainerRuntime, ContainerRuntimeOptions } from './container/middleware.js';
20
25
  export { getUserProtocol } from './http/user-protocol.js';
21
26
  export type { IUserProtocol } from './http/user-protocol.js';
22
27
  export { getAppInfo } from './http/app-info.js';
@@ -28,10 +33,16 @@ export { createNestErrorHandler, nestNotFoundHandler, NEST_REASON_PHRASES } from
28
33
  export type { NestErrorHandlerOptions, ErrorReportContext, ErrorReporter } from './http/nest-error.js';
29
34
  export { findMysqlDriverError, logMysqlDriverError } from './http/mysql-driver-error.js';
30
35
  export type { MysqlDriverErrorLike } from './http/mysql-driver-error.js';
31
- export { createQueryFailedNestErrorHandler, reportClassifiedDbError } from './http/query-failed-error.js';
36
+ export { createQueryFailedNestErrorHandler, classifyGenericMysqlDriverError } from './http/query-failed-error.js';
32
37
  export type { ClassifiedDbError, QueryFailedClassifier, QueryFailedNestErrorHandlerOptions, } from './http/query-failed-error.js';
38
+ export { createAppErrorHandler } from './http/app-error-handler.js';
39
+ export type { CreateAppErrorHandlerOptions } from './http/app-error-handler.js';
33
40
  export { normalizeTrailingSlash } from './http/trailing-slash.js';
34
41
  export type { ExecutionContextLike } from './http/execution-context.js';
42
+ export { defaultDefer, createWaitUntilDefer } from './http/defer.js';
43
+ export type { DeferExecutor } from './http/defer.js';
44
+ export { createSentryErrorReporter } from './http/nest-error.js';
45
+ export type { SentryExceptionReporterLike } from './http/nest-error.js';
35
46
  export { KVCache } from './cache/kv-cache.js';
36
47
  export type { KVNamespace, KVCacheOptions } from './cache/kv-cache.js';
37
48
  export { createStripeClient, verifyStripeWebhook } from './stripe/client.js';
@@ -41,6 +52,8 @@ export { sendInChunks } from './queue/send.js';
41
52
  export type { QueueLike, QueueSendMessage } from './queue/send.js';
42
53
  export { processBatch } from './queue/consumer.js';
43
54
  export type { QueueMessageLike, MessageBatchLike, ProcessBatchOptions, ProcessBatchResult } from './queue/consumer.js';
55
+ export { createQueueErrorHandler } from './queue/error-handler.js';
56
+ export type { CreateQueueErrorHandlerOptions } from './queue/error-handler.js';
44
57
  export { createAiGatewayProvider } from './ai/gateway.js';
45
58
  export type { AiGatewayConfig, AiGatewayProvider, AiGatewayBinding, AiGateway, AiGatewayOptions, } from './ai/gateway.js';
46
59
  export { getAuthenticationSecret } from './aws/secrets-manager.js';
package/dist/index.js CHANGED
@@ -11,10 +11,15 @@
11
11
  */
12
12
  // middleware
13
13
  export { finalizeResponse } from './middleware/finalize-response.js';
14
- export { validate, createSentryValidate } from './middleware/validation.js';
14
+ export { validate, createValidate } from './middleware/validation.js';
15
+ // Backward-compat alias; prefer createValidate({ sentry }).
16
+ // eslint-disable-next-line @typescript-eslint/no-deprecated -- intentional public re-export
17
+ export { createSentryValidate } from './middleware/validation.js';
15
18
  export { zNum, zNumNullable, zNumOptional, zNumWithDefault } from './middleware/zod-coerce.js';
16
19
  export { createAuthMiddleware } from './middleware/auth.js';
17
20
  export { perfLog } from './middleware/perf-log.js';
21
+ export { createIsolateMemo } from './container/isolate-memo.js';
22
+ export { createContainerRuntime } from './container/middleware.js';
18
23
  // http
19
24
  export { getUserProtocol } from './http/user-protocol.js';
20
25
  export { getAppInfo } from './http/app-info.js';
@@ -22,8 +27,11 @@ export { resolveAppEnv, isProductionEnv } from './http/app-env.js';
22
27
  export { HttpStatus } from './http/http-status.js';
23
28
  export { createNestErrorHandler, nestNotFoundHandler, NEST_REASON_PHRASES } from './http/nest-error.js';
24
29
  export { findMysqlDriverError, logMysqlDriverError } from './http/mysql-driver-error.js';
25
- export { createQueryFailedNestErrorHandler, reportClassifiedDbError } from './http/query-failed-error.js';
30
+ export { createQueryFailedNestErrorHandler, classifyGenericMysqlDriverError } from './http/query-failed-error.js';
31
+ export { createAppErrorHandler } from './http/app-error-handler.js';
26
32
  export { normalizeTrailingSlash } from './http/trailing-slash.js';
33
+ export { defaultDefer, createWaitUntilDefer } from './http/defer.js';
34
+ export { createSentryErrorReporter } from './http/nest-error.js';
27
35
  // cache
28
36
  export { KVCache } from './cache/kv-cache.js';
29
37
  // stripe
@@ -33,6 +41,7 @@ export { retryWhenDeadlock } from './db/retry.js';
33
41
  // queue
34
42
  export { sendInChunks } from './queue/send.js';
35
43
  export { processBatch } from './queue/consumer.js';
44
+ export { createQueueErrorHandler } from './queue/error-handler.js';
36
45
  // ai
37
46
  export { createAiGatewayProvider } from './ai/gateway.js';
38
47
  // aws
@@ -108,44 +108,15 @@ export interface SentryLike {
108
108
  captureException(error: unknown): void;
109
109
  }
110
110
  /**
111
- * Create a {@link validate}-like factory that additionally reports DTO validation 400s to Sentry.
111
+ * Create a bound {@link validate} factory with optional Sentry reporting on validation failures.
112
112
  *
113
- * The returned function has the same signature and behavior as {@link validate}; reporting is a pure
114
- * side effect that does not alter validation behavior or the response. Each report is tagged with
115
- * `error.type=dto_validation` and carries a `validation` context of `{ errorCount, errors }`.
116
- *
117
- * @param sentry - A Sentry-like client used to capture validation failures; see {@link SentryLike}.
118
- * @returns A function `(target, schema) => MiddlewareHandler` mirroring {@link validate}.
119
- *
120
- * @example
121
- * ```ts
122
- * import * as Sentry from '@sentry/cloudflare';
123
- * import { z } from 'zod';
124
- * import { createSentryValidate } from '@rdlabo/workers-hono-kit';
125
- *
126
- * const validate = createSentryValidate(Sentry);
127
- * app.post('/users', validate('json', z.object({ name: z.string() })), handler);
128
- * ```
113
+ * @param options.sentry - When set, 400 validation errors are reported (dto_validation tag + context).
114
+ * @returns `(target, schema[, validateOptions])` middleware same as {@link validate} when sentry is omitted.
129
115
  */
130
- export declare function createSentryValidate(sentry: SentryLike): <T>(target: ValidationTarget, schema: ZodType<T>) => import("hono").MiddlewareHandler<import("hono").Env, string, {
131
- in: {
132
- json?: unknown;
133
- query?: {} | undefined;
134
- param?: {} | undefined;
135
- header?: {} | undefined;
136
- cookie?: {} | undefined;
137
- form?: {} | undefined;
138
- };
139
- out: {
140
- json: T;
141
- query: T;
142
- param: T;
143
- header: T;
144
- cookie: T;
145
- form: T;
146
- };
147
- }, Response & import("hono").TypedResponse<{
148
- statusCode: number;
149
- message: string[];
150
- error: string;
151
- }, 400, "json">>;
116
+ export declare function createValidate(options?: {
117
+ sentry?: SentryLike;
118
+ }): typeof validate;
119
+ /**
120
+ * @deprecated Use {@link createValidate}({ sentry }) instead.
121
+ */
122
+ export declare function createSentryValidate(sentry: SentryLike): typeof validate;
@@ -64,26 +64,16 @@ export function validate(target, schema, options) {
64
64
  });
65
65
  }
66
66
  /**
67
- * Create a {@link validate}-like factory that additionally reports DTO validation 400s to Sentry.
67
+ * Create a bound {@link validate} factory with optional Sentry reporting on validation failures.
68
68
  *
69
- * The returned function has the same signature and behavior as {@link validate}; reporting is a pure
70
- * side effect that does not alter validation behavior or the response. Each report is tagged with
71
- * `error.type=dto_validation` and carries a `validation` context of `{ errorCount, errors }`.
72
- *
73
- * @param sentry - A Sentry-like client used to capture validation failures; see {@link SentryLike}.
74
- * @returns A function `(target, schema) => MiddlewareHandler` mirroring {@link validate}.
75
- *
76
- * @example
77
- * ```ts
78
- * import * as Sentry from '@sentry/cloudflare';
79
- * import { z } from 'zod';
80
- * import { createSentryValidate } from '@rdlabo/workers-hono-kit';
81
- *
82
- * const validate = createSentryValidate(Sentry);
83
- * app.post('/users', validate('json', z.object({ name: z.string() })), handler);
84
- * ```
69
+ * @param options.sentry - When set, 400 validation errors are reported (dto_validation tag + context).
70
+ * @returns `(target, schema[, validateOptions])` middleware same as {@link validate} when sentry is omitted.
85
71
  */
86
- export function createSentryValidate(sentry) {
72
+ export function createValidate(options) {
73
+ if (!options?.sentry) {
74
+ return validate;
75
+ }
76
+ const sentry = options.sentry;
87
77
  const onValidationError = (error) => {
88
78
  const messages = zodToMessages(error);
89
79
  sentry.withScope((scope) => {
@@ -92,5 +82,11 @@ export function createSentryValidate(sentry) {
92
82
  sentry.captureException(error);
93
83
  });
94
84
  };
95
- return (target, schema) => validate(target, schema, { onValidationError });
85
+ return (target, schema, validateOptions) => validate(target, schema, { ...validateOptions, onValidationError });
86
+ }
87
+ /**
88
+ * @deprecated Use {@link createValidate}({ sentry }) instead.
89
+ */
90
+ export function createSentryValidate(sentry) {
91
+ return createValidate({ sentry });
96
92
  }
@@ -0,0 +1,24 @@
1
+ import type { SentryExceptionReporterLike } from '../http/nest-error.js';
2
+ import type { QueueMessageLike } from './consumer.js';
3
+ /**
4
+ * Options for {@link createQueueErrorHandler}.
5
+ */
6
+ export interface CreateQueueErrorHandlerOptions {
7
+ /** Queue name for log prefix and optional capture tags. */
8
+ queue: string;
9
+ /**
10
+ * When set, `captureException` is called only after the final delivery attempt
11
+ * (`message.attempts > maxRetries`). Cloudflare Queues uses 1-based `attempts`; the last delivery
12
+ * before the dead-letter queue has `attempts === maxRetries + 1`.
13
+ */
14
+ maxRetries?: number;
15
+ /** Optional Sentry client. Omit for console-only reporting (e.g. airlec). */
16
+ sentry?: SentryExceptionReporterLike;
17
+ /** Override {@link sentry}.captureException (custom sink). */
18
+ captureException?: SentryExceptionReporterLike['captureException'];
19
+ }
20
+ /**
21
+ * Factory for {@link processBatch}'s `onError` hook: logs every failure and optionally reports to
22
+ * Sentry (or another sink) with queue / message id / attempts / body context.
23
+ */
24
+ export declare function createQueueErrorHandler(options: CreateQueueErrorHandlerOptions): (error: unknown, message: QueueMessageLike) => void;
@@ -0,0 +1,21 @@
1
+ /**
2
+ * Factory for {@link processBatch}'s `onError` hook: logs every failure and optionally reports to
3
+ * Sentry (or another sink) with queue / message id / attempts / body context.
4
+ */
5
+ export function createQueueErrorHandler(options) {
6
+ const { queue, maxRetries, sentry, captureException } = options;
7
+ const capture = captureException ?? sentry?.captureException.bind(sentry);
8
+ return (error, message) => {
9
+ console.error(`[Queue:${queue}] message ${message.id} failed (attempt ${message.attempts})`, error);
10
+ if (!capture) {
11
+ return;
12
+ }
13
+ if (maxRetries !== undefined && message.attempts <= maxRetries) {
14
+ return;
15
+ }
16
+ capture(error, {
17
+ tags: { queue, queue_message_id: message.id },
18
+ extra: { attempts: message.attempts, body: message.body },
19
+ });
20
+ };
21
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@rdlabo/workers-hono-kit",
3
- "version": "0.5.1",
3
+ "version": "0.6.1",
4
4
  "private": false,
5
5
  "publishConfig": {
6
6
  "access": "public"