@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/dist/index.js +1193 -0
- package/dist/index.js.map +1 -0
- package/package.json +76 -0
- package/src/component.ts +26 -0
- package/src/error.ts +144 -0
- package/src/index.ts +95 -0
- package/src/render.ts +286 -0
- package/src/signal.ts +213 -0
- package/src/ssr.ts +341 -0
- package/src/stream.ts +432 -0
- package/src/utils.ts +116 -0
- package/src/virtual-list.ts +405 -0
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, '<')
|
|
14
|
+
.replace(/>/g, '>')
|
|
15
|
+
.replace(/"/g, '"')
|
|
16
|
+
.replace(/'/g, ''');
|
|
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
|
+
}
|