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:
parent
dcb62d69d4
commit
d203c8d2ff
21 changed files with 500 additions and 82 deletions
13
.changeset/shy-baboons-occur.md
Normal file
13
.changeset/shy-baboons-occur.md
Normal 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.
|
81
packages/core/src/middleware/koa-experience-ssr.test.ts
Normal file
81
packages/core/src/middleware/koa-experience-ssr.test.ts
Normal 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 },
|
||||
})});`
|
||||
);
|
||||
});
|
||||
});
|
67
packages/core/src/middleware/koa-experience-ssr.ts
Normal file
67
packages/core/src/middleware/koa-experience-ssr.ts
Normal 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)})`
|
||||
);
|
||||
};
|
||||
}
|
|
@ -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 */,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -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);
|
||||
|
|
|
@ -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();
|
||||
};
|
||||
|
|
|
@ -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);
|
||||
|
|
|
@ -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),
|
||||
|
|
|
@ -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;
|
||||
};
|
||||
|
|
|
@ -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 },
|
||||
|
|
14
packages/experience/src/include.d/global.d.ts
vendored
14
packages/experience/src/include.d/global.d.ts
vendored
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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>
|
||||
|
||||
|
|
|
@ -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 });
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -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);
|
||||
};
|
||||
|
||||
|
|
4
packages/integration-tests/src/include.d/global.d.ts
vendored
Normal file
4
packages/integration-tests/src/include.d/global.d.ts
vendored
Normal file
|
@ -0,0 +1,4 @@
|
|||
interface Window {
|
||||
/** The SSR object for **experience**. */
|
||||
logtoSsr: unknown;
|
||||
}
|
|
@ -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();
|
||||
});
|
||||
});
|
|
@ -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>;
|
||||
|
|
|
@ -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';
|
||||
|
|
|
@ -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,
|
||||
|
|
28
packages/schemas/src/types/ssr.ts
Normal file
28
packages/schemas/src/types/ssr.ts
Normal 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__"';
|
Loading…
Add table
Reference in a new issue