friendlyeats-web icon indicating copy to clipboard operation
friendlyeats-web copied to clipboard

Fixing the service worker config

Open jamesdaniels opened this issue 1 year ago • 11 comments

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

jamesdaniels avatar Sep 05 '24 17:09 jamesdaniels

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 avatar Sep 13 '24 02:09 pashpashpash

@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.

jamesdaniels avatar Sep 13 '24 13:09 jamesdaniels

@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!

pashpashpash avatar Sep 13 '24 18:09 pashpashpash

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.

pashpashpash avatar Sep 18 '24 20:09 pashpashpash

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.

pashpashpash avatar Sep 19 '24 00:09 pashpashpash

@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 avatar Sep 19 '24 00:09 pashpashpash

@pashpashpash good catch thanks, i'll address that

jamesdaniels avatar Sep 19 '24 01:09 jamesdaniels

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:

  1. User clicks on a button on the client that fetches a server action
  2. Server action calls const { firebaseServerApp, currentUser } = await getAuthenticatedAppForUser();, currentUser is authenticated and works fine.
  3. User clicks on the button x amount of times again, and it works fine, user is authenticated
  4. User clicks on the button, and all of a sudden, currentUser is null and the server action fails.
  5. 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" },
    });
  });
}

pashpashpash avatar Oct 05 '24 13:10 pashpashpash

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:

  1. User clicks on a button on the client that fetches a server action
  2. Server action calls const { firebaseServerApp, currentUser } = await getAuthenticatedAppForUser();, currentUser is authenticated and works fine.
  3. User clicks on the button x amount of times again, and it works fine, user is authenticated
  4. User clicks on the button, and all of a sudden, currentUser is null and the server action fails.
  5. 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);
}

pashpashpash avatar Oct 20 '24 06:10 pashpashpash

@jamesdaniels This looks great! Thanks! Any plans to merge it?

abdennourzahaf avatar Feb 28 '25 22:02 abdennourzahaf

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 avatar May 05 '25 02:05 lucasoares

@lucasoares +1 to that.

bartcone avatar Nov 13 '25 03:11 bartcone