From 66b839742a96c80f5618386248cc7a5d4b1408ed Mon Sep 17 00:00:00 2001 From: Dan Ditomaso Date: Wed, 9 Jul 2025 22:02:10 -0400 Subject: [PATCH 1/5] Add node-transport lib (#703) * feat: add node transport * updated readme * Update packages/transport-node/README.md Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --------- Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- .gitignore | 3 +- deno.json | 1 + packages/transport-node/README.md | 28 +++++++++ packages/transport-node/deno.json | 8 +++ packages/transport-node/mod.ts | 1 + packages/transport-node/src/transport.ts | 77 ++++++++++++++++++++++++ 6 files changed, 117 insertions(+), 1 deletion(-) create mode 100644 packages/transport-node/README.md create mode 100644 packages/transport-node/deno.json create mode 100644 packages/transport-node/mod.ts create mode 100644 packages/transport-node/src/transport.ts diff --git a/.gitignore b/.gitignore index 76c403ce..9a47b879 100644 --- a/.gitignore +++ b/.gitignore @@ -5,4 +5,5 @@ stats.html .vite dev-dist __screenshots__* -*.diff \ No newline at end of file +*.diff +npm/ \ No newline at end of file diff --git a/deno.json b/deno.json index 2957035a..46ddd09a 100644 --- a/deno.json +++ b/deno.json @@ -3,6 +3,7 @@ "./packages/web", "./packages/core", "./packages/transport-deno", + "./packages/transport-node", "./packages/transport-http", "./packages/transport-web-bluetooth", "./packages/transport-web-serial" diff --git a/packages/transport-node/README.md b/packages/transport-node/README.md new file mode 100644 index 00000000..b0b787f1 --- /dev/null +++ b/packages/transport-node/README.md @@ -0,0 +1,28 @@ +# @meshtastic/transport-node + +[![JSR](https://jsr.io/badges/@meshtastic/transport-node)](https://jsr.io/@meshtastic/transport-node) +[![CI](https://img.shields.io/github/actions/workflow/status/meshtastic/js/ci.yml?branch=master&label=actions&logo=github&color=yellow)](https://github.com/meshtastic/js/actions/workflows/ci.yml) +[![CLA assistant](https://cla-assistant.io/readme/badge/meshtastic/meshtastic.js)](https://cla-assistant.io/meshtastic/meshtastic.js) +[![Fiscal Contributors](https://opencollective.com/meshtastic/tiers/badge.svg?label=Fiscal%20Contributors&color=deeppink)](https://opencollective.com/meshtastic/) +[![Vercel](https://img.shields.io/static/v1?label=Powered%20by&message=Vercel&style=flat&logo=vercel&color=000000)](https://vercel.com?utm_source=meshtastic&utm_campaign=oss) + +## Overview + +`@meshtastic/transport-node` Provides TCP transport (Node) for Meshtastic +devices. Installation instructions are available at +[JSR](https://jsr.io/@meshtastic/transport-node) +[NPM](https://www.npmjs.com/package/@meshtastic/transport-node) + +## Usage + +```ts +import { MeshDevice } from "@meshtastic/core"; +import { TransportNode } from "@meshtastic/transport-node"; + +const transport = await TransportNode.create("10.10.0.57"); +const device = new MeshDevice(transport); +``` + +## Stats + +![Alt](https://repobeats.axiom.co/api/embed/5330641586e92a2ec84676fedb98f6d4a7b25d69.svg "Repobeats analytics image") diff --git a/packages/transport-node/deno.json b/packages/transport-node/deno.json new file mode 100644 index 00000000..18f531a6 --- /dev/null +++ b/packages/transport-node/deno.json @@ -0,0 +1,8 @@ +{ + "name": "@meshtastic/transport-node", + "version": "0.0.1", + "description": "NodeJS-specific transport layer for Meshtastic web applications.", + "exports": { + ".": "./mod.ts" + } +} diff --git a/packages/transport-node/mod.ts b/packages/transport-node/mod.ts new file mode 100644 index 00000000..37b6657c --- /dev/null +++ b/packages/transport-node/mod.ts @@ -0,0 +1 @@ +export { TransportNode } from "./src/transport.ts"; diff --git a/packages/transport-node/src/transport.ts b/packages/transport-node/src/transport.ts new file mode 100644 index 00000000..1f984219 --- /dev/null +++ b/packages/transport-node/src/transport.ts @@ -0,0 +1,77 @@ +import { Utils } from "@meshtastic/core"; +import type { Types } from "@meshtastic/core"; +import { Socket } from "node:net"; +import { Readable, Writable } from "node:stream"; + +export class TransportNode implements Types.Transport { + private readonly _toDevice: WritableStream; + private readonly _fromDevice: ReadableStream; + + /** + * Creates and connects a new TransportNode instance. + * @param hostname - The IP address or hostname of the Meshtastic device. + * @param port - The port number for the TCP connection (defaults to 4403). + * @returns A promise that resolves with a connected TransportNode instance. + */ + public static create(hostname: string, port = 4403): Promise { + return new Promise((resolve, reject) => { + const socket = new Socket(); + + const onError = (err: Error) => { + socket.destroy(); + reject(err); + }; + + socket.once("error", onError); + + socket.connect(port, hostname, () => { + socket.removeListener("error", onError); + resolve(new TransportNode(socket)); + }); + }); + } + + /** + * Constructs a new TransportNode. + * @param connection - An active Node.js net.Socket connection. + */ + constructor(connection: Socket) { + connection.on("error", (err) => { + console.error("Socket connection error:", err); + }); + + const fromDeviceSource = Readable.toWeb(connection) as ReadableStream< + Uint8Array + >; + this._fromDevice = fromDeviceSource.pipeThrough(Utils.fromDeviceStream()); + + // Stream for data going FROM the application TO the Meshtastic device. + const toDeviceTransform = new TransformStream(); + this._toDevice = toDeviceTransform.writable; + + // The readable end of the transform is then piped to the Node.js socket. + // A similar assertion is needed here because `Writable.toWeb` also returns + // a generically typed stream (`WritableStream`). + toDeviceTransform.readable.pipeTo( + Writable.toWeb(connection) as WritableStream, + ) + .catch((err) => { + console.error("Error piping data to socket:", err); + connection.destroy(err as Error); + }); + } + + /** + * The WritableStream to send data to the Meshtastic device. + */ + public get toDevice(): WritableStream { + return this._toDevice; + } + + /** + * The ReadableStream to receive data from the Meshtastic device. + */ + public get fromDevice(): ReadableStream { + return this._fromDevice; + } +} From 7f177f6b83380fbb5542c21ad2502ee35ebf0864 Mon Sep 17 00:00:00 2001 From: Dan Ditomaso Date: Thu, 10 Jul 2025 12:28:23 -0400 Subject: [PATCH 2/5] Add npm publishing of JS libs (#702) * feat: publish js libs to npm * changed license type * updated readme * added id * added exclusion for web and deno packages * update comment * updating env var --- .github/workflows/ci.yml | 2 +- .github/workflows/js-ci.yml | 37 ------- .github/workflows/release.yml | 120 +++++++++++++++++---- .gitignore | 3 +- packages/web/LICENSE => LICENSE | 0 README.md | 4 +- deno.json | 13 +++ deno.lock | 104 +++++++++++++++--- packages/core/README.md | 1 + packages/core/deno.json | 1 + packages/transport-deno/README.md | 1 + packages/transport-deno/deno.json | 1 + packages/transport-http/README.md | 1 + packages/transport-http/deno.json | 1 + packages/transport-web-bluetooth/README.md | 1 + packages/transport-web-bluetooth/deno.json | 1 + packages/transport-web-serial/README.md | 1 + packages/transport-web-serial/deno.json | 1 + packages/web/deno.json | 1 - scripts/build_npm_package.ts | 101 +++++++++++++++++ 20 files changed, 317 insertions(+), 78 deletions(-) delete mode 100644 .github/workflows/js-ci.yml rename packages/web/LICENSE => LICENSE (100%) create mode 100644 scripts/build_npm_package.ts diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 3629d269..507c260a 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -41,4 +41,4 @@ jobs: run: deno fmt --check - name: Build Package - run: deno task build + run: deno task --filter web build diff --git a/.github/workflows/js-ci.yml b/.github/workflows/js-ci.yml deleted file mode 100644 index e03b29e7..00000000 --- a/.github/workflows/js-ci.yml +++ /dev/null @@ -1,37 +0,0 @@ -name: Pull Request - -on: - push: - paths: - - "packages/core" - - "packages/transport-deno" - - "packages/transport-http" - - "packages/transport-web-bluetooth" - - "packages/transport-web-serial" - branches: - - main - -jobs: - build: - runs-on: ubuntu-latest - - permissions: - contents: read - id-token: write - - steps: - - name: Checkout code - uses: actions/checkout@v4 - - - uses: denoland/setup-deno@v2 - with: - deno-version: v2.x - - - name: Check formatting - run: deno fmt --check - - - name: Check types - run: deno lint - - - name: Publish to JSR - run: npx jsr publish diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 887dc64c..509c7c84 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -5,65 +5,140 @@ on: types: [released, prereleased] permissions: + id-token: write # This is required for requesting the JWT contents: write packages: write jobs: - build-and-package: + build-and-publish: runs-on: ubuntu-latest steps: - - name: Checkout + # --- Checkout code --- + - name: Checkout Code uses: actions/checkout@v4 + with: + fetch-depth: 0 + # --- Setup Deno --- - name: Setup Deno uses: denoland/setup-deno@v2 with: deno-version: v2.x - - name: Install Dependencies - working-directory: packages/web - run: deno install + - name: Cache Deno Dependencies + uses: actions/cache@v4 + with: + path: ~/.cache/deno + key: ${{ runner.os }}-deno-${{ hashFiles('**/deno.lock') }} + restore-keys: | + ${{ runner.os }}-deno- - - name: Run tests - working-directory: packages/web - run: deno task test + # --- Determine Changed Packages --- + - name: Get Changed Package Directories + id: changed_packages + uses: tj-actions/changed-files@v44 + with: + dir_names: true + files: packages/** + files_ignore: "packages/web/**,packages/transport-deno/npm/**" + + # --- Setup Node for NPM Publishing --- + - name: Setup Node.js + if: steps.changed_packages.outputs.all_changed_and_modified_files != '' + uses: actions/setup-node@v4 + with: + node-version: 22 + registry-url: "https://registry.npmjs.org" + + - name: Verify NPM Authentication + if: steps.changed_packages.outputs.all_changed_and_modified_files != '' + run: npm whoami + env: + NPM_TOKEN: ${{ secrets.NPM_TOKEN }} + + # --- Build and Publish Packages to NPM & JSR --- + - name: Build and Publish Changed Packages + if: steps.changed_packages.outputs.all_changed_and_modified_files != '' + run: | + set -euo pipefail - - name: Build Package + excluded=("packages/web packages/transport-deno") + + for pkg_dir in ${{ steps.changed_packages.outputs.all_changed_and_modified_files }}; do + + echo "Building for NPM..." + deno task build:npm "$pkg_dir" + + echo "Publishing to NPM..." + npm publish "$pkg_dir/npm" --access public + + echo "Publishing to JSR..." + # We run this in a subshell to change directory just for this command. + # --allow-dirty is necessary because the 'npm' build directory is untracked. + (cd "$pkg_dir" && deno publish --allow-dirty) + done + env: + NPM_TOKEN: ${{ secrets.NPM_TOKEN }} + + - name: No Packages Changed + if: steps.changed_packages.outputs.all_changed_and_modified_files == '' + run: echo "No changed packages detected. Skipping publish." + + # --- Web Package Specific Tasks --- + - name: Check for Web Package Changes + id: web_changes + + run: | + if [[ "${{ steps.changed_packages.outputs.all_changed_and_modified_files }}" == *"packages/web"* ]]; then + echo "web_changed=true" >> $GITHUB_OUTPUT + else + echo "web_changed=false" >> $GITHUB_OUTPUT + fi + + - name: Run Web App Tests + if: steps.web_changes.outputs.web_changed == 'true' working-directory: packages/web - run: deno task build + run: deno task test - - name: Package Output + - name: Create Web App Release Archive + if: steps.web_changes.outputs.web_changed == 'true' working-directory: packages/web - run: deno task package + run: deno task package # Generates dist/build.tar - - name: Archive compressed build + - name: Upload Web App Archive + if: steps.web_changes.outputs.web_changed == 'true' uses: actions/upload-artifact@v4 with: - name: build + name: web-build + if-no-files-found: error path: packages/web/dist/build.tar - - name: Attach build.tar to release - run: | - gh release upload ${{ github.event.release.tag_name }} packages/web/dist/build.tar + - name: Attach Web Archive to GitHub Release + if: steps.web_changes.outputs.web_changed == 'true' + run: gh release upload ${{ github.event.release.tag_name }} packages/web/dist/build.tar env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + # --- Build & Push Container Image --- - name: Set up QEMU + if: steps.web_changes.outputs.web_changed == 'true' uses: docker/setup-qemu-action@v3 - - name: Buildah Build + - name: Build Container Image + if: steps.web_changes.outputs.web_changed == 'true' id: build-container uses: redhat-actions/buildah-build@v2 with: containerfiles: | ./infra/Containerfile - image: ${{github.event.repository.full_name}} - tags: latest ${{ github.sha }} + image: ghcr.io/${{ github.repository }} + tags: latest, ${{ github.event.release.tag_name }} oci: true platforms: linux/amd64, linux/arm64 - - name: Push To Registry + - name: Push Container to GHCR id: push-to-registry + if: steps.web_changes.outputs.web_changed == 'true' uses: redhat-actions/push-to-registry@v2 with: image: ${{ steps.build-container.outputs.image }} @@ -72,5 +147,6 @@ jobs: username: ${{ github.actor }} password: ${{ secrets.GITHUB_TOKEN }} - - name: Print image url + - name: Output Image URL + if: steps.web_changes.outputs.web_changed == 'true' run: echo "Image pushed to ${{ steps.push-to-registry.outputs.registry-paths }}" diff --git a/.gitignore b/.gitignore index 9a47b879..8d28e352 100644 --- a/.gitignore +++ b/.gitignore @@ -6,4 +6,5 @@ stats.html dev-dist __screenshots__* *.diff -npm/ \ No newline at end of file +npm/ + diff --git a/packages/web/LICENSE b/LICENSE similarity index 100% rename from packages/web/LICENSE rename to LICENSE diff --git a/README.md b/README.md index 442a7642..d895496a 100644 --- a/README.md +++ b/README.md @@ -25,8 +25,8 @@ All projects are located within the `packages/` directory: - **`packages/transport-web-bluetooth`:** Web Bluetooth Transport. - **`packages/transport-web-serial`:** Web Serial Transport. -All `Meshtastic JS` packages (core and transports) are published to -[JSR](https://jsr.io/@meshtastic). +All `Meshtastic JS` packages (core and transports) are published both to +[JSR](https://jsr.io/@meshtastic). [NPM](https://www.npmjs.com/org/meshtastic) --- diff --git a/deno.json b/deno.json index 46ddd09a..71604d00 100644 --- a/deno.json +++ b/deno.json @@ -8,8 +8,12 @@ "./packages/transport-web-bluetooth", "./packages/transport-web-serial" ], + "tasks": { + "build:npm": "deno run -A scripts/build_npm_package.ts" + }, "imports": { "@bufbuild/protobuf": "npm:@bufbuild/protobuf@^2.2.3", + "@deno/dnt": "jsr:@deno/dnt@^0.42.1", "@meshtastic/protobufs": "jsr:@meshtastic/protobufs@^2.7.0", "@types/node": "npm:@types/node@^22.13.10", "ste-simple-events": "npm:ste-simple-events@^3.0.11", @@ -33,5 +37,14 @@ }, "unstable": [ "sloppy-imports" + ], + "exclude": [ + "node_modules", + "dist", + "npm", + "build", + "coverage", + "out", + ".vscode-test" ] } diff --git a/deno.lock b/deno.lock index c80a48bc..cddc5bc6 100644 --- a/deno.lock +++ b/deno.lock @@ -1,9 +1,25 @@ { "version": "5", "specifiers": { + "jsr:@david/code-block-writer@^13.0.2": "13.0.3", + "jsr:@deno/cache-dir@0.20": "0.20.1", + "jsr:@deno/dnt@~0.42.1": "0.42.1", + "jsr:@deno/graph@0.86": "0.86.9", "jsr:@meshtastic/protobufs@^2.7.0": "2.7.0", + "jsr:@std/bytes@^1.0.5": "1.0.6", + "jsr:@std/fmt@1": "1.0.8", + "jsr:@std/fmt@^1.0.3": "1.0.8", + "jsr:@std/fs@1": "1.0.19", + "jsr:@std/fs@^1.0.6": "1.0.19", "jsr:@std/internal@^1.0.9": "1.0.9", + "jsr:@std/io@0.225": "0.225.2", + "jsr:@std/path@*": "1.1.1", + "jsr:@std/path@1": "1.1.1", + "jsr:@std/path@^1.0.8": "1.1.1", "jsr:@std/path@^1.1.0": "1.1.1", + "jsr:@std/path@^1.1.1": "1.1.1", + "jsr:@ts-morph/bootstrap@0.25": "0.25.0", + "jsr:@ts-morph/common@0.25": "0.25.0", "npm:@bufbuild/protobuf@^2.2.3": "2.6.0", "npm:@bufbuild/protobuf@^2.6.0": "2.6.0", "npm:@hookform/resolvers@^5.1.1": "5.1.1_react-hook-form@7.60.0__react@19.1.0_react@19.1.0", @@ -30,11 +46,11 @@ "npm:@radix-ui/react-toggle-group@^1.1.10": "1.1.10_@types+react@19.1.8_@types+react-dom@19.1.6__@types+react@19.1.8_react@19.1.0_react-dom@19.1.0__react@19.1.0", "npm:@radix-ui/react-tooltip@^1.2.7": "1.2.7_@types+react@19.1.8_@types+react-dom@19.1.6__@types+react@19.1.8_react@19.1.0_react-dom@19.1.0__react@19.1.0", "npm:@tailwindcss/postcss@^4.1.11": "4.1.11", - "npm:@tanstack/react-router-devtools@^1.125.4": "1.125.4_@tanstack+react-router@1.125.4__react@19.1.0__react-dom@19.1.0___react@19.1.0_react@19.1.0_react-dom@19.1.0__react@19.1.0", - "npm:@tanstack/react-router@^1.125.4": "1.125.4_react@19.1.0_react-dom@19.1.0__react@19.1.0", + "npm:@tanstack/react-router-devtools@^1.125.4": "1.125.6_@tanstack+react-router@1.125.6__react@19.1.0__react-dom@19.1.0___react@19.1.0_react@19.1.0_react-dom@19.1.0__react@19.1.0", + "npm:@tanstack/react-router@^1.125.4": "1.125.6_react@19.1.0_react-dom@19.1.0__react@19.1.0", "npm:@tanstack/router-cli@^1.125.4": "1.125.4", - "npm:@tanstack/router-devtools@^1.125.4": "1.125.4_@tanstack+react-router@1.125.4__react@19.1.0__react-dom@19.1.0___react@19.1.0_react@19.1.0_react-dom@19.1.0__react@19.1.0", - "npm:@tanstack/router-plugin@^1.125.5": "1.125.5_@tanstack+react-router@1.125.4__react@19.1.0__react-dom@19.1.0___react@19.1.0_vite@7.0.2__@types+node@22.16.0__picomatch@4.0.2_@babel+core@7.28.0_react@19.1.0_react-dom@19.1.0__react@19.1.0_@types+node@22.16.0", + "npm:@tanstack/router-devtools@^1.125.4": "1.125.6_@tanstack+react-router@1.125.6__react@19.1.0__react-dom@19.1.0___react@19.1.0_react@19.1.0_react-dom@19.1.0__react@19.1.0", + "npm:@tanstack/router-plugin@^1.125.5": "1.125.6_@tanstack+react-router@1.125.6__react@19.1.0__react-dom@19.1.0___react@19.1.0_vite@7.0.2__@types+node@22.16.0__picomatch@4.0.2_@babel+core@7.28.0_react@19.1.0_react-dom@19.1.0__react@19.1.0_@types+node@22.16.0", "npm:@testing-library/jest-dom@^6.6.3": "6.6.3", "npm:@testing-library/react@^16.3.0": "16.3.0_@testing-library+dom@10.4.0_@types+react@19.1.8_@types+react-dom@19.1.6__@types+react@19.1.8_react@19.1.0_react-dom@19.1.0__react@19.1.0", "npm:@testing-library/user-event@^14.6.1": "14.6.1_@testing-library+dom@10.4.0", @@ -94,20 +110,79 @@ "npm:zustand@5.0.6": "5.0.6_@types+react@19.1.8_immer@10.1.1_react@19.1.0" }, "jsr": { + "@david/code-block-writer@13.0.3": { + "integrity": "f98c77d320f5957899a61bfb7a9bead7c6d83ad1515daee92dbacc861e13bb7f" + }, + "@deno/cache-dir@0.20.1": { + "integrity": "dc4f3add14307f3ff3b712441ea4acabcbfc9a13f67c5adc78c3aac16ac5e2a0", + "dependencies": [ + "jsr:@deno/graph", + "jsr:@std/fmt@^1.0.3", + "jsr:@std/fs@^1.0.6", + "jsr:@std/io", + "jsr:@std/path@^1.0.8" + ] + }, + "@deno/dnt@0.42.1": { + "integrity": "85322b38eb40d4e8c5216d62536152c35b1bda9dc47c8c60860610397b960223", + "dependencies": [ + "jsr:@david/code-block-writer", + "jsr:@deno/cache-dir", + "jsr:@std/fmt@1", + "jsr:@std/fs@1", + "jsr:@std/path@1", + "jsr:@ts-morph/bootstrap" + ] + }, + "@deno/graph@0.86.9": { + "integrity": "c4f353a695bcc5246c099602977dabc6534eacea9999a35a8cb24e807192e6a1" + }, "@meshtastic/protobufs@2.7.0": { "integrity": "38357241bd8a7431c87366dbe12ce9e69f204ebb6ec23da12f7682765b6c8376", "dependencies": [ "npm:@bufbuild/protobuf@^2.2.3" ] }, + "@std/bytes@1.0.6": { + "integrity": "f6ac6adbd8ccd99314045f5703e23af0a68d7f7e58364b47d2c7f408aeb5820a" + }, + "@std/fmt@1.0.8": { + "integrity": "71e1fc498787e4434d213647a6e43e794af4fd393ef8f52062246e06f7e372b7" + }, + "@std/fs@1.0.19": { + "integrity": "051968c2b1eae4d2ea9f79a08a3845740ef6af10356aff43d3e2ef11ed09fb06", + "dependencies": [ + "jsr:@std/internal", + "jsr:@std/path@^1.1.1" + ] + }, "@std/internal@1.0.9": { "integrity": "bdfb97f83e4db7a13e8faab26fb1958d1b80cc64366501af78a0aee151696eb8" }, + "@std/io@0.225.2": { + "integrity": "3c740cd4ee4c082e6cfc86458f47e2ab7cb353dc6234d5e9b1f91a2de5f4d6c7", + "dependencies": [ + "jsr:@std/bytes" + ] + }, "@std/path@1.1.1": { "integrity": "fe00026bd3a7e6a27f73709b83c607798be40e20c81dde655ce34052fd82ec76", "dependencies": [ "jsr:@std/internal" ] + }, + "@ts-morph/bootstrap@0.25.0": { + "integrity": "3cd33ee80ac0aab8e5d2660c639a02187f0c8abfe454636ce86c00eb7e8407db", + "dependencies": [ + "jsr:@ts-morph/common" + ] + }, + "@ts-morph/common@0.25.0": { + "integrity": "e3ed1771e2fb61fbc3d2cb39ebbc4f89cd686d6d9bc6d91a71372be055ac1967", + "dependencies": [ + "jsr:@std/fs@1", + "jsr:@std/path@1" + ] } }, "npm": { @@ -1681,8 +1756,8 @@ "@tanstack/history@1.121.34": { "integrity": "sha512-YL8dGi5ZU+xvtav2boRlw4zrRghkY6hvdcmHhA0RGSJ/CBgzv+cbADW9eYJLx74XMZvIQ1pp6VMbrpXnnM5gHA==" }, - "@tanstack/react-router-devtools@1.125.4_@tanstack+react-router@1.125.4__react@19.1.0__react-dom@19.1.0___react@19.1.0_react@19.1.0_react-dom@19.1.0__react@19.1.0": { - "integrity": "sha512-eRg6Ht6bLno+Eo2Gps3cfjp++Mvy0brc6qs81At4beKZsCkS7jlmqHgrq6pXdH3l2bAQ/Dhr8J9zXvzxvRxf/g==", + "@tanstack/react-router-devtools@1.125.6_@tanstack+react-router@1.125.6__react@19.1.0__react-dom@19.1.0___react@19.1.0_react@19.1.0_react-dom@19.1.0__react@19.1.0": { + "integrity": "sha512-3+Np/HPQ1jpdai58xY6fScnjJz08iIG6aKhCVVy8tfPC76jTyXc+ygqM9wtUt3kJYy9/Lf7j3dAJlbtxF+TIXg==", "dependencies": [ "@tanstack/react-router", "@tanstack/router-devtools-core", @@ -1690,8 +1765,8 @@ "react-dom" ] }, - "@tanstack/react-router@1.125.4_react@19.1.0_react-dom@19.1.0__react@19.1.0": { - "integrity": "sha512-IIlDiFCR+RN/17dWtYKMYiJvH38tmtgTyciHkeZvr8vSWhThAeuwjzKneF4mQl3B//YHYcrFm7rZM59c24As/A==", + "@tanstack/react-router@1.125.6_react@19.1.0_react-dom@19.1.0__react@19.1.0": { + "integrity": "sha512-znyUGTq+WRhXwToNTYiluUBLjMdQVxz+ZQ9Ep2PBuS9O+3Qm3kaM7n64hA84ISoCtLqMwTo7Ofw0W4WeLdjpYg==", "dependencies": [ "@tanstack/history", "@tanstack/react-store", @@ -1743,8 +1818,8 @@ "tiny-invariant" ] }, - "@tanstack/router-devtools@1.125.4_@tanstack+react-router@1.125.4__react@19.1.0__react-dom@19.1.0___react@19.1.0_react@19.1.0_react-dom@19.1.0__react@19.1.0": { - "integrity": "sha512-VpCxC80C+t+HNt8xuRGoXDwV+0c1LFDNzOdL/dPlYSuTZWLd8zwOEi49oMaNSLeGJFLwgQx7FLP020UvQO6pMg==", + "@tanstack/router-devtools@1.125.6_@tanstack+react-router@1.125.6__react@19.1.0__react-dom@19.1.0___react@19.1.0_react@19.1.0_react-dom@19.1.0__react@19.1.0": { + "integrity": "sha512-Orlf0+5wANehVq5ocxfe3YyJnEtvc5tcNN/ldX20fOKyYeIhVQzR8Oa6RO3Jk2sY31grMwi6q199MTAJ7XshLQ==", "dependencies": [ "@tanstack/react-router", "@tanstack/react-router-devtools", @@ -1767,8 +1842,8 @@ "zod" ] }, - "@tanstack/router-plugin@1.125.5_@tanstack+react-router@1.125.4__react@19.1.0__react-dom@19.1.0___react@19.1.0_vite@7.0.2__@types+node@22.16.0__picomatch@4.0.2_@babel+core@7.28.0_react@19.1.0_react-dom@19.1.0__react@19.1.0_@types+node@22.16.0": { - "integrity": "sha512-blik4+pgwv0PEGYTtkVy6USQBwbPVex1WOBBfRudOaBf8vnGe7lBoEHZi6fora8QlmdTsEVhqX6IHDXuf3pWaQ==", + "@tanstack/router-plugin@1.125.6_@tanstack+react-router@1.125.6__react@19.1.0__react-dom@19.1.0___react@19.1.0_vite@7.0.2__@types+node@22.16.0__picomatch@4.0.2_@babel+core@7.28.0_react@19.1.0_react-dom@19.1.0__react@19.1.0_@types+node@22.16.0": { + "integrity": "sha512-SWfp++tkjb0grVqa/Xdvi9QAs93e9/fZMBZ6q0fvvQruMyciCmjWyE/qV3tS/Qh0WZdzIRP6yl8Gha2Lin4q1w==", "dependencies": [ "@babel/core", "@babel/plugin-syntax-jsx", @@ -3742,8 +3817,8 @@ "earcut@3.0.1": { "integrity": "sha512-0l1/0gOjESMeQyYaK5IDiPNvFeu93Z/cO0TjZh9eZ1vyCtZnA7KMZ8rQggpsJHIbGSdrqYq9OhuveadOVHCshw==" }, - "electron-to-chromium@1.5.179": { - "integrity": "sha512-UWKi/EbBopgfFsc5k61wFpV7WrnnSlSzW/e2XcBmS6qKYTivZlLtoll5/rdqRTxGglGHkmkW0j0pFNJG10EUIQ==" + "electron-to-chromium@1.5.180": { + "integrity": "sha512-ED+GEyEh3kYMwt2faNmgMB0b8O5qtATGgR4RmRsIp4T6p7B8vdMbIedYndnvZfsaXvSzegtpfqRMDNCjjiSduA==" }, "emoji-regex@8.0.0": { "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==" @@ -5105,6 +5180,7 @@ }, "workspace": { "dependencies": [ + "jsr:@deno/dnt@~0.42.1", "jsr:@meshtastic/protobufs@^2.7.0", "npm:@bufbuild/protobuf@^2.2.3", "npm:@types/node@^22.13.10", diff --git a/packages/core/README.md b/packages/core/README.md index 18ce7cf7..cca9d9d1 100644 --- a/packages/core/README.md +++ b/packages/core/README.md @@ -11,6 +11,7 @@ `@meshtastic/core` Provides core functionality for interfacing with Meshtastic devices. Installation instructions are available at [JSR](https://jsr.io/@meshtastic/core) +[NPM](https://www.npmjs.com/org/meshtastic) ## Usage diff --git a/packages/core/deno.json b/packages/core/deno.json index fb29ac95..624babd1 100644 --- a/packages/core/deno.json +++ b/packages/core/deno.json @@ -1,6 +1,7 @@ { "name": "@meshtastic/core", "version": "2.6.4", + "description": "Core functionalities for Meshtastic web applications.", "exports": { ".": "./mod.ts" }, diff --git a/packages/transport-deno/README.md b/packages/transport-deno/README.md index 1c5213f8..1193c476 100644 --- a/packages/transport-deno/README.md +++ b/packages/transport-deno/README.md @@ -11,6 +11,7 @@ `@meshtastic/transport-deno` Provides TCP transport (Deno) for Meshtastic devices. Installation instructions are avaliable at [JSR](https://jsr.io/@meshtastic/transport-deno) +[NPM](https://www.npmjs.com/org/meshtastic/transport-deno) ## Usage diff --git a/packages/transport-deno/deno.json b/packages/transport-deno/deno.json index 50968ed6..39a9178a 100644 --- a/packages/transport-deno/deno.json +++ b/packages/transport-deno/deno.json @@ -1,6 +1,7 @@ { "name": "@meshtastic/transport-deno", "version": "0.1.1", + "description": "Deno-specific transport layer for Meshtastic web applications.", "exports": { ".": "./mod.ts" } diff --git a/packages/transport-http/README.md b/packages/transport-http/README.md index 560b44b4..6fc0e854 100644 --- a/packages/transport-http/README.md +++ b/packages/transport-http/README.md @@ -11,6 +11,7 @@ `@meshtastic/transport-http` Provides HTTP(S) transport for Meshtastic devices. Installation instructions are available at [JSR](https://jsr.io/@meshtastic/transport-http) +[NPM](https://www.npmjs.com/package/@meshtastic/transport-http) ## Usage diff --git a/packages/transport-http/deno.json b/packages/transport-http/deno.json index 1bcb8348..0c3bf171 100644 --- a/packages/transport-http/deno.json +++ b/packages/transport-http/deno.json @@ -1,6 +1,7 @@ { "name": "@meshtastic/transport-http", "version": "0.2.1", + "description": "A transport layer for Meshtastic applications using HTTP.", "exports": { ".": "./mod.ts" } diff --git a/packages/transport-web-bluetooth/README.md b/packages/transport-web-bluetooth/README.md index ab1af911..fc790284 100644 --- a/packages/transport-web-bluetooth/README.md +++ b/packages/transport-web-bluetooth/README.md @@ -11,6 +11,7 @@ `@meshtastic/transport-web-bluetooth` Provides Web Bluetooth transport for Meshtastic devices. Installation instructions are available at [JSR](https://jsr.io/@meshtastic/transport-web-bluetooth) +[NPM](https://www.npmjs.com/org/meshtastic/transport-web-bluetooth) ## Usage diff --git a/packages/transport-web-bluetooth/deno.json b/packages/transport-web-bluetooth/deno.json index 6aab4b7b..169ad310 100644 --- a/packages/transport-web-bluetooth/deno.json +++ b/packages/transport-web-bluetooth/deno.json @@ -1,6 +1,7 @@ { "name": "@meshtastic/transport-web-bluetooth", "version": "0.1.2", + "description": "A transport layer for Meshtastic applications using Web Bluetooth.", "exports": { ".": "./mod.ts" }, diff --git a/packages/transport-web-serial/README.md b/packages/transport-web-serial/README.md index 81c08b99..7022a649 100644 --- a/packages/transport-web-serial/README.md +++ b/packages/transport-web-serial/README.md @@ -11,6 +11,7 @@ `@meshtastic/transport-web-serial` Provides Web Serial transport for Meshtastic devices. Installation instructions are avaliable at [JSR](https://jsr.io/@meshtastic/transport-web-serial) +[NPM](https://www.npmjs.com/package/@meshtastic/transport-web-serial) ## Usage diff --git a/packages/transport-web-serial/deno.json b/packages/transport-web-serial/deno.json index 45a90622..c6f3ead1 100644 --- a/packages/transport-web-serial/deno.json +++ b/packages/transport-web-serial/deno.json @@ -1,6 +1,7 @@ { "name": "@meshtastic/transport-web-serial", "version": "0.2.1", + "description": "A transport layer for Meshtastic applications using Web Serial API.", "exports": { ".": "./mod.ts" }, diff --git a/packages/web/deno.json b/packages/web/deno.json index a3da671b..21f97a4a 100644 --- a/packages/web/deno.json +++ b/packages/web/deno.json @@ -45,7 +45,6 @@ ], "strictPropertyInitialization": false }, - "exclude": [ "routeTree.gen.ts", "node_modules/", diff --git a/scripts/build_npm_package.ts b/scripts/build_npm_package.ts new file mode 100644 index 00000000..49b7a7aa --- /dev/null +++ b/scripts/build_npm_package.ts @@ -0,0 +1,101 @@ +import { build, emptyDir } from "@deno/dnt"; +import { join } from "jsr:@std/path@1/join"; + +interface DenoJsonConfig { + name: string; + version: string; + description: string; + imports?: Record; + exports?: Record; +} + +async function getJson(filePath: string) { + try { + return JSON.parse(await Deno.readTextFile(filePath)); + } catch (e) { + if (e instanceof Error) { + throw new Error(`Error reading or parsing ${filePath}: ${e.message}`); + } + } +} + +if (Deno.args.length !== 1) { + console.error("Usage: deno task build:npm "); + console.error( + "Example: deno task build:npm packages/core", + ); + Deno.exit(1); +} + +const packagePath = Deno.args[0]; +const denoJsonPath = join(packagePath, "deno.json"); +const outDir = join(packagePath, "npm"); + +// Read the deno.json file to get the package metadata. +let jsonContent: DenoJsonConfig; + +try { + jsonContent = await getJson(denoJsonPath); +} catch (error) { + console.log(`Error reading or parsing ${denoJsonPath}:`, error); + + if (error instanceof Deno.errors.NotFound) { + console.error(`Error: Config file not found at ${denoJsonPath}`); + } else { + console.error(`Error reading or parsing ${denoJsonPath}:`, error); + } + Deno.exit(1); +} + +const { name, version, description } = jsonContent; + +if (!name || !version || !description) { + console.error( + `Error: 'name', 'version', and 'description' must be defined in ${denoJsonPath}`, + ); + Deno.exit(1); +} + +console.log(`Building ${name}@${version} from ${packagePath}...`); + +// Clean the output directory before building. +await emptyDir(outDir); + +try { + await build({ + entryPoints: [join(packagePath, "mod.ts")], + outDir, + test: false, + shims: { + deno: true, + }, + package: { + name, + version, + description, + license: "GPL-3.0-only", + repository: { + type: "git", + url: "git+https://github.com/meshtastic/web.git", + }, + bugs: { + url: "https://github.com/meshtastic/web/issues", + }, + }, + compilerOptions: { + lib: ["DOM", "ESNext"], + }, + postBuild() { + Deno.copyFileSync("LICENSE", join(outDir, "LICENSE")); + Deno.copyFileSync( + join(packagePath, "README.md"), + join(outDir, "README.md"), + ); + }, + }); +} catch (error) { + console.error(`Error building ${name}@${version}:`, error); + Deno.exit(1); +} + +console.log(`✅ Successfully built ${name}@${version} to ${outDir}`); From f010365151974a59cb80cf1f80083ce63f5fd39f Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Thu, 10 Jul 2025 15:29:08 -0400 Subject: [PATCH 3/5] chore(deps): bump tj-actions/changed-files in /.github/workflows (#705) Bumps [tj-actions/changed-files](https://github.com/tj-actions/changed-files) from 44 to 46. - [Release notes](https://github.com/tj-actions/changed-files/releases) - [Changelog](https://github.com/tj-actions/changed-files/blob/main/HISTORY.md) - [Commits](https://github.com/tj-actions/changed-files/compare/v44...v46) --- updated-dependencies: - dependency-name: tj-actions/changed-files dependency-version: '46' dependency-type: direct:production ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- .github/workflows/release.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 509c7c84..4b4c4e04 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -36,7 +36,7 @@ jobs: # --- Determine Changed Packages --- - name: Get Changed Package Directories id: changed_packages - uses: tj-actions/changed-files@v44 + uses: tj-actions/changed-files@v46 with: dir_names: true files: packages/** From 5efa1d179582e5380ed5d1a5cf00581b26718f9d Mon Sep 17 00:00:00 2001 From: Dan Ditomaso Date: Fri, 11 Jul 2025 08:23:47 -0400 Subject: [PATCH 4/5] Prevented i18n package from escaping text (#704) * fix: prevented i18n package from escaping text * Update packages/web/vite.config.ts Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> * fix: stupid copilot --------- Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- packages/web/index.html | 12 +++++++----- packages/web/public/i18n/locales/en/dialog.json | 2 +- .../src/components/Dialog/LocationResponseDialog.tsx | 1 + .../Dialog/NodeDetailsDialog/NodeDetailsDialog.tsx | 1 + .../web/src/components/Dialog/PKIBackupDialog.tsx | 3 +++ .../Dialog/RefreshKeysDialog/RefreshKeysDialog.tsx | 3 ++- packages/web/src/core/dto/NodeNumToNodeInfoDTO.ts | 7 ++----- packages/web/src/pages/Messages.tsx | 1 + packages/web/vite.config.ts | 4 ++++ 9 files changed, 22 insertions(+), 12 deletions(-) diff --git a/packages/web/index.html b/packages/web/index.html index f21e342e..5c807b35 100644 --- a/packages/web/index.html +++ b/packages/web/index.html @@ -8,16 +8,18 @@ - + - + Meshtastic Web diff --git a/packages/web/public/i18n/locales/en/dialog.json b/packages/web/public/i18n/locales/en/dialog.json index 9b06b33a..56fb372b 100644 --- a/packages/web/public/i18n/locales/en/dialog.json +++ b/packages/web/public/i18n/locales/en/dialog.json @@ -178,6 +178,6 @@ "managedMode": { "confirmUnderstanding": "Yes, I know what I'm doing", "title": "Are you sure?", - "description": "Enabling Managed Mode blocks client applications (including the web client) from writing configurations to a radio. Once enabled, radio configurations can only be changed through Remote Admin messages. This setting is not required for remote node administration." + "description": "Enabling Managed Mode blocks client applications (including the web client) from writing configurations to a radio. Once enabled, radio configurations can only be changed through Remote Admin messages. This setting is not required for remote node administration." } } diff --git a/packages/web/src/components/Dialog/LocationResponseDialog.tsx b/packages/web/src/components/Dialog/LocationResponseDialog.tsx index eb6d5e25..93b31f57 100644 --- a/packages/web/src/components/Dialog/LocationResponseDialog.tsx +++ b/packages/web/src/components/Dialog/LocationResponseDialog.tsx @@ -47,6 +47,7 @@ export const LocationResponseDialog = ({ {t("locationResponse.title", { + interpolation: { escapeValue: false }, identifier: `${longName} (${shortName})`, })} diff --git a/packages/web/src/components/Dialog/NodeDetailsDialog/NodeDetailsDialog.tsx b/packages/web/src/components/Dialog/NodeDetailsDialog/NodeDetailsDialog.tsx index 356ab3b1..b9e3caad 100644 --- a/packages/web/src/components/Dialog/NodeDetailsDialog/NodeDetailsDialog.tsx +++ b/packages/web/src/components/Dialog/NodeDetailsDialog/NodeDetailsDialog.tsx @@ -169,6 +169,7 @@ export const NodeDetailsDialog = ({ {t("nodeDetails.title", { + interpolation: { escapeValue: false }, identifier: `${node.user?.longName ?? t("unknown.shortName")} (${ node.user?.shortName ?? t("unknown.shortName") })`, diff --git a/packages/web/src/components/Dialog/PKIBackupDialog.tsx b/packages/web/src/components/Dialog/PKIBackupDialog.tsx index 189d5053..51ee96a2 100644 --- a/packages/web/src/components/Dialog/PKIBackupDialog.tsx +++ b/packages/web/src/components/Dialog/PKIBackupDialog.tsx @@ -50,6 +50,7 @@ export const PkiBackupDialog = ({ ${ t("pkiBackup.header", { + interpolation: { escapeValue: false }, shortName: getMyNode()?.user?.shortName ?? t("unknown.shortName"), longName: getMyNode()?.user?.longName ?? t("unknown.longName"), }) @@ -63,6 +64,7 @@ export const PkiBackupDialog = ({ <body> <h1>${ t("pkiBackup.header", { + interpolation: { escapeValue: false }, shortName: getMyNode()?.user?.shortName ?? t("unknown.shortName"), longName: getMyNode()?.user?.longName ?? t("unknown.longName"), }) @@ -103,6 +105,7 @@ export const PkiBackupDialog = ({ const link = document.createElement("a"); link.href = url; link.download = t("pkiBackup.fileName", { + interpolation: { escapeValue: false }, shortName: getMyNode()?.user?.shortName ?? t("unknown.shortName"), longName: getMyNode()?.user?.longName ?? t("unknown.longName"), }); diff --git a/packages/web/src/components/Dialog/RefreshKeysDialog/RefreshKeysDialog.tsx b/packages/web/src/components/Dialog/RefreshKeysDialog/RefreshKeysDialog.tsx index 07d4350d..9d5dd5f5 100644 --- a/packages/web/src/components/Dialog/RefreshKeysDialog/RefreshKeysDialog.tsx +++ b/packages/web/src/components/Dialog/RefreshKeysDialog/RefreshKeysDialog.tsx @@ -10,7 +10,7 @@ import { LockKeyholeOpenIcon } from "lucide-react"; import { useRefreshKeysDialog } from "./useRefreshKeysDialog.ts"; import { useDevice } from "@core/stores/deviceStore.ts"; import { useTranslation } from "react-i18next"; -import { useMessageStore } from "../../../core/stores/messageStore/index.ts"; +import { useMessageStore } from "@core/stores/messageStore/index.ts"; export interface RefreshKeysDialogProps { open: boolean; @@ -35,6 +35,7 @@ export const RefreshKeysDialog = ( const text = { title: t("refreshKeys.title", { + interpolation: { escapeValue: false }, identifier: nodeWithError?.user?.longName ?? "", }), description: `${t("refreshKeys.description.unableToSendDmPrefix")}${ diff --git a/packages/web/src/core/dto/NodeNumToNodeInfoDTO.ts b/packages/web/src/core/dto/NodeNumToNodeInfoDTO.ts index af013633..5902fe4b 100644 --- a/packages/web/src/core/dto/NodeNumToNodeInfoDTO.ts +++ b/packages/web/src/core/dto/NodeNumToNodeInfoDTO.ts @@ -8,14 +8,11 @@ class NodeInfoFactory { const last4 = userIdHex.slice(-4); const longName = `Meshtastic ${last4}`; const shortName = last4; - const hwModel = Protobuf.Mesh.HardwareModel.UNSET; return create(Protobuf.Mesh.UserSchema, { id: userId, - longName: longName, - shortName: shortName, - hwModel: hwModel, - isLicensed: false, + longName: longName.toString(), + shortName: shortName.toString(), }); } diff --git a/packages/web/src/pages/Messages.tsx b/packages/web/src/pages/Messages.tsx index ccea0e0a..2059be3e 100644 --- a/packages/web/src/pages/Messages.tsx +++ b/packages/web/src/pages/Messages.tsx @@ -308,6 +308,7 @@ export const MessagesPage = () => { <PageLayout label={`${ t("page.title", { + interpolation: { escapeValue: false }, chatName: isBroadcast && currentChannel ? getChannelName(currentChannel) : isDirect && otherNode diff --git a/packages/web/vite.config.ts b/packages/web/vite.config.ts index 7bef6349..c0e8d375 100644 --- a/packages/web/vite.config.ts +++ b/packages/web/vite.config.ts @@ -12,6 +12,9 @@ try { hash = "DEV"; } +const CONTENT_SECURITY_POLICY = + "script-src 'self' 'unsafe-inline' https://cdn.jsdelivr.net; style-src 'self' 'unsafe-inline' data: https://rsms.me https://cdn.jsdelivr.net; img-src 'self' data:; font-src 'self' data: https://rsms.me https://cdn.jsdelivr.net; worker-src 'self' blob:; object-src 'none'; base-uri 'self';"; + export default defineConfig({ plugins: [ react(), @@ -49,6 +52,7 @@ export default defineConfig({ server: { port: 3000, headers: { + "content-security-policy": CONTENT_SECURITY_POLICY, "Cross-Origin-Opener-Policy": "same-origin", "Cross-Origin-Embedder-Policy": "require-corp", }, From ff73ed2521942f8982a15a8fbe6da303aad9c6ab Mon Sep 17 00:00:00 2001 From: Dan Ditomaso <dan.ditomaso@gmail.com> Date: Fri, 11 Jul 2025 10:32:13 -0400 Subject: [PATCH 5/5] Update README.md (#707) --- README.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index d895496a..505cf749 100644 --- a/README.md +++ b/README.md @@ -20,6 +20,7 @@ All projects are located within the `packages/` directory: designed to be hosted or served directly from a Meshtastic node. - **[Hosted version](https://client.meshtastic.org)** - **`packages/core`:** Core functionality for Meshtastic JS. +- **`packages/transport-node`:** TCP Transport for the NodeJS runtime. - **`packages/transport-deno`:** TCP Transport for the Deno runtime. - **`packages/transport-http`:** HTTP Transport. - **`packages/transport-web-bluetooth`:** Web Bluetooth Transport. @@ -34,8 +35,7 @@ All `Meshtastic JS` packages (core and transports) are published both to | Project | Repobeats | | :-------------------- | :-------------------------------------------------------------------------------------------------------------------- | -| Meshtastic Web Client | ![Alt](https://repobeats.axiom.co/api/embed/e5b062db986cb005d83e81724c00cb2b9cce8e4c.svg "Repobeats analytics image") | -| Meshtastic JS | ![Alt](https://repobeats.axiom.co/api/embed/5330641586e92a2ec84676fedb98f6d4a7b25d69.svg "Repobeats analytics image") | +| Meshtastic Web | ![Alt](https://repobeats.axiom.co/api/embed/e5b062db986cb005d83e81724c00cb2b9cce8e4c.svg "Repobeats analytics image") | ---