@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/README.md +386 -2
- package/index.cjs.default.js +1 -0
- package/index.cjs.js +426 -0
- package/index.cjs.mjs +2 -0
- package/index.d.ts +1 -0
- package/index.esm.js +396 -0
- package/package.json +33 -3
- package/src/index.d.ts +14 -0
- package/src/lib/constants/common.values.d.ts +2 -0
- package/src/lib/pino-logger/logger.helpers.d.ts +4 -0
- package/src/lib/pino-logger/pino-logger.d.ts +2 -0
- package/src/lib/pino-logger/pino-logger.middleware.d.ts +2 -0
- package/src/lib/predicates/is-prod.d.ts +5 -0
- package/src/lib/schemas/common.schema.d.ts +29 -0
- package/src/lib/services/crypto.service.d.ts +5 -0
- package/src/lib/utils/api-response.util.d.ts +76 -0
- package/src/lib/utils/error.util.d.ts +2 -0
- package/src/lib/utils/on-error.d.ts +2 -0
- package/src/lib/utils/on-not-found.d.ts +2 -0
- package/src/lib/utils/pagination.util.d.ts +2 -0
- package/src/lib/utils/token.util.d.ts +10 -0
- package/src/lib/utils/validation-hook.d.ts +2 -0
package/README.md
CHANGED
|
@@ -1,3 +1,387 @@
|
|
|
1
|
-
#
|
|
1
|
+
# @mattrika/server-utils
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
TypeScript utilities, shared types, interfaces and various other helpers for
|
|
4
|
+
Node.js server backends.
|
|
5
|
+
|
|
6
|
+
## Installation
|
|
7
|
+
|
|
8
|
+
```bash
|
|
9
|
+
npm install @mattrika/server-utils
|
|
10
|
+
# or
|
|
11
|
+
pnpm add @mattrika/server-utils
|
|
12
|
+
# or
|
|
13
|
+
yarn add @mattrika/server-utils
|
|
14
|
+
```
|
|
15
|
+
|
|
16
|
+
## Peer dependencies
|
|
17
|
+
|
|
18
|
+
This package is built for [Hono](https://hono.dev) + [Zod](https://zod.dev)
|
|
19
|
+
based Node.js services. The libraries below are declared as **peer
|
|
20
|
+
dependencies** and must be installed by the consuming application:
|
|
21
|
+
|
|
22
|
+
```bash
|
|
23
|
+
npm install hono zod @hono/zod-openapi @node-rs/argon2 es-toolkit pino stoker
|
|
24
|
+
```
|
|
25
|
+
|
|
26
|
+
> This package is **Node.js only** — it uses `node:crypto`, `node:fs` and
|
|
27
|
+
> `node:path`, and the Pino logger writes to the local filesystem.
|
|
28
|
+
|
|
29
|
+
## Usage
|
|
30
|
+
|
|
31
|
+
```typescript
|
|
32
|
+
import {
|
|
33
|
+
CryptoService,
|
|
34
|
+
TokenUtil,
|
|
35
|
+
buildPaginationResponse,
|
|
36
|
+
isProductionAppEnvironment,
|
|
37
|
+
} from '@mattrika/server-utils'
|
|
38
|
+
```
|
|
39
|
+
|
|
40
|
+
## API Reference
|
|
41
|
+
|
|
42
|
+
### `constants/` — Common values
|
|
43
|
+
|
|
44
|
+
Shared numeric constants.
|
|
45
|
+
|
|
46
|
+
| Export | Type | Value | Description |
|
|
47
|
+
| ------------------- | -------- | ----- | -------------------------------------- |
|
|
48
|
+
| `DEFAULT_PAGE_SIZE` | `number` | `20` | Default page size for paginated lists. |
|
|
49
|
+
| `MAX_PAGE_SIZE` | `number` | `100` | Maximum page size for paginated lists. |
|
|
50
|
+
|
|
51
|
+
---
|
|
52
|
+
|
|
53
|
+
### `pino-logger/` — Logging
|
|
54
|
+
|
|
55
|
+
#### `pinoLogger()`
|
|
56
|
+
|
|
57
|
+
Creates and returns a configured [Pino](https://getpino.io) logger.
|
|
58
|
+
|
|
59
|
+
| Environment | Transport |
|
|
60
|
+
| -------------- | -------------------------------- |
|
|
61
|
+
| production | `pino/file` → `./logs/app.log` |
|
|
62
|
+
| non-production | `pino-pretty` → colorized stdout |
|
|
63
|
+
|
|
64
|
+
- Ensures the `./logs` directory exists before writing (falls back to stdout if
|
|
65
|
+
the file transport fails to initialize).
|
|
66
|
+
- Log level is read from `PINO_LOG_LEVEL` (default `info`).
|
|
67
|
+
- Uses ISO timestamps and custom `pid`/`host`/`node_version` bindings.
|
|
68
|
+
- Redacts common secret fields (`authorization`, `cookie`, `password`,
|
|
69
|
+
`token`, `refreshToken`, `accessToken`, etc.) from request/response bodies,
|
|
70
|
+
replacing them with `[REDACTED]`.
|
|
71
|
+
|
|
72
|
+
**Returns:** `pino.Logger`
|
|
73
|
+
|
|
74
|
+
#### `pinoLoggerMiddleware()`
|
|
75
|
+
|
|
76
|
+
A Hono `MiddlewareHandler` that logs every request lifecycle event and attaches
|
|
77
|
+
a request-scoped child logger to the context.
|
|
78
|
+
|
|
79
|
+
- Creates a child logger with `requestId` (from `c.var['requestId']`) plus
|
|
80
|
+
method, path, query, headers and a safe copy of the body (non-`GET` only).
|
|
81
|
+
- Exposes the logger via `c.set('logger', childLogger)`.
|
|
82
|
+
- Logs `Incoming request` and `Request completed` (with status, duration and a
|
|
83
|
+
safe response body).
|
|
84
|
+
- Logs `HTTP error` (warn) for `HTTPException`s and `Unhandled server error`
|
|
85
|
+
(error) otherwise, re-throwing in both cases.
|
|
86
|
+
|
|
87
|
+
**Returns:** `MiddlewareHandler`
|
|
88
|
+
|
|
89
|
+
```typescript
|
|
90
|
+
import {
|
|
91
|
+
pinoLoggerMiddleware,
|
|
92
|
+
onErrorFn,
|
|
93
|
+
notFoundFn,
|
|
94
|
+
} from '@mattrika/server-utils'
|
|
95
|
+
import { Hono } from 'hono'
|
|
96
|
+
|
|
97
|
+
const app = new Hono()
|
|
98
|
+
app.use('*', pinoLoggerMiddleware())
|
|
99
|
+
app.onError(onErrorFn())
|
|
100
|
+
app.notFound(notFoundFn)
|
|
101
|
+
```
|
|
102
|
+
|
|
103
|
+
#### `safeResponseBody(res)`
|
|
104
|
+
|
|
105
|
+
Reads a cloned response body for logging without consuming the original stream.
|
|
106
|
+
Skips downloads (`content-disposition: attachment`) and non-JSON responses, and
|
|
107
|
+
truncates bodies larger than **1 MB**.
|
|
108
|
+
|
|
109
|
+
| Parameter | Type |
|
|
110
|
+
| --------- | ---------- |
|
|
111
|
+
| `res` | `Response` |
|
|
112
|
+
|
|
113
|
+
**Returns:** `Promise<unknown>` — parsed JSON, `undefined` for empty/invalid, or a
|
|
114
|
+
placeholder string such as `[NON-JSON RESPONSE]`, `[FILE DOWNLOAD SKIPPED]`,
|
|
115
|
+
`[RESPONSE TOO LARGE: <n> KB]`, `[UNREADABLE RESPONSE]`.
|
|
116
|
+
|
|
117
|
+
#### `safeRequestBody(req)`
|
|
118
|
+
|
|
119
|
+
Reads a cloned request body for logging. Skips `multipart/form-data` uploads
|
|
120
|
+
and non-JSON payloads, and truncates bodies larger than **1 MB**.
|
|
121
|
+
|
|
122
|
+
| Parameter | Type |
|
|
123
|
+
| --------- | ------------- |
|
|
124
|
+
| `req` | `HonoRequest` |
|
|
125
|
+
|
|
126
|
+
**Returns:** `Promise<unknown>` — parsed JSON, `undefined` for empty/non-JSON, or a
|
|
127
|
+
placeholder string such as `[FILE UPLOAD SKIPPED]`, `[BODY TOO LARGE: <n> KB]`,
|
|
128
|
+
`[UNREADABLE REQUEST BODY]`.
|
|
129
|
+
|
|
130
|
+
#### `ensureDirExists(filePath)`
|
|
131
|
+
|
|
132
|
+
Recursively creates the parent directory of a file path if it does not exist.
|
|
133
|
+
|
|
134
|
+
| Parameter | Type |
|
|
135
|
+
| ---------- | -------- |
|
|
136
|
+
| `filePath` | `string` |
|
|
137
|
+
|
|
138
|
+
**Returns:** `void`
|
|
139
|
+
|
|
140
|
+
---
|
|
141
|
+
|
|
142
|
+
### `predicates/` — Predicates
|
|
143
|
+
|
|
144
|
+
#### `isProductionAppEnvironment()`
|
|
145
|
+
|
|
146
|
+
Checks whether the app is running in production. It returns `true` when
|
|
147
|
+
`APP_ENV` equals `'production'`, otherwise it falls back to `NODE_ENV` (checked
|
|
148
|
+
in that order).
|
|
149
|
+
|
|
150
|
+
**Returns:** `boolean`
|
|
151
|
+
|
|
152
|
+
```typescript
|
|
153
|
+
isProductionAppEnvironment() // true when APP_ENV or NODE_ENV === 'production'
|
|
154
|
+
```
|
|
155
|
+
|
|
156
|
+
---
|
|
157
|
+
|
|
158
|
+
### `schemas/` — Zod schemas
|
|
159
|
+
|
|
160
|
+
Reusable [Zod](https://zod.dev) schemas built with `@hono/zod-openapi`.
|
|
161
|
+
|
|
162
|
+
| Export | Description |
|
|
163
|
+
| -------------- | ---------------------------------------------------------------------------------------------- |
|
|
164
|
+
| `zPagination` | `{ page?: number, size?: number }` — both coerced from strings. |
|
|
165
|
+
| `zCommonQuery` | `zPagination` plus `{ search?, orderBy?, sortOrder?: 'asc' \| 'desc' }`. |
|
|
166
|
+
| `zId` | `{ id: string }` |
|
|
167
|
+
| `zIds` | `{ ids: string[] }` with a minimum length of `1`. |
|
|
168
|
+
| `zPassword` | Password string (8–128 chars) requiring an uppercase letter, a number and a special character. |
|
|
169
|
+
| `zEmpty` | Empty object schema (`z.object({})`). |
|
|
170
|
+
| `zFile` | `{ file?: File, files?: File[] }`, exposed as OpenAPI binary strings. |
|
|
171
|
+
|
|
172
|
+
**Types**
|
|
173
|
+
|
|
174
|
+
| Export | Description |
|
|
175
|
+
| ------------------- | ------------------------------ |
|
|
176
|
+
| `CommonQueryParams` | `z.infer<typeof zCommonQuery>` |
|
|
177
|
+
| `PaginationParams` | `z.infer<typeof zPagination>` |
|
|
178
|
+
|
|
179
|
+
```typescript
|
|
180
|
+
import { zCommonQuery, zId, zPassword } from '@mattrika/server-utils'
|
|
181
|
+
```
|
|
182
|
+
|
|
183
|
+
---
|
|
184
|
+
|
|
185
|
+
### `services/` — Services
|
|
186
|
+
|
|
187
|
+
#### `CryptoService`
|
|
188
|
+
|
|
189
|
+
Static helpers for password hashing and secure random string generation.
|
|
190
|
+
|
|
191
|
+
##### `CryptoService.hash(plain)`
|
|
192
|
+
|
|
193
|
+
Hashes a plain-text value with **Argon2id** (64 MB memory, 3 iterations,
|
|
194
|
+
parallelism 1).
|
|
195
|
+
|
|
196
|
+
| Parameter | Type |
|
|
197
|
+
| --------- | -------- |
|
|
198
|
+
| `plain` | `string` |
|
|
199
|
+
|
|
200
|
+
**Returns:** `Promise<string>`
|
|
201
|
+
|
|
202
|
+
##### `CryptoService.verify(plain, hash)`
|
|
203
|
+
|
|
204
|
+
Verifies a plain-text value against an Argon2 hash.
|
|
205
|
+
|
|
206
|
+
| Parameter | Type |
|
|
207
|
+
| --------- | -------- |
|
|
208
|
+
| `plain` | `string` |
|
|
209
|
+
| `hash` | `string` |
|
|
210
|
+
|
|
211
|
+
**Returns:** `Promise<boolean>`
|
|
212
|
+
|
|
213
|
+
##### `CryptoService.random64chars()`
|
|
214
|
+
|
|
215
|
+
Generates a cryptographically secure random string of **64 hex characters**
|
|
216
|
+
(32 random bytes).
|
|
217
|
+
|
|
218
|
+
**Returns:** `string`
|
|
219
|
+
|
|
220
|
+
```typescript
|
|
221
|
+
import { CryptoService } from '@mattrika/server-utils'
|
|
222
|
+
|
|
223
|
+
const hash = await CryptoService.hash('s3cret')
|
|
224
|
+
await CryptoService.verify('s3cret', hash) // true
|
|
225
|
+
const sessionId = CryptoService.random64chars() // 64-char hex string
|
|
226
|
+
```
|
|
227
|
+
|
|
228
|
+
---
|
|
229
|
+
|
|
230
|
+
### `utils/` — HTTP & general utilities
|
|
231
|
+
|
|
232
|
+
#### `TokenUtil`
|
|
233
|
+
|
|
234
|
+
JWT helper built on `hono/jwt`. `exp` claims are seconds since the epoch, per the
|
|
235
|
+
JWT spec.
|
|
236
|
+
|
|
237
|
+
```typescript
|
|
238
|
+
constructor(secret: string, alg: SignatureAlgorithm = 'HS256')
|
|
239
|
+
```
|
|
240
|
+
|
|
241
|
+
| Member | Description | Returns |
|
|
242
|
+
| ------------------------------- | ---------------------------------------------------------------------------------------------- | -------------------- |
|
|
243
|
+
| `create(payload, lifeSeconds?)` | Signs a payload. Sets `exp` (in seconds) only when absent; default lifetime is `900` (15 min). | `Promise<string>` |
|
|
244
|
+
| `decode(token)` | Verifies and returns the payload. Throws on invalid/expired tokens. | `Promise<T \| null>` |
|
|
245
|
+
| `decodeUnexpired(token)` | Verifies without Hono's built-in `exp` check and returns the payload, or `null` if expired. | `Promise<T \| null>` |
|
|
246
|
+
|
|
247
|
+
All methods are generic over `T extends JWTPayload`.
|
|
248
|
+
|
|
249
|
+
```typescript
|
|
250
|
+
import { TokenUtil } from '@mattrika/server-utils'
|
|
251
|
+
|
|
252
|
+
const tokens = new TokenUtil(process.env['JWT_SECRET']!, 'HS256')
|
|
253
|
+
|
|
254
|
+
const token = await tokens.create({ sub: user.id }, 60 * 15)
|
|
255
|
+
const payload = await tokens.decodeUnexpired<{ sub: string }>(token)
|
|
256
|
+
if (!payload) {
|
|
257
|
+
// token is expired or invalid
|
|
258
|
+
}
|
|
259
|
+
```
|
|
260
|
+
|
|
261
|
+
#### `throwHttpError(error, defaultMessage?)`
|
|
262
|
+
|
|
263
|
+
Throws a Hono `HTTPException`. The status code is taken from the error's
|
|
264
|
+
`cause` when it is a number, otherwise `500`. The message defaults to
|
|
265
|
+
`'Something went wrong'`.
|
|
266
|
+
|
|
267
|
+
| Parameter | Type | Default |
|
|
268
|
+
| ---------------- | --------- | ------------------------ |
|
|
269
|
+
| `error` | `unknown` | — |
|
|
270
|
+
| `defaultMessage` | `string` | `'Something went wrong'` |
|
|
271
|
+
|
|
272
|
+
**Returns:** `never`
|
|
273
|
+
|
|
274
|
+
#### `isUniqueViolationPg(error)`
|
|
275
|
+
|
|
276
|
+
Returns `true` when a PostgreSQL error is a unique-constraint violation
|
|
277
|
+
(SQLSTATE `23505`).
|
|
278
|
+
|
|
279
|
+
| Parameter | Type |
|
|
280
|
+
| --------- | --------- |
|
|
281
|
+
| `error` | `unknown` |
|
|
282
|
+
|
|
283
|
+
**Returns:** `boolean`
|
|
284
|
+
|
|
285
|
+
#### `onErrorFn(debugLog?)`
|
|
286
|
+
|
|
287
|
+
Creates a Hono `ErrorHandler` that logs via the context logger (set by
|
|
288
|
+
`pinoLoggerMiddleware`) and responds with a JSON error envelope
|
|
289
|
+
(`{ data: null, error, message, success: false }`). The status code is derived
|
|
290
|
+
from `err.status` or `err.cause`, falling back to `500`. When `debugLog` is
|
|
291
|
+
`true`, the error is also printed and included in the response body.
|
|
292
|
+
|
|
293
|
+
| Parameter | Type | Default |
|
|
294
|
+
| ---------- | --------- | ------- |
|
|
295
|
+
| `debugLog` | `boolean` | `true` |
|
|
296
|
+
|
|
297
|
+
**Returns:** `ErrorHandler`
|
|
298
|
+
|
|
299
|
+
#### `notFoundFn`
|
|
300
|
+
|
|
301
|
+
A Hono `NotFoundHandler` that responds `404` with a JSON envelope including the
|
|
302
|
+
requested path.
|
|
303
|
+
|
|
304
|
+
```typescript
|
|
305
|
+
c.json(
|
|
306
|
+
{
|
|
307
|
+
success: false,
|
|
308
|
+
error: 'Not Found',
|
|
309
|
+
data: {},
|
|
310
|
+
message: `Not Found - ${path}`,
|
|
311
|
+
},
|
|
312
|
+
404,
|
|
313
|
+
)
|
|
314
|
+
```
|
|
315
|
+
|
|
316
|
+
#### `validationHook(result, _c)`
|
|
317
|
+
|
|
318
|
+
A `@hono/zod-openapi` validation hook. When validation fails
|
|
319
|
+
(`result.success === false`), it throws an HTTP `422 Unprocessable Entity`
|
|
320
|
+
error via `throwHttpError`.
|
|
321
|
+
|
|
322
|
+
| Parameter | Type |
|
|
323
|
+
| --------- | --------- |
|
|
324
|
+
| `result` | `any` |
|
|
325
|
+
| `_c` | `Context` |
|
|
326
|
+
|
|
327
|
+
**Returns:** `void`
|
|
328
|
+
|
|
329
|
+
#### `buildPaginationResponse(page, size, total)`
|
|
330
|
+
|
|
331
|
+
Builds a normalized pagination envelope from raw query values.
|
|
332
|
+
|
|
333
|
+
- `page` is clamped to a minimum of `1`.
|
|
334
|
+
- `size` is clamped to a minimum of `DEFAULT_PAGE_SIZE` (`20`).
|
|
335
|
+
- `total` is clamped to a minimum of `0`.
|
|
336
|
+
- `totalPages` is computed as `ceil(total / size)`.
|
|
337
|
+
|
|
338
|
+
| Parameter | Type |
|
|
339
|
+
| --------- | --------------------- |
|
|
340
|
+
| `page` | `number \| undefined` |
|
|
341
|
+
| `size` | `number \| undefined` |
|
|
342
|
+
| `total` | `number` |
|
|
343
|
+
|
|
344
|
+
**Returns:** `ResponsePagination` — `{ page, size, totalItems, totalPages }`
|
|
345
|
+
|
|
346
|
+
```typescript
|
|
347
|
+
import { buildPaginationResponse } from '@mattrika/server-utils'
|
|
348
|
+
|
|
349
|
+
buildPaginationResponse(2, 50, 213)
|
|
350
|
+
// { page: 2, size: 50, totalItems: 213, totalPages: 5 }
|
|
351
|
+
```
|
|
352
|
+
|
|
353
|
+
#### API response helpers (`api-response.util`)
|
|
354
|
+
|
|
355
|
+
Helpers for describing OpenAPI responses with `@hono/zod-openapi`.
|
|
356
|
+
|
|
357
|
+
| Export | Description |
|
|
358
|
+
| ------------------------------------------ | -------------------------------------------------------------------------------------------- |
|
|
359
|
+
| `ZodSchema` | Type alias: `ZodObject<any> \| ZodArray<ZodObject<any>>`. |
|
|
360
|
+
| `zResponsePagination` | Zod schema `{ page, size, totalItems, totalPages }`. |
|
|
361
|
+
| `ResponsePagination` | `z.infer<typeof zResponsePagination>`. |
|
|
362
|
+
| `ApiResponse(dataSchema, description)` | Builds an OpenAPI response object with `{ data, message, success, error?, meta? }`. |
|
|
363
|
+
| `ApiListResponse(dataSchema, description)` | Same as `ApiResponse`, plus a required `pagination` field. |
|
|
364
|
+
| `zStreamResponse` | Zod schema for a `ReadableStream` body with optional `Content-Type` / `Content-Disposition`. |
|
|
365
|
+
| `StreamResponse(description)` | Builds an OpenAPI `application/octet-stream` (stream) response description. |
|
|
366
|
+
| `ApiPdfResponse(description)` | Builds an OpenAPI `application/pdf` response description. |
|
|
367
|
+
|
|
368
|
+
```typescript
|
|
369
|
+
import {
|
|
370
|
+
ApiResponse,
|
|
371
|
+
ApiListResponse,
|
|
372
|
+
zId,
|
|
373
|
+
zResponsePagination,
|
|
374
|
+
} from '@mattrika/server-utils'
|
|
375
|
+
|
|
376
|
+
const getUser = {
|
|
377
|
+
responses: { 200: ApiResponse(zId, 'User') },
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
const listUsers = {
|
|
381
|
+
responses: { 200: ApiListResponse(zId, 'Users') },
|
|
382
|
+
}
|
|
383
|
+
```
|
|
384
|
+
|
|
385
|
+
## License
|
|
386
|
+
|
|
387
|
+
MIT
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
exports._default = require('./index.cjs.js').default;
|