pulse-js-framework 1.8.3 → 1.9.2

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.
@@ -0,0 +1,388 @@
1
+ /**
2
+ * Pulse SSR Streaming - Streaming server-side rendering
3
+ *
4
+ * Provides streaming SSR using Web Streams API (ReadableStream).
5
+ * Sends shell HTML immediately, streams async content as it resolves.
6
+ * Compatible with Node.js pipe() via Readable.fromWeb() and Web Streams.
7
+ *
8
+ * @module pulse-js-framework/runtime/ssr-stream
9
+ */
10
+
11
+ import { createContext, batch, setSSRMode as setPulseSSRMode } from './pulse.js';
12
+ import { MockDOMAdapter, withAdapter } from './dom-adapter.js';
13
+ import { serializeToHTML, serializeChildren, escapeAttr } from './ssr-serializer.js';
14
+ import { SSRAsyncContext, setSSRAsyncContext } from './ssr-async.js';
15
+ import { loggers } from './logger.js';
16
+
17
+ const log = loggers.dom;
18
+
19
+ // ============================================================================
20
+ // Constants
21
+ // ============================================================================
22
+
23
+ /**
24
+ * Minimal client-side script that replaces streaming boundary markers
25
+ * with resolved content. Injected once into the stream.
26
+ * ~200 bytes minified.
27
+ */
28
+ const STREAMING_RUNTIME_SCRIPT = `<script>function $P(i,h){var b=document.querySelectorAll('[data-ssr-boundary="'+i+'"]');if(b.length===2){var r=document.createRange();r.setStartAfter(b[0]);r.setEndBefore(b[1]);r.deleteContents();var t=document.createElement('template');t.innerHTML=h;r.insertNode(t.content);b[0].remove();b[1].remove()}}</script>`;
29
+
30
+ // ============================================================================
31
+ // SSR Stream Context
32
+ // ============================================================================
33
+
34
+ /**
35
+ * Manages streaming SSR state: boundary tracking, chunk queue, flush control.
36
+ */
37
+ export class SSRStreamContext {
38
+ constructor(options = {}) {
39
+ /** @type {number} Next boundary ID */
40
+ this._nextId = 0;
41
+
42
+ /** @type {Map<number, {promise: Promise, fallback: string}>} */
43
+ this.boundaries = new Map();
44
+
45
+ /** @type {boolean} */
46
+ this.shellFlushed = false;
47
+
48
+ /** @type {number} */
49
+ this.timeout = options.timeout ?? 10000;
50
+
51
+ /** @type {Function|null} */
52
+ this.onShellError = options.onShellError ?? null;
53
+
54
+ /** @type {Function|null} */
55
+ this.onBoundaryError = options.onBoundaryError ?? null;
56
+ }
57
+
58
+ /**
59
+ * Create a new boundary ID for an async chunk.
60
+ * @returns {number}
61
+ */
62
+ createBoundary() {
63
+ return this._nextId++;
64
+ }
65
+
66
+ /**
67
+ * Register an async boundary with its promise and fallback HTML.
68
+ * @param {number} id - Boundary ID
69
+ * @param {Promise} promise - Promise that resolves to content
70
+ * @param {string} fallback - Fallback HTML shown while loading
71
+ */
72
+ registerBoundary(id, promise, fallback) {
73
+ this.boundaries.set(id, { promise, fallback });
74
+ }
75
+
76
+ /**
77
+ * Get the number of pending boundaries.
78
+ * @returns {number}
79
+ */
80
+ get pendingCount() {
81
+ return this.boundaries.size;
82
+ }
83
+ }
84
+
85
+ // ============================================================================
86
+ // Boundary Markers
87
+ // ============================================================================
88
+
89
+ /**
90
+ * Create opening boundary marker comment.
91
+ * @param {number} id - Boundary ID
92
+ * @returns {string}
93
+ */
94
+ export function createBoundaryStart(id) {
95
+ return `<!--$B:${id}-->`;
96
+ }
97
+
98
+ /**
99
+ * Create closing boundary marker comment.
100
+ * @param {number} id - Boundary ID
101
+ * @returns {string}
102
+ */
103
+ export function createBoundaryEnd(id) {
104
+ return `<!--/$B:${id}-->`;
105
+ }
106
+
107
+ /**
108
+ * Create boundary marker pair with data attributes for client-side replacement.
109
+ * @param {number} id - Boundary ID
110
+ * @param {string} fallbackHtml - Fallback content HTML
111
+ * @returns {string}
112
+ */
113
+ export function createBoundaryMarker(id, fallbackHtml) {
114
+ return (
115
+ `<template data-ssr-boundary="${id}" style="display:none"></template>` +
116
+ fallbackHtml +
117
+ `<template data-ssr-boundary="${id}" style="display:none"></template>`
118
+ );
119
+ }
120
+
121
+ /**
122
+ * Create a replacement script that swaps boundary content on the client.
123
+ * @param {number} id - Boundary ID
124
+ * @param {string} html - Resolved content HTML
125
+ * @returns {string}
126
+ */
127
+ export function createReplacementScript(id, html) {
128
+ // Use JSON.stringify for safe JS string embedding, then escape HTML-sensitive sequences
129
+ const escaped = JSON.stringify(html)
130
+ .replace(/<\/script/gi, '<\\u002fscript')
131
+ .replace(/<!--/g, '<\\u0021--');
132
+ return `<script>$P(${Number(id)},${escaped})</script>`;
133
+ }
134
+
135
+ // ============================================================================
136
+ // Streaming SSR
137
+ // ============================================================================
138
+
139
+ /**
140
+ * @typedef {Object} RenderToStreamOptions
141
+ * @property {string} [shellStart] - HTML before app content
142
+ * @property {string} [shellEnd] - HTML after app content
143
+ * @property {number} [timeout=10000] - Timeout for async boundaries (ms)
144
+ * @property {Function} [onShellError] - Error callback during shell render
145
+ * @property {Function} [onBoundaryError] - Error callback for async boundary
146
+ * @property {string[]} [bootstrapScripts] - Script URLs to include in shell
147
+ * @property {string[]} [bootstrapModules] - Module script URLs to include
148
+ * @property {boolean} [generatePreloadHints=false] - Generate link preload tags
149
+ */
150
+
151
+ /**
152
+ * Render a component tree to a ReadableStream.
153
+ *
154
+ * Sends the HTML shell immediately, then streams async content
155
+ * as each boundary resolves. The browser renders progressive content
156
+ * without needing client-side JavaScript for the initial swap.
157
+ *
158
+ * @param {Function} componentFactory - Function that returns the root component
159
+ * @param {RenderToStreamOptions} [options] - Streaming options
160
+ * @returns {ReadableStream} HTML stream
161
+ *
162
+ * @example
163
+ * const stream = renderToStream(() => App(), {
164
+ * shellStart: '<!DOCTYPE html><html><head></head><body><div id="app">',
165
+ * shellEnd: '</div></body></html>',
166
+ * timeout: 5000
167
+ * });
168
+ *
169
+ * // Web Streams
170
+ * return new Response(stream, { headers: { 'Content-Type': 'text/html' } });
171
+ *
172
+ * // Node.js pipe
173
+ * import { Readable } from 'stream';
174
+ * Readable.fromWeb(stream).pipe(res);
175
+ */
176
+ export function renderToStream(componentFactory, options = {}) {
177
+ const {
178
+ shellStart = '',
179
+ shellEnd = '',
180
+ timeout = 10000,
181
+ onShellError = null,
182
+ onBoundaryError = null,
183
+ bootstrapScripts = [],
184
+ bootstrapModules = []
185
+ } = options;
186
+
187
+ const streamCtx = new SSRStreamContext({ timeout, onShellError, onBoundaryError });
188
+
189
+ let streamController;
190
+
191
+ const stream = new ReadableStream({
192
+ start(controller) {
193
+ streamController = controller;
194
+ const encoder = new TextEncoder();
195
+
196
+ // Run SSR in microtask to allow stream to be returned first
197
+ Promise.resolve().then(async () => {
198
+ try {
199
+ // Enable SSR mode
200
+ setPulseSSRMode(true);
201
+
202
+ const ctx = createContext({ name: 'ssr-stream' });
203
+ const asyncCtx = new SSRAsyncContext();
204
+ const adapter = new MockDOMAdapter();
205
+
206
+ let shellHtml = '';
207
+
208
+ try {
209
+ // Render shell synchronously
210
+ ctx.run(() => {
211
+ withAdapter(adapter, () => {
212
+ setSSRAsyncContext(asyncCtx);
213
+
214
+ const result = componentFactory();
215
+ if (result) {
216
+ adapter.appendChild(adapter.getBody(), result);
217
+ }
218
+
219
+ shellHtml = serializeChildren(adapter.getBody());
220
+ setSSRAsyncContext(null);
221
+ });
222
+ });
223
+ } catch (shellError) {
224
+ if (onShellError) {
225
+ onShellError(shellError);
226
+ }
227
+ log.error('SSR shell render error:', shellError.message);
228
+ controller.close();
229
+ setPulseSSRMode(false);
230
+ ctx.reset();
231
+ return;
232
+ }
233
+
234
+ // Build script tags
235
+ let scriptTags = '';
236
+ for (const src of bootstrapScripts) {
237
+ scriptTags += `<script src="${escapeAttr(src)}"></script>`;
238
+ }
239
+ for (const src of bootstrapModules) {
240
+ scriptTags += `<script type="module" src="${escapeAttr(src)}"></script>`;
241
+ }
242
+
243
+ // Enqueue shell HTML
244
+ const shellContent = shellStart + shellHtml + scriptTags;
245
+ controller.enqueue(encoder.encode(shellContent));
246
+ streamCtx.shellFlushed = true;
247
+
248
+ // If there are pending async operations, stream them
249
+ if (asyncCtx.pendingCount > 0) {
250
+ // Inject streaming runtime script
251
+ controller.enqueue(encoder.encode(STREAMING_RUNTIME_SCRIPT));
252
+
253
+ // Wait for each async operation and stream results
254
+ const boundaryPromises = [];
255
+
256
+ for (const { key, promise } of asyncCtx.pending) {
257
+ const boundaryId = streamCtx.createBoundary();
258
+
259
+ const boundaryPromise = Promise.resolve(promise)
260
+ .then(async () => {
261
+ // Re-render with resolved data
262
+ const boundaryAdapter = new MockDOMAdapter();
263
+ const boundaryCtx = createContext({ name: `ssr-boundary-${boundaryId}` });
264
+
265
+ try {
266
+ let boundaryHtml = '';
267
+ boundaryCtx.run(() => {
268
+ withAdapter(boundaryAdapter, () => {
269
+ setSSRAsyncContext(asyncCtx);
270
+ const result = componentFactory();
271
+ if (result) {
272
+ boundaryAdapter.appendChild(boundaryAdapter.getBody(), result);
273
+ }
274
+ boundaryHtml = serializeChildren(boundaryAdapter.getBody());
275
+ setSSRAsyncContext(null);
276
+ });
277
+ });
278
+
279
+ const script = createReplacementScript(boundaryId, boundaryHtml);
280
+ controller.enqueue(encoder.encode(script));
281
+ } finally {
282
+ boundaryCtx.reset();
283
+ }
284
+ })
285
+ .catch(err => {
286
+ if (onBoundaryError) {
287
+ onBoundaryError(boundaryId, err);
288
+ }
289
+ log.warn(`SSR boundary ${boundaryId} error:`, err.message);
290
+ });
291
+
292
+ boundaryPromises.push(boundaryPromise);
293
+ }
294
+
295
+ // Wait for all boundaries with timeout
296
+ const timeoutPromise = new Promise(resolve => {
297
+ setTimeout(() => {
298
+ log.warn(`SSR streaming timed out after ${timeout}ms`);
299
+ resolve();
300
+ }, timeout);
301
+ });
302
+
303
+ await Promise.race([
304
+ Promise.allSettled(boundaryPromises),
305
+ timeoutPromise
306
+ ]);
307
+ }
308
+
309
+ // Enqueue shell end and close
310
+ if (shellEnd) {
311
+ controller.enqueue(encoder.encode(shellEnd));
312
+ }
313
+ controller.close();
314
+
315
+ // Clean up
316
+ setPulseSSRMode(false);
317
+ ctx.reset();
318
+
319
+ } catch (err) {
320
+ log.error('SSR stream error:', err.message);
321
+ try {
322
+ controller.error(err);
323
+ } catch {
324
+ // Controller may already be closed
325
+ }
326
+ setPulseSSRMode(false);
327
+ }
328
+ });
329
+ },
330
+
331
+ cancel() {
332
+ // Stream was cancelled by consumer
333
+ setPulseSSRMode(false);
334
+ }
335
+ });
336
+
337
+ return stream;
338
+ }
339
+
340
+ /**
341
+ * Render to a ReadableStream with abort control.
342
+ *
343
+ * @param {Function} componentFactory - Function that returns the root component
344
+ * @param {RenderToStreamOptions} [options] - Streaming options
345
+ * @returns {{ stream: ReadableStream, abort: Function }}
346
+ *
347
+ * @example
348
+ * const { stream, abort } = renderToReadableStream(() => App(), options);
349
+ * // Later: abort() to cancel streaming
350
+ */
351
+ export function renderToReadableStream(componentFactory, options = {}) {
352
+ let abortController;
353
+
354
+ // Wrap with abort support
355
+ const wrappedOptions = {
356
+ ...options,
357
+ onShellError: (err) => {
358
+ options.onShellError?.(err);
359
+ }
360
+ };
361
+
362
+ const stream = renderToStream(componentFactory, wrappedOptions);
363
+
364
+ const abort = () => {
365
+ try {
366
+ stream.cancel('Aborted by caller');
367
+ } catch {
368
+ // Stream may already be closed
369
+ }
370
+ };
371
+
372
+ return { stream, abort };
373
+ }
374
+
375
+ // ============================================================================
376
+ // Exports
377
+ // ============================================================================
378
+
379
+ export default {
380
+ SSRStreamContext,
381
+ createBoundaryStart,
382
+ createBoundaryEnd,
383
+ createBoundaryMarker,
384
+ createReplacementScript,
385
+ renderToStream,
386
+ renderToReadableStream,
387
+ STREAMING_RUNTIME_SCRIPT
388
+ };
package/runtime/ssr.js CHANGED
@@ -2,6 +2,8 @@
2
2
  * Pulse SSR - Server-Side Rendering Module
3
3
  *
4
4
  * Provides server-side rendering and client-side hydration for Pulse applications.
5
+ * Includes streaming SSR, selective rendering (@client/@server), and hydration
6
+ * mismatch detection.
5
7
  *
6
8
  * @module pulse-js-framework/runtime/ssr
7
9
  *
@@ -25,6 +27,15 @@
25
27
  * hydrate('#app', () => App(), {
26
28
  * state: window.__PULSE_STATE__
27
29
  * });
30
+ *
31
+ * @example
32
+ * // Streaming SSR
33
+ * import { renderToStream } from 'pulse-js-framework/runtime/ssr';
34
+ *
35
+ * const stream = renderToStream(() => App(), {
36
+ * shellStart: '<!DOCTYPE html><html><body><div id="app">',
37
+ * shellEnd: '</div></body></html>'
38
+ * });
28
39
  */
29
40
 
30
41
  import { createContext, batch, setSSRMode as setPulseSSRMode, isSSRMode } from './pulse.js';
@@ -298,6 +309,76 @@ export function hydrate(target, componentFactory, options = {}) {
298
309
  };
299
310
  }
300
311
 
312
+ // ============================================================================
313
+ // Selective SSR: ClientOnly / ServerOnly (#39)
314
+ // ============================================================================
315
+
316
+ /**
317
+ * Render content only on the client side. During SSR, renders the fallback
318
+ * (or an empty comment node). On the client, renders the main content.
319
+ *
320
+ * Use this for browser-only components (charts, maps, animations, etc.)
321
+ * that would break during server-side rendering.
322
+ *
323
+ * @param {Function} clientFactory - Factory function for client-side content
324
+ * @param {Function} [fallbackFactory] - Optional fallback factory for SSR
325
+ * @returns {*} DOM node(s) or comment placeholder
326
+ *
327
+ * @example
328
+ * import { ClientOnly } from 'pulse-js-framework/runtime/ssr';
329
+ *
330
+ * const chart = ClientOnly(
331
+ * () => el('canvas.chart', { onmount: initChart }),
332
+ * () => el('.placeholder', 'Chart loading...')
333
+ * );
334
+ *
335
+ * @example
336
+ * // In .pulse file with @client directive:
337
+ * // .chart @client { canvas "Loading..." }
338
+ */
339
+ export function ClientOnly(clientFactory, fallbackFactory) {
340
+ if (isSSR()) {
341
+ // During SSR: render fallback or empty placeholder
342
+ if (fallbackFactory) {
343
+ return fallbackFactory();
344
+ }
345
+ // Return a comment node as placeholder
346
+ const adapter = getAdapter();
347
+ return adapter.createComment('client-only');
348
+ }
349
+ // On client: render the actual content
350
+ return clientFactory();
351
+ }
352
+
353
+ /**
354
+ * Render content only during SSR. On the client, renders an empty comment node.
355
+ *
356
+ * Use this for server-only content like JSON-LD structured data,
357
+ * meta tags, or server-side analytics scripts.
358
+ *
359
+ * @param {Function} serverFactory - Factory function for server-side content
360
+ * @returns {*} DOM node(s) or comment placeholder
361
+ *
362
+ * @example
363
+ * import { ServerOnly } from 'pulse-js-framework/runtime/ssr';
364
+ *
365
+ * const seoData = ServerOnly(
366
+ * () => el('script[type=application/ld+json]', JSON.stringify(schema))
367
+ * );
368
+ *
369
+ * @example
370
+ * // In .pulse file with @server directive:
371
+ * // .seo @server { script[type=application/ld+json] "{seoJson}" }
372
+ */
373
+ export function ServerOnly(serverFactory) {
374
+ if (isSSR()) {
375
+ return serverFactory();
376
+ }
377
+ // On client: empty placeholder
378
+ const adapter = getAdapter();
379
+ return adapter.createComment('server-only');
380
+ }
381
+
301
382
  // ============================================================================
302
383
  // State Serialization
303
384
  // ============================================================================
@@ -365,9 +446,12 @@ export function serializeState(state) {
365
446
  const preprocessed = preprocessForSerialization(state);
366
447
 
367
448
  return JSON.stringify(preprocessed)
368
- // Escape </script> to prevent XSS
369
- .replace(/<\/script/gi, '<\\/script')
370
- .replace(/<!--/g, '<\\!--');
449
+ // Escape all < and > to prevent HTML parser interference in <script> context
450
+ .replace(/</g, '\\u003c')
451
+ .replace(/>/g, '\\u003e')
452
+ // Escape unicode line/paragraph separators (invalid in JS strings pre-ES2019)
453
+ .replace(/\u2028/g, '\\u2028')
454
+ .replace(/\u2029/g, '\\u2029');
371
455
  }
372
456
 
373
457
  /**
@@ -455,6 +539,15 @@ export function clearSSRState() {
455
539
  export { isHydratingMode, getHydrationContext } from './ssr-hydrator.js';
456
540
  export { getSSRAsyncContext } from './ssr-async.js';
457
541
 
542
+ // Streaming SSR (#38)
543
+ export { renderToStream, renderToReadableStream } from './ssr-stream.js';
544
+
545
+ // Hydration mismatch detection (#44)
546
+ export { MismatchType, diffNodes, logMismatches, getSuggestion } from './ssr-mismatch.js';
547
+
548
+ // Preload hints (#42)
549
+ export { generatePreloadHints, getRoutePreloads, parseBuildManifest, createPreloadMiddleware, hintsToHTML } from './ssr-preload.js';
550
+
458
551
  // ============================================================================
459
552
  // Default Export
460
553
  // ============================================================================
@@ -465,6 +558,13 @@ export default {
465
558
  renderToStringSync,
466
559
  hydrate,
467
560
 
561
+ // Streaming
562
+ // renderToStream and renderToReadableStream are re-exported above
563
+
564
+ // Selective rendering
565
+ ClientOnly,
566
+ ServerOnly,
567
+
468
568
  // State management
469
569
  serializeState,
470
570
  deserializeState,
@@ -0,0 +1,108 @@
1
+ /**
2
+ * Pulse Express Adapter
3
+ *
4
+ * Middleware for Express.js that handles SSR, static asset serving,
5
+ * and state serialization for Pulse applications.
6
+ *
7
+ * @module pulse-js-framework/server/express
8
+ *
9
+ * @example
10
+ * import express from 'express';
11
+ * import { createExpressMiddleware } from 'pulse-js-framework/server/express';
12
+ * import App from './src/App.js';
13
+ *
14
+ * const app = express();
15
+ *
16
+ * app.use(createExpressMiddleware({
17
+ * app: ({ route }) => App({ route }),
18
+ * templatePath: './dist/index.html',
19
+ * distDir: './dist'
20
+ * }));
21
+ *
22
+ * app.listen(3000);
23
+ */
24
+
25
+ import { join } from 'path';
26
+ import { createPulseHandler, resolveStaticAsset } from './index.js';
27
+ import { createRequestContext } from './utils.js';
28
+
29
+ // ============================================================================
30
+ // Express Middleware
31
+ // ============================================================================
32
+
33
+ /**
34
+ * @typedef {import('./index.js').PulseMiddlewareOptions} PulseMiddlewareOptions
35
+ */
36
+
37
+ /**
38
+ * Create Express middleware for Pulse SSR.
39
+ *
40
+ * @param {PulseMiddlewareOptions} options - Middleware options
41
+ * @returns {Function} Express middleware (req, res, next)
42
+ *
43
+ * @example
44
+ * app.use(createExpressMiddleware({
45
+ * app: ({ route }) => App({ route }),
46
+ * templatePath: './dist/index.html',
47
+ * distDir: './dist',
48
+ * streaming: false
49
+ * }));
50
+ */
51
+ export function createExpressMiddleware(options = {}) {
52
+ const { distDir = 'dist', serveStatic = true } = options;
53
+ const handler = createPulseHandler(options);
54
+ const resolvedDistDir = join(process.cwd(), distDir);
55
+
56
+ return async function pulseMiddleware(req, res, next) {
57
+ // Skip non-GET requests
58
+ if (req.method !== 'GET') {
59
+ return next();
60
+ }
61
+
62
+ const pathname = req.path || req.url;
63
+
64
+ // Serve static assets first
65
+ if (serveStatic && pathname.startsWith('/assets/')) {
66
+ const asset = resolveStaticAsset(pathname, resolvedDistDir);
67
+ if (asset) {
68
+ for (const [key, value] of Object.entries(asset.headers)) {
69
+ res.setHeader(key, value);
70
+ }
71
+ return res.status(asset.status).end(asset.content);
72
+ }
73
+ }
74
+
75
+ // Skip API routes
76
+ if (pathname.startsWith('/api/')) {
77
+ return next();
78
+ }
79
+
80
+ try {
81
+ const ctx = createRequestContext(req, 'express');
82
+ const result = await handler(ctx);
83
+
84
+ res.status(result.status);
85
+ for (const [key, value] of Object.entries(result.headers)) {
86
+ res.setHeader(key, value);
87
+ }
88
+
89
+ // Handle streaming response
90
+ if (result.stream) {
91
+ const { Readable } = await import('stream');
92
+ const nodeStream = Readable.fromWeb(result.stream);
93
+ nodeStream.pipe(res);
94
+ return;
95
+ }
96
+
97
+ res.end(result.body);
98
+ } catch (error) {
99
+ next(error);
100
+ }
101
+ };
102
+ }
103
+
104
+ // ============================================================================
105
+ // Exports
106
+ // ============================================================================
107
+
108
+ export default { createExpressMiddleware };