asljs-appbase 0.0.0-stage → 0.1.4

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.
@@ -0,0 +1,152 @@
1
+ import { type Logger } from 'asljs-logging';
2
+ import { IncomingMessage, Server, ServerResponse } from 'node:http';
3
+ import { type BrowserModule } from './browser-modules.js';
4
+ /**
5
+ * The first port `startServer` tries without a port.
6
+ */
7
+ export declare const DEFAULT_PORT = 3000;
8
+ /**
9
+ * Answers a request by its URL, whose path is relative to where the handler
10
+ * is mounted: `/` is the handler's own root.
11
+ */
12
+ export type RequestHandler = (request: IncomingMessage, response: ServerResponse, url: URL) => Promise<void>;
13
+ export interface ServerOptions {
14
+ /**
15
+ * The folder whose files the server serves.
16
+ */
17
+ folder: string;
18
+ /**
19
+ * The HTML of `/`, made again on every request, e.g. the page of a viewer
20
+ * that loads its data; `/` is not found without it.
21
+ */
22
+ index?: () => Promise<string>;
23
+ /**
24
+ * The port to listen on, exactly; 0 picks a free one. When absent, the
25
+ * first free port from `DEFAULT_PORT` on.
26
+ */
27
+ port?: number;
28
+ host?: string;
29
+ /**
30
+ * Values sent as JSON, by path, e.g. `/_/data.json`: the value for the
31
+ * request URL, with its query, or a `jsonReply` for another status.
32
+ */
33
+ json?: Readonly<Record<string, (url: URL) => Promise<unknown>>>;
34
+ /**
35
+ * The request methods answered, e.g. `[ 'GET', 'HEAD' ]`; any other is
36
+ * refused with 405. All by default.
37
+ */
38
+ methods?: readonly string[];
39
+ /**
40
+ * The extensions of the module files served, e.g. `[ '.js', '.mjs' ]`;
41
+ * every file of a known type by default.
42
+ */
43
+ moduleTypes?: readonly string[];
44
+ /**
45
+ * Answers a `POST` with a JSON body, by the request URL and the parsed
46
+ * body: the status, and the value sent back as JSON. Without it, a `POST`
47
+ * is not found. A `POST` is answered only when its `Content-Type` is
48
+ * `application/json`, it was sent to the server by a local name, and, when
49
+ * it has an `Origin`, from a page of the server itself; any other is
50
+ * refused, so that another site cannot use it.
51
+ */
52
+ post?: (url: URL, body: unknown) => Promise<PostReply>;
53
+ /**
54
+ * Packages served under `MODULES_PREFIX`, for pages that import them through
55
+ * the import map `createImportMap(modules, MODULES_PREFIX)` gives; see
56
+ * `findBrowserModules`.
57
+ */
58
+ modules?: readonly BrowserModule[];
59
+ /**
60
+ * Whether a file may be served, by its path relative to `folder` with `/`
61
+ * separators; any file by default. A file it refuses is not found.
62
+ */
63
+ allow?: (relative: string) => boolean;
64
+ /**
65
+ * Where the server logs that it listens, at `information`, each request, at
66
+ * `trace`, and a request that failed, at `error`; nothing is logged when
67
+ * absent.
68
+ */
69
+ logger?: Logger;
70
+ }
71
+ /**
72
+ * What `createHandler` takes: the options of `startServer` without where it
73
+ * listens.
74
+ */
75
+ export type HandlerOptions = Omit<ServerOptions, 'port' | 'logger'>;
76
+ /**
77
+ * Where `serve` listens, and where it logs.
78
+ */
79
+ export type ListenOptions = Pick<ServerOptions, 'port' | 'host' | 'logger'>;
80
+ /**
81
+ * The answer to a `POST`: its status, and the value sent back as JSON.
82
+ */
83
+ export interface PostReply {
84
+ status: number;
85
+ body: unknown;
86
+ }
87
+ /**
88
+ * The reply to a save of a document that changed since its editor read it.
89
+ */
90
+ export declare function versionConflict(): PostReply;
91
+ /**
92
+ * Starts a web server for a folder: `/` is `index`, a path of `json` that
93
+ * value, and a file of the folder is served as it is, a `.md` file as its
94
+ * markdown text; nothing outside the folder, or that `allow` refuses, is
95
+ * served. The server sends data, never a rendering of it: rendering is the
96
+ * page's. Returns once it listens.
97
+ */
98
+ export declare function startServer(options: ServerOptions): Promise<Server>;
99
+ /**
100
+ * The handler of `startServer`, for a folder, to mount it with
101
+ * `mountHandlers` or to serve it with `serve`.
102
+ */
103
+ export declare function createHandler(options: HandlerOptions): RequestHandler;
104
+ /**
105
+ * A handler that passes a request whose path starts with `/<name>/` to the
106
+ * handler of `name` in `mounts`, with the path relative to it, and redirects
107
+ * `/<name>` to `/<name>/`. Any other request goes to `fallback`, or is not
108
+ * found.
109
+ */
110
+ export declare function mountHandlers(mounts: Readonly<Record<string, RequestHandler>>, fallback?: RequestHandler): RequestHandler;
111
+ /**
112
+ * Starts a web server that answers every request with `handler`. Returns
113
+ * once it listens.
114
+ */
115
+ export declare function serve(handler: RequestHandler, options?: ListenOptions): Promise<Server>;
116
+ /**
117
+ * Resolves once the server has closed. The first of `signals` closes it: it
118
+ * stops accepting connections and drops the open ones, so that whoever waits
119
+ * can release what the server used, such as a logger, before the process
120
+ * exits.
121
+ */
122
+ export declare function untilStopped(server: Server, signals?: readonly NodeJS.Signals[]): Promise<void>;
123
+ /**
124
+ * The address a server listens on, e.g. `http://127.0.0.1:3000/`.
125
+ */
126
+ export declare function serverUrl(server: Server): string;
127
+ /**
128
+ * An HTML page with the shared style and `style`.
129
+ */
130
+ export declare function page(title: string, body: string, style?: string): string;
131
+ declare const JSON_REPLY: unique symbol;
132
+ /**
133
+ * A value of a `json` route with a status other than 200.
134
+ */
135
+ export interface JsonReply extends PostReply {
136
+ [JSON_REPLY]: true;
137
+ }
138
+ /**
139
+ * What a `json` route answers with a status other than 200, e.g. 422 with
140
+ * the error.
141
+ */
142
+ export declare function jsonReply(status: number, body: unknown): JsonReply;
143
+ /**
144
+ * JSON that is safe inside a `<script>` element.
145
+ */
146
+ export declare function scriptJson(value: unknown): string;
147
+ export declare function escapeHtml(text: string): string;
148
+ /**
149
+ * Answers with `status` and `body`.
150
+ */
151
+ export declare function send(response: ServerResponse, status: number, contentType: string, body: string | Buffer): void;
152
+ export {};
package/dist/server.js ADDED
@@ -0,0 +1,354 @@
1
+ import { NullLogger } from 'asljs-logging';
2
+ import { once } from 'node:events';
3
+ import { readFile, stat } from 'node:fs/promises';
4
+ import { createServer } from 'node:http';
5
+ import path from 'node:path';
6
+ import { findModuleFile, MODULES_PREFIX } from './browser-modules.js';
7
+ /**
8
+ * The first port `startServer` tries without a port.
9
+ */
10
+ export const DEFAULT_PORT = 3000;
11
+ /**
12
+ * How many ports from `DEFAULT_PORT` on `startServer` tries.
13
+ */
14
+ const PORT_ATTEMPTS = 100;
15
+ /**
16
+ * The largest body a `POST` may have, in bytes.
17
+ */
18
+ const MAX_POST_BYTES = 10 * 1024 * 1024;
19
+ const CONTENT_TYPES = new Map([['.css',
20
+ 'text/css; charset=utf-8'],
21
+ ['.gif',
22
+ 'image/gif'],
23
+ ['.jpeg',
24
+ 'image/jpeg'],
25
+ ['.jpg',
26
+ 'image/jpeg'],
27
+ ['.js',
28
+ 'text/javascript; charset=utf-8'],
29
+ ['.json',
30
+ 'application/json; charset=utf-8'],
31
+ ['.map',
32
+ 'application/json; charset=utf-8'],
33
+ ['.md',
34
+ 'text/markdown; charset=utf-8'],
35
+ ['.mjs',
36
+ 'text/javascript; charset=utf-8'],
37
+ ['.pdf',
38
+ 'application/pdf'],
39
+ ['.png',
40
+ 'image/png'],
41
+ ['.svg',
42
+ 'image/svg+xml'],
43
+ ['.txt',
44
+ 'text/plain; charset=utf-8']]);
45
+ const STYLE = `body { font-family: system-ui, sans-serif; max-width: 960px; margin: 2rem auto; padding: 0 1rem; line-height: 1.5; }
46
+ pre { overflow-x: auto; }
47
+ pre:not(.mermaid) { background: #f4f4f4; padding: 0.75rem; }
48
+ code { font-family: ui-monospace, monospace; }`;
49
+ /**
50
+ * The reply to a save of a document that changed since its editor read it.
51
+ */
52
+ export function versionConflict() {
53
+ return { status: 409,
54
+ body: { error: 'The document changed since the editor opened it; copy your text, reload the page and edit again.' } };
55
+ }
56
+ /**
57
+ * Starts a web server for a folder: `/` is `index`, a path of `json` that
58
+ * value, and a file of the folder is served as it is, a `.md` file as its
59
+ * markdown text; nothing outside the folder, or that `allow` refuses, is
60
+ * served. The server sends data, never a rendering of it: rendering is the
61
+ * page's. Returns once it listens.
62
+ */
63
+ export async function startServer(options) {
64
+ return await serve(createHandler(options), options);
65
+ }
66
+ /**
67
+ * The handler of `startServer`, for a folder, to mount it with
68
+ * `mountHandlers` or to serve it with `serve`.
69
+ */
70
+ export function createHandler(options) {
71
+ return async (request, response, url) => await handle(options, request, response, url);
72
+ }
73
+ /**
74
+ * A handler that passes a request whose path starts with `/<name>/` to the
75
+ * handler of `name` in `mounts`, with the path relative to it, and redirects
76
+ * `/<name>` to `/<name>/`. Any other request goes to `fallback`, or is not
77
+ * found.
78
+ */
79
+ export function mountHandlers(mounts, fallback) {
80
+ return async (request, response, url) => {
81
+ const match = /^\/([^/]+)(\/.*)?$/.exec(url.pathname);
82
+ const name = match === null
83
+ ? undefined
84
+ : decodeURIComponent(match[1]);
85
+ if (name !== undefined
86
+ && Object.hasOwn(mounts, name)) {
87
+ if (match[2] === undefined) {
88
+ response.writeHead(302, { location: `${url.pathname}/${url.search}` });
89
+ response.end();
90
+ return;
91
+ }
92
+ const inner = new URL(url);
93
+ inner.pathname = match[2];
94
+ await mounts[name](request, response, inner);
95
+ return;
96
+ }
97
+ if (fallback !== undefined) {
98
+ await fallback(request, response, url);
99
+ return;
100
+ }
101
+ send(response, 404, 'text/plain; charset=utf-8', `Not found: ${url.pathname}`);
102
+ };
103
+ }
104
+ /**
105
+ * Starts a web server that answers every request with `handler`. Returns
106
+ * once it listens.
107
+ */
108
+ export async function serve(handler, options = {}) {
109
+ const logger = options.logger ?? new NullLogger();
110
+ const server = createServer((request, response) => {
111
+ logger.trace({ method: request.method,
112
+ url: request.url }, 'request');
113
+ handler(request, response, new URL(request.url ?? '/', 'http://localhost'))
114
+ .catch((error) => {
115
+ logger.error({ err: error,
116
+ url: request.url }, 'request failed');
117
+ send(response, 500, 'text/plain; charset=utf-8', error instanceof Error
118
+ ? error.message
119
+ : String(error));
120
+ });
121
+ });
122
+ const host = options.host ?? '127.0.0.1';
123
+ if (options.port === undefined) {
124
+ await listenOnFreePort(server, host);
125
+ }
126
+ else {
127
+ await listen(server, options.port, host);
128
+ }
129
+ logger.information({ url: serverUrl(server) }, 'listening');
130
+ return server;
131
+ }
132
+ /**
133
+ * Resolves once the server has closed. The first of `signals` closes it: it
134
+ * stops accepting connections and drops the open ones, so that whoever waits
135
+ * can release what the server used, such as a logger, before the process
136
+ * exits.
137
+ */
138
+ export async function untilStopped(server, signals = ['SIGINT',
139
+ 'SIGTERM']) {
140
+ const closed = once(server, 'close');
141
+ const stop = () => {
142
+ server.close();
143
+ server.closeAllConnections();
144
+ };
145
+ for (const signal of signals) {
146
+ process.once(signal, stop);
147
+ }
148
+ try {
149
+ await closed;
150
+ }
151
+ finally {
152
+ for (const signal of signals) {
153
+ process.off(signal, stop);
154
+ }
155
+ }
156
+ }
157
+ /**
158
+ * The address a server listens on, e.g. `http://127.0.0.1:3000/`.
159
+ */
160
+ export function serverUrl(server) {
161
+ const address = server.address();
162
+ const host = address.family === 'IPv6'
163
+ ? `[${address.address}]`
164
+ : address.address;
165
+ return `http://${host}:${address.port}/`;
166
+ }
167
+ /**
168
+ * An HTML page with the shared style and `style`.
169
+ */
170
+ export function page(title, body, style = '') {
171
+ return `<!doctype html>
172
+ <html>
173
+ <head>
174
+ <meta charset="utf-8">
175
+ <meta name="viewport" content="width=device-width, initial-scale=1">
176
+ <title>${escapeHtml(title)}</title>
177
+ <style>
178
+ ${STYLE}
179
+ ${style}
180
+ </style>
181
+ </head>
182
+ <body>
183
+ ${body}
184
+ </body>
185
+ </html>
186
+ `;
187
+ }
188
+ const JSON_REPLY = Symbol('jsonReply');
189
+ /**
190
+ * What a `json` route answers with a status other than 200, e.g. 422 with
191
+ * the error.
192
+ */
193
+ export function jsonReply(status, body) {
194
+ return { [JSON_REPLY]: true,
195
+ status,
196
+ body };
197
+ }
198
+ function isJsonReply(value) {
199
+ return typeof value === 'object'
200
+ && value !== null
201
+ && JSON_REPLY in value;
202
+ }
203
+ /**
204
+ * JSON that is safe inside a `<script>` element.
205
+ */
206
+ export function scriptJson(value) {
207
+ return JSON.stringify(value)
208
+ .replace(/</g, '\\u003c');
209
+ }
210
+ export function escapeHtml(text) {
211
+ return text
212
+ .replace(/&/g, '&amp;')
213
+ .replace(/</g, '&lt;')
214
+ .replace(/>/g, '&gt;')
215
+ .replace(/"/g, '&quot;');
216
+ }
217
+ function listen(server, port, host) {
218
+ return new Promise((resolve, reject) => {
219
+ const fail = (error) => reject(error);
220
+ server.once('error', fail);
221
+ server.listen(port, host, () => {
222
+ server.off('error', fail);
223
+ resolve();
224
+ });
225
+ });
226
+ }
227
+ /**
228
+ * Listens on the first port from `DEFAULT_PORT` on that is not in use.
229
+ */
230
+ async function listenOnFreePort(server, host) {
231
+ for (let offset = 0; offset < PORT_ATTEMPTS; offset += 1) {
232
+ try {
233
+ await listen(server, DEFAULT_PORT + offset, host);
234
+ return;
235
+ }
236
+ catch (error) {
237
+ if (error.code
238
+ !== 'EADDRINUSE') {
239
+ throw error;
240
+ }
241
+ }
242
+ }
243
+ throw new Error(`No free port from ${DEFAULT_PORT} to ${DEFAULT_PORT + PORT_ATTEMPTS - 1}; give one with --port.`);
244
+ }
245
+ async function handle(options, request, response, url) {
246
+ const pathname = decodeURIComponent(url.pathname);
247
+ if (options.methods !== undefined
248
+ && !options.methods.includes(request.method ?? 'GET')) {
249
+ response.writeHead(405, { 'content-type': 'text/plain; charset=utf-8',
250
+ allow: options.methods.join(', ') });
251
+ response.end('Method not allowed');
252
+ return;
253
+ }
254
+ if (request.method === 'POST') {
255
+ await handlePost(options, request, response, url);
256
+ return;
257
+ }
258
+ if (pathname === '/'
259
+ && options.index !== undefined) {
260
+ send(response, 200, 'text/html; charset=utf-8', await options.index());
261
+ return;
262
+ }
263
+ if (options.modules !== undefined
264
+ && pathname.startsWith(MODULES_PREFIX)) {
265
+ const target = findModuleFile(pathname.slice(MODULES_PREFIX.length), options.modules);
266
+ const type = target === null
267
+ || options.moduleTypes !== undefined
268
+ && !options.moduleTypes.includes(path.extname(target).toLowerCase())
269
+ ? undefined
270
+ : CONTENT_TYPES.get(path.extname(target).toLowerCase());
271
+ if (target !== null
272
+ && type !== undefined
273
+ && (await stat(target).catch(() => null))?.isFile()) {
274
+ send(response, 200, type, await readFile(target));
275
+ }
276
+ else {
277
+ send(response, 404, 'text/plain; charset=utf-8', `Not found: ${pathname}`);
278
+ }
279
+ return;
280
+ }
281
+ const valueOf = options.json !== undefined
282
+ && Object.hasOwn(options.json, pathname)
283
+ ? options.json[pathname]
284
+ : undefined;
285
+ if (valueOf !== undefined) {
286
+ const value = await valueOf(url);
287
+ const reply = isJsonReply(value)
288
+ ? value
289
+ : { status: 200,
290
+ body: value };
291
+ send(response, reply.status, 'application/json; charset=utf-8', JSON.stringify(reply.body));
292
+ return;
293
+ }
294
+ const file = path.resolve(options.folder, `.${pathname}`);
295
+ const relative = path.relative(options.folder, file);
296
+ if (relative.startsWith('..')
297
+ || path.isAbsolute(relative)
298
+ || options.allow?.(relative.split(path.sep).join('/'))
299
+ === false
300
+ || !(await stat(file).catch(() => null))?.isFile()) {
301
+ send(response, 404, 'text/plain; charset=utf-8', `Not found: ${pathname}`);
302
+ return;
303
+ }
304
+ send(response, 200, CONTENT_TYPES.get(path.extname(file).toLowerCase())
305
+ ?? 'application/octet-stream', await readFile(file));
306
+ }
307
+ async function handlePost(options, request, response, url) {
308
+ if (options.post === undefined) {
309
+ send(response, 404, 'text/plain; charset=utf-8', `Not found: ${url.pathname}`);
310
+ return;
311
+ }
312
+ const host = request.headers.host ?? '';
313
+ const hostname = new URL(`http://${host}`).hostname;
314
+ const origin = request.headers.origin;
315
+ if (!/^application\/json(?:;|$)/i.test(request.headers['content-type'] ?? '')
316
+ || !['localhost',
317
+ '127.0.0.1',
318
+ '[::1]',
319
+ options.host ?? '127.0.0.1']
320
+ .includes(hostname)
321
+ || origin !== undefined
322
+ && origin !== `http://${host}`) {
323
+ send(response, 403, 'text/plain; charset=utf-8', 'Forbidden');
324
+ return;
325
+ }
326
+ const chunks = [];
327
+ let size = 0;
328
+ for await (const chunk of request) {
329
+ size += chunk.length;
330
+ if (size > MAX_POST_BYTES) {
331
+ send(response, 413, 'text/plain; charset=utf-8', 'The body is too large.');
332
+ return;
333
+ }
334
+ chunks.push(chunk);
335
+ }
336
+ let body;
337
+ try {
338
+ body =
339
+ JSON.parse(Buffer.concat(chunks).toString('utf8'));
340
+ }
341
+ catch {
342
+ send(response, 400, 'text/plain; charset=utf-8', 'The body is not JSON.');
343
+ return;
344
+ }
345
+ const reply = await options.post(url, body);
346
+ send(response, reply.status, 'application/json; charset=utf-8', JSON.stringify(reply.body));
347
+ }
348
+ /**
349
+ * Answers with `status` and `body`.
350
+ */
351
+ export function send(response, status, contentType, body) {
352
+ response.writeHead(status, { 'content-type': contentType });
353
+ response.end(body);
354
+ }
@@ -0,0 +1,20 @@
1
+ import { type Config } from 'asljs-cmdbase';
2
+ /**
3
+ * A state folder given on the command line: the option, else the variable,
4
+ * e.g. `KB_STATE_DIR`, both resolving against `cwd`. A service gives none:
5
+ * its projects are configured by their own files.
6
+ */
7
+ export interface GivenStateFolder {
8
+ option?: string;
9
+ variable?: string;
10
+ env?: Readonly<Record<string, string | undefined>>;
11
+ cwd: string;
12
+ }
13
+ /**
14
+ * The folder where application `app` keeps what it stores between runs for
15
+ * the project in `folder`: the one `given` names, else `stateDir` of
16
+ * `config`, against the folder of the file, else `.<app>` in `folder`, e.g.
17
+ * `.kb`. Every application keeps its state in such a folder, which a
18
+ * project's `.gitignore` leaves out.
19
+ */
20
+ export declare function stateFolder(folder: string, app: string, config: Config, given?: GivenStateFolder): string;
@@ -0,0 +1,21 @@
1
+ import { configPath } from 'asljs-cmdbase';
2
+ import path from 'node:path';
3
+ /**
4
+ * The folder where application `app` keeps what it stores between runs for
5
+ * the project in `folder`: the one `given` names, else `stateDir` of
6
+ * `config`, against the folder of the file, else `.<app>` in `folder`, e.g.
7
+ * `.kb`. Every application keeps its state in such a folder, which a
8
+ * project's `.gitignore` leaves out.
9
+ */
10
+ export function stateFolder(folder, app, config, given) {
11
+ const named = given?.option
12
+ || (given?.variable === undefined
13
+ ? undefined
14
+ : given.env?.[given.variable])
15
+ || undefined;
16
+ if (named !== undefined) {
17
+ return path.resolve(given.cwd, named);
18
+ }
19
+ return configPath(config, 'stateDir')
20
+ ?? path.join(path.resolve(folder), `.${app}`);
21
+ }