@mattrika/server-utils 0.0.0-stage → 1.0.0

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/index.cjs.js ADDED
@@ -0,0 +1,426 @@
1
+ 'use strict';
2
+
3
+ var fs = require('fs');
4
+ var path = require('path');
5
+ var pino = require('pino');
6
+ var httpException = require('hono/http-exception');
7
+ var zodOpenapi = require('@hono/zod-openapi');
8
+ var crypto = require('crypto');
9
+ var argon2 = require('@node-rs/argon2');
10
+ var httpStatusCodes = require('stoker/http-status-codes');
11
+ var esToolkit = require('es-toolkit');
12
+ var jwt = require('hono/jwt');
13
+
14
+ const DEFAULT_PAGE_SIZE = 20;
15
+ const MAX_PAGE_SIZE = 100;
16
+
17
+ const MAX_SIZE = 1024 * 1024; // 1MB
18
+ async function safeResponseBody(res) {
19
+ try {
20
+ const contentType = res.headers.get('content-type') || '';
21
+ const disposition = res.headers.get('content-disposition') || '';
22
+ // ❌ Skip downloads
23
+ if (disposition.includes('attachment')) {
24
+ return '[FILE DOWNLOAD SKIPPED]';
25
+ }
26
+ // ❌ Skip non-JSON responses
27
+ if (!contentType.includes('application/json')) {
28
+ return '[NON-JSON RESPONSE]';
29
+ }
30
+ const text = await res.clone().text();
31
+ if (!text) return undefined;
32
+ const size = Buffer.byteLength(text, 'utf8');
33
+ if (size > MAX_SIZE) return `[RESPONSE TOO LARGE: ${Math.round(size / 1024)} KB]`;
34
+ return JSON.parse(text);
35
+ } catch {
36
+ return '[UNREADABLE RESPONSE]';
37
+ }
38
+ }
39
+ async function safeRequestBody(req) {
40
+ try {
41
+ const contentType = req.header('content-type') || '';
42
+ // ❌ Skip file uploads
43
+ if (contentType.includes('multipart/form-data')) {
44
+ return '[FILE UPLOAD SKIPPED]';
45
+ }
46
+ if (!contentType.includes('application/json')) {
47
+ return undefined;
48
+ }
49
+ const text = await req.raw.clone().text();
50
+ if (!text) return undefined;
51
+ const size = Buffer.byteLength(text, 'utf8');
52
+ if (size > MAX_SIZE) return `[BODY TOO LARGE: ${Math.round(size / 1024)} KB]`;
53
+ return JSON.parse(text);
54
+ } catch {
55
+ return '[UNREADABLE REQUEST BODY]';
56
+ }
57
+ }
58
+ function ensureDirExists(filePath) {
59
+ const dir = path.dirname(filePath);
60
+ if (!fs.existsSync(dir)) {
61
+ fs.mkdirSync(dir, {
62
+ recursive: true
63
+ });
64
+ }
65
+ }
66
+
67
+ /**
68
+ * Checks whether APP_ENV environment variable is set to 'production' or NODE_ENV environment variable is set to 'production', in that order.
69
+ * @returns boolean
70
+ */
71
+ function isProductionAppEnvironment() {
72
+ const appEnv = process?.env?.['APP_ENV'];
73
+ if (appEnv === 'production') return true;
74
+ const nodeEnv = process?.env?.['NODE_ENV'];
75
+ if (nodeEnv === 'production') return true;
76
+ return false;
77
+ }
78
+
79
+ const logFilePath = './logs/app.log';
80
+ let transport;
81
+ function pinoLogger() {
82
+ const isProd = isProductionAppEnvironment();
83
+ try {
84
+ // ensure logs folder exists
85
+ ensureDirExists(logFilePath);
86
+ transport = isProd ? pino.transport({
87
+ target: 'pino/file',
88
+ options: {
89
+ destination: logFilePath
90
+ }
91
+ }) : pino.transport({
92
+ targets: [{
93
+ target: 'pino-pretty',
94
+ options: {
95
+ colorize: true,
96
+ levelFirst: true,
97
+ translateTime: 'SYS:standard'
98
+ }
99
+ }]
100
+ });
101
+ } catch (err) {
102
+ console.error('⚠️ Logger file transport failed, falling back to stdout', err);
103
+ transport = undefined;
104
+ }
105
+ return pino({
106
+ level: process.env?.['PINO_LOG_LEVEL'] || 'info',
107
+ timestamp: pino.stdTimeFunctions.isoTime,
108
+ formatters: {
109
+ bindings: bindings => ({
110
+ pid: bindings?.['pid'],
111
+ host: bindings?.['hostname'],
112
+ node_version: process.version
113
+ })
114
+ },
115
+ redact: {
116
+ paths: ['req.headers.authorization', 'req.headers.cookie', 'req.body.password', 'req.body.confirmPassword', 'req.body.oldPassword', 'req.body.newPassword', 'req.body.token', 'req.body.refreshToken', 'req.body.accessToken', 'req.body.*.password', 'req.body.*.token', 'req.body.*.refreshToken', 'req.body.*.accessToken', 'res.body.token', 'res.body.refreshToken', 'res.body.accessToken', 'res.body.data.token', 'res.body.data.refreshToken', 'res.body.data.accessToken', 'res.body.*.token', 'res.body.*.refreshToken', 'res.body.*.accessToken'],
117
+ censor: '[REDACTED]'
118
+ }
119
+ }, transport);
120
+ }
121
+
122
+ const pinoLoggerMiddleware = () => {
123
+ return async (c, next) => {
124
+ const start = Date.now();
125
+ const logger = pinoLogger();
126
+ const childLogger = logger.child({
127
+ requestId: c.var['requestId'],
128
+ req: {
129
+ method: c.req.method,
130
+ path: c.req.path,
131
+ query: c.req.query(),
132
+ headers: c.req.header(),
133
+ body: c.req.method !== 'GET' ? await safeRequestBody(c.req) : undefined
134
+ }
135
+ });
136
+ c.set('logger', childLogger);
137
+ childLogger.info('Incoming request');
138
+ try {
139
+ await next();
140
+ const responseBody = await safeResponseBody(c.res);
141
+ childLogger.info({
142
+ res: {
143
+ status: c.res.status,
144
+ durationMs: Date.now() - start,
145
+ body: responseBody
146
+ }
147
+ }, 'Request completed');
148
+ } catch (err) {
149
+ if (err instanceof httpException.HTTPException) {
150
+ childLogger.warn({
151
+ res: {
152
+ status: err.status
153
+ },
154
+ error: err.message
155
+ }, 'HTTP error');
156
+ throw err;
157
+ }
158
+ childLogger.error({
159
+ err
160
+ }, 'Unhandled server error');
161
+ throw err;
162
+ }
163
+ };
164
+ };
165
+
166
+ const zPagination = zodOpenapi.z.object({
167
+ page: zodOpenapi.z.coerce.number().optional(),
168
+ size: zodOpenapi.z.coerce.number().optional()
169
+ });
170
+ const zCommonQuery = zodOpenapi.z.object({
171
+ search: zodOpenapi.z.string().optional(),
172
+ orderBy: zodOpenapi.z.string().optional(),
173
+ sortOrder: zodOpenapi.z.enum(['asc', 'desc']).optional()
174
+ }).extend(zPagination.shape);
175
+ const zId = zodOpenapi.z.object({
176
+ id: zodOpenapi.z.string()
177
+ });
178
+ const zIds = zodOpenapi.z.object({
179
+ ids: zodOpenapi.z.array(zodOpenapi.z.string()).min(1)
180
+ });
181
+ const zPassword = zodOpenapi.z.string().min(8).max(128).regex(/[A-Z]/, 'Must contain at least one uppercase letter').regex(/[0-9]/, 'Must contain at least one number').regex(/[^A-Za-z0-9\s]/, 'Must contain at least one special character');
182
+ const zEmpty = zodOpenapi.z.object({});
183
+ const zFile = zodOpenapi.z.object({
184
+ file: zodOpenapi.z.instanceof(File).openapi({
185
+ type: 'string',
186
+ format: 'binary'
187
+ }).optional(),
188
+ files: zodOpenapi.z.array(zodOpenapi.z.instanceof(File).openapi({
189
+ type: 'string',
190
+ format: 'binary'
191
+ })).optional()
192
+ });
193
+
194
+ // `argon2.Algorithm` is an ambient `const enum`, so its members cannot be
195
+ // accessed when `isolatedModules` is enabled. Argon2id === 2; typing the
196
+ // literal against the library's own option type keeps it type-safe.
197
+ const ARGON2ID = 2;
198
+ class CryptoService {
199
+ static async hash(plain) {
200
+ return argon2.hash(plain, {
201
+ algorithm: ARGON2ID,
202
+ memoryCost: 1 << 16,
203
+ // 64 MB
204
+ timeCost: 3,
205
+ parallelism: 1
206
+ });
207
+ }
208
+ static async verify(plain, hash) {
209
+ return argon2.verify(hash, plain);
210
+ }
211
+ static random64chars() {
212
+ // 32 bytes hex -> 64 hex chars
213
+ return crypto.randomBytes(32).toString('hex');
214
+ }
215
+ }
216
+
217
+ const zResponsePagination = zodOpenapi.z.object({
218
+ page: zodOpenapi.z.number(),
219
+ size: zodOpenapi.z.number(),
220
+ totalItems: zodOpenapi.z.number(),
221
+ totalPages: zodOpenapi.z.number()
222
+ });
223
+ function ApiResponse(dataSchema, description) {
224
+ return {
225
+ content: {
226
+ 'application/json': {
227
+ schema: zodOpenapi.z.object({
228
+ data: dataSchema,
229
+ message: zodOpenapi.z.string(),
230
+ success: zodOpenapi.z.boolean(),
231
+ error: zodOpenapi.z.any().optional(),
232
+ meta: zodOpenapi.z.any().optional()
233
+ })
234
+ }
235
+ },
236
+ description
237
+ };
238
+ }
239
+ function ApiListResponse(dataSchema, description) {
240
+ return {
241
+ content: {
242
+ 'application/json': {
243
+ schema: zodOpenapi.z.object({
244
+ data: dataSchema,
245
+ message: zodOpenapi.z.string(),
246
+ success: zodOpenapi.z.boolean(),
247
+ error: zodOpenapi.z.any().optional(),
248
+ meta: zodOpenapi.z.any().optional(),
249
+ pagination: zResponsePagination
250
+ })
251
+ }
252
+ },
253
+ description
254
+ };
255
+ }
256
+ // 1. Base Stream Schema
257
+ const zStreamResponse = zodOpenapi.z.object({
258
+ body: zodOpenapi.z.custom(val => val instanceof ReadableStream, {
259
+ message: 'Body must be a ReadableStream'
260
+ }),
261
+ headers: zodOpenapi.z.object({
262
+ 'Content-Type': zodOpenapi.z.string().default('application/octet-stream'),
263
+ 'Content-Disposition': zodOpenapi.z.string().refine(val => val.startsWith('inline; filename="'), {
264
+ message: 'Must follow format: inline; filename="..."'
265
+ })
266
+ }).optional()
267
+ });
268
+ // 2. Generic API Response for ReadableStream
269
+ function StreamResponse(description) {
270
+ return {
271
+ content: {
272
+ 'application/octet-stream': {
273
+ schema: zStreamResponse
274
+ }
275
+ },
276
+ description
277
+ };
278
+ }
279
+ function ApiPdfResponse(description) {
280
+ return {
281
+ content: {
282
+ 'application/pdf': {
283
+ schema: zodOpenapi.z.object({
284
+ format: zodOpenapi.z.literal('binary'),
285
+ type: zodOpenapi.z.literal('string')
286
+ })
287
+ }
288
+ },
289
+ description
290
+ };
291
+ }
292
+
293
+ function throwHttpError(error, defaultMessage = 'Something went wrong') {
294
+ const code = error instanceof Error ? Number.isNaN(error.cause) ? httpStatusCodes.INTERNAL_SERVER_ERROR : error.cause : httpStatusCodes.INTERNAL_SERVER_ERROR;
295
+ throw new httpException.HTTPException(code, {
296
+ message: error.message ?? defaultMessage
297
+ });
298
+ }
299
+ function isUniqueViolationPg(error) {
300
+ const e = error;
301
+ return e?.code === '23505'; // unique_violation
302
+ }
303
+
304
+ function toErrorCode(value) {
305
+ if (value === undefined || value === null || Number.isNaN(value)) {
306
+ return 500;
307
+ }
308
+ return typeof value === 'number' ? Math.max(value, 0) : 500;
309
+ }
310
+ function onErrorFn(debugLog = true) {
311
+ return async (err, c) => {
312
+ const statusCode = 'status' in err ? toErrorCode(err.status) : 'cause' in err ? toErrorCode(err.cause) : httpStatusCodes.INTERNAL_SERVER_ERROR;
313
+ const logger = c.get('logger');
314
+ logger.error({
315
+ err,
316
+ path: c.req.path,
317
+ method: c.req.method
318
+ }, 'Request failed');
319
+ if (debugLog) {
320
+ console.error(JSON.stringify(err, undefined, 2));
321
+ }
322
+ return c.json({
323
+ data: null,
324
+ error: !debugLog ? undefined : err,
325
+ message: err.cause ?? err.message,
326
+ success: false
327
+ }, statusCode);
328
+ };
329
+ }
330
+
331
+ const notFoundFn = c => {
332
+ return c.json({
333
+ success: false,
334
+ error: 'Not Found',
335
+ data: {},
336
+ message: `Not Found - ${c.req.path}`
337
+ }, httpStatusCodes.NOT_FOUND);
338
+ };
339
+
340
+ function buildPaginationResponse(page, size, total) {
341
+ const numPage = esToolkit.clamp(Number(page), 1) || 1;
342
+ const positivePage = numPage < 1 ? 1 : numPage;
343
+ const numSize = esToolkit.clamp(Number(size), DEFAULT_PAGE_SIZE);
344
+ const numTotal = esToolkit.clamp(Number(total), 0);
345
+ const totalPages = Math.ceil(numTotal / numSize);
346
+ return {
347
+ page: positivePage,
348
+ size: numSize,
349
+ totalItems: numTotal,
350
+ totalPages
351
+ };
352
+ }
353
+
354
+ class TokenUtil {
355
+ alg;
356
+ secret;
357
+ constructor(secret, alg = 'HS256') {
358
+ this.alg = alg;
359
+ this.secret = secret;
360
+ }
361
+ async create(payload, lifeSeconds = 15 * 60) {
362
+ if (payload.exp === undefined) {
363
+ // JWT NumericDate claims (`exp`) are seconds since the epoch.
364
+ payload.exp = Math.floor(Date.now() / 1000) + lifeSeconds;
365
+ }
366
+ return await jwt.sign(payload, this.secret, this.alg);
367
+ }
368
+ async decode(token) {
369
+ return await jwt.verify(token, this.secret, this.alg);
370
+ }
371
+ async decodeUnexpired(token) {
372
+ // Disable Hono's built-in `exp` check so an expired token resolves to
373
+ // `null` instead of throwing, then validate it here.
374
+ const {
375
+ exp,
376
+ ...rest
377
+ } = await jwt.verify(token, this.secret, {
378
+ alg: this.alg,
379
+ exp: false
380
+ });
381
+ const nowSeconds = Math.floor(Date.now() / 1000);
382
+ if (exp !== undefined && exp <= nowSeconds) return null;
383
+ return {
384
+ exp: exp,
385
+ ...rest
386
+ };
387
+ }
388
+ }
389
+
390
+ function validationHook(result, _c) {
391
+ if (!result.success) {
392
+ throwHttpError(new Error('Validation error', {
393
+ cause: httpStatusCodes.UNPROCESSABLE_ENTITY
394
+ }));
395
+ }
396
+ }
397
+
398
+ exports.ApiListResponse = ApiListResponse;
399
+ exports.ApiPdfResponse = ApiPdfResponse;
400
+ exports.ApiResponse = ApiResponse;
401
+ exports.CryptoService = CryptoService;
402
+ exports.DEFAULT_PAGE_SIZE = DEFAULT_PAGE_SIZE;
403
+ exports.MAX_PAGE_SIZE = MAX_PAGE_SIZE;
404
+ exports.StreamResponse = StreamResponse;
405
+ exports.TokenUtil = TokenUtil;
406
+ exports.buildPaginationResponse = buildPaginationResponse;
407
+ exports.ensureDirExists = ensureDirExists;
408
+ exports.isProductionAppEnvironment = isProductionAppEnvironment;
409
+ exports.isUniqueViolationPg = isUniqueViolationPg;
410
+ exports.notFoundFn = notFoundFn;
411
+ exports.onErrorFn = onErrorFn;
412
+ exports.pinoLogger = pinoLogger;
413
+ exports.pinoLoggerMiddleware = pinoLoggerMiddleware;
414
+ exports.safeRequestBody = safeRequestBody;
415
+ exports.safeResponseBody = safeResponseBody;
416
+ exports.throwHttpError = throwHttpError;
417
+ exports.validationHook = validationHook;
418
+ exports.zCommonQuery = zCommonQuery;
419
+ exports.zEmpty = zEmpty;
420
+ exports.zFile = zFile;
421
+ exports.zId = zId;
422
+ exports.zIds = zIds;
423
+ exports.zPagination = zPagination;
424
+ exports.zPassword = zPassword;
425
+ exports.zResponsePagination = zResponsePagination;
426
+ exports.zStreamResponse = zStreamResponse;
package/index.cjs.mjs ADDED
@@ -0,0 +1,2 @@
1
+ export * from './index.cjs.js';
2
+ export { _default as default } from './index.cjs.default.js';
package/index.d.ts ADDED
@@ -0,0 +1 @@
1
+ export * from "./src/index";