UNPKG

phecda-server

Version:

server framework that provide IOC/type-reuse/http&rpc-adaptor

371 lines (319 loc) 10.4 kB
import { fileURLToPath, pathToFileURL } from 'url' import { writeFile } from 'fs/promises' import { basename, dirname, isAbsolute, relative, resolve as resolvePath, } from 'path' import { createRequire } from 'module' import ts from 'typescript' import chokidar from 'chokidar' import Debug from 'debug' import { compile, genUnImportRet, handleClassTypes, slash } from './utils.mjs' const require = createRequire( import.meta.url) const debug = Debug('phecda-server/loader') const isLowVersion = parseFloat(process.version.slice(1)) < 18.19 const IS_DEV = process.env.NODE_ENV === 'development' let port let config const workdir = process.cwd() const configPath = resolvePath( workdir, process.env.PS_CONFIG_FILE || 'ps.json', ) // unimport let unimportRet, customLoad, customResolve const dtsPath = process.env.PS_DTS_PATH || 'ps.d.ts' // graph const watchFiles = new Set() const filesRecord = new Map() const moduleGraph = {} // ts let tsconfig = { module: ts.ModuleKind.ESNext, moduleResolution: ts.ModuleResolutionKind.NodeNext, } const EXTENSIONS = [ts.Extension.Ts, ts.Extension.Tsx, ts.Extension.Mts] const tsconfigPath = resolvePath(process.cwd(), 'tsconfig.json') const tsRet = ts.readConfigFile(tsconfigPath, ts.sys.readFile) if (!tsRet.error) { const { error, options } = ts.parseJsonConfigFileContent( tsRet.config, ts.sys, dirname(tsconfigPath), ) if (!error) tsconfig = options } const moduleResolutionCache = ts.createModuleResolutionCache( ts.sys.getCurrentDirectory(), x => x, tsconfig, ) const host = { fileExists: ts.sys.fileExists, readFile: ts.sys.readFile, } if (isLowVersion) await initialize() export async function initialize(data) { if (data) port = data.port debug('read config...') config = require(configPath) if (!config.paths) config.paths = {} const loaderPath = process.env.PS_LOADER_PATH if (loaderPath) { const loader = await import(loaderPath.startsWith('.') ? resolvePath(workdir, loaderPath) : loaderPath) if (typeof loader.load === 'function') customLoad = loader.load if (typeof loader.resolve === 'function') customResolve = loader.resolve } if (IS_DEV) { chokidar.watch(configPath, { persistent: true }).on('change', () => { port.postMessage( JSON.stringify({ type: 'relaunch', }), ) }) } if (!config.unimport) return unimportRet = await genUnImportRet(config.unimport) if (unimportRet) { debug('auto import...') await unimportRet.init() const unimportDtsPath = resolvePath(workdir, dtsPath) writeFile( unimportDtsPath, handleClassTypes( await unimportRet.generateTypeDeclarations({ resolvePath: (i) => { if (i.from.startsWith('.') || isAbsolute(i.from)) { const related = slash( relative(dirname(unimportDtsPath), i.from).replace(/\.ts(x)?$/, ''), ) return !related.startsWith('.') ? `./${related}` : related } return i.from }, }), ), ) } } function addUrlToGraph(url, parent) { if (!(url in moduleGraph)) moduleGraph[url] = new Set() moduleGraph[url].add(parent) return url + (filesRecord.has(url) ? `?t=${filesRecord.get(url)}` : '') } function getFileMid(file) { const filename = basename(file) const ret = filename.split('.') if (!['js', 'mjs', 'cjs', 'ts', 'tsx', 'mts', 'cts'].includes(ret.pop())) return '' if (!ret[0]) // .dockerfile return '' return ret[1] } export const resolve = async (specifier, context, nextResolve) => { if (customResolve) { const url = await customResolve(specifier, context) if (url) { return { format: 'ts', url, shortCircuit: true, } } } // entrypoint if (!context.parentURL) return nextResolve(specifier) // @todo skip resolve to improve performance // if (context.parentURL.includes('/node_modules/') && specifier.includes('/node_modules/')) // return nextResolve(specifier) const { resolvedModule } = ts.resolveModuleName( specifier, fileURLToPath(context.parentURL), tsconfig, host, moduleResolutionCache, ) // import among files in local project if ( resolvedModule && !resolvedModule.resolvedFileName.includes('/node_modules/') && EXTENSIONS.includes(resolvedModule.extension) ) { const url = addUrlToGraph( pathToFileURL(resolvedModule.resolvedFileName).href, context.parentURL.split('?')[0], ) const importerMid = getFileMid(context.parentURL) const sourceMid = getFileMid(resolvedModule.resolvedFileName) if (config.resolve && importerMid && sourceMid) { const resolver = config.resolve.find( item => item.source === sourceMid && item.importer === importerMid, ) if (resolver) { return { format: 'ts', url: pathToFileURL(resolvePath(workdir, resolver.path)).href, shortCircuit: true, } } } return { format: 'ts', url, shortCircuit: true, } } const resolveRet = await nextResolve(specifier) // ts resolve fail in some cases if (resolveRet.url && isAbsolute(resolveRet.url)) { const [path, query] = resolveRet.url.split('?') resolveRet.url = pathToFileURL(path).href + (query ? `?${query}` : '') } return resolveRet } // @todo the first params may be url or path, need to distinguish export const load = async (url, context, nextLoad) => { let mode if (context.importAttributes.ps) { mode = context.importAttributes.ps delete context.importAttributes.ps } url = url.split('?')[0] if (!url.includes('/node_modules/') && url.startsWith('file://') && !watchFiles.has(url) && !isLowVersion ) { watchFiles.add(url) if (IS_DEV && mode !== 'not-hmr') { if (isModuleFileUrl(url)) { port.postMessage( JSON.stringify({ type: 'init', files: [fileURLToPath(url)], }), ) } chokidar.watch(fileURLToPath(url), { persistent: true }).on( 'change', debounce(() => { try { const files = [...findTopScope(url, Date.now())].reverse() port.postMessage( JSON.stringify({ type: 'change', files, }), ) } catch (e) { port.postMessage( JSON.stringify({ type: 'relaunch', }), ) } }), ) } } // after hmr if (customLoad) { const source = await customLoad(url, context) if (source) { return { format: 'module', source, shortCircuit: true, } } } // resolveModuleName failed // I don't know why it failed if (url.endsWith('.ts')) context.format = 'ts' // module-typescript??? if (context.format === 'ts') { const { source } = await nextLoad(url, context) const code = typeof source === 'string' ? source : Buffer.from(source).toString() const compiled = await compile(code, url, config?.swc) if (unimportRet) { const { injectImports } = unimportRet return { format: 'module', source: ( await injectImports( compiled, slash(url.startsWith('file://') ? fileURLToPath(url) : url), ) ).code, shortCircuit: true, } } return { format: 'module', source: compiled, shortCircuit: true, } } else { return nextLoad(url, context) } } function findTopScope(url, time, modules = new Set()) { filesRecord.set(url, time) if (isModuleFileUrl(url)) { modules.add(fileURLToPath(url)) } else { if (!moduleGraph[url]) throw new Error('root file update') for (const i of [...moduleGraph[url]]) findTopScope(i, time, modules) } return modules } function debounce(cb, timeout = 500) { let timer return (...args) => { if (timer) return timer = setTimeout(() => { cb(...args) timer = undefined }, timeout) } } export function isModuleFileUrl(url) { const midName = getFileMid(url) if (!midName) return false if ( [ 'controller', 'rpc', 'service', 'module', 'extension', 'ext', 'guard', 'addon', 'filter', 'pipe', 'solo', ].includes(midName) ) return true return config.moduleFile && config.moduleFile.includes(midName) }