refactor progress

This commit is contained in:
Hampus Kraft
2026-02-17 12:22:36 +00:00
parent cb31608523
commit d5abd1a7e4
8257 changed files with 1190207 additions and 761040 deletions

View File

@@ -0,0 +1,28 @@
{
"name": "@fluxer/hono",
"version": "0.0.0",
"private": true,
"type": "module",
"exports": {
"./*": "./*"
},
"scripts": {
"test": "vitest run",
"typecheck": "tsgo --noEmit"
},
"dependencies": {
"@fluxer/constants": "workspace:*",
"@fluxer/errors": "workspace:*",
"@fluxer/hono_types": "workspace:*",
"@fluxer/ip_utils": "workspace:*",
"@fluxer/telemetry": "workspace:*",
"@hono/node-server": "catalog:",
"hono": "catalog:"
},
"devDependencies": {
"@types/node": "catalog:",
"@typescript/native-preview": "catalog:",
"vite-tsconfig-paths": "catalog:",
"vitest": "catalog:"
}
}

View File

@@ -0,0 +1,51 @@
/*
* Copyright (C) 2026 Fluxer Contributors
*
* This file is part of Fluxer.
*
* Fluxer is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* Fluxer is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
*/
export interface Flash {
message: string;
type: 'success' | 'error' | 'info' | 'warning';
detail?: string;
}
export function serializeFlash(flash: Flash): string {
return Buffer.from(JSON.stringify(flash)).toString('base64url');
}
export function parseFlash(cookie: string | undefined): Flash | undefined {
if (!cookie) {
return undefined;
}
try {
const decoded = Buffer.from(cookie, 'base64url').toString('utf-8');
const flash = JSON.parse(decoded) as Flash;
if (
typeof flash.message === 'string' &&
typeof flash.type === 'string' &&
['success', 'error', 'info', 'warning'].includes(flash.type)
) {
return flash;
}
return undefined;
} catch {
return undefined;
}
}

View File

@@ -0,0 +1,148 @@
/*
* Copyright (C) 2026 Fluxer Contributors
*
* This file is part of Fluxer.
*
* Fluxer is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* Fluxer is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
*/
import {createServer as createNodeServer, type IncomingMessage, type Server} from 'node:http';
import type {Duplex} from 'node:stream';
import {getRequestListener, type ServerType, serve} from '@hono/node-server';
import type {Hono} from 'hono';
export interface ServerOptions {
port: number;
hostname?: string;
onListen?: (info: {address: string; port: number}) => void;
}
export function createServer<E extends object = object>(app: Hono<E>, options: ServerOptions): ServerType {
const {port, hostname, onListen} = options;
return serve(
{
fetch: app.fetch,
port,
...(hostname !== undefined && {hostname}),
},
onListen,
);
}
export type UpgradeHandler = (req: IncomingMessage, socket: Duplex, head: Buffer) => void;
export interface NodeServerOptions extends ServerOptions {
onUpgrade?: UpgradeHandler;
onServerCreated?: (server: Server) => void;
}
function resolveServerAddress(
server: Server,
fallbackHost: string,
fallbackPort: number,
): {address: string; port: number} {
const address = server.address();
if (!address || typeof address === 'string') {
return {address: fallbackHost, port: fallbackPort};
}
return {address: address.address, port: address.port};
}
export function createServerWithUpgrade<E extends object = object>(app: Hono<E>, options: NodeServerOptions): Server {
const {port, hostname, onListen, onUpgrade, onServerCreated} = options;
const server = createNodeServer(getRequestListener(app.fetch));
const fallbackHost = hostname ?? '0.0.0.0';
if (onUpgrade) {
server.on('upgrade', (req, socket, head) => onUpgrade(req, socket, head));
}
if (onServerCreated) {
onServerCreated(server);
}
server.listen(port, hostname, () => {
if (onListen) {
onListen(resolveServerAddress(server, fallbackHost, port));
}
});
return server;
}
export type CleanupFunction = () => void | Promise<void>;
export interface ShutdownLogger {
info(msg: string): void;
info(obj: Record<string, unknown>, msg: string): void;
error(msg: string): void;
error(obj: Record<string, unknown>, msg: string): void;
}
const defaultShutdownLogger: ShutdownLogger = {
info(objOrMsg: Record<string, unknown> | string, msg?: string): void {
if (typeof objOrMsg === 'string') {
process.stdout.write(`[info] ${objOrMsg}\n`);
} else if (msg) {
process.stdout.write(`[info] ${msg} ${JSON.stringify(objOrMsg)}\n`);
}
},
error(objOrMsg: Record<string, unknown> | string, msg?: string): void {
if (typeof objOrMsg === 'string') {
process.stderr.write(`[error] ${objOrMsg}\n`);
} else if (msg) {
process.stderr.write(`[error] ${msg} ${JSON.stringify(objOrMsg)}\n`);
}
},
};
export interface GracefulShutdownOptions {
logger?: ShutdownLogger;
timeoutMs?: number;
}
export function setupGracefulShutdown(cleanupFn: CleanupFunction, options?: GracefulShutdownOptions): void {
const logger = options?.logger ?? defaultShutdownLogger;
const timeoutMs = options?.timeoutMs;
let isShuttingDown = false;
const shutdown = async (signal: string): Promise<void> => {
if (isShuttingDown) {
return;
}
isShuttingDown = true;
logger.info({signal}, `Received ${signal}, shutting down gracefully...`);
let timeoutHandle: NodeJS.Timeout | undefined;
if (timeoutMs && timeoutMs > 0) {
timeoutHandle = setTimeout(() => {
logger.error({timeoutMs}, 'Forcing shutdown after timeout');
process.exit(1);
}, timeoutMs);
}
try {
await cleanupFn();
if (timeoutHandle) {
clearTimeout(timeoutHandle);
}
} catch (err) {
logger.error({err: err instanceof Error ? err : new Error(String(err))}, 'Error during shutdown');
process.exit(1);
}
process.exit(0);
};
process.on('SIGINT', () => shutdown('SIGINT'));
process.on('SIGTERM', () => shutdown('SIGTERM'));
}

View File

@@ -0,0 +1,98 @@
/*
* Copyright (C) 2026 Fluxer Contributors
*
* This file is part of Fluxer.
*
* Fluxer is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* Fluxer is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
*/
import {createHmac, timingSafeEqual} from 'node:crypto';
export interface SessionConfig {
secretKey: string;
maxAgeSeconds?: number;
}
const DEFAULT_MAX_AGE_SECONDS = 60 * 60 * 24 * 7;
function signData(data: string, secretKey: string): string {
const signature = createHmac('sha256', secretKey).update(data).digest('base64url');
return `${data}.${signature}`;
}
function verifySignature(signedData: string, secretKey: string): string | null {
const dotIndex = signedData.lastIndexOf('.');
if (dotIndex === -1) {
return null;
}
const data = signedData.slice(0, dotIndex);
const providedSignature = signedData.slice(dotIndex + 1);
const expectedSignature = createHmac('sha256', secretKey).update(data).digest('base64url');
try {
const providedBuffer = Buffer.from(providedSignature, 'base64url');
const expectedBuffer = Buffer.from(expectedSignature, 'base64url');
if (providedBuffer.length !== expectedBuffer.length) {
return null;
}
if (timingSafeEqual(providedBuffer, expectedBuffer)) {
return data;
}
} catch {
return null;
}
return null;
}
export function createSession<T extends object>(data: T, secretKey: string): string {
const sessionWithTimestamp = {
...data,
createdAt: Math.floor(Date.now() / 1000),
};
const encoded = Buffer.from(JSON.stringify(sessionWithTimestamp)).toString('base64url');
return signData(encoded, secretKey);
}
export function parseSession<T extends object>(
cookie: string,
secretKey: string,
maxAgeSeconds: number = DEFAULT_MAX_AGE_SECONDS,
): (T & {createdAt: number}) | null {
const data = verifySignature(cookie, secretKey);
if (!data) {
return null;
}
try {
const decoded = Buffer.from(data, 'base64url').toString('utf-8');
const session = JSON.parse(decoded) as T & {createdAt: number};
if (typeof session.createdAt !== 'number') {
return null;
}
const now = Math.floor(Date.now() / 1000);
if (now - session.createdAt > maxAgeSeconds) {
return null;
}
return session;
} catch {
return null;
}
}

View File

@@ -0,0 +1,57 @@
/*
* Copyright (C) 2026 Fluxer Contributors
*
* This file is part of Fluxer.
*
* Fluxer is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* Fluxer is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
*/
import type {MiddlewareHandler} from 'hono';
const CACHEABLE_CONTENT_TYPES = [
'text/css',
'application/javascript',
'font/',
'image/',
'video/',
'audio/',
'application/font-woff2',
];
function shouldCache(contentType: string): boolean {
return CACHEABLE_CONTENT_TYPES.some((type) => contentType.startsWith(type));
}
export interface CacheHeadersOptions {
staticCacheControl?: string;
defaultCacheControl?: string;
}
export function cacheHeaders(options: CacheHeadersOptions = {}): MiddlewareHandler {
const {staticCacheControl = 'public, max-age=31536000, immutable', defaultCacheControl = 'no-cache'} = options;
return async (c, next) => {
await next();
const existingCacheControl = c.res.headers.get('Cache-Control');
if (existingCacheControl) {
return;
}
const contentType = c.res.headers.get('Content-Type') || '';
const cacheHeader = shouldCache(contentType) ? staticCacheControl : defaultCacheControl;
c.res.headers.set('Cache-Control', cacheHeader);
};
}

View File

@@ -0,0 +1,87 @@
/*
* Copyright (C) 2026 Fluxer Contributors
*
* This file is part of Fluxer.
*
* Fluxer is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* Fluxer is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
*/
import type {MiddlewareHandler} from 'hono';
export interface CorsOptions {
enabled?: boolean;
origins?: Array<string> | '*';
methods?: Array<string>;
allowedHeaders?: Array<string>;
exposedHeaders?: Array<string>;
credentials?: boolean;
maxAge?: number;
}
const DEFAULT_METHODS = ['GET', 'HEAD', 'PUT', 'PATCH', 'POST', 'DELETE', 'OPTIONS'];
const DEFAULT_HEADERS = ['Content-Type', 'Authorization', 'X-Requested-With', 'Accept-Language', 'X-Request-ID'];
export function cors(options: CorsOptions = {}): MiddlewareHandler {
const {
enabled = true,
origins = '*',
methods = DEFAULT_METHODS,
allowedHeaders = DEFAULT_HEADERS,
exposedHeaders = [],
credentials = false,
maxAge = 86400,
} = options;
return async (c, next) => {
if (!enabled) {
await next();
return;
}
const requestOrigin = c.req.header('origin');
if (origins === '*') {
c.header('Access-Control-Allow-Origin', '*');
} else if (Array.isArray(origins) && requestOrigin && origins.includes(requestOrigin)) {
c.header('Access-Control-Allow-Origin', requestOrigin);
c.header('Vary', 'Origin');
}
if (credentials) {
c.header('Access-Control-Allow-Credentials', 'true');
}
if (c.req.method === 'OPTIONS') {
c.header('Access-Control-Allow-Methods', methods.join(', '));
c.header('Access-Control-Allow-Headers', allowedHeaders.join(', '));
if (exposedHeaders.length > 0) {
c.header('Access-Control-Expose-Headers', exposedHeaders.join(', '));
}
if (maxAge !== undefined) {
c.header('Access-Control-Max-Age', maxAge.toString());
}
return c.body(null, 204);
}
if (exposedHeaders.length > 0) {
c.header('Access-Control-Expose-Headers', exposedHeaders.join(', '));
}
await next();
return;
};
}

View File

@@ -0,0 +1,52 @@
/*
* Copyright (C) 2026 Fluxer Contributors
*
* This file is part of Fluxer.
*
* Fluxer is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* Fluxer is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
*/
import {createErrorHandler as createFluxerErrorHandler} from '@fluxer/errors/src/ErrorHandler';
import type {Context, ErrorHandler} from 'hono';
export interface ErrorHandlerOptions {
includeStack?: boolean;
logger?: (error: Error, context: Context) => void;
captureException?: (error: Error, context?: Record<string, unknown>) => void;
}
export function createErrorHandler(options: ErrorHandlerOptions = {}): ErrorHandler {
const {includeStack = false, logger, captureException} = options;
const logError =
logger || captureException
? (error: Error, context: Context) => {
if (logger) {
logger(error, context);
}
if (captureException) {
captureException(error, {
path: context.req.path,
method: context.req.method,
status: context.res?.status,
});
}
}
: undefined;
return createFluxerErrorHandler({
includeStack,
logError,
});
}

View File

@@ -0,0 +1,70 @@
/*
* Copyright (C) 2026 Fluxer Contributors
*
* This file is part of Fluxer.
*
* Fluxer is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* Fluxer is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
*/
import {timingSafeEqual} from 'node:crypto';
import {UnauthorizedError} from '@fluxer/errors/src/domains/core/UnauthorizedError';
import {matchesAnyExactOrNestedPath} from '@fluxer/hono/src/middleware/utils/PathMatchers';
import type {MiddlewareHandler} from 'hono';
export interface InternalAuthOptions {
secret: string;
skipPaths?: Array<string>;
}
function timingSafeCompare(a: string, b: string): boolean {
const bufferA = Buffer.from(a);
const bufferB = Buffer.from(b);
if (bufferA.length !== bufferB.length) {
return false;
}
return timingSafeEqual(bufferA, bufferB);
}
export function createInternalAuth(options: InternalAuthOptions): MiddlewareHandler {
const {secret, skipPaths = ['/_health']} = options;
return async (c, next) => {
const path = c.req.path;
if (matchesAnyExactOrNestedPath(path, skipPaths)) {
await next();
return;
}
const authHeader = c.req.header('Authorization');
if (!authHeader) {
throw new UnauthorizedError({message: 'Missing Authorization header'});
}
if (!authHeader.startsWith('Bearer ')) {
throw new UnauthorizedError({message: 'Invalid Authorization header format'});
}
const token = authHeader.slice(7);
if (!timingSafeCompare(token, secret)) {
throw new UnauthorizedError({message: 'Invalid token'});
}
await next();
};
}

View File

@@ -0,0 +1,94 @@
/*
* Copyright (C) 2026 Fluxer Contributors
*
* This file is part of Fluxer.
*
* Fluxer is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* Fluxer is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
*/
import {matchesAnyPathPattern} from '@fluxer/hono/src/middleware/utils/PathMatchers';
import type {MetricsCollector} from '@fluxer/hono_types/src/MetricsTypes';
import type {MiddlewareHandler} from 'hono';
export interface MetricsOptions {
enabled?: boolean;
skipPaths?: Array<string>;
collector?: MetricsCollector;
includeMethod?: boolean;
includeStatus?: boolean;
includePath?: boolean;
}
export function metrics(options: MetricsOptions = {}): MiddlewareHandler {
const {
enabled = true,
skipPaths = ['/_health', '/metrics'],
collector,
includeMethod = true,
includeStatus = true,
includePath = false,
} = options;
return async (c, next) => {
if (!enabled || !collector) {
await next();
return;
}
const path = c.req.path;
if (matchesAnyPathPattern(path, skipPaths)) {
await next();
return;
}
const startTime = Date.now();
const method = c.req.method;
await next();
const durationMs = Date.now() - startTime;
const status = c.res.status;
const labels: Record<string, string> = {};
if (includeMethod) {
labels['method'] = method;
}
if (includeStatus) {
labels['status'] = status.toString();
}
if (includePath) {
labels['path'] = path;
}
collector.recordCounter({
name: 'http_requests_total',
value: 1,
labels,
});
collector.recordHistogram({
name: 'http_request_duration_ms',
value: durationMs,
labels,
});
if (status >= 400) {
collector.recordCounter({
name: 'http_errors_total',
value: 1,
labels,
});
}
};
}

View File

@@ -0,0 +1,156 @@
/*
* Copyright (C) 2026 Fluxer Contributors
*
* This file is part of Fluxer.
*
* Fluxer is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* Fluxer is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
*/
import type {CorsOptions} from '@fluxer/hono/src/middleware/Cors';
import {cors} from '@fluxer/hono/src/middleware/Cors';
import type {ErrorHandlerOptions} from '@fluxer/hono/src/middleware/ErrorHandler';
import {createErrorHandler} from '@fluxer/hono/src/middleware/ErrorHandler';
import type {MetricsOptions} from '@fluxer/hono/src/middleware/Metrics';
import {metrics} from '@fluxer/hono/src/middleware/Metrics';
import type {RateLimitOptions, RateLimitService} from '@fluxer/hono/src/middleware/RateLimit';
import {rateLimit} from '@fluxer/hono/src/middleware/RateLimit';
import type {RequestIdOptions} from '@fluxer/hono/src/middleware/RequestId';
import {requestId} from '@fluxer/hono/src/middleware/RequestId';
import type {LogFunction, RequestLoggerOptions} from '@fluxer/hono/src/middleware/RequestLogger';
import {requestLogger} from '@fluxer/hono/src/middleware/RequestLogger';
import {tracing} from '@fluxer/hono/src/middleware/Tracing';
import type {MetricsCollector} from '@fluxer/hono_types/src/MetricsTypes';
import type {TracingOptions} from '@fluxer/hono_types/src/TracingTypes';
import type {Context, Env, Hono, MiddlewareHandler} from 'hono';
export interface MiddlewareStackOptions {
requestId?: RequestIdOptions;
cors?: CorsOptions;
tracing?: TracingOptions;
metrics?: MetricsOptions & {collector?: MetricsCollector};
logger?: Omit<RequestLoggerOptions, 'log'> & {log?: LogFunction};
rateLimit?: RateLimitOptions & {service?: RateLimitService};
errorHandler?: ErrorHandlerOptions;
customMiddleware?: Array<MiddlewareHandler>;
}
export interface ApplyMiddlewareStackOptions extends MiddlewareStackOptions {
skipRequestId?: boolean;
skipCors?: boolean;
skipTracing?: boolean;
skipMetrics?: boolean;
skipLogger?: boolean;
skipRateLimit?: boolean;
skipErrorHandler?: boolean;
}
export function createStandardMiddlewareStack(options: MiddlewareStackOptions = {}): Array<MiddlewareHandler> {
const stack: Array<MiddlewareHandler> = [];
if (options.requestId) {
stack.push(requestId(options.requestId));
}
if (options.cors && options.cors.enabled !== false) {
stack.push(cors(options.cors));
}
if (options.tracing && options.tracing.enabled !== false) {
stack.push(tracing(options.tracing));
}
if (options.metrics && options.metrics.enabled !== false && options.metrics.collector) {
stack.push(metrics(options.metrics));
}
if (options.logger?.log) {
stack.push(
requestLogger({
log: options.logger.log,
skip: options.logger.skip,
}),
);
}
if (options.rateLimit && options.rateLimit.enabled !== false && options.rateLimit.service) {
stack.push(rateLimit(options.rateLimit));
}
if (options.customMiddleware) {
stack.push(...options.customMiddleware);
}
return stack;
}
function buildStackOptions(options: ApplyMiddlewareStackOptions): MiddlewareStackOptions {
return {
requestId: options.skipRequestId ? undefined : options.requestId,
cors: options.skipCors ? undefined : options.cors,
tracing: options.skipTracing ? undefined : options.tracing,
metrics: options.skipMetrics ? undefined : options.metrics,
logger: options.skipLogger ? undefined : options.logger,
rateLimit: options.skipRateLimit ? undefined : options.rateLimit,
customMiddleware: options.customMiddleware,
};
}
export function applyMiddlewareStack<E extends Env = Env>(
app: Hono<E>,
options: ApplyMiddlewareStackOptions = {},
): void {
const stack = createStandardMiddlewareStack(buildStackOptions(options));
for (const middleware of stack) {
app.use('*', middleware);
}
if (!options.skipErrorHandler) {
const errorHandler = createErrorHandler(options.errorHandler ?? {});
app.onError(errorHandler);
}
}
export function createDefaultLogger(options: {serviceName: string; skip?: Array<string>}): LogFunction {
return (data) => {
if (options.skip?.includes(data.path)) {
return;
}
console.log(
JSON.stringify({
service: options.serviceName,
method: data.method,
path: data.path,
status: data.status,
durationMs: data.durationMs,
timestamp: new Date().toISOString(),
}),
);
};
}
export function createDefaultErrorLogger(options: {serviceName: string}): (error: Error, context: Context) => void {
return (error: Error, context: Context) => {
console.error(
JSON.stringify({
service: options.serviceName,
error: error.message,
stack: error.stack,
path: context.req.path,
method: context.req.method,
timestamp: new Date().toISOString(),
}),
);
};
}

View File

@@ -0,0 +1,118 @@
/*
* Copyright (C) 2026 Fluxer Contributors
*
* This file is part of Fluxer.
*
* Fluxer is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* Fluxer is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
*/
import {matchesAnyPathPattern} from '@fluxer/hono/src/middleware/utils/PathMatchers';
import {extractClientIp} from '@fluxer/ip_utils/src/ClientIp';
import type {MiddlewareHandler} from 'hono';
export interface RateLimitResult {
allowed: boolean;
limit: number;
remaining: number;
resetTime: Date;
retryAfter?: number;
}
export interface RateLimitService {
checkLimit(params: {identifier: string; maxAttempts: number; windowMs: number}): Promise<RateLimitResult>;
}
export type KeyGenerator = (request: Request) => string | Promise<string>;
export interface RateLimitOptions {
enabled?: boolean;
skipPaths?: Array<string>;
service?: RateLimitService;
maxAttempts?: number;
windowMs?: number;
keyGenerator?: KeyGenerator;
onLimitExceeded?: (identifier: string, path: string) => void;
trustCfConnectingIp?: boolean;
}
function getClientIp(req: Request, trustCfConnectingIp?: boolean): string {
const ip = extractClientIp(req, {trustCfConnectingIp});
return ip ?? 'unknown';
}
function createDefaultKeyGenerator(trustCfConnectingIp?: boolean): KeyGenerator {
return function defaultKeyGenerator(req: Request): string {
return getClientIp(req, trustCfConnectingIp);
};
}
export function rateLimit(options: RateLimitOptions = {}): MiddlewareHandler {
const {
enabled = true,
skipPaths = ['/_health', '/metrics'],
service,
maxAttempts = 100,
windowMs = 60000,
keyGenerator,
onLimitExceeded,
trustCfConnectingIp = false,
} = options;
const resolvedKeyGenerator = keyGenerator ?? createDefaultKeyGenerator(trustCfConnectingIp);
return async (c, next) => {
if (!enabled || !service) {
await next();
return;
}
const path = c.req.path;
if (matchesAnyPathPattern(path, skipPaths)) {
await next();
return;
}
const identifier = await resolvedKeyGenerator(c.req.raw);
const result = await service.checkLimit({
identifier,
maxAttempts,
windowMs,
});
c.header('X-RateLimit-Limit', result.limit.toString());
c.header('X-RateLimit-Remaining', result.remaining.toString());
c.header('X-RateLimit-Reset', Math.floor(result.resetTime.getTime() / 1000).toString());
if (!result.allowed) {
if (result.retryAfter !== undefined) {
c.header('Retry-After', result.retryAfter.toString());
}
if (onLimitExceeded) {
onLimitExceeded(identifier, path);
}
return c.json(
{
error: 'Too Many Requests',
message: 'Rate limit exceeded',
retryAfter: result.retryAfter,
},
429,
);
}
await next();
return;
};
}

View File

@@ -0,0 +1,48 @@
/*
* Copyright (C) 2026 Fluxer Contributors
*
* This file is part of Fluxer.
*
* Fluxer is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* Fluxer is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
*/
import {randomUUID} from 'node:crypto';
import {Headers} from '@fluxer/constants/src/Headers';
import type {MiddlewareHandler} from 'hono';
export type RequestIdGenerator = () => string;
export interface RequestIdOptions {
headerName?: string;
generator?: RequestIdGenerator;
setResponseHeader?: boolean;
}
export const REQUEST_ID_KEY = 'requestId';
export function requestId(options: RequestIdOptions = {}): MiddlewareHandler {
const {headerName = Headers.X_REQUEST_ID, generator = randomUUID, setResponseHeader = true} = options;
return async (c, next) => {
const existingId = c.req.header(headerName);
const id = existingId || generator();
c.set(REQUEST_ID_KEY, id);
await next();
if (setResponseHeader) {
c.res.headers.set(headerName, id);
}
};
}

View File

@@ -0,0 +1,49 @@
/*
* Copyright (C) 2026 Fluxer Contributors
*
* This file is part of Fluxer.
*
* Fluxer is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* Fluxer is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
*/
import {matchesAnyPathPattern} from '@fluxer/hono/src/middleware/utils/PathMatchers';
import type {MiddlewareHandler} from 'hono';
export type LogFunction = (data: {method: string; path: string; status: number; durationMs: number}) => void;
export interface RequestLoggerOptions {
log: LogFunction;
skip?: Array<string>;
}
export function requestLogger(options: RequestLoggerOptions): MiddlewareHandler {
const {log, skip = []} = options;
return async (c, next) => {
const path = c.req.path;
if (matchesAnyPathPattern(path, skip)) {
return next();
}
const startTime = Date.now();
const method = c.req.method;
await next();
const durationMs = Date.now() - startTime;
const status = c.res.status;
log({method, path, status, durationMs});
};
}

View File

@@ -0,0 +1,81 @@
/*
* Copyright (C) 2026 Fluxer Contributors
*
* This file is part of Fluxer.
*
* Fluxer is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* Fluxer is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
*/
import type {MetricsCollector} from '@fluxer/hono_types/src/MetricsTypes';
import type {TracingOptions} from '@fluxer/hono_types/src/TracingTypes';
import {recordCounter, recordGauge, recordHistogram} from '@fluxer/telemetry/src/Metrics';
import {isTelemetryActive} from '@fluxer/telemetry/src/Telemetry';
import {setSpanAttributes, withSpan} from '@fluxer/telemetry/src/Tracing';
export interface TelemetryTracingOptions {
serviceName: string;
skipPaths?: Array<string>;
attachTraceparent?: boolean;
}
function createTelemetryMetricsCollector(): MetricsCollector {
return {
recordCounter: ({name, value, labels}: {name: string; value?: number; labels?: Record<string, string>}) => {
if (!isTelemetryActive()) {
return;
}
recordCounter(name, value ?? 1, labels);
},
recordHistogram: ({name, value, labels}: {name: string; value: number; labels?: Record<string, string>}) => {
if (!isTelemetryActive()) {
return;
}
recordHistogram(name, value, labels);
},
recordGauge: ({name, value, labels}: {name: string; value: number; labels?: Record<string, string>}) => {
if (!isTelemetryActive()) {
return;
}
recordGauge({name, value, dimensions: labels});
},
};
}
function createTelemetryTracingOptions(options: TelemetryTracingOptions): TracingOptions {
return {
enabled: isTelemetryActive(),
serviceName: options.serviceName,
skipPaths: options.skipPaths,
attachTraceparent: options.attachTraceparent,
withSpan: async (name: string, fn: () => Promise<void>) => {
await withSpan(name, async () => {
await fn();
return undefined;
});
},
setSpanAttributes: (attributes: Record<string, string | number | boolean>) => {
setSpanAttributes(attributes);
},
};
}
export function createServiceTelemetry(options: TelemetryTracingOptions): {
metricsCollector: MetricsCollector;
tracing: TracingOptions;
} {
return {
metricsCollector: createTelemetryMetricsCollector(),
tracing: createTelemetryTracingOptions(options),
};
}

View File

@@ -0,0 +1,86 @@
/*
* Copyright (C) 2026 Fluxer Contributors
*
* This file is part of Fluxer.
*
* Fluxer is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* Fluxer is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
*/
import {matchesAnyPathPattern} from '@fluxer/hono/src/middleware/utils/PathMatchers';
import type {TracingOptions} from '@fluxer/hono_types/src/TracingTypes';
import type {MiddlewareHandler} from 'hono';
export function tracing(options: TracingOptions = {}): MiddlewareHandler {
const {
enabled = true,
skipPaths = ['/_health', '/metrics'],
serviceName = 'hono-service',
withSpan,
setSpanAttributes,
attachTraceparent = false,
} = options;
return async (c, next) => {
if (!enabled) {
await next();
return;
}
const path = c.req.path;
if (matchesAnyPathPattern(path, skipPaths)) {
await next();
return;
}
if (!withSpan || !setSpanAttributes) {
await next();
return;
}
const method = c.req.method;
const spanName = `http.request ${method} ${path}`;
await withSpan(spanName, async () => {
const startTime = Date.now();
setSpanAttributes({
'http.method': method,
'http.url': c.req.url,
'http.target': path,
'http.scheme': c.req.header('x-forwarded-proto') ?? 'http',
'http.host': c.req.header('host') ?? 'unknown',
'http.user_agent': c.req.header('user-agent') ?? 'unknown',
'service.name': serviceName,
});
await next();
const durationMs = Date.now() - startTime;
const status = c.res.status;
setSpanAttributes({
'http.status_code': status,
'http.response_content_length': Number(c.res.headers.get('content-length') ?? 0),
'http.duration_ms': durationMs,
});
if (attachTraceparent) {
const traceparent = c.res.headers.get('traceparent');
if (traceparent) {
c.header('traceparent', traceparent);
}
}
});
};
}

View File

@@ -0,0 +1,277 @@
/*
* Copyright (C) 2026 Fluxer Contributors
*
* This file is part of Fluxer.
*
* Fluxer is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* Fluxer is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
*/
import {cacheHeaders} from '@fluxer/hono/src/middleware/CacheHeaders';
import {Hono} from 'hono';
import {describe, expect, test} from 'vitest';
describe('CacheHeaders Middleware', () => {
describe('static content caching', () => {
test('sets immutable cache for CSS files', async () => {
const app = new Hono();
app.use('*', cacheHeaders());
app.get('/styles.css', () => {
return new Response('body { color: red; }', {
headers: {'Content-Type': 'text/css'},
});
});
const response = await app.request('/styles.css');
expect(response.headers.get('Cache-Control')).toBe('public, max-age=31536000, immutable');
});
test('sets immutable cache for JavaScript files', async () => {
const app = new Hono();
app.use('*', cacheHeaders());
app.get('/app.js', () => {
return new Response('console.log("hello");', {
headers: {'Content-Type': 'application/javascript'},
});
});
const response = await app.request('/app.js');
expect(response.headers.get('Cache-Control')).toBe('public, max-age=31536000, immutable');
});
test('sets immutable cache for images', async () => {
const app = new Hono();
app.use('*', cacheHeaders());
app.get('/logo.png', (c) => {
c.header('Content-Type', 'image/png');
return c.body(new Uint8Array([1, 2, 3]));
});
const response = await app.request('/logo.png');
expect(response.headers.get('Cache-Control')).toBe('public, max-age=31536000, immutable');
});
test('sets immutable cache for fonts', async () => {
const app = new Hono();
app.use('*', cacheHeaders());
app.get('/font.woff2', (c) => {
c.header('Content-Type', 'font/woff2');
return c.body(new Uint8Array([1, 2, 3]));
});
const response = await app.request('/font.woff2');
expect(response.headers.get('Cache-Control')).toBe('public, max-age=31536000, immutable');
});
test('sets immutable cache for application/font-woff2', async () => {
const app = new Hono();
app.use('*', cacheHeaders());
app.get('/font.woff2', (c) => {
c.header('Content-Type', 'application/font-woff2');
return c.body(new Uint8Array([1, 2, 3]));
});
const response = await app.request('/font.woff2');
expect(response.headers.get('Cache-Control')).toBe('public, max-age=31536000, immutable');
});
test('sets immutable cache for video files', async () => {
const app = new Hono();
app.use('*', cacheHeaders());
app.get('/video.mp4', (c) => {
c.header('Content-Type', 'video/mp4');
return c.body(new Uint8Array([1, 2, 3]));
});
const response = await app.request('/video.mp4');
expect(response.headers.get('Cache-Control')).toBe('public, max-age=31536000, immutable');
});
test('sets immutable cache for audio files', async () => {
const app = new Hono();
app.use('*', cacheHeaders());
app.get('/audio.mp3', (c) => {
c.header('Content-Type', 'audio/mpeg');
return c.body(new Uint8Array([1, 2, 3]));
});
const response = await app.request('/audio.mp3');
expect(response.headers.get('Cache-Control')).toBe('public, max-age=31536000, immutable');
});
});
describe('default caching', () => {
test('sets no-cache for JSON responses', async () => {
const app = new Hono();
app.use('*', cacheHeaders());
app.get('/api/data', (c) => c.json({ok: true}));
const response = await app.request('/api/data');
expect(response.headers.get('Cache-Control')).toBe('no-cache');
});
test('sets no-cache for HTML responses', async () => {
const app = new Hono();
app.use('*', cacheHeaders());
app.get('/', (c) => {
c.header('Content-Type', 'text/html');
return c.html('<html><body>Hello</body></html>');
});
const response = await app.request('/');
expect(response.headers.get('Cache-Control')).toBe('no-cache');
});
test('sets no-cache for text/plain responses', async () => {
const app = new Hono();
app.use('*', cacheHeaders());
app.get('/readme.txt', (c) => c.text('Hello World'));
const response = await app.request('/readme.txt');
expect(response.headers.get('Cache-Control')).toBe('no-cache');
});
test('sets no-cache when Content-Type is not set', async () => {
const app = new Hono();
app.use('*', cacheHeaders());
app.get('/test', (c) => c.body('data'));
const response = await app.request('/test');
expect(response.headers.get('Cache-Control')).toBe('no-cache');
});
});
describe('existing Cache-Control header', () => {
test('does not override existing Cache-Control header', async () => {
const app = new Hono();
app.use('*', cacheHeaders());
app.get('/test', (c) => {
c.header('Cache-Control', 'private, max-age=3600');
return c.json({ok: true});
});
const response = await app.request('/test');
expect(response.headers.get('Cache-Control')).toBe('private, max-age=3600');
});
test('respects existing no-store header', async () => {
const app = new Hono();
app.use('*', cacheHeaders());
app.get('/sensitive', (c) => {
c.header('Cache-Control', 'no-store');
return c.json({secret: 'data'});
});
const response = await app.request('/sensitive');
expect(response.headers.get('Cache-Control')).toBe('no-store');
});
});
describe('custom options', () => {
test('uses custom staticCacheControl', async () => {
const app = new Hono();
app.use('*', cacheHeaders({staticCacheControl: 'public, max-age=86400'}));
app.get('/image.png', () => {
return new Response(new Uint8Array([1, 2, 3]), {
headers: {'Content-Type': 'image/png'},
});
});
const response = await app.request('/image.png');
expect(response.headers.get('Cache-Control')).toBe('public, max-age=86400');
});
test('uses custom defaultCacheControl', async () => {
const app = new Hono();
app.use('*', cacheHeaders({defaultCacheControl: 'no-store'}));
app.get('/api/data', (c) => c.json({ok: true}));
const response = await app.request('/api/data');
expect(response.headers.get('Cache-Control')).toBe('no-store');
});
test('uses both custom options together', async () => {
const app = new Hono();
app.use(
'*',
cacheHeaders({
staticCacheControl: 'public, max-age=7200',
defaultCacheControl: 'private, no-cache',
}),
);
app.get('/style.css', () => {
return new Response('body {}', {
headers: {'Content-Type': 'text/css'},
});
});
app.get('/api/data', (c) => c.json({ok: true}));
const cssResponse = await app.request('/style.css');
expect(cssResponse.headers.get('Cache-Control')).toBe('public, max-age=7200');
const jsonResponse = await app.request('/api/data');
expect(jsonResponse.headers.get('Cache-Control')).toBe('private, no-cache');
});
});
describe('content type variations', () => {
test('handles image/jpeg', async () => {
const app = new Hono();
app.use('*', cacheHeaders());
app.get('/photo.jpg', (c) => {
c.header('Content-Type', 'image/jpeg');
return c.body(new Uint8Array([1, 2, 3]));
});
const response = await app.request('/photo.jpg');
expect(response.headers.get('Cache-Control')).toBe('public, max-age=31536000, immutable');
});
test('handles image/gif', async () => {
const app = new Hono();
app.use('*', cacheHeaders());
app.get('/animation.gif', (c) => {
c.header('Content-Type', 'image/gif');
return c.body(new Uint8Array([1, 2, 3]));
});
const response = await app.request('/animation.gif');
expect(response.headers.get('Cache-Control')).toBe('public, max-age=31536000, immutable');
});
test('handles image/svg+xml', async () => {
const app = new Hono();
app.use('*', cacheHeaders());
app.get('/icon.svg', () => {
return new Response('<svg></svg>', {
headers: {'Content-Type': 'image/svg+xml'},
});
});
const response = await app.request('/icon.svg');
expect(response.headers.get('Cache-Control')).toBe('public, max-age=31536000, immutable');
});
test('handles video/webm', async () => {
const app = new Hono();
app.use('*', cacheHeaders());
app.get('/video.webm', (c) => {
c.header('Content-Type', 'video/webm');
return c.body(new Uint8Array([1, 2, 3]));
});
const response = await app.request('/video.webm');
expect(response.headers.get('Cache-Control')).toBe('public, max-age=31536000, immutable');
});
});
});

View File

@@ -0,0 +1,281 @@
/*
* Copyright (C) 2026 Fluxer Contributors
*
* This file is part of Fluxer.
*
* Fluxer is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* Fluxer is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
*/
import {cors} from '@fluxer/hono/src/middleware/Cors';
import {Hono} from 'hono';
import {describe, expect, test} from 'vitest';
describe('CORS Middleware', () => {
describe('wildcard origin', () => {
test('sets Access-Control-Allow-Origin to * for wildcard origins', async () => {
const app = new Hono();
app.use('*', cors({origins: '*'}));
app.get('/test', (c) => c.json({ok: true}));
const response = await app.request('/test', {
headers: {origin: 'https://example.com'},
});
expect(response.status).toBe(200);
expect(response.headers.get('Access-Control-Allow-Origin')).toBe('*');
});
test('does not set Vary header for wildcard origins', async () => {
const app = new Hono();
app.use('*', cors({origins: '*'}));
app.get('/test', (c) => c.json({ok: true}));
const response = await app.request('/test', {
headers: {origin: 'https://example.com'},
});
expect(response.headers.get('Vary')).toBeNull();
});
});
describe('specific origins', () => {
test('allows requests from whitelisted origin', async () => {
const app = new Hono();
app.use('*', cors({origins: ['https://allowed.com', 'https://also-allowed.com']}));
app.get('/test', (c) => c.json({ok: true}));
const response = await app.request('/test', {
headers: {origin: 'https://allowed.com'},
});
expect(response.status).toBe(200);
expect(response.headers.get('Access-Control-Allow-Origin')).toBe('https://allowed.com');
expect(response.headers.get('Vary')).toBe('Origin');
});
test('does not set origin header for non-whitelisted origin', async () => {
const app = new Hono();
app.use('*', cors({origins: ['https://allowed.com']}));
app.get('/test', (c) => c.json({ok: true}));
const response = await app.request('/test', {
headers: {origin: 'https://not-allowed.com'},
});
expect(response.status).toBe(200);
expect(response.headers.get('Access-Control-Allow-Origin')).toBeNull();
});
test('does not set origin header when no origin header in request', async () => {
const app = new Hono();
app.use('*', cors({origins: ['https://allowed.com']}));
app.get('/test', (c) => c.json({ok: true}));
const response = await app.request('/test');
expect(response.status).toBe(200);
expect(response.headers.get('Access-Control-Allow-Origin')).toBeNull();
});
});
describe('credentials', () => {
test('sets Access-Control-Allow-Credentials when credentials is true', async () => {
const app = new Hono();
app.use('*', cors({credentials: true}));
app.get('/test', (c) => c.json({ok: true}));
const response = await app.request('/test');
expect(response.headers.get('Access-Control-Allow-Credentials')).toBe('true');
});
test('does not set Access-Control-Allow-Credentials when credentials is false', async () => {
const app = new Hono();
app.use('*', cors({credentials: false}));
app.get('/test', (c) => c.json({ok: true}));
const response = await app.request('/test');
expect(response.headers.get('Access-Control-Allow-Credentials')).toBeNull();
});
});
describe('preflight requests (OPTIONS)', () => {
test('responds to preflight with 204 status', async () => {
const app = new Hono();
app.use('*', cors());
app.get('/test', (c) => c.json({ok: true}));
const response = await app.request('/test', {
method: 'OPTIONS',
headers: {origin: 'https://example.com'},
});
expect(response.status).toBe(204);
});
test('sets Access-Control-Allow-Methods header on preflight', async () => {
const app = new Hono();
app.use('*', cors({methods: ['GET', 'POST', 'PUT']}));
app.get('/test', (c) => c.json({ok: true}));
const response = await app.request('/test', {
method: 'OPTIONS',
});
expect(response.headers.get('Access-Control-Allow-Methods')).toBe('GET, POST, PUT');
});
test('uses default methods when not specified', async () => {
const app = new Hono();
app.use('*', cors());
app.get('/test', (c) => c.json({ok: true}));
const response = await app.request('/test', {
method: 'OPTIONS',
});
const methods = response.headers.get('Access-Control-Allow-Methods');
expect(methods).toContain('GET');
expect(methods).toContain('POST');
expect(methods).toContain('PUT');
expect(methods).toContain('DELETE');
expect(methods).toContain('OPTIONS');
});
test('sets Access-Control-Allow-Headers header on preflight', async () => {
const app = new Hono();
app.use('*', cors({allowedHeaders: ['Content-Type', 'Authorization', 'X-Custom-Header']}));
app.get('/test', (c) => c.json({ok: true}));
const response = await app.request('/test', {
method: 'OPTIONS',
});
expect(response.headers.get('Access-Control-Allow-Headers')).toBe('Content-Type, Authorization, X-Custom-Header');
});
test('sets Access-Control-Max-Age header on preflight', async () => {
const app = new Hono();
app.use('*', cors({maxAge: 3600}));
app.get('/test', (c) => c.json({ok: true}));
const response = await app.request('/test', {
method: 'OPTIONS',
});
expect(response.headers.get('Access-Control-Max-Age')).toBe('3600');
});
test('uses default maxAge of 86400', async () => {
const app = new Hono();
app.use('*', cors());
app.get('/test', (c) => c.json({ok: true}));
const response = await app.request('/test', {
method: 'OPTIONS',
});
expect(response.headers.get('Access-Control-Max-Age')).toBe('86400');
});
});
describe('exposed headers', () => {
test('sets Access-Control-Expose-Headers when exposedHeaders is provided', async () => {
const app = new Hono();
app.use('*', cors({exposedHeaders: ['X-Custom-Header', 'X-Another-Header']}));
app.get('/test', (c) => c.json({ok: true}));
const response = await app.request('/test');
expect(response.headers.get('Access-Control-Expose-Headers')).toBe('X-Custom-Header, X-Another-Header');
});
test('does not set Access-Control-Expose-Headers when empty', async () => {
const app = new Hono();
app.use('*', cors({exposedHeaders: []}));
app.get('/test', (c) => c.json({ok: true}));
const response = await app.request('/test');
expect(response.headers.get('Access-Control-Expose-Headers')).toBeNull();
});
test('sets exposed headers on both preflight and regular requests', async () => {
const app = new Hono();
app.use('*', cors({exposedHeaders: ['X-Custom-Header']}));
app.get('/test', (c) => c.json({ok: true}));
const preflightResponse = await app.request('/test', {method: 'OPTIONS'});
expect(preflightResponse.headers.get('Access-Control-Expose-Headers')).toBe('X-Custom-Header');
const regularResponse = await app.request('/test');
expect(regularResponse.headers.get('Access-Control-Expose-Headers')).toBe('X-Custom-Header');
});
});
describe('enabled option', () => {
test('skips CORS when enabled is false', async () => {
const app = new Hono();
app.use('*', cors({enabled: false}));
app.get('/test', (c) => c.json({ok: true}));
const response = await app.request('/test', {
headers: {origin: 'https://example.com'},
});
expect(response.status).toBe(200);
expect(response.headers.get('Access-Control-Allow-Origin')).toBeNull();
});
test('processes CORS when enabled is true', async () => {
const app = new Hono();
app.use('*', cors({enabled: true, origins: '*'}));
app.get('/test', (c) => c.json({ok: true}));
const response = await app.request('/test', {
headers: {origin: 'https://example.com'},
});
expect(response.headers.get('Access-Control-Allow-Origin')).toBe('*');
});
test('enabled defaults to true', async () => {
const app = new Hono();
app.use('*', cors());
app.get('/test', (c) => c.json({ok: true}));
const response = await app.request('/test', {
headers: {origin: 'https://example.com'},
});
expect(response.headers.get('Access-Control-Allow-Origin')).toBe('*');
});
});
describe('integration with routes', () => {
test('works with multiple routes', async () => {
const app = new Hono();
app.use('*', cors({origins: '*'}));
app.get('/api/users', (c) => c.json({users: []}));
app.post('/api/users', (c) => c.json({created: true}));
const getResponse = await app.request('/api/users');
expect(getResponse.headers.get('Access-Control-Allow-Origin')).toBe('*');
const postResponse = await app.request('/api/users', {method: 'POST'});
expect(postResponse.headers.get('Access-Control-Allow-Origin')).toBe('*');
});
});
});

View File

@@ -0,0 +1,248 @@
/*
* Copyright (C) 2026 Fluxer Contributors
*
* This file is part of Fluxer.
*
* Fluxer is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* Fluxer is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
*/
import {createErrorHandler} from '@fluxer/hono/src/middleware/ErrorHandler';
import {Hono} from 'hono';
import {HTTPException} from 'hono/http-exception';
import {describe, expect, test, vi} from 'vitest';
interface ErrorResponse {
code: string;
message: string;
stack?: string;
}
describe('ErrorHandler Middleware', () => {
describe('generic errors', () => {
test('handles generic Error with 500 status', async () => {
const app = new Hono();
app.onError(createErrorHandler());
app.get('/test', () => {
throw new Error('Something went wrong');
});
const response = await app.request('/test');
expect(response.status).toBe(500);
const body = (await response.json()) as ErrorResponse;
expect(body.code).toBe('INTERNAL_SERVER_ERROR');
expect(body.message).toBe('Something went wrong. Please try again later.');
});
test('includes stack trace when includeStack is true', async () => {
const app = new Hono();
app.onError(createErrorHandler({includeStack: true}));
app.get('/test', () => {
throw new Error('Something went wrong');
});
const response = await app.request('/test');
const body = (await response.json()) as ErrorResponse;
expect(body.message).toBe('Something went wrong');
expect(body.stack).toBeTruthy();
});
test('excludes stack trace by default', async () => {
const app = new Hono();
app.onError(createErrorHandler());
app.get('/test', () => {
throw new Error('Something went wrong');
});
const response = await app.request('/test');
const body = (await response.json()) as ErrorResponse;
expect(body.stack).toBeUndefined();
});
});
describe('HTTPException handling', () => {
test('handles HTTPException with correct status', async () => {
const app = new Hono();
app.onError(createErrorHandler());
app.get('/test', () => {
throw new HTTPException(404, {message: 'Resource not found'});
});
const response = await app.request('/test');
expect(response.status).toBe(404);
const body = (await response.json()) as ErrorResponse;
expect(body.code).toBe('NOT_FOUND');
expect(body.message).toBe('Resource not found');
});
test('handles HTTPException with 403 Forbidden', async () => {
const app = new Hono();
app.onError(createErrorHandler());
app.get('/test', () => {
throw new HTTPException(403, {message: 'Access denied'});
});
const response = await app.request('/test');
expect(response.status).toBe(403);
const body = (await response.json()) as ErrorResponse;
expect(body.code).toBe('FORBIDDEN');
expect(body.message).toBe('Access denied');
});
test('handles HTTPException without message', async () => {
const app = new Hono();
app.onError(createErrorHandler());
app.get('/test', () => {
throw new HTTPException(400);
});
const response = await app.request('/test');
expect(response.status).toBe(400);
const body = (await response.json()) as ErrorResponse;
expect(body.message).toBe('An error occurred');
});
});
describe('logger option', () => {
test('calls logger with error and context', async () => {
const logger = vi.fn();
const app = new Hono();
app.onError(createErrorHandler({logger}));
app.get('/test', () => {
throw new Error('Test error');
});
await app.request('/test');
expect(logger).toHaveBeenCalledTimes(1);
expect(logger).toHaveBeenCalledWith(expect.any(Error), expect.objectContaining({req: expect.anything()}));
});
test('does not call logger when not provided', async () => {
const app = new Hono();
app.onError(createErrorHandler());
app.get('/test', () => {
throw new Error('Test error');
});
const response = await app.request('/test');
expect(response.status).toBe(500);
});
});
describe('captureException option', () => {
test('calls captureException with error and context info', async () => {
const captureException = vi.fn();
const app = new Hono();
app.onError(createErrorHandler({captureException}));
app.get('/test', () => {
throw new Error('Test error');
});
await app.request('/test');
expect(captureException).toHaveBeenCalledTimes(1);
expect(captureException).toHaveBeenCalledWith(
expect.any(Error),
expect.objectContaining({
path: '/test',
method: 'GET',
}),
);
});
test('calls both logger and captureException when both provided', async () => {
const logger = vi.fn();
const captureException = vi.fn();
const app = new Hono();
app.onError(createErrorHandler({logger, captureException}));
app.get('/test', () => {
throw new Error('Test error');
});
await app.request('/test');
expect(logger).toHaveBeenCalledTimes(1);
expect(captureException).toHaveBeenCalledTimes(1);
});
});
describe('async errors', () => {
test('handles async errors', async () => {
const app = new Hono();
app.onError(createErrorHandler());
app.get('/test', async () => {
await Promise.resolve();
throw new Error('Async error');
});
const response = await app.request('/test');
expect(response.status).toBe(500);
});
test('handles rejected promises', async () => {
const app = new Hono();
app.onError(createErrorHandler());
app.get('/test', async () => {
return Promise.reject(new Error('Rejected promise'));
});
const response = await app.request('/test');
expect(response.status).toBe(500);
});
});
describe('multiple routes', () => {
test('handles errors from different routes', async () => {
const app = new Hono();
app.onError(createErrorHandler());
app.get('/route1', () => {
throw new HTTPException(404, {message: 'Route 1 not found'});
});
app.get('/route2', () => {
throw new Error('Route 2 error');
});
const response1 = await app.request('/route1');
expect(response1.status).toBe(404);
const response2 = await app.request('/route2');
expect(response2.status).toBe(500);
});
});
describe('error recovery', () => {
test('does not affect subsequent successful requests', async () => {
const app = new Hono();
app.onError(createErrorHandler());
app.get('/error', () => {
throw new Error('Error route');
});
app.get('/success', (c) => c.json({ok: true}));
const errorResponse = await app.request('/error');
expect(errorResponse.status).toBe(500);
const successResponse = await app.request('/success');
expect(successResponse.status).toBe(200);
const body = (await successResponse.json()) as {ok: boolean};
expect(body.ok).toBe(true);
});
});
});

View File

@@ -0,0 +1,310 @@
/*
* Copyright (C) 2026 Fluxer Contributors
*
* This file is part of Fluxer.
*
* Fluxer is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* Fluxer is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
*/
import {createErrorHandler} from '@fluxer/hono/src/middleware/ErrorHandler';
import {createInternalAuth} from '@fluxer/hono/src/middleware/InternalAuth';
import {Hono} from 'hono';
import {describe, expect, test} from 'vitest';
interface ErrorResponse {
code: string;
message: string;
}
describe('InternalAuth Middleware', () => {
const TEST_SECRET = 'test-secret-token';
describe('missing authorization', () => {
test('returns 401 when no Authorization header is present', async () => {
const app = new Hono();
app.use('*', createInternalAuth({secret: TEST_SECRET}));
app.onError(createErrorHandler());
app.get('/test', (c) => c.json({ok: true}));
const response = await app.request('/test');
expect(response.status).toBe(401);
const body = (await response.json()) as ErrorResponse;
expect(body.code).toBe('UNAUTHORIZED');
expect(body.message).toBe('Missing Authorization header');
});
});
describe('invalid authorization format', () => {
test('returns 401 when Authorization header does not start with Bearer', async () => {
const app = new Hono();
app.use('*', createInternalAuth({secret: TEST_SECRET}));
app.onError(createErrorHandler());
app.get('/test', (c) => c.json({ok: true}));
const response = await app.request('/test', {
headers: {
Authorization: 'Basic dXNlcjpwYXNz',
},
});
expect(response.status).toBe(401);
const body = (await response.json()) as ErrorResponse;
expect(body.code).toBe('UNAUTHORIZED');
expect(body.message).toBe('Invalid Authorization header format');
});
test('returns 401 when Authorization header is just Bearer without token', async () => {
const app = new Hono();
app.use('*', createInternalAuth({secret: TEST_SECRET}));
app.onError(createErrorHandler());
app.get('/test', (c) => c.json({ok: true}));
const response = await app.request('/test', {
headers: {
Authorization: 'Bearer',
},
});
expect(response.status).toBe(401);
const body = (await response.json()) as ErrorResponse;
expect(body.code).toBe('UNAUTHORIZED');
expect(body.message).toBe('Invalid Authorization header format');
});
});
describe('invalid token', () => {
test('returns 401 when token does not match secret', async () => {
const app = new Hono();
app.use('*', createInternalAuth({secret: TEST_SECRET}));
app.onError(createErrorHandler());
app.get('/test', (c) => c.json({ok: true}));
const response = await app.request('/test', {
headers: {
Authorization: 'Bearer wrong-token',
},
});
expect(response.status).toBe(401);
const body = (await response.json()) as ErrorResponse;
expect(body.code).toBe('UNAUTHORIZED');
expect(body.message).toBe('Invalid token');
});
test('returns 401 when token is empty after Bearer prefix', async () => {
const app = new Hono();
app.use('*', createInternalAuth({secret: TEST_SECRET}));
app.onError(createErrorHandler());
app.get('/test', (c) => c.json({ok: true}));
const response = await app.request('/test', {
headers: {
Authorization: 'Bearer ',
},
});
expect(response.status).toBe(401);
const body = (await response.json()) as ErrorResponse;
expect(body.code).toBe('UNAUTHORIZED');
expect(body.message).toBe('Invalid Authorization header format');
});
test('returns 401 when token has different length than secret', async () => {
const app = new Hono();
app.use('*', createInternalAuth({secret: TEST_SECRET}));
app.onError(createErrorHandler());
app.get('/test', (c) => c.json({ok: true}));
const response = await app.request('/test', {
headers: {
Authorization: 'Bearer short',
},
});
expect(response.status).toBe(401);
const body = (await response.json()) as ErrorResponse;
expect(body.code).toBe('UNAUTHORIZED');
expect(body.message).toBe('Invalid token');
});
});
describe('valid token', () => {
test('allows request through when token matches secret', async () => {
const app = new Hono();
app.use('*', createInternalAuth({secret: TEST_SECRET}));
app.onError(createErrorHandler());
app.get('/test', (c) => c.json({ok: true}));
const response = await app.request('/test', {
headers: {
Authorization: `Bearer ${TEST_SECRET}`,
},
});
expect(response.status).toBe(200);
const body = (await response.json()) as {ok: boolean};
expect(body.ok).toBe(true);
});
test('allows POST requests with valid token', async () => {
const app = new Hono();
app.use('*', createInternalAuth({secret: TEST_SECRET}));
app.onError(createErrorHandler());
app.post('/test', (c) => c.json({created: true}));
const response = await app.request('/test', {
method: 'POST',
headers: {
Authorization: `Bearer ${TEST_SECRET}`,
},
});
expect(response.status).toBe(200);
const body = (await response.json()) as {created: boolean};
expect(body.created).toBe(true);
});
});
describe('skipPaths option', () => {
test('skips auth for exact path match in skipPaths', async () => {
const app = new Hono();
app.use('*', createInternalAuth({secret: TEST_SECRET, skipPaths: ['/public']}));
app.onError(createErrorHandler());
app.get('/public', (c) => c.json({ok: true}));
const response = await app.request('/public');
expect(response.status).toBe(200);
const body = (await response.json()) as {ok: boolean};
expect(body.ok).toBe(true);
});
test('skips auth for paths starting with skipPath prefix', async () => {
const app = new Hono();
app.use('*', createInternalAuth({secret: TEST_SECRET, skipPaths: ['/public']}));
app.onError(createErrorHandler());
app.get('/public/nested', (c) => c.json({ok: true}));
const response = await app.request('/public/nested');
expect(response.status).toBe(200);
const body = (await response.json()) as {ok: boolean};
expect(body.ok).toBe(true);
});
test('does not skip auth for paths that do not match skipPaths', async () => {
const app = new Hono();
app.use('*', createInternalAuth({secret: TEST_SECRET, skipPaths: ['/public']}));
app.onError(createErrorHandler());
app.get('/private', (c) => c.json({ok: true}));
const response = await app.request('/private');
expect(response.status).toBe(401);
});
test('supports multiple skipPaths', async () => {
const app = new Hono();
app.use('*', createInternalAuth({secret: TEST_SECRET, skipPaths: ['/public', '/health']}));
app.onError(createErrorHandler());
app.get('/public', (c) => c.json({ok: true}));
app.get('/health', (c) => c.json({ok: true}));
app.get('/private', (c) => c.json({ok: true}));
const publicResponse = await app.request('/public');
expect(publicResponse.status).toBe(200);
const healthResponse = await app.request('/health');
expect(healthResponse.status).toBe(200);
const privateResponse = await app.request('/private');
expect(privateResponse.status).toBe(401);
});
});
describe('default skipPaths', () => {
test('default skipPaths includes /_health', async () => {
const app = new Hono();
app.use('*', createInternalAuth({secret: TEST_SECRET}));
app.onError(createErrorHandler());
app.get('/_health', (c) => c.json({ok: true}));
const response = await app.request('/_health');
expect(response.status).toBe(200);
const body = (await response.json()) as {ok: boolean};
expect(body.ok).toBe(true);
});
test('default skipPaths allows nested /_health paths', async () => {
const app = new Hono();
app.use('*', createInternalAuth({secret: TEST_SECRET}));
app.onError(createErrorHandler());
app.get('/_health/detailed', (c) => c.json({ok: true}));
const response = await app.request('/_health/detailed');
expect(response.status).toBe(200);
const body = (await response.json()) as {ok: boolean};
expect(body.ok).toBe(true);
});
test('default skipPaths still requires auth for non-health paths', async () => {
const app = new Hono();
app.use('*', createInternalAuth({secret: TEST_SECRET}));
app.onError(createErrorHandler());
app.get('/api', (c) => c.json({ok: true}));
const response = await app.request('/api');
expect(response.status).toBe(401);
});
});
describe('multiple routes', () => {
test('handles multiple routes with proper auth', async () => {
const app = new Hono();
app.use('*', createInternalAuth({secret: TEST_SECRET}));
app.onError(createErrorHandler());
app.get('/api/users', (c) => c.json({users: []}));
app.get('/api/posts', (c) => c.json({posts: []}));
const usersResponse = await app.request('/api/users', {
headers: {Authorization: `Bearer ${TEST_SECRET}`},
});
expect(usersResponse.status).toBe(200);
const postsResponse = await app.request('/api/posts', {
headers: {Authorization: `Bearer ${TEST_SECRET}`},
});
expect(postsResponse.status).toBe(200);
});
});
describe('error recovery', () => {
test('does not affect subsequent successful requests', async () => {
const app = new Hono();
app.use('*', createInternalAuth({secret: TEST_SECRET}));
app.onError(createErrorHandler());
app.get('/test', (c) => c.json({ok: true}));
const errorResponse = await app.request('/test');
expect(errorResponse.status).toBe(401);
const successResponse = await app.request('/test', {
headers: {Authorization: `Bearer ${TEST_SECRET}`},
});
expect(successResponse.status).toBe(200);
});
});
});

View File

@@ -0,0 +1,363 @@
/*
* Copyright (C) 2026 Fluxer Contributors
*
* This file is part of Fluxer.
*
* Fluxer is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* Fluxer is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
*/
import {metrics} from '@fluxer/hono/src/middleware/Metrics';
import type {MetricsCollector} from '@fluxer/hono_types/src/MetricsTypes';
import {Hono} from 'hono';
import {describe, expect, test, vi} from 'vitest';
function createMockCollector(): MetricsCollector {
return {
recordCounter: vi.fn(),
recordHistogram: vi.fn(),
recordGauge: vi.fn(),
};
}
describe('Metrics Middleware', () => {
describe('enabled option', () => {
test('skips metrics when enabled is false', async () => {
const collector = createMockCollector();
const app = new Hono();
app.use('*', metrics({enabled: false, collector}));
app.get('/test', (c) => c.json({ok: true}));
await app.request('/test');
expect(collector.recordCounter).not.toHaveBeenCalled();
expect(collector.recordHistogram).not.toHaveBeenCalled();
});
test('skips metrics when collector is not provided', async () => {
const app = new Hono();
app.use('*', metrics({enabled: true}));
app.get('/test', (c) => c.json({ok: true}));
const response = await app.request('/test');
expect(response.status).toBe(200);
});
test('records metrics when enabled and collector provided', async () => {
const collector = createMockCollector();
const app = new Hono();
app.use('*', metrics({enabled: true, collector}));
app.get('/test', (c) => c.json({ok: true}));
await app.request('/test');
expect(collector.recordCounter).toHaveBeenCalled();
expect(collector.recordHistogram).toHaveBeenCalled();
});
});
describe('skip paths', () => {
test('skips default health paths', async () => {
const collector = createMockCollector();
const app = new Hono();
app.use('*', metrics({collector}));
app.get('/_health', (c) => c.json({ok: true}));
app.get('/metrics', (c) => c.json({ok: true}));
await app.request('/_health');
await app.request('/metrics');
expect(collector.recordCounter).not.toHaveBeenCalled();
});
test('skips custom paths', async () => {
const collector = createMockCollector();
const app = new Hono();
app.use('*', metrics({skipPaths: ['/internal'], collector}));
app.get('/internal', (c) => c.json({ok: true}));
await app.request('/internal');
expect(collector.recordCounter).not.toHaveBeenCalled();
});
test('skips paths with wildcard patterns', async () => {
const collector = createMockCollector();
const app = new Hono();
app.use('*', metrics({skipPaths: ['/admin/*'], collector}));
app.get('/admin/dashboard', (c) => c.json({ok: true}));
app.get('/admin/users', (c) => c.json({ok: true}));
await app.request('/admin/dashboard');
await app.request('/admin/users');
expect(collector.recordCounter).not.toHaveBeenCalled();
});
test('records metrics for non-skipped paths', async () => {
const collector = createMockCollector();
const app = new Hono();
app.use('*', metrics({skipPaths: ['/skip'], collector}));
app.get('/api/users', (c) => c.json({ok: true}));
await app.request('/api/users');
expect(collector.recordCounter).toHaveBeenCalled();
});
});
describe('counter metrics', () => {
test('records http_requests_total counter', async () => {
const collector = createMockCollector();
const app = new Hono();
app.use('*', metrics({collector}));
app.get('/test', (c) => c.json({ok: true}));
await app.request('/test');
expect(collector.recordCounter).toHaveBeenCalledWith(
expect.objectContaining({
name: 'http_requests_total',
value: 1,
}),
);
});
test('records http_errors_total for 4xx responses', async () => {
const collector = createMockCollector();
const app = new Hono();
app.use('*', metrics({collector}));
app.get('/test', (c) => c.json({error: 'Bad Request'}, 400));
await app.request('/test');
expect(collector.recordCounter).toHaveBeenCalledWith(
expect.objectContaining({
name: 'http_errors_total',
value: 1,
}),
);
});
test('records http_errors_total for 5xx responses', async () => {
const collector = createMockCollector();
const app = new Hono();
app.use('*', metrics({collector}));
app.get('/test', (c) => c.json({error: 'Server Error'}, 500));
await app.request('/test');
expect(collector.recordCounter).toHaveBeenCalledWith(
expect.objectContaining({
name: 'http_errors_total',
value: 1,
}),
);
});
test('does not record http_errors_total for 2xx responses', async () => {
const collector = createMockCollector();
const app = new Hono();
app.use('*', metrics({collector}));
app.get('/test', (c) => c.json({ok: true}));
await app.request('/test');
const errorCalls = (collector.recordCounter as ReturnType<typeof vi.fn>).mock.calls.filter(
(call) => call[0].name === 'http_errors_total',
);
expect(errorCalls).toHaveLength(0);
});
test('does not record http_errors_total for 3xx responses', async () => {
const collector = createMockCollector();
const app = new Hono();
app.use('*', metrics({collector}));
app.get('/test', (c) => c.redirect('/other'));
await app.request('/test');
const errorCalls = (collector.recordCounter as ReturnType<typeof vi.fn>).mock.calls.filter(
(call) => call[0].name === 'http_errors_total',
);
expect(errorCalls).toHaveLength(0);
});
});
describe('histogram metrics', () => {
test('records http_request_duration_ms histogram', async () => {
const collector = createMockCollector();
const app = new Hono();
app.use('*', metrics({collector}));
app.get('/test', (c) => c.json({ok: true}));
await app.request('/test');
expect(collector.recordHistogram).toHaveBeenCalledWith(
expect.objectContaining({
name: 'http_request_duration_ms',
}),
);
});
test('records positive duration value', async () => {
const collector = createMockCollector();
const app = new Hono();
app.use('*', metrics({collector}));
app.get('/test', (c) => c.json({ok: true}));
await app.request('/test');
const histogramCall = (collector.recordHistogram as ReturnType<typeof vi.fn>).mock.calls.find(
(call) => call[0].name === 'http_request_duration_ms',
);
expect(histogramCall).toBeTruthy();
expect(histogramCall![0].value).toBeGreaterThanOrEqual(0);
});
});
describe('labels', () => {
test('includes method label when includeMethod is true', async () => {
const collector = createMockCollector();
const app = new Hono();
app.use('*', metrics({collector, includeMethod: true}));
app.get('/test', (c) => c.json({ok: true}));
await app.request('/test');
expect(collector.recordCounter).toHaveBeenCalledWith(
expect.objectContaining({
labels: expect.objectContaining({
method: 'GET',
}),
}),
);
});
test('includes method by default', async () => {
const collector = createMockCollector();
const app = new Hono();
app.use('*', metrics({collector}));
app.post('/test', (c) => c.json({ok: true}));
await app.request('/test', {method: 'POST'});
expect(collector.recordCounter).toHaveBeenCalledWith(
expect.objectContaining({
labels: expect.objectContaining({
method: 'POST',
}),
}),
);
});
test('excludes method label when includeMethod is false', async () => {
const collector = createMockCollector();
const app = new Hono();
app.use('*', metrics({collector, includeMethod: false}));
app.get('/test', (c) => c.json({ok: true}));
await app.request('/test');
const call = (collector.recordCounter as ReturnType<typeof vi.fn>).mock.calls[0];
expect(call[0].labels.method).toBeUndefined();
});
test('includes status label when includeStatus is true', async () => {
const collector = createMockCollector();
const app = new Hono();
app.use('*', metrics({collector, includeStatus: true}));
app.get('/test', (c) => c.json({ok: true}));
await app.request('/test');
expect(collector.recordCounter).toHaveBeenCalledWith(
expect.objectContaining({
labels: expect.objectContaining({
status: '200',
}),
}),
);
});
test('includes status by default', async () => {
const collector = createMockCollector();
const app = new Hono();
app.use('*', metrics({collector}));
app.get('/test', (c) => c.json({error: 'Not Found'}, 404));
await app.request('/test');
expect(collector.recordCounter).toHaveBeenCalledWith(
expect.objectContaining({
labels: expect.objectContaining({
status: '404',
}),
}),
);
});
test('excludes status label when includeStatus is false', async () => {
const collector = createMockCollector();
const app = new Hono();
app.use('*', metrics({collector, includeStatus: false}));
app.get('/test', (c) => c.json({ok: true}));
await app.request('/test');
const call = (collector.recordCounter as ReturnType<typeof vi.fn>).mock.calls[0];
expect(call[0].labels.status).toBeUndefined();
});
test('includes path label when includePath is true', async () => {
const collector = createMockCollector();
const app = new Hono();
app.use('*', metrics({collector, includePath: true}));
app.get('/api/users', (c) => c.json({ok: true}));
await app.request('/api/users');
expect(collector.recordCounter).toHaveBeenCalledWith(
expect.objectContaining({
labels: expect.objectContaining({
path: '/api/users',
}),
}),
);
});
test('excludes path label by default', async () => {
const collector = createMockCollector();
const app = new Hono();
app.use('*', metrics({collector}));
app.get('/api/users', (c) => c.json({ok: true}));
await app.request('/api/users');
const call = (collector.recordCounter as ReturnType<typeof vi.fn>).mock.calls[0];
expect(call[0].labels.path).toBeUndefined();
});
});
describe('multiple requests', () => {
test('records metrics for each request', async () => {
const collector = createMockCollector();
const app = new Hono();
app.use('*', metrics({collector}));
app.get('/test', (c) => c.json({ok: true}));
await app.request('/test');
await app.request('/test');
await app.request('/test');
expect(collector.recordCounter).toHaveBeenCalledTimes(3);
expect(collector.recordHistogram).toHaveBeenCalledTimes(3);
});
});
});

View File

@@ -0,0 +1,441 @@
/*
* Copyright (C) 2026 Fluxer Contributors
*
* This file is part of Fluxer.
*
* Fluxer is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* Fluxer is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
*/
import {Headers} from '@fluxer/constants/src/Headers';
import {
applyMiddlewareStack,
createDefaultErrorLogger,
createDefaultLogger,
createStandardMiddlewareStack,
} from '@fluxer/hono/src/middleware/MiddlewareStack';
import type {RateLimitResult, RateLimitService} from '@fluxer/hono/src/middleware/RateLimit';
import {REQUEST_ID_KEY} from '@fluxer/hono/src/middleware/RequestId';
import type {MetricsCollector} from '@fluxer/hono_types/src/MetricsTypes';
import type {Context} from 'hono';
import {Hono} from 'hono';
import {describe, expect, test, vi} from 'vitest';
function createMockRateLimitService(result: Partial<RateLimitResult> = {}): RateLimitService {
const defaultResult: RateLimitResult = {
allowed: true,
limit: 100,
remaining: 99,
resetTime: new Date(Date.now() + 60000),
...result,
};
return {
checkLimit: vi.fn().mockResolvedValue(defaultResult),
};
}
function createMockCollector(): MetricsCollector {
return {
recordCounter: vi.fn(),
recordHistogram: vi.fn(),
recordGauge: vi.fn(),
};
}
describe('createStandardMiddlewareStack', () => {
test('returns empty array with no options', () => {
const stack = createStandardMiddlewareStack();
expect(stack).toHaveLength(0);
});
test('includes requestId middleware when configured', () => {
const stack = createStandardMiddlewareStack({requestId: {}});
expect(stack).toHaveLength(1);
});
test('includes cors middleware when enabled', () => {
const stack = createStandardMiddlewareStack({cors: {enabled: true}});
expect(stack).toHaveLength(1);
});
test('excludes cors middleware when disabled', () => {
const stack = createStandardMiddlewareStack({cors: {enabled: false}});
expect(stack).toHaveLength(0);
});
test('includes tracing middleware when enabled with functions', () => {
const stack = createStandardMiddlewareStack({
tracing: {
enabled: true,
withSpan: vi.fn(),
setSpanAttributes: vi.fn(),
},
});
expect(stack).toHaveLength(1);
});
test('excludes tracing middleware when disabled', () => {
const stack = createStandardMiddlewareStack({
tracing: {enabled: false, withSpan: vi.fn(), setSpanAttributes: vi.fn()},
});
expect(stack).toHaveLength(0);
});
test('includes metrics middleware when enabled with collector', () => {
const stack = createStandardMiddlewareStack({
metrics: {enabled: true, collector: createMockCollector()},
});
expect(stack).toHaveLength(1);
});
test('excludes metrics middleware when no collector provided', () => {
const stack = createStandardMiddlewareStack({metrics: {enabled: true}});
expect(stack).toHaveLength(0);
});
test('includes logger middleware when log function provided', () => {
const stack = createStandardMiddlewareStack({logger: {log: vi.fn()}});
expect(stack).toHaveLength(1);
});
test('excludes logger middleware when no log function', () => {
const stack = createStandardMiddlewareStack({logger: {}});
expect(stack).toHaveLength(0);
});
test('includes rateLimit middleware when enabled with service', () => {
const stack = createStandardMiddlewareStack({
rateLimit: {enabled: true, service: createMockRateLimitService()},
});
expect(stack).toHaveLength(1);
});
test('excludes rateLimit middleware when no service provided', () => {
const stack = createStandardMiddlewareStack({rateLimit: {enabled: true}});
expect(stack).toHaveLength(0);
});
test('includes custom middleware', () => {
const customMiddleware = vi.fn();
const stack = createStandardMiddlewareStack({
customMiddleware: [customMiddleware, customMiddleware],
});
expect(stack).toHaveLength(2);
});
test('combines all middleware in correct order', () => {
const stack = createStandardMiddlewareStack({
requestId: {},
cors: {enabled: true},
tracing: {enabled: true, withSpan: vi.fn(), setSpanAttributes: vi.fn()},
metrics: {enabled: true, collector: createMockCollector()},
logger: {log: vi.fn()},
rateLimit: {enabled: true, service: createMockRateLimitService()},
customMiddleware: [vi.fn()],
});
expect(stack).toHaveLength(7);
});
});
describe('applyMiddlewareStack', () => {
test('applies requestId middleware', async () => {
const app = new Hono<{Variables: {[REQUEST_ID_KEY]: string}}>();
applyMiddlewareStack(app, {requestId: {}});
app.get('/test', (c) => c.json({id: c.get(REQUEST_ID_KEY)}));
const response = await app.request('/test');
expect(response.headers.get(Headers.X_REQUEST_ID)).toBeTruthy();
});
test('applies cors middleware', async () => {
const app = new Hono();
applyMiddlewareStack(app, {cors: {enabled: true, origins: '*'}});
app.get('/test', (c) => c.json({ok: true}));
const response = await app.request('/test', {
headers: {origin: 'https://example.com'},
});
expect(response.headers.get('Access-Control-Allow-Origin')).toBe('*');
});
test('applies error handler by default', async () => {
const app = new Hono();
applyMiddlewareStack(app, {});
app.get('/test', () => {
throw new Error('Test error');
});
const response = await app.request('/test');
expect(response.status).toBe(500);
const body = (await response.json()) as {code: string};
expect(body.code).toBe('INTERNAL_SERVER_ERROR');
});
test('skips requestId when skipRequestId is true', async () => {
const app = new Hono<{Variables: {[REQUEST_ID_KEY]: string}}>();
applyMiddlewareStack(app, {
requestId: {},
skipRequestId: true,
});
app.get('/test', (c) => c.json({id: c.get(REQUEST_ID_KEY)}));
const response = await app.request('/test');
expect(response.headers.get(Headers.X_REQUEST_ID)).toBeNull();
});
test('skips cors when skipCors is true', async () => {
const app = new Hono();
applyMiddlewareStack(app, {
cors: {enabled: true, origins: '*'},
skipCors: true,
});
app.get('/test', (c) => c.json({ok: true}));
const response = await app.request('/test', {
headers: {origin: 'https://example.com'},
});
expect(response.headers.get('Access-Control-Allow-Origin')).toBeNull();
});
test('skips tracing when skipTracing is true', async () => {
const withSpan = vi.fn();
const app = new Hono();
applyMiddlewareStack(app, {
tracing: {enabled: true, withSpan, setSpanAttributes: vi.fn()},
skipTracing: true,
});
app.get('/test', (c) => c.json({ok: true}));
await app.request('/test');
expect(withSpan).not.toHaveBeenCalled();
});
test('skips metrics when skipMetrics is true', async () => {
const collector = createMockCollector();
const app = new Hono();
applyMiddlewareStack(app, {
metrics: {enabled: true, collector},
skipMetrics: true,
});
app.get('/test', (c) => c.json({ok: true}));
await app.request('/test');
expect(collector.recordCounter).not.toHaveBeenCalled();
});
test('skips logger when skipLogger is true', async () => {
const log = vi.fn();
const app = new Hono();
applyMiddlewareStack(app, {
logger: {log},
skipLogger: true,
});
app.get('/test', (c) => c.json({ok: true}));
await app.request('/test');
expect(log).not.toHaveBeenCalled();
});
test('skips rateLimit when skipRateLimit is true', async () => {
const service = createMockRateLimitService();
const app = new Hono();
applyMiddlewareStack(app, {
rateLimit: {enabled: true, service},
skipRateLimit: true,
});
app.get('/test', (c) => c.json({ok: true}));
await app.request('/test');
expect(service.checkLimit).not.toHaveBeenCalled();
});
test('skips errorHandler when skipErrorHandler is true', async () => {
const app = new Hono();
applyMiddlewareStack(app, {
skipErrorHandler: true,
});
app.get('/test', () => {
throw new Error('Test error');
});
const response = await app.request('/test');
expect(response.status).toBe(500);
});
test('applies custom middleware', async () => {
const customMiddleware = vi.fn().mockImplementation(async (_c, next) => {
await next();
});
const app = new Hono();
applyMiddlewareStack(app, {
customMiddleware: [customMiddleware],
});
app.get('/test', (c) => c.json({ok: true}));
await app.request('/test');
expect(customMiddleware).toHaveBeenCalled();
});
test('applies logger with skip paths', async () => {
const log = vi.fn();
const app = new Hono();
applyMiddlewareStack(app, {
logger: {log, skip: ['/_health']},
});
app.get('/_health', (c) => c.json({ok: true}));
app.get('/api', (c) => c.json({ok: true}));
await app.request('/_health');
expect(log).not.toHaveBeenCalled();
await app.request('/api');
expect(log).toHaveBeenCalled();
});
});
describe('createDefaultLogger', () => {
test('logs request data as JSON', async () => {
const consoleSpy = vi.spyOn(console, 'log').mockImplementation(() => {});
const logger = createDefaultLogger({serviceName: 'test-service'});
logger({method: 'GET', path: '/api/test', status: 200, durationMs: 50});
expect(consoleSpy).toHaveBeenCalledTimes(1);
const loggedData = JSON.parse(consoleSpy.mock.calls[0][0] as string);
expect(loggedData.service).toBe('test-service');
expect(loggedData.method).toBe('GET');
expect(loggedData.path).toBe('/api/test');
expect(loggedData.status).toBe(200);
expect(loggedData.durationMs).toBe(50);
expect(loggedData.timestamp).toBeTruthy();
consoleSpy.mockRestore();
});
test('skips paths in skip array', () => {
const consoleSpy = vi.spyOn(console, 'log').mockImplementation(() => {});
const logger = createDefaultLogger({serviceName: 'test-service', skip: ['/_health']});
logger({method: 'GET', path: '/_health', status: 200, durationMs: 1});
expect(consoleSpy).not.toHaveBeenCalled();
consoleSpy.mockRestore();
});
test('logs paths not in skip array', () => {
const consoleSpy = vi.spyOn(console, 'log').mockImplementation(() => {});
const logger = createDefaultLogger({serviceName: 'test-service', skip: ['/_health']});
logger({method: 'GET', path: '/api/users', status: 200, durationMs: 1});
expect(consoleSpy).toHaveBeenCalledTimes(1);
consoleSpy.mockRestore();
});
});
describe('createDefaultErrorLogger', () => {
test('logs error data as JSON', async () => {
const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
const errorLogger = createDefaultErrorLogger({serviceName: 'test-service'});
const mockContext = {
req: {
path: '/api/test',
method: 'POST',
},
} as unknown as Context;
const error = new Error('Test error');
errorLogger(error, mockContext);
expect(consoleSpy).toHaveBeenCalledTimes(1);
const loggedData = JSON.parse(consoleSpy.mock.calls[0][0] as string);
expect(loggedData.service).toBe('test-service');
expect(loggedData.error).toBe('Test error');
expect(loggedData.stack).toBeTruthy();
expect(loggedData.path).toBe('/api/test');
expect(loggedData.method).toBe('POST');
expect(loggedData.timestamp).toBeTruthy();
consoleSpy.mockRestore();
});
});
describe('integration tests', () => {
test('full middleware stack works together', async () => {
const log = vi.fn();
const collector = createMockCollector();
const rateLimitService = createMockRateLimitService();
const app = new Hono();
applyMiddlewareStack(app, {
requestId: {},
cors: {enabled: true, origins: '*'},
metrics: {enabled: true, collector},
logger: {log},
rateLimit: {enabled: true, service: rateLimitService},
});
app.get('/test', (c) => c.json({ok: true}));
const response = await app.request('/test');
expect(response.status).toBe(200);
expect(response.headers.get(Headers.X_REQUEST_ID)).toBeTruthy();
expect(response.headers.get('Access-Control-Allow-Origin')).toBe('*');
expect(response.headers.get('X-RateLimit-Limit')).toBe('100');
expect(log).toHaveBeenCalled();
expect(collector.recordCounter).toHaveBeenCalled();
expect(rateLimitService.checkLimit).toHaveBeenCalled();
});
test('error handler catches errors from routes', async () => {
const app = new Hono();
applyMiddlewareStack(app, {requestId: {}});
app.get('/error', () => {
throw new Error('Route error');
});
const response = await app.request('/error');
expect(response.status).toBe(500);
expect(response.headers.get(Headers.X_REQUEST_ID)).toBeTruthy();
});
test('rate limiter blocks requests when limit exceeded', async () => {
const rateLimitService = createMockRateLimitService({allowed: false, remaining: 0});
const app = new Hono();
applyMiddlewareStack(app, {
rateLimit: {enabled: true, service: rateLimitService},
});
app.get('/test', (c) => c.json({ok: true}));
const response = await app.request('/test');
expect(response.status).toBe(429);
});
test('health endpoints are skipped by default for rate limiting', async () => {
const rateLimitService = createMockRateLimitService();
const app = new Hono();
applyMiddlewareStack(app, {
rateLimit: {enabled: true, service: rateLimitService},
});
app.get('/_health', (c) => c.json({ok: true}));
await app.request('/_health');
expect(rateLimitService.checkLimit).not.toHaveBeenCalled();
});
});

View File

@@ -0,0 +1,352 @@
/*
* Copyright (C) 2026 Fluxer Contributors
*
* This file is part of Fluxer.
*
* Fluxer is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* Fluxer is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
*/
import type {RateLimitResult, RateLimitService} from '@fluxer/hono/src/middleware/RateLimit';
import {rateLimit} from '@fluxer/hono/src/middleware/RateLimit';
import {Hono} from 'hono';
import {describe, expect, test, vi} from 'vitest';
function createMockRateLimitService(result: Partial<RateLimitResult> = {}): RateLimitService {
const defaultResult: RateLimitResult = {
allowed: true,
limit: 100,
remaining: 99,
resetTime: new Date(Date.now() + 60000),
...result,
};
return {
checkLimit: vi.fn().mockResolvedValue(defaultResult),
};
}
describe('RateLimit Middleware', () => {
describe('enabled option', () => {
test('skips rate limiting when enabled is false', async () => {
const service = createMockRateLimitService();
const app = new Hono();
app.use('*', rateLimit({enabled: false, service}));
app.get('/test', (c) => c.json({ok: true}));
const response = await app.request('/test');
expect(response.status).toBe(200);
expect(service.checkLimit).not.toHaveBeenCalled();
});
test('skips rate limiting when service is not provided', async () => {
const app = new Hono();
app.use('*', rateLimit({enabled: true}));
app.get('/test', (c) => c.json({ok: true}));
const response = await app.request('/test');
expect(response.status).toBe(200);
});
test('applies rate limiting when enabled and service provided', async () => {
const service = createMockRateLimitService();
const app = new Hono();
app.use('*', rateLimit({enabled: true, service}));
app.get('/test', (c) => c.json({ok: true}));
await app.request('/test');
expect(service.checkLimit).toHaveBeenCalled();
});
});
describe('skip paths', () => {
test('skips default health paths', async () => {
const service = createMockRateLimitService();
const app = new Hono();
app.use('*', rateLimit({service}));
app.get('/_health', (c) => c.json({ok: true}));
app.get('/metrics', (c) => c.json({ok: true}));
await app.request('/_health');
await app.request('/metrics');
expect(service.checkLimit).not.toHaveBeenCalled();
});
test('skips custom paths', async () => {
const service = createMockRateLimitService();
const app = new Hono();
app.use('*', rateLimit({skipPaths: ['/public'], service}));
app.get('/public', (c) => c.json({ok: true}));
await app.request('/public');
expect(service.checkLimit).not.toHaveBeenCalled();
});
test('skips paths with wildcard patterns', async () => {
const service = createMockRateLimitService();
const app = new Hono();
app.use('*', rateLimit({skipPaths: ['/static/*'], service}));
app.get('/static/file.js', (c) => c.json({ok: true}));
app.get('/static/images/logo.png', (c) => c.json({ok: true}));
await app.request('/static/file.js');
await app.request('/static/images/logo.png');
expect(service.checkLimit).not.toHaveBeenCalled();
});
test('applies rate limit to non-skipped paths', async () => {
const service = createMockRateLimitService();
const app = new Hono();
app.use('*', rateLimit({skipPaths: ['/public'], service}));
app.get('/api/users', (c) => c.json({ok: true}));
await app.request('/api/users');
expect(service.checkLimit).toHaveBeenCalled();
});
});
describe('rate limit headers', () => {
test('sets X-RateLimit-Limit header', async () => {
const service = createMockRateLimitService({limit: 100});
const app = new Hono();
app.use('*', rateLimit({service}));
app.get('/test', (c) => c.json({ok: true}));
const response = await app.request('/test');
expect(response.headers.get('X-RateLimit-Limit')).toBe('100');
});
test('sets X-RateLimit-Remaining header', async () => {
const service = createMockRateLimitService({remaining: 42});
const app = new Hono();
app.use('*', rateLimit({service}));
app.get('/test', (c) => c.json({ok: true}));
const response = await app.request('/test');
expect(response.headers.get('X-RateLimit-Remaining')).toBe('42');
});
test('sets X-RateLimit-Reset header as unix timestamp', async () => {
const resetTime = new Date(Date.now() + 60000);
const service = createMockRateLimitService({resetTime});
const app = new Hono();
app.use('*', rateLimit({service}));
app.get('/test', (c) => c.json({ok: true}));
const response = await app.request('/test');
const reset = response.headers.get('X-RateLimit-Reset');
expect(reset).toBe(Math.floor(resetTime.getTime() / 1000).toString());
});
});
describe('rate limit exceeded', () => {
test('returns 429 when rate limit exceeded', async () => {
const service = createMockRateLimitService({allowed: false, remaining: 0});
const app = new Hono();
app.use('*', rateLimit({service}));
app.get('/test', (c) => c.json({ok: true}));
const response = await app.request('/test');
expect(response.status).toBe(429);
});
test('returns error message when rate limit exceeded', async () => {
const service = createMockRateLimitService({allowed: false});
const app = new Hono();
app.use('*', rateLimit({service}));
app.get('/test', (c) => c.json({ok: true}));
const response = await app.request('/test');
const body = (await response.json()) as {error: string; message: string};
expect(body.error).toBe('Too Many Requests');
expect(body.message).toBe('Rate limit exceeded');
});
test('sets Retry-After header when retryAfter is provided', async () => {
const service = createMockRateLimitService({allowed: false, retryAfter: 60});
const app = new Hono();
app.use('*', rateLimit({service}));
app.get('/test', (c) => c.json({ok: true}));
const response = await app.request('/test');
expect(response.headers.get('Retry-After')).toBe('60');
});
test('includes retryAfter in response body', async () => {
const service = createMockRateLimitService({allowed: false, retryAfter: 30});
const app = new Hono();
app.use('*', rateLimit({service}));
app.get('/test', (c) => c.json({ok: true}));
const response = await app.request('/test');
const body = (await response.json()) as {retryAfter: number};
expect(body.retryAfter).toBe(30);
});
test('calls onLimitExceeded callback when provided', async () => {
const onLimitExceeded = vi.fn();
const service = createMockRateLimitService({allowed: false});
const app = new Hono();
app.use('*', rateLimit({service, onLimitExceeded}));
app.get('/api/test', (c) => c.json({ok: true}));
await app.request('/api/test');
expect(onLimitExceeded).toHaveBeenCalledWith(expect.any(String), '/api/test');
});
});
describe('key generation', () => {
test('uses default key generator based on IP', async () => {
const service = createMockRateLimitService();
const app = new Hono();
app.use('*', rateLimit({service}));
app.get('/test', (c) => c.json({ok: true}));
await app.request('/test', {
headers: {'x-forwarded-for': '192.168.1.100'},
});
expect(service.checkLimit).toHaveBeenCalledWith(
expect.objectContaining({
identifier: '192.168.1.100',
}),
);
});
test('uses custom key generator when provided', async () => {
const keyGenerator = vi.fn().mockReturnValue('custom-key');
const service = createMockRateLimitService();
const app = new Hono();
app.use('*', rateLimit({service, keyGenerator}));
app.get('/test', (c) => c.json({ok: true}));
await app.request('/test');
expect(keyGenerator).toHaveBeenCalled();
expect(service.checkLimit).toHaveBeenCalledWith(
expect.objectContaining({
identifier: 'custom-key',
}),
);
});
test('async key generator is supported', async () => {
const keyGenerator = vi.fn().mockResolvedValue('async-key');
const service = createMockRateLimitService();
const app = new Hono();
app.use('*', rateLimit({service, keyGenerator}));
app.get('/test', (c) => c.json({ok: true}));
await app.request('/test');
expect(service.checkLimit).toHaveBeenCalledWith(
expect.objectContaining({
identifier: 'async-key',
}),
);
});
});
describe('rate limit parameters', () => {
test('passes maxAttempts to service', async () => {
const service = createMockRateLimitService();
const app = new Hono();
app.use('*', rateLimit({service, maxAttempts: 50}));
app.get('/test', (c) => c.json({ok: true}));
await app.request('/test');
expect(service.checkLimit).toHaveBeenCalledWith(
expect.objectContaining({
maxAttempts: 50,
}),
);
});
test('uses default maxAttempts of 100', async () => {
const service = createMockRateLimitService();
const app = new Hono();
app.use('*', rateLimit({service}));
app.get('/test', (c) => c.json({ok: true}));
await app.request('/test');
expect(service.checkLimit).toHaveBeenCalledWith(
expect.objectContaining({
maxAttempts: 100,
}),
);
});
test('passes windowMs to service', async () => {
const service = createMockRateLimitService();
const app = new Hono();
app.use('*', rateLimit({service, windowMs: 30000}));
app.get('/test', (c) => c.json({ok: true}));
await app.request('/test');
expect(service.checkLimit).toHaveBeenCalledWith(
expect.objectContaining({
windowMs: 30000,
}),
);
});
test('uses default windowMs of 60000', async () => {
const service = createMockRateLimitService();
const app = new Hono();
app.use('*', rateLimit({service}));
app.get('/test', (c) => c.json({ok: true}));
await app.request('/test');
expect(service.checkLimit).toHaveBeenCalledWith(
expect.objectContaining({
windowMs: 60000,
}),
);
});
});
describe('allowed requests', () => {
test('allows request and calls next when under limit', async () => {
const service = createMockRateLimitService({allowed: true, remaining: 50});
const app = new Hono();
app.use('*', rateLimit({service}));
app.get('/test', (c) => c.json({ok: true}));
const response = await app.request('/test');
expect(response.status).toBe(200);
const body = (await response.json()) as {ok: boolean};
expect(body.ok).toBe(true);
});
test('sets rate limit headers even for allowed requests', async () => {
const service = createMockRateLimitService({allowed: true, limit: 100, remaining: 75});
const app = new Hono();
app.use('*', rateLimit({service}));
app.get('/test', (c) => c.json({ok: true}));
const response = await app.request('/test');
expect(response.headers.get('X-RateLimit-Limit')).toBe('100');
expect(response.headers.get('X-RateLimit-Remaining')).toBe('75');
});
});
});

View File

@@ -0,0 +1,173 @@
/*
* Copyright (C) 2026 Fluxer Contributors
*
* This file is part of Fluxer.
*
* Fluxer is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* Fluxer is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
*/
import {Headers} from '@fluxer/constants/src/Headers';
import {REQUEST_ID_KEY, requestId} from '@fluxer/hono/src/middleware/RequestId';
import {Hono} from 'hono';
import {describe, expect, test, vi} from 'vitest';
type AppEnv = {
Variables: {
requestId: string;
};
};
describe('RequestId Middleware', () => {
test('generates a request ID when none provided', async () => {
const app = new Hono<AppEnv>();
app.use('*', requestId());
app.get('/test', (c) => {
const id = c.get(REQUEST_ID_KEY);
return c.json({requestId: id});
});
const response = await app.request('/test');
expect(response.status).toBe(200);
const responseId = response.headers.get(Headers.X_REQUEST_ID);
expect(responseId).toBeTruthy();
expect(responseId).toMatch(/^[0-9a-f-]{36}$/);
const body = (await response.json()) as {requestId: string};
expect(body.requestId).toBe(responseId);
});
test('uses existing request ID from header', async () => {
const app = new Hono<AppEnv>();
app.use('*', requestId());
app.get('/test', (c) => {
const id = c.get(REQUEST_ID_KEY);
return c.json({requestId: id});
});
const existingId = 'existing-request-id-12345';
const response = await app.request('/test', {
headers: {
[Headers.X_REQUEST_ID]: existingId,
},
});
expect(response.status).toBe(200);
expect(response.headers.get(Headers.X_REQUEST_ID)).toBe(existingId);
const body = (await response.json()) as {requestId: string};
expect(body.requestId).toBe(existingId);
});
test('uses custom header name', async () => {
const customHeader = 'X-Custom-Request-ID';
const app = new Hono<AppEnv>();
app.use('*', requestId({headerName: customHeader}));
app.get('/test', (c) => {
const id = c.get(REQUEST_ID_KEY);
return c.json({requestId: id});
});
const existingId = 'custom-header-id';
const response = await app.request('/test', {
headers: {
[customHeader]: existingId,
},
});
expect(response.status).toBe(200);
expect(response.headers.get(customHeader)).toBe(existingId);
});
test('uses custom generator function', async () => {
const customGenerator = vi.fn().mockReturnValue('custom-generated-id');
const app = new Hono<AppEnv>();
app.use('*', requestId({generator: customGenerator}));
app.get('/test', (c) => {
const id = c.get(REQUEST_ID_KEY);
return c.json({requestId: id});
});
const response = await app.request('/test');
expect(response.status).toBe(200);
expect(customGenerator).toHaveBeenCalled();
expect(response.headers.get(Headers.X_REQUEST_ID)).toBe('custom-generated-id');
});
test('does not call generator when request ID exists', async () => {
const customGenerator = vi.fn().mockReturnValue('custom-generated-id');
const app = new Hono<AppEnv>();
app.use('*', requestId({generator: customGenerator}));
app.get('/test', (c) => c.json({ok: true}));
await app.request('/test', {
headers: {
[Headers.X_REQUEST_ID]: 'existing-id',
},
});
expect(customGenerator).not.toHaveBeenCalled();
});
test('does not set response header when setResponseHeader is false', async () => {
const app = new Hono<AppEnv>();
app.use('*', requestId({setResponseHeader: false}));
app.get('/test', (c) => c.json({ok: true}));
const response = await app.request('/test');
expect(response.status).toBe(200);
expect(response.headers.get(Headers.X_REQUEST_ID)).toBeNull();
});
test('sets response header by default', async () => {
const app = new Hono<AppEnv>();
app.use('*', requestId());
app.get('/test', (c) => c.json({ok: true}));
const response = await app.request('/test');
expect(response.status).toBe(200);
expect(response.headers.get(Headers.X_REQUEST_ID)).toBeTruthy();
});
test('request ID is available in context after middleware runs', async () => {
const app = new Hono<AppEnv>();
let capturedId: string | undefined;
app.use('*', requestId());
app.get('/test', (c) => {
capturedId = c.get(REQUEST_ID_KEY);
return c.json({ok: true});
});
await app.request('/test');
expect(capturedId).toBeTruthy();
expect(capturedId).toMatch(/^[0-9a-f-]{36}$/);
});
test('different requests get different IDs', async () => {
const app = new Hono<AppEnv>();
const capturedIds: Array<string> = [];
app.use('*', requestId());
app.get('/test', (c) => {
capturedIds.push(c.get(REQUEST_ID_KEY));
return c.json({ok: true});
});
await app.request('/test');
await app.request('/test');
await app.request('/test');
expect(capturedIds).toHaveLength(3);
expect(new Set(capturedIds).size).toBe(3);
});
});

View File

@@ -0,0 +1,275 @@
/*
* Copyright (C) 2026 Fluxer Contributors
*
* This file is part of Fluxer.
*
* Fluxer is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* Fluxer is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
*/
import {requestLogger} from '@fluxer/hono/src/middleware/RequestLogger';
import {Hono} from 'hono';
import {describe, expect, test, vi} from 'vitest';
describe('RequestLogger Middleware', () => {
describe('logging', () => {
test('calls log function with request data', async () => {
const log = vi.fn();
const app = new Hono();
app.use('*', requestLogger({log}));
app.get('/test', (c) => c.json({ok: true}));
await app.request('/test');
expect(log).toHaveBeenCalledTimes(1);
expect(log).toHaveBeenCalledWith(
expect.objectContaining({
method: 'GET',
path: '/test',
status: 200,
}),
);
});
test('includes method in log data', async () => {
const log = vi.fn();
const app = new Hono();
app.use('*', requestLogger({log}));
app.post('/users', (c) => c.json({created: true}));
await app.request('/users', {method: 'POST'});
expect(log).toHaveBeenCalledWith(
expect.objectContaining({
method: 'POST',
}),
);
});
test('includes path in log data', async () => {
const log = vi.fn();
const app = new Hono();
app.use('*', requestLogger({log}));
app.get('/api/users/123', (c) => c.json({ok: true}));
await app.request('/api/users/123');
expect(log).toHaveBeenCalledWith(
expect.objectContaining({
path: '/api/users/123',
}),
);
});
test('includes status code in log data', async () => {
const log = vi.fn();
const app = new Hono();
app.use('*', requestLogger({log}));
app.get('/error', (c) => c.json({error: 'Not found'}, 404));
await app.request('/error');
expect(log).toHaveBeenCalledWith(
expect.objectContaining({
status: 404,
}),
);
});
test('includes durationMs in log data', async () => {
const log = vi.fn();
const app = new Hono();
app.use('*', requestLogger({log}));
app.get('/test', (c) => c.json({ok: true}));
await app.request('/test');
expect(log).toHaveBeenCalledWith(
expect.objectContaining({
durationMs: expect.any(Number),
}),
);
expect(log.mock.calls[0][0].durationMs).toBeGreaterThanOrEqual(0);
});
});
describe('skip option', () => {
test('skips logging for paths in skip array', async () => {
const log = vi.fn();
const app = new Hono();
app.use('*', requestLogger({log, skip: ['/_health']}));
app.get('/_health', (c) => c.json({ok: true}));
await app.request('/_health');
expect(log).not.toHaveBeenCalled();
});
test('logs paths not in skip array', async () => {
const log = vi.fn();
const app = new Hono();
app.use('*', requestLogger({log, skip: ['/_health']}));
app.get('/api/users', (c) => c.json({ok: true}));
await app.request('/api/users');
expect(log).toHaveBeenCalled();
});
test('skips multiple paths', async () => {
const log = vi.fn();
const app = new Hono();
app.use('*', requestLogger({log, skip: ['/_health', '/metrics', '/_health']}));
app.get('/_health', (c) => c.json({ok: true}));
app.get('/metrics', (c) => c.json({ok: true}));
app.get('/_health', (c) => c.json({ok: true}));
await app.request('/_health');
await app.request('/metrics');
await app.request('/_health');
expect(log).not.toHaveBeenCalled();
});
test('uses empty skip array by default', async () => {
const log = vi.fn();
const app = new Hono();
app.use('*', requestLogger({log}));
app.get('/_health', (c) => c.json({ok: true}));
await app.request('/_health');
expect(log).toHaveBeenCalled();
});
});
describe('different HTTP methods', () => {
test('logs GET requests', async () => {
const log = vi.fn();
const app = new Hono();
app.use('*', requestLogger({log}));
app.get('/test', (c) => c.json({ok: true}));
await app.request('/test');
expect(log).toHaveBeenCalledWith(expect.objectContaining({method: 'GET'}));
});
test('logs POST requests', async () => {
const log = vi.fn();
const app = new Hono();
app.use('*', requestLogger({log}));
app.post('/test', (c) => c.json({ok: true}));
await app.request('/test', {method: 'POST'});
expect(log).toHaveBeenCalledWith(expect.objectContaining({method: 'POST'}));
});
test('logs PUT requests', async () => {
const log = vi.fn();
const app = new Hono();
app.use('*', requestLogger({log}));
app.put('/test', (c) => c.json({ok: true}));
await app.request('/test', {method: 'PUT'});
expect(log).toHaveBeenCalledWith(expect.objectContaining({method: 'PUT'}));
});
test('logs DELETE requests', async () => {
const log = vi.fn();
const app = new Hono();
app.use('*', requestLogger({log}));
app.delete('/test', (c) => c.json({ok: true}));
await app.request('/test', {method: 'DELETE'});
expect(log).toHaveBeenCalledWith(expect.objectContaining({method: 'DELETE'}));
});
test('logs PATCH requests', async () => {
const log = vi.fn();
const app = new Hono();
app.use('*', requestLogger({log}));
app.patch('/test', (c) => c.json({ok: true}));
await app.request('/test', {method: 'PATCH'});
expect(log).toHaveBeenCalledWith(expect.objectContaining({method: 'PATCH'}));
});
});
describe('different status codes', () => {
test('logs 200 OK responses', async () => {
const log = vi.fn();
const app = new Hono();
app.use('*', requestLogger({log}));
app.get('/test', (c) => c.json({ok: true}));
await app.request('/test');
expect(log).toHaveBeenCalledWith(expect.objectContaining({status: 200}));
});
test('logs 201 Created responses', async () => {
const log = vi.fn();
const app = new Hono();
app.use('*', requestLogger({log}));
app.post('/test', (c) => c.json({created: true}, 201));
await app.request('/test', {method: 'POST'});
expect(log).toHaveBeenCalledWith(expect.objectContaining({status: 201}));
});
test('logs 400 Bad Request responses', async () => {
const log = vi.fn();
const app = new Hono();
app.use('*', requestLogger({log}));
app.get('/test', (c) => c.json({error: 'Bad Request'}, 400));
await app.request('/test');
expect(log).toHaveBeenCalledWith(expect.objectContaining({status: 400}));
});
test('logs 500 Internal Server Error responses', async () => {
const log = vi.fn();
const app = new Hono();
app.use('*', requestLogger({log}));
app.get('/test', (c) => c.json({error: 'Server Error'}, 500));
await app.request('/test');
expect(log).toHaveBeenCalledWith(expect.objectContaining({status: 500}));
});
});
describe('multiple requests', () => {
test('logs each request separately', async () => {
const log = vi.fn();
const app = new Hono();
app.use('*', requestLogger({log}));
app.get('/first', (c) => c.json({ok: true}));
app.get('/second', (c) => c.json({ok: true}));
await app.request('/first');
await app.request('/second');
expect(log).toHaveBeenCalledTimes(2);
expect(log).toHaveBeenNthCalledWith(1, expect.objectContaining({path: '/first'}));
expect(log).toHaveBeenNthCalledWith(2, expect.objectContaining({path: '/second'}));
});
});
});

View File

@@ -0,0 +1,301 @@
/*
* Copyright (C) 2026 Fluxer Contributors
*
* This file is part of Fluxer.
*
* Fluxer is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* Fluxer is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
*/
import {tracing} from '@fluxer/hono/src/middleware/Tracing';
import {Hono} from 'hono';
import {describe, expect, test, vi} from 'vitest';
describe('Tracing Middleware', () => {
describe('enabled option', () => {
test('skips tracing when enabled is false', async () => {
const withSpan = vi.fn();
const setSpanAttributes = vi.fn();
const app = new Hono();
app.use('*', tracing({enabled: false, withSpan, setSpanAttributes}));
app.get('/test', (c) => c.json({ok: true}));
const response = await app.request('/test');
expect(response.status).toBe(200);
expect(withSpan).not.toHaveBeenCalled();
expect(setSpanAttributes).not.toHaveBeenCalled();
});
test('processes tracing when enabled is true', async () => {
const withSpan = vi.fn().mockImplementation(async (_name, fn) => fn());
const setSpanAttributes = vi.fn();
const app = new Hono();
app.use('*', tracing({enabled: true, withSpan, setSpanAttributes}));
app.get('/test', (c) => c.json({ok: true}));
const response = await app.request('/test');
expect(response.status).toBe(200);
expect(withSpan).toHaveBeenCalled();
});
});
describe('skip paths', () => {
test('skips default health paths', async () => {
const withSpan = vi.fn();
const setSpanAttributes = vi.fn();
const app = new Hono();
app.use('*', tracing({withSpan, setSpanAttributes}));
app.get('/_health', (c) => c.json({ok: true}));
app.get('/metrics', (c) => c.json({ok: true}));
await app.request('/_health');
await app.request('/metrics');
expect(withSpan).not.toHaveBeenCalled();
});
test('skips custom paths', async () => {
const withSpan = vi.fn();
const setSpanAttributes = vi.fn();
const app = new Hono();
app.use('*', tracing({skipPaths: ['/custom-skip'], withSpan, setSpanAttributes}));
app.get('/custom-skip', (c) => c.json({ok: true}));
app.get('/not-skipped', (c) => c.json({ok: true}));
await app.request('/custom-skip');
expect(withSpan).not.toHaveBeenCalled();
});
test('skips paths with wildcard patterns', async () => {
const withSpan = vi.fn();
const setSpanAttributes = vi.fn();
const app = new Hono();
app.use('*', tracing({skipPaths: ['/admin/*'], withSpan, setSpanAttributes}));
app.get('/admin/users', (c) => c.json({ok: true}));
app.get('/admin/settings', (c) => c.json({ok: true}));
app.get('/api/users', (c) => c.json({ok: true}));
await app.request('/admin/users');
await app.request('/admin/settings');
expect(withSpan).not.toHaveBeenCalled();
});
test('traces non-skipped paths', async () => {
const withSpan = vi.fn().mockImplementation(async (_name, fn) => fn());
const setSpanAttributes = vi.fn();
const app = new Hono();
app.use('*', tracing({skipPaths: ['/skip-this'], withSpan, setSpanAttributes}));
app.get('/trace-this', (c) => c.json({ok: true}));
await app.request('/trace-this');
expect(withSpan).toHaveBeenCalled();
});
});
describe('span creation', () => {
test('creates span with correct name format', async () => {
const withSpan = vi.fn().mockImplementation(async (_name, fn) => fn());
const setSpanAttributes = vi.fn();
const app = new Hono();
app.use('*', tracing({withSpan, setSpanAttributes}));
app.get('/api/users', (c) => c.json({ok: true}));
await app.request('/api/users');
expect(withSpan).toHaveBeenCalledWith('http.request GET /api/users', expect.any(Function));
});
test('creates span with POST method', async () => {
const withSpan = vi.fn().mockImplementation(async (_name, fn) => fn());
const setSpanAttributes = vi.fn();
const app = new Hono();
app.use('*', tracing({withSpan, setSpanAttributes}));
app.post('/api/users', (c) => c.json({ok: true}));
await app.request('/api/users', {method: 'POST'});
expect(withSpan).toHaveBeenCalledWith('http.request POST /api/users', expect.any(Function));
});
});
describe('span attributes', () => {
test('sets initial request attributes', async () => {
const withSpan = vi.fn().mockImplementation(async (_name, fn) => fn());
const setSpanAttributes = vi.fn();
const app = new Hono();
app.use('*', tracing({serviceName: 'test-service', withSpan, setSpanAttributes}));
app.get('/api/test', (c) => c.json({ok: true}));
await app.request('http://localhost/api/test', {
headers: {
host: 'localhost',
'user-agent': 'TestAgent/1.0',
},
});
expect(setSpanAttributes).toHaveBeenCalledWith(
expect.objectContaining({
'http.method': 'GET',
'http.target': '/api/test',
'http.host': 'localhost',
'http.user_agent': 'TestAgent/1.0',
'service.name': 'test-service',
}),
);
});
test('sets response attributes after handler completes', async () => {
const withSpan = vi.fn().mockImplementation(async (_name, fn) => fn());
const setSpanAttributes = vi.fn();
const app = new Hono();
app.use('*', tracing({withSpan, setSpanAttributes}));
app.get('/api/test', (c) => c.json({status: 'ok'}));
await app.request('/api/test');
expect(setSpanAttributes).toHaveBeenCalledWith(
expect.objectContaining({
'http.status_code': 200,
}),
);
});
test('sets duration attribute', async () => {
const withSpan = vi.fn().mockImplementation(async (_name, fn) => fn());
const setSpanAttributes = vi.fn();
const app = new Hono();
app.use('*', tracing({withSpan, setSpanAttributes}));
app.get('/api/test', (c) => c.json({ok: true}));
await app.request('/api/test');
const callWithDuration = setSpanAttributes.mock.calls.find((call) => Object.hasOwn(call[0], 'http.duration_ms'));
expect(callWithDuration).toBeTruthy();
expect(callWithDuration![0]['http.duration_ms']).toBeGreaterThanOrEqual(0);
});
test('uses x-forwarded-proto header for scheme', async () => {
const withSpan = vi.fn().mockImplementation(async (_name, fn) => fn());
const setSpanAttributes = vi.fn();
const app = new Hono();
app.use('*', tracing({withSpan, setSpanAttributes}));
app.get('/api/test', (c) => c.json({ok: true}));
await app.request('/api/test', {
headers: {'x-forwarded-proto': 'https'},
});
expect(setSpanAttributes).toHaveBeenCalledWith(
expect.objectContaining({
'http.scheme': 'https',
}),
);
});
test('defaults to http scheme when x-forwarded-proto not present', async () => {
const withSpan = vi.fn().mockImplementation(async (_name, fn) => fn());
const setSpanAttributes = vi.fn();
const app = new Hono();
app.use('*', tracing({withSpan, setSpanAttributes}));
app.get('/api/test', (c) => c.json({ok: true}));
await app.request('/api/test');
expect(setSpanAttributes).toHaveBeenCalledWith(
expect.objectContaining({
'http.scheme': 'http',
}),
);
});
});
describe('missing tracing functions', () => {
test('skips tracing when withSpan is not provided', async () => {
const app = new Hono();
app.use('*', tracing({setSpanAttributes: vi.fn()}));
app.get('/test', (c) => c.json({ok: true}));
const response = await app.request('/test');
expect(response.status).toBe(200);
});
test('skips tracing when setSpanAttributes is not provided', async () => {
const app = new Hono();
app.use('*', tracing({withSpan: vi.fn()}));
app.get('/test', (c) => c.json({ok: true}));
const response = await app.request('/test');
expect(response.status).toBe(200);
});
test('processes request normally when both are missing', async () => {
const app = new Hono();
app.use('*', tracing());
app.get('/test', (c) => c.json({ok: true}));
const response = await app.request('/test');
expect(response.status).toBe(200);
const body = (await response.json()) as {ok: boolean};
expect(body.ok).toBe(true);
});
});
describe('service name', () => {
test('uses custom service name', async () => {
const withSpan = vi.fn().mockImplementation(async (_name, fn) => fn());
const setSpanAttributes = vi.fn();
const app = new Hono();
app.use('*', tracing({serviceName: 'my-custom-service', withSpan, setSpanAttributes}));
app.get('/test', (c) => c.json({ok: true}));
await app.request('/test');
expect(setSpanAttributes).toHaveBeenCalledWith(
expect.objectContaining({
'service.name': 'my-custom-service',
}),
);
});
test('uses default service name when not specified', async () => {
const withSpan = vi.fn().mockImplementation(async (_name, fn) => fn());
const setSpanAttributes = vi.fn();
const app = new Hono();
app.use('*', tracing({withSpan, setSpanAttributes}));
app.get('/test', (c) => c.json({ok: true}));
await app.request('/test');
expect(setSpanAttributes).toHaveBeenCalledWith(
expect.objectContaining({
'service.name': 'hono-service',
}),
);
});
});
});

View File

@@ -0,0 +1,50 @@
/*
* Copyright (C) 2026 Fluxer Contributors
*
* This file is part of Fluxer.
*
* Fluxer is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* Fluxer is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
*/
export function matchesPathPattern(path: string, pattern: string): boolean {
if (pattern.endsWith('*')) {
return path.startsWith(pattern.slice(0, -1));
}
return path === pattern;
}
export function matchesAnyPathPattern(path: string, patterns: Array<string>): boolean {
for (const pattern of patterns) {
if (matchesPathPattern(path, pattern)) {
return true;
}
}
return false;
}
export function matchesExactOrNestedPath(path: string, prefix: string): boolean {
return path === prefix || path.startsWith(`${prefix}/`);
}
export function matchesAnyExactOrNestedPath(path: string, prefixes: Array<string>): boolean {
for (const prefix of prefixes) {
if (matchesExactOrNestedPath(path, prefix)) {
return true;
}
}
return false;
}

View File

@@ -0,0 +1,170 @@
/*
* Copyright (C) 2026 Fluxer Contributors
*
* This file is part of Fluxer.
*
* Fluxer is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* Fluxer is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
*/
import {createHmac, randomBytes, timingSafeEqual} from 'node:crypto';
import {CSRF_COOKIE_NAME, CSRF_FORM_FIELD, CSRF_HEADER_NAME} from '@fluxer/constants/src/Cookies';
import type {Context, MiddlewareHandler} from 'hono';
import {getCookie, setCookie} from 'hono/cookie';
const TOKEN_LENGTH = 32;
const TOKEN_MAX_AGE_SECONDS = 60 * 60 * 24;
export interface CreateCsrfProtectionOptions {
secretKeyBase: string;
secureCookie: boolean;
cookiePath?: string;
cookieSameSite?: 'Strict' | 'Lax' | 'None';
ignoredPathSuffixes?: Array<string>;
}
export interface CsrfProtection {
middleware: MiddlewareHandler;
getToken: (c: Context) => string;
verifySignedToken: (signedToken: string) => string | null;
}
export function createCsrfProtection(options: CreateCsrfProtectionOptions): CsrfProtection {
const secretKey = Buffer.from(options.secretKeyBase);
const cookiePath = options.cookiePath ?? '/';
const cookieSameSite = options.cookieSameSite ?? 'Strict';
const ignoredPathSuffixes = options.ignoredPathSuffixes ?? [];
function signToken(token: string): string {
const signature = createHmac('sha256', secretKey).update(token).digest('base64url');
return `${token}.${signature}`;
}
function verifySignedToken(signedToken: string): string | null {
const parts = signedToken.split('.');
if (parts.length !== 2) {
return null;
}
const [token, providedSignature] = parts;
if (!token || !providedSignature) {
return null;
}
const expectedSignature = createHmac('sha256', secretKey).update(token).digest('base64url');
try {
const providedBuffer = Buffer.from(providedSignature, 'base64url');
const expectedBuffer = Buffer.from(expectedSignature, 'base64url');
if (providedBuffer.length !== expectedBuffer.length) {
return null;
}
if (timingSafeEqual(providedBuffer, expectedBuffer)) {
return token;
}
} catch {
return null;
}
return null;
}
function getToken(c: Context): string {
const existingCookie = getCookie(c, CSRF_COOKIE_NAME);
if (existingCookie && verifySignedToken(existingCookie)) {
return existingCookie;
}
const token = randomBytes(TOKEN_LENGTH).toString('base64url');
const signedToken = signToken(token);
setCookie(c, CSRF_COOKIE_NAME, signedToken, {
httpOnly: true,
secure: options.secureCookie,
sameSite: cookieSameSite,
maxAge: TOKEN_MAX_AGE_SECONDS,
path: cookiePath,
});
return signedToken;
}
const middleware: MiddlewareHandler = async (c, next) => {
const method = c.req.method.toUpperCase();
if (!['POST', 'PUT', 'PATCH', 'DELETE'].includes(method)) {
await next();
return undefined;
}
const path = c.req.path;
if (ignoredPathSuffixes.some((suffix) => path.endsWith(suffix))) {
await next();
return undefined;
}
const cookieToken = getCookie(c, CSRF_COOKIE_NAME);
if (!cookieToken) {
return c.text('CSRF token missing', 403);
}
const verifiedCookieToken = verifySignedToken(cookieToken);
if (!verifiedCookieToken) {
return c.text('CSRF token invalid', 403);
}
const submittedToken = await extractSubmittedToken(c);
if (!submittedToken) {
return c.text('CSRF token not provided', 403);
}
const verifiedSubmittedToken = verifySignedToken(submittedToken);
if (!verifiedSubmittedToken) {
return c.text('CSRF token invalid', 403);
}
if (verifiedCookieToken !== verifiedSubmittedToken) {
return c.text('CSRF token mismatch', 403);
}
await next();
return undefined;
};
return {
middleware,
getToken,
verifySignedToken,
};
}
async function extractSubmittedToken(c: Context): Promise<string | null> {
const headerToken = c.req.header(CSRF_HEADER_NAME);
if (headerToken) {
return headerToken;
}
const contentType = c.req.header('content-type') ?? '';
const isFormRequest =
contentType.includes('application/x-www-form-urlencoded') || contentType.includes('multipart/form-data');
if (!isFormRequest) {
return null;
}
try {
const body = await c.req.parseBody();
const formToken = body[CSRF_FORM_FIELD];
return typeof formToken === 'string' ? formToken : null;
} catch {
return null;
}
}

View File

@@ -0,0 +1,139 @@
/*
* Copyright (C) 2026 Fluxer Contributors
*
* This file is part of Fluxer.
*
* Fluxer is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* Fluxer is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
*/
import {isIP} from 'node:net';
export interface ValidateOutboundEndpointOptions {
name: string;
allowHttp: boolean;
allowLocalhost: boolean;
allowPrivateIpLiterals: boolean;
}
export function validateOutboundEndpointUrl(rawEndpoint: string, options: ValidateOutboundEndpointOptions): URL {
let endpointUrl: URL;
try {
endpointUrl = new URL(rawEndpoint);
} catch {
throw new Error(`${options.name} must be a valid URL`);
}
if (endpointUrl.protocol !== 'http:' && endpointUrl.protocol !== 'https:') {
throw new Error(`${options.name} must use http or https`);
}
if (endpointUrl.protocol === 'http:' && !options.allowHttp) {
throw new Error(`${options.name} must use https`);
}
if (endpointUrl.username || endpointUrl.password) {
throw new Error(`${options.name} must not include URL credentials`);
}
if (endpointUrl.search || endpointUrl.hash) {
throw new Error(`${options.name} must not include query string or fragment`);
}
const hostname = endpointUrl.hostname.toLowerCase();
if (!hostname) {
throw new Error(`${options.name} must include a hostname`);
}
if (!options.allowLocalhost && isLocalhostHostname(hostname)) {
throw new Error(`${options.name} cannot use localhost`);
}
if (!options.allowPrivateIpLiterals && isPrivateOrSpecialIpLiteral(hostname)) {
throw new Error(`${options.name} cannot use private or special IP literals`);
}
return endpointUrl;
}
export function normalizeEndpointOrigin(endpointUrl: URL): string {
const pathname = endpointUrl.pathname.endsWith('/') ? endpointUrl.pathname.slice(0, -1) : endpointUrl.pathname;
const normalisedPath = pathname === '/' ? '' : pathname;
return `${endpointUrl.protocol}//${endpointUrl.host}${normalisedPath}`;
}
export function buildEndpointUrl(endpointUrl: URL, path: string): string {
const trimmedPath = path.trim();
if (!trimmedPath) {
return normalizeEndpointOrigin(endpointUrl);
}
const lowercasePath = trimmedPath.toLowerCase();
if (lowercasePath.startsWith('http://') || lowercasePath.startsWith('https://') || trimmedPath.startsWith('//')) {
throw new Error('Outbound path must be relative');
}
const prefixedPath = trimmedPath.startsWith('/') ? trimmedPath : `/${trimmedPath}`;
return `${normalizeEndpointOrigin(endpointUrl)}${prefixedPath}`;
}
function isLocalhostHostname(hostname: string): boolean {
return hostname === 'localhost' || hostname.endsWith('.localhost');
}
function isPrivateOrSpecialIpLiteral(hostname: string): boolean {
const ipVersion = isIP(hostname);
if (!ipVersion) {
return false;
}
if (ipVersion === 4) {
return isPrivateOrSpecialIPv4(hostname);
}
return isPrivateOrSpecialIPv6(hostname);
}
function isPrivateOrSpecialIPv4(hostname: string): boolean {
const octets = hostname.split('.').map((part) => Number(part));
if (octets.length !== 4 || octets.some((octet) => !Number.isInteger(octet) || octet < 0 || octet > 255)) {
return true;
}
const [first, second] = octets;
if (first === 0 || first === 10 || first === 127) return true;
if (first === 169 && second === 254) return true;
if (first === 172 && second >= 16 && second <= 31) return true;
if (first === 192 && second === 168) return true;
if (first === 100 && second >= 64 && second <= 127) return true;
if (first === 198 && (second === 18 || second === 19)) return true;
if (first >= 224) return true;
return false;
}
function isPrivateOrSpecialIPv6(hostname: string): boolean {
const lower = hostname.toLowerCase();
if (lower === '::' || lower === '::1') {
return true;
}
if (lower.startsWith('::ffff:')) {
const mapped = lower.slice('::ffff:'.length);
return isPrivateOrSpecialIPv4(mapped);
}
if (lower.startsWith('fe80:')) return true;
if (lower.startsWith('fc') || lower.startsWith('fd')) return true;
if (lower.startsWith('ff')) return true;
return false;
}

View File

@@ -0,0 +1,116 @@
/*
* Copyright (C) 2026 Fluxer Contributors
*
* This file is part of Fluxer.
*
* Fluxer is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* Fluxer is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
*/
import {CSRF_HEADER_NAME} from '@fluxer/constants/src/Cookies';
import {createCsrfProtection} from '@fluxer/hono/src/security/CsrfProtection';
import {Hono} from 'hono';
import {describe, expect, test} from 'vitest';
describe('CsrfProtection', () => {
test('accepts form token for mutating request', async () => {
const app = new Hono();
const protection = createCsrfProtection({
secretKeyBase: 'test-secret',
secureCookie: false,
});
app.use('*', protection.middleware);
app.get('/form', (c) => c.json({token: protection.getToken(c)}));
app.post('/submit', async (c) => {
const body = await c.req.parseBody();
return c.text(typeof body['locale'] === 'string' ? body['locale'] : 'missing');
});
const formResponse = await app.request('/form');
expect(formResponse.status).toBe(200);
const setCookie = formResponse.headers.get('set-cookie');
expect(setCookie).toBeTruthy();
const cookieHeader = setCookie?.split(';')[0] ?? '';
const body = (await formResponse.json()) as {token: string};
const submitResponse = await app.request('/submit', {
method: 'POST',
headers: {
'content-type': 'application/x-www-form-urlencoded',
cookie: cookieHeader,
},
body: `_csrf=${encodeURIComponent(body.token)}&locale=en-US`,
});
expect(submitResponse.status).toBe(200);
expect(await submitResponse.text()).toBe('en-US');
});
test('rejects mutating request without submitted token', async () => {
const app = new Hono();
const protection = createCsrfProtection({
secretKeyBase: 'test-secret',
secureCookie: false,
});
app.use('*', protection.middleware);
app.get('/form', (c) => c.json({token: protection.getToken(c)}));
app.post('/submit', (c) => c.text('ok'));
const formResponse = await app.request('/form');
const setCookie = formResponse.headers.get('set-cookie') ?? '';
const cookieHeader = setCookie.split(';')[0] ?? '';
const submitResponse = await app.request('/submit', {
method: 'POST',
headers: {
cookie: cookieHeader,
},
body: JSON.stringify({x: 1}),
});
expect(submitResponse.status).toBe(403);
});
test('accepts header token for json requests', async () => {
const app = new Hono();
const protection = createCsrfProtection({
secretKeyBase: 'test-secret',
secureCookie: false,
});
app.use('*', protection.middleware);
app.get('/form', (c) => c.json({token: protection.getToken(c)}));
app.post('/submit', (c) => c.text('ok'));
const formResponse = await app.request('/form');
const setCookie = formResponse.headers.get('set-cookie') ?? '';
const cookieHeader = setCookie.split(';')[0] ?? '';
const body = (await formResponse.json()) as {token: string};
const submitResponse = await app.request('/submit', {
method: 'POST',
headers: {
'content-type': 'application/json',
cookie: cookieHeader,
[CSRF_HEADER_NAME]: body.token,
},
body: JSON.stringify({x: 1}),
});
expect(submitResponse.status).toBe(200);
});
});

View File

@@ -0,0 +1,67 @@
/*
* Copyright (C) 2026 Fluxer Contributors
*
* This file is part of Fluxer.
*
* Fluxer is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* Fluxer is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
*/
import {buildEndpointUrl, validateOutboundEndpointUrl} from '@fluxer/hono/src/security/OutboundEndpoint';
import {describe, expect, test} from 'vitest';
describe('OutboundEndpoint', () => {
test('validates and normalises a safe endpoint', () => {
const endpoint = validateOutboundEndpointUrl('https://api.example.com/v1', {
name: 'test.endpoint',
allowHttp: false,
allowLocalhost: false,
allowPrivateIpLiterals: false,
});
expect(buildEndpointUrl(endpoint, '/users/@me')).toBe('https://api.example.com/v1/users/@me');
});
test('rejects localhost when not allowed', () => {
expect(() =>
validateOutboundEndpointUrl('http://localhost:8088', {
name: 'test.endpoint',
allowHttp: true,
allowLocalhost: false,
allowPrivateIpLiterals: true,
}),
).toThrow('cannot use localhost');
});
test('rejects private IP literals when not allowed', () => {
expect(() =>
validateOutboundEndpointUrl('http://192.168.1.8:8080', {
name: 'test.endpoint',
allowHttp: true,
allowLocalhost: true,
allowPrivateIpLiterals: false,
}),
).toThrow('private or special IP literals');
});
test('rejects absolute outbound paths', () => {
const endpoint = validateOutboundEndpointUrl('https://api.example.com', {
name: 'test.endpoint',
allowHttp: false,
allowLocalhost: false,
allowPrivateIpLiterals: false,
});
expect(() => buildEndpointUrl(endpoint, 'https://evil.example.com')).toThrow('Outbound path must be relative');
});
});

View File

@@ -0,0 +1,5 @@
{
"extends": "../../tsconfigs/package.json",
"compilerOptions": {},
"include": ["src/**/*"]
}

View File

@@ -0,0 +1,45 @@
/*
* Copyright (C) 2026 Fluxer Contributors
*
* This file is part of Fluxer.
*
* Fluxer is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* Fluxer is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
*/
import tsconfigPaths from 'vite-tsconfig-paths';
import {defineConfig} from 'vitest/config';
export default defineConfig({
root: process.cwd(),
plugins: [tsconfigPaths()],
cacheDir: './node_modules/.vitest',
test: {
globals: true,
environment: 'node',
pool: 'threads',
fileParallelism: true,
maxConcurrency: 4,
testTimeout: 10000,
hookTimeout: 5000,
isolate: false,
reporters: ['default', 'json'],
outputFile: './test-results.json',
coverage: {
provider: 'v8',
reporter: ['text', 'text-summary', 'json', 'html'],
reportsDirectory: './coverage',
exclude: ['**/node_modules/tests/test*.test.tsx', '**/*.test.ts'],
},
},
});