Fixing the service worker config
Getting the NextJS codelab fixed up, this PR is intended to be shipped alongside cl/671476274
- Update ignores to allow for easier local development
- Simply some code, namely using implied unsubscribe
- NextJS server actions are no longer experimental
- Drop service workers in favor of Cookies, fixes #306 #295 #294 #298 #297 #299 #308
- Added comments to explain
getAuthenticatedAppForUser, fixes #274 - Fix restaurant details, fixes #292
- Use prettier, fixes #251
- Have a default maxInstances of 2, should help for folks with policy limits like #302 #291
TODO:
- [ ] Sync up narrative in cl/671476274
- [x] Ensure that nextjs-start has all relevant changes
- [x] Do an unused import pass
- [x] Lint
- [x] Prettier
There's a problem with this if you log in with one user, then log in again as another user, it doesn't refresh and still uses the old user.
@pashpashpash hmmmm, that's odd. What browser are you using? Are you seeing a problem with the service worker?
I did guard the router refresh behind the service worker ready & so I could ensure auth state was in sync with the new magic url method.
I should do the same with login, only allow it if the service worker is registered and ready—since we rely on that.
@pashpashpash hmmmm, that's odd. What browser are you using? Are you seeing a problem with the service worker?
I did guard the router refresh behind the service worker ready & so I could ensure auth state was in sync with the new magic url method.
I should do the same with login, only allow it if the service worker is registered and ready—since we rely on that.
I'm on Chrome. It would also be helpful to have some documentation on the flows between the service worker, the client, and the server side rendering processes. I.e. if the server renders conditionally based on currentUser, but that user changes, will the page reload? Or will we need to include a reload in the clientside code. Explaining what we should expect. Thanks!
Another issue I found is that server actions seem to drop the current user (currentUser=null), even though when SSR'ing a page that later calls the server action, the server says there is a currentUser.
Yeah, upon further inspection, it looks like server actions are sent as POST requests, which in your service worker setup:
self.addEventListener("fetch", (event) => {
const { origin, pathname } = new URL(event.request.url);
if (origin !== self.location.origin) return;
// Use a magic URL to ensure that auth state is in sync between
// the client and the service worker
if (pathname.startsWith("/__/auth/wait/")) {
const uid = pathname.split("/").at(-1);
event.respondWith(waitForMatchingUid(uid));
return;
}
if (pathname.startsWith("/_next/")) return;
// Don't add headers to non-GET requests or those with an extension
// This helps with CSS, images, fonts, JSON, etc.
if (event.request.method !== "GET" || pathname.includes(".")) return;
event.respondWith(fetchWithFirebaseHeaders(event.request));
});
Means that the firebase auth headers are not included in the request. So, naturally, the action tries to run:
const { firebaseServerApp, currentUser } = await getAuthenticatedAppForUser();
And gets currentUser === null.
@jamesdaniels Okay I fixed this by allowing POST as well as GET requests in your service worker fetch event listener:
self.addEventListener("fetch", (event) => {
const { origin, pathname } = new URL(event.request.url);
if (origin !== self.location.origin) return;
// Use a magic URL to ensure that auth state is in sync between
// the client and the service worker
if (pathname.startsWith("/__/auth/wait/")) {
const uid = pathname.split("/").at(-1);
event.respondWith(waitForMatchingUid(uid));
return;
}
if (pathname.startsWith("/_next/")) return;
// Don't add headers to non-GET/POST requests or those with an extension
// This helps with CSS, images, fonts, JSON, etc.
if ((event.request.method === "GET" || event.request.method === "POST") && !pathname.includes(".")) {
event.respondWith(fetchWithFirebaseHeaders(event.request));
}
});
Please let me know if this is bad practice. But it seems to work fine. Idk
@pashpashpash good catch thanks, i'll address that
Sorry to keep bugging you, but I'm still getting a weird bug where the auth state randomly tells me that the current user is null, when attempting a server action.
Example scenario:
- User clicks on a button on the client that fetches a server action
- Server action calls
const { firebaseServerApp, currentUser } = await getAuthenticatedAppForUser();, currentUser is authenticated and works fine. - User clicks on the button x amount of times again, and it works fine, user is authenticated
- User clicks on the button, and all of a sudden,
currentUseris null and the server action fails. - User clicks on the button right after that failure (no page reload), and it works fine again.
The most frustrating part about this bug is it seemingly happens at random, it feels ephemeral. Often the button works fine, and subsequent button clicks with other server actions are authenticated. But then randomly later on it shows the user as unauthenticated again. No page refreshes or anything. Same page, just a different point in time.
EDIT: FYI: After some debugging, I was able to fix this by just retrying to get the authIdToken if the first time I tried it was null, i try 2 more times with a 0.25s sleep. Not sure why but it fixed the issue.
async function fetchWithFirebaseHeaders(request) {
let authIdToken = await getAuthIdToken();
if (!authIdToken) {
// sleep for 0.25s
await new Promise((resolve) => setTimeout(resolve, 250));
authIdToken = await getAuthIdToken();
}
if (!authIdToken) {
// sleep for 0.25s
await new Promise((resolve) => setTimeout(resolve, 250));
authIdToken = await getAuthIdToken();
}
if (authIdToken) {
const headers = new Headers(request.headers);
headers.append("Authorization", `Bearer ${authIdToken}`);
request = new Request(request, { headers });
}
return await fetch(request).catch((reason) => {
console.error(reason);
return new Response("Fail.", {
status: 500,
headers: { "Content-Type": "text/html" },
});
});
}
Sorry to keep bugging you, but I'm still getting a weird bug where the auth state randomly tells me that the current user is null, when attempting a server action.
Example scenario:
- User clicks on a button on the client that fetches a server action
- Server action calls
const { firebaseServerApp, currentUser } = await getAuthenticatedAppForUser();, currentUser is authenticated and works fine.- User clicks on the button x amount of times again, and it works fine, user is authenticated
- User clicks on the button, and all of a sudden,
currentUseris null and the server action fails.- User clicks on the button right after that failure (no page reload), and it works fine again.
The most frustrating part about this bug is it seemingly happens at random, it feels ephemeral. Often the button works fine, and subsequent button clicks with other server actions are authenticated. But then randomly later on it shows the user as unauthenticated again. No page refreshes or anything. Same page, just a different point in time.
EDIT: FYI: After some debugging, I was able to fix this by just retrying to get the authIdToken if the first time I tried it was null, i try 2 more times with a 0.25s sleep. Not sure why but it fixed the issue.
async function fetchWithFirebaseHeaders(request) { let authIdToken = await getAuthIdToken(); if (!authIdToken) { // sleep for 0.25s await new Promise((resolve) => setTimeout(resolve, 250)); authIdToken = await getAuthIdToken(); } if (!authIdToken) { // sleep for 0.25s await new Promise((resolve) => setTimeout(resolve, 250)); authIdToken = await getAuthIdToken(); } if (authIdToken) { const headers = new Headers(request.headers); headers.append("Authorization", `Bearer ${authIdToken}`); request = new Request(request, { headers }); } return await fetch(request).catch((reason) => { console.error(reason); return new Response("Fail.", { status: 500, headers: { "Content-Type": "text/html" }, }); }); }
Thank you both for looking at firebase with Next/RSC.
@pashpashpash Would you have a link to the whole service worker file that's working for you?
Also for curiosity - did you try setting / using cookies manually and to completely remove the service worker? I'm aware that this is probably going to prevent some FirebaseClient APIs to work correctly but I'm just trying to understand what other people have tried.
Thank you.
@payable-sebastien-kovacs
here's the full file I have that's working well (as far as I've seen with gratuitous manual e2e testing):
// auth-service-worker.js
import { initializeApp } from "firebase/app";
import { getAuth, getIdToken, onAuthStateChanged } from "firebase/auth";
// Extract Firebase config from query string
const serializedFirebaseConfig = new URLSearchParams(self.location.search).get(
"firebaseConfig"
);
if (!serializedFirebaseConfig) {
throw new Error(
"Firebase Config object not found in service worker query string."
);
}
const firebaseConfig = JSON.parse(serializedFirebaseConfig);
const app = initializeApp(firebaseConfig);
const auth = getAuth(app);
self.addEventListener("install", () => {
console.log("Service worker installed with Firebase config", firebaseConfig);
self.skipWaiting();
});
self.addEventListener("activate", (event) => {
event.waitUntil(self.clients.claim());
});
self.addEventListener("fetch", (event) => {
const { origin, pathname } = new URL(event.request.url);
if (origin !== self.location.origin) return;
// Use a magic URL to ensure that auth state is in sync between
// the client and the service worker
if (pathname.startsWith("/__/auth/wait/")) {
const uid = pathname.split("/").at(-1);
event.respondWith(waitForMatchingUid(uid));
return;
}
if (pathname.startsWith("/_next/")) return;
// Don't add headers to non-GET requests or those with an extension
// This helps with CSS, images, fonts, JSON, etc.
if ((event.request.method === "GET" || event.request.method === "POST") && !pathname.includes(".")) {
event.respondWith(fetchWithFirebaseHeaders(event.request));
}
});
async function fetchWithFirebaseHeaders(request) {
let authIdToken = await getAuthIdToken();
if (!authIdToken) {
// sleep for 0.25s
await new Promise((resolve) => setTimeout(resolve, 250));
authIdToken = await getAuthIdToken();
}
if (!authIdToken) {
// sleep for 0.25s
await new Promise((resolve) => setTimeout(resolve, 250));
authIdToken = await getAuthIdToken();
}
if (authIdToken) {
const headers = new Headers(request.headers);
headers.append("Authorization", `Bearer ${authIdToken}`);
request = new Request(request, { headers });
}
return await fetch(request).catch((reason) => {
console.error(reason);
return new Response("Fail.", {
status: 500,
headers: { "Content-Type": "text/html" },
});
});
}
async function waitForMatchingUid(_uid) {
const uid = _uid === "undefined" ? undefined : _uid;
await authStateReady();
await new Promise((resolve) => {
const unsubscribe = onAuthStateChanged(auth, (user) => {
if (user?.uid === uid) {
unsubscribe();
resolve();
}
});
});
return new Response(undefined, {
status: 200,
headers: { "Cache-Control": "no-store" },
});
}
function authStateReady() {
return new Promise((resolve) => {
if (auth.currentUser !== undefined) {
resolve();
} else {
const unsubscribe = onAuthStateChanged(auth, () => {
unsubscribe();
resolve();
});
}
});
}
async function getAuthIdToken() {
await authStateReady();
if (!auth.currentUser) return null;
return await getIdToken(auth.currentUser);
}
@jamesdaniels This looks great! Thanks! Any plans to merge it?
Hi @jamesdaniels or @jhuleatt. Is there any documentation or context on why the service worker was removed from this repository for Next.js applications?
I'm currently exploring best practices for integrating Firebase with a Next.js app. Firebase's own tutorials and examples don't use a service worker, but the Firehose site highlights several benefits of using one for authentication sessions: https://firebase.google.com/docs/auth/web/service-worker-sessions.
Given that, what is the currently recommended approach for integrating Firebase into production-ready Next.js applications?
@lucasoares +1 to that.