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 <noreply@anthropic.com>
This commit is contained in:
kappa
2026-01-29 10:09:23 +09:00
parent 4b793eaeef
commit d999ca7573
8 changed files with 885 additions and 209 deletions

540
package-lock.json generated
View File

@@ -7,8 +7,12 @@
"": { "": {
"name": "cloud-instances-api", "name": "cloud-instances-api",
"version": "1.0.0", "version": "1.0.0",
"dependencies": {
"hono": "^4.11.7"
},
"devDependencies": { "devDependencies": {
"@cloudflare/workers-types": "^4.20241205.0", "@cloudflare/workers-types": "^4.20241205.0",
"tsx": "^4.7.0",
"typescript": "^5.7.2", "typescript": "^5.7.2",
"vitest": "^2.1.8", "vitest": "^2.1.8",
"wrangler": "^4.59.3" "wrangler": "^4.59.3"
@@ -1832,6 +1836,28 @@
"node": "^8.16.0 || ^10.6.0 || >=11.0.0" "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": { "node_modules/kleur": {
"version": "4.1.5", "version": "4.1.5",
"resolved": "https://registry.npmjs.org/kleur/-/kleur-4.1.5.tgz", "resolved": "https://registry.npmjs.org/kleur/-/kleur-4.1.5.tgz",
@@ -1967,6 +1993,16 @@
"node": "^10 || ^12 || >=14" "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": { "node_modules/rollup": {
"version": "4.55.3", "version": "4.55.3",
"resolved": "https://registry.npmjs.org/rollup/-/rollup-4.55.3.tgz", "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.55.3.tgz",
@@ -2166,6 +2202,510 @@
"license": "0BSD", "license": "0BSD",
"optional": true "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": { "node_modules/typescript": {
"version": "5.9.3", "version": "5.9.3",
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz",

View File

@@ -33,5 +33,8 @@
"typescript": "^5.7.2", "typescript": "^5.7.2",
"vitest": "^2.1.8", "vitest": "^2.1.8",
"wrangler": "^4.59.3" "wrangler": "^4.59.3"
},
"dependencies": {
"hono": "^4.11.7"
} }
} }

191
src/app.ts Normal file
View File

@@ -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;

View File

@@ -5,199 +5,15 @@
*/ */
import { Env } from './types'; import { Env } from './types';
import { handleSync, handleInstances, handleHealth } from './routes'; import app from './app';
import {
authenticateRequest,
verifyApiKey,
createUnauthorizedResponse,
checkRateLimit,
createRateLimitResponse,
} from './middleware';
import { CORS, HTTP_STATUS } from './constants';
import { createLogger } from './utils/logger'; import { createLogger } from './utils/logger';
import { SyncOrchestrator } from './services/sync'; 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 { export default {
/** /**
* HTTP Request Handler * HTTP Request Handler (delegated to Hono)
*/ */
async fetch(request: Request, env: Env, _ctx: ExecutionContext): Promise<Response> { fetch: app.fetch,
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
);
}
},
/** /**
* Scheduled (Cron) Handler * Scheduled (Cron) Handler

View File

@@ -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<void> {
// 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<Response | void> {
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<Response | void> {
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<void> {
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();
}

View File

@@ -3,12 +3,19 @@
* Comprehensive health monitoring for database and provider sync status * Comprehensive health monitoring for database and provider sync status
*/ */
import type { Context } from 'hono';
import { Env } from '../types'; import { Env } from '../types';
import { HTTP_STATUS } from '../constants'; import { HTTP_STATUS } from '../constants';
import { createLogger } from '../utils/logger'; import { createLogger } from '../utils/logger';
const logger = createLogger('[Health]'); const logger = createLogger('[Health]');
// Context variables type
type Variables = {
requestId: string;
authenticated?: boolean;
};
/** /**
* Component health status * Component health status
*/ */
@@ -159,18 +166,17 @@ function sanitizeError(error: string): string {
/** /**
* Handle health check request * Handle health check request
* @param env - Cloudflare Worker environment * @param c - Hono context
* @param authenticated - Whether the request is authenticated (default: false)
*/ */
export async function handleHealth( export async function handleHealth(
env: Env, c: Context<{ Bindings: Env; Variables: Variables }>
authenticated: boolean = false
): Promise<Response> { ): Promise<Response> {
const timestamp = new Date().toISOString(); const timestamp = new Date().toISOString();
const authenticated = c.get('authenticated') ?? false;
try { try {
// Check database health // Check database health
const dbHealth = await checkDatabaseHealth(env.DB); const dbHealth = await checkDatabaseHealth(c.env.DB);
// If database is unhealthy, return early // If database is unhealthy, return early
if (dbHealth.status === 'unhealthy') { if (dbHealth.status === 'unhealthy') {
@@ -206,7 +212,7 @@ export async function handleHealth(
} }
// Get all providers with aggregated counts in a single query // Get all providers with aggregated counts in a single query
const providersWithCounts = await env.DB.prepare(` const providersWithCounts = await c.env.DB.prepare(`
SELECT SELECT
p.id, p.id,
p.name, p.name,

View File

@@ -5,10 +5,17 @@
* Integrates with cache service for performance optimization. * Integrates with cache service for performance optimization.
*/ */
import type { Context } from 'hono';
import type { Env, InstanceQueryParams } from '../types'; import type { Env, InstanceQueryParams } from '../types';
import { QueryService } from '../services/query'; import { QueryService } from '../services/query';
import { getGlobalCacheService } from '../services/cache'; import { getGlobalCacheService } from '../services/cache';
import { logger } from '../utils/logger'; import { logger } from '../utils/logger';
// Context variables type
type Variables = {
requestId: string;
authenticated?: boolean;
};
import { import {
SUPPORTED_PROVIDERS, SUPPORTED_PROVIDERS,
type SupportedProvider, type SupportedProvider,
@@ -311,24 +318,22 @@ function parseQueryParams(url: URL): {
/** /**
* Handle GET /instances endpoint * Handle GET /instances endpoint
* *
* @param request - HTTP request object * @param c - Hono context
* @param env - Cloudflare Worker environment bindings
* @returns JSON response with instance query results * @returns JSON response with instance query results
* *
* @example * @example
* GET /instances?provider=linode&min_vcpu=2&max_price=20&sort_by=price&order=asc&limit=50 * GET /instances?provider=linode&min_vcpu=2&max_price=20&sort_by=price&order=asc&limit=50
*/ */
export async function handleInstances( export async function handleInstances(
request: Request, c: Context<{ Bindings: Env; Variables: Variables }>
env: Env
): Promise<Response> { ): Promise<Response> {
const startTime = Date.now(); const startTime = Date.now();
logger.info('[Instances] Request received', { url: request.url }); logger.info('[Instances] Request received', { url: c.req.url });
try { try {
// Parse URL and query parameters // Parse URL and query parameters
const url = new URL(request.url); const url = new URL(c.req.url);
const parseResult = parseQueryParams(url); const parseResult = parseQueryParams(url);
// Handle validation errors // Handle validation errors
@@ -347,7 +352,7 @@ export async function handleInstances(
logger.info('[Instances] Query params validated', params as unknown as Record<string, unknown>); logger.info('[Instances] Query params validated', params as unknown as Record<string, unknown>);
// Get global cache service singleton (shared across all routes) // 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 // Generate cache key from query parameters
const cacheKey = cacheService.generateKey(params as unknown as Record<string, unknown>); const cacheKey = cacheService.generateKey(params as unknown as Record<string, unknown>);
@@ -421,7 +426,7 @@ export async function handleInstances(
}; };
// Get QueryService singleton (reused across requests) // 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 result = await queryService.queryInstances(queryParams);
const queryTime = Date.now() - startTime; const queryTime = Date.now() - startTime;

View File

@@ -5,12 +5,19 @@
* Validates request parameters and orchestrates sync operations. * Validates request parameters and orchestrates sync operations.
*/ */
import type { Context } from 'hono';
import type { Env } from '../types'; import type { Env } from '../types';
import { SyncOrchestrator } from '../services/sync'; import { SyncOrchestrator } from '../services/sync';
import { logger } from '../utils/logger'; import { logger } from '../utils/logger';
import { SUPPORTED_PROVIDERS, HTTP_STATUS } from '../constants'; import { SUPPORTED_PROVIDERS, HTTP_STATUS } from '../constants';
import { parseJsonBody, validateProviders, createErrorResponse } from '../utils/validation'; import { parseJsonBody, validateProviders, createErrorResponse } from '../utils/validation';
// Context variables type
type Variables = {
requestId: string;
authenticated?: boolean;
};
/** /**
* Request body interface for sync endpoint * Request body interface for sync endpoint
*/ */
@@ -23,8 +30,7 @@ interface SyncRequestBody {
/** /**
* Handle POST /sync endpoint * Handle POST /sync endpoint
* *
* @param request - HTTP request object * @param c - Hono context
* @param env - Cloudflare Worker environment bindings
* @returns JSON response with sync results * @returns JSON response with sync results
* *
* @example * @example
@@ -35,8 +41,7 @@ interface SyncRequestBody {
* } * }
*/ */
export async function handleSync( export async function handleSync(
request: Request, c: Context<{ Bindings: Env; Variables: Variables }>
env: Env
): Promise<Response> { ): Promise<Response> {
const startTime = Date.now(); const startTime = Date.now();
const startedAt = new Date().toISOString(); const startedAt = new Date().toISOString();
@@ -45,7 +50,7 @@ export async function handleSync(
try { try {
// Validate content-length before parsing body // Validate content-length before parsing body
const contentLength = request.headers.get('content-length'); const contentLength = c.req.header('content-length');
if (contentLength) { if (contentLength) {
const bodySize = parseInt(contentLength, 10); const bodySize = parseInt(contentLength, 10);
if (isNaN(bodySize) || bodySize > 10240) { // 10KB limit for sync if (isNaN(bodySize) || bodySize > 10240) { // 10KB limit for sync
@@ -57,12 +62,12 @@ export async function handleSync(
} }
// Parse and validate request body // Parse and validate request body
const contentType = request.headers.get('content-type'); const contentType = c.req.header('content-type');
let body: SyncRequestBody = {}; let body: SyncRequestBody = {};
// Only parse JSON if content-type is set // Only parse JSON if content-type is set
if (contentType && contentType.includes('application/json')) { if (contentType && contentType.includes('application/json')) {
const parseResult = await parseJsonBody<SyncRequestBody>(request); const parseResult = await parseJsonBody<SyncRequestBody>(c.req.raw);
if (!parseResult.success) { if (!parseResult.success) {
logger.error('[Sync] Invalid JSON in request body', { logger.error('[Sync] Invalid JSON in request body', {
code: parseResult.error.code, code: parseResult.error.code,
@@ -90,7 +95,7 @@ export async function handleSync(
logger.info('[Sync] Validation passed', { providers, force }); logger.info('[Sync] Validation passed', { providers, force });
// Initialize SyncOrchestrator // Initialize SyncOrchestrator
const orchestrator = new SyncOrchestrator(env.DB, env); const orchestrator = new SyncOrchestrator(c.env.DB, c.env);
// Execute synchronization // Execute synchronization
logger.info('[Sync] Starting synchronization', { providers }); logger.info('[Sync] Starting synchronization', { providers });