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
21 changes: 21 additions & 0 deletions src/modules/analytics.ts
Original file line number Diff line number Diff line change
Expand Up @@ -229,7 +229,12 @@ async function startAnalyticsProcessor(
}

function startHeartBeatProcessor(track: (params: TrackEventParams) => void) {
// Browser-only, like the other automatic events here (initialization, session
// duration, visibility). Outside a browser this timer fired a `me()` every
// interval for the lifetime of a long-lived server-side client, and kept the
// Node event loop alive. Explicit `analytics.track()` calls still work.
if (
typeof window === "undefined" ||
analyticsSharedState.isHeartBeatProcessing ||
(analyticsSharedState.config.heartBeatInterval ?? 0) < 10
) {
Expand Down Expand Up @@ -307,6 +312,22 @@ function transformEventDataToApiRequestData(sessionContext: SessionContext) {
}

let sessionContextPromise: Promise<SessionContext> | null = null;

/**
* Clears the memoized analytics session context.
*
* The context holds the `user_id` resolved by `auth.me()` and is reused for the
* lifetime of the session, so it has to be dropped whenever the identity
* changes. Without this, a visitor who loads a page anonymously and then logs in
* keeps reporting `user_id: null` on every subsequent event.
*
* @internal
*/
export function resetAnalyticsSessionContext() {
analyticsSharedState.sessionContext = null;
sessionContextPromise = null;
}

async function getSessionContext(
userAuthModule: AuthModule
): Promise<SessionContext> {
Expand Down
35 changes: 34 additions & 1 deletion src/modules/auth.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,10 +2,12 @@ import { AxiosInstance } from "axios";
import {
AuthModule,
AuthModuleOptions,
User,
VerifyOtpParams,
ChangePasswordParams,
ResetPasswordParams,
} from "./auth.types";
import { resetAnalyticsSessionContext } from "./analytics.js";

function isInsideIframe(): boolean {
if (typeof window === "undefined") return false;
Expand Down Expand Up @@ -91,10 +93,31 @@ export function createAuthModule(
appId: string,
options: AuthModuleOptions
): AuthModule {
// In-flight `me()` request, shared by concurrent callers. The analytics
// module resolves its session context through `me()` at client construction,
// at the same moment most apps issue their own `me()`. Browsers serialize the
// two identical GETs, so the second pays the first's full latency on every
// cold load.
//
// This shares the pending promise only — it is cleared as soon as the request
// settles, so no resolved user is ever retained. Caching the user across
// requests would leave the app rendering a stale identity after logout or a
// session swap.
let pendingMe: Promise<User> | null = null;
const clearPendingMe = () => {
pendingMe = null;
};

return {
// Get current user information
async me() {
return axios.get(`/apps/${appId}/entities/User/me`);
const request =
pendingMe ??
axios
.get<any, User>(`/apps/${appId}/entities/User/me`)
.finally(clearPendingMe);
pendingMe = request;
return request;
},

// Update current user data
Expand Down Expand Up @@ -158,6 +181,11 @@ export function createAuthModule(
// Remove token from axios headers (always do this)
delete axios.defaults.headers.common["Authorization"];

// Drop identity resolved under the previous session: a `me()` already in
// flight would otherwise resolve into callers that run after the logout.
clearPendingMe();
resetAnalyticsSessionContext();

// Only do the rest if in a browser environment
if (typeof window !== "undefined") {
// Remove token from localStorage
Expand All @@ -184,6 +212,11 @@ export function createAuthModule(
setToken(token: string, saveToStorage = true) {
if (!token) return;

// Same reasoning as in `logout`: the identity changes here, so anything
// resolved for the previous one must not be handed to later callers.
clearPendingMe();
resetAnalyticsSessionContext();

// handle token change for axios clients
axios.defaults.headers.common["Authorization"] = `Bearer ${token}`;
functionsAxiosClient.defaults.headers.common[
Expand Down
20 changes: 20 additions & 0 deletions tests/unit/analytics.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import {
TrackEventData,
} from "../../src/index.ts";
import { getSharedInstance } from "../../src/utils/sharedInstance.ts";
import { resetAnalyticsSessionContext } from "../../src/modules/analytics.ts";
import { User } from "../../src/modules/auth.types.ts";
import { AxiosInstance } from "axios";

Expand Down Expand Up @@ -82,6 +83,25 @@ describe("Analytics Module", () => {
});
});

test("should clear the memoized session context on reset", () => {
expect(sharedState?.sessionContext).toEqual({ user_id: "test-user-id" });

resetAnalyticsSessionContext();

// Called on every identity change. Without it, a visitor who loads
// anonymously and then logs in keeps reporting the pre-login identity.
expect(sharedState?.sessionContext).toBeNull();
});

test("should not start the heartbeat outside a browser", () => {
const heartBeatState = sharedState as unknown as {
isHeartBeatProcessing: boolean;
};

expect(typeof window).toBe("undefined");
expect(heartBeatState.isHeartBeatProcessing).toBeFalsy();
});

test("should track multiple events", async () => {
vi.useFakeTimers();

Expand Down
70 changes: 69 additions & 1 deletion tests/unit/auth.test.js
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { describe, test, expect, beforeEach, afterEach, vi } from 'vitest';
import nock from 'nock';
import { createClient } from '../../src/index.ts';
import { getSharedInstance } from '../../src/utils/sharedInstance.ts';

describe('Auth Module', () => {
let base44;
Expand Down Expand Up @@ -90,8 +91,75 @@ describe('Auth Module', () => {
// Verify all mocks were called
expect(scope.isDone()).toBe(true);
});

test('shares one in-flight request between concurrent callers', async () => {
const mockUser = { id: 'user-123', email: 'test@example.com' };

// A single interceptor: a second GET would hit disableNetConnect and throw.
scope.get(`/api/apps/${appId}/entities/User/me`).reply(200, mockUser);

const [first, second] = await Promise.all([
base44.auth.me(),
base44.auth.me(),
]);

expect(first).toEqual(mockUser);
expect(second).toEqual(mockUser);
expect(scope.isDone()).toBe(true);
});

test('does not reuse a resolved user across separate calls', async () => {
scope.get(`/api/apps/${appId}/entities/User/me`).reply(200, { id: 'user-1' });
scope.get(`/api/apps/${appId}/entities/User/me`).reply(200, { id: 'user-2' });

const first = await base44.auth.me();
const second = await base44.auth.me();

// Sharing is limited to the in-flight window; identity is never cached.
expect(first.id).toBe('user-1');
expect(second.id).toBe('user-2');
expect(scope.isDone()).toBe(true);
});

test('does not retain a rejected request', async () => {
const mockUser = { id: 'user-123' };
scope.get(`/api/apps/${appId}/entities/User/me`).reply(401, { detail: 'Unauthorized' });
scope.get(`/api/apps/${appId}/entities/User/me`).reply(200, mockUser);

await expect(base44.auth.me()).rejects.toThrow();
await expect(base44.auth.me()).resolves.toEqual(mockUser);

expect(scope.isDone()).toBe(true);
});

test('setToken() drops an in-flight request from the previous identity', async () => {
scope
.get(`/api/apps/${appId}/entities/User/me`)
.delay(50)
.reply(200, { id: 'anonymous' });
scope.get(`/api/apps/${appId}/entities/User/me`).reply(200, { id: 'logged-in' });

const beforeLogin = base44.auth.me();
base44.auth.setToken('new-access-token', false);
const afterLogin = await base44.auth.me();

// The call made after the identity change must not resolve into the
// request that was already in flight for the anonymous one.
expect(afterLogin.id).toBe('logged-in');
await expect(beforeLogin).resolves.toEqual({ id: 'anonymous' });
expect(scope.isDone()).toBe(true);
});

test('setToken() clears the analytics session context', () => {
const analyticsState = getSharedInstance('analytics', () => ({}));
analyticsState.sessionContext = { user_id: 'anonymous-user', session_id: 's1' };

base44.auth.setToken('new-access-token', false);

expect(analyticsState.sessionContext).toBeNull();
});
});

describe('updateMe()', () => {
test('should update current user data', async () => {
const updateData = {
Expand Down