@procedures/peer 0.52.2 → 0.53.1
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 +47 -0
- package/README.md +31 -0
- package/dist/.tsbuildinfo +1 -1
- package/dist/host/index.d.ts +1 -0
- package/dist/host/index.d.ts.map +1 -1
- package/dist/host/index.js +1 -0
- package/dist/host/index.js.map +1 -1
- package/dist/host/static-face.d.ts +59 -0
- package/dist/host/static-face.d.ts.map +1 -0
- package/dist/host/static-face.js +234 -0
- package/dist/host/static-face.js.map +1 -0
- package/dist/index.d.ts +2 -2
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +1 -1
- package/dist/index.js.map +1 -1
- package/package.json +8 -6
- package/src/host/index.ts +1 -0
- package/src/host/static-face.ts +264 -0
- package/src/index.ts +4 -0
|
@@ -0,0 +1,264 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The static face: the built files of a page (for example the `dist` folder of Vite) on the listener
|
|
3
|
+
* of a procedures host. Thus an app with a page needs no second server and no route code of its own.
|
|
4
|
+
*
|
|
5
|
+
* - The face takes each `GET` and `HEAD` request under its base path. Put it last in the faces of the
|
|
6
|
+
* listener. The first face that takes a request serves it, so the procedure faces keep their paths.
|
|
7
|
+
* - Each path stays in the root. A path that leaves the root, also through a symbolic link, gets 404.
|
|
8
|
+
* - A path with no file and no extension is a route of the page. A request that accepts `text/html`
|
|
9
|
+
* gets the fallback file (`index.html`), and the router of the page shows the route. Another
|
|
10
|
+
* request gets 404, so a probe or a client of data does not get a page.
|
|
11
|
+
* - Each response has a strict frame policy by default: only a page of the same origin can show the
|
|
12
|
+
* files in a frame (`DEFAULT_STATIC_HEADERS`). A header of the options replaces the default header
|
|
13
|
+
* of the same name. Each response also has `X-Content-Type-Options: nosniff`. An `ETag` lets the
|
|
14
|
+
* browser keep a file until it changes.
|
|
15
|
+
* - A path with a segment that starts with `.` gets 404, so `.env` or `.git` of a root stay hidden.
|
|
16
|
+
* Only `.well-known` is served.
|
|
17
|
+
* - A root that is missing gives 404. The next request reads the root again, so a later build serves.
|
|
18
|
+
* - Each request goes through the request tracking of the host (`track`, CLI-54), so the drain waits for it.
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
import { createReadStream } from "node:fs";
|
|
22
|
+
import { realpath, stat } from "node:fs/promises";
|
|
23
|
+
import type { IncomingMessage, ServerResponse } from "node:http";
|
|
24
|
+
import { extname, join, relative, resolve } from "node:path";
|
|
25
|
+
import { isInside } from "@mark1russell7/files";
|
|
26
|
+
import type { HostFaceFactory } from "./faces.js";
|
|
27
|
+
|
|
28
|
+
/** The options of `staticFace()`. */
|
|
29
|
+
export interface StaticFaceOptions {
|
|
30
|
+
/** The folder of the files. A relative path is relative to the working folder of the process. */
|
|
31
|
+
readonly root: string;
|
|
32
|
+
/** The path under which the face serves the files. The default is `/`. */
|
|
33
|
+
readonly basePath?: string | undefined;
|
|
34
|
+
/**
|
|
35
|
+
* The file, in the root, for a path with no file and no extension: a route of the page. Only a
|
|
36
|
+
* request that accepts `text/html` gets it. The default is `index.html`. `false` gives 404 for such a path.
|
|
37
|
+
*/
|
|
38
|
+
readonly fallback?: string | false | undefined;
|
|
39
|
+
/**
|
|
40
|
+
* The headers of each response of the face, for example `Content-Security-Policy`. A header of
|
|
41
|
+
* this record replaces the header of `DEFAULT_STATIC_HEADERS` with the same name, in any case.
|
|
42
|
+
*/
|
|
43
|
+
readonly headers?: Readonly<Record<string, string>> | undefined;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* The default headers of each response of the static face. Only a page of the same origin can show
|
|
48
|
+
* the files in a frame. The CSP rule is for the current browsers, and `X-Frame-Options` is for the
|
|
49
|
+
* old browsers.
|
|
50
|
+
*/
|
|
51
|
+
export const DEFAULT_STATIC_HEADERS: Readonly<Record<string, string>> = Object.freeze({
|
|
52
|
+
"Content-Security-Policy": "frame-ancestors 'self'",
|
|
53
|
+
"X-Frame-Options": "SAMEORIGIN",
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
/** This function gives the default headers with the headers of the options. A name of the options replaces the default name in any case. */
|
|
57
|
+
function headersWith(given: Readonly<Record<string, string>>): Readonly<Record<string, string>> {
|
|
58
|
+
const names = new Set(Object.keys(given).map((name) => name.toLowerCase()));
|
|
59
|
+
const kept = Object.entries(DEFAULT_STATIC_HEADERS).filter(([name]) => !names.has(name.toLowerCase()));
|
|
60
|
+
return { ...Object.fromEntries(kept), ...given };
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/** This function tells if a path has a hidden segment: a segment that starts with `.`, except `.well-known`. */
|
|
64
|
+
function hidden(path: string): boolean {
|
|
65
|
+
return path.split(/[\\/]/).some((segment) => segment.startsWith(".") && segment !== ".well-known");
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/** The content type of each extension that a built page has. Another extension gets `application/octet-stream`. */
|
|
69
|
+
const CONTENT_TYPES: Readonly<Record<string, string>> = {
|
|
70
|
+
".html": "text/html; charset=utf-8",
|
|
71
|
+
".js": "text/javascript; charset=utf-8",
|
|
72
|
+
".mjs": "text/javascript; charset=utf-8",
|
|
73
|
+
".css": "text/css; charset=utf-8",
|
|
74
|
+
".json": "application/json; charset=utf-8",
|
|
75
|
+
".map": "application/json; charset=utf-8",
|
|
76
|
+
".webmanifest": "application/manifest+json; charset=utf-8",
|
|
77
|
+
".txt": "text/plain; charset=utf-8",
|
|
78
|
+
".xml": "application/xml; charset=utf-8",
|
|
79
|
+
".svg": "image/svg+xml",
|
|
80
|
+
".png": "image/png",
|
|
81
|
+
".jpg": "image/jpeg",
|
|
82
|
+
".jpeg": "image/jpeg",
|
|
83
|
+
".gif": "image/gif",
|
|
84
|
+
".webp": "image/webp",
|
|
85
|
+
".avif": "image/avif",
|
|
86
|
+
".ico": "image/x-icon",
|
|
87
|
+
".woff": "font/woff",
|
|
88
|
+
".woff2": "font/woff2",
|
|
89
|
+
".ttf": "font/ttf",
|
|
90
|
+
".wasm": "application/wasm",
|
|
91
|
+
};
|
|
92
|
+
|
|
93
|
+
/** This function gives the content type of a file from its extension. */
|
|
94
|
+
export function contentTypeOf(path: string): string {
|
|
95
|
+
return CONTENT_TYPES[extname(path).toLowerCase()] ?? "application/octet-stream";
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/** A file to send: its real path, its size and its tag. */
|
|
99
|
+
interface Found {
|
|
100
|
+
readonly path: string;
|
|
101
|
+
readonly size: number;
|
|
102
|
+
readonly etag: string;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/** This function gives the file at a path in the root, or `undefined`. A folder gives its `index.html`. */
|
|
106
|
+
async function fileIn(root: string, path: string): Promise<Found | undefined> {
|
|
107
|
+
try {
|
|
108
|
+
let real = await realpath(resolve(root, `.${path}`));
|
|
109
|
+
// A symbolic link can point out of the root, or to a hidden file in it: the real path must stay in it, and not hidden
|
|
110
|
+
if (!isInside(root, real) || hidden(relative(root, real))) return undefined;
|
|
111
|
+
let info = await stat(real);
|
|
112
|
+
if (info.isDirectory()) {
|
|
113
|
+
real = await realpath(join(real, "index.html"));
|
|
114
|
+
if (!isInside(root, real) || hidden(relative(root, real))) return undefined;
|
|
115
|
+
info = await stat(real);
|
|
116
|
+
}
|
|
117
|
+
if (!info.isFile()) return undefined;
|
|
118
|
+
return { path: real, size: info.size, etag: `"${info.size.toString(36)}-${Math.floor(info.mtimeMs).toString(36)}"` };
|
|
119
|
+
} catch {
|
|
120
|
+
return undefined;
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/** This function gives the path of a request under the base path. A path out of the base path gives `undefined`. */
|
|
125
|
+
function pathUnder(url: string | undefined, basePath: string): string | undefined {
|
|
126
|
+
const raw = (url ?? "/").split("?")[0]!.split("#")[0]!;
|
|
127
|
+
if (raw !== basePath && !raw.startsWith(basePath === "/" ? "/" : `${basePath}/`)) return undefined;
|
|
128
|
+
const rest = basePath === "/" ? raw : raw.slice(basePath.length) || "/";
|
|
129
|
+
return rest;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/** This function tells if a request accepts a page, as the navigation of a browser does. */
|
|
133
|
+
function acceptsPage(req: IncomingMessage): boolean {
|
|
134
|
+
return (req.headers.accept ?? "").toLowerCase().includes("text/html");
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/** This function writes a short text answer. */
|
|
138
|
+
function answer(res: ServerResponse, status: number, text: string, headers: Readonly<Record<string, string>>): void {
|
|
139
|
+
res.writeHead(status, { ...headers, "Content-Type": "text/plain; charset=utf-8", "X-Content-Type-Options": "nosniff" });
|
|
140
|
+
res.end(text);
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
/** This function sends a file, or 304 when the browser has the same file. */
|
|
144
|
+
function send(req: IncomingMessage, res: ServerResponse, file: Found, headers: Readonly<Record<string, string>>): Promise<void> {
|
|
145
|
+
const common = {
|
|
146
|
+
...headers,
|
|
147
|
+
"Content-Type": contentTypeOf(file.path),
|
|
148
|
+
"X-Content-Type-Options": "nosniff",
|
|
149
|
+
"Cache-Control": "no-cache",
|
|
150
|
+
ETag: file.etag,
|
|
151
|
+
};
|
|
152
|
+
if (req.headers["if-none-match"] === file.etag) {
|
|
153
|
+
res.writeHead(304, common);
|
|
154
|
+
res.end();
|
|
155
|
+
return Promise.resolve();
|
|
156
|
+
}
|
|
157
|
+
res.writeHead(200, { ...common, "Content-Length": String(file.size) });
|
|
158
|
+
if (req.method === "HEAD") {
|
|
159
|
+
res.end();
|
|
160
|
+
return Promise.resolve();
|
|
161
|
+
}
|
|
162
|
+
return new Promise((done, fail) => {
|
|
163
|
+
const stream = createReadStream(file.path);
|
|
164
|
+
stream.on("error", (error) => {
|
|
165
|
+
res.destroy(error);
|
|
166
|
+
fail(error);
|
|
167
|
+
});
|
|
168
|
+
res.on("close", () => {
|
|
169
|
+
stream.destroy();
|
|
170
|
+
done();
|
|
171
|
+
});
|
|
172
|
+
stream.pipe(res);
|
|
173
|
+
});
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
/** This function removes the slashes at the end of a path. A loop, not a regular expression, keeps the time linear. */
|
|
177
|
+
function withoutTrailingSlashes(path: string): string {
|
|
178
|
+
let end = path.length;
|
|
179
|
+
while (end > 0 && path.charCodeAt(end - 1) === 0x2f) end--;
|
|
180
|
+
return path.slice(0, end);
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
/** This function gives the base path with one `/` at the start and none at the end: `app/` gives `/app`, and `` gives `/`. */
|
|
184
|
+
function basePathOf(given: string): string {
|
|
185
|
+
const trimmed = withoutTrailingSlashes(given);
|
|
186
|
+
if (trimmed === "") return "/";
|
|
187
|
+
return trimmed.startsWith("/") ? trimmed : `/${trimmed}`;
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
/**
|
|
191
|
+
* This function gives the static face: the files of a folder under a base path. Refer to the start of
|
|
192
|
+
* this file for its rules.
|
|
193
|
+
*
|
|
194
|
+
* @example
|
|
195
|
+
* ```typescript
|
|
196
|
+
* createProcedureHost({
|
|
197
|
+
* faces: { http: true, ws: true, page: staticFace({ root: "dist", headers: { "Content-Security-Policy": "default-src 'self'; frame-ancestors 'self'" } }) },
|
|
198
|
+
* listen: [{ port: 3000, knobs: LOCAL_LISTENER_KNOBS }],
|
|
199
|
+
* });
|
|
200
|
+
* ```
|
|
201
|
+
*/
|
|
202
|
+
export function staticFace(options: StaticFaceOptions): HostFaceFactory {
|
|
203
|
+
const basePath = basePathOf(options.basePath ?? "/");
|
|
204
|
+
const fallback = options.fallback ?? "index.html";
|
|
205
|
+
const headers = headersWith(options.headers ?? {});
|
|
206
|
+
const root = resolve(options.root);
|
|
207
|
+
/** The real path of the root, after its symbolic links. The first request reads it. */
|
|
208
|
+
let realRoot: Promise<string> | undefined;
|
|
209
|
+
|
|
210
|
+
const serve = async (req: IncomingMessage, res: ServerResponse, path: string): Promise<void> => {
|
|
211
|
+
let decoded: string;
|
|
212
|
+
try {
|
|
213
|
+
decoded = decodeURIComponent(path);
|
|
214
|
+
} catch {
|
|
215
|
+
answer(res, 400, "The path of the request is not valid.", headers);
|
|
216
|
+
return;
|
|
217
|
+
}
|
|
218
|
+
if (decoded.includes("\0")) {
|
|
219
|
+
answer(res, 400, "The path of the request is not valid.", headers);
|
|
220
|
+
return;
|
|
221
|
+
}
|
|
222
|
+
if (hidden(decoded)) {
|
|
223
|
+
answer(res, 404, "No file has this path.", headers);
|
|
224
|
+
return;
|
|
225
|
+
}
|
|
226
|
+
let base: string;
|
|
227
|
+
try {
|
|
228
|
+
base = await (realRoot ??= realpath(root));
|
|
229
|
+
} catch {
|
|
230
|
+
// The root is missing, for example before the first build: the next request reads it again
|
|
231
|
+
realRoot = undefined;
|
|
232
|
+
answer(res, 404, "No file has this path.", headers);
|
|
233
|
+
return;
|
|
234
|
+
}
|
|
235
|
+
const file =
|
|
236
|
+
(await fileIn(base, decoded)) ??
|
|
237
|
+
// A route of the page has no extension, and the browser asks for a page: the router of the page shows it
|
|
238
|
+
(fallback !== false && extname(decoded) === "" && acceptsPage(req) ? await fileIn(base, `/${fallback}`) : undefined);
|
|
239
|
+
if (file === undefined) {
|
|
240
|
+
answer(res, 404, "No file has this path.", headers);
|
|
241
|
+
return;
|
|
242
|
+
}
|
|
243
|
+
await send(req, res, file, headers);
|
|
244
|
+
};
|
|
245
|
+
|
|
246
|
+
return (context) => {
|
|
247
|
+
const handler = (req: IncomingMessage, res: ServerResponse): boolean => {
|
|
248
|
+
if (req.method !== "GET" && req.method !== "HEAD") return false;
|
|
249
|
+
const path = pathUnder(req.url, basePath);
|
|
250
|
+
if (path === undefined) return false;
|
|
251
|
+
const fail = (error: unknown): void => {
|
|
252
|
+
if (res.headersSent) {
|
|
253
|
+
res.destroy(error instanceof Error ? error : new Error(String(error)));
|
|
254
|
+
return;
|
|
255
|
+
}
|
|
256
|
+
answer(res, 500, "The face could not read the file.", headers);
|
|
257
|
+
};
|
|
258
|
+
if (context.track === undefined) serve(req, res, path).catch(fail);
|
|
259
|
+
else context.track(req, res, path, () => serve(req, res, path)).catch(fail);
|
|
260
|
+
return true;
|
|
261
|
+
};
|
|
262
|
+
return { handler, endpoint: { type: "static", scheme: "http", path: basePath } };
|
|
263
|
+
};
|
|
264
|
+
}
|
package/src/index.ts
CHANGED
|
@@ -32,6 +32,9 @@ export {
|
|
|
32
32
|
httpFace,
|
|
33
33
|
wsFace,
|
|
34
34
|
localFace,
|
|
35
|
+
staticFace,
|
|
36
|
+
DEFAULT_STATIC_HEADERS,
|
|
37
|
+
contentTypeOf,
|
|
35
38
|
LOCAL_LISTENER_KNOBS,
|
|
36
39
|
HOST_PROBE_PREFIX,
|
|
37
40
|
callOperations,
|
|
@@ -65,6 +68,7 @@ export type {
|
|
|
65
68
|
HostFaceFactory,
|
|
66
69
|
HostFaceUpgrade,
|
|
67
70
|
LocalFace,
|
|
71
|
+
StaticFaceOptions,
|
|
68
72
|
CallDetail,
|
|
69
73
|
CallOperation,
|
|
70
74
|
FaceHandler,
|