Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
76 changes: 76 additions & 0 deletions src/cookies.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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");
});
});
48 changes: 36 additions & 12 deletions src/cookies.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
}
36 changes: 36 additions & 0 deletions src/createServerClient.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
});
});
});
16 changes: 16 additions & 0 deletions src/createServerClient.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}