refactor progress
This commit is contained in:
28
packages/hono/package.json
Normal file
28
packages/hono/package.json
Normal 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:"
|
||||
}
|
||||
}
|
||||
51
packages/hono/src/Flash.tsx
Normal file
51
packages/hono/src/Flash.tsx
Normal 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;
|
||||
}
|
||||
}
|
||||
148
packages/hono/src/Server.tsx
Normal file
148
packages/hono/src/Server.tsx
Normal 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'));
|
||||
}
|
||||
98
packages/hono/src/Session.tsx
Normal file
98
packages/hono/src/Session.tsx
Normal 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;
|
||||
}
|
||||
}
|
||||
57
packages/hono/src/middleware/CacheHeaders.tsx
Normal file
57
packages/hono/src/middleware/CacheHeaders.tsx
Normal 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);
|
||||
};
|
||||
}
|
||||
87
packages/hono/src/middleware/Cors.tsx
Normal file
87
packages/hono/src/middleware/Cors.tsx
Normal 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;
|
||||
};
|
||||
}
|
||||
52
packages/hono/src/middleware/ErrorHandler.tsx
Normal file
52
packages/hono/src/middleware/ErrorHandler.tsx
Normal 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,
|
||||
});
|
||||
}
|
||||
70
packages/hono/src/middleware/InternalAuth.tsx
Normal file
70
packages/hono/src/middleware/InternalAuth.tsx
Normal 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();
|
||||
};
|
||||
}
|
||||
94
packages/hono/src/middleware/Metrics.tsx
Normal file
94
packages/hono/src/middleware/Metrics.tsx
Normal 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,
|
||||
});
|
||||
}
|
||||
};
|
||||
}
|
||||
156
packages/hono/src/middleware/MiddlewareStack.tsx
Normal file
156
packages/hono/src/middleware/MiddlewareStack.tsx
Normal 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(),
|
||||
}),
|
||||
);
|
||||
};
|
||||
}
|
||||
118
packages/hono/src/middleware/RateLimit.tsx
Normal file
118
packages/hono/src/middleware/RateLimit.tsx
Normal 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;
|
||||
};
|
||||
}
|
||||
48
packages/hono/src/middleware/RequestId.tsx
Normal file
48
packages/hono/src/middleware/RequestId.tsx
Normal 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);
|
||||
}
|
||||
};
|
||||
}
|
||||
49
packages/hono/src/middleware/RequestLogger.tsx
Normal file
49
packages/hono/src/middleware/RequestLogger.tsx
Normal 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});
|
||||
};
|
||||
}
|
||||
81
packages/hono/src/middleware/TelemetryAdapters.tsx
Normal file
81
packages/hono/src/middleware/TelemetryAdapters.tsx
Normal 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),
|
||||
};
|
||||
}
|
||||
86
packages/hono/src/middleware/Tracing.tsx
Normal file
86
packages/hono/src/middleware/Tracing.tsx
Normal 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);
|
||||
}
|
||||
}
|
||||
});
|
||||
};
|
||||
}
|
||||
277
packages/hono/src/middleware/tests/CacheHeaders.test.tsx
Normal file
277
packages/hono/src/middleware/tests/CacheHeaders.test.tsx
Normal 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');
|
||||
});
|
||||
});
|
||||
});
|
||||
281
packages/hono/src/middleware/tests/Cors.test.tsx
Normal file
281
packages/hono/src/middleware/tests/Cors.test.tsx
Normal 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('*');
|
||||
});
|
||||
});
|
||||
});
|
||||
248
packages/hono/src/middleware/tests/ErrorHandler.test.tsx
Normal file
248
packages/hono/src/middleware/tests/ErrorHandler.test.tsx
Normal 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);
|
||||
});
|
||||
});
|
||||
});
|
||||
310
packages/hono/src/middleware/tests/InternalAuth.test.tsx
Normal file
310
packages/hono/src/middleware/tests/InternalAuth.test.tsx
Normal 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);
|
||||
});
|
||||
});
|
||||
});
|
||||
363
packages/hono/src/middleware/tests/Metrics.test.tsx
Normal file
363
packages/hono/src/middleware/tests/Metrics.test.tsx
Normal 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);
|
||||
});
|
||||
});
|
||||
});
|
||||
441
packages/hono/src/middleware/tests/MiddlewareStack.test.tsx
Normal file
441
packages/hono/src/middleware/tests/MiddlewareStack.test.tsx
Normal 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();
|
||||
});
|
||||
});
|
||||
352
packages/hono/src/middleware/tests/RateLimit.test.tsx
Normal file
352
packages/hono/src/middleware/tests/RateLimit.test.tsx
Normal 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');
|
||||
});
|
||||
});
|
||||
});
|
||||
173
packages/hono/src/middleware/tests/RequestId.test.tsx
Normal file
173
packages/hono/src/middleware/tests/RequestId.test.tsx
Normal 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);
|
||||
});
|
||||
});
|
||||
275
packages/hono/src/middleware/tests/RequestLogger.test.tsx
Normal file
275
packages/hono/src/middleware/tests/RequestLogger.test.tsx
Normal 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'}));
|
||||
});
|
||||
});
|
||||
});
|
||||
301
packages/hono/src/middleware/tests/Tracing.test.tsx
Normal file
301
packages/hono/src/middleware/tests/Tracing.test.tsx
Normal 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',
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
50
packages/hono/src/middleware/utils/PathMatchers.tsx
Normal file
50
packages/hono/src/middleware/utils/PathMatchers.tsx
Normal 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;
|
||||
}
|
||||
170
packages/hono/src/security/CsrfProtection.tsx
Normal file
170
packages/hono/src/security/CsrfProtection.tsx
Normal 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;
|
||||
}
|
||||
}
|
||||
139
packages/hono/src/security/OutboundEndpoint.tsx
Normal file
139
packages/hono/src/security/OutboundEndpoint.tsx
Normal 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;
|
||||
}
|
||||
116
packages/hono/src/security/tests/CsrfProtection.test.tsx
Normal file
116
packages/hono/src/security/tests/CsrfProtection.test.tsx
Normal 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);
|
||||
});
|
||||
});
|
||||
67
packages/hono/src/security/tests/OutboundEndpoint.test.tsx
Normal file
67
packages/hono/src/security/tests/OutboundEndpoint.test.tsx
Normal 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');
|
||||
});
|
||||
});
|
||||
5
packages/hono/tsconfig.json
Normal file
5
packages/hono/tsconfig.json
Normal file
@@ -0,0 +1,5 @@
|
||||
{
|
||||
"extends": "../../tsconfigs/package.json",
|
||||
"compilerOptions": {},
|
||||
"include": ["src/**/*"]
|
||||
}
|
||||
45
packages/hono/vitest.config.ts
Normal file
45
packages/hono/vitest.config.ts
Normal 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'],
|
||||
},
|
||||
},
|
||||
});
|
||||
Reference in New Issue
Block a user