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/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 +17 -1
- 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
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 };
|
|
@@ -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 };
|
package/server/index.js
ADDED
|
@@ -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
|
+
};
|