@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.
- package/LICENSE +21 -0
- package/README.md +247 -0
- package/dist/ai/gateway.d.ts +40 -0
- package/dist/ai/gateway.js +36 -0
- package/dist/aws/cloudfront.d.ts +9 -0
- package/dist/aws/cloudfront.js +43 -0
- package/dist/aws/secrets-manager.d.ts +14 -0
- package/dist/aws/secrets-manager.js +43 -0
- package/dist/cache/kv-cache.d.ts +51 -0
- package/dist/cache/kv-cache.js +88 -0
- package/dist/db/connection.d.ts +41 -0
- package/dist/db/connection.js +48 -0
- package/dist/db/database.d.ts +60 -0
- package/dist/db/database.js +63 -0
- package/dist/db/index.d.ts +10 -0
- package/dist/db/index.js +8 -0
- package/dist/db/jst.d.ts +19 -0
- package/dist/db/jst.js +48 -0
- package/dist/db/orm-config.d.ts +62 -0
- package/dist/db/orm-config.js +42 -0
- package/dist/db/retry.d.ts +6 -0
- package/dist/db/retry.js +22 -0
- package/dist/db/write-result.d.ts +16 -0
- package/dist/db/write-result.js +13 -0
- package/dist/firebase/firebase-verifier.d.ts +17 -0
- package/dist/firebase/firebase-verifier.js +1 -0
- package/dist/firebase/identity-toolkit.d.ts +24 -0
- package/dist/firebase/identity-toolkit.js +64 -0
- package/dist/firebase/jose-firebase-verifier.d.ts +32 -0
- package/dist/firebase/jose-firebase-verifier.js +52 -0
- package/dist/firebase/remote-verifier.d.ts +9 -0
- package/dist/firebase/remote-verifier.js +44 -0
- package/dist/http/app-env.d.ts +19 -0
- package/dist/http/app-env.js +16 -0
- package/dist/http/app-info.d.ts +11 -0
- package/dist/http/app-info.js +8 -0
- package/dist/http/http-status.d.ts +62 -0
- package/dist/http/http-status.js +63 -0
- package/dist/http/nest-error.d.ts +72 -0
- package/dist/http/nest-error.js +65 -0
- package/dist/http/user-protocol.d.ts +11 -0
- package/dist/http/user-protocol.js +8 -0
- package/dist/index.d.ts +30 -0
- package/dist/index.js +28 -0
- package/dist/middleware/auth.d.ts +36 -0
- package/dist/middleware/auth.js +30 -0
- package/dist/middleware/finalize-response.d.ts +2 -0
- package/dist/middleware/finalize-response.js +53 -0
- package/dist/middleware/validation.d.ts +77 -0
- package/dist/middleware/validation.js +53 -0
- package/dist/middleware/zod-coerce.d.ts +9 -0
- package/dist/middleware/zod-coerce.js +50 -0
- package/dist/stripe/client.d.ts +19 -0
- package/dist/stripe/client.js +23 -0
- package/dist/testing/auth.d.ts +36 -0
- package/dist/testing/auth.js +42 -0
- package/dist/testing/configurable-fake.d.ts +14 -0
- package/dist/testing/configurable-fake.js +33 -0
- package/dist/testing/db.d.ts +37 -0
- package/dist/testing/db.js +74 -0
- package/dist/testing/fakes.d.ts +35 -0
- package/dist/testing/fakes.js +56 -0
- package/dist/testing/index.d.ts +8 -0
- package/dist/testing/index.js +10 -0
- package/dist/testing/stripe-fixtures.d.ts +13 -0
- package/dist/testing/stripe-fixtures.js +76 -0
- package/package.json +113 -0
- package/scripts/sync-dev-aws.mjs +59 -0
- package/src/ai/gateway.ts +81 -0
- package/src/aws/cloudfront.ts +65 -0
- package/src/aws/secrets-manager.ts +63 -0
- package/src/cache/kv-cache.ts +134 -0
- package/src/db/connection.ts +73 -0
- package/src/db/database.ts +133 -0
- package/src/db/index.ts +27 -0
- package/src/db/jst.ts +56 -0
- package/src/db/orm-config.ts +71 -0
- package/src/db/retry.ts +21 -0
- package/src/db/write-result.ts +23 -0
- package/src/firebase/firebase-verifier.ts +15 -0
- package/src/firebase/identity-toolkit.ts +82 -0
- package/src/firebase/jose-firebase-verifier.ts +71 -0
- package/src/firebase/remote-verifier.ts +49 -0
- package/src/http/app-env.ts +20 -0
- package/src/http/app-info.ts +16 -0
- package/src/http/http-status.ts +62 -0
- package/src/http/nest-error.ts +138 -0
- package/src/http/user-protocol.ts +16 -0
- package/src/index.ts +55 -0
- package/src/middleware/auth.ts +67 -0
- package/src/middleware/finalize-response.ts +61 -0
- package/src/middleware/validation.ts +84 -0
- package/src/middleware/zod-coerce.ts +65 -0
- package/src/stripe/client.ts +48 -0
- package/src/testing/auth.ts +62 -0
- package/src/testing/configurable-fake.ts +33 -0
- package/src/testing/db.ts +125 -0
- package/src/testing/fakes.ts +75 -0
- package/src/testing/index.ts +26 -0
- package/src/testing/stripe-fixtures.ts +85 -0
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Stripe オブジェクトの test fixture factory。実 SDK 型は巨大なので、テストが参照する範囲だけを
|
|
3
|
+
* 妥当な既定値で組み、`over` で上書きする(最後に 1 度だけ Stripe 型へキャスト)。fleet 各 repo の
|
|
4
|
+
* 課金テストで同じダミー PaymentIntent/Event/... を手組みしていた重複を集約する。
|
|
5
|
+
*/
|
|
6
|
+
export function fakeApiList(data, over = {}) {
|
|
7
|
+
return {
|
|
8
|
+
object: 'list',
|
|
9
|
+
data,
|
|
10
|
+
has_more: false,
|
|
11
|
+
url: '/v1/_test',
|
|
12
|
+
...over,
|
|
13
|
+
};
|
|
14
|
+
}
|
|
15
|
+
export function fakePaymentIntent(over = {}) {
|
|
16
|
+
return {
|
|
17
|
+
id: 'pi_test_1',
|
|
18
|
+
object: 'payment_intent',
|
|
19
|
+
amount: 1000,
|
|
20
|
+
currency: 'jpy',
|
|
21
|
+
status: 'succeeded',
|
|
22
|
+
created: 1_700_000_000,
|
|
23
|
+
...over,
|
|
24
|
+
};
|
|
25
|
+
}
|
|
26
|
+
export function fakeStripeEvent(type, dataObject, over = {}) {
|
|
27
|
+
return {
|
|
28
|
+
id: 'evt_test_1',
|
|
29
|
+
object: 'event',
|
|
30
|
+
api_version: '2024-06-20',
|
|
31
|
+
created: 1_700_000_000,
|
|
32
|
+
livemode: false,
|
|
33
|
+
type,
|
|
34
|
+
data: { object: dataObject },
|
|
35
|
+
...over,
|
|
36
|
+
};
|
|
37
|
+
}
|
|
38
|
+
export function fakeCheckoutSession(over = {}) {
|
|
39
|
+
return {
|
|
40
|
+
id: 'cs_test_1',
|
|
41
|
+
object: 'checkout.session',
|
|
42
|
+
url: 'https://checkout.stripe.test/cs_test_1',
|
|
43
|
+
mode: 'subscription',
|
|
44
|
+
status: 'open',
|
|
45
|
+
...over,
|
|
46
|
+
};
|
|
47
|
+
}
|
|
48
|
+
export function fakeCustomer(over = {}) {
|
|
49
|
+
return {
|
|
50
|
+
id: 'cus_test_1',
|
|
51
|
+
object: 'customer',
|
|
52
|
+
created: 1_700_000_000,
|
|
53
|
+
livemode: false,
|
|
54
|
+
...over,
|
|
55
|
+
};
|
|
56
|
+
}
|
|
57
|
+
export function fakePrice(over = {}) {
|
|
58
|
+
return {
|
|
59
|
+
id: 'price_test_1',
|
|
60
|
+
object: 'price',
|
|
61
|
+
active: true,
|
|
62
|
+
currency: 'jpy',
|
|
63
|
+
unit_amount: 1000,
|
|
64
|
+
...over,
|
|
65
|
+
};
|
|
66
|
+
}
|
|
67
|
+
export function fakeSubscription(over = {}) {
|
|
68
|
+
return {
|
|
69
|
+
id: 'sub_test_1',
|
|
70
|
+
object: 'subscription',
|
|
71
|
+
status: 'active',
|
|
72
|
+
customer: 'cus_test_1',
|
|
73
|
+
created: 1_700_000_000,
|
|
74
|
+
...over,
|
|
75
|
+
};
|
|
76
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@rdlabo/workers-hono-kit",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"private": false,
|
|
5
|
+
"publishConfig": {
|
|
6
|
+
"access": "public"
|
|
7
|
+
},
|
|
8
|
+
"type": "module",
|
|
9
|
+
"description": "Infrastructure toolkit for building APIs on Hono + Cloudflare Workers: Firebase ID-token verification (jose), AWS Secrets Manager (aws4fetch), ETag/validation/zod-coerce middleware, deadlock retry, and shared HTTP helpers.",
|
|
10
|
+
"author": "Masahiko Sakakibara<sakakibara@rdlabo.jp>",
|
|
11
|
+
"license": "MIT",
|
|
12
|
+
"repository": {
|
|
13
|
+
"type": "git",
|
|
14
|
+
"url": "git+https://github.com/rdlabo-team/workers-hono-kit.git"
|
|
15
|
+
},
|
|
16
|
+
"bugs": {
|
|
17
|
+
"url": "https://github.com/rdlabo-team/workers-hono-kit/issues"
|
|
18
|
+
},
|
|
19
|
+
"homepage": "https://github.com/rdlabo-team/workers-hono-kit#readme",
|
|
20
|
+
"keywords": [
|
|
21
|
+
"hono",
|
|
22
|
+
"cloudflare-workers",
|
|
23
|
+
"workers",
|
|
24
|
+
"edge",
|
|
25
|
+
"firebase",
|
|
26
|
+
"jose",
|
|
27
|
+
"aws-secrets-manager",
|
|
28
|
+
"middleware",
|
|
29
|
+
"etag",
|
|
30
|
+
"zod",
|
|
31
|
+
"nestjs"
|
|
32
|
+
],
|
|
33
|
+
"engines": {
|
|
34
|
+
"node": ">=20.0.0"
|
|
35
|
+
},
|
|
36
|
+
"files": [
|
|
37
|
+
"dist",
|
|
38
|
+
"src",
|
|
39
|
+
"scripts",
|
|
40
|
+
"!src/**/*.spec.ts"
|
|
41
|
+
],
|
|
42
|
+
"bin": {
|
|
43
|
+
"workers-hono-kit-sync-dev-aws": "./scripts/sync-dev-aws.mjs"
|
|
44
|
+
},
|
|
45
|
+
"exports": {
|
|
46
|
+
".": {
|
|
47
|
+
"types": "./dist/index.d.ts",
|
|
48
|
+
"import": "./dist/index.js",
|
|
49
|
+
"default": "./dist/index.js"
|
|
50
|
+
},
|
|
51
|
+
"./db": {
|
|
52
|
+
"types": "./dist/db/index.d.ts",
|
|
53
|
+
"import": "./dist/db/index.js",
|
|
54
|
+
"default": "./dist/db/index.js"
|
|
55
|
+
},
|
|
56
|
+
"./testing": {
|
|
57
|
+
"types": "./dist/testing/index.d.ts",
|
|
58
|
+
"import": "./dist/testing/index.js",
|
|
59
|
+
"default": "./dist/testing/index.js"
|
|
60
|
+
}
|
|
61
|
+
},
|
|
62
|
+
"scripts": {
|
|
63
|
+
"build": "tsc -p tsconfig.build.json",
|
|
64
|
+
"prepare": "npm run build",
|
|
65
|
+
"typecheck": "tsc --noEmit",
|
|
66
|
+
"test": "vitest run",
|
|
67
|
+
"test:watch": "vitest",
|
|
68
|
+
"lint": "eslint \"src/**/*.ts\"",
|
|
69
|
+
"lint:fix": "eslint \"src/**/*.ts\" --fix",
|
|
70
|
+
"format": "prettier --write \"src/**/*.ts\"",
|
|
71
|
+
"format:check": "prettier --check \"src/**/*.ts\""
|
|
72
|
+
},
|
|
73
|
+
"peerDependencies": {
|
|
74
|
+
"@hono/zod-validator": "^0.8.0",
|
|
75
|
+
"ai": "^6.0.0",
|
|
76
|
+
"ai-gateway-provider": "^3.1.0",
|
|
77
|
+
"aws4fetch": "^1.0.20",
|
|
78
|
+
"drizzle-orm": "^0.45.2",
|
|
79
|
+
"hono": "^4.6.0",
|
|
80
|
+
"jose": "^6.2.0",
|
|
81
|
+
"mysql2": "^3.14.0",
|
|
82
|
+
"stripe": ">=15",
|
|
83
|
+
"zod": "^4.4.0"
|
|
84
|
+
},
|
|
85
|
+
"peerDependenciesMeta": {
|
|
86
|
+
"ai": { "optional": true },
|
|
87
|
+
"ai-gateway-provider": { "optional": true },
|
|
88
|
+
"stripe": { "optional": true },
|
|
89
|
+
"drizzle-orm": { "optional": true },
|
|
90
|
+
"mysql2": { "optional": true }
|
|
91
|
+
},
|
|
92
|
+
"devDependencies": {
|
|
93
|
+
"@ai-sdk/anthropic": "^3.0.84",
|
|
94
|
+
"@ai-sdk/openai": "^3.0.71",
|
|
95
|
+
"@hono/eslint-config": "^2.1.0",
|
|
96
|
+
"@hono/zod-validator": "^0.8.0",
|
|
97
|
+
"@types/node": "^22.19.21",
|
|
98
|
+
"ai": "^6.0.204",
|
|
99
|
+
"ai-gateway-provider": "^3.1.3",
|
|
100
|
+
"aws4fetch": "^1.0.20",
|
|
101
|
+
"drizzle-orm": "0.45.2",
|
|
102
|
+
"eslint": "^9.39.4",
|
|
103
|
+
"hono": "^4.6.0",
|
|
104
|
+
"jose": "^6.2.3",
|
|
105
|
+
"mysql2": "3.22.5",
|
|
106
|
+
"prettier": "^3.8.4",
|
|
107
|
+
"stripe": "^20.4.1",
|
|
108
|
+
"typescript": "~5.6.2",
|
|
109
|
+
"typescript-eslint": "^8.61.1",
|
|
110
|
+
"vitest": "^2.1.0",
|
|
111
|
+
"zod": "^4.4.3"
|
|
112
|
+
}
|
|
113
|
+
}
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// Launch `wrangler` with AWS credentials injected as --var, resolved from the active
|
|
3
|
+
// AWS profile. Nothing is written to disk (no .dev.vars).
|
|
4
|
+
//
|
|
5
|
+
// Cloudflare Workers has no AWS SDK default-credential chain, so the Worker reads
|
|
6
|
+
// env.AWS_* at runtime. This resolves them via `aws configure export-credentials`
|
|
7
|
+
// (which honors AWS_PROFILE) and passes them as `--var KEY:VALUE` bindings on the
|
|
8
|
+
// wrangler argv — in-memory only, supporting short-lived SSO/temporary creds
|
|
9
|
+
// (incl. AWS_SESSION_TOKEN). Re-resolved on every launch, so creds stay fresh.
|
|
10
|
+
//
|
|
11
|
+
// All args after the script are forwarded to `wrangler` verbatim; the AWS --var flags
|
|
12
|
+
// are appended. Wire it as the `dev` npm script (it spawns wrangler itself, so it
|
|
13
|
+
// replaces both the old `predev` file-sync and the `wrangler dev` invocation):
|
|
14
|
+
// AWS_PROFILE=<profile> node node_modules/@rdlabo/workers-hono-kit/scripts/sync-dev-aws.mjs dev --var APP_ENV:development
|
|
15
|
+
//
|
|
16
|
+
// Trade-off vs .dev.vars: creds appear in the wrangler process args (visible to other
|
|
17
|
+
// users via `ps` on a shared host). Fine for a personal dev machine; nothing on disk.
|
|
18
|
+
// Exits non-zero if the profile can't be resolved, so the dev server never starts blind.
|
|
19
|
+
import { spawnSync } from 'node:child_process';
|
|
20
|
+
|
|
21
|
+
const CRED_KEYS = ['AWS_ACCESS_KEY_ID', 'AWS_SECRET_ACCESS_KEY', 'AWS_SESSION_TOKEN'];
|
|
22
|
+
const profile = process.env.AWS_PROFILE ?? '(default)';
|
|
23
|
+
|
|
24
|
+
const forwarded = process.argv.slice(2);
|
|
25
|
+
if (forwarded.length === 0) {
|
|
26
|
+
console.error('[dev-aws] usage: node sync-dev-aws.mjs <wrangler-args...> (e.g. dev --var APP_ENV:development)');
|
|
27
|
+
process.exit(1);
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
const cred = spawnSync('aws', ['configure', 'export-credentials', '--format', 'env-no-export'], { encoding: 'utf8' });
|
|
31
|
+
if (cred.status !== 0) {
|
|
32
|
+
console.error(`[dev-aws] could not export credentials for AWS_PROFILE=${profile}.`);
|
|
33
|
+
console.error('[dev-aws] check the AWS CLI is installed and the profile is valid (e.g. aws sso login).');
|
|
34
|
+
process.exit(1);
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
// "KEY=VALUE" -> ["--var", "KEY:VALUE"]. AWS values are base64 (A-Za-z0-9+/=) and contain
|
|
38
|
+
// no ':', so wrangler's split-on-first-':' is safe. Split each line on its first '=' only.
|
|
39
|
+
const credVars = (cred.stdout ?? '')
|
|
40
|
+
.split('\n')
|
|
41
|
+
.filter((line) => CRED_KEYS.some((k) => line.startsWith(`${k}=`)))
|
|
42
|
+
.flatMap((line) => {
|
|
43
|
+
const eq = line.indexOf('=');
|
|
44
|
+
return ['--var', `${line.slice(0, eq)}:${line.slice(eq + 1)}`];
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
if (credVars.length === 0) {
|
|
48
|
+
console.error('[dev-aws] no AWS credentials were returned; aborting.');
|
|
49
|
+
process.exit(1);
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
// Run the repo-local wrangler regardless of how this script was invoked.
|
|
53
|
+
const binDir = `${process.cwd()}/node_modules/.bin`;
|
|
54
|
+
console.log(`[dev-aws] wrangler ${forwarded.join(' ')} (+${credVars.length / 2} AWS --var, AWS_PROFILE=${profile})`);
|
|
55
|
+
const run = spawnSync('wrangler', [...forwarded, ...credVars], {
|
|
56
|
+
stdio: 'inherit',
|
|
57
|
+
env: { ...process.env, PATH: `${binDir}:${process.env.PATH ?? ''}` },
|
|
58
|
+
});
|
|
59
|
+
process.exit(run.status ?? 1);
|
|
@@ -0,0 +1,81 @@
|
|
|
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
|
+
import type { AiGateway, AiGatewayBindingSettings, AiGatewayOptions } from 'ai-gateway-provider';
|
|
15
|
+
|
|
16
|
+
export type { AiGateway, AiGatewayOptions } from 'ai-gateway-provider';
|
|
17
|
+
|
|
18
|
+
/** Workers の AI binding(`env.AI.gateway(name)`)の最小形。Cloudflare の `AiGateway` が構造的に適合する。 */
|
|
19
|
+
export type AiGatewayBinding = AiGatewayBindingSettings['binding'];
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* AI Gateway 設定。2 系統:
|
|
23
|
+
* - binding 形: Workers ランタイム(本番 / `wrangler dev`)。`env.AI.gateway(name)` を渡す。
|
|
24
|
+
* binding 経由は同一アカウント内で事前認証されるため Gateway トークン不要。
|
|
25
|
+
* - REST 形: Workers 外(Node の eval ハーネス等、binding 不可)。accountId + gateway + token で REST。
|
|
26
|
+
*/
|
|
27
|
+
export type AiGatewayConfig =
|
|
28
|
+
| {
|
|
29
|
+
/** `env.AI.gateway(name)` 等の AI Gateway binding。 */
|
|
30
|
+
binding: AiGatewayBinding;
|
|
31
|
+
/** キャッシュ / リトライ / メタデータ等の Gateway オプション(任意)。 */
|
|
32
|
+
options?: AiGatewayOptions;
|
|
33
|
+
}
|
|
34
|
+
| {
|
|
35
|
+
/** Cloudflare アカウント ID。 */
|
|
36
|
+
accountId: string;
|
|
37
|
+
/** AI Gateway 名。 */
|
|
38
|
+
gateway: string;
|
|
39
|
+
/**
|
|
40
|
+
* `cf-aig-authorization` に載せる Gateway 認証トークン。Authenticated Gateway のときだけ必要。
|
|
41
|
+
* unauthenticated Gateway では省略可(プロバイダの API キーではなく Gateway 自体への認証)。
|
|
42
|
+
*/
|
|
43
|
+
token?: string;
|
|
44
|
+
/** キャッシュ / リトライ / メタデータ等の Gateway オプション(任意)。 */
|
|
45
|
+
options?: AiGatewayOptions;
|
|
46
|
+
};
|
|
47
|
+
|
|
48
|
+
export interface AiGatewayProvider {
|
|
49
|
+
/**
|
|
50
|
+
* `@ai-sdk/*` のモデルを包んで AI Gateway 経由にする。
|
|
51
|
+
* 例: `aigateway(createAnthropic({ apiKey }).('claude-...'))`。
|
|
52
|
+
* 配列を渡すとフォールバック(先頭から順に試行)になる。
|
|
53
|
+
*/
|
|
54
|
+
aigateway: AiGateway;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* AI Gateway 用のプロバイダを生成する。binding 形 / REST 形のどちらでも可(欠落時は fail-fast)。
|
|
59
|
+
*/
|
|
60
|
+
export function createAiGatewayProvider(config: AiGatewayConfig): AiGatewayProvider {
|
|
61
|
+
if ('binding' in config) {
|
|
62
|
+
return { aigateway: createAiGateway({ binding: config.binding, options: config.options }) };
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
if (!config.accountId) {
|
|
66
|
+
throw new Error('AI Gateway: accountId が未設定です');
|
|
67
|
+
}
|
|
68
|
+
if (!config.gateway) {
|
|
69
|
+
throw new Error('AI Gateway: gateway 名が未設定です');
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
// token は Authenticated Gateway のときだけ apiKey として送る。unauthenticated では undefined で可。
|
|
73
|
+
return {
|
|
74
|
+
aigateway: createAiGateway({
|
|
75
|
+
accountId: config.accountId,
|
|
76
|
+
gateway: config.gateway,
|
|
77
|
+
apiKey: config.token,
|
|
78
|
+
options: config.options,
|
|
79
|
+
}),
|
|
80
|
+
};
|
|
81
|
+
}
|
|
@@ -0,0 +1,65 @@
|
|
|
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(
|
|
10
|
+
url: string,
|
|
11
|
+
privateKeyPem: string,
|
|
12
|
+
keyPairId: string,
|
|
13
|
+
dateLessThan: string | number | Date,
|
|
14
|
+
): Promise<string> {
|
|
15
|
+
const epochSeconds = Math.round(new Date(dateLessThan).getTime() / 1000);
|
|
16
|
+
|
|
17
|
+
const policy = JSON.stringify({
|
|
18
|
+
Statement: [{ Resource: url, Condition: { DateLessThan: { 'AWS:EpochTime': epochSeconds } } }],
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
const key = await crypto.subtle.importKey(
|
|
22
|
+
'pkcs8',
|
|
23
|
+
pemToDer(privateKeyPem),
|
|
24
|
+
{ name: 'RSASSA-PKCS1-v1_5', hash: 'SHA-1' },
|
|
25
|
+
false,
|
|
26
|
+
['sign'],
|
|
27
|
+
);
|
|
28
|
+
const signatureBuffer = await crypto.subtle.sign(
|
|
29
|
+
{ name: 'RSASSA-PKCS1-v1_5' },
|
|
30
|
+
key,
|
|
31
|
+
new TextEncoder().encode(policy),
|
|
32
|
+
);
|
|
33
|
+
|
|
34
|
+
const signature = toUrlSafeBase64(arrayBufferToBase64(signatureBuffer));
|
|
35
|
+
const separator = url.includes('?') ? '&' : '?';
|
|
36
|
+
|
|
37
|
+
// @aws-sdk/cloudfront-signer のクエリ順: Expires -> Key-Pair-Id -> Signature
|
|
38
|
+
return `${url}${separator}Expires=${epochSeconds}&Key-Pair-Id=${keyPairId}&Signature=${signature}`;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function toUrlSafeBase64(value: string): string {
|
|
42
|
+
return value.replace(/\+/g, '-').replace(/=/g, '_').replace(/\//g, '~');
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function arrayBufferToBase64(buffer: ArrayBuffer): string {
|
|
46
|
+
const bytes = new Uint8Array(buffer);
|
|
47
|
+
let binary = '';
|
|
48
|
+
for (const b of bytes) {
|
|
49
|
+
binary += String.fromCharCode(b);
|
|
50
|
+
}
|
|
51
|
+
return btoa(binary);
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function pemToDer(pem: string): ArrayBuffer {
|
|
55
|
+
const base64 = pem
|
|
56
|
+
.replace(/-----BEGIN [^-]+-----/, '')
|
|
57
|
+
.replace(/-----END [^-]+-----/, '')
|
|
58
|
+
.replace(/\s+/g, '');
|
|
59
|
+
const binary = atob(base64);
|
|
60
|
+
const bytes = new Uint8Array(binary.length);
|
|
61
|
+
for (let i = 0; i < binary.length; i++) {
|
|
62
|
+
bytes[i] = binary.charCodeAt(i);
|
|
63
|
+
}
|
|
64
|
+
return bytes.buffer;
|
|
65
|
+
}
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
import { AwsClient } from 'aws4fetch';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* AWS Secrets Manager の GetSecretValue を aws4fetch(SigV4 署名 fetch)で叩く汎用ヘルパ。
|
|
5
|
+
* Cloudflare Workers には AWS SDK も IAM ロールも無いため、AWS の静的キーを Workers secrets として
|
|
6
|
+
* 渡して署名する(移植元 `api/src/secrets-manager.ts` 相当)。DB 認証情報は Hyperdrive 側に持つので対象外。
|
|
7
|
+
*
|
|
8
|
+
* Secret の中身(スキーマ)と secretId は repo ごとに異なるため、`<T>` と `secretId` を呼び出し側が渡す。
|
|
9
|
+
*/
|
|
10
|
+
export interface AwsSecretsOptions {
|
|
11
|
+
accessKeyId: string;
|
|
12
|
+
secretAccessKey: string;
|
|
13
|
+
sessionToken?: string;
|
|
14
|
+
region: string;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Per-isolate cache: Secrets Manager は isolate ごと 1 回だけ叩く。region+accessKeyId+secretId を
|
|
19
|
+
* キーにし、資格情報ローテーション時は再取得する。promise をキャッシュして同時初回リクエストが 1 回の
|
|
20
|
+
* 呼び出しを共有する。reject 時はキャッシュをクリアして retry を許す。
|
|
21
|
+
*/
|
|
22
|
+
let cache: { key: string; value: Promise<unknown> } | null = null;
|
|
23
|
+
|
|
24
|
+
export function getAuthenticationSecret<T>(options: AwsSecretsOptions, secretId: string): Promise<T> {
|
|
25
|
+
const key = `${options.region}:${options.accessKeyId}:${secretId}`;
|
|
26
|
+
if (cache?.key !== key) {
|
|
27
|
+
const value = fetchSecret(options, secretId).catch((error: unknown) => {
|
|
28
|
+
cache = null;
|
|
29
|
+
throw error;
|
|
30
|
+
});
|
|
31
|
+
cache = { key, value };
|
|
32
|
+
}
|
|
33
|
+
return cache.value as Promise<T>;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
async function fetchSecret(options: AwsSecretsOptions, secretId: string): Promise<unknown> {
|
|
37
|
+
const aws = new AwsClient({
|
|
38
|
+
accessKeyId: options.accessKeyId,
|
|
39
|
+
secretAccessKey: options.secretAccessKey,
|
|
40
|
+
sessionToken: options.sessionToken,
|
|
41
|
+
service: 'secretsmanager',
|
|
42
|
+
region: options.region,
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
const response = await aws.fetch(`https://secretsmanager.${options.region}.amazonaws.com/`, {
|
|
46
|
+
method: 'POST',
|
|
47
|
+
headers: {
|
|
48
|
+
'Content-Type': 'application/x-amz-json-1.1',
|
|
49
|
+
'X-Amz-Target': 'secretsmanager.GetSecretValue',
|
|
50
|
+
},
|
|
51
|
+
body: JSON.stringify({ SecretId: secretId, VersionStage: 'AWSCURRENT' }),
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
if (!response.ok) {
|
|
55
|
+
throw new Error(`Secrets Manager GetSecretValue failed: ${response.status} ${await response.text()}`);
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
const body = (await response.json()) as { SecretString?: string };
|
|
59
|
+
if (!body.SecretString) {
|
|
60
|
+
throw new Error('Secrets Manager GetSecretValue returned no SecretString');
|
|
61
|
+
}
|
|
62
|
+
return JSON.parse(body.SecretString);
|
|
63
|
+
}
|
|
@@ -0,0 +1,134 @@
|
|
|
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
|
+
|
|
10
|
+
/** @cloudflare/workers-types の KVNamespace 最小サブセット(get/put/delete のみ使用)。 */
|
|
11
|
+
export interface KVNamespace {
|
|
12
|
+
get(key: string): Promise<string | null>;
|
|
13
|
+
put(key: string, value: string, options?: { expirationTtl?: number }): Promise<void>;
|
|
14
|
+
delete(key: string): Promise<void>;
|
|
15
|
+
}
|
|
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
|
+
|
|
28
|
+
interface CacheSetItem {
|
|
29
|
+
table: string;
|
|
30
|
+
type: string | number;
|
|
31
|
+
id: string | number;
|
|
32
|
+
data: unknown;
|
|
33
|
+
lifetime?: number;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
interface CacheKeyItem {
|
|
37
|
+
table: string;
|
|
38
|
+
type: string | number;
|
|
39
|
+
id: string | number;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
const encoder = new TextEncoder();
|
|
43
|
+
|
|
44
|
+
async function sha256Hex(input: string): Promise<string> {
|
|
45
|
+
const digest = await crypto.subtle.digest('SHA-256', encoder.encode(input));
|
|
46
|
+
return [...new Uint8Array(digest)].map((b) => b.toString(16).padStart(2, '0')).join('');
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export class KVCache {
|
|
50
|
+
readonly #kv: KVNamespace;
|
|
51
|
+
readonly #appName: string;
|
|
52
|
+
readonly #version: string;
|
|
53
|
+
readonly #minTtl: number;
|
|
54
|
+
readonly #defaultLifetime: number;
|
|
55
|
+
|
|
56
|
+
constructor(kv: KVNamespace, options: KVCacheOptions) {
|
|
57
|
+
this.#kv = kv;
|
|
58
|
+
this.#appName = options.appName;
|
|
59
|
+
this.#version = options.version ?? 'v8_';
|
|
60
|
+
this.#minTtl = options.minTtlSeconds ?? 60;
|
|
61
|
+
this.#defaultLifetime = options.defaultLifetime ?? 600;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
async #buildKey(table: string, type: string | number, id: string | number): Promise<string | undefined> {
|
|
65
|
+
const column = typeof id === 'string' ? await sha256Hex(id) : id;
|
|
66
|
+
const key = `${this.#appName}${this.#version}${table}_${type}_${column}`;
|
|
67
|
+
// KV のキーは 512〜1024 バイト上限。超えるものはキャッシュ対象外(cache-aside なので DB 直読みに落ちる)。
|
|
68
|
+
if (encoder.encode(key).byteLength > 1024) {
|
|
69
|
+
return undefined;
|
|
70
|
+
}
|
|
71
|
+
return key;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
async get<T>(table: string, type: string | number, id: string | number): Promise<T | undefined> {
|
|
75
|
+
const key = await this.#buildKey(table, type, id);
|
|
76
|
+
if (!key) {
|
|
77
|
+
return undefined;
|
|
78
|
+
}
|
|
79
|
+
try {
|
|
80
|
+
const data = await this.#kv.get(key);
|
|
81
|
+
if (!data) {
|
|
82
|
+
return undefined;
|
|
83
|
+
}
|
|
84
|
+
return JSON.parse(data) as T;
|
|
85
|
+
} catch {
|
|
86
|
+
return undefined;
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
async set(
|
|
91
|
+
table: string,
|
|
92
|
+
type: string | number,
|
|
93
|
+
id: string | number,
|
|
94
|
+
data: unknown,
|
|
95
|
+
lifetime?: number,
|
|
96
|
+
): Promise<void> {
|
|
97
|
+
if (!data) {
|
|
98
|
+
return;
|
|
99
|
+
}
|
|
100
|
+
const key = await this.#buildKey(table, type, id);
|
|
101
|
+
if (!key) {
|
|
102
|
+
return;
|
|
103
|
+
}
|
|
104
|
+
let payload: string;
|
|
105
|
+
try {
|
|
106
|
+
payload = JSON.stringify(data);
|
|
107
|
+
} catch {
|
|
108
|
+
return;
|
|
109
|
+
}
|
|
110
|
+
const ttl = Math.max(this.#minTtl, lifetime ?? this.#defaultLifetime);
|
|
111
|
+
await this.#kv.put(key, payload, { expirationTtl: ttl }).catch(() => undefined);
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
async setMany(items: CacheSetItem[]): Promise<void> {
|
|
115
|
+
if (items.length === 0) {
|
|
116
|
+
return;
|
|
117
|
+
}
|
|
118
|
+
await Promise.all(items.map((i) => this.set(i.table, i.type, i.id, i.data, i.lifetime)));
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
// T は呼び出し側が指定する戻り値型(get<T> と同じく ergonomics 目的)。
|
|
122
|
+
// eslint-disable-next-line @typescript-eslint/no-unnecessary-type-parameters
|
|
123
|
+
async getMany<T>(items: CacheKeyItem[]): Promise<{ id: string | number; value: T | undefined }[]> {
|
|
124
|
+
return Promise.all(items.map(async (i) => ({ id: i.id, value: await this.get<T>(i.table, i.type, i.id) })));
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
async delete(table: string, type: string | number, id: string | number): Promise<void> {
|
|
128
|
+
const key = await this.#buildKey(table, type, id);
|
|
129
|
+
if (!key) {
|
|
130
|
+
return;
|
|
131
|
+
}
|
|
132
|
+
await this.#kv.delete(key).catch(() => undefined);
|
|
133
|
+
}
|
|
134
|
+
}
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
import { createConnection } from 'mysql2/promise';
|
|
2
|
+
import type { Connection } from 'mysql2/promise';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Hyperdrive バインディングの最小形(@cloudflare/workers-types への依存を避けるための構造型)。
|
|
6
|
+
*/
|
|
7
|
+
export interface HyperdriveLike {
|
|
8
|
+
host: string;
|
|
9
|
+
user: string;
|
|
10
|
+
password: string;
|
|
11
|
+
database: string;
|
|
12
|
+
port: number;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Hyperdrive バインディングから mysql2 の createConnection 用オプションを作る。
|
|
17
|
+
* `disableEval: true`(Workers で eval 不可)は既定で付与。`extra` で timezone 等を上書き/追加。
|
|
18
|
+
*
|
|
19
|
+
* `decimalNumbers: true`: DECIMAL/NEWDECIMAL を文字列でなく JS number で返す。Drizzle の
|
|
20
|
+
* `$inferSelect`(decimal→string)と生 SQL reads の戻り値を、各 repo の数値ドメイン型
|
|
21
|
+
* (nutrition の number 等)に揃えるため既定で有効化。precision/scale が JS の安全整数域
|
|
22
|
+
* (decimal(15,2) 程度まで)を超える列が無いことが前提。
|
|
23
|
+
*
|
|
24
|
+
* `timezone: '+09:00'`: フリートの接続先 RDB は session time_zone=Asia/Tokyo。mysql2 の driver
|
|
25
|
+
* `timezone` 既定は `'local'`=Workers では UTC で、揃わないと `datetime/timestamp` の生 Date 読みが
|
|
26
|
+
* +9h・生 Date 書きが −9h ズレる(NestJS は JST 実行で一致=移植で顕在化する潜在バグ)。driver を
|
|
27
|
+
* 固定すれば round-trip の観測値は DB の session tz に非依存(内部格納 UTC 値だけ変わるが app 不可視)。
|
|
28
|
+
* 非 JST repo は `extra: { timezone: '...' }` で上書き可。
|
|
29
|
+
*/
|
|
30
|
+
export function hyperdriveConnectionOptions(
|
|
31
|
+
hyperdrive: HyperdriveLike,
|
|
32
|
+
extra?: Record<string, unknown>,
|
|
33
|
+
): Record<string, unknown> {
|
|
34
|
+
return {
|
|
35
|
+
host: hyperdrive.host,
|
|
36
|
+
user: hyperdrive.user,
|
|
37
|
+
password: hyperdrive.password,
|
|
38
|
+
database: hyperdrive.database,
|
|
39
|
+
port: hyperdrive.port,
|
|
40
|
+
disableEval: true,
|
|
41
|
+
decimalNumbers: true,
|
|
42
|
+
timezone: '+09:00',
|
|
43
|
+
...extra,
|
|
44
|
+
};
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export interface ExecutionContextLike {
|
|
48
|
+
waitUntil(promise: Promise<unknown>): void;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* primary/replica の接続を開いて `fn` を実行し、finally で `ctx.waitUntil` 越しに閉じる
|
|
53
|
+
* (receptray/tipsys の worker entry の接続ライフサイクル相当)。
|
|
54
|
+
*/
|
|
55
|
+
export async function withMysqlConnections<T>(
|
|
56
|
+
hyperdrives: { primary: HyperdriveLike; replica: HyperdriveLike },
|
|
57
|
+
ctx: ExecutionContextLike,
|
|
58
|
+
fn: (connections: { primary: Connection; replica: Connection }) => Promise<T>,
|
|
59
|
+
connectionOptions?: Record<string, unknown>,
|
|
60
|
+
): Promise<T> {
|
|
61
|
+
let primary: Connection | undefined;
|
|
62
|
+
let replica: Connection | undefined;
|
|
63
|
+
try {
|
|
64
|
+
primary = await createConnection(hyperdriveConnectionOptions(hyperdrives.primary, connectionOptions));
|
|
65
|
+
replica = await createConnection(hyperdriveConnectionOptions(hyperdrives.replica, connectionOptions));
|
|
66
|
+
return await fn({ primary, replica });
|
|
67
|
+
} finally {
|
|
68
|
+
const closing = [primary, replica].filter((c): c is Connection => c !== undefined).map((c) => c.end());
|
|
69
|
+
if (closing.length > 0) {
|
|
70
|
+
ctx.waitUntil(Promise.allSettled(closing));
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
}
|