roster-server 2.3.16 β 2.4.0
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 +56 -0
- package/docs/decisions/external-runtime-router-api.md +12 -0
- package/docs/decisions/git-master-acme-init-retry.md +7 -0
- package/docs/patterns/roster-dispatch-reuse.md +12 -0
- package/index.js +180 -88
- package/package.json +1 -1
- package/skills/roster-server/SKILL.md +61 -2
- package/test/roster-server.test.js +150 -0
package/README.md
CHANGED
|
@@ -350,6 +350,62 @@ console.log(customRoster.getUrl('api.example.com'));
|
|
|
350
350
|
// β https://api.example.com:8443
|
|
351
351
|
```
|
|
352
352
|
|
|
353
|
+
## π External Clusters / Sticky Runtimes
|
|
354
|
+
|
|
355
|
+
If your runtime already owns `server.listen(...)` (for example sticky-session workers in Node cluster, PM2, or custom LB workers), you can still use Roster's domain routing, virtual servers, and Socket.IO-compatible upgrade flow without calling `roster.start()`.
|
|
356
|
+
|
|
357
|
+
### Ownership Model
|
|
358
|
+
|
|
359
|
+
- `roster.start()` = Roster-owned HTTP/HTTPS lifecycle (Greenlock, listeners, ports)
|
|
360
|
+
- `roster.buildRuntimeRouter()` = externally-owned server lifecycle (you call `listen`)
|
|
361
|
+
|
|
362
|
+
### Minimal Worker Example
|
|
363
|
+
|
|
364
|
+
```javascript
|
|
365
|
+
import http from 'http';
|
|
366
|
+
import Roster from 'roster-server';
|
|
367
|
+
|
|
368
|
+
const roster = new Roster({ local: false, port: 443 });
|
|
369
|
+
|
|
370
|
+
roster.register('example.com', (virtualServer) => {
|
|
371
|
+
return (req, res) => {
|
|
372
|
+
res.writeHead(200, { 'Content-Type': 'text/plain' });
|
|
373
|
+
res.end('hello from example.com');
|
|
374
|
+
};
|
|
375
|
+
});
|
|
376
|
+
|
|
377
|
+
roster.register('*.example.com', (virtualServer) => {
|
|
378
|
+
// Socket.IO (or raw WS) can bind to virtualServer "upgrade" listeners here.
|
|
379
|
+
return (req, res) => {
|
|
380
|
+
res.writeHead(200, { 'Content-Type': 'text/plain' });
|
|
381
|
+
res.end('hello from wildcard');
|
|
382
|
+
};
|
|
383
|
+
});
|
|
384
|
+
|
|
385
|
+
const server = http.createServer();
|
|
386
|
+
const router = roster.buildRuntimeRouter({
|
|
387
|
+
targetPort: 443, // optional, defaults to roster.defaultPort
|
|
388
|
+
hostAliases: {
|
|
389
|
+
localhost: 'example.com'
|
|
390
|
+
}, // optional map, or callback: (host) => mappedHost
|
|
391
|
+
allowWwwRedirect: true // optional, defaults to true
|
|
392
|
+
});
|
|
393
|
+
|
|
394
|
+
router.attach(server);
|
|
395
|
+
|
|
396
|
+
// External runtime controls binding/lifecycle:
|
|
397
|
+
server.listen(3000);
|
|
398
|
+
```
|
|
399
|
+
|
|
400
|
+
### API Notes
|
|
401
|
+
|
|
402
|
+
- `roster.prepareSites(options?)`: builds virtual servers + app handlers without listening.
|
|
403
|
+
- `roster.buildRuntimeRouter(options?)` returns:
|
|
404
|
+
- `attach(server)` to bind `request` + `upgrade`
|
|
405
|
+
- `dispatchRequest(req, res)` for manual request dispatch
|
|
406
|
+
- `dispatchUpgrade(req, socket, head)` for manual upgrade dispatch
|
|
407
|
+
- `portData` + `diagnostics` snapshots for debugging
|
|
408
|
+
|
|
353
409
|
## π§ A Touch of Magic
|
|
354
410
|
|
|
355
411
|
You might be thinking, "But setting up HTTPS and virtual hosts is supposed to be complicated and time-consuming!" Well, not anymore. With RosterServer, you can get back to writing code that matters, like defending Earth from alien invaders! πΎπΎπΎ
|
|
@@ -0,0 +1,12 @@
|
|
|
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).
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
---
|
|
2
|
+
id: 47bowe7gen
|
|
3
|
+
type: decision
|
|
4
|
+
title: Pushed ACME retry commit
|
|
5
|
+
created: '2026-03-17 11:47:46'
|
|
6
|
+
---
|
|
7
|
+
Committed and pushed master commit ecb2723. Changes: added retry loop (3 attempts with incremental backoff) around ACME directory init in vendor/greenlock/greenlock.js and updated package-lock.json version from 2.2.10 to 2.2.12.
|
|
@@ -0,0 +1,12 @@
|
|
|
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.
|
package/index.js
CHANGED
|
@@ -109,6 +109,19 @@ function parseBooleanFlag(value, fallback = false) {
|
|
|
109
109
|
return fallback;
|
|
110
110
|
}
|
|
111
111
|
|
|
112
|
+
function normalizeHostInput(value) {
|
|
113
|
+
if (typeof value === 'string') return value;
|
|
114
|
+
if (!value || typeof value !== 'object') return '';
|
|
115
|
+
if (typeof value.servername === 'string') return value.servername;
|
|
116
|
+
if (typeof value.hostname === 'string') return value.hostname;
|
|
117
|
+
if (typeof value.subject === 'string') return value.subject;
|
|
118
|
+
return '';
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
function hostWithoutPort(value) {
|
|
122
|
+
return normalizeHostInput(value).split(':')[0].trim().toLowerCase();
|
|
123
|
+
}
|
|
124
|
+
|
|
112
125
|
function normalizeDomainForLocalHost(domain) {
|
|
113
126
|
return (domain || '').trim().toLowerCase().replace(/^www\./, '');
|
|
114
127
|
}
|
|
@@ -549,13 +562,21 @@ class Roster {
|
|
|
549
562
|
}
|
|
550
563
|
}
|
|
551
564
|
|
|
552
|
-
|
|
565
|
+
/**
|
|
566
|
+
* Register a domain handler.
|
|
567
|
+
* @param {string} domainString Domain, optionally with :port.
|
|
568
|
+
* @param {(virtualServer: VirtualServer) => Function} requestHandler Site app factory.
|
|
569
|
+
* @param {{ silent?: boolean, skipDomainBookkeeping?: boolean }} [options]
|
|
570
|
+
* @returns {Roster}
|
|
571
|
+
*/
|
|
572
|
+
register(domainString, requestHandler, options = {}) {
|
|
553
573
|
if (!domainString) {
|
|
554
574
|
throw new Error('Domain is required');
|
|
555
575
|
}
|
|
556
576
|
if (typeof requestHandler !== 'function') {
|
|
557
577
|
throw new Error('requestHandler must be a function');
|
|
558
578
|
}
|
|
579
|
+
const { silent = false, skipDomainBookkeeping = false } = options || {};
|
|
559
580
|
|
|
560
581
|
const { domain, port } = this.parseDomainWithPort(domainString);
|
|
561
582
|
|
|
@@ -565,11 +586,13 @@ class Roster {
|
|
|
565
586
|
return this;
|
|
566
587
|
}
|
|
567
588
|
const domainKey = port === this.defaultPort ? domain : `${domain}:${port}`;
|
|
568
|
-
this.domains.push(domain);
|
|
589
|
+
if (!skipDomainBookkeeping) this.domains.push(domain);
|
|
569
590
|
this.sites[domainKey] = requestHandler;
|
|
570
591
|
const root = wildcardRoot(domain);
|
|
571
592
|
if (root) this.wildcardZones.add(root);
|
|
572
|
-
|
|
593
|
+
if (!silent) {
|
|
594
|
+
log.info(`(β) Registered wildcard site: ${domain}${port !== this.defaultPort ? ':' + port : ''}`);
|
|
595
|
+
}
|
|
573
596
|
return this;
|
|
574
597
|
}
|
|
575
598
|
|
|
@@ -578,13 +601,17 @@ class Roster {
|
|
|
578
601
|
domainEntries.push(`www.${domain}`);
|
|
579
602
|
}
|
|
580
603
|
|
|
581
|
-
|
|
604
|
+
if (!skipDomainBookkeeping) {
|
|
605
|
+
this.domains.push(...domainEntries);
|
|
606
|
+
}
|
|
582
607
|
domainEntries.forEach(d => {
|
|
583
608
|
const domainKey = port === this.defaultPort ? d : `${d}:${port}`;
|
|
584
609
|
this.sites[domainKey] = requestHandler;
|
|
585
610
|
});
|
|
586
611
|
|
|
587
|
-
|
|
612
|
+
if (!silent) {
|
|
613
|
+
log.info(`(β) Registered site: ${domain}${port !== this.defaultPort ? ':' + port : ''}`);
|
|
614
|
+
}
|
|
588
615
|
return this;
|
|
589
616
|
}
|
|
590
617
|
|
|
@@ -628,6 +655,151 @@ class Roster {
|
|
|
628
655
|
return new VirtualServer(domain);
|
|
629
656
|
}
|
|
630
657
|
|
|
658
|
+
resolveRoutedHost(rawHost, hostAliases) {
|
|
659
|
+
const normalizedHost = hostWithoutPort(rawHost);
|
|
660
|
+
if (!normalizedHost) {
|
|
661
|
+
return {
|
|
662
|
+
normalizedHost: '',
|
|
663
|
+
routedHost: '',
|
|
664
|
+
isWww: false
|
|
665
|
+
};
|
|
666
|
+
}
|
|
667
|
+
|
|
668
|
+
let aliasedHost = normalizedHost;
|
|
669
|
+
if (typeof hostAliases === 'function') {
|
|
670
|
+
const mapped = hostAliases(normalizedHost);
|
|
671
|
+
if (mapped) aliasedHost = hostWithoutPort(mapped);
|
|
672
|
+
} else if (hostAliases && typeof hostAliases === 'object') {
|
|
673
|
+
const mapped = hostAliases[normalizedHost];
|
|
674
|
+
if (mapped) aliasedHost = hostWithoutPort(mapped);
|
|
675
|
+
}
|
|
676
|
+
|
|
677
|
+
const isWww = aliasedHost.startsWith('www.');
|
|
678
|
+
return {
|
|
679
|
+
normalizedHost: aliasedHost,
|
|
680
|
+
routedHost: isWww ? aliasedHost.slice(4) : aliasedHost,
|
|
681
|
+
isWww
|
|
682
|
+
};
|
|
683
|
+
}
|
|
684
|
+
|
|
685
|
+
createPortRequestDispatcher(portData, options = {}) {
|
|
686
|
+
const {
|
|
687
|
+
hostAliases,
|
|
688
|
+
allowWwwRedirect = true
|
|
689
|
+
} = options;
|
|
690
|
+
|
|
691
|
+
return (req, res) => {
|
|
692
|
+
const { normalizedHost, routedHost, isWww } = this.resolveRoutedHost(req.headers.host || '', hostAliases);
|
|
693
|
+
|
|
694
|
+
if (allowWwwRedirect && isWww) {
|
|
695
|
+
res.writeHead(301, { Location: `https://${routedHost}${req.url}` });
|
|
696
|
+
res.end();
|
|
697
|
+
return;
|
|
698
|
+
}
|
|
699
|
+
|
|
700
|
+
const resolved = this.getHandlerForPortData(routedHost, portData);
|
|
701
|
+
if (!resolved) {
|
|
702
|
+
res.writeHead(404);
|
|
703
|
+
res.end('Site not found');
|
|
704
|
+
return;
|
|
705
|
+
}
|
|
706
|
+
const { virtualServer, appHandler } = resolved;
|
|
707
|
+
|
|
708
|
+
if (virtualServer && virtualServer.requestListeners.length > 0) {
|
|
709
|
+
virtualServer.fallbackHandler = appHandler;
|
|
710
|
+
virtualServer.processRequest(req, res);
|
|
711
|
+
} else if (appHandler) {
|
|
712
|
+
appHandler(req, res);
|
|
713
|
+
} else {
|
|
714
|
+
res.writeHead(404);
|
|
715
|
+
res.end('Site not found');
|
|
716
|
+
}
|
|
717
|
+
};
|
|
718
|
+
}
|
|
719
|
+
|
|
720
|
+
createPortUpgradeDispatcher(portData, options = {}) {
|
|
721
|
+
const { hostAliases } = options;
|
|
722
|
+
return (req, socket, head) => {
|
|
723
|
+
const { routedHost } = this.resolveRoutedHost(req.headers.host || '', hostAliases);
|
|
724
|
+
const resolved = this.getHandlerForPortData(routedHost, portData);
|
|
725
|
+
if (resolved && resolved.virtualServer) {
|
|
726
|
+
resolved.virtualServer.processUpgrade(req, socket, head);
|
|
727
|
+
} else {
|
|
728
|
+
socket.destroy();
|
|
729
|
+
}
|
|
730
|
+
};
|
|
731
|
+
}
|
|
732
|
+
|
|
733
|
+
/**
|
|
734
|
+
* Prepare port-based virtual server/app handler data without listening.
|
|
735
|
+
* @param {{ targetPort?: number }} [options]
|
|
736
|
+
* @returns {{ sitesByPort: Record<number, { virtualServers: Record<string, VirtualServer>, appHandlers: Record<string, Function> }> }}
|
|
737
|
+
*/
|
|
738
|
+
prepareSites(options = {}) {
|
|
739
|
+
const { targetPort } = options;
|
|
740
|
+
const sitesByPort = {};
|
|
741
|
+
|
|
742
|
+
for (const [hostKey, siteApp] of Object.entries(this.sites)) {
|
|
743
|
+
if (hostKey.startsWith('www.')) continue;
|
|
744
|
+
|
|
745
|
+
const { domain, port } = this.parseDomainWithPort(hostKey);
|
|
746
|
+
if (targetPort !== undefined && port !== targetPort) continue;
|
|
747
|
+
|
|
748
|
+
if (!sitesByPort[port]) {
|
|
749
|
+
sitesByPort[port] = {
|
|
750
|
+
virtualServers: {},
|
|
751
|
+
appHandlers: {}
|
|
752
|
+
};
|
|
753
|
+
}
|
|
754
|
+
|
|
755
|
+
const virtualServer = this.createVirtualServer(domain);
|
|
756
|
+
sitesByPort[port].virtualServers[domain] = virtualServer;
|
|
757
|
+
this.domainServers[domain] = virtualServer;
|
|
758
|
+
|
|
759
|
+
const appHandler = siteApp(virtualServer);
|
|
760
|
+
sitesByPort[port].appHandlers[domain] = appHandler;
|
|
761
|
+
if (!domain.startsWith('*.')) {
|
|
762
|
+
sitesByPort[port].appHandlers[`www.${domain}`] = appHandler;
|
|
763
|
+
}
|
|
764
|
+
}
|
|
765
|
+
|
|
766
|
+
return { sitesByPort };
|
|
767
|
+
}
|
|
768
|
+
|
|
769
|
+
/**
|
|
770
|
+
* Build a router that can be attached to externally managed servers (e.g. sticky-session workers).
|
|
771
|
+
* This does not call listen() and does not boot Greenlock.
|
|
772
|
+
* @param {{ targetPort?: number, hostAliases?: Record<string, string>|((host: string) => string|undefined), allowWwwRedirect?: boolean }} [options]
|
|
773
|
+
* @returns {{ attach: (server: import('http').Server) => import('http').Server, dispatchRequest: Function, dispatchUpgrade: Function, portData: { virtualServers: Record<string, VirtualServer>, appHandlers: Record<string, Function> }, diagnostics: { targetPort: number, hosts: string[], virtualServers: string[] } }}
|
|
774
|
+
*/
|
|
775
|
+
buildRuntimeRouter(options = {}) {
|
|
776
|
+
const {
|
|
777
|
+
targetPort = this.defaultPort,
|
|
778
|
+
hostAliases,
|
|
779
|
+
allowWwwRedirect = true
|
|
780
|
+
} = options;
|
|
781
|
+
const { sitesByPort } = this.prepareSites({ targetPort });
|
|
782
|
+
const portData = sitesByPort[targetPort] || { virtualServers: {}, appHandlers: {} };
|
|
783
|
+
const dispatchRequest = this.createPortRequestDispatcher(portData, { hostAliases, allowWwwRedirect });
|
|
784
|
+
const dispatchUpgrade = this.createPortUpgradeDispatcher(portData, { hostAliases });
|
|
785
|
+
|
|
786
|
+
return {
|
|
787
|
+
attach: (server) => {
|
|
788
|
+
server.on('request', dispatchRequest);
|
|
789
|
+
server.on('upgrade', dispatchUpgrade);
|
|
790
|
+
return server;
|
|
791
|
+
},
|
|
792
|
+
dispatchRequest,
|
|
793
|
+
dispatchUpgrade,
|
|
794
|
+
portData,
|
|
795
|
+
diagnostics: {
|
|
796
|
+
targetPort,
|
|
797
|
+
hosts: Object.keys(portData.appHandlers),
|
|
798
|
+
virtualServers: Object.keys(portData.virtualServers)
|
|
799
|
+
}
|
|
800
|
+
};
|
|
801
|
+
}
|
|
802
|
+
|
|
631
803
|
// Assign port to domain, detecting collisions with already assigned ports
|
|
632
804
|
assignPortToDomain(domain) {
|
|
633
805
|
let port = domainToPort(domain, this.minLocalPort, this.maxLocalPort);
|
|
@@ -814,100 +986,20 @@ class Roster {
|
|
|
814
986
|
|
|
815
987
|
return greenlock.ready(async glx => {
|
|
816
988
|
const httpServer = glx.httpServer();
|
|
817
|
-
|
|
818
|
-
// Group sites by port
|
|
819
|
-
const sitesByPort = {};
|
|
820
|
-
for (const [hostKey, siteApp] of Object.entries(this.sites)) {
|
|
821
|
-
if (!hostKey.startsWith('www.')) {
|
|
822
|
-
const { domain, port } = this.parseDomainWithPort(hostKey);
|
|
823
|
-
if (!sitesByPort[port]) {
|
|
824
|
-
sitesByPort[port] = {
|
|
825
|
-
virtualServers: {},
|
|
826
|
-
appHandlers: {}
|
|
827
|
-
};
|
|
828
|
-
}
|
|
829
|
-
|
|
830
|
-
const virtualServer = this.createVirtualServer(domain);
|
|
831
|
-
sitesByPort[port].virtualServers[domain] = virtualServer;
|
|
832
|
-
this.domainServers[domain] = virtualServer;
|
|
833
|
-
|
|
834
|
-
const appHandler = siteApp(virtualServer);
|
|
835
|
-
sitesByPort[port].appHandlers[domain] = appHandler;
|
|
836
|
-
if (!domain.startsWith('*.')) {
|
|
837
|
-
sitesByPort[port].appHandlers[`www.${domain}`] = appHandler;
|
|
838
|
-
}
|
|
839
|
-
}
|
|
840
|
-
}
|
|
989
|
+
const { sitesByPort } = this.prepareSites();
|
|
841
990
|
|
|
842
991
|
const bunTlsHotReloadHandlers = [];
|
|
843
992
|
|
|
844
|
-
// Create dispatcher for each port
|
|
845
|
-
const createDispatcher = (portData) => {
|
|
846
|
-
return (req, res) => {
|
|
847
|
-
const host = req.headers.host || '';
|
|
848
|
-
|
|
849
|
-
const hostWithoutPort = host.split(':')[0].toLowerCase();
|
|
850
|
-
const domain = hostWithoutPort.startsWith('www.') ? hostWithoutPort.slice(4) : hostWithoutPort;
|
|
851
|
-
|
|
852
|
-
if (hostWithoutPort.startsWith('www.')) {
|
|
853
|
-
res.writeHead(301, { Location: `https://${domain}${req.url}` });
|
|
854
|
-
res.end();
|
|
855
|
-
return;
|
|
856
|
-
}
|
|
857
|
-
|
|
858
|
-
const resolved = this.getHandlerForPortData(domain, portData);
|
|
859
|
-
if (!resolved) {
|
|
860
|
-
res.writeHead(404);
|
|
861
|
-
res.end('Site not found');
|
|
862
|
-
return;
|
|
863
|
-
}
|
|
864
|
-
const { virtualServer, appHandler } = resolved;
|
|
865
|
-
|
|
866
|
-
if (virtualServer && virtualServer.requestListeners.length > 0) {
|
|
867
|
-
virtualServer.fallbackHandler = appHandler;
|
|
868
|
-
virtualServer.processRequest(req, res);
|
|
869
|
-
} else if (appHandler) {
|
|
870
|
-
appHandler(req, res);
|
|
871
|
-
} else {
|
|
872
|
-
res.writeHead(404);
|
|
873
|
-
res.end('Site not found');
|
|
874
|
-
}
|
|
875
|
-
};
|
|
876
|
-
};
|
|
877
|
-
|
|
878
993
|
httpServer.listen(80, this.hostname, () => {
|
|
879
994
|
log.info('HTTP server listening on port 80');
|
|
880
995
|
});
|
|
881
996
|
|
|
882
|
-
const createUpgradeHandler = (portData) => {
|
|
883
|
-
return (req, socket, head) => {
|
|
884
|
-
const host = req.headers.host || '';
|
|
885
|
-
const hostWithoutPort = host.split(':')[0].toLowerCase();
|
|
886
|
-
const domain = hostWithoutPort.startsWith('www.') ? hostWithoutPort.slice(4) : hostWithoutPort;
|
|
887
|
-
|
|
888
|
-
const resolved = this.getHandlerForPortData(domain, portData);
|
|
889
|
-
if (resolved && resolved.virtualServer) {
|
|
890
|
-
resolved.virtualServer.processUpgrade(req, socket, head);
|
|
891
|
-
} else {
|
|
892
|
-
socket.destroy();
|
|
893
|
-
}
|
|
894
|
-
};
|
|
895
|
-
};
|
|
896
|
-
|
|
897
997
|
// Handle different port types
|
|
898
998
|
for (const [port, portData] of Object.entries(sitesByPort)) {
|
|
899
999
|
const portNum = parseInt(port);
|
|
900
|
-
const dispatcher =
|
|
901
|
-
const upgradeHandler =
|
|
1000
|
+
const dispatcher = this.createPortRequestDispatcher(portData);
|
|
1001
|
+
const upgradeHandler = this.createPortUpgradeDispatcher(portData);
|
|
902
1002
|
const greenlockStorePath = this.greenlockStorePath;
|
|
903
|
-
const normalizeHostInput = (value) => {
|
|
904
|
-
if (typeof value === 'string') return value;
|
|
905
|
-
if (!value || typeof value !== 'object') return '';
|
|
906
|
-
if (typeof value.servername === 'string') return value.servername;
|
|
907
|
-
if (typeof value.hostname === 'string') return value.hostname;
|
|
908
|
-
if (typeof value.subject === 'string') return value.subject;
|
|
909
|
-
return '';
|
|
910
|
-
};
|
|
911
1003
|
const loadCert = (subjectDir) => {
|
|
912
1004
|
const normalizedSubject = normalizeHostInput(subjectDir).trim().toLowerCase();
|
|
913
1005
|
if (!normalizedSubject) return null;
|
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: false
|
|
17
17
|
});
|
|
18
18
|
|
|
19
19
|
roster.start();
|
|
@@ -155,9 +155,37 @@ new Roster({
|
|
|
155
155
|
### `roster.start()`
|
|
156
156
|
Loads sites, generates SSL config, starts servers. Returns `Promise<void>`.
|
|
157
157
|
|
|
158
|
-
### `roster.register(domain, handler)`
|
|
158
|
+
### `roster.register(domain, handler, options?)`
|
|
159
159
|
Manually register a domain handler. Domain can include port: `'api.com:8443'`. For wildcards use `'*.example.com'` or `'*.example.com:8080'`.
|
|
160
160
|
|
|
161
|
+
Optional registration options:
|
|
162
|
+
- `silent: true` suppresses registration logs (useful in clustered workers)
|
|
163
|
+
- `skipDomainBookkeeping: true` avoids `domains[]` side effects when external runtime owns registration/state
|
|
164
|
+
|
|
165
|
+
### `roster.prepareSites(options?)`
|
|
166
|
+
Builds VirtualServer + app handler wiring without listening. Useful when you need Roster routing internals but your runtime owns `server.listen(...)`.
|
|
167
|
+
|
|
168
|
+
Options:
|
|
169
|
+
- `targetPort` (optional): only prepare one port, defaults to all registered ports
|
|
170
|
+
|
|
171
|
+
Returns:
|
|
172
|
+
- `{ sitesByPort }` with `virtualServers` and `appHandlers` per port
|
|
173
|
+
|
|
174
|
+
### `roster.buildRuntimeRouter(options?)`
|
|
175
|
+
Build a cluster/sticky-runtime-compatible router for externally managed HTTP servers. This API does **not** call `listen()` and does **not** boot Greenlock.
|
|
176
|
+
|
|
177
|
+
Options:
|
|
178
|
+
- `targetPort` (optional): defaults to `roster.defaultPort`
|
|
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)
|
|
188
|
+
|
|
161
189
|
### `roster.getUrl(domain)`
|
|
162
190
|
Get environment-aware URL:
|
|
163
191
|
- Local mode: `http://localhost:{port}`
|
|
@@ -172,6 +200,12 @@ Get environment-aware URL:
|
|
|
172
200
|
3. Looks up domain β Gets `VirtualServer` instance
|
|
173
201
|
4. Routes to handler via `virtualServer.processRequest(req, res)`
|
|
174
202
|
|
|
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
|
+
|
|
175
209
|
### VirtualServer Architecture
|
|
176
210
|
Each domain gets isolated server instance that simulates `http.Server`:
|
|
177
211
|
- Captures `request` and `upgrade` event listeners
|
|
@@ -242,6 +276,30 @@ roster.register('test.local', (server) => {
|
|
|
242
276
|
roster.start();
|
|
243
277
|
```
|
|
244
278
|
|
|
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
|
+
|
|
245
303
|
### Environment-Aware Configuration
|
|
246
304
|
```javascript
|
|
247
305
|
const isProduction = process.env.NODE_ENV === 'production';
|
|
@@ -274,3 +332,4 @@ When implementing RosterServer:
|
|
|
274
332
|
- [ ] Use `roster.getUrl(domain)` for environment-aware URLs
|
|
275
333
|
- [ ] Handle Socket.IO paths correctly in returned handler
|
|
276
334
|
- [ ] Implement error handling in handlers
|
|
335
|
+
- [ ] For sticky/cluster runtimes, use `buildRuntimeRouter().attach(server)` instead of `start()`
|
|
@@ -6,6 +6,7 @@ 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');
|
|
9
10
|
const Roster = require('../index.js');
|
|
10
11
|
const {
|
|
11
12
|
wildcardRoot,
|
|
@@ -39,6 +40,20 @@ function httpGet(host, port, pathname = '/') {
|
|
|
39
40
|
});
|
|
40
41
|
}
|
|
41
42
|
|
|
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
|
+
|
|
42
57
|
describe('wildcardRoot', () => {
|
|
43
58
|
it('returns root domain for *.example.com', () => {
|
|
44
59
|
assert.strictEqual(wildcardRoot('*.example.com'), 'example.com');
|
|
@@ -335,6 +350,21 @@ describe('Roster', () => {
|
|
|
335
350
|
assert.strictEqual(roster.sites['api.example.com'], handler);
|
|
336
351
|
assert.strictEqual(roster.sites['www.api.example.com'], undefined);
|
|
337
352
|
});
|
|
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
|
+
});
|
|
338
368
|
});
|
|
339
369
|
|
|
340
370
|
describe('getUrl (exact domain)', () => {
|
|
@@ -415,6 +445,111 @@ describe('Roster', () => {
|
|
|
415
445
|
});
|
|
416
446
|
});
|
|
417
447
|
|
|
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
|
+
|
|
418
553
|
describe('Roster local mode (local: true)', () => {
|
|
419
554
|
it('starts HTTP server and responds for registered domain', async () => {
|
|
420
555
|
const roster = new Roster({
|
|
@@ -459,6 +594,21 @@ describe('Roster local mode (local: true)', () => {
|
|
|
459
594
|
closePortServers(roster);
|
|
460
595
|
}
|
|
461
596
|
});
|
|
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
|
+
});
|
|
462
612
|
});
|
|
463
613
|
|
|
464
614
|
describe('Roster loadSites', () => {
|