UNPKG

keycloakify

Version:

Framework to create custom Keycloak UIs

395 lines (321 loc) 13.3 kB
import { assert, type Equals, is } from "tsafe/assert"; import { id } from "tsafe/id"; import { z } from "zod"; import { join as pathJoin, dirname as pathDirname, relative as pathRelative, sep as pathSep } from "path"; import * as fsPr from "fs/promises"; import type { BuildContext } from "../shared/buildContext"; import { existsAsync } from "../tools/fs.existsAsync"; import { listInstalledModules } from "../tools/listInstalledModules"; import { crawlAsync } from "../tools/crawlAsync"; import { getIsPrettierAvailable, getPrettier } from "../tools/runPrettier"; import { readThisNpmPackageVersion } from "../tools/readThisNpmPackageVersion"; import { getExtensionModuleFileSourceCodeReadyToBeCopied, type BuildContextLike as BuildContextLike_getExtensionModuleFileSourceCodeReadyToBeCopied } from "./getExtensionModuleFileSourceCodeReadyToBeCopied"; import * as crypto from "crypto"; import { KEYCLOAK_THEME } from "../shared/constants"; import { exclude } from "tsafe/exclude"; import { isAmong } from "tsafe/isAmong"; export type ExtensionModuleMeta = { moduleName: string; version: string; files: { isPublic: boolean; fileRelativePath: string; hash: string; copyableFilePath: string; }[]; peerDependencies: Record<string, string>; }; const zExtensionModuleMeta = (() => { type ExpectedType = ExtensionModuleMeta; const zTargetType = z.object({ moduleName: z.string(), version: z.string(), files: z.array( z.object({ isPublic: z.boolean(), fileRelativePath: z.string(), hash: z.string(), copyableFilePath: z.string() }) ), peerDependencies: z.record(z.string()) }); type InferredType = z.infer<typeof zTargetType>; assert<Equals<InferredType, ExpectedType>>(); return id<z.ZodType<ExpectedType>>(zTargetType); })(); type ParsedCacheFile = { keycloakifyVersion: string; prettierConfigHash: string | null; thisFilePath: string; extensionModuleMetas: ExtensionModuleMeta[]; }; const zParsedCacheFile = (() => { type ExpectedType = ParsedCacheFile; const zTargetType = z.object({ keycloakifyVersion: z.string(), prettierConfigHash: z.union([z.string(), z.null()]), thisFilePath: z.string(), extensionModuleMetas: z.array(zExtensionModuleMeta) }); type InferredType = z.infer<typeof zTargetType>; assert<Equals<InferredType, ExpectedType>>(); return id<z.ZodType<ExpectedType>>(zTargetType); })(); const CACHE_FILE_RELATIVE_PATH = pathJoin("extension-modules", "cache.json"); export type BuildContextLike = BuildContextLike_getExtensionModuleFileSourceCodeReadyToBeCopied & { cacheDirPath: string; packageJsonFilePath: string; projectDirPath: string; }; assert<BuildContext extends BuildContextLike ? true : false>(); export async function getExtensionModuleMetas(params: { buildContext: BuildContextLike; }): Promise<ExtensionModuleMeta[]> { const { buildContext } = params; const cacheFilePath = pathJoin(buildContext.cacheDirPath, CACHE_FILE_RELATIVE_PATH); const keycloakifyVersion = readThisNpmPackageVersion(); const prettierConfigHash = await (async () => { if (!(await getIsPrettierAvailable())) { return null; } const { configHash } = await getPrettier(); return configHash; })(); const installedExtensionModules = await (async () => { let installedExtensionModules = await listInstalledModules({ packageJsonFilePath: buildContext.packageJsonFilePath, filter: ({ moduleName }) => moduleName.includes("keycloakify") && moduleName !== "keycloakify" }); installedExtensionModules = ( await Promise.all( installedExtensionModules.map(async entry => { if (!(await existsAsync(pathJoin(entry.dirPath, KEYCLOAK_THEME)))) { return undefined; } return entry; }) ) ).filter(exclude(undefined)); maybe_exclude_login_ui: { const LOGIN_UI_MODULE_NAME = "@keycloakify/login-ui"; if ( !installedExtensionModules .map(({ moduleName }) => moduleName) .includes(LOGIN_UI_MODULE_NAME) ) { break maybe_exclude_login_ui; } const shouldExcludeLoginUi = await (async () => { for (const entry of installedExtensionModules) { if (entry.moduleName === LOGIN_UI_MODULE_NAME) { continue; } if ( await existsAsync( pathJoin( entry.dirPath, KEYCLOAK_THEME, "login", "pages", "login" ) ) ) { return true; } } return false; })(); if (!shouldExcludeLoginUi) { break maybe_exclude_login_ui; } installedExtensionModules = installedExtensionModules.filter( ({ moduleName }) => moduleName !== LOGIN_UI_MODULE_NAME ); } return installedExtensionModules; })(); const cacheContent = await (async () => { if (!(await existsAsync(cacheFilePath))) { return undefined; } return await fsPr.readFile(cacheFilePath); })(); const extensionModuleMetas_cacheUpToDate: ExtensionModuleMeta[] = await (async () => { const parsedCacheFile: ParsedCacheFile | undefined = await (async () => { if (cacheContent === undefined) { return undefined; } const cacheContentStr = cacheContent.toString("utf8"); let parsedCacheFile: unknown; try { parsedCacheFile = JSON.parse(cacheContentStr); } catch { return undefined; } try { zParsedCacheFile.parse(parsedCacheFile); } catch { return undefined; } assert(is<ParsedCacheFile>(parsedCacheFile)); return parsedCacheFile; })(); if (parsedCacheFile === undefined) { return []; } if (parsedCacheFile.keycloakifyVersion !== keycloakifyVersion) { return []; } if (parsedCacheFile.prettierConfigHash !== prettierConfigHash) { return []; } if (parsedCacheFile.thisFilePath !== cacheFilePath) { return []; } const extensionModuleMetas_cacheUpToDate = parsedCacheFile.extensionModuleMetas.filter(extensionModuleMeta => { const correspondingInstalledExtensionModule = installedExtensionModules.find( installedExtensionModule => installedExtensionModule.moduleName === extensionModuleMeta.moduleName ); if (correspondingInstalledExtensionModule === undefined) { return false; } return ( correspondingInstalledExtensionModule.version === extensionModuleMeta.version ); }); return extensionModuleMetas_cacheUpToDate; })(); const extensionModuleMetas = await Promise.all( [...installedExtensionModules] .sort((a, b) => a.moduleName.localeCompare(b.moduleName)) .map( async ({ moduleName, version, peerDependencies, dirPath }): Promise<ExtensionModuleMeta> => { use_cache: { const extensionModuleMeta_cache = extensionModuleMetas_cacheUpToDate.find( extensionModuleMeta => extensionModuleMeta.moduleName === moduleName ); if (extensionModuleMeta_cache === undefined) { break use_cache; } return extensionModuleMeta_cache; } const files: ExtensionModuleMeta["files"] = []; await crawlAsync({ dirPath: pathJoin(dirPath, KEYCLOAK_THEME), returnedPathsType: "relative to dirPath", onFileFound: async fileRelativePath_fromReservedDir => { const isPublic = fileRelativePath_fromReservedDir.startsWith( `public${pathSep}` ); const fileRelativePath = isPublic ? pathRelative("public", fileRelativePath_fromReservedDir) : fileRelativePath_fromReservedDir; const sourceCode = await getExtensionModuleFileSourceCodeReadyToBeCopied({ buildContext, isPublic, fileRelativePath, isOwnershipAction: false, extensionModuleDirPath: dirPath, extensionModuleName: moduleName, extensionModuleVersion: version }); const hash = computeHash(sourceCode); const copyableFilePath = pathJoin( pathDirname(cacheFilePath), KEYCLOAK_THEME, fileRelativePath_fromReservedDir ); { const dirPath = pathDirname(copyableFilePath); if (!(await existsAsync(dirPath))) { await fsPr.mkdir(dirPath, { recursive: true }); } } fsPr.writeFile(copyableFilePath, sourceCode); files.push({ isPublic, fileRelativePath, hash, copyableFilePath }); } }); { const getId = (file: { isPublic: boolean; fileRelativePath: string; }) => `${file.isPublic ? "public" : "src"} - ${file.fileRelativePath}`; files.sort((a, b) => getId(a).localeCompare(getId(b))); } return id<ExtensionModuleMeta>({ moduleName, version, files, peerDependencies: Object.fromEntries( Object.entries(peerDependencies) .filter( ([moduleName]) => !isAmong(["react", "@types/react"], moduleName) ) .sort(([moduleName_a], [moduleName_b]) => moduleName_a.localeCompare(moduleName_b) ) ) }); } ) ); update_cache: { const parsedCacheFile = id<ParsedCacheFile>({ keycloakifyVersion, prettierConfigHash, thisFilePath: cacheFilePath, extensionModuleMetas }); const cacheContent_new = Buffer.from( JSON.stringify(parsedCacheFile, null, 2), "utf8" ); if (cacheContent !== undefined && cacheContent_new.equals(cacheContent)) { break update_cache; } create_dir: { const dirPath = pathDirname(cacheFilePath); if (await existsAsync(dirPath)) { break create_dir; } await fsPr.mkdir(dirPath, { recursive: true }); } await fsPr.writeFile(cacheFilePath, cacheContent_new); } return extensionModuleMetas; } export function computeHash(data: Buffer) { return crypto.createHash("sha256").update(data).digest("hex"); }