diff --git a/src/cookies.spec.ts b/src/cookies.spec.ts index b5d30c1..4bf7daa 100644 --- a/src/cookies.spec.ts +++ b/src/cookies.spec.ts @@ -1069,4 +1069,80 @@ describe("applyServerStorage", () => { }, ]); }); + + it("should log helpful error when setAll throws after response is sent", async () => { + const errors: any[][] = []; + const originalError = console.error; + console.error = (...args: any[]) => { + errors.push(args); + }; + + try { + const { storage, getAll, setAll, setItems, removedItems } = + createStorageFromOptions( + { + cookieEncoding: "raw", + cookies: { + getAll: async () => [], + setAll: async () => { + // Simulate the SvelteKit error when response is already sent + throw new Error( + "Cannot use `cookies.set(...)` after the response has been generated", + ); + }, + }, + }, + true, + ); + + await storage.setItem("storage-key", "value"); + + // This should throw, but log helpful context first + await expect( + applyServerStorage( + { getAll, setAll, setItems, removedItems }, + { + cookieEncoding: "raw", + }, + ), + ).rejects.toThrow("after the response has been generated"); + + expect(errors.length).toEqual(1); + expect(errors[0][0]).toContain( + "Cannot set cookies after response has been sent", + ); + expect(errors[0][0]).toContain("Token refresh completed too late"); + } finally { + console.error = originalError; + } + }); + + it("should re-throw errors that are not related to response already sent", async () => { + const { storage, getAll, setAll, setItems, removedItems } = + createStorageFromOptions( + { + cookieEncoding: "raw", + cookies: { + getAll: async () => [], + setAll: async () => { + // Simulate a different error + throw new Error("Network error or some other issue"); + }, + }, + }, + true, + ); + + await storage.setItem("storage-key", "value"); + + // This SHOULD throw because it's not a "response already sent" error + await expect( + applyServerStorage( + { getAll, setAll, setItems, removedItems }, + { + cookieEncoding: "raw", + }, + ), + ).rejects.toThrow("Network error or some other issue"); + }); }); diff --git a/src/cookies.ts b/src/cookies.ts index 71a94a3..a769133 100644 --- a/src/cookies.ts +++ b/src/cookies.ts @@ -468,16 +468,40 @@ export async function applyServerStorage( delete (removeCookieOptions as any).name; delete (setCookieOptions as any).name; - await setAll([ - ...removeCookies.map((name) => ({ - name, - value: "", - options: removeCookieOptions, - })), - ...setCookies.map(({ name, value }) => ({ - name, - value, - options: setCookieOptions, - })), - ]); + try { + await setAll([ + ...removeCookies.map((name) => ({ + name, + value: "", + options: removeCookieOptions, + })), + ...setCookies.map(({ name, value }) => ({ + name, + value, + options: setCookieOptions, + })), + ]); + } catch (error) { + // Better explain the case where cookies cannot be set because the response + // has already been sent. This can happen when token refresh completes + // asynchronously after the SSR framework has already generated and sent + // the HTTP response. + if ( + error instanceof Error && + (error.message.includes("after the response") || + error.message.includes("response has been generated")) + ) { + console.error( + "@supabase/ssr: Cannot set cookies after response has been sent. " + + "Token refresh completed too late in the request lifecycle. " + + "This should be prevented by the automatic session initialization, " + + "but if you're seeing this error, please report it as a bug.", + ); + // Don't throw - this prevents crashes but tokens won't be persisted + // until the next request + throw error; + } + // Re-throw other errors as they indicate a different problem + throw error; + } } diff --git a/src/createServerClient.spec.ts b/src/createServerClient.spec.ts index 21d612d..25e821b 100644 --- a/src/createServerClient.spec.ts +++ b/src/createServerClient.spec.ts @@ -380,4 +380,40 @@ describe("createServerClient", () => { }); }); }); + + describe("proactive session initialization", () => { + it("should automatically call getSession to prevent race conditions", async () => { + let getSessionCalled = false; + + const supabase = createServerClient( + "https://project-ref.supabase.co", + "publishable-key", + { + cookies: { + getAll() { + return []; + }, + setAll() {}, + }, + global: { + fetch: async () => { + throw new Error("Should not be called in this test"); + }, + }, + }, + ); + + // Spy on getSession + const originalGetSession = supabase.auth.getSession.bind(supabase.auth); + supabase.auth.getSession = async () => { + getSessionCalled = true; + return originalGetSession(); + }; + + // Wait for queue to execute + await new Promise((resolve) => setTimeout(resolve, 50)); + + expect(getSessionCalled).toBe(true); + }); + }); }); diff --git a/src/createServerClient.ts b/src/createServerClient.ts index 9eae3b1..4e15bf2 100644 --- a/src/createServerClient.ts +++ b/src/createServerClient.ts @@ -209,5 +209,21 @@ export function createServerClient< } }); + // Proactively load the session to trigger any necessary token refresh + // synchronously during request processing, before the response is generated. + // This prevents a race condition where async token refresh completes after + // the HTTP response has already been sent, which would cause cookie setting + // to fail. + // + // Promise.resolve().then() is used which means it executes after the current + // synchronous code. This ensures the session is initialized early in the + // request lifecycle without blocking the client creation. + Promise.resolve().then(() => { + client.auth.getSession().catch(() => { + // Ignore errors - if session loading fails, the client is still usable + // and subsequent auth operations will handle the error appropriately + }); + }); + return client; }