@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 +6 -0
- package/README.md +22 -31
- package/bin/cli.js +26 -35
- package/dockerfile +9 -1
- package/main.ts +5 -5
- package/package.json +1 -1
- package/src/app.ts +5 -4
- package/src/auth/Auth.ts +5 -4
- package/src/auth/stores/RedisStore.ts +6 -5
- package/src/auth/stores/index.ts +7 -6
- package/src/core/env.ts +7 -1
- package/src/core/i18n.ts +3 -2
- package/src/index.ts +2 -0
- package/src/orm/QueryBuilder.ts +2 -1
- package/src/plugins/response.ts +3 -2
- package/src/utils/Logger.ts +157 -0
- package/src/utils/index.ts +3 -0
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
|
|
35
|
-
npm install
|
|
34
|
+
npx @rapidd/core create-project && npm install
|
|
36
35
|
```
|
|
37
36
|
|
|
38
|
-
|
|
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
|
|
44
|
-
|
|
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
|
-
##
|
|
55
|
-
|
|
56
|
-
|
|
|
57
|
-
|
|
58
|
-
|
|
|
59
|
-
|
|
|
60
|
-
|
|
|
61
|
-
|
|
|
62
|
-
|
|
|
63
|
-
| Per-model ACL | ✓ | ✓ |
|
|
64
|
-
| Row-
|
|
65
|
-
|
|
|
66
|
-
|
|
|
67
|
-
|
|
|
68
|
-
|
|
69
|
-
|
|
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
|
|
242
|
-
npx 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'
|
|
103
|
-
'@
|
|
104
|
-
'
|
|
105
|
-
'
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
'@
|
|
109
|
-
'@
|
|
110
|
-
'@
|
|
111
|
-
'
|
|
112
|
-
|
|
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:
|
|
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
|
-
|
|
18
|
-
|
|
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
|
-
|
|
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
|
-
|
|
26
|
+
Logger.error(err as Error);
|
|
27
27
|
process.exit(1);
|
|
28
28
|
}
|
|
29
29
|
}
|
package/package.json
CHANGED
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
|
-
|
|
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
|
-
|
|
197
|
+
Logger.error(err);
|
|
197
198
|
process.exit(1);
|
|
198
199
|
});
|
|
199
200
|
|
|
200
|
-
process.on('unhandledRejection', (reason
|
|
201
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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', () =>
|
|
40
|
-
this.client.on('ready', () =>
|
|
41
|
-
this.client.on('error', (err: Error) =>
|
|
42
|
-
this.client.on('close', () =>
|
|
43
|
-
this.client.on('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;
|
package/src/auth/stores/index.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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';
|
package/src/orm/QueryBuilder.ts
CHANGED
|
@@ -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
|
-
|
|
2069
|
+
Logger.error(error);
|
|
2069
2070
|
|
|
2070
2071
|
// Default values
|
|
2071
2072
|
let statusCode: number = error.status_code || 500;
|
package/src/plugins/response.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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;
|
package/src/utils/index.ts
CHANGED
|
@@ -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',
|