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.
- package/cli/build.js +38 -0
- package/cli/index.js +12 -1
- package/cli/ssg.js +490 -0
- package/compiler/parser.js +20 -0
- package/compiler/transformer/view.js +80 -1
- package/package.json +19 -1
- package/runtime/form.js +604 -7
- package/runtime/ssr-mismatch.js +388 -0
- package/runtime/ssr-preload.js +228 -0
- package/runtime/ssr-serializer.js +3 -1
- package/runtime/ssr-stream.js +388 -0
- package/runtime/ssr.js +103 -3
- package/server/express.js +108 -0
- package/server/fastify.js +119 -0
- package/server/hono.js +107 -0
- package/server/index.js +208 -0
- package/server/utils.js +254 -0
|
@@ -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
|
|
369
|
-
.replace(
|
|
370
|
-
.replace(
|
|
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 };
|