Skip to content

Commit 54382f0

Browse files
committed
feat(serve): expose the query string as query in server scripts
File-based routing handed a server script its route parameters as `params` and then left the query string reachable only through `__stxServeSearch` - an internal, double-underscored name nobody would guess from the docs. So the ordinary case of a filtered, paginated list page had no supported way to read `?state=closed&page=2`. `query` is always an object, like `params`, so reading a key on a request with no query string is undefined rather than a crash. Static and dynamic routes both get it; a repeated key keeps its last value, matching URLSearchParams.get. Also pins async work in a component's <script server> across static routes, dynamic-route views, and dynamic-route components. Those three look interchangeable, and when one of them stops working the symptom is not an error - a component whose data never arrives renders its own empty state, so the page reads as a correct answer rather than a failure.
1 parent 20f1968 commit 54382f0

3 files changed

Lines changed: 164 additions & 0 deletions

File tree

packages/bun-plugin/src/serve.ts

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1173,6 +1173,19 @@ export async function serve(options: ServeOptions): Promise<void> {
11731173
// that — stacksjs/stacks#1967).
11741174
context.__stxServeSearch = search
11751175
;(globalThis as { __stxServeSearch?: string }).__stxServeSearch = search
1176+
1177+
// The query string as an object, the counterpart to `params`.
1178+
//
1179+
// File-based routing handed a server script its route parameters and then
1180+
// left the query string reachable only through `__stxServeSearch` — an
1181+
// internal, double-underscored name nobody would guess. So the ordinary
1182+
// case of a filtered, paginated list page had no supported way to read
1183+
// `?state=closed&page=2`.
1184+
//
1185+
// Always an object, like `params`, so `query.state` on a request with no
1186+
// query string is undefined rather than a crash. A repeated key keeps its
1187+
// last value, matching what `URLSearchParams.get` returns.
1188+
context.query = Object.fromEntries(new URLSearchParams(search ?? ''))
11761189
if (host)
11771190
context.host = host
11781191
context.cookies = cookies
Lines changed: 118 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,118 @@
1+
/**
2+
* A component's `<script server>` doing async work, across route kinds.
3+
*
4+
* Three shapes that look interchangeable and are easy to assume are:
5+
*
6+
* static route + component + await
7+
* dynamic route + view + await
8+
* dynamic route + component + await
9+
*
10+
* All three work. They are pinned because when one of them silently stops
11+
* working the symptom is not an error: a component whose data never arrives
12+
* renders its own empty-state branch, so the page looks like a correct answer
13+
* rather than a failure, and nothing is logged.
14+
*/
15+
16+
import { afterAll, beforeAll, describe, expect, it, setDefaultTimeout } from 'bun:test'
17+
import { mkdtemp, rm } from 'node:fs/promises'
18+
import { tmpdir } from 'node:os'
19+
import path from 'node:path'
20+
21+
setDefaultTimeout(60_000)
22+
23+
const PORT = 43_100 + (process.pid % 700)
24+
const BASE = `http://localhost:${PORT}`
25+
const SERVE_SRC = path.join(import.meta.dir, '..', 'src', 'serve.ts')
26+
27+
let dir: string
28+
let proc: ReturnType<typeof Bun.spawn> | null = null
29+
30+
function parseProbe(html: string): Record<string, unknown> {
31+
const m = html.match(/PROBE::(.*?)::END/s)
32+
if (!m)
33+
throw new Error(`no probe marker in response:\n${html.slice(0, 500)}`)
34+
return JSON.parse(m[1].replace(/&quot;/g, '"').replace(/&amp;/g, '&'))
35+
}
36+
37+
beforeAll(async () => {
38+
dir = await mkdtemp(path.join(tmpdir(), 'stx-comp-dyn-'))
39+
40+
// A component that does async work and reports what it got back. Bun.sleep
41+
// stands in for the database round trip that first showed this.
42+
await Bun.write(path.join(dir, 'components', 'AsyncProbe.stx'), `<script server>
43+
const label = String(who)
44+
await Bun.sleep(1)
45+
const loaded = await Promise.resolve('loaded-' + label)
46+
const probeJson = JSON.stringify({ label, loaded })
47+
</script>
48+
<div>PROBE::{{ probeJson }}::END</div>
49+
`)
50+
51+
// Static route rendering that component.
52+
await Bun.write(path.join(dir, 'views', 'static-host.stx'), `<AsyncProbe who="alpha" />
53+
`)
54+
55+
// Dynamic route rendering the same component.
56+
await Bun.write(path.join(dir, 'views', 'dyn', '[slug].stx'), `<AsyncProbe who="{{ params.slug }}" />
57+
`)
58+
59+
// Dynamic route doing the same async work in the view itself, as the control.
60+
await Bun.write(path.join(dir, 'views', 'inline', '[slug].stx'), `<script server>
61+
const label = String(params.slug)
62+
await Bun.sleep(1)
63+
const loaded = await Promise.resolve('loaded-' + label)
64+
const probeJson = JSON.stringify({ label, loaded })
65+
</script>
66+
<div>PROBE::{{ probeJson }}::END</div>
67+
`)
68+
69+
await Bun.write(path.join(dir, 'driver.ts'), `import { serve } from ${JSON.stringify(SERVE_SRC)}
70+
71+
serve({ patterns: ['views'], port: ${PORT} })
72+
`)
73+
74+
proc = Bun.spawn(['bun', path.join(dir, 'driver.ts')], { cwd: dir, stdout: 'pipe', stderr: 'pipe' })
75+
76+
for (let i = 0; i < 100; i++) {
77+
try {
78+
await fetch(`${BASE}/static-host`)
79+
break
80+
}
81+
catch {
82+
await Bun.sleep(100)
83+
}
84+
}
85+
})
86+
87+
afterAll(async () => {
88+
proc?.kill()
89+
await rm(dir, { recursive: true, force: true })
90+
})
91+
92+
describe('component <script server> across route kinds', () => {
93+
it('resolves async work in a component on a static route', async () => {
94+
const probe = parseProbe(await (await fetch(`${BASE}/static-host`)).text())
95+
96+
expect(probe.label).toBe('alpha')
97+
expect(probe.loaded).toBe('loaded-alpha')
98+
})
99+
100+
it('resolves async work in the view of a dynamic route', async () => {
101+
const probe = parseProbe(await (await fetch(`${BASE}/inline/bravo`)).text())
102+
103+
expect(probe.label).toBe('bravo')
104+
expect(probe.loaded).toBe('loaded-bravo')
105+
})
106+
107+
/**
108+
* The combination most likely to be missed: a component, on a dynamic route,
109+
* whose value comes from behind an await. Both the prop binding and the
110+
* resolved value have to survive.
111+
*/
112+
it('resolves async work in a component on a dynamic route', async () => {
113+
const probe = parseProbe(await (await fetch(`${BASE}/dyn/charlie`)).text())
114+
115+
expect(probe.label).toBe('charlie')
116+
expect(probe.loaded).toBe('loaded-charlie')
117+
})
118+
})

packages/bun-plugin/test/serve-request-context.test.ts

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -59,6 +59,11 @@ const probe = {
5959
merged: typeof __stxServeContext !== 'undefined' ? (__stxServeContext.mergedFromHook ?? null) : null,
6060
routeParamsId: route.params.id ?? null,
6161
routeQueryX: route.query.x ?? null,
62+
// query is the counterpart to params: the query string as an object,
63+
// rather than an internal string name nobody would guess.
64+
queryX: query?.x ?? null,
65+
queryMissing: query?.nope ?? null,
66+
queryIsObject: typeof query === 'object' && query !== null,
6267
}
6368
const probeJson = JSON.stringify(probe)
6469
</script>
@@ -71,6 +76,7 @@ const probeJson = JSON.stringify(probe)
7176
const probe = {
7277
cookieToken: cookies?.token ?? null,
7378
search: typeof __stxServeSearch !== 'undefined' ? __stxServeSearch : null,
79+
queryX: query?.x ?? null,
7480
}
7581
const probeJson = JSON.stringify(probe)
7682
</script>
@@ -147,6 +153,33 @@ describe('dynamic route params in <script server>', () => {
147153
expect(probe.routeQueryX).toBe('1')
148154
})
149155

156+
/**
157+
* `query` is the counterpart to `params`. Without it the query string was
158+
* reachable only through `__stxServeSearch` - an internal, double-underscored
159+
* name - so the ordinary filtered, paginated list page had no supported way
160+
* to read `?state=closed&page=2`.
161+
*/
162+
it('exposes the query string as an object beside params', async () => {
163+
const probe = parseProbe(await fetchText(`${BASE}/probe/x?x=1`))
164+
165+
expect(probe.queryX).toBe('1')
166+
expect(probe.queryIsObject).toBe(true)
167+
})
168+
169+
it('is an empty object, not a crash, when there is no query string', async () => {
170+
const probe = parseProbe(await fetchText(`${BASE}/probe/x`))
171+
172+
expect(probe.queryIsObject).toBe(true)
173+
expect(probe.queryX).toBeNull()
174+
expect(probe.queryMissing).toBeNull()
175+
})
176+
177+
it('reaches static routes too, not only dynamic ones', async () => {
178+
const probe = parseProbe(await fetchText(`${BASE}/slow?x=static`))
179+
180+
expect(probe.queryX).toBe('static')
181+
})
182+
150183
it('carries escaped client route params in SPA fragments', async () => {
151184
const res = await fetch(`${BASE}/probe/%3Cjob-15%3E`, {
152185
headers: { 'X-STX-Router': 'true' },

0 commit comments

Comments
 (0)