@qorejs/qore 0.6.0

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/src/ssr.ts ADDED
@@ -0,0 +1,341 @@
1
+ /**
2
+ * Qore SSR - Server-Side Rendering
3
+ * Complete SSR support with streaming, suspense, and data prefetching
4
+ */
5
+
6
+ import { StreamRenderer } from './stream';
7
+ import type { VNode, Component } from './render';
8
+
9
+ // HTML escaping
10
+ function escapeHtml(str: string): string {
11
+ return str
12
+ .replace(/&/g, '&')
13
+ .replace(/</g, '&lt;')
14
+ .replace(/>/g, '&gt;')
15
+ .replace(/"/g, '&quot;')
16
+ .replace(/'/g, '&#039;');
17
+ }
18
+
19
+ /**
20
+ * Convert VNode to HTML string (SSR)
21
+ */
22
+ export function renderToString(vnode: VNode): string {
23
+ if (vnode == null || vnode === false) return '';
24
+
25
+ if (typeof vnode === 'string') {
26
+ return escapeHtml(vnode);
27
+ }
28
+
29
+ if (typeof vnode === 'number') {
30
+ return String(vnode);
31
+ }
32
+
33
+ if (Array.isArray(vnode)) {
34
+ return vnode.map(v => renderToString(v)).join('');
35
+ }
36
+
37
+ if (typeof vnode === 'function') {
38
+ // Component function
39
+ try {
40
+ const result = vnode();
41
+ return renderToString(result);
42
+ } catch (err) {
43
+ console.error('Component render error:', err);
44
+ return '<!-- Error -->';
45
+ }
46
+ }
47
+
48
+ // Already a rendered node (shouldn't happen in SSR)
49
+ return '';
50
+ }
51
+
52
+ /**
53
+ * Render component to HTML string (SSR)
54
+ */
55
+ export function renderComponentToString(component: Component): string {
56
+ return renderToString(component());
57
+ }
58
+
59
+ /**
60
+ * Render props object to HTML attributes string
61
+ */
62
+ export function renderProps(props: Record<string, any> | null): string {
63
+ if (!props) return '';
64
+
65
+ const attrs: string[] = [];
66
+ for (const [key, value] of Object.entries(props)) {
67
+ if (key === 'children' || key === 'key' || value == null) continue;
68
+
69
+ if (key.startsWith('on')) continue; // Skip event handlers
70
+
71
+ if (key === 'className') {
72
+ attrs.push(`class="${escapeHtml(String(value))}"`);
73
+ } else if (key === 'style' && typeof value === 'object') {
74
+ const styleStr = Object.entries(value)
75
+ .map(([k, v]) => `${k.replace(/[A-Z]/g, m => `-${m.toLowerCase()}`)}: ${v}`)
76
+ .join('; ');
77
+ attrs.push(`style="${escapeHtml(styleStr)}"`);
78
+ } else if (typeof value !== 'function') {
79
+ attrs.push(`${key}="${escapeHtml(String(value))}"`);
80
+ }
81
+ }
82
+
83
+ return attrs.length > 0 ? ' ' + attrs.join(' ') : '';
84
+ }
85
+
86
+ /**
87
+ * Create HTML element string
88
+ */
89
+ export function renderElement(
90
+ type: string,
91
+ props: Record<string, any> | null,
92
+ children: VNode[]
93
+ ): string {
94
+ const propsStr = renderProps(props);
95
+ const childrenStr = children.map(c => renderToString(c)).join('');
96
+
97
+ // Self-closing tags
98
+ const selfClosing = ['area', 'base', 'br', 'col', 'embed', 'hr', 'img', 'input', 'link', 'meta', 'param', 'source', 'track', 'wbr'];
99
+
100
+ if (selfClosing.includes(type)) {
101
+ return `<${type}${propsStr} />`;
102
+ }
103
+
104
+ return `<${type}${propsStr}>${childrenStr}</${type}>`;
105
+ }
106
+
107
+ /**
108
+ * StreamRenderer for SSR - renders to a stream
109
+ */
110
+ export function renderToStream(
111
+ component: Component,
112
+ options?: {
113
+ chunkSize?: number;
114
+ onChunk?: (chunk: string) => void;
115
+ }
116
+ ): {
117
+ renderer: StreamRenderer;
118
+ promise: Promise<void>;
119
+ abort: () => void;
120
+ } {
121
+ const { chunkSize = 1000, onChunk } = options || {};
122
+ const renderer = new StreamRenderer();
123
+ let aborted = false;
124
+
125
+ const promise = Promise.resolve().then(() => {
126
+ if (aborted) return;
127
+
128
+ const vnode = component();
129
+ const html = renderToString(vnode);
130
+
131
+ if (html.length <= chunkSize) {
132
+ renderer.write(html);
133
+ onChunk?.(html);
134
+ } else {
135
+ // Chunk large content
136
+ for (let i = 0; i < html.length; i += chunkSize) {
137
+ if (aborted) break;
138
+ const chunk = html.slice(i, i + chunkSize);
139
+ renderer.write(chunk);
140
+ onChunk?.(chunk);
141
+ }
142
+ }
143
+
144
+ renderer.end();
145
+ });
146
+
147
+ return {
148
+ renderer,
149
+ promise,
150
+ abort: () => { aborted = true; }
151
+ };
152
+ }
153
+
154
+ /**
155
+ * Async VNode rendering - supports Promise-based components
156
+ */
157
+ export async function renderAsync(vnode: VNode | Promise<VNode>): Promise<string> {
158
+ const resolved = await vnode;
159
+
160
+ if (resolved == null || resolved === false) return '';
161
+
162
+ if (typeof resolved === 'string') {
163
+ return escapeHtml(resolved);
164
+ }
165
+
166
+ if (typeof resolved === 'number') {
167
+ return String(resolved);
168
+ }
169
+
170
+ if (Array.isArray(resolved)) {
171
+ const results = await Promise.all(resolved.map(v => renderAsync(v)));
172
+ return results.join('');
173
+ }
174
+
175
+ if (typeof resolved === 'function') {
176
+ try {
177
+ const result = await resolved();
178
+ return renderAsync(result);
179
+ } catch (err) {
180
+ console.error('Async component error:', err);
181
+ return '<!-- Async Error -->';
182
+ }
183
+ }
184
+
185
+ return '';
186
+ }
187
+
188
+ /**
189
+ * Async stream rendering - supports async components with streaming output
190
+ */
191
+ export async function renderToStreamAsync(
192
+ component: Component,
193
+ renderer: StreamRenderer,
194
+ options?: {
195
+ onChunk?: (chunk: string) => void;
196
+ }
197
+ ): Promise<void> {
198
+ const { onChunk } = options || {};
199
+
200
+ try {
201
+ const vnode = await Promise.resolve(component());
202
+ const html = await renderAsync(vnode);
203
+ renderer.write(html);
204
+ onChunk?.(html);
205
+ renderer.end();
206
+ } catch (err) {
207
+ console.error('Async stream render error:', err);
208
+ renderer.fail(err as Error);
209
+ }
210
+ }
211
+
212
+ /**
213
+ * Data prefetching context
214
+ */
215
+ export interface PrefetchContext {
216
+ promises: Promise<any>[];
217
+ errors: Error[];
218
+ add: (promise: Promise<any>) => void;
219
+ waitAll: () => Promise<void>;
220
+ }
221
+
222
+ /**
223
+ * Create prefetch context
224
+ */
225
+ export function createPrefetchContext(): PrefetchContext {
226
+ const promises: Promise<any>[] = [];
227
+ const errors: Error[] = [];
228
+
229
+ return {
230
+ promises,
231
+ errors,
232
+ add: (promise: Promise<any>) => {
233
+ promises.push(
234
+ promise.catch((err: Error) => {
235
+ errors.push(err);
236
+ return null;
237
+ })
238
+ );
239
+ },
240
+ waitAll: async () => {
241
+ await Promise.all(promises);
242
+ }
243
+ };
244
+ }
245
+
246
+ /**
247
+ * Prefetch data before rendering
248
+ * Returns a component that can be rendered after data is loaded
249
+ */
250
+ export async function prefetchAndRender<T>(
251
+ prefetchFn: () => Promise<T>,
252
+ renderFn: (data: T) => Component
253
+ ): Promise<string> {
254
+ const ctx = createPrefetchContext();
255
+ ctx.add(prefetchFn());
256
+ await ctx.waitAll();
257
+
258
+ if (ctx.errors.length > 0) {
259
+ console.error('Prefetch errors:', ctx.errors);
260
+ return '<!-- Prefetch Error -->';
261
+ }
262
+
263
+ // Get the data from the first promise
264
+ const data = await ctx.promises[0];
265
+ const component = renderFn(data);
266
+ return renderToString(component());
267
+ }
268
+
269
+ /**
270
+ * Suspense boundary for SSR
271
+ * Waits for async components to resolve
272
+ */
273
+ export async function renderWithSuspense(
274
+ component: Component,
275
+ options?: {
276
+ fallback?: string;
277
+ timeoutMs?: number;
278
+ }
279
+ ): Promise<string> {
280
+ const { fallback = '<!-- Loading -->', timeoutMs = 30000 } = options || {};
281
+
282
+ const timeout = new Promise<string>((_, reject) => {
283
+ setTimeout(() => reject(new Error('SSR timeout')), timeoutMs);
284
+ });
285
+
286
+ const render = Promise.resolve().then(() => {
287
+ try {
288
+ return renderToString(component());
289
+ } catch (err) {
290
+ console.error('Suspense render error:', err);
291
+ return fallback;
292
+ }
293
+ });
294
+
295
+ try {
296
+ return await Promise.race([render, timeout]);
297
+ } catch (err) {
298
+ console.error('Suspense timeout:', err);
299
+ return fallback;
300
+ }
301
+ }
302
+
303
+ /**
304
+ * Complete SSR render with all features
305
+ */
306
+ export interface SSRResult {
307
+ html: string;
308
+ state?: string; // Hydration state
309
+ errors: Error[];
310
+ }
311
+
312
+ export async function renderSSR(
313
+ component: Component,
314
+ options?: {
315
+ includeState?: boolean;
316
+ state?: any;
317
+ timeoutMs?: number;
318
+ }
319
+ ): Promise<SSRResult> {
320
+ const { includeState = false, state, timeoutMs } = options || {};
321
+ const errors: Error[] = [];
322
+
323
+ let html: string;
324
+ try {
325
+ html = await renderWithSuspense(component, { timeoutMs });
326
+ } catch (err) {
327
+ errors.push(err as Error);
328
+ html = '<!-- SSR Error -->';
329
+ }
330
+
331
+ const result: SSRResult = {
332
+ html,
333
+ errors
334
+ };
335
+
336
+ if (includeState && state) {
337
+ result.state = `<script>window.__QORE_STATE__ = ${JSON.stringify(state)}</script>`;
338
+ }
339
+
340
+ return result;
341
+ }