diff --git a/eslint.config.js b/eslint.config.js index f20a3d9..0b935b3 100644 --- a/eslint.config.js +++ b/eslint.config.js @@ -40,18 +40,12 @@ export default defineConfig([ }, rules: { 'max-len': ['warn', { code: 100 }], - 'no-multi-spaces': [ - 'warn', - { - exceptions: { - VariableDeclarator: true, - Property: true, - ImportAttribute: true, - }, - }, - ], + 'no-multi-spaces': ['off'], 'no-restricted-globals': ['error', ...restrictedGlobals], '@stylistic/dot-location': ['error', 'property'], + '@stylistic/padded-blocks': ['warn', { classes: 'always', blocks: 'never' }], + 'jsdoc/check-indentation': ['warn'], + 'jsdoc/tag-lines': ['warn', 'always', { count: 0, startLines: 1 }], }, }, diff --git a/package-lock.json b/package-lock.json index 982505f..8a0159e 100644 --- a/package-lock.json +++ b/package-lock.json @@ -29,6 +29,7 @@ "eslint-plugin-react": "^7.37.5", "eslint-plugin-react-hooks": "^5.2.0", "globals": "^16.2.0", + "tidy-jsdoc": "^1.4.1", "typescript": "^5.8.3", "typescript-eslint-language-service": "^5.0.5", "vite": "^6.3.5", @@ -1732,6 +1733,24 @@ "dev": true, "license": "MIT" }, + "node_modules/@types/linkify-it": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/@types/linkify-it/-/linkify-it-5.0.0.tgz", + "integrity": "sha512-sVDA58zAw4eWAffKOaQH5/5j3XeayukzDk+ewSsnv3p4yJEZHCCzMDiZM8e0OUrRvmpGZ85jf4yDHkHsgBNr9Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/markdown-it": { + "version": "12.2.3", + "resolved": "https://registry.npmjs.org/@types/markdown-it/-/markdown-it-12.2.3.tgz", + "integrity": "sha512-GKMHFfv3458yYy+v/N8gjufHO6MSZKCOXpZc5GXIWWy8uldwfmPn98vp81gZ5f9SVw8YYBctgfJ22a2d7AOMeQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/linkify-it": "*", + "@types/mdurl": "*" + } + }, "node_modules/@types/mdast": { "version": "4.0.4", "resolved": "https://registry.npmjs.org/@types/mdast/-/mdast-4.0.4.tgz", @@ -1742,6 +1761,13 @@ "@types/unist": "*" } }, + "node_modules/@types/mdurl": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/@types/mdurl/-/mdurl-2.0.0.tgz", + "integrity": "sha512-RGdgjQUZba5p6QEFAVx2OGb8rQDL/cPRG7GiedRzMcJ1tYnUANBncjbSB1NRGwbvjcPeikRABz2nshyPk1bhWg==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/ms": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/@types/ms/-/ms-2.1.0.tgz", @@ -2241,6 +2267,13 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/bluebird": { + "version": "3.7.2", + "resolved": "https://registry.npmjs.org/bluebird/-/bluebird-3.7.2.tgz", + "integrity": "sha512-XpNj6GDQzdfW+r2Wnn7xiSAd7TM3jzkxGXBGTtWKuSXv1xUV+azxAm8jdWZN06QTQk+2N2XB9jRDkvbmQmcRtg==", + "dev": true, + "license": "MIT" + }, "node_modules/boolbase": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/boolbase/-/boolbase-1.0.0.tgz", @@ -2385,6 +2418,19 @@ ], "license": "CC-BY-4.0" }, + "node_modules/catharsis": { + "version": "0.9.0", + "resolved": "https://registry.npmjs.org/catharsis/-/catharsis-0.9.0.tgz", + "integrity": "sha512-prMTQVpcns/tzFgFVkVp6ak6RykZyWb3gu8ckUpd6YkTlacOd3DXGJjIpD4Q6zJirizvaiAjSSHlOsA+6sNh2A==", + "dev": true, + "license": "MIT", + "dependencies": { + "lodash": "^4.17.15" + }, + "engines": { + "node": ">= 10" + } + }, "node_modules/ccount": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/ccount/-/ccount-2.0.1.tgz", @@ -3822,6 +3868,13 @@ "node": ">=0.8.19" } }, + "node_modules/inherits": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.3.tgz", + "integrity": "sha512-x00IRNXNy63jwGkJmzPigoySHbaqpNuzKbBOmzK+g2OdZpQ9w+sxCN+VSB3ja7IAge2OP2qpfxTjeNcyjmW1uw==", + "dev": true, + "license": "ISC" + }, "node_modules/internal-slot": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/internal-slot/-/internal-slot-1.1.0.tgz", @@ -4302,6 +4355,46 @@ "js-yaml": "bin/js-yaml.js" } }, + "node_modules/js2xmlparser": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/js2xmlparser/-/js2xmlparser-4.0.2.tgz", + "integrity": "sha512-6n4D8gLlLf1n5mNLQPRfViYzu9RATblzPEtm1SthMX1Pjao0r9YI9nw7ZIfRxQMERS87mcswrg+r/OYrPRX6jA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "xmlcreate": "^2.0.4" + } + }, + "node_modules/jsdoc": { + "version": "3.6.11", + "resolved": "https://registry.npmjs.org/jsdoc/-/jsdoc-3.6.11.tgz", + "integrity": "sha512-8UCU0TYeIYD9KeLzEcAu2q8N/mx9O3phAGl32nmHlE0LpaJL71mMkP4d+QE5zWfNt50qheHtOZ0qoxVrsX5TUg==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@babel/parser": "^7.9.4", + "@types/markdown-it": "^12.2.3", + "bluebird": "^3.7.2", + "catharsis": "^0.9.0", + "escape-string-regexp": "^2.0.0", + "js2xmlparser": "^4.0.2", + "klaw": "^3.0.0", + "markdown-it": "^12.3.2", + "markdown-it-anchor": "^8.4.1", + "marked": "^4.0.10", + "mkdirp": "^1.0.4", + "requizzle": "^0.2.3", + "strip-json-comments": "^3.1.0", + "taffydb": "2.6.2", + "underscore": "~1.13.2" + }, + "bin": { + "jsdoc": "jsdoc.js" + }, + "engines": { + "node": ">=12.0.0" + } + }, "node_modules/jsdoc-type-pratt-parser": { "version": "4.1.0", "resolved": "https://registry.npmjs.org/jsdoc-type-pratt-parser/-/jsdoc-type-pratt-parser-4.1.0.tgz", @@ -4312,6 +4405,22 @@ "node": ">=12.0.0" } }, + "node_modules/jsdoc/node_modules/escape-string-regexp": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-2.0.0.tgz", + "integrity": "sha512-UpzcLCXolUWcNu5HtVMHYdXJjArjsF9C0aNnquZYY4uW/Vu0miy5YoWvbV345HauVvcAUnpRuhMMcqTcGOY2+w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/jsdoc/node_modules/taffydb": { + "version": "2.6.2", + "resolved": "https://registry.npmjs.org/taffydb/-/taffydb-2.6.2.tgz", + "integrity": "sha512-y3JaeRSplks6NYQuCOj3ZFMO3j60rTwbuKCvZxsAraGYH2epusatvZ0baZYA01WsGqJBq/Dl6vOrMUJqyMj8kA==", + "dev": true + }, "node_modules/jsesc": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz", @@ -4392,6 +4501,16 @@ "json-buffer": "3.0.1" } }, + "node_modules/klaw": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/klaw/-/klaw-3.0.0.tgz", + "integrity": "sha512-0Fo5oir+O9jnXu5EefYbVK+mHMBeEVEy2cmctR1O1NECcCkPRreJKrS6Qt/j3KC2C148Dfo9i3pCmCMsdqGr0g==", + "dev": true, + "license": "MIT", + "dependencies": { + "graceful-fs": "^4.1.9" + } + }, "node_modules/kolorist": { "version": "1.8.0", "resolved": "https://registry.npmjs.org/kolorist/-/kolorist-1.8.0.tgz", @@ -4413,6 +4532,16 @@ "node": ">= 0.8.0" } }, + "node_modules/linkify-it": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/linkify-it/-/linkify-it-3.0.3.tgz", + "integrity": "sha512-ynTsyrFSdE5oZ/O9GEf00kPngmOfVwazR5GKDq6EYfhlpFug3J2zybX56a2PRRpc9P+FuSoGNAwjlbDs9jJBPQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "uc.micro": "^1.0.1" + } + }, "node_modules/locate-path": { "version": "6.0.0", "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-6.0.0.tgz", @@ -4429,6 +4558,13 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/lodash": { + "version": "4.17.21", + "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.21.tgz", + "integrity": "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==", + "dev": true, + "license": "MIT" + }, "node_modules/lodash.merge": { "version": "4.6.2", "resolved": "https://registry.npmjs.org/lodash.merge/-/lodash.merge-4.6.2.tgz", @@ -4480,6 +4616,44 @@ "@jridgewell/sourcemap-codec": "^1.5.0" } }, + "node_modules/markdown-it": { + "version": "12.3.2", + "resolved": "https://registry.npmjs.org/markdown-it/-/markdown-it-12.3.2.tgz", + "integrity": "sha512-TchMembfxfNVpHkbtriWltGWc+m3xszaRD0CZup7GFFhzIgQqxIfn3eGj1yZpfuflzPvfkt611B2Q/Bsk1YnGg==", + "dev": true, + "license": "MIT", + "dependencies": { + "argparse": "^2.0.1", + "entities": "~2.1.0", + "linkify-it": "^3.0.1", + "mdurl": "^1.0.1", + "uc.micro": "^1.0.5" + }, + "bin": { + "markdown-it": "bin/markdown-it.js" + } + }, + "node_modules/markdown-it-anchor": { + "version": "8.6.7", + "resolved": "https://registry.npmjs.org/markdown-it-anchor/-/markdown-it-anchor-8.6.7.tgz", + "integrity": "sha512-FlCHFwNnutLgVTflOYHPW2pPcl2AACqVzExlkGQNsi4CJgqOHN7YTgDd4LuhgN1BFO3TS0vLAruV1Td6dwWPJA==", + "dev": true, + "license": "Unlicense", + "peerDependencies": { + "@types/markdown-it": "*", + "markdown-it": "*" + } + }, + "node_modules/markdown-it/node_modules/entities": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/entities/-/entities-2.1.0.tgz", + "integrity": "sha512-hCx1oky9PFrJ611mf0ifBLBRW8lUUVRlFolb5gWRfIELabBlbp9xZvrqZLZAs+NxFnbfQoeGd8wDkygjg7U85w==", + "dev": true, + "license": "BSD-2-Clause", + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, "node_modules/markdown-table": { "version": "3.0.4", "resolved": "https://registry.npmjs.org/markdown-table/-/markdown-table-3.0.4.tgz", @@ -4491,6 +4665,19 @@ "url": "https://github.com/sponsors/wooorm" } }, + "node_modules/marked": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/marked/-/marked-4.3.0.tgz", + "integrity": "sha512-PRsaiG84bK+AMvxziE/lCFss8juXjNaWzVbN5tXAm4XjeaS9NAHhop+PjQxz2A9h8Q4M/xGmzP8vqNwy6JeK0A==", + "dev": true, + "license": "MIT", + "bin": { + "marked": "bin/marked.js" + }, + "engines": { + "node": ">= 12" + } + }, "node_modules/math-intrinsics": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", @@ -4753,6 +4940,13 @@ "dev": true, "license": "CC0-1.0" }, + "node_modules/mdurl": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/mdurl/-/mdurl-1.0.1.tgz", + "integrity": "sha512-/sKlQJCBYVY9Ers9hqzKou4H6V5UWc/M59TH2dvkt+84itfnq7uFOMLpOiOS4ujvHP4etln18fmIxA5R5fll0g==", + "dev": true, + "license": "MIT" + }, "node_modules/merge2": { "version": "1.4.1", "resolved": "https://registry.npmjs.org/merge2/-/merge2-1.4.1.tgz", @@ -5401,6 +5595,19 @@ "url": "https://github.com/sponsors/isaacs" } }, + "node_modules/mkdirp": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/mkdirp/-/mkdirp-1.0.4.tgz", + "integrity": "sha512-vVqVZQyf3WLx2Shd0qJ9xuvqgAyKPLAiqITEtqW0oIUjzo3PePDd6fW9iFz30ef7Ysp/oiWqbhszeGWW2T6Gzw==", + "dev": true, + "license": "MIT", + "bin": { + "mkdirp": "bin/cmd.js" + }, + "engines": { + "node": ">=10" + } + }, "node_modules/ms": { "version": "2.1.3", "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", @@ -5955,6 +6162,16 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/requizzle": { + "version": "0.2.4", + "resolved": "https://registry.npmjs.org/requizzle/-/requizzle-0.2.4.tgz", + "integrity": "sha512-JRrFk1D4OQ4SqovXOgdav+K8EAhSB/LJZqCz8tbX0KObcdeM15Ss59ozWMBWmmINMagCwmqn4ZNryUGpBsl6Jw==", + "dev": true, + "license": "MIT", + "dependencies": { + "lodash": "^4.17.21" + } + }, "node_modules/resolve": { "version": "2.0.0-next.5", "resolved": "https://registry.npmjs.org/resolve/-/resolve-2.0.0-next.5.tgz", @@ -6527,6 +6744,25 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/taffydb": { + "version": "2.7.3", + "resolved": "https://registry.npmjs.org/taffydb/-/taffydb-2.7.3.tgz", + "integrity": "sha512-GQ3gtYFSOAxSMN/apGtDKKkbJf+8izz5YfbGqIsUc7AMiQOapARZ76dhilRY2h39cynYxBFdafQo5HUL5vgkrg==", + "dev": true, + "license": "BSD-2-Clause" + }, + "node_modules/tidy-jsdoc": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/tidy-jsdoc/-/tidy-jsdoc-1.4.1.tgz", + "integrity": "sha512-FpH1oL6fEMMO0qPPAjoV8peAriwTjdys92TMsfMufrDERDGfmg2w90ieqOQ4RGDH7yuvDTqxR7a0W1Mfun8fzA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "jsdoc": "^3.6.3", + "taffydb": "^2.7.3", + "util": "^0.10.3" + } + }, "node_modules/tiny-invariant": { "version": "1.3.3", "resolved": "https://registry.npmjs.org/tiny-invariant/-/tiny-invariant-1.3.3.tgz", @@ -6722,6 +6958,13 @@ "typescript": ">= 4.0.0" } }, + "node_modules/uc.micro": { + "version": "1.0.6", + "resolved": "https://registry.npmjs.org/uc.micro/-/uc.micro-1.0.6.tgz", + "integrity": "sha512-8Y75pvTYkLJW2hWQHXxoqRgV7qb9B+9vFEtidML+7koHUFapnVJAZ6cKs+Qjz5Aw3aZWHMC6u0wJE3At+nSGwA==", + "dev": true, + "license": "MIT" + }, "node_modules/unbox-primitive": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/unbox-primitive/-/unbox-primitive-1.1.0.tgz", @@ -6741,6 +6984,13 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/underscore": { + "version": "1.13.7", + "resolved": "https://registry.npmjs.org/underscore/-/underscore-1.13.7.tgz", + "integrity": "sha512-GMXzWtsc57XAtguZgaQViUOzs0KTkk8ojr3/xAxXLITqf/3EMwxC0inyETfDFjH/Krbhuep0HNbbjI9i/q3F3g==", + "dev": true, + "license": "MIT" + }, "node_modules/undici-types": { "version": "7.8.0", "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.8.0.tgz", @@ -6861,6 +7111,16 @@ "punycode": "^2.1.0" } }, + "node_modules/util": { + "version": "0.10.4", + "resolved": "https://registry.npmjs.org/util/-/util-0.10.4.tgz", + "integrity": "sha512-0Pm9hTQ3se5ll1XihRic3FDIku70C+iHUdT/W926rSgHV5QgXsYbKZN8MSC3tJtSkhuROzvsQjAaFENRXr+19A==", + "dev": true, + "license": "MIT", + "dependencies": { + "inherits": "2.0.3" + } + }, "node_modules/vite": { "version": "6.3.5", "resolved": "https://registry.npmjs.org/vite/-/vite-6.3.5.tgz", @@ -7277,6 +7537,13 @@ "node": ">=0.10.0" } }, + "node_modules/xmlcreate": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/xmlcreate/-/xmlcreate-2.0.4.tgz", + "integrity": "sha512-nquOebG4sngPmGPICTS5EnxqhKbCmz5Ox5hsszI2T6U5qdrJizBc+0ilYSEjTSzU0yZcmvppztXe/5Al5fUwdg==", + "dev": true, + "license": "Apache-2.0" + }, "node_modules/yallist": { "version": "3.1.1", "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz", diff --git a/package.json b/package.json index b6f1779..74b6d5e 100644 --- a/package.json +++ b/package.json @@ -49,6 +49,7 @@ "eslint-plugin-react": "^7.37.5", "eslint-plugin-react-hooks": "^5.2.0", "globals": "^16.2.0", + "tidy-jsdoc": "^1.4.1", "typescript": "^5.8.3", "typescript-eslint-language-service": "^5.0.5", "vite": "^6.3.5", diff --git a/src/common/async/aborts.js b/src/common/async/aborts.js new file mode 100644 index 0000000..c2088a1 --- /dev/null +++ b/src/common/async/aborts.js @@ -0,0 +1,64 @@ +/** @module common/async */ + +/** + * @typedef TimeoutSignal + * @property {AbortSignal} signal the signal to pass down to blocking operations. + * @property {function(): void} cleanup a function to call when the timeout is no longer necessary + */ + +/** + * Create an abort signal tied to a timeout. + * Replaces `AbortSignal.timeout`, which doesn't consistently abort with a TimeoutError cross env. + * + * @param {number} ms - timeout in milliseconds + * @returns {TimeoutSignal} the timeout signal + */ +export function timeoutSignal(ms) { + const controller = new AbortController() + const timeout = setTimeout(() => { + controller.abort(new DOMException('Operation timed out', 'TimeoutError')) + }, ms) + + const cleanup = () => { + clearTimeout(timeout) + controller.signal.removeEventListener('abort', cleanup) + } + + controller.signal.addEventListener('abort', cleanup) + return { signal: controller.signal, cleanup } +} + +/** + * Create an abort signal that aborts if any of the passed signals aborts. + * Better than managing them manually because we do proper cleanup of non-triggered aborts. + * + * @param {...AbortSignal} signals - the signals to combine + * @returns {AbortSignal} the combined signal + */ +export function combineSignals(...signals) { + const controller = new AbortController() + /** @type { Array } */ + const cleanups = [] + + for (const signal of signals) { + if (signal.aborted) { + controller.abort(signal.reason) + return controller.signal + } + + const handler = () => { + if (!controller.signal.aborted) { + controller.abort(signal.reason) + } + } + + signal.addEventListener('abort', handler) + cleanups.push(() => signal.removeEventListener('abort', handler)) + } + + controller.signal.addEventListener('abort', () => { + cleanups.forEach(cb => cb()) + }) + + return controller.signal +} diff --git a/src/common/async/blocking-atom.js b/src/common/async/blocking-atom.js new file mode 100644 index 0000000..c7728b6 --- /dev/null +++ b/src/common/async/blocking-atom.js @@ -0,0 +1,53 @@ +/** @module common/async */ + +import { Semaphore } from './semaphore.js' + +/** + * simple blocking atom, for waiting for a value. + * cribbed mostly from {@link https://github.com/ComFreek/async-playground} + * + * @template T - the type we're holding + */ +export class BlockingAtom { + + /** @type {T | undefined} */ + #item + + /** @type {Semaphore} */ + #sema + + constructor() { + this.#sema = new Semaphore() + this.#item = undefined + } + + /** + * puts an item into the atom and unblocks an awaiter. + * + * @param {T} item the item to put into the atom + */ + set(item) { + this.#item = item + this.#sema.free() + } + + /** + * tries to get the item from the atom, and blocks until available. + * + * @example + * if (await atom.take()) + * console.log('got it!') + * + * @param {AbortSignal | undefined} signal - an abort signal to cancel the await + * @returns {Promise} a promise for the item, or undefined if something aborted. + */ + async get(signal) { + if (await this.#sema.take(signal)) { + return this.#item + } + + signal?.throwIfAborted() + return undefined + } + +} diff --git a/src/common/async/blocking-queue.js b/src/common/async/blocking-queue.js new file mode 100644 index 0000000..68a0b73 --- /dev/null +++ b/src/common/async/blocking-queue.js @@ -0,0 +1,67 @@ +/** @module common/async */ + +import { Semaphore } from './semaphore.js' + +/** + * simple blocking queue, for turning streams into async pulls. + * cribbed mostly from {@link https://github.com/ComFreek/async-playground} + * + * @template T + */ +export class BlockingQueue { + + /** @type {Semaphore} */ + #sema + + /** @type {T[]} */ + #items + + /** @type {number | undefined} */ + #maxsize + + constructor(maxsize = 1000) { + this.#sema = new Semaphore() + this.#items = [] + this.#maxsize = maxsize ? maxsize : undefined + } + + /** + * place one or more items on the queue, to be picked up by awaiters. + * + * @param {...T} elements the items to place on the queue. + */ + enqueue(...elements) { + for (const el of elements) { + if (this.#maxsize && this.#items.length >= this.#maxsize) { + throw Error('out of room') + } + + this.#items.push(el) + this.#sema.free() + } + } + + /** + * block while waiting for an item off the queue. + * + * @param {AbortSignal} [signal] a signal to use for aborting the block. + * @returns {Promise} the item off the queue; rejects if aborted. + */ + async dequeue(signal) { + if (await this.#sema.take(signal)) { + return this.#poll() + } + + signal?.throwIfAborted() + throw Error('canceled dequeue') + } + + #poll() { + const item = this.#items.length > 0 && this.#items.shift() + if (item) + return item + + throw Error('no elements') + } + +} diff --git a/src/common/async/gate.js b/src/common/async/gate.js new file mode 100644 index 0000000..8ec815e --- /dev/null +++ b/src/common/async/gate.js @@ -0,0 +1,82 @@ +/** @module common/async */ + +/** + * A Breaker, which allows creating wrapped functions which will only be executed before + * the breaker is tripped. + * + * @example + * const breaker = makeBreaker() + * + * state.addEventHandler('finish', breaker.tripThen((e) => { + * // this will only be allowed to run once + * // the second time the event fired, the handler is a no-op + * }) + * + * state.addEventHandler('error', breaker.tripThen((e) => { + * // all wrapped functions created by the same breaker share state + * // so if the above fired, this can never be called + * }) + * + * state.addEventHandler('message', breaker.untilTripped((e) => { + * // this will only be allowed to run many times + * // but not *after* any of the _once_ wrappers has been called + * }) + */ +export class Breaker { + + /** @type {undefined | VoidCallback} */ + #onTripped + + /** @type {boolean} */ + #tripped + + /** + * @param {VoidCallback} [onTripped] + * an optional callback, called when the breaker is tripped, /before/ any wrapped functions. + */ + constructor(onTripped) { + this.#tripped = false + this.#onTripped = onTripped + } + + /** @returns {boolean} true if the breaker has already tripped */ + tripped() { + return this.#tripped + } + + /** + * wrap the given callback in a function that will trip the breaker before it's called. + * any subsequent calls to the wrapped function will be no-ops. + * + * @param {Callback} fn the function to be wrapped in the breaker + * @returns {Callback} a wrapped function, controlled by the breaker + */ + tripThen(fn) { + return (...args) => { + if (!this.#tripped) { + this.#tripped = true + + // TODO: if these throw, what to do? + this.#onTripped?.() + fn(...args) + } + } + } + + /** + * wrap the given callback in a function that check the breaker before it's called. + * once the breaker has been tripped, calls to the wrapped function will be no-ops. + * + * @param {Callback} fn the function to be wrapped in the breaker + * @returns {Callback} a wrapped function, controlled by the breaker + */ + untilTripped(fn) { + return (...args) => { + if (!this.#tripped) { + // TODO: if these throw, what to do? + fn(...args) + } + } + } + +} diff --git a/src/common/async/semaphore.js b/src/common/async/semaphore.js new file mode 100644 index 0000000..9aa7483 --- /dev/null +++ b/src/common/async/semaphore.js @@ -0,0 +1,73 @@ +/** @module common/async */ + +/** + * Simple counting semaphore, for blocking async ops. + * cribbed mostly from {@link https://github.com/ComFreek/async-playground} + */ +export class Semaphore { + + /** @type { number } */ + #counter = 0 + + /** @type {Array} */ + #resolvers = [] + + constructor(count = 0) { + this.#counter = count + } + + /** + * try to take from the semaphore, reducing it's count + * if the semaphore is empty, blocks until available, or the given signal aborts. + * + * @param {AbortSignal | undefined} signal a signal to use to abort the block + * @returns {Promise} true if the semaphore was successfully taken, false if aborted. + */ + take(signal) { + return new Promise((resolve) => { + if (signal?.aborted) return resolve(false) + + // if there's resources available, use them + + this.#counter-- + if (this.#counter >= 0) return resolve(true) + + // otherwise add to pending + // and explicitly remove the resolver from the list on abort + + this.#resolvers.push(resolve) + signal?.addEventListener('abort', () => { + const index = this.#resolvers.indexOf(resolve) + if (index >= 0) { + this.#resolvers.splice(index, 1) + this.#counter++ + } + + resolve(false) + }) + }) + } + + /** + * try to take from the semaphore, reducing it's count, *without blocking*. + * + * @returns {boolean} true if the semaphore was taken, false otherwise. + */ + poll() { + if (this.#counter <= 0) return false + + this.#counter-- + return true + } + + /** announce that the semaphore is free to be taken by another awaiter. */ + free() { + this.#counter++ + + if (this.#resolvers.length > 0) { + const resolver = this.#resolvers.shift() + resolver && queueMicrotask(() => resolver(true)) + } + } + +}