@zudojs/http 1.1.0 → 1.2.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 +53 -3
- package/dist/httpAdapter/http.adapter.d.ts +34 -3
- package/dist/httpAdapter/http.adapter.js +22 -6
- package/dist/httpAdapter/http.adapters.js +4 -34
- package/dist/httpAdapter/httpAdapter.errorResponse.d.ts +2 -2
- package/dist/httpAdapter/httpAdapter.errorResponse.js +26 -9
- package/dist/httpAdapter/httpAdapter.logger.d.ts +19 -0
- package/dist/httpAdapter/httpAdapter.logger.js +21 -0
- package/dist/httpAdapter/node/httpNode.adapter.d.ts +12 -1
- package/dist/httpAdapter/node/httpNode.adapter.js +28 -14
- package/dist/httpAdapter/node/httpNode.request.d.ts +8 -7
- package/dist/httpAdapter/node/httpNode.request.js +17 -27
- package/dist/httpAdapter/node/httpNode.server.d.ts +6 -0
- package/dist/httpAdapter/node/httpNode.server.js +6 -0
- package/dist/httpAdapter/node/httpNode.type.d.ts +2 -1
- package/dist/httpCookies/http.cookies.d.ts +29 -2
- package/dist/httpCookies/http.cookies.js +44 -24
- package/dist/httpCookies/httpCookies.defaults.d.ts +40 -0
- package/dist/httpCookies/httpCookies.defaults.js +42 -0
- package/dist/httpCookies/index.d.ts +1 -0
- package/dist/httpCookies/index.js +1 -0
- package/dist/httpMiddleware/builtin/conditional/httpMiddleware.conditional.d.ts +22 -1
- package/dist/httpMiddleware/builtin/conditional/httpMiddleware.conditional.js +19 -4
- package/dist/httpMiddleware/builtin/conditional/index.d.ts +1 -0
- package/dist/httpMiddleware/builtin/cors/httpMiddleware.cors.d.ts +8 -0
- package/dist/httpMiddleware/builtin/cors/httpMiddleware.cors.js +25 -22
- package/dist/httpMiddleware/builtin/helpers/httpMiddleware.accessor.js +2 -6
- package/dist/httpMiddleware/builtin/helpers/httpMiddleware.media.d.ts +33 -0
- package/dist/httpMiddleware/builtin/helpers/httpMiddleware.media.js +59 -0
- package/dist/httpMiddleware/builtin/helpers/index.d.ts +1 -0
- package/dist/httpMiddleware/builtin/helpers/index.js +1 -0
- package/dist/httpMiddleware/builtin/image/httpMiddleware.image.d.ts +13 -0
- package/dist/httpMiddleware/builtin/image/httpMiddleware.image.js +31 -53
- package/dist/httpMiddleware/builtin/index.d.ts +1 -0
- package/dist/httpMiddleware/builtin/index.js +1 -0
- package/dist/httpMiddleware/builtin/rateLimit/httpMiddleware.rateLimit.d.ts +47 -0
- package/dist/httpMiddleware/builtin/rateLimit/httpMiddleware.rateLimit.js +61 -0
- package/dist/httpMiddleware/builtin/rateLimit/index.d.ts +7 -0
- package/dist/httpMiddleware/builtin/rateLimit/index.js +7 -0
- package/dist/httpMiddleware/builtin/static/httpMiddleware.static.js +18 -4
- package/dist/httpMiddleware/builtin/video/httpMiddleware.video.d.ts +19 -1
- package/dist/httpMiddleware/builtin/video/httpMiddleware.video.js +38 -59
- package/dist/httpMiddleware/builtin/video/httpMiddleware.video.options.d.ts +12 -0
- package/dist/httpMiddleware/builtin/video/httpMiddleware.video.options.js +31 -0
- package/dist/httpMiddleware/httpMiddleware.error.d.ts +5 -31
- package/dist/httpMiddleware/httpMiddleware.error.js +5 -53
- package/dist/httpProxy/http.proxy.d.ts +7 -0
- package/dist/httpProxy/http.proxy.js +9 -0
- package/dist/httpProxy/httpProxy.pathGuard.d.ts +23 -0
- package/dist/httpProxy/httpProxy.pathGuard.js +43 -0
- package/dist/httpProxy/index.d.ts +1 -0
- package/dist/httpProxy/index.js +1 -0
- package/dist/httpRequest/httpRequest.context.d.ts +7 -0
- package/dist/httpRequest/httpRequest.context.js +15 -16
- package/dist/httpRequest/index.d.ts +1 -0
- package/dist/httpRequest/index.js +1 -0
- package/dist/httpRequest/target/httpRequest.target.d.ts +45 -0
- package/dist/httpRequest/target/httpRequest.target.js +93 -0
- package/dist/httpRequest/target/index.d.ts +9 -0
- package/dist/httpRequest/target/index.js +9 -0
- package/dist/httpResponse/httpResponse.helper.js +2 -1
- package/dist/httpRouter/core/util/httpRoute.util.d.ts +14 -0
- package/dist/httpRouter/core/util/httpRoute.util.js +18 -14
- package/dist/httpSecurity/httpSecurity.config.d.ts +6 -0
- package/dist/httpSecurity/httpSecurity.config.js +1 -0
- package/dist/httpSecurity/httpSecurity.guard.d.ts +7 -9
- package/dist/httpSecurity/httpSecurity.guard.js +7 -14
- package/dist/httpSecurity/httpSecurity.nodeGuard.d.ts +48 -0
- package/dist/httpSecurity/httpSecurity.nodeGuard.js +32 -0
- package/dist/httpSecurity/httpSecurity.validator.js +4 -2
- package/dist/httpSecurity/index.d.ts +2 -0
- package/dist/httpSecurity/index.js +1 -0
- package/dist/httpServer/core/httpServer.core.d.ts +11 -0
- package/dist/httpServer/core/httpServer.core.js +30 -4
- package/dist/httpTrustProxy/httpTrustProxy.compilation.d.ts +5 -2
- package/dist/httpTrustProxy/httpTrustProxy.compilation.js +19 -2
- package/dist/httpTrustProxy/httpTrustProxy.type.d.ts +3 -1
- package/dist/index.js +0 -1
- package/package.json +5 -5
|
@@ -5,23 +5,14 @@
|
|
|
5
5
|
*
|
|
6
6
|
* Requires: npm install sharp
|
|
7
7
|
*/
|
|
8
|
-
import {
|
|
8
|
+
import { applyCompressedBody, getResponseBytes, getResponseMediaType, loadOptionalModule, } from "../helpers/index.js";
|
|
9
9
|
const DEFAULT_QUALITY = 80;
|
|
10
10
|
const DEFAULT_FORMAT = "jpeg";
|
|
11
|
-
async function getSharp() {
|
|
12
|
-
try {
|
|
13
|
-
const mod = await import("sharp");
|
|
14
|
-
return mod.default ?? mod;
|
|
15
|
-
}
|
|
16
|
-
catch {
|
|
17
|
-
throw new Error("Sharp is not installed. Run: npm install sharp");
|
|
18
|
-
}
|
|
19
|
-
}
|
|
20
11
|
export async function compressImage(buffer, options = {}) {
|
|
21
|
-
const
|
|
12
|
+
const sharp = await loadOptionalModule("sharp", "npm install sharp");
|
|
22
13
|
const quality = options.quality ?? DEFAULT_QUALITY;
|
|
23
14
|
const format = options.format ?? DEFAULT_FORMAT;
|
|
24
|
-
let pipeline =
|
|
15
|
+
let pipeline = sharp(buffer);
|
|
25
16
|
if (options.width || options.height) {
|
|
26
17
|
pipeline = pipeline.resize({
|
|
27
18
|
width: options.width,
|
|
@@ -30,59 +21,46 @@ export async function compressImage(buffer, options = {}) {
|
|
|
30
21
|
withoutEnlargement: true,
|
|
31
22
|
});
|
|
32
23
|
}
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
case "webp":
|
|
39
|
-
return pipeline.webp({ quality }).toBuffer();
|
|
40
|
-
case "avif":
|
|
41
|
-
return pipeline.avif({ quality }).toBuffer();
|
|
42
|
-
default:
|
|
43
|
-
return pipeline.toBuffer();
|
|
24
|
+
return pipeline[format]({ quality }).toBuffer();
|
|
25
|
+
}
|
|
26
|
+
function capDimension(requested, max) {
|
|
27
|
+
if (max === undefined) {
|
|
28
|
+
return requested;
|
|
44
29
|
}
|
|
30
|
+
return requested === undefined ? max : Math.min(requested, max);
|
|
45
31
|
}
|
|
32
|
+
/**
|
|
33
|
+
* Compresses `image/*` responses. Status, headers and body are read from the
|
|
34
|
+
* response returned by `next()`, and `maxWidth` / `maxHeight` cap every
|
|
35
|
+
* output.
|
|
36
|
+
*/
|
|
46
37
|
export function createImageCompressionMiddleware(options = {}) {
|
|
47
38
|
const enabled = options.enabled ?? true;
|
|
48
|
-
const defaultQuality = options.defaultQuality ?? DEFAULT_QUALITY;
|
|
49
|
-
const defaultFormat = options.defaultFormat ?? DEFAULT_FORMAT;
|
|
50
39
|
const contentTypeMap = options.contentTypeMap ?? {};
|
|
51
|
-
return async (
|
|
52
|
-
if (!enabled) {
|
|
53
|
-
return next();
|
|
54
|
-
}
|
|
40
|
+
return async (_context, next) => {
|
|
55
41
|
const response = await next();
|
|
56
|
-
const
|
|
57
|
-
if (!
|
|
42
|
+
const mediaType = getResponseMediaType(response);
|
|
43
|
+
if (!enabled || !mediaType?.startsWith("image/")) {
|
|
58
44
|
return response;
|
|
59
45
|
}
|
|
60
|
-
const
|
|
61
|
-
if (!
|
|
46
|
+
const buffer = getResponseBytes(response);
|
|
47
|
+
if (!buffer || buffer.length === 0) {
|
|
62
48
|
return response;
|
|
63
49
|
}
|
|
64
|
-
const
|
|
65
|
-
const
|
|
66
|
-
quality: defaultQuality,
|
|
67
|
-
format: defaultFormat,
|
|
68
|
-
};
|
|
50
|
+
const mapped = contentTypeMap[mediaType] ?? {};
|
|
51
|
+
const format = mapped.format ?? options.defaultFormat ?? DEFAULT_FORMAT;
|
|
69
52
|
try {
|
|
70
|
-
const compressed = await compressImage(buffer,
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
? "image/avif"
|
|
79
|
-
: contentType;
|
|
80
|
-
const headers = new Headers(response.headers);
|
|
81
|
-
headers.set("content-type", newContentType);
|
|
82
|
-
headers.set("cache-control", headers.get("cache-control") ?? "public, max-age=86400");
|
|
83
|
-
return applyHeadersToResponse(response, headers).setBody(compressed);
|
|
53
|
+
const compressed = await compressImage(buffer, {
|
|
54
|
+
...mapped,
|
|
55
|
+
quality: mapped.quality ?? options.defaultQuality ?? DEFAULT_QUALITY,
|
|
56
|
+
format,
|
|
57
|
+
width: capDimension(mapped.width, options.maxWidth),
|
|
58
|
+
height: capDimension(mapped.height, options.maxHeight),
|
|
59
|
+
});
|
|
60
|
+
return applyCompressedBody(response, compressed, `image/${format}`);
|
|
84
61
|
}
|
|
85
|
-
catch {
|
|
62
|
+
catch (error) {
|
|
63
|
+
options.onError?.(error, response);
|
|
86
64
|
return response;
|
|
87
65
|
}
|
|
88
66
|
};
|
|
@@ -13,6 +13,7 @@ export * from "./helpers/index.js";
|
|
|
13
13
|
export * from "./static/index.js";
|
|
14
14
|
export * from "./image/index.js";
|
|
15
15
|
export * from "./video/index.js";
|
|
16
|
+
export * from "./rateLimit/index.js";
|
|
16
17
|
/** State middleware — identity pass-through. */
|
|
17
18
|
export declare function createStateMiddleware(): import("../httpMiddleware.type.js").HttpMiddleware;
|
|
18
19
|
//# sourceMappingURL=index.d.ts.map
|
|
@@ -13,6 +13,7 @@ export * from "./helpers/index.js";
|
|
|
13
13
|
export * from "./static/index.js";
|
|
14
14
|
export * from "./image/index.js";
|
|
15
15
|
export * from "./video/index.js";
|
|
16
|
+
export * from "./rateLimit/index.js";
|
|
16
17
|
/** State middleware — identity pass-through. */
|
|
17
18
|
export function createStateMiddleware() {
|
|
18
19
|
return async (_context, next) => {
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Rate limiting middleware built on `@zudojs/security`.
|
|
3
|
+
*
|
|
4
|
+
* AGENTS.md requires public endpoints to be rate limited with
|
|
5
|
+
* `createRateLimiter` from `@zudojs/security`, and `@zudojs/http` offered no
|
|
6
|
+
* way to do that. This wraps the shared limiter; it does not re-implement it.
|
|
7
|
+
*
|
|
8
|
+
* The client key is `request.remoteAddress`, which the Node adapter has
|
|
9
|
+
* already resolved through its `trustProxy` setting, so forwarded headers
|
|
10
|
+
* only count when the adapter trusts the peer that sent them.
|
|
11
|
+
*
|
|
12
|
+
* A request with no usable client address (a Unix-socket peer, a context
|
|
13
|
+
* built without `remoteAddress`, a destroyed socket) is counted in one
|
|
14
|
+
* shared bucket, {@link UNKNOWN_CLIENT_RATE_LIMIT_IP}. The `@zudojs/security`
|
|
15
|
+
* default key generator refuses such a request with a `ConfigurationError`,
|
|
16
|
+
* which would otherwise surface as a 500, and skipping the limiter for it
|
|
17
|
+
* would give it unlimited requests.
|
|
18
|
+
*
|
|
19
|
+
* @module httpMiddleware/builtin/rateLimit
|
|
20
|
+
*/
|
|
21
|
+
import { createRateLimiter, type RateLimiterOptions } from "@zudojs/security";
|
|
22
|
+
import type { HttpMiddleware } from "../../httpMiddleware.type.js";
|
|
23
|
+
/**
|
|
24
|
+
* The `ip` given to the limiter for a request whose `remoteAddress` is
|
|
25
|
+
* missing or is not an IP address. `0.0.0.0` is never a real peer address,
|
|
26
|
+
* so these requests share one bucket without colliding with a client.
|
|
27
|
+
*/
|
|
28
|
+
export declare const UNKNOWN_CLIENT_RATE_LIMIT_IP = "0.0.0.0";
|
|
29
|
+
/**
|
|
30
|
+
* The limiter instance returned by `@zudojs/security`'s `createRateLimiter`.
|
|
31
|
+
*/
|
|
32
|
+
export type HttpRateLimiter = ReturnType<typeof createRateLimiter>;
|
|
33
|
+
/**
|
|
34
|
+
* Options for {@link createRateLimitMiddleware}: either the
|
|
35
|
+
* `@zudojs/security` limiter options, or an existing `limiter` to share
|
|
36
|
+
* between routes.
|
|
37
|
+
*/
|
|
38
|
+
export type RateLimitMiddlewareOptions = RateLimiterOptions | {
|
|
39
|
+
readonly limiter: HttpRateLimiter;
|
|
40
|
+
};
|
|
41
|
+
/**
|
|
42
|
+
* Creates middleware that answers `429 Too Many Requests` (with
|
|
43
|
+
* `Retry-After`, from the `@zudojs/security` handler) once a client exceeds
|
|
44
|
+
* its allowance, and otherwise passes the request on.
|
|
45
|
+
*/
|
|
46
|
+
export declare function createRateLimitMiddleware(options: RateLimitMiddlewareOptions): HttpMiddleware;
|
|
47
|
+
//# sourceMappingURL=httpMiddleware.rateLimit.d.ts.map
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Rate limiting middleware built on `@zudojs/security`.
|
|
3
|
+
*
|
|
4
|
+
* AGENTS.md requires public endpoints to be rate limited with
|
|
5
|
+
* `createRateLimiter` from `@zudojs/security`, and `@zudojs/http` offered no
|
|
6
|
+
* way to do that. This wraps the shared limiter; it does not re-implement it.
|
|
7
|
+
*
|
|
8
|
+
* The client key is `request.remoteAddress`, which the Node adapter has
|
|
9
|
+
* already resolved through its `trustProxy` setting, so forwarded headers
|
|
10
|
+
* only count when the adapter trusts the peer that sent them.
|
|
11
|
+
*
|
|
12
|
+
* A request with no usable client address (a Unix-socket peer, a context
|
|
13
|
+
* built without `remoteAddress`, a destroyed socket) is counted in one
|
|
14
|
+
* shared bucket, {@link UNKNOWN_CLIENT_RATE_LIMIT_IP}. The `@zudojs/security`
|
|
15
|
+
* default key generator refuses such a request with a `ConfigurationError`,
|
|
16
|
+
* which would otherwise surface as a 500, and skipping the limiter for it
|
|
17
|
+
* would give it unlimited requests.
|
|
18
|
+
*
|
|
19
|
+
* @module httpMiddleware/builtin/rateLimit
|
|
20
|
+
*/
|
|
21
|
+
import { createRateLimiter, parseClientIp, } from "@zudojs/security";
|
|
22
|
+
import { createResponseContext } from "../../../httpResponse/httpResponse.context.js";
|
|
23
|
+
/**
|
|
24
|
+
* The `ip` given to the limiter for a request whose `remoteAddress` is
|
|
25
|
+
* missing or is not an IP address. `0.0.0.0` is never a real peer address,
|
|
26
|
+
* so these requests share one bucket without colliding with a client.
|
|
27
|
+
*/
|
|
28
|
+
export const UNKNOWN_CLIENT_RATE_LIMIT_IP = "0.0.0.0";
|
|
29
|
+
/**
|
|
30
|
+
* Creates middleware that answers `429 Too Many Requests` (with
|
|
31
|
+
* `Retry-After`, from the `@zudojs/security` handler) once a client exceeds
|
|
32
|
+
* its allowance, and otherwise passes the request on.
|
|
33
|
+
*/
|
|
34
|
+
export function createRateLimitMiddleware(options) {
|
|
35
|
+
const limiter = "limiter" in options ? options.limiter : createRateLimiter(options);
|
|
36
|
+
return async (context, next) => {
|
|
37
|
+
const request = context.request;
|
|
38
|
+
const limitRequest = {
|
|
39
|
+
ip: clientIpOf(request.remoteAddress),
|
|
40
|
+
method: request.method,
|
|
41
|
+
path: request.path,
|
|
42
|
+
headers: request.headers,
|
|
43
|
+
};
|
|
44
|
+
const rejection = { statusCode: 429, headers: {} };
|
|
45
|
+
if (limiter.middleware(limitRequest, rejection).allowed) {
|
|
46
|
+
return next();
|
|
47
|
+
}
|
|
48
|
+
const response = createResponseContext().setStatus(rejection.statusCode);
|
|
49
|
+
for (const [name, value] of Object.entries(rejection.headers)) {
|
|
50
|
+
response.setHeader(name.toLowerCase(), value);
|
|
51
|
+
}
|
|
52
|
+
return response.setBody(rejection.body ?? "Too Many Requests");
|
|
53
|
+
};
|
|
54
|
+
}
|
|
55
|
+
function clientIpOf(remoteAddress) {
|
|
56
|
+
return typeof remoteAddress === "string" &&
|
|
57
|
+
parseClientIp(remoteAddress) !== undefined
|
|
58
|
+
? remoteAddress
|
|
59
|
+
: UNKNOWN_CLIENT_RATE_LIMIT_IP;
|
|
60
|
+
}
|
|
61
|
+
//# sourceMappingURL=httpMiddleware.rateLimit.js.map
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @zudojs/http/httpMiddleware/builtin/rateLimit
|
|
3
|
+
*
|
|
4
|
+
* Rate limiting middleware wrapping `@zudojs/security`'s `createRateLimiter`.
|
|
5
|
+
*/
|
|
6
|
+
export { createRateLimitMiddleware, UNKNOWN_CLIENT_RATE_LIMIT_IP, type HttpRateLimiter, type RateLimitMiddlewareOptions, } from "./httpMiddleware.rateLimit.js";
|
|
7
|
+
//# sourceMappingURL=index.d.ts.map
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @zudojs/http/httpMiddleware/builtin/rateLimit
|
|
3
|
+
*
|
|
4
|
+
* Rate limiting middleware wrapping `@zudojs/security`'s `createRateLimiter`.
|
|
5
|
+
*/
|
|
6
|
+
export { createRateLimitMiddleware, UNKNOWN_CLIENT_RATE_LIMIT_IP, } from "./httpMiddleware.rateLimit.js";
|
|
7
|
+
//# sourceMappingURL=index.js.map
|
|
@@ -7,6 +7,8 @@ import { readFile, stat } from "node:fs/promises";
|
|
|
7
7
|
import { createHash } from "node:crypto";
|
|
8
8
|
import { extname, join, resolve, sep } from "node:path";
|
|
9
9
|
import { applyHeadersToResponse } from "../helpers/index.js";
|
|
10
|
+
import { parseRequestTarget } from "../../../httpRequest/target/httpRequest.target.js";
|
|
11
|
+
import { evaluateConditionalRequest } from "../../../httpConditional/httpConditional.core.js";
|
|
10
12
|
const DEFAULT_INDEX = "index.html";
|
|
11
13
|
const DEFAULT_MAX_AGE = 3600;
|
|
12
14
|
const DEFAULT_MAX_FILE_SIZE = 10 * 1024 * 1024;
|
|
@@ -141,7 +143,7 @@ export function createStaticMiddleware(options) {
|
|
|
141
143
|
*/
|
|
142
144
|
let url;
|
|
143
145
|
try {
|
|
144
|
-
url =
|
|
146
|
+
url = parseRequestTarget(context.request.url);
|
|
145
147
|
}
|
|
146
148
|
catch {
|
|
147
149
|
return next();
|
|
@@ -200,9 +202,21 @@ export function createStaticMiddleware(options) {
|
|
|
200
202
|
* returns the whole body and has no 206 path, so advertising range
|
|
201
203
|
* support makes range-aware clients misbehave.
|
|
202
204
|
*/
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
205
|
+
/*
|
|
206
|
+
* RFC 9110 section 13.2.2 evaluation: `If-None-Match` lists, weak tags
|
|
207
|
+
* and `*`, then `If-Modified-Since`, plus `If-Match` /
|
|
208
|
+
* `If-Unmodified-Since` (412). An exact string compare missed all of
|
|
209
|
+
* those and re-sent the whole file.
|
|
210
|
+
*/
|
|
211
|
+
const headers = context.request.headers;
|
|
212
|
+
const conditional = evaluateConditionalRequest(context.request.method, {
|
|
213
|
+
ifMatch: headers["if-match"],
|
|
214
|
+
ifNoneMatch: headers["if-none-match"],
|
|
215
|
+
ifModifiedSince: headers["if-modified-since"],
|
|
216
|
+
ifUnmodifiedSince: headers["if-unmodified-since"],
|
|
217
|
+
}, { etag, lastModified });
|
|
218
|
+
if (conditional.statusCode !== undefined) {
|
|
219
|
+
return applyHeadersToResponse(context.response, responseHeaders).setStatus(conditional.statusCode);
|
|
206
220
|
}
|
|
207
221
|
return applyHeadersToResponse(context.response, responseHeaders).setBody(fileData);
|
|
208
222
|
};
|
|
@@ -3,14 +3,17 @@
|
|
|
3
3
|
*
|
|
4
4
|
* @module httpMiddleware/builtin/video
|
|
5
5
|
*
|
|
6
|
-
* Requires: npm install fluent-ffmpeg
|
|
6
|
+
* Requires: npm install fluent-ffmpeg (and an ffmpeg binary)
|
|
7
7
|
*/
|
|
8
8
|
import type { HttpMiddleware } from "../../httpMiddleware.type.js";
|
|
9
|
+
import { type MediaCompressionErrorHandler } from "../helpers/index.js";
|
|
9
10
|
export interface VideoCompressionOptions {
|
|
10
11
|
readonly bitrate?: string;
|
|
11
12
|
readonly preset?: "ultrafast" | "superfast" | "veryfast" | "faster" | "fast" | "medium" | "slow" | "slower" | "veryslow";
|
|
12
13
|
readonly crf?: number;
|
|
13
14
|
readonly format?: "mp4" | "webm" | "mov";
|
|
15
|
+
/** Input container; defaults to `mp4`. */
|
|
16
|
+
readonly inputFormat?: string;
|
|
14
17
|
readonly scale?: {
|
|
15
18
|
readonly width?: number;
|
|
16
19
|
readonly height?: number;
|
|
@@ -20,7 +23,22 @@ export interface VideoCompressionMiddlewareOptions {
|
|
|
20
23
|
readonly enabled?: boolean;
|
|
21
24
|
readonly contentTypeMap?: Record<string, VideoCompressionOptions>;
|
|
22
25
|
readonly tempDir?: string;
|
|
26
|
+
/**
|
|
27
|
+
* Called when compression fails. The original response is still served.
|
|
28
|
+
*/
|
|
29
|
+
readonly onError?: MediaCompressionErrorHandler;
|
|
23
30
|
}
|
|
31
|
+
/**
|
|
32
|
+
* Transcodes a video held in memory.
|
|
33
|
+
*
|
|
34
|
+
* `fluent-ffmpeg`'s export is a factory: `input()` exists only on the
|
|
35
|
+
* command it returns. Calling it on the module (as this did) threw
|
|
36
|
+
* `ffmpegInstance.input is not a function` on every call.
|
|
37
|
+
*/
|
|
24
38
|
export declare function compressVideo(inputBuffer: Buffer, options?: VideoCompressionOptions): Promise<Buffer>;
|
|
39
|
+
/**
|
|
40
|
+
* Compresses `video/*` responses, reading status, headers and body from the
|
|
41
|
+
* response returned by `next()`.
|
|
42
|
+
*/
|
|
25
43
|
export declare function createVideoCompressionMiddleware(options?: VideoCompressionMiddlewareOptions): HttpMiddleware;
|
|
26
44
|
//# sourceMappingURL=httpMiddleware.video.d.ts.map
|
|
@@ -3,79 +3,58 @@
|
|
|
3
3
|
*
|
|
4
4
|
* @module httpMiddleware/builtin/video
|
|
5
5
|
*
|
|
6
|
-
* Requires: npm install fluent-ffmpeg
|
|
6
|
+
* Requires: npm install fluent-ffmpeg (and an ffmpeg binary)
|
|
7
|
+
*/
|
|
8
|
+
import { Readable } from "node:stream";
|
|
9
|
+
import { applyCompressedBody, getResponseBytes, getResponseMediaType, loadOptionalModule, } from "../helpers/index.js";
|
|
10
|
+
import { CONTAINER_TYPES, DEFAULT_VIDEO_FORMAT as DEFAULT_FORMAT, buildOutputOptions, } from "./httpMiddleware.video.options.js";
|
|
11
|
+
/**
|
|
12
|
+
* Transcodes a video held in memory.
|
|
13
|
+
*
|
|
14
|
+
* `fluent-ffmpeg`'s export is a factory: `input()` exists only on the
|
|
15
|
+
* command it returns. Calling it on the module (as this did) threw
|
|
16
|
+
* `ffmpegInstance.input is not a function` on every call.
|
|
7
17
|
*/
|
|
8
|
-
import { applyHeadersToResponse } from "../helpers/index.js";
|
|
9
|
-
const DEFAULT_CRF = 28;
|
|
10
|
-
const DEFAULT_PRESET = "medium";
|
|
11
|
-
const DEFAULT_FORMAT = "mp4";
|
|
12
|
-
async function getFfmpeg() {
|
|
13
|
-
try {
|
|
14
|
-
const mod = await import("fluent-ffmpeg");
|
|
15
|
-
return mod.default ?? mod;
|
|
16
|
-
}
|
|
17
|
-
catch {
|
|
18
|
-
throw new Error("fluent-ffmpeg is not installed. Run: npm install fluent-ffmpeg");
|
|
19
|
-
}
|
|
20
|
-
}
|
|
21
18
|
export async function compressVideo(inputBuffer, options = {}) {
|
|
22
|
-
const
|
|
23
|
-
const crf = options.crf ?? DEFAULT_CRF;
|
|
24
|
-
const preset = options.preset ?? DEFAULT_PRESET;
|
|
25
|
-
const format = options.format ?? DEFAULT_FORMAT;
|
|
19
|
+
const ffmpeg = await loadOptionalModule("fluent-ffmpeg", "npm install fluent-ffmpeg");
|
|
26
20
|
return new Promise((resolve, reject) => {
|
|
27
|
-
const
|
|
28
|
-
|
|
29
|
-
.input(inputBuffer)
|
|
30
|
-
.inputFormat("mp4")
|
|
31
|
-
.outputOptions(
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
resolve(Buffer.concat(outputBuffer));
|
|
39
|
-
})
|
|
40
|
-
.on("error", (err) => {
|
|
41
|
-
reject(err);
|
|
42
|
-
})
|
|
43
|
-
.pipe((err, stdout) => {
|
|
44
|
-
if (err) {
|
|
45
|
-
reject(err);
|
|
46
|
-
return;
|
|
47
|
-
}
|
|
48
|
-
stdout.on("data", (chunk) => {
|
|
49
|
-
outputBuffer.push(chunk);
|
|
50
|
-
});
|
|
51
|
-
});
|
|
21
|
+
const chunks = [];
|
|
22
|
+
const output = ffmpeg()
|
|
23
|
+
.input(Readable.from([inputBuffer]))
|
|
24
|
+
.inputFormat(options.inputFormat ?? "mp4")
|
|
25
|
+
.outputOptions(buildOutputOptions(options))
|
|
26
|
+
.format(options.format ?? DEFAULT_FORMAT)
|
|
27
|
+
.on("error", reject)
|
|
28
|
+
.pipe();
|
|
29
|
+
output.on("data", (chunk) => chunks.push(chunk));
|
|
30
|
+
output.on("end", () => resolve(Buffer.concat(chunks)));
|
|
31
|
+
output.on("error", reject);
|
|
52
32
|
});
|
|
53
33
|
}
|
|
34
|
+
/**
|
|
35
|
+
* Compresses `video/*` responses, reading status, headers and body from the
|
|
36
|
+
* response returned by `next()`.
|
|
37
|
+
*/
|
|
54
38
|
export function createVideoCompressionMiddleware(options = {}) {
|
|
55
39
|
const enabled = options.enabled ?? true;
|
|
56
|
-
return async (
|
|
57
|
-
if (!enabled) {
|
|
58
|
-
return next();
|
|
59
|
-
}
|
|
40
|
+
return async (_context, next) => {
|
|
60
41
|
const response = await next();
|
|
61
|
-
const
|
|
62
|
-
if (!
|
|
42
|
+
const mediaType = getResponseMediaType(response);
|
|
43
|
+
if (!enabled || !mediaType?.startsWith("video/")) {
|
|
63
44
|
return response;
|
|
64
45
|
}
|
|
65
|
-
const body =
|
|
66
|
-
if (!
|
|
46
|
+
const body = getResponseBytes(response);
|
|
47
|
+
if (!body || body.length === 0) {
|
|
67
48
|
return response;
|
|
68
49
|
}
|
|
69
|
-
const
|
|
50
|
+
const compression = options.contentTypeMap?.[mediaType] ?? {};
|
|
51
|
+
const format = compression.format ?? DEFAULT_FORMAT;
|
|
70
52
|
try {
|
|
71
|
-
const compressed = await compressVideo(body,
|
|
72
|
-
|
|
73
|
-
const headers = new Headers(response.headers);
|
|
74
|
-
headers.set("content-type", newContentType);
|
|
75
|
-
headers.set("cache-control", headers.get("cache-control") ?? "public, max-age=86400");
|
|
76
|
-
return applyHeadersToResponse(response, headers).setBody(compressed);
|
|
53
|
+
const compressed = await compressVideo(body, compression);
|
|
54
|
+
return applyCompressedBody(response, compressed, CONTAINER_TYPES[format] ?? "video/mp4");
|
|
77
55
|
}
|
|
78
|
-
catch {
|
|
56
|
+
catch (error) {
|
|
57
|
+
options.onError?.(error, response);
|
|
79
58
|
return response;
|
|
80
59
|
}
|
|
81
60
|
};
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* FFmpeg argument building for the video compression middleware.
|
|
3
|
+
*
|
|
4
|
+
* @module httpMiddleware/builtin/video/options
|
|
5
|
+
*/
|
|
6
|
+
import type { VideoCompressionOptions } from "./httpMiddleware.video.js";
|
|
7
|
+
export declare const DEFAULT_VIDEO_FORMAT = "mp4";
|
|
8
|
+
/** Media type per output container. */
|
|
9
|
+
export declare const CONTAINER_TYPES: Readonly<Record<string, string>>;
|
|
10
|
+
/** Builds the ffmpeg output options for a compression request. */
|
|
11
|
+
export declare function buildOutputOptions(options: VideoCompressionOptions): string[];
|
|
12
|
+
//# sourceMappingURL=httpMiddleware.video.options.d.ts.map
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* FFmpeg argument building for the video compression middleware.
|
|
3
|
+
*
|
|
4
|
+
* @module httpMiddleware/builtin/video/options
|
|
5
|
+
*/
|
|
6
|
+
const DEFAULT_CRF = 28;
|
|
7
|
+
const DEFAULT_PRESET = "medium";
|
|
8
|
+
export const DEFAULT_VIDEO_FORMAT = "mp4";
|
|
9
|
+
/** Media type per output container. */
|
|
10
|
+
export const CONTAINER_TYPES = Object.freeze({
|
|
11
|
+
mp4: "video/mp4",
|
|
12
|
+
webm: "video/webm",
|
|
13
|
+
mov: "video/quicktime",
|
|
14
|
+
});
|
|
15
|
+
/** Builds the ffmpeg output options for a compression request. */
|
|
16
|
+
export function buildOutputOptions(options) {
|
|
17
|
+
const output = [
|
|
18
|
+
`-crf ${options.crf ?? DEFAULT_CRF}`,
|
|
19
|
+
`-preset ${options.preset ?? DEFAULT_PRESET}`,
|
|
20
|
+
/* A pipe is not seekable, so the moov atom must be fragmented. */
|
|
21
|
+
"-movflags frag_keyframe+empty_moov",
|
|
22
|
+
];
|
|
23
|
+
if (options.bitrate) {
|
|
24
|
+
output.push(`-b:v ${options.bitrate}`);
|
|
25
|
+
}
|
|
26
|
+
if (options.scale?.width || options.scale?.height) {
|
|
27
|
+
output.push(`-vf scale=${options.scale.width ?? -2}:${options.scale.height ?? -2}`);
|
|
28
|
+
}
|
|
29
|
+
return output;
|
|
30
|
+
}
|
|
31
|
+
//# sourceMappingURL=httpMiddleware.video.options.js.map
|
|
@@ -1,38 +1,12 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* HTTP middleware error types.
|
|
3
3
|
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
4
|
+
* `HttpMiddlewareError` and `HttpMiddlewarePipelineError` live in
|
|
5
|
+
* `@zudojs/errors` (both `BaseError`s, 500, not exposed, codes
|
|
6
|
+
* `HTTP_MIDDLEWARE_ERROR` / `MIDDLEWARE_PIPELINE_ERROR`); they are
|
|
7
|
+
* re-exported here so existing imports keep working.
|
|
6
8
|
*
|
|
7
9
|
* @module httpMiddleware/errors
|
|
8
10
|
*/
|
|
9
|
-
|
|
10
|
-
/**
|
|
11
|
-
* Error raised by an HTTP middleware function.
|
|
12
|
-
*/
|
|
13
|
-
export declare class HttpMiddlewareError extends BaseError {
|
|
14
|
-
/**
|
|
15
|
-
* The unique identifier of the middleware.
|
|
16
|
-
*/
|
|
17
|
-
readonly middlewareId: string | undefined;
|
|
18
|
-
/**
|
|
19
|
-
* The name of the middleware.
|
|
20
|
-
*/
|
|
21
|
-
readonly middlewareName: string | undefined;
|
|
22
|
-
constructor(message: string, options?: {
|
|
23
|
-
readonly middlewareId?: string;
|
|
24
|
-
readonly middlewareName?: string;
|
|
25
|
-
readonly cause?: unknown;
|
|
26
|
-
});
|
|
27
|
-
}
|
|
28
|
-
/**
|
|
29
|
-
* Error raised when the HTTP middleware pipeline fails.
|
|
30
|
-
*/
|
|
31
|
-
export declare class HttpMiddlewarePipelineError extends BaseError {
|
|
32
|
-
/**
|
|
33
|
-
* The errors that occurred during pipeline execution.
|
|
34
|
-
*/
|
|
35
|
-
readonly errors: readonly HttpMiddlewareError[];
|
|
36
|
-
constructor(errors: readonly HttpMiddlewareError[]);
|
|
37
|
-
}
|
|
11
|
+
export { HttpMiddlewareError, HttpMiddlewarePipelineError, type HttpMiddlewareErrorOptions, } from "@zudojs/errors";
|
|
38
12
|
//# sourceMappingURL=httpMiddleware.error.d.ts.map
|
|
@@ -1,60 +1,12 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* HTTP middleware error types.
|
|
3
3
|
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
4
|
+
* `HttpMiddlewareError` and `HttpMiddlewarePipelineError` live in
|
|
5
|
+
* `@zudojs/errors` (both `BaseError`s, 500, not exposed, codes
|
|
6
|
+
* `HTTP_MIDDLEWARE_ERROR` / `MIDDLEWARE_PIPELINE_ERROR`); they are
|
|
7
|
+
* re-exported here so existing imports keep working.
|
|
6
8
|
*
|
|
7
9
|
* @module httpMiddleware/errors
|
|
8
10
|
*/
|
|
9
|
-
|
|
10
|
-
/* -------------------------------------------------------------------------- */
|
|
11
|
-
/* Errors */
|
|
12
|
-
/* -------------------------------------------------------------------------- */
|
|
13
|
-
/**
|
|
14
|
-
* Error raised by an HTTP middleware function.
|
|
15
|
-
*/
|
|
16
|
-
export class HttpMiddlewareError extends BaseError {
|
|
17
|
-
/**
|
|
18
|
-
* The unique identifier of the middleware.
|
|
19
|
-
*/
|
|
20
|
-
middlewareId;
|
|
21
|
-
/**
|
|
22
|
-
* The name of the middleware.
|
|
23
|
-
*/
|
|
24
|
-
middlewareName;
|
|
25
|
-
constructor(message, options = {}) {
|
|
26
|
-
super(message, {
|
|
27
|
-
code: "HTTP_MIDDLEWARE_ERROR",
|
|
28
|
-
statusCode: 500,
|
|
29
|
-
expose: false,
|
|
30
|
-
cause: options.cause,
|
|
31
|
-
});
|
|
32
|
-
this.name = "HttpMiddlewareError";
|
|
33
|
-
this.middlewareId = options.middlewareId;
|
|
34
|
-
this.middlewareName = options.middlewareName;
|
|
35
|
-
}
|
|
36
|
-
}
|
|
37
|
-
/**
|
|
38
|
-
* Error raised when the HTTP middleware pipeline fails.
|
|
39
|
-
*/
|
|
40
|
-
export class HttpMiddlewarePipelineError extends BaseError {
|
|
41
|
-
/**
|
|
42
|
-
* The errors that occurred during pipeline execution.
|
|
43
|
-
*/
|
|
44
|
-
errors;
|
|
45
|
-
constructor(errors) {
|
|
46
|
-
const message = errors.length === 1
|
|
47
|
-
? `HTTP middleware pipeline failed: ${errors[0]?.message ?? "Unknown error"}`
|
|
48
|
-
: `HTTP middleware pipeline failed with ${errors.length} errors: ${errors
|
|
49
|
-
.map((e) => e.message)
|
|
50
|
-
.join(", ")}`;
|
|
51
|
-
super(message, {
|
|
52
|
-
code: "MIDDLEWARE_PIPELINE_ERROR",
|
|
53
|
-
statusCode: 500,
|
|
54
|
-
expose: false,
|
|
55
|
-
});
|
|
56
|
-
this.name = "HttpMiddlewarePipelineError";
|
|
57
|
-
this.errors = Object.freeze([...errors]);
|
|
58
|
-
}
|
|
59
|
-
}
|
|
11
|
+
export { HttpMiddlewareError, HttpMiddlewarePipelineError, } from "@zudojs/errors";
|
|
60
12
|
//# sourceMappingURL=httpMiddleware.error.js.map
|
|
@@ -128,6 +128,13 @@ export declare function assertSafeProxyTarget(target: string | URL, options?: Pr
|
|
|
128
128
|
export declare function assertSafeProxyRedirect(location: string | URL, currentURL: string | URL, options?: ProxySecurityOptions): URL;
|
|
129
129
|
export declare function resolveProxyTarget(target: string | URL, options?: ProxySecurityOptions): ProxyTarget;
|
|
130
130
|
export declare function isValidProxyTarget(target: string | URL | undefined | null): boolean;
|
|
131
|
+
/**
|
|
132
|
+
* Joins a request path onto a proxy base path.
|
|
133
|
+
*
|
|
134
|
+
* @throws {HttpError} 400 when `requestPath` contains a `.` / `..` segment
|
|
135
|
+
* (plain or percent-encoded), which an upstream would resolve outside
|
|
136
|
+
* `basePath`.
|
|
137
|
+
*/
|
|
131
138
|
export declare function joinProxyPath(basePath: string, requestPath: string): string;
|
|
132
139
|
export declare function rewriteProxyPath(path: string, options?: ProxyRewriteOptions): string;
|
|
133
140
|
export declare function normalizeProxyPath(path: string): string;
|
|
@@ -11,6 +11,7 @@ import { appendHeader, deleteHeader, getHeader, setHeader, } from "../httpProtoc
|
|
|
11
11
|
import { isValidHTTPURL, isValidHeaderValue } from "../httpValidation/index.js";
|
|
12
12
|
import { isLinkLocalAddress, isLoopbackAddress, isUniqueLocalAddress, parseIpAddress, } from "../httpTrustProxy/httpTrustProxy.ip.js";
|
|
13
13
|
import { assertSafeHeaderValue } from "../httpHeaders/security/index.js";
|
|
14
|
+
import { assertProxyPathContained } from "./httpProxy.pathGuard.js";
|
|
14
15
|
/* -------------------------------------------------------------------------- */
|
|
15
16
|
/* Target */
|
|
16
17
|
/* -------------------------------------------------------------------------- */
|
|
@@ -204,7 +205,15 @@ function parseProxyURL(target) {
|
|
|
204
205
|
/* -------------------------------------------------------------------------- */
|
|
205
206
|
/* Path Handling */
|
|
206
207
|
/* -------------------------------------------------------------------------- */
|
|
208
|
+
/**
|
|
209
|
+
* Joins a request path onto a proxy base path.
|
|
210
|
+
*
|
|
211
|
+
* @throws {HttpError} 400 when `requestPath` contains a `.` / `..` segment
|
|
212
|
+
* (plain or percent-encoded), which an upstream would resolve outside
|
|
213
|
+
* `basePath`.
|
|
214
|
+
*/
|
|
207
215
|
export function joinProxyPath(basePath, requestPath) {
|
|
216
|
+
assertProxyPathContained(requestPath);
|
|
208
217
|
const base = normalizeProxyPath(basePath);
|
|
209
218
|
const request = requestPath.startsWith("/") ? requestPath : `/${requestPath}`;
|
|
210
219
|
if (base === "/") {
|