pulse-js-framework 1.9.0 → 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/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 };
@@ -0,0 +1,119 @@
1
+ /**
2
+ * Pulse Fastify Adapter
3
+ *
4
+ * Plugin for Fastify that handles SSR, static asset serving,
5
+ * and state serialization for Pulse applications.
6
+ *
7
+ * @module pulse-js-framework/server/fastify
8
+ *
9
+ * @example
10
+ * import Fastify from 'fastify';
11
+ * import { createFastifyPlugin } from 'pulse-js-framework/server/fastify';
12
+ * import App from './src/App.js';
13
+ *
14
+ * const app = Fastify();
15
+ *
16
+ * app.register(createFastifyPlugin({
17
+ * app: ({ route }) => App({ route }),
18
+ * templatePath: './dist/index.html',
19
+ * distDir: './dist'
20
+ * }));
21
+ *
22
+ * app.listen({ port: 3000 });
23
+ */
24
+
25
+ import { join } from 'path';
26
+ import { createPulseHandler, resolveStaticAsset } from './index.js';
27
+ import { createRequestContext } from './utils.js';
28
+
29
+ // ============================================================================
30
+ // Fastify Plugin
31
+ // ============================================================================
32
+
33
+ /**
34
+ * @typedef {import('./index.js').PulseMiddlewareOptions} PulseMiddlewareOptions
35
+ */
36
+
37
+ /**
38
+ * Create a Fastify plugin for Pulse SSR.
39
+ *
40
+ * @param {PulseMiddlewareOptions} options - Plugin options
41
+ * @returns {Function} Fastify plugin (fastify, opts, done)
42
+ *
43
+ * @example
44
+ * app.register(createFastifyPlugin({
45
+ * app: ({ route }) => App({ route }),
46
+ * templatePath: './dist/index.html',
47
+ * distDir: './dist',
48
+ * streaming: false
49
+ * }));
50
+ */
51
+ export function createFastifyPlugin(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 pulsePlugin(fastify, opts) {
57
+ // Serve static assets
58
+ if (serveStatic) {
59
+ fastify.get('/assets/*', async (request, reply) => {
60
+ const pathname = request.url;
61
+ const asset = resolveStaticAsset(pathname, resolvedDistDir);
62
+
63
+ if (asset) {
64
+ for (const [key, value] of Object.entries(asset.headers)) {
65
+ reply.header(key, value);
66
+ }
67
+ return reply.status(asset.status).send(asset.content);
68
+ }
69
+
70
+ reply.callNotFound();
71
+ });
72
+ }
73
+
74
+ // Catch-all route for SSR (skip /api/* routes)
75
+ fastify.get('*', async (request, reply) => {
76
+ const pathname = new URL(request.url, 'http://localhost').pathname;
77
+
78
+ // Skip API routes
79
+ if (pathname.startsWith('/api/')) {
80
+ return reply.callNotFound();
81
+ }
82
+
83
+ // Skip asset requests that weren't found
84
+ if (pathname.startsWith('/assets/')) {
85
+ return reply.callNotFound();
86
+ }
87
+
88
+ try {
89
+ const ctx = createRequestContext(request, 'fastify');
90
+ const result = await handler(ctx);
91
+
92
+ reply.status(result.status);
93
+ for (const [key, value] of Object.entries(result.headers)) {
94
+ reply.header(key, value);
95
+ }
96
+
97
+ // Handle streaming response
98
+ if (result.stream) {
99
+ const { Readable } = await import('stream');
100
+ const nodeStream = Readable.fromWeb(result.stream);
101
+ return reply.send(nodeStream);
102
+ }
103
+
104
+ return reply.send(result.body);
105
+ } catch (error) {
106
+ fastify.log.error(error, '[Pulse/Fastify] SSR Error');
107
+ reply.status(500).send(
108
+ '<!DOCTYPE html><html><body><h1>Internal Server Error</h1></body></html>'
109
+ );
110
+ }
111
+ });
112
+ };
113
+ }
114
+
115
+ // ============================================================================
116
+ // Exports
117
+ // ============================================================================
118
+
119
+ export default { createFastifyPlugin };
package/server/hono.js ADDED
@@ -0,0 +1,107 @@
1
+ /**
2
+ * Pulse Hono Adapter
3
+ *
4
+ * Middleware for Hono framework that handles SSR, static asset serving,
5
+ * and state serialization for Pulse applications.
6
+ *
7
+ * @module pulse-js-framework/server/hono
8
+ *
9
+ * @example
10
+ * import { Hono } from 'hono';
11
+ * import { createHonoMiddleware } from 'pulse-js-framework/server/hono';
12
+ * import App from './src/App.js';
13
+ *
14
+ * const app = new Hono();
15
+ *
16
+ * app.use('*', createHonoMiddleware({
17
+ * app: ({ route }) => App({ route }),
18
+ * templatePath: './dist/index.html',
19
+ * distDir: './dist'
20
+ * }));
21
+ *
22
+ * export default app;
23
+ */
24
+
25
+ import { join } from 'path';
26
+ import { createPulseHandler, resolveStaticAsset } from './index.js';
27
+ import { createRequestContext } from './utils.js';
28
+
29
+ // ============================================================================
30
+ // Hono Middleware
31
+ // ============================================================================
32
+
33
+ /**
34
+ * @typedef {import('./index.js').PulseMiddlewareOptions} PulseMiddlewareOptions
35
+ */
36
+
37
+ /**
38
+ * Create Hono middleware for Pulse SSR.
39
+ *
40
+ * @param {PulseMiddlewareOptions} options - Middleware options
41
+ * @returns {Function} Hono middleware (c, next)
42
+ *
43
+ * @example
44
+ * app.use('*', createHonoMiddleware({
45
+ * app: ({ route }) => App({ route }),
46
+ * templatePath: './dist/index.html',
47
+ * streaming: true // Hono supports streaming natively
48
+ * }));
49
+ */
50
+ export function createHonoMiddleware(options = {}) {
51
+ const { distDir = 'dist', serveStatic = true } = options;
52
+ const handler = createPulseHandler(options);
53
+ const resolvedDistDir = join(process.cwd(), distDir);
54
+
55
+ return async function pulseMiddleware(c, next) {
56
+ // Skip non-GET requests
57
+ if (c.req.method !== 'GET') {
58
+ return next();
59
+ }
60
+
61
+ const url = new URL(c.req.url);
62
+ const pathname = url.pathname;
63
+
64
+ // Serve static assets first
65
+ if (serveStatic && pathname.startsWith('/assets/')) {
66
+ const asset = resolveStaticAsset(pathname, resolvedDistDir);
67
+ if (asset) {
68
+ return new Response(asset.content, {
69
+ status: asset.status,
70
+ headers: asset.headers
71
+ });
72
+ }
73
+ }
74
+
75
+ // Skip API routes
76
+ if (pathname.startsWith('/api/')) {
77
+ return next();
78
+ }
79
+
80
+ try {
81
+ const ctx = createRequestContext(c.req.raw, 'hono');
82
+ const result = await handler(ctx);
83
+
84
+ // Handle streaming response (Hono supports Web Streams natively)
85
+ if (result.stream) {
86
+ return new Response(result.stream, {
87
+ status: result.status,
88
+ headers: result.headers
89
+ });
90
+ }
91
+
92
+ return c.html(result.body, result.status);
93
+ } catch (error) {
94
+ console.error('[Pulse/Hono] SSR Error:', error.message);
95
+ return c.html(
96
+ '<!DOCTYPE html><html><body><h1>Internal Server Error</h1></body></html>',
97
+ 500
98
+ );
99
+ }
100
+ };
101
+ }
102
+
103
+ // ============================================================================
104
+ // Exports
105
+ // ============================================================================
106
+
107
+ export default { createHonoMiddleware };
@@ -0,0 +1,208 @@
1
+ /**
2
+ * Pulse Server Framework Adapters
3
+ *
4
+ * Provides middleware factories for popular Node.js server frameworks.
5
+ * Handles SSR, static asset serving, and state serialization.
6
+ *
7
+ * @module pulse-js-framework/server
8
+ *
9
+ * @example
10
+ * // Express
11
+ * import express from 'express';
12
+ * import { createExpressMiddleware } from 'pulse-js-framework/server/express';
13
+ *
14
+ * const app = express();
15
+ * app.use(createExpressMiddleware({ app: () => App() }));
16
+ *
17
+ * @example
18
+ * // Hono
19
+ * import { Hono } from 'hono';
20
+ * import { createHonoMiddleware } from 'pulse-js-framework/server/hono';
21
+ *
22
+ * const app = new Hono();
23
+ * app.use('*', createHonoMiddleware({ app: () => App() }));
24
+ *
25
+ * @example
26
+ * // Fastify
27
+ * import Fastify from 'fastify';
28
+ * import { createFastifyPlugin } from 'pulse-js-framework/server/fastify';
29
+ *
30
+ * const app = Fastify();
31
+ * app.register(createFastifyPlugin({ app: () => App() }));
32
+ */
33
+
34
+ import {
35
+ injectIntoTemplate,
36
+ readTemplate,
37
+ clearTemplateCache,
38
+ resolveStaticAsset,
39
+ createRequestContext,
40
+ getMimeType
41
+ } from './utils.js';
42
+
43
+ // ============================================================================
44
+ // Common Middleware Factory
45
+ // ============================================================================
46
+
47
+ /**
48
+ * @typedef {Object} PulseMiddlewareOptions
49
+ * @property {Function} app - Component factory: (ctx) => App()
50
+ * @property {string} [template] - HTML template string
51
+ * @property {string} [templatePath] - Path to HTML template file
52
+ * @property {string} [distDir='dist'] - Distribution directory for static assets
53
+ * @property {boolean} [streaming=false] - Enable streaming SSR
54
+ * @property {number} [timeout=5000] - SSR timeout (ms)
55
+ * @property {Function} [onError] - Error handler: (error, ctx) => void
56
+ * @property {Function} [getPreloadHints] - Preload hint generator
57
+ * @property {boolean} [serveStatic=true] - Serve static assets from distDir
58
+ */
59
+
60
+ /**
61
+ * Create a generic Pulse SSR handler.
62
+ * This is the core logic shared by all framework adapters.
63
+ *
64
+ * @param {PulseMiddlewareOptions} options - Middleware options
65
+ * @returns {Function} Handler: (requestContext) => Promise<{status, headers, body}>
66
+ */
67
+ export function createPulseHandler(options = {}) {
68
+ const {
69
+ app: appFactory,
70
+ template: templateStr,
71
+ templatePath,
72
+ distDir = 'dist',
73
+ streaming = false,
74
+ timeout = 5000,
75
+ onError,
76
+ getPreloadHints
77
+ } = options;
78
+
79
+ if (!appFactory) {
80
+ throw new Error('[Pulse Server] "app" option is required: provide a component factory function.');
81
+ }
82
+
83
+ return async function handleRequest(requestContext) {
84
+ try {
85
+ // Get template
86
+ const template = templateStr || (templatePath ? readTemplate(templatePath) : getDefaultTemplate());
87
+
88
+ // Generate preload hints if available
89
+ const head = getPreloadHints ? getPreloadHints(requestContext.pathname) : '';
90
+
91
+ if (streaming) {
92
+ return await handleStreamingSSR(appFactory, template, requestContext, { timeout, head });
93
+ }
94
+
95
+ return await handleStringSSR(appFactory, template, requestContext, { timeout, head });
96
+ } catch (error) {
97
+ if (onError) {
98
+ onError(error, requestContext);
99
+ }
100
+
101
+ return {
102
+ status: 500,
103
+ headers: { 'Content-Type': 'text/html; charset=utf-8' },
104
+ body: '<!DOCTYPE html><html><body><h1>Internal Server Error</h1></body></html>'
105
+ };
106
+ }
107
+ };
108
+ }
109
+
110
+ /**
111
+ * Handle string-based SSR (full page render).
112
+ * @private
113
+ */
114
+ async function handleStringSSR(appFactory, template, ctx, options = {}) {
115
+ const { renderToString, serializeState } = await import('../runtime/ssr.js');
116
+
117
+ const { html, state } = await renderToString(
118
+ () => appFactory({ route: ctx.pathname, query: ctx.query }),
119
+ { waitForAsync: true, timeout: options.timeout, serializeState: true }
120
+ );
121
+
122
+ const stateScript = state
123
+ ? `<script>window.__PULSE_STATE__=${serializeState(state)};</script>`
124
+ : '';
125
+
126
+ const page = injectIntoTemplate(template, {
127
+ html,
128
+ state: stateScript,
129
+ head: options.head || ''
130
+ });
131
+
132
+ return {
133
+ status: 200,
134
+ headers: { 'Content-Type': 'text/html; charset=utf-8' },
135
+ body: page
136
+ };
137
+ }
138
+
139
+ /**
140
+ * Handle streaming SSR.
141
+ * @private
142
+ */
143
+ async function handleStreamingSSR(appFactory, template, ctx, options = {}) {
144
+ const { renderToStream } = await import('../runtime/ssr-stream.js');
145
+
146
+ // Split template at app placeholder
147
+ const [shellStart, shellEnd] = template.split('<!--app-html-->');
148
+
149
+ const headContent = options.head || '';
150
+ const shellStartWithHead = headContent
151
+ ? shellStart.replace('</head>', `${headContent}\n</head>`)
152
+ : shellStart;
153
+
154
+ const stream = renderToStream(
155
+ () => appFactory({ route: ctx.pathname, query: ctx.query }),
156
+ {
157
+ shellStart: shellStartWithHead,
158
+ shellEnd: shellEnd || '',
159
+ timeout: options.timeout
160
+ }
161
+ );
162
+
163
+ return {
164
+ status: 200,
165
+ headers: { 'Content-Type': 'text/html; charset=utf-8', 'Transfer-Encoding': 'chunked' },
166
+ stream
167
+ };
168
+ }
169
+
170
+ /**
171
+ * Get default HTML template.
172
+ * @returns {string}
173
+ */
174
+ function getDefaultTemplate() {
175
+ return `<!DOCTYPE html>
176
+ <html lang="en">
177
+ <head>
178
+ <meta charset="UTF-8">
179
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
180
+ <title>Pulse App</title>
181
+ </head>
182
+ <body>
183
+ <div id="app"><!--app-html--></div>
184
+ <!--app-state-->
185
+ <script type="module" src="/assets/main.js"></script>
186
+ </body>
187
+ </html>`;
188
+ }
189
+
190
+ // ============================================================================
191
+ // Re-exports
192
+ // ============================================================================
193
+
194
+ export { injectIntoTemplate, readTemplate, clearTemplateCache, resolveStaticAsset, createRequestContext, getMimeType };
195
+
196
+ // ============================================================================
197
+ // Default Export
198
+ // ============================================================================
199
+
200
+ export default {
201
+ createPulseHandler,
202
+ injectIntoTemplate,
203
+ readTemplate,
204
+ clearTemplateCache,
205
+ resolveStaticAsset,
206
+ createRequestContext,
207
+ getMimeType
208
+ };