@thi.ng/server 0.8.1 → 0.9.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-17T13:40:35Z
3
+ - **Last updated**: 2025-03-22T12:33:45Z
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,19 @@ 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.9.0](https://github.com/thi-ng/umbrella/tree/@thi.ng/server@0.9.0) (2025-03-22)
15
+
16
+ #### 🚀 Features
17
+
18
+ - update static file serving ([e3ee3d6](https://github.com/thi-ng/umbrella/commit/e3ee3d6))
19
+ - update `StaticOpts` to support multiple `filters` & individual `headers` per file
20
+ - update `StaticOpts.prefix` type
21
+ - consolidate GET & HEAD handlers
22
+ - update `__fileHeaders()` to perform more checks and inject headers
23
+ - update `etagFileTimeModified()` to return base36 timestamp
24
+ - add `pathFilterASCII()` and `pathMaxLength()` filter predicates
25
+ - use `pathFilterASCII()` as default filter
26
+
14
27
  ## [0.8.0](https://github.com/thi-ng/umbrella/tree/@thi.ng/server@0.8.0) (2025-03-13)
15
28
 
16
29
  #### 🚀 Features
package/README.md CHANGED
@@ -7,7 +7,7 @@
7
7
  [![Mastodon Follow](https://img.shields.io/mastodon/follow/109331703950160316?domain=https%3A%2F%2Fmastodon.thi.ng&style=social)](https://mastodon.thi.ng/@toxi)
8
8
 
9
9
  > [!NOTE]
10
- > This is one of 203 standalone projects, maintained as part
10
+ > This is one of 204 standalone projects, maintained as part
11
11
  > of the [@thi.ng/umbrella](https://github.com/thi-ng/umbrella/) monorepo
12
12
  > and anti-framework.
13
13
  >
@@ -20,6 +20,7 @@
20
20
  - [Available interceptors](#available-interceptors)
21
21
  - [Custom interceptors](#custom-interceptors)
22
22
  - [Using interceptors](#using-interceptors)
23
+ - [Static file serving](#static-file-serving)
23
24
  - [Status](#status)
24
25
  - [Installation](#installation)
25
26
  - [Dependencies](#dependencies)
@@ -49,9 +50,9 @@ implementations.
49
50
  - Global interceptors for all routes and/or local for individual routes & HTTP methods
50
51
  - Automatic parsing of cookies and URL query strings (incl. nested params)
51
52
  - In-memory session storage & route interceptor
52
- - Configurable file serving (`ReadableStream`-based) with automatic MIME-type
53
- detection and support for Etags, as well as Brotli, Gzip and Deflate
54
- compression
53
+ - Configurable [static file serving](#static-file-serving)
54
+ (`ReadableStream`-based) with automatic MIME-type detection and support for
55
+ Etags, as well as Brotli, Gzip and Deflate compression
55
56
  - Utilities for parsing form-encoded multipart request bodies
56
57
 
57
58
  ### Interceptors
@@ -118,6 +119,19 @@ import { cacheControl } from "@thi.ng/server";
118
119
  }
119
120
  ```
120
121
 
122
+ ### Static file serving
123
+
124
+ The
125
+ [`staticFiles()`](https://docs.thi.ng/umbrella/server/functions/staticFiles.html)
126
+ route provider can be used to serve files from a given local root directory.
127
+ Multiple such routes can be defined. The handler is highly configurable in terms
128
+ of path validation/filtering, global and/or per-file headers, Etag generation,
129
+ compression. It also supports its own set of [interceptors](#interceptors).
130
+
131
+ See
132
+ [`StaticOpts`](https://docs.thi.ng/umbrella/server/interfaces/StaticOpts.html)
133
+ and example below for more details.
134
+
121
135
  ## Status
122
136
 
123
137
  **ALPHA** - bleeding edge / work-in-progress
@@ -150,7 +164,7 @@ For Node.js REPL:
150
164
  const ser = await import("@thi.ng/server");
151
165
  ```
152
166
 
153
- Package sizes (brotli'd, pre-treeshake): ESM: 6.15 KB
167
+ Package sizes (brotli'd, pre-treeshake): ESM: 6.25 KB
154
168
 
155
169
  ## Dependencies
156
170
 
@@ -220,7 +234,7 @@ const app = srv.server<AppCtx>({
220
234
  // use compression (if client supports it)
221
235
  compress: true,
222
236
  // route prefix
223
- prefix: "assets",
237
+ prefix: "/assets",
224
238
  // map to current CWD
225
239
  rootDir: ".",
226
240
  // strategy for computing etags (optional)
@@ -0,0 +1,96 @@
1
+ import type { Fn, MaybePromise, Predicate } from "@thi.ng/api";
2
+ import { type HashAlgo } from "@thi.ng/file-io";
3
+ import type { OutgoingHttpHeaders } from "node:http";
4
+ import type { Interceptor, RequestCtx, ServerRoute } from "./api.js";
5
+ /**
6
+ * Static file configuration options.
7
+ */
8
+ export interface StaticOpts<CTX extends RequestCtx = RequestCtx> {
9
+ /**
10
+ * Path to local root directory for static assets. Also see
11
+ * {@link StaticOpts.prefix}
12
+ *
13
+ * @defaultValue `.` (current cwd)
14
+ */
15
+ rootDir: string;
16
+ /**
17
+ * Filter predicate to exclude files from being served. Called with the
18
+ * absolute local file path. If the function returns false, the server
19
+ * produces a 404 response. By default all files (within
20
+ * {@link StaticOpts.rootDir}) will be allowed.
21
+ */
22
+ filter: Predicate<string>;
23
+ /**
24
+ * Base URL prefix for static assets. Also see {@link StaticOpts.rootDir}
25
+ *
26
+ * @defaultValue "static"
27
+ */
28
+ prefix: string;
29
+ /**
30
+ * Additional route specific interceptors.
31
+ */
32
+ intercept: Interceptor<CTX>[];
33
+ /**
34
+ * Additional common headers (e.g. cache control) for all static files
35
+ */
36
+ headers: OutgoingHttpHeaders;
37
+ /**
38
+ * If true (default: false), files will be served with brotli, gzip or deflate
39
+ * compression (if the client supports it).
40
+ *
41
+ * @defaultValue false
42
+ */
43
+ compress: boolean;
44
+ /**
45
+ * User defined function to compute an Etag value for given file path. The
46
+ * file is guaranteed to exist when this function is called.
47
+ */
48
+ etag: Fn<string, MaybePromise<string>>;
49
+ /**
50
+ * If true, the route will have its `auth` flag enabled, e.g. for use with
51
+ * the {@link authenticateWith} interceptor.
52
+ *
53
+ * @defaultValue false
54
+ */
55
+ auth: boolean;
56
+ }
57
+ /**
58
+ * Defines a configurable {@link ServerRoute} and handler for serving static
59
+ * files from a local directory (optionally with compression, see
60
+ * {@link StaticOpts.compress} for details).
61
+ *
62
+ * @param opts
63
+ */
64
+ export declare const staticFiles: <CTX extends RequestCtx = RequestCtx>({
65
+ prefix,
66
+ rootDir,
67
+ intercept,
68
+ filter,
69
+ compress,
70
+ auth,
71
+ etag,
72
+ headers,
73
+ }?: Partial<StaticOpts<CTX>>) => ServerRoute<CTX>;
74
+ /**
75
+ * Etag header value function for {@link StaticOpts.etag}. Computes Etag based
76
+ * on file modified date.
77
+ *
78
+ * @remarks
79
+ * Also see {@link etagFileHash}.
80
+ *
81
+ * @param path
82
+ */
83
+ export declare const etagFileTimeModified: (path: string) => string;
84
+ /**
85
+ * Higher-order Etag header value function for {@link StaticOpts.etag}. Computes
86
+ * Etag value by computing the hash digest of a given file. Uses MD5 by default.
87
+ *
88
+ * @remarks
89
+ * Also see {@link etagFileTimeModified}.
90
+ *
91
+ * @param algo
92
+ */
93
+ export declare const etagFileHash: (
94
+ algo?: HashAlgo
95
+ ) => (path: string) => Promise<string>;
96
+ //# sourceMappingURL=static.d.ts.map
@@ -0,0 +1,73 @@
1
+ import { fileHash as $fileHash } from "@thi.ng/file-io";
2
+ import { preferredTypeForPath } from "@thi.ng/mime";
3
+ import { existsSync, statSync } from "node:fs";
4
+ import { join } from "node:path";
5
+ import { isUnmodified } from "../utils/cache.js";
6
+ const staticFiles = ({
7
+ prefix = "static",
8
+ rootDir = ".",
9
+ intercept = [],
10
+ filter = () => true,
11
+ compress = false,
12
+ auth = false,
13
+ etag,
14
+ headers
15
+ } = {}) => ({
16
+ id: "__static",
17
+ match: [prefix, "+"],
18
+ auth,
19
+ handlers: {
20
+ head: {
21
+ fn: async (ctx) => {
22
+ const path = join(rootDir, ...ctx.match.rest);
23
+ ctx.logger.debug("path", path);
24
+ const $headers = await __fileHeaders(
25
+ path,
26
+ ctx,
27
+ filter,
28
+ etag,
29
+ headers
30
+ );
31
+ if (!$headers) return;
32
+ ctx.res.writeHead(200, {
33
+ "content-type": preferredTypeForPath(path),
34
+ ...$headers
35
+ });
36
+ },
37
+ intercept
38
+ },
39
+ get: {
40
+ fn: async (ctx) => {
41
+ const path = join(rootDir, ...ctx.match.rest);
42
+ ctx.logger.debug("path", path);
43
+ const $headers = await __fileHeaders(
44
+ path,
45
+ ctx,
46
+ filter,
47
+ etag,
48
+ headers
49
+ );
50
+ if (!$headers) return;
51
+ return ctx.server.sendFile(ctx, path, $headers, compress);
52
+ },
53
+ intercept
54
+ }
55
+ }
56
+ });
57
+ const __fileHeaders = async (path, ctx, filter, etag, headers) => {
58
+ if (!(existsSync(path) && filter(path))) {
59
+ return ctx.res.missing();
60
+ }
61
+ if (etag) {
62
+ const etagValue = await etag(path);
63
+ return isUnmodified(etagValue, ctx.req.headers["if-none-match"]) ? ctx.res.unmodified() : { ...headers, etag: etagValue };
64
+ }
65
+ return { ...headers };
66
+ };
67
+ const etagFileTimeModified = (path) => String(statSync(path).mtimeMs);
68
+ const etagFileHash = (algo = "md5") => (path) => $fileHash(path, void 0, algo);
69
+ export {
70
+ etagFileHash,
71
+ etagFileTimeModified,
72
+ staticFiles
73
+ };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@thi.ng/server",
3
- "version": "0.8.1",
3
+ "version": "0.9.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",
@@ -39,20 +39,20 @@
39
39
  "tool:tangle": "../../node_modules/.bin/tangle src/**/*.ts"
40
40
  },
41
41
  "dependencies": {
42
- "@thi.ng/api": "^8.11.23",
43
- "@thi.ng/arrays": "^2.10.20",
44
- "@thi.ng/cache": "^2.3.28",
45
- "@thi.ng/checks": "^3.7.3",
46
- "@thi.ng/errors": "^2.5.29",
47
- "@thi.ng/file-io": "^2.1.32",
48
- "@thi.ng/leaky-bucket": "^0.2.1",
49
- "@thi.ng/logger": "^3.1.4",
50
- "@thi.ng/mime": "^2.7.5",
51
- "@thi.ng/paths": "^5.2.6",
52
- "@thi.ng/router": "^4.1.23",
53
- "@thi.ng/strings": "^3.9.8",
54
- "@thi.ng/timestamp": "^1.1.8",
55
- "@thi.ng/uuid": "^1.1.20"
42
+ "@thi.ng/api": "^8.11.24",
43
+ "@thi.ng/arrays": "^2.10.21",
44
+ "@thi.ng/cache": "^2.3.29",
45
+ "@thi.ng/checks": "^3.7.4",
46
+ "@thi.ng/errors": "^2.5.30",
47
+ "@thi.ng/file-io": "^2.1.33",
48
+ "@thi.ng/leaky-bucket": "^0.2.2",
49
+ "@thi.ng/logger": "^3.1.5",
50
+ "@thi.ng/mime": "^2.7.6",
51
+ "@thi.ng/paths": "^5.2.7",
52
+ "@thi.ng/router": "^4.1.24",
53
+ "@thi.ng/strings": "^3.9.9",
54
+ "@thi.ng/timestamp": "^1.1.9",
55
+ "@thi.ng/uuid": "^1.1.21"
56
56
  },
57
57
  "devDependencies": {
58
58
  "@types/node": "^22.13.10",
@@ -167,5 +167,5 @@
167
167
  "status": "alpha",
168
168
  "year": 2024
169
169
  },
170
- "gitHead": "dfecb91b5b6a05db32d96f261e0c0ac6319240b9\n"
170
+ "gitHead": "0e2dadea20613d4a349177a669d73d7a4257898d\n"
171
171
  }
package/server.js CHANGED
@@ -140,7 +140,7 @@ class Server {
140
140
  res.notAllowed();
141
141
  }
142
142
  } catch (e) {
143
- this.logger.warn("error:", e.message);
143
+ this.logger.warn(`error:`, req.url, e.message);
144
144
  res.writeHead(500).end();
145
145
  }
146
146
  }
package/static.d.ts CHANGED
@@ -14,30 +14,40 @@ export interface StaticOpts<CTX extends RequestCtx = RequestCtx> {
14
14
  */
15
15
  rootDir: string;
16
16
  /**
17
- * Filter predicate to exclude files from being served. Called with the
18
- * absolute local file path. If the function returns false, the server
19
- * produces a 404 response. By default all files (within
20
- * {@link StaticOpts.rootDir}) will be allowed.
17
+ * Filter predicates to validate & exclude file paths from being served.
18
+ * Executed in given order and each one called with the absolute local file
19
+ * path. If any of the predicates returns false, the server produces a 404
20
+ * response. By default only uses {@link pathFilterASCII}.
21
21
  */
22
- filter: Predicate<string>;
22
+ filters: Predicate<string>[];
23
23
  /**
24
- * Base URL prefix for static assets. Also see {@link StaticOpts.rootDir}
24
+ * URL path prefix for defining the static assets route. Also see
25
+ * {@link StaticOpts.rootDir}.
26
+ *
27
+ * @remarks
28
+ * If given as array, each item is a path component, i.e. `["public",
29
+ * "img"]` is the same as `"/public/img"`.
25
30
  *
26
31
  * @defaultValue "static"
27
32
  */
28
- prefix: string;
33
+ prefix: string | string[];
29
34
  /**
30
35
  * Additional route specific interceptors.
31
36
  */
32
37
  intercept: Interceptor<CTX>[];
33
38
  /**
34
- * Additional common headers (e.g. cache control) for all static files
39
+ * Additional common headers (e.g. cache control) for a file. If given as
40
+ * function, it will be called with the absolute path (the function will
41
+ * only be called if the path exists and already has passed validation).
35
42
  */
36
- headers: OutgoingHttpHeaders;
43
+ headers: OutgoingHttpHeaders | Fn<string, OutgoingHttpHeaders>;
37
44
  /**
38
45
  * If true (default: false), files will be served with brotli, gzip or deflate
39
46
  * compression (if the client supports it).
40
47
  *
48
+ * @remarks
49
+ * Note: Whilst compression can
50
+ *
41
51
  * @defaultValue false
42
52
  */
43
53
  compress: boolean;
@@ -61,10 +71,10 @@ export interface StaticOpts<CTX extends RequestCtx = RequestCtx> {
61
71
  *
62
72
  * @param opts
63
73
  */
64
- export declare const staticFiles: <CTX extends RequestCtx = RequestCtx>({ prefix, rootDir, intercept, filter, compress, auth, etag, headers, }?: Partial<StaticOpts<CTX>>) => ServerRoute<CTX>;
74
+ export declare const staticFiles: <CTX extends RequestCtx = RequestCtx>({ prefix, rootDir, intercept, filters, compress, auth, etag, headers, }?: Partial<StaticOpts<CTX>>) => ServerRoute<CTX>;
65
75
  /**
66
76
  * Etag header value function for {@link StaticOpts.etag}. Computes Etag based
67
- * on file modified date.
77
+ * on base36-encoded file modified timestamp.
68
78
  *
69
79
  * @remarks
70
80
  * Also see {@link etagFileHash}.
@@ -82,4 +92,20 @@ export declare const etagFileTimeModified: (path: string) => string;
82
92
  * @param algo
83
93
  */
84
94
  export declare const etagFileHash: (algo?: HashAlgo) => (path: string) => Promise<string>;
95
+ /**
96
+ * Default path filter predicate for {@link StaticOpts.filter}. Rejects a file
97
+ * path which contains non-printable ASCII chars (i.e. outside the
98
+ * `[0x20,0x7fe]` range).
99
+ *
100
+ * @param path
101
+ */
102
+ export declare const pathFilterASCII: (path: string) => boolean;
103
+ /**
104
+ * Higher order path filter predicate for {@link StaticOpts.filter}. The
105
+ * returned predicate rejects an absolute file path if its length exceeds given
106
+ * `limit`.
107
+ *
108
+ * @param limit
109
+ */
110
+ export declare const pathMaxLength: (limit: number) => Predicate<string>;
85
111
  //# sourceMappingURL=static.d.ts.map
package/static.js CHANGED
@@ -1,71 +1,71 @@
1
+ import { isFunction, isString } from "@thi.ng/checks";
1
2
  import { fileHash as $fileHash } from "@thi.ng/file-io";
2
3
  import { preferredTypeForPath } from "@thi.ng/mime";
3
4
  import { existsSync, statSync } from "node:fs";
4
- import { join } from "node:path";
5
+ import { join, resolve } from "node:path";
5
6
  import { isUnmodified } from "./utils/cache.js";
6
7
  const staticFiles = ({
7
8
  prefix = "static",
8
9
  rootDir = ".",
9
10
  intercept = [],
10
- filter = () => true,
11
+ filters = [pathFilterASCII],
11
12
  compress = false,
12
13
  auth = false,
13
14
  etag,
14
15
  headers
15
- } = {}) => ({
16
- id: "__static",
17
- match: [prefix, "+"],
18
- auth,
19
- handlers: {
20
- head: {
21
- fn: async (ctx) => {
22
- const path = join(rootDir, ...ctx.match.rest);
23
- const $headers = await __fileHeaders(
24
- path,
25
- ctx,
26
- filter,
27
- etag,
28
- headers
29
- );
30
- if (!$headers) return;
31
- ctx.res.writeHead(200, {
32
- "content-type": preferredTypeForPath(path),
33
- ...$headers
34
- });
35
- },
36
- intercept
37
- },
38
- get: {
39
- fn: async (ctx) => {
40
- const path = join(rootDir, ...ctx.match.rest);
41
- const $headers = await __fileHeaders(
42
- path,
43
- ctx,
44
- filter,
45
- etag,
46
- headers
47
- );
48
- if (!$headers) return;
49
- return ctx.server.sendFile(ctx, path, $headers, compress);
50
- },
51
- intercept
16
+ } = {}) => {
17
+ rootDir = resolve(rootDir);
18
+ const filter = (path) => filters.every((f) => f(path));
19
+ return {
20
+ id: "__static",
21
+ match: isString(prefix) ? prefix + "/+" : [...prefix, "+"],
22
+ auth,
23
+ handlers: {
24
+ get: {
25
+ fn: async (ctx) => {
26
+ const path = resolve(join(rootDir, ...ctx.match.rest));
27
+ const $headers = await __fileHeaders(
28
+ rootDir,
29
+ path,
30
+ ctx,
31
+ filter,
32
+ etag,
33
+ headers
34
+ );
35
+ if (!$headers) return;
36
+ if (ctx.origMethod === "head") {
37
+ ctx.res.writeHead(200, {
38
+ "content-type": preferredTypeForPath(path),
39
+ ...$headers
40
+ });
41
+ return;
42
+ }
43
+ return ctx.server.sendFile(ctx, path, $headers, compress);
44
+ },
45
+ intercept
46
+ }
52
47
  }
53
- }
54
- });
55
- const __fileHeaders = async (path, ctx, filter, etag, headers) => {
56
- if (!(existsSync(path) && filter(path))) {
48
+ };
49
+ };
50
+ const __fileHeaders = async (rootDir, path, ctx, filter, etag, headers) => {
51
+ if (!(path.length > rootDir.length && filter(path) && existsSync(path))) {
57
52
  return ctx.res.missing();
58
53
  }
54
+ const $headers = isFunction(headers) ? headers(path) : headers;
59
55
  if (etag) {
60
56
  const etagValue = await etag(path);
61
- return isUnmodified(etagValue, ctx.req.headers["if-none-match"]) ? ctx.res.unmodified() : { ...headers, etag: etagValue };
57
+ return isUnmodified(etagValue, ctx.req.headers["if-none-match"]) ? ctx.res.unmodified() : { ...$headers, etag: etagValue };
62
58
  }
63
- return { ...headers };
59
+ return { ...$headers };
64
60
  };
65
- const etagFileTimeModified = (path) => String(statSync(path).mtimeMs);
61
+ const etagFileTimeModified = (path) => statSync(path).mtimeMs.toString(36);
66
62
  const etagFileHash = (algo = "md5") => (path) => $fileHash(path, void 0, algo);
63
+ const pathFilterASCII = (path) => !/[^\x20-\x7e]/.test(path);
64
+ const pathMaxLength = (limit) => (path) => path.length < limit;
67
65
  export {
68
66
  etagFileHash,
69
67
  etagFileTimeModified,
68
+ pathFilterASCII,
69
+ pathMaxLength,
70
70
  staticFiles
71
71
  };