also looking at this
fix(hmr): send SSE keepalive every 5s so /_stx/hmr survives idle timeouts
#1778
3 files
+345
-43
| @@ -45,6 +45,40 @@ const defaultStxModule = import('@stacksjs/stx') | ||
| 45 | 45 | * - both of the above again with a leading `pages/` stripped, for |
| 46 | 46 | * projects that nest routable views under `pages/` |
| 47 | 47 | */ |
| 48 | /** | |
| 49 | * Snapshot of the in-flight request, threaded from the fetch handler down to | |
| 50 | * the `<script server>` render (stacksjs/stacks#1967). | |
| 51 | * | |
| 52 | * The module-level `activeServe*` singletons are NOT safe to read at render | |
| 53 | * time: a render can suspend at an `await` (onRequest hooks, DB queries in | |
| 54 | * server scripts) while a concurrent request — the HMR EventSource opens on | |
| 55 | * every page load — runs its own handler and resets the singletons in its | |
| 56 | * `finally`. Threading an immutable per-request snapshot removes that race; | |
| 57 | * the singletons remain only as a fallback for legacy call sites. | |
| 58 | */ | |
| 59 | export interface ServeRequestContext { | |
| 60 | /** Full request URL (`req.url`). */ | |
| 61 | url: string | |
| 62 | /** Normalized request path (no query string). */ | |
| 63 | path: string | |
| 64 | /** Query string including the leading `?`, or `''`. */ | |
| 65 | search: string | |
| 66 | /** `Host` header, or `''`. */ | |
| 67 | host: string | |
| 68 | /** Raw `Cookie` header, or `''`. */ | |
| 69 | cookieHeader: string | |
| 70 | /** Parsed cookies — always an object. */ | |
| 71 | cookies: Record<string, string> | |
| 72 | /** Best-effort client IP, or `''`. */ | |
| 73 | ip: string | |
| 74 | /** Active locale for the SSR pass, when i18n is enabled. */ | |
| 75 | locale: string | null | |
| 76 | /** Dynamic route params (`[id].stx` → `{ id }`) — set by the dynamic-route renderer. */ | |
| 77 | params: Record<string, string> | |
| 78 | /** Extra keys merged from a non-Response `onRequest` return (app-owned). */ | |
| 79 | [key: string]: unknown | |
| 80 | } | |
| 81 | ||
| 48 | 82 | export function buildDynamicRouteRegexes(fileRouteBase: string): RegExp[] { |
| 49 | 83 | const toPattern = (p: string): string => p |
| 50 | 84 | .replace(/\[([^\]]+)\]/g, '([^/]+)') // [param] → capture group |
| @@ -317,14 +351,24 @@ export interface ServeOptions { | ||
| 317 | 351 | * return await proxyToApi(req) |
| 318 | 352 | * } |
| 319 | 353 | * |
| 320 | * The runtime awaits the return and only short-circuits on a truthy value, | |
| 354 | * The runtime awaits the return and only short-circuits on a `Response`, | |
| 321 | 355 | * so both sync and async null/undefined behave identically. |
| 356 | * | |
| 357 | * Returning a plain object instead merges it into the per-request context | |
| 358 | * that `<script server>` blocks see as `__stxServeContext` (and that is | |
| 359 | * mirrored on `globalThis.__stxServeContext` during the render). This is | |
| 360 | * the race-free channel for frameworks that authenticate/localize in | |
| 361 | * `onRequest` and need that state visible to server scripts — plain | |
| 362 | * globals set inside the hook can be clobbered by concurrent requests, | |
| 363 | * and AsyncLocalStorage `enterWith()` does not survive the hook's await | |
| 364 | * boundary (stacksjs/stacks#1967). | |
| 322 | 365 | */ |
| 323 | 366 | onRequest?: (req: Request) => |
| 324 | 367 | | Response |
| 368 | | Record<string, unknown> | |
| 325 | 369 | | null |
| 326 | 370 | | undefined |
| 327 | | Promise<Response | null | undefined> | |
| 371 | | Promise<Response | Record<string, unknown> | null | undefined> | |
| 328 | 372 | |
| 329 | 373 | /** |
| 330 | 374 | * **Page middleware.** Modelled on Laravel's named-route middleware. |
| @@ -942,17 +986,50 @@ export async function serve(options: ServeOptions): Promise<void> { | ||
| 942 | 986 | } |
| 943 | 987 | } |
| 944 | 988 | |
| 945 | /** Expose the current request query string + Host/cookies/IP to `<script server>` blocks. */ | |
| 946 | function injectServeRequestContext(context: Record<string, any>) { | |
| 947 | if (activeServeSearch) { | |
| 948 | context.__stxServeSearch = activeServeSearch | |
| 949 | ;(globalThis as { __stxServeSearch?: string }).__stxServeSearch = activeServeSearch | |
| 989 | /** | |
| 990 | * Expose the current request (query string, Host, cookies, IP, url, locale, | |
| 991 | * route params) to `<script server>` blocks. Prefers the threaded per-request | |
| 992 | * snapshot over the module singletons — the singletons can be clobbered by a | |
| 993 | * concurrent request while this render is suspended at an await | |
| 994 | * (stacksjs/stacks#1967); the snapshot cannot. | |
| 995 | */ | |
| 996 | function injectServeRequestContext(context: Record<string, any>, reqCtx?: ServeRequestContext) { | |
| 997 | const search = reqCtx ? reqCtx.search : activeServeSearch | |
| 998 | const host = reqCtx ? reqCtx.host : activeServeHost | |
| 999 | const cookies = reqCtx ? reqCtx.cookies : activeServeCookies | |
| 1000 | const ip = reqCtx ? reqCtx.ip : activeServeIp | |
| 1001 | ||
| 1002 | // Unconditional: a query-less request must OVERWRITE the previous | |
| 1003 | // request's value on the shared global, or stale search strings leak | |
| 1004 | // across requests (the old `if (activeServeSearch)` guard did exactly | |
| 1005 | // that — stacksjs/stacks#1967). | |
| 1006 | context.__stxServeSearch = search | |
| 1007 | ;(globalThis as { __stxServeSearch?: string }).__stxServeSearch = search | |
| 1008 | if (host) | |
| 1009 | context.host = host | |
| 1010 | context.cookies = cookies | |
| 1011 | if (ip) | |
| 1012 | context.ip = ip | |
| 1013 | ||
| 1014 | // The whole request in one object. As a context key it becomes an ambient | |
| 1015 | // `__stxServeContext` binding inside `<script server>` (a per-render | |
| 1016 | // snapshot, immune to global clobbering); the globalThis mirror serves | |
| 1017 | // out-of-continuation readers — e.g. Stacks' `requestContext` fallback — | |
| 1018 | // and is refreshed here, immediately before script execution, so it is | |
| 1019 | // current for the synchronous prefix of every server script. | |
| 1020 | const full: ServeRequestContext = reqCtx ?? { | |
| 1021 | url: '', | |
| 1022 | path: '', | |
| 1023 | search, | |
| 1024 | host, | |
| 1025 | cookieHeader: activeServeCookieHeader, | |
| 1026 | cookies, | |
| 1027 | ip, | |
| 1028 | locale: activeServeLocale, | |
| 1029 | params: {}, | |
| 950 | 1030 | } |
| 951 | if (activeServeHost) | |
| 952 | context.host = activeServeHost | |
| 953 | context.cookies = activeServeCookies | |
| 954 | if (activeServeIp) | |
| 955 | context.ip = activeServeIp | |
| 1031 | context.__stxServeContext = full | |
| 1032 | ;(globalThis as { __stxServeContext?: ServeRequestContext }).__stxServeContext = full | |
| 956 | 1033 | } |
| 957 | 1034 | |
| 958 | 1035 | /** Render cache must vary by locale/host/cookies — same `.stx` file can serve different `t()`/host/cookie-gated output. */ |
| @@ -1329,7 +1406,7 @@ export async function serve(options: ServeOptions): Promise<void> { | ||
| 1329 | 1406 | } |
| 1330 | 1407 | |
| 1331 | 1408 | // Lazy template processing function |
| 1332 | async function processTemplate(filePath: string): Promise<string> { | |
| 1409 | async function processTemplate(filePath: string, reqCtx?: ServeRequestContext): Promise<string> { | |
| 1333 | 1410 | const content = await Bun.file(filePath).text() |
| 1334 | 1411 | const skipCacheHint = /(?:^|[^\w$])__stx_skip_cache\s*=\s*true/.test(content) |
| 1335 | 1412 | |
| @@ -1370,7 +1447,10 @@ export async function serve(options: ServeOptions): Promise<void> { | ||
| 1370 | 1447 | // again and wipe what useHead just populated. |
| 1371 | 1448 | resetHead() |
| 1372 | 1449 | injectServeLocaleContext(context) |
| 1373 | injectServeRequestContext(context) | |
| 1450 | injectServeRequestContext(context, reqCtx) | |
| 1451 | // Static routes have no dynamic segments — give server scripts the same | |
| 1452 | // always-an-object `params` shape the dynamic path provides. | |
| 1453 | context.params = reqCtx?.params ?? {} | |
| 1374 | 1454 | for (const scriptBody of serverScripts) { |
| 1375 | 1455 | await extractVariables(scriptBody, context, filePath) |
| 1376 | 1456 | } |
| @@ -1477,7 +1557,7 @@ export async function serve(options: ServeOptions): Promise<void> { | ||
| 1477 | 1557 | } |
| 1478 | 1558 | |
| 1479 | 1559 | // Function to get or create route |
| 1480 | async function getRoute(requestPath: string): Promise<string | null> { | |
| 1560 | async function getRoute(requestPath: string, reqCtx?: ServeRequestContext): Promise<string | null> { | |
| 1481 | 1561 | // Dev mode: never cache — always re-process templates so file changes are reflected |
| 1482 | 1562 | // Production caching is handled by the production server, not the dev serve path |
| 1483 | 1563 | |
| @@ -1541,7 +1621,7 @@ export async function serve(options: ServeOptions): Promise<void> { | ||
| 1541 | 1621 | |
| 1542 | 1622 | if (normalizedFilePath === possible || relativeFilePath === possible) { |
| 1543 | 1623 | // Process template on every request (dev mode — no caching) |
| 1544 | const output = await processTemplate(filePath) | |
| 1624 | const output = await processTemplate(filePath, reqCtx) | |
| 1545 | 1625 | return output |
| 1546 | 1626 | } |
| 1547 | 1627 | } |
| @@ -1578,7 +1658,7 @@ export async function serve(options: ServeOptions): Promise<void> { | ||
| 1578 | 1658 | `/${fileRoute}.md` === requestPath || |
| 1579 | 1659 | `/${fileRoute}.html` === requestPath || |
| 1580 | 1660 | (isIndexFile && isRootRequest)) { |
| 1581 | const output = await processTemplate(filePath) | |
| 1661 | const output = await processTemplate(filePath, reqCtx) | |
| 1582 | 1662 | routes.set(requestPath, output) |
| 1583 | 1663 | return output |
| 1584 | 1664 | } |
| @@ -1589,7 +1669,7 @@ export async function serve(options: ServeOptions): Promise<void> { | ||
| 1589 | 1669 | const prettyRoute = fileRoute.slice(6) // Remove 'pages/' prefix |
| 1590 | 1670 | if (`/${prettyRoute}` === requestPath || |
| 1591 | 1671 | prettyRoute === normalizedPath) { |
| 1592 | const output = await processTemplate(filePath) | |
| 1672 | const output = await processTemplate(filePath, reqCtx) | |
| 1593 | 1673 | routes.set(requestPath, output) |
| 1594 | 1674 | return output |
| 1595 | 1675 | } |
| @@ -1630,7 +1710,7 @@ export async function serve(options: ServeOptions): Promise<void> { | ||
| 1630 | 1710 | const paramValues = match.slice(1) |
| 1631 | 1711 | |
| 1632 | 1712 | // Process template with dynamic params in context |
| 1633 | const output = await processTemplateDynamic(filePath, paramNames, paramValues, normalizedPath) | |
| 1713 | const output = await processTemplateDynamic(filePath, paramNames, paramValues, normalizedPath, reqCtx) | |
| 1634 | 1714 | routes.set(requestPath, output) |
| 1635 | 1715 | return output |
| 1636 | 1716 | } |
| @@ -1646,6 +1726,7 @@ export async function serve(options: ServeOptions): Promise<void> { | ||
| 1646 | 1726 | paramNames: string[], |
| 1647 | 1727 | paramValues: string[], |
| 1648 | 1728 | routePath: string, |
| 1729 | reqCtx?: ServeRequestContext, | |
| 1649 | 1730 | ): Promise<string> { |
| 1650 | 1731 | |
| 1651 | 1732 | const content = await Bun.file(filePath).text() |
| @@ -1669,11 +1750,34 @@ export async function serve(options: ServeOptions): Promise<void> { | ||
| 1669 | 1750 | __route: routePath, |
| 1670 | 1751 | } |
| 1671 | 1752 | |
| 1672 | // Add each param to context | |
| 1753 | // Name→value map of the dynamic segments, URL-decoded (path segments | |
| 1754 | // arrive percent-encoded; scripts want `café`, not `caf%C3%A9`). Single | |
| 1755 | // source of truth for the ambient bindings below, the server-script | |
| 1756 | // `params` object, and the client-side `window.stx._rp` injection. | |
| 1757 | const paramsObj: Record<string, string> = {} | |
| 1673 | 1758 | for (let i = 0; i < paramNames.length; i++) { |
| 1674 | context[paramNames[i]] = paramValues[i] | |
| 1759 | let value = paramValues[i] ?? '' | |
| 1760 | try { | |
| 1761 | value = decodeURIComponent(value) | |
| 1762 | } | |
| 1763 | catch { | |
| 1764 | // malformed escape — keep the raw segment | |
| 1765 | } | |
| 1766 | paramsObj[paramNames[i]] = value | |
| 1675 | 1767 | } |
| 1676 | 1768 | |
| 1769 | // Add each param to context as a bare identifier (`[id].stx` → `id`)... | |
| 1770 | for (const name of paramNames) { | |
| 1771 | context[name] = paramsObj[name] | |
| 1772 | } | |
| 1773 | // ...and as the documented `params` object — @stacksjs/stx's | |
| 1774 | // variable-extractor plumbs `context.params` into every server script, | |
| 1775 | // but the serve path never set it, so `params.id` was always `{}` | |
| 1776 | // (stacksjs/stacks#1967). | |
| 1777 | context.params = paramsObj | |
| 1778 | if (reqCtx) | |
| 1779 | reqCtx.params = paramsObj | |
| 1780 | ||
| 1677 | 1781 | const { processDirectives, extractVariables, defaultConfig, injectRouterScript: injectRouter, resetHead: resetHeadDyn } = await stxModule |
| 1678 | 1782 | // Fresh head state per request (see static-route handler above for rationale) |
| 1679 | 1783 | resetHeadDyn() |
| @@ -1683,7 +1787,7 @@ export async function serve(options: ServeOptions): Promise<void> { | ||
| 1683 | 1787 | // was first added (only the static-route path called |
| 1684 | 1788 | // injectServeRequestContext), so every dynamic route silently never |
| 1685 | 1789 | // saw `host`/`cookies`/`ip`/`__stxServeSearch` until this fix. |
| 1686 | injectServeRequestContext(context) | |
| 1790 | injectServeRequestContext(context, reqCtx) | |
| 1687 | 1791 | for (const scriptBody of dynServerScripts) { |
| 1688 | 1792 | await extractVariables(scriptBody, context, filePath) |
| 1689 | 1793 | } |
| @@ -1724,12 +1828,9 @@ export async function serve(options: ServeOptions): Promise<void> { | ||
| 1724 | 1828 | const dependencies = new Set<string>() |
| 1725 | 1829 | output = await processDirectives(output, context, filePath, config, dependencies) |
| 1726 | 1830 | |
| 1727 | // Inject route params for client-side useRoute().params | |
| 1831 | // Inject route params for client-side useRoute().params — the same | |
| 1832 | // decoded paramsObj the server script saw, so both sides agree. | |
| 1728 | 1833 | if (paramNames.length > 0) { |
| 1729 | const paramsObj: Record<string, string> = {} | |
| 1730 | for (let i = 0; i < paramNames.length; i++) { | |
| 1731 | paramsObj[paramNames[i]] = paramValues[i] | |
| 1732 | } | |
| 1733 | 1834 | const paramsScript = `<script>if(window.stx)window.stx._rp=${JSON.stringify(paramsObj)};else window.__stx_rp=${JSON.stringify(paramsObj)};</script>` |
| 1734 | 1835 | if (output.includes('</head>')) { |
| 1735 | 1836 | output = output.replace('</head>', `${paramsScript}\n</head>`) |
| @@ -2031,6 +2132,21 @@ export async function serve(options: ServeOptions): Promise<void> { | ||
| 2031 | 2132 | activeServeCookieHeader = req.headers.get('cookie') || '' |
| 2032 | 2133 | activeServeCookies = parseCookies(req) |
| 2033 | 2134 | activeServeIp = server.requestIP(req)?.address || (req.headers.get('x-forwarded-for') || '').split(',')[0]!.trim() |
| 2135 | // Immutable-per-request snapshot threaded down to the render. | |
| 2136 | // The singletons above can be reset by a concurrent request's | |
| 2137 | // `finally` while this render is suspended at an await; this | |
| 2138 | // object cannot (stacksjs/stacks#1967). | |
| 2139 | const reqCtx: ServeRequestContext = { | |
| 2140 | url: req.url, | |
| 2141 | path, | |
| 2142 | search: activeServeSearch, | |
| 2143 | host: activeServeHost, | |
| 2144 | cookieHeader: activeServeCookieHeader, | |
| 2145 | cookies: activeServeCookies, | |
| 2146 | ip: activeServeIp, | |
| 2147 | locale: activeServeLocale, | |
| 2148 | params: {}, | |
| 2149 | } | |
| 2034 | 2150 | try { |
| 2035 | 2151 | |
| 2036 | 2152 | // Handle CORS preflight |
| @@ -2056,16 +2172,19 @@ export async function serve(options: ServeOptions): Promise<void> { | ||
| 2056 | 2172 | // and gives the readyState a definite "open" before any change. |
| 2057 | 2173 | c.enqueue(hmrEncoder.encode('data: {"type":"connected"}\n\n')) |
| 2058 | 2174 | // Keepalive ping. Without traffic, the stream idles and gets |
| 2059 | // killed by Bun.serve's `idleTimeout` (default 10s) — the browser | |
| 2060 | // sees `ERR_INCOMPLETE_CHUNKED_ENCODING` and falls into a | |
| 2061 | // reconnect loop that mostly hides the HMR being broken. A | |
| 2062 | // 15s comment line (the leading `:` is the SSE comment syntax, | |
| 2063 | // ignored by the EventSource API) is well under any sane idle | |
| 2064 | // timeout (Bun's, reverse proxy's, browser's) and costs nothing. | |
| 2175 | // killed by Bun.serve's `idleTimeout` (default 10s) or a reverse | |
| 2176 | // proxy's read timeout — the browser then sees | |
| 2177 | // `ERR_INCOMPLETE_CHUNKED_ENCODING` and falls into a reconnect | |
| 2178 | // loop that mostly hides the HMR being broken. The ping MUST fire | |
| 2179 | // faster than the shortest such timeout: 15s was longer than Bun's | |
| 2180 | // 10s default, so the connection died before the first ping ever | |
| 2181 | // arrived. 5s comfortably beats Bun's 10s and typical proxy (rpx, | |
| 2182 | // nginx) read timeouts. The leading `:` is SSE comment syntax | |
| 2183 | // (ignored by the EventSource API) and costs nothing. | |
| 2065 | 2184 | keepalive = setInterval(() => { |
| 2066 | 2185 | try { c.enqueue(hmrEncoder.encode(': keepalive\n\n')) } |
| 2067 | 2186 | catch { /* stream closed — cancel handler clears the timer */ } |
| 2068 | }, 15_000) | |
| 2187 | }, 5_000) | |
| 2069 | 2188 | }, |
| 2070 | 2189 | cancel() { |
| 2071 | 2190 | if (keepalive) clearInterval(keepalive) |
| @@ -2082,10 +2201,17 @@ export async function serve(options: ServeOptions): Promise<void> { | ||
| 2082 | 2201 | }) |
| 2083 | 2202 | } |
| 2084 | 2203 | |
| 2085 | // Custom onRequest handler — short-circuits if a Response is returned | |
| 2204 | // Custom onRequest handler — short-circuits if a Response is | |
| 2205 | // returned. A plain-object return is merged into the request | |
| 2206 | // context instead, giving the hook a race-free way to hand | |
| 2207 | // state (auth cookies, locale, a user object, ...) to | |
| 2208 | // `<script server>` blocks — see the onRequest option docs. | |
| 2086 | 2209 | if (options.onRequest) { |
| 2087 | const customResponse = await options.onRequest(req) | |
| 2088 | if (customResponse) return customResponse | |
| 2210 | const hookResult = await options.onRequest(req) | |
| 2211 | if (hookResult instanceof Response) | |
| 2212 | return hookResult | |
| 2213 | if (hookResult && typeof hookResult === 'object') | |
| 2214 | Object.assign(reqCtx, hookResult) | |
| 2089 | 2215 | } |
| 2090 | 2216 | |
| 2091 | 2217 | // ── Page middleware pipeline ──────────────────────────────────── |
| @@ -2272,10 +2398,10 @@ export async function serve(options: ServeOptions): Promise<void> { | ||
| 2272 | 2398 | |
| 2273 | 2399 | // Redirect root to /home if no index exists (dashboard pattern) |
| 2274 | 2400 | if (path === '/') { |
| 2275 | const indexContent = await getRoute('/') | |
| 2401 | const indexContent = await getRoute('/', reqCtx) | |
| 2276 | 2402 | if (!indexContent) { |
| 2277 | 2403 | // Try /home as default landing page |
| 2278 | const homeContent = await getRoute('/home') | |
| 2404 | const homeContent = await getRoute('/home', reqCtx) | |
| 2279 | 2405 | if (homeContent) { |
| 2280 | 2406 | return new Response(null, { |
| 2281 | 2407 | status: 302, |
| @@ -2289,7 +2415,7 @@ export async function serve(options: ServeOptions): Promise<void> { | ||
| 2289 | 2415 | } |
| 2290 | 2416 | |
| 2291 | 2417 | // Try to serve the requested page (lazy load on demand) |
| 2292 | const content = await getRoute(path) | |
| 2418 | const content = await getRoute(path, reqCtx) | |
| 2293 | 2419 | if (content) { |
| 2294 | 2420 | // SPA navigation: return only <main> content as fragment |
| 2295 | 2421 | const isSpaNav = req.headers.get('X-STX-Router') === 'true' |
| @@ -2427,7 +2553,7 @@ export async function serve(options: ServeOptions): Promise<void> { | ||
| 2427 | 2553 | } |
| 2428 | 2554 | |
| 2429 | 2555 | // Try without extension |
| 2430 | const contentWithExt = await getRoute(`${path}.html`) | |
| 2556 | const contentWithExt = await getRoute(`${path}.html`, reqCtx) | |
| 2431 | 2557 | if (contentWithExt) { |
| 2432 | 2558 | return new Response(injectHmrClient(contentWithExt), { |
| 2433 | 2559 | headers: { |
| @@ -2585,7 +2711,7 @@ export async function serve(options: ServeOptions): Promise<void> { | ||
| 2585 | 2711 | // index-file conventions all just work. |
| 2586 | 2712 | for (const custom404 of ['/404', '/errors/404']) { |
| 2587 | 2713 | try { |
| 2588 | const customContent = await getRoute(custom404) | |
| 2714 | const customContent = await getRoute(custom404, reqCtx) | |
| 2589 | 2715 | if (customContent) { |
| 2590 | 2716 | return new Response( |
| 2591 | 2717 | isProd ? customContent : injectHmrClient(customContent), |
| @@ -0,0 +1,160 @@ | ||
| 1 | import { afterAll, beforeAll, describe, expect, it, setDefaultTimeout } from 'bun:test' | |
| 2 | import { mkdtemp, rm } from 'node:fs/promises' | |
| 3 | import { tmpdir } from 'node:os' | |
| 4 | import path from 'node:path' | |
| 5 | ||
| 6 | /** | |
| 7 | * Request context + dynamic params in `<script server>` (stacksjs/stacks#1967). | |
| 8 | * | |
| 9 | * Pins three fixes, end-to-end against a real serve() subprocess: | |
| 10 | * | |
| 11 | * 1. `params` — the documented name→value object for `[param].stx` routes is | |
| 12 | * populated (it used to be always `{}` on the serve path), URL-decoded, and | |
| 13 | * `useRoute().params`/`useRoute().query` agree with it server-side. | |
| 14 | * 2. `__stxServeContext` — the full request snapshot (url/path/search/cookies) | |
| 15 | * is an ambient binding in every server script, and a plain-object return | |
| 16 | * from `onRequest` is merged into it (the race-free app channel). | |
| 17 | * 3. The singleton race — a request whose handling suspends at an await while | |
| 18 | * ANOTHER request completes (resetting the module-level activeServe* | |
| 19 | * singletons in its `finally`) must still see ITS OWN cookies/search. | |
| 20 | * Before the per-request snapshot was threaded through, the suspended | |
| 21 | * render read the reset singletons: empty cookie, empty search. | |
| 22 | */ | |
| 23 | ||
| 24 | setDefaultTimeout(60_000) | |
| 25 | ||
| 26 | const PORT = 42_000 + (process.pid % 1000) | |
| 27 | const BASE = `http://localhost:${PORT}` | |
| 28 | const SERVE_SRC = path.join(import.meta.dir, '..', 'src', 'serve.ts') | |
| 29 | ||
| 30 | let dir: string | |
| 31 | let proc: ReturnType<typeof Bun.spawn> | null = null | |
| 32 | ||
| 33 | async function fetchText(url: string, headers: Record<string, string> = {}): Promise<string> { | |
| 34 | const res = await fetch(url, { headers }) | |
| 35 | return await res.text() | |
| 36 | } | |
| 37 | ||
| 38 | /** Extract and parse the PROBE::…::END JSON a fixture page renders ( HTML-escapes quotes). */ | |
| 39 | function parseProbe(html: string): Record<string, unknown> { | |
| 40 | const m = html.match(/PROBE::(.*?)::END/s) | |
| 41 | if (!m) | |
| 42 | throw new Error(`no probe marker in response:\n${html.slice(0, 400)}`) | |
| 43 | return JSON.parse(m[1].replace(/"/g, '"').replace(/&/g, '&')) | |
| 44 | } | |
| 45 | ||
| 46 | beforeAll(async () => { | |
| 47 | dir = await mkdtemp(path.join(tmpdir(), 'stx-serve-ctx-')) | |
| 48 | ||
| 49 | // A dynamic route probing every request-context surface at once. | |
| 50 | await Bun.write(path.join(dir, 'views', 'probe', '[id].stx'), `<script server> | |
| 51 | const route = useRoute() | |
| 52 | const probe = { | |
| 53 | bareId: typeof id !== 'undefined' ? id : null, | |
| 54 | paramsId: params?.id ?? null, | |
| 55 | cookieToken: cookies?.token ?? null, | |
| 56 | search: typeof __stxServeSearch !== 'undefined' ? __stxServeSearch : null, | |
| 57 | ctxPath: typeof __stxServeContext !== 'undefined' ? __stxServeContext.path : null, | |
| 58 | ctxUrl: typeof __stxServeContext !== 'undefined' ? __stxServeContext.url : null, | |
| 59 | merged: typeof __stxServeContext !== 'undefined' ? (__stxServeContext.mergedFromHook ?? null) : null, | |
| 60 | routeParamsId: route.params.id ?? null, | |
| 61 | routeQueryX: route.query.x ?? null, | |
| 62 | } | |
| 63 | const probeJson = JSON.stringify(probe) | |
| 64 | </script> | |
| 65 | <div id="probe">PROBE::{{ probeJson }}::END</div> | |
| 66 | `) | |
| 67 | ||
| 68 | // A static route whose HANDLING is delayed in onRequest — long enough for a | |
| 69 | // concurrent request to complete and reset the module singletons. | |
| 70 | await Bun.write(path.join(dir, 'views', 'slow.stx'), `<script server> | |
| 71 | const probe = { | |
| 72 | cookieToken: cookies?.token ?? null, | |
| 73 | search: typeof __stxServeSearch !== 'undefined' ? __stxServeSearch : null, | |
| 74 | } | |
| 75 | const probeJson = JSON.stringify(probe) | |
| 76 | </script> | |
| 77 | <div id="probe">PROBE::{{ probeJson }}::END</div> | |
| 78 | `) | |
| 79 | ||
| 80 | // Driver: boots serve() from source with an onRequest hook that (a) delays | |
| 81 | // /slow so the race window is wide open, and (b) returns a plain object so | |
| 82 | // the merge-into-context contract is exercised. | |
| 83 | await Bun.write(path.join(dir, 'driver.ts'), `import { serve } from ${JSON.stringify(SERVE_SRC)} | |
| 84 | ||
| 85 | serve({ | |
| 86 | patterns: ['views'], | |
| 87 | port: ${PORT}, | |
| 88 | async onRequest(req) { | |
| 89 | const { pathname } = new URL(req.url) | |
| 90 | if (pathname.startsWith('/slow')) | |
| 91 | await Bun.sleep(250) | |
| 92 | return { mergedFromHook: 'hook-value' } | |
| 93 | }, | |
| 94 | }) | |
| 95 | `) | |
| 96 | ||
| 97 | proc = Bun.spawn(['bun', 'driver.ts'], { cwd: dir, stdout: 'pipe', stderr: 'pipe' }) | |
| 98 | ||
| 99 | // Wait for the server to accept requests. | |
| 100 | const deadline = Date.now() + 30_000 | |
| 101 | while (true) { | |
| 102 | try { | |
| 103 | await fetch(`${BASE}/definitely-not-a-page`) | |
| 104 | break | |
| 105 | } | |
| 106 | catch { | |
| 107 | if (Date.now() > deadline) | |
| 108 | throw new Error('serve() subprocess never came up') | |
| 109 | await Bun.sleep(150) | |
| 110 | } | |
| 111 | } | |
| 112 | }) | |
| 113 | ||
| 114 | afterAll(async () => { | |
| 115 | proc?.kill() | |
| 116 | await rm(dir, { recursive: true, force: true }) | |
| 117 | }) | |
| 118 | ||
| 119 | describe('dynamic route params in <script server>', () => { | |
| 120 | it('populates params, the bare identifier, and useRoute() — URL-decoded', async () => { | |
| 121 | const html = await fetchText(`${BASE}/probe/caf%C3%A9-42?x=1`, { cookie: 'token=tok123' }) | |
| 122 | const probe = parseProbe(html) | |
| 123 | expect(probe.bareId).toBe('café-42') | |
| 124 | expect(probe.paramsId).toBe('café-42') | |
| 125 | expect(probe.routeParamsId).toBe('café-42') | |
| 126 | expect(probe.routeQueryX).toBe('1') | |
| 127 | }) | |
| 128 | }) | |
| 129 | ||
| 130 | describe('__stxServeContext in <script server>', () => { | |
| 131 | it('exposes url/path/search/cookies and the merged onRequest object', async () => { | |
| 132 | const html = await fetchText(`${BASE}/probe/abc?x=7`, { cookie: 'token=tok456' }) | |
| 133 | const probe = parseProbe(html) | |
| 134 | expect(probe.cookieToken).toBe('tok456') | |
| 135 | expect(probe.search).toBe('?x=7') | |
| 136 | expect(probe.ctxPath).toBe('/probe/abc') | |
| 137 | expect(String(probe.ctxUrl)).toContain('/probe/abc?x=7') | |
| 138 | expect(probe.merged).toBe('hook-value') | |
| 139 | }) | |
| 140 | ||
| 141 | it('a query-less request sees an EMPTY search, not the previous request\'s', async () => { | |
| 142 | await fetchText(`${BASE}/probe/first?stale=1`) // leaves ?stale=1 behind pre-fix | |
| 143 | const probe = parseProbe(await fetchText(`${BASE}/probe/second`)) | |
| 144 | expect(probe.search).toBe('') | |
| 145 | }) | |
| 146 | }) | |
| 147 | ||
| 148 | describe('concurrent-request singleton race (the #1967 nondeterminism)', () => { | |
| 149 | it('a render suspended at an await keeps its own cookies/search', async () => { | |
| 150 | // A: handling suspends 250ms in onRequest before the render runs. | |
| 151 | const a = fetchText(`${BASE}/slow?mine=1`, { cookie: 'token=slowAAA' }) | |
| 152 | // B: lands mid-suspension, completes fast, and its `finally` resets the | |
| 153 | // module singletons — which is what A's render used to read. | |
| 154 | await Bun.sleep(80) | |
| 155 | await fetchText(`${BASE}/definitely-not-a-page`) | |
| 156 | const probe = parseProbe(await a) | |
| 157 | expect(probe.cookieToken).toBe('slowAAA') | |
| 158 | expect(probe.search).toBe('?mine=1') | |
| 159 | }) | |
| 160 | }) | |
| @@ -530,7 +530,23 @@ catch { | ||
| 530 | 530 | |
| 531 | 531 | // Mock Vue/Nuxt-like composables commonly used in dashboard pages |
| 532 | 532 | const definePageMeta = (_meta: unknown) => {} |
| 533 | const useRoute = () => ({ params: {}, query: {}, path: '', name: '', fullPath: '', hash: '', matched: [] }) | |
| 533 | // useRoute() mirrors what the browser's useRoute() reads from | |
| 534 | // window.stx._rp — the serve path passes real params/search via context | |
| 535 | // (stacksjs/stacks#1967), so `useRoute().params.id` agrees on both sides. | |
| 536 | const useRoute = () => { | |
| 537 | const serveCtx = context.__stxServeContext as { path?: string, search?: string } | undefined | |
| 538 | const search = (context.__stxServeSearch as string | undefined) ?? serveCtx?.search ?? '' | |
| 539 | const path = serveCtx?.path ?? '' | |
| 540 | return { | |
| 541 | params: (context.params as Record<string, string> | undefined) ?? {}, | |
| 542 | query: Object.fromEntries(new URLSearchParams(search)), | |
| 543 | path, | |
| 544 | name: '', | |
| 545 | fullPath: path + search, | |
| 546 | hash: '', | |
| 547 | matched: [], | |
| 548 | } | |
| 549 | } | |
| 534 | 550 | const useRouter = () => ({ push: (_to: unknown) => {}, replace: (_to: unknown) => {}, back: () => {}, forward: () => {}, go: (_n: number) => {} }) |
| 535 | 551 | // useHead is auto-injected so server scripts can call it without explicitly |
| 536 | 552 | // importing from 'stx'. It mutates module-global currentHead in head.ts, |