@jongleberry/api-server 1.0.0 → 1.0.3

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
@@ -47,10 +47,11 @@ app.route("/").get((ctx) => ctx.json({ ok: true }));
47
47
  - **Compression** — `br` / `gzip` / `deflate` negotiation; 1 KB threshold; `SYNC_FLUSH` for streams
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
+ - **Request body limits** — `ctx.request.buffer()` and `.json()` use a safe 1 MB default, with per-call overrides
50
51
  - **AsyncLocalStorage** — per-request store via `app.setAsyncLocalStorage(als)`
51
52
  - **Cookies** — `ctx.cookies.get()` / `.set()` with full `Set-Cookie` options
52
53
  - **Cache-Control** — `ctx.cacheControl(visibility, maxAge)` helper
53
- - **Trusted client IP** — Cloudflare/AWS ALB-aware helper for Node, Deno, and Bun
54
+ - **Trusted client IP** — proxy headers are opt-in via `trustProxy`; standalone helpers support Node, Deno, and Bun
54
55
  - **Dev logger** — concurrent-request bar, color-coded status codes, timing thresholds; silent in `NODE_ENV=production` and `NODE_ENV=test`
55
56
  - **Error safety net** — error handlers that throw or return without a response still guarantee the client receives a response
56
57
 
@@ -86,7 +87,7 @@ See [docs/](docs/README.md) for full API reference:
86
87
 
87
88
  - No middleware stack. Routes are registered directly on the application; request processing runs top-to-bottom in a single async function per request.
88
89
  - No magic. `ctx.req` and `ctx.res` are the raw Node.js `IncomingMessage` and `ServerResponse` objects.
89
- - Body is pull-based. `ctx.request.buffer()` and `ctx.request.json()` are explicit calls; the body is never automatically parsed.
90
+ - Body is pull-based. `ctx.request.buffer()` and `ctx.request.json()` are explicit calls; the body is never automatically parsed and defaults to a 1 MB limit.
90
91
  - Responses are explicit. You choose buffered or streaming; the library doesn't buffer a stream or stream a buffer behind your back.
91
92
 
92
93
  ## License
@@ -14,6 +14,8 @@ export declare class Application extends EventEmitter {
14
14
  private extensions;
15
15
  private contextClass;
16
16
  private logger;
17
+ private bodyLimit;
18
+ private trustProxy;
17
19
  constructor(options?: ApplicationOptions);
18
20
  route(path: string): RouteBuilder;
19
21
  errorHandler(fn: ErrorHandler): void;
@@ -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 { getFallbackStatus, sendFallback } from "./fallback-response.mjs";
7
+ import { getFallbackBody, getFallbackStatus, sendFallback } from "./fallback-response.mjs";
8
8
  export class Application extends EventEmitter {
9
9
  router = Router();
10
10
  errorHandlerFn = null;
@@ -13,9 +13,13 @@ export class Application extends EventEmitter {
13
13
  extensions = {};
14
14
  contextClass = Context;
15
15
  logger;
16
+ bodyLimit;
17
+ trustProxy;
16
18
  constructor(options) {
17
19
  super();
18
20
  this.logger = new Logger(options?.logger);
21
+ this.bodyLimit = options?.bodyLimit ?? "1mb";
22
+ this.trustProxy = options?.trustProxy ?? false;
19
23
  }
20
24
  route(path) {
21
25
  return createRouteBuilder(this.router, path);
@@ -74,7 +78,7 @@ export class Application extends EventEmitter {
74
78
  }
75
79
  });
76
80
  const { onWriteHead, onFinish } = this.logger.onRequestStart(req);
77
- const ctx = new ContextClass(req, res, {}, timing, this.asyncLocalStorage, abortController, onWriteHead);
81
+ const ctx = new ContextClass(req, res, {}, timing, this.asyncLocalStorage, abortController, this.bodyLimit, this.trustProxy, onWriteHead);
78
82
  // Security headers
79
83
  res.setHeader("X-XSS-Protection", "0");
80
84
  res.setHeader("X-Frame-Options", "SAMEORIGIN");
@@ -124,8 +128,9 @@ export class Application extends EventEmitter {
124
128
  }
125
129
  }
126
130
  else if (!res.headersSent) {
127
- res.writeHead(getFallbackStatus(error));
128
- res.end(error.message);
131
+ const status = getFallbackStatus(error);
132
+ res.writeHead(status);
133
+ res.end(getFallbackBody(error, status));
129
134
  }
130
135
  onFinish(res.statusCode);
131
136
  }
@@ -5,6 +5,7 @@ import Negotiator from "negotiator";
5
5
  import compressibleFn from "compressible";
6
6
  const SUPPORTED_ENCODINGS = ["br", "gzip", "deflate"];
7
7
  const COMPRESSION_THRESHOLD = 1024;
8
+ const MAX_SYNC_COMPRESSION_BYTES = 1024 * 1024;
8
9
  export function negotiateEncoding(req) {
9
10
  const negotiator = new Negotiator(req);
10
11
  const encoding = negotiator.encoding(SUPPORTED_ENCODINGS);
@@ -38,6 +39,8 @@ export function compressSync(encoding, buffer) {
38
39
  export function shouldCompress(req, res, contentType, bodyLength) {
39
40
  if (bodyLength < COMPRESSION_THRESHOLD)
40
41
  return null;
42
+ if (Number.isFinite(bodyLength) && bodyLength > MAX_SYNC_COMPRESSION_BYTES)
43
+ return null;
41
44
  if (res.getHeader("Content-Encoding"))
42
45
  return null;
43
46
  const cacheControl = req.headers["cache-control"];
@@ -17,7 +17,8 @@ export declare class Context {
17
17
  assert: typeof httpAssert;
18
18
  private queryCache;
19
19
  private asyncLocalStorage;
20
- constructor(req: IncomingMessage, res: ServerResponse, params: Record<string, string | undefined>, timing: ServerTiming, als: AsyncLocalStorage<unknown> | null, abortController: AbortController, onWriteHead?: () => void);
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
22
  get query(): Record<string, string | string[]>;
22
23
  get store(): unknown;
23
24
  get ip(): string | undefined;
package/dist/context.mjs CHANGED
@@ -25,17 +25,19 @@ export class Context {
25
25
  assert;
26
26
  queryCache = null;
27
27
  asyncLocalStorage;
28
- constructor(req, res, params, timing, als, abortController, onWriteHead) {
28
+ trustProxy;
29
+ constructor(req, res, params, timing, als, abortController, bodyLimit, trustProxy, onWriteHead) {
29
30
  this.req = req;
30
31
  this.res = res;
31
32
  this.params = params;
32
- this.request = new Request(req, res);
33
+ this.request = new Request(req, res, bodyLimit);
33
34
  this.response = new Response(req, res, timing, onWriteHead);
34
35
  this.cookies = new Cookies(req, res);
35
36
  this.abortController = abortController;
36
37
  this.signal = abortController.signal;
37
38
  this.assert = httpAssert;
38
39
  this.asyncLocalStorage = als;
40
+ this.trustProxy = trustProxy;
39
41
  }
40
42
  get query() {
41
43
  if (this.queryCache)
@@ -48,10 +50,18 @@ export class Context {
48
50
  }
49
51
  const queryString = url.slice(questionMark + 1);
50
52
  const params = new URLSearchParams(queryString);
51
- const result = {};
52
- for (const key of params.keys()) {
53
- const values = params.getAll(key);
54
- result[key] = values.length === 1 ? values[0] : values;
53
+ const result = Object.create(null);
54
+ for (const [key, value] of params) {
55
+ const current = result[key];
56
+ if (current === undefined) {
57
+ result[key] = value;
58
+ }
59
+ else if (Array.isArray(current)) {
60
+ current.push(value);
61
+ }
62
+ else {
63
+ result[key] = [current, value];
64
+ }
55
65
  }
56
66
  this.queryCache = result;
57
67
  return this.queryCache;
@@ -61,7 +71,7 @@ export class Context {
61
71
  }
62
72
  get ip() {
63
73
  return resolveTrustedClientIp({
64
- headers: this.req.headers,
74
+ headers: this.trustProxy ? this.req.headers : undefined,
65
75
  socketRemoteAddress: this.req.socket?.remoteAddress,
66
76
  });
67
77
  }
@@ -1,3 +1,4 @@
1
1
  import type { ServerResponse } from "node:http";
2
2
  export declare function sendFallback(res: ServerResponse): void;
3
3
  export declare function getFallbackStatus(error: unknown): number;
4
+ export declare function getFallbackBody(error: unknown, status: number): string;
@@ -1,9 +1,10 @@
1
- const FALLBACK_STATUS = 404;
2
1
  const FALLBACK_BODY = "Not Found";
2
+ const ERROR_STATUS = 500;
3
+ const ERROR_BODY = "Internal Server Error";
3
4
  export function sendFallback(res) {
4
5
  try {
5
- res.writeHead(FALLBACK_STATUS);
6
- res.end(FALLBACK_BODY);
6
+ res.writeHead(ERROR_STATUS);
7
+ res.end(ERROR_BODY);
7
8
  }
8
9
  catch {
9
10
  // Socket may already be destroyed; nothing more we can do.
@@ -11,8 +12,14 @@ export function sendFallback(res) {
11
12
  }
12
13
  export function getFallbackStatus(error) {
13
14
  const status = error?.status;
14
- if (typeof status === "number" && Number.isInteger(status) && status >= 100 && status < 600) {
15
+ if (typeof status === "number" && Number.isInteger(status) && status >= 400 && status < 600) {
15
16
  return status;
16
17
  }
17
- return FALLBACK_STATUS;
18
+ return ERROR_STATUS;
19
+ }
20
+ export function getFallbackBody(error, status) {
21
+ if (status >= 500)
22
+ return ERROR_BODY;
23
+ const message = error?.message;
24
+ return typeof message === "string" && message ? message : FALLBACK_BODY;
18
25
  }
@@ -3,8 +3,9 @@ export declare class Request {
3
3
  private req;
4
4
  private res;
5
5
  private bodyPromise;
6
- constructor(req: IncomingMessage, res: ServerResponse);
6
+ private defaultLimit;
7
+ constructor(req: IncomingMessage, res: ServerResponse, defaultLimit?: string | number | false);
7
8
  is(type: string | string[]): string | false | null;
8
- buffer(limit?: string | number): Promise<Buffer>;
9
- json<T = unknown>(limit?: string | number): Promise<T>;
9
+ buffer(limit?: string | number | false): Promise<Buffer>;
10
+ json<T = unknown>(limit?: string | number | false): Promise<T>;
10
11
  }
package/dist/request.mjs CHANGED
@@ -7,19 +7,22 @@ export class Request {
7
7
  req;
8
8
  res;
9
9
  bodyPromise = null;
10
- constructor(req, res) {
10
+ defaultLimit;
11
+ constructor(req, res, defaultLimit = "1mb") {
11
12
  this.req = req;
12
13
  this.res = res;
14
+ this.defaultLimit = defaultLimit;
13
15
  }
14
16
  is(type) {
15
17
  return typeIs(this.req, Array.isArray(type) ? type : [type]);
16
18
  }
17
19
  buffer(limit) {
18
20
  if (!this.bodyPromise) {
21
+ const effectiveLimit = limit ?? this.defaultLimit;
19
22
  if (this.req.headers.expect === "100-continue") {
20
23
  this.res.writeContinue();
21
24
  }
22
- this.bodyPromise = readBody(this.req, limit);
25
+ this.bodyPromise = readBody(this.req, effectiveLimit);
23
26
  }
24
27
  return this.bodyPromise;
25
28
  }
@@ -33,9 +36,18 @@ export class Request {
33
36
  }
34
37
  }
35
38
  }
39
+ function parseLimit(limit) {
40
+ if (limit === false)
41
+ return Infinity;
42
+ const parsed = typeof limit === "number" ? limit : bytes.parse(limit);
43
+ if (parsed === null || parsed === undefined || !Number.isFinite(parsed) || parsed < 0) {
44
+ throw new TypeError(`Invalid request body limit: ${String(limit)}`);
45
+ }
46
+ return parsed;
47
+ }
36
48
  function readBody(req, limit) {
37
49
  return new Promise((resolve, reject) => {
38
- const maxBytes = limit !== undefined ? (bytes.parse(limit) ?? Infinity) : Infinity;
50
+ const maxBytes = parseLimit(limit);
39
51
  const chunks = [];
40
52
  let totalLength = 0;
41
53
  function cleanup() {
package/dist/types.d.mts CHANGED
@@ -9,5 +9,7 @@ export interface CookieOptions {
9
9
  maxAge?: number;
10
10
  }
11
11
  export interface ApplicationOptions {
12
+ bodyLimit?: string | number | false;
12
13
  logger?: LoggerOptions;
14
+ trustProxy?: boolean;
13
15
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@jongleberry/api-server",
3
- "version": "1.0.0",
3
+ "version": "1.0.3",
4
4
  "description": "A Node.js HTTP server library",
5
5
  "license": "MIT",
6
6
  "author": "Jonathan Ong",
@@ -76,10 +76,10 @@
76
76
  ],
77
77
  "repository": {
78
78
  "type": "git",
79
- "url": "git+https://github.com/jongleberry/api-server.git"
79
+ "url": "git+https://github.com/jonathanong/api-server.git"
80
80
  },
81
81
  "bugs": {
82
- "url": "https://github.com/jongleberry/api-server/issues"
82
+ "url": "https://github.com/jonathanong/api-server/issues"
83
83
  },
84
- "homepage": "https://github.com/jongleberry/api-server"
84
+ "homepage": "https://github.com/jonathanong/api-server"
85
85
  }