Skip to main content

Auth Session Cross-tab Effects

sign-out를 BroadcastChannel로 다른 탭에 전파하고, 서버 sign-out 실패는 다음 부팅에서 한 번 재시도하는 패턴이다.

핵심은 browser tabs share intent, boot effects reconcile server state다.

1. 모듈 코드

  • src/modules/auth/utils/crossTabSignOut.ts: BroadcastChannel을 lazy singleton으로 만들고 sign-out message를 publish/subscribe한다.
  • src/modules/auth/effect-components/SignOutOnOtherTabSignOutEffect.tsx: 다른 탭의 sign-out message를 받으면 현재 탭 session을 clear한다.
  • src/modules/auth/effect-components/PendingServerSignOutEffect.tsx: 이전 sign-out이 서버에 도달하지 못한 상태면 client config 로드 후 server sign-out mutation을 재시도한다.
  • src/modules/auth/hooks/useMarkSessionActive.ts: sign-in flow가 httpOnly cookie session의 존재를 client atom에 기록하고 pending sign-out을 해제한다.
  • src/modules/app/components/SharedAppProviders.tsx: pending server sign-out retry를 앱 공통 부트스트랩 effect로 배치한다.
// filepath: src/modules/auth/utils/crossTabSignOut.ts
const SIGN_OUT_CHANNEL_NAME = 'twenty-sign-out';

let sharedChannel: BroadcastChannel | null = null;

const getSharedSignOutChannel = (): BroadcastChannel | null => {
if (sharedChannel) {
return sharedChannel;
}

try {
sharedChannel = new BroadcastChannel(SIGN_OUT_CHANNEL_NAME);
} catch {
return null;
}

return sharedChannel;
};

export const broadcastSignOutToOtherTabs = () => {
getSharedSignOutChannel()?.postMessage({ type: 'sign-out' });
};

export const subscribeToSignOutFromOtherTabs = (
callback: () => void,
): (() => void) => {
const channel = getSharedSignOutChannel();

if (!channel) {
return () => {};
}

channel.onmessage = (event: MessageEvent) => {
if (event.data?.type === 'sign-out') {
callback();
}
};

return () => {
channel.onmessage = null;
};
};

// filepath: src/modules/auth/effect-components/SignOutOnOtherTabSignOutEffect.tsx
export const SignOutOnOtherTabSignOutEffect = () => {
const { clearSession } = useAuth();

useEffect(() => {
const unsubscribe = subscribeToSignOutFromOtherTabs(() => {
clearSession();
});

return unsubscribe;
}, [clearSession]);

return null;
};

// filepath: src/modules/auth/effect-components/PendingServerSignOutEffect.tsx
export const PendingServerSignOutEffect = () => {
const apolloClient = useApolloClient();
const store = useStore();
const { isLoadedOnce } = useAtomStateValue(clientConfigApiStatusState);

useEffect(() => {
const retryPendingServerSignOut = async () => {
if (!isLoadedOnce || !store.get(isPendingServerSignOutState.atom)) {
return;
}

if (store.get(isCookieAuthActiveState.atom)) {
store.set(isPendingServerSignOutState.atom, false);

return;
}

try {
await apolloClient.mutate({
mutation: SignOutDocument,
context: { skipRetry: true },
});
store.set(isPendingServerSignOutState.atom, false);
} catch {}
};

void retryPendingServerSignOut();
}, [apolloClient, isLoadedOnce, store]);

return null;
};

// filepath: src/modules/auth/hooks/useMarkSessionActive.ts
export const useMarkSessionActive = () => {
const store = useStore();

return useCallback(() => {
store.set(isCookieAuthActiveState.atom, true);
store.set(isPendingServerSignOutState.atom, false);
}, [store]);
};

2. 사용 예제

// filepath: src/modules/app/components/SharedAppProviders.tsx
export const SharedAppProviders = ({ children }: React.PropsWithChildren) => {
return (
<ApolloProvider>
<ClientConfigProviderEffect />
<PendingServerSignOutEffect />
<ClientConfigProvider>{children}</ClientConfigProvider>
</ApolloProvider>
);
};

// filepath: src/modules/app/components/WorkspaceAppProviders.tsx
export const WorkspaceAppProviders = () => {
return (
<SharedAppProviders>
<AuthProvider>
<Outlet />
<SignOutOnOtherTabSignOutEffect />
</AuthProvider>
</SharedAppProviders>
);
};