From df1c6f6ae5633d56746d5a10c3cf325fda4701ad Mon Sep 17 00:00:00 2001 From: DonOmalVindula Date: Sun, 6 Sep 2026 10:38:56 +0530 Subject: [PATCH] fix(nextjs): share the initialization between concurrent cold-start requests AsgardeoNextClient.initialize() marked the singleton as initialized before its first await, so a second request arriving while the first was still resolving the app origin proceeded with an uninitialized legacy client and failed with "Cannot read properties of undefined (reading 'getConfigData')". A failed initialization also left the client permanently flagged as initialized. Callers now await the initialization in progress, the flag is only set once it succeeds, and a failed attempt is retried by the next call. ensureInitialized() waits for an in-flight initialization instead of throwing. Co-Authored-By: Claude Fable 5.1 --- .changeset/nextjs-initialization-race.md | 5 + packages/nextjs/src/AsgardeoNextClient.ts | 49 +++++- .../AsgardeoNextClient.initialize.test.ts | 147 ++++++++++++++++++ 3 files changed, 193 insertions(+), 8 deletions(-) create mode 100644 .changeset/nextjs-initialization-race.md create mode 100644 packages/nextjs/src/__tests__/AsgardeoNextClient.initialize.test.ts diff --git a/.changeset/nextjs-initialization-race.md b/.changeset/nextjs-initialization-race.md new file mode 100644 index 000000000..9c236be80 --- /dev/null +++ b/.changeset/nextjs-initialization-race.md @@ -0,0 +1,5 @@ +--- +'@asgardeo/nextjs': patch +--- + +Concurrent requests during a cold start no longer race on a half-initialized client. `AsgardeoNextClient.initialize()` marked the singleton as initialized before its first `await`, so a second request arriving while the first one was still resolving the app origin went on with an uninitialized legacy client and failed with `Cannot read properties of undefined (reading 'getConfigData')`. A failed initialization also left the client permanently "initialized" but unusable. Callers now share the initialization in progress, the client is only marked as initialized once that succeeds, and a failed attempt is retried by the next request. diff --git a/packages/nextjs/src/AsgardeoNextClient.ts b/packages/nextjs/src/AsgardeoNextClient.ts index 3d4426a3c..2a0252809 100644 --- a/packages/nextjs/src/AsgardeoNextClient.ts +++ b/packages/nextjs/src/AsgardeoNextClient.ts @@ -76,6 +76,12 @@ class AsgardeoNextClient exte private asgardeo: LegacyAsgardeoNodeClient; + /** + * The initialization currently in progress, if any. Concurrent callers, for example parallel + * requests on a cold start, await this instead of racing on a half-initialized client. + */ + private initialization: Promise | undefined; + public isInitialized: boolean = false; private constructor() { @@ -96,21 +102,50 @@ class AsgardeoNextClient exte /** * Ensures the client is initialized before using it. - * Throws an error if the client is not initialized. + * Waits for an initialization that is still in progress and throws if none was started. */ protected override async ensureInitialized(): Promise { - if (!this.isInitialized) { - throw new Error( - '[AsgardeoNextClient] Client is not initialized. Make sure you have wrapped your app with AsgardeoProvider and provided the required configuration (baseUrl, clientId, etc.).', - ); + if (this.isInitialized) { + return; } + + if (this.initialization) { + await this.initialization; + + return; + } + + throw new Error( + '[AsgardeoNextClient] Client is not initialized. Make sure you have wrapped your app with AsgardeoProvider and provided the required configuration (baseUrl, clientId, etc.).', + ); } + /** + * Initializes the client once. Callers that arrive while an initialization is in progress share it, + * the client is only marked as initialized after that succeeds, and a failed attempt is retried by + * the next call instead of leaving the singleton permanently unusable. + */ override async initialize(config: T, storage?: Storage): Promise { if (this.isInitialized) { - return Promise.resolve(true); + return true; } + if (!this.initialization) { + this.initialization = this.performInitialization(config, storage) + .then((initialized: boolean) => { + this.isInitialized = initialized; + + return initialized; + }) + .finally(() => { + this.initialization = undefined; + }); + } + + return this.initialization; + } + + private async performInitialization(config: T, storage?: Storage): Promise { const { baseUrl, organizationHandle, @@ -124,8 +159,6 @@ class AsgardeoNextClient exte ...rest } = decorateConfigWithNextEnv(config); - this.isInitialized = true; - let resolvedOrganizationHandle: string | undefined = organizationHandle; if (!resolvedOrganizationHandle) { diff --git a/packages/nextjs/src/__tests__/AsgardeoNextClient.initialize.test.ts b/packages/nextjs/src/__tests__/AsgardeoNextClient.initialize.test.ts new file mode 100644 index 000000000..362e54509 --- /dev/null +++ b/packages/nextjs/src/__tests__/AsgardeoNextClient.initialize.test.ts @@ -0,0 +1,147 @@ +/** + * Copyright (c) 2026, WSO2 LLC. (https://www.wso2.com). + * + * WSO2 LLC. licenses this file to you under the Apache License, + * Version 2.0 (the "License"); you may not use this file except + * in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import {beforeEach, describe, expect, it, vi, Mock} from 'vitest'; +import AsgardeoNextClient from '../AsgardeoNextClient'; +import getClientOrigin from '../server/actions/getClientOrigin'; + +const {legacyClient} = vi.hoisted(() => { + const hoistedLegacyClient: {getConfigData: Mock; getSignInUrl: Mock; initialize: Mock} = { + getConfigData: vi.fn(), + getSignInUrl: vi.fn(), + initialize: vi.fn(), + }; + + return {legacyClient: hoistedLegacyClient}; +}); + +vi.mock('@asgardeo/node', async (importOriginal: () => Promise>) => ({ + ...(await importOriginal()), + // The SDK instantiates the legacy client with `new`, which an arrow function cannot serve. + // eslint-disable-next-line prefer-arrow-callback + LegacyAsgardeoNodeClient: vi.fn(function LegacyAsgardeoNodeClientMock(): unknown { + return legacyClient; + }), +})); + +vi.mock('../server/actions/getClientOrigin', () => ({default: vi.fn()})); +vi.mock('../server/actions/getSessionId', () => ({default: vi.fn(async () => 'session-1')})); + +interface Deferred { + promise: Promise; + reject: (reason: unknown) => void; + resolve: (value: T) => void; +} + +const defer = (): Deferred => { + let resolve: (value: T) => void = () => {}; + let reject: (reason: unknown) => void = () => {}; + const promise: Promise = new Promise((res: (value: T) => void, rej: (reason: unknown) => void) => { + resolve = res; + reject = rej; + }); + + return {promise, reject, resolve}; +}; + +describe('AsgardeoNextClient.initialize', () => { + const config: Record = { + baseUrl: 'https://api.asgardeo.io/t/acme', + clientId: 'client-id', + clientSecret: 'client-secret', + }; + + beforeEach(() => { + vi.clearAllMocks(); + + // Every test starts from a fresh singleton. + (AsgardeoNextClient as unknown as {instance: unknown}).instance = undefined; + + legacyClient.initialize.mockResolvedValue(true); + legacyClient.getConfigData.mockResolvedValue(config); + legacyClient.getSignInUrl.mockResolvedValue('https://api.asgardeo.io/t/acme/oauth2/authorize?client_id=client-id'); + }); + + it('shares one initialization between concurrent callers', async () => { + const origin: Deferred = defer(); + + (getClientOrigin as unknown as Mock).mockReturnValue(origin.promise); + + const client: AsgardeoNextClient = AsgardeoNextClient.getInstance(); + const first: Promise = client.initialize(config as any); + const second: Promise = client.initialize(config as any); + + expect(client.isInitialized).toBe(false); + expect(legacyClient.initialize).not.toHaveBeenCalled(); + + origin.resolve('http://localhost:3000'); + + await expect(first).resolves.toBe(true); + await expect(second).resolves.toBe(true); + expect(legacyClient.initialize).toHaveBeenCalledTimes(1); + expect(client.isInitialized).toBe(true); + }); + + it('lets callers that need an initialized client wait for the initialization in progress', async () => { + const origin: Deferred = defer(); + + (getClientOrigin as unknown as Mock).mockReturnValue(origin.promise); + + const client: AsgardeoNextClient = AsgardeoNextClient.getInstance(); + const initialization: Promise = client.initialize(config as any); + const authorizeUrl: Promise = client.getAuthorizeRequestUrl({}); + + expect(legacyClient.getSignInUrl).not.toHaveBeenCalled(); + + origin.resolve('http://localhost:3000'); + + await initialization; + await expect(authorizeUrl).resolves.toContain('/oauth2/authorize'); + }); + + it('does not mark the client as initialized when the initialization fails and retries on the next call', async () => { + (getClientOrigin as unknown as Mock).mockRejectedValueOnce( + new Error('headers() was called outside a request scope'), + ); + + const client: AsgardeoNextClient = AsgardeoNextClient.getInstance(); + + await expect(client.initialize(config as any)).rejects.toThrow('outside a request scope'); + expect(client.isInitialized).toBe(false); + expect(legacyClient.initialize).not.toHaveBeenCalled(); + await expect(client.getAuthorizeRequestUrl({})).rejects.toThrow(/not initialized/); + + (getClientOrigin as unknown as Mock).mockResolvedValue('http://localhost:3000'); + + await expect(client.initialize(config as any)).resolves.toBe(true); + expect(client.isInitialized).toBe(true); + expect(legacyClient.initialize).toHaveBeenCalledTimes(1); + }); + + it('initializes only once across sequential calls', async () => { + (getClientOrigin as unknown as Mock).mockResolvedValue('http://localhost:3000'); + + const client: AsgardeoNextClient = AsgardeoNextClient.getInstance(); + + await client.initialize(config as any); + await expect(client.initialize(config as any)).resolves.toBe(true); + + expect(getClientOrigin).toHaveBeenCalledTimes(1); + expect(legacyClient.initialize).toHaveBeenCalledTimes(1); + }); +});