@xoong/server 0.0.0-stage → 0.0.35
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 +21 -0
- package/README.md +25 -2
- package/package.json +37 -4
- package/src/handler.js +167 -0
- package/src/index.js +1 -0
- package/src/node.js +179 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Duong Tran Xuan
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
CHANGED
|
@@ -1,3 +1,26 @@
|
|
|
1
|
-
#
|
|
1
|
+
# @xoong/server
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
The server behind `xoong dev` and `xoong build`: one `(Request) => Response` handler for static files, an API proxy and TypeScript type stripping, with a Node adapter. Never shipped to the browser.
|
|
4
|
+
|
|
5
|
+
## Install
|
|
6
|
+
|
|
7
|
+
```bash
|
|
8
|
+
npm install @xoong/server
|
|
9
|
+
```
|
|
10
|
+
|
|
11
|
+
## Use
|
|
12
|
+
|
|
13
|
+
```js
|
|
14
|
+
import { createHandler } from '@xoong/server';
|
|
15
|
+
import { serve } from '@xoong/server/node';
|
|
16
|
+
```
|
|
17
|
+
See the docs for the options.
|
|
18
|
+
|
|
19
|
+
## More
|
|
20
|
+
|
|
21
|
+
* Documentation and demos: https://github.com/fpt-duongtx/xoong#readme
|
|
22
|
+
* Source: https://github.com/fpt-duongtx/xoong/tree/main/packages/server
|
|
23
|
+
* All packages share one version number and are released together: https://github.com/fpt-duongtx/xoong/releases
|
|
24
|
+
* xoong is a working prototype (0.0.x): the HTML syntax is still Draft.
|
|
25
|
+
|
|
26
|
+
MIT licence.
|
package/package.json
CHANGED
|
@@ -1,6 +1,39 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@xoong/server",
|
|
3
|
-
"version": "0.0.
|
|
4
|
-
"
|
|
5
|
-
"
|
|
6
|
-
|
|
3
|
+
"version": "0.0.35",
|
|
4
|
+
"description": "xoong server: one (Request) => Response handler for static files, an API proxy and TypeScript type stripping, plus a Node adapter. Dev and optional production use; never shipped to the browser.",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"exports": {
|
|
7
|
+
".": "./src/index.js",
|
|
8
|
+
"./node": "./src/node.js"
|
|
9
|
+
},
|
|
10
|
+
"license": "MIT",
|
|
11
|
+
"dependencies": {
|
|
12
|
+
"amaro": "^1.3.0"
|
|
13
|
+
},
|
|
14
|
+
"files": [
|
|
15
|
+
"src"
|
|
16
|
+
],
|
|
17
|
+
"keywords": [
|
|
18
|
+
"xoong",
|
|
19
|
+
"web-framework",
|
|
20
|
+
"openapi",
|
|
21
|
+
"custom-elements"
|
|
22
|
+
],
|
|
23
|
+
"engines": {
|
|
24
|
+
"node": ">=22.12"
|
|
25
|
+
},
|
|
26
|
+
"author": "Duong Tran Xuan <duongtx1505@icloud.com>",
|
|
27
|
+
"repository": {
|
|
28
|
+
"type": "git",
|
|
29
|
+
"url": "git+https://github.com/fpt-duongtx/xoong.git",
|
|
30
|
+
"directory": "packages/server"
|
|
31
|
+
},
|
|
32
|
+
"bugs": {
|
|
33
|
+
"url": "https://github.com/fpt-duongtx/xoong/issues"
|
|
34
|
+
},
|
|
35
|
+
"homepage": "https://github.com/fpt-duongtx/xoong#readme",
|
|
36
|
+
"publishConfig": {
|
|
37
|
+
"access": "public"
|
|
38
|
+
}
|
|
39
|
+
}
|
package/src/handler.js
ADDED
|
@@ -0,0 +1,167 @@
|
|
|
1
|
+
// @ts-check
|
|
2
|
+
/**
|
|
3
|
+
* One web-standard handler: (Request) => Promise<Response>.
|
|
4
|
+
* It runs anywhere a Fetch-API runtime exists. File access is injected, so this file imports no Node module.
|
|
5
|
+
*
|
|
6
|
+
* createHandler({
|
|
7
|
+
* files: async (path) => ({ body: Uint8Array|string, type?: string }) | null,
|
|
8
|
+
* proxy: { '/api': 'https://backend.internal' }, // prefix -> upstream origin
|
|
9
|
+
* packages: { '@xoong/core': { prefix: '/@xoong/core', exports: {...} } }, // served to the browser via an import map
|
|
10
|
+
* transform: { '.ts': (code, path) => javascript }, // e.g. strip TypeScript types
|
|
11
|
+
* })
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
const TYPES = {
|
|
15
|
+
'.html': 'text/html; charset=utf-8', '.js': 'text/javascript; charset=utf-8', '.mjs': 'text/javascript; charset=utf-8',
|
|
16
|
+
'.ts': 'text/javascript; charset=utf-8', '.css': 'text/css; charset=utf-8', '.json': 'application/json; charset=utf-8',
|
|
17
|
+
'.svg': 'image/svg+xml', '.png': 'image/png', '.jpg': 'image/jpeg', '.jpeg': 'image/jpeg', '.gif': 'image/gif',
|
|
18
|
+
'.webp': 'image/webp', '.avif': 'image/avif', '.ico': 'image/x-icon', '.woff': 'font/woff', '.woff2': 'font/woff2', '.ttf': 'font/ttf', '.otf': 'font/otf',
|
|
19
|
+
'.txt': 'text/plain; charset=utf-8', '.md': 'text/markdown; charset=utf-8', '.xml': 'application/xml', '.pdf': 'application/pdf', '.wasm': 'application/wasm',
|
|
20
|
+
'.map': 'application/json', '.webmanifest': 'application/manifest+json', '.mp4': 'video/mp4', '.webm': 'video/webm', '.mp3': 'audio/mpeg',
|
|
21
|
+
};
|
|
22
|
+
const extOf = (p) => { const i = p.lastIndexOf('.'); return i < 0 ? '' : p.slice(i).toLowerCase(); };
|
|
23
|
+
|
|
24
|
+
/** Headers that describe one hop and must not be copied through a proxy. */
|
|
25
|
+
const HOP = new Set(['connection', 'keep-alive', 'proxy-authenticate', 'proxy-authorization', 'te', 'trailer', 'transfer-encoding', 'upgrade', 'host', 'content-length']);
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Build the import map for the packages the page may import (bare specifiers like "@xoong/core").
|
|
29
|
+
* @param {Record<string, { prefix: string, exports: Record<string, string> | string }>} packages
|
|
30
|
+
*/
|
|
31
|
+
export function buildImportMap(packages) {
|
|
32
|
+
/** @type {Record<string, string>} */
|
|
33
|
+
const imports = {};
|
|
34
|
+
for (const [name, { prefix, exports }] of Object.entries(packages)) {
|
|
35
|
+
const map = typeof exports === 'string' ? { '.': exports } : exports;
|
|
36
|
+
for (const [sub, target] of Object.entries(map)) {
|
|
37
|
+
const file = typeof target === 'string' ? target : target?.default ?? target?.import;
|
|
38
|
+
if (typeof file !== 'string') continue;
|
|
39
|
+
imports[sub === '.' ? name : `${name}/${sub.replace(/^\.\//, '')}`] = `${prefix}/${file.replace(/^\.\//, '')}`;
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
return { imports };
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/** Insert an import map before the first <script> (or at the end of <head>) unless the page has one. */
|
|
46
|
+
export function injectImportMap(html, importMap) {
|
|
47
|
+
if (/<script[^>]+type\s*=\s*["']importmap["']/i.test(html)) return html;
|
|
48
|
+
const tag = `<script type="importmap">${JSON.stringify(importMap).replace(/</g, '\\u003c')}</script>`;
|
|
49
|
+
const script = html.search(/<script\b/i);
|
|
50
|
+
if (script >= 0) return html.slice(0, script) + tag + '\n' + html.slice(script);
|
|
51
|
+
const head = html.search(/<\/head>/i);
|
|
52
|
+
if (head >= 0) return html.slice(0, head) + tag + '\n' + html.slice(head);
|
|
53
|
+
return tag + '\n' + html;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* @param {{
|
|
58
|
+
* files?: (path: string) => Promise<{ body: Uint8Array | string, type?: string } | null>,
|
|
59
|
+
* proxy?: Record<string, string>,
|
|
60
|
+
* packages?: Record<string, { prefix: string, exports: Record<string, any> | string }>,
|
|
61
|
+
* transform?: Record<string, (code: string, path: string) => string | Promise<string>>,
|
|
62
|
+
* fetch?: typeof fetch,
|
|
63
|
+
* spa?: boolean,
|
|
64
|
+
* headers?: Record<string, string>,
|
|
65
|
+
* onError?: (error: unknown, request: Request) => void,
|
|
66
|
+
* }} options
|
|
67
|
+
*/
|
|
68
|
+
export function createHandler(options = {}) {
|
|
69
|
+
const { files, proxy = {}, packages = {}, transform = {}, spa = false, headers: extra = {}, onError } = options;
|
|
70
|
+
const upstreamFetch = options.fetch ?? globalThis.fetch;
|
|
71
|
+
const importMap = Object.keys(packages).length ? buildImportMap(packages) : null;
|
|
72
|
+
// Longest prefix first so "/api/v2" wins over "/api".
|
|
73
|
+
const proxies = Object.entries(proxy).sort((a, b) => b[0].length - a[0].length);
|
|
74
|
+
for (const [prefix, target] of proxies) {
|
|
75
|
+
if (!prefix.startsWith('/')) throw new Error(`xoong server: proxy prefix "${prefix}" must start with "/"`);
|
|
76
|
+
try { new URL(target); } catch { throw new Error(`xoong server: proxy target for "${prefix}" is not a valid URL: ${target}`); }
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/** path -> { code, out }: an unchanged source file is not transformed again on every reload. */
|
|
80
|
+
const transformed = new Map();
|
|
81
|
+
const text = (status, message) => new Response(message, { status, headers: { 'content-type': 'text/plain; charset=utf-8', ...extra } });
|
|
82
|
+
|
|
83
|
+
/** The part of the (still percent-encoded) path below `prefix`, or null when the path is not under it. */
|
|
84
|
+
function below(rawPath, prefix) {
|
|
85
|
+
if (prefix === '/') return rawPath;
|
|
86
|
+
if (rawPath === prefix) return '';
|
|
87
|
+
return rawPath.startsWith(prefix + '/') ? rawPath.slice(prefix.length) : null; // "/apix" is not under "/api"
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/** @param {Request} request @param {URL} url @param {string} rest @param {string} target */
|
|
91
|
+
async function forward(request, url, rest, target) {
|
|
92
|
+
const upstream = new URL(target);
|
|
93
|
+
upstream.pathname = upstream.pathname.replace(/\/$/, '') + (rest || '/');
|
|
94
|
+
upstream.search = url.search;
|
|
95
|
+
const out = new Headers();
|
|
96
|
+
for (const [k, v] of request.headers) if (!HOP.has(k.toLowerCase())) out.set(k, v);
|
|
97
|
+
out.set('x-forwarded-host', url.host);
|
|
98
|
+
out.set('x-forwarded-proto', url.protocol.replace(':', ''));
|
|
99
|
+
const hasBody = !['GET', 'HEAD'].includes(request.method);
|
|
100
|
+
try {
|
|
101
|
+
const res = await upstreamFetch(upstream, { method: request.method, headers: out, body: hasBody ? request.body : undefined, redirect: 'manual', ...(hasBody ? { duplex: 'half' } : {}) });
|
|
102
|
+
const back = new Headers();
|
|
103
|
+
for (const [k, v] of res.headers) if (!HOP.has(k.toLowerCase()) && k.toLowerCase() !== 'content-encoding') back.append(k, v); // append: several Set-Cookie headers must all survive
|
|
104
|
+
// fetch() already decoded the body, so the encoding header would now be wrong.
|
|
105
|
+
return new Response(res.body, { status: res.status, statusText: res.statusText, headers: back });
|
|
106
|
+
} catch (error) {
|
|
107
|
+
onError?.(error, request);
|
|
108
|
+
return text(502, `xoong server: could not reach ${upstream.origin} (${/** @type {Error} */ (error)?.message ?? error})`);
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/** Serve a file, applying transforms and the import map. */
|
|
113
|
+
async function serveFile(path, request) {
|
|
114
|
+
const found = await files?.(path);
|
|
115
|
+
if (!found) return null;
|
|
116
|
+
const ext = extOf(path);
|
|
117
|
+
let body = found.body;
|
|
118
|
+
if (transform[ext]) {
|
|
119
|
+
const code = typeof body === 'string' ? body : new TextDecoder().decode(body);
|
|
120
|
+
try {
|
|
121
|
+
const hit = transformed.get(path);
|
|
122
|
+
if (hit && hit.code === code) body = hit.out;
|
|
123
|
+
else { body = await transform[ext](code, path); transformed.set(path, { code, out: /** @type {string} */ (body) }); }
|
|
124
|
+
} catch (error) {
|
|
125
|
+
onError?.(error, request);
|
|
126
|
+
return text(500, `xoong server: could not transform ${path}\n${/** @type {Error} */ (error)?.message ?? error}`);
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
if (ext === '.html' && importMap) body = injectImportMap(typeof body === 'string' ? body : new TextDecoder().decode(body), importMap);
|
|
130
|
+
const h = { 'content-type': found.type ?? TYPES[ext] ?? 'application/octet-stream', 'cache-control': 'no-cache', ...extra };
|
|
131
|
+
return new Response(request.method === 'HEAD' ? null : /** @type {BodyInit} */ (body), { status: 200, headers: h });
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
return async function handle(/** @type {Request} */ request) {
|
|
135
|
+
const url = new URL(request.url);
|
|
136
|
+
let pathname;
|
|
137
|
+
try { pathname = decodeURIComponent(url.pathname); } catch { return text(400, 'Bad request'); }
|
|
138
|
+
if (pathname.includes('\0') || pathname.split('/').includes('..')) return text(400, 'Bad request');
|
|
139
|
+
|
|
140
|
+
// Match on the encoded path so the forwarded path is sliced from the same string that was matched.
|
|
141
|
+
for (const [prefix, target] of proxies) {
|
|
142
|
+
const rest = below(url.pathname, prefix);
|
|
143
|
+
if (rest !== null) return forward(request, url, rest, target);
|
|
144
|
+
}
|
|
145
|
+
if (request.method !== 'GET' && request.method !== 'HEAD') return text(405, 'Method not allowed');
|
|
146
|
+
|
|
147
|
+
// Files provided by installed packages (served under their prefix; the import map points here).
|
|
148
|
+
for (const pkg of Object.values(packages)) {
|
|
149
|
+
if (pathname.startsWith(pkg.prefix + '/')) {
|
|
150
|
+
const res = await serveFile(pathname, request);
|
|
151
|
+
if (res) return res;
|
|
152
|
+
return text(404, 'Not found');
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
const candidates = pathname.endsWith('/') ? [pathname + 'index.html'] : [pathname];
|
|
157
|
+
for (const path of candidates) {
|
|
158
|
+
const res = await serveFile(path, request);
|
|
159
|
+
if (res) return res;
|
|
160
|
+
}
|
|
161
|
+
if (spa && !extOf(pathname)) {
|
|
162
|
+
const res = await serveFile('/index.html', request);
|
|
163
|
+
if (res) return res;
|
|
164
|
+
}
|
|
165
|
+
return text(404, 'Not found');
|
|
166
|
+
};
|
|
167
|
+
}
|
package/src/index.js
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export { createHandler, buildImportMap, injectImportMap } from './handler.js';
|
package/src/node.js
ADDED
|
@@ -0,0 +1,179 @@
|
|
|
1
|
+
// @ts-check
|
|
2
|
+
/** Node adapters for @xoong/server: a file reader rooted at a folder, TypeScript stripping, and an http server. */
|
|
3
|
+
import { readFile, readdir, stat } from 'node:fs/promises';
|
|
4
|
+
import { createServer } from 'node:http';
|
|
5
|
+
import { createRequire } from 'node:module';
|
|
6
|
+
import { dirname, join, normalize, resolve, sep } from 'node:path';
|
|
7
|
+
import { Readable } from 'node:stream';
|
|
8
|
+
import { createHandler } from './handler.js';
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Read files under `root` only. Paths that escape the root (via .., symlink-free check) are refused.
|
|
12
|
+
* @param {string} root
|
|
13
|
+
*/
|
|
14
|
+
export function nodeFiles(root) {
|
|
15
|
+
const base = resolve(root);
|
|
16
|
+
return async (/** @type {string} */ path) => {
|
|
17
|
+
const file = resolve(join(base, normalize('.' + path)));
|
|
18
|
+
if (file !== base && !file.startsWith(base + sep)) return null;
|
|
19
|
+
try {
|
|
20
|
+
if (!(await stat(file)).isFile()) return null;
|
|
21
|
+
return { body: await readFile(file) };
|
|
22
|
+
} catch { return null; }
|
|
23
|
+
};
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/** Strip TypeScript types (erasable syntax only: no enum, namespace or parameter properties). */
|
|
27
|
+
export async function stripTypes(/** @type {string} */ code, /** @type {string} */ path) {
|
|
28
|
+
const { transformSync } = await import('amaro');
|
|
29
|
+
try { return transformSync(code, { mode: 'strip-only' }).code; } catch (error) {
|
|
30
|
+
throw new Error(`${path}: ${/** @type {Error} */ (error).message}\nxoong only strips types; use erasable TypeScript (no enum/namespace/parameter properties)`);
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/** Walk up from a resolved entry file to the package.json of the package called `name`. */
|
|
35
|
+
async function packageDirOf(entry, name) {
|
|
36
|
+
for (let dir = dirname(entry); dir !== dirname(dir); dir = dirname(dir)) {
|
|
37
|
+
try {
|
|
38
|
+
const pkg = JSON.parse(await readFile(join(dir, 'package.json'), 'utf8'));
|
|
39
|
+
if (pkg.name === name) return { dir, pkg };
|
|
40
|
+
} catch { /* keep walking */ }
|
|
41
|
+
}
|
|
42
|
+
return null;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* Find installed @xoong/* packages from `from` and describe how to serve them to the browser.
|
|
47
|
+
* URL prefixes: @xoong/core -> /@xoong/core; the unscoped umbrella `xoong` -> /vendor/xoong.
|
|
48
|
+
* @param {string} from a folder inside the project
|
|
49
|
+
* @param {string[]} names
|
|
50
|
+
*/
|
|
51
|
+
export async function findPackages(from, names = ['@xoong/core', '@xoong/data', '@xoong/forms', '@xoong/i18n', '@xoong/controls', '@xoong/richtext', '@xoong/ui', 'xoong'], extra = []) {
|
|
52
|
+
const require = createRequire(join(resolve(from), 'noop.js'));
|
|
53
|
+
const xoong = new Set(names);
|
|
54
|
+
names = [...names, ...extra.filter((n) => !xoong.has(n))];
|
|
55
|
+
/** @type {Record<string, { prefix: string, exports: any, dir: string }>} */
|
|
56
|
+
const out = {};
|
|
57
|
+
for (const name of names) {
|
|
58
|
+
let found = null;
|
|
59
|
+
try { found = await packageDirOf(require.resolve(name), name); } catch { /* not installed */ }
|
|
60
|
+
if (!found) continue;
|
|
61
|
+
const own = !xoong.has(name); // a package named in the project's config: its exports map is expanded (wildcards) so an import map can list every entry
|
|
62
|
+
out[name] = { prefix: name.startsWith('@') ? `/${name}` : own ? `/@${name}` : `/vendor/${name}`, exports: own ? await expandExports(found.dir, found.pkg.exports ?? found.pkg.main ?? './src/index.js') : found.pkg.exports ?? found.pkg.main ?? './src/index.js', dir: found.dir };
|
|
63
|
+
}
|
|
64
|
+
return out;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/** Entries that only Node can use: never given to the browser. */
|
|
68
|
+
const NODE_ONLY = /^\.\/server(\/|$)/;
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* An `exports` map as a flat { "./sub": "./file" }: conditions resolved (browser, import, default), wildcards ("./widgets/*": "./src/widgets/*.js")
|
|
72
|
+
* expanded against the files of the package, and Node-only entries ("./server") left out.
|
|
73
|
+
*/
|
|
74
|
+
export async function expandExports(dir, exports) {
|
|
75
|
+
const map = typeof exports === 'string' ? { '.': exports } : exports;
|
|
76
|
+
const pick = (t) => typeof t === 'string' ? t : t && typeof t === 'object' ? pick(t.browser ?? t.import ?? t.default) : undefined;
|
|
77
|
+
/** @type {Record<string, string>} */ const out = {};
|
|
78
|
+
for (const [key, target] of Object.entries(map)) {
|
|
79
|
+
const file = pick(target);
|
|
80
|
+
if (typeof file !== 'string' || NODE_ONLY.test(key)) continue;
|
|
81
|
+
if (!key.includes('*')) { out[key] = file; continue; }
|
|
82
|
+
const [keyHead, keyTail] = key.split('*');
|
|
83
|
+
const [fileHead, fileTail] = file.split('*');
|
|
84
|
+
const head = fileHead.replace(/^\.\//, '');
|
|
85
|
+
const folder = head.slice(0, head.lastIndexOf('/') + 1); // the part of the pattern that is a folder
|
|
86
|
+
const base = join(dir, folder);
|
|
87
|
+
let names;
|
|
88
|
+
try { names = await readdir(base, { recursive: true }); } catch { continue; }
|
|
89
|
+
for (const n of names.map((x) => x.split(sep).join('/'))) {
|
|
90
|
+
const rel = folder + n;
|
|
91
|
+
if (!rel.startsWith(head) || !rel.endsWith(fileTail) || rel.length < head.length + fileTail.length) continue;
|
|
92
|
+
const star = rel.slice(head.length, rel.length - fileTail.length);
|
|
93
|
+
if (star) out[`${keyHead}${star}${keyTail}`] = `${fileHead}${star}${fileTail}`;
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
return out;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/** The file a URL under a package prefix stands for: an exports subpath ("/theme.css") maps to its target, anything else is the path itself. */
|
|
100
|
+
export const packagePath = (exports, rest) => (typeof exports === 'object' && exports && typeof exports[`.${rest}`] === 'string' ? exports[`.${rest}`].replace(/^\./, '') : rest);
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* Dev handler: serves `root`, the installed @xoong packages (with an import map), strips .ts on the fly and proxies APIs.
|
|
104
|
+
* @param {{ root?: string, resolveFrom?: string, proxy?: Record<string,string>, spa?: boolean, onError?: (e: unknown, r: Request) => void }} [options]
|
|
105
|
+
* `resolveFrom`: folder to look for installed @xoong packages from (default: `root`).
|
|
106
|
+
*/
|
|
107
|
+
/** Dot-folders a page may read: generated tables and the standard well-known folder. Everything else (.env, .git, ...) is refused. */
|
|
108
|
+
const ALLOWED_DOT = new Set(['.xoong', '.well-known']);
|
|
109
|
+
export const isHidden = (/** @type {string} */ path) => path.split('/').some((seg) => seg.startsWith('.') && !ALLOWED_DOT.has(seg) && seg !== '');
|
|
110
|
+
|
|
111
|
+
export async function createDevHandler({ root = '.', resolveFrom = root, proxy = {}, spa = false, onError, packages: extraPackages = [] } = {}) {
|
|
112
|
+
const pkgs = await findPackages(resolveFrom, undefined, extraPackages);
|
|
113
|
+
const projectFiles = nodeFiles(root);
|
|
114
|
+
const exportsOf = Object.fromEntries(Object.values(pkgs).map((p) => [p.prefix, p.exports]));
|
|
115
|
+
const pkgFiles = Object.fromEntries(Object.values(pkgs).map((p) => [p.prefix, nodeFiles(p.dir)]));
|
|
116
|
+
const files = async (/** @type {string} */ path) => {
|
|
117
|
+
if (isHidden(path)) return null;
|
|
118
|
+
for (const [prefix, read] of Object.entries(pkgFiles)) if (path.startsWith(prefix + '/')) return read(packagePath(exportsOf[prefix], path.slice(prefix.length)));
|
|
119
|
+
return projectFiles(path);
|
|
120
|
+
};
|
|
121
|
+
const packages = Object.fromEntries(Object.entries(pkgs).map(([name, p]) => [name, { prefix: p.prefix, exports: p.exports }]));
|
|
122
|
+
return createHandler({ files, packages, proxy, spa, onError, transform: { '.ts': stripTypes } });
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/** The host name in a Host header: "[::1]:5173" -> "::1", "Localhost:80" -> "localhost". */
|
|
126
|
+
export function hostName(header) {
|
|
127
|
+
const h = header.trim().toLowerCase();
|
|
128
|
+
if (h.startsWith('[')) return h.slice(1, h.indexOf(']') > 0 ? h.indexOf(']') : undefined);
|
|
129
|
+
return h.replace(/:\d+$/, '');
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/**
|
|
133
|
+
* Adapt a Fetch-API handler to node:http.
|
|
134
|
+
* @param {(request: Request) => Promise<Response>} handler
|
|
135
|
+
* @param {{ port?: number, host?: string, allowedHosts?: string[] }} [options]
|
|
136
|
+
* `allowedHosts`: Host header names accepted besides localhost, 127.0.0.1 and [::1] (blocks DNS-rebinding pages).
|
|
137
|
+
*/
|
|
138
|
+
export function serve(handler, { port = 5173, host = '127.0.0.1', allowedHosts = [] } = {}) {
|
|
139
|
+
const allowed = new Set(['localhost', '127.0.0.1', '::1', host, ...allowedHosts].map((h) => h.toLowerCase()));
|
|
140
|
+
const server = createServer(async (req, res) => {
|
|
141
|
+
try {
|
|
142
|
+
const name = hostName(String(req.headers.host ?? ''));
|
|
143
|
+
if (!allowed.has(name)) {
|
|
144
|
+
res.writeHead(403, { 'content-type': 'text/plain' });
|
|
145
|
+
return res.end(`xoong server: host "${name}" is not allowed (add it to allowedHosts if this is intended)`);
|
|
146
|
+
}
|
|
147
|
+
const origin = `http://${req.headers.host ?? `${host}:${port}`}`;
|
|
148
|
+
const hasBody = req.method !== 'GET' && req.method !== 'HEAD';
|
|
149
|
+
const request = new Request(new URL(req.url ?? '/', origin), {
|
|
150
|
+
method: req.method,
|
|
151
|
+
headers: /** @type {any} */ (req.headers),
|
|
152
|
+
body: hasBody ? /** @type {any} */ (Readable.toWeb(req)) : undefined,
|
|
153
|
+
...(hasBody ? { duplex: 'half' } : {}),
|
|
154
|
+
});
|
|
155
|
+
const response = await handler(request);
|
|
156
|
+
const out = {};
|
|
157
|
+
for (const [k, v] of response.headers) if (k !== 'set-cookie') out[k] = v;
|
|
158
|
+
const cookies = response.headers.getSetCookie();
|
|
159
|
+
if (cookies.length) out['set-cookie'] = cookies; // several cookies must stay separate headers
|
|
160
|
+
res.writeHead(response.status, out);
|
|
161
|
+
if (!response.body) return res.end();
|
|
162
|
+
const stream = Readable.fromWeb(/** @type {any} */ (response.body));
|
|
163
|
+
stream.on('error', () => res.destroy());
|
|
164
|
+
res.on('close', () => stream.destroy()); // a closed SSE connection must stop the upstream read
|
|
165
|
+
stream.pipe(res);
|
|
166
|
+
} catch (error) {
|
|
167
|
+
if (!res.headersSent) res.writeHead(500, { 'content-type': 'text/plain' });
|
|
168
|
+
res.end('xoong server: internal error');
|
|
169
|
+
console.error(error);
|
|
170
|
+
}
|
|
171
|
+
});
|
|
172
|
+
return new Promise((resolveListen, reject) => {
|
|
173
|
+
server.once('error', reject);
|
|
174
|
+
server.listen(port, host, () => {
|
|
175
|
+
const address = /** @type {import('node:net').AddressInfo} */ (server.address());
|
|
176
|
+
resolveListen({ server, port: address.port, url: `http://${host}:${address.port}`, close: () => new Promise((r) => { server.closeAllConnections?.(); server.close(() => r(undefined)); }) });
|
|
177
|
+
});
|
|
178
|
+
});
|
|
179
|
+
}
|