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.
- package/dist/{chunk-5pnbtf0j.js → chunk-z66h8w4a.js} +365 -36
- package/dist/serve.d.ts +23 -0
- package/dist/serve.js +232 -26
- package/package.json +1 -1
|
@@ -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
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
"
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
const
|
|
23
|
-
|
|
24
|
-
const
|
|
25
|
-
|
|
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
|
-
|
|
28
|
-
lines.push(` '${route.pattern}': { ${paramsStr} }`);
|
|
43
|
+
lines.push(" }", "}", "", "export {}", "");
|
|
29
44
|
}
|
|
30
|
-
lines.
|
|
31
|
-
|
|
32
|
-
|
|
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
|
|
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.
|
|
551
|
-
|
|
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=
|
|
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(
|
|
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
|
-
|
|
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 !
|
|
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
|
|
9097
|
+
const HMR_OVERLAY_JS = `
|
|
9098
|
+
function __stxEsc(s){return String(s).replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>')}
|
|
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 — 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">✖ <span style="color:#f2f2f4">'+where+'</span> '+__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?'> ':' ')+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
|
-
|
|
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-
|
|
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
|
-
|
|
9541
|
-
|
|
9542
|
-
|
|
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(
|
|
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
|
-
|
|
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
|
|
10323
|
-
const
|
|
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(
|
|
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