From 69e5a0103ff6a5c1d1cebc5559537dc2fef5e4f7 Mon Sep 17 00:00:00 2001
From: XGHeaven <xgheaven@gmail.com>
Date: Thu, 31 Oct 2024 17:44:09 +0800
Subject: [PATCH] feat: add declaration task

---
 .changeset/long-experts-design.md             |   5 +
 packages/pkg/src/config/userConfig.ts         |  29 ++-
 packages/pkg/src/helpers/dts.ts               | 192 +++++++++---------
 .../src/helpers/formatAliasToTSPathsConfig.ts |  25 ---
 packages/pkg/src/helpers/getBuildTasks.ts     |   9 +
 packages/pkg/src/helpers/getRollupOptions.ts  |  14 +-
 packages/pkg/src/helpers/getTaskRunners.ts    |   5 +
 packages/pkg/src/helpers/rpc.ts               | 116 +++++++++++
 packages/pkg/src/helpers/runnerGroup.ts       |  42 ++++
 packages/pkg/src/plugins/component.ts         |  11 +-
 packages/pkg/src/rollupPlugins/dts.ts         |  95 ---------
 packages/pkg/src/tasks/declaration.rpc.ts     |  14 ++
 packages/pkg/src/tasks/declaration.ts         |  69 +++++++
 packages/pkg/src/tasks/declaration.worker.ts  |  25 +++
 packages/pkg/src/types.ts                     |  28 ++-
 packages/pkg/tests/helpers/rpc.test.ts        |  54 +++++
 packages/pkg/tests/helpers/runner.test.ts     |   2 +-
 .../__snapshots__/default.test.ts.snap        |  26 +++
 packages/pkg/tests/projects/default.test.ts   |   9 +-
 19 files changed, 539 insertions(+), 231 deletions(-)
 create mode 100644 .changeset/long-experts-design.md
 delete mode 100644 packages/pkg/src/helpers/formatAliasToTSPathsConfig.ts
 create mode 100644 packages/pkg/src/helpers/rpc.ts
 create mode 100644 packages/pkg/src/helpers/runnerGroup.ts
 delete mode 100644 packages/pkg/src/rollupPlugins/dts.ts
 create mode 100644 packages/pkg/src/tasks/declaration.rpc.ts
 create mode 100644 packages/pkg/src/tasks/declaration.ts
 create mode 100644 packages/pkg/src/tasks/declaration.worker.ts
 create mode 100644 packages/pkg/tests/helpers/rpc.test.ts

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<DeclarationTaskConfig>(
+              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<DtsInputFile[]> {
-  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<ts.ParsedCommandLine> {
+  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<DtsInputFile[]> {
+  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<DtsInputFile>(({ 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<ts.ParsedCommandLine> {
-  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<string, string>,
-          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<string, (...args: any[]) => Promise<any>>;
+
+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<R extends RpcMethods, L extends RpcMethods> {
+  private requestId = 0;
+  private requestStore = new Map<number, [resolve: (v: unknown) => 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<K extends keyof R>(name: K, args: Parameters<R[K]>): ReturnType<R[K]> {
+    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<R[K]>;
+  }
+
+  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<T> {
+  private parallelRunners: Array<Runner<T>> = [];
+  private concurrentRunners: Array<Runner<T>> = [];
+
+  constructor(public runners: Array<Runner<T>>, 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<T[]> {
+    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<string, string>;
-  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<string, CachedContent> = {};
-
-  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<void>;
+};
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<OutputResult> {
+  // 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<string, string>);
+      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<string>();
+      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<DeclarationWorkerMethods, DeclarationMainMethods>(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<DeclarationMainMethods, DeclarationWorkerMethods>(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<string, string>;
 }
 
-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<TaskConfig, TaskName>;
 
@@ -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<string>;
+}
+
+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<TestMethods, {}>(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<TestMethods, {}>(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<TestMethods, {}>(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
+    }
+  },
 ])