@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/CHANGELOG.md +42 -0
- package/LICENSE +201 -0
- package/README.md +235 -0
- package/api.d.ts +114 -0
- package/api.js +0 -0
- package/index.d.ts +17 -0
- package/index.js +16 -0
- package/interceptors/auth-route.d.ts +18 -0
- package/interceptors/auth-route.js +12 -0
- package/interceptors/cache-control.d.ts +25 -0
- package/interceptors/cache-control.js +23 -0
- package/interceptors/inject-headers.d.ts +8 -0
- package/interceptors/inject-headers.js +11 -0
- package/interceptors/logging.d.ts +19 -0
- package/interceptors/logging.js +30 -0
- package/interceptors/referrer-policy.d.ts +11 -0
- package/interceptors/referrer-policy.js +6 -0
- package/interceptors/session.d.ts +51 -0
- package/interceptors/session.js +38 -0
- package/interceptors/strict-transport.d.ts +11 -0
- package/interceptors/strict-transport.js +9 -0
- package/interceptors/x-origin-opener.d.ts +11 -0
- package/interceptors/x-origin-opener.js +6 -0
- package/interceptors/x-origin-resource.d.ts +11 -0
- package/interceptors/x-origin-resource.js +6 -0
- package/package.json +153 -0
- package/server.d.ts +25 -0
- package/server.js +213 -0
- package/static.d.ts +78 -0
- package/static.js +69 -0
- package/utils/cache.d.ts +2 -0
- package/utils/cache.js +4 -0
- package/utils/cookies.d.ts +7 -0
- package/utils/cookies.js +43 -0
- package/utils/formdata.d.ts +6 -0
- package/utils/formdata.js +57 -0
- package/utils/multipart.d.ts +11 -0
- package/utils/multipart.js +63 -0
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
|
+
};
|
package/utils/cache.d.ts
ADDED
package/utils/cache.js
ADDED
package/utils/cookies.js
ADDED
|
@@ -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
|
+
};
|