@rdlabo/workers-hono-kit 0.10.6 → 0.11.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +1 -1
- package/README.md +40 -764
- package/dist/db/columns.d.ts +0 -10
- package/dist/db/columns.js +1 -5
- package/dist/db/database.d.ts +34 -9
- package/dist/db/database.js +49 -9
- package/dist/db/index.d.ts +2 -4
- package/dist/db/index.js +1 -2
- package/docs/api-business-time.md +33 -0
- package/docs/api-db.md +49 -0
- package/docs/api-offline.md +67 -0
- package/docs/api-root.md +71 -0
- package/docs/api-testing.md +16 -0
- package/docs/api.md +12 -0
- package/docs/cli.md +33 -0
- package/docs/data-layer.md +45 -0
- package/docs/development.md +32 -0
- package/docs/http-auth.md +36 -0
- package/docs/realtime-offline.md +27 -0
- package/docs/role-policies.md +48 -0
- package/docs/testing-operations.md +25 -0
- package/package.json +5 -4
- package/scripts/check-realtime-bundle.mjs +0 -0
- package/scripts/query-realtime-do-metrics.mjs +0 -0
- package/dist/db/decimal.d.ts +0 -35
- package/dist/db/decimal.js +0 -58
package/README.md
CHANGED
|
@@ -1,795 +1,71 @@
|
|
|
1
1
|
# @rdlabo/workers-hono-kit
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
`@rdlabo/workers-hono-kit` provides infrastructure-layer helpers for Hono on Cloudflare Workers. Domain logic, database schemas, and application-specific policy stay in the consuming application.
|
|
4
4
|
|
|
5
|
-
|
|
6
|
-
|
|
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
|
-
- **AWS Secrets Manager / STS AssumeRole / CloudFront signed URLs** via focused SigV4-signed `fetch` ([`aws4fetch`](https://github.com/mhart/aws4fetch)) or Web Crypto, avoiding a broad SDK dependency when only a few AWS APIs are needed.
|
|
9
|
-
- **Middleware**: `finalizeResponse` (weak ETag via `hono/etag`), `validate` (NestJS `ValidationPipe`-shaped 400), and zod number-coercion helpers.
|
|
10
|
-
- **Standard API errors**: `createHttpErrorHandler` / `notFoundHandler` / `HttpStatus`.
|
|
11
|
-
- **Deadlock retry** (`ER_LOCK_DEADLOCK` exponential backoff) and an optional **MySQL data layer** (`@rdlabo/workers-hono-kit/db`) for Hyperdrive + Drizzle.
|
|
12
|
-
- **AI Gateway**: route `@ai-sdk` models through the Cloudflare AI Gateway.
|
|
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.
|
|
15
|
-
- **Testing helpers** (`@rdlabo/workers-hono-kit/testing`): a Drizzle-migration-backed test database, in-memory Firebase fake, configurable test doubles, and Stripe fixtures.
|
|
16
|
-
- **Realtime helpers**: Hibernation WebSocket upgrade/broadcast/close, legacy SSE bridging, and Durable Object retry policy.
|
|
17
|
-
|
|
18
|
-
## Install
|
|
19
|
-
|
|
20
|
-
```bash
|
|
5
|
+
```sh
|
|
21
6
|
npm install @rdlabo/workers-hono-kit
|
|
22
7
|
```
|
|
23
8
|
|
|
24
|
-
|
|
9
|
+
Install only the peer dependencies required by the features you use:
|
|
25
10
|
|
|
26
|
-
```
|
|
27
|
-
# Core
|
|
11
|
+
```sh
|
|
12
|
+
# Core HTTP, validation, Firebase, and AWS helpers
|
|
28
13
|
npm install hono zod @hono/zod-validator jose aws4fetch
|
|
29
14
|
|
|
30
|
-
#
|
|
31
|
-
npm install drizzle-orm mysql2
|
|
32
|
-
npm install ai ai-gateway-provider # createAiGatewayProvider
|
|
33
|
-
```
|
|
34
|
-
|
|
35
|
-
`stripe` is bundled as a direct dependency, so the Stripe helpers work without an extra install.
|
|
36
|
-
|
|
37
|
-
> **Compiled ESM, with types.** The package is published as compiled ES modules (`./dist/*.js`) plus
|
|
38
|
-
> declaration files (`./dist/*.d.ts`) via the `exports` field. It depends only on Web-standard APIs
|
|
39
|
-
> (`fetch`, `crypto.subtle`, `Response`) available on Cloudflare Workers (`workerd`) and other edge
|
|
40
|
-
> runtimes, and requires Node.js ≥ 20 for tooling. Four entry points are exposed:
|
|
41
|
-
>
|
|
42
|
-
> | Subpath | Import | Use |
|
|
43
|
-
> | --- | --- | --- |
|
|
44
|
-
> | `.` | `@rdlabo/workers-hono-kit` | Web-standard helpers (middleware, HTTP, Firebase, AWS, AI, Stripe, KV). |
|
|
45
|
-
> | `./db` | `@rdlabo/workers-hono-kit/db` | MySQL data layer (mysql2 + Drizzle). |
|
|
46
|
-
> | `./business-time` | `@rdlabo/workers-hono-kit/business-time` | JST business-time API (`toBusinessDateTime` / `normalizeBusinessDate` / `formatBusinessDateTime`, etc.). |
|
|
47
|
-
> | `./offline` | `@rdlabo/workers-hono-kit/offline` | Table-agnostic REST/DB method converters plus replica wire and clock helpers. |
|
|
48
|
-
> | `./testing` | `@rdlabo/workers-hono-kit/testing` | Test helpers (mysql2 + Drizzle + fakes/fixtures). |
|
|
49
|
-
|
|
50
|
-
## API
|
|
51
|
-
|
|
52
|
-
### Root — `@rdlabo/workers-hono-kit`
|
|
53
|
-
|
|
54
|
-
| Export | Description |
|
|
55
|
-
| --- | --- |
|
|
56
|
-
| `finalizeResponse()` | Middleware that adds a weak `ETag` (delegates to `hono/etag`; also handles `If-None-Match` → `304`). |
|
|
57
|
-
| `validate(target, schema, options?)` | Zod validator → NestJS `ValidationPipe`-shaped `400` (`{ statusCode, message[], error }`). `options.onValidationError(err, c)` to report (e.g. Sentry). |
|
|
58
|
-
| `createValidate({ sentry? })` | Bound `validate` factory. Pass `sentry` on Sentry apps; omit for console-only (review, cbs-ai). |
|
|
59
|
-
| `createSentryValidate(sentry)` | **Deprecated** — use `createValidate({ sentry })`. |
|
|
60
|
-
| `zNum` / `zNumWithDefault` / `zNumOptional` / `zNumNullable` | Number-coercion zod schemas (mirror class-transformer `@Transform`). |
|
|
61
|
-
| `getAuthenticationSecret<T>(options, secretId)` / `AwsSecretsOptions` | Fetch a secret from AWS Secrets Manager (SigV4 `fetch`, per-isolate cache). |
|
|
62
|
-
| `getTemporaryCredentials(options)` / `GetTemporaryCredentialsOptions` / `StsCredentials` | STS `AssumeRole` via SigV4 `fetch` (global `sts.amazonaws.com`); returns temporary credentials for browser S3 uploads. |
|
|
63
|
-
| `getCloudFrontSignedUrl(url, privateKeyPem, keyPairId, dateLessThan)` | CloudFront signed URL (canned policy, RSA-SHA1, URL-safe base64) — Web Crypto reimpl of `@aws-sdk/cloudfront-signer`, byte-identical query order. |
|
|
64
|
-
| `JoseFirebaseVerifier` / `FirebaseVerifier` / `DecodedIdToken` | Firebase ID-token verification (`verifyIdToken`, `getUser`, `deleteUser`). |
|
|
65
|
-
| `createRemoteFirebaseVerifier(projectId)` | Convenience factory: production verifier with a cached remote JWKS (verification only). |
|
|
66
|
-
| `createServiceAccountVerifier(serviceAccountJson)` | Cached verifier built from a service-account JSON, **with `IdentityToolkit`** (getUser/deleteUser). One per isolate, re-created only when the SA JSON changes. |
|
|
67
|
-
| `IdentityToolkit` / `ServiceAccount` / `SECURETOKEN_JWK_URL` | Identity Toolkit REST client + constants for `getUser` / `deleteUser`. |
|
|
68
|
-
| `retryWhenDeadlock(fn, retries?, delay?)` | Retry on MySQL `ER_LOCK_DEADLOCK` with exponential backoff. |
|
|
69
|
-
| `getUserProtocol(c)` / `IUserProtocol` | Read client IP / UA (`CF-Connecting-IP` → `X-Forwarded-For`). |
|
|
70
|
-
| `getAppInfo(c)` / `AppInfo` | Read `x-amz-meta-version` / `x-amz-meta-uuid`. |
|
|
71
|
-
| `resolveAppEnv(env)` / `isProductionEnv(env)` / `AppEnv` | Resolve `'development'` / `'production'` from `env.APP_ENV` (defaults to `'production'` for safety). |
|
|
72
|
-
| `HttpStatus` | Standard HTTP status code enum (IANA registry). |
|
|
73
|
-
| `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). |
|
|
74
|
-
| `createAppErrorHandler(options?)` / `CreateAppErrorHandlerOptions` | Standard `app.onError`: {@link createQueryFailedErrorHandler} + default {@link classifyGenericMysqlDriverError} + optional `sentry` (Sentry apps), `getReportError` / `reportError` (tests / container), or neither (no external reporting). |
|
|
75
|
-
| `createQueryFailedErrorHandler(options)` / `QueryFailedClassifier` / `ClassifiedDbError` | Lower-level compose when you need full control over `classify` + `onUnhandledError` without defaults. |
|
|
76
|
-
| `classifyGenericMysqlDriverError(err)` | Default classifier: any mysql2 driver error → `{ statusCode: 500, message: 'Internal server error' }`; non-DB errors → `null`. |
|
|
77
|
-
| `findMysqlDriverError(err)` / `logMysqlDriverError(err, statusCode)` | Low-level mysql2 driver-error detection (follows `err.cause`) and structured logging. For custom classifiers (e.g. odss). |
|
|
78
|
-
| `notFoundHandler(c)` | `app.notFound()` handler with `{ message: 'Cannot METHOD path', error, statusCode }` 404 body. |
|
|
79
|
-
| `normalizeTrailingSlash(request)` | Strip trailing slash(es) from the request URL before routing (Express/Nest parity). Does **not** 301-redirect — preserves POST/PUT/DELETE bodies. |
|
|
80
|
-
| `HTTP_ERROR_PHRASES` | `{ 400, 401, 403, 404 }` → standard `error` field phrases. |
|
|
81
|
-
| `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. |
|
|
82
|
-
| `createIdentityAuthFailureBody()` / `createLegacyIdentityAuthFailureBody()` / `createAuthFailureBody(scope, code, message)` / `AuthFailureScope` | Stable wire contract for distinguishing a lost global identity (`identity`) from recent-login (`reauthentication`) and feature credential (`credential`) failures. The legacy helper tags products whose installed clients still require auth failure as `403`. |
|
|
83
|
-
| `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`. |
|
|
84
|
-
| `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`. |
|
|
85
|
-
| `ErrorReporter` / `ErrorReportContext` | Types for a `reportError`-style unhandled-error reporter (e.g. wired to Sentry), paired with `createHttpErrorHandler`'s `onUnhandledError`. |
|
|
86
|
-
| `createSentryErrorReporter(sentry)` / `SentryExceptionReporterLike` | Build an `ErrorReporter` that forwards to Sentry with an optional `request_id` tag (no hard `@sentry/cloudflare` dependency). |
|
|
87
|
-
| `DeferExecutor` / `defaultDefer` / `createWaitUntilDefer(ctx)` | Fire-and-forget executor for Workers: both variants log background rejections without propagating them; `createWaitUntilDefer` also registers work via `ctx.waitUntil`. |
|
|
88
|
-
| `configureHibernationAutoResponse` / `upgradeHibernationWebSocket` / `broadcastHibernationWebSockets` | Hibernation WebSocket room primitives: runtime ping/pong without waking JavaScript, attachment-before-accept upgrade, and broadcast through sockets restored by `getWebSockets()`. |
|
|
89
|
-
| `acknowledgeHibernationWebSocketClose` / `closeHibernationWebSocket` | Safe close helpers, including normalization of reserved received-only close codes. |
|
|
90
|
-
| `retryDurableObjectOperation(operation, options?)` / `isRetryableDurableObjectError(error)` | Retry idempotent DO work only for `retryable && !overloaded`, with jittered exponential backoff. `operation` runs per attempt so callers create a fresh stub after an exception. |
|
|
91
|
-
| `createIdempotencyInput(...)` / `runIdempotentMutation(...)` | Canonical payload hashing and a transaction-bound mutation state machine. Missing keys preserve legacy behavior; replay/conflict/in-flight semantics are shared while each app owns its schema and ORM adapter. |
|
|
92
|
-
| `withIdempotencyHttpErrors(run)` | Maps only standard idempotency failures to 400/409/503 and rethrows unrelated failures. |
|
|
93
|
-
| `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`). |
|
|
94
|
-
| `KVCache` / `KVNamespace` / `KVCacheOptions` / `KVCacheErrorContext` / `KVCacheOperation` | Workers-KV cache-aside helper (key `appName+version+table_type_column`, sha256 for string ids, TTL clamped ≥60s). Set `appName` / `version` per application; optional `onError(error, context)` observes fail-soft read/parse/serialize/write/delete failures. Context contains only the operation and logical table, not cache types, keys, ids, or values. |
|
|
95
|
-
| `createStripeClient(secret, opts?)` / `verifyStripeWebhook(...)` / `CreateStripeClientOptions` | Workers-native Stripe client (fetch transport) + async webhook verification (SubtleCrypto). `apiVersion` optional (pin to a fixed Stripe API version). |
|
|
96
|
-
| `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`. |
|
|
97
|
-
| `stripeFailureMessageJa(reason)` | Render a `StripeFailureReason` (or `null`) as a single user-facing Japanese sentence (`decline_code` > `code`; fraud codes masked; unknown → generic). |
|
|
98
|
-
| `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). |
|
|
99
|
-
| `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. |
|
|
100
|
-
| `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. |
|
|
101
|
-
| `serializeIapFailureReason(reason)` / `IapFailureReason` | Serialize an IAP reason (`billing_retry` / `auto_renew_off` / `subscription_canceled` / `subscription_gone` + provider codes) directly, without the source/timestamp wrapper. |
|
|
102
|
-
| `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`. |
|
|
103
|
-
| `iapFailureKey(input)` | Provider-native `payment_failed.recursions_id`: iOS `${original_transaction_id}:${expires_date_ms}`, Android `${orderId}` (provider is in the `type` column). |
|
|
104
|
-
| `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`). |
|
|
105
|
-
| `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`. |
|
|
106
|
-
| `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). |
|
|
107
|
-
| `processBatch(batch, handler, options?)` / `isNonRetryableQueueError(error)` / `NonRetryableQueueErrorLike` / `MessageBatchLike` / `QueueMessageLike` / `ProcessBatchOptions` / `ProcessBatchResult` | Process a queue batch with bounded concurrency. Errors explicitly tagged with `queueDisposition: 'discard'` are reported and acked as permanent failures; all other errors are retried. |
|
|
108
|
-
| `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, except permanent failures which are reported immediately). |
|
|
109
|
-
| `assertStripeCustomerUpdated(options)` | Preserve the shared Stripe UPDATE→existence-check algorithm. `createNotFoundError(customerId)` can supply a domain-specific error without forking the algorithm. |
|
|
110
|
-
| `ExecutionContextLike` | Minimal `waitUntil`-only Workers execution context shape used by lifecycle-compatible APIs and deferred work helpers. |
|
|
111
|
-
|
|
112
|
-
Permanent Queue failures must opt in with the Queue-specific marker; unrelated `retryable` fields are ignored:
|
|
113
|
-
|
|
114
|
-
```ts
|
|
115
|
-
import type { NonRetryableQueueErrorLike } from '@rdlabo/workers-hono-kit';
|
|
116
|
-
|
|
117
|
-
class CustomerLinkMissingError extends Error implements NonRetryableQueueErrorLike {
|
|
118
|
-
readonly queueDisposition = 'discard' as const;
|
|
119
|
-
}
|
|
120
|
-
```
|
|
121
|
-
|
|
122
|
-
### Data layer — `@rdlabo/workers-hono-kit/db`
|
|
123
|
-
|
|
124
|
-
Requires the `drizzle-orm` and `mysql2` peers. Reads run against a replica via raw SQL; writes/transactions run against the primary through the Drizzle ORM with deadlock retry. The kit deliberately does not depend on the ORM's type identity — you pass the Drizzle instance in.
|
|
125
|
-
|
|
126
|
-
| Export | Description |
|
|
127
|
-
| --- | --- |
|
|
128
|
-
| `createHyperdriveDatabase(options)` | `DisposableDatabase` that lazily opens primary/replica connections from Hyperdrive bindings per request. Workers cleans them up at invocation end; the legacy `dispose()` is a no-op. |
|
|
129
|
-
| `createMysqlDatabase(options)` | Assemble a `Database` from an already-connected Drizzle ORM + replica `QueryRunner`. |
|
|
130
|
-
| `databaseFrom(orm, replica)` | Build a `Database` from an existing Drizzle instance + replica handle. |
|
|
131
|
-
| `Database` / `DisposableDatabase` / `QueryRunner` / `TxOf` | The `read` / `write` / `transaction` API and its supporting types. |
|
|
132
|
-
| `hyperdriveConnectionOptions(hyperdrive, overrides?)` / `HyperdriveLike` / `ExecutionContextLike` | Build mysql2 `createConnection` options from a Hyperdrive binding (`disableEval`, `decimalNumbers`, `timezone '+09:00'` by default). `timezone` controls mysql2's JavaScript `Date` conversion; it does not change the MySQL session timezone. |
|
|
133
|
-
| `withMysqlConnections(...)` | Open primary/replica connections in parallel and run a function. Workers cleans them up at invocation end. |
|
|
134
|
-
| `retryWhenDeadlock(fn, retries?, delay?)` | Same deadlock-retry helper as the root export. |
|
|
135
|
-
| `insertIdOf` / `affectedRowsOf` / `insertedIdsOf` / `DzWriteResult` | Extract `insertId` / `affectedRows` (and derive contiguous bulk-insert ids) from a mysql2 write result. |
|
|
136
|
-
| `toJstDate` / `jstTimestampParams` / `jstDatetimeParams` / `jstDateParams` | JST date/time normalization params (advanced use). |
|
|
137
|
-
| `MYSQL_TIMEZONE` | Default mysql2 connection `timezone` (`'+09:00'`) for the JST DB deployment. |
|
|
138
|
-
| `jstTimestamp` / `jstDatetime` / `jstDate` / `decimalNumber` | Drizzle column helpers (no repo-side wrapper needed). |
|
|
139
|
-
| `jstOnUpdateNow` | SQL expression for `ON UPDATE CURRENT_TIMESTAMP`. The `jstTimestamp` customType (and friends) do not support `.onUpdateNow()`, so pair it with `.$onUpdateFn(() => jstOnUpdateNow(fsp))`. |
|
|
140
|
-
| `coerceDecimalNumber` / `decimalNumberParams` | DECIMAL normalization params (the `decimalNumber` column helper is usually enough). |
|
|
141
|
-
| `DRIZZLE_ORM_OPTIONS` / `honoDrizzleConfig(options)` / `HonoDrizzleConfigOptions` | Shared Drizzle casing (`snake_case`) for both the runtime `drizzle()` call and `drizzle.config.ts`, keeping config ↔ runtime in sync. |
|
|
142
|
-
| `resolveDbSecret()` / `ResolvedDbSecret` | Resolve DB connection info from the `DB_SECRET` env var (an AWS RDS managed-secret JSON string) for CI migrate / local tooling. Returns `undefined` when `DB_SECRET` is unset; throws on invalid JSON or a missing required key. |
|
|
143
|
-
| `baselineMigrations(options)` / `readBaselineEntry(migrationsFolder)` / `BaselineMigrationsOptions` / `BaselineResult` / `BaselineEntry` | Brownfield first-deploy helper: mark an existing `0000_*` migration as applied without re-running DDL. |
|
|
144
|
-
|
|
145
|
-
#### Drizzle column helpers (`jstTimestamp` / `decimalNumber`, etc.)
|
|
146
|
-
|
|
147
|
-
- `drizzle-orm` is a **peer** only. The kit does not include `drizzle-orm` as a dependency (even after publishing, it uses the consumer's single copy).
|
|
148
|
-
- The consumer just keeps `drizzle-orm` in its `dependencies` as usual. **No `overrides` in `package.json` are needed.**
|
|
149
|
-
- The npm-published artifact contains no `devDependencies`, so installing it does not add a kit-specific `drizzle-orm` (there is only the one peer copy).
|
|
150
|
-
- The column helpers `import` the consumer's `drizzle-orm` at runtime, and the types are the `customType` inference as-is (`MySqlCustomColumnBuilder<…>`). No `any` is used, so the column's semantic type propagates to the consumer table's `$inferSelect`.
|
|
151
|
-
- **Precondition: resolve drizzle to a single copy.** Drizzle's `SQL` is a **nominal** type carrying a private field `shouldInlineParams`, so if the kit and the consumer resolve different copies, `jstTimestamp(…).default(sql\`…\`)` fails the whole schema with `TS2345 separate declarations of a private property 'shouldInlineParams'`. Under `file:`-link development, `drizzle-orm` nests under the kit and becomes a second copy, so **pin `drizzle-orm` to the consumer's own single copy in `tsconfig.json`**:
|
|
152
|
-
|
|
153
|
-
```jsonc
|
|
154
|
-
// tsconfig.json compilerOptions (merge with existing paths if any)
|
|
155
|
-
"paths": {
|
|
156
|
-
"drizzle-orm": ["./node_modules/drizzle-orm"],
|
|
157
|
-
"drizzle-orm/*": ["./node_modules/drizzle-orm/*"]
|
|
158
|
-
}
|
|
159
|
-
```
|
|
160
|
-
|
|
161
|
-
With `moduleResolution: "Bundler"`, `baseUrl` is not required (if `baseUrl` is already set, drop the leading `./`). On the published package (a single copy) these `paths` are harmless. **No `overrides` needed.**
|
|
162
|
-
- When developing against the kit via a direct `file:` link, run `npm install` in the kit repo itself to satisfy its peers (do not add `overrides` on the consumer side).
|
|
163
|
-
|
|
164
|
-
**`CURRENT_TIMESTAMP` vs the connection `timezone:'+09:00'`**
|
|
15
|
+
# Data and testing entry points
|
|
16
|
+
npm install drizzle-orm mysql2
|
|
165
17
|
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
| The app binds a `Date` (INSERT/UPDATE) | mysql2 + connection `timezone:'+09:00'` | Treated as JST on the wire (`datetime-wire` test) |
|
|
169
|
-
| `DEFAULT CURRENT_TIMESTAMP` / `ON UPDATE CURRENT_TIMESTAMP` | The MySQL server (session `time_zone`) | A **separate path** from the connection option. JST if the RDS `time_zone` is `+09:00`, UTC if UTC |
|
|
170
|
-
|
|
171
|
-
`jstTimestamp` / `jstDatetime` only handle read/write pass-through and DATE normalization; they do not change the timezone of server-side defaults. For columns that need `ON UPDATE`, keep the DDL intent with `.$onUpdateFn(() => jstOnUpdateNow(6))`.
|
|
172
|
-
|
|
173
|
-
### Business time — `@rdlabo/workers-hono-kit/business-time`
|
|
174
|
-
|
|
175
|
-
String-level JST business-time conversions (Workers UTC instant ↔ business calendar date / date-time),
|
|
176
|
-
with **no `mysql2` / `drizzle-orm` dependency**. This is a different layer from the `./db` column helpers
|
|
177
|
-
(which handle the MySQL wire format): the DB stays on JST, and the app handles JST explicitly through
|
|
178
|
-
this module instead of relying implicitly on the connection `timezone`.
|
|
179
|
-
|
|
180
|
-
| Export | Description |
|
|
181
|
-
| --- | --- |
|
|
182
|
-
| `today(ref?)` | The JST business calendar date (`YYYY-MM-DD`) of `ref` (defaults to now). |
|
|
183
|
-
| `toBusinessDate(instant)` | UTC instant → JST business calendar date (`YYYY-MM-DD`). |
|
|
184
|
-
| `normalizeBusinessDate(value)` | Normalize a `string` / `Date` / nullish to `YYYY-MM-DD`; a `YYYY-MM-DD` string passes through unchanged, nullish/empty/invalid → `null`. |
|
|
185
|
-
| `toBusinessDateTime(instant)` | UTC instant → JST business date-time (`YYYY-MM-DD HH:mm:ss`). |
|
|
186
|
-
| `parseBusinessDateTime(value)` | JST business date-time string → UTC instant (accepts a space or `T` separator). |
|
|
187
|
-
| `formatBusinessDateTime(instant, pattern?)` | Format an instant in the business TZ (Nest `helper.formatDate`-compatible tokens). |
|
|
188
|
-
| `startOfBusinessDay(date)` / `endOfBusinessDay(date)` | UTC instant of `00:00:00` / `23:59:59` on a JST business date. |
|
|
189
|
-
| `businessDateTimeInstant(date, time)` | JST business date + wall-clock time → UTC instant. |
|
|
190
|
-
| `addBusinessDays(date, days)` | Add calendar days to a JST business date. |
|
|
191
|
-
| `ageOnBusinessDate(birthDate, asOfDate?)` | Full years of age on a business date (`asOfDate` defaults to `today()`). |
|
|
192
|
-
| `DEFAULT_BUSINESS_DATETIME_PATTERN` | Default `formatBusinessDateTime` pattern (`YYYY-MM-DDThh:mm:ss`). |
|
|
193
|
-
| `BUSINESS_TIMEZONE` / `BusinessDate` / `BusinessDateTime` | JST timezone constant and the business-date / date-time string types. |
|
|
194
|
-
|
|
195
|
-
```ts
|
|
196
|
-
import {
|
|
197
|
-
toBusinessDate,
|
|
198
|
-
toBusinessDateTime,
|
|
199
|
-
formatBusinessDateTime,
|
|
200
|
-
addBusinessDays,
|
|
201
|
-
} from '@rdlabo/workers-hono-kit/business-time';
|
|
202
|
-
|
|
203
|
-
const now = new Date('2026-07-05T21:00:00Z');
|
|
204
|
-
toBusinessDate(now); // '2026-07-06' (JST)
|
|
205
|
-
toBusinessDateTime(now); // '2026-07-06 06:00:00'
|
|
206
|
-
formatBusinessDateTime(now); // '2026-07-06T06:00:00'
|
|
207
|
-
addBusinessDays('2026-07-06', 3); // '2026-07-09'
|
|
18
|
+
# AI Gateway
|
|
19
|
+
npm install ai ai-gateway-provider
|
|
208
20
|
```
|
|
209
21
|
|
|
210
|
-
|
|
22
|
+
Stripe is a direct dependency of the kit. The package is compiled ESM with declarations, uses Web-standard APIs such as `fetch`, `crypto.subtle`, and `Response`, and requires Node.js 20 or later for tooling.
|
|
211
23
|
|
|
212
|
-
|
|
213
|
-
replica wire values. This subpath does not define table projections, Zod object shapes,
|
|
214
|
-
public-column allowlists, schema hashes, or domain rules; those remain in each Hono application.
|
|
24
|
+
## Entry points
|
|
215
25
|
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
26
|
+
| Import | Responsibility |
|
|
27
|
+
| ---------------------------------------- | ------------------------------------------------------------------------------ |
|
|
28
|
+
| `@rdlabo/workers-hono-kit` | HTTP, auth, errors, Firebase, AWS, AI, Stripe, KV, queues, realtime primitives |
|
|
29
|
+
| `@rdlabo/workers-hono-kit/db` | Hyperdrive, MySQL, Drizzle, migrations, JST columns |
|
|
30
|
+
| `@rdlabo/workers-hono-kit/business-time` | JST business dates and date-times |
|
|
31
|
+
| `@rdlabo/workers-hono-kit/offline` | Offline replica wire, cursor, journal, and compatibility contracts |
|
|
32
|
+
| `@rdlabo/workers-hono-kit/realtime` | Durable Object WebSocket and retry helpers |
|
|
33
|
+
| `@rdlabo/workers-hono-kit/testing` | Test databases, auth helpers, fakes, and Stripe fixtures |
|
|
220
34
|
|
|
221
|
-
|
|
222
|
-
| --- | --- |
|
|
223
|
-
| `defineRestDbMethodConverter(converter)` | Type a product-owned, pure `MethodScheme ↔ TableScheme` converter without hiding HTTP or persistence side effects. |
|
|
224
|
-
| `RestDbMethodConverter` | Product-owned converter contract. Select and insert bundles may differ; every represented table and column remains required. |
|
|
225
|
-
| `CompleteRestDbTableScheme` | Compile-time lock requiring every represented table key and row column. |
|
|
226
|
-
| `toReplicaIsoDatetime(value)` | `Date` / datetime string → canonical UTC ISO-8601 wire value. |
|
|
227
|
-
| `toReplicaDateOnly(value)` | `Date` / date string / `null` → canonical `YYYY-MM-DD` / `null`. |
|
|
228
|
-
| `replicaTimestampMs(value)` | Replica datetime → epoch milliseconds for legacy DTOs. |
|
|
229
|
-
| `toTinyIntFlag(value)` / `fromTinyIntFlag(value)` | Boolean-like value ↔ numeric tinyint flag. |
|
|
230
|
-
| `replicaNowIso(clock?)` | Injectable wall clock → canonical UTC ISO-8601 wire value. |
|
|
35
|
+
Subpath imports keep optional database and testing dependencies out of the root runtime surface.
|
|
231
36
|
|
|
232
|
-
|
|
233
|
-
import {
|
|
234
|
-
defineRestDbMethodConverter,
|
|
235
|
-
replicaNowIso,
|
|
236
|
-
toReplicaIsoDatetime,
|
|
237
|
-
} from '@rdlabo/workers-hono-kit/offline';
|
|
37
|
+
## Documentation
|
|
238
38
|
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
39
|
+
- [HTTP and Authentication](https://docs.rdlabo.dev/projects/workers-hono-kit/docs/http-auth)
|
|
40
|
+
- [Data Layer](https://docs.rdlabo.dev/projects/workers-hono-kit/docs/data-layer)
|
|
41
|
+
- [Realtime and Offline](https://docs.rdlabo.dev/projects/workers-hono-kit/docs/realtime-offline)
|
|
42
|
+
- [Testing and Operations](https://docs.rdlabo.dev/projects/workers-hono-kit/docs/testing-operations)
|
|
43
|
+
- [API Reference](https://docs.rdlabo.dev/projects/workers-hono-kit/docs/api)
|
|
243
44
|
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
...foods[0],
|
|
247
|
-
allergens: allergens.map(({ value }) => value),
|
|
248
|
-
}),
|
|
249
|
-
toTableScheme: (method) => ({
|
|
250
|
-
foods: [{ id: method.id, memo: method.memo ?? null }],
|
|
251
|
-
allergens: method.allergens.map((value) => ({ threadId: method.id, value })),
|
|
252
|
-
}),
|
|
253
|
-
});
|
|
254
|
-
```
|
|
255
|
-
|
|
256
|
-
`toTableScheme` requires every key represented by its DB row types. This includes nullable/default
|
|
257
|
-
columns that Drizzle marks optional in `$inferInsert`; write `memo: method.memo ?? null` instead of
|
|
258
|
-
omitting `memo`. If a REST method intentionally does not own an `AUTO_INCREMENT` column, remove it
|
|
259
|
-
from that method's product-owned table scheme explicitly:
|
|
45
|
+
<!-- rdlabo-docs-omit -->
|
|
46
|
+
**Full documentation:** [https://docs.rdlabo.dev/projects/workers-hono-kit](https://docs.rdlabo.dev/projects/workers-hono-kit)
|
|
260
47
|
|
|
261
|
-
|
|
262
|
-
type CreateTables = {
|
|
263
|
-
foods: Omit<typeof foods.$inferInsert, 'id'>[];
|
|
264
|
-
};
|
|
265
|
-
```
|
|
48
|
+
## Prerelease channels
|
|
266
49
|
|
|
267
|
-
|
|
268
|
-
confirmed response before it is stored as `server_id`.
|
|
50
|
+
An open, non-draft pull request can be published to the npm `beta` dist-tag after its `Validation` and `Package Candidate` workflows pass. A repository owner or maintainer must add a comment whose entire body is:
|
|
269
51
|
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
two-generic form remains valid.
|
|
273
|
-
|
|
274
|
-
```ts
|
|
275
|
-
defineRestDbMethodConverter<Method, SelectTables, InsertTables, { userId: number }>({
|
|
276
|
-
toMethodScheme: ({ foods, allergens }) => composeFood(foods, allergens),
|
|
277
|
-
toTableScheme: (method, { userId }) => ({
|
|
278
|
-
foods: [{ userId, name: method.name, memo: method.memo ?? null }],
|
|
279
|
-
allergens: method.allergens.map((value) => ({ value })),
|
|
280
|
-
}),
|
|
281
|
-
});
|
|
52
|
+
```text
|
|
53
|
+
/beta
|
|
282
54
|
```
|
|
283
55
|
|
|
284
|
-
|
|
285
|
-
replicaNowIso(() => new Date('2026-07-23T10:00:00Z')); // '2026-07-23T10:00:00.000Z'
|
|
286
|
-
toReplicaIsoDatetime('2026-07-23T19:00:00+09:00'); // '2026-07-23T10:00:00.000Z'
|
|
287
|
-
```
|
|
56
|
+
The request authorizes only the pull request head SHA that existed when the comment was added. The workflow revalidates the owner or maintainer permission and head SHA immediately before publishing. Any new commit requires CI to pass again and a fresh owner or maintainer `/beta` comment. Fork pull requests are supported. Pull requests that change a release-gating workflow cannot be beta-published until those workflow changes land on `main`.
|
|
288
57
|
|
|
289
|
-
|
|
58
|
+
Beta versions use `<base>-beta.pr<PR number>.sha<12-character SHA>`. The candidate is built in a read-only workflow without npm publishing credentials. The privileged release workflow publishes only the validated immutable package artifact with lifecycle scripts disabled. A notification failure cannot invalidate a successful npm publish.
|
|
290
59
|
|
|
291
|
-
|
|
60
|
+
When a pull request is merged into `main`, it is automatically published to `beta` only after the required CI and `Package Candidate` succeed for that exact merge commit. Direct pushes to `main` do not publish a candidate.
|
|
292
61
|
|
|
293
|
-
|
|
294
|
-
| --- | --- |
|
|
295
|
-
| `createTestDb(options)` / `TestDb` / `CreateTestDbOptions` / `TestDbConnection` | Test database built from committed Drizzle migrations as the single source of truth: `resetSchema` / `createTestPool` / `truncateAll` / `seed` / `mysqlReachable`. |
|
|
296
|
-
| `FakeFirebaseVerifier` | In-memory `FirebaseVerifier` for offline route tests (`register` / `verifyIdToken` / `getUser` / `deleteUser`). |
|
|
297
|
-
| `createPoolDatabase(options)` / `CreatePoolDatabaseOptions` | A `Database` backed by a single pool used as both primary and replica. |
|
|
298
|
-
| `createNoopDatabase()` | A `Database` stub that throws on `write` / `transaction` to catch accidental DB use in DB-less routes. |
|
|
299
|
-
| `authHeaders(token, opts?)` | Build interceptor-compatible auth headers for requests. |
|
|
300
|
-
| `registerFirebaseToken(firebase, uid, record?, token?)` | Register a token in a `FakeFirebaseVerifier` (no DB). |
|
|
301
|
-
| `provisionUser(pool, firebase, opts)` | Register a token and provision a conventional `users(id, firebase_uid, agree)` row; returns the user id (idempotent). |
|
|
302
|
-
| `configurableFake(impl, name?)` | Build a test double from a partial implementation; un-stubbed members throw `"${name}.${method} not configured"`. |
|
|
303
|
-
| `fakeApiList` / `fakePaymentIntent` / `fakeStripeEvent` / `fakeCheckoutSession` / `fakeCustomer` / `fakePrice` / `fakeSubscription` | Stripe object fixtures with sensible defaults, overridable per test. |
|
|
304
|
-
| `fakeKv()` / `fakeQueue()` / `FakeQueue` | In-memory Workers KV / Queues producer doubles (`sent` + `batchCount` on queues for subrequest-bound assertions). |
|
|
305
|
-
|
|
306
|
-
## Usage
|
|
307
|
-
|
|
308
|
-
### Response finalization (ETag)
|
|
309
|
-
|
|
310
|
-
```ts
|
|
311
|
-
import { Hono } from 'hono';
|
|
312
|
-
import { finalizeResponse } from '@rdlabo/workers-hono-kit';
|
|
313
|
-
|
|
314
|
-
const app = new Hono();
|
|
315
|
-
app.use('*', finalizeResponse());
|
|
316
|
-
```
|
|
317
|
-
|
|
318
|
-
### Request validation
|
|
319
|
-
|
|
320
|
-
```ts
|
|
321
|
-
import { validate } from '@rdlabo/workers-hono-kit';
|
|
322
|
-
import { z } from 'zod';
|
|
323
|
-
|
|
324
|
-
app.post('/users', validate('json', z.object({ name: z.string() })), (c) => {
|
|
325
|
-
const body = c.req.valid('json'); // typed & validated
|
|
326
|
-
return c.json(body, 201);
|
|
327
|
-
});
|
|
328
|
-
|
|
329
|
-
// Report validation failures (response is unchanged):
|
|
330
|
-
validate('json', schema, {
|
|
331
|
-
onValidationError: (err, c) => Sentry.captureException(err),
|
|
332
|
-
});
|
|
333
|
-
```
|
|
334
|
-
|
|
335
|
-
`param` / `query` values arrive as strings — coerce numbers with the zod helpers:
|
|
336
|
-
|
|
337
|
-
```ts
|
|
338
|
-
import { zNum, zNumOptional } from '@rdlabo/workers-hono-kit';
|
|
339
|
-
|
|
340
|
-
const Params = z.object({ id: zNum(z.number().int()), page: zNumOptional() });
|
|
341
|
-
```
|
|
342
|
-
|
|
343
|
-
### Firebase ID-token verification
|
|
344
|
-
|
|
345
|
-
```ts
|
|
346
|
-
import { createRemoteFirebaseVerifier } from '@rdlabo/workers-hono-kit';
|
|
347
|
-
|
|
348
|
-
const verifier = createRemoteFirebaseVerifier(projectId);
|
|
349
|
-
const decoded = await verifier.verifyIdToken(idToken); // { uid, email, ... }
|
|
350
|
-
```
|
|
351
|
-
|
|
352
|
-
With `getUser` / `deleteUser` (needs a service account):
|
|
353
|
-
|
|
354
|
-
```ts
|
|
355
|
-
import { createRemoteJWKSet } from 'jose';
|
|
356
|
-
import { JoseFirebaseVerifier, IdentityToolkit, SECURETOKEN_JWK_URL } from '@rdlabo/workers-hono-kit';
|
|
357
|
-
|
|
358
|
-
const verifier = new JoseFirebaseVerifier({
|
|
359
|
-
projectId,
|
|
360
|
-
keyResolver: createRemoteJWKSet(new URL(SECURETOKEN_JWK_URL)),
|
|
361
|
-
identity: new IdentityToolkit(serviceAccount),
|
|
362
|
-
});
|
|
363
|
-
```
|
|
364
|
-
|
|
365
|
-
### AWS Secrets Manager
|
|
366
|
-
|
|
367
|
-
```ts
|
|
368
|
-
import { getAuthenticationSecret } from '@rdlabo/workers-hono-kit';
|
|
369
|
-
|
|
370
|
-
interface MySecret {
|
|
371
|
-
firebaseProduction: string;
|
|
372
|
-
stripeSecret: string;
|
|
373
|
-
}
|
|
374
|
-
|
|
375
|
-
const secret = await getAuthenticationSecret<MySecret>(
|
|
376
|
-
{
|
|
377
|
-
accessKeyId: env.AWS_ACCESS_KEY_ID,
|
|
378
|
-
secretAccessKey: env.AWS_SECRET_ACCESS_KEY,
|
|
379
|
-
region: 'ap-northeast-1',
|
|
380
|
-
},
|
|
381
|
-
'myapp/secret',
|
|
382
|
-
);
|
|
383
|
-
```
|
|
384
|
-
|
|
385
|
-
### STS AssumeRole (browser S3 uploads)
|
|
386
|
-
|
|
387
|
-
```ts
|
|
388
|
-
import { getTemporaryCredentials } from '@rdlabo/workers-hono-kit';
|
|
389
|
-
|
|
390
|
-
const credentials = await getTemporaryCredentials({
|
|
391
|
-
accessKeyId: env.AWS_ACCESS_KEY_ID,
|
|
392
|
-
secretAccessKey: env.AWS_SECRET_ACCESS_KEY,
|
|
393
|
-
roleArn: 'arn:aws:iam::123456789012:role/s3-put-app-only-role',
|
|
394
|
-
roleSessionName: `session-${userId}-${Date.now()}`,
|
|
395
|
-
});
|
|
396
|
-
// Return credentials to the browser; PutObject uses @aws-sdk/client-s3 with AccessKeyId / …
|
|
397
|
-
```
|
|
398
|
-
|
|
399
|
-
### Deadlock retry & HTTP helpers
|
|
400
|
-
|
|
401
|
-
```ts
|
|
402
|
-
import { retryWhenDeadlock, getUserProtocol, getAppInfo, HttpStatus } from '@rdlabo/workers-hono-kit';
|
|
403
|
-
|
|
404
|
-
await retryWhenDeadlock(() => db.transaction(/* ... */));
|
|
405
|
-
|
|
406
|
-
const { ipAddress, userAgent } = getUserProtocol(c);
|
|
407
|
-
const appInfo = getAppInfo(c);
|
|
408
|
-
return c.json(body, HttpStatus.CREATED);
|
|
409
|
-
```
|
|
410
|
-
|
|
411
|
-
### HTTP error / 404 handlers
|
|
412
|
-
|
|
413
|
-
`createHttpErrorHandler()` renders a thrown `HTTPException` as standard API error JSON,
|
|
414
|
-
and `notFoundHandler` gives the default unmatched-route 404 body.
|
|
415
|
-
|
|
416
|
-
#### App entry (fleet standard)
|
|
417
|
-
|
|
418
|
-
Use a **singleton** Hono app and inject the request-scoped container in middleware — do **not**
|
|
419
|
-
call `createApp(container).fetch(...)` on every request (rebuilds the route graph each time).
|
|
420
|
-
|
|
421
|
-
```ts
|
|
422
|
-
// worker.ts — once per isolate
|
|
423
|
-
const app = createApp();
|
|
424
|
-
export default Sentry.withSentry(/* … */, {
|
|
425
|
-
fetch: (req, env, ctx) => app.fetch(req, env, ctx),
|
|
426
|
-
});
|
|
427
|
-
|
|
428
|
-
// app.ts — fleet-standard onError (Sentry optional)
|
|
429
|
-
import * as Sentry from '@sentry/cloudflare';
|
|
430
|
-
import { createAppErrorHandler } from '@rdlabo/workers-hono-kit';
|
|
431
|
-
|
|
432
|
-
app.onError(
|
|
433
|
-
createAppErrorHandler({
|
|
434
|
-
sentry: Sentry, // omit on repos without Sentry (airlec, review, cbs-ai)
|
|
435
|
-
getReportError: (c) => c.get('container')?.reportError, // tests + scheduled paths
|
|
436
|
-
}),
|
|
437
|
-
);
|
|
438
|
-
|
|
439
|
-
// odss-mobile: add classify: classifyQueryFailed (repo parity)
|
|
440
|
-
// winecode: sentry + isHttpError in errors.ts (no container middleware)
|
|
441
|
-
// foodlabel: sentry + reportError: container.reportError (per-request container closure)
|
|
442
|
-
```
|
|
443
|
-
|
|
444
|
-
Reference: `winecode/hono` (singleton + container middleware). Legacy repos still using
|
|
445
|
-
per-request `createApp(container)` should migrate to this shape where possible.
|
|
446
|
-
|
|
447
|
-
**Isolate-scoped memo + container runtime** (shared across the fleet):
|
|
448
|
-
|
|
449
|
-
```ts
|
|
450
|
-
import { createContainerRuntime, createIsolateMemo } from '@rdlabo/workers-hono-kit';
|
|
451
|
-
|
|
452
|
-
// Secrets / env: cache successes per isolate; rejections are NOT cached (retry on next request).
|
|
453
|
-
const resolveSecrets = createIsolateMemo(async (env: Env) => { /* SM or env vars */ });
|
|
454
|
-
|
|
455
|
-
const { middleware: containerMiddleware, withContainer } = createContainerRuntime<Env, Container>({
|
|
456
|
-
hyperdrives: (env) => ({ primary: env.HYPERDRIVE_PRIMARY, replica: env.HYPERDRIVE_REPLICA }),
|
|
457
|
-
createContainer: async ({ env, executionCtx, primary, replica }) => {
|
|
458
|
-
const secret = await resolveSecrets(env);
|
|
459
|
-
return buildContainer({ /* db from primary/replica, secret, … */ });
|
|
460
|
-
},
|
|
461
|
-
});
|
|
462
|
-
```
|
|
62
|
+
Only `npm run release` creates a release tag. Stable `vX.Y.Z` tags publish to npm `latest`; revision/prerelease tags publish to `next`. Neither `beta` nor `next` publishing changes the npm `latest` dist-tag.
|
|
463
63
|
|
|
464
|
-
|
|
64
|
+
## Maintainers
|
|
465
65
|
|
|
466
|
-
|
|
467
|
-
import { createHttpErrorHandler, notFoundHandler } from '@rdlabo/workers-hono-kit';
|
|
468
|
-
|
|
469
|
-
app.notFound(notFoundHandler);
|
|
470
|
-
|
|
471
|
-
// Prefer createAppErrorHandler (see "App entry" above). Lower-level only when needed:
|
|
472
|
-
app.onError(createHttpErrorHandler());
|
|
473
|
-
```
|
|
474
|
-
|
|
475
|
-
**Important:** `Sentry.withSentry` does **not** capture errors handled by `app.onError`. Pass `sentry`
|
|
476
|
-
to `createAppErrorHandler` (or wire `getReportError` / `reportError` for tests and scheduled paths).
|
|
477
|
-
|
|
478
|
-
Repos with a custom DB error classifier (e.g. odss-mobile) pass `classify` to
|
|
479
|
-
`createAppErrorHandler` — do not call `createQueryFailedErrorHandler` directly unless you need full control.
|
|
480
|
-
|
|
481
|
-
### Auth middleware
|
|
482
|
-
|
|
483
|
-
Encodes the shared skeleton (read token header → verify → `getAppInfo` → resolve user id →
|
|
484
|
-
set context, with configurable reporting and response hooks). Inject your own verify/resolver,
|
|
485
|
-
context-variable names, and failure mode. By default a missing header remains backward compatible
|
|
486
|
-
and calls `verify('')`; set `rejectMissingToken: true` to reject missing/blank input first with
|
|
487
|
-
`AuthTokenMissingError`.
|
|
488
|
-
|
|
489
|
-
`createAuthMiddleware<Env, Verified, Id>` is generic over your Hono `Env`, so `c.set(...)` in
|
|
490
|
-
`setContext` is type-checked against your `Variables`.
|
|
491
|
-
|
|
492
|
-
```ts
|
|
493
|
-
import { createAuthMiddleware, createIdentityAuthFailureBody } from '@rdlabo/workers-hono-kit';
|
|
494
|
-
|
|
495
|
-
// AuthGuard: verify + resolve (and provision) the DB user id.
|
|
496
|
-
const userAuth = createAuthMiddleware<AppEnv, UserRecord, number>({
|
|
497
|
-
rejectMissingToken: true,
|
|
498
|
-
verify: (token) => container.firebase.verifyIdToken(token),
|
|
499
|
-
resolveUserId: (record, _c, appInfo) =>
|
|
500
|
-
container.auth.getUserIdFromFirebase(record, appInfo).catch(() => container.auth.createUser(record)),
|
|
501
|
-
setContext: (c, { verified, appInfo, userId }) => {
|
|
502
|
-
c.set('userRecord', verified);
|
|
503
|
-
c.set('userId', userId);
|
|
504
|
-
c.set('appInfo', appInfo);
|
|
505
|
-
},
|
|
506
|
-
reportFailure: (error, context, { stage, tokenPresent }) => {
|
|
507
|
-
// Suppress expected credential rejection; report dependency/internal failures without tokens.
|
|
508
|
-
},
|
|
509
|
-
onFailure: (_error, context, { stage }) =>
|
|
510
|
-
context.json(createIdentityAuthFailureBody(), 401),
|
|
511
|
-
});
|
|
512
|
-
|
|
513
|
-
// TokenGuard (login): verify only — omit resolveUserId. Override the failure if needed.
|
|
514
|
-
const tokenAuth = createAuthMiddleware<AppEnv, UserRecord>({
|
|
515
|
-
rejectMissingToken: true,
|
|
516
|
-
verify: (token) => container.firebase.verifyIdToken(token),
|
|
517
|
-
setContext: (c, { verified }) => c.set('userRecord', verified),
|
|
518
|
-
onFailure: (_e, c) => c.json(createIdentityAuthFailureBody(), 401),
|
|
519
|
-
});
|
|
520
|
-
```
|
|
521
|
-
|
|
522
|
-
`reportFailure(error, context, details)` receives only the stage (`token`, `verify`, `appInfo`,
|
|
523
|
-
`resolveUserId`, or `setContext`) and a `tokenPresent` boolean; raw token data is never included in
|
|
524
|
-
`details`. If the hook is omitted, the historical `console.error(error)` behavior remains. A reporting
|
|
525
|
-
hook failure is logged but cannot change the authentication response. `onFailure` receives the same
|
|
526
|
-
details as its third argument and may be asynchronous.
|
|
527
|
-
|
|
528
|
-
Authentication failures use three explicit scopes. Only `identity` permits a client to purge its
|
|
529
|
-
global authenticated session, offline replica boundary, and outbox. `reauthentication` means the
|
|
530
|
-
identity remains valid but a recent sign-in is required; `credential` belongs to a domain feature
|
|
531
|
-
such as a public booking token. New APIs use `401`; products with installed clients that historically
|
|
532
|
-
interpret auth failure as `403` use `createLegacyIdentityAuthFailureBody()` until that compatibility
|
|
533
|
-
contract can be retired. An untagged `403` is an authenticated permission/business denial and must
|
|
534
|
-
not be used as a global-session invalidation signal. Domain-specific `code` values remain product-owned.
|
|
535
|
-
`createAuthMiddleware` retains its historical untagged `403` default for source/runtime compatibility;
|
|
536
|
-
the tagged identity contract is an explicit `onFailure` opt-in as shown above.
|
|
537
|
-
|
|
538
|
-
### Latency instrumentation (`perfLog`)
|
|
539
|
-
|
|
540
|
-
Records one data point per request — `t_app` (time inside the app), `colo`, `cold`/`warm`, matched
|
|
541
|
-
route, `status` — and ships it to **Workers Logs** and/or **Workers Analytics Engine**. This lets you
|
|
542
|
-
measure a low-traffic Worker after the fact (retained + queryable) instead of watching a live
|
|
543
|
-
`wrangler tail`. Register it first so it wraps everything.
|
|
544
|
-
|
|
545
|
-
```ts
|
|
546
|
-
import { perfLog } from '@rdlabo/workers-hono-kit';
|
|
547
|
-
|
|
548
|
-
// A) app served with env (`app.fetch(req, env, ctx)`): bare — reads `PERF` (Analytics Engine
|
|
549
|
-
// dataset binding) and `PERF_LOG === '1'` (Workers Logs) off `c.env`.
|
|
550
|
-
app.use('*', perfLog());
|
|
551
|
-
|
|
552
|
-
// B) bindings not on Hono env (legacy per-request createApp): pass explicitly — prefer fleet
|
|
553
|
-
// standard singleton app + container middleware so env is always on `c.env`.
|
|
554
|
-
app.use('*', perfLog({ console: env.PERF_LOG === '1', dataset: env.PERF }));
|
|
555
|
-
```
|
|
556
|
-
|
|
557
|
-
```toml
|
|
558
|
-
# wrangler.toml — dataset is created on first write (no provisioning); needs [observability] for Logs.
|
|
559
|
-
[[analytics_engine_datasets]]
|
|
560
|
-
binding = "PERF"
|
|
561
|
-
dataset = "myapp_perf"
|
|
562
|
-
```
|
|
563
|
-
|
|
564
|
-
Query percentiles by route/colo with the Analytics Engine SQL API:
|
|
565
|
-
|
|
566
|
-
```sql
|
|
567
|
-
SELECT blob1 AS path, blob2 AS colo,
|
|
568
|
-
quantileWeighted(0.5)(double1, _sample_interval) AS p50,
|
|
569
|
-
quantileWeighted(0.9)(double1, _sample_interval) AS p90
|
|
570
|
-
FROM myapp_perf WHERE timestamp > now() - INTERVAL '7' DAY
|
|
571
|
-
GROUP BY path, colo ORDER BY p90 DESC
|
|
572
|
-
```
|
|
573
|
-
|
|
574
|
-
> **Scope of `t_app`**: it covers everything *inside* the app; work done in `fetch` *before* the app
|
|
575
|
-
> (e.g. secrets fetch / DB connect in container middleware vs. building the container in `worker.fetch`) is
|
|
576
|
-
> not comparable across differently-wired apps. Instrument the `fetch` seam if you need a secrets/connect
|
|
577
|
-
> cold breakdown. On production Workers `Date.now()` only advances at I/O boundaries, so `t_app` ≈ I/O
|
|
578
|
-
> wait, not CPU time.
|
|
579
|
-
|
|
580
|
-
### AI Gateway
|
|
581
|
-
|
|
582
|
-
Route `@ai-sdk` models through the Cloudflare AI Gateway — either with a Workers `AI` binding
|
|
583
|
-
(production / `wrangler dev`) or with REST credentials (non-Workers contexts).
|
|
584
|
-
|
|
585
|
-
```ts
|
|
586
|
-
import { createAiGatewayProvider } from '@rdlabo/workers-hono-kit';
|
|
587
|
-
import { openai } from '@ai-sdk/openai';
|
|
588
|
-
|
|
589
|
-
// Binding form (Workers):
|
|
590
|
-
const provider = createAiGatewayProvider({ binding: env.AI.gateway('my-gateway') });
|
|
591
|
-
|
|
592
|
-
// REST form (anywhere):
|
|
593
|
-
const rest = createAiGatewayProvider({
|
|
594
|
-
accountId: env.CF_ACCOUNT_ID,
|
|
595
|
-
gateway: 'my-gateway',
|
|
596
|
-
token: env.CF_AIG_TOKEN,
|
|
597
|
-
});
|
|
598
|
-
|
|
599
|
-
const model = provider.aigateway(openai('gpt-4o-mini'));
|
|
600
|
-
```
|
|
601
|
-
|
|
602
|
-
### MySQL data layer (Hyperdrive + Drizzle)
|
|
603
|
-
|
|
604
|
-
```ts
|
|
605
|
-
import { createHyperdriveDatabase, hyperdriveConnectionOptions } from '@rdlabo/workers-hono-kit/db';
|
|
606
|
-
import { drizzle } from 'drizzle-orm/mysql2';
|
|
607
|
-
import { DRIZZLE_ORM_OPTIONS } from '@rdlabo/workers-hono-kit/db';
|
|
608
|
-
|
|
609
|
-
const db = createHyperdriveDatabase({
|
|
610
|
-
primaryHyperdrive: env.HYPERDRIVE,
|
|
611
|
-
replicaHyperdrive: env.HYPERDRIVE_REPLICA,
|
|
612
|
-
createOrm: (conn) => drizzle(conn, { ...DRIZZLE_ORM_OPTIONS, schema }),
|
|
613
|
-
});
|
|
614
|
-
|
|
615
|
-
const rows = await db.read('SELECT * FROM users WHERE id = ?', [id]); // replica, raw SQL
|
|
616
|
-
await db.write((dz) => dz.insert(users).values({ name })); // primary, deadlock-retried
|
|
617
|
-
```
|
|
618
|
-
|
|
619
|
-
### KV cache
|
|
620
|
-
|
|
621
|
-
```ts
|
|
622
|
-
import { KVCache } from '@rdlabo/workers-hono-kit';
|
|
623
|
-
|
|
624
|
-
const cache = new KVCache(env.CACHE, { appName: 'myapp' }); // version prefix defaults to 'v8_'
|
|
625
|
-
await cache.set('users', 'byId', userId, user, 600);
|
|
626
|
-
const hit = await cache.get<User>('users', 'byId', userId);
|
|
627
|
-
```
|
|
628
|
-
|
|
629
|
-
Cache failures remain fail-soft. To report them without changing caller behavior, configure the
|
|
630
|
-
optional observer (for example, to forward the raw error to Sentry). Kit-generated context is
|
|
631
|
-
limited to `operation` and `table`; it never adds the cache type, generated key, id, or value.
|
|
632
|
-
|
|
633
|
-
```ts
|
|
634
|
-
const cache = new KVCache(env.CACHE, {
|
|
635
|
-
appName: 'myapp',
|
|
636
|
-
onError: (error, context) => reportError(error, context),
|
|
637
|
-
});
|
|
638
|
-
```
|
|
639
|
-
|
|
640
|
-
### Stripe (Workers-native)
|
|
641
|
-
|
|
642
|
-
```ts
|
|
643
|
-
import { createStripeClient, verifyStripeWebhook } from '@rdlabo/workers-hono-kit';
|
|
644
|
-
|
|
645
|
-
const stripe = createStripeClient(secret); // or { apiVersion: '2024-04-10' } to pin
|
|
646
|
-
const event = await verifyStripeWebhook(secret, webhookSecret, rawBody, c.req.header('stripe-signature') ?? '');
|
|
647
|
-
```
|
|
648
|
-
|
|
649
|
-
### Payment failure & subscription reconcile
|
|
650
|
-
|
|
651
|
-
Store only the raw reason; render the user-facing message on read (so wording changes never need a migration).
|
|
652
|
-
|
|
653
|
-
```ts
|
|
654
|
-
import {
|
|
655
|
-
extractStripeFailureReason,
|
|
656
|
-
serializePaymentFailure,
|
|
657
|
-
paymentFailureMessageJa,
|
|
658
|
-
} from '@rdlabo/workers-hono-kit';
|
|
659
|
-
|
|
660
|
-
// On a Stripe failure webhook: persist the normalized reason.
|
|
661
|
-
const reason = extractStripeFailureReason(event.data.object);
|
|
662
|
-
if (reason) {
|
|
663
|
-
await db.write.insert(paymentFailed).values({
|
|
664
|
-
type: 'stripe',
|
|
665
|
-
status: 'failed',
|
|
666
|
-
receipt: serializePaymentFailure({ reason, source: 'webhook.invoice.payment_failed', occurredAt }),
|
|
667
|
-
});
|
|
668
|
-
}
|
|
669
|
-
|
|
670
|
-
// On read: provider-agnostic Japanese message.
|
|
671
|
-
const message = paymentFailureMessageJa({ status: row.status, type: row.type, reason: parsed?.reason });
|
|
672
|
-
```
|
|
673
|
-
|
|
674
|
-
In-app purchase: verify → classify → key the row by billing cycle.
|
|
675
|
-
|
|
676
|
-
```ts
|
|
677
|
-
import {
|
|
678
|
-
verifyAppleReceipt,
|
|
679
|
-
classifyAppleRenewal,
|
|
680
|
-
iapFailureKey,
|
|
681
|
-
serializeIapFailureReason,
|
|
682
|
-
} from '@rdlabo/workers-hono-kit';
|
|
683
|
-
|
|
684
|
-
const verify = await verifyAppleReceipt(receipt, { password: appleSharedSecret });
|
|
685
|
-
const cls = classifyAppleRenewal(verify, Date.now());
|
|
686
|
-
if (cls.state === 'billing_retry' || cls.state === 'lapsed') {
|
|
687
|
-
await db.write.insert(paymentFailed).values({
|
|
688
|
-
type: 'ios',
|
|
689
|
-
status: cls.state === 'billing_retry' ? 'failed' : 'canceled',
|
|
690
|
-
recursions_id: iapFailureKey({
|
|
691
|
-
platform: 'ios',
|
|
692
|
-
originalTransactionId: cls.originalTransactionId!,
|
|
693
|
-
expiresDateMs: cls.expiresDateMs!,
|
|
694
|
-
}),
|
|
695
|
-
receipt: serializeIapFailureReason({
|
|
696
|
-
code: cls.state === 'billing_retry' ? 'billing_retry' : 'subscription_canceled',
|
|
697
|
-
statusCode: cls.statusCode,
|
|
698
|
-
billingRetryStatus: cls.billingRetryStatus,
|
|
699
|
-
autoRenewStatus: cls.autoRenewStatus,
|
|
700
|
-
}),
|
|
701
|
-
});
|
|
702
|
-
}
|
|
703
|
-
```
|
|
704
|
-
|
|
705
|
-
### Testing
|
|
706
|
-
|
|
707
|
-
```ts
|
|
708
|
-
import { createTestDb, FakeFirebaseVerifier, configurableFake } from '@rdlabo/workers-hono-kit/testing';
|
|
709
|
-
|
|
710
|
-
const testDb = createTestDb({ dbName: 'myapp_test', migrationsFolder: './drizzle' });
|
|
711
|
-
await testDb.resetSchema();
|
|
712
|
-
const pool = testDb.createTestPool();
|
|
713
|
-
|
|
714
|
-
const firebase = new FakeFirebaseVerifier();
|
|
715
|
-
firebase.register('token-1', { uid: 'uid-1', email: 'a@example.com' });
|
|
716
|
-
|
|
717
|
-
const gateway = configurableFake<PaymentGateway>({ charge: async () => ({ ok: true }) }, 'PaymentGateway');
|
|
718
|
-
```
|
|
719
|
-
|
|
720
|
-
## Local development / linking
|
|
721
|
-
|
|
722
|
-
If you consume this package via a local path (e.g. `"@rdlabo/workers-hono-kit": "../../hono-kit"`) rather than from npm, TypeScript and esbuild resolve the package's bare imports from *its own* `node_modules`, which can create a second `zod` instance. That breaks types where your zod-inferred values flow into other libraries (e.g. Drizzle inserts). Dedupe with tsconfig `paths`:
|
|
723
|
-
|
|
724
|
-
```jsonc
|
|
725
|
-
{
|
|
726
|
-
"compilerOptions": {
|
|
727
|
-
"baseUrl": ".",
|
|
728
|
-
"paths": {
|
|
729
|
-
"zod": ["node_modules/zod"],
|
|
730
|
-
"zod/*": ["node_modules/zod/*"],
|
|
731
|
-
"@hono/zod-validator": ["node_modules/@hono/zod-validator"]
|
|
732
|
-
}
|
|
733
|
-
}
|
|
734
|
-
}
|
|
735
|
-
```
|
|
736
|
-
|
|
737
|
-
When installed from npm normally, package managers dedupe `zod` to a single copy and this is not needed.
|
|
738
|
-
|
|
739
|
-
## CLI
|
|
740
|
-
|
|
741
|
-
The package ships three `bin` commands (run via `npx` or an npm script in the consuming app):
|
|
742
|
-
|
|
743
|
-
| Command | Use |
|
|
744
|
-
| --- | --- |
|
|
745
|
-
| `workers-hono-kit-sync-dev-aws <wrangler-args…>` | Launch `wrangler` with AWS credentials injected as `--var`, resolved from the active AWS profile (honors `AWS_PROFILE`, supports short-lived SSO/temporary creds). Nothing is written to disk — replaces `.dev.vars`. Wire it as the `dev` script, e.g. `AWS_PROFILE=<p> workers-hono-kit-sync-dev-aws dev --var APP_ENV:development`. |
|
|
746
|
-
| `workers-hono-kit-check-subrequest-fanout [dir…]` | CI gate that greps for per-item external-call fan-outs (`runWithConcurrency(` / `PromisePool` / `.withConcurrency(`) that would eventually exceed the Workers subrequest cap. Annotate a genuinely-safe site with `subrequest-ok`. Scans `src` by default; exits 1 on an un-annotated marker. |
|
|
747
|
-
| `workers-hono-kit-db-baseline [--migrations ./drizzle]` | Brownfield first-deploy helper: record the baseline `0000` migration as *already applied* on an existing MySQL DB without running its DDL (the CLI wrapper around `baselineMigrations` / `readBaselineEntry`). Reads DB credentials from `DB_SECRET` (AWS RDS managed secret) or the individual `DB_*` env vars. |
|
|
748
|
-
|
|
749
|
-
## Storage-agnostic role policies
|
|
750
|
-
|
|
751
|
-
`createRolePolicy` builds pure RBAC checks without coupling the policy to a database schema. The
|
|
752
|
-
application can resolve roles from a membership table, a `users.role` column, token claims, or any
|
|
753
|
-
other source.
|
|
754
|
-
|
|
755
|
-
```ts
|
|
756
|
-
import { createRolePolicy } from '@rdlabo/workers-hono-kit';
|
|
757
|
-
|
|
758
|
-
type Role = 'owner' | 'admin' | 'member' | 'read';
|
|
759
|
-
type Permission = 'organization.manage' | 'resource.write' | 'resource.read';
|
|
760
|
-
|
|
761
|
-
const policy = createRolePolicy<Role, Permission>({
|
|
762
|
-
permissions: {
|
|
763
|
-
owner: ['organization.manage', 'resource.write', 'resource.read'],
|
|
764
|
-
admin: ['resource.write', 'resource.read'],
|
|
765
|
-
member: ['resource.write', 'resource.read'],
|
|
766
|
-
read: ['resource.read'],
|
|
767
|
-
},
|
|
768
|
-
assignableRoles: {
|
|
769
|
-
owner: ['admin', 'member', 'read'],
|
|
770
|
-
admin: ['member', 'read'],
|
|
771
|
-
member: [],
|
|
772
|
-
read: [],
|
|
773
|
-
},
|
|
774
|
-
manageableRoles: {
|
|
775
|
-
owner: ['admin', 'member', 'read'],
|
|
776
|
-
admin: ['member', 'read'],
|
|
777
|
-
member: [],
|
|
778
|
-
read: [],
|
|
779
|
-
},
|
|
780
|
-
});
|
|
781
|
-
```
|
|
782
|
-
|
|
783
|
-
## Development
|
|
784
|
-
|
|
785
|
-
```bash
|
|
786
|
-
npm install
|
|
787
|
-
npm run typecheck # tsc --noEmit
|
|
788
|
-
npm run lint # eslint
|
|
789
|
-
npm test # vitest
|
|
790
|
-
npm run build # tsc -p tsconfig.build.json → dist/
|
|
791
|
-
```
|
|
66
|
+
- [rdlabo](https://rdlabo.dev/)
|
|
792
67
|
|
|
793
68
|
## License
|
|
794
69
|
|
|
795
|
-
[MIT](./LICENSE) © rdlabo-
|
|
70
|
+
[MIT](./LICENSE) © rdlabo-dev
|
|
71
|
+
<!-- /rdlabo-docs-omit -->
|