@thi.ng/server 0.7.0 → 0.8.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/CHANGELOG.md CHANGED
@@ -1,6 +1,6 @@
1
1
  # Change Log
2
2
 
3
- - **Last updated**: 2025-03-09T19:21:53Z
3
+ - **Last updated**: 2025-03-13T14:21:38Z
4
4
  - **Generator**: [thi.ng/monopub](https://thi.ng/monopub)
5
5
 
6
6
  All notable changes to this project will be documented in this file.
@@ -11,6 +11,15 @@ See [Conventional Commits](https://conventionalcommits.org/) for commit guidelin
11
11
  **Note:** Unlisted _patch_ versions only involve non-code or otherwise excluded changes
12
12
  and/or version bumps of transitive dependencies.
13
13
 
14
+ ## [0.8.0](https://github.com/thi-ng/umbrella/tree/@thi.ng/server@0.8.0) (2025-03-13)
15
+
16
+ #### 🚀 Features
17
+
18
+ - add method adapter, update `RequestCtx` ([360abda](https://github.com/thi-ng/umbrella/commit/360abda))
19
+ - add `ServerOpts.method` to allow for method conversion
20
+ - add `rejectUserAgents()` interceptor ([de0c373](https://github.com/thi-ng/umbrella/commit/de0c373))
21
+ - add UA presets for AI bots & scrapers
22
+
14
23
  ## [0.7.0](https://github.com/thi-ng/umbrella/tree/@thi.ng/server@0.7.0) (2025-03-09)
15
24
 
16
25
  #### 🚀 Features
package/README.md CHANGED
@@ -80,6 +80,7 @@ for more details.
80
80
  - [`logResponse()`](https://docs.thi.ng/umbrella/server/functions/logResponse.html): Response logging
81
81
  - [`rateLimiter()`](https://docs.thi.ng/umbrella/server/functions/rateLimiter-1.html): Configurable rate limiting
82
82
  - [`referrerPolicy()`](https://docs.thi.ng/umbrella/server/functions/referrerPolicy-1.html): Policy header injection
83
+ - [`rejectUserAgents()`](https://docs.thi.ng/umbrella/server/functions/rejectUserAgents.html): Configurable UA blocking
83
84
  - [`sessionInterceptor()`](https://docs.thi.ng/umbrella/server/functions/sessionInterceptor-1.html): User defined in-memory sessions with TTL
84
85
  - [`strictTransportSecurity()`](https://docs.thi.ng/umbrella/server/functions/strictTransportSecurity.html): Policy header injection
85
86
 
@@ -149,7 +150,7 @@ For Node.js REPL:
149
150
  const ser = await import("@thi.ng/server");
150
151
  ```
151
152
 
152
- Package sizes (brotli'd, pre-treeshake): ESM: 5.15 KB
153
+ Package sizes (brotli'd, pre-treeshake): ESM: 6.15 KB
153
154
 
154
155
  ## Dependencies
155
156
 
@@ -203,6 +204,8 @@ const app = srv.server<AppCtx>({
203
204
  intercept: [
204
205
  // log all requests (using server's configured logger)
205
206
  srv.logRequest(),
207
+ // block known AI bots
208
+ srv.rejectUserAgents(srv.USER_AGENT_AI_BOTS),
206
209
  // lookup/create sessions (using above interceptor)
207
210
  session,
208
211
  // ensure routes with `auth` flag have a logged-in user
package/api.d.ts CHANGED
@@ -1,4 +1,4 @@
1
- import type { Fn, Maybe, MaybePromise } from "@thi.ng/api";
1
+ import type { Fn, Fn2, Maybe, MaybePromise } from "@thi.ng/api";
2
2
  import type { ILogger } from "@thi.ng/logger";
3
3
  import type { Route, RouteMatch } from "@thi.ng/router";
4
4
  import type { IncomingMessage } from "node:http";
@@ -67,6 +67,12 @@ export interface ServerOpts<CTX extends RequestCtx = RequestCtx> {
67
67
  * Reference: https://nodejs.org/api/net.html#class-netblocklist
68
68
  */
69
69
  blockList: BlockList;
70
+ /**
71
+ * HTTP method adapter/converter. The default implementation only converts
72
+ * {@link Method} `head` to `get` if the route does not provide a `head`
73
+ * handler.
74
+ */
75
+ method: Fn2<Method, Pick<RequestCtx, "req" | "route" | "match" | "cookies" | "query">, Method>;
70
76
  }
71
77
  export interface ServerRoute<CTX extends RequestCtx = RequestCtx> extends Route {
72
78
  handlers: Partial<Record<Method, RequestHandler<CTX>>>;
@@ -85,6 +91,8 @@ export interface RequestCtx {
85
91
  res: ServerResponse;
86
92
  route: CompiledServerRoute;
87
93
  match: RouteMatch;
94
+ method: Method;
95
+ origMethod: Method;
88
96
  path: string;
89
97
  query: Record<string, any>;
90
98
  cookies?: Record<string, string>;
package/index.d.ts CHANGED
@@ -8,6 +8,7 @@ export * from "./interceptors/logging.js";
8
8
  export * from "./interceptors/measure.js";
9
9
  export * from "./interceptors/rate-limit.js";
10
10
  export * from "./interceptors/referrer-policy.js";
11
+ export * from "./interceptors/reject-useragent.js";
11
12
  export * from "./interceptors/strict-transport.js";
12
13
  export * from "./interceptors/x-origin-opener.js";
13
14
  export * from "./interceptors/x-origin-resource.js";
package/index.js CHANGED
@@ -8,6 +8,7 @@ export * from "./interceptors/logging.js";
8
8
  export * from "./interceptors/measure.js";
9
9
  export * from "./interceptors/rate-limit.js";
10
10
  export * from "./interceptors/referrer-policy.js";
11
+ export * from "./interceptors/reject-useragent.js";
11
12
  export * from "./interceptors/strict-transport.js";
12
13
  export * from "./interceptors/x-origin-opener.js";
13
14
  export * from "./interceptors/x-origin-resource.js";
@@ -0,0 +1,21 @@
1
+ import type { Interceptor } from "../api.js";
2
+ /**
3
+ * Pre-interceptor to check `User-Agent` header against given regexp. If the
4
+ * regexp matches, triggers a HTTP 403 response and terminates the request.
5
+ *
6
+ * @param patterns
7
+ */
8
+ export declare const rejectUserAgents: (patterns: string | RegExp) => Interceptor;
9
+ /**
10
+ * String defining partial regexp of known AI bot names in `User-Agent` header.
11
+ *
12
+ * Source: https://github.com/qwebltd/Useful-scripts/blob/main/Bash%20scripts%20for%20Linux/nginx-badbot-forbids.conf
13
+ */
14
+ export declare const USER_AGENT_AI_BOTS = "AI2Bot|Anthropic|BrightBot|ByteDance|ByteSpider|CCBot|ChatGPT|ClaudeBot|Claude-Web|Cohere-AI|Cohere-Training-Data-Crawler|DiffBot|DuckAssistBot|FriendlyCrawler|Friendly_Crawler|Google-CloudVertexBot|GPTBot|ICC-Crawler|Img2Dataset|Kangaroo Bot|MLBot|OAI-SearchBot|PanguBot|Sentibot|VelenPublicWebCrawler|Webzio-Extended";
15
+ /**
16
+ * String defining partial regexp of known crawlers & scraper names in `User-Agent` header.
17
+ *
18
+ * Source: https://github.com/qwebltd/Useful-scripts/blob/main/Bash%20scripts%20for%20Linux/nginx-rate-limiting.conf
19
+ */
20
+ export declare const USER_AGENT_SCRAPERS = "008|AddSugarSpiderBot|AdsBot|AhrefsBot|AmazonBot|Arachmo|Barkrowler|BimBot|BlexBot|Boitho.com|BTBot|ConveraCrawler|DiamondBot|DotBot|Earthcom.info|EmeraldShield.com|EsperanzaBot|FacebookBot|Fast Enterprise|FindLinks|FurlBot|FyberSpider|GaisBot|GigaBot|GirafaBot|GoogleOther|Go-HTTP-Client|HL_Ftien_Spider|Holmes|HTDig|ICCrawler|Ichiro|IgdeSpyder|ImageSiftBot|IonCrawl|IRLbot|ISSCyberRiskCrawler|IssueCrawler|Jaxified Bot|JyxoBot|KoepaBot|Kototoi.org|Larbin|LDSpider|LinkWalker|LMSpider|Lwp-Trivial|L.Webis|Mabontland|Magpie-Crawler|Mail.RU_Bot|Masscan-NG|Meltwater|Meta-ExternalAgent|Mogimogi|MoreoverBot|Morning Paper|MSRBot|MVAClient|MXBot|NetResearchServer|NetSeer Crawler|NewsGator|NiceBot|NUSearch Spider|Nutch|Nymesis|OmniExplorer_Bot|OrbBot|OozBot|PageBitesHyperBot|Peer39_Crawler|PolyBot|PSBot|PycUrl|Qseero|Radian6|RampyBot|RufusBot|SandCrawler|SBIder|Scrapy|SeekBot|SemanticDiscovery|Semrush|Sensis Web Crawler|SEOChat|Shim-Crawler|SiteBot|Snappy|SurveyBot|Sqworm|SuggyBot|SynooBot|TerrawizBot|TheSuBot|Thumbnail.cz|TimpiBot|TinEye|TruwoGPS|TurnItInBot|TweetedTimes Bot|UrlFileBot|Vagabondo|Vortex|Voyager|VYU2|WebCollage|Wf84|WomlpeFactory|Xaldon_WebSpider|Yacy|YasakliBot";
21
+ //# sourceMappingURL=reject-useragent.d.ts.map
@@ -0,0 +1,21 @@
1
+ import { isString } from "@thi.ng/checks";
2
+ const rejectUserAgents = (patterns) => {
3
+ const regexp = isString(patterns) ? new RegExp(patterns) : patterns;
4
+ return {
5
+ pre: (ctx) => {
6
+ const ua = ctx.req.headers["user-agent"];
7
+ if (ua && regexp.test(ua)) {
8
+ ctx.res.forbidden();
9
+ return false;
10
+ }
11
+ return true;
12
+ }
13
+ };
14
+ };
15
+ const USER_AGENT_AI_BOTS = "AI2Bot|Anthropic|BrightBot|ByteDance|ByteSpider|CCBot|ChatGPT|ClaudeBot|Claude-Web|Cohere-AI|Cohere-Training-Data-Crawler|DiffBot|DuckAssistBot|FriendlyCrawler|Friendly_Crawler|Google-CloudVertexBot|GPTBot|ICC-Crawler|Img2Dataset|Kangaroo Bot|MLBot|OAI-SearchBot|PanguBot|Sentibot|VelenPublicWebCrawler|Webzio-Extended";
16
+ const USER_AGENT_SCRAPERS = "008|AddSugarSpiderBot|AdsBot|AhrefsBot|AmazonBot|Arachmo|Barkrowler|BimBot|BlexBot|Boitho.com|BTBot|ConveraCrawler|DiamondBot|DotBot|Earthcom.info|EmeraldShield.com|EsperanzaBot|FacebookBot|Fast Enterprise|FindLinks|FurlBot|FyberSpider|GaisBot|GigaBot|GirafaBot|GoogleOther|Go-HTTP-Client|HL_Ftien_Spider|Holmes|HTDig|ICCrawler|Ichiro|IgdeSpyder|ImageSiftBot|IonCrawl|IRLbot|ISSCyberRiskCrawler|IssueCrawler|Jaxified Bot|JyxoBot|KoepaBot|Kototoi.org|Larbin|LDSpider|LinkWalker|LMSpider|Lwp-Trivial|L.Webis|Mabontland|Magpie-Crawler|Mail.RU_Bot|Masscan-NG|Meltwater|Meta-ExternalAgent|Mogimogi|MoreoverBot|Morning Paper|MSRBot|MVAClient|MXBot|NetResearchServer|NetSeer Crawler|NewsGator|NiceBot|NUSearch Spider|Nutch|Nymesis|OmniExplorer_Bot|OrbBot|OozBot|PageBitesHyperBot|Peer39_Crawler|PolyBot|PSBot|PycUrl|Qseero|Radian6|RampyBot|RufusBot|SandCrawler|SBIder|Scrapy|SeekBot|SemanticDiscovery|Semrush|Sensis Web Crawler|SEOChat|Shim-Crawler|SiteBot|Snappy|SurveyBot|Sqworm|SuggyBot|SynooBot|TerrawizBot|TheSuBot|Thumbnail.cz|TimpiBot|TinEye|TruwoGPS|TurnItInBot|TweetedTimes Bot|UrlFileBot|Vagabondo|Vortex|Voyager|VYU2|WebCollage|Wf84|WomlpeFactory|Xaldon_WebSpider|Yacy|YasakliBot";
17
+ export {
18
+ USER_AGENT_AI_BOTS,
19
+ USER_AGENT_SCRAPERS,
20
+ rejectUserAgents
21
+ };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@thi.ng/server",
3
- "version": "0.7.0",
3
+ "version": "0.8.0",
4
4
  "description": "Minimal HTTP server with declarative routing, static file serving and freely extensible via pre/post interceptors",
5
5
  "type": "module",
6
6
  "module": "./index.js",
@@ -45,7 +45,7 @@
45
45
  "@thi.ng/checks": "^3.7.2",
46
46
  "@thi.ng/errors": "^2.5.28",
47
47
  "@thi.ng/file-io": "^2.1.31",
48
- "@thi.ng/leaky-bucket": "^0.1.0",
48
+ "@thi.ng/leaky-bucket": "^0.2.0",
49
49
  "@thi.ng/logger": "^3.1.3",
50
50
  "@thi.ng/mime": "^2.7.4",
51
51
  "@thi.ng/paths": "^5.2.5",
@@ -123,6 +123,9 @@
123
123
  "./interceptors/referrer-policy": {
124
124
  "default": "./interceptors/referrer-policy.js"
125
125
  },
126
+ "./interceptors/reject-useragent": {
127
+ "default": "./interceptors/reject-useragent.js"
128
+ },
126
129
  "./interceptors/strict-transport": {
127
130
  "default": "./interceptors/strict-transport.js"
128
131
  },
@@ -164,5 +167,5 @@
164
167
  "status": "alpha",
165
168
  "year": 2024
166
169
  },
167
- "gitHead": "55987d581e4985b1c3091ba6be3f9b53a0c5eeea\n"
170
+ "gitHead": "c508e27f87e59a812bbbb838317f8f710bd77c0c\n"
168
171
  }
package/server.d.ts CHANGED
@@ -10,6 +10,7 @@ export declare class Server<CTX extends RequestCtx = RequestCtx> {
10
10
  server: http.Server<typeof http.IncomingMessage, typeof ServerResponse>;
11
11
  host: string;
12
12
  protected augmentCtx: Fn<RequestCtx, CTX>;
13
+ protected methodAdapter: ServerOpts["method"];
13
14
  constructor(opts?: Partial<ServerOpts<CTX>>);
14
15
  start(): Promise<boolean>;
15
16
  stop(): Promise<boolean>;
@@ -26,15 +27,86 @@ export declare const server: <CTX extends RequestCtx>(opts?: Partial<ServerOpts<
26
27
  * for various commonly used HTTP statuses/errors.
27
28
  */
28
29
  export declare class ServerResponse extends http.ServerResponse<http.IncomingMessage> {
30
+ /**
31
+ * Writes a HTTP 204 header (plus given `headers`) and ends the response.
32
+ *
33
+ * @param headers
34
+ */
29
35
  noContent(headers?: http.OutgoingHttpHeaders): void;
36
+ /**
37
+ * Writes a HTTP 302 header to redirect to given URL, add given additional
38
+ * `headers` and ends the response.
39
+ *
40
+ * @remarks
41
+ * Also see {@link ServerResponse.seeOther}.
42
+ *
43
+ * @param headers
44
+ */
30
45
  redirectTo(location: string, headers?: http.OutgoingHttpHeaders): void;
46
+ /**
47
+ * Writes a HTTP 303 header to redirect to given URL, add given additional
48
+ * `headers` and ends the response.
49
+ *
50
+ * @remarks
51
+ * Also see {@link ServerResponse.redirectTo}.
52
+ *
53
+ * @param headers
54
+ */
31
55
  seeOther(location: string, headers?: http.OutgoingHttpHeaders): void;
56
+ /**
57
+ * Writes a HTTP 304 header (plus given `headers`) and ends the response.
58
+ *
59
+ * @param headers
60
+ */
32
61
  unmodified(headers?: http.OutgoingHttpHeaders): void;
62
+ /**
63
+ * Writes a HTTP 400 header (plus given `headers`) and ends the response
64
+ * (with optional `body`).
65
+ *
66
+ * @param headers
67
+ */
68
+ badRequest(headers?: http.OutgoingHttpHeaders, body?: any): void;
69
+ /**
70
+ * Writes a HTTP 401 header (plus given `headers`) and ends the response
71
+ * (with optional `body`).
72
+ *
73
+ * @param headers
74
+ */
33
75
  unauthorized(headers?: http.OutgoingHttpHeaders, body?: any): void;
76
+ /**
77
+ * Writes a HTTP 403 header (plus given `headers`) and ends the response
78
+ * (with optional `body`).
79
+ *
80
+ * @param headers
81
+ */
34
82
  forbidden(headers?: http.OutgoingHttpHeaders, body?: any): void;
83
+ /**
84
+ * Writes a HTTP 404 header (plus given `headers`) and ends the response
85
+ * (with optional `body`).
86
+ *
87
+ * @param headers
88
+ */
35
89
  missing(headers?: http.OutgoingHttpHeaders, body?: any): void;
90
+ /**
91
+ * Writes a HTTP 405 header (plus given `headers`) and ends the response
92
+ * (with optional `body`).
93
+ *
94
+ * @param headers
95
+ */
36
96
  notAllowed(headers?: http.OutgoingHttpHeaders, body?: any): void;
97
+ /**
98
+ * Writes a HTTP 406 header (plus given `headers`) and ends the response
99
+ * (with optional `body`).
100
+ *
101
+ * @param headers
102
+ */
37
103
  notAcceptable(headers?: http.OutgoingHttpHeaders, body?: any): void;
104
+ /**
105
+ * Writes a HTTP 429 header (plus given `headers`) and ends the response
106
+ * (with optional `body`).
107
+ *
108
+ * @param headers
109
+ */
38
110
  rateLimit(headers?: http.OutgoingHttpHeaders, body?: any): void;
39
111
  /**
40
112
  * HTTP 444. Indicates the server has returned no information to the client and closed
package/server.js CHANGED
@@ -20,6 +20,7 @@ class Server {
20
20
  this.opts = opts;
21
21
  this.logger = opts.logger ?? new ConsoleLogger("server");
22
22
  this.host = opts.host ?? "localhost";
23
+ this.methodAdapter = opts.method ?? ((method, { route }) => method === "head" && !route.handlers.head && route.handlers.get ? (console.log("adapted head"), "get") : method);
23
24
  if (isIPv6(this.host)) this.host = normalizeIPv6Address(this.host);
24
25
  this.augmentCtx = opts.context ?? identity;
25
26
  const routes = [
@@ -44,6 +45,7 @@ class Server {
44
45
  server;
45
46
  host;
46
47
  augmentCtx;
48
+ methodAdapter;
47
49
  async start() {
48
50
  const {
49
51
  ssl,
@@ -101,15 +103,22 @@ class Server {
101
103
  const match = this.router.route(path);
102
104
  if (match.id === MISSING) return res.missing();
103
105
  const route = this.router.routeForID(match.id).spec;
104
- let method = req.method.toLowerCase();
106
+ const rawCookies = req.headers["cookie"] || req.headers["set-cookie"]?.join(";");
107
+ const cookies = rawCookies ? parseCoookies(rawCookies) : {};
108
+ const query = parseSearchParams(url.searchParams);
109
+ const origMethod = req.method.toLowerCase();
110
+ const method = this.methodAdapter(origMethod, {
111
+ req,
112
+ route,
113
+ match,
114
+ query,
115
+ cookies
116
+ });
105
117
  if (method === "options" && !route.handlers.options) {
106
118
  return res.noContent({
107
119
  allow: Object.keys(route.handlers).map(upper).join(", ")
108
120
  });
109
121
  }
110
- const rawCookies = req.headers["cookie"] || req.headers["set-cookie"]?.join(";");
111
- const cookies = rawCookies ? parseCoookies(rawCookies) : {};
112
- const query = parseSearchParams(url.searchParams);
113
122
  const ctx = this.augmentCtx({
114
123
  // @ts-ignore
115
124
  server: this,
@@ -120,11 +129,10 @@ class Server {
120
129
  query,
121
130
  cookies,
122
131
  route,
123
- match
132
+ match,
133
+ method,
134
+ origMethod
124
135
  });
125
- if (method === "head" && !route.handlers.head && route.handlers.get) {
126
- method = "get";
127
- }
128
136
  const handler = route.handlers[method];
129
137
  if (handler) {
130
138
  this.runHandler(handler, ctx);
@@ -232,33 +240,106 @@ class Server {
232
240
  }
233
241
  const server = (opts) => new Server(opts);
234
242
  class ServerResponse extends http.ServerResponse {
243
+ /**
244
+ * Writes a HTTP 204 header (plus given `headers`) and ends the response.
245
+ *
246
+ * @param headers
247
+ */
235
248
  noContent(headers) {
236
249
  this.writeHead(204, headers).end();
237
250
  }
251
+ /**
252
+ * Writes a HTTP 302 header to redirect to given URL, add given additional
253
+ * `headers` and ends the response.
254
+ *
255
+ * @remarks
256
+ * Also see {@link ServerResponse.seeOther}.
257
+ *
258
+ * @param headers
259
+ */
238
260
  redirectTo(location, headers) {
239
261
  this.writeHead(302, { ...headers, location }).end();
240
262
  }
263
+ /**
264
+ * Writes a HTTP 303 header to redirect to given URL, add given additional
265
+ * `headers` and ends the response.
266
+ *
267
+ * @remarks
268
+ * Also see {@link ServerResponse.redirectTo}.
269
+ *
270
+ * @param headers
271
+ */
241
272
  seeOther(location, headers) {
242
273
  this.writeHead(303, { ...headers, location }).end();
243
274
  }
275
+ /**
276
+ * Writes a HTTP 304 header (plus given `headers`) and ends the response.
277
+ *
278
+ * @param headers
279
+ */
244
280
  unmodified(headers) {
245
281
  this.writeHead(304, headers).end();
246
282
  }
283
+ /**
284
+ * Writes a HTTP 400 header (plus given `headers`) and ends the response
285
+ * (with optional `body`).
286
+ *
287
+ * @param headers
288
+ */
289
+ badRequest(headers, body) {
290
+ this.writeHead(400, headers).end(body);
291
+ }
292
+ /**
293
+ * Writes a HTTP 401 header (plus given `headers`) and ends the response
294
+ * (with optional `body`).
295
+ *
296
+ * @param headers
297
+ */
247
298
  unauthorized(headers, body) {
248
299
  this.writeHead(401, headers).end(body);
249
300
  }
301
+ /**
302
+ * Writes a HTTP 403 header (plus given `headers`) and ends the response
303
+ * (with optional `body`).
304
+ *
305
+ * @param headers
306
+ */
250
307
  forbidden(headers, body) {
251
308
  this.writeHead(403, headers).end(body);
252
309
  }
310
+ /**
311
+ * Writes a HTTP 404 header (plus given `headers`) and ends the response
312
+ * (with optional `body`).
313
+ *
314
+ * @param headers
315
+ */
253
316
  missing(headers, body) {
254
317
  this.writeHead(404, headers).end(body);
255
318
  }
319
+ /**
320
+ * Writes a HTTP 405 header (plus given `headers`) and ends the response
321
+ * (with optional `body`).
322
+ *
323
+ * @param headers
324
+ */
256
325
  notAllowed(headers, body) {
257
326
  this.writeHead(405, headers).end(body);
258
327
  }
328
+ /**
329
+ * Writes a HTTP 406 header (plus given `headers`) and ends the response
330
+ * (with optional `body`).
331
+ *
332
+ * @param headers
333
+ */
259
334
  notAcceptable(headers, body) {
260
335
  this.writeHead(406, headers).end(body);
261
336
  }
337
+ /**
338
+ * Writes a HTTP 429 header (plus given `headers`) and ends the response
339
+ * (with optional `body`).
340
+ *
341
+ * @param headers
342
+ */
262
343
  rateLimit(headers, body) {
263
344
  this.writeHead(429, headers).end(body);
264
345
  }