@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 +73 -5
- package/dist/http/http-status.d.ts +7 -13
- package/dist/http/http-status.js +7 -13
- package/dist/index.d.ts +2 -0
- package/dist/index.js +1 -0
- package/dist/middleware/finalize-response.d.ts +13 -14
- package/dist/middleware/finalize-response.js +23 -54
- package/dist/middleware/maintenance.d.ts +113 -0
- package/dist/middleware/maintenance.js +171 -0
- package/package.json +1 -1
- package/dist/http/nest-error.d.ts +0 -149
- package/dist/http/nest-error.js +0 -120
- package/dist/payment/persistence.d.ts +0 -24
- package/dist/payment/persistence.js +0 -32
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
|
|
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` (
|
|
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
|
|
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
|
|
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
|
|
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
|
|
2
|
+
* Standard HTTP status codes, keyed by their conventional names.
|
|
3
3
|
*
|
|
4
4
|
* @remarks
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
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
|
-
|
|
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
|
-
|
|
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,
|
package/dist/http/http-status.js
CHANGED
|
@@ -1,11 +1,11 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* HTTP status codes
|
|
2
|
+
* Standard HTTP status codes, keyed by their conventional names.
|
|
3
3
|
*
|
|
4
4
|
* @remarks
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
3
|
+
* Create a Hono middleware that adds a weak ETag to buffered responses.
|
|
4
4
|
*
|
|
5
|
-
*
|
|
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
|
-
*
|
|
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`)
|
|
14
|
-
*
|
|
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
|
-
*
|
|
18
|
-
*
|
|
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
|
-
* // →
|
|
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
|
-
*
|
|
3
|
+
* Pre-built Hono ETag middleware in weak mode.
|
|
3
4
|
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
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
|
-
|
|
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
|
|
11
|
+
* Create a Hono middleware that adds a weak ETag to buffered responses.
|
|
24
12
|
*
|
|
25
|
-
*
|
|
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
|
-
*
|
|
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`)
|
|
34
|
-
*
|
|
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
|
-
*
|
|
38
|
-
*
|
|
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
|
-
* // →
|
|
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
|
-
|
|
69
|
-
|
|
70
|
-
|
|
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,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 {};
|
package/dist/http/nest-error.js
DELETED
|
@@ -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
|
-
}
|