ReviewOS

also looking at this

stacks/stx

fix(hmr): send SSE keepalive every 5s so /_stx/hmr survives idle timeouts

#1778
Merged glennmichael123 wants to merge fix/hmr-keepalive-sse into main
3 files +345 -43
packages/bun-plugin/src/serve.tsmodified+168-42
Changes to packages/bun-plugin/src/serve.ts
@@ -45,6 +45,40 @@ const defaultStxModule = import('@stacksjs/stx')
4545 * - both of the above again with a leading `pages/` stripped, for
4646 * projects that nest routable views under `pages/`
4747 */
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 */
59export 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
4882export function buildDynamicRouteRegexes(fileRouteBase: string): RegExp[] {
4983 const toPattern = (p: string): string => p
5084 .replace(/\[([^\]]+)\]/g, '([^/]+)') // [param] → capture group
@@ -317,14 +351,24 @@ export interface ServeOptions {
317351 * return await proxyToApi(req)
318352 * }
319353 *
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`,
321355 * 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).
322365 */
323366 onRequest?: (req: Request) =>
324367 | Response
368 | Record<string, unknown>
325369 | null
326370 | undefined
327 | Promise<Response | null | undefined>
371 | Promise<Response | Record<string, unknown> | null | undefined>
328372
329373 /**
330374 * **Page middleware.** Modelled on Laravel's named-route middleware.
@@ -942,17 +986,50 @@ export async function serve(options: ServeOptions): Promise<void> {
942986 }
943987 }
944988
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: {},
9501030 }
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
9561033 }
9571034
9581035 /** 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> {
13291406 }
13301407
13311408 // Lazy template processing function
1332 async function processTemplate(filePath: string): Promise<string> {
1409 async function processTemplate(filePath: string, reqCtx?: ServeRequestContext): Promise<string> {
13331410 const content = await Bun.file(filePath).text()
13341411 const skipCacheHint = /(?:^|[^\w$])__stx_skip_cache\s*=\s*true/.test(content)
13351412
@@ -1370,7 +1447,10 @@ export async function serve(options: ServeOptions): Promise<void> {
13701447 // again and wipe what useHead just populated.
13711448 resetHead()
13721449 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 ?? {}
13741454 for (const scriptBody of serverScripts) {
13751455 await extractVariables(scriptBody, context, filePath)
13761456 }
@@ -1477,7 +1557,7 @@ export async function serve(options: ServeOptions): Promise<void> {
14771557 }
14781558
14791559 // 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> {
14811561 // Dev mode: never cache — always re-process templates so file changes are reflected
14821562 // Production caching is handled by the production server, not the dev serve path
14831563
@@ -1541,7 +1621,7 @@ export async function serve(options: ServeOptions): Promise<void> {
15411621
15421622 if (normalizedFilePath === possible || relativeFilePath === possible) {
15431623 // Process template on every request (dev mode — no caching)
1544 const output = await processTemplate(filePath)
1624 const output = await processTemplate(filePath, reqCtx)
15451625 return output
15461626 }
15471627 }
@@ -1578,7 +1658,7 @@ export async function serve(options: ServeOptions): Promise<void> {
15781658 `/${fileRoute}.md` === requestPath ||
15791659 `/${fileRoute}.html` === requestPath ||
15801660 (isIndexFile && isRootRequest)) {
1581 const output = await processTemplate(filePath)
1661 const output = await processTemplate(filePath, reqCtx)
15821662 routes.set(requestPath, output)
15831663 return output
15841664 }
@@ -1589,7 +1669,7 @@ export async function serve(options: ServeOptions): Promise<void> {
15891669 const prettyRoute = fileRoute.slice(6) // Remove 'pages/' prefix
15901670 if (`/${prettyRoute}` === requestPath ||
15911671 prettyRoute === normalizedPath) {
1592 const output = await processTemplate(filePath)
1672 const output = await processTemplate(filePath, reqCtx)
15931673 routes.set(requestPath, output)
15941674 return output
15951675 }
@@ -1630,7 +1710,7 @@ export async function serve(options: ServeOptions): Promise<void> {
16301710 const paramValues = match.slice(1)
16311711
16321712 // 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)
16341714 routes.set(requestPath, output)
16351715 return output
16361716 }
@@ -1646,6 +1726,7 @@ export async function serve(options: ServeOptions): Promise<void> {
16461726 paramNames: string[],
16471727 paramValues: string[],
16481728 routePath: string,
1729 reqCtx?: ServeRequestContext,
16491730 ): Promise<string> {
16501731
16511732 const content = await Bun.file(filePath).text()
@@ -1669,11 +1750,34 @@ export async function serve(options: ServeOptions): Promise<void> {
16691750 __route: routePath,
16701751 }
16711752
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> = {}
16731758 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
16751767 }
16761768
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
16771781 const { processDirectives, extractVariables, defaultConfig, injectRouterScript: injectRouter, resetHead: resetHeadDyn } = await stxModule
16781782 // Fresh head state per request (see static-route handler above for rationale)
16791783 resetHeadDyn()
@@ -1683,7 +1787,7 @@ export async function serve(options: ServeOptions): Promise<void> {
16831787 // was first added (only the static-route path called
16841788 // injectServeRequestContext), so every dynamic route silently never
16851789 // saw `host`/`cookies`/`ip`/`__stxServeSearch` until this fix.
1686 injectServeRequestContext(context)
1790 injectServeRequestContext(context, reqCtx)
16871791 for (const scriptBody of dynServerScripts) {
16881792 await extractVariables(scriptBody, context, filePath)
16891793 }
@@ -1724,12 +1828,9 @@ export async function serve(options: ServeOptions): Promise<void> {
17241828 const dependencies = new Set<string>()
17251829 output = await processDirectives(output, context, filePath, config, dependencies)
17261830
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.
17281833 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 }
17331834 const paramsScript = `<script>if(window.stx)window.stx._rp=${JSON.stringify(paramsObj)};else window.__stx_rp=${JSON.stringify(paramsObj)};</script>`
17341835 if (output.includes('</head>')) {
17351836 output = output.replace('</head>', `${paramsScript}\n</head>`)
@@ -2031,6 +2132,21 @@ export async function serve(options: ServeOptions): Promise<void> {
20312132 activeServeCookieHeader = req.headers.get('cookie') || ''
20322133 activeServeCookies = parseCookies(req)
20332134 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 }
20342150 try {
20352151
20362152 // Handle CORS preflight
@@ -2056,16 +2172,19 @@ export async function serve(options: ServeOptions): Promise<void> {
20562172 // and gives the readyState a definite "open" before any change.
20572173 c.enqueue(hmrEncoder.encode('data: {"type":"connected"}\n\n'))
20582174 // 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.
20652184 keepalive = setInterval(() => {
20662185 try { c.enqueue(hmrEncoder.encode(': keepalive\n\n')) }
20672186 catch { /* stream closed — cancel handler clears the timer */ }
2068 }, 15_000)
2187 }, 5_000)
20692188 },
20702189 cancel() {
20712190 if (keepalive) clearInterval(keepalive)
@@ -2082,10 +2201,17 @@ export async function serve(options: ServeOptions): Promise<void> {
20822201 })
20832202 }
20842203
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.
20862209 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)
20892215 }
20902216
20912217 // ── Page middleware pipeline ────────────────────────────────────
@@ -2272,10 +2398,10 @@ export async function serve(options: ServeOptions): Promise<void> {
22722398
22732399 // Redirect root to /home if no index exists (dashboard pattern)
22742400 if (path === '/') {
2275 const indexContent = await getRoute('/')
2401 const indexContent = await getRoute('/', reqCtx)
22762402 if (!indexContent) {
22772403 // Try /home as default landing page
2278 const homeContent = await getRoute('/home')
2404 const homeContent = await getRoute('/home', reqCtx)
22792405 if (homeContent) {
22802406 return new Response(null, {
22812407 status: 302,
@@ -2289,7 +2415,7 @@ export async function serve(options: ServeOptions): Promise<void> {
22892415 }
22902416
22912417 // Try to serve the requested page (lazy load on demand)
2292 const content = await getRoute(path)
2418 const content = await getRoute(path, reqCtx)
22932419 if (content) {
22942420 // SPA navigation: return only <main> content as fragment
22952421 const isSpaNav = req.headers.get('X-STX-Router') === 'true'
@@ -2427,7 +2553,7 @@ export async function serve(options: ServeOptions): Promise<void> {
24272553 }
24282554
24292555 // Try without extension
2430 const contentWithExt = await getRoute(`${path}.html`)
2556 const contentWithExt = await getRoute(`${path}.html`, reqCtx)
24312557 if (contentWithExt) {
24322558 return new Response(injectHmrClient(contentWithExt), {
24332559 headers: {
@@ -2585,7 +2711,7 @@ export async function serve(options: ServeOptions): Promise<void> {
25852711 // index-file conventions all just work.
25862712 for (const custom404 of ['/404', '/errors/404']) {
25872713 try {
2588 const customContent = await getRoute(custom404)
2714 const customContent = await getRoute(custom404, reqCtx)
25892715 if (customContent) {
25902716 return new Response(
25912717 isProd ? customContent : injectHmrClient(customContent),
packages/bun-plugin/test/serve-request-context.test.tsadded+160-0
Changes to packages/bun-plugin/test/serve-request-context.test.ts
@@ -0,0 +1,160 @@
1import { afterAll, beforeAll, describe, expect, it, setDefaultTimeout } from 'bun:test'
2import { mkdtemp, rm } from 'node:fs/promises'
3import { tmpdir } from 'node:os'
4import 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
24setDefaultTimeout(60_000)
25
26const PORT = 42_000 + (process.pid % 1000)
27const BASE = `http://localhost:${PORT}`
28const SERVE_SRC = path.join(import.meta.dir, '..', 'src', 'serve.ts')
29
30let dir: string
31let proc: ReturnType<typeof Bun.spawn> | null = null
32
33async 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). */
39function 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(/&quot;/g, '"').replace(/&amp;/g, '&'))
44}
45
46beforeAll(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>
51const route = useRoute()
52const 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}
63const 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>
71const probe = {
72 cookieToken: cookies?.token ?? null,
73 search: typeof __stxServeSearch !== 'undefined' ? __stxServeSearch : null,
74}
75const 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
85serve({
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
114afterAll(async () => {
115 proc?.kill()
116 await rm(dir, { recursive: true, force: true })
117})
118
119describe('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
130describe('__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
148describe('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})
packages/stx/src/variable-extractor.tsmodified+17-1
Changes to packages/stx/src/variable-extractor.ts
@@ -530,7 +530,23 @@ catch {
530530
531531 // Mock Vue/Nuxt-like composables commonly used in dashboard pages
532532 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 }
534550 const useRouter = () => ({ push: (_to: unknown) => {}, replace: (_to: unknown) => {}, back: () => {}, forward: () => {}, go: (_n: number) => {} })
535551 // useHead is auto-injected so server scripts can call it without explicitly
536552 // importing from 'stx'. It mutates module-global currentHead in head.ts,