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__"';