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/README.md +46 -0
- package/cli/help.js +583 -0
- package/cli/index.js +24 -105
- package/package.json +16 -3
- package/runtime/async.js +39 -0
- package/runtime/dom-adapter.js +663 -0
- package/runtime/dom-element.js +107 -0
- package/runtime/index.js +2 -0
- package/runtime/pulse.js +40 -0
- package/runtime/ssr-async.js +229 -0
- package/runtime/ssr-hydrator.js +310 -0
- package/runtime/ssr-serializer.js +266 -0
- package/runtime/ssr.js +463 -0
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
|
+
};
|