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.
- package/LICENSE.md +51 -0
- package/README.md +81 -2
- package/dist/browser-modules.d.ts +36 -0
- package/dist/browser-modules.js +148 -0
- package/dist/editor-app.d.ts +199 -0
- package/dist/editor-app.js +201 -0
- package/dist/files.d.ts +25 -0
- package/dist/files.js +48 -0
- package/dist/index.d.ts +8 -0
- package/dist/index.js +8 -0
- package/dist/markdown.d.ts +27 -0
- package/dist/markdown.js +84 -0
- package/dist/post-process.d.ts +50 -0
- package/dist/post-process.js +84 -0
- package/dist/server.d.ts +152 -0
- package/dist/server.js +354 -0
- package/dist/state-folder.d.ts +20 -0
- package/dist/state-folder.js +21 -0
- package/docs/appbase.md +182 -0
- package/package.json +62 -4
package/dist/server.d.ts
ADDED
|
@@ -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, '&')
|
|
213
|
+
.replace(/</g, '<')
|
|
214
|
+
.replace(/>/g, '>')
|
|
215
|
+
.replace(/"/g, '"');
|
|
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
|
+
}
|