pulse-js-framework 1.9.0 → 1.9.3

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,254 @@
1
+ /**
2
+ * Pulse Server Utilities
3
+ *
4
+ * Shared utilities for server framework adapters.
5
+ * Provides HTML injection, asset serving helpers, and request context.
6
+ *
7
+ * @module pulse-js-framework/server/utils
8
+ */
9
+
10
+ import { readFileSync, existsSync, statSync } from 'fs';
11
+ import { extname, resolve, sep } from 'path';
12
+
13
+ // ============================================================================
14
+ // MIME Types
15
+ // ============================================================================
16
+
17
+ const MIME_TYPES = {
18
+ '.html': 'text/html; charset=utf-8',
19
+ '.js': 'application/javascript; charset=utf-8',
20
+ '.mjs': 'application/javascript; charset=utf-8',
21
+ '.css': 'text/css; charset=utf-8',
22
+ '.json': 'application/json; charset=utf-8',
23
+ '.png': 'image/png',
24
+ '.jpg': 'image/jpeg',
25
+ '.jpeg': 'image/jpeg',
26
+ '.gif': 'image/gif',
27
+ '.svg': 'image/svg+xml',
28
+ '.ico': 'image/x-icon',
29
+ '.webp': 'image/webp',
30
+ '.avif': 'image/avif',
31
+ '.woff': 'font/woff',
32
+ '.woff2': 'font/woff2',
33
+ '.ttf': 'font/ttf',
34
+ '.eot': 'application/vnd.ms-fontobject',
35
+ '.otf': 'font/otf',
36
+ '.txt': 'text/plain; charset=utf-8',
37
+ '.xml': 'application/xml; charset=utf-8',
38
+ '.wasm': 'application/wasm',
39
+ '.map': 'application/json; charset=utf-8',
40
+ '.mp4': 'video/mp4',
41
+ '.webm': 'video/webm',
42
+ '.mp3': 'audio/mpeg',
43
+ '.ogg': 'audio/ogg'
44
+ };
45
+
46
+ /**
47
+ * Get MIME type for a file extension.
48
+ * @param {string} filePath - File path or extension
49
+ * @returns {string} MIME type
50
+ */
51
+ export function getMimeType(filePath) {
52
+ const ext = extname(filePath).toLowerCase();
53
+ return MIME_TYPES[ext] || 'application/octet-stream';
54
+ }
55
+
56
+ // ============================================================================
57
+ // HTML Template Injection
58
+ // ============================================================================
59
+
60
+ /**
61
+ * Inject rendered HTML and state into an HTML template.
62
+ *
63
+ * @param {string} template - HTML template string
64
+ * @param {Object} options - Injection options
65
+ * @param {string} [options.html] - Rendered app HTML
66
+ * @param {string} [options.state] - Serialized state script tag
67
+ * @param {string} [options.head] - Extra head content (preload hints, meta)
68
+ * @param {string} [options.bodyAttrs] - Extra body attributes
69
+ * @returns {string} Complete HTML page
70
+ */
71
+ export function injectIntoTemplate(template, options = {}) {
72
+ const { html = '', state = '', head = '', bodyAttrs = '' } = options;
73
+
74
+ let result = template;
75
+
76
+ // Inject app HTML
77
+ result = result.replace('<!--app-html-->', html);
78
+
79
+ // Inject state
80
+ result = result.replace('<!--app-state-->', state);
81
+
82
+ // Inject head content (preload hints, meta tags)
83
+ if (head) {
84
+ result = result.replace('</head>', `${head}\n</head>`);
85
+ }
86
+
87
+ // Inject body attributes
88
+ if (bodyAttrs) {
89
+ result = result.replace('<body', `<body ${bodyAttrs}`);
90
+ }
91
+
92
+ return result;
93
+ }
94
+
95
+ /**
96
+ * Read an HTML template from disk with caching.
97
+ * @param {string} templatePath - Path to HTML template
98
+ * @returns {string} Template content
99
+ */
100
+ const templateCache = new Map();
101
+
102
+ export function readTemplate(templatePath) {
103
+ if (templateCache.has(templatePath)) {
104
+ return templateCache.get(templatePath);
105
+ }
106
+
107
+ if (!existsSync(templatePath)) {
108
+ throw new Error(`[Pulse Server] Template not found: ${templatePath}`);
109
+ }
110
+
111
+ const content = readFileSync(templatePath, 'utf-8');
112
+ templateCache.set(templatePath, content);
113
+ return content;
114
+ }
115
+
116
+ /**
117
+ * Clear template cache (useful in development).
118
+ */
119
+ export function clearTemplateCache() {
120
+ templateCache.clear();
121
+ }
122
+
123
+ // ============================================================================
124
+ // Static Asset Serving
125
+ // ============================================================================
126
+
127
+ /**
128
+ * @typedef {Object} StaticAssetResult
129
+ * @property {Buffer|null} content - File content
130
+ * @property {string} mimeType - MIME type
131
+ * @property {number} [status] - HTTP status code
132
+ * @property {Object} [headers] - Response headers
133
+ */
134
+
135
+ /**
136
+ * Resolve a static asset from the dist directory.
137
+ *
138
+ * @param {string} pathname - Request pathname
139
+ * @param {string} distDir - Distribution directory
140
+ * @param {Object} [options] - Options
141
+ * @param {number} [options.maxAge=31536000] - Cache max-age for assets
142
+ * @param {boolean} [options.immutable=true] - Set immutable cache for hashed assets
143
+ * @returns {StaticAssetResult|null} Asset result or null if not found
144
+ */
145
+ export function resolveStaticAsset(pathname, distDir, options = {}) {
146
+ const { maxAge = 31536000, immutable = true } = options;
147
+
148
+ // Security: prevent directory traversal
149
+ const normalized = pathname.replace(/\.\./g, '').replace(/\/+/g, '/');
150
+ const resolvedDist = resolve(distDir) + sep;
151
+ const filePath = resolve(distDir, normalized);
152
+
153
+ // Must be within distDir (trailing sep prevents prefix attacks like dist-evil/)
154
+ if (!filePath.startsWith(resolvedDist) && filePath !== resolve(distDir)) {
155
+ return null;
156
+ }
157
+
158
+ if (!existsSync(filePath) || !statSync(filePath).isFile()) {
159
+ return null;
160
+ }
161
+
162
+ const content = readFileSync(filePath);
163
+ const mimeType = getMimeType(filePath);
164
+
165
+ // Hashed assets get long cache
166
+ const isHashed = /\.[a-f0-9]{8,}\.\w+$/.test(pathname);
167
+ const cacheControl = isHashed && immutable
168
+ ? `public, max-age=${maxAge}, immutable`
169
+ : `public, max-age=${Math.min(maxAge, 3600)}`;
170
+
171
+ return {
172
+ content,
173
+ mimeType,
174
+ status: 200,
175
+ headers: {
176
+ 'Content-Type': mimeType,
177
+ 'Content-Length': String(content.length),
178
+ 'Cache-Control': cacheControl
179
+ }
180
+ };
181
+ }
182
+
183
+ // ============================================================================
184
+ // Request Context
185
+ // ============================================================================
186
+
187
+ /**
188
+ * @typedef {Object} PulseRequestContext
189
+ * @property {string} url - Full URL
190
+ * @property {string} pathname - URL pathname
191
+ * @property {Object} query - Query parameters
192
+ * @property {string} method - HTTP method
193
+ * @property {Object} headers - Request headers
194
+ */
195
+
196
+ /**
197
+ * Create a Pulse request context from various framework request objects.
198
+ *
199
+ * @param {Object} req - Framework-specific request object
200
+ * @param {string} [type='generic'] - Framework type
201
+ * @returns {PulseRequestContext}
202
+ */
203
+ export function createRequestContext(req, type = 'generic') {
204
+ switch (type) {
205
+ case 'express':
206
+ return {
207
+ url: req.originalUrl || req.url,
208
+ pathname: req.path || new URL(req.url, 'http://localhost').pathname,
209
+ query: req.query || {},
210
+ method: req.method,
211
+ headers: req.headers || {}
212
+ };
213
+
214
+ case 'hono':
215
+ return {
216
+ url: req.url || '',
217
+ pathname: new URL(req.url, 'http://localhost').pathname,
218
+ query: Object.fromEntries(new URL(req.url, 'http://localhost').searchParams),
219
+ method: req.method,
220
+ headers: Object.fromEntries(req.headers || [])
221
+ };
222
+
223
+ case 'fastify':
224
+ return {
225
+ url: req.url,
226
+ pathname: req.routeOptions?.url || new URL(req.url, 'http://localhost').pathname,
227
+ query: req.query || {},
228
+ method: req.method,
229
+ headers: req.headers || {}
230
+ };
231
+
232
+ default:
233
+ return {
234
+ url: typeof req.url === 'string' ? req.url : '',
235
+ pathname: new URL(typeof req.url === 'string' ? req.url : '/', 'http://localhost').pathname,
236
+ query: {},
237
+ method: req.method || 'GET',
238
+ headers: req.headers || {}
239
+ };
240
+ }
241
+ }
242
+
243
+ // ============================================================================
244
+ // Exports
245
+ // ============================================================================
246
+
247
+ export default {
248
+ getMimeType,
249
+ injectIntoTemplate,
250
+ readTemplate,
251
+ clearTemplateCache,
252
+ resolveStaticAsset,
253
+ createRequestContext
254
+ };