Skip to content

Commit 1dfa08e

Browse files
committed
fix(router): preserve write routing in directly served requests
Establish the database routing context once per served request, resolving the context runner lazily at startup. Keep write state across awaits and isolate concurrent readers. Cover direct HTTP write tracking and isolated cold-start serving without application preloads. Router and database suites: 1432 tests. Unit suite: 366 tests. Framework type checks and pickier lint pass. Secure SQLite HTTP comparisons completed 76014 requests with exactly 76014 persisted logs and no errors. This is a correctness prerequisite, not a throughput improvement claim.
1 parent 9787fd9 commit 1dfa08e

4 files changed

Lines changed: 87 additions & 1 deletion

File tree

storage/framework/core/router/src/stacks-router.ts

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4287,6 +4287,10 @@ export function createStacksRouter(config: StacksRouterConfig = {}): StacksRoute
42874287
// cookie was only ever seeded on API responses. See the wrapper.
42884288
wrapHandleRequestForCsrf(bunRouter)
42894289

4290+
// Directly served requests need the same read-after-write tracking as
4291+
// serverResponse(), including requests that bypass the route pipeline.
4292+
await wrapHandleRequestForDatabaseContext(bunRouter)
4293+
42904294
// After the routes and the view configuration, before the first request:
42914295
// the one moment an application can do work once without racing a reader
42924296
// for it. See `BootHook`.
@@ -4807,6 +4811,18 @@ let routesLoadPromise: Promise<void> | null = null
48074811
type ContextRunner = <T>(fn: () => T) => T
48084812
let routingContextRunner: ContextRunner | null = null
48094813

4814+
const databaseContextWrappedRouters = new WeakSet<Router>()
4815+
4816+
async function wrapHandleRequestForDatabaseContext(router: Router): Promise<void> {
4817+
if (databaseContextWrappedRouters.has(router)) return
4818+
const runInRoutingContext = await getRoutingContextRunner()
4819+
// Concurrent serve() calls can resolve the runner together.
4820+
if (databaseContextWrappedRouters.has(router)) return
4821+
const original = router.handleRequest.bind(router)
4822+
router.handleRequest = request => runInRoutingContext(() => original(request))
4823+
databaseContextWrappedRouters.add(router)
4824+
}
4825+
48104826
async function getRoutingContextRunner(): Promise<ContextRunner> {
48114827
if (!routingContextRunner) {
48124828
try {
Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
# Keep application preloads out of the router's cold-start import-cycle probe.
2+
# Leave preload absent: Bun rejects an empty preload array during startup.
Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
import { createStacksRouter } from '../../src/stacks-router'
2+
3+
const router = createStacksRouter()
4+
router.get('/__cold_start', () => ({ ready: true }))
5+
const server = await router.serve({ port: 0 })
6+
try {
7+
const response = await fetch(`http://localhost:${server.port}/__cold_start`)
8+
const body = await response.json()
9+
if (response.status !== 200 || body.ready !== true)
10+
throw new Error(`Unexpected cold-start response: ${response.status}`)
11+
console.log('router-cold-start-ok')
12+
}
13+
finally {
14+
await server.stop(true)
15+
}

storage/framework/core/router/tests/read-routing-context.test.ts

Lines changed: 54 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,9 +18,62 @@
1818

1919
import { describe, expect, test } from 'bun:test'
2020
import { contextHasWritten, markContextWrote, withRoutingContext } from '@stacksjs/database'
21-
import { serverResponse } from '../src/stacks-router'
21+
import { createStacksRouter, serverResponse } from '../src/stacks-router'
2222

2323
describe('routing context plumbing', () => {
24+
test('direct serving starts without preloading the database package', async () => {
25+
const child = Bun.spawn([
26+
process.execPath,
27+
`--config=${import.meta.dir}/fixtures/cold-start.toml`,
28+
`${import.meta.dir}/fixtures/serve-cold-start.ts`,
29+
], { stdout: 'pipe', stderr: 'pipe' })
30+
const timeout = setTimeout(() => child.kill(), 10_000)
31+
try {
32+
const [exitCode, stdout, stderr] = await Promise.all([
33+
child.exited,
34+
new Response(child.stdout).text(),
35+
new Response(child.stderr).text(),
36+
])
37+
expect(exitCode, stderr).toBe(0)
38+
expect(stdout).toContain('router-cold-start-ok')
39+
}
40+
finally {
41+
clearTimeout(timeout)
42+
child.kill()
43+
await child.exited
44+
}
45+
}, 15_000)
46+
47+
test('directly served requests track writes and isolate concurrent readers', async () => {
48+
const router = createStacksRouter()
49+
const writing = Promise.withResolvers<void>()
50+
const release = Promise.withResolvers<void>()
51+
router.get('/__routing_write', async () => {
52+
const before = contextHasWritten()
53+
markContextWrote()
54+
writing.resolve()
55+
await release.promise
56+
return { before, after: contextHasWritten() }
57+
})
58+
router.get('/__routing_read', () => ({ wrote: contextHasWritten() }))
59+
const server = await router.serve({ port: 0 })
60+
try {
61+
const writer = fetch(`http://localhost:${server.port}/__routing_write`)
62+
await writing.promise
63+
const reader = await fetch(`http://localhost:${server.port}/__routing_read`)
64+
expect(await reader.json()).toEqual({ wrote: false })
65+
release.resolve()
66+
expect(await (await writer).json()).toEqual({ before: false, after: true })
67+
const later = await fetch(`http://localhost:${server.port}/__routing_read`)
68+
expect(await later.json()).toEqual({ wrote: false })
69+
expect(contextHasWritten()).toBe(false)
70+
}
71+
finally {
72+
release.resolve()
73+
await server.stop(true)
74+
}
75+
}, 15_000)
76+
2477
test('marking a write is inert with no context established', () => {
2578
// Background jobs and one-shot scripts have no request boundary; they
2679
// must not throw, they simply get no read-your-writes guarantee.

0 commit comments

Comments
 (0)