@jongleberry/api-server 1.1.0 → 1.2.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -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
@@ -15,8 +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;
19
20
  private strictJsonContentType;
21
+ private readonly oversizedBodyStrategy;
22
+ private readonly fallbackContentSecurityPolicy;
23
+ private readonly strictHttpMethods;
20
24
  constructor(options?: ApplicationOptions);
21
25
  route(path: string): RouteBuilder;
22
26
  errorHandler(fn: ErrorHandler): void;
@@ -1,10 +1,12 @@
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 { ensureFallbackHeaders, getFallbackBody, getFallbackStatus, safeString, sendFallback, SECURITY_HEADERS, } from "./fallback-response.mjs";
7
+ import { getRawPath } from "./request-path.mjs";
8
+ import { createRequestAbortController } from "./request-abort.mjs";
9
+ import { applySecurityHeaders, ensureFallbackHeaders, getFallbackBody, getFallbackStatus, resolveSecurityHeaders, safeString, sendFallback, } from "./fallback-response.mjs";
8
10
  export class Application extends EventEmitter {
9
11
  router = Router();
10
12
  errorHandlerFn = null;
@@ -14,14 +16,22 @@ export class Application extends EventEmitter {
14
16
  contextClass = Context;
15
17
  logger;
16
18
  bodyLimit;
19
+ securityHeaders;
17
20
  trustProxy;
18
21
  strictJsonContentType;
22
+ oversizedBodyStrategy;
23
+ fallbackContentSecurityPolicy;
24
+ strictHttpMethods;
19
25
  constructor(options) {
20
26
  super();
21
27
  this.logger = new Logger(options?.logger);
22
28
  this.bodyLimit = options?.bodyLimit ?? "1mb";
29
+ this.securityHeaders = resolveSecurityHeaders(options?.securityHeaders);
23
30
  this.trustProxy = options?.trustProxy ?? false;
24
31
  this.strictJsonContentType = options?.strictJsonContentType ?? false;
32
+ this.oversizedBodyStrategy = options?.oversizedBodyStrategy ?? "drain";
33
+ this.fallbackContentSecurityPolicy = options?.fallbackContentSecurityPolicy ?? false;
34
+ this.strictHttpMethods = options?.strictHttpMethods ?? false;
25
35
  }
26
36
  route(path) {
27
37
  return createRouteBuilder(this.router, path);
@@ -59,7 +69,7 @@ export class Application extends EventEmitter {
59
69
  // Swallow listener throws so the 500 response still goes out.
60
70
  }
61
71
  if (!res.headersSent) {
62
- ensureFallbackHeaders(res);
72
+ ensureFallbackHeaders(res, this.securityHeaders, this.fallbackContentSecurityPolicy);
63
73
  res.writeHead(500);
64
74
  res.end("Internal Server Error");
65
75
  }
@@ -72,22 +82,17 @@ export class Application extends EventEmitter {
72
82
  }
73
83
  }
74
84
  async runRequest(req, res) {
75
- const abortController = new AbortController();
85
+ const abortController = createRequestAbortController(res);
76
86
  const timing = new ServerTiming();
77
87
  const ContextClass = this.contextClass;
78
- req.on("close", () => {
79
- if (!res.writableEnded) {
80
- abortController.abort();
81
- }
82
- });
83
88
  const { onWriteHead, onFinish } = this.logger.onRequestStart(req);
84
- const ctx = new ContextClass(req, res, {}, timing, this.asyncLocalStorage, abortController, this.bodyLimit, this.trustProxy, onWriteHead, this.strictJsonContentType);
85
- // Security headers
86
- for (const [name, value] of Object.entries(SECURITY_HEADERS)) {
87
- res.setHeader(name, value);
88
- }
89
+ const ctx = new ContextClass(req, res, {}, timing, this.asyncLocalStorage, abortController, this.bodyLimit, this.trustProxy, onWriteHead, this.strictJsonContentType, this.oversizedBodyStrategy);
90
+ applySecurityHeaders(res, this.securityHeaders);
89
91
  try {
90
92
  const method = req.method ?? "GET";
93
+ if (this.strictHttpMethods && !isSupportedHttpMethod(method)) {
94
+ throw Object.assign(new Error("Unsupported HTTP method"), { status: 400 });
95
+ }
91
96
  const url = req.url ?? "/";
92
97
  const rawPath = getRawPath(url);
93
98
  const routePath = rawPath.replace(/^\/+/, "/") || "/";
@@ -101,7 +106,7 @@ export class Application extends EventEmitter {
101
106
  await this.notFoundHandlerFn(ctx);
102
107
  }
103
108
  else {
104
- ensureFallbackHeaders(res);
109
+ ensureFallbackHeaders(res, this.securityHeaders, this.fallbackContentSecurityPolicy);
105
110
  res.writeHead(404);
106
111
  res.end("Not Found");
107
112
  }
@@ -126,12 +131,12 @@ export class Application extends EventEmitter {
126
131
  // registered error handler threw or returned without sending one. Without
127
132
  // this, requests hang until the socket times out (issue #1948).
128
133
  if (!res.headersSent) {
129
- sendFallback(res);
134
+ sendFallback(res, this.securityHeaders, this.fallbackContentSecurityPolicy);
130
135
  }
131
136
  }
132
137
  else if (!res.headersSent) {
133
138
  const status = getFallbackStatus(error);
134
- ensureFallbackHeaders(res);
139
+ ensureFallbackHeaders(res, this.securityHeaders, this.fallbackContentSecurityPolicy);
135
140
  res.writeHead(status);
136
141
  res.end(getFallbackBody(error, status));
137
142
  }
@@ -139,16 +144,4 @@ export class Application extends EventEmitter {
139
144
  }
140
145
  }
141
146
  }
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
- }
154
147
  export const createApp = (options) => new Application(options);
@@ -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, strictJsonContentType?: boolean);
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, strictJsonContentType) {
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, strictJsonContentType ?? false);
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 { parse, serialize } from "cookie";
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 = parse(header);
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 = serialize(name, value, opts);
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,16 +1,11 @@
1
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;
13
- export declare function sendFallback(res: ServerResponse): void;
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;
14
9
  export declare function getFallbackStatus(error: unknown): number;
15
10
  export declare function safeString(err: unknown): string;
16
11
  export declare function getFallbackBody(error: unknown, status: number): string;
@@ -3,22 +3,44 @@ const FALLBACK_BODY = "Not Found";
3
3
  const ERROR_STATUS = 500;
4
4
  const ERROR_BODY = "Internal Server Error";
5
5
  const TEXT_PLAIN_CONTENT_TYPE = "text/plain; charset=utf-8";
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
+ ];
6
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
- "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
20
  };
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)) {
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
+ })) {
22
44
  try {
23
45
  if (typeof res.hasHeader !== "function" || !res.hasHeader(name)) {
24
46
  res.setHeader(name, value);
@@ -28,10 +50,20 @@ export function ensureFallbackHeaders(res) {
28
50
  // Header mutation can fail on destroyed sockets or non-standard responses.
29
51
  }
30
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
+ }
31
63
  }
32
- export function sendFallback(res) {
64
+ export function sendFallback(res, securityHeaders = SECURITY_HEADERS, contentSecurityPolicy = false) {
33
65
  try {
34
- ensureFallbackHeaders(res);
66
+ ensureFallbackHeaders(res, securityHeaders, contentSecurityPolicy);
35
67
  res.writeHead(ERROR_STATUS);
36
68
  res.end(ERROR_BODY);
37
69
  }
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";
@@ -0,0 +1,2 @@
1
+ import type { ServerResponse } from "node:http";
2
+ export declare function createRequestAbortController(response: ServerResponse): AbortController;
@@ -0,0 +1,21 @@
1
+ function isPositiveFinite(limit) {
2
+ return limit > 0 && Number.isFinite(limit);
3
+ }
4
+ export function createRequestAbortController(response) {
5
+ const abortController = new AbortController();
6
+ const initialLimit = response.getMaxListeners();
7
+ const reservedListenerSlot = isPositiveFinite(initialLimit);
8
+ if (reservedListenerSlot) {
9
+ response.setMaxListeners(initialLimit + 1);
10
+ }
11
+ response.once("close", () => {
12
+ const currentLimit = response.getMaxListeners();
13
+ if (reservedListenerSlot && isPositiveFinite(currentLimit)) {
14
+ response.setMaxListeners(currentLimit - 1);
15
+ }
16
+ if (!response.writableEnded) {
17
+ abortController.abort();
18
+ }
19
+ });
20
+ return abortController;
21
+ }
@@ -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
+ }
@@ -1,11 +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;
8
- constructor(req: IncomingMessage, res: ServerResponse, defaultLimit?: string | number | false, strictJsonContentType?: boolean);
9
+ private readonly oversizedBodyStrategy;
10
+ constructor(req: IncomingMessage, res: ServerResponse, defaultLimit?: string | number | false, strictJsonContentType?: boolean, oversizedBodyStrategy?: OversizedBodyStrategy);
9
11
  is(type: string | string[]): string | false | null;
10
12
  buffer(limit?: string | number | false): Promise<Buffer>;
11
13
  json<T = unknown>(limit?: string | number | false): Promise<T>;
package/dist/request.mjs CHANGED
@@ -9,11 +9,13 @@ export class Request {
9
9
  bodyPromise = null;
10
10
  defaultLimit;
11
11
  strictJsonContentType;
12
- constructor(req, res, defaultLimit = "1mb", strictJsonContentType = false) {
12
+ oversizedBodyStrategy;
13
+ constructor(req, res, defaultLimit = "1mb", strictJsonContentType = false, oversizedBodyStrategy = "drain") {
13
14
  this.req = req;
14
15
  this.res = res;
15
16
  this.defaultLimit = defaultLimit;
16
17
  this.strictJsonContentType = strictJsonContentType;
18
+ this.oversizedBodyStrategy = oversizedBodyStrategy;
17
19
  }
18
20
  is(type) {
19
21
  return typeIs(this.req, Array.isArray(type) ? type : [type]);
@@ -21,10 +23,10 @@ export class Request {
21
23
  buffer(limit) {
22
24
  if (!this.bodyPromise) {
23
25
  const effectiveLimit = limit ?? this.defaultLimit;
24
- if (this.req.headers.expect === "100-continue") {
26
+ if (this.oversizedBodyStrategy === "drain" && this.req.headers.expect === "100-continue") {
25
27
  this.res.writeContinue();
26
28
  }
27
- this.bodyPromise = readBody(this.req, effectiveLimit);
29
+ this.bodyPromise = readBody(this.req, this.res, effectiveLimit, this.oversizedBodyStrategy);
28
30
  }
29
31
  return this.bodyPromise;
30
32
  }
@@ -64,9 +66,20 @@ function parseLimit(limit) {
64
66
  }
65
67
  return parsed;
66
68
  }
67
- function readBody(req, limit) {
69
+ function readBody(req, res, limit, oversizedBodyStrategy) {
68
70
  return new Promise((resolve, reject) => {
69
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
+ }
70
83
  const chunks = [];
71
84
  let totalLength = 0;
72
85
  function cleanup() {
@@ -82,10 +95,8 @@ function readBody(req, limit) {
82
95
  req.removeListener("data", onData);
83
96
  req.removeListener("end", onEnd);
84
97
  req.removeListener("error", onError);
85
- req.on("error", noop);
86
- reject(Object.assign(new Error("Request entity too large"), { status: 413 }));
87
- // Drain remaining data so the connection stays reusable (HTTP keep-alive)
88
- req.resume();
98
+ reject(createOversizedBodyError());
99
+ handleOversizedBody(req, res, oversizedBodyStrategy);
89
100
  return;
90
101
  }
91
102
  chunks.push(chunk);
@@ -103,3 +114,26 @@ function readBody(req, limit) {
103
114
  req.on("error", onError);
104
115
  });
105
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", "Accept-Encoding");
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", "Accept-Encoding");
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
@@ -1,4 +1,8 @@
1
+ import { METHODS } from "node:http";
1
2
  import Router from "find-my-way";
3
+ export function isSupportedHttpMethod(method) {
4
+ return METHODS.includes(method);
5
+ }
2
6
  export function createRouteBuilder(router, path) {
3
7
  const builder = {
4
8
  get(handler) {
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,8 +13,15 @@ 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;
15
25
  /**
16
26
  * When true, ctx.request.json() rejects requests whose Content-Type is not
17
27
  * application/json (or a compatible JSON subtype such as application/merge-patch+json)
@@ -0,0 +1,3 @@
1
+ type VaryHeader = undefined | string | number | readonly string[];
2
+ export declare function mergeVary(header: VaryHeader): string;
3
+ export {};
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.1.0",
3
+ "version": "1.2.1",
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": "^1.1.1",
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,15 +39,15 @@
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": "^25.8.0",
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.51.0",
47
+ "oxfmt": "^0.59.0",
48
48
  "oxlint": "^1.65.0",
49
49
  "supertest": "^7.2.2",
50
- "typescript": "^6.0.3",
50
+ "typescript": "^7.0.2",
51
51
  "vitest": "^4.1.6"
52
52
  },
53
53
  "scripts": {