roster-server 2.4.0 → 2.4.4

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/index.js CHANGED
@@ -109,19 +109,6 @@ 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
-
125
112
  function normalizeDomainForLocalHost(domain) {
126
113
  return (domain || '').trim().toLowerCase().replace(/^www\./, '');
127
114
  }
@@ -265,6 +252,9 @@ class Roster {
265
252
  this.portServers = {}; // Store servers by port
266
253
  this.domainPorts = {}; // Store domain → port mapping for local mode
267
254
  this.assignedPorts = new Set(); // Track ports assigned to domains (not OS availability)
255
+ this._sitesByPort = {};
256
+ this._initialized = false;
257
+ this._sniCallback = null;
268
258
  this.hostname = options.hostname ?? '::';
269
259
  this.filename = options.filename || 'index';
270
260
  this.minLocalPort = options.minLocalPort || 4000;
@@ -278,6 +268,12 @@ class Roster {
278
268
  }
279
269
 
280
270
  this.skipLocalCheck = parseBooleanFlag(options.skipLocalCheck, true);
271
+ this.autoCertificates = parseBooleanFlag(options.autoCertificates, true);
272
+ this.certificateRenewIntervalMs = Number.isFinite(Number(options.certificateRenewIntervalMs))
273
+ ? Math.max(60000, Number(options.certificateRenewIntervalMs))
274
+ : 12 * 60 * 60 * 1000;
275
+ this._greenlockRuntime = null;
276
+ this._certificateRenewTimer = null;
281
277
 
282
278
  const port = options.port === undefined ? 443 : options.port;
283
279
  if (port === 80 && !this.local) {
@@ -562,21 +558,13 @@ class Roster {
562
558
  }
563
559
  }
564
560
 
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 = {}) {
561
+ register(domainString, requestHandler) {
573
562
  if (!domainString) {
574
563
  throw new Error('Domain is required');
575
564
  }
576
565
  if (typeof requestHandler !== 'function') {
577
566
  throw new Error('requestHandler must be a function');
578
567
  }
579
- const { silent = false, skipDomainBookkeeping = false } = options || {};
580
568
 
581
569
  const { domain, port } = this.parseDomainWithPort(domainString);
582
570
 
@@ -586,13 +574,11 @@ class Roster {
586
574
  return this;
587
575
  }
588
576
  const domainKey = port === this.defaultPort ? domain : `${domain}:${port}`;
589
- if (!skipDomainBookkeeping) this.domains.push(domain);
577
+ this.domains.push(domain);
590
578
  this.sites[domainKey] = requestHandler;
591
579
  const root = wildcardRoot(domain);
592
580
  if (root) this.wildcardZones.add(root);
593
- if (!silent) {
594
- log.info(`(✔) Registered wildcard site: ${domain}${port !== this.defaultPort ? ':' + port : ''}`);
595
- }
581
+ log.info(`(✔) Registered wildcard site: ${domain}${port !== this.defaultPort ? ':' + port : ''}`);
596
582
  return this;
597
583
  }
598
584
 
@@ -601,17 +587,13 @@ class Roster {
601
587
  domainEntries.push(`www.${domain}`);
602
588
  }
603
589
 
604
- if (!skipDomainBookkeeping) {
605
- this.domains.push(...domainEntries);
606
- }
590
+ this.domains.push(...domainEntries);
607
591
  domainEntries.forEach(d => {
608
592
  const domainKey = port === this.defaultPort ? d : `${d}:${port}`;
609
593
  this.sites[domainKey] = requestHandler;
610
594
  });
611
595
 
612
- if (!silent) {
613
- log.info(`(✔) Registered site: ${domain}${port !== this.defaultPort ? ':' + port : ''}`);
614
- }
596
+ log.info(`(✔) Registered site: ${domain}${port !== this.defaultPort ? ':' + port : ''}`);
615
597
  return this;
616
598
  }
617
599
 
@@ -655,151 +637,6 @@ class Roster {
655
637
  return new VirtualServer(domain);
656
638
  }
657
639
 
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
-
803
640
  // Assign port to domain, detecting collisions with already assigned ports
804
641
  assignPortToDomain(domain) {
805
642
  let port = domainToPort(domain, this.minLocalPort, this.maxLocalPort);
@@ -834,85 +671,151 @@ class Roster {
834
671
  return null;
835
672
  }
836
673
 
837
- // Start server in local mode with HTTP - simplified version
838
- startLocalMode() {
839
- // Store mapping of domain to port for later retrieval
840
- this.domainPorts = {};
841
-
842
- // Create a simple HTTP server for each domain with CRC32-based ports
843
- for (const [hostKey, siteApp] of Object.entries(this.sites)) {
844
- const domain = hostKey.split(':')[0]; // Remove port if present
674
+ _normalizeHostInput(value) {
675
+ if (typeof value === 'string') return value;
676
+ if (!value || typeof value !== 'object') return '';
677
+ if (typeof value.servername === 'string') return value.servername;
678
+ if (typeof value.hostname === 'string') return value.hostname;
679
+ if (typeof value.subject === 'string') return value.subject;
680
+ return '';
681
+ }
845
682
 
846
- // Skip www domains in local mode
847
- if (domain.startsWith('www.')) {
848
- continue;
849
- }
683
+ _loadCert(subjectDir) {
684
+ const normalizedSubject = this._normalizeHostInput(subjectDir).trim().toLowerCase();
685
+ if (!normalizedSubject) return null;
686
+ const certPath = path.join(this.greenlockStorePath, 'live', normalizedSubject);
687
+ const keyPath = path.join(certPath, 'privkey.pem');
688
+ const certFilePath = path.join(certPath, 'cert.pem');
689
+ const chainPath = path.join(certPath, 'chain.pem');
690
+ if (fs.existsSync(keyPath) && fs.existsSync(certFilePath) && fs.existsSync(chainPath)) {
691
+ return {
692
+ key: fs.readFileSync(keyPath, 'utf8'),
693
+ cert: fs.readFileSync(certFilePath, 'utf8') + fs.readFileSync(chainPath, 'utf8')
694
+ };
695
+ }
696
+ return null;
697
+ }
850
698
 
851
- // Calculate deterministic port based on domain CRC32, with collision detection
852
- const port = this.assignPortToDomain(domain);
699
+ _resolvePemsForServername(servername) {
700
+ const host = this._normalizeHostInput(servername).trim().toLowerCase();
701
+ if (!host) return null;
702
+ const candidates = buildCertLookupCandidates(host);
703
+ for (const candidate of candidates) {
704
+ const pems = this._loadCert(candidate);
705
+ if (pems) return pems;
706
+ }
707
+ return null;
708
+ }
853
709
 
854
- // Store domain → port mapping
855
- this.domainPorts[domain] = port;
710
+ _initSiteHandlers() {
711
+ this._sitesByPort = {};
712
+ for (const [hostKey, siteApp] of Object.entries(this.sites)) {
713
+ if (hostKey.startsWith('www.')) continue;
714
+ const { domain, port } = this.parseDomainWithPort(hostKey);
715
+ if (!this._sitesByPort[port]) {
716
+ this._sitesByPort[port] = {
717
+ virtualServers: {},
718
+ appHandlers: {}
719
+ };
720
+ }
856
721
 
857
- // Create virtual server for the domain
858
722
  const virtualServer = this.createVirtualServer(domain);
723
+ this._sitesByPort[port].virtualServers[domain] = virtualServer;
859
724
  this.domainServers[domain] = virtualServer;
860
725
 
861
- // Initialize app with virtual server
862
726
  const appHandler = siteApp(virtualServer);
727
+ this._sitesByPort[port].appHandlers[domain] = appHandler;
728
+ if (!domain.startsWith('*.')) {
729
+ this._sitesByPort[port].appHandlers[`www.${domain}`] = appHandler;
730
+ }
731
+ }
732
+ }
863
733
 
864
- // Create simple dispatcher for this domain
865
- const dispatcher = (req, res) => {
866
- // Set fallback handler on virtual server for non-Socket.IO requests
867
- virtualServer.fallbackHandler = appHandler;
868
-
869
- if (virtualServer.requestListeners.length > 0) {
870
- virtualServer.processRequest(req, res);
871
- } else if (appHandler) {
872
- appHandler(req, res);
873
- } else {
874
- res.writeHead(404);
875
- res.end('Site not found');
876
- }
877
- };
734
+ _createDispatcher(portData) {
735
+ return (req, res) => {
736
+ const host = req.headers.host || '';
737
+ const hostWithoutPort = host.split(':')[0].toLowerCase();
738
+ const domain = hostWithoutPort.startsWith('www.') ? hostWithoutPort.slice(4) : hostWithoutPort;
878
739
 
879
- // Create HTTP server for this domain
880
- const httpServer = http.createServer(dispatcher);
881
- this.portServers[port] = httpServer;
740
+ if (hostWithoutPort.startsWith('www.')) {
741
+ const protocol = this.local ? 'http' : 'https';
742
+ res.writeHead(301, { Location: `${protocol}://${domain}${req.url}` });
743
+ res.end();
744
+ return;
745
+ }
882
746
 
883
- // Handle WebSocket upgrade events
884
- httpServer.on('upgrade', (req, socket, head) => {
885
- virtualServer.processUpgrade(req, socket, head);
886
- });
747
+ const resolved = this.getHandlerForPortData(domain, portData);
748
+ if (!resolved) {
749
+ res.writeHead(404);
750
+ res.end('Site not found');
751
+ return;
752
+ }
753
+ const { virtualServer, appHandler } = resolved;
887
754
 
888
- httpServer.listen(port, 'localhost', () => {
889
- const cleanDomain = normalizeDomainForLocalHost(domain);
890
- log.info(`🌐 ${domain} → http://${localHostForDomain(cleanDomain)}:${port}`);
891
- });
755
+ if (virtualServer && virtualServer.requestListeners.length > 0) {
756
+ virtualServer.fallbackHandler = appHandler;
757
+ virtualServer.processRequest(req, res);
758
+ } else if (appHandler) {
759
+ appHandler(req, res);
760
+ } else {
761
+ res.writeHead(404);
762
+ res.end('Site not found');
763
+ }
764
+ };
765
+ }
892
766
 
893
- httpServer.on('error', (error) => {
894
- log.error(`❌ Error on port ${port} for ${domain}:`, error.message);
895
- });
896
- }
767
+ _createUpgradeHandler(portData) {
768
+ return (req, socket, head) => {
769
+ const host = req.headers.host || '';
770
+ const hostWithoutPort = host.split(':')[0].toLowerCase();
771
+ const domain = hostWithoutPort.startsWith('www.') ? hostWithoutPort.slice(4) : hostWithoutPort;
897
772
 
898
- log.info(`(✔) Started ${Object.keys(this.portServers).length} sites in local mode`);
899
- return Promise.resolve();
773
+ const resolved = this.getHandlerForPortData(domain, portData);
774
+ if (resolved && resolved.virtualServer) {
775
+ resolved.virtualServer.processUpgrade(req, socket, head);
776
+ } else {
777
+ socket.destroy();
778
+ }
779
+ };
900
780
  }
901
781
 
902
- async start() {
903
- await this.loadSites();
782
+ _initSniResolver() {
783
+ this._sniCallback = (servername, callback) => {
784
+ const normalizedServername = this._normalizeHostInput(servername).trim().toLowerCase();
785
+ try {
786
+ const pems = this._resolvePemsForServername(normalizedServername);
787
+ if (pems) {
788
+ callback(null, tls.createSecureContext({ key: pems.key, cert: pems.cert }));
789
+ return;
790
+ }
791
+ } catch (error) {
792
+ callback(error);
793
+ return;
794
+ }
904
795
 
905
- // Skip Greenlock configuration generation in local mode
906
- if (!this.local) {
907
- this.generateConfigJson();
908
- }
796
+ // Cluster-friendly automatic issuance path (no internal listen lifecycle).
797
+ if (!this._greenlockRuntime || !normalizedServername) {
798
+ callback(new Error(`No certificate files available for ${servername}`));
799
+ return;
800
+ }
909
801
 
910
- // Handle local mode with simple HTTP server
911
- if (this.local) {
912
- return this.startLocalMode();
913
- }
802
+ this._greenlockRuntime.get({ servername: normalizedServername })
803
+ .then(() => {
804
+ const issued = this._resolvePemsForServername(normalizedServername);
805
+ if (issued) {
806
+ callback(null, tls.createSecureContext({ key: issued.key, cert: issued.cert }));
807
+ } else {
808
+ callback(new Error(`No certificate files available for ${servername}`));
809
+ }
810
+ })
811
+ .catch((error) => {
812
+ callback(error);
813
+ });
814
+ };
815
+ }
914
816
 
915
- const greenlockOptions = {
817
+ _buildGreenlockOptions() {
818
+ return {
916
819
  packageRoot: __dirname,
917
820
  configDir: this.greenlockStorePath,
918
821
  maintainerEmail: this.email,
@@ -966,7 +869,6 @@ class Roster {
966
869
  if (eventDomain && !msg.includes(`[${eventDomain}]`)) {
967
870
  msg = `[${eventDomain}] ${msg}`;
968
871
  }
969
- // Suppress known benign warnings from ACME when using acme-dns-01-cli
970
872
  if (event === 'warning' && typeof msg === 'string') {
971
873
  if (/acme-dns-01-cli.*(incorrect function signatures|deprecated use of callbacks)/i.test(msg)) return;
972
874
  if (/dns-01 challenge plugin should have zones/i.test(msg)) return;
@@ -976,8 +878,173 @@ class Roster {
976
878
  else log.info(msg);
977
879
  }
978
880
  };
979
- // Keep a direct greenlock runtime handle so we can call get() explicitly under Bun
980
- // before binding :443, avoiding invalid non-TLS responses on startup.
881
+ }
882
+
883
+ _getManagedCertificateSubjects() {
884
+ const uniqueDomains = new Set();
885
+ this.domains.forEach((domain) => {
886
+ const root = domain.startsWith('*.') ? wildcardRoot(domain) : domain.replace(/^www\./, '');
887
+ if (root) uniqueDomains.add(root);
888
+ });
889
+ const subjects = [];
890
+ uniqueDomains.forEach((domain) => {
891
+ subjects.push(domain);
892
+ const includeWildcard = this.wildcardZones.has(domain) && this.dnsChallenge && !this.combineWildcardCerts;
893
+ if (includeWildcard) subjects.push(`*.${domain}`);
894
+ });
895
+ return [...new Set(subjects)];
896
+ }
897
+
898
+ _startCertificateRenewLoop() {
899
+ if (!this._greenlockRuntime || this._certificateRenewTimer) return;
900
+ const subjects = this._getManagedCertificateSubjects();
901
+ if (subjects.length === 0) return;
902
+ this._certificateRenewTimer = setInterval(() => {
903
+ subjects.forEach((subject) => {
904
+ this._greenlockRuntime.get({ servername: subject }).catch((error) => {
905
+ log.warn(`⚠️ Certificate renew check failed for ${subject}: ${error?.message || error}`);
906
+ });
907
+ });
908
+ }, this.certificateRenewIntervalMs);
909
+ if (typeof this._certificateRenewTimer.unref === 'function') {
910
+ this._certificateRenewTimer.unref();
911
+ }
912
+ }
913
+
914
+ async ensureCertificate(servername) {
915
+ if (this.local) {
916
+ throw new Error('ensureCertificate() is not available in local mode');
917
+ }
918
+ if (!this._initialized) {
919
+ throw new Error('Call init() before ensureCertificate()');
920
+ }
921
+ const normalizedServername = this._normalizeHostInput(servername).trim().toLowerCase();
922
+ if (!normalizedServername) {
923
+ throw new Error('servername is required');
924
+ }
925
+ let pems = this._resolvePemsForServername(normalizedServername);
926
+ if (pems) return pems;
927
+ if (!this._greenlockRuntime) {
928
+ throw new Error('autoCertificates is disabled; enable { autoCertificates: true } to issue certificates automatically');
929
+ }
930
+ await this._greenlockRuntime.get({ servername: normalizedServername });
931
+ pems = this._resolvePemsForServername(normalizedServername);
932
+ if (!pems) {
933
+ throw new Error(`Certificate issuance completed but no PEM files were found for ${normalizedServername}`);
934
+ }
935
+ return pems;
936
+ }
937
+
938
+ async init() {
939
+ if (this._initialized) return this;
940
+ await this.loadSites();
941
+ if (!this.local) {
942
+ this.generateConfigJson();
943
+ if (this.autoCertificates) {
944
+ this._greenlockRuntime = GreenlockShim.create(this._buildGreenlockOptions());
945
+ }
946
+ }
947
+ this._initSiteHandlers();
948
+ if (!this.local) {
949
+ this._initSniResolver();
950
+ if (this.autoCertificates) {
951
+ this._startCertificateRenewLoop();
952
+ }
953
+ }
954
+ this._initialized = true;
955
+ return this;
956
+ }
957
+
958
+ requestHandler(port) {
959
+ if (!this._initialized) throw new Error('Call init() before requestHandler()');
960
+ const targetPort = port || this.defaultPort;
961
+ const portData = this._sitesByPort[targetPort];
962
+ if (!portData) {
963
+ return (req, res) => {
964
+ res.writeHead(404);
965
+ res.end('Site not found');
966
+ };
967
+ }
968
+ return this._createDispatcher(portData);
969
+ }
970
+
971
+ upgradeHandler(port) {
972
+ if (!this._initialized) throw new Error('Call init() before upgradeHandler()');
973
+ const targetPort = port || this.defaultPort;
974
+ const portData = this._sitesByPort[targetPort];
975
+ if (!portData) {
976
+ return (req, socket, head) => { socket.destroy(); };
977
+ }
978
+ return this._createUpgradeHandler(portData);
979
+ }
980
+
981
+ sniCallback() {
982
+ if (!this._initialized) throw new Error('Call init() before sniCallback()');
983
+ if (!this._sniCallback) throw new Error('SNI callback not available in local mode');
984
+ return this._sniCallback;
985
+ }
986
+
987
+ attach(server, { port } = {}) {
988
+ if (!this._initialized) throw new Error('Call init() before attach()');
989
+ server.on('request', this.requestHandler(port));
990
+ server.on('upgrade', this.upgradeHandler(port));
991
+ return this;
992
+ }
993
+
994
+ startLocalMode() {
995
+ this.domainPorts = {};
996
+
997
+ for (const portData of Object.values(this._sitesByPort)) {
998
+ for (const [domain, virtualServer] of Object.entries(portData.virtualServers)) {
999
+ if (domain.startsWith('www.')) continue;
1000
+
1001
+ const port = this.assignPortToDomain(domain);
1002
+ this.domainPorts[domain] = port;
1003
+
1004
+ const appHandler = portData.appHandlers[domain];
1005
+
1006
+ const dispatcher = (req, res) => {
1007
+ virtualServer.fallbackHandler = appHandler;
1008
+ if (virtualServer.requestListeners.length > 0) {
1009
+ virtualServer.processRequest(req, res);
1010
+ } else if (appHandler) {
1011
+ appHandler(req, res);
1012
+ } else {
1013
+ res.writeHead(404);
1014
+ res.end('Site not found');
1015
+ }
1016
+ };
1017
+
1018
+ const httpServer = http.createServer(dispatcher);
1019
+ this.portServers[port] = httpServer;
1020
+
1021
+ httpServer.on('upgrade', (req, socket, head) => {
1022
+ virtualServer.processUpgrade(req, socket, head);
1023
+ });
1024
+
1025
+ httpServer.listen(port, 'localhost', () => {
1026
+ const cleanDomain = normalizeDomainForLocalHost(domain);
1027
+ log.info(`🌐 ${domain} → http://${localHostForDomain(cleanDomain)}:${port}`);
1028
+ });
1029
+
1030
+ httpServer.on('error', (error) => {
1031
+ log.error(`❌ Error on port ${port} for ${domain}:`, error.message);
1032
+ });
1033
+ }
1034
+ }
1035
+
1036
+ log.info(`(✔) Started ${Object.keys(this.portServers).length} sites in local mode`);
1037
+ return Promise.resolve();
1038
+ }
1039
+
1040
+ async start() {
1041
+ await this.init();
1042
+
1043
+ if (this.local) {
1044
+ return this.startLocalMode();
1045
+ }
1046
+
1047
+ const greenlockOptions = this._buildGreenlockOptions();
981
1048
  const greenlockRuntime = GreenlockShim.create(greenlockOptions);
982
1049
  const greenlock = Greenlock.init({
983
1050
  ...greenlockOptions,
@@ -986,50 +1053,22 @@ class Roster {
986
1053
 
987
1054
  return greenlock.ready(async glx => {
988
1055
  const httpServer = glx.httpServer();
989
- const { sitesByPort } = this.prepareSites();
990
-
991
1056
  const bunTlsHotReloadHandlers = [];
992
1057
 
993
1058
  httpServer.listen(80, this.hostname, () => {
994
1059
  log.info('HTTP server listening on port 80');
995
1060
  });
996
1061
 
997
- // Handle different port types
998
- for (const [port, portData] of Object.entries(sitesByPort)) {
1062
+ for (const [port, portData] of Object.entries(this._sitesByPort)) {
999
1063
  const portNum = parseInt(port);
1000
- const dispatcher = this.createPortRequestDispatcher(portData);
1001
- const upgradeHandler = this.createPortUpgradeDispatcher(portData);
1002
- const greenlockStorePath = this.greenlockStorePath;
1003
- const loadCert = (subjectDir) => {
1004
- const normalizedSubject = normalizeHostInput(subjectDir).trim().toLowerCase();
1005
- if (!normalizedSubject) return null;
1006
- const certPath = path.join(greenlockStorePath, 'live', normalizedSubject);
1007
- const keyPath = path.join(certPath, 'privkey.pem');
1008
- const certFilePath = path.join(certPath, 'cert.pem');
1009
- const chainPath = path.join(certPath, 'chain.pem');
1010
- if (fs.existsSync(keyPath) && fs.existsSync(certFilePath) && fs.existsSync(chainPath)) {
1011
- return {
1012
- key: fs.readFileSync(keyPath, 'utf8'),
1013
- cert: fs.readFileSync(certFilePath, 'utf8') + fs.readFileSync(chainPath, 'utf8')
1014
- };
1015
- }
1016
- return null;
1017
- };
1018
- const resolvePemsForServername = (servername) => {
1019
- const host = normalizeHostInput(servername).trim().toLowerCase();
1020
- if (!host) return null;
1021
- const candidates = buildCertLookupCandidates(host);
1022
- for (const candidate of candidates) {
1023
- const pems = loadCert(candidate);
1024
- if (pems) return pems;
1025
- }
1026
- return null;
1027
- };
1064
+ const dispatcher = this._createDispatcher(portData);
1065
+ const upgradeHandler = this._createUpgradeHandler(portData);
1066
+
1028
1067
  const issueAndReloadPemsForServername = async (servername) => {
1029
- const host = normalizeHostInput(servername).trim().toLowerCase();
1068
+ const host = this._normalizeHostInput(servername).trim().toLowerCase();
1030
1069
  if (!host) return null;
1031
1070
 
1032
- let pems = resolvePemsForServername(host);
1071
+ let pems = this._resolvePemsForServername(host);
1033
1072
  if (pems) return pems;
1034
1073
 
1035
1074
  try {
@@ -1038,11 +1077,9 @@ class Roster {
1038
1077
  log.warn(`⚠️ Greenlock issuance failed for ${host}: ${error?.message || error}`);
1039
1078
  }
1040
1079
 
1041
- pems = resolvePemsForServername(host);
1080
+ pems = this._resolvePemsForServername(host);
1042
1081
  if (pems) return pems;
1043
1082
 
1044
- // For wildcard zones, try a valid subdomain bootstrap host so Greenlock can
1045
- // resolve the wildcard site without relying on invalid "*.domain" servername input.
1046
1083
  const wildcardSubject = wildcardSubjectForHost(host);
1047
1084
  const zone = wildcardSubject ? wildcardRoot(wildcardSubject) : null;
1048
1085
  if (zone) {
@@ -1052,11 +1089,12 @@ class Roster {
1052
1089
  } catch (error) {
1053
1090
  log.warn(`⚠️ Greenlock wildcard bootstrap failed for ${bootstrapHost}: ${error?.message || error}`);
1054
1091
  }
1055
- pems = resolvePemsForServername(host);
1092
+ pems = this._resolvePemsForServername(host);
1056
1093
  }
1057
1094
 
1058
1095
  return pems;
1059
1096
  };
1097
+
1060
1098
  const ensureBunDefaultPems = async (primaryDomain) => {
1061
1099
  let pems = await issueAndReloadPemsForServername(primaryDomain);
1062
1100
 
@@ -1066,7 +1104,7 @@ class Roster {
1066
1104
 
1067
1105
  if (pems && needsWildcard && !certCoversName(pems.cert, `*.${primaryDomain}`)) {
1068
1106
  log.warn(`⚠️ Existing cert for ${primaryDomain} lacks *.${primaryDomain} SAN — clearing stale cert for combined re-issuance`);
1069
- const certDir = path.join(greenlockStorePath, 'live', primaryDomain);
1107
+ const certDir = path.join(this.greenlockStorePath, 'live', primaryDomain);
1070
1108
  try { fs.rmSync(certDir, { recursive: true, force: true }); } catch {}
1071
1109
  pems = null;
1072
1110
  }
@@ -1081,7 +1119,7 @@ class Roster {
1081
1119
  log.error(`❌ Failed to obtain certificate for ${certSubject} under Bun:`, error?.message || error);
1082
1120
  }
1083
1121
 
1084
- pems = resolvePemsForServername(primaryDomain);
1122
+ pems = this._resolvePemsForServername(primaryDomain);
1085
1123
  if (pems) return pems;
1086
1124
 
1087
1125
  throw new Error(
@@ -1091,15 +1129,11 @@ class Roster {
1091
1129
  };
1092
1130
 
1093
1131
  if (portNum === this.defaultPort) {
1094
- // Bun has known gaps around SNICallback compatibility.
1095
- // Fallback to static cert loading for the primary domain on default HTTPS port.
1096
1132
  const tlsOpts = { minVersion: this.tlsMinVersion, maxVersion: this.tlsMaxVersion };
1097
1133
  let httpsServer;
1098
1134
 
1099
1135
  if (isBunRuntime) {
1100
1136
  const primaryDomain = Object.keys(portData.virtualServers)[0];
1101
- // Under Bun, avoid glx.httpsServer fallback (may serve invalid TLS on :443).
1102
- // Require concrete PEM files and create native https server directly.
1103
1137
  let defaultPems = await ensureBunDefaultPems(primaryDomain);
1104
1138
  httpsServer = https.createServer({
1105
1139
  ...tlsOpts,
@@ -1135,21 +1169,18 @@ class Roster {
1135
1169
  }
1136
1170
 
1137
1171
  this.portServers[portNum] = httpsServer;
1138
-
1139
- // Handle WebSocket upgrade events
1140
1172
  httpsServer.on('upgrade', upgradeHandler);
1141
1173
 
1142
1174
  httpsServer.listen(portNum, this.hostname, () => {
1143
1175
  log.info(`HTTPS server listening on port ${portNum}`);
1144
1176
  });
1145
1177
  } else {
1146
- // Create HTTPS server for custom ports using Greenlock certificates
1147
1178
  const httpsOptions = {
1148
1179
  minVersion: this.tlsMinVersion,
1149
1180
  maxVersion: this.tlsMaxVersion,
1150
1181
  SNICallback: (servername, callback) => {
1151
1182
  try {
1152
- const pems = resolvePemsForServername(servername);
1183
+ const pems = this._resolvePemsForServername(servername);
1153
1184
  if (pems) {
1154
1185
  callback(null, tls.createSecureContext({ key: pems.key, cert: pems.cert }));
1155
1186
  } else {
@@ -1162,8 +1193,6 @@ class Roster {
1162
1193
  };
1163
1194
 
1164
1195
  const httpsServer = https.createServer(httpsOptions, dispatcher);
1165
-
1166
- // Handle WebSocket upgrade events
1167
1196
  httpsServer.on('upgrade', upgradeHandler);
1168
1197
 
1169
1198
  httpsServer.on('error', (error) => {
@@ -1171,7 +1200,6 @@ class Roster {
1171
1200
  });
1172
1201
 
1173
1202
  httpsServer.on('tlsClientError', (error) => {
1174
- // Suppress HTTP request errors to avoid log spam
1175
1203
  if (!error.message.includes('http request')) {
1176
1204
  log.error(`TLS error on port ${portNum}:`, error.message);
1177
1205
  }
@@ -1195,7 +1223,7 @@ class Roster {
1195
1223
  : 30000;
1196
1224
  const maxAttempts = Number.isFinite(Number(process.env.ROSTER_BUN_WILDCARD_PREWARM_MAX_ATTEMPTS))
1197
1225
  ? Math.max(0, Number(process.env.ROSTER_BUN_WILDCARD_PREWARM_MAX_ATTEMPTS))
1198
- : 0; // 0 = retry forever
1226
+ : 0;
1199
1227
 
1200
1228
  for (const zone of this.wildcardZones) {
1201
1229
  const bootstrapHost = `bun-bootstrap.${zone}`;
@@ -1204,7 +1232,6 @@ class Roster {
1204
1232
  log.warn(`⚠️ Bun runtime detected: prewarming wildcard certificate via ${bootstrapHost} (attempt ${attempt})`);
1205
1233
  let reloaded = false;
1206
1234
  for (const reloadTls of bunTlsHotReloadHandlers) {
1207
- // Trigger issuance + immediately hot-reload default TLS context when ready.
1208
1235
  reloaded = (await reloadTls(bootstrapHost, `prewarm ${bootstrapHost} attempt ${attempt}`)) || reloaded;
1209
1236
  }
1210
1237
  if (!reloaded) {
@@ -1223,7 +1250,6 @@ class Roster {
1223
1250
  }
1224
1251
  };
1225
1252
 
1226
- // Background prewarm + retries so HTTPS startup is not blocked by DNS propagation timing.
1227
1253
  attemptPrewarm().catch(() => {});
1228
1254
  }
1229
1255
  }