From d999ca7573e8ced9904eff1377593002ad69a2bc Mon Sep 17 00:00:00 2001 From: kappa Date: Thu, 29 Jan 2026 10:09:23 +0900 Subject: [PATCH] feat: migrate to Hono framework - Add Hono as HTTP framework for Cloudflare Workers - Create app.ts with declarative routing and middleware - Add hono-adapters.ts for auth, rate limit, request ID middleware - Refactor handlers to use Hono Context signature - Maintain all existing business logic unchanged Co-Authored-By: Claude Opus 4.5 --- package-lock.json | 540 ++++++++++++++++++++++++++++++++ package.json | 3 + src/app.ts | 191 +++++++++++ src/index.ts | 190 +---------- src/middleware/hono-adapters.ts | 110 +++++++ src/routes/health.ts | 18 +- src/routes/instances.ts | 21 +- src/routes/sync.ts | 21 +- 8 files changed, 885 insertions(+), 209 deletions(-) create mode 100644 src/app.ts create mode 100644 src/middleware/hono-adapters.ts diff --git a/package-lock.json b/package-lock.json index e10fc5a..2748ecc 100644 --- a/package-lock.json +++ b/package-lock.json @@ -7,8 +7,12 @@ "": { "name": "cloud-instances-api", "version": "1.0.0", + "dependencies": { + "hono": "^4.11.7" + }, "devDependencies": { "@cloudflare/workers-types": "^4.20241205.0", + "tsx": "^4.7.0", "typescript": "^5.7.2", "vitest": "^2.1.8", "wrangler": "^4.59.3" @@ -1832,6 +1836,28 @@ "node": "^8.16.0 || ^10.6.0 || >=11.0.0" } }, + "node_modules/get-tsconfig": { + "version": "4.13.0", + "resolved": "https://registry.npmjs.org/get-tsconfig/-/get-tsconfig-4.13.0.tgz", + "integrity": "sha512-1VKTZJCwBrvbd+Wn3AOgQP/2Av+TfTCOlE4AcRJE72W1ksZXbAx8PPBR9RzgTeSPzlPMHrbANMH3LbltH73wxQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "resolve-pkg-maps": "^1.0.0" + }, + "funding": { + "url": "https://github.com/privatenumber/get-tsconfig?sponsor=1" + } + }, + "node_modules/hono": { + "version": "4.11.7", + "resolved": "https://registry.npmjs.org/hono/-/hono-4.11.7.tgz", + "integrity": "sha512-l7qMiNee7t82bH3SeyUCt9UF15EVmaBvsppY2zQtrbIhl/yzBTny+YUxsVjSjQ6gaqaeVtZmGocom8TzBlA4Yw==", + "license": "MIT", + "engines": { + "node": ">=16.9.0" + } + }, "node_modules/kleur": { "version": "4.1.5", "resolved": "https://registry.npmjs.org/kleur/-/kleur-4.1.5.tgz", @@ -1967,6 +1993,16 @@ "node": "^10 || ^12 || >=14" } }, + "node_modules/resolve-pkg-maps": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/resolve-pkg-maps/-/resolve-pkg-maps-1.0.0.tgz", + "integrity": "sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/privatenumber/resolve-pkg-maps?sponsor=1" + } + }, "node_modules/rollup": { "version": "4.55.3", "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.55.3.tgz", @@ -2166,6 +2202,510 @@ "license": "0BSD", "optional": true }, + "node_modules/tsx": { + "version": "4.21.0", + "resolved": "https://registry.npmjs.org/tsx/-/tsx-4.21.0.tgz", + "integrity": "sha512-5C1sg4USs1lfG0GFb2RLXsdpXqBSEhAaA/0kPL01wxzpMqLILNxIxIOKiILz+cdg/pLnOUxFYOR5yhHU666wbw==", + "dev": true, + "license": "MIT", + "dependencies": { + "esbuild": "~0.27.0", + "get-tsconfig": "^4.7.5" + }, + "bin": { + "tsx": "dist/cli.mjs" + }, + "engines": { + "node": ">=18.0.0" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + } + }, + "node_modules/tsx/node_modules/@esbuild/aix-ppc64": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.27.2.tgz", + "integrity": "sha512-GZMB+a0mOMZs4MpDbj8RJp4cw+w1WV5NYD6xzgvzUJ5Ek2jerwfO2eADyI6ExDSUED+1X8aMbegahsJi+8mgpw==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/android-arm": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.27.2.tgz", + "integrity": "sha512-DVNI8jlPa7Ujbr1yjU2PfUSRtAUZPG9I1RwW4F4xFB1Imiu2on0ADiI/c3td+KmDtVKNbi+nffGDQMfcIMkwIA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/android-arm64": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.27.2.tgz", + "integrity": "sha512-pvz8ZZ7ot/RBphf8fv60ljmaoydPU12VuXHImtAs0XhLLw+EXBi2BLe3OYSBslR4rryHvweW5gmkKFwTiFy6KA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/android-x64": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.27.2.tgz", + "integrity": "sha512-z8Ank4Byh4TJJOh4wpz8g2vDy75zFL0TlZlkUkEwYXuPSgX8yzep596n6mT7905kA9uHZsf/o2OJZubl2l3M7A==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/darwin-arm64": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.27.2.tgz", + "integrity": "sha512-davCD2Zc80nzDVRwXTcQP/28fiJbcOwvdolL0sOiOsbwBa72kegmVU0Wrh1MYrbuCL98Omp5dVhQFWRKR2ZAlg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/darwin-x64": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.27.2.tgz", + "integrity": "sha512-ZxtijOmlQCBWGwbVmwOF/UCzuGIbUkqB1faQRf5akQmxRJ1ujusWsb3CVfk/9iZKr2L5SMU5wPBi1UWbvL+VQA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/freebsd-arm64": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.27.2.tgz", + "integrity": "sha512-lS/9CN+rgqQ9czogxlMcBMGd+l8Q3Nj1MFQwBZJyoEKI50XGxwuzznYdwcav6lpOGv5BqaZXqvBSiB/kJ5op+g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/freebsd-x64": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.27.2.tgz", + "integrity": "sha512-tAfqtNYb4YgPnJlEFu4c212HYjQWSO/w/h/lQaBK7RbwGIkBOuNKQI9tqWzx7Wtp7bTPaGC6MJvWI608P3wXYA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/linux-arm": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.27.2.tgz", + "integrity": "sha512-vWfq4GaIMP9AIe4yj1ZUW18RDhx6EPQKjwe7n8BbIecFtCQG4CfHGaHuh7fdfq+y3LIA2vGS/o9ZBGVxIDi9hw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/linux-arm64": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.27.2.tgz", + "integrity": "sha512-hYxN8pr66NsCCiRFkHUAsxylNOcAQaxSSkHMMjcpx0si13t1LHFphxJZUiGwojB1a/Hd5OiPIqDdXONia6bhTw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/linux-ia32": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.27.2.tgz", + "integrity": "sha512-MJt5BRRSScPDwG2hLelYhAAKh9imjHK5+NE/tvnRLbIqUWa+0E9N4WNMjmp/kXXPHZGqPLxggwVhz7QP8CTR8w==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/linux-loong64": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.27.2.tgz", + "integrity": "sha512-lugyF1atnAT463aO6KPshVCJK5NgRnU4yb3FUumyVz+cGvZbontBgzeGFO1nF+dPueHD367a2ZXe1NtUkAjOtg==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/linux-mips64el": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.27.2.tgz", + "integrity": "sha512-nlP2I6ArEBewvJ2gjrrkESEZkB5mIoaTswuqNFRv/WYd+ATtUpe9Y09RnJvgvdag7he0OWgEZWhviS1OTOKixw==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/linux-ppc64": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.27.2.tgz", + "integrity": "sha512-C92gnpey7tUQONqg1n6dKVbx3vphKtTHJaNG2Ok9lGwbZil6DrfyecMsp9CrmXGQJmZ7iiVXvvZH6Ml5hL6XdQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/linux-riscv64": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.27.2.tgz", + "integrity": "sha512-B5BOmojNtUyN8AXlK0QJyvjEZkWwy/FKvakkTDCziX95AowLZKR6aCDhG7LeF7uMCXEJqwa8Bejz5LTPYm8AvA==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/linux-s390x": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.27.2.tgz", + "integrity": "sha512-p4bm9+wsPwup5Z8f4EpfN63qNagQ47Ua2znaqGH6bqLlmJ4bx97Y9JdqxgGZ6Y8xVTixUnEkoKSHcpRlDnNr5w==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/linux-x64": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.27.2.tgz", + "integrity": "sha512-uwp2Tip5aPmH+NRUwTcfLb+W32WXjpFejTIOWZFw/v7/KnpCDKG66u4DLcurQpiYTiYwQ9B7KOeMJvLCu/OvbA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/netbsd-arm64": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.27.2.tgz", + "integrity": "sha512-Kj6DiBlwXrPsCRDeRvGAUb/LNrBASrfqAIok+xB0LxK8CHqxZ037viF13ugfsIpePH93mX7xfJp97cyDuTZ3cw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/netbsd-x64": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.27.2.tgz", + "integrity": "sha512-HwGDZ0VLVBY3Y+Nw0JexZy9o/nUAWq9MlV7cahpaXKW6TOzfVno3y3/M8Ga8u8Yr7GldLOov27xiCnqRZf0tCA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/openbsd-arm64": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.27.2.tgz", + "integrity": "sha512-DNIHH2BPQ5551A7oSHD0CKbwIA/Ox7+78/AWkbS5QoRzaqlev2uFayfSxq68EkonB+IKjiuxBFoV8ESJy8bOHA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/openbsd-x64": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.27.2.tgz", + "integrity": "sha512-/it7w9Nb7+0KFIzjalNJVR5bOzA9Vay+yIPLVHfIQYG/j+j9VTH84aNB8ExGKPU4AzfaEvN9/V4HV+F+vo8OEg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/openharmony-arm64": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.27.2.tgz", + "integrity": "sha512-LRBbCmiU51IXfeXk59csuX/aSaToeG7w48nMwA6049Y4J4+VbWALAuXcs+qcD04rHDuSCSRKdmY63sruDS5qag==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/sunos-x64": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.27.2.tgz", + "integrity": "sha512-kMtx1yqJHTmqaqHPAzKCAkDaKsffmXkPHThSfRwZGyuqyIeBvf08KSsYXl+abf5HDAPMJIPnbBfXvP2ZC2TfHg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/win32-arm64": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.27.2.tgz", + "integrity": "sha512-Yaf78O/B3Kkh+nKABUF++bvJv5Ijoy9AN1ww904rOXZFLWVc5OLOfL56W+C8F9xn5JQZa3UX6m+IktJnIb1Jjg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/win32-ia32": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.27.2.tgz", + "integrity": "sha512-Iuws0kxo4yusk7sw70Xa2E2imZU5HoixzxfGCdxwBdhiDgt9vX9VUCBhqcwY7/uh//78A1hMkkROMJq9l27oLQ==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/win32-x64": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.27.2.tgz", + "integrity": "sha512-sRdU18mcKf7F+YgheI/zGf5alZatMUTKj/jNS6l744f9u3WFu4v7twcUI9vu4mknF4Y9aDlblIie0IM+5xxaqQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/esbuild": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.27.2.tgz", + "integrity": "sha512-HyNQImnsOC7X9PMNaCIeAm4ISCQXs5a5YasTXVliKv4uuBo1dKrG0A+uQS8M5eXjVMnLg3WgXaKvprHlFJQffw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.27.2", + "@esbuild/android-arm": "0.27.2", + "@esbuild/android-arm64": "0.27.2", + "@esbuild/android-x64": "0.27.2", + "@esbuild/darwin-arm64": "0.27.2", + "@esbuild/darwin-x64": "0.27.2", + "@esbuild/freebsd-arm64": "0.27.2", + "@esbuild/freebsd-x64": "0.27.2", + "@esbuild/linux-arm": "0.27.2", + "@esbuild/linux-arm64": "0.27.2", + "@esbuild/linux-ia32": "0.27.2", + "@esbuild/linux-loong64": "0.27.2", + "@esbuild/linux-mips64el": "0.27.2", + "@esbuild/linux-ppc64": "0.27.2", + "@esbuild/linux-riscv64": "0.27.2", + "@esbuild/linux-s390x": "0.27.2", + "@esbuild/linux-x64": "0.27.2", + "@esbuild/netbsd-arm64": "0.27.2", + "@esbuild/netbsd-x64": "0.27.2", + "@esbuild/openbsd-arm64": "0.27.2", + "@esbuild/openbsd-x64": "0.27.2", + "@esbuild/openharmony-arm64": "0.27.2", + "@esbuild/sunos-x64": "0.27.2", + "@esbuild/win32-arm64": "0.27.2", + "@esbuild/win32-ia32": "0.27.2", + "@esbuild/win32-x64": "0.27.2" + } + }, "node_modules/typescript": { "version": "5.9.3", "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", diff --git a/package.json b/package.json index dba229e..56bd24e 100644 --- a/package.json +++ b/package.json @@ -33,5 +33,8 @@ "typescript": "^5.7.2", "vitest": "^2.1.8", "wrangler": "^4.59.3" + }, + "dependencies": { + "hono": "^4.11.7" } } diff --git a/src/app.ts b/src/app.ts new file mode 100644 index 0000000..0201e98 --- /dev/null +++ b/src/app.ts @@ -0,0 +1,191 @@ +/** + * Hono Application Setup + * + * Configures Hono app with CORS, security headers, and routes. + */ + +import { Hono } from 'hono'; +import type { Context } from 'hono'; +import type { Env } from './types'; +import { CORS, HTTP_STATUS } from './constants'; +import { createLogger } from './utils/logger'; +import { + requestIdMiddleware, + authMiddleware, + rateLimitMiddleware, + optionalAuthMiddleware, +} from './middleware/hono-adapters'; +import { handleHealth } from './routes/health'; +import { handleInstances } from './routes/instances'; +import { handleSync } from './routes/sync'; + +const logger = createLogger('[App]'); + +// Context variables type +type Variables = { + requestId: string; + authenticated?: boolean; +}; + +// Create Hono app with type-safe bindings +const app = new Hono<{ Bindings: Env; Variables: Variables }>(); + +/** + * Get CORS origin for request + * Reused from original index.ts logic + */ +function getCorsOrigin(c: Context<{ Bindings: Env; Variables: Variables }>): string { + const origin = c.req.header('Origin'); + const env = c.env; + + // Environment variable has explicit origin configured (highest priority) + if (env.CORS_ORIGIN && env.CORS_ORIGIN !== '*') { + return env.CORS_ORIGIN; + } + + // Build allowed origins list based on environment + const isDevelopment = env.ENVIRONMENT === 'development'; + const allowedOrigins = isDevelopment + ? [...CORS.ALLOWED_ORIGINS, ...CORS.DEVELOPMENT_ORIGINS] + : CORS.ALLOWED_ORIGINS; + + // Request origin is in allowed list + if (origin && allowedOrigins.includes(origin)) { + return origin; + } + + // Log unmatched origins for security monitoring + if (origin && !allowedOrigins.includes(origin)) { + const sanitizedOrigin = origin.replace(/[\r\n\t]/g, '').substring(0, 256); + logger.warn('Unmatched origin - using default', { + requested_origin: sanitizedOrigin, + environment: env.ENVIRONMENT || 'production', + default_origin: CORS.DEFAULT_ORIGIN, + }); + } + + // Return explicit default (no wildcard) + return CORS.DEFAULT_ORIGIN; +} + +/** + * CORS middleware + * Configured dynamically based on request origin + */ +app.use('*', async (c, next) => { + // Handle OPTIONS preflight - must come before await next() + if (c.req.method === 'OPTIONS') { + const origin = getCorsOrigin(c); + c.res.headers.set('Access-Control-Allow-Origin', origin); + c.res.headers.set('Access-Control-Allow-Methods', 'GET, POST, OPTIONS'); + c.res.headers.set('Access-Control-Allow-Headers', 'Content-Type, X-API-Key'); + c.res.headers.set('Access-Control-Max-Age', CORS.MAX_AGE); + return c.body(null, 204); + } + + await next(); + + // Set CORS headers after processing + const origin = getCorsOrigin(c); + c.res.headers.set('Access-Control-Allow-Origin', origin); + c.res.headers.set('Access-Control-Allow-Methods', 'GET, POST, OPTIONS'); + c.res.headers.set('Access-Control-Allow-Headers', 'Content-Type, X-API-Key'); + c.res.headers.set('Access-Control-Max-Age', CORS.MAX_AGE); + c.res.headers.set( + 'Access-Control-Expose-Headers', + 'X-RateLimit-Retry-After, Retry-After, X-Request-ID' + ); +}); + +/** + * Request ID middleware + * Adds unique request ID for tracing + */ +app.use('*', requestIdMiddleware); + +/** + * Security headers middleware + * Applied to all responses + */ +app.use('*', async (c, next) => { + await next(); + + // Add security headers to response + c.res.headers.set('X-Content-Type-Options', 'nosniff'); + c.res.headers.set('X-Frame-Options', 'DENY'); + c.res.headers.set('Strict-Transport-Security', 'max-age=31536000'); + c.res.headers.set('Content-Security-Policy', "default-src 'none'"); + c.res.headers.set('X-XSS-Protection', '1; mode=block'); + c.res.headers.set('Referrer-Policy', 'no-referrer'); +}); + +/** + * Environment validation middleware + * Checks required environment variables before processing + */ +app.use('*', async (c, next) => { + const required = ['API_KEY']; + const missing = required.filter((key) => !c.env[key as keyof Env]); + + if (missing.length > 0) { + logger.error('Missing required environment variables', { + missing, + request_id: c.get('requestId'), + }); + + return c.json( + { + error: 'Service Unavailable', + message: 'Service configuration error', + }, + 503 + ); + } + + return next(); +}); + +/** + * Routes + */ + +// Health check (public endpoint with optional authentication) +app.get('/health', optionalAuthMiddleware, handleHealth); + +// Query instances (authenticated, rate limited) +app.get('/instances', authMiddleware, rateLimitMiddleware, handleInstances); + +// Sync trigger (authenticated, rate limited) +app.post('/sync', authMiddleware, rateLimitMiddleware, handleSync); + +/** + * 404 handler + */ +app.notFound((c) => { + return c.json( + { + error: 'Not Found', + path: c.req.path, + }, + HTTP_STATUS.NOT_FOUND + ); +}); + +/** + * Global error handler + */ +app.onError((err, c) => { + logger.error('Request error', { + error: err, + request_id: c.get('requestId'), + }); + + return c.json( + { + error: 'Internal Server Error', + }, + HTTP_STATUS.INTERNAL_ERROR + ); +}); + +export default app; diff --git a/src/index.ts b/src/index.ts index 9e41a43..aa937ea 100644 --- a/src/index.ts +++ b/src/index.ts @@ -5,199 +5,15 @@ */ import { Env } from './types'; -import { handleSync, handleInstances, handleHealth } from './routes'; -import { - authenticateRequest, - verifyApiKey, - createUnauthorizedResponse, - checkRateLimit, - createRateLimitResponse, -} from './middleware'; -import { CORS, HTTP_STATUS } from './constants'; +import app from './app'; import { createLogger } from './utils/logger'; import { SyncOrchestrator } from './services/sync'; -/** - * Validate required environment variables - */ -function validateEnv(env: Env): { valid: boolean; missing: string[] } { - const required = ['API_KEY']; - const missing = required.filter(key => !env[key as keyof Env]); - return { valid: missing.length === 0, missing }; -} - -/** - * Get CORS origin for request - * - * Security: No wildcard fallback. Returns explicit allowed origin or default. - * Logs unmatched origins for monitoring. - */ -function getCorsOrigin(request: Request, env: Env): string { - const origin = request.headers.get('Origin'); - const logger = createLogger('[CORS]', env); - - // Environment variable has explicit origin configured (highest priority) - if (env.CORS_ORIGIN && env.CORS_ORIGIN !== '*') { - return env.CORS_ORIGIN; - } - - // Build allowed origins list based on environment - const isDevelopment = env.ENVIRONMENT === 'development'; - const allowedOrigins = isDevelopment - ? [...CORS.ALLOWED_ORIGINS, ...CORS.DEVELOPMENT_ORIGINS] - : CORS.ALLOWED_ORIGINS; - - // Request origin is in allowed list - if (origin && allowedOrigins.includes(origin)) { - return origin; - } - - // Log unmatched origins for security monitoring - if (origin && !allowedOrigins.includes(origin)) { - // Sanitize origin to prevent log injection (remove control characters) - const sanitizedOrigin = origin.replace(/[\r\n\t]/g, '').substring(0, 256); - logger.warn('Unmatched origin - using default', { - requested_origin: sanitizedOrigin, - environment: env.ENVIRONMENT || 'production', - default_origin: CORS.DEFAULT_ORIGIN - }); - } - - // Return explicit default (no wildcard) - return CORS.DEFAULT_ORIGIN; -} - -/** - * Add security headers to response - * Performance optimization: Reuses response body without cloning to minimize memory allocation - * - * Benefits: - * - Avoids Response.clone() which copies the entire body stream - * - Directly references response.body (ReadableStream) without duplication - * - Reduces memory allocation and GC pressure per request - * - * Note: response.body can be null for 204 No Content or empty responses - */ -function addSecurityHeaders(response: Response, corsOrigin?: string, requestId?: string): Response { - const headers = new Headers(response.headers); - - // Basic security headers - headers.set('X-Content-Type-Options', 'nosniff'); - headers.set('X-Frame-Options', 'DENY'); - headers.set('Strict-Transport-Security', 'max-age=31536000'); - - // CORS headers - headers.set('Access-Control-Allow-Origin', corsOrigin || CORS.DEFAULT_ORIGIN); - headers.set('Access-Control-Allow-Methods', 'GET, POST, OPTIONS'); - headers.set('Access-Control-Allow-Headers', 'Content-Type, X-API-Key'); - headers.set('Access-Control-Max-Age', CORS.MAX_AGE); - headers.set('Access-Control-Expose-Headers', 'X-RateLimit-Retry-After, Retry-After, X-Request-ID'); - - // Additional security headers - headers.set('Content-Security-Policy', "default-src 'none'"); - headers.set('X-XSS-Protection', '1; mode=block'); - headers.set('Referrer-Policy', 'no-referrer'); - - // Request ID for audit trail - if (requestId) { - headers.set('X-Request-ID', requestId); - } - - // Create new Response with same body reference (no copy) and updated headers - return new Response(response.body, { - status: response.status, - statusText: response.statusText, - headers, - }); -} - export default { /** - * HTTP Request Handler + * HTTP Request Handler (delegated to Hono) */ - async fetch(request: Request, env: Env, _ctx: ExecutionContext): Promise { - const url = new URL(request.url); - const path = url.pathname; - - // Generate request ID for audit trail (use CF-Ray if available, otherwise generate UUID) - const requestId = request.headers.get('CF-Ray') || crypto.randomUUID(); - - // Get CORS origin based on request and configuration - const corsOrigin = getCorsOrigin(request, env); - - try { - // Handle OPTIONS preflight requests - if (request.method === 'OPTIONS') { - return addSecurityHeaders(new Response(null, { status: 204 }), corsOrigin, requestId); - } - - // Validate required environment variables - const envValidation = validateEnv(env); - if (!envValidation.valid) { - const logger = createLogger('[Worker]'); - logger.error('Missing required environment variables', { missing: envValidation.missing, request_id: requestId }); - return addSecurityHeaders( - Response.json( - { error: 'Service Unavailable', message: 'Service configuration error' }, - { status: 503 } - ), - corsOrigin, - requestId - ); - } - - // Health check (public endpoint with optional authentication) - if (path === '/health') { - const apiKey = request.headers.get('X-API-Key'); - const authenticated = apiKey ? verifyApiKey(apiKey, env) : false; - return addSecurityHeaders(await handleHealth(env, authenticated), corsOrigin, requestId); - } - - // Authentication required for all other endpoints - const isAuthenticated = await authenticateRequest(request, env); - if (!isAuthenticated) { - return addSecurityHeaders(createUnauthorizedResponse(), corsOrigin, requestId); - } - - // Rate limiting for authenticated endpoints - const rateLimitCheck = await checkRateLimit(request, path, env); - if (!rateLimitCheck.allowed) { - return addSecurityHeaders(createRateLimitResponse(rateLimitCheck.retryAfter!), corsOrigin, requestId); - } - - // Query instances - if (path === '/instances' && request.method === 'GET') { - return addSecurityHeaders(await handleInstances(request, env), corsOrigin, requestId); - } - - // Sync trigger - if (path === '/sync' && request.method === 'POST') { - return addSecurityHeaders(await handleSync(request, env), corsOrigin, requestId); - } - - // 404 Not Found - return addSecurityHeaders( - Response.json( - { error: 'Not Found', path }, - { status: HTTP_STATUS.NOT_FOUND } - ), - corsOrigin, - requestId - ); - - } catch (error) { - const logger = createLogger('[Worker]'); - logger.error('Request error', { error, request_id: requestId }); - return addSecurityHeaders( - Response.json( - { error: 'Internal Server Error' }, - { status: HTTP_STATUS.INTERNAL_ERROR } - ), - corsOrigin, - requestId - ); - } - }, + fetch: app.fetch, /** * Scheduled (Cron) Handler diff --git a/src/middleware/hono-adapters.ts b/src/middleware/hono-adapters.ts new file mode 100644 index 0000000..0dcde33 --- /dev/null +++ b/src/middleware/hono-adapters.ts @@ -0,0 +1,110 @@ +/** + * Hono Middleware Adapters + * + * Adapts existing authentication and rate limiting middleware to Hono's middleware pattern. + */ + +import type { Context, Next } from 'hono'; +import type { Env } from '../types'; +import { + authenticateRequest, + verifyApiKey, + createUnauthorizedResponse, +} from './auth'; +import { checkRateLimit, createRateLimitResponse } from './rateLimit'; +import { createLogger } from '../utils/logger'; + +const logger = createLogger('[Middleware]'); + +// Context variables type +type Variables = { + requestId: string; + authenticated?: boolean; +}; + +/** + * Request ID middleware + * Adds unique request ID to context for tracing + */ +export async function requestIdMiddleware( + c: Context<{ Bindings: Env; Variables: Variables }>, + next: Next +): Promise { + // Use CF-Ray if available, otherwise generate UUID + const requestId = c.req.header('CF-Ray') || crypto.randomUUID(); + + // Store in context for handlers to use + c.set('requestId', requestId); + + await next(); + + // Add to response headers + c.res.headers.set('X-Request-ID', requestId); +} + +/** + * Authentication middleware + * Validates X-API-Key header using existing auth logic + */ +export async function authMiddleware( + c: Context<{ Bindings: Env; Variables: Variables }>, + next: Next +): Promise { + const request = c.req.raw; + const env = c.env; + + const isAuthenticated = await authenticateRequest(request, env); + + if (!isAuthenticated) { + logger.warn('[Auth] Unauthorized request', { + path: c.req.path, + requestId: c.get('requestId'), + }); + return createUnauthorizedResponse(); + } + + await next(); +} + +/** + * Rate limiting middleware + * Applies rate limits based on endpoint using existing rate limit logic + */ +export async function rateLimitMiddleware( + c: Context<{ Bindings: Env; Variables: Variables }>, + next: Next +): Promise { + const request = c.req.raw; + const path = c.req.path; + const env = c.env; + + const rateLimitCheck = await checkRateLimit(request, path, env); + + if (!rateLimitCheck.allowed) { + logger.warn('[RateLimit] Rate limit exceeded', { + path, + retryAfter: rateLimitCheck.retryAfter, + requestId: c.get('requestId'), + }); + return createRateLimitResponse(rateLimitCheck.retryAfter!); + } + + await next(); +} + +/** + * Optional authentication middleware for health check + * Checks if API key is provided and valid, stores result in context + */ +export async function optionalAuthMiddleware( + c: Context<{ Bindings: Env; Variables: Variables }>, + next: Next +): Promise { + const apiKey = c.req.header('X-API-Key'); + const authenticated = apiKey ? verifyApiKey(apiKey, c.env) : false; + + // Store authentication status in context + c.set('authenticated', authenticated); + + await next(); +} diff --git a/src/routes/health.ts b/src/routes/health.ts index a0eca32..ea642d0 100644 --- a/src/routes/health.ts +++ b/src/routes/health.ts @@ -3,12 +3,19 @@ * Comprehensive health monitoring for database and provider sync status */ +import type { Context } from 'hono'; import { Env } from '../types'; import { HTTP_STATUS } from '../constants'; import { createLogger } from '../utils/logger'; const logger = createLogger('[Health]'); +// Context variables type +type Variables = { + requestId: string; + authenticated?: boolean; +}; + /** * Component health status */ @@ -159,18 +166,17 @@ function sanitizeError(error: string): string { /** * Handle health check request - * @param env - Cloudflare Worker environment - * @param authenticated - Whether the request is authenticated (default: false) + * @param c - Hono context */ export async function handleHealth( - env: Env, - authenticated: boolean = false + c: Context<{ Bindings: Env; Variables: Variables }> ): Promise { const timestamp = new Date().toISOString(); + const authenticated = c.get('authenticated') ?? false; try { // Check database health - const dbHealth = await checkDatabaseHealth(env.DB); + const dbHealth = await checkDatabaseHealth(c.env.DB); // If database is unhealthy, return early if (dbHealth.status === 'unhealthy') { @@ -206,7 +212,7 @@ export async function handleHealth( } // Get all providers with aggregated counts in a single query - const providersWithCounts = await env.DB.prepare(` + const providersWithCounts = await c.env.DB.prepare(` SELECT p.id, p.name, diff --git a/src/routes/instances.ts b/src/routes/instances.ts index 0cf02c0..585d024 100644 --- a/src/routes/instances.ts +++ b/src/routes/instances.ts @@ -5,10 +5,17 @@ * Integrates with cache service for performance optimization. */ +import type { Context } from 'hono'; import type { Env, InstanceQueryParams } from '../types'; import { QueryService } from '../services/query'; import { getGlobalCacheService } from '../services/cache'; import { logger } from '../utils/logger'; + +// Context variables type +type Variables = { + requestId: string; + authenticated?: boolean; +}; import { SUPPORTED_PROVIDERS, type SupportedProvider, @@ -311,24 +318,22 @@ function parseQueryParams(url: URL): { /** * Handle GET /instances endpoint * - * @param request - HTTP request object - * @param env - Cloudflare Worker environment bindings + * @param c - Hono context * @returns JSON response with instance query results * * @example * GET /instances?provider=linode&min_vcpu=2&max_price=20&sort_by=price&order=asc&limit=50 */ export async function handleInstances( - request: Request, - env: Env + c: Context<{ Bindings: Env; Variables: Variables }> ): Promise { const startTime = Date.now(); - logger.info('[Instances] Request received', { url: request.url }); + logger.info('[Instances] Request received', { url: c.req.url }); try { // Parse URL and query parameters - const url = new URL(request.url); + const url = new URL(c.req.url); const parseResult = parseQueryParams(url); // Handle validation errors @@ -347,7 +352,7 @@ export async function handleInstances( logger.info('[Instances] Query params validated', params as unknown as Record); // Get global cache service singleton (shared across all routes) - const cacheService = getGlobalCacheService(CACHE_TTL.INSTANCES, env.RATE_LIMIT_KV); + const cacheService = getGlobalCacheService(CACHE_TTL.INSTANCES, c.env.RATE_LIMIT_KV); // Generate cache key from query parameters const cacheKey = cacheService.generateKey(params as unknown as Record); @@ -421,7 +426,7 @@ export async function handleInstances( }; // Get QueryService singleton (reused across requests) - const queryService = getQueryService(env.DB, env); + const queryService = getQueryService(c.env.DB, c.env); const result = await queryService.queryInstances(queryParams); const queryTime = Date.now() - startTime; diff --git a/src/routes/sync.ts b/src/routes/sync.ts index 99fa017..1e4c3ac 100644 --- a/src/routes/sync.ts +++ b/src/routes/sync.ts @@ -5,12 +5,19 @@ * Validates request parameters and orchestrates sync operations. */ +import type { Context } from 'hono'; import type { Env } from '../types'; import { SyncOrchestrator } from '../services/sync'; import { logger } from '../utils/logger'; import { SUPPORTED_PROVIDERS, HTTP_STATUS } from '../constants'; import { parseJsonBody, validateProviders, createErrorResponse } from '../utils/validation'; +// Context variables type +type Variables = { + requestId: string; + authenticated?: boolean; +}; + /** * Request body interface for sync endpoint */ @@ -23,8 +30,7 @@ interface SyncRequestBody { /** * Handle POST /sync endpoint * - * @param request - HTTP request object - * @param env - Cloudflare Worker environment bindings + * @param c - Hono context * @returns JSON response with sync results * * @example @@ -35,8 +41,7 @@ interface SyncRequestBody { * } */ export async function handleSync( - request: Request, - env: Env + c: Context<{ Bindings: Env; Variables: Variables }> ): Promise { const startTime = Date.now(); const startedAt = new Date().toISOString(); @@ -45,7 +50,7 @@ export async function handleSync( try { // Validate content-length before parsing body - const contentLength = request.headers.get('content-length'); + const contentLength = c.req.header('content-length'); if (contentLength) { const bodySize = parseInt(contentLength, 10); if (isNaN(bodySize) || bodySize > 10240) { // 10KB limit for sync @@ -57,12 +62,12 @@ export async function handleSync( } // Parse and validate request body - const contentType = request.headers.get('content-type'); + const contentType = c.req.header('content-type'); let body: SyncRequestBody = {}; // Only parse JSON if content-type is set if (contentType && contentType.includes('application/json')) { - const parseResult = await parseJsonBody(request); + const parseResult = await parseJsonBody(c.req.raw); if (!parseResult.success) { logger.error('[Sync] Invalid JSON in request body', { code: parseResult.error.code, @@ -90,7 +95,7 @@ export async function handleSync( logger.info('[Sync] Validation passed', { providers, force }); // Initialize SyncOrchestrator - const orchestrator = new SyncOrchestrator(env.DB, env); + const orchestrator = new SyncOrchestrator(c.env.DB, c.env); // Execute synchronization logger.info('[Sync] Starting synchronization', { providers });