@jongleberry/api-server 1.0.3 → 1.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/dist/application.d.mts +1 -0
- package/dist/application.mjs +25 -10
- package/dist/context.d.mts +1 -1
- package/dist/context.mjs +2 -2
- package/dist/fallback-response.d.mts +13 -1
- package/dist/fallback-response.mjs +52 -2
- package/dist/logger.mjs +10 -4
- package/dist/request.d.mts +2 -1
- package/dist/request.mjs +20 -1
- package/dist/types.d.mts +9 -0
- package/package.json +2 -2
package/dist/application.d.mts
CHANGED
|
@@ -16,6 +16,7 @@ export declare class Application extends EventEmitter {
|
|
|
16
16
|
private logger;
|
|
17
17
|
private bodyLimit;
|
|
18
18
|
private trustProxy;
|
|
19
|
+
private strictJsonContentType;
|
|
19
20
|
constructor(options?: ApplicationOptions);
|
|
20
21
|
route(path: string): RouteBuilder;
|
|
21
22
|
errorHandler(fn: ErrorHandler): void;
|
package/dist/application.mjs
CHANGED
|
@@ -4,7 +4,7 @@ import { createRouteBuilder } 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 { getFallbackBody, getFallbackStatus, sendFallback } from "./fallback-response.mjs";
|
|
7
|
+
import { ensureFallbackHeaders, getFallbackBody, getFallbackStatus, safeString, sendFallback, SECURITY_HEADERS, } from "./fallback-response.mjs";
|
|
8
8
|
export class Application extends EventEmitter {
|
|
9
9
|
router = Router();
|
|
10
10
|
errorHandlerFn = null;
|
|
@@ -15,11 +15,13 @@ export class Application extends EventEmitter {
|
|
|
15
15
|
logger;
|
|
16
16
|
bodyLimit;
|
|
17
17
|
trustProxy;
|
|
18
|
+
strictJsonContentType;
|
|
18
19
|
constructor(options) {
|
|
19
20
|
super();
|
|
20
21
|
this.logger = new Logger(options?.logger);
|
|
21
22
|
this.bodyLimit = options?.bodyLimit ?? "1mb";
|
|
22
23
|
this.trustProxy = options?.trustProxy ?? false;
|
|
24
|
+
this.strictJsonContentType = options?.strictJsonContentType ?? false;
|
|
23
25
|
}
|
|
24
26
|
route(path) {
|
|
25
27
|
return createRouteBuilder(this.router, path);
|
|
@@ -47,7 +49,7 @@ export class Application extends EventEmitter {
|
|
|
47
49
|
// Safety net: if runRequest rejects before its own try-catch (e.g. during
|
|
48
50
|
// context/timing setup), ensure the client always gets a response instead
|
|
49
51
|
// of a socket hang-up from an unhandled promise rejection.
|
|
50
|
-
const error = err instanceof Error ? err : new Error(
|
|
52
|
+
const error = err instanceof Error ? err : new Error(safeString(err));
|
|
51
53
|
try {
|
|
52
54
|
if (this.listenerCount("error") > 0) {
|
|
53
55
|
this.emit("error", error);
|
|
@@ -57,6 +59,7 @@ export class Application extends EventEmitter {
|
|
|
57
59
|
// Swallow listener throws so the 500 response still goes out.
|
|
58
60
|
}
|
|
59
61
|
if (!res.headersSent) {
|
|
62
|
+
ensureFallbackHeaders(res);
|
|
60
63
|
res.writeHead(500);
|
|
61
64
|
res.end("Internal Server Error");
|
|
62
65
|
}
|
|
@@ -78,17 +81,15 @@ export class Application extends EventEmitter {
|
|
|
78
81
|
}
|
|
79
82
|
});
|
|
80
83
|
const { onWriteHead, onFinish } = this.logger.onRequestStart(req);
|
|
81
|
-
const ctx = new ContextClass(req, res, {}, timing, this.asyncLocalStorage, abortController, this.bodyLimit, this.trustProxy, onWriteHead);
|
|
84
|
+
const ctx = new ContextClass(req, res, {}, timing, this.asyncLocalStorage, abortController, this.bodyLimit, this.trustProxy, onWriteHead, this.strictJsonContentType);
|
|
82
85
|
// Security headers
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
+
for (const [name, value] of Object.entries(SECURITY_HEADERS)) {
|
|
87
|
+
res.setHeader(name, value);
|
|
88
|
+
}
|
|
86
89
|
try {
|
|
87
90
|
const method = req.method ?? "GET";
|
|
88
91
|
const url = req.url ?? "/";
|
|
89
|
-
const rawPath =
|
|
90
|
-
? new URL(url).pathname
|
|
91
|
-
: url.split("?")[0];
|
|
92
|
+
const rawPath = getRawPath(url);
|
|
92
93
|
const routePath = rawPath.replace(/^\/+/, "/") || "/";
|
|
93
94
|
const found = this.router.find(method, routePath);
|
|
94
95
|
if (found) {
|
|
@@ -100,6 +101,7 @@ export class Application extends EventEmitter {
|
|
|
100
101
|
await this.notFoundHandlerFn(ctx);
|
|
101
102
|
}
|
|
102
103
|
else {
|
|
104
|
+
ensureFallbackHeaders(res);
|
|
103
105
|
res.writeHead(404);
|
|
104
106
|
res.end("Not Found");
|
|
105
107
|
}
|
|
@@ -107,7 +109,7 @@ export class Application extends EventEmitter {
|
|
|
107
109
|
onFinish(res.statusCode);
|
|
108
110
|
}
|
|
109
111
|
catch (err) {
|
|
110
|
-
const error = err instanceof Error ? err : new Error(
|
|
112
|
+
const error = err instanceof Error ? err : new Error(safeString(err));
|
|
111
113
|
if (this.listenerCount("error") > 0) {
|
|
112
114
|
this.emit("error", error);
|
|
113
115
|
}
|
|
@@ -129,6 +131,7 @@ export class Application extends EventEmitter {
|
|
|
129
131
|
}
|
|
130
132
|
else if (!res.headersSent) {
|
|
131
133
|
const status = getFallbackStatus(error);
|
|
134
|
+
ensureFallbackHeaders(res);
|
|
132
135
|
res.writeHead(status);
|
|
133
136
|
res.end(getFallbackBody(error, status));
|
|
134
137
|
}
|
|
@@ -136,4 +139,16 @@ export class Application extends EventEmitter {
|
|
|
136
139
|
}
|
|
137
140
|
}
|
|
138
141
|
}
|
|
142
|
+
function getRawPath(url) {
|
|
143
|
+
const lower = url.slice(0, 8).toLowerCase();
|
|
144
|
+
if (lower.startsWith("http://") || lower.startsWith("https://")) {
|
|
145
|
+
try {
|
|
146
|
+
return new URL(url).pathname;
|
|
147
|
+
}
|
|
148
|
+
catch {
|
|
149
|
+
throw Object.assign(new Error("Invalid URL"), { status: 400 });
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
return url.split("?")[0];
|
|
153
|
+
}
|
|
139
154
|
export const createApp = (options) => new Application(options);
|
package/dist/context.d.mts
CHANGED
|
@@ -18,7 +18,7 @@ export declare class Context {
|
|
|
18
18
|
private queryCache;
|
|
19
19
|
private asyncLocalStorage;
|
|
20
20
|
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);
|
|
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, strictJsonContentType?: boolean);
|
|
22
22
|
get query(): Record<string, string | string[]>;
|
|
23
23
|
get store(): unknown;
|
|
24
24
|
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) {
|
|
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);
|
|
34
34
|
this.response = new Response(req, res, timing, onWriteHead);
|
|
35
35
|
this.cookies = new Cookies(req, res);
|
|
36
36
|
this.abortController = abortController;
|
|
@@ -1,4 +1,16 @@
|
|
|
1
|
-
import type
|
|
1
|
+
import { type ServerResponse } from "node:http";
|
|
2
|
+
export declare const SECURITY_HEADERS: {
|
|
3
|
+
readonly "X-XSS-Protection": "0";
|
|
4
|
+
readonly "X-Frame-Options": "SAMEORIGIN";
|
|
5
|
+
readonly "X-Content-Type-Options": "nosniff";
|
|
6
|
+
readonly "Strict-Transport-Security": "max-age=15552000; includeSubDomains";
|
|
7
|
+
readonly "Referrer-Policy": "no-referrer";
|
|
8
|
+
readonly "X-DNS-Prefetch-Control": "off";
|
|
9
|
+
readonly "X-Download-Options": "noopen";
|
|
10
|
+
readonly "X-Permitted-Cross-Domain-Policies": "none";
|
|
11
|
+
};
|
|
12
|
+
export declare function ensureFallbackHeaders(res: ServerResponse): void;
|
|
2
13
|
export declare function sendFallback(res: ServerResponse): void;
|
|
3
14
|
export declare function getFallbackStatus(error: unknown): number;
|
|
15
|
+
export declare function safeString(err: unknown): string;
|
|
4
16
|
export declare function getFallbackBody(error: unknown, status: number): string;
|
|
@@ -1,8 +1,37 @@
|
|
|
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";
|
|
5
|
+
const TEXT_PLAIN_CONTENT_TYPE = "text/plain; charset=utf-8";
|
|
6
|
+
export const SECURITY_HEADERS = {
|
|
7
|
+
"X-XSS-Protection": "0",
|
|
8
|
+
"X-Frame-Options": "SAMEORIGIN",
|
|
9
|
+
"X-Content-Type-Options": "nosniff",
|
|
10
|
+
"Strict-Transport-Security": "max-age=15552000; includeSubDomains",
|
|
11
|
+
"Referrer-Policy": "no-referrer",
|
|
12
|
+
"X-DNS-Prefetch-Control": "off",
|
|
13
|
+
"X-Download-Options": "noopen",
|
|
14
|
+
"X-Permitted-Cross-Domain-Policies": "none",
|
|
15
|
+
};
|
|
16
|
+
const FALLBACK_HEADERS = {
|
|
17
|
+
"Content-Type": TEXT_PLAIN_CONTENT_TYPE,
|
|
18
|
+
...SECURITY_HEADERS,
|
|
19
|
+
};
|
|
20
|
+
export function ensureFallbackHeaders(res) {
|
|
21
|
+
for (const [name, value] of Object.entries(FALLBACK_HEADERS)) {
|
|
22
|
+
try {
|
|
23
|
+
if (typeof res.hasHeader !== "function" || !res.hasHeader(name)) {
|
|
24
|
+
res.setHeader(name, value);
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
catch {
|
|
28
|
+
// Header mutation can fail on destroyed sockets or non-standard responses.
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
}
|
|
4
32
|
export function sendFallback(res) {
|
|
5
33
|
try {
|
|
34
|
+
ensureFallbackHeaders(res);
|
|
6
35
|
res.writeHead(ERROR_STATUS);
|
|
7
36
|
res.end(ERROR_BODY);
|
|
8
37
|
}
|
|
@@ -17,9 +46,30 @@ export function getFallbackStatus(error) {
|
|
|
17
46
|
}
|
|
18
47
|
return ERROR_STATUS;
|
|
19
48
|
}
|
|
49
|
+
function escapeHtml(unsafe) {
|
|
50
|
+
return unsafe
|
|
51
|
+
.replaceAll("&", "&")
|
|
52
|
+
.replaceAll("<", "<")
|
|
53
|
+
.replaceAll(">", ">")
|
|
54
|
+
.replaceAll('"', """)
|
|
55
|
+
.replaceAll("'", "'");
|
|
56
|
+
}
|
|
57
|
+
export function safeString(err) {
|
|
58
|
+
try {
|
|
59
|
+
return String(err);
|
|
60
|
+
}
|
|
61
|
+
catch {
|
|
62
|
+
return "[Object null prototype]";
|
|
63
|
+
}
|
|
64
|
+
}
|
|
20
65
|
export function getFallbackBody(error, status) {
|
|
21
66
|
if (status >= 500)
|
|
22
67
|
return ERROR_BODY;
|
|
23
|
-
const
|
|
24
|
-
|
|
68
|
+
const err = error;
|
|
69
|
+
const message = err?.message;
|
|
70
|
+
if (err?.expose === false) {
|
|
71
|
+
return STATUS_CODES[status] || FALLBACK_BODY;
|
|
72
|
+
}
|
|
73
|
+
const msg = typeof message === "string" && message ? message : STATUS_CODES[status] || FALLBACK_BODY;
|
|
74
|
+
return escapeHtml(msg);
|
|
25
75
|
}
|
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
|
+
}
|
package/dist/request.d.mts
CHANGED
|
@@ -4,7 +4,8 @@ export declare class Request {
|
|
|
4
4
|
private res;
|
|
5
5
|
private bodyPromise;
|
|
6
6
|
private defaultLimit;
|
|
7
|
-
|
|
7
|
+
private strictJsonContentType;
|
|
8
|
+
constructor(req: IncomingMessage, res: ServerResponse, defaultLimit?: string | number | false, strictJsonContentType?: boolean);
|
|
8
9
|
is(type: string | string[]): string | false | null;
|
|
9
10
|
buffer(limit?: string | number | false): Promise<Buffer>;
|
|
10
11
|
json<T = unknown>(limit?: string | number | false): Promise<T>;
|
package/dist/request.mjs
CHANGED
|
@@ -8,10 +8,12 @@ export class Request {
|
|
|
8
8
|
res;
|
|
9
9
|
bodyPromise = null;
|
|
10
10
|
defaultLimit;
|
|
11
|
-
|
|
11
|
+
strictJsonContentType;
|
|
12
|
+
constructor(req, res, defaultLimit = "1mb", strictJsonContentType = false) {
|
|
12
13
|
this.req = req;
|
|
13
14
|
this.res = res;
|
|
14
15
|
this.defaultLimit = defaultLimit;
|
|
16
|
+
this.strictJsonContentType = strictJsonContentType;
|
|
15
17
|
}
|
|
16
18
|
is(type) {
|
|
17
19
|
return typeIs(this.req, Array.isArray(type) ? type : [type]);
|
|
@@ -27,6 +29,23 @@ export class Request {
|
|
|
27
29
|
return this.bodyPromise;
|
|
28
30
|
}
|
|
29
31
|
async json(limit) {
|
|
32
|
+
if (this.strictJsonContentType) {
|
|
33
|
+
// type-is semantics:
|
|
34
|
+
// null → no body (no Content-Length / Transfer-Encoding header);
|
|
35
|
+
// skip the content-type check — buffer() will return an empty
|
|
36
|
+
// buffer that JSON.parse rejects with 400 as usual.
|
|
37
|
+
// false → a body is indicated but Content-Type is not a JSON type.
|
|
38
|
+
// We 415 only when the body actually has content (CL > 0 or
|
|
39
|
+
// Transfer-Encoding is set without CL). A Content-Length: 0
|
|
40
|
+
// body has nothing to read, so let it fall through to 400.
|
|
41
|
+
// truthy → recognised JSON type; proceed normally.
|
|
42
|
+
const mediaType = this.is(["json", "application/*+json"]);
|
|
43
|
+
const cl = this.req.headers["content-length"];
|
|
44
|
+
const emptyBody = cl !== undefined && Number(cl) === 0;
|
|
45
|
+
if (mediaType === false && !emptyBody) {
|
|
46
|
+
throw Object.assign(new Error("Unsupported Media Type"), { status: 415 });
|
|
47
|
+
}
|
|
48
|
+
}
|
|
30
49
|
const buf = await this.buffer(limit);
|
|
31
50
|
try {
|
|
32
51
|
return JSON.parse(buf.toString("utf8"));
|
package/dist/types.d.mts
CHANGED
|
@@ -12,4 +12,13 @@ export interface ApplicationOptions {
|
|
|
12
12
|
bodyLimit?: string | number | false;
|
|
13
13
|
logger?: LoggerOptions;
|
|
14
14
|
trustProxy?: boolean;
|
|
15
|
+
/**
|
|
16
|
+
* When true, ctx.request.json() rejects requests whose Content-Type is not
|
|
17
|
+
* application/json (or a compatible JSON subtype such as application/merge-patch+json)
|
|
18
|
+
* with a 415 Unsupported Media Type error. Requests with no body are unaffected.
|
|
19
|
+
*
|
|
20
|
+
* Defaults to false (lenient: any Content-Type is accepted, preserving
|
|
21
|
+
* backward-compatible behavior).
|
|
22
|
+
*/
|
|
23
|
+
strictJsonContentType?: boolean;
|
|
15
24
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@jongleberry/api-server",
|
|
3
|
-
"version": "1.0
|
|
3
|
+
"version": "1.1.0",
|
|
4
4
|
"description": "A Node.js HTTP server library",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"author": "Jonathan Ong",
|
|
@@ -44,7 +44,7 @@
|
|
|
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.51.0",
|
|
48
48
|
"oxlint": "^1.65.0",
|
|
49
49
|
"supertest": "^7.2.2",
|
|
50
50
|
"typescript": "^6.0.3",
|