@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 CHANGED
@@ -1,3 +1,387 @@
1
- # Temporary Holding Version
1
+ # @mattrika/server-utils
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
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;