@serve.zone/dcrouter 16.5.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 (153) 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 +6 -1
  6. package/dist_ts/classes.dcrouter.js +119 -41
  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/db/documents/classes.smartmta-storage.doc.d.ts +8 -0
  14. package/dist_ts/db/documents/classes.smartmta-storage.doc.js +91 -0
  15. package/dist_ts/db/documents/index.d.ts +1 -0
  16. package/dist_ts/db/documents/index.js +2 -1
  17. package/dist_ts/dns/classes.dns-server-runtime.d.ts +0 -1
  18. package/dist_ts/dns/classes.dns-server-runtime.js +4 -36
  19. package/dist_ts/dns/index.d.ts +1 -0
  20. package/dist_ts/dns/index.js +2 -1
  21. package/dist_ts/dns/manager.dns.d.ts +1 -0
  22. package/dist_ts/dns/manager.dns.js +49 -14
  23. package/dist_ts/dns/providers/cloudflare.provider.js +19 -4
  24. package/dist_ts/dns/txt-record-presentation.d.ts +20 -0
  25. package/dist_ts/dns/txt-record-presentation.js +131 -0
  26. package/dist_ts/email/classes.accepted-email-spool.d.ts +32 -3
  27. package/dist_ts/email/classes.accepted-email-spool.js +174 -13
  28. package/dist_ts/email/classes.email-domain.manager.d.ts +14 -1
  29. package/dist_ts/email/classes.email-domain.manager.js +151 -8
  30. package/dist_ts/email/classes.email-settings.manager.js +2 -1
  31. package/dist_ts/email/classes.mail-dns-sync.d.ts +31 -5
  32. package/dist_ts/email/classes.mail-dns-sync.js +508 -117
  33. package/dist_ts/email/classes.smartmta-blob-storage-manager.d.ts +27 -0
  34. package/dist_ts/email/classes.smartmta-blob-storage-manager.js +110 -0
  35. package/dist_ts/email/classes.smartmta-storage-manager.d.ts +6 -8
  36. package/dist_ts/email/classes.smartmta-storage-manager.js +63 -86
  37. package/dist_ts/email/classes.workapp-mail-manager.d.ts +1 -0
  38. package/dist_ts/email/classes.workapp-mail-manager.js +31 -4
  39. package/dist_ts/email/index.d.ts +4 -0
  40. package/dist_ts/email/index.js +5 -1
  41. package/dist_ts/email/mail-dns-txt-policy.d.ts +18 -0
  42. package/dist_ts/email/mail-dns-txt-policy.js +466 -0
  43. package/dist_ts/email/reconcile-mail-dns-singleton.d.ts +47 -0
  44. package/dist_ts/email/reconcile-mail-dns-singleton.js +159 -0
  45. package/dist_ts/email/smartmta-storage-policy.d.ts +9 -0
  46. package/dist_ts/email/smartmta-storage-policy.js +58 -0
  47. package/dist_ts/opsserver/classes.opsserver.d.ts +8 -0
  48. package/dist_ts/opsserver/classes.opsserver.js +34 -1
  49. package/dist_ts/opsserver/handlers/certificate.handler.js +25 -4
  50. package/dist_ts/opsserver/handlers/config.handler.js +9 -5
  51. package/dist_ts/opsserver/handlers/dns-provider.handler.js +19 -2
  52. package/dist_ts/opsserver/handlers/dns-record.handler.js +25 -4
  53. package/dist_ts/opsserver/handlers/domain.handler.js +37 -3
  54. package/dist_ts/opsserver/handlers/email-domain.handler.js +5 -7
  55. package/dist_ts/opsserver/handlers/email-ops.handler.d.ts +1 -0
  56. package/dist_ts/opsserver/handlers/email-ops.handler.js +26 -7
  57. package/dist_ts/opsserver/handlers/index.d.ts +1 -0
  58. package/dist_ts/opsserver/handlers/index.js +2 -1
  59. package/dist_ts/opsserver/handlers/logs.handler.d.ts +1 -1
  60. package/dist_ts/opsserver/handlers/logs.handler.js +3 -3
  61. package/dist_ts/opsserver/handlers/realtime.handler.d.ts +37 -0
  62. package/dist_ts/opsserver/handlers/realtime.handler.js +154 -0
  63. package/dist_ts/opsserver/handlers/remoteingress.handler.js +22 -1
  64. package/dist_ts/opsserver/handlers/route-management.handler.js +47 -5
  65. package/dist_ts/opsserver/handlers/stats.handler.js +3 -5
  66. package/dist_ts/paths.d.ts +0 -11
  67. package/dist_ts/paths.js +1 -22
  68. package/dist_ts/plugins.d.ts +2 -35
  69. package/dist_ts/plugins.js +3 -63
  70. package/dist_ts_interfaces/data/email-domain.d.ts +14 -0
  71. package/dist_ts_interfaces/requests/config.d.ts +1 -1
  72. package/dist_ts_interfaces/requests/email-ops.d.ts +34 -1
  73. package/dist_ts_interfaces/requests/index.d.ts +1 -0
  74. package/dist_ts_interfaces/requests/index.js +2 -1
  75. package/dist_ts_interfaces/requests/realtime.d.ts +38 -0
  76. package/dist_ts_interfaces/requests/realtime.js +11 -0
  77. package/dist_ts_migrations/index.d.ts +8 -0
  78. package/dist_ts_migrations/index.js +489 -12
  79. package/dist_ts_web/00_commitinfo_data.js +1 -1
  80. package/dist_ts_web/appstate/certificates.js +9 -2
  81. package/dist_ts_web/appstate/domains.js +16 -5
  82. package/dist_ts_web/appstate/email-domains.d.ts +3 -0
  83. package/dist_ts_web/appstate/email-domains.js +47 -14
  84. package/dist_ts_web/appstate/email-ops.d.ts +1 -0
  85. package/dist_ts_web/appstate/email-ops.js +20 -2
  86. package/dist_ts_web/appstate/realtime.d.ts +27 -0
  87. package/dist_ts_web/appstate/realtime.js +71 -0
  88. package/dist_ts_web/appstate/remoteingress.js +9 -3
  89. package/dist_ts_web/appstate/routes.js +16 -5
  90. package/dist_ts_web/appstate/runtime.js +135 -7
  91. package/dist_ts_web/appstate.d.ts +1 -0
  92. package/dist_ts_web/appstate.js +2 -1
  93. package/dist_ts_web/elements/email/email-domain-creation-progress.d.ts +33 -0
  94. package/dist_ts_web/elements/email/email-domain-creation-progress.js +257 -0
  95. package/dist_ts_web/elements/email/ops-view-email-domains.js +233 -68
  96. package/dist_ts_web/elements/email/ops-view-emails.d.ts +4 -2
  97. package/dist_ts_web/elements/email/ops-view-emails.js +25 -13
  98. package/dist_ts_web/elements/overview/ops-view-config.js +5 -5
  99. package/package.json +23 -19
  100. package/ts/00_commitinfo_data.ts +1 -1
  101. package/ts/classes.dcrouter.ts +136 -44
  102. package/ts/db/classes.cache.cleaner.ts +18 -3
  103. package/ts/db/classes.dcrouter-db.ts +0 -2
  104. package/ts/db/documents/classes.cached.email.ts +78 -4
  105. package/ts/db/documents/classes.dns-record.doc.ts +1 -0
  106. package/ts/db/documents/classes.smartmta-storage.doc.ts +25 -0
  107. package/ts/db/documents/index.ts +1 -0
  108. package/ts/dns/classes.dns-server-runtime.ts +9 -45
  109. package/ts/dns/index.ts +1 -0
  110. package/ts/dns/manager.dns.ts +50 -16
  111. package/ts/dns/providers/cloudflare.provider.ts +21 -3
  112. package/ts/dns/txt-record-presentation.ts +134 -0
  113. package/ts/email/classes.accepted-email-spool.ts +220 -14
  114. package/ts/email/classes.email-domain.manager.ts +201 -7
  115. package/ts/email/classes.email-settings.manager.ts +1 -0
  116. package/ts/email/classes.mail-dns-sync.ts +588 -122
  117. package/ts/email/classes.smartmta-blob-storage-manager.ts +139 -0
  118. package/ts/email/classes.smartmta-storage-manager.ts +86 -83
  119. package/ts/email/classes.workapp-mail-manager.ts +47 -4
  120. package/ts/email/index.ts +4 -0
  121. package/ts/email/mail-dns-txt-policy.ts +505 -0
  122. package/ts/email/reconcile-mail-dns-singleton.ts +224 -0
  123. package/ts/email/smartmta-storage-policy.ts +73 -0
  124. package/ts/opsserver/classes.opsserver.ts +42 -0
  125. package/ts/opsserver/handlers/certificate.handler.ts +24 -3
  126. package/ts/opsserver/handlers/config.handler.ts +9 -5
  127. package/ts/opsserver/handlers/dns-provider.handler.ts +18 -1
  128. package/ts/opsserver/handlers/dns-record.handler.ts +24 -3
  129. package/ts/opsserver/handlers/domain.handler.ts +36 -2
  130. package/ts/opsserver/handlers/email-domain.handler.ts +4 -5
  131. package/ts/opsserver/handlers/email-ops.handler.ts +29 -6
  132. package/ts/opsserver/handlers/index.ts +1 -0
  133. package/ts/opsserver/handlers/logs.handler.ts +2 -2
  134. package/ts/opsserver/handlers/realtime.handler.ts +222 -0
  135. package/ts/opsserver/handlers/remoteingress.handler.ts +21 -0
  136. package/ts/opsserver/handlers/route-management.handler.ts +46 -4
  137. package/ts/opsserver/handlers/stats.handler.ts +2 -4
  138. package/ts/paths.ts +0 -25
  139. package/ts/plugins.ts +2 -69
  140. package/ts_web/00_commitinfo_data.ts +1 -1
  141. package/ts_web/appstate/certificates.ts +6 -1
  142. package/ts_web/appstate/domains.ts +11 -4
  143. package/ts_web/appstate/email-domains.ts +68 -14
  144. package/ts_web/appstate/email-ops.ts +20 -1
  145. package/ts_web/appstate/realtime.ts +109 -0
  146. package/ts_web/appstate/remoteingress.ts +6 -2
  147. package/ts_web/appstate/routes.ts +11 -4
  148. package/ts_web/appstate/runtime.ts +176 -7
  149. package/ts_web/appstate.ts +1 -0
  150. package/ts_web/elements/email/email-domain-creation-progress.ts +340 -0
  151. package/ts_web/elements/email/ops-view-email-domains.ts +273 -74
  152. package/ts_web/elements/email/ops-view-emails.ts +23 -16
  153. package/ts_web/elements/overview/ops-view-config.ts +4 -4
@@ -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';
@@ -0,0 +1,340 @@
1
+ import type * as interfaces from '../../../dist_ts_interfaces/index.js';
2
+
3
+ export interface IEmailDomainCreationProgress {
4
+ percentage: number;
5
+ indeterminate: boolean;
6
+ showPercentage: boolean;
7
+ statusText: string;
8
+ terminalLines: string[];
9
+ }
10
+
11
+ export interface IEmailDomainCreationProgressInput {
12
+ domain?: interfaces.data.IEmailDomain;
13
+ operation?: interfaces.requests.IEmailDomainActionResult | null;
14
+ requestCompleted: boolean;
15
+ }
16
+
17
+ const settledIntentStatuses = new Set<interfaces.data.TEmailDnsIntentStatus>([
18
+ 'satisfied',
19
+ 'valid',
20
+ 'conflict',
21
+ 'failed',
22
+ ]);
23
+
24
+ const formatOperationError = (
25
+ errorArg: interfaces.data.IEmailDomainOperationError | interfaces.requests.IEmailDomainActionError,
26
+ ): string => {
27
+ const location = errorArg.recordKey ? `${errorArg.recordKey}: ` : '';
28
+ return `${location}${errorArg.message} [${errorArg.code}]`;
29
+ };
30
+
31
+ const appendDnsStatusLines = (
32
+ linesArg: string[],
33
+ dnsStatusArg: interfaces.data.IEmailDomainDnsStatus,
34
+ ): void => {
35
+ for (const [label, status] of [
36
+ ['MX', dnsStatusArg.mx],
37
+ ['SPF', dnsStatusArg.spf],
38
+ ['DKIM', dnsStatusArg.dkim],
39
+ ['DMARC', dnsStatusArg.dmarc],
40
+ ] as const) {
41
+ if (status !== 'unchecked') {
42
+ linesArg.push(`${label}: ${status}`);
43
+ }
44
+ }
45
+ };
46
+
47
+ export const getEmailDomainCreationProgress = (
48
+ inputArg: IEmailDomainCreationProgressInput,
49
+ ): IEmailDomainCreationProgress => {
50
+ const { domain, operation, requestCompleted } = inputArg;
51
+
52
+ if (!domain) {
53
+ if (!requestCompleted) {
54
+ return {
55
+ percentage: 0,
56
+ indeterminate: true,
57
+ showPercentage: false,
58
+ statusText: 'Generating DKIM material and creating the domain record...',
59
+ terminalLines: ['Submitting email domain configuration'],
60
+ };
61
+ }
62
+
63
+ const lines = ['Email domain creation request completed without a persisted domain'];
64
+ for (const error of operation?.errors || []) {
65
+ lines.push(formatOperationError(error));
66
+ }
67
+ if (operation?.message && !lines.includes(operation.message)) {
68
+ lines.push(operation.message);
69
+ }
70
+ return {
71
+ percentage: 100,
72
+ indeterminate: false,
73
+ showPercentage: false,
74
+ statusText: operation?.message || 'Email domain creation failed.',
75
+ terminalLines: lines,
76
+ };
77
+ }
78
+
79
+ const reconciliation = domain.reconciliation;
80
+ const lines = [`Domain record persisted: ${domain.domain}`];
81
+ if (!reconciliation || reconciliation.intents.length === 0) {
82
+ if (!requestCompleted) {
83
+ lines.push('Preparing the managed DNS plan');
84
+ return {
85
+ percentage: 0,
86
+ indeterminate: true,
87
+ showPercentage: false,
88
+ statusText: 'Preparing the managed DNS plan...',
89
+ terminalLines: lines,
90
+ };
91
+ }
92
+
93
+ lines.push('No DNS reconciliation plan is available');
94
+ const errors = reconciliation?.errors.length
95
+ ? reconciliation.errors
96
+ : operation?.errors || [];
97
+ for (const error of errors) {
98
+ lines.push(formatOperationError(error));
99
+ }
100
+ const retryAt = reconciliation?.retryAt || operation?.retryAt;
101
+ if (retryAt) {
102
+ lines.push(`Retry scheduled for ${retryAt}`);
103
+ }
104
+ const statusText =
105
+ reconciliation?.lifecycleStatus === 'failed'
106
+ ? operation?.message || `DNS setup failed for ${domain.domain}.`
107
+ : retryAt
108
+ ? 'DNS reconciliation is pending and will retry automatically.'
109
+ : operation?.message || 'The domain was created without a DNS reconciliation plan.';
110
+ return {
111
+ percentage: 100,
112
+ indeterminate: false,
113
+ showPercentage: false,
114
+ statusText,
115
+ terminalLines: lines,
116
+ };
117
+ }
118
+
119
+ const settledCount = reconciliation.intents.filter((intent) =>
120
+ settledIntentStatuses.has(intent.status)
121
+ ).length;
122
+ lines.push(`DNS plan prepared: ${reconciliation.intents.length} managed records`);
123
+ lines.push(
124
+ `DNS reconciliation: ${settledCount} settled, ${reconciliation.intents.length - settledCount} pending`,
125
+ );
126
+ appendDnsStatusLines(lines, domain.dnsStatus);
127
+
128
+ if (reconciliation.lifecycleStatus === 'active') {
129
+ lines.push('Email domain is active');
130
+ return {
131
+ percentage: 100,
132
+ indeterminate: false,
133
+ showPercentage: true,
134
+ statusText: `${domain.domain} is active.`,
135
+ terminalLines: lines,
136
+ };
137
+ }
138
+
139
+ if (!requestCompleted) {
140
+ return {
141
+ percentage: 0,
142
+ indeterminate: true,
143
+ showPercentage: false,
144
+ statusText: 'Reconciling and validating managed DNS records...',
145
+ terminalLines: lines,
146
+ };
147
+ }
148
+
149
+ const errors = reconciliation.errors.length > 0
150
+ ? reconciliation.errors
151
+ : operation?.errors || [];
152
+ for (const error of errors) {
153
+ lines.push(formatOperationError(error));
154
+ }
155
+ const retryAt = reconciliation.retryAt || operation?.retryAt;
156
+ if (retryAt) {
157
+ lines.push(`Retry scheduled for ${retryAt}`);
158
+ }
159
+
160
+ if (reconciliation.lifecycleStatus === 'failed') {
161
+ return {
162
+ percentage: 100,
163
+ indeterminate: false,
164
+ showPercentage: false,
165
+ statusText: operation?.message || `DNS setup failed for ${domain.domain}.`,
166
+ terminalLines: lines,
167
+ };
168
+ }
169
+
170
+ return {
171
+ percentage: 100,
172
+ indeterminate: false,
173
+ showPercentage: false,
174
+ statusText: retryAt
175
+ ? `DNS reconciliation is pending and will retry automatically.`
176
+ : `DNS reconciliation is pending for ${domain.domain}.`,
177
+ terminalLines: lines,
178
+ };
179
+ };
180
+
181
+ export const findNewEmailDomain = (
182
+ domainsArg: interfaces.data.IEmailDomain[],
183
+ expectedDomainArg: string,
184
+ existingIdsArg: ReadonlySet<string>,
185
+ ): interfaces.data.IEmailDomain | undefined => {
186
+ const expectedDomain = expectedDomainArg.trim().toLowerCase();
187
+ const candidates = domainsArg.filter(
188
+ (domain) => domain.domain.toLowerCase() === expectedDomain && !existingIdsArg.has(domain.id),
189
+ );
190
+ if (candidates.length > 1) {
191
+ throw new Error(`Multiple new email domains appeared for ${expectedDomain}`);
192
+ }
193
+ return candidates[0];
194
+ };
195
+
196
+ const waitForPollInterval = async (delayMsArg: number, signalArg: AbortSignal): Promise<void> => {
197
+ if (signalArg.aborted) return;
198
+ await new Promise<void>((resolve) => {
199
+ const finish = () => {
200
+ signalArg.removeEventListener('abort', handleAbort);
201
+ resolve();
202
+ };
203
+ const handleAbort = () => {
204
+ clearTimeout(timeoutId);
205
+ finish();
206
+ };
207
+ const timeoutId = setTimeout(finish, delayMsArg);
208
+ signalArg.addEventListener('abort', handleAbort, { once: true });
209
+ });
210
+ };
211
+
212
+ export interface IObserveNewEmailDomainOptions {
213
+ expectedDomain: string;
214
+ existingIds: ReadonlySet<string>;
215
+ signal: AbortSignal;
216
+ listDomains: () => Promise<interfaces.data.IEmailDomain[]>;
217
+ getDomain: (idArg: string) => Promise<interfaces.data.IEmailDomain | null>;
218
+ onDomain: (domainArg: interfaces.data.IEmailDomain) => void;
219
+ subscribeToDomains?: (
220
+ listenerArg: (domainsArg: interfaces.data.IEmailDomain[]) => void,
221
+ ) => () => void;
222
+ pollIntervalMs?: number;
223
+ timeoutMs?: number;
224
+ }
225
+
226
+ export const observeNewEmailDomain = async (
227
+ optionsArg: IObserveNewEmailDomainOptions,
228
+ ): Promise<interfaces.data.IEmailDomain | undefined> => {
229
+ const startedAt = Date.now();
230
+ const pollIntervalMs = optionsArg.pollIntervalMs ?? (
231
+ optionsArg.subscribeToDomains ? 15_000 : 600
232
+ );
233
+ const timeoutMs = optionsArg.timeoutMs ?? 120_000;
234
+ let observedId: string | undefined;
235
+ let latestDomain: interfaces.data.IEmailDomain | undefined;
236
+ let pushedDomains: interfaces.data.IEmailDomain[] | undefined;
237
+ let wakeObserver: (() => void) | undefined;
238
+
239
+ const unsubscribe = optionsArg.subscribeToDomains?.((domainsArg) => {
240
+ pushedDomains = domainsArg;
241
+ wakeObserver?.();
242
+ });
243
+
244
+ try {
245
+ while (!optionsArg.signal.aborted && Date.now() - startedAt < timeoutMs) {
246
+ try {
247
+ let domain: interfaces.data.IEmailDomain | null | undefined;
248
+ if (!observedId) {
249
+ const domainSnapshot = pushedDomains || await optionsArg.listDomains();
250
+ pushedDomains = undefined;
251
+ const foundDomain = findNewEmailDomain(
252
+ domainSnapshot,
253
+ optionsArg.expectedDomain,
254
+ optionsArg.existingIds,
255
+ );
256
+ observedId = foundDomain?.id;
257
+ domain = foundDomain;
258
+ } else if (pushedDomains) {
259
+ domain = pushedDomains.find((candidate) => candidate.id === observedId);
260
+ pushedDomains = undefined;
261
+ } else {
262
+ domain = await optionsArg.getDomain(observedId);
263
+ }
264
+
265
+ if (optionsArg.signal.aborted) break;
266
+ if (domain) {
267
+ if (domain !== latestDomain) {
268
+ latestDomain = domain;
269
+ optionsArg.onDomain(domain);
270
+ }
271
+ const lifecycleStatus = domain.reconciliation?.lifecycleStatus;
272
+ if (lifecycleStatus === 'active' || lifecycleStatus === 'failed') {
273
+ return domain;
274
+ }
275
+ }
276
+ } catch (error: unknown) {
277
+ if (error instanceof Error && error.message.startsWith('Multiple new email domains')) {
278
+ throw error;
279
+ }
280
+ // The realtime subscription or bounded fallback read will retry.
281
+ }
282
+
283
+ const updatePromise = new Promise<void>((resolve) => {
284
+ wakeObserver = resolve;
285
+ });
286
+ if (pushedDomains) {
287
+ wakeObserver = undefined;
288
+ continue;
289
+ }
290
+ await Promise.race([
291
+ updatePromise,
292
+ waitForPollInterval(pollIntervalMs, optionsArg.signal),
293
+ ]);
294
+ wakeObserver = undefined;
295
+ }
296
+ } finally {
297
+ unsubscribe?.();
298
+ wakeObserver?.();
299
+ }
300
+
301
+ return latestDomain;
302
+ };
303
+
304
+ export interface IAbortableDeadlineOptions {
305
+ signal?: AbortSignal;
306
+ timeoutMs: number;
307
+ timeoutMessage: string;
308
+ }
309
+
310
+ export const withAbortableDeadline = async <T>(
311
+ promiseArg: Promise<T>,
312
+ optionsArg: IAbortableDeadlineOptions,
313
+ ): Promise<T> => {
314
+ return new Promise<T>((resolve, reject) => {
315
+ let settled = false;
316
+ const finish = (callbackArg: () => void) => {
317
+ if (settled) return;
318
+ settled = true;
319
+ clearTimeout(timeoutId);
320
+ optionsArg.signal?.removeEventListener('abort', handleAbort);
321
+ callbackArg();
322
+ };
323
+ const handleAbort = () => {
324
+ finish(() => reject(new Error('Email domain creation progress was interrupted')));
325
+ };
326
+ const timeoutId = setTimeout(() => {
327
+ finish(() => reject(new Error(optionsArg.timeoutMessage)));
328
+ }, optionsArg.timeoutMs);
329
+
330
+ if (optionsArg.signal?.aborted) {
331
+ handleAbort();
332
+ return;
333
+ }
334
+ optionsArg.signal?.addEventListener('abort', handleAbort, { once: true });
335
+ promiseArg.then(
336
+ (value) => finish(() => resolve(value)),
337
+ (error) => finish(() => reject(error)),
338
+ );
339
+ });
340
+ };