bun-plugin-stx 0.2.155 → 0.2.156

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.
@@ -12,31 +12,50 @@ import path4 from "path";
12
12
  // ../router/src/codegen.ts
13
13
  import fs from "fs";
14
14
  import path from "path";
15
+
16
+ // ../router/src/page-routes.ts
17
+ var NON_PAGE_DIRS = ["components", "layouts", "partials"];
18
+ function isNonPageRoutePath(relativePath) {
19
+ const segments = relativePath.split(/[\\/]/).filter(Boolean);
20
+ return segments.slice(0, -1).some((segment) => NON_PAGE_DIRS.includes(segment));
21
+ }
22
+ function isNonPageRoutePattern(pattern) {
23
+ return isNonPageRoutePath(pattern.replace(/^\//, ""));
24
+ }
25
+
26
+ // ../router/src/codegen.ts
15
27
  function generateRouteTypes(routes, outputDir) {
16
- const lines = [
17
- "// Auto-generated by STX - do not edit",
18
- 'declare module "stx/routes" {',
19
- " interface RouteMap {"
20
- ];
21
- for (const route of routes) {
22
- const paramEntries = [];
23
- for (const p of route.params) {
24
- const type = route.pattern.includes(`:${p}*`) ? "string[]" : "string";
25
- paramEntries.push(`${p}: ${type}`);
28
+ const pageRoutes = routes.filter((r) => !isNonPageRoutePattern(r.pattern));
29
+ const lines = ["// Auto-generated by STX - do not edit"];
30
+ if (pageRoutes.length === 0) {
31
+ lines.push("// No page routes found \u2014 route paths stay typed as string.", "export {}", "");
32
+ } else {
33
+ lines.push(`declare module '@stacksjs/stx' {`, " interface KnownRoutes {");
34
+ for (const route of pageRoutes) {
35
+ const paramEntries = [];
36
+ for (const p of route.params) {
37
+ const optional = route.pattern.includes(`:${p}?`) ? "?" : "";
38
+ paramEntries.push(`${p}${optional}: string`);
39
+ }
40
+ const paramsStr = paramEntries.join("; ");
41
+ lines.push(` '${route.pattern}': { ${paramsStr} }`);
26
42
  }
27
- const paramsStr = paramEntries.join("; ");
28
- lines.push(` '${route.pattern}': { ${paramsStr} }`);
43
+ lines.push(" }", "}", "", "export {}", "");
29
44
  }
30
- lines.push(" }", "}", "");
31
- fs.mkdirSync(outputDir, { recursive: true });
32
- fs.writeFileSync(path.join(outputDir, "route-types.d.ts"), lines.join(`
33
- `));
45
+ const contents = lines.join(`
46
+ `);
47
+ const target = path.join(outputDir, "route-types.d.ts");
48
+ try {
49
+ if (fs.existsSync(target) && fs.readFileSync(target, "utf8") === contents)
50
+ return;
51
+ fs.mkdirSync(outputDir, { recursive: true });
52
+ fs.writeFileSync(target, contents);
53
+ } catch {}
34
54
  }
35
55
  function generateRouteManifest(routes, outputDir, pagesDir) {
36
56
  const cwd = process.cwd();
37
57
  const relativePages = path.relative(cwd, pagesDir);
38
- const excludeDirs = ["components", "layouts", "partials"];
39
- const pageRoutes = routes.filter((r) => !excludeDirs.some((dir) => r.pattern.startsWith(`/${dir}/`) || r.pattern === `/${dir}`));
58
+ const pageRoutes = routes.filter((r) => !isNonPageRoutePattern(r.pattern));
40
59
  const lines = [
41
60
  "// Auto-generated by stx \u2014 do not edit",
42
61
  `// Source: ${relativePages || "pages"}/`,
@@ -155,6 +174,9 @@ function scanDirectory(dir, extensions) {
155
174
  if (entry.name.startsWith(".")) {
156
175
  continue;
157
176
  }
177
+ if (NON_PAGE_DIRS.includes(entry.name)) {
178
+ continue;
179
+ }
158
180
  files.push(...scanDirectory(fullPath, extensions));
159
181
  } else if (entry.isFile()) {
160
182
  const ext = path4.extname(entry.name);
@@ -525,8 +547,8 @@ function createRouteLocation(pathname, params, meta, search = "") {
525
547
  meta
526
548
  };
527
549
  }
528
- function createMiddlewareContext(to, from, request) {
529
- const isServer = typeof window === "undefined";
550
+ function createMiddlewareContext(to, from, request, options) {
551
+ const isServer = options?.isServer ?? typeof window === "undefined";
530
552
  const isClient = !isServer;
531
553
  const responseHeaders = new Headers;
532
554
  return {
@@ -547,13 +569,22 @@ async function runMiddleware(middlewareNames, context) {
547
569
  for (const name of names) {
548
570
  const middleware = getMiddleware(name);
549
571
  if (!middleware) {
550
- console.warn(`Middleware '${name}' not found`);
551
- continue;
572
+ console.error(`Route middleware '${name}' is not registered \u2014 failing the guard closed.`);
573
+ return {
574
+ passed: false,
575
+ abort: {
576
+ type: "abort",
577
+ error: {
578
+ statusCode: 500,
579
+ message: `Route middleware '${name}' is not registered`
580
+ }
581
+ },
582
+ state: context.state,
583
+ responseHeaders: context.responseHeaders
584
+ };
552
585
  }
553
586
  if (middleware.mode === "server" && !isServer)
554
587
  continue;
555
- if (middleware.mode === "client" && isServer)
556
- continue;
557
588
  try {
558
589
  const result = await middleware.handler(context);
559
590
  if (result) {
@@ -612,11 +643,11 @@ function getRouterScript() {
612
643
  const source = `
613
644
  ;(function(){
614
645
  'use strict';
615
- var ROUTER_REV=3;
646
+ var ROUTER_REV=7;
616
647
  if(window.__stxRouter&&window.__stxRouter.__rev===ROUTER_REV)return;
617
648
 
618
649
  // \u2500\u2500 Configuration \u2500\u2500
619
- var defaults={container:'main',loadingClass:'stx-navigating',viewTransitions:true,cache:true,scrollToTop:true,prefetch:true,progress:true,progressColor:'#78dce8',progressHeight:'2px',interceptAllLinks:false,prefetchCacheMax:50};
650
+ var defaults={container:'main',loadingClass:'stx-navigating',viewTransitions:true,cache:true,scrollToTop:true,prefetch:true,progress:true,progressColor:'#78dce8',progressHeight:'2px',interceptAllLinks:false,prefetchCacheMax:50,routeFocus:true,announceRoute:true,interceptForms:false};
620
651
  var o=Object.assign({},defaults,window.__stxRouterConfig||{},window.STX_ROUTER_OPTIONS||{});
621
652
  var containerSel=o.container;
622
653
  var debug=!!o.debug;
@@ -1127,6 +1158,23 @@ else {
1127
1158
  var wantsFragment=shouldUseFragmentResponse();
1128
1159
  return fetch(url,{headers:wantsFragment?{'X-STX-Router':'true','Accept':'text/html'}:{'Accept':'text/html'}}).then(function(r){
1129
1160
  if(!r.ok)throw new Error(r.status);
1161
+ // A route guard answered with a redirect and fetch followed it
1162
+ // transparently, so r.ok is the DESTINATION's 200 and this markup
1163
+ // belongs to r.url \u2014 not to the path that was requested. Without
1164
+ // retargeting, an auth guard's /login body is swapped in while the
1165
+ // address bar, the history entry and the cache key all still read
1166
+ // /dashboard: the user sees a login form at a URL that claims to be
1167
+ // the page they asked for, and reloading bounces them again (#1849).
1168
+ if(r.redirected&&r.url){
1169
+ var rd=new URL(r.url,location.href);
1170
+ // A guard that sends you off-origin (a hosted IdP) cannot be
1171
+ // resolved by swapping a fragment \u2014 hand it to the browser.
1172
+ if(rd.origin!==location.origin){location.href=r.url;return null}
1173
+ log('[router] guard redirected:',url,'->',rd.pathname);
1174
+ url=rd.pathname+rd.search+rd.hash;
1175
+ targetPath=cacheKey(url);
1176
+ targetHash=rd.hash;
1177
+ }
1130
1178
  // Before anything is parsed or swapped: a fragment from a different
1131
1179
  // build must not be handed to this page's runtime (#1772).
1132
1180
  var incomingBuild=r.headers.get('X-STX-Build')||'';
@@ -1188,6 +1236,60 @@ else {
1188
1236
  // pendingContainerAttrs \u2014 see the note above writeHistory about the cost of
1189
1237
  // adding an argument to swap() and its three call sites (#1807).
1190
1238
  var pendingLayoutDecl=null;
1239
+ // \u2500\u2500 Focus and route announcement \u2500\u2500
1240
+ // A fragment swap replaces content without a document load, so the browser
1241
+ // does none of what it normally does for a navigation. Focus stays on a link
1242
+ // that no longer exists, which the browser resets to <body> \u2014 a keyboard user
1243
+ // is silently returned to the top of the tab order \u2014 and a screen reader says
1244
+ // nothing at all, so there is no signal the page changed. Neither is fixable
1245
+ // from app code, because only the router knows a navigation happened (#1862).
1246
+ function routeAnnouncer(){
1247
+ var el=document.getElementById('stx-route-announcer');
1248
+ if(el)return el;
1249
+ el=document.createElement('div');
1250
+ el.id='stx-route-announcer';
1251
+ el.setAttribute('aria-live','polite');
1252
+ el.setAttribute('role','status');
1253
+ el.setAttribute('aria-atomic','true');
1254
+ // Visually hidden but still ANNOUNCED. display:none and visibility:hidden
1255
+ // are both skipped by screen readers, which would make this a silent no-op
1256
+ // that looks correct in the DOM.
1257
+ el.style.cssText='position:absolute;width:1px;height:1px;margin:-1px;padding:0;overflow:hidden;clip:rect(0 0 0 0);clip-path:inset(50%);white-space:nowrap;border:0';
1258
+ document.body.appendChild(el);
1259
+ return el;
1260
+ }
1261
+
1262
+ function announceRoute(){
1263
+ if(o.announceRoute===false)return;
1264
+ var c=getContainer();
1265
+ var h=c&&c.querySelector?c.querySelector('h1'):null;
1266
+ // The heading names the destination better than the title, which often
1267
+ // carries a site-name suffix. Falls back to the title, then gives up
1268
+ // rather than announcing an empty string.
1269
+ var label=(h&&(h.textContent||'').trim())||document.title||'';
1270
+ if(!label)return;
1271
+ var el=routeAnnouncer();
1272
+ // Cleared first: several screen readers ignore a live-region update whose
1273
+ // text is identical to what is already there, so navigating between two
1274
+ // pages with the same heading would announce only the first.
1275
+ el.textContent='';
1276
+ el.textContent=label;
1277
+ }
1278
+
1279
+ function focusAfterNavigation(hash){
1280
+ if(o.routeFocus===false)return;
1281
+ var target=null;
1282
+ if(hash){try{target=document.querySelector(hash)}catch(e){target=null}}
1283
+ if(!target)target=getContainer();
1284
+ if(!target||!target.focus)return;
1285
+ // The container is not naturally focusable. tabindex="-1" makes it
1286
+ // programmatically focusable without inserting it into the tab order.
1287
+ if(!target.hasAttribute('tabindex'))target.setAttribute('tabindex','-1');
1288
+ // preventScroll because scroll position is already decided just above \u2014
1289
+ // focusing would otherwise fight scrollToTop and the hash scrollIntoView.
1290
+ try{target.focus({preventScroll:true})}catch(e){try{target.focus()}catch(e2){}}
1291
+ }
1292
+
1191
1293
  function swap(html,url,pushState,hash){
1192
1294
  var fragMark=/^<!--stx-fragment(?: rt=([01]))?-->/.exec(html);
1193
1295
  var isFragment=!!fragMark;
@@ -1310,6 +1412,12 @@ else {
1310
1412
  if(o.scrollToTop&&!hash)window.scrollTo({top:0,behavior:'instant'});
1311
1413
  else if(hash){var el=document.querySelector(hash);if(el)el.scrollIntoView({behavior:'smooth'})}
1312
1414
  window.dispatchEvent(new CustomEvent('stx:navigate',{detail:{url:url}}));
1415
+ // Before page scripts run, so a page that focuses its own control on
1416
+ // mount still wins \u2014 its script executes after this.
1417
+ focusAfterNavigation(hash);
1418
+ // Deferred a tick because the title is applied by the caller after this
1419
+ // swap resolves, and the announcement should read the new one.
1420
+ setTimeout(announceRoute,0);
1313
1421
  // Execute page scripts FIRST \u2014 they define setup functions and set _latestSetup
1314
1422
  log('[router] frag scripts:', fragScripts.length);
1315
1423
  document.querySelectorAll('script[data-stx-page]').forEach(function(s){s.remove()});
@@ -1782,16 +1890,66 @@ else {
1782
1890
  }
1783
1891
 
1784
1892
  // \u2500\u2500 Link interception \u2500\u2500
1893
+ // Links the router must never touch, however it found them. This is the
1894
+ // opt-out set, and it is deliberately separate from shouldIntercept's
1895
+ // eligibility rules below: [data-stx-link] is an author saying "this is a
1896
+ // router link", which answers eligibility but must NOT override an explicit
1897
+ // opt-out or a non-navigational scheme.
1898
+ function isRouterExcluded(link){
1899
+ if(!link)return true;
1900
+ var href=link.getAttribute('href');
1901
+ if(!href)return true;
1902
+ if(href.startsWith('http')||href.startsWith('#')||href.startsWith('mailto:')||href.startsWith('tel:')||href.startsWith('javascript:'))return true;
1903
+ if(link.target==='_blank')return true;
1904
+ if(link.hasAttribute('data-stx-no-router')||link.hasAttribute('data-no-router')||link.hasAttribute('download'))return true;
1905
+ return false;
1906
+ }
1907
+
1908
+ // Paths the router can actually render, compiled from the server's own route
1909
+ // table and shipped as regex sources (#1864). Absent when discovery found
1910
+ // nothing, which means UNKNOWN, not "owns nothing" \u2014 treating it as the
1911
+ // latter would disable SPA navigation for a whole site.
1912
+ var ownedRoutes=null;
1913
+ (function(){
1914
+ var raw=o.ownedRoutes;
1915
+ if(!raw||!raw.length)return;
1916
+ var compiled=[];
1917
+ for(var i=0;i<raw.length;i++){
1918
+ try{compiled.push(new RegExp(raw[i]))}
1919
+ catch(e){}
1920
+ }
1921
+ if(compiled.length)ownedRoutes=compiled;
1922
+ })();
1923
+
1924
+ // Does the router own this path? Only meaningful when the route table made it
1925
+ // to the client; otherwise every path is treated as owned, preserving the old
1926
+ // behaviour.
1927
+ function routerOwns(pathname){
1928
+ if(!ownedRoutes)return true;
1929
+ for(var i=0;i<ownedRoutes.length;i++){
1930
+ if(ownedRoutes[i].test(pathname))return true;
1931
+ }
1932
+ return false;
1933
+ }
1934
+
1935
+ // Eligibility for auto-claiming an arbitrary same-origin anchor under
1936
+ // interceptAllLinks. Only reached for anchors the author did not mark.
1785
1937
  function shouldIntercept(link){
1786
- if(!link)return false;
1938
+ if(isRouterExcluded(link))return false;
1787
1939
  var href=link.getAttribute('href');
1788
- if(!href)return false;
1789
- if(href.startsWith('http')||href.startsWith('#')||href.startsWith('mailto:')||href.startsWith('tel:')||href.startsWith('javascript:'))return false;
1790
- if(link.target==='_blank')return false;
1791
- if(link.hasAttribute('data-stx-no-router')||link.hasAttribute('data-no-router')||link.hasAttribute('download'))return false;
1792
1940
  if(href.startsWith('?'))return true;
1793
1941
  if(href===location.pathname)return false;
1794
1942
  if(!getContainer())return false;
1943
+ // An endpoint the router cannot render must go to the browser. Claiming it
1944
+ // meant a failed fragment fetch, a fallback navigation, and the endpoint
1945
+ // hit TWICE \u2014 on OAuth redirects, that mints state twice.
1946
+ var path=href;
1947
+ var q=path.indexOf('?');if(q!==-1)path=path.slice(0,q);
1948
+ var h=path.indexOf('#');if(h!==-1)path=path.slice(0,h);
1949
+ if(path.charAt(0)==='/'&&!routerOwns(path)){
1950
+ log('[router] not a known route, leaving to the browser:',href);
1951
+ return false;
1952
+ }
1795
1953
  return true;
1796
1954
  }
1797
1955
 
@@ -1804,6 +1962,10 @@ else {
1804
1962
  if(e.metaKey||e.ctrlKey||e.shiftKey||e.altKey||e.button!==0)return;
1805
1963
  if(!e.target||!e.target.closest)return;
1806
1964
  var link=e.target.closest('[data-stx-link]');
1965
+ // A marked link still has to clear the opt-out set. It used to skip it
1966
+ // entirely, so data-no-router on a [data-stx-link] anchor was silently
1967
+ // ignored and an OAuth redirect got claimed by the router.
1968
+ if(link&&isRouterExcluded(link)){log('[router] excluded:',link.getAttribute('href'));return}
1807
1969
  if(!link&&o.interceptAllLinks){
1808
1970
  var anchor=e.target.closest('a[href]');
1809
1971
  if(anchor&&shouldIntercept(anchor))link=anchor;
@@ -1811,14 +1973,147 @@ else {
1811
1973
  if(!link){return}
1812
1974
  var href=link.getAttribute('href');
1813
1975
  log('[router] click intercepted:',href,'container:',!!getContainer(),'defaultPrevented:',e.defaultPrevented);
1814
- if(!href||href.startsWith('http')||href.startsWith('#')||href.startsWith('mailto:')||href.startsWith('tel:')){log('[router] skipped:',href);return}
1815
- if(link.target==='_blank'||link.hasAttribute('download'))return;
1816
1976
  e.preventDefault();
1817
1977
  e.stopPropagation();
1818
1978
  log('[router] navigating to:',href);
1819
1979
  navigate(withCurrentLocale(href));
1820
1980
  },true);
1821
1981
 
1982
+ // \u2500\u2500 Form submission \u2500\u2500
1983
+ // A submit IS a navigation, and the router only ever watched clicks. So any
1984
+ // in-app form tore down the SPA with a full document load and discarded
1985
+ // every signal on the page, and the only way out was a hand-written fetch in
1986
+ // a @submit handler with its own loading and error state.
1987
+ //
1988
+ // Gated exactly like links: a form opts in with data-stx-form, or
1989
+ // interceptForms claims them all \u2014 the same shape as [data-stx-link] /
1990
+ // interceptAllLinks. Deliberately not on by default: a POST has side
1991
+ // effects, so claiming one has to be a decision rather than something that
1992
+ // starts happening on upgrade (#1863).
1993
+ function formAttrOf(form,submitter,submitterAttr,formAttr){
1994
+ // A submit button overrides the form: formaction / formmethod / formenctype.
1995
+ var v=submitter&&submitter.getAttribute?submitter.getAttribute(submitterAttr):null;
1996
+ return (v!==null&&v!==undefined)?v:form.getAttribute(formAttr);
1997
+ }
1998
+
1999
+ function formTargetUrl(form,submitter){
2000
+ return formAttrOf(form,submitter,'formaction','action')||(location.pathname+location.search);
2001
+ }
2002
+
2003
+ function formMethodOf(form,submitter){
2004
+ return String(formAttrOf(form,submitter,'formmethod','method')||'get').toLowerCase();
2005
+ }
2006
+
2007
+ function formExcluded(form,submitter){
2008
+ if(!form||form.tagName!=='FORM')return true;
2009
+ if(form.hasAttribute('data-stx-no-router')||form.hasAttribute('data-no-router'))return true;
2010
+ // A form aimed at another browsing context is not this page's navigation.
2011
+ if(form.target&&form.target!=='_self')return true;
2012
+ try{
2013
+ // Resolving against location handles relative, absolute and foreign
2014
+ // schemes in one step: mailto: parses to a null origin and is excluded.
2015
+ if(new URL(formTargetUrl(form,submitter),location.href).origin!==location.origin)return true;
2016
+ }
2017
+ catch(e){return true}
2018
+ return false;
2019
+ }
2020
+
2021
+ function setFormPending(form,on){
2022
+ if(on){form.classList.add('stx-submitting');form.setAttribute('aria-busy','true')}
2023
+ else{form.classList.remove('stx-submitting');form.removeAttribute('aria-busy')}
2024
+ }
2025
+
2026
+ function submitForm(form,submitter){
2027
+ var method=formMethodOf(form,submitter);
2028
+ var action=formTargetUrl(form,submitter);
2029
+ var fd=new FormData(form);
2030
+ // A named submit button contributes its value, exactly as a native submit
2031
+ // would. FormData does not include it, so multi-button forms (Save vs
2032
+ // Delete) would otherwise lose the one piece of state that distinguishes them.
2033
+ if(submitter&&submitter.name)fd.append(submitter.name,submitter.value||'');
2034
+
2035
+ if(method==='get'){
2036
+ // A GET form is a link whose href the user filled in. Serialise and hand
2037
+ // it to the ordinary navigation path \u2014 no special swap handling, and it
2038
+ // inherits caching, prefetch and the guard-redirect behaviour for free.
2039
+ var qs=new URLSearchParams();
2040
+ fd.forEach(function(v,k){if(typeof v==='string')qs.append(k,v)});
2041
+ var gu=new URL(action,location.href);
2042
+ gu.search=qs.toString();
2043
+ return navigate(gu.pathname+gu.search+gu.hash);
2044
+ }
2045
+
2046
+ setFormPending(form,true);
2047
+ form.dispatchEvent(new CustomEvent('stx:form-submit',{bubbles:true,detail:{action:action,method:method}}));
2048
+
2049
+ var enctype=String(formAttrOf(form,submitter,'formenctype','enctype')||'').toLowerCase();
2050
+ // Respect the declared encoding. Handing FormData to fetch always produces
2051
+ // multipart, which a server expecting urlencoded will read as empty.
2052
+ var body=enctype.indexOf('multipart')===0?fd:new URLSearchParams(fd);
2053
+ var wantsFragment=shouldUseFragmentResponse();
2054
+
2055
+ return fetch(action,{
2056
+ method:method.toUpperCase(),
2057
+ body:body,
2058
+ headers:wantsFragment?{'X-STX-Router':'true','Accept':'text/html'}:{'Accept':'text/html'},
2059
+ credentials:'same-origin',
2060
+ }).then(function(r){
2061
+ // POST/redirect/GET. The server said where the result lives, so go there
2062
+ // through the normal path rather than swapping the POST's body under the
2063
+ // form's own URL \u2014 otherwise reloading re-submits.
2064
+ if(r.redirected&&r.url){
2065
+ var rd=new URL(r.url,location.href);
2066
+ if(rd.origin!==location.origin){location.href=r.url;return}
2067
+ return navigate(rd.pathname+rd.search+rd.hash);
2068
+ }
2069
+ return r.text().then(function(html){
2070
+ var isFrag=wantsFragment&&r.headers.get('X-STX-Fragment')==='true';
2071
+ pendingContainerAttrs=isFrag?(r.headers.get('X-STX-Container-Attrs')||''):'';
2072
+ pendingLayoutDecl=isFrag?null:{layout:r.headers.get('X-STX-Layout')||'',group:r.headers.get('X-STX-Layout-Group')||''};
2073
+ var marked=isFrag?fragmentMarker(r.headers.get('X-STX-Runtime')||'')+html:html;
2074
+ // Deliberately NOT cached: this body is the answer to one POST, and
2075
+ // serving it later for a GET of the same path would be a lie.
2076
+ // 'replace' so re-rendered validation errors do not stack one history
2077
+ // entry per attempt.
2078
+ return Promise.resolve(swap(marked,cacheKey(action),'replace','')).then(function(){
2079
+ applyTitle(r.headers.get('X-STX-Title')||'');
2080
+ });
2081
+ });
2082
+ }).catch(function(err){
2083
+ // No native re-submit on failure: that would send the POST a second time.
2084
+ // The app is told instead, and the form is left intact for a retry.
2085
+ console.error('[router] form submit failed:',err);
2086
+ form.dispatchEvent(new CustomEvent('stx:form-error',{bubbles:true,detail:{error:err,action:action}}));
2087
+ }).finally(function(){setFormPending(form,false)});
2088
+ }
2089
+
2090
+ document.addEventListener('submit',function(e){
2091
+ // Bubble phase, and defaultPrevented is honoured, so a page's own @submit
2092
+ // handler always wins. This is a fallback for forms nobody else claimed,
2093
+ // never an override of one that is already handled.
2094
+ if(e.defaultPrevented)return;
2095
+ var form=e.target;
2096
+ if(!form||form.tagName!=='FORM')return;
2097
+ if(!form.hasAttribute('data-stx-form')&&!form.hasAttribute('data-stx-link')&&!o.interceptForms)return;
2098
+ var submitter=e.submitter||null;
2099
+ if(formExcluded(form,submitter))return;
2100
+ // Without a container there is nothing to swap into, so a native submit is
2101
+ // the only thing that can work.
2102
+ if(!getContainer())return;
2103
+ // Constraint validation has already run by the time submit fires, so an
2104
+ // invalid form never reaches here.
2105
+ e.preventDefault();
2106
+ // A synchronous throw in here would be swallowed by dispatchEvent, and
2107
+ // preventDefault has already run \u2014 so the form would be silently dead with
2108
+ // nothing in the console. Report it and clear the pending state instead.
2109
+ try{submitForm(form,submitter)}
2110
+ catch(err){
2111
+ console.error('[router] form submit failed:',err);
2112
+ setFormPending(form,false);
2113
+ form.dispatchEvent(new CustomEvent('stx:form-error',{bubbles:true,detail:{error:err}}));
2114
+ }
2115
+ });
2116
+
1822
2117
  // \u2500\u2500 Back/forward \u2500\u2500
1823
2118
  window.addEventListener('popstate',function(){
1824
2119
  navigate(location.pathname+location.search+location.hash,false);
@@ -1829,13 +2124,23 @@ else {
1829
2124
  document.addEventListener('mouseover',function(e){
1830
2125
  if(!e.target||!e.target.closest)return;
1831
2126
  var link=e.target.closest('[data-stx-link]');
1832
- if(!link)return;
2127
+ // Same opt-out set as the click path. Without it, hovering a link the
2128
+ // router is not allowed to claim still fired a real GET at it \u2014 a
2129
+ // logout or OAuth URL was requested on hover alone.
2130
+ if(isRouterExcluded(link))return;
1833
2131
  var href=withCurrentLocale(link.getAttribute('href'));
1834
2132
  var key=cacheKey(href);
1835
2133
  if(cache[key]||prefetching[key])return;
1836
2134
  prefetching[key]=true;
1837
2135
  var wantsFragment=shouldUseFragmentResponse();
1838
2136
  fetch(href,{headers:wantsFragment?{'X-STX-Router':'true','Accept':'text/html'}:{'Accept':'text/html'}}).then(function(r){
2137
+ // Never cache a body that came from somewhere else. A guarded route
2138
+ // prefetched while logged out answers with /login's markup, and
2139
+ // storing it under the guarded key poisons the cache: the later real
2140
+ // navigation is a cache hit, so it swaps the login page in without
2141
+ // ever hitting the network, where the redirect check above would
2142
+ // have caught it (#1849).
2143
+ if(r.redirected)return null;
1839
2144
  var isFrag=wantsFragment&&r.headers.get('X-STX-Fragment')==='true';
1840
2145
  var pLayout=r.headers.get('X-STX-Layout')||'';
1841
2146
  var pGroup=r.headers.get('X-STX-Layout-Group')||'';
@@ -1843,7 +2148,7 @@ else {
1843
2148
  var pRuntime=r.headers.get('X-STX-Runtime')||'';
1844
2149
  return r.text().then(function(html){return{html:isFrag?fragmentMarker(pRuntime)+html:html,layout:pLayout,layoutGroup:pGroup,title:pTitle}});
1845
2150
  }).then(function(result){
1846
- if(o.cache)setCache(key,result.html,result.layout,result.layoutGroup,result.title);
2151
+ if(result&&o.cache)setCache(key,result.html,result.layout,result.layoutGroup,result.title);
1847
2152
  }).catch(function(){}).finally(function(){delete prefetching[key]});
1848
2153
  },true);
1849
2154
  }
@@ -1943,6 +2248,27 @@ else {
1943
2248
  fetch(url,{headers:wantsFragment?{'X-STX-Router':'true'}:{'Accept':'text/html'}}).then(function(r){var isFrag=wantsFragment&&r.headers.get('X-STX-Fragment')==='true';var pLayout=r.headers.get('X-STX-Layout')||'';var pGroup=r.headers.get('X-STX-Layout-Group')||'';var pTitle=r.headers.get('X-STX-Title')||'';var pRuntime=r.headers.get('X-STX-Runtime')||'';return r.text().then(function(html){return{html:isFrag?fragmentMarker(pRuntime)+html:html,layout:pLayout,layoutGroup:pGroup,title:pTitle}})}).then(function(result){setCache(key,result.html,result.layout,result.layoutGroup,result.title)}).catch(function(){});
1944
2249
  }
1945
2250
  },
2251
+ // Re-run the CURRENT route against the server and swap the result.
2252
+ //
2253
+ // There was no way to do this. invalidate() on a query only re-runs client
2254
+ // fetches, so anything the server rendered \u2014 a list the mutation just
2255
+ // changed, a count in the layout \u2014 could only be refreshed with a full
2256
+ // document load, which discards every signal on the page. That is the
2257
+ // exact thing the SPA exists to avoid, so apps reached for
2258
+ // location.reload() and lost their state (#1850, #1858).
2259
+ //
2260
+ // force:true both evicts the cache entry and defeats navigate's
2261
+ // same-URL early return, so this is exposing behaviour the router already
2262
+ // had rather than adding a second code path. 'replace' because a refresh
2263
+ // is not a new place \u2014 it must not add a history entry you can go Back to.
2264
+ refresh:function(){
2265
+ return navigate(location.pathname+location.search+location.hash,'replace',true);
2266
+ },
2267
+ // Expire ONE entry, so a mutation can invalidate just the page it affected
2268
+ // and let the next visit re-fetch, instead of throwing the whole cache away.
2269
+ invalidate:function(url){
2270
+ evictCache(cacheKey(url||(location.pathname+location.search)));
2271
+ },
1946
2272
  clearCache:function(){for(var k in cache)delete cache[k];for(var lk in layoutCache)delete layoutCache[lk];for(var gk in layoutGroupCache)delete layoutGroupCache[gk];cacheOrder.length=0},
1947
2273
  cache:cache,
1948
2274
  swap:swap,
@@ -1979,6 +2305,8 @@ export {
1979
2305
  navigateTo,
1980
2306
  matchRoute,
1981
2307
  loadMiddlewareFromDirectory,
2308
+ isNonPageRoutePattern,
2309
+ isNonPageRoutePath,
1982
2310
  hasMiddleware,
1983
2311
  getRouterScript,
1984
2312
  getMiddlewareNames,
@@ -2002,5 +2330,6 @@ export {
2002
2330
  clearMiddleware,
2003
2331
  bracketPathToRegex,
2004
2332
  abortNavigation,
2005
- Router
2333
+ Router,
2334
+ NON_PAGE_DIRS
2006
2335
  };
package/dist/serve.d.ts CHANGED
@@ -98,7 +98,27 @@ export declare function render404Page(opts: {
98
98
  isProduction: boolean
99
99
  }): string;
100
100
  export declare function resolveServeOpenUrl(port: number, openPath?: string): string;
101
+ /**
102
+ * Did rendering fail?
103
+ *
104
+ * processDirectives swallows a template error and returns an HTML comment as
105
+ * the entire document. That was then served with the page's own status — 200 —
106
+ * so a broken page looked healthy to every uptime check, CDN and browser cache
107
+ * that saw it, and the only evidence was a comment in a blank page
108
+ * (stacksjs/stx#1854).
109
+ *
110
+ * Both other stx servers already get this right: production-server.ts looks up
111
+ * a /500 route and dev-server/serve-app.ts calls renderErrorPage(500).
112
+ */
113
+ export declare function isRenderFailure(html: string): boolean;
101
114
  export declare function isRenderableCacheCandidate(html: string): boolean;
115
+ /**
116
+ * A rendered /500 page is usable as an error body only if it exists and did not
117
+ * ITSELF fail to render — otherwise serving it swaps one blank failure comment
118
+ * for another. Returns the page to serve, or null to fall through to the next
119
+ * candidate / the marker body (#1854).
120
+ */
121
+ export declare function usableErrorPage(rendered: string | null): string | null;
102
122
  export declare function bundleBrowserAsset(entrypoint: string): Promise<Response>;
103
123
  /**
104
124
  * Start the STX development server
@@ -147,7 +167,10 @@ export declare interface ServeRequestContext {
147
167
  ip: string
148
168
  locale: string | null
149
169
  params: Record<string, string>
170
+ method: string
171
+ request?: Request
150
172
  responseStatus?: number
173
+ actionRedirect?: string
151
174
  [key: string]: unknown
152
175
  }
153
176
  export declare interface ServeOptions {
package/dist/serve.js CHANGED
@@ -8718,7 +8718,9 @@ var defaultConfigDir4 = /* @__PURE__ */ resolve12(process20.cwd(), "config");
8718
8718
  var defaultGeneratedDir4 = /* @__PURE__ */ resolve12(process20.cwd(), "src/generated");
8719
8719
 
8720
8720
  // src/serve.ts
8721
- import { BUILD_ID_HEADER, extractPageResponseStatus, getBuildId, stateDir, stateDirName } from "@stacksjs/stx";
8721
+ import { BUILD_ID_HEADER, extractPageResponseStatus, findContainerRegion, getBuildId, mergeCrosswindConfig, stateDir, stateDirName } from "@stacksjs/stx";
8722
+ import { buildCodeFrame, locateFailureLine } from "@stacksjs/stx/build-message";
8723
+ import { clearBundleFailures, getBundleFailures } from "@stacksjs/stx/client-script-bundler";
8722
8724
  var defaultStxModule = import("@stacksjs/stx");
8723
8725
  var REACTIVE_TEMPLATE_DIRECTIVE_RE = /@for|:for|@if|:if|@else|:else|x-for|x-if|x-else/;
8724
8726
  function extractServerScriptsFromTemplate(content) {
@@ -8926,8 +8928,14 @@ function resolveServeOpenUrl(port, openPath = "/") {
8926
8928
  return `${baseUrl}/`;
8927
8929
  }
8928
8930
  }
8931
+ function isRenderFailure(html) {
8932
+ return html.includes("<!-- Template Processing failed:") || html.includes("<!-- stx rendering error -->");
8933
+ }
8929
8934
  function isRenderableCacheCandidate(html) {
8930
- return !html.includes("<!-- Template Processing failed:") && !html.includes("<!-- stx rendering error -->");
8935
+ return !isRenderFailure(html);
8936
+ }
8937
+ function usableErrorPage(rendered) {
8938
+ return rendered && !isRenderFailure(rendered) ? rendered : null;
8931
8939
  }
8932
8940
  var defaultStxConfig = {
8933
8941
  partialsDir: "partials",
@@ -9022,6 +9030,11 @@ async function serve(options) {
9022
9030
  checkEnv: false,
9023
9031
  verbose: false
9024
9032
  });
9033
+ try {
9034
+ const stxMod = options.stxModule ? options.stxModule : await defaultStxModule;
9035
+ if (stxMod && typeof stxMod.resolveStxDirectories === "function")
9036
+ stxMod.resolveStxDirectories(stxConfig, process19.cwd());
9037
+ } catch {}
9025
9038
  try {
9026
9039
  const stxMod = options.stxModule ? options.stxModule : await defaultStxModule;
9027
9040
  if (stxMod && typeof stxMod.loadStxConfig === "function") {
@@ -9081,16 +9094,84 @@ After installing: bun add bun-plugin-stx`);
9081
9094
  }
9082
9095
  }
9083
9096
  }
9084
- const HMR_CLIENT_SCRIPT = `<script data-stx-hmr>(()=>{if(window.__stxHmr)return;window.__stxHmr=1;function bust(){var ls=document.querySelectorAll('link[rel="stylesheet"]');for(var i=0;i<ls.length;i++){var l=ls[i];var u=new URL(l.href,location.href);u.searchParams.set('v',Date.now().toString(36));l.href=u.toString()}}var es=new EventSource('/_stx/hmr');es.onmessage=function(e){try{var m=JSON.parse(e.data);if(m.type==='reload')location.reload();else if(m.type==='css')bust()}catch(_){}};es.onerror=function(){if(es.readyState===2){setTimeout(function(){location.reload()},400)}}})()</script>`;
9097
+ const HMR_OVERLAY_JS = `
9098
+ function __stxEsc(s){return String(s).replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;')}
9099
+ function __stxOverlay(errs){
9100
+ var id='__stx_build_error';
9101
+ var el=document.getElementById(id);
9102
+ if(!errs||!errs.length){if(el&&el.parentNode)el.parentNode.removeChild(el);return}
9103
+ if(!el){el=document.createElement('div');el.id=id;document.body.appendChild(el)}
9104
+ el.setAttribute('style','position:fixed;top:0;left:0;right:0;bottom:0;z-index:2147483647;background:rgba(12,12,14,.98);color:#e8e8ea;font:13px/1.7 ui-monospace,SFMono-Regular,Menlo,monospace;padding:40px 32px;overflow:auto');
9105
+ var w=0,i,j;
9106
+ for(i=0;i<errs.length;i++){var fr=errs[i].frame||[];for(j=0;j<fr.length;j++){var ln=String(fr[j].number).length;if(ln>w)w=ln}}
9107
+ var h='<div style="max-width:1040px;margin:0 auto">';
9108
+ h+='<div style="color:#ff6b6b;font-size:15px;font-weight:600;margin-bottom:6px">Client script failed to bundle</div>';
9109
+ h+='<div style="color:#8b8b95;margin-bottom:28px">The page rendered, but this script shipped unbundled &mdash; its imports did not resolve, so its bindings do nothing.</div>';
9110
+ for(i=0;i<errs.length;i++){
9111
+ var e=errs[i];
9112
+ var where=__stxEsc(e.file||'')+(e.line?':'+e.line+(e.column?':'+e.column:''):'');
9113
+ h+='<div style="margin-bottom:30px">';
9114
+ h+='<div style="color:#ff6b6b;margin-bottom:10px">&#10006; <span style="color:#f2f2f4">'+where+'</span>&nbsp;&nbsp;'+__stxEsc(e.message)+'</div>';
9115
+ var frame=e.frame||[];
9116
+ if(frame.length){
9117
+ h+='<pre style="margin:0;padding:14px 16px;background:#18181c;border-radius:6px;overflow-x:auto">';
9118
+ for(j=0;j<frame.length;j++){
9119
+ var f=frame[j];
9120
+ var n=String(f.number);while(n.length<w)n=' '+n;
9121
+ var style=f.isError?'color:#ffd7d7;background:#3b1e1e;display:block':'color:#8b8b95;display:block';
9122
+ h+='<span style="'+style+'">'+(f.isError?'&gt; ':'&nbsp;&nbsp;')+n+' | '+__stxEsc(f.text)+'</span>';
9123
+ }
9124
+ h+='</pre>';
9125
+ }
9126
+ h+='</div>';
9127
+ }
9128
+ h+='<div style="color:#6b6b75">This clears itself when the bundle succeeds.</div></div>';
9129
+ el.innerHTML=h;
9130
+ }
9131
+ `;
9132
+ const HMR_CLIENT_SCRIPT = `<script data-stx-hmr>(()=>{if(window.__stxHmr)return;window.__stxHmr=1;${HMR_OVERLAY_JS}function bust(){var ls=document.querySelectorAll('link[rel="stylesheet"]');for(var i=0;i<ls.length;i++){var l=ls[i];var u=new URL(l.href,location.href);u.searchParams.set('v',Date.now().toString(36));l.href=u.toString()}}var es=new EventSource('/_stx/hmr');function swapFragment(){var r=window.stxRouter;if(r&&typeof r.refresh==='function'){try{return r.refresh().then(function(ok){if(!ok)location.reload()},function(){location.reload()})}catch(_){location.reload()}}else{location.reload()}}function reloadStores(){var s=window.stx;if(!s||typeof s.__hmrReplaceStores!=='function'){location.reload();return}fetch('/_stx/stores.js',{cache:'no-store'}).then(function(r){return r.ok?r.text():null}).then(function(code){if(code===null){location.reload();return}s.__hmrReplaceStores(code)}).catch(function(){location.reload()})}es.onmessage=function(e){try{var m=JSON.parse(e.data);if(m.type==='reload')location.reload();else if(m.type==='css')bust();else if(m.type==='fragment')swapFragment();else if(m.type==='store')reloadStores();else if(m.type==='build-error')__stxOverlay(m.errors)}catch(_){}};es.onerror=function(){if(es.readyState===2){setTimeout(function(){location.reload()},400)}}})()</script>`;
9085
9133
  function injectHmrClient(html) {
9086
9134
  if (!html)
9087
9135
  return html;
9136
+ refreshBuildErrors();
9088
9137
  const closeBody = html.lastIndexOf("</body>");
9089
9138
  if (closeBody === -1) {
9090
9139
  return html + HMR_CLIENT_SCRIPT;
9091
9140
  }
9092
9141
  return html.slice(0, closeBody) + HMR_CLIENT_SCRIPT + html.slice(closeBody);
9093
9142
  }
9143
+ let currentBuildErrors = [];
9144
+ async function refreshBuildErrors() {
9145
+ const failures = getBundleFailures();
9146
+ clearBundleFailures();
9147
+ const errors = [];
9148
+ for (const failure of failures) {
9149
+ const details = failure.details?.length ? failure.details : [{ file: failure.filePath, message: failure.message }];
9150
+ for (const detail of details) {
9151
+ let frame = [];
9152
+ let line;
9153
+ if (detail.file) {
9154
+ try {
9155
+ const source = await Bun.file(detail.file).text();
9156
+ line = locateFailureLine(source, detail.lineText);
9157
+ if (line)
9158
+ frame = buildCodeFrame(source, line);
9159
+ } catch {}
9160
+ }
9161
+ errors.push({
9162
+ file: detail.file,
9163
+ line,
9164
+ column: line ? detail.column : undefined,
9165
+ message: detail.message,
9166
+ frame
9167
+ });
9168
+ }
9169
+ }
9170
+ if (JSON.stringify(errors) === JSON.stringify(currentBuildErrors))
9171
+ return;
9172
+ currentBuildErrors = errors;
9173
+ broadcastHmr({ type: "build-error", errors });
9174
+ }
9094
9175
  const watchersStarted = new Set;
9095
9176
  const startWatcher = (dir) => {
9096
9177
  if (watchersStarted.has(dir))
@@ -9114,19 +9195,44 @@ After installing: bun add bun-plugin-stx`);
9114
9195
  htmlCache.clear();
9115
9196
  crosswindCssCache.clear();
9116
9197
  resolvedStxModule?.clearDevCaches?.();
9117
- }
9118
- broadcastHmr(isCss && !isStxLike ? { type: "css", file: f } : { type: "reload", file: f });
9198
+ resolvedStxModule?.clearStoreCache?.();
9199
+ resolvedStxModule?.clearComposableCache?.();
9200
+ if (layoutsDir && f.endsWith(".stx") && nodePath.resolve(f).startsWith(nodePath.resolve(layoutsDir)))
9201
+ writeLayoutTypes();
9202
+ }
9203
+ const resolved = nodePath.resolve(dir, f);
9204
+ const isStore = resolved.startsWith(nodePath.resolve(storesDir)) || composableDirs.some((d) => resolved.startsWith(nodePath.resolve(d)));
9205
+ const isLayout = !!layoutsDir && resolved.startsWith(nodePath.resolve(layoutsDir));
9206
+ if (isCss && !isStxLike)
9207
+ broadcastHmr({ type: "css", file: f });
9208
+ else if (isStore)
9209
+ broadcastHmr({ type: "store", file: f });
9210
+ else if (isStxLike && !isLayout)
9211
+ broadcastHmr({ type: "fragment", file: f });
9212
+ else
9213
+ broadcastHmr({ type: "reload", file: f });
9119
9214
  });
9120
9215
  watcher.on("error", () => {});
9121
9216
  watchersStarted.add(dir);
9122
9217
  } catch {}
9123
9218
  };
9219
+ const writeLayoutTypes = async () => {
9220
+ try {
9221
+ const stxMod = options.stxModule ? options.stxModule : await defaultStxModule;
9222
+ stxMod?.generateLayoutTypes?.(layoutsDir, stxMod?.stateDir?.(process19.cwd()));
9223
+ } catch {}
9224
+ };
9225
+ await writeLayoutTypes();
9226
+ const storesDir = nodePath.resolve(stxConfig.root || ".", stxConfig.storesDir || "stores");
9227
+ const composableDirs = stxConfig.composablesDir ? [nodePath.resolve(stxConfig.root || ".", stxConfig.composablesDir)] : ["composables", "functions"].map((d) => nodePath.resolve(stxConfig.root || ".", d));
9124
9228
  for (const dir of [
9125
9229
  componentsDir,
9126
9230
  layoutsDir,
9127
9231
  partialsDir,
9128
9232
  publicDir,
9129
9233
  "resources/assets",
9234
+ storesDir,
9235
+ ...composableDirs,
9130
9236
  ...options.watchDirs ?? []
9131
9237
  ]) {
9132
9238
  if (!dir)
@@ -9414,7 +9520,7 @@ After installing: bun add bun-plugin-stx`);
9414
9520
  pageMiddlewarePatterns.length = 0;
9415
9521
  await Promise.all(files.map((f) => detectPageMiddleware(f)));
9416
9522
  try {
9417
- const { Router } = await import("./chunk-5pnbtf0j.js");
9523
+ const { Router } = await import("./chunk-z66h8w4a.js");
9418
9524
  const pagesDirs = patterns.map((p) => p.replace(/\/$/, "")).filter(Boolean);
9419
9525
  const router = new Router(process19.cwd(), { pagesDirs, stateDir: stateDirName() });
9420
9526
  if (!options.quiet)
@@ -9537,21 +9643,15 @@ After installing: bun add bun-plugin-stx`);
9537
9643
  });
9538
9644
  }
9539
9645
  const userConfig = await crosswindUserConfigPromise;
9540
- if (userConfig.safelist) {
9541
- for (const cls of userConfig.safelist)
9542
- classes.add(cls);
9543
- }
9646
+ const merged = mergeCrosswindConfig(cw.config, userConfig);
9647
+ const generatorConfig = merged.config;
9648
+ const { includePreflight, minify } = merged;
9649
+ for (const cls of merged.safelist)
9650
+ classes.add(cls);
9544
9651
  const cacheKey = [...classes].sort().join(" ");
9545
9652
  const cached = crosswindCssCache.get(cacheKey);
9546
9653
  if (cached !== undefined)
9547
9654
  return cached;
9548
- const generatorConfig = { ...cw.config, preflight: true, minify: false };
9549
- if (userConfig.shortcuts) {
9550
- generatorConfig.shortcuts = { ...generatorConfig.shortcuts || {}, ...userConfig.shortcuts };
9551
- }
9552
- if (userConfig.theme) {
9553
- generatorConfig.theme = { ...generatorConfig.theme || {}, ...userConfig.theme };
9554
- }
9555
9655
  const generator = new cw.CSSGenerator(generatorConfig);
9556
9656
  for (const className of classes) {
9557
9657
  generator.generate(className);
@@ -9565,7 +9665,7 @@ After installing: bun add bun-plugin-stx`);
9565
9665
  for (const p of parts)
9566
9666
  generator.generate(p);
9567
9667
  }
9568
- const baseCss = generator.toCSS(true, false);
9668
+ const baseCss = generator.toCSS(includePreflight, minify);
9569
9669
  for (const [name, classStr] of Object.entries(shortcuts)) {
9570
9670
  if (!classes.has(name))
9571
9671
  continue;
@@ -9600,12 +9700,65 @@ After installing: bun add bun-plugin-stx`);
9600
9700
  return "";
9601
9701
  }
9602
9702
  }
9703
+ async function parseFormBody(request) {
9704
+ const type = request.headers.get("content-type") || "";
9705
+ const form = {};
9706
+ const put = (key, value) => {
9707
+ const existing = form[key];
9708
+ if (existing === undefined)
9709
+ form[key] = value;
9710
+ else if (Array.isArray(existing))
9711
+ existing.push(value);
9712
+ else
9713
+ form[key] = [existing, value];
9714
+ };
9715
+ if (type.includes("multipart/form-data")) {
9716
+ const data = await request.formData();
9717
+ for (const [key, value] of data.entries()) {
9718
+ if (typeof value === "string")
9719
+ put(key, value);
9720
+ }
9721
+ return form;
9722
+ }
9723
+ if (type.includes("application/x-www-form-urlencoded")) {
9724
+ for (const [key, value] of new URLSearchParams(await request.text()).entries())
9725
+ put(key, value);
9726
+ return form;
9727
+ }
9728
+ return form;
9729
+ }
9730
+ async function runPageAction(context, reqCtx) {
9731
+ if (!reqCtx || reqCtx.method === "GET" || reqCtx.method === "HEAD")
9732
+ return;
9733
+ if (typeof context.action !== "function" || !reqCtx.request)
9734
+ return;
9735
+ let form = {};
9736
+ try {
9737
+ form = await parseFormBody(reqCtx.request);
9738
+ } catch {}
9739
+ const result = await context.action({
9740
+ request: reqCtx.request,
9741
+ form,
9742
+ params: reqCtx.params ?? {},
9743
+ query: context.query ?? {},
9744
+ cookies: reqCtx.cookies ?? {}
9745
+ });
9746
+ if (result && typeof result === "object") {
9747
+ const to = result.redirect;
9748
+ if (typeof to === "string" && to) {
9749
+ reqCtx.actionRedirect = to;
9750
+ return;
9751
+ }
9752
+ Object.assign(context, result);
9753
+ }
9754
+ }
9603
9755
  async function processTemplate(filePath, reqCtx) {
9604
9756
  const content = await Bun.file(filePath).text();
9605
9757
  if (reqCtx)
9606
9758
  reqCtx.responseStatus = extractPageResponseStatus(content) ?? 200;
9607
9759
  const skipCacheHint = /(?:^|[^\w$])__stx_skip_cache\s*=\s*true/.test(content);
9608
- if (ENABLE_HTML_CACHE && !skipCacheHint) {
9760
+ const isMutating = !!reqCtx && reqCtx.method !== "GET" && reqCtx.method !== "HEAD";
9761
+ if (ENABLE_HTML_CACHE && !skipCacheHint && !isMutating) {
9609
9762
  const cacheKey = htmlCacheKey(filePath, reqCtx);
9610
9763
  const cachedEntry = htmlCache.get(cacheKey);
9611
9764
  if (cachedEntry && await templateSignatureFresh(cachedEntry.signature)) {
@@ -9627,6 +9780,7 @@ After installing: bun add bun-plugin-stx`);
9627
9780
  for (const scriptBody of serverScripts) {
9628
9781
  await extractVariables(scriptBody, context, filePath);
9629
9782
  }
9783
+ await runPageAction(context, reqCtx);
9630
9784
  context.__stx_head_preset = true;
9631
9785
  const config5 = {
9632
9786
  ...defaultConfig4,
@@ -9674,7 +9828,7 @@ After installing: bun add bun-plugin-stx`);
9674
9828
  } else {
9675
9829
  output = output.replace(/<template[^>]*>/gi, "").replace(/<\/template>/gi, "");
9676
9830
  }
9677
- if (ENABLE_HTML_CACHE && !context.__stx_skip_cache && !skipCacheHint && isRenderableCacheCandidate(output)) {
9831
+ if (ENABLE_HTML_CACHE && !context.__stx_skip_cache && !skipCacheHint && !isMutating && isRenderableCacheCandidate(output)) {
9678
9832
  const signature = await buildTemplateSignature(filePath, dependencies);
9679
9833
  htmlCache.set(htmlCacheKey(filePath, reqCtx), { html: output, signature, status: reqCtx?.responseStatus ?? 200 });
9680
9834
  }
@@ -10046,7 +10200,9 @@ ${html}`;
10046
10200
  cookies: activeServeCookies,
10047
10201
  ip: activeServeIp,
10048
10202
  locale: activeServeLocale,
10049
- params: {}
10203
+ params: {},
10204
+ method: (req.method || "GET").toUpperCase(),
10205
+ request: req
10050
10206
  };
10051
10207
  try {
10052
10208
  if (req.method === "OPTIONS") {
@@ -10062,6 +10218,11 @@ ${html}`;
10062
10218
  c.enqueue(hmrEncoder.encode(`data: {"type":"connected"}
10063
10219
 
10064
10220
  `));
10221
+ if (currentBuildErrors.length > 0) {
10222
+ c.enqueue(hmrEncoder.encode(`data: ${JSON.stringify({ type: "build-error", errors: currentBuildErrors })}
10223
+
10224
+ `));
10225
+ }
10065
10226
  keepalive = setInterval(() => {
10066
10227
  try {
10067
10228
  c.enqueue(hmrEncoder.encode(`: keepalive
@@ -10102,6 +10263,18 @@ ${html}`;
10102
10263
  return new Response(null, { headers });
10103
10264
  return new Response(content2, { headers });
10104
10265
  }
10266
+ if (path === "/_stx/stores.js") {
10267
+ const stx = await stxModule;
10268
+ const code = typeof stx.getStoreScript === "function" ? await stx.getStoreScript() : null;
10269
+ return new Response(code ?? "", {
10270
+ status: code === null ? 404 : 200,
10271
+ headers: {
10272
+ "Content-Type": "application/javascript; charset=utf-8",
10273
+ "Cache-Control": "no-store",
10274
+ ...corsHeaders
10275
+ }
10276
+ });
10277
+ }
10105
10278
  const crosswindAsset = path.match(/^\/_stx\/crosswind\.([a-f0-9]{16})\.css$/);
10106
10279
  if (crosswindAsset) {
10107
10280
  const stx = await stxModule;
@@ -10280,6 +10453,12 @@ ${html}`;
10280
10453
  }
10281
10454
  }
10282
10455
  const content = await getRoute(path, reqCtx);
10456
+ if (reqCtx.actionRedirect) {
10457
+ return new Response(null, {
10458
+ status: 303,
10459
+ headers: { ...corsHeaders, Location: reqCtx.actionRedirect }
10460
+ });
10461
+ }
10283
10462
  if (content) {
10284
10463
  const responseStatus = reqCtx.responseStatus ?? 200;
10285
10464
  const isSpaNav = req.headers.get("X-STX-Router") === "true";
@@ -10319,8 +10498,13 @@ ${html}`;
10319
10498
  headStyles.push(crosswindLinkMatch[0]);
10320
10499
  }
10321
10500
  }
10322
- const mainOpenMatch = fragment.match(/<main\b[^>]*>/i);
10323
- const mainCloseIdx = fragment.lastIndexOf("</main>");
10501
+ const containerSelector = stxConfig?.router?.container || "main";
10502
+ const containerRegion = findContainerRegion(fragment, containerSelector);
10503
+ if (!containerRegion) {
10504
+ console.warn(`[stx] No SPA swap container matching "${containerSelector}" in ${new URL(req.url).pathname}. SPA navigation is disabled for links on this page; the full document will load instead. Add a matching element, or point router.container at one this page has.`);
10505
+ }
10506
+ const mainOpenMatch = containerRegion ? { 0: containerRegion.openTag, index: containerRegion.openIndex } : null;
10507
+ const mainCloseIdx = containerRegion ? containerRegion.end : -1;
10324
10508
  if (mainOpenMatch && mainCloseIdx !== -1) {
10325
10509
  const bodyMatch = content.match(/<body\b[^>]*>/i);
10326
10510
  if (bodyMatch) {
@@ -10333,7 +10517,7 @@ ${html}`;
10333
10517
  headStyles.push(bodyStyleMatch[0]);
10334
10518
  }
10335
10519
  }
10336
- containerAttrs = mainOpenMatch[0].replace(/^<main\b/i, "").replace(/\/?>$/, "").trim();
10520
+ containerAttrs = mainOpenMatch[0].replace(new RegExp(`^<${containerRegion.tagName}\\b`, "i"), "").replace(/\/?>$/, "").trim();
10337
10521
  const mainStart = mainOpenMatch.index + mainOpenMatch[0].length;
10338
10522
  mainContentStart = mainStart;
10339
10523
  mainContentEnd = mainCloseIdx;
@@ -10383,7 +10567,7 @@ ${trailingPageScripts.join(`
10383
10567
  const pageTitle = titleMatch ? titleMatch[1].trim() : "";
10384
10568
  const shipsRuntime = content.includes("data-stx-runtime");
10385
10569
  return new Response(fragment, {
10386
- status: responseStatus,
10570
+ status: isRenderFailure(content) ? 500 : responseStatus,
10387
10571
  headers: {
10388
10572
  "Content-Type": "text/html; charset=utf-8",
10389
10573
  "X-STX-Fragment": "true",
@@ -10404,8 +10588,28 @@ ${trailingPageScripts.join(`
10404
10588
  runtimeCount++;
10405
10589
  return runtimeCount === 1 ? match : "";
10406
10590
  });
10591
+ const pageRenderFailed = isRenderFailure(cleaned);
10592
+ if (pageRenderFailed) {
10593
+ console.error(`[stx] render failed for ${new URL(req.url).pathname} \u2014 serving 500`);
10594
+ for (const custom500 of ["/500", "/errors/500"]) {
10595
+ try {
10596
+ const errorPage = usableErrorPage(await getRoute(custom500, reqCtx));
10597
+ if (errorPage) {
10598
+ const isProd2 = isProductionServe();
10599
+ return new Response(isProd2 ? errorPage : injectHmrClient(errorPage), {
10600
+ status: 500,
10601
+ headers: {
10602
+ "Content-Type": "text/html; charset=utf-8",
10603
+ "Cache-Control": "no-store",
10604
+ ...corsHeaders
10605
+ }
10606
+ });
10607
+ }
10608
+ } catch {}
10609
+ }
10610
+ }
10407
10611
  return new Response(injectHmrClient(cleaned), {
10408
- status: responseStatus,
10612
+ status: pageRenderFailed ? 500 : responseStatus,
10409
10613
  headers: {
10410
10614
  "Content-Type": "text/html; charset=utf-8",
10411
10615
  "Cache-Control": "no-store",
@@ -10664,6 +10868,7 @@ if (import.meta.main) {
10664
10868
  main().catch(console.error);
10665
10869
  }
10666
10870
  export {
10871
+ usableErrorPage,
10667
10872
  staticCacheControl,
10668
10873
  serve,
10669
10874
  routeSpecificity,
@@ -10671,6 +10876,7 @@ export {
10671
10876
  render404Page,
10672
10877
  isStaticAssetPath,
10673
10878
  isRenderableCacheCandidate,
10879
+ isRenderFailure,
10674
10880
  isProductionServe,
10675
10881
  extractServerScriptsFromTemplate,
10676
10882
  csrfTokenToMint,
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "bun-plugin-stx",
3
3
  "type": "module",
4
- "version": "0.2.155",
4
+ "version": "0.2.156",
5
5
  "description": "A Bun plugin that allows for using Laravel Blade-like syntax.",
6
6
  "author": "Chris Breuer <chris@stacksjs.org>",
7
7
  "license": "MIT",