Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

refactor: experience ssr #6229

Merged
merged 2 commits into from
Jul 12, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 13 additions & 0 deletions .changeset/shy-baboons-occur.md
Original file line number Diff line number Diff line change
@@ -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 changes: 81 additions & 0 deletions packages/core/src/middleware/koa-experience-ssr.test.ts
Original file line number Diff line number Diff line change
@@ -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 changes: 67 additions & 0 deletions packages/core/src/middleware/koa-experience-ssr.ts
Original file line number Diff line number Diff line change
@@ -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)})`
);
};
}
30 changes: 17 additions & 13 deletions packages/core/src/middleware/koa-serve-static.ts
Original file line number Diff line number Diff line change
@@ -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';
Expand All @@ -8,8 +9,11 @@
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 = {
Expand All @@ -19,19 +23,19 @@

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;

Check warning on line 32 in packages/core/src/middleware/koa-serve-static.ts

View check run for this annotation

Codecov / codecov/patch

packages/core/src/middleware/koa-serve-static.ts#L26-L32

Added lines #L26 - L32 were not covered by tests
ctx.set('Cache-Control', 'no-cache, no-store, must-revalidate');
} else {
await send(ctx, ctx.path, {
...options,
maxage: 604_800_000 /* 7 days */,
});

Check warning on line 38 in packages/core/src/middleware/koa-serve-static.ts

View check run for this annotation

Codecov / codecov/patch

packages/core/src/middleware/koa-serve-static.ts#L34-L38

Added lines #L34 - L38 were not covered by tests
}
}

Expand Down
17 changes: 10 additions & 7 deletions packages/core/src/oidc/init.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@
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';
Expand Down Expand Up @@ -57,7 +57,7 @@
// Temporarily removed 'EdDSA' since it's not supported by browser yet
const supportedSigningAlgs = Object.freeze(['RS256', 'PS256', 'ES256', 'ES384', 'ES512'] as const);

export default function initOidc(

Check warning on line 60 in packages/core/src/oidc/init.ts

View workflow job for this annotation

GitHub Actions / ESLint Report Analysis

packages/core/src/oidc/init.ts#L60

[max-params] Function 'initOidc' has too many parameters (5). Maximum allowed is 4.
envSet: EnvSet,
queries: Queries,
libraries: Libraries,
Expand Down Expand Up @@ -198,17 +198,20 @@
},
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

Check warning on line 203 in packages/core/src/oidc/init.ts

View check run for this annotation

Codecov / codecov/patch

packages/core/src/oidc/init.ts#L201-L203

Added lines #L201 - L203 were not covered by tests
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
),

Check warning on line 211 in packages/core/src/oidc/init.ts

View check run for this annotation

Codecov / codecov/patch

packages/core/src/oidc/init.ts#L206-L211

Added lines #L206 - L211 were not covered by tests
{ sameSite: 'lax', overwrite: true, httpOnly: false }
);

const params = trySafe(() => extraParamsObjectGuard.parse(ctx.oidc.params ?? {})) ?? {};

switch (prompt.name) {
case 'login': {
return '/' + buildLoginPromptUrl(params, appId);
Expand Down
10 changes: 4 additions & 6 deletions packages/core/src/oidc/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -94,17 +94,15 @@
searchParams.append('app_id', String(appId));
}

if (params[ExtraParamsKey.OrganizationId]) {
searchParams.append(ExtraParamsKey.OrganizationId, params[ExtraParamsKey.OrganizationId]);
}

Check warning on line 99 in packages/core/src/oidc/utils.ts

View check run for this annotation

Codecov / codecov/patch

packages/core/src/oidc/utils.ts#L98-L99

Added lines #L98 - L99 were not covered by tests

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();
};
23 changes: 5 additions & 18 deletions packages/core/src/routes/well-known.ts
Original file line number Diff line number Diff line change
@@ -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';

Expand Down Expand Up @@ -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) => {
Expand All @@ -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);
Expand Down
4 changes: 3 additions & 1 deletion packages/core/src/tenants/Tenant.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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),
Expand Down
31 changes: 31 additions & 0 deletions packages/core/src/utils/i18n.ts
Original file line number Diff line number Diff line change
@@ -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;
};
Loading
Loading