0
Fork 0
mirror of https://github.com/logto-io/logto.git synced 2025-03-31 22:51:25 -05:00

refactor: experience ssr (#6229)

* refactor: experience ssr

* refactor: fix parameter issue
This commit is contained in:
Gao Sun 2024-07-12 19:00:36 +08:00 committed by GitHub
parent dcb62d69d4
commit d203c8d2ff
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
21 changed files with 500 additions and 82 deletions

View file

@ -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.

View file

@ -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: `<script>
const logtoSsr=${ssrPlaceholder};
</script>`,
};
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 },
})});`
);
});
});

View file

@ -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<StateT, ContextT extends WithI18nContext>(
libraries: Libraries,
queries: Queries
): MiddlewareType<StateT, ContextT> {
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)})`
);
};
}

View file

@ -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 */,
});
}
}

View file

@ -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);

View file

@ -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();
};

View file

@ -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<T extends AnonymousRouter>(
'/.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<T extends AnonymousRouter>(
query: { lng },
} = ctx.guard;
const {
languageInfo: { autoDetect, fallbackLanguage },
} = await findDefaultSignInExperience();
const acceptableLanguages = conditionalArray<string | string[]>(
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);

View file

@ -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),

View file

@ -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<unknown, IRouterParamContext>;
languageInfo: SignInExperience['languageInfo'];
customLanguages: readonly string[];
lng?: string;
};
export const getExperienceLanguage = ({
ctx,
languageInfo: { autoDetect, fallbackLanguage },
customLanguages,
lng,
}: GetExperienceLanguage) => {
const acceptableLanguages = conditionalArray<string | string[]>(
lng,
autoDetect && detectLanguage(ctx),
fallbackLanguage
);
const language =
acceptableLanguages.find((tag) => isBuiltInLanguageTag(tag) || customLanguages.includes(tag)) ??
'en';
return language;
};

View file

@ -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 };
}
const detectedLanguage = detectLanguage();
const response = await getPhrasesApi({
localLanguage: Array.isArray(detectedLanguage) ? detectedLanguage.join(' ') : detectedLanguage,
language,
});
const remotePhrases = await response.json<LocalePhrase>();
const lng = response.headers.get('Content-Language');
if (!lng) {
throw new Error('lng not found');
}
return { phrases: remotePhrases, lng };
};
export const getI18nResource = async (
language?: string
): Promise<{ resources: Resource; lng: string }> => {
const detectedLanguage = detectLanguage();
try {
const response = await getPhrases({
localLanguage: Array.isArray(detectedLanguage)
? detectedLanguage.join(' ')
: detectedLanguage,
language,
});
const phrases = await response.json<LocalePhrase>();
const lng = response.headers.get('Content-Language');
if (!lng) {
throw new Error('lng not found');
}
const { phrases, lng } = await getPhrases(language);
return {
resources: { [lng]: phrases },

View file

@ -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<SsrData> | undefined;
declare global {
const logtoNativeSdk: LogtoNativeSdkInfo | undefined;
const logtoSsr: LogtoSsr;
interface Window {
logtoNativeSdk: LogtoNativeSdkInfo | undefined;
logtoSsr: LogtoSsr;
}
}

View file

@ -5,21 +5,9 @@
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover">
<title></title>
<!-- Preload well-known APIs -->
<script>
const searchParams = new URLSearchParams(window.location.search);
const isPreview = searchParams.has('preview');
// Preview mode does not query phrases
const preLoadLinks = isPreview ? [] : ['/api/.well-known/phrases'];
preLoadLinks.forEach((linkUrl) => {
const link = document.createElement('link');
link.rel = 'preload';
link.href = linkUrl;
link.as = 'fetch';
link.crossOrigin = 'anonymous';
document.head.appendChild(link);
});
/* See {@link packages/schemas/src/types/ssr.ts} */
window.logtoSsr = "__LOGTO_SSR__";
</script>
</head>

View file

@ -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 });

View file

@ -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<SearchKeysCamelCase, string>);
export const handleSearchParametersData = () => {
const { search } = window.location;

View file

@ -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<SignInExperienceResponse> => {
const response = await getSignInExperience<SignInExperienceResponse>();
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<SignInExperienceResponse>();
return parseSignInExperienceResponse(response);
};

View file

@ -0,0 +1,4 @@
interface Window {
/** The SSR object for **experience**. */
logtoSsr: unknown;
}

View file

@ -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();
});
});

View file

@ -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<typeof logtoUiCookieGuard>;
export type LogtoUiCookie = Partial<{
appId: string;
organizationId: string;
}>;
export const logtoUiCookieGuard = z
.object({ appId: z.string(), organizationId: z.string() })
.partial() satisfies ToZodObject<LogtoUiCookie>;

View file

@ -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';

View file

@ -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,

View file

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