tova 0.4.6 → 0.4.7

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.
@@ -1,7 +1,7 @@
1
1
  // Client-side router for Tova — integrated with the signal system
2
2
  // Route changes are reactive: components that read route() or params() auto-update.
3
3
 
4
- import { createSignal, tova_el } from './reactivity.js';
4
+ import { createSignal, tova_el, tova_fragment, createEffect, onCleanup } from './reactivity.js';
5
5
 
6
6
  // ─── Route Signal ─────────────────────────────────────────
7
7
  // The route is a signal, so any component/effect that reads it
@@ -18,36 +18,107 @@ const [route, setRoute] = createSignal({
18
18
  let routeDefinitions = [];
19
19
  let routeChangeCallbacks = [];
20
20
  let notFoundComponent = null;
21
+ let beforeNavigateHooks = [];
22
+ let afterNavigateHooks = [];
23
+
24
+ // ─── Path Validation ─────────────────────────────────────
25
+ // Reject absolute URLs, protocol-relative URLs, and javascript: URIs
26
+ // to prevent open redirects and XSS.
27
+
28
+ function isValidPath(path) {
29
+ if (typeof path !== 'string') return false;
30
+ // Reject protocol-relative URLs (//evil.com)
31
+ if (path.startsWith('//')) return false;
32
+ // Reject absolute URLs with schemes (http:, javascript:, data:, etc.)
33
+ if (/^[a-zA-Z][a-zA-Z0-9+\-.]*:/.test(path)) return false;
34
+ // Must start with / or be a relative path segment
35
+ return true;
36
+ }
21
37
 
22
38
  // ─── Public API ───────────────────────────────────────────
23
39
 
24
40
  export function defineRoutes(routeMap) {
25
- routeDefinitions = Object.entries(routeMap).map(([path, component]) => {
41
+ routeDefinitions = [];
42
+ const entries = Object.entries(routeMap);
43
+ for (const [path, value] of entries) {
26
44
  // Special 404 route
27
45
  if (path === '404' || path === '*') {
46
+ const component = (typeof value === 'object' && value !== null && value.component) ? value.component : value;
28
47
  notFoundComponent = component;
29
48
  // Catch-all '*' still gets a regex pattern for matching
30
49
  if (path === '*') {
31
- return { path, pattern: /^(.*)$/, component, isCatchAll: true };
50
+ routeDefinitions.push({ path, pattern: /^(.*)$/, component, isCatchAll: true, children: null });
51
+ }
52
+ continue;
53
+ }
54
+
55
+ if (typeof value === 'object' && value !== null && value.component) {
56
+ // Nested route definition: { component: Layout, children: { "/": Home, "/settings": Settings } }
57
+ const parentDef = {
58
+ path,
59
+ pattern: pathToRegex(path, true), // prefix match for parent
60
+ component: value.component,
61
+ isCatchAll: false,
62
+ children: [],
63
+ };
64
+ if (value.children) {
65
+ for (const [childPath, childComponent] of Object.entries(value.children)) {
66
+ const fullPath = childPath === '/' ? path : path + childPath;
67
+ parentDef.children.push({
68
+ path: fullPath,
69
+ relativePath: childPath,
70
+ pattern: pathToRegex(fullPath),
71
+ component: childComponent,
72
+ isCatchAll: false,
73
+ children: null,
74
+ });
75
+ }
32
76
  }
33
- return null;
77
+ routeDefinitions.push(parentDef);
78
+ } else {
79
+ routeDefinitions.push({
80
+ path,
81
+ pattern: pathToRegex(path),
82
+ component: value,
83
+ isCatchAll: false,
84
+ children: null,
85
+ });
34
86
  }
35
- return {
36
- path,
37
- pattern: pathToRegex(path),
38
- component,
39
- isCatchAll: false,
40
- };
41
- }).filter(Boolean);
87
+ }
42
88
  // Match initial route
43
89
  handleRouteChange();
44
90
  }
45
91
 
46
92
  export function navigate(path) {
93
+ // Validate path first — reject unsafe URLs regardless of environment
94
+ if (!isValidPath(path)) {
95
+ if (typeof console !== 'undefined') {
96
+ console.warn('Tova router: Blocked navigation to unsafe path: ' + path);
97
+ }
98
+ return;
99
+ }
100
+ // Normalize: ensure path starts with /
101
+ const normalizedPath = path.startsWith('/') ? path : '/' + path;
102
+
103
+ // Run beforeNavigate hooks — any returning false cancels navigation
104
+ const from = route();
105
+ for (const hook of beforeNavigateHooks) {
106
+ const result = hook(from, normalizedPath);
107
+ if (result === false) return;
108
+ // If hook returns a string, redirect to that path instead
109
+ if (typeof result === 'string' && isValidPath(result)) {
110
+ if (typeof window !== 'undefined') {
111
+ window.history.pushState({}, '', result);
112
+ }
113
+ handleRouteChange();
114
+ return;
115
+ }
116
+ }
117
+
47
118
  if (typeof window !== 'undefined') {
48
- window.history.pushState({}, '', path);
49
- handleRouteChange();
119
+ window.history.pushState({}, '', normalizedPath);
50
120
  }
121
+ handleRouteChange();
51
122
  }
52
123
 
53
124
  export function getCurrentRoute() {
@@ -71,6 +142,29 @@ export function onRouteChange(callback) {
71
142
  routeChangeCallbacks.push(callback);
72
143
  }
73
144
 
145
+ // ─── Navigation Guards ───────────────────────────────────
146
+ // beforeNavigate: called before route changes. Return false to cancel,
147
+ // return a string to redirect, return true/undefined to proceed.
148
+ // afterNavigate: called after route has changed.
149
+
150
+ export function beforeNavigate(callback) {
151
+ beforeNavigateHooks.push(callback);
152
+ // Return unsubscribe function
153
+ return () => {
154
+ const idx = beforeNavigateHooks.indexOf(callback);
155
+ if (idx !== -1) beforeNavigateHooks.splice(idx, 1);
156
+ };
157
+ }
158
+
159
+ export function afterNavigate(callback) {
160
+ afterNavigateHooks.push(callback);
161
+ // Return unsubscribe function
162
+ return () => {
163
+ const idx = afterNavigateHooks.indexOf(callback);
164
+ if (idx !== -1) afterNavigateHooks.splice(idx, 1);
165
+ };
166
+ }
167
+
74
168
  // ─── Router Component ─────────────────────────────────────
75
169
  // Renders the matched route's component reactively.
76
170
  // Usage: <Router /> in JSX
@@ -83,6 +177,23 @@ export function Router() {
83
177
  return null;
84
178
  }
85
179
 
180
+ // ─── Outlet Component ────────────────────────────────────
181
+ // Renders the matched child route's component inside a parent layout.
182
+ // Usage: <Outlet /> inside a layout component
183
+ // Child route is stored as a signal for reactivity and isolation.
184
+
185
+ const [currentChildRoute, setCurrentChildRoute] = createSignal(null);
186
+
187
+ export function Outlet() {
188
+ const child = currentChildRoute();
189
+ if (child && child.component) {
190
+ const comp = child.component;
191
+ const params = child.params || {};
192
+ return typeof comp === 'function' ? comp(params) : comp;
193
+ }
194
+ return null;
195
+ }
196
+
86
197
  // ─── Link Component ───────────────────────────────────────
87
198
  // Client-side navigation link.
88
199
  // Usage: <Link href="/about">"About"</Link>
@@ -101,10 +212,28 @@ export function Link({ href, children, ...rest }) {
101
212
  // ─── Redirect Component ──────────────────────────────────
102
213
  // Immediately navigates to a different path when rendered.
103
214
  // Usage: <Redirect to="/login" />
215
+ // Loop protection: max 10 redirects in 1 second to prevent infinite loops.
216
+
217
+ let _redirectCount = 0;
218
+ let _redirectWindowStart = 0;
219
+ const _MAX_REDIRECTS = 10;
220
+ const _REDIRECT_WINDOW_MS = 1000;
104
221
 
105
222
  export function Redirect({ to }) {
106
223
  if (typeof window !== 'undefined') {
107
- queueMicrotask(() => navigate(to));
224
+ queueMicrotask(() => {
225
+ const now = typeof performance !== 'undefined' ? performance.now() : Date.now();
226
+ if (now - _redirectWindowStart > _REDIRECT_WINDOW_MS) {
227
+ _redirectCount = 0;
228
+ _redirectWindowStart = now;
229
+ }
230
+ _redirectCount++;
231
+ if (_redirectCount > _MAX_REDIRECTS) {
232
+ console.error(`Tova router: Redirect loop detected (>${_MAX_REDIRECTS} redirects in ${_REDIRECT_WINDOW_MS}ms). Aborting redirect to "${to}".`);
233
+ return;
234
+ }
235
+ navigate(to);
236
+ });
108
237
  }
109
238
  return null;
110
239
  }
@@ -146,32 +275,64 @@ function handleRouteChange() {
146
275
  for (const cb of routeChangeCallbacks) {
147
276
  cb(matched);
148
277
  }
278
+
279
+ // Run afterNavigate hooks
280
+ const currentRoute = route();
281
+ for (const hook of afterNavigateHooks) {
282
+ hook(currentRoute);
283
+ }
149
284
  }
150
285
 
151
286
  function matchRoute(path) {
152
287
  for (const def of routeDefinitions) {
153
- const match = def.pattern.exec(path);
154
- if (match) {
155
- const params = extractParams(def.path, match);
156
- return { path: def.path, component: def.component, params };
288
+ if (def.children && def.children.length > 0) {
289
+ for (const child of def.children) {
290
+ const childMatch = child.pattern.exec(path);
291
+ if (childMatch) {
292
+ const childParams = extractParams(child.path, childMatch);
293
+ setCurrentChildRoute({ component: child.component, params: childParams });
294
+ const parentMatch = def.pattern.exec(path);
295
+ const parentParams = extractParams(def.path, parentMatch || []);
296
+ return { path: def.path, component: def.component, params: { ...parentParams, ...childParams } };
297
+ }
298
+ }
299
+ const parentMatch = def.pattern.exec(path);
300
+ if (parentMatch) {
301
+ setCurrentChildRoute(null);
302
+ const params = extractParams(def.path, parentMatch);
303
+ return { path: def.path, component: def.component, params };
304
+ }
305
+ } else {
306
+ const match = def.pattern.exec(path);
307
+ if (match) {
308
+ setCurrentChildRoute(null);
309
+ const params = extractParams(def.path, match);
310
+ return { path: def.path, component: def.component, params };
311
+ }
157
312
  }
158
313
  }
314
+ setCurrentChildRoute(null);
159
315
  return null;
160
316
  }
161
317
 
162
- function pathToRegex(path) {
163
- // Handle optional parameters: :id? becomes ([^/]+)?
318
+ function pathToRegex(path, prefixMatch) {
319
+ // Handle optional parameters: :id? becomes ([^/]*)?
164
320
  // Handle required parameters: :id becomes ([^/]+)
165
321
  // Handle catch-all: * becomes (.*)
166
322
  const pattern = path
167
323
  .replace(/:([a-zA-Z_]+)\?/g, '([^/]*)?') // optional params
168
324
  .replace(/:([a-zA-Z_]+)/g, '([^/]+)') // required params
169
325
  .replace(/\*/g, '(.*)'); // catch-all
170
- return new RegExp(`^${pattern}$`);
326
+ // For parent routes with children, match as prefix
327
+ if (prefixMatch) {
328
+ return new RegExp('^' + pattern + '(?:/.*)?$');
329
+ }
330
+ return new RegExp('^' + pattern + '$');
171
331
  }
172
332
 
173
333
  function extractParams(routePath, match) {
174
334
  const params = {};
335
+ if (!match) return params;
175
336
  // Match both required (:name) and optional (:name?) params
176
337
  const paramNames = (routePath.match(/:([a-zA-Z_]+)\??/g) || [])
177
338
  .map(p => p.replace(/^:/, '').replace(/\?$/, ''));
@@ -187,14 +348,43 @@ function extractParams(routePath, match) {
187
348
  // ─── Browser Init ─────────────────────────────────────────
188
349
 
189
350
  if (typeof window !== 'undefined') {
190
- window.addEventListener('popstate', handleRouteChange);
351
+ window.addEventListener('popstate', () => {
352
+ // Run beforeNavigate hooks for browser back/forward
353
+ const from = route();
354
+ const toPath = window.location.pathname;
355
+ for (const hook of beforeNavigateHooks) {
356
+ const result = hook(from, toPath);
357
+ if (result === false) {
358
+ // Cancel: push the previous path back
359
+ window.history.pushState({}, '', from.path);
360
+ return;
361
+ }
362
+ }
363
+ handleRouteChange();
364
+ });
191
365
 
192
366
  // Intercept link clicks for client-side navigation
193
367
  document.addEventListener('click', (e) => {
194
368
  const link = e.target.closest('a[href]');
195
- if (link && link.href.startsWith(window.location.origin)) {
196
- e.preventDefault();
197
- navigate(link.getAttribute('href'));
369
+ if (!link) return;
370
+ // Use the resolved href for origin comparison (not raw attribute)
371
+ if (!link.href.startsWith(window.location.origin)) return;
372
+ // Skip links with target, download, or external rel
373
+ if (link.target === '_blank') return;
374
+ if (typeof link.hasAttribute === 'function' && link.hasAttribute('download')) return;
375
+ if (typeof link.getAttribute === 'function') {
376
+ const rel = link.getAttribute('rel');
377
+ if (rel && rel.includes('external')) return;
378
+ }
379
+ e.preventDefault();
380
+ // Use pathname from the resolved URL for safe navigation
381
+ try {
382
+ const url = new URL(link.href);
383
+ navigate(url.pathname + (url.search || '') + (url.hash || ''));
384
+ } catch (_) {
385
+ // Fallback for environments without URL constructor
386
+ const href = typeof link.getAttribute === 'function' ? link.getAttribute('href') : link.href;
387
+ if (href && isValidPath(href)) navigate(href);
198
388
  }
199
389
  });
200
390
  }
@@ -1,46 +1,181 @@
1
1
  // RPC bridge — client calls to server functions are auto-routed via HTTP
2
+ // Includes CSRF protection, request timeouts, and interceptor middleware.
2
3
 
3
- const RPC_BASE = typeof window !== 'undefined'
4
- ? (window.__TOVA_RPC_BASE || '')
5
- : 'http://localhost:3000';
4
+ // ─── Configuration ────────────────────────────────────────
5
+
6
+ const _config = {
7
+ base: typeof window !== 'undefined' ? (window.__TOVA_RPC_BASE || '') : 'http://localhost:3000',
8
+ timeout: 30000, // 30s default timeout
9
+ csrfHeader: 'X-Tova-CSRF',
10
+ csrfToken: null, // auto-detected from meta tag or set manually
11
+ credentials: 'same-origin', // fetch credentials mode
12
+ };
13
+
14
+ // Interceptor chains — each is { request?: fn, response?: fn, error?: fn }
15
+ const _interceptors = [];
16
+
17
+ // ─── CSRF Token Management ────────────────────────────────
18
+
19
+ function getCSRFToken() {
20
+ if (_config.csrfToken) return _config.csrfToken;
21
+ // Auto-detect from <meta name="csrf-token" content="..."> (server-rendered)
22
+ if (typeof document !== 'undefined') {
23
+ const meta = document.querySelector('meta[name="csrf-token"]');
24
+ if (meta) {
25
+ _config.csrfToken = meta.getAttribute('content');
26
+ return _config.csrfToken;
27
+ }
28
+ }
29
+ return null;
30
+ }
31
+
32
+ // ─── Core RPC Function ────────────────────────────────────
6
33
 
7
34
  export async function rpc(functionName, args = []) {
8
- const url = `${RPC_BASE}/rpc/${functionName}`;
35
+ const url = `${_config.base}/rpc/${functionName}`;
9
36
 
10
37
  // Convert positional args to object if needed
11
38
  let body;
12
39
  if (args.length === 1 && typeof args[0] === 'object' && !Array.isArray(args[0])) {
13
40
  body = args[0];
14
41
  } else if (args.length > 0) {
15
- // Send as array, server will handle positional mapping
16
42
  body = { __args: args };
17
43
  } else {
18
44
  body = {};
19
45
  }
20
46
 
47
+ // Build headers
48
+ const headers = { 'Content-Type': 'application/json' };
49
+ const csrf = getCSRFToken();
50
+ if (csrf) {
51
+ headers[_config.csrfHeader] = csrf;
52
+ }
53
+
54
+ // Build request options
55
+ let requestOptions = {
56
+ method: 'POST',
57
+ headers,
58
+ body: JSON.stringify(body),
59
+ credentials: _config.credentials,
60
+ };
61
+
62
+ // Run request interceptors
63
+ for (const interceptor of _interceptors) {
64
+ if (interceptor.request) {
65
+ const result = interceptor.request({ url, functionName, args, options: requestOptions });
66
+ if (result && typeof result === 'object') {
67
+ requestOptions = { ...requestOptions, ...result };
68
+ }
69
+ }
70
+ }
71
+
72
+ // AbortController for timeout
73
+ const controller = typeof AbortController !== 'undefined' ? new AbortController() : null;
74
+ if (controller) {
75
+ requestOptions.signal = controller.signal;
76
+ }
77
+ const timeoutId = controller && _config.timeout > 0
78
+ ? setTimeout(() => controller.abort(), _config.timeout)
79
+ : null;
80
+
21
81
  try {
22
- const response = await fetch(url, {
23
- method: 'POST',
24
- headers: { 'Content-Type': 'application/json' },
25
- body: JSON.stringify(body),
26
- });
82
+ const response = await fetch(url, requestOptions);
83
+
84
+ if (timeoutId) clearTimeout(timeoutId);
27
85
 
28
86
  if (!response.ok) {
29
87
  const errorText = await response.text();
30
- throw new Error(`RPC call to '${functionName}' failed: ${response.status} ${errorText}`);
88
+ const err = new Error(`RPC call to '${functionName}' failed: ${response.status} ${errorText}`);
89
+ err.status = response.status;
90
+ err.functionName = functionName;
91
+
92
+ // Run error interceptors
93
+ for (const interceptor of _interceptors) {
94
+ if (interceptor.error) {
95
+ const handled = interceptor.error(err, { url, functionName, args, response });
96
+ if (handled === false) return undefined; // Interceptor suppressed the error
97
+ }
98
+ }
99
+
100
+ throw err;
101
+ }
102
+
103
+ let data = await response.json();
104
+
105
+ // Run response interceptors
106
+ for (const interceptor of _interceptors) {
107
+ if (interceptor.response) {
108
+ const transformed = interceptor.response(data, { url, functionName, args, response });
109
+ if (transformed !== undefined) data = transformed;
110
+ }
31
111
  }
32
112
 
33
- const data = await response.json();
34
113
  return data.result;
35
114
  } catch (error) {
36
- if (error.message.includes('RPC call')) throw error;
115
+ if (timeoutId) clearTimeout(timeoutId);
116
+
117
+ // Wrap AbortError as timeout
118
+ if (error.name === 'AbortError') {
119
+ const err = new Error(`RPC call to '${functionName}' timed out after ${_config.timeout}ms`);
120
+ err.code = 'TIMEOUT';
121
+ err.functionName = functionName;
122
+
123
+ for (const interceptor of _interceptors) {
124
+ if (interceptor.error) {
125
+ const handled = interceptor.error(err, { url, functionName, args });
126
+ if (handled === false) return undefined;
127
+ }
128
+ }
129
+
130
+ throw err;
131
+ }
132
+
133
+ if (error.message && error.message.includes('RPC call')) throw error;
37
134
  throw new Error(`RPC call to '${functionName}' failed: ${error.message}`);
38
135
  }
39
136
  }
40
137
 
41
- // Configure RPC base URL
42
- export function configureRPC(baseUrl) {
43
- if (typeof window !== 'undefined') {
44
- window.__TOVA_RPC_BASE = baseUrl;
138
+ // ─── Configuration API ────────────────────────────────────
139
+
140
+ export function configureRPC(options) {
141
+ if (typeof options === 'string') {
142
+ // Backward compat: configureRPC('http://...')
143
+ _config.base = options;
144
+ if (typeof window !== 'undefined') window.__TOVA_RPC_BASE = options;
145
+ return;
146
+ }
147
+ if (options.baseUrl !== undefined) {
148
+ _config.base = options.baseUrl;
149
+ if (typeof window !== 'undefined') window.__TOVA_RPC_BASE = options.baseUrl;
45
150
  }
151
+ if (options.timeout !== undefined) _config.timeout = options.timeout;
152
+ if (options.csrfToken !== undefined) _config.csrfToken = options.csrfToken;
153
+ if (options.csrfHeader !== undefined) _config.csrfHeader = options.csrfHeader;
154
+ if (options.credentials !== undefined) _config.credentials = options.credentials;
155
+ }
156
+
157
+ // ─── Interceptor API ──────────────────────────────────────
158
+ // Usage:
159
+ // const unsub = addRPCInterceptor({
160
+ // request({ url, functionName, args, options }) {
161
+ // options.headers['Authorization'] = 'Bearer ' + token;
162
+ // return options;
163
+ // },
164
+ // response(data, { functionName }) { ... },
165
+ // error(err, { functionName }) { ... },
166
+ // });
167
+ // unsub(); // remove interceptor
168
+
169
+ export function addRPCInterceptor(interceptor) {
170
+ _interceptors.push(interceptor);
171
+ return () => {
172
+ const idx = _interceptors.indexOf(interceptor);
173
+ if (idx !== -1) _interceptors.splice(idx, 1);
174
+ };
175
+ }
176
+
177
+ // ─── Set CSRF Token ───────────────────────────────────────
178
+
179
+ export function setCSRFToken(token) {
180
+ _config.csrfToken = token;
46
181
  }
@@ -17,9 +17,9 @@ function escapeHtml(str) {
17
17
  return str.replace(_RE_HTML_G, ch => _ESC_HTML[ch]);
18
18
  }
19
19
 
20
- const _ESC_ATTR = { '&': '&amp;', '"': '&quot;' };
21
- const _RE_ATTR = /[&"]/;
22
- const _RE_ATTR_G = /[&"]/g;
20
+ const _ESC_ATTR = { '&': '&amp;', '"': '&quot;', '<': '&lt;', '>': '&gt;' };
21
+ const _RE_ATTR = /[&"<>]/;
22
+ const _RE_ATTR_G = /[&"<>]/g;
23
23
 
24
24
  function escapeAttr(str) {
25
25
  if (typeof str !== 'string') return String(str);
@@ -28,9 +28,21 @@ function escapeAttr(str) {
28
28
  }
29
29
 
30
30
  // ─── SSR ID Counter for hydration markers ─────────────────
31
+ // Request-scoped via AsyncLocalStorage (Node.js) or fallback to global.
32
+ // Use createSSRContext() for concurrent-safe SSR rendering.
31
33
  let ssrIdCounter = 0;
32
34
 
35
+ // Per-request context for concurrent SSR safety
36
+ let _currentSSRContext = null;
37
+
38
+ export function createSSRContext() {
39
+ return { idCounter: 0 };
40
+ }
41
+
33
42
  function nextSSRId() {
43
+ if (_currentSSRContext) {
44
+ return ++_currentSSRContext.idCounter;
45
+ }
34
46
  return ++ssrIdCounter;
35
47
  }
36
48
 
@@ -38,6 +50,20 @@ export function resetSSRIdCounter() {
38
50
  ssrIdCounter = 0;
39
51
  }
40
52
 
53
+ // Run a render function within an isolated SSR context.
54
+ // This ensures concurrent SSR requests don't share state.
55
+ // Usage: const html = withSSRContext(() => renderToString(App()));
56
+ export function withSSRContext(fn) {
57
+ const ctx = createSSRContext();
58
+ const prev = _currentSSRContext;
59
+ _currentSSRContext = ctx;
60
+ try {
61
+ return fn();
62
+ } finally {
63
+ _currentSSRContext = prev;
64
+ }
65
+ }
66
+
41
67
  // ─── Render props to attribute string ─────────────────────
42
68
  function renderPropsToString(props, vnode) {
43
69
  let html = '';
@@ -190,9 +216,37 @@ function flattenSSR(children) {
190
216
  return result;
191
217
  }
192
218
 
193
- // Render a full HTML page with the app component for SSR
194
- export function renderPage(component, { title = 'Tova App', head = '', scriptSrc = '/client.js' } = {}) {
219
+ // Build safe head HTML from structured tag descriptors.
220
+ // Usage: renderHeadTags([{ tag: 'meta', attrs: { name: 'desc', content: userInput } }])
221
+ // All attribute values are escaped — safe for user input.
222
+ export function renderHeadTags(tags) {
223
+ if (!tags || !Array.isArray(tags)) return '';
224
+ const parts = [];
225
+ for (const { tag, attrs = {}, content } of tags) {
226
+ let html = `<${escapeHtml(tag)}`;
227
+ for (const [key, val] of Object.entries(attrs)) {
228
+ if (val === false || val == null) continue;
229
+ html += ` ${escapeHtml(key)}="${escapeAttr(String(val))}"`;
230
+ }
231
+ if (VOID_ELEMENTS.has(tag)) {
232
+ html += ' />';
233
+ } else {
234
+ html += `>${content ? escapeHtml(content) : ''}</${escapeHtml(tag)}>`;
235
+ }
236
+ parts.push(html);
237
+ }
238
+ return parts.join('\n ');
239
+ }
240
+
241
+ // Render a full HTML page with the app component for SSR.
242
+ // `head` accepts either a raw HTML string (trusted content only) or an array
243
+ // of tag descriptors for safe rendering: [{ tag: 'meta', attrs: { name: 'desc', content: '...' } }]
244
+ // SECURITY: Raw string `head` must contain only developer-authored content — never user input.
245
+ // Use the array form or renderHeadTags() for safe user-controlled head content.
246
+ export function renderPage(component, { title = 'Tova App', head = '', scriptSrc = '/client.js', cspNonce } = {}) {
195
247
  const appHtml = renderToString(typeof component === 'function' ? component() : component);
248
+ const headHtml = Array.isArray(head) ? renderHeadTags(head) : head;
249
+ const nonceAttr = cspNonce ? ` nonce="${escapeAttr(cspNonce)}"` : '';
196
250
 
197
251
  return `<!DOCTYPE html>
198
252
  <html>
@@ -200,11 +254,11 @@ export function renderPage(component, { title = 'Tova App', head = '', scriptSrc
200
254
  <meta charset="utf-8">
201
255
  <meta name="viewport" content="width=device-width, initial-scale=1">
202
256
  <title>${escapeHtml(title)}</title>
203
- ${head}
257
+ ${headHtml}
204
258
  </head>
205
259
  <body>
206
260
  <div id="app">${appHtml}</div>
207
- <script type="module" src="${escapeAttr(scriptSrc)}"></script>
261
+ <script type="module" src="${escapeAttr(scriptSrc)}"${nonceAttr}></script>
208
262
  </body>
209
263
  </html>`;
210
264
  }
@@ -349,12 +403,14 @@ export function renderToReadableStream(vnode, options = {}) {
349
403
 
350
404
  // Render a full HTML page as a stream
351
405
  export function renderPageToStream(component, options = {}) {
352
- const { title = 'Tova App', head = '', scriptSrc = '/client.js', onError, bufferSize } = options;
406
+ const { title = 'Tova App', head = '', scriptSrc = '/client.js', onError, bufferSize, cspNonce } = options;
407
+ const headHtml = Array.isArray(head) ? renderHeadTags(head) : head;
408
+ const nonceAttr = cspNonce ? ` nonce="${escapeAttr(cspNonce)}"` : '';
353
409
 
354
410
  return new ReadableStream({
355
411
  start(controller) {
356
412
  // Flush head immediately so CSS/JS start downloading (bypass buffer)
357
- 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">`);
413
+ 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 ${headHtml}\n</head>\n<body>\n <div id="app">`);
358
414
 
359
415
  const buf = new BufferedController(controller, bufferSize);
360
416
  try {
@@ -366,7 +422,7 @@ export function renderPageToStream(component, options = {}) {
366
422
  }
367
423
 
368
424
  buf.flush();
369
- controller.enqueue(`</div>\n <script type="module" src="${escapeAttr(scriptSrc)}"></script>\n</body>\n</html>`);
425
+ controller.enqueue(`</div>\n <script type="module" src="${escapeAttr(scriptSrc)}"${nonceAttr}></script>\n</body>\n</html>`);
370
426
  controller.close();
371
427
  },
372
428
  });