@rdlabo/workers-hono-kit 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (100) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +247 -0
  3. package/dist/ai/gateway.d.ts +40 -0
  4. package/dist/ai/gateway.js +36 -0
  5. package/dist/aws/cloudfront.d.ts +9 -0
  6. package/dist/aws/cloudfront.js +43 -0
  7. package/dist/aws/secrets-manager.d.ts +14 -0
  8. package/dist/aws/secrets-manager.js +43 -0
  9. package/dist/cache/kv-cache.d.ts +51 -0
  10. package/dist/cache/kv-cache.js +88 -0
  11. package/dist/db/connection.d.ts +41 -0
  12. package/dist/db/connection.js +48 -0
  13. package/dist/db/database.d.ts +60 -0
  14. package/dist/db/database.js +63 -0
  15. package/dist/db/index.d.ts +10 -0
  16. package/dist/db/index.js +8 -0
  17. package/dist/db/jst.d.ts +19 -0
  18. package/dist/db/jst.js +48 -0
  19. package/dist/db/orm-config.d.ts +62 -0
  20. package/dist/db/orm-config.js +42 -0
  21. package/dist/db/retry.d.ts +6 -0
  22. package/dist/db/retry.js +22 -0
  23. package/dist/db/write-result.d.ts +16 -0
  24. package/dist/db/write-result.js +13 -0
  25. package/dist/firebase/firebase-verifier.d.ts +17 -0
  26. package/dist/firebase/firebase-verifier.js +1 -0
  27. package/dist/firebase/identity-toolkit.d.ts +24 -0
  28. package/dist/firebase/identity-toolkit.js +64 -0
  29. package/dist/firebase/jose-firebase-verifier.d.ts +32 -0
  30. package/dist/firebase/jose-firebase-verifier.js +52 -0
  31. package/dist/firebase/remote-verifier.d.ts +9 -0
  32. package/dist/firebase/remote-verifier.js +44 -0
  33. package/dist/http/app-env.d.ts +19 -0
  34. package/dist/http/app-env.js +16 -0
  35. package/dist/http/app-info.d.ts +11 -0
  36. package/dist/http/app-info.js +8 -0
  37. package/dist/http/http-status.d.ts +62 -0
  38. package/dist/http/http-status.js +63 -0
  39. package/dist/http/nest-error.d.ts +72 -0
  40. package/dist/http/nest-error.js +65 -0
  41. package/dist/http/user-protocol.d.ts +11 -0
  42. package/dist/http/user-protocol.js +8 -0
  43. package/dist/index.d.ts +30 -0
  44. package/dist/index.js +28 -0
  45. package/dist/middleware/auth.d.ts +36 -0
  46. package/dist/middleware/auth.js +30 -0
  47. package/dist/middleware/finalize-response.d.ts +2 -0
  48. package/dist/middleware/finalize-response.js +53 -0
  49. package/dist/middleware/validation.d.ts +77 -0
  50. package/dist/middleware/validation.js +53 -0
  51. package/dist/middleware/zod-coerce.d.ts +9 -0
  52. package/dist/middleware/zod-coerce.js +50 -0
  53. package/dist/stripe/client.d.ts +19 -0
  54. package/dist/stripe/client.js +23 -0
  55. package/dist/testing/auth.d.ts +36 -0
  56. package/dist/testing/auth.js +42 -0
  57. package/dist/testing/configurable-fake.d.ts +14 -0
  58. package/dist/testing/configurable-fake.js +33 -0
  59. package/dist/testing/db.d.ts +37 -0
  60. package/dist/testing/db.js +74 -0
  61. package/dist/testing/fakes.d.ts +35 -0
  62. package/dist/testing/fakes.js +56 -0
  63. package/dist/testing/index.d.ts +8 -0
  64. package/dist/testing/index.js +10 -0
  65. package/dist/testing/stripe-fixtures.d.ts +13 -0
  66. package/dist/testing/stripe-fixtures.js +76 -0
  67. package/package.json +113 -0
  68. package/scripts/sync-dev-aws.mjs +59 -0
  69. package/src/ai/gateway.ts +81 -0
  70. package/src/aws/cloudfront.ts +65 -0
  71. package/src/aws/secrets-manager.ts +63 -0
  72. package/src/cache/kv-cache.ts +134 -0
  73. package/src/db/connection.ts +73 -0
  74. package/src/db/database.ts +133 -0
  75. package/src/db/index.ts +27 -0
  76. package/src/db/jst.ts +56 -0
  77. package/src/db/orm-config.ts +71 -0
  78. package/src/db/retry.ts +21 -0
  79. package/src/db/write-result.ts +23 -0
  80. package/src/firebase/firebase-verifier.ts +15 -0
  81. package/src/firebase/identity-toolkit.ts +82 -0
  82. package/src/firebase/jose-firebase-verifier.ts +71 -0
  83. package/src/firebase/remote-verifier.ts +49 -0
  84. package/src/http/app-env.ts +20 -0
  85. package/src/http/app-info.ts +16 -0
  86. package/src/http/http-status.ts +62 -0
  87. package/src/http/nest-error.ts +138 -0
  88. package/src/http/user-protocol.ts +16 -0
  89. package/src/index.ts +55 -0
  90. package/src/middleware/auth.ts +67 -0
  91. package/src/middleware/finalize-response.ts +61 -0
  92. package/src/middleware/validation.ts +84 -0
  93. package/src/middleware/zod-coerce.ts +65 -0
  94. package/src/stripe/client.ts +48 -0
  95. package/src/testing/auth.ts +62 -0
  96. package/src/testing/configurable-fake.ts +33 -0
  97. package/src/testing/db.ts +125 -0
  98. package/src/testing/fakes.ts +75 -0
  99. package/src/testing/index.ts +26 -0
  100. package/src/testing/stripe-fixtures.ts +85 -0
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 rdlabo-team
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,247 @@
1
+ # @rdlabo/workers-hono-kit
2
+
3
+ Infrastructure toolkit for building APIs on [Hono](https://hono.dev) + [Cloudflare Workers](https://workers.cloudflare.com).
4
+
5
+ It provides the building blocks a NestJS-style API needs but that don't run on `workerd` (no Node.js AWS SDK, no `firebase-admin`), plus middleware that matches Express / NestJS response semantics byte-for-byte:
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** via SigV4-signed `fetch` ([`aws4fetch`](https://github.com/mhart/aws4fetch)) — no AWS SDK.
9
+ - **Middleware**: `finalizeResponse` (Express-compatible weak ETag + JSON charset), `validate` (NestJS `ValidationPipe`-shaped 400), and zod number-coercion helpers.
10
+ - **Deadlock retry** (`ER_LOCK_DEADLOCK` exponential backoff).
11
+ - **HTTP helpers**: `getUserProtocol`, `getAppInfo`, `HttpStatus`.
12
+
13
+ ## Install
14
+
15
+ ```bash
16
+ npm install @rdlabo/workers-hono-kit
17
+ ```
18
+
19
+ Peer dependencies — install the ones you use:
20
+
21
+ ```bash
22
+ npm install hono zod @hono/zod-validator jose aws4fetch
23
+ ```
24
+
25
+ > **TypeScript sources, no build step.** The package is published as `.ts` via the `exports` field and is meant to be consumed by a bundler that compiles TypeScript — wrangler/esbuild, Vite, etc. — targeting `workerd` or another edge runtime. It relies only on Web-standard APIs (`fetch`, `crypto.subtle`, `Response`) available on Cloudflare Workers.
26
+
27
+ ## API
28
+
29
+ | Export | Description |
30
+ | --- | --- |
31
+ | `finalizeResponse()` | Middleware that adds an Express-compatible weak `ETag` and JSON `charset=utf-8`. |
32
+ | `validate(target, schema, options?)` | Zod validator → NestJS `ValidationPipe`-shaped `400` (`{ statusCode, message[], error }`). `options.onValidationError(err, c)` to report (e.g. Sentry). |
33
+ | `zNum` / `zNumWithDefault` / `zNumOptional` / `zNumNullable` | Number-coercion zod schemas (mirror class-transformer `@Transform`). |
34
+ | `getAuthenticationSecret<T>(options, secretId)` / `AwsSecretsOptions` | Fetch a secret from AWS Secrets Manager (SigV4 `fetch`, per-isolate cache). |
35
+ | `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. |
36
+ | `JoseFirebaseVerifier` / `FirebaseVerifier` / `DecodedIdToken` | Firebase ID-token verification (`verifyIdToken`, `getUser`, `deleteUser`). |
37
+ | `createRemoteFirebaseVerifier(projectId)` | Convenience factory: production verifier with a cached remote JWKS (verification only). |
38
+ | `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. |
39
+ | `IdentityToolkit` / `ServiceAccount` / `SECURETOKEN_JWK_URL` | Identity Toolkit REST client + constants for `getUser` / `deleteUser`. |
40
+ | `retryWhenDeadlock(fn, retries?, delay?)` | Retry on MySQL `ER_LOCK_DEADLOCK` with exponential backoff. |
41
+ | `getUserProtocol(c)` / `IUserProtocol` | Read client IP / UA (`CF-Connecting-IP` → `X-Forwarded-For`). |
42
+ | `getAppInfo(c)` / `AppInfo` | Read `x-amz-meta-version` / `x-amz-meta-uuid`. |
43
+ | `HttpStatus` | HTTP status enum identical to NestJS `@nestjs/common`. |
44
+ | `createNestErrorHandler(options?)` / `NestErrorHandlerOptions` | `app.onError()` handler that maps a thrown `HTTPException` to the NestJS exception-filter body (`{ statusCode, message, error? }`; `401` omits `error`). Configurable field order, reason phrases, error predicate, and unhandled-error report hook. |
45
+ | `nestNotFoundHandler(c)` | `app.notFound()` handler with the Express/Nest default `{ message: 'Cannot METHOD path', error, statusCode }` 404 body. |
46
+ | `NEST_REASON_PHRASES` | `{ 400, 401, 403, 404 }` → NestJS reason phrases. |
47
+ | `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. |
48
+ | `ErrorReporter` / `ErrorReportContext` | Types for a `container.reportError`-style unhandled-error reporter (e.g. wired to Sentry), paired with `createNestErrorHandler`'s `onUnhandledError`. |
49
+ | `KVCache` / `KVNamespace` / `KVCacheOptions` | Workers-KV cache-aside helper (key `appName+version+table_type_column`, sha256 for string ids, TTL clamped ≥60s). `appName`/`version` per repo. |
50
+ | `createStripeClient(secret, opts?)` / `verifyStripeWebhook(...)` / `CreateStripeClientOptions` | Workers-native Stripe client (fetch transport) + async webhook verification (SubtleCrypto). `apiVersion` optional (pin per `/api` parity). |
51
+
52
+ ## Usage
53
+
54
+ ### Response finalization (ETag / charset)
55
+
56
+ ```ts
57
+ import { Hono } from 'hono';
58
+ import { finalizeResponse } from '@rdlabo/workers-hono-kit';
59
+
60
+ const app = new Hono();
61
+ app.use('*', finalizeResponse());
62
+ ```
63
+
64
+ ### Request validation
65
+
66
+ ```ts
67
+ import { validate } from '@rdlabo/workers-hono-kit';
68
+ import { z } from 'zod';
69
+
70
+ app.post('/users', validate('json', z.object({ name: z.string() })), (c) => {
71
+ const body = c.req.valid('json'); // typed & validated
72
+ return c.json(body, 201);
73
+ });
74
+
75
+ // Report validation failures (response is unchanged):
76
+ validate('json', schema, {
77
+ onValidationError: (err, c) => Sentry.captureException(err),
78
+ });
79
+ ```
80
+
81
+ `param` / `query` values arrive as strings — coerce numbers with the zod helpers:
82
+
83
+ ```ts
84
+ import { zNum, zNumOptional } from '@rdlabo/workers-hono-kit';
85
+
86
+ const Params = z.object({ id: zNum(z.number().int()), page: zNumOptional() });
87
+ ```
88
+
89
+ ### Firebase ID-token verification
90
+
91
+ ```ts
92
+ import { createRemoteFirebaseVerifier } from '@rdlabo/workers-hono-kit';
93
+
94
+ const verifier = createRemoteFirebaseVerifier(projectId);
95
+ const decoded = await verifier.verifyIdToken(idToken); // { uid, email, ... }
96
+ ```
97
+
98
+ With `getUser` / `deleteUser` (needs a service account):
99
+
100
+ ```ts
101
+ import { createRemoteJWKSet } from 'jose';
102
+ import { JoseFirebaseVerifier, IdentityToolkit, SECURETOKEN_JWK_URL } from '@rdlabo/workers-hono-kit';
103
+
104
+ const verifier = new JoseFirebaseVerifier({
105
+ projectId,
106
+ keyResolver: createRemoteJWKSet(new URL(SECURETOKEN_JWK_URL)),
107
+ identity: new IdentityToolkit(serviceAccount),
108
+ });
109
+ ```
110
+
111
+ ### AWS Secrets Manager
112
+
113
+ ```ts
114
+ import { getAuthenticationSecret } from '@rdlabo/workers-hono-kit';
115
+
116
+ interface MySecret {
117
+ firebaseProduction: string;
118
+ stripeSecret: string;
119
+ }
120
+
121
+ const secret = await getAuthenticationSecret<MySecret>(
122
+ {
123
+ accessKeyId: env.AWS_ACCESS_KEY_ID,
124
+ secretAccessKey: env.AWS_SECRET_ACCESS_KEY,
125
+ region: 'ap-northeast-1',
126
+ },
127
+ 'myapp/secret',
128
+ );
129
+ ```
130
+
131
+ ### Deadlock retry & HTTP helpers
132
+
133
+ ```ts
134
+ import { retryWhenDeadlock, getUserProtocol, getAppInfo, HttpStatus } from '@rdlabo/workers-hono-kit';
135
+
136
+ await retryWhenDeadlock(() => db.transaction(/* ... */));
137
+
138
+ const { ipAddress, userAgent } = getUserProtocol(c);
139
+ const appInfo = getAppInfo(c);
140
+ return c.json(body, HttpStatus.CREATED);
141
+ ```
142
+
143
+ ### NestJS-shaped error / 404 handlers
144
+
145
+ `createNestErrorHandler()` renders a thrown `HTTPException` as the NestJS exception-filter
146
+ body, and `nestNotFoundHandler` gives the Express/Nest default 404. The defaults match the
147
+ NestJS canonical shape (`{ statusCode, message, error? }`, `401` omits `error`); each repo
148
+ keeps its own byte-parity via options.
149
+
150
+ ```ts
151
+ import { createNestErrorHandler, nestNotFoundHandler } from '@rdlabo/workers-hono-kit';
152
+
153
+ app.notFound(nestNotFoundHandler);
154
+ app.onError(createNestErrorHandler());
155
+
156
+ // Repo-specific parity deltas:
157
+ app.onError(
158
+ createNestErrorHandler({
159
+ fieldOrder: 'message-first', // emit { message, error, statusCode } instead of statusCode-first
160
+ onUnhandledError: (err, c) => container.reportError?.(err, { requestId: c.get('requestId') }),
161
+ isHttpError: (e): e is HttpError => e instanceof HttpError, // a custom error class with a `.body` escape hatch
162
+ }),
163
+ );
164
+ ```
165
+
166
+ ### Auth middleware
167
+
168
+ Encodes the shared skeleton (read token header → verify → `getAppInfo` → resolve user id →
169
+ set context, with `console.error` + a configurable failure on error). Inject the repo's
170
+ verify/resolver, context-variable names, and failure mode.
171
+
172
+ `createAuthMiddleware<Env, Verified, Id>` is generic over your Hono `Env`, so `c.set(...)` in
173
+ `setContext` is type-checked against your `Variables`.
174
+
175
+ ```ts
176
+ import { createAuthMiddleware } from '@rdlabo/workers-hono-kit';
177
+
178
+ // AuthGuard: verify + resolve (and provision) the DB user id.
179
+ const userAuth = createAuthMiddleware<AppEnv, UserRecord, number>({
180
+ verify: (token) => container.firebase.verifyIdToken(token),
181
+ resolveUserId: (record, _c, appInfo) =>
182
+ container.auth.getUserIdFromFirebase(record, appInfo).catch(() => container.auth.createUser(record)),
183
+ setContext: (c, { verified, appInfo, userId }) => {
184
+ c.set('userRecord', verified);
185
+ c.set('userId', userId);
186
+ c.set('appInfo', appInfo);
187
+ },
188
+ });
189
+
190
+ // TokenGuard (login): verify only — omit resolveUserId. Override the failure if needed.
191
+ const tokenAuth = createAuthMiddleware<AppEnv, UserRecord>({
192
+ verify: (token) => container.firebase.verifyIdToken(token),
193
+ setContext: (c, { verified }) => c.set('userRecord', verified),
194
+ onFailure: (_e, c) => c.json({ message: 'Unauthorized', statusCode: 401 }, 401),
195
+ });
196
+ ```
197
+
198
+ ### KV cache
199
+
200
+ ```ts
201
+ import { KVCache } from '@rdlabo/workers-hono-kit';
202
+
203
+ const cache = new KVCache(env.CACHE, { appName: 'myapp' }); // version defaults to 'v8_'
204
+ await cache.set('users', 'byId', userId, user, 600);
205
+ const hit = await cache.get<User>('users', 'byId', userId);
206
+ ```
207
+
208
+ ### Stripe (Workers-native)
209
+
210
+ ```ts
211
+ import { createStripeClient, verifyStripeWebhook } from '@rdlabo/workers-hono-kit';
212
+
213
+ const stripe = createStripeClient(secret); // or { apiVersion: '2024-04-10' } to pin
214
+ const event = await verifyStripeWebhook(secret, webhookSecret, rawBody, c.req.header('stripe-signature') ?? '');
215
+ ```
216
+
217
+ ## Local development / linking
218
+
219
+ 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`:
220
+
221
+ ```jsonc
222
+ {
223
+ "compilerOptions": {
224
+ "baseUrl": ".",
225
+ "paths": {
226
+ "zod": ["node_modules/zod"],
227
+ "zod/*": ["node_modules/zod/*"],
228
+ "@hono/zod-validator": ["node_modules/@hono/zod-validator"]
229
+ }
230
+ }
231
+ }
232
+ ```
233
+
234
+ When installed from npm normally, package managers dedupe `zod` to a single copy and this is not needed.
235
+
236
+ ## Development
237
+
238
+ ```bash
239
+ npm install
240
+ npm run typecheck # tsc --noEmit
241
+ npm run lint # eslint
242
+ npm test # vitest
243
+ ```
244
+
245
+ ## License
246
+
247
+ [MIT](./LICENSE) © rdlabo-team
@@ -0,0 +1,40 @@
1
+ import type { AiGateway, AiGatewayBindingSettings, AiGatewayOptions } from 'ai-gateway-provider';
2
+ export type { AiGateway, AiGatewayOptions } from 'ai-gateway-provider';
3
+ /** Workers の AI binding(`env.AI.gateway(name)`)の最小形。Cloudflare の `AiGateway` が構造的に適合する。 */
4
+ export type AiGatewayBinding = AiGatewayBindingSettings['binding'];
5
+ /**
6
+ * AI Gateway 設定。2 系統:
7
+ * - binding 形: Workers ランタイム(本番 / `wrangler dev`)。`env.AI.gateway(name)` を渡す。
8
+ * binding 経由は同一アカウント内で事前認証されるため Gateway トークン不要。
9
+ * - REST 形: Workers 外(Node の eval ハーネス等、binding 不可)。accountId + gateway + token で REST。
10
+ */
11
+ export type AiGatewayConfig = {
12
+ /** `env.AI.gateway(name)` 等の AI Gateway binding。 */
13
+ binding: AiGatewayBinding;
14
+ /** キャッシュ / リトライ / メタデータ等の Gateway オプション(任意)。 */
15
+ options?: AiGatewayOptions;
16
+ } | {
17
+ /** Cloudflare アカウント ID。 */
18
+ accountId: string;
19
+ /** AI Gateway 名。 */
20
+ gateway: string;
21
+ /**
22
+ * `cf-aig-authorization` に載せる Gateway 認証トークン。Authenticated Gateway のときだけ必要。
23
+ * unauthenticated Gateway では省略可(プロバイダの API キーではなく Gateway 自体への認証)。
24
+ */
25
+ token?: string;
26
+ /** キャッシュ / リトライ / メタデータ等の Gateway オプション(任意)。 */
27
+ options?: AiGatewayOptions;
28
+ };
29
+ export interface AiGatewayProvider {
30
+ /**
31
+ * `@ai-sdk/*` のモデルを包んで AI Gateway 経由にする。
32
+ * 例: `aigateway(createAnthropic({ apiKey }).('claude-...'))`。
33
+ * 配列を渡すとフォールバック(先頭から順に試行)になる。
34
+ */
35
+ aigateway: AiGateway;
36
+ }
37
+ /**
38
+ * AI Gateway 用のプロバイダを生成する。binding 形 / REST 形のどちらでも可(欠落時は fail-fast)。
39
+ */
40
+ export declare function createAiGatewayProvider(config: AiGatewayConfig): AiGatewayProvider;
@@ -0,0 +1,36 @@
1
+ /**
2
+ * Cloudflare AI Gateway のプロバイダ生成(`ai` SDK + `ai-gateway-provider`)。
3
+ * フリート共通 = foodlabel / winecode / receptray hono の AI 呼び出しを必ず Gateway 経由にする。
4
+ *
5
+ * `createAiGateway` が返す wrapper で `@ai-sdk/*` のモデルを包むと、SDK が組み立てた
6
+ * プロバイダ宛リクエスト(api.openai.com / api.anthropic.com / *-aiplatform.googleapis.com 等)を
7
+ * AI Gateway の Universal Endpoint 経由に差し替える。OpenAI / Anthropic / Google Vertex(SA) の
8
+ * いずれも同じ `aigateway(model)` で透過的にルーティングされる(Vertex も対応)。
9
+ *
10
+ * ここはインフラ層(Gateway 識別子と認証トークンの注入だけ)。プロバイダの API キーや
11
+ * Vertex の SA 認証情報は各 repo 側でモデル生成時に渡す(pass-through)。
12
+ */
13
+ import { createAiGateway } from 'ai-gateway-provider';
14
+ /**
15
+ * AI Gateway 用のプロバイダを生成する。binding 形 / REST 形のどちらでも可(欠落時は fail-fast)。
16
+ */
17
+ export function createAiGatewayProvider(config) {
18
+ if ('binding' in config) {
19
+ return { aigateway: createAiGateway({ binding: config.binding, options: config.options }) };
20
+ }
21
+ if (!config.accountId) {
22
+ throw new Error('AI Gateway: accountId が未設定です');
23
+ }
24
+ if (!config.gateway) {
25
+ throw new Error('AI Gateway: gateway 名が未設定です');
26
+ }
27
+ // token は Authenticated Gateway のときだけ apiKey として送る。unauthenticated では undefined で可。
28
+ return {
29
+ aigateway: createAiGateway({
30
+ accountId: config.accountId,
31
+ gateway: config.gateway,
32
+ apiKey: config.token,
33
+ options: config.options,
34
+ }),
35
+ };
36
+ }
@@ -0,0 +1,9 @@
1
+ /**
2
+ * CloudFront 署名付き URL 生成(`@aws-sdk/cloudfront-signer` の getSignedUrl を Web Crypto で再実装)。
3
+ * Cloudflare Workers ネイティブ(aws-sdk 不要)。フリート共通 = tipsys/winecode hono。
4
+ *
5
+ * canned policy を RSASSA-PKCS1-v1_5 + SHA-1 で署名し、AWS の URL-safe base64 変換
6
+ * '+' -> '-' , '/' -> '~' , '=' -> '_'
7
+ * を施して `Expires` / `Key-Pair-Id` / `Signature` の順でクエリを付与する(aws-sdk の出力とバイト一致)。
8
+ */
9
+ export declare function getCloudFrontSignedUrl(url: string, privateKeyPem: string, keyPairId: string, dateLessThan: string | number | Date): Promise<string>;
@@ -0,0 +1,43 @@
1
+ /**
2
+ * CloudFront 署名付き URL 生成(`@aws-sdk/cloudfront-signer` の getSignedUrl を Web Crypto で再実装)。
3
+ * Cloudflare Workers ネイティブ(aws-sdk 不要)。フリート共通 = tipsys/winecode hono。
4
+ *
5
+ * canned policy を RSASSA-PKCS1-v1_5 + SHA-1 で署名し、AWS の URL-safe base64 変換
6
+ * '+' -> '-' , '/' -> '~' , '=' -> '_'
7
+ * を施して `Expires` / `Key-Pair-Id` / `Signature` の順でクエリを付与する(aws-sdk の出力とバイト一致)。
8
+ */
9
+ export async function getCloudFrontSignedUrl(url, privateKeyPem, keyPairId, dateLessThan) {
10
+ const epochSeconds = Math.round(new Date(dateLessThan).getTime() / 1000);
11
+ const policy = JSON.stringify({
12
+ Statement: [{ Resource: url, Condition: { DateLessThan: { 'AWS:EpochTime': epochSeconds } } }],
13
+ });
14
+ const key = await crypto.subtle.importKey('pkcs8', pemToDer(privateKeyPem), { name: 'RSASSA-PKCS1-v1_5', hash: 'SHA-1' }, false, ['sign']);
15
+ const signatureBuffer = await crypto.subtle.sign({ name: 'RSASSA-PKCS1-v1_5' }, key, new TextEncoder().encode(policy));
16
+ const signature = toUrlSafeBase64(arrayBufferToBase64(signatureBuffer));
17
+ const separator = url.includes('?') ? '&' : '?';
18
+ // @aws-sdk/cloudfront-signer のクエリ順: Expires -> Key-Pair-Id -> Signature
19
+ return `${url}${separator}Expires=${epochSeconds}&Key-Pair-Id=${keyPairId}&Signature=${signature}`;
20
+ }
21
+ function toUrlSafeBase64(value) {
22
+ return value.replace(/\+/g, '-').replace(/=/g, '_').replace(/\//g, '~');
23
+ }
24
+ function arrayBufferToBase64(buffer) {
25
+ const bytes = new Uint8Array(buffer);
26
+ let binary = '';
27
+ for (const b of bytes) {
28
+ binary += String.fromCharCode(b);
29
+ }
30
+ return btoa(binary);
31
+ }
32
+ function pemToDer(pem) {
33
+ const base64 = pem
34
+ .replace(/-----BEGIN [^-]+-----/, '')
35
+ .replace(/-----END [^-]+-----/, '')
36
+ .replace(/\s+/g, '');
37
+ const binary = atob(base64);
38
+ const bytes = new Uint8Array(binary.length);
39
+ for (let i = 0; i < binary.length; i++) {
40
+ bytes[i] = binary.charCodeAt(i);
41
+ }
42
+ return bytes.buffer;
43
+ }
@@ -0,0 +1,14 @@
1
+ /**
2
+ * AWS Secrets Manager の GetSecretValue を aws4fetch(SigV4 署名 fetch)で叩く汎用ヘルパ。
3
+ * Cloudflare Workers には AWS SDK も IAM ロールも無いため、AWS の静的キーを Workers secrets として
4
+ * 渡して署名する(移植元 `api/src/secrets-manager.ts` 相当)。DB 認証情報は Hyperdrive 側に持つので対象外。
5
+ *
6
+ * Secret の中身(スキーマ)と secretId は repo ごとに異なるため、`<T>` と `secretId` を呼び出し側が渡す。
7
+ */
8
+ export interface AwsSecretsOptions {
9
+ accessKeyId: string;
10
+ secretAccessKey: string;
11
+ sessionToken?: string;
12
+ region: string;
13
+ }
14
+ export declare function getAuthenticationSecret<T>(options: AwsSecretsOptions, secretId: string): Promise<T>;
@@ -0,0 +1,43 @@
1
+ import { AwsClient } from 'aws4fetch';
2
+ /**
3
+ * Per-isolate cache: Secrets Manager は isolate ごと 1 回だけ叩く。region+accessKeyId+secretId を
4
+ * キーにし、資格情報ローテーション時は再取得する。promise をキャッシュして同時初回リクエストが 1 回の
5
+ * 呼び出しを共有する。reject 時はキャッシュをクリアして retry を許す。
6
+ */
7
+ let cache = null;
8
+ export function getAuthenticationSecret(options, secretId) {
9
+ const key = `${options.region}:${options.accessKeyId}:${secretId}`;
10
+ if (cache?.key !== key) {
11
+ const value = fetchSecret(options, secretId).catch((error) => {
12
+ cache = null;
13
+ throw error;
14
+ });
15
+ cache = { key, value };
16
+ }
17
+ return cache.value;
18
+ }
19
+ async function fetchSecret(options, secretId) {
20
+ const aws = new AwsClient({
21
+ accessKeyId: options.accessKeyId,
22
+ secretAccessKey: options.secretAccessKey,
23
+ sessionToken: options.sessionToken,
24
+ service: 'secretsmanager',
25
+ region: options.region,
26
+ });
27
+ const response = await aws.fetch(`https://secretsmanager.${options.region}.amazonaws.com/`, {
28
+ method: 'POST',
29
+ headers: {
30
+ 'Content-Type': 'application/x-amz-json-1.1',
31
+ 'X-Amz-Target': 'secretsmanager.GetSecretValue',
32
+ },
33
+ body: JSON.stringify({ SecretId: secretId, VersionStage: 'AWSCURRENT' }),
34
+ });
35
+ if (!response.ok) {
36
+ throw new Error(`Secrets Manager GetSecretValue failed: ${response.status} ${await response.text()}`);
37
+ }
38
+ const body = (await response.json());
39
+ if (!body.SecretString) {
40
+ throw new Error('Secrets Manager GetSecretValue returned no SecretString');
41
+ }
42
+ return JSON.parse(body.SecretString);
43
+ }
@@ -0,0 +1,51 @@
1
+ /**
2
+ * Workers KV を使った cache-aside キャッシュ(フリート共通 = winecode/tipsys hono の CacheService)。
3
+ * 同一 DB を参照する透過キャッシュなので `/api` とレスポンスは一致する(perf 層であり parity に影響しない)。
4
+ *
5
+ * キー構成は各 repo の `/api`(旧 Valkey)と一致させる:
6
+ * `${appName}${version}${table}_${type}_${column}` (column: id が string なら sha256hex、number はそのまま)
7
+ * KV の expirationTtl は 60s 下限のため lifetime を 60 でクランプする。
8
+ */
9
+ /** @cloudflare/workers-types の KVNamespace 最小サブセット(get/put/delete のみ使用)。 */
10
+ export interface KVNamespace {
11
+ get(key: string): Promise<string | null>;
12
+ put(key: string, value: string, options?: {
13
+ expirationTtl?: number;
14
+ }): Promise<void>;
15
+ delete(key: string): Promise<void>;
16
+ }
17
+ export interface KVCacheOptions {
18
+ /** キー前置(repo 名)。例 `'winecode'` / `'tipsys'`。 */
19
+ appName: string;
20
+ /** バージョン前置。既定 `'v8_'`。 */
21
+ version?: string;
22
+ /** lifetime の下限秒(KV の最小 TTL)。既定 `60`。 */
23
+ minTtlSeconds?: number;
24
+ /** lifetime 未指定時の既定秒。既定 `600`。 */
25
+ defaultLifetime?: number;
26
+ }
27
+ interface CacheSetItem {
28
+ table: string;
29
+ type: string | number;
30
+ id: string | number;
31
+ data: unknown;
32
+ lifetime?: number;
33
+ }
34
+ interface CacheKeyItem {
35
+ table: string;
36
+ type: string | number;
37
+ id: string | number;
38
+ }
39
+ export declare class KVCache {
40
+ #private;
41
+ constructor(kv: KVNamespace, options: KVCacheOptions);
42
+ get<T>(table: string, type: string | number, id: string | number): Promise<T | undefined>;
43
+ set(table: string, type: string | number, id: string | number, data: unknown, lifetime?: number): Promise<void>;
44
+ setMany(items: CacheSetItem[]): Promise<void>;
45
+ getMany<T>(items: CacheKeyItem[]): Promise<{
46
+ id: string | number;
47
+ value: T | undefined;
48
+ }[]>;
49
+ delete(table: string, type: string | number, id: string | number): Promise<void>;
50
+ }
51
+ export {};
@@ -0,0 +1,88 @@
1
+ /**
2
+ * Workers KV を使った cache-aside キャッシュ(フリート共通 = winecode/tipsys hono の CacheService)。
3
+ * 同一 DB を参照する透過キャッシュなので `/api` とレスポンスは一致する(perf 層であり parity に影響しない)。
4
+ *
5
+ * キー構成は各 repo の `/api`(旧 Valkey)と一致させる:
6
+ * `${appName}${version}${table}_${type}_${column}` (column: id が string なら sha256hex、number はそのまま)
7
+ * KV の expirationTtl は 60s 下限のため lifetime を 60 でクランプする。
8
+ */
9
+ const encoder = new TextEncoder();
10
+ async function sha256Hex(input) {
11
+ const digest = await crypto.subtle.digest('SHA-256', encoder.encode(input));
12
+ return [...new Uint8Array(digest)].map((b) => b.toString(16).padStart(2, '0')).join('');
13
+ }
14
+ export class KVCache {
15
+ #kv;
16
+ #appName;
17
+ #version;
18
+ #minTtl;
19
+ #defaultLifetime;
20
+ constructor(kv, options) {
21
+ this.#kv = kv;
22
+ this.#appName = options.appName;
23
+ this.#version = options.version ?? 'v8_';
24
+ this.#minTtl = options.minTtlSeconds ?? 60;
25
+ this.#defaultLifetime = options.defaultLifetime ?? 600;
26
+ }
27
+ async #buildKey(table, type, id) {
28
+ const column = typeof id === 'string' ? await sha256Hex(id) : id;
29
+ const key = `${this.#appName}${this.#version}${table}_${type}_${column}`;
30
+ // KV のキーは 512〜1024 バイト上限。超えるものはキャッシュ対象外(cache-aside なので DB 直読みに落ちる)。
31
+ if (encoder.encode(key).byteLength > 1024) {
32
+ return undefined;
33
+ }
34
+ return key;
35
+ }
36
+ async get(table, type, id) {
37
+ const key = await this.#buildKey(table, type, id);
38
+ if (!key) {
39
+ return undefined;
40
+ }
41
+ try {
42
+ const data = await this.#kv.get(key);
43
+ if (!data) {
44
+ return undefined;
45
+ }
46
+ return JSON.parse(data);
47
+ }
48
+ catch {
49
+ return undefined;
50
+ }
51
+ }
52
+ async set(table, type, id, data, lifetime) {
53
+ if (!data) {
54
+ return;
55
+ }
56
+ const key = await this.#buildKey(table, type, id);
57
+ if (!key) {
58
+ return;
59
+ }
60
+ let payload;
61
+ try {
62
+ payload = JSON.stringify(data);
63
+ }
64
+ catch {
65
+ return;
66
+ }
67
+ const ttl = Math.max(this.#minTtl, lifetime ?? this.#defaultLifetime);
68
+ await this.#kv.put(key, payload, { expirationTtl: ttl }).catch(() => undefined);
69
+ }
70
+ async setMany(items) {
71
+ if (items.length === 0) {
72
+ return;
73
+ }
74
+ await Promise.all(items.map((i) => this.set(i.table, i.type, i.id, i.data, i.lifetime)));
75
+ }
76
+ // T は呼び出し側が指定する戻り値型(get<T> と同じく ergonomics 目的)。
77
+ // eslint-disable-next-line @typescript-eslint/no-unnecessary-type-parameters
78
+ async getMany(items) {
79
+ return Promise.all(items.map(async (i) => ({ id: i.id, value: await this.get(i.table, i.type, i.id) })));
80
+ }
81
+ async delete(table, type, id) {
82
+ const key = await this.#buildKey(table, type, id);
83
+ if (!key) {
84
+ return;
85
+ }
86
+ await this.#kv.delete(key).catch(() => undefined);
87
+ }
88
+ }
@@ -0,0 +1,41 @@
1
+ import type { Connection } from 'mysql2/promise';
2
+ /**
3
+ * Hyperdrive バインディングの最小形(@cloudflare/workers-types への依存を避けるための構造型)。
4
+ */
5
+ export interface HyperdriveLike {
6
+ host: string;
7
+ user: string;
8
+ password: string;
9
+ database: string;
10
+ port: number;
11
+ }
12
+ /**
13
+ * Hyperdrive バインディングから mysql2 の createConnection 用オプションを作る。
14
+ * `disableEval: true`(Workers で eval 不可)は既定で付与。`extra` で timezone 等を上書き/追加。
15
+ *
16
+ * `decimalNumbers: true`: DECIMAL/NEWDECIMAL を文字列でなく JS number で返す。Drizzle の
17
+ * `$inferSelect`(decimal→string)と生 SQL reads の戻り値を、各 repo の数値ドメイン型
18
+ * (nutrition の number 等)に揃えるため既定で有効化。precision/scale が JS の安全整数域
19
+ * (decimal(15,2) 程度まで)を超える列が無いことが前提。
20
+ *
21
+ * `timezone: '+09:00'`: フリートの接続先 RDB は session time_zone=Asia/Tokyo。mysql2 の driver
22
+ * `timezone` 既定は `'local'`=Workers では UTC で、揃わないと `datetime/timestamp` の生 Date 読みが
23
+ * +9h・生 Date 書きが −9h ズレる(NestJS は JST 実行で一致=移植で顕在化する潜在バグ)。driver を
24
+ * 固定すれば round-trip の観測値は DB の session tz に非依存(内部格納 UTC 値だけ変わるが app 不可視)。
25
+ * 非 JST repo は `extra: { timezone: '...' }` で上書き可。
26
+ */
27
+ export declare function hyperdriveConnectionOptions(hyperdrive: HyperdriveLike, extra?: Record<string, unknown>): Record<string, unknown>;
28
+ export interface ExecutionContextLike {
29
+ waitUntil(promise: Promise<unknown>): void;
30
+ }
31
+ /**
32
+ * primary/replica の接続を開いて `fn` を実行し、finally で `ctx.waitUntil` 越しに閉じる
33
+ * (receptray/tipsys の worker entry の接続ライフサイクル相当)。
34
+ */
35
+ export declare function withMysqlConnections<T>(hyperdrives: {
36
+ primary: HyperdriveLike;
37
+ replica: HyperdriveLike;
38
+ }, ctx: ExecutionContextLike, fn: (connections: {
39
+ primary: Connection;
40
+ replica: Connection;
41
+ }) => Promise<T>, connectionOptions?: Record<string, unknown>): Promise<T>;