Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Fixing the service worker config #307

Open
wants to merge 38 commits into
base: master
Choose a base branch
from

Conversation

jamesdaniels
Copy link
Member

@jamesdaniels jamesdaniels commented Sep 5, 2024

Getting the NextJS codelab fixed up, this PR is intended to be shipped alongside cl/671476274

TODO:

  • Sync up narrative in cl/671476274
  • Ensure that nextjs-start has all relevant changes
  • Do an unused import pass
  • Lint
  • Prettier

@jamesdaniels jamesdaniels marked this pull request as ready for review September 10, 2024 18:46
@pashpashpash
Copy link

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.

@jamesdaniels
Copy link
Member Author

@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
Copy link

pashpashpash commented Sep 13, 2024

@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
Copy link

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
Copy link

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
Copy link

pashpashpash commented Sep 19, 2024

@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

@jamesdaniels
Copy link
Member Author

@pashpashpash good catch thanks, i'll address that

@pashpashpash
Copy link

pashpashpash commented Oct 5, 2024

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" },
    });
  });
}

@payable-sebastien-kovacs

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.

@pashpashpash
Copy link

@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);
}

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
3 participants