ReviewOS

also looking at this

stacks/stx

feat: router and runtime audit fixes

#1656
Closed fingersandmind wants to merge fix/router-and-runtime-audit-fixes into main
3 files +87 -42
packages/router/src/client.tsmodified+61-38
Changes to packages/router/src/client.ts
@@ -27,9 +27,20 @@ export function getRouterScript(): string {
2727 var o=Object.assign({},defaults,window.__stxRouterConfig||{},window.STX_ROUTER_OPTIONS||{});
2828 var containerSel=o.container;
2929
30 var CACHE_TTL=5*60*1000; // 5 minutes
3031 var cache={};
3132 var prefetching={};
3233 var isNavigating=false;
34 var currentAbort=null;
35
36 function normalizePath(p){return p.length>1&&p.charAt(p.length-1)==='/'?p.slice(0,-1):p}
37 function getCached(key){
38 var entry=cache[key];
39 if(!entry)return null;
40 if(Date.now()-entry.ts>CACHE_TTL){delete cache[key];return null}
41 return entry.html;
42 }
43 function setCache(key,html){cache[key]={html:html,ts:Date.now()}}
3344
3445 function getContainer(){
3546 return document.querySelector(containerSel)||document.querySelector('[data-stx-content]')||document.querySelector('main');
@@ -37,14 +48,13 @@ export function getRouterScript(): string {
3748
3849 // ── Navigation ──
3950 function navigate(url,pushState){
40 if(isNavigating)return;
4151 var t=new URL(url,location.origin);
4252
4353 // Different origin → full navigation
4454 if(t.origin!==location.origin){location.href=url;return}
4555
4656 // Hash-only navigation on same page
47 if(t.pathname===location.pathname&&t.hash){
57 if(normalizePath(t.pathname)===normalizePath(location.pathname)&&t.hash){
4858 if(pushState!==false)history.pushState({},'',t.href);
4959 var el=document.querySelector(t.hash);
5060 if(el)el.scrollIntoView({behavior:'smooth'});
@@ -54,27 +64,36 @@ export function getRouterScript(): string {
5464 // Same page, no hash → skip
5565 if(t.href===location.href&&!t.hash)return;
5666
67 // Save scroll position before navigating away
68 history.replaceState({scrollX:window.scrollX,scrollY:window.scrollY},'',location.href);
69
70 // Abort any in-flight navigation
71 if(currentAbort){currentAbort.abort();currentAbort=null}
72
5773 isNavigating=true;
5874 document.body.classList.add(o.loadingClass);
5975
60 var targetHref=t.href;
61 var targetPath=t.pathname;
76 var targetPath=normalizePath(t.pathname);
6277 var targetHash=t.hash;
6378
64 function done(){isNavigating=false;document.body.classList.remove(o.loadingClass)}
79 function done(){isNavigating=false;currentAbort=null;document.body.classList.remove(o.loadingClass)}
6580
66 if(o.cache&&cache[targetPath]){
67 swap(cache[targetPath],targetPath,pushState,targetHash);
81 var cached=o.cache&&getCached(targetPath);
82 if(cached){
83 swap(cached,targetPath,pushState,targetHash);
6884 done();
6985 }
70else {
71 fetch(url,{headers:{'X-STX-Router':'true','Accept':'text/html'}}).then(function(r){
86 else {
87 var abort=new AbortController();
88 currentAbort=abort;
89 fetch(url,{headers:{'X-STX-Router':'true','Accept':'text/html'},signal:abort.signal}).then(function(r){
7290 if(!r.ok)throw new Error(r.status);
7391 return r.text();
7492 }).then(function(html){
75 if(o.cache)cache[targetPath]=html;
93 if(o.cache)setCache(targetPath,html);
7694 swap(html,targetPath,pushState,targetHash);
77 }).catch(function(){
95 }).catch(function(err){
96 if(err&&err.name==='AbortError')return;
7897 location.href=url;
7998 }).finally(done);
8099 }
@@ -181,17 +200,26 @@ else {
181200 if(pushState!==false)history.pushState({},'',url+(hash||''));
182201
183202 // Update active nav links
184 updateNav(url);
185203 updateActiveLinks();
186204
187 // Scroll
188 if(o.scrollToTop&&!hash)window.scrollTo({top:0,behavior:'instant'});
205 // Scroll — restore saved position on back/forward, otherwise scroll to top
206 if(pushState===false&&history.state&&typeof history.state.scrollY==='number'){
207 window.scrollTo({top:history.state.scrollY,left:history.state.scrollX,behavior:'instant'});
208 }
209 else if(o.scrollToTop&&!hash)window.scrollTo({top:0,behavior:'instant'});
189210 else if(hash){var el=document.querySelector(hash);if(el)el.scrollIntoView({behavior:'smooth'})}
190211
191212 // Update title
192213 var newTitle=doc.querySelector('title');
193214 if(newTitle)document.title=newTitle.textContent;
194215
216 // Swap meta tags (description, og:*, twitter:*)
217 var swappableMeta='meta[name="description"],meta[property^="og:"],meta[name^="twitter:"]';
218 document.querySelectorAll(swappableMeta).forEach(function(m){m.remove()});
219 doc.querySelectorAll(swappableMeta).forEach(function(m){
220 document.head.appendChild(m.cloneNode(true));
221 });
222
195223 // Dispatch navigation events
196224 window.dispatchEvent(new CustomEvent('stx:navigate',{detail:{url:url}}));
197225 window.dispatchEvent(new Event('stx:load'));
@@ -220,7 +248,7 @@ else {
220248 if(href.startsWith('http')||href.startsWith('#')||href.startsWith('mailto:')||href.startsWith('tel:')||href.startsWith('javascript:'))return false;
221249 if(link.target==='_blank')return false;
222250 if(link.hasAttribute('data-stx-no-router')||link.hasAttribute('data-no-router')||link.hasAttribute('download'))return false;
223 if(href===location.pathname)return false;
251 if(normalizePath(href)===normalizePath(location.pathname))return false;
224252 if(!getContainer())return false;
225253 return true;
226254 }
@@ -236,7 +264,10 @@ else {
236264 },true);
237265
238266 // ── Back/forward ──
239 window.addEventListener('popstate',function(){
267 window.addEventListener('popstate',function(e){
268 // Cancel any in-flight navigation so back/forward always works
269 if(currentAbort){currentAbort.abort();currentAbort=null}
270 isNavigating=false;
240271 navigate(location.pathname+location.search+location.hash,false);
241272 });
242273
@@ -248,37 +279,29 @@ else {
248279 if(!shouldIntercept(link))return;
249280 // Only prefetch links with data-stx-prefetch or all internal links
250281 var href=link.getAttribute('href');
251 if(cache[href]||prefetching[href])return;
252 prefetching[href]=true;
282 var cacheKey=normalizePath(new URL(href,location.origin).pathname);
283 if(getCached(cacheKey)||prefetching[cacheKey])return;
284 prefetching[cacheKey]=true;
253285 fetch(href,{headers:{'X-STX-Router':'true','Accept':'text/html'}}).then(function(r){return r.text()}).then(function(html){
254 if(o.cache)cache[href]=html;
255 }).catch(function(){}).finally(function(){delete prefetching[href]});
286 if(o.cache)setCache(cacheKey,html);
287 }).catch(function(){}).finally(function(){delete prefetching[cacheKey]});
256288 },true);
257289 }
258290
259291 // ── Active link management ──
260 function updateNav(url){
261 document.querySelectorAll('nav a[href], #mobileNav a[href], [data-stx-nav] a[href]').forEach(function(a){
262 var href=a.getAttribute('href');
263 if(!href||href.startsWith('#')||href.startsWith('http'))return;
264 var isActive=(href===url)||(href==='/'&&url==='/');
265 if(a.hasAttribute('data-stx-link')){
266 var ac=a.getAttribute('data-stx-active-class')||'active';
267 if(isActive)a.classList.add(ac);else a.classList.remove(ac);
268 }
269 });
270 }
271
272292 function updateActiveLinks(){
293 var cur=normalizePath(location.pathname);
273294 var links=document.querySelectorAll('[data-stx-link]');
274 var cur=location.pathname;
275295 links.forEach(function(link){
276 var href=link.getAttribute('href')||'';
296 var href=normalizePath(link.getAttribute('href')||'');
277297 var ac=link.getAttribute('data-stx-active-class')||'active';
278298 var eac=link.getAttribute('data-stx-exact-active-class')||'exact-active';
299 // Remove all active classes first
279300 ac.split(' ').forEach(function(cls){if(cls)link.classList.remove(cls)});
280301 eac.split(' ').forEach(function(cls){if(cls)link.classList.remove(cls)});
302 // Exact match
281303 var isExact=cur===href;
304 // Prefix match (root "/" only matches itself)
282305 var isActive=href!=='/'?cur.startsWith(href):cur==='/';
283306 if(isExact)eac.split(' ').forEach(function(cls){if(cls)link.classList.add(cls)});
284307 if(isActive)ac.split(' ').forEach(function(cls){if(cls)link.classList.add(cls)});
@@ -305,14 +328,15 @@ else {
305328 navigate:navigate,
306329 navigateTo:navigate,
307330 prefetch:function(url){
308 if(!cache[url]){
309 fetch(url,{headers:{'X-STX-Router':'true'}}).then(function(r){return r.text()}).then(function(html){cache[url]=html}).catch(function(){});
331 var key=normalizePath(new URL(url,location.origin).pathname);
332 if(!getCached(key)){
333 fetch(url,{headers:{'X-STX-Router':'true'}}).then(function(r){return r.text()}).then(function(html){setCache(key,html)}).catch(function(){});
310334 }
311335 },
312336 clearCache:function(){for(var k in cache)delete cache[k]},
313337 cache:cache,
314338 swap:swap,
315 updateNav:updateNav
339 updateActiveLinks:updateActiveLinks
316340 };
317341
318342 window.__stxRouter=router;
@@ -324,7 +348,6 @@ else {
324348 injectStyles();
325349 injectViewTransitionCSS();
326350 updateActiveLinks();
327 updateNav(location.pathname);
328351 }
329352
330353 if(document.readyState==='loading')document.addEventListener('DOMContentLoaded',init);else init();
packages/stx/src/hydration.tsmodified+5-3
Changes to packages/stx/src/hydration.ts
@@ -254,9 +254,10 @@ export class HydrationRuntime {
254254 this.options.onHydrating()
255255 this.root.classList.add('stx-hydrating')
256256
257 // Set up timeout
257 // Set up timeout (cleared on success to prevent dangling timer)
258 let timeoutId: ReturnType<typeof setTimeout>
258259 const timeoutPromise = new Promise<never>((_, reject) => {
259 setTimeout(() => reject(new Error('Hydration timeout')), this.options.timeout)
260 timeoutId = setTimeout(() => reject(new Error('Hydration timeout')), this.options.timeout)
260261 })
261262
262263 // Run hydration with timeout
@@ -264,6 +265,7 @@ export class HydrationRuntime {
264265 this.runHydration(),
265266 timeoutPromise,
266267 ])
268 clearTimeout(timeoutId!)
267269
268270 this.isHydrated = true
269271 this.root.classList.remove('stx-hydrating')
@@ -360,7 +362,7 @@ catch (error) {
360362 id,
361363 name: name || id,
362364 strategy: strategyAttr as ComponentHydrationConfig['strategy'],
363 props: propsAttr ? JSON.parse(propsAttr) : {},
365 props: propsAttr ? (() => { try { return JSON.parse(propsAttr) } catch (e) { console.warn('[Hydration] Failed to parse props for', id, e); return {} } })() : {},
364366 events: [],
365367 }
366368
packages/stx/src/signals.tsmodified+21-1
Changes to packages/stx/src/signals.ts
@@ -1426,6 +1426,10 @@ else {
14261426 };
14271427 window.addEventListener('popstate', syncFromUrl);
14281428 window.addEventListener('stx:navigate', syncFromUrl);
1429 onDestroy(function() {
1430 window.removeEventListener('popstate', syncFromUrl);
1431 window.removeEventListener('stx:navigate', syncFromUrl);
1432 });
14291433 return {
14301434 data: params,
14311435 get: function(key) { return params()[key]; },
@@ -1449,6 +1453,14 @@ else {
14491453 // ==========================================================================
14501454
14511455 var _queryCache = {};
1456 var _queryCacheKeys = [];
1457 var QUERY_CACHE_MAX = 50;
1458 function _evictQueryCache() {
1459 while (_queryCacheKeys.length > QUERY_CACHE_MAX) {
1460 var oldest = _queryCacheKeys.shift();
1461 delete _queryCache[oldest];
1462 }
1463 }
14521464
14531465 function useQuery(url, options) {
14541466 options = options || {};
@@ -1492,6 +1504,8 @@ else {
14921504 data.set(transformed);
14931505 isStale.set(false);
14941506 _queryCache[key] = { data: transformed, timestamp: Date.now() };
1507 if (_queryCacheKeys.indexOf(key) === -1) _queryCacheKeys.push(key);
1508 _evictQueryCache();
14951509 if (options.onSuccess) options.onSuccess(transformed);
14961510 // Schedule cache eviction
14971511 setTimeout(function() { delete _queryCache[key]; }, cacheTime);
@@ -1924,7 +1938,13 @@ else if (name === '@text' || name === ':text') {
19241938 }
19251939else if (name === '@html' || name === ':html') {
19261940 effect(() => {
1927 el.innerHTML = evalAttrExpr(value);
1941 var rawHtml = evalAttrExpr(value);
1942 // Basic sanitization: strip <script> tags and on* event handlers
1943 if (typeof rawHtml === 'string') {
1944 rawHtml = rawHtml.replace(/<script\\b[^>]*>[\\s\\S]*?<\\/script>/gi, '');
1945 rawHtml = rawHtml.replace(/\\son\\w+\\s*=\\s*["'][^"']*["']/gi, '');
1946 }
1947 el.innerHTML = rawHtml;
19281948 });
19291949 el.removeAttribute(name);
19301950 }