diff --git a/.changeset/nextjs-provider-upstream-calls.md b/.changeset/nextjs-provider-upstream-calls.md new file mode 100644 index 000000000..45717c26f --- /dev/null +++ b/.changeset/nextjs-provider-upstream-calls.md @@ -0,0 +1,5 @@ +--- +'@asgardeo/nextjs': patch +--- + +Fewer identity-server requests per server render. `AsgardeoProvider` requested the SCIM2 `Me` and `Schemas` resources twice per render (once for the user, once for the profile) and fetched the branding preference on every request. The user is now derived from the profile response, the branding preference is cached in memory for a few minutes per base URL, type, name and locale (a failed fetch is not cached), and the branding request uses the configured `preferences.i18n.language` instead of a hard-coded `en-US`. diff --git a/packages/nextjs/src/server/AsgardeoProvider.tsx b/packages/nextjs/src/server/AsgardeoProvider.tsx index 5015697ac..6ea7d092b 100644 --- a/packages/nextjs/src/server/AsgardeoProvider.tsx +++ b/packages/nextjs/src/server/AsgardeoProvider.tsx @@ -18,7 +18,15 @@ 'use server'; -import {BrandingPreference, AsgardeoRuntimeError, IdToken, Organization, User, UserProfile} from '@asgardeo/node'; +import { + BrandingPreference, + AsgardeoRuntimeError, + IdToken, + Organization, + User, + UserProfile, + generateUserProfile, +} from '@asgardeo/node'; import {AsgardeoProviderProps} from '@asgardeo/react'; import {FC, PropsWithChildren, ReactElement} from 'react'; import clearSession from './actions/clearSession'; @@ -29,7 +37,6 @@ import getCurrentOrganizationAction from './actions/getCurrentOrganizationAction import getMyOrganizations from './actions/getMyOrganizations'; import getSessionId from './actions/getSessionId'; import getSessionPayload from './actions/getSessionPayload'; -import getUserAction from './actions/getUserAction'; import getUserProfileAction from './actions/getUserProfileAction'; import handleOAuthCallbackAction from './actions/handleOAuthCallbackAction'; import httpRequestAction from './actions/httpRequestAction'; @@ -155,19 +162,19 @@ const AsgardeoServerProvider: FC> if (shouldFetchUserProfile) { try { - const userResponse: { - data: {user: User | null}; - error: string | null; - success: boolean; - } = await getUserAction(sessionId); const userProfileResponse: { data: {userProfile: UserProfile}; error: string | null; success: boolean; } = await getUserProfileAction(sessionId); - user = userResponse.data?.user || {}; userProfile = userProfileResponse.data?.userProfile ?? userProfile; + + // `getUser()` would request the same SCIM2 resources a second time; derive the user from the profile + // instead. Without schemas the profile already holds the ID token claims used as the fallback. + user = userProfile.schemas?.length + ? generateUserProfile(userProfile.profile, userProfile.schemas) + : userProfile.profile ?? {}; } catch (error) { logger.warn('[AsgardeoServerProvider] Failed to fetch user profile from SCIM2:', error?.toString()); } @@ -194,21 +201,21 @@ const AsgardeoServerProvider: FC> } } - // Fetch branding preference if branding is enabled in config + // Fetch branding preference if branding is enabled in config. The action caches the result for a few + // minutes, so this does not cost a request to the identity server on every render. if (config?.preferences?.theme?.inheritFromBranding !== false) { try { brandingPreference = await getBrandingPreference( { baseUrl: config?.baseUrl as string, - locale: 'en-US', + locale: config?.preferences?.i18n?.language ?? 'en-US', name: config.applicationId || config.organizationHandle, type: config.applicationId ? 'APP' : 'ORG', }, sessionId, ); } catch (error) { - // eslint-disable-next-line no-console - console.warn('[AsgardeoServerProvider] Failed to fetch branding preference:', error); + logger.warn('[AsgardeoServerProvider] Failed to fetch branding preference:', error?.toString()); } } diff --git a/packages/nextjs/src/server/actions/__tests__/getBrandingPreference.test.ts b/packages/nextjs/src/server/actions/__tests__/getBrandingPreference.test.ts index 0e1fab00d..b30636167 100644 --- a/packages/nextjs/src/server/actions/__tests__/getBrandingPreference.test.ts +++ b/packages/nextjs/src/server/actions/__tests__/getBrandingPreference.test.ts @@ -21,6 +21,7 @@ import {AsgardeoAPIError, getBrandingPreference as baseGetBrandingPreference} fr import {describe, it, expect, vi, beforeEach, afterEach, type Mock} from 'vitest'; // Now import SUT and mocked exports +import {clearBrandingPreferenceCache} from '../../../utils/brandingPreferenceCache'; import getBrandingPreference from '../getBrandingPreference'; // Mock the upstream module first. Keep all dependencies inside the factory. @@ -62,6 +63,7 @@ describe('getBrandingPreference (Next.js server action)', () => { beforeEach(() => { vi.resetAllMocks(); + clearBrandingPreferenceCache(); (baseGetBrandingPreference as unknown as Mock).mockResolvedValue(mockPref); }); @@ -82,6 +84,16 @@ describe('getBrandingPreference (Next.js server action)', () => { expect(result).toBe(mockPref); }); + it('should serve the cached preference for the same base URL, type, name and locale', async () => { + const config: Cfg = {baseUrl: 'https://api.asgardeo.io/t/acme', locale: 'en-US', name: 'app-1', type: 'APP'}; + + await getBrandingPreference(config); + await getBrandingPreference({...config}); + await getBrandingPreference({...config, locale: 'fr-FR'}); + + expect(baseGetBrandingPreference).toHaveBeenCalledTimes(2); + }); + it('should wrap an AsgardeoAPIError from upstream, preserving statusCode', async () => { const upstream: AsgardeoAPIError = new AsgardeoAPIError('Not found', 'BRAND_404', 'server', 404); (baseGetBrandingPreference as unknown as Mock).mockRejectedValueOnce(upstream); diff --git a/packages/nextjs/src/server/actions/getBrandingPreference.ts b/packages/nextjs/src/server/actions/getBrandingPreference.ts index 45ea40d0b..839f0234f 100644 --- a/packages/nextjs/src/server/actions/getBrandingPreference.ts +++ b/packages/nextjs/src/server/actions/getBrandingPreference.ts @@ -24,18 +24,22 @@ import { BrandingPreference, getBrandingPreference as baseGetBrandingPreference, } from '@asgardeo/node'; +import {withBrandingPreferenceCache} from '../../utils/brandingPreferenceCache'; /** * Server action to get branding preferences. + * + * The result is cached in memory for a few minutes per base URL, type, name and locale: the provider needs + * it on every server render and it changes rarely. */ const getBrandingPreference = async ( config: GetBrandingPreferenceConfig, sessionId?: string | undefined, // eslint-disable-line @typescript-eslint/no-unused-vars ): Promise => { try { - const brandingPreference: BrandingPreference = await baseGetBrandingPreference(config); + const cacheKey: string = [config.baseUrl, config.type ?? '', config.name ?? '', config.locale ?? ''].join('|'); - return brandingPreference; + return await withBrandingPreferenceCache(cacheKey, () => baseGetBrandingPreference(config)); } catch (error) { throw new AsgardeoAPIError( `Failed to get branding preferences: ${error instanceof Error ? error.message : String(error)}`, diff --git a/packages/nextjs/src/utils/__tests__/brandingPreferenceCache.test.ts b/packages/nextjs/src/utils/__tests__/brandingPreferenceCache.test.ts new file mode 100644 index 000000000..1e954f88a --- /dev/null +++ b/packages/nextjs/src/utils/__tests__/brandingPreferenceCache.test.ts @@ -0,0 +1,79 @@ +/** + * 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 {afterEach, beforeEach, describe, expect, it, vi, Mock} from 'vitest'; +import { + BRANDING_PREFERENCE_CACHE_TTL_MS, + clearBrandingPreferenceCache, + withBrandingPreferenceCache, +} from '../brandingPreferenceCache'; + +describe('withBrandingPreferenceCache', () => { + beforeEach(() => { + vi.useFakeTimers(); + vi.setSystemTime(new Date('2026-09-06T00:00:00Z')); + clearBrandingPreferenceCache(); + }); + + afterEach(() => { + vi.useRealTimers(); + }); + + it('loads once per key within the TTL and shares the in-flight request', async () => { + const load: Mock = vi.fn().mockResolvedValue({theme: 'light'}); + + const [first, second] = await Promise.all([ + withBrandingPreferenceCache('acme|APP|app-1|en-US', load), + withBrandingPreferenceCache('acme|APP|app-1|en-US', load), + ]); + const third: unknown = await withBrandingPreferenceCache('acme|APP|app-1|en-US', load); + + expect(load).toHaveBeenCalledTimes(1); + expect(first).toEqual({theme: 'light'}); + expect(second).toBe(first); + expect(third).toBe(first); + }); + + it('loads again once the TTL has elapsed', async () => { + const load: Mock = vi.fn().mockResolvedValue({theme: 'light'}); + + await withBrandingPreferenceCache('key', load); + vi.advanceTimersByTime(BRANDING_PREFERENCE_CACHE_TTL_MS + 1); + await withBrandingPreferenceCache('key', load); + + expect(load).toHaveBeenCalledTimes(2); + }); + + it('keeps different keys apart', async () => { + const load: Mock = vi.fn().mockResolvedValue({theme: 'light'}); + + await withBrandingPreferenceCache('acme|APP|app-1|en-US', load); + await withBrandingPreferenceCache('acme|APP|app-1|fr-FR', load); + + expect(load).toHaveBeenCalledTimes(2); + }); + + it('does not cache a failed load', async () => { + const load: Mock = vi.fn().mockRejectedValueOnce(new Error('unavailable')).mockResolvedValue({theme: 'light'}); + + await expect(withBrandingPreferenceCache('key', load)).rejects.toThrow('unavailable'); + await expect(withBrandingPreferenceCache('key', load)).resolves.toEqual({theme: 'light'}); + + expect(load).toHaveBeenCalledTimes(2); + }); +}); diff --git a/packages/nextjs/src/utils/brandingPreferenceCache.ts b/packages/nextjs/src/utils/brandingPreferenceCache.ts new file mode 100644 index 000000000..11e78f31e --- /dev/null +++ b/packages/nextjs/src/utils/brandingPreferenceCache.ts @@ -0,0 +1,66 @@ +/** + * 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. + */ + +/** + * How long a branding preference is served from memory before it is fetched again. + */ +export const BRANDING_PREFERENCE_CACHE_TTL_MS: number = 5 * 60 * 1000; + +interface CacheEntry { + expiresAt: number; + value: Promise; +} + +const cache: Map> = new Map(); + +/** + * Serves `load()` from an in-memory cache for `ttlMs`. Concurrent callers share the same in-flight request, + * and a failed load is not cached so the next caller retries. + * + * The server provider needs the branding preference on every render, and it changes rarely, so fetching it + * from the identity server on every request is wasted latency. + */ +export const withBrandingPreferenceCache = async ( + key: string, + load: () => Promise, + ttlMs: number = BRANDING_PREFERENCE_CACHE_TTL_MS, +): Promise => { + const now: number = Date.now(); + const existing: CacheEntry | undefined = cache.get(key) as CacheEntry | undefined; + + if (existing && existing.expiresAt > now) { + return existing.value; + } + + const value: Promise = load().catch((error: unknown) => { + cache.delete(key); + + throw error; + }); + + cache.set(key, {expiresAt: now + ttlMs, value}); + + return value; +}; + +/** + * Empties the cache (used by tests and after configuration changes). + */ +export const clearBrandingPreferenceCache = (): void => { + cache.clear(); +};