├── .changeset ├── README.md └── config.json ├── .gitattributes ├── .github └── workflows │ └── ci.yml ├── .gitignore ├── .husky ├── commit-msg └── pre-commit ├── .lintstagedrc ├── .prettierignore ├── .prettierrc ├── LICENSE ├── README-zh.md ├── README.md ├── codecov.yml ├── commitlint.config.js ├── eslint.config.mjs ├── package.json ├── packages ├── benchmark │ ├── CHANGELOG.md │ ├── README-zh.md │ ├── README.md │ ├── dts-bundle.config.json │ ├── package.json │ ├── src │ │ ├── benchmark.ts │ │ ├── helper.ts │ │ ├── index.ts │ │ └── types.ts │ ├── tsconfig.json │ └── tsup.config.ts ├── core │ ├── CHANGELOG.md │ ├── LICENSE │ ├── README-zh.md │ ├── README.md │ ├── __tests__ │ │ ├── browser │ │ │ ├── fileUtils.spec.ts │ │ │ ├── getFileHashChunks.spec.ts │ │ │ ├── helper.spec.ts │ │ │ ├── helper2.spec.ts │ │ │ ├── workerPoolForHash.spec.ts │ │ │ └── workerWrapper.spec.ts │ │ ├── fixture │ │ │ ├── mockBlob.ts │ │ │ ├── mockFile.txt │ │ │ ├── mockMiniSubject.ts │ │ │ ├── mockWebWorker.ts │ │ │ ├── mockWorkerPool.ts │ │ │ └── mockWorkerWrapper.ts │ │ └── node │ │ │ ├── arrayUtils.spec.ts │ │ │ ├── getRootHashByChunks.spec.ts │ │ │ ├── is.spec.ts │ │ │ ├── merkleTree.spec.ts │ │ │ ├── miniSubject.spec.ts │ │ │ ├── workerPool.spec.ts │ │ │ └── workerWrapper.spec.ts │ ├── jest.config.ts │ ├── package.json │ ├── rollup.config.ts │ ├── src │ │ ├── entity │ │ │ ├── index.ts │ │ │ ├── merkleTree.ts │ │ │ ├── workerPool.ts │ │ │ └── workerWrapper.ts │ │ ├── getFileHashChunks.ts │ │ ├── getRootHashByChunks.ts │ │ ├── helper.ts │ │ ├── iife.ts │ │ ├── interface │ │ │ ├── fileHashChunks.ts │ │ │ ├── fileMetaInfo.ts │ │ │ ├── fnTypes.ts │ │ │ ├── index.ts │ │ │ ├── strategy.ts │ │ │ ├── workerReq.ts │ │ │ └── workerRes.ts │ │ ├── main.ts │ │ ├── utils │ │ │ ├── arrayUtils.ts │ │ │ ├── fileUtils.ts │ │ │ ├── index.ts │ │ │ ├── is.ts │ │ │ ├── miniSubject.ts │ │ │ └── rand.ts │ │ └── worker │ │ │ ├── hash.worker.ts │ │ │ ├── workerPoolForHash.ts │ │ │ └── workerService.ts │ └── tsconfig.json └── playground │ ├── benchmark-demo │ ├── package.json │ ├── src │ │ └── index.ts │ ├── tsconfig.json │ └── tsup.config.ts │ ├── iife-demo │ ├── index.html │ ├── package.json │ ├── prepare.js │ └── server.js │ ├── node-demo │ ├── package.json │ ├── src │ │ └── index.ts │ ├── tsconfig.json │ └── tsup.config.ts │ ├── react-webpack-demo │ ├── .babelrc │ ├── package.json │ ├── public │ │ └── index.html │ ├── src │ │ └── index.tsx │ ├── tsconfig.json │ └── webpack.config.js │ └── vue-vite-demo │ ├── index.html │ ├── package.json │ ├── src │ ├── App.vue │ ├── hooks │ │ └── useFileHashInfo.ts │ └── main.ts │ ├── tsconfig.json │ └── vite.config.ts ├── pnpm-lock.yaml ├── pnpm-workspace.yaml ├── scripts ├── clear.js ├── fileCopier.js └── syncReadme.js ├── tsconfig.json └── turbo.json /.changeset/README.md: -------------------------------------------------------------------------------- 1 | # Changesets 2 | 3 | Hello and welcome! This folder has been automatically generated by `@changesets/cli`, a build tool that works 4 | with multi-package repos, or single-package repos to help you version and publish your code. You can 5 | find the full documentation for it [in our repository](https://github.com/changesets/changesets) 6 | 7 | We have a quick list of common questions to get you started engaging with this project in 8 | [our documentation](https://github.com/changesets/changesets/blob/main/docs/common-questions.md) 9 | -------------------------------------------------------------------------------- /.changeset/config.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://unpkg.com/@changesets/config@3.0.5/schema.json", 3 | "changelog": "@changesets/cli/changelog", 4 | "commit": false, 5 | "fixed": [], 6 | "linked": [], 7 | "access": "restricted", 8 | "baseBranch": "main", 9 | "updateInternalDependencies": "patch", 10 | "ignore": [] 11 | } 12 | -------------------------------------------------------------------------------- /.gitattributes: -------------------------------------------------------------------------------- 1 | # Auto detect text files and perform LF normalization 2 | * text=auto 3 | -------------------------------------------------------------------------------- /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | name: Workflow for Codecov 2 | on: [push] 3 | jobs: 4 | run: 5 | runs-on: ubuntu-latest 6 | steps: 7 | - uses: actions/checkout@v4 8 | - name: Install berry 9 | run: corepack enable 10 | - uses: actions/setup-node@v3 11 | with: 12 | node-version: 22 13 | - name: Install Dependencies 14 | run: pnpm install --no-frozen-lockfile 15 | - name: Run Test 16 | run: pnpm test 17 | - name: Upload coverage reports to Codecov 18 | uses: codecov/codecov-action@v4.0.1 19 | with: 20 | token: ${{ secrets.CODECOV_TOKEN }} 21 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # See http://help.github.com/ignore-files/ for more about ignoring files. 2 | 3 | # Compiled output, 排除打包产物 4 | dist 5 | /output 6 | /tmp 7 | /out-tsc 8 | /bazel-out 9 | 10 | # 排除子项目的打包产物 11 | **/output 12 | **/.turbo 13 | 14 | # Node, 忽略任意层级下的 node_modules 目录 15 | **/node_modules/ 16 | npm-debug.log 17 | yarn-error.log 18 | 19 | # 忽略覆盖率 20 | **/coverage/ 21 | 22 | # IDEs and editors 23 | .idea/ 24 | .project 25 | .classpath 26 | .c9/ 27 | *.launch 28 | .settings/ 29 | *.sublime-workspace 30 | 31 | # Visual Studio Code 32 | .vscode/* 33 | !.vscode/settings.json 34 | !.vscode/tasks.json 35 | !.vscode/launch.json 36 | !.vscode/extensions.json 37 | .history/* 38 | 39 | # Miscellaneous 40 | /.angular/cache 41 | .sass-cache/ 42 | /connect.lock 43 | /libpeerconnection.log 44 | testem.log 45 | /typings 46 | 47 | # System files 48 | .DS_Store 49 | Thumbs.db 50 | 51 | # browser-demo 52 | /packages/playground/iife-demo/global.js 53 | /packages/playground/iife-demo/worker/* 54 | -------------------------------------------------------------------------------- /.husky/commit-msg: -------------------------------------------------------------------------------- 1 | npx --no-install commitlint --edit "$1" -------------------------------------------------------------------------------- /.husky/pre-commit: -------------------------------------------------------------------------------- 1 | npx lint-staged 2 | -------------------------------------------------------------------------------- /.lintstagedrc: -------------------------------------------------------------------------------- 1 | { 2 | "*.{js,jsx,ts,tsx}": ["eslint --fix", "prettier --write"] 3 | } 4 | -------------------------------------------------------------------------------- /.prettierignore: -------------------------------------------------------------------------------- 1 | /dist/ 2 | /node_modules/ 3 | /coverage/ 4 | /.idea/ 5 | /.husky/ 6 | /.github/ 7 | /.changeset/ 8 | -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "printWidth": 100, 3 | "semi": false, 4 | "singleQuote": true, 5 | "trailingComma": "all", 6 | "proseWrap": "never", 7 | "htmlWhitespaceSensitivity": "strict", 8 | "endOfLine": "auto" 9 | } 10 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2024 Tkunl 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README-zh.md: -------------------------------------------------------------------------------- 1 | # Hash Worker [![npm package](https://img.shields.io/npm/v/hash-worker.svg)](https://www.npmjs.com/package/hash-worker) [![Bundle size](https://badgen.net/bundlephobia/minzip/hash-worker)](https://bundlephobia.com/result?p=hash-worker) [![codecov](https://codecov.io/gh/Tkunl/hash-worker/graph/badge.svg?token=G7GYAPEPYS)](https://codecov.io/gh/Tkunl/hash-worker) ![GitHub License](https://img.shields.io/github/license/Tkunl/hash-worker) 2 | 3 |

4 | 5 |

6 | 7 | ## Introduce 8 | 9 | [English Document](./README.md) 10 | 11 | **Hash-worker** 是一个用于快速计算文件哈希值的库。 12 | 13 | 它基于 hash-wasm 且利用了 WebWorker 进行并行计算,从而加快了计算文件分片的计算速度。 14 | 15 | Hash-worker 支持三种哈希计算算法:`md5`, `crc32` 和 `xxHash64`。 16 | 17 | 同时支持 `浏览器` 和 `Node.js` 环境。 18 | 19 | > [!WARNING] 20 | > Hash-worker 计算出的 MerkleHash 是基于文件块哈希值构建的 MerkleTree 的根哈希值。请注意,这并不直接等同于文件本身的哈希值。 21 | 22 | ## Install 23 | 24 | ```bash 25 | $ pnpm install hash-worker 26 | ``` 27 | 28 | ## Usage 29 | 30 | ### Global 31 | 32 | ```html 33 | 34 | 35 | 36 | 39 | ``` 40 | 41 | 其中 `global.js` 和 `hash.worker.mjs` 是执行 `package.json` 中的 `build:core` 后的打包产物 42 | 43 | 打包产物位于 `packages/core/dist` 目录 44 | 45 | ### ESM 46 | 47 | ``` ts 48 | import { getFileHashChunks, destroyWorkerPool, HashChksRes, HashChksParam } from 'hash-worker' 49 | 50 | function handleGetHash(file: File) { 51 | const param: HashChksParam = { 52 | file: file, 53 | config: { 54 | workerCount: 8, 55 | strategy: Strategy.md5 56 | } 57 | } 58 | 59 | getFileHashChunks(param).then((data: HashChksRes) => { 60 | console.log('chunksHash', data.chunksHash) 61 | }) 62 | } 63 | 64 | /** 65 | * Destroy Worker Thread 66 | */ 67 | function handleDestroyWorkerPool() { 68 | destroyWorkerPool() 69 | } 70 | ``` 71 | 72 | > [!WARNING] 73 | > 如果你在使用 `Vite` 作为构建工具, 需要在 `Vite` 的配置文件中, 添加如下配置, 用于排除 vite 的依赖优化 74 | 75 | ```js 76 | // vite.config.js 77 | import { defineConfig } from 'vite' 78 | import vue from '@vitejs/plugin-vue' 79 | 80 | export default defineConfig({ 81 | plugins: [vue()], 82 | // other configurations ... 83 | optimizeDeps: { 84 | exclude: ['hash-worker'] // new added.. 85 | } 86 | }) 87 | ``` 88 | 89 | > [!WARNING] 90 | > 如果你在使用 `Webpack` 作为构建工具, 需要在 Webpack 的配置文件中, 添加如下配置, 用于排除 node 相关模块的解析 91 | 92 | ```js 93 | // webpack.config.js 94 | module.exports = { 95 | // new added.. 96 | resolve: { 97 | fallback: { 98 | fs: false, 99 | path: false, 100 | 'fs/promises': false, 101 | worker_threads: false, 102 | }, 103 | }, 104 | // new added.. 105 | externals: { 106 | fs: 'commonjs fs', 107 | path: 'commonjs path', 108 | 'fs/promises': 'commonjs fs/promises', 109 | worker_threads: 'commonjs worker_threads', 110 | }, 111 | } 112 | ``` 113 | 114 | ## Options 115 | 116 | **HashChksParam** 117 | 118 | HashChksParam 是用于配置计算哈希值所需的参数。 119 | 120 | | filed | type | default | description | 121 | |----------|--------|---------|-----------------------------| 122 | | file | File | / | 需要计算 Hash 的文件(浏览器环境下必填) | 123 | | filePath | string | / | 需要计算 Hash 的文件路径 (Node环境下必填) | 124 | | config | Config | Config | 计算 Hash 时的参数 | 125 | 126 | **Config** 127 | 128 | | filed | type | default | description | 129 | |--------------------------|----------|----------------|---------------------------| 130 | | chunkSize | number | 10 (MB) | 文件分片的大小 | 131 | | workerCount | number | 8 | 计算 Hash 时同时开启的 worker 数量 | 132 | | strategy | Strategy | Strategy.mixed | hash 计算策略 | 133 | | borderCount | number | 100 | 'mixed' 模式下 hash 计算规则的分界点 | 134 | | isCloseWorkerImmediately | boolean | true | 当计算完成时, 是否立即销毁 Worker 线程 | 135 | 136 | ```ts 137 | // strategy.ts 138 | export enum Strategy { 139 | md5 = 'md5', 140 | crc32 = 'crc32', 141 | xxHash64 = 'xxHash64', 142 | mixed = 'mixed', 143 | } 144 | ``` 145 | 146 | 当采用 Strategy.mixed 策略时,若文件分片数量少于 borderCount,将采用 md5 算法计算哈希值来构建 MerkleTree。 147 | 否则,则切换至使用 crc32 算法进行 MerkleTree 的构建。 148 | 149 | **HashChksRes** 150 | 151 | HashChksRes 是计算哈希值之后的返回结果。 152 | 153 | | filed | type | description | 154 | |------------|--------------|--------------------------| 155 | | chunksBlob | Blob[] | 仅在浏览器环境下,会返回文件分片的 Blob[] | 156 | | chunksHash | string[] | 文件分片的 Hash[] | 157 | | merkleHash | string | 文件的 merkleHash | 158 | | metadata | FileMetaInfo | 文件的 metadata | 159 | 160 | **FileMetaInfo** 161 | 162 | | filed | type | description | 163 | |--------------|--------|----------------| 164 | | name | string | 用于计算 hash 的文件名 | 165 | | size | number | 文件大小,单位:KB | 166 | | lastModified | number | 文件最后一次修改的时间戳 | 167 | | type | string | 文件后缀名 | 168 | 169 | ### [Benchmark (MD5)](./packages/benchmark/README-zh.md) 170 | 171 | | Worker Count | Speed | 172 | |--------------|-----------| 173 | | 1 | 229 MB/s | 174 | | 4 | 632 MB/s | 175 | | 8 | 886 MB/s | 176 | | 12 | 1037 MB/s | 177 | 178 | * 以上数据是运行在 `Chrome v131` 和 `AMD Ryzen9 5950X` CPU 下, 通过使用 md5 来计算 hash 得到的。 179 | 180 | ## LICENSE 181 | 182 | [MIT](./LICENSE) 183 | 184 | ## Contributions 185 | 186 | 欢迎贡献代码!如果你发现了一个 bug 或者想添加一个新功能,请提交一个 issue 或 pull request。 187 | 188 | ## Author and contributors 189 | 190 |

191 | 192 | Tkunl 193 | 194 | 195 | Kanno 196 | 197 | 198 | Eternal-could 199 | 200 |

201 | 202 | 203 | 204 | 205 | 206 | 207 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Hash Worker [![npm package](https://img.shields.io/npm/v/hash-worker.svg)](https://www.npmjs.com/package/hash-worker) [![Bundle size](https://badgen.net/bundlephobia/minzip/hash-worker)](https://bundlephobia.com/result?p=hash-worker) [![codecov](https://codecov.io/gh/Tkunl/hash-worker/graph/badge.svg?token=G7GYAPEPYS)](https://codecov.io/gh/Tkunl/hash-worker) ![GitHub License](https://img.shields.io/github/license/Tkunl/hash-worker) 2 | 3 |

4 | 5 |

6 | 7 | ## Introduce 8 | 9 | [中文文档](./README-zh.md) 10 | 11 | **Hash-worker** is a library for fast calculation of file chunk hashes. 12 | 13 | It is based on `hash-wasm` and utilizes `WebWorkers` for parallel computation, which speeds up computation when 14 | processing file blocks. 15 | 16 | Hash-worker supports three hash computation algorithms: `md5`, `crc32` and `xxHash64`. 17 | 18 | Both `browser` and `Node.js` are supported. 19 | 20 | > [!WARNING] 21 | > The merkleHash computed by the Hash-worker is the root hash of a MerkleTree constructed based on file block hashes. Note that this is not directly equivalent to a hash of the file itself. 22 | 23 | ## Install 24 | 25 | ```bash 26 | $ pnpm install hash-worker 27 | ``` 28 | 29 | ## Usage 30 | 31 | ### Global 32 | 33 | ```html 34 | 35 | 36 | 37 | 40 | ``` 41 | 42 | The `global.js` and `hash.worker.mjs` are the build artifacts resulting from executing `build:core` in `package.json`. 43 | 44 | The build artifacts are located in the `packages/core/dist` directory. 45 | 46 | ### ESM 47 | 48 | ``` ts 49 | import { getFileHashChunks, destroyWorkerPool, HashChksRes, HashChksParam } from 'hash-worker' 50 | 51 | function handleGetHash(file: File) { 52 | const param: HashChksParam = { 53 | file: file, 54 | config: { 55 | workerCount: 8, 56 | strategy: Strategy.md5 57 | } 58 | } 59 | 60 | getFileHashChunks(param).then((data: HashChksRes) => { 61 | console.log('chunksHash', data.chunksHash) 62 | }) 63 | } 64 | 65 | /** 66 | * Destroy Worker Thread 67 | */ 68 | function handleDestroyWorkerPool() { 69 | destroyWorkerPool() 70 | } 71 | ``` 72 | 73 | > [!WARNING] 74 | If you are using `Vite` as your build tool, you need to add some configurations in your `vite.config.js` to exclude hash-worker from optimizeDeps. 75 | 76 | ```js 77 | // vite.config.js 78 | import { defineConfig } from 'vite' 79 | import vue from '@vitejs/plugin-vue' 80 | 81 | export default defineConfig({ 82 | plugins: [vue()], 83 | // other configurations ... 84 | optimizeDeps: { 85 | exclude: ['hash-worker'] // new added.. 86 | } 87 | }) 88 | ``` 89 | 90 | > [!WARNING] 91 | > 92 | > If you are using `Webpack` as your build tool, you need add some configs in your `webpack.config.js` for exclude the parsing of node related modules. 93 | 94 | ```js 95 | // webpack.config.js 96 | module.exports = { 97 | // new added.. 98 | resolve: { 99 | fallback: { 100 | fs: false, 101 | path: false, 102 | 'fs/promises': false, 103 | worker_threads: false, 104 | }, 105 | }, 106 | // new added.. 107 | externals: { 108 | fs: 'commonjs fs', 109 | path: 'commonjs path', 110 | 'fs/promises': 'commonjs fs/promises', 111 | worker_threads: 'commonjs worker_threads', 112 | }, 113 | } 114 | ``` 115 | 116 | ## Options 117 | 118 | **HashChksParam** 119 | 120 | HashChksParam is used to configure the parameters needed to calculate the hash. 121 | 122 | | filed | type | default | description | 123 | |----------|--------|---------|--------------------------------------------------------------------------------------| 124 | | file | File | / | Files that need to calculate the hash (required for browser environments) | 125 | | filePath | string | / | Path to the file where the hash is to be calculated (required for Node environments) | 126 | | config | Config | Config | Parameters for calculating the Hash | 127 | 128 | **Config** 129 | 130 | | filed | type | default | description | 131 | |--------------------------|----------|----------------|-----------------------------------------------------------------------------------| 132 | | chunkSize | number | 10 (MB) | Size of the file slice | 133 | | workerCount | number | 8 | Number of workers turned on at the same time as the hash is calculated | 134 | | strategy | Strategy | Strategy.mixed | Hash computation strategy | 135 | | borderCount | number | 100 | The cutoff for the hash calculation rule in 'mixed' mode | 136 | | isCloseWorkerImmediately | boolean | true | Whether to destroy the worker thread immediately when the calculation is complete | 137 | 138 | ```ts 139 | // strategy.ts 140 | export enum Strategy { 141 | md5 = 'md5', 142 | crc32 = 'crc32', 143 | xxHash64 = 'xxHash64', 144 | mixed = 'mixed', 145 | } 146 | ``` 147 | 148 | When Strategy.mixed strategy is used, if the number of file fragments is less than borderCount, the md5 algorithm will 149 | be used to calculate the hash value to build the MerkleTree. 150 | Otherwise, it switches to using the crc32 algorithm for MerkleTree construction. 151 | 152 | **HashChksRes** 153 | 154 | HashChksRes is the returned result after calculating the hash value. 155 | 156 | | filed | type | description | 157 | |------------|--------------|-------------------------------------------------------------------------| 158 | | chunksBlob | Blob[] | In a browser environment only, the Blob[] of the file slice is returned | 159 | | chunksHash | string[] | Hash[] for file slicing | 160 | | merkleHash | string | The merkleHash of the file | 161 | | metadata | FileMetaInfo | The metadata of the file | 162 | 163 | **FileMetaInfo** 164 | 165 | | filed | type | description | 166 | |--------------|--------|-------------------------------------------------| 167 | | name | string | The name of the file used to calculate the hash | 168 | | size | number | File size in KB | 169 | | lastModified | number | Timestamp of the last modification of the file | 170 | | type | string | file extension | 171 | 172 | ### [Benchmark (MD5)](./packages/benchmark/README.md) 173 | 174 | | Worker Count | Speed | 175 | |--------------|-----------| 176 | | 1 | 229 MB/s | 177 | | 4 | 632 MB/s | 178 | | 8 | 886 MB/s | 179 | | 12 | 1037 MB/s | 180 | 181 | The above data is run on the `Chrome v131` and `AMD Ryzen9 5950X` CPU, by using md5 to calculate hash. 182 | 183 | ## LICENSE 184 | 185 | [MIT](./LICENSE) 186 | 187 | ## Contributions 188 | 189 | Contributions are welcome! If you find a bug or want to add a new feature, please open an issue or submit a pull 190 | request. 191 | 192 | ## Author and contributors 193 | 194 |

195 | 196 | Tkunl 197 | 198 | 199 | Eternal-could 200 | 201 | 202 | Kanno 203 | 204 |

205 | -------------------------------------------------------------------------------- /codecov.yml: -------------------------------------------------------------------------------- 1 | codecov: 2 | require_ci_to_pass: true 3 | 4 | coverage: 5 | status: 6 | project: 7 | default: 8 | target: 90% 9 | threshold: 2% 10 | base: auto 11 | if_ci_failed: error 12 | branches: 13 | - 'master' 14 | 15 | comment: 16 | layout: 'reach, diff, flags, files' 17 | behavior: default 18 | require_changes: false 19 | branches: 20 | - 'master' 21 | -------------------------------------------------------------------------------- /commitlint.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | extends: ['@commitlint/config-conventional'], 3 | rules: { 4 | 'type-enum': [ 5 | // type枚举 6 | 2, 7 | 'always', 8 | [ 9 | 'build', // 编译相关的修改,例如发布版本、对项目构建或者依赖的改动 10 | 'feat', // 新功能 11 | 'fix', // 修补bug 12 | 'docs', // 文档修改 13 | 'style', // 代码格式修改, 注意不是 css 修改 14 | 'refactor', // 重构 15 | 'perf', // 优化相关,比如提升性能、体验 16 | 'test', // 测试用例修改 17 | 'revert', // 代码回滚 18 | 'ci', // 持续集成修改 19 | 'config', // 配置修改 20 | 'chore', // 其他改动 21 | ], 22 | ], 23 | 'type-empty': [2, 'never'], // never: type 不能为空; always: type 必须为空 24 | 'type-case': [0, 'always', 'lower-case'], // type 必须小写,upper-case大写,camel-case小驼峰,kebab-case短横线,pascal-case大驼峰,等等 25 | 'scope-empty': [0], 26 | 'scope-case': [0], 27 | 'subject-empty': [2, 'never'], // subject不能为空 28 | 'subject-case': [0], 29 | 'subject-full-stop': [0, 'never', '.'], // subject 以.为结束标记 30 | 'header-max-length': [2, 'always', 72], // header 最长72 31 | 'body-leading-blank': [0], // body 换行 32 | 'footer-leading-blank': [0, 'always'], // footer 以空行开头 33 | }, 34 | } 35 | -------------------------------------------------------------------------------- /eslint.config.mjs: -------------------------------------------------------------------------------- 1 | import typescriptEslint from '@typescript-eslint/eslint-plugin' 2 | import prettier from 'eslint-plugin-prettier' 3 | import globals from 'globals' 4 | import tsParser from '@typescript-eslint/parser' 5 | import path from 'node:path' 6 | import { fileURLToPath } from 'node:url' 7 | import js from '@eslint/js' 8 | import { FlatCompat } from '@eslint/eslintrc' 9 | 10 | const __filename = fileURLToPath(import.meta.url) 11 | const __dirname = path.dirname(__filename) 12 | const compat = new FlatCompat({ 13 | baseDirectory: __dirname, 14 | recommendedConfig: js.configs.recommended, 15 | allConfig: js.configs.all, 16 | }) 17 | 18 | export default [ 19 | { 20 | ignores: ['**/node_modules/', '**/output/', '**/dist/', '**/playground/'], 21 | }, 22 | ...compat.extends( 23 | 'eslint:recommended', 24 | 'plugin:@typescript-eslint/recommended', 25 | 'plugin:prettier/recommended', 26 | ), 27 | { 28 | plugins: { 29 | '@typescript-eslint': typescriptEslint, 30 | prettier, 31 | }, 32 | 33 | languageOptions: { 34 | globals: { 35 | ...globals.browser, 36 | ...globals.node, 37 | }, 38 | 39 | parser: tsParser, 40 | ecmaVersion: 2022, 41 | sourceType: 'module', 42 | }, 43 | 44 | rules: { 45 | 'prettier/prettier': 'error', 46 | '@typescript-eslint/no-unused-vars': 'error', 47 | '@typescript-eslint/no-explicit-any': 'off', 48 | '@typescript-eslint/ban-ts-comment': 'off', 49 | '@typescript-eslint/no-require-imports': 'off', 50 | 51 | '@typescript-eslint/no-unused-expressions': [ 52 | 'error', 53 | { 54 | allowShortCircuit: true, 55 | allowTernary: true, 56 | allowTaggedTemplates: true, 57 | }, 58 | ], 59 | }, 60 | }, 61 | ] 62 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "private": true, 3 | "packageManager": "pnpm@9.15.2", 4 | "scripts": { 5 | "dev:core": "pnpm --filter=./packages/core run dev", 6 | "dev:benchmark": "pnpm --filter=./packages/benchmark run dev", 7 | "build:core": "turbo run build", 8 | "build:benchmark": "turbo run build:benchmark", 9 | "build:node-demo": "turbo run build:node-demo", 10 | "build:benchmark-demo": "turbo run build:benchmark-demo", 11 | "build:all": "turbo run build:all", 12 | "play-benchmark": "pnpm run build:benchmark-demo && pnpm --filter=./packages/playground/benchmark-demo run play", 13 | "play-node": "pnpm run build:node-demo && pnpm --filter=./packages/playground/node-demo run play", 14 | "play-iife": "pnpm run build:core && pnpm --filter=./packages/playground/iife-demo run play", 15 | "play-vue-vite": "pnpm run build:core && pnpm --filter=./packages/playground/vue-vite-demo run play", 16 | "play-react-webpack": "pnpm run build:core && pnpm --filter=./packages/playground/react-webpack-demo run play", 17 | "test": "pnpm --filter=./packages/core run test", 18 | "lint": "eslint --fix", 19 | "format": "prettier --write '**/*.{js,jsx,ts,tsx,json}'", 20 | "check-format": "prettier --check '**/*.{js,jsx,ts,tsx,json}'", 21 | "prepare": "husky", 22 | "pre-commit": "lint-staged", 23 | "commitlint": "commitlint --config commitlint.config.js -e -V", 24 | "sync-readme": "node scripts/syncReadme.js", 25 | "clear:node_modules": "node scripts/clear.js --pattern=node_modules", 26 | "clear:dist": "node scripts/clear.js --pattern=dist", 27 | "clear:cache": "node scripts/clear.js --pattern=cache", 28 | "clear:coverage": "node scripts/clear.js --pattern=coverage", 29 | "clear:all": "node scripts/clear.js --pattern=all" 30 | }, 31 | "keywords": [ 32 | "hash-worker", 33 | "hash" 34 | ], 35 | "author": "Tkunl", 36 | "license": "MIT", 37 | "devDependencies": { 38 | "@changesets/cli": "^2.27.11", 39 | "@commitlint/cli": "^19.6.1", 40 | "@commitlint/config-conventional": "^19.6.0", 41 | "@eslint/eslintrc": "^3.2.0", 42 | "@eslint/js": "^9.18.0", 43 | "@jest/types": "^29.6.3", 44 | "@rollup/plugin-node-resolve": "^15.3.1", 45 | "@swc/core": "^1.10.7", 46 | "@types/jest": "^29.5.12", 47 | "@types/node": "^20.14.2", 48 | "@typescript-eslint/eslint-plugin": "^8.19.1", 49 | "@typescript-eslint/parser": "^8.19.1", 50 | "browserslist": "^4.23.0", 51 | "chalk": "^5.4.1", 52 | "dts-bundle-generator": "^9.5.1", 53 | "eslint": "^9.18.0", 54 | "eslint-config-prettier": "^9.1.0", 55 | "eslint-plugin-prettier": "^5.2.1", 56 | "globals": "^15.14.0", 57 | "husky": "^9.1.7", 58 | "jest": "^29.7.0", 59 | "jest-environment-jsdom": "^29.7.0", 60 | "lint-staged": "^15.3.0", 61 | "prettier": "^3.4.2", 62 | "rimraf": "^6.0.1", 63 | "rollup": "^4.30.1", 64 | "rollup-plugin-dts": "^6.1.0", 65 | "rollup-plugin-swc3": "^0.12.1", 66 | "ts-jest": "^29.1.5", 67 | "ts-jest-mock-import-meta": "^1.2.0", 68 | "tsup": "^8.3.5", 69 | "tsx": "^4.11.0", 70 | "turbo": "^2.3.3", 71 | "typescript": "^5.4.5" 72 | } 73 | } 74 | -------------------------------------------------------------------------------- /packages/benchmark/CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # hash-worker-benchmark 2 | 3 | ## 1.0.0 4 | 5 | ### Major Changes 6 | 7 | - release version 1.0.0 8 | 9 | ### Patch Changes 10 | 11 | - Updated dependencies 12 | - hash-worker@1.0.0 13 | 14 | ## 0.0.1 15 | 16 | ### Patch Changes 17 | 18 | - Updated dependencies 19 | - hash-worker@0.1.3 20 | -------------------------------------------------------------------------------- /packages/benchmark/README-zh.md: -------------------------------------------------------------------------------- 1 | ## Introduce for benchmark 2 | 3 | 该项目用于测试 Hash worker 在不同线程下的哈希计算速度 4 | 5 | 它同时支持 `浏览器` 环境和 `Node.js` 环境 6 | 7 | ### Usage 8 | 9 | ```ts 10 | import { benchmark, BenchmarkOptions } from 'hash-worker-benchmark' 11 | 12 | // options 是可选的 13 | const options: BenchmarkOptions = {} 14 | benchmark(options) 15 | ``` 16 | 17 | ### Options 18 | 19 | **BenchmarkOptions** 20 | 21 | | filed | type | default | description | 22 | | ------------------- | -------- | --------------------------------------- |---------------------| 23 | | sizeInMB | number | 500 | 用于测试的文件大小 (MB) | 24 | | strategy | Strategy | Strategy.md5 | hash 计算策略 | 25 | | workerCountTobeTest | number[] | [1, 1, 1, 4, 4, 4, 8, 8, 8, 12, 12, 12] | 1/4/8/12 线程下各测试 3 次 | 26 | 27 | ```ts 28 | // strategy.ts 29 | export enum Strategy { 30 | md5 = 'md5', 31 | crc32 = 'crc32', 32 | xxHash64 = 'xxHash64', 33 | mixed = 'mixed', 34 | } 35 | ``` 36 | ### LICENSE 37 | 38 | [MIT](./../../LICENSE) 39 | -------------------------------------------------------------------------------- /packages/benchmark/README.md: -------------------------------------------------------------------------------- 1 | ## Introduce for benchmark 2 | 3 | This project is used to test the hash calculation speed of Hash worker in different threads. 4 | 5 | It supports both `Browser` and `Node.js` environments. 6 | 7 | ### Usage 8 | 9 | ```ts 10 | import { benchmark, BenchmarkOptions } from 'hash-worker-benchmark' 11 | 12 | // options is optional. 13 | const options: BenchmarkOptions = {} 14 | benchmark(options) 15 | ``` 16 | 17 | ### Options 18 | 19 | **BenchmarkOptions** 20 | 21 | | filed | type | default | description | 22 | | ------------------- | -------- | --------------------------------------- | -------------------------- | 23 | | sizeInMB | number | 500 | File size for testing (MB) | 24 | | strategy | Strategy | Strategy.md5 | Hash computation strategy | 25 | | workerCountTobeTest | number[] | [1, 1, 1, 4, 4, 4, 8, 8, 8, 12, 12, 12] | Hashing performance was measured 3 times in each of the 1/4/8/12 threads | 26 | 27 | ```ts 28 | // strategy.ts 29 | export enum Strategy { 30 | md5 = 'md5', 31 | crc32 = 'crc32', 32 | xxHash64 = 'xxHash64', 33 | mixed = 'mixed', 34 | } 35 | ``` 36 | ### LICENSE 37 | 38 | [MIT](./../../LICENSE) 39 | -------------------------------------------------------------------------------- /packages/benchmark/dts-bundle.config.json: -------------------------------------------------------------------------------- 1 | { 2 | "entries": [ 3 | { 4 | "filePath": "./src/index.ts", 5 | "outFile": "./dist/index.d.ts" 6 | } 7 | ] 8 | } 9 | -------------------------------------------------------------------------------- /packages/benchmark/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "hash-worker-benchmark", 3 | "version": "1.0.0", 4 | "private": true, 5 | "type": "module", 6 | "main": "./dist/index.cjs", 7 | "module": "./dist/index.js", 8 | "types": "./dist/index.d.ts", 9 | "exports": { 10 | ".": { 11 | "types": "./dist/index.d.ts", 12 | "require": "./dist/index.cjs", 13 | "import": "./dist/index.js" 14 | } 15 | }, 16 | "scripts": { 17 | "dev": "tsup --config tsup.config.ts --watch", 18 | "build:benchmark": "pnpm rm:dist && tsup --config tsup.config.ts && pnpm build:dts", 19 | "build:dts": "dts-bundle-generator --config dts-bundle.config.json", 20 | "rm:dist": "rimraf ./dist" 21 | }, 22 | "license": "MIT", 23 | "dependencies": { 24 | "hash-worker": "workspace:*" 25 | }, 26 | "peerDependencies": { 27 | "hash-worker": "workspace:*" 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /packages/benchmark/src/benchmark.ts: -------------------------------------------------------------------------------- 1 | import { getFileHashChunks, isBrowser, isNode, Strategy } from 'hash-worker' 2 | import { BenchmarkOptions, NormalizeOptions } from './types' 3 | import { 4 | createMockFile, 5 | createMockFileInLocal, 6 | deleteLocalFile, 7 | normalizeBenchmarkOptions, 8 | sleep, 9 | } from './helper' 10 | import { ChalkInstance } from 'chalk' 11 | 12 | const filePath = './data.txt' 13 | const fileName = 'data.txt' 14 | 15 | function buildParamsForBrowser(options: BenchmarkOptions): NormalizeOptions { 16 | const { sizeInMB, strategy, workerCountTobeTest } = normalizeBenchmarkOptions(options) 17 | const mockFile = createMockFile(fileName, sizeInMB) 18 | 19 | return { 20 | sizeInMB, 21 | params: workerCountTobeTest.map((workerCount) => ({ 22 | file: mockFile, 23 | config: { 24 | workerCount, 25 | strategy, 26 | }, 27 | })), 28 | } 29 | } 30 | 31 | function buildParamsForNode(options: BenchmarkOptions): NormalizeOptions { 32 | const { sizeInMB, strategy, workerCountTobeTest } = normalizeBenchmarkOptions(options) 33 | 34 | return { 35 | sizeInMB, 36 | params: workerCountTobeTest.map((workerCount) => ({ 37 | filePath, 38 | config: { 39 | workerCount, 40 | strategy, 41 | }, 42 | })), 43 | } 44 | } 45 | 46 | export async function benchmark(options: BenchmarkOptions = {}) { 47 | const isNodeEnv = isNode() 48 | const isBrowserEnv = isBrowser() 49 | const yellow = 'color: #FFB049;' 50 | let chalkYellow: ChalkInstance 51 | 52 | await initChalk() 53 | 54 | logInitialMsg() 55 | let normalizeOptions: NormalizeOptions 56 | if (isBrowserEnv) { 57 | console.log('Creating mock file ⏳') 58 | normalizeOptions = buildParamsForBrowser(options) 59 | } else if (isNodeEnv) { 60 | normalizeOptions = buildParamsForNode(options) 61 | } else { 62 | throw new Error('Unsupported environment') 63 | } 64 | 65 | const { sizeInMB, params } = normalizeOptions 66 | 67 | if (isNodeEnv) { 68 | console.log('Creating mock file ⏳') 69 | await createMockFileInLocal(filePath, sizeInMB) 70 | } 71 | 72 | let preWorkerCount = 1 73 | const preSpeed: number[] = [] 74 | 75 | const getAverageSpeed = (workerCount = 0) => { 76 | if (preSpeed.length === 0) return 77 | const averageSpeed = preSpeed.reduce((acc, cur) => acc + cur, 0) / preSpeed.length 78 | logAvgSpeed(averageSpeed) 79 | preWorkerCount = workerCount 80 | preSpeed.length = 0 81 | } 82 | 83 | logStrategy(normalizeOptions.params[0].config?.strategy) 84 | 85 | for (const param of params) { 86 | const workerCount = param.config!.workerCount! 87 | if (workerCount !== preWorkerCount) getAverageSpeed(workerCount) 88 | const beforeDate = Date.now() 89 | await getFileHashChunks(param) 90 | const overTime = Date.now() - beforeDate 91 | const speed = sizeInMB / (overTime / 1000) 92 | if (workerCount === preWorkerCount) preSpeed.push(speed) 93 | logCurSpeed(overTime, workerCount, speed) 94 | await sleep(1000) 95 | } 96 | getAverageSpeed(preWorkerCount) 97 | 98 | if (isNodeEnv) { 99 | console.log('Clearing temp file ⏳') 100 | await deleteLocalFile(filePath) 101 | } 102 | 103 | logCompletion() 104 | 105 | async function initChalk() { 106 | if (isNodeEnv) { 107 | const chalk = (await import('chalk')).default 108 | chalkYellow = chalk.hex('#FFB049') 109 | } 110 | } 111 | 112 | function logInitialMsg() { 113 | isBrowserEnv && console.log('%cHash Worker Benchmark 🎯', yellow) 114 | isNodeEnv && console.log(`${chalkYellow!('Hash Worker Benchmark')} 🎯`) 115 | } 116 | 117 | function logStrategy(strategy?: Strategy) { 118 | isBrowserEnv && console.log(`Running benchmark for %c${strategy} %cstrategy 🚀`, yellow, '') 119 | isNodeEnv && console.log(`Running benchmark for ${chalkYellow!(strategy + ' strategy')} 🚀`) 120 | } 121 | 122 | function logAvgSpeed(averageSpeed: number) { 123 | isBrowserEnv && console.log(`Average speed: %c${averageSpeed.toFixed(2)} Mb/s`, yellow) 124 | isNodeEnv && console.log(`Average speed: ${chalkYellow!(averageSpeed.toFixed(2) + 'Mb/s')}`) 125 | } 126 | 127 | function logCurSpeed(overTime: number, workerCount: number, speed: number) { 128 | isBrowserEnv && 129 | console.log( 130 | `Get file hash in: %c${overTime} ms%c by using %c${workerCount} worker%c, speed: %c${speed.toFixed(2)} Mb/s`, 131 | yellow, // 为 overTime 设置黄色 132 | '', // 重置为默认颜色 133 | yellow, // 为 workerCount 设置黄色 134 | '', // 重置为默认颜色 135 | yellow, // 为 speed 设置黄色 136 | ) 137 | isNodeEnv && 138 | console.log( 139 | `Get file hash in: ${chalkYellow!(overTime + ' ms')} by using ${chalkYellow!(workerCount) + ' worker'}, ` + 140 | `speed: ${chalkYellow!(speed.toFixed(2) + ' Mb/s')}`, 141 | ) 142 | } 143 | 144 | function logCompletion() { 145 | isBrowserEnv && console.log('%cDone 🎈', yellow) 146 | isNodeEnv && console.log(chalkYellow!('Done ') + '🎈') 147 | if (isBrowserEnv) { 148 | alert('Please check the console for benchmark information ~') 149 | } 150 | } 151 | } 152 | -------------------------------------------------------------------------------- /packages/benchmark/src/helper.ts: -------------------------------------------------------------------------------- 1 | import { BenchmarkOptions } from './types' 2 | import { Strategy } from 'hash-worker' 3 | 4 | export function normalizeBenchmarkOptions(options: BenchmarkOptions): Required { 5 | const defaultWorkerCountTobeTest = [1, 1, 1, 4, 4, 4, 8, 8, 8, 12, 12, 12] 6 | const { sizeInMB, strategy, workerCountTobeTest } = options 7 | 8 | const normalizeOptions = { 9 | sizeInMB: sizeInMB ?? 500, 10 | strategy: strategy ?? Strategy.md5, 11 | workerCountTobeTest: workerCountTobeTest ?? defaultWorkerCountTobeTest, 12 | } 13 | 14 | const { workerCountTobeTest: _workerCountTobeTest } = normalizeOptions 15 | 16 | if ( 17 | _workerCountTobeTest.length === 0 || 18 | _workerCountTobeTest.find((num: number) => num <= 0 || num > 32 || !Number.isInteger(num)) 19 | ) { 20 | throw new Error('Illegal workerCount') 21 | } 22 | 23 | return normalizeOptions 24 | } 25 | 26 | export async function sleep(ms: number) { 27 | await new Promise((rs) => setTimeout(() => rs(), ms)) 28 | } 29 | 30 | export async function createMockFileInLocal(filePath: string, sizeInMB: number): Promise { 31 | const { createWriteStream } = await import('fs') 32 | const { randomBytes } = await import('crypto') 33 | const generateRandomData = (size: number) => randomBytes(size).toString('hex') 34 | 35 | const stream = createWriteStream(filePath) 36 | const size = 1024 * 1024 * sizeInMB // 总大小转换为字节 37 | const chunkSize = 1024 * 512 // 每次写入512KB 38 | 39 | let written = 0 40 | 41 | return new Promise((resolve, reject) => { 42 | stream.on('error', reject) 43 | const write = (): void => { 44 | let ok = true 45 | do { 46 | const chunk = generateRandomData(chunkSize > size - written ? size - written : chunkSize) 47 | written += chunk.length / 2 // 更新已写入的长度,除以2因为hex字符串表示的字节长度是实际长度的一半 48 | 49 | if (written >= size) { 50 | // 如果达到或超过预定大小,则写入最后一个块并结束 51 | stream.write(chunk, () => stream.end()) 52 | resolve() 53 | } else { 54 | // 否则,继续写入 55 | ok = stream.write(chunk) 56 | } 57 | } while (written < size && ok) 58 | if (written < size) { 59 | // 'drain' 事件会在可以安全地继续写入数据到流中时触发 60 | stream.once('drain', write) 61 | } 62 | } 63 | write() 64 | }) 65 | } 66 | 67 | export async function deleteLocalFile(path: string) { 68 | const { unlinkSync } = await import('fs') 69 | unlinkSync(path) 70 | } 71 | 72 | export function createMockFile(fileName: string, sizeInMB: number): File { 73 | // 每 MB 大约为 1048576 字节 74 | const size = sizeInMB * 1048576 75 | const buffer = new ArrayBuffer(size) 76 | const view = new Uint8Array(buffer) 77 | 78 | // 填充随机内容 79 | for (let i = 0; i < size; i++) { 80 | // 随机填充每个字节,这里是填充 0-255 的随机数 81 | // 实际应用中,你可能需要调整生成随机数的方式以达到所需的随机性 82 | view[i] = Math.floor(Math.random() * 256) 83 | } 84 | 85 | // 将 ArrayBuffer 转换为Blob 86 | const blob = new Blob([view], { type: 'application/octet-stream' }) 87 | 88 | // 将 Blob 转换为File 89 | return new File([blob], fileName, { type: 'application/octet-stream' }) 90 | } 91 | -------------------------------------------------------------------------------- /packages/benchmark/src/index.ts: -------------------------------------------------------------------------------- 1 | export type * from './types' 2 | export * from './benchmark' 3 | -------------------------------------------------------------------------------- /packages/benchmark/src/types.ts: -------------------------------------------------------------------------------- 1 | import { HashChksParam, Strategy } from 'hash-worker' 2 | 3 | export interface BenchmarkOptions { 4 | sizeInMB?: number // 默认: 测试文件 500MB 5 | strategy?: Strategy // 默认: 使用 MD5 作为 hash 策略 6 | workerCountTobeTest?: number[] // 默认: 1, 4, 8, 12 线程各测三次 7 | } 8 | 9 | export interface NormalizeOptions { 10 | sizeInMB: number 11 | params: HashChksParam[] 12 | } 13 | -------------------------------------------------------------------------------- /packages/benchmark/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "../../tsconfig.json", 3 | "include": ["src/**/*.ts"] 4 | } 5 | -------------------------------------------------------------------------------- /packages/benchmark/tsup.config.ts: -------------------------------------------------------------------------------- 1 | import { defineConfig } from 'tsup' 2 | 3 | export default defineConfig({ 4 | entry: ['src/index.ts'], 5 | format: ['esm', 'cjs'], 6 | external: ['hash-worker'], 7 | }) 8 | -------------------------------------------------------------------------------- /packages/core/CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # hash-worker 2 | 3 | ## 1.0.1 4 | 5 | ### Minor Changes 6 | 7 | - fix: 当 0 <= chunkSize < 1 时, 导致分片函数死循环的问题 8 | 9 | ## 1.0.0 10 | 11 | ### Major Changes 12 | 13 | - release version 1.0.0 14 | 15 | ## 0.1.3 16 | 17 | ### Minor Changes 18 | 19 | - feat: 添加了 Webpack 下报错的解决方案, 升级了项目到最新的依赖 20 | -------------------------------------------------------------------------------- /packages/core/LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2024 Tkunl 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /packages/core/README-zh.md: -------------------------------------------------------------------------------- 1 | # Hash Worker [![npm package](https://img.shields.io/npm/v/hash-worker.svg)](https://www.npmjs.com/package/hash-worker) [![Bundle size](https://badgen.net/bundlephobia/minzip/hash-worker)](https://bundlephobia.com/result?p=hash-worker) [![codecov](https://codecov.io/gh/Tkunl/hash-worker/graph/badge.svg?token=G7GYAPEPYS)](https://codecov.io/gh/Tkunl/hash-worker) ![GitHub License](https://img.shields.io/github/license/Tkunl/hash-worker) 2 | 3 |

4 | 5 |

6 | 7 | ## Introduce 8 | 9 | [English Document](./README.md) 10 | 11 | **Hash-worker** 是一个用于快速计算文件哈希值的库。 12 | 13 | 它基于 hash-wasm 且利用了 WebWorker 进行并行计算,从而加快了计算文件分片的计算速度。 14 | 15 | Hash-worker 支持三种哈希计算算法:`md5`, `crc32` 和 `xxHash64`。 16 | 17 | 同时支持 `浏览器` 和 `Node.js` 环境。 18 | 19 | > [!WARNING] 20 | > Hash-worker 计算出的 MerkleHash 是基于文件块哈希值构建的 MerkleTree 的根哈希值。请注意,这并不直接等同于文件本身的哈希值。 21 | 22 | ## Install 23 | 24 | ```bash 25 | $ pnpm install hash-worker 26 | ``` 27 | 28 | ## Usage 29 | 30 | ### Global 31 | 32 | ```html 33 | 34 | 35 | 36 | 39 | ``` 40 | 41 | 其中 `global.js` 和 `hash.worker.mjs` 是执行 `package.json` 中的 `build:core` 后的打包产物 42 | 43 | 打包产物位于 `packages/core/dist` 目录 44 | 45 | ### ESM 46 | 47 | ``` ts 48 | import { getFileHashChunks, destroyWorkerPool, HashChksRes, HashChksParam } from 'hash-worker' 49 | 50 | function handleGetHash(file: File) { 51 | const param: HashChksParam = { 52 | file: file, 53 | config: { 54 | workerCount: 8, 55 | strategy: Strategy.md5 56 | } 57 | } 58 | 59 | getFileHashChunks(param).then((data: HashChksRes) => { 60 | console.log('chunksHash', data.chunksHash) 61 | }) 62 | } 63 | 64 | /** 65 | * Destroy Worker Thread 66 | */ 67 | function handleDestroyWorkerPool() { 68 | destroyWorkerPool() 69 | } 70 | ``` 71 | 72 | > [!WARNING] 73 | > 如果你在使用 `Vite` 作为构建工具, 需要在 `Vite` 的配置文件中, 添加如下配置, 用于排除 vite 的依赖优化 74 | 75 | ```js 76 | // vite.config.js 77 | import { defineConfig } from 'vite' 78 | import vue from '@vitejs/plugin-vue' 79 | 80 | export default defineConfig({ 81 | plugins: [vue()], 82 | // other configurations ... 83 | optimizeDeps: { 84 | exclude: ['hash-worker'] // new added.. 85 | } 86 | }) 87 | ``` 88 | 89 | > [!WARNING] 90 | > 如果你在使用 `Webpack` 作为构建工具, 需要在 Webpack 的配置文件中, 添加如下配置, 用于排除 node 相关模块的解析 91 | 92 | ```js 93 | // webpack.config.js 94 | module.exports = { 95 | // new added.. 96 | resolve: { 97 | fallback: { 98 | fs: false, 99 | path: false, 100 | 'fs/promises': false, 101 | worker_threads: false, 102 | }, 103 | }, 104 | // new added.. 105 | externals: { 106 | fs: 'commonjs fs', 107 | path: 'commonjs path', 108 | 'fs/promises': 'commonjs fs/promises', 109 | worker_threads: 'commonjs worker_threads', 110 | }, 111 | } 112 | ``` 113 | 114 | ## Options 115 | 116 | **HashChksParam** 117 | 118 | HashChksParam 是用于配置计算哈希值所需的参数。 119 | 120 | | filed | type | default | description | 121 | |----------|--------|---------|-----------------------------| 122 | | file | File | / | 需要计算 Hash 的文件(浏览器环境下必填) | 123 | | filePath | string | / | 需要计算 Hash 的文件路径 (Node环境下必填) | 124 | | config | Config | Config | 计算 Hash 时的参数 | 125 | 126 | **Config** 127 | 128 | | filed | type | default | description | 129 | |--------------------------|----------|----------------|---------------------------| 130 | | chunkSize | number | 10 (MB) | 文件分片的大小 | 131 | | workerCount | number | 8 | 计算 Hash 时同时开启的 worker 数量 | 132 | | strategy | Strategy | Strategy.mixed | hash 计算策略 | 133 | | borderCount | number | 100 | 'mixed' 模式下 hash 计算规则的分界点 | 134 | | isCloseWorkerImmediately | boolean | true | 当计算完成时, 是否立即销毁 Worker 线程 | 135 | 136 | ```ts 137 | // strategy.ts 138 | export enum Strategy { 139 | md5 = 'md5', 140 | crc32 = 'crc32', 141 | xxHash64 = 'xxHash64', 142 | mixed = 'mixed', 143 | } 144 | ``` 145 | 146 | 当采用 Strategy.mixed 策略时,若文件分片数量少于 borderCount,将采用 md5 算法计算哈希值来构建 MerkleTree。 147 | 否则,则切换至使用 crc32 算法进行 MerkleTree 的构建。 148 | 149 | **HashChksRes** 150 | 151 | HashChksRes 是计算哈希值之后的返回结果。 152 | 153 | | filed | type | description | 154 | |------------|--------------|--------------------------| 155 | | chunksBlob | Blob[] | 仅在浏览器环境下,会返回文件分片的 Blob[] | 156 | | chunksHash | string[] | 文件分片的 Hash[] | 157 | | merkleHash | string | 文件的 merkleHash | 158 | | metadata | FileMetaInfo | 文件的 metadata | 159 | 160 | **FileMetaInfo** 161 | 162 | | filed | type | description | 163 | |--------------|--------|----------------| 164 | | name | string | 用于计算 hash 的文件名 | 165 | | size | number | 文件大小,单位:KB | 166 | | lastModified | number | 文件最后一次修改的时间戳 | 167 | | type | string | 文件后缀名 | 168 | 169 | ### [Benchmark (MD5)](./packages/benchmark/README-zh.md) 170 | 171 | | Worker Count | Speed | 172 | |--------------|-----------| 173 | | 1 | 229 MB/s | 174 | | 4 | 632 MB/s | 175 | | 8 | 886 MB/s | 176 | | 12 | 1037 MB/s | 177 | 178 | * 以上数据是运行在 `Chrome v131` 和 `AMD Ryzen9 5950X` CPU 下, 通过使用 md5 来计算 hash 得到的。 179 | 180 | ## LICENSE 181 | 182 | [MIT](./LICENSE) 183 | 184 | ## Contributions 185 | 186 | 欢迎贡献代码!如果你发现了一个 bug 或者想添加一个新功能,请提交一个 issue 或 pull request。 187 | 188 | ## Author and contributors 189 | 190 |

191 | 192 | Tkunl 193 | 194 | 195 | Kanno 196 | 197 | 198 | Eternal-could 199 | 200 |

201 | 202 | 203 | 204 | 205 | 206 | 207 | -------------------------------------------------------------------------------- /packages/core/README.md: -------------------------------------------------------------------------------- 1 | # Hash Worker [![npm package](https://img.shields.io/npm/v/hash-worker.svg)](https://www.npmjs.com/package/hash-worker) [![Bundle size](https://badgen.net/bundlephobia/minzip/hash-worker)](https://bundlephobia.com/result?p=hash-worker) [![codecov](https://codecov.io/gh/Tkunl/hash-worker/graph/badge.svg?token=G7GYAPEPYS)](https://codecov.io/gh/Tkunl/hash-worker) ![GitHub License](https://img.shields.io/github/license/Tkunl/hash-worker) 2 | 3 |

4 | 5 |

6 | 7 | ## Introduce 8 | 9 | [中文文档](./README-zh.md) 10 | 11 | **Hash-worker** is a library for fast calculation of file chunk hashes. 12 | 13 | It is based on `hash-wasm` and utilizes `WebWorkers` for parallel computation, which speeds up computation when 14 | processing file blocks. 15 | 16 | Hash-worker supports three hash computation algorithms: `md5`, `crc32` and `xxHash64`. 17 | 18 | Both `browser` and `Node.js` are supported. 19 | 20 | > [!WARNING] 21 | > The merkleHash computed by the Hash-worker is the root hash of a MerkleTree constructed based on file block hashes. Note that this is not directly equivalent to a hash of the file itself. 22 | 23 | ## Install 24 | 25 | ```bash 26 | $ pnpm install hash-worker 27 | ``` 28 | 29 | ## Usage 30 | 31 | ### Global 32 | 33 | ```html 34 | 35 | 36 | 37 | 40 | ``` 41 | 42 | The `global.js` and `hash.worker.mjs` are the build artifacts resulting from executing `build:core` in `package.json`. 43 | 44 | The build artifacts are located in the `packages/core/dist` directory. 45 | 46 | ### ESM 47 | 48 | ``` ts 49 | import { getFileHashChunks, destroyWorkerPool, HashChksRes, HashChksParam } from 'hash-worker' 50 | 51 | function handleGetHash(file: File) { 52 | const param: HashChksParam = { 53 | file: file, 54 | config: { 55 | workerCount: 8, 56 | strategy: Strategy.md5 57 | } 58 | } 59 | 60 | getFileHashChunks(param).then((data: HashChksRes) => { 61 | console.log('chunksHash', data.chunksHash) 62 | }) 63 | } 64 | 65 | /** 66 | * Destroy Worker Thread 67 | */ 68 | function handleDestroyWorkerPool() { 69 | destroyWorkerPool() 70 | } 71 | ``` 72 | 73 | > [!WARNING] 74 | If you are using `Vite` as your build tool, you need to add some configurations in your `vite.config.js` to exclude hash-worker from optimizeDeps. 75 | 76 | ```js 77 | // vite.config.js 78 | import { defineConfig } from 'vite' 79 | import vue from '@vitejs/plugin-vue' 80 | 81 | export default defineConfig({ 82 | plugins: [vue()], 83 | // other configurations ... 84 | optimizeDeps: { 85 | exclude: ['hash-worker'] // new added.. 86 | } 87 | }) 88 | ``` 89 | 90 | > [!WARNING] 91 | > 92 | > If you are using `Webpack` as your build tool, you need add some configs in your `webpack.config.js` for exclude the parsing of node related modules. 93 | 94 | ```js 95 | // webpack.config.js 96 | module.exports = { 97 | // new added.. 98 | resolve: { 99 | fallback: { 100 | fs: false, 101 | path: false, 102 | 'fs/promises': false, 103 | worker_threads: false, 104 | }, 105 | }, 106 | // new added.. 107 | externals: { 108 | fs: 'commonjs fs', 109 | path: 'commonjs path', 110 | 'fs/promises': 'commonjs fs/promises', 111 | worker_threads: 'commonjs worker_threads', 112 | }, 113 | } 114 | ``` 115 | 116 | ## Options 117 | 118 | **HashChksParam** 119 | 120 | HashChksParam is used to configure the parameters needed to calculate the hash. 121 | 122 | | filed | type | default | description | 123 | |----------|--------|---------|--------------------------------------------------------------------------------------| 124 | | file | File | / | Files that need to calculate the hash (required for browser environments) | 125 | | filePath | string | / | Path to the file where the hash is to be calculated (required for Node environments) | 126 | | config | Config | Config | Parameters for calculating the Hash | 127 | 128 | **Config** 129 | 130 | | filed | type | default | description | 131 | |--------------------------|----------|----------------|-----------------------------------------------------------------------------------| 132 | | chunkSize | number | 10 (MB) | Size of the file slice | 133 | | workerCount | number | 8 | Number of workers turned on at the same time as the hash is calculated | 134 | | strategy | Strategy | Strategy.mixed | Hash computation strategy | 135 | | borderCount | number | 100 | The cutoff for the hash calculation rule in 'mixed' mode | 136 | | isCloseWorkerImmediately | boolean | true | Whether to destroy the worker thread immediately when the calculation is complete | 137 | 138 | ```ts 139 | // strategy.ts 140 | export enum Strategy { 141 | md5 = 'md5', 142 | crc32 = 'crc32', 143 | xxHash64 = 'xxHash64', 144 | mixed = 'mixed', 145 | } 146 | ``` 147 | 148 | When Strategy.mixed strategy is used, if the number of file fragments is less than borderCount, the md5 algorithm will 149 | be used to calculate the hash value to build the MerkleTree. 150 | Otherwise, it switches to using the crc32 algorithm for MerkleTree construction. 151 | 152 | **HashChksRes** 153 | 154 | HashChksRes is the returned result after calculating the hash value. 155 | 156 | | filed | type | description | 157 | |------------|--------------|-------------------------------------------------------------------------| 158 | | chunksBlob | Blob[] | In a browser environment only, the Blob[] of the file slice is returned | 159 | | chunksHash | string[] | Hash[] for file slicing | 160 | | merkleHash | string | The merkleHash of the file | 161 | | metadata | FileMetaInfo | The metadata of the file | 162 | 163 | **FileMetaInfo** 164 | 165 | | filed | type | description | 166 | |--------------|--------|-------------------------------------------------| 167 | | name | string | The name of the file used to calculate the hash | 168 | | size | number | File size in KB | 169 | | lastModified | number | Timestamp of the last modification of the file | 170 | | type | string | file extension | 171 | 172 | ### [Benchmark (MD5)](./packages/benchmark/README.md) 173 | 174 | | Worker Count | Speed | 175 | |--------------|-----------| 176 | | 1 | 229 MB/s | 177 | | 4 | 632 MB/s | 178 | | 8 | 886 MB/s | 179 | | 12 | 1037 MB/s | 180 | 181 | The above data is run on the `Chrome v131` and `AMD Ryzen9 5950X` CPU, by using md5 to calculate hash. 182 | 183 | ## LICENSE 184 | 185 | [MIT](./LICENSE) 186 | 187 | ## Contributions 188 | 189 | Contributions are welcome! If you find a bug or want to add a new feature, please open an issue or submit a pull 190 | request. 191 | 192 | ## Author and contributors 193 | 194 |

195 | 196 | Tkunl 197 | 198 | 199 | Eternal-could 200 | 201 | 202 | Kanno 203 | 204 |

205 | -------------------------------------------------------------------------------- /packages/core/__tests__/browser/fileUtils.spec.ts: -------------------------------------------------------------------------------- 1 | import { getArrayBufFromBlobs, getFileMetadata, sliceFile } from '../../src/utils' 2 | import path from 'path' 3 | import fs from 'fs/promises' 4 | import { MockBlob } from '../fixture/mockBlob' 5 | 6 | // 在测试文件的顶部,模拟 Blob.prototype.arrayBuffer 7 | function mockArrayBuffer(): void { 8 | // 将全局 Blob 替换为 MockBlob 9 | global.Blob = MockBlob 10 | } 11 | 12 | describe('sliceFile', () => { 13 | it('should slice a file into chunks of specified size', () => { 14 | // 创建一个模拟的 File 对象 15 | const file = new File([new ArrayBuffer(5 * 1024 * 1024)], 'test.pdf', { 16 | type: 'application/pdf', 17 | }) // 创建一个5MB的文件 18 | 19 | const chunks = sliceFile(file, 1) // 指定将文件分割成1MB大小的块 20 | 21 | // 断言:检查chunks的长度是否为5 22 | expect(chunks.length).toBe(5) 23 | 24 | // 遍历chunks,验证每个块的大小是否符合预期(除了最后一个块可能小于1MB) 25 | chunks.forEach((chunk, index) => { 26 | if (index === chunks.length - 1) { 27 | // 若是最后一个块,则其大小应小于等于1MB 28 | expect(chunk.size).toBeLessThanOrEqual(1 * 1024 * 1024) 29 | } else { 30 | // 否则,每个块的大小应该严格等于1MB 31 | expect(chunk.size).toBe(1 * 1024 * 1024) 32 | } 33 | }) 34 | }) 35 | }) 36 | 37 | describe('getArrayBufFromBlobs', () => { 38 | beforeAll(() => { 39 | // 在所有测试运行之前模拟 arrayBuffer 40 | mockArrayBuffer() 41 | }) 42 | 43 | it('should correctly return array buffers from an array of blobs', async () => { 44 | // 创建测试用的 ArrayBuffer 和 Blob 45 | const buffer1 = new ArrayBuffer(10) 46 | const buffer2 = new ArrayBuffer(20) 47 | const blob1 = new Blob([buffer1]) 48 | const blob2 = new Blob([buffer2]) 49 | 50 | // 调用你的函数 51 | const result = await getArrayBufFromBlobs([blob1, blob2]) 52 | 53 | // 进行断言 54 | expect(result.length).toBe(2) 55 | expect(result[0]).toBeInstanceOf(ArrayBuffer) 56 | expect(result[1]).toBeInstanceOf(ArrayBuffer) 57 | expect(result[0].byteLength).toEqual(10) 58 | expect(result[1].byteLength).toEqual(20) 59 | }) 60 | }) 61 | 62 | describe('getFileMetadata', () => { 63 | it('should correctly return file metadata in browser env', async () => { 64 | const fileName = 'test.pdf' 65 | // 创建一个模拟的 File 对象 66 | const file = new File([new ArrayBuffer(5 * 1024 * 1024)], fileName, { 67 | type: 'application/pdf', 68 | }) // 创建一个 5MB 的文件 69 | 70 | const fileInfo = await getFileMetadata(file) 71 | expect(fileInfo.name).toBe(fileName) 72 | expect(fileInfo.type).toBe('.pdf') 73 | }) 74 | 75 | it('should correctly return file metadata in node env', async () => { 76 | const filePath = path.join(__dirname, './../fixture/mockFile.txt') 77 | 78 | const fileInfo = await getFileMetadata(undefined, filePath) 79 | const stats = await fs.stat(filePath) 80 | 81 | expect(fileInfo.name).toBe('mockFile.txt') 82 | expect(fileInfo.size).toBe(stats.size / 1024) 83 | expect(fileInfo.type).toBe('.txt') 84 | }) 85 | }) 86 | -------------------------------------------------------------------------------- /packages/core/__tests__/browser/getFileHashChunks.spec.ts: -------------------------------------------------------------------------------- 1 | import { getFileHashChunks } from '../../src/getFileHashChunks' 2 | 3 | jest.mock('../../src/utils/is', () => ({ 4 | isNode: jest.fn(() => false), 5 | isBrowser: jest.fn(() => false), 6 | })) 7 | 8 | jest.mock('../../src/worker/workerService', () => ({ 9 | WorkerService: jest.fn(() => ({ 10 | terminate: jest.fn(), 11 | })), 12 | })) 13 | 14 | jest.mock('../../src/utils/fileUtils', () => ({ 15 | getFileMetadata: jest.fn(() => ({ 16 | name: 'fakeFileName.txt', 17 | })), 18 | })) 19 | 20 | jest.mock('../../src/helper', () => ({ 21 | ...jest.requireActual('../../src/helper'), // 从中导入所有原始实现 22 | processFileInBrowser: jest.fn(() => ({ 23 | chunksBlob: [], 24 | chunksHash: ['hash in browser'], 25 | fileHash: 'hash in browser', 26 | })), 27 | processFileInNode: jest.fn(() => ({ 28 | chunksHash: ['hash in node'], 29 | fileHash: 'hash in node', 30 | })), 31 | })) 32 | 33 | import * as is from '../../src/utils/is' 34 | import { HashChksParam } from '../../src/interface' 35 | 36 | function setBrowserEnv() { 37 | ;(is.isNode as jest.Mock).mockImplementation(() => false) 38 | ;(is.isBrowser as jest.Mock).mockImplementation(() => true) 39 | } 40 | 41 | function setNodeEnv() { 42 | ;(is.isNode as jest.Mock).mockImplementation(() => true) 43 | ;(is.isBrowser as jest.Mock).mockImplementation(() => false) 44 | } 45 | 46 | describe('getFileHashChunks', () => { 47 | it('should return getFileHashChunks result correctly in browser env', async () => { 48 | setBrowserEnv() 49 | const param = { 50 | file: new File([], 'test.pdf', { 51 | type: 'application/pdf', 52 | }), 53 | } 54 | const result = await getFileHashChunks(param) 55 | 56 | expect(result.merkleHash).toEqual('hash in browser') 57 | expect(result.metadata.name).toEqual('fakeFileName.txt') 58 | }) 59 | 60 | it('should return getFileHashChunks result correctly in node env', async () => { 61 | setNodeEnv() 62 | const param = { 63 | filePath: 'dummyPath', 64 | } 65 | const result = await getFileHashChunks(param) 66 | 67 | expect(result.merkleHash).toEqual('hash in node') 68 | expect(result.metadata.name).toEqual('fakeFileName.txt') 69 | }) 70 | }) 71 | -------------------------------------------------------------------------------- /packages/core/__tests__/browser/helper.spec.ts: -------------------------------------------------------------------------------- 1 | import { Strategy } from '../../src/interface' 2 | 3 | jest.mock('hash-wasm', () => ({ 4 | crc32: jest.fn(() => Promise.resolve('crc32hash')), 5 | md5: jest.fn(() => Promise.resolve('md5hash')), 6 | xxhash64: jest.fn(() => Promise.resolve('xxhash64')), 7 | })) 8 | 9 | jest.mock('../../src/utils/is', () => ({ 10 | isNode: jest.fn(() => false), 11 | isBrowser: jest.fn(() => false), 12 | })) 13 | 14 | jest.mock('../../src/worker/workerService', () => { 15 | return { 16 | WorkerService: jest.fn().mockImplementation(() => ({ 17 | getMD5ForFiles: jest.fn(), 18 | getCRC32ForFiles: jest.fn(), 19 | getXxHash64ForFiles: jest.fn(), 20 | terminate: jest.fn(), 21 | })), 22 | } 23 | }) 24 | 25 | import { getChunksHashMultiple, getChunksHashSingle, normalizeParam } from '../../src/helper' 26 | import * as is from '../../src/utils/is' 27 | import { WorkerService } from '../../src/worker/workerService' 28 | 29 | function setNodeEnv() { 30 | ;(is.isNode as jest.Mock).mockImplementation(() => true) 31 | ;(is.isBrowser as jest.Mock).mockImplementation(() => false) 32 | } 33 | 34 | function setBrowserEnv() { 35 | ;(is.isNode as jest.Mock).mockImplementation(() => false) 36 | ;(is.isBrowser as jest.Mock).mockImplementation(() => true) 37 | } 38 | 39 | describe('normalizeParam', () => { 40 | let mockFile: File 41 | 42 | beforeAll(async () => { 43 | mockFile = new File([new ArrayBuffer(5)], 'test.pdf', { 44 | type: 'application/pdf', 45 | }) 46 | }) 47 | 48 | it('throws an error for unsupported environment', () => { 49 | expect(() => { 50 | normalizeParam({ filePath: '' }) 51 | }).toThrow('Unsupported environment') 52 | }) 53 | 54 | it('requires filePath attribute in node environment', () => { 55 | setNodeEnv() 56 | expect(() => { 57 | normalizeParam({ file: mockFile }) 58 | }).toThrow('The filePath attribute is required in node environment') 59 | }) 60 | 61 | it('requires filePath attribute in browser environment', () => { 62 | setBrowserEnv() 63 | expect(() => { 64 | normalizeParam({ filePath: 'mockPath' }) 65 | }).toThrow('The file attribute is required in browser environment') 66 | }) 67 | 68 | it('get filePath in param correctly in node environment', () => { 69 | setNodeEnv() 70 | const param = normalizeParam({ filePath: 'mockPath' }) 71 | expect(param.filePath).toBe('mockPath') 72 | }) 73 | 74 | it('get file in param correctly in browser environment', () => { 75 | setBrowserEnv() 76 | const param = normalizeParam({ file: mockFile }) 77 | expect(param.file).toBeTruthy() 78 | }) 79 | }) 80 | 81 | describe('getChunksHashSingle', () => { 82 | const testArrayBuffer = new Uint8Array([1, 2, 3]).buffer 83 | 84 | it('should use md5 hashing strategy for md5 strategy option', async () => { 85 | const result = await getChunksHashSingle(Strategy.md5, testArrayBuffer) 86 | expect(result).toEqual(['md5hash']) 87 | }) 88 | 89 | it('should use mixed hashing strategy for mixed strategy option', async () => { 90 | const result = await getChunksHashSingle(Strategy.mixed, testArrayBuffer) 91 | expect(result).toEqual(['md5hash']) 92 | }) 93 | 94 | it('should use crc32 hashing strategy for crc32 strategy option', async () => { 95 | const result = await getChunksHashSingle(Strategy.crc32, testArrayBuffer) 96 | expect(result).toEqual(['crc32hash']) 97 | }) 98 | 99 | it('should use xxHash64 hashing strategy for xxHash64 strategy option', async () => { 100 | const result = await getChunksHashSingle(Strategy.xxHash64, testArrayBuffer) 101 | expect(result).toEqual(['xxhash64']) 102 | }) 103 | }) 104 | 105 | describe('getChunksHashMultiple', () => { 106 | const arrayBuffers: ArrayBuffer[] = [new ArrayBuffer(10), new ArrayBuffer(20)] 107 | let workerSvc: WorkerService 108 | 109 | beforeEach(() => { 110 | workerSvc = new WorkerService(6) 111 | }) 112 | 113 | it('should use MD5 hashing for MD5 strategy', async () => { 114 | await getChunksHashMultiple(Strategy.md5, arrayBuffers, 5, 3, workerSvc) 115 | expect(workerSvc.getMD5ForFiles).toHaveBeenCalledWith(arrayBuffers) 116 | }) 117 | 118 | it('should use CRC32 hashing for CRC32 strategy', async () => { 119 | await getChunksHashMultiple(Strategy.crc32, arrayBuffers, 5, 3, workerSvc) 120 | expect(workerSvc.getCRC32ForFiles).toHaveBeenCalledWith(arrayBuffers) 121 | }) 122 | 123 | it('should use xxHash64 hashing for xxHash64 strategy', async () => { 124 | await getChunksHashMultiple(Strategy.xxHash64, arrayBuffers, 5, 3, workerSvc) 125 | expect(workerSvc.getXxHash64ForFiles).toHaveBeenCalledWith(arrayBuffers) 126 | }) 127 | 128 | it('should use MD5 hashing for mixed strategy when chunksCount <= borderCount', async () => { 129 | await getChunksHashMultiple(Strategy.mixed, arrayBuffers, 2, 3, workerSvc) 130 | expect(workerSvc.getMD5ForFiles).toHaveBeenCalledWith(arrayBuffers) 131 | }) 132 | 133 | it('should use CRC32 hashing for mixed strategy when chunksCount > borderCount', async () => { 134 | await getChunksHashMultiple(Strategy.mixed, arrayBuffers, 4, 3, workerSvc) 135 | expect(workerSvc.getCRC32ForFiles).toHaveBeenCalledWith(arrayBuffers) 136 | }) 137 | }) 138 | -------------------------------------------------------------------------------- /packages/core/__tests__/browser/helper2.spec.ts: -------------------------------------------------------------------------------- 1 | import * as helper from '../../src/helper' 2 | import { Config, Strategy } from '../../src/interface' 3 | import { getFileSliceLocations, readFileAsArrayBuffer, sliceFile } from '../../src/utils' 4 | import { WorkerService } from '../../src/worker/workerService' 5 | import { MockBlob } from '../fixture/mockBlob' 6 | import { getRootHashByChunks } from '../../src/getRootHashByChunks' 7 | import { processFileInBrowser, processFileInNode } from '../../src/helper' 8 | 9 | global.Blob = MockBlob 10 | 11 | jest.mock('../../src/worker/workerService', () => ({ 12 | WorkerService: jest.fn().mockImplementation(() => ({ 13 | terminate: jest.fn(), 14 | })), 15 | })) 16 | 17 | jest.mock('../../src/utils/fileUtils', () => ({ 18 | sliceFile: jest.fn(), 19 | getArrayBufFromBlobs: jest.fn(), 20 | getFileSliceLocations: jest.fn(), 21 | readFileAsArrayBuffer: jest.fn(), 22 | })) 23 | 24 | jest.mock('../../src/helper', () => ({ 25 | ...jest.requireActual('../../src/helper'), // 从中导入所有原始实现 26 | getChunksHashSingle: jest.fn(), 27 | getChunksHashMultiple: jest.fn(), 28 | })) 29 | 30 | jest.mock('../../src/getRootHashByChunks', () => ({ 31 | getRootHashByChunks: jest.fn(), 32 | })) 33 | 34 | describe('processFileInBrowser', () => { 35 | const fakeFile = new File([], 'test.pdf', { 36 | type: 'application/pdf', 37 | }) 38 | const config: Required = { 39 | chunkSize: 10, 40 | strategy: Strategy.md5, 41 | workerCount: 6, 42 | isCloseWorkerImmediately: true, 43 | borderCount: 2, 44 | isShowLog: false, 45 | } 46 | const workerSvc = new WorkerService(1) 47 | 48 | beforeEach(() => { 49 | // 用于清除所有已模拟函数(mock functions)的调用记录和实例数据 50 | jest.clearAllMocks() 51 | }) 52 | 53 | it('should process single chunk file by using processFileInBrowser function', async () => { 54 | ;(sliceFile as jest.Mock).mockReturnValue([new Blob(['chunk'])]) 55 | ;(helper.getChunksHashSingle as jest.Mock).mockResolvedValue(['hash1']) 56 | ;(getRootHashByChunks as jest.Mock).mockResolvedValue('rootHash') 57 | 58 | const result = await helper.processFileInBrowser( 59 | fakeFile, 60 | config, 61 | workerSvc, 62 | helper.getChunksHashSingle, 63 | helper.getChunksHashMultiple, 64 | ) 65 | 66 | expect(sliceFile).toHaveBeenCalledWith(fakeFile, config.chunkSize) 67 | expect(helper.getChunksHashSingle).toHaveBeenCalled() 68 | expect(getRootHashByChunks).toHaveBeenCalledWith(['hash1']) 69 | expect(result.fileHash).toEqual('rootHash') 70 | }) 71 | 72 | it('should process multiple chunk file by using processFileInBrowser function', async () => { 73 | ;(sliceFile as jest.Mock).mockReturnValue([new Blob(['chunk']), new Blob(['chunk2'])]) 74 | ;(helper.getChunksHashMultiple as jest.Mock).mockResolvedValue(['hash1', 'hash2']) 75 | ;(getRootHashByChunks as jest.Mock).mockResolvedValue('rootHash') 76 | 77 | const result = await processFileInBrowser( 78 | fakeFile, 79 | config, 80 | workerSvc, 81 | helper.getChunksHashSingle, 82 | helper.getChunksHashMultiple, 83 | ) 84 | 85 | expect(sliceFile).toHaveBeenCalledWith(fakeFile, config.chunkSize) 86 | expect(helper.getChunksHashMultiple as jest.Mock).toHaveBeenCalled() 87 | expect(getRootHashByChunks).toHaveBeenCalledWith(['hash1', 'hash2']) 88 | expect(result.fileHash).toEqual('rootHash') 89 | }) 90 | }) 91 | 92 | describe('processFileInNode function', () => { 93 | const config: Required = { 94 | chunkSize: 10, 95 | strategy: Strategy.md5, 96 | workerCount: 6, 97 | isCloseWorkerImmediately: true, 98 | borderCount: 2, 99 | isShowLog: false, 100 | } 101 | 102 | const workerSvc = new WorkerService(1) 103 | 104 | beforeEach(() => { 105 | // 用于清除所有已模拟函数(mock functions)的调用记录和实例数据 106 | jest.clearAllMocks() 107 | }) 108 | 109 | it('should process single chunk file by using processFileInNode function', async () => { 110 | ;(helper.getChunksHashSingle as jest.Mock).mockResolvedValue(['hash1']) 111 | ;(getFileSliceLocations as jest.Mock).mockResolvedValue({ sliceLocation: [0], endLocation: 10 }) 112 | ;(readFileAsArrayBuffer as jest.Mock).mockResolvedValue(new ArrayBuffer(10)) 113 | ;(getRootHashByChunks as jest.Mock).mockResolvedValue('rootHash') 114 | 115 | const result = await processFileInNode( 116 | 'dummyPath', 117 | config, 118 | workerSvc, 119 | helper.getChunksHashSingle, 120 | helper.getChunksHashMultiple, 121 | ) 122 | 123 | expect(result.chunksHash).toEqual(['hash1']) 124 | expect(helper.getChunksHashSingle).toHaveBeenCalled() 125 | expect(result.fileHash).toEqual('rootHash') 126 | }) 127 | 128 | it('should process multiple chunk file by using processFileInNode function', async () => { 129 | ;(helper.getChunksHashMultiple as jest.Mock).mockResolvedValue(['hash1', 'hash2']) 130 | ;(getFileSliceLocations as jest.Mock).mockResolvedValue({ 131 | sliceLocation: [0, 10], 132 | endLocation: 20, 133 | }) 134 | ;(readFileAsArrayBuffer as jest.Mock).mockResolvedValue(new ArrayBuffer(10)) 135 | ;(getRootHashByChunks as jest.Mock).mockResolvedValue('rootHash') 136 | 137 | const result = await processFileInNode( 138 | 'dummyPath', 139 | config, 140 | workerSvc, 141 | helper.getChunksHashSingle, 142 | helper.getChunksHashMultiple, 143 | ) 144 | 145 | expect(result.chunksHash).toEqual(['hash1', 'hash2']) 146 | expect(helper.getChunksHashMultiple).toHaveBeenCalled() 147 | expect(result.fileHash).toEqual('rootHash') 148 | }) 149 | }) 150 | -------------------------------------------------------------------------------- /packages/core/__tests__/browser/workerPoolForHash.spec.ts: -------------------------------------------------------------------------------- 1 | import { MockWebWorker } from '../fixture/mockWebWorker' 2 | import { WorkerPoolForHash } from '../../src/worker/workerPoolForHash' 3 | 4 | // 模拟浏览器下的 Web Worker 5 | ;(global as any).Worker = MockWebWorker 6 | 7 | // 模拟 Node.js 下的 worker_threads 8 | jest.mock('worker_threads', () => ({ 9 | Worker: jest.fn(), 10 | })) 11 | 12 | describe('WorkerPoolForMd5s', () => { 13 | test('create function should initialize pool correctly in Node environment', async () => { 14 | const pool = await WorkerPoolForHash.create(4) 15 | expect(pool.pool.length).toBe(4) 16 | expect((await import('worker_threads')).Worker).toHaveBeenCalledTimes(4) 17 | }) 18 | }) 19 | -------------------------------------------------------------------------------- /packages/core/__tests__/browser/workerWrapper.spec.ts: -------------------------------------------------------------------------------- 1 | import { StatusEnum, WorkerWrapper } from '../../src/entity' 2 | import { MockWebWorker } from '../fixture/mockWebWorker' 3 | 4 | // 在全局空间中声明这个类,以模拟在浏览器中的 Worker 行为 5 | ;(global as any).Worker = MockWebWorker 6 | 7 | describe('WorkerWrapper', () => { 8 | it('should handle messages correctly in browser environment', async () => { 9 | const webWorker = new Worker('') 10 | const workerWrapper = new WorkerWrapper(webWorker) 11 | const getFn = (param: ArrayBuffer) => param 12 | const restoreFn: any = () => {} 13 | const promise = workerWrapper.run(new ArrayBuffer(1), 0, getFn, restoreFn) 14 | 15 | await expect(promise).resolves.toBe('hash-string') 16 | expect(workerWrapper.status).toBe(StatusEnum.WAITING) 17 | expect(webWorker.terminate).toHaveBeenCalledTimes(0) // 根据需要测试 terminate 被调用的次数 18 | }) 19 | 20 | it('should call terminate on the worker', () => { 21 | const mockTerminate = jest.fn() 22 | const worker = new Worker('') 23 | worker.terminate = mockTerminate 24 | 25 | const workerWrapper = new WorkerWrapper(worker) 26 | workerWrapper.terminate() 27 | 28 | expect(mockTerminate).toHaveBeenCalled() 29 | }) 30 | }) 31 | -------------------------------------------------------------------------------- /packages/core/__tests__/fixture/mockBlob.ts: -------------------------------------------------------------------------------- 1 | export class MockBlob extends Blob { 2 | constructor(...args: ConstructorParameters) { 3 | super(...args) 4 | } 5 | 6 | // 模拟 arrayBuffer 方法 7 | arrayBuffer(): Promise { 8 | return new Promise((resolve, reject) => { 9 | const reader: FileReader = new FileReader() 10 | reader.onload = () => { 11 | if (reader.result) { 12 | resolve(reader.result as ArrayBuffer) 13 | } else { 14 | reject(new Error('ArrayBuffer is null')) 15 | } 16 | } 17 | reader.onerror = () => { 18 | reject(new Error('FileReader failed to read the Blob')) 19 | } 20 | reader.readAsArrayBuffer(this) 21 | }) 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /packages/core/__tests__/fixture/mockFile.txt: -------------------------------------------------------------------------------- 1 | Here is mockFile 2 | Here is mockFile 3 | Here is mockFile 4 | Here is mockFile 5 | Here is mockFile 6 | Here is mockFile 7 | Here is mockFile 8 | Here is mockFile 9 | Here is mockFile 10 | Here is mockFile 11 | -------------------------------------------------------------------------------- /packages/core/__tests__/fixture/mockMiniSubject.ts: -------------------------------------------------------------------------------- 1 | import { MiniSubject } from '../../src/utils' 2 | 3 | export class MockMiniSubject extends MiniSubject { 4 | constructor(value: T) { 5 | super(value) 6 | } 7 | 8 | next(value: T) { 9 | this._value = value 10 | this.subscribers.forEach((cb) => cb(value)) 11 | } 12 | } 13 | -------------------------------------------------------------------------------- /packages/core/__tests__/fixture/mockWebWorker.ts: -------------------------------------------------------------------------------- 1 | export class MockWebWorker { 2 | onmessage?: (event: any) => void 3 | onerror?: (event: ErrorEvent) => void 4 | postMessage = jest.fn().mockImplementation(() => { 5 | this.onmessage && this.onmessage({ result: 'hash-string', chunk: new ArrayBuffer(1) }) 6 | }) 7 | terminate = jest.fn() 8 | } 9 | -------------------------------------------------------------------------------- /packages/core/__tests__/fixture/mockWorkerPool.ts: -------------------------------------------------------------------------------- 1 | import { WorkerPool } from '../../src/entity' 2 | import { MockMiniSubject } from './mockMiniSubject' 3 | import { MockWorkerWrapper } from './mockWorkerWrapper' 4 | 5 | export class MockWorkerPool extends WorkerPool { 6 | constructor(maxWorkers = 4) { 7 | super(maxWorkers) 8 | this.curRunningCount = new MockMiniSubject(0) 9 | for (let i = 0; i < maxWorkers; i++) { 10 | this.pool.push(new MockWorkerWrapper()) 11 | } 12 | } 13 | } 14 | -------------------------------------------------------------------------------- /packages/core/__tests__/fixture/mockWorkerWrapper.ts: -------------------------------------------------------------------------------- 1 | import { StatusEnum, WorkerWrapper } from '../../src/entity' 2 | 3 | export class MockWorkerWrapper extends WorkerWrapper { 4 | constructor() { 5 | super({ 6 | terminate: () => {}, 7 | } as Worker) 8 | this.status = StatusEnum.WAITING 9 | } 10 | 11 | // eslint-disable-next-line @typescript-eslint/no-unused-vars 12 | run(param: U, index: number, getFn: any, restoreFn: any) { 13 | return Promise.resolve('result' as unknown as T) 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /packages/core/__tests__/node/arrayUtils.spec.ts: -------------------------------------------------------------------------------- 1 | import { getArrParts, getFileSliceLocations, readFileAsArrayBuffer } from '../../src/utils' 2 | import fs from 'fs/promises' 3 | import path from 'path' 4 | import { describe } from 'node:test' 5 | 6 | test('getArrParts should split array into parts of given size', () => { 7 | const input = [1, 2, 3, 4, 5, 6, 7] 8 | const size = 3 9 | const expectedOutput = [[1, 2, 3], [4, 5, 6], [7]] 10 | 11 | const result = getArrParts(input, size) 12 | 13 | expect(result).toEqual(expectedOutput) 14 | }) 15 | 16 | test('getArrParts should handle empty array', () => { 17 | const input: [] = [] 18 | const size = 3 19 | const expectedOutput: [] = [] 20 | 21 | const result = getArrParts(input, size) 22 | 23 | expect(result).toEqual(expectedOutput) 24 | }) 25 | 26 | test('getArrParts should handle size larger than array length', () => { 27 | const input = [1, 2, 3] 28 | const size = 5 29 | const expectedOutput = [[1, 2, 3]] 30 | 31 | const result = getArrParts(input, size) 32 | 33 | expect(result).toEqual(expectedOutput) 34 | }) 35 | 36 | test('getArrParts should handle size of 1', () => { 37 | const input = [1, 2, 3] 38 | const size = 1 39 | const expectedOutput = [[1], [2], [3]] 40 | 41 | const result = getArrParts(input, size) 42 | 43 | expect(result).toEqual(expectedOutput) 44 | }) 45 | 46 | test('getArrParts should handle size equal to array length', () => { 47 | const input = [1, 2, 3] 48 | const size = 3 49 | const expectedOutput = [[1, 2, 3]] 50 | 51 | const result = getArrParts(input, size) 52 | 53 | expect(result).toEqual(expectedOutput) 54 | }) 55 | 56 | describe('getFileSliceLocations function slices file as expected', async () => { 57 | let sliceLocation: [number, number][] 58 | let endLocation: number 59 | const filePath = path.join(__dirname, './../fixture/mockFile.txt') 60 | 61 | // 基于你的测试文件和 baseSize 计算出预期的分片 62 | let expectedEndLocation: number 63 | 64 | // 在所有测试运行之前,执行一次异步操作。 65 | beforeAll(async () => { 66 | const baseSize = 1 67 | const result = await getFileSliceLocations(filePath, baseSize) 68 | const stats = await fs.stat(filePath) 69 | 70 | sliceLocation = result.sliceLocation 71 | endLocation = result.endLocation 72 | expectedEndLocation = stats.size 73 | }) 74 | 75 | // 假设你知道 fileContent 的大小,可以根据它计算期望的 sliceLocation 值 76 | const expectedSliceLocation = [[0, 1048575]] 77 | 78 | it('sliceLocation should match expected value.', () => { 79 | expect(sliceLocation).toEqual(expectedSliceLocation) 80 | }) 81 | 82 | it('endLocation should match expected value.', () => { 83 | expect(endLocation).toBe(expectedEndLocation) 84 | }) 85 | }) 86 | 87 | describe('readFileAsArrayBuffer reads specified range of file into ArrayBuffer', () => { 88 | const filePath = path.join(__dirname, './../fixture/mockFile.txt') 89 | const start = 0 90 | let end: number 91 | let arrayBuffer: ArrayBuffer 92 | let expectedLength: number 93 | 94 | beforeAll(async () => { 95 | arrayBuffer = await readFileAsArrayBuffer(filePath, start, end) 96 | const stats = await fs.stat(filePath) 97 | end = stats.size 98 | expectedLength = end - start 99 | }) 100 | 101 | // ArrayBuffer 的 byteLength 应该与请求的字节长度一致 102 | it(`ArrayBuffer should be expectedLength bytes long`, () => { 103 | expect(arrayBuffer.byteLength).toBe(expectedLength) 104 | }) 105 | }) 106 | -------------------------------------------------------------------------------- /packages/core/__tests__/node/getRootHashByChunks.spec.ts: -------------------------------------------------------------------------------- 1 | import { getRootHashByChunks } from '../../src/getRootHashByChunks' 2 | 3 | describe('getRootHashByChunks', () => { 4 | it('should return the root hash', async () => { 5 | const hashList = ['a'] 6 | const result = await getRootHashByChunks(hashList) 7 | expect(result).toBe('a') 8 | }) 9 | }) 10 | -------------------------------------------------------------------------------- /packages/core/__tests__/node/is.spec.ts: -------------------------------------------------------------------------------- 1 | import { isBrowser, isBrowser2, isEmpty, isNode } from '../../src/utils' 2 | 3 | test('isEmpty correctly identifies empty values', () => { 4 | expect(isEmpty(undefined)).toBe(true) 5 | expect(isEmpty(null)).toBe(true) 6 | expect(isEmpty('')).toBe(true) 7 | expect(isEmpty([])).toBe(true) 8 | expect(isEmpty(new Map())).toBe(true) 9 | expect(isEmpty(new Set())).toBe(true) 10 | expect(isEmpty({})).toBe(true) 11 | }) 12 | 13 | // 测试 isEmpty 函数对非空值的正确判断 14 | test('isEmpty correctly identifies non-empty values', () => { 15 | expect(isEmpty('text')).toBe(false) 16 | expect(isEmpty([1, 2, 3])).toBe(false) 17 | expect(isEmpty(new Map([['key', 'value']]))).toBe(false) 18 | expect(isEmpty(new Set([1, 2, 3]))).toBe(false) 19 | expect(isEmpty({ key: 'value' })).toBe(false) 20 | }) 21 | 22 | // 测试环境检测函数 23 | test('Environment detection functions', () => { 24 | // 例如,以下假设测试运行在 Node.js 环境中 25 | expect(isBrowser()).toBe(false) 26 | expect(isBrowser2()).toBe(false) 27 | expect(isNode()).toBe(true) 28 | }) 29 | -------------------------------------------------------------------------------- /packages/core/__tests__/node/merkleTree.spec.ts: -------------------------------------------------------------------------------- 1 | import { md5 } from 'hash-wasm' 2 | import { MerkleTree } from '../../src/entity' 3 | 4 | async function createHash(data: string): Promise { 5 | return md5(data) 6 | } 7 | 8 | // 测试空数组初始化应该抛出错误 9 | test('init() with empty array should throw an error', async () => { 10 | const tree = new MerkleTree() 11 | 12 | // 如果期望异步函数抛出错误,可以使用 expect(...).rejects.toThrow(...) 13 | await expect(tree.init([])).rejects.toThrow('Empty Nodes') 14 | }) 15 | 16 | // 测试 MerkleTree.init 用字符串哈希列表初始化 17 | test('MerkleTree.init with string hash list', async () => { 18 | // 创建一些测试用的哈希 19 | const hashList = [ 20 | await createHash('data1'), 21 | await createHash('data2'), 22 | await createHash('data3'), 23 | await createHash('data4'), 24 | ] 25 | 26 | // 创建 MerkleTree 实例并初始化 27 | const merkleTree = new MerkleTree() 28 | await expect(merkleTree.init(hashList)).resolves.toBeUndefined() // 如果不期望具体的返回值,可以检查被调用函数是否成功被解决 29 | 30 | // 检查是否所有叶子节点都被正确创建 31 | expect(merkleTree.leafs.length).toBe(hashList.length) 32 | hashList.forEach((hash, index) => { 33 | expect(merkleTree.leafs[index].h).toBe(hash) 34 | }) 35 | 36 | // 检查 root 是否被设置 37 | expect(merkleTree.root.h).toBeTruthy() 38 | }) 39 | -------------------------------------------------------------------------------- /packages/core/__tests__/node/miniSubject.spec.ts: -------------------------------------------------------------------------------- 1 | import { MiniSubject } from '../../src/utils' 2 | 3 | test('MiniSubject initializes with a value and can get the value', () => { 4 | const initial = 10 5 | const subject = new MiniSubject(initial) 6 | expect(subject.value).toBe(initial) // 使用 expect().toBe() 来断言值 7 | }) 8 | 9 | // 测试 MiniSubject 能够订阅并立即接收初始值 10 | test('MiniSubject can subscribe and receive the initial value immediately', () => { 11 | const initial = 'test' 12 | const subject = new MiniSubject(initial) 13 | const mockCallback = jest.fn() // 使用 jest 的模拟函数 14 | 15 | subject.subscribe(mockCallback) 16 | expect(mockCallback).toHaveBeenCalledWith(initial) // 确认回调函数被立即以初始值调用 17 | }) 18 | 19 | // 测试 MiniSubject 在调用 next 后能够更新所有订阅者 20 | test('MiniSubject updates all subscribers on next', () => { 21 | const subject = new MiniSubject(0) 22 | // 创建一个 Jest 模拟函数, 能够记录它被调用的情况, 包括调用了多少次, 以及每次调用时传入的参数等信息 23 | const mockCallback1 = jest.fn() 24 | const mockCallback2 = jest.fn() 25 | 26 | subject.subscribe(mockCallback1) 27 | subject.next(42) // 更新值 28 | subject.subscribe(mockCallback2) 29 | 30 | expect(mockCallback1.mock.calls.length).toBe(2) 31 | expect(mockCallback1.mock.calls[0][0]).toBe(0) 32 | expect(mockCallback1.mock.calls[1][0]).toBe(42) 33 | 34 | expect(mockCallback2.mock.calls.length).toBe(1) 35 | }) 36 | 37 | // 测试 MiniSubject 取消订阅后不会更新回调函数 38 | test('MiniSubject does not update unsubscribed callbacks', () => { 39 | const subject = new MiniSubject('initial') 40 | const mockCallback = jest.fn() 41 | 42 | const id = subject.subscribe(mockCallback) 43 | subject.unsubscribe(id) 44 | subject.next('updated') 45 | 46 | // 回调函数只应被调用一次(订阅时) 47 | expect(mockCallback.mock.calls.length).toBe(1) 48 | }) 49 | -------------------------------------------------------------------------------- /packages/core/__tests__/node/workerPool.spec.ts: -------------------------------------------------------------------------------- 1 | import { MockWorkerPool } from '../fixture/mockWorkerPool' 2 | 3 | describe('WorkerPool', () => { 4 | // 测试 WorkerPool 是否能够执行任务并返回结果 5 | test('should execute tasks and return results', async () => { 6 | const workerPool = new MockWorkerPool(2) 7 | const params = [new ArrayBuffer(8), new ArrayBuffer(8)] 8 | 9 | const getFn = (param: ArrayBuffer) => param 10 | const restoreFn: any = () => {} 11 | 12 | const results = await workerPool.exec(params, getFn, restoreFn) 13 | 14 | // 使用 Jest 的 toEqual 进行深度比较 15 | expect(results).toEqual(['result', 'result']) 16 | }) 17 | 18 | // 测试 WorkerPool 是否能够正确地终止所有 workers 19 | test('should terminate all workers', () => { 20 | const workerPool = new MockWorkerPool(2) 21 | // 使用 jest.spyOn 为每个 worker 的 terminate 方法设置监视 22 | const terminateSpies = workerPool.pool.map((worker) => jest.spyOn(worker, 'terminate')) 23 | 24 | workerPool.terminate() 25 | 26 | // 检查每个 spy 是否被调用了一次 27 | terminateSpies.forEach((spy) => { 28 | expect(spy).toHaveBeenCalledTimes(1) 29 | }) 30 | }) 31 | 32 | test('should execute tasks and return results', async () => { 33 | const workerPool = new MockWorkerPool(2) 34 | const params = [new ArrayBuffer(8), new ArrayBuffer(8)] 35 | 36 | const getFn = (param: ArrayBuffer) => param 37 | const restoreFn: any = () => {} 38 | 39 | const results = await workerPool.exec(params, getFn, restoreFn) 40 | 41 | expect(results).toEqual(['result', 'result']) 42 | }) 43 | 44 | test('should terminate all workers', () => { 45 | const workerPool = new MockWorkerPool(2) 46 | 47 | // 使用 Jest 的 mock 函数来模拟 terminate 方法 48 | workerPool.pool.forEach((worker) => { 49 | worker.terminate = jest.fn() 50 | }) 51 | 52 | workerPool.terminate() 53 | 54 | // 检查每个 worker 的 terminate 方法是否被调用了一次 55 | workerPool.pool.forEach((worker) => { 56 | expect(worker.terminate).toHaveBeenCalledTimes(1) 57 | }) 58 | }) 59 | }) 60 | -------------------------------------------------------------------------------- /packages/core/__tests__/node/workerWrapper.spec.ts: -------------------------------------------------------------------------------- 1 | import { WorkerWrapper } from '../../src/entity' 2 | import { Worker as NodeWorker } from 'worker_threads' 3 | 4 | // 模拟 Node.js 的 'worker_threads' 模块 5 | jest.mock('worker_threads', () => { 6 | // 设置 NodeWorker 实例的模拟行为 7 | const mockPostMessage = jest.fn() 8 | const mockTerminate = jest.fn() 9 | const mockOnMessage = jest.fn().mockImplementation((event, handler) => { 10 | if (event === 'message') { 11 | setTimeout(() => { 12 | handler({ result: 'hash-string', chunk: new ArrayBuffer(1) }) 13 | }, 0) 14 | } 15 | }) 16 | 17 | return { 18 | Worker: jest.fn().mockImplementation(() => ({ 19 | postMessage: mockPostMessage, 20 | on: mockOnMessage, 21 | terminate: mockTerminate, 22 | setMaxListeners: jest.fn(), 23 | })), 24 | } 25 | }) 26 | 27 | describe('WorkerWrapper', () => { 28 | it('should send and process messages correctly in node environment', async () => { 29 | const NodeWorkerMock = NodeWorker as unknown as jest.Mock 30 | 31 | const worker = new NodeWorkerMock() 32 | const workerWrapper = new WorkerWrapper(worker) 33 | 34 | const getFn = (param: ArrayBuffer) => param 35 | const restoreFn: any = () => {} 36 | 37 | const res = await workerWrapper.run( 38 | new ArrayBuffer(1), 39 | 0, 40 | getFn, 41 | restoreFn, 42 | ) 43 | 44 | expect(res).toBe('hash-string') 45 | expect(worker.terminate).toHaveBeenCalledTimes(0) // 根据需要测试 terminate 被调用的次数 46 | }) 47 | }) 48 | -------------------------------------------------------------------------------- /packages/core/jest.config.ts: -------------------------------------------------------------------------------- 1 | import type { Config } from 'jest' 2 | const projectsConfigWrapper = (configs: Record[]): any[] => 3 | configs.map((config) => ({ 4 | ...config, 5 | coveragePathIgnorePatterns: ['/__tests__/fixture/'], 6 | preset: 'ts-jest', 7 | transform: { 8 | '^.+\\.tsx?$': [ 9 | // 为了解决报错: The 'import.meta' meta-property is only allowed when the '--module' option is 'es2020', 'esnext', or 'system'. 10 | // make ts-jest happy ^_^ 11 | 'ts-jest', 12 | { 13 | diagnostics: { 14 | ignoreCodes: [1343], 15 | }, 16 | astTransformers: { 17 | before: [ 18 | { 19 | path: 'ts-jest-mock-import-meta', 20 | // 此处 url 可随便符合 url 格式的字符串, 因为在运行 Jest 时, Worker 会被 Mock Worker 换掉 21 | options: { metaObjectReplacement: { url: 'https://test.com' } }, 22 | }, 23 | ], 24 | }, 25 | }, 26 | ], 27 | }, 28 | })) 29 | 30 | const config: Config = { 31 | collectCoverage: true, 32 | coverageReporters: ['lcov'], 33 | projects: projectsConfigWrapper([ 34 | { 35 | displayName: 'node', 36 | testEnvironment: 'node', 37 | testMatch: ['**/__tests__/node/**/*.spec.ts'], 38 | }, 39 | { 40 | displayName: 'browser', 41 | testEnvironment: 'jsdom', 42 | testMatch: ['**/__tests__/browser/**/*.spec.ts'], 43 | }, 44 | ]), 45 | } 46 | 47 | export default config 48 | -------------------------------------------------------------------------------- /packages/core/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "hash-worker", 3 | "version": "1.0.1", 4 | "description": "hash-worker is a tool for quickly calculating file's hash", 5 | "author": "https://github.com/Tkunl", 6 | "repository": "https://github.com/Tkunl/hash-worker", 7 | "type": "module", 8 | "main": "./dist/index.js", 9 | "module": "./dist/index.mjs", 10 | "types": "./dist/index.d.ts", 11 | "unpkg": "./dist/global.js", 12 | "jsdelivr": "./dist/global.js", 13 | "exports": { 14 | ".": { 15 | "types": "./dist/index.d.ts", 16 | "require": "./dist/index.js", 17 | "import": "./dist/index.mjs" 18 | }, 19 | "./global": { 20 | "types": "./dist/global.d.ts", 21 | "require": "./dist/global.js", 22 | "import": "./dist/global.js" 23 | } 24 | }, 25 | "files": [ 26 | "dist", 27 | "README.md", 28 | "README-zh.md", 29 | "package.json", 30 | "LICENSE" 31 | ], 32 | "scripts": { 33 | "dev": "rollup --config rollup.config.ts --configPlugin swc3 --watch", 34 | "build": "pnpm rm:dist && rollup --config rollup.config.ts --configPlugin swc3", 35 | "test": "jest --coverage", 36 | "rm:dist": "rimraf ./dist" 37 | }, 38 | "keywords": [ 39 | "hash-worker", 40 | "hash" 41 | ], 42 | "license": "MIT", 43 | "dependencies": { 44 | "hash-wasm": "^4.12.0" 45 | }, 46 | "devDependencies": { 47 | "ts-node": "^10.9.2" 48 | } 49 | } 50 | -------------------------------------------------------------------------------- /packages/core/rollup.config.ts: -------------------------------------------------------------------------------- 1 | import { defineConfig } from 'rollup' 2 | import { dts } from 'rollup-plugin-dts' 3 | import { nodeResolve } from '@rollup/plugin-node-resolve' 4 | import { minify, swc } from 'rollup-plugin-swc3' 5 | 6 | const bundleName = 'HashWorker' 7 | 8 | // 一般来说现代项目不需要自行压缩这些 cjs/esm 模块,因为现代构建工具会自动处理 9 | // 其次发包发布压缩的包意义在于减少安装大小,但是实际上这个行为可有可无 10 | // 关于 iife/umd 面向现代的前端提供 iife 就可以了。 11 | // 因此你不需要过多复杂的配置。 12 | 13 | export default defineConfig([ 14 | // esm 产物 15 | { 16 | input: 'src/main.ts', 17 | output: [ 18 | { file: 'dist/index.mjs', format: 'esm', exports: 'named' }, 19 | { file: 'dist/index.js', format: 'cjs', exports: 'named' }, 20 | ], 21 | plugins: [ 22 | nodeResolve(), 23 | swc({ sourceMaps: true }), 24 | minify({ mangle: true, module: true, compress: true, sourceMap: true }), 25 | ], 26 | external: ['worker_threads'], 27 | }, 28 | // esm 类型产物 29 | { 30 | input: 'src/main.ts', 31 | output: { file: 'dist/index.d.ts' }, 32 | plugins: [dts()], 33 | external: ['worker_threads'], 34 | }, 35 | // iife 产物 36 | { 37 | input: 'src/main.ts', 38 | output: { file: 'dist/global.js', format: 'iife', name: bundleName }, 39 | plugins: [ 40 | nodeResolve(), 41 | swc({ sourceMaps: true }), 42 | minify({ mangle: true, module: false, compress: true, sourceMap: true }), 43 | ], 44 | external: ['worker_threads'], 45 | }, 46 | // iife 类型产物 47 | { 48 | input: 'src/iife.ts', 49 | output: { file: 'dist/global.d.ts', format: 'es' }, 50 | plugins: [dts()], 51 | external: ['worker_threads'], 52 | }, 53 | // Worker 54 | { 55 | input: 'src/worker/hash.worker.ts', 56 | output: { file: 'dist/worker/hash.worker.mjs', format: 'esm' }, 57 | plugins: [ 58 | nodeResolve(), 59 | swc({ sourceMaps: true }), 60 | minify({ mangle: true, module: true, compress: true }), 61 | ], 62 | }, 63 | ]) 64 | -------------------------------------------------------------------------------- /packages/core/src/entity/index.ts: -------------------------------------------------------------------------------- 1 | export * from './merkleTree' 2 | export * from './workerPool' 3 | export * from './workerWrapper' 4 | -------------------------------------------------------------------------------- /packages/core/src/entity/merkleTree.ts: -------------------------------------------------------------------------------- 1 | import { md5 } from 'hash-wasm' 2 | 3 | // 定义 Merkle 树节点的接口 4 | interface IMerkleNode { 5 | h: string 6 | l: IMerkleNode | null 7 | r: IMerkleNode | null 8 | } 9 | 10 | // 定义 Merkle 树的接口 11 | interface IMerkleTree { 12 | root: IMerkleNode 13 | leafs: IMerkleNode[] 14 | // 你可以根据需要添加其他属性或方法,例如校验、添加和生成树等功能 15 | } 16 | 17 | // Merkle 树节点的类实现 18 | export class MerkleNode implements IMerkleNode { 19 | h: string 20 | l: IMerkleNode | null 21 | r: IMerkleNode | null 22 | 23 | constructor(hash: string, left: IMerkleNode | null = null, right: IMerkleNode | null = null) { 24 | this.h = hash 25 | this.l = left 26 | this.r = right 27 | } 28 | } 29 | 30 | // Merkle 树的类实现 31 | export class MerkleTree implements IMerkleTree { 32 | root: IMerkleNode = new MerkleNode('') 33 | leafs: IMerkleNode[] = [] 34 | 35 | async init(hashList: string[]): Promise 36 | async init(leafNodes: IMerkleNode[]): Promise 37 | async init(nodes: string[] | IMerkleNode[]): Promise { 38 | if (nodes.length === 0) { 39 | throw new Error('Empty Nodes') 40 | } 41 | if (typeof nodes[0] === 'string') { 42 | this.leafs = nodes.map((node) => new MerkleNode(node as string)) 43 | } else { 44 | this.leafs = nodes as IMerkleNode[] 45 | } 46 | this.root = await this.buildTree() 47 | } 48 | 49 | getRootHash() { 50 | return this.root.h 51 | } 52 | 53 | async buildTree(): Promise { 54 | // 实现构建 Merkle 树的逻辑。根据叶子节点创建父节点,一直到根节点。 55 | let currentLevelNodes = this.leafs 56 | while (currentLevelNodes.length > 1) { 57 | const parentNodes: IMerkleNode[] = [] 58 | for (let i = 0; i < currentLevelNodes.length; i += 2) { 59 | const left = currentLevelNodes[i] 60 | const right = i + 1 < currentLevelNodes.length ? currentLevelNodes[i + 1] : null 61 | // 具体的哈希计算方法 62 | const parentHash = await this.calculateHash(left, right) 63 | parentNodes.push(new MerkleNode(parentHash, left, right)) 64 | } 65 | currentLevelNodes = parentNodes 66 | } 67 | 68 | return currentLevelNodes[0] // 返回根节点 69 | } 70 | 71 | private async calculateHash(left: IMerkleNode, right: IMerkleNode | null): Promise { 72 | return right ? md5(left.h + right.h) : left.h 73 | } 74 | } 75 | -------------------------------------------------------------------------------- /packages/core/src/entity/workerPool.ts: -------------------------------------------------------------------------------- 1 | import { StatusEnum, WorkerWrapper } from './workerWrapper' 2 | import { MiniSubject } from '../utils' 3 | import { getFn, restoreFn } from '../interface' 4 | 5 | export abstract class WorkerPool { 6 | pool: WorkerWrapper[] = [] 7 | maxWorkerCount: number 8 | curRunningCount = new MiniSubject(0) 9 | results: any[] = [] 10 | 11 | protected constructor(maxWorkers: number) { 12 | this.maxWorkerCount = maxWorkers 13 | } 14 | 15 | exec(params: U[], getFn: getFn, restoreFn: restoreFn) { 16 | this.results.length = 0 17 | const workerParams = params.map((param, index) => ({ data: param, index })) 18 | 19 | return new Promise((rs) => { 20 | this.curRunningCount.subscribe((count) => { 21 | if (count < this.maxWorkerCount && workerParams.length !== 0) { 22 | // 当前能跑的任务数量 23 | let curTaskCount = this.maxWorkerCount - count 24 | if (curTaskCount > workerParams.length) { 25 | curTaskCount = workerParams.length 26 | } 27 | 28 | // 此时可以用来执行任务的 Worker 29 | const canUseWorker: WorkerWrapper[] = [] 30 | for (const worker of this.pool) { 31 | if (worker.status === StatusEnum.WAITING) { 32 | canUseWorker.push(worker) 33 | if (canUseWorker.length === curTaskCount) { 34 | break 35 | } 36 | } 37 | } 38 | 39 | const paramsToRun = workerParams.splice(0, curTaskCount) 40 | 41 | // 更新当前正在跑起来的 worker 数量 42 | this.curRunningCount.next(this.curRunningCount.value + curTaskCount) 43 | canUseWorker.forEach((workerApp, index) => { 44 | const param = paramsToRun[index] 45 | workerApp 46 | .run(param.data, param.index, getFn, restoreFn) 47 | .then((res) => { 48 | this.results[param.index] = res 49 | }) 50 | .catch((e) => { 51 | this.results[param.index] = e 52 | }) 53 | .finally(() => { 54 | this.curRunningCount.next(this.curRunningCount.value - 1) 55 | }) 56 | }) 57 | } 58 | 59 | if (this.curRunningCount.value === 0 && workerParams.length === 0) { 60 | rs(this.results as T[]) 61 | } 62 | }) 63 | }) 64 | } 65 | 66 | terminate() { 67 | this.pool.forEach((workerWrapper) => workerWrapper.terminate()) 68 | } 69 | } 70 | -------------------------------------------------------------------------------- /packages/core/src/entity/workerWrapper.ts: -------------------------------------------------------------------------------- 1 | import { Worker as NodeWorker } from 'worker_threads' 2 | import { isBrowser, isNode } from '../utils' 3 | import { getFn, restoreFn, WorkerRes } from '../interface' 4 | 5 | type Resolve = (value: T | PromiseLike) => void 6 | type Reject = (reason?: any) => void 7 | 8 | export enum StatusEnum { 9 | RUNNING = 'running', 10 | WAITING = 'waiting', 11 | } 12 | 13 | export class WorkerWrapper { 14 | worker: Worker | NodeWorker 15 | status: StatusEnum 16 | 17 | constructor(worker: Worker | NodeWorker) { 18 | this.worker = worker 19 | this.status = StatusEnum.WAITING 20 | } 21 | 22 | run(param: U, index: number, getFn: getFn, restoreFn: restoreFn) { 23 | this.status = StatusEnum.RUNNING 24 | 25 | const onMessage = (rs: Resolve) => (dataFromWorker: unknown) => { 26 | let data: WorkerRes 27 | 28 | if (isBrowser()) { 29 | data = (dataFromWorker as { data: WorkerRes }).data 30 | } 31 | if (isNode()) { 32 | data = dataFromWorker as WorkerRes 33 | } 34 | const { result, chunk } = data! 35 | if (result && chunk) { 36 | restoreFn({ 37 | buf: chunk, 38 | index, 39 | }) 40 | this.status = StatusEnum.WAITING 41 | rs(result as T) 42 | } 43 | } 44 | 45 | const onError = (rj: Reject) => (ev: ErrorEvent) => { 46 | this.status = StatusEnum.WAITING 47 | rj(ev) 48 | } 49 | 50 | if (isBrowser()) { 51 | const worker = this.worker as Worker 52 | return new Promise((rs, rj) => { 53 | worker.onmessage = onMessage(rs) 54 | worker.onerror = onError(rj) 55 | worker.postMessage(param, [getFn(param)]) 56 | }) 57 | } 58 | 59 | if (isNode()) { 60 | const worker = this.worker as NodeWorker 61 | return new Promise((rs, rj) => { 62 | // 处理 MaxListenersExceededWarning: Possible EventEmitter memory leak detected 警告 63 | worker.setMaxListeners(1024) 64 | worker.on('message', onMessage(rs)) 65 | worker.on('error', onError(rj)) 66 | worker.postMessage(param, [getFn(param)]) 67 | }) 68 | } 69 | 70 | throw new Error('Unsupported environment') 71 | } 72 | 73 | terminate() { 74 | this.worker.terminate() 75 | } 76 | } 77 | -------------------------------------------------------------------------------- /packages/core/src/getFileHashChunks.ts: -------------------------------------------------------------------------------- 1 | import { getFileMetadata, isBrowser, isNode } from './utils' 2 | import { WorkerService } from './worker/workerService' 3 | import { HashChksParam, HashChksRes } from './interface' 4 | import { normalizeParam, processFileInBrowser, processFileInNode } from './helper' 5 | 6 | let workerService: WorkerService | null = null 7 | let curWorkerCount: number = 0 8 | 9 | /** 10 | * 将文件进行分片, 并获取分片后的 hashList 11 | * @param param 12 | */ 13 | async function getFileHashChunks(param: HashChksParam): Promise { 14 | const { config, file, filePath } = normalizeParam(param) 15 | const { isCloseWorkerImmediately, isShowLog, workerCount } = config 16 | 17 | if (workerService === null || curWorkerCount !== workerCount) { 18 | destroyWorkerPool() 19 | workerService = new WorkerService(config.workerCount) 20 | } 21 | 22 | const metadata = await getFileMetadata(file, filePath) 23 | 24 | let chunksBlob: Blob[] = [] 25 | let chunksHash: string[] = [] 26 | let fileHash = '' 27 | 28 | let beforeTime: number = 0 29 | let overTime: number = 0 30 | 31 | isShowLog && (beforeTime = Date.now()) 32 | 33 | if (isBrowser() && file) { 34 | const res = await processFileInBrowser(file, config, workerService) 35 | chunksBlob = res.chunksBlob 36 | chunksHash = res.chunksHash 37 | fileHash = res.fileHash 38 | } 39 | 40 | if (isNode() && filePath) { 41 | const res = await processFileInNode(filePath, config, workerService) 42 | chunksHash = res.chunksHash 43 | fileHash = res.fileHash 44 | } 45 | 46 | isShowLog && (overTime = Date.now() - beforeTime) 47 | isShowLog && 48 | console.log( 49 | `get file hash in: ${overTime} ms by using ${config.workerCount} worker, speed: ${metadata.size / 1024 / (overTime / 1000)} Mb/s`, 50 | ) 51 | 52 | const res: HashChksRes = { 53 | chunksHash, 54 | merkleHash: fileHash, 55 | metadata, 56 | } 57 | 58 | if (isBrowser()) { 59 | res.chunksBlob = chunksBlob 60 | } 61 | 62 | isCloseWorkerImmediately && destroyWorkerPool() 63 | return res 64 | } 65 | 66 | function destroyWorkerPool() { 67 | workerService && workerService.terminate() 68 | workerService = null 69 | curWorkerCount = 0 70 | } 71 | 72 | export { getFileHashChunks, destroyWorkerPool } 73 | -------------------------------------------------------------------------------- /packages/core/src/getRootHashByChunks.ts: -------------------------------------------------------------------------------- 1 | import { MerkleTree } from './entity' 2 | 3 | export async function getRootHashByChunks(hashList: string[]) { 4 | const merkleTree = new MerkleTree() 5 | await merkleTree.init(hashList) 6 | return merkleTree.getRootHash() 7 | } 8 | -------------------------------------------------------------------------------- /packages/core/src/helper.ts: -------------------------------------------------------------------------------- 1 | import { crc32, md5, xxhash64 } from 'hash-wasm' 2 | import { getRootHashByChunks } from './getRootHashByChunks' 3 | import { Config, HashChksParam, Strategy } from './interface' 4 | import { 5 | getArrayBufFromBlobs, 6 | getArrParts, 7 | getFileSliceLocations, 8 | isBrowser, 9 | isNode, 10 | readFileAsArrayBuffer, 11 | sliceFile, 12 | } from './utils' 13 | import { WorkerService } from './worker/workerService' 14 | 15 | const DEFAULT_MAX_WORKERS = 8 16 | const BORDER_COUNT = 100 17 | 18 | /** 19 | * 标准化参数 20 | * @param param 21 | */ 22 | export function normalizeParam(param: HashChksParam) { 23 | const env: 'node' | 'browser' = (() => { 24 | if (isNode()) return 'node' 25 | if (isBrowser()) return 'browser' 26 | throw new Error('Unsupported environment') 27 | })() 28 | 29 | const { chunkSize, workerCount, strategy, borderCount, isCloseWorkerImmediately, isShowLog } = 30 | param.config ?? {} 31 | 32 | const config = { 33 | // 默认 10MB 分片大小 34 | chunkSize: chunkSize ?? 10, 35 | // 默认使用 8个 Worker 线程 36 | workerCount: workerCount ?? DEFAULT_MAX_WORKERS, 37 | // 默认使用混合模式计算 hash 38 | strategy: strategy ?? Strategy.mixed, 39 | // 默认以 100 分片数量作为边界 40 | borderCount: borderCount ?? BORDER_COUNT, 41 | // 默认计算 hash 后立即关闭 worker 42 | isCloseWorkerImmediately: isCloseWorkerImmediately ?? true, 43 | // 是否显示速度 log 44 | isShowLog: isShowLog ?? false, 45 | } 46 | 47 | if (env === 'node') { 48 | if (!param.filePath) { 49 | throw new Error('The filePath attribute is required in node environment') 50 | } 51 | return { 52 | ...param, 53 | config, 54 | filePath: param.filePath, 55 | } 56 | } 57 | 58 | if (env === 'browser') { 59 | if (!param.file) { 60 | throw new Error('The file attribute is required in browser environment') 61 | } 62 | return { 63 | ...param, 64 | config, 65 | file: param.file, 66 | } 67 | } 68 | 69 | throw new Error('Unsupported environment') 70 | } 71 | 72 | /** 73 | * 计算单个文件分片的 Hash 74 | * @param strategy hash 策略 75 | * @param arrayBuffer 文件分片的 arrayBuffer 76 | */ 77 | export async function getChunksHashSingle(strategy: Strategy, arrayBuffer: ArrayBuffer) { 78 | const unit8Array = new Uint8Array(arrayBuffer) 79 | const getHashStrategy = (strategy: Strategy) => { 80 | if (strategy === Strategy.md5) return md5 81 | if (strategy === Strategy.crc32) return crc32 82 | if (strategy === Strategy.xxHash64) return xxhash64 83 | throw Error('Unknown strategy') 84 | } 85 | 86 | return [await getHashStrategy(strategy === Strategy.mixed ? Strategy.md5 : strategy)(unit8Array)] 87 | } 88 | 89 | /** 90 | * 计算多个文件分片的 Hash 91 | * @param strategy hash 策略 92 | * @param arrayBuffers 当前文件分片的 arrayBuffer 数组 (组内) 93 | * @param chunksCount 文件的全部分片数量 94 | * @param borderCount Strategy.mixed 时的边界个数 95 | * @param workerSvc WorkerService 96 | */ 97 | export async function getChunksHashMultiple( 98 | strategy: Strategy, 99 | arrayBuffers: ArrayBuffer[], 100 | chunksCount: number, 101 | borderCount: number, 102 | workerSvc: WorkerService, 103 | ) { 104 | const processor = { 105 | [Strategy.xxHash64]: () => workerSvc.getXxHash64ForFiles(arrayBuffers), 106 | [Strategy.md5]: () => workerSvc.getMD5ForFiles(arrayBuffers), 107 | [Strategy.crc32]: () => workerSvc.getCRC32ForFiles(arrayBuffers), 108 | [Strategy.mixed]: () => 109 | chunksCount <= borderCount 110 | ? workerSvc.getMD5ForFiles(arrayBuffers) 111 | : workerSvc.getCRC32ForFiles(arrayBuffers), 112 | } 113 | 114 | return processor[strategy]() 115 | } 116 | 117 | export async function processFileInBrowser( 118 | file: File, 119 | config: Required, 120 | workerSvc: WorkerService, 121 | _getChunksHashSingle = getChunksHashSingle, 122 | _getChunksHashMultiple = getChunksHashMultiple, 123 | ) { 124 | const { chunkSize, strategy, workerCount, borderCount } = config 125 | 126 | // 文件分片 127 | const chunksBlob = sliceFile(file, chunkSize) 128 | let chunksHash: string[] = [] 129 | 130 | const singleChunkProcessor = async () => { 131 | const arrayBuffer = await chunksBlob[0].arrayBuffer() 132 | chunksHash = await _getChunksHashSingle(strategy, arrayBuffer) 133 | } 134 | 135 | const multipleChunksProcessor = async () => { 136 | let chunksBuf: ArrayBuffer[] = [] 137 | // 将文件分片进行分组, 组内任务并行执行, 组外任务串行执行 138 | const chunksPart = getArrParts(chunksBlob, workerCount) 139 | const tasks = chunksPart.map((part) => async () => { 140 | // 手动释放上一次用于计算 Hash 的 ArrayBuffer 141 | chunksBuf.length = 0 142 | chunksBuf = await getArrayBufFromBlobs(part) 143 | // 执行不同的 hash 计算策略 144 | return _getChunksHashMultiple(strategy, chunksBuf, chunksBlob.length, borderCount, workerSvc) 145 | }) 146 | 147 | for (const task of tasks) { 148 | const result = await task() 149 | chunksHash.push(...result) 150 | } 151 | chunksBuf && (chunksBuf.length = 0) 152 | } 153 | 154 | chunksBlob.length === 1 ? await singleChunkProcessor() : await multipleChunksProcessor() 155 | const fileHash = await getRootHashByChunks(chunksHash) 156 | 157 | return { 158 | chunksBlob, 159 | chunksHash, 160 | fileHash, 161 | } 162 | } 163 | 164 | export async function processFileInNode( 165 | filePath: string, 166 | config: Required, 167 | workerSvc: WorkerService, 168 | _getChunksHashSingle = getChunksHashSingle, 169 | _getChunksHashMultiple = getChunksHashMultiple, 170 | ) { 171 | const { chunkSize, strategy, workerCount, borderCount } = config 172 | 173 | // 文件分片 174 | const { sliceLocation, endLocation } = await getFileSliceLocations(filePath, chunkSize) 175 | let chunksHash: string[] = [] 176 | 177 | const singleChunkProcessor = async () => { 178 | const arrayBuffer = await readFileAsArrayBuffer(filePath, 0, endLocation) 179 | chunksHash = await _getChunksHashSingle(strategy, arrayBuffer) 180 | } 181 | 182 | const multipleChunksProcessor = async () => { 183 | let chunksBuf: ArrayBuffer[] = [] 184 | // 分组后的起始分割位置 185 | const sliceLocationPart = getArrParts<[number, number]>(sliceLocation, workerCount) 186 | const tasks = sliceLocationPart.map((partArr) => async () => { 187 | // 手动释放上一次用于计算 Hash 的 ArrayBuffer 188 | chunksBuf.length = 0 189 | chunksBuf = await Promise.all( 190 | partArr.map((part) => readFileAsArrayBuffer(filePath, part[0], part[1])), 191 | ) 192 | // 执行不同的 hash 计算策略 193 | return _getChunksHashMultiple( 194 | strategy, 195 | chunksBuf, 196 | sliceLocation.length, 197 | borderCount, 198 | workerSvc, 199 | ) 200 | }) 201 | 202 | for (const task of tasks) { 203 | const result = await task() 204 | chunksHash.push(...result) 205 | } 206 | chunksBuf.length = 0 207 | } 208 | 209 | sliceLocation.length === 1 ? await singleChunkProcessor() : await multipleChunksProcessor() 210 | const fileHash = await getRootHashByChunks(chunksHash) 211 | 212 | return { 213 | chunksHash, 214 | fileHash, 215 | } 216 | } 217 | -------------------------------------------------------------------------------- /packages/core/src/iife.ts: -------------------------------------------------------------------------------- 1 | // 这个文件是为了让你的 iife 有一个 namespace 的类型提示 2 | import * as HashWorker from './main' 3 | 4 | export default HashWorker 5 | -------------------------------------------------------------------------------- /packages/core/src/interface/fileHashChunks.ts: -------------------------------------------------------------------------------- 1 | import { FileMetaInfo } from './fileMetaInfo' 2 | import { Strategy } from './strategy' 3 | 4 | export interface Config { 5 | chunkSize?: number // 分片大小 MB 6 | workerCount?: number // worker 线程数量 7 | strategy?: Strategy // hash 计算策略 8 | borderCount?: number // 使用 'mixed' 时的分界点, 分片数量少于 borderCount 时使用 md5 作为 hash 算法, 否则使用 crc32 9 | isCloseWorkerImmediately?: boolean // 是否在计算 hash 后立即关闭 worker 10 | isShowLog?: boolean // 是否显示 log 11 | } 12 | 13 | interface BaseParam { 14 | config?: Config 15 | } 16 | 17 | interface BrowserEnvParam extends BaseParam { 18 | file: File // 待计算 Hash 的文件 (浏览器环境) 19 | filePath?: never // 当 file 存在时,filePath 不能存在 20 | } 21 | 22 | interface NodeEnvParam extends BaseParam { 23 | file?: never // 当 filePath 存在时,file 不能存在 24 | filePath: string // 待计算 Hash 的文件的 URL (Node 环境) 25 | } 26 | 27 | // 使用交叉类型确保 file 和 filePath 二者之一必须存在 28 | export type HashChksParam = BrowserEnvParam | NodeEnvParam 29 | 30 | export interface HashChksRes { 31 | chunksBlob?: Blob[] // 文件分片的 Blob[] 32 | chunksHash: string[] // 文件分片的 Hash[] 33 | merkleHash: string // 文件的 merkleHash 34 | metadata: FileMetaInfo // 文件的 metadata 35 | } 36 | -------------------------------------------------------------------------------- /packages/core/src/interface/fileMetaInfo.ts: -------------------------------------------------------------------------------- 1 | export interface FileMetaInfo { 2 | name: string // 文件名 3 | size: number // 文件大小 KB 4 | lastModified: number // 时间戳 5 | type: string // 文件的后缀名 6 | } 7 | -------------------------------------------------------------------------------- /packages/core/src/interface/fnTypes.ts: -------------------------------------------------------------------------------- 1 | interface restoreFnOption { 2 | bufs?: ArrayBuffer[] 3 | buf: ArrayBuffer 4 | index: number 5 | } 6 | 7 | export type getFn = (param: T) => ArrayBuffer 8 | export type restoreFn = (options: restoreFnOption) => void 9 | -------------------------------------------------------------------------------- /packages/core/src/interface/index.ts: -------------------------------------------------------------------------------- 1 | export type * from './fileHashChunks' 2 | export type * from './fileMetaInfo' 3 | export type * from './workerRes' 4 | export type * from './workerReq' 5 | export type * from './fnTypes' 6 | 7 | export * from './strategy' 8 | -------------------------------------------------------------------------------- /packages/core/src/interface/strategy.ts: -------------------------------------------------------------------------------- 1 | export enum Strategy { 2 | md5 = 'md5', 3 | crc32 = 'crc32', 4 | xxHash64 = 'xxHash64', 5 | mixed = 'mixed', 6 | } 7 | -------------------------------------------------------------------------------- /packages/core/src/interface/workerReq.ts: -------------------------------------------------------------------------------- 1 | import { Strategy } from './strategy' 2 | 3 | export interface WorkerReq { 4 | chunk: ArrayBuffer 5 | strategy: Strategy 6 | } 7 | -------------------------------------------------------------------------------- /packages/core/src/interface/workerRes.ts: -------------------------------------------------------------------------------- 1 | export interface WorkerRes { 2 | result: T 3 | chunk: ArrayBuffer 4 | } 5 | -------------------------------------------------------------------------------- /packages/core/src/main.ts: -------------------------------------------------------------------------------- 1 | export * from './entity' 2 | export * from './interface' 3 | export * from './utils' 4 | export * from './getFileHashChunks' 5 | export * from './getRootHashByChunks' 6 | -------------------------------------------------------------------------------- /packages/core/src/utils/arrayUtils.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * [1, 2, 3, 4] => [[1, 2], [3, 4]] 3 | * @param chunks 原始数组 4 | * @param size 分 part 大小 5 | */ 6 | export function getArrParts(chunks: any, size: number) { 7 | const result: T[][] = [] 8 | let tempPart: T[] = [] 9 | chunks.forEach((chunk: T) => { 10 | tempPart.push(chunk) 11 | if (tempPart.length === size) { 12 | result.push(tempPart) 13 | tempPart = [] 14 | } 15 | }) 16 | if (tempPart.length !== 0) result.push(tempPart) 17 | return result 18 | } 19 | -------------------------------------------------------------------------------- /packages/core/src/utils/fileUtils.ts: -------------------------------------------------------------------------------- 1 | import { FileMetaInfo } from '../interface' 2 | import { isBrowser, isNode } from './is' 3 | 4 | /** 5 | * 分割文件 6 | * @param file 7 | * @param baseSize 默认分块大小为 1MB 8 | */ 9 | export function sliceFile(file: File, baseSize = 1) { 10 | if (baseSize <= 0) throw Error('baseSize must be greater than 0') 11 | const chunkSize = Math.max(1, baseSize * 1048576) // 1MB = 1024 * 1024 12 | const chunks: Blob[] = [] 13 | let startPos = 0 14 | while (startPos < file.size) { 15 | chunks.push(file.slice(startPos, startPos + chunkSize)) 16 | startPos += chunkSize 17 | } 18 | return chunks 19 | } 20 | 21 | /** 22 | * 分割文件, 获取每个分片的起止位置 23 | * @param filePath 24 | * @param baseSize 默认分块大小为 1MB 25 | */ 26 | export async function getFileSliceLocations(filePath: string, baseSize = 1) { 27 | if (baseSize <= 0) throw Error('baseSize must be greater than 0') 28 | const fsp = await import('fs/promises') 29 | const chunkSize = Math.max(1, baseSize * 1048576) // 1MB = 1024 * 1024 30 | const stats = await fsp.stat(filePath) 31 | const end = stats.size // Bytes 字节 32 | const sliceLocation: [number, number][] = [] 33 | for (let cur = 0; cur < end; cur += chunkSize) { 34 | sliceLocation.push([cur, cur + chunkSize - 1]) 35 | } 36 | return { sliceLocation, endLocation: end } 37 | } 38 | 39 | /** 40 | * 将 File 转成 ArrayBuffer 41 | * 注意: Blob 无法直接移交到 Worker 中, 所以需要放到主线程中执行 42 | * @param chunks 43 | */ 44 | export async function getArrayBufFromBlobs(chunks: Blob[]): Promise { 45 | return Promise.all(chunks.map((chunk) => chunk.arrayBuffer())) 46 | } 47 | 48 | /** 49 | * 读取一个文件并将它转成 ArrayBuffer 50 | * @param path 文件路径 51 | * @param start 起始位置(字节) 52 | * @param end 结束位置(字节) 53 | */ 54 | export async function readFileAsArrayBuffer(path: string, start: number, end: number) { 55 | const fs = await import('fs') 56 | const readStream = fs.createReadStream(path, { start, end }) 57 | const chunks: any[] = [] 58 | return new Promise((rs, rj) => { 59 | readStream.on('data', (chunk) => { 60 | chunks.push(chunk) // 收集数据块 61 | }) 62 | 63 | readStream.on('end', () => { 64 | const buf = Buffer.concat(chunks) // 合并所有数据块构成 Buffer 65 | const arrayBuf = buf.buffer.slice(buf.byteOffset, buf.byteOffset + buf.byteLength) 66 | rs(arrayBuf as ArrayBuffer) 67 | }) 68 | 69 | readStream.on('error', (e) => { 70 | rj(e) 71 | }) 72 | }) 73 | } 74 | 75 | /** 76 | * 获取文件元数据 77 | * @param file 文件 78 | * @param filePath 文件路径 79 | */ 80 | export async function getFileMetadata(file?: File, filePath?: string): Promise { 81 | if (file && isBrowser()) { 82 | let fileType: string | undefined = '' 83 | 84 | if (file.name.includes('.')) { 85 | fileType = file.name.split('.').pop() 86 | fileType = fileType !== void 0 ? '.' + fileType : '' 87 | } 88 | 89 | return { 90 | name: file.name, 91 | size: file.size / 1024, 92 | lastModified: file.lastModified, 93 | type: fileType, 94 | } 95 | } 96 | 97 | if (filePath && isNode()) { 98 | const fsp: typeof import('node:fs/promises') = await import('fs/promises') 99 | const path: typeof import('node:path') = await import('path') 100 | const stats = await fsp.stat(filePath) 101 | return { 102 | name: path.basename(filePath), 103 | size: stats.size / 1024, 104 | lastModified: stats.mtime.getTime(), 105 | type: path.extname(filePath), 106 | } 107 | } 108 | throw new Error('Unsupported environment') 109 | } 110 | -------------------------------------------------------------------------------- /packages/core/src/utils/index.ts: -------------------------------------------------------------------------------- 1 | import * as arr from './arrayUtils' 2 | import * as fileUtils from './fileUtils' 3 | import * as is from './is' 4 | import * as miniSubject from './miniSubject' 5 | import * as rand from './rand' 6 | 7 | /** 8 | * 兼容 import utils from './utils'; utils.someFn() 写法 9 | */ 10 | export default { 11 | ...rand, 12 | ...arr, 13 | ...miniSubject, 14 | ...fileUtils, 15 | ...is, 16 | } 17 | 18 | /** 19 | * 兼容 import { someFunctionFromRand } from './utils' 写法 20 | */ 21 | export * from './arrayUtils' 22 | export * from './fileUtils' 23 | export * from './is' 24 | export * from './miniSubject' 25 | export * from './rand' 26 | -------------------------------------------------------------------------------- /packages/core/src/utils/is.ts: -------------------------------------------------------------------------------- 1 | export function isEmpty(value: any) { 2 | if (value === void 0 || value === null || value === '') { 3 | return true 4 | } 5 | if (Array.isArray(value)) { 6 | return value.length === 0 7 | } 8 | if (value instanceof Map || value instanceof Set) { 9 | return value.size === 0 10 | } 11 | if (typeof value === 'object' && !Array.isArray(value) && !(value instanceof Date)) { 12 | return Object.keys(value).length === 0 13 | } 14 | return false 15 | } 16 | 17 | /** 18 | * 判断当前运行环境是否是浏览器 19 | * @returns {boolean} 如果是在浏览器环境中运行,返回 true;否则返回 false 20 | */ 21 | export function isBrowser(): boolean { 22 | return typeof window !== 'undefined' && typeof window.document !== 'undefined' 23 | } 24 | 25 | /** 26 | * 判断当前运行环境是否是浏览器(Worker 中) 27 | * @returns {boolean} 如果是在浏览器环境中运行,返回 true;否则返回 false 28 | */ 29 | export function isBrowser2(): boolean { 30 | return typeof self !== 'undefined' && typeof self.postMessage === 'function' 31 | } 32 | 33 | /** 34 | * 判断当前运行环境是否是 Node.js 35 | * @returns {boolean} 如果是在 Node.js 环境中运行,返回 true;否则返回 false 36 | */ 37 | export function isNode(): boolean { 38 | return ( 39 | typeof global !== 'undefined' && 40 | typeof process !== 'undefined' && 41 | typeof process.versions !== 'undefined' && 42 | typeof process.versions.node !== 'undefined' 43 | ) 44 | } 45 | -------------------------------------------------------------------------------- /packages/core/src/utils/miniSubject.ts: -------------------------------------------------------------------------------- 1 | import { generateUUID } from './rand' 2 | 3 | type Cb = (value: T) => void 4 | 5 | export class MiniSubject { 6 | protected _value: T 7 | protected subscribers: Map> = new Map() 8 | 9 | constructor(value: T) { 10 | this._value = value 11 | } 12 | 13 | get value() { 14 | return this._value 15 | } 16 | 17 | next(value: T) { 18 | this._value = value 19 | this.subscribers.forEach((cb) => cb(value)) 20 | } 21 | 22 | subscribe(cb: Cb) { 23 | const id = generateUUID() 24 | this.subscribers.set(id, cb) 25 | cb(this.value) 26 | return id 27 | } 28 | 29 | unsubscribe(id: string) { 30 | this.subscribers.delete(id) 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /packages/core/src/utils/rand.ts: -------------------------------------------------------------------------------- 1 | export function generateUUID(): string { 2 | return 'xxxxxxxxxxxx4xxxyxxxxxxxxxxxxxxx'.replace(/[xy]/g, function (c) { 3 | const r = (Math.random() * 16) | 0 4 | const v = c === 'x' ? r : (r & 0x3) | 0x8 5 | return v.toString(16) 6 | }) 7 | } 8 | -------------------------------------------------------------------------------- /packages/core/src/worker/hash.worker.ts: -------------------------------------------------------------------------------- 1 | /// 2 | 3 | import { crc32, md5, xxhash64 } from 'hash-wasm' 4 | import { Strategy, WorkerReq } from '../interface' 5 | import { isBrowser2, isNode } from '../utils' 6 | 7 | async function calculateHash(req: WorkerReq) { 8 | const { chunk: buf, strategy } = req 9 | 10 | let hash = '' 11 | // const u8 = new Uint8Array(buf) 12 | if (strategy === Strategy.md5) hash = await md5(new Uint8Array(buf)) 13 | if (strategy === Strategy.crc32) hash = await crc32(new Uint8Array(buf)) 14 | if (strategy === Strategy.xxHash64) hash = await xxhash64(new Uint8Array(buf)) 15 | 16 | return { 17 | result: hash, 18 | chunk: req, 19 | } 20 | } 21 | 22 | if (isBrowser2()) { 23 | addEventListener('message', async ({ data }: { data: WorkerReq }) => { 24 | const res = await calculateHash(data) 25 | postMessage(res, [data.chunk]) 26 | }) 27 | } 28 | 29 | if (isNode()) { 30 | ;(async () => { 31 | const { parentPort } = await import('worker_threads') 32 | parentPort && 33 | parentPort.on('message', async (data: WorkerReq) => { 34 | const res = await calculateHash(data) 35 | parentPort.postMessage(res, [data.chunk]) 36 | }) 37 | })() 38 | } 39 | -------------------------------------------------------------------------------- /packages/core/src/worker/workerPoolForHash.ts: -------------------------------------------------------------------------------- 1 | import { WorkerPool } from '../entity' 2 | import { WorkerWrapper } from '../entity' 3 | import { isBrowser, isNode } from '../utils' 4 | 5 | export class WorkerPoolForHash extends WorkerPool { 6 | constructor(maxWorkers: number) { 7 | super(maxWorkers) 8 | } 9 | 10 | static async create(maxWorkers: number) { 11 | const instance = new WorkerPoolForHash(maxWorkers) 12 | const countArr = Array.from({ length: maxWorkers }) 13 | 14 | if (isBrowser()) { 15 | instance.pool = countArr.map(() => { 16 | return new WorkerWrapper( 17 | new Worker(new URL('./worker/hash.worker.mjs', import.meta.url), { type: 'module' }), 18 | ) 19 | }) 20 | } 21 | 22 | if (isNode()) { 23 | const { Worker: NodeWorker } = await import('worker_threads') 24 | instance.pool = countArr.map(() => { 25 | return new WorkerWrapper( 26 | new NodeWorker(new URL('./worker/hash.worker.mjs', import.meta.url)), 27 | ) 28 | }) 29 | } 30 | 31 | return instance 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /packages/core/src/worker/workerService.ts: -------------------------------------------------------------------------------- 1 | import { WorkerPoolForHash } from './workerPoolForHash' 2 | import { getFn, restoreFn, Strategy, WorkerReq } from '../interface' 3 | 4 | export class WorkerService { 5 | MAX_WORKERS 6 | pool: WorkerPoolForHash | undefined 7 | 8 | constructor(maxWorkers: number) { 9 | this.MAX_WORKERS = maxWorkers 10 | } 11 | 12 | private async getHashForFiles(chunks: ArrayBuffer[], strategy: Strategy) { 13 | if (this.pool === undefined) { 14 | this.pool = await WorkerPoolForHash.create(this.MAX_WORKERS) 15 | } 16 | const params: WorkerReq[] = chunks.map((chunk) => ({ 17 | chunk, 18 | strategy, 19 | })) 20 | 21 | const getFn: getFn = (param: WorkerReq) => param.chunk 22 | const restoreFn: restoreFn = (options) => { 23 | const { index, buf } = options 24 | chunks[index] = buf 25 | } 26 | 27 | return this.pool.exec(params, getFn, restoreFn) 28 | } 29 | 30 | getMD5ForFiles(chunks: ArrayBuffer[]) { 31 | return this.getHashForFiles(chunks, Strategy.md5) 32 | } 33 | 34 | getCRC32ForFiles(chunks: ArrayBuffer[]) { 35 | return this.getHashForFiles(chunks, Strategy.crc32) 36 | } 37 | 38 | getXxHash64ForFiles(chunks: ArrayBuffer[]) { 39 | return this.getHashForFiles(chunks, Strategy.xxHash64) 40 | } 41 | 42 | terminate() { 43 | this.pool && this.pool.terminate() 44 | this.pool = undefined 45 | } 46 | } 47 | -------------------------------------------------------------------------------- /packages/core/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "../../tsconfig.json" 3 | } 4 | -------------------------------------------------------------------------------- /packages/playground/benchmark-demo/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "benchmark", 3 | "private": true, 4 | "type": "module", 5 | "license": "MIT", 6 | "scripts": { 7 | "play": "node dist/index.js", 8 | "build:benchmark-demo": "pnpm rm:dist && tsup --config tsup.config.ts", 9 | "rm:dist": "rimraf ./dist" 10 | }, 11 | "dependencies": { 12 | "hash-worker": "workspace:*", 13 | "hash-worker-benchmark": "workspace:*" 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /packages/playground/benchmark-demo/src/index.ts: -------------------------------------------------------------------------------- 1 | import { benchmark, BenchmarkOptions } from 'hash-worker-benchmark' 2 | import { Strategy } from 'hash-worker' 3 | 4 | const options: BenchmarkOptions = { 5 | sizeInMB: 100, 6 | workerCountTobeTest: [8, 8, 8], 7 | strategy: Strategy.xxHash64, 8 | } 9 | 10 | benchmark(options).then(() => {}) 11 | -------------------------------------------------------------------------------- /packages/playground/benchmark-demo/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "../../../tsconfig.json", 3 | "compilerOptions": { 4 | "moduleResolution": "node", 5 | "module": "ESNext", 6 | "outDir": "./dist", // 生成的 JavaScript 文件输出目录 7 | "rootDir": "./src", // TypeScript 源文件目录 8 | "sourceMap": false, // 不生成 .map 文件 9 | "declaration": false // 不生成 .d.ts 文件 10 | }, 11 | "include": ["src/**/*.ts"] 12 | } 13 | -------------------------------------------------------------------------------- /packages/playground/benchmark-demo/tsup.config.ts: -------------------------------------------------------------------------------- 1 | import { defineConfig } from 'tsup' 2 | 3 | export default defineConfig({ 4 | entry: ['src/index.ts'], 5 | format: ['esm'], 6 | }) 7 | -------------------------------------------------------------------------------- /packages/playground/iife-demo/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | IIFE Demo 8 | 9 | 10 | 11 | 12 | 13 | 35 | 36 |
37 |

Hello

38 |
39 |

如果你在使用 Chrome 浏览器, 并且在控制台日志中发现了报错输出

40 |

这可能是因为你开启了 Vue.js devtools 或 React Developer Tools 插件

41 |

关闭它们, 错误就会消失.

42 |
43 | 44 | 45 |
46 | 47 | 48 | 49 | -------------------------------------------------------------------------------- /packages/playground/iife-demo/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "browser-demo", 3 | "private": true, 4 | "license": "MIT", 5 | "scripts": { 6 | "play": "node prepare.js && node server.js" 7 | } 8 | } 9 | -------------------------------------------------------------------------------- /packages/playground/iife-demo/prepare.js: -------------------------------------------------------------------------------- 1 | const path = require('path') 2 | const { copyFiles } = require('../../../scripts/fileCopier') 3 | 4 | const rootDir = process.cwd() 5 | 6 | // 定义要复制的文件列表 7 | const filesToCopy = [ 8 | { 9 | src: path.resolve(rootDir, '../../core/dist/global.js'), 10 | dest: path.resolve(rootDir, 'global.js'), 11 | }, 12 | { 13 | src: path.resolve(rootDir, '../../core/dist/worker/hash.worker.mjs'), 14 | dest: path.resolve(rootDir, './worker/hash.worker.mjs'), 15 | }, 16 | ] 17 | 18 | // 执行文件复制 19 | copyFiles(filesToCopy) 20 | -------------------------------------------------------------------------------- /packages/playground/iife-demo/server.js: -------------------------------------------------------------------------------- 1 | const http = require('http') 2 | const fs = require('fs') 3 | const path = require('path') 4 | 5 | const server = http.createServer((req, res) => { 6 | // 获取请求的文件路径 7 | let filePath = '.' + req.url 8 | if (filePath === './') { 9 | filePath = './index.html' 10 | } 11 | 12 | // 获取文件扩展名 13 | const extname = String(path.extname(filePath)).toLowerCase() 14 | const mimeTypes = { 15 | '.html': 'text/html', 16 | '.js': 'application/javascript', 17 | '.mjs': 'application/javascript', 18 | '.css': 'text/css', 19 | '.png': 'image/png', 20 | '.jpg': 'image/jpg', 21 | '.json': 'application/json', 22 | } 23 | 24 | const contentType = mimeTypes[extname] || 'application/octet-stream' 25 | 26 | // 读取文件 27 | fs.readFile(filePath, (error, content) => { 28 | if (error) { 29 | if (error.code === 'ENOENT') { 30 | // 如果文件不存在,返回 404 页面 31 | fs.readFile('./404.html', (error404, content404) => { 32 | res.writeHead(404, { 'Content-Type': 'text/html' }) 33 | res.end(content404, 'utf-8') 34 | }) 35 | } else { 36 | // 其他错误,返回 500 页面 37 | res.writeHead(500) 38 | res.end(`Server Error: ${error.code}`) 39 | } 40 | } else { 41 | // 成功读取文件,返回内容 42 | res.writeHead(200, { 'Content-Type': contentType }) 43 | res.end(content, 'utf-8') 44 | } 45 | }) 46 | }) 47 | 48 | const PORT = process.env.PORT || 8891 49 | server.listen(PORT, () => { 50 | console.log(`Server running at http://localhost:${PORT}/`) 51 | }) 52 | -------------------------------------------------------------------------------- /packages/playground/node-demo/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "node-demo", 3 | "private": true, 4 | "type": "module", 5 | "license": "MIT", 6 | "scripts": { 7 | "play": "node dist/index.js", 8 | "build:node-demo": "pnpm rm:dist && tsup --config tsup.config.ts", 9 | "rm:dist": "rimraf ./dist" 10 | }, 11 | "dependencies": { 12 | "hash-worker": "workspace:*", 13 | "hash-worker-benchmark": "workspace:*" 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /packages/playground/node-demo/src/index.ts: -------------------------------------------------------------------------------- 1 | import { getFileHashChunks, HashChksParam, Strategy } from 'hash-worker' 2 | 3 | const param: HashChksParam = { 4 | filePath: 'filePath...', 5 | config: { 6 | strategy: Strategy.md5, 7 | workerCount: 8, 8 | isShowLog: true, 9 | }, 10 | } 11 | 12 | const beforeDate = Date.now() 13 | function main() { 14 | getFileHashChunks(param).then((res: any) => { 15 | console.log(res) 16 | }) 17 | } 18 | 19 | main() 20 | -------------------------------------------------------------------------------- /packages/playground/node-demo/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "../../../tsconfig.json", 3 | "compilerOptions": { 4 | "moduleResolution": "node", 5 | "module": "ESNext", 6 | "outDir": "./dist", // 生成的 JavaScript 文件输出目录 7 | "rootDir": "./src", // TypeScript 源文件目录 8 | "sourceMap": false, // 不生成 .map 文件 9 | "declaration": false // 不生成 .d.ts 文件 10 | }, 11 | "include": ["src/**/*.ts"] 12 | } 13 | -------------------------------------------------------------------------------- /packages/playground/node-demo/tsup.config.ts: -------------------------------------------------------------------------------- 1 | import { defineConfig } from 'tsup' 2 | 3 | export default defineConfig({ 4 | entry: ['src/index.ts'], 5 | format: ['esm'], 6 | }) 7 | -------------------------------------------------------------------------------- /packages/playground/react-webpack-demo/.babelrc: -------------------------------------------------------------------------------- 1 | { 2 | "presets": ["@babel/preset-env", "@babel/preset-react", "@babel/preset-typescript"] 3 | } 4 | -------------------------------------------------------------------------------- /packages/playground/react-webpack-demo/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "react-webpack-demo", 3 | "private": true, 4 | "license": "MIT", 5 | "scripts": { 6 | "play": "webpack serve --mode development" 7 | }, 8 | "dependencies": { 9 | "react": "^18.2.0", 10 | "react-dom": "^18.2.0", 11 | "hash-worker": "workspace:*" 12 | }, 13 | "devDependencies": { 14 | "@babel/core": "^7.26.0", 15 | "@babel/preset-env": "^7.26.0", 16 | "@babel/preset-react": "^7.26.3", 17 | "@babel/preset-typescript": "^7.26.0", 18 | "@types/react": "^19.0.3", 19 | "@types/react-dom": "^19.0.2", 20 | "babel-loader": "^9.2.1", 21 | "typescript": "^5.7.2", 22 | "webpack": "^5.97.1", 23 | "webpack-cli": "^6.0.1", 24 | "webpack-dev-server": "^5.2.0" 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /packages/playground/react-webpack-demo/public/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | React Webpack Demo 7 | 8 | 9 |
10 | 11 | 12 | 13 | -------------------------------------------------------------------------------- /packages/playground/react-webpack-demo/src/index.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import ReactDOM from 'react-dom/client' 3 | import { getFileHashChunks, HashChksRes, HashChksParam } from 'hash-worker' 4 | 5 | function App() { 6 | let file: File 7 | 8 | function handleInputChange(e: any) { 9 | file = e.target.files[0] 10 | } 11 | 12 | function handleGetHash() { 13 | const param: HashChksParam = { 14 | file: file!, 15 | config: { 16 | workerCount: 8, 17 | }, 18 | } 19 | 20 | getFileHashChunks(param).then((data: HashChksRes) => { 21 | console.log(data) 22 | alert('Calculation complete, please check the console!') 23 | }) 24 | } 25 | 26 | return ( 27 | <> 28 |
Hello
29 | 30 | 31 | 32 | ) 33 | } 34 | 35 | ReactDOM.createRoot(document.querySelector('#app')!).render( 36 | 37 | 38 | , 39 | ) 40 | -------------------------------------------------------------------------------- /packages/playground/react-webpack-demo/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "../../../tsconfig.json", 3 | "compilerOptions": { 4 | "jsx": "react", 5 | "types": ["react", "react-dom"] 6 | } 7 | } 8 | -------------------------------------------------------------------------------- /packages/playground/react-webpack-demo/webpack.config.js: -------------------------------------------------------------------------------- 1 | const path = require('path') 2 | 3 | module.exports = { 4 | entry: './src/index.tsx', 5 | output: { 6 | filename: 'bundle.js', 7 | path: path.resolve(__dirname, 'dist'), 8 | publicPath: '/', 9 | }, 10 | resolve: { 11 | extensions: ['.ts', '.tsx', '.js'], 12 | fallback: { 13 | fs: false, 14 | path: false, 15 | 'fs/promises': false, 16 | worker_threads: false, 17 | }, 18 | }, 19 | module: { 20 | rules: [ 21 | { 22 | test: /\.(ts|tsx)$/, 23 | use: 'babel-loader', 24 | exclude: /node_modules/, 25 | }, 26 | ], 27 | }, 28 | externals: { 29 | fs: 'commonjs fs', 30 | path: 'commonjs path', 31 | 'fs/promises': 'commonjs fs/promises', 32 | worker_threads: 'commonjs worker_threads', 33 | }, 34 | devServer: { 35 | static: { 36 | directory: path.join(__dirname, 'public'), 37 | }, 38 | compress: true, 39 | port: 8890, 40 | historyApiFallback: true, 41 | }, 42 | } 43 | -------------------------------------------------------------------------------- /packages/playground/vue-vite-demo/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | Vue Vite Demo 7 | 8 | 9 |
10 | 11 | 12 | 13 | -------------------------------------------------------------------------------- /packages/playground/vue-vite-demo/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "vue-demo", 3 | "private": true, 4 | "type": "module", 5 | "license": "MIT", 6 | "scripts": { 7 | "play": "vite --port 8888" 8 | }, 9 | "dependencies": { 10 | "hash-worker": "workspace:*", 11 | "hash-worker-benchmark": "workspace:*", 12 | "vue": "^3.5.13" 13 | }, 14 | "devDependencies": { 15 | "@vitejs/plugin-vue": "^5.2.1", 16 | "vite": "^6.0.7" 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /packages/playground/vue-vite-demo/src/App.vue: -------------------------------------------------------------------------------- 1 | 17 | 18 | 28 | -------------------------------------------------------------------------------- /packages/playground/vue-vite-demo/src/hooks/useFileHashInfo.ts: -------------------------------------------------------------------------------- 1 | import { ref } from 'vue' 2 | import { 3 | destroyWorkerPool, 4 | getFileHashChunks, 5 | HashChksParam, 6 | HashChksRes, 7 | Strategy, 8 | } from 'hash-worker' 9 | 10 | export function useFileHashInfo() { 11 | const file = ref() 12 | 13 | function handleInputChange(e: any) { 14 | file.value = e.target.files[0] 15 | } 16 | 17 | function handleGetHash() { 18 | const param: HashChksParam = { 19 | file: file.value!, 20 | config: { 21 | workerCount: 8, 22 | strategy: Strategy.md5, 23 | }, 24 | } 25 | 26 | getFileHashChunks(param).then((res: HashChksRes) => { 27 | console.log(res) 28 | alert('Calculation complete, please check the console!') 29 | }) 30 | } 31 | 32 | function handleDestroyWorkerPool() { 33 | destroyWorkerPool() 34 | } 35 | 36 | return { 37 | handleInputChange, 38 | handleGetHash, 39 | handleDestroyWorkerPool, 40 | } 41 | } 42 | -------------------------------------------------------------------------------- /packages/playground/vue-vite-demo/src/main.ts: -------------------------------------------------------------------------------- 1 | import { createApp } from 'vue' 2 | import App from './App.vue' 3 | 4 | createApp(App).mount('#app') 5 | -------------------------------------------------------------------------------- /packages/playground/vue-vite-demo/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "../../../tsconfig.json", 3 | "compilerOptions": { 4 | "types": ["vite/client"] 5 | } 6 | } 7 | -------------------------------------------------------------------------------- /packages/playground/vue-vite-demo/vite.config.ts: -------------------------------------------------------------------------------- 1 | import { defineConfig } from 'vite' 2 | import vue from '@vitejs/plugin-vue' 3 | 4 | export default defineConfig({ 5 | plugins: [vue()], 6 | optimizeDeps: { 7 | exclude: ['hash-worker'], 8 | }, 9 | }) 10 | -------------------------------------------------------------------------------- /pnpm-workspace.yaml: -------------------------------------------------------------------------------- 1 | packages: 2 | - "packages/core" 3 | - "packages/benchmark" 4 | - "packages/playground/*" 5 | -------------------------------------------------------------------------------- /scripts/clear.js: -------------------------------------------------------------------------------- 1 | const rimraf = require('rimraf') 2 | const path = require('path') 3 | 4 | const nodeModulesDir = [ 5 | '', 6 | 'packages/benchmark/', 7 | 'packages/core/', 8 | 'packages/playground/benchmark-demo/', 9 | 'packages/playground/node-demo/', 10 | 'packages/playground/react-webpack-demo/', 11 | 'packages/playground/vue-vite-demo/', 12 | ].map((dir) => dir + 'node_modules') 13 | 14 | const distToBeBundled = [ 15 | 'packages/benchmark/', 16 | 'packages/core/', 17 | 'packages/playground/benchmark-demo/', 18 | 'packages/playground/node-demo/', 19 | ] 20 | 21 | const distDir = distToBeBundled.map((dir) => dir + 'dist') 22 | const turboCacheDir = distToBeBundled.map((dir) => dir + '.turbo') 23 | const iifeDemoDeps = [ 24 | 'packages/playground/iife-demo/global.js', 25 | 'packages/playground/iife-demo/worker', 26 | ] 27 | 28 | const coverageDir = ['packages/core/coverage'] 29 | 30 | // 主函数来删除所有路径,并处理错误 31 | function removePaths(paths) { 32 | paths.forEach((path) => { 33 | try { 34 | rimraf.sync(path) 35 | console.log(`Successfully deleted: ${path}`) 36 | } catch (err) { 37 | console.error(`Failed to delete: ${path}, Error: ${err.message}`) 38 | } 39 | }) 40 | 41 | console.log('All deletion attempts have been processed.') 42 | } 43 | 44 | function processArgs() { 45 | const args = process.argv.slice(2) 46 | let pattern = '' 47 | 48 | args.forEach((arg) => { 49 | const [key, value] = arg.split('=') 50 | if (key === '--pattern') { 51 | pattern = value 52 | } 53 | }) 54 | return pattern 55 | } 56 | 57 | ;(() => { 58 | const startTime = Date.now() // 记录开始时间 59 | const pattern = processArgs() // 获取执行参数 60 | 61 | // 定义目录映射 62 | const dirMap = { 63 | node_modules: nodeModulesDir, 64 | dist: distDir, 65 | cache: turboCacheDir, 66 | coverage: coverageDir, 67 | } 68 | 69 | let pathsToDelete = [] 70 | 71 | if (pattern === 'all') { 72 | pathsToDelete = [ 73 | ...nodeModulesDir, 74 | ...distDir, 75 | ...turboCacheDir, 76 | ...iifeDemoDeps, 77 | ...coverageDir, 78 | ] 79 | } else if (dirMap[pattern]) { 80 | pathsToDelete = dirMap[pattern] 81 | } 82 | 83 | // 解析路径并删除 84 | pathsToDelete = pathsToDelete.map((p) => path.resolve(process.cwd(), p)) 85 | 86 | removePaths(pathsToDelete) 87 | 88 | const endTime = Date.now() // 记录结束时间 89 | const timeTaken = endTime - startTime // 计算总耗时 90 | console.log(`Total time taken: ${timeTaken}ms`) 91 | })() 92 | -------------------------------------------------------------------------------- /scripts/fileCopier.js: -------------------------------------------------------------------------------- 1 | const fs = require('fs') 2 | const path = require('path') 3 | 4 | /** 5 | * 复制文件的函数 6 | * @param {string} src - 源文件路径 7 | * @param {string} dest - 目标文件路径 8 | */ 9 | function copyFile(src, dest) { 10 | // 如果目标文件已存在,则先删除 11 | if (fs.existsSync(dest)) { 12 | try { 13 | fs.unlinkSync(dest) 14 | console.log(`Deleted existing file at ${dest}`) 15 | } catch (err) { 16 | console.error(`Error deleting file at ${dest}:`, err) 17 | return 18 | } 19 | } 20 | 21 | const readStream = fs.createReadStream(src) 22 | const writeStream = fs.createWriteStream(dest) 23 | 24 | readStream.on('error', (err) => { 25 | console.error(`Error reading file from ${src}:`, err) 26 | }) 27 | 28 | writeStream.on('error', (err) => { 29 | console.error(`Error writing file to ${dest}:`, err) 30 | }) 31 | 32 | writeStream.on('finish', () => { 33 | console.log(`Successfully copied ${src} to ${dest}`) 34 | }) 35 | 36 | readStream.pipe(writeStream) 37 | } 38 | 39 | /** 40 | * 复制多个文件的函数 41 | * @param {Array<{src: string, dest: string}>} files - 包含源文件和目标文件路径的对象数组 42 | */ 43 | function copyFiles(files) { 44 | files.forEach(({ src, dest }) => { 45 | // 确保目标目录存在 46 | const destDir = path.dirname(dest) 47 | if (!fs.existsSync(destDir)) { 48 | fs.mkdirSync(destDir, { recursive: true }) 49 | } 50 | 51 | copyFile(src, dest) 52 | }) 53 | } 54 | 55 | // 使用 module.exports 导出 copyFiles 函数 56 | module.exports = { copyFiles } 57 | -------------------------------------------------------------------------------- /scripts/syncReadme.js: -------------------------------------------------------------------------------- 1 | const path = require('path') 2 | const { copyFiles } = require('./fileCopier') 3 | 4 | // 获取项目的根目录路径 5 | const rootDir = process.cwd() 6 | 7 | // 定义要复制的文件列表 8 | const filesToCopy = [ 9 | { 10 | src: path.resolve(rootDir, 'README.md'), 11 | dest: path.resolve(rootDir, 'packages/core/README.md'), 12 | }, 13 | { 14 | src: path.resolve(rootDir, 'README-zh.md'), 15 | dest: path.resolve(rootDir, 'packages/core/README-zh.md'), 16 | }, 17 | ] 18 | 19 | // 执行文件复制 20 | copyFiles(filesToCopy) 21 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "es2022", 4 | "module": "ESNext", 5 | "sourceMap": true, 6 | "declaration": true, // 是否包含 d.ts 文件 7 | "strict": true, // Ts 的严格模式 8 | "esModuleInterop": true, // 可以使用 es6 的导入语法导入 cjs 模块 9 | "forceConsistentCasingInFileNames": true, // 确保文件大小写一致来确保引用的一致性 10 | "skipLibCheck": true, // 跳过第三方库的 d.ts 类型文件检查 11 | "lib": ["esnext", "dom"], // 指定编译过程中需要包括的库文件列表, 这些库文件是声明 js 运行时和 dom 中可用的全局变量的类型 12 | "moduleResolution": "Bundler" // 定义依赖解析策略, 默认是 node 模块解析策略 13 | } 14 | } 15 | -------------------------------------------------------------------------------- /turbo.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://turbo.build/schema.json", 3 | "tasks": { 4 | "build": { 5 | "dependsOn": ["^build"], 6 | "outputs": ["dist/**"], 7 | "outputLogs": "new-only" 8 | }, 9 | "build:benchmark": { 10 | "dependsOn": ["^build", "^build:benchmark"], 11 | "outputs": ["dist/**"], 12 | "outputLogs": "new-only" 13 | }, 14 | "build:benchmark-demo": { 15 | "dependsOn": ["^build:benchmark", "^build:benchmark-demo"], 16 | "outputs": ["dist/**"], 17 | "outputLogs": "new-only" 18 | }, 19 | "build:node-demo": { 20 | "dependsOn": ["^build", "^build:node-demo"], 21 | "outputs": ["dist/**"], 22 | "outputLogs": "new-only" 23 | }, 24 | "build:all": { 25 | "dependsOn": ["build", "build:benchmark", "build:benchmark-demo", "build:node-demo"], 26 | "outputs": ["dist/**"], 27 | "outputLogs": "new-only" 28 | } 29 | } 30 | } 31 | --------------------------------------------------------------------------------