@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 +21 -0
- package/README.md +94 -0
- package/dist/application.d.mts +27 -0
- package/dist/application.mjs +134 -0
- package/dist/cache-control.d.mts +2 -0
- package/dist/cache-control.mjs +40 -0
- package/dist/compression.d.mts +9 -0
- package/dist/compression.mjs +49 -0
- package/dist/context.d.mts +32 -0
- package/dist/context.mjs +102 -0
- package/dist/cookies.d.mts +10 -0
- package/dist/cookies.mjs +30 -0
- package/dist/etag.d.mts +3 -0
- package/dist/etag.mjs +17 -0
- package/dist/fallback-response.d.mts +3 -0
- package/dist/fallback-response.mjs +18 -0
- package/dist/index.d.mts +14 -0
- package/dist/index.mjs +10 -0
- package/dist/logger.d.mts +32 -0
- package/dist/logger.mjs +111 -0
- package/dist/request.d.mts +10 -0
- package/dist/request.mjs +74 -0
- package/dist/response.d.mts +21 -0
- package/dist/response.mjs +124 -0
- package/dist/router.d.mts +13 -0
- package/dist/router.mjs +33 -0
- package/dist/server-timing.d.mts +7 -0
- package/dist/server-timing.mjs +17 -0
- package/dist/trusted-client-ip.d.mts +20 -0
- package/dist/trusted-client-ip.mjs +57 -0
- package/dist/types.d.mts +13 -0
- package/dist/types.mjs +1 -0
- package/package.json +85 -0
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,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;
|
package/dist/context.mjs
ADDED
|
@@ -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
|
+
}
|
package/dist/cookies.mjs
ADDED
|
@@ -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
|
+
}
|
package/dist/etag.d.mts
ADDED
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,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
|
+
}
|
package/dist/index.d.mts
ADDED
|
@@ -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 {};
|
package/dist/logger.mjs
ADDED
|
@@ -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
|
+
}
|
package/dist/request.mjs
ADDED
|
@@ -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 {};
|
package/dist/router.mjs
ADDED
|
@@ -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,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
|
+
}
|
package/dist/types.d.mts
ADDED
|
@@ -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
|
+
}
|