@arcraz/common
v1.6.0
Published
Common utilities for the arcraz ecosystem
Readme
@arcraz/common
A TypeScript utilities library for the arcraz ecosystem, providing shared functionality across microservices.
Complete overhaul with factory pattern, Zod validation, and ESM-only build.
Requirements
- Node.js 24+ (ESM-only)
- Yarn package manager
Installation
yarn add @arcraz/commonFeatures
- Factory Pattern - No singletons, full control over instances
- Zod Config Validation - Type-safe configuration with runtime validation
- Subpath Exports - Tree-shaking support for smaller bundles
- Namespace Isolation - Redis keys and RabbitMQ queues are automatically namespaced by
NODE_ENV - Native Drivers - Direct
pgPool andamqplib(no wrappers) - Error Hierarchy - Structured
AppErrorbase class with HTTP subclasses and type guard - Redis Rate Limiter - Fixed-window rate limiting with atomic Redis operations
- Enhanced Repository - Soft-delete, sort-validated pagination, and
findByIdout of the box
Quick Start
Configuration
import { loadEnv, getEnv, requireEnv } from '@arcraz/common/config';
import { DatabaseConfigSchema, RedisConfigSchema } from '@arcraz/common/config';
// Load .env file
loadEnv();
// Get environment variables
const nodeEnv = getEnv('NODE_ENV', 'development');
const dbHost = requireEnv('DATABASE_HOST'); // Throws if not set
// Validate configuration with Zod
const dbConfig = DatabaseConfigSchema.parse({
name: 'main',
host: process.env.DB_HOST,
port: parseInt(process.env.DB_PORT || '5432'),
database: process.env.DB_NAME,
user: process.env.DB_USER,
password: process.env.DB_PASSWORD,
});Database (PostgreSQL)
import { createDatabasePool } from '@arcraz/common/database';
import { BaseRepository, PagedResponseHelper, ConverterHelper } from '@arcraz/common/database';
// Create a connection pool
const pool = createDatabasePool({
name: 'main',
host: 'localhost',
port: 5432,
database: 'myapp',
user: 'api',
password: 'secret',
});
// Direct query
const result = await pool.query('SELECT * FROM users WHERE id = $1', [userId]);
// Using BaseRepository
class UserRepository extends BaseRepository {
async getById(id: string) {
return this.queryOne<User>(
'SELECT * FROM users WHERE id = $1',
[id]
);
}
async getPaged(page: number, pageSize: number) {
const pager = new PagedResponseHelper(page, pageSize);
const results = await this.query<User>(
`SELECT *, COUNT(*) OVER() as full_count
FROM users
LIMIT $1 OFFSET $2`,
[pager.limit, pager.offset]
);
return pager.result(results);
}
}
const userRepo = new UserRepository(pool);
const user = await userRepo.getById('123');
// Cleanup
await pool.end();Enhanced Repository
import { createDatabasePool, EnhancedRepository } from '@arcraz/common/database';
import type { EnhancedRepositoryConfig } from '@arcraz/common/database';
interface User {
id: number;
name: string;
email: string;
isActive: boolean;
}
class UserRepository extends EnhancedRepository<User> {
constructor(db: DatabasePool, readOnlyDb?: DatabasePool) {
super(db, {
table: 'users',
primaryKey: 'id',
softDelete: true,
sort: {
allowedColumns: ['name', 'email', 'created_at'],
defaultColumn: 'created_at',
defaultDirection: 'DESC',
},
}, readOnlyDb);
}
}
const pool = createDatabasePool({ /* ... */ });
const userRepo = new UserRepository(pool);
// Find by primary key (respects soft-delete)
const user = await userRepo.findById(123);
// Paginated + sorted (sort column validated against allowlist)
const page = await userRepo.findAll({
page: 1,
pageSize: 20,
sortBy: 'name',
sortDir: 'ASC',
});
// { pageNumber: 1, pageCount: 5, totalCount: 100, results: [...] }
// Soft-delete (sets is_active = false)
await userRepo.deleteById(123);
// Restore soft-deleted row
await userRepo.restoreById(123);
// Invalid sort columns throw (SQL injection prevention)
await userRepo.findAll({ sortBy: 'DROP TABLE users;--' }); // throws ErrorRedis
import { createRedisClient, getNamespacedKey } from '@arcraz/common/redis';
// Create client
const redis = createRedisClient({
host: 'localhost',
port: 6379,
});
// Keys are automatically namespaced by NODE_ENV
// With NODE_ENV=production: "{production}:user:123"
const key = getNamespacedKey('user:123');
await redis.set(key, JSON.stringify({ name: 'John' }), 'EX', 3600);
const data = await redis.get(key);
// Cleanup
await redis.quit();Redis Rate Limiter
import { createRedisClient, createRateLimiter } from '@arcraz/common/redis';
const redis = createRedisClient({ host: 'localhost' });
// Create a rate limiter (fixed-window algorithm)
const apiLimiter = createRateLimiter(redis, {
prefix: 'api',
maxRequests: 100,
windowSizeSeconds: 60,
});
// Check rate limit for a user/IP
const result = await apiLimiter.checkLimit('user-123');
// {
// allowed: true,
// current: 1,
// limit: 100,
// remaining: 99,
// resetAtMs: 1735689660000
// }
if (!result.allowed) {
throw new RateLimitError('Too many requests');
}
// Different limiters for different endpoints
const loginLimiter = createRateLimiter(redis, {
prefix: 'login',
maxRequests: 5,
windowSizeSeconds: 300, // 5 minutes
});
// Reset a specific identifier's limit
await apiLimiter.resetLimit('user-123');RabbitMQ
import {
createRabbitMQConnection,
publish,
consume,
getNamespacedQueue
} from '@arcraz/common/rabbitmq';
// Create connection with reconnection options
const conn = await createRabbitMQConnection({
host: 'localhost',
port: 5672,
username: 'guest',
password: 'guest',
appName: 'my-service',
// Reconnection settings (all optional, shown with defaults)
reconnectDelayMs: 5000, // Base delay between attempts
reconnectMaxDelayMs: 60000, // Max delay cap (1 minute)
reconnectBackoffMultiplier: 2, // Exponential backoff multiplier
maxReconnectAttempts: 10, // Max attempts (0 = infinite)
});
// Queues are namespaced by NODE_ENV
// With NODE_ENV=production: "production-my-queue"
const queueName = getNamespacedQueue('my-queue');
await conn.channel.assertQueue(queueName, { durable: true });
// Publish messages
await publish(conn, 'my-queue', { event: 'user.created', data: { id: '123' } });
// Consume messages
await consume(conn, 'my-queue', async (msg, content) => {
console.log('Received:', content);
});
// Register onReconnect callback to re-setup after connection recovery
conn.onReconnect(async () => {
await conn.channel.assertQueue(queueName, { durable: true });
await consume(conn, 'my-queue', handler);
console.log('Consumer re-registered after reconnect');
});
// Cleanup
await conn.close();Reconnection Behavior
When the broker connection drops, createRabbitMQConnection automatically reconnects with exponential backoff and jitter:
| Attempt | Delay (base=5s, multiplier=2) | |---------|-------------------------------| | 1 | ~5s + jitter | | 2 | ~10s + jitter | | 3 | ~20s + jitter | | 4 | ~40s + jitter | | 5+ | ~60s + jitter (capped) |
- Jitter (0–1s random) prevents thundering herd when multiple services reconnect simultaneously
maxReconnectAttempts: 0disables the attempt limit for infinite retriesonReconnect(callback)runs after each successful reconnection — use this to re-register consumers and re-assert queues, since the old channel is replaced on reconnect
Caching
import { createRedisClient } from '@arcraz/common/redis';
import { ApiCache, DatabaseCache } from '@arcraz/common/caches';
const redis = createRedisClient({ host: 'localhost' });
// API Cache - for caching external API responses
const apiCache = new ApiCache(redis, 'weather', async (city: string) => {
const response = await fetch(`https://api.weather.com/${city}`);
return response.json();
}, 3600); // 1 hour TTL
const weather = await apiCache.getCached('new-york');
// Database Cache - for caching query results
const dbCache = new DatabaseCache(redis, 'users', async (id: string) => {
return userRepo.getById(id);
}, 300); // 5 minute TTL
const user = await dbCache.getCached('123');
await dbCache.deleteCached('123'); // Invalidate on updateLogging
import { createLogger } from '@arcraz/common/logging';
const logger = createLogger({
level: 'info',
format: 'json', // 'json' | 'logfmt' | 'pretty'
service: 'my-service',
});
logger.info('Server started', { port: 3000 });
logger.error('Failed to connect', { error: err.message });Security (Express Middleware)
import { createHelmetConfig, createCorsConfig } from '@arcraz/common/security';
import express from 'express';
import helmet from 'helmet';
import cors from 'cors';
const app = express();
// Secure headers
app.use(helmet(createHelmetConfig()));
// CORS
app.use(cors(createCorsConfig({
origins: ['https://myapp.com'],
credentials: true,
})));Errors
import {
AppError,
BadRequestError,
NotFoundError,
ValidationError,
InternalError,
isAppError
} from '@arcraz/common/errors';
// Throw typed HTTP errors with default status codes and messages
throw new NotFoundError('User not found');
throw new BadRequestError('Invalid email format');
// ValidationError supports field-level details
throw new ValidationError('Validation failed', {
email: 'must be a valid email',
password: 'must be at least 8 characters',
});
// InternalError defaults to isOperational=false (unexpected errors)
throw new InternalError('Database connection lost');
// Type guard for error handling middleware
app.use((err, req, res, next) => {
if (isAppError(err)) {
res.status(err.statusCode).json({
code: err.code,
message: err.message,
details: err.details,
});
} else {
res.status(500).json({ code: 'INTERNAL_ERROR', message: 'Something went wrong' });
}
});Available error classes:
| Class | Status | Code | isOperational |
|-------|--------|------|---------------|
| BadRequestError | 400 | BAD_REQUEST | true |
| UnauthorizedError | 401 | UNAUTHORIZED | true |
| ForbiddenError | 403 | FORBIDDEN | true |
| NotFoundError | 404 | NOT_FOUND | true |
| ConflictError | 409 | CONFLICT | true |
| ValidationError | 422 | VALIDATION_ERROR | true |
| RateLimitError | 429 | RATE_LIMIT_EXCEEDED | true |
| InternalError | 500 | INTERNAL_ERROR | false |
Helpers
import {
dataObscurer,
removeSensitiveValues,
redactSensitiveValues,
convertToEnum,
isValidEnumValue
} from '@arcraz/common/helpers';
// Mask sensitive data
const masked = dataObscurer('4111111111111111', { showLeft: 4, showRight: 4 });
// "4111********1111"
// Remove sensitive fields
const clean = removeSensitiveValues(
{ name: 'John', password: 'secret', ssn: '123-45-6789' },
['password', 'ssn']
);
// { name: 'John' }
// Redact instead of remove
const redacted = redactSensitiveValues(
{ name: 'John', password: 'secret' },
['password']
);
// { name: 'John', password: '[REDACTED]' }
// Enum conversion
enum Status { Active = 'ACTIVE', Inactive = 'INACTIVE' }
const status = convertToEnum('ACTIVE', Status); // Status.Active
const isValid = isValidEnumValue('ACTIVE', Status); // trueSocket.IO
Single-Namespace Server
import { createSocketIOServer } from '@arcraz/common/socketio';
const io = await createSocketIOServer({
httpServer,
cors: { origin: 'https://myapp.com', credentials: true },
authVerify: async (auth) => {
const user = await verifyToken(auth.token as string);
return user; // attached to socket.data.user
},
});
io.namespace.on('connection', (socket) => {
io.trackConnection(socket.data.user.id, socket.id);
socket.on('disconnect', () => io.untrackConnection(socket.id));
});
// Emit helpers
io.emitToUser('user-123', 'notification', { text: 'Hello' });
io.emitToRoom('lobby', 'message', { from: 'system', text: 'Welcome' });
io.broadcast('announcement', { text: 'Server restarting' });Multi-Namespace Hub (Typed Events)
import { createSocketIOHub } from '@arcraz/common/socketio';
import type { EventMap } from '@arcraz/common/socketio';
// Define typed events per namespace
type ChatClientEvents = { sendMessage: (text: string) => void };
type ChatServerEvents = { newMessage: (msg: { from: string; text: string }) => void };
type NotifServerEvents = { alert: (data: { level: string; message: string }) => void };
const hub = await createSocketIOHub({
httpServer,
cors: { origin: 'https://myapp.com', credentials: true },
});
// Each namespace gets independent auth, events, tracking, and room management
const chat = hub.addNamespace<ChatClientEvents, ChatServerEvents, EventMap, User>({
path: '/chat',
authVerify: async (auth) => verifyToken(auth.token as string),
eventHandlers: {
sendMessage: (socket, text) => {
chat.emitToRoom('general', 'newMessage', { from: socket.data.user.name, text });
},
},
onConnection: (socket, roomManager) => {
chat.trackConnection(socket.data.user.id, socket.id);
roomManager.joinRoom(socket.id, 'channel', 'general');
},
onDisconnect: (socket) => {
chat.untrackConnection(socket.id);
},
});
const notifications = hub.addNamespace<EventMap, NotifServerEvents>({
path: '/notifications',
});
// Typed emit — TypeScript enforces correct event names and argument types
chat.broadcast('newMessage', { from: 'system', text: 'Welcome!' });
notifications.emitToRoom('admins', 'alert', { level: 'warn', message: 'CPU high' });
// Room manager: structured naming
chat.roomManager.buildRoomName('workspace', 'w1', 'channel', 'general');
// -> 'workspace:w1:channel:general'Types
import { Result, ok, err, isOk, isErr, unwrap, unwrapOr } from '@arcraz/common/types';
// Result type for error handling without exceptions
function divide(a: number, b: number): Result<number, string> {
if (b === 0) return err('Division by zero');
return ok(a / b);
}
const result = divide(10, 2);
if (isOk(result)) {
console.log('Result:', unwrap(result)); // 5
}
// Or with default value
const value = unwrapOr(divide(10, 0), 0); // 0Subpath Exports
Import only what you need for optimal tree-shaking:
import { ... } from '@arcraz/common'; // Everything
import { ... } from '@arcraz/common/config'; // Config & Zod schemas
import { ... } from '@arcraz/common/database'; // PostgreSQL, BaseRepository, EnhancedRepository
import { ... } from '@arcraz/common/redis'; // Redis client, operations, rate limiter
import { ... } from '@arcraz/common/rabbitmq'; // RabbitMQ
import { ... } from '@arcraz/common/caches'; // Caching utilities
import { ... } from '@arcraz/common/logging'; // Logger
import { ... } from '@arcraz/common/security'; // Helmet & CORS configs
import { ... } from '@arcraz/common/errors'; // AppError hierarchy & isAppError guard
import { ... } from '@arcraz/common/helpers'; // Utility functions
import { ... } from '@arcraz/common/types'; // TypeScript types & enums
import { ... } from '@arcraz/common/constants'; // Default values
import { ... } from '@arcraz/common/socketio'; // Socket.IO server, hub, room managerNamespace Isolation
Redis and RabbitMQ resources are automatically namespaced by NODE_ENV to prevent collisions in shared environments:
| Service | Pattern | Example (NODE_ENV=staging) |
|-----------|--------------------------|--------------------------------|
| Redis | {NODE_ENV}:key | {staging}:user:123 |
| RabbitMQ | NODE_ENV-queue | staging-notifications |
// Redis - curly braces ensure cluster slot compatibility
getNamespacedKey('user:123'); // "{staging}:user:123"
stripNamespace('{staging}:user:123'); // "user:123"
extractNamespace('{staging}:user:123'); // "staging"
// RabbitMQ
getNamespacedQueue('notifications'); // "staging-notifications"
getNamespacedExchange('events'); // "staging-events"
stripQueueNamespace('staging-notifications'); // "notifications"Development
Scripts
yarn install # Install dependencies
yarn lint # Run ESLint
yarn test # Run Vitest tests
yarn build # Build to dist/Testing
Tests use Vitest and are located in __tests__/:
yarn test # Run all tests
yarn test:watch # Watch mode
yarn test:coverage # With coverage reportLicense
Apache-2.0 - See LICENSE for details.
