diff --git a/.changeset/shy-baboons-occur.md b/.changeset/shy-baboons-occur.md new file mode 100644 index 00000000000..2ce015d70ac --- /dev/null +++ b/.changeset/shy-baboons-occur.md @@ -0,0 +1,13 @@ +--- +"@logto/experience": minor +"@logto/schemas": minor +"@logto/core": minor +"@logto/integration-tests": patch +--- + +support experience data server-side rendering + +Logto now injects the sign-in experience settings and phrases into the `index.html` file for better first-screen performance. The experience app will still fetch the settings and phrases from the server if: + +- The server didn't inject the settings and phrases. +- The parameters in the URL are different from server-rendered data. diff --git a/packages/core/src/middleware/koa-experience-ssr.test.ts b/packages/core/src/middleware/koa-experience-ssr.test.ts new file mode 100644 index 00000000000..44c17227fcf --- /dev/null +++ b/packages/core/src/middleware/koa-experience-ssr.test.ts @@ -0,0 +1,81 @@ +import { ssrPlaceholder } from '@logto/schemas'; + +import { mockSignInExperience } from '#src/__mocks__/sign-in-experience.js'; +import { MockTenant } from '#src/test-utils/tenant.js'; +import { createContextWithRouteParameters } from '#src/utils/test-utils.js'; + +import koaExperienceSsr from './koa-experience-ssr.js'; + +const { jest } = import.meta; + +describe('koaExperienceSsr()', () => { + const phrases = { foo: 'bar' }; + const baseCtx = Object.freeze({ + ...createContextWithRouteParameters({}), + locale: 'en', + query: {}, + set: jest.fn(), + }); + const tenant = new MockTenant( + undefined, + { + customPhrases: { + findAllCustomLanguageTags: jest.fn().mockResolvedValue([]), + }, + }, + undefined, + { + signInExperiences: { + getFullSignInExperience: jest.fn().mockResolvedValue(mockSignInExperience), + }, + phrases: { getPhrases: jest.fn().mockResolvedValue(phrases) }, + } + ); + + const next = jest.fn().mockReturnValue(Promise.resolve()); + + afterEach(() => { + jest.clearAllMocks(); + }); + + it('should call next() and do nothing if the response body is not a string', async () => { + const symbol = Symbol('nothing'); + const ctx = { ...baseCtx, body: symbol }; + await koaExperienceSsr(tenant.libraries, tenant.queries)(ctx, next); + expect(next).toHaveBeenCalledTimes(1); + expect(ctx.body).toBe(symbol); + }); + + it('should call next() and do nothing if the request path is not an index path', async () => { + const ctx = { ...baseCtx, path: '/foo', body: '...' }; + await koaExperienceSsr(tenant.libraries, tenant.queries)(ctx, next); + expect(next).toHaveBeenCalledTimes(1); + expect(ctx.body).toBe('...'); + }); + + it('should call next() and do nothing if the required placeholders are not present', async () => { + const ctx = { ...baseCtx, path: '/', body: '...' }; + await koaExperienceSsr(tenant.libraries, tenant.queries)(ctx, next); + expect(next).toHaveBeenCalledTimes(1); + expect(ctx.body).toBe('...'); + }); + + it('should prefetch the experience data and inject it into the HTML response', async () => { + const ctx = { + ...baseCtx, + path: '/', + body: ``, + }; + await koaExperienceSsr(tenant.libraries, tenant.queries)(ctx, next); + expect(next).toHaveBeenCalledTimes(1); + expect(ctx.body).not.toContain(ssrPlaceholder); + expect(ctx.body).toContain( + `const logtoSsr=Object.freeze(${JSON.stringify({ + signInExperience: { data: mockSignInExperience }, + phrases: { lng: 'en', data: phrases }, + })});` + ); + }); +}); diff --git a/packages/core/src/middleware/koa-experience-ssr.ts b/packages/core/src/middleware/koa-experience-ssr.ts new file mode 100644 index 00000000000..1ddab180399 --- /dev/null +++ b/packages/core/src/middleware/koa-experience-ssr.ts @@ -0,0 +1,67 @@ +import { type SsrData, logtoCookieKey, logtoUiCookieGuard, ssrPlaceholder } from '@logto/schemas'; +import { pick, trySafe } from '@silverhand/essentials'; +import type { MiddlewareType } from 'koa'; + +import type Libraries from '#src/tenants/Libraries.js'; +import type Queries from '#src/tenants/Queries.js'; +import { getExperienceLanguage } from '#src/utils/i18n.js'; + +import { type WithI18nContext } from './koa-i18next.js'; +import { isIndexPath } from './koa-serve-static.js'; + +/** + * Create a middleware to prefetch the experience data and inject it into the HTML response. Some + * conditions must be met: + * + * - The response body should be a string after the middleware chain (calling `next()`). + * - The request path should be an index path. + * - The SSR placeholder string ({@link ssrPlaceholder}) should be present in the response body. + * + * Otherwise, the middleware will do nothing. + */ +export default function koaExperienceSsr( + libraries: Libraries, + queries: Queries +): MiddlewareType { + return async (ctx, next) => { + await next(); + + if ( + !(typeof ctx.body === 'string' && isIndexPath(ctx.path)) || + !ctx.body.includes(ssrPlaceholder) + ) { + return; + } + + const logtoUiCookie = + trySafe(() => + logtoUiCookieGuard.parse(JSON.parse(ctx.cookies.get(logtoCookieKey) ?? '{}')) + ) ?? {}; + + const [signInExperience, customLanguages] = await Promise.all([ + libraries.signInExperiences.getFullSignInExperience({ + locale: ctx.locale, + ...logtoUiCookie, + }), + queries.customPhrases.findAllCustomLanguageTags(), + ]); + const language = getExperienceLanguage({ + ctx, + languageInfo: signInExperience.languageInfo, + customLanguages, + }); + const phrases = await libraries.phrases.getPhrases(language); + + ctx.set('Content-Language', language); + ctx.body = ctx.body.replace( + ssrPlaceholder, + `Object.freeze(${JSON.stringify({ + signInExperience: { + ...pick(logtoUiCookie, 'appId', 'organizationId'), + data: signInExperience, + }, + phrases: { lng: language, data: phrases }, + } satisfies SsrData)})` + ); + }; +} diff --git a/packages/core/src/middleware/koa-serve-static.ts b/packages/core/src/middleware/koa-serve-static.ts index b6e6b9a95c1..0ce53b25c82 100644 --- a/packages/core/src/middleware/koa-serve-static.ts +++ b/packages/core/src/middleware/koa-serve-static.ts @@ -1,5 +1,6 @@ // Modified from https://github.com/koajs/static/blob/7f0ed88c8902e441da4e30b42f108617d8dff9ec/index.js +import fs from 'node:fs/promises'; import path from 'node:path'; import type { MiddlewareType } from 'koa'; @@ -8,8 +9,11 @@ import send from 'koa-send'; import assertThat from '#src/utils/assert-that.js'; const index = 'index.html'; +const indexContentType = 'text/html; charset=utf-8'; +export const isIndexPath = (path: string) => + ['/', `/${index}`].some((value) => path.endsWith(value)); -export default function serve(root: string) { +export default function koaServeStatic(root: string) { assertThat(root, new Error('Root directory is required to serve files.')); const options: send.SendOptions = { @@ -19,19 +23,19 @@ export default function serve(root: string) { const serve: MiddlewareType = async (ctx, next) => { if (ctx.method === 'HEAD' || ctx.method === 'GET') { - const filePath = await send(ctx, ctx.path, { - ...options, - // eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing - ...(!['/', `/${options.index || ''}`].some((path) => ctx.path.endsWith(path)) && { - maxage: 604_800_000 /* 7 days */, - }), - }); - - const filename = path.basename(filePath); - - // No cache for the index file - if (filename === index || filename.startsWith(index + '.')) { + // Directly read and set the content of the index file since we need to replace the + // placeholders in the file with the actual values. It should be OK as the index file is + // small. + if (isIndexPath(ctx.path)) { + const content = await fs.readFile(path.join(root, index), 'utf8'); + ctx.type = indexContentType; + ctx.body = content; ctx.set('Cache-Control', 'no-cache, no-store, must-revalidate'); + } else { + await send(ctx, ctx.path, { + ...options, + maxage: 604_800_000 /* 7 days */, + }); } } diff --git a/packages/core/src/oidc/init.ts b/packages/core/src/oidc/init.ts index 590f6488d76..5ec7474ab56 100644 --- a/packages/core/src/oidc/init.ts +++ b/packages/core/src/oidc/init.ts @@ -15,7 +15,7 @@ import { type LogtoUiCookie, ExtraParamsKey, } from '@logto/schemas'; -import { conditional, trySafe, tryThat } from '@silverhand/essentials'; +import { removeUndefinedKeys, trySafe, tryThat } from '@silverhand/essentials'; import i18next from 'i18next'; import { koaBody } from 'koa-body'; import Provider, { errors } from 'oidc-provider'; @@ -198,17 +198,20 @@ export default function initOidc( }, interactions: { url: (ctx, { params: { client_id: appId }, prompt }) => { - // @deprecated use search params instead + const params = trySafe(() => extraParamsObjectGuard.parse(ctx.oidc.params ?? {})) ?? {}; + + // Cookies are required to apply the correct server-side rendering ctx.cookies.set( logtoCookieKey, - JSON.stringify({ - appId: conditional(Boolean(appId) && String(appId)), - } satisfies LogtoUiCookie), + JSON.stringify( + removeUndefinedKeys({ + appId: typeof appId === 'string' ? appId : undefined, + organizationId: params.organization_id, + }) satisfies LogtoUiCookie + ), { sameSite: 'lax', overwrite: true, httpOnly: false } ); - const params = trySafe(() => extraParamsObjectGuard.parse(ctx.oidc.params ?? {})) ?? {}; - switch (prompt.name) { case 'login': { return '/' + buildLoginPromptUrl(params, appId); diff --git a/packages/core/src/oidc/utils.ts b/packages/core/src/oidc/utils.ts index 276b56bb134..f25a4c323f6 100644 --- a/packages/core/src/oidc/utils.ts +++ b/packages/core/src/oidc/utils.ts @@ -94,17 +94,15 @@ export const buildLoginPromptUrl = (params: ExtraParamsObject, appId?: unknown): searchParams.append('app_id', String(appId)); } + if (params[ExtraParamsKey.OrganizationId]) { + searchParams.append(ExtraParamsKey.OrganizationId, params[ExtraParamsKey.OrganizationId]); + } + if (directSignIn) { searchParams.append('fallback', firstScreen); const [method, target] = directSignIn.split(':'); return path.join('direct', method ?? '', target ?? '') + getSearchParamString(); } - // Append other valid params as-is - const { first_screen: _, interaction_mode: __, direct_sign_in: ___, ...rest } = params; - for (const [key, value] of Object.entries(rest)) { - searchParams.append(key, value); - } - return firstScreen + getSearchParamString(); }; diff --git a/packages/core/src/routes/well-known.ts b/packages/core/src/routes/well-known.ts index 3738c0f742b..416d4b451a3 100644 --- a/packages/core/src/routes/well-known.ts +++ b/packages/core/src/routes/well-known.ts @@ -1,11 +1,9 @@ -import { isBuiltInLanguageTag } from '@logto/phrases-experience'; -import { adminTenantId, guardFullSignInExperience } from '@logto/schemas'; -import { conditionalArray } from '@silverhand/essentials'; +import { adminTenantId, fullSignInExperienceGuard } from '@logto/schemas'; import { z } from 'zod'; import { EnvSet, getTenantEndpoint } from '#src/env-set/index.js'; -import detectLanguage from '#src/i18n/detect-language.js'; import koaGuard from '#src/middleware/koa-guard.js'; +import { getExperienceLanguage } from '#src/utils/i18n.js'; import type { AnonymousRouter, RouterInitArgs } from './types.js'; @@ -43,7 +41,7 @@ export default function wellKnownRoutes( '/.well-known/sign-in-exp', koaGuard({ query: z.object({ organizationId: z.string(), appId: z.string() }).partial(), - response: guardFullSignInExperience, + response: fullSignInExperienceGuard, status: 200, }), async (ctx, next) => { @@ -68,20 +66,9 @@ export default function wellKnownRoutes( query: { lng }, } = ctx.guard; - const { - languageInfo: { autoDetect, fallbackLanguage }, - } = await findDefaultSignInExperience(); - - const acceptableLanguages = conditionalArray( - lng, - autoDetect && detectLanguage(ctx), - fallbackLanguage - ); + const { languageInfo } = await findDefaultSignInExperience(); const customLanguages = await findAllCustomLanguageTags(); - const language = - acceptableLanguages.find( - (tag) => isBuiltInLanguageTag(tag) || customLanguages.includes(tag) - ) ?? 'en'; + const language = getExperienceLanguage({ ctx, languageInfo, customLanguages, lng }); ctx.set('Content-Language', language); ctx.body = await getPhrases(language); diff --git a/packages/core/src/tenants/Tenant.ts b/packages/core/src/tenants/Tenant.ts index 8d082f7fca0..092cfdeb77b 100644 --- a/packages/core/src/tenants/Tenant.ts +++ b/packages/core/src/tenants/Tenant.ts @@ -17,6 +17,7 @@ import koaAutoConsent from '#src/middleware/koa-auto-consent.js'; import koaConnectorErrorHandler from '#src/middleware/koa-connector-error-handler.js'; import koaConsoleRedirectProxy from '#src/middleware/koa-console-redirect-proxy.js'; import koaErrorHandler from '#src/middleware/koa-error-handler.js'; +import koaExperienceSsr from '#src/middleware/koa-experience-ssr.js'; import koaI18next from '#src/middleware/koa-i18next.js'; import koaOidcErrorHandler from '#src/middleware/koa-oidc-error-handler.js'; import koaSecurityHeaders from '#src/middleware/koa-security-headers.js'; @@ -166,9 +167,10 @@ export default class Tenant implements TenantContext { ); } - // Mount UI + // Mount experience app app.use( compose([ + koaExperienceSsr(libraries, queries), koaSpaSessionGuard(provider, queries), mount(`/${experience.routes.consent}`, koaAutoConsent(provider, queries)), koaSpaProxy(mountedApps), diff --git a/packages/core/src/utils/i18n.ts b/packages/core/src/utils/i18n.ts index b240ece7096..a900635db25 100644 --- a/packages/core/src/utils/i18n.ts +++ b/packages/core/src/utils/i18n.ts @@ -1,7 +1,38 @@ +import { isBuiltInLanguageTag } from '@logto/phrases-experience'; +import { type SignInExperience } from '@logto/schemas'; +import { conditionalArray } from '@silverhand/essentials'; import type { i18n } from 'i18next'; import _i18next from 'i18next'; +import { type ParameterizedContext } from 'koa'; +import { type IRouterParamContext } from 'koa-router'; + +import detectLanguage from '#src/i18n/detect-language.js'; // This may be fixed by a cjs require wrapper. TBD. // See https://github.com/microsoft/TypeScript/issues/49189 // eslint-disable-next-line no-restricted-syntax export const i18next = _i18next as unknown as i18n; + +type GetExperienceLanguage = { + ctx: ParameterizedContext; + languageInfo: SignInExperience['languageInfo']; + customLanguages: readonly string[]; + lng?: string; +}; + +export const getExperienceLanguage = ({ + ctx, + languageInfo: { autoDetect, fallbackLanguage }, + customLanguages, + lng, +}: GetExperienceLanguage) => { + const acceptableLanguages = conditionalArray( + lng, + autoDetect && detectLanguage(ctx), + fallbackLanguage + ); + const language = + acceptableLanguages.find((tag) => isBuiltInLanguageTag(tag) || customLanguages.includes(tag)) ?? + 'en'; + return language; +}; diff --git a/packages/experience/src/i18n/utils.ts b/packages/experience/src/i18n/utils.ts index de12fb7b9f3..82bbc432f06 100644 --- a/packages/experience/src/i18n/utils.ts +++ b/packages/experience/src/i18n/utils.ts @@ -1,31 +1,40 @@ import type { LocalePhrase } from '@logto/phrases-experience'; import resource from '@logto/phrases-experience'; import type { LanguageInfo } from '@logto/schemas'; +import { isObject } from '@silverhand/essentials'; import type { Resource } from 'i18next'; import i18next from 'i18next'; import LanguageDetector from 'i18next-browser-languagedetector'; -import { getPhrases } from '@/apis/settings'; +import { getPhrases as getPhrasesApi } from '@/apis/settings'; + +const getPhrases = async (language?: string) => { + // Directly use the server-side phrases if it's already fetched + if (isObject(logtoSsr) && (!language || logtoSsr.phrases.lng === language)) { + return { phrases: logtoSsr.phrases.data, lng: logtoSsr.phrases.lng }; + } -export const getI18nResource = async ( - language?: string -): Promise<{ resources: Resource; lng: string }> => { const detectedLanguage = detectLanguage(); + const response = await getPhrasesApi({ + localLanguage: Array.isArray(detectedLanguage) ? detectedLanguage.join(' ') : detectedLanguage, + language, + }); - try { - const response = await getPhrases({ - localLanguage: Array.isArray(detectedLanguage) - ? detectedLanguage.join(' ') - : detectedLanguage, - language, - }); + const remotePhrases = await response.json(); + const lng = response.headers.get('Content-Language'); + + if (!lng) { + throw new Error('lng not found'); + } - const phrases = await response.json(); - const lng = response.headers.get('Content-Language'); + return { phrases: remotePhrases, lng }; +}; - if (!lng) { - throw new Error('lng not found'); - } +export const getI18nResource = async ( + language?: string +): Promise<{ resources: Resource; lng: string }> => { + try { + const { phrases, lng } = await getPhrases(language); return { resources: { [lng]: phrases }, diff --git a/packages/experience/src/include.d/global.d.ts b/packages/experience/src/include.d/global.d.ts index ce00e0c80ab..48994b57693 100644 --- a/packages/experience/src/include.d/global.d.ts +++ b/packages/experience/src/include.d/global.d.ts @@ -1,4 +1,4 @@ -// Logto Native SDK +import { type SsrData } from '@logto/schemas'; type LogtoNativeSdkInfo = { platform: 'ios' | 'android'; @@ -10,4 +10,14 @@ type LogtoNativeSdkInfo = { }; }; -declare const logtoNativeSdk: LogtoNativeSdkInfo | undefined; +type LogtoSsr = string | Readonly | undefined; + +declare global { + const logtoNativeSdk: LogtoNativeSdkInfo | undefined; + const logtoSsr: LogtoSsr; + + interface Window { + logtoNativeSdk: LogtoNativeSdkInfo | undefined; + logtoSsr: LogtoSsr; + } +} diff --git a/packages/experience/src/index.html b/packages/experience/src/index.html index e317d14155a..8b5cb68dc51 100644 --- a/packages/experience/src/index.html +++ b/packages/experience/src/index.html @@ -5,21 +5,9 @@ - diff --git a/packages/experience/src/jest.setup.ts b/packages/experience/src/jest.setup.ts index bc92589ba52..3206801a8a2 100644 --- a/packages/experience/src/jest.setup.ts +++ b/packages/experience/src/jest.setup.ts @@ -1,4 +1,5 @@ import { type LocalePhrase } from '@logto/phrases-experience'; +import { ssrPlaceholder } from '@logto/schemas'; import { type DeepPartial } from '@silverhand/essentials'; import i18next from 'i18next'; import { initReactI18next } from 'react-i18next'; @@ -18,3 +19,6 @@ export const setupI18nForTesting = async ( }); void setupI18nForTesting(); + +// eslint-disable-next-line @silverhand/fp/no-mutating-methods +Object.defineProperty(global, 'logtoSsr', { value: ssrPlaceholder }); diff --git a/packages/experience/src/utils/search-parameters.ts b/packages/experience/src/utils/search-parameters.ts index 4c1328cfbf2..07b779cf007 100644 --- a/packages/experience/src/utils/search-parameters.ts +++ b/packages/experience/src/utils/search-parameters.ts @@ -1,5 +1,9 @@ import { condString } from '@silverhand/essentials'; +export const searchKeysCamelCase = Object.freeze(['organizationId', 'appId'] as const); + +type SearchKeysCamelCase = (typeof searchKeysCamelCase)[number]; + export const searchKeys = Object.freeze({ /** * The key for specifying the organization ID that may be used to override the default settings. @@ -7,7 +11,7 @@ export const searchKeys = Object.freeze({ organizationId: 'organization_id', /** The current application ID. */ appId: 'app_id', -}); +} satisfies Record); export const handleSearchParametersData = () => { const { search } = window.location; diff --git a/packages/experience/src/utils/sign-in-experience.ts b/packages/experience/src/utils/sign-in-experience.ts index 3175202ee61..6540c54fafd 100644 --- a/packages/experience/src/utils/sign-in-experience.ts +++ b/packages/experience/src/utils/sign-in-experience.ts @@ -4,12 +4,15 @@ */ import { SignInIdentifier } from '@logto/schemas'; +import { isObject } from '@silverhand/essentials'; import i18next from 'i18next'; import { getSignInExperience } from '@/apis/settings'; import type { SignInExperienceResponse } from '@/types'; import { filterSocialConnectors } from '@/utils/social-connectors'; +import { searchKeys, searchKeysCamelCase } from './search-parameters'; + const parseSignInExperienceResponse = ( response: SignInExperienceResponse ): SignInExperienceResponse => { @@ -22,8 +25,20 @@ const parseSignInExperienceResponse = ( }; export const getSignInExperienceSettings = async (): Promise => { - const response = await getSignInExperience(); + if (isObject(logtoSsr)) { + const { data, ...rest } = logtoSsr.signInExperience; + if ( + searchKeysCamelCase.every((key) => { + const ssrValue = rest[key]; + const storageValue = sessionStorage.getItem(searchKeys[key]) ?? undefined; + return (!ssrValue && !storageValue) || ssrValue === storageValue; + }) + ) { + return data; + } + } + const response = await getSignInExperience(); return parseSignInExperienceResponse(response); }; diff --git a/packages/integration-tests/src/include.d/global.d.ts b/packages/integration-tests/src/include.d/global.d.ts new file mode 100644 index 00000000000..27e85338e69 --- /dev/null +++ b/packages/integration-tests/src/include.d/global.d.ts @@ -0,0 +1,4 @@ +interface Window { + /** The SSR object for **experience**. */ + logtoSsr: unknown; +} diff --git a/packages/integration-tests/src/tests/experience/server-side-rendering.test.ts b/packages/integration-tests/src/tests/experience/server-side-rendering.test.ts new file mode 100644 index 00000000000..265c76713eb --- /dev/null +++ b/packages/integration-tests/src/tests/experience/server-side-rendering.test.ts @@ -0,0 +1,162 @@ +import fs from 'node:fs/promises'; +import os from 'node:os'; +import path from 'node:path'; + +import { demoAppApplicationId, fullSignInExperienceGuard } from '@logto/schemas'; +import { type Page } from 'puppeteer'; +import { z } from 'zod'; + +import { demoAppUrl } from '#src/constants.js'; +import { OrganizationApiTest } from '#src/helpers/organization.js'; +import ExpectExperience from '#src/ui-helpers/expect-experience.js'; + +const ssrDataGuard = z.object({ + signInExperience: z.object({ + appId: z.string().optional(), + organizationId: z.string().optional(), + data: fullSignInExperienceGuard, + }), + phrases: z.object({ + lng: z.string(), + data: z.record(z.unknown()), + }), +}); + +class Trace { + protected tracePath?: string; + + constructor(protected page?: Page) {} + + async start() { + if (this.tracePath) { + throw new Error('Trace already started'); + } + + if (!this.page) { + throw new Error('Page not set'); + } + + const traceDirectory = await fs.mkdtemp(path.join(os.tmpdir(), 'trace-')); + this.tracePath = path.join(traceDirectory, 'trace.json'); + await this.page.tracing.start({ path: this.tracePath, categories: ['devtools.timeline'] }); + } + + async stop() { + if (!this.page) { + throw new Error('Page not set'); + } + + return this.page.tracing.stop(); + } + + async read() { + if (!this.tracePath) { + throw new Error('Trace not started'); + } + + return JSON.parse(await fs.readFile(this.tracePath, 'utf8')); + } + + reset(page: Page) { + this.page = page; + this.tracePath = undefined; + } + + async cleanup() { + if (this.tracePath) { + await fs.unlink(this.tracePath); + } + } +} + +describe('server-side rendering', () => { + const trace = new Trace(); + const expectTraceNotToHaveWellKnownEndpoints = async () => { + /* eslint-disable @typescript-eslint/no-unsafe-assignment */ + const traceData: { traceEvents: unknown[] } = await trace.read(); + expect(traceData.traceEvents).not.toContainEqual( + expect.objectContaining({ + args: expect.objectContaining({ + data: expect.objectContaining({ url: expect.stringContaining('api/.well-known/') }), + }), + }) + ); + /* eslint-enable @typescript-eslint/no-unsafe-assignment */ + }; + + afterEach(async () => { + await trace.cleanup(); + }); + + it('should render the page with data from the server and not request the well-known endpoints', async () => { + const experience = new ExpectExperience(await browser.newPage()); + + trace.reset(experience.page); + await trace.start(); + await experience.navigateTo(demoAppUrl.href); + await trace.stop(); + + // Check page variables + const data = await experience.page.evaluate(() => { + return window.logtoSsr; + }); + + const parsed = ssrDataGuard.parse(data); + + expect(parsed.signInExperience.appId).toBe(demoAppApplicationId); + expect(parsed.signInExperience.organizationId).toBeUndefined(); + + // Check network requests + await expectTraceNotToHaveWellKnownEndpoints(); + }); + + it('should render the page with data from the server with invalid organization ID', async () => { + const experience = new ExpectExperience(await browser.newPage()); + + trace.reset(experience.page); + await trace.start(); + // Although the organization ID is invalid, the server should still render the page with the + // ID provided which indicates the result under the given parameters. + await experience.navigateTo(`${demoAppUrl.href}?organization_id=org-id`); + await trace.stop(); + + // Check page variables + const data = await experience.page.evaluate(() => { + return window.logtoSsr; + }); + + const parsed = ssrDataGuard.parse(data); + + expect(parsed.signInExperience.appId).toBe(demoAppApplicationId); + expect(parsed.signInExperience.organizationId).toBe('org-id'); + + // Check network requests + await expectTraceNotToHaveWellKnownEndpoints(); + }); + + it('should render the page with data from the server with valid organization ID', async () => { + const logoUrl = 'mock://fake-url-for-ssr/logo.png'; + const organizationApi = new OrganizationApiTest(); + const organization = await organizationApi.create({ name: 'foo', branding: { logoUrl } }); + const experience = new ExpectExperience(await browser.newPage()); + + trace.reset(experience.page); + await trace.start(); + await experience.navigateTo(`${demoAppUrl.href}?organization_id=${organization.id}`); + await trace.stop(); + + // Check page variables + const data = await experience.page.evaluate(() => { + return window.logtoSsr; + }); + + const parsed = ssrDataGuard.parse(data); + + expect(parsed.signInExperience.appId).toBe(demoAppApplicationId); + expect(parsed.signInExperience.organizationId).toBe(organization.id); + expect(parsed.signInExperience.data.branding.logoUrl).toBe(logoUrl); + + // Check network requests + await expectTraceNotToHaveWellKnownEndpoints(); + }); +}); diff --git a/packages/schemas/src/types/cookie.ts b/packages/schemas/src/types/cookie.ts index 4e76f7bb01b..a155067f136 100644 --- a/packages/schemas/src/types/cookie.ts +++ b/packages/schemas/src/types/cookie.ts @@ -1,5 +1,12 @@ import { z } from 'zod'; -export const logtoUiCookieGuard = z.object({ appId: z.string() }).partial(); +import { type ToZodObject } from '../utils/zod.js'; -export type LogtoUiCookie = z.infer; +export type LogtoUiCookie = Partial<{ + appId: string; + organizationId: string; +}>; + +export const logtoUiCookieGuard = z + .object({ appId: z.string(), organizationId: z.string() }) + .partial() satisfies ToZodObject; diff --git a/packages/schemas/src/types/index.ts b/packages/schemas/src/types/index.ts index d6bfbac974a..6806a3ca338 100644 --- a/packages/schemas/src/types/index.ts +++ b/packages/schemas/src/types/index.ts @@ -29,3 +29,4 @@ export * from './consent.js'; export * from './onboarding.js'; export * from './sign-in-experience.js'; export * from './subject-token.js'; +export * from './ssr.js'; diff --git a/packages/schemas/src/types/sign-in-experience.ts b/packages/schemas/src/types/sign-in-experience.ts index 9f6bf95ebb1..57bb8d2d96d 100644 --- a/packages/schemas/src/types/sign-in-experience.ts +++ b/packages/schemas/src/types/sign-in-experience.ts @@ -41,7 +41,7 @@ export type FullSignInExperience = SignInExperience & { googleOneTap?: GoogleOneTapConfig & { clientId: string; connectorId: string }; }; -export const guardFullSignInExperience = SignInExperiences.guard.extend({ +export const fullSignInExperienceGuard = SignInExperiences.guard.extend({ socialConnectors: connectorMetadataGuard .omit({ description: true, diff --git a/packages/schemas/src/types/ssr.ts b/packages/schemas/src/types/ssr.ts new file mode 100644 index 00000000000..64f1a67c11b --- /dev/null +++ b/packages/schemas/src/types/ssr.ts @@ -0,0 +1,28 @@ +import { type LocalePhrase } from '@logto/phrases-experience'; + +import { type FullSignInExperience } from './sign-in-experience.js'; + +/** + * The server-side rendering data type for **experience**. + */ +export type SsrData = { + signInExperience: { + appId?: string; + organizationId?: string; + data: FullSignInExperience; + }; + phrases: { + lng: string; + data: LocalePhrase; + }; +}; + +/** + * Variable placeholder for **experience** server-side rendering. The value should be replaced by + * the server. + * + * CAUTION: The value should be kept in sync with {@link file://./../../../experience/src/index.html}. + * + * @see {@link SsrData} for the data structure to replace the placeholders. + */ +export const ssrPlaceholder = '"__LOGTO_SSR__"';