@rdlabo/workers-hono-kit 0.6.11 → 0.6.14

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
@@ -2,15 +2,16 @@
2
2
 
3
3
  Infrastructure toolkit for building APIs on [Hono](https://hono.dev) + [Cloudflare Workers](https://workers.cloudflare.com).
4
4
 
5
- It provides the building blocks a NestJS-style API needs but that don't run on `workerd` (no Node.js AWS SDK, no `firebase-admin`), plus middleware that matches Express / NestJS response semantics byte-for-byte:
5
+ It provides the building blocks a NestJS-style API needs but that don't run on `workerd` (no Node.js AWS SDK, no `firebase-admin`), plus middleware for common HTTP response concerns:
6
6
 
7
7
  - **Firebase ID-token verification** on Workers via [`jose`](https://github.com/panva/jose) (RS256 against Google's securetoken JWKS), with optional Identity Toolkit REST for `getUser` / `deleteUser`.
8
8
  - **AWS Secrets Manager / STS AssumeRole / CloudFront signed URLs** via SigV4-signed `fetch` ([`aws4fetch`](https://github.com/mhart/aws4fetch)) or Web Crypto — no AWS SDK.
9
- - **Middleware**: `finalizeResponse` (Express-compatible weak ETag + JSON charset), `validate` (NestJS `ValidationPipe`-shaped 400), and zod number-coercion helpers.
9
+ - **Middleware**: `finalizeResponse` (weak ETag via `hono/etag`), `validate` (NestJS `ValidationPipe`-shaped 400), and zod number-coercion helpers.
10
10
  - **Standard API errors**: `createHttpErrorHandler` / `notFoundHandler` / `HttpStatus`.
11
11
  - **Deadlock retry** (`ER_LOCK_DEADLOCK` exponential backoff) and an optional **MySQL data layer** (`@rdlabo/workers-hono-kit/db`) for Hyperdrive + Drizzle.
12
12
  - **AI Gateway**: route `@ai-sdk` models through the Cloudflare AI Gateway.
13
13
  - **Stripe** Workers-native client + async webhook verification.
14
+ - **Payment failure & subscription reconcile**: provider-agnostic `payment_failed` helpers — Stripe decline reasons → Japanese messages, Apple / Google subscription-renewal classification, `iapFailureKey` / receipt (de)serialization, and Stripe reconcile branch decisions.
14
15
  - **Testing helpers** (`@rdlabo/workers-hono-kit/testing`): a Drizzle-migration-backed test database, in-memory Firebase fake, configurable test doubles, and Stripe fixtures.
15
16
 
16
17
  ## Install
@@ -50,7 +51,7 @@ npm install ai ai-gateway-provider # createAiGatewayProvider
50
51
 
51
52
  | Export | Description |
52
53
  | --- | --- |
53
- | `finalizeResponse()` | Middleware that adds an Express-compatible weak `ETag` and JSON `charset=utf-8`. |
54
+ | `finalizeResponse()` | Middleware that adds a weak `ETag` (delegates to `hono/etag`; also handles `If-None-Match` → `304`). |
54
55
  | `validate(target, schema, options?)` | Zod validator → NestJS `ValidationPipe`-shaped `400` (`{ statusCode, message[], error }`). `options.onValidationError(err, c)` to report (e.g. Sentry). |
55
56
  | `createValidate({ sentry? })` | Bound `validate` factory. Pass `sentry` on Sentry apps; omit for console-only (review, cbs-ai). |
56
57
  | `createSentryValidate(sentry)` | **Deprecated** — use `createValidate({ sentry })`. |
@@ -66,7 +67,7 @@ npm install ai ai-gateway-provider # createAiGatewayProvider
66
67
  | `getUserProtocol(c)` / `IUserProtocol` | Read client IP / UA (`CF-Connecting-IP` → `X-Forwarded-For`). |
67
68
  | `getAppInfo(c)` / `AppInfo` | Read `x-amz-meta-version` / `x-amz-meta-uuid`. |
68
69
  | `resolveAppEnv(env)` / `isProductionEnv(env)` / `AppEnv` | Resolve `'development'` / `'production'` from `env.APP_ENV` (defaults to `'production'` for safety). |
69
- | `HttpStatus` | HTTP status enum identical to NestJS `@nestjs/common`. |
70
+ | `HttpStatus` | Standard HTTP status code enum (IANA registry). |
70
71
  | `createHttpErrorHandler(options?)` / `HttpErrorHandlerOptions` | `app.onError()` handler that maps a thrown `HTTPException` to `{ statusCode, message, error? }` (`401` omits `error`). Optional custom error predicate and unhandled-error report hook. Unhandled errors log via `console.error` (mysql2 errors include `sqlMessage` / `errno` when detectable). |
71
72
  | `createAppErrorHandler(options?)` / `CreateAppErrorHandlerOptions` | Standard `app.onError`: {@link createQueryFailedErrorHandler} + default {@link classifyGenericMysqlDriverError} + optional `sentry` (Sentry apps), `getReportError` / `reportError` (tests / container), or neither (no external reporting). |
72
73
  | `createQueryFailedErrorHandler(options)` / `QueryFailedClassifier` / `ClassifiedDbError` | Lower-level compose when you need full control over `classify` + `onUnhandledError` without defaults. |
@@ -77,12 +78,23 @@ npm install ai ai-gateway-provider # createAiGatewayProvider
77
78
  | `HTTP_ERROR_PHRASES` | `{ 400, 401, 403, 404 }` → standard `error` field phrases. |
78
79
  | `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. |
79
80
  | `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`. |
81
+ | `createMaintenanceMiddleware(options)` / `createMaintenanceWaitHandler(options)` / `isMaintenanceEnabled(env)` / `MAINTENANCE_CODE` / `MAINTENANCE_WAIT_PATH` | Fleet maintenance short-circuit: when enabled (`MAINTENANCE=1`), every non-allowlisted request returns `503` + `{ statusCode, message, code: 'MAINTENANCE' }` **before** container/DB. Pair with `GET /public/maintenance/wait` SSE (`event: ping` / `event: ended`) so clients can auto-dismiss a lock UI. Mount after `cors`, before `containerMiddleware`. |
80
82
  | `ErrorReporter` / `ErrorReportContext` | Types for a `reportError`-style unhandled-error reporter (e.g. wired to Sentry), paired with `createHttpErrorHandler`'s `onUnhandledError`. |
81
83
  | `createSentryErrorReporter(sentry)` / `SentryExceptionReporterLike` | Build an `ErrorReporter` that forwards to Sentry with an optional `request_id` tag (no hard `@sentry/cloudflare` dependency). |
82
84
  | `DeferExecutor` / `defaultDefer` / `createWaitUntilDefer(ctx)` | Fire-and-forget executor for Workers: `defaultDefer` swallows rejections (tests); `createWaitUntilDefer` registers work via `ctx.waitUntil`. |
83
85
  | `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`). |
84
86
  | `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. |
85
87
  | `createStripeClient(secret, opts?)` / `verifyStripeWebhook(...)` / `CreateStripeClientOptions` | Workers-native Stripe client (fetch transport) + async webhook verification (SubtleCrypto). `apiVersion` optional (pin to a fixed Stripe API version). |
88
+ | `extractStripeFailureReason(source)` / `StripeFailureReason` | Duck-type a Stripe `PaymentIntent` / `Invoice` / `{ paymentIntent?, invoice? }` / thrown error into a normalized `{ code, declineCode, message, paymentIntentId, invoiceId, subscriptionId }` (SDK-free), or `null`. |
89
+ | `stripeFailureMessageJa(reason)` | Render a `StripeFailureReason` (or `null`) as a single user-facing Japanese sentence (`decline_code` > `code`; fraud codes masked; unknown → generic). |
90
+ | `PaymentDeclinedError` / `toPaymentDeclinedError(error, status?)` / `PaymentDeclinedBody` | `HTTPException` carrying a verbatim `{ statusCode, message, code?, declineCode? }` body for a synchronous card decline (defaults to `400`). `toPaymentDeclinedError` returns `null` for non-declines (re-throw → 500). |
91
+ | `classifyStripeReconcile(subscription)` / `StripeReconcileAction` | Classify an expanded Stripe subscription into `trial` / `clear` / `canceled` / `failed` / `action_required` / `none` (termination evaluated before `succeeded`). Consumer does the DB write. |
92
+ | `serializePaymentFailure(record)` / `parsePaymentFailure(receipt)` / `PaymentFailureRecord` / `PaymentFailureReason` / `PaymentFailureSource` | (De)serialize the `payment_failed.receipt` JSON. `parsePaymentFailure` restores both a full Stripe record and a bare IAP reason. |
93
+ | `serializeIapFailureReason(reason)` / `IapFailureReason` | Serialize an IAP reason (`billing_retry` / `auto_renew_off` / `subscription_canceled` / `subscription_gone` + provider codes) directly, without the source/timestamp wrapper. |
94
+ | `paymentFailureMessageJa(input)` / `PaymentFailureStatus` / `PaymentFailureType` / `UNRESOLVED_PAYMENT_STATUSES` | Provider-agnostic Japanese message for a `payment_failed` row (`canceled` re-subscribe prompt, IAP `failed` App Store/Google Play prompt, else Stripe wording). `UNRESOLVED_PAYMENT_STATUSES` = everything except `resolved` for read/resolve `WHERE`. |
95
+ | `iapFailureKey(input)` | Provider-native `payment_failed.recursions_id`: iOS `${original_transaction_id}:${expires_date_ms}`, Android `${orderId}` (provider is in the `type` column). |
96
+ | `verifyAppleReceipt(receipt, opts)` / `classifyAppleRenewal(verify, now)` / `AppleRenewalClassification` / `AppleRenewalState` / `AppleVerifyReceiptResponse` / `ApplePendingRenewalInfo` / `AppleLatestReceiptInfo` | Verify an App Store receipt (production → sandbox fallback; inject `password` / `fetchImpl`) and classify it into `billing_retry` / `lapsed` / `active` / `unknown` plus the raw fields used (`statusCode` / `billingRetryStatus` / `autoRenewStatus`, latest `original_transaction_id` / `expires_date_ms`). |
97
+ | `googleAccessToken(creds, fetch?)` / `getGoogleSubscription(opts)` / `classifyGoogleSubscription(purchase, now)` / `GoogleSubscriptionClassification` / `GoogleSubscriptionState` / `GoogleSubscriptionPurchase` / `GoogleOAuthCredentials` | Exchange a refresh token for an Android Publisher access token (throws on `invalid_grant`), fetch a subscription purchase, and classify it into `canceled` / `gone` / `active` / `unknown` plus raw `statusCode` / `cancelReason`. |
86
98
  | `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). |
87
99
  | `processBatch(batch, handler, options?)` / `MessageBatchLike` / `QueueMessageLike` / `ProcessBatchOptions` / `ProcessBatchResult` | Process a queue batch with bounded concurrency (consumer-side counterpart to `sendInChunks`). |
88
100
  | `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). |
@@ -195,7 +207,7 @@ Requires the `drizzle-orm` and `mysql2` peers. Consolidates duplicated test boil
195
207
 
196
208
  ## Usage
197
209
 
198
- ### Response finalization (ETag / charset)
210
+ ### Response finalization (ETag)
199
211
 
200
212
  ```ts
201
213
  import { Hono } from 'hono';
@@ -504,6 +516,62 @@ const stripe = createStripeClient(secret); // or { apiVersion: '2024-04-10' } to
504
516
  const event = await verifyStripeWebhook(secret, webhookSecret, rawBody, c.req.header('stripe-signature') ?? '');
505
517
  ```
506
518
 
519
+ ### Payment failure & subscription reconcile
520
+
521
+ Store only the raw reason; render the user-facing message on read (so wording changes never need a migration).
522
+
523
+ ```ts
524
+ import {
525
+ extractStripeFailureReason,
526
+ serializePaymentFailure,
527
+ paymentFailureMessageJa,
528
+ } from '@rdlabo/workers-hono-kit';
529
+
530
+ // On a Stripe failure webhook: persist the normalized reason.
531
+ const reason = extractStripeFailureReason(event.data.object);
532
+ if (reason) {
533
+ await db.write.insert(paymentFailed).values({
534
+ type: 'stripe',
535
+ status: 'failed',
536
+ receipt: serializePaymentFailure({ reason, source: 'webhook.invoice.payment_failed', occurredAt }),
537
+ });
538
+ }
539
+
540
+ // On read: provider-agnostic Japanese message.
541
+ const message = paymentFailureMessageJa({ status: row.status, type: row.type, reason: parsed?.reason });
542
+ ```
543
+
544
+ In-app purchase: verify → classify → key the row by billing cycle.
545
+
546
+ ```ts
547
+ import {
548
+ verifyAppleReceipt,
549
+ classifyAppleRenewal,
550
+ iapFailureKey,
551
+ serializeIapFailureReason,
552
+ } from '@rdlabo/workers-hono-kit';
553
+
554
+ const verify = await verifyAppleReceipt(receipt, { password: appleSharedSecret });
555
+ const cls = classifyAppleRenewal(verify, Date.now());
556
+ if (cls.state === 'billing_retry' || cls.state === 'lapsed') {
557
+ await db.write.insert(paymentFailed).values({
558
+ type: 'ios',
559
+ status: cls.state === 'billing_retry' ? 'failed' : 'canceled',
560
+ recursions_id: iapFailureKey({
561
+ platform: 'ios',
562
+ originalTransactionId: cls.originalTransactionId!,
563
+ expiresDateMs: cls.expiresDateMs!,
564
+ }),
565
+ receipt: serializeIapFailureReason({
566
+ code: cls.state === 'billing_retry' ? 'billing_retry' : 'subscription_canceled',
567
+ statusCode: cls.statusCode,
568
+ billingRetryStatus: cls.billingRetryStatus,
569
+ autoRenewStatus: cls.autoRenewStatus,
570
+ }),
571
+ });
572
+ }
573
+ ```
574
+
507
575
  ### Testing
508
576
 
509
577
  ```ts
@@ -1,11 +1,11 @@
1
1
  /**
2
- * HTTP status codes mirroring the `HttpStatus` enum from `@nestjs/common`.
2
+ * Standard HTTP status codes, keyed by their conventional names.
3
3
  *
4
4
  * @remarks
5
- * Provides a single source of truth for referencing status codes by the same names NestJS uses, so a
6
- * Hono app can emit responses whose status matches a NestJS service byte-for-byte. The member set and
7
- * numeric values intentionally track `@nestjs/common` rather than the IANA registry, including a few
8
- * non-standard codes that NestJS ships.
5
+ * A single source of truth for referencing status codes by name from a Hono app. The member set and
6
+ * numeric values follow the IANA HTTP status code registry. (Earlier versions mirrored the
7
+ * `@nestjs/common` `HttpStatus` enum including its non-standard codes and aliases — for a
8
+ * NestJS Hono migration; that parity is no longer maintained.)
9
9
  */
10
10
  export declare enum HttpStatus {
11
11
  CONTINUE = 100,
@@ -21,10 +21,7 @@ export declare enum HttpStatus {
21
21
  PARTIAL_CONTENT = 206,
22
22
  MULTI_STATUS = 207,
23
23
  ALREADY_REPORTED = 208,
24
- /** Non-standard WebDAV extension carried over from NestJS. */
25
- CONTENT_DIFFERENT = 210,
26
- /** Multiple Choices (300). Named `AMBIGUOUS` to match NestJS. */
27
- AMBIGUOUS = 300,
24
+ MULTIPLE_CHOICES = 300,
28
25
  MOVED_PERMANENTLY = 301,
29
26
  FOUND = 302,
30
27
  SEE_OTHER = 303,
@@ -51,15 +48,12 @@ export declare enum HttpStatus {
51
48
  EXPECTATION_FAILED = 417,
52
49
  /** "I'm a teapot" (418), from RFC 2324. */
53
50
  I_AM_A_TEAPOT = 418,
54
- /** Misdirected Request (421). Named `MISDIRECTED` to match NestJS. */
55
- MISDIRECTED = 421,
51
+ MISDIRECTED_REQUEST = 421,
56
52
  UNPROCESSABLE_ENTITY = 422,
57
53
  LOCKED = 423,
58
54
  FAILED_DEPENDENCY = 424,
59
55
  PRECONDITION_REQUIRED = 428,
60
56
  TOO_MANY_REQUESTS = 429,
61
- /** Non-standard code carried over from NestJS. */
62
- UNRECOVERABLE_ERROR = 456,
63
57
  INTERNAL_SERVER_ERROR = 500,
64
58
  NOT_IMPLEMENTED = 501,
65
59
  BAD_GATEWAY = 502,
@@ -1,11 +1,11 @@
1
1
  /**
2
- * HTTP status codes mirroring the `HttpStatus` enum from `@nestjs/common`.
2
+ * Standard HTTP status codes, keyed by their conventional names.
3
3
  *
4
4
  * @remarks
5
- * Provides a single source of truth for referencing status codes by the same names NestJS uses, so a
6
- * Hono app can emit responses whose status matches a NestJS service byte-for-byte. The member set and
7
- * numeric values intentionally track `@nestjs/common` rather than the IANA registry, including a few
8
- * non-standard codes that NestJS ships.
5
+ * A single source of truth for referencing status codes by name from a Hono app. The member set and
6
+ * numeric values follow the IANA HTTP status code registry. (Earlier versions mirrored the
7
+ * `@nestjs/common` `HttpStatus` enum including its non-standard codes and aliases — for a
8
+ * NestJS Hono migration; that parity is no longer maintained.)
9
9
  */
10
10
  export var HttpStatus;
11
11
  (function (HttpStatus) {
@@ -22,10 +22,7 @@ export var HttpStatus;
22
22
  HttpStatus[HttpStatus["PARTIAL_CONTENT"] = 206] = "PARTIAL_CONTENT";
23
23
  HttpStatus[HttpStatus["MULTI_STATUS"] = 207] = "MULTI_STATUS";
24
24
  HttpStatus[HttpStatus["ALREADY_REPORTED"] = 208] = "ALREADY_REPORTED";
25
- /** Non-standard WebDAV extension carried over from NestJS. */
26
- HttpStatus[HttpStatus["CONTENT_DIFFERENT"] = 210] = "CONTENT_DIFFERENT";
27
- /** Multiple Choices (300). Named `AMBIGUOUS` to match NestJS. */
28
- HttpStatus[HttpStatus["AMBIGUOUS"] = 300] = "AMBIGUOUS";
25
+ HttpStatus[HttpStatus["MULTIPLE_CHOICES"] = 300] = "MULTIPLE_CHOICES";
29
26
  HttpStatus[HttpStatus["MOVED_PERMANENTLY"] = 301] = "MOVED_PERMANENTLY";
30
27
  HttpStatus[HttpStatus["FOUND"] = 302] = "FOUND";
31
28
  HttpStatus[HttpStatus["SEE_OTHER"] = 303] = "SEE_OTHER";
@@ -52,15 +49,12 @@ export var HttpStatus;
52
49
  HttpStatus[HttpStatus["EXPECTATION_FAILED"] = 417] = "EXPECTATION_FAILED";
53
50
  /** "I'm a teapot" (418), from RFC 2324. */
54
51
  HttpStatus[HttpStatus["I_AM_A_TEAPOT"] = 418] = "I_AM_A_TEAPOT";
55
- /** Misdirected Request (421). Named `MISDIRECTED` to match NestJS. */
56
- HttpStatus[HttpStatus["MISDIRECTED"] = 421] = "MISDIRECTED";
52
+ HttpStatus[HttpStatus["MISDIRECTED_REQUEST"] = 421] = "MISDIRECTED_REQUEST";
57
53
  HttpStatus[HttpStatus["UNPROCESSABLE_ENTITY"] = 422] = "UNPROCESSABLE_ENTITY";
58
54
  HttpStatus[HttpStatus["LOCKED"] = 423] = "LOCKED";
59
55
  HttpStatus[HttpStatus["FAILED_DEPENDENCY"] = 424] = "FAILED_DEPENDENCY";
60
56
  HttpStatus[HttpStatus["PRECONDITION_REQUIRED"] = 428] = "PRECONDITION_REQUIRED";
61
57
  HttpStatus[HttpStatus["TOO_MANY_REQUESTS"] = 429] = "TOO_MANY_REQUESTS";
62
- /** Non-standard code carried over from NestJS. */
63
- HttpStatus[HttpStatus["UNRECOVERABLE_ERROR"] = 456] = "UNRECOVERABLE_ERROR";
64
58
  HttpStatus[HttpStatus["INTERNAL_SERVER_ERROR"] = 500] = "INTERNAL_SERVER_ERROR";
65
59
  HttpStatus[HttpStatus["NOT_IMPLEMENTED"] = 501] = "NOT_IMPLEMENTED";
66
60
  HttpStatus[HttpStatus["BAD_GATEWAY"] = 502] = "BAD_GATEWAY";
package/dist/index.d.ts CHANGED
@@ -18,6 +18,8 @@ export { createAuthMiddleware } from './middleware/auth.js';
18
18
  export type { AuthMiddlewareOptions } from './middleware/auth.js';
19
19
  export { perfLog } from './middleware/perf-log.js';
20
20
  export type { PerfLogOptions, AnalyticsEngineDatasetLike } from './middleware/perf-log.js';
21
+ export { createMaintenanceMiddleware, createMaintenanceWaitHandler, isMaintenanceEnabled, MAINTENANCE_BODY, MAINTENANCE_CODE, MAINTENANCE_WAIT_PATH, } from './middleware/maintenance.js';
22
+ export type { MaintenanceBody, MaintenanceMiddlewareOptions, MaintenanceWaitOptions, } from './middleware/maintenance.js';
21
23
  export { createIsolateMemo } from './container/isolate-memo.js';
22
24
  export type { IsolateMemo } from './container/isolate-memo.js';
23
25
  export { createContainerRuntime } from './container/middleware.js';
package/dist/index.js CHANGED
@@ -18,6 +18,7 @@ export { createSentryValidate } from './middleware/validation.js';
18
18
  export { zNum, zNumNullable, zNumOptional, zNumWithDefault } from './middleware/zod-coerce.js';
19
19
  export { createAuthMiddleware } from './middleware/auth.js';
20
20
  export { perfLog } from './middleware/perf-log.js';
21
+ export { createMaintenanceMiddleware, createMaintenanceWaitHandler, isMaintenanceEnabled, MAINTENANCE_BODY, MAINTENANCE_CODE, MAINTENANCE_WAIT_PATH, } from './middleware/maintenance.js';
21
22
  export { createIsolateMemo } from './container/isolate-memo.js';
22
23
  export { createContainerRuntime } from './container/middleware.js';
23
24
  // http
@@ -1,21 +1,21 @@
1
1
  import type { MiddlewareHandler } from 'hono';
2
2
  /**
3
- * Create a Hono middleware that finalizes responses for byte-parity with an Express/Nest backend.
3
+ * Create a Hono middleware that adds a weak ETag to buffered responses.
4
4
  *
5
- * After the downstream handler runs, it performs two adjustments:
5
+ * @remarks
6
+ * ETag generation (and conditional `If-None-Match` → `304 Not Modified` handling) is delegated to
7
+ * Hono's official `hono/etag` middleware in weak mode, so the ETag format is `W/"<sha1-hex>"`. This
8
+ * no longer emulates the Express `etag` package byte-for-byte — the kit dropped NestJS/Express parity.
6
9
  *
7
- * 1. **JSON charset**: Express's `res.json` emits `application/json; charset=utf-8`, whereas Hono's
8
- * `c.json` emits a bare `application/json`. A bare `application/json` content type is rewritten
9
- * to include `; charset=utf-8`.
10
- * 2. **Weak ETag**: An Express `etag`-package compatible weak ETag is added, matching the format an
11
- * Express/Nest backend applies to responses by default. See {@link weakEtag} for the exact format.
10
+ * Two categories of response are skipped so the downstream stream is never consumed:
12
11
  *
13
- * Server-Sent Events (`text/event-stream`) are skipped entirely because the stream cannot be
14
- * buffered. ETag generation is also skipped for `204`/`304` responses, responses that already carry
15
- * an `etag` header, and responses without a body.
12
+ * 1. **Server-Sent Events** (`text/event-stream`): the stream cannot be buffered to hash.
13
+ * 2. **Bodyless responses** (e.g. `204`/`304`, or handlers returning `null`): nothing to hash.
16
14
  *
17
- * @returns A {@link MiddlewareHandler} that rewrites the response headers (and body, when an ETag
18
- * must be computed) in place.
15
+ * Responses that already carry an `ETag` header are left untouched by `hono/etag`.
16
+ *
17
+ * @returns A {@link MiddlewareHandler} that sets the `ETag` header (or short-circuits to `304` when
18
+ * the request's `If-None-Match` matches).
19
19
  *
20
20
  * @example
21
21
  * ```ts
@@ -25,8 +25,7 @@ import type { MiddlewareHandler } from 'hono';
25
25
  * const app = new Hono();
26
26
  * app.use('*', finalizeResponse());
27
27
  * app.get('/users', (c) => c.json({ ok: true }));
28
- * // → Content-Type: application/json; charset=utf-8
29
- * // → ETag: W/"b-..." (b = 0xb = 11 bytes, the length of `{"ok":true}`)
28
+ * // → ETag: W/"<sha1-hex of the body>"
30
29
  * ```
31
30
  */
32
31
  export declare function finalizeResponse(): MiddlewareHandler;
@@ -1,41 +1,29 @@
1
+ import { etag } from 'hono/etag';
1
2
  /**
2
- * Compute an Express `etag`-package compatible weak ETag for a response body.
3
+ * Pre-built Hono ETag middleware in weak mode.
3
4
  *
4
- * The format is `W/"<byteLength-in-hex>-<first 27 chars of base64(sha1(body))>"`, byte-for-byte
5
- * identical to the weak ETag produced by the Express `etag` package. This deliberately differs
6
- * from `hono/etag`'s own format so responses match an Express/Nest backend exactly.
7
- *
8
- * @param body - The raw response body bytes to hash.
9
- * @returns The weak ETag header value (e.g. `W/"1a-Qwerty..."`).
5
+ * @remarks
6
+ * Reused across requests; it is stateless per invocation (all state lives on the passed `Context`).
10
7
  * @internal
11
8
  */
12
- async function weakEtag(body) {
13
- const digest = await crypto.subtle.digest('SHA-1', body);
14
- const bytes = new Uint8Array(digest);
15
- let bin = '';
16
- for (const b of bytes) {
17
- bin += String.fromCharCode(b);
18
- }
19
- const b64 = btoa(bin).substring(0, 27);
20
- return `W/"${body.byteLength.toString(16)}-${b64}"`;
21
- }
9
+ const applyWeakEtag = etag({ weak: true });
22
10
  /**
23
- * Create a Hono middleware that finalizes responses for byte-parity with an Express/Nest backend.
11
+ * Create a Hono middleware that adds a weak ETag to buffered responses.
24
12
  *
25
- * After the downstream handler runs, it performs two adjustments:
13
+ * @remarks
14
+ * ETag generation (and conditional `If-None-Match` → `304 Not Modified` handling) is delegated to
15
+ * Hono's official `hono/etag` middleware in weak mode, so the ETag format is `W/"<sha1-hex>"`. This
16
+ * no longer emulates the Express `etag` package byte-for-byte — the kit dropped NestJS/Express parity.
26
17
  *
27
- * 1. **JSON charset**: Express's `res.json` emits `application/json; charset=utf-8`, whereas Hono's
28
- * `c.json` emits a bare `application/json`. A bare `application/json` content type is rewritten
29
- * to include `; charset=utf-8`.
30
- * 2. **Weak ETag**: An Express `etag`-package compatible weak ETag is added, matching the format an
31
- * Express/Nest backend applies to responses by default. See {@link weakEtag} for the exact format.
18
+ * Two categories of response are skipped so the downstream stream is never consumed:
32
19
  *
33
- * Server-Sent Events (`text/event-stream`) are skipped entirely because the stream cannot be
34
- * buffered. ETag generation is also skipped for `204`/`304` responses, responses that already carry
35
- * an `etag` header, and responses without a body.
20
+ * 1. **Server-Sent Events** (`text/event-stream`): the stream cannot be buffered to hash.
21
+ * 2. **Bodyless responses** (e.g. `204`/`304`, or handlers returning `null`): nothing to hash.
36
22
  *
37
- * @returns A {@link MiddlewareHandler} that rewrites the response headers (and body, when an ETag
38
- * must be computed) in place.
23
+ * Responses that already carry an `ETag` header are left untouched by `hono/etag`.
24
+ *
25
+ * @returns A {@link MiddlewareHandler} that sets the `ETag` header (or short-circuits to `304` when
26
+ * the request's `If-None-Match` matches).
39
27
  *
40
28
  * @example
41
29
  * ```ts
@@ -45,38 +33,19 @@ async function weakEtag(body) {
45
33
  * const app = new Hono();
46
34
  * app.use('*', finalizeResponse());
47
35
  * app.get('/users', (c) => c.json({ ok: true }));
48
- * // → Content-Type: application/json; charset=utf-8
49
- * // → ETag: W/"b-..." (b = 0xb = 11 bytes, the length of `{"ok":true}`)
36
+ * // → ETag: W/"<sha1-hex of the body>"
50
37
  * ```
51
38
  */
52
39
  export function finalizeResponse() {
53
40
  return async (c, next) => {
54
41
  await next();
55
- const status = c.res.status;
56
42
  const contentType = c.res.headers.get('content-type') ?? '';
57
- // Leave SSE / streaming responses untouched.
58
- if (contentType.includes('text/event-stream')) {
59
- return;
60
- }
61
- // Charset target: add the charset when a JSON response leaves it unspecified.
62
- const needsCharset = contentType === 'application/json';
63
- // ETag target: Express omits it on 204/304 and respects an existing ETag.
64
- const needsEtag = status !== 204 && status !== 304 && !c.res.headers.has('etag') && !!c.res.body;
65
- if (!needsCharset && !needsEtag) {
43
+ // Leave SSE / streaming responses untouched, and skip bodyless responses (204/304/null).
44
+ if (contentType.includes('text/event-stream') || !c.res.body) {
66
45
  return;
67
46
  }
68
- const headers = new Headers(c.res.headers);
69
- if (needsCharset) {
70
- headers.set('content-type', 'application/json; charset=utf-8');
71
- }
72
- if (needsEtag) {
73
- const buf = await c.res.clone().arrayBuffer();
74
- headers.set('ETag', await weakEtag(buf));
75
- c.res = new Response(buf, { status, statusText: c.res.statusText, headers });
76
- }
77
- else {
78
- // Swap only the headers without reading the body.
79
- c.res = new Response(c.res.body, { status, statusText: c.res.statusText, headers });
80
- }
47
+ // The downstream handler already ran, so pass a no-op `next`; `hono/etag` then hashes the
48
+ // buffered body and applies the ETag (or a conditional 304) to the existing response.
49
+ await applyWeakEtag(c, async () => undefined);
81
50
  };
82
51
  }
@@ -0,0 +1,113 @@
1
+ import type { Context, Env, MiddlewareHandler } from 'hono';
2
+ /** Canonical API error `code` for fleet-wide maintenance short-circuit. */
3
+ export declare const MAINTENANCE_CODE: "MAINTENANCE";
4
+ /** Default allowlisted SSE path that stays open while the rest of the API returns 503. */
5
+ export declare const MAINTENANCE_WAIT_PATH = "/public/maintenance/wait";
6
+ /** JSON body returned for every blocked request during maintenance. */
7
+ export interface MaintenanceBody {
8
+ statusCode: 503;
9
+ message: string;
10
+ code: typeof MAINTENANCE_CODE;
11
+ }
12
+ /** Default 503 body (no phrase `error` field — would collide with `code` shape on the client). */
13
+ export declare const MAINTENANCE_BODY: MaintenanceBody;
14
+ /**
15
+ * True when the Workers binding / wrangler var `MAINTENANCE` is the string `'1'`.
16
+ *
17
+ * @param env - Bindings object that may carry `MAINTENANCE`
18
+ */
19
+ export declare function isMaintenanceEnabled(env: {
20
+ MAINTENANCE?: string;
21
+ } | null | undefined): boolean;
22
+ /**
23
+ * Options for {@link createMaintenanceMiddleware}.
24
+ *
25
+ * @typeParam E - The Hono `Env` of the application.
26
+ */
27
+ export interface MaintenanceMiddlewareOptions<E extends Env = Env> {
28
+ /**
29
+ * Whether maintenance mode is currently on for this request.
30
+ *
31
+ * @remarks
32
+ * Typical wiring: `(c) => isMaintenanceEnabled(c.env)`. Injected so tests and non-env sources
33
+ * (future KV) can supply their own predicate without forking the middleware.
34
+ */
35
+ isEnabled: (c: Context<E>) => boolean;
36
+ /**
37
+ * Paths that stay reachable during maintenance (default: {@link MAINTENANCE_WAIT_PATH}).
38
+ *
39
+ * @remarks
40
+ * Compared against `pathname` with and without a trailing slash. {@link MAINTENANCE_WAIT_PATH}
41
+ * is handled inside this middleware (SSE) so it never reaches container/DB. Other allowlisted
42
+ * paths call `next()`.
43
+ */
44
+ allowPaths?: readonly string[];
45
+ /** Override the default {@link MAINTENANCE_BODY.message}. */
46
+ message?: string;
47
+ /** Optional `Retry-After` header (seconds). */
48
+ retryAfterSeconds?: number;
49
+ /**
50
+ * Ping interval for the in-middleware wait SSE (ms). Defaults to `5_000`.
51
+ *
52
+ * @see {@link MaintenanceWaitOptions.pingIntervalMs}
53
+ */
54
+ pingIntervalMs?: number;
55
+ }
56
+ /**
57
+ * Options for {@link createMaintenanceWaitHandler}.
58
+ *
59
+ * @typeParam E - The Hono `Env` of the application.
60
+ */
61
+ export interface MaintenanceWaitOptions<E extends Env = Env> {
62
+ /**
63
+ * Whether maintenance mode is still on. Re-evaluated on each ping tick.
64
+ *
65
+ * @remarks
66
+ * Wrangler `vars` do not change inside a long-lived isolate; after a deploy that clears
67
+ * `MAINTENANCE`, new connections (and clients that reconnect) see `false` and get `ended`.
68
+ */
69
+ isEnabled: (c: Context<E>) => boolean;
70
+ /**
71
+ * Interval between SSE `ping` events and `isEnabled` re-checks (ms). Defaults to `5_000`.
72
+ */
73
+ pingIntervalMs?: number;
74
+ }
75
+ /**
76
+ * Short-circuit middleware: when enabled, every non-allowlisted request returns
77
+ * `503` + `{ statusCode, message, code: 'MAINTENANCE' }` without running downstream
78
+ * (so container / Hyperdrive / secrets stay cold).
79
+ *
80
+ * @remarks
81
+ * Mount **after** `cors` / `finalizeResponse` and **before** `containerMiddleware`.
82
+ * {@link MAINTENANCE_WAIT_PATH} is served **inside this middleware** (both when
83
+ * maintenance is on and off) so the wait SSE never reaches container / DB. Other
84
+ * `allowPaths` still call `next()`.
85
+ *
86
+ * @typeParam E - The Hono `Env` of the application.
87
+ * @param options - Enable predicate, allowlist, and optional body/header overrides.
88
+ * @returns A {@link MiddlewareHandler} that either returns 503 / SSE or calls `next()`.
89
+ *
90
+ * @example
91
+ * ```ts
92
+ * app.use('*', createMaintenanceMiddleware({
93
+ * isEnabled: (c) => isMaintenanceEnabled(c.env),
94
+ * }));
95
+ * // Optional: also register createMaintenanceWaitHandler as a route — redundant when
96
+ * // this middleware is mounted, because MAINTENANCE_WAIT_PATH is handled here.
97
+ * ```
98
+ */
99
+ export declare function createMaintenanceMiddleware<E extends Env = Env>(options: MaintenanceMiddlewareOptions<E>): MiddlewareHandler<E>;
100
+ /**
101
+ * SSE handler for {@link MAINTENANCE_WAIT_PATH}.
102
+ *
103
+ * @remarks
104
+ * - Already off → emit `event: ended` once and close.
105
+ * - Still on → emit `event: ping` on an interval; when `isEnabled` becomes false, emit
106
+ * `event: ended` and close. Clients should close the EventSource on `ended` and dismiss UI.
107
+ *
108
+ * @typeParam E - The Hono `Env` of the application.
109
+ * @param options - Enable predicate and ping interval.
110
+ * @returns A handler `(c) => Response` suitable for `app.get(MAINTENANCE_WAIT_PATH, …)`
111
+ * or for embedding inside {@link createMaintenanceMiddleware}.
112
+ */
113
+ export declare function createMaintenanceWaitHandler<E extends Env = Env>(options: MaintenanceWaitOptions<E>): (c: Context<E>) => Response;
@@ -0,0 +1,171 @@
1
+ /** Canonical API error `code` for fleet-wide maintenance short-circuit. */
2
+ export const MAINTENANCE_CODE = 'MAINTENANCE';
3
+ /** Default allowlisted SSE path that stays open while the rest of the API returns 503. */
4
+ export const MAINTENANCE_WAIT_PATH = '/public/maintenance/wait';
5
+ /** Default 503 body (no phrase `error` field — would collide with `code` shape on the client). */
6
+ export const MAINTENANCE_BODY = {
7
+ statusCode: 503,
8
+ message: 'Service temporarily unavailable',
9
+ code: MAINTENANCE_CODE,
10
+ };
11
+ /**
12
+ * True when the Workers binding / wrangler var `MAINTENANCE` is the string `'1'`.
13
+ *
14
+ * @param env - Bindings object that may carry `MAINTENANCE`
15
+ */
16
+ export function isMaintenanceEnabled(env) {
17
+ return env?.MAINTENANCE === '1';
18
+ }
19
+ const SSE_HEADERS = {
20
+ 'Content-Type': 'text/event-stream',
21
+ 'Cache-Control': 'no-cache',
22
+ Connection: 'keep-alive',
23
+ 'X-Accel-Buffering': 'no',
24
+ };
25
+ function normalizePath(pathname) {
26
+ if (pathname.length > 1 && pathname.endsWith('/')) {
27
+ return pathname.slice(0, -1);
28
+ }
29
+ return pathname;
30
+ }
31
+ function isAllowlisted(pathname, allowPaths) {
32
+ const normalized = normalizePath(pathname);
33
+ return allowPaths.has(normalized) || allowPaths.has(pathname);
34
+ }
35
+ /**
36
+ * Short-circuit middleware: when enabled, every non-allowlisted request returns
37
+ * `503` + `{ statusCode, message, code: 'MAINTENANCE' }` without running downstream
38
+ * (so container / Hyperdrive / secrets stay cold).
39
+ *
40
+ * @remarks
41
+ * Mount **after** `cors` / `finalizeResponse` and **before** `containerMiddleware`.
42
+ * {@link MAINTENANCE_WAIT_PATH} is served **inside this middleware** (both when
43
+ * maintenance is on and off) so the wait SSE never reaches container / DB. Other
44
+ * `allowPaths` still call `next()`.
45
+ *
46
+ * @typeParam E - The Hono `Env` of the application.
47
+ * @param options - Enable predicate, allowlist, and optional body/header overrides.
48
+ * @returns A {@link MiddlewareHandler} that either returns 503 / SSE or calls `next()`.
49
+ *
50
+ * @example
51
+ * ```ts
52
+ * app.use('*', createMaintenanceMiddleware({
53
+ * isEnabled: (c) => isMaintenanceEnabled(c.env),
54
+ * }));
55
+ * // Optional: also register createMaintenanceWaitHandler as a route — redundant when
56
+ * // this middleware is mounted, because MAINTENANCE_WAIT_PATH is handled here.
57
+ * ```
58
+ */
59
+ export function createMaintenanceMiddleware(options) {
60
+ const allowPaths = new Set((options.allowPaths ?? [MAINTENANCE_WAIT_PATH]).map((p) => normalizePath(p)));
61
+ const message = options.message ?? MAINTENANCE_BODY.message;
62
+ const waitHandler = createMaintenanceWaitHandler({
63
+ isEnabled: options.isEnabled,
64
+ pingIntervalMs: options.pingIntervalMs,
65
+ });
66
+ const waitPath = normalizePath(MAINTENANCE_WAIT_PATH);
67
+ return async (c, next) => {
68
+ const pathname = normalizePath(new URL(c.req.url).pathname);
69
+ // Wait SSE must never hit container/DB — handle it here whether maintenance is on or off.
70
+ if (pathname === waitPath) {
71
+ return waitHandler(c);
72
+ }
73
+ if (!options.isEnabled(c)) {
74
+ return next();
75
+ }
76
+ if (isAllowlisted(pathname, allowPaths)) {
77
+ return next();
78
+ }
79
+ if (options.retryAfterSeconds != null) {
80
+ c.header('Retry-After', String(options.retryAfterSeconds));
81
+ }
82
+ const body = {
83
+ statusCode: 503,
84
+ message,
85
+ code: MAINTENANCE_CODE,
86
+ };
87
+ return c.json(body, 503);
88
+ };
89
+ }
90
+ /**
91
+ * SSE handler for {@link MAINTENANCE_WAIT_PATH}.
92
+ *
93
+ * @remarks
94
+ * - Already off → emit `event: ended` once and close.
95
+ * - Still on → emit `event: ping` on an interval; when `isEnabled` becomes false, emit
96
+ * `event: ended` and close. Clients should close the EventSource on `ended` and dismiss UI.
97
+ *
98
+ * @typeParam E - The Hono `Env` of the application.
99
+ * @param options - Enable predicate and ping interval.
100
+ * @returns A handler `(c) => Response` suitable for `app.get(MAINTENANCE_WAIT_PATH, …)`
101
+ * or for embedding inside {@link createMaintenanceMiddleware}.
102
+ */
103
+ export function createMaintenanceWaitHandler(options) {
104
+ const pingIntervalMs = options.pingIntervalMs ?? 5_000;
105
+ const encoder = new TextEncoder();
106
+ return (c) => {
107
+ const clientSignal = c.req.raw.signal;
108
+ if (!options.isEnabled(c)) {
109
+ const stream = new ReadableStream({
110
+ start(controller) {
111
+ controller.enqueue(encoder.encode('event: ended\ndata: ended\n\n'));
112
+ controller.close();
113
+ },
114
+ });
115
+ return new Response(stream, { headers: SSE_HEADERS });
116
+ }
117
+ let heartbeat;
118
+ const stream = new ReadableStream({
119
+ start(controller) {
120
+ let closed = false;
121
+ const close = () => {
122
+ if (closed) {
123
+ return;
124
+ }
125
+ closed = true;
126
+ if (heartbeat) {
127
+ clearInterval(heartbeat);
128
+ heartbeat = undefined;
129
+ }
130
+ try {
131
+ controller.close();
132
+ }
133
+ catch {
134
+ /* already closed */
135
+ }
136
+ };
137
+ const enqueue = (chunk) => {
138
+ try {
139
+ controller.enqueue(encoder.encode(chunk));
140
+ }
141
+ catch {
142
+ close();
143
+ }
144
+ };
145
+ const end = () => {
146
+ enqueue('event: ended\ndata: ended\n\n');
147
+ close();
148
+ };
149
+ if (clientSignal.aborted) {
150
+ close();
151
+ return;
152
+ }
153
+ clientSignal.addEventListener('abort', close, { once: true });
154
+ heartbeat = setInterval(() => {
155
+ if (!options.isEnabled(c)) {
156
+ end();
157
+ return;
158
+ }
159
+ enqueue('event: ping\ndata: ping\n\n');
160
+ }, pingIntervalMs);
161
+ },
162
+ cancel() {
163
+ if (heartbeat) {
164
+ clearInterval(heartbeat);
165
+ heartbeat = undefined;
166
+ }
167
+ },
168
+ });
169
+ return new Response(stream, { headers: SSE_HEADERS });
170
+ };
171
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@rdlabo/workers-hono-kit",
3
- "version": "0.6.11",
3
+ "version": "0.6.14",
4
4
  "private": false,
5
5
  "publishConfig": {
6
6
  "access": "public"
@@ -1,149 +0,0 @@
1
- import type { Context, Env } from 'hono';
2
- import type { ContentfulStatusCode } from 'hono/utils/http-status';
3
- /**
4
- * Reason phrases attached by the NestJS default exception filter, keyed by HTTP status code.
5
- *
6
- * @remarks
7
- * Mirrors the `error` field values NestJS produces for common client-error statuses, so a Hono app can
8
- * return byte-identical error bodies. Used as the default `reasonPhrases` map by {@link createNestErrorHandler}.
9
- */
10
- export declare const NEST_REASON_PHRASES: Record<number, string>;
11
- /**
12
- * Contextual metadata passed to an {@link ErrorReporter} when reporting an unexpected error.
13
- */
14
- export interface ErrorReportContext {
15
- /** Correlation id for the failing request, if one is tracked. */
16
- requestId?: string;
17
- }
18
- /**
19
- * Signature of a function that reports an unexpected (non-HTTP) error to an external sink such as Sentry.
20
- *
21
- * @remarks
22
- * Wire it into {@link createNestErrorHandler} via `onUnhandledError`, e.g.
23
- * `(err, c) => reporter(err, { requestId: c.get('requestId') })`. The reporting client itself is
24
- * intentionally kept out of this kit; the consumer supplies the implementation.
25
- *
26
- * @param error - The thrown value being reported.
27
- * @param context - Optional correlation context for the failing request.
28
- */
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;
46
- /**
47
- * Minimal shape read from a value treated as an HTTP error: its status, message, and optional body.
48
- *
49
- * @internal
50
- */
51
- interface HttpErrorLike {
52
- /** HTTP status code to respond with. */
53
- status: ContentfulStatusCode;
54
- /** Human-readable error message placed in the response body. */
55
- message: string;
56
- /**
57
- * Escape hatch for a fully custom response body. When present, it is rendered verbatim instead of
58
- * the NestJS-shaped body.
59
- */
60
- body?: unknown;
61
- }
62
- /**
63
- * Options controlling how {@link createNestErrorHandler} shapes error responses.
64
- *
65
- * @typeParam E - The Hono environment type, so `onUnhandledError` receives a correctly typed context.
66
- */
67
- export interface NestErrorHandlerOptions<E extends Env = Env> {
68
- /** Status-to-reason-phrase map for the `error` field. Defaults to {@link NEST_REASON_PHRASES}. */
69
- reasonPhrases?: Record<number, string>;
70
- /**
71
- * Statuses that return only `{ statusCode, message }`, omitting the `error` field. Defaults to `[401]`,
72
- * matching NestJS where a generic `HttpException(msg, 401)` carries no `error`.
73
- */
74
- bareStatuses?: readonly number[];
75
- /**
76
- * Field order of the non-bare error body. Defaults to `'statusCode-first'` (the NestJS canonical order).
77
- * Use `'message-first'` to emit `{ message, error, statusCode }` when byte parity requires it.
78
- */
79
- fieldOrder?: 'statusCode-first' | 'message-first';
80
- /**
81
- * Fallback `error` value for statuses that are neither bare nor present in `reasonPhrases`. Defaults to
82
- * `undefined`, meaning the `error` field is omitted when no reason phrase is known. Set to a string such
83
- * as `'Error'` to always include an `error` field, faithfully reproducing the NestJS default exception
84
- * filter behavior where `error` is always present.
85
- */
86
- fallbackReason?: string;
87
- /**
88
- * Predicate identifying which thrown values are HTTP errors. Defaults to detecting Hono's `HTTPException`.
89
- * Override it (e.g. `(e) => e instanceof MyHttpError`) when the app throws a custom HTTP error type.
90
- */
91
- isHttpError?: (err: unknown) => err is HttpErrorLike;
92
- /**
93
- * Hook invoked before an unexpected (non-HTTP) error is returned as a 500, typically used to report the
94
- * error (e.g. to Sentry). Any exception thrown by this hook is swallowed so reporting cannot alter the
95
- * error response.
96
- */
97
- onUnhandledError?: (err: unknown, c: Context<E>) => void;
98
- /**
99
- * Response body for unexpected errors returned as 500. Defaults to
100
- * `{ statusCode: 500, message: 'Internal server error' }`.
101
- */
102
- internalServerErrorBody?: unknown;
103
- }
104
- /**
105
- * Create a Hono `onError` handler that maps thrown errors to NestJS-shaped error JSON.
106
- *
107
- * @remarks
108
- * Reproduces the NestJS default exception filter so a Hono app returns byte-identical error bodies:
109
- * - HTTP errors (by default `HTTPException`) are mapped to a NestJS-shaped body; if the error carries a
110
- * custom `body`, that body is returned verbatim.
111
- * - Statuses listed in `bareStatuses` (default `[401]`) omit the `error` field.
112
- * - Any other (unexpected) error triggers `onUnhandledError`, is logged via `console.error`, and returns 500.
113
- *
114
- * Per-app differences in body field order, HTTP error type, and reporting hook are absorbed through
115
- * {@link NestErrorHandlerOptions}, while the branching logic stays shared.
116
- *
117
- * @typeParam E - The Hono environment type propagated to `onUnhandledError`.
118
- * @param options - Overrides for reason phrases, bare statuses, field order, error detection, and reporting.
119
- * @returns A handler suitable for `app.onError(...)`.
120
- *
121
- * @example
122
- * ```ts
123
- * app.onError(
124
- * createNestErrorHandler({
125
- * fieldOrder: 'message-first',
126
- * fallbackReason: 'Error',
127
- * onUnhandledError: (err, c) => reportError(err, { requestId: c.get('requestId') }),
128
- * }),
129
- * );
130
- * ```
131
- */
132
- export declare function createNestErrorHandler<E extends Env = Env>(options?: NestErrorHandlerOptions<E>): (err: Error, c: Context<E>) => Response;
133
- /**
134
- * Hono `notFound` handler that returns the canonical Express/NestJS unmatched-route 404 body.
135
- *
136
- * @remarks
137
- * Produces `{ message: "Cannot <METHOD> <path>", error: 'Not Found', statusCode: 404 }`, matching the
138
- * NestJS default 404 response so unmatched routes stay byte-identical.
139
- *
140
- * @param c - The Hono request context for the unmatched route.
141
- * @returns A 404 JSON response.
142
- *
143
- * @example
144
- * ```ts
145
- * app.notFound(nestNotFoundHandler);
146
- * ```
147
- */
148
- export declare function nestNotFoundHandler(c: Context): Response;
149
- export {};
@@ -1,120 +0,0 @@
1
- import { findMysqlDriverError, logMysqlDriverError } from './mysql-driver-error.js';
2
- /**
3
- * Reason phrases attached by the NestJS default exception filter, keyed by HTTP status code.
4
- *
5
- * @remarks
6
- * Mirrors the `error` field values NestJS produces for common client-error statuses, so a Hono app can
7
- * return byte-identical error bodies. Used as the default `reasonPhrases` map by {@link createNestErrorHandler}.
8
- */
9
- export const NEST_REASON_PHRASES = {
10
- 400: 'Bad Request',
11
- 401: 'Unauthorized',
12
- 403: 'Forbidden',
13
- 404: 'Not Found',
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
- }
23
- /**
24
- * Structurally detect Hono's `HTTPException` without relying on `instanceof`.
25
- *
26
- * @remarks
27
- * When this kit is symlinked into a consumer, the `hono` instance it resolves can differ from the
28
- * consumer's `hono`, so an `HTTPException` from one copy fails an `instanceof` check against the other.
29
- * Detecting the presence of a `getResponse()` method and a numeric `status` is stable across module
30
- * boundaries and production bundles.
31
- *
32
- * @param err - The thrown value to test.
33
- * @returns `true` when `err` looks like a Hono `HTTPException`.
34
- *
35
- * @internal
36
- */
37
- const isHTTPException = (err) => err instanceof Error &&
38
- typeof err.getResponse === 'function' &&
39
- typeof err.status === 'number';
40
- /**
41
- * Create a Hono `onError` handler that maps thrown errors to NestJS-shaped error JSON.
42
- *
43
- * @remarks
44
- * Reproduces the NestJS default exception filter so a Hono app returns byte-identical error bodies:
45
- * - HTTP errors (by default `HTTPException`) are mapped to a NestJS-shaped body; if the error carries a
46
- * custom `body`, that body is returned verbatim.
47
- * - Statuses listed in `bareStatuses` (default `[401]`) omit the `error` field.
48
- * - Any other (unexpected) error triggers `onUnhandledError`, is logged via `console.error`, and returns 500.
49
- *
50
- * Per-app differences in body field order, HTTP error type, and reporting hook are absorbed through
51
- * {@link NestErrorHandlerOptions}, while the branching logic stays shared.
52
- *
53
- * @typeParam E - The Hono environment type propagated to `onUnhandledError`.
54
- * @param options - Overrides for reason phrases, bare statuses, field order, error detection, and reporting.
55
- * @returns A handler suitable for `app.onError(...)`.
56
- *
57
- * @example
58
- * ```ts
59
- * app.onError(
60
- * createNestErrorHandler({
61
- * fieldOrder: 'message-first',
62
- * fallbackReason: 'Error',
63
- * onUnhandledError: (err, c) => reportError(err, { requestId: c.get('requestId') }),
64
- * }),
65
- * );
66
- * ```
67
- */
68
- export function createNestErrorHandler(options = {}) {
69
- const { reasonPhrases = NEST_REASON_PHRASES, bareStatuses = [401], fieldOrder = 'statusCode-first', isHttpError = isHTTPException, onUnhandledError, internalServerErrorBody = { statusCode: 500, message: 'Internal server error' }, fallbackReason, } = options;
70
- return (err, c) => {
71
- if (isHttpError(err)) {
72
- // Escape hatch for a custom error body: render it verbatim.
73
- if (err.body !== undefined) {
74
- return c.json(err.body, err.status);
75
- }
76
- // reasonPhrases[status] is typed as string, but with noUncheckedIndexedAccess disabled it can be
77
- // undefined at runtime. The fallbackReason fallback for unregistered statuses is intentional.
78
- // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
79
- const reason = bareStatuses.includes(err.status) ? undefined : (reasonPhrases[err.status] ?? fallbackReason);
80
- if (reason === undefined) {
81
- return c.json({ statusCode: err.status, message: err.message }, err.status);
82
- }
83
- const body = fieldOrder === 'message-first'
84
- ? { message: err.message, error: reason, statusCode: err.status }
85
- : { statusCode: err.status, message: err.message, error: reason };
86
- return c.json(body, err.status);
87
- }
88
- try {
89
- onUnhandledError?.(err, c);
90
- }
91
- catch {
92
- // Reporting must never change the behavior of the error response.
93
- }
94
- if (findMysqlDriverError(err)) {
95
- logMysqlDriverError(err, 500);
96
- }
97
- else {
98
- console.error(err);
99
- }
100
- return c.json(internalServerErrorBody, 500);
101
- };
102
- }
103
- /**
104
- * Hono `notFound` handler that returns the canonical Express/NestJS unmatched-route 404 body.
105
- *
106
- * @remarks
107
- * Produces `{ message: "Cannot <METHOD> <path>", error: 'Not Found', statusCode: 404 }`, matching the
108
- * NestJS default 404 response so unmatched routes stay byte-identical.
109
- *
110
- * @param c - The Hono request context for the unmatched route.
111
- * @returns A 404 JSON response.
112
- *
113
- * @example
114
- * ```ts
115
- * app.notFound(nestNotFoundHandler);
116
- * ```
117
- */
118
- export function nestNotFoundHandler(c) {
119
- return c.json({ message: `Cannot ${c.req.method} ${new URL(c.req.url).pathname}`, error: 'Not Found', statusCode: 404 }, 404);
120
- }
@@ -1,24 +0,0 @@
1
- import type { SQL } from 'drizzle-orm';
2
- /**
3
- * `payment_failed` persistence helpers shared across the fleet.
4
- *
5
- * @remarks
6
- * `drizzle-orm` is a **peer** of this kit (the consumer resolves a single copy). These helpers only
7
- * build SQL fragments — the consumer's repository executes them, so it works regardless of the
8
- * consumer's DB access layer (`db.write` / `helper.query`).
9
- */
10
- /**
11
- * The `onDuplicateKeyUpdate.set` for an `insert(paymentFailed)…` that **never re-opens a `resolved`
12
- * row** — the idempotency guard against Stripe's delayed/out-of-order webhook redelivery leaving a
13
- * paid user with a permanent failure banner.
14
- *
15
- * @remarks
16
- * `receipt` is evaluated **before** `status` (SET is left→right and later columns see already-updated
17
- * ones), so both branch on the *pre-update* `payment_failed.status`. `type`/`user_id` are always
18
- * refreshed. Spread into the call:
19
- * `insert(paymentFailed).values(...).onDuplicateKeyUpdate({ set: reopenGuardedPaymentFailedSet() })`.
20
- *
21
- * Assumes the canonical column names (`type`, `user_id`, `status`, `receipt`) and the drizzle schema
22
- * property names (`type`, `userId`, `status`, `receipt`) used fleet-wide.
23
- */
24
- export declare function reopenGuardedPaymentFailedSet(): Record<'type' | 'userId' | 'status' | 'receipt', SQL>;
@@ -1,32 +0,0 @@
1
- import { sql } from 'drizzle-orm';
2
- /**
3
- * `payment_failed` persistence helpers shared across the fleet.
4
- *
5
- * @remarks
6
- * `drizzle-orm` is a **peer** of this kit (the consumer resolves a single copy). These helpers only
7
- * build SQL fragments — the consumer's repository executes them, so it works regardless of the
8
- * consumer's DB access layer (`db.write` / `helper.query`).
9
- */
10
- /**
11
- * The `onDuplicateKeyUpdate.set` for an `insert(paymentFailed)…` that **never re-opens a `resolved`
12
- * row** — the idempotency guard against Stripe's delayed/out-of-order webhook redelivery leaving a
13
- * paid user with a permanent failure banner.
14
- *
15
- * @remarks
16
- * `receipt` is evaluated **before** `status` (SET is left→right and later columns see already-updated
17
- * ones), so both branch on the *pre-update* `payment_failed.status`. `type`/`user_id` are always
18
- * refreshed. Spread into the call:
19
- * `insert(paymentFailed).values(...).onDuplicateKeyUpdate({ set: reopenGuardedPaymentFailedSet() })`.
20
- *
21
- * Assumes the canonical column names (`type`, `user_id`, `status`, `receipt`) and the drizzle schema
22
- * property names (`type`, `userId`, `status`, `receipt`) used fleet-wide.
23
- */
24
- export function reopenGuardedPaymentFailedSet() {
25
- return {
26
- type: sql `values(\`type\`)`,
27
- userId: sql `values(\`user_id\`)`,
28
- // resolved 行は理由も status も据え置き(再オープンしない)。status 代入より前に評価する。
29
- receipt: sql `IF(\`payment_failed\`.\`status\` = 'resolved', \`payment_failed\`.\`receipt\`, values(\`receipt\`))`,
30
- status: sql `IF(\`payment_failed\`.\`status\` = 'resolved', \`payment_failed\`.\`status\`, values(\`status\`))`,
31
- };
32
- }