Skip to content

Commit fc7f07d

Browse files
committed
Capture the SSR request cookie eagerly so nested fetches stay authenticated (#263)
`CwaFetch`'s ofetch `onRequest` called `useRequestHeaders(['cookie'])`, which threw `[nuxt] instance unavailable` for every nested/batch resource fetched during SSR. The reported mechanism — "ofetch runs interceptors asynchronously" — is wrong. `$fetchRaw`'s body runs synchronously up to its first `await`, and `callHooks` is invoked before it suspends, so `onRequest` inherits its caller's context. The real cause is in our own code. `useRequestHeaders` resolves the Nuxt instance via `useNuxtApp()`, which throws rather than degrading. Nuxt's `asyncContext` defaults to false, so unctx keeps that instance in a plain module variable and clears it at the first suspension, and its `__restore()` hook only covers code rewritten by `unctx/transform` — a closed list of `defineNuxtPlugin`, `defineNuxtRouteMiddleware` and friends. `fetcher.ts` is an untransformed plain class, so every `await` in it destroys the context for everything downstream: the primary fetch reached the interceptor synchronously and worked, while everything after `await result.response` -> `fetchBatch` threw. ofetch's retry path loses the context too, re-entering `onRequest` after a `setTimeout`. It hid well: the throw surfaces as a rejected promise, so each nested resource was quietly marked errored instead of crashing the render. And `credentials: 'include'` does nothing server-side — no cookie jar in undici — so that header append is the ONLY thing forwarding auth cookies during SSR, and authenticated SSR requests silently downgraded to anonymous. Not a regression despite the "latest deps" framing: `asyncContext: false` is unchanged and the interceptor has not changed since dfcf340 (Jan 2025). Capture the cookie in the constructor instead, where the plugin still has a live Nuxt context, and close over it. `onRequest` stays synchronous and needs no context, so nested resources and retries both work. Safe because exactly one CwaFetch exists per Cwa per plugin invocation — per SSR request; `new Cwa(` and `new CwaFetch(` each have exactly one non-spec call site. The cookie must stay in that closure: hoisting it to module scope would leak one user's auth cookie into another's request. The constructor already requires Nuxt context for `useRuntimeConfig()` and `useCookie()`, so this widens nothing, and the only cookie mutations (sign-in/sign-out) are client-side actions. Rejected `runWithContext()` in the interceptor: on the server it always returns a Promise, forcing an async interceptor and changing request timing. Rejected app-side `experimental.asyncContext: true`: a module must not require an app-level experimental flag to forward auth cookies. cwa-fetch.spec.ts moves to the nuxt environment with mockNuxtImport — the existing `vi.mock('#imports', ...)` was not intercepting at all, which went unnoticed because no test ever invoked `onRequest`.
1 parent 9d717f5 commit fc7f07d

3 files changed

Lines changed: 154 additions & 7 deletions

File tree

CLAUDE.md

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -452,6 +452,26 @@ Reached **71.0%** statement coverage (2026-06-28). See `### Coverage progress` a
452452
453453
---
454454
455+
## Bug: `[nuxt] instance unavailable` — SSR auth cookies dropped on nested resources ✅ Fixed ([#263](https://github.com/components-web-app/cwa-nuxt-module/issues/263))
456+
457+
Fixed 2026-07-16. `CwaFetch`'s ofetch `onRequest` interceptor called `useRequestHeaders(['cookie'])`, which threw for every nested/batch resource during SSR.
458+
459+
**The reported mechanism ("ofetch runs interceptors asynchronously") is wrong** — `$fetchRaw`'s body runs synchronously up to its first `await`, and `callHooks` is invoked before it suspends, so `onRequest` inherits its *caller's* context.
460+
461+
**Real mechanism:** `useRequestHeaders``useRequestEvent``useNuxtApp()`, which **throws** (not `tryUseNuxtApp`). Nuxt's `asyncContext` defaults to `false`, so unctx holds the instance in a plain module variable and clears it at the first suspension (`callAsync`: `currentInstance = void 0`). Its `__restore()` hook only applies to code rewritten by `unctx/transform` — a **closed list** (`defineNuxtPlugin`, `defineNuxtRouteMiddleware`, `defineNuxtComponent`, `definePageMeta`). **`fetcher.ts` is an untransformed plain class, so every `await` in it destroys the Nuxt context for everything downstream.** The primary fetch reaches `onRequest` synchronously and works; everything after `await result.response` (`fetcher.ts:214`) → `fetchBatch` threw. ofetch's retry path (`await new Promise(setTimeout)` → re-enter) loses it too.
462+
463+
**Why it hid:** the throw surfaces as a *rejected promise*, so `fetcher.ts:169` caught it and marked each nested resource errored rather than crashing. And `credentials: 'include'` does nothing server-side (no cookie jar in undici) — that header `append` is the **only** SSR cookie forwarding — so authenticated SSR requests silently downgraded to anonymous. Not a regression despite the "latest deps" framing: `asyncContext: false` is unchanged and the interceptor hasn't changed since `dfcf3406` (Jan 2025).
464+
465+
**Fix:** capture the cookie **eagerly in the constructor** (live Nuxt context, via the plugin) and close over it; `onRequest` stays synchronous and context-free.
466+
467+
> **⚠️ Why this is safe, and the one way to make it catastrophic.** Exactly one `CwaFetch` exists per `Cwa` per plugin invocation — i.e. **per SSR request** (`new Cwa(` and `new CwaFetch(` each have exactly one non-spec call site: `plugin.ts:14`, `cwa.ts:71`). The captured cookie **must stay in the constructor closure**. Hoisting it to module scope — as `ResourceTypeFromIri` (`resource-utils.ts:79`) does with its shared singleton, mutated per-request from `cwa.ts:68` — would leak one user's auth cookie into another user's request. The constructor already requires Nuxt context (`useRuntimeConfig()`, `useCookie()`), so eager capture widens nothing, and the only cookie mutations (`signIn`/`signOut`) are client-side, so it cannot go stale mid-render.
468+
469+
**Rejected:** `runWithContext()` in the interceptor (always returns a Promise server-side → forces an async interceptor and changes timing); app-side `experimental.asyncContext: true` (a mitigation that pushes the burden onto every consuming app).
470+
471+
**Testing note:** `cwa-fetch.spec.ts` moved to `@vitest-environment nuxt` + `mockNuxtImport('useRequestHeaders', ...)` — the pre-existing `vi.mock('#imports', ...)` **was not intercepting at all**, which went unnoticed because no test ever invoked `onRequest`. Uses `useProcess()` rather than `import.meta.server` because `import.meta.server` isn't settable in this test env (see the `test.todo` in `process.spec.ts`).
472+
473+
---
474+
455475
## Bug: dynamic position loses its `component` after an SSR load of a nested page ✅ Fixed ([#261](https://github.com/components-web-app/cwa-nuxt-module/issues/261))
456476
457477
**Reported from:** SRNTE (a nested static page whose parent is a data page using the dynamic page template). Fixed 2026-07-16.

src/runtime/api/fetcher/cwa-fetch.spec.ts

Lines changed: 108 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,16 @@
1-
// @vitest-environment happy-dom
1+
// @vitest-environment nuxt
22

3-
import { describe, expect, test, vi } from 'vitest'
3+
import { beforeEach, describe, expect, test, vi } from 'vitest'
44
import { $fetch } from 'ofetch'
5+
import { mockNuxtImport } from '@nuxt/test-utils/runtime'
6+
import * as processComposables from '#cwa/composables/process'
57
import CwaFetch from './cwa-fetch'
68

79
vi.mock('ofetch')
8-
vi.mock('#imports', () => ({ useRequestHeaders: vi.fn(() => ({})) }))
10+
11+
// `vi.mock('#imports')` does not intercept here — use mockNuxtImport (see CLAUDE.md)
12+
const mockUseRequestHeaders = vi.hoisted(() => vi.fn(() => ({}) as Record<string, string | undefined>))
13+
mockNuxtImport('useRequestHeaders', () => mockUseRequestHeaders)
914

1015
describe('Create a fetch instances with defaults', () => {
1116
test('Correct defaults are set on fetch', () => {
@@ -42,3 +47,103 @@ describe('CwaFetch -> getRequestOptions', () => {
4247
expect(opts.headers['content-type']).toBe(expectedContentType)
4348
})
4449
})
50+
51+
/**
52+
* Server-side cookie forwarding must not depend on the Nuxt async context being alive when ofetch
53+
* runs `onRequest`.
54+
*
55+
* `useRequestHeaders` -> `useRequestEvent` -> `useNuxtApp()`, which THROWS `[nuxt] instance
56+
* unavailable` (it does not use `tryUseNuxtApp`). Nuxt's `asyncContext` defaults to false, so unctx
57+
* keeps the instance in a plain module variable and clears it the moment a callback suspends
58+
* (`unctx` `callAsync`: `currentInstance = void 0`). unctx's `__restore()` only works in code
59+
* rewritten by `unctx/transform`, which Nuxt applies to a closed list (`defineNuxtPlugin`,
60+
* `defineNuxtRouteMiddleware`, ...) — `fetcher.ts` is an untransformed plain class, so every `await`
61+
* in it destroys the context for everything downstream. The primary resource fetch reaches
62+
* `onRequest` synchronously and works; every nested/batch resource after `await result.response`
63+
* did not. ofetch's retry path (`await new Promise(setTimeout)` then re-enter) loses it too.
64+
*
65+
* So the cookie is captured EAGERLY in the constructor, which the plugin runs inside a live Nuxt
66+
* context. Safe because exactly one CwaFetch exists per Cwa per plugin invocation — i.e. per SSR
67+
* request — so it can never leak across requests. See #263.
68+
*/
69+
describe('CwaFetch -> server-side cookie forwarding', () => {
70+
// ofetch normalises `ctx.options.headers` to a Headers instance before calling onRequest, so a
71+
// real Headers here is faithful to runtime.
72+
const createRequestCtx = () => ({
73+
request: '/_/routes//',
74+
options: { headers: new Headers() },
75+
})
76+
77+
function captureOnRequest() {
78+
// @ts-expect-error mocked
79+
const createSpy = vi.spyOn($fetch, 'create').mockReturnValue(vi.fn())
80+
void new CwaFetch('https://my-api')
81+
return createSpy.mock.calls[0][0].onRequest
82+
}
83+
84+
function contextIsLost() {
85+
mockUseRequestHeaders.mockImplementation(() => {
86+
throw new Error('[nuxt] instance unavailable')
87+
})
88+
}
89+
90+
beforeEach(() => {
91+
vi.clearAllMocks()
92+
})
93+
94+
test('captures the request cookie at construction and still forwards it once the Nuxt context is gone', () => {
95+
vi.spyOn(processComposables, 'useProcess').mockReturnValue({ isClient: false, isServer: true })
96+
mockUseRequestHeaders.mockReturnValue({ cookie: 'api_component=jwt; cwa_auth=1' })
97+
98+
const onRequest = captureOnRequest()
99+
100+
// read eagerly, inside the plugin's live Nuxt context
101+
expect(mockUseRequestHeaders).toHaveBeenCalledWith(['cookie'])
102+
expect(mockUseRequestHeaders).toHaveBeenCalledTimes(1)
103+
104+
contextIsLost()
105+
106+
const ctx = createRequestCtx()
107+
expect(() => onRequest(ctx)).not.toThrow()
108+
expect(ctx.options.headers.get('cookie')).toBe('api_component=jwt; cwa_auth=1')
109+
// never re-read per request — the point of the fix
110+
expect(mockUseRequestHeaders).toHaveBeenCalledTimes(1)
111+
})
112+
113+
test('forwards the captured cookie on every request from the same instance, including ofetch retries', () => {
114+
vi.spyOn(processComposables, 'useProcess').mockReturnValue({ isClient: false, isServer: true })
115+
mockUseRequestHeaders.mockReturnValue({ cookie: 'api_component=jwt' })
116+
117+
const onRequest = captureOnRequest()
118+
contextIsLost()
119+
120+
// a retry re-enters onRequest across a setTimeout, so it can never have Nuxt context
121+
for (const _attempt of [1, 2]) {
122+
const ctx = createRequestCtx()
123+
onRequest(ctx)
124+
expect(ctx.options.headers.get('cookie')).toBe('api_component=jwt')
125+
}
126+
})
127+
128+
test('does not read or forward request headers on the client', () => {
129+
vi.spyOn(processComposables, 'useProcess').mockReturnValue({ isClient: true, isServer: false })
130+
131+
const onRequest = captureOnRequest()
132+
expect(mockUseRequestHeaders).not.toHaveBeenCalled()
133+
134+
const ctx = createRequestCtx()
135+
onRequest(ctx)
136+
// the browser attaches its own cookies via `credentials: 'include'`
137+
expect(ctx.options.headers.get('cookie')).toBeNull()
138+
})
139+
140+
test('appends no cookie header when the incoming request has none', () => {
141+
vi.spyOn(processComposables, 'useProcess').mockReturnValue({ isClient: false, isServer: true })
142+
mockUseRequestHeaders.mockReturnValue({})
143+
144+
const onRequest = captureOnRequest()
145+
const ctx = createRequestCtx()
146+
onRequest(ctx)
147+
expect(ctx.options.headers.get('cookie')).toBeNull()
148+
})
149+
})

src/runtime/api/fetcher/cwa-fetch.ts

Lines changed: 26 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import { $fetch } from 'ofetch'
22
import type { $Fetch } from 'ofetch'
33
import type { RequestHeaders } from 'h3'
4+
import { useProcess } from '../../composables/process'
45
import { useRequestHeaders } from '#imports'
56

67
interface RequestOptions {
@@ -13,6 +14,28 @@ export default class CwaFetch {
1314
public readonly fetch: $Fetch
1415

1516
constructor(baseURL: string) {
17+
// Capture the incoming request's cookie NOW, while the plugin still has a live Nuxt context.
18+
//
19+
// `useRequestHeaders` resolves the Nuxt instance via `useNuxtApp()`, which THROWS `[nuxt]
20+
// instance unavailable` rather than degrading. Nuxt's `asyncContext` defaults to false, so unctx
21+
// holds that instance in a plain module variable and clears it as soon as a callback suspends —
22+
// and its `__restore()` hook only applies to code rewritten by `unctx/transform` (a closed list:
23+
// `defineNuxtPlugin`, `defineNuxtRouteMiddleware`, ...). `fetcher.ts` is an untransformed plain
24+
// class, so every `await` in it destroys the context for everything downstream. Reading the
25+
// cookie inside `onRequest` therefore worked only for the primary fetch (which reaches the
26+
// interceptor synchronously) and threw for every nested/batch resource, silently downgrading
27+
// authenticated SSR requests to anonymous. ofetch's retry path loses the context too, since it
28+
// re-enters `onRequest` after a `setTimeout`.
29+
//
30+
// SAFETY: exactly one CwaFetch exists per Cwa, per plugin invocation — i.e. per SSR request —
31+
// so this cookie can never leak into another user's request. It MUST stay in this closure and
32+
// must never be hoisted to module scope or onto shared state. The only cookie mutations are
33+
// sign-in/sign-out, which are client-side actions, so it cannot go stale mid-render. This adds
34+
// no new constraint: the constructor already requires Nuxt context for `useRuntimeConfig()` and
35+
// `useCookie()`. See #263.
36+
const { isServer } = useProcess()
37+
const requestCookie = isServer ? useRequestHeaders(['cookie']).cookie : undefined
38+
1639
this.fetch = $fetch.create({
1740
baseURL,
1841
retryDelay: 200,
@@ -37,10 +60,9 @@ export default class CwaFetch {
3760
checkRequestForPrefix(ctx.request.url)
3861
}
3962

40-
if (import.meta.server) {
41-
const { cookie } = useRequestHeaders(['cookie'])
42-
cookie && ctx.options.headers.append('cookie', cookie)
43-
}
63+
// captured at construction — needs no Nuxt context, so this stays synchronous and works
64+
// for nested/batch resources and retries alike
65+
requestCookie && ctx.options.headers.append('cookie', requestCookie)
4466
},
4567
})
4668
}

0 commit comments

Comments
 (0)