hapi-terminator 0.0.5 → 0.1.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
@@ -6,7 +6,7 @@ A Hapi plugin that terminates requests with payloads that exceed a specified siz
6
6
 
7
7
  - 🛡️ Protects against large payload attacks
8
8
  - 🔀 Different limits for registered vs unregistered routes
9
- - 🎯 Configurable thresholds using numbers or custom functions
9
+ - 🎯 Per-route limit configuration
10
10
  - ⚡ Terminates connections early to save resources
11
11
  - 📦 TypeScript support included
12
12
 
@@ -54,39 +54,83 @@ await server.start();
54
54
  console.log('Server running on %s', server.info.uri);
55
55
  ```
56
56
 
57
- ### Using Custom Functions
57
+ ### Per-Route Limits
58
58
 
59
- You can provide custom functions to determine whether a request should be terminated:
59
+ You can override the global limits for specific routes by setting the limit in the route options:
60
60
 
61
61
  ```typescript
62
- const requestTerminateOptions: TerminatorOptions = {
63
- registeredLimit: (request, size) => {
64
- // Custom logic based on request properties
65
- if (request.path === '/upload') {
66
- return size > 10 * 1024 * 1024; // 10MB for upload route
67
- }
68
- return size > 500 * 1024; // 500KB for other routes
62
+ import Hapi, { type PluginSpecificConfiguration } from '@hapi/hapi';
63
+ import terminatorPlugin, { type TerminatorOptions, type TerminatorRouteOptions } from 'hapi-terminator';
64
+
65
+ type RoutePluginOptions = PluginSpecificConfiguration & TerminatorRouteOptions;
66
+
67
+ const server = Hapi.server({ port: 3000, host: '127.0.0.1' });
68
+
69
+ await server.register({
70
+ plugin: terminatorPlugin,
71
+ options: {
72
+ registeredLimit: 500 * 1024, // 500KB default for registered routes
73
+ unregisteredLimit: 100 * 1024, // 100KB for unregistered routes
69
74
  },
70
- unregisteredLimit: (request, size) => {
71
- return size > 100 * 1024; // 100KB for unregistered routes
75
+ });
76
+
77
+ // Standard route with default limit (500KB)
78
+ server.route({
79
+ method: ['GET', 'POST'],
80
+ path: '/',
81
+ handler: () => 'Hello World!',
82
+ });
83
+
84
+ // Upload route with higher limit (10MB)
85
+ server.route({
86
+ method: ['POST'],
87
+ path: '/upload',
88
+ handler: () => ({ success: true }),
89
+ options: {
90
+ plugins: {
91
+ 'hapi-terminator': { limit: 10 * 1024 * 1024 }, // 10MB
92
+ } as RoutePluginOptions,
72
93
  },
73
- };
94
+ });
95
+
96
+ // Unlimited route (disable limit for this specific route)
97
+ server.route({
98
+ method: ['POST'],
99
+ path: '/stream',
100
+ handler: () => ({ success: true }),
101
+ options: {
102
+ plugins: {
103
+ 'hapi-terminator': { limit: null }, // No limit
104
+ } as RoutePluginOptions,
105
+ },
106
+ });
107
+
108
+ await server.start();
74
109
  ```
75
110
 
76
111
  ## Configuration
77
112
 
78
113
  ### TerminatorOptions
79
114
 
80
- | Option | Type | Description |
81
- | ------------------- | --------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------- |
82
- | `registeredLimit` | `number \| ((request: Request, size: number) => boolean)` | Maximum payload size for registered routes. Can be a number in bytes or a function that returns `true` if the request should be terminated. |
83
- | `unregisteredLimit` | `number \| ((request: Request, size: number) => boolean)` | Maximum payload size for unregistered routes. Can be a number in bytes or a function that returns `true` if the request should be terminated. |
115
+ | Option | Type | Description |
116
+ | ------------------- | -------- | ------------------------------------------------------------------------------------------------------------- |
117
+ | `registeredLimit` | `number` | Maximum payload size in bytes for registered routes. Must be >= 0. Set to `null` or `undefined` to disable. |
118
+ | `unregisteredLimit` | `number` | Maximum payload size in bytes for unregistered routes. Must be >= 0. Set to `null` or `undefined` to disable. |
119
+
120
+ ### TerminatorRouteOptions
121
+
122
+ You can configure per-route limits using the route options:
123
+
124
+ | Option | Type | Description |
125
+ | ------- | -------- | -------------------------------------------------------------------------------------------------------------------------------------- |
126
+ | `limit` | `number` | Maximum payload size in bytes for this specific route. Must be >= 0. Overrides the global `registeredLimit`. Set to `null` to disable. |
84
127
 
85
128
  ### Behavior
86
129
 
87
130
  - **Registered Routes**: When a payload exceeds the limit on a registered route, the socket is destroyed and a `413 Payload Too Large` error is thrown.
88
131
  - **Unregistered Routes**: When a payload exceeds the limit on an unregistered route, the socket is destroyed and a `404 Not Found` error is thrown.
89
- - **Disabled**: Set to `null`, `undefined`, or a negative number to disable termination for that category.
132
+ - **Per-Route Limits**: Route-specific limits take precedence over global limits, allowing you to customize limits for individual routes.
133
+ - **Disabled**: Set to `null` or `undefined` to disable termination for that category or route.
90
134
 
91
135
  ## How It Works
92
136
 
package/dist/index.d.ts CHANGED
@@ -1,8 +1,7 @@
1
- import type { Server, Request } from '@hapi/hapi';
2
- export type TerminatorOptions = {
3
- registeredLimit?: number | ((request: Request, size: number) => boolean);
4
- unregisteredLimit?: number | ((request: Request, size: number) => boolean);
5
- };
1
+ import type { Server } from '@hapi/hapi';
2
+ import { z } from 'zod/mini';
3
+ export type TerminatorRouteOptions = z.infer<typeof TerminatorRouteOptionsSchema>;
4
+ export type TerminatorOptions = z.infer<typeof TerminatorOptionsSchema>;
6
5
  export declare const plugin: {
7
6
  pkg: {
8
7
  name: string;
@@ -30,6 +29,7 @@ export declare const plugin: {
30
29
  };
31
30
  dependencies: {
32
31
  "@hapi/boom": string;
32
+ zod: string;
33
33
  };
34
34
  prettier: string;
35
35
  "lint-staged": {
@@ -54,7 +54,16 @@ export declare const plugin: {
54
54
  };
55
55
  register: typeof register;
56
56
  };
57
- declare function register(server: Server, options: TerminatorOptions): Promise<void>;
57
+ declare const TerminatorRouteOptionsSchema: z.ZodMiniOptional<z.ZodMiniNullable<z.ZodMiniObject<{
58
+ "hapi-terminator": z.ZodMiniObject<{
59
+ limit: z.ZodMiniOptional<z.ZodMiniNullable<z.ZodMiniNumber<number>>>;
60
+ }, z.core.$strip>;
61
+ }, z.core.$strip>>>;
62
+ declare const TerminatorOptionsSchema: z.ZodMiniOptional<z.ZodMiniNullable<z.ZodMiniObject<{
63
+ registeredLimit: z.ZodMiniOptional<z.ZodMiniNullable<z.ZodMiniNumber<number>>>;
64
+ unregisteredLimit: z.ZodMiniOptional<z.ZodMiniNullable<z.ZodMiniNumber<number>>>;
65
+ }, z.core.$strip>>>;
66
+ declare function register(server: Server, rawOptions: TerminatorOptions): Promise<void>;
58
67
  declare const _default: {
59
68
  plugin: {
60
69
  pkg: {
@@ -83,6 +92,7 @@ declare const _default: {
83
92
  };
84
93
  dependencies: {
85
94
  "@hapi/boom": string;
95
+ zod: string;
86
96
  };
87
97
  prettier: string;
88
98
  "lint-staged": {
package/dist/index.js CHANGED
@@ -1,13 +1,25 @@
1
+ import assert from 'node:assert';
1
2
  import Boom from '@hapi/boom';
3
+ import { z } from 'zod/mini';
2
4
  import pkg from './package.json';
5
+ const LIMIT_OPTION_NAMES = {
6
+ REGISTERED: 'registeredLimit',
7
+ UNREGISTERED: 'unregisteredLimit',
8
+ };
9
+ const PACKAGE_NAME = 'hapi-terminator';
10
+ assert(PACKAGE_NAME === pkg.name);
3
11
  export const plugin = { pkg, register };
4
- async function register(server, options) {
5
- server.ext('onRequest', onRequest(options));
12
+ const LimitOptionShape = z.nullish(z.number().check(z.minimum(0)));
13
+ const TerminatorRouteOptionsSchema = z.nullish(z.object({ [PACKAGE_NAME]: z.object({ limit: LimitOptionShape }) }));
14
+ const TerminatorOptionsSchema = z.nullish(z.object({ registeredLimit: LimitOptionShape, unregisteredLimit: LimitOptionShape }));
15
+ async function register(server, rawOptions) {
16
+ const options = TerminatorOptionsSchema.parse(rawOptions);
17
+ const routeOptionsCache = new Map();
18
+ server.ext('onRequest', validateHookHandler(options, routeOptionsCache));
6
19
  }
7
- function onRequest(options) {
20
+ function validateHookHandler(pluginOptions, routeOptionsCache) {
8
21
  return (request, h) => {
9
- const unregisteredRouteHandler = handleUnregisteredRoute(request, h, options);
10
- const registeredRouteHandler = handleRegisteredRoute(request, h, options);
22
+ const handler = validateRoute(request, h, pluginOptions);
11
23
  const rawContentLength = request.headers['content-length'];
12
24
  if (!rawContentLength) {
13
25
  return h.continue;
@@ -16,41 +28,47 @@ function onRequest(options) {
16
28
  if (Number.isNaN(contentLength)) {
17
29
  return h.continue;
18
30
  }
19
- const matchedRoute = request.server.match(request.method, request.path);
20
- if (matchedRoute != null) {
21
- return registeredRouteHandler(contentLength);
31
+ const { route, options } = getRouteAndOptions(request, routeOptionsCache) ?? {};
32
+ if (route != null) {
33
+ return handler(contentLength, LIMIT_OPTION_NAMES.REGISTERED, options, option => {
34
+ throw Boom.entityTooLarge(`Payload content length greater than maximum allowed: ${option}`);
35
+ });
22
36
  }
23
- return unregisteredRouteHandler(contentLength);
24
- };
25
- }
26
- function handleUnregisteredRoute(request, h, options) {
27
- return (contentLength) => {
28
- if (options.unregisteredLimit == null ||
29
- (typeof options.unregisteredLimit === 'number' && options.unregisteredLimit < 0)) {
30
- return h.continue;
31
- }
32
- if ((typeof options.unregisteredLimit === 'number' && contentLength > options.unregisteredLimit) ||
33
- (typeof options.unregisteredLimit === 'function' && options.unregisteredLimit(request, contentLength))) {
34
- request.raw.req.socket?.destroy();
37
+ assert(options == null, "Unregistered routes can't have route options");
38
+ return handler(contentLength, LIMIT_OPTION_NAMES.UNREGISTERED, null, () => {
35
39
  throw Boom.notFound();
36
- }
37
- return h.continue;
40
+ });
38
41
  };
39
42
  }
40
- function handleRegisteredRoute(request, h, options) {
41
- return (contentLength) => {
42
- if (options.registeredLimit == null ||
43
- (typeof options.registeredLimit === 'number' && options.registeredLimit < 0)) {
43
+ function getRouteAndOptions(request, routeOptionsCache) {
44
+ const matchedRoute = request.server.match(request.method, request.path);
45
+ if (matchedRoute == null) {
46
+ return null;
47
+ }
48
+ const cacheKey = `${matchedRoute.method}-${matchedRoute.path}`;
49
+ const cachedResult = routeOptionsCache.get(cacheKey);
50
+ if (cachedResult != null) {
51
+ return { options: cachedResult, route: matchedRoute };
52
+ }
53
+ const options = getRoutePluginSettings(matchedRoute);
54
+ routeOptionsCache.set(cacheKey, options);
55
+ return { options, route: matchedRoute };
56
+ }
57
+ function validateRoute(request, h, options) {
58
+ return (contentLength, optionName, routeOptions, throwError) => {
59
+ const option = options?.[optionName];
60
+ const limit = routeOptions?.[PACKAGE_NAME]?.limit ?? option;
61
+ if (limit == null) {
44
62
  return h.continue;
45
63
  }
46
- if ((typeof options.registeredLimit === 'number' && contentLength > options.registeredLimit) ||
47
- (typeof options.registeredLimit === 'function' && options.registeredLimit(request, contentLength))) {
64
+ if (contentLength > limit) {
48
65
  request.raw.req.socket?.destroy();
49
- throw Boom.entityTooLarge(typeof options.registeredLimit === 'number'
50
- ? `Payload content length greater than maximum allowed: ${options.registeredLimit}`
51
- : undefined);
66
+ throwError(limit);
52
67
  }
53
68
  return h.continue;
54
69
  };
55
70
  }
71
+ function getRoutePluginSettings(matchedRoute) {
72
+ return TerminatorRouteOptionsSchema.safeParse(matchedRoute?.settings.plugins).data;
73
+ }
56
74
  export default { plugin };
package/dist/package.json CHANGED
@@ -3,7 +3,7 @@
3
3
  "main": "dist/index.js",
4
4
  "typings": "dist/index.d.ts",
5
5
  "type": "module",
6
- "version": "0.0.5",
6
+ "version": "0.1.0",
7
7
  "license": "MIT",
8
8
  "author": {
9
9
  "name": "Kamaal Farah"
@@ -23,7 +23,8 @@
23
23
  "typescript-eslint": "^8.52.0"
24
24
  },
25
25
  "dependencies": {
26
- "@hapi/boom": "^10.0.1"
26
+ "@hapi/boom": "^10.0.1",
27
+ "zod": "^4.3.5"
27
28
  },
28
29
  "prettier": "@kamaalio/prettier-config",
29
30
  "lint-staged": {
package/package.json CHANGED
@@ -3,7 +3,7 @@
3
3
  "main": "dist/index.js",
4
4
  "typings": "dist/index.d.ts",
5
5
  "type": "module",
6
- "version": "0.0.5",
6
+ "version": "0.1.0",
7
7
  "license": "MIT",
8
8
  "author": {
9
9
  "name": "Kamaal Farah"
@@ -23,7 +23,8 @@
23
23
  "typescript-eslint": "^8.52.0"
24
24
  },
25
25
  "dependencies": {
26
- "@hapi/boom": "^10.0.1"
26
+ "@hapi/boom": "^10.0.1",
27
+ "zod": "^4.3.5"
27
28
  },
28
29
  "prettier": "@kamaalio/prettier-config",
29
30
  "lint-staged": {