mastercontroller 1.2.12 → 1.2.14
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/.claude/settings.local.json +12 -0
- package/MasterAction.js +297 -73
- package/MasterControl.js +112 -19
- package/MasterHtml.js +101 -14
- package/MasterRouter.js +281 -66
- package/MasterTemplate.js +96 -3
- package/README.md +0 -44
- package/error/ErrorBoundary.js +353 -0
- package/error/HydrationMismatch.js +265 -0
- package/error/MasterBackendErrorHandler.js +769 -0
- package/{MasterError.js → error/MasterError.js} +2 -2
- package/error/MasterErrorHandler.js +487 -0
- package/error/MasterErrorLogger.js +360 -0
- package/error/MasterErrorMiddleware.js +407 -0
- package/error/SSRErrorHandler.js +273 -0
- package/monitoring/MasterCache.js +400 -0
- package/monitoring/MasterMemoryMonitor.js +188 -0
- package/monitoring/MasterProfiler.js +409 -0
- package/monitoring/PerformanceMonitor.js +233 -0
- package/package.json +3 -3
- package/security/CSPConfig.js +319 -0
- package/security/EventHandlerValidator.js +464 -0
- package/security/MasterSanitizer.js +429 -0
- package/security/MasterValidator.js +546 -0
- package/security/SecurityMiddleware.js +486 -0
- package/security/SessionSecurity.js +416 -0
- package/ssr/hydration-client.js +93 -0
- package/ssr/runtime-ssr.cjs +553 -0
- package/ssr/ssr-shims.js +73 -0
- package/examples/FileServingExample.js +0 -88
|
@@ -0,0 +1,416 @@
|
|
|
1
|
+
// version 1.0.1
|
|
2
|
+
// MasterController Session Security - Secure cookie handling, session fixation prevention
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Secure session handling for MasterController
|
|
6
|
+
* Prevents: Session fixation, session hijacking, cookie theft
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
const crypto = require('crypto');
|
|
10
|
+
const { logger } = require('../error/MasterErrorLogger');
|
|
11
|
+
|
|
12
|
+
// Session store (use Redis in production)
|
|
13
|
+
const sessionStore = new Map();
|
|
14
|
+
|
|
15
|
+
class SessionSecurity {
|
|
16
|
+
constructor(options = {}) {
|
|
17
|
+
this.cookieName = options.cookieName || 'mc_session';
|
|
18
|
+
this.secret = options.secret || this._generateSecret();
|
|
19
|
+
this.maxAge = options.maxAge || 86400000; // 24 hours
|
|
20
|
+
this.httpOnly = options.httpOnly !== false;
|
|
21
|
+
this.secure = options.secure !== false; // Only send over HTTPS
|
|
22
|
+
this.sameSite = options.sameSite || 'strict'; // 'strict', 'lax', or 'none'
|
|
23
|
+
this.rolling = options.rolling !== false; // Extend expiry on each request
|
|
24
|
+
this.regenerateInterval = options.regenerateInterval || 3600000; // 1 hour
|
|
25
|
+
this.domain = options.domain || null;
|
|
26
|
+
this.path = options.path || '/';
|
|
27
|
+
|
|
28
|
+
// Session fingerprinting
|
|
29
|
+
this.useFingerprint = options.useFingerprint !== false;
|
|
30
|
+
|
|
31
|
+
// Start cleanup interval
|
|
32
|
+
this._startCleanup();
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Session middleware
|
|
37
|
+
*/
|
|
38
|
+
middleware() {
|
|
39
|
+
return async (req, res, next) => {
|
|
40
|
+
// Parse session from cookie
|
|
41
|
+
const sessionId = this._parseCookie(req);
|
|
42
|
+
|
|
43
|
+
if (sessionId) {
|
|
44
|
+
// Load existing session
|
|
45
|
+
const session = sessionStore.get(sessionId);
|
|
46
|
+
|
|
47
|
+
if (session && this._isSessionValid(session, req)) {
|
|
48
|
+
// Check if session needs regeneration
|
|
49
|
+
if (this._shouldRegenerate(session)) {
|
|
50
|
+
req.session = await this._regenerateSession(sessionId, session, res);
|
|
51
|
+
} else {
|
|
52
|
+
// Use existing session
|
|
53
|
+
req.session = session.data;
|
|
54
|
+
req.sessionId = sessionId;
|
|
55
|
+
|
|
56
|
+
// Update last access time
|
|
57
|
+
session.lastAccess = Date.now();
|
|
58
|
+
|
|
59
|
+
// Extend expiry if rolling
|
|
60
|
+
if (this.rolling) {
|
|
61
|
+
session.expiry = Date.now() + this.maxAge;
|
|
62
|
+
this._setCookie(res, sessionId);
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
} else {
|
|
66
|
+
// Invalid or expired session
|
|
67
|
+
if (session) {
|
|
68
|
+
sessionStore.delete(sessionId);
|
|
69
|
+
logger.warn({
|
|
70
|
+
code: 'MC_SECURITY_SESSION_INVALID',
|
|
71
|
+
message: 'Invalid session detected',
|
|
72
|
+
sessionId: sessionId.substring(0, 10) + '...'
|
|
73
|
+
});
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
// Create new session
|
|
77
|
+
req.session = await this._createSession(req, res);
|
|
78
|
+
}
|
|
79
|
+
} else {
|
|
80
|
+
// No session cookie, create new session
|
|
81
|
+
req.session = await this._createSession(req, res);
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
// Save session on response
|
|
85
|
+
const originalEnd = res.end;
|
|
86
|
+
res.end = (...args) => {
|
|
87
|
+
this._saveSession(req);
|
|
88
|
+
originalEnd.apply(res, args);
|
|
89
|
+
};
|
|
90
|
+
|
|
91
|
+
next();
|
|
92
|
+
};
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* Create new session
|
|
97
|
+
*/
|
|
98
|
+
async _createSession(req, res) {
|
|
99
|
+
const sessionId = this._generateSessionId();
|
|
100
|
+
const fingerprint = this.useFingerprint ? this._generateFingerprint(req) : null;
|
|
101
|
+
|
|
102
|
+
const sessionData = {
|
|
103
|
+
id: sessionId,
|
|
104
|
+
data: {},
|
|
105
|
+
createdAt: Date.now(),
|
|
106
|
+
lastAccess: Date.now(),
|
|
107
|
+
expiry: Date.now() + this.maxAge,
|
|
108
|
+
fingerprint,
|
|
109
|
+
regeneratedAt: Date.now()
|
|
110
|
+
};
|
|
111
|
+
|
|
112
|
+
sessionStore.set(sessionId, sessionData);
|
|
113
|
+
|
|
114
|
+
// Set cookie
|
|
115
|
+
this._setCookie(res, sessionId);
|
|
116
|
+
|
|
117
|
+
req.sessionId = sessionId;
|
|
118
|
+
|
|
119
|
+
return sessionData.data;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/**
|
|
123
|
+
* Regenerate session (prevent session fixation)
|
|
124
|
+
*/
|
|
125
|
+
async _regenerateSession(oldSessionId, oldSession, res) {
|
|
126
|
+
const newSessionId = this._generateSessionId();
|
|
127
|
+
|
|
128
|
+
// Copy session data to new session
|
|
129
|
+
const newSession = {
|
|
130
|
+
id: newSessionId,
|
|
131
|
+
data: { ...oldSession.data },
|
|
132
|
+
createdAt: oldSession.createdAt,
|
|
133
|
+
lastAccess: Date.now(),
|
|
134
|
+
expiry: Date.now() + this.maxAge,
|
|
135
|
+
fingerprint: oldSession.fingerprint,
|
|
136
|
+
regeneratedAt: Date.now()
|
|
137
|
+
};
|
|
138
|
+
|
|
139
|
+
// Delete old session
|
|
140
|
+
sessionStore.delete(oldSessionId);
|
|
141
|
+
|
|
142
|
+
// Store new session
|
|
143
|
+
sessionStore.set(newSessionId, newSession);
|
|
144
|
+
|
|
145
|
+
// Update cookie
|
|
146
|
+
this._setCookie(res, newSessionId);
|
|
147
|
+
|
|
148
|
+
logger.info({
|
|
149
|
+
code: 'MC_SECURITY_SESSION_REGENERATED',
|
|
150
|
+
message: 'Session regenerated',
|
|
151
|
+
oldSessionId: oldSessionId.substring(0, 10) + '...',
|
|
152
|
+
newSessionId: newSessionId.substring(0, 10) + '...'
|
|
153
|
+
});
|
|
154
|
+
|
|
155
|
+
return newSession.data;
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
/**
|
|
159
|
+
* Save session data
|
|
160
|
+
*/
|
|
161
|
+
_saveSession(req) {
|
|
162
|
+
if (!req.sessionId) return;
|
|
163
|
+
|
|
164
|
+
const session = sessionStore.get(req.sessionId);
|
|
165
|
+
if (session) {
|
|
166
|
+
session.data = req.session;
|
|
167
|
+
session.lastAccess = Date.now();
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
/**
|
|
172
|
+
* Check if session is valid
|
|
173
|
+
*/
|
|
174
|
+
_isSessionValid(session, req) {
|
|
175
|
+
// Check expiry
|
|
176
|
+
if (Date.now() > session.expiry) {
|
|
177
|
+
return false;
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
// Check fingerprint
|
|
181
|
+
if (this.useFingerprint && session.fingerprint) {
|
|
182
|
+
const currentFingerprint = this._generateFingerprint(req);
|
|
183
|
+
if (currentFingerprint !== session.fingerprint) {
|
|
184
|
+
logger.warn({
|
|
185
|
+
code: 'MC_SECURITY_SESSION_HIJACK_ATTEMPT',
|
|
186
|
+
message: 'Session hijacking attempt detected',
|
|
187
|
+
sessionId: session.id.substring(0, 10) + '...',
|
|
188
|
+
expectedFingerprint: session.fingerprint,
|
|
189
|
+
actualFingerprint: currentFingerprint
|
|
190
|
+
});
|
|
191
|
+
return false;
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
return true;
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
/**
|
|
199
|
+
* Check if session should be regenerated
|
|
200
|
+
*/
|
|
201
|
+
_shouldRegenerate(session) {
|
|
202
|
+
const age = Date.now() - session.regeneratedAt;
|
|
203
|
+
return age > this.regenerateInterval;
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
/**
|
|
207
|
+
* Generate session ID
|
|
208
|
+
*/
|
|
209
|
+
_generateSessionId() {
|
|
210
|
+
return crypto.randomBytes(32).toString('hex');
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
/**
|
|
214
|
+
* Generate secret for signing
|
|
215
|
+
*/
|
|
216
|
+
_generateSecret() {
|
|
217
|
+
return crypto.randomBytes(64).toString('hex');
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
/**
|
|
221
|
+
* Generate fingerprint for session hijacking detection
|
|
222
|
+
*/
|
|
223
|
+
_generateFingerprint(req) {
|
|
224
|
+
const components = [
|
|
225
|
+
req.headers['user-agent'] || '',
|
|
226
|
+
req.headers['accept-language'] || '',
|
|
227
|
+
req.connection.remoteAddress || '',
|
|
228
|
+
// Don't include Accept-Encoding (changes too often)
|
|
229
|
+
];
|
|
230
|
+
|
|
231
|
+
return crypto
|
|
232
|
+
.createHash('sha256')
|
|
233
|
+
.update(components.join('|'))
|
|
234
|
+
.digest('hex');
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
/**
|
|
238
|
+
* Parse session cookie from request
|
|
239
|
+
*/
|
|
240
|
+
_parseCookie(req) {
|
|
241
|
+
const cookies = req.headers.cookie;
|
|
242
|
+
if (!cookies) return null;
|
|
243
|
+
|
|
244
|
+
const match = cookies.match(new RegExp(`${this.cookieName}=([^;]+)`));
|
|
245
|
+
return match ? match[1] : null;
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
/**
|
|
249
|
+
* Set session cookie
|
|
250
|
+
*/
|
|
251
|
+
_setCookie(res, sessionId) {
|
|
252
|
+
const options = [
|
|
253
|
+
`${this.cookieName}=${sessionId}`,
|
|
254
|
+
`Max-Age=${Math.floor(this.maxAge / 1000)}`,
|
|
255
|
+
`Path=${this.path}`
|
|
256
|
+
];
|
|
257
|
+
|
|
258
|
+
if (this.domain) {
|
|
259
|
+
options.push(`Domain=${this.domain}`);
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
if (this.httpOnly) {
|
|
263
|
+
options.push('HttpOnly');
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
if (this.secure) {
|
|
267
|
+
options.push('Secure');
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
if (this.sameSite) {
|
|
271
|
+
options.push(`SameSite=${this.sameSite}`);
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
res.setHeader('Set-Cookie', options.join('; '));
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
/**
|
|
278
|
+
* Destroy session
|
|
279
|
+
*/
|
|
280
|
+
destroySession(req, res) {
|
|
281
|
+
if (req.sessionId) {
|
|
282
|
+
sessionStore.delete(req.sessionId);
|
|
283
|
+
|
|
284
|
+
// Clear cookie
|
|
285
|
+
const options = [
|
|
286
|
+
`${this.cookieName}=`,
|
|
287
|
+
'Max-Age=0',
|
|
288
|
+
`Path=${this.path}`
|
|
289
|
+
];
|
|
290
|
+
|
|
291
|
+
if (this.domain) {
|
|
292
|
+
options.push(`Domain=${this.domain}`);
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
res.setHeader('Set-Cookie', options.join('; '));
|
|
296
|
+
|
|
297
|
+
req.session = null;
|
|
298
|
+
req.sessionId = null;
|
|
299
|
+
|
|
300
|
+
logger.info({
|
|
301
|
+
code: 'MC_SECURITY_SESSION_DESTROYED',
|
|
302
|
+
message: 'Session destroyed'
|
|
303
|
+
});
|
|
304
|
+
}
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
/**
|
|
308
|
+
* Get session by ID
|
|
309
|
+
*/
|
|
310
|
+
getSession(sessionId) {
|
|
311
|
+
const session = sessionStore.get(sessionId);
|
|
312
|
+
return session ? session.data : null;
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
/**
|
|
316
|
+
* Update session expiry
|
|
317
|
+
*/
|
|
318
|
+
touch(sessionId) {
|
|
319
|
+
const session = sessionStore.get(sessionId);
|
|
320
|
+
if (session) {
|
|
321
|
+
session.lastAccess = Date.now();
|
|
322
|
+
session.expiry = Date.now() + this.maxAge;
|
|
323
|
+
}
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
/**
|
|
327
|
+
* Cleanup expired sessions
|
|
328
|
+
*/
|
|
329
|
+
_startCleanup() {
|
|
330
|
+
setInterval(() => {
|
|
331
|
+
const now = Date.now();
|
|
332
|
+
let cleaned = 0;
|
|
333
|
+
|
|
334
|
+
for (const [sessionId, session] of sessionStore.entries()) {
|
|
335
|
+
if (now > session.expiry) {
|
|
336
|
+
sessionStore.delete(sessionId);
|
|
337
|
+
cleaned++;
|
|
338
|
+
}
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
if (cleaned > 0) {
|
|
342
|
+
logger.info({
|
|
343
|
+
code: 'MC_SECURITY_SESSION_CLEANUP',
|
|
344
|
+
message: `Cleaned up ${cleaned} expired sessions`,
|
|
345
|
+
totalSessions: sessionStore.size
|
|
346
|
+
});
|
|
347
|
+
}
|
|
348
|
+
}, 60000); // Run every minute
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
/**
|
|
352
|
+
* Get session store size (for monitoring)
|
|
353
|
+
*/
|
|
354
|
+
getSessionCount() {
|
|
355
|
+
return sessionStore.size;
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
/**
|
|
359
|
+
* Clear all sessions (for testing)
|
|
360
|
+
*/
|
|
361
|
+
clearAllSessions() {
|
|
362
|
+
const count = sessionStore.size;
|
|
363
|
+
sessionStore.clear();
|
|
364
|
+
logger.warn({
|
|
365
|
+
code: 'MC_SECURITY_ALL_SESSIONS_CLEARED',
|
|
366
|
+
message: `Cleared all ${count} sessions`
|
|
367
|
+
});
|
|
368
|
+
}
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
// Create singleton instance
|
|
372
|
+
const session = new SessionSecurity();
|
|
373
|
+
|
|
374
|
+
/**
|
|
375
|
+
* Factory functions
|
|
376
|
+
*/
|
|
377
|
+
|
|
378
|
+
function createSessionMiddleware(options = {}) {
|
|
379
|
+
const instance = new SessionSecurity(options);
|
|
380
|
+
return instance.middleware();
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
function destroySession(req, res) {
|
|
384
|
+
return session.destroySession(req, res);
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
/**
|
|
388
|
+
* Security best practices for sessions
|
|
389
|
+
*/
|
|
390
|
+
|
|
391
|
+
const SESSION_BEST_PRACTICES = {
|
|
392
|
+
production: {
|
|
393
|
+
secure: true,
|
|
394
|
+
httpOnly: true,
|
|
395
|
+
sameSite: 'strict',
|
|
396
|
+
maxAge: 3600000, // 1 hour
|
|
397
|
+
regenerateInterval: 900000, // 15 minutes
|
|
398
|
+
useFingerprint: true
|
|
399
|
+
},
|
|
400
|
+
development: {
|
|
401
|
+
secure: false, // Allow HTTP in dev
|
|
402
|
+
httpOnly: true,
|
|
403
|
+
sameSite: 'lax',
|
|
404
|
+
maxAge: 86400000, // 24 hours
|
|
405
|
+
regenerateInterval: 3600000, // 1 hour
|
|
406
|
+
useFingerprint: true
|
|
407
|
+
}
|
|
408
|
+
};
|
|
409
|
+
|
|
410
|
+
module.exports = {
|
|
411
|
+
SessionSecurity,
|
|
412
|
+
session,
|
|
413
|
+
createSessionMiddleware,
|
|
414
|
+
destroySession,
|
|
415
|
+
SESSION_BEST_PRACTICES
|
|
416
|
+
};
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* MasterController Client-Side Hydration Runtime
|
|
3
|
+
* Handles error boundaries and hydration mismatch detection
|
|
4
|
+
* Version: 2.0.1
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
// Import error boundary
|
|
8
|
+
import { ErrorBoundary } from '../error/ErrorBoundary.js';
|
|
9
|
+
|
|
10
|
+
// Import hydration mismatch detection
|
|
11
|
+
const isDevelopment = window.location.hostname === 'localhost' ||
|
|
12
|
+
window.location.hostname === '127.0.0.1';
|
|
13
|
+
|
|
14
|
+
if (isDevelopment && typeof require !== 'undefined') {
|
|
15
|
+
try {
|
|
16
|
+
const { enableHydrationMismatchDetection } = require('../error/HydrationMismatch.js');
|
|
17
|
+
enableHydrationMismatchDetection({
|
|
18
|
+
verbose: localStorage.getItem('mc-hydration-debug') === 'true',
|
|
19
|
+
delay: 1000
|
|
20
|
+
});
|
|
21
|
+
} catch (e) {
|
|
22
|
+
console.warn('[MasterController] Could not load hydration mismatch detection:', e.message);
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
// Auto-wrap app root with error boundary if not already wrapped
|
|
27
|
+
document.addEventListener('DOMContentLoaded', () => {
|
|
28
|
+
const appRoot = document.querySelector('root-layout') || document.body;
|
|
29
|
+
|
|
30
|
+
// Check if already wrapped
|
|
31
|
+
if (!appRoot.closest('error-boundary')) {
|
|
32
|
+
// Create error boundary wrapper
|
|
33
|
+
const boundary = document.createElement('error-boundary');
|
|
34
|
+
|
|
35
|
+
// Set development mode
|
|
36
|
+
if (isDevelopment) {
|
|
37
|
+
boundary.setAttribute('dev-mode', '');
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
// Configure custom error handler
|
|
41
|
+
boundary.onError = (errorInfo) => {
|
|
42
|
+
console.error('[App Error]', errorInfo);
|
|
43
|
+
|
|
44
|
+
// Send to monitoring service if configured
|
|
45
|
+
if (window.masterControllerErrorReporter) {
|
|
46
|
+
window.masterControllerErrorReporter(errorInfo);
|
|
47
|
+
}
|
|
48
|
+
};
|
|
49
|
+
|
|
50
|
+
// Wrap content
|
|
51
|
+
const parent = appRoot.parentNode;
|
|
52
|
+
parent.insertBefore(boundary, appRoot);
|
|
53
|
+
boundary.appendChild(appRoot);
|
|
54
|
+
}
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
// Global error reporter hook
|
|
58
|
+
window.masterControllerErrorReporter = window.masterControllerErrorReporter || function(errorData) {
|
|
59
|
+
console.log('[MasterController] Error reported:', errorData);
|
|
60
|
+
|
|
61
|
+
// Example: Send to your monitoring service
|
|
62
|
+
// fetch('/api/errors', {
|
|
63
|
+
// method: 'POST',
|
|
64
|
+
// headers: { 'Content-Type': 'application/json' },
|
|
65
|
+
// body: JSON.stringify(errorData)
|
|
66
|
+
// });
|
|
67
|
+
|
|
68
|
+
// Example: Send to Sentry
|
|
69
|
+
// if (window.Sentry) {
|
|
70
|
+
// Sentry.captureException(new Error(errorData.message), {
|
|
71
|
+
// extra: errorData
|
|
72
|
+
// });
|
|
73
|
+
// }
|
|
74
|
+
};
|
|
75
|
+
|
|
76
|
+
// Log successful hydration
|
|
77
|
+
if (isDevelopment) {
|
|
78
|
+
window.addEventListener('load', () => {
|
|
79
|
+
const ssrElements = document.querySelectorAll('[data-ssr]');
|
|
80
|
+
if (ssrElements.length > 0) {
|
|
81
|
+
console.log(
|
|
82
|
+
`%c✓ MasterController Hydration Complete`,
|
|
83
|
+
'color: #10b981; font-weight: bold; font-size: 14px;'
|
|
84
|
+
);
|
|
85
|
+
console.log(` ${ssrElements.length} server-rendered components hydrated`);
|
|
86
|
+
}
|
|
87
|
+
});
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
// Export for manual use
|
|
91
|
+
export {
|
|
92
|
+
ErrorBoundary
|
|
93
|
+
};
|