├── .c8rc ├── .changeset ├── README.md ├── config.json ├── five-mirrors-type.md ├── grumpy-dragons-appear.md ├── khaki-peas-kick.md ├── pre.json └── witty-lights-walk.md ├── .github └── workflows │ ├── core.yml │ ├── extension.yml │ ├── monorepo.yml │ ├── pr-check.yml │ ├── react.yml │ └── release.yaml ├── .gitignore ├── .nvmrc ├── .vscode └── settings.json ├── .yarnrc ├── CODEOWNERS ├── LICENSE ├── README.md ├── biome.json ├── codecov.yml ├── eslint.config.mjs ├── package-lock.json ├── package.json ├── packages ├── core │ ├── CHANGELOG.md │ ├── LICENSE │ ├── README.md │ ├── package.json │ ├── src │ │ ├── classifiers │ │ │ └── gql-classifier.ts │ │ ├── gateways │ │ │ ├── network.ts │ │ │ ├── simple-cache.ts │ │ │ └── static.ts │ │ ├── index.ts │ │ ├── logger.ts │ │ ├── routing │ │ │ ├── ping.test.ts │ │ │ ├── ping.ts │ │ │ ├── preferred-with-fallback.ts │ │ │ ├── random.test.ts │ │ │ ├── random.ts │ │ │ ├── round-robin.test.ts │ │ │ ├── round-robin.ts │ │ │ ├── simple-cache.ts │ │ │ ├── static.test.ts │ │ │ └── static.ts │ │ ├── telemetry.ts │ │ ├── types.ts │ │ ├── utils │ │ │ ├── ario.ts │ │ │ ├── b64.test.ts │ │ │ ├── base64.ts │ │ │ ├── hash.test.ts │ │ │ ├── hash.ts │ │ │ ├── random.test.ts │ │ │ └── random.ts │ │ ├── verification │ │ │ ├── data-root-verifier.ts │ │ │ ├── hash-verifier.ts │ │ │ └── signature-verifier.ts │ │ ├── wayfinder.test.ts │ │ └── wayfinder.ts │ └── tsconfig.json ├── extension │ ├── CHANGELOG.md │ ├── LICENSE │ ├── README.md │ ├── assets │ │ ├── css │ │ │ └── styles.css │ │ ├── icon128.png │ │ ├── icon16.png │ │ └── icon48.png │ ├── manifest.json │ ├── package.json │ ├── src │ │ ├── background.ts │ │ ├── constants.ts │ │ ├── content.ts │ │ ├── ens.ts │ │ ├── helpers.ts │ │ ├── popup.html │ │ ├── popup.ts │ │ ├── routing.ts │ │ └── types.ts │ ├── tsconfig.json │ └── vite.config.js └── react │ ├── CHANGELOG.md │ ├── LICENSE │ ├── README.md │ ├── package.json │ ├── src │ ├── components │ │ ├── index.ts │ │ └── wayfinder-provider.tsx │ ├── hooks │ │ └── index.ts │ └── index.ts │ └── tsconfig.json ├── resources └── license.header.mjs ├── scripts └── verify.ts └── tsconfig.json /.c8rc: -------------------------------------------------------------------------------- 1 | { 2 | "extension": [".ts"], 3 | "include": ["src/**/*.ts"], 4 | "exclude": ["**/*.d.ts", "**/*.test.ts", "src/types/**/*.ts"], 5 | "all": true, 6 | "coverage": true, 7 | "reporter": ["text", "html", "lcov"] 8 | } 9 | -------------------------------------------------------------------------------- /.changeset/README.md: -------------------------------------------------------------------------------- 1 | # Changesets 2 | 3 | Hello and welcome! This folder has been automatically generated by `@changesets/cli`, a build tool that works 4 | with multi-package repos, or single-package repos to help you version and publish your code. You can 5 | find the full documentation for it [in our repository](https://github.com/changesets/changesets) 6 | 7 | We have a quick list of common questions to get you started engaging with this project in 8 | [our documentation](https://github.com/changesets/changesets/blob/main/docs/common-questions.md) 9 | -------------------------------------------------------------------------------- /.changeset/config.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://unpkg.com/@changesets/config@3.1.1/schema.json", 3 | "changelog": "@changesets/cli/changelog", 4 | "commit": true, 5 | "skipCI": true, 6 | "message": "chore(release): version packages", 7 | "fixed": [], 8 | "linked": [], 9 | "access": "public", 10 | "baseBranch": "main", 11 | "updateInternalDependencies": "patch", 12 | "ignore": [] 13 | } 14 | -------------------------------------------------------------------------------- /.changeset/five-mirrors-type.md: -------------------------------------------------------------------------------- 1 | --- 2 | "@ar.io/wayfinder-react": patch 3 | --- 4 | 5 | Adds useWayfinderUrl and useWayfinderRequest hooks 6 | -------------------------------------------------------------------------------- /.changeset/grumpy-dragons-appear.md: -------------------------------------------------------------------------------- 1 | --- 2 | "@ar.io/wayfinder-core": patch 3 | --- 4 | 5 | Adds optional telemetry support for wayfinder requests 6 | -------------------------------------------------------------------------------- /.changeset/khaki-peas-kick.md: -------------------------------------------------------------------------------- 1 | --- 2 | "@ar.io/wayfinder-core": patch 3 | --- 4 | 5 | Make sample rate and service name optional 6 | -------------------------------------------------------------------------------- /.changeset/pre.json: -------------------------------------------------------------------------------- 1 | { 2 | "mode": "pre", 3 | "tag": "alpha", 4 | "initialVersions": { 5 | "@ar.io/wayfinder-core": "0.0.5-alpha.2", 6 | "@ar.io/wayfinder-extension": "0.0.18-alpha.2", 7 | "@ar.io/wayfinder-react": "0.0.5-alpha.3" 8 | }, 9 | "changesets": [ 10 | "five-mirrors-type", 11 | "grumpy-dragons-appear", 12 | "witty-lights-walk" 13 | ] 14 | } 15 | -------------------------------------------------------------------------------- /.changeset/witty-lights-walk.md: -------------------------------------------------------------------------------- 1 | --- 2 | "@ar.io/wayfinder-core": patch 3 | --- 4 | 5 | Updates type exports from wayfinder-core 6 | -------------------------------------------------------------------------------- /.github/workflows/core.yml: -------------------------------------------------------------------------------- 1 | name: Core Library 2 | 3 | on: 4 | push: 5 | branches: [ main, alpha ] 6 | paths: 7 | - 'packages/core/**' 8 | - '.github/workflows/core.yml' 9 | - 'package.json' 10 | pull_request: 11 | branches: [ main, alpha ] 12 | paths: 13 | - 'packages/core/**' 14 | - '.github/workflows/core.yml' 15 | - 'package.json' 16 | 17 | jobs: 18 | lint: 19 | runs-on: ubuntu-latest 20 | 21 | steps: 22 | - uses: actions/checkout@v4 23 | 24 | - name: Setup Node.js 25 | uses: actions/setup-node@v4 26 | with: 27 | node-version-file: '.nvmrc' 28 | cache: 'npm' 29 | 30 | - name: Install dependencies 31 | run: npm install --frozen-lockfile 32 | 33 | - name: Lint 34 | run: npm run lint:check -w @ar.io/wayfinder-core 35 | 36 | test: 37 | runs-on: ubuntu-latest 38 | 39 | steps: 40 | - uses: actions/checkout@v4 41 | 42 | - name: Setup Node.js 43 | uses: actions/setup-node@v4 44 | with: 45 | node-version-file: '.nvmrc' 46 | cache: 'npm' 47 | 48 | - name: Install dependencies 49 | run: npm install --frozen-lockfile 50 | 51 | - name: Test 52 | run: npm run test:unit -w @ar.io/wayfinder-core 53 | 54 | build: 55 | runs-on: ubuntu-latest 56 | 57 | steps: 58 | - uses: actions/checkout@v4 59 | 60 | - name: Setup Node.js 61 | uses: actions/setup-node@v4 62 | with: 63 | node-version-file: '.nvmrc' 64 | cache: 'npm' 65 | 66 | - name: Install dependencies 67 | run: npm install --frozen-lockfile 68 | 69 | - name: Build core library 70 | run: npm run build -w @ar.io/wayfinder-core 71 | 72 | # TODO: Upload artifacts and use changeset for publishing new versions 73 | -------------------------------------------------------------------------------- /.github/workflows/extension.yml: -------------------------------------------------------------------------------- 1 | name: Chrome Extension 2 | 3 | on: 4 | push: 5 | branches: [ main, alpha ] 6 | paths: 7 | - 'packages/extension/**' 8 | - 'packages/core/**' 9 | - '.github/workflows/extension.yml' 10 | - 'package.json' 11 | pull_request: 12 | branches: [ main, alpha ] 13 | paths: 14 | - 'packages/extension/**' 15 | - 'packages/core/**' 16 | - '.github/workflows/extension.yml' 17 | - 'package.json' 18 | 19 | jobs: 20 | build: 21 | runs-on: ubuntu-latest 22 | 23 | steps: 24 | - uses: actions/checkout@v4 25 | 26 | - name: Setup Node.js 27 | uses: actions/setup-node@v4 28 | with: 29 | node-version-file: '.nvmrc' 30 | cache: 'npm' 31 | 32 | - name: Install dependencies 33 | run: npm install --frozen-lockfile 34 | 35 | - name: Lint 36 | run: npm run lint:check -w @ar.io/wayfinder-extension 37 | 38 | - name: Build core library first 39 | run: npm run build -w @ar.io/wayfinder-core 40 | 41 | - name: Build extension 42 | run: npm run build -w @ar.io/wayfinder-extension 43 | 44 | # TODO: Upload artifacts and use changeset for publishing new versions 45 | 46 | 47 | -------------------------------------------------------------------------------- /.github/workflows/monorepo.yml: -------------------------------------------------------------------------------- 1 | name: Monorepo 2 | 3 | on: 4 | push: 5 | branches: [ main, alpha ] 6 | paths-ignore: 7 | - 'packages/core/**' 8 | - 'packages/extension/**' 9 | - 'packages/react/**' 10 | - '.github/workflows/core.yml' 11 | - '.github/workflows/extension.yml' 12 | - '.github/workflows/react.yml' 13 | pull_request: 14 | branches: [ main, alpha ] 15 | paths-ignore: 16 | - 'packages/core/**' 17 | - 'packages/extension/**' 18 | - 'packages/react/**' 19 | - '.github/workflows/core.yml' 20 | - '.github/workflows/extension.yml' 21 | - '.github/workflows/react.yml' 22 | 23 | jobs: 24 | build: 25 | runs-on: ubuntu-latest 26 | 27 | steps: 28 | - uses: actions/checkout@v4 29 | 30 | - name: Setup Node.js 31 | uses: actions/setup-node@v4 32 | with: 33 | node-version-file: '.nvmrc' 34 | cache: 'npm' 35 | 36 | - name: Install dependencies 37 | run: npm install --frozen-lockfile 38 | 39 | - name: Lint 40 | run: npm run lint:check --workspaces 41 | 42 | - name: Build all packages 43 | run: npm run build --workspaces 44 | 45 | - name: Run tests 46 | run: npm run test --workspaces 47 | -------------------------------------------------------------------------------- /.github/workflows/pr-check.yml: -------------------------------------------------------------------------------- 1 | name: Diff Check 2 | 3 | on: 4 | workflow_call: 5 | pull_request: 6 | branches: [ main, alpha ] 7 | # This runs on all PRs regardless of paths changed 8 | 9 | jobs: 10 | changed-files: 11 | runs-on: ubuntu-latest 12 | outputs: 13 | core: ${{ steps.filter.outputs.core }} 14 | extension: ${{ steps.filter.outputs.extension }} 15 | react: ${{ steps.filter.outputs.react }} 16 | any: ${{ steps.filter.outputs.any }} 17 | 18 | steps: 19 | - uses: actions/checkout@v4 20 | with: 21 | fetch-depth: 0 22 | 23 | - name: Check changed files 24 | uses: dorny/paths-filter@v2 25 | id: filter 26 | with: 27 | filters: | 28 | core: 29 | - 'packages/core/**' 30 | extension: 31 | - 'packages/extension/**' 32 | - 'packages/core/**' 33 | react: 34 | - 'packages/react/**' 35 | - 'packages/core/**' 36 | any: 37 | - '**' 38 | 39 | core: 40 | needs: changed-files 41 | if: ${{ needs.changed-files.outputs.core == 'true' }} 42 | runs-on: ubuntu-latest 43 | 44 | steps: 45 | - uses: actions/checkout@v4 46 | 47 | - name: Setup Node.js 48 | uses: actions/setup-node@v4 49 | with: 50 | node-version-file: '.nvmrc' 51 | cache: 'npm' 52 | 53 | - name: Install dependencies 54 | run: npm install --frozen-lockfile 55 | 56 | - name: Lint 57 | run: npm run lint:check -w @ar.io/wayfinder-core 58 | 59 | - name: Build core library 60 | run: npm run build -w @ar.io/wayfinder-core 61 | 62 | extension: 63 | needs: changed-files 64 | if: ${{ needs.changed-files.outputs.extension == 'true' }} 65 | runs-on: ubuntu-latest 66 | 67 | steps: 68 | - uses: actions/checkout@v3 69 | 70 | - name: Setup Node.js 71 | uses: actions/setup-node@v4 72 | with: 73 | node-version-file: '.nvmrc' 74 | cache: 'npm' 75 | 76 | - name: Install dependencies 77 | run: npm install --frozen-lockfile 78 | 79 | - name: Lint 80 | run: npm run lint:check -w @ar.io/wayfinder-extension 81 | 82 | - name: Build core library first 83 | run: npm run build -w @ar.io/wayfinder-core 84 | 85 | - name: Build extension 86 | run: npm run build -w @ar.io/wayfinder-extension 87 | 88 | react: 89 | needs: changed-files 90 | if: ${{ needs.changed-files.outputs.react == 'true' }} 91 | runs-on: ubuntu-latest 92 | 93 | steps: 94 | - uses: actions/checkout@v4 95 | 96 | - name: Setup Node.js 97 | uses: actions/setup-node@v4 98 | with: 99 | node-version-file: '.nvmrc' 100 | cache: 'npm' 101 | 102 | - name: Install dependencies 103 | run: npm install --frozen-lockfile 104 | 105 | - name: Lint 106 | run: npm run lint:check -w @ar.io/wayfinder-react 107 | 108 | - name: Build core library first 109 | run: npm run build -w @ar.io/wayfinder-core 110 | 111 | - name: Build react components 112 | run: npm run build -w @ar.io/wayfinder-react 113 | -------------------------------------------------------------------------------- /.github/workflows/react.yml: -------------------------------------------------------------------------------- 1 | name: React Components & Hooks 2 | 3 | on: 4 | push: 5 | branches: [ main, alpha ] 6 | paths: 7 | - 'packages/react/**' 8 | - 'packages/core/**' 9 | - '.github/workflows/react.yml' 10 | - 'package.json' 11 | pull_request: 12 | branches: [ main, alpha ] 13 | paths: 14 | - 'packages/react/**' 15 | - 'packages/core/**' 16 | - '.github/workflows/react.yml' 17 | - 'package.json' 18 | 19 | jobs: 20 | build: 21 | runs-on: ubuntu-latest 22 | 23 | steps: 24 | - uses: actions/checkout@v4 25 | 26 | - name: Setup Node.js 27 | uses: actions/setup-node@v4 28 | with: 29 | node-version-file: '.nvmrc' 30 | cache: 'npm' 31 | 32 | - name: Install dependencies 33 | run: npm install --frozen-lockfile 34 | 35 | - name: Lint 36 | run: npm run lint:check -w @ar.io/wayfinder-react 37 | 38 | - name: Build core library first 39 | run: npm run build -w @ar.io/wayfinder-core 40 | 41 | - name: Build React components 42 | run: npm run build -w @ar.io/wayfinder-react 43 | 44 | # TODO: Upload artifacts and use changeset for publishing new versions 45 | -------------------------------------------------------------------------------- /.github/workflows/release.yaml: -------------------------------------------------------------------------------- 1 | name: Release 2 | 3 | on: 4 | push: 5 | branches: 6 | - main 7 | - alpha 8 | 9 | jobs: 10 | # run the monorepo.yml to confirm all tests, linting and formatting is passing 11 | monorepo: 12 | uses: ./.github/workflows/pr-check.yml 13 | 14 | release: 15 | name: Release 16 | runs-on: ubuntu-latest 17 | permissions: 18 | contents: write 19 | packages: write 20 | pull-requests: write 21 | id-token: write 22 | steps: 23 | - name: Checkout Repository 24 | uses: actions/checkout@v4 25 | with: 26 | fetch-depth: 0 27 | token: ${{ secrets.GITHUB_TOKEN }} 28 | 29 | - name: Setup Node.js 30 | uses: actions/setup-node@v4 31 | with: 32 | node-version: 18 33 | registry-url: 'https://registry.npmjs.org' 34 | cache: npm 35 | 36 | - name: Install Dependencies 37 | run: npm ci 38 | 39 | - name: Setup Git User 40 | run: | 41 | git config --global user.name "github-actions[bot]" 42 | git config --global user.email "github-actions[bot]@users.noreply.github.com" 43 | 44 | - name: Check Prerelease Mode for Alpha Branch 45 | if: github.ref == 'refs/heads/alpha' 46 | run: | 47 | if [ -f ".changeset/pre.json" ]; then 48 | echo "Already in pre-release mode, skipping..." 49 | else 50 | npx changeset pre enter alpha 51 | fi 52 | 53 | - name: Build all packages 54 | run: npm run build --workspaces 55 | 56 | - name: Create Release Pull Request or Publish to npm 57 | id: changesets 58 | uses: changesets/action@v1 59 | with: 60 | publish: npx changeset publish 61 | commit: "chore(release): version packages" 62 | title: "chore(release): version packages" 63 | env: 64 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 65 | NPM_TOKEN: ${{ secrets.NPM_TOKEN }} 66 | NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} 67 | 68 | - name: Push Tags 69 | if: steps.changesets.outputs.published == 'true' 70 | run: git push --follow-tags 71 | 72 | - name: Create GitHub Release 73 | if: steps.changesets.outputs.published == 'true' 74 | uses: ncipollo/release-action@v1 75 | with: 76 | generateReleaseNotes: true 77 | prerelease: ${{ github.ref == 'refs/heads/alpha' }} 78 | tag: ${{ steps.changesets.outputs.publishedPackages[0].name }}@${{ steps.changesets.outputs.publishedPackages[0].version }} 79 | token: ${{ secrets.GITHUB_TOKEN }} 80 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Ignore node_modules directory 2 | node_modules/ 3 | 4 | # Ignore TypeScript build output 5 | dist/ 6 | 7 | # Ignore logs 8 | logs/ 9 | *.log 10 | npm-debug.log* 11 | yarn-debug.log* 12 | yarn-error.log* 13 | 14 | # Ignore environment variable files 15 | .env 16 | .env.local 17 | .env.*.local 18 | 19 | # Ignore macOS system files 20 | .DS_Store 21 | 22 | # Ignore Linux system files 23 | *.swp 24 | 25 | # Ignore temporary files 26 | tmp/ 27 | temp/ 28 | 29 | # Ignore IDE and editor specific files 30 | .idea/ 31 | *.sublime-project 32 | *.sublime-workspace 33 | 34 | # Ignore TypeScript cache 35 | *.tsbuildinfo 36 | 37 | # Ignore Chrome extension files that shouldn't be committed 38 | chrome-profile/ 39 | 40 | # coverage files 41 | coverage/ 42 | 43 | # claude 44 | .claude/ 45 | -------------------------------------------------------------------------------- /.nvmrc: -------------------------------------------------------------------------------- 1 | v22.14.0 2 | -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "editor.formatOnSave": true, 3 | "editor.codeActionsOnSave": { 4 | "source.fixAll.eslint": "always" 5 | }, 6 | "editor.defaultFormatter": "biomejs.biome", 7 | "[typescript]": { 8 | "editor.formatOnSave": true 9 | }, 10 | "[markdown]": { 11 | "editor.formatOnSave": true 12 | }, 13 | "[json]": { 14 | "editor.formatOnSave": true 15 | }, 16 | "search.exclude": { 17 | "**/node_modules": true, 18 | "**/dist": true, 19 | "**/coverage": true, 20 | "**/cache": true 21 | }, 22 | "cSpell.words": ["ARIO", "wayfinder"] 23 | } 24 | -------------------------------------------------------------------------------- /.yarnrc: -------------------------------------------------------------------------------- 1 | ignore-engines true 2 | -------------------------------------------------------------------------------- /CODEOWNERS: -------------------------------------------------------------------------------- 1 | * @ardriveapp/services 2 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Apache License 2 | Version 2.0, January 2004 3 | http://www.apache.org/licenses/ 4 | 5 | TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION 6 | 7 | 1. Definitions. 8 | 9 | "License" shall mean the terms and conditions for use, reproduction, 10 | and distribution as defined by Sections 1 through 9 of this document. 11 | 12 | "Licensor" shall mean the copyright owner or entity authorized by 13 | the copyright owner that is granting the License. 14 | 15 | "Legal Entity" shall mean the union of the acting entity and all 16 | other entities that control, are controlled by, or are under common 17 | control with that entity. For the purposes of this definition, 18 | "control" means (i) the power, direct or indirect, to cause the 19 | direction or management of such entity, whether by contract or 20 | otherwise, or (ii) ownership of fifty percent (50%) or more of the 21 | outstanding shares, or (iii) beneficial ownership of such entity. 22 | 23 | "You" (or "Your") shall mean an individual or Legal Entity 24 | exercising permissions granted by this License. 25 | 26 | "Source" form shall mean the preferred form for making modifications, 27 | including but not limited to software source code, documentation 28 | source, and configuration files. 29 | 30 | "Object" form shall mean any form resulting from mechanical 31 | transformation or translation of a Source form, including but 32 | not limited to compiled object code, generated documentation, 33 | and conversions to other media types. 34 | 35 | "Work" shall mean the work of authorship, whether in Source or 36 | Object form, made available under the License, as indicated by a 37 | copyright notice that is included in or attached to the work 38 | (an example is provided in the Appendix below). 39 | 40 | "Derivative Works" shall mean any work, whether in Source or Object 41 | form, that is based on (or derived from) the Work and for which the 42 | editorial revisions, annotations, elaborations, or other modifications 43 | represent, as a whole, an original work of authorship. For the purposes 44 | of this License, Derivative Works shall not include works that remain 45 | separable from, or merely link (or bind by name) to the interfaces of, 46 | the Work and Derivative Works thereof. 47 | 48 | "Contribution" shall mean any work of authorship, including 49 | the original version of the Work and any modifications or additions 50 | to that Work or Derivative Works thereof, that is intentionally 51 | submitted to Licensor for inclusion in the Work by the copyright owner 52 | or by an individual or Legal Entity authorized to submit on behalf of 53 | the copyright owner. For the purposes of this definition, "submitted" 54 | means any form of electronic, verbal, or written communication sent 55 | to the Licensor or its representatives, including but not limited to 56 | communication on electronic mailing lists, source code control systems, 57 | and issue tracking systems that are managed by, or on behalf of, the 58 | Licensor for the purpose of discussing and improving the Work, but 59 | excluding communication that is conspicuously marked or otherwise 60 | designated in writing by the copyright owner as "Not a Contribution." 61 | 62 | "Contributor" shall mean Licensor and any individual or Legal Entity 63 | on behalf of whom a Contribution has been received by Licensor and 64 | subsequently incorporated within the Work. 65 | 66 | 2. Grant of Copyright License. Subject to the terms and conditions of 67 | this License, each Contributor hereby grants to You a perpetual, 68 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 69 | copyright license to reproduce, prepare Derivative Works of, 70 | publicly display, publicly perform, sublicense, and distribute the 71 | Work and such Derivative Works in Source or Object form. 72 | 73 | 3. Grant of Patent License. Subject to the terms and conditions of 74 | this License, each Contributor hereby grants to You a perpetual, 75 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 76 | (except as stated in this section) patent license to make, have made, 77 | use, offer to sell, sell, import, and otherwise transfer the Work, 78 | where such license applies only to those patent claims licensable 79 | by such Contributor that are necessarily infringed by their 80 | Contribution(s) alone or by combination of their Contribution(s) 81 | with the Work to which such Contribution(s) was submitted. If You 82 | institute patent litigation against any entity (including a 83 | cross-claim or counterclaim in a lawsuit) alleging that the Work 84 | or a Contribution incorporated within the Work constitutes direct 85 | or contributory patent infringement, then any patent licenses 86 | granted to You under this License for that Work shall terminate 87 | as of the date such litigation is filed. 88 | 89 | 4. Redistribution. You may reproduce and distribute copies of the 90 | Work or Derivative Works thereof in any medium, with or without 91 | modifications, and in Source or Object form, provided that You 92 | meet the following conditions: 93 | 94 | (a) You must give any other recipients of the Work or 95 | Derivative Works a copy of this License; and 96 | 97 | (b) You must cause any modified files to carry prominent notices 98 | stating that You changed the files; and 99 | 100 | (c) You must retain, in the Source form of any Derivative Works 101 | that You distribute, all copyright, patent, trademark, and 102 | attribution notices from the Source form of the Work, 103 | excluding those notices that do not pertain to any part of 104 | the Derivative Works; and 105 | 106 | (d) If the Work includes a "NOTICE" text file as part of its 107 | distribution, then any Derivative Works that You distribute must 108 | include a readable copy of the attribution notices contained 109 | within such NOTICE file, excluding those notices that do not 110 | pertain to any part of the Derivative Works, in at least one 111 | of the following places: within a NOTICE text file distributed 112 | as part of the Derivative Works; within the Source form or 113 | documentation, if provided along with the Derivative Works; or, 114 | within a display generated by the Derivative Works, if and 115 | wherever such third-party notices normally appear. The contents 116 | of the NOTICE file are for informational purposes only and 117 | do not modify the License. You may add Your own attribution 118 | notices within Derivative Works that You distribute, alongside 119 | or as an addendum to the NOTICE text from the Work, provided 120 | that such additional attribution notices cannot be construed 121 | as modifying the License. 122 | 123 | You may add Your own copyright statement to Your modifications and 124 | may provide additional or different license terms and conditions 125 | for use, reproduction, or distribution of Your modifications, or 126 | for any such Derivative Works as a whole, provided Your use, 127 | reproduction, and distribution of the Work otherwise complies with 128 | the conditions stated in this License. 129 | 130 | 5. Submission of Contributions. Unless You explicitly state otherwise, 131 | any Contribution intentionally submitted for inclusion in the Work 132 | by You to the Licensor shall be under the terms and conditions of 133 | this License, without any additional terms or conditions. 134 | Notwithstanding the above, nothing herein shall supersede or modify 135 | the terms of any separate license agreement you may have executed 136 | with Licensor regarding such Contributions. 137 | 138 | 6. Trademarks. This License does not grant permission to use the trade 139 | names, trademarks, service marks, or product names of the Licensor, 140 | except as required for reasonable and customary use in describing the 141 | origin of the Work and reproducing the content of the NOTICE file. 142 | 143 | 7. Disclaimer of Warranty. Unless required by applicable law or 144 | agreed to in writing, Licensor provides the Work (and each 145 | Contributor provides its Contributions) on an "AS IS" BASIS, 146 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or 147 | implied, including, without limitation, any warranties or conditions 148 | of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A 149 | PARTICULAR PURPOSE. You are solely responsible for determining the 150 | appropriateness of using or redistributing the Work and assume any 151 | risks associated with Your exercise of permissions under this License. 152 | 153 | 8. Limitation of Liability. In no event and under no legal theory, 154 | whether in tort (including negligence), contract, or otherwise, 155 | unless required by applicable law (such as deliberate and grossly 156 | negligent acts) or agreed to in writing, shall any Contributor be 157 | liable to You for damages, including any direct, indirect, special, 158 | incidental, or consequential damages of any character arising as a 159 | result of this License or out of the use or inability to use the 160 | Work (including but not limited to damages for loss of goodwill, 161 | work stoppage, computer failure or malfunction, or any and all 162 | other commercial damages or losses), even if such Contributor 163 | has been advised of the possibility of such damages. 164 | 165 | 9. Accepting Warranty or Additional Liability. While redistributing 166 | the Work or Derivative Works thereof, You may choose to offer, 167 | and charge a fee for, acceptance of support, warranty, indemnity, 168 | or other liability obligations and/or rights consistent with this 169 | License. However, in accepting such obligations, You may act only 170 | on Your own behalf and on Your sole responsibility, not on behalf 171 | of any other Contributor, and only if You agree to indemnify, 172 | defend, and hold each Contributor harmless for any liability 173 | incurred by, or claims asserted against, such Contributor by reason 174 | of your accepting any such warranty or additional liability. 175 | 176 | END OF TERMS AND CONDITIONS 177 | 178 | APPENDIX: How to apply the Apache License to your work. 179 | 180 | To apply the Apache License to your work, attach the following 181 | boilerplate notice, with the fields enclosed by brackets "[]" 182 | replaced with your own identifying information. (Don't include 183 | the brackets!) The text should be enclosed in the appropriate 184 | comment syntax for the file format. We also recommend that a 185 | file or class name and description of purpose be included on the 186 | same "printed page" as the copyright notice for easier 187 | identification within third-party archives. 188 | 189 | Copyright [2024] [Permanent Data Solutions, Inc.] 190 | 191 | Licensed under the Apache License, Version 2.0 (the "License"); 192 | you may not use this file except in compliance with the License. 193 | You may obtain a copy of the License at 194 | 195 | http://www.apache.org/licenses/LICENSE-2.0 196 | 197 | Unless required by applicable law or agreed to in writing, software 198 | distributed under the License is distributed on an "AS IS" BASIS, 199 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 200 | See the License for the specific language governing permissions and 201 | limitations under the License. 202 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Wayfinder 2 | 3 | Wayfinder is a set of tools and libraries that enable decentralized and cryptographically verified access to data stored on Arweave via the [AR.IO Network](https://ar.io). 4 | 5 | ## Packages 6 | 7 | This monorepo contains the following packages: 8 | 9 | - **[@ar.io/wayfinder-core](./packages/core)**: Core JavaScript library for the Wayfinder routing and verification protocol 10 | - **[@ar.io/wayfinder-react](./packages/react)**: React components for Wayfinder, including Hooks and Context provider 11 | - **[@ar.io/wayfinder-extension](./packages/extension)**: Chrome extension for Wayfinder 12 | - **[@ar.io/wayfinder-cli](./packages/cli)**: CLI for interacting with Wayfinder in the terminal 13 | 14 | ## What is it? 15 | 16 | Wayfinder is a simple, open-source client-side routing and verification protocol for the permaweb. It leverages the [AR.IO Network](https://ar.io) to route users to the most optimal gateway for a given request. 17 | 18 | ## Who is it built for? 19 | 20 | - Anyone who wants to browse the Permaweb. Since no wallet is required, the user does not need to have ever touched tokens or uploaded data. 21 | - Developers who want to integrate ar:// protocol. Wayfinder allows developers to retrieve data from Arweave via the [ar.io network], ensuring decentralized access to all assets of your permaweb app. 22 | 23 | ## Releases 24 | 25 | This project uses [Changesets](https://github.com/changesets/changesets) to manage versioning and package releases. 26 | 27 | ### Creating a Changeset 28 | 29 | To create a changeset when making changes: 30 | 31 | ```bash 32 | npx changeset 33 | ``` 34 | 35 | This will guide you through the process of documenting your changes and selecting which packages are affected. Changesets will be used during the release process to update package versions and generate changelogs. 36 | 37 | ### Automated Releases 38 | 39 | This repository is configured with GitHub Actions workflows that automate the release process: 40 | 41 | - **Main Branch**: When changes are merged to `main`, a standard release is created 42 | - **Alpha Branch**: When changes are merged to `alpha`, a prerelease (beta tagged) is created 43 | 44 | The workflow automatically: 45 | 1. Determines whether to create a prerelease or standard release based on the branch 46 | 2. Versions packages using changesets 47 | 3. Publishes to npm 48 | 4. Creates GitHub releases 49 | 5. Pushes tags back to the repository 50 | 51 | To use the automated process: 52 | 1. Create changesets for your changes 53 | 2. Push your changes to a feature branch 54 | 3. Create a pull request to `alpha` (for prereleases) or `main` (for standard releases) 55 | 4. When the PR is merged, the release will be automatically created 56 | 57 | ### Manual Release Process 58 | 59 | If you need to release manually, follow these steps: 60 | 61 | #### Normal Releases 62 | 63 | To release a new version: 64 | 65 | 1. Ensure all changes are documented with changesets 66 | 2. Run the version command to update package versions and changelogs: 67 | 68 | ```bash 69 | npx changeset version 70 | ``` 71 | 72 | 3. Review the version changes and changelogs 73 | 4. Commit the changes: 74 | 75 | ```bash 76 | git add . 77 | git commit -m "chore(release): version packages" 78 | ``` 79 | 80 | 5. Publish the packages to npm: 81 | 82 | ```bash 83 | npm run build 84 | npx changeset publish 85 | ``` 86 | 87 | 6. Push the changes and tags: 88 | 89 | ```bash 90 | git push origin main --follow-tags 91 | ``` 92 | 93 | #### Prerelease Mode 94 | 95 | For prerelease versions (e.g., beta, alpha): 96 | 97 | 1. Enter prerelease mode specifying the tag: 98 | 99 | ```bash 100 | npx changeset pre enter beta 101 | ``` 102 | 103 | 2. Create changesets as normal: 104 | 105 | ```bash 106 | npx changeset 107 | ``` 108 | 109 | 3. Version and publish as normal: 110 | 111 | ```bash 112 | npx changeset version 113 | # Review changes 114 | git add . 115 | git commit -m "chore(release): prerelease version packages" 116 | npm run build 117 | npx changeset publish 118 | git push origin main --follow-tags 119 | ``` 120 | 121 | 4. Exit prerelease mode when ready for a stable release: 122 | 123 | ```bash 124 | npx changeset pre exit 125 | ``` 126 | 127 | 5. Follow the normal release process for the stable version. 128 | -------------------------------------------------------------------------------- /biome.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://biomejs.dev/schemas/1.9.4/schema.json", 3 | "vcs": { "enabled": false, "clientKind": "git", "useIgnoreFile": false }, 4 | "files": { 5 | "ignoreUnknown": false, 6 | "ignore": ["node_modules", "dist", "coverage"] 7 | }, 8 | "formatter": { "enabled": true, "indentStyle": "space", "indentWidth": 2 }, 9 | "organizeImports": { "enabled": true }, 10 | "linter": { 11 | "enabled": true, 12 | "rules": { 13 | "recommended": false, 14 | "complexity": { 15 | "noExtraBooleanCast": "error", 16 | "noMultipleSpacesInRegularExpressionLiterals": "error", 17 | "noUselessCatch": "error", 18 | "noUselessTypeConstraint": "error", 19 | "noWith": "error" 20 | }, 21 | "correctness": { 22 | "noConstAssign": "error", 23 | "noConstantCondition": "error", 24 | "noEmptyCharacterClassInRegex": "error", 25 | "noEmptyPattern": "error", 26 | "noGlobalObjectCalls": "error", 27 | "noInvalidBuiltinInstantiation": "error", 28 | "noInvalidConstructorSuper": "error", 29 | "noNewSymbol": "off", 30 | "noNonoctalDecimalEscape": "error", 31 | "noPrecisionLoss": "error", 32 | "noSelfAssign": "error", 33 | "noSetterReturn": "error", 34 | "noSwitchDeclarations": "error", 35 | "noUndeclaredVariables": "error", 36 | "noUnreachable": "error", 37 | "noUnreachableSuper": "error", 38 | "noUnsafeFinally": "error", 39 | "noUnsafeOptionalChaining": "error", 40 | "noUnusedLabels": "error", 41 | "noUnusedPrivateClassMembers": "error", 42 | "noUnusedVariables": "error", 43 | "useArrayLiterals": "off", 44 | "useIsNan": "error", 45 | "useValidForDirection": "error", 46 | "useYield": "error" 47 | }, 48 | "style": { 49 | "noArguments": "error", 50 | "noNamespace": "error", 51 | "noVar": "error", 52 | "useAsConstAssertion": "error", 53 | "useConst": "error" 54 | }, 55 | "suspicious": { 56 | "noAsyncPromiseExecutor": "error", 57 | "noCatchAssign": "error", 58 | "noClassAssign": "error", 59 | "noCompareNegZero": "error", 60 | "noControlCharactersInRegex": "error", 61 | "noDebugger": "error", 62 | "noDoubleEquals": "error", 63 | "noDuplicateCase": "error", 64 | "noDuplicateClassMembers": "error", 65 | "noDuplicateObjectKeys": "error", 66 | "noDuplicateParameters": "error", 67 | "noEmptyBlockStatements": "error", 68 | "noExplicitAny": "off", 69 | "noExtraNonNullAssertion": "error", 70 | "noFallthroughSwitchClause": "error", 71 | "noFunctionAssign": "error", 72 | "noGlobalAssign": "error", 73 | "noImportAssign": "error", 74 | "noMisleadingCharacterClass": "error", 75 | "noMisleadingInstantiator": "error", 76 | "noPrototypeBuiltins": "error", 77 | "noRedeclare": "error", 78 | "noShadowRestrictedNames": "error", 79 | "noSparseArray": "error", 80 | "noUnsafeDeclarationMerging": "error", 81 | "noUnsafeNegation": "error", 82 | "useGetterReturn": "error", 83 | "useNamespaceKeyword": "error", 84 | "useValidTypeof": "error" 85 | } 86 | } 87 | }, 88 | "javascript": { 89 | "formatter": { "quoteStyle": "single" }, 90 | "globals": ["chrome"] 91 | } 92 | } 93 | -------------------------------------------------------------------------------- /codecov.yml: -------------------------------------------------------------------------------- 1 | coverage: 2 | status: 3 | project: 4 | default: 5 | target: 80% 6 | threshold: 1% 7 | ignore: 8 | - 'src/cli/*' 9 | - 'src/cli/commands/*' 10 | - 'src/common/token/*' 11 | -------------------------------------------------------------------------------- /eslint.config.mjs: -------------------------------------------------------------------------------- 1 | /** 2 | * WayFinder 3 | * Copyright (C) 2022-2025 Permanent Data Solutions, Inc. All Rights Reserved. 4 | * 5 | * This program is free software: you can redistribute it and/or modify 6 | * it under the terms of the GNU Affero General Public License as published by 7 | * the Free Software Foundation, either version 3 of the License, or 8 | * (at your option) any later version. 9 | * 10 | * This program is distributed in the hope that it will be useful, 11 | * but WITHOUT ANY WARRANTY; without even the implied warranty of 12 | * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 13 | * GNU Affero General Public License for more details. 14 | * 15 | * You should have received a copy of the GNU Affero General Public License 16 | * along with this program. If not, see . 17 | */ 18 | import path from 'node:path'; 19 | import { fileURLToPath } from 'node:url'; 20 | import { FlatCompat } from '@eslint/eslintrc'; 21 | import js from '@eslint/js'; 22 | import typescriptEslint from '@typescript-eslint/eslint-plugin'; 23 | import tsParser from '@typescript-eslint/parser'; 24 | import header from 'eslint-plugin-header'; 25 | 26 | const __filename = fileURLToPath(import.meta.url); 27 | const __dirname = path.dirname(__filename); 28 | 29 | // workaround: https://github.com/Stuk/eslint-plugin-header/issues/57#issuecomment-2378485611 30 | header.rules.header.meta.schema = false; 31 | const compat = new FlatCompat({ 32 | baseDirectory: __dirname, 33 | recommendedConfig: js.configs.recommended, 34 | allConfig: js.configs.all, 35 | }); 36 | 37 | export default [ 38 | ...compat.extends( 39 | 'eslint:recommended', 40 | 'plugin:@typescript-eslint/eslint-recommended', 41 | 'plugin:@typescript-eslint/recommended', 42 | ), 43 | { 44 | files: ['**/*.ts', '**/*.tsx'], 45 | 46 | plugins: { 47 | '@typescript-eslint': typescriptEslint, 48 | header, 49 | }, 50 | languageOptions: { 51 | parser: tsParser, 52 | ecmaVersion: 2022, 53 | sourceType: 'module', 54 | parserOptions: { 55 | project: './tsconfig.json', 56 | }, 57 | }, 58 | rules: { 59 | '@typescript-eslint/no-explicit-any': ['off'], 60 | eqeqeq: ['error', 'smart'], 61 | 'header/header': [2, './resources/license.header.mjs'], 62 | }, 63 | }, 64 | ]; 65 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@ar.io/wayfinder-monorepo", 3 | "version": "0.0.14", 4 | "description": "WayFinder monorepo containing the Chrome extension, core library, and React components", 5 | "private": true, 6 | "type": "module", 7 | "workspaces": ["packages/*"], 8 | "devDependencies": { 9 | "@biomejs/biome": "1.9.4", 10 | "@changesets/cli": "^2.29.4", 11 | "@typescript-eslint/eslint-plugin": "^8.29.0", 12 | "@typescript-eslint/parser": "^8.29.0", 13 | "c8": "^10.1.3", 14 | "changesets": "^1.0.2", 15 | "eslint": "^9.25.1", 16 | "eslint-plugin-header": "^3.1.1", 17 | "rimraf": "^6.0.1", 18 | "tsx": "^4.20.3", 19 | "typescript": "^5.4.5" 20 | }, 21 | "scripts": { 22 | "test": "npm run test --workspaces", 23 | "clean": "rimraf packages/*/dist", 24 | "build": "npm run build --workspaces", 25 | "lint:fix": "biome check --write --unsafe && eslint --fix packages/*/src", 26 | "lint:check": "biome check --unsafe && eslint packages/*/src", 27 | "format:fix": "biome format --write", 28 | "format:check": "biome format", 29 | "verify": "tsx scripts/verify.ts" 30 | }, 31 | "repository": { 32 | "type": "git", 33 | "url": "git+https://github.com/ar-io/wayfinder.git" 34 | }, 35 | "keywords": [], 36 | "author": "Permanent Data Solutions, Inc.", 37 | "license": "Apache-2.0", 38 | "bugs": { 39 | "url": "https://github.com/ar-io/wayfinder/issues" 40 | }, 41 | "homepage": "https://github.com/ar-io/wayfinder#readme" 42 | } 43 | -------------------------------------------------------------------------------- /packages/core/CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # @ar.io/wayfinder-core 2 | 3 | ## 0.0.5-alpha.4 4 | 5 | ### Patch Changes 6 | 7 | - 2d72bba: Adds optional telemetry support for wayfinder requests 8 | 9 | ## 0.0.5-alpha.3 10 | 11 | ### Patch Changes 12 | 13 | - 063e480: Updates type exports from wayfinder-core 14 | 15 | ## 0.0.5-alpha.2 16 | 17 | ### Patch Changes 18 | 19 | - b85ec7e: Fix type for NetworkGatweaysProvider to allow AoARIORead 20 | 21 | ## 0.0.5-alpha.1 22 | 23 | ### Patch Changes 24 | 25 | - aba2beb: Update README and docs 26 | 27 | ## 0.0.5-alpha.0 28 | 29 | ### Patch Changes 30 | 31 | - 4afd953: Update logger imports and utils 32 | 33 | ## 0.0.4 34 | 35 | ### Patch Changes 36 | 37 | - e43548d: Move utils to various utils files, move logger to logger.ts 38 | 39 | ## 0.0.4-alpha.1 40 | 41 | ### Patch Changes 42 | 43 | - 78ad2b2: Organize wayfinder settings, update defaults for routing and verification 44 | 45 | ## 0.0.4-alpha.0 46 | 47 | ### Patch Changes 48 | 49 | - 7c81839: Updated wayfinder configurations 50 | 51 | ## 0.0.3 52 | 53 | ### Patch Changes 54 | 55 | - 53613fb: Fix router logic when handling input as ar:// 56 | - c12a8f8: added signature verification strategies and fixed hash algo for browser compatibility 57 | - 45d2884: added signature verification strategies and fixed hash algo for browser compatibility 58 | - 8e7facb: Publish new alpha for roam 59 | - 2605cdb: Added signature verification stratgies and modified hashing to browser compatible sha256. 60 | - d431437: Publish sha256 change with actual builds! 61 | - 1ceb8df: Import crypto default for browser compatiability with polyfills' 62 | - 2109250: Publishing new alpha with updated sha256 63 | 64 | ## 0.0.3-alpha.6 65 | 66 | ### Patch Changes 67 | 68 | - 1ceb8df: Import crypto default for browser compatiability with polyfills' 69 | 70 | ## 0.0.3-alpha.5 71 | 72 | ### Patch Changes 73 | 74 | - 53613fb: Fix router logic when handling input as ar:// 75 | 76 | ## 0.0.3-alpha.4 77 | 78 | ### Patch Changes 79 | 80 | - 8e7facb: Publish new alpha for roam 81 | 82 | ## 0.0.3-alpha.3 83 | 84 | ### Patch Changes 85 | 86 | - d431437: Publish sha256 change with actual builds! 87 | 88 | ## 0.0.3-alpha.2 89 | 90 | ### Patch Changes 91 | 92 | - 2109250: Publishing new alpha with updated sha256 93 | 94 | ## 0.0.3-alpha.1 95 | 96 | ### Patch Changes 97 | 98 | - c12a8f8: added signature verification strategies and fixed hash algo for browser compatibility 99 | - 2605cdb: Added signature verification stratgies and modified hashing to browser compatible sha256. 100 | 101 | ## 0.0.3-beta.0 102 | 103 | ### Patch Changes 104 | 105 | - 45d2884: added signature verification strategies and fixed hash algo for browser compatibility 106 | 107 | ## 0.0.2 108 | 109 | ### Patch Changes 110 | 111 | - updated build outputs 112 | 113 | ## 0.0.2-beta.0 114 | 115 | ### Patch Changes 116 | 117 | - Update LICENSE and package.json 118 | 119 | ## 0.0.2 120 | 121 | ### Patch Changes 122 | 123 | - init standalone wayfinder-core package 124 | -------------------------------------------------------------------------------- /packages/core/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@ar.io/wayfinder-core", 3 | "version": "0.0.5-alpha.4", 4 | "description": "WayFinder core library for intelligently routing to optimal AR.IO gateways", 5 | "main": "dist/index.js", 6 | "module": "dist/index.js", 7 | "types": "dist/index.d.ts", 8 | "type": "module", 9 | "license": "Apache-2.0", 10 | "keywords": ["ar-io", "arweave", "wayfinder", "ar://"], 11 | "author": { 12 | "name": "Permanent Data Solutions Inc", 13 | "email": "info@ar.io", 14 | "website": "https://ar.io" 15 | }, 16 | "publishConfig": { 17 | "access": "public" 18 | }, 19 | "files": ["dist", "package.json", "README.md", "LICENSE"], 20 | "exports": { 21 | ".": { 22 | "types": "./dist/index.d.ts", 23 | "import": "./dist/index.js" 24 | } 25 | }, 26 | "repository": { 27 | "type": "git", 28 | "url": "git+https://github.com/ar-io/wayfinder.git" 29 | }, 30 | "scripts": { 31 | "build": "npm run clean && tsc", 32 | "clean": "rimraf dist", 33 | "test": "npm run test:unit", 34 | "test:unit": "c8 tsx --test 'src/**/*.test.ts'", 35 | "lint:fix": "biome check --write --unsafe --config-path=../../biome.json", 36 | "lint:check": "biome check --unsafe --config-path=../../biome.json", 37 | "format:fix": "biome format --write --config-path=../../biome.json", 38 | "format:check": "biome format --config-path=../../biome.json" 39 | }, 40 | "dependencies": { 41 | "@dha-team/arbundles": "^1.0.3", 42 | "@opentelemetry/api": "^1.9.0", 43 | "@opentelemetry/exporter-trace-otlp-http": "^0.202.0", 44 | "@opentelemetry/sdk-trace-base": "^2.0.1", 45 | "@opentelemetry/sdk-trace-node": "^2.0.1", 46 | "@opentelemetry/sdk-trace-web": "^2.0.1", 47 | "arweave": "^1.14.0", 48 | "eventemitter3": "^5.0.1", 49 | "plimit-lit": "^3.0.1", 50 | "rfc4648": "^1.5.4" 51 | }, 52 | "peerDependencies": { 53 | "@ar.io/sdk": ">=3.12.0" 54 | }, 55 | "devDependencies": { 56 | "@ar.io/sdk": "^3.13.0", 57 | "@types/node": "^24.0.0", 58 | "tsx": "^4.20.3" 59 | } 60 | } 61 | -------------------------------------------------------------------------------- /packages/core/src/classifiers/gql-classifier.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * WayFinder 3 | * Copyright (C) 2022-2025 Permanent Data Solutions, Inc. 4 | * 5 | * Licensed under the Apache License, Version 2.0 (the "License"); 6 | * you may not use this file except in compliance with the License. 7 | * You may obtain a copy of the License at 8 | * 9 | * http://www.apache.org/licenses/LICENSE-2.0 10 | * 11 | * Unless required by applicable law or agreed to in writing, software 12 | * distributed under the License is distributed on an "AS IS" BASIS, 13 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 14 | * See the License for the specific language governing permissions and 15 | * limitations under the License. 16 | */ 17 | import { defaultLogger } from '../logger.js'; 18 | /** 19 | * WayFinder 20 | * Copyright (C) 2022-2025 Permanent Data Solutions, Inc. 21 | * 22 | * Licensed under the Apache License, Version 2.0 (the "License"); 23 | * you may not use this file except in compliance with the License. 24 | * You may obtain a copy of the License at 25 | * 26 | * http://www.apache.org/licenses/LICENSE-2.0 27 | * 28 | * Unless required by applicable law or agreed to in writing, software 29 | * distributed under the License is distributed on an "AS IS" BASIS, 30 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 31 | * See the License for the specific language governing permissions and 32 | * limitations under the License. 33 | */ 34 | import type { DataClassifier, Logger } from '../types.js'; 35 | 36 | export class GqlClassifier implements DataClassifier { 37 | private readonly gqlEndpoint: URL; 38 | private readonly logger: Logger; 39 | 40 | constructor({ 41 | gqlEndpoint = 'https://arweave-search.goldsky.com/graphql', 42 | logger = defaultLogger, 43 | }: { 44 | gqlEndpoint?: string; 45 | logger?: Logger; 46 | } = {}) { 47 | this.gqlEndpoint = new URL(gqlEndpoint); 48 | this.logger = logger; 49 | } 50 | 51 | async classify({ 52 | txId, 53 | }: { txId: string }): Promise<'ans104' | 'transaction'> { 54 | const response = await fetch(this.gqlEndpoint.toString(), { 55 | method: 'POST', 56 | headers: { 57 | 'Content-Type': 'application/json', 58 | }, 59 | body: JSON.stringify({ 60 | query: ` 61 | query GetTransaction($id: ID!) { 62 | transactions(ids: [$id]) { 63 | edges { 64 | node { 65 | bundledIn { 66 | id 67 | } 68 | } 69 | } 70 | } 71 | } 72 | `, 73 | variables: { 74 | id: txId, 75 | }, 76 | }), 77 | }); 78 | 79 | if (!response.ok) { 80 | this.logger.debug('Failed to fetch transaction from GraphQL', { 81 | txId, 82 | status: response.status, 83 | }); 84 | return 'transaction'; 85 | } 86 | 87 | const result = await response.json(); 88 | const bundledIn = result.data?.transaction?.bundledIn; 89 | 90 | return bundledIn ? 'ans104' : 'transaction'; 91 | } 92 | } 93 | -------------------------------------------------------------------------------- /packages/core/src/gateways/network.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * WayFinder 3 | * Copyright (C) 2022-2025 Permanent Data Solutions, Inc. 4 | * 5 | * Licensed under the Apache License, Version 2.0 (the "License"); 6 | * you may not use this file except in compliance with the License. 7 | * You may obtain a copy of the License at 8 | * 9 | * http://www.apache.org/licenses/LICENSE-2.0 10 | * 11 | * Unless required by applicable law or agreed to in writing, software 12 | * distributed under the License is distributed on an "AS IS" BASIS, 13 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 14 | * See the License for the specific language governing permissions and 15 | * limitations under the License. 16 | */ 17 | import type { AoARIORead } from '@ar.io/sdk'; 18 | import { defaultLogger } from '../logger.js'; 19 | import type { GatewaysProvider, Logger } from '../types.js'; 20 | 21 | export class NetworkGatewaysProvider implements GatewaysProvider { 22 | private ario: AoARIORead; 23 | private sortBy: 'totalDelegatedStake' | 'operatorStake' | 'startTimestamp'; 24 | private sortOrder: 'asc' | 'desc'; 25 | private limit: number; 26 | private filter: (gateway: any) => boolean; 27 | private logger: Logger; 28 | 29 | constructor({ 30 | ario, 31 | sortBy = 'operatorStake', 32 | sortOrder = 'desc', 33 | limit = 1000, 34 | filter = (g) => g.status === 'joined', 35 | logger = defaultLogger, 36 | }: { 37 | ario: AoARIORead; 38 | sortBy?: 'totalDelegatedStake' | 'operatorStake' | 'startTimestamp'; 39 | sortOrder?: 'asc' | 'desc'; 40 | limit?: number; 41 | blocklist?: string[]; 42 | filter?: (gateway: any) => boolean; 43 | logger?: Logger; 44 | }) { 45 | this.ario = ario; 46 | this.sortBy = sortBy; 47 | this.sortOrder = sortOrder; 48 | this.limit = limit; 49 | this.filter = filter; 50 | this.logger = logger; 51 | } 52 | 53 | async getGateways(): Promise { 54 | let cursor: string | undefined; 55 | let attempts = 0; 56 | const gateways: any[] = []; 57 | 58 | this.logger.debug('Starting to fetch gateways from AR.IO network', { 59 | sortBy: this.sortBy, 60 | sortOrder: this.sortOrder, 61 | limit: this.limit, 62 | }); 63 | 64 | do { 65 | try { 66 | this.logger.debug('Fetching gateways batch', { cursor, attempts }); 67 | 68 | const { items: newGateways = [], nextCursor } = 69 | await this.ario.getGateways({ 70 | limit: 1000, 71 | cursor, 72 | sortBy: this.sortBy, 73 | sortOrder: this.sortOrder, 74 | }); 75 | 76 | gateways.push(...newGateways); 77 | cursor = nextCursor; 78 | attempts = 0; // reset attempts if we get a new cursor 79 | 80 | this.logger.debug('Fetched gateways batch', { 81 | batchSize: newGateways.length, 82 | totalFetched: gateways.length, 83 | nextCursor: cursor, 84 | }); 85 | } catch (error: any) { 86 | this.logger.error('Error fetching gateways', { 87 | cursor, 88 | attempts, 89 | error: error.message, 90 | stack: error.stack, 91 | }); 92 | attempts++; 93 | } 94 | } while (cursor !== undefined && attempts < 3); 95 | 96 | // filter out any gateways that are not joined 97 | const filteredGateways = gateways.filter(this.filter).slice(0, this.limit); 98 | 99 | this.logger.debug('Finished fetching gateways', { 100 | totalFetched: gateways.length, 101 | filteredCount: filteredGateways.length, 102 | }); 103 | 104 | return filteredGateways.map( 105 | (g) => 106 | new URL( 107 | `${g.settings.protocol}://${g.settings.fqdn}:${g.settings.port}`, 108 | ), 109 | ); 110 | } 111 | } 112 | -------------------------------------------------------------------------------- /packages/core/src/gateways/simple-cache.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * WayFinder 3 | * Copyright (C) 2022-2025 Permanent Data Solutions, Inc. 4 | * 5 | * Licensed under the Apache License, Version 2.0 (the "License"); 6 | * you may not use this file except in compliance with the License. 7 | * You may obtain a copy of the License at 8 | * 9 | * http://www.apache.org/licenses/LICENSE-2.0 10 | * 11 | * Unless required by applicable law or agreed to in writing, software 12 | * distributed under the License is distributed on an "AS IS" BASIS, 13 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 14 | * See the License for the specific language governing permissions and 15 | * limitations under the License. 16 | */ 17 | import { defaultLogger } from '../logger.js'; 18 | /** 19 | * WayFinder 20 | * Copyright (C) 2022-2025 Permanent Data Solutions, Inc. 21 | * 22 | * Licensed under the Apache License, Version 2.0 (the "License"); 23 | * you may not use this file except in compliance with the License. 24 | * You may obtain a copy of the License at 25 | * 26 | * http://www.apache.org/licenses/LICENSE-2.0 27 | * 28 | * Unless required by applicable law or agreed to in writing, software 29 | * distributed under the License is distributed on an "AS IS" BASIS, 30 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 31 | * See the License for the specific language governing permissions and 32 | * limitations under the License. 33 | */ 34 | import type { GatewaysProvider, Logger } from '../types.js'; 35 | 36 | export class SimpleCacheGatewaysProvider implements GatewaysProvider { 37 | private gatewaysProvider: GatewaysProvider; 38 | private ttlSeconds: number; 39 | private lastUpdated: number; 40 | private gatewaysCache: URL[]; 41 | private logger: Logger; 42 | 43 | constructor({ 44 | gatewaysProvider, 45 | ttlSeconds = 60 * 60, // 1 hour 46 | logger = defaultLogger, 47 | }: { 48 | gatewaysProvider: GatewaysProvider; 49 | ttlSeconds?: number; 50 | logger?: Logger; 51 | }) { 52 | this.gatewaysCache = []; 53 | this.gatewaysProvider = gatewaysProvider; 54 | this.ttlSeconds = ttlSeconds; 55 | this.lastUpdated = 0; 56 | this.logger = logger; 57 | } 58 | 59 | async getGateways(params?: { path?: string; subdomain?: string }): Promise< 60 | URL[] 61 | > { 62 | const now = Date.now(); 63 | if ( 64 | this.gatewaysCache.length === 0 || 65 | now - this.lastUpdated > this.ttlSeconds * 1000 66 | ) { 67 | try { 68 | this.logger.debug('Cache expired, fetching new gateways', { 69 | cacheAge: now - this.lastUpdated, 70 | ttlSeconds: this.ttlSeconds, 71 | }); 72 | 73 | // preserve the cache if the fetch fails 74 | const allGateways = await this.gatewaysProvider.getGateways(params); 75 | this.gatewaysCache = allGateways; 76 | this.lastUpdated = now; 77 | 78 | this.logger.debug('Updated gateways cache', { 79 | gatewayCount: allGateways.length, 80 | }); 81 | } catch (error: any) { 82 | this.logger.error('Failed to fetch gateways', { 83 | error: error.message, 84 | stack: error.stack, 85 | }); 86 | } 87 | } else { 88 | this.logger.debug('Using cached gateways', { 89 | cacheAge: now - this.lastUpdated, 90 | ttlSeconds: this.ttlSeconds, 91 | gatewayCount: this.gatewaysCache.length, 92 | }); 93 | } 94 | return this.gatewaysCache; 95 | } 96 | } 97 | -------------------------------------------------------------------------------- /packages/core/src/gateways/static.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * WayFinder 3 | * Copyright (C) 2022-2025 Permanent Data Solutions, Inc. 4 | * 5 | * Licensed under the Apache License, Version 2.0 (the "License"); 6 | * you may not use this file except in compliance with the License. 7 | * You may obtain a copy of the License at 8 | * 9 | * http://www.apache.org/licenses/LICENSE-2.0 10 | * 11 | * Unless required by applicable law or agreed to in writing, software 12 | * distributed under the License is distributed on an "AS IS" BASIS, 13 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 14 | * See the License for the specific language governing permissions and 15 | * limitations under the License. 16 | */ 17 | import type { GatewaysProvider } from '../types.js'; 18 | 19 | export class StaticGatewaysProvider implements GatewaysProvider { 20 | private gateways: URL[]; 21 | constructor({ gateways }: { gateways: string[] }) { 22 | this.gateways = gateways.map((g) => new URL(g)); 23 | } 24 | 25 | async getGateways(): Promise { 26 | return this.gateways; 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /packages/core/src/index.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * WayFinder 3 | * Copyright (C) 2022-2025 Permanent Data Solutions, Inc. 4 | * 5 | * Licensed under the Apache License, Version 2.0 (the "License"); 6 | * you may not use this file except in compliance with the License. 7 | * You may obtain a copy of the License at 8 | * 9 | * http://www.apache.org/licenses/LICENSE-2.0 10 | * 11 | * Unless required by applicable law or agreed to in writing, software 12 | * distributed under the License is distributed on an "AS IS" BASIS, 13 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 14 | * See the License for the specific language governing permissions and 15 | * limitations under the License. 16 | */ 17 | 18 | // types 19 | export * from './types.js'; 20 | 21 | // routing strategies 22 | export * from './routing/random.js'; 23 | export * from './routing/static.js'; 24 | export * from './routing/ping.js'; 25 | export * from './routing/round-robin.js'; 26 | export * from './routing/preferred-with-fallback.js'; 27 | export * from './routing/simple-cache.js'; 28 | 29 | // gateways providers 30 | export * from './gateways/network.js'; 31 | export * from './gateways/simple-cache.js'; 32 | export * from './gateways/static.js'; 33 | 34 | // verification strategies 35 | export * from './verification/data-root-verifier.js'; 36 | export * from './verification/hash-verifier.js'; 37 | export * from './verification/signature-verifier.js'; 38 | 39 | // wayfinder 40 | // TODO: consider exporting just Wayfinder and move all the types to the types folder 41 | export * from './wayfinder.js'; 42 | -------------------------------------------------------------------------------- /packages/core/src/logger.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * WayFinder 3 | * Copyright (C) 2022-2025 Permanent Data Solutions, Inc. 4 | * 5 | * Licensed under the Apache License, Version 2.0 (the "License"); 6 | * you may not use this file except in compliance with the License. 7 | * You may obtain a copy of the License at 8 | * 9 | * http://www.apache.org/licenses/LICENSE-2.0 10 | * 11 | * Unless required by applicable law or agreed to in writing, software 12 | * distributed under the License is distributed on an "AS IS" BASIS, 13 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 14 | * See the License for the specific language governing permissions and 15 | * limitations under the License. 16 | */ 17 | 18 | import type { Logger } from './types.js'; 19 | 20 | /** 21 | * Default console logger implementation 22 | */ 23 | export const defaultLogger: Logger = { 24 | debug: console.debug, 25 | info: console.info, 26 | warn: console.warn, 27 | error: console.error, 28 | }; 29 | -------------------------------------------------------------------------------- /packages/core/src/routing/ping.test.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * WayFinder 3 | * Copyright (C) 2022-2025 Permanent Data Solutions, Inc. 4 | * 5 | * Licensed under the Apache License, Version 2.0 (the "License"); 6 | * you may not use this file except in compliance with the License. 7 | * You may obtain a copy of the License at 8 | * 9 | * http://www.apache.org/licenses/LICENSE-2.0 10 | * 11 | * Unless required by applicable law or agreed to in writing, software 12 | * distributed under the License is distributed on an "AS IS" BASIS, 13 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 14 | * See the License for the specific language governing permissions and 15 | * limitations under the License. 16 | */ 17 | import assert from 'node:assert/strict'; 18 | import { afterEach, beforeEach, describe, it } from 'node:test'; 19 | 20 | import { FastestPingRoutingStrategy } from './ping.js'; 21 | 22 | describe('FastestPingRoutingStrategy', () => { 23 | // Original fetch function 24 | const originalFetch = global.fetch; 25 | 26 | // Mock response options for each gateway 27 | const mockResponses = new Map(); 28 | 29 | beforeEach(() => { 30 | // reset mock responses 31 | mockResponses.clear(); 32 | 33 | // mock fetch to simulate network latency and response status 34 | // @ts-expect-error - we're mocking the fetch function 35 | global.fetch = async (url: string | URL) => { 36 | const urlString = url.toString(); 37 | 38 | // find the matching gateway 39 | let matchingGateway = ''; 40 | for (const gateway of mockResponses.keys()) { 41 | if (urlString.startsWith(gateway)) { 42 | matchingGateway = gateway; 43 | break; 44 | } 45 | } 46 | 47 | if (!matchingGateway) { 48 | return Promise.reject( 49 | new Error(`No mock response for URL: ${urlString}`), 50 | ); 51 | } 52 | 53 | const { status, delayMs } = mockResponses.get(matchingGateway)!; 54 | 55 | // simulate network delay 56 | await new Promise((resolve) => setTimeout(resolve, delayMs)); 57 | 58 | return new Response(null, { status }); 59 | }; 60 | 61 | // mock AbortSignal.timeout 62 | if (!AbortSignal.timeout) { 63 | (AbortSignal as any).timeout = (ms: number) => { 64 | const controller = new AbortController(); 65 | setTimeout(() => controller.abort(), ms); 66 | return controller.signal; 67 | }; 68 | } 69 | }); 70 | 71 | // restore original fetch after tests 72 | afterEach(() => { 73 | global.fetch = originalFetch; 74 | }); 75 | 76 | it('selects the gateway with the lowest latency', async () => { 77 | const gateways = [ 78 | new URL('https://slow.com'), 79 | new URL('https://fast.com'), 80 | new URL('https://medium.com'), 81 | ]; 82 | 83 | // configure mock responses 84 | mockResponses.set('https://slow.com', { status: 200, delayMs: 300 }); 85 | mockResponses.set('https://fast.com', { status: 200, delayMs: 50 }); 86 | mockResponses.set('https://medium.com', { status: 200, delayMs: 150 }); 87 | 88 | const strategy = new FastestPingRoutingStrategy({ timeoutMs: 500 }); 89 | 90 | // select the gateway with the lowest latency 91 | const selectedGateway = await strategy.selectGateway({ 92 | gateways, 93 | }); 94 | 95 | assert.equal( 96 | selectedGateway.toString(), 97 | 'https://fast.com/', 98 | 'Should select the gateway with the lowest latency', 99 | ); 100 | }); 101 | 102 | // test for subdomain 103 | it('selects the gateway with the lowest latency for a subdomain', async () => { 104 | const gateways = [ 105 | new URL('https://slow.com'), 106 | new URL('https://fast.com'), 107 | new URL('https://medium.com'), 108 | ]; 109 | 110 | // configure mock responses 111 | mockResponses.set('https://subdomain.slow.com', { 112 | status: 200, 113 | delayMs: 300, 114 | }); 115 | mockResponses.set('https://subdomain.fast.com', { 116 | status: 200, 117 | delayMs: 50, 118 | }); 119 | mockResponses.set('https://subdomain.medium.com', { 120 | status: 200, 121 | delayMs: 150, 122 | }); 123 | 124 | const strategy = new FastestPingRoutingStrategy({ timeoutMs: 500 }); 125 | 126 | // select the gateway with the lowest latency 127 | const selectedGateway = await strategy.selectGateway({ 128 | gateways, 129 | subdomain: 'subdomain', 130 | }); 131 | 132 | assert.equal( 133 | selectedGateway.toString(), 134 | 'https://fast.com/', 135 | 'Should select the gateway with the lowest latency for a subdomain', 136 | ); 137 | }); 138 | 139 | it('selects the gateway with the lowest latency for a path', async () => { 140 | const gateways = [ 141 | new URL('https://slow.com'), 142 | new URL('https://fast.com'), 143 | new URL('https://medium.com'), 144 | ]; 145 | 146 | // configure mock responses 147 | mockResponses.set('https://slow.com/path', { status: 200, delayMs: 300 }); 148 | mockResponses.set('https://fast.com/path', { status: 200, delayMs: 50 }); 149 | mockResponses.set('https://medium.com/path', { status: 200, delayMs: 150 }); 150 | 151 | const strategy = new FastestPingRoutingStrategy({ timeoutMs: 500 }); 152 | 153 | // select the gateway with the lowest latency 154 | const selectedGateway = await strategy.selectGateway({ 155 | gateways, 156 | path: '/path', 157 | }); 158 | 159 | assert.equal( 160 | selectedGateway.toString(), 161 | 'https://fast.com/', 162 | 'Should select the gateway with the lowest latency for a path', 163 | ); 164 | }); 165 | 166 | it('ignores gateways that return non-200 status codes', async () => { 167 | const gateways = [ 168 | new URL('https://error.com'), 169 | new URL('https://success.com'), 170 | new URL('https://another-error.com'), 171 | ]; 172 | 173 | // configure mock responses 174 | mockResponses.set('https://error.com', { status: 404, delayMs: 50 }); 175 | mockResponses.set('https://success.com', { status: 200, delayMs: 100 }); 176 | mockResponses.set('https://another-error.com', { 177 | status: 500, 178 | delayMs: 75, 179 | }); 180 | 181 | const strategy = new FastestPingRoutingStrategy({ timeoutMs: 500 }); 182 | 183 | // select the gateway with the lowest latency 184 | const selectedGateway = await strategy.selectGateway({ 185 | gateways, 186 | }); 187 | 188 | assert.equal( 189 | selectedGateway.toString(), 190 | 'https://success.com/', 191 | 'Should select the gateway that returns a 200 status code', 192 | ); 193 | }); 194 | 195 | it('throws an error when all gateways fail', async () => { 196 | const gateways = [ 197 | new URL('https://error1.com'), 198 | new URL('https://error2.com'), 199 | ]; 200 | 201 | // configure mock responses 202 | mockResponses.set('https://error1.com', { status: 404, delayMs: 50 }); 203 | mockResponses.set('https://error2.com', { status: 500, delayMs: 75 }); 204 | 205 | const strategy = new FastestPingRoutingStrategy({ timeoutMs: 500 }); 206 | 207 | // console.log(await strategy.selectGateway({ gateways }), 'test'); 208 | 209 | // select the gateway with the lowest latency 210 | await assert.rejects( 211 | async () => await strategy.selectGateway({ gateways }), 212 | 'Should throw an error when all gateways fail', 213 | ); 214 | }); 215 | 216 | it('handles network errors gracefully', async () => { 217 | const gateways = [ 218 | new URL('https://network-error.com'), 219 | new URL('https://success.com'), 220 | ]; 221 | 222 | // configure mock responses 223 | mockResponses.set('https://success.com', { status: 200, delayMs: 100 }); 224 | 225 | // override fetch for the network error case 226 | const originalFetchMock = global.fetch; 227 | // @ts-expect-error - we're mocking the fetch function 228 | global.fetch = async (url: string | URL) => { 229 | if (url.toString().includes('network-error')) { 230 | throw new Error('Network error'); 231 | } 232 | return originalFetchMock(url); 233 | }; 234 | 235 | const strategy = new FastestPingRoutingStrategy({ timeoutMs: 500 }); 236 | 237 | // select the gateway with the lowest latency 238 | const selectedGateway = await strategy.selectGateway({ 239 | gateways, 240 | }); 241 | 242 | assert.equal( 243 | selectedGateway.toString(), 244 | 'https://success.com/', 245 | 'Should handle network errors and select the working gateway', 246 | ); 247 | }); 248 | 249 | it('respects the timeout parameter', async () => { 250 | const gateways = [ 251 | new URL('https://timeout.com'), 252 | new URL('https://fast.com'), 253 | ]; 254 | 255 | // configure mock responses 256 | mockResponses.set('https://timeout.com', { status: 200, delayMs: 300 }); 257 | mockResponses.set('https://fast.com', { status: 200, delayMs: 50 }); 258 | 259 | // set a short timeout 260 | const strategy = new FastestPingRoutingStrategy({ timeoutMs: 100 }); 261 | 262 | const selectedGateway = await strategy.selectGateway({ 263 | gateways, 264 | }); 265 | 266 | assert.equal( 267 | selectedGateway.toString(), 268 | 'https://fast.com/', 269 | 'Should respect the timeout and select only gateways that respond within the timeout', 270 | ); 271 | }); 272 | 273 | it('throws an error when no gateways are provided', async () => { 274 | const gateways: URL[] = []; 275 | const strategy = new FastestPingRoutingStrategy(); 276 | 277 | // select the gateway with the lowest latency 278 | await assert.rejects( 279 | async () => await strategy.selectGateway({ gateways }), 280 | /No gateways provided/, 281 | 'Should throw an error when no gateways are provided', 282 | ); 283 | }); 284 | }); 285 | -------------------------------------------------------------------------------- /packages/core/src/routing/ping.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * WayFinder 3 | * Copyright (C) 2022-2025 Permanent Data Solutions, Inc. 4 | * 5 | * Licensed under the Apache License, Version 2.0 (the "License"); 6 | * you may not use this file except in compliance with the License. 7 | * You may obtain a copy of the License at 8 | * 9 | * http://www.apache.org/licenses/LICENSE-2.0 10 | * 11 | * Unless required by applicable law or agreed to in writing, software 12 | * distributed under the License is distributed on an "AS IS" BASIS, 13 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 14 | * See the License for the specific language governing permissions and 15 | * limitations under the License. 16 | */ 17 | import { pLimit } from 'plimit-lit'; 18 | import { defaultLogger } from '../logger.js'; 19 | import type { Logger, RoutingStrategy } from '../types.js'; 20 | 21 | export class FastestPingRoutingStrategy implements RoutingStrategy { 22 | private timeoutMs: number; 23 | private logger: Logger; 24 | private maxConcurrency: number; 25 | 26 | constructor({ 27 | timeoutMs = 500, 28 | maxConcurrency = 50, 29 | logger = defaultLogger, 30 | }: { 31 | timeoutMs?: number; 32 | maxConcurrency?: number; 33 | logger?: Logger; 34 | } = {}) { 35 | this.timeoutMs = timeoutMs; 36 | this.logger = logger; 37 | this.maxConcurrency = maxConcurrency; 38 | } 39 | 40 | async selectGateway({ 41 | gateways, 42 | path = '', 43 | subdomain, 44 | }: { 45 | gateways: URL[]; 46 | path?: string; 47 | subdomain?: string; 48 | }): Promise { 49 | if (gateways.length === 0) { 50 | const error = new Error('No gateways provided'); 51 | this.logger.error('Failed to select gateway', { error: error.message }); 52 | throw error; 53 | } 54 | 55 | this.logger.debug( 56 | `Pinging ${gateways.length} gateways with timeout ${this.timeoutMs}ms`, 57 | { 58 | gateways: gateways.map((g) => g.toString()), 59 | timeoutMs: this.timeoutMs, 60 | probePath: path, 61 | }, 62 | ); 63 | 64 | const throttle = pLimit(Math.min(this.maxConcurrency, gateways.length)); 65 | const pingPromises = gateways.map( 66 | async (gateway): Promise<{ gateway: URL; durationMs: number }> => { 67 | return throttle(async () => { 68 | const url = new URL(gateway.toString()); 69 | if (subdomain) { 70 | url.hostname = `${subdomain}.${url.hostname}`; 71 | } 72 | const pingUrl = new URL(path.replace(/^\//, ''), url).toString(); 73 | 74 | this.logger.debug(`Pinging gateway ${gateway.toString()}`, { 75 | gateway: gateway.toString(), 76 | pingUrl, 77 | }); 78 | 79 | const startTime = Date.now(); 80 | const response = await fetch(pingUrl, { 81 | method: 'HEAD', 82 | signal: AbortSignal.timeout(this.timeoutMs), 83 | }); 84 | 85 | if (response.ok) { 86 | // clear the queue to prevent the next gateway from being pinged 87 | throttle.clearQueue(); 88 | return { gateway, durationMs: Date.now() - startTime }; 89 | } 90 | 91 | throw new Error('Failed to ping gateway', { 92 | cause: { 93 | gateway: gateway.toString(), 94 | path: path, 95 | status: response.status, 96 | }, 97 | }); 98 | }); 99 | }, 100 | ); 101 | 102 | const { gateway, durationMs } = await Promise.any(pingPromises); 103 | 104 | this.logger.debug('Successfully selected fastest gateway', { 105 | gateway: gateway.toString(), 106 | durationMs, 107 | }); 108 | 109 | return gateway; 110 | } 111 | } 112 | -------------------------------------------------------------------------------- /packages/core/src/routing/preferred-with-fallback.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * WayFinder 3 | * Copyright (C) 2022-2025 Permanent Data Solutions, Inc. 4 | * 5 | * Licensed under the Apache License, Version 2.0 (the "License"); 6 | * you may not use this file except in compliance with the License. 7 | * You may obtain a copy of the License at 8 | * 9 | * http://www.apache.org/licenses/LICENSE-2.0 10 | * 11 | * Unless required by applicable law or agreed to in writing, software 12 | * distributed under the License is distributed on an "AS IS" BASIS, 13 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 14 | * See the License for the specific language governing permissions and 15 | * limitations under the License. 16 | */ 17 | 18 | import { defaultLogger } from '../logger.js'; 19 | import type { Logger, RoutingStrategy } from '../types.js'; 20 | import { FastestPingRoutingStrategy } from './ping.js'; 21 | 22 | export class PreferredWithFallbackRoutingStrategy implements RoutingStrategy { 23 | public readonly name = 'preferred-with-fallback'; 24 | private preferredGateway: URL; 25 | private fallbackStrategy: RoutingStrategy; 26 | private logger: Logger; 27 | 28 | constructor({ 29 | preferredGateway, 30 | fallbackStrategy = new FastestPingRoutingStrategy(), 31 | logger = defaultLogger, 32 | }: { 33 | preferredGateway: string; 34 | fallbackStrategy?: RoutingStrategy; 35 | logger?: Logger; 36 | }) { 37 | this.logger = logger; 38 | this.fallbackStrategy = fallbackStrategy; 39 | this.preferredGateway = new URL(preferredGateway); 40 | } 41 | 42 | async selectGateway({ 43 | gateways = [], 44 | path = '', 45 | subdomain, 46 | }: { 47 | gateways: URL[]; 48 | path?: string; 49 | subdomain?: string; 50 | }): Promise { 51 | this.logger.debug('Attempting to connect to preferred gateway', { 52 | preferredGateway: this.preferredGateway.toString(), 53 | }); 54 | 55 | try { 56 | // Check if the preferred gateway is responsive 57 | const url = new URL(this.preferredGateway.toString()); 58 | if (subdomain) { 59 | url.hostname = `${subdomain}.${url.hostname}`; 60 | } 61 | const probeUrl = path ? new URL(path.replace(/^\//, ''), url) : url; 62 | const response = await fetch(probeUrl.toString(), { 63 | method: 'HEAD', 64 | signal: AbortSignal.timeout(1000), 65 | }); 66 | 67 | if (response.ok) { 68 | this.logger.debug('Successfully connected to preferred gateway', { 69 | preferredGateway: this.preferredGateway.toString(), 70 | }); 71 | return this.preferredGateway; 72 | } 73 | 74 | throw new Error( 75 | `Preferred gateway responded with status: ${response.status}`, 76 | ); 77 | } catch (error) { 78 | this.logger.warn( 79 | 'Failed to connect to preferred gateway, falling back to alternative strategy', 80 | { 81 | preferredGateway: this.preferredGateway.toString(), 82 | error: error instanceof Error ? error.message : String(error), 83 | fallbackStrategy: this.fallbackStrategy.constructor.name, 84 | }, 85 | ); 86 | 87 | // Fall back to the provided routing strategy 88 | return this.fallbackStrategy.selectGateway({ 89 | gateways, 90 | path, 91 | subdomain, 92 | }); 93 | } 94 | } 95 | } 96 | -------------------------------------------------------------------------------- /packages/core/src/routing/random.test.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * WayFinder 3 | * Copyright (C) 2022-2025 Permanent Data Solutions, Inc. 4 | * 5 | * Licensed under the Apache License, Version 2.0 (the "License"); 6 | * you may not use this file except in compliance with the License. 7 | * You may obtain a copy of the License at 8 | * 9 | * http://www.apache.org/licenses/LICENSE-2.0 10 | * 11 | * Unless required by applicable law or agreed to in writing, software 12 | * distributed under the License is distributed on an "AS IS" BASIS, 13 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 14 | * See the License for the specific language governing permissions and 15 | * limitations under the License. 16 | */ 17 | import assert from 'node:assert/strict'; 18 | import { describe, it } from 'node:test'; 19 | 20 | import { RandomRoutingStrategy } from './random.js'; 21 | 22 | describe('RandomRoutingStrategy', () => { 23 | it('selects a gateway from the provided list', async () => { 24 | // Arrange 25 | const gateways = [ 26 | new URL('https://example1.com'), 27 | new URL('https://example2.com'), 28 | new URL('https://example3.com'), 29 | ]; 30 | const strategy = new RandomRoutingStrategy(); 31 | const selectedGateway = await strategy.selectGateway({ gateways }); 32 | assert.ok( 33 | gateways.includes(selectedGateway), 34 | 'The selected gateway should be one of the gateways provided', 35 | ); 36 | }); 37 | 38 | it('throws error when no gateways are provided', async () => { 39 | const gateways: URL[] = []; 40 | const strategy = new RandomRoutingStrategy(); 41 | await assert.rejects( 42 | async () => await strategy.selectGateway({ gateways }), 43 | /No gateways available/, 44 | 'Should throw an error when no gateways are provided', 45 | ); 46 | }); 47 | 48 | it('should distribute gateway selection somewhat randomly', async () => { 49 | const gateways = [ 50 | new URL('https://example1.com'), 51 | new URL('https://example2.com'), 52 | new URL('https://example3.com'), 53 | new URL('https://example4.com'), 54 | new URL('https://example5.com'), 55 | ]; 56 | const strategy = new RandomRoutingStrategy(); 57 | const selections = new Map(); 58 | 59 | // select gateways multiple times 60 | const iterations = 100; 61 | for (let i = 0; i < iterations; i++) { 62 | const gateway = await strategy.selectGateway({ gateways }); 63 | const key = gateway.toString(); 64 | selections.set(key, (selections.get(key) || 0) + 1); 65 | } 66 | 67 | // each gateway should be selected at least once 68 | for (const gateway of gateways) { 69 | const key = gateway.toString(); 70 | assert.ok( 71 | selections.has(key), 72 | `Gateway ${key} should be selected at least once`, 73 | ); 74 | } 75 | 76 | // no gateway should be selected more than 50% of the time 77 | for (const [key, count] of selections.entries()) { 78 | assert.ok( 79 | count < iterations * 0.5, 80 | `Gateway ${key} was selected ${count} times, which is more than 50% of iterations`, 81 | ); 82 | } 83 | }); 84 | }); 85 | -------------------------------------------------------------------------------- /packages/core/src/routing/random.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * WayFinder 3 | * Copyright (C) 2022-2025 Permanent Data Solutions, Inc. 4 | * 5 | * Licensed under the Apache License, Version 2.0 (the "License"); 6 | * you may not use this file except in compliance with the License. 7 | * You may obtain a copy of the License at 8 | * 9 | * http://www.apache.org/licenses/LICENSE-2.0 10 | * 11 | * Unless required by applicable law or agreed to in writing, software 12 | * distributed under the License is distributed on an "AS IS" BASIS, 13 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 14 | * See the License for the specific language governing permissions and 15 | * limitations under the License. 16 | */ 17 | import type { RoutingStrategy } from '../types.js'; 18 | import { randomInt } from '../utils/random.js'; 19 | 20 | export class RandomRoutingStrategy implements RoutingStrategy { 21 | async selectGateway({ 22 | gateways, 23 | }: { 24 | gateways: URL[]; 25 | path?: string; 26 | subdomain?: string; 27 | }): Promise { 28 | if (gateways.length === 0) { 29 | throw new Error('No gateways available'); 30 | } 31 | return gateways[randomInt(0, gateways.length)]; 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /packages/core/src/routing/round-robin.test.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * WayFinder 3 | * Copyright (C) 2022-2025 Permanent Data Solutions, Inc. 4 | * 5 | * Licensed under the Apache License, Version 2.0 (the "License"); 6 | * you may not use this file except in compliance with the License. 7 | * You may obtain a copy of the License at 8 | * 9 | * http://www.apache.org/licenses/LICENSE-2.0 10 | * 11 | * Unless required by applicable law or agreed to in writing, software 12 | * distributed under the License is distributed on an "AS IS" BASIS, 13 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 14 | * See the License for the specific language governing permissions and 15 | * limitations under the License. 16 | */ 17 | import assert from 'node:assert/strict'; 18 | import { describe, it } from 'node:test'; 19 | 20 | import { RoundRobinRoutingStrategy } from './round-robin.js'; 21 | describe('RoundRobinRoutingStrategy', () => { 22 | it('selects gateways in order and cycles back to the beginning', async () => { 23 | const gateways = [ 24 | new URL('https://example1.com'), 25 | new URL('https://example2.com'), 26 | new URL('https://example3.com'), 27 | ]; 28 | 29 | const strategy = new RoundRobinRoutingStrategy({ gateways }); 30 | 31 | const selection1 = await strategy.selectGateway(); 32 | assert.equal( 33 | selection1.toString(), 34 | gateways[0].toString(), 35 | 'Should select the first gateway first', 36 | ); 37 | 38 | const selection2 = await strategy.selectGateway(); 39 | assert.equal( 40 | selection2.toString(), 41 | gateways[1].toString(), 42 | 'Should select the second gateway second', 43 | ); 44 | 45 | const selection3 = await strategy.selectGateway(); 46 | assert.equal( 47 | selection3.toString(), 48 | gateways[2].toString(), 49 | 'Should select the third gateway third', 50 | ); 51 | 52 | // should cycle back to the first gateway 53 | const selection4 = await strategy.selectGateway(); 54 | assert.equal( 55 | selection4.toString(), 56 | gateways[0].toString(), 57 | 'Should cycle back to the first gateway', 58 | ); 59 | }); 60 | 61 | it('uses the internal list even when a different list is provided', async () => { 62 | const initialGateways = [ 63 | new URL('https://example1.com'), 64 | new URL('https://example2.com'), 65 | ]; 66 | 67 | const newGateways = [ 68 | new URL('https://example3.com'), 69 | new URL('https://example4.com'), 70 | ]; 71 | 72 | const strategy = new RoundRobinRoutingStrategy({ 73 | gateways: initialGateways, 74 | }); 75 | 76 | const selection1 = await strategy.selectGateway({ 77 | gateways: newGateways, 78 | }); 79 | assert.equal( 80 | selection1.toString(), 81 | initialGateways[0].toString(), 82 | 'Should use the internal list even when a different list is provided', 83 | ); 84 | 85 | const selection2 = await strategy.selectGateway({ 86 | gateways: newGateways, 87 | }); 88 | assert.equal( 89 | selection2.toString(), 90 | initialGateways[1].toString(), 91 | 'Should use the internal list even when a different list is provided', 92 | ); 93 | }); 94 | 95 | it('handles a single gateway by returning it repeatedly', async () => { 96 | const gateways = [new URL('https://example1.com')]; 97 | const strategy = new RoundRobinRoutingStrategy({ 98 | gateways, 99 | }); 100 | 101 | const selection1 = await strategy.selectGateway({ 102 | gateways: [new URL('https://example2.com')], 103 | }); 104 | assert.equal( 105 | selection1.toString(), 106 | gateways[0].toString(), 107 | 'Should return the single gateway', 108 | ); 109 | 110 | const selection2 = await strategy.selectGateway({ 111 | gateways: [new URL('https://example2.com')], 112 | }); 113 | assert.equal( 114 | selection2.toString(), 115 | gateways[0].toString(), 116 | 'Should return the single gateway again', 117 | ); 118 | }); 119 | }); 120 | -------------------------------------------------------------------------------- /packages/core/src/routing/round-robin.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * WayFinder 3 | * Copyright (C) 2022-2025 Permanent Data Solutions, Inc. 4 | * 5 | * Licensed under the Apache License, Version 2.0 (the "License"); 6 | * you may not use this file except in compliance with the License. 7 | * You may obtain a copy of the License at 8 | * 9 | * http://www.apache.org/licenses/LICENSE-2.0 10 | * 11 | * Unless required by applicable law or agreed to in writing, software 12 | * distributed under the License is distributed on an "AS IS" BASIS, 13 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 14 | * See the License for the specific language governing permissions and 15 | * limitations under the License. 16 | */ 17 | import { defaultLogger } from '../logger.js'; 18 | /** 19 | * WayFinder 20 | * Copyright (C) 2022-2025 Permanent Data Solutions, Inc. 21 | * 22 | * Licensed under the Apache License, Version 2.0 (the "License"); 23 | * you may not use this file except in compliance with the License. 24 | * You may obtain a copy of the License at 25 | * 26 | * http://www.apache.org/licenses/LICENSE-2.0 27 | * 28 | * Unless required by applicable law or agreed to in writing, software 29 | * distributed under the License is distributed on an "AS IS" BASIS, 30 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 31 | * See the License for the specific language governing permissions and 32 | * limitations under the License. 33 | */ 34 | import type { Logger, RoutingStrategy } from '../types.js'; 35 | 36 | export class RoundRobinRoutingStrategy implements RoutingStrategy { 37 | public readonly name = 'round-robin'; 38 | private gateways: URL[]; 39 | private currentIndex: number; 40 | private logger: Logger; 41 | 42 | constructor({ 43 | gateways, 44 | logger = defaultLogger, 45 | }: { 46 | gateways: URL[]; 47 | logger?: Logger; 48 | }) { 49 | this.gateways = gateways; 50 | this.currentIndex = 0; 51 | this.logger = logger; 52 | } 53 | 54 | // provided gateways are ignored 55 | async selectGateway({ 56 | gateways = [], 57 | }: { 58 | gateways?: URL[]; 59 | path?: string; 60 | subdomain?: string; 61 | } = {}): Promise { 62 | if (gateways.length > 0) { 63 | this.logger.warn( 64 | 'RoundRobinRoutingStrategy does not accept provided gateways. Ignoring provided gateways...', 65 | { 66 | providedGateways: gateways.length, 67 | internalGateways: this.gateways, 68 | }, 69 | ); 70 | } 71 | const gateway = this.gateways[this.currentIndex]; 72 | this.currentIndex = (this.currentIndex + 1) % this.gateways.length; 73 | return gateway; 74 | } 75 | } 76 | -------------------------------------------------------------------------------- /packages/core/src/routing/simple-cache.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * WayFinder 3 | * Copyright (C) 2022-2025 Permanent Data Solutions, Inc. 4 | * 5 | * Licensed under the Apache License, Version 2.0 (the "License"); 6 | * you may not use this file except in compliance with the License. 7 | * You may obtain a copy of the License at 8 | * 9 | * http://www.apache.org/licenses/LICENSE-2.0 10 | * 11 | * Unless required by applicable law or agreed to in writing, software 12 | * distributed under the License is distributed on an "AS IS" BASIS, 13 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 14 | * See the License for the specific language governing permissions and 15 | * limitations under the License. 16 | */ 17 | import { defaultLogger } from '../logger.js'; 18 | /** 19 | * WayFinder 20 | * Copyright (C) 2022-2025 Permanent Data Solutions, Inc. 21 | * 22 | * Licensed under the Apache License, Version 2.0 (the "License"); 23 | * you may not use this file except in compliance with the License. 24 | * You may obtain a copy of the License at 25 | * 26 | * http://www.apache.org/licenses/LICENSE-2.0 27 | * 28 | * Unless required by applicable law or agreed to in writing, software 29 | * distributed under the License is distributed on an "AS IS" BASIS, 30 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 31 | * See the License for the specific language governing permissions and 32 | * limitations under the License. 33 | */ 34 | import type { Logger, RoutingStrategy } from '../types.js'; 35 | 36 | export class SimpleCacheRoutingStrategy implements RoutingStrategy { 37 | public readonly name = 'simple-cache'; 38 | private routingStrategy: RoutingStrategy; 39 | private ttlSeconds: number; 40 | private lastUpdated: number; 41 | private cachedGateway: URL | null; 42 | private logger: Logger; 43 | 44 | constructor({ 45 | routingStrategy, 46 | ttlSeconds = 60 * 60, // 1 hour 47 | logger = defaultLogger, 48 | }: { 49 | routingStrategy: RoutingStrategy; 50 | ttlSeconds?: number; 51 | logger?: Logger; 52 | }) { 53 | this.routingStrategy = routingStrategy; 54 | this.ttlSeconds = ttlSeconds; 55 | this.lastUpdated = 0; 56 | this.cachedGateway = null; 57 | this.logger = logger; 58 | } 59 | 60 | async selectGateway(params: { 61 | gateways: URL[]; 62 | path?: string; 63 | subdomain?: string; 64 | }): Promise { 65 | const now = Date.now(); 66 | if ( 67 | this.cachedGateway === null || 68 | now - this.lastUpdated > this.ttlSeconds * 1000 69 | ) { 70 | try { 71 | this.logger.debug('Cache expired, selecting new gateway', { 72 | cacheAge: now - this.lastUpdated, 73 | ttlSeconds: this.ttlSeconds, 74 | }); 75 | 76 | // preserve the cache if the selection fails 77 | const selectedGateway = 78 | await this.routingStrategy.selectGateway(params); 79 | this.cachedGateway = selectedGateway; 80 | this.lastUpdated = now; 81 | 82 | this.logger.debug('Updated gateway cache', { 83 | selectedGateway: selectedGateway.toString(), 84 | }); 85 | } catch (error: any) { 86 | this.logger.error('Failed to select gateway', { 87 | error: error.message, 88 | stack: error.stack, 89 | }); 90 | // If we have a cached gateway, return it even if expired 91 | if (this.cachedGateway !== null) { 92 | this.logger.warn( 93 | 'Returning expired cached gateway due to selection failure', 94 | ); 95 | return this.cachedGateway; 96 | } 97 | throw error; 98 | } 99 | } else { 100 | this.logger.debug('Using cached gateway', { 101 | cacheAge: now - this.lastUpdated, 102 | ttlSeconds: this.ttlSeconds, 103 | cachedGateway: this.cachedGateway.toString(), 104 | }); 105 | } 106 | return this.cachedGateway!; 107 | } 108 | } 109 | -------------------------------------------------------------------------------- /packages/core/src/routing/static.test.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * WayFinder 3 | * Copyright (C) 2022-2025 Permanent Data Solutions, Inc. 4 | * 5 | * Licensed under the Apache License, Version 2.0 (the "License"); 6 | * you may not use this file except in compliance with the License. 7 | * You may obtain a copy of the License at 8 | * 9 | * http://www.apache.org/licenses/LICENSE-2.0 10 | * 11 | * Unless required by applicable law or agreed to in writing, software 12 | * distributed under the License is distributed on an "AS IS" BASIS, 13 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 14 | * See the License for the specific language governing permissions and 15 | * limitations under the License. 16 | */ 17 | import assert from 'node:assert/strict'; 18 | import { describe, it } from 'node:test'; 19 | 20 | import { StaticRoutingStrategy } from './static.js'; 21 | 22 | describe('StaticRoutingStrategy', () => { 23 | it('returns the configured gateway regardless of the gateways parameter', async () => { 24 | const staticGateway = 'https://static-example.com/'; 25 | const strategy = new StaticRoutingStrategy({ 26 | gateway: staticGateway, 27 | }); 28 | 29 | const result1 = await strategy.selectGateway(); 30 | const result2 = await strategy.selectGateway(); 31 | const result3 = await strategy.selectGateway(); 32 | 33 | assert.equal( 34 | result1.toString(), 35 | staticGateway, 36 | 'Should return the static gateway', 37 | ); 38 | assert.equal( 39 | result2.toString(), 40 | staticGateway, 41 | 'Should return the static gateway', 42 | ); 43 | assert.equal( 44 | result3.toString(), 45 | staticGateway, 46 | 'Should return the static gateway even when no gateways are provided', 47 | ); 48 | }); 49 | 50 | it('logs a warning when gateways are provided', async () => { 51 | const staticGateway = 'https://static-example.com/'; 52 | 53 | const strategy = new StaticRoutingStrategy({ 54 | gateway: staticGateway, 55 | }); 56 | 57 | const providedGateways = [ 58 | new URL('https://example1.com'), 59 | new URL('https://example2.com'), 60 | ]; 61 | 62 | await strategy.selectGateway({ gateways: providedGateways }); 63 | }); 64 | 65 | it('throws an error when an invalid URL is provided', () => { 66 | assert.throws( 67 | () => 68 | new StaticRoutingStrategy({ 69 | gateway: 'not-a-valid-url', 70 | }), 71 | /Invalid URL/, 72 | 'Should throw an error when an invalid URL is provided', 73 | ); 74 | }); 75 | }); 76 | -------------------------------------------------------------------------------- /packages/core/src/routing/static.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * WayFinder 3 | * Copyright (C) 2022-2025 Permanent Data Solutions, Inc. 4 | * 5 | * Licensed under the Apache License, Version 2.0 (the "License"); 6 | * you may not use this file except in compliance with the License. 7 | * You may obtain a copy of the License at 8 | * 9 | * http://www.apache.org/licenses/LICENSE-2.0 10 | * 11 | * Unless required by applicable law or agreed to in writing, software 12 | * distributed under the License is distributed on an "AS IS" BASIS, 13 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 14 | * See the License for the specific language governing permissions and 15 | * limitations under the License. 16 | */ 17 | import { defaultLogger } from '../logger.js'; 18 | /** 19 | * WayFinder 20 | * Copyright (C) 2022-2025 Permanent Data Solutions, Inc. 21 | * 22 | * Licensed under the Apache License, Version 2.0 (the "License"); 23 | * you may not use this file except in compliance with the License. 24 | * You may obtain a copy of the License at 25 | * 26 | * http://www.apache.org/licenses/LICENSE-2.0 27 | * 28 | * Unless required by applicable law or agreed to in writing, software 29 | * distributed under the License is distributed on an "AS IS" BASIS, 30 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 31 | * See the License for the specific language governing permissions and 32 | * limitations under the License. 33 | */ 34 | import type { Logger, RoutingStrategy } from '../types.js'; 35 | 36 | export class StaticRoutingStrategy implements RoutingStrategy { 37 | public readonly name = 'static'; 38 | private gateway: URL; 39 | private logger: Logger; 40 | 41 | constructor({ 42 | gateway, 43 | logger = defaultLogger, 44 | }: { 45 | gateway: string; 46 | logger?: Logger; 47 | }) { 48 | this.logger = logger; 49 | 50 | this.gateway = new URL(gateway); 51 | } 52 | 53 | // provided gateways are ignored 54 | async selectGateway({ 55 | gateways = [], 56 | }: { 57 | gateways?: URL[]; 58 | path?: string; 59 | subdomain?: string; 60 | } = {}): Promise { 61 | if (gateways.length > 0) { 62 | this.logger.warn( 63 | 'StaticRoutingStrategy does not accept provided gateways. Ignoring provided gateways...', 64 | { 65 | providedGateways: gateways.length, 66 | internalGateway: this.gateway, 67 | }, 68 | ); 69 | } 70 | return this.gateway; 71 | } 72 | } 73 | -------------------------------------------------------------------------------- /packages/core/src/telemetry.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * WayFinder 3 | * Copyright (C) 2022-2025 Permanent Data Solutions, Inc. 4 | * 5 | * Licensed under the Apache License, Version 2.0 (the "License"); 6 | * you may not use this file except in compliance with the License. 7 | * You may obtain a copy of the License at 8 | * 9 | * http://www.apache.org/licenses/LICENSE-2.0 10 | * 11 | * Unless required by applicable law or agreed to in writing, software 12 | * distributed under the License is distributed on an "AS IS" BASIS, 13 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 14 | * See the License for the specific language governing permissions and 15 | * limitations under the License. 16 | */ 17 | import { 18 | DiagConsoleLogger, 19 | DiagLogLevel, 20 | Span, 21 | type Tracer, 22 | context, 23 | diag, 24 | trace, 25 | } from '@opentelemetry/api'; 26 | import { OTLPTraceExporter } from '@opentelemetry/exporter-trace-otlp-http'; 27 | import { resourceFromAttributes } from '@opentelemetry/resources'; 28 | import { 29 | BatchSpanProcessor, 30 | TraceIdRatioBasedSampler, 31 | } from '@opentelemetry/sdk-trace-base'; 32 | import { NodeTracerProvider } from '@opentelemetry/sdk-trace-node'; 33 | import { WebTracerProvider } from '@opentelemetry/sdk-trace-web'; 34 | import { 35 | ATTR_SERVICE_NAME, 36 | ATTR_SERVICE_VERSION, 37 | } from '@opentelemetry/semantic-conventions'; 38 | 39 | import type { 40 | GatewaysProvider, 41 | TelemetryConfig, 42 | WayfinderOptions, 43 | } from './types.js'; 44 | import { WayfinderEmitter } from './wayfinder.js'; 45 | 46 | export const initTelemetry = ( 47 | config: TelemetryConfig = { 48 | enabled: false, 49 | sampleRate: 0, 50 | serviceName: 'wayfinder-core', 51 | }, 52 | ): Tracer | undefined => { 53 | if (config.enabled === false) return undefined; 54 | diag.setLogger(new DiagConsoleLogger(), DiagLogLevel.ERROR); 55 | 56 | const exporter = new OTLPTraceExporter({ 57 | url: config.exporterUrl ?? 'https://api.honeycomb.io', 58 | headers: { 59 | 'x-honeycomb-team': config.apiKey ?? '', 60 | 'x-honeycomb-dataset': 'wayfinder-dev', 61 | }, 62 | }); 63 | 64 | const isBrowser = typeof window !== 'undefined'; 65 | const spanProcessor = new BatchSpanProcessor(exporter); 66 | const sampler = new TraceIdRatioBasedSampler(config.sampleRate ?? 1); 67 | const resource = resourceFromAttributes({ 68 | [ATTR_SERVICE_NAME]: 'wayfinder-core', 69 | [ATTR_SERVICE_VERSION]: 'v0.0.5-alpha.5', // hard coded for now as importing JSON breaks wayfinder-react 70 | }); 71 | 72 | const provider = isBrowser 73 | ? new WebTracerProvider({ 74 | sampler, 75 | resource, 76 | spanProcessors: [spanProcessor], 77 | }) 78 | : new NodeTracerProvider({ 79 | sampler, 80 | resource, 81 | spanProcessors: [spanProcessor], 82 | }); 83 | 84 | provider.register(); 85 | 86 | return trace.getTracer('wayfinder-core'); 87 | }; 88 | 89 | export const startRequestSpans = ({ 90 | originalUrl, 91 | emitter, 92 | tracer, 93 | verificationSettings, 94 | routingSettings, 95 | gatewaysProvider, 96 | }: { 97 | originalUrl?: string; 98 | emitter?: WayfinderEmitter; 99 | tracer?: Tracer; 100 | verificationSettings?: WayfinderOptions['verificationSettings']; 101 | routingSettings?: WayfinderOptions['routingSettings']; 102 | gatewaysProvider?: GatewaysProvider; 103 | } = {}) => { 104 | const activeContext = context.active(); 105 | const parentSpan = tracer?.startSpan( 106 | 'wayfinder.request', 107 | { 108 | attributes: { 109 | originalUrl: originalUrl ?? 'undefined', 110 | 'verification.enabled': verificationSettings?.enabled ?? false, 111 | 'verification.strategy': 112 | verificationSettings?.strategy?.constructor.name ?? 'undefined', 113 | 'verification.strict': verificationSettings?.strict ?? false, 114 | 'verification.trustedGateways': 115 | verificationSettings?.strategy?.trustedGateways 116 | ?.map((gateway) => gateway.toString()) 117 | .join(','), 118 | 'routing.strategy': 119 | routingSettings?.strategy?.constructor.name ?? 'undefined', 120 | gatewaysProvider: gatewaysProvider?.constructor.name, 121 | }, 122 | }, 123 | activeContext, 124 | ); 125 | const parentContext = parentSpan 126 | ? trace.setSpan(context.active(), parentSpan) 127 | : context.active(); 128 | let routingSpan: Span | undefined; 129 | let verificationSpan: Span | undefined; 130 | // add listeners on the emitter to the span 131 | emitter?.on('routing-started', () => { 132 | if (parentSpan && !routingSpan) { 133 | context.with(parentContext, () => { 134 | routingSpan = tracer?.startSpan('wayfinder.routing'); 135 | }); 136 | } 137 | }); 138 | emitter?.on('routing-skipped', () => { 139 | parentSpan?.setAttribute('routing.skipped', true); 140 | routingSpan?.end(); 141 | parentSpan?.end(); 142 | }); 143 | emitter?.on('routing-succeeded', () => { 144 | parentSpan?.setAttribute('routing.succeeded', true); 145 | routingSpan?.end(); 146 | }); 147 | emitter?.on('verification-progress', () => { 148 | if (parentSpan && !verificationSpan) { 149 | context.with(parentContext, () => { 150 | verificationSpan = tracer?.startSpan('wayfinder.verification'); 151 | }); 152 | } 153 | }); 154 | emitter?.on('verification-succeeded', () => { 155 | parentSpan?.setAttribute('verification.succeeded', true); 156 | verificationSpan?.end(); 157 | parentSpan?.end(); 158 | }); 159 | emitter?.on('verification-failed', () => { 160 | parentSpan?.setAttribute('verification.failed', true); 161 | verificationSpan?.end(); 162 | parentSpan?.end(); 163 | }); 164 | 165 | return { parentSpan, routingSpan, verificationSpan }; 166 | }; 167 | -------------------------------------------------------------------------------- /packages/core/src/types.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * WayFinder 3 | * Copyright (C) 2022-2025 Permanent Data Solutions, Inc. 4 | * 5 | * Licensed under the Apache License, Version 2.0 (the "License"); 6 | * you may not use this file except in compliance with the License. 7 | * You may obtain a copy of the License at 8 | * 9 | * http://www.apache.org/licenses/LICENSE-2.0 10 | * 11 | * Unless required by applicable law or agreed to in writing, software 12 | * distributed under the License is distributed on an "AS IS" BASIS, 13 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 14 | * See the License for the specific language governing permissions and 15 | * limitations under the License. 16 | */ 17 | 18 | import type { WayfinderEmitter } from './wayfinder.js'; 19 | 20 | // Types 21 | 22 | /** 23 | * This is an extension of the fetch function that allows for overriding the verification and routing settings for a single request. 24 | */ 25 | export type WayfinderFetch = ( 26 | input: URL | RequestInfo, 27 | init?: RequestInit & { 28 | verificationSettings?: WayfinderOptions['verificationSettings']; 29 | routingSettings?: WayfinderOptions['routingSettings']; 30 | }, 31 | ) => Promise; 32 | 33 | export type WayfinderEvent = { 34 | 'routing-started': { originalUrl: string }; 35 | 'routing-skipped': { originalUrl: string }; 36 | 'routing-succeeded': { 37 | originalUrl: string; 38 | selectedGateway: string; 39 | redirectUrl: string; 40 | }; 41 | 'routing-failed': Error; 42 | 'verification-succeeded': { txId: string }; 43 | 'verification-failed': Error; 44 | 'verification-skipped': { originalUrl: string }; 45 | 'verification-progress': { 46 | txId: string; 47 | processedBytes: number; 48 | totalBytes: number; 49 | }; 50 | }; 51 | 52 | // Interfaces 53 | 54 | /** 55 | * Simple logger interface that Wayfinder will use 56 | * This allows users to provide their own logger implementation 57 | */ 58 | export interface Logger { 59 | debug: (message: string, ...args: any[]) => void; 60 | info: (message: string, ...args: any[]) => void; 61 | warn: (message: string, ...args: any[]) => void; 62 | error: (message: string, ...args: any[]) => void; 63 | } 64 | 65 | export interface WayfinderRoutingEventArgs { 66 | onRoutingStarted?: (payload: WayfinderEvent['routing-started']) => void; 67 | onRoutingSkipped?: (payload: WayfinderEvent['routing-skipped']) => void; 68 | onRoutingSucceeded?: (payload: WayfinderEvent['routing-succeeded']) => void; 69 | } 70 | 71 | export interface WayfinderVerificationEventArgs { 72 | onVerificationSucceeded?: ( 73 | payload: WayfinderEvent['verification-succeeded'], 74 | ) => void; 75 | onVerificationFailed?: ( 76 | payload: WayfinderEvent['verification-failed'], 77 | ) => void; 78 | onVerificationProgress?: ( 79 | payload: WayfinderEvent['verification-progress'], 80 | ) => void; 81 | } 82 | 83 | export interface WayfinderEventArgs { 84 | verification?: WayfinderVerificationEventArgs; 85 | routing?: WayfinderRoutingEventArgs; 86 | parentEmitter?: WayfinderEmitter; 87 | } 88 | 89 | /** 90 | * Configuration options for the Wayfinder 91 | */ 92 | export interface WayfinderOptions { 93 | /** 94 | * Logger to use for logging 95 | * @default defaultLogger (standard console logger) 96 | */ 97 | logger?: Logger; 98 | 99 | /** 100 | * The gateways provider to use for routing requests. 101 | */ 102 | gatewaysProvider: GatewaysProvider; 103 | 104 | /** 105 | * The verification settings to use for verifying data 106 | */ 107 | verificationSettings?: { 108 | /** 109 | * Whether verification is enabled. If false, verification will be skipped for all requests. If true, strategy must be provided. 110 | * @default true 111 | */ 112 | enabled?: boolean; 113 | 114 | /** 115 | * The events to use for verification 116 | */ 117 | events?: WayfinderVerificationEventArgs | undefined; 118 | 119 | /** 120 | * The verification strategy to use for verifying data 121 | */ 122 | strategy?: VerificationStrategy; 123 | 124 | /** 125 | * Whether verification should be strict (blocking) 126 | * If true, verification failures will cause requests to fail 127 | * If false, verification will be performed asynchronously with events emitted 128 | * @default false 129 | */ 130 | strict?: boolean; 131 | }; 132 | 133 | /** 134 | * The routing settings to use for routing requests 135 | */ 136 | routingSettings?: { 137 | /** 138 | * The events to use for routing requests 139 | */ 140 | events?: WayfinderRoutingEventArgs; 141 | 142 | /** 143 | * The routing strategy to use for routing requests 144 | */ 145 | strategy?: RoutingStrategy; 146 | }; 147 | 148 | /** 149 | * Telemetry configuration used to initialize OpenTelemetry tracing 150 | */ 151 | telemetrySettings?: TelemetryConfig; 152 | } 153 | 154 | export interface TelemetryConfig { 155 | /** Enable or disable telemetry collection */ 156 | enabled: boolean; 157 | /** Sampling ratio between 0 and 1 */ 158 | sampleRate?: number; 159 | /** Honeycomb API key */ 160 | apiKey?: string; 161 | /** Optional custom OTLP exporter URL */ 162 | exporterUrl?: string; 163 | /** Service name used for traces */ 164 | serviceName?: string; 165 | } 166 | 167 | // Interfaces 168 | 169 | export type DataStream = ReadableStream | AsyncIterable; 170 | 171 | export interface GatewaysProvider { 172 | getGateways(params?: { path?: string; subdomain?: string }): Promise; 173 | } 174 | 175 | export interface RoutingStrategy { 176 | selectGateway(params: { 177 | gateways: URL[]; 178 | path?: string; 179 | subdomain?: string; 180 | }): Promise; 181 | } 182 | 183 | export interface VerificationStrategy { 184 | trustedGateways: URL[]; 185 | verifyData(params: { data: DataStream; txId: string }): Promise; 186 | } 187 | 188 | export interface DataClassifier { 189 | classify(params: { txId: string }): Promise<'ans104' | 'transaction'>; 190 | } 191 | -------------------------------------------------------------------------------- /packages/core/src/utils/ario.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * WayFinder 3 | * Copyright (C) 2022-2025 Permanent Data Solutions, Inc. 4 | * 5 | * Licensed under the Apache License, Version 2.0 (the "License"); 6 | * you may not use this file except in compliance with the License. 7 | * You may obtain a copy of the License at 8 | * 9 | * http://www.apache.org/licenses/LICENSE-2.0 10 | * 11 | * Unless required by applicable law or agreed to in writing, software 12 | * distributed under the License is distributed on an "AS IS" BASIS, 13 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 14 | * See the License for the specific language governing permissions and 15 | * limitations under the License. 16 | */ 17 | export const arioGatewayHeaders = { 18 | digest: 'x-ar-io-digest', 19 | verified: 'x-ar-io-verified', 20 | txId: 'x-arns-resolved-tx-id', 21 | processId: 'x-arns-resolved-process-id', 22 | dataItemOffset: 'x-ar-io-data-item-offset', 23 | dataItemDataOffset: 'x-ar-io-data-item-data-offset', 24 | dataItemSize: 'x-ar-io-data-item-size', 25 | rootTransactionId: 'x-ar-io-root-transaction-id', 26 | }; 27 | -------------------------------------------------------------------------------- /packages/core/src/utils/b64.test.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * WayFinder 3 | * Copyright (C) 2022-2025 Permanent Data Solutions, Inc. 4 | * 5 | * Licensed under the Apache License, Version 2.0 (the "License"); 6 | * you may not use this file except in compliance with the License. 7 | * You may obtain a copy of the License at 8 | * 9 | * http://www.apache.org/licenses/LICENSE-2.0 10 | * 11 | * Unless required by applicable law or agreed to in writing, software 12 | * distributed under the License is distributed on an "AS IS" BASIS, 13 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 14 | * See the License for the specific language governing permissions and 15 | * limitations under the License. 16 | */ 17 | import { strict as assert } from 'node:assert'; 18 | import { describe, it } from 'node:test'; 19 | 20 | import { fromB64Url, toB64Url } from './base64.js'; 21 | 22 | describe('b64utils', () => { 23 | it('should convert various strings to base64url and back', () => { 24 | const testStrings = [ 25 | 'Hello, World!', 26 | 'Test123!@#', 27 | 'Base64URLEncoding', 28 | 'Special_Chars+/', 29 | '', 30 | 'A', 31 | '1234567890', 32 | ]; 33 | for (const str of testStrings) { 34 | const encoded = toB64Url(new TextEncoder().encode(str)); 35 | const decoded = fromB64Url(encoded); 36 | assert.deepStrictEqual( 37 | decoded, 38 | new TextEncoder().encode(str), 39 | `Failed for string: ${str}`, 40 | ); 41 | } 42 | }); 43 | it('should convert various Uint8Arrays to base64url and back', () => { 44 | const testBuffers = [ 45 | new TextEncoder().encode('Hello, World!'), 46 | new TextEncoder().encode('Test123!@#'), 47 | new TextEncoder().encode('Base64URLEncoding'), 48 | new TextEncoder().encode('Special_Chars+/'), 49 | new Uint8Array(0), 50 | new TextEncoder().encode('A'), 51 | new TextEncoder().encode('1234567890'), 52 | ]; 53 | for (const buf of testBuffers) { 54 | const encoded = toB64Url(buf); 55 | const decoded = fromB64Url(encoded); 56 | assert.deepStrictEqual( 57 | decoded, 58 | buf, 59 | `Failed for buffer: ${new TextDecoder().decode(buf)}`, 60 | ); 61 | } 62 | }); 63 | it('should handle edge cases for base64url conversion', () => { 64 | const edgeCases = [ 65 | '', 66 | 'A', 67 | 'AA', 68 | 'AAA', 69 | '====', 70 | '===', 71 | '==', 72 | '=', 73 | 'A===', 74 | 'AA==', 75 | 'AAA=', 76 | ]; 77 | for (const testCase of edgeCases) { 78 | const encoded = toB64Url(new TextEncoder().encode(testCase)); 79 | const decoded = new TextDecoder().decode(fromB64Url(encoded)); 80 | assert.strictEqual( 81 | decoded, 82 | testCase, 83 | `Failed for edge case: ${testCase}`, 84 | ); 85 | } 86 | }); 87 | }); 88 | -------------------------------------------------------------------------------- /packages/core/src/utils/base64.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * WayFinder 3 | * Copyright (C) 2022-2025 Permanent Data Solutions, Inc. 4 | * 5 | * Licensed under the Apache License, Version 2.0 (the "License"); 6 | * you may not use this file except in compliance with the License. 7 | * You may obtain a copy of the License at 8 | * 9 | * http://www.apache.org/licenses/LICENSE-2.0 10 | * 11 | * Unless required by applicable law or agreed to in writing, software 12 | * distributed under the License is distributed on an "AS IS" BASIS, 13 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 14 | * See the License for the specific language governing permissions and 15 | * limitations under the License. 16 | */ 17 | 18 | import { createHash } from 'crypto'; 19 | import { base32 } from 'rfc4648'; 20 | 21 | // safely encodes and decodes base64url strings to and from buffers 22 | const BASE64_CHAR_62 = '+'; 23 | const BASE64_CHAR_63 = '/'; 24 | const BASE64URL_CHAR_62 = '-'; 25 | const BASE64URL_CHAR_63 = '_'; 26 | const BASE64_PADDING = '='; 27 | 28 | function base64urlToBase64(str: string): string { 29 | const padLength = str.length % 4; 30 | if (padLength) { 31 | str += BASE64_PADDING.repeat(4 - padLength); 32 | } 33 | 34 | return str 35 | .replaceAll(BASE64URL_CHAR_62, BASE64_CHAR_62) 36 | .replaceAll(BASE64URL_CHAR_63, BASE64_CHAR_63); 37 | } 38 | 39 | export function fromB64Url(str: string): Uint8Array { 40 | const b64Str = base64urlToBase64(str); 41 | const binaryStr = atob(b64Str); 42 | return new Uint8Array([...binaryStr].map((c) => c.charCodeAt(0))); 43 | } 44 | 45 | export function toB64Url(bytes: Uint8Array): string { 46 | const b64Str = btoa(String.fromCharCode(...bytes)); 47 | return base64urlFromBase64(b64Str); 48 | } 49 | 50 | function base64urlFromBase64(str: string) { 51 | return str 52 | .replaceAll(BASE64_CHAR_62, BASE64URL_CHAR_62) 53 | .replaceAll(BASE64_CHAR_63, BASE64URL_CHAR_63) 54 | .replaceAll(BASE64_PADDING, ''); 55 | } 56 | 57 | export function sha256B64Url(input: Uint8Array): string { 58 | return toB64Url(new Uint8Array(createHash('sha256').update(input).digest())); 59 | } 60 | 61 | export function sandboxFromId(id: string): string { 62 | return base32.stringify(fromB64Url(id), { pad: false }).toLowerCase(); 63 | } 64 | -------------------------------------------------------------------------------- /packages/core/src/utils/hash.test.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * WayFinder 3 | * Copyright (C) 2022-2025 Permanent Data Solutions, Inc. 4 | * 5 | * Licensed under the Apache License, Version 2.0 (the "License"); 6 | * you may not use this file except in compliance with the License. 7 | * You may obtain a copy of the License at 8 | * 9 | * http://www.apache.org/licenses/LICENSE-2.0 10 | * 11 | * Unless required by applicable law or agreed to in writing, software 12 | * distributed under the License is distributed on an "AS IS" BASIS, 13 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 14 | * See the License for the specific language governing permissions and 15 | * limitations under the License. 16 | */ 17 | import { strict as assert } from 'node:assert'; 18 | import { Readable } from 'node:stream'; 19 | import { describe, it } from 'node:test'; 20 | 21 | import { hashDataStreamToB64Url } from './hash.js'; 22 | 23 | describe('hashDataStreamToB64Url', { timeout: 5000 }, () => { 24 | // Test with Readable stream (Node.js) 25 | it('should hash a Node.js Readable stream correctly', async () => { 26 | const testData = 'test data'; 27 | const stream = Readable.from([testData]); 28 | 29 | const result = await hashDataStreamToB64Url({ 30 | stream: stream, 31 | }); 32 | 33 | // SHA-256 hash of 'test data' encoded as base64url 34 | const expectedHash = 'kW8AJ6V1B0znKjMXd8NHjWUT94alkb2JLaGld78jNfk'; 35 | assert.equal(result, expectedHash); 36 | }); 37 | 38 | it('should hash a Node.js Readable stream with a different algorithm', async () => { 39 | const testData = 'test data'; 40 | const stream = Readable.from([testData]); 41 | 42 | const result = await hashDataStreamToB64Url({ 43 | stream: stream, 44 | algorithm: 'sha512', 45 | }); 46 | 47 | // SHA-512 hash of 'test data' encoded as base64url 48 | const expectedHash = 49 | 'Dh4h7PEF7IU9JNcohnrXBhPCFmOkaTB0sqNhnBvTnWa1iMM3I7tGbHJCToDjymPCSQeKs0e6uUKFAOfuQwWdDQ'; 50 | assert.equal(result, expectedHash); 51 | }); 52 | 53 | it('should work with empty Readable stream', async () => { 54 | const stream = Readable.from(['']); 55 | 56 | const result = await hashDataStreamToB64Url({ 57 | stream: stream, 58 | }); 59 | 60 | // SHA-256 hash of empty string encoded as base64url 61 | const expectedHash = '47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU'; 62 | assert.equal(result, expectedHash); 63 | }); 64 | 65 | it('should work with multiple chunks in Readable stream', async () => { 66 | const chunks = ['chunk1', 'chunk2', 'chunk3']; 67 | const stream = Readable.from(chunks); 68 | 69 | const result = await hashDataStreamToB64Url({ 70 | stream: stream, 71 | }); 72 | 73 | // SHA-256 hash of 'chunk1chunk2chunk3' encoded as base64url 74 | const expectedHash = 'v-CLQeRXfUn7d10dvGnS20KbzsIJ5GNzcM2I8tbJZGk'; 75 | assert.equal(result, expectedHash); 76 | }); 77 | 78 | // Test with Web ReadableStream 79 | it('should hash a Web ReadableStream correctly', async () => { 80 | // Create a ReadableStream from a string 81 | const testData = 'test data'; 82 | const encoder = new TextEncoder(); 83 | const uint8Array = encoder.encode(testData); 84 | 85 | const stream = new ReadableStream({ 86 | start(controller) { 87 | controller.enqueue(uint8Array); 88 | controller.close(); 89 | }, 90 | }); 91 | 92 | const result = await hashDataStreamToB64Url({ 93 | stream: stream, 94 | }); 95 | 96 | // SHA-256 hash of 'test data' encoded as base64url 97 | const expectedHash = 'kW8AJ6V1B0znKjMXd8NHjWUT94alkb2JLaGld78jNfk'; 98 | assert.equal(result, expectedHash); 99 | }); 100 | 101 | it('should hash a Web ReadableStream with a different algorithm', async () => { 102 | // Create a ReadableStream from a string 103 | const testData = 'test data'; 104 | const encoder = new TextEncoder(); 105 | const uint8Array = encoder.encode(testData); 106 | 107 | const stream = new ReadableStream({ 108 | start(controller) { 109 | controller.enqueue(uint8Array); 110 | controller.close(); 111 | }, 112 | }); 113 | 114 | const result = await hashDataStreamToB64Url({ 115 | stream: stream, 116 | algorithm: 'sha512', 117 | }); 118 | 119 | // SHA-512 hash of 'test data' encoded as base64url 120 | const expectedHash = 121 | 'Dh4h7PEF7IU9JNcohnrXBhPCFmOkaTB0sqNhnBvTnWa1iMM3I7tGbHJCToDjymPCSQeKs0e6uUKFAOfuQwWdDQ'; 122 | assert.equal(result, expectedHash); 123 | }); 124 | 125 | it('should work with empty Web ReadableStream', async () => { 126 | const stream = new ReadableStream({ 127 | start(controller) { 128 | controller.close(); 129 | }, 130 | }); 131 | 132 | const result = await hashDataStreamToB64Url({ 133 | stream: stream, 134 | }); 135 | 136 | // SHA-256 hash of empty string encoded as base64url 137 | const expectedHash = '47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU'; 138 | assert.equal(result, expectedHash); 139 | }); 140 | 141 | it('should work with multiple chunks in Web ReadableStream', async () => { 142 | const encoder = new TextEncoder(); 143 | const chunks = ['chunk1', 'chunk2', 'chunk3'].map((chunk) => 144 | encoder.encode(chunk), 145 | ); 146 | 147 | const stream = new ReadableStream({ 148 | start(controller) { 149 | chunks.forEach((chunk) => controller.enqueue(chunk)); 150 | controller.close(); 151 | }, 152 | }); 153 | 154 | const result = await hashDataStreamToB64Url({ 155 | stream: stream, 156 | }); 157 | 158 | // SHA-256 hash of 'chunk1chunk2chunk3' encoded as base64url 159 | const expectedHash = 'v-CLQeRXfUn7d10dvGnS20KbzsIJ5GNzcM2I8tbJZGk'; 160 | assert.equal(result, expectedHash); 161 | }); 162 | 163 | // Test with large data to ensure streaming works correctly 164 | it('should correctly hash large streams in chunks', async () => { 165 | // Create a large string (1MB) 166 | const largeString = 'a'.repeat(1024 * 1024); 167 | 168 | // Test with Node.js Readable 169 | const nodeStream = Readable.from([largeString]); 170 | const nodeResult = await hashDataStreamToB64Url({ 171 | stream: nodeStream, 172 | }); 173 | 174 | // Known hash of 1MB of 'a' characters 175 | const expectedHash = 'm8GyooiyavclejYneuOBan1PFuicHn530KXEi61is2A'; 176 | assert.equal(nodeResult, expectedHash); 177 | 178 | // Test with Web ReadableStream 179 | const encoder = new TextEncoder(); 180 | // Create chunks to simulate streaming 181 | const chunkSize = 64 * 1024; // 64KB chunks 182 | const chunks: Uint8Array[] = []; 183 | for (let i = 0; i < largeString.length; i += chunkSize) { 184 | chunks.push(encoder.encode(largeString.substring(i, i + chunkSize))); 185 | } 186 | 187 | const webStream = new ReadableStream({ 188 | start(controller) { 189 | chunks.forEach((chunk) => controller.enqueue(chunk)); 190 | controller.close(); 191 | }, 192 | }); 193 | 194 | const webResult = await hashDataStreamToB64Url({ 195 | stream: webStream, 196 | }); 197 | assert.equal(webResult, expectedHash); 198 | }); 199 | }); 200 | -------------------------------------------------------------------------------- /packages/core/src/utils/hash.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * WayFinder 3 | * Copyright (C) 2022-2025 Permanent Data Solutions, Inc. 4 | * 5 | * Licensed under the Apache License, Version 2.0 (the "License"); 6 | * you may not use this file except in compliance with the License. 7 | * You may obtain a copy of the License at 8 | * 9 | * http://www.apache.org/licenses/LICENSE-2.0 10 | * 11 | * Unless required by applicable law or agreed to in writing, software 12 | * distributed under the License is distributed on an "AS IS" BASIS, 13 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 14 | * See the License for the specific language governing permissions and 15 | * limitations under the License. 16 | */ 17 | import { createHash } from 'crypto'; 18 | import { defaultLogger } from '../logger.js'; 19 | import type { DataStream, Logger } from '../types.js'; 20 | import { toB64Url } from './base64.js'; 21 | 22 | export function isAsyncIterable( 23 | obj: unknown, 24 | ): obj is AsyncIterable { 25 | return ( 26 | obj !== null && 27 | typeof obj === 'object' && 28 | Symbol.asyncIterator in obj && 29 | typeof (obj as AsyncIterable)[Symbol.asyncIterator] === 30 | 'function' 31 | ); 32 | } 33 | 34 | export async function* readableStreamToAsyncIterable( 35 | stream: ReadableStream, 36 | ): AsyncIterable { 37 | const reader = stream.getReader(); 38 | try { 39 | while (true) { 40 | const { done, value } = await reader.read(); 41 | if (done) { 42 | return; 43 | } 44 | yield value; 45 | } 46 | } finally { 47 | reader.releaseLock(); 48 | } 49 | } 50 | 51 | export async function hashDataStreamToB64Url({ 52 | stream, 53 | algorithm = 'sha256', 54 | logger = defaultLogger, 55 | }: { 56 | stream: DataStream; 57 | algorithm?: string; 58 | logger?: Logger; 59 | }): Promise { 60 | try { 61 | logger.debug('Starting to hash data stream', { 62 | algorithm, 63 | streamType: isAsyncIterable(stream) ? 'AsyncIterable' : 'ReadableStream', 64 | }); 65 | 66 | const asyncIterable = isAsyncIterable(stream) 67 | ? stream 68 | : readableStreamToAsyncIterable(stream); 69 | 70 | const hash = createHash(algorithm); 71 | let bytesProcessed = 0; 72 | 73 | for await (const chunk of asyncIterable) { 74 | hash.update(chunk); 75 | bytesProcessed += chunk.length; 76 | 77 | // Log progress occasionally (every ~1MB) 78 | if (bytesProcessed % (1024 * 1024) < chunk.length) { 79 | logger.debug('Hashing progress', { 80 | bytesProcessed, 81 | algorithm, 82 | }); 83 | } 84 | } 85 | 86 | const hashResult = toB64Url(new Uint8Array(hash.digest())); 87 | logger.debug('Finished hashing data stream', { 88 | bytesProcessed, 89 | algorithm, 90 | hashResult, 91 | }); 92 | 93 | return hashResult; 94 | } catch (error: any) { 95 | logger.error('Error hashing data stream', { 96 | error: error.message, 97 | stack: error.stack, 98 | algorithm, 99 | }); 100 | return undefined; 101 | } 102 | } 103 | -------------------------------------------------------------------------------- /packages/core/src/utils/random.test.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * WayFinder 3 | * Copyright (C) 2022-2025 Permanent Data Solutions, Inc. 4 | * 5 | * Licensed under the Apache License, Version 2.0 (the "License"); 6 | * you may not use this file except in compliance with the License. 7 | * You may obtain a copy of the License at 8 | * 9 | * http://www.apache.org/licenses/LICENSE-2.0 10 | * 11 | * Unless required by applicable law or agreed to in writing, software 12 | * distributed under the License is distributed on an "AS IS" BASIS, 13 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 14 | * See the License for the specific language governing permissions and 15 | * limitations under the License. 16 | */ 17 | import { strict as assert } from 'node:assert'; 18 | import { describe, it } from 'node:test'; 19 | 20 | import { randomInt } from './random.js'; 21 | 22 | describe('randomInt', () => { 23 | it('should generate a random integer within the specified range', () => { 24 | const min = 1; 25 | const max = 10; 26 | 27 | // Run multiple iterations to test randomness 28 | for (let i = 0; i < 100; i++) { 29 | const result = randomInt(min, max); 30 | 31 | // Check that the result is an integer 32 | assert.equal(Math.floor(result), result); 33 | 34 | // Check that the result is within the specified range 35 | assert.ok(result >= min); 36 | assert.ok(result < max); 37 | } 38 | }); 39 | 40 | it('should work with zero as the minimum value', () => { 41 | const min = 0; 42 | const max = 5; 43 | 44 | // Run multiple iterations 45 | for (let i = 0; i < 100; i++) { 46 | const result = randomInt(min, max); 47 | 48 | assert.ok(result >= min); 49 | assert.ok(result < max); 50 | } 51 | }); 52 | 53 | it('should work with negative numbers', () => { 54 | const min = -10; 55 | const max = -5; 56 | 57 | // Run multiple iterations 58 | for (let i = 0; i < 100; i++) { 59 | const result = randomInt(min, max); 60 | 61 | assert.ok(result >= min); 62 | assert.ok(result < max); 63 | } 64 | }); 65 | 66 | it('should work with a range that spans negative to positive', () => { 67 | const min = -5; 68 | const max = 5; 69 | 70 | // Run multiple iterations 71 | for (let i = 0; i < 100; i++) { 72 | const result = randomInt(min, max); 73 | 74 | assert.ok(result >= min); 75 | assert.ok(result < max); 76 | } 77 | }); 78 | 79 | it('should generate all possible values in a small range over many iterations', () => { 80 | const min = 0; 81 | const max = 5; 82 | const possibleValues = new Set(); 83 | 84 | // Run many iterations to ensure we get all possible values 85 | for (let i = 0; i < 1000; i++) { 86 | possibleValues.add(randomInt(min, max)); 87 | } 88 | 89 | // With many iterations, we should get all possible values 90 | assert.equal(possibleValues.size, max - min); 91 | 92 | // Check that all values in the range are present 93 | for (let i = min; i < max; i++) { 94 | assert.ok(possibleValues.has(i)); 95 | } 96 | }); 97 | }); 98 | -------------------------------------------------------------------------------- /packages/core/src/utils/random.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * WayFinder 3 | * Copyright (C) 2022-2025 Permanent Data Solutions, Inc. 4 | * 5 | * Licensed under the Apache License, Version 2.0 (the "License"); 6 | * you may not use this file except in compliance with the License. 7 | * You may obtain a copy of the License at 8 | * 9 | * http://www.apache.org/licenses/LICENSE-2.0 10 | * 11 | * Unless required by applicable law or agreed to in writing, software 12 | * distributed under the License is distributed on an "AS IS" BASIS, 13 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 14 | * See the License for the specific language governing permissions and 15 | * limitations under the License. 16 | */ 17 | 18 | /** 19 | * Returns a random integer between min (inclusive) and max (exclusive) 20 | * @param min - The minimum value (inclusive) 21 | * @param max - The maximum value (exclusive) 22 | * @returns A random integer between min and max 23 | */ 24 | export function randomInt(min: number, max: number): number { 25 | return Math.floor(Math.random() * (max - min)) + min; 26 | } 27 | -------------------------------------------------------------------------------- /packages/core/src/verification/data-root-verifier.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * WayFinder 3 | * Copyright (C) 2022-2025 Permanent Data Solutions, Inc. 4 | * 5 | * Licensed under the Apache License, Version 2.0 (the "License"); 6 | * you may not use this file except in compliance with the License. 7 | * You may obtain a copy of the License at 8 | * 9 | * http://www.apache.org/licenses/LICENSE-2.0 10 | * 11 | * Unless required by applicable law or agreed to in writing, software 12 | * distributed under the License is distributed on an "AS IS" BASIS, 13 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 14 | * See the License for the specific language governing permissions and 15 | * limitations under the License. 16 | */ 17 | import Arweave from 'arweave'; 18 | import { 19 | Chunk, 20 | MAX_CHUNK_SIZE, 21 | MIN_CHUNK_SIZE, 22 | buildLayers, 23 | generateLeaves, 24 | } from 'arweave/node/lib/merkle.js'; 25 | 26 | import { pLimit } from 'plimit-lit'; 27 | import { GqlClassifier } from '../classifiers/gql-classifier.js'; 28 | import { defaultLogger } from '../logger.js'; 29 | import { 30 | DataClassifier, 31 | DataStream, 32 | Logger, 33 | VerificationStrategy, 34 | } from '../types.js'; 35 | import { toB64Url } from '../utils/base64.js'; 36 | import { 37 | isAsyncIterable, 38 | readableStreamToAsyncIterable, 39 | } from '../utils/hash.js'; 40 | 41 | export const convertDataStreamToDataRoot = async ({ 42 | stream, 43 | }: { 44 | stream: DataStream; 45 | }): Promise => { 46 | const chunks: Chunk[] = []; 47 | let leftover = new Uint8Array(0); 48 | let cursor = 0; 49 | 50 | const asyncIterable = isAsyncIterable(stream) 51 | ? stream 52 | : readableStreamToAsyncIterable(stream); 53 | 54 | for await (const data of asyncIterable) { 55 | const inputChunk = new Uint8Array( 56 | data.buffer, 57 | data.byteOffset, 58 | data.byteLength, 59 | ); 60 | const combined = new Uint8Array(leftover.length + inputChunk.length); 61 | combined.set(leftover, 0); 62 | combined.set(inputChunk, leftover.length); 63 | 64 | let startIndex = 0; 65 | while (combined.length - startIndex >= MAX_CHUNK_SIZE) { 66 | let chunkSize = MAX_CHUNK_SIZE; 67 | const remainderAfterThis = combined.length - startIndex - MAX_CHUNK_SIZE; 68 | if (remainderAfterThis > 0 && remainderAfterThis < MIN_CHUNK_SIZE) { 69 | chunkSize = Math.ceil((combined.length - startIndex) / 2); 70 | } 71 | 72 | const chunkData = combined.slice(startIndex, startIndex + chunkSize); 73 | const dataHash = await Arweave.crypto.hash(chunkData); 74 | 75 | chunks.push({ 76 | dataHash, 77 | minByteRange: cursor, 78 | maxByteRange: cursor + chunkSize, 79 | }); 80 | 81 | cursor += chunkSize; 82 | startIndex += chunkSize; 83 | } 84 | 85 | leftover = combined.slice(startIndex); 86 | } 87 | 88 | if (leftover.length > 0) { 89 | // TODO: ensure a web friendly crypto hash function is used in web 90 | const dataHash = await Arweave.crypto.hash(leftover); 91 | chunks.push({ 92 | dataHash, 93 | minByteRange: cursor, 94 | maxByteRange: cursor + leftover.length, 95 | }); 96 | } 97 | 98 | const leaves = await generateLeaves(chunks); 99 | const root = await buildLayers(leaves); 100 | return toB64Url(new Uint8Array(root.id)); 101 | }; 102 | 103 | // TODO: this is a TransactionDataRootVerificationStrategy, we will hold of on implementing Ans104DataRootVerificationStrategy for now 104 | export class DataRootVerificationStrategy implements VerificationStrategy { 105 | public readonly trustedGateways: URL[]; 106 | private readonly maxConcurrency: number; 107 | private readonly logger: Logger; 108 | private readonly classifier: DataClassifier; 109 | constructor({ 110 | trustedGateways, 111 | maxConcurrency = 1, 112 | logger = defaultLogger, 113 | classifier = new GqlClassifier({ logger }), 114 | }: { 115 | trustedGateways: URL[]; 116 | maxConcurrency?: number; 117 | logger?: Logger; 118 | classifier?: DataClassifier; 119 | }) { 120 | this.trustedGateways = trustedGateways; 121 | this.maxConcurrency = maxConcurrency; 122 | this.logger = logger; 123 | this.classifier = classifier; 124 | } 125 | 126 | /** 127 | * Get the data root for a given txId from all trusted gateways and ensure they all match. 128 | * @param txId - The txId to get the data root for. 129 | * @returns The data root for the given txId. 130 | */ 131 | async getDataRoot({ txId }: { txId: string }): Promise { 132 | this.logger.debug('Getting data root for txId', { 133 | txId, 134 | maxConcurrency: this.maxConcurrency, 135 | trustedGateways: this.trustedGateways, 136 | }); 137 | 138 | // TODO: shuffle gateways to avoid bias 139 | const throttle = pLimit(this.maxConcurrency); 140 | const dataRootPromises = this.trustedGateways.map( 141 | async (gateway): Promise<{ dataRoot: string; gateway: URL }> => { 142 | return throttle(async () => { 143 | const response = await fetch( 144 | `${gateway.toString()}tx/${txId}/data_root`, 145 | ); 146 | if (!response.ok) { 147 | // skip this gateway 148 | throw new Error('Failed to fetch data root for txId', { 149 | cause: { 150 | txId, 151 | gateway: gateway.toString(), 152 | }, 153 | }); 154 | } 155 | const dataRoot = await response.text(); 156 | return { dataRoot, gateway }; 157 | }); 158 | }, 159 | ); 160 | 161 | const { dataRoot, gateway } = await Promise.any(dataRootPromises); 162 | this.logger.debug('Successfully fetched data root for txId', { 163 | txId, 164 | dataRoot, 165 | gateway: gateway.toString(), 166 | }); 167 | return dataRoot; 168 | } 169 | 170 | async verifyData({ 171 | data, 172 | txId, 173 | }: { 174 | data: DataStream; 175 | txId: string; 176 | }): Promise { 177 | // classify the data, if ans104 throw an error 178 | const dataType = await this.classifier.classify({ txId }); 179 | if (dataType === 'ans104') { 180 | throw new Error( 181 | 'ANS-104 data is not supported for data root verification', 182 | { 183 | cause: { txId }, 184 | }, 185 | ); 186 | } 187 | 188 | const [computedDataRoot, trustedDataRoot] = await Promise.all([ 189 | convertDataStreamToDataRoot({ 190 | stream: data, 191 | }), 192 | this.getDataRoot({ 193 | txId, 194 | }), 195 | ]); 196 | if (computedDataRoot !== trustedDataRoot) { 197 | throw new Error('Data root does not match', { 198 | cause: { computedDataRoot, trustedDataRoot }, 199 | }); 200 | } 201 | } 202 | } 203 | -------------------------------------------------------------------------------- /packages/core/src/verification/hash-verifier.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * WayFinder 3 | * Copyright (C) 2022-2025 Permanent Data Solutions, Inc. 4 | * 5 | * Licensed under the Apache License, Version 2.0 (the "License"); 6 | * you may not use this file except in compliance with the License. 7 | * You may obtain a copy of the License at 8 | * 9 | * http://www.apache.org/licenses/LICENSE-2.0 10 | * 11 | * Unless required by applicable law or agreed to in writing, software 12 | * distributed under the License is distributed on an "AS IS" BASIS, 13 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 14 | * See the License for the specific language governing permissions and 15 | * limitations under the License. 16 | */ 17 | import { pLimit } from 'plimit-lit'; 18 | import { defaultLogger } from '../logger.js'; 19 | import type { DataStream, Logger, VerificationStrategy } from '../types.js'; 20 | import { arioGatewayHeaders } from '../utils/ario.js'; 21 | import { sandboxFromId } from '../utils/base64.js'; 22 | import { hashDataStreamToB64Url } from '../utils/hash.js'; 23 | 24 | export class HashVerificationStrategy implements VerificationStrategy { 25 | public readonly trustedGateways: URL[]; 26 | private readonly maxConcurrency: number; 27 | private readonly logger: Logger; 28 | constructor({ 29 | trustedGateways, 30 | maxConcurrency = 1, 31 | logger = defaultLogger, 32 | }: { 33 | trustedGateways: URL[]; 34 | maxConcurrency?: number; 35 | logger?: Logger; 36 | }) { 37 | this.trustedGateways = trustedGateways; 38 | this.maxConcurrency = maxConcurrency; 39 | this.logger = logger; 40 | } 41 | 42 | /** 43 | * Gets the digest for a given txId from all trusted gateways and ensures they all match. 44 | * @param txId - The txId to get the digest for. 45 | * @returns The digest for the given txId. 46 | */ 47 | async getDigest({ 48 | txId, 49 | }: { 50 | txId: string; 51 | }): Promise<{ hash: string; algorithm: 'sha256' }> { 52 | this.logger.debug('Getting digest for txId', { 53 | txId, 54 | maxConcurrency: this.maxConcurrency, 55 | trustedGateways: this.trustedGateways, 56 | }); 57 | 58 | // TODO: shuffle gateways to avoid bias 59 | const throttle = pLimit(this.maxConcurrency); 60 | const hashPromises = this.trustedGateways.map( 61 | async (gateway: URL): Promise<{ hash: string; gateway: URL }> => { 62 | return throttle(async () => { 63 | const sandbox = sandboxFromId(txId); 64 | const urlWithSandbox = `${gateway.protocol}//${sandbox}.${gateway.hostname}/${txId}`; 65 | /** 66 | * This is a problem because we're not able to verify the hash of the data item if the gateway doesn't have the data in its cache. We start with a HEAD request, if it fails, we do a GET request to hydrate the cache and then a HEAD request again to get the cached digest. 67 | */ 68 | for (const method of ['HEAD', 'GET', 'HEAD']) { 69 | const response = await fetch(urlWithSandbox, { 70 | method, 71 | redirect: 'follow', 72 | mode: 'cors', 73 | headers: { 74 | 'Cache-Control': 'no-cache', 75 | }, 76 | }); 77 | if (!response.ok) { 78 | // skip if the request failed or the digest is not present 79 | throw new Error('Failed to fetch digest for txId', { 80 | cause: { 81 | txId, 82 | gateway: gateway.toString(), 83 | }, 84 | }); 85 | } 86 | 87 | const fetchedTxIdHash = response.headers.get( 88 | arioGatewayHeaders.digest, 89 | ); 90 | 91 | if (fetchedTxIdHash) { 92 | // avoid hitting other gateways if we've found the hash 93 | throttle.clearQueue(); 94 | return { hash: fetchedTxIdHash, gateway }; 95 | } 96 | } 97 | 98 | throw new Error('No hash found for txId', { 99 | cause: { 100 | txId, 101 | gateway: gateway.toString(), 102 | }, 103 | }); 104 | }); 105 | }, 106 | ); 107 | 108 | const { hash, gateway } = await Promise.any(hashPromises); 109 | this.logger.debug( 110 | 'Successfully fetched digest for txId from trusted gateway', 111 | { 112 | txId, 113 | hash, 114 | gateway: gateway.toString(), 115 | }, 116 | ); 117 | return { hash, algorithm: 'sha256' }; 118 | } 119 | 120 | async verifyData({ 121 | data, 122 | txId, 123 | }: { 124 | data: DataStream; 125 | txId: string; 126 | }): Promise { 127 | // kick off the hash computation, but don't wait for it until we compute our own hash 128 | const [computedHash, fetchedHash] = await Promise.all([ 129 | hashDataStreamToB64Url({ stream: data }), 130 | this.getDigest({ txId }), 131 | ]); 132 | // await on the hash promise and compare to get a little concurrency when computing hashes over larger data 133 | if (computedHash === undefined) { 134 | throw new Error('Hash could not be computed'); 135 | } 136 | if (computedHash !== fetchedHash.hash) { 137 | throw new Error('Hash does not match', { 138 | cause: { computedHash, trustedHash: fetchedHash }, 139 | }); 140 | } 141 | } 142 | } 143 | -------------------------------------------------------------------------------- /packages/core/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "ESNext", 4 | "useDefineForClassFields": true, 5 | "lib": ["ESNext", "DOM"], 6 | "module": "NodeNext", 7 | "skipLibCheck": true, 8 | "moduleResolution": "NodeNext", 9 | "allowImportingTsExtensions": false, 10 | "resolveJsonModule": true, 11 | "isolatedModules": true, 12 | "noEmit": false, 13 | "declaration": true, 14 | "declarationMap": true, 15 | "sourceMap": false, 16 | "outDir": "dist", 17 | "strict": true, 18 | "noUnusedLocals": true, 19 | "noUnusedParameters": true, 20 | "noFallthroughCasesInSwitch": true, 21 | "esModuleInterop": true, 22 | "paths": { 23 | "@ar.io/wayfinder/*": ["./src/*"] 24 | } 25 | }, 26 | "include": ["src"], 27 | "exclude": ["node_modules", "dist", "**/*.test.ts"] 28 | } 29 | -------------------------------------------------------------------------------- /packages/extension/CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # @ar.io/wayfinder-extension 2 | 3 | ## 0.0.18-alpha.4 4 | 5 | ### Patch Changes 6 | 7 | - Updated dependencies [2d72bba] 8 | - @ar.io/wayfinder-core@0.0.5-alpha.4 9 | 10 | ## 0.0.18-alpha.3 11 | 12 | ### Patch Changes 13 | 14 | - Updated dependencies [063e480] 15 | - @ar.io/wayfinder-core@0.0.5-alpha.3 16 | 17 | ## 0.0.18-alpha.2 18 | 19 | ### Patch Changes 20 | 21 | - Updated dependencies [b85ec7e] 22 | - @ar.io/wayfinder-core@0.0.5-alpha.2 23 | 24 | ## 0.0.18-alpha.1 25 | 26 | ### Patch Changes 27 | 28 | - Updated dependencies [aba2beb] 29 | - @ar.io/wayfinder-core@0.0.5-alpha.1 30 | 31 | ## 0.0.18-alpha.0 32 | 33 | ### Patch Changes 34 | 35 | - Updated dependencies [4afd953] 36 | - @ar.io/wayfinder-core@0.0.5-alpha.0 37 | 38 | ## 0.0.17 39 | 40 | ### Patch Changes 41 | 42 | - Updated dependencies [e43548d] 43 | - @ar.io/wayfinder-core@0.0.4 44 | 45 | ## 0.0.17-alpha.1 46 | 47 | ### Patch Changes 48 | 49 | - Updated dependencies [78ad2b2] 50 | - @ar.io/wayfinder-core@0.0.4-alpha.1 51 | 52 | ## 0.0.17-alpha.0 53 | 54 | ### Patch Changes 55 | 56 | - Updated dependencies [7c81839] 57 | - @ar.io/wayfinder-core@0.0.4-alpha.0 58 | 59 | ## 0.0.16 60 | 61 | ### Patch Changes 62 | 63 | - Updated dependencies [53613fb] 64 | - Updated dependencies [c12a8f8] 65 | - Updated dependencies [45d2884] 66 | - Updated dependencies [8e7facb] 67 | - Updated dependencies [2605cdb] 68 | - Updated dependencies [d431437] 69 | - Updated dependencies [1ceb8df] 70 | - Updated dependencies [2109250] 71 | - @ar.io/wayfinder-core@0.0.3 72 | 73 | ## 0.0.16-alpha.6 74 | 75 | ### Patch Changes 76 | 77 | - Updated dependencies [1ceb8df] 78 | - @ar.io/wayfinder-core@0.0.3-alpha.6 79 | 80 | ## 0.0.16-alpha.5 81 | 82 | ### Patch Changes 83 | 84 | - Updated dependencies [53613fb] 85 | - @ar.io/wayfinder-core@0.0.3-alpha.5 86 | 87 | ## 0.0.16-alpha.4 88 | 89 | ### Patch Changes 90 | 91 | - Updated dependencies [8e7facb] 92 | - @ar.io/wayfinder-core@0.0.3-alpha.4 93 | 94 | ## 0.0.16-alpha.3 95 | 96 | ### Patch Changes 97 | 98 | - Updated dependencies [d431437] 99 | - @ar.io/wayfinder-core@0.0.3-alpha.3 100 | 101 | ## 0.0.16-alpha.2 102 | 103 | ### Patch Changes 104 | 105 | - Updated dependencies [2109250] 106 | - @ar.io/wayfinder-core@0.0.3-alpha.2 107 | 108 | ## 0.0.16-alpha.1 109 | 110 | ### Patch Changes 111 | 112 | - Updated dependencies [c12a8f8] 113 | - Updated dependencies [2605cdb] 114 | - @ar.io/wayfinder-core@0.0.3-alpha.1 115 | 116 | ## 0.0.16-beta.0 117 | 118 | ### Patch Changes 119 | 120 | - Updated dependencies [45d2884] 121 | - @ar.io/wayfinder-core@0.0.3-beta.0 122 | 123 | ## 0.0.15 124 | 125 | ### Patch Changes 126 | 127 | - Updated dependencies 128 | - @ar.io/wayfinder-core@0.0.2 129 | 130 | ## 0.0.15-beta.0 131 | 132 | ### Patch Changes 133 | 134 | - Updated dependencies 135 | - @ar.io/wayfinder-core@0.0.2-beta.0 136 | -------------------------------------------------------------------------------- /packages/extension/README.md: -------------------------------------------------------------------------------- 1 | # WayFinder Chrome Extension 2 | 3 | The WayFinder Chrome extension intelligently routes users to optimal AR.IO gateways, ensuring streamlined access to the permaweb on Arweave. 4 | 5 | ## Features 6 | 7 | - ar:// routing in the browser search bar and within pages 8 | - Automatically routes ArNS names and Arweave Transaction IDs to available gateways 9 | - DNS TXT Record Redirection for user-friendly permaweb navigation 10 | - Algorithmic Gateway Selection for optimal routing 11 | - Gateway Discovery through AR.IO Gateway Address registry 12 | - Static Gateway Configuration for advanced users 13 | - Continuous Gateway Health Checks 14 | - Usage History and metrics 15 | - UI Theming with light and dark modes 16 | - Privacy-Preserving Design 17 | 18 | ## Development 19 | 20 | ### Requirements 21 | 22 | - `node` - v22+ 23 | - `npm` - v10.9.2 24 | 25 | ### Build 26 | 27 | ```bash 28 | # Install dependencies 29 | npm install 30 | 31 | # Build the extension 32 | yarn build 33 | ``` 34 | 35 | ### Loading into Chrome 36 | 37 | 1. Run `yarn build` to create a fresh `dist` directory 38 | 2. Navigate to `Manage Extensions` 39 | 3. Click `Load unpacked` 40 | 4. Select the `dist` directory and hit `Load` 41 | 42 | ## License 43 | 44 | AGPL-3.0-only 45 | -------------------------------------------------------------------------------- /packages/extension/assets/icon128.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ar-io/wayfinder/79254d1b803a4e04e089e2797dbfb7ec2465ab37/packages/extension/assets/icon128.png -------------------------------------------------------------------------------- /packages/extension/assets/icon16.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ar-io/wayfinder/79254d1b803a4e04e089e2797dbfb7ec2465ab37/packages/extension/assets/icon16.png -------------------------------------------------------------------------------- /packages/extension/assets/icon48.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ar-io/wayfinder/79254d1b803a4e04e089e2797dbfb7ec2465ab37/packages/extension/assets/icon48.png -------------------------------------------------------------------------------- /packages/extension/manifest.json: -------------------------------------------------------------------------------- 1 | { 2 | "manifest_version": 3, 3 | "name": "AR.IO WayFinder", 4 | "version": "0.0.14", 5 | "description": "WayFinder (Alpha) streamlines access to the Permaweb through the AR.IO Network and Arweave Name System.", 6 | "permissions": ["storage", "webNavigation", "webRequest"], 7 | "host_permissions": [""], 8 | "background": { 9 | "service_worker": "background.js", 10 | "type": "module" 11 | }, 12 | "action": { 13 | "default_popup": "popup.html" 14 | }, 15 | "content_scripts": [ 16 | { 17 | "matches": [""], 18 | "js": ["content.js"], 19 | "type": "module" 20 | } 21 | ], 22 | "content_security_policy": { 23 | "extension_pages": "script-src 'self'; object-src 'none'; font-src 'self' https://fonts.gstatic.com;" 24 | }, 25 | "icons": { 26 | "16": "assets/icon16.png", 27 | "48": "assets/icon48.png", 28 | "128": "assets/icon128.png" 29 | }, 30 | "web_accessible_resources": [ 31 | { 32 | "resources": ["**/*"], 33 | "matches": [""] 34 | } 35 | ] 36 | } 37 | -------------------------------------------------------------------------------- /packages/extension/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@ar.io/wayfinder-extension", 3 | "version": "0.0.18-alpha.4", 4 | "description": "WayFinder Chrome extension that intelligently routes users to optimal AR.IO gateways", 5 | "private": true, 6 | "type": "module", 7 | "license": "Apache-2.0", 8 | "repository": { 9 | "type": "git", 10 | "url": "git+https://github.com/ar-io/wayfinder.git" 11 | }, 12 | "devDependencies": { 13 | "@types/chrome": "^0.0.268", 14 | "@types/node": "^20.12.12", 15 | "@vitejs/plugin-react": "^4.3.1", 16 | "@vitejs/plugin-react-swc": "^3.7.0", 17 | "vite": "^5.2.10", 18 | "vite-plugin-copy": "^0.1.6", 19 | "vite-plugin-html": "^3.2.2", 20 | "vite-plugin-node-polyfills": "^0.22.0", 21 | "vite-plugin-static-copy": "^1.0.6" 22 | }, 23 | "scripts": { 24 | "start": "vite preview", 25 | "build": "vite build --config vite.config.js", 26 | "clean": "rimraf dist", 27 | "test": "echo \"Passing\"", 28 | "lint:fix": "biome check --write --unsafe", 29 | "lint:check": "biome check --unsafe", 30 | "format:fix": "biome format --write", 31 | "format:check": "biome format" 32 | }, 33 | "dependencies": { 34 | "@ar.io/sdk": "^3.5.0", 35 | "@ar.io/wayfinder-core": "0.0.5-alpha.4", 36 | "@permaweb/aoconnect": "0.0.69" 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /packages/extension/src/background.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * WayFinder 3 | * Copyright (C) 2022-2025 Permanent Data Solutions, Inc. 4 | * 5 | * Licensed under the Apache License, Version 2.0 (the "License"); 6 | * you may not use this file except in compliance with the License. 7 | * You may obtain a copy of the License at 8 | * 9 | * http://www.apache.org/licenses/LICENSE-2.0 10 | * 11 | * Unless required by applicable law or agreed to in writing, software 12 | * distributed under the License is distributed on an "AS IS" BASIS, 13 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 14 | * See the License for the specific language governing permissions and 15 | * limitations under the License. 16 | */ 17 | import { AOProcess, ARIO, AoGateway, WalletAddress } from '@ar.io/sdk/web'; 18 | import { connect } from '@permaweb/aoconnect'; 19 | import { 20 | ARIO_MAINNET_PROCESS_ID, 21 | DEFAULT_AO_CU_URL, 22 | OPTIMAL_GATEWAY_ROUTE_METHOD, 23 | } from './constants'; 24 | import { 25 | backgroundGatewayBenchmarking, 26 | isKnownGateway, 27 | saveToHistory, 28 | updateGatewayPerformance, 29 | } from './helpers'; 30 | import { getRoutableGatewayUrl } from './routing'; 31 | import { RedirectedTabInfo } from './types'; 32 | 33 | // Global variables 34 | const redirectedTabs: Record = {}; 35 | const requestTimings = new Map(); 36 | 37 | console.log('🚀 Initializing AR.IO...'); 38 | let arIO = ARIO.init({ 39 | process: new AOProcess({ 40 | processId: ARIO_MAINNET_PROCESS_ID, 41 | ao: connect({ 42 | CU_URL: DEFAULT_AO_CU_URL, 43 | MODE: 'legacy', 44 | }), 45 | }), 46 | }); 47 | 48 | // Set default values in Chrome storage 49 | chrome.storage.local.set({ 50 | routingMethod: OPTIMAL_GATEWAY_ROUTE_METHOD, 51 | localGatewayAddressRegistry: {}, 52 | blacklistedGateways: [], 53 | processId: ARIO_MAINNET_PROCESS_ID, 54 | aoCuUrl: DEFAULT_AO_CU_URL, 55 | ensResolutionEnabled: true, 56 | }); 57 | 58 | // Ensure we sync the registry before running benchmarking 59 | async function initializeWayfinder() { 60 | console.log('🔄 Initializing Wayfinder...'); 61 | await syncGatewayAddressRegistry(); // **Wait for GAR sync to complete** 62 | await backgroundGatewayBenchmarking(); // **Benchmark after GAR is ready** 63 | } 64 | 65 | initializeWayfinder().catch((err) => 66 | console.error('🚨 Error during Wayfinder initialization:', err), 67 | ); 68 | 69 | /** 70 | * Handles browser navigation for `ar://` links. 71 | */ 72 | chrome.webNavigation.onBeforeNavigate.addListener( 73 | (details) => { 74 | setTimeout(async () => { 75 | try { 76 | const url = new URL(details.url); 77 | const arUrl = url.searchParams.get('q'); 78 | 79 | if (!arUrl || !arUrl.startsWith('ar://')) return; 80 | 81 | const { url: redirectTo, gatewayFQDN } = 82 | await getRoutableGatewayUrl(arUrl); 83 | 84 | if (redirectTo) { 85 | const startTime = performance.now(); 86 | chrome.tabs.update(details.tabId, { url: redirectTo }); 87 | 88 | // ✅ Track that this tab was redirected, but don't update performance yet 89 | redirectedTabs[details.tabId] = { 90 | originalGateway: gatewayFQDN, 91 | expectedSandboxRedirect: /^[a-z0-9_-]{43}$/i.test(arUrl.slice(5)), // True if it's a TxId 92 | startTime, 93 | }; 94 | } 95 | } catch (error) { 96 | console.error('❌ Error processing ar:// navigation:', error); 97 | } 98 | }, 0); // 🔥 Defer execution to avoid blocking listener thread 99 | }, 100 | { url: [{ schemes: ['http', 'https'] }] }, 101 | ); 102 | 103 | /** 104 | * Tracks request start time. 105 | */ 106 | chrome.webRequest.onBeforeRequest.addListener( 107 | (details) => { 108 | requestTimings.set(details.requestId, performance.now()); 109 | }, 110 | { urls: [''] }, 111 | ); 112 | 113 | /** 114 | * Tracks successful gateway requests for performance metrics. 115 | */ 116 | chrome.webRequest.onCompleted.addListener( 117 | async (details) => { 118 | const gatewayFQDN = new URL(details.url).hostname; 119 | 120 | // ✅ Ignore non-ar:// navigations 121 | if (!redirectedTabs[details.tabId]) return; 122 | 123 | // ✅ Only track requests if they originated from an `ar://` redirection 124 | if (!(await isKnownGateway(gatewayFQDN))) return; 125 | 126 | const startTime = redirectedTabs[details.tabId].startTime; 127 | if (!startTime) return; 128 | 129 | // ✅ Cleanup tracking after use 130 | delete redirectedTabs[details.tabId]; 131 | 132 | // ✅ Update performance metrics 133 | await updateGatewayPerformance(gatewayFQDN, startTime); 134 | }, 135 | { urls: [''] }, 136 | ); 137 | 138 | /** 139 | * Tracks ArNS resolution responses. 140 | */ 141 | chrome.webRequest.onHeadersReceived.addListener( 142 | (details) => { 143 | const tabInfo = redirectedTabs[details.tabId]; 144 | 145 | if (tabInfo) { 146 | for (const header of details.responseHeaders || []) { 147 | if (header.name.toLowerCase() === 'x-arns-resolved-id') { 148 | const timestamp = new Date().toISOString(); 149 | saveToHistory(details.url, header.value || 'undefined', timestamp); 150 | break; 151 | } 152 | } 153 | 154 | // 🔥 Always remove tracking for this tab, regardless of headers 155 | delete redirectedTabs[details.tabId]; 156 | } 157 | }, 158 | { urls: [''] }, 159 | ['responseHeaders'], 160 | ); 161 | 162 | /** 163 | * Handles failed gateway requests. 164 | */ 165 | /** 166 | * Handles failed gateway requests. 167 | */ 168 | chrome.webRequest.onErrorOccurred.addListener( 169 | async (details) => { 170 | // ✅ Ignore background benchmark failures to avoid double counting 171 | if (redirectedTabs[details.tabId]) return; 172 | 173 | const gatewayFQDN = new URL(details.url).hostname; 174 | if (!(await isKnownGateway(gatewayFQDN))) return; 175 | 176 | const { gatewayPerformance = {} } = await chrome.storage.local.get([ 177 | 'gatewayPerformance', 178 | ]); 179 | 180 | if (!gatewayPerformance[gatewayFQDN]) { 181 | gatewayPerformance[gatewayFQDN] = { 182 | responseTimes: [], 183 | failures: 0, 184 | successCount: 0, 185 | }; 186 | } 187 | 188 | gatewayPerformance[gatewayFQDN].failures += 1; 189 | 190 | await chrome.storage.local.set({ gatewayPerformance }); 191 | }, 192 | { urls: [''] }, 193 | ); 194 | 195 | /** 196 | * Periodically cleans up requestTimings to prevent memory leaks. 197 | */ 198 | setInterval(() => { 199 | const now = performance.now(); 200 | for (const [requestId, timestamp] of requestTimings.entries()) { 201 | if (now - timestamp > 60000) { 202 | requestTimings.delete(requestId); // Remove old requests older than 1 min 203 | } 204 | } 205 | }, 30000); // Runs every 30 seconds 206 | 207 | /** 208 | * Handles messages from content scripts for syncing gateway data. 209 | */ 210 | chrome.runtime.onMessage.addListener((request, _sender, sendResponse) => { 211 | if ( 212 | !['syncGatewayAddressRegistry', 'setArIOProcessId', 'setAoCuUrl'].includes( 213 | request.message, 214 | ) && 215 | request.type !== 'convertArUrlToHttpUrl' 216 | ) { 217 | console.warn('⚠️ Unauthorized message:', request); 218 | return; 219 | } 220 | 221 | if (request.message === 'syncGatewayAddressRegistry') { 222 | syncGatewayAddressRegistry() 223 | .then(() => backgroundGatewayBenchmarking()) 224 | .then(() => sendResponse({})) 225 | .catch((error) => { 226 | console.error('❌ Failed to sync GAR:', error); 227 | sendResponse({ error: 'Failed to sync gateway address registry.' }); 228 | }); 229 | 230 | return true; // ✅ Keeps connection open for async response 231 | } 232 | 233 | if (request.message === 'setAoCuUrl') { 234 | reinitializeArIO() 235 | .then(() => syncGatewayAddressRegistry()) 236 | .then(() => sendResponse({})) 237 | .catch((error) => { 238 | console.error( 239 | '❌ Failed to set new AO CU Url and reinitialize AR.IO:', 240 | error, 241 | ); 242 | sendResponse({ 243 | error: 'Failed to set new AO CU Url and reinitialize AR.IO.', 244 | }); 245 | }); 246 | return true; 247 | } 248 | 249 | if (request.type === 'convertArUrlToHttpUrl') { 250 | const arUrl = request.arUrl; 251 | getRoutableGatewayUrl(arUrl) 252 | .then((response) => { 253 | if (!response || !response.url) { 254 | throw new Error('URL resolution failed, response is invalid'); 255 | } 256 | sendResponse({ url: response.url }); // ✅ Extract only the URL 257 | }) 258 | .catch((error) => { 259 | console.error('Error in message listener:', error); 260 | sendResponse({ error: error.message }); 261 | }); 262 | 263 | return true; // Keeps the response channel open for async calls 264 | } 265 | }); 266 | 267 | /** 268 | * Fetches and stores the AR.IO Gateway Address Registry. 269 | */ 270 | async function syncGatewayAddressRegistry(): Promise { 271 | try { 272 | const { processId, aoCuUrl } = await chrome.storage.local.get([ 273 | 'processId', 274 | 'aoCuUrl', 275 | ]); 276 | 277 | if (!processId) { 278 | throw new Error('❌ Process ID missing in local storage.'); 279 | } 280 | 281 | if (!aoCuUrl) { 282 | throw new Error('❌ AO CU Url missing in local storage.'); 283 | } 284 | 285 | console.log( 286 | `🔄 Fetching Gateway Address Registry from ${aoCuUrl} with Process ID: ${processId}`, 287 | ); 288 | 289 | const registry: Record = {}; 290 | let cursor: string | undefined = undefined; 291 | let totalFetched = 0; 292 | 293 | do { 294 | const response = await arIO.getGateways({ 295 | limit: 1000, 296 | cursor, 297 | }); 298 | 299 | if (!response?.items || response.items.length === 0) { 300 | console.warn('⚠️ No gateways found in this batch.'); 301 | break; 302 | } 303 | 304 | response.items.forEach(({ gatewayAddress, ...gatewayData }) => { 305 | registry[gatewayAddress] = gatewayData; 306 | }); 307 | 308 | totalFetched += response.items.length; 309 | cursor = response.nextCursor; 310 | } while (cursor); 311 | 312 | if (totalFetched === 0) { 313 | console.warn('⚠️ No gateways found after full sync.'); 314 | } else { 315 | await chrome.storage.local.set({ localGatewayAddressRegistry: registry }); 316 | console.log(`✅ Synced ${totalFetched} gateways.`); 317 | } 318 | } catch (error) { 319 | console.error('❌ Error syncing Gateway Address Registry:', error); 320 | } 321 | } 322 | 323 | /** 324 | * Reinitializes AR.IO with updated process ID. 325 | */ 326 | async function reinitializeArIO(): Promise { 327 | try { 328 | const { processId, aoCuUrl } = await chrome.storage.local.get([ 329 | 'processId', 330 | 'aoCuUrl', 331 | ]); 332 | arIO = ARIO.init({ 333 | process: new AOProcess({ 334 | processId: processId, 335 | ao: connect({ MODE: 'legacy', CU_URL: aoCuUrl }), 336 | }), 337 | }); 338 | console.log('🔄 AR.IO reinitialized with Process ID:', processId); 339 | } catch { 340 | arIO = ARIO.init(); 341 | console.error('❌ Failed to reinitialize AR.IO. Using default.'); 342 | } 343 | } 344 | -------------------------------------------------------------------------------- /packages/extension/src/constants.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * WayFinder 3 | * Copyright (C) 2022-2025 Permanent Data Solutions, Inc. 4 | * 5 | * Licensed under the Apache License, Version 2.0 (the "License"); 6 | * you may not use this file except in compliance with the License. 7 | * You may obtain a copy of the License at 8 | * 9 | * http://www.apache.org/licenses/LICENSE-2.0 10 | * 11 | * Unless required by applicable law or agreed to in writing, software 12 | * distributed under the License is distributed on an "AS IS" BASIS, 13 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 14 | * See the License for the specific language governing permissions and 15 | * limitations under the License. 16 | */ 17 | import { AoGatewayWithAddress } from '@ar.io/sdk'; 18 | 19 | export const DEFAULT_GATEWAY: AoGatewayWithAddress = { 20 | operatorStake: 250000000000, 21 | settings: { 22 | allowedDelegates: [], 23 | allowDelegatedStaking: true, 24 | autoStake: false, 25 | delegateRewardShareRatio: 5, 26 | fqdn: 'arweave.net', 27 | label: 'Arweave.net', 28 | minDelegatedStake: 100000000, 29 | note: 'Arweave ecosystem gateway.', 30 | port: 443, 31 | properties: '', 32 | protocol: 'https', 33 | }, 34 | stats: { 35 | failedConsecutiveEpochs: 0, 36 | passedEpochCount: 0, 37 | passedConsecutiveEpochs: 0, 38 | totalEpochCount: 0, 39 | failedEpochCount: 0, 40 | observedEpochCount: 0, 41 | prescribedEpochCount: 0, 42 | }, 43 | status: 'joined', 44 | totalDelegatedStake: 0, 45 | weights: { 46 | stakeWeight: 0, 47 | tenureWeight: 0, 48 | gatewayRewardRatioWeight: 0, 49 | normalizedCompositeWeight: 0, 50 | observerRewardRatioWeight: 0, 51 | compositeWeight: 0, 52 | gatewayPerformanceRatio: 0, 53 | observerPerformanceRatio: 0, 54 | }, 55 | startTimestamp: 0, 56 | endTimestamp: 0, 57 | observerAddress: '', 58 | services: { 59 | bundlers: [], 60 | }, 61 | gatewayAddress: 'DEFAULT', 62 | }; 63 | 64 | export const ARIO_MAINNET_PROCESS_ID = 65 | 'qNvAoz0TgcH7DMg8BCVn8jF32QH5L6T29VjHxhHqqGE'; 66 | export const GASLESS_ARNS_DNS_EXPIRATION_TIME = 15 * 60 * 1000; // 15 minutes 67 | export const DEFAULT_AO_CU_URL = 'https://cu.ardrive.io'; 68 | export const MAX_HISTORY_ITEMS = 20; // How many items are stored in wayfinder history 69 | export const TOP_ONCHAIN_GATEWAY_LIMIT = 25; // The top amount of gateways returned for onchain performance ranking 70 | export const DNS_LOOKUP_API = 'https://dns.google/resolve'; 71 | export const RANDOM_ROUTE_METHOD = 'random'; 72 | export const STAKE_RANDOM_ROUTE_METHOD = 'stakeRandom'; 73 | export const HIGHEST_STAKE_ROUTE_METHOD = 'highestStake'; 74 | export const RANDOM_TOP_FIVE_STAKED_ROUTE_METHOD = 'topFiveStake'; 75 | export const WEIGHTED_ONCHAIN_PERFORMANCE_ROUTE_METHOD = 76 | 'weightedOnchainPerformance'; 77 | export const OPTIMAL_GATEWAY_ROUTE_METHOD = 'optimalGateway'; 78 | -------------------------------------------------------------------------------- /packages/extension/src/content.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * WayFinder 3 | * Copyright (C) 2022-2025 Permanent Data Solutions, Inc. 4 | * 5 | * Licensed under the Apache License, Version 2.0 (the "License"); 6 | * you may not use this file except in compliance with the License. 7 | * You may obtain a copy of the License at 8 | * 9 | * http://www.apache.org/licenses/LICENSE-2.0 10 | * 11 | * Unless required by applicable law or agreed to in writing, software 12 | * distributed under the License is distributed on an "AS IS" BASIS, 13 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 14 | * See the License for the specific language governing permissions and 15 | * limitations under the License. 16 | */ 17 | if (document.readyState === 'loading') { 18 | document.addEventListener('DOMContentLoaded', afterContentDOMLoaded); 19 | } else { 20 | afterContentDOMLoaded(); 21 | } 22 | 23 | async function afterContentDOMLoaded() { 24 | // Gather all elements with `ar://` protocol 25 | const arElements = document.querySelectorAll( 26 | 'a[href^="ar://"], img[src^="ar://"], iframe[src^="ar://"], ' + 27 | 'audio > source[src^="ar://"], video > source[src^="ar://"], ' + 28 | 'link[href^="ar://"], embed[src^="ar://"], object[data^="ar://"]', 29 | ); 30 | 31 | arElements.forEach((element) => { 32 | let arUrl: string | null = null; 33 | switch (element.tagName) { 34 | case 'A': 35 | arUrl = (element as HTMLAnchorElement).href; 36 | chrome.runtime.sendMessage( 37 | { type: 'convertArUrlToHttpUrl', arUrl }, 38 | (response) => { 39 | if (response && response.url) { 40 | (element as HTMLAnchorElement).href = response.url; 41 | } else { 42 | console.error(`Failed to load URL: ${response.error}`); 43 | } 44 | }, 45 | ); 46 | break; 47 | case 'IMG': 48 | arUrl = (element as HTMLImageElement).src; 49 | chrome.runtime.sendMessage( 50 | { type: 'convertArUrlToHttpUrl', arUrl }, 51 | (response) => { 52 | if (response && response.url) { 53 | (element as HTMLImageElement).src = response.url; 54 | } else { 55 | console.error(`Failed to load image: ${response.error}`); 56 | } 57 | }, 58 | ); 59 | break; 60 | case 'IFRAME': 61 | arUrl = (element as HTMLIFrameElement).src; 62 | chrome.runtime.sendMessage( 63 | { type: 'convertArUrlToHttpUrl', arUrl }, 64 | (response) => { 65 | if (response && response.url) { 66 | (element as HTMLIFrameElement).src = response.url; 67 | } else { 68 | console.error(`Failed to load iframe: ${response.error}`); 69 | } 70 | }, 71 | ); 72 | break; 73 | case 'SOURCE': 74 | arUrl = (element as HTMLSourceElement).src; 75 | if ( 76 | element.parentNode && 77 | element.parentNode.nodeType === Node.ELEMENT_NODE 78 | ) { 79 | const parentElement = element.parentNode as HTMLMediaElement; 80 | if ( 81 | parentElement.tagName === 'AUDIO' || 82 | parentElement.tagName === 'VIDEO' 83 | ) { 84 | chrome.runtime.sendMessage( 85 | { type: 'convertArUrlToHttpUrl', arUrl }, 86 | (response) => { 87 | if (response && response.url) { 88 | (element as HTMLSourceElement).src = response.url; 89 | parentElement.load(); // Load the media element with the new source 90 | } else { 91 | console.error(`Failed to load source: ${response.error}`); 92 | } 93 | }, 94 | ); 95 | } else { 96 | console.error('Unexpected parent for source element', element); 97 | } 98 | } 99 | break; 100 | case 'LINK': 101 | arUrl = (element as HTMLLinkElement).href; 102 | chrome.runtime.sendMessage( 103 | { type: 'convertArUrlToHttpUrl', arUrl }, 104 | (response) => { 105 | if (response && response.url) { 106 | // Create a clone of the original element 107 | const newLinkEl = element.cloneNode(true) as HTMLLinkElement; 108 | 109 | // Set the new URL to the cloned element 110 | newLinkEl.href = response.url; 111 | 112 | // Replace the old link element with the new one 113 | if (element.parentNode) { 114 | element.parentNode.replaceChild(newLinkEl, element); 115 | } 116 | } else { 117 | console.error(`Failed to load link element: ${response.error}`); 118 | } 119 | }, 120 | ); 121 | break; 122 | case 'EMBED': 123 | arUrl = (element as HTMLEmbedElement).src; 124 | chrome.runtime.sendMessage( 125 | { type: 'convertArUrlToHttpUrl', arUrl }, 126 | (response) => { 127 | if (response && response.url) { 128 | (element as HTMLEmbedElement).src = response.url; // Set the new URL 129 | } else { 130 | console.error(`Failed to load embed element: ${response.error}`); 131 | } 132 | }, 133 | ); 134 | break; 135 | case 'OBJECT': 136 | arUrl = (element as HTMLObjectElement).data; 137 | chrome.runtime.sendMessage( 138 | { type: 'convertArUrlToHttpUrl', arUrl }, 139 | (response) => { 140 | if (response && response.url) { 141 | (element as HTMLObjectElement).data = response.url; // Set the new URL 142 | } else { 143 | console.error(`Failed to load object: ${response.error}`); 144 | } 145 | }, 146 | ); 147 | break; 148 | default: 149 | console.error('Unexpected element', element); 150 | } 151 | }); 152 | } 153 | -------------------------------------------------------------------------------- /packages/extension/src/ens.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * WayFinder 3 | * Copyright (C) 2022-2025 Permanent Data Solutions, Inc. 4 | * 5 | * Licensed under the Apache License, Version 2.0 (the "License"); 6 | * you may not use this file except in compliance with the License. 7 | * You may obtain a copy of the License at 8 | * 9 | * http://www.apache.org/licenses/LICENSE-2.0 10 | * 11 | * Unless required by applicable law or agreed to in writing, software 12 | * distributed under the License is distributed on an "AS IS" BASIS, 13 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 14 | * See the License for the specific language governing permissions and 15 | * limitations under the License. 16 | */ 17 | export async function fetchEnsArweaveTxId( 18 | ensName: string, 19 | ): Promise { 20 | try { 21 | const response = await fetch(`https://api.ensdata.net/${ensName}`); 22 | if (!response.ok) throw new Error(`ENS API error: ${response.statusText}`); 23 | 24 | const data = await response.json(); 25 | return data['ar://'] || data['contentHash'] || null; // Return the Arweave TX ID or content hash if available 26 | } catch (error) { 27 | console.error(`❌ Failed to fetch ENS data for ${ensName}:`, error); 28 | return null; 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /packages/extension/src/helpers.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * WayFinder 3 | * Copyright (C) 2022-2025 Permanent Data Solutions, Inc. 4 | * 5 | * Licensed under the Apache License, Version 2.0 (the "License"); 6 | * you may not use this file except in compliance with the License. 7 | * You may obtain a copy of the License at 8 | * 9 | * http://www.apache.org/licenses/LICENSE-2.0 10 | * 11 | * Unless required by applicable law or agreed to in writing, software 12 | * distributed under the License is distributed on an "AS IS" BASIS, 13 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 14 | * See the License for the specific language governing permissions and 15 | * limitations under the License. 16 | */ 17 | import { AoGatewayWithAddress } from '@ar.io/sdk'; 18 | import { MAX_HISTORY_ITEMS, TOP_ONCHAIN_GATEWAY_LIMIT } from './constants'; 19 | import { getGarForRouting, selectTopOnChainGateways } from './routing'; 20 | 21 | export async function backgroundGatewayBenchmarking() { 22 | console.log( 23 | `📡 Running Gateway benchmark against top ${TOP_ONCHAIN_GATEWAY_LIMIT} gateways...`, 24 | ); 25 | 26 | const gar = await getGarForRouting(); 27 | const topGateways = selectTopOnChainGateways(gar).slice( 28 | 0, 29 | TOP_ONCHAIN_GATEWAY_LIMIT, 30 | ); // ✅ Limit to **Top 25**, avoid over-pinging 31 | 32 | if (topGateways.length === 0) { 33 | console.warn('⚠️ No top-performing gateways available.'); 34 | return; 35 | } 36 | 37 | const now = Date.now(); 38 | await Promise.allSettled( 39 | topGateways.map(async (gateway: AoGatewayWithAddress) => { 40 | const fqdn = gateway.settings.fqdn; 41 | const startTime = performance.now(); // ✅ Correctly record start time 42 | 43 | try { 44 | await fetch(`https://${fqdn}`, { method: 'HEAD', mode: 'no-cors' }); 45 | updateGatewayPerformance(fqdn, startTime); // ✅ Pass the original start time 46 | return { fqdn, responseTime: performance.now() - startTime }; 47 | } catch { 48 | updateGatewayPerformance(fqdn, startTime); // ❌ Still pass `startTime`, not 0 49 | return { fqdn, responseTime: Infinity }; 50 | } 51 | }), 52 | ); 53 | 54 | // 🔥 Update last benchmark timestamp 55 | await chrome.storage.local.set({ lastBenchmarkTime: now }); 56 | 57 | console.log('✅ Gateway benchmark completed and metrics updated.'); 58 | } 59 | 60 | /** 61 | * Runs a **background validation** for **top performing gateways** instead of a single cached one. 62 | * - If they are too slow, marks them as stale. 63 | */ 64 | export async function backgroundValidateCachedGateway() { 65 | console.log('📡 Running lightweight background gateway validation...'); 66 | 67 | const gar = await getGarForRouting(); 68 | const topGateways = selectTopOnChainGateways(gar).slice(0, 5); // 🔥 Validate top **5** gateways 69 | 70 | const now = Date.now(); 71 | const pingResults = await Promise.allSettled( 72 | topGateways.map(async (gateway) => { 73 | const fqdn = gateway.settings.fqdn; 74 | const start = performance.now(); 75 | try { 76 | await fetch(`https://${fqdn}`, { method: 'HEAD', mode: 'no-cors' }); 77 | const responseTime = performance.now() - start; 78 | 79 | updateGatewayPerformance(fqdn, responseTime); // ✅ Update EMA 80 | 81 | return { fqdn, responseTime }; 82 | } catch { 83 | updateGatewayPerformance(fqdn, 0); // ❌ Register failure 84 | return { fqdn, responseTime: Infinity }; 85 | } 86 | }), 87 | ); 88 | 89 | // 🔄 If all fail, schedule a **full benchmark** instead 90 | if ( 91 | pingResults.every((res) => (res as any).value?.responseTime === Infinity) 92 | ) { 93 | console.warn( 94 | '⚠️ Background validation failed. Scheduling full benchmark...', 95 | ); 96 | await backgroundGatewayBenchmarking(); 97 | } else { 98 | console.log('✅ Background validation completed.'); 99 | } 100 | 101 | // 🔥 Update last validation timestamp 102 | await chrome.storage.local.set({ lastBenchmarkTime: now }); 103 | } 104 | 105 | /** 106 | * Checks if a hostname belongs to a known AR.IO gateway. 107 | */ 108 | export async function isKnownGateway(fqdn: string): Promise { 109 | const normalizedFQDN = await normalizeGatewayFQDN(fqdn); 110 | 111 | const { localGatewayAddressRegistry = {} } = await chrome.storage.local.get([ 112 | 'localGatewayAddressRegistry', 113 | ]); 114 | 115 | return Object.values(localGatewayAddressRegistry).some( 116 | (gw: any) => gw.settings.fqdn === normalizedFQDN, 117 | ); 118 | } 119 | 120 | /** 121 | * Updates gateway performance metrics using an Exponential Moving Average (EMA). 122 | */ 123 | export async function updateGatewayPerformance( 124 | rawFQDN: string, // The full hostname from the request 125 | startTime: number, 126 | ) { 127 | const gatewayFQDN = await normalizeGatewayFQDN(rawFQDN); // ✅ Normalize before storage 128 | const responseTime = Math.max(0, performance.now() - startTime); // Prevent negatives 129 | 130 | // Ensure performance storage is initialized 131 | const storage = await chrome.storage.local.get(['gatewayPerformance']); 132 | const gatewayPerformance = storage.gatewayPerformance || {}; 133 | 134 | // Ensure the gateway entry exists 135 | if (!gatewayPerformance[gatewayFQDN]) { 136 | gatewayPerformance[gatewayFQDN] = { 137 | avgResponseTime: responseTime, // Set initial average 138 | failures: 0, 139 | successCount: 1, // First success 140 | }; 141 | } else { 142 | const prevAvg = 143 | gatewayPerformance[gatewayFQDN].avgResponseTime || responseTime; 144 | const alpha = 0.2; // **Smoothing factor (higher = reacts faster, lower = more stable)** 145 | 146 | // 🔥 Compute new EMA for response time 147 | gatewayPerformance[gatewayFQDN].avgResponseTime = 148 | alpha * responseTime + (1 - alpha) * prevAvg; 149 | 150 | gatewayPerformance[gatewayFQDN].successCount += 1; 151 | } 152 | 153 | // console.log( 154 | // `Updating Gateway Performance: ${gatewayFQDN} | New Response Time: ${responseTime} New Avg Response Time: ${gatewayPerformance[gatewayFQDN].avgResponseTime.toFixed(2)}ms` 155 | // ); 156 | 157 | // 🔥 Store under the **root** FQDN 158 | await chrome.storage.local.set({ gatewayPerformance }); 159 | } 160 | 161 | /** 162 | * Extracts the base gateway FQDN from a potentially subdomain-prefixed FQDN. 163 | * Ensures that ArNS subdomains and TXID-based URLs resolve to their root gateway. 164 | * 165 | * @param fqdn The full hostname from the request. 166 | * @returns The normalized gateway FQDN. 167 | */ 168 | export async function normalizeGatewayFQDN(fqdn: string): Promise { 169 | const { localGatewayAddressRegistry = {} } = await chrome.storage.local.get([ 170 | 'localGatewayAddressRegistry', 171 | ]); 172 | 173 | const knownGateways = Object.values(localGatewayAddressRegistry).map( 174 | (gw: any) => gw.settings.fqdn, 175 | ); 176 | 177 | // ✅ Direct match (e.g., `arweave.net`) 178 | if (knownGateways.includes(fqdn)) { 179 | return fqdn; 180 | } 181 | 182 | // 🔍 Check if fqdn is a **subdomain** of a known gateway (e.g., `example.arweave.net`) 183 | for (const gatewayFQDN of knownGateways) { 184 | if (fqdn.endsWith(`.${gatewayFQDN}`)) { 185 | return gatewayFQDN; // ✅ Return base FQDN 186 | } 187 | } 188 | 189 | // 🚨 Unknown gateway fallback 190 | // console.warn(`⚠️ Unknown gateway encountered: ${fqdn}`); 191 | return fqdn; 192 | } 193 | 194 | /** 195 | * Saves a history entry. 196 | */ 197 | export function saveToHistory( 198 | url: string, 199 | resolvedId: string, 200 | timestamp: string, 201 | ) { 202 | chrome.storage.local.get('history', (data) => { 203 | let history = data.history || []; 204 | history.unshift({ url, resolvedId, timestamp }); 205 | history = history.slice(0, MAX_HISTORY_ITEMS); 206 | chrome.storage.local.set({ history }); 207 | }); 208 | } 209 | 210 | export function isBase64URL(address: string): boolean { 211 | const trimmedBase64URL = address.toString().trim(); 212 | const BASE_64_REXEX = new RegExp('^[a-zA-Z0-9-_s+]{43}$'); 213 | return BASE_64_REXEX.test(trimmedBase64URL); 214 | } 215 | -------------------------------------------------------------------------------- /packages/extension/src/popup.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | AR.IO WayFinder 7 | 11 | 12 | 13 | 14 | 15 |
16 |

About the ar:// protocol

17 |

18 | WayFinder uses the ar:// protocol. This is a unique web 19 | address schema powered by the 20 | AR.IO Network 21 | and used to access content stored on the Arweave Permaweb. By using this 22 | protocol, users can directly navigate to and interact with decentralized 23 | web pages and applications via their 24 | ArNS Name 25 | or Arweave transaction ID. 26 |

27 |
28 |
29 | 32 | 50 | 57 |
58 | 59 | 60 | 61 | 62 | 164 | 165 | 166 | 167 | 215 | 238 | 239 | 240 | -------------------------------------------------------------------------------- /packages/extension/src/types.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * WayFinder 3 | * Copyright (C) 2022-2025 Permanent Data Solutions, Inc. 4 | * 5 | * Licensed under the Apache License, Version 2.0 (the "License"); 6 | * you may not use this file except in compliance with the License. 7 | * You may obtain a copy of the License at 8 | * 9 | * http://www.apache.org/licenses/LICENSE-2.0 10 | * 11 | * Unless required by applicable law or agreed to in writing, software 12 | * distributed under the License is distributed on an "AS IS" BASIS, 13 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 14 | * See the License for the specific language governing permissions and 15 | * limitations under the License. 16 | */ 17 | import { AoGatewayWithAddress } from '@ar.io/sdk'; 18 | 19 | export type RedirectedTabInfo = { 20 | originalGateway: string; // The original gateway FQDN (e.g., "permagate.io") 21 | expectedSandboxRedirect: boolean; // Whether we expect a sandbox redirect 22 | sandboxRedirectUrl?: string; // The final redirected URL (if applicable) 23 | startTime: number; // Timestamp of when the request started 24 | }; 25 | 26 | export type GatewayRegistry = Record; 27 | -------------------------------------------------------------------------------- /packages/extension/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "esnext", 4 | "module": "esnext", 5 | "moduleResolution": "bundler", 6 | "esModuleInterop": true, 7 | "strict": true, 8 | "jsx": "preserve", 9 | "sourceMap": true, 10 | "baseUrl": "." 11 | }, 12 | "include": ["src/**/*.ts", "src/**/*.tsx"], 13 | "exclude": ["node_modules"] 14 | } 15 | -------------------------------------------------------------------------------- /packages/extension/vite.config.js: -------------------------------------------------------------------------------- 1 | /** 2 | * WayFinder 3 | * Copyright (C) 2022-2025 Permanent Data Solutions, Inc. All Rights Reserved. 4 | * 5 | * This program is free software: you can redistribute it and/or modify 6 | * it under the terms of the GNU Affero General Public License as published by 7 | * the Free Software Foundation, either version 3 of the License, or 8 | * (at your option) any later version. 9 | * 10 | * This program is distributed in the hope that it will be useful, 11 | * but WITHOUT ANY WARRANTY; without even the implied warranty of 12 | * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 13 | * GNU Affero General Public License for more details. 14 | * 15 | * You should have received a copy of the GNU Affero General Public License 16 | * along with this program. If not, see . 17 | */ 18 | import { defineConfig } from 'vite'; 19 | import { nodePolyfills } from 'vite-plugin-node-polyfills'; 20 | import { viteStaticCopy } from 'vite-plugin-static-copy'; 21 | 22 | export default defineConfig({ 23 | plugins: [ 24 | nodePolyfills({ 25 | globals: { 26 | Buffer: true, 27 | global: true, 28 | process: true, 29 | }, 30 | }), 31 | viteStaticCopy({ 32 | targets: [ 33 | { 34 | src: 'src/popup.html', 35 | dest: '.', 36 | }, 37 | 38 | { 39 | src: 'manifest.json', 40 | dest: '.', 41 | }, 42 | { 43 | src: 'assets', 44 | dest: '', 45 | }, 46 | { 47 | src: 'package.json', 48 | dest: '.', 49 | }, 50 | ], 51 | }), 52 | ], 53 | build: { 54 | sourcemap: true, 55 | outDir: 'dist', 56 | emptyOutDir: true, 57 | rollupOptions: { 58 | input: { 59 | background: './src/background.ts', 60 | content: './src/content.ts', 61 | popup: './src/popup.ts', 62 | }, 63 | output: { 64 | entryFileNames: '[name].js', 65 | chunkFileNames: '[name].js', 66 | assetFileNames: '[name].[ext]', 67 | }, 68 | }, 69 | }, 70 | server: { 71 | port: 3000, 72 | }, 73 | }); 74 | -------------------------------------------------------------------------------- /packages/react/CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # @ar.io/wayfinder-react 2 | 3 | ## 0.0.5-alpha.5 4 | 5 | ### Patch Changes 6 | 7 | - Updated dependencies [2d72bba] 8 | - @ar.io/wayfinder-core@0.0.5-alpha.4 9 | 10 | ## 0.0.5-alpha.4 11 | 12 | ### Patch Changes 13 | 14 | - 46fe8a4: Adds useWayfinderUrl and useWayfinderRequest hooks 15 | - Updated dependencies [063e480] 16 | - @ar.io/wayfinder-core@0.0.5-alpha.3 17 | 18 | ## 0.0.5-alpha.3 19 | 20 | ### Patch Changes 21 | 22 | - Updated dependencies [b85ec7e] 23 | - @ar.io/wayfinder-core@0.0.5-alpha.2 24 | 25 | ## 0.0.5-alpha.2 26 | 27 | ### Patch Changes 28 | 29 | - 74efdd7: Update exports and docs for wayfinder-react 30 | 31 | ## 0.0.5-alpha.1 32 | 33 | ### Patch Changes 34 | 35 | - Updated dependencies [aba2beb] 36 | - @ar.io/wayfinder-core@0.0.5-alpha.1 37 | 38 | ## 0.0.5-alpha.0 39 | 40 | ### Patch Changes 41 | 42 | - Updated dependencies [4afd953] 43 | - @ar.io/wayfinder-core@0.0.5-alpha.0 44 | 45 | ## 0.0.4 46 | 47 | ### Patch Changes 48 | 49 | - Updated dependencies [e43548d] 50 | - @ar.io/wayfinder-core@0.0.4 51 | 52 | ## 0.0.4-alpha.1 53 | 54 | ### Patch Changes 55 | 56 | - Updated dependencies [78ad2b2] 57 | - @ar.io/wayfinder-core@0.0.4-alpha.1 58 | 59 | ## 0.0.4-alpha.0 60 | 61 | ### Patch Changes 62 | 63 | - Updated dependencies [7c81839] 64 | - @ar.io/wayfinder-core@0.0.4-alpha.0 65 | 66 | ## 0.0.3 67 | 68 | ### Patch Changes 69 | 70 | - Updated dependencies [53613fb] 71 | - Updated dependencies [c12a8f8] 72 | - Updated dependencies [45d2884] 73 | - Updated dependencies [8e7facb] 74 | - Updated dependencies [2605cdb] 75 | - Updated dependencies [d431437] 76 | - Updated dependencies [1ceb8df] 77 | - Updated dependencies [2109250] 78 | - @ar.io/wayfinder-core@0.0.3 79 | 80 | ## 0.0.3-alpha.6 81 | 82 | ### Patch Changes 83 | 84 | - Updated dependencies [1ceb8df] 85 | - @ar.io/wayfinder-core@0.0.3-alpha.6 86 | 87 | ## 0.0.3-alpha.5 88 | 89 | ### Patch Changes 90 | 91 | - Updated dependencies [53613fb] 92 | - @ar.io/wayfinder-core@0.0.3-alpha.5 93 | 94 | ## 0.0.3-alpha.4 95 | 96 | ### Patch Changes 97 | 98 | - Updated dependencies [8e7facb] 99 | - @ar.io/wayfinder-core@0.0.3-alpha.4 100 | 101 | ## 0.0.3-alpha.3 102 | 103 | ### Patch Changes 104 | 105 | - Updated dependencies [d431437] 106 | - @ar.io/wayfinder-core@0.0.3-alpha.3 107 | 108 | ## 0.0.3-alpha.2 109 | 110 | ### Patch Changes 111 | 112 | - Updated dependencies [2109250] 113 | - @ar.io/wayfinder-core@0.0.3-alpha.2 114 | 115 | ## 0.0.3-alpha.1 116 | 117 | ### Patch Changes 118 | 119 | - Updated dependencies [c12a8f8] 120 | - Updated dependencies [2605cdb] 121 | - @ar.io/wayfinder-core@0.0.3-alpha.1 122 | 123 | ## 0.0.3-beta.0 124 | 125 | ### Patch Changes 126 | 127 | - Updated dependencies [45d2884] 128 | - @ar.io/wayfinder-core@0.0.3-beta.0 129 | 130 | ## 0.0.2 131 | 132 | ### Patch Changes 133 | 134 | - updated build outputs 135 | - Updated dependencies 136 | - @ar.io/wayfinder-core@0.0.2 137 | 138 | ## 0.0.2-beta.0 139 | 140 | ### Patch Changes 141 | 142 | - Update LICENSE and package.json 143 | - Updated dependencies 144 | - @ar.io/wayfinder-core@0.0.2-beta.0 145 | -------------------------------------------------------------------------------- /packages/react/LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Wayfinder React Components 4 | Copyright (c) 2025 Permanent Data Solutions, Inc. 5 | 6 | Permission is hereby granted, free of charge, to any person obtaining a copy 7 | of this software and associated documentation files (the "Software"), to deal 8 | in the Software without restriction, including without limitation the rights 9 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 10 | copies of the Software, and to permit persons to whom the Software is 11 | furnished to do so, subject to the following conditions: 12 | 13 | The above copyright notice and this permission notice shall be included in all 14 | copies or substantial portions of the Software. 15 | 16 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 17 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 18 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 19 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 20 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 21 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 22 | SOFTWARE. -------------------------------------------------------------------------------- /packages/react/README.md: -------------------------------------------------------------------------------- 1 | # WayFinder React Components 2 | 3 | React components and hooks for the WayFinder project, making it easy to integrate AR.IO gateway routing in React applications. 4 | 5 | ## Features 6 | 7 | - React components for displaying and interacting with AR.IO gateways 8 | - Hooks for gateway selection and routing 9 | - Integration with the WayFinder core library 10 | 11 | ## Installation 12 | 13 | ```bash 14 | # Using npm 15 | npm install @ar.io/wayfinder-react @ar.io/wayfinder-core 16 | 17 | # Using yarn 18 | yarn add @ar.io/wayfinder-react @ar.io/wayfinder-core 19 | ``` 20 | 21 | ## Usage 22 | 23 | ```jsx 24 | import { 25 | WayfinderProvider, 26 | useWayfinderRequest, 27 | useWayfinderUrl, 28 | } from '@ar.io/wayfinder-react'; 29 | import { NetworkGatewaysProvider } from '@ar.io/wayfinder-core'; 30 | import { ARIO } from '@ar.io/sdk'; 31 | 32 | // Wrap your app with the provider 33 | function App() { 34 | return ( 35 | 44 | 45 | 46 | ); 47 | } 48 | 49 | // Use components 50 | function YourComponent() { 51 | const txId = 'your-transaction-id'; // Replace with actual txId 52 | 53 | // Use custom hooks for URL resolution and data fetching 54 | const request = useWayfinderRequest(); 55 | const { resolvedUrl, isLoading: urlLoading, error: urlError } = useWayfinderUrl({ txId }); 56 | 57 | // Use custom hooks for data fetching 58 | const [data, setData] = useState(null); 59 | const [dataLoading, setDataLoading] = useState(false); 60 | const [dataError, setDataError] = useState(null); 61 | 62 | useEffect(() => { 63 | (async () => { 64 | try { 65 | setDataLoading(true); 66 | setDataError(null); 67 | // fetch the data for the txId using wayfinder 68 | const response = await request(`ar://${txId}`, { 69 | verificationSettings: { 70 | enabled: true, // enable verification on the request 71 | strict: true, // don't use the data if it's not verified 72 | }, 73 | }); 74 | const data = await response.arrayBuffer(); // or response.json() if you want to parse the data as JSON 75 | setData(data); 76 | } catch (error) { 77 | setDataError(error as Error); 78 | } finally { 79 | setDataLoading(false); 80 | } 81 | })(); 82 | }, [request, txId]); 83 | 84 | return ( 85 |
86 | {urlLoading &&

Resolving URL...

} 87 | {urlError &&

Error resolving URL: {urlError.message}

} 88 | {resolvedUrl && View on WayFinder} 89 |
90 | {dataLoading &&

Loading data...

} 91 | {dataError &&

Error loading data: {dataError.message}

} 92 |
{data}
93 |
94 | ); 95 | } 96 | ``` 97 | -------------------------------------------------------------------------------- /packages/react/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@ar.io/wayfinder-react", 3 | "version": "0.0.5-alpha.5", 4 | "description": "React components for WayFinder", 5 | "main": "dist/index.js", 6 | "module": "dist/index.js", 7 | "types": "dist/index.d.ts", 8 | "type": "module", 9 | "license": "MIT", 10 | "publishConfig": { 11 | "access": "public" 12 | }, 13 | "files": ["dist", "package.json", "README.md", "LICENSE"], 14 | "author": { 15 | "name": "Permanent Data Solutions Inc", 16 | "email": "info@ar.io", 17 | "website": "https://ar.io" 18 | }, 19 | "repository": { 20 | "type": "git", 21 | "url": "git+https://github.com/ar-io/wayfinder.git" 22 | }, 23 | "scripts": { 24 | "build": "tsc", 25 | "clean": "rimraf dist", 26 | "test": "echo \"Passing\"", 27 | "lint:fix": "biome check --write --unsafe", 28 | "lint:check": "biome check --unsafe", 29 | "format:fix": "biome format --write", 30 | "format:check": "biome format" 31 | }, 32 | "dependencies": { 33 | "@ar.io/wayfinder-core": "0.0.5-alpha.4", 34 | "react": "^18.2.0", 35 | "react-dom": "^18.2.0" 36 | }, 37 | "devDependencies": { 38 | "@types/react": "^18.2.0", 39 | "@types/react-dom": "^18.2.0" 40 | }, 41 | "peerDependencies": { 42 | "react": "^18.0.0", 43 | "react-dom": "^18.0.0" 44 | } 45 | } 46 | -------------------------------------------------------------------------------- /packages/react/src/components/index.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * WayFinder 3 | * Copyright (C) 2022-2025 Permanent Data Solutions, Inc. 4 | * 5 | * Licensed under the Apache License, Version 2.0 (the "License"); 6 | * you may not use this file except in compliance with the License. 7 | * You may obtain a copy of the License at 8 | * 9 | * http://www.apache.org/licenses/LICENSE-2.0 10 | * 11 | * Unless required by applicable law or agreed to in writing, software 12 | * distributed under the License is distributed on an "AS IS" BASIS, 13 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 14 | * See the License for the specific language governing permissions and 15 | * limitations under the License. 16 | */ 17 | 18 | export * from './wayfinder-provider.js'; 19 | -------------------------------------------------------------------------------- /packages/react/src/components/wayfinder-provider.tsx: -------------------------------------------------------------------------------- 1 | /** 2 | * WayFinder 3 | * Copyright (C) 2022-2025 Permanent Data Solutions, Inc. 4 | * 5 | * Licensed under the Apache License, Version 2.0 (the "License"); 6 | * you may not use this file except in compliance with the License. 7 | * You may obtain a copy of the License at 8 | * 9 | * http://www.apache.org/licenses/LICENSE-2.0 10 | * 11 | * Unless required by applicable law or agreed to in writing, software 12 | * distributed under the License is distributed on an "AS IS" BASIS, 13 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 14 | * See the License for the specific language governing permissions and 15 | * limitations under the License. 16 | */ 17 | import { Wayfinder, type WayfinderOptions } from '@ar.io/wayfinder-core'; 18 | import React, { createContext, useMemo } from 'react'; 19 | 20 | export interface WayfinderContextValue { 21 | wayfinder: Wayfinder; 22 | } 23 | 24 | export const WayfinderContext = createContext< 25 | WayfinderContextValue | undefined 26 | >(undefined); 27 | 28 | export interface WayfinderProviderProps extends WayfinderOptions { 29 | children: React.ReactNode; 30 | } 31 | 32 | export const WayfinderProvider: React.FC = ({ 33 | children, 34 | ...options 35 | }) => { 36 | const wayfinder = useMemo(() => new Wayfinder(options), [options]); 37 | return ( 38 | 39 | {children} 40 | 41 | ); 42 | }; 43 | -------------------------------------------------------------------------------- /packages/react/src/hooks/index.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * WayFinder 3 | * Copyright (C) 2022-2025 Permanent Data Solutions, Inc. 4 | * 5 | * Licensed under the Apache License, Version 2.0 (the "License"); 6 | * you may not use this file except in compliance with the License. 7 | * You may obtain a copy of the License at 8 | * 9 | * http://www.apache.org/licenses/LICENSE-2.0 10 | * 11 | * Unless required by applicable law or agreed to in writing, software 12 | * distributed under the License is distributed on an "AS IS" BASIS, 13 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 14 | * See the License for the specific language governing permissions and 15 | * limitations under the License. 16 | */ 17 | 18 | import { Wayfinder } from '@ar.io/wayfinder-core'; 19 | import { useContext, useEffect, useState } from 'react'; 20 | import { 21 | WayfinderContext, 22 | type WayfinderContextValue, 23 | } from '../components/wayfinder-provider.js'; 24 | 25 | /** 26 | * Hook for getting the Wayfinder instance 27 | * @returns Wayfinder instance 28 | */ 29 | export const useWayfinder = (): WayfinderContextValue => { 30 | const context = useContext(WayfinderContext); 31 | if (!context) { 32 | throw new Error('useWayfinder must be used within a WayfinderProvider'); 33 | } 34 | return context; 35 | }; 36 | 37 | /** 38 | * Hook for getting the Wayfinder request function 39 | * @returns Wayfinder request function 40 | */ 41 | export const useWayfinderRequest = (): Wayfinder['request'] => { 42 | const { wayfinder } = useWayfinder(); 43 | return wayfinder.request; 44 | }; 45 | 46 | /** 47 | * Hook for resolving a transaction ID to a WayFinder URL using the Wayfinder instance (e.g. txId -> https:///txId) 48 | * @param txId - The transaction ID to resolve 49 | * @returns Object containing the resolved URL and loading state 50 | */ 51 | export const useWayfinderUrl = ({ txId }: { txId: string }) => { 52 | const { wayfinder } = useWayfinder(); 53 | const [resolvedUrl, setResolvedUrl] = useState(null); 54 | const [isLoading, setIsLoading] = useState(false); 55 | const [error, setError] = useState(null); 56 | 57 | useEffect(() => { 58 | if (!txId) { 59 | setResolvedUrl(null); 60 | setError(null); 61 | return; 62 | } 63 | 64 | setIsLoading(true); 65 | setError(null); 66 | 67 | (async () => { 68 | try { 69 | const resolved = await wayfinder.resolveUrl({ 70 | originalUrl: `ar://${txId}`, 71 | }); 72 | setResolvedUrl(resolved.toString()); 73 | } catch (err) { 74 | setError( 75 | err instanceof Error ? err : new Error('Failed to resolve URL'), 76 | ); 77 | } finally { 78 | setIsLoading(false); 79 | } 80 | })(); 81 | }, [txId, wayfinder]); 82 | 83 | return { resolvedUrl, isLoading, error, txId }; 84 | }; 85 | -------------------------------------------------------------------------------- /packages/react/src/index.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * WayFinder 3 | * Copyright (C) 2022-2025 Permanent Data Solutions, Inc. 4 | * 5 | * Licensed under the Apache License, Version 2.0 (the "License"); 6 | * you may not use this file except in compliance with the License. 7 | * You may obtain a copy of the License at 8 | * 9 | * http://www.apache.org/licenses/LICENSE-2.0 10 | * 11 | * Unless required by applicable law or agreed to in writing, software 12 | * distributed under the License is distributed on an "AS IS" BASIS, 13 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 14 | * See the License for the specific language governing permissions and 15 | * limitations under the License. 16 | */ 17 | 18 | export * from './components/index.js'; 19 | export * from './hooks/index.js'; 20 | -------------------------------------------------------------------------------- /packages/react/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "ESNext", 4 | "useDefineForClassFields": true, 5 | "lib": ["ESNext", "DOM"], 6 | "module": "NodeNext", 7 | "skipLibCheck": true, 8 | "moduleResolution": "NodeNext", 9 | "allowImportingTsExtensions": false, 10 | "resolveJsonModule": true, 11 | "isolatedModules": true, 12 | "noEmit": false, 13 | "declaration": true, 14 | "outDir": "dist", 15 | "strict": true, 16 | "noUnusedLocals": true, 17 | "noUnusedParameters": true, 18 | "noFallthroughCasesInSwitch": true, 19 | "jsx": "react-jsx", 20 | "esModuleInterop": true 21 | }, 22 | "include": ["src"] 23 | } 24 | -------------------------------------------------------------------------------- /resources/license.header.mjs: -------------------------------------------------------------------------------- 1 | /** 2 | * WayFinder 3 | * Copyright (C) 2022-2025 Permanent Data Solutions, Inc. 4 | * 5 | * Licensed under the Apache License, Version 2.0 (the "License"); 6 | * you may not use this file except in compliance with the License. 7 | * You may obtain a copy of the License at 8 | * 9 | * http://www.apache.org/licenses/LICENSE-2.0 10 | * 11 | * Unless required by applicable law or agreed to in writing, software 12 | * distributed under the License is distributed on an "AS IS" BASIS, 13 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 14 | * See the License for the specific language governing permissions and 15 | * limitations under the License. 16 | */ 17 | -------------------------------------------------------------------------------- /scripts/verify.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * WayFinder 3 | * Copyright (C) 2022-2025 Permanent Data Solutions, Inc. 4 | * 5 | * Licensed under the Apache License, Version 2.0 (the "License"); 6 | * you may not use this file except in compliance with the License. 7 | * You may obtain a copy of the License at 8 | * 9 | * http://www.apache.org/licenses/LICENSE-2.0 10 | * 11 | * Unless required by applicable law or agreed to in writing, software 12 | * distributed under the License is distributed on an "AS IS" BASIS, 13 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 14 | * See the License for the specific language governing permissions and 15 | * limitations under the License. 16 | */ 17 | import { ARIO } from '@ar.io/sdk'; 18 | import { NetworkGatewaysProvider } from '../packages/core/src/gateways/network.js'; 19 | import { StaticRoutingStrategy } from '../packages/core/src/routing/static.js'; 20 | import { 21 | VerificationStrategy, 22 | WayfinderEvent, 23 | } from '../packages/core/src/types.js'; 24 | import { DataRootVerificationStrategy } from '../packages/core/src/verification/data-root-verifier.js'; 25 | import { HashVerificationStrategy } from '../packages/core/src/verification/hash-verifier.js'; 26 | import { SignatureVerificationStrategy } from '../packages/core/src/verification/signature-verifier.js'; 27 | import { Wayfinder } from '../packages/core/src/wayfinder.js'; 28 | 29 | // Define the verification strategies 30 | type VerificationStrategyType = 'data-root' | 'hash' | 'signature'; 31 | 32 | // Parse command line arguments 33 | function parseArgs() { 34 | const args = process.argv.slice(2); 35 | const txId = args[0]; 36 | const strategyType = (args[1] || 'hash') as VerificationStrategyType; 37 | const gateway = args[2] || 'https://permagate.io'; 38 | 39 | if (!txId) { 40 | console.error('Usage: node verify.js [verification-strategy]'); 41 | console.error( 42 | 'Verification strategy options: data-root, hash, signature (default: hash)', 43 | ); 44 | process.exit(1); 45 | } 46 | 47 | if (!['data-root', 'hash', 'signature'].includes(strategyType)) { 48 | console.error( 49 | 'Invalid verification strategy. Options: data-root, hash, signature', 50 | ); 51 | process.exit(1); 52 | } 53 | 54 | return { txId, strategyType, gateway }; 55 | } 56 | 57 | // Create the appropriate verification strategy based on the input 58 | function createVerificationStrategy( 59 | strategyType: VerificationStrategyType, 60 | gateway: string, 61 | ): VerificationStrategy { 62 | // Use permagate.io as the trusted provider for verification 63 | const trustedGateway = new URL(gateway); 64 | 65 | switch (strategyType) { 66 | case 'data-root': 67 | return new DataRootVerificationStrategy({ 68 | trustedGateways: [trustedGateway], 69 | }); 70 | case 'hash': 71 | return new HashVerificationStrategy({ 72 | trustedGateways: [trustedGateway], 73 | }); 74 | case 'signature': 75 | return new SignatureVerificationStrategy({ 76 | trustedGateways: [trustedGateway], 77 | }); 78 | default: 79 | throw new Error(`Unknown verification strategy: ${strategyType}`); 80 | } 81 | } 82 | 83 | async function main() { 84 | const { txId, strategyType, gateway } = parseArgs(); 85 | 86 | console.log( 87 | `Verifying transaction ${txId} using ${strategyType} verification...`, 88 | ); 89 | 90 | try { 91 | // Create a Wayfinder instance with the appropriate verification strategy 92 | const verificationStrategy = createVerificationStrategy( 93 | strategyType, 94 | gateway, 95 | ); 96 | 97 | // Set up a static gateway provider with a known gateway 98 | const gatewaysProvider = new NetworkGatewaysProvider({ 99 | ario: ARIO.mainnet(), 100 | sortBy: 'operatorStake', 101 | sortOrder: 'desc', 102 | limit: 10, 103 | }); 104 | 105 | // Use static routing to simplify the verification process 106 | const routingStrategy = new StaticRoutingStrategy({ 107 | gateway: 'https://permagate.io', 108 | }); 109 | 110 | // Create the Wayfinder instance with strict verification (will throw errors if verification fails) 111 | const wayfinder = new Wayfinder({ 112 | gatewaysProvider, 113 | routingSettings: { 114 | strategy: routingStrategy, 115 | }, 116 | telemetrySettings: { 117 | enabled: true, 118 | sampleRate: 1, 119 | serviceName: 'verify-script', 120 | }, 121 | verificationSettings: { 122 | enabled: true, 123 | strategy: verificationStrategy, 124 | events: { 125 | onVerificationSucceeded: ( 126 | event: WayfinderEvent['verification-succeeded'], 127 | ) => { 128 | console.log( 129 | `✅ Verification successful for transaction ${event.txId}`, 130 | ); 131 | }, 132 | onVerificationFailed: ( 133 | error: WayfinderEvent['verification-failed'], 134 | ) => { 135 | console.error(`❌ Verification failed: ${error.message}`); 136 | if (error.cause) { 137 | console.error('Cause:', error.cause); 138 | } 139 | }, 140 | onVerificationProgress: ( 141 | event: WayfinderEvent['verification-progress'], 142 | ) => { 143 | const percentage = Math.round( 144 | (event.processedBytes / event.totalBytes) * 100, 145 | ); 146 | console.log( 147 | `Verifying: ${percentage}% (${event.processedBytes}/${event.totalBytes} bytes)\r`, 148 | ); 149 | }, 150 | }, 151 | }, 152 | logger: { 153 | debug: (_message: string, _data: Record) => { 154 | // noop 155 | console.log('debug', _message, _data); 156 | }, 157 | info: (message: string, data: Record) => { 158 | console.log(message, data); 159 | }, 160 | warn: (message: string, data: Record) => { 161 | console.log(message, data); 162 | }, 163 | error: (message: string, data: Record) => { 164 | console.error(message, data); 165 | }, 166 | }, 167 | }); 168 | 169 | // Request the transaction data using the ar:// protocol 170 | const response = await wayfinder.request(`ar://${txId}`); 171 | 172 | // Consume the response to ensure verification completes 173 | await response.text(); 174 | 175 | // wait for 15 seconds so telemetry can flush 176 | await new Promise((resolve) => setTimeout(resolve, 15000)); 177 | } catch (error) { 178 | console.error('Error during verification:'); 179 | console.error(error); 180 | process.exit(1); 181 | } 182 | } 183 | 184 | main().catch((error) => { 185 | console.error('Unhandled error:', error); 186 | process.exit(1); 187 | }); 188 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "esnext", 4 | "module": "esnext", 5 | "moduleResolution": "bundler", 6 | "esModuleInterop": true, 7 | "strict": true, 8 | "jsx": "preserve", 9 | "sourceMap": true, 10 | "baseUrl": "." 11 | }, 12 | "include": ["packages/**/*.ts", "packages/**/*.tsx", "scripts/**/*.ts"], 13 | "exclude": ["node_modules", "dist"] 14 | } 15 | --------------------------------------------------------------------------------