Skip to content

Commit df1c6f6

Browse files
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 <noreply@anthropic.com>
1 parent 409ebae commit df1c6f6

3 files changed

Lines changed: 193 additions & 8 deletions

File tree

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
'@asgardeo/nextjs': patch
3+
---
4+
5+
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.

‎packages/nextjs/src/AsgardeoNextClient.ts‎

Lines changed: 41 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -76,6 +76,12 @@ class AsgardeoNextClient<T extends AsgardeoNextConfig = AsgardeoNextConfig> exte
7676

7777
private asgardeo: LegacyAsgardeoNodeClient<T>;
7878

79+
/**
80+
* The initialization currently in progress, if any. Concurrent callers, for example parallel
81+
* requests on a cold start, await this instead of racing on a half-initialized client.
82+
*/
83+
private initialization: Promise<boolean> | undefined;
84+
7985
public isInitialized: boolean = false;
8086

8187
private constructor() {
@@ -96,21 +102,50 @@ class AsgardeoNextClient<T extends AsgardeoNextConfig = AsgardeoNextConfig> exte
96102

97103
/**
98104
* Ensures the client is initialized before using it.
99-
* Throws an error if the client is not initialized.
105+
* Waits for an initialization that is still in progress and throws if none was started.
100106
*/
101107
protected override async ensureInitialized(): Promise<void> {
102-
if (!this.isInitialized) {
103-
throw new Error(
104-
'[AsgardeoNextClient] Client is not initialized. Make sure you have wrapped your app with AsgardeoProvider and provided the required configuration (baseUrl, clientId, etc.).',
105-
);
108+
if (this.isInitialized) {
109+
return;
106110
}
111+
112+
if (this.initialization) {
113+
await this.initialization;
114+
115+
return;
116+
}
117+
118+
throw new Error(
119+
'[AsgardeoNextClient] Client is not initialized. Make sure you have wrapped your app with AsgardeoProvider and provided the required configuration (baseUrl, clientId, etc.).',
120+
);
107121
}
108122

123+
/**
124+
* Initializes the client once. Callers that arrive while an initialization is in progress share it,
125+
* the client is only marked as initialized after that succeeds, and a failed attempt is retried by
126+
* the next call instead of leaving the singleton permanently unusable.
127+
*/
109128
override async initialize(config: T, storage?: Storage): Promise<boolean> {
110129
if (this.isInitialized) {
111-
return Promise.resolve(true);
130+
return true;
112131
}
113132

133+
if (!this.initialization) {
134+
this.initialization = this.performInitialization(config, storage)
135+
.then((initialized: boolean) => {
136+
this.isInitialized = initialized;
137+
138+
return initialized;
139+
})
140+
.finally(() => {
141+
this.initialization = undefined;
142+
});
143+
}
144+
145+
return this.initialization;
146+
}
147+
148+
private async performInitialization(config: T, storage?: Storage): Promise<boolean> {
114149
const {
115150
baseUrl,
116151
organizationHandle,
@@ -124,8 +159,6 @@ class AsgardeoNextClient<T extends AsgardeoNextConfig = AsgardeoNextConfig> exte
124159
...rest
125160
} = decorateConfigWithNextEnv(config);
126161

127-
this.isInitialized = true;
128-
129162
let resolvedOrganizationHandle: string | undefined = organizationHandle;
130163

131164
if (!resolvedOrganizationHandle) {
Lines changed: 147 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,147 @@
1+
/**
2+
* Copyright (c) 2026, WSO2 LLC. (https://www.wso2.com).
3+
*
4+
* WSO2 LLC. licenses this file to you under the Apache License,
5+
* Version 2.0 (the "License"); you may not use this file except
6+
* in compliance with the License.
7+
* You may obtain a copy of the License at
8+
*
9+
* http://www.apache.org/licenses/LICENSE-2.0
10+
*
11+
* Unless required by applicable law or agreed to in writing,
12+
* software distributed under the License is distributed on an
13+
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
14+
* KIND, either express or implied. See the License for the
15+
* specific language governing permissions and limitations
16+
* under the License.
17+
*/
18+
19+
import {beforeEach, describe, expect, it, vi, Mock} from 'vitest';
20+
import AsgardeoNextClient from '../AsgardeoNextClient';
21+
import getClientOrigin from '../server/actions/getClientOrigin';
22+
23+
const {legacyClient} = vi.hoisted(() => {
24+
const hoistedLegacyClient: {getConfigData: Mock; getSignInUrl: Mock; initialize: Mock} = {
25+
getConfigData: vi.fn(),
26+
getSignInUrl: vi.fn(),
27+
initialize: vi.fn(),
28+
};
29+
30+
return {legacyClient: hoistedLegacyClient};
31+
});
32+
33+
vi.mock('@asgardeo/node', async (importOriginal: () => Promise<Record<string, unknown>>) => ({
34+
...(await importOriginal()),
35+
// The SDK instantiates the legacy client with `new`, which an arrow function cannot serve.
36+
// eslint-disable-next-line prefer-arrow-callback
37+
LegacyAsgardeoNodeClient: vi.fn(function LegacyAsgardeoNodeClientMock(): unknown {
38+
return legacyClient;
39+
}),
40+
}));
41+
42+
vi.mock('../server/actions/getClientOrigin', () => ({default: vi.fn()}));
43+
vi.mock('../server/actions/getSessionId', () => ({default: vi.fn(async () => 'session-1')}));
44+
45+
interface Deferred<T> {
46+
promise: Promise<T>;
47+
reject: (reason: unknown) => void;
48+
resolve: (value: T) => void;
49+
}
50+
51+
const defer = <T>(): Deferred<T> => {
52+
let resolve: (value: T) => void = () => {};
53+
let reject: (reason: unknown) => void = () => {};
54+
const promise: Promise<T> = new Promise<T>((res: (value: T) => void, rej: (reason: unknown) => void) => {
55+
resolve = res;
56+
reject = rej;
57+
});
58+
59+
return {promise, reject, resolve};
60+
};
61+
62+
describe('AsgardeoNextClient.initialize', () => {
63+
const config: Record<string, unknown> = {
64+
baseUrl: 'https://api.asgardeo.io/t/acme',
65+
clientId: 'client-id',
66+
clientSecret: 'client-secret',
67+
};
68+
69+
beforeEach(() => {
70+
vi.clearAllMocks();
71+
72+
// Every test starts from a fresh singleton.
73+
(AsgardeoNextClient as unknown as {instance: unknown}).instance = undefined;
74+
75+
legacyClient.initialize.mockResolvedValue(true);
76+
legacyClient.getConfigData.mockResolvedValue(config);
77+
legacyClient.getSignInUrl.mockResolvedValue('https://api.asgardeo.io/t/acme/oauth2/authorize?client_id=client-id');
78+
});
79+
80+
it('shares one initialization between concurrent callers', async () => {
81+
const origin: Deferred<string> = defer<string>();
82+
83+
(getClientOrigin as unknown as Mock).mockReturnValue(origin.promise);
84+
85+
const client: AsgardeoNextClient = AsgardeoNextClient.getInstance();
86+
const first: Promise<boolean> = client.initialize(config as any);
87+
const second: Promise<boolean> = client.initialize(config as any);
88+
89+
expect(client.isInitialized).toBe(false);
90+
expect(legacyClient.initialize).not.toHaveBeenCalled();
91+
92+
origin.resolve('http://localhost:3000');
93+
94+
await expect(first).resolves.toBe(true);
95+
await expect(second).resolves.toBe(true);
96+
expect(legacyClient.initialize).toHaveBeenCalledTimes(1);
97+
expect(client.isInitialized).toBe(true);
98+
});
99+
100+
it('lets callers that need an initialized client wait for the initialization in progress', async () => {
101+
const origin: Deferred<string> = defer<string>();
102+
103+
(getClientOrigin as unknown as Mock).mockReturnValue(origin.promise);
104+
105+
const client: AsgardeoNextClient = AsgardeoNextClient.getInstance();
106+
const initialization: Promise<boolean> = client.initialize(config as any);
107+
const authorizeUrl: Promise<string> = client.getAuthorizeRequestUrl({});
108+
109+
expect(legacyClient.getSignInUrl).not.toHaveBeenCalled();
110+
111+
origin.resolve('http://localhost:3000');
112+
113+
await initialization;
114+
await expect(authorizeUrl).resolves.toContain('/oauth2/authorize');
115+
});
116+
117+
it('does not mark the client as initialized when the initialization fails and retries on the next call', async () => {
118+
(getClientOrigin as unknown as Mock).mockRejectedValueOnce(
119+
new Error('headers() was called outside a request scope'),
120+
);
121+
122+
const client: AsgardeoNextClient = AsgardeoNextClient.getInstance();
123+
124+
await expect(client.initialize(config as any)).rejects.toThrow('outside a request scope');
125+
expect(client.isInitialized).toBe(false);
126+
expect(legacyClient.initialize).not.toHaveBeenCalled();
127+
await expect(client.getAuthorizeRequestUrl({})).rejects.toThrow(/not initialized/);
128+
129+
(getClientOrigin as unknown as Mock).mockResolvedValue('http://localhost:3000');
130+
131+
await expect(client.initialize(config as any)).resolves.toBe(true);
132+
expect(client.isInitialized).toBe(true);
133+
expect(legacyClient.initialize).toHaveBeenCalledTimes(1);
134+
});
135+
136+
it('initializes only once across sequential calls', async () => {
137+
(getClientOrigin as unknown as Mock).mockResolvedValue('http://localhost:3000');
138+
139+
const client: AsgardeoNextClient = AsgardeoNextClient.getInstance();
140+
141+
await client.initialize(config as any);
142+
await expect(client.initialize(config as any)).resolves.toBe(true);
143+
144+
expect(getClientOrigin).toHaveBeenCalledTimes(1);
145+
expect(legacyClient.initialize).toHaveBeenCalledTimes(1);
146+
});
147+
});

0 commit comments

Comments
 (0)