roster-server 2.2.12 → 2.3.2
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/README.md +25 -2
- package/index.js +28 -38
- package/lib/resolve-site-app.js +42 -0
- package/lib/static-site-handler.js +132 -0
- package/package.json +1 -1
- package/skills/roster-server/SKILL.md +15 -4
- package/test/roster-server.test.js +114 -0
- package/tasks/lessons.md +0 -5
package/README.md
CHANGED
|
@@ -8,6 +8,7 @@ Welcome to **RosterServer**, the ultimate domain host router with automatic HTTP
|
|
|
8
8
|
|
|
9
9
|
- **Automatic HTTPS** with Let's Encrypt via Greenlock.
|
|
10
10
|
- **Dynamic Site Loading**: Just drop your Node.js apps in the `www` folder.
|
|
11
|
+
- **Static Sites**: No code? No problem. Drop a folder with `index.html` (and assets) and RosterServer serves it automatically—modular static handler with path-traversal protection and strict 404s.
|
|
11
12
|
- **Virtual Hosting**: Serve multiple domains from a single server.
|
|
12
13
|
- **Automatic Redirects**: Redirect `www` subdomains to the root domain.
|
|
13
14
|
- **Zero Configuration**: Well, almost zero. Just a tiny bit of setup.
|
|
@@ -48,12 +49,20 @@ Your project should look something like this:
|
|
|
48
49
|
│ └── index.js
|
|
49
50
|
├── subdomain.example.com/
|
|
50
51
|
│ └── index.js
|
|
52
|
+
├── static-site.com/ # Static site: no index.js needed
|
|
53
|
+
│ ├── index.html
|
|
54
|
+
│ ├── css/
|
|
55
|
+
│ └── images/
|
|
51
56
|
├── other-domain.com/
|
|
52
57
|
│ └── index.js
|
|
53
58
|
└── *.example.com/ # Wildcard: one handler for all subdomains (api.example.com, app.example.com, etc.)
|
|
54
59
|
└── index.js
|
|
55
60
|
```
|
|
56
61
|
|
|
62
|
+
Each domain folder can have either:
|
|
63
|
+
- **Node app**: `index.js`, `index.mjs`, or `index.cjs` (exporting a request handler).
|
|
64
|
+
- **Static site**: `index.html` (and any assets). If no JS entry exists, RosterServer serves the folder as static files. Node takes precedence when both exist.
|
|
65
|
+
|
|
57
66
|
### Wildcard DNS (*.example.com)
|
|
58
67
|
|
|
59
68
|
You can serve all subdomains of a domain with a single handler in three ways:
|
|
@@ -107,7 +116,10 @@ server.start();
|
|
|
107
116
|
|
|
108
117
|
### Your Site Handlers
|
|
109
118
|
|
|
110
|
-
Each domain
|
|
119
|
+
Each domain has its own folder under `www`. You can use:
|
|
120
|
+
|
|
121
|
+
- **Node app**: Put `index.js` (or `index.mjs` / `index.cjs`) that exports a request handler function.
|
|
122
|
+
- **Static site**: Put `index.html` and your assets (CSS, JS, images). RosterServer will serve files from that folder. `GET /` serves `index.html`; other paths serve the file if it exists, or 404. Path traversal is blocked. If both an index script and `index.html` exist, the script is used.
|
|
111
123
|
|
|
112
124
|
### Examples
|
|
113
125
|
|
|
@@ -203,6 +215,17 @@ And that's it! Your server is now hosting multiple HTTPS-enabled sites. 🎉
|
|
|
203
215
|
|
|
204
216
|
## 🤯 But Wait, There's More!
|
|
205
217
|
|
|
218
|
+
### Static Sites (index.html)
|
|
219
|
+
|
|
220
|
+
Domains under `www` that have no `index.js`/`index.mjs`/`index.cjs` but do have `index.html` are served as static sites. The logic lives in `lib/static-site-handler.js` and `lib/resolve-site-app.js`:
|
|
221
|
+
|
|
222
|
+
- **`GET /`** and **`GET /index.html`** serve `index.html`.
|
|
223
|
+
- Any other path serves the file under the domain folder if it exists; otherwise **404** (strict, no SPA fallback).
|
|
224
|
+
- Path traversal (e.g. `/../`) is rejected with **403**.
|
|
225
|
+
- Content-Type is set from extension (html, css, js, images, fonts, etc.).
|
|
226
|
+
|
|
227
|
+
No Express or extra dependencies—plain Node. At startup you’ll see `(✔) Loaded site: https://example.com (static)` for these domains.
|
|
228
|
+
|
|
206
229
|
### Automatic SSL Certificate Management
|
|
207
230
|
|
|
208
231
|
RosterServer uses [greenlock-express](https://www.npmjs.com/package/greenlock-express) to automatically obtain and renew SSL certificates from Let's Encrypt. No need to manually manage certificates ever again. Unless you enjoy that sort of thing. 🧐
|
|
@@ -213,7 +236,7 @@ All requests to `www.yourdomain.com` are automatically redirected to `yourdomain
|
|
|
213
236
|
|
|
214
237
|
### Dynamic Site Loading
|
|
215
238
|
|
|
216
|
-
Add a new site?
|
|
239
|
+
Add a new site? Drop it into the `www` folder: either an `index.js` (or `.mjs`/`.cjs`) for a Node app, or an `index.html` (plus assets) for a static site. RosterServer picks the right handler automatically. Restart the server to load new sites—nodemon has your back. 😅
|
|
217
240
|
|
|
218
241
|
## ⚙️ Configuration Options
|
|
219
242
|
|
package/index.js
CHANGED
|
@@ -7,6 +7,7 @@ const crypto = require('crypto');
|
|
|
7
7
|
const { EventEmitter } = require('events');
|
|
8
8
|
const Greenlock = require('./vendor/greenlock-express/greenlock-express.js');
|
|
9
9
|
const GreenlockShim = require('./vendor/greenlock-express/greenlock-shim.js');
|
|
10
|
+
const { resolveSiteApp } = require('./lib/resolve-site-app.js');
|
|
10
11
|
const log = require('lemonlog')('roster');
|
|
11
12
|
|
|
12
13
|
const isBunRuntime = typeof Bun !== 'undefined' || (typeof process !== 'undefined' && process.release?.name === 'bun');
|
|
@@ -308,49 +309,38 @@ class Roster {
|
|
|
308
309
|
const domain = dirent.name;
|
|
309
310
|
const domainPath = path.join(this.wwwPath, domain);
|
|
310
311
|
|
|
311
|
-
|
|
312
|
-
|
|
312
|
+
let resolved;
|
|
313
|
+
try {
|
|
314
|
+
resolved = await resolveSiteApp(domainPath, { filename: this.filename });
|
|
315
|
+
} catch (err) {
|
|
316
|
+
log.warn(`⚠️ Error loading site in ${domainPath}:`, err);
|
|
317
|
+
continue;
|
|
318
|
+
}
|
|
313
319
|
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
try {
|
|
318
|
-
// Try dynamic import first
|
|
319
|
-
siteApp = await import(indexPath).catch(() => {
|
|
320
|
-
// Fallback to require for CommonJS modules
|
|
321
|
-
return require(indexPath);
|
|
322
|
-
});
|
|
323
|
-
// Handle default exports
|
|
324
|
-
siteApp = siteApp.default || siteApp;
|
|
325
|
-
break;
|
|
326
|
-
} catch (err) {
|
|
327
|
-
log.warn(`⚠️ Error loading ${indexPath}:`, err);
|
|
328
|
-
}
|
|
329
|
-
}
|
|
320
|
+
if (!resolved) {
|
|
321
|
+
log.warn(`⚠️ No index file (js/mjs/cjs or index.html) found in ${domainPath}`);
|
|
322
|
+
continue;
|
|
330
323
|
}
|
|
331
324
|
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
// Wildcard site: one handler for all subdomains (e.g. *.example.com)
|
|
339
|
-
this.domains.push(domain);
|
|
340
|
-
this.sites[domain] = siteApp;
|
|
341
|
-
const root = wildcardRoot(domain);
|
|
342
|
-
if (root) this.wildcardZones.add(root);
|
|
343
|
-
log.info(`(✔) Loaded wildcard site: https://${domain}`);
|
|
344
|
-
} else {
|
|
345
|
-
const domainEntries = [domain, `www.${domain}`];
|
|
346
|
-
this.domains.push(...domainEntries);
|
|
347
|
-
domainEntries.forEach(d => {
|
|
348
|
-
this.sites[d] = siteApp;
|
|
349
|
-
});
|
|
350
|
-
log.info(`(✔) Loaded site: https://${domain}`);
|
|
325
|
+
const { siteApp, type } = resolved;
|
|
326
|
+
|
|
327
|
+
if (domain.startsWith('*.')) {
|
|
328
|
+
if (this.disableWildcard) {
|
|
329
|
+
log.warn(`⚠️ Wildcard site skipped (disableWildcard enabled): ${domain}`);
|
|
330
|
+
continue;
|
|
351
331
|
}
|
|
332
|
+
this.domains.push(domain);
|
|
333
|
+
this.sites[domain] = siteApp;
|
|
334
|
+
const root = wildcardRoot(domain);
|
|
335
|
+
if (root) this.wildcardZones.add(root);
|
|
336
|
+
log.info(`(✔) Loaded wildcard site: https://${domain}${type === 'static' ? ' (static)' : ''}`);
|
|
352
337
|
} else {
|
|
353
|
-
|
|
338
|
+
const domainEntries = [domain, `www.${domain}`];
|
|
339
|
+
this.domains.push(...domainEntries);
|
|
340
|
+
domainEntries.forEach(d => {
|
|
341
|
+
this.sites[d] = siteApp;
|
|
342
|
+
});
|
|
343
|
+
log.info(`(✔) Loaded site: https://${domain}${type === 'static' ? ' (static)' : ''}`);
|
|
354
344
|
}
|
|
355
345
|
}
|
|
356
346
|
}
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const fs = require('fs');
|
|
4
|
+
const path = require('path');
|
|
5
|
+
const { createStaticHandler } = require('./static-site-handler.js');
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Resolve a site app for a domain directory.
|
|
9
|
+
* Tries index.js / index.mjs / index.cjs first; falls back to static (index.html) if present.
|
|
10
|
+
* @param {string} domainPath - Absolute path to the domain folder (e.g. www/example.com)
|
|
11
|
+
* @param {{ filename?: string }} options - Optional. filename defaults to 'index'.
|
|
12
|
+
* @returns {Promise<{ siteApp: function, type: 'js' | 'static' } | null>}
|
|
13
|
+
*/
|
|
14
|
+
async function resolveSiteApp(domainPath, options = {}) {
|
|
15
|
+
const filename = options.filename || 'index';
|
|
16
|
+
const possibleIndexFiles = ['js', 'mjs', 'cjs'].map(ext => `${filename}.${ext}`);
|
|
17
|
+
|
|
18
|
+
for (const indexFile of possibleIndexFiles) {
|
|
19
|
+
const indexPath = path.join(domainPath, indexFile);
|
|
20
|
+
if (fs.existsSync(indexPath)) {
|
|
21
|
+
try {
|
|
22
|
+
let siteApp = await import(indexPath).catch(() => {
|
|
23
|
+
return require(indexPath);
|
|
24
|
+
});
|
|
25
|
+
siteApp = siteApp.default || siteApp;
|
|
26
|
+
return { siteApp, type: 'js' };
|
|
27
|
+
} catch (err) {
|
|
28
|
+
throw err;
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
const indexHtmlPath = path.join(domainPath, 'index.html');
|
|
34
|
+
if (fs.existsSync(indexHtmlPath)) {
|
|
35
|
+
const siteApp = createStaticHandler(domainPath);
|
|
36
|
+
return { siteApp, type: 'static' };
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
return null;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
module.exports = { resolveSiteApp };
|
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const fs = require('fs');
|
|
4
|
+
const path = require('path');
|
|
5
|
+
|
|
6
|
+
const MIME_BY_EXT = {
|
|
7
|
+
html: 'text/html',
|
|
8
|
+
htm: 'text/html',
|
|
9
|
+
css: 'text/css',
|
|
10
|
+
js: 'application/javascript',
|
|
11
|
+
json: 'application/json',
|
|
12
|
+
png: 'image/png',
|
|
13
|
+
jpg: 'image/jpeg',
|
|
14
|
+
jpeg: 'image/jpeg',
|
|
15
|
+
gif: 'image/gif',
|
|
16
|
+
ico: 'image/x-icon',
|
|
17
|
+
svg: 'image/svg+xml',
|
|
18
|
+
webp: 'image/webp',
|
|
19
|
+
woff: 'font/woff',
|
|
20
|
+
woff2: 'font/woff2',
|
|
21
|
+
ttf: 'font/ttf',
|
|
22
|
+
eot: 'application/vnd.ms-fontobject',
|
|
23
|
+
map: 'application/json',
|
|
24
|
+
txt: 'text/plain',
|
|
25
|
+
xml: 'application/xml',
|
|
26
|
+
pdf: 'application/pdf'
|
|
27
|
+
};
|
|
28
|
+
|
|
29
|
+
const DEFAULT_MIME = 'application/octet-stream';
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Resolve request path to a safe filesystem path under rootPath.
|
|
33
|
+
* Returns null if path escapes rootPath (traversal) or is invalid.
|
|
34
|
+
* @param {string} rootPath - Absolute directory root
|
|
35
|
+
* @param {string} requestPath - URL path (e.g. /css/style.css)
|
|
36
|
+
* @returns {string|null} Absolute file path or null
|
|
37
|
+
*/
|
|
38
|
+
function resolvePath(rootPath, requestPath) {
|
|
39
|
+
const normalized = path.normalize(requestPath.replace(/^\//, '').replace(/\/+/g, path.sep));
|
|
40
|
+
if (normalized.startsWith('..') || normalized.includes('..' + path.sep) || normalized.includes(path.sep + '..')) {
|
|
41
|
+
return null;
|
|
42
|
+
}
|
|
43
|
+
const realRoot = path.resolve(rootPath);
|
|
44
|
+
const absolute = path.resolve(rootPath, normalized);
|
|
45
|
+
if (absolute !== realRoot && !absolute.startsWith(realRoot + path.sep)) {
|
|
46
|
+
return null;
|
|
47
|
+
}
|
|
48
|
+
return absolute;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* Get Content-Type for a file path.
|
|
53
|
+
* @param {string} filePath
|
|
54
|
+
* @returns {string}
|
|
55
|
+
*/
|
|
56
|
+
function getContentType(filePath) {
|
|
57
|
+
const ext = path.extname(filePath).slice(1).toLowerCase();
|
|
58
|
+
return MIME_BY_EXT[ext] || DEFAULT_MIME;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* Create a static site handler that serves files from rootPath.
|
|
63
|
+
* Compatible with Roster contract: (virtualServer) => (req, res) => void.
|
|
64
|
+
* - GET / or /index.html serves index.html
|
|
65
|
+
* - Serves existing files by path; 404 otherwise (strict mode)
|
|
66
|
+
* - Path traversal protected
|
|
67
|
+
* @param {string} rootPath - Absolute path to site root (e.g. www/example.com)
|
|
68
|
+
* @returns {function(virtualServer): function(req, res): void}
|
|
69
|
+
*/
|
|
70
|
+
function createStaticHandler(rootPath) {
|
|
71
|
+
const root = path.resolve(rootPath);
|
|
72
|
+
|
|
73
|
+
return function staticSiteFactory(virtualServer) {
|
|
74
|
+
return function staticHandler(req, res) {
|
|
75
|
+
if (req.method !== 'GET' && req.method !== 'HEAD') {
|
|
76
|
+
res.writeHead(405, { 'Content-Type': 'text/plain' });
|
|
77
|
+
res.end('Method Not Allowed');
|
|
78
|
+
return;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
let requestPath = (req.url || '/').split('?')[0];
|
|
82
|
+
if (requestPath === '' || requestPath === '/') {
|
|
83
|
+
requestPath = '/index.html';
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
const filePath = resolvePath(root, requestPath);
|
|
87
|
+
if (!filePath) {
|
|
88
|
+
res.writeHead(403, { 'Content-Type': 'text/plain' });
|
|
89
|
+
res.end('Forbidden');
|
|
90
|
+
return;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
if (!fs.existsSync(filePath)) {
|
|
94
|
+
res.writeHead(404, { 'Content-Type': 'text/plain' });
|
|
95
|
+
res.end('Not Found');
|
|
96
|
+
return;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
const stat = fs.statSync(filePath);
|
|
100
|
+
let servePath = filePath;
|
|
101
|
+
if (!stat.isFile()) {
|
|
102
|
+
if (!stat.isDirectory()) {
|
|
103
|
+
res.writeHead(404, { 'Content-Type': 'text/plain' });
|
|
104
|
+
res.end('Not Found');
|
|
105
|
+
return;
|
|
106
|
+
}
|
|
107
|
+
const indexInDir = path.join(filePath, 'index.html');
|
|
108
|
+
if (!fs.existsSync(indexInDir) || !fs.statSync(indexInDir).isFile()) {
|
|
109
|
+
res.writeHead(404, { 'Content-Type': 'text/plain' });
|
|
110
|
+
res.end('Not Found');
|
|
111
|
+
return;
|
|
112
|
+
}
|
|
113
|
+
servePath = indexInDir;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
const contentType = getContentType(servePath);
|
|
117
|
+
const content = fs.readFileSync(servePath);
|
|
118
|
+
|
|
119
|
+
res.writeHead(200, {
|
|
120
|
+
'Content-Type': contentType,
|
|
121
|
+
'Content-Length': content.length
|
|
122
|
+
});
|
|
123
|
+
if (req.method === 'GET') {
|
|
124
|
+
res.end(content);
|
|
125
|
+
} else {
|
|
126
|
+
res.end();
|
|
127
|
+
}
|
|
128
|
+
};
|
|
129
|
+
};
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
module.exports = { createStaticHandler, resolvePath, getContentType, MIME_BY_EXT };
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
---
|
|
2
2
|
name: roster-server
|
|
3
|
-
description: Virtual hosting for multiple HTTPS sites with Let's Encrypt SSL automation. Each domain gets isolated VirtualServer instance, supports Express/Socket.IO/custom handlers, local HTTP dev mode with CRC32-based ports, automatic www redirects, and SNI certificate management.
|
|
3
|
+
description: Virtual hosting for multiple HTTPS sites with Let's Encrypt SSL automation. Each domain gets isolated VirtualServer instance, supports Express/Socket.IO/custom handlers, static sites (index.html only, no Node), local HTTP dev mode with CRC32-based ports, automatic www redirects, and SNI certificate management. Static site logic is modular (lib/static-site-handler.js, lib/resolve-site-app.js).
|
|
4
4
|
---
|
|
5
5
|
|
|
6
6
|
## Quick Setup
|
|
@@ -42,14 +42,22 @@ project/
|
|
|
42
42
|
│ │ └── index.js # Handler for example.com
|
|
43
43
|
│ ├── api.example.com/
|
|
44
44
|
│ │ └── index.js # Handler for subdomain
|
|
45
|
+
│ ├── static-site.com/ # Static site (no index.js)
|
|
46
|
+
│ │ ├── index.html
|
|
47
|
+
│ │ ├── css/
|
|
48
|
+
│ │ └── images/
|
|
45
49
|
│ └── *.example.com/
|
|
46
50
|
│ └── index.js # Wildcard: one handler for all subdomains
|
|
47
51
|
└── server.js # Your setup
|
|
48
52
|
```
|
|
49
53
|
|
|
54
|
+
**Site resolution**: For each domain folder, RosterServer looks for `index.js` / `index.mjs` / `index.cjs` first. If none exist but `index.html` exists, it serves the folder as a static site (modular handler in `lib/static-site-handler.js`). Node app takes precedence when both exist.
|
|
55
|
+
|
|
50
56
|
## Handler Patterns
|
|
51
57
|
|
|
52
|
-
Each `www/{domain}/index.js` must export a function that receives `httpsServer` and returns a request handler.
|
|
58
|
+
**Node app**: Each `www/{domain}/index.js` (or `.mjs`/`.cjs`) must export a function that receives `httpsServer` and returns a request handler.
|
|
59
|
+
|
|
60
|
+
**Static site**: If the domain folder has no index script but has `index.html`, RosterServer serves the folder as static files (`GET /` → `index.html`, other paths → file or 404, path-traversal protected). No code required.
|
|
53
61
|
|
|
54
62
|
### Pattern 1: Basic HTTP Handler
|
|
55
63
|
```javascript
|
|
@@ -112,6 +120,9 @@ roster.register('*.example.com', handler);
|
|
|
112
120
|
roster.register('*.example.com:8080', handler);
|
|
113
121
|
```
|
|
114
122
|
|
|
123
|
+
### Pattern 5: Static Site (no code)
|
|
124
|
+
Place only `index.html` (and assets) in `www/example.com/`. No `index.js` needed. RosterServer serves files with path-traversal protection; `/` → `index.html`, other paths → file or 404. Implemented in `lib/static-site-handler.js` and `lib/resolve-site-app.js`.
|
|
125
|
+
|
|
115
126
|
## Key Configuration Options
|
|
116
127
|
|
|
117
128
|
```javascript
|
|
@@ -183,7 +194,7 @@ Each domain gets isolated server instance that simulates `http.Server`:
|
|
|
183
194
|
|
|
184
195
|
**Port 443 in use**: Use different port `{ port: 8443 }`
|
|
185
196
|
**Certificate failed**: Check firewall (ports 80, 443), verify DNS, try `staging: true`
|
|
186
|
-
**Site not found**: Verify directory name matches domain
|
|
197
|
+
**Site not found**: Verify directory name matches domain. For Node: check `index.js` exports function. For static: ensure `index.html` exists (no index script).
|
|
187
198
|
**Local port conflict**: Adjust `minLocalPort`/`maxLocalPort` range
|
|
188
199
|
**Socket.IO not working**: Ensure handler checks `io.opts.path` and returns properly
|
|
189
200
|
|
|
@@ -253,7 +264,7 @@ roster.start();
|
|
|
253
264
|
When implementing RosterServer:
|
|
254
265
|
|
|
255
266
|
- [ ] Create `www/` directory structure with domain folders
|
|
256
|
-
- [ ] Each domain has `index.js` exporting `(httpsServer) => handler`
|
|
267
|
+
- [ ] Each domain has either `index.js` (or `.mjs`/`.cjs`) exporting `(httpsServer) => handler`, or `index.html` (and assets) for a static site
|
|
257
268
|
- [ ] Configure email for Let's Encrypt notifications
|
|
258
269
|
- [ ] Test with `local: true` first
|
|
259
270
|
- [ ] Test with `staging: true` before production
|
|
@@ -539,6 +539,120 @@ describe('Roster loadSites', () => {
|
|
|
539
539
|
});
|
|
540
540
|
await assert.doesNotReject(roster.loadSites());
|
|
541
541
|
});
|
|
542
|
+
|
|
543
|
+
it('loads static site from www/domain when index.html exists and no index.js', async () => {
|
|
544
|
+
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'roster-test-'));
|
|
545
|
+
const wwwPath = path.join(tmpDir, 'www');
|
|
546
|
+
const siteDir = path.join(wwwPath, 'static.example');
|
|
547
|
+
fs.mkdirSync(siteDir, { recursive: true });
|
|
548
|
+
fs.writeFileSync(path.join(siteDir, 'index.html'), '<html>hello</html>', 'utf8');
|
|
549
|
+
try {
|
|
550
|
+
const roster = new Roster({ wwwPath, local: true });
|
|
551
|
+
await roster.loadSites();
|
|
552
|
+
assert.ok(roster.sites['static.example']);
|
|
553
|
+
assert.ok(roster.sites['www.static.example']);
|
|
554
|
+
const handler = roster.sites['static.example'];
|
|
555
|
+
assert.strictEqual(typeof handler, 'function');
|
|
556
|
+
const appHandler = handler(roster.createVirtualServer('static.example'));
|
|
557
|
+
assert.strictEqual(typeof appHandler, 'function');
|
|
558
|
+
} finally {
|
|
559
|
+
fs.rmSync(tmpDir, { recursive: true, force: true });
|
|
560
|
+
}
|
|
561
|
+
});
|
|
562
|
+
|
|
563
|
+
it('loads index.js over index.html when both exist', async () => {
|
|
564
|
+
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'roster-test-'));
|
|
565
|
+
const wwwPath = path.join(tmpDir, 'www');
|
|
566
|
+
const siteDir = path.join(wwwPath, 'both.example');
|
|
567
|
+
fs.mkdirSync(siteDir, { recursive: true });
|
|
568
|
+
fs.writeFileSync(path.join(siteDir, 'index.html'), '<html>static</html>', 'utf8');
|
|
569
|
+
fs.writeFileSync(
|
|
570
|
+
path.join(siteDir, 'index.js'),
|
|
571
|
+
'module.exports = () => (req, res) => { res.writeHead(200); res.end("js"); };',
|
|
572
|
+
'utf8'
|
|
573
|
+
);
|
|
574
|
+
try {
|
|
575
|
+
const roster = new Roster({ wwwPath, local: true });
|
|
576
|
+
await roster.loadSites();
|
|
577
|
+
const handler = roster.sites['both.example'];
|
|
578
|
+
const appHandler = handler(roster.createVirtualServer('both.example'));
|
|
579
|
+
let body = '';
|
|
580
|
+
const res = { writeHead: () => {}, end: (b) => { body = (b || '').toString(); } };
|
|
581
|
+
appHandler({ url: '/', method: 'GET' }, res);
|
|
582
|
+
assert.strictEqual(body, 'js');
|
|
583
|
+
} finally {
|
|
584
|
+
fs.rmSync(tmpDir, { recursive: true, force: true });
|
|
585
|
+
}
|
|
586
|
+
});
|
|
587
|
+
|
|
588
|
+
it('static site serves index.html for / in local mode', async () => {
|
|
589
|
+
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'roster-test-'));
|
|
590
|
+
const wwwPath = path.join(tmpDir, 'www');
|
|
591
|
+
const siteDir = path.join(wwwPath, 'staticlocal.example');
|
|
592
|
+
fs.mkdirSync(siteDir, { recursive: true });
|
|
593
|
+
const html = '<html><body>static ok</body></html>';
|
|
594
|
+
fs.writeFileSync(path.join(siteDir, 'index.html'), html, 'utf8');
|
|
595
|
+
const roster = new Roster({ wwwPath, local: true, minLocalPort: 19200, maxLocalPort: 19209 });
|
|
596
|
+
try {
|
|
597
|
+
await roster.start();
|
|
598
|
+
const port = roster.domainPorts['staticlocal.example'];
|
|
599
|
+
assert.ok(typeof port === 'number');
|
|
600
|
+
await new Promise((r) => setTimeout(r, 50));
|
|
601
|
+
const result = await httpGet('localhost', port, '/');
|
|
602
|
+
assert.strictEqual(result.statusCode, 200);
|
|
603
|
+
assert.ok(result.body.includes('static ok'));
|
|
604
|
+
} finally {
|
|
605
|
+
closePortServers(roster);
|
|
606
|
+
fs.rmSync(tmpDir, { recursive: true, force: true });
|
|
607
|
+
}
|
|
608
|
+
});
|
|
609
|
+
|
|
610
|
+
it('static site returns 404 for non-existent path', async () => {
|
|
611
|
+
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'roster-test-'));
|
|
612
|
+
const wwwPath = path.join(tmpDir, 'www');
|
|
613
|
+
const siteDir = path.join(wwwPath, 'static404.example');
|
|
614
|
+
fs.mkdirSync(siteDir, { recursive: true });
|
|
615
|
+
fs.writeFileSync(path.join(siteDir, 'index.html'), '<html>ok</html>', 'utf8');
|
|
616
|
+
const roster = new Roster({ wwwPath, local: true, minLocalPort: 19210, maxLocalPort: 19219 });
|
|
617
|
+
try {
|
|
618
|
+
await roster.start();
|
|
619
|
+
const port = roster.domainPorts['static404.example'];
|
|
620
|
+
assert.ok(typeof port === 'number');
|
|
621
|
+
await new Promise((r) => setTimeout(r, 50));
|
|
622
|
+
const result = await httpGet('localhost', port, '/nonexistent.html');
|
|
623
|
+
assert.strictEqual(result.statusCode, 404);
|
|
624
|
+
} finally {
|
|
625
|
+
closePortServers(roster);
|
|
626
|
+
fs.rmSync(tmpDir, { recursive: true, force: true });
|
|
627
|
+
}
|
|
628
|
+
});
|
|
629
|
+
|
|
630
|
+
it('static site serves index.html for subpath directory (/en/)', async () => {
|
|
631
|
+
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'roster-test-'));
|
|
632
|
+
const wwwPath = path.join(tmpDir, 'www');
|
|
633
|
+
const siteDir = path.join(wwwPath, 'subpath.example');
|
|
634
|
+
fs.mkdirSync(siteDir, { recursive: true });
|
|
635
|
+
fs.writeFileSync(path.join(siteDir, 'index.html'), '<html>root</html>', 'utf8');
|
|
636
|
+
const enDir = path.join(siteDir, 'en');
|
|
637
|
+
fs.mkdirSync(enDir, { recursive: true });
|
|
638
|
+
fs.writeFileSync(path.join(enDir, 'index.html'), '<html><body>en page</body></html>', 'utf8');
|
|
639
|
+
const roster = new Roster({ wwwPath, local: true, minLocalPort: 19220, maxLocalPort: 19229 });
|
|
640
|
+
try {
|
|
641
|
+
await roster.start();
|
|
642
|
+
const port = roster.domainPorts['subpath.example'];
|
|
643
|
+
assert.ok(typeof port === 'number');
|
|
644
|
+
await new Promise((r) => setTimeout(r, 50));
|
|
645
|
+
const resultSlash = await httpGet('localhost', port, '/en/');
|
|
646
|
+
assert.strictEqual(resultSlash.statusCode, 200);
|
|
647
|
+
assert.ok(resultSlash.body.includes('en page'));
|
|
648
|
+
const resultNoSlash = await httpGet('localhost', port, '/en');
|
|
649
|
+
assert.strictEqual(resultNoSlash.statusCode, 200);
|
|
650
|
+
assert.ok(resultNoSlash.body.includes('en page'));
|
|
651
|
+
} finally {
|
|
652
|
+
closePortServers(roster);
|
|
653
|
+
fs.rmSync(tmpDir, { recursive: true, force: true });
|
|
654
|
+
}
|
|
655
|
+
});
|
|
542
656
|
});
|
|
543
657
|
|
|
544
658
|
describe('Roster generateConfigJson', () => {
|
package/tasks/lessons.md
DELETED
|
@@ -1,5 +0,0 @@
|
|
|
1
|
-
# Lessons Learned
|
|
2
|
-
|
|
3
|
-
- When wildcard TLS must run under Bun, do not rely on manual DNS instructions; default to API-driven DNS-01 TXT creation/removal (Linode/Akamai) with propagation polling, then fall back to manual mode only when no provider token is configured.
|
|
4
|
-
- Do not keep speculative resolver/workaround attempts that are not the root cause; if a change does not resolve the issue with evidence, revert/simplify immediately so temporary experiments do not become permanent complexity.
|
|
5
|
-
- Never declare TLS/wildcard fixed based only on ACME success logs; always verify the certificate actually served to the target host with `openssl s_client` before closing the issue.
|