diff --git a/.changeset/long-experts-design.md b/.changeset/long-experts-design.md new file mode 100644 index 00000000..5f158a46 --- /dev/null +++ b/.changeset/long-experts-design.md @@ -0,0 +1,5 @@ +--- +'@ice/pkg': minor +--- + +feat: add individual declaration task for speed diff --git a/packages/pkg/src/config/userConfig.ts b/packages/pkg/src/config/userConfig.ts index 3a8a6719..1775bb9f 100644 --- a/packages/pkg/src/config/userConfig.ts +++ b/packages/pkg/src/config/userConfig.ts @@ -8,6 +8,8 @@ import type { BundleUserConfig, TransformUserConfig, TransformTaskConfig, + DeclarationTaskConfig, + DeclarationUserConfig, } from '../types.js'; function getUserConfig() { @@ -24,6 +26,9 @@ function getUserConfig() { const defaultTransformUserConfig: TransformUserConfig = { formats: ['esm', 'es2017'], }; + const defaultDeclarationUserConfig: DeclarationUserConfig = { + outputMode: 'multi', + }; const userConfig = [ { name: 'entry', @@ -75,8 +80,30 @@ function getUserConfig() { }, { name: 'declaration', - validation: 'boolean', + validation: 'boolean|object', defaultValue: true, + setConfig: (config: TaskConfig, declaration: UserConfig['declaration']) => { + if (config.type === 'declaration') { + if (declaration === false) { + return config; + } + let taskConfig = config; + const mergedConfig = typeof declaration === 'object' ? { + ...defaultDeclarationUserConfig, + ...declaration, + } : { ...defaultDeclarationUserConfig }; + + Object.keys(mergedConfig).forEach((key) => { + taskConfig = mergeValueToTaskConfig( + taskConfig, + key, + mergedConfig[key], + ); + }); + + return taskConfig; + } + }, }, // TODO: validate values recursively { diff --git a/packages/pkg/src/helpers/dts.ts b/packages/pkg/src/helpers/dts.ts index e9d16750..d95f65a6 100644 --- a/packages/pkg/src/helpers/dts.ts +++ b/packages/pkg/src/helpers/dts.ts @@ -1,10 +1,7 @@ import ts from 'typescript'; import consola from 'consola'; -import { performance } from 'perf_hooks'; -import { timeFrom, normalizePath } from '../utils.js'; -import { createLogger } from './logger.js'; -import formatAliasToTSPathsConfig from './formatAliasToTSPathsConfig.js'; -import type { TaskConfig } from '../types.js'; +import { normalizePath } from '../utils.js'; +import { TaskConfig } from '../types.js'; import { prepareSingleFileReplaceTscAliasPaths } from 'tsc-alias'; import fse from 'fs-extra'; import * as path from 'path'; @@ -23,8 +20,8 @@ export interface DtsInputFile extends File { dtsPath?: string; } -const normalizeDtsInput = (file: File, rootDir: string, outputDir: string): DtsInputFile => { - const { filePath, ext } = file; +const normalizeDtsInput = (filePath: string, rootDir: string, outputDir: string): DtsInputFile => { + const ext = path.extname(filePath) as FileExt; // https://www.typescriptlang.org/docs/handbook/esm-node.html#new-file-extensions // a.js -> a.d.ts // a.cjs -> a.d.cts @@ -34,59 +31,106 @@ const normalizeDtsInput = (file: File, rootDir: string, outputDir: string): DtsI // a.mts -> a.d.mts const dtsPath = filePath.replace(path.join(rootDir, 'src'), outputDir).replace(ext, `.d.${/^\.[jt]/.test(ext) ? '' : ext[1]}ts`); return { - ...file, + filePath, + ext, dtsPath, }; }; -interface DtsCompileOptions { +export interface DtsCompileOptions { // In watch mode, it only contains the updated file names. In build mode, it contains all file names. - files: File[]; + files: string[]; alias: TaskConfig['alias']; rootDir: string; outputDir: string; +} + +function formatAliasToTSPathsConfig(alias: TaskConfig['alias']) { + const paths: { [from: string]: [string] } = {}; + + Object.entries(alias || {}) + .forEach(([key, value]) => { + const [pathKey, pathValue] = formatPath(key, value); + paths[pathKey] = [pathValue]; + }); + return paths; } -export async function dtsCompile({ files, alias, rootDir, outputDir }: DtsCompileOptions): Promise { - if (!files.length) { - return; +function formatPath(key: string, value: string) { + if (key.endsWith('$')) { + return [key.replace(/\$$/, ''), value]; } + // abc -> abc/* + // abc/ -> abc/* + return [addWildcard(key), addWildcard(value)]; +} - const tsConfig = await getTSConfig(rootDir, outputDir, alias); +function addWildcard(str: string) { + return `${str.endsWith('/') ? str : `${str}/`}*`; +} - const logger = createLogger('dts'); +async function getTSConfig( + rootDir: string, + outputDir: string, + alias: TaskConfig['alias'], +) { + const defaultTSCompilerOptions: ts.CompilerOptions = { + allowJs: true, + declaration: true, + emitDeclarationOnly: true, + incremental: true, + skipLibCheck: true, + paths: formatAliasToTSPathsConfig(alias), // default add alias to paths + }; + const projectTSConfig = await getProjectTSConfig(rootDir); + const tsConfig: ts.ParsedCommandLine = merge( + { options: defaultTSCompilerOptions }, + projectTSConfig, + { + options: { + outDir: outputDir, + rootDir: path.join(rootDir, 'src'), + }, + }, + ); - logger.debug('Start Compiling typescript declarations...'); + return tsConfig; +} - const dtsCompileStart = performance.now(); +async function getProjectTSConfig(rootDir: string): Promise { + const tsconfigPath = ts.findConfigFile(rootDir, ts.sys.fileExists); + if (tsconfigPath) { + const tsconfigFile = ts.readConfigFile(tsconfigPath, ts.sys.readFile); + return ts.parseJsonConfigFileContent( + tsconfigFile.config, + ts.sys, + path.dirname(tsconfigPath), + ); + } - const _files = files - .map((file) => normalizeDtsInput(file, rootDir, outputDir)) - .map(({ filePath, dtsPath, ...rest }) => ({ - ...rest, - // Be compatible with Windows env. - filePath: normalizePath(filePath), - dtsPath: normalizePath(dtsPath), - })); + return { + options: {}, + fileNames: [], + errors: [], + }; +} - const dtsFiles = {}; +export async function dtsCompile({ files, rootDir, outputDir, alias }: DtsCompileOptions): Promise { + if (!files.length) { + return []; + } - // Create ts host and custom the writeFile and readFile. - const host = ts.createCompilerHost(tsConfig.options); - host.writeFile = (fileName, contents) => { - dtsFiles[fileName] = contents; - }; + const tsConfig = await getTSConfig(rootDir, outputDir, alias); - const _readFile = host.readFile; - // Hijack `readFile` to prevent reading file twice - host.readFile = (fileName) => { - const foundItem = files.find((file) => file.filePath === fileName); - if (foundItem && foundItem.srcCode) { - return foundItem.srcCode; - } - return _readFile(fileName); - }; + const _files = files + .map((file) => normalizeDtsInput(file, rootDir, outputDir)) + .map(({ filePath, dtsPath, ...rest }) => ({ + ...rest, + // Be compatible with Windows env. + filePath: normalizePath(filePath), + dtsPath: normalizePath(dtsPath), + })); // In order to only include the update files instead of all the files in the watch mode. function getProgramRootNames(originalFilenames: string[]) { @@ -97,7 +141,13 @@ export async function dtsCompile({ files, alias, rootDir, outputDir }: DtsCompil return [...needCompileFileNames, ...dtsFilenames]; } - // Create ts program. + const dtsFiles = {}; + const host = ts.createCompilerHost(tsConfig.options); + + host.writeFile = (fileName, contents) => { + dtsFiles[fileName] = contents; + }; + const programOptions: ts.CreateProgramOptions = { rootNames: getProgramRootNames(tsConfig.fileNames), options: tsConfig.options, @@ -107,8 +157,6 @@ export async function dtsCompile({ files, alias, rootDir, outputDir }: DtsCompil }; const program = ts.createProgram(programOptions); - logger.debug(`Initializing program takes ${timeFrom(dtsCompileStart)}`); - const emitResult = program.emit(); if (emitResult.diagnostics && emitResult.diagnostics.length > 0) { @@ -123,9 +171,17 @@ export async function dtsCompile({ files, alias, rootDir, outputDir }: DtsCompil }); } + if (!Object.keys(alias).length) { + // no alias config + return _files.map((file) => ({ + ...file, + dtsContent: dtsFiles[file.dtsPath], + })); + } + // We use tsc-alias to resolve d.ts alias. // Reason: https://github.com/microsoft/TypeScript/issues/30952#issuecomment-1114225407 - const tsConfigLocalPath = path.join(rootDir, 'node_modules/pkg/tsconfig.json'); + const tsConfigLocalPath = path.join(rootDir, 'node_modules/.cache/ice-pkg/tsconfig.json'); await fse.ensureFile(tsConfigLocalPath); await fse.writeJSON(tsConfigLocalPath, { ...tsConfig, @@ -142,53 +198,5 @@ export async function dtsCompile({ files, alias, rootDir, outputDir }: DtsCompil dtsContent: dtsFiles[file.dtsPath] ? runFile({ fileContents: dtsFiles[file.dtsPath], filePath: file.dtsPath }) : '', })); - logger.debug(`Generating declaration files take ${timeFrom(dtsCompileStart)}`); - return result; } - -async function getTSConfig( - rootDir: string, - outputDir: string, - alias: TaskConfig['alias'], -) { - const defaultTSCompilerOptions: ts.CompilerOptions = { - allowJs: true, - declaration: true, - emitDeclarationOnly: true, - incremental: true, - skipLibCheck: true, - paths: formatAliasToTSPathsConfig(alias), // default add alias to paths - }; - const projectTSConfig = await getProjectTSConfig(rootDir); - const tsConfig: ts.ParsedCommandLine = merge( - { options: defaultTSCompilerOptions }, - projectTSConfig, - { - options: { - outDir: outputDir, - rootDir: path.join(rootDir, 'src'), - }, - }, - ); - - return tsConfig; -} - -async function getProjectTSConfig(rootDir: string): Promise { - const tsconfigPath = ts.findConfigFile(rootDir, ts.sys.fileExists); - if (tsconfigPath) { - const tsconfigFile = ts.readConfigFile(tsconfigPath, ts.sys.readFile); - return ts.parseJsonConfigFileContent( - tsconfigFile.config, - ts.sys, - path.dirname(tsconfigPath), - ); - } - - return { - options: {}, - fileNames: [], - errors: [], - }; -} diff --git a/packages/pkg/src/helpers/formatAliasToTSPathsConfig.ts b/packages/pkg/src/helpers/formatAliasToTSPathsConfig.ts deleted file mode 100644 index 01831976..00000000 --- a/packages/pkg/src/helpers/formatAliasToTSPathsConfig.ts +++ /dev/null @@ -1,25 +0,0 @@ -import { TaskConfig } from '../types.js'; - -export default function formatAliasToTSPathsConfig(alias: TaskConfig['alias']) { - const paths: { [from: string]: [string] } = {}; - - Object.entries(alias || {}).forEach(([key, value]) => { - const [pathKey, pathValue] = formatPath(key, value); - paths[pathKey] = [pathValue]; - }); - - return paths; -} - -function formatPath(key: string, value: string) { - if (key.endsWith('$')) { - return [key.replace(/\$$/, ''), value]; - } - // abc -> abc/* - // abc/ -> abc/* - return [addWildcard(key), addWildcard(value)]; -} - -function addWildcard(str: string) { - return `${str.endsWith('/') ? str : `${str}/`}*`; -} diff --git a/packages/pkg/src/helpers/getBuildTasks.ts b/packages/pkg/src/helpers/getBuildTasks.ts index d64fe045..cef183a3 100644 --- a/packages/pkg/src/helpers/getBuildTasks.ts +++ b/packages/pkg/src/helpers/getBuildTasks.ts @@ -1,4 +1,5 @@ import deepmerge from 'deepmerge'; +import path from 'node:path'; import { formatEntry, getTransformDefaultOutputDir } from './getTaskIO.js'; import { getDefaultBundleSwcConfig, getDefaultTransformSwcConfig } from './defaultSwcConfig.js'; import { stringifyObject } from '../utils.js'; @@ -49,6 +50,14 @@ function getBuildTask(buildTask: BuildTask, context: Context): BuildTask { defaultTransformSwcConfig, config.swcCompileOptions || {}, ); + } else if (config.type === 'declaration') { + // 这个 output 仅仅用于生成正确的 .d.ts 的 alias,不做实际输出目录 + config.outputDir = path.resolve(rootDir, config.transformFormats[0]); + if (config.outputMode === 'unique') { + config.declarationOutputDirs = [path.resolve(rootDir, 'typings')]; + } else { + config.declarationOutputDirs = config.transformFormats.map((format) => path.resolve(rootDir, format)); + } } else { throw new Error('Invalid task type.'); } diff --git a/packages/pkg/src/helpers/getRollupOptions.ts b/packages/pkg/src/helpers/getRollupOptions.ts index fa03fb2d..faf875f0 100644 --- a/packages/pkg/src/helpers/getRollupOptions.ts +++ b/packages/pkg/src/helpers/getRollupOptions.ts @@ -6,7 +6,6 @@ import autoprefixer from 'autoprefixer'; import PostcssPluginRpxToVw from 'postcss-plugin-rpx2vw'; import json from '@rollup/plugin-json'; import swcPlugin from '../rollupPlugins/swc.js'; -import dtsPlugin from '../rollupPlugins/dts.js'; import minifyPlugin from '../rollupPlugins/minify.js'; import babelPlugin from '../rollupPlugins/babel.js'; import { builtinNodeModules } from './builtinModules.js'; @@ -44,7 +43,7 @@ export function getRollupOptions( context: Context, taskRunnerContext: TaskRunnerContext, ) { - const { pkg, commandArgs, command, userConfig, rootDir } = context; + const { pkg, commandArgs, command, rootDir } = context; const { name: taskName, config: taskConfig } = taskRunnerContext.buildTask; const rollupOptions: RollupOptions = {}; const plugins: Plugin[] = []; @@ -73,17 +72,6 @@ export function getRollupOptions( ); if (taskConfig.type === 'transform') { - if (userConfig.declaration) { - plugins.unshift( - dtsPlugin({ - rootDir, - entry: taskConfig.entry as Record, - generateTypesForJs: userConfig.generateTypesForJs, - alias: taskConfig.alias, - outputDir: taskConfig.outputDir, - }), - ); - } plugins.push(transformAliasPlugin(rootDir, taskConfig.alias)); } else if (taskConfig.type === 'bundle') { const [external, globals] = getExternalsAndGlobals(taskConfig, pkg as PkgJson); diff --git a/packages/pkg/src/helpers/getTaskRunners.ts b/packages/pkg/src/helpers/getTaskRunners.ts index 346d6b08..90f9f946 100644 --- a/packages/pkg/src/helpers/getTaskRunners.ts +++ b/packages/pkg/src/helpers/getTaskRunners.ts @@ -1,6 +1,7 @@ import { BuildTask, Context, type OutputResult, type TaskRunnerContext } from '../types.js'; import { createTransformTask } from '../tasks/transform.js'; import { createBundleTask } from '../tasks/bundle.js'; +import { createDeclarationTask } from '../tasks/declaration.js'; import { Runner } from './runner.js'; import { FSWatcher } from 'chokidar'; @@ -20,6 +21,10 @@ export function getTaskRunners(buildTasks: BuildTask[], context: Context, watche return createBundleTask(taskRunnerContext); }); } + case 'declaration': { + const taskRunnerContext: TaskRunnerContext = { mode: 'production', buildTask, buildContext: context, watcher }; + return createDeclarationTask(taskRunnerContext); + } default: { // @ts-expect-error unreachable throw new Error(`Unknown task type of ${config.type}`); diff --git a/packages/pkg/src/helpers/rpc.ts b/packages/pkg/src/helpers/rpc.ts new file mode 100644 index 00000000..bf9bce02 --- /dev/null +++ b/packages/pkg/src/helpers/rpc.ts @@ -0,0 +1,116 @@ +import { MessagePort } from 'node:worker_threads'; + +export type RpcMethods = Record Promise>; + +enum RpcMessageType { + Request = 'req', + Response = 'res', + ResponseError = 'resError' +} + +interface RpcBaseMessage { + __rpc__: string; + type: RpcMessageType; +} + +interface RpcRequestMessage extends RpcBaseMessage { + id: number; + type: RpcMessageType.Request; + method: string; + args: unknown[]; +} + +interface RpcResponseMessage extends RpcBaseMessage { + id: number; + type: RpcMessageType.Response | RpcMessageType.ResponseError; + data: unknown; +} + +type RpcMessage = RpcRequestMessage | RpcResponseMessage; + +const RPC_SIGN = 'pkg-rpc'; + +function isRpcMessage(message: unknown): message is RpcMessage { + return message && typeof message === 'object' && (message as RpcMessage).__rpc__ === RPC_SIGN; +} + +export class Rpc { + private requestId = 0; + private requestStore = new Map void, reject: (e: unknown) => void]>(); + + constructor(private tunnel: MessagePort, private rpcMethods: L) { + // tunnel.onMessage?.(this.onMessage.bind(this)); + this.tunnel.on('message', this.onMessage.bind(this)); + } + + call(name: K, args: Parameters): ReturnType { + const reqId = ++this.requestId; + + this.postMessage({ + __rpc__: RPC_SIGN, + type: RpcMessageType.Request, + id: reqId, + method: name as string, + args: args as unknown[], + }); + + let resolve; + let reject; + const promise = new Promise((res, rej) => { + resolve = res; + reject = rej; + }); + + this.requestStore.set(reqId, [resolve, reject]); + + return promise as ReturnType; + } + + private onMessage(message: unknown) { + if (isRpcMessage(message)) { + switch (message.type) { + case RpcMessageType.Request: { + const { id, method, args } = message; + const fn = this.rpcMethods[method]; + new Promise((resolve, reject) => { + if (fn) { + resolve(fn(...args)); + } else { + reject(new Error(`Method ${method} not found`)); + } + }).then((returnData) => { + this.postMessage({ + __rpc__: RPC_SIGN, + type: RpcMessageType.Response, + id, + data: returnData, + }); + }, (error) => { + this.postMessage({ + __rpc__: RPC_SIGN, + type: RpcMessageType.ResponseError, + id, + // TODO: stringify error + data: error, + }); + }); + break; + } + case RpcMessageType.ResponseError: + case RpcMessageType.Response: { + const { id, data } = message; + const fn = this.requestStore.get(id); + if (fn) { + this.requestStore.delete(id); + fn[message.type === RpcMessageType.Response ? 0 : 1](data); + } + break; + } + } + } + } + + private postMessage(data: RpcMessage) { + this.tunnel.postMessage(data); + } +} diff --git a/packages/pkg/src/helpers/runnerGroup.ts b/packages/pkg/src/helpers/runnerGroup.ts new file mode 100644 index 00000000..039d7992 --- /dev/null +++ b/packages/pkg/src/helpers/runnerGroup.ts @@ -0,0 +1,42 @@ +import { Runner, RunnerStatus } from './runner.js'; +import { WatchChangedFile } from '../types.js'; +import { concurrentPromiseAll } from '../utils.js'; +import { RunnerReporter } from './runnerReporter.js'; + +export class RunnerGroup { + private parallelRunners: Array> = []; + private concurrentRunners: Array> = []; + + constructor(public runners: Array>, public reporter: RunnerReporter) { + for (const runner of runners) { + if (runner.isParallel) { + this.parallelRunners.push(runner); + } else { + this.concurrentRunners.push(runner); + } + runner.on('status', () => { + if (runner.isRunning) { + this.reporter.onRunnerStart(runner); + } else if (runner.isFinished) { + this.reporter.onRunnerEnd(runner); + } + }); + } + } + + async run(changedFiles?: WatchChangedFile[]): Promise { + const startTime = Date.now(); + const parallelPromise = Promise.all(this.parallelRunners.map((runner) => runner.run(changedFiles))); + const concurrentPromise = concurrentPromiseAll(this.concurrentRunners.map((runner) => () => runner.run(changedFiles)), 1); + + const [parallelResults, concurrentResults] = await Promise.all([parallelPromise, concurrentPromise]); + const stopTime = Date.now(); + this.reporter.onStop({ + startTime, + stopTime, + cost: stopTime - startTime, + runners: this.runners, + }); + return [...parallelResults, ...concurrentResults]; + } +} diff --git a/packages/pkg/src/plugins/component.ts b/packages/pkg/src/plugins/component.ts index 0b5a9360..9427aaf2 100644 --- a/packages/pkg/src/plugins/component.ts +++ b/packages/pkg/src/plugins/component.ts @@ -14,8 +14,9 @@ const plugin: Plugin = (api) => { registerUserConfig(config.getUserConfig()); registerCliOption(config.getCliOptions()); + const transformFormats = userConfig.transform?.formats || ['esm', 'es2017']; // TODO: Move default value to userConfig defaultValue - (userConfig.transform?.formats || ['esm', 'es2017']).forEach((format) => { + transformFormats.forEach((format) => { registerTask(`transform-${format}`, { type: 'transform', }); @@ -35,6 +36,14 @@ const plugin: Plugin = (api) => { }); } } + + if ((userConfig.declaration ?? true) && transformFormats.length) { + // eslint-disable-next-line @typescript-eslint/consistent-type-assertions + registerTask(TaskName.DECLARATION, { + type: 'declaration', + transformFormats, + }); + } }; export default plugin; diff --git a/packages/pkg/src/rollupPlugins/dts.ts b/packages/pkg/src/rollupPlugins/dts.ts deleted file mode 100644 index a5ba5651..00000000 --- a/packages/pkg/src/rollupPlugins/dts.ts +++ /dev/null @@ -1,95 +0,0 @@ -import { extname } from 'path'; -import { createFilter } from '@rollup/pluginutils'; -import { dtsCompile, type File } from '../helpers/dts.js'; - -import type { Plugin } from 'rollup'; -import type { TaskConfig, UserConfig } from '../types.js'; -import type { DtsInputFile, FileExt } from '../helpers/dts.js'; - -interface CachedContent extends DtsInputFile { - updated: boolean; -} - -interface DtsPluginOptions { - rootDir: string; - entry: Record; - alias: TaskConfig['alias']; - outputDir: string; - generateTypesForJs?: UserConfig['generateTypesForJs']; -} - -// dtsPlugin is used to generate declaration file when transforming -function dtsPlugin({ - rootDir, - alias, - generateTypesForJs, - outputDir, -}: DtsPluginOptions): Plugin { - const includeFileRegexps = [/\.(?:[cm]?ts|tsx)$/]; - if (generateTypesForJs) { - includeFileRegexps.push(/\.(?:[cm]?js|jsx)$/); - } - const dtsFilter = createFilter( - includeFileRegexps, // include - [/node_modules/, /\.d\.[cm]?ts$/], // exclude - ); - // Actually, it's useful in dev. - const cachedContents: Record = {}; - - return { - name: 'ice-pkg:dts', - transform(code, id) { - if (dtsFilter(id)) { - if (!cachedContents[id]) { - cachedContents[id] = { - srcCode: code, - updated: true, - ext: extname(id) as FileExt, - filePath: id, - }; - } else if (cachedContents[id].srcCode !== code) { - cachedContents[id].srcCode = code; - cachedContents[id].updated = true; - } - } - // Always return null to escape transforming - return null; - }, - - async buildEnd() { - // should re-run typescript programs - const updatedIds = Object.keys(cachedContents).filter((id) => cachedContents[id].updated); - - let dtsFiles: DtsInputFile[]; - if (updatedIds.length) { - const files: File[] = updatedIds.map((id) => ({ - ext: cachedContents[id].ext, - filePath: id, - srcCode: cachedContents[id].srcCode, - })); - dtsFiles = await dtsCompile({ files, alias, rootDir, outputDir }); - } else { - dtsFiles = Object.keys(cachedContents).map((id) => { - const { updated, ...rest } = cachedContents[id]; - return { ...rest }; - }); - } - dtsFiles.forEach((file) => { - this.emitFile({ - type: 'asset', - fileName: file.dtsPath, - source: file.dtsContent, - }); - - cachedContents[file.filePath] = { - ...cachedContents[file.filePath], - ...file, - }; - }); - - updatedIds.forEach((updateId) => { cachedContents[updateId].updated = false; }); - }, - }; -} - -export default dtsPlugin; diff --git a/packages/pkg/src/tasks/declaration.rpc.ts b/packages/pkg/src/tasks/declaration.rpc.ts new file mode 100644 index 00000000..323a9b8f --- /dev/null +++ b/packages/pkg/src/tasks/declaration.rpc.ts @@ -0,0 +1,14 @@ +import type { DtsCompileOptions } from '../helpers/dts.js'; + +// eslint-disable-next-line @typescript-eslint/consistent-type-definitions +export type DeclarationMainMethods = { +}; + +// eslint-disable-next-line @typescript-eslint/consistent-type-definitions +export type DeclarationWorkerMethods = { + /** + * @param outputDirs 输出到的目录,支持多个目录 + * @param options 编译配置,这里面的 outputDir 没有任何用处 + */ + run: (outputDirs: string[], options: DtsCompileOptions) => Promise; +}; diff --git a/packages/pkg/src/tasks/declaration.ts b/packages/pkg/src/tasks/declaration.ts new file mode 100644 index 00000000..a3223a94 --- /dev/null +++ b/packages/pkg/src/tasks/declaration.ts @@ -0,0 +1,69 @@ +import path from 'node:path'; +import { Worker, MessagePort } from 'node:worker_threads'; +import { fileURLToPath } from 'node:url'; +import { DeclarationTaskConfig, OutputResult, TaskRunnerContext, WatchChangedFile } from '../types.js'; +import globby from 'globby'; +import { Runner } from '../helpers/runner.js'; +import { Rpc } from '../helpers/rpc.js'; +import { DeclarationMainMethods, DeclarationWorkerMethods } from './declaration.rpc.js'; +import { getExistedChangedFilesPath } from '../helpers/watcher.js'; +import { getTransformEntryDirs } from '../helpers/getTaskIO.js'; + +const dirname = path.dirname(fileURLToPath(import.meta.url)); + +export function createDeclarationTask(context: TaskRunnerContext) { + return new DeclarationRunner(context); +} + +class DeclarationRunner extends Runner { + // eslint-disable-next-line @typescript-eslint/class-literal-property-style + override get isParallel() { + return true; + } + + async doRun(changedFiles?: WatchChangedFile[]) { + const { context } = this; + // getTransformEntryDirs + // TODO: 应该使用和 transform 一致的目录 + let files: string[]; + + if (changedFiles) { + files = getExistedChangedFilesPath(changedFiles); + } else { + const entryDirs = getTransformEntryDirs(context.buildContext.rootDir, context.buildTask.config.entry as Record); + const result = await Promise.all(entryDirs.map((entry) => globby('**/*.{ts,tsx,mts,cts}', { + cwd: entry, + onlyFiles: true, + ignore: ['**/*.d.{ts,mts,cts}'], + absolute: true, + }))); + // unique files + const filesSet = new Set(); + for (const item of result) { + for (const file of item) { + filesSet.add(file); + } + } + files = Array.from(filesSet); + } + const worker = new Worker(path.join(dirname, './declaration.worker.js')); + const rpc = new Rpc(worker as unknown as MessagePort, { + }); + + const buildConfig = context.buildTask.config as DeclarationTaskConfig; + await rpc.call('run', [buildConfig.declarationOutputDirs, { + files, + rootDir: context.buildContext.rootDir, + outputDir: buildConfig.outputDir, + alias: buildConfig.alias, + }]); + + await worker.terminate(); + + return { + taskName: context.buildTask.name, + outputs: [], + outputFiles: [], + }; + } +} diff --git a/packages/pkg/src/tasks/declaration.worker.ts b/packages/pkg/src/tasks/declaration.worker.ts new file mode 100644 index 00000000..592d4185 --- /dev/null +++ b/packages/pkg/src/tasks/declaration.worker.ts @@ -0,0 +1,25 @@ +import fs from 'fs-extra'; +import path from 'node:path'; +import { parentPort } from 'node:worker_threads'; +import { dtsCompile } from '../helpers/dts.js'; +import { Rpc } from '../helpers/rpc.js'; +import { DeclarationMainMethods, DeclarationWorkerMethods } from './declaration.rpc.js'; + +const rpc = new Rpc(parentPort, { + run: async (outputDirs, options) => { + const dtsFiles = await dtsCompile(options); + + await Promise.all(outputDirs.map(async (dir) => { + await fs.ensureDir(dir); + for (const file of dtsFiles) { + if (!file.dtsContent) { + continue; + } + const relDtsPath = path.relative(options.outputDir, file.dtsPath); + const dtsPath = path.join(dir, relDtsPath); + await fs.ensureDir(path.dirname(dtsPath)); + await fs.writeFile(dtsPath, file.dtsContent); + } + })); + }, +}); diff --git a/packages/pkg/src/types.ts b/packages/pkg/src/types.ts index a37d139b..14ea4c25 100644 --- a/packages/pkg/src/types.ts +++ b/packages/pkg/src/types.ts @@ -103,6 +103,17 @@ export interface BundleUserConfig { browser?: boolean; } + +export interface DeclarationUserConfig { + /** + * How to output declaration files. + * - 'multi' output .d.ts to every transform format folder, like esm/es2017 + * - 'unique' output .d.ts to `typings` folder of the root + * @default 'multi' + */ + outputMode?: 'multi' | 'unique'; +} + export interface UserConfig { /** * Entry for a task @@ -134,7 +145,7 @@ export interface UserConfig { * Generate .d.ts files from TypeScript files in your project. * @default true */ - declaration?: boolean; + declaration?: boolean | DeclarationUserConfig; /** * Configure JSX transform type. @@ -248,7 +259,19 @@ export interface TransformTaskConfig extends _TaskConfig, TransformUserConfig { define?: Record; } -export type TaskConfig = BundleTaskConfig | TransformTaskConfig; +export interface DeclarationTaskConfig extends _TaskConfig, DeclarationUserConfig { + type: 'declaration'; + /** + * 记录 transform 配置的 format 用于计算实际的输出目录 + */ + transformFormats?: TransformUserConfig['formats']; + /** + * 实际的输出目录,可以同时输出到 esm、es2017 内等 + */ + declarationOutputDirs?: string[]; +} + +export type TaskConfig = BundleTaskConfig | TransformTaskConfig | DeclarationTaskConfig; export type BuildTask = _BuildTask; @@ -273,6 +296,7 @@ export enum TaskName { 'TRANSFORM_ES2017' = 'transform-es2017', 'BUNDLE_ES5' = 'bundle-es5', 'BUNDLE_ES2017' = 'bundle-es2017', + 'DECLARATION' = 'declaration' } type TaskKey = keyof typeof TaskName; // TODO: The type name should be renamed to TaskName. diff --git a/packages/pkg/tests/helpers/rpc.test.ts b/packages/pkg/tests/helpers/rpc.test.ts new file mode 100644 index 00000000..083f5c25 --- /dev/null +++ b/packages/pkg/tests/helpers/rpc.test.ts @@ -0,0 +1,54 @@ +import { describe, it, expect } from 'vitest'; +import { Rpc, RpcMethods } from '../../src/helpers/rpc'; +import { MessageChannel } from 'node:worker_threads'; + +interface TestMethods extends RpcMethods { + testMethod(arg: string): Promise; +} + +const serverMethods: TestMethods = { + testMethod: async (arg: string) => `result-${arg}`, +}; + +describe('Rpc', () => { + it('should handle call method correctly', async () => { + const channel = new MessageChannel(); + const clientRpc = new Rpc(channel.port1, {}); + const serverRpc = new Rpc<{}, TestMethods>(channel.port2, serverMethods); + const resultPromise = clientRpc.call('testMethod', ['arg1']); + const result = await resultPromise; + expect(result) + .toBe('result-arg1'); + }); + + it('should handle errors in the server method', async () => { + const channel = new MessageChannel(); + const serverMethodsWithError: TestMethods = { + testMethod: async (arg: string) => { + if (arg === 'error') { + throw new Error('Server error'); + } + return `result-${arg}`; + }, + }; + const clientRpc = new Rpc(channel.port1, {}); + const serverRpc = new Rpc<{}, TestMethods>(channel.port2, serverMethodsWithError); + try { + await clientRpc.call('testMethod', ['error']); + } catch (error) { + expect(error.message) + .toBe('Server error'); + } + }); + + it('should throw error for non-existent method', async () => { + const channel = new MessageChannel(); + const serverRpc = new Rpc<{}, TestMethods>(channel.port2, serverMethods); + const clientRpc = new Rpc(channel.port1, {}); + try { + await clientRpc.call('nonExistentMethod', []); + } catch (error) { + expect(error.message).toBe('Method nonExistentMethod not found'); + } + }); +}); diff --git a/packages/pkg/tests/helpers/runner.test.ts b/packages/pkg/tests/helpers/runner.test.ts index 74ef14c6..6fb9afdb 100644 --- a/packages/pkg/tests/helpers/runner.test.ts +++ b/packages/pkg/tests/helpers/runner.test.ts @@ -73,7 +73,7 @@ describe('Runner Tests', () => { { const metric = runner.getMetric('testMark'); expect(metric.delta.length).toEqual(2) - expect(metric.cost).toBeGreaterThan(50) + expect(metric.cost).toBeGreaterThanOrEqual(50) } }); diff --git a/packages/pkg/tests/projects/__snapshots__/default.test.ts.snap b/packages/pkg/tests/projects/__snapshots__/default.test.ts.snap index 567385a5..671de2e7 100644 --- a/packages/pkg/tests/projects/__snapshots__/default.test.ts.snap +++ b/packages/pkg/tests/projects/__snapshots__/default.test.ts.snap @@ -143,6 +143,32 @@ exports[`Run config default > esm structure 1`] = ` } `; +exports[`Run config no-declaration > cjs structure 1`] = `null`; + +exports[`Run config no-declaration > dist structure 1`] = `null`; + +exports[`Run config no-declaration > es2017 structure 1`] = ` +{ + "files": [ + { + "name": "index.js", + }, + ], + "name": "es2017", +} +`; + +exports[`Run config no-declaration > esm structure 1`] = ` +{ + "files": [ + { + "name": "index.js", + }, + ], + "name": "esm", +} +`; + exports[`Run config sourcemap-enable > cjs structure 1`] = `null`; exports[`Run config sourcemap-enable > dist structure 1`] = `null`; diff --git a/packages/pkg/tests/projects/default.test.ts b/packages/pkg/tests/projects/default.test.ts index 225afab6..f014408a 100644 --- a/packages/pkg/tests/projects/default.test.ts +++ b/packages/pkg/tests/projects/default.test.ts @@ -60,5 +60,12 @@ runProjectTest('default', [ config: { sourceMaps: true } - } + }, + { + name: 'no-declaration', + snapshot: 'structure', + config: { + declaration: false + } + }, ])