@serve.zone/dcrouter 17.0.0 → 17.1.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.
Files changed (139) hide show
  1. package/.smartconfig.json +5 -2
  2. package/deno.json +1 -1
  3. package/dist_serve/bundle.js +1316 -1151
  4. package/dist_ts/00_commitinfo_data.js +1 -1
  5. package/dist_ts/classes.dcrouter.d.ts +3 -1
  6. package/dist_ts/classes.dcrouter.js +110 -49
  7. package/dist_ts/db/classes.cache.cleaner.d.ts +3 -0
  8. package/dist_ts/db/classes.cache.cleaner.js +7 -3
  9. package/dist_ts/db/classes.dcrouter-db.js +1 -2
  10. package/dist_ts/db/documents/classes.cached.email.d.ts +41 -4
  11. package/dist_ts/db/documents/classes.cached.email.js +36 -5
  12. package/dist_ts/db/documents/classes.dns-record.doc.js +3 -1
  13. package/dist_ts/dns/classes.dns-server-runtime.d.ts +0 -1
  14. package/dist_ts/dns/classes.dns-server-runtime.js +4 -36
  15. package/dist_ts/dns/index.d.ts +1 -0
  16. package/dist_ts/dns/index.js +2 -1
  17. package/dist_ts/dns/manager.dns.d.ts +1 -0
  18. package/dist_ts/dns/manager.dns.js +49 -14
  19. package/dist_ts/dns/providers/cloudflare.provider.js +19 -4
  20. package/dist_ts/dns/txt-record-presentation.d.ts +20 -0
  21. package/dist_ts/dns/txt-record-presentation.js +131 -0
  22. package/dist_ts/email/classes.accepted-email-spool.d.ts +32 -3
  23. package/dist_ts/email/classes.accepted-email-spool.js +174 -13
  24. package/dist_ts/email/classes.email-domain.manager.d.ts +14 -1
  25. package/dist_ts/email/classes.email-domain.manager.js +151 -8
  26. package/dist_ts/email/classes.email-settings.manager.js +2 -2
  27. package/dist_ts/email/classes.mail-dns-sync.d.ts +31 -5
  28. package/dist_ts/email/classes.mail-dns-sync.js +508 -117
  29. package/dist_ts/email/classes.smartmta-blob-storage-manager.d.ts +2 -2
  30. package/dist_ts/email/classes.smartmta-blob-storage-manager.js +4 -3
  31. package/dist_ts/email/classes.workapp-mail-manager.d.ts +1 -0
  32. package/dist_ts/email/classes.workapp-mail-manager.js +26 -2
  33. package/dist_ts/email/index.d.ts +2 -0
  34. package/dist_ts/email/index.js +3 -1
  35. package/dist_ts/email/mail-dns-txt-policy.d.ts +18 -0
  36. package/dist_ts/email/mail-dns-txt-policy.js +466 -0
  37. package/dist_ts/email/reconcile-mail-dns-singleton.d.ts +47 -0
  38. package/dist_ts/email/reconcile-mail-dns-singleton.js +159 -0
  39. package/dist_ts/opsserver/classes.opsserver.d.ts +8 -0
  40. package/dist_ts/opsserver/classes.opsserver.js +34 -1
  41. package/dist_ts/opsserver/handlers/certificate.handler.js +25 -4
  42. package/dist_ts/opsserver/handlers/config.handler.js +9 -5
  43. package/dist_ts/opsserver/handlers/dns-provider.handler.js +19 -2
  44. package/dist_ts/opsserver/handlers/dns-record.handler.js +25 -4
  45. package/dist_ts/opsserver/handlers/domain.handler.js +37 -3
  46. package/dist_ts/opsserver/handlers/email-domain.handler.js +5 -7
  47. package/dist_ts/opsserver/handlers/email-ops.handler.d.ts +1 -0
  48. package/dist_ts/opsserver/handlers/email-ops.handler.js +26 -7
  49. package/dist_ts/opsserver/handlers/index.d.ts +1 -0
  50. package/dist_ts/opsserver/handlers/index.js +2 -1
  51. package/dist_ts/opsserver/handlers/logs.handler.d.ts +1 -1
  52. package/dist_ts/opsserver/handlers/logs.handler.js +3 -3
  53. package/dist_ts/opsserver/handlers/realtime.handler.d.ts +37 -0
  54. package/dist_ts/opsserver/handlers/realtime.handler.js +154 -0
  55. package/dist_ts/opsserver/handlers/remoteingress.handler.js +22 -1
  56. package/dist_ts/opsserver/handlers/route-management.handler.js +47 -5
  57. package/dist_ts/paths.d.ts +0 -11
  58. package/dist_ts/paths.js +1 -22
  59. package/dist_ts/plugins.d.ts +0 -34
  60. package/dist_ts/plugins.js +1 -62
  61. package/dist_ts_interfaces/data/email-domain.d.ts +14 -0
  62. package/dist_ts_interfaces/requests/config.d.ts +1 -1
  63. package/dist_ts_interfaces/requests/email-ops.d.ts +34 -1
  64. package/dist_ts_interfaces/requests/index.d.ts +1 -0
  65. package/dist_ts_interfaces/requests/index.js +2 -1
  66. package/dist_ts_interfaces/requests/realtime.d.ts +38 -0
  67. package/dist_ts_interfaces/requests/realtime.js +11 -0
  68. package/dist_ts_migrations/index.d.ts +6 -0
  69. package/dist_ts_migrations/index.js +204 -9
  70. package/dist_ts_web/00_commitinfo_data.js +1 -1
  71. package/dist_ts_web/appstate/certificates.js +9 -2
  72. package/dist_ts_web/appstate/domains.js +16 -5
  73. package/dist_ts_web/appstate/email-domains.d.ts +3 -0
  74. package/dist_ts_web/appstate/email-domains.js +47 -14
  75. package/dist_ts_web/appstate/email-ops.d.ts +1 -0
  76. package/dist_ts_web/appstate/email-ops.js +20 -2
  77. package/dist_ts_web/appstate/realtime.d.ts +27 -0
  78. package/dist_ts_web/appstate/realtime.js +71 -0
  79. package/dist_ts_web/appstate/remoteingress.js +9 -3
  80. package/dist_ts_web/appstate/routes.js +16 -5
  81. package/dist_ts_web/appstate/runtime.js +135 -7
  82. package/dist_ts_web/appstate.d.ts +1 -0
  83. package/dist_ts_web/appstate.js +2 -1
  84. package/dist_ts_web/elements/email/email-domain-creation-progress.d.ts +33 -0
  85. package/dist_ts_web/elements/email/email-domain-creation-progress.js +257 -0
  86. package/dist_ts_web/elements/email/ops-view-email-domains.js +233 -68
  87. package/dist_ts_web/elements/email/ops-view-emails.d.ts +4 -2
  88. package/dist_ts_web/elements/email/ops-view-emails.js +25 -13
  89. package/dist_ts_web/elements/overview/ops-view-config.js +5 -5
  90. package/package.json +22 -19
  91. package/ts/00_commitinfo_data.ts +1 -1
  92. package/ts/classes.dcrouter.ts +121 -54
  93. package/ts/db/classes.cache.cleaner.ts +18 -3
  94. package/ts/db/classes.dcrouter-db.ts +0 -2
  95. package/ts/db/documents/classes.cached.email.ts +78 -4
  96. package/ts/db/documents/classes.dns-record.doc.ts +1 -0
  97. package/ts/dns/classes.dns-server-runtime.ts +9 -45
  98. package/ts/dns/index.ts +1 -0
  99. package/ts/dns/manager.dns.ts +50 -16
  100. package/ts/dns/providers/cloudflare.provider.ts +21 -3
  101. package/ts/dns/txt-record-presentation.ts +134 -0
  102. package/ts/email/classes.accepted-email-spool.ts +220 -14
  103. package/ts/email/classes.email-domain.manager.ts +201 -7
  104. package/ts/email/classes.email-settings.manager.ts +1 -1
  105. package/ts/email/classes.mail-dns-sync.ts +588 -122
  106. package/ts/email/classes.smartmta-blob-storage-manager.ts +3 -2
  107. package/ts/email/classes.workapp-mail-manager.ts +34 -1
  108. package/ts/email/index.ts +2 -0
  109. package/ts/email/mail-dns-txt-policy.ts +505 -0
  110. package/ts/email/reconcile-mail-dns-singleton.ts +224 -0
  111. package/ts/opsserver/classes.opsserver.ts +42 -0
  112. package/ts/opsserver/handlers/certificate.handler.ts +24 -3
  113. package/ts/opsserver/handlers/config.handler.ts +9 -5
  114. package/ts/opsserver/handlers/dns-provider.handler.ts +18 -1
  115. package/ts/opsserver/handlers/dns-record.handler.ts +24 -3
  116. package/ts/opsserver/handlers/domain.handler.ts +36 -2
  117. package/ts/opsserver/handlers/email-domain.handler.ts +4 -5
  118. package/ts/opsserver/handlers/email-ops.handler.ts +29 -6
  119. package/ts/opsserver/handlers/index.ts +1 -0
  120. package/ts/opsserver/handlers/logs.handler.ts +2 -2
  121. package/ts/opsserver/handlers/realtime.handler.ts +222 -0
  122. package/ts/opsserver/handlers/remoteingress.handler.ts +21 -0
  123. package/ts/opsserver/handlers/route-management.handler.ts +46 -4
  124. package/ts/paths.ts +0 -25
  125. package/ts/plugins.ts +0 -68
  126. package/ts_web/00_commitinfo_data.ts +1 -1
  127. package/ts_web/appstate/certificates.ts +6 -1
  128. package/ts_web/appstate/domains.ts +11 -4
  129. package/ts_web/appstate/email-domains.ts +68 -14
  130. package/ts_web/appstate/email-ops.ts +20 -1
  131. package/ts_web/appstate/realtime.ts +109 -0
  132. package/ts_web/appstate/remoteingress.ts +6 -2
  133. package/ts_web/appstate/routes.ts +11 -4
  134. package/ts_web/appstate/runtime.ts +176 -7
  135. package/ts_web/appstate.ts +1 -0
  136. package/ts_web/elements/email/email-domain-creation-progress.ts +340 -0
  137. package/ts_web/elements/email/ops-view-email-domains.ts +273 -74
  138. package/ts_web/elements/email/ops-view-emails.ts +23 -16
  139. package/ts_web/elements/overview/ops-view-config.ts +4 -4
@@ -2,6 +2,7 @@ import * as plugins from '../plugins.js';
2
2
  import * as interfaces from '../../ts_interfaces/index.js';
3
3
  import { appState } from './shared.js';
4
4
  import { getActionContext } from './login.js';
5
+ import { captureRealtimeTicket, isRealtimeTicketCurrent } from './realtime.js';
5
6
 
6
7
 
7
8
  // ============================================================================
@@ -20,6 +21,7 @@ export type TEmailDomainOperationName =
20
21
  export interface IEmailDomainOperationState extends interfaces.requests.IEmailDomainActionResult {
21
22
  operation: TEmailDomainOperationName;
22
23
  completedAt: number;
24
+ domain?: interfaces.data.IEmailDomain;
23
25
  }
24
26
 
25
27
  export interface IEmailDomainsState {
@@ -46,7 +48,9 @@ export const emailDomainsStatePart = await appState.getStatePart<IEmailDomainsSt
46
48
 
47
49
  const createOperationState = (
48
50
  operationArg: TEmailDomainOperationName,
49
- responseArg: interfaces.requests.IEmailDomainActionResult,
51
+ responseArg: interfaces.requests.IEmailDomainActionResult & {
52
+ domain?: interfaces.data.IEmailDomain;
53
+ },
50
54
  ): IEmailDomainOperationState => ({
51
55
  operation: operationArg,
52
56
  completedAt: Date.now(),
@@ -120,6 +124,7 @@ export const fetchEmailDomainsAction = emailDomainsStatePart.createAction(
120
124
  const context = getActionContext();
121
125
  const currentState = statePartArg.getState()!;
122
126
  if (!context.identity) return currentState;
127
+ const ticket = captureRealtimeTicket('emailDomains');
123
128
 
124
129
  try {
125
130
  const request = new plugins.domtools.plugins.typedrequest.TypedRequest<
@@ -130,8 +135,9 @@ export const fetchEmailDomainsAction = emailDomainsStatePart.createAction(
130
135
  >('/typedrequest', 'getEmailServerSettings');
131
136
  const response = await request.fire({ identity: context.identity });
132
137
  const settingsResponse = await settingsRequest.fire({ identity: context.identity });
138
+ if (!isRealtimeTicketCurrent(ticket)) return statePartArg.getState()!;
133
139
  return {
134
- ...currentState,
140
+ ...statePartArg.getState()!,
135
141
  domains: response.domains,
136
142
  settings: settingsResponse.settings,
137
143
  isLoading: false,
@@ -139,8 +145,9 @@ export const fetchEmailDomainsAction = emailDomainsStatePart.createAction(
139
145
  lastUpdated: Date.now(),
140
146
  };
141
147
  } catch (error: unknown) {
148
+ if (!isRealtimeTicketCurrent(ticket)) return statePartArg.getState()!;
142
149
  return {
143
- ...currentState,
150
+ ...statePartArg.getState()!,
144
151
  isLoading: false,
145
152
  error: error instanceof Error ? error.message : 'Failed to fetch email domains',
146
153
  };
@@ -169,14 +176,16 @@ export const createEmailDomainAction = emailDomainsStatePart.createAction<{
169
176
  >('/typedrequest', 'createEmailDomain');
170
177
  const response = await request.fire({ identity: context.identity, ...args });
171
178
  const operation = createOperationState('create', response);
172
- if (!response.success) {
173
- return applyOperationState(currentState, operation, 'Failed to create email domain');
174
- }
175
179
  const refreshedState = await actionContext!.dispatch(fetchEmailDomainsAction, null);
176
- return applyOperationState(refreshedState, operation, 'Email domain created');
180
+ return applyOperationState(
181
+ refreshedState,
182
+ operation,
183
+ response.success ? 'Email domain created' : 'Failed to create email domain',
184
+ );
177
185
  } catch (error: unknown) {
178
186
  const operation = createFailedOperationState('create', error, 'Failed to create email domain');
179
- return applyOperationState(currentState, operation, 'Failed to create email domain');
187
+ const refreshedState = await actionContext!.dispatch(fetchEmailDomainsAction, null);
188
+ return applyOperationState(refreshedState, operation, 'Failed to create email domain');
180
189
  }
181
190
  });
182
191
 
@@ -260,27 +269,50 @@ export const deleteEmailDomainAction = emailDomainsStatePart.createAction<string
260
269
  );
261
270
 
262
271
  export const validateEmailDomainAction = emailDomainsStatePart.createAction<string>(
263
- async (statePartArg, id, actionContext) => {
272
+ async (statePartArg, id) => {
264
273
  const context = getActionContext();
265
274
  const currentState = statePartArg.getState()!;
266
275
  if (!context.identity) {
267
276
  const operation = createFailedOperationState('validate', new Error('Not authenticated'), 'Not authenticated');
268
277
  return applyOperationState(currentState, operation, 'Failed to validate email domain DNS');
269
278
  }
279
+
280
+ const ticket = captureRealtimeTicket('emailDomains');
270
281
  try {
271
282
  const request = new plugins.domtools.plugins.typedrequest.TypedRequest<
272
283
  interfaces.requests.IReq_ValidateEmailDomain
273
284
  >('/typedrequest', 'validateEmailDomain');
274
285
  const response = await request.fire({ identity: context.identity, id });
275
286
  const operation = createOperationState('validate', response);
276
- if (!response.success) {
277
- return applyOperationState(currentState, operation, 'Failed to validate email domain DNS');
287
+ let latestState = statePartArg.getState()!;
288
+
289
+ if (response.domain && isRealtimeTicketCurrent(ticket)) {
290
+ const existingIndex = latestState.domains.findIndex((domain) => domain.id === response.domain!.id);
291
+ const domains = [...latestState.domains];
292
+ if (existingIndex >= 0) {
293
+ domains[existingIndex] = response.domain;
294
+ } else {
295
+ domains.push(response.domain);
296
+ }
297
+ latestState = {
298
+ ...latestState,
299
+ domains,
300
+ lastUpdated: Date.now(),
301
+ };
278
302
  }
279
- const refreshedState = await actionContext!.dispatch(fetchEmailDomainsAction, null);
280
- return applyOperationState(refreshedState, operation, 'Email domain DNS validated');
303
+
304
+ return applyOperationState(
305
+ latestState,
306
+ operation,
307
+ response.success ? 'Email domain DNS validated' : 'Failed to validate email domain DNS',
308
+ );
281
309
  } catch (error: unknown) {
282
310
  const operation = createFailedOperationState('validate', error, 'Failed to validate email domain DNS');
283
- return applyOperationState(currentState, operation, 'Failed to validate email domain DNS');
311
+ return applyOperationState(
312
+ statePartArg.getState()!,
313
+ operation,
314
+ 'Failed to validate email domain DNS',
315
+ );
284
316
  }
285
317
  },
286
318
  );
@@ -315,6 +347,28 @@ export const provisionEmailDomainDnsAction = emailDomainsStatePart.createAction<
315
347
  // Email Domain Standalone Functions
316
348
  // ============================================================================
317
349
 
350
+ export async function fetchEmailDomainsSnapshot(): Promise<interfaces.data.IEmailDomain[]> {
351
+ const context = getActionContext();
352
+ if (!context.identity) throw new Error('Not authenticated');
353
+ const request = new plugins.domtools.plugins.typedrequest.TypedRequest<
354
+ interfaces.requests.IReq_GetEmailDomains
355
+ >('/typedrequest', 'getEmailDomains');
356
+ const response = await request.fire({ identity: context.identity });
357
+ return response.domains;
358
+ }
359
+
360
+ export async function fetchEmailDomainSnapshot(
361
+ idArg: string,
362
+ ): Promise<interfaces.data.IEmailDomain | null> {
363
+ const context = getActionContext();
364
+ if (!context.identity) throw new Error('Not authenticated');
365
+ const request = new plugins.domtools.plugins.typedrequest.TypedRequest<
366
+ interfaces.requests.IReq_GetEmailDomain
367
+ >('/typedrequest', 'getEmailDomain');
368
+ const response = await request.fire({ identity: context.identity, id: idArg });
369
+ return response.domain;
370
+ }
371
+
318
372
  export async function fetchEmailDomainDnsRecords(id: string) {
319
373
  const context = getActionContext();
320
374
  const request = new plugins.domtools.plugins.typedrequest.TypedRequest<
@@ -2,6 +2,7 @@ import * as plugins from '../plugins.js';
2
2
  import * as interfaces from '../../ts_interfaces/index.js';
3
3
  import { appState } from './shared.js';
4
4
  import { getActionContext } from './login.js';
5
+ import { captureRealtimeTicket, isRealtimeTicketCurrent } from './realtime.js';
5
6
 
6
7
  export interface IEmailOpsState {
7
8
  emails: interfaces.requests.IEmail[];
@@ -29,6 +30,7 @@ export const fetchAllEmailsAction = emailOpsStatePart.createAction(async (stateP
29
30
  const context = getActionContext();
30
31
  const currentState = statePartArg.getState()!;
31
32
  if (!context.identity) return currentState;
33
+ const ticket = captureRealtimeTicket(['emailQueue', 'smtpAttempts']);
32
34
 
33
35
  try {
34
36
  const request = new plugins.domtools.plugins.typedrequest.TypedRequest<
@@ -38,16 +40,19 @@ export const fetchAllEmailsAction = emailOpsStatePart.createAction(async (stateP
38
40
  const response = await request.fire({
39
41
  identity: context.identity,
40
42
  });
43
+ if (!isRealtimeTicketCurrent(ticket)) return statePartArg.getState()!;
41
44
 
42
45
  return {
46
+ ...statePartArg.getState()!,
43
47
  emails: response.emails,
44
48
  isLoading: false,
45
49
  error: null,
46
50
  lastUpdated: Date.now(),
47
51
  };
48
52
  } catch (error) {
53
+ if (!isRealtimeTicketCurrent(ticket)) return statePartArg.getState()!;
49
54
  return {
50
- ...currentState,
55
+ ...statePartArg.getState()!,
51
56
  isLoading: false,
52
57
  error: error instanceof Error ? error.message : 'Failed to fetch emails',
53
58
  };
@@ -55,3 +60,17 @@ export const fetchAllEmailsAction = emailOpsStatePart.createAction(async (stateP
55
60
  });
56
61
 
57
62
  // ============================================================================
63
+ export async function fetchEmailDetailSnapshot(
64
+ emailIdArg: string,
65
+ ): Promise<interfaces.requests.IEmailDetail | null> {
66
+ const context = getActionContext();
67
+ if (!context.identity) return null;
68
+ const request = new plugins.domtools.plugins.typedrequest.TypedRequest<
69
+ interfaces.requests.IReq_GetEmailDetail
70
+ >('/typedrequest', 'getEmailDetail');
71
+ const response = await request.fire({
72
+ identity: context.identity,
73
+ emailId: emailIdArg,
74
+ });
75
+ return response.email;
76
+ }
@@ -0,0 +1,109 @@
1
+ import * as interfaces from '../../ts_interfaces/index.js';
2
+
3
+ export interface IOpsRealtimeTicket {
4
+ serverEpoch: string | null;
5
+ revisions: Partial<interfaces.requests.IOpsRealtimeRevisionMap>;
6
+ }
7
+
8
+ export type TOpsRealtimeListener = (
9
+ invalidationsArg: interfaces.requests.IOpsRealtimeInvalidation[],
10
+ ) => void;
11
+
12
+ const emptyRevisions = (): interfaces.requests.IOpsRealtimeRevisionMap => {
13
+ return Object.fromEntries(
14
+ interfaces.requests.opsRealtimeResources.map((resource) => [resource, 0]),
15
+ ) as unknown as interfaces.requests.IOpsRealtimeRevisionMap;
16
+ };
17
+
18
+ /**
19
+ * Orders pushes and HTTP reads. A read may apply only while every captured
20
+ * resource revision is still current.
21
+ */
22
+ export class OpsRealtimeFreshness {
23
+ private serverEpoch: string | null = null;
24
+ private revisions = emptyRevisions();
25
+ private listeners = new Set<TOpsRealtimeListener>();
26
+
27
+ public getKnownState(): {
28
+ knownServerEpoch?: string;
29
+ knownRevisions: interfaces.requests.IOpsRealtimeRevisionMap;
30
+ } {
31
+ return {
32
+ knownServerEpoch: this.serverEpoch || undefined,
33
+ knownRevisions: { ...this.revisions },
34
+ };
35
+ }
36
+
37
+ public capture(
38
+ resourcesArg:
39
+ | interfaces.requests.TOpsRealtimeResource
40
+ | interfaces.requests.TOpsRealtimeResource[],
41
+ ): IOpsRealtimeTicket {
42
+ const resources = Array.isArray(resourcesArg) ? resourcesArg : [resourcesArg];
43
+ return {
44
+ serverEpoch: this.serverEpoch,
45
+ revisions: Object.fromEntries(
46
+ resources.map((resource) => [resource, this.revisions[resource]]),
47
+ ),
48
+ };
49
+ }
50
+
51
+ public isCurrent(ticketArg: IOpsRealtimeTicket): boolean {
52
+ if (ticketArg.serverEpoch !== this.serverEpoch) return false;
53
+ return Object.entries(ticketArg.revisions).every(([resource, revision]) => {
54
+ return this.revisions[resource as interfaces.requests.TOpsRealtimeResource] === revision;
55
+ });
56
+ }
57
+
58
+ public accept(
59
+ serverEpochArg: string,
60
+ invalidationsArg: interfaces.requests.IOpsRealtimeInvalidation[],
61
+ ): interfaces.requests.IOpsRealtimeInvalidation[] {
62
+ const epochChanged = this.serverEpoch !== serverEpochArg;
63
+ if (epochChanged) {
64
+ this.serverEpoch = serverEpochArg;
65
+ this.revisions = emptyRevisions();
66
+ }
67
+
68
+ const accepted: interfaces.requests.IOpsRealtimeInvalidation[] = [];
69
+ for (const invalidation of invalidationsArg) {
70
+ if (!interfaces.requests.opsRealtimeResources.includes(invalidation.resource)) {
71
+ continue;
72
+ }
73
+ if (invalidation.revision <= this.revisions[invalidation.resource]) {
74
+ continue;
75
+ }
76
+ this.revisions[invalidation.resource] = invalidation.revision;
77
+ accepted.push(invalidation);
78
+ }
79
+
80
+ if (accepted.length > 0) {
81
+ for (const listener of Array.from(this.listeners)) {
82
+ listener(accepted);
83
+ }
84
+ }
85
+ return accepted;
86
+ }
87
+
88
+ public subscribe(listenerArg: TOpsRealtimeListener): () => void {
89
+ this.listeners.add(listenerArg);
90
+ return () => this.listeners.delete(listenerArg);
91
+ }
92
+
93
+ public reset(): void {
94
+ this.serverEpoch = null;
95
+ this.revisions = emptyRevisions();
96
+ }
97
+ }
98
+
99
+ export const opsRealtimeFreshness = new OpsRealtimeFreshness();
100
+
101
+ export const captureRealtimeTicket = (
102
+ resourcesArg:
103
+ | interfaces.requests.TOpsRealtimeResource
104
+ | interfaces.requests.TOpsRealtimeResource[],
105
+ ): IOpsRealtimeTicket => opsRealtimeFreshness.capture(resourcesArg);
106
+
107
+ export const isRealtimeTicketCurrent = (ticketArg: IOpsRealtimeTicket): boolean => {
108
+ return opsRealtimeFreshness.isCurrent(ticketArg);
109
+ };
@@ -2,6 +2,7 @@ import * as plugins from '../plugins.js';
2
2
  import * as interfaces from '../../ts_interfaces/index.js';
3
3
  import { appState } from './shared.js';
4
4
  import { getActionContext } from './login.js';
5
+ import { captureRealtimeTicket, isRealtimeTicketCurrent } from './realtime.js';
5
6
 
6
7
  // ============================================================================
7
8
  // Remote Ingress State
@@ -52,6 +53,7 @@ export const fetchRemoteIngressAction = remoteIngressStatePart.createAction(asyn
52
53
  const context = getActionContext();
53
54
  const currentState = statePartArg.getState()!;
54
55
  if (!context.identity) return currentState;
56
+ const ticket = captureRealtimeTicket('edges');
55
57
 
56
58
  try {
57
59
  const edgesRequest = new plugins.domtools.plugins.typedrequest.TypedRequest<
@@ -71,9 +73,10 @@ export const fetchRemoteIngressAction = remoteIngressStatePart.createAction(asyn
71
73
  statusRequest.fire({ identity: context.identity }),
72
74
  hubSettingsRequest.fire({ identity: context.identity }),
73
75
  ]);
76
+ if (!isRealtimeTicketCurrent(ticket)) return statePartArg.getState()!;
74
77
 
75
78
  return {
76
- ...currentState,
79
+ ...statePartArg.getState()!,
77
80
  edges: edgesResponse.edges,
78
81
  statuses: statusResponse.statuses,
79
82
  hubSettings: hubSettingsResponse.settings,
@@ -82,8 +85,9 @@ export const fetchRemoteIngressAction = remoteIngressStatePart.createAction(asyn
82
85
  lastUpdated: Date.now(),
83
86
  };
84
87
  } catch (error) {
88
+ if (!isRealtimeTicketCurrent(ticket)) return statePartArg.getState()!;
85
89
  return {
86
- ...currentState,
90
+ ...statePartArg.getState()!,
87
91
  isLoading: false,
88
92
  error: error instanceof Error ? error.message : 'Failed to fetch remote ingress data',
89
93
  };
@@ -2,6 +2,7 @@ import * as plugins from '../plugins.js';
2
2
  import * as interfaces from '../../ts_interfaces/index.js';
3
3
  import { appState } from './shared.js';
4
4
  import { getActionContext } from './login.js';
5
+ import { captureRealtimeTicket, isRealtimeTicketCurrent } from './realtime.js';
5
6
 
6
7
  // ============================================================================
7
8
  // Route Management State
@@ -40,6 +41,7 @@ export const fetchMergedRoutesAction = routeManagementStatePart.createAction(asy
40
41
  const context = getActionContext();
41
42
  const currentState = statePartArg.getState()!;
42
43
  if (!context.identity) return currentState;
44
+ const ticket = captureRealtimeTicket('routes');
43
45
 
44
46
  try {
45
47
  const request = new plugins.domtools.plugins.typedrequest.TypedRequest<
@@ -49,9 +51,10 @@ export const fetchMergedRoutesAction = routeManagementStatePart.createAction(asy
49
51
  const response = await request.fire({
50
52
  identity: context.identity,
51
53
  });
54
+ if (!isRealtimeTicketCurrent(ticket)) return statePartArg.getState()!;
52
55
 
53
56
  return {
54
- ...currentState,
57
+ ...statePartArg.getState()!,
55
58
  mergedRoutes: response.routes,
56
59
  warnings: response.warnings,
57
60
  isLoading: false,
@@ -59,8 +62,9 @@ export const fetchMergedRoutesAction = routeManagementStatePart.createAction(asy
59
62
  lastUpdated: Date.now(),
60
63
  };
61
64
  } catch (error) {
65
+ if (!isRealtimeTicketCurrent(ticket)) return statePartArg.getState()!;
62
66
  return {
63
- ...currentState,
67
+ ...statePartArg.getState()!,
64
68
  isLoading: false,
65
69
  error: error instanceof Error ? error.message : 'Failed to fetch routes',
66
70
  };
@@ -71,6 +75,7 @@ export const fetchHttpRedirectsAction = routeManagementStatePart.createAction(as
71
75
  const context = getActionContext();
72
76
  const currentState = statePartArg.getState()!;
73
77
  if (!context.identity) return currentState;
78
+ const ticket = captureRealtimeTicket('routes');
74
79
 
75
80
  try {
76
81
  const request = new plugins.domtools.plugins.typedrequest.TypedRequest<
@@ -80,17 +85,19 @@ export const fetchHttpRedirectsAction = routeManagementStatePart.createAction(as
80
85
  const response = await request.fire({
81
86
  identity: context.identity,
82
87
  });
88
+ if (!isRealtimeTicketCurrent(ticket)) return statePartArg.getState()!;
83
89
 
84
90
  return {
85
- ...currentState,
91
+ ...statePartArg.getState()!,
86
92
  httpRedirects: response.redirects,
87
93
  isLoading: false,
88
94
  error: null,
89
95
  lastUpdated: Date.now(),
90
96
  };
91
97
  } catch (error) {
98
+ if (!isRealtimeTicketCurrent(ticket)) return statePartArg.getState()!;
92
99
  return {
93
- ...currentState,
100
+ ...statePartArg.getState()!,
94
101
  isLoading: false,
95
102
  error: error instanceof Error ? error.message : 'Failed to fetch HTTP redirects',
96
103
  };
@@ -10,14 +10,124 @@ import { securityPolicyStatePart, fetchSecurityPolicyAction } from './security.j
10
10
  import { certificateStatePart, fetchCertificateOverviewAction } from './certificates.js';
11
11
  import { remoteIngressStatePart, fetchRemoteIngressAction } from './remoteingress.js';
12
12
  import { vpnStatePart, fetchVpnAction } from './vpn.js';
13
-
13
+ import { emailDomainsStatePart, fetchEmailDomainsAction } from './email-domains.js';
14
+ import { emailOpsStatePart, fetchAllEmailsAction } from './email-ops.js';
15
+ import {
16
+ domainsStatePart,
17
+ fetchDnsRecordsForDomainAction,
18
+ fetchDomainsAndProvidersAction,
19
+ } from './domains.js';
20
+ import {
21
+ fetchHttpRedirectsAction,
22
+ fetchMergedRoutesAction,
23
+ routeManagementStatePart,
24
+ } from './routes.js';
25
+ import { opsRealtimeFreshness } from './realtime.js';
14
26
 
15
27
  // ============================================================================
16
28
  // TypedSocket Client for Real-time Log Streaming
17
29
  // ============================================================================
18
30
 
19
31
  let socketClient: plugins.typedsocket.TypedSocket | null = null;
32
+ let socketStatusSubscription: { unsubscribe: () => void } | null = null;
33
+ let socketConnectInFlight = false;
34
+ let socketGeneration = 0;
20
35
  const socketRouter = new plugins.domtools.plugins.typedrequest.TypedRouter();
36
+ type TRealtimeRefreshGroup =
37
+ | 'emailDomains'
38
+ | 'emailOps'
39
+ | 'dns'
40
+ | 'routes'
41
+ | 'edges'
42
+ | 'certificates';
43
+
44
+ const pendingRealtimeRefreshes = new Set<TRealtimeRefreshGroup>();
45
+ let realtimeRefreshLoopRunning = false;
46
+
47
+ const resourceRefreshGroups: Record<
48
+ interfaces.requests.TOpsRealtimeResource,
49
+ TRealtimeRefreshGroup
50
+ > = {
51
+ emailDomains: 'emailDomains',
52
+ emailQueue: 'emailOps',
53
+ smtpAttempts: 'emailOps',
54
+ dns: 'dns',
55
+ routes: 'routes',
56
+ edges: 'edges',
57
+ certificates: 'certificates',
58
+ };
59
+
60
+ const refreshRealtimeGroup = async (groupArg: TRealtimeRefreshGroup): Promise<void> => {
61
+ switch (groupArg) {
62
+ case 'emailDomains':
63
+ await emailDomainsStatePart.dispatchAction(fetchEmailDomainsAction, null);
64
+ break;
65
+ case 'emailOps':
66
+ await emailOpsStatePart.dispatchAction(fetchAllEmailsAction, null);
67
+ break;
68
+ case 'dns': {
69
+ const selectedDomainId = domainsStatePart.getState()!.selectedDomainId;
70
+ await domainsStatePart.dispatchAction(fetchDomainsAndProvidersAction, null);
71
+ if (selectedDomainId) {
72
+ await domainsStatePart.dispatchAction(
73
+ fetchDnsRecordsForDomainAction,
74
+ { domainId: selectedDomainId },
75
+ );
76
+ }
77
+ break;
78
+ }
79
+ case 'routes':
80
+ await Promise.all([
81
+ routeManagementStatePart.dispatchAction(fetchMergedRoutesAction, null),
82
+ routeManagementStatePart.dispatchAction(fetchHttpRedirectsAction, null),
83
+ ]);
84
+ break;
85
+ case 'edges':
86
+ await remoteIngressStatePart.dispatchAction(fetchRemoteIngressAction, null);
87
+ break;
88
+ case 'certificates':
89
+ await certificateStatePart.dispatchAction(fetchCertificateOverviewAction, null);
90
+ break;
91
+ }
92
+ };
93
+
94
+ const runRealtimeRefreshLoop = async (): Promise<void> => {
95
+ if (realtimeRefreshLoopRunning) return;
96
+ realtimeRefreshLoopRunning = true;
97
+ try {
98
+ while (pendingRealtimeRefreshes.size > 0) {
99
+ const groups = Array.from(pendingRealtimeRefreshes);
100
+ pendingRealtimeRefreshes.clear();
101
+ await Promise.all(
102
+ groups.map(async (group) => {
103
+ try {
104
+ await refreshRealtimeGroup(group);
105
+ } catch (error) {
106
+ console.error('Realtime resource refresh failed:', group, error);
107
+ }
108
+ }),
109
+ );
110
+ }
111
+ } finally {
112
+ realtimeRefreshLoopRunning = false;
113
+ if (pendingRealtimeRefreshes.size > 0) {
114
+ queueMicrotask(() => void runRealtimeRefreshLoop());
115
+ }
116
+ }
117
+ };
118
+
119
+ const acceptRealtimeInvalidations = (
120
+ serverEpochArg: string,
121
+ invalidationsArg: interfaces.requests.IOpsRealtimeInvalidation[],
122
+ ): void => {
123
+ const accepted = opsRealtimeFreshness.accept(serverEpochArg, invalidationsArg);
124
+ for (const invalidation of accepted) {
125
+ pendingRealtimeRefreshes.add(resourceRefreshGroups[invalidation.resource]);
126
+ }
127
+ if (accepted.length > 0) {
128
+ queueMicrotask(() => void runRealtimeRefreshLoop());
129
+ }
130
+ };
21
131
 
22
132
  // Batched log entry handler — buffers incoming entries and flushes once per animation frame
23
133
  let logEntryBuffer: interfaces.data.ILogEntry[] = [];
@@ -51,28 +161,85 @@ socketRouter.addTypedHandler(
51
161
  )
52
162
  );
53
163
 
164
+ socketRouter.addTypedHandler(
165
+ new plugins.domtools.plugins.typedrequest.TypedHandler<
166
+ interfaces.requests.IReq_PushOpsRealtimeInvalidation
167
+ >(
168
+ 'pushOpsRealtimeInvalidation',
169
+ async (dataArg) => {
170
+ acceptRealtimeInvalidations(dataArg.serverEpoch, dataArg.invalidations);
171
+ return {};
172
+ },
173
+ ),
174
+ );
175
+
176
+ const subscribeSocket = async (
177
+ clientArg: plugins.typedsocket.TypedSocket,
178
+ generationArg: number,
179
+ ): Promise<void> => {
180
+ const identity = getActionContext().identity;
181
+ if (!identity || generationArg !== socketGeneration || clientArg !== socketClient) return;
182
+
183
+ const request = clientArg.createTypedRequest<interfaces.requests.IReq_SubscribeOpsRealtime>(
184
+ 'subscribeOpsRealtime',
185
+ );
186
+ const response = await request.fire({
187
+ identity,
188
+ ...opsRealtimeFreshness.getKnownState(),
189
+ });
190
+ if (generationArg !== socketGeneration || clientArg !== socketClient) return;
191
+ acceptRealtimeInvalidations(response.serverEpoch, response.invalidations);
192
+ };
193
+
54
194
  async function connectSocket() {
55
- if (socketClient) return;
195
+ if (socketClient || socketConnectInFlight || document.hidden) return;
196
+ socketConnectInFlight = true;
197
+ const generation = ++socketGeneration;
56
198
  try {
57
- socketClient = await plugins.typedsocket.TypedSocket.createClient(
199
+ const client = await plugins.typedsocket.TypedSocket.createClient(
58
200
  socketRouter,
59
201
  plugins.typedsocket.TypedSocket.useWindowLocationOriginUrl(),
202
+ { autoReconnect: true },
60
203
  );
61
- await socketClient.setTag('role', 'ops_dashboard');
204
+ if (generation !== socketGeneration || !loginStatePart.getState()!.isLoggedIn) {
205
+ await client.stop();
206
+ return;
207
+ }
208
+
209
+ socketClient = client;
210
+ socketStatusSubscription = client.statusSubject.subscribe((status) => {
211
+ if (status === 'connected') {
212
+ void subscribeSocket(client, generation).catch((error) => {
213
+ console.error('Realtime subscription failed after reconnect:', error);
214
+ });
215
+ }
216
+ });
217
+ await subscribeSocket(client, generation);
62
218
  } catch (err) {
63
219
  console.error('TypedSocket connection failed:', err);
220
+ socketStatusSubscription?.unsubscribe();
221
+ socketStatusSubscription = null;
222
+ if (socketClient) {
223
+ await socketClient.stop().catch(() => undefined);
224
+ }
64
225
  socketClient = null;
226
+ } finally {
227
+ socketConnectInFlight = false;
65
228
  }
66
229
  }
67
230
 
68
231
  async function disconnectSocket() {
69
- if (socketClient) {
232
+ socketGeneration++;
233
+ socketStatusSubscription?.unsubscribe();
234
+ socketStatusSubscription = null;
235
+ const client = socketClient;
236
+ socketClient = null;
237
+ if (client) {
70
238
  try {
71
- await socketClient.stop();
239
+ await client.stop();
72
240
  } catch {
73
241
  // ignore disconnect errors
74
242
  }
75
- socketClient = null;
76
243
  }
77
244
  }
78
245
 
@@ -285,6 +452,8 @@ loginStatePart.select((s) => s.isLoggedIn).subscribe((isLoggedIn) => {
285
452
  if (isLoggedIn) {
286
453
  connectSocket();
287
454
  } else {
455
+ pendingRealtimeRefreshes.clear();
456
+ opsRealtimeFreshness.reset();
288
457
  disconnectSocket();
289
458
  }
290
459
  }
@@ -19,6 +19,7 @@ export * from './appstate/domains.js';
19
19
  export * from './appstate/routes.js';
20
20
  export * from './appstate/users.js';
21
21
  export * from './appstate/email-domains.js';
22
+ export * from './appstate/realtime.js';
22
23
 
23
24
  // Side-effect module: auto-refresh scheduling and TypedSocket log streaming.
24
25
  import './appstate/runtime.js';