@benjavicente/start-client-core 1.167.9 → 1.168.2
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.
- package/dist/esm/client/hydrateStart.js +4 -2
- package/dist/esm/client/hydrateStart.js.map +1 -1
- package/dist/esm/client-rpc/serverFnFetcher.d.ts +21 -0
- package/dist/esm/client-rpc/serverFnFetcher.js +94 -71
- package/dist/esm/client-rpc/serverFnFetcher.js.map +1 -1
- package/dist/esm/createCsrfMiddleware.d.ts +46 -0
- package/dist/esm/createCsrfMiddleware.js +63 -0
- package/dist/esm/createCsrfMiddleware.js.map +1 -0
- package/dist/esm/createMiddleware.d.ts +4 -0
- package/dist/esm/createMiddleware.js.map +1 -1
- package/dist/esm/createServerFn.d.ts +44 -31
- package/dist/esm/createServerFn.js +1 -1
- package/dist/esm/createServerFn.js.map +1 -1
- package/dist/esm/fake-entries/plugin-adapters.d.ts +3 -0
- package/dist/esm/fake-entries/plugin-adapters.js +7 -0
- package/dist/esm/fake-entries/plugin-adapters.js.map +1 -0
- package/dist/esm/fake-entries/router.d.ts +1 -0
- package/dist/esm/fake-entries/router.js +6 -0
- package/dist/esm/fake-entries/router.js.map +1 -0
- package/dist/esm/{fake-start-entry.d.ts → fake-entries/start.d.ts} +0 -1
- package/dist/esm/fake-entries/start.js +6 -0
- package/dist/esm/fake-entries/start.js.map +1 -0
- package/dist/esm/getDefaultSerovalPlugins.d.ts +2 -1
- package/dist/esm/getDefaultSerovalPlugins.js.map +1 -1
- package/dist/esm/index.d.ts +4 -1
- package/dist/esm/index.js +3 -1
- package/dist/esm/tests/createCsrfMiddleware.test.d.ts +1 -0
- package/package.json +9 -10
- package/skills/start-core/SKILL.md +11 -7
- package/skills/start-core/auth-server-primitives/SKILL.md +410 -0
- package/skills/start-core/deployment/SKILL.md +9 -0
- package/skills/start-core/execution-model/SKILL.md +68 -19
- package/skills/start-core/middleware/SKILL.md +42 -9
- package/skills/start-core/server-functions/SKILL.md +115 -17
- package/src/client/hydrateStart.ts +12 -6
- package/src/client-rpc/serverFnFetcher.ts +132 -103
- package/src/createCsrfMiddleware.ts +197 -0
- package/src/createMiddleware.ts +4 -0
- package/src/createServerFn.ts +192 -63
- package/src/fake-entries/plugin-adapters.ts +4 -0
- package/src/fake-entries/router.ts +1 -0
- package/src/{fake-start-entry.ts → fake-entries/start.ts} +0 -1
- package/src/getDefaultSerovalPlugins.ts +2 -1
- package/src/index.tsx +16 -0
- package/src/start-entry.d.ts +9 -2
- package/src/tests/createCsrfMiddleware.test.ts +290 -0
- package/src/tests/createServerFn.test-d.ts +152 -2
- package/src/tests/createServerMiddleware.test-d.ts +16 -3
- package/bin/intent.js +0 -25
- package/dist/esm/fake-start-entry.js +0 -7
- 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**:
|
|
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 @
|
|
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 @
|
|
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 @
|
|
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 @
|
|
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 @
|
|
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.
|
|
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
|
-
|
|
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
|
-
|
|
203
|
-
|
|
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
|
-
|
|
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:
|
|
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
|
-
###
|
|
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
|
-
//
|
|
331
|
+
// WRONG — Next.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
|
-
|
|
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
|
-
###
|
|
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
|
-
###
|
|
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
|
-
|
|
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
|
-
//
|
|
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
|
-
//
|
|
328
|
-
const
|
|
329
|
-
<button onClick={() =>
|
|
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
|
-
|
|
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.
|
|
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
|
-
|
|
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
|
|
396
|
-
|
|
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
|
-
|
|
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
|
-
|
|
405
|
+
if (!drainCancelled) {
|
|
406
|
+
onError?.('Stream processing error:', err)
|
|
407
|
+
}
|
|
412
408
|
}
|
|
413
409
|
})()
|
|
414
410
|
|
|
415
|
-
|
|
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
|
}
|