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

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 { 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<Response> {
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

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
*/
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<Response> {
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,

View File

@@ -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<Response> {
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<string, unknown>);
// 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<string, unknown>);
@@ -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;

View File

@@ -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<Response> {
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<SyncRequestBody>(request);
const parseResult = await parseJsonBody<SyncRequestBody>(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 });