@serve.zone/dcrouter 11.10.7 → 11.12.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.
@@ -528,10 +528,36 @@ export class DcRouter {
528
528
  }
529
529
 
530
530
  public async start() {
531
+ await this.checkSystemLimits();
531
532
  logger.log('info', 'Starting DcRouter Services');
532
533
  await this.serviceManager.start();
533
534
  this.logStartupSummary();
534
535
  }
536
+
537
+ /**
538
+ * Detect OS-level resource limits and warn if they are too low for production use.
539
+ * This is detection only — no attempts to raise limits.
540
+ */
541
+ private async checkSystemLimits(): Promise<void> {
542
+ try {
543
+ const fs = new plugins.smartfs.SmartFs(new plugins.smartfs.SmartFsProviderNode());
544
+ const limitsContent = await fs.file('/proc/self/limits').encoding('utf8').read() as string;
545
+ const nofileLine = limitsContent.split('\n').find((line: string) => line.startsWith('Max open files'));
546
+ if (nofileLine) {
547
+ const parts = nofileLine.split(/\s{2,}/);
548
+ const softLimit = parseInt(parts[1], 10);
549
+ const hardLimit = parseInt(parts[2], 10);
550
+ if (softLimit < 65536) {
551
+ logger.log('warn', `File descriptor soft limit is ${softLimit} (hard: ${hardLimit}). ` +
552
+ `For production use, set --ulimit nofile=65536:65536 on the container runtime.`);
553
+ } else {
554
+ logger.log('info', `File descriptor limits: soft=${softLimit}, hard=${hardLimit}`);
555
+ }
556
+ }
557
+ } catch {
558
+ // Non-Linux or /proc not available — silently skip
559
+ }
560
+ }
535
561
 
536
562
  /**
537
563
  * Log comprehensive startup summary
@@ -708,9 +734,28 @@ export class DcRouter {
708
734
  // Track cert entries loaded from cert store so we can populate certificateStatusMap after start
709
735
  const loadedCertEntries: Array<{domain: string; publicKey: string; validUntil?: number; validFrom?: number}> = [];
710
736
 
711
- // Create SmartProxy configuration
737
+ // Create SmartProxy configuration with sensible gateway defaults.
738
+ // User's smartProxyConfig overrides these defaults via spread.
712
739
  const smartProxyConfig: plugins.smartproxy.ISmartProxyOptions = {
740
+ // --- dcrouter gateway defaults ---
741
+ maxConnectionsPerIP: 100,
742
+ connectionRateLimitPerMinute: 600,
743
+ socketTimeout: 120_000,
744
+ inactivityTimeout: 120_000,
745
+ keepAlive: true,
746
+ noDelay: true,
747
+ gracefulShutdownTimeout: 30_000,
748
+ // --- user overrides ---
713
749
  ...this.options.smartProxyConfig,
750
+ // --- deep-merge defaults.security so user can override maxConnections ---
751
+ defaults: {
752
+ ...this.options.smartProxyConfig?.defaults,
753
+ security: {
754
+ maxConnections: 50_000,
755
+ ...this.options.smartProxyConfig?.defaults?.security,
756
+ },
757
+ },
758
+ // --- always set by dcrouter (after spread) ---
714
759
  routes,
715
760
  acme: acmeConfig,
716
761
  certStore: {
package/ts/plugins.ts CHANGED
@@ -47,13 +47,13 @@ import * as qenv from '@push.rocks/qenv';
47
47
  import * as smartacme from '@push.rocks/smartacme';
48
48
  import * as smartdata from '@push.rocks/smartdata';
49
49
  import * as smartdns from '@push.rocks/smartdns';
50
- import * as smartfile from '@push.rocks/smartfile';
50
+ import * as smartfs from '@push.rocks/smartfs';
51
51
  import * as smartguard from '@push.rocks/smartguard';
52
52
  import * as smartjwt from '@push.rocks/smartjwt';
53
53
  import * as smartlog from '@push.rocks/smartlog';
54
54
  import * as smartmetrics from '@push.rocks/smartmetrics';
55
55
  import * as smartmta from '@push.rocks/smartmta';
56
- import * as smartmongo from '@push.rocks/smartmongo';
56
+ import * as smartdb from '@push.rocks/smartdb';
57
57
  import * as smartnetwork from '@push.rocks/smartnetwork';
58
58
  import * as smartpath from '@push.rocks/smartpath';
59
59
  import * as smartproxy from '@push.rocks/smartproxy';
@@ -64,7 +64,7 @@ import * as smartrx from '@push.rocks/smartrx';
64
64
  import * as smartunique from '@push.rocks/smartunique';
65
65
  import * as taskbuffer from '@push.rocks/taskbuffer';
66
66
 
67
- export { projectinfo, qenv, smartacme, smartdata, smartdns, smartfile, smartguard, smartjwt, smartlog, smartmetrics, smartmongo, smartmta, smartnetwork, smartpath, smartproxy, smartpromise, smartradius, smartrequest, smartrx, smartunique, taskbuffer };
67
+ export { projectinfo, qenv, smartacme, smartdata, smartdns, smartfs, smartguard, smartjwt, smartlog, smartmetrics, smartdb, smartmta, smartnetwork, smartpath, smartproxy, smartpromise, smartradius, smartrequest, smartrx, smartunique, taskbuffer };
68
68
 
69
69
  // Define SmartLog types for use in error handling
70
70
  export type TLogLevel = 'error' | 'warn' | 'info' | 'success' | 'debug';
@@ -90,7 +90,7 @@ export {
90
90
  uuid,
91
91
  }
92
92
 
93
- // Filesystem utilities (compatibility helpers for smartfile v13+)
93
+ // Filesystem utilities
94
94
  export const fsUtils = {
95
95
  /**
96
96
  * Ensure a directory exists, creating it recursively if needed (sync)
@@ -3,6 +3,6 @@
3
3
  */
4
4
  export const commitinfo = {
5
5
  name: '@serve.zone/dcrouter',
6
- version: '11.10.7',
6
+ version: '11.12.0',
7
7
  description: 'A multifaceted routing service handling mail and SMS delivery functions.'
8
8
  }
@@ -1186,18 +1186,33 @@ export const toggleApiTokenAction = routeManagementStatePart.createAction<{
1186
1186
  let socketClient: plugins.typedsocket.TypedSocket | null = null;
1187
1187
  const socketRouter = new plugins.domtools.plugins.typedrequest.TypedRouter();
1188
1188
 
1189
+ // Batched log entry handler — buffers incoming entries and flushes once per animation frame
1190
+ let logEntryBuffer: interfaces.data.ILogEntry[] = [];
1191
+ let logFlushScheduled = false;
1192
+
1193
+ function flushLogEntries() {
1194
+ logFlushScheduled = false;
1195
+ if (logEntryBuffer.length === 0) return;
1196
+ const current = logStatePart.getState()!;
1197
+ const updated = [...current.recentLogs, ...logEntryBuffer];
1198
+ logEntryBuffer = [];
1199
+ // Cap at 2000 entries
1200
+ if (updated.length > 2000) {
1201
+ updated.splice(0, updated.length - 2000);
1202
+ }
1203
+ logStatePart.setState({ ...current, recentLogs: updated } as ILogState);
1204
+ }
1205
+
1189
1206
  // Register handler for pushed log entries from the server
1190
1207
  socketRouter.addTypedHandler(
1191
1208
  new plugins.domtools.plugins.typedrequest.TypedHandler<interfaces.requests.IReq_PushLogEntry>(
1192
1209
  'pushLogEntry',
1193
1210
  async (dataArg) => {
1194
- const current = logStatePart.getState()!;
1195
- const updated = [...current.recentLogs, dataArg.entry];
1196
- // Cap at 2000 entries
1197
- if (updated.length > 2000) {
1198
- updated.splice(0, updated.length - 2000);
1211
+ logEntryBuffer.push(dataArg.entry);
1212
+ if (!logFlushScheduled) {
1213
+ logFlushScheduled = true;
1214
+ requestAnimationFrame(flushLogEntries);
1199
1215
  }
1200
- logStatePart.setState({ ...current, recentLogs: updated } as ILogState);
1201
1216
  return {};
1202
1217
  }
1203
1218
  )
@@ -1228,8 +1243,21 @@ async function disconnectSocket() {
1228
1243
  }
1229
1244
  }
1230
1245
 
1246
+ // In-flight guard to prevent concurrent refresh requests
1247
+ let isRefreshing = false;
1248
+
1231
1249
  // Combined refresh action for efficient polling
1232
1250
  async function dispatchCombinedRefreshAction() {
1251
+ if (isRefreshing) return;
1252
+ isRefreshing = true;
1253
+ try {
1254
+ await dispatchCombinedRefreshActionInner();
1255
+ } finally {
1256
+ isRefreshing = false;
1257
+ }
1258
+ }
1259
+
1260
+ async function dispatchCombinedRefreshActionInner() {
1233
1261
  const context = getActionContext();
1234
1262
  if (!context.identity) return;
1235
1263
  const currentView = uiStatePart.getState()!.activeView;
@@ -1355,48 +1383,48 @@ async function dispatchCombinedRefreshAction() {
1355
1383
  }
1356
1384
  }
1357
1385
 
1358
- // Initialize auto-refresh
1359
- let refreshInterval: NodeJS.Timeout | null = null;
1360
- let currentRefreshRate = 1000; // Track current refresh rate to avoid unnecessary restarts
1361
-
1362
- // Initialize auto-refresh when UI state is ready
1363
- (() => {
1364
- const startAutoRefresh = () => {
1365
- const uiState = uiStatePart.getState()!;
1366
- const loginState = loginStatePart.getState()!;
1367
-
1368
- // Only start if conditions are met and not already running at the same rate
1369
- if (uiState.autoRefresh && loginState.isLoggedIn) {
1370
- // Check if we need to restart the interval (rate changed or not running)
1371
- if (!refreshInterval || currentRefreshRate !== uiState.refreshInterval) {
1372
- stopAutoRefresh();
1373
- currentRefreshRate = uiState.refreshInterval;
1374
- refreshInterval = setInterval(() => {
1375
- // Use combined refresh action for efficiency
1376
- dispatchCombinedRefreshAction();
1377
- }, uiState.refreshInterval);
1378
- }
1379
- } else {
1380
- stopAutoRefresh();
1381
- }
1382
- };
1386
+ // Create a proper action for the combined refresh so we can use createScheduledAction
1387
+ const combinedRefreshAction = statsStatePart.createAction<void>(async (statePartArg) => {
1388
+ await dispatchCombinedRefreshAction();
1389
+ // Return current state — dispatchCombinedRefreshAction already updates all state parts directly
1390
+ return statePartArg.getState()!;
1391
+ });
1392
+
1393
+ // Scheduled refresh process with autoPause: 'visibility' — automatically pauses when tab is hidden
1394
+ let refreshProcess: ReturnType<typeof statsStatePart.createScheduledAction> | null = null;
1383
1395
 
1384
- const stopAutoRefresh = () => {
1385
- if (refreshInterval) {
1386
- clearInterval(refreshInterval);
1387
- refreshInterval = null;
1388
- currentRefreshRate = 0;
1396
+ const startAutoRefresh = () => {
1397
+ const uiState = uiStatePart.getState()!;
1398
+ const loginState = loginStatePart.getState()!;
1399
+
1400
+ if (uiState.autoRefresh && loginState.isLoggedIn) {
1401
+ // Dispose old process if interval changed or not running
1402
+ if (refreshProcess) {
1403
+ refreshProcess.dispose();
1404
+ refreshProcess = null;
1389
1405
  }
1390
- };
1406
+ refreshProcess = statsStatePart.createScheduledAction({
1407
+ action: combinedRefreshAction,
1408
+ payload: undefined,
1409
+ intervalMs: uiState.refreshInterval,
1410
+ autoPause: 'visibility',
1411
+ });
1412
+ } else {
1413
+ if (refreshProcess) {
1414
+ refreshProcess.dispose();
1415
+ refreshProcess = null;
1416
+ }
1417
+ }
1418
+ };
1391
1419
 
1392
- // Watch for relevant changes only
1393
- let previousAutoRefresh = uiStatePart.getState()!.autoRefresh;
1394
- let previousRefreshInterval = uiStatePart.getState()!.refreshInterval;
1395
- let previousIsLoggedIn = loginStatePart.getState()!.isLoggedIn;
1396
-
1397
- uiStatePart.state.subscribe((state) => {
1398
- // Only restart if relevant values changed
1399
- if (state.autoRefresh !== previousAutoRefresh ||
1420
+ // Watch for relevant changes
1421
+ let previousAutoRefresh = uiStatePart.getState()!.autoRefresh;
1422
+ let previousRefreshInterval = uiStatePart.getState()!.refreshInterval;
1423
+ let previousIsLoggedIn = loginStatePart.getState()!.isLoggedIn;
1424
+
1425
+ uiStatePart.select((s) => ({ autoRefresh: s.autoRefresh, refreshInterval: s.refreshInterval }))
1426
+ .subscribe((state) => {
1427
+ if (state.autoRefresh !== previousAutoRefresh ||
1400
1428
  state.refreshInterval !== previousRefreshInterval) {
1401
1429
  previousAutoRefresh = state.autoRefresh;
1402
1430
  previousRefreshInterval = state.refreshInterval;
@@ -1404,26 +1432,33 @@ let currentRefreshRate = 1000; // Track current refresh rate to avoid unnecessar
1404
1432
  }
1405
1433
  });
1406
1434
 
1407
- loginStatePart.state.subscribe((state) => {
1408
- // Only restart if login state changed
1409
- if (state.isLoggedIn !== previousIsLoggedIn) {
1410
- previousIsLoggedIn = state.isLoggedIn;
1411
- startAutoRefresh();
1435
+ loginStatePart.select((s) => s.isLoggedIn).subscribe((isLoggedIn) => {
1436
+ if (isLoggedIn !== previousIsLoggedIn) {
1437
+ previousIsLoggedIn = isLoggedIn;
1438
+ startAutoRefresh();
1412
1439
 
1413
- // Connect/disconnect TypedSocket based on login state
1414
- if (state.isLoggedIn) {
1415
- connectSocket();
1416
- } else {
1417
- disconnectSocket();
1418
- }
1440
+ // Connect/disconnect TypedSocket based on login state
1441
+ if (isLoggedIn) {
1442
+ connectSocket();
1443
+ } else {
1444
+ disconnectSocket();
1419
1445
  }
1420
- });
1421
-
1422
- // Initial start
1423
- startAutoRefresh();
1446
+ }
1447
+ });
1424
1448
 
1425
- // Connect TypedSocket if already logged in (e.g., persistent session)
1426
- if (loginStatePart.getState()!.isLoggedIn) {
1449
+ // Pause/resume WebSocket when tab visibility changes
1450
+ document.addEventListener('visibilitychange', () => {
1451
+ if (document.hidden) {
1452
+ disconnectSocket();
1453
+ } else if (loginStatePart.getState()!.isLoggedIn) {
1427
1454
  connectSocket();
1428
1455
  }
1429
- })();
1456
+ });
1457
+
1458
+ // Initial start
1459
+ startAutoRefresh();
1460
+
1461
+ // Connect TypedSocket if already logged in (e.g., persistent session)
1462
+ if (loginStatePart.getState()!.isLoggedIn) {
1463
+ connectSocket();
1464
+ }
@@ -25,7 +25,7 @@ export class OpsViewCertificates extends DeesElement {
25
25
 
26
26
  constructor() {
27
27
  super();
28
- const sub = appstate.certificateStatePart.state.subscribe((newState) => {
28
+ const sub = appstate.certificateStatePart.select().subscribe((newState) => {
29
29
  this.certState = newState;
30
30
  });
31
31
  this.rxSubscriptions.push(sub);
@@ -28,7 +28,7 @@ export class OpsViewEmails extends DeesElement {
28
28
 
29
29
  async connectedCallback() {
30
30
  await super.connectedCallback();
31
- this.stateSubscription = appstate.emailOpsStatePart.state.subscribe((state) => {
31
+ this.stateSubscription = appstate.emailOpsStatePart.select().subscribe((state) => {
32
32
  this.emails = state.emails;
33
33
  this.isLoading = state.isLoading;
34
34
  });
@@ -47,10 +47,11 @@ export class OpsViewNetwork extends DeesElement {
47
47
  // Track if we need to update the chart to avoid unnecessary re-renders
48
48
  private lastChartUpdate = 0;
49
49
  private chartUpdateThreshold = 1000; // Minimum ms between chart updates
50
-
50
+
51
51
  private trafficUpdateTimer: any = null;
52
52
  private requestsPerSecHistory: number[] = []; // Track requests/sec over time for trend
53
53
  private historyLoaded = false; // Whether server-side throughput history has been loaded
54
+ private visibilityHandler: (() => void) | null = null;
54
55
 
55
56
  constructor() {
56
57
  super();
@@ -59,28 +60,42 @@ export class OpsViewNetwork extends DeesElement {
59
60
  this.updateNetworkData();
60
61
  this.startTrafficUpdateTimer();
61
62
  }
62
-
63
+
63
64
  async connectedCallback() {
64
65
  await super.connectedCallback();
65
-
66
+
67
+ // Pause/resume traffic timer when tab visibility changes
68
+ this.visibilityHandler = () => {
69
+ if (document.hidden) {
70
+ this.stopTrafficUpdateTimer();
71
+ } else {
72
+ this.startTrafficUpdateTimer();
73
+ }
74
+ };
75
+ document.addEventListener('visibilitychange', this.visibilityHandler);
76
+
66
77
  // When network view becomes visible, ensure we fetch network data
67
78
  await appstate.networkStatePart.dispatchAction(appstate.fetchNetworkStatsAction, null);
68
79
  }
69
-
80
+
70
81
  async disconnectedCallback() {
71
82
  await super.disconnectedCallback();
72
83
  this.stopTrafficUpdateTimer();
84
+ if (this.visibilityHandler) {
85
+ document.removeEventListener('visibilitychange', this.visibilityHandler);
86
+ this.visibilityHandler = null;
87
+ }
73
88
  }
74
89
 
75
90
  private subscribeToStateParts() {
76
91
  // Subscribe and track unsubscribe functions
77
- const statsUnsubscribe = appstate.statsStatePart.state.subscribe((state) => {
92
+ const statsUnsubscribe = appstate.statsStatePart.select().subscribe((state) => {
78
93
  this.statsState = state;
79
94
  this.updateNetworkData();
80
95
  });
81
96
  this.rxSubscriptions.push(statsUnsubscribe);
82
97
 
83
- const networkUnsubscribe = appstate.networkStatePart.state.subscribe((state) => {
98
+ const networkUnsubscribe = appstate.networkStatePart.select().subscribe((state) => {
84
99
  this.networkState = state;
85
100
  this.updateNetworkData();
86
101
  });
@@ -25,7 +25,7 @@ export class OpsViewRemoteIngress extends DeesElement {
25
25
 
26
26
  constructor() {
27
27
  super();
28
- const sub = appstate.remoteIngressStatePart.state.subscribe((newState) => {
28
+ const sub = appstate.remoteIngressStatePart.select().subscribe((newState) => {
29
29
  this.riState = newState;
30
30
  });
31
31
  this.rxSubscriptions.push(sub);
package/ts_web/readme.md CHANGED
@@ -111,7 +111,7 @@ ts_web/
111
111
 
112
112
  ### State Management
113
113
 
114
- The app uses `@push.rocks/smartstate` with multiple state parts:
114
+ The app uses `@push.rocks/smartstate` v2.3+ with multiple state parts, scheduled actions with `autoPause: 'visibility'`, and batched updates:
115
115
 
116
116
  | State Part | Mode | Description |
117
117
  |-----------|------|-------------|
@@ -125,6 +125,16 @@ The app uses `@push.rocks/smartstate` with multiple state parts:
125
125
  | `certificateStatePart` | Soft | Certificate list, summary, loading state |
126
126
  | `remoteIngressStatePart` | Soft | Edge list, statuses, new edge secret |
127
127
 
128
+ ### Tab Visibility Optimization
129
+
130
+ The dashboard automatically pauses all background activity when the browser tab is hidden and resumes when visible:
131
+
132
+ - **Auto-refresh polling** uses `createScheduledAction` with `autoPause: 'visibility'` — stops HTTP requests while the tab is sleeping
133
+ - **In-flight guard** prevents concurrent refresh requests from piling up
134
+ - **WebSocket connection** disconnects when hidden and reconnects when visible, preventing log entry accumulation
135
+ - **Network traffic timer** pauses chart updates when the tab is backgrounded
136
+ - **Log entry batching** — incoming WebSocket log pushes are buffered and flushed once per animation frame to avoid per-entry re-renders
137
+
128
138
  ### Actions
129
139
 
130
140
  ```typescript
package/ts_web/router.ts CHANGED
@@ -38,7 +38,7 @@ class AppRouter {
38
38
  }
39
39
 
40
40
  private setupStateSync(): void {
41
- appstate.uiStatePart.state.subscribe((uiState) => {
41
+ appstate.uiStatePart.select().subscribe((uiState) => {
42
42
  if (this.suppressStateUpdate) return;
43
43
 
44
44
  const currentPath = window.location.pathname;