hapi-terminator 0.3.0 → 0.4.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 +45 -39
- package/{dist/index.d.ts → index.d.ts} +0 -7
- package/index.js +71 -0
- package/package.json +5 -4
- package/dist/index.js +0 -94
- package/dist/package.json +0 -52
package/README.md
CHANGED
|
@@ -36,7 +36,6 @@ const server = Hapi.server({ port: 3000, host: '127.0.0.1' });
|
|
|
36
36
|
|
|
37
37
|
const requestTerminateOptions: TerminatorOptions = {
|
|
38
38
|
unregisteredLimit: 500 * 1024, // 500KB for unregistered routes
|
|
39
|
-
registeredLimit: 500 * 1024, // 500KB for registered routes
|
|
40
39
|
};
|
|
41
40
|
|
|
42
41
|
await server.register({
|
|
@@ -48,6 +47,11 @@ server.route({
|
|
|
48
47
|
method: ['GET', 'POST'],
|
|
49
48
|
path: '/',
|
|
50
49
|
handler: () => 'Hello World!',
|
|
50
|
+
options: {
|
|
51
|
+
payload: {
|
|
52
|
+
maxBytes: 500 * 1024, // 500KB limit for this route
|
|
53
|
+
},
|
|
54
|
+
},
|
|
51
55
|
});
|
|
52
56
|
|
|
53
57
|
await server.start();
|
|
@@ -56,29 +60,31 @@ console.log('Server running on %s', server.info.uri);
|
|
|
56
60
|
|
|
57
61
|
### Per-Route Limits
|
|
58
62
|
|
|
59
|
-
You can
|
|
60
|
-
|
|
61
|
-
```typescript
|
|
62
|
-
import Hapi, { type PluginSpecificConfiguration } from '@hapi/hapi';
|
|
63
|
-
import terminatorPlugin, { type TerminatorOptions, type TerminatorRouteOptions } from 'hapi-terminator';
|
|
63
|
+
You can set limits for specific routes using Hapi's native `payload.maxBytes` configuration:
|
|
64
64
|
|
|
65
|
-
|
|
65
|
+
````typescript
|
|
66
|
+
import Hapi from '@hapi/hapi';
|
|
67
|
+
import terminatorPlugin, { type TerminatorOptions } from 'hapi-terminator';
|
|
66
68
|
|
|
67
69
|
const server = Hapi.server({ port: 3000, host: '127.0.0.1' });
|
|
68
70
|
|
|
69
71
|
await server.register({
|
|
70
72
|
plugin: terminatorPlugin,
|
|
71
73
|
options: {
|
|
72
|
-
registeredLimit: 500 * 1024, // 500KB default for registered routes
|
|
73
74
|
unregisteredLimit: 100 * 1024, // 100KB for unregistered routes
|
|
74
75
|
},
|
|
75
76
|
});
|
|
76
77
|
|
|
77
|
-
// Standard route with
|
|
78
|
+
// Standard route with 500KB limit
|
|
78
79
|
server.route({
|
|
79
80
|
method: ['GET', 'POST'],
|
|
80
81
|
path: '/',
|
|
81
82
|
handler: () => 'Hello World!',
|
|
83
|
+
options: {
|
|
84
|
+
payload: {
|
|
85
|
+
maxBytes: 500 * 1024, // 500KB
|
|
86
|
+
},
|
|
87
|
+
},
|
|
82
88
|
});
|
|
83
89
|
|
|
84
90
|
// Upload route with higher limit (10MB)
|
|
@@ -87,26 +93,13 @@ server.route({
|
|
|
87
93
|
path: '/upload',
|
|
88
94
|
handler: () => ({ success: true }),
|
|
89
95
|
options: {
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
}
|
|
96
|
+
payload: {
|
|
97
|
+
maxBytes: 10 * 1024 * 1024, // 10MB
|
|
98
|
+
},
|
|
93
99
|
},
|
|
94
100
|
});
|
|
95
101
|
|
|
96
|
-
|
|
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();
|
|
109
|
-
```
|
|
102
|
+
});\n\nawait server.start();\n```
|
|
110
103
|
|
|
111
104
|
### Boolean Limits for Unregistered Routes
|
|
112
105
|
|
|
@@ -122,7 +115,6 @@ const server = Hapi.server({ port: 3000, host: '127.0.0.1' });
|
|
|
122
115
|
await server.register({
|
|
123
116
|
plugin: terminatorPlugin,
|
|
124
117
|
options: {
|
|
125
|
-
registeredLimit: 1024 * 1024, // 1MB for registered routes
|
|
126
118
|
unregisteredLimit: true, // Immediately reject all unregistered routes
|
|
127
119
|
},
|
|
128
120
|
});
|
|
@@ -132,11 +124,16 @@ server.route({
|
|
|
132
124
|
method: ['POST'],
|
|
133
125
|
path: '/api/data',
|
|
134
126
|
handler: () => ({ success: true }),
|
|
127
|
+
options: {
|
|
128
|
+
payload: {
|
|
129
|
+
maxBytes: 1024 * 1024, // 1MB
|
|
130
|
+
},
|
|
131
|
+
},
|
|
135
132
|
});
|
|
136
133
|
|
|
137
134
|
// Any request to unregistered routes (e.g., /unknown) will be rejected immediately
|
|
138
135
|
await server.start();
|
|
139
|
-
|
|
136
|
+
````
|
|
140
137
|
|
|
141
138
|
You can also set `unregisteredLimit` to `false` to bypass payload size checks for unregistered routes:
|
|
142
139
|
|
|
@@ -144,7 +141,6 @@ You can also set `unregisteredLimit` to `false` to bypass payload size checks fo
|
|
|
144
141
|
await server.register({
|
|
145
142
|
plugin: terminatorPlugin,
|
|
146
143
|
options: {
|
|
147
|
-
registeredLimit: 500 * 1024, // 500KB for registered routes
|
|
148
144
|
unregisteredLimit: false, // Bypass payload size checks for unregistered routes
|
|
149
145
|
},
|
|
150
146
|
});
|
|
@@ -156,23 +152,33 @@ await server.register({
|
|
|
156
152
|
|
|
157
153
|
| Option | Type | Description |
|
|
158
154
|
| ------------------- | ------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
|
159
|
-
| `registeredLimit` | `number` | Maximum payload size in bytes for registered routes. Must be >= 0. Set to `null` or `undefined` to disable. |
|
|
160
155
|
| `unregisteredLimit` | `number \| boolean` | Maximum payload size in bytes for unregistered routes. Must be >= 0. Set to `null` or `undefined` to disable. Set to `true` to reject all requests immediately. Set to `false` to bypass payload size checks. |
|
|
161
156
|
|
|
162
|
-
###
|
|
157
|
+
### Route Payload Configuration
|
|
158
|
+
|
|
159
|
+
Use Hapi's native `payload.maxBytes` option in your route configuration to set per-route limits:
|
|
163
160
|
|
|
164
|
-
|
|
161
|
+
Use Hapi's native `payload.maxBytes` option in your route configuration to set per-route limits:
|
|
165
162
|
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
163
|
+
```typescript
|
|
164
|
+
server.route({
|
|
165
|
+
method: 'POST',
|
|
166
|
+
path: '/upload',
|
|
167
|
+
handler: () => ({ success: true }),
|
|
168
|
+
options: {
|
|
169
|
+
payload: {
|
|
170
|
+
maxBytes: 10 * 1024 * 1024, // 10MB
|
|
171
|
+
},
|
|
172
|
+
},
|
|
173
|
+
});
|
|
174
|
+
```
|
|
169
175
|
|
|
170
176
|
### Behavior
|
|
171
177
|
|
|
172
|
-
- **Registered Routes**: When a payload exceeds
|
|
173
|
-
- **Unregistered Routes**: When a payload exceeds the
|
|
174
|
-
- **Per-Route Limits**:
|
|
175
|
-
- **Disabled**:
|
|
178
|
+
- **Registered Routes**: Routes use Hapi's native `payload.maxBytes` setting. When a payload exceeds this limit, the socket is gracefully ended and a `413 Payload Too Large` error is returned.
|
|
179
|
+
- **Unregistered Routes**: When a payload exceeds the `unregisteredLimit`, the socket is gracefully ended and a `404 Not Found` error is returned.
|
|
180
|
+
- **Per-Route Limits**: Use Hapi's `payload.maxBytes` to customize limits for individual routes.
|
|
181
|
+
- **Disabled**: Omit `payload.maxBytes` to allow unlimited payload size for a route.
|
|
176
182
|
- **Boolean Values for Unregistered Routes**:
|
|
177
183
|
- Set `unregisteredLimit` to `true` to immediately reject all unregistered route requests regardless of Content-Length (even 0 bytes).
|
|
178
184
|
- Set `unregisteredLimit` to `false` to bypass payload size checks for unregistered route requests (they will still receive 404 responses).
|
|
@@ -1,6 +1,5 @@
|
|
|
1
1
|
import type { Server } from '@hapi/hapi';
|
|
2
2
|
import { z } from 'zod/mini';
|
|
3
|
-
export type TerminatorRouteOptions = z.infer<typeof TerminatorRouteOptionsSchema>;
|
|
4
3
|
export type TerminatorOptions = z.infer<typeof TerminatorOptionsSchema>;
|
|
5
4
|
export declare const plugin: {
|
|
6
5
|
pkg: {
|
|
@@ -53,13 +52,7 @@ export declare const plugin: {
|
|
|
53
52
|
};
|
|
54
53
|
register: typeof register;
|
|
55
54
|
};
|
|
56
|
-
declare const TerminatorRouteOptionsSchema: z.ZodMiniOptional<z.ZodMiniNullable<z.ZodMiniObject<{
|
|
57
|
-
"hapi-terminator": z.ZodMiniObject<{
|
|
58
|
-
limit: z.ZodMiniOptional<z.ZodMiniNullable<z.ZodMiniNumber<number>>>;
|
|
59
|
-
}, z.core.$strip>;
|
|
60
|
-
}, z.core.$strip>>>;
|
|
61
55
|
declare const TerminatorOptionsSchema: z.ZodMiniOptional<z.ZodMiniNullable<z.ZodMiniObject<{
|
|
62
|
-
registeredLimit: z.ZodMiniOptional<z.ZodMiniNullable<z.ZodMiniNumber<number>>>;
|
|
63
56
|
unregisteredLimit: z.ZodMiniUnion<readonly [z.ZodMiniOptional<z.ZodMiniNullable<z.ZodMiniNumber<number>>>, z.ZodMiniBoolean<boolean>]>;
|
|
64
57
|
}, z.core.$strip>>>;
|
|
65
58
|
declare function register(server: Server, rawOptions: TerminatorOptions): Promise<void>;
|
package/index.js
ADDED
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
import { z } from 'zod/mini';
|
|
2
|
+
import pkg from './package.json';
|
|
3
|
+
export const plugin = { pkg, register };
|
|
4
|
+
const TerminatorOptionsSchema = z.nullish(z.object({
|
|
5
|
+
unregisteredLimit: z.union([z.nullish(z.number().check(z.minimum(0))), z.boolean()]),
|
|
6
|
+
}));
|
|
7
|
+
async function register(server, rawOptions) {
|
|
8
|
+
const options = TerminatorOptionsSchema.parse(rawOptions);
|
|
9
|
+
server.ext('onRequest', validateHookHandler(options));
|
|
10
|
+
}
|
|
11
|
+
function validateHookHandler(pluginOptions) {
|
|
12
|
+
return (request, h) => {
|
|
13
|
+
const handler = validateRoute(request, h);
|
|
14
|
+
const hasTransferEncoding = Boolean(request.headers['transfer-encoding']);
|
|
15
|
+
const rawContentLength = request.headers['content-length'];
|
|
16
|
+
const willProcessPayload = hasTransferEncoding || Boolean(rawContentLength);
|
|
17
|
+
if (!willProcessPayload) {
|
|
18
|
+
return h.continue;
|
|
19
|
+
}
|
|
20
|
+
const contentLength = Number.parseInt(rawContentLength || '0', 10);
|
|
21
|
+
const route = request.server.match(request.method, request.path, request.info.host);
|
|
22
|
+
if (route != null) {
|
|
23
|
+
const maxBytes = route.settings.payload?.maxBytes;
|
|
24
|
+
return handler(contentLength, maxBytes, option => {
|
|
25
|
+
return h
|
|
26
|
+
.response({
|
|
27
|
+
error: 'Request Entity Too Large',
|
|
28
|
+
message: `Payload content length greater than maximum allowed: ${option}`,
|
|
29
|
+
statusCode: 413,
|
|
30
|
+
})
|
|
31
|
+
.code(413);
|
|
32
|
+
});
|
|
33
|
+
}
|
|
34
|
+
return handler(contentLength, (hasTransferEncoding ? 0 : null) ?? pluginOptions?.unregisteredLimit, () => {
|
|
35
|
+
return h.response({ error: 'Not Found', message: 'Not Found', statusCode: 404 }).code(404);
|
|
36
|
+
});
|
|
37
|
+
};
|
|
38
|
+
}
|
|
39
|
+
function validateRoute(request, h) {
|
|
40
|
+
return (contentLength, limit, response) => {
|
|
41
|
+
if (limit == null) {
|
|
42
|
+
return h.continue;
|
|
43
|
+
}
|
|
44
|
+
if (limit === false) {
|
|
45
|
+
return h.continue;
|
|
46
|
+
}
|
|
47
|
+
if (limit === true) {
|
|
48
|
+
const result = response(0).takeover();
|
|
49
|
+
closeSocketsOnFinish(request);
|
|
50
|
+
return result;
|
|
51
|
+
}
|
|
52
|
+
if (limit === 0 || contentLength > limit) {
|
|
53
|
+
const result = response(limit).takeover();
|
|
54
|
+
closeSocketsOnFinish(request);
|
|
55
|
+
return result;
|
|
56
|
+
}
|
|
57
|
+
return h.continue;
|
|
58
|
+
};
|
|
59
|
+
}
|
|
60
|
+
function closeSocketsOnFinish(request) {
|
|
61
|
+
request.raw.res.once('finish', () => {
|
|
62
|
+
const socket = request.raw.req.socket;
|
|
63
|
+
if (socket.destroy) {
|
|
64
|
+
socket.destroy();
|
|
65
|
+
}
|
|
66
|
+
else {
|
|
67
|
+
socket.end();
|
|
68
|
+
}
|
|
69
|
+
});
|
|
70
|
+
}
|
|
71
|
+
export default { plugin };
|
package/package.json
CHANGED
|
@@ -1,9 +1,9 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "hapi-terminator",
|
|
3
|
-
"main": "
|
|
4
|
-
"typings": "
|
|
3
|
+
"main": "index.js",
|
|
4
|
+
"typings": "index.d.ts",
|
|
5
5
|
"type": "module",
|
|
6
|
-
"version": "0.
|
|
6
|
+
"version": "0.4.0",
|
|
7
7
|
"license": "MIT",
|
|
8
8
|
"author": {
|
|
9
9
|
"name": "Kamaal Farah"
|
|
@@ -47,6 +47,7 @@
|
|
|
47
47
|
"access": "public"
|
|
48
48
|
},
|
|
49
49
|
"files": [
|
|
50
|
-
"
|
|
50
|
+
"index.js",
|
|
51
|
+
"index.d.ts"
|
|
51
52
|
]
|
|
52
53
|
}
|
package/dist/index.js
DELETED
|
@@ -1,94 +0,0 @@
|
|
|
1
|
-
import assert from 'node:assert';
|
|
2
|
-
import { z } from 'zod/mini';
|
|
3
|
-
import pkg from './package.json';
|
|
4
|
-
const LIMIT_OPTION_NAMES = {
|
|
5
|
-
REGISTERED: 'registeredLimit',
|
|
6
|
-
UNREGISTERED: 'unregisteredLimit',
|
|
7
|
-
};
|
|
8
|
-
const PACKAGE_NAME = 'hapi-terminator';
|
|
9
|
-
assert(PACKAGE_NAME === pkg.name);
|
|
10
|
-
export const plugin = { pkg, register };
|
|
11
|
-
const TerminatorRouteOptionsSchema = z.nullish(z.object({ [PACKAGE_NAME]: z.object({ limit: z.nullish(z.number().check(z.minimum(0))) }) }));
|
|
12
|
-
const TerminatorOptionsSchema = z.nullish(z.object({
|
|
13
|
-
registeredLimit: z.nullish(z.number().check(z.minimum(0))),
|
|
14
|
-
unregisteredLimit: z.union([z.nullish(z.number().check(z.minimum(0))), z.boolean()]),
|
|
15
|
-
}));
|
|
16
|
-
async function register(server, rawOptions) {
|
|
17
|
-
const options = TerminatorOptionsSchema.parse(rawOptions);
|
|
18
|
-
const routeOptionsCache = new Map();
|
|
19
|
-
server.ext('onRequest', validateHookHandler(options, routeOptionsCache));
|
|
20
|
-
}
|
|
21
|
-
function validateHookHandler(pluginOptions, routeOptionsCache) {
|
|
22
|
-
return (request, h) => {
|
|
23
|
-
const handler = validateRoute(request, h, pluginOptions);
|
|
24
|
-
const rawContentLength = request.headers['content-length'];
|
|
25
|
-
if (!rawContentLength) {
|
|
26
|
-
return h.continue;
|
|
27
|
-
}
|
|
28
|
-
const contentLength = Number.parseInt(rawContentLength, 10);
|
|
29
|
-
if (Number.isNaN(contentLength)) {
|
|
30
|
-
return h.continue;
|
|
31
|
-
}
|
|
32
|
-
const { route, options } = getRouteAndOptions(request, routeOptionsCache) ?? {};
|
|
33
|
-
if (route != null) {
|
|
34
|
-
return handler(contentLength, LIMIT_OPTION_NAMES.REGISTERED, options, option => {
|
|
35
|
-
return h
|
|
36
|
-
.response({
|
|
37
|
-
error: 'Request Entity Too Large',
|
|
38
|
-
message: `Payload content length greater than maximum allowed: ${option}`,
|
|
39
|
-
statusCode: 413,
|
|
40
|
-
})
|
|
41
|
-
.code(413);
|
|
42
|
-
});
|
|
43
|
-
}
|
|
44
|
-
assert(options == null, "Unregistered routes can't have route options");
|
|
45
|
-
return handler(contentLength, LIMIT_OPTION_NAMES.UNREGISTERED, null, () => {
|
|
46
|
-
return h.response({ error: 'Not Found', message: 'Not Found', statusCode: 404 }).code(404);
|
|
47
|
-
});
|
|
48
|
-
};
|
|
49
|
-
}
|
|
50
|
-
function getRouteAndOptions(request, routeOptionsCache) {
|
|
51
|
-
const matchedRoute = request.server.match(request.method, request.path, request.info.host);
|
|
52
|
-
if (matchedRoute == null) {
|
|
53
|
-
return null;
|
|
54
|
-
}
|
|
55
|
-
const cacheKey = `${matchedRoute.method}-${matchedRoute.path}`;
|
|
56
|
-
const cachedResult = routeOptionsCache.get(cacheKey);
|
|
57
|
-
if (cachedResult != null) {
|
|
58
|
-
return { options: cachedResult, route: matchedRoute };
|
|
59
|
-
}
|
|
60
|
-
const options = getRoutePluginSettings(matchedRoute);
|
|
61
|
-
routeOptionsCache.set(cacheKey, options);
|
|
62
|
-
return { options, route: matchedRoute };
|
|
63
|
-
}
|
|
64
|
-
function validateRoute(request, h, options) {
|
|
65
|
-
return (contentLength, optionName, routeOptions, response) => {
|
|
66
|
-
const option = options?.[optionName];
|
|
67
|
-
const limit = routeOptions?.[PACKAGE_NAME]?.limit ?? option;
|
|
68
|
-
if (limit == null) {
|
|
69
|
-
return h.continue;
|
|
70
|
-
}
|
|
71
|
-
if (limit === false) {
|
|
72
|
-
return h.continue;
|
|
73
|
-
}
|
|
74
|
-
if (limit === true) {
|
|
75
|
-
const result = response(0).takeover();
|
|
76
|
-
request.raw.res.once('finish', () => {
|
|
77
|
-
request.raw.req.socket.end();
|
|
78
|
-
});
|
|
79
|
-
return result;
|
|
80
|
-
}
|
|
81
|
-
if (contentLength <= limit) {
|
|
82
|
-
return h.continue;
|
|
83
|
-
}
|
|
84
|
-
const result = response(limit).takeover();
|
|
85
|
-
request.raw.res.once('finish', () => {
|
|
86
|
-
request.raw.req.socket.end();
|
|
87
|
-
});
|
|
88
|
-
return result;
|
|
89
|
-
};
|
|
90
|
-
}
|
|
91
|
-
function getRoutePluginSettings(matchedRoute) {
|
|
92
|
-
return TerminatorRouteOptionsSchema.safeParse(matchedRoute?.settings.plugins).data;
|
|
93
|
-
}
|
|
94
|
-
export default { plugin };
|
package/dist/package.json
DELETED
|
@@ -1,52 +0,0 @@
|
|
|
1
|
-
{
|
|
2
|
-
"name": "hapi-terminator",
|
|
3
|
-
"main": "dist/index.js",
|
|
4
|
-
"typings": "dist/index.d.ts",
|
|
5
|
-
"type": "module",
|
|
6
|
-
"version": "0.3.0",
|
|
7
|
-
"license": "MIT",
|
|
8
|
-
"author": {
|
|
9
|
-
"name": "Kamaal Farah"
|
|
10
|
-
},
|
|
11
|
-
"devDependencies": {
|
|
12
|
-
"@eslint/js": "^9.39.2",
|
|
13
|
-
"@hapi/hapi": "^21.4.4",
|
|
14
|
-
"@kamaalio/prettier-config": "^0.1.2",
|
|
15
|
-
"@types/bun": "latest",
|
|
16
|
-
"eslint": "^9.39.2",
|
|
17
|
-
"globals": "^17.0.0",
|
|
18
|
-
"husky": "^9.1.7",
|
|
19
|
-
"jiti": "^2.6.1",
|
|
20
|
-
"lint-staged": "^16.2.7",
|
|
21
|
-
"prettier": "^3.7.4",
|
|
22
|
-
"typescript": "^5.9.3",
|
|
23
|
-
"typescript-eslint": "^8.52.0"
|
|
24
|
-
},
|
|
25
|
-
"dependencies": {
|
|
26
|
-
"zod": "^4.3.5"
|
|
27
|
-
},
|
|
28
|
-
"prettier": "@kamaalio/prettier-config",
|
|
29
|
-
"lint-staged": {
|
|
30
|
-
"**/*.{js,ts,tsx}": [
|
|
31
|
-
"eslint --fix"
|
|
32
|
-
],
|
|
33
|
-
"**/*": "prettier --write --ignore-unknown"
|
|
34
|
-
},
|
|
35
|
-
"scripts": {
|
|
36
|
-
"compile": "tsc --project tsconfig.build.json",
|
|
37
|
-
"format": "prettier --write .",
|
|
38
|
-
"format:check": "prettier --check .",
|
|
39
|
-
"lint": "eslint .",
|
|
40
|
-
"prepare": "bunx husky",
|
|
41
|
-
"prepack": "rm -rf dist && bun run compile",
|
|
42
|
-
"quality": "bun run format:check && bun run lint && bun run typecheck",
|
|
43
|
-
"test": "bun test",
|
|
44
|
-
"typecheck": "tsc --noEmit"
|
|
45
|
-
},
|
|
46
|
-
"publishConfig": {
|
|
47
|
-
"access": "public"
|
|
48
|
-
},
|
|
49
|
-
"files": [
|
|
50
|
-
"dist"
|
|
51
|
-
]
|
|
52
|
-
}
|