hapi-terminator 0.0.6 → 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,13 +1,7 @@
1
- import type { Server, Request } from '@hapi/hapi';
2
- type LimitOption = number | ((request: Request, size: number) => boolean);
3
- type LimitOptionName = (typeof LIMIT_OPTION_NAMES)[keyof typeof LIMIT_OPTION_NAMES];
4
- export type TerminatorOptions = {
5
- [Name in LimitOptionName]?: LimitOption;
6
- };
7
- declare const LIMIT_OPTION_NAMES: {
8
- readonly REGISTERED: "registeredLimit";
9
- readonly UNREGISTERED: "unregisteredLimit";
10
- };
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>;
11
5
  export declare const plugin: {
12
6
  pkg: {
13
7
  name: string;
@@ -35,6 +29,7 @@ export declare const plugin: {
35
29
  };
36
30
  dependencies: {
37
31
  "@hapi/boom": string;
32
+ zod: string;
38
33
  };
39
34
  prettier: string;
40
35
  "lint-staged": {
@@ -59,7 +54,16 @@ export declare const plugin: {
59
54
  };
60
55
  register: typeof register;
61
56
  };
62
- 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>;
63
67
  declare const _default: {
64
68
  plugin: {
65
69
  pkg: {
@@ -88,6 +92,7 @@ declare const _default: {
88
92
  };
89
93
  dependencies: {
90
94
  "@hapi/boom": string;
95
+ zod: string;
91
96
  };
92
97
  prettier: string;
93
98
  "lint-staged": {
package/dist/index.js CHANGED
@@ -1,16 +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';
3
5
  const LIMIT_OPTION_NAMES = {
4
6
  REGISTERED: 'registeredLimit',
5
7
  UNREGISTERED: 'unregisteredLimit',
6
8
  };
9
+ const PACKAGE_NAME = 'hapi-terminator';
10
+ assert(PACKAGE_NAME === pkg.name);
7
11
  export const plugin = { pkg, register };
8
- async function register(server, options) {
9
- 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));
10
19
  }
11
- function onRequest(options) {
20
+ function validateHookHandler(pluginOptions, routeOptionsCache) {
12
21
  return (request, h) => {
13
- const handler = validateRoute(request, h, options);
22
+ const handler = validateRoute(request, h, pluginOptions);
14
23
  const rawContentLength = request.headers['content-length'];
15
24
  if (!rawContentLength) {
16
25
  return h.continue;
@@ -19,29 +28,47 @@ function onRequest(options) {
19
28
  if (Number.isNaN(contentLength)) {
20
29
  return h.continue;
21
30
  }
22
- const matchedRoute = request.server.match(request.method, request.path);
23
- if (matchedRoute != null) {
24
- return handler(contentLength, LIMIT_OPTION_NAMES.REGISTERED, option => {
25
- throw Boom.entityTooLarge(typeof option === 'number' ? `Payload content length greater than maximum allowed: ${option}` : undefined);
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}`);
26
35
  });
27
36
  }
28
- return handler(contentLength, LIMIT_OPTION_NAMES.UNREGISTERED, () => {
37
+ assert(options == null, "Unregistered routes can't have route options");
38
+ return handler(contentLength, LIMIT_OPTION_NAMES.UNREGISTERED, null, () => {
29
39
  throw Boom.notFound();
30
40
  });
31
41
  };
32
42
  }
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
+ }
33
57
  function validateRoute(request, h, options) {
34
- return (contentLength, optionName, throwError) => {
35
- const option = options[optionName];
36
- if (option == null || (typeof option === 'number' && option < 0)) {
58
+ return (contentLength, optionName, routeOptions, throwError) => {
59
+ const option = options?.[optionName];
60
+ const limit = routeOptions?.[PACKAGE_NAME]?.limit ?? option;
61
+ if (limit == null) {
37
62
  return h.continue;
38
63
  }
39
- if ((typeof option === 'number' && contentLength > option) ||
40
- (typeof option === 'function' && option(request, contentLength))) {
64
+ if (contentLength > limit) {
41
65
  request.raw.req.socket?.destroy();
42
- throwError(option);
66
+ throwError(limit);
43
67
  }
44
68
  return h.continue;
45
69
  };
46
70
  }
71
+ function getRoutePluginSettings(matchedRoute) {
72
+ return TerminatorRouteOptionsSchema.safeParse(matchedRoute?.settings.plugins).data;
73
+ }
47
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.6",
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.6",
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": {