@benjavicente/start-client-core 1.167.9 → 1.168.3

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.
Files changed (51) hide show
  1. package/dist/esm/client/hydrateStart.js +4 -2
  2. package/dist/esm/client/hydrateStart.js.map +1 -1
  3. package/dist/esm/client-rpc/serverFnFetcher.d.ts +21 -0
  4. package/dist/esm/client-rpc/serverFnFetcher.js +94 -71
  5. package/dist/esm/client-rpc/serverFnFetcher.js.map +1 -1
  6. package/dist/esm/createCsrfMiddleware.d.ts +46 -0
  7. package/dist/esm/createCsrfMiddleware.js +63 -0
  8. package/dist/esm/createCsrfMiddleware.js.map +1 -0
  9. package/dist/esm/createMiddleware.d.ts +4 -0
  10. package/dist/esm/createMiddleware.js.map +1 -1
  11. package/dist/esm/createServerFn.d.ts +44 -31
  12. package/dist/esm/createServerFn.js +1 -1
  13. package/dist/esm/createServerFn.js.map +1 -1
  14. package/dist/esm/fake-entries/plugin-adapters.d.ts +3 -0
  15. package/dist/esm/fake-entries/plugin-adapters.js +7 -0
  16. package/dist/esm/fake-entries/plugin-adapters.js.map +1 -0
  17. package/dist/esm/fake-entries/router.d.ts +1 -0
  18. package/dist/esm/fake-entries/router.js +6 -0
  19. package/dist/esm/fake-entries/router.js.map +1 -0
  20. package/dist/esm/{fake-start-entry.d.ts → fake-entries/start.d.ts} +0 -1
  21. package/dist/esm/fake-entries/start.js +6 -0
  22. package/dist/esm/fake-entries/start.js.map +1 -0
  23. package/dist/esm/getDefaultSerovalPlugins.d.ts +2 -1
  24. package/dist/esm/getDefaultSerovalPlugins.js.map +1 -1
  25. package/dist/esm/index.d.ts +4 -1
  26. package/dist/esm/index.js +3 -1
  27. package/dist/esm/tests/createCsrfMiddleware.test.d.ts +1 -0
  28. package/package.json +10 -11
  29. package/skills/start-core/SKILL.md +11 -7
  30. package/skills/start-core/auth-server-primitives/SKILL.md +410 -0
  31. package/skills/start-core/deployment/SKILL.md +9 -0
  32. package/skills/start-core/execution-model/SKILL.md +68 -19
  33. package/skills/start-core/middleware/SKILL.md +42 -9
  34. package/skills/start-core/server-functions/SKILL.md +115 -17
  35. package/src/client/hydrateStart.ts +12 -6
  36. package/src/client-rpc/serverFnFetcher.ts +132 -103
  37. package/src/createCsrfMiddleware.ts +197 -0
  38. package/src/createMiddleware.ts +4 -0
  39. package/src/createServerFn.ts +192 -63
  40. package/src/fake-entries/plugin-adapters.ts +4 -0
  41. package/src/fake-entries/router.ts +1 -0
  42. package/src/{fake-start-entry.ts → fake-entries/start.ts} +0 -1
  43. package/src/getDefaultSerovalPlugins.ts +2 -1
  44. package/src/index.tsx +16 -0
  45. package/src/start-entry.d.ts +9 -2
  46. package/src/tests/createCsrfMiddleware.test.ts +290 -0
  47. package/src/tests/createServerFn.test-d.ts +152 -2
  48. package/src/tests/createServerMiddleware.test-d.ts +16 -3
  49. package/bin/intent.js +0 -25
  50. package/dist/esm/fake-start-entry.js +0 -7
  51. package/dist/esm/fake-start-entry.js.map +0 -1
@@ -21,7 +21,7 @@ sources:
21
21
  Middleware customizes the behavior of server functions and server routes. It is composable — middleware can depend on other middleware to form a chain.
22
22
 
23
23
  > **CRITICAL**: TypeScript enforces method order: `middleware()` → `inputValidator()` → `client()` → `server()`. Wrong order causes type errors.
24
- > **CRITICAL**: Client context sent via `sendContext` is NOT validated by default. If you send dynamic user-generated data, validate it in server-side middleware before use.
24
+ > **CRITICAL**: Validating the _shape_ of `sendContext` (e.g. `z.string().uuid().parse(...)`) is NOT authorization. A parsed identifier is a well-formed identifier, not an authorized one. Always re-check access against the session principal before using a client-sent ID as a query key, filter, or path parameter.
25
25
 
26
26
  ## Two Types of Middleware
27
27
 
@@ -40,7 +40,7 @@ Request middleware cannot depend on server function middleware. Server function
40
40
  Runs on ALL server requests (SSR, server routes, server functions):
41
41
 
42
42
  ```tsx
43
- // Use @tanstack/<framework>-start for your framework (react, solid, vue)
43
+ // Use @benjavicente/<framework>-start for your framework (react, solid, vue)
44
44
  import { createMiddleware } from '@benjavicente/react-start'
45
45
 
46
46
  const loggingMiddleware = createMiddleware().server(
@@ -57,7 +57,7 @@ const loggingMiddleware = createMiddleware().server(
57
57
  Has both client and server phases:
58
58
 
59
59
  ```tsx
60
- // Use @tanstack/<framework>-start for your framework (react, solid, vue)
60
+ // Use @benjavicente/<framework>-start for your framework (react, solid, vue)
61
61
  import { createMiddleware } from '@benjavicente/react-start'
62
62
 
63
63
  const authMiddleware = createMiddleware({ type: 'function' })
@@ -78,7 +78,7 @@ const authMiddleware = createMiddleware({ type: 'function' })
78
78
  ## Attaching Middleware to Server Functions
79
79
 
80
80
  ```tsx
81
- // Use @tanstack/<framework>-start for your framework (react, solid, vue)
81
+ // Use @benjavicente/<framework>-start for your framework (react, solid, vue)
82
82
  import { createServerFn } from '@benjavicente/react-start'
83
83
 
84
84
  const fn = createServerFn()
@@ -173,7 +173,7 @@ Create `src/start.ts` to configure global middleware:
173
173
 
174
174
  ```tsx
175
175
  // src/start.ts
176
- // Use @tanstack/<framework>-start for your framework (react, solid, vue)
176
+ // Use @benjavicente/<framework>-start for your framework (react, solid, vue)
177
177
  import { createStart, createMiddleware } from '@benjavicente/react-start'
178
178
 
179
179
  const requestLogger = createMiddleware().server(async ({ next, request }) => {
@@ -241,7 +241,11 @@ const authMiddleware = createMiddleware().server(async ({ next, request }) => {
241
241
  if (!session) throw new Error('Unauthorized')
242
242
  return next({ context: { session } })
243
243
  })
244
+ ```
245
+
246
+ > **Attach `authMiddleware` to every `createServerFn` that needs auth.** Server functions are RPC endpoints — a route `beforeLoad` does NOT protect the RPC, only the route's UI. Pair every protected route with handler-level enforcement here. See [router-core/auth-and-guards](../../../../router-core/skills/router-core/auth-and-guards/SKILL.md) and [start-core/auth-server-primitives](../auth-server-primitives/SKILL.md).
244
247
 
248
+ ```tsx
245
249
  type Permissions = Record<string, string[]>
246
250
 
247
251
  function authorizationMiddleware(permissions: Permissions) {
@@ -281,7 +285,7 @@ Headers merge across middleware. Later middleware overrides earlier. Call-site h
281
285
  ### Custom fetch
282
286
 
283
287
  ```tsx
284
- // Use @tanstack/<framework>-start for your framework (react, solid, vue)
288
+ // Use @benjavicente/<framework>-start for your framework (react, solid, vue)
285
289
  import type { CustomFetch } from '@benjavicente/react-start'
286
290
 
287
291
  const loggingMiddleware = createMiddleware({ type: 'function' }).client(
@@ -299,23 +303,50 @@ Fetch precedence (highest to lowest): call site → later middleware → earlier
299
303
 
300
304
  ## Common Mistakes
301
305
 
302
- ### 1. HIGH: Trusting client sendContext without validation
306
+ ### 1. CRITICAL: Trusting client sendContext shape check is not access check
307
+
308
+ `sendContext` from a client middleware arrives on the server as untrusted client input. Most agents stop after parsing the shape with Zod and assume the value is safe. It isn't: a parsed UUID is _some_ workspace, not the requesting user's workspace. Without a membership check against the session principal, you've built a tenant-walking endpoint.
309
+
310
+ **Layer 1 — WRONG (no validation):**
303
311
 
304
312
  ```tsx
305
- // WRONG — client can send arbitrary data
306
313
  .server(async ({ next, context }) => {
314
+ // SQL-injectable AND tenant-walkable
307
315
  await db.query(`SELECT * FROM workspace_${context.workspaceId}`)
308
316
  return next()
309
317
  })
318
+ ```
319
+
320
+ **Layer 2 — STILL WRONG (shape only):**
310
321
 
311
- // CORRECT — validate before use
322
+ ```tsx
312
323
  .server(async ({ next, context }) => {
324
+ // Looks safe, isn't. UUID is well-formed but the user may not be a member.
313
325
  const workspaceId = z.string().uuid().parse(context.workspaceId)
314
326
  await db.query('SELECT * FROM workspaces WHERE id = $1', [workspaceId])
315
327
  return next()
316
328
  })
317
329
  ```
318
330
 
331
+ **Layer 3 — CORRECT (shape AND access):**
332
+
333
+ ```tsx
334
+ .middleware([authMiddleware]) // session loaded from cookie, NOT from sendContext
335
+ .server(async ({ next, context }) => {
336
+ const workspaceId = z.string().uuid().parse(context.workspaceId)
337
+ // Verify the session principal can access this workspace.
338
+ const member = await db.memberships.find({
339
+ userId: context.session.userId,
340
+ workspaceId,
341
+ })
342
+ if (!member) throw new Error('Not a member of this workspace')
343
+ await db.query('SELECT * FROM workspaces WHERE id = $1', [workspaceId])
344
+ return next({ context: { workspaceId } })
345
+ })
346
+ ```
347
+
348
+ The session itself must come from a server-trusted source (the cookie + DB lookup in `authMiddleware`), never from `sendContext` — anything the client can send, the client can lie about. See [start-core/auth-server-primitives](../auth-server-primitives/SKILL.md).
349
+
319
350
  ### 2. MEDIUM: Confusing request vs server function middleware
320
351
 
321
352
  Request middleware runs on ALL requests (SSR, routes, functions). Server function middleware runs only for `createServerFn` calls and has `.client()` method.
@@ -363,3 +394,5 @@ createMiddleware({ type: 'function' })
363
394
 
364
395
  - [start-core/server-functions](../server-functions/SKILL.md) — what middleware wraps
365
396
  - [start-core/server-routes](../server-routes/SKILL.md) — middleware on API endpoints
397
+ - [start-core/auth-server-primitives](../auth-server-primitives/SKILL.md) — building the `authMiddleware` factory itself: session cookie reads, OAuth state, CSRF
398
+ - [router-core/auth-and-guards](../../../../router-core/skills/router-core/auth-and-guards/SKILL.md) — routing-side guards (route `beforeLoad` does NOT protect server functions; pair guards with `authMiddleware` on every protected RPC)
@@ -19,6 +19,7 @@ sources:
19
19
 
20
20
  Server functions are type-safe RPCs created with `createServerFn`. They run exclusively on the server but can be called from anywhere — loaders, components, hooks, event handlers, or other server functions.
21
21
 
22
+ > **CRITICAL**: Server functions are RPC endpoints. They are reachable by direct POST regardless of which route renders the calling UI. **Auth must be enforced inside the handler (or via middleware) — a route `beforeLoad` does NOT protect the RPC.** See [start-core/auth-server-primitives](../auth-server-primitives/SKILL.md) for the session/middleware pattern.
22
23
  > **CRITICAL**: Loaders are ISOMORPHIC — they run on BOTH client and server. Database queries, file system access, and secret API keys MUST go inside `createServerFn`, NOT in loaders directly.
23
24
  > **CRITICAL**: Do not use `"use server"` directives, `getServerSideProps`, or any Next.js/Remix server patterns. TanStack Start uses `createServerFn` exclusively.
24
25
 
@@ -199,16 +200,29 @@ import {
199
200
  setResponseStatus,
200
201
  } from '@benjavicente/react-start/server'
201
202
 
202
- const getCachedData = createServerFn({ method: 'GET' }).handler(async () => {
203
- const request = getRequest()
204
- const authHeader = getRequestHeader('Authorization')
205
-
203
+ // Public, non-personalized data safe to cache shared across users.
204
+ const getPublicData = createServerFn({ method: 'GET' }).handler(async () => {
206
205
  setResponseHeaders({
206
+ // 'public' is correct ONLY when the response does not depend on identity.
207
+ // For anything tied to a session/user/tenant, use 'private' or 'no-store'.
207
208
  'Cache-Control': 'public, max-age=300',
208
209
  })
209
210
  setResponseStatus(200)
211
+ return fetchPublicData()
212
+ })
210
213
 
211
- return fetchData()
214
+ // Authenticated data — must NOT be 'public'.
215
+ const getMyData = createServerFn({ method: 'GET' }).handler(async () => {
216
+ const authHeader = getRequestHeader('Authorization')
217
+ // ... auth check ...
218
+
219
+ setResponseHeaders({
220
+ // 'private' = only the user-agent may cache. Vary by Cookie/Authorization
221
+ // so any intermediary that does cache keys by identity, not URL alone.
222
+ 'Cache-Control': 'private, max-age=60',
223
+ Vary: 'Cookie, Authorization',
224
+ })
225
+ return fetchPersonalizedData()
212
226
  })
213
227
  ```
214
228
 
@@ -254,7 +268,33 @@ Static imports of server functions are safe — the build replaces implementatio
254
268
 
255
269
  ## Common Mistakes
256
270
 
257
- ### 1. CRITICAL: Putting server-only code in loaders
271
+ ### 1. CRITICAL: Relying on a route guard to protect a server function
272
+
273
+ A `beforeLoad` redirect protects the **route's UI**, not the **RPC**. `createServerFn` exposes a callable endpoint that an attacker can hit directly — no need to load the route at all. Auth on the route is necessary but not sufficient.
274
+
275
+ ```tsx
276
+ // WRONG — the route guard doesn't reach the handler
277
+ const getMyOrders = createServerFn({ method: 'GET' }).handler(async () => {
278
+ return db.orders.findMany() // ← anyone can call the RPC
279
+ })
280
+ export const Route = createFileRoute('/_authenticated/orders')({
281
+ beforeLoad: ({ context }) => {
282
+ if (!context.auth.isAuthenticated) throw redirect({ to: '/login' })
283
+ },
284
+ loader: () => getMyOrders(),
285
+ })
286
+
287
+ // CORRECT — auth enforced on the handler itself
288
+ const getMyOrders = createServerFn({ method: 'GET' })
289
+ .middleware([authMiddleware])
290
+ .handler(async ({ context }) => {
291
+ return db.orders.findMany({ where: { userId: context.session.userId } })
292
+ })
293
+ ```
294
+
295
+ Apply `authMiddleware` (or an equivalent in-handler check) to **every** `createServerFn` that needs auth. See [start-core/auth-server-primitives](../auth-server-primitives/SKILL.md) for the full session/middleware pattern and [start-core/middleware](../middleware/SKILL.md) for composing the factory.
296
+
297
+ ### 2. CRITICAL: Putting server-only code in loaders
258
298
 
259
299
  ```tsx
260
300
  // WRONG — loader is ISOMORPHIC, runs on BOTH client and server
@@ -276,22 +316,47 @@ export const Route = createFileRoute('/posts')({
276
316
  })
277
317
  ```
278
318
 
279
- ### 2. CRITICAL: Using Next.js/Remix server patterns
319
+ ### 3. CRITICAL: Using Next.js / Remix / React Router DOM patterns
320
+
321
+ If the file lives at `src/pages/`, `app/layout.tsx`, `_app/`, or imports anything from `react-router-dom` or `next/`, it is wrong-framework code. TanStack Start uses `src/routes/` + `createFileRoute` + `createServerFn`.
280
322
 
281
323
  ```tsx
282
324
  // WRONG — "use server" is a React directive, not used in TanStack Start
283
325
  'use server'
284
326
  export async function getUser() { ... }
285
327
 
286
- // WRONG — getServerSideProps is Next.js
328
+ // WRONG — getServerSideProps is Next.js Pages Router
287
329
  export async function getServerSideProps() { ... }
288
330
 
289
- // CORRECTTanStack Start uses createServerFn
331
+ // WRONGNext.js App Router server component data fetching
332
+ export default async function Page() {
333
+ const data = await fetch(...).then(r => r.json())
334
+ return <div>{data}</div>
335
+ }
336
+
337
+ // WRONG — Remix
338
+ export async function loader({ request }) { ... }
339
+ export async function action({ request }) { ... }
340
+
341
+ // WRONG — react-router-dom (a different library)
342
+ import { Link, useNavigate } from 'react-router-dom'
343
+
344
+ // CORRECT — TanStack Start
345
+ import { createServerFn } from '@benjavicente/react-start'
346
+ import { Link, useNavigate, createFileRoute } from '@benjavicente/react-router'
347
+
290
348
  const getUser = createServerFn({ method: 'GET' })
291
349
  .handler(async () => { ... })
350
+
351
+ export const Route = createFileRoute('/users/$id')({
352
+ loader: ({ params }) => getUser({ data: { id: params.id } }),
353
+ component: UserPage,
354
+ })
292
355
  ```
293
356
 
294
- ### 3. HIGH: Dynamic imports for server functions
357
+ If you see `src/pages/`, `app/layout.tsx`, or `react-router-dom` in agent output, the agent is generating for the wrong framework. Build will fail or routes will conflict at runtime.
358
+
359
+ ### 4. HIGH: Dynamic imports for server functions
295
360
 
296
361
  ```tsx
297
362
  // WRONG — can cause bundler issues
@@ -301,7 +366,7 @@ const { getUser } = await import('~/utils/users.functions')
301
366
  import { getUser } from '~/utils/users.functions'
302
367
  ```
303
368
 
304
- ### 4. HIGH: Awaiting server function without calling it
369
+ ### 5. HIGH: Awaiting server function without calling it
305
370
 
306
371
  `createServerFn` returns a function — it must be invoked with `()`:
307
372
 
@@ -316,20 +381,53 @@ const data = await getItems()
316
381
  const data = await getItems({ data: { id: '1' } })
317
382
  ```
318
383
 
319
- ### 5. MEDIUM: Not using useServerFn for component calls
384
+ ### 6. CRITICAL: Caching authenticated responses with `Cache-Control: public`
385
+
386
+ `Cache-Control: public, max-age=N` tells every CDN, proxy, and shared cache between you and the user that this response can be served to anyone. If the response depends on the session (user, tenant, role), the first user's response gets cached and replayed to the next user — a cross-tenant data leak.
387
+
388
+ ```tsx
389
+ // WRONG — auth'd response, public cache, leaks to next user via CDN
390
+ const getMyOrders = createServerFn({ method: 'GET' }).handler(async () => {
391
+ const session = await requireSession() // identity-dependent
392
+ setResponseHeaders({ 'Cache-Control': 'public, max-age=300' })
393
+ return db.orders.findMany({ where: { userId: session.userId } })
394
+ })
395
+
396
+ // CORRECT — private + Vary so any cache that does store it keys by identity
397
+ const getMyOrders = createServerFn({ method: 'GET' }).handler(async () => {
398
+ const session = await requireSession()
399
+ setResponseHeaders({
400
+ 'Cache-Control': 'private, max-age=60',
401
+ Vary: 'Cookie, Authorization',
402
+ })
403
+ return db.orders.findMany({ where: { userId: session.userId } })
404
+ })
405
+
406
+ // ALSO CORRECT — opt out entirely for sensitive data
407
+ setResponseHeaders({ 'Cache-Control': 'no-store' })
408
+ ```
409
+
410
+ Rule of thumb: if the handler reads a session/cookie/auth header or branches on identity, the response is **not** `public`. Default to `private` (or `no-store` for sensitive data); reach for `public` only on responses that are byte-for-byte identical regardless of who asks. See also [start-core/deployment](../deployment/SKILL.md) for ISR/Cache-Control on full pages.
320
411
 
321
- When calling server functions from event handlers in components, use `useServerFn` to get proper React integration:
412
+ ### 7. MEDIUM: When to wrap with `useServerFn`
413
+
414
+ `useServerFn` is **required** when the server function uses `throw redirect()` or `throw notFound()` — the hook wires the throw into the router so the redirect actually navigates. For server functions that just return data (call them directly or via `useMutation`/`useQuery`), the hook is optional.
322
415
 
323
416
  ```tsx
324
- // WRONG — direct call doesn't integrate with React lifecycle
417
+ // Plain data — direct call is fine (also fine to pass to useMutation/useQuery)
325
418
  <button onClick={() => deletePost({ data: { id } })}>Delete</button>
419
+ useMutation({ mutationFn: deletePost })
326
420
 
327
- // CORRECTuseServerFn integrates with React
328
- const deletePostFn = useServerFn(deletePost)
329
- <button onClick={() => deletePostFn({ data: { id } })}>Delete</button>
421
+ // Throws redirect/notFound MUST wrap with useServerFn so the router handles the throw
422
+ const signupFn = useServerFn(signup) // signup throws redirect on success
423
+ <button onClick={() => signupFn({ data: form })}>Sign up</button>
330
424
  ```
331
425
 
426
+ If in doubt: wrap with `useServerFn`. It's a no-op for plain-data functions and the safe default when a function might later add a redirect.
427
+
332
428
  ## Cross-References
333
429
 
334
430
  - [start-core/execution-model](../execution-model/SKILL.md) — understanding where code runs
335
431
  - [start-core/middleware](../middleware/SKILL.md) — composing server functions with middleware
432
+ - [start-core/auth-server-primitives](../auth-server-primitives/SKILL.md) — sessions, cookies, OAuth, CSRF, rate limiting (the server-side half of auth; `getCurrentUser`/`useSession`-style helpers belong here, not at module scope)
433
+ - [router-core/auth-and-guards](../../../../router-core/skills/router-core/auth-and-guards/SKILL.md) — the routing side: route guards do NOT protect server functions, so always re-check auth in the handler or via middleware
@@ -1,12 +1,14 @@
1
1
  import { hydrate } from '@benjavicente/router-core/ssr/client'
2
2
 
3
+ import { startInstance } from '#tanstack-start-entry'
4
+ import {
5
+ hasPluginAdapters,
6
+ pluginSerializationAdapters,
7
+ } from '#tanstack-start-plugin-adapters'
8
+ import { getRouter } from '#tanstack-router-entry'
3
9
  import { ServerFunctionSerializationAdapter } from './ServerFunctionSerializationAdapter'
4
- import type { AnyStartInstanceOptions } from '../createStart'
5
10
  import type { AnyRouter, AnySerializationAdapter } from '@benjavicente/router-core'
6
- // eslint-disable-next-line import/no-duplicates,import/order
7
- import { getRouter } from '#tanstack-router-entry'
8
- // eslint-disable-next-line import/no-duplicates,import/order
9
- import { startInstance } from '#tanstack-start-entry'
11
+ import type { AnyStartInstanceOptions } from '../createStart'
10
12
 
11
13
  export async function hydrateStart(): Promise<AnyRouter> {
12
14
  const router = await getRouter()
@@ -26,6 +28,10 @@ export async function hydrateStart(): Promise<AnyRouter> {
26
28
  } as AnyStartInstanceOptions
27
29
  }
28
30
 
31
+ // Only spread plugin adapters if any are configured (this will tree-shake away otherwise)
32
+ if (hasPluginAdapters) {
33
+ serializationAdapters.push(...pluginSerializationAdapters)
34
+ }
29
35
  serializationAdapters.push(ServerFunctionSerializationAdapter)
30
36
  if (router.options.serializationAdapters) {
31
37
  serializationAdapters.push(...router.options.serializationAdapters)
@@ -35,7 +41,7 @@ export async function hydrateStart(): Promise<AnyRouter> {
35
41
  basepath: process.env.TSS_ROUTER_BASEPATH,
36
42
  ...{ serializationAdapters },
37
43
  })
38
- if (!router.stores.matchesId.state.length) {
44
+ if (!router.stores.matchesId.get().length) {
39
45
  await hydrate(router)
40
46
  }
41
47
 
@@ -20,6 +20,70 @@ import type { Plugin as SerovalPlugin } from 'seroval'
20
20
 
21
21
  let serovalPlugins: Array<SerovalPlugin<any, any>> | null = null
22
22
 
23
+ /**
24
+ * Current async post-processing context for deserialization.
25
+ *
26
+ * Some deserializers need to perform async work after synchronous deserialization
27
+ * (e.g., decoding RSC payloads, fetching remote data). This context allows them
28
+ * to register promises that must complete before the deserialized value is used.
29
+ *
30
+ * This uses a synchronous execution context pattern:
31
+ * - Each call to `fromCrossJSON` is synchronous
32
+ * - Within that synchronous execution, all `fromSerializable` calls happen
33
+ * - We set the context before `fromCrossJSON`, clear it after
34
+ * - For streaming chunks, we set/clear context around each `onMessage` call
35
+ *
36
+ * Even with concurrent server function calls, each individual deserialization
37
+ * is atomic (synchronous), so promises are correctly scoped to their call.
38
+ */
39
+ let currentPostProcessContext: Array<Promise<unknown>> | null = null
40
+
41
+ /**
42
+ * Set the current post-processing context for async deserialization work.
43
+ * Called before deserialization starts.
44
+ *
45
+ * @param ctx - Array to collect async work promises, or null to clear
46
+ */
47
+ export function setPostProcessContext(
48
+ ctx: Array<Promise<unknown>> | null,
49
+ ): void {
50
+ currentPostProcessContext = ctx
51
+ }
52
+
53
+ /**
54
+ * Get the current post-processing context.
55
+ * Returns null if no deserialization is in progress.
56
+ */
57
+ export function getPostProcessContext(): Array<Promise<unknown>> | null {
58
+ return currentPostProcessContext
59
+ }
60
+
61
+ /**
62
+ * Track an async post-processing promise in the current deserialization context.
63
+ * Called by deserializers that need to perform async work after sync deserialization.
64
+ *
65
+ * If no context is active (e.g., on server), this is a no-op.
66
+ *
67
+ * @param promise - The async work promise to track
68
+ */
69
+ export function trackPostProcessPromise(promise: Promise<unknown>): void {
70
+ if (currentPostProcessContext) {
71
+ currentPostProcessContext.push(promise)
72
+ }
73
+ }
74
+
75
+ /**
76
+ * Helper to await all post-processing promises.
77
+ * Uses Promise.allSettled to ensure all promises complete even if some reject.
78
+ */
79
+ async function awaitPostProcessPromises(
80
+ promises: Array<Promise<unknown>>,
81
+ ): Promise<void> {
82
+ if (promises.length > 0) {
83
+ await Promise.allSettled(promises)
84
+ }
85
+ }
86
+
23
87
  /**
24
88
  * Checks if an object has at least one own enumerable property.
25
89
  * More efficient than Object.keys(obj).length > 0 as it short-circuits on first property.
@@ -228,23 +292,19 @@ async function getResponse(fn: () => Promise<Response>) {
228
292
  },
229
293
  })
230
294
  }
231
- // If it's a stream from the start serializer, process it as such
232
- else if (contentType.includes('application/x-ndjson')) {
233
- const refs = new Map()
234
- result = await processServerFnResponse({
235
- response,
236
- onMessage: (msg) =>
237
- fromCrossJSON(msg, { refs, plugins: serovalPlugins! }),
238
- onError(msg, error) {
239
- // TODO how could we notify consumer that an error occurred?
240
- console.error(msg, error)
241
- },
242
- })
243
- }
244
295
  // If it's a JSON response, it can be simpler
245
296
  else if (contentType.includes('application/json')) {
246
297
  const jsonPayload = await response.json()
247
- result = fromCrossJSON(jsonPayload, { plugins: serovalPlugins! })
298
+ // Track async post-processing work for this deserialization
299
+ const postProcessPromises: Array<Promise<unknown>> = []
300
+ setPostProcessContext(postProcessPromises)
301
+ try {
302
+ result = fromCrossJSON(jsonPayload, { plugins: serovalPlugins! })
303
+ } finally {
304
+ setPostProcessContext(null)
305
+ }
306
+ // Await any async post-processing before returning
307
+ await awaitPostProcessPromises(postProcessPromises)
248
308
  }
249
309
 
250
310
  if (!result) {
@@ -284,93 +344,13 @@ async function getResponse(fn: () => Promise<Response>) {
284
344
  return response
285
345
  }
286
346
 
287
- async function processServerFnResponse({
288
- response,
289
- onMessage,
290
- onError,
291
- }: {
292
- response: Response
293
- onMessage: (msg: any) => any
294
- onError?: (msg: string, error?: any) => void
295
- }) {
296
- if (!response.body) {
297
- throw new Error('No response body')
298
- }
299
-
300
- const reader = response.body.pipeThrough(new TextDecoderStream()).getReader()
301
-
302
- let buffer = ''
303
- let firstRead = false
304
- let firstObject
305
-
306
- while (!firstRead) {
307
- const { value, done } = await reader.read()
308
- if (value) buffer += value
309
-
310
- if (buffer.length === 0 && done) {
311
- throw new Error('Stream ended before first object')
312
- }
313
-
314
- // common case: buffer ends with newline
315
- if (buffer.endsWith('\n')) {
316
- const lines = buffer.split('\n').filter(Boolean)
317
- const firstLine = lines[0]
318
- if (!firstLine) throw new Error('No JSON line in the first chunk')
319
- firstObject = JSON.parse(firstLine)
320
- firstRead = true
321
- buffer = lines.slice(1).join('\n')
322
- } else {
323
- // fallback: wait for a newline to parse first object safely
324
- const newlineIndex = buffer.indexOf('\n')
325
- if (newlineIndex >= 0) {
326
- const line = buffer.slice(0, newlineIndex).trim()
327
- buffer = buffer.slice(newlineIndex + 1)
328
- if (line.length > 0) {
329
- firstObject = JSON.parse(line)
330
- firstRead = true
331
- }
332
- }
333
- }
334
- }
335
-
336
- // process rest of the stream asynchronously
337
- ;(async () => {
338
- try {
339
- // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
340
- while (true) {
341
- const { value, done } = await reader.read()
342
- if (value) buffer += value
343
-
344
- const lastNewline = buffer.lastIndexOf('\n')
345
- if (lastNewline >= 0) {
346
- const chunk = buffer.slice(0, lastNewline)
347
- buffer = buffer.slice(lastNewline + 1)
348
- const lines = chunk.split('\n').filter(Boolean)
349
-
350
- for (const line of lines) {
351
- try {
352
- onMessage(JSON.parse(line))
353
- } catch (e) {
354
- onError?.(`Invalid JSON line: ${line}`, e)
355
- }
356
- }
357
- }
358
-
359
- if (done) {
360
- break
361
- }
362
- }
363
- } catch (err) {
364
- onError?.('Stream processing error:', err)
365
- }
366
- })()
367
-
368
- return onMessage(firstObject)
369
- }
370
-
371
347
  /**
372
348
  * Processes a framed response where each JSON chunk is a complete JSON string
373
349
  * (already decoded by frame decoder).
350
+ *
351
+ * Uses per-chunk post-processing context to ensure async deserialization work
352
+ * completes before the next chunk is processed. This prevents issues when
353
+ * streaming values require async post-processing (e.g., RSC decoding).
374
354
  */
375
355
  async function processFramedResponse({
376
356
  jsonStream,
@@ -392,8 +372,11 @@ async function processFramedResponse({
392
372
  // Each frame is a complete JSON string
393
373
  const firstObject = JSON.parse(firstValue)
394
374
 
395
- // Process remaining frames asynchronously (for streaming refs like RawStream)
396
- ;(async () => {
375
+ // Process remaining frames for streaming refs like RawStream.
376
+ // Keep draining until the server closes the stream.
377
+ // Each chunk gets its own post-processing context to properly scope async work.
378
+ let drainCancelled = false as boolean
379
+ const drain = (async () => {
397
380
  try {
398
381
  // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
399
382
  while (true) {
@@ -401,16 +384,62 @@ async function processFramedResponse({
401
384
  if (done) break
402
385
  if (value) {
403
386
  try {
404
- onMessage(JSON.parse(value))
387
+ // Set up post-processing context for this chunk
388
+ const chunkPostProcessPromises: Array<Promise<unknown>> = []
389
+ setPostProcessContext(chunkPostProcessPromises)
390
+ try {
391
+ onMessage(JSON.parse(value))
392
+ } finally {
393
+ setPostProcessContext(null)
394
+ }
395
+ // Await any async post-processing from this chunk before processing next.
396
+ // This ensures values requiring async work are ready before their
397
+ // containing Promise/Stream resolves/emits to consumers.
398
+ await awaitPostProcessPromises(chunkPostProcessPromises)
405
399
  } catch (e) {
406
400
  onError?.(`Invalid JSON: ${value}`, e)
407
401
  }
408
402
  }
409
403
  }
410
404
  } catch (err) {
411
- onError?.('Stream processing error:', err)
405
+ if (!drainCancelled) {
406
+ onError?.('Stream processing error:', err)
407
+ }
412
408
  }
413
409
  })()
414
410
 
415
- return onMessage(firstObject)
411
+ // Process first object with its own post-processing context
412
+ let result: any
413
+ const initialPostProcessPromises: Array<Promise<unknown>> = []
414
+ setPostProcessContext(initialPostProcessPromises)
415
+ try {
416
+ result = onMessage(firstObject)
417
+ } catch (err) {
418
+ setPostProcessContext(null)
419
+ drainCancelled = true
420
+ reader.cancel().catch(() => {})
421
+ throw err
422
+ }
423
+ setPostProcessContext(null)
424
+
425
+ // Await initial post-processing promises before returning result
426
+ await awaitPostProcessPromises(initialPostProcessPromises)
427
+
428
+ // If the initial decode fails async, stop draining to avoid holding
429
+ // onto the response body and raw stream buffers unnecessarily.
430
+ Promise.resolve(result).catch(() => {
431
+ drainCancelled = true
432
+ reader.cancel().catch(() => {})
433
+ })
434
+
435
+ // Detach reader once draining completes.
436
+ drain.finally(() => {
437
+ try {
438
+ reader.releaseLock()
439
+ } catch {
440
+ // Ignore
441
+ }
442
+ })
443
+
444
+ return result
416
445
  }