import type { OidcSpaVitePluginParams } from "./vite-plugin"; import type { ResolvedConfig } from "vite"; import type { PluginContext } from "rollup"; import { promises as fs, readFileSync, existsSync } from "node:fs"; import * as path from "node:path"; import { assert } from "../tools/tsafe/assert"; import type { Equals } from "../tools/tsafe/Equals"; import type { ProjectType } from "./projectType"; import { resolveCandidate, resolvePackageFile, normalizeAbsolute, splitId, normalizeRequestPath } from "./utils"; type EntryResolution = { absolutePath: string; normalizedPath: string; watchFiles: string[]; }; const ORIGINAL_QUERY_PARAM = "oidc-spa-original"; const REACT_ROUTER_ENTRY_CANDIDATES = [ "entry.client.tsx", "entry.client.ts", "entry.client.jsx", "entry.client.js" ]; const TANSTACK_ENTRY_CANDIDATES = ["client.tsx", "client.ts", "client.jsx", "client.js"]; export function createHandleClientEntrypoint(params: { oidcSpaVitePluginParams: OidcSpaVitePluginParams; resolvedConfig: ResolvedConfig; projectType: ProjectType; }) { const { oidcSpaVitePluginParams, resolvedConfig, projectType } = params; const entryResolution = resolveEntryForProject({ config: resolvedConfig, projectType }); function getClientEntrypointSource(params: { applicationEntrypointImportSpecifier: string; }): string { const { applicationEntrypointImportSpecifier } = params; const { browserRuntimeFreeze, tokenSubstitution, DPoP, sessionRestorationMethod } = oidcSpaVitePluginParams ?? {}; return [ `import { oidcEarlyInit } from "oidc-spa/entrypoint";`, // prettier-ignore !browserRuntimeFreeze ? "" : `import { browserRuntimeFreeze } from "oidc-spa/browser-runtime-freeze";`, // prettier-ignore !DPoP ? "" : `import { DPoP } from "oidc-spa/DPoP";`, // prettier-ignore !tokenSubstitution ? "" : `import { tokenSubstitution } from "oidc-spa/token-substitution";`, `const { shouldLoadApp } = oidcEarlyInit({`, // prettier-ignore `BASE_URL: ${(() => { switch (projectType) { case "nuxt": return "__NUXT__.config.app.baseURL"; default: return `"${resolvedConfig.base}"`; } })()},`, // prettier-ignore sessionRestorationMethod === undefined ? "" : `sessionRestorationMethod: "${sessionRestorationMethod}",`, `securityDefenses: {`, // prettier-ignore !browserRuntimeFreeze ? "" : `...browserRuntimeFreeze(${paramsToJsLiteral(browserRuntimeFreeze)}),`, // prettier-ignore !DPoP ? "" : `...DPoP(${paramsToJsLiteral(DPoP)}),`, // prettier-ignore !tokenSubstitution ? "" : `...tokenSubstitution(${paramsToJsLiteral_tokenSubstitution(tokenSubstitution)})`, `}`, `});`, ``, `if (shouldLoadApp) {`, `import(${JSON.stringify(applicationEntrypointImportSpecifier)});`, `}` ].join("\n"); function paramsToJsLiteral( params: | Exclude | Exclude ): string { const { enabled, ...rest } = params; return JSON.stringify(rest); } function paramsToJsLiteral_tokenSubstitution( params: Exclude ): string { const { enabled, trustedExternalResourceServers = [], trustedExternalServiceWorkerSources = [], ...rest } = params; assert>; const mustacheStrToJsTemplateStringLiteral = (mustacheStr: string) => `\`${mustacheStr.replace(/{{/g, "${").replace(/}}/g, "}")}\``; const arrToJsLiteral = (arr: string[]) => [ "[", `${arr.map(mStr => mustacheStrToJsTemplateStringLiteral(mStr)).join(", ")}`, "]" ].join(" "); return [ "{", `trustedExternalResourceServers: ${arrToJsLiteral(trustedExternalResourceServers)},`, `trustedExternalServiceWorkerSources: ${arrToJsLiteral( trustedExternalServiceWorkerSources )},`, "}" ].join(" "); } } async function load_handleClientEntrypoint(params: { id: string; pluginContext: PluginContext; doUseOriginalEntrypoint: boolean; buildMarker?: string; }): Promise { const { id, pluginContext, doUseOriginalEntrypoint, buildMarker } = params; const { path: rawPath, queryParams } = splitId(id); const normalizedRequestPath = normalizeRequestPath(rawPath); const isMatch = normalizedRequestPath && normalizedRequestPath === entryResolution.normalizedPath; if (!isMatch) { return null; } const isOriginalRequest = doUseOriginalEntrypoint || queryParams.getAll(ORIGINAL_QUERY_PARAM).includes("true"); if (isOriginalRequest) { entryResolution.watchFiles.forEach(file => pluginContext.addWatchFile(file)); const originalSource = await fs.readFile(entryResolution.absolutePath, "utf8"); return buildMarker === undefined ? originalSource : `${buildMarker}\n${originalSource}`; } entryResolution.watchFiles.forEach(file => pluginContext.addWatchFile(file)); return getClientEntrypointSource({ applicationEntrypointImportSpecifier: `./${path.basename( entryResolution.absolutePath )}?${ORIGINAL_QUERY_PARAM}=true` }); } return { load_handleClientEntrypoint, getClientEntrypointSource }; } function resolveEntryForProject({ config, projectType }: { config: ResolvedConfig; projectType: ProjectType; }): EntryResolution { const root = config.root; switch (projectType) { case "tanstack-start": { const candidate = resolveCandidate({ root, subDirectories: ["src"], filenames: TANSTACK_ENTRY_CANDIDATES }); const entryPath = candidate ?? resolvePackageFile("@tanstack/react-start", [ "dist", "plugin", "default-entry", "client.tsx" ]); const normalized = normalizeAbsolute(entryPath); const resolution: EntryResolution = { absolutePath: entryPath, normalizedPath: normalized, watchFiles: candidate ? [entryPath] : [] }; return resolution; } case "react-router-framework": { const candidate = resolveCandidate({ root, subDirectories: ["app"], filenames: REACT_ROUTER_ENTRY_CANDIDATES }); const entryPath = candidate ?? resolvePackageFile("@react-router/dev", [ "dist", "config", "defaults", "entry.client.tsx" ]); const normalized = normalizeAbsolute(entryPath); const resolution: EntryResolution = { absolutePath: entryPath, normalizedPath: normalized, watchFiles: candidate ? [entryPath] : [] }; return resolution; } case "nuxt": { const rollupInput = config.build.rollupOptions?.input; let entryPath: string; if (typeof rollupInput === "string") { entryPath = rollupInput; } else if (Array.isArray(rollupInput)) { assert(rollupInput.length > 0, "Nuxt rollupOptions.input array is empty"); entryPath = rollupInput[0]; } else if (rollupInput && typeof rollupInput === "object") { const inputRecord = rollupInput; assert( Object.keys(inputRecord).length > 0, "Nuxt rollupOptions.input object must contain at least one entry" ); entryPath = Object.values(inputRecord)[0]; } else { throw new Error( "Could not resolve Nuxt entry point from Vite config. " + "rollupOptions.input is undefined or has an unexpected type." ); } // Ensure entryPath is absolute before normalizing. // Nuxt's rollupOptions.input can be relative, but Vite resolves IDs to absolute paths. const absoluteEntryPath = path.isAbsolute(entryPath) ? entryPath : path.resolve(root, entryPath); const normalized = normalizeAbsolute(absoluteEntryPath); const resolution: EntryResolution = { absolutePath: absoluteEntryPath, normalizedPath: normalized, // Nuxt's entry is generated/virtual and managed internally; watching not needed watchFiles: [] }; return resolution; } case "other": { const indexHtmlPath = (() => { const rollupInput = config.build.rollupOptions?.input; const htmlCandidates: string[] = []; const addCandidate = (maybePath: string) => { const candidate = path.isAbsolute(maybePath) ? maybePath : path.resolve(root, maybePath); if (path.extname(candidate).toLowerCase() === ".html") { htmlCandidates.push(candidate); } }; if (typeof rollupInput === "string") { addCandidate(rollupInput); } else if (Array.isArray(rollupInput)) { rollupInput.forEach(addCandidate); } else if (rollupInput && typeof rollupInput === "object") { Object.values(rollupInput).forEach(addCandidate); } if (htmlCandidates.length > 1) { throw new Error( [ "oidc-spa: Multiple HTML inputs detected in Vite configuration.", `Found: ${htmlCandidates.join(", ")}.`, "No worries, if the oidc-spa Vite plugin fails you can still configure the client entrypoint manually.", "Please refer to the documentation for more details." ].join(" ") ); } const defaultIndexHtml = path.resolve(root, "index.html"); const indexHtmlPath = htmlCandidates[0] ?? (existsSync(defaultIndexHtml) ? defaultIndexHtml : undefined); if (indexHtmlPath === undefined) { throw new Error( [ "oidc-spa: Could not locate index.html.", "Checked Vite rollupOptions.input for HTML entries and the default index.html at the project root.", "No worries, if the oidc-spa Vite plugin fails you can still configure the client entrypoint manually.", "Please refer to the documentation for more details." ].join(" ") ); } return indexHtmlPath; })(); const indexHtmlContent = readFileSync(indexHtmlPath, "utf8"); const bodyMatch = indexHtmlContent.match(/]*>([\s\S]*?)<\/body>/i); const bodyContent = bodyMatch?.[1] ?? indexHtmlContent; const moduleScriptSrcs: string[] = []; const scriptRegex = /]*\btype\s*=\s*["']module["'][^>]*\bsrc\s*=\s*["']([^"']+)["'][^>]*>/gi; let match: RegExpExecArray | null; // eslint-disable-next-line no-cond-assign while ((match = scriptRegex.exec(bodyContent)) !== null) { const [, src] = match; moduleScriptSrcs.push(src); } if (moduleScriptSrcs.length === 0) { throw new Error( [ 'oidc-spa: Could not find a