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.
- package/package.json +14 -2
- package/src/analyzer/analyzer.js +10 -5
- package/src/analyzer/type-registry.js +22 -3
- package/src/codegen/base-codegen.js +15 -4
- package/src/codegen/client-codegen.js +9 -6
- package/src/codegen/codegen.js +3 -2
- package/src/codegen/server-codegen.js +526 -81
- package/src/lsp/server.js +44 -25
- package/src/parser/server-ast.js +2 -1
- package/src/parser/server-parser.js +12 -1
- package/src/runtime/embedded.js +3 -3
- package/src/runtime/reactivity.js +405 -23
- package/src/runtime/router.js +215 -25
- package/src/runtime/rpc.js +152 -17
- package/src/runtime/ssr.js +66 -10
- package/src/runtime/testing.js +241 -0
- package/src/version.js +1 -1
package/src/runtime/router.js
CHANGED
|
@@ -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 =
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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({}, '',
|
|
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(() =>
|
|
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
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
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
|
-
|
|
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',
|
|
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
|
|
196
|
-
|
|
197
|
-
|
|
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
|
}
|
package/src/runtime/rpc.js
CHANGED
|
@@ -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
|
-
|
|
4
|
-
|
|
5
|
-
|
|
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 = `${
|
|
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
|
-
|
|
24
|
-
|
|
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
|
-
|
|
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 (
|
|
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
|
-
//
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
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
|
}
|
package/src/runtime/ssr.js
CHANGED
|
@@ -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 = { '&': '&', '"': '"' };
|
|
21
|
-
const _RE_ATTR = /[&"]/;
|
|
22
|
-
const _RE_ATTR_G = /[&"]/g;
|
|
20
|
+
const _ESC_ATTR = { '&': '&', '"': '"', '<': '<', '>': '>' };
|
|
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
|
-
//
|
|
194
|
-
|
|
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
|
-
${
|
|
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 ${
|
|
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
|
});
|