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:
191
src/app.ts
Normal file
191
src/app.ts
Normal 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;
|
||||
Reference in New Issue
Block a user