bun-plugin-stx 0.2.152 → 0.2.154
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/{chunk-8nbccnre.js → chunk-5pnbtf0j.js} +305 -28
- package/dist/serve.d.ts +42 -0
- package/dist/serve.js +91 -9
- package/package.json +2 -1
|
@@ -621,6 +621,28 @@ function getRouterScript() {
|
|
|
621
621
|
var containerSel=o.container;
|
|
622
622
|
var debug=!!o.debug;
|
|
623
623
|
function log(){if(debug&&typeof console!=='undefined'&&console.log)console.log.apply(console,arguments)}
|
|
624
|
+
|
|
625
|
+
// \u2500\u2500 Build skew (stacksjs/stx#1772) \u2500\u2500
|
|
626
|
+
// The build that rendered THIS document, and therefore the build the runtime
|
|
627
|
+
// executing right now came from. Under bun --watch a save restarts the server,
|
|
628
|
+
// so the next navigation can fetch a fragment produced by a newer build; when
|
|
629
|
+
// the scoped-script or binding format drifted between the two, the old runtime
|
|
630
|
+
// cannot hydrate the new fragment \u2014 literal moustaches, dead bindings, stale
|
|
631
|
+
// canvas. Sporadic, and never on a clean boot.
|
|
632
|
+
//
|
|
633
|
+
// Conservative on purpose: act only when BOTH ids are known. A missing id is
|
|
634
|
+
// "no information", never "mismatch", so statically hosted output (no headers)
|
|
635
|
+
// and older servers behave exactly as before.
|
|
636
|
+
var buildMeta=document.querySelector('meta[name="stx-build"]');
|
|
637
|
+
var loadedBuild=buildMeta?(buildMeta.getAttribute('content')||''):'';
|
|
638
|
+
window.__stxBuild=loadedBuild;
|
|
639
|
+
function isBuildSkew(incoming){
|
|
640
|
+
return !!(loadedBuild&&incoming&&incoming!==loadedBuild);
|
|
641
|
+
}
|
|
642
|
+
function reloadForSkew(url,incoming){
|
|
643
|
+
log('[router] build skew: page is',loadedBuild,'server is',incoming,'\u2014 full navigation');
|
|
644
|
+
location.href=url;
|
|
645
|
+
}
|
|
624
646
|
function runViewTransition(callback){
|
|
625
647
|
if(!o.viewTransitions||!document.startViewTransition)return false;
|
|
626
648
|
try{
|
|
@@ -816,6 +838,44 @@ function getRouterScript() {
|
|
|
816
838
|
// Layout-level scripts (theme, nav setup) execute on initial page load and
|
|
817
839
|
// should NOT re-execute when navigating to another page with the same layout.
|
|
818
840
|
var executedScriptHashes={};
|
|
841
|
+
|
|
842
|
+
// \u2500\u2500 Re-execution contract (stacksjs/stx#1773) \u2500\u2500
|
|
843
|
+
// Whether a generated script may run again after an SPA swap used to be
|
|
844
|
+
// SNIFFED from its source: does it start with '(' or ';', or mention
|
|
845
|
+
// window.stx.mount. That is a property of the emitted shape, not of intent,
|
|
846
|
+
// so every change to how scripts are wrapped was one edit away from silently
|
|
847
|
+
// reclassifying them \u2014 and the symptom of getting it wrong is a component
|
|
848
|
+
// that renders completely dead (literal moustaches, stuck :show) only on a
|
|
849
|
+
// REVISIT, because nav-away already disposed its scope.
|
|
850
|
+
//
|
|
851
|
+
// Emitters now declare it: data-stx-run="always" on self-contained scope
|
|
852
|
+
// IIFEs and mount() wrappers, which own their scope and whose registration
|
|
853
|
+
// _cleanupContainer removed on the way out. Anything unstamped falls back to
|
|
854
|
+
// the old sniff, so a page rendered by an older server behaves exactly as
|
|
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*\\[/;
|
|
872
|
+
function runsAlways(declared, code){
|
|
873
|
+
if(declared==='always')return true;
|
|
874
|
+
if(declared==='once')return false;
|
|
875
|
+
if(REGISTERS_SCOPE.test(code))return true;
|
|
876
|
+
var head=code.trimStart();
|
|
877
|
+
return head.charAt(0)==='('||head.charAt(0)===';';
|
|
878
|
+
}
|
|
819
879
|
function hashScript(code){
|
|
820
880
|
var h=0;for(var i=0;i<code.length;i++){h=((h<<5)-h)+code.charCodeAt(i);h|=0}
|
|
821
881
|
return h;
|
|
@@ -827,11 +887,99 @@ function getRouterScript() {
|
|
|
827
887
|
var match=code.match(/function (__stx_setup_[A-Za-z0-9_]+)\\s*\\(/);
|
|
828
888
|
return match?match[1]:'';
|
|
829
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
|
+
}
|
|
830
902
|
function isSignalsRuntimeScript(script,code){
|
|
831
903
|
return !!(script&&script.hasAttribute&&script.hasAttribute('data-stx-runtime'))
|
|
832
904
|
||(code.indexOf('_cleanupContainer')!==-1&&code.indexOf('signals runtime loading')!==-1)
|
|
833
905
|
||code.indexOf("'use strict';var cloakStyle")!==-1;
|
|
834
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
|
+
}
|
|
835
983
|
// Record scripts from the initial page load
|
|
836
984
|
document.querySelectorAll('script').forEach(function(s){
|
|
837
985
|
var text=s.textContent||'';
|
|
@@ -892,7 +1040,20 @@ function getRouterScript() {
|
|
|
892
1040
|
return containerSel==='main'||containerSel==='[data-stx-content]';
|
|
893
1041
|
}
|
|
894
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.
|
|
895
1055
|
function navigate(url,pushState,force){
|
|
1056
|
+
if(pushState&&typeof pushState==='object')pushState=pushState.replace?'replace':true;
|
|
896
1057
|
// Lang-picker passes force=true with an already-localized path (/en/...).
|
|
897
1058
|
// Re-localizing would map it back to the *current* locale and no-op.
|
|
898
1059
|
if(!force) url=withCurrentLocale(url);
|
|
@@ -908,7 +1069,7 @@ function getRouterScript() {
|
|
|
908
1069
|
if(t.origin!==location.origin){location.href=url;return Promise.resolve(false)}
|
|
909
1070
|
|
|
910
1071
|
if(t.pathname===location.pathname&&t.hash){
|
|
911
|
-
if(pushState!==false)
|
|
1072
|
+
if(pushState!==false)writeHistory(pushState,t.href);
|
|
912
1073
|
var el=document.querySelector(t.hash);
|
|
913
1074
|
if(el)el.scrollIntoView({behavior:'smooth'});
|
|
914
1075
|
return Promise.resolve(true);
|
|
@@ -944,6 +1105,7 @@ function getRouterScript() {
|
|
|
944
1105
|
return r.text();
|
|
945
1106
|
}).then(function(html){
|
|
946
1107
|
log('[router] full page fetched from cache path, len:',html.length);
|
|
1108
|
+
pendingLayoutDecl={layout:layoutCache[targetPath]||'',group:layoutGroupCache[targetPath]||''};
|
|
947
1109
|
return swap(html,targetPath,pushState,targetHash);
|
|
948
1110
|
}).catch(function(err){
|
|
949
1111
|
console.error('[router] full page fetch error:',err);
|
|
@@ -951,6 +1113,7 @@ function getRouterScript() {
|
|
|
951
1113
|
}).finally(done);
|
|
952
1114
|
}
|
|
953
1115
|
pendingContainerAttrs=attrsCache[targetPath]||'';
|
|
1116
|
+
pendingLayoutDecl=null;
|
|
954
1117
|
return Promise.resolve(swap(cache[targetPath],targetPath,pushState,targetHash)).then(function(){
|
|
955
1118
|
applyTitle(titleCache[targetPath]);
|
|
956
1119
|
return true;
|
|
@@ -964,11 +1127,16 @@ else {
|
|
|
964
1127
|
var wantsFragment=shouldUseFragmentResponse();
|
|
965
1128
|
return fetch(url,{headers:wantsFragment?{'X-STX-Router':'true','Accept':'text/html'}:{'Accept':'text/html'}}).then(function(r){
|
|
966
1129
|
if(!r.ok)throw new Error(r.status);
|
|
1130
|
+
// Before anything is parsed or swapped: a fragment from a different
|
|
1131
|
+
// build must not be handed to this page's runtime (#1772).
|
|
1132
|
+
var incomingBuild=r.headers.get('X-STX-Build')||'';
|
|
1133
|
+
if(isBuildSkew(incomingBuild)){reloadForSkew(url,incomingBuild);return null}
|
|
967
1134
|
var isFragment=wantsFragment&&r.headers.get('X-STX-Fragment')==='true';
|
|
968
1135
|
var newLayout=r.headers.get('X-STX-Layout')||'';
|
|
969
1136
|
var newGroup=r.headers.get('X-STX-Layout-Group')||'';
|
|
970
1137
|
var newTitle=r.headers.get('X-STX-Title')||'';
|
|
971
1138
|
var newCAttrs=r.headers.get('X-STX-Container-Attrs')||'';
|
|
1139
|
+
var newRuntime=r.headers.get('X-STX-Runtime')||'';
|
|
972
1140
|
// Layout change? Fetch the FULL page (no X-STX-Router header) and do full document swap
|
|
973
1141
|
if(isFragment&&checkLayoutChange(newLayout,url,newGroup)){
|
|
974
1142
|
log('[router] layout change \u2014 fetching full page for document swap');
|
|
@@ -981,12 +1149,13 @@ else {
|
|
|
981
1149
|
});
|
|
982
1150
|
});
|
|
983
1151
|
}
|
|
984
|
-
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}});
|
|
985
1153
|
}).then(function(result){
|
|
986
1154
|
if(!result)return;
|
|
987
|
-
if(result.isFragment)result.html=
|
|
1155
|
+
if(result.isFragment)result.html=fragmentMarker(result.runtime)+result.html;
|
|
988
1156
|
if(o.cache)setCache(targetPath,result.html,result.layout,result.layoutGroup,result.title,result.containerAttrs);
|
|
989
1157
|
pendingContainerAttrs=result.isFragment?(result.containerAttrs||''):'';
|
|
1158
|
+
pendingLayoutDecl=result.isFragment?null:{layout:result.layout||'',group:result.layoutGroup||''};
|
|
990
1159
|
return Promise.resolve(swap(result.html,targetPath,pushState,targetHash)).then(function(){
|
|
991
1160
|
// Fragment swaps carry no <head>; apply the title from the header.
|
|
992
1161
|
// Full-document swaps already set document.title from the <title> tag.
|
|
@@ -1011,9 +1180,19 @@ else {
|
|
|
1011
1180
|
||html.indexOf('data-stx-content')!==-1;
|
|
1012
1181
|
}
|
|
1013
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;
|
|
1014
1191
|
function swap(html,url,pushState,hash){
|
|
1015
|
-
var
|
|
1016
|
-
|
|
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);
|
|
1017
1196
|
if(!isFragment&&!isStxDocument(html)){log('[router] non-stx document \u2014 full navigation to:',url);location.href=url;return Promise.resolve(false)}
|
|
1018
1197
|
var currentContent=getContainer();
|
|
1019
1198
|
log('[router] swap: isFragment='+isFragment+' container='+!!currentContent+' tag='+(currentContent&¤tContent.tagName)+' selector='+containerSel+' htmlLen='+html.length);
|
|
@@ -1021,6 +1200,14 @@ else {
|
|
|
1021
1200
|
|
|
1022
1201
|
// Fragment mode: server returned just the page content (no document wrapper)
|
|
1023
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
|
+
}
|
|
1024
1211
|
if(window.stx&&window.stx._cleanupContainer)window.stx._cleanupContainer(currentContent);
|
|
1025
1212
|
function doFragSwap(){
|
|
1026
1213
|
// Extract scripts from fragment before injecting HTML
|
|
@@ -1030,15 +1217,34 @@ else {
|
|
|
1030
1217
|
var fragCrosswindHrefs=[];
|
|
1031
1218
|
var fragCrosswindCSS=null;
|
|
1032
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;
|
|
1033
1236
|
if(code&&code.trim()&&!isSignalsRuntimeScript({hasAttribute:function(name){return name==='data-stx-runtime'&&attrs.indexOf('data-stx-runtime')!==-1}},code)){
|
|
1034
1237
|
var slot='fragment-'+(++fragScriptId);
|
|
1035
1238
|
var scoped=/(?:^|\\s)data-stx-scoped(?:\\s|=|$)/i.test(attrs);
|
|
1036
|
-
|
|
1239
|
+
var runDecl=(attrs.match(/data-stx-run\\s*=\\s*["']?(always|once)["']?/i)||[])[1];
|
|
1240
|
+
fragScripts.push({text:code,slot:slot,setupName:generatedSetupName(code),scoped:scoped,run:runDecl?runDecl.toLowerCase():''});
|
|
1037
1241
|
// Retain scoped setup code in its inert placeholder. A placeholder
|
|
1038
1242
|
// inside template.content is unreachable through document, so the
|
|
1039
1243
|
// repeated component runtime must execute it for each clone.
|
|
1040
1244
|
return '<scr'+'ipt type="application/stx-pending" data-stx-route-script="'+slot+'"'
|
|
1041
|
-
+(scoped?' data-stx-scoped':'')
|
|
1245
|
+
+(scoped?' data-stx-scoped':'')
|
|
1246
|
+
+(runDecl?' data-stx-run="'+runDecl.toLowerCase()+'"':'')
|
|
1247
|
+
+'>'+code+'<\\/scr'+'ipt>';
|
|
1042
1248
|
}
|
|
1043
1249
|
return '';
|
|
1044
1250
|
});
|
|
@@ -1098,7 +1304,7 @@ else {
|
|
|
1098
1304
|
currentContent.innerHTML=cleanFrag;
|
|
1099
1305
|
// Remove old page scripts
|
|
1100
1306
|
document.querySelectorAll('script[data-stx-page]').forEach(function(s){s.remove()});
|
|
1101
|
-
if(pushState!==false)
|
|
1307
|
+
if(pushState!==false)writeHistory(pushState,url+(hash||''));
|
|
1102
1308
|
updateNav(url);
|
|
1103
1309
|
updateActiveLinks();
|
|
1104
1310
|
if(o.scrollToTop&&!hash)window.scrollTo({top:0,behavior:'instant'});
|
|
@@ -1141,17 +1347,14 @@ else {
|
|
|
1141
1347
|
// re-execute because each page has its own setup.
|
|
1142
1348
|
var h=hashScript(code);
|
|
1143
1349
|
var isSetup=code.indexOf('__stx_setup_')!==-1;
|
|
1144
|
-
//
|
|
1145
|
-
//
|
|
1146
|
-
//
|
|
1147
|
-
//
|
|
1148
|
-
//
|
|
1149
|
-
//
|
|
1150
|
-
//
|
|
1151
|
-
|
|
1152
|
-
// and all :if branches stay visible at once.
|
|
1153
|
-
// Mirrors the full-doc swap path's runAlways escape below.
|
|
1154
|
-
var isAlreadyScoped=code.trimStart().charAt(0)==='('||code.trimStart().charAt(0)===';'||code.indexOf('window.stx.mount')>-1;
|
|
1350
|
+
// Self-contained scope scripts must re-execute on every navigation:
|
|
1351
|
+
// they own their scope, won't collide with prior runs, and
|
|
1352
|
+
// _cleanupContainer deleted their entry from window.stx._scopes on
|
|
1353
|
+
// the way out. Skipping one leaves the page leaf with no scope
|
|
1354
|
+
// registered \u2014 bindIf/@event never re-bind and every :if branch
|
|
1355
|
+
// stays visible at once. Declared by the emitter via data-stx-run,
|
|
1356
|
+
// sniffed only as a fallback (#1773).
|
|
1357
|
+
var isAlreadyScoped=runsAlways(entry.run,code);
|
|
1155
1358
|
if(!isSetup&&!isAlreadyScoped&&executedScriptHashes[h]){
|
|
1156
1359
|
log('[router] skipping already-executed script (hash dedup)');
|
|
1157
1360
|
return;
|
|
@@ -1171,7 +1374,12 @@ else {
|
|
|
1171
1374
|
document.body.appendChild(ns);
|
|
1172
1375
|
}
|
|
1173
1376
|
});
|
|
1174
|
-
|
|
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));
|
|
1175
1383
|
// THEN fire stx:load \u2014 now _latestSetup is set and processElement has the right scope
|
|
1176
1384
|
window.dispatchEvent(new Event('stx:load'));
|
|
1177
1385
|
}
|
|
@@ -1190,6 +1398,27 @@ else {
|
|
|
1190
1398
|
// Full document mode: parse with DOMParser and extract container content
|
|
1191
1399
|
var parser=new DOMParser();
|
|
1192
1400
|
var doc=parser.parseFromString(html,'text/html');
|
|
1401
|
+
// Full documents carry the build id as a meta rather than a header, and
|
|
1402
|
+
// this path also serves cached HTML, so check here too (#1772).
|
|
1403
|
+
var docBuildMeta=doc.querySelector('meta[name="stx-build"]');
|
|
1404
|
+
var docBuild=docBuildMeta?(docBuildMeta.getAttribute('content')||''):'';
|
|
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
|
+
}
|
|
1193
1422
|
var newContent=doc.querySelector(containerSel)||doc.querySelector('[data-stx-content]')||doc.querySelector('main');
|
|
1194
1423
|
if(!newContent){location.href=url;return Promise.resolve(false)}
|
|
1195
1424
|
|
|
@@ -1276,14 +1505,33 @@ else {
|
|
|
1276
1505
|
// For same-layout: swap only the container (<main>)
|
|
1277
1506
|
var newBody=doc.querySelector('body');
|
|
1278
1507
|
var isLayoutChange=false;
|
|
1508
|
+
var declaredLayout=pendingLayoutDecl;
|
|
1509
|
+
pendingLayoutDecl=null;
|
|
1279
1510
|
if(newBody){
|
|
1280
1511
|
var newMeta=doc.querySelector('meta[name="stx-layout"]');
|
|
1281
1512
|
var curMeta=document.querySelector('meta[name="stx-layout"]');
|
|
1282
1513
|
var curLayout=curMeta?curMeta.getAttribute('content'):'';
|
|
1283
|
-
|
|
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'):'');
|
|
1284
1530
|
var curGroup=getCurrentLayoutGroup();
|
|
1285
|
-
var newGroup=
|
|
1286
|
-
|
|
1531
|
+
var newGroup=declaredLayout&&declaredLayout.group
|
|
1532
|
+
? declaredLayout.group
|
|
1533
|
+
: getDocLayoutGroup(doc,newLayout);
|
|
1534
|
+
isLayoutChange=curGroup!==newGroup||!!(curLayout&&newLayout&&curLayout!==newLayout);
|
|
1287
1535
|
}
|
|
1288
1536
|
var incomingSetupName=newBody?(newBody.getAttribute('data-stx')||''):'';
|
|
1289
1537
|
// Scripts assigned through innerHTML are inert. Preserve executable
|
|
@@ -1303,7 +1551,7 @@ else {
|
|
|
1303
1551
|
var slot='document-'+(++routedBodyScriptId);
|
|
1304
1552
|
var setupName=generatedSetupName(text);
|
|
1305
1553
|
if(setupName)incomingSetupName=setupName;
|
|
1306
|
-
routedBodyScripts.push({text:text,runAlways:true,slot:slot,setupName:setupName});
|
|
1554
|
+
routedBodyScripts.push({text:text,runAlways:true,slot:slot,setupName:setupName,run:s.getAttribute('data-stx-run')||''});
|
|
1307
1555
|
s.textContent='';
|
|
1308
1556
|
s.setAttribute('type','application/stx-pending');
|
|
1309
1557
|
s.setAttribute('data-stx-route-script',slot);
|
|
@@ -1410,7 +1658,7 @@ else {
|
|
|
1410
1658
|
}
|
|
1411
1659
|
|
|
1412
1660
|
// Push history state (before active link updates so location.pathname is current)
|
|
1413
|
-
if(pushState!==false)
|
|
1661
|
+
if(pushState!==false)writeHistory(pushState,url+(hash||''));
|
|
1414
1662
|
|
|
1415
1663
|
// Update active nav links
|
|
1416
1664
|
updateNav(url);
|
|
@@ -1431,6 +1679,34 @@ else {
|
|
|
1431
1679
|
document.documentElement.lang=doc.documentElement.lang;
|
|
1432
1680
|
}
|
|
1433
1681
|
|
|
1682
|
+
// \u2500\u2500 Reconcile <html> attributes (stacksjs/stx#1798) \u2500\u2500
|
|
1683
|
+
// A layout that scopes its design tokens to the root element
|
|
1684
|
+
// (html.marketing { --bg: \u2026 }) needs that class to LEAVE when you
|
|
1685
|
+
// navigate to a layout that doesn't want it \u2014 otherwise both token sets
|
|
1686
|
+
// match at once and the second layout paints with the first one's
|
|
1687
|
+
// palette. Head stylesheets are additive across a swap, so the class is
|
|
1688
|
+
// the only thing disambiguating them.
|
|
1689
|
+
//
|
|
1690
|
+
// Only what stx wrote is touched, per the markers emitted by
|
|
1691
|
+
// document-shell.ts. Diffing the whole element against the incoming
|
|
1692
|
+
// document would strip the color-mode boot's dark class and
|
|
1693
|
+
// data-reduced-motion, which exist only on the live page.
|
|
1694
|
+
if(doc.documentElement){
|
|
1695
|
+
var curRoot=document.documentElement,incRoot=doc.documentElement;
|
|
1696
|
+
var tokens=function(el,attr){var v=el.getAttribute(attr);return v?v.split(/\\s+/).filter(Boolean):[]};
|
|
1697
|
+
var prevCls=tokens(curRoot,'data-stx-html-class'),nextCls=tokens(incRoot,'data-stx-html-class');
|
|
1698
|
+
prevCls.forEach(function(c){if(nextCls.indexOf(c)===-1)curRoot.classList.remove(c)});
|
|
1699
|
+
nextCls.forEach(function(c){curRoot.classList.add(c)});
|
|
1700
|
+
if(nextCls.length)curRoot.setAttribute('data-stx-html-class',nextCls.join(' '));
|
|
1701
|
+
else curRoot.removeAttribute('data-stx-html-class');
|
|
1702
|
+
|
|
1703
|
+
var prevNames=tokens(curRoot,'data-stx-html-attrs'),nextNames=tokens(incRoot,'data-stx-html-attrs');
|
|
1704
|
+
prevNames.forEach(function(n){if(nextNames.indexOf(n)===-1)curRoot.removeAttribute(n)});
|
|
1705
|
+
nextNames.forEach(function(n){var v=incRoot.getAttribute(n);if(v!==null)curRoot.setAttribute(n,v)});
|
|
1706
|
+
if(nextNames.length)curRoot.setAttribute('data-stx-html-attrs',nextNames.join(' '));
|
|
1707
|
+
else curRoot.removeAttribute('data-stx-html-attrs');
|
|
1708
|
+
}
|
|
1709
|
+
|
|
1434
1710
|
window.dispatchEvent(new CustomEvent('stx:navigate',{detail:{url:url}}));
|
|
1435
1711
|
|
|
1436
1712
|
// Execute page scripts FIRST \u2014 they define setup functions and set _latestSetup
|
|
@@ -1460,7 +1736,7 @@ else {
|
|
|
1460
1736
|
// Skip the wrap for module scripts \u2014 they get their own scope from
|
|
1461
1737
|
// ESM, and top-level 'import' is illegal inside a block, which
|
|
1462
1738
|
// would throw SyntaxError before the script ever runs.
|
|
1463
|
-
var alreadyScoped=
|
|
1739
|
+
var alreadyScoped=runsAlways(typeof entry==='string'?'':entry.run,text);
|
|
1464
1740
|
ns.textContent=(hasImport||alreadyScoped)?text:'{'+text+'}';
|
|
1465
1741
|
ns.setAttribute('data-stx-page','');
|
|
1466
1742
|
var placeholder=entry.slot?document.querySelector('script[data-stx-route-script="'+entry.slot+'"]'):null;
|
|
@@ -1564,7 +1840,8 @@ else {
|
|
|
1564
1840
|
var pLayout=r.headers.get('X-STX-Layout')||'';
|
|
1565
1841
|
var pGroup=r.headers.get('X-STX-Layout-Group')||'';
|
|
1566
1842
|
var pTitle=r.headers.get('X-STX-Title')||'';
|
|
1567
|
-
|
|
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}});
|
|
1568
1845
|
}).then(function(result){
|
|
1569
1846
|
if(o.cache)setCache(key,result.html,result.layout,result.layoutGroup,result.title);
|
|
1570
1847
|
}).catch(function(){}).finally(function(){delete prefetching[key]});
|
|
@@ -1663,7 +1940,7 @@ else {
|
|
|
1663
1940
|
var key=cacheKey(url);
|
|
1664
1941
|
if(!cache[key]){
|
|
1665
1942
|
var wantsFragment=shouldUseFragmentResponse();
|
|
1666
|
-
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?
|
|
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(){});
|
|
1667
1944
|
}
|
|
1668
1945
|
},
|
|
1669
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
|
|
@@ -65,6 +97,7 @@ export declare function render404Page(opts: {
|
|
|
65
97
|
routes?: string[]
|
|
66
98
|
isProduction: boolean
|
|
67
99
|
}): string;
|
|
100
|
+
export declare function resolveServeOpenUrl(port: number, openPath?: string): string;
|
|
68
101
|
export declare function isRenderableCacheCandidate(html: string): boolean;
|
|
69
102
|
export declare function bundleBrowserAsset(entrypoint: string): Promise<Response>;
|
|
70
103
|
/**
|
|
@@ -72,6 +105,14 @@ export declare function bundleBrowserAsset(entrypoint: string): Promise<Response
|
|
|
72
105
|
* @param options Server options with patterns and port
|
|
73
106
|
*/
|
|
74
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';
|
|
75
116
|
/**
|
|
76
117
|
* Build the candidate regexes for a dynamic route file (stacksjs/stx#1927).
|
|
77
118
|
*
|
|
@@ -112,6 +153,7 @@ export declare interface ServeRequestContext {
|
|
|
112
153
|
export declare interface ServeOptions {
|
|
113
154
|
patterns: string[]
|
|
114
155
|
port?: number
|
|
156
|
+
openPath?: string
|
|
115
157
|
componentsDir?: string
|
|
116
158
|
layoutsDir?: string
|
|
117
159
|
partialsDir?: string
|
package/dist/serve.js
CHANGED
|
@@ -8718,7 +8718,7 @@ 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 { extractPageResponseStatus, stateDir, stateDirName } from "@stacksjs/stx";
|
|
8721
|
+
import { BUILD_ID_HEADER, extractPageResponseStatus, getBuildId, stateDir, stateDirName } from "@stacksjs/stx";
|
|
8722
8722
|
var defaultStxModule = import("@stacksjs/stx");
|
|
8723
8723
|
var REACTIVE_TEMPLATE_DIRECTIVE_RE = /@for|:for|@if|:if|@else|:else|x-for|x-if|x-else/;
|
|
8724
8724
|
function extractServerScriptsFromTemplate(content) {
|
|
@@ -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, "&").replace(/</g, "<").replace(/>/g, ">").replace(/"/g, """).replace(/'/g, "'");
|
|
8776
8815
|
}
|
|
@@ -8878,6 +8917,15 @@ function render404Page(opts) {
|
|
|
8878
8917
|
</body>
|
|
8879
8918
|
</html>`;
|
|
8880
8919
|
}
|
|
8920
|
+
function resolveServeOpenUrl(port, openPath = "/") {
|
|
8921
|
+
const baseUrl = `http://localhost:${port}`;
|
|
8922
|
+
try {
|
|
8923
|
+
const url = new URL(openPath || "/", `${baseUrl}/`);
|
|
8924
|
+
return url.origin === baseUrl ? url.toString() : `${baseUrl}/`;
|
|
8925
|
+
} catch {
|
|
8926
|
+
return `${baseUrl}/`;
|
|
8927
|
+
}
|
|
8928
|
+
}
|
|
8881
8929
|
function isRenderableCacheCandidate(html) {
|
|
8882
8930
|
return !html.includes("<!-- Template Processing failed:") && !html.includes("<!-- stx rendering error -->");
|
|
8883
8931
|
}
|
|
@@ -9362,7 +9410,7 @@ After installing: bun add bun-plugin-stx`);
|
|
|
9362
9410
|
pageMiddlewarePatterns.length = 0;
|
|
9363
9411
|
await Promise.all(files.map((f) => detectPageMiddleware(f)));
|
|
9364
9412
|
try {
|
|
9365
|
-
const { Router } = await import("./chunk-
|
|
9413
|
+
const { Router } = await import("./chunk-5pnbtf0j.js");
|
|
9366
9414
|
const pagesDirs = patterns.map((p) => p.replace(/\/$/, "")).filter(Boolean);
|
|
9367
9415
|
const router = new Router(process19.cwd(), { pagesDirs, stateDir: stateDirName() });
|
|
9368
9416
|
if (!options.quiet)
|
|
@@ -9692,10 +9740,17 @@ After installing: bun add bun-plugin-stx`);
|
|
|
9692
9740
|
}
|
|
9693
9741
|
}
|
|
9694
9742
|
}
|
|
9695
|
-
|
|
9743
|
+
const isAssetRequest = isStaticAssetPath(normalizedPath);
|
|
9744
|
+
const dynamicFiles = files.filter((f) => {
|
|
9745
|
+
const nf = f.replace(/^\.\//, "").replace(/\\/g, "/");
|
|
9746
|
+
if (!nf.includes("["))
|
|
9747
|
+
return false;
|
|
9748
|
+
if (isAssetRequest && /\[\.\.\./.test(nf))
|
|
9749
|
+
return false;
|
|
9750
|
+
return true;
|
|
9751
|
+
}).sort((a, b) => routeSpecificity(b) - routeSpecificity(a));
|
|
9752
|
+
for (const filePath of dynamicFiles) {
|
|
9696
9753
|
const normalizedFilePath = filePath.replace(/^\.\//, "").replace(/\\/g, "/");
|
|
9697
|
-
if (!normalizedFilePath.includes("["))
|
|
9698
|
-
continue;
|
|
9699
9754
|
let relativeFilePath = normalizedFilePath;
|
|
9700
9755
|
for (const pattern of patterns) {
|
|
9701
9756
|
const normalizedPattern = pattern.replace(/\\/g, "/").replace(/\/$/, "");
|
|
@@ -9963,6 +10018,7 @@ ${html}`;
|
|
|
9963
10018
|
req = new Request(req, { headers });
|
|
9964
10019
|
}
|
|
9965
10020
|
}
|
|
10021
|
+
let mintedCsrfToken = null;
|
|
9966
10022
|
const _i18nResp = await (async () => {
|
|
9967
10023
|
activeServeLocale = i18nLocale;
|
|
9968
10024
|
activeServeSearch = url.search;
|
|
@@ -9970,6 +10026,9 @@ ${html}`;
|
|
|
9970
10026
|
activeServeCookieHeader = req.headers.get("cookie") || "";
|
|
9971
10027
|
activeServeCookies = parseCookies(req);
|
|
9972
10028
|
activeServeIp = server.requestIP(req)?.address || (req.headers.get("x-forwarded-for") || "").split(",")[0].trim();
|
|
10029
|
+
mintedCsrfToken = csrfTokenToMint(req, activeServeCookies);
|
|
10030
|
+
if (mintedCsrfToken)
|
|
10031
|
+
activeServeCookies[CSRF_COOKIE] = mintedCsrfToken;
|
|
9973
10032
|
const reqCtx = {
|
|
9974
10033
|
url: req.url,
|
|
9975
10034
|
path,
|
|
@@ -10314,6 +10373,7 @@ ${trailingPageScripts.join(`
|
|
|
10314
10373
|
`)}`;
|
|
10315
10374
|
const titleMatch = content.match(/<title[^>]*>([\s\S]*?)<\/title>/i);
|
|
10316
10375
|
const pageTitle = titleMatch ? titleMatch[1].trim() : "";
|
|
10376
|
+
const shipsRuntime = content.includes("data-stx-runtime");
|
|
10317
10377
|
return new Response(fragment, {
|
|
10318
10378
|
status: responseStatus,
|
|
10319
10379
|
headers: {
|
|
@@ -10321,7 +10381,9 @@ ${trailingPageScripts.join(`
|
|
|
10321
10381
|
"X-STX-Fragment": "true",
|
|
10322
10382
|
"X-STX-Layout": pageLayout,
|
|
10323
10383
|
"X-STX-Layout-Group": pageLayoutGroup,
|
|
10384
|
+
"X-STX-Runtime": shipsRuntime ? "true" : "false",
|
|
10324
10385
|
...pageTitle && { "X-STX-Title": encodeURIComponent(pageTitle) },
|
|
10386
|
+
[BUILD_ID_HEADER]: getBuildId(),
|
|
10325
10387
|
...containerAttrs && { "X-STX-Container-Attrs": encodeURIComponent(containerAttrs) },
|
|
10326
10388
|
"Cache-Control": "no-store",
|
|
10327
10389
|
...corsHeaders
|
|
@@ -10478,7 +10540,20 @@ ${trailingPageScripts.join(`
|
|
|
10478
10540
|
activeServeIp = "";
|
|
10479
10541
|
}
|
|
10480
10542
|
})();
|
|
10481
|
-
|
|
10543
|
+
let _finalResp = await applyI18nToResponse(_i18nResp, i18nLocale ?? (i18nConfig?.defaultLocale ?? "en"), path);
|
|
10544
|
+
if (mintedCsrfToken) {
|
|
10545
|
+
try {
|
|
10546
|
+
_finalResp.headers.append("Set-Cookie", csrfCookieHeader(mintedCsrfToken, req.url.startsWith("https://")));
|
|
10547
|
+
} catch {
|
|
10548
|
+
const headers = new Headers(_finalResp.headers);
|
|
10549
|
+
headers.append("Set-Cookie", csrfCookieHeader(mintedCsrfToken, req.url.startsWith("https://")));
|
|
10550
|
+
_finalResp = new Response(_finalResp.body, {
|
|
10551
|
+
status: _finalResp.status,
|
|
10552
|
+
statusText: _finalResp.statusText,
|
|
10553
|
+
headers
|
|
10554
|
+
});
|
|
10555
|
+
}
|
|
10556
|
+
}
|
|
10482
10557
|
if (options.onResponse) {
|
|
10483
10558
|
const hooked = await options.onResponse(req, _finalResp);
|
|
10484
10559
|
if (hooked instanceof Response)
|
|
@@ -10538,10 +10613,11 @@ ${trailingPageScripts.join(`
|
|
|
10538
10613
|
const elapsed = (performance.now() - startTime).toFixed(0);
|
|
10539
10614
|
const routeCount = sourceFiles?.length || 0;
|
|
10540
10615
|
const patternsStr = patterns.join(", ");
|
|
10616
|
+
const openUrl = resolveServeOpenUrl(actualPort, options.openPath);
|
|
10541
10617
|
console.log();
|
|
10542
10618
|
console.log(` \x1B[36m\x1B[1mstx\x1B[0m`);
|
|
10543
10619
|
console.log();
|
|
10544
|
-
console.log(` \x1B[32m\u279C\x1B[0m \x1B[1mLocal\x1B[0m: \x1B[
|
|
10620
|
+
console.log(` \x1B[32m\u279C\x1B[0m \x1B[1mLocal\x1B[0m: \x1B[36m${openUrl}\x1B[0m`);
|
|
10545
10621
|
console.log(` \x1B[32m\u279C\x1B[0m \x1B[1mRoutes\x1B[0m: \x1B[2m${routeCount} files from ${patternsStr}\x1B[0m`);
|
|
10546
10622
|
console.log();
|
|
10547
10623
|
console.log(` \x1B[2mready in ${elapsed}ms\x1B[0m`);
|
|
@@ -10557,7 +10633,7 @@ ${trailingPageScripts.join(`
|
|
|
10557
10633
|
const cmd = line.trim().toLowerCase();
|
|
10558
10634
|
if (cmd === "o") {
|
|
10559
10635
|
const openCmd = process19.platform === "darwin" ? "open" : process19.platform === "win32" ? "start" : "xdg-open";
|
|
10560
|
-
Bun.spawn([openCmd,
|
|
10636
|
+
Bun.spawn([openCmd, openUrl]);
|
|
10561
10637
|
} else if (cmd === "q") {
|
|
10562
10638
|
process19.exit(0);
|
|
10563
10639
|
}
|
|
@@ -10582,10 +10658,16 @@ if (import.meta.main) {
|
|
|
10582
10658
|
export {
|
|
10583
10659
|
staticCacheControl,
|
|
10584
10660
|
serve,
|
|
10661
|
+
routeSpecificity,
|
|
10662
|
+
resolveServeOpenUrl,
|
|
10585
10663
|
render404Page,
|
|
10664
|
+
isStaticAssetPath,
|
|
10586
10665
|
isRenderableCacheCandidate,
|
|
10587
10666
|
isProductionServe,
|
|
10588
10667
|
extractServerScriptsFromTemplate,
|
|
10668
|
+
csrfTokenToMint,
|
|
10669
|
+
csrfCookieHeader,
|
|
10589
10670
|
bundleBrowserAsset,
|
|
10590
|
-
buildDynamicRouteRegexes
|
|
10671
|
+
buildDynamicRouteRegexes,
|
|
10672
|
+
CSRF_COOKIE
|
|
10591
10673
|
};
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "bun-plugin-stx",
|
|
3
3
|
"type": "module",
|
|
4
|
-
"version": "0.2.
|
|
4
|
+
"version": "0.2.154",
|
|
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",
|
|
@@ -39,6 +39,7 @@
|
|
|
39
39
|
"import": "./dist/preload.js"
|
|
40
40
|
},
|
|
41
41
|
"./serve": {
|
|
42
|
+
"types": "./dist/serve.d.ts",
|
|
42
43
|
"bun": "./dist/serve.js",
|
|
43
44
|
"import": "./dist/serve.js"
|
|
44
45
|
},
|