tova 0.1.1 → 0.2.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.
@@ -21,6 +21,51 @@ function escapeAttr(str) {
21
21
  return str.replace(/&/g, '&').replace(/"/g, '"');
22
22
  }
23
23
 
24
+ // ─── SSR ID Counter for hydration markers ─────────────────
25
+ let ssrIdCounter = 0;
26
+
27
+ function nextSSRId() {
28
+ return ++ssrIdCounter;
29
+ }
30
+
31
+ export function resetSSRIdCounter() {
32
+ ssrIdCounter = 0;
33
+ }
34
+
35
+ // ─── Render props to attribute string ─────────────────────
36
+ function renderPropsToString(props, vnode) {
37
+ let html = '';
38
+ for (const [key, value] of Object.entries(props || {})) {
39
+ if (key === 'key' || key === 'ref') continue;
40
+ if (key.startsWith('on')) continue; // skip event handlers in SSR
41
+
42
+ const val = typeof value === 'function' ? value() : value;
43
+ if (val === false || val == null) continue;
44
+
45
+ if (key === 'className') {
46
+ html += ` class="${escapeAttr(val)}"`;
47
+ } else if (key === 'style' && typeof val === 'object') {
48
+ const styleStr = Object.entries(val)
49
+ .map(([k, v]) => `${k.replace(/[A-Z]/g, m => '-' + m.toLowerCase())}:${v}`)
50
+ .join(';');
51
+ html += ` style="${escapeAttr(styleStr)}"`;
52
+ } else if (key === 'checked' || key === 'disabled' || key === 'selected' || key === 'readonly') {
53
+ if (val) html += ` ${key}`;
54
+ } else if (key === 'value') {
55
+ html += ` value="${escapeAttr(val)}"`;
56
+ } else {
57
+ html += ` ${key}="${escapeAttr(val)}"`;
58
+ }
59
+ }
60
+
61
+ // Add data-tova-component attribute for named components
62
+ if (vnode && vnode._componentName) {
63
+ html += ` data-tova-component="${escapeAttr(vnode._componentName)}"`;
64
+ }
65
+
66
+ return html;
67
+ }
68
+
24
69
  // Render a vnode tree to an HTML string
25
70
  export function renderToString(vnode) {
26
71
  if (vnode === null || vnode === undefined) {
@@ -53,36 +98,33 @@ export function renderToString(vnode) {
53
98
 
54
99
  // Dynamic node (ErrorBoundary etc.)
55
100
  if (vnode.tag === '__dynamic' && typeof vnode.compute === 'function') {
56
- return renderToString(vnode.compute());
101
+ const id = nextSSRId();
102
+ try {
103
+ const inner = vnode.compute();
104
+ const content = renderToString(inner);
105
+ return `<!--tova-s:${id}-->${content}<!--/tova-s:${id}-->`;
106
+ } catch (e) {
107
+ // If this is an ErrorBoundary with a fallback, render fallback
108
+ if (vnode._fallback) {
109
+ try {
110
+ const fallbackContent = typeof vnode._fallback === 'function'
111
+ ? vnode._fallback({ error: e, reset: () => {} })
112
+ : vnode._fallback;
113
+ return `<!--tova-s:${id}-->${renderToString(fallbackContent)}<!--/tova-s:${id}-->`;
114
+ } catch (fallbackError) {
115
+ // Fallback also threw — re-throw
116
+ throw fallbackError;
117
+ }
118
+ }
119
+ throw e;
120
+ }
57
121
  }
58
122
 
59
123
  // Element
60
124
  const tag = vnode.tag;
61
125
  let html = `<${tag}`;
62
126
 
63
- // Render props as attributes
64
- for (const [key, value] of Object.entries(vnode.props || {})) {
65
- if (key === 'key' || key === 'ref') continue;
66
- if (key.startsWith('on')) continue; // skip event handlers in SSR
67
-
68
- const val = typeof value === 'function' ? value() : value;
69
- if (val === false || val == null) continue;
70
-
71
- if (key === 'className') {
72
- html += ` class="${escapeAttr(val)}"`;
73
- } else if (key === 'style' && typeof val === 'object') {
74
- const styleStr = Object.entries(val)
75
- .map(([k, v]) => `${k.replace(/[A-Z]/g, m => '-' + m.toLowerCase())}:${v}`)
76
- .join(';');
77
- html += ` style="${escapeAttr(styleStr)}"`;
78
- } else if (key === 'checked' || key === 'disabled' || key === 'selected' || key === 'readonly') {
79
- if (val) html += ` ${key}`;
80
- } else if (key === 'value') {
81
- html += ` value="${escapeAttr(val)}"`;
82
- } else {
83
- html += ` ${key}="${escapeAttr(val)}"`;
84
- }
85
- }
127
+ html += renderPropsToString(vnode.props, vnode);
86
128
 
87
129
  // Self-closing
88
130
  if (VOID_ELEMENTS.has(tag)) {
@@ -132,3 +174,133 @@ export function renderPage(component, { title = 'Tova App', head = '', scriptSrc
132
174
  </body>
133
175
  </html>`;
134
176
  }
177
+
178
+ // ─── Streaming SSR ─────────────────────────────────────────
179
+
180
+ // Stream a single vnode, writing chunks to the controller
181
+ function streamVNode(vnode, controller) {
182
+ if (vnode === null || vnode === undefined) {
183
+ return;
184
+ }
185
+
186
+ if (typeof vnode === 'function') {
187
+ streamVNode(vnode(), controller);
188
+ return;
189
+ }
190
+
191
+ if (typeof vnode === 'string') {
192
+ controller.enqueue(escapeHtml(vnode));
193
+ return;
194
+ }
195
+
196
+ if (typeof vnode === 'number' || typeof vnode === 'boolean') {
197
+ controller.enqueue(escapeHtml(String(vnode)));
198
+ return;
199
+ }
200
+
201
+ if (Array.isArray(vnode)) {
202
+ for (const child of vnode) {
203
+ streamVNode(child, controller);
204
+ }
205
+ return;
206
+ }
207
+
208
+ if (!vnode.__tova) {
209
+ controller.enqueue(escapeHtml(String(vnode)));
210
+ return;
211
+ }
212
+
213
+ // Fragment
214
+ if (vnode.tag === '__fragment') {
215
+ for (const child of flattenSSR(vnode.children)) {
216
+ streamVNode(child, controller);
217
+ }
218
+ return;
219
+ }
220
+
221
+ // Dynamic node (ErrorBoundary etc.)
222
+ if (vnode.tag === '__dynamic' && typeof vnode.compute === 'function') {
223
+ const id = nextSSRId();
224
+ controller.enqueue(`<!--tova-s:${id}-->`);
225
+ try {
226
+ const inner = vnode.compute();
227
+ streamVNode(inner, controller);
228
+ } catch (e) {
229
+ if (vnode._fallback) {
230
+ try {
231
+ const fallbackContent = typeof vnode._fallback === 'function'
232
+ ? vnode._fallback({ error: e, reset: () => {} })
233
+ : vnode._fallback;
234
+ streamVNode(fallbackContent, controller);
235
+ } catch (fallbackError) {
236
+ controller.enqueue(`<!--tova-ssr-error-->`);
237
+ }
238
+ } else {
239
+ // No boundary — re-throw for outer error handling
240
+ controller.enqueue(`<!--/tova-s:${id}-->`);
241
+ throw e;
242
+ }
243
+ }
244
+ controller.enqueue(`<!--/tova-s:${id}-->`);
245
+ return;
246
+ }
247
+
248
+ // Element
249
+ const tag = vnode.tag;
250
+ let openTag = `<${tag}`;
251
+ openTag += renderPropsToString(vnode.props, vnode);
252
+
253
+ if (VOID_ELEMENTS.has(tag)) {
254
+ controller.enqueue(openTag + ' />');
255
+ return;
256
+ }
257
+
258
+ controller.enqueue(openTag + '>');
259
+
260
+ const children = flattenSSR(vnode.children || []);
261
+ for (const child of children) {
262
+ streamVNode(child, controller);
263
+ }
264
+
265
+ controller.enqueue(`</${tag}>`);
266
+ }
267
+
268
+ // Render a vnode tree to a Web ReadableStream
269
+ export function renderToReadableStream(vnode, options = {}) {
270
+ const { onError } = options;
271
+
272
+ return new ReadableStream({
273
+ start(controller) {
274
+ try {
275
+ streamVNode(vnode, controller);
276
+ } catch (e) {
277
+ if (onError) onError(e);
278
+ controller.enqueue(`<!--tova-ssr-error-->`);
279
+ }
280
+ controller.close();
281
+ },
282
+ });
283
+ }
284
+
285
+ // Render a full HTML page as a stream
286
+ export function renderPageToStream(component, options = {}) {
287
+ const { title = 'Tova App', head = '', scriptSrc = '/client.js', onError } = options;
288
+
289
+ return new ReadableStream({
290
+ start(controller) {
291
+ // Flush head immediately so CSS/JS start downloading
292
+ controller.enqueue(`<!DOCTYPE html>\n<html>\n<head>\n <meta charset="utf-8">\n <meta name="viewport" content="width=device-width, initial-scale=1">\n <title>${escapeHtml(title)}</title>\n ${head}\n</head>\n<body>\n <div id="app">`);
293
+
294
+ try {
295
+ const vnode = typeof component === 'function' ? component() : component;
296
+ streamVNode(vnode, controller);
297
+ } catch (e) {
298
+ if (onError) onError(e);
299
+ controller.enqueue(`<!--tova-ssr-error-->`);
300
+ }
301
+
302
+ controller.enqueue(`</div>\n <script type="module" src="${escapeAttr(scriptSrc)}"></script>\n</body>\n</html>`);
303
+ controller.close();
304
+ },
305
+ });
306
+ }