UNPKG

@nestia/sdk

Version:

Nestia SDK and Swagger generator

452 lines (415 loc) 15.8 kB
import { doNotThrowTransformError } from "@nestia/core"; import fs from "fs"; import path from "path"; import { pathToFileURL } from "url"; import { INestiaConfig } from "../../INestiaConfig"; import { EmittedJavaScriptPatcher } from "../../utils/EmittedJavaScriptPatcher"; import { TsConfigReader } from "../../utils/TsConfigReader"; import { TtscExecutor } from "../../utils/TtscExecutor"; export namespace NestiaConfigLoader { export interface ICompilerOptions { raw: { compilerOptions?: Record<string, any>; }; } export const compilerOptions = async ( project: string, ): Promise<ICompilerOptions> => { const configFileName = findConfigFile(process.cwd(), project); if (!configFileName) throw new Error(`unable to find "${project}" file.`); const tsconfig = await TsConfigReader.read(configFileName); return { raw: { compilerOptions: typeof tsconfig?.compilerOptions === "object" ? (tsconfig.compilerOptions as Record<string, any>) : {}, }, }; }; export const configurations = async ( file: string, compilerOptions: Record<string, any>, ): Promise<INestiaConfig[]> => { if (fs.existsSync(path.resolve(file)) === false) throw new Error(`Unable to find "${file}" file.`); doNotThrowTransformError(false); if (compilerOptions.plugins !== undefined) assertPlugins(file, compilerOptions.plugins); const configFile: string = await materializeConfiguration({ file, compilerOptions, }); const loaded: unknown = await loadMaterializedModule(configFile); const instance: INestiaConfig | INestiaConfig[] = extractConfiguration( file, loaded, ); const configurations: INestiaConfig[] = Array.isArray(instance) ? instance : [instance]; return assertConfigurations(file, configurations); }; const MATERIALIZED_ROOTS: Set<string> = new Set(); let CLEANUP_REGISTERED: boolean = false; const materializeConfiguration = async (props: { file: string; compilerOptions: Record<string, any>; }): Promise<string> => { const configFile: string = path.resolve(props.file); const project: string = process.env.NESTIA_PROJECT ?? "tsconfig.json"; const projectFile: string | undefined = findConfigFile( process.cwd(), project, ); if (projectFile === undefined) throw new Error(`unable to find "${project}" file.`); const projectRoot: string = path.dirname(path.resolve(projectFile)); const wrapperRoot: string = fs.mkdtempSync( path.join(ensureMaterializedRoot(projectRoot), "tsconfig-"), ); const outputRoot: string = fs.mkdtempSync( path.join(ensureMaterializedRoot(projectRoot), "run-"), ); const wrapperFile: string = path.join(wrapperRoot, "tsconfig.json"); const wrapperConfig = { extends: projectFile, compilerOptions: { noEmit: false, noUnusedLocals: false, noUnusedParameters: false, ...nodeAmbientCompilerOptions(projectRoot, props.compilerOptions), // The wrapper compiles the config file only to require() its JS; .d.ts // is never read, and tsgo's declaration emitter can nil-panic on a // config that calls into Nest. Force it off regardless of the project. declaration: false, declarationMap: false, outDir: outputRoot, plugins: materializePlugins(props.compilerOptions.plugins), rootDir: projectRoot, }, include: [configFile], exclude: [path.join(projectRoot, "src", "test", "**", "*")], }; fs.writeFileSync( wrapperFile, JSON.stringify(wrapperConfig, null, 2), "utf8", ); try { TtscExecutor.run({ cwd: projectRoot, env: sdkTransformEnv(), project: wrapperFile, }); } catch (error) { const stderr: string = readChildOutput(error, "stderr"); const stdout: string = readChildOutput(error, "stdout"); const detail: string = stderr || stdout; const cause: Error = error instanceof Error ? error : new Error(String(error)); const status: number | string | undefined = (cause as { status?: number }).status ?? (cause as NodeJS.ErrnoException).code; throw new Error( detail ? `failed to compile "${props.file}" through ttsc:\n${detail}` : `failed to compile "${props.file}" through ttsc (exit code ${status ?? "unknown"}). Run \`npx ttsc -p ${projectFile}\` to see the underlying diagnostics.`, { cause }, ); } finally { fs.rmSync(wrapperRoot, { force: true, recursive: true }); } await EmittedJavaScriptPatcher.importMetaUrl(outputRoot); const configKey: string = emittedJavaScriptKey(projectRoot, configFile); const next: string = path.join(outputRoot, configKey); if (fs.existsSync(next) === false) throw new Error( `failed to materialize "${props.file}" through ttsc native transform.`, ); return next; }; const ensureMaterializedRoot = (projectRoot: string): string => { const root: string = path.join( projectRoot, "node_modules", ".nestia", "config-loader", ); fs.mkdirSync(root, { recursive: true }); MATERIALIZED_ROOTS.add(root); if (CLEANUP_REGISTERED === false) { CLEANUP_REGISTERED = true; const sweep = (): void => { for (const location of MATERIALIZED_ROOTS) fs.rmSync(location, { force: true, recursive: true }); }; process.once("exit", sweep); // process.once("exit", …) does not fire on SIGINT/SIGTERM. Without // these handlers a Ctrl-C during codegen leaves `run-*` and // `tsconfig-*` mkdtempSync directories behind under // node_modules/.nestia/config-loader/ until a subsequent clean exit. // The module-level CLEANUP_REGISTERED flag above guards against // re-entrancy within this module; we deliberately do NOT gate on // `process.listenerCount(signal) > 0` because the parallel sweep in // `ConfigAnalyzer.ensureRuntimeCleanup` (or any user-app SIGINT // handler) could register first, and that gate would skip our // registration — leaving MATERIALIZED_ROOTS unswept on Ctrl-C. // Windows note: `process.kill(pid, "SIGINT")` calls TerminateProcess // rather than re-raising; whichever module registers FIRST runs its // sweep, the second is skipped, RUNTIME/MATERIALIZED cleanup is // best-effort on Windows. SIGHUP is a no-op for most common code // paths on Windows (Node fires it on console-close and exits within // seconds). const onSignal = (signal: NodeJS.Signals): void => { sweep(); process.kill(process.pid, signal); }; for (const signal of ["SIGINT", "SIGTERM", "SIGHUP"] as const) { process.once(signal, onSignal); } } return root; }; const emittedJavaScriptKey = (projectRoot: string, file: string): string => { const relative: string = path.relative(projectRoot, file); const extension: string = path.extname(relative).toLowerCase(); const emitted: string = extension === ".mts" ? ".mjs" : extension === ".cts" ? ".cjs" : ".js"; return path .join( path.dirname(relative), `${path.basename(relative, extension)}${emitted}`, ) .split(path.sep) .join(path.posix.sep); }; const nodeAmbientCompilerOptions = ( projectRoot: string, compilerOptions: Record<string, any>, ): { typeRoots?: string[]; types: string[] } => { const typeRoots: string[] = uniqueStrings([ ...asStringArray(compilerOptions.typeRoots), ...resolveNodeTypeRoots(projectRoot), ]); const types: string[] = uniqueStrings([ "node", ...asStringArray(compilerOptions.types).filter((value) => value !== "*"), ]); return { ...(typeRoots.length !== 0 ? { typeRoots } : {}), types, }; }; const resolveNodeTypeRoots = (projectRoot: string): string[] => { const roots: string[] = []; for (const base of [projectRoot, process.cwd(), __dirname]) try { const location: string = require.resolve("@types/node/package.json", { paths: [base], }); roots.push(path.dirname(path.dirname(location))); } catch { continue; } return roots; }; const asStringArray = (input: unknown): string[] => Array.isArray(input) ? input.filter((elem): elem is string => typeof elem === "string") : []; const uniqueStrings = (input: string[]): string[] => [...new Set(input)]; type MaterializePlugin = Record<string, unknown> & { transform?: unknown }; const sdkTransformEnv = (): NodeJS.ProcessEnv => process.env.NESTIA_SDK_TRANSFORM === undefined ? { NESTIA_SDK_TRANSFORM: "1" } : {}; const materializePlugins = (input: unknown): MaterializePlugin[] => { const plugins: MaterializePlugin[] = Array.isArray(input) ? input .filter((p) => typeof p === "object" && p !== null) .map((p) => ({ ...(p as MaterializePlugin) })) : []; const typia: MaterializePlugin | undefined = plugins.find((p) => isTransform(p, "typia"), ); const core: MaterializePlugin | undefined = plugins.find((p) => isTransform(p, "@nestia/core"), ); return [ { ...(typia ?? {}), transform: "typia/lib/transform", enabled: false, }, normalizePlugin({ ...(core ?? {}), transform: "@nestia/core/native/transform.cjs", }), ]; }; const normalizePlugin = (plugin: MaterializePlugin): MaterializePlugin => { const output: MaterializePlugin = { ...plugin }; if (output.enabled === false) delete output.enabled; return output; }; const isTransform = (plugin: MaterializePlugin, name: string): boolean => typeof plugin.transform === "string" && plugin.transform.includes(name); const findConfigFile = (cwd: string, project: string): string | undefined => { const candidate: string = path.isAbsolute(project) ? project : path.resolve(cwd, project); if (fs.existsSync(candidate)) return candidate; if (path.isAbsolute(project) || project.includes(path.sep)) return undefined; let current: string = path.resolve(cwd); while (true) { const next: string = path.join(current, project); if (fs.existsSync(next)) return next; const parent: string = path.dirname(current); if (parent === current) return undefined; current = parent; } }; const extractConfiguration = ( file: string, loaded: unknown, ): INestiaConfig | INestiaConfig[] => { const candidates: unknown[] = []; const collect = (value: unknown): void => { if (isObject(value)) { candidates.push(value.default); candidates.push(value.NESTIA_CONFIG); if (isObject(value.default)) { candidates.push(value.default.default); candidates.push(value.default.NESTIA_CONFIG); } } candidates.push(value); }; collect(loaded); const matched: unknown = candidates.find( (value) => Array.isArray(value) || (isObject(value) && Object.hasOwn(value, "input")), ); if (matched === undefined) throw new Error( `invalid "${file}" data: configuration must be exported.`, ); return matched as INestiaConfig | INestiaConfig[]; }; const loadMaterializedModule = async (file: string): Promise<unknown> => { if (file.endsWith(".mjs")) { const dynamicImport = new Function( "specifier", "return import(specifier)", ) as (specifier: string) => Promise<unknown>; return dynamicImport(pathToFileURL(file).href); } return require(file); }; const assertPlugins = ( file: string, input: unknown, ): Array<Record<string, any>> => { if ( Array.isArray(input) && input.every((elem) => typeof elem === "object" && elem !== null) ) return input as Array<Record<string, any>>; throw new Error( `invalid "${file}" data: compilerOptions.plugins must be an array.`, ); }; const assertConfigurations = ( file: string, input: unknown[], ): INestiaConfig[] => { input.forEach((config, index) => assertConfig(file, config, index)); return input as INestiaConfig[]; }; const assertConfig = (file: string, input: unknown, index: number): void => { if (isObject(input) === false) throw new Error( `invalid "${file}" data: configuration #${index} must be an object.`, ); const config: INestiaConfig = input as unknown as INestiaConfig; if (isInput(config.input) === false) throw new Error( `invalid "${file}" data: configuration #${index}.input is invalid.`, ); for (const [key, value] of [ ["output", config.output], ["distribute", config.distribute], ["e2e", config.e2e], ] as const) if (value !== undefined && typeof value !== "string") throw new Error( `invalid "${file}" data: configuration #${index}.${key} must be a string.`, ); for (const [key, value] of [ ["keyword", config.keyword], ["simulate", config.simulate], ["propagate", config.propagate], ["clone", config.clone], ["primitive", config.primitive], ["assert", config.assert], ["json", config.json], ] as const) if (value !== undefined && typeof value !== "boolean") throw new Error( `invalid "${file}" data: configuration #${index}.${key} must be a boolean.`, ); if (config.swagger !== undefined && isSwagger(config.swagger) === false) throw new Error( `invalid "${file}" data: configuration #${index}.swagger is invalid.`, ); }; const isInput = (input: unknown): input is INestiaConfig["input"] => { if ( typeof input === "string" || typeof input === "function" || isStringArray(input) ) return true; if (isObject(input) === false) return false; return ( isStringArray(input.include) && (input.exclude === undefined || isStringArray(input.exclude)) ); }; const isSwagger = (input: unknown): input is INestiaConfig.ISwaggerConfig => { if (isObject(input) === false) return false; return ( typeof input.output === "string" && (input.openapi === undefined || ["2.0", "3.0", "3.1", "3.2"].includes(input.openapi as string)) && (input.beautify === undefined || typeof input.beautify === "boolean" || typeof input.beautify === "number") && (input.additional === undefined || typeof input.additional === "boolean") && (input.decompose === undefined || typeof input.decompose === "boolean") && (input.operationId === undefined || typeof input.operationId === "function") ); }; const isObject = (input: unknown): input is Record<string, unknown> => typeof input === "object" && input !== null && Array.isArray(input) === false; const isStringArray = (input: unknown): input is string[] => Array.isArray(input) && input.every((elem) => typeof elem === "string"); const readChildOutput = ( error: unknown, key: "stderr" | "stdout", ): string => { if (!error || typeof error !== "object" || !(key in error)) return ""; const value = (error as Record<string, unknown>)[key]; if (value === null || value === undefined) return ""; if (typeof value === "string") return value.trim(); if (Buffer.isBuffer(value)) return value.toString("utf8").trim(); return ""; }; }