bun-plugin-stx 0.2.153 → 0.2.155

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.
@@ -853,11 +853,28 @@ function getRouterScript() {
853
853
  // _cleanupContainer removed on the way out. Anything unstamped falls back to
854
854
  // the old sniff, so a page rendered by an older server behaves exactly as
855
855
  // before.
856
+ //
857
+ // The sniff itself now asks what the script REGISTERS rather than how it is
858
+ // spelled (#1828). cleanupContainer deletes window.stx._scopes entries,
859
+ // element disposers and destroy hooks on the way out, so the scripts that
860
+ // must run again are exactly the ones that put a scope back. That is
861
+ // detectable; a leading character is not.
862
+ //
863
+ // Deliberately NOT "strip leading comments, then test the first character",
864
+ // which is the obvious repair and is wrong in the expensive direction. Three
865
+ // emitters open with a comment and MUST NOT re-run: the animation scripts
866
+ // register matchMedia and DOMContentLoaded listeners plus an
867
+ // IntersectionObserver, and the STX lifecycle runtime owns a Map of live
868
+ // instances. Comment-stripping would flip all three to re-running on every
869
+ // navigation, leaking a listener and an observer each time and resetting the
870
+ // instance registry \u2014 trading a silent missing component for a silent leak.
871
+ var REGISTERS_SCOPE=/window\\.stx\\.mount|_scopes\\s*\\[|\\.initScope\\s*\\(|__stxComponentFactories\\s*\\[/;
856
872
  function runsAlways(declared, code){
857
873
  if(declared==='always')return true;
858
874
  if(declared==='once')return false;
875
+ if(REGISTERS_SCOPE.test(code))return true;
859
876
  var head=code.trimStart();
860
- return head.charAt(0)==='('||head.charAt(0)===';'||code.indexOf('window.stx.mount')>-1;
877
+ return head.charAt(0)==='('||head.charAt(0)===';';
861
878
  }
862
879
  function hashScript(code){
863
880
  var h=0;for(var i=0;i<code.length;i++){h=((h<<5)-h)+code.charCodeAt(i);h|=0}
@@ -870,11 +887,99 @@ function getRouterScript() {
870
887
  var match=code.match(/function (__stx_setup_[A-Za-z0-9_]+)\\s*\\(/);
871
888
  return match?match[1]:'';
872
889
  }
890
+ // Does this fetched DOCUMENT carry the signals runtime?
891
+ //
892
+ // Unlike a fragment, a document holds the answer directly, so there is
893
+ // nothing to infer from markers or ask the server for: look for the script.
894
+ // Both emitted shapes carry data-stx-runtime \u2014 inline, and the serve-mode
895
+ // src="/_stx/runtime.js" \u2014 with the content sniff behind it for a document
896
+ // rendered by a server old enough not to stamp the attribute.
897
+ function documentShipsRuntime(doc,html){
898
+ if(doc&&doc.querySelector&&doc.querySelector('script[data-stx-runtime]'))return true;
899
+ return html.indexOf("'use strict';var cloakStyle")!==-1
900
+ ||(html.indexOf('_cleanupContainer')!==-1&&html.indexOf('signals runtime loading')!==-1);
901
+ }
873
902
  function isSignalsRuntimeScript(script,code){
874
903
  return !!(script&&script.hasAttribute&&script.hasAttribute('data-stx-runtime'))
875
904
  ||(code.indexOf('_cleanupContainer')!==-1&&code.indexOf('signals runtime loading')!==-1)
876
905
  ||code.indexOf("'use strict';var cloakStyle")!==-1;
877
906
  }
907
+ // A fragment never carries the signals runtime: the server strips the runtime
908
+ // IIFE out of it, and every re-execution path here filters it out again. So a
909
+ // page that never needed a runtime cannot hydrate a fragment that does \u2014 the
910
+ // setup function would be defined but never invoked, scope IIFEs throw while
911
+ // destructuring window.stx, and every x-cloak element stays display:none
912
+ // forever against the cloak style shipped on EVERY page. Hand those off to a
913
+ // real navigation, which loads the destination's own runtime (#1809).
914
+ //
915
+ // The SERVER decides this now, and says so in X-STX-Runtime, which travels
916
+ // into the fragment marker as rt=1 / rt=0 so it survives the prefetch cache.
917
+ // It is answering a question it knows the answer to exactly \u2014 did I put a
918
+ // runtime in this page \u2014 where the router could only guess from markup.
919
+ //
920
+ // Guessing missed a whole page shape (#1827). Every marker below originates in
921
+ // a client SCRIPT, so a page with reactive syntax and no script anywhere \u2014 a
922
+ // <script server>-only login form built from :if / x-model / :disabled \u2014 emits
923
+ // none of them, while the server ships it the runtime. Whatever root marker it
924
+ // does get lands on <body>, which a fragment excludes by construction. So
925
+ // <p :if="error" x-cloak>Invalid credentials</p>
926
+ // swapped into a runtime-less page keeps the x-cloak the server stamped on it,
927
+ // and only the runtime ever removes that: server-rendered text, in the DOM, at
928
+ // display:none forever.
929
+ //
930
+ // The same held for x-data on the routed container, whose scope attribute is
931
+ // hoisted out of the fragment body into X-STX-Container-Attrs. Asking the
932
+ // server covers both without either being enumerated.
933
+ //
934
+ // The sniff stays as the fallback for a server too old to declare it. Its
935
+ // discriminators matter. x-cloak and data-stx-scoped are NOT usable: each
936
+ // fragment inlines the head styles, cloak rule included, and the appearance
937
+ // bootstrap emits a self-contained data-stx-scoped script on pages with no
938
+ // reactivity at all. A bare window.stx is not usable either \u2014 the server
939
+ // prepends a _latestSetup=null clear script to every fragment. Any of those
940
+ // would make this always true and disable SPA navigation everywhere.
941
+ function fragmentNeedsRuntime(frag,declared){
942
+ if(declared==='1')return true;
943
+ if(declared==='0')return false;
944
+ if(frag.indexOf('__stx_setup_')!==-1
945
+ ||frag.indexOf('data-stx-scope=')!==-1
946
+ ||frag.indexOf('data-stx-reactive')!==-1
947
+ ||/=\\s*window\\.stx\\s*;/.test(frag))return true;
948
+ var content=stripInertRegions(frag);
949
+ return BARE_DIRECTIVE.test(content)||content.indexOf('{{')!==-1;
950
+ }
951
+ // Documentation is both the largest population of runtime-less pages and the
952
+ // likeliest to contain directive syntax as literal CONTENT, so the fallback
953
+ // reads past the regions where that content lives. A page of stx examples must
954
+ // not full-reload on every hop through the docs \u2014 that is the regression
955
+ // #1809 existed to remove.
956
+ function stripInertRegions(frag){
957
+ return frag
958
+ .replace(new RegExp('<scr'+'ipt\\\\b[\\\\s\\\\S]*?<\\\\/scr'+'ipt>','gi'),'')
959
+ .replace(/<style\\b[\\s\\S]*?<\\/style>/gi,'')
960
+ .replace(/<pre\\b[\\s\\S]*?<\\/pre>/gi,'')
961
+ .replace(/<code\\b[\\s\\S]*?<\\/code>/gi,'')
962
+ .replace(/<!--[\\s\\S]*?-->/g,'');
963
+ }
964
+ // Anchored on the whitespace before an attribute name and the = that follows,
965
+ // so it reads attribute POSITIONS rather than characters. Bare ':' and '@' are
966
+ // everywhere in ordinary content \u2014 'xlink:href', 'background: red', '10:00',
967
+ // an email address \u2014 and none of those sit in that position.
968
+ //
969
+ // The x- arm is deliberately open rather than a list of known directives:
970
+ // x-attr is a GENERIC binding prefix, so x-href / x-src / x-value / x-alt bind
971
+ // exactly like x-text does. Measured over the 339 rendered docs pages, the
972
+ // open form costs nothing over an allowlist (3 matches either way, all three
973
+ // of them pages the CURRENT four-marker sniff already matches because they
974
+ // document its marker names) while covering directives an allowlist forgets.
975
+ var BARE_DIRECTIVE=/\\s(?::[a-z][\\w-]*|@[a-z][\\w.-]*|x-[a-z][\\w-]*)\\s*=\\s*["']/i;
976
+ // The fragment marker doubles as the carrier for the server's declaration.
977
+ // Encoding it in the HTML rather than threading a parameter keeps it inside
978
+ // the one value that already reaches swap() through every path, the prefetch
979
+ // cache included.
980
+ function fragmentMarker(declared){
981
+ return '<!--stx-fragment'+(declared==='true'?' rt=1':declared==='false'?' rt=0':'')+'-->';
982
+ }
878
983
  // Record scripts from the initial page load
879
984
  document.querySelectorAll('script').forEach(function(s){
880
985
  var text=s.textContent||'';
@@ -935,7 +1040,20 @@ function getRouterScript() {
935
1040
  return containerSel==='main'||containerSel==='[data-stx-content]';
936
1041
  }
937
1042
 
1043
+ // Write the address bar. The mode travels in the same slot as the legacy
1044
+ // pushState boolean: false still means "do not touch history", true still
1045
+ // pushes, and the string 'replace' replaces. Threading a fourth argument
1046
+ // through swap() to each of the three history sites would have been the
1047
+ // alternative (#1807).
1048
+ function writeHistory(mode,href){
1049
+ if(mode==='replace')history.replaceState({},'',href);
1050
+ else history.pushState({},'',href);
1051
+ }
1052
+
1053
+ // Second arg accepts the legacy pushState boolean OR an options object
1054
+ // { replace }. Callers inside this file still pass the boolean.
938
1055
  function navigate(url,pushState,force){
1056
+ if(pushState&&typeof pushState==='object')pushState=pushState.replace?'replace':true;
939
1057
  // Lang-picker passes force=true with an already-localized path (/en/...).
940
1058
  // Re-localizing would map it back to the *current* locale and no-op.
941
1059
  if(!force) url=withCurrentLocale(url);
@@ -951,7 +1069,7 @@ function getRouterScript() {
951
1069
  if(t.origin!==location.origin){location.href=url;return Promise.resolve(false)}
952
1070
 
953
1071
  if(t.pathname===location.pathname&&t.hash){
954
- if(pushState!==false)history.pushState({},'',t.href);
1072
+ if(pushState!==false)writeHistory(pushState,t.href);
955
1073
  var el=document.querySelector(t.hash);
956
1074
  if(el)el.scrollIntoView({behavior:'smooth'});
957
1075
  return Promise.resolve(true);
@@ -987,6 +1105,7 @@ function getRouterScript() {
987
1105
  return r.text();
988
1106
  }).then(function(html){
989
1107
  log('[router] full page fetched from cache path, len:',html.length);
1108
+ pendingLayoutDecl={layout:layoutCache[targetPath]||'',group:layoutGroupCache[targetPath]||''};
990
1109
  return swap(html,targetPath,pushState,targetHash);
991
1110
  }).catch(function(err){
992
1111
  console.error('[router] full page fetch error:',err);
@@ -994,6 +1113,7 @@ function getRouterScript() {
994
1113
  }).finally(done);
995
1114
  }
996
1115
  pendingContainerAttrs=attrsCache[targetPath]||'';
1116
+ pendingLayoutDecl=null;
997
1117
  return Promise.resolve(swap(cache[targetPath],targetPath,pushState,targetHash)).then(function(){
998
1118
  applyTitle(titleCache[targetPath]);
999
1119
  return true;
@@ -1016,6 +1136,7 @@ else {
1016
1136
  var newGroup=r.headers.get('X-STX-Layout-Group')||'';
1017
1137
  var newTitle=r.headers.get('X-STX-Title')||'';
1018
1138
  var newCAttrs=r.headers.get('X-STX-Container-Attrs')||'';
1139
+ var newRuntime=r.headers.get('X-STX-Runtime')||'';
1019
1140
  // Layout change? Fetch the FULL page (no X-STX-Router header) and do full document swap
1020
1141
  if(isFragment&&checkLayoutChange(newLayout,url,newGroup)){
1021
1142
  log('[router] layout change \u2014 fetching full page for document swap');
@@ -1028,12 +1149,13 @@ else {
1028
1149
  });
1029
1150
  });
1030
1151
  }
1031
- return r.text().then(function(html){return{html:html,isFragment:isFragment,layout:newLayout,layoutGroup:newGroup,title:newTitle,containerAttrs:newCAttrs}});
1152
+ return r.text().then(function(html){return{html:html,isFragment:isFragment,layout:newLayout,layoutGroup:newGroup,title:newTitle,containerAttrs:newCAttrs,runtime:newRuntime}});
1032
1153
  }).then(function(result){
1033
1154
  if(!result)return;
1034
- if(result.isFragment)result.html='<!--stx-fragment-->'+result.html;
1155
+ if(result.isFragment)result.html=fragmentMarker(result.runtime)+result.html;
1035
1156
  if(o.cache)setCache(targetPath,result.html,result.layout,result.layoutGroup,result.title,result.containerAttrs);
1036
1157
  pendingContainerAttrs=result.isFragment?(result.containerAttrs||''):'';
1158
+ pendingLayoutDecl=result.isFragment?null:{layout:result.layout||'',group:result.layoutGroup||''};
1037
1159
  return Promise.resolve(swap(result.html,targetPath,pushState,targetHash)).then(function(){
1038
1160
  // Fragment swaps carry no <head>; apply the title from the header.
1039
1161
  // Full-document swaps already set document.title from the <title> tag.
@@ -1058,9 +1180,19 @@ else {
1058
1180
  ||html.indexOf('data-stx-content')!==-1;
1059
1181
  }
1060
1182
 
1183
+ // The layout and group the SERVER declared for the document about to be
1184
+ // swapped in. Set immediately before each swap() call by whichever path
1185
+ // fetched it, and consumed once.
1186
+ //
1187
+ // Threaded this way rather than as a swap() parameter, matching
1188
+ // pendingContainerAttrs \u2014 see the note above writeHistory about the cost of
1189
+ // adding an argument to swap() and its three call sites (#1807).
1190
+ var pendingLayoutDecl=null;
1061
1191
  function swap(html,url,pushState,hash){
1062
- var isFragment=html.indexOf('<!--stx-fragment-->')===0;
1063
- if(isFragment)html=html.slice('<!--stx-fragment-->'.length);
1192
+ var fragMark=/^<!--stx-fragment(?: rt=([01]))?-->/.exec(html);
1193
+ var isFragment=!!fragMark;
1194
+ var declaredRuntime=fragMark&&fragMark[1]?fragMark[1]:'';
1195
+ if(isFragment)html=html.slice(fragMark[0].length);
1064
1196
  if(!isFragment&&!isStxDocument(html)){log('[router] non-stx document \u2014 full navigation to:',url);location.href=url;return Promise.resolve(false)}
1065
1197
  var currentContent=getContainer();
1066
1198
  log('[router] swap: isFragment='+isFragment+' container='+!!currentContent+' tag='+(currentContent&&currentContent.tagName)+' selector='+containerSel+' htmlLen='+html.length);
@@ -1068,6 +1200,14 @@ else {
1068
1200
 
1069
1201
  // Fragment mode: server returned just the page content (no document wrapper)
1070
1202
  if(isFragment){
1203
+ // Before _cleanupContainer, not after: handing off later would dispose
1204
+ // the OUTGOING page's scopes and then reload anyway. Inside doFragSwap
1205
+ // would be later still \u2014 innerHTML is already replaced by then.
1206
+ if(!window.stx&&fragmentNeedsRuntime(html,declaredRuntime)){
1207
+ log('[router] fragment needs the signals runtime and this page has none \u2014 full navigation to:',url);
1208
+ location.href=url;
1209
+ return Promise.resolve(false);
1210
+ }
1071
1211
  if(window.stx&&window.stx._cleanupContainer)window.stx._cleanupContainer(currentContent);
1072
1212
  function doFragSwap(){
1073
1213
  // Extract scripts from fragment before injecting HTML
@@ -1077,6 +1217,22 @@ else {
1077
1217
  var fragCrosswindHrefs=[];
1078
1218
  var fragCrosswindCSS=null;
1079
1219
  var cleanFrag=html.replace(new RegExp('<scr'+'ipt\\\\b([^>]*)>([\\\\s\\\\S]*?)<\\\\/scr'+'ipt>','gi'),function(m,attrs,code){
1220
+ // A data block is not code, and everything below this line assumes it
1221
+ // is: the body gets stashed in fragScripts, re-emitted as a pending
1222
+ // placeholder, then wrapped in braces and executed. Wrapping a JSON
1223
+ // object in braces reparses it as a labelled block, so its first ':'
1224
+ // is a SyntaxError on every single fragment swap
1225
+ // (stacksjs/stx#1801). @structuredData renders at its call site,
1226
+ // which inside a layout is @section('content') \u2014 i.e. in the routed
1227
+ // container \u2014 so the natural way to write it hit this.
1228
+ //
1229
+ // Left exactly as found rather than stripped: crawlers read
1230
+ // structured data wherever it sits, and the browser will not execute
1231
+ // a non-JS type anyway. Mirrors prepareRoutedBodyScripts' notion of
1232
+ // executable, which this path never had.
1233
+ var typeMatch=attrs.match(/\\btype\\s*=\\s*(?:"([^"]*)"|'([^']*)'|([^\\s>]+))/i);
1234
+ var scriptType=((typeMatch&&(typeMatch[1]||typeMatch[2]||typeMatch[3]))||'').trim().toLowerCase();
1235
+ if(scriptType&&scriptType!=='text/javascript'&&scriptType!=='application/javascript'&&scriptType!=='module')return m;
1080
1236
  if(code&&code.trim()&&!isSignalsRuntimeScript({hasAttribute:function(name){return name==='data-stx-runtime'&&attrs.indexOf('data-stx-runtime')!==-1}},code)){
1081
1237
  var slot='fragment-'+(++fragScriptId);
1082
1238
  var scoped=/(?:^|\\s)data-stx-scoped(?:\\s|=|$)/i.test(attrs);
@@ -1148,7 +1304,7 @@ else {
1148
1304
  currentContent.innerHTML=cleanFrag;
1149
1305
  // Remove old page scripts
1150
1306
  document.querySelectorAll('script[data-stx-page]').forEach(function(s){s.remove()});
1151
- if(pushState!==false)history.pushState({},'',url+(hash||''));
1307
+ if(pushState!==false)writeHistory(pushState,url+(hash||''));
1152
1308
  updateNav(url);
1153
1309
  updateActiveLinks();
1154
1310
  if(o.scrollToTop&&!hash)window.scrollTo({top:0,behavior:'instant'});
@@ -1218,7 +1374,12 @@ else {
1218
1374
  document.body.appendChild(ns);
1219
1375
  }
1220
1376
  });
1221
- log('[router] scripts done. _latestSetup:', !!window.stx._latestSetup);
1377
+ // Guarded: the router ships on every page, the signals runtime only on
1378
+ // pages that have a client script. log() is debug-gated but its
1379
+ // ARGUMENTS are evaluated at the call site regardless, so an unguarded
1380
+ // read here threw on every runtime-less page \u2014 aborting the swap and
1381
+ // falling back to a full document load (#1809).
1382
+ log('[router] scripts done. _latestSetup:', !!(window.stx&&window.stx._latestSetup));
1222
1383
  // THEN fire stx:load \u2014 now _latestSetup is set and processElement has the right scope
1223
1384
  window.dispatchEvent(new Event('stx:load'));
1224
1385
  }
@@ -1242,6 +1403,22 @@ else {
1242
1403
  var docBuildMeta=doc.querySelector('meta[name="stx-build"]');
1243
1404
  var docBuild=docBuildMeta?(docBuildMeta.getAttribute('content')||''):'';
1244
1405
  if(isBuildSkew(docBuild)){reloadForSkew(url,docBuild);return Promise.resolve(false)}
1406
+ // Same hand-off as the fragment path, for the same reason (#1809, #1839).
1407
+ // A full document DOES carry the destination's runtime \u2014 and this path
1408
+ // throws it away: prepareRoutedBodyScripts drops every script[src] and
1409
+ // everything isSignalsRuntimeScript matches, on the assumption that the
1410
+ // current page already has one. When it does not, nothing puts a runtime
1411
+ // back, so the destination's client code dies on its first line with
1412
+ // "defineStore is not defined" (defineStore lives on window.stx) and the
1413
+ // page renders quietly inert \u2014 no error reaches the user.
1414
+ //
1415
+ // Before the swap and before _cleanupContainer, so we neither dispose the
1416
+ // outgoing page's scopes nor half-replace the document and then reload.
1417
+ if(!window.stx&&documentShipsRuntime(doc,html)){
1418
+ log('[router] document needs the signals runtime and this page has none \u2014 full navigation to:',url);
1419
+ location.href=url;
1420
+ return Promise.resolve(false);
1421
+ }
1245
1422
  var newContent=doc.querySelector(containerSel)||doc.querySelector('[data-stx-content]')||doc.querySelector('main');
1246
1423
  if(!newContent){location.href=url;return Promise.resolve(false)}
1247
1424
 
@@ -1328,14 +1505,33 @@ else {
1328
1505
  // For same-layout: swap only the container (<main>)
1329
1506
  var newBody=doc.querySelector('body');
1330
1507
  var isLayoutChange=false;
1508
+ var declaredLayout=pendingLayoutDecl;
1509
+ pendingLayoutDecl=null;
1331
1510
  if(newBody){
1332
1511
  var newMeta=doc.querySelector('meta[name="stx-layout"]');
1333
1512
  var curMeta=document.querySelector('meta[name="stx-layout"]');
1334
1513
  var curLayout=curMeta?curMeta.getAttribute('content'):'';
1335
- var newLayout=newMeta?newMeta.getAttribute('content'):'';
1514
+ // The server's answer wins over the document's metas. We are only here
1515
+ // because checkLayoutChange already said the group changed, based on
1516
+ // exactly these headers \u2014 re-deriving from metas and disagreeing meant
1517
+ // paying for the full-document fetch and then discarding the reason we
1518
+ // made it, so the destination's <main> was swapped into the previous
1519
+ // page's chrome (#1833).
1520
+ //
1521
+ // They disagree more easily than they look. The bun-plugin serve path
1522
+ // calls a layout-less page 'default' while the client's own fallback
1523
+ // calls it 'app'; a <head> with attributes silently skips the meta
1524
+ // injection entirely; and the i18n path rewrites every page's group to
1525
+ // i18n:<locale>. Any one of those leaves the metas saying "unchanged"
1526
+ // about a document the headers said had changed.
1527
+ var newLayout=declaredLayout&&declaredLayout.layout
1528
+ ? declaredLayout.layout
1529
+ : (newMeta?newMeta.getAttribute('content'):'');
1336
1530
  var curGroup=getCurrentLayoutGroup();
1337
- var newGroup=getDocLayoutGroup(doc,newLayout);
1338
- isLayoutChange=curGroup!==newGroup||(curLayout&&newLayout&&curLayout!==newLayout);
1531
+ var newGroup=declaredLayout&&declaredLayout.group
1532
+ ? declaredLayout.group
1533
+ : getDocLayoutGroup(doc,newLayout);
1534
+ isLayoutChange=curGroup!==newGroup||!!(curLayout&&newLayout&&curLayout!==newLayout);
1339
1535
  }
1340
1536
  var incomingSetupName=newBody?(newBody.getAttribute('data-stx')||''):'';
1341
1537
  // Scripts assigned through innerHTML are inert. Preserve executable
@@ -1462,7 +1658,7 @@ else {
1462
1658
  }
1463
1659
 
1464
1660
  // Push history state (before active link updates so location.pathname is current)
1465
- if(pushState!==false)history.pushState({},'',url+(hash||''));
1661
+ if(pushState!==false)writeHistory(pushState,url+(hash||''));
1466
1662
 
1467
1663
  // Update active nav links
1468
1664
  updateNav(url);
@@ -1644,7 +1840,8 @@ else {
1644
1840
  var pLayout=r.headers.get('X-STX-Layout')||'';
1645
1841
  var pGroup=r.headers.get('X-STX-Layout-Group')||'';
1646
1842
  var pTitle=r.headers.get('X-STX-Title')||'';
1647
- return r.text().then(function(html){return{html:isFrag?'<!--stx-fragment-->'+html:html,layout:pLayout,layoutGroup:pGroup,title:pTitle}});
1843
+ var pRuntime=r.headers.get('X-STX-Runtime')||'';
1844
+ return r.text().then(function(html){return{html:isFrag?fragmentMarker(pRuntime)+html:html,layout:pLayout,layoutGroup:pGroup,title:pTitle}});
1648
1845
  }).then(function(result){
1649
1846
  if(o.cache)setCache(key,result.html,result.layout,result.layoutGroup,result.title);
1650
1847
  }).catch(function(){}).finally(function(){delete prefetching[key]});
@@ -1743,7 +1940,7 @@ else {
1743
1940
  var key=cacheKey(url);
1744
1941
  if(!cache[key]){
1745
1942
  var wantsFragment=shouldUseFragmentResponse();
1746
- 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')||'';return r.text().then(function(html){return{html:isFrag?'<!--stx-fragment-->'+html:html,layout:pLayout,layoutGroup:pGroup,title:pTitle}})}).then(function(result){setCache(key,result.html,result.layout,result.layoutGroup,result.title)}).catch(function(){});
1943
+ 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(){});
1747
1944
  }
1748
1945
  },
1749
1946
  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},
package/dist/serve.d.ts CHANGED
@@ -9,7 +9,39 @@ export declare function extractServerScriptsFromTemplate(content: string): {
9
9
  serverScripts: string[]
10
10
  templateContent: string
11
11
  };
12
+ /**
13
+ * A token to mint for this request, or null to leave it alone.
14
+ *
15
+ * Safe methods only, and only when the request carries none already: anything
16
+ * else is somebody's live session, and rotating their token mid-flight would
17
+ * reject the very form they are about to submit.
18
+ */
19
+ export declare function csrfTokenToMint(req: Request, cookies: Record<string, string>): string | null;
20
+ /** The `Set-Cookie` value for a minted token. Readable by script, as double-submit requires. */
21
+ export declare function csrfCookieHeader(token: string, secure: boolean): string;
12
22
  export declare function buildDynamicRouteRegexes(fileRouteBase: string): RegExp[];
23
+ /**
24
+ * Rank a dynamic route file by specificity so the resolver can order candidates
25
+ * most-specific-first and never let a catch-all (`[...x]`) or a broad `[param]`
26
+ * shadow a more specific route (stacksjs/stx#1837).
27
+ *
28
+ * `discoverFiles` returns Bun glob order — where the catch-all frequently lands
29
+ * first — and `getRoute` takes the FIRST regex match. Without ordering, `/foo/15`
30
+ * matched `[...all].stx` (`^(.+)$`) before `foo/[id].stx`, so every dynamic
31
+ * detail route 404'd. Higher score = more specific = tried first:
32
+ * - static segment → +100 (strongly preferred)
33
+ * - `[param]` segment → +1 (weakly preferred)
34
+ * - `[...rest]` catch-all → -10000 (always last)
35
+ */
36
+ export declare function routeSpecificity(fileRouteBase: string): number;
37
+ /**
38
+ * True when a request path is for a STATIC ASSET (a non-page file extension like
39
+ * `.jpg`/`.css`/`.js`) rather than a page. Used so a catch-all page can never
40
+ * shadow an asset request that publicDir should serve — `getRoute` runs before
41
+ * the publicDir handler (stacksjs/stx#1841). Page extensions (`.stx`/`.md`/
42
+ * `.html`) return false; those are routable.
43
+ */
44
+ export declare function isStaticAssetPath(requestPath: string): boolean;
13
45
  /**
14
46
  * Are we serving in production? In production the built-in 404 must not
15
47
  * enumerate the app's route list (information disclosure) and must not
@@ -73,6 +105,14 @@ export declare function bundleBrowserAsset(entrypoint: string): Promise<Response
73
105
  * @param options Server options with patterns and port
74
106
  */
75
107
  export declare function serve(options: ServeOptions): Promise<void>;
108
+ /**
109
+ * The cookie name a double-submit CSRF check reads.
110
+ *
111
+ * Spelled here rather than imported, because stx does not depend on whatever
112
+ * framework is doing the checking - it only has to agree on the name, and this
113
+ * is the name every double-submit implementation in this ecosystem uses.
114
+ */
115
+ export declare const CSRF_COOKIE: 'X-CSRF-Token';
76
116
  /**
77
117
  * Build the candidate regexes for a dynamic route file (stacksjs/stx#1927).
78
118
  *
package/dist/serve.js CHANGED
@@ -8756,6 +8756,29 @@ function extractServerScriptsFromTemplate(content) {
8756
8756
  retained.push(content.slice(retainedFrom));
8757
8757
  return { serverScripts, templateContent: retained.join("") };
8758
8758
  }
8759
+ var CSRF_COOKIE = "X-CSRF-Token";
8760
+ function csrfTokenToMint(req, cookies) {
8761
+ const method = (req.method || "GET").toUpperCase();
8762
+ if (method !== "GET" && method !== "HEAD")
8763
+ return null;
8764
+ if (cookies[CSRF_COOKIE] || cookies["csrf-token"])
8765
+ return null;
8766
+ const accept = req.headers.get("accept") || "";
8767
+ if (accept && !accept.includes("text/html") && !accept.includes("*/*"))
8768
+ return null;
8769
+ const bytes = new Uint8Array(32);
8770
+ crypto.getRandomValues(bytes);
8771
+ return [...bytes].map((byte) => byte.toString(16).padStart(2, "0")).join("");
8772
+ }
8773
+ function csrfCookieHeader(token, secure) {
8774
+ return [
8775
+ `${CSRF_COOKIE}=${token}`,
8776
+ "Path=/",
8777
+ "SameSite=Lax",
8778
+ "Max-Age=7200",
8779
+ secure ? "Secure" : null
8780
+ ].filter(Boolean).join("; ");
8781
+ }
8759
8782
  function buildDynamicRouteRegexes(fileRouteBase) {
8760
8783
  const toPattern = (p) => p.replace(/\[\.\.\.([^\]]+)\]/g, "(.+)").replace(/\[([^\]]+)\]/g, "([^/]+)").replace(/\//g, "\\/");
8761
8784
  const patterns = [new RegExp(`^${toPattern(fileRouteBase)}$`)];
@@ -8771,6 +8794,22 @@ function buildDynamicRouteRegexes(fileRouteBase) {
8771
8794
  }
8772
8795
  return patterns;
8773
8796
  }
8797
+ function routeSpecificity(fileRouteBase) {
8798
+ const segments = fileRouteBase.replace(/^\.\//, "").replace(/\\/g, "/").replace(/\.(stx|md|html)$/, "").split("/");
8799
+ let score = 0;
8800
+ for (const segment of segments) {
8801
+ if (/\[\.\.\./.test(segment))
8802
+ score -= 1e4;
8803
+ else if (segment.includes("["))
8804
+ score += 1;
8805
+ else
8806
+ score += 100;
8807
+ }
8808
+ return score;
8809
+ }
8810
+ function isStaticAssetPath(requestPath) {
8811
+ return /\.[a-z0-9]+$/i.test(requestPath) && !/\.(?:stx|md|html)$/i.test(requestPath);
8812
+ }
8774
8813
  function escapeHtml(input) {
8775
8814
  return input.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;").replace(/'/g, "&#39;");
8776
8815
  }
@@ -9244,6 +9283,10 @@ After installing: bun add bun-plugin-stx`);
9244
9283
  };
9245
9284
  context.__stxServeContext = full;
9246
9285
  globalThis.__stxServeContext = full;
9286
+ context.setResponseStatus = (status) => {
9287
+ if (Number.isInteger(status) && status >= 100 && status <= 599)
9288
+ full.responseStatus = status;
9289
+ };
9247
9290
  }
9248
9291
  function htmlCacheKey(filePath, reqCtx) {
9249
9292
  if (RENDER_CACHE_VARY === "source")
@@ -9371,7 +9414,7 @@ After installing: bun add bun-plugin-stx`);
9371
9414
  pageMiddlewarePatterns.length = 0;
9372
9415
  await Promise.all(files.map((f) => detectPageMiddleware(f)));
9373
9416
  try {
9374
- const { Router } = await import("./chunk-3dygdr9g.js");
9417
+ const { Router } = await import("./chunk-5pnbtf0j.js");
9375
9418
  const pagesDirs = patterns.map((p) => p.replace(/\/$/, "")).filter(Boolean);
9376
9419
  const router = new Router(process19.cwd(), { pagesDirs, stateDir: stateDirName() });
9377
9420
  if (!options.quiet)
@@ -9565,8 +9608,11 @@ After installing: bun add bun-plugin-stx`);
9565
9608
  if (ENABLE_HTML_CACHE && !skipCacheHint) {
9566
9609
  const cacheKey = htmlCacheKey(filePath, reqCtx);
9567
9610
  const cachedEntry = htmlCache.get(cacheKey);
9568
- if (cachedEntry && await templateSignatureFresh(cachedEntry.signature))
9611
+ if (cachedEntry && await templateSignatureFresh(cachedEntry.signature)) {
9612
+ if (reqCtx)
9613
+ reqCtx.responseStatus = cachedEntry.status;
9569
9614
  return cachedEntry.html;
9615
+ }
9570
9616
  }
9571
9617
  const { serverScripts, templateContent } = extractServerScriptsFromTemplate(content);
9572
9618
  const context = {
@@ -9630,13 +9676,14 @@ After installing: bun add bun-plugin-stx`);
9630
9676
  }
9631
9677
  if (ENABLE_HTML_CACHE && !context.__stx_skip_cache && !skipCacheHint && isRenderableCacheCandidate(output)) {
9632
9678
  const signature = await buildTemplateSignature(filePath, dependencies);
9633
- htmlCache.set(htmlCacheKey(filePath, reqCtx), { html: output, signature });
9679
+ htmlCache.set(htmlCacheKey(filePath, reqCtx), { html: output, signature, status: reqCtx?.responseStatus ?? 200 });
9634
9680
  }
9635
9681
  return output;
9636
9682
  }
9637
9683
  async function getRoute(requestPath, reqCtx) {
9638
9684
  const files = await discoverFiles();
9639
9685
  let normalizedPath = requestPath.startsWith("/") ? requestPath.slice(1) : requestPath;
9686
+ normalizedPath = normalizedPath.replace(/\/+$/, "");
9640
9687
  const possibleFiles = [];
9641
9688
  for (const ext of [".stx", ".md", ".html", ""]) {
9642
9689
  const directPath = normalizedPath.endsWith(ext) ? normalizedPath : `${normalizedPath}${ext}`;
@@ -9701,10 +9748,17 @@ After installing: bun add bun-plugin-stx`);
9701
9748
  }
9702
9749
  }
9703
9750
  }
9704
- for (const filePath of files) {
9751
+ const isAssetRequest = isStaticAssetPath(normalizedPath);
9752
+ const dynamicFiles = files.filter((f) => {
9753
+ const nf = f.replace(/^\.\//, "").replace(/\\/g, "/");
9754
+ if (!nf.includes("["))
9755
+ return false;
9756
+ if (isAssetRequest && /\[\.\.\./.test(nf))
9757
+ return false;
9758
+ return true;
9759
+ }).sort((a, b) => routeSpecificity(b) - routeSpecificity(a));
9760
+ for (const filePath of dynamicFiles) {
9705
9761
  const normalizedFilePath = filePath.replace(/^\.\//, "").replace(/\\/g, "/");
9706
- if (!normalizedFilePath.includes("["))
9707
- continue;
9708
9762
  let relativeFilePath = normalizedFilePath;
9709
9763
  for (const pattern of patterns) {
9710
9764
  const normalizedPattern = pattern.replace(/\\/g, "/").replace(/\/$/, "");
@@ -9972,6 +10026,7 @@ ${html}`;
9972
10026
  req = new Request(req, { headers });
9973
10027
  }
9974
10028
  }
10029
+ let mintedCsrfToken = null;
9975
10030
  const _i18nResp = await (async () => {
9976
10031
  activeServeLocale = i18nLocale;
9977
10032
  activeServeSearch = url.search;
@@ -9979,6 +10034,9 @@ ${html}`;
9979
10034
  activeServeCookieHeader = req.headers.get("cookie") || "";
9980
10035
  activeServeCookies = parseCookies(req);
9981
10036
  activeServeIp = server.requestIP(req)?.address || (req.headers.get("x-forwarded-for") || "").split(",")[0].trim();
10037
+ mintedCsrfToken = csrfTokenToMint(req, activeServeCookies);
10038
+ if (mintedCsrfToken)
10039
+ activeServeCookies[CSRF_COOKIE] = mintedCsrfToken;
9982
10040
  const reqCtx = {
9983
10041
  url: req.url,
9984
10042
  path,
@@ -10323,6 +10381,7 @@ ${trailingPageScripts.join(`
10323
10381
  `)}`;
10324
10382
  const titleMatch = content.match(/<title[^>]*>([\s\S]*?)<\/title>/i);
10325
10383
  const pageTitle = titleMatch ? titleMatch[1].trim() : "";
10384
+ const shipsRuntime = content.includes("data-stx-runtime");
10326
10385
  return new Response(fragment, {
10327
10386
  status: responseStatus,
10328
10387
  headers: {
@@ -10330,6 +10389,7 @@ ${trailingPageScripts.join(`
10330
10389
  "X-STX-Fragment": "true",
10331
10390
  "X-STX-Layout": pageLayout,
10332
10391
  "X-STX-Layout-Group": pageLayoutGroup,
10392
+ "X-STX-Runtime": shipsRuntime ? "true" : "false",
10333
10393
  ...pageTitle && { "X-STX-Title": encodeURIComponent(pageTitle) },
10334
10394
  [BUILD_ID_HEADER]: getBuildId(),
10335
10395
  ...containerAttrs && { "X-STX-Container-Attrs": encodeURIComponent(containerAttrs) },
@@ -10488,7 +10548,20 @@ ${trailingPageScripts.join(`
10488
10548
  activeServeIp = "";
10489
10549
  }
10490
10550
  })();
10491
- const _finalResp = await applyI18nToResponse(_i18nResp, i18nLocale ?? (i18nConfig?.defaultLocale ?? "en"), path);
10551
+ let _finalResp = await applyI18nToResponse(_i18nResp, i18nLocale ?? (i18nConfig?.defaultLocale ?? "en"), path);
10552
+ if (mintedCsrfToken) {
10553
+ try {
10554
+ _finalResp.headers.append("Set-Cookie", csrfCookieHeader(mintedCsrfToken, req.url.startsWith("https://")));
10555
+ } catch {
10556
+ const headers = new Headers(_finalResp.headers);
10557
+ headers.append("Set-Cookie", csrfCookieHeader(mintedCsrfToken, req.url.startsWith("https://")));
10558
+ _finalResp = new Response(_finalResp.body, {
10559
+ status: _finalResp.status,
10560
+ statusText: _finalResp.statusText,
10561
+ headers
10562
+ });
10563
+ }
10564
+ }
10492
10565
  if (options.onResponse) {
10493
10566
  const hooked = await options.onResponse(req, _finalResp);
10494
10567
  if (hooked instanceof Response)
@@ -10593,11 +10666,16 @@ if (import.meta.main) {
10593
10666
  export {
10594
10667
  staticCacheControl,
10595
10668
  serve,
10669
+ routeSpecificity,
10596
10670
  resolveServeOpenUrl,
10597
10671
  render404Page,
10672
+ isStaticAssetPath,
10598
10673
  isRenderableCacheCandidate,
10599
10674
  isProductionServe,
10600
10675
  extractServerScriptsFromTemplate,
10676
+ csrfTokenToMint,
10677
+ csrfCookieHeader,
10601
10678
  bundleBrowserAsset,
10602
- buildDynamicRouteRegexes
10679
+ buildDynamicRouteRegexes,
10680
+ CSRF_COOKIE
10603
10681
  };
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "bun-plugin-stx",
3
3
  "type": "module",
4
- "version": "0.2.153",
4
+ "version": "0.2.155",
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",