├── .browserslistrc ├── .github └── workflows │ ├── node.js.yml │ └── release.yml ├── .gitignore ├── LICENSE.md ├── README.md ├── jest.config.js ├── node_test.js ├── nodemon.json ├── package-lock.json ├── package.json ├── rollup.config.dev.js ├── rollup.config.js ├── rollup.config.worker.dev.js ├── rollup.config.worker.js ├── src ├── index.ts ├── lib │ ├── colors.ts │ ├── getErrorPreview.ts │ ├── keys.ts │ ├── parseTopLevelYieldStatements.ts │ ├── polyfills │ │ ├── ErrorEvent.ts │ │ ├── Event.ts │ │ ├── EventTarget.ts │ │ ├── Promise.withResolvers.ts │ │ ├── PromiseRejectionEvent.ts │ │ ├── Worker.ts │ │ ├── WorkerGlobalScope.ts │ │ ├── import.meta.resolve.ts │ │ └── web-worker.ts │ ├── replaceContents.ts │ ├── serialize.ts │ ├── setupWorkerListeners.ts │ ├── types.d.ts │ └── worker.worker.ts └── test.ts ├── test ├── edge-cases.test.js ├── examples.test.js └── import.test.js └── tsconfig.json /.browserslistrc: -------------------------------------------------------------------------------- 1 | defaults and fully supports es6-module 2 | maintained node versions -------------------------------------------------------------------------------- /.github/workflows/node.js.yml: -------------------------------------------------------------------------------- 1 | # This workflow will do a clean installation of node dependencies, cache/restore them, build the source code and run tests across different versions of node 2 | # For more information see: https://docs.github.com/en/actions/automating-builds-and-tests/building-and-testing-nodejs 3 | 4 | name: Node.js CI 5 | 6 | on: 7 | push: 8 | branches: ["main"] 9 | pull_request: 10 | branches: ["main"] 11 | 12 | jobs: 13 | build: 14 | runs-on: ubuntu-latest 15 | 16 | strategy: 17 | matrix: 18 | node-version: [20.x, 21.x] 19 | # See supported Node.js release schedule at https://nodejs.org/en/about/releases/ 20 | 21 | steps: 22 | - uses: actions/checkout@v3 23 | - name: Use Node.js ${{ matrix.node-version }} 24 | uses: actions/setup-node@v3 25 | with: 26 | node-version: ${{ matrix.node-version }} 27 | cache: "npm" 28 | - run: npm ci 29 | - run: npm run build --if-present 30 | - run: npm test 31 | -------------------------------------------------------------------------------- /.github/workflows/release.yml: -------------------------------------------------------------------------------- 1 | name: Publish Package to npmjs 2 | on: 3 | release: 4 | types: [published] 5 | jobs: 6 | build: 7 | runs-on: ubuntu-latest 8 | steps: 9 | - uses: actions/checkout@v4 10 | - uses: actions/setup-node@v3 11 | with: 12 | node-version: "20.x" 13 | registry-url: "https://registry.npmjs.org" 14 | - run: npm ci 15 | - run: npm publish 16 | env: 17 | NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} 18 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | /dist 3 | /.temp -------------------------------------------------------------------------------- /LICENSE.md: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2023 Walter van der Giessen 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the “Software”), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: 6 | 7 | The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. 8 | 9 | THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 10 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 |
2 | 3 | [![Multithreading Banner](https://github.com/W4G1/multithreading/assets/38042257/ead29359-37c3-4fd2-819b-349ddeec5524)](https://multithreading.io) 4 | 5 | [![License](https://img.shields.io/github/license/W4G1/multithreading)](https://github.com/W4G1/multithreading/blob/main/LICENSE.md) 6 | [![Downloads](https://img.shields.io/npm/dw/multithreading?color=%238956FF)](https://www.npmjs.com/package/multithreading) 7 | [![NPM version](https://img.shields.io/npm/v/multithreading)](https://www.npmjs.com/package/multithreading?activeTab=versions) 8 | [![GitHub Repo stars](https://img.shields.io/github/stars/W4G1/multithreading?logo=github&label=Star&labelColor=rgb(26%2C%2030%2C%2035)&color=rgb(13%2C%2017%2C%2023))](https://github.com/W4G1/multithreading) 9 | [![Node.js CI](https://github.com/W4G1/multithreading/actions/workflows/node.js.yml/badge.svg)](https://github.com/W4G1/multithreading/actions/workflows/node.js.yml) 10 | 11 |
12 | 13 | # multithreading 14 | 15 | Multithreading is a tiny runtime that allows you to execute JavaScript functions on separate threads. It is designed to be as simple and fast as possible, and to be used in a similar way to regular functions. 16 | 17 | With a minified size of only 4.5kb, it has first class support for [Node.js](https://nodejs.org/), [Deno](https://deno.com/) and the [browser](https://caniuse.com/?search=webworkers). It can also be used with any framework or library such as [React](https://react.dev/), [Vue](https://vuejs.org/) or [Svelte](https://svelte.dev/). 18 | 19 | Depending on the environment, it uses [Worker Threads](https://nodejs.org/api/worker_threads.html) or [Web Workers](https://developer.mozilla.org/en-US/docs/Web/API/Worker). In addition to [ES6 generators](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Statements/function*) to make multithreading as simple as possible. 20 | 21 | ### The State of Multithreading in JavaScript 22 | 23 | JavaScript's single-threaded nature means that tasks are executed one after the other, leading to potential performance bottlenecks and underutilized CPU resources. While [Web Workers](https://developer.mozilla.org/en-US/docs/Web/API/Worker) and [Worker Threads](https://nodejs.org/api/worker_threads.html) offer a way to offload tasks to separate threads, managing the state and communication between these threads is often complex and error-prone. 24 | 25 | This project aims to solve these challenges by providing an intuitive Web Worker abstraction that mirrors the behavior of regular JavaScript functions. 26 | This way it feels like you're executing a regular function, but in reality, it's running in parallel on a separate threads. 27 | 28 | ## Installation 29 | 30 | ```bash 31 | npm install multithreading 32 | ``` 33 | 34 | ## Usage 35 | 36 | #### Basic example 37 | 38 | ```js 39 | import { threaded } from "multithreading"; 40 | 41 | const add = threaded(function* (a, b) { 42 | return a + b; 43 | }); 44 | 45 | console.log(await add(5, 10)); // 15 46 | ``` 47 | The `add` function is executed on a separate thread, and the result is returned to the main thread when the function is done executing. Consecutive invocations will be automatically executed in parallel on separate threads. 48 | 49 | #### Example with shared state 50 | 51 | ```js 52 | import { threaded, $claim, $unclaim } from "multithreading"; 53 | 54 | const user = { 55 | name: "john", 56 | balance: 0, 57 | }; 58 | 59 | const add = threaded(async function* (amount) { 60 | yield user; // Add user to dependencies 61 | 62 | await $claim(user); // Wait for write lock 63 | 64 | user.balance += amount; 65 | 66 | $unclaim(user); // Release write lock 67 | }); 68 | 69 | await Promise.all([ 70 | add(5), 71 | add(10), 72 | ]); 73 | 74 | console.log(user.balance); // 15 75 | ``` 76 | This example shows how to use a shared state across multiple threads. It introduces the concepts of claiming and unclaiming write access using `$claim` and `$unclaim`. This is to ensure that only one thread can write to a shared state at a time. 77 | 78 | > Always `$unclaim()` a shared state after use, otherwise the write lock will never be released and other threads that want to write to this state will be blocked indefinitely. 79 | 80 | The `yield` statement is used to specify external dependencies, and must be defined at the top of the function. 81 | 82 | #### Example with external functions 83 | 84 | ```js 85 | import { threaded, $claim, $unclaim } from "multithreading"; 86 | 87 | // Some external function 88 | function add (a, b) { 89 | return a + b; 90 | } 91 | 92 | const user = { 93 | name: "john", 94 | balance: 0, 95 | }; 96 | 97 | const addBalance = threaded(async function* (amount) { 98 | yield user; 99 | yield add; // Add external function to dependencies 100 | 101 | await $claim(user); 102 | 103 | user.balance = add(user.balance, amount); 104 | 105 | $unclaim(user); 106 | }); 107 | 108 | 109 | await Promise.all([ 110 | addBalance(5), 111 | addBalance(10), 112 | ]); 113 | 114 | console.log(user.balance); // 15 115 | ``` 116 | In this example, the `add` function is used within the multithreaded `addBalance` function. The `yield` statement is used to declare external dependencies. You can think of it as a way to import external data, functions or even packages. 117 | 118 | As with previous examples, the shared state is managed using `$claim` and `$unclaim` to guarantee proper synchronization and prevent data conflicts. 119 | 120 | > External functions like `add` cannot have external dependencies themselves. All variables and functions used by an external function must be declared within the function itself. 121 | 122 | ### Using imports from external packages 123 | 124 | When using external modules, you can dynamically import them by using `yield "some-package"`. This is useful when you want to use other packages within a threaded function. 125 | 126 | ```js 127 | import { threaded } from "multithreading"; 128 | 129 | const getId = threaded(function* () { 130 | /** 131 | * @type {import("uuid")} 132 | */ 133 | const { v4 } = yield "uuid"; // Import other package 134 | 135 | return v4(); 136 | } 137 | 138 | console.log(await getId()); // 1a107623-3052-4f61-aca9-9d9388fb2d81 139 | ``` 140 | 141 | You can also import external modules in a variety of other ways: 142 | ```js 143 | const { v4 } = yield "npm:uuid"; // Using npm specifier (available in Deno) 144 | const { v4 } = yield "https://esm.sh/uuid"; // From CDN url (available in browser and Deno) 145 | ``` 146 | 147 | ## Enhanced Error Handling 148 | Errors inside threads are automatically injected with a pretty stack trace. 149 | This makes it easier to identify which line of your function caused the error, and in which thread the error occured. 150 | 151 | ![Example of an enhanced stack trace](https://github.com/W4G1/multithreading/assets/38042257/4c5b3352-ad19-4270-86e4-dad6fc6e0fe6) 152 | -------------------------------------------------------------------------------- /jest.config.js: -------------------------------------------------------------------------------- 1 | /** @type {import('jest').Config} */ 2 | module.exports = { 3 | // verbose: true, 4 | rootDir: "./test", 5 | }; 6 | -------------------------------------------------------------------------------- /node_test.js: -------------------------------------------------------------------------------- 1 | "use strict"; 2 | 3 | /** @type {import('./src/index.ts').threaded} */ 4 | const threaded = require("./dist/index.js").threaded; 5 | /** @type {import('./src/index.ts').$unclaim}*/ 6 | const $unclaim = require("./dist/index.js").$unclaim; 7 | /** @type {import('./src/index.ts').$claim}*/ 8 | const $claim = require("./dist/index.js").$claim; 9 | 10 | function add(a, b) { 11 | return a + b; 12 | } 13 | 14 | async function main() { 15 | const add = threaded(function* (a, b) { 16 | return a + b; 17 | }); 18 | 19 | console.log(await add(5, 10)); // 15 20 | 21 | // const user = { 22 | // name: "john", 23 | // balance: 100, 24 | // }; 25 | 26 | // const addBalance = threaded(async function* (amount) { 27 | // yield { user, add }; 28 | 29 | // await $claim(user); 30 | 31 | // // Thread now has ownership over user and is 32 | // // guaranteed not to change by other threads 33 | // user.balance = add(user.balance, amount); 34 | 35 | // $unclaim(user); 36 | 37 | // return user; 38 | // }); 39 | 40 | // await Promise.all([ 41 | // addBalance(10), 42 | // addBalance(10), 43 | // addBalance(10), 44 | // addBalance(10), 45 | // addBalance(10), 46 | // addBalance(10), 47 | // addBalance(10), 48 | // addBalance(10), 49 | // addBalance(10), 50 | // addBalance(10), 51 | // ]); 52 | 53 | // console.assert(user.balance === 200, "Balance should be 200"); 54 | 55 | // console.log("Result in main:", user); 56 | 57 | // addBalance.dispose(); 58 | } 59 | 60 | main(); 61 | -------------------------------------------------------------------------------- /nodemon.json: -------------------------------------------------------------------------------- 1 | { 2 | "watch": ["."], 3 | "ext": "js,ts,json", 4 | "ignore": [".temp", "dist", ".rollup.cache"], 5 | "exec": "rollup -c rollup.config.worker.dev.js --configPlugin @rollup/plugin-swc && rollup -c rollup.config.dev.js --configPlugin @rollup/plugin-swc && node ./node_test.js" 6 | } 7 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "multithreading", 3 | "version": "0.2.1", 4 | "description": "⚡ Multithreading functions in JavaScript to speedup heavy workloads, designed to feel like writing vanilla functions.", 5 | "author": "Walter van der Giessen ", 6 | "homepage": "https://multithreading.io", 7 | "license": "MIT", 8 | "keywords": [ 9 | "multithreading", 10 | "threads", 11 | "webworkers", 12 | "parallel", 13 | "concurrent", 14 | "concurrency", 15 | "web-workers", 16 | "worker-threads" 17 | ], 18 | "types": "./dist/index.d.ts", 19 | "module": "./dist/index.mjs", 20 | "main": "./dist/index.js", 21 | "exports": { 22 | "types": "./dist/index.d.ts", 23 | "import": "./dist/index.mjs", 24 | "require": "./dist/index.js" 25 | }, 26 | "files": [ 27 | "dist/" 28 | ], 29 | "repository": { 30 | "type": "git", 31 | "url": "git+https://github.com/W4G1/multithreading.git" 32 | }, 33 | "publishConfig": { 34 | "access": "public" 35 | }, 36 | "engines": { 37 | "node": ">=20" 38 | }, 39 | "devDependencies": { 40 | "@babel/plugin-transform-runtime": "^7.24.0", 41 | "@babel/preset-env": "^7.24.0", 42 | "@babel/preset-typescript": "^7.23.3", 43 | "@rollup/plugin-babel": "^6.0.4", 44 | "@rollup/plugin-inject": "^5.0.5", 45 | "@rollup/plugin-node-resolve": "^15.2.3", 46 | "@rollup/plugin-replace": "^5.0.5", 47 | "@rollup/plugin-swc": "^0.3.0", 48 | "@rollup/plugin-terser": "^0.4.4", 49 | "@types/node": "^20.11.26", 50 | "cross-env": "^7.0.3", 51 | "jest": "^29.7.0", 52 | "nodemon": "^3.1.0", 53 | "rimraf": "^5.0.5", 54 | "rollup": "^4.13.0", 55 | "rollup-plugin-ts": "^3.4.5", 56 | "tslib": "^2.6.2", 57 | "typescript": "^5.4.2", 58 | "uuid": "^9.0.1" 59 | }, 60 | "scripts": { 61 | "build:worker": "rimraf .temp && rollup -c rollup.config.worker.js --configPlugin @rollup/plugin-swc", 62 | "build": "npm run build:worker && rollup -c rollup.config.js --configPlugin @rollup/plugin-swc && rimraf .temp", 63 | "dev": "nodemon", 64 | "prepublishOnly": "npm run build", 65 | "test": "cross-env NODE_OPTIONS=\"$NODE_OPTIONS --experimental-vm-modules\" jest" 66 | }, 67 | "bugs": { 68 | "url": "https://github.com/W4G1/multithreading/issues" 69 | }, 70 | "optionalDependencies": { 71 | "@rollup/rollup-linux-x64-gnu": "^4.13.0" 72 | } 73 | } -------------------------------------------------------------------------------- /rollup.config.dev.js: -------------------------------------------------------------------------------- 1 | import terser from "@rollup/plugin-terser"; 2 | import replace from "@rollup/plugin-replace"; 3 | import fs from "node:fs"; 4 | import swc from "@rollup/plugin-swc"; 5 | 6 | const bundleResolve = { 7 | esm: "import.meta.resolve", 8 | cjs: "require.resolve", 9 | }; 10 | 11 | export default ["cjs"].flatMap((type) => { 12 | const ext = type === "esm" ? "mjs" : "js"; 13 | return [""].map( 14 | (version) => 15 | /** @type {import('rollup').RollupOptions} */ ({ 16 | input: `src/index.ts`, 17 | treeshake: version === ".min", 18 | 19 | plugins: [ 20 | swc(), 21 | replace({ 22 | __INLINE_WORKER__: fs 23 | .readFileSync(`.temp/worker.${type}${version}.js`, "utf8") 24 | .replaceAll("\\", "\\\\") 25 | .replaceAll("`", "\\`") 26 | .replaceAll("$", "\\$"), 27 | }), 28 | { 29 | resolveImportMeta(prop, { format }) { 30 | if (prop === "resolve") { 31 | return bundleResolve[format]; 32 | } 33 | }, 34 | }, 35 | ], 36 | output: [ 37 | { 38 | file: `dist/index.${ext}`, 39 | format: type, 40 | sourcemap: false, 41 | name: "multithreading", 42 | dynamicImportInCjs: true, 43 | globals: { 44 | "web-worker": "Worker", 45 | }, 46 | plugins: [...(version === ".min" ? [terser()] : [])], 47 | }, 48 | ], 49 | }) 50 | ); 51 | }); 52 | -------------------------------------------------------------------------------- /rollup.config.js: -------------------------------------------------------------------------------- 1 | import resolve from "@rollup/plugin-node-resolve"; 2 | import terser from "@rollup/plugin-terser"; 3 | import babel from "@rollup/plugin-babel"; 4 | import replace from "@rollup/plugin-replace"; 5 | import swc from "@rollup/plugin-swc"; 6 | import fs from "node:fs"; 7 | import ts from "rollup-plugin-ts"; 8 | 9 | const bundleResolve = { 10 | esm: "import.meta.resolve", 11 | cjs: "require.resolve", 12 | }; 13 | 14 | export default ["esm", "cjs"].flatMap((type) => { 15 | const ext = type === "esm" ? "mjs" : "js"; 16 | return ["", ".min"].map( 17 | (version) => 18 | /** @type {import('rollup').RollupOptions} */ ({ 19 | input: `src/index.ts`, 20 | treeshake: version === ".min", 21 | plugins: [ 22 | ...(type === "cjs" && version === "" 23 | ? [ 24 | ts({ 25 | browserslist: false, 26 | transpileOnly: true, 27 | }), 28 | ] 29 | : []), 30 | swc(), 31 | resolve(), 32 | babel({ 33 | babelHelpers: "bundled", 34 | include: ["src/**/*.ts"], 35 | extensions: [".js", ".ts"], 36 | exclude: ["./node_modules/**"], 37 | }), 38 | replace({ 39 | __INLINE_WORKER__: fs 40 | .readFileSync(`.temp/worker.${type}${version}.js`, "utf8") 41 | .replaceAll("\\", "\\\\") 42 | .replaceAll("`", "\\`") 43 | .replaceAll("$", "\\$"), 44 | }), 45 | { 46 | resolveImportMeta(prop, { format }) { 47 | if (prop === "resolve") { 48 | return bundleResolve[format]; 49 | } 50 | }, 51 | }, 52 | ], 53 | 54 | output: [ 55 | { 56 | file: `dist/index${version}.${ext}`, 57 | format: type, 58 | sourcemap: false, 59 | name: "multithreading", 60 | dynamicImportInCjs: true, 61 | globals: { 62 | "web-worker": "Worker", 63 | }, 64 | plugins: [ 65 | ...(version === ".min" 66 | ? [ 67 | terser({ 68 | compress: { 69 | toplevel: true, 70 | passes: 2, 71 | }, 72 | mangle: {}, 73 | }), 74 | ] 75 | : []), 76 | ], 77 | }, 78 | ], 79 | }) 80 | ); 81 | }); 82 | -------------------------------------------------------------------------------- /rollup.config.worker.dev.js: -------------------------------------------------------------------------------- 1 | import swc from "@rollup/plugin-swc"; 2 | 3 | /** @type {import('rollup').RollupOptions[]} */ 4 | export default [ 5 | { 6 | input: `src/lib/worker.worker.ts`, 7 | plugins: [swc()], 8 | output: [ 9 | { 10 | file: ".temp/worker.cjs.js", 11 | format: "cjs", 12 | sourcemap: false, 13 | dynamicImportInCjs: true, 14 | name: "multithreading", 15 | }, 16 | ], 17 | }, 18 | ]; 19 | -------------------------------------------------------------------------------- /rollup.config.worker.js: -------------------------------------------------------------------------------- 1 | import resolve from "@rollup/plugin-node-resolve"; 2 | import terser from "@rollup/plugin-terser"; 3 | import babel from "@rollup/plugin-babel"; 4 | 5 | /** @type {import('rollup').RollupOptions[]} */ 6 | export default [ 7 | { 8 | input: `src/lib/worker.worker.ts`, 9 | plugins: [ 10 | resolve(), 11 | babel({ 12 | babelHelpers: "bundled", 13 | include: ["src/**/*.ts"], 14 | extensions: [".js", ".ts"], 15 | exclude: ["./node_modules/**"], 16 | presets: ["@babel/typescript"], 17 | }), 18 | ], 19 | output: [ 20 | { 21 | file: ".temp/worker.esm.js", 22 | format: "esm", 23 | sourcemap: false, 24 | name: "multithreading", 25 | }, 26 | { 27 | file: ".temp/worker.esm.min.js", 28 | format: "esm", 29 | sourcemap: false, 30 | plugins: [ 31 | terser({ 32 | compress: { 33 | toplevel: true, 34 | passes: 3, 35 | }, 36 | }), 37 | ], 38 | name: "multithreading", 39 | }, 40 | { 41 | file: ".temp/worker.cjs.js", 42 | format: "cjs", 43 | sourcemap: false, 44 | dynamicImportInCjs: true, 45 | name: "multithreading", 46 | }, 47 | { 48 | file: ".temp/worker.cjs.min.js", 49 | format: "cjs", 50 | sourcemap: false, 51 | dynamicImportInCjs: true, 52 | plugins: [ 53 | terser({ 54 | compress: { 55 | toplevel: true, 56 | passes: 2, 57 | }, 58 | mangle: {}, 59 | }), 60 | ], 61 | name: "multithreading", 62 | }, 63 | ], 64 | }, 65 | ]; 66 | -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- 1 | import "./lib/polyfills/Promise.withResolvers.ts"; 2 | import "./lib/polyfills/import.meta.resolve.ts"; 3 | import "./lib/polyfills/web-worker.ts"; 4 | 5 | import { serialize } from "./lib/serialize.ts"; 6 | import * as $ from "./lib/keys.ts"; 7 | import { MainEvent, UserFunction } from "./lib/types"; 8 | import { setupWorkerListeners } from "./lib/setupWorkerListeners.ts"; 9 | import { parseTopLevelYieldStatements } from "./lib/parseTopLevelYieldStatements.ts"; 10 | 11 | const inlineWorker = `__INLINE_WORKER__`; 12 | 13 | export async function $claim(value: Object) {} 14 | export function $unclaim(value: Object) {} 15 | 16 | const workerPools = new WeakMap(); 17 | const valueOwnershipQueue = new WeakMap(); 18 | 19 | interface ThreadedConfig { 20 | debug: boolean; 21 | maxThreads: number; 22 | } 23 | 24 | export function threaded, TReturn>( 25 | fn: UserFunction 26 | ): ((...args: T) => Promise) & { dispose: () => void }; 27 | 28 | export function threaded, TReturn>( 29 | config: Partial, 30 | fn: UserFunction 31 | ): ((...args: T) => Promise) & { dispose: () => void }; 32 | 33 | export function threaded, TReturn>( 34 | configOrFn: Partial | UserFunction, 35 | maybeFn?: UserFunction 36 | ): ((...args: T) => Promise) & { dispose: () => void } { 37 | const config: ThreadedConfig = { 38 | debug: false, 39 | maxThreads: 40 | typeof navigator !== "undefined" ? navigator.hardwareConcurrency : 4, 41 | }; 42 | let fn: UserFunction; 43 | 44 | if (typeof configOrFn === "function") { 45 | fn = configOrFn as UserFunction; 46 | } else { 47 | Object.assign(config, configOrFn); 48 | fn = maybeFn as UserFunction; 49 | } 50 | 51 | let context: Record = {}; 52 | const workerPool: Worker[] = []; 53 | const invocationQueue = new Map>(); 54 | 55 | workerPools.set(fn, workerPool); 56 | 57 | let invocationCount = 0; 58 | 59 | const init = (async () => { 60 | const fnStr = fn.toString(); 61 | 62 | const yieldList = await parseTopLevelYieldStatements(fnStr); 63 | 64 | // @ts-ignore - Call function without arguments 65 | const gen = fn(); 66 | 67 | for (const yieldItem of yieldList) { 68 | // @ts-ignore - Pass empty object to prevent TypeError when user has destructured import 69 | const result = await gen.next({}); 70 | 71 | if (yieldItem[$.Type] !== "variable") continue; 72 | 73 | context[yieldItem[$.Name]] = result.value; 74 | } 75 | 76 | for (const key in context) { 77 | // Initialize the ownership queue 78 | valueOwnershipQueue.set(context[key], []); 79 | } 80 | 81 | const workerCode = [ 82 | inlineWorker, 83 | `__internal.${$.UserFunction} = ${fnStr};`, 84 | ]; 85 | 86 | const serializedVariables = serialize(context); 87 | 88 | for (const [key, value] of Object.entries(serializedVariables)) { 89 | if (value[$.WasType] !== $.Function) continue; 90 | // globalthis. is necessary to prevent duplicate variable names when the function is named 91 | workerCode.unshift(`globalThis.${key} = ${value.value};`); 92 | 93 | delete serializedVariables[key]; 94 | } 95 | 96 | const workerCodeString = workerCode.join("\r\n"); 97 | 98 | for (let i = 0; i < config.maxThreads; i++) { 99 | const worker = new (await Worker)( 100 | encodeURI( 101 | "data:application/javascript;base64," + btoa(workerCodeString) 102 | ), 103 | { 104 | type: "module", 105 | } 106 | ); 107 | 108 | setupWorkerListeners( 109 | worker, 110 | context, 111 | valueOwnershipQueue, 112 | invocationQueue, 113 | workerPool, 114 | workerCodeString, 115 | i 116 | ); 117 | 118 | workerPool.push(worker); 119 | 120 | worker.postMessage({ 121 | [$.EventType]: $.Init, 122 | [$.EventValue]: { 123 | [$.ProcessId]: i, 124 | [$.YieldList]: yieldList, 125 | [$.Variables]: serializedVariables, 126 | [$.Code]: workerCodeString, 127 | [$.DebugEnabled]: config.debug, 128 | }, 129 | } satisfies MainEvent); 130 | } 131 | })(); 132 | 133 | const wrapper = async (...args: T) => { 134 | await init; 135 | 136 | const worker = workerPool[invocationCount % config.maxThreads]; 137 | 138 | const pwr = Promise.withResolvers(); 139 | invocationQueue.set(invocationCount, pwr); 140 | 141 | worker.postMessage({ 142 | [$.EventType]: $.Invocation, 143 | [$.EventValue]: { 144 | [$.InvocationId]: invocationCount++, 145 | [$.Args]: args, 146 | }, 147 | } satisfies MainEvent); 148 | 149 | return pwr.promise; 150 | }; 151 | 152 | wrapper.dispose = () => { 153 | for (const worker of workerPool) { 154 | worker.terminate(); 155 | } 156 | 157 | workerPools.delete(fn); 158 | invocationQueue.forEach((pwr) => pwr.reject("Disposed")); 159 | invocationQueue.clear(); 160 | }; 161 | 162 | return wrapper; 163 | } 164 | -------------------------------------------------------------------------------- /src/lib/colors.ts: -------------------------------------------------------------------------------- 1 | export const red = "\x1b[31m"; 2 | export const cyan = "\x1b[36m"; 3 | export const gray = "\x1b[90m"; 4 | export const reset = "\x1b[39m"; 5 | -------------------------------------------------------------------------------- /src/lib/getErrorPreview.ts: -------------------------------------------------------------------------------- 1 | const colorGray = "\x1b[90m"; 2 | const colorRed = "\x1b[31m"; 3 | const colorCyan = "\x1b[36m"; 4 | const colorReset = "\x1b[39m"; 5 | 6 | export function getErrorPreview(error: Error, code: string, pid: number) { 7 | const [message, ...serializedStackFrames] = error.stack!.split("\n"); 8 | 9 | // Check if error originates from inside the user function 10 | const stackFrame = serializedStackFrames.find((frame) => 11 | frame.includes("data:application/javascript;base64") 12 | ); 13 | 14 | if (!stackFrame) { 15 | return error.stack!; 16 | } 17 | 18 | // Split at the comma of data:application/javascript;base64, 19 | const [functionPart, tracePart] = stackFrame.split(","); 20 | 21 | const [encodedBodyPart, lineNumberStr, columnNumberStr] = 22 | tracePart.split(":"); 23 | 24 | const lineNumber = parseInt(lineNumberStr); 25 | const columnNumber = parseInt(columnNumberStr); 26 | 27 | const codeLines = code.split(/\r?\n/); 28 | 29 | const amountOfPreviousLines = Math.min(3, lineNumber - 1); 30 | const amountOfNextLines = 2; 31 | 32 | const previewLines = codeLines.slice( 33 | lineNumber - (amountOfPreviousLines + 1), 34 | lineNumber + amountOfNextLines 35 | ); 36 | 37 | const previousLineLength = 38 | codeLines[lineNumber - 1].trimEnd().length - columnNumber; 39 | 40 | previewLines.splice( 41 | amountOfPreviousLines + 1, 42 | 0, 43 | colorRed + 44 | " ".repeat(columnNumber - 1) + 45 | "^".repeat(previousLineLength) + 46 | " " + 47 | // "Error" + 48 | message + 49 | colorGray 50 | ); 51 | 52 | const index = serializedStackFrames.indexOf(stackFrame); 53 | serializedStackFrames[index] = 54 | ` at ${colorCyan}${colorReset}\n` + 55 | colorGray + 56 | " " + 57 | previewLines.join("\n ") + 58 | colorReset; 59 | 60 | // return message + "\n" + serializedStackFrames.slice(0, index + 1).join("\n"); 61 | return ( 62 | message.split(":").slice(1).join(":").trim() + 63 | "\n" + 64 | serializedStackFrames.slice(0, index + 1).join("\n") 65 | ); 66 | } 67 | -------------------------------------------------------------------------------- /src/lib/keys.ts: -------------------------------------------------------------------------------- 1 | export const Function = 2; 2 | export const Other = 3; 3 | export const Init = 4; 4 | export const Claim = 5; 5 | export const Unclaim = 6; 6 | export const Return = 7; 7 | export const ClaimAcceptance = 8; 8 | export const ClaimRejection = 9; 9 | export const Invocation = 12; 10 | export const Synchronization = 13; 11 | export const Error = 14; 12 | 13 | export const Variables = "a"; 14 | export const Args = "b"; 15 | export const EventType = "c"; 16 | export const EventValue = "d"; 17 | export const WasType = "e"; 18 | export const Name = "f"; 19 | export const YieldList = "g"; 20 | export const InvocationId = "h"; 21 | export const Value = "i"; 22 | export const ProcessId = "j"; 23 | export const DebugEnabled = "k"; 24 | export const Type = "l"; 25 | export const AbsolutePath = "m"; 26 | export const Code = "n"; 27 | export const Pid = "o"; 28 | export const UserFunction = "p"; 29 | export const Internal = "q"; 30 | export const ShareableNameMap = "r"; 31 | export const ValueClaimMap = "s"; 32 | export const ValueInUseCount = "t"; 33 | 34 | export declare type Function = typeof Function; 35 | export declare type Other = typeof Other; 36 | export declare type Init = typeof Init; 37 | export declare type Claim = typeof Claim; 38 | export declare type Unclaim = typeof Unclaim; 39 | export declare type Return = typeof Return; 40 | export declare type ClaimAcceptance = typeof ClaimAcceptance; 41 | export declare type ClaimRejection = typeof ClaimRejection; 42 | export declare type Invocation = typeof Invocation; 43 | export declare type Synchronization = typeof Synchronization; 44 | export declare type Error = typeof Error; 45 | 46 | export declare type Variables = typeof Variables; 47 | export declare type Args = typeof Args; 48 | export declare type EventType = typeof EventType; 49 | export declare type EventValue = typeof EventValue; 50 | export declare type WasType = typeof WasType; 51 | export declare type Name = typeof Name; 52 | export declare type YieldList = typeof YieldList; 53 | export declare type InvocationId = typeof InvocationId; 54 | export declare type Value = typeof Value; 55 | export declare type ProcessId = typeof ProcessId; 56 | export declare type DebugEnabled = typeof DebugEnabled; 57 | export declare type Type = typeof Type; 58 | export declare type AbsolutePath = typeof AbsolutePath; 59 | export declare type Code = typeof Code; 60 | export declare type Pid = typeof Pid; 61 | export declare type UserFunction = typeof UserFunction; 62 | export declare type Internal = typeof Internal; 63 | export declare type ShareableNameMap = typeof ShareableNameMap; 64 | export declare type ValueClaimMap = typeof ValueClaimMap; 65 | export declare type ValueInUseCount = typeof ValueInUseCount; 66 | -------------------------------------------------------------------------------- /src/lib/parseTopLevelYieldStatements.ts: -------------------------------------------------------------------------------- 1 | import * as $ from "./keys.ts"; 2 | import { YieldList } from "./types"; 3 | 4 | async function parseImport(name: string): Promise { 5 | const resolved = await import.meta.resolve(name); 6 | 7 | if ( 8 | resolved.startsWith("http://") || 9 | resolved.startsWith("https://") || 10 | resolved.startsWith("npm:") || 11 | resolved.startsWith("node:") 12 | ) 13 | return resolved; 14 | 15 | // Check if running in browser 16 | const isBrowser = typeof window !== "undefined"; 17 | 18 | if (isBrowser) { 19 | // If running in browser, return the resolved URL 20 | return resolved; 21 | } 22 | 23 | const { pathToFileURL } = await import("node:url"); 24 | 25 | return pathToFileURL(resolved).toString(); 26 | } 27 | 28 | export async function parseTopLevelYieldStatements( 29 | fnStr: string 30 | ): Promise { 31 | const bodyStart = fnStr.indexOf("{") + 1; 32 | const code = fnStr.slice(bodyStart, -1).trim(); 33 | const lines = code.split(/(?:\s*[;\r\n]+\s*)+/); 34 | 35 | const yieldList: YieldList = []; 36 | 37 | // let insideCommentBlock = false; 38 | 39 | for (const line of lines) { 40 | // Skip comments 41 | // if (line.startsWith("/*")) insideCommentBlock = true; 42 | // if (line.endsWith("*/") || line.startsWith("*/")) { 43 | // insideCommentBlock = false; 44 | // continue; 45 | // } 46 | // if (insideCommentBlock || line.startsWith("//")) continue; 47 | 48 | // If line is not a yield statement, stop parsing 49 | if (!line.includes("yield ")) continue; 50 | 51 | const yielded = line.split("yield ")[1]; 52 | 53 | if (/^["'`]/.test(yielded)) { 54 | const name = yielded.slice(1, -1); 55 | 56 | yieldList.push({ 57 | [$.Type]: "import", 58 | [$.Name]: name, 59 | [$.AbsolutePath]: await parseImport(name), 60 | }); 61 | } else { 62 | yieldList.push({ 63 | [$.Type]: "variable", 64 | [$.Name]: yielded, 65 | }); 66 | } 67 | } 68 | 69 | return yieldList; 70 | } 71 | -------------------------------------------------------------------------------- /src/lib/polyfills/ErrorEvent.ts: -------------------------------------------------------------------------------- 1 | import Event from "./Event.ts"; 2 | 3 | export default class ErrorEvent extends Event implements globalThis.ErrorEvent { 4 | colno: number = 0; 5 | error: any; 6 | filename: string = ""; 7 | lineno: number = 0; 8 | message: string = ""; 9 | 10 | constructor(init: ErrorEventInit) { 11 | super("error"); 12 | Object.assign(this, init); 13 | } 14 | } 15 | -------------------------------------------------------------------------------- /src/lib/polyfills/Event.ts: -------------------------------------------------------------------------------- 1 | import EventTarget from "./EventTarget"; 2 | 3 | export default class Event implements globalThis.Event { 4 | bubbles: boolean = false; 5 | cancelBubble: boolean = false; 6 | cancelable: boolean = false; 7 | composed: boolean = false; 8 | currentTarget: EventTarget | null = null; 9 | defaultPrevented: boolean = false; 10 | eventPhase: number = 0; 11 | isTrusted: boolean = false; 12 | returnValue: boolean = false; 13 | srcElement: EventTarget | null = null; 14 | target: EventTarget | null = null; 15 | timeStamp: number; 16 | type: string; 17 | composedPath(): EventTarget[]; 18 | composedPath(): EventTarget[] { 19 | throw new Error("Method not implemented."); 20 | } 21 | initEvent( 22 | type: string, 23 | bubbles?: boolean | undefined, 24 | cancelable?: boolean | undefined 25 | ): void; 26 | initEvent( 27 | type: string, 28 | bubbles?: boolean | undefined, 29 | cancelable?: boolean | undefined 30 | ): void; 31 | initEvent(type: unknown, bubbles?: unknown, cancelable?: unknown): void { 32 | throw new Error("Method not implemented."); 33 | } 34 | preventDefault(): void; 35 | preventDefault(): void { 36 | // throw new Error("Method not implemented."); 37 | } 38 | stopImmediatePropagation(): void; 39 | stopImmediatePropagation(): void { 40 | // throw new Error("Method not implemented."); 41 | } 42 | stopPropagation(): void; 43 | stopPropagation(): void { 44 | // throw new Error("Method not implemented."); 45 | } 46 | NONE: 0 = 0; 47 | CAPTURING_PHASE: 1 = 1; 48 | AT_TARGET: 2 = 2; 49 | BUBBLING_PHASE: 3 = 3; 50 | 51 | // Custom 52 | data: any; 53 | 54 | constructor(type: string, target: EventTarget | null = null) { 55 | this.type = type; 56 | this.timeStamp = Date.now(); 57 | } 58 | } 59 | -------------------------------------------------------------------------------- /src/lib/polyfills/EventTarget.ts: -------------------------------------------------------------------------------- 1 | import Event from "./Event.ts"; 2 | 3 | const EVENTS = Symbol.for("events"); 4 | 5 | export default class EventTarget implements globalThis.EventTarget { 6 | constructor() { 7 | Object.defineProperty(this, EVENTS, { 8 | value: new Map(), 9 | }); 10 | } 11 | addEventListener( 12 | type: string, 13 | callback: EventListenerOrEventListenerObject | null, 14 | options?: boolean | AddEventListenerOptions | undefined 15 | ): void; 16 | addEventListener(type: unknown, callback: unknown, options?: unknown): void { 17 | let events = this[EVENTS].get(type); 18 | if (!events) this[EVENTS].set(type, (events = [])); 19 | events.push(callback); 20 | } 21 | 22 | dispatchEvent(event: Event): boolean; 23 | dispatchEvent(event: unknown): boolean { 24 | event.target = event.currentTarget = this; 25 | if (this["on" + event.type]) { 26 | try { 27 | this["on" + event.type](event); 28 | } catch (err) { 29 | console.error(err); 30 | } 31 | } 32 | const list = this[EVENTS].get(event.type); 33 | if (list == null) return false; 34 | list.forEach((handler) => { 35 | try { 36 | handler.call(this, event); 37 | } catch (err) { 38 | console.error(err); 39 | } 40 | }); 41 | 42 | return false; 43 | } 44 | 45 | removeEventListener( 46 | type: string, 47 | callback: EventListenerOrEventListenerObject | null, 48 | options?: boolean | EventListenerOptions | undefined 49 | ): void; 50 | removeEventListener( 51 | type: unknown, 52 | callback: unknown, 53 | options?: unknown 54 | ): void { 55 | let events = this[EVENTS].get(type); 56 | if (events) { 57 | const index = events.indexOf(callback); 58 | if (index !== -1) events.splice(index, 1); 59 | } 60 | } 61 | } 62 | -------------------------------------------------------------------------------- /src/lib/polyfills/Promise.withResolvers.ts: -------------------------------------------------------------------------------- 1 | Promise.withResolvers ??= function withResolvers() { 2 | var a, 3 | b, 4 | c = new this(function (resolve, reject) { 5 | a = resolve; 6 | b = reject; 7 | }); 8 | return { resolve: a, reject: b, promise: c }; 9 | }; 10 | -------------------------------------------------------------------------------- /src/lib/polyfills/PromiseRejectionEvent.ts: -------------------------------------------------------------------------------- 1 | import Event from "./Event.ts"; 2 | 3 | export default class PromiseRejectionEvent 4 | extends Event 5 | implements globalThis.PromiseRejectionEvent 6 | { 7 | promise!: Promise; 8 | reason!: any; 9 | 10 | constructor(init: PromiseRejectionEventInit) { 11 | super("unhandledrejection"); 12 | Object.assign(this, init); 13 | } 14 | } 15 | -------------------------------------------------------------------------------- /src/lib/polyfills/Worker.ts: -------------------------------------------------------------------------------- 1 | import EventTarget from "./EventTarget.ts"; 2 | import Event from "./Event.ts"; 3 | 4 | globalThis.Worker ??= (async () => { 5 | const { default: threads } = await import("node:worker_threads"); 6 | const { URL, pathToFileURL, fileURLToPath } = await import("node:url"); 7 | 8 | const WORKER = Symbol.for("worker"); 9 | 10 | return class Worker extends EventTarget implements globalThis.Worker { 11 | constructor(url: string, options: WorkerOptions = {}) { 12 | super(); 13 | const { name, type } = options; 14 | url += ""; 15 | let mod; 16 | if (/^data:/.test(url)) { 17 | mod = url; 18 | } else { 19 | const baseUrl = pathToFileURL(process.cwd() + "/"); 20 | 21 | mod = fileURLToPath(new URL(url, baseUrl)); 22 | } 23 | 24 | const worker = new threads.Worker(new URL(import.meta.url), { 25 | workerData: { mod, name, type }, 26 | }); 27 | Object.defineProperty(this, WORKER, { 28 | value: worker, 29 | }); 30 | worker.on("message", (data) => { 31 | const event = new Event("message"); 32 | event.data = data; 33 | this.dispatchEvent(event); 34 | }); 35 | worker.on("error", (error) => { 36 | error.type = "error"; 37 | this.dispatchEvent(error); 38 | }); 39 | worker.on("exit", () => { 40 | this.dispatchEvent(new Event("close")); 41 | }); 42 | } 43 | 44 | onmessage: ((this: Worker, ev: MessageEvent) => any) | null = null; 45 | onmessageerror: ((this: Worker, ev: MessageEvent) => any) | null = 46 | null; 47 | postMessage(message: any, transfer: Transferable[]): void; 48 | postMessage( 49 | message: any, 50 | options?: StructuredSerializeOptions | undefined 51 | ): void; 52 | postMessage(message: unknown, options?: unknown): void { 53 | this[WORKER].postMessage(message, options); 54 | } 55 | terminate(): void; 56 | terminate(): void { 57 | this[WORKER].terminate(); 58 | } 59 | onerror: ((this: AbstractWorker, ev: ErrorEvent) => any) | null = null; 60 | }; 61 | })(); 62 | -------------------------------------------------------------------------------- /src/lib/polyfills/WorkerGlobalScope.ts: -------------------------------------------------------------------------------- 1 | import EventTarget from "./EventTarget.ts"; 2 | 3 | export default Worker instanceof Promise 4 | ? (async () => { 5 | const { default: threads } = await import("node:worker_threads"); 6 | 7 | return class WorkerGlobalScope extends EventTarget { 8 | postMessage(data, transferList) { 9 | threads.parentPort!.postMessage(data, transferList); 10 | } 11 | // Emulates https://developer.mozilla.org/en-US/docs/Web/API/DedicatedWorkerGlobalScope/close 12 | close() { 13 | process.exit(); 14 | } 15 | }; 16 | })() 17 | : undefined; 18 | -------------------------------------------------------------------------------- /src/lib/polyfills/import.meta.resolve.ts: -------------------------------------------------------------------------------- 1 | import.meta.resolve ??= async function (specifier: string, parentUrl?: string) { 2 | const { 3 | Module: { createRequire }, 4 | } = await import("node:module"); 5 | const require = createRequire(import.meta.url); 6 | 7 | return require.resolve(specifier, { 8 | ...(parentUrl ? { paths: [parentUrl] } : {}), 9 | }); 10 | }; 11 | -------------------------------------------------------------------------------- /src/lib/polyfills/web-worker.ts: -------------------------------------------------------------------------------- 1 | import "./Worker.ts"; 2 | import Event from "./Event.ts"; 3 | import ErrorEvent from "./ErrorEvent.ts"; 4 | import WorkerGlobalScopePromise from "./WorkerGlobalScope.ts"; 5 | import PromiseRejectionEvent from "./PromiseRejectionEvent.ts"; 6 | 7 | Worker instanceof Promise && 8 | (async () => { 9 | const { default: threads } = await import("node:worker_threads"); 10 | const { default: VM } = await import("node:vm"); 11 | 12 | const WorkerGlobalScope = await WorkerGlobalScopePromise!; 13 | 14 | return threads.isMainThread || workerThread(); 15 | 16 | async function workerThread() { 17 | let { mod, name, type } = threads.workerData; 18 | if (!mod) return await Worker; 19 | 20 | // turn global into a mock WorkerGlobalScope 21 | const self = (global.self = global); 22 | 23 | // enqueue messages to dispatch after modules are loaded 24 | let queue: Event[] | null = []; 25 | 26 | function flushQueue() { 27 | const buffered = queue; 28 | queue = null; 29 | buffered!.forEach((event) => { 30 | self.dispatchEvent(event); 31 | }); 32 | } 33 | 34 | threads.parentPort!.on("message", (data) => { 35 | const event = new Event("message"); 36 | event.data = data; 37 | if (queue == null) self.dispatchEvent(event); 38 | else queue.push(event); 39 | }); 40 | 41 | threads.parentPort!.on("error", (error) => { 42 | error.type = "Error"; 43 | self.dispatchEvent(new ErrorEvent({ error })); 44 | }); 45 | 46 | process.on("unhandledRejection", (reason, promise) => { 47 | self.dispatchEvent(new PromiseRejectionEvent({ reason, promise })); 48 | }); 49 | 50 | process.on("uncaughtException", (error, origin) => { 51 | self.dispatchEvent(new ErrorEvent({ error })); 52 | }); 53 | 54 | let proto = Object.getPrototypeOf(global); 55 | delete proto.constructor; 56 | Object.defineProperties(WorkerGlobalScope.prototype, proto); 57 | proto = Object.setPrototypeOf(global, new WorkerGlobalScope()); 58 | [ 59 | "postMessage", 60 | "addEventListener", 61 | "removeEventListener", 62 | "dispatchEvent", 63 | ].forEach((fn) => { 64 | proto[fn] = proto[fn].bind(global); 65 | }); 66 | global.name = name; 67 | 68 | const isDataUrl = /^data:/.test(mod); 69 | 70 | if (type === "module") { 71 | import(mod) 72 | .catch((err) => { 73 | if (isDataUrl && err.message === "Not supported") { 74 | console.warn( 75 | "Worker(): Importing data: URLs requires Node 12.10+. Falling back to classic worker." 76 | ); 77 | return evaluateDataUrl(mod, name); 78 | } 79 | console.error(err); 80 | }) 81 | .then(flushQueue); 82 | } else { 83 | try { 84 | if (isDataUrl) { 85 | evaluateDataUrl(mod, name); 86 | } else { 87 | require(mod); 88 | } 89 | } catch (err) { 90 | console.error(err); 91 | } 92 | Promise.resolve().then(flushQueue); 93 | } 94 | } 95 | 96 | function evaluateDataUrl(url: string, name: string) { 97 | const { data } = parseDataUrl(url); 98 | return VM.runInThisContext(data, { 99 | filename: "worker.<" + (name || "data:") + ">", 100 | }); 101 | } 102 | 103 | function parseDataUrl(url: string) { 104 | let [m, type, encoding, data] = 105 | url.match(/^data: *([^;,]*)(?: *; *([^,]*))? *,(.*)$/) || []; 106 | if (!m) throw Error("Invalid Data URL."); 107 | if (encoding) 108 | switch (encoding.toLowerCase()) { 109 | case "base64": 110 | data = Buffer.from(data, "base64").toString(); 111 | break; 112 | default: 113 | throw Error('Unknown Data URL encoding "' + encoding + '"'); 114 | } 115 | return { type, data }; 116 | } 117 | })(); 118 | -------------------------------------------------------------------------------- /src/lib/replaceContents.ts: -------------------------------------------------------------------------------- 1 | export function replaceContents( 2 | originalObject: T, 3 | newValue: T 4 | ) { 5 | if (Array.isArray(originalObject)) { 6 | // Clear the array and push new values 7 | originalObject.length = 0; 8 | (newValue as typeof originalObject).forEach((item) => 9 | originalObject.push(item) 10 | ); 11 | } else if (originalObject instanceof Map) { 12 | // Clear the map and set new key-value pairs 13 | originalObject.clear(); 14 | (newValue as typeof originalObject).forEach(([key, value]) => 15 | originalObject.set(key, value) 16 | ); 17 | } else if (originalObject instanceof Set) { 18 | // Clear the set and add new values 19 | originalObject.clear(); 20 | (newValue as typeof originalObject).forEach((item) => 21 | originalObject.add(item) 22 | ); 23 | } else if (typeof originalObject === "object" && originalObject !== null) { 24 | // Clear the object and assign new properties 25 | for (const key in originalObject) { 26 | delete originalObject[key]; 27 | } 28 | Object.assign(originalObject, newValue); 29 | } else { 30 | throw new Error("Unsupported object type"); 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /src/lib/serialize.ts: -------------------------------------------------------------------------------- 1 | import * as $ from "./keys.ts"; 2 | 3 | export const serialize = (variables: Record) => { 4 | const serializedVariables: Record = {}; 5 | 6 | for (const [key, value] of Object.entries(variables)) { 7 | if (typeof value === "function") { 8 | serializedVariables[key] = { 9 | [$.WasType]: $.Function, 10 | value: value.toString(), 11 | }; 12 | } else { 13 | serializedVariables[key] = value; 14 | } 15 | } 16 | 17 | return serializedVariables; 18 | }; 19 | 20 | export const deserialize = (variables: Record) => { 21 | const deserializedVariables: Record = {}; 22 | 23 | for (const [key, value] of Object.entries(variables)) { 24 | if (typeof value === "object" && $.WasType in value) { 25 | switch (value[$.WasType]) { 26 | default: 27 | deserializedVariables[key] = value; 28 | break; 29 | } 30 | } else { 31 | deserializedVariables[key] = value; 32 | } 33 | } 34 | 35 | return deserializedVariables; 36 | }; 37 | -------------------------------------------------------------------------------- /src/lib/setupWorkerListeners.ts: -------------------------------------------------------------------------------- 1 | import { 2 | ClaimAcceptanceEvent, 3 | InitEvent, 4 | MainEvent, 5 | ThreadEvent, 6 | } from "./types"; 7 | import { replaceContents } from "./replaceContents.ts"; 8 | import * as $ from "./keys.ts"; 9 | import { getErrorPreview } from "./getErrorPreview.ts"; 10 | import { red, reset } from "./colors.ts"; 11 | 12 | function announceOwnership(queue: Worker[], valueName: string, value: Object) { 13 | // Get first worker in queue 14 | const worker = queue[0]; 15 | 16 | worker.postMessage({ 17 | [$.EventType]: $.ClaimAcceptance, 18 | [$.EventValue]: { 19 | [$.Name]: valueName, 20 | [$.Value]: value, 21 | }, 22 | } satisfies ClaimAcceptanceEvent); 23 | } 24 | 25 | export function setupWorkerListeners( 26 | worker: Worker, 27 | context: Record, 28 | valueOwnershipQueue: WeakMap, 29 | invocationQueue: Map>, 30 | workerPool: Worker[], 31 | workerCodeString: string, 32 | pid: number 33 | ) { 34 | worker.onmessage = (e: MessageEvent) => { 35 | switch (e.data[$.EventType]) { 36 | case $.Return: 37 | const invocationId = e.data[$.EventValue][$.InvocationId]; 38 | const value = e.data[$.EventValue][$.Value]; 39 | 40 | const { resolve } = invocationQueue.get(invocationId)!; 41 | resolve(value); 42 | invocationQueue.delete(invocationId); 43 | break; 44 | case $.Claim: { 45 | const valueName = e.data[$.EventValue]; 46 | const value = context[valueName]; 47 | const queue = valueOwnershipQueue.get(value)!; 48 | queue.push(worker); 49 | 50 | if (queue.length === 1) { 51 | announceOwnership(queue, valueName, value); 52 | } 53 | break; 54 | } 55 | case $.Unclaim: { 56 | const data = e.data[$.EventValue]; 57 | const valueName = data[$.Name]; 58 | const value = context[valueName]; 59 | const ownershipQueue = valueOwnershipQueue.get(value)!; 60 | 61 | // Check if worker is first in queue 62 | if (ownershipQueue[0] !== worker) break; 63 | 64 | const newValue = data[$.Value]; 65 | 66 | // Update local value with new value 67 | replaceContents(value, newValue); 68 | 69 | // Synchronize all other workers with new value 70 | for (const otherWorker of workerPool) { 71 | if (otherWorker === worker) continue; 72 | worker.postMessage({ 73 | [$.EventType]: $.Synchronization, 74 | [$.EventValue]: { 75 | [$.Name]: valueName, 76 | [$.Value]: newValue, 77 | }, 78 | } satisfies MainEvent); 79 | } 80 | 81 | ownershipQueue.shift(); 82 | 83 | if (ownershipQueue.length > 0) { 84 | announceOwnership(ownershipQueue, valueName, value); 85 | } 86 | break; 87 | } 88 | case $.Error: { 89 | const error = e.data[$.EventValue]; 90 | error.message = getErrorPreview(error as Error, workerCodeString, pid); 91 | error.stack = ""; // Deno doesn't like custom stack traces, use message instead 92 | invocationQueue.forEach(({ reject }) => reject(error)); 93 | } 94 | } 95 | }; 96 | } 97 | -------------------------------------------------------------------------------- /src/lib/types.d.ts: -------------------------------------------------------------------------------- 1 | import * as $ from "./keys.ts"; 2 | 3 | // Either AsyncGenerator or Generator 4 | type CommonGenerator = 5 | | AsyncGenerator 6 | | Generator; 7 | 8 | type UserFunction = ( 9 | ...args: T 10 | ) => CommonGenerator; 11 | 12 | type ImportYield = { 13 | [$.Type]: "import"; 14 | [$.Name]: string; 15 | [$.AbsolutePath]: string; 16 | }; 17 | type VariableYield = { 18 | [$.Type]: "variable"; 19 | [$.Name]: string; 20 | }; 21 | 22 | type YieldList = (ImportYield | VariableYield)[]; 23 | 24 | interface ReturnEvent { 25 | [$.EventType]: $.Return; 26 | [$.EventValue]: { 27 | [$.InvocationId]: data[$.InvocationId]; 28 | [$.Value]: returnValue.value; 29 | }; 30 | } 31 | 32 | interface InitEvent { 33 | [$.EventType]: $.Init; 34 | [$.EventValue]: { 35 | [$.ProcessId]: number; 36 | [$.YieldList]: YieldList; 37 | [$.Variables]: Record; 38 | [$.Code]: string; 39 | [$.DebugEnabled]: boolean; 40 | }; 41 | } 42 | 43 | interface ClaimEvent { 44 | [$.EventType]: $.Claim; 45 | [$.EventValue]: string; 46 | } 47 | 48 | interface UnclaimEvent { 49 | [$.EventType]: $.Unclaim; 50 | [$.EventValue]: { 51 | [$.Name]: string; 52 | [$.Value]: any; 53 | }; 54 | } 55 | 56 | interface ClaimAcceptanceEvent { 57 | [$.EventType]: $.ClaimAcceptance; 58 | [$.EventValue]: { 59 | [$.Name]: valueName; 60 | [$.Value]: valueName; 61 | }; 62 | } 63 | 64 | interface InvocationEvent { 65 | [$.EventType]: $.Invocation; 66 | [$.EventValue]: { 67 | [$.InvocationId]: number; 68 | [$.Args]: any[]; 69 | }; 70 | } 71 | 72 | interface SynchronizationEvent { 73 | [$.EventType]: $.Synchronization; 74 | [$.EventValue]: { 75 | [$.Name]: string; 76 | [$.Value]: any; 77 | }; 78 | } 79 | 80 | interface ErrorEvent { 81 | [$.EventType]: $.Error; 82 | [$.EventValue]: any; 83 | } 84 | 85 | type MainEvent = 86 | | InitEvent 87 | | InvocationEvent 88 | | ClaimAcceptanceEvent 89 | | SynchronizationEvent; 90 | type ThreadEvent = ReturnEvent | ClaimEvent | UnclaimEvent | ErrorEvent; 91 | -------------------------------------------------------------------------------- /src/lib/worker.worker.ts: -------------------------------------------------------------------------------- 1 | import "./polyfills/Promise.withResolvers.ts"; 2 | import { deserialize } from "./serialize.ts"; 3 | import * as $ from "./keys.ts"; 4 | import { 5 | ClaimAcceptanceEvent, 6 | InitEvent, 7 | InvocationEvent, 8 | MainEvent, 9 | SynchronizationEvent, 10 | ThreadEvent, 11 | UserFunction, 12 | YieldList, 13 | } from "./types"; 14 | import { replaceContents } from "./replaceContents.ts"; 15 | import { getErrorPreview } from "./getErrorPreview.ts"; 16 | import { cyan, red, reset } from "./colors.ts"; 17 | 18 | declare global { 19 | var __internal: { 20 | [$.UserFunction]: UserFunction; 21 | [$.Code]: string; 22 | }; 23 | function $claim(value: Object): Promise; 24 | function $unclaim(value: Object): void; 25 | } 26 | 27 | // Wrap in self-invoking function to avoid polluting the global namespace 28 | // and avoid name collisions with the user defined function 29 | globalThis.__internal = (function () { 30 | const state = { 31 | [$.UserFunction]: function* () {} as UserFunction, 32 | [$.Code]: "", 33 | }; 34 | 35 | let pid = -1; 36 | 37 | // const originalLog = console.log; 38 | // console.log = (...args) => { 39 | // originalLog(`${cyan}[Thread_${pid}]${reset}`, ...args); 40 | // }; 41 | // const originalError = console.error; 42 | // console.error = (...args) => { 43 | // originalError(`${red}[Thread_${pid}]${reset}`, ...args); 44 | // }; 45 | 46 | globalThis.$claim = async function $claim(value: Object) { 47 | const valueName = shareableNameMap.get(value)!; 48 | 49 | valueInUseCount[valueName]++; 50 | 51 | // First check if the variable is already (being) claimed 52 | if (valueClaimMap.has(valueName)) { 53 | return valueClaimMap.get(valueName)!.promise; 54 | } 55 | 56 | valueClaimMap.set(valueName, Promise.withResolvers()); 57 | 58 | postMessage({ 59 | [$.EventType]: $.Claim, 60 | [$.EventValue]: valueName, 61 | } satisfies ThreadEvent); 62 | 63 | return valueClaimMap.get(valueName)!.promise; 64 | }; 65 | 66 | globalThis.$unclaim = function $unclaim(value: Object) { 67 | const valueName = shareableNameMap.get(value)!; 68 | 69 | if (--valueInUseCount[valueName] > 0) return; 70 | 71 | valueClaimMap.delete(valueName); 72 | postMessage({ 73 | [$.EventType]: $.Unclaim, 74 | [$.EventValue]: { 75 | [$.Name]: valueName, 76 | [$.Value]: value, 77 | }, 78 | } satisfies ThreadEvent); 79 | }; 80 | 81 | let yieldList: YieldList = []; 82 | 83 | const shareableNameMap = new WeakMap(); 84 | 85 | // ShareableValues that are currently (being) claimed 86 | const valueClaimMap = new Map>(); 87 | // ShareableValues that are currently in use by 88 | // one of the invokations of the user defined function 89 | const valueInUseCount: Record = {}; 90 | 91 | function handleClaimAcceptance(data: ClaimAcceptanceEvent[$.EventValue]) { 92 | const valueName = data[$.Name]; 93 | replaceContents(globalThis[valueName], data[$.Value]); 94 | 95 | valueClaimMap.get(valueName)!.resolve(); 96 | } 97 | 98 | async function handleInit(data: InitEvent[$.EventValue]) { 99 | pid = data[$.ProcessId]; 100 | yieldList = data[$.YieldList]; 101 | state[$.Code] = data[$.Code]; 102 | const variables = deserialize(data[$.Variables]); 103 | 104 | for (const key in variables) { 105 | const value = variables[key]; 106 | if (value instanceof Object) { 107 | shareableNameMap.set(value, key); 108 | valueInUseCount[key] = 0; 109 | } 110 | } 111 | 112 | Object.assign(globalThis, variables); 113 | } 114 | 115 | async function handleInvocation( 116 | data: InvocationEvent[$.EventValue] 117 | ): Promise { 118 | const gen = state[$.UserFunction](...data[$.Args]); 119 | 120 | let isDone = false; 121 | let returnValue = undefined; 122 | let isFirstImport = true; 123 | 124 | for (const yieldItem of yieldList) { 125 | if (yieldItem[$.Type] === "import") { 126 | const resolved = await import(yieldItem[$.AbsolutePath]); 127 | 128 | if (isFirstImport) { 129 | await gen.next(); 130 | isFirstImport = false; 131 | } 132 | 133 | const result = await gen.next(resolved); 134 | 135 | if (result.done) { 136 | isDone = true; 137 | returnValue = result.value; 138 | break; 139 | } 140 | } else { 141 | const result = await gen.next(); 142 | 143 | if (result.done) { 144 | isDone = true; 145 | returnValue = result.value; 146 | break; 147 | } 148 | } 149 | } 150 | 151 | if (!isDone) { 152 | const result = await gen.next(); 153 | returnValue = result.value; 154 | } 155 | 156 | postMessage({ 157 | [$.EventType]: $.Return, 158 | [$.EventValue]: { 159 | [$.InvocationId]: data[$.InvocationId], 160 | [$.Value]: returnValue, 161 | }, 162 | } satisfies ThreadEvent); 163 | } 164 | 165 | async function handleSynchronization( 166 | data: SynchronizationEvent[$.EventValue] 167 | ) { 168 | const valueName = data[$.Name]; 169 | replaceContents(globalThis[valueName], data[$.Value]); 170 | } 171 | 172 | // On unhandled promise rejection 173 | self.addEventListener("unhandledrejection", (event) => { 174 | event.preventDefault(); 175 | 176 | postMessage({ 177 | [$.EventType]: $.Error, 178 | [$.EventValue]: event.reason, 179 | } satisfies ThreadEvent); 180 | 181 | close(); 182 | }); 183 | 184 | // On uncaught exception 185 | self.addEventListener("error", (event) => { 186 | event.preventDefault(); 187 | 188 | postMessage({ 189 | [$.EventType]: $.Error, 190 | [$.EventValue]: event.error, 191 | } satisfies ThreadEvent); 192 | 193 | close(); 194 | }); 195 | 196 | globalThis.onmessage = async (e: MessageEvent) => { 197 | switch (e.data[$.EventType]) { 198 | case $.Init: 199 | handleInit(e.data[$.EventValue]); 200 | break; 201 | case $.Invocation: 202 | handleInvocation(e.data[$.EventValue]); 203 | break; 204 | case $.ClaimAcceptance: 205 | handleClaimAcceptance(e.data[$.EventValue]); 206 | break; 207 | case $.Synchronization: 208 | handleSynchronization(e.data[$.EventValue]); 209 | break; 210 | } 211 | }; 212 | 213 | return state; 214 | })(); 215 | -------------------------------------------------------------------------------- /src/test.ts: -------------------------------------------------------------------------------- 1 | import { threaded, $claim, $unclaim } from "./index.ts"; 2 | 3 | function add(a, b) { 4 | return a + b; 5 | } 6 | 7 | async function main() { 8 | const user = { 9 | name: "john", 10 | balance: 100, 11 | }; 12 | 13 | const addBalance = threaded(async function* (amount) { 14 | yield { user, add }; 15 | 16 | await $claim(user); 17 | 18 | // Thread now has ownership over user and is 19 | // guaranteed not to change by other threads 20 | user.balance = add(user.balance, amount); 21 | 22 | $unclaim(user); 23 | 24 | return user; 25 | }); 26 | 27 | await Promise.all([ 28 | addBalance(10), 29 | addBalance(10), 30 | addBalance(10), 31 | addBalance(10), 32 | addBalance(10), 33 | addBalance(10), 34 | addBalance(10), 35 | addBalance(10), 36 | addBalance(10), 37 | addBalance(10), 38 | ]); 39 | 40 | addBalance.dispose(); 41 | 42 | console.assert(user.balance === 200, "Balance should be 200"); 43 | 44 | console.log("Result in main:", user); 45 | } 46 | 47 | main(); 48 | -------------------------------------------------------------------------------- /test/edge-cases.test.js: -------------------------------------------------------------------------------- 1 | /** @type {import('../src/index.ts').threaded} */ 2 | const threaded = require("../dist/index.js").threaded; 3 | /** @type {import('../src/index.ts').$unclaim}*/ 4 | const $unclaim = require("../dist/index.js").$unclaim; 5 | /** @type {import('../src/index.ts').$claim}*/ 6 | const $claim = require("../dist/index.js").$claim; 7 | 8 | describe("Edge case tests", () => { 9 | test("No yield", async () => { 10 | const add = threaded(function* (a, b) { 11 | return a + b; 12 | }); 13 | 14 | expect(await add(5, 10)).toBe(15); 15 | 16 | add.dispose(); 17 | }); 18 | 19 | test("Unused dependencies", async () => { 20 | const user = { 21 | name: "john", 22 | }; 23 | 24 | const add = threaded(function* (a, b) { 25 | yield user; 26 | return a + b; 27 | }); 28 | 29 | expect(await add(5, 10)).toBe(15); 30 | 31 | add.dispose(); 32 | }); 33 | 34 | test("Empty function", async () => { 35 | const add = threaded(function* () {}); 36 | 37 | expect(await add()).toBe(undefined); 38 | 39 | add.dispose(); 40 | }); 41 | 42 | test("No params defined but still invoking with params", async () => { 43 | const add = threaded(function* () { 44 | return true; 45 | }); 46 | 47 | expect(await add(1, 2, 3)).toBe(true); 48 | 49 | add.dispose(); 50 | }); 51 | 52 | test("Params defined but not invoking with params", async () => { 53 | const add = threaded(function* (a, b) { 54 | return true; 55 | }); 56 | 57 | expect(await add()).toBe(true); 58 | 59 | add.dispose(); 60 | }); 61 | 62 | test("No return", async () => { 63 | const add = threaded(function* (a, b) { 64 | a + b; 65 | }); 66 | 67 | expect(await add(1, 2)).toBe(undefined); 68 | 69 | add.dispose(); 70 | }); 71 | 72 | test("No return but yielding", async () => { 73 | const user = { 74 | name: "john", 75 | }; 76 | 77 | const add = threaded(function* () { 78 | yield user; 79 | }); 80 | 81 | expect(await add()).toBe(undefined); 82 | 83 | add.dispose(); 84 | }); 85 | }); 86 | -------------------------------------------------------------------------------- /test/examples.test.js: -------------------------------------------------------------------------------- 1 | /** @type {import('../src/index.ts').threaded} */ 2 | const threaded = require("../dist/index.js").threaded; 3 | /** @type {import('../src/index.ts').$unclaim}*/ 4 | const $unclaim = require("../dist/index.js").$unclaim; 5 | /** @type {import('../src/index.ts').$claim}*/ 6 | const $claim = require("../dist/index.js").$claim; 7 | 8 | describe("Example tests", () => { 9 | test("Minimal example", async () => { 10 | const add = threaded(function* (a, b) { 11 | return a + b; 12 | }); 13 | 14 | expect(await add(5, 10)).toBe(15); 15 | 16 | add.dispose(); 17 | }); 18 | 19 | test("Example with shared state", async () => { 20 | const user = { 21 | name: "john", 22 | balance: 0, 23 | }; 24 | 25 | const add = threaded(async function* (amount) { 26 | yield user; // Specify dependencies 27 | 28 | await $claim(user); // Wait for write lock 29 | 30 | user.balance += amount; 31 | 32 | $unclaim(user); // Release write lock 33 | }); 34 | 35 | await Promise.all([add(5), add(10)]); 36 | 37 | expect(user.balance).toBe(15); 38 | 39 | add.dispose(); 40 | }); 41 | 42 | test("Example with external functions", async () => { 43 | // Some external function 44 | function add(a, b) { 45 | return a + b; 46 | } 47 | 48 | const user = { 49 | name: "john", 50 | balance: 0, 51 | }; 52 | 53 | const addBalance = threaded(async function* (amount) { 54 | yield user; 55 | yield add; 56 | 57 | await $claim(user); 58 | 59 | user.balance = add(user.balance, amount); 60 | 61 | $unclaim(user); 62 | }); 63 | 64 | await Promise.all([addBalance(5), addBalance(10)]); 65 | 66 | expect(user.balance).toBe(15); 67 | 68 | addBalance.dispose(); 69 | }); 70 | }); 71 | -------------------------------------------------------------------------------- /test/import.test.js: -------------------------------------------------------------------------------- 1 | /** @type {import('../src/index.ts').threaded} */ 2 | const threaded = require("../dist/index.js").threaded; 3 | /** @type {import('../src/index.ts').$unclaim}*/ 4 | const $unclaim = require("../dist/index.js").$unclaim; 5 | /** @type {import('../src/index.ts').$claim}*/ 6 | const $claim = require("../dist/index.js").$claim; 7 | 8 | const users = ["john", "jane", "joe"]; 9 | const accounts = [{ user: "john" }, { user: "jane" }, { user: "joe" }]; 10 | 11 | describe("Import tests", () => { 12 | test("Single import", async () => { 13 | const fn = threaded(function* () { 14 | const { v4 } = yield "uuid"; 15 | return v4(); 16 | }); 17 | 18 | const result = await fn(); 19 | 20 | expect(result).toHaveLength(36); 21 | 22 | fn.dispose(); 23 | }); 24 | 25 | test("Multiple import", async () => { 26 | const fn = threaded(function* () { 27 | const { v4 } = yield "uuid"; 28 | const { v1 } = yield "uuid"; 29 | return { 30 | v4: v4(), 31 | v1: v1(), 32 | }; 33 | }); 34 | 35 | const result = await fn(); 36 | 37 | expect(result).toHaveProperty("v4"); 38 | expect(result).toHaveProperty("v1"); 39 | expect(result.v4).toHaveLength(36); 40 | expect(result.v1).toHaveLength(36); 41 | 42 | fn.dispose(); 43 | }); 44 | 45 | test("Multiple import with single variable first", async () => { 46 | const fn = threaded(function* () { 47 | yield users; 48 | const { v4 } = yield "uuid"; 49 | const { v1 } = yield "uuid"; 50 | return { 51 | users, 52 | v4: v4(), 53 | v1: v1(), 54 | }; 55 | }); 56 | 57 | const result = await fn(); 58 | 59 | expect(result.users).toHaveLength(3); 60 | expect(result).toHaveProperty("v4"); 61 | expect(result).toHaveProperty("v1"); 62 | expect(result.v4).toHaveLength(36); 63 | expect(result.v1).toHaveLength(36); 64 | 65 | fn.dispose(); 66 | }); 67 | 68 | test("Multiple import with single variable in the middle", async () => { 69 | const fn = threaded(function* () { 70 | const { v4 } = yield "uuid"; 71 | yield users; 72 | const { v1 } = yield "uuid"; 73 | return { 74 | users, 75 | v4: v4(), 76 | v1: v1(), 77 | }; 78 | }); 79 | 80 | const result = await fn(); 81 | 82 | expect(result.users).toHaveLength(3); 83 | expect(result).toHaveProperty("v4"); 84 | expect(result).toHaveProperty("v1"); 85 | expect(result.v4).toHaveLength(36); 86 | expect(result.v1).toHaveLength(36); 87 | 88 | fn.dispose(); 89 | }); 90 | 91 | test("Multiple import with single variable last", async () => { 92 | const fn = threaded(function* () { 93 | const { v4 } = yield "uuid"; 94 | const { v1 } = yield "uuid"; 95 | yield users; 96 | return { 97 | users, 98 | v4: v4(), 99 | v1: v1(), 100 | }; 101 | }); 102 | 103 | const result = await fn(); 104 | 105 | expect(result.users).toHaveLength(3); 106 | expect(result).toHaveProperty("v4"); 107 | expect(result).toHaveProperty("v1"); 108 | expect(result.v4).toHaveLength(36); 109 | expect(result.v1).toHaveLength(36); 110 | 111 | fn.dispose(); 112 | }); 113 | 114 | test("Multiple import with multiple variables first", async () => { 115 | const fn = threaded(function* () { 116 | yield users; 117 | yield accounts; 118 | const { v4 } = yield "uuid"; 119 | const { v1 } = yield "uuid"; 120 | return { 121 | users, 122 | accounts, 123 | v4: v4(), 124 | v1: v1(), 125 | }; 126 | }); 127 | 128 | const result = await fn(); 129 | 130 | expect(result.users).toHaveLength(3); 131 | expect(result.accounts).toHaveLength(3); 132 | expect(result).toHaveProperty("v4"); 133 | expect(result).toHaveProperty("v1"); 134 | expect(result.v4).toHaveLength(36); 135 | expect(result.v1).toHaveLength(36); 136 | 137 | fn.dispose(); 138 | }); 139 | 140 | test("Multiple import with multiple variables in the middle", async () => { 141 | const fn = threaded(function* () { 142 | const { v4 } = yield "uuid"; 143 | yield users; 144 | yield accounts; 145 | const { v1 } = yield "uuid"; 146 | return { 147 | users, 148 | accounts, 149 | v4: v4(), 150 | v1: v1(), 151 | }; 152 | }); 153 | 154 | const result = await fn(); 155 | 156 | expect(result.users).toHaveLength(3); 157 | expect(result.accounts).toHaveLength(3); 158 | expect(result).toHaveProperty("v4"); 159 | expect(result).toHaveProperty("v1"); 160 | expect(result.v4).toHaveLength(36); 161 | expect(result.v1).toHaveLength(36); 162 | 163 | fn.dispose(); 164 | }); 165 | 166 | test("Multiple import with multiple variables last", async () => { 167 | const fn = threaded(function* () { 168 | const { v4 } = yield "uuid"; 169 | const { v1 } = yield "uuid"; 170 | yield users; 171 | yield accounts; 172 | return { 173 | users, 174 | accounts, 175 | v4: v4(), 176 | v1: v1(), 177 | }; 178 | }); 179 | 180 | const result = await fn(); 181 | 182 | expect(result.users).toHaveLength(3); 183 | expect(result.accounts).toHaveLength(3); 184 | expect(result).toHaveProperty("v4"); 185 | expect(result).toHaveProperty("v1"); 186 | expect(result.v4).toHaveLength(36); 187 | expect(result.v1).toHaveLength(36); 188 | 189 | fn.dispose(); 190 | }); 191 | 192 | test("Multiple import with multiple variables spread out variant 1", async () => { 193 | const fn = threaded(function* () { 194 | const { v4 } = yield "uuid"; 195 | yield users; 196 | const { v1 } = yield "uuid"; 197 | yield accounts; 198 | return { 199 | users, 200 | accounts, 201 | v4: v4(), 202 | v1: v1(), 203 | }; 204 | }); 205 | 206 | const result = await fn(); 207 | 208 | expect(result.users).toHaveLength(3); 209 | expect(result.accounts).toHaveLength(3); 210 | expect(result).toHaveProperty("v4"); 211 | expect(result).toHaveProperty("v1"); 212 | expect(result.v4).toHaveLength(36); 213 | expect(result.v1).toHaveLength(36); 214 | 215 | fn.dispose(); 216 | }); 217 | 218 | test("Multiple import with multiple variables spread out variant 2", async () => { 219 | const fn = threaded(function* () { 220 | yield users; 221 | const { v4 } = yield "uuid"; 222 | yield accounts; 223 | const { v1 } = yield "uuid"; 224 | return { 225 | users, 226 | accounts, 227 | v4: v4(), 228 | v1: v1(), 229 | }; 230 | }); 231 | 232 | const result = await fn(); 233 | 234 | expect(result.users).toHaveLength(3); 235 | expect(result.accounts).toHaveLength(3); 236 | expect(result).toHaveProperty("v4"); 237 | expect(result).toHaveProperty("v1"); 238 | expect(result.v4).toHaveLength(36); 239 | expect(result.v1).toHaveLength(36); 240 | 241 | fn.dispose(); 242 | }); 243 | }); 244 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | /* Visit https://aka.ms/tsconfig to read more about this file */ 4 | 5 | /* Projects */ 6 | // "incremental": true, /* Save .tsbuildinfo files to allow for incremental compilation of projects. */ 7 | // "composite": true, /* Enable constraints that allow a TypeScript project to be used with project references. */ 8 | // "tsBuildInfoFile": "./.tsbuildinfo", /* Specify the path to .tsbuildinfo incremental compilation file. */ 9 | // "disableSourceOfProjectReferenceRedirect": true, /* Disable preferring source files instead of declaration files when referencing composite projects. */ 10 | // "disableSolutionSearching": true, /* Opt a project out of multi-project reference checking when editing. */ 11 | // "disableReferencedProjectLoad": true, /* Reduce the number of projects loaded automatically by TypeScript. */ 12 | 13 | /* Language and Environment */ 14 | "target": "ES2017", /* Set the JavaScript language version for emitted JavaScript and include compatible library declarations. */ 15 | "lib": ["ESNext", "DOM", "WebWorker"], /* Specify a set of bundled library declaration files that describe the target runtime environment. */ 16 | // "jsx": "preserve", /* Specify what JSX code is generated. */ 17 | // "experimentalDecorators": true, /* Enable experimental support for legacy experimental decorators. */ 18 | // "emitDecoratorMetadata": true, /* Emit design-type metadata for decorated declarations in source files. */ 19 | // "jsxFactory": "", /* Specify the JSX factory function used when targeting React JSX emit, e.g. 'React.createElement' or 'h'. */ 20 | // "jsxFragmentFactory": "", /* Specify the JSX Fragment reference used for fragments when targeting React JSX emit e.g. 'React.Fragment' or 'Fragment'. */ 21 | // "jsxImportSource": "", /* Specify module specifier used to import the JSX factory functions when using 'jsx: react-jsx*'. */ 22 | // "reactNamespace": "", /* Specify the object invoked for 'createElement'. This only applies when targeting 'react' JSX emit. */ 23 | // "noLib": true, /* Disable including any library files, including the default lib.d.ts. */ 24 | // "useDefineForClassFields": true, /* Emit ECMAScript-standard-compliant class fields. */ 25 | // "moduleDetection": "auto", /* Control what method is used to detect module-format JS files. */ 26 | 27 | /* Modules */ 28 | "module": "es2022", /* Specify what module code is generated. */ 29 | // "rootDir": "./", /* Specify the root folder within your source files. */ 30 | "moduleResolution": "node", /* Specify how TypeScript looks up a file from a given module specifier. */ 31 | // "baseUrl": "./", /* Specify the base directory to resolve non-relative module names. */ 32 | // "paths": {}, /* Specify a set of entries that re-map imports to additional lookup locations. */ 33 | // "rootDirs": [], /* Allow multiple folders to be treated as one when resolving modules. */ 34 | // "typeRoots": [], /* Specify multiple folders that act like './node_modules/@types'. */ 35 | "types": ["node"], /* Specify type package names to be included without being referenced in a source file. */ 36 | // "allowUmdGlobalAccess": true, /* Allow accessing UMD globals from modules. */ 37 | // "moduleSuffixes": [], /* List of file name suffixes to search when resolving a module. */ 38 | "allowImportingTsExtensions": true, /* Allow imports to include TypeScript file extensions. Requires '--moduleResolution bundler' and either '--noEmit' or '--emitDeclarationOnly' to be set. */ 39 | // "resolvePackageJsonExports": true, /* Use the package.json 'exports' field when resolving package imports. */ 40 | // "resolvePackageJsonImports": true, /* Use the package.json 'imports' field when resolving imports. */ 41 | // "customConditions": [], /* Conditions to set in addition to the resolver-specific defaults when resolving imports. */ 42 | // "resolveJsonModule": true, /* Enable importing .json files. */ 43 | // "allowArbitraryExtensions": true, /* Enable importing files with any extension, provided a declaration file is present. */ 44 | // "noResolve": true, /* Disallow 'import's, 'require's or ''s from expanding the number of files TypeScript should add to a project. */ 45 | 46 | /* JavaScript Support */ 47 | // "allowJs": true, /* Allow JavaScript files to be a part of your program. Use the 'checkJS' option to get errors from these files. */ 48 | // "checkJs": true, /* Enable error reporting in type-checked JavaScript files. */ 49 | // "maxNodeModuleJsDepth": 1, /* Specify the maximum folder depth used for checking JavaScript files from 'node_modules'. Only applicable with 'allowJs'. */ 50 | 51 | /* Emit */ 52 | "declaration": true, /* Generate .d.ts files from TypeScript and JavaScript files in your project. */ 53 | // "declarationMap": true, /* Create sourcemaps for d.ts files. */ 54 | "emitDeclarationOnly": true, /* Only output d.ts files and not JavaScript files. */ 55 | // "sourceMap": true, /* Create source map files for emitted JavaScript files. */ 56 | // "inlineSourceMap": true, /* Include sourcemap files inside the emitted JavaScript. */ 57 | // "outFile": "./", /* Specify a file that bundles all outputs into one JavaScript file. If 'declaration' is true, also designates a file that bundles all .d.ts output. */ 58 | // "outDir": "./", /* Specify an output folder for all emitted files. */ 59 | // "removeComments": true, /* Disable emitting comments. */ 60 | // "noEmit": true, /* Disable emitting files from a compilation. */ 61 | // "importHelpers": true, /* Allow importing helper functions from tslib once per project, instead of including them per-file. */ 62 | // "importsNotUsedAsValues": "remove", /* Specify emit/checking behavior for imports that are only used for types. */ 63 | // "downlevelIteration": true, /* Emit more compliant, but verbose and less performant JavaScript for iteration. */ 64 | // "sourceRoot": "", /* Specify the root path for debuggers to find the reference source code. */ 65 | // "mapRoot": "", /* Specify the location where debugger should locate map files instead of generated locations. */ 66 | // "inlineSources": true, /* Include source code in the sourcemaps inside the emitted JavaScript. */ 67 | // "emitBOM": true, /* Emit a UTF-8 Byte Order Mark (BOM) in the beginning of output files. */ 68 | // "newLine": "crlf", /* Set the newline character for emitting files. */ 69 | // "stripInternal": true, /* Disable emitting declarations that have '@internal' in their JSDoc comments. */ 70 | // "noEmitHelpers": true, /* Disable generating custom helper functions like '__extends' in compiled output. */ 71 | // "noEmitOnError": true, /* Disable emitting files if any type checking errors are reported. */ 72 | // "preserveConstEnums": true, /* Disable erasing 'const enum' declarations in generated code. */ 73 | "declarationDir": "dist", /* Specify the output directory for generated declaration files. */ 74 | // "preserveValueImports": true, /* Preserve unused imported values in the JavaScript output that would otherwise be removed. */ 75 | 76 | /* Interop Constraints */ 77 | // "isolatedModules": true, /* Ensure that each file can be safely transpiled without relying on other imports. */ 78 | // "verbatimModuleSyntax": true, /* Do not transform or elide any imports or exports not marked as type-only, ensuring they are written in the output file's format based on the 'module' setting. */ 79 | // "allowSyntheticDefaultImports": true, /* Allow 'import x from y' when a module doesn't have a default export. */ 80 | "esModuleInterop": true, /* Emit additional JavaScript to ease support for importing CommonJS modules. This enables 'allowSyntheticDefaultImports' for type compatibility. */ 81 | // "preserveSymlinks": true, /* Disable resolving symlinks to their realpath. This correlates to the same flag in node. */ 82 | "forceConsistentCasingInFileNames": true, /* Ensure that casing is correct in imports. */ 83 | 84 | /* Type Checking */ 85 | "strict": true, /* Enable all strict type-checking options. */ 86 | // "noImplicitAny": true, /* Enable error reporting for expressions and declarations with an implied 'any' type. */ 87 | // "strictNullChecks": true, /* When type checking, take into account 'null' and 'undefined'. */ 88 | // "strictFunctionTypes": true, /* When assigning functions, check to ensure parameters and the return values are subtype-compatible. */ 89 | // "strictBindCallApply": true, /* Check that the arguments for 'bind', 'call', and 'apply' methods match the original function. */ 90 | // "strictPropertyInitialization": true, /* Check for class properties that are declared but not set in the constructor. */ 91 | // "noImplicitThis": true, /* Enable error reporting when 'this' is given the type 'any'. */ 92 | // "useUnknownInCatchVariables": true, /* Default catch clause variables as 'unknown' instead of 'any'. */ 93 | // "alwaysStrict": true, /* Ensure 'use strict' is always emitted. */ 94 | // "noUnusedLocals": true, /* Enable error reporting when local variables aren't read. */ 95 | // "noUnusedParameters": true, /* Raise an error when a function parameter isn't read. */ 96 | // "exactOptionalPropertyTypes": true, /* Interpret optional property types as written, rather than adding 'undefined'. */ 97 | // "noImplicitReturns": true, /* Enable error reporting for codepaths that do not explicitly return in a function. */ 98 | // "noFallthroughCasesInSwitch": true, /* Enable error reporting for fallthrough cases in switch statements. */ 99 | // "noUncheckedIndexedAccess": true, /* Add 'undefined' to a type when accessed using an index. */ 100 | // "noImplicitOverride": true, /* Ensure overriding members in derived classes are marked with an override modifier. */ 101 | // "noPropertyAccessFromIndexSignature": true, /* Enforces using indexed accessors for keys declared using an indexed type. */ 102 | // "allowUnusedLabels": true, /* Disable error reporting for unused labels. */ 103 | // "allowUnreachableCode": true, /* Disable error reporting for unreachable code. */ 104 | 105 | /* Completeness */ 106 | // "skipDefaultLibCheck": true, /* Skip type checking .d.ts files that are included with TypeScript. */ 107 | "skipLibCheck": true, /* Skip type checking all .d.ts files. */ 108 | "outDir": "./dist" 109 | }, 110 | "include": [ 111 | "src" 112 | ] 113 | } 114 | --------------------------------------------------------------------------------