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
- Keep scheduled handler separate for cron jobs

Test: 175/176 tests pass (1 pre-existing failure)
Build: wrangler deploy --dry-run succeeds

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
kappa
2026-01-29 10:05:10 +09:00
parent 4b793eaeef
commit 44327cef1a
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;