pulse-js-framework 1.7.13 → 1.7.16

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/runtime/ssr.js ADDED
@@ -0,0 +1,463 @@
1
+ /**
2
+ * Pulse SSR - Server-Side Rendering Module
3
+ *
4
+ * Provides server-side rendering and client-side hydration for Pulse applications.
5
+ *
6
+ * @module pulse-js-framework/runtime/ssr
7
+ *
8
+ * @example
9
+ * // Server-side rendering
10
+ * import { renderToString, serializeState } from 'pulse-js-framework/runtime/ssr';
11
+ *
12
+ * const { html, state } = await renderToString(() => App(), {
13
+ * waitForAsync: true
14
+ * });
15
+ *
16
+ * res.send(`
17
+ * <div id="app">${html}</div>
18
+ * <script>window.__PULSE_STATE__ = ${serializeState(state)};</script>
19
+ * `);
20
+ *
21
+ * @example
22
+ * // Client-side hydration
23
+ * import { hydrate } from 'pulse-js-framework/runtime/ssr';
24
+ *
25
+ * hydrate('#app', () => App(), {
26
+ * state: window.__PULSE_STATE__
27
+ * });
28
+ */
29
+
30
+ import { createContext, batch, setSSRMode as setPulseSSRMode, isSSRMode } from './pulse.js';
31
+ import { MockDOMAdapter, withAdapter, getAdapter } from './dom-adapter.js';
32
+ import { serializeToHTML, serializeChildren } from './ssr-serializer.js';
33
+ import { SSRAsyncContext, setSSRAsyncContext, getSSRAsyncContext } from './ssr-async.js';
34
+ import {
35
+ setHydrationMode,
36
+ createHydrationContext,
37
+ disposeHydration,
38
+ isHydratingMode,
39
+ getHydrationContext
40
+ } from './ssr-hydrator.js';
41
+ import { loggers } from './logger.js';
42
+
43
+ const log = loggers.dom;
44
+
45
+ // ============================================================================
46
+ // SSR Mode State
47
+ // ============================================================================
48
+
49
+ /**
50
+ * Check if currently in SSR mode.
51
+ * Use this to conditionally skip browser-only code.
52
+ * @returns {boolean}
53
+ *
54
+ * @example
55
+ * import { isSSR } from 'pulse-js-framework/runtime/ssr';
56
+ *
57
+ * effect(() => {
58
+ * if (isSSR()) return; // Skip on server
59
+ * window.addEventListener('resize', handleResize);
60
+ * return () => window.removeEventListener('resize', handleResize);
61
+ * });
62
+ */
63
+ export function isSSR() {
64
+ return isSSRMode();
65
+ }
66
+
67
+ /**
68
+ * Set SSR mode (internal use).
69
+ * @param {boolean} enabled
70
+ * @internal
71
+ */
72
+ export function setSSRMode(enabled) {
73
+ setPulseSSRMode(enabled);
74
+ }
75
+
76
+ // ============================================================================
77
+ // Server-Side Rendering
78
+ // ============================================================================
79
+
80
+ /**
81
+ * @typedef {Object} RenderToStringOptions
82
+ * @property {boolean} [waitForAsync=true] - Wait for async operations before rendering
83
+ * @property {number} [timeout=5000] - Timeout for async operations (ms)
84
+ * @property {boolean} [serializeState=true] - Include state in result
85
+ */
86
+
87
+ /**
88
+ * @typedef {Object} RenderResult
89
+ * @property {string} html - Rendered HTML string
90
+ * @property {Object|null} state - Serialized state (if serializeState is true)
91
+ */
92
+
93
+ /**
94
+ * Render a component tree to an HTML string.
95
+ *
96
+ * Creates an isolated reactive context and renders the component using
97
+ * MockDOMAdapter, then serializes the result to HTML.
98
+ *
99
+ * @param {Function} componentFactory - Function that returns the root component
100
+ * @param {RenderToStringOptions} [options] - Rendering options
101
+ * @returns {Promise<RenderResult>} Rendered HTML and optional state
102
+ *
103
+ * @example
104
+ * // Basic rendering
105
+ * const { html } = await renderToString(() => App());
106
+ *
107
+ * @example
108
+ * // With async data fetching
109
+ * const { html, state } = await renderToString(() => App(), {
110
+ * waitForAsync: true,
111
+ * timeout: 10000,
112
+ * serializeState: true
113
+ * });
114
+ */
115
+ export async function renderToString(componentFactory, options = {}) {
116
+ const {
117
+ waitForAsync = true,
118
+ timeout = 5000,
119
+ serializeState: includeState = true
120
+ } = options;
121
+
122
+ // Enable SSR mode
123
+ setSSRMode(true);
124
+
125
+ // Create isolated reactive context
126
+ const ctx = createContext({ name: 'ssr' });
127
+
128
+ // Create async context for data collection
129
+ const asyncCtx = new SSRAsyncContext();
130
+
131
+ // Create mock DOM adapter
132
+ const adapter = new MockDOMAdapter();
133
+
134
+ let html = '';
135
+ let state = null;
136
+
137
+ try {
138
+ // Run in isolated context
139
+ await ctx.run(async () => {
140
+ withAdapter(adapter, () => {
141
+ setSSRAsyncContext(asyncCtx);
142
+
143
+ // First render pass - collects async operations
144
+ const result = componentFactory();
145
+
146
+ if (result) {
147
+ adapter.appendChild(adapter.getBody(), result);
148
+ }
149
+ });
150
+
151
+ // Wait for async operations if requested
152
+ if (waitForAsync && asyncCtx.pendingCount > 0) {
153
+ try {
154
+ await asyncCtx.waitAll(timeout);
155
+ } catch (e) {
156
+ log.warn('SSR async timeout:', e.message);
157
+ }
158
+
159
+ // Second render pass with resolved data
160
+ withAdapter(adapter, () => {
161
+ adapter.reset();
162
+ const result = componentFactory();
163
+ if (result) {
164
+ adapter.appendChild(adapter.getBody(), result);
165
+ }
166
+ });
167
+ }
168
+
169
+ // Serialize to HTML
170
+ withAdapter(adapter, () => {
171
+ html = serializeChildren(adapter.getBody());
172
+
173
+ // Collect state if requested
174
+ if (includeState) {
175
+ state = asyncCtx.getAllResolved();
176
+ }
177
+
178
+ setSSRAsyncContext(null);
179
+ });
180
+ });
181
+ } finally {
182
+ // Clean up
183
+ setSSRMode(false);
184
+ ctx.reset();
185
+ }
186
+
187
+ return { html, state };
188
+ }
189
+
190
+ /**
191
+ * Render to string synchronously (no async data waiting).
192
+ * Use this when you don't need to wait for async operations.
193
+ *
194
+ * @param {Function} componentFactory - Function that returns the root component
195
+ * @returns {string} Rendered HTML string
196
+ *
197
+ * @example
198
+ * const html = renderToStringSync(() => StaticPage());
199
+ */
200
+ export function renderToStringSync(componentFactory) {
201
+ setSSRMode(true);
202
+ const ctx = createContext({ name: 'ssr-sync' });
203
+ const adapter = new MockDOMAdapter();
204
+
205
+ let html = '';
206
+
207
+ try {
208
+ ctx.run(() => {
209
+ withAdapter(adapter, () => {
210
+ const result = componentFactory();
211
+ if (result) {
212
+ adapter.appendChild(adapter.getBody(), result);
213
+ }
214
+ html = serializeChildren(adapter.getBody());
215
+ });
216
+ });
217
+ } finally {
218
+ setSSRMode(false);
219
+ ctx.reset();
220
+ }
221
+
222
+ return html;
223
+ }
224
+
225
+ // ============================================================================
226
+ // Client-Side Hydration
227
+ // ============================================================================
228
+
229
+ /**
230
+ * @typedef {Object} HydrateOptions
231
+ * @property {Object} [state] - Server state to restore
232
+ * @property {Function} [onMismatch] - Callback when hydration mismatch detected
233
+ */
234
+
235
+ /**
236
+ * Hydrate server-rendered HTML by attaching event listeners and
237
+ * connecting to the reactive system.
238
+ *
239
+ * @param {string|Element} target - CSS selector or DOM element
240
+ * @param {Function} componentFactory - Function that returns the root component
241
+ * @param {HydrateOptions} [options] - Hydration options
242
+ * @returns {Function} Cleanup function to dispose hydration
243
+ *
244
+ * @example
245
+ * // Basic hydration
246
+ * const dispose = hydrate('#app', () => App());
247
+ *
248
+ * // Later, if needed:
249
+ * dispose();
250
+ *
251
+ * @example
252
+ * // With state restoration
253
+ * hydrate('#app', () => App(), {
254
+ * state: window.__PULSE_STATE__,
255
+ * onMismatch: (expected, actual) => {
256
+ * console.warn('Hydration mismatch:', expected, actual);
257
+ * }
258
+ * });
259
+ */
260
+ export function hydrate(target, componentFactory, options = {}) {
261
+ const { state, onMismatch } = options;
262
+
263
+ // Get container element
264
+ const container = typeof target === 'string'
265
+ ? document.querySelector(target)
266
+ : target;
267
+
268
+ if (!container) {
269
+ throw new Error(`[Pulse SSR] Hydration target not found: ${target}`);
270
+ }
271
+
272
+ // Restore state if provided
273
+ if (state) {
274
+ restoreState(state);
275
+ }
276
+
277
+ // Create hydration context
278
+ const ctx = createHydrationContext(container);
279
+
280
+ if (onMismatch) {
281
+ ctx.onMismatch = onMismatch;
282
+ }
283
+
284
+ // Enable hydration mode
285
+ setHydrationMode(true, ctx);
286
+
287
+ try {
288
+ // Run component factory - this will attach listeners to existing DOM
289
+ componentFactory();
290
+ } finally {
291
+ // Disable hydration mode
292
+ setHydrationMode(false, null);
293
+ }
294
+
295
+ // Return cleanup function
296
+ return () => {
297
+ disposeHydration(ctx);
298
+ };
299
+ }
300
+
301
+ // ============================================================================
302
+ // State Serialization
303
+ // ============================================================================
304
+
305
+ /**
306
+ * Recursively preprocess a value for serialization.
307
+ * Converts Date and undefined to our special format.
308
+ * @private
309
+ */
310
+ function preprocessForSerialization(value, seen = new WeakSet()) {
311
+ // Handle null
312
+ if (value === null) return null;
313
+
314
+ // Handle undefined
315
+ if (value === undefined) return { __t: 'U' };
316
+
317
+ // Handle Date
318
+ if (value instanceof Date) {
319
+ return { __t: 'D', v: value.toISOString() };
320
+ }
321
+
322
+ // Handle functions - skip
323
+ if (typeof value === 'function') return undefined;
324
+
325
+ // Handle arrays
326
+ if (Array.isArray(value)) {
327
+ return value.map(item => preprocessForSerialization(item, seen));
328
+ }
329
+
330
+ // Handle objects
331
+ if (typeof value === 'object') {
332
+ // Circular reference check
333
+ if (seen.has(value)) {
334
+ return '[Circular]';
335
+ }
336
+ seen.add(value);
337
+
338
+ const result = {};
339
+ for (const [key, val] of Object.entries(value)) {
340
+ const processed = preprocessForSerialization(val, seen);
341
+ if (processed !== undefined) {
342
+ result[key] = processed;
343
+ }
344
+ }
345
+ return result;
346
+ }
347
+
348
+ // Primitives pass through
349
+ return value;
350
+ }
351
+
352
+ /**
353
+ * Serialize state for safe transfer from server to client.
354
+ * Handles special types like Date and undefined.
355
+ *
356
+ * @param {*} state - State to serialize
357
+ * @returns {string} JSON string safe for embedding in HTML
358
+ *
359
+ * @example
360
+ * const json = serializeState({ date: new Date(), name: 'Test' });
361
+ * // Can be safely embedded in <script> tag
362
+ */
363
+ export function serializeState(state) {
364
+ // Pre-process to handle Date and undefined before JSON.stringify
365
+ const preprocessed = preprocessForSerialization(state);
366
+
367
+ return JSON.stringify(preprocessed)
368
+ // Escape </script> to prevent XSS
369
+ .replace(/<\/script/gi, '<\\/script')
370
+ .replace(/<!--/g, '<\\!--');
371
+ }
372
+
373
+ /**
374
+ * Deserialize state received from server.
375
+ * Restores special types like Date.
376
+ *
377
+ * @param {string|Object} data - Serialized state (string or already parsed object)
378
+ * @returns {Object} Deserialized state
379
+ *
380
+ * @example
381
+ * const state = deserializeState(window.__PULSE_STATE__);
382
+ */
383
+ export function deserializeState(data) {
384
+ const parsed = typeof data === 'string' ? JSON.parse(data) : data;
385
+
386
+ return JSON.parse(JSON.stringify(parsed), (key, value) => {
387
+ if (value && typeof value === 'object') {
388
+ // Restore Date objects
389
+ if (value.__t === 'D') {
390
+ return new Date(value.v);
391
+ }
392
+ // Restore undefined
393
+ if (value.__t === 'U') {
394
+ return undefined;
395
+ }
396
+ }
397
+ return value;
398
+ });
399
+ }
400
+
401
+ /**
402
+ * Restore serialized state into the application.
403
+ * Override this function for custom state restoration logic.
404
+ *
405
+ * @param {Object} state - Deserialized state object
406
+ *
407
+ * @example
408
+ * // Default implementation just stores in global
409
+ * restoreState(window.__PULSE_STATE__);
410
+ */
411
+ export function restoreState(state) {
412
+ const deserialized = typeof state === 'string'
413
+ ? deserializeState(state)
414
+ : state;
415
+
416
+ // Store in global for access by components
417
+ if (typeof globalThis !== 'undefined') {
418
+ globalThis.__PULSE_SSR_STATE__ = deserialized;
419
+ }
420
+ }
421
+
422
+ /**
423
+ * Get restored SSR state.
424
+ * Use this in components to access server-fetched data.
425
+ *
426
+ * @param {string} [key] - Optional key to get specific value
427
+ * @returns {*} Full state or specific value
428
+ *
429
+ * @example
430
+ * const userData = getSSRState('user');
431
+ */
432
+ export function getSSRState(key) {
433
+ const state = globalThis?.__PULSE_SSR_STATE__ || {};
434
+ return key ? state[key] : state;
435
+ }
436
+
437
+ // ============================================================================
438
+ // Re-exports for convenience
439
+ // ============================================================================
440
+
441
+ export { isHydratingMode, getHydrationContext } from './ssr-hydrator.js';
442
+ export { getSSRAsyncContext } from './ssr-async.js';
443
+
444
+ // ============================================================================
445
+ // Default Export
446
+ // ============================================================================
447
+
448
+ export default {
449
+ // Core functions
450
+ renderToString,
451
+ renderToStringSync,
452
+ hydrate,
453
+
454
+ // State management
455
+ serializeState,
456
+ deserializeState,
457
+ restoreState,
458
+ getSSRState,
459
+
460
+ // Mode checks
461
+ isSSR,
462
+ isHydratingMode
463
+ };