@rapidd/core 2.1.2 → 2.1.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/.env.example CHANGED
@@ -68,3 +68,9 @@ RLS_ENABLED=
68
68
  # Namespace prefix for SQL session variables (default: app)
69
69
  RLS_NAMESPACE=app
70
70
  # Configure RLS variables in src/config/rls.ts
71
+
72
+ # ── Logging ───────────────────────────────────────────
73
+ # essential (default), fine, or finest
74
+ LOG_LEVEL=essential
75
+ # Directory for log files (default: logs/). Empty string disables file logging.
76
+ LOG_DIR=logs
package/README.md CHANGED
@@ -31,18 +31,14 @@ Rapidd generates a fully-featured REST API from your database schema — then ge
31
31
 
32
32
  ```bash
33
33
  mkdir my-api && cd my-api
34
- npx rapidd create-project # scaffold project files
35
- npm install
34
+ npx @rapidd/core create-project && npm install
36
35
  ```
37
36
 
38
- ```env
39
- DATABASE_URL="postgresql://user:pass@localhost:5432/mydb"
40
- ```
37
+ Set `DATABASE_URL` in `.env`, then add your schema in `prisma/schema.prisma`(or create from DB via `npx prisma db pull`):
41
38
 
42
39
  ```bash
43
- npx prisma db pull # introspect existing database
44
- npx rapidd build # generate models, routes & ACL scaffold
45
- npm run dev # http://localhost:3000
40
+ npx rapidd build # generate models, routes & ACL scaffold
41
+ npm run dev # http://localhost:3000
46
42
  ```
47
43
 
48
44
  Every table gets full CRUD endpoints. Auth is enabled automatically when a user table is detected. Every auto-detected value — auth fields, password hashing, JWT secrets, session store — is overridable via env vars. See [`.env.example`](.env.example) for the full list.
@@ -51,25 +47,22 @@ Every table gets full CRUD endpoints. Auth is enabled automatically when a user
51
47
 
52
48
  ---
53
49
 
54
- ## Features
55
-
56
- | Feature | PostgreSQL | MySQL/MariaDB |
57
- |---------|:----------:|:-------------:|
58
- | CRUD API generation | ✓ | ✓ |
59
- | Query filtering (20+ operators) | ✓ | ✓ |
60
- | Relations & deep includes | ✓ | ✓ |
61
- | Field selection | ✓ | ✓ |
62
- | JWT authentication (4 methods) | ✓ | ✓ |
63
- | Per-model ACL | ✓ | ✓ |
64
- | Row-Level Security (database-enforced) | ✓ | — |
65
- | Rate limiting (Redis + memory fallback) | | ✓ |
66
- | File uploads with MIME validation | ✓ | ✓ |
67
- | SMTP mailer with EJS templates | ✓ | ✓ |
68
- | Config-driven HTTP client | ✓ | ✓ |
69
- | i18n (10 languages) | | |
70
- | Security headers (HSTS, CSP, etc.) | ✓ | ✓ |
71
-
72
- > **MySQL note:** ACL provides application-level access control for all databases. RLS adds database-enforced row filtering as a second layer (PostgreSQL-only). For MySQL, ACL is your primary access control mechanism and covers most use cases. See the **[Access Control wiki](https://github.com/MertDalbudak/rapidd/wiki/Access-Control-(ACL))** for details.
50
+ ## How It Compares
51
+
52
+ | | Rapidd | Hasura | PostgREST | Supabase | Strapi |
53
+ |---|:---:|:---:|:---:|:---:|:---:|
54
+ | Full source code ownership | ✓ | — | — | — | ✓ |
55
+ | Schema-first (no UI) | ✓ | ✓ | ✓ | ✓ | — |
56
+ | REST API | | — | ✓ | ✓ | ✓ |
57
+ | Multi-database | | ✓ | — | — | ✓ |
58
+ | Built-in auth | | — | — | ✓ | ✓ |
59
+ | Per-model ACL | ✓ | ✓ | — | — | ✓ |
60
+ | Row-level security | ✓* | ✓ | ✓ | ✓ | — |
61
+ | Before/after middleware | | | | — | ✓ |
62
+ | Custom routes alongside generated | | — | — | ✓ | ✓ |
63
+ | No vendor dependency | | ✓ | ✓ | — | ✓ |
64
+
65
+ <sub>* PostgreSQL only. All other features support PostgreSQL and MySQL/MariaDB.</sub>
73
66
 
74
67
  ---
75
68
 
@@ -235,11 +228,9 @@ docker build -t my-api . && docker run -p 3000:3000 --env-file .env my-api
235
228
  | [`@rapidd/core`](https://www.npmjs.com/package/@rapidd/core) | Framework runtime, project scaffolding, and unified `npx rapidd` CLI |
236
229
  | [`@rapidd/build`](https://www.npmjs.com/package/@rapidd/build) | Code generation — models, routes, and ACL from your Prisma schema |
237
230
 
238
- All commands go through `npx rapidd`:
239
-
240
231
  ```bash
241
- npx rapidd create-project # scaffold a new project (@rapidd/core)
242
- npx rapidd build # generate from schema (@rapidd/build)
232
+ npx @rapidd/core create-project # scaffold a new project
233
+ npx rapidd build # generate from schema (after npm install)
243
234
  ```
244
235
 
245
236
  ---
package/bin/cli.js CHANGED
@@ -87,6 +87,19 @@ function createProject() {
87
87
  }
88
88
  }
89
89
 
90
+ // Read versions from this package's own package.json so they never drift
91
+ const corePkg = JSON.parse(fs.readFileSync(path.join(packageRoot, 'package.json'), 'utf-8'));
92
+ const coreDeps = corePkg.dependencies || {};
93
+ const coreDevDeps = corePkg.devDependencies || {};
94
+
95
+ function pick(source, keys) {
96
+ const result = {};
97
+ for (const key of keys) {
98
+ if (source[key]) result[key] = source[key];
99
+ }
100
+ return result;
101
+ }
102
+
90
103
  // Generate a fresh package.json for the new project
91
104
  const pkg = {
92
105
  name: projectName,
@@ -97,41 +110,19 @@ function createProject() {
97
110
  dev: 'tsx watch main.ts',
98
111
  build: 'tsc',
99
112
  },
100
- engines: { node: '>=24.0.0' },
101
- dependencies: {
102
- '@fastify/cookie': '^11.0.2',
103
- '@fastify/cors': '^11.0.0',
104
- '@fastify/formbody': '^8.0.2',
105
- '@fastify/multipart': '^9.4.0',
106
- '@fastify/static': '^9.0.0',
107
- '@prisma/adapter-mariadb': '^7.0.1',
108
- '@prisma/adapter-pg': '^7.0.1',
109
- '@prisma/client': '^7.0.1',
110
- '@prisma/internals': '^7.0.1',
111
- 'bcrypt': '^6.0.0',
112
- 'dotenv': '^17.3.1',
113
- 'ejs': '^4.0.1',
114
- 'fastify': '^5.2.1',
115
- 'fastify-plugin': '^5.0.1',
116
- 'ioredis': '^5.6.1',
117
- 'jsonwebtoken': '^9.0.2',
118
- 'luxon': '^3.7.2',
119
- 'nodemailer': '^8.0.1',
120
- 'pg': '^8.16.3',
121
- },
122
- devDependencies: {
123
- '@rapidd/build': '^2.1.3',
124
- '@types/bcrypt': '^6.0.0',
125
- '@types/ejs': '^3.1.5',
126
- '@types/jsonwebtoken': '^9.0.8',
127
- '@types/luxon': '^3.7.1',
128
- '@types/node': '^22.12.0',
129
- '@types/nodemailer': '^7.0.9',
130
- '@types/pg': '^8.11.11',
131
- 'prisma': '^7.0.2',
132
- 'tsx': '^4.19.2',
133
- 'typescript': '^5.7.3',
134
- },
113
+ engines: corePkg.engines || { node: '>=24.0.0' },
114
+ dependencies: pick(coreDeps, [
115
+ '@fastify/cookie', '@fastify/cors', '@fastify/formbody', '@fastify/multipart', '@fastify/static',
116
+ '@prisma/adapter-mariadb', '@prisma/adapter-pg', '@prisma/client', '@prisma/internals',
117
+ 'bcrypt', 'dotenv', 'ejs', 'fastify', 'fastify-plugin',
118
+ 'ioredis', 'jsonwebtoken', 'luxon', 'nodemailer', 'pg',
119
+ ]),
120
+ devDependencies: pick(coreDevDeps, [
121
+ '@rapidd/build',
122
+ '@types/bcrypt', '@types/ejs', '@types/jsonwebtoken', '@types/luxon',
123
+ '@types/node', '@types/nodemailer', '@types/pg',
124
+ 'prisma', 'tsx', 'typescript',
125
+ ]),
135
126
  };
136
127
 
137
128
  fs.writeFileSync(
package/dockerfile CHANGED
@@ -25,7 +25,7 @@ COPY prisma ./prisma
25
25
  RUN npx prisma generate --generator client
26
26
 
27
27
  # Stage 3: Runtime
28
- FROM node:24-alpine
28
+ FROM node:current-alpine
29
29
 
30
30
  WORKDIR /app
31
31
 
@@ -50,8 +50,16 @@ COPY --chown=rapidd:nodejs public ./public
50
50
 
51
51
  RUN apk update && apk upgrade --no-cache && rm -rf /var/cache/apk/*
52
52
 
53
+ RUN mkdir -p /app/uploads /app/temp/uploads /app/logs && \
54
+ chown -R rapidd:nodejs /app/uploads /app/temp /app/logs
55
+
56
+ VOLUME ["/app/uploads", "/app/logs"]
57
+
53
58
  USER rapidd
54
59
 
55
60
  EXPOSE 3000
56
61
 
62
+ HEALTHCHECK --interval=30s --timeout=5s --start-period=10s --retries=3 \
63
+ CMD node -e "fetch('http://localhost:3000/').then(r => process.exit(r.status === 404 ? 0 : 1)).catch(() => process.exit(1))"
64
+
57
65
  ENTRYPOINT ["node", "dist/main.js"]
package/main.ts CHANGED
@@ -1,6 +1,7 @@
1
1
  import 'dotenv/config';
2
2
  import { getEnv } from './src/core/env';
3
3
  import { buildApp } from './src/app';
4
+ import { Logger } from './src/utils/Logger';
4
5
 
5
6
  /**
6
7
  * Application entry point
@@ -14,16 +15,15 @@ export async function start(): Promise<void> {
14
15
  const app = await buildApp();
15
16
 
16
17
  await app.listen({ port, host });
17
- console.log(`[Rapidd] Server running at http://${host}:${port}`);
18
- console.log(`[Rapidd] Environment: ${getEnv('NODE_ENV')}`);
18
+ Logger.log('Server running', { host, port });
19
+ Logger.log('Environment', { env: getEnv('NODE_ENV') });
19
20
 
20
21
  // Warn if running compiled build with development NODE_ENV
21
22
  if (process.argv[1]?.includes('/dist/') && getEnv('NODE_ENV') === 'development') {
22
- console.warn('[Rapidd] Warning: Running compiled build with NODE_ENV=development.');
23
- console.warn('[Rapidd] Set NODE_ENV=production in your .env for production use.');
23
+ Logger.warn('Running compiled build with NODE_ENV=development. Set NODE_ENV=production in your .env for production use.');
24
24
  }
25
25
  } catch (err) {
26
- console.error('[Startup Error]', (err as Error).message);
26
+ Logger.error(err as Error);
27
27
  process.exit(1);
28
28
  }
29
29
  }
package/package.json CHANGED
@@ -3,7 +3,7 @@
3
3
  "publishConfig": {
4
4
  "access": "public"
5
5
  },
6
- "version": "2.1.2",
6
+ "version": "2.1.3",
7
7
  "description": "Code-first REST API framework for TypeScript. Database in, API out.",
8
8
  "main": "dist/main.js",
9
9
  "bin": {
package/src/app.ts CHANGED
@@ -12,6 +12,7 @@ import { LanguageDict } from './core/i18n';
12
12
  import { disconnectAll } from './core/prisma';
13
13
  import { validateEnv } from './core/env';
14
14
  import { env } from './utils';
15
+ import { Logger } from './utils/Logger';
15
16
 
16
17
  // Plugins
17
18
  import securityPlugin from './plugins/security';
@@ -183,7 +184,7 @@ async function loadRoutes(app: FastifyInstance, routePath: string): Promise<void
183
184
  await app.register(plugin, { prefix: route });
184
185
  }
185
186
  } catch (err) {
186
- console.error(`Failed to load route ${route}:`, (err as Error).message);
187
+ Logger.error(err as Error, { route });
187
188
  }
188
189
  }
189
190
  }
@@ -193,12 +194,12 @@ async function loadRoutes(app: FastifyInstance, routePath: string): Promise<void
193
194
 
194
195
  // Handle uncaught errors
195
196
  process.on('uncaughtException', (err) => {
196
- console.error('[Uncaught Exception]', err);
197
+ Logger.error(err);
197
198
  process.exit(1);
198
199
  });
199
200
 
200
- process.on('unhandledRejection', (reason, promise) => {
201
- console.error('[Unhandled Rejection] at:', promise, 'reason:', reason);
201
+ process.on('unhandledRejection', (reason) => {
202
+ Logger.error(reason as Error);
202
203
  });
203
204
 
204
205
  export default buildApp;
package/src/auth/Auth.ts CHANGED
@@ -5,6 +5,7 @@ import { authPrisma } from '../core/prisma';
5
5
  import { ErrorResponse } from '../core/errors';
6
6
  import { createStore, SessionStoreManager } from './stores';
7
7
  import { loadDMMF, findUserModel, findIdentifierFields, findPasswordField } from '../core/dmmf';
8
+ import { Logger } from '../utils/Logger';
8
9
  import type { RapiddUser, AuthOptions, AuthMethod, ISessionStore } from '../types';
9
10
 
10
11
  /**
@@ -99,7 +100,7 @@ export class Auth {
99
100
  try {
100
101
  await loadDMMF();
101
102
  } catch {
102
- console.warn('[Auth] Could not load DMMF, auth auto-detection skipped');
103
+ Logger.warn('Auth: could not load DMMF, auto-detection skipped');
103
104
  return;
104
105
  }
105
106
 
@@ -109,7 +110,7 @@ export class Auth {
109
110
  // Check if a model name was explicitly configured
110
111
  if (!this.options.userModel) {
111
112
  this._authDisabled = true;
112
- console.log('[Auth] No user table detected in schema, auth disabled');
113
+ Logger.log('Auth: no user table detected, auth disabled');
113
114
  return;
114
115
  }
115
116
  // Model name was set but not found in DMMF — warn but don't disable
@@ -142,7 +143,7 @@ export class Auth {
142
143
  throw new Error('[Auth] JWT_SECRET is required in production. Generate one with: node -e "console.log(require(\'crypto\').randomBytes(32).toString(\'hex\'))"');
143
144
  }
144
145
  this.options.jwt.secret = crypto.randomBytes(32).toString('hex');
145
- console.warn('[Auth] No JWT_SECRET set, using auto-generated secret (sessions won\'t persist across restarts)');
146
+ Logger.warn('Auth: no JWT_SECRET set, using auto-generated secret (sessions won\'t persist across restarts)');
146
147
  }
147
148
  if (!this.options.jwt.refreshSecret) {
148
149
  if (process.env.NODE_ENV === 'production') {
@@ -173,7 +174,7 @@ export class Auth {
173
174
  this._userModel = (authPrisma as any)[match];
174
175
  return this._userModel;
175
176
  }
176
- console.warn(`[Auth] userModel="${modelName}" not found in Prisma models`);
177
+ Logger.warn('Auth: userModel not found in Prisma models', { userModel: modelName });
177
178
  }
178
179
 
179
180
  for (const name of ['users', 'user', 'Users', 'User']) {
@@ -1,5 +1,6 @@
1
1
  import Redis from 'ioredis';
2
2
  import { ISessionStore } from './ISessionStore';
3
+ import { Logger } from '../../utils/Logger';
3
4
 
4
5
  /**
5
6
  * Redis session store using ioredis.
@@ -36,11 +37,11 @@ export class RedisStore extends ISessionStore {
36
37
  connectTimeout: 5000,
37
38
  });
38
39
 
39
- this.client.on('connect', () => console.log('[RedisStore] Connected'));
40
- this.client.on('ready', () => console.log('[RedisStore] Ready'));
41
- this.client.on('error', (err: Error) => console.error('[RedisStore] Error:', err.message));
42
- this.client.on('close', () => console.warn('[RedisStore] Connection closed'));
43
- this.client.on('reconnecting', () => console.log('[RedisStore] Reconnecting...'));
40
+ this.client.on('connect', () => Logger.log('RedisStore connected'));
41
+ this.client.on('ready', () => Logger.log('RedisStore ready'));
42
+ this.client.on('error', (err: Error) => Logger.error(err));
43
+ this.client.on('close', () => Logger.warn('RedisStore connection closed'));
44
+ this.client.on('reconnecting', () => Logger.log('RedisStore reconnecting'));
44
45
 
45
46
  this._initialized = true;
46
47
  return this.client;
@@ -1,6 +1,7 @@
1
1
  import { ISessionStore } from './ISessionStore';
2
2
  import { MemoryStore } from './MemoryStore';
3
3
  import { RedisStore } from './RedisStore';
4
+ import { Logger } from '../../utils/Logger';
4
5
 
5
6
  const builtInStores: Record<string, new (options?: any) => ISessionStore> = {
6
7
  memory: MemoryStore,
@@ -41,7 +42,7 @@ export class SessionStoreManager extends ISessionStore {
41
42
 
42
43
  const StoreClass = builtInStores[this.storeName];
43
44
  if (!StoreClass) {
44
- console.warn(`[SessionStore] Unknown store "${this.storeName}", using memory`);
45
+ Logger.warn('SessionStore: unknown store, using memory', { store: this.storeName });
45
46
  this._primaryStore = this._fallbackStore;
46
47
  this._initialized = true;
47
48
  return;
@@ -50,7 +51,7 @@ export class SessionStoreManager extends ISessionStore {
50
51
  try {
51
52
  this._primaryStore = new StoreClass({ ttl: this.ttl });
52
53
  } catch (err) {
53
- console.warn(`[SessionStore] Failed to create ${this.storeName}: ${(err as Error).message}, using memory`);
54
+ Logger.warn('SessionStore: failed to create store, using memory', { store: this.storeName, error: (err as Error).message });
54
55
  this._primaryStore = this._fallbackStore;
55
56
  }
56
57
 
@@ -70,15 +71,15 @@ export class SessionStoreManager extends ISessionStore {
70
71
  try {
71
72
  const isHealthy = await this._primaryStore!.isHealthy();
72
73
  if (isHealthy && this._usingFallback) {
73
- console.log(`[SessionStore] ${this.storeName} recovered, switching back from memory`);
74
+ Logger.log('SessionStore: recovered, switching back from memory', { store: this.storeName });
74
75
  this._usingFallback = false;
75
76
  } else if (!isHealthy && !this._usingFallback) {
76
- console.warn(`[SessionStore] ${this.storeName} unavailable, switching to memory`);
77
+ Logger.warn('SessionStore: unavailable, switching to memory', { store: this.storeName });
77
78
  this._usingFallback = true;
78
79
  }
79
80
  } catch {
80
81
  if (!this._usingFallback) {
81
- console.warn(`[SessionStore] ${this.storeName} health check failed, switching to memory`);
82
+ Logger.warn('SessionStore: health check failed, switching to memory', { store: this.storeName });
82
83
  this._usingFallback = true;
83
84
  }
84
85
  }
@@ -98,7 +99,7 @@ export class SessionStoreManager extends ISessionStore {
98
99
  try {
99
100
  return await (this._primaryStore as any)[operation](...args);
100
101
  } catch (err) {
101
- console.warn(`[SessionStore] ${this.storeName}.${operation} failed: ${(err as Error).message}, switching to memory`);
102
+ Logger.warn('SessionStore: operation failed, switching to memory', { store: this.storeName, operation, error: (err as Error).message });
102
103
  this._usingFallback = true;
103
104
  return (this._fallbackStore as any)[operation](...args);
104
105
  }
package/src/core/env.ts CHANGED
@@ -51,6 +51,10 @@ export interface EnvConfig {
51
51
 
52
52
  // Proxy
53
53
  TRUST_PROXY?: boolean;
54
+
55
+ // Logging
56
+ LOG_LEVEL: 'essential' | 'fine' | 'finest';
57
+ LOG_DIR: string;
54
58
  }
55
59
 
56
60
  const REQUIRED_VARS = [
@@ -76,7 +80,9 @@ const DEFAULTS: Partial<Record<keyof EnvConfig, string | number | boolean>> = {
76
80
  REDIS_PORT: 6379,
77
81
  REDIS_DB_RATE_LIMIT: 0,
78
82
  REDIS_DB_AUTH: 1,
79
- RLS_NAMESPACE: 'app'
83
+ RLS_NAMESPACE: 'app',
84
+ LOG_LEVEL: 'essential',
85
+ LOG_DIR: 'logs',
80
86
  };
81
87
 
82
88
  /**
package/src/core/i18n.ts CHANGED
@@ -1,5 +1,6 @@
1
1
  import { readdirSync } from 'fs';
2
2
  import path from 'path';
3
+ import { Logger } from '../utils/Logger';
3
4
 
4
5
  const ROOT = process.env.ROOT || '';
5
6
  const DEFAULT_STRINGS_PATH = ROOT ? path.join(ROOT, 'locales') : './locale';
@@ -40,14 +41,14 @@ export class LanguageDict {
40
41
  this._dictionaries[langCode] = require(dictPath);
41
42
  this._available.push(langCode);
42
43
  } catch (error) {
43
- console.error(`Failed to load dictionary for ${langCode}:`, (error as Error).message);
44
+ Logger.error(error as Error, { langCode });
44
45
  }
45
46
  }
46
47
  }
47
48
 
48
49
  this._initialized = true;
49
50
  } catch (error) {
50
- console.error('Failed to initialize LanguageDict:', (error as Error).message);
51
+ Logger.error(error as Error);
51
52
  this._dictionaries = {};
52
53
  this._available = [];
53
54
  }
package/src/index.ts CHANGED
@@ -44,6 +44,7 @@ export { modelMiddleware } from './core/middleware';
44
44
  // ── Utilities ────────────────────────────────────────────────────────────────
45
45
  export { ApiClient, ApiClientError } from './utils/ApiClient';
46
46
  export { Mailer } from './utils/Mailer';
47
+ export { Logger } from './utils/Logger';
47
48
  export { env } from './utils';
48
49
 
49
50
  // ── Environment ──────────────────────────────────────────────────────────────
@@ -110,3 +111,4 @@ export type {
110
111
  } from './plugins/upload';
111
112
 
112
113
  export type { EnvConfig } from './core/env';
114
+ export type { LogLevel } from './utils/Logger';
@@ -1,5 +1,6 @@
1
1
  import { prisma, prismaTransaction, getAcl } from '../core/prisma';
2
2
  import { ErrorResponse } from '../core/errors';
3
+ import { Logger } from '../utils/Logger';
3
4
  import * as dmmf from '../core/dmmf';
4
5
  import type {
5
6
  RelationConfig,
@@ -2065,7 +2066,7 @@ class QueryBuilder {
2065
2066
  * Handle Prisma errors and convert to standardized error responses
2066
2067
  */
2067
2068
  static errorHandler(error: any, data: Record<string, unknown> = {}): QueryErrorResponse {
2068
- console.error(error);
2069
+ Logger.error(error);
2069
2070
 
2070
2071
  // Default values
2071
2072
  let statusCode: number = error.status_code || 500;
@@ -2,6 +2,7 @@ import { FastifyPluginAsync, FastifyReply, FastifyRequest } from 'fastify';
2
2
  import fp from 'fastify-plugin';
3
3
  import { ErrorResponse, ErrorBasicResponse } from '../core/errors';
4
4
  import { LanguageDict } from '../core/i18n';
5
+ import { Logger } from '../utils/Logger';
5
6
  import type { ListMeta } from '../types';
6
7
 
7
8
  /**
@@ -37,7 +38,7 @@ const responsePlugin: FastifyPluginAsync = async (fastify) => {
37
38
  const request = this.request;
38
39
  const language = request?.language || 'en_US';
39
40
  const error = new ErrorResponse(statusCode, message, data as Record<string, unknown> | null);
40
- console.error(`Error ${statusCode}: ${message}`);
41
+ Logger.error(message, { statusCode });
41
42
  return this.code(statusCode).send(error.toJSON(language));
42
43
  });
43
44
 
@@ -72,7 +73,7 @@ const responsePlugin: FastifyPluginAsync = async (fastify) => {
72
73
  ? 'Something went wrong'
73
74
  : err.message || String(error);
74
75
 
75
- console.error(error);
76
+ Logger.error(error);
76
77
  return reply.code(status).send({ status_code: status, message });
77
78
  });
78
79
  };
@@ -0,0 +1,157 @@
1
+ import { appendFileSync, mkdirSync, existsSync } from 'fs';
2
+ import path from 'path';
3
+
4
+ // ── Types ────────────────────────────────────────────────────────────────────
5
+
6
+ export type LogLevel = 'essential' | 'fine' | 'finest';
7
+
8
+ // ── Level Config ─────────────────────────────────────────────────────────────
9
+
10
+ const LEVELS: LogLevel[] = ['essential', 'fine', 'finest'];
11
+
12
+ function parseLevel(value: string | undefined): LogLevel {
13
+ if (value && LEVELS.includes(value as LogLevel)) {
14
+ return value as LogLevel;
15
+ }
16
+ return 'essential';
17
+ }
18
+
19
+ // ── State ────────────────────────────────────────────────────────────────────
20
+
21
+ const _level: LogLevel = parseLevel(process.env.LOG_LEVEL);
22
+ const _silent: boolean = process.env.NODE_ENV === 'test';
23
+ const _logDir: string = process.env.LOG_DIR ?? 'logs';
24
+
25
+ // ── File Writing ─────────────────────────────────────────────────────────────
26
+
27
+ let _dirChecked = false;
28
+
29
+ function ensureLogDir(): void {
30
+ if (_dirChecked || !_logDir) return;
31
+ const dir = path.isAbsolute(_logDir) ? _logDir : path.join(process.cwd(), _logDir);
32
+ if (!existsSync(dir)) {
33
+ mkdirSync(dir, { recursive: true });
34
+ }
35
+ _dirChecked = true;
36
+ }
37
+
38
+ function writeToFile(filename: string, line: string): void {
39
+ if (!_logDir) return;
40
+ try {
41
+ ensureLogDir();
42
+ const dir = path.isAbsolute(_logDir) ? _logDir : path.join(process.cwd(), _logDir);
43
+ appendFileSync(path.join(dir, filename), line + '\n');
44
+ } catch {
45
+ // Silently ignore file write failures — don't crash the app for logging
46
+ }
47
+ }
48
+
49
+ // ── Formatting ───────────────────────────────────────────────────────────────
50
+
51
+ function timestamp(): string {
52
+ return new Date().toISOString();
53
+ }
54
+
55
+ function formatData(data: unknown[]): string {
56
+ if (data.length === 0) return '';
57
+ const parts = data.map(d => {
58
+ if (d === null || d === undefined) return String(d);
59
+ if (typeof d === 'object') {
60
+ try { return JSON.stringify(d); } catch { return String(d); }
61
+ }
62
+ return String(d);
63
+ });
64
+ return ' ' + parts.join(' ');
65
+ }
66
+
67
+ function formatDataPretty(data: unknown[]): string {
68
+ if (data.length === 0) return '';
69
+ const parts = data.map(d => {
70
+ if (d === null || d === undefined) return String(d);
71
+ if (typeof d === 'object') {
72
+ try { return JSON.stringify(d, null, 2); } catch { return String(d); }
73
+ }
74
+ return String(d);
75
+ });
76
+ return '\n' + parts.join('\n');
77
+ }
78
+
79
+ function formatError(error: Error | string | unknown): { message: string; toString: string; stack: string } {
80
+ if (error instanceof Error) {
81
+ return {
82
+ message: error.message,
83
+ toString: error.toString(),
84
+ stack: error.stack || error.toString(),
85
+ };
86
+ }
87
+ const str = String(error);
88
+ return { message: str, toString: str, stack: str };
89
+ }
90
+
91
+ // ── Logger ───────────────────────────────────────────────────────────────────
92
+
93
+ export const Logger = {
94
+ log(message: string, ...data: unknown[]): void {
95
+ if (_silent) return;
96
+
97
+ let output: string;
98
+ switch (_level) {
99
+ case 'essential':
100
+ output = `[${timestamp()}] [LOG] ${message}`;
101
+ break;
102
+ case 'fine':
103
+ output = `[${timestamp()}] [LOG] ${message}${formatData(data)}`;
104
+ break;
105
+ case 'finest':
106
+ output = `[${timestamp()}] [LOG] ${message}${formatDataPretty(data)}`;
107
+ break;
108
+ }
109
+
110
+ console.log(output);
111
+ writeToFile('app.log', output);
112
+ },
113
+
114
+ warn(message: string, ...data: unknown[]): void {
115
+ if (_silent) return;
116
+
117
+ let output: string;
118
+ switch (_level) {
119
+ case 'essential':
120
+ output = `[${timestamp()}] [WARN] ${message}`;
121
+ break;
122
+ case 'fine':
123
+ output = `[${timestamp()}] [WARN] ${message}${formatData(data)}`;
124
+ break;
125
+ case 'finest':
126
+ output = `[${timestamp()}] [WARN] ${message}${formatDataPretty(data)}`;
127
+ break;
128
+ }
129
+
130
+ console.warn(output);
131
+ writeToFile('app.log', output);
132
+ },
133
+
134
+ error(error: Error | string | unknown, ...data: unknown[]): void {
135
+ if (_silent) return;
136
+
137
+ const err = formatError(error);
138
+ let output: string;
139
+
140
+ switch (_level) {
141
+ case 'essential':
142
+ output = `[${timestamp()}] [ERROR] ${err.message}`;
143
+ break;
144
+ case 'fine':
145
+ output = `[${timestamp()}] [ERROR] ${err.toString}${formatData(data)}`;
146
+ break;
147
+ case 'finest':
148
+ output = `[${timestamp()}] [ERROR] ${err.stack}${formatDataPretty(data)}`;
149
+ break;
150
+ }
151
+
152
+ console.error(output);
153
+ writeToFile('error.log', output);
154
+ },
155
+ };
156
+
157
+ export default Logger;
@@ -4,6 +4,9 @@ export type { ServiceConfig, EndpointConfig, AuthConfig, RequestOptions, ApiResp
4
4
  export { Mailer } from './Mailer';
5
5
  export type { EmailConfig, EmailOptions, EmailAttachment, EmailResult } from './Mailer';
6
6
 
7
+ export { Logger } from './Logger';
8
+ export type { LogLevel } from './Logger';
9
+
7
10
  export const env = {
8
11
  isProduction: () => process.env.NODE_ENV === 'production',
9
12
  isDevelopment: () => __filename.endsWith('.ts') || process.env.NODE_ENV === 'development',