@jongleberry/api-server 1.0.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/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Jonathan Ong
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,94 @@
1
+ # @jongleberry/api-server
2
+
3
+ A lightweight Node.js HTTP server library built on a trie router with automatic compression, ETag caching, streaming, and a dev-friendly request logger.
4
+
5
+ ## Install
6
+
7
+ ```sh
8
+ npm install @jongleberry/api-server
9
+ ```
10
+
11
+ Requires Node.js ≥ 24.
12
+
13
+ ## Quick start
14
+
15
+ ```ts
16
+ import http from "node:http";
17
+ import { Application } from "@jongleberry/api-server";
18
+
19
+ const app = new Application();
20
+
21
+ app.route("/hello").get((ctx) => {
22
+ ctx.json({ hello: "world" });
23
+ });
24
+
25
+ app.route("/users/:id").get((ctx) => {
26
+ ctx.json({ id: ctx.params.id });
27
+ });
28
+
29
+ http.createServer(app.callback()).listen(3000);
30
+ ```
31
+
32
+ Or use the factory shorthand:
33
+
34
+ ```ts
35
+ import { createApp } from "@jongleberry/api-server";
36
+
37
+ const app = createApp();
38
+ app.route("/").get((ctx) => ctx.json({ ok: true }));
39
+ ```
40
+
41
+ ## Features
42
+
43
+ - **Trie router** — `find-my-way` under the hood; zero regex overhead on the hot path
44
+ - **Buffered responses** — `ctx.json()`, `ctx.response.text()`, `.html()`, `.xml()`, `.buffer()`
45
+ - **Streaming** — `ctx.pipeline(readable, ...transforms)` with back-pressure and error propagation
46
+ - **Automatic ETag** — SHA-256 ETag on every buffered 2xx; `If-None-Match` → 304
47
+ - **Compression** — `br` / `gzip` / `deflate` negotiation; 1 KB threshold; `SYNC_FLUSH` for streams
48
+ - **Server-Timing** — response latency as a `Server-Timing` header (buffered) or trailer (streaming)
49
+ - **Abort signals** — `ctx.signal` / `ctx.abortController` wired to client disconnect
50
+ - **AsyncLocalStorage** — per-request store via `app.setAsyncLocalStorage(als)`
51
+ - **Cookies** — `ctx.cookies.get()` / `.set()` with full `Set-Cookie` options
52
+ - **Cache-Control** — `ctx.cacheControl(visibility, maxAge)` helper
53
+ - **Trusted client IP** — Cloudflare/AWS ALB-aware helper for Node, Deno, and Bun
54
+ - **Dev logger** — concurrent-request bar, color-coded status codes, timing thresholds; silent in `NODE_ENV=production` and `NODE_ENV=test`
55
+ - **Error safety net** — error handlers that throw or return without a response still guarantee the client receives a response
56
+
57
+ ## Requirements
58
+
59
+ - Node.js ≥ 24.0.0
60
+ - ESM (`"type": "module"` or `.mjs` imports)
61
+
62
+ ## Documentation
63
+
64
+ See [docs/](docs/README.md) for full API reference:
65
+
66
+ | Topic | Description |
67
+ | -------------------------------------------------- | --------------------------------------------------- |
68
+ | [Getting started](docs/getting-started.md) | Install, hello world, mounting on http.createServer |
69
+ | [Routing](docs/routing.md) | Route registration, params, notFoundHandler |
70
+ | [Context](docs/context.md) | Full `ctx` API surface |
71
+ | [Request](docs/request.md) | Body parsing, size limits, content-type detection |
72
+ | [Response](docs/response.md) | Buffered and streaming responses |
73
+ | [ETag and caching](docs/etag-and-caching.md) | Automatic ETags, 304s, Cache-Control |
74
+ | [Compression](docs/compression.md) | br/gzip/deflate negotiation |
75
+ | [Server-Timing](docs/server-timing.md) | Response latency headers and trailers |
76
+ | [Cookies](docs/cookies.md) | Reading and writing cookies |
77
+ | [Error handling](docs/error-handling.md) | errorHandler, notFoundHandler, http-errors |
78
+ | [Async local storage](docs/async-local-storage.md) | Per-request store |
79
+ | [Abort signals](docs/abort-signals.md) | Client-disconnect propagation |
80
+ | [Logger](docs/logger.md) | Dev logger configuration |
81
+ | [Trusted client IP](docs/trusted-client-ip.md) | Node, Deno, and Bun client IP helpers |
82
+ | [Extending context](docs/extending-context.md) | Adding methods to ctx |
83
+ | [Testing](docs/testing.md) | Testing patterns with vitest and supertest |
84
+
85
+ ## Design
86
+
87
+ - No middleware stack. Routes are registered directly on the application; request processing runs top-to-bottom in a single async function per request.
88
+ - 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
+ - Responses are explicit. You choose buffered or streaming; the library doesn't buffer a stream or stream a buffer behind your back.
91
+
92
+ ## License
93
+
94
+ MIT © Jonathan Ong 2026
@@ -0,0 +1,27 @@
1
+ import { EventEmitter } from "node:events";
2
+ import type { RequestListener } from "node:http";
3
+ import type { AsyncLocalStorage } from "node:async_hooks";
4
+ import { Context } from "./context.mts";
5
+ import { type RouteBuilder } from "./router.mts";
6
+ import type { ApplicationOptions } from "./types.mts";
7
+ export type ErrorHandler = (ctx: Context, error: Error) => Promise<void> | void;
8
+ export type NotFoundHandler = (ctx: Context) => Promise<void> | void;
9
+ export declare class Application extends EventEmitter {
10
+ private router;
11
+ private errorHandlerFn;
12
+ private notFoundHandlerFn;
13
+ private asyncLocalStorage;
14
+ private extensions;
15
+ private contextClass;
16
+ private logger;
17
+ constructor(options?: ApplicationOptions);
18
+ route(path: string): RouteBuilder;
19
+ errorHandler(fn: ErrorHandler): void;
20
+ notFoundHandler(fn: NotFoundHandler): void;
21
+ setAsyncLocalStorage(als: AsyncLocalStorage<unknown>): void;
22
+ extend(methods: Record<string, unknown>): void;
23
+ callback(): RequestListener;
24
+ private handleRequest;
25
+ private runRequest;
26
+ }
27
+ export declare const createApp: (options?: ApplicationOptions) => Application;
@@ -0,0 +1,134 @@
1
+ import { EventEmitter } from "node:events";
2
+ import { Context, createContextClass } from "./context.mjs";
3
+ import { createRouteBuilder } from "./router.mjs";
4
+ import Router from "find-my-way";
5
+ import { ServerTiming } from "./server-timing.mjs";
6
+ import { Logger } from "./logger.mjs";
7
+ import { getFallbackStatus, sendFallback } from "./fallback-response.mjs";
8
+ export class Application extends EventEmitter {
9
+ router = Router();
10
+ errorHandlerFn = null;
11
+ notFoundHandlerFn = null;
12
+ asyncLocalStorage = null;
13
+ extensions = {};
14
+ contextClass = Context;
15
+ logger;
16
+ constructor(options) {
17
+ super();
18
+ this.logger = new Logger(options?.logger);
19
+ }
20
+ route(path) {
21
+ return createRouteBuilder(this.router, path);
22
+ }
23
+ errorHandler(fn) {
24
+ this.errorHandlerFn = fn;
25
+ }
26
+ notFoundHandler(fn) {
27
+ this.notFoundHandlerFn = fn;
28
+ }
29
+ setAsyncLocalStorage(als) {
30
+ this.asyncLocalStorage = als;
31
+ }
32
+ extend(methods) {
33
+ Object.assign(this.extensions, methods);
34
+ this.contextClass = createContextClass(this.extensions);
35
+ }
36
+ callback() {
37
+ return (req, res) => {
38
+ this.handleRequest(req, res);
39
+ };
40
+ }
41
+ handleRequest(req, res) {
42
+ const run = () => this.runRequest(req, res).catch((err) => {
43
+ // Safety net: if runRequest rejects before its own try-catch (e.g. during
44
+ // context/timing setup), ensure the client always gets a response instead
45
+ // of a socket hang-up from an unhandled promise rejection.
46
+ const error = err instanceof Error ? err : new Error(String(err));
47
+ try {
48
+ if (this.listenerCount("error") > 0) {
49
+ this.emit("error", error);
50
+ }
51
+ }
52
+ catch {
53
+ // Swallow listener throws so the 500 response still goes out.
54
+ }
55
+ if (!res.headersSent) {
56
+ res.writeHead(500);
57
+ res.end("Internal Server Error");
58
+ }
59
+ });
60
+ if (this.asyncLocalStorage) {
61
+ this.asyncLocalStorage.run({}, run);
62
+ }
63
+ else {
64
+ run();
65
+ }
66
+ }
67
+ async runRequest(req, res) {
68
+ const abortController = new AbortController();
69
+ const timing = new ServerTiming();
70
+ const ContextClass = this.contextClass;
71
+ req.on("close", () => {
72
+ if (!res.writableEnded) {
73
+ abortController.abort();
74
+ }
75
+ });
76
+ const { onWriteHead, onFinish } = this.logger.onRequestStart(req);
77
+ const ctx = new ContextClass(req, res, {}, timing, this.asyncLocalStorage, abortController, onWriteHead);
78
+ // Security headers
79
+ res.setHeader("X-XSS-Protection", "0");
80
+ res.setHeader("X-Frame-Options", "SAMEORIGIN");
81
+ res.setHeader("X-Content-Type-Options", "nosniff");
82
+ try {
83
+ const method = req.method ?? "GET";
84
+ const url = req.url ?? "/";
85
+ const rawPath = url.startsWith("http://") || url.startsWith("https://")
86
+ ? new URL(url).pathname
87
+ : url.split("?")[0];
88
+ const routePath = rawPath.replace(/^\/+/, "/") || "/";
89
+ const found = this.router.find(method, routePath);
90
+ if (found) {
91
+ ctx.params = found.params;
92
+ await found.handler(req, res, found.params, ctx, found.searchParams);
93
+ }
94
+ if (!ctx.response.sent) {
95
+ if (this.notFoundHandlerFn) {
96
+ await this.notFoundHandlerFn(ctx);
97
+ }
98
+ else {
99
+ res.writeHead(404);
100
+ res.end("Not Found");
101
+ }
102
+ }
103
+ onFinish(res.statusCode);
104
+ }
105
+ catch (err) {
106
+ const error = err instanceof Error ? err : new Error(String(err));
107
+ if (this.listenerCount("error") > 0) {
108
+ this.emit("error", error);
109
+ }
110
+ if (this.errorHandlerFn) {
111
+ try {
112
+ await this.errorHandlerFn(ctx, error);
113
+ }
114
+ catch (handlerErr) {
115
+ if (this.listenerCount("error") > 0) {
116
+ this.emit("error", handlerErr);
117
+ }
118
+ }
119
+ // Safety net: ensure the client always receives a response, even if the
120
+ // registered error handler threw or returned without sending one. Without
121
+ // this, requests hang until the socket times out (issue #1948).
122
+ if (!res.headersSent) {
123
+ sendFallback(res);
124
+ }
125
+ }
126
+ else if (!res.headersSent) {
127
+ res.writeHead(getFallbackStatus(error));
128
+ res.end(error.message);
129
+ }
130
+ onFinish(res.statusCode);
131
+ }
132
+ }
133
+ }
134
+ export const createApp = (options) => new Application(options);
@@ -0,0 +1,2 @@
1
+ import type { ServerResponse } from "node:http";
2
+ export declare function applyCacheControl(res: ServerResponse, type: "public" | "private", ttl?: number | string): void;
@@ -0,0 +1,40 @@
1
+ const TIME_UNITS = {
2
+ second: 1,
3
+ seconds: 1,
4
+ minute: 60,
5
+ minutes: 60,
6
+ hour: 3600,
7
+ hours: 3600,
8
+ day: 86400,
9
+ days: 86400,
10
+ week: 604800,
11
+ weeks: 604800,
12
+ month: 2592000,
13
+ months: 2592000,
14
+ year: 31536000,
15
+ years: 31536000,
16
+ };
17
+ function parseTtl(ttl) {
18
+ if (typeof ttl === "number")
19
+ return ttl;
20
+ const match = /^(\d+)\s+(\w+)$/.exec(ttl.trim());
21
+ if (!match)
22
+ throw new Error(`Invalid TTL format: "${ttl}"`);
23
+ const amount = Number.parseInt(match[1], 10);
24
+ const unit = match[2].toLowerCase();
25
+ const multiplier = TIME_UNITS[unit];
26
+ if (multiplier === undefined)
27
+ throw new Error(`Unknown TTL unit: "${match[2]}"`);
28
+ return amount * multiplier;
29
+ }
30
+ export function applyCacheControl(res, type, ttl) {
31
+ if (type === "public") {
32
+ if (ttl === undefined)
33
+ throw new Error("TTL is required for public cache control");
34
+ const maxAge = parseTtl(ttl);
35
+ res.setHeader("Cache-Control", `public, max-age=${maxAge}`);
36
+ }
37
+ else {
38
+ res.setHeader("Cache-Control", "private, no-cache, no-store, must-revalidate");
39
+ }
40
+ }
@@ -0,0 +1,9 @@
1
+ import zlib from "node:zlib";
2
+ import type { IncomingMessage } from "node:http";
3
+ export declare function negotiateEncoding(req: IncomingMessage): string | null;
4
+ export declare function isCompressible(contentType: string): boolean;
5
+ export declare function createCompressStream(encoding: string): zlib.BrotliCompress | zlib.Gzip | zlib.Deflate;
6
+ export declare function compressSync(encoding: string, buffer: Buffer): Buffer;
7
+ export declare function shouldCompress(req: IncomingMessage, res: {
8
+ getHeader(name: string): string | number | string[] | undefined;
9
+ }, contentType: string, bodyLength: number): string | null;
@@ -0,0 +1,49 @@
1
+ import zlib from "node:zlib";
2
+ // @ts-ignore
3
+ import Negotiator from "negotiator";
4
+ // @ts-ignore
5
+ import compressibleFn from "compressible";
6
+ const SUPPORTED_ENCODINGS = ["br", "gzip", "deflate"];
7
+ const COMPRESSION_THRESHOLD = 1024;
8
+ export function negotiateEncoding(req) {
9
+ const negotiator = new Negotiator(req);
10
+ const encoding = negotiator.encoding(SUPPORTED_ENCODINGS);
11
+ if (!encoding || encoding === "identity")
12
+ return null;
13
+ return encoding;
14
+ }
15
+ export function isCompressible(contentType) {
16
+ return compressibleFn(contentType) === true;
17
+ }
18
+ export function createCompressStream(encoding) {
19
+ if (encoding === "br") {
20
+ return zlib.createBrotliCompress({
21
+ flush: zlib.constants.BROTLI_OPERATION_FLUSH,
22
+ });
23
+ }
24
+ if (encoding === "gzip") {
25
+ return zlib.createGzip({ flush: zlib.constants.Z_SYNC_FLUSH });
26
+ }
27
+ return zlib.createDeflate({ flush: zlib.constants.Z_SYNC_FLUSH });
28
+ }
29
+ export function compressSync(encoding, buffer) {
30
+ if (encoding === "br") {
31
+ return zlib.brotliCompressSync(buffer);
32
+ }
33
+ if (encoding === "gzip") {
34
+ return zlib.gzipSync(buffer);
35
+ }
36
+ return zlib.deflateSync(buffer);
37
+ }
38
+ export function shouldCompress(req, res, contentType, bodyLength) {
39
+ if (bodyLength < COMPRESSION_THRESHOLD)
40
+ return null;
41
+ if (res.getHeader("Content-Encoding"))
42
+ return null;
43
+ const cacheControl = req.headers["cache-control"];
44
+ if (cacheControl && cacheControl.includes("no-transform"))
45
+ return null;
46
+ if (!isCompressible(contentType))
47
+ return null;
48
+ return negotiateEncoding(req);
49
+ }
@@ -0,0 +1,32 @@
1
+ import type { IncomingMessage, ServerResponse } from "node:http";
2
+ import type { AsyncLocalStorage } from "node:async_hooks";
3
+ import httpAssert from "http-assert";
4
+ import { Request } from "./request.mts";
5
+ import { Response } from "./response.mts";
6
+ import { Cookies } from "./cookies.mts";
7
+ import type { ServerTiming } from "./server-timing.mts";
8
+ export declare class Context {
9
+ req: IncomingMessage;
10
+ res: ServerResponse;
11
+ params: Record<string, string | undefined>;
12
+ request: Request;
13
+ response: Response;
14
+ cookies: Cookies;
15
+ signal: AbortSignal;
16
+ abortController: AbortController;
17
+ assert: typeof httpAssert;
18
+ private queryCache;
19
+ private asyncLocalStorage;
20
+ constructor(req: IncomingMessage, res: ServerResponse, params: Record<string, string | undefined>, timing: ServerTiming, als: AsyncLocalStorage<unknown> | null, abortController: AbortController, onWriteHead?: () => void);
21
+ get query(): Record<string, string | string[]>;
22
+ get store(): unknown;
23
+ get ip(): string | undefined;
24
+ set(header: string, value: string): void;
25
+ setType(type: string): void;
26
+ setStatus(code: number): void;
27
+ throw(status: number, message?: string, code?: string): never;
28
+ json(data: unknown): void;
29
+ pipeline(source: NodeJS.ReadableStream, ...transforms: NodeJS.ReadWriteStream[]): Promise<void>;
30
+ cacheControl(type: "public" | "private", ttl?: number | string): void;
31
+ }
32
+ export declare function createContextClass(extensions: Record<string, unknown>): typeof Context;
@@ -0,0 +1,102 @@
1
+ import httpAssert from "http-assert";
2
+ import createHttpError from "http-errors";
3
+ import { Request } from "./request.mjs";
4
+ import { Response } from "./response.mjs";
5
+ import { Cookies } from "./cookies.mjs";
6
+ import { applyCacheControl } from "./cache-control.mjs";
7
+ import { resolveTrustedClientIp } from "./trusted-client-ip.mjs";
8
+ const CONTENT_TYPES = {
9
+ json: "application/json; charset=utf-8",
10
+ html: "text/html; charset=utf-8",
11
+ text: "text/plain; charset=utf-8",
12
+ xml: "application/xml; charset=utf-8",
13
+ bin: "application/octet-stream",
14
+ form: "application/x-www-form-urlencoded",
15
+ };
16
+ export class Context {
17
+ req;
18
+ res;
19
+ params;
20
+ request;
21
+ response;
22
+ cookies;
23
+ signal;
24
+ abortController;
25
+ assert;
26
+ queryCache = null;
27
+ asyncLocalStorage;
28
+ constructor(req, res, params, timing, als, abortController, onWriteHead) {
29
+ this.req = req;
30
+ this.res = res;
31
+ this.params = params;
32
+ this.request = new Request(req, res);
33
+ this.response = new Response(req, res, timing, onWriteHead);
34
+ this.cookies = new Cookies(req, res);
35
+ this.abortController = abortController;
36
+ this.signal = abortController.signal;
37
+ this.assert = httpAssert;
38
+ this.asyncLocalStorage = als;
39
+ }
40
+ get query() {
41
+ if (this.queryCache)
42
+ return this.queryCache;
43
+ const url = this.req.url ?? "";
44
+ const questionMark = url.indexOf("?");
45
+ if (questionMark === -1) {
46
+ this.queryCache = {};
47
+ return this.queryCache;
48
+ }
49
+ const queryString = url.slice(questionMark + 1);
50
+ 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;
55
+ }
56
+ this.queryCache = result;
57
+ return this.queryCache;
58
+ }
59
+ get store() {
60
+ return this.asyncLocalStorage?.getStore();
61
+ }
62
+ get ip() {
63
+ return resolveTrustedClientIp({
64
+ headers: this.req.headers,
65
+ socketRemoteAddress: this.req.socket?.remoteAddress,
66
+ });
67
+ }
68
+ set(header, value) {
69
+ this.res.setHeader(header, value);
70
+ }
71
+ setType(type) {
72
+ this.res.setHeader("Content-Type", CONTENT_TYPES[type] ?? type);
73
+ }
74
+ setStatus(code) {
75
+ this.response.setStatus(code);
76
+ if (code === 204 || code === 205) {
77
+ this.response.empty();
78
+ }
79
+ }
80
+ throw(status, message, code) {
81
+ const err = message !== undefined ? createHttpError(status, message) : createHttpError(status);
82
+ if (code !== undefined) {
83
+ Object.assign(err, { code });
84
+ }
85
+ throw err;
86
+ }
87
+ json(data) {
88
+ this.response.json(data);
89
+ }
90
+ pipeline(source, ...transforms) {
91
+ return this.response.pipeline(source, ...transforms);
92
+ }
93
+ cacheControl(type, ttl) {
94
+ applyCacheControl(this.res, type, ttl);
95
+ }
96
+ }
97
+ export function createContextClass(extensions) {
98
+ class ExtendedContext extends Context {
99
+ }
100
+ Object.assign(ExtendedContext.prototype, extensions);
101
+ return ExtendedContext;
102
+ }
@@ -0,0 +1,10 @@
1
+ import type { IncomingMessage, ServerResponse } from "node:http";
2
+ import type { CookieOptions } from "./types.mts";
3
+ export declare class Cookies {
4
+ private req;
5
+ private res;
6
+ private parsed;
7
+ constructor(req: IncomingMessage, res: ServerResponse);
8
+ get(name: string): string | undefined;
9
+ set(name: string, value: string, opts?: CookieOptions): void;
10
+ }
@@ -0,0 +1,30 @@
1
+ import { parse, serialize } from "cookie";
2
+ export class Cookies {
3
+ req;
4
+ res;
5
+ parsed = null;
6
+ constructor(req, res) {
7
+ this.req = req;
8
+ this.res = res;
9
+ }
10
+ get(name) {
11
+ if (!this.parsed) {
12
+ const header = this.req.headers.cookie ?? "";
13
+ this.parsed = parse(header);
14
+ }
15
+ return this.parsed[name];
16
+ }
17
+ set(name, value, opts) {
18
+ const existing = this.res.getHeader("Set-Cookie");
19
+ const serialized = serialize(name, value, opts);
20
+ if (Array.isArray(existing)) {
21
+ this.res.setHeader("Set-Cookie", [...existing, serialized]);
22
+ }
23
+ else if (existing) {
24
+ this.res.setHeader("Set-Cookie", [String(existing), serialized]);
25
+ }
26
+ else {
27
+ this.res.setHeader("Set-Cookie", serialized);
28
+ }
29
+ }
30
+ }
@@ -0,0 +1,3 @@
1
+ import type { IncomingMessage } from "node:http";
2
+ export declare function generateETag(body: Buffer): string;
3
+ export declare function isFresh(req: IncomingMessage, etag: string): boolean;
package/dist/etag.mjs ADDED
@@ -0,0 +1,17 @@
1
+ import crypto from "node:crypto";
2
+ export function generateETag(body) {
3
+ const hash = crypto.createHash("sha256").update(body).digest("base64url");
4
+ return `"${hash}"`;
5
+ }
6
+ export function isFresh(req, etag) {
7
+ const method = req.method ?? "GET";
8
+ if (method !== "GET" && method !== "HEAD")
9
+ return false;
10
+ const ifNoneMatch = req.headers["if-none-match"];
11
+ if (!ifNoneMatch)
12
+ return false;
13
+ if (ifNoneMatch === "*")
14
+ return true;
15
+ const tags = ifNoneMatch.split(",").map((t) => t.trim());
16
+ return tags.includes(etag);
17
+ }
@@ -0,0 +1,3 @@
1
+ import type { ServerResponse } from "node:http";
2
+ export declare function sendFallback(res: ServerResponse): void;
3
+ export declare function getFallbackStatus(error: unknown): number;
@@ -0,0 +1,18 @@
1
+ const FALLBACK_STATUS = 404;
2
+ const FALLBACK_BODY = "Not Found";
3
+ export function sendFallback(res) {
4
+ try {
5
+ res.writeHead(FALLBACK_STATUS);
6
+ res.end(FALLBACK_BODY);
7
+ }
8
+ catch {
9
+ // Socket may already be destroyed; nothing more we can do.
10
+ }
11
+ }
12
+ export function getFallbackStatus(error) {
13
+ const status = error?.status;
14
+ if (typeof status === "number" && Number.isInteger(status) && status >= 100 && status < 600) {
15
+ return status;
16
+ }
17
+ return FALLBACK_STATUS;
18
+ }
@@ -0,0 +1,14 @@
1
+ export * from "./application.mts";
2
+ export type { ErrorHandler, NotFoundHandler } from "./application.mts";
3
+ export * from "./context.mts";
4
+ export * from "./request.mts";
5
+ export * from "./response.mts";
6
+ export * from "./cookies.mts";
7
+ export * from "./server-timing.mts";
8
+ export * from "./logger.mts";
9
+ export type { LoggerOptions } from "./logger.mts";
10
+ export * from "./etag.mts";
11
+ export * from "./cache-control.mts";
12
+ export * from "./compression.mts";
13
+ export type { Handler, RouteBuilder } from "./router.mts";
14
+ export type { CookieOptions, ApplicationOptions } from "./types.mts";
package/dist/index.mjs ADDED
@@ -0,0 +1,10 @@
1
+ export * from "./application.mjs";
2
+ export * from "./context.mjs";
3
+ export * from "./request.mjs";
4
+ export * from "./response.mjs";
5
+ export * from "./cookies.mjs";
6
+ export * from "./server-timing.mjs";
7
+ export * from "./logger.mjs";
8
+ export * from "./etag.mjs";
9
+ export * from "./cache-control.mjs";
10
+ export * from "./compression.mjs";
@@ -0,0 +1,32 @@
1
+ import type { IncomingMessage } from "node:http";
2
+ export interface LoggerOptions {
3
+ timingThresholds?: {
4
+ yellow: number;
5
+ orange: number;
6
+ red: number;
7
+ };
8
+ }
9
+ interface RequestLogger {
10
+ onWriteHead: () => void;
11
+ onFinish: (status: number) => void;
12
+ }
13
+ export declare class Logger {
14
+ private enabled;
15
+ private level;
16
+ private thresholds;
17
+ private activeSlots;
18
+ private highWaterMark;
19
+ constructor(options?: LoggerOptions);
20
+ isEnabled(): boolean;
21
+ onRequestStart(req: IncomingMessage): RequestLogger;
22
+ private createErrorLevelLogger;
23
+ private assignSlot;
24
+ private freeSlot;
25
+ private print;
26
+ private renderBar;
27
+ private elapsedMs;
28
+ private formatTiming;
29
+ private colorStatus;
30
+ private colorTiming;
31
+ }
32
+ export {};
@@ -0,0 +1,111 @@
1
+ const RESET = "\x1b[0m";
2
+ const GREEN = "\x1b[32m";
3
+ const YELLOW = "\x1b[33m";
4
+ const RED = "\x1b[31m";
5
+ const CYAN = "\x1b[36m";
6
+ const ORANGE = "\x1b[38;5;208m";
7
+ const DEFAULT_THRESHOLDS = { yellow: 50, orange: 250, red: 500 };
8
+ export class Logger {
9
+ enabled;
10
+ level;
11
+ thresholds;
12
+ activeSlots = new Map();
13
+ highWaterMark = 0;
14
+ constructor(options = {}) {
15
+ const env = process.env.NODE_ENV;
16
+ this.enabled = env !== "production" && env !== "test";
17
+ this.level = process.env.LOG_LEVEL === "info" ? "info" : "error";
18
+ this.thresholds = { ...DEFAULT_THRESHOLDS, ...options.timingThresholds };
19
+ }
20
+ isEnabled() {
21
+ return this.enabled;
22
+ }
23
+ onRequestStart(req) {
24
+ if (!this.enabled)
25
+ return { onWriteHead: noop, onFinish: noop };
26
+ if (this.level === "error") {
27
+ return this.createErrorLevelLogger(req);
28
+ }
29
+ const slot = this.assignSlot();
30
+ const method = req.method ?? "GET";
31
+ const url = req.url ?? "/";
32
+ const startTime = process.hrtime.bigint();
33
+ this.print("──‣", "┈┈┈┈┈", method, url, "┬", slot);
34
+ return {
35
+ onWriteHead: () => {
36
+ const ms = this.elapsedMs(startTime);
37
+ this.print("···", this.colorTiming(ms, this.formatTiming(ms)), method, url, "·", slot);
38
+ },
39
+ onFinish: (status) => {
40
+ const ms = this.elapsedMs(startTime);
41
+ this.freeSlot(slot);
42
+ this.print(this.colorStatus(status, String(status)), this.colorTiming(ms, this.formatTiming(ms)), method, url, "┴", slot);
43
+ },
44
+ };
45
+ }
46
+ createErrorLevelLogger(req) {
47
+ const method = req.method ?? "GET";
48
+ const url = req.url ?? "/";
49
+ const startTime = process.hrtime.bigint();
50
+ return {
51
+ onWriteHead: noop,
52
+ onFinish: (status) => {
53
+ if (status >= 500) {
54
+ const ms = this.elapsedMs(startTime);
55
+ process.stdout.write(`${RED}${status}${RESET} ${this.colorTiming(ms, this.formatTiming(ms))} ${method} ${url}\n`);
56
+ }
57
+ },
58
+ };
59
+ }
60
+ assignSlot() {
61
+ let slot = 0;
62
+ while (this.activeSlots.has(slot))
63
+ slot++;
64
+ this.activeSlots.set(slot, true);
65
+ if (slot + 1 > this.highWaterMark)
66
+ this.highWaterMark = slot + 1;
67
+ return slot;
68
+ }
69
+ freeSlot(slot) {
70
+ this.activeSlots.delete(slot);
71
+ while (this.highWaterMark > 0 && !this.activeSlots.has(this.highWaterMark - 1)) {
72
+ this.highWaterMark--;
73
+ }
74
+ }
75
+ print(statusStr, timingStr, method, url, slotChar, slotIndex) {
76
+ const bar = this.renderBar(slotChar, slotIndex);
77
+ process.stdout.write(`${statusStr} ${timingStr} ${method}┈ ${bar} ${url}\n`);
78
+ }
79
+ renderBar(eventChar, eventSlot) {
80
+ const parts = [];
81
+ for (let i = 0; i < this.highWaterMark; i++) {
82
+ parts.push(i === eventSlot ? eventChar : this.activeSlots.has(i) ? "│" : "┈");
83
+ }
84
+ return parts.join("┈");
85
+ }
86
+ elapsedMs(startTime) {
87
+ return Number(process.hrtime.bigint() - startTime) / 1_000_000;
88
+ }
89
+ formatTiming(ms) {
90
+ return `${Math.round(ms)}ms`.padStart(5, "┈");
91
+ }
92
+ colorStatus(status, str) {
93
+ if (status >= 500)
94
+ return `${RED}${str}${RESET}`;
95
+ if (status >= 400)
96
+ return `${ORANGE}${str}${RESET}`;
97
+ if (status >= 300)
98
+ return `${CYAN}${str}${RESET}`;
99
+ return `${GREEN}${str}${RESET}`;
100
+ }
101
+ colorTiming(ms, str) {
102
+ if (ms >= this.thresholds.red)
103
+ return `${RED}${str}${RESET}`;
104
+ if (ms >= this.thresholds.orange)
105
+ return `${ORANGE}${str}${RESET}`;
106
+ if (ms >= this.thresholds.yellow)
107
+ return `${YELLOW}${str}${RESET}`;
108
+ return str;
109
+ }
110
+ }
111
+ function noop() { }
@@ -0,0 +1,10 @@
1
+ import type { IncomingMessage, ServerResponse } from "node:http";
2
+ export declare class Request {
3
+ private req;
4
+ private res;
5
+ private bodyPromise;
6
+ constructor(req: IncomingMessage, res: ServerResponse);
7
+ is(type: string | string[]): string | false | null;
8
+ buffer(limit?: string | number): Promise<Buffer>;
9
+ json<T = unknown>(limit?: string | number): Promise<T>;
10
+ }
@@ -0,0 +1,74 @@
1
+ // @ts-ignore
2
+ import typeIs from "type-is";
3
+ // @ts-ignore
4
+ import bytes from "bytes";
5
+ function noop() { }
6
+ export class Request {
7
+ req;
8
+ res;
9
+ bodyPromise = null;
10
+ constructor(req, res) {
11
+ this.req = req;
12
+ this.res = res;
13
+ }
14
+ is(type) {
15
+ return typeIs(this.req, Array.isArray(type) ? type : [type]);
16
+ }
17
+ buffer(limit) {
18
+ if (!this.bodyPromise) {
19
+ if (this.req.headers.expect === "100-continue") {
20
+ this.res.writeContinue();
21
+ }
22
+ this.bodyPromise = readBody(this.req, limit);
23
+ }
24
+ return this.bodyPromise;
25
+ }
26
+ async json(limit) {
27
+ const buf = await this.buffer(limit);
28
+ try {
29
+ return JSON.parse(buf.toString("utf8"));
30
+ }
31
+ catch {
32
+ throw Object.assign(new Error("Invalid JSON"), { status: 400 });
33
+ }
34
+ }
35
+ }
36
+ function readBody(req, limit) {
37
+ return new Promise((resolve, reject) => {
38
+ const maxBytes = limit !== undefined ? (bytes.parse(limit) ?? Infinity) : Infinity;
39
+ const chunks = [];
40
+ let totalLength = 0;
41
+ function cleanup() {
42
+ req.removeListener("data", onData);
43
+ req.removeListener("end", onEnd);
44
+ req.removeListener("error", onError);
45
+ }
46
+ function onData(chunk) {
47
+ totalLength += chunk.length;
48
+ if (totalLength > maxBytes) {
49
+ // Remove data/end listeners but keep a no-op error handler during drain
50
+ // to prevent unhandled 'error' events from crashing the process.
51
+ req.removeListener("data", onData);
52
+ req.removeListener("end", onEnd);
53
+ req.removeListener("error", onError);
54
+ req.on("error", noop);
55
+ reject(Object.assign(new Error("Request entity too large"), { status: 413 }));
56
+ // Drain remaining data so the connection stays reusable (HTTP keep-alive)
57
+ req.resume();
58
+ return;
59
+ }
60
+ chunks.push(chunk);
61
+ }
62
+ function onEnd() {
63
+ cleanup();
64
+ resolve(Buffer.concat(chunks));
65
+ }
66
+ function onError(err) {
67
+ cleanup();
68
+ reject(err);
69
+ }
70
+ req.on("data", onData);
71
+ req.on("end", onEnd);
72
+ req.on("error", onError);
73
+ });
74
+ }
@@ -0,0 +1,21 @@
1
+ import type { IncomingMessage, ServerResponse } from "node:http";
2
+ import type { ServerTiming } from "./server-timing.mts";
3
+ export declare class Response {
4
+ private req;
5
+ private res;
6
+ private timing;
7
+ private responseSent;
8
+ private statusCode;
9
+ private onWriteHeadCallback;
10
+ constructor(req: IncomingMessage, res: ServerResponse, timing: ServerTiming, onWriteHead?: () => void);
11
+ setStatus(code: number): void;
12
+ get sent(): boolean;
13
+ json(data: unknown): void;
14
+ text(data: string): void;
15
+ html(data: string): void;
16
+ xml(data: string): void;
17
+ buffer(data: Buffer, contentType: string): void;
18
+ pipeline(source: NodeJS.ReadableStream, ...transforms: NodeJS.ReadWriteStream[]): Promise<void>;
19
+ empty(): void;
20
+ private sendBuffered;
21
+ }
@@ -0,0 +1,124 @@
1
+ import { pipeline } from "node:stream/promises";
2
+ import { generateETag, isFresh } from "./etag.mjs";
3
+ import { shouldCompress, createCompressStream, compressSync } from "./compression.mjs";
4
+ export class Response {
5
+ req;
6
+ res;
7
+ timing;
8
+ responseSent = false;
9
+ statusCode = 200;
10
+ // Only called for non-HEAD streaming responses (pipeline), not buffered or HEAD
11
+ onWriteHeadCallback = null;
12
+ constructor(req, res, timing, onWriteHead) {
13
+ this.req = req;
14
+ this.res = res;
15
+ this.timing = timing;
16
+ this.onWriteHeadCallback = onWriteHead ?? null;
17
+ }
18
+ setStatus(code) {
19
+ this.statusCode = code;
20
+ }
21
+ get sent() {
22
+ return this.responseSent;
23
+ }
24
+ json(data) {
25
+ const body = Buffer.from(JSON.stringify(data), "utf8");
26
+ this.sendBuffered(body, "application/json; charset=utf-8");
27
+ }
28
+ text(data) {
29
+ const body = Buffer.from(data, "utf8");
30
+ this.sendBuffered(body, "text/plain; charset=utf-8");
31
+ }
32
+ html(data) {
33
+ const body = Buffer.from(data, "utf8");
34
+ this.sendBuffered(body, "text/html; charset=utf-8");
35
+ }
36
+ xml(data) {
37
+ const body = Buffer.from(data, "utf8");
38
+ this.sendBuffered(body, "application/xml; charset=utf-8");
39
+ }
40
+ buffer(data, contentType) {
41
+ this.sendBuffered(data, contentType);
42
+ }
43
+ async pipeline(source, ...transforms) {
44
+ if (this.responseSent)
45
+ throw new Error("Response already sent");
46
+ this.responseSent = true;
47
+ const contentType = this.res.getHeader("Content-Type") ?? "application/octet-stream";
48
+ const encoding = shouldCompress(this.req, this.res, contentType, Infinity);
49
+ if (encoding) {
50
+ this.res.setHeader("Content-Encoding", encoding);
51
+ this.res.setHeader("Vary", "Accept-Encoding");
52
+ }
53
+ // HEAD requests: send headers only, no body
54
+ if (this.req.method === "HEAD") {
55
+ this.timing.markResponseStarted();
56
+ const finishedAt = process.hrtime.bigint();
57
+ this.res.setHeader("Server-Timing", this.timing.getBufferedHeaderValue(finishedAt));
58
+ this.res.writeHead(this.statusCode);
59
+ this.res.end();
60
+ return;
61
+ }
62
+ this.res.setHeader("Trailer", "Server-Timing");
63
+ this.timing.markResponseStarted();
64
+ this.onWriteHeadCallback?.();
65
+ this.res.writeHead(this.statusCode);
66
+ const compressStream = encoding ? createCompressStream(encoding) : null;
67
+ // Build pipeline stages
68
+ const stages = [...transforms];
69
+ if (compressStream)
70
+ stages.push(compressStream);
71
+ if (stages.length > 0) {
72
+ await pipeline(source, ...stages, this.res);
73
+ }
74
+ else {
75
+ await pipeline(source, this.res);
76
+ }
77
+ const finishedAt = process.hrtime.bigint();
78
+ this.res.addTrailers({ "Server-Timing": this.timing.getBufferedHeaderValue(finishedAt) });
79
+ }
80
+ empty() {
81
+ if (this.responseSent)
82
+ throw new Error("Response already sent");
83
+ this.responseSent = true;
84
+ this.timing.markResponseStarted();
85
+ const finishedAt = process.hrtime.bigint();
86
+ this.res.setHeader("Server-Timing", this.timing.getBufferedHeaderValue(finishedAt));
87
+ this.res.writeHead(this.statusCode);
88
+ this.res.end();
89
+ }
90
+ sendBuffered(body, contentType) {
91
+ if (this.responseSent)
92
+ throw new Error("Response already sent");
93
+ this.responseSent = true;
94
+ const etag = generateETag(body);
95
+ if (this.statusCode >= 200 && this.statusCode < 300 && isFresh(this.req, etag)) {
96
+ this.timing.markResponseStarted();
97
+ this.res.writeHead(304);
98
+ this.res.end();
99
+ return;
100
+ }
101
+ const encoding = shouldCompress(this.req, this.res, contentType, body.length);
102
+ let finalBody = body;
103
+ if (encoding) {
104
+ finalBody = compressSync(encoding, body);
105
+ this.res.setHeader("Content-Encoding", encoding);
106
+ this.res.setHeader("Vary", "Accept-Encoding");
107
+ }
108
+ this.res.setHeader("Content-Type", contentType);
109
+ this.res.setHeader("Content-Length", finalBody.length);
110
+ if (this.statusCode >= 200 && this.statusCode < 300) {
111
+ this.res.setHeader("ETag", etag);
112
+ }
113
+ this.timing.markResponseStarted();
114
+ const finishedAt = process.hrtime.bigint();
115
+ this.res.setHeader("Server-Timing", this.timing.getBufferedHeaderValue(finishedAt));
116
+ this.res.writeHead(this.statusCode);
117
+ if (this.req.method === "HEAD") {
118
+ this.res.end();
119
+ }
120
+ else {
121
+ this.res.end(finalBody);
122
+ }
123
+ }
124
+ }
@@ -0,0 +1,13 @@
1
+ import Router from "find-my-way";
2
+ import type { Context } from "./context.mts";
3
+ type RouterInstance = Router.Instance<Router.HTTPVersion.V1>;
4
+ export type Handler = (ctx: Context) => Promise<void> | void;
5
+ export interface RouteBuilder {
6
+ get(handler: Handler): RouteBuilder;
7
+ post(handler: Handler): RouteBuilder;
8
+ put(handler: Handler): RouteBuilder;
9
+ delete(handler: Handler): RouteBuilder;
10
+ patch(handler: Handler): RouteBuilder;
11
+ }
12
+ export declare function createRouteBuilder(router: RouterInstance, path: string): RouteBuilder;
13
+ export {};
@@ -0,0 +1,33 @@
1
+ import Router from "find-my-way";
2
+ export function createRouteBuilder(router, path) {
3
+ const builder = {
4
+ get(handler) {
5
+ router.on("GET", path, wrapHandler(handler));
6
+ router.on("HEAD", path, wrapHandler(handler));
7
+ return builder;
8
+ },
9
+ post(handler) {
10
+ router.on("POST", path, wrapHandler(handler));
11
+ return builder;
12
+ },
13
+ put(handler) {
14
+ router.on("PUT", path, wrapHandler(handler));
15
+ return builder;
16
+ },
17
+ delete(handler) {
18
+ router.on("DELETE", path, wrapHandler(handler));
19
+ return builder;
20
+ },
21
+ patch(handler) {
22
+ router.on("PATCH", path, wrapHandler(handler));
23
+ return builder;
24
+ },
25
+ };
26
+ return builder;
27
+ }
28
+ function wrapHandler(handler) {
29
+ return (_req, _res, _params, store) => {
30
+ const ctx = store;
31
+ return handler(ctx);
32
+ };
33
+ }
@@ -0,0 +1,7 @@
1
+ export declare class ServerTiming {
2
+ private requestStart;
3
+ private responseStartedAt;
4
+ constructor();
5
+ markResponseStarted(): void;
6
+ getBufferedHeaderValue(responseFinishedAt: bigint): string;
7
+ }
@@ -0,0 +1,17 @@
1
+ export class ServerTiming {
2
+ requestStart;
3
+ responseStartedAt = null;
4
+ constructor() {
5
+ this.requestStart = process.hrtime.bigint();
6
+ }
7
+ markResponseStarted() {
8
+ this.responseStartedAt = process.hrtime.bigint();
9
+ }
10
+ getBufferedHeaderValue(responseFinishedAt) {
11
+ const now = responseFinishedAt;
12
+ const responseStarted = this.responseStartedAt ?? now;
13
+ const timeToResponseStarted = Number(responseStarted - this.requestStart) / 1_000_000;
14
+ const timeToResponseFinished = Number(now - responseStarted) / 1_000_000;
15
+ return `responseStarted;dur=${timeToResponseStarted.toFixed(3)}, responseFinished;dur=${timeToResponseFinished.toFixed(3)}`;
16
+ }
17
+ }
@@ -0,0 +1,20 @@
1
+ export type HeaderRecord = Record<string, string | string[] | number | undefined>;
2
+ export type HeaderGetter = {
3
+ get(name: string): string | null;
4
+ };
5
+ export type HeaderSource = HeaderGetter | HeaderRecord;
6
+ export type RequestLike = {
7
+ headers: HeaderSource;
8
+ };
9
+ export type RemoteAddress = string | {
10
+ address?: string;
11
+ hostname?: string;
12
+ port?: number;
13
+ };
14
+ export interface TrustedClientIpOptions {
15
+ headers?: HeaderSource;
16
+ request?: RequestLike;
17
+ socketRemoteAddress?: string;
18
+ remoteAddress?: RemoteAddress;
19
+ }
20
+ export declare function resolveTrustedClientIp(options: TrustedClientIpOptions): string | undefined;
@@ -0,0 +1,57 @@
1
+ function isHeaderGetter(headers) {
2
+ return typeof headers.get === "function";
3
+ }
4
+ function firstHeaderValue(value) {
5
+ const first = Array.isArray(value) ? value[0] : value;
6
+ return first === undefined ? undefined : String(first);
7
+ }
8
+ function headerRecordValue(headers, name) {
9
+ const direct = firstHeaderValue(headers[name]);
10
+ if (direct !== undefined)
11
+ return direct;
12
+ for (const [key, value] of Object.entries(headers)) {
13
+ if (key.toLowerCase() === name)
14
+ return firstHeaderValue(value);
15
+ }
16
+ return undefined;
17
+ }
18
+ function headerValue(headers, name) {
19
+ if (!headers)
20
+ return undefined;
21
+ if (isHeaderGetter(headers))
22
+ return headers.get(name) ?? undefined;
23
+ return headerRecordValue(headers, name);
24
+ }
25
+ function withoutPort(value) {
26
+ if (value.startsWith("[")) {
27
+ const end = value.indexOf("]");
28
+ if (end > 0)
29
+ return value.slice(1, end);
30
+ }
31
+ const colon = value.lastIndexOf(":");
32
+ if (colon > -1 && value.indexOf(":") === colon && /^\d+$/.test(value.slice(colon + 1))) {
33
+ return value.slice(0, colon);
34
+ }
35
+ return value;
36
+ }
37
+ function normalizeIp(value) {
38
+ const trimmed = value?.trim();
39
+ if (!trimmed)
40
+ return undefined;
41
+ return withoutPort(trimmed);
42
+ }
43
+ function firstForwardedIp(value) {
44
+ return normalizeIp(value?.split(",")[0]);
45
+ }
46
+ function remoteAddressValue(value) {
47
+ if (typeof value === "string")
48
+ return normalizeIp(value);
49
+ return normalizeIp(value?.hostname ?? value?.address);
50
+ }
51
+ export function resolveTrustedClientIp(options) {
52
+ const headers = options.headers ?? options.request?.headers;
53
+ return (firstForwardedIp(headerValue(headers, "cf-connecting-ip")) ??
54
+ firstForwardedIp(headerValue(headers, "x-forwarded-for")) ??
55
+ remoteAddressValue(options.remoteAddress) ??
56
+ normalizeIp(options.socketRemoteAddress));
57
+ }
@@ -0,0 +1,13 @@
1
+ import type { LoggerOptions } from "./logger.mts";
2
+ export interface CookieOptions {
3
+ httpOnly?: boolean;
4
+ secure?: boolean;
5
+ sameSite?: "strict" | "lax" | "none" | true;
6
+ path?: string;
7
+ domain?: string;
8
+ expires?: Date;
9
+ maxAge?: number;
10
+ }
11
+ export interface ApplicationOptions {
12
+ logger?: LoggerOptions;
13
+ }
package/dist/types.mjs ADDED
@@ -0,0 +1 @@
1
+ export {};
package/package.json ADDED
@@ -0,0 +1,85 @@
1
+ {
2
+ "name": "@jongleberry/api-server",
3
+ "version": "1.0.0",
4
+ "description": "A Node.js HTTP server library",
5
+ "license": "MIT",
6
+ "author": "Jonathan Ong",
7
+ "type": "module",
8
+ "files": [
9
+ "dist",
10
+ "README.md",
11
+ "LICENSE"
12
+ ],
13
+ "exports": {
14
+ ".": {
15
+ "types": "./dist/index.d.mts",
16
+ "import": "./dist/index.mjs"
17
+ },
18
+ "./trusted-client-ip": {
19
+ "types": "./dist/trusted-client-ip.d.mts",
20
+ "import": "./dist/trusted-client-ip.mjs"
21
+ }
22
+ },
23
+ "engines": {
24
+ "node": ">=24.0.0"
25
+ },
26
+ "dependencies": {
27
+ "bytes": "^3.1.2",
28
+ "compressible": "^2.0.18",
29
+ "cookie": "^1.1.1",
30
+ "find-my-way": "^9.6.0",
31
+ "http-assert": "^1.5.0",
32
+ "http-errors": "^2.0.1",
33
+ "negotiator": "^1.0.0",
34
+ "type-is": "^2.1.0"
35
+ },
36
+ "devDependencies": {
37
+ "@types/bytes": "^3.1.5",
38
+ "@types/compressible": "^2.0.3",
39
+ "@types/http-assert": "^1.5.6",
40
+ "@types/http-errors": "^2.0.5",
41
+ "@types/negotiator": "^0.6.4",
42
+ "@types/node": "^25.8.0",
43
+ "@types/supertest": "^7.2.0",
44
+ "@types/type-is": "^1.6.7",
45
+ "@vitest/coverage-v8": "^4.1.6",
46
+ "husky": "^9.1.7",
47
+ "oxfmt": "^0.50.0",
48
+ "oxlint": "^1.65.0",
49
+ "supertest": "^7.2.2",
50
+ "typescript": "^6.0.3",
51
+ "vitest": "^4.1.6"
52
+ },
53
+ "scripts": {
54
+ "build": "node scripts/build.mjs",
55
+ "prepare": "node scripts/install-husky.mjs",
56
+ "prepublishOnly": "npm run typecheck && npm test && npm run build",
57
+ "typecheck": "tsc --noEmit",
58
+ "lint": "oxlint src/ runtime-tests/",
59
+ "format": "oxfmt src/ runtime-tests/ docs/ README.md",
60
+ "format:check": "oxfmt --check src/ runtime-tests/ docs/ README.md",
61
+ "test": "vitest run",
62
+ "test:coverage": "vitest run --coverage && node scripts/strip-lcov-branches.mjs",
63
+ "test:deno": "deno test --allow-net=127.0.0.1,localhost runtime-tests/trusted-client-ip.deno.test.mts",
64
+ "test:bun": "bun test runtime-tests/trusted-client-ip.bun.test.mts",
65
+ "test:runtimes": "npm run test:deno && npm run test:bun",
66
+ "test:watch": "vitest"
67
+ },
68
+ "keywords": [
69
+ "http",
70
+ "server",
71
+ "node",
72
+ "api",
73
+ "express",
74
+ "koa",
75
+ "router"
76
+ ],
77
+ "repository": {
78
+ "type": "git",
79
+ "url": "git+https://github.com/jongleberry/api-server.git"
80
+ },
81
+ "bugs": {
82
+ "url": "https://github.com/jongleberry/api-server/issues"
83
+ },
84
+ "homepage": "https://github.com/jongleberry/api-server"
85
+ }