@arcraz/common
v1.9.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();Shallow camelize (preserve JSONB content)
By default, BaseRepository runs results through humps.camelizeKeys, which
recurses into objects and arrays — every key inside a JSONB column is rewritten
from snake_case to camelCase. That is wrong when JSONB content is part of
an authoring contract (validated by Zod, written by migrations, referenced
verbatim in mobile/UI code). Pass shallowCamelize: true to camelize only
top-level row keys; values pass through by reference.
class GameRepository extends BaseRepository {
constructor(db: DatabasePool, readOnlyDb?: DatabasePool) {
// top-level row keys become camelCase, but theme_config JSONB is untouched
super(db, readOnlyDb, { shallowCamelize: true });
}
async findById(id: string) {
return this.queryOne<{ id: string; themeConfig: ThemeConfig }>('SELECT * FROM games WHERE id = $1', [id]);
}
}
// game.themeConfig.branding.display_name stays 'display_name' (not 'displayName')EnhancedRepository accepts the same option as a fourth constructor argument:
super(db, { table: 'games', primaryKey: 'id' }, readOnlyDb, { shallowCamelize: true });If you need the helpers directly (without the repository class), the database
subpath also exports camelCaseRowsShallow, camelCaseRowShallow,
queryWithCamelCaseShallow, and queryOneWithCamelCaseShallow.
pg-promise pool (createPgPromisePool)
createDatabasePool above is pg-based. If your data layer is built on
pg-promise (db.one/oneOrNone/none/manyOrNone, $/named/ params, :json,
db.tx, pgp.helpers.ColumnSet), use createPgPromisePool instead. It returns
both the db handle and the pgp root, and matches the Arcraz game-backend
convention: automatic snake→camel column mapping on every query (via a receive
hook), the shared type parsers (NUMERIC→float, BIGINT→number with an overflow
guard, DATE→string), and a verified-TLS posture.
import { createPgPromisePool } from '@arcraz/common/database';
const { db, pgp, close } = createPgPromisePool({
connectionString: process.env.DATABASE_URL!,
maxConnections: 20,
caCert: process.env.DATABASE_CA_CERT // PEM; verifies the managed-DB issuer
});
// Idiomatic pg-promise queries; columns arrive camelCased.
const user = await db.oneOrNone('SELECT * FROM users WHERE id = $/id/', { id });
// pgp.helpers for multi-row inserts (ColumnSet), transactions via db.tx(), etc.
// On shutdown / test teardown — end the pool (else connections leak / exit hangs):
process.on('SIGTERM', () => void close());TLS posture: loopback connections (localhost / 127.0.0.1 / ::1, matched on
the parsed hostname — not a substring of the URL) skip TLS for dev; every other
connection verifies the upstream certificate — with caCert if provided, else
the system trust store. rejectUnauthorized: false is never produced, and an
unparseable connection string fails closed (TLS required).
Timeouts & pooling: a statementTimeoutMs (default 30s) aborts runaway
queries so they can't exhaust the pool; maxConnections defaults to 20. All
pools share one memoized pg-promise root (per pg-promise's singleton
requirement) — pgp is that shared root.
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
createRedisClient() returns a RedisInstance wrapper ({ client, namespace,
close(), isConnected() }) — not a raw ioredis client. Use the exported operation
helpers (set, get, del, …), which take the instance as their first arg and
namespace keys automatically. The raw ioredis client is available at
redis.client if you need it.
import { createRedisClient, set, get, del } from '@arcraz/common/redis';
// Create client
const redis = createRedisClient({
host: 'localhost',
port: 6379
});
// Operation helpers namespace the key by NODE_ENV automatically.
// With NODE_ENV=production the stored key is "{production}:user:123".
await set(redis, 'user:123', JSON.stringify({ name: 'John' }), { ttl: 3600 });
const data = await get(redis, 'user:123');
await del(redis, 'user:123'); // del/exists/expire take a positional useNamespace (default true)
// Cleanup
await redis.close();Also exported: createRedisCluster({ nodes: [...] }) for cluster deployments;
list helpers (lpush/rpush/lrange/…); and the namespace helpers
(getNamespacedKey, stripNamespace, …). set/get take an options object
({ ttl, nx, xx, useNamespace }); del/exists/expire take a positional
useNamespace boolean.
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,
namespaced: true // prefix keys with {NODE_ENV}: — see note below (default: false)
});
// 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');
namespaced(defaultfalse) prefixes rate-limit keys with the{NODE_ENV}:namespace. When multiple environments/services share one Redis, set it totrueso staging and prod don't share counters. It's left opt-in to avoid silently re-scoping existing limiters on a package upgrade — a one-time warning is logged if it's leftfalsein a deployed (non-local) environment.
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' } });
// Also available: publishToExchange(conn, exchange, data, routingKey?) for fanout/
// topic routing, and publishWithDelay(conn, queue, data, delayMs) (needs the
// rabbitmq-delayed-message-exchange broker plugin).
// Consume messages
// On a handler error (and when noAck is not set), the message is nacked WITHOUT
// requeue to avoid a poison-message loop. Without a dead-letter exchange it is
// then DROPPED — configure deadLetterExchange so failures are routed there for
// inspection/replay. The exchange must already exist.
await consume(
conn,
'my-queue',
async (msg, content) => {
console.log('Received:', content);
},
{
deadLetterExchange: 'my-dlx', // optional; sets queue arg x-dead-letter-exchange
deadLetterRoutingKey: 'my-queue.failed' // optional
}
);
// 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 update
// Force a refresh (bypass cache, re-run the loader, re-cache)
const fresh = await dbCache.refreshCached('123');
// Pass parse=false to store/return the raw string instead of JSON-parsing
const raw = await dbCache.getCached('123', false); // string | nullLogging
import { createLogger } from '@arcraz/common/logging';
const logger = createLogger({
level: 'info',
format: 'json', // 'json' | 'logfmt' | 'pretty'
serviceName: 'my-service'
// Sensitive keys (password/token/apiKey/...) in the logged context are
// redacted by default; set redact:false to opt out.
});
logger.info('Server started', { port: 3000 });
logger.error('Failed to connect', { error: err.message });Security (Express Middleware)
import { createHelmetConfig, createOriginCorsConfig } 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 with an origin allowlist — createOriginCorsConfig is the intended helper
// for this; its first arg IS the origins list, and it enables credentials.
app.use(cors(createOriginCorsConfig(['https://myapp.com'], { credentials: true })));
// Or with the base factory (NOTE: the field is `origin`, singular — not `origins`;
// createCorsConfig spreads onto CorsConfig, so a mistyped `origins` is silently
// dropped, leaving CORS at the restrictive default):
// app.use(cors(createCorsConfig({ origin: ['https://myapp.com'], credentials: true })));Also exported from @arcraz/common/security:
createApiHelmetConfig (CSP-disabled variant for pure JSON APIs),
createDevCorsConfig (permissive — dev only, never production),
createOriginCorsConfig (allowlist, shown above), createDynamicCorsConfig
(callback validator), plus the config types (HelmetConfig, CorsConfig, …).
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 {
obscureData,
removeSensitiveValues,
redactSensitiveValues,
DEFAULT_SENSITIVE_KEYS,
convertToEnum,
isValidEnumValue,
getEnumValues,
getEnumKeys
} from '@arcraz/common/helpers';
// Mask sensitive data (default masking char is '#'; pass placeholder to change it)
const masked = obscureData('4111111111111111', { showLeft: 4, showRight: 4 });
// "4111########1111"
// Remove sensitive fields. Key matching is CASE-INSENSITIVE and circular-safe.
// Pass DEFAULT_SENSITIVE_KEYS to scrub the common secret keys instead of a hand list.
const clean = removeSensitiveValues(
{ name: 'John', Password: 'secret', ssn: '123-45-6789' },
['password', 'ssn'] // matches 'Password' too (case-insensitive)
);
// { name: 'John' }
// Redact instead of remove
const redacted = redactSensitiveValues({ name: 'John', password: 'secret' }, DEFAULT_SENSITIVE_KEYS);
// { name: 'John', password: '[REDACTED]' }
// Enum conversion + inspection
enum Status {
Active = 'ACTIVE',
Inactive = 'INACTIVE'
}
const status = convertToEnum('ACTIVE', Status); // Status.Active
const isValid = isValidEnumValue('ACTIVE', Status); // true
const values = getEnumValues(Status); // ['ACTIVE', 'INACTIVE']
const keys = getEnumKeys(Status); // ['Active', 'Inactive']The
logginglogger scrubs these same keys from log context by default — see Logging.
Socket.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'AWS S3
S3 helpers ship from @arcraz/common/aws/s3 — a functional wrapper over
@aws-sdk/client-s3 (the client is passed explicitly, no singletons). Works
against AWS S3 or any S3-compatible service (MinIO) via endpoint +
forcePathStyle.
import {
createS3Client,
closeS3Client,
upload,
download,
downloadAsBuffer,
downloadAsString,
exists,
getMetadata,
listObjects,
copyObject,
deleteObject,
deleteObjects,
getPresignedDownloadUrl,
getPresignedUploadUrl
} from '@arcraz/common/aws/s3';
const s3 = createS3Client({
region: 'us-east-1'
// credentials optional — falls back to the default AWS credential chain.
// endpoint + forcePathStyle for MinIO / S3-compatible services.
});
// Upload (body: Buffer | Readable | string)
await upload(s3, 'my-bucket', 'reports/2026.json', JSON.stringify(data), {
contentType: 'application/json',
cacheControl: 'max-age=3600',
serverSideEncryption: 'AES256'
});
// Download — stream, buffer, or string (all return null when the key is absent)
const stream = await download(s3, 'my-bucket', 'reports/2026.json'); // Readable | null
const buf = await downloadAsBuffer(s3, 'my-bucket', 'reports/2026.json'); // Buffer | null
const text = await downloadAsString(s3, 'my-bucket', 'reports/2026.json'); // string | null
// Existence + metadata
await exists(s3, 'my-bucket', 'reports/2026.json'); // boolean
await getMetadata(s3, 'my-bucket', 'reports/2026.json'); // { key, size?, lastModified?, etag?, storageClass? } | null
// List (paginated via continuationToken)
const { objects, continuationToken } = await listObjects(s3, 'my-bucket', {
prefix: 'reports/',
maxKeys: 100
});
// Copy (source key is URL-encoded internally so spaces / '#' work)
await copyObject(s3, 'src-bucket', 'a/b.json', 'dst-bucket', 'a/b.json');
// Delete one or many (deleteObjects no-ops on an empty array)
await deleteObject(s3, 'my-bucket', 'reports/2026.json');
await deleteObjects(s3, 'my-bucket', ['a.json', 'b.json']);
// Presigned URLs (expiresIn defaults to 3600s)
const getUrl = await getPresignedDownloadUrl(s3, 'my-bucket', 'a.json', { expiresIn: 900 });
const putUrl = await getPresignedUploadUrl(s3, 'my-bucket', 'a.json', {
expiresIn: 900,
contentType: 'application/json'
});
// Cleanup
closeS3Client(s3);AWS Bedrock
Bedrock runtime helpers ship from @arcraz/common/aws/bedrock — a thin wrapper
over @aws-sdk/client-bedrock-runtime for invoking Claude models and generating
Titan embeddings.
import { createBedrockClient, closeBedrockClient, invokeClaude, complete, generateEmbedding, generateEmbeddings } from '@arcraz/common/aws/bedrock';
const bedrock = createBedrockClient({ region: 'us-east-1' });
// Full message-based invocation
const res = await invokeClaude(bedrock, [{ role: 'user', content: 'Summarize this changelog...' }], {
modelId: 'anthropic.claude-3-5-sonnet-20240620-v1:0', // default
systemPrompt: 'You are a concise release-notes writer.',
maxTokens: 4096,
temperature: 0.7
});
// { content: string, stopReason: string, usage: { inputTokens, outputTokens } }
// One-shot text completion (returns just the string)
const text = await complete(bedrock, 'Write a haiku about caching.');
// Embeddings (Titan by default)
const one = await generateEmbedding(bedrock, 'hello world'); // { embedding: number[], inputTokens? }
const many = await generateEmbeddings(bedrock, ['a', 'b', 'c']); // EmbeddingResponse[]
closeBedrockClient(bedrock);AWS CloudFront
CloudFront helpers ship from @arcraz/common/aws/cloudfront — signed URLs and cookies for protected origins plus invalidation for cache busting on publish flows.
import { createCloudFrontSigner, signUrl, createCloudFrontInvalidator, createInvalidation, closeCloudFrontInvalidator } from '@arcraz/common/aws/cloudfront';
// Signed URL for a protected origin
const signer = createCloudFrontSigner({
keyPairId: process.env.CF_KEY_PAIR_ID!,
privateKey: process.env.CF_PRIVATE_KEY!,
distributionDomain: 'd123abc.cloudfront.net'
});
const url = signUrl(signer, '/levels/v7.json', {
expires: new Date(Date.now() + 3600 * 1000)
});
// Invalidate a path after a content publish
const invalidator = createCloudFrontInvalidator({ region: 'us-east-1' });
const { id, status } = await createInvalidation(
invalidator,
process.env.CF_DISTRIBUTION_ID!,
['/levels/manifest.json'],
{ callerReference: 'content-v7' } // optional, defaults to a timestamp
);
console.log(`Invalidation ${id} → ${status}`);
closeCloudFrontInvalidator(invalidator);createInvalidation prepends a leading / to any path that lacks one and rejects empty path lists. Reusing a callerReference is idempotent — CloudFront returns the existing invalidation instead of creating a new one — so a content-version id makes a good caller reference.
To protect a whole path prefix rather than a single object, use signed cookies instead of a signed URL:
import { signCookies, getCookieHeaders } from '@arcraz/common/aws/cloudfront';
const cookies = signCookies(signer, {
resourcePath: '/levels/*', // defaults to '/*'
expires: new Date(Date.now() + 3600 * 1000) // Date or Unix-seconds number
});
// { 'CloudFront-Policy', 'CloudFront-Signature', 'CloudFront-Key-Pair-Id' }
// Render as Set-Cookie values (Secure / HttpOnly / SameSite=Strict by default)
res.setHeader('Set-Cookie', getCookieHeaders(cookies, 'cdn.myapp.com'));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'; // pg + pg-promise pools, 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 manager
import { ... } from '@arcraz/common/aws/s3'; // S3 client, upload, presigned URLs
import { ... } from '@arcraz/common/aws/cloudfront'; // Signed URLs, signed cookies, invalidations
import { ... } from '@arcraz/common/aws/bedrock'; // Bedrock runtime clientNamespace 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.
