From f65c2da5875c4d733a98ba1f17d5d454acf35e15 Mon Sep 17 00:00:00 2001 From: sh <37271604+shumvgolove@users.noreply.github.com> Date: Wed, 14 Oct 2026 12:53:53 +0400 Subject: [PATCH] nodejs: lazy per-backend libsimplex download (#7543) * ci: drop dlltool from nodejs libs release * nodejs: download libsimplex into the user cache * nodejs: load libsimplex at runtime per backend * support bot: select db backend with --db * python: match backend switch error text * nodejs: download prebuilt add-on at runtime * ci: build nodejs add-on for libs release * calculator bot: load libs and add-on in image * support bot: document add-on override * nodejs: keep worker test output free of colors * nodejs: verify sha-256 of downloaded assets * python: pin core libs release with sha-256 * ci: add packages release script * ci: release nodejs add-on and open release PR * ci: publish packages after release PR * nodejs: keep npm from building the add-on * calculator bot: build the add-on in the image * ci: merge package release workflows * ci: open release PR when publishing fails * ci: build the add-on with node-addon-api only * ci: skip publishing in a called packages workflow * ci: update package release actions to Node 24 * nodejs: fix receive test timeout * ci: test packages on pull requests * nodejs: reuse the database in the close stress test * ci: keep failed package tests from hanging * ci: skip preset server tests * ci: plan package builds from the changes * ci: test packages with a given libsimplex * ci: test packages with libsimplex of core changes --- .github/workflows/build.yml | 54 +- .github/workflows/packages.yml | 354 ++++++++++++ apps/simplex-calculator-bot/README.md | 2 +- apps/simplex-support-bot/README.md | 38 +- apps/simplex-support-bot/bot.test.ts | 90 ++- apps/simplex-support-bot/src/config.ts | 32 +- apps/simplex-support-bot/vitest.config.ts | 7 - packages/simplex-chat-nodejs/.gitignore | 1 - packages/simplex-chat-nodejs/.npmignore | 1 - packages/simplex-chat-nodejs/README.md | 26 +- packages/simplex-chat-nodejs/binding.gyp | 30 +- packages/simplex-chat-nodejs/cpp/simplex.cc | 174 +++++- packages/simplex-chat-nodejs/cpp/simplex.h | 32 +- .../docs/Namespace.core.md | 2 + .../docs/api.Class.ChatApi.md | 203 ++++--- .../docs/api.TypeAlias.DbConfig.md | 8 +- .../docs/bot.Function.run.md | 2 +- .../docs/bot.Function.subscribeChatItems.md | 2 +- .../docs/bot.Interface.BotConfig.md | 18 +- .../docs/core.Class.ChatAPIError.md | 8 +- .../docs/core.Class.ChatInitError.md | 8 +- ...MigrationError.Interface.ErrorMigration.md | 8 +- ...rationError.Interface.ErrorNotADatabase.md | 6 +- ...ore.DBMigrationError.Interface.ErrorSQL.md | 8 +- ...tionError.Interface.InvalidConfirmation.md | 4 +- ...grationError.Interface.InvalidQueueSize.md | 4 +- .../core.DBMigrationError.TypeAlias.Tag.md | 2 +- .../core.Enumeration.MigrationConfirmation.md | 10 +- .../docs/core.Function.chatCloseStore.md | 2 +- .../docs/core.Function.chatDecryptFile.md | 2 +- .../docs/core.Function.chatEncryptFile.md | 2 +- .../docs/core.Function.chatMigrateInit.md | 2 +- .../docs/core.Function.chatReadFile.md | 2 +- .../docs/core.Function.chatRecvMsgWait.md | 2 +- .../docs/core.Function.chatSendCmd.md | 2 +- .../docs/core.Function.chatWriteFile.md | 2 +- .../docs/core.Function.loadLibrary.md | 24 + .../docs/core.Interface.APIResult.md | 6 +- .../docs/core.Interface.CryptoArgs.md | 6 +- .../docs/core.Interface.UpMigration.md | 6 +- .../core.MTRError.Interface.MTREDifferent.md | 6 +- .../core.MTRError.Interface.MTRENoDown.md | 6 +- .../docs/core.MTRError.TypeAlias.Tag.md | 2 +- ...re.MigrationError.Interface.MEDowngrade.md | 6 +- ...core.MigrationError.Interface.MEUpgrade.md | 6 +- ...MigrationError.Interface.MigrationError.md | 6 +- .../docs/core.MigrationError.TypeAlias.Tag.md | 2 +- .../docs/core.TypeAlias.Backend.md | 11 + .../docs/core.TypeAlias.DBMigrationError.md | 2 +- .../docs/core.TypeAlias.MTRError.md | 2 +- .../docs/core.TypeAlias.MigrationError.md | 2 +- packages/simplex-chat-nodejs/jest.config.js | 5 + packages/simplex-chat-nodejs/package.json | 14 +- packages/simplex-chat-nodejs/src/api.ts | 9 +- packages/simplex-chat-nodejs/src/cli.ts | 42 ++ packages/simplex-chat-nodejs/src/core.ts | 22 + .../simplex-chat-nodejs/src/download-libs.js | 266 --------- packages/simplex-chat-nodejs/src/libs.ts | 202 +++++++ packages/simplex-chat-nodejs/src/release.ts | 14 + packages/simplex-chat-nodejs/src/simplex.d.ts | 1 + packages/simplex-chat-nodejs/src/simplex.js | 23 +- .../tests/api.unit.test.ts | 23 + .../simplex-chat-nodejs/tests/core.test.ts | 52 +- .../simplex-chat-nodejs/tests/libs.test.ts | 466 +++++++++++++++ .../simplex-chat-nodejs/tests/loader.test.ts | 107 ++++ packages/simplex-chat-nodejs/tests/zip.ts | 47 ++ .../src/simplex_chat/_native.py | 35 +- .../src/simplex_chat/_version.py | 17 +- .../tests/test_native_cache.py | 53 +- .../tests/test_native_url.py | 6 +- scripts/calculator-bot/Dockerfile | 9 +- scripts/ci/packages_release.py | 529 ++++++++++++++++++ scripts/ci/test_packages_release.py | 288 ++++++++++ 73 files changed, 2823 insertions(+), 650 deletions(-) create mode 100644 .github/workflows/packages.yml create mode 100644 packages/simplex-chat-nodejs/docs/core.Function.loadLibrary.md create mode 100644 packages/simplex-chat-nodejs/docs/core.TypeAlias.Backend.md create mode 100644 packages/simplex-chat-nodejs/src/cli.ts delete mode 100644 packages/simplex-chat-nodejs/src/download-libs.js create mode 100644 packages/simplex-chat-nodejs/src/libs.ts create mode 100644 packages/simplex-chat-nodejs/src/release.ts create mode 100644 packages/simplex-chat-nodejs/tests/libs.test.ts create mode 100644 packages/simplex-chat-nodejs/tests/loader.test.ts create mode 100644 packages/simplex-chat-nodejs/tests/zip.ts create mode 100644 scripts/ci/packages_release.py create mode 100644 scripts/ci/test_packages_release.py diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 1acc26b6e4..0bd229a576 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -80,13 +80,23 @@ jobs: runs-on: ubuntu-latest outputs: libs_tag: ${{ steps.libs_commit.outputs.tag }} + core_changed: ${{ steps.core.outputs.core }} steps: - name: Clone project - if: github.event_name == 'push' + if: github.event_name == 'push' || github.event_name == 'pull_request' uses: actions/checkout@v3 with: fetch-depth: 2 + - name: Check chat core change + id: core + if: github.event_name == 'pull_request' + env: + BASE: ${{ github.event.pull_request.base.sha }} + run: | + git fetch -q --depth=1 origin "$BASE" + python3 scripts/ci/packages_release.py changes "$BASE" | grep '^core=' >> "$GITHUB_OUTPUT" + - name: Build changelog id: build_changelog if: startsWith(github.ref, 'refs/tags/v') @@ -300,8 +310,9 @@ jobs: done strip /out/simplex-chat + # Also for pull requests that change the chat core, for the package tests (packages-pr). - name: Build library - if: needs.maybe-release.outputs.libs_tag && matrix.libs + if: matrix.libs && (needs.maybe-release.outputs.libs_tag || (needs.maybe-release.outputs.core_changed && matrix.arch == 'x86_64')) shell: docker exec -t builder sh -eu {0} run: | SKIP_VLC=1 scripts/desktop/build-lib-linux.sh @@ -310,6 +321,14 @@ jobs: find apps/multiplatform/common/src/commonMain/cpp/desktop/libs/linux-${{ matrix.arch }} -maxdepth 1 -type f -exec cp {} release-libs/$name/libs/ \; (cd release-libs/$name && zip -r ../$name.zip libs) + - name: Upload library artifact + if: matrix.libs && needs.maybe-release.outputs.core_changed && matrix.arch == 'x86_64' + uses: actions/upload-artifact@v7 + with: + name: simplex-chat-libs-linux-${{ matrix.arch }} + path: release-libs/simplex-chat-libs-linux-${{ matrix.arch }}.zip + if-no-files-found: error + - name: Upload library if: needs.maybe-release.outputs.libs_tag && matrix.libs uses: softprops/action-gh-release@v2 @@ -837,3 +856,34 @@ jobs: bin_hash: ${{ steps.windows_desktop_build.outputs.package_hash }} github_ref: ${{ github.ref }} github_token: ${{ secrets.GITHUB_TOKEN }} + +# ========================= +# Packages release +# ========================= + +# A new library release is tested with the Node.js and Python packages and updates them in a release PR. + + packages: + needs: [maybe-release, build-linux, build-linux-postgres, build-macos, build-windows] + if: ${{ !cancelled() && needs.maybe-release.outputs.libs_tag }} + # the called workflow can't be granted more than its caller, and its publish job needs these + permissions: + contents: write + id-token: write + uses: ./.github/workflows/packages.yml + with: + libs_release: ${{ needs.maybe-release.outputs.libs_tag }} + secrets: inherit + +# A pull request that changes the chat core is tested with the Node.js and Python packages. + + packages-pr: + needs: [maybe-release, build-linux] + # build-linux uploads the library before its chat tests run + if: ${{ !cancelled() && needs.maybe-release.outputs.core_changed }} + permissions: + contents: write + id-token: write + uses: ./.github/workflows/packages.yml + with: + libs_artifact: true diff --git a/.github/workflows/packages.yml b/.github/workflows/packages.yml new file mode 100644 index 0000000000..28011cf804 --- /dev/null +++ b/.github/workflows/packages.yml @@ -0,0 +1,354 @@ +name: packages + +# Releases simplex-chat (npm, PyPI) and @simplex-chat/types (npm) from master and stable: +# 1. A missing Node.js add-on release (nodejs-addon-) is built and uploaded to simplex-chat-libs. +# 2. Versions that are not published yet are published, and tagged -v on their commit. +# npm prerelease versions get dist-tag beta, others latest. +# 3. The release PR packages-release/ pins new library and add-on releases with their SHA-256 +# and bumps the versions of changed packages. Merging it publishes them (step 2). +# Package tests run on every platform with the pinned libsimplex. build.yml runs them with the libsimplex it builds: +# for pull requests that change the chat core (linux-x86_64), and for a new library release before its release PR. + +# Publishing uses trusted publishing (OIDC) for this workflow on npm and PyPI, +# or the NPM_TOKEN and PYPI_API_TOKEN secrets when they are set. +# Test repositories: variables NPM_SCOPE, PYPI_PROJECT and PYPI_URL / PYPI_UPLOAD_URL (TestPyPI) +# publish under other names. + +# Secrets: +# ------- +# NODEJS_REPO_TOKEN +# Only select repositories: simplex-chat-libs +# Permissions: +# * Contents (Read and Write) +# RELEASE_PR_TOKEN +# Only select repositories: simplex-chat +# Permissions: +# * Contents (Read and Write) +# * Pull requests (Read and Write) +# A PR opened with GITHUB_TOKEN does not run the checks of other workflows. + +on: + workflow_dispatch: + inputs: + nodejs_version: + description: "simplex-chat npm version, bumped when needed by default" + required: false + types_version: + description: "@simplex-chat/types version, bumped when needed by default" + required: false + python_version: + description: "simplex-chat PyPI version, bumped when needed by default" + required: false + # build.yml: a pull request that changes the chat core, or a new library release + workflow_call: + inputs: + libs_artifact: + description: "test with simplex-chat-libs-linux-x86_64 built by the calling run" + type: boolean + default: false + libs_release: + description: "test with this simplex-chat-libs release" + type: string + default: "" + push: + branches: + - master + - stable + paths: + - "packages/simplex-chat-nodejs/**" + - "packages/simplex-chat-client/types/typescript/**" + - "packages/simplex-chat-python/**" + - ".github/workflows/packages.yml" + pull_request: + paths: + - "packages/simplex-chat-nodejs/**" + - "packages/simplex-chat-client/types/typescript/**" + - "packages/simplex-chat-python/**" + - ".github/workflows/packages.yml" + +concurrency: + # build.yml and this workflow both run for a pull request that changes the core and the packages + group: ${{ github.workflow }}-packages-${{ github.ref }} + cancel-in-progress: ${{ github.event_name == 'pull_request' }} + +permissions: + contents: read + +env: + NPM_SCOPE: ${{ vars.NPM_SCOPE }} + PYPI_PROJECT: ${{ vars.PYPI_PROJECT }} + PYPI_URL: ${{ vars.PYPI_URL }} + +jobs: + addon: + runs-on: ubuntu-latest + outputs: + tag: ${{ steps.tag.outputs.tag }} + release_branch: ${{ steps.branch.outputs.release_branch }} + missing: ${{ steps.release.outputs.missing }} + platforms: ${{ steps.plan.outputs.platforms }} + test: ${{ steps.plan.outputs.test }} + steps: + - name: Checkout Code + uses: actions/checkout@v6 + + - name: Add-on release tag + id: tag + run: echo "tag=$(python3 scripts/ci/packages_release.py addon-tag)" >> "$GITHUB_OUTPUT" + + - name: Release branch + id: branch + if: github.event_name != 'pull_request' && (github.ref == 'refs/heads/master' || github.ref == 'refs/heads/stable') + run: echo "release_branch=true" >> "$GITHUB_OUTPUT" + + - name: Check the add-on release + id: release + if: steps.branch.outputs.release_branch + env: + GH_TOKEN: ${{ secrets.NODEJS_REPO_TOKEN }} + LIBS_REPOSITORY: ${{ github.repository }}-libs + TAG: ${{ steps.tag.outputs.tag }} + run: | + if ! gh release view "$TAG" --repo "$LIBS_REPOSITORY" > /dev/null 2>&1; then + echo "missing=true" >> "$GITHUB_OUTPUT" + fi + + - name: Build plan + id: plan + env: + EVENT: ${{ github.event_name }} + BASE: ${{ github.event.pull_request.base.sha }} + RELEASE_MISSING: ${{ steps.release.outputs.missing }} + LIBS_ARTIFACT: ${{ inputs.libs_artifact }} + LIBS_RELEASE: ${{ inputs.libs_release }} + run: | + if [ -n "$BASE" ]; then git fetch -q --depth=1 origin "$BASE"; fi + python3 scripts/ci/packages_release.py build-plan --event "$EVENT" --base "$BASE" \ + --release-missing "$RELEASE_MISSING" --libs-artifact "$LIBS_ARTIFACT" --libs-release "$LIBS_RELEASE" >> "$GITHUB_OUTPUT" + + build: + name: "build ${{ matrix.platform }}" + needs: addon + if: needs.addon.outputs.platforms != '[]' + runs-on: ${{ matrix.os }} + timeout-minutes: 30 + strategy: + fail-fast: false + matrix: + include: ${{ fromJSON(needs.addon.outputs.platforms) }} + defaults: + run: + shell: bash + working-directory: packages/simplex-chat-nodejs + steps: + - name: Checkout Code + uses: actions/checkout@v6 + + - name: Setup Node.js + uses: actions/setup-node@v7 + with: + node-version: 22 + + - name: Setup Python + uses: actions/setup-python@v7 + with: + python-version: "3.12" + + # The local types: the dependency can be bumped to a version that is not published yet. + - name: Build simplex-chat (npm) and add-on + run: | + (cd ../simplex-chat-client/types/typescript && npm install --no-audit --no-fund && npx tsc) + npm install --ignore-scripts --no-save --no-audit --no-fund ../simplex-chat-client/types/typescript + npx --yes node-gyp rebuild + npx tsc && cp src/simplex.* dist + cp build/Release/simplex.node build/simplex-chat-nodejs-${{ matrix.platform }}.node + + - name: Download libsimplex of this run + if: needs.addon.outputs.test && inputs.libs_artifact + uses: actions/download-artifact@v8 + with: + name: simplex-chat-libs-${{ matrix.platform }} + path: ${{ runner.temp }}/libsimplex + + - name: Download libsimplex release + if: needs.addon.outputs.test && inputs.libs_release + env: + URL: https://github.com/${{ github.repository }}-libs/releases/download/${{ inputs.libs_release }}/simplex-chat-libs-${{ matrix.platform }}.zip + run: curl -fsSL --create-dirs -o "$RUNNER_TEMP/libsimplex/simplex-chat-libs-${{ matrix.platform }}.zip" "$URL" + + - name: Unpack libsimplex + if: needs.addon.outputs.test && (inputs.libs_artifact || inputs.libs_release) + run: python -m zipfile -e "$RUNNER_TEMP/libsimplex/simplex-chat-libs-${{ matrix.platform }}.zip" "$RUNNER_TEMP/libsimplex" + + - name: Test simplex-chat (npm) + if: needs.addon.outputs.test + env: + SIMPLEX_LIBS_DIR: ${{ (inputs.libs_artifact || inputs.libs_release) && format('{0}/libsimplex/libs', runner.temp) || '' }} + # A failed test can leave chat controllers running, and jest then waits for them. + # Tests on the public preset servers fail in CI when a server is slow to connect. + run: npx jest --forceExit --testNamePattern '^(?!.*use preset servers)' + + - name: Test simplex-chat (PyPI) + if: needs.addon.outputs.test + working-directory: packages/simplex-chat-python + run: | + python -m pip install -e ".[test]" + python -m pytest -q + + - name: Upload add-on artifact + uses: actions/upload-artifact@v7 + with: + name: simplex-nodejs-addon-${{ matrix.platform }} + path: packages/simplex-chat-nodejs/build/simplex-chat-nodejs-${{ matrix.platform }}.node + if-no-files-found: error + + publish: + needs: addon + # Trusted publishing accepts this workflow only when it is not called by another one (build.yml). + if: needs.addon.outputs.release_branch && contains(github.workflow_ref, '/.github/workflows/packages.yml@') + runs-on: ubuntu-latest + permissions: + contents: write + id-token: write + env: + NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} + steps: + - name: Checkout Code + uses: actions/checkout@v6 + with: + fetch-depth: 0 + + - name: Versions to publish + id: plan + run: python3 scripts/ci/packages_release.py publish-plan >> "$GITHUB_OUTPUT" + + - name: Setup Node.js + if: steps.plan.outputs.types_version || steps.plan.outputs.nodejs_version + uses: actions/setup-node@v7 + with: + node-version: 24 + registry-url: https://registry.npmjs.org + + - name: Setup Python + if: steps.plan.outputs.python_version + uses: actions/setup-python@v7 + with: + python-version: "3.12" + + # Before the test names: the build installs @simplex-chat/types from this repository. + - name: Build and test simplex-chat (npm) + if: steps.plan.outputs.nodejs_version + working-directory: packages/simplex-chat-nodejs + run: | + (cd ../simplex-chat-client/types/typescript && npm install --no-audit --no-fund && npx tsc) + npm install --ignore-scripts --no-save --no-audit --no-fund ../simplex-chat-client/types/typescript + npx tsc && cp src/simplex.* dist + npx jest tests/libs.test.ts tests/api.unit.test.ts + + - name: Use test package names + if: vars.NPM_SCOPE || vars.PYPI_PROJECT + run: python3 scripts/ci/packages_release.py test-names + + - name: Publish @simplex-chat/types + if: steps.plan.outputs.types_version + working-directory: packages/simplex-chat-client/types/typescript + env: + VERSION: ${{ steps.plan.outputs.types_version }} + DIST_TAG: ${{ steps.plan.outputs.types_dist_tag }} + run: | + npm install --no-audit --no-fund + npm publish --provenance --access public --tag "$DIST_TAG" + git tag "types-v$VERSION" && git push origin "types-v$VERSION" + + - name: Publish simplex-chat (npm) + if: steps.plan.outputs.nodejs_version + working-directory: packages/simplex-chat-nodejs + env: + VERSION: ${{ steps.plan.outputs.nodejs_version }} + DIST_TAG: ${{ steps.plan.outputs.nodejs_dist_tag }} + run: | + npm publish --provenance --access public --tag "$DIST_TAG" + git tag "nodejs-v$VERSION" && git push origin "nodejs-v$VERSION" + + - name: Build and test simplex-chat (PyPI) + if: steps.plan.outputs.python_version + working-directory: packages/simplex-chat-python + run: | + python -m pip install build -e ".[test]" + python -m pytest -q + python -m build + + - name: Publish simplex-chat (PyPI) + if: steps.plan.outputs.python_version + uses: pypa/gh-action-pypi-publish@release/v1 + with: + packages-dir: packages/simplex-chat-python/dist + repository-url: ${{ vars.PYPI_UPLOAD_URL || 'https://upload.pypi.org/legacy/' }} + password: ${{ secrets.PYPI_API_TOKEN }} + + - name: Tag the published PyPI version + if: steps.plan.outputs.python_version + env: + VERSION: ${{ steps.plan.outputs.python_version }} + run: git tag "python-v$VERSION" && git push origin "python-v$VERSION" + + release: + # After publish, for its version tags. A failed publish leaves its versions unbumped. + needs: [addon, build, publish] + # A merged release PR changes only versions and pins. + if: >- + !cancelled() && needs.addon.outputs.release_branch && needs.build.result != 'failure' + && !startsWith(github.event.head_commit.message, 'packages: release') + runs-on: ubuntu-latest + steps: + - name: Checkout Code + uses: actions/checkout@v6 + with: + fetch-depth: 0 + + # Outside the checkout: the release PR commits every change in it. + - name: Download add-on artifacts + if: needs.addon.outputs.missing + uses: actions/download-artifact@v8 + with: + pattern: simplex-nodejs-addon-* + merge-multiple: true + path: ${{ runner.temp }}/addons + + - name: Create add-on release + if: needs.addon.outputs.missing + uses: softprops/action-gh-release@v3 + with: + repository: ${{ github.repository }}-libs + tag_name: ${{ needs.addon.outputs.tag }} + files: ${{ runner.temp }}/addons/*.node + token: ${{ secrets.NODEJS_REPO_TOKEN }} + + - name: Update pins and versions + id: prepare + env: + GH_TOKEN: ${{ secrets.NODEJS_REPO_TOKEN }} + LIBS_REPOSITORY: ${{ github.repository }}-libs + NODEJS_VERSION: ${{ inputs.nodejs_version }} + TYPES_VERSION: ${{ inputs.types_version }} + PYTHON_VERSION: ${{ inputs.python_version }} + run: | + python3 scripts/ci/packages_release.py prepare \ + --nodejs-version "$NODEJS_VERSION" \ + --types-version "$TYPES_VERSION" \ + --python-version "$PYTHON_VERSION" \ + --summary "$RUNNER_TEMP/summary.md" + echo "title=$(head -n 1 "$RUNNER_TEMP/summary.md")" >> "$GITHUB_OUTPUT" + + - name: Create or update release PR + uses: peter-evans/create-pull-request@v8 + with: + token: ${{ secrets.RELEASE_PR_TOKEN }} + branch: packages-release/${{ github.ref_name }} + base: ${{ github.ref_name }} + title: ${{ steps.prepare.outputs.title }} + commit-message: ${{ steps.prepare.outputs.title }} + body-path: ${{ runner.temp }}/summary.md + author: SimpleX Chat + committer: SimpleX Chat + delete-branch: true diff --git a/apps/simplex-calculator-bot/README.md b/apps/simplex-calculator-bot/README.md index 028e470964..d8985700cd 100644 --- a/apps/simplex-calculator-bot/README.md +++ b/apps/simplex-calculator-bot/README.md @@ -25,7 +25,7 @@ The bot prints its address and keeps its data in `./data`. To add a SimpleX name to the address, register the name with the address short link, and run `npm start -- --domain yourname.simplex`. The name is kept when the bot starts without `--domain`. -To use the library from this repository, build it and run `npm install --no-save ../../packages/simplex-chat-nodejs` instead of `npm install`. +To use the library from this repository, build it and run `npm install --no-save ../../packages/simplex-chat-nodejs` instead of `npm install`, then start the bot with `SIMPLEX_ADDON_PATH=../../packages/simplex-chat-nodejs/build/Release/simplex.node`. ## Test diff --git a/apps/simplex-support-bot/README.md b/apps/simplex-support-bot/README.md index 19b9ab8bd6..d95aa5b59b 100644 --- a/apps/simplex-support-bot/README.md +++ b/apps/simplex-support-bot/README.md @@ -12,30 +12,18 @@ A business-address bot that triages incoming support chats, optionally runs them ```bash cd apps/simplex-support-bot -npm install # downloads native libs + transitive deps +npm install npm run build # tsc ``` -By default this installs the **SQLite** backend. +The native library is downloaded on first start into the user cache. To download it ahead of time (for example in a Dockerfile), run as the user that runs the bot: -To use **PostgreSQL** instead, drop a `.npmrc` next to `package.json` *before* `npm install`: - -```bash -echo 'simplex_backend=postgres' > .npmrc -npm install # now pulls postgres-flavored native libs -npm run build +```sh +npx simplex-chat install --backend sqlite +npx simplex-chat install --backend postgres # Linux x86_64 only ``` -`.npmrc` lives next to the package — npm reads it natively, no extra setup. - -### Switching backends - -`npm install` is a no-op for already-installed deps, so editing `.npmrc` and re-running `npm install` will *not* re-trigger `simplex-chat`'s preinstall. To switch backends, force a clean install: - -```bash -rm -rf node_modules -npm install # download-libs.js re-runs and pulls the right native lib -``` +Select the database with `--db sqlite` (default) or `--db postgres`. ## Run @@ -47,11 +35,9 @@ npm start -- --team-group "Support Team" # PostgreSQL npm start -- --team-group "Support Team" \ - --pg-conn "postgres://user:pass@host/db" + --db postgres --pg-conn "postgres://user:pass@host/db" ``` -The bot runs via `npm start` so npm can expose `.npmrc` settings to the process — `detectBackend()` reads `npm_config_simplex_backend` to know which backend was installed. - ## Flags Run `npm start -- --help` for the auto-generated reference. Summary: @@ -60,6 +46,7 @@ Run `npm start -- --help` for the auto-generated reference. Summary: |---|---|---|---|---| | `--team-group` | both | yes | — | team group display name | | `--state-file` | both | no | `./data/state.json` | path to bot state JSON | +| `--db` | both | no | `sqlite` | `sqlite` or `postgres` | | `--sqlite-file-prefix` | sqlite | no | `./data/simplex` | DB file prefix (creates `_chat.db`, `_agent.db`) | | `--sqlite-key` | sqlite | no | (unencrypted) | SQLCipher encryption key | | `--pg-conn` | postgres | yes | — | PostgreSQL connection string | @@ -76,7 +63,8 @@ Run `npm start -- --help` for the auto-generated reference. Summary: | Var | Purpose | |---|---| | `GROK_API_KEY` | xAI API key; enables Grok replies | -| `SIMPLEX_BACKEND` | alternative to `.npmrc` for selecting the install backend (`sqlite` or `postgres`) | +| `SIMPLEX_LIBS_DIR` | directory with a local libsimplex build for the `--db` backend, instead of the downloaded one | +| `SIMPLEX_ADDON_PATH` | locally built Node.js add-on (`simplex.node`), instead of the downloaded one | ## Local development against unreleased lib changes @@ -84,18 +72,18 @@ This package depends on `simplex-chat` from npm. To test against an in-tree vers ```bash # In packages/simplex-chat-nodejs +npm install && npm run build npm link # In apps/simplex-support-bot npm link simplex-chat +export SIMPLEX_ADDON_PATH=../../packages/simplex-chat-nodejs/build/Release/simplex.node ``` `npm unlink simplex-chat && npm install` reverts to the registry version. ## Troubleshooting -- **`--pg-conn is required when backend is postgres`** — the postgres backend is installed but you didn't pass a connection string. +- **`--pg-conn is required with --db postgres`** — pass a connection string, or use `--db sqlite`. - **`libpq5` errors at startup** — install `libpq5` on the host (`apt install libpq5` on Debian/Ubuntu). - **`ENOENT: no such file or directory, open './data/state.json'`** — the parent directory of `--state-file` must exist; `mkdir -p data` before starting. -- **Wrong backend installed** — check `node_modules/simplex-chat/libs/installed.txt`. Edit `.npmrc`, then `rm -rf node_modules && npm install` to switch (`npm install` alone won't re-run the dep's preinstall). -- **`libpq` connection error** at startup with sqlite-flavored config (or vice versa) — `.npmrc` was changed but libs weren't reinstalled. See "Switching backends" above. diff --git a/apps/simplex-support-bot/bot.test.ts b/apps/simplex-support-bot/bot.test.ts index 37521e49dc..a9785de1e3 100644 --- a/apps/simplex-support-bot/bot.test.ts +++ b/apps/simplex-support-bot/bot.test.ts @@ -2403,54 +2403,48 @@ describe("parseConfig Validation", () => { .toThrow(/--complete-hours must be a non-negative integer, got "abc"/) }) - test("postgres backend without --pg-conn → throws", () => { - const prev = process.env.SIMPLEX_BACKEND - process.env.SIMPLEX_BACKEND = "postgres" - try { - expect(() => parseConfig(baseArgs)) - .toThrow(/--pg-conn is required when backend is postgres/) - } finally { - if (prev === undefined) delete process.env.SIMPLEX_BACKEND - else process.env.SIMPLEX_BACKEND = prev - } + test("--db postgres without --pg-conn → throws", () => { + expect(() => parseConfig([...baseArgs, "--db", "postgres"])) + .toThrow(/--pg-conn is required with --db postgres/) }) - test("postgres backend with --pg-conn → db is postgres DbConfig", () => { - const prev = process.env.SIMPLEX_BACKEND - process.env.SIMPLEX_BACKEND = "postgres" - try { - const cfg = parseConfig([...baseArgs, "--pg-conn", "postgres://user:pass@localhost/db"]) - expect(cfg.db).toEqual({type: "postgres", connectionString: "postgres://user:pass@localhost/db"}) - } finally { - if (prev === undefined) delete process.env.SIMPLEX_BACKEND - else process.env.SIMPLEX_BACKEND = prev - } + test("--db postgres with --pg-conn → db is postgres DbConfig", () => { + const cfg = parseConfig([...baseArgs, "--db", "postgres", "--pg-conn", "postgres://user:pass@localhost/db"]) + expect(cfg.db).toEqual({type: "postgres", connectionString: "postgres://user:pass@localhost/db"}) }) - test("postgres backend with --pg-schema → DbConfig carries schemaPrefix", () => { - const prev = process.env.SIMPLEX_BACKEND - process.env.SIMPLEX_BACKEND = "postgres" - try { - const cfg = parseConfig([...baseArgs, "--pg-conn", "postgres://localhost/db", "--pg-schema", "bot"]) - expect(cfg.db).toEqual({type: "postgres", connectionString: "postgres://localhost/db", schemaPrefix: "bot"}) - } finally { - if (prev === undefined) delete process.env.SIMPLEX_BACKEND - else process.env.SIMPLEX_BACKEND = prev - } + test("--db postgres with --pg-schema → DbConfig carries schemaPrefix", () => { + const cfg = parseConfig([...baseArgs, "--db", "postgres", "--pg-conn", "postgres://localhost/db", "--pg-schema", "bot"]) + expect(cfg.db).toEqual({type: "postgres", connectionString: "postgres://localhost/db", schemaPrefix: "bot"}) }) - test("sqlite backend (default) → db is sqlite DbConfig with default filePrefix", () => { - const prevBackend = process.env.SIMPLEX_BACKEND - const prevNpm = process.env.npm_config_simplex_backend - delete process.env.SIMPLEX_BACKEND - delete process.env.npm_config_simplex_backend - try { - const cfg = parseConfig(baseArgs) - expect(cfg.db).toEqual({type: "sqlite", filePrefix: "./data/simplex"}) - } finally { - if (prevBackend !== undefined) process.env.SIMPLEX_BACKEND = prevBackend - if (prevNpm !== undefined) process.env.npm_config_simplex_backend = prevNpm - } + test("default --db → sqlite DbConfig with default filePrefix", () => { + const cfg = parseConfig(baseArgs) + expect(cfg.db).toEqual({type: "sqlite", filePrefix: "./data/simplex"}) + }) + + test("--pg-conn with sqlite → throws", () => { + expect(() => parseConfig([...baseArgs, "--pg-conn", "postgres://localhost/db"])) + .toThrow(/--pg-conn and --pg-schema require --db postgres/) + }) + + test("--pg-schema with sqlite → throws", () => { + expect(() => parseConfig([...baseArgs, "--pg-schema", "bot"])) + .toThrow(/--pg-conn and --pg-schema require --db postgres/) + }) + + test("--sqlite-key with postgres → throws", () => { + expect(() => parseConfig([...baseArgs, "--db", "postgres", "--pg-conn", "c", "--sqlite-key", "k"])) + .toThrow(/--sqlite-file-prefix and --sqlite-key require --db sqlite/) + }) + + test("--sqlite-file-prefix with postgres → throws", () => { + expect(() => parseConfig([...baseArgs, "--db", "postgres", "--pg-conn", "c", "--sqlite-file-prefix", "./x"])) + .toThrow(/--sqlite-file-prefix and --sqlite-key require --db sqlite/) + }) + + test("invalid --db → throws", () => { + expect(() => parseConfig([...baseArgs, "--db", "mysql"])).toThrow(/Allowed choices are sqlite, postgres/) }) test("sqlite backend with --sqlite-key → DbConfig carries encryptionKey", () => { @@ -2468,18 +2462,6 @@ describe("parseConfig Validation", () => { .toThrow(/required option '--team-group/) }) - test("invalid SIMPLEX_BACKEND → throws", () => { - const prev = process.env.SIMPLEX_BACKEND - process.env.SIMPLEX_BACKEND = "mysql" - try { - expect(() => parseConfig(baseArgs)) - .toThrow(/Invalid SIMPLEX_BACKEND: "mysql"/) - } finally { - if (prev === undefined) delete process.env.SIMPLEX_BACKEND - else process.env.SIMPLEX_BACKEND = prev - } - }) - test("--complete-hours negative → throws", () => { // parseArgs refuses "-1" as a bare arg (ambiguous with a short flag), so use `=` form expect(() => parseConfig([...baseArgs, "--complete-hours", "-1"])) diff --git a/apps/simplex-support-bot/src/config.ts b/apps/simplex-support-bot/src/config.ts index 8fbd006aef..be33db0c38 100644 --- a/apps/simplex-support-bot/src/config.ts +++ b/apps/simplex-support-bot/src/config.ts @@ -1,4 +1,4 @@ -import {Command} from "commander" +import {Command, Option} from "commander" import {api} from "simplex-chat" export interface IdName { @@ -6,8 +6,6 @@ export interface IdName { name: string } -export type Backend = "sqlite" | "postgres" - export interface Config { stateFile: string // local path to the bot's state JSON db: api.DbConfig // passed to ChatApi.init / bot.run @@ -21,18 +19,6 @@ export interface Config { grokApiKey: string | null } -// Mirrors packages/simplex-chat-nodejs/src/download-libs.js so runtime detection -// matches what was used at install time. Works whether the user installed via -// SIMPLEX_BACKEND env var, .npmrc (→ npm_config_simplex_backend), or the -// --simplex_backend=postgres CLI flag (also surfaced as npm_config_*). -export function detectBackend(): Backend { - const raw = (process.env.SIMPLEX_BACKEND || process.env.npm_config_simplex_backend || "sqlite").toLowerCase() - if (raw !== "sqlite" && raw !== "postgres") { - throw new Error(`Invalid SIMPLEX_BACKEND: "${raw}". Must be "sqlite" or "postgres".`) - } - return raw -} - export function parseIdName(s: string): IdName { const i = s.indexOf(":") if (i < 1) throw new Error(`Invalid ID:name format: "${s}"`) @@ -57,21 +43,23 @@ function buildCommand(): Command { .description("business-address triage bot") .requiredOption("--team-group ", "team group display name") .option("--state-file ", "state JSON path", "./data/state.json") + .addOption(new Option("--db ", "database backend").choices(["sqlite", "postgres"]).default("sqlite")) .option("--sqlite-file-prefix ", "SQLite DB file prefix", "./data/simplex") .option("--sqlite-key ", "SQLCipher encryption key (default: unencrypted)") - .option("--pg-conn ", "PostgreSQL connection string (required for postgres)") + .option("--pg-conn ", "PostgreSQL connection string (required with --db postgres)") .option("--pg-schema ", "PostgreSQL schema prefix (default: simplex_v1)") .option("-a, --auto-add-team-members ", "comma-separated ID:name pairs (e.g. 1:Alice,2:Bob)") .option("--timezone ", "IANA timezone for weekend detection", "UTC") .option("--complete-hours ", "auto-complete chats after N hours idle (0 disables)", parseNonNegativeInt("--complete-hours"), 3) .option("--card-flush-seconds ", "debounce card state writes", parseNonNegativeInt("--card-flush-seconds"), 300) .option("--context-file ", "text file with Grok system context (required if GROK_API_KEY set)") - .addHelpText("after", "\nEnvironment:\n GROK_API_KEY xAI API key — enables Grok replies\n SIMPLEX_BACKEND sqlite | postgres — alternative to .npmrc for backend selection\n") + .addHelpText("after", "\nEnvironment:\n GROK_API_KEY xAI API key — enables Grok replies\n SIMPLEX_LIBS_DIR local libsimplex build for the --db backend\n SIMPLEX_ADDON_PATH locally built Node.js add-on\n") } interface RawOpts { teamGroup: string stateFile: string + db: api.DbConfig["type"] sqliteFilePrefix: string sqliteKey?: string pgConn?: string @@ -93,19 +81,19 @@ export function parseConfig(args: string[]): Config { throw err } const opts = cmd.opts() + const given = (option: keyof RawOpts) => cmd.getOptionValueSource(option) === "cli" const grokApiKey = process.env.GROK_API_KEY || null - const backend = detectBackend() let db: api.DbConfig - if (backend === "sqlite") { + if (opts.db === "sqlite") { + if (given("pgConn") || given("pgSchema")) throw new Error("--pg-conn and --pg-schema require --db postgres") db = opts.sqliteKey ? {type: "sqlite", filePrefix: opts.sqliteFilePrefix, encryptionKey: opts.sqliteKey} : {type: "sqlite", filePrefix: opts.sqliteFilePrefix} } else { - if (!opts.pgConn) { - throw new Error("--pg-conn is required when backend is postgres (PostgreSQL connection string)") - } + if (given("sqliteFilePrefix") || given("sqliteKey")) throw new Error("--sqlite-file-prefix and --sqlite-key require --db sqlite") + if (!opts.pgConn) throw new Error("--pg-conn is required with --db postgres") db = opts.pgSchema ? {type: "postgres", connectionString: opts.pgConn, schemaPrefix: opts.pgSchema} : {type: "postgres", connectionString: opts.pgConn} diff --git a/apps/simplex-support-bot/vitest.config.ts b/apps/simplex-support-bot/vitest.config.ts index c143572a87..3a70f6c5e7 100644 --- a/apps/simplex-support-bot/vitest.config.ts +++ b/apps/simplex-support-bot/vitest.config.ts @@ -5,13 +5,6 @@ export default defineConfig({ test: { globals: true, testTimeout: 10000, - // Clear backend signals — .npmrc next to package.json otherwise injects - // npm_config_simplex_backend into every test's env, breaking sqlite-default - // assumptions in parseConfig tests. - env: { - SIMPLEX_BACKEND: "", - npm_config_simplex_backend: "", - }, }, resolve: { alias: { diff --git a/packages/simplex-chat-nodejs/.gitignore b/packages/simplex-chat-nodejs/.gitignore index 322e38bfda..096404c64c 100644 --- a/packages/simplex-chat-nodejs/.gitignore +++ b/packages/simplex-chat-nodejs/.gitignore @@ -2,7 +2,6 @@ node_modules/ package-lock.json .vscode build/ -libs/ dist/ coverage/ tmp/ diff --git a/packages/simplex-chat-nodejs/.npmignore b/packages/simplex-chat-nodejs/.npmignore index 26fdd85dff..3e2e84b087 100644 --- a/packages/simplex-chat-nodejs/.npmignore +++ b/packages/simplex-chat-nodejs/.npmignore @@ -1,3 +1,2 @@ -libs/ build/ node_modules/ diff --git a/packages/simplex-chat-nodejs/README.md b/packages/simplex-chat-nodejs/README.md index 05c99eca5c..6ccd26fc05 100644 --- a/packages/simplex-chat-nodejs/README.md +++ b/packages/simplex-chat-nodejs/README.md @@ -55,40 +55,38 @@ If you cloned this repository, you can: cd ./packages/simplex-chat-nodejs npm install npm run build -node ./examples/squaring-bot-readme.js +SIMPLEX_ADDON_PATH=build/Release/simplex.node node ./examples/squaring-bot-readme.js ``` +Building the Node.js add-on from source needs [node-gyp](https://github.com/nodejs/node-gyp) build tools. + There is an example with more options in [./examples/squaring-bot.ts](./examples/squaring-bot.ts). You can run it with: `npx ts-node ./examples/squaring-bot.ts` A larger example, with a business address and commands as keys: [SimpleX Calculator](../../apps/simplex-calculator-bot/). -## PostgreSQL backend +## Native library -By default, the package uses SQLite. To use PostgreSQL instead: +`libsimplex` and the prebuilt Node.js add-on are downloaded on first use into the user cache (`$XDG_CACHE_HOME/simplex-chat` or `~/.cache/simplex-chat` on Linux, `~/Library/Caches/simplex-chat` on macOS, `%LOCALAPPDATA%\simplex-chat` on Windows), shared with the Python library. To download them ahead of time, for example in a Dockerfile, run as the user that runs the app: ```bash -npm install simplex-chat --simplex_backend=postgres +npx simplex-chat install # sqlite (default) +npx simplex-chat install --backend postgres # Linux x86_64 only ``` -Or persist the setting in `.npmrc`: +Set `SIMPLEX_LIBS_DIR` to a directory with a local build of `libsimplex` and its dependencies to use it instead; it must be built for the backend in use. Set `SIMPLEX_ADDON_PATH` to a locally built add-on (`build/Release/simplex.node`) to use it instead of the downloaded one. -```ini -simplex_backend=postgres -``` +`ChatApi.init` loads the library for `DbConfig.type`; the low-level `core` functions require `core.loadLibrary(backend)` first. -### Prerequisites (PostgreSQL) +## PostgreSQL backend + +`DbConfig.type` selects the backend; one backend per process. - `libpq5` must be installed on the host system (`apt install libpq5` on Debian/Ubuntu) - PostgreSQL backend is only available for Linux x86_64 - A PostgreSQL server accessible via connection string -### Passing PostgreSQL connection - -The `DbConfig` type is a discriminated union — pick the variant that matches -the backend you installed: - ```ts // SQLite (default) dbOpts: {type: "sqlite", filePrefix: "./data/bot"} diff --git a/packages/simplex-chat-nodejs/binding.gyp b/packages/simplex-chat-nodejs/binding.gyp index cfa6d61039..de6dbfbfd4 100644 --- a/packages/simplex-chat-nodejs/binding.gyp +++ b/packages/simplex-chat-nodejs/binding.gyp @@ -15,36 +15,8 @@ "msvs_settings": { "VCCLCompilerTool": { "ExceptionHandling": 1 } }, "defines": [ "NAPI_DISABLE_CPP_EXCEPTIONS" ], "conditions": [ - ["OS=='mac'", { - "libraries": [ - "-L<(module_root_dir)/libs", - "-lsimplex" - ], - "xcode_settings": { - "OTHER_LDFLAGS": [ - "-Wl,-rpath,@loader_path/../../libs" - ] - } - }], ["OS=='linux'", { - "libraries": [ - "-L<(module_root_dir)/libs", - "-lsimplex" - ], - "ldflags": [ - "-Wl,-rpath,'$$ORIGIN'/../../libs" - ] - }], - ["OS=='win'", { - "libraries": [ - "<(module_root_dir)/libs/libsimplex.lib" - ], - "copies": [{ - "destination": "<(PRODUCT_DIR)", - "files": [ - "<(module_root_dir)/libs/*" - ] - }] + "libraries": [ "-ldl" ] }] ] } diff --git a/packages/simplex-chat-nodejs/cpp/simplex.cc b/packages/simplex-chat-nodejs/cpp/simplex.cc index bcca2b994a..6cc2172ee1 100644 --- a/packages/simplex-chat-nodejs/cpp/simplex.cc +++ b/packages/simplex-chat-nodejs/cpp/simplex.cc @@ -8,15 +8,102 @@ #include #include #include +#include #include #include #include +#ifdef _WIN32 +#include +#else +#include +#endif #include "simplex.h" namespace simplex { using namespace Napi; +struct Library { + std::string path; + hs_init_with_rtsopts_fn hs_init_with_rtsopts = nullptr; + hs_thread_done_fn hs_thread_done = nullptr; + chat_migrate_init_fn chat_migrate_init = nullptr; + chat_migrate_init_queue_fn chat_migrate_init_queue = nullptr; + chat_close_store_fn chat_close_store = nullptr; + chat_send_cmd_fn chat_send_cmd = nullptr; + chat_recv_msg_wait_fn chat_recv_msg_wait = nullptr; + chat_write_file_fn chat_write_file = nullptr; + chat_read_file_fn chat_read_file = nullptr; + chat_encrypt_file_fn chat_encrypt_file = nullptr; + chat_decrypt_file_fn chat_decrypt_file = nullptr; +}; + +static Library lib; +static std::atomic loaded{false}; +static std::mutex load_mutex; + +#ifdef _WIN32 +typedef HMODULE LibHandle; + +static LibHandle OpenLibrary(const std::string& path) { + int n = MultiByteToWideChar(CP_UTF8, 0, path.c_str(), -1, nullptr, 0); + std::wstring wpath(n, L'\0'); + MultiByteToWideChar(CP_UTF8, 0, path.c_str(), -1, &wpath[0], n); + // dependencies resolve from the libsimplex.dll, application and system dirs, not the cwd or PATH + return LoadLibraryExW(wpath.c_str(), nullptr, LOAD_LIBRARY_SEARCH_DLL_LOAD_DIR | LOAD_LIBRARY_SEARCH_DEFAULT_DIRS); +} + +static void CloseLibrary(LibHandle h) { + FreeLibrary(h); +} + +static void* FindSymbol(LibHandle h, const char* name) { + return reinterpret_cast(GetProcAddress(h, name)); +} + +static std::string LastLoadError() { + DWORD code = GetLastError(); + char* msg = nullptr; + FormatMessageA(FORMAT_MESSAGE_ALLOCATE_BUFFER | FORMAT_MESSAGE_FROM_SYSTEM | FORMAT_MESSAGE_IGNORE_INSERTS, + nullptr, code, 0, reinterpret_cast(&msg), 0, nullptr); + std::string err = msg ? msg : "error " + std::to_string(code); + LocalFree(msg); + // system messages end with CRLF + err.erase(err.find_last_not_of("\r\n") + 1); + return err; +} +#else +typedef void* LibHandle; + +static LibHandle OpenLibrary(const std::string& path) { + return dlopen(path.c_str(), RTLD_NOW | RTLD_LOCAL); +} + +static void CloseLibrary(LibHandle h) { + dlclose(h); +} + +static void* FindSymbol(LibHandle h, const char* name) { + return dlsym(h, name); +} + +static std::string LastLoadError() { + const char* err = dlerror(); + return err ? err : "unknown error"; +} +#endif + +template +static void Resolve(LibHandle h, const char* name, F& fn) { + fn = reinterpret_cast(FindSymbol(h, name)); +} + +template +static void ResolveRequired(LibHandle h, const char* name, F& fn, std::string& missing) { + Resolve(h, name, fn); + if (fn == nullptr && missing.empty()) missing = name; +} + void haskell_init() { #ifdef _WIN32 // non-moving GC is broken on windows with GHC 9.4-9.6.3 @@ -40,7 +127,57 @@ void haskell_init() { nullptr}; #endif char **pargv = const_cast(argv); - hs_init_with_rtsopts(&argc, &pargv); + lib.hs_init_with_rtsopts(&argc, &pargv); +} + +static bool RequireLoaded(Env env) { + if (loaded) return true; + Error::New(env, "libsimplex is not loaded, call core.loadLibrary(backend) first").ThrowAsJavaScriptException(); + return false; +} + +Value Load(const CallbackInfo& args) { + Env env = args.Env(); + if (args.Length() < 1 || !args[0].IsString()) { + TypeError::New(env, "Expected string (libPath)").ThrowAsJavaScriptException(); + return env.Undefined(); + } + std::string path = args[0].As().Utf8Value(); + // worker_threads may load concurrently; the Haskell runtime must be initialized once per process + std::lock_guard lock(load_mutex); + if (loaded) { + if (path != lib.path) Error::New(env, "libsimplex already loaded from " + lib.path).ThrowAsJavaScriptException(); + return env.Undefined(); + } + LibHandle h = OpenLibrary(path); + if (h == nullptr) { + std::string err = LastLoadError(); + Error::New(env, "cannot load " + path + ": " + err).ThrowAsJavaScriptException(); + return env.Undefined(); + } + Library l; + l.path = path; + std::string missing; + ResolveRequired(h, "hs_init_with_rtsopts", l.hs_init_with_rtsopts, missing); + ResolveRequired(h, "chat_migrate_init", l.chat_migrate_init, missing); + ResolveRequired(h, "chat_close_store", l.chat_close_store, missing); + ResolveRequired(h, "chat_send_cmd", l.chat_send_cmd, missing); + ResolveRequired(h, "chat_recv_msg_wait", l.chat_recv_msg_wait, missing); + ResolveRequired(h, "chat_write_file", l.chat_write_file, missing); + ResolveRequired(h, "chat_read_file", l.chat_read_file, missing); + ResolveRequired(h, "chat_encrypt_file", l.chat_encrypt_file, missing); + ResolveRequired(h, "chat_decrypt_file", l.chat_decrypt_file, missing); + if (!missing.empty()) { + CloseLibrary(h); + Error::New(env, path + " does not export " + missing).ThrowAsJavaScriptException(); + return env.Undefined(); + } + Resolve(h, "chat_migrate_init_queue", l.chat_migrate_init_queue); + Resolve(h, "hs_thread_done", l.hs_thread_done); + lib = l; + haskell_init(); + loaded = true; + return env.Undefined(); } class ResultAsyncWorker : public AsyncWorker { @@ -287,7 +424,7 @@ class Receiver { request = std::move(queue_.front()); queue_.pop_front(); } - char* c_res = chat_recv_msg_wait(ctrl_, request.wait); + char* c_res = lib.chat_recv_msg_wait(ctrl_, request.wait); napi_status status = Settle(request.deferred, [c_res](Napi::Env env, Promise::Deferred& deferred) { if (c_res == nullptr) { deferred.Reject(Error::New(env, "chat_recv_msg_wait failed").Value()); @@ -312,7 +449,7 @@ class Receiver { } pending_->Tsfn().Release(); // Each OS thread that enters Haskell keeps an RTS task record until it calls hs_thread_done. - hs_thread_done(); + if (lib.hs_thread_done) lib.hs_thread_done(); } template @@ -361,6 +498,7 @@ ResultAsyncWorker::ResultProcessor MigrateResultProcessor() { Value ChatMigrateInit(const CallbackInfo& args) { Env env = args.Env(); + if (!RequireLoaded(env)) return env.Undefined(); if (args.Length() < 3 || !args[0].IsString() || !args[1].IsString() || !args[2].IsString()) { TypeError::New(env, "Expected three string arguments").ThrowAsJavaScriptException(); return env.Undefined(); @@ -375,7 +513,7 @@ Value ChatMigrateInit(const CallbackInfo& args) { auto execute_fn = [path, key, confirm](ResultAsyncWorker* worker) { chat_ctrl ctrl = nullptr; - char* c_res = chat_migrate_init(path.c_str(), key.c_str(), confirm.c_str(), &ctrl); + char* c_res = lib.chat_migrate_init(path.c_str(), key.c_str(), confirm.c_str(), &ctrl); worker->SetCtrl(reinterpret_cast(ctrl)); HandleCResult(worker, c_res, "chat_migrate_init"); }; @@ -388,6 +526,11 @@ Value ChatMigrateInit(const CallbackInfo& args) { Value ChatMigrateInitQueue(const CallbackInfo& args) { Env env = args.Env(); + if (!RequireLoaded(env)) return env.Undefined(); + if (lib.chat_migrate_init_queue == nullptr) { + Error::New(env, "loaded libsimplex does not export chat_migrate_init_queue; queue size needs a newer libsimplex").ThrowAsJavaScriptException(); + return env.Undefined(); + } if (args.Length() < 4 || !args[0].IsString() || !args[1].IsString() || !args[2].IsString() || !args[3].IsNumber()) { TypeError::New(env, "Expected three string arguments and number").ThrowAsJavaScriptException(); return env.Undefined(); @@ -408,7 +551,7 @@ Value ChatMigrateInitQueue(const CallbackInfo& args) { auto execute_fn = [path, key, confirm, queue_size](ResultAsyncWorker* worker) { chat_ctrl ctrl = nullptr; - char* c_res = chat_migrate_init_queue(path.c_str(), key.c_str(), confirm.c_str(), queue_size, &ctrl); + char* c_res = lib.chat_migrate_init_queue(path.c_str(), key.c_str(), confirm.c_str(), queue_size, &ctrl); worker->SetCtrl(reinterpret_cast(ctrl)); HandleCResult(worker, c_res, "chat_migrate_init_queue"); }; @@ -421,6 +564,7 @@ Value ChatMigrateInitQueue(const CallbackInfo& args) { Value ChatCloseStore(const CallbackInfo& args) { Env env = args.Env(); + if (!RequireLoaded(env)) return env.Undefined(); if (args.Length() < 1 || !args[0].IsBigInt()) { TypeError::New(env, "Expected bigint (ctrl)").ThrowAsJavaScriptException(); return env.Undefined(); @@ -436,7 +580,7 @@ Value ChatCloseStore(const CallbackInfo& args) { if (receiver) { receiver->Stop(); } - char* c_res = chat_close_store(ctrl); + char* c_res = lib.chat_close_store(ctrl); HandleCResult(worker, c_res, "chat_close_store"); }; @@ -448,6 +592,7 @@ Value ChatCloseStore(const CallbackInfo& args) { Value ChatSendCmd(const CallbackInfo& args) { Env env = args.Env(); + if (!RequireLoaded(env)) return env.Undefined(); if (args.Length() < 2 || !args[0].IsBigInt() || !args[1].IsString()) { TypeError::New(env, "Expected bigint (ctrl) and string (cmd)").ThrowAsJavaScriptException(); return env.Undefined(); @@ -460,7 +605,7 @@ Value ChatSendCmd(const CallbackInfo& args) { Promise promise = CreatePromiseAndCallback(env, cb); auto execute_fn = [ctrl, cmd](ResultAsyncWorker* worker) { - char* c_res = chat_send_cmd(ctrl, cmd.c_str()); + char* c_res = lib.chat_send_cmd(ctrl, cmd.c_str()); HandleCResult(worker, c_res, "chat_send_cmd"); }; @@ -472,6 +617,7 @@ Value ChatSendCmd(const CallbackInfo& args) { Value ChatRecvMsgWait(const CallbackInfo& args) { Env env = args.Env(); + if (!RequireLoaded(env)) return env.Undefined(); if (args.Length() < 2 || !args[0].IsBigInt() || !args[1].IsNumber()) { TypeError::New(env, "Expected bigint (ctrl), number (wait)").ThrowAsJavaScriptException(); return env.Undefined(); @@ -503,6 +649,7 @@ Value ChatRecvMsgWait(const CallbackInfo& args) { Value ChatWriteFile(const CallbackInfo& args) { Env env = args.Env(); + if (!RequireLoaded(env)) return env.Undefined(); if (args.Length() < 3 || !args[0].IsBigInt() || !args[1].IsString() || !(args[2].IsArrayBuffer() || args[2].IsTypedArray())) { TypeError::New(env, "Expected bigint (ctrl), string (path), ArrayBuffer or Uint8Array").ThrowAsJavaScriptException(); return env.Undefined(); @@ -530,7 +677,7 @@ Value ChatWriteFile(const CallbackInfo& args) { Promise promise = CreatePromiseAndCallback(env, cb); auto execute_fn = [ctrl, path, data, len](ResultAsyncWorker* worker) { - char* c_res = chat_write_file(ctrl, path.c_str(), data, static_cast(len)); + char* c_res = lib.chat_write_file(ctrl, path.c_str(), data, static_cast(len)); HandleCResult(worker, c_res, "chat_write_file"); }; @@ -543,6 +690,7 @@ Value ChatWriteFile(const CallbackInfo& args) { Value ChatReadFile(const CallbackInfo& args) { Env env = args.Env(); + if (!RequireLoaded(env)) return env.Undefined(); if (args.Length() < 3 || !args[0].IsString() || !args[1].IsString() || !args[2].IsString()) { TypeError::New(env, "Expected three strings (path, key, nonce)").ThrowAsJavaScriptException(); return env.Undefined(); @@ -556,7 +704,7 @@ Value ChatReadFile(const CallbackInfo& args) { Promise promise = CreatePromiseAndCallback(env, cb); auto execute_fn = [path, key, nonce](BinaryAsyncWorker* worker) { - char* buf = chat_read_file(path.c_str(), key.c_str(), nonce.c_str()); + char* buf = lib.chat_read_file(path.c_str(), key.c_str(), nonce.c_str()); if (buf == nullptr) { worker->SetWorkerError("chat_read_file failed"); return; @@ -586,6 +734,7 @@ Value ChatReadFile(const CallbackInfo& args) { Value ChatEncryptFile(const CallbackInfo& args) { Env env = args.Env(); + if (!RequireLoaded(env)) return env.Undefined(); if (args.Length() < 3 || !args[0].IsBigInt() || !args[1].IsString() || !args[2].IsString()) { TypeError::New(env, "Expected bigint (ctrl), two strings (fromPath, toPath)").ThrowAsJavaScriptException(); return env.Undefined(); @@ -599,7 +748,7 @@ Value ChatEncryptFile(const CallbackInfo& args) { Promise promise = CreatePromiseAndCallback(env, cb); auto execute_fn = [ctrl, fromPath, toPath](ResultAsyncWorker* worker) { - char* c_res = chat_encrypt_file(ctrl, fromPath.c_str(), toPath.c_str()); + char* c_res = lib.chat_encrypt_file(ctrl, fromPath.c_str(), toPath.c_str()); HandleCResult(worker, c_res, "chat_encrypt_file"); }; @@ -611,6 +760,7 @@ Value ChatEncryptFile(const CallbackInfo& args) { Value ChatDecryptFile(const CallbackInfo& args) { Env env = args.Env(); + if (!RequireLoaded(env)) return env.Undefined(); if (args.Length() < 4 || !args[0].IsString() || !args[1].IsString() || !args[2].IsString() || !args[3].IsString()) { TypeError::New(env, "Expected four strings (fromPath, key, nonce, toPath)").ThrowAsJavaScriptException(); return env.Undefined(); @@ -625,7 +775,7 @@ Value ChatDecryptFile(const CallbackInfo& args) { Promise promise = CreatePromiseAndCallback(env, cb); auto execute_fn = [fromPath, key, nonce, toPath](ResultAsyncWorker* worker) { - char* c_res = chat_decrypt_file(fromPath.c_str(), key.c_str(), nonce.c_str(), toPath.c_str()); + char* c_res = lib.chat_decrypt_file(fromPath.c_str(), key.c_str(), nonce.c_str(), toPath.c_str()); HandleCResult(worker, c_res, "chat_decrypt_file"); }; @@ -636,7 +786,7 @@ Value ChatDecryptFile(const CallbackInfo& args) { } Object Init(Env env, Object exports) { - haskell_init(); + exports.Set("load", Function::New(env, Load)); auto* receivers = new Receivers(); // Stopping all receivers before joining any bounds teardown by the longest in-flight receive. env.AddCleanupHook([receivers]() { diff --git a/packages/simplex-chat-nodejs/cpp/simplex.h b/packages/simplex-chat-nodejs/cpp/simplex.h index ddc052946a..7c44313295 100644 --- a/packages/simplex-chat-nodejs/cpp/simplex.h +++ b/packages/simplex-chat-nodejs/cpp/simplex.h @@ -9,40 +9,30 @@ #ifndef SimpleX_h #define SimpleX_h -extern "C" void hs_init(int argc, char **argv[]); -extern "C" void hs_init_with_rtsopts(int * argc, char **argv[]); -extern "C" void hs_thread_done(void); - typedef long* chat_ctrl; +typedef void (*hs_init_with_rtsopts_fn)(int *argc, char **argv[]); +typedef void (*hs_thread_done_fn)(void); // the last parameter is used to return the pointer to chat controller -extern "C" char *chat_migrate_init(const char *path, const char *key, const char *confirm, chat_ctrl *ctrl); -extern "C" char *chat_migrate_init_queue(const char *path, const char *key, const char *confirm, const int queueSize, chat_ctrl *ctrl); -extern "C" char *chat_close_store(chat_ctrl ctrl); -extern "C" char *chat_reopen_store(chat_ctrl ctrl); -extern "C" char *chat_send_cmd(chat_ctrl ctrl, const char *cmd); -extern "C" char *chat_recv_msg_wait(chat_ctrl ctrl, const int wait); -extern "C" char *chat_parse_markdown(const char *str); -extern "C" char *chat_parse_server(const char *str); -extern "C" char *chat_password_hash(const char *pwd, const char *salt); -extern "C" char *chat_valid_name(const char *name); -extern "C" int chat_json_length(const char *str); -extern "C" char *chat_encrypt_media(chat_ctrl ctrl, const char *key, const char *frame, const int len); -extern "C" char *chat_decrypt_media(const char *key, const char *frame, const int len); +typedef char *(*chat_migrate_init_fn)(const char *path, const char *key, const char *confirm, chat_ctrl *ctrl); +typedef char *(*chat_migrate_init_queue_fn)(const char *path, const char *key, const char *confirm, int queueSize, chat_ctrl *ctrl); +typedef char *(*chat_close_store_fn)(chat_ctrl ctrl); +typedef char *(*chat_send_cmd_fn)(chat_ctrl ctrl, const char *cmd); +typedef char *(*chat_recv_msg_wait_fn)(chat_ctrl ctrl, int wait); // chat_write_file returns null-terminated string with JSON of WriteFileResult -extern "C" char *chat_write_file(chat_ctrl ctrl, const char *path, const char *data, const int len); +typedef char *(*chat_write_file_fn)(chat_ctrl ctrl, const char *path, const char *data, int len); // chat_read_file returns a buffer with: // result status (1 byte), then if // status == 0 (success): buffer length (uint32, 4 bytes), buffer of specified length. // status == 1 (error): null-terminated error message string. -extern "C" char *chat_read_file(const char *path, const char *key, const char *nonce); +typedef char *(*chat_read_file_fn)(const char *path, const char *key, const char *nonce); // chat_encrypt_file returns null-terminated string with JSON of WriteFileResult -extern "C" char *chat_encrypt_file(chat_ctrl ctrl, const char *fromPath, const char *toPath); +typedef char *(*chat_encrypt_file_fn)(chat_ctrl ctrl, const char *fromPath, const char *toPath); // chat_decrypt_file returns null-terminated string with the error message -extern "C" char *chat_decrypt_file(const char *fromPath, const char *key, const char *nonce, const char *toPath); +typedef char *(*chat_decrypt_file_fn)(const char *fromPath, const char *key, const char *nonce, const char *toPath); #endif /* simplex_h */ \ No newline at end of file diff --git a/packages/simplex-chat-nodejs/docs/Namespace.core.md b/packages/simplex-chat-nodejs/docs/Namespace.core.md index 82b0d9ffba..fdf9f8b4dd 100644 --- a/packages/simplex-chat-nodejs/docs/Namespace.core.md +++ b/packages/simplex-chat-nodejs/docs/Namespace.core.md @@ -32,6 +32,7 @@ You are unlikely to ever need to use this module directly. ## Type Aliases +- [Backend](core.TypeAlias.Backend.md) - [DBMigrationError](core.TypeAlias.DBMigrationError.md) - [MigrationError](core.TypeAlias.MigrationError.md) - [MTRError](core.TypeAlias.MTRError.md) @@ -46,3 +47,4 @@ You are unlikely to ever need to use this module directly. - [chatRecvMsgWait](core.Function.chatRecvMsgWait.md) - [chatSendCmd](core.Function.chatSendCmd.md) - [chatWriteFile](core.Function.chatWriteFile.md) +- [loadLibrary](core.Function.loadLibrary.md) diff --git a/packages/simplex-chat-nodejs/docs/api.Class.ChatApi.md b/packages/simplex-chat-nodejs/docs/api.Class.ChatApi.md index 287099906b..cf293f55d9 100644 --- a/packages/simplex-chat-nodejs/docs/api.Class.ChatApi.md +++ b/packages/simplex-chat-nodejs/docs/api.Class.ChatApi.md @@ -6,7 +6,7 @@ # Class: ChatApi -Defined in: [src/api.ts:97](../src/api.ts#L97) +Defined in: [src/api.ts:95](../src/api.ts#L95) Main API class for interacting with the chat core library. @@ -16,7 +16,7 @@ Main API class for interacting with the chat core library. > `protected` **ctrl\_**: `bigint` \| `undefined` -Defined in: [src/api.ts:103](../src/api.ts#L103) +Defined in: [src/api.ts:101](../src/api.ts#L101) ## Accessors @@ -26,7 +26,7 @@ Defined in: [src/api.ts:103](../src/api.ts#L103) > **get** **ctrl**(): `bigint` -Defined in: [src/api.ts:344](../src/api.ts#L344) +Defined in: [src/api.ts:343](../src/api.ts#L343) Chat controller reference @@ -42,7 +42,7 @@ Chat controller reference > **get** **initialized**(): `boolean` -Defined in: [src/api.ts:330](../src/api.ts#L330) +Defined in: [src/api.ts:329](../src/api.ts#L329) Chat controller is initialized @@ -58,7 +58,7 @@ Chat controller is initialized > **get** **started**(): `boolean` -Defined in: [src/api.ts:337](../src/api.ts#L337) +Defined in: [src/api.ts:336](../src/api.ts#L336) Chat controller is started @@ -72,7 +72,7 @@ Chat controller is started > **apiAcceptContactRequest**(`contactReqId`): `Promise`\<`Contact`\> -Defined in: [src/api.ts:750](../src/api.ts#L750) +Defined in: [src/api.ts:770](../src/api.ts#L770) Accept contact request. Network usage: interactive. @@ -93,7 +93,7 @@ Network usage: interactive. > **apiAcceptMember**(`groupId`, `groupMemberId`, `memberRole`): `Promise`\<`GroupMember`\> -Defined in: [src/api.ts:570](../src/api.ts#L570) +Defined in: [src/api.ts:590](../src/api.ts#L590) Accept group member. Requires Admin role. Network usage: background. @@ -122,7 +122,7 @@ Network usage: background. > **apiAddMember**(`groupId`, `contactId`, `memberRole`): `Promise`\<`GroupMember`\> -Defined in: [src/api.ts:550](../src/api.ts#L550) +Defined in: [src/api.ts:560](../src/api.ts#L560) Add contact to group. Requires bot to have Admin role. Network usage: interactive. @@ -147,11 +147,44 @@ Network usage: interactive. *** +### apiAddMemberViaGroup() + +> **apiAddMemberViaGroup**(`groupId`, `viaGroupId`, `groupMemberId`, `memberRole`): `Promise`\<`GroupMember`\> + +Defined in: [src/api.ts:570](../src/api.ts#L570) + +Add member of another group to group. Requires bot to have Admin role in the group, and direct messages allowed for its role in another group. +Network usage: interactive. + +#### Parameters + +##### groupId + +`number` + +##### viaGroupId + +`number` + +##### groupMemberId + +`number` + +##### memberRole + +`GroupMemberRole` + +#### Returns + +`Promise`\<`GroupMember`\> + +*** + ### apiBlockMembersForAll() > **apiBlockMembersForAll**(`groupId`, `groupMemberIds`, `blocked`): `Promise`\<`void`\> -Defined in: [src/api.ts:590](../src/api.ts#L590) +Defined in: [src/api.ts:610](../src/api.ts#L610) Block members. Requires Moderator role. Network usage: background. @@ -180,7 +213,7 @@ Network usage: background. > **apiCancelFile**(`fileId`): `Promise`\<`void`\> -Defined in: [src/api.ts:540](../src/api.ts#L540) +Defined in: [src/api.ts:550](../src/api.ts#L550) Cancel file. Network usage: background. @@ -201,7 +234,7 @@ Network usage: background. > **apiChatItemReaction**(`chatType`, `chatId`, `chatItemId`, `add`, `reaction`): `Promise`\<`ACIReaction`\> -Defined in: [src/api.ts:513](../src/api.ts#L513) +Defined in: [src/api.ts:523](../src/api.ts#L523) Add/remove message reaction. Network usage: background. @@ -238,7 +271,7 @@ Network usage: background. > **apiConnect**(`userId`, `incognito`, `preparedLink`): `Promise`\<[`ConnReqType`](api.Enumeration.ConnReqType.md)\> -Defined in: [src/api.ts:719](../src/api.ts#L719) +Defined in: [src/api.ts:739](../src/api.ts#L739) Connect via prepared SimpleX link. The link can be 1-time invitation link, contact address or group link Network usage: interactive. @@ -267,7 +300,7 @@ Network usage: interactive. > **apiConnectActiveUser**(`connLink`): `Promise`\<[`ConnReqType`](api.Enumeration.ConnReqType.md)\> -Defined in: [src/api.ts:728](../src/api.ts#L728) +Defined in: [src/api.ts:748](../src/api.ts#L748) Connect via SimpleX link as string in the active user profile. Network usage: interactive. @@ -288,7 +321,7 @@ Network usage: interactive. > **apiConnectPlan**(`userId`, `connectionLink`): `Promise`\<\[`ConnectionPlan`, `CreatedConnLink`\]\> -Defined in: [src/api.ts:709](../src/api.ts#L709) +Defined in: [src/api.ts:729](../src/api.ts#L729) Determine SimpleX link type and if the bot is already connected via this link. Network usage: interactive. @@ -313,7 +346,7 @@ Network usage: interactive. > **apiCreateActiveUser**(`profile?`): `Promise`\<`User`\> -Defined in: [src/api.ts:887](../src/api.ts#L887) +Defined in: [src/api.ts:907](../src/api.ts#L907) Create new user profile Network usage: no. @@ -334,7 +367,7 @@ Network usage: no. > **apiCreateGroupLink**(`groupId`, `memberRole`): `Promise`\<`string`\> -Defined in: [src/api.ts:650](../src/api.ts#L650) +Defined in: [src/api.ts:670](../src/api.ts#L670) Create group link. Network usage: interactive. @@ -359,7 +392,7 @@ Network usage: interactive. > **apiCreateLink**(`userId`): `Promise`\<`string`\> -Defined in: [src/api.ts:696](../src/api.ts#L696) +Defined in: [src/api.ts:716](../src/api.ts#L716) Create 1-time invitation link. Network usage: interactive. @@ -380,7 +413,7 @@ Network usage: interactive. > **apiCreateMemberContact**(`groupId`, `groupMemberId`): `Promise`\<`Contact`\> -Defined in: [src/api.ts:953](../src/api.ts#L953) +Defined in: [src/api.ts:973](../src/api.ts#L973) Create a direct message contact with a group member. Returns the created contact. @@ -406,7 +439,7 @@ Network usage: interactive. > **apiCreateUserAddress**(`userId`): `Promise`\<`CreatedConnLink`\> -Defined in: [src/api.ts:361](../src/api.ts#L361) +Defined in: [src/api.ts:360](../src/api.ts#L360) Create bot address. Network usage: interactive. @@ -427,7 +460,7 @@ Network usage: interactive. > **apiDeleteChat**(`chatType`, `chatId`, `deleteMode?`): `Promise`\<`void`\> -Defined in: [src/api.ts:809](../src/api.ts#L809) +Defined in: [src/api.ts:829](../src/api.ts#L829) Delete chat. Network usage: background. @@ -456,7 +489,7 @@ Network usage: background. > **apiDeleteChatItems**(`chatType`, `chatId`, `chatItemIds`, `deleteMode`): `Promise`\<`ChatItemDeletion`[]\> -Defined in: [src/api.ts:488](../src/api.ts#L488) +Defined in: [src/api.ts:498](../src/api.ts#L498) Delete message. Network usage: background. @@ -489,7 +522,7 @@ Network usage: background. > **apiDeleteGroupLink**(`groupId`): `Promise`\<`void`\> -Defined in: [src/api.ts:672](../src/api.ts#L672) +Defined in: [src/api.ts:692](../src/api.ts#L692) Delete group link. Network usage: background. @@ -510,7 +543,7 @@ Network usage: background. > **apiDeleteMemberChatItem**(`groupId`, `chatItemIds`): `Promise`\<`ChatItemDeletion`[]\> -Defined in: [src/api.ts:503](../src/api.ts#L503) +Defined in: [src/api.ts:513](../src/api.ts#L513) Moderate message. Requires Moderator role (and higher than message author's). Network usage: background. @@ -535,7 +568,7 @@ Network usage: background. > **apiDeleteUser**(`userId`, `delSMPQueues`, `viewPwd?`): `Promise`\<`void`\> -Defined in: [src/api.ts:917](../src/api.ts#L917) +Defined in: [src/api.ts:937](../src/api.ts#L937) Delete user profile. Network usage: background. @@ -564,7 +597,7 @@ Network usage: background. > **apiDeleteUserAddress**(`userId`): `Promise`\<`void`\> -Defined in: [src/api.ts:371](../src/api.ts#L371) +Defined in: [src/api.ts:370](../src/api.ts#L370) Deletes a user address. Network usage: background. @@ -585,7 +618,7 @@ Network usage: background. > **apiGetActiveUser**(): `Promise`\<`User` \| `undefined`\> -Defined in: [src/api.ts:867](../src/api.ts#L867) +Defined in: [src/api.ts:887](../src/api.ts#L887) Get active user profile Network usage: no. @@ -600,7 +633,7 @@ Network usage: no. > **apiGetChat**(`chatType`, `chatId`, `count`): `Promise`\<`any`\> -Defined in: [src/api.ts:857](../src/api.ts#L857) +Defined in: [src/api.ts:877](../src/api.ts#L877) Get chat items. Network usage: no. @@ -629,7 +662,7 @@ Network usage: no. > **apiGetChats**(`userId`, `pagination`, `query?`, `pendingConnections?`): `Promise`\<`AChat`[]\> -Defined in: [src/api.ts:794](../src/api.ts#L794) +Defined in: [src/api.ts:814](../src/api.ts#L814) Get chat previews (paginated). Network usage: no. @@ -666,7 +699,7 @@ on large databases. > **apiGetGroupLink**(`groupId`): `Promise`\<`GroupLink`\> -Defined in: [src/api.ts:681](../src/api.ts#L681) +Defined in: [src/api.ts:701](../src/api.ts#L701) Get group link. Network usage: no. @@ -687,7 +720,7 @@ Network usage: no. > **apiGetGroupLinkStr**(`groupId`): `Promise`\<`string`\> -Defined in: [src/api.ts:687](../src/api.ts#L687) +Defined in: [src/api.ts:707](../src/api.ts#L707) #### Parameters @@ -705,7 +738,7 @@ Defined in: [src/api.ts:687](../src/api.ts#L687) > **apiGetUserAddress**(`userId`): `Promise`\<`UserContactLink` \| `undefined`\> -Defined in: [src/api.ts:381](../src/api.ts#L381) +Defined in: [src/api.ts:380](../src/api.ts#L380) Get bot address and settings. Network usage: no. @@ -726,7 +759,7 @@ Network usage: no. > **apiJoinGroup**(`groupId`): `Promise`\<`GroupInfo`\> -Defined in: [src/api.ts:560](../src/api.ts#L560) +Defined in: [src/api.ts:580](../src/api.ts#L580) Join group. Network usage: interactive. @@ -747,7 +780,7 @@ Network usage: interactive. > **apiLeaveGroup**(`groupId`): `Promise`\<`GroupInfo`\> -Defined in: [src/api.ts:610](../src/api.ts#L610) +Defined in: [src/api.ts:630](../src/api.ts#L630) Leave group. Network usage: background. @@ -768,7 +801,7 @@ Network usage: background. > **apiListContacts**(`userId`): `Promise`\<`Contact`[]\> -Defined in: [src/api.ts:770](../src/api.ts#L770) +Defined in: [src/api.ts:790](../src/api.ts#L790) Get contacts. Network usage: no. @@ -789,7 +822,7 @@ Network usage: no. > **apiListGroups**(`userId`, `contactId?`, `search?`): `Promise`\<`GroupInfo`[]\> -Defined in: [src/api.ts:780](../src/api.ts#L780) +Defined in: [src/api.ts:800](../src/api.ts#L800) Get groups. Network usage: no. @@ -818,7 +851,7 @@ Network usage: no. > **apiListMembers**(`groupId`): `Promise`\<`GroupMember`[]\> -Defined in: [src/api.ts:620](../src/api.ts#L620) +Defined in: [src/api.ts:640](../src/api.ts#L640) Get group members. Network usage: no. @@ -839,7 +872,7 @@ Network usage: no. > **apiListUsers**(): `Promise`\<`UserInfo`[]\> -Defined in: [src/api.ts:897](../src/api.ts#L897) +Defined in: [src/api.ts:917](../src/api.ts#L917) Get all user profiles Network usage: no. @@ -854,7 +887,7 @@ Network usage: no. > **apiNewGroup**(`userId`, `groupProfile`): `Promise`\<`GroupInfo`\> -Defined in: [src/api.ts:630](../src/api.ts#L630) +Defined in: [src/api.ts:650](../src/api.ts#L650) Create group. Network usage: no. @@ -879,7 +912,7 @@ Network usage: no. > **apiReceiveFile**(`fileId`): `Promise`\<`AChatItem`\> -Defined in: [src/api.ts:529](../src/api.ts#L529) +Defined in: [src/api.ts:539](../src/api.ts#L539) Receive file. Network usage: no. @@ -900,7 +933,7 @@ Network usage: no. > **apiRejectContactRequest**(`contactReqId`): `Promise`\<`void`\> -Defined in: [src/api.ts:760](../src/api.ts#L760) +Defined in: [src/api.ts:780](../src/api.ts#L780) Reject contact request. The user who sent the request is **not notified**. Network usage: no. @@ -921,7 +954,7 @@ Network usage: no. > **apiRemoveMembers**(`groupId`, `memberIds`, `withMessages?`): `Promise`\<`GroupMember`[]\> -Defined in: [src/api.ts:600](../src/api.ts#L600) +Defined in: [src/api.ts:620](../src/api.ts#L620) Remove members. Requires Admin role. Network usage: background. @@ -950,7 +983,7 @@ Network usage: background. > **apiSendMemberContactInvitation**(`contactId`, `message?`): `Promise`\<`Contact`\> -Defined in: [src/api.ts:964](../src/api.ts#L964) +Defined in: [src/api.ts:984](../src/api.ts#L984) Send a direct message invitation to a group member contact. The contact must have been created with [apiCreateMemberContact](#apicreatemembercontact). @@ -976,7 +1009,7 @@ Network usage: interactive. > **apiSendMessages**(`chat`, `messages`, `liveMessage?`): `Promise`\<`AChatItem`[]\> -Defined in: [src/api.ts:432](../src/api.ts#L432) +Defined in: [src/api.ts:442](../src/api.ts#L442) Send messages. Network usage: background. @@ -1005,7 +1038,7 @@ Network usage: background. > **apiSendTextMessage**(`chat`, `text`, `inReplyTo?`): `Promise`\<`AChatItem`[]\> -Defined in: [src/api.ts:455](../src/api.ts#L455) +Defined in: [src/api.ts:465](../src/api.ts#L465) Send text message. Network usage: background. @@ -1034,7 +1067,7 @@ Network usage: background. > **apiSendTextReply**(`chatItem`, `text`): `Promise`\<`AChatItem`[]\> -Defined in: [src/api.ts:463](../src/api.ts#L463) +Defined in: [src/api.ts:473](../src/api.ts#L473) Send text message in reply to received message. Network usage: background. @@ -1059,7 +1092,7 @@ Network usage: background. > **apiSetActiveUser**(`userId`, `viewPwd?`): `Promise`\<`User`\> -Defined in: [src/api.ts:907](../src/api.ts#L907) +Defined in: [src/api.ts:927](../src/api.ts#L927) Set active user profile Network usage: no. @@ -1084,7 +1117,7 @@ Network usage: no. > **apiSetAddressSettings**(`userId`, `__namedParameters`): `Promise`\<`void`\> -Defined in: [src/api.ts:415](../src/api.ts#L415) +Defined in: [src/api.ts:414](../src/api.ts#L414) Set bot address settings. Network usage: interactive. @@ -1109,7 +1142,7 @@ Network usage: interactive. > **apiSetAutoAcceptMemberContacts**(`userId`, `onOff`): `Promise`\<`void`\> -Defined in: [src/api.ts:846](../src/api.ts#L846) +Defined in: [src/api.ts:866](../src/api.ts#L866) Set auto-accept member contacts. Network usage: no. @@ -1134,7 +1167,7 @@ Network usage: no. > **apiSetContactCustomData**(`contactId`, `customData?`): `Promise`\<`void`\> -Defined in: [src/api.ts:836](../src/api.ts#L836) +Defined in: [src/api.ts:856](../src/api.ts#L856) Set contact custom data. Network usage: no. @@ -1159,7 +1192,7 @@ Network usage: no. > **apiSetContactPrefs**(`contactId`, `preferences`): `Promise`\<`void`\> -Defined in: [src/api.ts:943](../src/api.ts#L943) +Defined in: [src/api.ts:963](../src/api.ts#L963) Configure chat preference overrides for the contact. Network usage: background. @@ -1184,7 +1217,7 @@ Network usage: background. > **apiSetGroupCustomData**(`groupId`, `customData?`): `Promise`\<`void`\> -Defined in: [src/api.ts:826](../src/api.ts#L826) +Defined in: [src/api.ts:846](../src/api.ts#L846) Set group custom data. Network usage: no. @@ -1209,7 +1242,7 @@ Network usage: no. > **apiSetGroupLinkMemberRole**(`groupId`, `memberRole`): `Promise`\<`void`\> -Defined in: [src/api.ts:663](../src/api.ts#L663) +Defined in: [src/api.ts:683](../src/api.ts#L683) Set member role for group link. Network usage: no. @@ -1234,7 +1267,7 @@ Network usage: no. > **apiSetMembersRole**(`groupId`, `groupMemberIds`, `memberRole`): `Promise`\<`void`\> -Defined in: [src/api.ts:580](../src/api.ts#L580) +Defined in: [src/api.ts:600](../src/api.ts#L600) Set members role. Requires Admin role. Network usage: background. @@ -1263,7 +1296,7 @@ Network usage: background. > **apiSetProfileAddress**(`userId`, `enable`): `Promise`\<`UserProfileUpdateSummary`\> -Defined in: [src/api.ts:399](../src/api.ts#L399) +Defined in: [src/api.ts:398](../src/api.ts#L398) Add address to bot profile. Network usage: interactive. @@ -1284,11 +1317,33 @@ Network usage: interactive. *** +### apiSetUserDomain() + +> **apiSetUserDomain**(`userId`, `simplexDomain?`): `Promise`\<`User`\> + +Defined in: [src/api.ts:427](../src/api.ts#L427) + +#### Parameters + +##### userId + +`number` + +##### simplexDomain? + +`string` + +#### Returns + +`Promise`\<`User`\> + +*** + ### apiUpdateChatItem() > **apiUpdateChatItem**(`chatType`, `chatId`, `chatItemId`, `msgContent`, `liveMessage`): `Promise`\<`ChatItem`\> -Defined in: [src/api.ts:471](../src/api.ts#L471) +Defined in: [src/api.ts:481](../src/api.ts#L481) Update message. Network usage: background. @@ -1325,7 +1380,7 @@ Network usage: background. > **apiUpdateGroupProfile**(`groupId`, `groupProfile`): `Promise`\<`GroupInfo`\> -Defined in: [src/api.ts:640](../src/api.ts#L640) +Defined in: [src/api.ts:660](../src/api.ts#L660) Update group profile. Network usage: background. @@ -1350,7 +1405,7 @@ Network usage: background. > **apiUpdateProfile**(`userId`, `profile`): `Promise`\<`UserProfileUpdateSummary` \| `undefined`\> -Defined in: [src/api.ts:927](../src/api.ts#L927) +Defined in: [src/api.ts:947](../src/api.ts#L947) Update user profile. Network usage: background. @@ -1375,7 +1430,7 @@ Network usage: background. > **close**(): `Promise`\<`void`\> -Defined in: [src/api.ts:158](../src/api.ts#L158) +Defined in: [src/api.ts:157](../src/api.ts#L157) Stop chat controller and close chat database. The database is not closed if stopping fails. @@ -1391,7 +1446,7 @@ Usually doesn't need to be called in chat bots. > **off**\<`K`\>(`event`, `subscriber?`): `void` -Defined in: [src/api.ts:302](../src/api.ts#L302) +Defined in: [src/api.ts:301](../src/api.ts#L301) Unsubscribe all or a specific handler from a specific event. @@ -1425,7 +1480,7 @@ An optional subscriber function for the event. > **offAny**(`receiver?`): `void` -Defined in: [src/api.ts:318](../src/api.ts#L318) +Defined in: [src/api.ts:317](../src/api.ts#L317) Unsubscribe all or a specific handler from any events. @@ -1449,7 +1504,7 @@ An optional subscriber function for the event. > **on**\<`K`\>(`subscribers`): `void` -Defined in: [src/api.ts:212](../src/api.ts#L212) +Defined in: [src/api.ts:211](../src/api.ts#L211) Subscribe multiple event handlers at once. @@ -1479,7 +1534,7 @@ If the same function is subscribed to event. > **on**\<`K`\>(`event`, `subscriber`): `void` -Defined in: [src/api.ts:220](../src/api.ts#L220) +Defined in: [src/api.ts:219](../src/api.ts#L219) Subscribe a handler to a specific event. @@ -1517,7 +1572,7 @@ If the same function is subscribed to event. > **onAny**(`receiver`): `void` -Defined in: [src/api.ts:243](../src/api.ts#L243) +Defined in: [src/api.ts:242](../src/api.ts#L242) Subscribe a handler to any event. @@ -1543,7 +1598,7 @@ If the same function is subscribed to event. > **once**\<`K`\>(`event`, `subscriber`): `void` -Defined in: [src/api.ts:254](../src/api.ts#L254) +Defined in: [src/api.ts:253](../src/api.ts#L253) Subscribe a handler to a specific event to be delivered one time. @@ -1581,7 +1636,7 @@ If the same function is subscribed to event. > **recvChatEvent**(`wait?`): `Promise`\<`ChatEvent` \| `undefined`\> -Defined in: [src/api.ts:353](../src/api.ts#L353) +Defined in: [src/api.ts:352](../src/api.ts#L352) #### Parameters @@ -1599,7 +1654,7 @@ Defined in: [src/api.ts:353](../src/api.ts#L353) > **sendChatCmd**(`cmd`): `Promise`\<`ChatResponse`\> -Defined in: [src/api.ts:349](../src/api.ts#L349) +Defined in: [src/api.ts:348](../src/api.ts#L348) #### Parameters @@ -1617,7 +1672,7 @@ Defined in: [src/api.ts:349](../src/api.ts#L349) > **startChat**(): `Promise`\<`void`\> -Defined in: [src/api.ts:124](../src/api.ts#L124) +Defined in: [src/api.ts:123](../src/api.ts#L123) Start chat controller. Must be called with the existing user profile. @@ -1631,7 +1686,7 @@ Start chat controller. Must be called with the existing user profile. > **stopChat**(): `Promise`\<`void`\> -Defined in: [src/api.ts:147](../src/api.ts#L147) +Defined in: [src/api.ts:146](../src/api.ts#L146) Stop chat controller. `close` calls it before closing the database. @@ -1649,7 +1704,7 @@ Usually doesn't need to be called in chat bots. > **wait**\<`K`\>(`event`): `Promise`\<`ChatEvent` & `object`\> -Defined in: [src/api.ts:262](../src/api.ts#L262) +Defined in: [src/api.ts:261](../src/api.ts#L261) Waits for specific event, with an optional predicate. Returns `undefined` on timeout if specified. @@ -1674,7 +1729,7 @@ Returns `undefined` on timeout if specified. > **wait**\<`K`\>(`event`, `predicate`): `Promise`\<`ChatEvent` & `object`\> -Defined in: [src/api.ts:263](../src/api.ts#L263) +Defined in: [src/api.ts:262](../src/api.ts#L262) Waits for specific event, with an optional predicate. Returns `undefined` on timeout if specified. @@ -1703,7 +1758,7 @@ Returns `undefined` on timeout if specified. > **wait**\<`K`\>(`event`, `timeout`): `Promise`\ -Defined in: [src/api.ts:264](../src/api.ts#L264) +Defined in: [src/api.ts:263](../src/api.ts#L263) Waits for specific event, with an optional predicate. Returns `undefined` on timeout if specified. @@ -1732,7 +1787,7 @@ Returns `undefined` on timeout if specified. > **wait**\<`K`\>(`event`, `predicate`, `timeout`): `Promise`\ -Defined in: [src/api.ts:265](../src/api.ts#L265) +Defined in: [src/api.ts:264](../src/api.ts#L264) Waits for specific event, with an optional predicate. Returns `undefined` on timeout if specified. @@ -1767,9 +1822,9 @@ Returns `undefined` on timeout if specified. > `static` **init**(`db`, `confirm?`, `queueSize?`): `Promise`\<`ChatApi`\> -Defined in: [src/api.ts:111](../src/api.ts#L111) +Defined in: [src/api.ts:109](../src/api.ts#L109) -Initializes the ChatApi. +Initializes the ChatApi, loading libsimplex for `db.type` (downloaded on first use). #### Parameters diff --git a/packages/simplex-chat-nodejs/docs/api.TypeAlias.DbConfig.md b/packages/simplex-chat-nodejs/docs/api.TypeAlias.DbConfig.md index 7fe255327c..26934a9c6b 100644 --- a/packages/simplex-chat-nodejs/docs/api.TypeAlias.DbConfig.md +++ b/packages/simplex-chat-nodejs/docs/api.TypeAlias.DbConfig.md @@ -8,12 +8,10 @@ > **DbConfig** = \{ `encryptionKey?`: `string`; `filePrefix`: `string`; `type`: `"sqlite"`; \} \| \{ `connectionString`: `string`; `schemaPrefix?`: `string`; `type`: `"postgres"`; \} -Defined in: [src/api.ts:65](../src/api.ts#L65) +Defined in: [src/api.ts:63](../src/api.ts#L63) -Database configuration. The native library is built against exactly one -backend (see `simplex_backend` / `SIMPLEX_BACKEND` at install time); this -type makes the caller state which one they are targeting so field names -can't lie about their meaning. +Database configuration. `type` selects the libsimplex backend loaded by +`ChatApi.init`; one backend per process. ## Union Members diff --git a/packages/simplex-chat-nodejs/docs/bot.Function.run.md b/packages/simplex-chat-nodejs/docs/bot.Function.run.md index 0af2644bc2..bc31ad01a8 100644 --- a/packages/simplex-chat-nodejs/docs/bot.Function.run.md +++ b/packages/simplex-chat-nodejs/docs/bot.Function.run.md @@ -8,7 +8,7 @@ > **run**(`__namedParameters`): `Promise`\<\[[`ChatApi`](api.Class.ChatApi.md), `User`, `UserContactLink` \| `undefined`\]\> -Defined in: [src/bot.ts:48](../src/bot.ts#L48) +Defined in: [src/bot.ts:49](../src/bot.ts#L49) ## Parameters diff --git a/packages/simplex-chat-nodejs/docs/bot.Function.subscribeChatItems.md b/packages/simplex-chat-nodejs/docs/bot.Function.subscribeChatItems.md index 8fbb699f80..0bcc49cbc6 100644 --- a/packages/simplex-chat-nodejs/docs/bot.Function.subscribeChatItems.md +++ b/packages/simplex-chat-nodejs/docs/bot.Function.subscribeChatItems.md @@ -8,7 +8,7 @@ > **subscribeChatItems**(`bot`, `onMessage`, `commands`): `void` -Defined in: [src/bot.ts:108](../src/bot.ts#L108) +Defined in: [src/bot.ts:110](../src/bot.ts#L110) ## Parameters diff --git a/packages/simplex-chat-nodejs/docs/bot.Interface.BotConfig.md b/packages/simplex-chat-nodejs/docs/bot.Interface.BotConfig.md index 3f7757041b..036afe64d4 100644 --- a/packages/simplex-chat-nodejs/docs/bot.Interface.BotConfig.md +++ b/packages/simplex-chat-nodejs/docs/bot.Interface.BotConfig.md @@ -14,7 +14,7 @@ Defined in: [src/bot.ts:36](../src/bot.ts#L36) > **dbOpts**: [`BotDbOpts`](bot.TypeAlias.BotDbOpts.md) -Defined in: [src/bot.ts:38](../src/bot.ts#L38) +Defined in: [src/bot.ts:39](../src/bot.ts#L39) *** @@ -22,7 +22,7 @@ Defined in: [src/bot.ts:38](../src/bot.ts#L38) > `optional` **events?**: [`EventSubscribers`](api.TypeAlias.EventSubscribers.md) -Defined in: [src/bot.ts:45](../src/bot.ts#L45) +Defined in: [src/bot.ts:46](../src/bot.ts#L46) *** @@ -30,7 +30,7 @@ Defined in: [src/bot.ts:45](../src/bot.ts#L45) > `optional` **onCommands?**: `object` -Defined in: [src/bot.ts:42](../src/bot.ts#L42) +Defined in: [src/bot.ts:43](../src/bot.ts#L43) #### Index Signature @@ -42,7 +42,7 @@ Defined in: [src/bot.ts:42](../src/bot.ts#L42) > `optional` **onMessage?**: (`chatItem`, `content`, `chat`) => `void` \| `Promise`\<`void`\> -Defined in: [src/bot.ts:40](../src/bot.ts#L40) +Defined in: [src/bot.ts:41](../src/bot.ts#L41) #### Parameters @@ -68,7 +68,7 @@ Defined in: [src/bot.ts:40](../src/bot.ts#L40) > **options**: [`BotOptions`](bot.Interface.BotOptions.md) -Defined in: [src/bot.ts:39](../src/bot.ts#L39) +Defined in: [src/bot.ts:40](../src/bot.ts#L40) *** @@ -77,3 +77,11 @@ Defined in: [src/bot.ts:39](../src/bot.ts#L39) > **profile**: `Profile` Defined in: [src/bot.ts:37](../src/bot.ts#L37) + +*** + +### simplexDomain? + +> `optional` **simplexDomain?**: `string` \| `null` + +Defined in: [src/bot.ts:38](../src/bot.ts#L38) diff --git a/packages/simplex-chat-nodejs/docs/core.Class.ChatAPIError.md b/packages/simplex-chat-nodejs/docs/core.Class.ChatAPIError.md index 3953d777da..64e3e764df 100644 --- a/packages/simplex-chat-nodejs/docs/core.Class.ChatAPIError.md +++ b/packages/simplex-chat-nodejs/docs/core.Class.ChatAPIError.md @@ -6,7 +6,7 @@ # Class: ChatAPIError -Defined in: [src/core.ts:95](../src/core.ts#L95) +Defined in: [src/core.ts:117](../src/core.ts#L117) ## Extends @@ -18,7 +18,7 @@ Defined in: [src/core.ts:95](../src/core.ts#L95) > **new ChatAPIError**(`message`, `chatError?`): `ChatAPIError` -Defined in: [src/core.ts:96](../src/core.ts#L96) +Defined in: [src/core.ts:118](../src/core.ts#L118) #### Parameters @@ -44,7 +44,7 @@ Defined in: [src/core.ts:96](../src/core.ts#L96) > **chatError**: `ChatError` \| `undefined` = `undefined` -Defined in: [src/core.ts:96](../src/core.ts#L96) +Defined in: [src/core.ts:118](../src/core.ts#L118) *** @@ -52,7 +52,7 @@ Defined in: [src/core.ts:96](../src/core.ts#L96) > **message**: `string` -Defined in: [src/core.ts:96](../src/core.ts#L96) +Defined in: [src/core.ts:118](../src/core.ts#L118) #### Inherited from diff --git a/packages/simplex-chat-nodejs/docs/core.Class.ChatInitError.md b/packages/simplex-chat-nodejs/docs/core.Class.ChatInitError.md index 649090e3ac..aefc4b9073 100644 --- a/packages/simplex-chat-nodejs/docs/core.Class.ChatInitError.md +++ b/packages/simplex-chat-nodejs/docs/core.Class.ChatInitError.md @@ -6,7 +6,7 @@ # Class: ChatInitError -Defined in: [src/core.ts:119](../src/core.ts#L119) +Defined in: [src/core.ts:141](../src/core.ts#L141) ## Extends @@ -18,7 +18,7 @@ Defined in: [src/core.ts:119](../src/core.ts#L119) > **new ChatInitError**(`message`, `dbMigrationError`): `ChatInitError` -Defined in: [src/core.ts:120](../src/core.ts#L120) +Defined in: [src/core.ts:142](../src/core.ts#L142) #### Parameters @@ -44,7 +44,7 @@ Defined in: [src/core.ts:120](../src/core.ts#L120) > **dbMigrationError**: [`DBMigrationError`](core.TypeAlias.DBMigrationError.md) -Defined in: [src/core.ts:120](../src/core.ts#L120) +Defined in: [src/core.ts:142](../src/core.ts#L142) *** @@ -52,7 +52,7 @@ Defined in: [src/core.ts:120](../src/core.ts#L120) > **message**: `string` -Defined in: [src/core.ts:120](../src/core.ts#L120) +Defined in: [src/core.ts:142](../src/core.ts#L142) #### Inherited from diff --git a/packages/simplex-chat-nodejs/docs/core.DBMigrationError.Interface.ErrorMigration.md b/packages/simplex-chat-nodejs/docs/core.DBMigrationError.Interface.ErrorMigration.md index fea7bfb531..9b6ad3c099 100644 --- a/packages/simplex-chat-nodejs/docs/core.DBMigrationError.Interface.ErrorMigration.md +++ b/packages/simplex-chat-nodejs/docs/core.DBMigrationError.Interface.ErrorMigration.md @@ -6,7 +6,7 @@ # Interface: ErrorMigration -Defined in: [src/core.ts:152](../src/core.ts#L152) +Defined in: [src/core.ts:174](../src/core.ts#L174) ## Extends @@ -18,7 +18,7 @@ Defined in: [src/core.ts:152](../src/core.ts#L152) > **dbFile**: `string` -Defined in: [src/core.ts:154](../src/core.ts#L154) +Defined in: [src/core.ts:176](../src/core.ts#L176) *** @@ -26,7 +26,7 @@ Defined in: [src/core.ts:154](../src/core.ts#L154) > **migrationError**: [`MigrationError`](core.TypeAlias.MigrationError.md) -Defined in: [src/core.ts:155](../src/core.ts#L155) +Defined in: [src/core.ts:177](../src/core.ts#L177) *** @@ -34,7 +34,7 @@ Defined in: [src/core.ts:155](../src/core.ts#L155) > **type**: `"errorMigration"` -Defined in: [src/core.ts:153](../src/core.ts#L153) +Defined in: [src/core.ts:175](../src/core.ts#L175) #### Overrides diff --git a/packages/simplex-chat-nodejs/docs/core.DBMigrationError.Interface.ErrorNotADatabase.md b/packages/simplex-chat-nodejs/docs/core.DBMigrationError.Interface.ErrorNotADatabase.md index eb0bfb11cb..d54f59003f 100644 --- a/packages/simplex-chat-nodejs/docs/core.DBMigrationError.Interface.ErrorNotADatabase.md +++ b/packages/simplex-chat-nodejs/docs/core.DBMigrationError.Interface.ErrorNotADatabase.md @@ -6,7 +6,7 @@ # Interface: ErrorNotADatabase -Defined in: [src/core.ts:147](../src/core.ts#L147) +Defined in: [src/core.ts:169](../src/core.ts#L169) ## Extends @@ -18,7 +18,7 @@ Defined in: [src/core.ts:147](../src/core.ts#L147) > **dbFile**: `string` -Defined in: [src/core.ts:149](../src/core.ts#L149) +Defined in: [src/core.ts:171](../src/core.ts#L171) *** @@ -26,7 +26,7 @@ Defined in: [src/core.ts:149](../src/core.ts#L149) > **type**: `"errorNotADatabase"` -Defined in: [src/core.ts:148](../src/core.ts#L148) +Defined in: [src/core.ts:170](../src/core.ts#L170) #### Overrides diff --git a/packages/simplex-chat-nodejs/docs/core.DBMigrationError.Interface.ErrorSQL.md b/packages/simplex-chat-nodejs/docs/core.DBMigrationError.Interface.ErrorSQL.md index ffaa364f9b..e76de932f6 100644 --- a/packages/simplex-chat-nodejs/docs/core.DBMigrationError.Interface.ErrorSQL.md +++ b/packages/simplex-chat-nodejs/docs/core.DBMigrationError.Interface.ErrorSQL.md @@ -6,7 +6,7 @@ # Interface: ErrorSQL -Defined in: [src/core.ts:158](../src/core.ts#L158) +Defined in: [src/core.ts:180](../src/core.ts#L180) ## Extends @@ -18,7 +18,7 @@ Defined in: [src/core.ts:158](../src/core.ts#L158) > **dbFile**: `string` -Defined in: [src/core.ts:160](../src/core.ts#L160) +Defined in: [src/core.ts:182](../src/core.ts#L182) *** @@ -26,7 +26,7 @@ Defined in: [src/core.ts:160](../src/core.ts#L160) > **migrationSQLError**: `string` -Defined in: [src/core.ts:161](../src/core.ts#L161) +Defined in: [src/core.ts:183](../src/core.ts#L183) *** @@ -34,7 +34,7 @@ Defined in: [src/core.ts:161](../src/core.ts#L161) > **type**: `"errorSQL"` -Defined in: [src/core.ts:159](../src/core.ts#L159) +Defined in: [src/core.ts:181](../src/core.ts#L181) #### Overrides diff --git a/packages/simplex-chat-nodejs/docs/core.DBMigrationError.Interface.InvalidConfirmation.md b/packages/simplex-chat-nodejs/docs/core.DBMigrationError.Interface.InvalidConfirmation.md index 67711030da..30157c808d 100644 --- a/packages/simplex-chat-nodejs/docs/core.DBMigrationError.Interface.InvalidConfirmation.md +++ b/packages/simplex-chat-nodejs/docs/core.DBMigrationError.Interface.InvalidConfirmation.md @@ -6,7 +6,7 @@ # Interface: InvalidConfirmation -Defined in: [src/core.ts:139](../src/core.ts#L139) +Defined in: [src/core.ts:161](../src/core.ts#L161) ## Extends @@ -18,7 +18,7 @@ Defined in: [src/core.ts:139](../src/core.ts#L139) > **type**: `"invalidConfirmation"` -Defined in: [src/core.ts:140](../src/core.ts#L140) +Defined in: [src/core.ts:162](../src/core.ts#L162) #### Overrides diff --git a/packages/simplex-chat-nodejs/docs/core.DBMigrationError.Interface.InvalidQueueSize.md b/packages/simplex-chat-nodejs/docs/core.DBMigrationError.Interface.InvalidQueueSize.md index 9093b5e9b1..5264e4977f 100644 --- a/packages/simplex-chat-nodejs/docs/core.DBMigrationError.Interface.InvalidQueueSize.md +++ b/packages/simplex-chat-nodejs/docs/core.DBMigrationError.Interface.InvalidQueueSize.md @@ -6,7 +6,7 @@ # Interface: InvalidQueueSize -Defined in: [src/core.ts:143](../src/core.ts#L143) +Defined in: [src/core.ts:165](../src/core.ts#L165) ## Extends @@ -18,7 +18,7 @@ Defined in: [src/core.ts:143](../src/core.ts#L143) > **type**: `"invalidQueueSize"` -Defined in: [src/core.ts:144](../src/core.ts#L144) +Defined in: [src/core.ts:166](../src/core.ts#L166) #### Overrides diff --git a/packages/simplex-chat-nodejs/docs/core.DBMigrationError.TypeAlias.Tag.md b/packages/simplex-chat-nodejs/docs/core.DBMigrationError.TypeAlias.Tag.md index b04f2f5af1..d94d5893ad 100644 --- a/packages/simplex-chat-nodejs/docs/core.DBMigrationError.TypeAlias.Tag.md +++ b/packages/simplex-chat-nodejs/docs/core.DBMigrationError.TypeAlias.Tag.md @@ -8,4 +8,4 @@ > **Tag** = `"invalidConfirmation"` \| `"invalidQueueSize"` \| `"errorNotADatabase"` \| `"errorMigration"` \| `"errorSQL"` -Defined in: [src/core.ts:133](../src/core.ts#L133) +Defined in: [src/core.ts:155](../src/core.ts#L155) diff --git a/packages/simplex-chat-nodejs/docs/core.Enumeration.MigrationConfirmation.md b/packages/simplex-chat-nodejs/docs/core.Enumeration.MigrationConfirmation.md index 48e250c7d4..cc2dea3901 100644 --- a/packages/simplex-chat-nodejs/docs/core.Enumeration.MigrationConfirmation.md +++ b/packages/simplex-chat-nodejs/docs/core.Enumeration.MigrationConfirmation.md @@ -6,7 +6,7 @@ # Enumeration: MigrationConfirmation -Defined in: [src/core.ts:104](../src/core.ts#L104) +Defined in: [src/core.ts:126](../src/core.ts#L126) Migration confirmation mode @@ -16,7 +16,7 @@ Migration confirmation mode > **Console**: `"console"` -Defined in: [src/core.ts:107](../src/core.ts#L107) +Defined in: [src/core.ts:129](../src/core.ts#L129) *** @@ -24,7 +24,7 @@ Defined in: [src/core.ts:107](../src/core.ts#L107) > **Error**: `"error"` -Defined in: [src/core.ts:108](../src/core.ts#L108) +Defined in: [src/core.ts:130](../src/core.ts#L130) *** @@ -32,7 +32,7 @@ Defined in: [src/core.ts:108](../src/core.ts#L108) > **YesUp**: `"yesUp"` -Defined in: [src/core.ts:105](../src/core.ts#L105) +Defined in: [src/core.ts:127](../src/core.ts#L127) *** @@ -40,4 +40,4 @@ Defined in: [src/core.ts:105](../src/core.ts#L105) > **YesUpDown**: `"yesUpDown"` -Defined in: [src/core.ts:106](../src/core.ts#L106) +Defined in: [src/core.ts:128](../src/core.ts#L128) diff --git a/packages/simplex-chat-nodejs/docs/core.Function.chatCloseStore.md b/packages/simplex-chat-nodejs/docs/core.Function.chatCloseStore.md index 4b0324b92c..69be845ed3 100644 --- a/packages/simplex-chat-nodejs/docs/core.Function.chatCloseStore.md +++ b/packages/simplex-chat-nodejs/docs/core.Function.chatCloseStore.md @@ -8,7 +8,7 @@ > **chatCloseStore**(`ctrl`): `Promise`\<`void`\> -Defined in: [src/core.ts:20](../src/core.ts#L20) +Defined in: [src/core.ts:42](../src/core.ts#L42) Close chat store diff --git a/packages/simplex-chat-nodejs/docs/core.Function.chatDecryptFile.md b/packages/simplex-chat-nodejs/docs/core.Function.chatDecryptFile.md index b6786fe1cb..a2a901ddc5 100644 --- a/packages/simplex-chat-nodejs/docs/core.Function.chatDecryptFile.md +++ b/packages/simplex-chat-nodejs/docs/core.Function.chatDecryptFile.md @@ -8,7 +8,7 @@ > **chatDecryptFile**(`fromPath`, `__namedParameters`, `toPath`): `Promise`\<`void`\> -Defined in: [src/core.ts:76](../src/core.ts#L76) +Defined in: [src/core.ts:98](../src/core.ts#L98) Decrypt file diff --git a/packages/simplex-chat-nodejs/docs/core.Function.chatEncryptFile.md b/packages/simplex-chat-nodejs/docs/core.Function.chatEncryptFile.md index 9e30e55a23..8f4c7815c4 100644 --- a/packages/simplex-chat-nodejs/docs/core.Function.chatEncryptFile.md +++ b/packages/simplex-chat-nodejs/docs/core.Function.chatEncryptFile.md @@ -8,7 +8,7 @@ > **chatEncryptFile**(`ctrl`, `fromPath`, `toPath`): `Promise`\<[`CryptoArgs`](core.Interface.CryptoArgs.md)\> -Defined in: [src/core.ts:68](../src/core.ts#L68) +Defined in: [src/core.ts:90](../src/core.ts#L90) Encrypt file diff --git a/packages/simplex-chat-nodejs/docs/core.Function.chatMigrateInit.md b/packages/simplex-chat-nodejs/docs/core.Function.chatMigrateInit.md index dbe4912520..c6ad146aa1 100644 --- a/packages/simplex-chat-nodejs/docs/core.Function.chatMigrateInit.md +++ b/packages/simplex-chat-nodejs/docs/core.Function.chatMigrateInit.md @@ -8,7 +8,7 @@ > **chatMigrateInit**(`dbPath`, `dbKey`, `confirm`, `queueSize?`): `Promise`\<`bigint`\> -Defined in: [src/core.ts:8](../src/core.ts#L8) +Defined in: [src/core.ts:30](../src/core.ts#L30) Initialize chat controller diff --git a/packages/simplex-chat-nodejs/docs/core.Function.chatReadFile.md b/packages/simplex-chat-nodejs/docs/core.Function.chatReadFile.md index f6713f22d8..f9413c4d74 100644 --- a/packages/simplex-chat-nodejs/docs/core.Function.chatReadFile.md +++ b/packages/simplex-chat-nodejs/docs/core.Function.chatReadFile.md @@ -8,7 +8,7 @@ > **chatReadFile**(`path`, `__namedParameters`): `Promise`\<`Buffer`\<`ArrayBufferLike`\>\> -Defined in: [src/core.ts:61](../src/core.ts#L61) +Defined in: [src/core.ts:83](../src/core.ts#L83) Read buffer from encrypted file diff --git a/packages/simplex-chat-nodejs/docs/core.Function.chatRecvMsgWait.md b/packages/simplex-chat-nodejs/docs/core.Function.chatRecvMsgWait.md index 719cf610a1..8aaa2e1590 100644 --- a/packages/simplex-chat-nodejs/docs/core.Function.chatRecvMsgWait.md +++ b/packages/simplex-chat-nodejs/docs/core.Function.chatRecvMsgWait.md @@ -8,7 +8,7 @@ > **chatRecvMsgWait**(`ctrl`, `wait`): `Promise`\<`ChatEvent` \| `undefined`\> -Defined in: [src/core.ts:40](../src/core.ts#L40) +Defined in: [src/core.ts:62](../src/core.ts#L62) Receive chat event diff --git a/packages/simplex-chat-nodejs/docs/core.Function.chatSendCmd.md b/packages/simplex-chat-nodejs/docs/core.Function.chatSendCmd.md index 4296a714cf..9f8c34be0f 100644 --- a/packages/simplex-chat-nodejs/docs/core.Function.chatSendCmd.md +++ b/packages/simplex-chat-nodejs/docs/core.Function.chatSendCmd.md @@ -8,7 +8,7 @@ > **chatSendCmd**(`ctrl`, `cmd`): `Promise`\<`ChatResponse`\> -Defined in: [src/core.ts:28](../src/core.ts#L28) +Defined in: [src/core.ts:50](../src/core.ts#L50) Send chat command as string diff --git a/packages/simplex-chat-nodejs/docs/core.Function.chatWriteFile.md b/packages/simplex-chat-nodejs/docs/core.Function.chatWriteFile.md index 4ca640d8c4..bd22e9d2c6 100644 --- a/packages/simplex-chat-nodejs/docs/core.Function.chatWriteFile.md +++ b/packages/simplex-chat-nodejs/docs/core.Function.chatWriteFile.md @@ -8,7 +8,7 @@ > **chatWriteFile**(`ctrl`, `path`, `buffer`): `Promise`\<[`CryptoArgs`](core.Interface.CryptoArgs.md)\> -Defined in: [src/core.ts:53](../src/core.ts#L53) +Defined in: [src/core.ts:75](../src/core.ts#L75) Write buffer to encrypted file diff --git a/packages/simplex-chat-nodejs/docs/core.Function.loadLibrary.md b/packages/simplex-chat-nodejs/docs/core.Function.loadLibrary.md new file mode 100644 index 0000000000..a2034b953d --- /dev/null +++ b/packages/simplex-chat-nodejs/docs/core.Function.loadLibrary.md @@ -0,0 +1,24 @@ +[**simplex-chat**](README.md) + +*** + +[simplex-chat](README.md) / [core](Namespace.core.md) / loadLibrary + +# Function: loadLibrary() + +> **loadLibrary**(`backend`): `Promise`\<`void`\> + +Defined in: [src/core.ts:13](../src/core.ts#L13) + +Resolve (downloading on first use) and load libsimplex for the backend. +One libsimplex per process: the Haskell runtime is initialized once, and another backend is rejected. + +## Parameters + +### backend + +[`Backend`](core.TypeAlias.Backend.md) + +## Returns + +`Promise`\<`void`\> diff --git a/packages/simplex-chat-nodejs/docs/core.Interface.APIResult.md b/packages/simplex-chat-nodejs/docs/core.Interface.APIResult.md index 2ede6d9ffb..63b0caba85 100644 --- a/packages/simplex-chat-nodejs/docs/core.Interface.APIResult.md +++ b/packages/simplex-chat-nodejs/docs/core.Interface.APIResult.md @@ -6,7 +6,7 @@ # Interface: APIResult\ -Defined in: [src/core.ts:90](../src/core.ts#L90) +Defined in: [src/core.ts:112](../src/core.ts#L112) ## Type Parameters @@ -20,7 +20,7 @@ Defined in: [src/core.ts:90](../src/core.ts#L90) > `optional` **error?**: `ChatError` -Defined in: [src/core.ts:92](../src/core.ts#L92) +Defined in: [src/core.ts:114](../src/core.ts#L114) *** @@ -28,4 +28,4 @@ Defined in: [src/core.ts:92](../src/core.ts#L92) > `optional` **result?**: `R` -Defined in: [src/core.ts:91](../src/core.ts#L91) +Defined in: [src/core.ts:113](../src/core.ts#L113) diff --git a/packages/simplex-chat-nodejs/docs/core.Interface.CryptoArgs.md b/packages/simplex-chat-nodejs/docs/core.Interface.CryptoArgs.md index 51a55b86b6..939b3f7430 100644 --- a/packages/simplex-chat-nodejs/docs/core.Interface.CryptoArgs.md +++ b/packages/simplex-chat-nodejs/docs/core.Interface.CryptoArgs.md @@ -6,7 +6,7 @@ # Interface: CryptoArgs -Defined in: [src/core.ts:114](../src/core.ts#L114) +Defined in: [src/core.ts:136](../src/core.ts#L136) File encryption key and nonce @@ -16,7 +16,7 @@ File encryption key and nonce > **fileKey**: `string` -Defined in: [src/core.ts:115](../src/core.ts#L115) +Defined in: [src/core.ts:137](../src/core.ts#L137) *** @@ -24,4 +24,4 @@ Defined in: [src/core.ts:115](../src/core.ts#L115) > **fileNonce**: `string` -Defined in: [src/core.ts:116](../src/core.ts#L116) +Defined in: [src/core.ts:138](../src/core.ts#L138) diff --git a/packages/simplex-chat-nodejs/docs/core.Interface.UpMigration.md b/packages/simplex-chat-nodejs/docs/core.Interface.UpMigration.md index 9e1fca49b4..30a7f0b060 100644 --- a/packages/simplex-chat-nodejs/docs/core.Interface.UpMigration.md +++ b/packages/simplex-chat-nodejs/docs/core.Interface.UpMigration.md @@ -6,7 +6,7 @@ # Interface: UpMigration -Defined in: [src/core.ts:193](../src/core.ts#L193) +Defined in: [src/core.ts:215](../src/core.ts#L215) ## Properties @@ -14,7 +14,7 @@ Defined in: [src/core.ts:193](../src/core.ts#L193) > **upName**: `string` -Defined in: [src/core.ts:194](../src/core.ts#L194) +Defined in: [src/core.ts:216](../src/core.ts#L216) *** @@ -22,4 +22,4 @@ Defined in: [src/core.ts:194](../src/core.ts#L194) > **withDown**: `boolean` -Defined in: [src/core.ts:195](../src/core.ts#L195) +Defined in: [src/core.ts:217](../src/core.ts#L217) diff --git a/packages/simplex-chat-nodejs/docs/core.MTRError.Interface.MTREDifferent.md b/packages/simplex-chat-nodejs/docs/core.MTRError.Interface.MTREDifferent.md index f85fb492be..aaaa506f1e 100644 --- a/packages/simplex-chat-nodejs/docs/core.MTRError.Interface.MTREDifferent.md +++ b/packages/simplex-chat-nodejs/docs/core.MTRError.Interface.MTREDifferent.md @@ -6,7 +6,7 @@ # Interface: MTREDifferent -Defined in: [src/core.ts:214](../src/core.ts#L214) +Defined in: [src/core.ts:236](../src/core.ts#L236) ## Extends @@ -18,7 +18,7 @@ Defined in: [src/core.ts:214](../src/core.ts#L214) > **downMigrations**: `string`[] -Defined in: [src/core.ts:216](../src/core.ts#L216) +Defined in: [src/core.ts:238](../src/core.ts#L238) *** @@ -26,7 +26,7 @@ Defined in: [src/core.ts:216](../src/core.ts#L216) > **type**: `"different"` -Defined in: [src/core.ts:215](../src/core.ts#L215) +Defined in: [src/core.ts:237](../src/core.ts#L237) #### Overrides diff --git a/packages/simplex-chat-nodejs/docs/core.MTRError.Interface.MTRENoDown.md b/packages/simplex-chat-nodejs/docs/core.MTRError.Interface.MTRENoDown.md index f031ac8982..e088536be9 100644 --- a/packages/simplex-chat-nodejs/docs/core.MTRError.Interface.MTRENoDown.md +++ b/packages/simplex-chat-nodejs/docs/core.MTRError.Interface.MTRENoDown.md @@ -6,7 +6,7 @@ # Interface: MTRENoDown -Defined in: [src/core.ts:209](../src/core.ts#L209) +Defined in: [src/core.ts:231](../src/core.ts#L231) ## Extends @@ -18,7 +18,7 @@ Defined in: [src/core.ts:209](../src/core.ts#L209) > **dbMigrations**: `string`[] -Defined in: [src/core.ts:211](../src/core.ts#L211) +Defined in: [src/core.ts:233](../src/core.ts#L233) *** @@ -26,7 +26,7 @@ Defined in: [src/core.ts:211](../src/core.ts#L211) > **type**: `"noDown"` -Defined in: [src/core.ts:210](../src/core.ts#L210) +Defined in: [src/core.ts:232](../src/core.ts#L232) #### Overrides diff --git a/packages/simplex-chat-nodejs/docs/core.MTRError.TypeAlias.Tag.md b/packages/simplex-chat-nodejs/docs/core.MTRError.TypeAlias.Tag.md index 59501b539a..058c64d396 100644 --- a/packages/simplex-chat-nodejs/docs/core.MTRError.TypeAlias.Tag.md +++ b/packages/simplex-chat-nodejs/docs/core.MTRError.TypeAlias.Tag.md @@ -8,4 +8,4 @@ > **Tag** = `"noDown"` \| `"different"` -Defined in: [src/core.ts:203](../src/core.ts#L203) +Defined in: [src/core.ts:225](../src/core.ts#L225) diff --git a/packages/simplex-chat-nodejs/docs/core.MigrationError.Interface.MEDowngrade.md b/packages/simplex-chat-nodejs/docs/core.MigrationError.Interface.MEDowngrade.md index f6202b9456..523fe8fe1e 100644 --- a/packages/simplex-chat-nodejs/docs/core.MigrationError.Interface.MEDowngrade.md +++ b/packages/simplex-chat-nodejs/docs/core.MigrationError.Interface.MEDowngrade.md @@ -6,7 +6,7 @@ # Interface: MEDowngrade -Defined in: [src/core.ts:182](../src/core.ts#L182) +Defined in: [src/core.ts:204](../src/core.ts#L204) ## Extends @@ -18,7 +18,7 @@ Defined in: [src/core.ts:182](../src/core.ts#L182) > **downMigrations**: `string`[] -Defined in: [src/core.ts:184](../src/core.ts#L184) +Defined in: [src/core.ts:206](../src/core.ts#L206) *** @@ -26,7 +26,7 @@ Defined in: [src/core.ts:184](../src/core.ts#L184) > **type**: `"downgrade"` -Defined in: [src/core.ts:183](../src/core.ts#L183) +Defined in: [src/core.ts:205](../src/core.ts#L205) #### Overrides diff --git a/packages/simplex-chat-nodejs/docs/core.MigrationError.Interface.MEUpgrade.md b/packages/simplex-chat-nodejs/docs/core.MigrationError.Interface.MEUpgrade.md index 7467895b74..c5856f1943 100644 --- a/packages/simplex-chat-nodejs/docs/core.MigrationError.Interface.MEUpgrade.md +++ b/packages/simplex-chat-nodejs/docs/core.MigrationError.Interface.MEUpgrade.md @@ -6,7 +6,7 @@ # Interface: MEUpgrade -Defined in: [src/core.ts:177](../src/core.ts#L177) +Defined in: [src/core.ts:199](../src/core.ts#L199) ## Extends @@ -18,7 +18,7 @@ Defined in: [src/core.ts:177](../src/core.ts#L177) > **type**: `"upgrade"` -Defined in: [src/core.ts:178](../src/core.ts#L178) +Defined in: [src/core.ts:200](../src/core.ts#L200) #### Overrides @@ -30,4 +30,4 @@ Defined in: [src/core.ts:178](../src/core.ts#L178) > **upMigrations**: [`UpMigration`](core.Interface.UpMigration.md)[] -Defined in: [src/core.ts:179](../src/core.ts#L179) +Defined in: [src/core.ts:201](../src/core.ts#L201) diff --git a/packages/simplex-chat-nodejs/docs/core.MigrationError.Interface.MigrationError.md b/packages/simplex-chat-nodejs/docs/core.MigrationError.Interface.MigrationError.md index 7b2546fcd5..f3f7d9bc1f 100644 --- a/packages/simplex-chat-nodejs/docs/core.MigrationError.Interface.MigrationError.md +++ b/packages/simplex-chat-nodejs/docs/core.MigrationError.Interface.MigrationError.md @@ -6,7 +6,7 @@ # Interface: MigrationError -Defined in: [src/core.ts:187](../src/core.ts#L187) +Defined in: [src/core.ts:209](../src/core.ts#L209) ## Extends @@ -18,7 +18,7 @@ Defined in: [src/core.ts:187](../src/core.ts#L187) > **mtrError**: [`MTRError`](core.TypeAlias.MTRError.md) -Defined in: [src/core.ts:189](../src/core.ts#L189) +Defined in: [src/core.ts:211](../src/core.ts#L211) *** @@ -26,7 +26,7 @@ Defined in: [src/core.ts:189](../src/core.ts#L189) > **type**: `"migrationError"` -Defined in: [src/core.ts:188](../src/core.ts#L188) +Defined in: [src/core.ts:210](../src/core.ts#L210) #### Overrides diff --git a/packages/simplex-chat-nodejs/docs/core.MigrationError.TypeAlias.Tag.md b/packages/simplex-chat-nodejs/docs/core.MigrationError.TypeAlias.Tag.md index e2e1dcb33f..92f649e615 100644 --- a/packages/simplex-chat-nodejs/docs/core.MigrationError.TypeAlias.Tag.md +++ b/packages/simplex-chat-nodejs/docs/core.MigrationError.TypeAlias.Tag.md @@ -8,4 +8,4 @@ > **Tag** = `"upgrade"` \| `"downgrade"` \| `"migrationError"` -Defined in: [src/core.ts:171](../src/core.ts#L171) +Defined in: [src/core.ts:193](../src/core.ts#L193) diff --git a/packages/simplex-chat-nodejs/docs/core.TypeAlias.Backend.md b/packages/simplex-chat-nodejs/docs/core.TypeAlias.Backend.md new file mode 100644 index 0000000000..9b32c20d5b --- /dev/null +++ b/packages/simplex-chat-nodejs/docs/core.TypeAlias.Backend.md @@ -0,0 +1,11 @@ +[**simplex-chat**](README.md) + +*** + +[simplex-chat](README.md) / [core](Namespace.core.md) / Backend + +# Type Alias: Backend + +> **Backend** = `"sqlite"` \| `"postgres"` + +Defined in: [src/core.ts:5](../src/core.ts#L5) diff --git a/packages/simplex-chat-nodejs/docs/core.TypeAlias.DBMigrationError.md b/packages/simplex-chat-nodejs/docs/core.TypeAlias.DBMigrationError.md index 3f4797fb8e..159ce87391 100644 --- a/packages/simplex-chat-nodejs/docs/core.TypeAlias.DBMigrationError.md +++ b/packages/simplex-chat-nodejs/docs/core.TypeAlias.DBMigrationError.md @@ -8,4 +8,4 @@ > **DBMigrationError** = [`InvalidConfirmation`](core.DBMigrationError.Interface.InvalidConfirmation.md) \| [`InvalidQueueSize`](core.DBMigrationError.Interface.InvalidQueueSize.md) \| [`ErrorNotADatabase`](core.DBMigrationError.Interface.ErrorNotADatabase.md) \| [`ErrorMigration`](core.DBMigrationError.Interface.ErrorMigration.md) \| [`ErrorSQL`](core.DBMigrationError.Interface.ErrorSQL.md) -Defined in: [src/core.ts:125](../src/core.ts#L125) +Defined in: [src/core.ts:147](../src/core.ts#L147) diff --git a/packages/simplex-chat-nodejs/docs/core.TypeAlias.MTRError.md b/packages/simplex-chat-nodejs/docs/core.TypeAlias.MTRError.md index 66d6a092dc..1c4ceb16e9 100644 --- a/packages/simplex-chat-nodejs/docs/core.TypeAlias.MTRError.md +++ b/packages/simplex-chat-nodejs/docs/core.TypeAlias.MTRError.md @@ -8,4 +8,4 @@ > **MTRError** = [`MTRENoDown`](core.MTRError.Interface.MTRENoDown.md) \| [`MTREDifferent`](core.MTRError.Interface.MTREDifferent.md) -Defined in: [src/core.ts:198](../src/core.ts#L198) +Defined in: [src/core.ts:220](../src/core.ts#L220) diff --git a/packages/simplex-chat-nodejs/docs/core.TypeAlias.MigrationError.md b/packages/simplex-chat-nodejs/docs/core.TypeAlias.MigrationError.md index 6c0ba77541..485311f0de 100644 --- a/packages/simplex-chat-nodejs/docs/core.TypeAlias.MigrationError.md +++ b/packages/simplex-chat-nodejs/docs/core.TypeAlias.MigrationError.md @@ -8,4 +8,4 @@ > **MigrationError** = [`MEUpgrade`](core.MigrationError.Interface.MEUpgrade.md) \| [`MEDowngrade`](core.MigrationError.Interface.MEDowngrade.md) \| [`MigrationError`](core.MigrationError.Interface.MigrationError.md) -Defined in: [src/core.ts:165](../src/core.ts#L165) +Defined in: [src/core.ts:187](../src/core.ts#L187) diff --git a/packages/simplex-chat-nodejs/jest.config.js b/packages/simplex-chat-nodejs/jest.config.js index 18c5ddf0e5..8f37dfe843 100644 --- a/packages/simplex-chat-nodejs/jest.config.js +++ b/packages/simplex-chat-nodejs/jest.config.js @@ -1,3 +1,8 @@ +const path = require("path") + +// Tests load the locally built add-on instead of downloading the released one. +process.env.SIMPLEX_ADDON_PATH ??= path.join(__dirname, "build", "Release", "simplex.node") + module.exports = { preset: "ts-jest", maxWorkers: 1, diff --git a/packages/simplex-chat-nodejs/package.json b/packages/simplex-chat-nodejs/package.json index a482bafae8..ca62e57fa6 100644 --- a/packages/simplex-chat-nodejs/package.json +++ b/packages/simplex-chat-nodejs/package.json @@ -3,20 +3,20 @@ "version": "7.1.0-beta.6.2", "main": "dist/index.js", "types": "dist/index.d.ts", + "gypfile": false, + "bin": { + "simplex-chat": "dist/cli.js" + }, "files": [ "src", - "cpp", "dist", - "binding.gyp", "tsconfig.json", "docs", "examples" ], "scripts": { - "preinstall": "node src/download-libs.js", - "install": "node-gyp configure; node-gyp rebuild --release", "install-tools": "npm install -g node-gyp", - "configure": "node-gyp configure; mkdir libs 2> /dev/null | true", + "configure": "node-gyp configure", "build": "node-gyp rebuild && tsc && cp ./src/simplex.* ./dist", "run": "node src/index.js", "build-run": "node-gyp build && node src/index.js", @@ -26,13 +26,13 @@ "dependencies": { "@simplex-chat/types": "^0.12.1", "extract-zip": "^2.0.1", - "fast-deep-equal": "^3.1.3", - "node-addon-api": "^8.5.0" + "fast-deep-equal": "^3.1.3" }, "devDependencies": { "@types/jest": "^30.0.0", "@types/node": "^25.0.5", "jest": "^30.2.0", + "node-addon-api": "^8.5.0", "ts-jest": "^29.4.6", "typedoc": "^0.28.15", "typedoc-plugin-markdown": "^4.9.0", diff --git a/packages/simplex-chat-nodejs/src/api.ts b/packages/simplex-chat-nodejs/src/api.ts index 95d942ebf1..4ee4e4b3b9 100644 --- a/packages/simplex-chat-nodejs/src/api.ts +++ b/packages/simplex-chat-nodejs/src/api.ts @@ -57,10 +57,8 @@ interface EventSubscriber { } /** - * Database configuration. The native library is built against exactly one - * backend (see `simplex_backend` / `SIMPLEX_BACKEND` at install time); this - * type makes the caller state which one they are targeting so field names - * can't lie about their meaning. + * Database configuration. `type` selects the libsimplex backend loaded by + * `ChatApi.init`; one backend per process. */ export type DbConfig = | { @@ -108,7 +106,7 @@ export class ChatApi { private constructor(protected ctrl_: bigint | undefined) {} /** - * Initializes the ChatApi. + * Initializes the ChatApi, loading libsimplex for `db.type` (downloaded on first use). * @param {DbConfig} db - Database configuration (sqlite or postgres). * @param {core.MigrationConfirmation} [confirm=core.MigrationConfirmation.YesUp] - Migration confirmation mode. * @param {number} [queueSize] - Size of internal queues, the core default is used when omitted. @@ -119,6 +117,7 @@ export class ChatApi { queueSize?: number ): Promise { const [path, key] = dbConfigToMigrateArgs(db) + await core.loadLibrary(db.type) const ctrl = await core.chatMigrateInit(path, key, confirm, queueSize) return new ChatApi(ctrl) } diff --git a/packages/simplex-chat-nodejs/src/cli.ts b/packages/simplex-chat-nodejs/src/cli.ts new file mode 100644 index 0000000000..8f24e5a89d --- /dev/null +++ b/packages/simplex-chat-nodejs/src/cli.ts @@ -0,0 +1,42 @@ +#!/usr/bin/env node +import {parseArgs} from "util" +import type {Backend} from "./core" +import {resolveAddonPath, resolveLibsDir} from "./libs" + +const USAGE = "usage: simplex-chat install [--backend sqlite|postgres]" +const EXIT_INSTALL_FAILED = 1 +const EXIT_USAGE = 2 + +export function parseInstallArgs(argv: string[]): Backend { + const {positionals, values: {backend = "sqlite"}} = parseArgs({args: argv, allowPositionals: true, options: {backend: {type: "string"}}}) + if (positionals.length !== 1 || positionals[0] !== "install") throw new Error("expected command: install") + if (backend !== "sqlite" && backend !== "postgres") throw new Error(`invalid backend: ${backend}`) + return backend +} + +export async function main(argv: string[]): Promise { + if (argv.includes("-h") || argv.includes("--help")) { + console.log(USAGE) + return 0 + } + let backend: Backend + try { + backend = parseInstallArgs(argv) + } catch (e) { + console.error(`${(e as Error).message}\n${USAGE}`) + return EXIT_USAGE + } + try { + const [dir, addon] = await Promise.all([resolveLibsDir(backend), resolveAddonPath()]) + console.log(`libsimplex installed at: ${dir}`) + console.log(`Node.js add-on installed at: ${addon}`) + return 0 + } catch (e) { + console.error(`install failed: ${(e as Error).message}`) + return EXIT_INSTALL_FAILED + } +} + +if (require.main === module) { + main(process.argv.slice(2)).then(code => { process.exitCode = code }) +} diff --git a/packages/simplex-chat-nodejs/src/core.ts b/packages/simplex-chat-nodejs/src/core.ts index 49664fed71..5c246cf5c1 100644 --- a/packages/simplex-chat-nodejs/src/core.ts +++ b/packages/simplex-chat-nodejs/src/core.ts @@ -1,6 +1,28 @@ import {ChatEvent, ChatResponse, T} from "@simplex-chat/types" +import * as libs from "./libs" import * as simplex from "./simplex" +export type Backend = "sqlite" | "postgres" + +let loading: {backend: Backend, promise: Promise} | undefined + +/** + * Resolve (downloading on first use) and load libsimplex for the backend. + * One libsimplex per process: the Haskell runtime is initialized once, and another backend is rejected. + */ +export function loadLibrary(backend: Backend): Promise { + if (loading) { + if (loading.backend === backend) return loading.promise + return Promise.reject(new Error(`libsimplex already loaded with backend=${loading.backend}; cannot switch to ${backend} in the same process`)) + } + const promise = Promise.all([libs.resolveLibsDir(backend), libs.resolveAddonPath()]) + .then(([dir, addon]) => simplex.load(addon, libs.libPath(dir))) + loading = {backend, promise} + // a failed download or load can be retried + promise.catch(() => { loading = undefined }) + return promise +} + /** * Initialize chat controller * @param {number} [queueSize] - Size of internal queues, the core default is used when omitted. diff --git a/packages/simplex-chat-nodejs/src/download-libs.js b/packages/simplex-chat-nodejs/src/download-libs.js deleted file mode 100644 index fc4560fe81..0000000000 --- a/packages/simplex-chat-nodejs/src/download-libs.js +++ /dev/null @@ -1,266 +0,0 @@ -const https = require('https'); -const fs = require('fs'); -const path = require('path'); -const extract = require('extract-zip'); - -const GITHUB_REPO = 'simplex-chat/simplex-chat-libs'; -const RELEASE_TAG = 'core-7.1.0.11'; -const BACKEND = (process.env.SIMPLEX_BACKEND || process.env.npm_config_simplex_backend || 'sqlite').toLowerCase(); -// A locally built libsimplex, copied into libs/ rather than loaded in place: -// the addon's RUNPATH is $ORIGIN/../../libs. -const LIBS_DIR_OVERRIDE = process.env.SIMPLEX_LIBS_DIR || process.env.npm_config_simplex_libs_dir; -const LIB_NAMES = ['libsimplex.so', 'libsimplex.dylib', 'libsimplex.dll']; - -if (BACKEND !== 'sqlite' && BACKEND !== 'postgres') { - console.error(`✗ Invalid SIMPLEX_BACKEND: "${BACKEND}". Must be "sqlite" or "postgres".`); - process.exit(1); -} - -if (BACKEND === 'postgres' && (process.platform !== 'linux' || process.arch !== 'x64')) { - console.error(`✗ SIMPLEX_BACKEND=postgres is only supported on Linux x86_64.`); - process.exit(1); -} - -const ROOT_DIR = process.cwd(); // Root of the package being installed -const LIBS_DIR = path.join(ROOT_DIR, 'libs') -const INSTALLED_FILE = path.join(LIBS_DIR, 'installed.txt'); - -// Detect platform and architecture -function getPlatformInfo() { - const platform = process.platform; - const arch = process.arch; - - let platformName; - let archName; - - if (platform === 'linux') { - platformName = 'linux'; - } else if (platform === 'darwin') { - platformName = 'macos'; - } else if (platform === 'win32') { - platformName = 'windows'; - } else { - throw new Error(`Unsupported platform: ${platform}`); - } - - if (arch === 'x64') { - archName = 'x86_64'; - } else if (arch === 'arm64') { - archName = 'aarch64'; - } else { - throw new Error(`Unsupported architecture: ${arch}`); - } - - return { platformName, archName }; -} - -// Cleanup on libs version mismatch -function cleanLibsDirectory() { - if (fs.existsSync(LIBS_DIR)) { - console.log('Cleaning old libraries...'); - fs.rmSync(LIBS_DIR, { recursive: true, force: true }); - fs.mkdirSync(LIBS_DIR, { recursive: true }); - console.log('✓ Old libraries removed'); - } -} - -// Check if libraries are already installed with the correct version -function isAlreadyInstalled() { - if (!fs.existsSync(INSTALLED_FILE)) { - return false; - } - - try { - const installedVersion = fs.readFileSync(INSTALLED_FILE, 'utf-8').trim(); - const expectedVersion = `${RELEASE_TAG}:${BACKEND}`; - if (installedVersion === expectedVersion) { - console.log(`✓ Libraries version ${RELEASE_TAG}:${BACKEND} already installed`); - return true; - } else { - console.log(`Version mismatch: installed ${installedVersion}, need ${expectedVersion}`); - cleanLibsDirectory(); - return false; - } - } catch (err) { - console.warn(`Could not read installed.txt: ${err.message}`); - return false; - } -} - -// No version check: the files behind SIMPLEX_LIBS_DIR change on every rebuild. -function installFromOverride() { - if (!fs.existsSync(LIBS_DIR_OVERRIDE)) { - throw new Error(`SIMPLEX_LIBS_DIR does not exist: ${LIBS_DIR_OVERRIDE}`); - } - const lib = LIB_NAMES.find((name) => fs.existsSync(path.join(LIBS_DIR_OVERRIDE, name))); - if (!lib) { - throw new Error(`No ${LIB_NAMES.join(' / ')} in SIMPLEX_LIBS_DIR: ${LIBS_DIR_OVERRIDE}`); - } - console.log(`Using libraries from SIMPLEX_LIBS_DIR: ${LIBS_DIR_OVERRIDE}`); - cleanLibsDirectory(); - copyDirSync(LIBS_DIR_OVERRIDE, LIBS_DIR); - // Not a release tag: a later install without the variable sees a mismatch - // and replaces these files with the released ones. - fs.writeFileSync(INSTALLED_FILE, `${LIBS_DIR_OVERRIDE}:${BACKEND}`, 'utf-8'); - console.log(`✓ Installed ${lib} and its runtime libraries from ${LIBS_DIR_OVERRIDE}`); -} - -async function install() { - try { - if (LIBS_DIR_OVERRIDE) { - installFromOverride(); - return; - } - - // Check if already installed - if (isAlreadyInstalled()) { - return; - } - - const { platformName, archName } = getPlatformInfo(); - const repoName = GITHUB_REPO.split('/')[1]; - const backendSuffix = BACKEND === 'postgres' ? '-postgres' : ''; - const zipFilename = `${repoName}-${platformName}-${archName}${backendSuffix}.zip`; - const ZIP_URL = `https://github.com/${GITHUB_REPO}/releases/download/${RELEASE_TAG}/${zipFilename}`; - const ZIP_PATH = path.join(ROOT_DIR, zipFilename); - const TEMP_EXTRACT_DIR = path.join(ROOT_DIR, '.temp-extract'); - - console.log(`Detected: ${platformName} ${archName}`); - console.log(`Backend: ${BACKEND}`); - console.log(`Downloading: ${zipFilename}`); - - // Create libs directory - if (!fs.existsSync(LIBS_DIR)) { - fs.mkdirSync(LIBS_DIR, { recursive: true }); - } - - // Download zip with error handling - await downloadFile(ZIP_URL, ZIP_PATH); - - // Extract to temporary directory - console.log('Extracting to temporary directory...'); - if (!fs.existsSync(TEMP_EXTRACT_DIR)) { - fs.mkdirSync(TEMP_EXTRACT_DIR, { recursive: true }); - } - await extract(ZIP_PATH, { dir: TEMP_EXTRACT_DIR }); - - // Move libs folder contents to final location - console.log('Moving libraries to libs/...'); - const libsSourcePath = path.join(TEMP_EXTRACT_DIR, 'libs'); - - if (fs.existsSync(libsSourcePath)) { - // Copy all files from libs folder to LIBS_DIR - const files = fs.readdirSync(libsSourcePath); - files.forEach(file => { - const src = path.join(libsSourcePath, file); - const dest = path.join(LIBS_DIR, file); - - if (fs.statSync(src).isDirectory()) { - copyDirSync(src, dest); - } else { - fs.copyFileSync(src, dest); - } - }); - } else { - throw new Error('libs folder not found in zip archive'); - } - - // Write installed.txt with version - fs.writeFileSync(INSTALLED_FILE, `${RELEASE_TAG}:${BACKEND}`, 'utf-8'); - console.log(`✓ Wrote version ${RELEASE_TAG}:${BACKEND} to installed.txt`); - - // Cleanup - fs.rmSync(TEMP_EXTRACT_DIR, { recursive: true, force: true }); - fs.unlinkSync(ZIP_PATH); - console.log('✓ Installation complete'); - } catch (err) { - console.error('✗ Failed:', err.message); - process.exit(1); - } -} - -// Helper function to recursively copy directories -function copyDirSync(src, dest) { - if (!fs.existsSync(dest)) { - fs.mkdirSync(dest, { recursive: true }); - } - const files = fs.readdirSync(src); - files.forEach(file => { - const srcFile = path.join(src, file); - const destFile = path.join(dest, file); - if (fs.statSync(srcFile).isDirectory()) { - copyDirSync(srcFile, destFile); - } else { - fs.copyFileSync(srcFile, destFile); - } - }); -} - -function downloadFile(url, dest) { - return new Promise((resolve, reject) => { - const file = fs.createWriteStream(dest); - - https.get(url, { headers: { 'User-Agent': 'Node.js' } }, (response) => { - // Handle redirects - if (response.statusCode === 302 || response.statusCode === 301) { - file.destroy(); - fs.unlink(dest, () => {}); - return downloadFile(response.headers.location, dest) - .then(resolve) - .catch(reject); - } - - // Handle 404 - if (response.statusCode === 404) { - file.destroy(); - fs.unlink(dest, () => {}); - reject(new Error( - `Release artifact not found (404). Check:\n` + - ` - Repository exists: ${url.split('/releases')[0]}\n` + - ` - Release tag exists: ${RELEASE_TAG}\n` + - ` - Artifact filename is correct` - )); - return; - } - - // Handle 403 - if (response.statusCode === 403) { - file.destroy(); - fs.unlink(dest, () => {}); - reject(new Error( - `Access denied (403). The repository may be private.\n` + - `Set GITHUB_TOKEN environment variable for private repos.` - )); - return; - } - - // Handle other HTTP errors - if (response.statusCode < 200 || response.statusCode >= 300) { - file.destroy(); - fs.unlink(dest, () => {}); - reject(new Error( - `HTTP ${response.statusCode}: Failed to download from ${url}` - )); - return; - } - - response.pipe(file); - - file.on('finish', () => { - file.close(); - resolve(); - }); - - file.on('error', (err) => { - fs.unlink(dest, () => {}); - reject(new Error(`File write error: ${err.message}`)); - }); - }).on('error', (err) => { - file.destroy(); - fs.unlink(dest, () => {}); - reject(new Error(`Download error: ${err.message}`)); - }); - }); -} - -install(); diff --git a/packages/simplex-chat-nodejs/src/libs.ts b/packages/simplex-chat-nodejs/src/libs.ts new file mode 100644 index 0000000000..b8888ede1d --- /dev/null +++ b/packages/simplex-chat-nodejs/src/libs.ts @@ -0,0 +1,202 @@ +import * as crypto from "crypto" +import * as fs from "fs" +import * as http from "http" +import * as https from "https" +import * as os from "os" +import * as path from "path" +import {pipeline} from "stream/promises" +import extract = require("extract-zip") +import type {Backend} from "./core" +import {ADDON_RELEASE, LIBS_RELEASE, SHA256} from "./release" + +const GITHUB_REPO = "simplex-chat/simplex-chat-libs" +const REQUEST_TIMEOUT_MS = 60_000 +export const MAX_REDIRECTS = 5 +const PLATFORMS: {[platform: string]: {name: string, lib: string} | undefined} = { + linux: {name: "linux", lib: "libsimplex.so"}, + darwin: {name: "macos", lib: "libsimplex.dylib"}, + win32: {name: "windows", lib: "libsimplex.dll"}, +} +const ARCHS: {[arch: string]: string | undefined} = {x64: "x86_64", arm64: "aarch64"} +const SUPPORTED = ["linux-x86_64", "linux-aarch64", "macos-x86_64", "macos-aarch64", "windows-x86_64"] + +function unsupported(platform: string, arch: string): Error { + return new Error(`Unsupported platform: ${platform}/${arch}; supported: ${SUPPORTED.join(", ")}`) +} + +export function platformTag(platform: string, arch: string): string { + const tag = `${PLATFORMS[platform]?.name}-${ARCHS[arch]}` + if (!SUPPORTED.includes(tag)) throw unsupported(platform, arch) + return tag +} + +function libName(platform: string = process.platform, arch: string = process.arch): string { + const lib = PLATFORMS[platform]?.lib + if (!lib) throw unsupported(platform, arch) + return lib +} + +export function cacheRoot(platform: string, env: NodeJS.ProcessEnv, home: string = os.homedir()): string { + if (platform === "darwin") return path.join(home, "Library", "Caches", "simplex-chat") + if (platform === "win32") { + if (!env.LOCALAPPDATA) throw new Error("LOCALAPPDATA is not set") + return path.join(env.LOCALAPPDATA, "simplex-chat") + } + return path.join(env.XDG_CACHE_HOME || path.join(home, ".cache"), "simplex-chat") +} + +export function libsAsset(backend: Backend, tag: string): string { + const suffix = backend === "postgres" ? "-postgres" : "" + return `simplex-chat-libs-${tag}${suffix}.zip` +} + +export function addonAsset(tag: string): string { + return `simplex-chat-nodejs-${tag}.node` +} + +export function releaseUrl(release: string, asset: string): string { + return `https://github.com/${GITHUB_REPO}/releases/download/${release}/${asset}` +} + +function pinnedSha256(asset: string): string { + const sha256 = SHA256[asset] + if (!sha256) throw new Error(`no SHA-256 is pinned for ${asset}`) + return sha256 +} + +export function libPath(dir: string): string { + return path.join(dir, libName()) +} + +export async function resolveLibsDir( + backend: Backend, + env: NodeJS.ProcessEnv = process.env, + platform: string = process.platform, + arch: string = process.arch +): Promise { + const lib = libName(platform, arch) + if (env.SIMPLEX_LIBS_DIR) { + const dir = path.resolve(env.SIMPLEX_LIBS_DIR) + if (!fs.existsSync(path.join(dir, lib))) throw new Error(`SIMPLEX_LIBS_DIR has no ${lib}: ${dir}`) + return dir + } + const tag = platformTag(platform, arch) + if (backend === "postgres" && tag !== "linux-x86_64") { + throw new Error(`postgres backend is only supported on linux-x86_64; current platform is ${tag}`) + } + const target = path.resolve(cacheRoot(platform, env), LIBS_RELEASE, backend) + if (!fs.existsSync(path.join(target, lib))) { + const asset = libsAsset(backend, tag) + await installLibs(releaseUrl(LIBS_RELEASE, asset), pinnedSha256(asset), target, lib) + } + return target +} + +export async function resolveAddonPath( + env: NodeJS.ProcessEnv = process.env, + platform: string = process.platform, + arch: string = process.arch +): Promise { + if (env.SIMPLEX_ADDON_PATH) { + const file = path.resolve(env.SIMPLEX_ADDON_PATH) + if (!fs.existsSync(file)) throw new Error(`SIMPLEX_ADDON_PATH does not exist: ${file}`) + return file + } + if (!ADDON_RELEASE) throw new Error("no prebuilt Node.js add-on is pinned in this version; set SIMPLEX_ADDON_PATH") + const asset = addonAsset(platformTag(platform, arch)) + const target = path.resolve(cacheRoot(platform, env), ADDON_RELEASE, asset) + if (!fs.existsSync(target)) await installAddon(releaseUrl(ADDON_RELEASE, asset), pinnedSha256(asset), target) + return target +} + +export function installLibs(url: string, sha256: string, target: string, lib: string, timeoutMs: number = REQUEST_TIMEOUT_MS): Promise { + return inTempDir(target, async tmp => { + console.error(`Downloading libsimplex from ${url} ...`) + const zipPath = path.join(tmp, "libs.zip") + await download(url, zipPath, timeoutMs) + await verifySha256(zipPath, sha256, url) + await extract(zipPath, {dir: tmp}) + const extracted = path.join(tmp, "libs") + if (!fs.existsSync(path.join(extracted, lib))) throw new Error(`libs/${lib} missing from ${url}`) + await renameInto(extracted, target, path.join(target, lib)) + }) +} + +export function installAddon(url: string, sha256: string, target: string, timeoutMs: number = REQUEST_TIMEOUT_MS): Promise { + return inTempDir(target, async tmp => { + console.error(`Downloading the Node.js add-on from ${url} ...`) + const file = path.join(tmp, path.basename(target)) + await download(url, file, timeoutMs) + await verifySha256(file, sha256, url) + await renameInto(file, target, target) + }) +} + +async function verifySha256(file: string, expected: string, url: string): Promise { + const hash = crypto.createHash("sha256") + for await (const chunk of fs.createReadStream(file)) hash.update(chunk) + const actual = hash.digest("hex") + if (actual !== expected) throw new Error(`SHA-256 of ${url} is ${actual}, expected ${expected}`) +} + +// The temp dir is next to the target, so the final rename stays on one filesystem. +async function inTempDir(target: string, install: (tmp: string) => Promise): Promise { + const parent = path.dirname(target) + await fs.promises.mkdir(parent, {recursive: true}) + const tmp = await fs.promises.mkdtemp(path.join(parent, ".download-")) + try { + await install(tmp) + } finally { + await fs.promises.rm(tmp, {recursive: true, force: true}) + } +} + +async function renameInto(from: string, target: string, installed: string): Promise { + try { + await fs.promises.rename(from, target) + } catch (e) { + // Another process installed the same version first; its files are identical. + // Windows reports renaming onto an existing directory or a loaded file as EPERM. + const code = (e as NodeJS.ErrnoException).code + const lost = (code === "EEXIST" || code === "ENOTEMPTY" || code === "EPERM") && fs.existsSync(target) + if (!lost) throw e + if (!fs.existsSync(installed)) { + throw new Error(`another process partially populated ${target} but ${path.basename(installed)} is missing; remove it manually and retry`) + } + } +} + +function download(url: string, dest: string, timeoutMs: number, redirects = 0): Promise { + return new Promise((resolve, reject) => { + const get = url.startsWith("https:") ? https.get : http.get + // Once a response arrives, request errors are ignored: a download settles via pipeline, which + // waits for the file to close, so tmp cleanup cannot fail with EBUSY on Windows. + let res: http.IncomingMessage | undefined + const req = get(url, {headers: {"User-Agent": "simplex-chat-nodejs"}, timeout: timeoutMs}, response => { + res = response + const status = response.statusCode ?? 0 + const location = response.headers.location + if (status >= 300 && status < 400 && location) { + response.resume() + if (redirects >= MAX_REDIRECTS) return reject(new Error(`too many redirects downloading ${url}`)) + let next: URL + try { + next = new URL(location, url) + } catch (e) { + return reject(e) + } + if (next.protocol !== new URL(url).protocol) return reject(new Error(`redirect from ${url} to ${next} changes protocol`)) + download(next.toString(), dest, timeoutMs, redirects + 1).then(resolve, reject) + return + } + if (status !== 200) { + response.resume() + return reject(new Error(`HTTP ${status} downloading ${url}`)) + } + pipeline(response, fs.createWriteStream(dest)).then(resolve, reject) + }) + const abort = (err: Error) => (res ? res.destroy(err) : req.destroy(err)) + req.on("timeout", () => abort(new Error(`timeout downloading ${url}`))) + req.on("error", e => { if (!res) reject(e) }) + }) +} diff --git a/packages/simplex-chat-nodejs/src/release.ts b/packages/simplex-chat-nodejs/src/release.ts new file mode 100644 index 0000000000..ccd436e250 --- /dev/null +++ b/packages/simplex-chat-nodejs/src/release.ts @@ -0,0 +1,14 @@ +// Written by scripts/ci/packages_release.py from the simplex-chat-libs releases. + +export const LIBS_RELEASE = "core-7.1.0.11" + +export const ADDON_RELEASE: string | undefined = undefined + +export const SHA256: {readonly [asset: string]: string} = { + "simplex-chat-libs-linux-aarch64.zip": "d66aa1b30b9fa20225dad8ef75a9c605961e1c0adde2ba0283e2fcac4e15d260", + "simplex-chat-libs-linux-x86_64-postgres.zip": "925b3166f969b12082ff8ee659e5f2a9933fe40120deb80c9aa3002a5ba6c803", + "simplex-chat-libs-linux-x86_64.zip": "dabad0824e8813ea4a084fbf37e1ba4cf6dcea9cd8ccadc521fec200f246208a", + "simplex-chat-libs-macos-aarch64.zip": "aa74608b149fff7508a47eaac4284dcc6d189c6ed8b7c18428df9158bc176a61", + "simplex-chat-libs-macos-x86_64.zip": "a0e21457e1077be7f71b46c1c27452945b2cee2b6c50efb0004412ce5b7042ec", + "simplex-chat-libs-windows-x86_64.zip": "c01dad574daa101a1cbfd31ddbf2b3d0d986a4a42f97d789d2f878b2050b2b4b", +} diff --git a/packages/simplex-chat-nodejs/src/simplex.d.ts b/packages/simplex-chat-nodejs/src/simplex.d.ts index 1e0ca825a6..0187556afd 100644 --- a/packages/simplex-chat-nodejs/src/simplex.d.ts +++ b/packages/simplex-chat-nodejs/src/simplex.d.ts @@ -1,5 +1,6 @@ // These functions are defined in CPP add-on ../cpp/simplex.cc +export function load(addonPath: string, libPath: string): void export function chat_migrate_init(dbPath: string, dbKey: string, confirm: string): Promise<[bigint, string]> export function chat_migrate_init_queue(dbPath: string, dbKey: string, confirm: string, queueSize: number): Promise<[bigint, string]> export function chat_close_store(ctrl: bigint): Promise diff --git a/packages/simplex-chat-nodejs/src/simplex.js b/packages/simplex-chat-nodejs/src/simplex.js index fd26c67438..3bdfb528df 100644 --- a/packages/simplex-chat-nodejs/src/simplex.js +++ b/packages/simplex-chat-nodejs/src/simplex.js @@ -1 +1,22 @@ -module.exports = require("../build/Release/simplex") +// The add-on is downloaded from the libsimplex release, so it is loaded at runtime by core.loadLibrary. +const NOT_LOADED = "libsimplex is not loaded, call core.loadLibrary(backend) first" +const BINDINGS = ["chat_migrate_init", "chat_migrate_init_queue", "chat_close_store", "chat_send_cmd", "chat_recv_msg_wait", + "chat_write_file", "chat_read_file", "chat_encrypt_file", "chat_decrypt_file"] + +let addon + +exports.load = (addonPath, libPath) => { + if (!addon) { + const addonModule = {exports: {}} + process.dlopen(addonModule, addonPath) + addon = addonModule.exports + } + addon.load(libPath) +} + +for (const name of BINDINGS) { + exports[name] = (...args) => { + if (!addon) throw new Error(NOT_LOADED) + return addon[name](...args) + } +} diff --git a/packages/simplex-chat-nodejs/tests/api.unit.test.ts b/packages/simplex-chat-nodejs/tests/api.unit.test.ts index 8bfecc3347..b4ae456af9 100644 --- a/packages/simplex-chat-nodejs/tests/api.unit.test.ts +++ b/packages/simplex-chat-nodejs/tests/api.unit.test.ts @@ -5,6 +5,7 @@ import * as core from "../src/core" const user = {userId: 1} as T.User async function chatWithResponse(response: object): Promise { + jest.spyOn(core, "loadLibrary").mockResolvedValue() jest.spyOn(core, "chatMigrateInit").mockResolvedValue(BigInt(1)) jest.spyOn(core, "chatSendCmd").mockResolvedValue(response as ChatResponse) return api.ChatApi.init({type: "sqlite", filePrefix: "unused"}) @@ -45,6 +46,7 @@ describe("documented success responses", () => { describe("startChat lifecycle", () => { function chatWithResponses(...responses: object[]): Promise { + jest.spyOn(core, "loadLibrary").mockResolvedValue() jest.spyOn(core, "chatMigrateInit").mockResolvedValue(BigInt(1)) jest.spyOn(core, "chatRecvMsgWait").mockImplementation(() => new Promise(resolve => setTimeout(() => resolve(undefined), 10))) const send = jest.spyOn(core, "chatSendCmd") @@ -126,3 +128,24 @@ describe("startChat lifecycle", () => { expect(core.chatRecvMsgWait).toHaveBeenCalledWith(BigInt(1), 500_000) }) }) + +describe("ChatApi.init", () => { + it("loads the library for the configured backend before opening the database", async () => { + let loaded!: () => void + const load = jest.spyOn(core, "loadLibrary").mockReturnValue(new Promise(resolve => { loaded = resolve })) + const migrate = jest.spyOn(core, "chatMigrateInit").mockResolvedValue(BigInt(1)) + const init = api.ChatApi.init({type: "postgres", connectionString: "postgres://unused"}) + await new Promise(setImmediate) + expect(load).toHaveBeenCalledWith("postgres") + expect(migrate).not.toHaveBeenCalled() + loaded() + await init + expect(migrate).toHaveBeenCalled() + }) + + it("rejects an invalid config before loading the library", async () => { + const load = jest.spyOn(core, "loadLibrary").mockResolvedValue() + await expect(api.ChatApi.init({type: "mysql"} as unknown as api.DbConfig)).rejects.toThrow('Invalid DbConfig: {"type":"mysql"}') + expect(load).not.toHaveBeenCalled() + }) +}) diff --git a/packages/simplex-chat-nodejs/tests/core.test.ts b/packages/simplex-chat-nodejs/tests/core.test.ts index 8eb106a5b5..fee903d6c5 100644 --- a/packages/simplex-chat-nodejs/tests/core.test.ts +++ b/packages/simplex-chat-nodejs/tests/core.test.ts @@ -2,8 +2,19 @@ import {execFile, spawnSync} from "child_process"; import * as fs from "fs"; import * as path from "path"; import {core} from "../src/index"; +import * as libs from "../src/libs"; +import * as simplex from "../src/simplex"; describe("Core tests", () => { + let libPath: string; + let addonPath: string; + // the first run downloads libsimplex + beforeAll(async () => { + const libsDir = await libs.resolveLibsDir("sqlite"); + libPath = libs.libPath(libsDir); + addonPath = await libs.resolveAddonPath(); + await core.loadLibrary("sqlite"); + }, 300000); const tmpDir = "./tests/tmp"; const dbPath = path.join(tmpDir, "simplex_v1"); @@ -125,9 +136,10 @@ describe("Core tests", () => { const start = Date.now(); await fs.promises.stat(tmpDir); expect(Date.now() - start).toBeLessThan(200); + // one receiver thread per controller runs the 4 receives in turn await Promise.all(receives); await stopAndClose(ctrl); - }, 10000); + }, 30000); const itOnLinux = process.platform === "linux" ? it : it.skip; @@ -191,10 +203,42 @@ describe("Core tests", () => { expect(await receives).toEqual([{event: undefined}, {error: "chat receiver stopped"}]); }, 10000); + it("should accept loading libsimplex again from the same path", () => { + expect(() => simplex.load(addonPath, libPath)).not.toThrow(); + }); + + it("should refuse to load libsimplex from another path", () => { + expect(() => simplex.load(addonPath, path.resolve("other", "libsimplex.so"))).toThrow(`libsimplex already loaded from ${libPath}`); + }); + + it("should load libsimplex after a failed load and in several workers", () => { + const addon = path.resolve(__dirname, "..", "build", "Release", "simplex.node"); + const childDbPath = path.resolve(tmpDir, "simplex_workers"); + const script = ` + const {Worker} = require("worker_threads"); + const simplex = require(${JSON.stringify(addon)}); + try { simplex.load(${JSON.stringify(addon)}) } catch (e) { console.log(JSON.stringify(e.message.endsWith("does not export hs_init_with_rtsopts"))) } + const load = ${JSON.stringify(`require(${JSON.stringify(addon)}).load(${JSON.stringify(libPath)})`)}; + const workers = Array.from({length: 4}, () => new Worker(load, {eval: true})); + Promise.all(workers.map((w) => new Promise((resolve, reject) => w.on("exit", resolve).on("error", reject)))) + .then(async (codes) => { + simplex.load(${JSON.stringify(libPath)}); + const [ctrl] = await simplex.chat_migrate_init(${JSON.stringify(childDbPath)}, "key", "yesUp"); + await simplex.chat_send_cmd(ctrl, "/_stop"); + console.log(JSON.stringify({codes, closed: await simplex.chat_close_store(ctrl) === ""})); + }); + `; + const child = spawnSync(process.execPath, ["-e", script], {timeout: 30000, encoding: "utf8"}); + if (child.status !== 0 || child.signal !== null) console.log("child stderr:", child.stderr); + expect({status: child.status, signal: child.signal, stdout: child.stdout.trim()}) + .toEqual({status: 0, signal: null, stdout: 'true\n{"codes":[0,0,0,0],"closed":true}'}); + }, 35000); + it("should let the process exit while a receiver is idle", () => { const childDbPath = path.resolve(tmpDir, "simplex_child"); const script = ` const simplex = require("./build/Release/simplex.node"); + simplex.load(${JSON.stringify(libPath)}); simplex.chat_migrate_init(${JSON.stringify(childDbPath)}, "key", "yesUp") .then(([ctrl]) => simplex.chat_recv_msg_wait(ctrl, 1)) .then((res) => console.log("received " + JSON.stringify(res))); @@ -209,16 +253,18 @@ describe("Core tests", () => { const script = ` const fs = require("fs"), path = require("path"); const simplex = require("./build/Release/simplex.node"); + simplex.load(${JSON.stringify(libPath)}); (async () => { + // a new database runs every migration, each committed with a disk flush that takes up to 30 ms on CI runners + const dir = fs.mkdtempSync(path.join(${JSON.stringify(path.resolve(tmpDir))}, "close-")); for (let i = 0; i < 40; i++) { - const dir = fs.mkdtempSync(path.join(${JSON.stringify(path.resolve(tmpDir))}, "close-")); const [ctrl] = await simplex.chat_migrate_init(path.join(dir, "simplex"), "key", "yesUp"); await simplex.chat_send_cmd(ctrl, "/v"); await simplex.chat_send_cmd(ctrl, "/_stop"); const res = await simplex.chat_close_store(ctrl); - fs.rmSync(dir, {recursive: true, force: true}); if (res !== "") throw new Error("close failed: " + res); } + fs.rmSync(dir, {recursive: true, force: true}); })(); `; const runChild = () => new Promise<{code: number | null, signal: NodeJS.Signals | null, stderr: string}>((resolve) => { diff --git a/packages/simplex-chat-nodejs/tests/libs.test.ts b/packages/simplex-chat-nodejs/tests/libs.test.ts new file mode 100644 index 0000000000..c8a65f6bdd --- /dev/null +++ b/packages/simplex-chat-nodejs/tests/libs.test.ts @@ -0,0 +1,466 @@ +import * as fs from "fs" +import * as http from "http" +import * as https from "https" +import * as os from "os" +import * as path from "path" +import {AddressInfo} from "net" +import * as crypto from "crypto" +import {addonAsset, cacheRoot, installAddon, installLibs, libPath, libsAsset, MAX_REDIRECTS, platformTag, releaseUrl, resolveAddonPath, resolveLibsDir} from "../src/libs" +import * as release from "../src/release" +import {main, parseInstallArgs} from "../src/cli" +import * as libs from "../src/libs" +import {storedZip} from "./zip" + +jest.mock("../src/release", () => ({LIBS_RELEASE: "core-test", ADDON_RELEASE: "nodejs-addon-test", SHA256: {}})) + +const LIB = "libtest.so" +const ADDON = Buffer.from("addon") +const PINNED = release.SHA256 as {[asset: string]: string} + +function sha256(data: Buffer): string { + return crypto.createHash("sha256").update(data).digest("hex") +} +const LINUX_LIB = "libsimplex.so" +const {XDG_CACHE_HOME, LOCALAPPDATA, SIMPLEX_LIBS_DIR, SIMPLEX_ADDON_PATH} = process.env + +function setEnv(name: string, value: string | undefined): void { + if (value === undefined) delete process.env[name] + else process.env[name] = value +} + +// A path resolution regression must neither download nor write into the real user cache. +let home: string +beforeEach(() => { + home = fs.mkdtempSync(path.join(os.tmpdir(), "libs-home-")) + jest.spyOn(os, "homedir").mockReturnValue(home) + jest.spyOn(https, "get").mockImplementation(() => { throw new Error("unexpected download") }) + setEnv("XDG_CACHE_HOME", home) + setEnv("LOCALAPPDATA", home) + setEnv("SIMPLEX_LIBS_DIR", undefined) + setEnv("SIMPLEX_ADDON_PATH", undefined) +}) +afterEach(() => { + jest.restoreAllMocks() + setEnv("XDG_CACHE_HOME", XDG_CACHE_HOME) + setEnv("LOCALAPPDATA", LOCALAPPDATA) + setEnv("SIMPLEX_LIBS_DIR", SIMPLEX_LIBS_DIR) + setEnv("SIMPLEX_ADDON_PATH", SIMPLEX_ADDON_PATH) + fs.rmSync(home, {recursive: true, force: true}) +}) + +describe("paths", () => { + let libsDir: string + + beforeEach(() => { + libsDir = fs.mkdtempSync(path.join(os.tmpdir(), "libs-dir-")) + fs.writeFileSync(libPath(libsDir), "lib") + }) + afterEach(() => fs.rmSync(libsDir, {recursive: true, force: true})) + + it("uses the Python cache layout", () => { + expect(cacheRoot("linux", {XDG_CACHE_HOME: "/x"}, "/h")).toBe(path.join("/x", "simplex-chat")) + expect(cacheRoot("linux", {}, "/h")).toBe(path.join("/h", ".cache", "simplex-chat")) + expect(cacheRoot("darwin", {}, "/h")).toBe(path.join("/h", "Library", "Caches", "simplex-chat")) + expect(cacheRoot("win32", {LOCALAPPDATA: "C:\\L"}, "/h")).toBe(path.join("C:\\L", "simplex-chat")) + expect(() => cacheRoot("win32", {}, "/h")).toThrow("LOCALAPPDATA is not set") + }) + + it.each([ + ["linux", "x64", "linux-x86_64"], + ["linux", "arm64", "linux-aarch64"], + ["darwin", "x64", "macos-x86_64"], + ["darwin", "arm64", "macos-aarch64"], + ["win32", "x64", "windows-x86_64"], + ])("tags %s/%s as %s", (platform, arch, tag) => { + expect(platformTag(platform, arch)).toBe(tag) + }) + + it("rejects unsupported platforms", () => { + expect(() => platformTag("win32", "arm64")).toThrow("Unsupported platform") + }) + + it("rejects an unsupported OS with its arch", async () => { + await expect(resolveLibsDir("sqlite", {}, "freebsd", "riscv64")).rejects.toThrow("Unsupported platform: freebsd/riscv64; supported: ") + }) + + it("uses the release URLs", () => { + const releases = "https://github.com/simplex-chat/simplex-chat-libs/releases/download" + expect(releaseUrl("core-7.1.0.11", libsAsset("sqlite", "macos-aarch64"))).toBe(`${releases}/core-7.1.0.11/simplex-chat-libs-macos-aarch64.zip`) + expect(releaseUrl("core-7.1.0.11", libsAsset("postgres", "linux-x86_64"))).toBe(`${releases}/core-7.1.0.11/simplex-chat-libs-linux-x86_64-postgres.zip`) + expect(releaseUrl("nodejs-addon-1", addonAsset("windows-x86_64"))).toBe(`${releases}/nodejs-addon-1/simplex-chat-nodejs-windows-x86_64.node`) + }) + + it("resolves an absolute SIMPLEX_LIBS_DIR", async () => { + await expect(resolveLibsDir("postgres", {SIMPLEX_LIBS_DIR: libsDir})).resolves.toBe(libsDir) + }) + + it("uses SIMPLEX_LIBS_DIR on an unsupported CPU", async () => { + fs.writeFileSync(path.join(libsDir, LINUX_LIB), "lib") + await expect(resolveLibsDir("sqlite", {SIMPLEX_LIBS_DIR: libsDir}, "linux", "arm")).resolves.toBe(libsDir) + }) + + it("uses SIMPLEX_LIBS_DIR for postgres outside linux-x86_64", async () => { + fs.writeFileSync(path.join(libsDir, LINUX_LIB), "lib") + await expect(resolveLibsDir("postgres", {SIMPLEX_LIBS_DIR: libsDir}, "linux", "arm64")).resolves.toBe(libsDir) + }) + + it("returns a relative SIMPLEX_LIBS_DIR as absolute", async () => { + await expect(resolveLibsDir("sqlite", {SIMPLEX_LIBS_DIR: path.relative(process.cwd(), libsDir)})).resolves.toBe(libsDir) + }) + + it.each([ + ["linux", "x64", "libsimplex.so"], + ["darwin", "arm64", "libsimplex.dylib"], + ["win32", "x64", "libsimplex.dll"], + ])("looks for the %s library name", async (platform, arch, lib) => { + fs.rmSync(libPath(libsDir)) + await expect(resolveLibsDir("sqlite", {SIMPLEX_LIBS_DIR: libsDir}, platform, arch)).rejects.toThrow(`SIMPLEX_LIBS_DIR has no ${lib}: `) + }) + + it("returns a relative SIMPLEX_ADDON_PATH as absolute", async () => { + const addon = path.join(libsDir, "simplex.node") + fs.writeFileSync(addon, "addon") + await expect(resolveAddonPath({SIMPLEX_ADDON_PATH: path.relative(process.cwd(), addon)})).resolves.toBe(addon) + }) + + it("rejects a missing SIMPLEX_ADDON_PATH", async () => { + const addon = path.join(libsDir, "simplex.node") + await expect(resolveAddonPath({SIMPLEX_ADDON_PATH: addon})).rejects.toThrow(`SIMPLEX_ADDON_PATH does not exist: ${addon}`) + }) + + it("rejects a SIMPLEX_LIBS_DIR without libsimplex", async () => { + fs.rmSync(libPath(libsDir)) + await expect(resolveLibsDir("sqlite", {SIMPLEX_LIBS_DIR: libsDir})).rejects.toThrow(`SIMPLEX_LIBS_DIR has no ${path.basename(libPath(libsDir))}: ${libsDir}`) + }) +}) + +describe("installLibs", () => { + let server: http.Server + let base: string + let tmp: string + const routes: {[p: string]: (res: http.ServerResponse) => void} = {} + + beforeAll(async () => { + server = http.createServer((req, res) => { + const route = routes[req.url ?? ""] + if (route) route(res) + else res.writeHead(404).end() + }) + await new Promise(resolve => server.listen(0, "127.0.0.1", resolve)) + base = `http://127.0.0.1:${(server.address() as AddressInfo).port}` + }) + + afterAll(() => new Promise(resolve => server.close(() => resolve()))) + + beforeEach(() => { tmp = fs.mkdtempSync(path.join(os.tmpdir(), "libs-test-")) }) + afterEach(() => fs.rmSync(tmp, {recursive: true, force: true})) + + const good = storedZip({[`libs/${LIB}`]: "lib", "libs/libHSdep.so": "dep"}) + const GOOD_SHA = sha256(good) + routes["/good.zip"] = res => res.writeHead(200).end(good) + routes["/redirect.zip"] = res => res.writeHead(302, {location: "/good.zip"}).end() + const nolib = storedZip({"other/file": "x"}) + routes["/nolib.zip"] = res => res.writeHead(200).end(nolib) + routes["/stall.zip"] = res => { res.writeHead(200); res.write(good.subarray(0, 10)) } + let loopRequests = 0 + routes["/loop.zip"] = res => { loopRequests++; res.writeHead(302, {location: "/loop.zip"}).end() } + routes["/to-http.zip"] = res => res.writeHead(302, {location: `${base}/good.zip`}).end() + routes["/bad-location.zip"] = res => res.writeHead(302, {location: "http://[bad"}).end() + routes["/no-location.zip"] = res => res.writeHead(302).end() + routes["/hang.zip"] = () => {} + routes["/cross-host.zip"] = res => res.writeHead(302, {location: "https://objects.libs.test/good.zip"}).end() + let downloadDirs: string[] = [] + routes["/observed.zip"] = res => { downloadDirs = leftovers(); res.writeHead(200).end(good) } + + function leftovers(): string[] { + return fs.readdirSync(tmp).filter(f => f.startsWith(".download-")) + } + + // Serves https requests from a local http route (the requested path by default), recording the requested URLs. + function mockHttps(route?: string): string[] { + const requested: string[] = [] + jest.spyOn(https, "get").mockImplementation(((url: string, options: http.RequestOptions, cb: (res: http.IncomingMessage) => void) => { + requested.push(url) + return http.get(`${base}${route ?? new URL(url).pathname}`, options, cb) + }) as unknown as typeof https.get) + return requested + } + + function renameFails(code: string): void { + jest.spyOn(fs.promises, "rename").mockRejectedValue(Object.assign(new Error(code), {code})) + } + + beforeEach(() => { jest.spyOn(console, "error").mockImplementation(() => {}) }) + + it("follows a redirect and installs libs/", async () => { + const target = path.join(tmp, "v1", "sqlite") + await installLibs(`${base}/redirect.zip`, GOOD_SHA, target, LIB) + expect(fs.readFileSync(path.join(target, LIB), "utf8")).toBe("lib") + expect(fs.existsSync(path.join(target, "libHSdep.so"))).toBe(true) + }) + + it("downloads into a temp dir next to the target", async () => { + await installLibs(`${base}/observed.zip`, GOOD_SHA, path.join(tmp, "sqlite"), LIB) + expect(downloadDirs).toHaveLength(1) + expect(leftovers()).toEqual([]) + }) + + it("stops following a redirect loop", async () => { + loopRequests = 0 + await expect(installLibs(`${base}/loop.zip`, GOOD_SHA, path.join(tmp, "sqlite"), LIB)).rejects.toThrow("too many redirects") + expect(loopRequests).toBe(MAX_REDIRECTS + 1) + expect(leftovers()).toEqual([]) + }) + + it("follows an https redirect to another host", async () => { + const requested = mockHttps() + const target = path.join(tmp, "sqlite") + await installLibs("https://libs.test/cross-host.zip", GOOD_SHA, target, LIB) + expect(requested).toEqual(["https://libs.test/cross-host.zip", "https://objects.libs.test/good.zip"]) + expect(fs.readFileSync(path.join(target, LIB), "utf8")).toBe("lib") + }) + + it("rejects a redirect from https to http", async () => { + mockHttps("/to-http.zip") + await expect(installLibs("https://libs.test/libs.zip", GOOD_SHA, path.join(tmp, "sqlite"), LIB)).rejects.toThrow("changes protocol") + expect(leftovers()).toEqual([]) + }) + + it("rejects an invalid redirect location", async () => { + await expect(installLibs(`${base}/bad-location.zip`, GOOD_SHA, path.join(tmp, "sqlite"), LIB)).rejects.toThrow("Invalid URL") + expect(leftovers()).toEqual([]) + }) + + it("rejects a redirect without a location", async () => { + await expect(installLibs(`${base}/no-location.zip`, GOOD_SHA, path.join(tmp, "sqlite"), LIB)).rejects.toThrow("HTTP 302") + expect(leftovers()).toEqual([]) + }) + + it("rejects 404 and cleans up", async () => { + const target = path.join(tmp, "sqlite") + await expect(installLibs(`${base}/missing.zip`, GOOD_SHA, target, LIB)).rejects.toThrow("HTTP 404") + expect(leftovers()).toEqual([]) + expect(fs.existsSync(target)).toBe(false) + }) + + it("rejects a zip without the lib", async () => { + const target = path.join(tmp, "sqlite") + await expect(installLibs(`${base}/nolib.zip`, sha256(nolib), target, LIB)).rejects.toThrow(`libs/${LIB} missing`) + expect(leftovers()).toEqual([]) + }) + + it("times out a request without a response", async () => { + await expect(installLibs(`${base}/hang.zip`, GOOD_SHA, path.join(tmp, "sqlite"), LIB, 200)).rejects.toThrow("timeout") + expect(leftovers()).toEqual([]) + }) + + it("times out a stalled download", async () => { + const target = path.join(tmp, "sqlite") + await expect(installLibs(`${base}/stall.zip`, GOOD_SHA, target, LIB, 200)).rejects.toThrow("timeout") + expect(leftovers()).toEqual([]) + }) + + it.each(["sqlite", "postgres"] as const)("installs %s into a relative XDG_CACHE_HOME and returns an absolute path", async backend => { + const zip = storedZip({[`libs/${LINUX_LIB}`]: "lib"}) + const asset = libsAsset(backend, "linux-x86_64") + PINNED[asset] = sha256(zip) + routes["/libsimplex.zip"] = res => res.writeHead(200).end(zip) + const requested = mockHttps("/libsimplex.zip") + const cache = path.relative(process.cwd(), path.join(tmp, "cache")) + const dir = await resolveLibsDir(backend, {XDG_CACHE_HOME: cache}, "linux", "x64") + expect(requested).toEqual([releaseUrl("core-test", asset)]) + expect(path.isAbsolute(dir)).toBe(true) + expect(dir).toBe(path.join(tmp, "cache", "simplex-chat", "core-test", backend)) + expect(fs.readFileSync(path.join(dir, LINUX_LIB), "utf8")).toBe("lib") + }) + + it("downloads the add-on into the cache", async () => { + routes["/addon.node"] = res => res.writeHead(200).end(ADDON) + PINNED[addonAsset("linux-x86_64")] = sha256(ADDON) + const requested = mockHttps("/addon.node") + const addon = await resolveAddonPath({XDG_CACHE_HOME: tmp}, "linux", "x64") + const version = path.join(tmp, "simplex-chat", "nodejs-addon-test") + expect(requested).toEqual([releaseUrl("nodejs-addon-test", addonAsset("linux-x86_64"))]) + expect(addon).toBe(path.join(version, "simplex-chat-nodejs-linux-x86_64.node")) + expect(fs.readFileSync(addon, "utf8")).toBe("addon") + expect(fs.readdirSync(version)).toEqual(["simplex-chat-nodejs-linux-x86_64.node"]) + }) + + it("does not download a cached add-on", async () => { + const requested = mockHttps("/addon.node") + const cached = path.join(cacheRoot("darwin", {}), "nodejs-addon-test", "simplex-chat-nodejs-macos-aarch64.node") + fs.mkdirSync(path.dirname(cached), {recursive: true}) + fs.writeFileSync(cached, "addon") + await expect(resolveAddonPath({}, "darwin", "arm64")).resolves.toBe(cached) + expect(requested).toEqual([]) + }) + + it("rejects a download with another SHA-256 and cleans up", async () => { + const target = path.join(tmp, "sqlite") + await expect(installLibs(`${base}/good.zip`, sha256(ADDON), target, LIB)).rejects.toThrow(`SHA-256 of ${base}/good.zip is ${GOOD_SHA}, expected ${sha256(ADDON)}`) + expect(fs.readdirSync(tmp)).toEqual([]) + }) + + it("rejects an add-on with another SHA-256", async () => { + routes["/addon.node"] = res => res.writeHead(200).end(ADDON) + const target = path.join(tmp, "simplex.node") + await expect(installAddon(`${base}/addon.node`, GOOD_SHA, target)).rejects.toThrow("SHA-256 of") + expect(fs.readdirSync(tmp)).toEqual([]) + }) + + it("rejects an asset without a pinned SHA-256", async () => { + mockHttps("/good.zip") + await expect(resolveLibsDir("sqlite", {XDG_CACHE_HOME: tmp}, "darwin", "x64")) + .rejects.toThrow("no SHA-256 is pinned for simplex-chat-libs-macos-x86_64.zip") + }) + + it("requires SIMPLEX_ADDON_PATH without a pinned add-on release", async () => { + jest.replaceProperty(release, "ADDON_RELEASE", undefined) + await expect(resolveAddonPath({XDG_CACHE_HOME: tmp}, "linux", "x64")) + .rejects.toThrow("no prebuilt Node.js add-on is pinned in this version; set SIMPLEX_ADDON_PATH") + }) + + it("rejects a missing add-on and cleans up", async () => { + const target = path.join(tmp, "simplex.node") + await expect(installAddon(`${base}/missing.node`, sha256(ADDON), target)).rejects.toThrow("HTTP 404") + expect(fs.readdirSync(tmp)).toEqual([]) + }) + + it("accepts EPERM from rename onto an installed add-on", async () => { + routes["/addon.node"] = res => res.writeHead(200).end(ADDON) + const target = path.join(tmp, "simplex.node") + fs.writeFileSync(target, "addon") + renameFails("EPERM") + await expect(installAddon(`${base}/addon.node`, sha256(ADDON), target)).resolves.toBeUndefined() + expect(leftovers()).toEqual([]) + }) + + it("does not download a cached lib", async () => { + const requested = mockHttps("/good.zip") + const cached = path.join(tmp, "simplex-chat", "core-test", "postgres") + fs.mkdirSync(cached, {recursive: true}) + fs.writeFileSync(path.join(cached, LINUX_LIB), "lib") + await expect(resolveLibsDir("postgres", {XDG_CACHE_HOME: tmp}, "linux", "x64")).resolves.toBe(cached) + expect(requested).toEqual([]) + }) + + it("rejects a target populated without the lib", async () => { + const target = path.join(tmp, "sqlite") + fs.mkdirSync(target) + fs.writeFileSync(path.join(target, "other"), "x") + await expect(installLibs(`${base}/good.zip`, GOOD_SHA, target, LIB)).rejects.toThrow(`another process partially populated ${target}`) + expect(leftovers()).toEqual([]) + }) + + it.each([["linux", "arm64"], ["darwin", "arm64"]])("rejects postgres on %s/%s without downloading", async (platform, arch) => { + const requested = mockHttps("/good.zip") + await expect(resolveLibsDir("postgres", {XDG_CACHE_HOME: tmp}, platform, arch)).rejects.toThrow("postgres backend is only supported on linux-x86_64") + expect(requested).toEqual([]) + }) + + it.each(["EPERM", "EEXIST"])("accepts %s from rename onto an installed target", async code => { + const target = path.join(tmp, "sqlite") + fs.mkdirSync(target) + fs.writeFileSync(path.join(target, LIB), "lib") + renameFails(code) + await expect(installLibs(`${base}/good.zip`, GOOD_SHA, target, LIB)).resolves.toBeUndefined() + expect(leftovers()).toEqual([]) + }) + + it("rethrows EPERM from rename when the target does not exist", async () => { + renameFails("EPERM") + await expect(installLibs(`${base}/good.zip`, GOOD_SHA, path.join(tmp, "sqlite"), LIB)).rejects.toThrow("EPERM") + expect(leftovers()).toEqual([]) + }) + + it("lets concurrent installs share one target", async () => { + const target = path.join(tmp, "sqlite") + await Promise.all([ + installLibs(`${base}/good.zip`, GOOD_SHA, target, LIB), + installLibs(`${base}/good.zip`, GOOD_SHA, target, LIB), + ]) + expect(fs.readFileSync(path.join(target, LIB), "utf8")).toBe("lib") + expect(leftovers()).toEqual([]) + }) +}) + +describe("cli", () => { + function installWith(libsDir: string, addon: string): Promise { + setEnv("SIMPLEX_LIBS_DIR", libsDir) + setEnv("SIMPLEX_ADDON_PATH", addon) + return main(["install"]) + } + + it("parses install arguments", () => { + expect(parseInstallArgs(["install"])).toBe("sqlite") + expect(parseInstallArgs(["install", "--backend", "postgres"])).toBe("postgres") + expect(parseInstallArgs(["install", "--backend=postgres"])).toBe("postgres") + expect(() => parseInstallArgs(["install", "--backend", "mysql"])).toThrow("invalid backend: mysql") + expect(() => parseInstallArgs(["install", "--backend"])).toThrow("argument missing") + expect(() => parseInstallArgs(["install", "--force"])).toThrow("Unknown option '--force'") + expect(() => parseInstallArgs(["run"])).toThrow("expected command: install") + expect(() => parseInstallArgs(["install", "extra"])).toThrow("expected command: install") + expect(() => parseInstallArgs([])).toThrow("expected command: install") + }) + + it("prints the libs directory and the add-on", async () => { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), "libs-cli-")) + const addon = path.join(dir, "simplex.node") + fs.writeFileSync(libPath(dir), "lib") + fs.writeFileSync(addon, "addon") + const log = jest.spyOn(console, "log").mockImplementation(() => {}) + try { + await expect(installWith(dir, addon)).resolves.toBe(0) + expect(log.mock.calls).toEqual([[`libsimplex installed at: ${dir}`], [`Node.js add-on installed at: ${addon}`]]) + } finally { + fs.rmSync(dir, {recursive: true, force: true}) + } + }) + + it("returns 1 without the add-on", async () => { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), "libs-cli-")) + fs.writeFileSync(libPath(dir), "lib") + const log = jest.spyOn(console, "log").mockImplementation(() => {}) + const error = jest.spyOn(console, "error").mockImplementation(() => {}) + try { + await expect(installWith(dir, path.join(dir, "simplex.node"))).resolves.toBe(1) + expect(log).not.toHaveBeenCalled() + expect(error).toHaveBeenCalledWith(expect.stringContaining("install failed: SIMPLEX_ADDON_PATH does not exist")) + } finally { + fs.rmSync(dir, {recursive: true, force: true}) + } + }) + + it("returns 1 when install fails", async () => { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), "libs-cli-")) + const addon = path.join(dir, "simplex.node") + fs.writeFileSync(addon, "addon") + const error = jest.spyOn(console, "error").mockImplementation(() => {}) + try { + await expect(installWith(dir, addon)).resolves.toBe(1) + expect(error).toHaveBeenCalledWith(expect.stringContaining("install failed: SIMPLEX_LIBS_DIR has no")) + } finally { + fs.rmSync(dir, {recursive: true, force: true}) + } + }) + + it("installs the requested backend", async () => { + const resolve = jest.spyOn(libs, "resolveLibsDir").mockResolvedValue("/libs") + jest.spyOn(libs, "resolveAddonPath").mockResolvedValue("/addon.node") + jest.spyOn(console, "log").mockImplementation(() => {}) + await expect(main(["install", "--backend", "postgres"])).resolves.toBe(0) + expect(resolve).toHaveBeenCalledWith("postgres") + }) + + it.each(["--help", "-h"])("prints usage on %s", async flag => { + const log = jest.spyOn(console, "log").mockImplementation(() => {}) + await expect(main(["install", flag])).resolves.toBe(0) + expect(log).toHaveBeenCalledWith("usage: simplex-chat install [--backend sqlite|postgres]") + }) + + it("returns 2 with usage on invalid arguments", async () => { + const error = jest.spyOn(console, "error").mockImplementation(() => {}) + await expect(main(["install", "--backend", "mysql"])).resolves.toBe(2) + expect(error).toHaveBeenCalledWith("invalid backend: mysql\nusage: simplex-chat install [--backend sqlite|postgres]") + }) +}) diff --git a/packages/simplex-chat-nodejs/tests/loader.test.ts b/packages/simplex-chat-nodejs/tests/loader.test.ts new file mode 100644 index 0000000000..6a03fe8feb --- /dev/null +++ b/packages/simplex-chat-nodejs/tests/loader.test.ts @@ -0,0 +1,107 @@ +import {spawnSync} from "child_process" +import * as path from "path" + +jest.mock("../src/simplex", () => ({load: jest.fn()})) +jest.mock("../src/libs", () => ({libPath: jest.requireActual("../src/libs").libPath, resolveLibsDir: jest.fn(), resolveAddonPath: jest.fn()})) + +const PACKAGE_DIR = path.join(__dirname, "..") +const ADDON = path.join(PACKAGE_DIR, "build", "Release", "simplex.node") +const CHILD_TIMEOUT_MS = 10000 +const LIBS_DIR = path.resolve("libs") +const SWITCH_ERROR = "libsimplex already loaded with backend=sqlite; cannot switch to postgres in the same process" +const ADDON_PATH = path.resolve("simplex.node") +const {libPath} = jest.requireActual("../src/libs") + +function runAddon(script: string): string { + const r = spawnSync(process.execPath, ["-e", `const simplex = require(${JSON.stringify(ADDON)});\n${script}`], {encoding: "utf8", timeout: CHILD_TIMEOUT_MS}) + return `${r.stdout}${r.stderr}status=${r.status} signal=${r.signal}` +} + +function freshCore(): {core: typeof import("../src/core"), resolveLibsDir: jest.Mock, load: jest.Mock} { + let modules!: ReturnType + jest.isolateModules(() => { + const libs = require("../src/libs") + libs.resolveAddonPath.mockResolvedValue(ADDON_PATH) + modules = { + core: require("../src/core"), + resolveLibsDir: libs.resolveLibsDir, + load: require("../src/simplex").load, + } + }) + return modules +} + +describe("native load", () => { + it("requires load before every FFI call", () => { + const out = runAddon(` + for (const name of Object.keys(simplex).filter(n => n !== "load").sort()) { + try { simplex[name](); console.log(name + ": no error") } catch (e) { console.log(name + ": " + e.message) } + }`) + const bindings = ["chat_close_store", "chat_decrypt_file", "chat_encrypt_file", "chat_migrate_init", "chat_migrate_init_queue", + "chat_read_file", "chat_recv_msg_wait", "chat_send_cmd", "chat_write_file"] + const expected = bindings.map(name => `${name}: libsimplex is not loaded, call core.loadLibrary(backend) first\n`).join("") + expect(out).toBe(`${expected}status=0 signal=null`) + }) + + it("reports the path of a library that cannot be opened", () => { + const out = runAddon(`try { simplex.load("/nonexistent/libsimplex.so") } catch (e) { console.log(e.message) }`) + expect(out).toContain("cannot load /nonexistent/libsimplex.so") + }) + + it("rejects a non-string library path", () => { + const out = runAddon(`try { simplex.load(1) } catch (e) { console.log(e.name + ": " + e.message) }`) + expect(out).toContain("TypeError: Expected string (libPath)") + }) + + it("rejects a library without the chat exports", () => { + const out = runAddon(`try { simplex.load(${JSON.stringify(ADDON)}) } catch (e) { console.log(e.message) }`) + expect(out).toContain(`${ADDON} does not export hs_init_with_rtsopts`) + }) +}) + +describe("add-on module", () => { + it("rejects FFI calls before the add-on is loaded", () => { + const simplex = jest.requireActual("../src/simplex") + expect(() => simplex.chat_send_cmd(BigInt(1), "/v")).toThrow("libsimplex is not loaded, call core.loadLibrary(backend) first") + }) +}) + +describe("loadLibrary", () => { + it("shares one load between concurrent calls", async () => { + const {core, resolveLibsDir, load} = freshCore() + resolveLibsDir.mockResolvedValue(LIBS_DIR) + const first = core.loadLibrary("sqlite") + const second = core.loadLibrary("sqlite") + expect(second).toBe(first) + await Promise.all([first, second]) + expect(resolveLibsDir).toHaveBeenCalledTimes(1) + expect(load.mock.calls).toEqual([[ADDON_PATH, libPath(LIBS_DIR)]]) + }) + + it("refuses to switch backend while the first load is in progress", async () => { + const {core, resolveLibsDir} = freshCore() + resolveLibsDir.mockResolvedValue(LIBS_DIR) + const first = core.loadLibrary("sqlite") + await expect(core.loadLibrary("postgres")).rejects.toThrow(SWITCH_ERROR) + await first + }) + + it("keeps a completed load for the process", async () => { + const {core, resolveLibsDir, load} = freshCore() + resolveLibsDir.mockResolvedValue(LIBS_DIR) + await core.loadLibrary("sqlite") + await core.loadLibrary("sqlite") + await expect(core.loadLibrary("postgres")).rejects.toThrow(SWITCH_ERROR) + expect(resolveLibsDir).toHaveBeenCalledTimes(1) + expect(load).toHaveBeenCalledTimes(1) + }) + + it("retries after a failed load", async () => { + const {core, resolveLibsDir, load} = freshCore() + resolveLibsDir.mockRejectedValueOnce(new Error("HTTP 503")).mockResolvedValue(LIBS_DIR) + await expect(core.loadLibrary("sqlite")).rejects.toThrow("HTTP 503") + await expect(core.loadLibrary("postgres")).resolves.toBeUndefined() + expect(resolveLibsDir.mock.calls).toEqual([["sqlite"], ["postgres"]]) + expect(load).toHaveBeenCalledTimes(1) + }) +}) diff --git a/packages/simplex-chat-nodejs/tests/zip.ts b/packages/simplex-chat-nodejs/tests/zip.ts new file mode 100644 index 0000000000..547c8c211c --- /dev/null +++ b/packages/simplex-chat-nodejs/tests/zip.ts @@ -0,0 +1,47 @@ +import * as zlib from "zlib" + +const LOCAL_FILE_HEADER = 0x04034b50 +const CENTRAL_DIRECTORY_HEADER = 0x02014b50 +const END_OF_CENTRAL_DIRECTORY = 0x06054b50 +const ZIP_VERSION = 20 +const LOCAL_HEADER_SIZE = 30 +const CENTRAL_ENTRY_SIZE = 46 +const END_RECORD_SIZE = 22 + +export function storedZip(files: {[name: string]: string}): Buffer { + const local: Buffer[] = [] + const central: Buffer[] = [] + let offset = 0 + for (const [name, content] of Object.entries(files)) { + const nameBuf = Buffer.from(name) + const data = Buffer.from(content) + const crc = zlib.crc32(data) + const header = Buffer.alloc(LOCAL_HEADER_SIZE) + header.writeUInt32LE(LOCAL_FILE_HEADER, 0) + header.writeUInt16LE(ZIP_VERSION, 4) + header.writeUInt32LE(crc, 14) + header.writeUInt32LE(data.length, 18) + header.writeUInt32LE(data.length, 22) + header.writeUInt16LE(nameBuf.length, 26) + local.push(header, nameBuf, data) + const entry = Buffer.alloc(CENTRAL_ENTRY_SIZE) + entry.writeUInt32LE(CENTRAL_DIRECTORY_HEADER, 0) + entry.writeUInt16LE(ZIP_VERSION, 4) + entry.writeUInt16LE(ZIP_VERSION, 6) + entry.writeUInt32LE(crc, 16) + entry.writeUInt32LE(data.length, 20) + entry.writeUInt32LE(data.length, 24) + entry.writeUInt16LE(nameBuf.length, 28) + entry.writeUInt32LE(offset, 42) + central.push(entry, nameBuf) + offset += header.length + nameBuf.length + data.length + } + const centralBuf = Buffer.concat(central) + const end = Buffer.alloc(END_RECORD_SIZE) + end.writeUInt32LE(END_OF_CENTRAL_DIRECTORY, 0) + end.writeUInt16LE(Object.keys(files).length, 8) + end.writeUInt16LE(Object.keys(files).length, 10) + end.writeUInt32LE(centralBuf.length, 12) + end.writeUInt32LE(offset, 16) + return Buffer.concat([...local, centralBuf, end]) +} diff --git a/packages/simplex-chat-python/src/simplex_chat/_native.py b/packages/simplex-chat-python/src/simplex_chat/_native.py index 009ff9a1e0..009ec685f4 100644 --- a/packages/simplex-chat-python/src/simplex_chat/_native.py +++ b/packages/simplex-chat-python/src/simplex_chat/_native.py @@ -7,6 +7,7 @@ from __future__ import annotations import ctypes import errno +import hashlib import os import platform import sys @@ -18,7 +19,7 @@ from ctypes import POINTER, c_char_p, c_int, c_uint8, c_void_p from pathlib import Path from typing import Any, Literal -from ._version import LIBS_VERSION +from ._version import LIBS_RELEASE, LIBS_SHA256 Backend = Literal["sqlite", "postgres"] @@ -59,11 +60,14 @@ def _libname() -> str: return _LIBNAME[sys.platform] -def _libs_url(backend: Backend) -> str: +def _libs_asset(backend: Backend) -> str: suffix = "-postgres" if backend == "postgres" else "" + return f"simplex-chat-libs-{_platform_tag()}{suffix}.zip" + + +def _libs_url(backend: Backend) -> str: return ( - f"https://github.com/{_GITHUB_REPO}/releases/download/" - f"v{LIBS_VERSION}/simplex-chat-libs-{_platform_tag()}{suffix}.zip" + f"https://github.com/{_GITHUB_REPO}/releases/download/{LIBS_RELEASE}/{_libs_asset(backend)}" ) @@ -84,7 +88,7 @@ def _resolve_libs_dir(backend: Backend) -> Path: "postgres backend is only supported on linux-x86_64; " f"current platform is {_platform_tag()}" ) - target = _cache_root() / f"v{LIBS_VERSION}" / backend + target = _cache_root() / LIBS_RELEASE / backend if not (target / _libname()).exists(): _download(target, backend) return target @@ -125,16 +129,21 @@ def _download(target: Path, backend: Backend) -> None: what we need and proceed. NEVER rmtree the target: that creates a TOCTOU window where another process is reading/loading the file we're deleting. """ + asset = _libs_asset(backend) + sha256 = LIBS_SHA256.get(asset) + if sha256 is None: + raise RuntimeError(f"no SHA-256 is pinned for {asset}") target.parent.mkdir(parents=True, exist_ok=True) url = _libs_url(backend) print( - f"Downloading libsimplex ({_platform_tag()}, {backend}) v{LIBS_VERSION} from {url} ...", + f"Downloading libsimplex ({_platform_tag()}, {backend}) {LIBS_RELEASE} from {url} ...", file=sys.stderr, flush=True, ) with tempfile.TemporaryDirectory(dir=target.parent) as tmp: zip_path = Path(tmp) / "libs.zip" _stream_to_file(url, zip_path, timeout=60.0) + _verify_sha256(zip_path, sha256, url) with zipfile.ZipFile(zip_path) as zf: zf.extractall(tmp) # zip layout: /libs/libsimplex.* + libHS*.* @@ -158,6 +167,16 @@ def _download(target: Path, backend: Backend) -> None: ) from e +def _verify_sha256(file: Path, expected: str, url: str) -> None: + digest = hashlib.sha256() + with file.open("rb") as f: + while chunk := f.read(_DOWNLOAD_CHUNK): + digest.update(chunk) + actual = digest.hexdigest() + if actual != expected: + raise RuntimeError(f"SHA-256 of {url} is {actual}, expected {expected}") + + _lock = threading.Lock() _lib: ctypes.CDLL | None = None _libc: ctypes.CDLL | None = None @@ -230,8 +249,8 @@ def lib_for(backend: Backend) -> ctypes.CDLL: if _lib is not None: if _backend != backend: raise RuntimeError( - f"libsimplex already loaded with backend={_backend!r}; " - f"cannot switch to {backend!r} in the same process" + f"libsimplex already loaded with backend={_backend}; " + f"cannot switch to {backend} in the same process" ) return _lib libs_dir = _resolve_libs_dir(backend) diff --git a/packages/simplex-chat-python/src/simplex_chat/_version.py b/packages/simplex-chat-python/src/simplex_chat/_version.py index c6c24a1e59..0786a81795 100644 --- a/packages/simplex-chat-python/src/simplex_chat/_version.py +++ b/packages/simplex-chat-python/src/simplex_chat/_version.py @@ -1,9 +1,16 @@ -"""Single source of truth for both the Python package version and the -simplex-chat-libs release tag we depend on. +"""Package version and the simplex-chat-libs release it loads. -Bump both together for normal releases. For wrapper-only fixes use a PEP 440 -post-release: __version__ = "6.5.2.post1", LIBS_VERSION unchanged. +Written by scripts/ci/packages_release.py: the release PR bumps the version +and pins new library releases with the SHA-256 of their assets. """ __version__ = "7.1.0b6" # PEP 440 — read by hatchling for wheel metadata -LIBS_VERSION = "7.1.0-beta.6" # simplex-chat-libs release tag (no 'v' prefix) +LIBS_RELEASE = "core-7.1.0.11" # simplex-chat-libs release tag +LIBS_SHA256 = { + "simplex-chat-libs-linux-aarch64.zip": "d66aa1b30b9fa20225dad8ef75a9c605961e1c0adde2ba0283e2fcac4e15d260", + "simplex-chat-libs-linux-x86_64-postgres.zip": "925b3166f969b12082ff8ee659e5f2a9933fe40120deb80c9aa3002a5ba6c803", + "simplex-chat-libs-linux-x86_64.zip": "dabad0824e8813ea4a084fbf37e1ba4cf6dcea9cd8ccadc521fec200f246208a", + "simplex-chat-libs-macos-aarch64.zip": "aa74608b149fff7508a47eaac4284dcc6d189c6ed8b7c18428df9158bc176a61", + "simplex-chat-libs-macos-x86_64.zip": "a0e21457e1077be7f71b46c1c27452945b2cee2b6c50efb0004412ce5b7042ec", + "simplex-chat-libs-windows-x86_64.zip": "c01dad574daa101a1cbfd31ddbf2b3d0d986a4a42f97d789d2f878b2050b2b4b", +} diff --git a/packages/simplex-chat-python/tests/test_native_cache.py b/packages/simplex-chat-python/tests/test_native_cache.py index 6c4f2a923d..ec10879077 100644 --- a/packages/simplex-chat-python/tests/test_native_cache.py +++ b/packages/simplex-chat-python/tests/test_native_cache.py @@ -1,10 +1,12 @@ +import hashlib import zipfile from pathlib import Path import pytest -from simplex_chat._native import _cache_root, _download, _resolve_libs_dir -from simplex_chat._version import LIBS_VERSION +from simplex_chat import _native +from simplex_chat._native import _cache_root, _download, _resolve_libs_dir, lib_for +from simplex_chat._version import LIBS_RELEASE def test_cache_root_linux(tmp_path, monkeypatch): @@ -42,7 +44,7 @@ def test_resolve_downloads_when_missing(tmp_path, monkeypatch): monkeypatch.setattr("simplex_chat._native._download", fake_download) libs_dir = _resolve_libs_dir("sqlite") - assert libs_dir == tmp_path / "simplex-chat" / f"v{LIBS_VERSION}" / "sqlite" + assert libs_dir == tmp_path / "simplex-chat" / LIBS_RELEASE / "sqlite" assert called["backend"] == "sqlite" assert (libs_dir / "libsimplex.so").exists() @@ -50,7 +52,7 @@ def test_resolve_downloads_when_missing(tmp_path, monkeypatch): def test_resolve_uses_cache_on_second_call(tmp_path, monkeypatch): monkeypatch.setenv("XDG_CACHE_HOME", str(tmp_path)) monkeypatch.setattr("sys.platform", "linux") - cached = tmp_path / "simplex-chat" / f"v{LIBS_VERSION}" / "sqlite" + cached = tmp_path / "simplex-chat" / LIBS_RELEASE / "sqlite" cached.mkdir(parents=True) (cached / "libsimplex.so").touch() # Should NOT call _download — use the cached file. @@ -67,9 +69,8 @@ def test_postgres_on_macos_rejected(monkeypatch): _resolve_libs_dir("postgres") -def test_atomic_install(tmp_path, monkeypatch): - """Build a fake libs zip, mock _stream_to_file, verify extraction + atomic rename.""" - # Build zip: libs/libsimplex.so + libs/libHS-stub.so +def fake_libs_zip(tmp_path, monkeypatch, sha256=None) -> None: + """Serve a zip with libs/libsimplex.so + libs/libHS-stub.so, pinned with `sha256` (its own by default).""" src = tmp_path / "src" / "libs" src.mkdir(parents=True) (src / "libsimplex.so").write_text("fake-so") @@ -84,20 +85,54 @@ def test_atomic_install(tmp_path, monkeypatch): shutil.copy(zip_path, dest) + pinned = sha256 or hashlib.sha256(zip_path.read_bytes()).hexdigest() monkeypatch.setattr("simplex_chat._native._stream_to_file", fake_stream) monkeypatch.setattr("simplex_chat._native._platform_tag", lambda: "linux-x86_64") + monkeypatch.setattr( + "simplex_chat._native.LIBS_SHA256", {"simplex-chat-libs-linux-x86_64.zip": pinned} + ) + +def test_atomic_install(tmp_path, monkeypatch): + fake_libs_zip(tmp_path, monkeypatch) target = tmp_path / "out" _download(target, "sqlite") assert (target / "libsimplex.so").read_text() == "fake-so" assert (target / "libHS-stub.so").read_text() == "fake-hs" +def test_download_rejects_another_sha256(tmp_path, monkeypatch): + fake_libs_zip(tmp_path, monkeypatch, sha256="0" * 64) + target = tmp_path / "out" + with pytest.raises(RuntimeError, match=f"expected {'0' * 64}"): + _download(target, "sqlite") + assert sorted(p.name for p in tmp_path.iterdir()) == ["fake-libs.zip", "src"] + + +def test_download_requires_pinned_sha256(tmp_path, monkeypatch): + fake_libs_zip(tmp_path, monkeypatch) + with pytest.raises( + RuntimeError, match="no SHA-256 is pinned for simplex-chat-libs-linux-x86_64-postgres.zip" + ): + _download(tmp_path / "out", "postgres") + + def test_libc_on_windows_is_ucrt(monkeypatch): loaded: list[str | None] = [] monkeypatch.setattr("sys.platform", "win32") monkeypatch.setattr("ctypes.CDLL", lambda name: loaded.append(name)) - from simplex_chat import _native - _native._load_libc() assert loaded == ["ucrtbase"] + + +def test_lib_for_rejects_backend_switch(monkeypatch): + monkeypatch.setattr(_native, "_lib", object()) + monkeypatch.setattr(_native, "_backend", "sqlite") + monkeypatch.setattr( + _native, "_resolve_libs_dir", lambda _: pytest.fail("must not resolve libs") + ) + with pytest.raises(RuntimeError) as exc: + lib_for("postgres") + assert str(exc.value) == ( + "libsimplex already loaded with backend=sqlite; cannot switch to postgres in the same process" + ) diff --git a/packages/simplex-chat-python/tests/test_native_url.py b/packages/simplex-chat-python/tests/test_native_url.py index 12270c9db1..f1d60d5170 100644 --- a/packages/simplex-chat-python/tests/test_native_url.py +++ b/packages/simplex-chat-python/tests/test_native_url.py @@ -3,7 +3,7 @@ from unittest.mock import patch import pytest from simplex_chat._native import _libname, _libs_url, _platform_tag -from simplex_chat._version import LIBS_VERSION +from simplex_chat._version import LIBS_RELEASE @patch("sys.platform", "linux") @@ -45,7 +45,7 @@ def test_url_sqlite(_): assert ( _libs_url("sqlite") == "https://github.com/simplex-chat/simplex-chat-libs/releases/download/" - f"v{LIBS_VERSION}/simplex-chat-libs-linux-x86_64.zip" + f"{LIBS_RELEASE}/simplex-chat-libs-linux-x86_64.zip" ) @@ -54,5 +54,5 @@ def test_url_postgres(_): assert ( _libs_url("postgres") == "https://github.com/simplex-chat/simplex-chat-libs/releases/download/" - f"v{LIBS_VERSION}/simplex-chat-libs-linux-x86_64-postgres.zip" + f"{LIBS_RELEASE}/simplex-chat-libs-linux-x86_64-postgres.zip" ) diff --git a/scripts/calculator-bot/Dockerfile b/scripts/calculator-bot/Dockerfile index 6e049ec3d6..4b0728484c 100644 --- a/scripts/calculator-bot/Dockerfile +++ b/scripts/calculator-bot/Dockerfile @@ -66,8 +66,9 @@ RUN apt-get update && apt-get install -y --no-install-recommends \ rm -rf /var/lib/apt/lists/* COPY --from=libsimplex /libs /opt/simplex/libs -# Without it, npm re-running the library's preinstall downloads the released libs. -ENV SIMPLEX_LIBS_DIR=/opt/simplex/libs +# The library loads libsimplex and the add-on built below instead of downloading the released ones. +ENV SIMPLEX_LIBS_DIR=/opt/simplex/libs \ + SIMPLEX_ADDON_PATH=/src/packages/simplex-chat-nodejs/build/Release/simplex.node WORKDIR /src COPY packages/simplex-chat-client/types/typescript packages/simplex-chat-client/types/typescript @@ -79,6 +80,7 @@ COPY packages/simplex-chat-nodejs packages/simplex-chat-nodejs RUN cd packages/simplex-chat-nodejs && \ npm install --no-audit --no-fund && \ npm install --no-save --no-audit --no-fund ../simplex-chat-client/types/typescript && \ + npx node-gyp rebuild && \ npx tsc && \ cp src/simplex.* dist/ @@ -105,7 +107,10 @@ RUN apt-get update && apt-get install -y --no-install-recommends \ ca-certificates dumb-init libffi8 libgmp10 libssl3 zlib1g && \ rm -rf /var/lib/apt/lists/* +COPY --from=build /opt/simplex/libs /opt/simplex/libs COPY --from=build --chown=node:node /src /src +ENV SIMPLEX_LIBS_DIR=/opt/simplex/libs \ + SIMPLEX_ADDON_PATH=/src/packages/simplex-chat-nodejs/build/Release/simplex.node # A named volume takes this ownership; a bind mount must be owned by uid 1000. RUN mkdir -p /src/apps/simplex-calculator-bot/data && \ diff --git a/scripts/ci/packages_release.py b/scripts/ci/packages_release.py new file mode 100644 index 0000000000..5378f311b5 --- /dev/null +++ b/scripts/ci/packages_release.py @@ -0,0 +1,529 @@ +#!/usr/bin/env python3 +"""Release automation for simplex-chat and @simplex-chat/types on npm and simplex-chat on PyPI. + +addon-tag print the add-on release tag for the current sources +changes print whether the changes since a commit touch the chat core and the add-on +build-plan print the platforms to build the add-on on and whether to run the package tests +prepare update pins and versions for a release PR +publish-plan print the package versions that are not published yet +test-names rename the packages to NPM_SCOPE and PYPI_PROJECT (test repositories) +""" + +from __future__ import annotations + +import argparse +import hashlib +import json +import os +import re +import subprocess +import sys +import urllib.error +import urllib.request +from dataclasses import dataclass, field, replace +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[2] +NODEJS_DIR = "packages/simplex-chat-nodejs" +TYPES_DIR = "packages/simplex-chat-client/types/typescript" +PYTHON_DIR = "packages/simplex-chat-python" +RELEASE_FILE = f"{NODEJS_DIR}/src/release.ts" +PYTHON_VERSION_FILE = f"{PYTHON_DIR}/src/simplex_chat/_version.py" +ADDON_SOURCES = [f"{NODEJS_DIR}/binding.gyp", f"{NODEJS_DIR}/cpp"] +CORE_SOURCES = ["src", "simplex-chat.cabal", "cabal.project"] +ADDON_TAG_HASH_LENGTH = 12 +LIBS_ASSETS = [ + "simplex-chat-libs-linux-aarch64.zip", + "simplex-chat-libs-linux-x86_64-postgres.zip", + "simplex-chat-libs-linux-x86_64.zip", + "simplex-chat-libs-macos-aarch64.zip", + "simplex-chat-libs-macos-x86_64.zip", + "simplex-chat-libs-windows-x86_64.zip", +] +ADDON_RUNNERS = { + "linux-aarch64": "ubuntu-22.04-arm", + "linux-x86_64": "ubuntu-22.04", + "macos-aarch64": "macos-latest", + "macos-x86_64": "macos-15-intel", + "windows-x86_64": "windows-latest", +} +ADDON_PLATFORMS = list(ADDON_RUNNERS) +# build.yml builds libsimplex of pull requests on this platform only +CORE_TEST_PLATFORM = "linux-x86_64" +ADDON_ASSETS = [f"simplex-chat-nodejs-{platform}.node" for platform in ADDON_PLATFORMS] +SHA256_HEX = re.compile(r"^[0-9a-f]{64}$") + + +# Pure functions + + +def addon_tag(files: dict[str, bytes], node_addon_api: str) -> str: + digest = hashlib.sha256() + for name in sorted(files): + digest.update(name.encode() + b"\0" + files[name] + b"\0") + digest.update(f"node-addon-api@{node_addon_api}".encode()) + return f"nodejs-addon-{digest.hexdigest()[:ADDON_TAG_HASH_LENGTH]}" + + +def bump_npm_version(version: str) -> str: + """A package release of app version 7.1.0-beta.6 is 7.1.0-beta.6.1, then 7.1.0-beta.6.2.""" + _, _, prerelease = version.partition("-") + if len(prerelease.split(".")) == 2: + return f"{version}.1" + head, _, last = version.rpartition(".") + return f"{head}.{int(last) + 1}" if last.isdigit() else f"{version}.1" + + +def bump_python_version(version: str) -> str: + """A package release of app version 7.1.0b6 is 7.1.0b6.post1, then 7.1.0b6.post2.""" + post = re.search(r"\.post(\d+)$", version) + return f"{version[: post.start()]}.post{int(post[1]) + 1}" if post else f"{version}.post1" + + +def dist_tag(version: str) -> str: + return "beta" if "-" in version else "latest" + + +def registry_name(name: str, scope: str | None) -> str: + """Package name on npm: test repositories publish under their own scope.""" + return f"{scope}/{name.split('/')[-1]}" if scope else name + + +@dataclass(frozen=True) +class Pins: + libs_release: str + sha256: dict[str, str] + addon_release: str | None = None + + +def parse_release(source: str) -> Pins: + libs = re.search(r'export const LIBS_RELEASE = "([^"]+)"', source) + addon = re.search(r'export const ADDON_RELEASE: string \| undefined = (?:"([^"]+)"|undefined)', source) + if not libs or not addon: + raise ValueError(f"cannot parse {RELEASE_FILE}") + hashes = dict(re.findall(r'^ {2}"([^"]+)": "([0-9a-f]{64})",$', source, re.M)) + return Pins(libs[1], hashes, addon[1]) + + +def render_release(pins: Pins) -> str: + hashes = "".join(f' "{asset}": "{pins.sha256[asset]}",\n' for asset in sorted(pins.sha256)) + addon = f'"{pins.addon_release}"' if pins.addon_release else "undefined" + return ( + "// Written by scripts/ci/packages_release.py from the simplex-chat-libs releases.\n" + "\n" + f'export const LIBS_RELEASE = "{pins.libs_release}"\n' + "\n" + f"export const ADDON_RELEASE: string | undefined = {addon}\n" + "\n" + "export const SHA256: {readonly [asset: string]: string} = {\n" + f"{hashes}}}\n" + ) + + +def parse_python_version(source: str) -> tuple[str, Pins]: + version = re.search(r'^__version__ = "([^"]+)"', source, re.M) + libs = re.search(r'^LIBS_RELEASE = "([^"]+)"', source, re.M) + if not version or not libs: + raise ValueError(f"cannot parse {PYTHON_VERSION_FILE}") + hashes = dict(re.findall(r'^ {4}"([^"]+)": "([0-9a-f]{64})",$', source, re.M)) + return version[1], Pins(libs[1], hashes) + + +def render_python_version(version: str, pins: Pins) -> str: + hashes = "".join(f' "{asset}": "{pins.sha256[asset]}",\n' for asset in sorted(pins.sha256)) + return ( + '"""Package version and the simplex-chat-libs release it loads.\n' + "\n" + "Written by scripts/ci/packages_release.py: the release PR bumps the version\n" + "and pins new library releases with the SHA-256 of their assets.\n" + '"""\n' + "\n" + f'__version__ = "{version}" # PEP 440 — read by hatchling for wheel metadata\n' + f'LIBS_RELEASE = "{pins.libs_release}" # simplex-chat-libs release tag\n' + "LIBS_SHA256 = {\n" + f"{hashes}}}\n" + ) + + +def release_digests(release: dict | None, assets: list[str]) -> dict[str, str] | None: + """Asset digests of a GitHub release, or None when the release is missing or incomplete.""" + if release is None: + return None + digests = {a["name"]: (a.get("digest") or "").removeprefix("sha256:") for a in release["assets"]} + if not all(SHA256_HEX.match(digests.get(asset, "")) for asset in assets): + return None + return {asset: digests[asset] for asset in assets} + + +@dataclass(frozen=True) +class Package: + version: str + changed: bool + published: bool + + +@dataclass(frozen=True) +class State: + libs_tag: str + libs_digests: dict[str, str] | None + addon_tag: str + addon_digests: dict[str, str] | None + nodejs_pins: Pins + python_pins: Pins + types: Package + nodejs: Package + python: Package + types_version: str | None = None + nodejs_version: str | None = None + python_version: str | None = None + + +@dataclass(frozen=True) +class Plan: + nodejs_pins: Pins + python_pins: Pins + types_version: str + types_bumped: bool + nodejs_version: str + nodejs_changed: bool + python_version: str + python_changed: bool + notes: list[str] = field(default_factory=list) + + @property + def types_dependency(self) -> str | None: + return f"^{self.types_version}" if self.types_bumped else None + + @property + def title(self) -> str: + parts = [] + if self.types_bumped: + parts.append(f"types {self.types_version}") + if self.nodejs_changed: + parts.append(f"nodejs {self.nodejs_version}") + if self.python_changed: + parts.append(f"python {self.python_version}") + return f"packages: release {', '.join(parts)}" + + +def plan(state: State) -> Plan: + """What a release PR changes, from the state of the branch and the releases.""" + notes: list[str] = [] + + def pin(pins: Pins, field_name: str, tag: str, digests: dict[str, str] | None, what: str) -> tuple[Pins, bool]: + current = getattr(pins, field_name) + if current == tag: + return pins, False + if digests is None: + notes.append(f"{what} release {tag} is missing or incomplete; {current or 'none'} stays pinned") + return pins, False + notes.append(f"{what}: {tag}") + return replace(pins, **{field_name: tag, "sha256": {**pins.sha256, **digests}}), True + + nodejs_pins, nodejs_libs = pin( + state.nodejs_pins, "libs_release", state.libs_tag, state.libs_digests, "Node.js libsimplex" + ) + nodejs_pins, addon = pin(nodejs_pins, "addon_release", state.addon_tag, state.addon_digests, "Node.js add-on") + python_pins, python_libs = pin( + state.python_pins, "libs_release", state.libs_tag, state.libs_digests, "Python libsimplex" + ) + + def next_version(pkg: Package, changed: bool, override: str | None, bump) -> str: + if override: + return override + return bump(pkg.version) if changed and pkg.published else pkg.version + + types_version = next_version(state.types, state.types.changed, state.types_version, bump_npm_version) + types_bumped = types_version != state.types.version + nodejs_changed = nodejs_libs or addon or types_bumped or state.nodejs.changed + nodejs_version = next_version(state.nodejs, nodejs_changed, state.nodejs_version, bump_npm_version) + python_changed = python_libs or state.python.changed + python_version = next_version(state.python, python_changed, state.python_version, bump_python_version) + for name, old, new in [ + ("@simplex-chat/types", state.types.version, types_version), + ("simplex-chat (npm)", state.nodejs.version, nodejs_version), + ("simplex-chat (PyPI)", state.python.version, python_version), + ]: + if old != new: + notes.append(f"{name}: {old} → {new}") + return Plan( + nodejs_pins=nodejs_pins, + python_pins=python_pins, + types_version=types_version, + types_bumped=types_bumped, + nodejs_version=nodejs_version, + nodejs_changed=nodejs_changed or nodejs_version != state.nodejs.version, + python_version=python_version, + python_changed=python_changed or python_version != state.python.version, + notes=notes, + ) + + +@dataclass(frozen=True) +class BuildPlan: + platforms: list[str] + test: bool + + +def build_plan( + event: str, release_missing: bool, libs_artifact: bool, libs_release: str, core_changed: bool, addon_changed: bool +) -> BuildPlan: + """Each package test runs once, against the libsimplex of the change: a pull request that changes the core + is tested by build.yml with its own libsimplex (libs_artifact), a new library release with that release.""" + if libs_artifact: + return BuildPlan([CORE_TEST_PLATFORM], test=True) + if libs_release: + return BuildPlan(ADDON_PLATFORMS, test=True) + if event == "pull_request": + if core_changed: + return BuildPlan(ADDON_PLATFORMS if addon_changed else [], test=False) + return BuildPlan(ADDON_PLATFORMS, test=True) + return BuildPlan(ADDON_PLATFORMS if release_missing else [], test=True) + + +def build_matrix(platforms: list[str]) -> str: + return json.dumps([{"platform": platform, "os": ADDON_RUNNERS[platform]} for platform in platforms]) + + +# Repository and registry access + + +def git(*args: str) -> str: + return subprocess.run(["git", *args], cwd=ROOT, check=True, capture_output=True, text=True).stdout.strip() + + +def read_text(file: str) -> str: + return (ROOT / file).read_text() + + +def read_field(file: str, pattern: str) -> str: + found = re.search(pattern, read_text(file), re.M) + if not found: + raise ValueError(f"no match for {pattern} in {file}") + return found[1] + + +def read_json(file: str) -> dict: + return json.loads(read_text(file)) + + +def write_json(file: str, value: dict) -> None: + (ROOT / file).write_text(json.dumps(value, indent=2, ensure_ascii=False) + "\n") + + +def current_addon_tag() -> str: + files = { + path.relative_to(ROOT).as_posix(): path.read_bytes() + for source in ADDON_SOURCES + for path in ([ROOT / source] if (ROOT / source).is_file() else sorted((ROOT / source).rglob("*"))) + if path.is_file() + } + return addon_tag(files, read_json(f"{NODEJS_DIR}/package.json")["devDependencies"]["node-addon-api"]) + + +def changed_since(base: str, sources: list[str]) -> bool: + diff = subprocess.run(["git", "diff", "--quiet", base, "HEAD", "--", *sources], cwd=ROOT) + return diff.returncode != 0 + + +def changes(base: str) -> None: + print(f"core={'true' if changed_since(base, CORE_SOURCES) else ''}") + print(f"addon={'true' if changed_since(base, ADDON_SOURCES) else ''}") + + +def print_build_plan(args: argparse.Namespace) -> None: + pull_request = args.event == "pull_request" and not args.libs_artifact + result = build_plan( + args.event, + release_missing=args.release_missing, + libs_artifact=args.libs_artifact, + libs_release=args.libs_release, + core_changed=pull_request and changed_since(args.base, CORE_SOURCES), + addon_changed=pull_request and changed_since(args.base, ADDON_SOURCES), + ) + print(f"platforms={build_matrix(result.platforms)}") + print(f"test={'true' if result.test else ''}") + + +def publish_tag(package: str, version: str) -> str: + """Tag of the commit a version was published from (packages.yml).""" + return f"{package}-v{version}" + + +def published_tag(package: str, version: str) -> str | None: + """The publish tag of the version, when it exists: registries list new versions with a delay.""" + tag = f"refs/tags/{publish_tag(package, version)}" + found = subprocess.run(["git", "rev-parse", "--verify", "--quiet", tag], cwd=ROOT, capture_output=True) + return tag if found.returncode == 0 else None + + +def changed_since_publish(package: str, version: str, directory: str, version_file: str, version_pattern: str) -> bool: + """Shipped files changed since the version was published, or since it was set when it was not published yet.""" + tag = published_tag(package, version) + base = tag or git("log", "-1", "--format=%H", "-G", version_pattern, "--", version_file) + diff = subprocess.run(["git", "diff", "--quiet", base, "HEAD", "--", directory, f":!{directory}/tests"], cwd=ROOT) + return diff.returncode != 0 + + +def http_json(url: str, headers: dict[str, str] | None = None) -> dict | None: + """The JSON at `url`, or None for 404.""" + request = urllib.request.Request(url, headers={"User-Agent": "simplex-chat-release", **(headers or {})}) + try: + with urllib.request.urlopen(request, timeout=60) as response: + return json.load(response) + except urllib.error.HTTPError as e: + if e.code == 404: + return None + raise + + +def npm_published(name: str, version: str) -> bool: + registry = os.environ.get("NPM_REGISTRY", "https://registry.npmjs.org") + package = registry_name(name, os.environ.get("NPM_SCOPE")).replace("/", "%2F") + return http_json(f"{registry}/{package}/{version}") is not None + + +def python_project() -> str: + return read_field(f"{PYTHON_DIR}/pyproject.toml", r'^name = "([^"]+)"') + + +def pypi_published(version: str) -> bool: + index = os.environ.get("PYPI_URL", "https://pypi.org") + project = os.environ.get("PYPI_PROJECT") or python_project() + return http_json(f"{index}/pypi/{project}/{version}/json") is not None + + +def github_release(tag: str) -> dict | None: + repo = os.environ.get("LIBS_REPOSITORY", "simplex-chat/simplex-chat-libs") + headers = {"Accept": "application/vnd.github+json"} + if token := os.environ.get("GH_TOKEN"): + headers["Authorization"] = f"Bearer {token}" + return http_json(f"https://api.github.com/repos/{repo}/releases/tags/{tag}", headers) + + +def npm_package(key: str, directory: str) -> Package: + package = read_json(f"{directory}/package.json") + changed = changed_since_publish(key, package["version"], directory, f"{directory}/package.json", '"version":') + published = bool(published_tag(key, package["version"])) or npm_published(package["name"], package["version"]) + return Package(package["version"], changed, published) + + +def prepare(args: argparse.Namespace) -> None: + cabal_version = read_field("simplex-chat.cabal", r"^version:\s*(\S+)") + libs_tag = f"core-{cabal_version}" + addon = current_addon_tag() + python_version, python_pins = parse_python_version(read_text(PYTHON_VERSION_FILE)) + python_changed = changed_since_publish("python", python_version, PYTHON_DIR, PYTHON_VERSION_FILE, "^__version__ =") + result = plan( + State( + libs_tag=libs_tag, + libs_digests=release_digests(github_release(libs_tag), LIBS_ASSETS), + addon_tag=addon, + addon_digests=release_digests(github_release(addon), ADDON_ASSETS), + nodejs_pins=parse_release(read_text(RELEASE_FILE)), + python_pins=python_pins, + types=npm_package("types", TYPES_DIR), + nodejs=npm_package("nodejs", NODEJS_DIR), + python=Package( + python_version, + python_changed, + bool(published_tag("python", python_version)) or pypi_published(python_version), + ), + types_version=args.types_version, + nodejs_version=args.nodejs_version, + python_version=args.python_version, + ) + ) + (ROOT / RELEASE_FILE).write_text(render_release(result.nodejs_pins)) + (ROOT / PYTHON_VERSION_FILE).write_text(render_python_version(result.python_version, result.python_pins)) + types = read_json(f"{TYPES_DIR}/package.json") + write_json(f"{TYPES_DIR}/package.json", {**types, "version": result.types_version}) + nodejs = read_json(f"{NODEJS_DIR}/package.json") + dependencies = nodejs["dependencies"] + if result.types_dependency: + dependencies = {**dependencies, "@simplex-chat/types": result.types_dependency} + write_json(f"{NODEJS_DIR}/package.json", {**nodejs, "version": result.nodejs_version, "dependencies": dependencies}) + summary = result.title + "\n\n" + "".join(f"- {note}\n" for note in result.notes) + if args.summary: + Path(args.summary).write_text(summary) + print(summary, end="") + + +def publish_plan() -> None: + for key, directory in [("types", TYPES_DIR), ("nodejs", NODEJS_DIR)]: + package = read_json(f"{directory}/package.json") + pending = not published_tag(key, package["version"]) and not npm_published(package["name"], package["version"]) + print(f"{key}_version={package['version'] if pending else ''}") + print(f"{key}_dist_tag={dist_tag(package['version'])}") + version, _ = parse_python_version(read_text(PYTHON_VERSION_FILE)) + pending = not published_tag("python", version) and not pypi_published(version) + print(f"python_version={version if pending else ''}") + + +def test_names() -> None: + if scope := os.environ.get("NPM_SCOPE"): + # npm provenance requires the repository of the publishing workflow. + url = f"git+https://github.com/{os.environ['GITHUB_REPOSITORY']}.git" + types = read_json(f"{TYPES_DIR}/package.json") + write_json( + f"{TYPES_DIR}/package.json", + {**types, "name": registry_name(types["name"], scope), "repository": {**types["repository"], "url": url}}, + ) + nodejs = read_json(f"{NODEJS_DIR}/package.json") + alias = f"npm:{registry_name(types['name'], scope)}@{nodejs['dependencies'][types['name']]}" + write_json( + f"{NODEJS_DIR}/package.json", + { + **nodejs, + "name": registry_name(nodejs["name"], scope), + "repository": {**nodejs["repository"], "url": url}, + "dependencies": {**nodejs["dependencies"], types["name"]: alias}, + }, + ) + if project := os.environ.get("PYPI_PROJECT"): + pyproject = ROOT / PYTHON_DIR / "pyproject.toml" + pyproject.write_text( + re.sub(r'^name = "[^"]+"', f'name = "{project}"', pyproject.read_text(), count=1, flags=re.M) + ) + + +def github_bool(value: str) -> bool: + """GitHub expressions pass booleans to the shell as "true", "false" or "".""" + return value == "true" + + +def main(argv: list[str]) -> None: + parser = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter) + commands = parser.add_subparsers(dest="command", required=True) + commands.add_parser("addon-tag") + changes_parser = commands.add_parser("changes") + changes_parser.add_argument("base", help="the commit to compare with") + build_parser = commands.add_parser("build-plan") + build_parser.add_argument("--event", required=True, help="github.event_name") + build_parser.add_argument("--base", default="", help="the base commit of a pull request") + build_parser.add_argument("--release-missing", type=github_bool, default=False) + build_parser.add_argument("--libs-artifact", type=github_bool, default=False) + build_parser.add_argument("--libs-release", default="") + prepare_parser = commands.add_parser("prepare") + for package in ["nodejs", "types", "python"]: + prepare_parser.add_argument(f"--{package}-version", help="this version instead of a bump when needed") + prepare_parser.add_argument("--summary", help="file for the PR title and body") + commands.add_parser("publish-plan") + commands.add_parser("test-names") + args = parser.parse_args(argv) + if args.command == "addon-tag": + print(current_addon_tag()) + elif args.command == "changes": + changes(args.base) + elif args.command == "build-plan": + print_build_plan(args) + elif args.command == "prepare": + prepare(args) + elif args.command == "publish-plan": + publish_plan() + else: + test_names() + + +if __name__ == "__main__": + main(sys.argv[1:]) diff --git a/scripts/ci/test_packages_release.py b/scripts/ci/test_packages_release.py new file mode 100644 index 0000000000..4d0f0ac2f4 --- /dev/null +++ b/scripts/ci/test_packages_release.py @@ -0,0 +1,288 @@ +import subprocess +import tempfile +import unittest +from dataclasses import replace +from pathlib import Path +from unittest import mock + +import packages_release +from packages_release import ( + ADDON_ASSETS, + ADDON_PLATFORMS, + BuildPlan, + LIBS_ASSETS, + Package, + Pins, + State, + addon_tag, + build_matrix, + build_plan, + bump_npm_version, + bump_python_version, + changed_since, + changed_since_publish, + published_tag, + dist_tag, + parse_python_version, + parse_release, + plan, + registry_name, + release_digests, + render_python_version, + render_release, +) + + +def sha(n: int) -> str: + return f"{n:x}" * 64 + + +def digests(assets: list[str], n: int) -> dict[str, str]: + return {asset: sha(n) for asset in assets} + + +def github_release(assets: list[str], n: int) -> dict: + return {"assets": [{"name": name, "digest": f"sha256:{sha(n)}"} for name in assets]} + + +def state(**overrides) -> State: + current = State( + libs_tag="core-1.0.0.1", + libs_digests=digests(LIBS_ASSETS, 1), + addon_tag="nodejs-addon-aaa", + addon_digests=digests(ADDON_ASSETS, 2), + nodejs_pins=Pins("core-1.0.0.1", {**digests(LIBS_ASSETS, 1), **digests(ADDON_ASSETS, 2)}, "nodejs-addon-aaa"), + python_pins=Pins("core-1.0.0.1", digests(LIBS_ASSETS, 1)), + types=Package("0.12.1", changed=False, published=True), + nodejs=Package("7.1.0-beta.6.1", changed=False, published=True), + python=Package("7.1.0b6", changed=False, published=True), + ) + return replace(current, **overrides) + + +NEW_LIBS = {"libs_tag": "core-1.0.0.2", "libs_digests": digests(LIBS_ASSETS, 3)} +NEW_ADDON = {"addon_tag": "nodejs-addon-bbb", "addon_digests": digests(ADDON_ASSETS, 4)} + + +class VersionTest(unittest.TestCase): + def test_npm_bump_adds_a_package_release_to_an_app_version(self): + self.assertEqual(bump_npm_version("7.1.0-beta.6"), "7.1.0-beta.6.1") + self.assertEqual(bump_npm_version("7.1.0-beta.6.1"), "7.1.0-beta.6.2") + self.assertEqual(bump_npm_version("0.12.9"), "0.12.10") + self.assertEqual(bump_npm_version("7.0.2"), "7.0.3") + + def test_python_bump_uses_post_releases(self): + self.assertEqual(bump_python_version("7.1.0b6"), "7.1.0b6.post1") + self.assertEqual(bump_python_version("7.1.0b6.post9"), "7.1.0b6.post10") + self.assertEqual(bump_python_version("7.0.2"), "7.0.2.post1") + + def test_prereleases_publish_as_beta(self): + self.assertEqual(dist_tag("7.1.0-beta.6.2"), "beta") + self.assertEqual(dist_tag("7.1.0"), "latest") + + def test_test_scope_renames_npm_packages(self): + self.assertEqual(registry_name("@simplex-chat/types", "@test"), "@test/types") + self.assertEqual(registry_name("simplex-chat", "@test"), "@test/simplex-chat") + self.assertEqual(registry_name("simplex-chat", None), "simplex-chat") + + +class AddonTagTest(unittest.TestCase): + def test_depends_on_sources_and_node_addon_api(self): + files = {"a.cc": b"1", "b.h": b"2"} + tag = addon_tag(files, "^8.5.0") + self.assertRegex(tag, r"^nodejs-addon-[0-9a-f]{12}$") + self.assertEqual(addon_tag(dict(reversed(files.items())), "^8.5.0"), tag) + self.assertNotEqual(addon_tag({**files, "b.h": b"3"}, "^8.5.0"), tag) + self.assertNotEqual(addon_tag({"a.cc": b"1", "c.h": b"2"}, "^8.5.0"), tag) + self.assertNotEqual(addon_tag(files, "^8.6.0"), tag) + + +class PinFilesTest(unittest.TestCase): + def test_release_ts_round_trip(self): + pins = Pins("core-1.0.0.1", {"b.zip": sha(2), "a.zip": sha(1)}, "nodejs-addon-aaa") + self.assertEqual(parse_release(render_release(pins)), pins) + self.assertIsNone(parse_release(render_release(replace(pins, addon_release=None))).addon_release) + + def test_python_version_round_trip(self): + pins = Pins("core-1.0.0.1", {"b.zip": sha(2), "a.zip": sha(1)}) + self.assertEqual(parse_python_version(render_python_version("7.1.0b6.post1", pins)), ("7.1.0b6.post1", pins)) + + +class ReleaseDigestsTest(unittest.TestCase): + def test_requires_every_asset_with_a_digest(self): + self.assertEqual(release_digests(github_release(LIBS_ASSETS, 3), LIBS_ASSETS), digests(LIBS_ASSETS, 3)) + self.assertIsNone(release_digests(github_release(LIBS_ASSETS[1:], 3), LIBS_ASSETS)) + self.assertIsNone(release_digests(None, LIBS_ASSETS)) + no_digests = {"assets": [{"name": name, "digest": None} for name in LIBS_ASSETS]} + self.assertIsNone(release_digests(no_digests, LIBS_ASSETS)) + + +class PlanTest(unittest.TestCase): + def test_nothing_changes_when_current(self): + result = plan(state()) + self.assertEqual((result.nodejs_changed, result.python_changed, result.types_bumped), (False, False, False)) + self.assertEqual(result.notes, []) + + def test_new_libs_release_updates_both_libraries(self): + result = plan(state(**NEW_LIBS)) + self.assertEqual(result.nodejs_pins.libs_release, "core-1.0.0.2") + self.assertEqual(result.python_pins.libs_release, "core-1.0.0.2") + self.assertEqual(result.nodejs_pins.sha256[LIBS_ASSETS[0]], sha(3)) + self.assertEqual(result.nodejs_pins.sha256[ADDON_ASSETS[0]], sha(2)) + self.assertEqual(result.python_pins.sha256[LIBS_ASSETS[0]], sha(3)) + self.assertEqual(result.title, "packages: release nodejs 7.1.0-beta.6.2, python 7.1.0b6.post1") + + def test_new_addon_release_updates_nodejs_only(self): + result = plan(state(**NEW_ADDON)) + self.assertEqual(result.nodejs_pins.addon_release, "nodejs-addon-bbb") + self.assertEqual(result.nodejs_pins.sha256[ADDON_ASSETS[0]], sha(4)) + self.assertEqual(result.title, "packages: release nodejs 7.1.0-beta.6.2") + + def test_incomplete_release_keeps_the_pins(self): + result = plan(state(addon_tag="nodejs-addon-bbb", addon_digests=None)) + self.assertEqual(result.nodejs_pins.addon_release, "nodejs-addon-aaa") + self.assertFalse(result.nodejs_changed) + self.assertEqual( + result.notes, + ["Node.js add-on release nodejs-addon-bbb is missing or incomplete; nodejs-addon-aaa stays pinned"], + ) + + def test_source_changes_bump_their_package(self): + self.assertEqual( + plan(state(nodejs=Package("7.1.0-beta.6.1", True, True))).title, "packages: release nodejs 7.1.0-beta.6.2" + ) + self.assertEqual( + plan(state(python=Package("7.1.0b6", True, True))).title, "packages: release python 7.1.0b6.post1" + ) + + def test_types_bump_updates_the_nodejs_dependency(self): + result = plan(state(types=Package("0.12.1", True, True))) + self.assertEqual(result.types_dependency, "^0.12.2") + self.assertEqual(result.title, "packages: release types 0.12.2, nodejs 7.1.0-beta.6.2") + + def test_all_changes_combine_into_one_release(self): + result = plan( + state( + **NEW_LIBS, + **NEW_ADDON, + types=Package("0.12.1", True, True), + nodejs=Package("7.1.0-beta.6.1", True, True), + python=Package("7.1.0b6", True, True), + ) + ) + self.assertEqual( + (result.nodejs_pins.libs_release, result.nodejs_pins.addon_release), ("core-1.0.0.2", "nodejs-addon-bbb") + ) + self.assertEqual(result.title, "packages: release types 0.12.2, nodejs 7.1.0-beta.6.2, python 7.1.0b6.post1") + + def test_unpublished_versions_are_not_bumped_again(self): + result = plan( + state( + **NEW_LIBS, nodejs=Package("7.1.0-beta.6.2", True, False), python=Package("7.1.0b6.post1", False, False) + ) + ) + self.assertEqual(result.title, "packages: release nodejs 7.1.0-beta.6.2, python 7.1.0b6.post1") + + def test_requested_versions_win(self): + result = plan(state(nodejs_version="7.2.0", types_version="0.13.0", python_version="7.2.0")) + self.assertEqual(result.types_dependency, "^0.13.0") + self.assertEqual(result.title, "packages: release types 0.13.0, nodejs 7.2.0, python 7.2.0") + + +class BuildPlanTest(unittest.TestCase): + def plan(self, event="push", release_missing=False, libs_artifact=False, libs_release="", core=False, addon=False): + return build_plan(event, release_missing, libs_artifact, libs_release, core, addon) + + def test_push_builds_a_missing_addon_release_and_tests_it(self): + self.assertEqual(self.plan(release_missing=True), BuildPlan(ADDON_PLATFORMS, test=True)) + self.assertEqual(self.plan(), BuildPlan([], test=True)) + + def test_pull_request_tests_with_the_pinned_libs(self): + self.assertEqual(self.plan("pull_request"), BuildPlan(ADDON_PLATFORMS, test=True)) + self.assertEqual(self.plan("pull_request", addon=True), BuildPlan(ADDON_PLATFORMS, test=True)) + + def test_core_change_leaves_tests_to_build_yml(self): + self.assertEqual(self.plan("pull_request", core=True), BuildPlan([], test=False)) + self.assertEqual(self.plan("pull_request", core=True, addon=True), BuildPlan(ADDON_PLATFORMS, test=False)) + + def test_libsimplex_of_the_pull_request_is_tested_on_its_platform(self): + self.assertEqual( + self.plan("pull_request", libs_artifact=True, core=True), BuildPlan(["linux-x86_64"], test=True) + ) + + def test_new_library_release_is_tested_on_every_platform(self): + self.assertEqual(self.plan(libs_release="core-1.0.0.2"), BuildPlan(ADDON_PLATFORMS, test=True)) + + def test_matrix_names_the_runner_of_each_platform(self): + self.assertEqual( + build_matrix(["linux-x86_64", "windows-x86_64"]), + '[{"platform": "linux-x86_64", "os": "ubuntu-22.04"}, {"platform": "windows-x86_64", "os": "windows-latest"}]', + ) + + +class ChangedSincePublishTest(unittest.TestCase): + def setUp(self): + self.repo = Path(tempfile.mkdtemp()) + self.git("init", "-q") + self.write("pkg/version.txt", 'version = "1"\n') + self.write("pkg/src.txt", "a") + self.commit("set version") + + def git(self, *args): + subprocess.run(["git", "-c", "user.name=t", "-c", "user.email=t@t", *args], cwd=self.repo, check=True) + + def write(self, file, text): + (self.repo / file).parent.mkdir(parents=True, exist_ok=True) + (self.repo / file).write_text(text) + + def commit(self, message): + self.git("add", "-A") + self.git("commit", "-q", "-m", message) + + def changed(self): + with mock.patch.object(packages_release, "ROOT", self.repo): + return changed_since_publish("pkg", "1", "pkg", "pkg/version.txt", "^version =") + + def test_compares_with_the_commit_that_set_the_version(self): + self.assertFalse(self.changed()) + self.write("pkg/src.txt", "b") + self.commit("change") + self.assertTrue(self.changed()) + + def test_compares_with_the_published_commit(self): + self.write("pkg/src.txt", "b") + self.commit("change") + self.git("tag", "pkg-v1") + self.assertFalse(self.changed()) + self.write("pkg/src.txt", "c") + self.commit("change after publish") + self.assertTrue(self.changed()) + + def test_publish_tag_marks_the_version_published(self): + with mock.patch.object(packages_release, "ROOT", self.repo): + self.assertIsNone(published_tag("pkg", "1")) + self.git("tag", "pkg-v1") + self.assertEqual(published_tag("pkg", "1"), "refs/tags/pkg-v1") + self.assertIsNone(published_tag("pkg", "2")) + + def test_changed_since_looks_only_at_the_sources(self): + base = subprocess.run( + ["git", "rev-parse", "HEAD"], cwd=self.repo, capture_output=True, text=True + ).stdout.strip() + self.write("other/file.txt", "o") + self.commit("other change") + with mock.patch.object(packages_release, "ROOT", self.repo): + self.assertFalse(changed_since(base, ["pkg"])) + self.write("pkg/src.txt", "b") + self.commit("source change") + self.assertTrue(changed_since(base, ["pkg"])) + + def test_ignores_tests_and_other_directories(self): + self.write("pkg/tests/t.txt", "t") + self.write("other/file.txt", "o") + self.commit("tests and other files") + self.assertFalse(self.changed()) + + +if __name__ == "__main__": + unittest.main()