├── .github ├── dependabot.yml └── workflows │ └── nodejs.yml ├── .gitignore ├── .npmignore ├── .taprc ├── CHANGELOG.md ├── CODE_OF_CONDUCT.md ├── CONTRIBUTING ├── LICENSE ├── README.md ├── binding.gyp ├── examples └── simple │ ├── extended.js │ ├── index.js │ └── index.mjs ├── lib ├── binding.d.ts └── index.ts ├── package.json ├── prebuilds ├── darwin-arm64 │ └── node.napi.node ├── darwin-x64 │ └── node.napi.node ├── linux-x64 │ └── node.napi.node └── win32-x64 │ └── node.napi.node ├── src ├── locks.cc └── locks.h ├── test ├── locks.ts └── tsconfig.json ├── tsconfig.json └── tsup.config.ts /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | updates: 3 | - package-ecosystem: "github-actions" 4 | directory: "/" 5 | schedule: 6 | interval: "monthly" 7 | open-pull-requests-limit: 10 8 | 9 | - package-ecosystem: "npm" 10 | directory: "/" 11 | schedule: 12 | interval: "weekly" 13 | open-pull-requests-limit: 10 -------------------------------------------------------------------------------- /.github/workflows/nodejs.yml: -------------------------------------------------------------------------------- 1 | on: 2 | push: 3 | branches: 4 | - current 5 | - next 6 | - 'v*' 7 | paths-ignore: 8 | - 'docs/**' 9 | - '*.md' 10 | pull_request: 11 | paths-ignore: 12 | - 'docs/**' 13 | - '*.md' 14 | 15 | name: CI 16 | 17 | jobs: 18 | lint: 19 | name: Lint 20 | runs-on: ubuntu-latest 21 | steps: 22 | - uses: actions/checkout@v4 23 | with: 24 | persist-credentials: false 25 | - name: Install Node.js 26 | uses: actions/setup-node@v4 27 | with: 28 | node-version: v22.x 29 | cache: 'npm' 30 | cache-dependency-path: package.json 31 | 32 | - name: Install dependencies 33 | run: npm install 34 | 35 | - name: Check linting 36 | run: npm run lint 37 | 38 | test: 39 | name: Test 40 | strategy: 41 | fail-fast: false 42 | matrix: 43 | os: [ubuntu-latest, macos-latest, windows-latest] 44 | node-version: [18.x, 20.x, 22.x] 45 | runs-on: ${{matrix.os}} 46 | steps: 47 | - uses: actions/checkout@v4 48 | with: 49 | persist-credentials: false 50 | 51 | - name: Use Node.js ${{ matrix.node-version }} 52 | id: node_setup 53 | uses: actions/setup-node@v4 54 | with: 55 | node-version: ${{ matrix.node-version }} 56 | cache: 'npm' 57 | cache-dependency-path: package.json 58 | 59 | - name: Install Dependencies 60 | run: npm install 61 | 62 | - name: Test 63 | env: 64 | NODE_VERSION: ${{ steps.node_setup.outputs.node-version }} 65 | run: npm run test:ci 66 | 67 | - name: Upload artifacts 68 | uses: actions/upload-artifact@v4 69 | with: 70 | name: ${{ github.run_id }}-${{ matrix.os }}-${{ steps.node_setup.outputs.node-version }}-${{ github.sha }} 71 | retention-days: 5 72 | path: ./prebuilds 73 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .nyc_output 2 | .vscode 3 | node_modules 4 | package-lock.json 5 | dist 6 | coverage 7 | build 8 | -------------------------------------------------------------------------------- /.npmignore: -------------------------------------------------------------------------------- 1 | .github 2 | .nyc_output 3 | package-lock.json 4 | coverage 5 | examples 6 | -------------------------------------------------------------------------------- /.taprc: -------------------------------------------------------------------------------- 1 | check-coverage: true 2 | color: true 3 | coverage: true 4 | coverage-report: 5 | - html 6 | - text 7 | jobs: 2 8 | no-browser: true 9 | test-env: TS_NODE_PROJECT=test/tsconfig.json 10 | test-ignore: $. 11 | test-regex: ((\/|^)(tests?|__tests?__)\/.*|\.(tests?|spec)|^\/?tests?)\.([mc]js|[jt]sx?)$ 12 | timeout: 60 13 | ts: true 14 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Changelog 2 | 3 | All notable changes to this project will be documented in this file. See [standard-version](https://github.com/conventional-changelog/standard-version) for commit guidelines. 4 | 5 | ### [3.1.1](https://github.com/piscinajs/piscina-locks/compare/v3.1.0...v3.1.1) (2024-12-31) 6 | 7 | 8 | ### Bug Fixes 9 | 10 | * correct bad merge commit causing invalid package.json exports ([#72](https://github.com/piscinajs/piscina-locks/issues/72)) ([dc50b94](https://github.com/piscinajs/piscina-locks/commit/dc50b94f562e333eb0a61471d8de0222e869b125)) 11 | 12 | ## [3.1.0](https://github.com/piscinajs/piscina-locks/compare/v3.0.0...v3.1.0) (2024-12-22) 13 | 14 | 15 | ### Features 16 | 17 | * add darwin-arm64 ([356d8d8](https://github.com/piscinajs/piscina-locks/commit/356d8d888544b579cd124d1501120d3933822006)) 18 | 19 | ## [3.0.0](https://github.com/piscinajs/piscina-locks/compare/v2.0.2...v3.0.0) (2023-12-13) 20 | 21 | 22 | ### ⚠ BREAKING CHANGES 23 | 24 | * namespace piscina-locks (#4) 25 | 26 | ### Features 27 | 28 | * namespace piscina-locks ([#4](https://github.com/piscinajs/piscina-locks/issues/4)) ([42c6173](https://github.com/piscinajs/piscina-locks/commit/42c61739920e2e73edbddbbdd844c3e4d49ab93d)) 29 | -------------------------------------------------------------------------------- /CODE_OF_CONDUCT.md: -------------------------------------------------------------------------------- 1 | 2 | # Contributor Covenant Code of Conduct 3 | 4 | ## Our Pledge 5 | 6 | We as members, contributors, and leaders pledge to make participation in our 7 | community a harassment-free experience for everyone, regardless of age, body 8 | size, visible or invisible disability, ethnicity, sex characteristics, gender 9 | identity and expression, level of experience, education, socio-economic status, 10 | nationality, personal appearance, race, religion, or sexual identity 11 | and orientation. 12 | 13 | We pledge to act and interact in ways that contribute to an open, welcoming, 14 | diverse, inclusive, and healthy community. 15 | 16 | ## Our Standards 17 | 18 | Examples of behavior that contributes to a positive environment for our 19 | community include: 20 | 21 | * Demonstrating empathy and kindness toward other people 22 | * Being respectful of differing opinions, viewpoints, and experiences 23 | * Giving and gracefully accepting constructive feedback 24 | * Accepting responsibility and apologizing to those affected by our mistakes, 25 | and learning from the experience 26 | * Focusing on what is best not just for us as individuals, but for the 27 | overall community 28 | 29 | Examples of unacceptable behavior include: 30 | 31 | * The use of sexualized language or imagery, and sexual attention or 32 | advances of any kind 33 | * Trolling, insulting or derogatory comments, and personal or political attacks 34 | * Public or private harassment 35 | * Publishing others' private information, such as a physical or email 36 | address, without their explicit permission 37 | * Other conduct which could reasonably be considered inappropriate in a 38 | professional setting 39 | 40 | ## Enforcement Responsibilities 41 | 42 | Community leaders are responsible for clarifying and enforcing our standards of 43 | acceptable behavior and will take appropriate and fair corrective action in 44 | response to any behavior that they deem inappropriate, threatening, offensive, 45 | or harmful. 46 | 47 | Community leaders have the right and responsibility to remove, edit, or reject 48 | comments, commits, code, wiki edits, issues, and other contributions that are 49 | not aligned to this Code of Conduct, and will communicate reasons for moderation 50 | decisions when appropriate. 51 | 52 | ## Scope 53 | 54 | This Code of Conduct applies within all community spaces, and also applies when 55 | an individual is officially representing the community in public spaces. 56 | Examples of representing our community include using an official e-mail address, 57 | posting via an official social media account, or acting as an appointed 58 | representative at an online or offline event. 59 | 60 | ## Enforcement 61 | 62 | Instances of abusive, harassing, or otherwise unacceptable behavior may be 63 | reported to the community leaders responsible for enforcement at 64 | jasnell@gmail.com, anna@addaleax.net, or matteo.collina@gmail.com. 65 | All complaints will be reviewed and investigated promptly and fairly. 66 | 67 | All community leaders are obligated to respect the privacy and security of the 68 | reporter of any incident. 69 | 70 | ## Enforcement Guidelines 71 | 72 | Community leaders will follow these Community Impact Guidelines in determining 73 | the consequences for any action they deem in violation of this Code of Conduct: 74 | 75 | ### 1. Correction 76 | 77 | **Community Impact**: Use of inappropriate language or other behavior deemed 78 | unprofessional or unwelcome in the community. 79 | 80 | **Consequence**: A private, written warning from community leaders, providing 81 | clarity around the nature of the violation and an explanation of why the 82 | behavior was inappropriate. A public apology may be requested. 83 | 84 | ### 2. Warning 85 | 86 | **Community Impact**: A violation through a single incident or series 87 | of actions. 88 | 89 | **Consequence**: A warning with consequences for continued behavior. No 90 | interaction with the people involved, including unsolicited interaction with 91 | those enforcing the Code of Conduct, for a specified period of time. This 92 | includes avoiding interactions in community spaces as well as external channels 93 | like social media. Violating these terms may lead to a temporary or 94 | permanent ban. 95 | 96 | ### 3. Temporary Ban 97 | 98 | **Community Impact**: A serious violation of community standards, including 99 | sustained inappropriate behavior. 100 | 101 | **Consequence**: A temporary ban from any sort of interaction or public 102 | communication with the community for a specified period of time. No public or 103 | private interaction with the people involved, including unsolicited interaction 104 | with those enforcing the Code of Conduct, is allowed during this period. 105 | Violating these terms may lead to a permanent ban. 106 | 107 | ### 4. Permanent Ban 108 | 109 | **Community Impact**: Demonstrating a pattern of violation of community 110 | standards, including sustained inappropriate behavior, harassment of an 111 | individual, or aggression toward or disparagement of classes of individuals. 112 | 113 | **Consequence**: A permanent ban from any sort of public interaction within 114 | the community. 115 | 116 | ## Attribution 117 | 118 | This Code of Conduct is adapted from the [Contributor Covenant][homepage], 119 | version 2.0, available at 120 | https://www.contributor-covenant.org/version/2/0/code_of_conduct.html. 121 | 122 | Community Impact Guidelines were inspired by [Mozilla's code of conduct 123 | enforcement ladder](https://github.com/mozilla/diversity). 124 | 125 | [homepage]: https://www.contributor-covenant.org 126 | 127 | For answers to common questions about this code of conduct, see the FAQ at 128 | https://www.contributor-covenant.org/faq. Translations are available at 129 | https://www.contributor-covenant.org/translations. 130 | -------------------------------------------------------------------------------- /CONTRIBUTING: -------------------------------------------------------------------------------- 1 | # Piscina is an OPEN Open Source Project 2 | 3 | ## What? 4 | 5 | Individuals making significant and valuable contributions are given commit-access to the project to contribute as they see fit. This project is more like an open wiki than a standard guarded open source project. 6 | 7 | ## Rules 8 | 9 | There are a few basic ground-rules for contributors: 10 | 11 | 1. **No `--force` pushes** on `master` or modifying the Git history in any way after a PR has been merged. 12 | 1. **Non-master branches** ought to be used for ongoing work. 13 | 1. **External API changes and significant modifications** ought to be subject to an **internal pull-request** to solicit feedback from other contributors. 14 | 1. Internal pull-requests to solicit feedback are *encouraged* for any other non-trivial contribution but left to the discretion of the contributor. 15 | 1. Contributors should attempt to adhere to the prevailing code-style. 16 | 1. 100% code coverage 17 | 1. Semantic Versioning is used. 18 | 19 | ## Releases 20 | 21 | Declaring formal releases remains the prerogative of the project maintainer. 22 | 23 | ## Changes to this arrangement 24 | 25 | This document may also be subject to pull-requests or changes by contributors where you believe you have something valuable to add or change. 26 | 27 | ----------------------------------------- 28 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2020 James M Snell and the Piscina contributors 4 | 5 | Piscina contributors listed at https://github.com/jasnell/piscina#the-team and 6 | in the README file. 7 | 8 | Permission is hereby granted, free of charge, to any person obtaining a copy 9 | of this software and associated documentation files (the "Software"), to deal 10 | in the Software without restriction, including without limitation the rights 11 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 12 | copies of the Software, and to permit persons to whom the Software is 13 | furnished to do so, subject to the following conditions: 14 | 15 | The above copyright notice and this permission notice shall be included in all 16 | copies or substantial portions of the Software. 17 | 18 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 19 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 20 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 21 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 22 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 23 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 24 | SOFTWARE. 25 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Piscina-locks 2 | 3 | Piscina-locks is an implementation of the [Web Locks API][] for Node.js. 4 | 5 | The Web Locks API allows code to acquire a lock, perform work while the lock 6 | is held, then have the lock automatically released when the work is complete. 7 | 8 | Written using TypeScript and N-API. 9 | 10 | For Node.js 12.x and higher. 11 | 12 | [MIT Licensed][]. 13 | 14 | ## Example 15 | 16 | ```js 17 | const { request } = require('piscina-locks'); 18 | const { setTimeout } = require('timers/promises'); 19 | 20 | (async function() { 21 | return await request('resource', async (lock) => { 22 | await setTimeout(1000); 23 | return lock.name; 24 | }); 25 | })(); 26 | ``` 27 | 28 | Using workers: 29 | 30 | ```js 31 | const { request } = require('piscina-locks'); 32 | const { Worker, isMainThread } = require('worker_threads'); 33 | const { promisify } = require('util'); 34 | const sleep = promisify(setTimeout); 35 | 36 | request('shared-resource', async () => { 37 | console.log(isMainThread); 38 | await sleep(1000); 39 | }); 40 | 41 | if (isMainThread) { 42 | // eslint-disable-next-line no-new 43 | new Worker(__filename); 44 | } 45 | ``` 46 | 47 | ## `request(name\[, options], callback)` 48 | 49 | * `name` (`string`) The name of the lock to acquire. 50 | * `options` (`object`) 51 | * `mode` (`string`) Must be `'exclusive'` or `'shared'`. Defaults to 52 | `'exclusive'`. There can be only one holder of an `'exclusive'` 53 | lock at a time but multiple holders of `'shared'` locks. 54 | * `ifAvailable` (`boolean`) When `true`, the request will fail if 55 | the lock cannot be granted immediately. Defaults to `false`. 56 | * `steal` (`boolean`) When `true`, any existing held locks are released 57 | and the associated Promises rejected with an `AbortError` and the 58 | requested lock will be granted. Defaults to `false`. 59 | * `signal` (`AbortSignal` | `EventEmitter`) An `AbortSignal` that can 60 | be used to cancel a pending lock request. 61 | * `callback` (`function`) Invoked when the lock is acquired. The callback 62 | is invoked with the granted `Lock` as the only argument. If `ifAvailable` 63 | is `true` and the lock is not available, the argument will be `null`. The 64 | callback may be a regular function or an async function. 65 | 66 | Returns a `Promise` that resolves with the return value of `callback` after 67 | the granted `Lock` is released. The `Promise` will be rejected if the lock 68 | request is canceled, the granted lock is stolen, or `callback` throws. 69 | 70 | ## `query()` 71 | 72 | * Returns `object` 73 | * `pending` (`object[]`) 74 | * `name` (`string`) 75 | * `mode` (`string`) 76 | * `held` (`object[]`) 77 | * `name` (`string`) 78 | * `mode` (`string`) 79 | 80 | The `query()` method is a diagnostic utility that lists the pending lock 81 | requests and currently held locks. It is useful for debugging purposes only. 82 | 83 | ## The Team 84 | 85 | * James M Snell 86 | 87 | ## Acknowledgements 88 | 89 | Piscina development is sponsored by [NearForm Research][]. 90 | 91 | [MIT Licensed]: LICENSE.md 92 | [NearForm Research]: https://www.nearform.com/research/ 93 | [Web Locks API]: https://developer.mozilla.org/en-US/docs/Web/API/Web_Locks_API 94 | -------------------------------------------------------------------------------- /binding.gyp: -------------------------------------------------------------------------------- 1 | { 2 | 'targets': [{ 3 | 'target_name': 'piscina_locks', 4 | 'sources': [ 'src/locks.cc' ], 5 | 'include_dirs': [ 6 | "src", 7 | " { 8 | res = resolve; 9 | }); 10 | 11 | (async function () { 12 | setTimeout(res, 1000); 13 | 14 | // The lock can be held beyond the callback by using 15 | // an async function that returns a Promise that is 16 | // resolved later. 17 | const req = request('shared-resource', async () => { 18 | return p; 19 | }); 20 | await req; 21 | })(); 22 | -------------------------------------------------------------------------------- /examples/simple/index.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const { request } = require('../..'); 4 | const { Worker, isMainThread } = require('worker_threads'); 5 | const { promisify } = require('util'); 6 | const sleep = promisify(setTimeout); 7 | 8 | request('shared-resource', async () => { 9 | console.log(isMainThread); 10 | await sleep(1000); 11 | }); 12 | 13 | if (isMainThread) { 14 | // eslint-disable-next-line no-new 15 | new Worker(__filename); 16 | } 17 | -------------------------------------------------------------------------------- /examples/simple/index.mjs: -------------------------------------------------------------------------------- 1 | import { request } from 'piscina-locks'; 2 | import { Worker, isMainThread } from 'worker_threads'; 3 | import { promisify } from 'util'; 4 | 5 | const sleep = promisify(setTimeout); 6 | 7 | request('shared-resource', async () => { 8 | console.log(isMainThread); 9 | await sleep(1000); 10 | }); 11 | 12 | if (isMainThread) { 13 | // eslint-disable-next-line no-new 14 | new Worker(new URL('./index.mjs', import.meta.url)); 15 | } 16 | -------------------------------------------------------------------------------- /lib/binding.d.ts: -------------------------------------------------------------------------------- 1 | declare module 'node-gyp-build' 2 | -------------------------------------------------------------------------------- /lib/index.ts: -------------------------------------------------------------------------------- 1 | import binding from 'node-gyp-build'; 2 | import { version } from '../package.json'; 3 | 4 | import { resolve } from 'path'; 5 | 6 | const { 7 | LockRequest, 8 | snapshot 9 | } = binding(resolve(__dirname, '../..')); 10 | 11 | const GRANTED = 0; 12 | const NOT_AVAILABLE = 1; 13 | const CANCELED = 2; 14 | 15 | interface Lock { 16 | name : string; 17 | mode : string; 18 | release() : void; 19 | } 20 | 21 | interface AbortSignalEventTargetAddOptions { 22 | once : boolean; 23 | } 24 | 25 | interface AbortSignalEventTarget { 26 | addEventListener : ( 27 | name : 'abort', 28 | listener : () => void, 29 | options? : AbortSignalEventTargetAddOptions) => void; 30 | removeEventListener : ( 31 | name : 'abort', 32 | listener : () => void, 33 | options? : AbortSignalEventTargetAddOptions) => void; 34 | aborted? : boolean; 35 | } 36 | interface AbortSignalEventEmitter { 37 | once : (name : 'abort', listener : () => void) => void; 38 | off : (name : 'abort', listener : () => void) => void; 39 | } 40 | type AbortSignalAny = AbortSignalEventTarget | AbortSignalEventEmitter; 41 | function onabort (abortSignal : AbortSignalAny, listener : () => void) { 42 | if ('addEventListener' in abortSignal) { 43 | abortSignal.addEventListener('abort', listener, { once: true }); 44 | } else { 45 | abortSignal.once('abort', listener); 46 | } 47 | } 48 | 49 | function clearAbort (abortSignal : AbortSignalAny, listener : () => void) { 50 | if ('addEventListener' in abortSignal) { 51 | abortSignal.removeEventListener('abort', listener, { once: true }); 52 | } else { 53 | abortSignal.off('abort', listener); 54 | } 55 | } 56 | 57 | class AbortError extends Error { 58 | constructor () { 59 | super('The task has been aborted'); 60 | } 61 | 62 | get name () { return 'AbortError'; } 63 | } 64 | 65 | interface RequestOptions { 66 | mode? : 'exclusive' | 'shared'; 67 | ifAvailable? : boolean; 68 | steal? : boolean; 69 | signal? : AbortSignalAny; 70 | } 71 | 72 | interface FilledRequestOptions extends RequestOptions { 73 | mode: 'exclusive' | 'shared'; 74 | ifAvailable: boolean; 75 | steal: boolean; 76 | signal? : AbortSignalAny; 77 | } 78 | 79 | const kDefaultRequestOptions : FilledRequestOptions = { 80 | mode: 'exclusive', 81 | ifAvailable: false, 82 | steal: false, 83 | signal: undefined 84 | }; 85 | 86 | type LockCallback = (lock? : Lock | null) => void; 87 | 88 | export async function request ( 89 | name : string, 90 | options? : RequestOptions | LockCallback, 91 | callback? : LockCallback) : Promise { 92 | if (typeof options === 'function') { 93 | callback = options as LockCallback; 94 | options = kDefaultRequestOptions; 95 | } 96 | 97 | name = `${name}`; 98 | 99 | if (typeof callback !== 'function') { 100 | throw new TypeError('Callback must be a function'); 101 | } 102 | 103 | if (options == null || typeof options !== 'object') { 104 | throw new TypeError('Options must be an object'); 105 | } 106 | 107 | options = { ...kDefaultRequestOptions, ...options }; 108 | 109 | let request : typeof LockRequest | undefined; 110 | 111 | const abortListener = () => request.cancel(); 112 | 113 | if (options.mode !== 'exclusive' && options.mode !== 'shared') { 114 | throw new RangeError('Invalid mode'); 115 | } 116 | 117 | if (typeof options.ifAvailable !== 'boolean') { 118 | throw new TypeError('options.ifAvailable must be a boolean'); 119 | } 120 | 121 | if (typeof options.steal !== 'boolean') { 122 | throw new TypeError('options.steal must be a boolean'); 123 | } 124 | 125 | if (options.signal != null) { 126 | if ((options.signal as AbortSignalEventTarget).aborted) { 127 | return Promise.reject(new AbortError()); 128 | } 129 | onabort(options.signal, abortListener); 130 | } 131 | 132 | const lock : Lock | null = await new Promise((resolve, reject) => { 133 | request = new LockRequest( 134 | name, 135 | (options as RequestOptions).mode === 'exclusive' ? 0 : 1, 136 | Boolean((options as RequestOptions).ifAvailable), 137 | Boolean((options as RequestOptions).steal), 138 | (status : number, lock : Lock) => { 139 | if ((options as RequestOptions).signal != null) { 140 | clearAbort( 141 | (options as RequestOptions).signal as AbortSignalAny, 142 | abortListener); 143 | } 144 | request = undefined; 145 | switch (status) { 146 | case GRANTED: return resolve(lock); 147 | case NOT_AVAILABLE: return resolve(null); 148 | case CANCELED: return reject(new AbortError()); 149 | } 150 | }); 151 | }); 152 | 153 | try { 154 | return await (callback(lock) as any); 155 | } finally { 156 | if (lock != null) { 157 | lock.release(); 158 | } 159 | } 160 | } 161 | 162 | export function query () { 163 | return snapshot(); 164 | } 165 | 166 | export { 167 | version 168 | }; 169 | 170 | export default { 171 | request, 172 | query, 173 | version 174 | }; 175 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "piscina-locks", 3 | "version": "3.1.1", 4 | "description": "A Web Locks API implementation for Node.js", 5 | "keywords": [ 6 | "worker_threads", 7 | "locks" 8 | ], 9 | "author": "James M Snell ", 10 | "license": "MIT", 11 | "main": "./dist/lib/index.js", 12 | "module": "./dist/lib/index.mjs", 13 | "types": "./dist/lib/index.d.ts", 14 | "exports": { 15 | ".": { 16 | "import": { 17 | "types": "./dist/lib/index.d.mts", 18 | "default": "./dist/lib/index.mjs" 19 | }, 20 | "require": { 21 | "types": "./dist/lib/index.d.ts", 22 | "default": "./dist/lib/index.js" 23 | }, 24 | "types": "./dist/lib/index.d.ts", 25 | "default": "./dist/lib/index.js" 26 | } 27 | }, 28 | "scripts": { 29 | "prebuildify": "cross-env prebuildify --napi --strip --target $(node -v)", 30 | "prebuildify:ci": "cross-env prebuildify --napi --strip --target $NODE_VERSION", 31 | "install": "node-gyp-build", 32 | "build": "npm run prebuildify && tsup", 33 | "build:ci": "npm run prebuildify:ci && tsup", 34 | "lint": "standardx \"**/*.{ts,mjs,js,cjs}\" | snazzy", 35 | "test-only": "tap --ts", 36 | "test": "npm run lint && npm run build && npm run test-only", 37 | "test:ci": "npm run lint && npm run build:ci && npm run test-only", 38 | "prepack": "npm run build" 39 | }, 40 | "repository": { 41 | "type": "git", 42 | "url": "git+https://github.com/piscinajs/piscina-locks.git" 43 | }, 44 | "eslintConfig": { 45 | "rules": { 46 | "semi": [ 47 | "error", 48 | "always" 49 | ], 50 | "no-unused-vars": "off", 51 | "no-use-before-define": "off", 52 | "no-unreachable-loop": "off", 53 | "no-dupe-class-members": "off", 54 | "@typescript-eslint/no-unused-vars": "error" 55 | }, 56 | "globals": { 57 | "SharedArrayBuffer": true, 58 | "Atomics": true, 59 | "AbortController": true, 60 | "MessageChannel": true 61 | } 62 | }, 63 | "standardx": { 64 | "parser": "@typescript-eslint/parser", 65 | "plugins": [ 66 | "@typescript-eslint/eslint-plugin" 67 | ] 68 | }, 69 | "devDependencies": { 70 | "@types/node": "^22.10.2", 71 | "@typescript-eslint/eslint-plugin": "^6.14.0", 72 | "@typescript-eslint/parser": "^6.14.0", 73 | "abort-controller": "^3.0.0", 74 | "cross-env": "^7.0.3", 75 | "prebuildify": "^6.0.1", 76 | "snazzy": "^9.0.0", 77 | "standardx": "^7.0.0", 78 | "tap": "^15.0.6", 79 | "ts-node": "^10.9.2", 80 | "tsup": "^8.2.4", 81 | "typescript": "^5.3.3" 82 | }, 83 | "dependencies": { 84 | "node-addon-api": "^8.3.0", 85 | "node-gyp-build": "^4.2.3" 86 | } 87 | } 88 | -------------------------------------------------------------------------------- /prebuilds/darwin-arm64/node.napi.node: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/piscinajs/piscina-locks/988346ed9c60498821e46873abc176a17253669f/prebuilds/darwin-arm64/node.napi.node -------------------------------------------------------------------------------- /prebuilds/darwin-x64/node.napi.node: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/piscinajs/piscina-locks/988346ed9c60498821e46873abc176a17253669f/prebuilds/darwin-x64/node.napi.node -------------------------------------------------------------------------------- /prebuilds/linux-x64/node.napi.node: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/piscinajs/piscina-locks/988346ed9c60498821e46873abc176a17253669f/prebuilds/linux-x64/node.napi.node -------------------------------------------------------------------------------- /prebuilds/win32-x64/node.napi.node: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/piscinajs/piscina-locks/988346ed9c60498821e46873abc176a17253669f/prebuilds/win32-x64/node.napi.node -------------------------------------------------------------------------------- /src/locks.cc: -------------------------------------------------------------------------------- 1 | #include 2 | #include 3 | #include 4 | 5 | using namespace Napi; 6 | 7 | // There is a single LockManager for the process. 8 | namespace piscina { 9 | 10 | using Napi::Boolean; 11 | using Napi::CallbackInfo; 12 | using Napi::Error; 13 | using Napi::EscapableHandleScope; 14 | using Napi::HandleScope; 15 | using Napi::Function; 16 | using Napi::FunctionReference; 17 | using Napi::Number; 18 | using Napi::Object; 19 | using Napi::ObjectWrap; 20 | using Napi::Persistent; 21 | using Napi::RangeError; 22 | using Napi::String; 23 | using Napi::TypeError; 24 | using Napi::Value; 25 | 26 | namespace locks { 27 | 28 | Lock::Lock(const std::string& name, Mode mode) : name_(name), mode_(mode) {} 29 | 30 | // Disconnect this Lock from the LockWrap that might be currently holding it. 31 | void Lock::Eject(EjectedReason reason) { 32 | if (owner_ != nullptr) { 33 | owner_->lock_ = nullptr; 34 | owner_->reason_ = reason; 35 | owner_ = nullptr; 36 | } 37 | } 38 | 39 | Object LockWrap::NewInstance(Napi::Env env, Lock* lock) { 40 | EscapableHandleScope scope(env); 41 | Object obj = env.GetInstanceData()->lock_wrap.New({}); 42 | LockWrap* wrap = LockWrap::Unwrap(obj); 43 | wrap->lock_ = lock; 44 | lock->owner_ = wrap; 45 | return scope.Escape(napi_value(obj)).ToObject(); 46 | } 47 | 48 | LockWrap::LockWrap( 49 | const CallbackInfo& info) 50 | : ObjectWrap(info) {} 51 | 52 | LockWrap::~LockWrap() { 53 | if (lock_ != nullptr) 54 | piscina::per_process::lock_manager.Release(lock_); 55 | } 56 | 57 | Value LockWrap::Name(const CallbackInfo& info) { 58 | return lock_ != nullptr 59 | ? String::New(Env(), lock_->name()) 60 | : Env().Undefined(); 61 | } 62 | 63 | Value LockWrap::Mode(const CallbackInfo& info) { 64 | if (lock_ != nullptr) 65 | 66 | switch (lock_->mode()) { 67 | case Lock::Mode::EXCLUSIVE: 68 | return String::New(Env(), "exclusive"); 69 | case Lock::Mode::SHARED: 70 | return String::New(Env(), "shared"); 71 | } 72 | 73 | return Env().Undefined(); 74 | 75 | } 76 | 77 | Value LockWrap::Held(const CallbackInfo& info) { 78 | return Boolean::New(Env(), lock_ != nullptr); 79 | } 80 | 81 | Value LockWrap::EjectedReason(const CallbackInfo& info) { 82 | return Number::New(Env(), static_cast(reason_)); 83 | } 84 | 85 | FunctionReference LockWrap::Init(Napi::Env env) { 86 | Function func = DefineClass(env, "Lock", { 87 | InstanceMethod("release", &LockWrap::Release), 88 | InstanceAccessor<&LockWrap::Name>("name"), 89 | InstanceAccessor<&LockWrap::Mode>("mode"), 90 | InstanceAccessor<&LockWrap::Held>("held"), 91 | InstanceAccessor<&LockWrap::EjectedReason>("ejectedReason") 92 | }); 93 | return Persistent(func); 94 | } 95 | 96 | Value LockWrap::Release(const CallbackInfo& info) { 97 | if (lock_ != nullptr) 98 | piscina::per_process::lock_manager.Release(lock_); 99 | return Env().Undefined(); 100 | } 101 | 102 | LockManager::LockManager() { 103 | uv_mutex_init(&mutex_); 104 | } 105 | 106 | LockManager::~LockManager() { 107 | uv_mutex_destroy(&mutex_); 108 | } 109 | 110 | void LockManager::Release(Lock* lock) { 111 | lock->Eject(Lock::EjectedReason::RELEASED); 112 | ScopedLock scoped_lock(&mutex_); 113 | for (const auto& other : held_) { 114 | if (other.get() == lock) { 115 | held_.erase( 116 | std::remove(held_.begin(), held_.end(), other), 117 | held_.end()); 118 | } 119 | } 120 | ProcessQueue(); 121 | } 122 | 123 | void LockManager::Request(LockRequest* request) { 124 | ScopedLock scoped_lock(&mutex_); 125 | // If request->steal() is true, if there's an existing 126 | // lock held, we're going to steal it away and grant it 127 | // to this request. 128 | if (request->steal()) { 129 | // It's important to know that while the lock is being 130 | // stolen from whatever code is using it, that code is 131 | // still likely running, only without the protection of 132 | // the lock. So take great care with this. 133 | for (auto const& lock : held_) { 134 | if (lock->name() == request->name()) { 135 | lock->Eject(Lock::EjectedReason::STOLEN); 136 | held_.erase( 137 | std::remove(held_.begin(), held_.end(), lock), 138 | held_.end()); 139 | } 140 | } 141 | requests_.push_front(request); 142 | return ProcessQueue(); 143 | } 144 | 145 | // If request->if_available() is true and IsGrantable() is 146 | // false, we're going to reject immediately. 147 | if (request->if_available() && !IsGrantable(request)) { 148 | return request->Notify(LockRequest::Status::NOT_AVAILABLE); 149 | } 150 | 151 | // Otherwise, put the request into the queue and process the queue. 152 | requests_.push_back(request); 153 | ProcessQueue(); 154 | } 155 | 156 | void LockManager::Cancel(LockRequest* request) { 157 | ScopedLock scope_lock(&mutex_); 158 | for (auto const& req : requests_) { 159 | if (req == request) { 160 | req->Notify(LockRequest::Status::CANCELED); 161 | requests_.erase( 162 | std::remove(requests_.begin(), requests_.end(), req), 163 | requests_.end()); 164 | return; 165 | } 166 | } 167 | } 168 | 169 | bool LockManager::IsGrantable(LockRequest* request) { 170 | if (request->mode() == Lock::Mode::EXCLUSIVE) { 171 | for (auto const& lock : held_) 172 | if (lock->name() == request->name()) return false; 173 | 174 | for (auto const& req : requests_) { 175 | if (request == req) break; 176 | if (req->name() == request->name()) return false; 177 | } 178 | return true; 179 | } 180 | 181 | for (auto const& lock : held_) { 182 | if (lock->name() == request->name() && 183 | lock->mode() == Lock::Mode::EXCLUSIVE) { 184 | return false; 185 | } 186 | } 187 | 188 | for (auto const& req : requests_) { 189 | if (request == req) break; 190 | if (req->name() == request->name() && 191 | req->mode() == Lock::Mode::EXCLUSIVE) { 192 | return false; 193 | } 194 | } 195 | return true; 196 | } 197 | 198 | void LockManager::ProcessQueue() { 199 | for (auto const& request : requests_) { 200 | if (IsGrantable(request)) { 201 | Lock* lock = new Lock(request->name(), request->mode()); 202 | held_.emplace_back(lock); 203 | requests_.erase( 204 | std::remove(requests_.begin(), requests_.end(), request), 205 | requests_.end()); 206 | request->Notify(LockRequest::Status::GRANTED, lock); 207 | } 208 | } 209 | } 210 | 211 | void LockManager::Snapshot(LockSnapshotCallback callback) { 212 | ScopedLock scoped_lock(&mutex_); 213 | for (const auto& request : requests_) 214 | callback(LockSnapshotType::REQUEST, request->name(), request->mode()); 215 | for (const auto& lock : held_) 216 | callback(LockSnapshotType::LOCK, lock->name(), lock->mode()); 217 | } 218 | 219 | void LockRequest::OnNotify() { 220 | // Create a handle scope? 221 | // Create a content scope? 222 | HandleScope scope(Env()); 223 | Number ret_status = Number::New(Env(), static_cast(status_)); 224 | Napi::Value ret_lock = Env().Undefined(); 225 | if (lock_ != nullptr) 226 | ret_lock = LockWrap::NewInstance(Env(), lock_); 227 | callback_.Call(Env().Global(), { ret_status, ret_lock }); 228 | uv_close(reinterpret_cast(&async_), nullptr); 229 | } 230 | 231 | uv_loop_t* LockRequest::event_loop() const { 232 | uv_loop_t* loop; 233 | napi_get_uv_event_loop(napi_env(Env()), &loop); 234 | return loop; 235 | } 236 | 237 | // info[0] -> string, name of the lock 238 | // info[1] -> int, mode, exclusive vs shared 239 | // info[2] -> bool, if_available 240 | // info[3] -> bool, steal 241 | // info[4] -> function 242 | LockRequest::LockRequest(const CallbackInfo& info) 243 | : ObjectWrap(info) { 244 | 245 | if (!info[0].IsString()) { 246 | throw TypeError::New( 247 | info.Env(), 248 | std::string("name must be a string")); 249 | } 250 | 251 | if (!info[1].IsNumber()) { 252 | throw TypeError::New( 253 | info.Env(), 254 | std::string("mode must be a number")); 255 | } 256 | 257 | if (!info[2].IsBoolean()) { 258 | throw TypeError::New( 259 | info.Env(), 260 | std::string("if_available must be a boolean")); 261 | } 262 | 263 | if (!info[3].IsBoolean()) { 264 | throw TypeError::New( 265 | info.Env(), 266 | std::string("steal must be a boolean")); 267 | } 268 | 269 | if (!info[4].IsFunction()) { 270 | throw TypeError::New( 271 | info.Env(), 272 | std::string("callback must be a function")); 273 | } 274 | 275 | name_ = info[0].As(); 276 | mode_ = static_cast(int32_t(info[1].As())); 277 | 278 | if (mode_ > Lock::Mode::SHARED) { 279 | throw RangeError::New( 280 | info.Env(), 281 | std::string("invalid lock mode")); 282 | } 283 | 284 | if (bool(info[2].As())) 285 | flags_ = static_cast(static_cast(flags_) | static_cast(Flag::IF_AVAILABLE)); 286 | 287 | if (bool(info[3].As())) 288 | flags_ = static_cast(static_cast(flags_) | static_cast(Flag::STEAL)); 289 | 290 | callback_ = Persistent(info[4].As()); 291 | 292 | async_.data = this; 293 | uv_async_init(event_loop(), &async_, [](uv_async_t* handle) { 294 | LockRequest* req = static_cast(handle->data); 295 | req->OnNotify(); 296 | }); 297 | 298 | piscina::per_process::lock_manager.Request(this); 299 | } 300 | 301 | Value LockRequest::Cancel(const CallbackInfo& info) { 302 | piscina::per_process::lock_manager.Cancel(this); 303 | return Env().Undefined(); 304 | } 305 | 306 | void LockRequest::Init(Napi::Env env, Napi::Object exports) { 307 | Function func = 308 | DefineClass(env, 309 | "LockRequest", 310 | { 311 | InstanceMethod("cancel", &LockRequest::Cancel) 312 | }); 313 | 314 | LocksInstanceData* instance_data = new LocksInstanceData(); 315 | instance_data->lock_request = Persistent(func); 316 | instance_data->lock_wrap = LockWrap::Init(env); 317 | 318 | env.SetInstanceData(instance_data); 319 | exports["LockRequest"] = func; 320 | } 321 | 322 | void LockRequest::Notify(Status status, Lock* lock) { 323 | status_ = status; 324 | lock_ = lock; 325 | uv_async_send(&async_); 326 | } 327 | 328 | } // namespace locks 329 | namespace per_process { 330 | locks::LockManager lock_manager; 331 | } // namespace per_process 332 | } // namespace piscina 333 | 334 | Napi::Value Snapshot(const Napi::CallbackInfo& info) { 335 | Napi::Object obj = Napi::Object::New(info.Env()); 336 | Napi::Array pending = Napi::Array::New(info.Env()); 337 | Napi::Array held = Napi::Array::New(info.Env()); 338 | obj["pending"] = pending; 339 | obj["held"] = held; 340 | 341 | piscina::per_process::lock_manager.Snapshot([&]( 342 | piscina::locks::LockSnapshotType type, 343 | const std::string& name, 344 | piscina::locks::Lock::Mode mode) { 345 | Napi::Object item = Napi::Object::New(info.Env()); 346 | item["name"] = name; 347 | item["mode"] = Napi::String::New( 348 | info.Env(), 349 | mode == piscina::locks::Lock::Mode::EXCLUSIVE 350 | ? "exclusive" : "shared"); 351 | switch (type) { 352 | case piscina::locks::LockSnapshotType::REQUEST: 353 | pending[pending.Length()] = item; 354 | break; 355 | case piscina::locks::LockSnapshotType::LOCK: 356 | held[held.Length()] = item; 357 | break; 358 | } 359 | }); 360 | return obj; 361 | } 362 | 363 | Object Init(Env env, Object exports) { 364 | piscina::locks::LockRequest::Init(env, exports); 365 | exports.Set( 366 | Napi::String::New(env, "snapshot"), 367 | Napi::Function::New(env, Snapshot)); 368 | return exports; 369 | } 370 | 371 | NODE_API_MODULE(piscina_locks, Init) 372 | -------------------------------------------------------------------------------- /src/locks.h: -------------------------------------------------------------------------------- 1 | #ifndef LOCKS_H_ 2 | #define LOCKS_H_ 3 | 4 | #include 5 | #include 6 | #include 7 | #include 8 | #include 9 | 10 | namespace piscina { 11 | namespace locks { 12 | 13 | struct ScopedLock { 14 | uv_mutex_t* mutex; 15 | ScopedLock(uv_mutex_t* mutex_) : mutex(mutex_) { 16 | uv_mutex_lock(mutex); 17 | } 18 | ~ScopedLock() { 19 | uv_mutex_unlock(mutex); 20 | } 21 | }; 22 | 23 | struct LocksInstanceData { 24 | Napi::FunctionReference lock_request; 25 | Napi::FunctionReference lock_wrap; 26 | }; 27 | 28 | class LockWrap; 29 | 30 | class Lock { 31 | public: 32 | enum class Mode { 33 | EXCLUSIVE, 34 | SHARED 35 | }; 36 | 37 | enum class EjectedReason { 38 | NONE, 39 | RELEASED, 40 | STOLEN 41 | }; 42 | 43 | Lock(const std::string& name, Mode mode); 44 | 45 | const std::string& name() const { return name_; } 46 | Mode mode() const { return mode_; } 47 | 48 | void Eject(EjectedReason reason); 49 | 50 | private: 51 | std::string name_; 52 | Lock::Mode mode_; 53 | 54 | LockWrap* owner_ = nullptr; 55 | friend class LockWrap; 56 | }; 57 | 58 | class LockWrap : public Napi::ObjectWrap { 59 | public: 60 | static Napi::FunctionReference Init(Napi::Env env); 61 | static Napi::Object NewInstance(Napi::Env env, Lock* lock); 62 | 63 | LockWrap(const Napi::CallbackInfo& info); 64 | ~LockWrap(); 65 | 66 | Napi::Value Name(const Napi::CallbackInfo& info); 67 | Napi::Value Mode(const Napi::CallbackInfo& info); 68 | Napi::Value Held(const Napi::CallbackInfo& info); 69 | Napi::Value EjectedReason(const Napi::CallbackInfo& info); 70 | 71 | Lock* lock() const { return lock_; } 72 | 73 | private: 74 | Napi::Value Release(const Napi::CallbackInfo& info); 75 | 76 | Lock* lock_ = nullptr; 77 | Lock::EjectedReason reason_ = Lock::EjectedReason::NONE; 78 | friend class Lock; 79 | }; 80 | 81 | class LockRequest : public Napi::ObjectWrap { 82 | public: 83 | enum class Flag { 84 | NONE, 85 | IF_AVAILABLE = 1, 86 | STEAL = 2 87 | }; 88 | 89 | enum class Status { 90 | GRANTED, 91 | NOT_AVAILABLE, 92 | CANCELED 93 | }; 94 | 95 | static void Init(Napi::Env env, Napi::Object exports); 96 | 97 | const std::string& name() const { return name_; } 98 | Lock::Mode mode() const { return mode_; } 99 | 100 | inline bool steal() const { 101 | return static_cast(flags_) & static_cast(Flag::STEAL); 102 | } 103 | 104 | inline bool if_available() const { 105 | return static_cast(flags_) & static_cast(Flag::IF_AVAILABLE); 106 | } 107 | 108 | LockRequest(const Napi::CallbackInfo& info); 109 | 110 | void Notify(Status status, Lock* lock = nullptr); 111 | 112 | uv_loop_t* event_loop() const; 113 | 114 | private: 115 | Napi::Value Cancel(const Napi::CallbackInfo& info); 116 | 117 | static void OnAsync(uv_async_t* handle); 118 | void OnNotify(); 119 | 120 | uv_async_t async_; 121 | std::string name_; 122 | Lock::Mode mode_; 123 | Flag flags_ = Flag::NONE; 124 | Napi::FunctionReference callback_; 125 | Lock* lock_ = nullptr; 126 | Status status_ = Status::GRANTED; 127 | }; 128 | 129 | enum class LockSnapshotType { 130 | REQUEST, 131 | LOCK 132 | }; 133 | 134 | using LockSnapshotCallback = 135 | std::function; 136 | 137 | class LockManager { 138 | public: 139 | LockManager(); 140 | ~LockManager(); 141 | void Request(LockRequest* request); 142 | void Cancel(LockRequest* request); 143 | 144 | void Release(Lock* lock); 145 | 146 | void Snapshot(LockSnapshotCallback cb); 147 | 148 | private: 149 | bool IsGrantable(LockRequest* request); 150 | void ProcessQueue(); 151 | 152 | std::deque requests_; 153 | 154 | // The LockManager owns the locks. Pointer references to the 155 | // Lock will be held by LockWrap while the lock is held but 156 | // the LockManager controls the lifetime. 157 | std::deque> held_; 158 | 159 | uv_mutex_t mutex_; 160 | }; 161 | 162 | } // namespace locks 163 | 164 | namespace per_process { 165 | extern locks::LockManager lock_manager; 166 | } // namespace per_process 167 | } // namespace piscina 168 | 169 | #endif // LOCKS_H_ 170 | -------------------------------------------------------------------------------- /test/locks.ts: -------------------------------------------------------------------------------- 1 | import { test } from 'tap'; 2 | import { version as _version } from '../package.json'; 3 | import { EventEmitter } from 'events'; 4 | import { AbortController } from 'abort-controller'; 5 | import { 6 | request, 7 | query, 8 | version 9 | } from '..'; 10 | 11 | function sleep (n : number) { 12 | return new Promise((resolve) => { 13 | setTimeout(resolve, n); 14 | }); 15 | } 16 | 17 | test('request and query are exposed on export', async ({ equal }) => { 18 | equal(typeof request, 'function'); 19 | equal(typeof query, 'function'); 20 | equal(version, _version); 21 | }); 22 | 23 | test('basically works', async ({ equal }) => { 24 | const ret = await request('test1', async (lock) => { 25 | equal(lock.name, 'test1'); 26 | equal(lock.mode, 'exclusive'); 27 | return 1; 28 | }); 29 | equal(ret, 1); 30 | }); 31 | 32 | test('shared locks work', async ({ resolves }) => { 33 | const p1 = request('hello', { mode: 'shared' }, async () => { 34 | await sleep(10); 35 | }); 36 | const p2 = request('hello', { mode: 'shared' }, async () => { 37 | await sleep(10); 38 | }); 39 | await resolves(Promise.all([p1, p2])); 40 | }); 41 | 42 | test('shared locks work reentrantly', async ({ equal }) => { 43 | const ret = await request('shared', { mode: 'shared' }, async () => { 44 | await request('shared', { mode: 'shared' }, async () => { 45 | await sleep(10); 46 | }); 47 | return 1; 48 | }); 49 | equal(ret, 1); 50 | }); 51 | 52 | test('exclusive locks work non-reentrantly', async ({ rejects }) => { 53 | const ac = new AbortController(); 54 | const p = request('exclusive', async () => { 55 | await request('exclusive', { signal: ac.signal as any, mode: 'shared' }, async () => { 56 | await sleep(10); 57 | }); 58 | }); 59 | setTimeout(() => ac.abort(), 100); 60 | await rejects(p, /aborted/); 61 | }); 62 | 63 | test('validates lock name is string', async ({ rejects }) => { 64 | rejects(() => request((Symbol('') as any), () => {}), 65 | /Cannot convert a Symbol/); 66 | }); 67 | 68 | test('validates callback is given', async ({ rejects }) => { 69 | rejects(() => request(''), TypeError); 70 | }); 71 | 72 | test('validates options is an object', async ({ rejects }) => { 73 | rejects(() => request('', 'hi' as any, () => {}), TypeError); 74 | rejects(() => request('', 1 as any, () => {}), TypeError); 75 | rejects(() => request('', null as any, () => {}), TypeError); 76 | rejects(() => request('', undefined, () => {}), TypeError); 77 | rejects(() => request('', true as any, () => {}), TypeError); 78 | }); 79 | 80 | test('validates options types', async ({ rejects }) => { 81 | rejects(() => request('', { mode: 1 as any }, () => {}), RangeError); 82 | rejects(() => request('', { mode: 'foo' as any }, () => {}), RangeError); 83 | rejects(() => request('', { mode: true as any }, () => {}), RangeError); 84 | rejects(() => request('', { ifAvailable: 'yes' as any }, () => {}), TypeError); 85 | rejects(() => request('', { ifAvailable: 1 as any }, () => {}), TypeError); 86 | rejects(() => request('', { ifAvailable: {} as any }, () => {}), TypeError); 87 | rejects(() => request('', { steal: 1 as any }, () => {}), TypeError); 88 | rejects(() => request('', { steal: 'hi' as any }, () => {}), TypeError); 89 | rejects(() => request('', { steal: {} as any }, () => {}), TypeError); 90 | }); 91 | 92 | test('generates a summary', async ({ equal, ok }) => { 93 | const summary = query(); 94 | equal(typeof summary, 'object'); 95 | ok(Array.isArray(summary.pending)); 96 | ok(Array.isArray(summary.held)); 97 | }); 98 | 99 | test('waits for lock to free', async ({ resolves, ok }) => { 100 | let check : boolean = false; 101 | const p1 = request('hello', async () => { 102 | await sleep(10); 103 | check = true; 104 | }); 105 | const p2 = request('hello', async () => { 106 | ok(check); 107 | }); 108 | await resolves(Promise.all([p1, p2])); 109 | }); 110 | 111 | test('cancels with AbortError', async ({ resolves, rejects, equal }) => { 112 | const unusedSignal = new EventEmitter(); 113 | const p1 = request('hello', { signal: unusedSignal }, async () => { 114 | await sleep(10); 115 | }); 116 | const signal = new EventEmitter(); 117 | const p2 = request('hello', { signal }, async () => {}); 118 | signal.emit('abort'); 119 | 120 | await Promise.all([ 121 | resolves(p1), 122 | rejects(p2, /aborted/) 123 | ]); 124 | 125 | equal(unusedSignal.listenerCount('abort'), 0); 126 | }); 127 | 128 | test('cancels with AbortError (2)', async ({ resolves, rejects }) => { 129 | const unusedAc = new AbortController(); 130 | const p1 = request('hello', { signal: unusedAc.signal as any }, async () => { 131 | await sleep(10); 132 | }); 133 | const ac = new AbortController(); 134 | const p2 = request('hello', { signal: ac.signal as any }, async () => {}); 135 | ac.abort(); 136 | 137 | await Promise.all([ 138 | resolves(p1), 139 | rejects(p2, /aborted/) 140 | ]); 141 | }); 142 | 143 | test('fails when already aborted', async ({ rejects }) => { 144 | const p1 = request('hello', { signal: { aborted: true } as any }, async () => {}); 145 | await rejects(p1, /aborted/); 146 | }); 147 | 148 | test('lock null when not available', async ({ resolves, equal }) => { 149 | const p1 = request('hello', async () => { 150 | await sleep(10); 151 | }); 152 | const p2 = request('hello', { ifAvailable: true }, async (lock) => { 153 | equal(lock, null); 154 | }); 155 | 156 | await Promise.all([ 157 | resolves(p1), 158 | resolves(p2) 159 | ]); 160 | }); 161 | -------------------------------------------------------------------------------- /test/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "../tsconfig", 3 | "compilerOptions": { 4 | "strict": false, 5 | "noImplicitAny": false 6 | }, 7 | "include": [ 8 | "./**/*.ts" 9 | ] 10 | } 11 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "es2019", 4 | "module": "commonjs", 5 | "moduleResolution": "node", 6 | "lib": ["es2019"], 7 | "outDir": "dist", 8 | "rootDir": ".", 9 | "declaration": true, 10 | "sourceMap": true, 11 | 12 | "strict": true, 13 | 14 | "noUnusedLocals": true, 15 | "noUnusedParameters": true, 16 | "noImplicitReturns": true, 17 | "noFallthroughCasesInSwitch": true, 18 | "esModuleInterop": true, 19 | 20 | "resolveJsonModule": true, /* Include modules imported with '.json' extension */ 21 | "forceConsistentCasingInFileNames": true /* Disallow inconsistently-cased references to the same file. */ 22 | }, 23 | "include": ["lib", "tsup.config.ts"], 24 | } 25 | -------------------------------------------------------------------------------- /tsup.config.ts: -------------------------------------------------------------------------------- 1 | import { defineConfig } from 'tsup'; 2 | import { copyFile } from 'node:fs/promises'; 3 | import { join } from 'node:path'; 4 | 5 | export default defineConfig({ 6 | outDir: 'dist/lib', 7 | entry: ['./lib/index.ts'], 8 | format: ['cjs', 'esm'], 9 | target: 'node18', 10 | sourcemap: true, 11 | clean: true, 12 | minify: false, 13 | shims: true, 14 | dts: true, 15 | async onSuccess () { 16 | await copyFile( 17 | join(__dirname, 'package.json'), 18 | join(__dirname, 'dist/package.json') 19 | ); 20 | } 21 | }); 22 | --------------------------------------------------------------------------------