@thi.ng/server 0.1.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/server.js ADDED
@@ -0,0 +1,213 @@
1
+ import { isFunction } from "@thi.ng/checks";
2
+ import { readText } from "@thi.ng/file-io";
3
+ import { ConsoleLogger } from "@thi.ng/logger";
4
+ import { preferredTypeForPath } from "@thi.ng/mime";
5
+ import { Router } from "@thi.ng/router";
6
+ import { createReadStream } from "node:fs";
7
+ import * as http from "node:http";
8
+ import * as https from "node:https";
9
+ import { pipeline, Transform } from "node:stream";
10
+ import { createBrotliCompress, createDeflate, createGzip } from "node:zlib";
11
+ import { parseCoookies } from "./utils/cookies.js";
12
+ import { parseSearchParams } from "./utils/formdata.js";
13
+ const MISSING = "__missing";
14
+ class Server {
15
+ constructor(opts = {}) {
16
+ this.opts = opts;
17
+ this.logger = opts.logger ?? new ConsoleLogger("server");
18
+ const routes = [
19
+ {
20
+ id: MISSING,
21
+ match: ["__404__"],
22
+ handlers: {
23
+ get: async ({ res }) => this.missing(res)
24
+ }
25
+ },
26
+ ...this.opts.routes ?? []
27
+ ];
28
+ this.router = new Router({
29
+ default: MISSING,
30
+ prefix: opts.prefix ?? "/",
31
+ trim: opts.trim ?? true,
32
+ routes: routes.map(this.compileRoute.bind(this))
33
+ });
34
+ }
35
+ logger;
36
+ router;
37
+ server;
38
+ async start() {
39
+ const ssl = this.opts.ssl;
40
+ const port = this.opts.port ?? (ssl ? 443 : 8080);
41
+ const host = this.opts.host ?? "localhost";
42
+ try {
43
+ this.server = ssl ? https.createServer(
44
+ {
45
+ key: readText(ssl.key, this.logger),
46
+ cert: readText(ssl.cert, this.logger)
47
+ },
48
+ this.listener.bind(this)
49
+ ) : http.createServer({}, this.listener.bind(this));
50
+ this.server.listen(port, host, void 0, () => {
51
+ this.logger.info(
52
+ `starting server: http${ssl ? "s" : ""}://${host}:${port}`
53
+ );
54
+ });
55
+ return true;
56
+ } catch (e) {
57
+ this.logger.severe(e);
58
+ return false;
59
+ }
60
+ }
61
+ async stop() {
62
+ if (this.server) {
63
+ this.logger.info(`stopping server...`);
64
+ this.server.close();
65
+ }
66
+ return true;
67
+ }
68
+ async listener(req, res) {
69
+ const url = new URL(req.url, `http://${req.headers.host}`);
70
+ if (this.opts.host && this.opts.host !== url.host) {
71
+ res.writeHead(503).end();
72
+ return;
73
+ }
74
+ const path = decodeURIComponent(url.pathname);
75
+ try {
76
+ const query = parseSearchParams(url.searchParams);
77
+ const match = this.router.route(path);
78
+ const route = this.router.routeForID(match.id).spec;
79
+ const rawCookies = req.headers["set-cookie"]?.join(";");
80
+ const cookies = rawCookies ? parseCoookies(rawCookies) : {};
81
+ const ctx = {
82
+ server: this,
83
+ logger: this.logger,
84
+ req,
85
+ res,
86
+ path,
87
+ query,
88
+ cookies,
89
+ route,
90
+ match
91
+ };
92
+ if (match.id === MISSING) {
93
+ this.runHandler(route.handlers.get, ctx);
94
+ return;
95
+ }
96
+ let method = ctx.query?.__method || req.method.toLowerCase();
97
+ if (method === "head" && !route.handlers.head && route.handlers.get) {
98
+ method = "get";
99
+ }
100
+ const handler = route.handlers[method];
101
+ if (handler) {
102
+ this.runHandler(handler, ctx);
103
+ } else {
104
+ res.writeHead(405).end();
105
+ }
106
+ } catch (e) {
107
+ this.logger.warn("error:", e.message);
108
+ res.writeHead(500).end();
109
+ }
110
+ }
111
+ async runHandler({ fn, pre, post }, ctx) {
112
+ const runPhase = async (fns) => {
113
+ for (let f of fns) {
114
+ if (!await f(ctx)) {
115
+ ctx.res.end();
116
+ return false;
117
+ }
118
+ }
119
+ return true;
120
+ };
121
+ try {
122
+ if (pre && !await runPhase(pre)) return;
123
+ await fn(ctx);
124
+ if (post && !await runPhase(post)) return;
125
+ ctx.res.end();
126
+ } catch (e) {
127
+ this.logger.warn(`handler error:`, e);
128
+ if (!ctx.res.headersSent) {
129
+ ctx.res.writeHead(500).end();
130
+ }
131
+ }
132
+ }
133
+ compileRoute(route) {
134
+ const compilePhase = (handler, phase) => {
135
+ const fns = [];
136
+ for (let x of this.opts.intercept ?? []) {
137
+ if (x[phase]) fns.push(x[phase].bind(x));
138
+ }
139
+ if (!isFunction(handler)) {
140
+ for (let x of handler.intercept ?? []) {
141
+ if (x[phase]) fns.push(x[phase].bind(x));
142
+ }
143
+ }
144
+ return fns.length ? phase === "post" ? fns.reverse() : fns : void 0;
145
+ };
146
+ const result = { ...route, handlers: {} };
147
+ for (let method in route.handlers) {
148
+ const handler = route.handlers[method];
149
+ result.handlers[method] = {
150
+ fn: isFunction(handler) ? handler : handler.fn,
151
+ pre: compilePhase(handler, "pre"),
152
+ post: compilePhase(handler, "post")
153
+ };
154
+ }
155
+ return result;
156
+ }
157
+ addRoutes(routes) {
158
+ for (let r of routes) {
159
+ this.logger.debug("registering route:", r.id, r.match);
160
+ }
161
+ this.router.addRoutes(routes.map(this.compileRoute.bind(this)));
162
+ this.logger.debug(this.router.index);
163
+ }
164
+ sendFile({ req, res }, path, headers, compress = false) {
165
+ const mime = headers?.["content-type"] ?? preferredTypeForPath(path);
166
+ const accept = req.headers["accept-encoding"];
167
+ const encoding = compress && accept ? [
168
+ { mode: "br", tx: createBrotliCompress },
169
+ { mode: "gzip", tx: createGzip },
170
+ { mode: "deflate", tx: createDeflate }
171
+ ].find((x) => accept.includes(x.mode)) : void 0;
172
+ return new Promise((resolve) => {
173
+ try {
174
+ this.logger.debug("sending file:", path, "mime:", mime, accept);
175
+ const src = createReadStream(path);
176
+ const mergedHeaders = { "content-type": mime, ...headers };
177
+ if (encoding) {
178
+ mergedHeaders["content-encoding"] = encoding.mode;
179
+ }
180
+ res.writeHead(200, mergedHeaders);
181
+ const finalize = (err) => {
182
+ if (err) res.end();
183
+ resolve();
184
+ };
185
+ encoding ? pipeline(src, encoding.tx(), res, finalize) : pipeline(src, res, finalize);
186
+ } catch (e) {
187
+ this.logger.warn(e.message);
188
+ this.missing(res);
189
+ resolve();
190
+ }
191
+ });
192
+ }
193
+ unauthorized(res) {
194
+ res.writeHead(403, "Forbidden").end();
195
+ }
196
+ unmodified(res) {
197
+ res.writeHead(304, "Not modified").end();
198
+ }
199
+ missing(res) {
200
+ res.writeHead(404, "Not found").end();
201
+ }
202
+ redirectTo(res, location) {
203
+ res.writeHead(302, { location }).end();
204
+ }
205
+ redirectToRoute(res, route) {
206
+ this.redirectTo(res, this.router.format(route));
207
+ }
208
+ }
209
+ const server = (opts) => new Server(opts);
210
+ export {
211
+ Server,
212
+ server
213
+ };
package/static.d.ts ADDED
@@ -0,0 +1,78 @@
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, ServerRoute } from "./api.js";
5
+ /**
6
+ * Static file configuration options.
7
+ */
8
+ export interface StaticOpts {
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[];
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
+ /**
51
+ * Defines a configurable {@link ServerRoute} and handler for serving static
52
+ * files from a local directory (optionally with compression, see
53
+ * {@link StaticOpts.compress} for details).
54
+ *
55
+ * @param opts
56
+ */
57
+ export declare const staticFiles: ({ prefix, rootDir, intercept, filter, compress, etag, headers, }?: Partial<StaticOpts>) => ServerRoute;
58
+ /**
59
+ * Etag header value function for {@link StaticOpts.etag}. Computes Etag based
60
+ * on file modified date.
61
+ *
62
+ * @remarks
63
+ * Also see {@link etagFileHash}.
64
+ *
65
+ * @param path
66
+ */
67
+ export declare const etagFileTimeModified: (path: string) => string;
68
+ /**
69
+ * Higher-order Etag header value function for {@link StaticOpts.etag}. Computes
70
+ * Etag value by computing the hash digest of a given file. Uses MD5 by default.
71
+ *
72
+ * @remarks
73
+ * Also see {@link etagFileTimeModified}.
74
+ *
75
+ * @param algo
76
+ */
77
+ export declare const etagFileHash: (algo?: HashAlgo) => (path: string) => Promise<string>;
78
+ //# sourceMappingURL=static.d.ts.map
package/static.js ADDED
@@ -0,0 +1,69 @@
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
+ etag,
13
+ headers
14
+ } = {}) => ({
15
+ id: "__static",
16
+ match: [prefix, "+"],
17
+ handlers: {
18
+ head: {
19
+ fn: async (ctx) => {
20
+ const path = join(rootDir, ...ctx.match.rest);
21
+ const $headers = await __fileHeaders(
22
+ path,
23
+ ctx,
24
+ filter,
25
+ etag,
26
+ headers
27
+ );
28
+ if (!$headers) return;
29
+ ctx.res.writeHead(200, {
30
+ "content-type": preferredTypeForPath(path),
31
+ ...$headers
32
+ });
33
+ },
34
+ intercept
35
+ },
36
+ get: {
37
+ fn: async (ctx) => {
38
+ const path = join(rootDir, ...ctx.match.rest);
39
+ const $headers = await __fileHeaders(
40
+ path,
41
+ ctx,
42
+ filter,
43
+ etag,
44
+ headers
45
+ );
46
+ if (!$headers) return;
47
+ return ctx.server.sendFile(ctx, path, $headers, compress);
48
+ },
49
+ intercept
50
+ }
51
+ }
52
+ });
53
+ const __fileHeaders = async (path, ctx, filter, etag, headers) => {
54
+ if (!(existsSync(path) && filter(path))) {
55
+ return ctx.server.missing(ctx.res);
56
+ }
57
+ if (etag) {
58
+ const etagValue = await etag(path);
59
+ return isUnmodified(etagValue, ctx.req.headers["if-none-match"]) ? ctx.server.unmodified(ctx.res) : { ...headers, etag: etagValue };
60
+ }
61
+ return { ...headers };
62
+ };
63
+ const etagFileTimeModified = (path) => String(statSync(path).mtimeMs);
64
+ const etagFileHash = (algo = "md5") => (path) => $fileHash(path, void 0, algo);
65
+ export {
66
+ etagFileHash,
67
+ etagFileTimeModified,
68
+ staticFiles
69
+ };
@@ -0,0 +1,2 @@
1
+ export declare const isUnmodified: (etag: string, header?: string) => boolean;
2
+ //# sourceMappingURL=cache.d.ts.map
package/utils/cache.js ADDED
@@ -0,0 +1,4 @@
1
+ const isUnmodified = (etag, header) => header ? header.includes(",") ? header.split(/,\s+/g).some((x) => x === etag) : header === etag : false;
2
+ export {
3
+ isUnmodified
4
+ };
@@ -0,0 +1,7 @@
1
+ export interface ParseCookieOpts {
2
+ domain: string;
3
+ path: string;
4
+ now: number;
5
+ }
6
+ export declare const parseCoookies: (rawCookies?: string, { domain, path, now, }?: Partial<ParseCookieOpts>) => Record<string, string>;
7
+ //# sourceMappingURL=cookies.d.ts.map
@@ -0,0 +1,43 @@
1
+ const parseCoookies = (rawCookies = "", {
2
+ domain = "localhost",
3
+ path = "/",
4
+ now = Date.now()
5
+ } = {}) => {
6
+ const res = {};
7
+ let currK;
8
+ let currV;
9
+ for (let part of rawCookies.split(";")) {
10
+ let [name, value] = part.split("=").map((x) => x.trim());
11
+ value = decodeURIComponent(value);
12
+ switch (name.toLowerCase()) {
13
+ case "domain":
14
+ if (value !== "." && !domain.endsWith(value)) currK = null;
15
+ break;
16
+ case "expires":
17
+ if (Date.parse(value) < now) currK = null;
18
+ break;
19
+ case "path":
20
+ if (!path.startsWith(value)) currK = null;
21
+ break;
22
+ case "maxage":
23
+ case "httponly":
24
+ case "priority":
25
+ case "samesite":
26
+ case "secure":
27
+ break;
28
+ default:
29
+ if (currK && currV) {
30
+ res[currK] = currV;
31
+ }
32
+ currK = name;
33
+ currV = value;
34
+ }
35
+ }
36
+ if (currK && currV) {
37
+ res[currK] = currV;
38
+ }
39
+ return res;
40
+ };
41
+ export {
42
+ parseCoookies
43
+ };
@@ -0,0 +1,6 @@
1
+ import type { IncomingMessage } from "node:http";
2
+ export declare const parseSearchParams: (params: URLSearchParams) => any;
3
+ export declare const parseQuerystring: (url: string) => Record<string, any>;
4
+ export declare const parseRequestFormData: (req: IncomingMessage) => Promise<Record<string, any>>;
5
+ export declare const parseFormData: (body: string) => Record<string, any>;
6
+ //# sourceMappingURL=formdata.d.ts.map
@@ -0,0 +1,57 @@
1
+ import { isArray, isProtoPath } from "@thi.ng/checks";
2
+ import { illegalArgs } from "@thi.ng/errors";
3
+ import { setInUnsafe, updateInUnsafe } from "@thi.ng/paths";
4
+ const parseSearchParams = (params) => {
5
+ const acc = {};
6
+ for (let [k, v] of params) {
7
+ if (k.includes("[")) return parseObjectVal(acc, k, v);
8
+ if (!isProtoPath(k)) acc[k] = v;
9
+ }
10
+ return acc;
11
+ };
12
+ const parseQuerystring = (url) => {
13
+ const idx = url.indexOf("?");
14
+ return idx >= 0 ? parseFormData(url.substring(idx + 1)) : {};
15
+ };
16
+ const parseRequestFormData = async (req) => {
17
+ let body = "";
18
+ for await (let chunk of req) body += chunk.toString();
19
+ return parseFormData(body);
20
+ };
21
+ const parseFormData = (body) => body.split("&").reduce((acc, x) => {
22
+ x = decodeURIComponent(x.replace(/\+/g, " "));
23
+ const idx = x.indexOf("=");
24
+ if (idx < 1) return acc;
25
+ const k = x.substring(0, idx);
26
+ const v = x.substring(idx + 1);
27
+ if (k.includes("[")) return parseObjectVal(acc, k, v);
28
+ if (!isProtoPath(k)) acc[k] = v;
29
+ return acc;
30
+ }, {});
31
+ const parseObjectVal = (acc, key, val) => {
32
+ const parts = key.split("[");
33
+ if (!parts[0]) __illegal(key);
34
+ const path = [parts[0]];
35
+ for (let i = 1, n = parts.length - 1; i <= n; i++) {
36
+ const p = parts[i];
37
+ if (p === "]") {
38
+ if (i < n) __illegal(key);
39
+ return !isProtoPath(path) ? updateInUnsafe(
40
+ acc,
41
+ path,
42
+ (curr) => isArray(curr) ? [...curr, val] : [val]
43
+ ) : acc;
44
+ }
45
+ const idx = p.indexOf("]");
46
+ if (idx < 0 || idx < p.length - 1) __illegal(key);
47
+ path.push(p.substring(0, p.length - 1));
48
+ }
49
+ return !isProtoPath(path) ? setInUnsafe(acc, path, val) : acc;
50
+ };
51
+ const __illegal = (key) => illegalArgs("invalid form param: " + key);
52
+ export {
53
+ parseFormData,
54
+ parseQuerystring,
55
+ parseRequestFormData,
56
+ parseSearchParams
57
+ };
@@ -0,0 +1,11 @@
1
+ import type { IncomingMessage } from "node:http";
2
+ import type { Readable } from "node:stream";
3
+ export interface MultipartPart {
4
+ headers: Record<string, string>;
5
+ body: string | Uint8Array;
6
+ }
7
+ export declare const parseRequestMultipartData: (req: IncomingMessage) => AsyncGenerator<MultipartPart, void, unknown>;
8
+ export declare function parseMultipartData(req: Readable, boundary: string): AsyncGenerator<MultipartPart, void, unknown>;
9
+ export declare const chunkParser: (boundary: string) => (chunk: Buffer) => Generator<MultipartPart, void, unknown>;
10
+ export declare const parsePart: (buf: Uint8Array) => MultipartPart;
11
+ //# sourceMappingURL=multipart.d.ts.map
@@ -0,0 +1,63 @@
1
+ import { findSequence } from "@thi.ng/arrays";
2
+ import { illegalArgs } from "@thi.ng/errors";
3
+ const DECODER = new TextDecoder();
4
+ const parseRequestMultipartData = (req) => {
5
+ const ctype = req.headers["content-type"];
6
+ if (!ctype) illegalArgs("missing content-type");
7
+ const boundary = /boundary=([a-z0-9-]+)/i.exec(ctype);
8
+ if (!boundary) illegalArgs("missing boundary");
9
+ return parseMultipartData(req, boundary[1]);
10
+ };
11
+ async function* parseMultipartData(req, boundary) {
12
+ const parser = chunkParser(boundary);
13
+ for await (let chunk of req) yield* parser(chunk);
14
+ }
15
+ const chunkParser = (boundary) => {
16
+ const boundaryBytes = new TextEncoder().encode(boundary);
17
+ let buf = new Uint8Array(0);
18
+ let from = 0;
19
+ return function* (chunk) {
20
+ const $buf = new Uint8Array(buf.length + chunk.length);
21
+ $buf.set(buf);
22
+ $buf.set(chunk, buf.length);
23
+ buf = $buf;
24
+ while (buf.length) {
25
+ const idx = findSequence(buf, boundaryBytes, from);
26
+ if (idx < 0) {
27
+ from = Math.max(buf.length - boundaryBytes.length + 1, 0);
28
+ return;
29
+ }
30
+ if (idx >= 4) {
31
+ yield parsePart(buf.slice(2, idx - 4));
32
+ }
33
+ buf = buf.slice(idx + boundaryBytes.length);
34
+ from = 0;
35
+ }
36
+ };
37
+ };
38
+ const MPART_SEP = [13, 10, 13, 10];
39
+ const parsePart = (buf) => {
40
+ const idx = findSequence(buf, MPART_SEP);
41
+ if (idx < 0) illegalArgs("invalid multipart data");
42
+ const headers = DECODER.decode(buf.slice(0, idx)).split("\r\n").reduce((acc, x) => {
43
+ const idx2 = x.indexOf(":");
44
+ let key = x.substring(0, idx2).toLowerCase();
45
+ let val = x.substring(idx2 + 1).trim();
46
+ if (key === "content-disposition") {
47
+ const name = /name="([a-z0-9_.-]{1,64})"/i.exec(val);
48
+ if (name) acc["name"] = name[1];
49
+ const filename = /filename="([a-z0-9_.+\-~@ ()\[\]]{1,128})"/i.exec(val);
50
+ if (filename) acc["filename"] = filename[1];
51
+ }
52
+ acc[key] = val;
53
+ return acc;
54
+ }, {});
55
+ const body = buf.slice(idx + 4);
56
+ return headers["content-type"] ? { headers, body } : { headers, body: DECODER.decode(body) };
57
+ };
58
+ export {
59
+ chunkParser,
60
+ parseMultipartData,
61
+ parsePart,
62
+ parseRequestMultipartData
63
+ };