From 651a21a03562c2706de355b50288775bc2c5049e Mon Sep 17 00:00:00 2001 From: Alexander Zinn Date: Sat, 16 Aug 2025 14:17:46 -0400 Subject: [PATCH] Initial Commit --- .gitignore | 175 ++++++++++++++++++ .npmrc | 2 + .nvmrc | 1 + .prettierrc | 12 ++ README | 27 +++ bunfig.toml | 11 ++ eslint.config.js | 41 ++++ examples/.gitignore | 175 ++++++++++++++++++ examples/README.md | 15 ++ examples/bun.lockb | Bin 0 -> 3124 bytes examples/bunfig.toml | 11 ++ examples/package.json | 17 ++ examples/src/example-observables.ts | 13 ++ examples/tsconfig.json | 28 +++ package.json | 56 ++++++ scripts/ci-build.sh | 29 +++ scripts/ci-deploy.sh | 83 +++++++++ scripts/prepare-package-json.sh | 16 ++ src/assertions/assertUnreachable.ts | 3 + src/assertions/index.ts | 3 + src/disposables/Disposable.ts | 23 +++ src/disposables/DisposableList.ts | 17 ++ src/disposables/IDisposable.ts | 3 + src/disposables/index.ts | 6 + src/events/EventEmitter.ts | 23 +++ src/events/EventPublisher.ts | 39 ++++ src/events/IEvent.ts | 4 + src/events/index.ts | 6 + src/functions/index.ts | 3 + src/functions/rangeIterator.ts | 16 ++ src/index.ts | 12 ++ src/maths/Averager.ts | 33 ++++ src/maths/Random.ts | 23 +++ src/maths/index.ts | 4 + src/observables/ReadOnlySubject.ts | 18 ++ src/observables/Subject.ts | 39 ++++ src/observables/index.ts | 4 + src/strings/index.ts | 15 ++ test/assertions/assertUnreachable.test.ts | 10 + test/disposables/Disposable.test.ts | 41 ++++ test/disposables/DisposableList.test.ts | 36 ++++ test/events/EventEmitter.test.ts | 19 ++ test/events/EventPublisher.test.ts | 3 + test/functions/range.test.ts | 20 ++ test/observables/ReadOnlySubject.test.ts | 60 ++++++ test/observables/Subject.test.ts | 55 ++++++ .../WhenGeneratingRandomStrings.test.ts | 39 ++++ tsconfig.d.json | 30 +++ tsconfig.json | 28 +++ 49 files changed, 1347 insertions(+) create mode 100644 .gitignore create mode 100644 .npmrc create mode 100644 .nvmrc create mode 100644 .prettierrc create mode 100644 README create mode 100644 bunfig.toml create mode 100644 eslint.config.js create mode 100644 examples/.gitignore create mode 100644 examples/README.md create mode 100755 examples/bun.lockb create mode 100644 examples/bunfig.toml create mode 100644 examples/package.json create mode 100644 examples/src/example-observables.ts create mode 100644 examples/tsconfig.json create mode 100644 package.json create mode 100755 scripts/ci-build.sh create mode 100755 scripts/ci-deploy.sh create mode 100755 scripts/prepare-package-json.sh create mode 100644 src/assertions/assertUnreachable.ts create mode 100644 src/assertions/index.ts create mode 100644 src/disposables/Disposable.ts create mode 100644 src/disposables/DisposableList.ts create mode 100644 src/disposables/IDisposable.ts create mode 100644 src/disposables/index.ts create mode 100644 src/events/EventEmitter.ts create mode 100644 src/events/EventPublisher.ts create mode 100644 src/events/IEvent.ts create mode 100644 src/events/index.ts create mode 100644 src/functions/index.ts create mode 100644 src/functions/rangeIterator.ts create mode 100644 src/index.ts create mode 100644 src/maths/Averager.ts create mode 100644 src/maths/Random.ts create mode 100644 src/maths/index.ts create mode 100644 src/observables/ReadOnlySubject.ts create mode 100644 src/observables/Subject.ts create mode 100644 src/observables/index.ts create mode 100644 src/strings/index.ts create mode 100644 test/assertions/assertUnreachable.test.ts create mode 100644 test/disposables/Disposable.test.ts create mode 100644 test/disposables/DisposableList.test.ts create mode 100644 test/events/EventEmitter.test.ts create mode 100644 test/events/EventPublisher.test.ts create mode 100644 test/functions/range.test.ts create mode 100644 test/observables/ReadOnlySubject.test.ts create mode 100644 test/observables/Subject.test.ts create mode 100644 test/strings/WhenGeneratingRandomStrings.test.ts create mode 100644 tsconfig.d.json create mode 100644 tsconfig.json diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..9b1ee42 --- /dev/null +++ b/.gitignore @@ -0,0 +1,175 @@ +# Based on https://raw.githubusercontent.com/github/gitignore/main/Node.gitignore + +# Logs + +logs +_.log +npm-debug.log_ +yarn-debug.log* +yarn-error.log* +lerna-debug.log* +.pnpm-debug.log* + +# Caches + +.cache + +# Diagnostic reports (https://nodejs.org/api/report.html) + +report.[0-9]_.[0-9]_.[0-9]_.[0-9]_.json + +# Runtime data + +pids +_.pid +_.seed +*.pid.lock + +# Directory for instrumented libs generated by jscoverage/JSCover + +lib-cov + +# Coverage directory used by tools like istanbul + +coverage +*.lcov + +# nyc test coverage + +.nyc_output + +# Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files) + +.grunt + +# Bower dependency directory (https://bower.io/) + +bower_components + +# node-waf configuration + +.lock-wscript + +# Compiled binary addons (https://nodejs.org/api/addons.html) + +build/Release + +# Dependency directories + +node_modules/ +jspm_packages/ + +# Snowpack dependency directory (https://snowpack.dev/) + +web_modules/ + +# TypeScript cache + +*.tsbuildinfo + +# Optional npm cache directory + +.npm + +# Optional eslint cache + +.eslintcache + +# Optional stylelint cache + +.stylelintcache + +# Microbundle cache + +.rpt2_cache/ +.rts2_cache_cjs/ +.rts2_cache_es/ +.rts2_cache_umd/ + +# Optional REPL history + +.node_repl_history + +# Output of 'npm pack' + +*.tgz + +# Yarn Integrity file + +.yarn-integrity + +# dotenv environment variable files + +.env +.env.development.local +.env.test.local +.env.production.local +.env.local + +# parcel-bundler cache (https://parceljs.org/) + +.parcel-cache + +# Next.js build output + +.next +out + +# Nuxt.js build / generate output + +.nuxt +dist + +# Gatsby files + +# Comment in the public line in if your project uses Gatsby and not Next.js + +# https://nextjs.org/blog/next-9-1#public-directory-support + +# public + +# vuepress build output + +.vuepress/dist + +# vuepress v2.x temp and cache directory + +.temp + +# Docusaurus cache and generated files + +.docusaurus + +# Serverless directories + +.serverless/ + +# FuseBox cache + +.fusebox/ + +# DynamoDB Local files + +.dynamodb/ + +# TernJS port file + +.tern-port + +# Stores VSCode versions used for testing VSCode extensions + +.vscode-test + +# yarn v2 + +.yarn/cache +.yarn/unplugged +.yarn/build-state.yml +.yarn/install-state.gz +.pnp.* + +# IntelliJ based IDEs +.idea + +# Finder (MacOS) folder config +.DS_Store diff --git a/.npmrc b/.npmrc new file mode 100644 index 0000000..69a45c1 --- /dev/null +++ b/.npmrc @@ -0,0 +1,2 @@ +@techniker-me:registry=https://registry-node.techniker.me +//registry-node.techniker.me/:_authToken="${NODE_REGISTRY_AUTH_TOKEN}" \ No newline at end of file diff --git a/.nvmrc b/.nvmrc new file mode 100644 index 0000000..8fdd954 --- /dev/null +++ b/.nvmrc @@ -0,0 +1 @@ +22 \ No newline at end of file diff --git a/.prettierrc b/.prettierrc new file mode 100644 index 0000000..caa814d --- /dev/null +++ b/.prettierrc @@ -0,0 +1,12 @@ +{ + "arrowParens": "avoid", + "bracketSameLine": true, + "bracketSpacing": false, + "printWidth": 160, + "semi": true, + "singleAttributePerLine": false, + "singleQuote": true, + "tabWidth": 2, + "trailingComma": "none", + "useTabs": false +} diff --git a/README b/README new file mode 100644 index 0000000..7d2c25b --- /dev/null +++ b/README @@ -0,0 +1,27 @@ +# @zinntechniker/tools + +A library of useful tools + +### Assertions + +- Assert unreachable + +### Disposables + +- Disposable +- DisposabeList + +### Events + +- EventEmitter +- EventPublisher + +### Maths + +- Averager +- Random + +### Observables + +- Subject +- ReadOnlySubject diff --git a/bunfig.toml b/bunfig.toml new file mode 100644 index 0000000..f836c76 --- /dev/null +++ b/bunfig.toml @@ -0,0 +1,11 @@ +telemetry = false + +[install] +exact = true + +[install.lockfile] +save = false + +[test] +coverage = true +coverageSkipTestFiles = true \ No newline at end of file diff --git a/eslint.config.js b/eslint.config.js new file mode 100644 index 0000000..c16afea --- /dev/null +++ b/eslint.config.js @@ -0,0 +1,41 @@ +import globals from 'globals'; +import pluginJs from '@eslint/js'; +import tseslint from 'typescript-eslint'; + +export default [ + { + ...tseslint.configs.recommended, + ...pluginJs.configs.recommended, + ignores: [ + // Existing ignores + './test/', + 'node_modules/**', // Explicitly ignore node_modules and subdirs + // Add Bun-specific cache ignores (adjust paths if needed) + '**/.bun/**', // Covers Bun's cache dirs + '/home/teamcity-agent/.bun/**', // Absolute path for CI (if known; customize for your agent) + // Other common ignores + '**/dist/**', // Build outputs + '**/build/**', + '**/temp/**', + '**/cache/**', + '**/*.min.js', // Minified files + ], + files: ['./src/**/*.{ts,js,tsx,jsx}'], // Expanded to include JSX if needed; keeps it scoped to src + languageOptions: { + globals: { ...globals.browser, ...globals.node }, + parserOptions: { + ecmaVersion: 'latest', // Ensure modern JS support + sourceType: 'module', + }, + }, + rules: { + // Optional: Suppress specific warnings seen in log (e.g., unused disables) + 'no-unused-vars': 'warn', // Downgrade if needed + // Add rules to handle common log issues (customize as needed) + '@typescript-eslint/no-non-null-assertion': 'off', // Temporarily disable if causing many errors + '@typescript-eslint/no-unsafe-enum-comparison': 'off', + // Ignore undefined rules in third-party code + 'eslint-plugin/no-property-in-node': 'off', + }, + }, +]; diff --git a/examples/.gitignore b/examples/.gitignore new file mode 100644 index 0000000..9b1ee42 --- /dev/null +++ b/examples/.gitignore @@ -0,0 +1,175 @@ +# Based on https://raw.githubusercontent.com/github/gitignore/main/Node.gitignore + +# Logs + +logs +_.log +npm-debug.log_ +yarn-debug.log* +yarn-error.log* +lerna-debug.log* +.pnpm-debug.log* + +# Caches + +.cache + +# Diagnostic reports (https://nodejs.org/api/report.html) + +report.[0-9]_.[0-9]_.[0-9]_.[0-9]_.json + +# Runtime data + +pids +_.pid +_.seed +*.pid.lock + +# Directory for instrumented libs generated by jscoverage/JSCover + +lib-cov + +# Coverage directory used by tools like istanbul + +coverage +*.lcov + +# nyc test coverage + +.nyc_output + +# Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files) + +.grunt + +# Bower dependency directory (https://bower.io/) + +bower_components + +# node-waf configuration + +.lock-wscript + +# Compiled binary addons (https://nodejs.org/api/addons.html) + +build/Release + +# Dependency directories + +node_modules/ +jspm_packages/ + +# Snowpack dependency directory (https://snowpack.dev/) + +web_modules/ + +# TypeScript cache + +*.tsbuildinfo + +# Optional npm cache directory + +.npm + +# Optional eslint cache + +.eslintcache + +# Optional stylelint cache + +.stylelintcache + +# Microbundle cache + +.rpt2_cache/ +.rts2_cache_cjs/ +.rts2_cache_es/ +.rts2_cache_umd/ + +# Optional REPL history + +.node_repl_history + +# Output of 'npm pack' + +*.tgz + +# Yarn Integrity file + +.yarn-integrity + +# dotenv environment variable files + +.env +.env.development.local +.env.test.local +.env.production.local +.env.local + +# parcel-bundler cache (https://parceljs.org/) + +.parcel-cache + +# Next.js build output + +.next +out + +# Nuxt.js build / generate output + +.nuxt +dist + +# Gatsby files + +# Comment in the public line in if your project uses Gatsby and not Next.js + +# https://nextjs.org/blog/next-9-1#public-directory-support + +# public + +# vuepress build output + +.vuepress/dist + +# vuepress v2.x temp and cache directory + +.temp + +# Docusaurus cache and generated files + +.docusaurus + +# Serverless directories + +.serverless/ + +# FuseBox cache + +.fusebox/ + +# DynamoDB Local files + +.dynamodb/ + +# TernJS port file + +.tern-port + +# Stores VSCode versions used for testing VSCode extensions + +.vscode-test + +# yarn v2 + +.yarn/cache +.yarn/unplugged +.yarn/build-state.yml +.yarn/install-state.gz +.pnp.* + +# IntelliJ based IDEs +.idea + +# Finder (MacOS) folder config +.DS_Store diff --git a/examples/README.md b/examples/README.md new file mode 100644 index 0000000..cc840fd --- /dev/null +++ b/examples/README.md @@ -0,0 +1,15 @@ +# examples + +To install dependencies: + +```bash +bun install +``` + +To run: + +```bash +bun run index.ts +``` + +This project was created using `bun init` in bun v1.1.20. [Bun](https://bun.sh) is a fast all-in-one JavaScript runtime. diff --git a/examples/bun.lockb b/examples/bun.lockb new file mode 100755 index 0000000000000000000000000000000000000000..2ce339ffb435dd7f623799053910ffdfe387c653 GIT binary patch literal 3124 zcmd5;4Nz276n+Z};-aX8Fgj_AKbXM2{ecC9Wl2m2qaOhWN0Vw<-tInFcHzAT3+UA1 zs8OS#P_)A|GPGzWmf@5V4*fuoqBIPVQ&}c7Ns=NqQaa91=e~Ux-Z%-2HDhPz&bjx! z^L=~nIrrUjmWC>eSVoym+mw{8Se{*Elfh6}?Z!M4ZKV_@JCkqV6vZl;7eNp=dauV1 zSdU*d2e;2xWEi=1C#3GiV_80#N4t8?-2Snz!5@r3W_T3gcb9ew>pW~gP$h=^0xQMJ z;0+AtdeAdL*MJ5x=LOKSK<^cvgW~PJanQ$VcD}Q&%XejKs;d0vwyWmuiooBN>{o8t z8}?D+s+o3O=TKVhDfd}dXfH=?q3Lb~%hy$&09*{=s@q-5Q`@@aR8AC+>r2wCV@bHSMrOhtc z-q=k&bnx6_c8%H^w)Wk@;aH0N(*NuN-R7^_xWj$^v){~Zb(=a$6(@@$W(OR)09eO@WR-7Brr)zBh8SNApTaB&n?|%S-oOv25E;S7Y`1!47BJcK`DS?BOC_ah{}2XW9q*=ogZTI!hu}dv)%6 zrX{_pFDdp_%3|0PRQm(h&=%9fzNByDETL(t4$1y@R$u9&qrLStK90gI5w#*-DRvgL zG;G73gu!|)S#~6?u=?#sGMz!0mZ)nT3FngfM~k_}*uzbsmp8oeboVl4>rU6^QlG8L zitWd%LjUalGJo0jK-uLJGm)HH$ zJWDyJB{XH_K=zYSV^udkPOR@)Q+tK*cDhExE33OhzPlsh#pevUEgf5U@pP)&vPgCP zXqWr_(c!GKy_G}rQq#&W^!eV-*}dz)s*I(Y{_e$3NgjWApgLi9fZLSld+G9ubAgRB z$R$CA!26&C#~thY@#6uye?RU%D9i`KUG#;ZWjdBIlC+KG3|1?ND=xW_p(wpvqmr|9 z8D%#`%A@55D{Z#fO{hj98T-p4XpYX|h(PEklv|muC`zLT+&vgjzR>-EzGakm^o_&` z*p1WV!1pV@dodT%6#|DI3ON#)?paMtR#6=6DrVd#_1(Mj)OB2^=SrT_k}6WgKjkYv zuJnFhRs%<|96{h-0oQ&niJF98EJ5II0at%7$%}EMmQS)APWXqwA z^psAC&45O{RPc${BC@I7aIk=OunPWwsriubx%1>}68uC8fM8+)f378?5RY)k{&(tc D3zS4O literal 0 HcmV?d00001 diff --git a/examples/bunfig.toml b/examples/bunfig.toml new file mode 100644 index 0000000..8fd35a9 --- /dev/null +++ b/examples/bunfig.toml @@ -0,0 +1,11 @@ +telemetry = false + +[install] +exact = true + +[install.lockfile] +save = false + +[test] +coverage = true +coverageSkipTestFiles = true diff --git a/examples/package.json b/examples/package.json new file mode 100644 index 0000000..82a3c54 --- /dev/null +++ b/examples/package.json @@ -0,0 +1,17 @@ +{ + "name": "@zinntechniker/tools-examples", + "version": "0.0.1", + "type": "module", + "author": { + "name": "Alexander Zinn", + "git+url": "https://github.com/zinntechniker/tools" + }, + "devDependencies": { + "@types/bun": "latest", + "@zinntechniker/tools": "latest", + "typescript": "5.5.2" + }, + "publishConfig": { + "registry": "https://registry-node.techniker.me" + } +} diff --git a/examples/src/example-observables.ts b/examples/src/example-observables.ts new file mode 100644 index 0000000..906e3d7 --- /dev/null +++ b/examples/src/example-observables.ts @@ -0,0 +1,13 @@ +import Tools from '@zinntechniker/tools'; + +const { + observables: {Subject, ReadOnlySubject} +} = Tools; + +const subject = new Subject(0); +const subjectSubscriber = (value: number) => { + console.log("Subject's value changed to [%o]", value); +}; +subject.subscribe(subjectSubscriber); + +subject.value = 12; diff --git a/examples/tsconfig.json b/examples/tsconfig.json new file mode 100644 index 0000000..7c7361f --- /dev/null +++ b/examples/tsconfig.json @@ -0,0 +1,28 @@ +{ + "compilerOptions": { + // Enable latest features + "lib": ["ESNext", "DOM"], + "target": "ESNext", + "module": "ESNext", + "moduleDetection": "force", + "allowJs": true, + + // Bundler mode + "moduleResolution": "bundler", + "allowImportingTsExtensions": true, + "verbatimModuleSyntax": true, + "noEmit": true, + + // Best practices + "strict": true, + "skipLibCheck": true, + "noFallthroughCasesInSwitch": true, + + // Some stricter flags (disabled by default) + "noUnusedLocals": true, + "noUnusedParameters": true, + "noPropertyAccessFromIndexSignature": false + }, + "include": ["src"], + "exclude": ["./dist", "./test"] +} diff --git a/package.json b/package.json new file mode 100644 index 0000000..73c6f0a --- /dev/null +++ b/package.json @@ -0,0 +1,56 @@ +{ + "name": "@techniker-me/tools", + "version": "2025.0.16", + "type": "module", + "private": false, + "types": "./dist/types/index.d.ts", + "exports": { + "types": "./dist/types/index.d.ts", + "import": "./dist/node/index.js", + "browser": "./dist/browser/index.js", + "default": "./dist/node/index.js" + }, + "author": { + "name": "Alexander Zinn", + "git+url": "https://git.techniker.me/techniker-me/tools.git" + }, + "publishConfig": { + "registry": "http://localhost:7873" + }, + "scripts": { + "ci-install": "bun install", + "ci-test": "bun test", + "ci-build": "bash scripts/ci-build.sh", + "ci-deploy:ga": "bash scripts/ci-deploy.sh --beta false", + "ci-deploy:beta": "bash scripts/ci-deploy.sh --beta true", + "lint": "eslint", + "lint:fix": "eslint --fix", + "test": "bun test", + "build:node:debug": "bun build ./src/index.ts --target=node --sourcemap=none --format=esm --sourcemap=inline --outdir=dist/node", + "build:browser:debug": "bun build ./src/index.ts --target=browser --sourcemap=none --format=esm --sourcemap=inline --outdir=dist/browser", + "build:node": "bun build ./src/index.ts --target=node --sourcemap=none --format=esm --splitting --minify --outdir=dist/node", + "build:browser": "bun build ./src/index.ts --target=browser --sourcemap=none --format=esm --splitting --minify --outdir=dist/browser", + "build:types": "bunx tsc -p tsconfig.d.json", + "build:prepare-package-json": "bash scripts/prepare-package-json.sh" + }, + "devDependencies": { + "@eslint/js": "8.57.1", + "@types/bun": "latest", + "@types/node": "22.5.2", + "@typescript-eslint/eslint-plugin": "8.37.0", + "@typescript-eslint/parser": "8.37.0", + "eslint": "8.57.1", + "globals": "15.9.0", + "prettier": "3.3.3", + "typescript": "5.5.4", + "typescript-eslint": "8.4.0" + }, + "files": [ + "dist", + "browser", + "node", + "types", + "package.json", + "README.md" + ] +} diff --git a/scripts/ci-build.sh b/scripts/ci-build.sh new file mode 100755 index 0000000..b8df7be --- /dev/null +++ b/scripts/ci-build.sh @@ -0,0 +1,29 @@ +#! /usr/bin/env bash + +set -o errexit +set -o nounset +set -o pipefail + +distDirectory=${DIST_DIRECTORY:-"dist"} + +rm -rf ${distDirectory} + +bun run lint +bun run build:node +bun run build:browser +bun run build:types +bun run build:prepare-package-json + +echo "Copying [.npmrc] to [${distDirectory}]" +cp .npmrc ./${distDirectory} + +echo "Copying [.nvmrc] to [${distDirectory}]" +cp .nvmrc ./${distDirectory} + +echo "Copying [README.md] to [${distDirectory}]" +cp README ./${distDirectory} + +ls ${distDirectory} + +echo -e "\nci-build complete!" +exit 0 diff --git a/scripts/ci-deploy.sh b/scripts/ci-deploy.sh new file mode 100755 index 0000000..88b7d3f --- /dev/null +++ b/scripts/ci-deploy.sh @@ -0,0 +1,83 @@ +#!/usr/bin/env bash + +set -o errexit +set -o nounset +set -o pipefail + + +registryUrl="http://localhost:4873" +# registryUrl="https://registry-node.techniker.me" +packageVersionToDeploy="" +isBeta="false" + +while [[ "${#}" -gt 0 ]]; do + case "${1}" in + --beta) + isBeta="${2}" + shift 2 + ;; + --version) + packageVersionToDeploy="${2}" + shift 2 + ;; + *) + echo "Unknown option [${1}]" + exit "${LINENO}" + ;; + esac +done + +function cleanDirectory { + local directory="${1}" + + if [ -d "${directory}" ]; then + echo "Deleting [${directory}]..." + + rm -rf "${directory}" + fi +} + +function removePackageJsonMember { + local packageJsonPath="dist/package.json" + local memberToRemove="${1}" + + if [ -f "${packageJsonPath}" ]; then + echo "Removing [${memberToRemove}] from the dist/package.json" + + jq "del(.${memberToRemove})" "${packageJsonPath}" >tmp.$$.json && mv tmp.$$.json "$packageJsonPath" + else + echo "Error: [${packageJsonPath}] not found." + fi +} + +function updatePackageJsonVersion { + local versionToUpdate="${1}" + + if [ isBeta == "true" ]; then + echo "Version to update [${versionToUpdate}] Contains beta" + echo "Updating package.json version to [${versionToUpdate}]" + + local packageJsonVersion=$(jq -r '.version' package.json) + + sed -i "s/\"version\": \"${packageJsonVersion}\"/\"version\": \"${versionToUpdate}\"/" dist/package.json + fi +} + +echo "Deploying [${packageVersionToDeploy}]" +echo "isBeta [${isBeta}]" + +cleanDirectory "dist" + +bun run ci-build + +removePackageJsonMember "devDependencies" +removePackageJsonMember "scripts" + +echo "publishing to ${registryUrl}" +if [ "${isBeta}" == "true" ]; then + updatePackageJsonVersion "${packageVersionToDeploy}" + npm publish --registry "${registryUrl}" --tag beta +else + npm publish --registry "${registryUrl}" +fi + diff --git a/scripts/prepare-package-json.sh b/scripts/prepare-package-json.sh new file mode 100755 index 0000000..a516f9a --- /dev/null +++ b/scripts/prepare-package-json.sh @@ -0,0 +1,16 @@ +#!/usr/bin/env bash + +set -o errexit +set -o nounset +set -o pipefail + +distDirectory=${DIST_DIRECTORY:-"dist"} +packageJsonPath=${PACKAGE_JSON_PATH:-"package.json"} + +if [ ! -d "${distDirectory}" ]; then + echo "Unable to prepare package.json, [${distDirectory}] not found" + exit $LINENO +fi + +echo "Preparing [package.json] to [${distDirectory}]" +jq '{name, version, author, type, types, exports, files, publishConfig}' "${packageJsonPath}" > "${distDirectory}/package.json" diff --git a/src/assertions/assertUnreachable.ts b/src/assertions/assertUnreachable.ts new file mode 100644 index 0000000..0e58f35 --- /dev/null +++ b/src/assertions/assertUnreachable.ts @@ -0,0 +1,3 @@ +export default function assertUnreachable(never: never): never { + throw new Error(`Should not have reached [${never}]`); +} diff --git a/src/assertions/index.ts b/src/assertions/index.ts new file mode 100644 index 0000000..db703d9 --- /dev/null +++ b/src/assertions/index.ts @@ -0,0 +1,3 @@ +import assertUnreachable from './assertUnreachable'; + +export {assertUnreachable}; diff --git a/src/disposables/Disposable.ts b/src/disposables/Disposable.ts new file mode 100644 index 0000000..0e3bfb1 --- /dev/null +++ b/src/disposables/Disposable.ts @@ -0,0 +1,23 @@ +import type IDisposable from './IDisposable'; + +export default class Disposable implements IDisposable { + private _isDisposed = false; + private _disposable: () => void; + + constructor(disposable: () => void) { + this._disposable = disposable; + } + + get isDisposed(): boolean { + return this._isDisposed; + } + + public dispose(): void { + if (this._isDisposed) { + return; + } + + this._disposable(); + this._isDisposed = true; + } +} diff --git a/src/disposables/DisposableList.ts b/src/disposables/DisposableList.ts new file mode 100644 index 0000000..98e311f --- /dev/null +++ b/src/disposables/DisposableList.ts @@ -0,0 +1,17 @@ +import type IDisposable from './IDisposable'; + +export default class DisposableList implements IDisposable { + private readonly _disposables: IDisposable[] = []; + + public add(disposable: IDisposable): void { + this._disposables.push(disposable); + } + + public dispose(): void { + while (this._disposables.length) { + const disposable = this._disposables.shift() as IDisposable; + + disposable.dispose(); + } + } +} diff --git a/src/disposables/IDisposable.ts b/src/disposables/IDisposable.ts new file mode 100644 index 0000000..8917250 --- /dev/null +++ b/src/disposables/IDisposable.ts @@ -0,0 +1,3 @@ +export default interface IDisposable { + dispose(): void; +} diff --git a/src/disposables/index.ts b/src/disposables/index.ts new file mode 100644 index 0000000..3665977 --- /dev/null +++ b/src/disposables/index.ts @@ -0,0 +1,6 @@ +import type IDisposable from './IDisposable'; +import Disposable from './Disposable'; +import DisposableList from './DisposableList'; + +export type {IDisposable}; +export {Disposable, DisposableList}; diff --git a/src/events/EventEmitter.ts b/src/events/EventEmitter.ts new file mode 100644 index 0000000..c55ef2e --- /dev/null +++ b/src/events/EventEmitter.ts @@ -0,0 +1,23 @@ +import type IDisposable from '../disposables/IDisposable'; +import type IEvent from './IEvent'; +import {Disposable} from '../disposables'; + +export default class EventEmitter implements IDisposable { + private readonly _listeners: Array<(event: IEvent) => void> = []; + + public subscribe(listener: (event: IEvent) => void): Disposable { + const listenerIndex = this._listeners.push(listener); + + return new Disposable(() => this._listeners.splice(listenerIndex - 1, 1)); + } + + public emit>(event: Event): void { + this._listeners.forEach(listener => listener(event)); + } + + public dispose(): void { + while (this._listeners.length) { + this._listeners.shift(); + } + } +} diff --git a/src/events/EventPublisher.ts b/src/events/EventPublisher.ts new file mode 100644 index 0000000..b23db68 --- /dev/null +++ b/src/events/EventPublisher.ts @@ -0,0 +1,39 @@ +import type IEvent from './IEvent'; +import {Disposable, DisposableList} from '../disposables'; +import EventEmitter from './EventEmitter'; + +export default class EventPublisher { + private readonly _eventEmitters: Record> = {}; + private readonly _disposables = new DisposableList(); + + constructor() { + this._eventEmitters['no-subscribers'] = new EventEmitter(); + } + + public addNoSubscriberHandler(handler: (event: IEvent) => Promise | void) { + return this._eventEmitters['no-subscribers'].subscribe(handler); + } + + public subscribe(event: string | number, handler: (event: IEvent) => Promise | void): Disposable { + const emitter = this._eventEmitters[event] ?? this.createEmitter(event); + const subscription = emitter.subscribe(handler); + + this._disposables.add(subscription); + + return subscription; + } + + public publish(eventName: string | number, event: IEvent): void { + (this._eventEmitters[eventName] || this._eventEmitters['no-subscribers']).emit>(event); + } + + public dispose(): void { + Object.values(this._eventEmitters).forEach(emitter => emitter.dispose()); + } + + private createEmitter(event: string | number): EventEmitter> { + const eventEmitter = new EventEmitter>(); + + return (this._eventEmitters[event] = eventEmitter); + } +} diff --git a/src/events/IEvent.ts b/src/events/IEvent.ts new file mode 100644 index 0000000..4d6b6f3 --- /dev/null +++ b/src/events/IEvent.ts @@ -0,0 +1,4 @@ +export default interface IEvent { + type: string; + payload?: Payload; +} diff --git a/src/events/index.ts b/src/events/index.ts new file mode 100644 index 0000000..3c2f61a --- /dev/null +++ b/src/events/index.ts @@ -0,0 +1,6 @@ +import type IEvent from './IEvent'; +import EventEmitter from './EventEmitter'; +import EventPublisher from './EventPublisher'; + +export type {IEvent}; +export {EventEmitter, EventPublisher}; diff --git a/src/functions/index.ts b/src/functions/index.ts new file mode 100644 index 0000000..f941cb4 --- /dev/null +++ b/src/functions/index.ts @@ -0,0 +1,3 @@ +import {createRangeIterator} from './rangeIterator'; + +export {createRangeIterator}; diff --git a/src/functions/rangeIterator.ts b/src/functions/rangeIterator.ts new file mode 100644 index 0000000..48e4ee4 --- /dev/null +++ b/src/functions/rangeIterator.ts @@ -0,0 +1,16 @@ +export function createRangeIterator(start: number, end: number, step = 1) { + return { + [Symbol.iterator]() { + return this; + }, + next() { + if (start < end) { + start = start + step; + + return {value: start, done: false}; + } + + return {value: end, done: true}; + } + }; +} diff --git a/src/index.ts b/src/index.ts new file mode 100644 index 0000000..6b5be9e --- /dev/null +++ b/src/index.ts @@ -0,0 +1,12 @@ +import type IDisposable from './disposables/IDisposable'; +import type IEvent from './events/IEvent'; + +export type {IDisposable, IEvent}; + +export {assertUnreachable} from './assertions'; +export {Disposable, DisposableList} from './disposables'; +export {EventEmitter, EventPublisher} from './events'; +export {Averager, Random} from './maths'; +export {Subject, ReadOnlySubject} from './observables'; +export {Strings} from './strings'; +export {createRangeIterator} from './functions'; diff --git a/src/maths/Averager.ts b/src/maths/Averager.ts new file mode 100644 index 0000000..60b7497 --- /dev/null +++ b/src/maths/Averager.ts @@ -0,0 +1,33 @@ +import {Subject, ReadOnlySubject} from '../observables'; + +export default class Averager { + private readonly _sampleSize: number; + private readonly _samples: number[] = []; + private readonly _average = new Subject(0); + private readonly _readOnlyAverage = new ReadOnlySubject(this._average); + private _onPush: (sample: number) => void = this.onPushOnly.bind(this); + + constructor(sampleSize: number) { + this._sampleSize = sampleSize; + } + + get average(): ReadOnlySubject { + return this._readOnlyAverage; + } + + public push(value: number): void { + this._onPush(value); + } + + private onPushOnly(sample: number) { + if (this._samples.push(sample) === this._sampleSize) { + this._onPush = this.pushAndCalculateAverage.bind(this); + } + } + + private pushAndCalculateAverage(sample: number) { + this._samples.push(sample); + this._samples.shift(); + this._average.value = this._samples.reduce((sum, sampleValue) => sum + sampleValue, 0) / this._sampleSize; + } +} diff --git a/src/maths/Random.ts b/src/maths/Random.ts new file mode 100644 index 0000000..70adeba --- /dev/null +++ b/src/maths/Random.ts @@ -0,0 +1,23 @@ +export default class Random { + private static readonly lowercaseLetters = 'abcdefghijklmnopqrstuvwxyz'; + private static readonly uppercaseLatters = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ'; + private static readonly letters = this.lowercaseLetters + this.uppercaseLatters; + + public static number(): number { + return Math.random(); + } + + public static numberInRange(min: number, max: number): number { + return Math.floor(Math.random() * (max - min + 1)) + min; + } + + public static string(length: number): string { + let result = ''; + + for (let idx = 0; idx < length; idx++) { + result += this.letters[this.numberInRange(0, this.letters.length - 1)]; + } + + return result; + } +} diff --git a/src/maths/index.ts b/src/maths/index.ts new file mode 100644 index 0000000..fee2ea8 --- /dev/null +++ b/src/maths/index.ts @@ -0,0 +1,4 @@ +import Averager from './Averager'; +import Random from './Random'; + +export {Averager, Random}; diff --git a/src/observables/ReadOnlySubject.ts b/src/observables/ReadOnlySubject.ts new file mode 100644 index 0000000..72242d8 --- /dev/null +++ b/src/observables/ReadOnlySubject.ts @@ -0,0 +1,18 @@ +import Disposable from '../disposables/Disposable'; +import Subject from './Subject'; + +export default class ReadOnlySubject { + private readonly _subject: Subject; + + constructor(subject: Subject) { + this._subject = subject; + } + + get value(): T { + return this._subject.value; + } + + public subscribe(listener: (value: T) => void): Disposable { + return this._subject.subscribe(listener); + } +} diff --git a/src/observables/Subject.ts b/src/observables/Subject.ts new file mode 100644 index 0000000..08f2da4 --- /dev/null +++ b/src/observables/Subject.ts @@ -0,0 +1,39 @@ +import Disposable from '../disposables/Disposable'; + +export default class Subject { + private readonly _listeners: Array<(value: T) => void> = []; + private _value: T; + + constructor(value: T) { + this._value = value; + } + + set value(value: T) { + if (this._value === value) { + return; + } + + this._value = value; + this.notifyListeners(value); + } + + get value(): T { + return this._value; + } + + public subscribe(listener: (value: T) => void): Disposable { + const listenerIndex = this._listeners.push(listener); + + listener(this.value); + + return new Disposable(() => { + this._listeners.splice(listenerIndex, 1); + }); + } + + private notifyListeners(value: T): void { + for (const listener of this._listeners) { + listener(value); + } + } +} diff --git a/src/observables/index.ts b/src/observables/index.ts new file mode 100644 index 0000000..fae5a89 --- /dev/null +++ b/src/observables/index.ts @@ -0,0 +1,4 @@ +import Subject from './Subject'; +import ReadOnlySubject from './ReadOnlySubject'; + +export {Subject, ReadOnlySubject}; diff --git a/src/strings/index.ts b/src/strings/index.ts new file mode 100644 index 0000000..424e184 --- /dev/null +++ b/src/strings/index.ts @@ -0,0 +1,15 @@ +export class Strings { + static random(length = 8) { + if (length > 11) { + throw new Error(`[Strings] max length [11]`); + } + + return Math.random() + .toString(36) + .substring(2, length + 2); + } + + constructor() { + throw new Error('Strings is a static class that may not be instantiated'); + } +} diff --git a/test/assertions/assertUnreachable.test.ts b/test/assertions/assertUnreachable.test.ts new file mode 100644 index 0000000..bd22451 --- /dev/null +++ b/test/assertions/assertUnreachable.test.ts @@ -0,0 +1,10 @@ +import {describe, it, expect} from 'bun:test'; +import {assertUnreachable} from '../../src/assertions'; + +describe(`When asserting unreachable code`, () => { + it(`throws an error`, () => { + const value = 'NEVER_VALUE'; + + expect(() => assertUnreachable(value)).toThrowError(); + }); +}); diff --git a/test/disposables/Disposable.test.ts b/test/disposables/Disposable.test.ts new file mode 100644 index 0000000..6439443 --- /dev/null +++ b/test/disposables/Disposable.test.ts @@ -0,0 +1,41 @@ +import type {Mock} from 'bun:test'; +import {mock, describe, it, beforeEach, expect} from 'bun:test'; +import {Disposable} from '../../src/disposables'; + +describe(`When using a Disposable`, () => { + describe(`Given a Disposable`, () => { + let mockFunction: Mock; + let disposable: Disposable; + + beforeEach(() => { + mockFunction = mock(() => {}); + disposable = new Disposable(mockFunction); + }); + + describe(`Given the disposable is disposed`, () => { + beforeEach(() => { + disposable.dispose(); + }); + + it(`executes the disposble`, () => { + expect(mockFunction).toHaveBeenCalled(); + }); + + it(`marks the disposbale disposed`, () => { + expect(disposable.isDisposed).toBeTrue(); + }); + + describe(`Given the disposable is repeatedly disposed`, () => { + beforeEach(() => { + for (let count = 0; count < 10; count++) { + disposable.dispose(); + } + }); + + it(`disposes the disposable only once`, () => { + expect(mockFunction).toHaveBeenCalledTimes(1); + }); + }); + }); + }); +}); diff --git a/test/disposables/DisposableList.test.ts b/test/disposables/DisposableList.test.ts new file mode 100644 index 0000000..e1782c7 --- /dev/null +++ b/test/disposables/DisposableList.test.ts @@ -0,0 +1,36 @@ +import type {Mock} from 'bun:test'; +import {mock, describe, it, beforeAll, beforeEach, expect} from 'bun:test'; +import {Disposable, DisposableList} from '../../src/disposables'; + +describe(`When using a DisposableList`, () => { + let disposableList: DisposableList; + + beforeEach(() => { + disposableList = new DisposableList(); + }); + + describe(`Given a disposbale list with multiple disposables`, () => { + let mocks: Array>; + let mockDisposables: Disposable[]; + + beforeEach(() => { + mocks = [mock(() => {}), mock(() => {}), mock(() => {}), mock(() => {})]; + mockDisposables = mocks.map(mock => new Disposable(mock)); + mockDisposables.forEach(mockDisposable => disposableList.add(mockDisposable)); + }); + + describe(`Given the disposable list is disposed`, () => { + beforeEach(() => { + disposableList.dispose(); + }); + + it(`disposes all of the disposables in the list`, () => { + mocks.forEach(mock => expect(mock).toHaveBeenCalled()); + }); + + it(`marks the disposable as disposed`, () => { + mockDisposables.forEach(disposable => expect(disposable.isDisposed).toBeTrue()); + }); + }); + }); +}); diff --git a/test/events/EventEmitter.test.ts b/test/events/EventEmitter.test.ts new file mode 100644 index 0000000..03b994c --- /dev/null +++ b/test/events/EventEmitter.test.ts @@ -0,0 +1,19 @@ +import {afterAll, beforeEach, describe, expect, it, mock} from 'bun:test'; +import {EventEmitter} from '../../src/events'; + +describe(`When emitting an event`, () => { + const eventEmitter: EventEmitter = new EventEmitter(); + + describe(`Given a callback is subscribed`, () => { + const mockListener = mock(() => undefined); + const subscription = eventEmitter.subscribe(mockListener); + + describe(`Given an event is emitted`, () => { + beforeEach(() => eventEmitter.emit('mock event')); + + it(`notifies the callback with the event`, () => expect(mockListener).toHaveBeenCalled()); + }); + + afterAll(() => subscription.dispose()); + }); +}); diff --git a/test/events/EventPublisher.test.ts b/test/events/EventPublisher.test.ts new file mode 100644 index 0000000..34804ba --- /dev/null +++ b/test/events/EventPublisher.test.ts @@ -0,0 +1,3 @@ +import {describe} from 'bun:test'; + +describe.todo(`When publishing events`); diff --git a/test/functions/range.test.ts b/test/functions/range.test.ts new file mode 100644 index 0000000..3497f15 --- /dev/null +++ b/test/functions/range.test.ts @@ -0,0 +1,20 @@ +import {describe, it, expect, mock} from 'bun:test'; +import {createRangeIterator} from '../../src/functions'; + +describe('When generating a range iterator', () => { + describe('Given start and stop values', () => { + const start = 1; + const stop = 10; + const mockFn = mock(() => undefined); + + it('generates an iterator of the correct length', () => { + const rangeIterator = createRangeIterator(1, 10); + + for (const idx of rangeIterator) { + mockFn(); + } + + expect(mockFn).toHaveBeenCalledTimes(9); + }); + }); +}); diff --git a/test/observables/ReadOnlySubject.test.ts b/test/observables/ReadOnlySubject.test.ts new file mode 100644 index 0000000..bb3ed57 --- /dev/null +++ b/test/observables/ReadOnlySubject.test.ts @@ -0,0 +1,60 @@ +import {jest, describe, it, expect} from 'bun:test'; +import {Subject, ReadOnlySubject} from '../../src/observables'; + +describe(`A ReadOnlySubject`, () => { + it(`exposes the Subject value`, () => { + const subject = new Subject(`init-value`); + const readOnlySubject = new ReadOnlySubject(subject); + + expect(readOnlySubject.value).toBe(`init-value`); + }); + + it(`updates the ReadOnlySubject value when set`, () => { + const subject = new Subject('init-value'); + const readOnlySubject = new ReadOnlySubject(subject); + + subject.value = 'some thing'; + + expect(readOnlySubject.value).toBe('some thing'); + }); + + it(`notifies subcribers when subscribing`, () => { + const subject = new Subject('init-value'); + const readOnlySubject = new ReadOnlySubject(subject); + const listener = jest.fn() as (value: string) => void; + + readOnlySubject.subscribe(listener); + + expect(listener).toHaveBeenCalledWith('init-value'); + }); + + it(`notifies subcribers when value changes`, () => { + const subject = new Subject('init-value'); + const readOnlySubject = new ReadOnlySubject(subject); + const listener = jest.fn() as (value: string) => void; + + readOnlySubject.subscribe(listener); + + subject.value = 'some thing'; + + expect(listener).toHaveBeenCalledWith('some thing'); + }); + + it(`notifies all subcribers when value changes`, () => { + const subject = new Subject('init-value'); + const readOnlySubject = new ReadOnlySubject(subject); + const listener1 = jest.fn() as (value: string) => void; + const listener2 = jest.fn() as (value: string) => void; + const listener3 = jest.fn() as (value: string) => void; + + readOnlySubject.subscribe(listener1); + readOnlySubject.subscribe(listener2); + readOnlySubject.subscribe(listener3); + + subject.value = 'some thing'; + + expect(listener1).toHaveBeenCalledWith('some thing'); + expect(listener2).toHaveBeenCalledWith('some thing'); + expect(listener3).toHaveBeenCalledWith('some thing'); + }); +}); diff --git a/test/observables/Subject.test.ts b/test/observables/Subject.test.ts new file mode 100644 index 0000000..7527a62 --- /dev/null +++ b/test/observables/Subject.test.ts @@ -0,0 +1,55 @@ +import {jest, describe, it, expect} from 'bun:test'; +import {Subject} from '../../src/observables'; + +describe('A Subject', () => { + it('exposes the Subject value', () => { + const subject = new Subject('init-value'); + + expect(subject.value).toBe('init-value'); + }); + + it('updates the Subject value when set', () => { + const subject = new Subject('init-value'); + + subject.value = 'some thing'; + + expect(subject.value).toBe('some thing'); + }); + + it('notifies subcribers when subscribing', () => { + const subject = new Subject('init-value'); + const listener = jest.fn() as (value: string) => void; + + subject.subscribe(listener); + + expect(listener).toHaveBeenCalledWith('init-value'); + }); + + it('notifies subcribers when value changes', () => { + const subject = new Subject('init-value'); + const listener = jest.fn() as (value: string) => void; + + subject.subscribe(listener); + + subject.value = 'some thing'; + + expect(listener).toHaveBeenCalledWith('some thing'); + }); + + it('notifies all subcribers when value changes', () => { + const subject = new Subject('init-value'); + const listener1 = jest.fn() as (value: string) => void; + const listener2 = jest.fn() as (value: string) => void; + const listener3 = jest.fn() as (value: string) => void; + + subject.subscribe(listener1); + subject.subscribe(listener2); + subject.subscribe(listener3); + + subject.value = 'some thing'; + + expect(listener1).toHaveBeenCalledWith('some thing'); + expect(listener2).toHaveBeenCalledWith('some thing'); + expect(listener3).toHaveBeenCalledWith('some thing'); + }); +}); diff --git a/test/strings/WhenGeneratingRandomStrings.test.ts b/test/strings/WhenGeneratingRandomStrings.test.ts new file mode 100644 index 0000000..13a639c --- /dev/null +++ b/test/strings/WhenGeneratingRandomStrings.test.ts @@ -0,0 +1,39 @@ +import {describe, beforeAll, it, expect} from 'bun:test'; +import {Strings} from '../../src/strings'; + +const randomStringCount = 11; + +describe('When generating random strings', () => { + it('generates a random string', () => { + const randomString = Strings.random(); + + expect(typeof randomString).toBe('string'); + expect(randomString.length).toBeGreaterThan(0); + }); + + describe('Given a length argument', () => { + const length = 4; + + it('generates a random string of the given length', () => { + const randomString = Strings.random(length); + + expect(typeof randomString).toBe('string'); + expect(randomString.length).toBeGreaterThan(0); + expect(randomString.length).toBe(length); + }); + }); + + describe(`Given [${randomStringCount}] are generated`, () => { + let randomStrings; + + beforeAll(() => { + randomStrings = Array.from({length: randomStringCount}, () => Strings.random()); + }); + + it('generates random strings such that no strings are identical', () => { + const uniqueRandomStrings = new Set(randomStrings); + + expect(uniqueRandomStrings.size).toBe(randomStringCount); + }); + }); +}); diff --git a/tsconfig.d.json b/tsconfig.d.json new file mode 100644 index 0000000..c579e5f --- /dev/null +++ b/tsconfig.d.json @@ -0,0 +1,30 @@ +{ + "compilerOptions": { + // Enable latest features + "lib": ["esnext", "dom"], + "target": "es6", + "module": "esnext", + + // Bundler mode + "moduleResolution": "bundler", + "esModuleInterop": true, + "allowImportingTsExtensions": true, + "allowArbitraryExtensions": true, + "isolatedModules": true, + "declaration": true, + "emitDeclarationOnly": true, + "declarationDir": "dist/types", + + // Best practices + "strict": true, + "skipLibCheck": true, + "noFallthroughCasesInSwitch": true, + + // Some stricter flags (disabled by default) + "noUnusedLocals": true, + "noUnusedParameters": true, + "noPropertyAccessFromIndexSignature": false + }, + "include": ["src"], + "exclude": ["./dist", "./test"] +} diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 0000000..c8f0850 --- /dev/null +++ b/tsconfig.json @@ -0,0 +1,28 @@ +{ + "compilerOptions": { + // Enable latest features + "lib": ["ESNext", "DOM"], + "target": "ESNext", + "module": "ESNext", + "moduleDetection": "force", + "allowJs": true, + + // Bundler mode + "moduleResolution": "bundler", + "allowImportingTsExtensions": true, + "verbatimModuleSyntax": true, + "noEmit": true, + + // Best practices + "strict": true, + "skipLibCheck": true, + "noFallthroughCasesInSwitch": true, + + // Some stricter flags (disabled by default) + "noUnusedLocals": true, + "noUnusedParameters": true, + "noPropertyAccessFromIndexSignature": false + }, + "include": ["src"], + "exclude": ["./dist"] +}