@jongleberry/api-server 1.0.4 → 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 +1 -0
- package/dist/application.d.mts +5 -0
- package/dist/application.mjs +26 -17
- package/dist/context.d.mts +2 -1
- package/dist/context.mjs +2 -2
- package/dist/cookies.mjs +4 -4
- package/dist/fallback-response.d.mts +9 -3
- package/dist/fallback-response.mjs +70 -8
- package/dist/index.d.mts +1 -1
- package/dist/logger.mjs +10 -4
- package/dist/request-path.d.mts +1 -0
- package/dist/request-path.mjs +12 -0
- package/dist/request.d.mts +4 -1
- package/dist/request.mjs +61 -8
- package/dist/response.mjs +3 -2
- package/dist/router.d.mts +1 -0
- package/dist/router.mjs +4 -0
- package/dist/types.d.mts +19 -0
- package/dist/vary.d.mts +3 -0
- package/dist/vary.mjs +20 -0
- package/package.json +4 -4
package/README.md
CHANGED
|
@@ -48,6 +48,7 @@ app.route("/").get((ctx) => ctx.json({ ok: true }));
|
|
|
48
48
|
- **Server-Timing** — response latency as a `Server-Timing` header (buffered) or trailer (streaming)
|
|
49
49
|
- **Abort signals** — `ctx.signal` / `ctx.abortController` wired to client disconnect
|
|
50
50
|
- **Request body limits** — `ctx.request.buffer()` and `.json()` use a safe 1 MB default, with per-call overrides
|
|
51
|
+
- **Opt-in hardening** — fallback CSP, close-on-oversize, and strict HTTP method policies without default behavior changes
|
|
51
52
|
- **AsyncLocalStorage** — per-request store via `app.setAsyncLocalStorage(als)`
|
|
52
53
|
- **Cookies** — `ctx.cookies.get()` / `.set()` with full `Set-Cookie` options
|
|
53
54
|
- **Cache-Control** — `ctx.cacheControl(visibility, maxAge)` helper
|
package/dist/application.d.mts
CHANGED
|
@@ -15,7 +15,12 @@ export declare class Application extends EventEmitter {
|
|
|
15
15
|
private contextClass;
|
|
16
16
|
private logger;
|
|
17
17
|
private bodyLimit;
|
|
18
|
+
private readonly securityHeaders;
|
|
18
19
|
private trustProxy;
|
|
20
|
+
private strictJsonContentType;
|
|
21
|
+
private readonly oversizedBodyStrategy;
|
|
22
|
+
private readonly fallbackContentSecurityPolicy;
|
|
23
|
+
private readonly strictHttpMethods;
|
|
19
24
|
constructor(options?: ApplicationOptions);
|
|
20
25
|
route(path: string): RouteBuilder;
|
|
21
26
|
errorHandler(fn: ErrorHandler): void;
|
package/dist/application.mjs
CHANGED
|
@@ -1,10 +1,11 @@
|
|
|
1
1
|
import { EventEmitter } from "node:events";
|
|
2
2
|
import { Context, createContextClass } from "./context.mjs";
|
|
3
|
-
import { createRouteBuilder } from "./router.mjs";
|
|
3
|
+
import { createRouteBuilder, isSupportedHttpMethod } from "./router.mjs";
|
|
4
4
|
import Router from "find-my-way";
|
|
5
5
|
import { ServerTiming } from "./server-timing.mjs";
|
|
6
6
|
import { Logger } from "./logger.mjs";
|
|
7
|
-
import {
|
|
7
|
+
import { getRawPath } from "./request-path.mjs";
|
|
8
|
+
import { applySecurityHeaders, ensureFallbackHeaders, getFallbackBody, getFallbackStatus, resolveSecurityHeaders, safeString, sendFallback, } from "./fallback-response.mjs";
|
|
8
9
|
export class Application extends EventEmitter {
|
|
9
10
|
router = Router();
|
|
10
11
|
errorHandlerFn = null;
|
|
@@ -14,12 +15,22 @@ export class Application extends EventEmitter {
|
|
|
14
15
|
contextClass = Context;
|
|
15
16
|
logger;
|
|
16
17
|
bodyLimit;
|
|
18
|
+
securityHeaders;
|
|
17
19
|
trustProxy;
|
|
20
|
+
strictJsonContentType;
|
|
21
|
+
oversizedBodyStrategy;
|
|
22
|
+
fallbackContentSecurityPolicy;
|
|
23
|
+
strictHttpMethods;
|
|
18
24
|
constructor(options) {
|
|
19
25
|
super();
|
|
20
26
|
this.logger = new Logger(options?.logger);
|
|
21
27
|
this.bodyLimit = options?.bodyLimit ?? "1mb";
|
|
28
|
+
this.securityHeaders = resolveSecurityHeaders(options?.securityHeaders);
|
|
22
29
|
this.trustProxy = options?.trustProxy ?? false;
|
|
30
|
+
this.strictJsonContentType = options?.strictJsonContentType ?? false;
|
|
31
|
+
this.oversizedBodyStrategy = options?.oversizedBodyStrategy ?? "drain";
|
|
32
|
+
this.fallbackContentSecurityPolicy = options?.fallbackContentSecurityPolicy ?? false;
|
|
33
|
+
this.strictHttpMethods = options?.strictHttpMethods ?? false;
|
|
23
34
|
}
|
|
24
35
|
route(path) {
|
|
25
36
|
return createRouteBuilder(this.router, path);
|
|
@@ -47,7 +58,7 @@ export class Application extends EventEmitter {
|
|
|
47
58
|
// Safety net: if runRequest rejects before its own try-catch (e.g. during
|
|
48
59
|
// context/timing setup), ensure the client always gets a response instead
|
|
49
60
|
// of a socket hang-up from an unhandled promise rejection.
|
|
50
|
-
const error = err instanceof Error ? err : new Error(
|
|
61
|
+
const error = err instanceof Error ? err : new Error(safeString(err));
|
|
51
62
|
try {
|
|
52
63
|
if (this.listenerCount("error") > 0) {
|
|
53
64
|
this.emit("error", error);
|
|
@@ -57,7 +68,7 @@ export class Application extends EventEmitter {
|
|
|
57
68
|
// Swallow listener throws so the 500 response still goes out.
|
|
58
69
|
}
|
|
59
70
|
if (!res.headersSent) {
|
|
60
|
-
ensureFallbackHeaders(res);
|
|
71
|
+
ensureFallbackHeaders(res, this.securityHeaders, this.fallbackContentSecurityPolicy);
|
|
61
72
|
res.writeHead(500);
|
|
62
73
|
res.end("Internal Server Error");
|
|
63
74
|
}
|
|
@@ -73,23 +84,21 @@ export class Application extends EventEmitter {
|
|
|
73
84
|
const abortController = new AbortController();
|
|
74
85
|
const timing = new ServerTiming();
|
|
75
86
|
const ContextClass = this.contextClass;
|
|
76
|
-
|
|
87
|
+
res.once("close", () => {
|
|
77
88
|
if (!res.writableEnded) {
|
|
78
89
|
abortController.abort();
|
|
79
90
|
}
|
|
80
91
|
});
|
|
81
92
|
const { onWriteHead, onFinish } = this.logger.onRequestStart(req);
|
|
82
|
-
const ctx = new ContextClass(req, res, {}, timing, this.asyncLocalStorage, abortController, this.bodyLimit, this.trustProxy, onWriteHead);
|
|
83
|
-
|
|
84
|
-
res.setHeader("X-XSS-Protection", "0");
|
|
85
|
-
res.setHeader("X-Frame-Options", "SAMEORIGIN");
|
|
86
|
-
res.setHeader("X-Content-Type-Options", "nosniff");
|
|
93
|
+
const ctx = new ContextClass(req, res, {}, timing, this.asyncLocalStorage, abortController, this.bodyLimit, this.trustProxy, onWriteHead, this.strictJsonContentType, this.oversizedBodyStrategy);
|
|
94
|
+
applySecurityHeaders(res, this.securityHeaders);
|
|
87
95
|
try {
|
|
88
96
|
const method = req.method ?? "GET";
|
|
97
|
+
if (this.strictHttpMethods && !isSupportedHttpMethod(method)) {
|
|
98
|
+
throw Object.assign(new Error("Unsupported HTTP method"), { status: 400 });
|
|
99
|
+
}
|
|
89
100
|
const url = req.url ?? "/";
|
|
90
|
-
const rawPath =
|
|
91
|
-
? new URL(url).pathname
|
|
92
|
-
: url.split("?")[0];
|
|
101
|
+
const rawPath = getRawPath(url);
|
|
93
102
|
const routePath = rawPath.replace(/^\/+/, "/") || "/";
|
|
94
103
|
const found = this.router.find(method, routePath);
|
|
95
104
|
if (found) {
|
|
@@ -101,7 +110,7 @@ export class Application extends EventEmitter {
|
|
|
101
110
|
await this.notFoundHandlerFn(ctx);
|
|
102
111
|
}
|
|
103
112
|
else {
|
|
104
|
-
ensureFallbackHeaders(res);
|
|
113
|
+
ensureFallbackHeaders(res, this.securityHeaders, this.fallbackContentSecurityPolicy);
|
|
105
114
|
res.writeHead(404);
|
|
106
115
|
res.end("Not Found");
|
|
107
116
|
}
|
|
@@ -109,7 +118,7 @@ export class Application extends EventEmitter {
|
|
|
109
118
|
onFinish(res.statusCode);
|
|
110
119
|
}
|
|
111
120
|
catch (err) {
|
|
112
|
-
const error = err instanceof Error ? err : new Error(
|
|
121
|
+
const error = err instanceof Error ? err : new Error(safeString(err));
|
|
113
122
|
if (this.listenerCount("error") > 0) {
|
|
114
123
|
this.emit("error", error);
|
|
115
124
|
}
|
|
@@ -126,12 +135,12 @@ export class Application extends EventEmitter {
|
|
|
126
135
|
// registered error handler threw or returned without sending one. Without
|
|
127
136
|
// this, requests hang until the socket times out (issue #1948).
|
|
128
137
|
if (!res.headersSent) {
|
|
129
|
-
sendFallback(res);
|
|
138
|
+
sendFallback(res, this.securityHeaders, this.fallbackContentSecurityPolicy);
|
|
130
139
|
}
|
|
131
140
|
}
|
|
132
141
|
else if (!res.headersSent) {
|
|
133
142
|
const status = getFallbackStatus(error);
|
|
134
|
-
ensureFallbackHeaders(res);
|
|
143
|
+
ensureFallbackHeaders(res, this.securityHeaders, this.fallbackContentSecurityPolicy);
|
|
135
144
|
res.writeHead(status);
|
|
136
145
|
res.end(getFallbackBody(error, status));
|
|
137
146
|
}
|
package/dist/context.d.mts
CHANGED
|
@@ -5,6 +5,7 @@ import { Request } from "./request.mts";
|
|
|
5
5
|
import { Response } from "./response.mts";
|
|
6
6
|
import { Cookies } from "./cookies.mts";
|
|
7
7
|
import type { ServerTiming } from "./server-timing.mts";
|
|
8
|
+
import type { OversizedBodyStrategy } from "./types.mts";
|
|
8
9
|
export declare class Context {
|
|
9
10
|
req: IncomingMessage;
|
|
10
11
|
res: ServerResponse;
|
|
@@ -18,7 +19,7 @@ export declare class Context {
|
|
|
18
19
|
private queryCache;
|
|
19
20
|
private asyncLocalStorage;
|
|
20
21
|
private trustProxy;
|
|
21
|
-
constructor(req: IncomingMessage, res: ServerResponse, params: Record<string, string | undefined>, timing: ServerTiming, als: AsyncLocalStorage<unknown> | null, abortController: AbortController, bodyLimit: string | number | false, trustProxy: boolean, onWriteHead?: () => void);
|
|
22
|
+
constructor(req: IncomingMessage, res: ServerResponse, params: Record<string, string | undefined>, timing: ServerTiming, als: AsyncLocalStorage<unknown> | null, abortController: AbortController, bodyLimit: string | number | false, trustProxy: boolean, onWriteHead?: () => void, strictJsonContentType?: boolean, oversizedBodyStrategy?: OversizedBodyStrategy);
|
|
22
23
|
get query(): Record<string, string | string[]>;
|
|
23
24
|
get store(): unknown;
|
|
24
25
|
get ip(): string | undefined;
|
package/dist/context.mjs
CHANGED
|
@@ -26,11 +26,11 @@ export class Context {
|
|
|
26
26
|
queryCache = null;
|
|
27
27
|
asyncLocalStorage;
|
|
28
28
|
trustProxy;
|
|
29
|
-
constructor(req, res, params, timing, als, abortController, bodyLimit, trustProxy, onWriteHead) {
|
|
29
|
+
constructor(req, res, params, timing, als, abortController, bodyLimit, trustProxy, onWriteHead, strictJsonContentType, oversizedBodyStrategy) {
|
|
30
30
|
this.req = req;
|
|
31
31
|
this.res = res;
|
|
32
32
|
this.params = params;
|
|
33
|
-
this.request = new Request(req, res, bodyLimit);
|
|
33
|
+
this.request = new Request(req, res, bodyLimit, strictJsonContentType ?? false, oversizedBodyStrategy ?? "drain");
|
|
34
34
|
this.response = new Response(req, res, timing, onWriteHead);
|
|
35
35
|
this.cookies = new Cookies(req, res);
|
|
36
36
|
this.abortController = abortController;
|
package/dist/cookies.mjs
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { parseCookie, stringifySetCookie } from "cookie";
|
|
2
2
|
export class Cookies {
|
|
3
3
|
req;
|
|
4
4
|
res;
|
|
@@ -10,13 +10,13 @@ export class Cookies {
|
|
|
10
10
|
get(name) {
|
|
11
11
|
if (!this.parsed) {
|
|
12
12
|
const header = this.req.headers.cookie ?? "";
|
|
13
|
-
this.parsed =
|
|
13
|
+
this.parsed = parseCookie(header);
|
|
14
14
|
}
|
|
15
|
-
return this.parsed[name];
|
|
15
|
+
return this.parsed?.[name];
|
|
16
16
|
}
|
|
17
17
|
set(name, value, opts) {
|
|
18
18
|
const existing = this.res.getHeader("Set-Cookie");
|
|
19
|
-
const serialized =
|
|
19
|
+
const serialized = stringifySetCookie({ name, value, ...opts }, { encode: encodeURIComponent });
|
|
20
20
|
if (Array.isArray(existing)) {
|
|
21
21
|
this.res.setHeader("Set-Cookie", [...existing, serialized]);
|
|
22
22
|
}
|
|
@@ -1,5 +1,11 @@
|
|
|
1
|
-
import type
|
|
2
|
-
|
|
3
|
-
export
|
|
1
|
+
import { type ServerResponse } from "node:http";
|
|
2
|
+
import type { SecurityHeaderName, SecurityHeadersOptions } from "./types.mts";
|
|
3
|
+
export type ResolvedSecurityHeaders = Readonly<Partial<Record<SecurityHeaderName, string>>>;
|
|
4
|
+
export declare const SECURITY_HEADERS: ResolvedSecurityHeaders;
|
|
5
|
+
export declare function resolveSecurityHeaders(options?: SecurityHeadersOptions): ResolvedSecurityHeaders;
|
|
6
|
+
export declare function applySecurityHeaders(res: ServerResponse, securityHeaders: ResolvedSecurityHeaders): void;
|
|
7
|
+
export declare function ensureFallbackHeaders(res: ServerResponse, securityHeaders?: ResolvedSecurityHeaders, contentSecurityPolicy?: string | false): void;
|
|
8
|
+
export declare function sendFallback(res: ServerResponse, securityHeaders?: ResolvedSecurityHeaders, contentSecurityPolicy?: string | false): void;
|
|
4
9
|
export declare function getFallbackStatus(error: unknown): number;
|
|
10
|
+
export declare function safeString(err: unknown): string;
|
|
5
11
|
export declare function getFallbackBody(error: unknown, status: number): string;
|
|
@@ -1,15 +1,46 @@
|
|
|
1
|
+
import { STATUS_CODES } from "node:http";
|
|
1
2
|
const FALLBACK_BODY = "Not Found";
|
|
2
3
|
const ERROR_STATUS = 500;
|
|
3
4
|
const ERROR_BODY = "Internal Server Error";
|
|
4
5
|
const TEXT_PLAIN_CONTENT_TYPE = "text/plain; charset=utf-8";
|
|
5
|
-
const
|
|
6
|
-
"
|
|
6
|
+
const SECURITY_HEADER_NAMES = [
|
|
7
|
+
"X-XSS-Protection",
|
|
8
|
+
"X-Frame-Options",
|
|
9
|
+
"X-Content-Type-Options",
|
|
10
|
+
"Strict-Transport-Security",
|
|
11
|
+
"Referrer-Policy",
|
|
12
|
+
"X-DNS-Prefetch-Control",
|
|
13
|
+
"X-Download-Options",
|
|
14
|
+
"X-Permitted-Cross-Domain-Policies",
|
|
15
|
+
];
|
|
16
|
+
export const SECURITY_HEADERS = {
|
|
7
17
|
"X-XSS-Protection": "0",
|
|
8
18
|
"X-Frame-Options": "SAMEORIGIN",
|
|
9
19
|
"X-Content-Type-Options": "nosniff",
|
|
10
20
|
};
|
|
11
|
-
export function
|
|
12
|
-
|
|
21
|
+
export function resolveSecurityHeaders(options) {
|
|
22
|
+
const resolved = { ...SECURITY_HEADERS };
|
|
23
|
+
for (const name of SECURITY_HEADER_NAMES) {
|
|
24
|
+
const value = options?.[name];
|
|
25
|
+
if (value === false) {
|
|
26
|
+
delete resolved[name];
|
|
27
|
+
}
|
|
28
|
+
else if (typeof value === "string") {
|
|
29
|
+
resolved[name] = value;
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
return resolved;
|
|
33
|
+
}
|
|
34
|
+
export function applySecurityHeaders(res, securityHeaders) {
|
|
35
|
+
for (const [name, value] of Object.entries(securityHeaders)) {
|
|
36
|
+
res.setHeader(name, value);
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
export function ensureFallbackHeaders(res, securityHeaders = SECURITY_HEADERS, contentSecurityPolicy = false) {
|
|
40
|
+
for (const [name, value] of Object.entries({
|
|
41
|
+
"Content-Type": TEXT_PLAIN_CONTENT_TYPE,
|
|
42
|
+
...securityHeaders,
|
|
43
|
+
})) {
|
|
13
44
|
try {
|
|
14
45
|
if (typeof res.hasHeader !== "function" || !res.hasHeader(name)) {
|
|
15
46
|
res.setHeader(name, value);
|
|
@@ -19,10 +50,20 @@ export function ensureFallbackHeaders(res) {
|
|
|
19
50
|
// Header mutation can fail on destroyed sockets or non-standard responses.
|
|
20
51
|
}
|
|
21
52
|
}
|
|
53
|
+
if (contentSecurityPolicy !== false) {
|
|
54
|
+
try {
|
|
55
|
+
if (typeof res.hasHeader !== "function" || !res.hasHeader("Content-Security-Policy")) {
|
|
56
|
+
res.setHeader("Content-Security-Policy", contentSecurityPolicy);
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
catch {
|
|
60
|
+
// Header mutation can fail on destroyed sockets or non-standard responses.
|
|
61
|
+
}
|
|
62
|
+
}
|
|
22
63
|
}
|
|
23
|
-
export function sendFallback(res) {
|
|
64
|
+
export function sendFallback(res, securityHeaders = SECURITY_HEADERS, contentSecurityPolicy = false) {
|
|
24
65
|
try {
|
|
25
|
-
ensureFallbackHeaders(res);
|
|
66
|
+
ensureFallbackHeaders(res, securityHeaders, contentSecurityPolicy);
|
|
26
67
|
res.writeHead(ERROR_STATUS);
|
|
27
68
|
res.end(ERROR_BODY);
|
|
28
69
|
}
|
|
@@ -37,9 +78,30 @@ export function getFallbackStatus(error) {
|
|
|
37
78
|
}
|
|
38
79
|
return ERROR_STATUS;
|
|
39
80
|
}
|
|
81
|
+
function escapeHtml(unsafe) {
|
|
82
|
+
return unsafe
|
|
83
|
+
.replaceAll("&", "&")
|
|
84
|
+
.replaceAll("<", "<")
|
|
85
|
+
.replaceAll(">", ">")
|
|
86
|
+
.replaceAll('"', """)
|
|
87
|
+
.replaceAll("'", "'");
|
|
88
|
+
}
|
|
89
|
+
export function safeString(err) {
|
|
90
|
+
try {
|
|
91
|
+
return String(err);
|
|
92
|
+
}
|
|
93
|
+
catch {
|
|
94
|
+
return "[Object null prototype]";
|
|
95
|
+
}
|
|
96
|
+
}
|
|
40
97
|
export function getFallbackBody(error, status) {
|
|
41
98
|
if (status >= 500)
|
|
42
99
|
return ERROR_BODY;
|
|
43
|
-
const
|
|
44
|
-
|
|
100
|
+
const err = error;
|
|
101
|
+
const message = err?.message;
|
|
102
|
+
if (err?.expose === false) {
|
|
103
|
+
return STATUS_CODES[status] || FALLBACK_BODY;
|
|
104
|
+
}
|
|
105
|
+
const msg = typeof message === "string" && message ? message : STATUS_CODES[status] || FALLBACK_BODY;
|
|
106
|
+
return escapeHtml(msg);
|
|
45
107
|
}
|
package/dist/index.d.mts
CHANGED
|
@@ -11,4 +11,4 @@ export * from "./etag.mts";
|
|
|
11
11
|
export * from "./cache-control.mts";
|
|
12
12
|
export * from "./compression.mts";
|
|
13
13
|
export type { Handler, RouteBuilder } from "./router.mts";
|
|
14
|
-
export type { CookieOptions, ApplicationOptions } from "./types.mts";
|
|
14
|
+
export type { CookieOptions, ApplicationOptions, OversizedBodyStrategy, SecurityHeaderName, SecurityHeadersOptions, } from "./types.mts";
|
package/dist/logger.mjs
CHANGED
|
@@ -27,8 +27,8 @@ export class Logger {
|
|
|
27
27
|
return this.createErrorLevelLogger(req);
|
|
28
28
|
}
|
|
29
29
|
const slot = this.assignSlot();
|
|
30
|
-
const method = req.method ?? "GET";
|
|
31
|
-
const url = req.url ?? "/";
|
|
30
|
+
const method = sanitize(req.method ?? "GET");
|
|
31
|
+
const url = sanitize(req.url ?? "/");
|
|
32
32
|
const startTime = process.hrtime.bigint();
|
|
33
33
|
this.print("──‣", "┈┈┈┈┈", method, url, "┬", slot);
|
|
34
34
|
return {
|
|
@@ -44,8 +44,8 @@ export class Logger {
|
|
|
44
44
|
};
|
|
45
45
|
}
|
|
46
46
|
createErrorLevelLogger(req) {
|
|
47
|
-
const method = req.method ?? "GET";
|
|
48
|
-
const url = req.url ?? "/";
|
|
47
|
+
const method = sanitize(req.method ?? "GET");
|
|
48
|
+
const url = sanitize(req.url ?? "/");
|
|
49
49
|
const startTime = process.hrtime.bigint();
|
|
50
50
|
return {
|
|
51
51
|
onWriteHead: noop,
|
|
@@ -109,3 +109,9 @@ export class Logger {
|
|
|
109
109
|
}
|
|
110
110
|
}
|
|
111
111
|
function noop() { }
|
|
112
|
+
function sanitize(str) {
|
|
113
|
+
// eslint-disable-next-line no-control-regex
|
|
114
|
+
return str
|
|
115
|
+
.replace(/\x1B\[[0-9;]*[a-zA-Z]/g, "")
|
|
116
|
+
.replace(/[\x00-\x1F\x7F-\x9F\u200E\u200F\u202A-\u202E\u2066-\u2069]/g, "");
|
|
117
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export declare function getRawPath(url: string): string;
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
export function getRawPath(url) {
|
|
2
|
+
const lower = url.slice(0, 8).toLowerCase();
|
|
3
|
+
if (lower.startsWith("http://") || lower.startsWith("https://")) {
|
|
4
|
+
try {
|
|
5
|
+
return new URL(url).pathname;
|
|
6
|
+
}
|
|
7
|
+
catch {
|
|
8
|
+
throw Object.assign(new Error("Invalid URL"), { status: 400 });
|
|
9
|
+
}
|
|
10
|
+
}
|
|
11
|
+
return url.split("?")[0];
|
|
12
|
+
}
|
package/dist/request.d.mts
CHANGED
|
@@ -1,10 +1,13 @@
|
|
|
1
1
|
import type { IncomingMessage, ServerResponse } from "node:http";
|
|
2
|
+
import type { OversizedBodyStrategy } from "./types.mts";
|
|
2
3
|
export declare class Request {
|
|
3
4
|
private req;
|
|
4
5
|
private res;
|
|
5
6
|
private bodyPromise;
|
|
6
7
|
private defaultLimit;
|
|
7
|
-
|
|
8
|
+
private strictJsonContentType;
|
|
9
|
+
private readonly oversizedBodyStrategy;
|
|
10
|
+
constructor(req: IncomingMessage, res: ServerResponse, defaultLimit?: string | number | false, strictJsonContentType?: boolean, oversizedBodyStrategy?: OversizedBodyStrategy);
|
|
8
11
|
is(type: string | string[]): string | false | null;
|
|
9
12
|
buffer(limit?: string | number | false): Promise<Buffer>;
|
|
10
13
|
json<T = unknown>(limit?: string | number | false): Promise<T>;
|
package/dist/request.mjs
CHANGED
|
@@ -8,10 +8,14 @@ export class Request {
|
|
|
8
8
|
res;
|
|
9
9
|
bodyPromise = null;
|
|
10
10
|
defaultLimit;
|
|
11
|
-
|
|
11
|
+
strictJsonContentType;
|
|
12
|
+
oversizedBodyStrategy;
|
|
13
|
+
constructor(req, res, defaultLimit = "1mb", strictJsonContentType = false, oversizedBodyStrategy = "drain") {
|
|
12
14
|
this.req = req;
|
|
13
15
|
this.res = res;
|
|
14
16
|
this.defaultLimit = defaultLimit;
|
|
17
|
+
this.strictJsonContentType = strictJsonContentType;
|
|
18
|
+
this.oversizedBodyStrategy = oversizedBodyStrategy;
|
|
15
19
|
}
|
|
16
20
|
is(type) {
|
|
17
21
|
return typeIs(this.req, Array.isArray(type) ? type : [type]);
|
|
@@ -19,14 +23,31 @@ export class Request {
|
|
|
19
23
|
buffer(limit) {
|
|
20
24
|
if (!this.bodyPromise) {
|
|
21
25
|
const effectiveLimit = limit ?? this.defaultLimit;
|
|
22
|
-
if (this.req.headers.expect === "100-continue") {
|
|
26
|
+
if (this.oversizedBodyStrategy === "drain" && this.req.headers.expect === "100-continue") {
|
|
23
27
|
this.res.writeContinue();
|
|
24
28
|
}
|
|
25
|
-
this.bodyPromise = readBody(this.req, effectiveLimit);
|
|
29
|
+
this.bodyPromise = readBody(this.req, this.res, effectiveLimit, this.oversizedBodyStrategy);
|
|
26
30
|
}
|
|
27
31
|
return this.bodyPromise;
|
|
28
32
|
}
|
|
29
33
|
async json(limit) {
|
|
34
|
+
if (this.strictJsonContentType) {
|
|
35
|
+
// type-is semantics:
|
|
36
|
+
// null → no body (no Content-Length / Transfer-Encoding header);
|
|
37
|
+
// skip the content-type check — buffer() will return an empty
|
|
38
|
+
// buffer that JSON.parse rejects with 400 as usual.
|
|
39
|
+
// false → a body is indicated but Content-Type is not a JSON type.
|
|
40
|
+
// We 415 only when the body actually has content (CL > 0 or
|
|
41
|
+
// Transfer-Encoding is set without CL). A Content-Length: 0
|
|
42
|
+
// body has nothing to read, so let it fall through to 400.
|
|
43
|
+
// truthy → recognised JSON type; proceed normally.
|
|
44
|
+
const mediaType = this.is(["json", "application/*+json"]);
|
|
45
|
+
const cl = this.req.headers["content-length"];
|
|
46
|
+
const emptyBody = cl !== undefined && Number(cl) === 0;
|
|
47
|
+
if (mediaType === false && !emptyBody) {
|
|
48
|
+
throw Object.assign(new Error("Unsupported Media Type"), { status: 415 });
|
|
49
|
+
}
|
|
50
|
+
}
|
|
30
51
|
const buf = await this.buffer(limit);
|
|
31
52
|
try {
|
|
32
53
|
return JSON.parse(buf.toString("utf8"));
|
|
@@ -45,9 +66,20 @@ function parseLimit(limit) {
|
|
|
45
66
|
}
|
|
46
67
|
return parsed;
|
|
47
68
|
}
|
|
48
|
-
function readBody(req, limit) {
|
|
69
|
+
function readBody(req, res, limit, oversizedBodyStrategy) {
|
|
49
70
|
return new Promise((resolve, reject) => {
|
|
50
71
|
const maxBytes = parseLimit(limit);
|
|
72
|
+
if (oversizedBodyStrategy === "close") {
|
|
73
|
+
const contentLength = Number(req.headers["content-length"]);
|
|
74
|
+
if (Number.isFinite(contentLength) && contentLength > maxBytes) {
|
|
75
|
+
reject(createOversizedBodyError());
|
|
76
|
+
handleOversizedBody(req, res, oversizedBodyStrategy);
|
|
77
|
+
return;
|
|
78
|
+
}
|
|
79
|
+
if (req.headers.expect === "100-continue" && !res.headersSent) {
|
|
80
|
+
res.writeContinue();
|
|
81
|
+
}
|
|
82
|
+
}
|
|
51
83
|
const chunks = [];
|
|
52
84
|
let totalLength = 0;
|
|
53
85
|
function cleanup() {
|
|
@@ -63,10 +95,8 @@ function readBody(req, limit) {
|
|
|
63
95
|
req.removeListener("data", onData);
|
|
64
96
|
req.removeListener("end", onEnd);
|
|
65
97
|
req.removeListener("error", onError);
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
// Drain remaining data so the connection stays reusable (HTTP keep-alive)
|
|
69
|
-
req.resume();
|
|
98
|
+
reject(createOversizedBodyError());
|
|
99
|
+
handleOversizedBody(req, res, oversizedBodyStrategy);
|
|
70
100
|
return;
|
|
71
101
|
}
|
|
72
102
|
chunks.push(chunk);
|
|
@@ -84,3 +114,26 @@ function readBody(req, limit) {
|
|
|
84
114
|
req.on("error", onError);
|
|
85
115
|
});
|
|
86
116
|
}
|
|
117
|
+
function createOversizedBodyError() {
|
|
118
|
+
return Object.assign(new Error("Request entity too large"), { status: 413 });
|
|
119
|
+
}
|
|
120
|
+
function handleOversizedBody(req, res, strategy) {
|
|
121
|
+
req.on("error", noop);
|
|
122
|
+
if (strategy === "drain") {
|
|
123
|
+
req.resume();
|
|
124
|
+
return;
|
|
125
|
+
}
|
|
126
|
+
req.pause();
|
|
127
|
+
if (res.headersSent) {
|
|
128
|
+
res.destroy();
|
|
129
|
+
return;
|
|
130
|
+
}
|
|
131
|
+
if ((req.httpVersionMajor ?? 1) < 2) {
|
|
132
|
+
try {
|
|
133
|
+
res.setHeader("Connection", "close");
|
|
134
|
+
}
|
|
135
|
+
catch {
|
|
136
|
+
res.destroy();
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
}
|
package/dist/response.mjs
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { pipeline } from "node:stream/promises";
|
|
2
2
|
import { generateETag, isFresh } from "./etag.mjs";
|
|
3
3
|
import { shouldCompress, createCompressStream, compressSync } from "./compression.mjs";
|
|
4
|
+
import { mergeVary } from "./vary.mjs";
|
|
4
5
|
export class Response {
|
|
5
6
|
req;
|
|
6
7
|
res;
|
|
@@ -48,7 +49,7 @@ export class Response {
|
|
|
48
49
|
const encoding = shouldCompress(this.req, this.res, contentType, Infinity);
|
|
49
50
|
if (encoding) {
|
|
50
51
|
this.res.setHeader("Content-Encoding", encoding);
|
|
51
|
-
this.res.setHeader("Vary", "
|
|
52
|
+
this.res.setHeader("Vary", mergeVary(this.res.getHeader("Vary")));
|
|
52
53
|
}
|
|
53
54
|
// HEAD requests: send headers only, no body
|
|
54
55
|
if (this.req.method === "HEAD") {
|
|
@@ -103,7 +104,7 @@ export class Response {
|
|
|
103
104
|
if (encoding) {
|
|
104
105
|
finalBody = compressSync(encoding, body);
|
|
105
106
|
this.res.setHeader("Content-Encoding", encoding);
|
|
106
|
-
this.res.setHeader("Vary", "
|
|
107
|
+
this.res.setHeader("Vary", mergeVary(this.res.getHeader("Vary")));
|
|
107
108
|
}
|
|
108
109
|
this.res.setHeader("Content-Type", contentType);
|
|
109
110
|
this.res.setHeader("Content-Length", finalBody.length);
|
package/dist/router.d.mts
CHANGED
|
@@ -9,5 +9,6 @@ export interface RouteBuilder {
|
|
|
9
9
|
delete(handler: Handler): RouteBuilder;
|
|
10
10
|
patch(handler: Handler): RouteBuilder;
|
|
11
11
|
}
|
|
12
|
+
export declare function isSupportedHttpMethod(method: string): boolean;
|
|
12
13
|
export declare function createRouteBuilder(router: RouterInstance, path: string): RouteBuilder;
|
|
13
14
|
export {};
|
package/dist/router.mjs
CHANGED
package/dist/types.d.mts
CHANGED
|
@@ -1,4 +1,7 @@
|
|
|
1
1
|
import type { LoggerOptions } from "./logger.mts";
|
|
2
|
+
export type SecurityHeaderName = "X-XSS-Protection" | "X-Frame-Options" | "X-Content-Type-Options" | "Strict-Transport-Security" | "Referrer-Policy" | "X-DNS-Prefetch-Control" | "X-Download-Options" | "X-Permitted-Cross-Domain-Policies";
|
|
3
|
+
export type SecurityHeadersOptions = Partial<Record<SecurityHeaderName, string | false>>;
|
|
4
|
+
export type OversizedBodyStrategy = "drain" | "close";
|
|
2
5
|
export interface CookieOptions {
|
|
3
6
|
httpOnly?: boolean;
|
|
4
7
|
secure?: boolean;
|
|
@@ -10,6 +13,22 @@ export interface CookieOptions {
|
|
|
10
13
|
}
|
|
11
14
|
export interface ApplicationOptions {
|
|
12
15
|
bodyLimit?: string | number | false;
|
|
16
|
+
/** Defaults to "drain", preserving HTTP keep-alive after a 413 response. */
|
|
17
|
+
oversizedBodyStrategy?: OversizedBodyStrategy;
|
|
18
|
+
/** Applied only to framework-generated fallback responses. Defaults to false. */
|
|
19
|
+
fallbackContentSecurityPolicy?: string | false;
|
|
13
20
|
logger?: LoggerOptions;
|
|
21
|
+
securityHeaders?: SecurityHeadersOptions;
|
|
14
22
|
trustProxy?: boolean;
|
|
23
|
+
/** Reject methods outside node:http.METHODS with 400. Defaults to false. */
|
|
24
|
+
strictHttpMethods?: boolean;
|
|
25
|
+
/**
|
|
26
|
+
* When true, ctx.request.json() rejects requests whose Content-Type is not
|
|
27
|
+
* application/json (or a compatible JSON subtype such as application/merge-patch+json)
|
|
28
|
+
* with a 415 Unsupported Media Type error. Requests with no body are unaffected.
|
|
29
|
+
*
|
|
30
|
+
* Defaults to false (lenient: any Content-Type is accepted, preserving
|
|
31
|
+
* backward-compatible behavior).
|
|
32
|
+
*/
|
|
33
|
+
strictJsonContentType?: boolean;
|
|
15
34
|
}
|
package/dist/vary.d.mts
ADDED
package/dist/vary.mjs
ADDED
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
export function mergeVary(header) {
|
|
2
|
+
const members = (Array.isArray(header) ? header : [header])
|
|
3
|
+
.flatMap((value) => String(value ?? "").split(","))
|
|
4
|
+
.map((value) => value.trim())
|
|
5
|
+
.filter(Boolean);
|
|
6
|
+
const merged = [];
|
|
7
|
+
const seen = new Set();
|
|
8
|
+
for (const member of members) {
|
|
9
|
+
const normalized = member.toLowerCase();
|
|
10
|
+
if (normalized === "*")
|
|
11
|
+
return "*";
|
|
12
|
+
if (seen.has(normalized))
|
|
13
|
+
continue;
|
|
14
|
+
seen.add(normalized);
|
|
15
|
+
merged.push(member);
|
|
16
|
+
}
|
|
17
|
+
if (!seen.has("accept-encoding"))
|
|
18
|
+
merged.push("Accept-Encoding");
|
|
19
|
+
return merged.join(", ");
|
|
20
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@jongleberry/api-server",
|
|
3
|
-
"version": "1.0
|
|
3
|
+
"version": "1.2.0",
|
|
4
4
|
"description": "A Node.js HTTP server library",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"author": "Jonathan Ong",
|
|
@@ -26,7 +26,7 @@
|
|
|
26
26
|
"dependencies": {
|
|
27
27
|
"bytes": "^3.1.2",
|
|
28
28
|
"compressible": "^2.0.18",
|
|
29
|
-
"cookie": "^
|
|
29
|
+
"cookie": "^2.0.1",
|
|
30
30
|
"find-my-way": "^9.6.0",
|
|
31
31
|
"http-assert": "^1.5.0",
|
|
32
32
|
"http-errors": "^2.0.1",
|
|
@@ -39,12 +39,12 @@
|
|
|
39
39
|
"@types/http-assert": "^1.5.6",
|
|
40
40
|
"@types/http-errors": "^2.0.5",
|
|
41
41
|
"@types/negotiator": "^0.6.4",
|
|
42
|
-
"@types/node": "^
|
|
42
|
+
"@types/node": "^26.0.1",
|
|
43
43
|
"@types/supertest": "^7.2.0",
|
|
44
44
|
"@types/type-is": "^1.6.7",
|
|
45
45
|
"@vitest/coverage-v8": "^4.1.6",
|
|
46
46
|
"husky": "^9.1.7",
|
|
47
|
-
"oxfmt": "^0.
|
|
47
|
+
"oxfmt": "^0.56.0",
|
|
48
48
|
"oxlint": "^1.65.0",
|
|
49
49
|
"supertest": "^7.2.2",
|
|
50
50
|
"typescript": "^6.0.3",
|