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 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 should have its own folder under `www`, containing an `index.js` that exports a request handler function.
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? Just drop it into the `www` folder with an `index.js` file, and RosterServer will handle the rest. No need to restart the server. Well, you might need to restart the server. But that's what `nodemon` is for, right? 😅
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
- const possibleIndexFiles = ['js', 'mjs', 'cjs'].map(ext => `${this.filename}.${ext}`);
312
- let siteApp;
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
- for (const indexFile of possibleIndexFiles) {
315
- const indexPath = path.join(domainPath, indexFile);
316
- if (fs.existsSync(indexPath)) {
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
- if (siteApp) {
333
- if (domain.startsWith('*.')) {
334
- if (this.disableWildcard) {
335
- log.warn(`⚠️ Wildcard site skipped (disableWildcard enabled): ${domain}`);
336
- continue;
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
- log.warn(`⚠️ No index file (js/mjs/cjs) found in ${domainPath}`);
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
- "version": "2.2.12",
3
+ "version": "2.3.2",
4
4
  "description": "👾 RosterServer - A domain host router to host multiple HTTPS.",
5
5
  "main": "index.js",
6
6
  "scripts": {
@@ -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, check `index.js` exports function
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.