@veloxts/core 0.7.5 → 0.7.7

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/CHANGELOG.md CHANGED
@@ -1,5 +1,17 @@
1
1
  # @veloxts/core
2
2
 
3
+ ## 0.7.7
4
+
5
+ ### Patch Changes
6
+
7
+ - refactor(router): rename swaggerUIPlugin → swaggerPlugin, remove redundant exports
8
+
9
+ ## 0.7.6
10
+
11
+ ### Patch Changes
12
+
13
+ - feat(router): custom access levels for the Resource API + advanced Architectural Patterns
14
+
3
15
  ## 0.7.5
4
16
 
5
17
  ### Patch Changes
package/dist/app.js CHANGED
@@ -6,7 +6,7 @@
6
6
  import fastify from 'fastify';
7
7
  import fp from 'fastify-plugin';
8
8
  import { setupContextHook } from './context.js';
9
- import { isVeloxError, VeloxError } from './errors.js';
9
+ import { ConflictError, isVeloxError, VeloxError } from './errors.js';
10
10
  import { isFastifyPlugin, isVeloxPlugin, validatePluginMetadata } from './plugin.js';
11
11
  import { requestLogger } from './plugins/request-logger.js';
12
12
  import { registerStatic } from './plugins/static.js';
@@ -136,13 +136,13 @@ export class VeloxApp {
136
136
  if (error.name === 'PrismaClientKnownRequestError' &&
137
137
  'code' in error &&
138
138
  error.code === 'P2002') {
139
- const prismaError = error;
140
- const fields = prismaError.meta?.target?.join(', ') ?? 'field';
141
- return reply.status(409).send({
142
- error: 'ConflictError',
143
- message: `A record with this ${fields} already exists`,
144
- statusCode: 409,
145
- });
139
+ const meta = 'meta' in error && typeof error.meta === 'object' && error.meta !== null
140
+ ? error.meta
141
+ : undefined;
142
+ const target = Array.isArray(meta?.target) ? meta.target : [];
143
+ const fieldNames = target.length > 0 ? target.join(', ') : 'field';
144
+ const conflictError = new ConflictError(`A record with this ${fieldNames} already exists`, target.length > 0 ? target : undefined);
145
+ return reply.status(conflictError.statusCode).send(conflictError.toJSON());
146
146
  }
147
147
  let statusCode = 500;
148
148
  if (isVeloxError(error)) {
@@ -415,6 +415,43 @@ await app.register(databasePlugin({ client: prisma }));
415
415
  },
416
416
  docsUrl: 'https://veloxts.dev/errors/VELOX-4003',
417
417
  },
418
+ 'VELOX-4004': {
419
+ code: 'VELOX-4004',
420
+ title: 'Unique Constraint Violation',
421
+ description: 'A database operation failed because it would create a duplicate record.',
422
+ statusCode: 409,
423
+ fix: {
424
+ suggestion: 'Check for existing records before creating, or use upsert.',
425
+ example: `// Use upsert to handle duplicates gracefully
426
+ const user = await prisma.user.upsert({
427
+ where: { email: input.email },
428
+ create: input,
429
+ update: input,
430
+ });
431
+
432
+ // Or use the db() wrapper from @veloxts/orm
433
+ import { db } from '@veloxts/orm';
434
+ const user = await db(() => prisma.user.create({ data: input }));
435
+ // Automatically throws ConflictError with field names`,
436
+ },
437
+ docsUrl: 'https://veloxts.dev/errors/VELOX-4004',
438
+ },
439
+ 'VELOX-4005': {
440
+ code: 'VELOX-4005',
441
+ title: 'Foreign Key Constraint Violation',
442
+ description: 'A database operation failed because a related record does not exist.',
443
+ statusCode: 400,
444
+ fix: {
445
+ suggestion: 'Verify that all referenced records exist before creating or updating.',
446
+ example: `// Check related record exists first
447
+ const post = await prisma.post.findUnique({ where: { id: input.postId } });
448
+ if (!post) throw new NotFoundError('Post', input.postId);
449
+
450
+ // Then create the comment
451
+ const comment = await prisma.comment.create({ data: input });`,
452
+ },
453
+ docsUrl: 'https://veloxts.dev/errors/VELOX-4005',
454
+ },
418
455
  // ==========================================================================
419
456
  // VALIDATION ERRORS (5XXX)
420
457
  // ==========================================================================
package/dist/errors.d.ts CHANGED
@@ -8,7 +8,7 @@ export { ERROR_CATALOG, ERROR_DOMAINS, type ErrorCatalogEntry, type ErrorCode, t
8
8
  * Known error codes in the VeloxTS framework core
9
9
  * Can be extended via declaration merging by plugins
10
10
  */
11
- export type VeloxCoreErrorCode = 'VALIDATION_ERROR' | 'NOT_FOUND' | 'CONFIGURATION_ERROR' | 'PLUGIN_REGISTRATION_ERROR' | 'SERVER_ALREADY_RUNNING' | 'SERVER_NOT_RUNNING' | 'SERVER_START_ERROR' | 'SERVER_STOP_ERROR' | 'INVALID_PLUGIN_METADATA';
11
+ export type VeloxCoreErrorCode = 'VALIDATION_ERROR' | 'NOT_FOUND' | 'CONFLICT' | 'FORBIDDEN' | 'UNAUTHORIZED' | 'SERVICE_UNAVAILABLE' | 'RATE_LIMITED' | 'UNPROCESSABLE' | 'CONFIGURATION_ERROR' | 'PLUGIN_REGISTRATION_ERROR' | 'SERVER_ALREADY_RUNNING' | 'SERVER_NOT_RUNNING' | 'SERVER_START_ERROR' | 'SERVER_STOP_ERROR' | 'INVALID_PLUGIN_METADATA';
12
12
  /**
13
13
  * Registry for error codes - allows plugins to extend via declaration merging
14
14
  *
@@ -64,6 +64,56 @@ export interface NotFoundErrorResponse extends BaseErrorResponse {
64
64
  /** Optional identifier of the resource */
65
65
  resourceId?: string;
66
66
  }
67
+ /**
68
+ * Conflict error response with field details
69
+ */
70
+ export interface ConflictErrorResponse extends BaseErrorResponse {
71
+ error: 'ConflictError';
72
+ statusCode: 409;
73
+ code: 'CONFLICT';
74
+ fields?: string[];
75
+ }
76
+ /**
77
+ * Too many requests error response with retry timing
78
+ */
79
+ export interface TooManyRequestsErrorResponse extends BaseErrorResponse {
80
+ error: 'TooManyRequestsError';
81
+ statusCode: 429;
82
+ code: 'RATE_LIMITED';
83
+ retryAfter?: number;
84
+ }
85
+ /**
86
+ * Forbidden error response
87
+ */
88
+ export interface ForbiddenErrorResponse extends BaseErrorResponse {
89
+ error: 'ForbiddenError';
90
+ statusCode: 403;
91
+ code: 'FORBIDDEN';
92
+ }
93
+ /**
94
+ * Unauthorized error response
95
+ */
96
+ export interface UnauthorizedErrorResponse extends BaseErrorResponse {
97
+ error: 'UnauthorizedError';
98
+ statusCode: 401;
99
+ code: 'UNAUTHORIZED';
100
+ }
101
+ /**
102
+ * Service unavailable error response
103
+ */
104
+ export interface ServiceUnavailableErrorResponse extends BaseErrorResponse {
105
+ error: 'ServiceUnavailableError';
106
+ statusCode: 503;
107
+ code: 'SERVICE_UNAVAILABLE';
108
+ }
109
+ /**
110
+ * Unprocessable entity error response
111
+ */
112
+ export interface UnprocessableEntityErrorResponse extends BaseErrorResponse {
113
+ error: 'UnprocessableEntityError';
114
+ statusCode: 422;
115
+ code: 'UNPROCESSABLE';
116
+ }
67
117
  /**
68
118
  * Generic VeloxTS error response for all other errors
69
119
  */
@@ -86,7 +136,7 @@ export interface GenericErrorResponse extends BaseErrorResponse {
86
136
  * }
87
137
  * ```
88
138
  */
89
- export type ErrorResponse = ValidationErrorResponse | NotFoundErrorResponse | GenericErrorResponse;
139
+ export type ErrorResponse = ValidationErrorResponse | NotFoundErrorResponse | ConflictErrorResponse | TooManyRequestsErrorResponse | ForbiddenErrorResponse | UnauthorizedErrorResponse | ServiceUnavailableErrorResponse | UnprocessableEntityErrorResponse | GenericErrorResponse;
90
140
  /**
91
141
  * Type guard for validation error responses
92
142
  */
@@ -272,6 +322,95 @@ export declare class NotFoundError extends VeloxError<'NOT_FOUND'> {
272
322
  */
273
323
  toJSON(): NotFoundErrorResponse;
274
324
  }
325
+ /**
326
+ * Conflict error for duplicate resources
327
+ *
328
+ * Used when an operation would violate a uniqueness constraint
329
+ * (e.g., creating a user with an email that already exists)
330
+ *
331
+ * @example
332
+ * ```typescript
333
+ * throw new ConflictError('A user with this email already exists', ['email']);
334
+ * ```
335
+ */
336
+ export declare class ConflictError extends VeloxError<'CONFLICT'> {
337
+ readonly fields?: string[];
338
+ constructor(message: string, fields?: string[]);
339
+ toJSON(): ConflictErrorResponse;
340
+ }
341
+ /**
342
+ * Forbidden error for insufficient permissions
343
+ *
344
+ * Used when an authenticated user lacks the required permissions
345
+ * for an operation (different from UnauthorizedError which means not authenticated)
346
+ *
347
+ * @example
348
+ * ```typescript
349
+ * throw new ForbiddenError('You do not have permission to delete this resource');
350
+ * ```
351
+ */
352
+ export declare class ForbiddenError extends VeloxError<'FORBIDDEN'> {
353
+ constructor(message?: string);
354
+ toJSON(): ForbiddenErrorResponse;
355
+ }
356
+ /**
357
+ * Unauthorized error for missing or invalid authentication
358
+ *
359
+ * Used when a request lacks valid authentication credentials
360
+ *
361
+ * @example
362
+ * ```typescript
363
+ * throw new UnauthorizedError('Invalid or expired token');
364
+ * ```
365
+ */
366
+ export declare class UnauthorizedError extends VeloxError<'UNAUTHORIZED'> {
367
+ constructor(message?: string);
368
+ toJSON(): UnauthorizedErrorResponse;
369
+ }
370
+ /**
371
+ * Service unavailable error for downstream failures
372
+ *
373
+ * Used when an external service or dependency is unreachable
374
+ *
375
+ * @example
376
+ * ```typescript
377
+ * throw new ServiceUnavailableError('Payment gateway is temporarily unavailable');
378
+ * ```
379
+ */
380
+ export declare class ServiceUnavailableError extends VeloxError<'SERVICE_UNAVAILABLE'> {
381
+ constructor(message?: string);
382
+ toJSON(): ServiceUnavailableErrorResponse;
383
+ }
384
+ /**
385
+ * Too many requests error for rate limiting
386
+ *
387
+ * Used when a client exceeds the allowed request rate
388
+ *
389
+ * @example
390
+ * ```typescript
391
+ * throw new TooManyRequestsError('Rate limit exceeded', 60);
392
+ * ```
393
+ */
394
+ export declare class TooManyRequestsError extends VeloxError<'RATE_LIMITED'> {
395
+ readonly retryAfter?: number;
396
+ constructor(message?: string, retryAfter?: number);
397
+ toJSON(): TooManyRequestsErrorResponse;
398
+ }
399
+ /**
400
+ * Unprocessable entity error for semantically invalid requests
401
+ *
402
+ * Used when the request is syntactically valid but semantically wrong
403
+ * (e.g., trying to publish a draft that has no content)
404
+ *
405
+ * @example
406
+ * ```typescript
407
+ * throw new UnprocessableEntityError('Cannot publish a post without content');
408
+ * ```
409
+ */
410
+ export declare class UnprocessableEntityError extends VeloxError<'UNPROCESSABLE'> {
411
+ constructor(message: string);
412
+ toJSON(): UnprocessableEntityErrorResponse;
413
+ }
275
414
  /**
276
415
  * Type guard to check if an error is a VeloxError
277
416
  *
@@ -311,6 +450,30 @@ export declare function isNotFoundError(error: unknown): error is NotFoundError;
311
450
  * @returns true if error is a ConfigurationError instance
312
451
  */
313
452
  export declare function isConfigurationError(error: unknown): error is ConfigurationError;
453
+ /**
454
+ * Type guard to check if an error is a ConflictError
455
+ */
456
+ export declare function isConflictError(error: unknown): error is ConflictError;
457
+ /**
458
+ * Type guard to check if an error is a ForbiddenError
459
+ */
460
+ export declare function isForbiddenError(error: unknown): error is ForbiddenError;
461
+ /**
462
+ * Type guard to check if an error is an UnauthorizedError
463
+ */
464
+ export declare function isUnauthorizedError(error: unknown): error is UnauthorizedError;
465
+ /**
466
+ * Type guard to check if an error is a ServiceUnavailableError
467
+ */
468
+ export declare function isServiceUnavailableError(error: unknown): error is ServiceUnavailableError;
469
+ /**
470
+ * Type guard to check if an error is a TooManyRequestsError
471
+ */
472
+ export declare function isTooManyRequestsError(error: unknown): error is TooManyRequestsError;
473
+ /**
474
+ * Type guard to check if an error is an UnprocessableEntityError
475
+ */
476
+ export declare function isUnprocessableEntityError(error: unknown): error is UnprocessableEntityError;
314
477
  /**
315
478
  * Helper to ensure exhaustive handling of error types
316
479
  * Throws at compile time if a case is not handled
package/dist/errors.js CHANGED
@@ -271,6 +271,177 @@ export class NotFoundError extends VeloxError {
271
271
  };
272
272
  }
273
273
  }
274
+ /**
275
+ * Conflict error for duplicate resources
276
+ *
277
+ * Used when an operation would violate a uniqueness constraint
278
+ * (e.g., creating a user with an email that already exists)
279
+ *
280
+ * @example
281
+ * ```typescript
282
+ * throw new ConflictError('A user with this email already exists', ['email']);
283
+ * ```
284
+ */
285
+ export class ConflictError extends VeloxError {
286
+ fields;
287
+ constructor(message, fields) {
288
+ super(message, 409, 'CONFLICT');
289
+ this.name = 'ConflictError';
290
+ this.fields = fields;
291
+ if (Error.captureStackTrace) {
292
+ Error.captureStackTrace(this, ConflictError);
293
+ }
294
+ }
295
+ toJSON() {
296
+ return {
297
+ error: 'ConflictError',
298
+ message: this.message,
299
+ statusCode: 409,
300
+ code: 'CONFLICT',
301
+ fields: this.fields,
302
+ };
303
+ }
304
+ }
305
+ /**
306
+ * Forbidden error for insufficient permissions
307
+ *
308
+ * Used when an authenticated user lacks the required permissions
309
+ * for an operation (different from UnauthorizedError which means not authenticated)
310
+ *
311
+ * @example
312
+ * ```typescript
313
+ * throw new ForbiddenError('You do not have permission to delete this resource');
314
+ * ```
315
+ */
316
+ export class ForbiddenError extends VeloxError {
317
+ constructor(message = 'Forbidden') {
318
+ super(message, 403, 'FORBIDDEN');
319
+ this.name = 'ForbiddenError';
320
+ if (Error.captureStackTrace) {
321
+ Error.captureStackTrace(this, ForbiddenError);
322
+ }
323
+ }
324
+ toJSON() {
325
+ return {
326
+ error: 'ForbiddenError',
327
+ message: this.message,
328
+ statusCode: 403,
329
+ code: 'FORBIDDEN',
330
+ };
331
+ }
332
+ }
333
+ /**
334
+ * Unauthorized error for missing or invalid authentication
335
+ *
336
+ * Used when a request lacks valid authentication credentials
337
+ *
338
+ * @example
339
+ * ```typescript
340
+ * throw new UnauthorizedError('Invalid or expired token');
341
+ * ```
342
+ */
343
+ export class UnauthorizedError extends VeloxError {
344
+ constructor(message = 'Unauthorized') {
345
+ super(message, 401, 'UNAUTHORIZED');
346
+ this.name = 'UnauthorizedError';
347
+ if (Error.captureStackTrace) {
348
+ Error.captureStackTrace(this, UnauthorizedError);
349
+ }
350
+ }
351
+ toJSON() {
352
+ return {
353
+ error: 'UnauthorizedError',
354
+ message: this.message,
355
+ statusCode: 401,
356
+ code: 'UNAUTHORIZED',
357
+ };
358
+ }
359
+ }
360
+ /**
361
+ * Service unavailable error for downstream failures
362
+ *
363
+ * Used when an external service or dependency is unreachable
364
+ *
365
+ * @example
366
+ * ```typescript
367
+ * throw new ServiceUnavailableError('Payment gateway is temporarily unavailable');
368
+ * ```
369
+ */
370
+ export class ServiceUnavailableError extends VeloxError {
371
+ constructor(message = 'Service unavailable') {
372
+ super(message, 503, 'SERVICE_UNAVAILABLE');
373
+ this.name = 'ServiceUnavailableError';
374
+ if (Error.captureStackTrace) {
375
+ Error.captureStackTrace(this, ServiceUnavailableError);
376
+ }
377
+ }
378
+ toJSON() {
379
+ return {
380
+ error: 'ServiceUnavailableError',
381
+ message: this.message,
382
+ statusCode: 503,
383
+ code: 'SERVICE_UNAVAILABLE',
384
+ };
385
+ }
386
+ }
387
+ /**
388
+ * Too many requests error for rate limiting
389
+ *
390
+ * Used when a client exceeds the allowed request rate
391
+ *
392
+ * @example
393
+ * ```typescript
394
+ * throw new TooManyRequestsError('Rate limit exceeded', 60);
395
+ * ```
396
+ */
397
+ export class TooManyRequestsError extends VeloxError {
398
+ retryAfter;
399
+ constructor(message = 'Too many requests', retryAfter) {
400
+ super(message, 429, 'RATE_LIMITED');
401
+ this.name = 'TooManyRequestsError';
402
+ this.retryAfter = retryAfter;
403
+ if (Error.captureStackTrace) {
404
+ Error.captureStackTrace(this, TooManyRequestsError);
405
+ }
406
+ }
407
+ toJSON() {
408
+ return {
409
+ error: 'TooManyRequestsError',
410
+ message: this.message,
411
+ statusCode: 429,
412
+ code: 'RATE_LIMITED',
413
+ retryAfter: this.retryAfter,
414
+ };
415
+ }
416
+ }
417
+ /**
418
+ * Unprocessable entity error for semantically invalid requests
419
+ *
420
+ * Used when the request is syntactically valid but semantically wrong
421
+ * (e.g., trying to publish a draft that has no content)
422
+ *
423
+ * @example
424
+ * ```typescript
425
+ * throw new UnprocessableEntityError('Cannot publish a post without content');
426
+ * ```
427
+ */
428
+ export class UnprocessableEntityError extends VeloxError {
429
+ constructor(message) {
430
+ super(message, 422, 'UNPROCESSABLE');
431
+ this.name = 'UnprocessableEntityError';
432
+ if (Error.captureStackTrace) {
433
+ Error.captureStackTrace(this, UnprocessableEntityError);
434
+ }
435
+ }
436
+ toJSON() {
437
+ return {
438
+ error: 'UnprocessableEntityError',
439
+ message: this.message,
440
+ statusCode: 422,
441
+ code: 'UNPROCESSABLE',
442
+ };
443
+ }
444
+ }
274
445
  // ============================================================================
275
446
  // Type Guards
276
447
  // ============================================================================
@@ -321,6 +492,42 @@ export function isNotFoundError(error) {
321
492
  export function isConfigurationError(error) {
322
493
  return error instanceof ConfigurationError;
323
494
  }
495
+ /**
496
+ * Type guard to check if an error is a ConflictError
497
+ */
498
+ export function isConflictError(error) {
499
+ return error instanceof ConflictError;
500
+ }
501
+ /**
502
+ * Type guard to check if an error is a ForbiddenError
503
+ */
504
+ export function isForbiddenError(error) {
505
+ return error instanceof ForbiddenError;
506
+ }
507
+ /**
508
+ * Type guard to check if an error is an UnauthorizedError
509
+ */
510
+ export function isUnauthorizedError(error) {
511
+ return error instanceof UnauthorizedError;
512
+ }
513
+ /**
514
+ * Type guard to check if an error is a ServiceUnavailableError
515
+ */
516
+ export function isServiceUnavailableError(error) {
517
+ return error instanceof ServiceUnavailableError;
518
+ }
519
+ /**
520
+ * Type guard to check if an error is a TooManyRequestsError
521
+ */
522
+ export function isTooManyRequestsError(error) {
523
+ return error instanceof TooManyRequestsError;
524
+ }
525
+ /**
526
+ * Type guard to check if an error is an UnprocessableEntityError
527
+ */
528
+ export function isUnprocessableEntityError(error) {
529
+ return error instanceof UnprocessableEntityError;
530
+ }
324
531
  // ============================================================================
325
532
  // Utility Types
326
533
  // ============================================================================
package/dist/index.d.ts CHANGED
@@ -21,16 +21,21 @@ export type { StartOptions } from './app.js';
21
21
  export { VeloxApp, velox, veloxApp } from './app.js';
22
22
  export type { BaseContext } from './context.js';
23
23
  export { createContext, isContext, setupContextHook, setupTestContext } from './context.js';
24
- export type { ErrorCode, ErrorResponse, GenericErrorResponse, InterpolationVars, NotFoundErrorResponse, ValidationErrorResponse, VeloxCoreErrorCode, VeloxErrorCode, VeloxErrorCodeRegistry, } from './errors.js';
25
- export { assertNever, ConfigurationError, fail, isConfigurationError, isNotFoundError, isNotFoundErrorResponse, isValidationError, isValidationErrorResponse, isVeloxError, isVeloxFailure, logDeprecation, logWarning, NotFoundError, ValidationError, VeloxError, VeloxFailure, } from './errors.js';
26
- export type { InferPluginOptions, PluginMetadata, PluginOptions, VeloxPlugin } from './plugin.js';
27
- export { definePlugin, isFastifyPlugin, isVeloxPlugin, validatePluginMetadata } from './plugin.js';
24
+ export type { ConflictErrorResponse, ErrorCode, ErrorResponse, ForbiddenErrorResponse, GenericErrorResponse, InterpolationVars, NotFoundErrorResponse, ServiceUnavailableErrorResponse, TooManyRequestsErrorResponse, UnauthorizedErrorResponse, UnprocessableEntityErrorResponse, ValidationErrorResponse, VeloxCoreErrorCode, VeloxErrorCode, VeloxErrorCodeRegistry, } from './errors.js';
25
+ export { assertNever, ConfigurationError, ConflictError, ForbiddenError, fail, isConfigurationError, isConflictError, isForbiddenError, isNotFoundError, isNotFoundErrorResponse, isServiceUnavailableError, isTooManyRequestsError, isUnauthorizedError, isUnprocessableEntityError, isValidationError, isValidationErrorResponse, isVeloxError, isVeloxFailure, logDeprecation, logWarning, NotFoundError, ServiceUnavailableError, TooManyRequestsError, UnauthorizedError, UnprocessableEntityError, ValidationError, VeloxError, VeloxFailure, } from './errors.js';
26
+ export type { ContextPluginConfig, InferPluginOptions, PluginMetadata, PluginOptions, VeloxPlugin, } from './plugin.js';
27
+ export { defineContextPlugin, definePlugin, isFastifyPlugin, isVeloxPlugin, validatePluginMetadata, } from './plugin.js';
28
28
  export type { AsyncHandler, JsonArray, JsonObject, JsonPrimitive, JsonValue, LifecycleHook, ShutdownHandler, SyncHandler, } from './types.js';
29
29
  export type { FrozenVeloxAppConfig, ValidHost, ValidPort, VeloxAppConfig, VeloxFastifyOptions, } from './utils/config.js';
30
30
  export { isValidHost, isValidPort } from './utils/config.js';
31
31
  export type { AuthContextExtension, CombineContexts, ContextExtension, CoreContext, DbContextExtension, defineContext, MergeContext, SessionContextExtension, } from './typed-context.js';
32
32
  export type { CacheControl, StaticOptions } from './plugins/static.js';
33
33
  export { registerStatic } from './plugins/static.js';
34
+ export { rawBodyPlugin } from './plugins/raw-body.js';
34
35
  export { requestLogger } from './plugins/request-logger.js';
35
36
  export type { Logger, LogLevel } from './utils/logger.js';
36
37
  export { createLogger } from './utils/logger.js';
38
+ export type { FireAndForgetOptions } from './utils/fire-and-forget.js';
39
+ export { fireAndForget } from './utils/fire-and-forget.js';
40
+ export type { RetryOptions } from './utils/retry.js';
41
+ export { withRetry } from './utils/retry.js';
package/dist/index.js CHANGED
@@ -21,12 +21,18 @@ const packageJson = require('../package.json');
21
21
  export const VELOX_VERSION = packageJson.version ?? '0.0.0-unknown';
22
22
  export { VeloxApp, velox, veloxApp } from './app.js';
23
23
  export { createContext, isContext, setupContextHook, setupTestContext } from './context.js';
24
- export { assertNever, ConfigurationError, fail, isConfigurationError, isNotFoundError, isNotFoundErrorResponse, isValidationError, isValidationErrorResponse, isVeloxError, isVeloxFailure, logDeprecation, logWarning, NotFoundError, ValidationError, VeloxError, VeloxFailure, } from './errors.js';
25
- export { definePlugin, isFastifyPlugin, isVeloxPlugin, validatePluginMetadata } from './plugin.js';
24
+ export { assertNever, ConfigurationError, ConflictError, ForbiddenError, fail, isConfigurationError, isConflictError, isForbiddenError, isNotFoundError, isNotFoundErrorResponse, isServiceUnavailableError, isTooManyRequestsError, isUnauthorizedError, isUnprocessableEntityError, isValidationError, isValidationErrorResponse, isVeloxError, isVeloxFailure, logDeprecation, logWarning, NotFoundError, ServiceUnavailableError, TooManyRequestsError, UnauthorizedError, UnprocessableEntityError, ValidationError, VeloxError, VeloxFailure, } from './errors.js';
25
+ export { defineContextPlugin, definePlugin, isFastifyPlugin, isVeloxPlugin, validatePluginMetadata, } from './plugin.js';
26
26
  export { isValidHost, isValidPort } from './utils/config.js';
27
27
  export { registerStatic } from './plugins/static.js';
28
28
  // ============================================================================
29
+ // Raw Body (Webhook Support)
30
+ // ============================================================================
31
+ export { rawBodyPlugin } from './plugins/raw-body.js';
32
+ // ============================================================================
29
33
  // Request Logging (Development)
30
34
  // ============================================================================
31
35
  export { requestLogger } from './plugins/request-logger.js';
32
36
  export { createLogger } from './utils/logger.js';
37
+ export { fireAndForget } from './utils/fire-and-forget.js';
38
+ export { withRetry } from './utils/retry.js';
package/dist/plugin.d.ts CHANGED
@@ -172,6 +172,58 @@ export declare function isVeloxPlugin(value: unknown): value is VeloxPlugin;
172
172
  * ```
173
173
  */
174
174
  export declare function isFastifyPlugin<Options extends PluginOptions = PluginOptions>(value: unknown): value is FastifyPluginAsync<Options>;
175
+ /**
176
+ * Configuration for a context-injecting plugin
177
+ *
178
+ * @template TService - The type of service to inject into the context
179
+ */
180
+ export interface ContextPluginConfig<TService> {
181
+ /** Plugin name (e.g., '@myapp/analytics') */
182
+ name: string;
183
+ /** Plugin version (semver) */
184
+ version: string;
185
+ /** Context key used to access the service (e.g., 'analytics' for ctx.analytics) */
186
+ contextKey: string;
187
+ /** Factory function to create the service instance */
188
+ create: () => TService | Promise<TService>;
189
+ /** Optional cleanup function called on server shutdown */
190
+ close?: (service: TService) => void | Promise<void>;
191
+ }
192
+ /**
193
+ * Creates a plugin that injects a service into the request context
194
+ *
195
+ * Eliminates the runtime boilerplate of `Symbol.for()`, `decorateRequest()`,
196
+ * `addHook('onRequest')`, and `addHook('onClose')` that every
197
+ * context-injecting plugin must implement.
198
+ *
199
+ * **Note on types:** You still need `declare module` for TypeScript to
200
+ * know about the context property. This helper handles the runtime
201
+ * injection; declaration merging handles the types:
202
+ *
203
+ * ```typescript
204
+ * declare module '@veloxts/core' {
205
+ * interface BaseContext {
206
+ * analytics: Analytics;
207
+ * }
208
+ * }
209
+ * ```
210
+ *
211
+ * @template TService - The type of service to inject
212
+ * @param config - Plugin configuration
213
+ * @returns A VeloxPlugin ready for registration
214
+ *
215
+ * @example
216
+ * ```typescript
217
+ * export const analyticsPlugin = defineContextPlugin({
218
+ * name: '@myapp/analytics',
219
+ * version: '1.0.0',
220
+ * contextKey: 'analytics',
221
+ * create: () => new Analytics(process.env.ANALYTICS_KEY!),
222
+ * close: (a) => a.flush(),
223
+ * });
224
+ * ```
225
+ */
226
+ export declare function defineContextPlugin<TService>(config: ContextPluginConfig<TService>): VeloxPlugin;
175
227
  /**
176
228
  * Infers the options type from a VeloxPlugin
177
229
  *
package/dist/plugin.js CHANGED
@@ -135,3 +135,66 @@ export function isVeloxPlugin(value) {
135
135
  export function isFastifyPlugin(value) {
136
136
  return typeof value === 'function';
137
137
  }
138
+ /**
139
+ * Creates a plugin that injects a service into the request context
140
+ *
141
+ * Eliminates the runtime boilerplate of `Symbol.for()`, `decorateRequest()`,
142
+ * `addHook('onRequest')`, and `addHook('onClose')` that every
143
+ * context-injecting plugin must implement.
144
+ *
145
+ * **Note on types:** You still need `declare module` for TypeScript to
146
+ * know about the context property. This helper handles the runtime
147
+ * injection; declaration merging handles the types:
148
+ *
149
+ * ```typescript
150
+ * declare module '@veloxts/core' {
151
+ * interface BaseContext {
152
+ * analytics: Analytics;
153
+ * }
154
+ * }
155
+ * ```
156
+ *
157
+ * @template TService - The type of service to inject
158
+ * @param config - Plugin configuration
159
+ * @returns A VeloxPlugin ready for registration
160
+ *
161
+ * @example
162
+ * ```typescript
163
+ * export const analyticsPlugin = defineContextPlugin({
164
+ * name: '@myapp/analytics',
165
+ * version: '1.0.0',
166
+ * contextKey: 'analytics',
167
+ * create: () => new Analytics(process.env.ANALYTICS_KEY!),
168
+ * close: (a) => a.flush(),
169
+ * });
170
+ * ```
171
+ */
172
+ export function defineContextPlugin(config) {
173
+ const symbolKey = Symbol.for(`${config.name}:${config.contextKey}`);
174
+ const plugin = definePlugin({
175
+ name: config.name,
176
+ version: config.version,
177
+ async register(fastify) {
178
+ const service = await config.create();
179
+ // Store on Fastify instance via symbol for programmatic retrieval
180
+ // (e.g., getCacheFromInstance pattern in ecosystem packages)
181
+ Object.defineProperty(fastify, symbolKey, {
182
+ value: service,
183
+ writable: false,
184
+ enumerable: false,
185
+ configurable: false,
186
+ });
187
+ fastify.decorateRequest(config.contextKey, undefined);
188
+ fastify.addHook('onRequest', async (request) => {
189
+ request[config.contextKey] = service;
190
+ });
191
+ if (config.close) {
192
+ const closeFn = config.close;
193
+ fastify.addHook('onClose', async () => {
194
+ await closeFn(service);
195
+ });
196
+ }
197
+ },
198
+ });
199
+ return plugin;
200
+ }
@@ -0,0 +1,37 @@
1
+ /**
2
+ * Raw Body Plugin
3
+ *
4
+ * Preserves the raw request body as a Buffer on the request object.
5
+ * Required for webhook signature verification (Stripe, GitHub, etc.)
6
+ * where the raw bytes must match the HMAC signature.
7
+ *
8
+ * Uses a `preParsing` hook to capture the raw bytes before Fastify's
9
+ * built-in JSON parser processes them, so normal JSON parsing is preserved
10
+ * for all routes.
11
+ *
12
+ * @module plugins/raw-body
13
+ */
14
+ declare module 'fastify' {
15
+ interface FastifyRequest {
16
+ /** Raw request body as Buffer (available when rawBodyPlugin is registered) */
17
+ rawBody?: Buffer;
18
+ }
19
+ }
20
+ /**
21
+ * Fastify plugin that preserves the raw request body.
22
+ *
23
+ * After registration, `request.rawBody` contains the raw Buffer
24
+ * for all requests. Fastify's built-in JSON parser continues to
25
+ * work normally — this plugin does not replace it.
26
+ *
27
+ * @example
28
+ * ```typescript
29
+ * import { rawBodyPlugin } from '@veloxts/core';
30
+ *
31
+ * await app.register(rawBodyPlugin);
32
+ *
33
+ * // In a webhook handler:
34
+ * const isValid = verifySignature(request.rawBody, request.headers['stripe-signature']);
35
+ * ```
36
+ */
37
+ export declare const rawBodyPlugin: import("../plugin.js").VeloxPlugin<import("fastify").FastifyPluginOptions>;
@@ -0,0 +1,48 @@
1
+ /**
2
+ * Raw Body Plugin
3
+ *
4
+ * Preserves the raw request body as a Buffer on the request object.
5
+ * Required for webhook signature verification (Stripe, GitHub, etc.)
6
+ * where the raw bytes must match the HMAC signature.
7
+ *
8
+ * Uses a `preParsing` hook to capture the raw bytes before Fastify's
9
+ * built-in JSON parser processes them, so normal JSON parsing is preserved
10
+ * for all routes.
11
+ *
12
+ * @module plugins/raw-body
13
+ */
14
+ import { Readable } from 'node:stream';
15
+ import { definePlugin } from '../plugin.js';
16
+ /**
17
+ * Fastify plugin that preserves the raw request body.
18
+ *
19
+ * After registration, `request.rawBody` contains the raw Buffer
20
+ * for all requests. Fastify's built-in JSON parser continues to
21
+ * work normally — this plugin does not replace it.
22
+ *
23
+ * @example
24
+ * ```typescript
25
+ * import { rawBodyPlugin } from '@veloxts/core';
26
+ *
27
+ * await app.register(rawBodyPlugin);
28
+ *
29
+ * // In a webhook handler:
30
+ * const isValid = verifySignature(request.rawBody, request.headers['stripe-signature']);
31
+ * ```
32
+ */
33
+ export const rawBodyPlugin = definePlugin({
34
+ name: '@veloxts/raw-body',
35
+ version: '1.0.0',
36
+ async register(fastify) {
37
+ fastify.decorateRequest('rawBody', undefined);
38
+ fastify.addHook('preParsing', async (request, _reply, payload) => {
39
+ const chunks = [];
40
+ for await (const chunk of payload) {
41
+ chunks.push(typeof chunk === 'string' ? Buffer.from(chunk) : chunk);
42
+ }
43
+ const rawBody = Buffer.concat(chunks);
44
+ request.rawBody = rawBody;
45
+ return Readable.from(rawBody);
46
+ });
47
+ },
48
+ });
@@ -0,0 +1,33 @@
1
+ /**
2
+ * Fire-and-forget utility for non-blocking side effects
3
+ *
4
+ * @module utils/fire-and-forget
5
+ */
6
+ export interface FireAndForgetOptions {
7
+ /** Label for error logging context */
8
+ label?: string;
9
+ /** Custom error handler (defaults to VeloxTS logger) */
10
+ onError?: (error: unknown) => void;
11
+ }
12
+ /**
13
+ * Execute a promise without awaiting it, catching and logging any errors.
14
+ *
15
+ * Use this for non-critical side effects (analytics, notifications, audit logs)
16
+ * that shouldn't block the main response.
17
+ *
18
+ * @param promise - The promise to execute in the background
19
+ * @param options - Optional label and error handler
20
+ *
21
+ * @example
22
+ * ```typescript
23
+ * import { fireAndForget } from '@veloxts/core';
24
+ *
25
+ * // In a procedure handler
26
+ * fireAndForget(analytics.track('user.created', { userId }), {
27
+ * label: 'analytics',
28
+ * });
29
+ *
30
+ * return user; // Returns immediately
31
+ * ```
32
+ */
33
+ export declare function fireAndForget(promise: Promise<unknown>, options?: FireAndForgetOptions): void;
@@ -0,0 +1,40 @@
1
+ /**
2
+ * Fire-and-forget utility for non-blocking side effects
3
+ *
4
+ * @module utils/fire-and-forget
5
+ */
6
+ import { createLogger } from './logger.js';
7
+ const log = createLogger('core');
8
+ /**
9
+ * Execute a promise without awaiting it, catching and logging any errors.
10
+ *
11
+ * Use this for non-critical side effects (analytics, notifications, audit logs)
12
+ * that shouldn't block the main response.
13
+ *
14
+ * @param promise - The promise to execute in the background
15
+ * @param options - Optional label and error handler
16
+ *
17
+ * @example
18
+ * ```typescript
19
+ * import { fireAndForget } from '@veloxts/core';
20
+ *
21
+ * // In a procedure handler
22
+ * fireAndForget(analytics.track('user.created', { userId }), {
23
+ * label: 'analytics',
24
+ * });
25
+ *
26
+ * return user; // Returns immediately
27
+ * ```
28
+ */
29
+ export function fireAndForget(promise, options) {
30
+ const { label, onError } = options ?? {};
31
+ promise.catch((error) => {
32
+ if (onError) {
33
+ onError(error);
34
+ }
35
+ else {
36
+ const prefix = label ? `[${label}] ` : '';
37
+ log.error(`${prefix}Fire-and-forget error:`, error);
38
+ }
39
+ });
40
+ }
@@ -0,0 +1,41 @@
1
+ /**
2
+ * Retry utility with exponential backoff
3
+ *
4
+ * @module utils/retry
5
+ */
6
+ export interface RetryOptions {
7
+ /** Maximum number of attempts (default: 3, minimum: 1) */
8
+ attempts?: number;
9
+ /** Base delay in milliseconds (default: 500) */
10
+ delayMs?: number;
11
+ /** Use exponential backoff (default: true) */
12
+ exponential?: boolean;
13
+ /** Only retry if this predicate returns true */
14
+ retryIf?: (error: unknown) => boolean;
15
+ /**
16
+ * Called after each failed attempt with the error and the 1-based
17
+ * attempt number that just failed. Called on every failure including
18
+ * the final one.
19
+ */
20
+ onRetry?: (error: unknown, attempt: number) => void;
21
+ }
22
+ /**
23
+ * Retry an async operation with configurable backoff.
24
+ *
25
+ * @param fn - The async function to retry
26
+ * @param options - Retry configuration
27
+ * @returns The result of the first successful invocation
28
+ * @throws The last error if all attempts are exhausted
29
+ *
30
+ * @example
31
+ * ```typescript
32
+ * import { withRetry } from '@veloxts/core';
33
+ *
34
+ * const result = await withRetry(() => fetch('https://api.example.com/data'), {
35
+ * attempts: 3,
36
+ * retryIf: (err) => err instanceof Error && err.message.includes('ECONNRESET'),
37
+ * onRetry: (err, attempt) => console.log(`Attempt ${attempt} failed:`, err),
38
+ * });
39
+ * ```
40
+ */
41
+ export declare function withRetry<T>(fn: () => Promise<T>, options?: RetryOptions): Promise<T>;
@@ -0,0 +1,48 @@
1
+ /**
2
+ * Retry utility with exponential backoff
3
+ *
4
+ * @module utils/retry
5
+ */
6
+ /**
7
+ * Retry an async operation with configurable backoff.
8
+ *
9
+ * @param fn - The async function to retry
10
+ * @param options - Retry configuration
11
+ * @returns The result of the first successful invocation
12
+ * @throws The last error if all attempts are exhausted
13
+ *
14
+ * @example
15
+ * ```typescript
16
+ * import { withRetry } from '@veloxts/core';
17
+ *
18
+ * const result = await withRetry(() => fetch('https://api.example.com/data'), {
19
+ * attempts: 3,
20
+ * retryIf: (err) => err instanceof Error && err.message.includes('ECONNRESET'),
21
+ * onRetry: (err, attempt) => console.log(`Attempt ${attempt} failed:`, err),
22
+ * });
23
+ * ```
24
+ */
25
+ export async function withRetry(fn, options) {
26
+ const { attempts = 3, delayMs = 500, exponential = true, retryIf, onRetry } = options ?? {};
27
+ if (attempts < 1) {
28
+ throw new Error('withRetry: attempts must be at least 1');
29
+ }
30
+ let lastError;
31
+ for (let attempt = 1; attempt <= attempts; attempt++) {
32
+ try {
33
+ return await fn();
34
+ }
35
+ catch (error) {
36
+ lastError = error;
37
+ if (retryIf && !retryIf(error)) {
38
+ throw error;
39
+ }
40
+ onRetry?.(error, attempt);
41
+ if (attempt < attempts) {
42
+ const delay = exponential ? delayMs * 2 ** (attempt - 1) : delayMs;
43
+ await new Promise((resolve) => setTimeout(resolve, delay));
44
+ }
45
+ }
46
+ }
47
+ throw lastError;
48
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@veloxts/core",
3
- "version": "0.7.5",
3
+ "version": "0.7.7",
4
4
  "description": "Fastify wrapper and plugin system for VeloxTS framework",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",