roster-server 2.4.0 β 2.4.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 +48 -39
- package/demo/cluster-sticky-https-sni.js +90 -0
- package/demo/cluster-sticky-worker.js +60 -0
- package/docs/architecture/decision-roster-cluster-friendly-api.md +30 -0
- package/index.js +204 -269
- package/package.json +1 -1
- package/skills/roster-server/SKILL.md +39 -59
- package/test/roster-server.test.js +273 -150
- package/docs/decisions/external-runtime-router-api.md +0 -12
- package/docs/patterns/roster-dispatch-reuse.md +0 -12
package/package.json
CHANGED
|
@@ -13,7 +13,7 @@ const roster = new Roster({
|
|
|
13
13
|
email: 'admin@example.com',
|
|
14
14
|
wwwPath: '/srv/www',
|
|
15
15
|
greenlockStorePath: '/srv/greenlock.d',
|
|
16
|
-
local:
|
|
16
|
+
local: true
|
|
17
17
|
});
|
|
18
18
|
|
|
19
19
|
roster.start();
|
|
@@ -123,6 +123,30 @@ roster.register('*.example.com:8080', handler);
|
|
|
123
123
|
### Pattern 5: Static Site (no code)
|
|
124
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
125
|
|
|
126
|
+
### Pattern 6: Cluster-Friendly (external server)
|
|
127
|
+
```javascript
|
|
128
|
+
const https = require('https');
|
|
129
|
+
const Roster = require('roster-server');
|
|
130
|
+
|
|
131
|
+
const roster = new Roster({
|
|
132
|
+
email: 'admin@example.com',
|
|
133
|
+
wwwPath: '/srv/www',
|
|
134
|
+
greenlockStorePath: '/srv/greenlock.d'
|
|
135
|
+
});
|
|
136
|
+
|
|
137
|
+
await roster.init();
|
|
138
|
+
|
|
139
|
+
const server = https.createServer({ SNICallback: roster.sniCallback() });
|
|
140
|
+
roster.attach(server);
|
|
141
|
+
|
|
142
|
+
// Master passes connections β worker never calls listen()
|
|
143
|
+
process.on('message', (msg, connection) => {
|
|
144
|
+
if (msg === 'sticky-session:connection') {
|
|
145
|
+
server.emit('connection', connection);
|
|
146
|
+
}
|
|
147
|
+
});
|
|
148
|
+
```
|
|
149
|
+
|
|
126
150
|
## Key Configuration Options
|
|
127
151
|
|
|
128
152
|
```javascript
|
|
@@ -137,7 +161,7 @@ new Roster({
|
|
|
137
161
|
staging: false, // true = Let's Encrypt staging
|
|
138
162
|
|
|
139
163
|
// Server
|
|
140
|
-
hostname: '
|
|
164
|
+
hostname: '::',
|
|
141
165
|
port: 443, // Default HTTPS port (NOT 80!)
|
|
142
166
|
|
|
143
167
|
// Local mode
|
|
@@ -153,38 +177,25 @@ new Roster({
|
|
|
153
177
|
## Core API
|
|
154
178
|
|
|
155
179
|
### `roster.start()`
|
|
156
|
-
Loads sites, generates SSL config, starts servers. Returns `Promise<void>`.
|
|
157
|
-
|
|
158
|
-
### `roster.register(domain, handler, options?)`
|
|
159
|
-
Manually register a domain handler. Domain can include port: `'api.com:8443'`. For wildcards use `'*.example.com'` or `'*.example.com:8080'`.
|
|
180
|
+
Loads sites, generates SSL config, starts servers. Returns `Promise<void>`. Calls `init()` internally.
|
|
160
181
|
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
- `skipDomainBookkeeping: true` avoids `domains[]` side effects when external runtime owns registration/state
|
|
182
|
+
### `roster.init()`
|
|
183
|
+
Loads sites, creates VirtualServers, prepares dispatchers β but creates **no servers** and calls **no `.listen()`**. Returns `Promise<Roster>`. Idempotent. Use this for cluster-friendly integration where an external manager owns the socket.
|
|
164
184
|
|
|
165
|
-
### `roster.
|
|
166
|
-
|
|
185
|
+
### `roster.requestHandler(port?)`
|
|
186
|
+
Returns `(req, res) => void` dispatcher for a port (defaults to `defaultPort`). Requires `init()` first. Handles Host-header routing, wwwβnon-www redirects, wildcard matching.
|
|
167
187
|
|
|
168
|
-
|
|
169
|
-
|
|
188
|
+
### `roster.upgradeHandler(port?)`
|
|
189
|
+
Returns `(req, socket, head) => void` for WebSocket upgrade routing. Requires `init()` first.
|
|
170
190
|
|
|
171
|
-
|
|
172
|
-
|
|
191
|
+
### `roster.sniCallback()`
|
|
192
|
+
Returns `(servername, callback) => void` TLS SNI callback that resolves certs from `greenlockStorePath`. Production mode only. Requires `init()` first.
|
|
173
193
|
|
|
174
|
-
### `roster.
|
|
175
|
-
|
|
194
|
+
### `roster.attach(server, { port }?)`
|
|
195
|
+
Convenience: wires `requestHandler` + `upgradeHandler` onto an external `http.Server` or `https.Server`. Returns `this`. Requires `init()` first.
|
|
176
196
|
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
- `hostAliases` (optional): object map or callback `(host) => mappedHost`
|
|
180
|
-
- `allowWwwRedirect` (optional, default `true`)
|
|
181
|
-
|
|
182
|
-
Returns:
|
|
183
|
-
- `attach(server)` binds request + upgrade handlers to an existing server
|
|
184
|
-
- `dispatchRequest(req, res)` pure request dispatcher
|
|
185
|
-
- `dispatchUpgrade(req, socket, head)` pure upgrade dispatcher
|
|
186
|
-
- `portData` routing snapshot
|
|
187
|
-
- `diagnostics` metadata (`targetPort`, hosts, virtual servers)
|
|
197
|
+
### `roster.register(domain, handler)`
|
|
198
|
+
Manually register a domain handler. Domain can include port: `'api.com:8443'`. For wildcards use `'*.example.com'` or `'*.example.com:8080'`.
|
|
188
199
|
|
|
189
200
|
### `roster.getUrl(domain)`
|
|
190
201
|
Get environment-aware URL:
|
|
@@ -200,12 +211,6 @@ Get environment-aware URL:
|
|
|
200
211
|
3. Looks up domain β Gets `VirtualServer` instance
|
|
201
212
|
4. Routes to handler via `virtualServer.processRequest(req, res)`
|
|
202
213
|
|
|
203
|
-
### External Runtime Flow (cluster/sticky workers)
|
|
204
|
-
1. Register handlers with `roster.register(...)`
|
|
205
|
-
2. Build router via `roster.buildRuntimeRouter(...)`
|
|
206
|
-
3. Attach to externally created server: `router.attach(server)`
|
|
207
|
-
4. External runtime calls `server.listen(...)`
|
|
208
|
-
|
|
209
214
|
### VirtualServer Architecture
|
|
210
215
|
Each domain gets isolated server instance that simulates `http.Server`:
|
|
211
216
|
- Captures `request` and `upgrade` event listeners
|
|
@@ -276,30 +281,6 @@ roster.register('test.local', (server) => {
|
|
|
276
281
|
roster.start();
|
|
277
282
|
```
|
|
278
283
|
|
|
279
|
-
### External Cluster/Sticky Worker
|
|
280
|
-
```javascript
|
|
281
|
-
const http = require('http');
|
|
282
|
-
const Roster = require('roster-server');
|
|
283
|
-
|
|
284
|
-
const roster = new Roster({ local: false, port: 443 });
|
|
285
|
-
|
|
286
|
-
roster.register('example.com', (virtualServer) => {
|
|
287
|
-
return (req, res) => {
|
|
288
|
-
res.writeHead(200, { 'Content-Type': 'text/plain' });
|
|
289
|
-
res.end('worker response');
|
|
290
|
-
};
|
|
291
|
-
}, { silent: true });
|
|
292
|
-
|
|
293
|
-
const server = http.createServer();
|
|
294
|
-
const router = roster.buildRuntimeRouter({
|
|
295
|
-
targetPort: 443,
|
|
296
|
-
hostAliases: { localhost: 'example.com' }
|
|
297
|
-
});
|
|
298
|
-
|
|
299
|
-
router.attach(server);
|
|
300
|
-
server.listen(3000); // owned by external runtime
|
|
301
|
-
```
|
|
302
|
-
|
|
303
284
|
### Environment-Aware Configuration
|
|
304
285
|
```javascript
|
|
305
286
|
const isProduction = process.env.NODE_ENV === 'production';
|
|
@@ -332,4 +313,3 @@ When implementing RosterServer:
|
|
|
332
313
|
- [ ] Use `roster.getUrl(domain)` for environment-aware URLs
|
|
333
314
|
- [ ] Handle Socket.IO paths correctly in returned handler
|
|
334
315
|
- [ ] Implement error handling in handlers
|
|
335
|
-
- [ ] For sticky/cluster runtimes, use `buildRuntimeRouter().attach(server)` instead of `start()`
|
|
@@ -6,7 +6,6 @@ const path = require('path');
|
|
|
6
6
|
const fs = require('fs');
|
|
7
7
|
const http = require('http');
|
|
8
8
|
const os = require('os');
|
|
9
|
-
const rosterLog = require('lemonlog')('roster');
|
|
10
9
|
const Roster = require('../index.js');
|
|
11
10
|
const {
|
|
12
11
|
wildcardRoot,
|
|
@@ -40,20 +39,6 @@ function httpGet(host, port, pathname = '/') {
|
|
|
40
39
|
});
|
|
41
40
|
}
|
|
42
41
|
|
|
43
|
-
async function withPatchedInfoLogger(fn) {
|
|
44
|
-
const originalInfo = rosterLog.info;
|
|
45
|
-
const messages = [];
|
|
46
|
-
rosterLog.info = (...args) => {
|
|
47
|
-
messages.push(args.map((value) => String(value)).join(' '));
|
|
48
|
-
};
|
|
49
|
-
try {
|
|
50
|
-
await fn();
|
|
51
|
-
} finally {
|
|
52
|
-
rosterLog.info = originalInfo;
|
|
53
|
-
}
|
|
54
|
-
return messages;
|
|
55
|
-
}
|
|
56
|
-
|
|
57
42
|
describe('wildcardRoot', () => {
|
|
58
43
|
it('returns root domain for *.example.com', () => {
|
|
59
44
|
assert.strictEqual(wildcardRoot('*.example.com'), 'example.com');
|
|
@@ -350,21 +335,6 @@ describe('Roster', () => {
|
|
|
350
335
|
assert.strictEqual(roster.sites['api.example.com'], handler);
|
|
351
336
|
assert.strictEqual(roster.sites['www.api.example.com'], undefined);
|
|
352
337
|
});
|
|
353
|
-
it('skipDomainBookkeeping avoids domain list side effects', () => {
|
|
354
|
-
const roster = new Roster({ local: true });
|
|
355
|
-
const handler = () => {};
|
|
356
|
-
roster.register('bookkeep.example', handler, { skipDomainBookkeeping: true });
|
|
357
|
-
assert.strictEqual(roster.sites['bookkeep.example'], handler);
|
|
358
|
-
assert.strictEqual(roster.sites['www.bookkeep.example'], handler);
|
|
359
|
-
assert.deepStrictEqual(roster.domains, []);
|
|
360
|
-
});
|
|
361
|
-
it('silent register suppresses registration logs', async () => {
|
|
362
|
-
const roster = new Roster({ local: true });
|
|
363
|
-
const messages = await withPatchedInfoLogger(async () => {
|
|
364
|
-
roster.register('silent.example', () => {}, { silent: true });
|
|
365
|
-
});
|
|
366
|
-
assert.strictEqual(messages.some((line) => line.includes('Registered site: silent.example')), false);
|
|
367
|
-
});
|
|
368
338
|
});
|
|
369
339
|
|
|
370
340
|
describe('getUrl (exact domain)', () => {
|
|
@@ -445,111 +415,6 @@ describe('Roster', () => {
|
|
|
445
415
|
});
|
|
446
416
|
});
|
|
447
417
|
|
|
448
|
-
describe('Roster buildRuntimeRouter', () => {
|
|
449
|
-
it('attaches to external HTTP server and dispatches exact + wildcard hosts', async () => {
|
|
450
|
-
const roster = new Roster({ local: true, hostname: 'localhost' });
|
|
451
|
-
roster.register('example.com', () => (req, res) => {
|
|
452
|
-
res.writeHead(200, { 'Content-Type': 'text/plain' });
|
|
453
|
-
res.end('exact');
|
|
454
|
-
});
|
|
455
|
-
roster.register('*.wild.example', () => (req, res) => {
|
|
456
|
-
res.writeHead(200, { 'Content-Type': 'text/plain' });
|
|
457
|
-
res.end('wild');
|
|
458
|
-
});
|
|
459
|
-
|
|
460
|
-
const server = http.createServer();
|
|
461
|
-
const router = roster.buildRuntimeRouter({ targetPort: roster.defaultPort });
|
|
462
|
-
router.attach(server);
|
|
463
|
-
|
|
464
|
-
await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve));
|
|
465
|
-
const { port } = server.address();
|
|
466
|
-
try {
|
|
467
|
-
const exact = await new Promise((resolve, reject) => {
|
|
468
|
-
const req = http.request({
|
|
469
|
-
host: '127.0.0.1',
|
|
470
|
-
port,
|
|
471
|
-
path: '/',
|
|
472
|
-
method: 'GET',
|
|
473
|
-
headers: { host: 'example.com' }
|
|
474
|
-
}, (res) => {
|
|
475
|
-
let body = '';
|
|
476
|
-
res.on('data', (chunk) => { body += chunk; });
|
|
477
|
-
res.on('end', () => resolve({ statusCode: res.statusCode, body }));
|
|
478
|
-
});
|
|
479
|
-
req.on('error', reject);
|
|
480
|
-
req.end();
|
|
481
|
-
});
|
|
482
|
-
assert.strictEqual(exact.statusCode, 200);
|
|
483
|
-
assert.strictEqual(exact.body, 'exact');
|
|
484
|
-
|
|
485
|
-
const wildcard = await new Promise((resolve, reject) => {
|
|
486
|
-
const req = http.request({
|
|
487
|
-
host: '127.0.0.1',
|
|
488
|
-
port,
|
|
489
|
-
path: '/',
|
|
490
|
-
method: 'GET',
|
|
491
|
-
headers: { host: 'api.wild.example' }
|
|
492
|
-
}, (res) => {
|
|
493
|
-
let body = '';
|
|
494
|
-
res.on('data', (chunk) => { body += chunk; });
|
|
495
|
-
res.on('end', () => resolve({ statusCode: res.statusCode, body }));
|
|
496
|
-
});
|
|
497
|
-
req.on('error', reject);
|
|
498
|
-
req.end();
|
|
499
|
-
});
|
|
500
|
-
assert.strictEqual(wildcard.statusCode, 200);
|
|
501
|
-
assert.strictEqual(wildcard.body, 'wild');
|
|
502
|
-
} finally {
|
|
503
|
-
await new Promise((resolve) => server.close(resolve));
|
|
504
|
-
}
|
|
505
|
-
});
|
|
506
|
-
|
|
507
|
-
it('dispatchUpgrade reaches virtual server upgrade listeners', () => {
|
|
508
|
-
const roster = new Roster({ local: true });
|
|
509
|
-
let upgraded = false;
|
|
510
|
-
roster.register('sio.example.com', (virtualServer) => {
|
|
511
|
-
virtualServer.on('upgrade', () => {
|
|
512
|
-
upgraded = true;
|
|
513
|
-
});
|
|
514
|
-
return () => {};
|
|
515
|
-
});
|
|
516
|
-
|
|
517
|
-
const router = roster.buildRuntimeRouter();
|
|
518
|
-
const socket = {
|
|
519
|
-
destroyed: false,
|
|
520
|
-
destroy() {
|
|
521
|
-
this.destroyed = true;
|
|
522
|
-
}
|
|
523
|
-
};
|
|
524
|
-
router.dispatchUpgrade(
|
|
525
|
-
{ headers: { host: 'sio.example.com' }, url: '/socket.io/' },
|
|
526
|
-
socket,
|
|
527
|
-
Buffer.alloc(0)
|
|
528
|
-
);
|
|
529
|
-
|
|
530
|
-
assert.strictEqual(upgraded, true);
|
|
531
|
-
assert.strictEqual(socket.destroyed, false);
|
|
532
|
-
});
|
|
533
|
-
|
|
534
|
-
it('buildRuntimeRouter itself does not call listen', () => {
|
|
535
|
-
const roster = new Roster({ local: true });
|
|
536
|
-
roster.register('nolisten.example', () => () => {});
|
|
537
|
-
|
|
538
|
-
let listenCalled = false;
|
|
539
|
-
const originalListen = http.Server.prototype.listen;
|
|
540
|
-
http.Server.prototype.listen = function (...args) {
|
|
541
|
-
listenCalled = true;
|
|
542
|
-
return originalListen.apply(this, args);
|
|
543
|
-
};
|
|
544
|
-
try {
|
|
545
|
-
roster.buildRuntimeRouter();
|
|
546
|
-
} finally {
|
|
547
|
-
http.Server.prototype.listen = originalListen;
|
|
548
|
-
}
|
|
549
|
-
assert.strictEqual(listenCalled, false);
|
|
550
|
-
});
|
|
551
|
-
});
|
|
552
|
-
|
|
553
418
|
describe('Roster local mode (local: true)', () => {
|
|
554
419
|
it('starts HTTP server and responds for registered domain', async () => {
|
|
555
420
|
const roster = new Roster({
|
|
@@ -594,21 +459,6 @@ describe('Roster local mode (local: true)', () => {
|
|
|
594
459
|
closePortServers(roster);
|
|
595
460
|
}
|
|
596
461
|
});
|
|
597
|
-
it('start local mode keeps subdomain localhost mapping', async () => {
|
|
598
|
-
const roster = new Roster({
|
|
599
|
-
local: true,
|
|
600
|
-
minLocalPort: 19110,
|
|
601
|
-
maxLocalPort: 19119
|
|
602
|
-
});
|
|
603
|
-
roster.register('api.logtest.example', () => () => {});
|
|
604
|
-
await roster.start();
|
|
605
|
-
try {
|
|
606
|
-
const localUrl = roster.getUrl('api.logtest.example');
|
|
607
|
-
assert.ok(localUrl && localUrl.startsWith('http://api.localhost:'));
|
|
608
|
-
} finally {
|
|
609
|
-
closePortServers(roster);
|
|
610
|
-
}
|
|
611
|
-
});
|
|
612
462
|
});
|
|
613
463
|
|
|
614
464
|
describe('Roster loadSites', () => {
|
|
@@ -868,3 +718,276 @@ describe('Roster generateConfigJson', () => {
|
|
|
868
718
|
}
|
|
869
719
|
});
|
|
870
720
|
});
|
|
721
|
+
|
|
722
|
+
describe('Roster init() (cluster-friendly API)', () => {
|
|
723
|
+
it('initializes without creating or listening on any server', async () => {
|
|
724
|
+
const roster = new Roster({
|
|
725
|
+
local: true,
|
|
726
|
+
minLocalPort: 19300,
|
|
727
|
+
maxLocalPort: 19309
|
|
728
|
+
});
|
|
729
|
+
roster.register('init-test.example', (server) => {
|
|
730
|
+
return (req, res) => { res.writeHead(200); res.end('ok'); };
|
|
731
|
+
});
|
|
732
|
+
await roster.init();
|
|
733
|
+
assert.strictEqual(roster._initialized, true);
|
|
734
|
+
assert.strictEqual(Object.keys(roster.portServers).length, 0);
|
|
735
|
+
assert.ok(roster._sitesByPort[443]);
|
|
736
|
+
assert.ok(roster.domainServers['init-test.example']);
|
|
737
|
+
});
|
|
738
|
+
|
|
739
|
+
it('is idempotent (calling init twice does not reinitialize)', async () => {
|
|
740
|
+
const roster = new Roster({ local: true });
|
|
741
|
+
roster.register('idem.example', () => () => {});
|
|
742
|
+
await roster.init();
|
|
743
|
+
const firstSitesByPort = roster._sitesByPort;
|
|
744
|
+
await roster.init();
|
|
745
|
+
assert.strictEqual(roster._sitesByPort, firstSitesByPort);
|
|
746
|
+
});
|
|
747
|
+
|
|
748
|
+
it('start() still works after manual init()', async () => {
|
|
749
|
+
const roster = new Roster({
|
|
750
|
+
local: true,
|
|
751
|
+
minLocalPort: 19310,
|
|
752
|
+
maxLocalPort: 19319
|
|
753
|
+
});
|
|
754
|
+
roster.register('after-init.example', (server) => {
|
|
755
|
+
return (req, res) => { res.writeHead(200); res.end('after-init'); };
|
|
756
|
+
});
|
|
757
|
+
await roster.init();
|
|
758
|
+
await roster.start();
|
|
759
|
+
try {
|
|
760
|
+
const port = roster.domainPorts['after-init.example'];
|
|
761
|
+
assert.ok(typeof port === 'number');
|
|
762
|
+
await new Promise((r) => setTimeout(r, 50));
|
|
763
|
+
const result = await httpGet('localhost', port, '/');
|
|
764
|
+
assert.strictEqual(result.statusCode, 200);
|
|
765
|
+
assert.strictEqual(result.body, 'after-init');
|
|
766
|
+
} finally {
|
|
767
|
+
closePortServers(roster);
|
|
768
|
+
}
|
|
769
|
+
});
|
|
770
|
+
});
|
|
771
|
+
|
|
772
|
+
describe('Roster requestHandler() / upgradeHandler()', () => {
|
|
773
|
+
it('throws if called before init()', () => {
|
|
774
|
+
const roster = new Roster({ local: true });
|
|
775
|
+
assert.throws(() => roster.requestHandler(), /Call init\(\) before/);
|
|
776
|
+
assert.throws(() => roster.upgradeHandler(), /Call init\(\) before/);
|
|
777
|
+
});
|
|
778
|
+
|
|
779
|
+
it('returns a working request dispatcher after init()', async () => {
|
|
780
|
+
const roster = new Roster({ local: true });
|
|
781
|
+
roster.register('handler-test.example', (server) => {
|
|
782
|
+
return (req, res) => { res.writeHead(200); res.end('dispatched'); };
|
|
783
|
+
});
|
|
784
|
+
await roster.init();
|
|
785
|
+
|
|
786
|
+
const handler = roster.requestHandler();
|
|
787
|
+
assert.strictEqual(typeof handler, 'function');
|
|
788
|
+
|
|
789
|
+
let statusCode, body;
|
|
790
|
+
const fakeRes = {
|
|
791
|
+
writeHead: (s) => { statusCode = s; },
|
|
792
|
+
end: (b) => { body = b; }
|
|
793
|
+
};
|
|
794
|
+
handler({ headers: { host: 'handler-test.example' }, url: '/' }, fakeRes);
|
|
795
|
+
assert.strictEqual(statusCode, 200);
|
|
796
|
+
assert.strictEqual(body, 'dispatched');
|
|
797
|
+
});
|
|
798
|
+
|
|
799
|
+
it('returns 404 dispatcher for unregistered port', async () => {
|
|
800
|
+
const roster = new Roster({ local: true });
|
|
801
|
+
roster.register('port-test.example', () => () => {});
|
|
802
|
+
await roster.init();
|
|
803
|
+
|
|
804
|
+
const handler = roster.requestHandler(9999);
|
|
805
|
+
let statusCode;
|
|
806
|
+
const fakeRes = {
|
|
807
|
+
writeHead: (s) => { statusCode = s; },
|
|
808
|
+
end: () => {}
|
|
809
|
+
};
|
|
810
|
+
handler({ headers: { host: 'port-test.example' }, url: '/' }, fakeRes);
|
|
811
|
+
assert.strictEqual(statusCode, 404);
|
|
812
|
+
});
|
|
813
|
+
|
|
814
|
+
it('upgrade handler destroys socket for unknown host', async () => {
|
|
815
|
+
const roster = new Roster({ local: true });
|
|
816
|
+
roster.register('upgrade-test.example', () => () => {});
|
|
817
|
+
await roster.init();
|
|
818
|
+
|
|
819
|
+
const handler = roster.upgradeHandler();
|
|
820
|
+
let destroyed = false;
|
|
821
|
+
const fakeSocket = { destroy: () => { destroyed = true; } };
|
|
822
|
+
handler({ headers: { host: 'unknown.example' }, url: '/' }, fakeSocket, Buffer.alloc(0));
|
|
823
|
+
assert.strictEqual(destroyed, true);
|
|
824
|
+
});
|
|
825
|
+
|
|
826
|
+
it('www redirect uses http:// protocol in local mode', async () => {
|
|
827
|
+
const roster = new Roster({ local: true });
|
|
828
|
+
roster.register('redirect.example', (server) => {
|
|
829
|
+
return (req, res) => { res.writeHead(200); res.end('ok'); };
|
|
830
|
+
});
|
|
831
|
+
await roster.init();
|
|
832
|
+
|
|
833
|
+
const handler = roster.requestHandler();
|
|
834
|
+
let location;
|
|
835
|
+
const fakeRes = {
|
|
836
|
+
writeHead: (s, headers) => { location = headers?.Location; },
|
|
837
|
+
end: () => {}
|
|
838
|
+
};
|
|
839
|
+
handler({ headers: { host: 'www.redirect.example' }, url: '/path' }, fakeRes);
|
|
840
|
+
assert.ok(location);
|
|
841
|
+
assert.ok(location.startsWith('http://'), `Expected http:// redirect, got: ${location}`);
|
|
842
|
+
});
|
|
843
|
+
|
|
844
|
+
it('dispatches correctly for custom registered port via requestHandler(port)', async () => {
|
|
845
|
+
const roster = new Roster({ local: true });
|
|
846
|
+
roster.register('api.ported.example:8443', () => {
|
|
847
|
+
return (req, res) => { res.writeHead(200); res.end('port-8443'); };
|
|
848
|
+
});
|
|
849
|
+
await roster.init();
|
|
850
|
+
|
|
851
|
+
const handler = roster.requestHandler(8443);
|
|
852
|
+
let statusCode;
|
|
853
|
+
let body;
|
|
854
|
+
const fakeRes = {
|
|
855
|
+
writeHead: (s) => { statusCode = s; },
|
|
856
|
+
end: (b) => { body = b; }
|
|
857
|
+
};
|
|
858
|
+
handler({ headers: { host: 'api.ported.example' }, url: '/' }, fakeRes);
|
|
859
|
+
assert.strictEqual(statusCode, 200);
|
|
860
|
+
assert.strictEqual(body, 'port-8443');
|
|
861
|
+
});
|
|
862
|
+
|
|
863
|
+
it('www redirect uses https:// protocol in production mode', async () => {
|
|
864
|
+
const roster = new Roster({ local: false });
|
|
865
|
+
roster.register('redirect-prod.example', () => {
|
|
866
|
+
return (req, res) => { res.writeHead(200); res.end('ok'); };
|
|
867
|
+
});
|
|
868
|
+
await roster.init();
|
|
869
|
+
|
|
870
|
+
const handler = roster.requestHandler();
|
|
871
|
+
let location;
|
|
872
|
+
const fakeRes = {
|
|
873
|
+
writeHead: (s, headers) => { location = headers?.Location; },
|
|
874
|
+
end: () => {}
|
|
875
|
+
};
|
|
876
|
+
handler({ headers: { host: 'www.redirect-prod.example' }, url: '/secure' }, fakeRes);
|
|
877
|
+
assert.ok(location);
|
|
878
|
+
assert.ok(location.startsWith('https://'), `Expected https:// redirect, got: ${location}`);
|
|
879
|
+
});
|
|
880
|
+
});
|
|
881
|
+
|
|
882
|
+
describe('Roster sniCallback()', () => {
|
|
883
|
+
it('throws if called before init()', () => {
|
|
884
|
+
const roster = new Roster({ local: false });
|
|
885
|
+
assert.throws(() => roster.sniCallback(), /Call init\(\) before/);
|
|
886
|
+
});
|
|
887
|
+
|
|
888
|
+
it('throws in local mode (no SNI in HTTP)', async () => {
|
|
889
|
+
const roster = new Roster({ local: true });
|
|
890
|
+
roster.register('sni-local.example', () => () => {});
|
|
891
|
+
await roster.init();
|
|
892
|
+
assert.throws(() => roster.sniCallback(), /not available in local mode/);
|
|
893
|
+
});
|
|
894
|
+
|
|
895
|
+
it('returns a function after init() in production mode', async () => {
|
|
896
|
+
const roster = new Roster({ local: false });
|
|
897
|
+
roster.register('sni-prod.example', () => () => {});
|
|
898
|
+
await roster.init();
|
|
899
|
+
const cb = roster.sniCallback();
|
|
900
|
+
assert.strictEqual(typeof cb, 'function');
|
|
901
|
+
});
|
|
902
|
+
});
|
|
903
|
+
|
|
904
|
+
describe('Roster attach()', () => {
|
|
905
|
+
it('throws if called before init()', () => {
|
|
906
|
+
const roster = new Roster({ local: true });
|
|
907
|
+
const fakeServer = { on: () => {} };
|
|
908
|
+
assert.throws(() => roster.attach(fakeServer), /Call init\(\) before/);
|
|
909
|
+
});
|
|
910
|
+
|
|
911
|
+
it('wires request and upgrade listeners onto external server', async () => {
|
|
912
|
+
const roster = new Roster({ local: true });
|
|
913
|
+
roster.register('attach-test.example', (server) => {
|
|
914
|
+
return (req, res) => { res.writeHead(200); res.end('attached'); };
|
|
915
|
+
});
|
|
916
|
+
await roster.init();
|
|
917
|
+
|
|
918
|
+
const listeners = {};
|
|
919
|
+
const fakeServer = {
|
|
920
|
+
on: (event, fn) => { listeners[event] = fn; }
|
|
921
|
+
};
|
|
922
|
+
const result = roster.attach(fakeServer);
|
|
923
|
+
assert.strictEqual(result, roster);
|
|
924
|
+
assert.strictEqual(typeof listeners['request'], 'function');
|
|
925
|
+
assert.strictEqual(typeof listeners['upgrade'], 'function');
|
|
926
|
+
});
|
|
927
|
+
|
|
928
|
+
it('uses provided port option when attaching', async () => {
|
|
929
|
+
const roster = new Roster({ local: true });
|
|
930
|
+
roster.register('attach-443.example', () => (req, res) => { res.writeHead(200); res.end('on-443'); });
|
|
931
|
+
roster.register('attach-9443.example:9443', () => (req, res) => { res.writeHead(200); res.end('on-9443'); });
|
|
932
|
+
await roster.init();
|
|
933
|
+
|
|
934
|
+
const listeners = {};
|
|
935
|
+
const fakeServer = {
|
|
936
|
+
on: (event, fn) => { listeners[event] = fn; }
|
|
937
|
+
};
|
|
938
|
+
roster.attach(fakeServer, { port: 9443 });
|
|
939
|
+
|
|
940
|
+
let statusCode;
|
|
941
|
+
let body;
|
|
942
|
+
const fakeRes = {
|
|
943
|
+
writeHead: (s) => { statusCode = s; },
|
|
944
|
+
end: (b) => { body = b; }
|
|
945
|
+
};
|
|
946
|
+
listeners.request({ headers: { host: 'attach-9443.example' }, url: '/' }, fakeRes);
|
|
947
|
+
assert.strictEqual(statusCode, 200);
|
|
948
|
+
assert.strictEqual(body, 'on-9443');
|
|
949
|
+
});
|
|
950
|
+
|
|
951
|
+
it('attached handler dispatches requests correctly', async () => {
|
|
952
|
+
const roster = new Roster({
|
|
953
|
+
local: true,
|
|
954
|
+
minLocalPort: 19400,
|
|
955
|
+
maxLocalPort: 19409
|
|
956
|
+
});
|
|
957
|
+
roster.register('attach-http.example', (server) => {
|
|
958
|
+
return (req, res) => {
|
|
959
|
+
res.writeHead(200, { 'Content-Type': 'text/plain' });
|
|
960
|
+
res.end('from-attach');
|
|
961
|
+
};
|
|
962
|
+
});
|
|
963
|
+
await roster.init();
|
|
964
|
+
|
|
965
|
+
const server = http.createServer();
|
|
966
|
+
roster.attach(server);
|
|
967
|
+
|
|
968
|
+
const port = 19400;
|
|
969
|
+
await new Promise((resolve, reject) => {
|
|
970
|
+
server.listen(port, 'localhost', resolve);
|
|
971
|
+
server.on('error', reject);
|
|
972
|
+
});
|
|
973
|
+
try {
|
|
974
|
+
await new Promise((r) => setTimeout(r, 50));
|
|
975
|
+
const result = await new Promise((resolve, reject) => {
|
|
976
|
+
const req = http.get(
|
|
977
|
+
{ host: 'localhost', port, path: '/', headers: { host: 'attach-http.example' } },
|
|
978
|
+
(res) => {
|
|
979
|
+
let body = '';
|
|
980
|
+
res.on('data', (chunk) => { body += chunk; });
|
|
981
|
+
res.on('end', () => resolve({ statusCode: res.statusCode, body }));
|
|
982
|
+
}
|
|
983
|
+
);
|
|
984
|
+
req.on('error', reject);
|
|
985
|
+
req.setTimeout(2000, () => { req.destroy(); reject(new Error('timeout')); });
|
|
986
|
+
});
|
|
987
|
+
assert.strictEqual(result.statusCode, 200);
|
|
988
|
+
assert.strictEqual(result.body, 'from-attach');
|
|
989
|
+
} finally {
|
|
990
|
+
server.close();
|
|
991
|
+
}
|
|
992
|
+
});
|
|
993
|
+
});
|
|
@@ -1,12 +0,0 @@
|
|
|
1
|
-
---
|
|
2
|
-
id: zezv04723c
|
|
3
|
-
type: decision
|
|
4
|
-
title: 'Decision: External runtime router API'
|
|
5
|
-
created: '2026-03-20 16:17:36'
|
|
6
|
-
---
|
|
7
|
-
# Decision: External runtime router API
|
|
8
|
-
|
|
9
|
-
**What**: Added `buildRuntimeRouter(options)` and `prepareSites(options)` so cluster/sticky runtimes can use Roster host dispatch + VirtualServer + upgrade routing without calling `start()`.
|
|
10
|
-
**Where**: `index.js` (`prepareSites`, `buildRuntimeRouter`, extracted request/upgrade dispatcher helpers).
|
|
11
|
-
**Why**: Existing consumers had to duplicate internal `start()` dispatcher logic to integrate with externally-managed `listen()` lifecycles.
|
|
12
|
-
**Alternatives rejected**: Auto-starting hidden servers from `buildRuntimeRouter` (rejected to keep ownership explicit and avoid side effects).
|
|
@@ -1,12 +0,0 @@
|
|
|
1
|
-
---
|
|
2
|
-
id: jgoo52m5h0
|
|
3
|
-
type: pattern
|
|
4
|
-
title: 'Pattern: Reusable host dispatch internals'
|
|
5
|
-
created: '2026-03-20 16:17:36'
|
|
6
|
-
---
|
|
7
|
-
# Pattern: Reusable host dispatch internals
|
|
8
|
-
|
|
9
|
-
**What**: Extracted shared host routing pieces from `start()` into reusable methods (`resolveRoutedHost`, `createPortRequestDispatcher`, `createPortUpgradeDispatcher`, `prepareSites`).
|
|
10
|
-
**Where used**: `start()` production path and new `buildRuntimeRouter()` path.
|
|
11
|
-
**When to apply**: Any future feature needing Roster host routing in non-Greenlock server lifecycles or custom server ownership.
|
|
12
|
-
**Notes**: `register()` now supports `{ silent, skipDomainBookkeeping }` for worker-safe registration and reduced duplicate log noise.
|