@payez/next-mvp 4.1.11 → 4.1.13
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/dist/api-handlers/auth/refresh.js +15 -38
- package/dist/auth/better-auth.d.ts +0 -3
- package/dist/auth/better-auth.js +164 -69
- package/dist/client/better-auth-client.d.ts +14 -2
- package/dist/lib/ensure-fresh-access-token.js +8 -42
- package/dist/lib/session-store.d.ts +0 -1
- package/dist/lib/session-store.js +29 -33
- package/dist/lib/startup-init.js +19 -19
- package/dist/lib/token-lifecycle.d.ts +0 -2
- package/dist/lib/token-lifecycle.js +3 -21
- package/package.json +5 -4
- package/scripts/check-internal-url-usage.sh +73 -73
- package/src/api/index.ts +17 -17
- package/src/api-handlers/account/masked-info.ts +45 -45
- package/src/api-handlers/account/profile.ts +80 -80
- package/src/api-handlers/account/recovery/initiate.ts +23 -23
- package/src/api-handlers/account/recovery/send-code.ts +25 -25
- package/src/api-handlers/account/recovery/verify-code.ts +25 -25
- package/src/api-handlers/account/reset-password.ts +23 -23
- package/src/api-handlers/account/send-code.ts +76 -76
- package/src/api-handlers/account/validate-password.ts +118 -118
- package/src/api-handlers/admin/stats.ts +249 -249
- package/src/api-handlers/anon/preferences.ts +123 -123
- package/src/api-handlers/auth/jwks.ts +20 -20
- package/src/api-handlers/auth/login.ts +240 -240
- package/src/api-handlers/auth/refresh.ts +15 -38
- package/src/api-handlers/auth/validate.ts +43 -43
- package/src/api-handlers/session/viability.ts +142 -142
- package/src/auth/better-auth.ts +130 -29
- package/src/auth/unauthenticated-routes.ts +2 -2
- package/src/client/better-auth-client.ts +70 -70
- package/src/client/fetchWithSession.ts +21 -21
- package/src/client/index.ts +13 -13
- package/src/client/useAnonSession.ts +131 -131
- package/src/components/admin/VibeAdminContext.tsx +87 -87
- package/src/config/logging-config.ts +82 -82
- package/src/config/unauthenticated-routes.ts +19 -19
- package/src/edge/internal-api-url.ts +65 -65
- package/src/edge/middleware.ts +42 -42
- package/src/hooks/useProfile.ts +75 -75
- package/src/lib/anon-session.ts +213 -213
- package/src/lib/auth-secret.ts +137 -137
- package/src/lib/demo-mode.ts +17 -17
- package/src/lib/ensure-fresh-access-token.ts +8 -42
- package/src/lib/idp-client-config.ts +559 -559
- package/src/lib/idp-fetch.ts +126 -126
- package/src/lib/internal-api.ts +171 -171
- package/src/lib/jwt-decode-client.ts +45 -45
- package/src/lib/rate-limit-service.ts +9 -9
- package/src/lib/redis.ts +26 -26
- package/src/lib/refresh-token-validator.ts +64 -64
- package/src/lib/secret-validation.ts +8 -8
- package/src/lib/session-store.ts +692 -695
- package/src/lib/session.ts +34 -34
- package/src/lib/startup-init.ts +251 -251
- package/src/lib/token-expiry.ts +40 -40
- package/src/lib/token-lifecycle.ts +470 -497
- package/src/lib/types/api-responses.ts +336 -336
- package/src/middleware/auth-decision.ts +43 -43
- package/src/models/DecodedAccessToken.ts +16 -16
- package/src/models/SessionModel.ts +264 -264
- package/src/pages/admin-roles/index.ts +9 -9
- package/src/pages/security/EnhancedSecurityPage.tsx +9 -9
- package/src/routes/account/index.ts +31 -31
- package/src/routes/account/masked-info.ts +36 -36
- package/src/routes/account/send-code.ts +39 -39
- package/src/routes/account/verify-email.ts +40 -40
- package/src/routes/account/verify-sms.ts +40 -40
- package/src/routes/auth/index.ts +22 -22
- package/src/routes/auth/refresh.ts +48 -48
- package/src/routes/auth/session.ts +166 -166
- package/src/routes/index.ts +17 -17
- package/src/routes/session/index.ts +7 -7
- package/src/routes/session/refresh-viability.ts +17 -17
- package/src/server/auth.ts +373 -373
- package/src/server/decode-session.ts +202 -202
- package/src/services/signalrActivityService.ts +258 -258
- package/src/theme/ThemeProvider.tsx +39 -39
- package/src/theme/default.ts +33 -33
- package/src/theme/index.ts +31 -31
- package/src/theme/types.ts +69 -69
- package/src/theme/useTheme.ts +57 -57
- package/src/theme/utils.ts +40 -40
- package/src/types/api.ts +12 -12
- package/src/types/auth.d.ts +23 -23
- package/src/types/auth.ts +22 -22
- package/src/types/logging.ts +11 -11
- package/src/types/recovery.ts +54 -54
- package/src/utils/api.ts +353 -353
- package/src/utils/circuitBreaker.ts +40 -40
- package/src/utils/error-message.ts +108 -108
- package/src/utils/layout/reservedSpace.ts +124 -124
|
@@ -1,258 +1,258 @@
|
|
|
1
|
-
import * as signalR from '@microsoft/signalr';
|
|
2
|
-
|
|
3
|
-
export interface HealthStatus {
|
|
4
|
-
isHealthy: boolean;
|
|
5
|
-
message: string;
|
|
6
|
-
lastHeartbeat: Date | null;
|
|
7
|
-
connectionId: string | null | undefined;
|
|
8
|
-
responseTime?: string;
|
|
9
|
-
}
|
|
10
|
-
|
|
11
|
-
export type HealthStatusCallback = (status: HealthStatus) => void;
|
|
12
|
-
|
|
13
|
-
/**
|
|
14
|
-
* SignalR-based health service following Occam's Razor principle:
|
|
15
|
-
* - If SignalR connection is alive = Service is working
|
|
16
|
-
* - If SignalR connection is dead = Service is not working
|
|
17
|
-
* - No complex orchestration, just connection state monitoring
|
|
18
|
-
*/
|
|
19
|
-
class SignalRActivityService {
|
|
20
|
-
private connection: signalR.HubConnection | null = null;
|
|
21
|
-
private subscribers: Set<HealthStatusCallback> = new Set();
|
|
22
|
-
private currentStatus: HealthStatus = {
|
|
23
|
-
isHealthy: false,
|
|
24
|
-
message: 'Disconnected',
|
|
25
|
-
lastHeartbeat: null,
|
|
26
|
-
connectionId: null
|
|
27
|
-
};
|
|
28
|
-
private heartbeatTimeout: NodeJS.Timeout | null = null;
|
|
29
|
-
private readonly heartbeatTimeoutMs = 45000; // 45 seconds (server sends every 30s)
|
|
30
|
-
|
|
31
|
-
/**
|
|
32
|
-
* Start the health monitoring connection
|
|
33
|
-
* @param idpBaseUrl - The base URL of the IDP server (e.g., 'http://localhost:32785')
|
|
34
|
-
*/
|
|
35
|
-
async start(idpBaseUrl: string): Promise<void> {
|
|
36
|
-
// If we already have a connected or connecting connection, don't start again
|
|
37
|
-
if (this.connection &&
|
|
38
|
-
(this.connection.state === signalR.HubConnectionState.Connected ||
|
|
39
|
-
this.connection.state === signalR.HubConnectionState.Connecting)) {
|
|
40
|
-
console.info('[SignalRHealth] Connection already active, skipping start');
|
|
41
|
-
return;
|
|
42
|
-
}
|
|
43
|
-
|
|
44
|
-
// Stop existing connection if it exists
|
|
45
|
-
if (this.connection) {
|
|
46
|
-
await this.stop();
|
|
47
|
-
}
|
|
48
|
-
|
|
49
|
-
try {
|
|
50
|
-
if (!idpBaseUrl) {
|
|
51
|
-
throw new Error('IDP base URL is required for health monitoring');
|
|
52
|
-
}
|
|
53
|
-
|
|
54
|
-
// Construct absolute hub URL safely
|
|
55
|
-
const activityHubUrl = new URL('/healthHub', idpBaseUrl).toString();
|
|
56
|
-
console.info('[SignalRHealth] Using hub URL:', activityHubUrl);
|
|
57
|
-
|
|
58
|
-
this.connection = new signalR.HubConnectionBuilder()
|
|
59
|
-
.withUrl(activityHubUrl, {
|
|
60
|
-
withCredentials: false,
|
|
61
|
-
transport: signalR.HttpTransportType.WebSockets | signalR.HttpTransportType.ServerSentEvents | signalR.HttpTransportType.LongPolling,
|
|
62
|
-
})
|
|
63
|
-
.withAutomaticReconnect({
|
|
64
|
-
nextRetryDelayInMilliseconds: (retryContext) => {
|
|
65
|
-
// More conservative backoff: 5s, 15s, 45s, then stop trying
|
|
66
|
-
const delays = [5000, 15000, 45000];
|
|
67
|
-
if (retryContext.previousRetryCount >= delays.length) {
|
|
68
|
-
return null; // Stop automatic reconnection
|
|
69
|
-
}
|
|
70
|
-
return delays[retryContext.previousRetryCount];
|
|
71
|
-
}
|
|
72
|
-
})
|
|
73
|
-
.configureLogging(signalR.LogLevel.Critical) // Only critical errors, hide connection noise
|
|
74
|
-
.build();
|
|
75
|
-
|
|
76
|
-
// Handle connection events
|
|
77
|
-
this.connection.onclose(() => {
|
|
78
|
-
this.updateStatus({
|
|
79
|
-
isHealthy: false,
|
|
80
|
-
message: 'Service unavailable',
|
|
81
|
-
lastHeartbeat: null,
|
|
82
|
-
connectionId: null
|
|
83
|
-
});
|
|
84
|
-
this.clearHeartbeatTimeout();
|
|
85
|
-
});
|
|
86
|
-
|
|
87
|
-
this.connection.onreconnecting(() => {
|
|
88
|
-
this.updateStatus({
|
|
89
|
-
isHealthy: false,
|
|
90
|
-
message: 'Service unavailable',
|
|
91
|
-
lastHeartbeat: this.currentStatus.lastHeartbeat,
|
|
92
|
-
connectionId: null
|
|
93
|
-
});
|
|
94
|
-
});
|
|
95
|
-
|
|
96
|
-
this.connection.onreconnected((connectionId) => {
|
|
97
|
-
this.updateStatus({
|
|
98
|
-
isHealthy: true,
|
|
99
|
-
message: 'Service operational',
|
|
100
|
-
lastHeartbeat: new Date(),
|
|
101
|
-
connectionId
|
|
102
|
-
});
|
|
103
|
-
this.resetHeartbeatTimeout();
|
|
104
|
-
});
|
|
105
|
-
|
|
106
|
-
// Handle heartbeat messages - this is the core health indicator
|
|
107
|
-
this.connection.on('Heartbeat', (data) => {
|
|
108
|
-
this.updateStatus({
|
|
109
|
-
isHealthy: true,
|
|
110
|
-
message: 'Service operational',
|
|
111
|
-
lastHeartbeat: new Date(),
|
|
112
|
-
connectionId: this.connection?.connectionId || null
|
|
113
|
-
});
|
|
114
|
-
this.resetHeartbeatTimeout();
|
|
115
|
-
});
|
|
116
|
-
|
|
117
|
-
// Handle initial health status
|
|
118
|
-
this.connection.on('HealthStatus', (data) => {
|
|
119
|
-
this.updateStatus({
|
|
120
|
-
isHealthy: data.status === 'healthy',
|
|
121
|
-
message: data.message || 'Service connected',
|
|
122
|
-
lastHeartbeat: new Date(),
|
|
123
|
-
connectionId: this.connection?.connectionId || null
|
|
124
|
-
});
|
|
125
|
-
this.resetHeartbeatTimeout();
|
|
126
|
-
});
|
|
127
|
-
|
|
128
|
-
// Start the connection
|
|
129
|
-
await this.connection.start();
|
|
130
|
-
console.info('[SignalRHealth] Connection started, connectionId:', this.connection.connectionId);
|
|
131
|
-
|
|
132
|
-
this.updateStatus({
|
|
133
|
-
isHealthy: true,
|
|
134
|
-
message: 'Service connected',
|
|
135
|
-
lastHeartbeat: new Date(),
|
|
136
|
-
connectionId: this.connection.connectionId
|
|
137
|
-
});
|
|
138
|
-
|
|
139
|
-
this.resetHeartbeatTimeout();
|
|
140
|
-
|
|
141
|
-
} catch (error) {
|
|
142
|
-
// Reduce console noise for expected connection failures
|
|
143
|
-
const errorMessage = error instanceof Error ? error.message : String(error);
|
|
144
|
-
console.warn('[SignalRHealth] Connection start failed:', errorMessage);
|
|
145
|
-
const isConnectionRefused = errorMessage.includes('ERR_CONNECTION_REFUSED') ||
|
|
146
|
-
errorMessage.includes('Failed to fetch') ||
|
|
147
|
-
errorMessage.includes('Failed to complete negotiation');
|
|
148
|
-
|
|
149
|
-
if (isConnectionRefused) {
|
|
150
|
-
// Service is down - this is expected, log at info level
|
|
151
|
-
console.info('SignalR Health Service: Backend service unavailable');
|
|
152
|
-
} else {
|
|
153
|
-
// Unexpected error - log as error
|
|
154
|
-
console.error('SignalR Health Service failed to start:', error);
|
|
155
|
-
}
|
|
156
|
-
|
|
157
|
-
this.updateStatus({
|
|
158
|
-
isHealthy: false,
|
|
159
|
-
message: 'Service unavailable',
|
|
160
|
-
lastHeartbeat: null,
|
|
161
|
-
connectionId: null
|
|
162
|
-
});
|
|
163
|
-
}
|
|
164
|
-
}
|
|
165
|
-
|
|
166
|
-
/**
|
|
167
|
-
* Stop the health monitoring connection
|
|
168
|
-
*/
|
|
169
|
-
async stop(): Promise<void> {
|
|
170
|
-
this.clearHeartbeatTimeout();
|
|
171
|
-
|
|
172
|
-
if (this.connection) {
|
|
173
|
-
try {
|
|
174
|
-
// Check if connection is in a state that can be stopped
|
|
175
|
-
if (this.connection.state !== signalR.HubConnectionState.Disconnected) {
|
|
176
|
-
await this.connection.stop();
|
|
177
|
-
}
|
|
178
|
-
} catch (error) {
|
|
179
|
-
// Ignore "connection was stopped before the hub handshake could complete" errors
|
|
180
|
-
// as these are expected during rapid start/stop cycles
|
|
181
|
-
const errorMessage = error instanceof Error ? error.message : String(error);
|
|
182
|
-
if (!errorMessage.includes('stopped before the hub handshake could complete')) {
|
|
183
|
-
console.error('Error stopping SignalR health connection:', error);
|
|
184
|
-
}
|
|
185
|
-
}
|
|
186
|
-
this.connection = null;
|
|
187
|
-
}
|
|
188
|
-
|
|
189
|
-
this.updateStatus({
|
|
190
|
-
isHealthy: false,
|
|
191
|
-
message: 'Disconnected',
|
|
192
|
-
lastHeartbeat: null,
|
|
193
|
-
connectionId: null
|
|
194
|
-
});
|
|
195
|
-
}
|
|
196
|
-
|
|
197
|
-
/**
|
|
198
|
-
* Subscribe to health status changes
|
|
199
|
-
*/
|
|
200
|
-
subscribe(callback: HealthStatusCallback): () => void {
|
|
201
|
-
this.subscribers.add(callback);
|
|
202
|
-
|
|
203
|
-
// Immediately notify with current status
|
|
204
|
-
callback(this.currentStatus);
|
|
205
|
-
|
|
206
|
-
// Return unsubscribe function
|
|
207
|
-
return () => {
|
|
208
|
-
this.subscribers.delete(callback);
|
|
209
|
-
};
|
|
210
|
-
}
|
|
211
|
-
|
|
212
|
-
/**
|
|
213
|
-
* Get current health status
|
|
214
|
-
*/
|
|
215
|
-
getCurrentStatus(): HealthStatus {
|
|
216
|
-
return { ...this.currentStatus };
|
|
217
|
-
}
|
|
218
|
-
|
|
219
|
-
private updateStatus(newStatus: HealthStatus): void {
|
|
220
|
-
this.currentStatus = newStatus;
|
|
221
|
-
this.notifySubscribers();
|
|
222
|
-
}
|
|
223
|
-
|
|
224
|
-
private notifySubscribers(): void {
|
|
225
|
-
this.subscribers.forEach(callback => {
|
|
226
|
-
try {
|
|
227
|
-
callback(this.currentStatus);
|
|
228
|
-
} catch (error) {
|
|
229
|
-
console.error('Error in health status subscriber:', error);
|
|
230
|
-
}
|
|
231
|
-
});
|
|
232
|
-
}
|
|
233
|
-
|
|
234
|
-
private resetHeartbeatTimeout(): void {
|
|
235
|
-
this.clearHeartbeatTimeout();
|
|
236
|
-
|
|
237
|
-
// If we don't receive a heartbeat within the timeout period, consider service unhealthy
|
|
238
|
-
this.heartbeatTimeout = setTimeout(() => {
|
|
239
|
-
this.updateStatus({
|
|
240
|
-
isHealthy: false,
|
|
241
|
-
message: 'Service unavailable',
|
|
242
|
-
lastHeartbeat: this.currentStatus.lastHeartbeat,
|
|
243
|
-
connectionId: this.currentStatus.connectionId
|
|
244
|
-
});
|
|
245
|
-
}, this.heartbeatTimeoutMs);
|
|
246
|
-
}
|
|
247
|
-
|
|
248
|
-
private clearHeartbeatTimeout(): void {
|
|
249
|
-
if (this.heartbeatTimeout) {
|
|
250
|
-
clearTimeout(this.heartbeatTimeout);
|
|
251
|
-
this.heartbeatTimeout = null;
|
|
252
|
-
}
|
|
253
|
-
}
|
|
254
|
-
|
|
255
|
-
}
|
|
256
|
-
|
|
257
|
-
// Export singleton instance
|
|
258
|
-
export const signalRActivityService = new SignalRActivityService();
|
|
1
|
+
import * as signalR from '@microsoft/signalr';
|
|
2
|
+
|
|
3
|
+
export interface HealthStatus {
|
|
4
|
+
isHealthy: boolean;
|
|
5
|
+
message: string;
|
|
6
|
+
lastHeartbeat: Date | null;
|
|
7
|
+
connectionId: string | null | undefined;
|
|
8
|
+
responseTime?: string;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export type HealthStatusCallback = (status: HealthStatus) => void;
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* SignalR-based health service following Occam's Razor principle:
|
|
15
|
+
* - If SignalR connection is alive = Service is working
|
|
16
|
+
* - If SignalR connection is dead = Service is not working
|
|
17
|
+
* - No complex orchestration, just connection state monitoring
|
|
18
|
+
*/
|
|
19
|
+
class SignalRActivityService {
|
|
20
|
+
private connection: signalR.HubConnection | null = null;
|
|
21
|
+
private subscribers: Set<HealthStatusCallback> = new Set();
|
|
22
|
+
private currentStatus: HealthStatus = {
|
|
23
|
+
isHealthy: false,
|
|
24
|
+
message: 'Disconnected',
|
|
25
|
+
lastHeartbeat: null,
|
|
26
|
+
connectionId: null
|
|
27
|
+
};
|
|
28
|
+
private heartbeatTimeout: NodeJS.Timeout | null = null;
|
|
29
|
+
private readonly heartbeatTimeoutMs = 45000; // 45 seconds (server sends every 30s)
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Start the health monitoring connection
|
|
33
|
+
* @param idpBaseUrl - The base URL of the IDP server (e.g., 'http://localhost:32785')
|
|
34
|
+
*/
|
|
35
|
+
async start(idpBaseUrl: string): Promise<void> {
|
|
36
|
+
// If we already have a connected or connecting connection, don't start again
|
|
37
|
+
if (this.connection &&
|
|
38
|
+
(this.connection.state === signalR.HubConnectionState.Connected ||
|
|
39
|
+
this.connection.state === signalR.HubConnectionState.Connecting)) {
|
|
40
|
+
console.info('[SignalRHealth] Connection already active, skipping start');
|
|
41
|
+
return;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
// Stop existing connection if it exists
|
|
45
|
+
if (this.connection) {
|
|
46
|
+
await this.stop();
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
try {
|
|
50
|
+
if (!idpBaseUrl) {
|
|
51
|
+
throw new Error('IDP base URL is required for health monitoring');
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
// Construct absolute hub URL safely
|
|
55
|
+
const activityHubUrl = new URL('/healthHub', idpBaseUrl).toString();
|
|
56
|
+
console.info('[SignalRHealth] Using hub URL:', activityHubUrl);
|
|
57
|
+
|
|
58
|
+
this.connection = new signalR.HubConnectionBuilder()
|
|
59
|
+
.withUrl(activityHubUrl, {
|
|
60
|
+
withCredentials: false,
|
|
61
|
+
transport: signalR.HttpTransportType.WebSockets | signalR.HttpTransportType.ServerSentEvents | signalR.HttpTransportType.LongPolling,
|
|
62
|
+
})
|
|
63
|
+
.withAutomaticReconnect({
|
|
64
|
+
nextRetryDelayInMilliseconds: (retryContext) => {
|
|
65
|
+
// More conservative backoff: 5s, 15s, 45s, then stop trying
|
|
66
|
+
const delays = [5000, 15000, 45000];
|
|
67
|
+
if (retryContext.previousRetryCount >= delays.length) {
|
|
68
|
+
return null; // Stop automatic reconnection
|
|
69
|
+
}
|
|
70
|
+
return delays[retryContext.previousRetryCount];
|
|
71
|
+
}
|
|
72
|
+
})
|
|
73
|
+
.configureLogging(signalR.LogLevel.Critical) // Only critical errors, hide connection noise
|
|
74
|
+
.build();
|
|
75
|
+
|
|
76
|
+
// Handle connection events
|
|
77
|
+
this.connection.onclose(() => {
|
|
78
|
+
this.updateStatus({
|
|
79
|
+
isHealthy: false,
|
|
80
|
+
message: 'Service unavailable',
|
|
81
|
+
lastHeartbeat: null,
|
|
82
|
+
connectionId: null
|
|
83
|
+
});
|
|
84
|
+
this.clearHeartbeatTimeout();
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
this.connection.onreconnecting(() => {
|
|
88
|
+
this.updateStatus({
|
|
89
|
+
isHealthy: false,
|
|
90
|
+
message: 'Service unavailable',
|
|
91
|
+
lastHeartbeat: this.currentStatus.lastHeartbeat,
|
|
92
|
+
connectionId: null
|
|
93
|
+
});
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
this.connection.onreconnected((connectionId) => {
|
|
97
|
+
this.updateStatus({
|
|
98
|
+
isHealthy: true,
|
|
99
|
+
message: 'Service operational',
|
|
100
|
+
lastHeartbeat: new Date(),
|
|
101
|
+
connectionId
|
|
102
|
+
});
|
|
103
|
+
this.resetHeartbeatTimeout();
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
// Handle heartbeat messages - this is the core health indicator
|
|
107
|
+
this.connection.on('Heartbeat', (data) => {
|
|
108
|
+
this.updateStatus({
|
|
109
|
+
isHealthy: true,
|
|
110
|
+
message: 'Service operational',
|
|
111
|
+
lastHeartbeat: new Date(),
|
|
112
|
+
connectionId: this.connection?.connectionId || null
|
|
113
|
+
});
|
|
114
|
+
this.resetHeartbeatTimeout();
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
// Handle initial health status
|
|
118
|
+
this.connection.on('HealthStatus', (data) => {
|
|
119
|
+
this.updateStatus({
|
|
120
|
+
isHealthy: data.status === 'healthy',
|
|
121
|
+
message: data.message || 'Service connected',
|
|
122
|
+
lastHeartbeat: new Date(),
|
|
123
|
+
connectionId: this.connection?.connectionId || null
|
|
124
|
+
});
|
|
125
|
+
this.resetHeartbeatTimeout();
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
// Start the connection
|
|
129
|
+
await this.connection.start();
|
|
130
|
+
console.info('[SignalRHealth] Connection started, connectionId:', this.connection.connectionId);
|
|
131
|
+
|
|
132
|
+
this.updateStatus({
|
|
133
|
+
isHealthy: true,
|
|
134
|
+
message: 'Service connected',
|
|
135
|
+
lastHeartbeat: new Date(),
|
|
136
|
+
connectionId: this.connection.connectionId
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
this.resetHeartbeatTimeout();
|
|
140
|
+
|
|
141
|
+
} catch (error) {
|
|
142
|
+
// Reduce console noise for expected connection failures
|
|
143
|
+
const errorMessage = error instanceof Error ? error.message : String(error);
|
|
144
|
+
console.warn('[SignalRHealth] Connection start failed:', errorMessage);
|
|
145
|
+
const isConnectionRefused = errorMessage.includes('ERR_CONNECTION_REFUSED') ||
|
|
146
|
+
errorMessage.includes('Failed to fetch') ||
|
|
147
|
+
errorMessage.includes('Failed to complete negotiation');
|
|
148
|
+
|
|
149
|
+
if (isConnectionRefused) {
|
|
150
|
+
// Service is down - this is expected, log at info level
|
|
151
|
+
console.info('SignalR Health Service: Backend service unavailable');
|
|
152
|
+
} else {
|
|
153
|
+
// Unexpected error - log as error
|
|
154
|
+
console.error('SignalR Health Service failed to start:', error);
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
this.updateStatus({
|
|
158
|
+
isHealthy: false,
|
|
159
|
+
message: 'Service unavailable',
|
|
160
|
+
lastHeartbeat: null,
|
|
161
|
+
connectionId: null
|
|
162
|
+
});
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
/**
|
|
167
|
+
* Stop the health monitoring connection
|
|
168
|
+
*/
|
|
169
|
+
async stop(): Promise<void> {
|
|
170
|
+
this.clearHeartbeatTimeout();
|
|
171
|
+
|
|
172
|
+
if (this.connection) {
|
|
173
|
+
try {
|
|
174
|
+
// Check if connection is in a state that can be stopped
|
|
175
|
+
if (this.connection.state !== signalR.HubConnectionState.Disconnected) {
|
|
176
|
+
await this.connection.stop();
|
|
177
|
+
}
|
|
178
|
+
} catch (error) {
|
|
179
|
+
// Ignore "connection was stopped before the hub handshake could complete" errors
|
|
180
|
+
// as these are expected during rapid start/stop cycles
|
|
181
|
+
const errorMessage = error instanceof Error ? error.message : String(error);
|
|
182
|
+
if (!errorMessage.includes('stopped before the hub handshake could complete')) {
|
|
183
|
+
console.error('Error stopping SignalR health connection:', error);
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
this.connection = null;
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
this.updateStatus({
|
|
190
|
+
isHealthy: false,
|
|
191
|
+
message: 'Disconnected',
|
|
192
|
+
lastHeartbeat: null,
|
|
193
|
+
connectionId: null
|
|
194
|
+
});
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
/**
|
|
198
|
+
* Subscribe to health status changes
|
|
199
|
+
*/
|
|
200
|
+
subscribe(callback: HealthStatusCallback): () => void {
|
|
201
|
+
this.subscribers.add(callback);
|
|
202
|
+
|
|
203
|
+
// Immediately notify with current status
|
|
204
|
+
callback(this.currentStatus);
|
|
205
|
+
|
|
206
|
+
// Return unsubscribe function
|
|
207
|
+
return () => {
|
|
208
|
+
this.subscribers.delete(callback);
|
|
209
|
+
};
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
/**
|
|
213
|
+
* Get current health status
|
|
214
|
+
*/
|
|
215
|
+
getCurrentStatus(): HealthStatus {
|
|
216
|
+
return { ...this.currentStatus };
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
private updateStatus(newStatus: HealthStatus): void {
|
|
220
|
+
this.currentStatus = newStatus;
|
|
221
|
+
this.notifySubscribers();
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
private notifySubscribers(): void {
|
|
225
|
+
this.subscribers.forEach(callback => {
|
|
226
|
+
try {
|
|
227
|
+
callback(this.currentStatus);
|
|
228
|
+
} catch (error) {
|
|
229
|
+
console.error('Error in health status subscriber:', error);
|
|
230
|
+
}
|
|
231
|
+
});
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
private resetHeartbeatTimeout(): void {
|
|
235
|
+
this.clearHeartbeatTimeout();
|
|
236
|
+
|
|
237
|
+
// If we don't receive a heartbeat within the timeout period, consider service unhealthy
|
|
238
|
+
this.heartbeatTimeout = setTimeout(() => {
|
|
239
|
+
this.updateStatus({
|
|
240
|
+
isHealthy: false,
|
|
241
|
+
message: 'Service unavailable',
|
|
242
|
+
lastHeartbeat: this.currentStatus.lastHeartbeat,
|
|
243
|
+
connectionId: this.currentStatus.connectionId
|
|
244
|
+
});
|
|
245
|
+
}, this.heartbeatTimeoutMs);
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
private clearHeartbeatTimeout(): void {
|
|
249
|
+
if (this.heartbeatTimeout) {
|
|
250
|
+
clearTimeout(this.heartbeatTimeout);
|
|
251
|
+
this.heartbeatTimeout = null;
|
|
252
|
+
}
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
// Export singleton instance
|
|
258
|
+
export const signalRActivityService = new SignalRActivityService();
|
|
@@ -1,39 +1,39 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Theme Provider for @payez/next-mvp
|
|
3
|
-
*
|
|
4
|
-
* Provides theme configuration to all child components via React Context.
|
|
5
|
-
* Consumer apps wrap their app with this provider and pass custom theme config.
|
|
6
|
-
*/
|
|
7
|
-
|
|
8
|
-
'use client';
|
|
9
|
-
|
|
10
|
-
import React, { createContext, useContext } from 'react';
|
|
11
|
-
import { ThemeConfig } from './types';
|
|
12
|
-
import { defaultTheme } from './default';
|
|
13
|
-
import { mergeDeep } from './utils';
|
|
14
|
-
|
|
15
|
-
const ThemeContext = createContext<ThemeConfig>(defaultTheme);
|
|
16
|
-
|
|
17
|
-
export interface ThemeProviderProps {
|
|
18
|
-
children: React.ReactNode;
|
|
19
|
-
theme?: Partial<ThemeConfig>;
|
|
20
|
-
}
|
|
21
|
-
|
|
22
|
-
export function ThemeProvider({ children, theme }: ThemeProviderProps) {
|
|
23
|
-
// Deep merge consumer theme with defaults
|
|
24
|
-
const mergedTheme = mergeDeep(defaultTheme, theme || {});
|
|
25
|
-
|
|
26
|
-
return (
|
|
27
|
-
<ThemeContext.Provider value={mergedTheme}>
|
|
28
|
-
{children}
|
|
29
|
-
</ThemeContext.Provider>
|
|
30
|
-
);
|
|
31
|
-
}
|
|
32
|
-
|
|
33
|
-
export function useTheme() {
|
|
34
|
-
const context = useContext(ThemeContext);
|
|
35
|
-
if (!context) {
|
|
36
|
-
throw new Error('useTheme must be used within ThemeProvider');
|
|
37
|
-
}
|
|
38
|
-
return context;
|
|
39
|
-
}
|
|
1
|
+
/**
|
|
2
|
+
* Theme Provider for @payez/next-mvp
|
|
3
|
+
*
|
|
4
|
+
* Provides theme configuration to all child components via React Context.
|
|
5
|
+
* Consumer apps wrap their app with this provider and pass custom theme config.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
'use client';
|
|
9
|
+
|
|
10
|
+
import React, { createContext, useContext } from 'react';
|
|
11
|
+
import { ThemeConfig } from './types';
|
|
12
|
+
import { defaultTheme } from './default';
|
|
13
|
+
import { mergeDeep } from './utils';
|
|
14
|
+
|
|
15
|
+
const ThemeContext = createContext<ThemeConfig>(defaultTheme);
|
|
16
|
+
|
|
17
|
+
export interface ThemeProviderProps {
|
|
18
|
+
children: React.ReactNode;
|
|
19
|
+
theme?: Partial<ThemeConfig>;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export function ThemeProvider({ children, theme }: ThemeProviderProps) {
|
|
23
|
+
// Deep merge consumer theme with defaults
|
|
24
|
+
const mergedTheme = mergeDeep(defaultTheme, theme || {});
|
|
25
|
+
|
|
26
|
+
return (
|
|
27
|
+
<ThemeContext.Provider value={mergedTheme}>
|
|
28
|
+
{children}
|
|
29
|
+
</ThemeContext.Provider>
|
|
30
|
+
);
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export function useTheme() {
|
|
34
|
+
const context = useContext(ThemeContext);
|
|
35
|
+
if (!context) {
|
|
36
|
+
throw new Error('useTheme must be used within ThemeProvider');
|
|
37
|
+
}
|
|
38
|
+
return context;
|
|
39
|
+
}
|
package/src/theme/default.ts
CHANGED
|
@@ -1,33 +1,33 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Default Theme Configuration for @payez/next-mvp
|
|
3
|
-
*
|
|
4
|
-
* This is the fallback theme when no custom theme is provided.
|
|
5
|
-
* It provides sensible defaults that match the current unbranded styling.
|
|
6
|
-
*/
|
|
7
|
-
|
|
8
|
-
import { ThemeConfig } from './types';
|
|
9
|
-
|
|
10
|
-
export const defaultTheme: ThemeConfig = {
|
|
11
|
-
branding: {
|
|
12
|
-
logo: {
|
|
13
|
-
light: "/logo.svg",
|
|
14
|
-
dark: "/logo.svg",
|
|
15
|
-
alt: "Logo",
|
|
16
|
-
height: "h-10",
|
|
17
|
-
width: "w-auto",
|
|
18
|
-
},
|
|
19
|
-
appName: "App",
|
|
20
|
-
},
|
|
21
|
-
colors: {
|
|
22
|
-
primary: "#3b82f6", // blue-500
|
|
23
|
-
background: "bg-gray-50",
|
|
24
|
-
card: "bg-white",
|
|
25
|
-
muted: "text-gray-600",
|
|
26
|
-
border: "border-gray-200",
|
|
27
|
-
},
|
|
28
|
-
layout: {
|
|
29
|
-
maxWidth: "max-w-4xl",
|
|
30
|
-
padding: "p-6",
|
|
31
|
-
spacing: "space-y-6",
|
|
32
|
-
},
|
|
33
|
-
};
|
|
1
|
+
/**
|
|
2
|
+
* Default Theme Configuration for @payez/next-mvp
|
|
3
|
+
*
|
|
4
|
+
* This is the fallback theme when no custom theme is provided.
|
|
5
|
+
* It provides sensible defaults that match the current unbranded styling.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { ThemeConfig } from './types';
|
|
9
|
+
|
|
10
|
+
export const defaultTheme: ThemeConfig = {
|
|
11
|
+
branding: {
|
|
12
|
+
logo: {
|
|
13
|
+
light: "/logo.svg",
|
|
14
|
+
dark: "/logo.svg",
|
|
15
|
+
alt: "Logo",
|
|
16
|
+
height: "h-10",
|
|
17
|
+
width: "w-auto",
|
|
18
|
+
},
|
|
19
|
+
appName: "App",
|
|
20
|
+
},
|
|
21
|
+
colors: {
|
|
22
|
+
primary: "#3b82f6", // blue-500
|
|
23
|
+
background: "bg-gray-50",
|
|
24
|
+
card: "bg-white",
|
|
25
|
+
muted: "text-gray-600",
|
|
26
|
+
border: "border-gray-200",
|
|
27
|
+
},
|
|
28
|
+
layout: {
|
|
29
|
+
maxWidth: "max-w-4xl",
|
|
30
|
+
padding: "p-6",
|
|
31
|
+
spacing: "space-y-6",
|
|
32
|
+
},
|
|
33
|
+
};
|