@thi.ng/server 0.2.0 → 0.3.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-01-30T15:45:22Z
3
+ - **Last updated**: 2025-02-02T22:46:17Z
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,13 @@ 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.3.0](https://github.com/thi-ng/umbrella/tree/@thi.ng/server@0.3.0) (2025-02-02)
15
+
16
+ #### 🚀 Features
17
+
18
+ - add more HTTP error response methods ([5731ff3](https://github.com/thi-ng/umbrella/commit/5731ff3))
19
+ - add ServerResponse, IPv6 support ([22f64c5](https://github.com/thi-ng/umbrella/commit/22f64c5))
20
+
14
21
  ## [0.2.0](https://github.com/thi-ng/umbrella/tree/@thi.ng/server@0.2.0) (2025-01-30)
15
22
 
16
23
  #### 🚀 Features
package/api.d.ts CHANGED
@@ -1,8 +1,8 @@
1
1
  import type { Fn, 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
- import type { IncomingMessage, ServerResponse } from "node:http";
5
- import type { Server } from "./server.js";
4
+ import type { IncomingMessage } from "node:http";
5
+ import type { ServerResponse, Server } from "./server.js";
6
6
  export type Method = "get" | "put" | "post" | "delete" | "head" | "options" | "patch";
7
7
  export interface ServerOpts<CTX extends RequestCtx = RequestCtx> {
8
8
  logger: ILogger;
package/index.d.ts CHANGED
@@ -13,6 +13,7 @@ export * from "./session/session.js";
13
13
  export * from "./session/memory.js";
14
14
  export * from "./utils/cookies.js";
15
15
  export * from "./utils/cache.js";
16
+ export * from "./utils/host.js";
16
17
  export * from "./utils/formdata.js";
17
18
  export * from "./utils/multipart.js";
18
19
  //# sourceMappingURL=index.d.ts.map
package/index.js CHANGED
@@ -13,5 +13,6 @@ export * from "./session/session.js";
13
13
  export * from "./session/memory.js";
14
14
  export * from "./utils/cookies.js";
15
15
  export * from "./utils/cache.js";
16
+ export * from "./utils/host.js";
16
17
  export * from "./utils/formdata.js";
17
18
  export * from "./utils/multipart.js";
@@ -1,7 +1,7 @@
1
1
  const authenticateWith = (pred) => ({
2
2
  pre: (ctx) => {
3
3
  if (ctx.route.auth && !pred(ctx)) {
4
- ctx.server.unauthorized(ctx.res);
4
+ ctx.res.unauthorized();
5
5
  return false;
6
6
  }
7
7
  return true;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@thi.ng/server",
3
- "version": "0.2.0",
3
+ "version": "0.3.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",
@@ -145,6 +145,9 @@
145
145
  "./utils/formdata": {
146
146
  "default": "./utils/formdata.js"
147
147
  },
148
+ "./utils/host": {
149
+ "default": "./utils/host.js"
150
+ },
148
151
  "./utils/multipart": {
149
152
  "default": "./utils/multipart.js"
150
153
  }
@@ -153,5 +156,5 @@
153
156
  "status": "alpha",
154
157
  "year": 2024
155
158
  },
156
- "gitHead": "078de98f4365f0d472c87198bcd6a112e732d9ef\n"
159
+ "gitHead": "fa1407b41ef907a5523d30bcb28691a5aed6e85c\n"
157
160
  }
package/server.d.ts CHANGED
@@ -7,21 +7,33 @@ export declare class Server<CTX extends RequestCtx = RequestCtx> {
7
7
  opts: Partial<ServerOpts<CTX>>;
8
8
  logger: ILogger;
9
9
  router: Router<CompiledServerRoute<CTX>>;
10
- server: http.Server;
10
+ server: http.Server<typeof http.IncomingMessage, typeof ServerResponse>;
11
+ host: string;
11
12
  protected augmentCtx: Fn<RequestCtx, CTX>;
12
13
  constructor(opts?: Partial<ServerOpts<CTX>>);
13
14
  start(): Promise<boolean>;
14
15
  stop(): Promise<boolean>;
15
- protected listener(req: http.IncomingMessage, res: http.ServerResponse): Promise<void>;
16
+ protected listener(req: http.IncomingMessage, res: ServerResponse): Promise<void>;
16
17
  protected runHandler({ fn, pre, post }: CompiledHandler, ctx: CTX): Promise<void>;
17
18
  protected compileRoute(route: ServerRoute<CTX>): CompiledServerRoute<CTX>;
18
- addRoutes(routes: ServerRoute[]): void;
19
+ addRoutes(routes: ServerRoute<CTX>[]): void;
19
20
  sendFile({ req, res }: RequestCtx, path: string, headers?: http.OutgoingHttpHeaders, compress?: boolean): Promise<void>;
20
- unauthorized(res: http.ServerResponse): void;
21
- unmodified(res: http.ServerResponse): void;
22
- missing(res: http.ServerResponse): void;
23
- redirectTo(res: http.ServerResponse, location: string): void;
24
- redirectToRoute(res: http.ServerResponse, route: RouteMatch): void;
21
+ redirectToRoute(res: ServerResponse, route: RouteMatch): void;
25
22
  }
26
23
  export declare const server: <CTX extends RequestCtx>(opts?: Partial<ServerOpts<CTX>>) => Server<CTX>;
24
+ /**
25
+ * Extended version of the default NodeJS ServerResponse with additional methods
26
+ * for various commonly used HTTP statuses/errors.
27
+ */
28
+ export declare class ServerResponse extends http.ServerResponse<http.IncomingMessage> {
29
+ noContent(headers?: http.OutgoingHttpHeaders): void;
30
+ redirectTo(location: string, headers?: http.OutgoingHttpHeaders): void;
31
+ seeOther(location: string, headers?: http.OutgoingHttpHeaders): void;
32
+ unmodified(headers?: http.OutgoingHttpHeaders): void;
33
+ unauthorized(headers?: http.OutgoingHttpHeaders, body?: any): void;
34
+ forbidden(headers?: http.OutgoingHttpHeaders, body?: any): void;
35
+ missing(headers?: http.OutgoingHttpHeaders, body?: any): void;
36
+ notAllowed(headers?: http.OutgoingHttpHeaders, body?: any): void;
37
+ notAcceptable(headers?: http.OutgoingHttpHeaders, body?: any): void;
38
+ }
27
39
  //# sourceMappingURL=server.d.ts.map
package/server.js CHANGED
@@ -4,26 +4,30 @@ import { readText } from "@thi.ng/file-io";
4
4
  import { ConsoleLogger } from "@thi.ng/logger";
5
5
  import { preferredTypeForPath } from "@thi.ng/mime";
6
6
  import { Router } from "@thi.ng/router";
7
+ import { upper } from "@thi.ng/strings";
7
8
  import { createReadStream } from "node:fs";
8
9
  import * as http from "node:http";
9
10
  import * as https from "node:https";
11
+ import { isIPv6 } from "node:net";
10
12
  import { pipeline, Transform } from "node:stream";
11
13
  import { createBrotliCompress, createDeflate, createGzip } from "node:zlib";
12
14
  import { parseCoookies } from "./utils/cookies.js";
13
15
  import { parseSearchParams } from "./utils/formdata.js";
14
- import { upper } from "@thi.ng/strings";
16
+ import { isMatchingHost, normalizeIPv6Address } from "./utils/host.js";
15
17
  const MISSING = "__missing";
16
18
  class Server {
17
19
  constructor(opts = {}) {
18
20
  this.opts = opts;
19
21
  this.logger = opts.logger ?? new ConsoleLogger("server");
22
+ this.host = opts.host ?? "localhost";
23
+ if (isIPv6(this.host)) this.host = normalizeIPv6Address(this.host);
20
24
  this.augmentCtx = opts.context ?? identity;
21
25
  const routes = [
22
26
  {
23
27
  id: MISSING,
24
28
  match: ["__404__"],
25
29
  handlers: {
26
- get: async ({ res }) => this.missing(res)
30
+ get: async ({ res }) => res.missing()
27
31
  }
28
32
  },
29
33
  ...this.opts.routes ?? []
@@ -38,19 +42,22 @@ class Server {
38
42
  logger;
39
43
  router;
40
44
  server;
45
+ host;
41
46
  augmentCtx;
42
47
  async start() {
43
- const ssl = this.opts.ssl;
44
- const port = this.opts.port ?? (ssl ? 443 : 8080);
45
- const host = this.opts.host ?? "localhost";
48
+ const { ssl, host = "localhost", port = ssl ? 443 : 8080 } = this.opts;
46
49
  try {
47
50
  this.server = ssl ? https.createServer(
48
51
  {
49
52
  key: readText(ssl.key, this.logger),
50
- cert: readText(ssl.cert, this.logger)
53
+ cert: readText(ssl.cert, this.logger),
54
+ ServerResponse
51
55
  },
52
56
  this.listener.bind(this)
53
- ) : http.createServer({}, this.listener.bind(this));
57
+ ) : http.createServer(
58
+ { ServerResponse },
59
+ this.listener.bind(this)
60
+ );
54
61
  this.server.listen(port, host, void 0, () => {
55
62
  this.logger.info(
56
63
  `starting server: http${ssl ? "s" : ""}://${host}:${port}`
@@ -70,13 +77,13 @@ class Server {
70
77
  return true;
71
78
  }
72
79
  async listener(req, res) {
73
- const url = new URL(req.url, `http://${req.headers.host}`);
74
- if (this.opts.host && this.opts.host !== url.host) {
75
- res.writeHead(503).end();
76
- return;
77
- }
78
- const path = decodeURIComponent(url.pathname);
79
80
  try {
81
+ const url = new URL(req.url, `http://${req.headers.host}`);
82
+ if (this.opts.host && !isMatchingHost(url.hostname, this.opts.host)) {
83
+ res.writeHead(503).end();
84
+ return;
85
+ }
86
+ const path = decodeURIComponent(url.pathname);
80
87
  const query = parseSearchParams(url.searchParams);
81
88
  const match = this.router.route(path);
82
89
  const route = this.router.routeForID(match.id).spec;
@@ -112,7 +119,7 @@ class Server {
112
119
  if (handler) {
113
120
  this.runHandler(handler, ctx);
114
121
  } else {
115
- res.writeHead(405).end();
122
+ res.notAllowed();
116
123
  }
117
124
  } catch (e) {
118
125
  this.logger.warn("error:", e.message);
@@ -196,29 +203,47 @@ class Server {
196
203
  encoding ? pipeline(src, encoding.tx(), res, finalize) : pipeline(src, res, finalize);
197
204
  } catch (e) {
198
205
  this.logger.warn(e.message);
199
- this.missing(res);
206
+ res.missing();
200
207
  resolve();
201
208
  }
202
209
  });
203
210
  }
204
- unauthorized(res) {
205
- res.writeHead(403, "Forbidden").end();
211
+ redirectToRoute(res, route) {
212
+ res.redirectTo(this.router.format(route));
213
+ }
214
+ }
215
+ const server = (opts) => new Server(opts);
216
+ class ServerResponse extends http.ServerResponse {
217
+ noContent(headers) {
218
+ this.writeHead(204, headers).end();
206
219
  }
207
- unmodified(res) {
208
- res.writeHead(304, "Not modified").end();
220
+ redirectTo(location, headers) {
221
+ this.writeHead(302, { ...headers, location }).end();
209
222
  }
210
- missing(res) {
211
- res.writeHead(404, "Not found").end();
223
+ seeOther(location, headers) {
224
+ this.writeHead(303, { ...headers, location }).end();
212
225
  }
213
- redirectTo(res, location) {
214
- res.writeHead(302, { location }).end();
226
+ unmodified(headers) {
227
+ this.writeHead(304, headers).end();
215
228
  }
216
- redirectToRoute(res, route) {
217
- this.redirectTo(res, this.router.format(route));
229
+ unauthorized(headers, body) {
230
+ this.writeHead(401, headers).end(body);
231
+ }
232
+ forbidden(headers, body) {
233
+ this.writeHead(403, headers).end(body);
234
+ }
235
+ missing(headers, body) {
236
+ this.writeHead(404, headers).end(body);
237
+ }
238
+ notAllowed(headers, body) {
239
+ this.writeHead(405, headers).end(body);
240
+ }
241
+ notAcceptable(headers, body) {
242
+ this.writeHead(406, headers).end(body);
218
243
  }
219
244
  }
220
- const server = (opts) => new Server(opts);
221
245
  export {
222
246
  Server,
247
+ ServerResponse,
223
248
  server
224
249
  };
@@ -32,7 +32,8 @@ export declare class SessionInterceptor<CTX extends RequestCtx = RequestCtx, SES
32
32
  constructor({ store, factory, cookieName, cookieOpts, }?: Partial<SessionOpts<CTX, SESSION>>);
33
33
  pre(ctx: CTX): Promise<boolean>;
34
34
  delete(ctx: CTX, sessionID: string): Promise<void>;
35
- withSession(res: ServerResponse, sessionID: string): void;
35
+ newSession(ctx: CTX): Promise<SESSION | undefined>;
36
+ withSession(res: ServerResponse, sessionID: string): ServerResponse<import("http").IncomingMessage>;
36
37
  }
37
38
  /**
38
39
  * Factory function to create a new {@link SessionInterceptor} instance.
@@ -18,16 +18,14 @@ class SessionInterceptor {
18
18
  this.cookieOpts = cookieOpts;
19
19
  }
20
20
  async pre(ctx) {
21
- const { res, logger, cookies } = ctx;
22
- const id = cookies?.[this.cookieName];
21
+ const id = ctx.cookies?.[this.cookieName];
23
22
  let session = id ? await this.store.get(id) : void 0;
24
23
  if (!session) {
25
- session = this.factory(ctx);
26
- logger.info("new session:", session.id);
27
- this.store.set(session);
24
+ session = await this.newSession(ctx);
25
+ if (!session) return false;
28
26
  }
29
27
  ctx.session = session;
30
- this.withSession(res, session.id);
28
+ this.withSession(ctx.res, session.id);
31
29
  return true;
32
30
  }
33
31
  async delete(ctx, sessionID) {
@@ -40,8 +38,17 @@ class SessionInterceptor {
40
38
  );
41
39
  }
42
40
  }
41
+ async newSession(ctx) {
42
+ const session = this.factory(ctx);
43
+ ctx.logger.info("new session:", session.id);
44
+ if (!await this.store.set(session)) {
45
+ ctx.logger.warn("could not store session...");
46
+ return;
47
+ }
48
+ return session;
49
+ }
43
50
  withSession(res, sessionID) {
44
- res.appendHeader(
51
+ return res.appendHeader(
45
52
  "set-cookie",
46
53
  `${this.cookieName}=${sessionID};Max-Age=${this.store.ttl};${this.cookieOpts}`
47
54
  );
package/static.js CHANGED
@@ -54,11 +54,11 @@ const staticFiles = ({
54
54
  });
55
55
  const __fileHeaders = async (path, ctx, filter, etag, headers) => {
56
56
  if (!(existsSync(path) && filter(path))) {
57
- return ctx.server.missing(ctx.res);
57
+ return ctx.res.missing();
58
58
  }
59
59
  if (etag) {
60
60
  const etagValue = await etag(path);
61
- return isUnmodified(etagValue, ctx.req.headers["if-none-match"]) ? ctx.server.unmodified(ctx.res) : { ...headers, etag: etagValue };
61
+ return isUnmodified(etagValue, ctx.req.headers["if-none-match"]) ? ctx.res.unmodified() : { ...headers, etag: etagValue };
62
62
  }
63
63
  return { ...headers };
64
64
  };
@@ -0,0 +1,42 @@
1
+ /**
2
+ * Takes a hostname or IPv6 address `test` and compares it against `expected`,
3
+ * returns true if matching.
4
+ *
5
+ * @remarks
6
+ * If `test` is an IPv6 address in the format `[...]`, it will be compared
7
+ * without surrounding square brackets. If `expected` is an IPv6 address it MUST
8
+ * have been pre-normalized using {@link normalizeIPv6Address}. For performance
9
+ * reasons only `test` will be automatically normalized, i.e. `::1` will be
10
+ * normalized as `0:0:0:0:0:0:0:1`.
11
+ *
12
+ * @param test
13
+ * @param expected
14
+ *
15
+ * @internal
16
+ */
17
+ export declare const isMatchingHost: (test: string, expected: string) => boolean;
18
+ /**
19
+ * Parses given IPv6 address into an 8-tuple of 16bit uints. Throws error if
20
+ * address is invalid.
21
+ *
22
+ * @remarks
23
+ * https://en.wikipedia.org/wiki/IPv6_address
24
+ *
25
+ * @param addr
26
+ */
27
+ export declare const parseIPv6Address: (addr: string) => number[];
28
+ /**
29
+ * Returns normalized version of given IPv6 address, i.e. expanding `::`
30
+ * sections, removing leading zeroes and performing other syntactic validations.
31
+ * The returned address always has 8 parts. Throws error if address is invalid.
32
+ *
33
+ * @remarks
34
+ * Internally uses {@link parseIPv6Address}.
35
+ *
36
+ * Reference:
37
+ * https://en.wikipedia.org/wiki/IPv6_address
38
+ *
39
+ * @param addr
40
+ */
41
+ export declare const normalizeIPv6Address: (addr: string) => string;
42
+ //# sourceMappingURL=host.d.ts.map
package/utils/host.js ADDED
@@ -0,0 +1,40 @@
1
+ import { illegalArgs } from "@thi.ng/errors";
2
+ import { HEX } from "@thi.ng/strings";
3
+ const isMatchingHost = (test, expected) => /^\[[0-9a-f:]+\]$/.test(test) ? normalizeIPv6Address(test.substring(1, test.length - 1)) === expected : test === expected;
4
+ const parseIPv6Address = (addr) => {
5
+ if (addr == "::") return [0, 0, 0, 0, 0, 0, 0, 0];
6
+ if (addr == "::1") return [0, 0, 0, 0, 0, 0, 0, 1];
7
+ const n = addr.length - 1;
8
+ if (n > 38) invalidIPv6(addr);
9
+ const parts = [];
10
+ let curr = 0;
11
+ let zeroes = -1;
12
+ for (let i = 0; i <= n; i++) {
13
+ const ch = addr[i];
14
+ if (i === n && ch == ":") illegalArgs(addr);
15
+ if (i === n || ch === ":") {
16
+ if (parts.length >= (zeroes >= 0 ? 6 : 8)) invalidIPv6(addr);
17
+ const end = i === n ? n + 1 : i > curr ? i : invalidIPv6(addr);
18
+ if (end - curr > 4) invalidIPv6(addr);
19
+ parts.push(parseInt(addr.substring(curr, end), 16));
20
+ if (addr[i + 1] === ":") {
21
+ if (zeroes >= 0) invalidIPv6(addr);
22
+ zeroes = parts.length;
23
+ i++;
24
+ }
25
+ curr = i + 1;
26
+ } else if (!HEX[ch]) invalidIPv6(addr);
27
+ }
28
+ if (zeroes >= 0) {
29
+ parts.splice(zeroes, 0, ...new Array(8 - parts.length).fill(0));
30
+ }
31
+ if (parts.length !== 8) invalidIPv6(addr);
32
+ return parts;
33
+ };
34
+ const normalizeIPv6Address = (addr) => parseIPv6Address(addr).map((x) => x.toString(16)).join(":");
35
+ const invalidIPv6 = (addr) => illegalArgs("invalid IPv6 address: " + addr);
36
+ export {
37
+ isMatchingHost,
38
+ normalizeIPv6Address,
39
+ parseIPv6Address
40
+ };