├── .github ├── FUNDING.yml ├── dependabot.yml ├── release.yml └── workflows │ ├── bump.yml │ ├── ci.yml │ ├── dependabot.yml │ ├── docs.yml │ └── publish.yml ├── .gitignore ├── .vscode ├── extensions.json └── settings.json ├── CONTRIBUTING.md ├── LICENSE ├── README.md ├── biome.json ├── bun.lock ├── docs └── getting-started │ ├── directory-structure.md │ ├── index.md │ └── installation.md ├── package.json ├── scripts └── exports.ts ├── src ├── index.ts ├── lib │ ├── cache │ │ ├── cache.test.ts │ │ └── cache.ts │ ├── clients │ │ ├── email-verifier.test.ts │ │ ├── email-verifier.ts │ │ └── pwned-passwords.ts │ ├── entities │ │ ├── entity.test.ts │ │ └── entity.ts │ ├── env │ │ ├── env.test.ts │ │ └── env.ts │ ├── errors.ts │ ├── fs │ │ ├── fs.test.ts │ │ └── fs.ts │ ├── geo │ │ ├── geo.test.ts │ │ └── geo.ts │ ├── jobs │ │ ├── job.ts │ │ └── manager.ts │ ├── kv │ │ ├── kv.test.ts │ │ └── kv.ts │ ├── parsers │ │ ├── number-parser.test.ts │ │ ├── number-parser.ts │ │ └── string-parser.ts │ ├── queue │ │ └── queue.ts │ ├── storage │ │ ├── accessors.ts │ │ └── storage.ts │ ├── tasks │ │ ├── manager.ts │ │ └── task.ts │ ├── types.ts │ └── values │ │ ├── email.test.ts │ │ ├── email.ts │ │ ├── ip-address.test.ts │ │ ├── ip-address.ts │ │ ├── password.test.ts │ │ ├── password.ts │ │ └── user-agent.ts ├── mocks │ └── cf.ts └── worker.ts ├── tsconfig.json └── typedoc.json /.github/FUNDING.yml: -------------------------------------------------------------------------------- 1 | github: sergiodxa 2 | -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | enable-beta-ecosystems: true 3 | 4 | updates: 5 | - package-ecosystem: "github-actions" 6 | directory: "/" 7 | schedule: 8 | interval: "monthly" 9 | reviewers: 10 | - "sergiodxa" 11 | assignees: 12 | - "sergiodxa" 13 | 14 | - package-ecosystem: bun 15 | directory: / 16 | schedule: 17 | interval: "weekly" 18 | reviewers: 19 | - "sergiodxa" 20 | assignees: 21 | - "sergiodxa" 22 | -------------------------------------------------------------------------------- /.github/release.yml: -------------------------------------------------------------------------------- 1 | changelog: 2 | categories: 3 | - title: New Features 4 | labels: 5 | - enhancement 6 | - title: Documentation Changes 7 | labels: 8 | - documentation 9 | - title: Bug Fixes 10 | labels: 11 | - bug 12 | - title: Example 13 | labels: 14 | - example 15 | - title: Deprecations 16 | labels: 17 | - deprecated 18 | - title: Other Changes 19 | labels: 20 | - "*" 21 | -------------------------------------------------------------------------------- /.github/workflows/bump.yml: -------------------------------------------------------------------------------- 1 | name: Bump version 2 | 3 | on: 4 | workflow_dispatch: 5 | inputs: 6 | version: 7 | description: "Type of version to bump" 8 | required: true 9 | type: choice 10 | options: 11 | - major 12 | - minor 13 | - patch 14 | - premajor 15 | - preminor 16 | - prepatch 17 | - prerelease 18 | 19 | jobs: 20 | bump-version: 21 | name: Bump version 22 | runs-on: ubuntu-latest 23 | steps: 24 | - uses: actions/checkout@v4 25 | with: 26 | ssh-key: ${{ secrets.DEPLOY_KEY }} 27 | 28 | - uses: oven-sh/setup-bun@v2 29 | - run: bun install --frozen-lockfile 30 | 31 | - uses: actions/setup-node@v4 32 | with: 33 | node-version: "lts/*" 34 | 35 | - run: | 36 | git config user.name 'Sergio Xalambrí' 37 | git config user.email 'hello@sergiodxa.com' 38 | 39 | - run: npm version ${{ github.event.inputs.version }} 40 | - run: git push origin main --follow-tags 41 | -------------------------------------------------------------------------------- /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | name: CI 2 | 3 | on: [push] 4 | 5 | jobs: 6 | build: 7 | name: Build 8 | runs-on: ubuntu-latest 9 | steps: 10 | - uses: actions/checkout@v4 11 | - uses: oven-sh/setup-bun@v2 12 | - run: bun install --frozen-lockfile 13 | - run: bun run build 14 | 15 | typecheck: 16 | name: Typechecker 17 | runs-on: ubuntu-latest 18 | steps: 19 | - uses: actions/checkout@v4 20 | - uses: oven-sh/setup-bun@v2 21 | - run: bun install --frozen-lockfile 22 | - run: bun run typecheck 23 | 24 | quality: 25 | name: Code Quality 26 | runs-on: ubuntu-latest 27 | steps: 28 | - uses: actions/checkout@v4 29 | - uses: oven-sh/setup-bun@v2 30 | - run: bun install --frozen-lockfile 31 | - run: bun run quality 32 | 33 | test: 34 | name: Tests 35 | runs-on: ubuntu-latest 36 | steps: 37 | - uses: actions/checkout@v4 38 | - uses: oven-sh/setup-bun@v2 39 | - run: bun install --frozen-lockfile 40 | - run: bun test 41 | 42 | exports: 43 | name: Verify Exports 44 | runs-on: ubuntu-latest 45 | steps: 46 | - uses: actions/checkout@v4 47 | - uses: oven-sh/setup-bun@v2 48 | - run: bun install --frozen-lockfile 49 | - run: bun run exports 50 | -------------------------------------------------------------------------------- /.github/workflows/dependabot.yml: -------------------------------------------------------------------------------- 1 | name: Enable auto-merge for Dependabot PRs 2 | 3 | on: 4 | pull_request: 5 | types: opened 6 | 7 | permissions: 8 | contents: write 9 | pull-requests: write 10 | 11 | jobs: 12 | dependabot: 13 | runs-on: ubuntu-latest 14 | if: ${{ github.actor == 'dependabot[bot]' }} 15 | steps: 16 | - id: metadata 17 | uses: dependabot/fetch-metadata@v2 18 | with: 19 | github-token: "${{ secrets.GITHUB_TOKEN }}" 20 | 21 | - run: gh pr merge --auto --squash "$PR_URL" 22 | env: 23 | PR_URL: ${{github.event.pull_request.html_url}} 24 | GITHUB_TOKEN: ${{secrets.GITHUB_TOKEN}} 25 | -------------------------------------------------------------------------------- /.github/workflows/docs.yml: -------------------------------------------------------------------------------- 1 | name: Deploy Documentation 2 | 3 | on: 4 | release: 5 | types: [published] 6 | workflow_dispatch: 7 | 8 | permissions: 9 | contents: read 10 | pages: write 11 | id-token: write 12 | 13 | concurrency: 14 | group: "docs" 15 | cancel-in-progress: false 16 | 17 | jobs: 18 | deploy: 19 | environment: 20 | name: github-pages 21 | url: ${{ steps.deployment.outputs.page_url }} 22 | runs-on: ubuntu-latest 23 | steps: 24 | - uses: actions/checkout@v4 25 | - uses: oven-sh/setup-bun@v2 26 | - run: bun install --frozen-lockfile && bunx typedoc 27 | - uses: actions/configure-pages@v5 28 | - uses: actions/upload-pages-artifact@v3 29 | with: 30 | path: "./pages" 31 | - uses: actions/deploy-pages@v4 32 | id: deployment 33 | -------------------------------------------------------------------------------- /.github/workflows/publish.yml: -------------------------------------------------------------------------------- 1 | name: Publish 2 | 3 | on: 4 | release: 5 | types: [published] 6 | 7 | jobs: 8 | publish-npm: 9 | name: "Publish to npm" 10 | runs-on: ubuntu-latest 11 | permissions: 12 | contents: read 13 | id-token: write 14 | steps: 15 | - uses: actions/checkout@v4 16 | - uses: oven-sh/setup-bun@v2 17 | - run: bun install --frozen-lockfile 18 | - run: bun run build 19 | - run: bun run exports 20 | 21 | - uses: actions/setup-node@v4 22 | with: 23 | node-version: "lts/*" 24 | registry-url: "https://registry.npmjs.org" 25 | 26 | - run: npm publish --provenance --access public 27 | env: 28 | NODE_AUTH_TOKEN: ${{secrets.NPM_TOKEN}} 29 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | /build 2 | /coverage 3 | /pages 4 | /node_modules -------------------------------------------------------------------------------- /.vscode/extensions.json: -------------------------------------------------------------------------------- 1 | { 2 | "recommendations": ["biomejs.biome"] 3 | } 4 | -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "[javascript]": { 3 | "editor.defaultFormatter": "biomejs.biome" 4 | }, 5 | "[javascriptreact]": { 6 | "editor.defaultFormatter": "biomejs.biome" 7 | }, 8 | "[json]": { 9 | "editor.defaultFormatter": "biomejs.biome" 10 | }, 11 | "[jsonc]": { 12 | "editor.defaultFormatter": "biomejs.biome" 13 | }, 14 | "[typescript]": { 15 | "editor.defaultFormatter": "biomejs.biome" 16 | }, 17 | "[typescriptreact]": { 18 | "editor.defaultFormatter": "biomejs.biome" 19 | }, 20 | "editor.codeActionsOnSave": { 21 | "source.organizeImports.biome": "explicit", 22 | "quickfix.biome": "explicit" 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # Contribution 2 | 3 | ## Setup 4 | 5 | Run `bun install` to install the dependencies. 6 | 7 | Run the tests with `bun test`. 8 | 9 | Run the code quality checker with `bun run quality`. 10 | 11 | Run the typechecker with `bun run typecheck`. 12 | 13 | Run the exports checker with `bun run exports`. 14 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2024 Sergio Xalambrí 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # EdgeKit.js 2 | 3 | EdgeKit.js is a toolkit to helps you build on top of Cloudflare Development Platform with ease. 4 | 5 | ## Features 6 | 7 | - Global environment access 8 | - Key-Value Store 9 | - Server-side Cache with TTL 10 | - File Storage 11 | - Database 12 | - Background Jobs 13 | - Scheduled Tasks 14 | - Browser Rendering 15 | - Rate Limiting 16 | - And more things! 17 | 18 | ## Usage 19 | 20 | Create a new Edge-first app using EdgeKit.js with the following command: 21 | 22 | ```bash 23 | npx degit edgefirst-dev/starter my-app 24 | ``` 25 | 26 | This will give you a new Cloudflare Worker project with EdgeKit.js already setup and React Router v7. 27 | 28 | ## Manual Setup 29 | 30 | Install the toolkit: 31 | 32 | ```bash 33 | bun add edgekitjs 34 | ``` 35 | 36 | In your Cloudflare Worker, call the `bootstrap` function and export it. 37 | 38 | ```ts 39 | import schema from "db:schema"; // Import your Drizzle schema 40 | import { bootstrap } from "edgekitjs/worker"; 41 | 42 | export default bootstrap({ 43 | orm: { schema }, 44 | 45 | rateLimit: { limit: 1000, period: 60 }, 46 | 47 | jobs() { 48 | // Register your jobs here 49 | return []; 50 | }, 51 | 52 | tasks() { 53 | // Schedule your tasks here 54 | return []; 55 | } 56 | 57 | async onRequest(request) { 58 | // Inside this function you can use all the functions provided by EdgeKit.js 59 | return new Response("Hello, World!", { status: 200 }); 60 | }, 61 | }); 62 | 63 | 64 | declare module "edgekitjs" { 65 | export interface Environment { 66 | // Add your custom env variables or bindings here 67 | } 68 | 69 | // Override the default DatabaseSchema with your own 70 | type Schema = typeof schema; 71 | export interface DatabaseSchema extends Schema {} 72 | } 73 | ``` 74 | 75 | Now you can import the functions from `edgekitjs` and use it in any part of your Edge-first app. 76 | 77 | ## Author 78 | 79 | - [Sergio Xalambrí](https://sergiodxa.com) 80 | -------------------------------------------------------------------------------- /biome.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://biomejs.dev/schemas/1.7.1/schema.json", 3 | "organizeImports": { 4 | "enabled": true 5 | }, 6 | "linter": { 7 | "enabled": true, 8 | "rules": { 9 | "recommended": true, 10 | "correctness": { 11 | "useHookAtTopLevel": "error" 12 | }, 13 | "performance": { 14 | "noBarrelFile": "error", 15 | "noReExportAll": "error" 16 | }, 17 | "style": { 18 | "noDefaultExport": "error", 19 | "noNegationElse": "error", 20 | "useConst": "off", 21 | "useExportType": "off", 22 | "useImportType": "off" 23 | }, 24 | "suspicious": { 25 | "noConsoleLog": "warn", 26 | "noEmptyBlockStatements": "warn", 27 | "noSkippedTests": "error" 28 | } 29 | } 30 | }, 31 | "formatter": { "enabled": true }, 32 | "vcs": { 33 | "enabled": true, 34 | "clientKind": "git", 35 | "defaultBranch": "main", 36 | "useIgnoreFile": true 37 | }, 38 | "overrides": [ 39 | { 40 | "include": ["**/*.md"], 41 | "formatter": { "indentStyle": "tab" } 42 | } 43 | ] 44 | } 45 | -------------------------------------------------------------------------------- /bun.lock: -------------------------------------------------------------------------------- 1 | { 2 | "lockfileVersion": 1, 3 | "workspaces": { 4 | "": { 5 | "name": "edgekitjs", 6 | "dependencies": { 7 | "@cloudflare/puppeteer": "^1.0.1", 8 | "@edgefirst-dev/api-client": "^0.1.0", 9 | "@edgefirst-dev/data": "^0.0.4", 10 | "@edgefirst-dev/r2-file-storage": "^1.1.0", 11 | "@edgefirst-dev/worker-kv-rate-limit": "^1.0.0", 12 | "@mjackson/form-data-parser": "^0.7.0", 13 | "@mjackson/headers": "^0.10.0", 14 | "@oslojs/crypto": "^1.0.1", 15 | "@oslojs/encoding": "^1.1.0", 16 | "@paralleldrive/cuid2": "^2.2.2", 17 | "bcryptjs": "^3.0.0", 18 | "bowser": "^2.11.0", 19 | "inflected": "^2.1.0", 20 | "is-ip": "^5.0.1", 21 | "type-fest": "^4.30.0", 22 | }, 23 | "devDependencies": { 24 | "@arethetypeswrong/cli": "^0.18.1", 25 | "@biomejs/biome": "^1.9.4", 26 | "@total-typescript/tsconfig": "^1.0.4", 27 | "@types/bcryptjs": "^3.0.0", 28 | "@types/bun": "^1.2.5", 29 | "@types/inflected": "^2.1.3", 30 | "consola": "^3.2.3", 31 | "msw": "^2.6.8", 32 | "typedoc": "^0.28.0", 33 | "typedoc-plugin-mdn-links": "^5.0.1", 34 | "typescript": "^5.7.2", 35 | "wrangler": "^4.0.0", 36 | }, 37 | "peerDependencies": { 38 | "@cloudflare/workers-types": "^4.20250313.0", 39 | "drizzle-orm": "^0.40.0", 40 | }, 41 | }, 42 | }, 43 | "packages": { 44 | "@andrewbranch/untar.js": ["@andrewbranch/untar.js@1.0.3", "", {}, "sha512-Jh15/qVmrLGhkKJBdXlK1+9tY4lZruYjsgkDFj08ZmDiWVBLJcqkok7Z0/R0In+i1rScBpJlSvrTS2Lm41Pbnw=="], 45 | 46 | "@arethetypeswrong/cli": ["@arethetypeswrong/cli@0.18.1", "", { "dependencies": { "@arethetypeswrong/core": "0.18.1", "chalk": "^4.1.2", "cli-table3": "^0.6.3", "commander": "^10.0.1", "marked": "^9.1.2", "marked-terminal": "^7.1.0", "semver": "^7.5.4" }, "bin": { "attw": "dist/index.js" } }, "sha512-SS1Z5gRSvbP4tl98KlNygSUp3Yfenktt782MQKEbYm6GFPowztnnvdEUhQGm2uVDIH4YkU6av+n8Lm6OEOigqA=="], 47 | 48 | "@arethetypeswrong/core": ["@arethetypeswrong/core@0.18.1", "", { "dependencies": { "@andrewbranch/untar.js": "^1.0.3", "@loaderkit/resolve": "^1.0.2", "cjs-module-lexer": "^1.2.3", "fflate": "^0.8.2", "lru-cache": "^11.0.1", "semver": "^7.5.4", "typescript": "5.6.1-rc", "validate-npm-package-name": "^5.0.0" } }, "sha512-uUw47cLgB6zYOpAxFp94NG/J9ev0wcOC+UOmTCFEWtbDEn4vpR0ScoPxD7LCGcPczOd7bDJSJL/gMSz3BknYcw=="], 49 | 50 | "@biomejs/biome": ["@biomejs/biome@1.9.4", "", { "optionalDependencies": { "@biomejs/cli-darwin-arm64": "1.9.4", "@biomejs/cli-darwin-x64": "1.9.4", "@biomejs/cli-linux-arm64": "1.9.4", "@biomejs/cli-linux-arm64-musl": "1.9.4", "@biomejs/cli-linux-x64": "1.9.4", "@biomejs/cli-linux-x64-musl": "1.9.4", "@biomejs/cli-win32-arm64": "1.9.4", "@biomejs/cli-win32-x64": "1.9.4" }, "bin": { "biome": "bin/biome" } }, "sha512-1rkd7G70+o9KkTn5KLmDYXihGoTaIGO9PIIN2ZB7UJxFrWw04CZHPYiMRjYsaDvVV7hP1dYNRLxSANLaBFGpog=="], 51 | 52 | "@biomejs/cli-darwin-arm64": ["@biomejs/cli-darwin-arm64@1.9.4", "", { "os": "darwin", "cpu": "arm64" }, "sha512-bFBsPWrNvkdKrNCYeAp+xo2HecOGPAy9WyNyB/jKnnedgzl4W4Hb9ZMzYNbf8dMCGmUdSavlYHiR01QaYR58cw=="], 53 | 54 | "@biomejs/cli-darwin-x64": ["@biomejs/cli-darwin-x64@1.9.4", "", { "os": "darwin", "cpu": "x64" }, "sha512-ngYBh/+bEedqkSevPVhLP4QfVPCpb+4BBe2p7Xs32dBgs7rh9nY2AIYUL6BgLw1JVXV8GlpKmb/hNiuIxfPfZg=="], 55 | 56 | "@biomejs/cli-linux-arm64": ["@biomejs/cli-linux-arm64@1.9.4", "", { "os": "linux", "cpu": "arm64" }, "sha512-fJIW0+LYujdjUgJJuwesP4EjIBl/N/TcOX3IvIHJQNsAqvV2CHIogsmA94BPG6jZATS4Hi+xv4SkBBQSt1N4/g=="], 57 | 58 | "@biomejs/cli-linux-arm64-musl": ["@biomejs/cli-linux-arm64-musl@1.9.4", "", { "os": "linux", "cpu": "arm64" }, "sha512-v665Ct9WCRjGa8+kTr0CzApU0+XXtRgwmzIf1SeKSGAv+2scAlW6JR5PMFo6FzqqZ64Po79cKODKf3/AAmECqA=="], 59 | 60 | "@biomejs/cli-linux-x64": ["@biomejs/cli-linux-x64@1.9.4", "", { "os": "linux", "cpu": "x64" }, "sha512-lRCJv/Vi3Vlwmbd6K+oQ0KhLHMAysN8lXoCI7XeHlxaajk06u7G+UsFSO01NAs5iYuWKmVZjmiOzJ0OJmGsMwg=="], 61 | 62 | "@biomejs/cli-linux-x64-musl": ["@biomejs/cli-linux-x64-musl@1.9.4", "", { "os": "linux", "cpu": "x64" }, "sha512-gEhi/jSBhZ2m6wjV530Yy8+fNqG8PAinM3oV7CyO+6c3CEh16Eizm21uHVsyVBEB6RIM8JHIl6AGYCv6Q6Q9Tg=="], 63 | 64 | "@biomejs/cli-win32-arm64": ["@biomejs/cli-win32-arm64@1.9.4", "", { "os": "win32", "cpu": "arm64" }, "sha512-tlbhLk+WXZmgwoIKwHIHEBZUwxml7bRJgk0X2sPyNR3S93cdRq6XulAZRQJ17FYGGzWne0fgrXBKpl7l4M87Hg=="], 65 | 66 | "@biomejs/cli-win32-x64": ["@biomejs/cli-win32-x64@1.9.4", "", { "os": "win32", "cpu": "x64" }, "sha512-8Y5wMhVIPaWe6jw2H+KlEm4wP/f7EW3810ZLmDlrEEy5KvBsb9ECEfu/kMWD484ijfQ8+nIi0giMgu9g1UAuuA=="], 67 | 68 | "@braidai/lang": ["@braidai/lang@1.1.0", "", {}, "sha512-xyJYkiyNQtTyCLeHxZmOs7rnB94D+N1IjKNArQIh8+8lTBOY7TFgwEV+Ow5a1uaBi5j2w9fLbWcJFTWLDItl5g=="], 69 | 70 | "@bundled-es-modules/cookie": ["@bundled-es-modules/cookie@2.0.1", "", { "dependencies": { "cookie": "^0.7.2" } }, "sha512-8o+5fRPLNbjbdGRRmJj3h6Hh1AQJf2dk3qQ/5ZFb+PXkRNiSoMGGUKlsgLfrxneb72axVJyIYji64E2+nNfYyw=="], 71 | 72 | "@bundled-es-modules/statuses": ["@bundled-es-modules/statuses@1.0.1", "", { "dependencies": { "statuses": "^2.0.1" } }, "sha512-yn7BklA5acgcBr+7w064fGV+SGIFySjCKpqjcWgBAIfrAkY+4GQTJJHQMeT3V/sgz23VTEVV8TtOmkvJAhFVfg=="], 73 | 74 | "@bundled-es-modules/tough-cookie": ["@bundled-es-modules/tough-cookie@0.1.6", "", { "dependencies": { "@types/tough-cookie": "^4.0.5", "tough-cookie": "^4.1.4" } }, "sha512-dvMHbL464C0zI+Yqxbz6kZ5TOEp7GLW+pry/RWndAR8MJQAXZ2rPmIs8tziTZjeIyhSNZgZbCePtfSbdWqStJw=="], 75 | 76 | "@cloudflare/kv-asset-handler": ["@cloudflare/kv-asset-handler@0.4.0", "", { "dependencies": { "mime": "^3.0.0" } }, "sha512-+tv3z+SPp+gqTIcImN9o0hqE9xyfQjI1XD9pL6NuKjua9B1y7mNYv0S9cP+QEbA4ppVgGZEmKOvHX5G5Ei1CVA=="], 77 | 78 | "@cloudflare/puppeteer": ["@cloudflare/puppeteer@1.0.2", "", { "dependencies": { "@puppeteer/browsers": "2.2.3", "debug": "4.3.4", "devtools-protocol": "0.0.1273771", "ws": "8.17.0" } }, "sha512-I4UmeOg/9lmdrDqcXp1ZZALjCpk6ysygMZifVoI75YNuVHmvOjfkaXWRE1p/WgAs9phDeZrY7AqoK1YLGHwwww=="], 79 | 80 | "@cloudflare/unenv-preset": ["@cloudflare/unenv-preset@2.3.2", "", { "peerDependencies": { "unenv": "2.0.0-rc.17", "workerd": "^1.20250508.0" }, "optionalPeers": ["workerd"] }, "sha512-MtUgNl+QkQyhQvv5bbWP+BpBC1N0me4CHHuP2H4ktmOMKdB/6kkz/lo+zqiA4mEazb4y+1cwyNjVrQ2DWeE4mg=="], 81 | 82 | "@cloudflare/workerd-darwin-64": ["@cloudflare/workerd-darwin-64@1.20250525.0", "", { "os": "darwin", "cpu": "x64" }, "sha512-L5l+7sSJJT2+riR5rS3Q3PKNNySPjWfRIeaNGMVRi1dPO6QPi4lwuxfRUFNoeUdilZJUVPfSZvTtj9RedsKznQ=="], 83 | 84 | "@cloudflare/workerd-darwin-arm64": ["@cloudflare/workerd-darwin-arm64@1.20250525.0", "", { "os": "darwin", "cpu": "arm64" }, "sha512-Y3IbIdrF/vJWh/WBvshwcSyUh175VAiLRW7963S1dXChrZ1N5wuKGQm9xY69cIGVtitpMJWWW3jLq7J/Xxwm0Q=="], 85 | 86 | "@cloudflare/workerd-linux-64": ["@cloudflare/workerd-linux-64@1.20250525.0", "", { "os": "linux", "cpu": "x64" }, "sha512-KSyQPAby+c6cpENoO0ayCQlY6QIh28l/+QID7VC1SLXfiNHy+hPNsH1vVBTST6CilHVAQSsy9tCZ9O9XECB8yg=="], 87 | 88 | "@cloudflare/workerd-linux-arm64": ["@cloudflare/workerd-linux-arm64@1.20250525.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-Nt0FUxS2kQhJUea4hMCNPaetkrAFDhPnNX/ntwcqVlGgnGt75iaAhupWJbU0GB+gIWlKeuClUUnDZqKbicoKyg=="], 89 | 90 | "@cloudflare/workerd-windows-64": ["@cloudflare/workerd-windows-64@1.20250525.0", "", { "os": "win32", "cpu": "x64" }, "sha512-mwTj+9f3uIa4NEXR1cOa82PjLa6dbrb3J+KCVJFYIaq7e63VxEzOchCXS4tublT2pmOhmFqkgBMXrxozxNkR2Q=="], 91 | 92 | "@cloudflare/workers-types": ["@cloudflare/workers-types@4.20250313.0", "", {}, "sha512-iqyzZwogC+3yL8h58vMhjYQUHUZA8XazE3Q+ofR59wDOnsPe/u9W6+aCl408N0iNBhMPvpJQQ3/lkz0iJN2fhA=="], 93 | 94 | "@colors/colors": ["@colors/colors@1.5.0", "", {}, "sha512-ooWCrlZP11i8GImSjTHYHLkvFDP48nS4+204nGb1RiX/WXYHmJA2III9/e2DWVabCESdW7hBAEzHRqUn9OUVvQ=="], 95 | 96 | "@cspotcode/source-map-support": ["@cspotcode/source-map-support@0.8.1", "", { "dependencies": { "@jridgewell/trace-mapping": "0.3.9" } }, "sha512-IchNf6dN4tHoMFIn/7OE8LWZ19Y6q/67Bmf6vnGREv8RSbBVb9LPJxEcnwrcwX6ixSvaiGoomAUvu4YSxXrVgw=="], 97 | 98 | "@edgefirst-dev/api-client": ["@edgefirst-dev/api-client@0.1.0", "", {}, "sha512-8K++H9Bck1h80gDz8q9B77CC1kFu8YnNnNgREdxj4/PniUNad9TPSFGwlCH2ex+thc+s8O9tVm2k2v7Gd4HV0g=="], 99 | 100 | "@edgefirst-dev/data": ["@edgefirst-dev/data@0.0.4", "", {}, "sha512-VLhlvEPDJ0Sd0pE6sAYTQkIqZCXVonaWlgRJIQQHzfjTXCadF77qqHj5NxaPSc4wCul0DJO/0MnejVqJAXUiRg=="], 101 | 102 | "@edgefirst-dev/r2-file-storage": ["@edgefirst-dev/r2-file-storage@1.1.0", "", { "peerDependencies": { "@cloudflare/workers-types": "^4.20250320.0", "@mjackson/file-storage": "^0.6.1" } }, "sha512-HT6Isrhp6Ewd1j2xpREFcSVLeARK+emrMwnYJ2gQsGCA9LdTu2+k3l8KTE/dbHpVvy5c9g4J0rjAuxnNV1LjtA=="], 103 | 104 | "@edgefirst-dev/worker-kv-rate-limit": ["@edgefirst-dev/worker-kv-rate-limit@1.0.0", "", { "dependencies": { "@cloudflare/workers-types": "^4.20240903.0" } }, "sha512-YV3HMDmyzP/oBfTmqdlZoKebIlgk50pXakYyK0rdp8D9bxoGD9P5fM8fgJS1W2AWPYTmmskJhNn+ppah1vmjiA=="], 105 | 106 | "@emnapi/runtime": ["@emnapi/runtime@1.3.1", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-kEBmG8KyqtxJZv+ygbEim+KCGtIq1fC22Ms3S4ziXmYKm8uyoLX0MHONVKwp+9opg390VaKRNt4a7A9NwmpNhw=="], 107 | 108 | "@esbuild/aix-ppc64": ["@esbuild/aix-ppc64@0.25.4", "", { "os": "aix", "cpu": "ppc64" }, "sha512-1VCICWypeQKhVbE9oW/sJaAmjLxhVqacdkvPLEjwlttjfwENRSClS8EjBz0KzRyFSCPDIkuXW34Je/vk7zdB7Q=="], 109 | 110 | "@esbuild/android-arm": ["@esbuild/android-arm@0.25.4", "", { "os": "android", "cpu": "arm" }, "sha512-QNdQEps7DfFwE3hXiU4BZeOV68HHzYwGd0Nthhd3uCkkEKK7/R6MTgM0P7H7FAs5pU/DIWsviMmEGxEoxIZ+ZQ=="], 111 | 112 | "@esbuild/android-arm64": ["@esbuild/android-arm64@0.25.4", "", { "os": "android", "cpu": "arm64" }, "sha512-bBy69pgfhMGtCnwpC/x5QhfxAz/cBgQ9enbtwjf6V9lnPI/hMyT9iWpR1arm0l3kttTr4L0KSLpKmLp/ilKS9A=="], 113 | 114 | "@esbuild/android-x64": ["@esbuild/android-x64@0.25.4", "", { "os": "android", "cpu": "x64" }, "sha512-TVhdVtQIFuVpIIR282btcGC2oGQoSfZfmBdTip2anCaVYcqWlZXGcdcKIUklfX2wj0JklNYgz39OBqh2cqXvcQ=="], 115 | 116 | "@esbuild/darwin-arm64": ["@esbuild/darwin-arm64@0.25.4", "", { "os": "darwin", "cpu": "arm64" }, "sha512-Y1giCfM4nlHDWEfSckMzeWNdQS31BQGs9/rouw6Ub91tkK79aIMTH3q9xHvzH8d0wDru5Ci0kWB8b3up/nl16g=="], 117 | 118 | "@esbuild/darwin-x64": ["@esbuild/darwin-x64@0.25.4", "", { "os": "darwin", "cpu": "x64" }, "sha512-CJsry8ZGM5VFVeyUYB3cdKpd/H69PYez4eJh1W/t38vzutdjEjtP7hB6eLKBoOdxcAlCtEYHzQ/PJ/oU9I4u0A=="], 119 | 120 | "@esbuild/freebsd-arm64": ["@esbuild/freebsd-arm64@0.25.4", "", { "os": "freebsd", "cpu": "arm64" }, "sha512-yYq+39NlTRzU2XmoPW4l5Ifpl9fqSk0nAJYM/V/WUGPEFfek1epLHJIkTQM6bBs1swApjO5nWgvr843g6TjxuQ=="], 121 | 122 | "@esbuild/freebsd-x64": ["@esbuild/freebsd-x64@0.25.4", "", { "os": "freebsd", "cpu": "x64" }, "sha512-0FgvOJ6UUMflsHSPLzdfDnnBBVoCDtBTVyn/MrWloUNvq/5SFmh13l3dvgRPkDihRxb77Y17MbqbCAa2strMQQ=="], 123 | 124 | "@esbuild/linux-arm": ["@esbuild/linux-arm@0.25.4", "", { "os": "linux", "cpu": "arm" }, "sha512-kro4c0P85GMfFYqW4TWOpvmF8rFShbWGnrLqlzp4X1TNWjRY3JMYUfDCtOxPKOIY8B0WC8HN51hGP4I4hz4AaQ=="], 125 | 126 | "@esbuild/linux-arm64": ["@esbuild/linux-arm64@0.25.4", "", { "os": "linux", "cpu": "arm64" }, "sha512-+89UsQTfXdmjIvZS6nUnOOLoXnkUTB9hR5QAeLrQdzOSWZvNSAXAtcRDHWtqAUtAmv7ZM1WPOOeSxDzzzMogiQ=="], 127 | 128 | "@esbuild/linux-ia32": ["@esbuild/linux-ia32@0.25.4", "", { "os": "linux", "cpu": "ia32" }, "sha512-yTEjoapy8UP3rv8dB0ip3AfMpRbyhSN3+hY8mo/i4QXFeDxmiYbEKp3ZRjBKcOP862Ua4b1PDfwlvbuwY7hIGQ=="], 129 | 130 | "@esbuild/linux-loong64": ["@esbuild/linux-loong64@0.25.4", "", { "os": "linux", "cpu": "none" }, "sha512-NeqqYkrcGzFwi6CGRGNMOjWGGSYOpqwCjS9fvaUlX5s3zwOtn1qwg1s2iE2svBe4Q/YOG1q6875lcAoQK/F4VA=="], 131 | 132 | "@esbuild/linux-mips64el": ["@esbuild/linux-mips64el@0.25.4", "", { "os": "linux", "cpu": "none" }, "sha512-IcvTlF9dtLrfL/M8WgNI/qJYBENP3ekgsHbYUIzEzq5XJzzVEV/fXY9WFPfEEXmu3ck2qJP8LG/p3Q8f7Zc2Xg=="], 133 | 134 | "@esbuild/linux-ppc64": ["@esbuild/linux-ppc64@0.25.4", "", { "os": "linux", "cpu": "ppc64" }, "sha512-HOy0aLTJTVtoTeGZh4HSXaO6M95qu4k5lJcH4gxv56iaycfz1S8GO/5Jh6X4Y1YiI0h7cRyLi+HixMR+88swag=="], 135 | 136 | "@esbuild/linux-riscv64": ["@esbuild/linux-riscv64@0.25.4", "", { "os": "linux", "cpu": "none" }, "sha512-i8JUDAufpz9jOzo4yIShCTcXzS07vEgWzyX3NH2G7LEFVgrLEhjwL3ajFE4fZI3I4ZgiM7JH3GQ7ReObROvSUA=="], 137 | 138 | "@esbuild/linux-s390x": ["@esbuild/linux-s390x@0.25.4", "", { "os": "linux", "cpu": "s390x" }, "sha512-jFnu+6UbLlzIjPQpWCNh5QtrcNfMLjgIavnwPQAfoGx4q17ocOU9MsQ2QVvFxwQoWpZT8DvTLooTvmOQXkO51g=="], 139 | 140 | "@esbuild/linux-x64": ["@esbuild/linux-x64@0.25.4", "", { "os": "linux", "cpu": "x64" }, "sha512-6e0cvXwzOnVWJHq+mskP8DNSrKBr1bULBvnFLpc1KY+d+irZSgZ02TGse5FsafKS5jg2e4pbvK6TPXaF/A6+CA=="], 141 | 142 | "@esbuild/netbsd-arm64": ["@esbuild/netbsd-arm64@0.25.4", "", { "os": "none", "cpu": "arm64" }, "sha512-vUnkBYxZW4hL/ie91hSqaSNjulOnYXE1VSLusnvHg2u3jewJBz3YzB9+oCw8DABeVqZGg94t9tyZFoHma8gWZQ=="], 143 | 144 | "@esbuild/netbsd-x64": ["@esbuild/netbsd-x64@0.25.4", "", { "os": "none", "cpu": "x64" }, "sha512-XAg8pIQn5CzhOB8odIcAm42QsOfa98SBeKUdo4xa8OvX8LbMZqEtgeWE9P/Wxt7MlG2QqvjGths+nq48TrUiKw=="], 145 | 146 | "@esbuild/openbsd-arm64": ["@esbuild/openbsd-arm64@0.25.4", "", { "os": "openbsd", "cpu": "arm64" }, "sha512-Ct2WcFEANlFDtp1nVAXSNBPDxyU+j7+tId//iHXU2f/lN5AmO4zLyhDcpR5Cz1r08mVxzt3Jpyt4PmXQ1O6+7A=="], 147 | 148 | "@esbuild/openbsd-x64": ["@esbuild/openbsd-x64@0.25.4", "", { "os": "openbsd", "cpu": "x64" }, "sha512-xAGGhyOQ9Otm1Xu8NT1ifGLnA6M3sJxZ6ixylb+vIUVzvvd6GOALpwQrYrtlPouMqd/vSbgehz6HaVk4+7Afhw=="], 149 | 150 | "@esbuild/sunos-x64": ["@esbuild/sunos-x64@0.25.4", "", { "os": "sunos", "cpu": "x64" }, "sha512-Mw+tzy4pp6wZEK0+Lwr76pWLjrtjmJyUB23tHKqEDP74R3q95luY/bXqXZeYl4NYlvwOqoRKlInQialgCKy67Q=="], 151 | 152 | "@esbuild/win32-arm64": ["@esbuild/win32-arm64@0.25.4", "", { "os": "win32", "cpu": "arm64" }, "sha512-AVUP428VQTSddguz9dO9ngb+E5aScyg7nOeJDrF1HPYu555gmza3bDGMPhmVXL8svDSoqPCsCPjb265yG/kLKQ=="], 153 | 154 | "@esbuild/win32-ia32": ["@esbuild/win32-ia32@0.25.4", "", { "os": "win32", "cpu": "ia32" }, "sha512-i1sW+1i+oWvQzSgfRcxxG2k4I9n3O9NRqy8U+uugaT2Dy7kLO9Y7wI72haOahxceMX8hZAzgGou1FhndRldxRg=="], 155 | 156 | "@esbuild/win32-x64": ["@esbuild/win32-x64@0.25.4", "", { "os": "win32", "cpu": "x64" }, "sha512-nOT2vZNw6hJ+z43oP1SPea/G/6AbN6X+bGNhNuq8NtRHy4wsMhw765IKLNmnjek7GvjWBYQ8Q5VBoYTFg9y1UQ=="], 157 | 158 | "@fastify/busboy": ["@fastify/busboy@2.1.1", "", {}, "sha512-vBZP4NlzfOlerQTnba4aqZoMhE/a9HY7HRqoOPaETQcSQuWEIyZMHGfVu6w9wGtGK5fED5qRs2DteVCjOH60sA=="], 159 | 160 | "@gerrit0/mini-shiki": ["@gerrit0/mini-shiki@3.2.3", "", { "dependencies": { "@shikijs/engine-oniguruma": "^3.2.2", "@shikijs/langs": "^3.2.2", "@shikijs/themes": "^3.2.2", "@shikijs/types": "^3.2.2", "@shikijs/vscode-textmate": "^10.0.2" } }, "sha512-yemSYr0Oiqk5NAQRfbD5DKUTlThiZw1MxTMx/YpQTg6m4QRJDtV2JTYSuNevgx1ayy/O7x+uwDjh3IgECGFY/Q=="], 161 | 162 | "@img/sharp-darwin-arm64": ["@img/sharp-darwin-arm64@0.33.5", "", { "optionalDependencies": { "@img/sharp-libvips-darwin-arm64": "1.0.4" }, "os": "darwin", "cpu": "arm64" }, "sha512-UT4p+iz/2H4twwAoLCqfA9UH5pI6DggwKEGuaPy7nCVQ8ZsiY5PIcrRvD1DzuY3qYL07NtIQcWnBSY/heikIFQ=="], 163 | 164 | "@img/sharp-darwin-x64": ["@img/sharp-darwin-x64@0.33.5", "", { "optionalDependencies": { "@img/sharp-libvips-darwin-x64": "1.0.4" }, "os": "darwin", "cpu": "x64" }, "sha512-fyHac4jIc1ANYGRDxtiqelIbdWkIuQaI84Mv45KvGRRxSAa7o7d1ZKAOBaYbnepLC1WqxfpimdeWfvqqSGwR2Q=="], 165 | 166 | "@img/sharp-libvips-darwin-arm64": ["@img/sharp-libvips-darwin-arm64@1.0.4", "", { "os": "darwin", "cpu": "arm64" }, "sha512-XblONe153h0O2zuFfTAbQYAX2JhYmDHeWikp1LM9Hul9gVPjFY427k6dFEcOL72O01QxQsWi761svJ/ev9xEDg=="], 167 | 168 | "@img/sharp-libvips-darwin-x64": ["@img/sharp-libvips-darwin-x64@1.0.4", "", { "os": "darwin", "cpu": "x64" }, "sha512-xnGR8YuZYfJGmWPvmlunFaWJsb9T/AO2ykoP3Fz/0X5XV2aoYBPkX6xqCQvUTKKiLddarLaxpzNe+b1hjeWHAQ=="], 169 | 170 | "@img/sharp-libvips-linux-arm": ["@img/sharp-libvips-linux-arm@1.0.5", "", { "os": "linux", "cpu": "arm" }, "sha512-gvcC4ACAOPRNATg/ov8/MnbxFDJqf/pDePbBnuBDcjsI8PssmjoKMAz4LtLaVi+OnSb5FK/yIOamqDwGmXW32g=="], 171 | 172 | "@img/sharp-libvips-linux-arm64": ["@img/sharp-libvips-linux-arm64@1.0.4", "", { "os": "linux", "cpu": "arm64" }, "sha512-9B+taZ8DlyyqzZQnoeIvDVR/2F4EbMepXMc/NdVbkzsJbzkUjhXv/70GQJ7tdLA4YJgNP25zukcxpX2/SueNrA=="], 173 | 174 | "@img/sharp-libvips-linux-s390x": ["@img/sharp-libvips-linux-s390x@1.0.4", "", { "os": "linux", "cpu": "s390x" }, "sha512-u7Wz6ntiSSgGSGcjZ55im6uvTrOxSIS8/dgoVMoiGE9I6JAfU50yH5BoDlYA1tcuGS7g/QNtetJnxA6QEsCVTA=="], 175 | 176 | "@img/sharp-libvips-linux-x64": ["@img/sharp-libvips-linux-x64@1.0.4", "", { "os": "linux", "cpu": "x64" }, "sha512-MmWmQ3iPFZr0Iev+BAgVMb3ZyC4KeFc3jFxnNbEPas60e1cIfevbtuyf9nDGIzOaW9PdnDciJm+wFFaTlj5xYw=="], 177 | 178 | "@img/sharp-libvips-linuxmusl-arm64": ["@img/sharp-libvips-linuxmusl-arm64@1.0.4", "", { "os": "linux", "cpu": "arm64" }, "sha512-9Ti+BbTYDcsbp4wfYib8Ctm1ilkugkA/uscUn6UXK1ldpC1JjiXbLfFZtRlBhjPZ5o1NCLiDbg8fhUPKStHoTA=="], 179 | 180 | "@img/sharp-libvips-linuxmusl-x64": ["@img/sharp-libvips-linuxmusl-x64@1.0.4", "", { "os": "linux", "cpu": "x64" }, "sha512-viYN1KX9m+/hGkJtvYYp+CCLgnJXwiQB39damAO7WMdKWlIhmYTfHjwSbQeUK/20vY154mwezd9HflVFM1wVSw=="], 181 | 182 | "@img/sharp-linux-arm": ["@img/sharp-linux-arm@0.33.5", "", { "optionalDependencies": { "@img/sharp-libvips-linux-arm": "1.0.5" }, "os": "linux", "cpu": "arm" }, "sha512-JTS1eldqZbJxjvKaAkxhZmBqPRGmxgu+qFKSInv8moZ2AmT5Yib3EQ1c6gp493HvrvV8QgdOXdyaIBrhvFhBMQ=="], 183 | 184 | "@img/sharp-linux-arm64": ["@img/sharp-linux-arm64@0.33.5", "", { "optionalDependencies": { "@img/sharp-libvips-linux-arm64": "1.0.4" }, "os": "linux", "cpu": "arm64" }, "sha512-JMVv+AMRyGOHtO1RFBiJy/MBsgz0x4AWrT6QoEVVTyh1E39TrCUpTRI7mx9VksGX4awWASxqCYLCV4wBZHAYxA=="], 185 | 186 | "@img/sharp-linux-s390x": ["@img/sharp-linux-s390x@0.33.5", "", { "optionalDependencies": { "@img/sharp-libvips-linux-s390x": "1.0.4" }, "os": "linux", "cpu": "s390x" }, "sha512-y/5PCd+mP4CA/sPDKl2961b+C9d+vPAveS33s6Z3zfASk2j5upL6fXVPZi7ztePZ5CuH+1kW8JtvxgbuXHRa4Q=="], 187 | 188 | "@img/sharp-linux-x64": ["@img/sharp-linux-x64@0.33.5", "", { "optionalDependencies": { "@img/sharp-libvips-linux-x64": "1.0.4" }, "os": "linux", "cpu": "x64" }, "sha512-opC+Ok5pRNAzuvq1AG0ar+1owsu842/Ab+4qvU879ippJBHvyY5n2mxF1izXqkPYlGuP/M556uh53jRLJmzTWA=="], 189 | 190 | "@img/sharp-linuxmusl-arm64": ["@img/sharp-linuxmusl-arm64@0.33.5", "", { "optionalDependencies": { "@img/sharp-libvips-linuxmusl-arm64": "1.0.4" }, "os": "linux", "cpu": "arm64" }, "sha512-XrHMZwGQGvJg2V/oRSUfSAfjfPxO+4DkiRh6p2AFjLQztWUuY/o8Mq0eMQVIY7HJ1CDQUJlxGGZRw1a5bqmd1g=="], 191 | 192 | "@img/sharp-linuxmusl-x64": ["@img/sharp-linuxmusl-x64@0.33.5", "", { "optionalDependencies": { "@img/sharp-libvips-linuxmusl-x64": "1.0.4" }, "os": "linux", "cpu": "x64" }, "sha512-WT+d/cgqKkkKySYmqoZ8y3pxx7lx9vVejxW/W4DOFMYVSkErR+w7mf2u8m/y4+xHe7yY9DAXQMWQhpnMuFfScw=="], 193 | 194 | "@img/sharp-wasm32": ["@img/sharp-wasm32@0.33.5", "", { "dependencies": { "@emnapi/runtime": "^1.2.0" }, "cpu": "none" }, "sha512-ykUW4LVGaMcU9lu9thv85CbRMAwfeadCJHRsg2GmeRa/cJxsVY9Rbd57JcMxBkKHag5U/x7TSBpScF4U8ElVzg=="], 195 | 196 | "@img/sharp-win32-ia32": ["@img/sharp-win32-ia32@0.33.5", "", { "os": "win32", "cpu": "ia32" }, "sha512-T36PblLaTwuVJ/zw/LaH0PdZkRz5rd3SmMHX8GSmR7vtNSP5Z6bQkExdSK7xGWyxLw4sUknBuugTelgw2faBbQ=="], 197 | 198 | "@img/sharp-win32-x64": ["@img/sharp-win32-x64@0.33.5", "", { "os": "win32", "cpu": "x64" }, "sha512-MpY/o8/8kj+EcnxwvrP4aTJSWw/aZ7JIGR4aBeZkZw5B7/Jn+tY9/VNwtcoGmdT7GfggGIU4kygOMSbYnOrAbg=="], 199 | 200 | "@inquirer/confirm": ["@inquirer/confirm@5.1.6", "", { "dependencies": { "@inquirer/core": "^10.1.7", "@inquirer/type": "^3.0.4" }, "peerDependencies": { "@types/node": ">=18" }, "optionalPeers": ["@types/node"] }, "sha512-6ZXYK3M1XmaVBZX6FCfChgtponnL0R6I7k8Nu+kaoNkT828FVZTcca1MqmWQipaW2oNREQl5AaPCUOOCVNdRMw=="], 201 | 202 | "@inquirer/core": ["@inquirer/core@10.1.7", "", { "dependencies": { "@inquirer/figures": "^1.0.10", "@inquirer/type": "^3.0.4", "ansi-escapes": "^4.3.2", "cli-width": "^4.1.0", "mute-stream": "^2.0.0", "signal-exit": "^4.1.0", "wrap-ansi": "^6.2.0", "yoctocolors-cjs": "^2.1.2" }, "peerDependencies": { "@types/node": ">=18" }, "optionalPeers": ["@types/node"] }, "sha512-AA9CQhlrt6ZgiSy6qoAigiA1izOa751ugX6ioSjqgJ+/Gd+tEN/TORk5sUYNjXuHWfW0r1n/a6ak4u/NqHHrtA=="], 203 | 204 | "@inquirer/figures": ["@inquirer/figures@1.0.10", "", {}, "sha512-Ey6176gZmeqZuY/W/nZiUyvmb1/qInjcpiZjXWi6nON+nxJpD1bxtSoBxNliGISae32n6OwbY+TSXPZ1CfS4bw=="], 205 | 206 | "@inquirer/type": ["@inquirer/type@3.0.4", "", { "peerDependencies": { "@types/node": ">=18" }, "optionalPeers": ["@types/node"] }, "sha512-2MNFrDY8jkFYc9Il9DgLsHhMzuHnOYM1+CUYVWbzu9oT0hC7V7EcYvdCKeoll/Fcci04A+ERZ9wcc7cQ8lTkIA=="], 207 | 208 | "@jridgewell/resolve-uri": ["@jridgewell/resolve-uri@3.1.2", "", {}, "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw=="], 209 | 210 | "@jridgewell/sourcemap-codec": ["@jridgewell/sourcemap-codec@1.5.0", "", {}, "sha512-gv3ZRaISU3fjPAgNsriBRqGWQL6quFx04YMPW/zD8XMLsU32mhCCbfbO6KZFLjvYpCZ8zyDEgqsgf+PwPaM7GQ=="], 211 | 212 | "@jridgewell/trace-mapping": ["@jridgewell/trace-mapping@0.3.9", "", { "dependencies": { "@jridgewell/resolve-uri": "^3.0.3", "@jridgewell/sourcemap-codec": "^1.4.10" } }, "sha512-3Belt6tdc8bPgAtbcmdtNJlirVoTmEb5e2gC94PnkwEW9jI6CAHUeoG85tjWP5WquqfavoMtMwiG4P926ZKKuQ=="], 213 | 214 | "@loaderkit/resolve": ["@loaderkit/resolve@1.0.3", "", { "dependencies": { "@braidai/lang": "^1.0.0" } }, "sha512-oo51csrgEfeHO593bqoPOGwrX093QzDWrc/7y876b/ObDqp2Hbw+rl+3s26WRXIbnhty40T403nwU4UFX3KQCg=="], 215 | 216 | "@mjackson/file-storage": ["@mjackson/file-storage@0.3.0", "", { "dependencies": { "@mjackson/lazy-file": "^3.3.0" } }, "sha512-VhVPmLEaHemr9+vGNJWHMI3g4p4tm0evk8U6pVSoFq7Sid3xeOJwXgx6ELK8ewqRZDiMdJQjsnYU+/LVBfSu4w=="], 217 | 218 | "@mjackson/form-data-parser": ["@mjackson/form-data-parser@0.7.0", "", { "dependencies": { "@mjackson/multipart-parser": "^0.8.0" } }, "sha512-Y8O5+nsTv4K9Q8ziyuoru8JqYTLsP1PRi7xiFDAx4vrXEvO16NtOO7RmvXIJ2ZB59gP/wV3X1OvqdpoeRgsruA=="], 219 | 220 | "@mjackson/headers": ["@mjackson/headers@0.10.0", "", {}, "sha512-U1Eu1gF979k7ZoIBsJyD+T5l9MjtPONsZfoXfktsQHPJD0s7SokBGx+tLKDLsOY+gzVYAWS0yRFDNY8cgbQzWQ=="], 221 | 222 | "@mjackson/lazy-file": ["@mjackson/lazy-file@3.3.1", "", { "dependencies": { "mrmime": "^2.0.0" } }, "sha512-BxpNT1KmLx0OLYfgQESx/AKGD2czwfZXh9c0SaDUQY2DRAaVYtAvSQE5EkpATFdQQKqfL+iXVoaQ/SN+w7/CDA=="], 223 | 224 | "@mjackson/multipart-parser": ["@mjackson/multipart-parser@0.8.2", "", { "dependencies": { "@mjackson/headers": "^0.10.0" } }, "sha512-KltttyypazaJ9kD1GpiOTEop9/YA5aZPwKfpbmuMYoYSyJhQc+0pqaQcZSHUJVdJBvIWgx7TTQSDJdnNqP5dxA=="], 225 | 226 | "@mswjs/interceptors": ["@mswjs/interceptors@0.38.7", "", { "dependencies": { "@open-draft/deferred-promise": "^2.2.0", "@open-draft/logger": "^0.3.0", "@open-draft/until": "^2.0.0", "is-node-process": "^1.2.0", "outvariant": "^1.4.3", "strict-event-emitter": "^0.5.1" } }, "sha512-Jkb27iSn7JPdkqlTqKfhncFfnEZsIJVYxsFbUSWEkxdIPdsyngrhoDBk0/BGD2FQcRH99vlRrkHpNTyKqI+0/w=="], 227 | 228 | "@noble/hashes": ["@noble/hashes@1.7.1", "", {}, "sha512-B8XBPsn4vT/KJAGqDzbwztd+6Yte3P4V7iafm24bxgDe/mlRuK6xmWPuCNrKt2vDafZ8MfJLlchDG/vYafQEjQ=="], 229 | 230 | "@open-draft/deferred-promise": ["@open-draft/deferred-promise@2.2.0", "", {}, "sha512-CecwLWx3rhxVQF6V4bAgPS5t+So2sTbPgAzafKkVizyi7tlwpcFpdFqq+wqF2OwNBmqFuu6tOyouTuxgpMfzmA=="], 231 | 232 | "@open-draft/logger": ["@open-draft/logger@0.3.0", "", { "dependencies": { "is-node-process": "^1.2.0", "outvariant": "^1.4.0" } }, "sha512-X2g45fzhxH238HKO4xbSr7+wBS8Fvw6ixhTDuvLd5mqh6bJJCFAPwU9mPDxbcrRtfxv4u5IHCEH77BmxvXmmxQ=="], 233 | 234 | "@open-draft/until": ["@open-draft/until@2.1.0", "", {}, "sha512-U69T3ItWHvLwGg5eJ0n3I62nWuE6ilHlmz7zM0npLBRvPRd7e6NYmg54vvRtP5mZG7kZqZCFVdsTWo7BPtBujg=="], 235 | 236 | "@oslojs/asn1": ["@oslojs/asn1@1.0.0", "", { "dependencies": { "@oslojs/binary": "1.0.0" } }, "sha512-zw/wn0sj0j0QKbIXfIlnEcTviaCzYOY3V5rAyjR6YtOByFtJiT574+8p9Wlach0lZH9fddD4yb9laEAIl4vXQA=="], 237 | 238 | "@oslojs/binary": ["@oslojs/binary@1.0.0", "", {}, "sha512-9RCU6OwXU6p67H4NODbuxv2S3eenuQ4/WFLrsq+K/k682xrznH5EVWA7N4VFk9VYVcbFtKqur5YQQZc0ySGhsQ=="], 239 | 240 | "@oslojs/crypto": ["@oslojs/crypto@1.0.1", "", { "dependencies": { "@oslojs/asn1": "1.0.0", "@oslojs/binary": "1.0.0" } }, "sha512-7n08G8nWjAr/Yu3vu9zzrd0L9XnrJfpMioQcvCMxBIiF5orECHe5/3J0jmXRVvgfqMm/+4oxlQ+Sq39COYLcNQ=="], 241 | 242 | "@oslojs/encoding": ["@oslojs/encoding@1.1.0", "", {}, "sha512-70wQhgYmndg4GCPxPPxPGevRKqTIJ2Nh4OkiMWmDAVYsTQ+Ta7Sq+rPevXyXGdzr30/qZBnyOalCszoMxlyldQ=="], 243 | 244 | "@paralleldrive/cuid2": ["@paralleldrive/cuid2@2.2.2", "", { "dependencies": { "@noble/hashes": "^1.1.5" } }, "sha512-ZOBkgDwEdoYVlSeRbYYXs0S9MejQofiVYoTbKzy/6GQa39/q5tQU2IX46+shYnUkpEl3wc+J6wRlar7r2EK2xA=="], 245 | 246 | "@puppeteer/browsers": ["@puppeteer/browsers@2.2.3", "", { "dependencies": { "debug": "4.3.4", "extract-zip": "2.0.1", "progress": "2.0.3", "proxy-agent": "6.4.0", "semver": "7.6.0", "tar-fs": "3.0.5", "unbzip2-stream": "1.4.3", "yargs": "17.7.2" }, "bin": { "browsers": "lib/cjs/main-cli.js" } }, "sha512-bJ0UBsk0ESOs6RFcLXOt99a3yTDcOKlzfjad+rhFwdaG1Lu/Wzq58GHYCDTlZ9z6mldf4g+NTb+TXEfe0PpnsQ=="], 247 | 248 | "@shikijs/engine-oniguruma": ["@shikijs/engine-oniguruma@3.2.2", "", { "dependencies": { "@shikijs/types": "3.2.2", "@shikijs/vscode-textmate": "^10.0.2" } }, "sha512-vyXRnWVCSvokwbaUD/8uPn6Gqsf5Hv7XwcW4AgiU4Z2qwy19sdr6VGzMdheKKN58tJOOe5MIKiNb901bgcUXYQ=="], 249 | 250 | "@shikijs/langs": ["@shikijs/langs@3.2.2", "", { "dependencies": { "@shikijs/types": "3.2.2" } }, "sha512-NY0Urg2dV9ETt3JIOWoMPuoDNwte3geLZ4M1nrPHbkDS8dWMpKcEwlqiEIGqtwZNmt5gKyWpR26ln2Bg2ecPgw=="], 251 | 252 | "@shikijs/themes": ["@shikijs/themes@3.2.2", "", { "dependencies": { "@shikijs/types": "3.2.2" } }, "sha512-Zuq4lgAxVKkb0FFdhHSdDkALuRpsj1so1JdihjKNQfgM78EHxV2JhO10qPsMrm01FkE3mDRTdF68wfmsqjt6HA=="], 253 | 254 | "@shikijs/types": ["@shikijs/types@3.2.2", "", { "dependencies": { "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.4" } }, "sha512-a5TiHk7EH5Lso8sHcLHbVNNhWKP0Wi3yVnXnu73g86n3WoDgEra7n3KszyeCGuyoagspQ2fzvy4cpSc8pKhb0A=="], 255 | 256 | "@shikijs/vscode-textmate": ["@shikijs/vscode-textmate@10.0.2", "", {}, "sha512-83yeghZ2xxin3Nj8z1NMd/NCuca+gsYXswywDy5bHvwlWL8tpTQmzGeUuHd9FC3E/SBEMvzJRwWEOz5gGes9Qg=="], 257 | 258 | "@sindresorhus/is": ["@sindresorhus/is@4.6.0", "", {}, "sha512-t09vSN3MdfsyCHoFcTRCH/iUtG7OJ0CsjzB8cjAmKc/va/kIgeDI/TxsigdncE/4be734m0cvIYwNaV4i2XqAw=="], 259 | 260 | "@tootallnate/quickjs-emscripten": ["@tootallnate/quickjs-emscripten@0.23.0", "", {}, "sha512-C5Mc6rdnsaJDjO3UpGW/CQTHtCKaYlScZTly4JIu97Jxo/odCiH0ITnDXSJPTOrEKk/ycSZ0AOgTmkDtkOsvIA=="], 261 | 262 | "@total-typescript/tsconfig": ["@total-typescript/tsconfig@1.0.4", "", {}, "sha512-fO4ctMPGz1kOFOQ4RCPBRBfMy3gDn+pegUfrGyUFRMv/Rd0ZM3/SHH3hFCYG4u6bPLG8OlmOGcBLDexvyr3A5w=="], 263 | 264 | "@types/bcryptjs": ["@types/bcryptjs@3.0.0", "", { "dependencies": { "bcryptjs": "*" } }, "sha512-WRZOuCuaz8UcZZE4R5HXTco2goQSI2XxjGY3hbM/xDvwmqFWd4ivooImsMx65OKM6CtNKbnZ5YL+YwAwK7c1dg=="], 265 | 266 | "@types/bun": ["@types/bun@1.2.15", "", { "dependencies": { "bun-types": "1.2.15" } }, "sha512-U1ljPdBEphF0nw1MIk0hI7kPg7dFdPyM7EenHsp6W5loNHl7zqy6JQf/RKCgnUn2KDzUpkBwHPnEJEjII594bA=="], 267 | 268 | "@types/cookie": ["@types/cookie@0.6.0", "", {}, "sha512-4Kh9a6B2bQciAhf7FSuMRRkUWecJgJu9nPnx3yzpsfXX/c50REIqpHY4C82bXP90qrLtXtkDxTZosYO3UpOwlA=="], 269 | 270 | "@types/hast": ["@types/hast@3.0.4", "", { "dependencies": { "@types/unist": "*" } }, "sha512-WPs+bbQw5aCj+x6laNGWLH3wviHtoCv/P3+otBhbOhJgG8qtpdAMlTCxLtsTWA7LH1Oh/bFCHsBn0TPS5m30EQ=="], 271 | 272 | "@types/inflected": ["@types/inflected@2.1.3", "", {}, "sha512-qEllJ4fo4Cn8sPu/6+2Iw6ouxcFuQIfj3PDRO8cvzvUaJ5udD2IGwFm6xrzOQSJm4MzXRcvZkR3rqwWxvxP8eQ=="], 273 | 274 | "@types/node": ["@types/node@22.13.8", "", { "dependencies": { "undici-types": "~6.20.0" } }, "sha512-G3EfaZS+iOGYWLLRCEAXdWK9my08oHNZ+FHluRiggIYJPOXzhOiDgpVCUHaUvyIC5/fj7C/p637jdzC666AOKQ=="], 275 | 276 | "@types/statuses": ["@types/statuses@2.0.5", "", {}, "sha512-jmIUGWrAiwu3dZpxntxieC+1n/5c3mjrImkmOSQ2NC5uP6cYO4aAZDdSmRcI5C1oiTmqlZGHC+/NmJrKogbP5A=="], 277 | 278 | "@types/tough-cookie": ["@types/tough-cookie@4.0.5", "", {}, "sha512-/Ad8+nIOV7Rl++6f1BdKxFSMgmoqEoYbHRpPcx3JEfv8VRsQe9Z4mCXeJBzxs7mbHY/XOZZuXlRNfhpVPbs6ZA=="], 279 | 280 | "@types/unist": ["@types/unist@3.0.3", "", {}, "sha512-ko/gIFJRv177XgZsZcBwnqJN5x/Gien8qNOn0D5bQU/zAzVf9Zt3BlcUiLqhV9y4ARk0GbT3tnUiPNgnTXzc/Q=="], 281 | 282 | "@types/ws": ["@types/ws@8.5.14", "", { "dependencies": { "@types/node": "*" } }, "sha512-bd/YFLW+URhBzMXurx7lWByOu+xzU9+kb3RboOteXYDfW+tr+JZa99OyNmPINEGB/ahzKrEuc8rcv4gnpJmxTw=="], 283 | 284 | "@types/yauzl": ["@types/yauzl@2.10.3", "", { "dependencies": { "@types/node": "*" } }, "sha512-oJoftv0LSuaDZE3Le4DbKX+KS9G36NzOeSap90UIK0yMA/NhKJhqlSGtNDORNRaIbQfzjXDrQa0ytJ6mNRGz/Q=="], 285 | 286 | "acorn": ["acorn@8.14.0", "", { "bin": { "acorn": "bin/acorn" } }, "sha512-cl669nCJTZBsL97OF4kUQm5g5hC2uihk0NxY3WENAC0TYdILVkAyHymAntgxGkl7K+t0cXIrH5siy5S4XkFycA=="], 287 | 288 | "acorn-walk": ["acorn-walk@8.3.2", "", {}, "sha512-cjkyv4OtNCIeqhHrfS81QWXoCBPExR/J62oyEqepVw8WaQeSqpW2uhuLPh1m9eWhDuOo/jUXVTlifvesOWp/4A=="], 289 | 290 | "agent-base": ["agent-base@7.1.3", "", {}, "sha512-jRR5wdylq8CkOe6hei19GGZnxM6rBGwFl3Bg0YItGDimvjGtAvdZk4Pu6Cl4u4Igsws4a1fd1Vq3ezrhn4KmFw=="], 291 | 292 | "ansi-escapes": ["ansi-escapes@7.0.0", "", { "dependencies": { "environment": "^1.0.0" } }, "sha512-GdYO7a61mR0fOlAsvC9/rIHf7L96sBc6dEWzeOu+KAea5bZyQRPIpojrVoI4AXGJS/ycu/fBTdLrUkA4ODrvjw=="], 293 | 294 | "ansi-regex": ["ansi-regex@6.1.0", "", {}, "sha512-7HSX4QQb4CspciLpVFwyRe79O3xsIZDDLER21kERQ71oaPodF8jL725AgJMFAYbooIqolJoRLuM81SpeUkpkvA=="], 295 | 296 | "ansi-styles": ["ansi-styles@4.3.0", "", { "dependencies": { "color-convert": "^2.0.1" } }, "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg=="], 297 | 298 | "any-promise": ["any-promise@1.3.0", "", {}, "sha512-7UvmKalWRt1wgjL1RrGxoSJW/0QZFIegpeGvZG9kjp8vrRu55XTHbwnqq2GpXm9uLbcuhxm3IqX9OB4MZR1b2A=="], 299 | 300 | "argparse": ["argparse@2.0.1", "", {}, "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q=="], 301 | 302 | "as-table": ["as-table@1.0.55", "", { "dependencies": { "printable-characters": "^1.0.42" } }, "sha512-xvsWESUJn0JN421Xb9MQw6AsMHRCUknCe0Wjlxvjud80mU4E6hQf1A6NzQKcYNmYw62MfzEtXc+badstZP3JpQ=="], 303 | 304 | "ast-types": ["ast-types@0.13.4", "", { "dependencies": { "tslib": "^2.0.1" } }, "sha512-x1FCFnFifvYDDzTaLII71vG5uvDwgtmDTEVWAxrgeiR8VjMONcCXJx7E+USjDtHlwFmt9MysbqgF9b9Vjr6w+w=="], 305 | 306 | "b4a": ["b4a@1.6.7", "", {}, "sha512-OnAYlL5b7LEkALw87fUVafQw5rVR9RjwGd4KUwNQ6DrrNmaVaUCgLipfVlzrPQ4tWOR9P0IXGNOx50jYCCdSJg=="], 307 | 308 | "balanced-match": ["balanced-match@1.0.2", "", {}, "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw=="], 309 | 310 | "bare-events": ["bare-events@2.5.4", "", {}, "sha512-+gFfDkR8pj4/TrWCGUGWmJIkBwuxPS5F+a5yWjOHQt2hHvNZd5YLzadjmDUtFmMM4y429bnKLa8bYBMHcYdnQA=="], 311 | 312 | "bare-fs": ["bare-fs@2.3.5", "", { "dependencies": { "bare-events": "^2.0.0", "bare-path": "^2.0.0", "bare-stream": "^2.0.0" } }, "sha512-SlE9eTxifPDJrT6YgemQ1WGFleevzwY+XAP1Xqgl56HtcrisC2CHCZ2tq6dBpcH2TnNxwUEUGhweo+lrQtYuiw=="], 313 | 314 | "bare-os": ["bare-os@2.4.4", "", {}, "sha512-z3UiI2yi1mK0sXeRdc4O1Kk8aOa/e+FNWZcTiPB/dfTWyLypuE99LibgRaQki914Jq//yAWylcAt+mknKdixRQ=="], 315 | 316 | "bare-path": ["bare-path@2.1.3", "", { "dependencies": { "bare-os": "^2.1.0" } }, "sha512-lh/eITfU8hrj9Ru5quUp0Io1kJWIk1bTjzo7JH1P5dWmQ2EL4hFUlfI8FonAhSlgIfhn63p84CDY/x+PisgcXA=="], 317 | 318 | "bare-stream": ["bare-stream@2.6.5", "", { "dependencies": { "streamx": "^2.21.0" }, "peerDependencies": { "bare-buffer": "*", "bare-events": "*" }, "optionalPeers": ["bare-buffer", "bare-events"] }, "sha512-jSmxKJNJmHySi6hC42zlZnq00rga4jjxcgNZjY9N5WlOe/iOoGRtdwGsHzQv2RlH2KOYMwGUXhf2zXd32BA9RA=="], 319 | 320 | "base64-js": ["base64-js@1.5.1", "", {}, "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA=="], 321 | 322 | "basic-ftp": ["basic-ftp@5.0.5", "", {}, "sha512-4Bcg1P8xhUuqcii/S0Z9wiHIrQVPMermM1any+MX5GeGD7faD3/msQUDGLol9wOcz4/jbg/WJnGqoJF6LiBdtg=="], 323 | 324 | "bcryptjs": ["bcryptjs@3.0.2", "", { "bin": { "bcrypt": "bin/bcrypt" } }, "sha512-k38b3XOZKv60C4E2hVsXTolJWfkGRMbILBIe2IBITXciy5bOsTKot5kDrf3ZfufQtQOUN5mXceUEpU1rTl9Uog=="], 325 | 326 | "blake3-wasm": ["blake3-wasm@2.1.5", "", {}, "sha512-F1+K8EbfOZE49dtoPtmxUQrpXaBIl3ICvasLh+nJta0xkz+9kF/7uet9fLnwKqhDrmj6g+6K3Tw9yQPUg2ka5g=="], 327 | 328 | "bowser": ["bowser@2.11.0", "", {}, "sha512-AlcaJBi/pqqJBIQ8U9Mcpc9i8Aqxn88Skv5d+xBX006BY5u8N3mGLHa5Lgppa7L/HfwgwLgZ6NYs+Ag6uUmJRA=="], 329 | 330 | "brace-expansion": ["brace-expansion@2.0.1", "", { "dependencies": { "balanced-match": "^1.0.0" } }, "sha512-XnAIvQ8eM+kC6aULx6wuQiwVsnzsi9d3WxzV3FpWTGA19F621kwdbsAcFKXgKUHZWsy+mY6iL1sHTxWEFCytDA=="], 331 | 332 | "buffer": ["buffer@5.7.1", "", { "dependencies": { "base64-js": "^1.3.1", "ieee754": "^1.1.13" } }, "sha512-EHcyIPBQ4BSGlvjB16k5KgAJ27CIsHY/2JBmCRReo48y9rQ3MaUzWX3KVlBa4U7MyX02HdVj0K7C3WaB3ju7FQ=="], 333 | 334 | "buffer-crc32": ["buffer-crc32@0.2.13", "", {}, "sha512-VO9Ht/+p3SN7SKWqcrgEzjGbRSJYTx+Q1pTQC0wrWqHx0vpJraQ6GtHx8tvcg1rlK1byhU5gccxgOgj7B0TDkQ=="], 335 | 336 | "bun-types": ["bun-types@1.2.5", "", { "dependencies": { "@types/node": "*", "@types/ws": "~8.5.10" } }, "sha512-3oO6LVGGRRKI4kHINx5PIdIgnLRb7l/SprhzqXapmoYkFl5m4j6EvALvbDVuuBFaamB46Ap6HCUxIXNLCGy+tg=="], 337 | 338 | "chalk": ["chalk@4.1.2", "", { "dependencies": { "ansi-styles": "^4.1.0", "supports-color": "^7.1.0" } }, "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA=="], 339 | 340 | "char-regex": ["char-regex@1.0.2", "", {}, "sha512-kWWXztvZ5SBQV+eRgKFeh8q5sLuZY2+8WUIzlxWVTg+oGwY14qylx1KbKzHd8P6ZYkAg0xyIDU9JMHhyJMZ1jw=="], 341 | 342 | "cjs-module-lexer": ["cjs-module-lexer@1.4.3", "", {}, "sha512-9z8TZaGM1pfswYeXrUpzPrkx8UnWYdhJclsiYMm6x/w5+nN+8Tf/LnAgfLGQCm59qAOxU8WwHEq2vNwF6i4j+Q=="], 343 | 344 | "cli-highlight": ["cli-highlight@2.1.11", "", { "dependencies": { "chalk": "^4.0.0", "highlight.js": "^10.7.1", "mz": "^2.4.0", "parse5": "^5.1.1", "parse5-htmlparser2-tree-adapter": "^6.0.0", "yargs": "^16.0.0" }, "bin": { "highlight": "bin/highlight" } }, "sha512-9KDcoEVwyUXrjcJNvHD0NFc/hiwe/WPVYIleQh2O1N2Zro5gWJZ/K+3DGn8w8P/F6FxOgzyC5bxDyHIgCSPhGg=="], 345 | 346 | "cli-table3": ["cli-table3@0.6.5", "", { "dependencies": { "string-width": "^4.2.0" }, "optionalDependencies": { "@colors/colors": "1.5.0" } }, "sha512-+W/5efTR7y5HRD7gACw9yQjqMVvEMLBHmboM/kPWam+H+Hmyrgjh6YncVKK122YZkXrLudzTuAukUw9FnMf7IQ=="], 347 | 348 | "cli-width": ["cli-width@4.1.0", "", {}, "sha512-ouuZd4/dm2Sw5Gmqy6bGyNNNe1qt9RpmxveLSO7KcgsTnU7RXfsw+/bukWGo1abgBiMAic068rclZsO4IWmmxQ=="], 349 | 350 | "cliui": ["cliui@8.0.1", "", { "dependencies": { "string-width": "^4.2.0", "strip-ansi": "^6.0.1", "wrap-ansi": "^7.0.0" } }, "sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ=="], 351 | 352 | "clone-regexp": ["clone-regexp@3.0.0", "", { "dependencies": { "is-regexp": "^3.0.0" } }, "sha512-ujdnoq2Kxb8s3ItNBtnYeXdm07FcU0u8ARAT1lQ2YdMwQC+cdiXX8KoqMVuglztILivceTtp4ivqGSmEmhBUJw=="], 353 | 354 | "color": ["color@4.2.3", "", { "dependencies": { "color-convert": "^2.0.1", "color-string": "^1.9.0" } }, "sha512-1rXeuUUiGGrykh+CeBdu5Ie7OJwinCgQY0bc7GCRxy5xVHy+moaqkpL/jqQq0MtQOeYcrqEz4abc5f0KtU7W4A=="], 355 | 356 | "color-convert": ["color-convert@2.0.1", "", { "dependencies": { "color-name": "~1.1.4" } }, "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ=="], 357 | 358 | "color-name": ["color-name@1.1.4", "", {}, "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA=="], 359 | 360 | "color-string": ["color-string@1.9.1", "", { "dependencies": { "color-name": "^1.0.0", "simple-swizzle": "^0.2.2" } }, "sha512-shrVawQFojnZv6xM40anx4CkoDP+fZsw/ZerEMsW/pyzsRbElpsL/DBVW7q3ExxwusdNXI3lXpuhEZkzs8p5Eg=="], 361 | 362 | "commander": ["commander@10.0.1", "", {}, "sha512-y4Mg2tXshplEbSGzx7amzPwKKOCGuoSRP/CjEdwwk0FOGlUbq6lKuoyDZTNZkmxHdJtp54hdfY/JUrdL7Xfdug=="], 363 | 364 | "consola": ["consola@3.4.2", "", {}, "sha512-5IKcdX0nnYavi6G7TtOhwkYzyjfJlatbjMjuLSfE2kYT5pMDOilZ4OvMhi637CcDICTmz3wARPoyhqyX1Y+XvA=="], 365 | 366 | "convert-hrtime": ["convert-hrtime@5.0.0", "", {}, "sha512-lOETlkIeYSJWcbbcvjRKGxVMXJR+8+OQb/mTPbA4ObPMytYIsUbuOE0Jzy60hjARYszq1id0j8KgVhC+WGZVTg=="], 367 | 368 | "cookie": ["cookie@0.7.2", "", {}, "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w=="], 369 | 370 | "data-uri-to-buffer": ["data-uri-to-buffer@2.0.2", "", {}, "sha512-ND9qDTLc6diwj+Xe5cdAgVTbLVdXbtxTJRXRhli8Mowuaan+0EJOtdqJ0QCHNSSPyoXGx9HX2/VMnKeC34AChA=="], 371 | 372 | "debug": ["debug@4.3.4", "", { "dependencies": { "ms": "2.1.2" } }, "sha512-PRWFHuSU3eDtQJPvnNY7Jcket1j0t5OuOsFzPPzsekD52Zl8qUfFIPEiswXqIvHWGVHOgX+7G/vCNNhehwxfkQ=="], 373 | 374 | "defu": ["defu@6.1.4", "", {}, "sha512-mEQCMmwJu317oSz8CwdIOdwf3xMif1ttiM8LTufzc3g6kR+9Pe236twL8j3IYT1F7GfRgGcW6MWxzZjLIkuHIg=="], 375 | 376 | "degenerator": ["degenerator@5.0.1", "", { "dependencies": { "ast-types": "^0.13.4", "escodegen": "^2.1.0", "esprima": "^4.0.1" } }, "sha512-TllpMR/t0M5sqCXfj85i4XaAzxmS5tVA16dqvdkMwGmzI+dXLXnw3J+3Vdv7VKw+ThlTMboK6i9rnZ6Nntj5CQ=="], 377 | 378 | "detect-libc": ["detect-libc@2.0.3", "", {}, "sha512-bwy0MGW55bG41VqxxypOsdSdGqLwXPI/focwgTYCFMbdUiBAxLg9CFzG08sz2aqzknwiX7Hkl0bQENjg8iLByw=="], 379 | 380 | "devtools-protocol": ["devtools-protocol@0.0.1273771", "", {}, "sha512-QDbb27xcTVReQQW/GHJsdQqGKwYBE7re7gxehj467kKP2DKuYBUj6i2k5LRiAC66J1yZG/9gsxooz/s9pcm0Og=="], 381 | 382 | "drizzle-orm": ["drizzle-orm@0.40.0", "", { "peerDependencies": { "@aws-sdk/client-rds-data": ">=3", "@cloudflare/workers-types": ">=4", "@electric-sql/pglite": ">=0.2.0", "@libsql/client": ">=0.10.0", "@libsql/client-wasm": ">=0.10.0", "@neondatabase/serverless": ">=0.10.0", "@op-engineering/op-sqlite": ">=2", "@opentelemetry/api": "^1.4.1", "@planetscale/database": ">=1", "@prisma/client": "*", "@tidbcloud/serverless": "*", "@types/better-sqlite3": "*", "@types/pg": "*", "@types/sql.js": "*", "@vercel/postgres": ">=0.8.0", "@xata.io/client": "*", "better-sqlite3": ">=7", "bun-types": "*", "expo-sqlite": ">=14.0.0", "gel": ">=2", "knex": "*", "kysely": "*", "mysql2": ">=2", "pg": ">=8", "postgres": ">=3", "sql.js": ">=1", "sqlite3": ">=5" }, "optionalPeers": ["@aws-sdk/client-rds-data", "@cloudflare/workers-types", "@electric-sql/pglite", "@libsql/client", "@libsql/client-wasm", "@neondatabase/serverless", "@op-engineering/op-sqlite", "@opentelemetry/api", "@planetscale/database", "@prisma/client", "@tidbcloud/serverless", "@types/better-sqlite3", "@types/pg", "@types/sql.js", "@vercel/postgres", "@xata.io/client", "better-sqlite3", "bun-types", "expo-sqlite", "gel", "knex", "kysely", "mysql2", "pg", "postgres", "sql.js", "sqlite3"] }, "sha512-7ptk/HQiMSrEZHnAsSlBESXWj52VwgMmyTEfoNmpNN2ZXpcz13LwHfXTIghsAEud7Z5UJhDOp8U07ujcqme7wg=="], 383 | 384 | "emoji-regex": ["emoji-regex@8.0.0", "", {}, "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A=="], 385 | 386 | "emojilib": ["emojilib@2.4.0", "", {}, "sha512-5U0rVMU5Y2n2+ykNLQqMoqklN9ICBT/KsvC1Gz6vqHbz2AXXGkG+Pm5rMWk/8Vjrr/mY9985Hi8DYzn1F09Nyw=="], 387 | 388 | "end-of-stream": ["end-of-stream@1.4.4", "", { "dependencies": { "once": "^1.4.0" } }, "sha512-+uw1inIHVPQoaVuHzRyXd21icM+cnt4CzD5rW+NC1wjOUSTOs+Te7FOv7AhN7vS9x/oIyhLP5PR1H+phQAHu5Q=="], 389 | 390 | "entities": ["entities@4.5.0", "", {}, "sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw=="], 391 | 392 | "environment": ["environment@1.1.0", "", {}, "sha512-xUtoPkMggbz0MPyPiIWr1Kp4aeWJjDZ6SMvURhimjdZgsRuDplF5/s9hcgGhyXMhs+6vpnuoiZ2kFiu3FMnS8Q=="], 393 | 394 | "esbuild": ["esbuild@0.25.4", "", { "optionalDependencies": { "@esbuild/aix-ppc64": "0.25.4", "@esbuild/android-arm": "0.25.4", "@esbuild/android-arm64": "0.25.4", "@esbuild/android-x64": "0.25.4", "@esbuild/darwin-arm64": "0.25.4", "@esbuild/darwin-x64": "0.25.4", "@esbuild/freebsd-arm64": "0.25.4", "@esbuild/freebsd-x64": "0.25.4", "@esbuild/linux-arm": "0.25.4", "@esbuild/linux-arm64": "0.25.4", "@esbuild/linux-ia32": "0.25.4", "@esbuild/linux-loong64": "0.25.4", "@esbuild/linux-mips64el": "0.25.4", "@esbuild/linux-ppc64": "0.25.4", "@esbuild/linux-riscv64": "0.25.4", "@esbuild/linux-s390x": "0.25.4", "@esbuild/linux-x64": "0.25.4", "@esbuild/netbsd-arm64": "0.25.4", "@esbuild/netbsd-x64": "0.25.4", "@esbuild/openbsd-arm64": "0.25.4", "@esbuild/openbsd-x64": "0.25.4", "@esbuild/sunos-x64": "0.25.4", "@esbuild/win32-arm64": "0.25.4", "@esbuild/win32-ia32": "0.25.4", "@esbuild/win32-x64": "0.25.4" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-8pgjLUcUjcgDg+2Q4NYXnPbo/vncAY4UmyaCm0jZevERqCHZIaWwdJHkf8XQtu4AxSKCdvrUbT0XUr1IdZzI8Q=="], 395 | 396 | "escalade": ["escalade@3.2.0", "", {}, "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA=="], 397 | 398 | "escodegen": ["escodegen@2.1.0", "", { "dependencies": { "esprima": "^4.0.1", "estraverse": "^5.2.0", "esutils": "^2.0.2" }, "optionalDependencies": { "source-map": "~0.6.1" }, "bin": { "esgenerate": "bin/esgenerate.js", "escodegen": "bin/escodegen.js" } }, "sha512-2NlIDTwUWJN0mRPQOdtQBzbUHvdGY2P1VXSyU83Q3xKxM7WHX2Ql8dKq782Q9TgQUNOLEzEYu9bzLNj1q88I5w=="], 399 | 400 | "esprima": ["esprima@4.0.1", "", { "bin": { "esparse": "./bin/esparse.js", "esvalidate": "./bin/esvalidate.js" } }, "sha512-eGuFFw7Upda+g4p+QHvnW0RyTX/SVeJBDM/gCtMARO0cLuT2HcEKnTPvhjV6aGeqrCB/sbNop0Kszm0jsaWU4A=="], 401 | 402 | "estraverse": ["estraverse@5.3.0", "", {}, "sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA=="], 403 | 404 | "esutils": ["esutils@2.0.3", "", {}, "sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g=="], 405 | 406 | "exit-hook": ["exit-hook@2.2.1", "", {}, "sha512-eNTPlAD67BmP31LDINZ3U7HSF8l57TxOY2PmBJ1shpCvpnxBF93mWCE8YHBnXs8qiUZJc9WDcWIeC3a2HIAMfw=="], 407 | 408 | "exsolve": ["exsolve@1.0.4", "", {}, "sha512-xsZH6PXaER4XoV+NiT7JHp1bJodJVT+cxeSH1G0f0tlT0lJqYuHUP3bUx2HtfTDvOagMINYp8rsqusxud3RXhw=="], 409 | 410 | "extract-zip": ["extract-zip@2.0.1", "", { "dependencies": { "debug": "^4.1.1", "get-stream": "^5.1.0", "yauzl": "^2.10.0" }, "optionalDependencies": { "@types/yauzl": "^2.9.1" }, "bin": { "extract-zip": "cli.js" } }, "sha512-GDhU9ntwuKyGXdZBUgTIe+vXnWj0fppUEtMDL0+idd5Sta8TGpHssn/eusA9mrPr9qNDym6SxAYZjNvCn/9RBg=="], 411 | 412 | "fast-fifo": ["fast-fifo@1.3.2", "", {}, "sha512-/d9sfos4yxzpwkDkuN7k2SqFKtYNmCTzgfEpz82x34IM9/zc8KGxQoXg1liNC/izpRM/MBdt44Nmx41ZWqk+FQ=="], 413 | 414 | "fd-slicer": ["fd-slicer@1.1.0", "", { "dependencies": { "pend": "~1.2.0" } }, "sha512-cE1qsB/VwyQozZ+q1dGxR8LBYNZeofhEdUNGSMbQD3Gw2lAzX9Zb3uIU6Ebc/Fmyjo9AWWfnn0AUCHqtevs/8g=="], 415 | 416 | "fflate": ["fflate@0.8.2", "", {}, "sha512-cPJU47OaAoCbg0pBvzsgpTPhmhqI5eJjh/JIu8tPj5q+T7iLvW/JAYUqmE7KOB4R1ZyEhzBaIQpQpardBF5z8A=="], 417 | 418 | "fsevents": ["fsevents@2.3.3", "", { "os": "darwin" }, "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw=="], 419 | 420 | "function-timeout": ["function-timeout@0.1.1", "", {}, "sha512-0NVVC0TaP7dSTvn1yMiy6d6Q8gifzbvQafO46RtLG/kHJUBNd+pVRGOBoK44wNBvtSPUJRfdVvkFdD3p0xvyZg=="], 421 | 422 | "get-caller-file": ["get-caller-file@2.0.5", "", {}, "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg=="], 423 | 424 | "get-source": ["get-source@2.0.12", "", { "dependencies": { "data-uri-to-buffer": "^2.0.0", "source-map": "^0.6.1" } }, "sha512-X5+4+iD+HoSeEED+uwrQ07BOQr0kEDFMVqqpBuI+RaZBpBpHCuXxo70bjar6f0b0u/DQJsJ7ssurpP0V60Az+w=="], 425 | 426 | "get-stream": ["get-stream@5.2.0", "", { "dependencies": { "pump": "^3.0.0" } }, "sha512-nBF+F1rAZVCu/p7rjzgA+Yb4lfYXrpl7a6VmJrU8wF9I1CKvP/QwPNZHnOlwbTkY6dvtFIzFMSyQXbLoTQPRpA=="], 427 | 428 | "get-uri": ["get-uri@6.0.4", "", { "dependencies": { "basic-ftp": "^5.0.2", "data-uri-to-buffer": "^6.0.2", "debug": "^4.3.4" } }, "sha512-E1b1lFFLvLgak2whF2xDBcOy6NLVGZBqqjJjsIhvopKfWWEi64pLVTWWehV8KlLerZkfNTA95sTe2OdJKm1OzQ=="], 429 | 430 | "glob-to-regexp": ["glob-to-regexp@0.4.1", "", {}, "sha512-lkX1HJXwyMcprw/5YUZc2s7DrpAiHB21/V+E1rHUrVNokkvB6bqMzT0VfV6/86ZNabt1k14YOIaT7nDvOX3Iiw=="], 431 | 432 | "graphql": ["graphql@16.10.0", "", {}, "sha512-AjqGKbDGUFRKIRCP9tCKiIGHyriz2oHEbPIbEtcSLSs4YjReZOIPQQWek4+6hjw62H9QShXHyaGivGiYVLeYFQ=="], 433 | 434 | "has-flag": ["has-flag@4.0.0", "", {}, "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ=="], 435 | 436 | "headers-polyfill": ["headers-polyfill@4.0.3", "", {}, "sha512-IScLbePpkvO846sIwOtOTDjutRMWdXdJmXdMvk6gCBHxFO8d+QKOQedyZSxFTTFYRSmlgSTDtXqqq4pcenBXLQ=="], 437 | 438 | "highlight.js": ["highlight.js@10.7.3", "", {}, "sha512-tzcUFauisWKNHaRkN4Wjl/ZA07gENAjFl3J/c480dprkGTg5EQstgaNFqBfUqCq54kZRIEcreTsAgF/m2quD7A=="], 439 | 440 | "http-proxy-agent": ["http-proxy-agent@7.0.2", "", { "dependencies": { "agent-base": "^7.1.0", "debug": "^4.3.4" } }, "sha512-T1gkAiYYDWYx3V5Bmyu7HcfcvL7mUrTWiM6yOfa3PIphViJ/gFPbvidQ+veqSOHci/PxBcDabeUNCzpOODJZig=="], 441 | 442 | "https-proxy-agent": ["https-proxy-agent@7.0.6", "", { "dependencies": { "agent-base": "^7.1.2", "debug": "4" } }, "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw=="], 443 | 444 | "ieee754": ["ieee754@1.2.1", "", {}, "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA=="], 445 | 446 | "inflected": ["inflected@2.1.0", "", {}, "sha512-hAEKNxvHf2Iq3H60oMBHkB4wl5jn3TPF3+fXek/sRwAB5gP9xWs4r7aweSF95f99HFoz69pnZTcu8f0SIHV18w=="], 447 | 448 | "ip-address": ["ip-address@9.0.5", "", { "dependencies": { "jsbn": "1.1.0", "sprintf-js": "^1.1.3" } }, "sha512-zHtQzGojZXTwZTHQqra+ETKd4Sn3vgi7uBmlPoXVWZqYvuKmtI0l/VZTjqGmJY9x88GGOaZ9+G9ES8hC4T4X8g=="], 449 | 450 | "ip-regex": ["ip-regex@5.0.0", "", {}, "sha512-fOCG6lhoKKakwv+C6KdsOnGvgXnmgfmp0myi3bcNwj3qfwPAxRKWEuFhvEFF7ceYIz6+1jRZ+yguLFAmUNPEfw=="], 451 | 452 | "is-arrayish": ["is-arrayish@0.3.2", "", {}, "sha512-eVRqCvVlZbuw3GrM63ovNSNAeA1K16kaR/LRY/92w0zxQ5/1YzwblUX652i4Xs9RwAGjW9d9y6X88t8OaAJfWQ=="], 453 | 454 | "is-fullwidth-code-point": ["is-fullwidth-code-point@3.0.0", "", {}, "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg=="], 455 | 456 | "is-ip": ["is-ip@5.0.1", "", { "dependencies": { "ip-regex": "^5.0.0", "super-regex": "^0.2.0" } }, "sha512-FCsGHdlrOnZQcp0+XT5a+pYowf33itBalCl+7ovNXC/7o5BhIpG14M3OrpPPdBSIQJCm+0M5+9mO7S9VVTTCFw=="], 457 | 458 | "is-node-process": ["is-node-process@1.2.0", "", {}, "sha512-Vg4o6/fqPxIjtxgUH5QLJhwZ7gW5diGCVlXpuUfELC62CuxM1iHcRe51f2W1FDy04Ai4KJkagKjx3XaqyfRKXw=="], 459 | 460 | "is-regexp": ["is-regexp@3.1.0", "", {}, "sha512-rbku49cWloU5bSMI+zaRaXdQHXnthP6DZ/vLnfdSKyL4zUzuWnomtOEiZZOd+ioQ+avFo/qau3KPTc7Fjy1uPA=="], 461 | 462 | "jsbn": ["jsbn@1.1.0", "", {}, "sha512-4bYVV3aAMtDTTu4+xsDYa6sy9GyJ69/amsu9sYF2zqjiEoZA5xJi3BrfX3uY+/IekIu7MwdObdbDWpoZdBv3/A=="], 463 | 464 | "linkify-it": ["linkify-it@5.0.0", "", { "dependencies": { "uc.micro": "^2.0.0" } }, "sha512-5aHCbzQRADcdP+ATqnDuhhJ/MRIqDkZX5pyjFHRRysS8vZ5AbqGEoFIb6pYHPZ+L/OC2Lc+xT8uHVVR5CAK/wQ=="], 465 | 466 | "lru-cache": ["lru-cache@11.1.0", "", {}, "sha512-QIXZUBJUx+2zHUdQujWejBkcD9+cs94tLn0+YL8UrCh+D5sCXZ4c7LaEH48pNwRY3MLDgqUFyhlCyjJPf1WP0A=="], 467 | 468 | "lunr": ["lunr@2.3.9", "", {}, "sha512-zTU3DaZaF3Rt9rhN3uBMGQD3dD2/vFQqnvZCDv4dl5iOzq2IZQqTxu90r4E5J+nP70J3ilqVCrbho2eWaeW8Ow=="], 469 | 470 | "markdown-it": ["markdown-it@14.1.0", "", { "dependencies": { "argparse": "^2.0.1", "entities": "^4.4.0", "linkify-it": "^5.0.0", "mdurl": "^2.0.0", "punycode.js": "^2.3.1", "uc.micro": "^2.1.0" }, "bin": { "markdown-it": "bin/markdown-it.mjs" } }, "sha512-a54IwgWPaeBCAAsv13YgmALOF1elABB08FxO9i+r4VFk5Vl4pKokRPeX8u5TCgSsPi6ec1otfLjdOpVcgbpshg=="], 471 | 472 | "marked": ["marked@9.1.6", "", { "bin": { "marked": "bin/marked.js" } }, "sha512-jcByLnIFkd5gSXZmjNvS1TlmRhCXZjIzHYlaGkPlLIekG55JDR2Z4va9tZwCiP+/RDERiNhMOFu01xd6O5ct1Q=="], 473 | 474 | "marked-terminal": ["marked-terminal@7.3.0", "", { "dependencies": { "ansi-escapes": "^7.0.0", "ansi-regex": "^6.1.0", "chalk": "^5.4.1", "cli-highlight": "^2.1.11", "cli-table3": "^0.6.5", "node-emoji": "^2.2.0", "supports-hyperlinks": "^3.1.0" }, "peerDependencies": { "marked": ">=1 <16" } }, "sha512-t4rBvPsHc57uE/2nJOLmMbZCQ4tgAccAED3ngXQqW6g+TxA488JzJ+FK3lQkzBQOI1mRV/r/Kq+1ZlJ4D0owQw=="], 475 | 476 | "mdurl": ["mdurl@2.0.0", "", {}, "sha512-Lf+9+2r+Tdp5wXDXC4PcIBjTDtq4UKjCPMQhKIuzpJNW0b96kVqSwW0bT7FhRSfmAiFYgP+SCRvdrDozfh0U5w=="], 477 | 478 | "mime": ["mime@3.0.0", "", { "bin": { "mime": "cli.js" } }, "sha512-jSCU7/VB1loIWBZe14aEYHU/+1UMEHoaO7qxCOVJOw9GgH72VAWppxNcjU+x9a2k3GSIBXNKxXQFqRvvZ7vr3A=="], 479 | 480 | "miniflare": ["miniflare@4.20250525.0", "", { "dependencies": { "@cspotcode/source-map-support": "0.8.1", "acorn": "8.14.0", "acorn-walk": "8.3.2", "exit-hook": "2.2.1", "glob-to-regexp": "0.4.1", "sharp": "^0.33.5", "stoppable": "1.1.0", "undici": "^5.28.5", "workerd": "1.20250525.0", "ws": "8.18.0", "youch": "3.3.4", "zod": "3.22.3" }, "bin": { "miniflare": "bootstrap.js" } }, "sha512-F5XRDn9WqxUaHphUT8qwy5WXC/3UwbBRJTdjjP5uwHX82vypxIlHNyHziZnplPLhQa1kbSdIY7wfuP1XJyyYZw=="], 481 | 482 | "minimatch": ["minimatch@9.0.5", "", { "dependencies": { "brace-expansion": "^2.0.1" } }, "sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow=="], 483 | 484 | "mrmime": ["mrmime@2.0.1", "", {}, "sha512-Y3wQdFg2Va6etvQ5I82yUhGdsKrcYox6p7FfL1LbK2J4V01F9TGlepTIhnK24t7koZibmg82KGglhA1XK5IsLQ=="], 485 | 486 | "ms": ["ms@2.1.2", "", {}, "sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w=="], 487 | 488 | "msw": ["msw@2.8.7", "", { "dependencies": { "@bundled-es-modules/cookie": "^2.0.1", "@bundled-es-modules/statuses": "^1.0.1", "@bundled-es-modules/tough-cookie": "^0.1.6", "@inquirer/confirm": "^5.0.0", "@mswjs/interceptors": "^0.38.7", "@open-draft/deferred-promise": "^2.2.0", "@open-draft/until": "^2.1.0", "@types/cookie": "^0.6.0", "@types/statuses": "^2.0.4", "graphql": "^16.8.1", "headers-polyfill": "^4.0.2", "is-node-process": "^1.2.0", "outvariant": "^1.4.3", "path-to-regexp": "^6.3.0", "picocolors": "^1.1.1", "strict-event-emitter": "^0.5.1", "type-fest": "^4.26.1", "yargs": "^17.7.2" }, "peerDependencies": { "typescript": ">= 4.8.x" }, "optionalPeers": ["typescript"], "bin": { "msw": "cli/index.js" } }, "sha512-0TGfV4oQiKpa3pDsQBDf0xvFP+sRrqEOnh2n1JWpHVKHJHLv6ZmY1HCZpCi7uDiJTeIHJMBpmBiRmBJN+ETPSQ=="], 489 | 490 | "mustache": ["mustache@4.2.0", "", { "bin": { "mustache": "bin/mustache" } }, "sha512-71ippSywq5Yb7/tVYyGbkBggbU8H3u5Rz56fH60jGFgr8uHwxs+aSKeqmluIVzM0m0kB7xQjKS6qPfd0b2ZoqQ=="], 491 | 492 | "mute-stream": ["mute-stream@2.0.0", "", {}, "sha512-WWdIxpyjEn+FhQJQQv9aQAYlHoNVdzIzUySNV1gHUPDSdZJ3yZn7pAAbQcV7B56Mvu881q9FZV+0Vx2xC44VWA=="], 493 | 494 | "mz": ["mz@2.7.0", "", { "dependencies": { "any-promise": "^1.0.0", "object-assign": "^4.0.1", "thenify-all": "^1.0.0" } }, "sha512-z81GNO7nnYMEhrGh9LeymoE4+Yr0Wn5McHIZMK5cfQCl+NDX08sCZgUc9/6MHni9IWuFLm1Z3HTCXu2z9fN62Q=="], 495 | 496 | "netmask": ["netmask@2.0.2", "", {}, "sha512-dBpDMdxv9Irdq66304OLfEmQ9tbNRFnFTuZiLo+bD+r332bBmMJ8GBLXklIXXgxd3+v9+KUnZaUR5PJMa75Gsg=="], 497 | 498 | "node-emoji": ["node-emoji@2.2.0", "", { "dependencies": { "@sindresorhus/is": "^4.6.0", "char-regex": "^1.0.2", "emojilib": "^2.4.0", "skin-tone": "^2.0.0" } }, "sha512-Z3lTE9pLaJF47NyMhd4ww1yFTAP8YhYI8SleJiHzM46Fgpm5cnNzSl9XfzFNqbaz+VlJrIj3fXQ4DeN1Rjm6cw=="], 499 | 500 | "object-assign": ["object-assign@4.1.1", "", {}, "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg=="], 501 | 502 | "ohash": ["ohash@2.0.11", "", {}, "sha512-RdR9FQrFwNBNXAr4GixM8YaRZRJ5PUWbKYbE5eOsrwAjJW0q2REGcf79oYPsLyskQCZG1PLN+S/K1V00joZAoQ=="], 503 | 504 | "once": ["once@1.4.0", "", { "dependencies": { "wrappy": "1" } }, "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w=="], 505 | 506 | "outvariant": ["outvariant@1.4.3", "", {}, "sha512-+Sl2UErvtsoajRDKCE5/dBz4DIvHXQQnAxtQTF04OJxY0+DyZXSo5P5Bb7XYWOh81syohlYL24hbDwxedPUJCA=="], 507 | 508 | "pac-proxy-agent": ["pac-proxy-agent@7.2.0", "", { "dependencies": { "@tootallnate/quickjs-emscripten": "^0.23.0", "agent-base": "^7.1.2", "debug": "^4.3.4", "get-uri": "^6.0.1", "http-proxy-agent": "^7.0.0", "https-proxy-agent": "^7.0.6", "pac-resolver": "^7.0.1", "socks-proxy-agent": "^8.0.5" } }, "sha512-TEB8ESquiLMc0lV8vcd5Ql/JAKAoyzHFXaStwjkzpOpC5Yv+pIzLfHvjTSdf3vpa2bMiUQrg9i6276yn8666aA=="], 509 | 510 | "pac-resolver": ["pac-resolver@7.0.1", "", { "dependencies": { "degenerator": "^5.0.0", "netmask": "^2.0.2" } }, "sha512-5NPgf87AT2STgwa2ntRMr45jTKrYBGkVU36yT0ig/n/GMAa3oPqhZfIQ2kMEimReg0+t9kZViDVZ83qfVUlckg=="], 511 | 512 | "parse5": ["parse5@5.1.1", "", {}, "sha512-ugq4DFI0Ptb+WWjAdOK16+u/nHfiIrcE+sh8kZMaM0WllQKLI9rOUq6c2b7cwPkXdzfQESqvoqK6ug7U/Yyzug=="], 513 | 514 | "parse5-htmlparser2-tree-adapter": ["parse5-htmlparser2-tree-adapter@6.0.1", "", { "dependencies": { "parse5": "^6.0.1" } }, "sha512-qPuWvbLgvDGilKc5BoicRovlT4MtYT6JfJyBOMDsKoiT+GiuP5qyrPCnR9HcPECIJJmZh5jRndyNThnhhb/vlA=="], 515 | 516 | "path-to-regexp": ["path-to-regexp@6.3.0", "", {}, "sha512-Yhpw4T9C6hPpgPeA28us07OJeqZ5EzQTkbfwuhsUg0c237RomFoETJgmp2sa3F/41gfLE6G5cqcYwznmeEeOlQ=="], 517 | 518 | "pathe": ["pathe@2.0.3", "", {}, "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w=="], 519 | 520 | "pend": ["pend@1.2.0", "", {}, "sha512-F3asv42UuXchdzt+xXqfW1OGlVBe+mxa2mqI0pg5yAHZPvFmY3Y6drSf/GQ1A86WgWEN9Kzh/WrgKa6iGcHXLg=="], 521 | 522 | "picocolors": ["picocolors@1.1.1", "", {}, "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA=="], 523 | 524 | "printable-characters": ["printable-characters@1.0.42", "", {}, "sha512-dKp+C4iXWK4vVYZmYSd0KBH5F/h1HoZRsbJ82AVKRO3PEo8L4lBS/vLwhVtpwwuYcoIsVY+1JYKR268yn480uQ=="], 525 | 526 | "progress": ["progress@2.0.3", "", {}, "sha512-7PiHtLll5LdnKIMw100I+8xJXR5gW2QwWYkT6iJva0bXitZKa/XMrSbdmg3r2Xnaidz9Qumd0VPaMrZlF9V9sA=="], 527 | 528 | "proxy-agent": ["proxy-agent@6.4.0", "", { "dependencies": { "agent-base": "^7.0.2", "debug": "^4.3.4", "http-proxy-agent": "^7.0.1", "https-proxy-agent": "^7.0.3", "lru-cache": "^7.14.1", "pac-proxy-agent": "^7.0.1", "proxy-from-env": "^1.1.0", "socks-proxy-agent": "^8.0.2" } }, "sha512-u0piLU+nCOHMgGjRbimiXmA9kM/L9EHh3zL81xCdp7m+Y2pHIsnmbdDoEDoAz5geaonNR6q6+yOPQs6n4T6sBQ=="], 529 | 530 | "proxy-from-env": ["proxy-from-env@1.1.0", "", {}, "sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg=="], 531 | 532 | "psl": ["psl@1.15.0", "", { "dependencies": { "punycode": "^2.3.1" } }, "sha512-JZd3gMVBAVQkSs6HdNZo9Sdo0LNcQeMNP3CozBJb3JYC/QUYZTnKxP+f8oWRX4rHP5EurWxqAHTSwUCjlNKa1w=="], 533 | 534 | "pump": ["pump@3.0.2", "", { "dependencies": { "end-of-stream": "^1.1.0", "once": "^1.3.1" } }, "sha512-tUPXtzlGM8FE3P0ZL6DVs/3P58k9nk8/jZeQCurTJylQA8qFYzHFfhBJkuqyE0FifOsQ0uKWekiZ5g8wtr28cw=="], 535 | 536 | "punycode": ["punycode@2.3.1", "", {}, "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg=="], 537 | 538 | "punycode.js": ["punycode.js@2.3.1", "", {}, "sha512-uxFIHU0YlHYhDQtV4R9J6a52SLx28BCjT+4ieh7IGbgwVJWO+km431c4yRlREUAsAmt/uMjQUyQHNEPf0M39CA=="], 539 | 540 | "querystringify": ["querystringify@2.2.0", "", {}, "sha512-FIqgj2EUvTa7R50u0rGsyTftzjYmv/a3hO345bZNrqabNqjtgiDMgmo4mkUjd+nzU5oF3dClKqFIPUKybUyqoQ=="], 541 | 542 | "require-directory": ["require-directory@2.1.1", "", {}, "sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q=="], 543 | 544 | "requires-port": ["requires-port@1.0.0", "", {}, "sha512-KigOCHcocU3XODJxsu8i/j8T9tzT4adHiecwORRQ0ZZFcp7ahwXuRU1m+yuO90C5ZUyGeGfocHDI14M3L3yDAQ=="], 545 | 546 | "semver": ["semver@7.7.1", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-hlq8tAfn0m/61p4BVRcPzIGr6LKiMwo4VM6dGi6pt4qcRkmNzTcWq6eCEjEh+qXjkMDvPlOFFSGwQjoEa6gyMA=="], 547 | 548 | "sharp": ["sharp@0.33.5", "", { "dependencies": { "color": "^4.2.3", "detect-libc": "^2.0.3", "semver": "^7.6.3" }, "optionalDependencies": { "@img/sharp-darwin-arm64": "0.33.5", "@img/sharp-darwin-x64": "0.33.5", "@img/sharp-libvips-darwin-arm64": "1.0.4", "@img/sharp-libvips-darwin-x64": "1.0.4", "@img/sharp-libvips-linux-arm": "1.0.5", "@img/sharp-libvips-linux-arm64": "1.0.4", "@img/sharp-libvips-linux-s390x": "1.0.4", "@img/sharp-libvips-linux-x64": "1.0.4", "@img/sharp-libvips-linuxmusl-arm64": "1.0.4", "@img/sharp-libvips-linuxmusl-x64": "1.0.4", "@img/sharp-linux-arm": "0.33.5", "@img/sharp-linux-arm64": "0.33.5", "@img/sharp-linux-s390x": "0.33.5", "@img/sharp-linux-x64": "0.33.5", "@img/sharp-linuxmusl-arm64": "0.33.5", "@img/sharp-linuxmusl-x64": "0.33.5", "@img/sharp-wasm32": "0.33.5", "@img/sharp-win32-ia32": "0.33.5", "@img/sharp-win32-x64": "0.33.5" } }, "sha512-haPVm1EkS9pgvHrQ/F3Xy+hgcuMV0Wm9vfIBSiwZ05k+xgb0PkBQpGsAA/oWdDobNaZTH5ppvHtzCFbnSEwHVw=="], 549 | 550 | "signal-exit": ["signal-exit@4.1.0", "", {}, "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw=="], 551 | 552 | "simple-swizzle": ["simple-swizzle@0.2.2", "", { "dependencies": { "is-arrayish": "^0.3.1" } }, "sha512-JA//kQgZtbuY83m+xT+tXJkmJncGMTFT+C+g2h2R9uxkYIrE2yy9sgmcLhCnw57/WSD+Eh3J97FPEDFnbXnDUg=="], 553 | 554 | "skin-tone": ["skin-tone@2.0.0", "", { "dependencies": { "unicode-emoji-modifier-base": "^1.0.0" } }, "sha512-kUMbT1oBJCpgrnKoSr0o6wPtvRWT9W9UKvGLwfJYO2WuahZRHOpEyL1ckyMGgMWh0UdpmaoFqKKD29WTomNEGA=="], 555 | 556 | "smart-buffer": ["smart-buffer@4.2.0", "", {}, "sha512-94hK0Hh8rPqQl2xXc3HsaBoOXKV20MToPkcXvwbISWLEs+64sBq5kFgn2kJDHb1Pry9yrP0dxrCI9RRci7RXKg=="], 557 | 558 | "socks": ["socks@2.8.4", "", { "dependencies": { "ip-address": "^9.0.5", "smart-buffer": "^4.2.0" } }, "sha512-D3YaD0aRxR3mEcqnidIs7ReYJFVzWdd6fXJYUM8ixcQcJRGTka/b3saV0KflYhyVJXKhb947GndU35SxYNResQ=="], 559 | 560 | "socks-proxy-agent": ["socks-proxy-agent@8.0.5", "", { "dependencies": { "agent-base": "^7.1.2", "debug": "^4.3.4", "socks": "^2.8.3" } }, "sha512-HehCEsotFqbPW9sJ8WVYB6UbmIMv7kUUORIF2Nncq4VQvBfNBLibW9YZR5dlYCSUhwcD628pRllm7n+E+YTzJw=="], 561 | 562 | "source-map": ["source-map@0.6.1", "", {}, "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g=="], 563 | 564 | "sprintf-js": ["sprintf-js@1.1.3", "", {}, "sha512-Oo+0REFV59/rz3gfJNKQiBlwfHaSESl1pcGyABQsnnIfWOFt6JNj5gCog2U6MLZ//IGYD+nA8nI+mTShREReaA=="], 565 | 566 | "stacktracey": ["stacktracey@2.1.8", "", { "dependencies": { "as-table": "^1.0.36", "get-source": "^2.0.12" } }, "sha512-Kpij9riA+UNg7TnphqjH7/CzctQ/owJGNbFkfEeve4Z4uxT5+JapVLFXcsurIfN34gnTWZNJ/f7NMG0E8JDzTw=="], 567 | 568 | "statuses": ["statuses@2.0.1", "", {}, "sha512-RwNA9Z/7PrK06rYLIzFMlaF+l73iwpzsqRIFgbMLbTcLD6cOao82TaWefPXQvB2fOC4AjuYSEndS7N/mTCbkdQ=="], 569 | 570 | "stoppable": ["stoppable@1.1.0", "", {}, "sha512-KXDYZ9dszj6bzvnEMRYvxgeTHU74QBFL54XKtP3nyMuJ81CFYtABZ3bAzL2EdFUaEwJOBOgENyFj3R7oTzDyyw=="], 571 | 572 | "streamx": ["streamx@2.22.0", "", { "dependencies": { "fast-fifo": "^1.3.2", "text-decoder": "^1.1.0" }, "optionalDependencies": { "bare-events": "^2.2.0" } }, "sha512-sLh1evHOzBy/iWRiR6d1zRcLao4gGZr3C1kzNz4fopCOKJb6xD9ub8Mpi9Mr1R6id5o43S+d93fI48UC5uM9aw=="], 573 | 574 | "strict-event-emitter": ["strict-event-emitter@0.5.1", "", {}, "sha512-vMgjE/GGEPEFnhFub6pa4FmJBRBVOLpIII2hvCZ8Kzb7K0hlHo7mQv6xYrBvCL2LtAIBwFUK8wvuJgTVSQ5MFQ=="], 575 | 576 | "string-width": ["string-width@4.2.3", "", { "dependencies": { "emoji-regex": "^8.0.0", "is-fullwidth-code-point": "^3.0.0", "strip-ansi": "^6.0.1" } }, "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g=="], 577 | 578 | "strip-ansi": ["strip-ansi@6.0.1", "", { "dependencies": { "ansi-regex": "^5.0.1" } }, "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A=="], 579 | 580 | "super-regex": ["super-regex@0.2.0", "", { "dependencies": { "clone-regexp": "^3.0.0", "function-timeout": "^0.1.0", "time-span": "^5.1.0" } }, "sha512-WZzIx3rC1CvbMDloLsVw0lkZVKJWbrkJ0k1ghKFmcnPrW1+jWbgTkTEWVtD9lMdmI4jZEz40+naBxl1dCUhXXw=="], 581 | 582 | "supports-color": ["supports-color@7.2.0", "", { "dependencies": { "has-flag": "^4.0.0" } }, "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw=="], 583 | 584 | "supports-hyperlinks": ["supports-hyperlinks@3.2.0", "", { "dependencies": { "has-flag": "^4.0.0", "supports-color": "^7.0.0" } }, "sha512-zFObLMyZeEwzAoKCyu1B91U79K2t7ApXuQfo8OuxwXLDgcKxuwM+YvcbIhm6QWqz7mHUH1TVytR1PwVVjEuMig=="], 585 | 586 | "tar-fs": ["tar-fs@3.0.5", "", { "dependencies": { "pump": "^3.0.0", "tar-stream": "^3.1.5" }, "optionalDependencies": { "bare-fs": "^2.1.1", "bare-path": "^2.1.0" } }, "sha512-JOgGAmZyMgbqpLwct7ZV8VzkEB6pxXFBVErLtb+XCOqzc6w1xiWKI9GVd6bwk68EX7eJ4DWmfXVmq8K2ziZTGg=="], 587 | 588 | "tar-stream": ["tar-stream@3.1.7", "", { "dependencies": { "b4a": "^1.6.4", "fast-fifo": "^1.2.0", "streamx": "^2.15.0" } }, "sha512-qJj60CXt7IU1Ffyc3NJMjh6EkuCFej46zUqJ4J7pqYlThyd9bO0XBTmcOIhSzZJVWfsLks0+nle/j538YAW9RQ=="], 589 | 590 | "text-decoder": ["text-decoder@1.2.3", "", { "dependencies": { "b4a": "^1.6.4" } }, "sha512-3/o9z3X0X0fTupwsYvR03pJ/DjWuqqrfwBgTQzdWDiQSm9KitAyz/9WqsT2JQW7KV2m+bC2ol/zqpW37NHxLaA=="], 591 | 592 | "thenify": ["thenify@3.3.1", "", { "dependencies": { "any-promise": "^1.0.0" } }, "sha512-RVZSIV5IG10Hk3enotrhvz0T9em6cyHBLkH/YAZuKqd8hRkKhSfCGIcP2KUY0EPxndzANBmNllzWPwak+bheSw=="], 593 | 594 | "thenify-all": ["thenify-all@1.6.0", "", { "dependencies": { "thenify": ">= 3.1.0 < 4" } }, "sha512-RNxQH/qI8/t3thXJDwcstUO4zeqo64+Uy/+sNVRBx4Xn2OX+OZ9oP+iJnNFqplFra2ZUVeKCSa2oVWi3T4uVmA=="], 595 | 596 | "through": ["through@2.3.8", "", {}, "sha512-w89qg7PI8wAdvX60bMDP+bFoD5Dvhm9oLheFp5O4a2QF0cSBGsBX4qZmadPMvVqlLJBBci+WqGGOAPvcDeNSVg=="], 597 | 598 | "time-span": ["time-span@5.1.0", "", { "dependencies": { "convert-hrtime": "^5.0.0" } }, "sha512-75voc/9G4rDIJleOo4jPvN4/YC4GRZrY8yy1uU4lwrB3XEQbWve8zXoO5No4eFrGcTAMYyoY67p8jRQdtA1HbA=="], 599 | 600 | "tough-cookie": ["tough-cookie@4.1.4", "", { "dependencies": { "psl": "^1.1.33", "punycode": "^2.1.1", "universalify": "^0.2.0", "url-parse": "^1.5.3" } }, "sha512-Loo5UUvLD9ScZ6jh8beX1T6sO1w2/MpCRpEP7V280GKMVUQ0Jzar2U3UJPsrdbziLEMMhu3Ujnq//rhiFuIeag=="], 601 | 602 | "tslib": ["tslib@2.8.1", "", {}, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="], 603 | 604 | "type-fest": ["type-fest@4.41.0", "", {}, "sha512-TeTSQ6H5YHvpqVwBRcnLDCBnDOHWYu7IvGbHT6N8AOymcr9PJGjc1GTtiWZTYg0NCgYwvnYWEkVChQAr9bjfwA=="], 605 | 606 | "typedoc": ["typedoc@0.28.4", "", { "dependencies": { "@gerrit0/mini-shiki": "^3.2.2", "lunr": "^2.3.9", "markdown-it": "^14.1.0", "minimatch": "^9.0.5", "yaml": "^2.7.1" }, "peerDependencies": { "typescript": "5.0.x || 5.1.x || 5.2.x || 5.3.x || 5.4.x || 5.5.x || 5.6.x || 5.7.x || 5.8.x" }, "bin": { "typedoc": "bin/typedoc" } }, "sha512-xKvKpIywE1rnqqLgjkoq0F3wOqYaKO9nV6YkkSat6IxOWacUCc/7Es0hR3OPmkIqkPoEn7U3x+sYdG72rstZQA=="], 607 | 608 | "typedoc-plugin-mdn-links": ["typedoc-plugin-mdn-links@5.0.2", "", { "peerDependencies": { "typedoc": "0.27.x || 0.28.x" } }, "sha512-Bd3lsVWPSpDkn6NGZyPHpcK088PUvH4SRq4RD97OjA6l8PQA3yOnJhGACtjmIDdcenRTgWUosH+55ANZhx/wkw=="], 609 | 610 | "typescript": ["typescript@5.8.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-p1diW6TqL9L07nNxvRMM7hMMw4c5XOo/1ibL4aAIGmSAt9slTE1Xgw5KWuof2uTOvCg9BY7ZRi+GaF+7sfgPeQ=="], 611 | 612 | "uc.micro": ["uc.micro@2.1.0", "", {}, "sha512-ARDJmphmdvUk6Glw7y9DQ2bFkKBHwQHLi2lsaH6PPmz/Ka9sFOBsBluozhDltWmnv9u/cF6Rt87znRTPV+yp/A=="], 613 | 614 | "ufo": ["ufo@1.6.1", "", {}, "sha512-9a4/uxlTWJ4+a5i0ooc1rU7C7YOw3wT+UGqdeNNHWnOF9qcMBgLRS+4IYUqbczewFx4mLEig6gawh7X6mFlEkA=="], 615 | 616 | "unbzip2-stream": ["unbzip2-stream@1.4.3", "", { "dependencies": { "buffer": "^5.2.1", "through": "^2.3.8" } }, "sha512-mlExGW4w71ebDJviH16lQLtZS32VKqsSfk80GCfUlwT/4/hNRFsoscrF/c++9xinkMzECL1uL9DDwXqFWkruPg=="], 617 | 618 | "undici": ["undici@5.28.5", "", { "dependencies": { "@fastify/busboy": "^2.0.0" } }, "sha512-zICwjrDrcrUE0pyyJc1I2QzBkLM8FINsgOrt6WjA+BgajVq9Nxu2PbFFXUrAggLfDXlZGZBVZYw7WNV5KiBiBA=="], 619 | 620 | "undici-types": ["undici-types@6.20.0", "", {}, "sha512-Ny6QZ2Nju20vw1SRHe3d9jVu6gJ+4e3+MMpqu7pqE5HT6WsTSlce++GQmK5UXS8mzV8DSYHrQH+Xrf2jVcuKNg=="], 621 | 622 | "unenv": ["unenv@2.0.0-rc.17", "", { "dependencies": { "defu": "^6.1.4", "exsolve": "^1.0.4", "ohash": "^2.0.11", "pathe": "^2.0.3", "ufo": "^1.6.1" } }, "sha512-B06u0wXkEd+o5gOCMl/ZHl5cfpYbDZKAT+HWTL+Hws6jWu7dCiqBBXXXzMFcFVJb8D4ytAnYmxJA83uwOQRSsg=="], 623 | 624 | "unicode-emoji-modifier-base": ["unicode-emoji-modifier-base@1.0.0", "", {}, "sha512-yLSH4py7oFH3oG/9K+XWrz1pSi3dfUrWEnInbxMfArOfc1+33BlGPQtLsOYwvdMy11AwUBetYuaRxSPqgkq+8g=="], 625 | 626 | "universalify": ["universalify@0.2.0", "", {}, "sha512-CJ1QgKmNg3CwvAv/kOFmtnEN05f0D/cn9QntgNOQlQF9dgvVTHj3t+8JPdjqawCHk7V/KA+fbUqzZ9XWhcqPUg=="], 627 | 628 | "url-parse": ["url-parse@1.5.10", "", { "dependencies": { "querystringify": "^2.1.1", "requires-port": "^1.0.0" } }, "sha512-WypcfiRhfeUP9vvF0j6rw0J3hrWrw6iZv3+22h6iRMJ/8z1Tj6XfLP4DsUix5MhMPnXpiHDoKyoZ/bdCkwBCiQ=="], 629 | 630 | "validate-npm-package-name": ["validate-npm-package-name@5.0.1", "", {}, "sha512-OljLrQ9SQdOUqTaQxqL5dEfZWrXExyyWsozYlAWFawPVNuD83igl7uJD2RTkNMbniIYgt8l81eCJGIdQF7avLQ=="], 631 | 632 | "workerd": ["workerd@1.20250525.0", "", { "optionalDependencies": { "@cloudflare/workerd-darwin-64": "1.20250525.0", "@cloudflare/workerd-darwin-arm64": "1.20250525.0", "@cloudflare/workerd-linux-64": "1.20250525.0", "@cloudflare/workerd-linux-arm64": "1.20250525.0", "@cloudflare/workerd-windows-64": "1.20250525.0" }, "bin": { "workerd": "bin/workerd" } }, "sha512-SXJgLREy/Aqw2J71Oah0Pbu+SShbqbTExjVQyRBTM1r7MG7fS5NUlknhnt6sikjA/t4cO09Bi8OJqHdTkrcnYQ=="], 633 | 634 | "wrangler": ["wrangler@4.18.0", "", { "dependencies": { "@cloudflare/kv-asset-handler": "0.4.0", "@cloudflare/unenv-preset": "2.3.2", "blake3-wasm": "2.1.5", "esbuild": "0.25.4", "miniflare": "4.20250525.0", "path-to-regexp": "6.3.0", "unenv": "2.0.0-rc.17", "workerd": "1.20250525.0" }, "optionalDependencies": { "fsevents": "~2.3.2" }, "peerDependencies": { "@cloudflare/workers-types": "^4.20250525.0" }, "optionalPeers": ["@cloudflare/workers-types"], "bin": { "wrangler": "bin/wrangler.js", "wrangler2": "bin/wrangler.js" } }, "sha512-/ng0KI9io97SNsBU1rheADBLLTE5Djybgsi4gXuvH1RBKJGpyj1xWvZ2fuWu8vAonit3EiZkwtERTm6kESHP3A=="], 635 | 636 | "wrap-ansi": ["wrap-ansi@6.2.0", "", { "dependencies": { "ansi-styles": "^4.0.0", "string-width": "^4.1.0", "strip-ansi": "^6.0.0" } }, "sha512-r6lPcBGxZXlIcymEu7InxDMhdW0KDxpLgoFLcguasxCaJ/SOIZwINatK9KY/tf+ZrlywOKU0UDj3ATXUBfxJXA=="], 637 | 638 | "wrappy": ["wrappy@1.0.2", "", {}, "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ=="], 639 | 640 | "ws": ["ws@8.17.0", "", { "peerDependencies": { "bufferutil": "^4.0.1", "utf-8-validate": ">=5.0.2" }, "optionalPeers": ["bufferutil", "utf-8-validate"] }, "sha512-uJq6108EgZMAl20KagGkzCKfMEjxmKvZHG7Tlq0Z6nOky7YF7aq4mOx6xK8TJ/i1LeK4Qus7INktacctDgY8Ow=="], 641 | 642 | "y18n": ["y18n@5.0.8", "", {}, "sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA=="], 643 | 644 | "yallist": ["yallist@4.0.0", "", {}, "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A=="], 645 | 646 | "yaml": ["yaml@2.7.1", "", { "bin": { "yaml": "bin.mjs" } }, "sha512-10ULxpnOCQXxJvBgxsn9ptjq6uviG/htZKk9veJGhlqn3w/DxQ631zFF+nlQXLwmImeS5amR2dl2U8sg6U9jsQ=="], 647 | 648 | "yargs": ["yargs@17.7.2", "", { "dependencies": { "cliui": "^8.0.1", "escalade": "^3.1.1", "get-caller-file": "^2.0.5", "require-directory": "^2.1.1", "string-width": "^4.2.3", "y18n": "^5.0.5", "yargs-parser": "^21.1.1" } }, "sha512-7dSzzRQ++CKnNI/krKnYRV7JKKPUXMEh61soaHKg9mrWEhzFWhFnxPxGl+69cD1Ou63C13NUPCnmIcrvqCuM6w=="], 649 | 650 | "yargs-parser": ["yargs-parser@21.1.1", "", {}, "sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw=="], 651 | 652 | "yauzl": ["yauzl@2.10.0", "", { "dependencies": { "buffer-crc32": "~0.2.3", "fd-slicer": "~1.1.0" } }, "sha512-p4a9I6X6nu6IhoGmBqAcbJy1mlC4j27vEPZX9F4L4/vZT3Lyq1VkFHw/V/PUcB9Buo+DG3iHkT0x3Qya58zc3g=="], 653 | 654 | "yoctocolors-cjs": ["yoctocolors-cjs@2.1.2", "", {}, "sha512-cYVsTjKl8b+FrnidjibDWskAv7UKOfcwaVZdp/it9n1s9fU3IkgDbhdIRKCW4JDsAlECJY0ytoVPT3sK6kideA=="], 655 | 656 | "youch": ["youch@3.3.4", "", { "dependencies": { "cookie": "^0.7.1", "mustache": "^4.2.0", "stacktracey": "^2.1.8" } }, "sha512-UeVBXie8cA35DS6+nBkls68xaBBXCye0CNznrhszZjTbRVnJKQuNsyLKBTTL4ln1o1rh2PKtv35twV7irj5SEg=="], 657 | 658 | "zod": ["zod@3.22.3", "", {}, "sha512-EjIevzuJRiRPbVH4mGc8nApb/lVLKVpmUhAaR5R5doKGfAnGJ6Gr3CViAVjP+4FWSxCsybeWQdcgCtbX+7oZug=="], 659 | 660 | "@arethetypeswrong/core/typescript": ["typescript@5.6.1-rc", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-E3b2+1zEFu84jB0YQi9BORDjz9+jGbwwy1Zi3G0LUNw7a7cePUrHMRNy8aPh53nXpkFGVHSxIZo5vKTfYaFiBQ=="], 661 | 662 | "@edgefirst-dev/worker-kv-rate-limit/@cloudflare/workers-types": ["@cloudflare/workers-types@4.20250224.0", "", {}, "sha512-j6ZwQ5G2moQRaEtGI2u5TBQhVXv/XwOS5jfBAheZHcpCM07zm8j0i8jZHHLq/6VA8e6VRjKohOyj5j6tZ1KHLQ=="], 663 | 664 | "@inquirer/core/ansi-escapes": ["ansi-escapes@4.3.2", "", { "dependencies": { "type-fest": "^0.21.3" } }, "sha512-gKXj5ALrKWQLsYG9jlTRmR/xKluxHV+Z9QEwNIgCfM1/uwPMCuzVVnh5mwTd+OuBZcwSIMbqssNWRm1lE51QaQ=="], 665 | 666 | "@puppeteer/browsers/semver": ["semver@7.6.0", "", { "dependencies": { "lru-cache": "^6.0.0" }, "bin": { "semver": "bin/semver.js" } }, "sha512-EnwXhrlwXMk9gKu5/flx5sv/an57AkRplG3hTK68W7FRDN+k+OWBj65M7719OkA82XLBxrcX0KSHj+X5COhOVg=="], 667 | 668 | "@types/bun/bun-types": ["bun-types@1.2.15", "", { "dependencies": { "@types/node": "*" } }, "sha512-NarRIaS+iOaQU1JPfyKhZm4AsUOrwUOqRNHY0XxI8GI8jYxiLXLcdjYMG9UKS+fwWasc1uw1htV9AX24dD+p4w=="], 669 | 670 | "cli-highlight/yargs": ["yargs@16.2.0", "", { "dependencies": { "cliui": "^7.0.2", "escalade": "^3.1.1", "get-caller-file": "^2.0.5", "require-directory": "^2.1.1", "string-width": "^4.2.0", "y18n": "^5.0.5", "yargs-parser": "^20.2.2" } }, "sha512-D1mvvtDG0L5ft/jGWkLpG1+m0eQxOfaBvTNELraWj22wSVUMWxZUvYgJYcKh6jGGIkJFhH4IZPQhR4TKpc8mBw=="], 671 | 672 | "cliui/wrap-ansi": ["wrap-ansi@7.0.0", "", { "dependencies": { "ansi-styles": "^4.0.0", "string-width": "^4.1.0", "strip-ansi": "^6.0.0" } }, "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q=="], 673 | 674 | "get-uri/data-uri-to-buffer": ["data-uri-to-buffer@6.0.2", "", {}, "sha512-7hvf7/GW8e86rW0ptuwS3OcBGDjIi6SZva7hCyWC0yYry2cOPmLIjXAUHI6DK2HsnwJd9ifmt57i8eV2n4YNpw=="], 675 | 676 | "marked-terminal/chalk": ["chalk@5.4.1", "", {}, "sha512-zgVZuo2WcZgfUEmsn6eO3kINexW8RAE4maiQ8QNs8CtpPCSyMiYsULR3HQYkm3w8FIA3SberyMJMSldGsW+U3w=="], 677 | 678 | "miniflare/ws": ["ws@8.18.0", "", { "peerDependencies": { "bufferutil": "^4.0.1", "utf-8-validate": ">=5.0.2" }, "optionalPeers": ["bufferutil", "utf-8-validate"] }, "sha512-8VbfWfHLbbwu3+N6OKsOMpBdT4kXPDDB9cJk2bJ6mh9ucxdlnNvH1e+roYkKmN9Nxw2yjz7VzeO9oOz2zJ04Pw=="], 679 | 680 | "parse5-htmlparser2-tree-adapter/parse5": ["parse5@6.0.1", "", {}, "sha512-Ofn/CTFzRGTTxwpNEs9PP93gXShHcTq255nzRYSKe8AkVpZY7e1fpmTfOyoIvjP5HG7Z2ZM7VS9PPhQGW2pOpw=="], 681 | 682 | "proxy-agent/lru-cache": ["lru-cache@7.18.3", "", {}, "sha512-jumlc0BIUrS3qJGgIkWZsyfAM7NCWiBcCDhnd+3NNM5KbBmLTgHVfWBcg6W+rLUsIpzpERPsvwUP7CckAQSOoA=="], 683 | 684 | "strip-ansi/ansi-regex": ["ansi-regex@5.0.1", "", {}, "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ=="], 685 | 686 | "@inquirer/core/ansi-escapes/type-fest": ["type-fest@0.21.3", "", {}, "sha512-t0rzBq87m3fVcduHDUFhKmyyX+9eo6WQjZvf51Ea/M0Q7+T374Jp1aUiyUl0GKxp8M/OETVHSDvmkyPgvX+X2w=="], 687 | 688 | "@puppeteer/browsers/semver/lru-cache": ["lru-cache@6.0.0", "", { "dependencies": { "yallist": "^4.0.0" } }, "sha512-Jo6dJ04CmSjuznwJSS3pUeWmd/H0ffTlkXXgwZi+eq1UCmqQwCh+eLsYOYCwY991i2Fah4h1BEMCx4qThGbsiA=="], 689 | 690 | "cli-highlight/yargs/cliui": ["cliui@7.0.4", "", { "dependencies": { "string-width": "^4.2.0", "strip-ansi": "^6.0.0", "wrap-ansi": "^7.0.0" } }, "sha512-OcRE68cOsVMXp1Yvonl/fzkQOyjLSu/8bhPDfQt0e0/Eb283TKP20Fs2MqoPsr9SwA595rRCA+QMzYc9nBP+JQ=="], 691 | 692 | "cli-highlight/yargs/yargs-parser": ["yargs-parser@20.2.9", "", {}, "sha512-y11nGElTIV+CT3Zv9t7VKl+Q3hTQoT9a1Qzezhhl6Rp21gJ/IVTW7Z3y9EWXhuUBC2Shnf+DX0antecpAwSP8w=="], 693 | 694 | "cli-highlight/yargs/cliui/wrap-ansi": ["wrap-ansi@7.0.0", "", { "dependencies": { "ansi-styles": "^4.0.0", "string-width": "^4.1.0", "strip-ansi": "^6.0.0" } }, "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q=="], 695 | } 696 | } 697 | -------------------------------------------------------------------------------- /docs/getting-started/directory-structure.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: Directory Structure 3 | --- 4 | 5 | # Directory Structure 6 | 7 | The directory structure of an Edge-first application is similar to a typical Cloudflare Worker project. Here's an example of a typical directory structure for an Edge-first application: 8 | 9 | ``` 10 | my-app/ 11 | ├── worker.ts 12 | ├── app/ 13 | │ ├── assets/ 14 | │ ├── clients/ 15 | │ ├── components/ 16 | │ ├── entities/ 17 | │ ├── helpers/ 18 | │ ├── jobs/ 19 | │ ├── mocks/ 20 | │ ├── repositories/ 21 | │ ├── resources/ 22 | │ ├── services/ 23 | │ ├── tasks/ 24 | │ ├── views/ 25 | | | ├── layouts/ 26 | ├── config/ 27 | | ├── redirects.ts 28 | ├── db/ 29 | | ├── helpers/ 30 | | ├── migrations/ 31 | | ├── schema.ts 32 | | ├── seed.sql 33 | ├── scripts/ 34 | ``` 35 | 36 | Here's a brief overview of each directory: 37 | 38 | - `app/`: Contains the main application code. 39 | - `assets/`: Contains static assets like images, fonts, and stylesheets. 40 | - `clients/`: Contains API clients. 41 | - `components/`: Contains reusable UI components. 42 | - `entities/`: Contains domain entities. 43 | - `helpers/`: Contains utility functions. 44 | - `jobs/`: Contains background jobs. 45 | - `mocks/`: Contains mock data for testing. 46 | - `repositories/`: Contains data access logic. 47 | - `resources/`: Contains configuration files. 48 | - `services/`: Contains business logic. 49 | - `tasks/`: Contains scheduled tasks. 50 | - `views/`: Contains view templates. 51 | - `layouts/`: Contains layout templates. 52 | - `config/`: Contains configuration files. 53 | - `redirects.ts`: Contains URL redirect rules. 54 | - `db/`: Contains database-related files. 55 | - `helpers/`: Contains database helper functions. 56 | - `migrations/`: Contains database migration scripts. 57 | - `schema.ts`: Contains the database schema definition. 58 | - `seed.sql`: Contains seed data for the database. 59 | - `scripts/`: Contains utility scripts. 60 | -------------------------------------------------------------------------------- /docs/getting-started/index.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: Getting Started 3 | children: 4 | - ./installation.md 5 | - ./directory-structure.md 6 | --- 7 | 8 | # Getting Started 9 | 10 | ## Meet EdgeKit.js 11 | 12 | EdgeKit.js is a toolkit that helps you build Edge-first applications on top of Cloudflare Workers. It provides a set of tools to help you build your application on the edge. 13 | 14 | ## Why EdgeKit.js? 15 | 16 | - **Global Environment Access**: Access to global environment variables. 17 | - **Key-Value Store**: Store data in a key-value store. 18 | - **Server-side Cache with TTL**: Cache data on the server with a time-to-live. 19 | - **File Storage**: Store files on the edge. 20 | - **Database**: Access to a database. 21 | - **Background Jobs**: Run background jobs. 22 | - **Scheduled Tasks**: Schedule tasks to run at a specific time. 23 | - **Browser Rendering**: Render content on the edge. 24 | - **Rate Limiting**: Limit the number of requests to your application. 25 | - **And more things!** 26 | -------------------------------------------------------------------------------- /docs/getting-started/installation.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: Installation 3 | --- 4 | 5 | # Installation 6 | 7 | ## Creating an Edge-first App 8 | 9 | ### Installing EdgeKit.js 10 | 11 | To install EdgeKit.js, run the following command: 12 | 13 | ```bash 14 | bun install edgekitjs 15 | ``` 16 | 17 | This will install EdgeKit.js in your project. 18 | 19 | Next, you need to bootstrap EdgeKit.js in your Cloudflare Worker script. To do this, add the following code to your `worker.js` file: 20 | 21 | ```javascript 22 | import { bootstrap } from "edgekitjs"; 23 | 24 | export default bootstrap({ 25 | onRequest(request) { 26 | // Write your application here 27 | return new Response("Hello, World!", { 28 | headers: { 29 | "content-type": "text/plain", 30 | }, 31 | }); 32 | }, 33 | }); 34 | ``` 35 | 36 | ### Creating a New Edge-first App 37 | 38 | To create a new Edge-first app using EdgeKit.js, run the following command: 39 | 40 | ```bash 41 | npx degit edgefirst-dev/starter my-app 42 | ``` 43 | 44 | This will give you a new Cloudflare Worker project with EdgeKit.js already set up and React Router v7. 45 | 46 | Once the application has been created, you can start it locally by running the following command: 47 | 48 | ```bash 49 | cd example-app 50 | bun install 51 | bun run dev 52 | ``` 53 | 54 | ## Initial Configuration (WIP) 55 | 56 | To configure EdgeKit.js, you need to create a `.dev.vars` file in the root of your project. Here is an example configuration: 57 | 58 | ```env 59 | APP_ENV="development" 60 | 61 | CLOUDFLARE_ACCOUNT_ID="" 62 | CLOUDFLARE_DATABASE_ID="" 63 | CLOUDFLARE_API_TOKEN="" 64 | 65 | GRAVATAR_API_TOKEN="" 66 | 67 | VERIFIER_API_KEY="" 68 | ``` 69 | 70 | This configuration file will be used to set up the necessary environment variables for your application. 71 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "edgekitjs", 3 | "version": "0.0.51", 4 | "description": "The core of the Edge-first Stack", 5 | "license": "MIT", 6 | "funding": [ 7 | "https://github.com/sponsors/sergiodxa" 8 | ], 9 | "author": { 10 | "name": "Sergio Xalambrí", 11 | "email": "hello+oss@sergiodxa.com", 12 | "url": "https://sergiodxa.com" 13 | }, 14 | "repository": { 15 | "type": "git", 16 | "url": "https://github.com/edgefirst-dev/kit" 17 | }, 18 | "homepage": "https://github.com/edgefirst-dev/kit", 19 | "bugs": { 20 | "url": "https://github.com/edgefirst-dev/kit/issues" 21 | }, 22 | "scripts": { 23 | "build": "tsc", 24 | "typecheck": "tsc --noEmit", 25 | "quality": "biome check .", 26 | "quality:fix": "biome check . --write --unsafe", 27 | "exports": "bun run ./scripts/exports.ts" 28 | }, 29 | "sideEffects": false, 30 | "type": "module", 31 | "engines": { 32 | "node": ">=20.0.0" 33 | }, 34 | "files": [ 35 | "build", 36 | "package.json", 37 | "README.md" 38 | ], 39 | "exports": { 40 | ".": "./build/index.js", 41 | "./worker": "./build/worker.js", 42 | "./package.json": "./package.json" 43 | }, 44 | "peerDependencies": { 45 | "@cloudflare/workers-types": "^4.20250313.0", 46 | "drizzle-orm": "^0.40.0" 47 | }, 48 | "dependencies": { 49 | "@cloudflare/puppeteer": "^1.0.1", 50 | "@edgefirst-dev/api-client": "^0.1.0", 51 | "@edgefirst-dev/data": "^0.0.4", 52 | "@edgefirst-dev/r2-file-storage": "^1.1.0", 53 | "@edgefirst-dev/worker-kv-rate-limit": "^1.0.0", 54 | "@mjackson/form-data-parser": "^0.7.0", 55 | "@mjackson/headers": "^0.10.0", 56 | "@oslojs/crypto": "^1.0.1", 57 | "@oslojs/encoding": "^1.1.0", 58 | "@paralleldrive/cuid2": "^2.2.2", 59 | "bcryptjs": "^3.0.0", 60 | "bowser": "^2.11.0", 61 | "inflected": "^2.1.0", 62 | "is-ip": "^5.0.1", 63 | "type-fest": "^4.30.0" 64 | }, 65 | "devDependencies": { 66 | "@arethetypeswrong/cli": "^0.18.1", 67 | "@biomejs/biome": "^1.9.4", 68 | "@total-typescript/tsconfig": "^1.0.4", 69 | "@types/bcryptjs": "^3.0.0", 70 | "@types/bun": "^1.2.5", 71 | "@types/inflected": "^2.1.3", 72 | "consola": "^3.2.3", 73 | "msw": "^2.6.8", 74 | "typedoc": "^0.28.0", 75 | "typedoc-plugin-mdn-links": "^5.0.1", 76 | "typescript": "^5.7.2", 77 | "wrangler": "^4.0.0" 78 | } 79 | } 80 | -------------------------------------------------------------------------------- /scripts/exports.ts: -------------------------------------------------------------------------------- 1 | async function main() { 2 | let proc = Bun.spawn([ 3 | "bunx", 4 | "attw", 5 | "-f", 6 | "table-flipped", 7 | "--no-emoji", 8 | "--no-color", 9 | "--pack", 10 | ]); 11 | 12 | let text = await new Response(proc.stdout).text(); 13 | 14 | let entrypointLines = text 15 | .slice(text.indexOf('"remix-i18next/')) 16 | .split("\n") 17 | .filter(Boolean) 18 | .filter((line) => !line.includes("─")) 19 | .map((line) => 20 | line 21 | .replaceAll(/[^\d "()/A-Za-z│-]/g, "") 22 | .replaceAll("90m│39m", "│") 23 | .replaceAll(/^│/g, "") 24 | .replaceAll(/│$/g, ""), 25 | ); 26 | 27 | let pkg = await Bun.file("package.json").json(); 28 | let entrypoints = entrypointLines.map((entrypointLine) => { 29 | let [entrypoint, ...resolutionColumns] = entrypointLine.split("│"); 30 | if (!entrypoint) throw new Error("Entrypoint not found"); 31 | if (!resolutionColumns[2]) throw new Error("ESM resolution not found"); 32 | if (!resolutionColumns[3]) throw new Error("Bundler resolution not found"); 33 | return { 34 | entrypoint: entrypoint.replace(pkg.name, ".").trim(), 35 | esm: resolutionColumns[2].trim(), 36 | bundler: resolutionColumns[3].trim(), 37 | }; 38 | }); 39 | 40 | let entrypointsWithProblems = entrypoints.filter( 41 | (item) => item.esm.includes("fail") || item.bundler.includes("fail"), 42 | ); 43 | 44 | if (entrypointsWithProblems.length > 0) { 45 | console.error("Entrypoints with problems:"); 46 | process.exit(1); 47 | } 48 | } 49 | 50 | await main().catch((error) => { 51 | console.error(error); 52 | process.exit(1); 53 | }); 54 | 55 | export {}; 56 | -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- 1 | export type { Environment, DatabaseSchema } from "./lib/types.js"; 2 | export type { Cache } from "./lib/cache/cache.js"; 3 | export type { Env } from "./lib/env/env.js"; 4 | export type { FS } from "./lib/fs/fs.js"; 5 | export type { Geo } from "./lib/geo/geo.js"; 6 | export type { KV } from "./lib/kv/kv.js"; 7 | export type { Queue } from "./lib/queue/queue.js"; 8 | export type { WorkerKVRateLimit } from "@edgefirst-dev/worker-kv-rate-limit"; 9 | 10 | // biome-ignore lint/performance/noBarrelFile: This is ok 11 | export { Job } from "./lib/jobs/job.js"; 12 | export { Task } from "./lib/tasks/task.js"; 13 | export { 14 | bindings, 15 | cache, 16 | env, 17 | fs, 18 | geo, 19 | headers, 20 | kv, 21 | orm, 22 | puppeteer, 23 | queue, 24 | rateLimit, 25 | request, 26 | signal, 27 | defer as waitUntil, 28 | } from "./lib/storage/accessors.js"; 29 | export { IPAddress } from "./lib/values/ip-address.js"; 30 | export { Password } from "./lib/values/password.js"; 31 | export { UserAgent } from "./lib/values/user-agent.js"; 32 | export { Email } from "./lib/values/email.js"; 33 | export { PwnedPasswords } from "./lib/clients/pwned-passwords.js"; 34 | export { EmailVerifier } from "./lib/clients/email-verifier.js"; 35 | export { StringParser, type CUID } from "./lib/parsers/string-parser.js"; 36 | export { NumberParser } from "./lib/parsers/number-parser.js"; 37 | export { Entity, TableEntity } from "./lib/entities/entity.js"; 38 | export { 39 | EdgeConfigError, 40 | EdgeContextError, 41 | EdgeEnvKeyError, 42 | EdgeError, 43 | EdgeRequestGeoError, 44 | } from "./lib/errors.js"; 45 | -------------------------------------------------------------------------------- /src/lib/cache/cache.test.ts: -------------------------------------------------------------------------------- 1 | import { describe, expect, mock, test } from "bun:test"; 2 | import { MockKVNamespace } from "../../mocks/cf.js"; 3 | import { Cache } from "./cache.js"; 4 | 5 | describe(Cache.name, () => { 6 | test("#constructor", () => { 7 | let kv = new MockKVNamespace(); 8 | 9 | let cache = new Cache(kv); 10 | 11 | expect(cache).toBeInstanceOf(Cache); 12 | }); 13 | 14 | test("#binding", () => { 15 | let kv = new MockKVNamespace(); 16 | 17 | let cache = new Cache(kv); 18 | 19 | expect(cache.binding).toEqual(kv); 20 | }); 21 | 22 | test("#fetch", async () => { 23 | let kv = new MockKVNamespace(); 24 | let cacheFn = mock().mockImplementation(() => "result"); 25 | 26 | let cache = new Cache(kv); 27 | let result = await cache.fetch("key", cacheFn); 28 | 29 | expect(result).toBe("result"); 30 | expect(kv.get).toHaveBeenCalledTimes(1); 31 | expect(kv.put).toHaveBeenCalledTimes(1); 32 | expect(cacheFn).toHaveBeenCalledTimes(1); 33 | }); 34 | 35 | test("#fetch (cached)", async () => { 36 | let kv = new MockKVNamespace([["cache:key", { value: "result" }]]); 37 | let cacheFn = mock().mockImplementation(() => "result"); 38 | 39 | let cache = new Cache(kv); 40 | 41 | let result = await cache.fetch("key", cacheFn); 42 | 43 | expect(result).toBe("result"); 44 | expect(kv.get).toHaveBeenCalledTimes(1); 45 | // None of these should be called because data comes from cache 46 | expect(kv.put).toHaveBeenCalledTimes(0); 47 | expect(cacheFn).toHaveBeenCalledTimes(0); 48 | }); 49 | 50 | test("#fetch with TTL", async () => { 51 | let kv = new MockKVNamespace(); 52 | let cacheFn = mock().mockImplementation(() => "result"); 53 | 54 | let cache = new Cache(kv); 55 | let result = await cache.fetch("key", 120, cacheFn); 56 | 57 | expect(result).toBe("result"); 58 | expect(kv.get).toHaveBeenCalledTimes(1); 59 | expect(kv.put).toHaveBeenCalledTimes(1); 60 | expect(cacheFn).toHaveBeenCalledTimes(1); 61 | }); 62 | 63 | test("#purge", async () => { 64 | let kv = new MockKVNamespace(); 65 | let cacheFn = mock().mockImplementation(() => "result"); 66 | 67 | let cache = new Cache(kv); 68 | 69 | await cache.fetch("key", cacheFn); 70 | cache.purge("key"); 71 | 72 | expect(kv.delete).toHaveBeenCalledTimes(1); 73 | expect(cacheFn).toHaveBeenCalledTimes(1); 74 | }); 75 | }); 76 | -------------------------------------------------------------------------------- /src/lib/cache/cache.ts: -------------------------------------------------------------------------------- 1 | import type { KVNamespace } from "@cloudflare/workers-types"; 2 | import type { Jsonifiable } from "type-fest"; 3 | 4 | /** 5 | * @group Cache 6 | */ 7 | export namespace Cache { 8 | /** 9 | * A string representing a key in the cache. 10 | */ 11 | export type Key = string; 12 | /** 13 | * A number representing the time-to-live for the cache. 14 | */ 15 | export type TTL = number; 16 | 17 | export namespace Fetch { 18 | export interface Options { 19 | /** 20 | * The key to use for the cache 21 | */ 22 | key: string; 23 | /** 24 | * The time-to-live for the cache 25 | */ 26 | ttl?: number; 27 | } 28 | 29 | export type CallbackFunction = () => T | Promise; 30 | } 31 | } 32 | 33 | /** 34 | * Cache functions result in your Edge-first applications. 35 | * @group Cache 36 | */ 37 | export class Cache { 38 | protected prefix = "cache"; 39 | 40 | constructor(protected kv: KVNamespace) {} 41 | 42 | /** 43 | * A read-only property that gives you the `KVNamespace` used by the Cache object. 44 | * 45 | * The namespace can be used to access the KVNamespace directly in case you need to integrate with it. 46 | * @example 47 | * let namespace = cache().binding; 48 | */ 49 | get binding() { 50 | return this.kv; 51 | } 52 | 53 | /** 54 | * The `cache().fetch` method is used to get a value from the cache or 55 | * calculate it if it's not there. 56 | * 57 | * The function expects the key, the TTL, and a function that will be called 58 | * to calculate the value if it's not in the cache. 59 | * @param key The cache key to use, always prefixed by `cache:` 60 | * @param ttl The time-to-live for the cache, in seconds 61 | * @param callback The function to call if the cache is not found 62 | * 63 | * @example 64 | * let ONE_HOUR_IN_SECONDS = 3600; 65 | * 66 | * let value = await cache().fetch("key", ONE_HOUR_IN_SECONDS, async () => { 67 | * // do something expensive and return the value 68 | * }); 69 | * 70 | * // The TTL is optional, it defaults to 60 seconds if not provided. 71 | * 72 | * @example 73 | * await cache().fetch("another-key", async () => { 74 | * // The TTL is optional, it defaults to 60 seconds 75 | * }); 76 | */ 77 | async fetch( 78 | key: Cache.Key, 79 | cb: Cache.Fetch.CallbackFunction, 80 | ): Promise; 81 | async fetch( 82 | key: Cache.Key, 83 | ttl: Cache.TTL, 84 | callback: Cache.Fetch.CallbackFunction, 85 | ): Promise; 86 | async fetch( 87 | key: Cache.Key, 88 | ttlOrCb: Cache.TTL | Cache.Fetch.CallbackFunction, 89 | callback?: Cache.Fetch.CallbackFunction, 90 | ): Promise { 91 | let cacheKey = this.getPrefixedKey(key); 92 | 93 | let cached = await this.kv.get(cacheKey, "text"); 94 | if (cached) return JSON.parse(cached) as T; 95 | 96 | let result = await this.cb(ttlOrCb, callback)(); 97 | 98 | await this.kv.put(cacheKey, JSON.stringify(result), { 99 | expirationTtl: this.ttl(ttlOrCb) || 60, 100 | }); 101 | 102 | return result; 103 | } 104 | 105 | /** 106 | * The `cache().purge` method is used to remove a key from the cache. 107 | * @param key The cache key to delete, always prefixed by `cache:` 108 | * 109 | * @example 110 | * cache().purge("key"); 111 | */ 112 | async purge(key: Cache.Key) { 113 | await this.kv.delete(this.getPrefixedKey(key)); 114 | } 115 | 116 | protected getPrefixedKey(key: Cache.Key): string { 117 | return `${this.prefix}:${key}`; 118 | } 119 | 120 | private ttl(ttlOrCb: Cache.TTL | Cache.Fetch.CallbackFunction): number { 121 | if (typeof ttlOrCb === "number") return ttlOrCb; 122 | return 60; 123 | } 124 | 125 | private cb( 126 | ttlOrCb: Cache.TTL | Cache.Fetch.CallbackFunction, 127 | callback?: Cache.Fetch.CallbackFunction, 128 | ): Cache.Fetch.CallbackFunction { 129 | let cb: Cache.Fetch.CallbackFunction | null = null; 130 | if (typeof ttlOrCb === "function") cb = ttlOrCb; 131 | else if (callback) cb = callback; 132 | else throw new Error("No callback function provided"); 133 | return cb; 134 | } 135 | } 136 | -------------------------------------------------------------------------------- /src/lib/clients/email-verifier.test.ts: -------------------------------------------------------------------------------- 1 | import { afterAll, beforeAll, describe, expect, mock, test } from "bun:test"; 2 | import { http, HttpResponse } from "msw"; 3 | import { setupServer } from "msw/native"; 4 | import { Email } from "../values/email.js"; 5 | import { EmailVerifier } from "./email-verifier"; 6 | 7 | mock.module("../storage/accessors.js", () => { 8 | return { 9 | orm: mock(), 10 | env() { 11 | return { 12 | fetch(key: string) { 13 | return key; 14 | }, 15 | }; 16 | }, 17 | }; 18 | }); 19 | 20 | describe(EmailVerifier.name, () => { 21 | let server = setupServer(); 22 | let email = Email.from("john.doe@company.com"); 23 | 24 | beforeAll(() => server.listen()); 25 | afterAll(() => server.close()); 26 | 27 | test("#constructor()", () => { 28 | const client = new EmailVerifier(); 29 | expect(client).toBeInstanceOf(EmailVerifier); 30 | }); 31 | 32 | test("#verify()", async () => { 33 | let client = new EmailVerifier(); 34 | 35 | server.resetHandlers( 36 | http.get(`https://verifyright.co/verify/${email}`, () => { 37 | return HttpResponse.json({ status: true }); 38 | }), 39 | ); 40 | 41 | expect(client.verify(email)).resolves.toBeUndefined(); 42 | }); 43 | 44 | test("#profile() with error", async () => { 45 | let client = new EmailVerifier(); 46 | 47 | server.resetHandlers( 48 | http.get(`https://verifyright.co/verify/${email}`, () => { 49 | return HttpResponse.json({ 50 | status: false, 51 | error: { code: 2, message: "Disposable email address" }, 52 | }); 53 | }), 54 | ); 55 | 56 | expect(client.verify(email)).rejects.toThrowError( 57 | EmailVerifier.InvalidEmailError, 58 | ); 59 | }); 60 | }); 61 | -------------------------------------------------------------------------------- /src/lib/clients/email-verifier.ts: -------------------------------------------------------------------------------- 1 | import { APIClient } from "@edgefirst-dev/api-client"; 2 | import { ObjectParser } from "@edgefirst-dev/data/parser"; 3 | 4 | import { env } from "../storage/accessors.js"; 5 | import type { Email } from "../values/email.js"; 6 | 7 | export class EmailVerifier extends APIClient { 8 | constructor() { 9 | super(new URL("https://verifyright.co")); 10 | } 11 | 12 | override async before(request: Request) { 13 | let url = new URL(request.url); 14 | let apiKey = env().fetch("VERIFIER_API_KEY"); 15 | if (apiKey) url.searchParams.append("token", apiKey); 16 | return new Request(url.toString(), request); 17 | } 18 | 19 | public async verify(value: Email) { 20 | let response = await this.get(`/verify/${value.toString()}`); 21 | 22 | let result = await response.json(); 23 | 24 | let parser = new ObjectParser(result); 25 | 26 | if (parser.boolean("status")) return; 27 | let error = parser.object("error"); 28 | 29 | throw new EmailVerifier.InvalidEmailError( 30 | error.number("code"), 31 | error.string("message"), 32 | ); 33 | } 34 | } 35 | 36 | export namespace EmailVerifier { 37 | /** 38 | * The `InvalidEmailError` is thrown when an email fails verification by the 39 | * external API. 40 | */ 41 | export class InvalidEmailError extends Error { 42 | override name = "InvalidEmailError"; 43 | 44 | /** 45 | * Constructs an `InvalidEmailError` with a specific error code and message. 46 | * 47 | * @param code - The error code returned by the API. 48 | * @param message - The error message returned by the API. 49 | */ 50 | constructor( 51 | public code: number, 52 | message: string, 53 | ) { 54 | super(message); 55 | } 56 | } 57 | } 58 | -------------------------------------------------------------------------------- /src/lib/clients/pwned-passwords.ts: -------------------------------------------------------------------------------- 1 | import { APIClient } from "@edgefirst-dev/api-client"; 2 | import { sha1 } from "@oslojs/crypto/sha1"; 3 | import { encodeHexLowerCase } from "@oslojs/encoding"; 4 | 5 | export class PwnedPasswords extends APIClient { 6 | constructor() { 7 | super(new URL("https://api.pwnedpasswords.com")); 8 | } 9 | 10 | protected override async after( 11 | _: Request, 12 | response: Response, 13 | ): Promise { 14 | if (response.ok) return response; 15 | throw new Error(`PwnedPasswords API failed: ${response.statusText}`); 16 | } 17 | 18 | async isPwned(hash: string) { 19 | let hashPrefix = hash.slice(0, 5); 20 | 21 | let response = await this.get(`/range/${hashPrefix}`); 22 | 23 | let data = await response.text(); 24 | let items = data.split("\n"); 25 | 26 | for (let item of items) { 27 | let hashSuffix = item.slice(0, 35).toLowerCase(); 28 | if (hash === hashPrefix + hashSuffix) return true; 29 | } 30 | 31 | return false; 32 | } 33 | 34 | static hash(value: string) { 35 | return encodeHexLowerCase(sha1(new TextEncoder().encode(value))); 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /src/lib/entities/entity.test.ts: -------------------------------------------------------------------------------- 1 | import { describe, expect, test } from "bun:test"; 2 | 3 | import { CUID } from "../parsers/string-parser"; 4 | import { TableEntity } from "./entity"; 5 | 6 | describe(TableEntity.name, () => { 7 | let data = { 8 | id: "a3j3p00nmf5fnhggm9zqc6l8" as CUID, 9 | createdAt: new Date(), 10 | updatedAt: new Date(), 11 | name: "John Doe", 12 | }; 13 | 14 | class TestModel extends TableEntity { 15 | get name() { 16 | return this.parser.string("name"); 17 | } 18 | } 19 | 20 | test(".from()", () => { 21 | let model = TestModel.from(data); 22 | expect(model).toBeInstanceOf(TestModel); 23 | }); 24 | 25 | test("#id", () => { 26 | let model = TestModel.from(data); 27 | expect(model.id).toBe(data.id); 28 | }); 29 | 30 | test("#createdAt", () => { 31 | let model = TestModel.from(data); 32 | expect(model.createdAt).toEqual(data.createdAt); 33 | }); 34 | 35 | test("#updatedAt", () => { 36 | let model = TestModel.from(data); 37 | expect(model.updatedAt).toEqual(data.updatedAt); 38 | }); 39 | 40 | test("#toString()", () => { 41 | let model = TestModel.from(data); 42 | expect(model.toString()).toBe(`test-model:${data.id}`); 43 | }); 44 | }); 45 | -------------------------------------------------------------------------------- /src/lib/entities/entity.ts: -------------------------------------------------------------------------------- 1 | import { Data } from "@edgefirst-dev/data"; 2 | import { ObjectParser } from "@edgefirst-dev/data/parser"; 3 | import type { Table } from "drizzle-orm"; 4 | import { dasherize, underscore } from "inflected"; 5 | import { StringParser } from "../parsers/string-parser.js"; 6 | 7 | /** 8 | * An entity represents a single object in the domain model. 9 | * 10 | * Most entities are backed by a database table, but some entities may be 11 | * transient and not persisted to the database, or may be backed by a different 12 | * kind of data store. 13 | */ 14 | export abstract class Entity extends Data { 15 | override toString() { 16 | return `${dasherize(underscore(this.constructor.name))}`; 17 | } 18 | } 19 | 20 | /** 21 | * A table entity represents a single row in a database table. 22 | * 23 | * Table entities are backed by a database table, and are typically used to 24 | * represent domain objects that are persisted to the database. 25 | */ 26 | export abstract class TableEntity extends Entity { 27 | static from( 28 | this: new ( 29 | parser: ObjectParser, 30 | ) => M, 31 | data: T["$inferSelect"], 32 | ) { 33 | // biome-ignore lint/complexity/noThisInStatic: It's ok 34 | return new this(new ObjectParser(data)); 35 | } 36 | 37 | static fromMany( 38 | this: new ( 39 | parser: ObjectParser, 40 | ) => M, 41 | data: T["$inferSelect"][], 42 | ) { 43 | // biome-ignore lint/complexity/noThisInStatic: It's ok 44 | return data.map((datum) => new this(new ObjectParser(datum))); 45 | } 46 | 47 | get id() { 48 | return new StringParser(this.parser.string("id")).cuid(); 49 | } 50 | 51 | get createdAt() { 52 | return this.parser.date("createdAt"); 53 | } 54 | 55 | get updatedAt() { 56 | return this.parser.date("updatedAt"); 57 | } 58 | 59 | override toString() { 60 | return `${dasherize(underscore(this.constructor.name))}:${this.id}`; 61 | } 62 | } 63 | -------------------------------------------------------------------------------- /src/lib/env/env.test.ts: -------------------------------------------------------------------------------- 1 | import { describe, expect, test } from "bun:test"; 2 | 3 | import { EdgeEnvKeyError } from "../errors.js"; 4 | import { Environment } from "../types.js"; 5 | import { Env } from "./env.js"; 6 | 7 | describe(Env.name, () => { 8 | let environment = { KEY: "value" } as unknown as Environment; 9 | 10 | test("#constructor", () => { 11 | let env = new Env(environment); 12 | expect(env).toBeInstanceOf(Env); 13 | }); 14 | 15 | test("#constructor with missing environment", () => { 16 | // @ts-expect-error - Testing invalid input 17 | expect(() => new Env(undefined)).toThrow(); 18 | }); 19 | 20 | test("#fetch", () => { 21 | let env = new Env(environment); 22 | expect(env.fetch("KEY")).toBe("value"); 23 | }); 24 | 25 | test("#fetch with fallback", () => { 26 | let env = new Env(environment); 27 | expect(env.fetch("OPTIONAL", "fallback")).toBe("fallback"); 28 | }); 29 | 30 | test("#fetch with missing", () => { 31 | let env = new Env(environment); 32 | expect(() => env.fetch("OPTIONAL")).toThrow(EdgeEnvKeyError); 33 | }); 34 | }); 35 | 36 | // Overwrite Environment on this file 37 | declare module "../types.js" { 38 | interface Environment { 39 | KEY: string; 40 | OPTIONAL?: string; 41 | } 42 | } 43 | -------------------------------------------------------------------------------- /src/lib/env/env.ts: -------------------------------------------------------------------------------- 1 | import { EdgeContextError, EdgeEnvKeyError } from "../errors.js"; 2 | import type { Environment } from "../types.js"; 3 | 4 | /** 5 | * Access environment variables in your Edge-first application. 6 | */ 7 | export class Env { 8 | constructor(protected env: Environment) { 9 | if (!env) throw new EdgeContextError("env().fetch"); 10 | } 11 | 12 | /** 13 | * Retrieve a value from the environment variables. 14 | * If the key is not found, an error is thrown. 15 | * An optional fallback value can be provided. 16 | * @param key The key to fetch from the environment variables. 17 | * @param fallback An optional fallback value to return if the key is not found. 18 | * @returns 19 | */ 20 | fetch( 21 | key: Key, 22 | fallback?: Environment[Key], 23 | ): Environment[Key] { 24 | let data = this.env[key]; 25 | if (data) return data; 26 | 27 | if (fallback) return fallback; 28 | throw new EdgeEnvKeyError(key); 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /src/lib/errors.ts: -------------------------------------------------------------------------------- 1 | export class EdgeError extends Error { 2 | override name = "EdgeError"; 3 | } 4 | 5 | export class EdgeContextError extends EdgeError { 6 | override name = "EdgeContextError"; 7 | 8 | constructor(method: string) { 9 | super(`You must run "${method}()" from inside an Edge-first context.`); 10 | } 11 | } 12 | 13 | export class EdgeConfigError extends EdgeError { 14 | override name = "EdgeConfigError"; 15 | 16 | constructor(key: string) { 17 | super(`Configure ${key} in your wrangler.toml file.`); 18 | } 19 | } 20 | 21 | export class EdgeBootstrapConfigError extends EdgeError { 22 | override name = "EdgeBootstrapConfigError"; 23 | 24 | constructor(key: string) { 25 | super(`Configure ${key} in your bootstrap options.`); 26 | } 27 | } 28 | 29 | export class EdgeEnvKeyError extends EdgeError { 30 | override name = "EdgeEnvKeyError"; 31 | 32 | constructor(key: string) { 33 | super(`Key not found: ${key}`); 34 | } 35 | } 36 | 37 | export class EdgeRequestGeoError extends EdgeError { 38 | override name = "EdgeRequestGeoError"; 39 | override message = 40 | "The request object does not contain the 'cf' property required to access the geolocation information."; 41 | } 42 | -------------------------------------------------------------------------------- /src/lib/fs/fs.test.ts: -------------------------------------------------------------------------------- 1 | import { describe, expect, mock, test } from "bun:test"; 2 | 3 | import { FileUpload } from "@mjackson/form-data-parser"; 4 | import { MockR2Bucket } from "../../mocks/cf.js"; 5 | import { FS } from "./fs.js"; 6 | 7 | describe(FS.name, () => { 8 | test("#constructor", () => { 9 | let r2 = new MockR2Bucket(); 10 | 11 | let fs = new FS(r2); 12 | 13 | expect(fs).toBeInstanceOf(FS); 14 | }); 15 | 16 | test("#binding", () => { 17 | let r2 = new MockR2Bucket(); 18 | let fs = new FS(r2); 19 | 20 | expect(fs.binding).toEqual(r2); 21 | }); 22 | 23 | test("#keys", async () => { 24 | let r2 = new MockR2Bucket([ 25 | // @ts-expect-error - missing metadata 26 | ["test:1", { key: "test:1" }], 27 | // @ts-expect-error - missing metadata 28 | ["2", { key: "2" }], 29 | ]); 30 | 31 | let fs = new FS(r2); 32 | 33 | let result = await fs.keys({}); 34 | 35 | expect(r2.list).toHaveBeenCalledTimes(1); 36 | expect(result).toEqual({ 37 | keys: ["test:1", "2"], 38 | done: true, 39 | cursor: null, 40 | }); 41 | }); 42 | 43 | test("#keys with prefix", async () => { 44 | // @ts-expect-error - missing metadata 45 | let r2 = new MockR2Bucket([["test:1", { key: "test:1" }]]); 46 | 47 | let fs = new FS(r2); 48 | 49 | let result = await fs.keys({ prefix: "test" }); 50 | 51 | expect(r2.list).toHaveBeenCalledTimes(1); 52 | expect(result).toEqual({ 53 | keys: ["test:1"], 54 | done: true, 55 | cursor: null, 56 | }); 57 | }); 58 | 59 | test("#keys with limit", async () => { 60 | let r2 = new MockR2Bucket([ 61 | // @ts-expect-error - missing metadata 62 | ["test:1", { key: "test:1" }], 63 | // @ts-expect-error - missing metadata 64 | ["2", { key: "2" }], 65 | ]); 66 | 67 | let fs = new FS(r2); 68 | 69 | let result = await fs.keys({ limit: 1 }); 70 | 71 | expect(r2.list).toHaveBeenCalledTimes(1); 72 | expect(result).toEqual({ 73 | keys: ["test:1"], 74 | done: false, 75 | cursor: "1", 76 | }); 77 | }); 78 | 79 | test("#keys with cursor", async () => { 80 | let r2 = new MockR2Bucket([ 81 | // @ts-expect-error - missing metadata 82 | ["test:1", { key: "test:1" }], 83 | // @ts-expect-error - missing metadata 84 | ["2", { key: "2" }], 85 | ]); 86 | 87 | let fs = new FS(r2); 88 | 89 | let result = await fs.keys({ limit: 1, cursor: "1" }); 90 | 91 | expect(r2.list).toHaveBeenCalledTimes(1); 92 | expect(result).toEqual({ 93 | keys: ["2"], 94 | done: true, 95 | cursor: null, 96 | }); 97 | }); 98 | 99 | test("#serve", async () => { 100 | let arrayBuffer = new ArrayBuffer(8); 101 | let r2 = new MockR2Bucket([ 102 | [ 103 | "test:1", 104 | // @ts-expect-error - missing metadata 105 | { key: "test:1", arrayBuffer: () => Promise.resolve(arrayBuffer) }, 106 | ], 107 | ]); 108 | 109 | let fs = new FS(r2); 110 | 111 | let response = await fs.serve("test:1"); 112 | 113 | expect(r2.get).toHaveBeenCalledTimes(1); 114 | expect(response.arrayBuffer()).resolves.toEqual(arrayBuffer); 115 | }); 116 | 117 | test("#serve with custom headers", async () => { 118 | let arrayBuffer = new ArrayBuffer(8); 119 | 120 | let r2 = new MockR2Bucket([ 121 | [ 122 | "test:1", 123 | // @ts-expect-error - missing metadata 124 | { key: "test:1", arrayBuffer: () => Promise.resolve(arrayBuffer) }, 125 | ], 126 | ]); 127 | 128 | let fs = new FS(r2); 129 | 130 | let response = await fs.serve("test:1", { 131 | headers: { 132 | "content-type": "text/plain", 133 | "content-length": "8", 134 | }, 135 | }); 136 | 137 | expect(r2.get).toHaveBeenCalledTimes(1); 138 | expect(response.headers.get("content-type")).toBe("text/plain"); 139 | expect(response.headers.get("content-length")).toBe("8"); 140 | }); 141 | 142 | test("#serve (not found)", async () => { 143 | let r2 = new MockR2Bucket(); 144 | 145 | let fs = new FS(r2); 146 | 147 | let response = await fs.serve("test:1"); 148 | 149 | expect(r2.get).toHaveBeenCalledTimes(1); 150 | expect(response.status).toBe(404); 151 | }); 152 | 153 | test("#serve with fallback", async () => { 154 | let r2 = new MockR2Bucket(); 155 | 156 | let fs = new FS(r2); 157 | 158 | let response = await fs.serve("test:1", { fallback: "fallback" }); 159 | 160 | expect(r2.get).toHaveBeenCalledTimes(1); 161 | expect(response.status).toBe(404); 162 | expect(response.text()).resolves.toBe("fallback"); 163 | }); 164 | 165 | test("#serve with HTTP metadata", async () => { 166 | let arrayBuffer = new ArrayBuffer(8); 167 | let r2 = new MockR2Bucket([ 168 | [ 169 | "test:1", 170 | // @ts-expect-error - missing metadata 171 | { 172 | key: "test:1", 173 | arrayBuffer: () => Promise.resolve(arrayBuffer), 174 | writeHttpMetadata(headers: Headers) { 175 | headers.set("content-type", "text/plain"); 176 | headers.set("content-length", "8"); 177 | }, 178 | }, 179 | ], 180 | ]); 181 | 182 | let fs = new FS(r2); 183 | 184 | let response = await fs.serve("test:1"); 185 | 186 | expect(r2.get).toHaveBeenCalledTimes(1); 187 | expect(response.headers.get("content-type")).toBe("text/plain"); 188 | expect(response.headers.get("content-length")).toBe("8"); 189 | }); 190 | 191 | test("#serve with HTTP metadata (error)", async () => { 192 | let arrayBuffer = new ArrayBuffer(8); 193 | let r2 = new MockR2Bucket([ 194 | [ 195 | "test:1", 196 | // @ts-expect-error - missing metadata 197 | { 198 | key: "test:1", 199 | arrayBuffer: () => Promise.resolve(arrayBuffer), 200 | writeHttpMetadata(headers: Headers) { 201 | throw new Error("Failed to write metadata"); 202 | }, 203 | }, 204 | ], 205 | ]); 206 | 207 | let fs = new FS(r2); 208 | 209 | let response = await fs.serve("test:1"); 210 | 211 | expect(r2.get).toHaveBeenCalledTimes(1); 212 | expect(response.headers.get("content-type")).not.toBe("text/plain"); 213 | expect(response.headers.get("content-length")).not.toBe("8"); 214 | }); 215 | 216 | test("#uploadHandler", async () => { 217 | let r2 = new MockR2Bucket(); 218 | let fs = new FS(r2); 219 | 220 | let file = new File(["test"], "test.txt", { type: "text/plain" }); 221 | 222 | let fileUpload = { 223 | fieldName: "file", 224 | name: "test.txt", 225 | type: "text/plain", 226 | arrayBuffer: () => file.arrayBuffer(), 227 | } as FileUpload; 228 | 229 | let uploadHandler = fs.uploadHandler(["file"]); 230 | 231 | expect(uploadHandler(fileUpload)).resolves.toEqual(file); 232 | }); 233 | 234 | test("#uploadHandler with non-allowed field name", async () => { 235 | let r2 = new MockR2Bucket(); 236 | 237 | let fs = new FS(r2); 238 | 239 | let file = new File(["test"], "test.txt", { type: "text/plain" }); 240 | 241 | let fileUpload = { 242 | fieldName: "file", 243 | name: "test.txt", 244 | type: "text/plain", 245 | arrayBuffer: () => file.arrayBuffer(), 246 | } as FileUpload; 247 | 248 | let uploadHandler = fs.uploadHandler(["other"]); 249 | 250 | expect(uploadHandler(fileUpload)).resolves.toBeUndefined(); 251 | }); 252 | 253 | test("#uploadHandler with missing fieldName", async () => { 254 | let r2 = new MockR2Bucket(); 255 | 256 | let fs = new FS(r2); 257 | 258 | let file = new File(["test"], "test.txt", { type: "text/plain" }); 259 | 260 | let fileUpload = { 261 | name: "test.txt", 262 | type: "text/plain", 263 | arrayBuffer: () => file.arrayBuffer(), 264 | } as FileUpload; 265 | 266 | let uploadHandler = fs.uploadHandler(["file"]); 267 | 268 | expect(uploadHandler(fileUpload)).resolves.toBeUndefined(); 269 | }); 270 | 271 | test("#uploadHandler with getKey", async () => { 272 | let r2 = new MockR2Bucket(); 273 | 274 | let fs = new FS(r2); 275 | 276 | let file = new File(["test"], "test.txt", { type: "text/plain" }); 277 | 278 | let fileUpload = { 279 | fieldName: "file", 280 | name: "test.txt", 281 | type: "text/plain", 282 | arrayBuffer: () => file.arrayBuffer(), 283 | } as FileUpload; 284 | 285 | let getKeyFn = mock().mockImplementation(() => "key"); 286 | 287 | let uploadHandler = fs.uploadHandler(["file"], getKeyFn); 288 | 289 | expect(uploadHandler(fileUpload)).resolves.toEqual(file); 290 | expect(getKeyFn).toHaveBeenCalledTimes(1); 291 | expect(getKeyFn).toHaveBeenCalledWith("test.txt"); 292 | }); 293 | }); 294 | -------------------------------------------------------------------------------- /src/lib/fs/fs.ts: -------------------------------------------------------------------------------- 1 | import { R2FileStorage } from "@edgefirst-dev/r2-file-storage"; 2 | import type { FileUploadHandler } from "@mjackson/form-data-parser"; 3 | 4 | export namespace FS { 5 | export namespace Keys { 6 | export type Options = { 7 | /** 8 | * The prefix to match keys against. Keys will only be returned if they 9 | * start with given prefix 10 | */ 11 | prefix?: string; 12 | /** 13 | * The number of results to return. Maximum of `1000`. 14 | * @default 1000 15 | */ 16 | limit?: number; 17 | /** 18 | * An opaque token that indicates where to continue listing objects from. 19 | * A cursor can be retrieved from a previous list operation. 20 | */ 21 | cursor?: string; 22 | /** 23 | * The character to use when grouping keys. 24 | * @default "/" 25 | */ 26 | delimiter?: string; 27 | }; 28 | 29 | export type Result = 30 | | { keys: string[]; done: false; cursor: string } 31 | | { keys: string[]; done: true; cursor: null }; 32 | } 33 | 34 | export namespace Server { 35 | export type Init = ResponseInit & { 36 | /** 37 | * The body to use if the file doesn't exist. 38 | */ 39 | fallback?: BodyInit | null; 40 | }; 41 | } 42 | 43 | export namespace UploadHandler { 44 | export type AllowedFieldNames = string[]; 45 | export type GetKeyFunction = (name: string) => string; 46 | } 47 | } 48 | 49 | /** 50 | * Upload, store and serve images, videos, music, documents and other 51 | * unstructured data in your Edge-first application. 52 | */ 53 | export class FS extends R2FileStorage { 54 | get binding(): R2Bucket { 55 | return this.r2; 56 | } 57 | 58 | /** 59 | * Returns a list of all keys in storage. 60 | */ 61 | async keys(options: FS.Keys.Options = {}): Promise { 62 | let result = await this.r2.list(options); 63 | let keys = result.objects.map((object) => object.key); 64 | if (result.truncated) return { keys, done: false, cursor: result.cursor }; 65 | return { keys, done: true, cursor: null }; 66 | } 67 | 68 | /** 69 | * Returns a Response with the file body and correct headers. 70 | * If the file doesn't exits it returns a 404 response with an empty body. 71 | * @param key The key of the file to serve 72 | * @param init The response init object, with an optional fallback body 73 | */ 74 | async serve( 75 | key: string, 76 | { fallback, ...init }: FS.Server.Init = {}, 77 | ): Promise { 78 | let object = await this.r2.get(key); 79 | 80 | if (!object) { 81 | return new Response(fallback ?? null, { ...init, status: 404 }); 82 | } 83 | 84 | let headers = new Headers(init?.headers); 85 | 86 | // This may throw, we don't want to break the response 87 | try { 88 | object.writeHttpMetadata(headers); 89 | // biome-ignore lint/suspicious/noEmptyBlockStatements: We don't need to do anything here 90 | } catch {} 91 | 92 | return new Response(await object.arrayBuffer(), { headers }); 93 | } 94 | 95 | /** 96 | * Create a new FileUploadHandler function that will automatically upload 97 | * files to the File Storage. 98 | * 99 | * The handle will only upload files if they match a list of valid input 100 | * field name. The key used to store the file can be customized with a 101 | * `getKey` function. 102 | * @param allowedFieldNames The form field names allowed to upload files. 103 | * @param getKey A function that returns the key usd to store the file in the file system. 104 | * @returns A file upload handler function that can be used with the parseFormData. 105 | */ 106 | uploadHandler( 107 | allowedFieldNames: FS.UploadHandler.AllowedFieldNames, 108 | getKey?: FS.UploadHandler.GetKeyFunction, 109 | ): FileUploadHandler { 110 | return async (fileUpload) => { 111 | if (!fileUpload.fieldName) return; 112 | if (!allowedFieldNames.includes(fileUpload.fieldName)) return; 113 | 114 | let key = getKey ? getKey(fileUpload.name) : crypto.randomUUID(); 115 | 116 | let file = new File([await fileUpload.arrayBuffer()], key, { 117 | type: fileUpload.type, 118 | }); 119 | 120 | await this.set(key, file); 121 | 122 | return file; 123 | }; 124 | } 125 | } 126 | -------------------------------------------------------------------------------- /src/lib/geo/geo.test.ts: -------------------------------------------------------------------------------- 1 | import { describe, expect, test } from "bun:test"; 2 | import type { Request as CfRequest } from "@cloudflare/workers-types"; 3 | 4 | import { EdgeRequestGeoError } from "../errors.js"; 5 | import { Geo } from "./geo.js"; 6 | 7 | describe(Geo.name, () => { 8 | let request = new Request("https://example.com") as unknown as CfRequest; 9 | 10 | // @ts-expect-error - cf is not part of the RequestInit type 11 | request.cf = { 12 | country: "US", 13 | region: "CA", 14 | city: "San Francisco", 15 | postalCode: "94107", 16 | latitude: "37.7697", 17 | longitude: "-122.3933", 18 | timezone: "America/Los_Angeles", 19 | metroCode: "807", 20 | continent: "NA", 21 | isEUCountry: "1", 22 | }; 23 | 24 | test("#constructor", () => { 25 | let geo = new Geo(request); 26 | expect(geo).toBeInstanceOf(Geo); 27 | }); 28 | 29 | test("#constructor throws", () => { 30 | let request = new Request("https://example.com") as unknown as CfRequest; 31 | expect(() => new Geo(request)).toThrow(EdgeRequestGeoError); 32 | }); 33 | 34 | test("#country", () => { 35 | let geo = new Geo(request); 36 | expect(geo.country).toBe("US"); 37 | }); 38 | 39 | test("#region", () => { 40 | let geo = new Geo(request); 41 | expect(geo.region).toBe("CA"); 42 | }); 43 | 44 | test("#city", () => { 45 | let geo = new Geo(request); 46 | expect(geo.city).toBe("San Francisco"); 47 | }); 48 | 49 | test("#postalCode", () => { 50 | let geo = new Geo(request); 51 | expect(geo.postalCode).toBe("94107"); 52 | }); 53 | 54 | test("#latitude", () => { 55 | let geo = new Geo(request); 56 | expect(geo.latitude).toBe("37.7697"); 57 | }); 58 | 59 | test("#longitude", () => { 60 | let geo = new Geo(request); 61 | expect(geo.longitude).toBe("-122.3933"); 62 | }); 63 | 64 | test("#timezone", () => { 65 | let geo = new Geo(request); 66 | expect(geo.timezone).toBe("America/Los_Angeles"); 67 | }); 68 | 69 | test("#metroCode", () => { 70 | let geo = new Geo(request); 71 | expect(geo.metroCode).toBe("807"); 72 | }); 73 | 74 | test("#continent", () => { 75 | let geo = new Geo(request); 76 | expect(geo.continent).toBe("NA"); 77 | }); 78 | 79 | test("#isEurope", () => { 80 | let geo = new Geo(request); 81 | expect(geo.isEurope).toBe(true); 82 | }); 83 | 84 | test("#toJSON", () => { 85 | let geo = new Geo(request); 86 | 87 | expect(geo.toJSON()).toEqual({ 88 | country: "US", 89 | region: "CA", 90 | city: "San Francisco", 91 | postalCode: "94107", 92 | latitude: "37.7697", 93 | longitude: "-122.3933", 94 | timezone: "America/Los_Angeles", 95 | metroCode: "807", 96 | continent: "NA", 97 | isEurope: true, 98 | }); 99 | }); 100 | 101 | test("#toString", () => { 102 | let geo = new Geo(request); 103 | 104 | expect(geo.toString()).toBe( 105 | JSON.stringify({ 106 | country: "US", 107 | region: "CA", 108 | city: "San Francisco", 109 | postalCode: "94107", 110 | latitude: "37.7697", 111 | longitude: "-122.3933", 112 | timezone: "America/Los_Angeles", 113 | metroCode: "807", 114 | continent: "NA", 115 | isEurope: true, 116 | }), 117 | ); 118 | }); 119 | }); 120 | -------------------------------------------------------------------------------- /src/lib/geo/geo.ts: -------------------------------------------------------------------------------- 1 | import type { 2 | ContinentCode, 3 | Iso3166Alpha2Code, 4 | Request, 5 | } from "@cloudflare/workers-types"; 6 | import { Data } from "@edgefirst-dev/data"; 7 | import { ObjectParser } from "@edgefirst-dev/data/parser"; 8 | import { EdgeRequestGeoError } from "../errors.js"; 9 | 10 | /** 11 | * Entity that represents the geographical information of the client's request. 12 | * This information is provided by Cloudflare Workers and is accessible through 13 | * the `request.cf` object. 14 | * 15 | * @example 16 | * import { geo } from "@edgefirst/core"; 17 | * geo().timezone; // "America/New_York" 18 | */ 19 | export class Geo extends Data { 20 | constructor(protected request: Request) { 21 | if (!request.cf) throw new EdgeRequestGeoError(); 22 | super(new ObjectParser(request.cf)); 23 | } 24 | 25 | get country() { 26 | return this.parser.string("country") as Iso3166Alpha2Code | "T1"; 27 | } 28 | 29 | get region() { 30 | return this.parser.string("region"); 31 | } 32 | 33 | get city() { 34 | return this.parser.string("city"); 35 | } 36 | 37 | get postalCode() { 38 | return this.parser.string("postalCode"); 39 | } 40 | 41 | get latitude() { 42 | return this.parser.string("latitude"); 43 | } 44 | 45 | get longitude() { 46 | return this.parser.string("longitude"); 47 | } 48 | 49 | get timezone() { 50 | return this.parser.string("timezone"); 51 | } 52 | 53 | get metroCode() { 54 | return this.parser.string("metroCode"); 55 | } 56 | 57 | get continent() { 58 | return this.parser.string("continent") as ContinentCode; 59 | } 60 | 61 | get isEurope() { 62 | return this.parser.string("isEUCountry") === "1"; 63 | } 64 | 65 | override toJSON() { 66 | return { 67 | country: this.country, 68 | region: this.region, 69 | city: this.city, 70 | postalCode: this.postalCode, 71 | latitude: this.latitude, 72 | longitude: this.longitude, 73 | timezone: this.timezone, 74 | metroCode: this.metroCode, 75 | continent: this.continent, 76 | isEurope: this.isEurope, 77 | }; 78 | } 79 | 80 | override toString() { 81 | return JSON.stringify(this.toJSON()); 82 | } 83 | } 84 | -------------------------------------------------------------------------------- /src/lib/jobs/job.ts: -------------------------------------------------------------------------------- 1 | import type { Data } from "@edgefirst-dev/data"; 2 | import type { ObjectParser } from "@edgefirst-dev/data/parser"; 3 | import type { JsonObject } from "type-fest"; 4 | import { queue } from "../storage/accessors.js"; 5 | 6 | /** 7 | * The `Job` class provides a structure for defining and processing background jobs with automatic validation. 8 | * 9 | * Each subclass must define a `data` class, which extends 10 | * `Data`, to represent the input structure. 11 | * 12 | * The `Job` class will automatically instantiate the `data` class during 13 | * validation using the provided `ObjectParser`. 14 | * 15 | * Subclasses only need to define the `data` attribute and implement the 16 | * `perform` method to process the job. 17 | * 18 | * @template Input - The type of data the job will process, which must extend `Data`. 19 | * 20 | * @example 21 | * class MyData extends Data { 22 | * get userId(): number { 23 | * return this.parser.getNumber("userId"); 24 | * } 25 | * } 26 | * 27 | * class MyJob extends Job { 28 | * protected readonly data = MyData; 29 | * 30 | * async perform(input: MyData): Promise { 31 | * console.log(`Processing job for user ID: ${input.userId}`); 32 | * } 33 | * } 34 | * 35 | * // Enqueue a job with the provided data. 36 | * MyJob.enqueue({ userId: 123 }); 37 | */ 38 | export abstract class Job { 39 | /** 40 | * The `Data` class for this job, which is used for validation. Must be 41 | * defined by subclasses. 42 | */ 43 | protected abstract readonly data: new ( 44 | parser: ObjectParser, 45 | ) => Input; 46 | 47 | /** 48 | * Validates the incoming data using the `data` class defined in the subclass. 49 | * 50 | * This method automatically creates an instance of the `data` class using the provided `ObjectParser`. 51 | * 52 | * @param body - The `ObjectParser` containing the incoming data. 53 | * @returns A promise that resolves to the validated `Input` data. 54 | */ 55 | async validate(body: ObjectParser): Promise { 56 | return new this.data(body); 57 | } 58 | 59 | /** 60 | * Abstract method that defines the job's logic after the data has been 61 | * validated. 62 | * 63 | * Subclasses must implement this method to define the actions taken with the 64 | * validated input. 65 | * 66 | * @param input - The validated input data. 67 | * @returns A promise that resolves once the job processing is complete. 68 | */ 69 | abstract perform(input: Input): Promise; 70 | 71 | /** 72 | * Enqueues a job with the provided message, adding it to the job queue for 73 | * future processing. 74 | * 75 | * This static method allows jobs to be scheduled by adding the job name and 76 | * the message to the queue. 77 | * 78 | * @param message - An object containing the job data to be enqueued. 79 | * 80 | * @example 81 | * MyJob.enqueue({ userId: 123, action: 'process' }); 82 | */ 83 | static enqueue(this: new () => J, message: T) { 84 | // biome-ignore lint/complexity/noThisInStatic: We need it for better DX 85 | queue().enqueue({ job: this.name, ...message }); 86 | } 87 | } 88 | -------------------------------------------------------------------------------- /src/lib/jobs/manager.ts: -------------------------------------------------------------------------------- 1 | import type { Message } from "@cloudflare/workers-types"; 2 | import type { Data } from "@edgefirst-dev/data"; 3 | import { ObjectParser } from "@edgefirst-dev/data/parser"; 4 | import { defer } from "../storage/accessors.js"; 5 | import type { Job } from "./job.js"; 6 | 7 | /** 8 | * The `JobsManager` class is responsible for managing and processing jobs. 9 | * It registers job instances, processes batches of messages, and handles 10 | * individual messages by delegating them to the appropriate job. 11 | * 12 | * Each job is registered using the `register` method, and jobs are then 13 | * processed via `handle` or `processBatch`. 14 | * 15 | * @example 16 | * let manager = new JobsManager(); 17 | * manager.register(new MyJob()); 18 | * // The batch here comes from the CF Queue. 19 | * await manager.performBatch(batch, (error, message) => { 20 | * console.error(error) 21 | * message.retry(); 22 | * }); 23 | */ 24 | export class JobsManager { 25 | /** A map storing the registered jobs, keyed by their class name. */ 26 | #jobs = new Map>(); 27 | 28 | constructor(jobs: Job[]) { 29 | for (let job of jobs) this.register(job); 30 | } 31 | 32 | /** 33 | * Registers a job instance with the `JobsManager`, allowing it to process 34 | * messages for that job. 35 | * 36 | * The job is stored in the internal job map, keyed by the job's class name. 37 | * 38 | * @param job - The job instance to register. 39 | * 40 | * @example 41 | * manager.register(new MyJob()); 42 | */ 43 | private register>(job: T): void { 44 | this.#jobs.set(job.constructor.name, job); 45 | } 46 | 47 | /** 48 | * Processes a batch of messages, delegating each message to the appropriate 49 | * job based on the `job` field in the message body. 50 | * 51 | * If an error occurs during processing, the optional `onError` function is 52 | * called with the error and the message to help you debug and retry it. 53 | * 54 | * Every job processed in the batch is passed to waitUntil so that the batch 55 | * is processed in parallel and doesn't need to be awaited. 56 | * 57 | * @param batch - The batch of messages to process. 58 | * @param onError - An optional callback function to handle errors during processing. 59 | * 60 | * @example 61 | * manager.performBatch(batch, (error, message) => { 62 | * console.error(error); 63 | * message.retry(); 64 | * }); 65 | */ 66 | async processBatch( 67 | batch: MessageBatch, 68 | onError?: JobsManager.ErrorFunction, 69 | ): Promise { 70 | for (let message of batch.messages) { 71 | defer(this.process(message, onError)); 72 | } 73 | } 74 | 75 | /** 76 | * Process an individual message, delegating it to the appropriate job based 77 | * on the `job` field in the message body. 78 | * 79 | * The method validates the message body, finds the corresponding job, and 80 | * calls the `perform` method on the job. 81 | * 82 | * If the job is not registered or validation fails, an error is thrown. 83 | * 84 | * If an error occurs during processing, the optional `onError` function is 85 | * called with the error and the message to help you debug and retry it. 86 | * 87 | * @param message - The message to process. 88 | * @param onError - An optional callback function to handle errors during processing. 89 | * 90 | * @example 91 | * await manager.perform(message, (error, message) => { 92 | * console.error(error); 93 | * message.retry(); 94 | * }); 95 | */ 96 | async process( 97 | message: Message, 98 | onError?: JobsManager.ErrorFunction, 99 | ): Promise { 100 | try { 101 | let body = new ObjectParser(message.body); 102 | let jobName = body.string("job"); 103 | let job = this.#jobs.get(jobName); 104 | if (!job) throw new Error(`Job ${jobName} not registered`); 105 | let input = await job.validate(body); 106 | await job.perform(input); 107 | message.ack(); 108 | } catch (error) { 109 | if (onError) onError(error, message); 110 | } 111 | } 112 | } 113 | 114 | export namespace JobsManager { 115 | /** 116 | * A type defining the error handling function signature. 117 | * 118 | * This function is called when an error occurs during message processing. 119 | * 120 | * @param error - The error that occurred. 121 | * @param message - The message that was being processed when the error occurred. 122 | */ 123 | export type ErrorFunction = (error: unknown, message: Message) => void; 124 | } 125 | -------------------------------------------------------------------------------- /src/lib/kv/kv.test.ts: -------------------------------------------------------------------------------- 1 | import { describe, expect, test } from "bun:test"; 2 | 3 | import { MockKVNamespace } from "../../mocks/cf.js"; 4 | import { KV } from "./kv.js"; 5 | 6 | describe(KV.name, () => { 7 | test("#constructor", () => { 8 | let KVNamespace = new MockKVNamespace(); 9 | let kv = new KV(KVNamespace); 10 | expect(kv).toBeInstanceOf(KV); 11 | }); 12 | 13 | test("#binding", () => { 14 | let KVNamespace = new MockKVNamespace(); 15 | let kv = new KV(KVNamespace); 16 | expect(kv.binding).toBe(KVNamespace); 17 | }); 18 | 19 | test("#keys", async () => { 20 | let kvNamespace = new MockKVNamespace([ 21 | ["key:1", { value: "value" }], 22 | ["key:2", { value: "value2" }], 23 | ]); 24 | 25 | let kv = new KV(kvNamespace); 26 | 27 | expect(kv.keys()).resolves.toEqual({ 28 | keys: [ 29 | { name: "key:1", meta: undefined, ttl: undefined }, 30 | { name: "key:2", meta: undefined, ttl: undefined }, 31 | ], 32 | cursor: null, 33 | done: true, 34 | }); 35 | }); 36 | 37 | test("#keys with prefix", async () => { 38 | let kvNamespace = new MockKVNamespace([ 39 | ["prefix:1", { value: "value" }], 40 | ["key", { value: "value2" }], 41 | ]); 42 | 43 | let kv = new KV(kvNamespace); 44 | 45 | expect(kv.keys("prefix")).resolves.toEqual({ 46 | keys: [{ name: "prefix:1", meta: undefined, ttl: undefined }], 47 | cursor: null, 48 | done: true, 49 | }); 50 | }); 51 | 52 | test("#keys paginated", async () => { 53 | let kvNamespace = new MockKVNamespace([ 54 | ["key:1", { value: "value" }], 55 | ["key:2", { value: "value2" }], 56 | ]); 57 | 58 | let kv = new KV(kvNamespace); 59 | 60 | expect(kv.keys({ limit: 1 })).resolves.toEqual({ 61 | keys: [{ name: "key:1", meta: undefined, ttl: undefined }], 62 | done: false, 63 | cursor: "1", 64 | }); 65 | }); 66 | 67 | test("#keys with cursor", async () => { 68 | let kvNamespace = new MockKVNamespace([ 69 | ["key:1", { value: "value" }], 70 | ["key:2", { value: "value2" }], 71 | ]); 72 | 73 | let kv = new KV(kvNamespace); 74 | 75 | expect(kv.keys({ cursor: "1", limit: 1 })).resolves.toEqual({ 76 | keys: [{ name: "key:2", meta: undefined, ttl: undefined }], 77 | done: true, 78 | cursor: null, 79 | }); 80 | }); 81 | 82 | test("#get", async () => { 83 | let kvNamespace = new MockKVNamespace([["key", { value: "value" }]]); 84 | 85 | let kv = new KV(kvNamespace); 86 | 87 | expect(kv.get("key")).resolves.toEqual({ 88 | data: "value", 89 | meta: null, 90 | }); 91 | }); 92 | 93 | test("#get with metadata", async () => { 94 | let kvNamespace = new MockKVNamespace([ 95 | ["key", { value: "value", metadata: { meta: "data" } }], 96 | ]); 97 | 98 | let kv = new KV(kvNamespace); 99 | 100 | expect(kv.get("key")).resolves.toEqual({ 101 | data: "value", 102 | meta: { meta: "data" }, 103 | }); 104 | }); 105 | 106 | test("#set", async () => { 107 | let kvNamespace = new MockKVNamespace(); 108 | 109 | let kv = new KV(kvNamespace); 110 | 111 | await kv.set("key", "value"); 112 | 113 | expect(kvNamespace.put).toHaveBeenCalledTimes(1); 114 | expect(kvNamespace.put).toHaveBeenCalledWith( 115 | "key", 116 | JSON.stringify("value"), 117 | { expirationTtl: undefined, metadata: undefined }, 118 | ); 119 | }); 120 | 121 | test("#set with TTL and metadata", async () => { 122 | let kvNamespace = new MockKVNamespace(); 123 | 124 | let kv = new KV(kvNamespace); 125 | 126 | await kv.set("key", "value", { ttl: 100, metadata: { meta: "data" } }); 127 | 128 | expect(kvNamespace.put).toHaveBeenCalledTimes(1); 129 | expect(kvNamespace.put).toHaveBeenCalledWith( 130 | "key", 131 | JSON.stringify("value"), 132 | { expirationTtl: 100, metadata: { meta: "data" } }, 133 | ); 134 | }); 135 | 136 | test("#has (true)", async () => { 137 | let kvNamespace = new MockKVNamespace([["key", { value: "value" }]]); 138 | 139 | let kv = new KV(kvNamespace); 140 | 141 | expect(await kv.has("key")).toBeTrue(); 142 | expect(kvNamespace.get).toHaveBeenCalledTimes(1); 143 | }); 144 | 145 | test("#has (false)", async () => { 146 | let kvNamespace = new MockKVNamespace(); 147 | 148 | let kv = new KV(kvNamespace); 149 | 150 | expect(await kv.has("key")).toBeFalse(); 151 | expect(kvNamespace.get).toHaveBeenCalledTimes(1); 152 | }); 153 | 154 | test("#remove", async () => { 155 | let kvNamespace = new MockKVNamespace([["key", { value: "value" }]]); 156 | let kv = new KV(kvNamespace); 157 | 158 | await kv.remove("key"); 159 | 160 | expect(kvNamespace.delete).toHaveBeenCalledTimes(1); 161 | expect(kv.has("key")).resolves.toBeFalse(); 162 | }); 163 | }); 164 | -------------------------------------------------------------------------------- /src/lib/kv/kv.ts: -------------------------------------------------------------------------------- 1 | import type { KVNamespace } from "@cloudflare/workers-types"; 2 | import type { Jsonifiable } from "type-fest"; 3 | 4 | /** 5 | * Add a global, low-latency key-value data storage to your Edge-first 6 | * application. 7 | */ 8 | export class KV { 9 | constructor(protected kv: KVNamespace) {} 10 | 11 | get binding() { 12 | return this.kv; 13 | } 14 | 15 | /** 16 | * Retrieves all keys from the KV storage. 17 | * @param prefix The prefix to filter keys by. 18 | * @param options The options to use when fetching keys. 19 | */ 20 | async keys( 21 | prefix: KV.Keys.Prefix, 22 | options?: KV.Keys.Options, 23 | ): Promise>; 24 | async keys( 25 | options?: KV.Keys.Options, 26 | ): Promise>; 27 | async keys(): Promise>; 28 | async keys( 29 | prefixOrOptions?: KV.Keys.Prefix | KV.Keys.Options, 30 | maybeOptions: KV.Keys.Options = {}, 31 | ): Promise> { 32 | let prefix = typeof prefixOrOptions === "string" ? prefixOrOptions : ""; 33 | let options = 34 | typeof prefixOrOptions === "object" ? prefixOrOptions : maybeOptions; 35 | 36 | let data = await this.kv.list({ prefix, ...options }); 37 | let keys = data.keys.map((key) => { 38 | return { name: key.name, meta: key.metadata, ttl: key.expiration }; 39 | }); 40 | 41 | if (data.list_complete) { 42 | return { keys, cursor: null, done: true }; 43 | } 44 | 45 | return { keys, cursor: data.cursor, done: false }; 46 | } 47 | 48 | /** 49 | * Retrieves an item from the Key-Value storage. 50 | * @param key The key to retrieve. 51 | * @returns The value and metadata of the key. 52 | */ 53 | async get( 54 | key: KV.Get.Key, 55 | ): Promise> { 56 | let result = await this.kv.getWithMetadata(key, "json"); 57 | let meta = (result.metadata ?? null) as Meta | null; 58 | return { data: result.value ?? null, meta }; 59 | } 60 | 61 | /** 62 | * Puts an item in the storage. 63 | * @param key The key to set. 64 | * @param value The value to store, it must be serializable to JSON. 65 | * @param options The options to use when setting the key. 66 | */ 67 | set( 68 | key: KV.Set.Key, 69 | value: Value, 70 | options?: KV.Set.Options, 71 | ) { 72 | return this.kv.put(key, JSON.stringify(value), { 73 | expirationTtl: options?.ttl, 74 | metadata: options?.metadata, 75 | }); 76 | } 77 | 78 | /** 79 | * Checks if an item exists in the storage. 80 | * @param key The key to check. 81 | * @returns Whether the key exists or not. 82 | */ 83 | async has(key: KV.Has.Key): Promise { 84 | let result = await this.kv.get(key); 85 | return Boolean(result); 86 | } 87 | 88 | /** 89 | * Delete an item from the storage. 90 | * @param key The key to delete. 91 | */ 92 | remove(key: KV.Remove.Key) { 93 | return this.kv.delete(key); 94 | } 95 | } 96 | 97 | export namespace KV { 98 | export interface Key { 99 | /** The name of the key. */ 100 | name: string; 101 | /** The metadata stored along the key. */ 102 | meta: Meta | undefined; 103 | /** The time-to-live of the key. */ 104 | ttl?: number; 105 | } 106 | 107 | export namespace Keys { 108 | export type Prefix = string; 109 | 110 | export interface Options { 111 | /** The maximum number of keys to return. */ 112 | limit?: number; 113 | /** The cursor to use when fetching more pages of keys. */ 114 | cursor?: string; 115 | } 116 | 117 | export type Result = 118 | | { keys: Key[]; cursor: null; done: true } 119 | | { keys: Key[]; cursor: string; done: false }; 120 | } 121 | 122 | export namespace Get { 123 | export type Key = string; 124 | export type Value = Jsonifiable; 125 | export type Meta = Record; 126 | 127 | export interface Output { 128 | /** 129 | * The value stored along the key. 130 | * It will be null if the key was not found. 131 | */ 132 | data: T | null; 133 | /** 134 | * The metadata stored along the key. 135 | */ 136 | meta: M | null; 137 | } 138 | } 139 | 140 | export namespace Set { 141 | export type Key = string; 142 | export type Value = Jsonifiable; 143 | export type Meta = Record; 144 | 145 | export interface Options { 146 | /** 147 | * The time-to-live of the key. 148 | */ 149 | ttl?: number; 150 | /** 151 | * Extra metadata to store along the key. 152 | */ 153 | metadata?: T; 154 | } 155 | } 156 | 157 | export namespace Has { 158 | export type Key = string; 159 | export type Output = boolean; 160 | } 161 | 162 | export namespace Remove { 163 | export type Key = string; 164 | } 165 | } 166 | -------------------------------------------------------------------------------- /src/lib/parsers/number-parser.test.ts: -------------------------------------------------------------------------------- 1 | import { describe, expect, test } from "bun:test"; 2 | import { NumberParser } from "./number-parser"; 3 | 4 | describe(NumberParser.name, () => { 5 | describe("#currency", () => { 6 | test("with defaults", () => { 7 | let parser = new NumberParser(1234.56); 8 | expect(parser.currency("USD")).toBe("$1,234.56"); 9 | }); 10 | 11 | test("with options", () => { 12 | let parser = new NumberParser(1234.56); 13 | expect( 14 | parser.currency("USD", { 15 | minimumFractionDigits: 0, 16 | maximumFractionDigits: 0, 17 | }), 18 | ).toBe("$1,235"); 19 | }); 20 | 21 | test("with different currency", () => { 22 | let parser = new NumberParser(1234.56); 23 | expect(parser.currency("EUR")).toBe("€1,234.56"); 24 | }); 25 | 26 | test("with different locale", () => { 27 | let parser = new NumberParser(1234.56); 28 | parser.locales = "es"; 29 | expect(parser.currency("USD")).toBe("1234,56 US$"); 30 | }); 31 | }); 32 | 33 | describe("#format", () => { 34 | test("with defaults", () => { 35 | let parser = new NumberParser(1234.56); 36 | expect(parser.format()).toBe("1,234.56"); 37 | }); 38 | 39 | test("with options", () => { 40 | let parser = new NumberParser(1234.56); 41 | expect( 42 | parser.format({ minimumFractionDigits: 0, maximumFractionDigits: 0 }), 43 | ).toBe("1,235"); 44 | }); 45 | 46 | test("with different locale", () => { 47 | let parser = new NumberParser(1234.56); 48 | parser.locales = "de"; 49 | expect(parser.format()).toBe("1.234,56"); 50 | }); 51 | }); 52 | 53 | describe("#percent", () => { 54 | test("with defaults", () => { 55 | let parser = new NumberParser(12.34); 56 | expect(parser.percent()).toBe("12%"); 57 | }); 58 | 59 | test("with options", () => { 60 | let parser = new NumberParser(12.34); 61 | expect( 62 | parser.percent({ minimumFractionDigits: 0, maximumFractionDigits: 0 }), 63 | ).toBe("12%"); 64 | }); 65 | 66 | test("with different locale", () => { 67 | let parser = new NumberParser(12.34); 68 | parser.locales = "es"; 69 | expect(parser.percent()).toBe("12 %"); 70 | }); 71 | }); 72 | 73 | describe("#timestamp", () => { 74 | test("valid date", () => { 75 | let parser = new NumberParser(Date.now()); 76 | expect(parser.timestamp()).toBeInstanceOf(Date); 77 | }); 78 | 79 | test("invalid date", () => { 80 | let parser = new NumberParser(Number.NaN); 81 | expect(() => { 82 | parser.timestamp(); 83 | }).toThrow("Invalid date"); 84 | }); 85 | }); 86 | 87 | describe("#fileSize", () => { 88 | test("bytes", () => { 89 | let parser = new NumberParser(100); 90 | expect(parser.fileSize()).toBe("100B"); 91 | }); 92 | 93 | test("kilobytes", () => { 94 | let parser = new NumberParser(1024); 95 | expect(parser.fileSize()).toBe("1kB"); 96 | }); 97 | 98 | test("megabytes", () => { 99 | let parser = new NumberParser(1024 * 1024); 100 | expect(parser.fileSize()).toBe("1MB"); 101 | }); 102 | 103 | test("gigabytes", () => { 104 | let parser = new NumberParser(1024 * 1024 * 1024); 105 | expect(parser.fileSize()).toBe("1GB"); 106 | }); 107 | 108 | test("terabytes", () => { 109 | let parser = new NumberParser(1024 * 1024 * 1024 * 1024); 110 | expect(parser.fileSize()).toBe("1TB"); 111 | }); 112 | 113 | test("petabytes", () => { 114 | let parser = new NumberParser(1024 * 1024 * 1024 * 1024 * 1024); 115 | expect(parser.fileSize()).toBe("1PB"); 116 | }); 117 | }); 118 | }); 119 | -------------------------------------------------------------------------------- /src/lib/parsers/number-parser.ts: -------------------------------------------------------------------------------- 1 | import { Parser } from "@edgefirst-dev/data/parser"; 2 | 3 | export class NumberParser extends Parser { 4 | #locales = ["en"]; 5 | 6 | set locales(value: string | readonly string[]) { 7 | this.#locales = Intl.getCanonicalLocales(value); 8 | } 9 | 10 | currency( 11 | currency: string, 12 | options: Omit = {}, 13 | ) { 14 | if (!Intl.supportedValuesOf("currency").includes(currency)) { 15 | throw new Error(`Currency ${currency} is not supported`); 16 | } 17 | 18 | return this.value.toLocaleString(this.#locales, { 19 | currency, 20 | ...options, 21 | style: "currency", 22 | }); 23 | } 24 | 25 | format(options: Omit = {}) { 26 | return this.value.toLocaleString(this.#locales, { 27 | ...options, 28 | style: "decimal", 29 | }); 30 | } 31 | 32 | percent(options: Omit = {}) { 33 | return (this.value / 100).toLocaleString(this.#locales, { 34 | ...options, 35 | style: "percent", 36 | }); 37 | } 38 | 39 | timestamp() { 40 | let date = new Date(this.value); 41 | if (Number.isNaN(date.getTime())) throw new Error("Invalid date"); 42 | return date; 43 | } 44 | 45 | fileSize() { 46 | let units = [ 47 | "byte", 48 | "kilobyte", 49 | "megabyte", 50 | "gigabyte", 51 | "terabyte", 52 | "petabyte", 53 | "exabyte", 54 | "zettabyte", 55 | "yottabyte", 56 | ]; 57 | 58 | let index = 0; 59 | let size = this.value; 60 | while (size >= 1024 && index < units.length) { 61 | size /= 1024; 62 | index++; 63 | } 64 | 65 | return size.toLocaleString(this.#locales, { 66 | style: "unit", 67 | unit: units[index], 68 | unitDisplay: "narrow", 69 | }); 70 | } 71 | 72 | range(min: number, max: number) { 73 | if (this.value < min || this.value > max) { 74 | throw new Error(`Value must be between ${min} and ${max}`); 75 | } 76 | 77 | return this.value; 78 | } 79 | } 80 | -------------------------------------------------------------------------------- /src/lib/parsers/string-parser.ts: -------------------------------------------------------------------------------- 1 | import { Parser } from "@edgefirst-dev/data/parser"; 2 | import { isCuid } from "@paralleldrive/cuid2"; 3 | 4 | import { IPAddress } from "../values/ip-address.js"; 5 | import { UserAgent } from "../values/user-agent.js"; 6 | 7 | export type CUID = string & { __cuid: true }; 8 | 9 | export class StringParser extends Parser { 10 | enum(...values: Value[]): Value { 11 | if (values.includes(this.value as Value)) return this.value as Value; 12 | throw new Error( 13 | `Expected one of ${values.join(", ")}, but got ${this.value}`, 14 | ); 15 | } 16 | 17 | url(): URL { 18 | try { 19 | return new URL(this.value); 20 | } catch { 21 | throw new Error(`Expected a valid URL, but got ${this.value}`); 22 | } 23 | } 24 | 25 | datetime() { 26 | let date = new Date(this.value); 27 | if (date.toString() === "Invalid Date") { 28 | throw new Error(`Expected a valid date, but got ${this.value}`); 29 | } 30 | return date; 31 | } 32 | 33 | ip() { 34 | if (IPAddress.canParse(this.value)) return IPAddress.from(this.value); 35 | throw new Error(`Expected a valid IP address, but got ${this.value}`); 36 | } 37 | 38 | cuid() { 39 | if (isCuid(this.value)) return this.value as CUID; 40 | throw new Error(`Expected a valid CUID, but got ${this.value}`); 41 | } 42 | 43 | wordCount() { 44 | return this.value.split(/\s+/).length; 45 | } 46 | 47 | userAgent() { 48 | if (UserAgent.canParse(this.value)) return UserAgent.from(this.value); 49 | throw new Error(`Expected a valid user agent, but got ${this.value}`); 50 | } 51 | } 52 | -------------------------------------------------------------------------------- /src/lib/queue/queue.ts: -------------------------------------------------------------------------------- 1 | import type { Queue as WorkerQueue } from "@cloudflare/workers-types"; 2 | import type { Jsonifiable } from "type-fest"; 3 | 4 | export namespace Queue { 5 | export type ContentType = "text" | "bytes" | "json" | "v8"; 6 | 7 | export namespace Enqueue { 8 | export type Payload = Jsonifiable; 9 | 10 | export interface Options { 11 | contentType?: ContentType; 12 | delay?: number; 13 | } 14 | } 15 | } 16 | 17 | /** 18 | * Enqueue for processing later any kind of payload of data. 19 | */ 20 | export class Queue { 21 | constructor(protected queue: WorkerQueue) {} 22 | 23 | get binding() { 24 | return this.queue; 25 | } 26 | 27 | async enqueue( 28 | payload: Payload, 29 | options?: Queue.Enqueue.Options, 30 | ) { 31 | await this.queue.send(payload, options); 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /src/lib/storage/accessors.ts: -------------------------------------------------------------------------------- 1 | import cfPuppeteer, { type WorkersLaunchOptions } from "@cloudflare/puppeteer"; 2 | import { EdgeConfigError } from "../errors.js"; 3 | import { storage } from "./storage.js"; 4 | 5 | /** 6 | * Upload, store and serve images, videos, music, documents and other 7 | * unstructured data in your Edge-first application. 8 | */ 9 | export function fs() { 10 | return storage.access("fs"); 11 | } 12 | 13 | /** 14 | * The `cache` function gives you access to a cache object powered by 15 | * Cloudflare Worker KV. 16 | * 17 | * Every cached key will be prefixed by `cache:` to avoid conflicts with other 18 | * keys. 19 | * 20 | * This function is memoized so the next time you call it, it will return the 21 | * same instance of the cache object. 22 | */ 23 | export function cache() { 24 | return storage.access("cache"); 25 | } 26 | 27 | /** 28 | * Get a Drizzle ORM instance for your Edge-first application already connected 29 | * to your D1 database. 30 | */ 31 | export function orm() { 32 | return storage.access("orm"); 33 | } 34 | 35 | /** 36 | * The `env` function gives you access to the environment variables in a 37 | * type-safe way. 38 | */ 39 | export function env() { 40 | return storage.access("env"); 41 | } 42 | 43 | /** 44 | * Add a global, low-latency key-value data storage to your Edge-first 45 | * application. 46 | */ 47 | export function kv() { 48 | return storage.access("kv"); 49 | } 50 | 51 | /** 52 | * Access the request object in your Edge-first application. 53 | */ 54 | export function request() { 55 | return storage.access("request"); 56 | } 57 | 58 | /** 59 | * Access the AbortSignal associated with the request in your Edge-first 60 | * application. 61 | */ 62 | export function signal() { 63 | return storage.access("signal"); 64 | } 65 | 66 | /** 67 | * Access the headers of the request in your Edge-first application. 68 | */ 69 | export function headers() { 70 | return storage.access("headers"); 71 | } 72 | 73 | /** 74 | * Access the geolocation information of the request in your Edge-first 75 | * application. 76 | */ 77 | export function geo() { 78 | return storage.access("geo"); 79 | } 80 | 81 | /** 82 | * Enqueue for processing later any kind of payload of data. 83 | */ 84 | export function queue() { 85 | return storage.access("queue"); 86 | } 87 | 88 | /** 89 | * Get access to a rate limiter for your Edge-first application. 90 | * 91 | * The RateLimit object gives you an `limit` method you can call with any key 92 | * to identify the thing you want to rate limit. 93 | * 94 | * The default limit is set to 10, the default period is set to 60s, this means 95 | * by default any call to `limit` will allow 10 calls in a limit of 60s 96 | * 97 | * There's also a `reset` method that will delete the rate limit for a given 98 | * key. 99 | * 100 | * The `writeHttpMetadata` method will fill a Headers object with the necessary 101 | * headers to inform the client about the rate limit. If a Headers object is not 102 | * provided, a new one will be created and returned. 103 | * 104 | * @example 105 | * import { rateLimit } from "edgekitjs"; 106 | * 107 | * @example 108 | * let rateLimit = rateLimit(); 109 | * 110 | * @example 111 | * let rateLimit = rateLimit({ limit: 10, period: 60 }); 112 | * 113 | * @example 114 | * let result = await rateLimit.limit({ key }); 115 | * if (result.success) return json(data); 116 | * return json(error, { status: 429 }); 117 | * 118 | * @example 119 | * let headers = await rateLimit.writeHttpMetadata(key); 120 | * if (!result.success) return json(error, { status: 429, headers }); 121 | * return json(data, { headers }); 122 | * 123 | * @example 124 | * await rateLimit.reset(key); 125 | */ 126 | export function rateLimit() { 127 | return storage.access("rateLimit"); 128 | } 129 | 130 | /** 131 | * When you want to run a async process but don't want to block your application 132 | * to wait for it to finish, you can use the `defer` function. 133 | * 134 | * This let you run the async process as a run and forget, the process will run 135 | * in the background until it's completed and the Worker will not shutdown in 136 | * the meantime. 137 | * 138 | * In many cases this can solve the problem of a background Job too, but 139 | * consider that in case of failure the process will not be retried. 140 | * @param promise The promise to defer 141 | * @example 142 | * defer(analytics.track("event", { user })); 143 | */ 144 | export function defer(promise: Promise) { 145 | return storage.access("waitUntil")(promise); 146 | } 147 | 148 | export function bindings() { 149 | return storage.access("bindings"); 150 | } 151 | 152 | export async function puppeteer(options?: WorkersLaunchOptions) { 153 | let bindings = storage.access("bindings"); 154 | if (!bindings.BROWSER) throw new EdgeConfigError("BROWSER"); 155 | let browser = await cfPuppeteer.launch(bindings.BROWSER, options); 156 | let page = await browser.newPage(); 157 | return { page, browser }; 158 | } 159 | -------------------------------------------------------------------------------- /src/lib/storage/storage.ts: -------------------------------------------------------------------------------- 1 | import { AsyncLocalStorage } from "node:async_hooks"; 2 | import type { ExecutionContext, Request } from "@cloudflare/workers-types"; 3 | import { WorkerKVRateLimit } from "@edgefirst-dev/worker-kv-rate-limit"; 4 | import SuperHeaders from "@mjackson/headers"; 5 | import type { Logger } from "drizzle-orm"; 6 | import { DrizzleD1Database, drizzle } from "drizzle-orm/d1"; 7 | import { Cache } from "../cache/cache.js"; 8 | import { Env } from "../env/env.js"; 9 | import { EdgeContextError } from "../errors.js"; 10 | import { FS } from "../fs/fs.js"; 11 | import { Geo } from "../geo/geo.js"; 12 | import { KV } from "../kv/kv.js"; 13 | import { Queue } from "../queue/queue.js"; 14 | import type { 15 | DatabaseSchema, 16 | Environment, 17 | WaitUntilFunction, 18 | } from "../types.js"; 19 | 20 | export interface EdgeFirstContext { 21 | bindings: Environment; 22 | cache?: Cache; 23 | env: Env; 24 | fs?: FS; 25 | geo?: Geo; 26 | headers?: SuperHeaders; 27 | kv?: KV; 28 | options: Storage.SetupOptions["options"]; 29 | orm?: DrizzleD1Database; 30 | queue?: Queue; 31 | rateLimit?: WorkerKVRateLimit; 32 | request?: Request; 33 | signal?: AbortSignal; 34 | waitUntil: WaitUntilFunction; 35 | } 36 | 37 | class Storage extends AsyncLocalStorage { 38 | setup( 39 | { request, env, ctx, options }: Storage.SetupOptions, 40 | callback: () => T, 41 | ) { 42 | let waitUntil = ctx.waitUntil.bind(ctx); 43 | 44 | return this.run( 45 | { 46 | bindings: env, 47 | cache: env.KV && new Cache(env.KV), 48 | env: new Env(env), 49 | fs: env.FS && new FS(env.FS), 50 | geo: request && new Geo(request), 51 | headers: request && new SuperHeaders(request.headers), 52 | kv: env.KV && new KV(env.KV), 53 | options, 54 | orm: env.DB && options?.orm && drizzle(env.DB, options.orm), 55 | queue: env.QUEUE && new Queue(env.QUEUE), 56 | rateLimit: 57 | env.KV && 58 | new WorkerKVRateLimit( 59 | env.KV, 60 | options?.rateLimit ?? { limit: 100, period: 60 }, 61 | ), 62 | waitUntil, 63 | }, 64 | callback, 65 | ); 66 | } 67 | 68 | access( 69 | key: K, 70 | ): NonNullable { 71 | let store = this.getStore(); 72 | if (!store) throw new EdgeContextError(key); 73 | let value = store[key]; 74 | if (!value) throw new EdgeContextError(key); 75 | return value; 76 | } 77 | } 78 | 79 | export const storage = new Storage(); 80 | 81 | export namespace Storage { 82 | export interface SetupOptions { 83 | env: Environment; 84 | ctx: Pick; 85 | request?: Request; 86 | options: { 87 | /** The options for the ORM. */ 88 | orm?: { 89 | /** The database schema for the ORM. */ 90 | schema: DatabaseSchema; 91 | /** The logger for the ORM. */ 92 | logger?: Logger; 93 | }; 94 | 95 | /** The options for the rate limit. */ 96 | rateLimit?: WorkerKVRateLimit.Options; 97 | }; 98 | } 99 | } 100 | -------------------------------------------------------------------------------- /src/lib/tasks/manager.ts: -------------------------------------------------------------------------------- 1 | import type { ScheduledController } from "@cloudflare/workers-types"; 2 | import { defer } from "../storage/accessors.js"; 3 | import type { Task } from "./task.js"; 4 | 5 | export class TaskManager { 6 | #tasks: Set; 7 | 8 | constructor(tasks: Task[]) { 9 | this.#tasks = new Set(tasks); 10 | } 11 | 12 | process(event: ScheduledController): void { 13 | let now = new Date(event.scheduledTime); 14 | for (let task of this.#tasks) { 15 | if (this.shouldRunTask(task, now)) defer(task.perform()); 16 | } 17 | } 18 | 19 | private shouldRunTask(task: Task, date: Date): boolean { 20 | if (!task.constraints) return true; 21 | return task.constraints.every((constraint: Task.Constraint) => 22 | constraint(date), 23 | ); 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /src/lib/tasks/task.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Copied from Superflare scheduled tasks 3 | * @see https://github.com/jplhomer/superflare/blob/cdeb3068e410340c7eb5f1b1d997bf95ae61e3a0/packages/superflare/src/scheduled.ts#L49-L55 4 | */ 5 | const where = { 6 | minute: (minute: number) => (date: Date) => date.getMinutes() === minute, 7 | hour: (hour: number) => (date: Date) => date.getHours() === hour, 8 | day: (day: number) => (date: Date) => date.getDay() === day, 9 | date: (monthDate: number) => (date: Date) => date.getDate() === monthDate, 10 | month: (month: number) => (date: Date) => date.getMonth() === month, 11 | }; 12 | 13 | export namespace Task { 14 | export type Constraint = (date: Date) => boolean; 15 | } 16 | 17 | export abstract class Task { 18 | constraints: Task.Constraint[] = []; 19 | 20 | /** 21 | * Run every minute. 22 | */ 23 | everyMinute(): this { 24 | return this; 25 | } 26 | 27 | /** 28 | * Run hourly at the top. 29 | */ 30 | hourly(): this { 31 | this.constraints.push(where.minute(0)); 32 | 33 | return this; 34 | } 35 | 36 | /** 37 | * Run daily at midnight UTC. 38 | */ 39 | daily(): this { 40 | this.constraints.push(where.minute(0)); 41 | this.constraints.push(where.hour(0)); 42 | 43 | return this; 44 | } 45 | 46 | /** 47 | * Run daily at a specific time UTC. 48 | */ 49 | dailyAt(time: string): this { 50 | let [hour, minute] = time.split(":"); 51 | this.constraints.push(where.minute(Number.parseInt(minute ?? "0", 10))); 52 | this.constraints.push(where.hour(Number.parseInt(hour ?? "0", 10))); 53 | 54 | return this; 55 | } 56 | 57 | /** 58 | * Run weekly on Sunday at midnight UTC. 59 | */ 60 | weekly(): this { 61 | this.constraints.push(where.day(0)); 62 | this.constraints.push(where.hour(0)); 63 | this.constraints.push(where.minute(0)); 64 | 65 | return this; 66 | } 67 | 68 | /** 69 | * Run weekly on a specific day of the week at a specific time UTC. 70 | */ 71 | weeklyOn(day: string, time: string): this { 72 | let [hour, minute] = time.split(":"); 73 | this.constraints.push(where.day(Number.parseInt(day, 10))); 74 | this.constraints.push(where.minute(Number.parseInt(minute ?? "0", 10))); 75 | this.constraints.push(where.hour(Number.parseInt(hour ?? "0", 10))); 76 | 77 | return this; 78 | } 79 | 80 | /** 81 | * Run monthly on the first day of the month at midnight UTC. 82 | */ 83 | monthly(): this { 84 | this.constraints.push(where.date(1)); 85 | this.constraints.push(where.hour(0)); 86 | this.constraints.push(where.minute(0)); 87 | 88 | return this; 89 | } 90 | 91 | /** 92 | * Run monthly on a specific date of the month at a specific time UTC. 93 | */ 94 | monthlyOn(date: string, time: string): this { 95 | let [hour, minute] = time.split(":"); 96 | this.constraints.push(where.date(Number.parseInt(date, 10))); 97 | this.constraints.push(where.minute(Number.parseInt(minute ?? "0", 10))); 98 | this.constraints.push(where.hour(Number.parseInt(hour ?? "0", 10))); 99 | 100 | return this; 101 | } 102 | 103 | yearly(): this { 104 | // Months are 0-based, LOL 105 | this.constraints.push(where.month(0)); 106 | this.constraints.push(where.date(1)); 107 | this.constraints.push(where.hour(0)); 108 | this.constraints.push(where.minute(0)); 109 | 110 | return this; 111 | } 112 | 113 | abstract perform(): Promise; 114 | } 115 | -------------------------------------------------------------------------------- /src/lib/types.ts: -------------------------------------------------------------------------------- 1 | import type { BrowserWorker } from "@cloudflare/puppeteer"; 2 | import type { 3 | D1Database, 4 | ExecutionContext, 5 | KVNamespace, 6 | Queue, 7 | R2Bucket, 8 | } from "@cloudflare/workers-types"; 9 | 10 | export type WaitUntilFunction = ExecutionContext["waitUntil"]; 11 | 12 | export interface Environment extends Cloudflare.Env { 13 | // Cloudflare Bindings 14 | DB: D1Database; 15 | FS: R2Bucket; 16 | KV: KVNamespace; 17 | QUEUE: Queue; 18 | BROWSER: BrowserWorker; 19 | // Environment variables 20 | VERIFIER_API_KEY?: string; 21 | } 22 | 23 | export interface DatabaseSchema extends Record {} 24 | -------------------------------------------------------------------------------- /src/lib/values/email.test.ts: -------------------------------------------------------------------------------- 1 | import { afterAll, beforeAll, describe, expect, mock, test } from "bun:test"; 2 | 3 | import { http, HttpResponse } from "msw"; 4 | import { setupServer } from "msw/native"; 5 | import { EmailVerifier } from "../clients/email-verifier"; 6 | import { Email } from "./email"; 7 | 8 | mock.module("../storage/accessors.js", () => { 9 | return { 10 | env() { 11 | return { 12 | fetch(key: string): string { 13 | return key; 14 | }, 15 | }; 16 | }, 17 | }; 18 | }); 19 | 20 | const value = "john@example.com"; 21 | const complex = "john.doe+service@company.example.com"; 22 | 23 | describe(Email.name, () => { 24 | test(".from() using string", () => { 25 | let email = Email.from(value); 26 | expect(email).toBeInstanceOf(Email); 27 | }); 28 | 29 | test(".from() using Email", () => { 30 | let original = Email.from(value); 31 | let email = Email.from(original); 32 | expect(email).toBeInstanceOf(Email); 33 | expect(email.toString()).toBe(original.toString()); 34 | expect(email).not.toBe(original); // Should be a new instance 35 | }); 36 | 37 | test(".from() throw if it's invalid", () => { 38 | expect(() => Email.from("invalid")).toThrow(); 39 | expect(() => Email.from("invalid@")).toThrow(); 40 | expect(() => Email.from("@invalid")).toThrow(); 41 | expect(() => Email.from("invalid@invalid")).toThrow(); 42 | }); 43 | 44 | test("#toString()", () => { 45 | let email = Email.from(value); 46 | expect(email.toString()).toBe(value); 47 | }); 48 | 49 | test("#toJSON()", () => { 50 | let email = Email.from(value); 51 | expect(email.toJSON()).toBe(value); 52 | }); 53 | 54 | test("get username", () => { 55 | let email = Email.from(value); 56 | expect(email.username).toBe("john"); 57 | }); 58 | 59 | test("get hostname", () => { 60 | let email = Email.from(value); 61 | expect(email.hostname).toBe("example.com"); 62 | }); 63 | 64 | test("get username with complex email", () => { 65 | let email = Email.from(complex); 66 | expect(email.username).toBe("john.doe+service"); 67 | }); 68 | 69 | test("get hostname with complex email", () => { 70 | let email = Email.from(complex); 71 | expect(email.hostname).toBe("company.example.com"); 72 | }); 73 | 74 | test("get alias", () => { 75 | let email = Email.from(complex); 76 | expect(email.alias).toBe("service"); 77 | }); 78 | 79 | test("get alias with simple email", () => { 80 | let email = Email.from(value); 81 | expect(email.alias).toBeUndefined(); 82 | }); 83 | 84 | test("#hash", () => { 85 | let email = Email.from(value); 86 | expect(email.hash.toString()).toBe( 87 | "855f96e983f1f8e8be944692b6f719fd54329826cb62e98015efee8e2e071dd4", 88 | ); 89 | }); 90 | 91 | test("set username", () => { 92 | let email = Email.from(value); 93 | email.username = "jane"; 94 | expect(email.toString()).toBe("jane@example.com"); 95 | }); 96 | 97 | test("set hostname", () => { 98 | let email = Email.from(value); 99 | email.hostname = "example.net"; 100 | expect(email.toString()).toBe("john@example.net"); 101 | }); 102 | 103 | test("set alias", () => { 104 | let email = Email.from(value); 105 | email.alias = "alias"; 106 | expect(email.toString()).toBe("john+alias@example.com"); 107 | }); 108 | 109 | test("set alias to undefined", () => { 110 | let email = Email.from(complex); 111 | email.alias = undefined; 112 | expect(email.toString()).toBe("john.doe@company.example.com"); 113 | }); 114 | 115 | test("#hasAlias", () => { 116 | let email = Email.from(complex); 117 | expect(email.hasAlias()).toBeTrue(); 118 | }); 119 | 120 | test("#hasAlias with simple email", () => { 121 | let email = Email.from(value); 122 | expect(email.hasAlias()).toBeFalse(); 123 | }); 124 | 125 | test(".canParse", () => { 126 | expect(Email.canParse(value)).toBeTrue(); 127 | expect(Email.canParse("invalid")).toBeFalse(); 128 | }); 129 | 130 | describe("#verify", () => { 131 | let server = setupServer(); 132 | 133 | beforeAll(() => server.listen()); 134 | afterAll(() => server.close()); 135 | 136 | test("with valid email", async () => { 137 | let email = Email.from(value); 138 | server.resetHandlers( 139 | http.get(`https://verifyright.co/verify/${value}`, () => { 140 | return HttpResponse.json({ status: true }); 141 | }), 142 | ); 143 | 144 | expect(email.verify()).resolves.toBeUndefined(); 145 | }); 146 | 147 | test("with invalid email", async () => { 148 | let email = Email.from(value); 149 | 150 | server.resetHandlers( 151 | http.get(`https://verifyright.co/verify/${value}`, () => { 152 | return HttpResponse.json({ 153 | status: false, 154 | error: { code: 2, message: "Disposable email address" }, 155 | }); 156 | }), 157 | ); 158 | 159 | expect(email.verify()).rejects.toThrow(); 160 | }); 161 | }); 162 | 163 | describe("Overwrite the verifier", () => { 164 | let verify = mock().mockImplementation(async (email: Email) => { 165 | if (email.hostname === "example.com") return; 166 | throw new EmailVerifier.InvalidEmailError(2, "Disposable email address"); 167 | }); 168 | 169 | class MyEmail extends Email { 170 | protected override verifier = { verify }; 171 | } 172 | 173 | test("with custom verifier", async () => { 174 | let email = MyEmail.from(value); 175 | await email.verify(); 176 | expect(verify).toHaveBeenCalledWith(email); 177 | }); 178 | 179 | test("with failing custom verifier", async () => { 180 | let email = MyEmail.from(complex); 181 | expect(email.verify()).rejects.toThrow(); 182 | expect(verify).toHaveBeenCalledWith(email); 183 | }); 184 | }); 185 | }); 186 | -------------------------------------------------------------------------------- /src/lib/values/email.ts: -------------------------------------------------------------------------------- 1 | import { sha256 } from "@oslojs/crypto/sha2"; 2 | import { encodeHexLowerCase } from "@oslojs/encoding"; 3 | 4 | import { EmailVerifier } from "../clients/email-verifier.js"; 5 | 6 | /** 7 | * The `Email` class represents an email address, providing methods to 8 | * validate, parse, and manipulate the email address components like username, 9 | * domain, and TLD. It also supports email validation through an external API. 10 | * 11 | * This class is immutable in its interface but allows controlled modifications 12 | * to the username, hostname, and TLD through getters and setters. 13 | */ 14 | export class Email { 15 | private value: string; 16 | protected verifier: Email.Verifier = new EmailVerifier(); 17 | 18 | /** 19 | * Creates and returns an Email object referencing the Email specified using 20 | * an email string, or a base Email object 21 | */ 22 | constructor(email: string | Email) { 23 | if (email instanceof Email) { 24 | this.value = email.toString(); 25 | } else if (this.isValid(email)) { 26 | this.value = email.trim(); 27 | } else { 28 | throw new TypeError(`Invalid email ${email}`); 29 | } 30 | } 31 | 32 | /** 33 | * Static factory method to create an `Email` object. 34 | * 35 | * @param email - A string representing an email address or another `Email` instance. 36 | * @returns A new `Email` object. 37 | */ 38 | static from( 39 | this: new ( 40 | email: string | Email, 41 | ) => T, 42 | email: string | Email, 43 | ) { 44 | // biome-ignore lint/complexity/noThisInStatic: Needed for subclasses 45 | return new this(email); 46 | } 47 | 48 | /** 49 | * Determines if the provided value can be parsed as a valid `Email`. 50 | * 51 | * This method checks whether the input is either an instance of the `Email` 52 | * class or a valid email string that can be successfully parsed by the 53 | * `Email.from()` method. 54 | * 55 | * @param email - The email value to check, which can either be a string or an instance of `Email`. 56 | * @returns `true` if the value can be parsed as an `Email`, otherwise `false`. 57 | * 58 | * @example 59 | * const result1 = Email.canParse("user@example.com"); // true 60 | * const result2 = Email.canParse("invalid-email"); // false 61 | * const emailInstance = Email.from("user@example.com"); 62 | * const result3 = Email.canParse(emailInstance); // true 63 | */ 64 | static canParse( 65 | this: new ( 66 | email: string | Email, 67 | ) => M, 68 | email: string | Email, 69 | ) { 70 | if (email instanceof Email) return true; 71 | try { 72 | // biome-ignore lint/complexity/noThisInStatic: Needed for subclasses 73 | new this(email); 74 | return true; 75 | } catch { 76 | return false; 77 | } 78 | } 79 | 80 | /** 81 | * Returns the full email address as a string. 82 | * 83 | * @returns A string representation of the email. 84 | */ 85 | public toString() { 86 | return this.value; 87 | } 88 | 89 | /** 90 | * Serializes the email address to JSON format, returning the same value as `toString()`. 91 | * 92 | * @returns A string representation of the email for JSON serialization. 93 | */ 94 | public toJSON() { 95 | return this.value; 96 | } 97 | 98 | /** 99 | * Returns the hash of the email address using the SHA-256 algorithm. 100 | * 101 | * @returns A string containing the SHA-256 hash of the email address. 102 | */ 103 | public get hash() { 104 | return encodeHexLowerCase( 105 | sha256(new TextEncoder().encode(this.value.toString())), 106 | ); 107 | } 108 | 109 | /** 110 | * Gets the username (part before the `@` symbol) of the email. 111 | * 112 | * @throws {Error} If the username is missing. 113 | * @returns A string containing the username. 114 | */ 115 | public get username(): string { 116 | let username = this.value.split("@").at(0); 117 | if (!username) throw new Error("Missing username"); 118 | return username; 119 | } 120 | 121 | /** 122 | * Gets the domain (part after the `@` symbol) of the email. 123 | * 124 | * @throws {Error} If the domain is missing. 125 | * @returns A string containing the domain. 126 | */ 127 | public get hostname(): string { 128 | let hostname = this.value.split("@").at(1); 129 | if (!hostname) throw new Error("Missing hostname"); 130 | return hostname; 131 | } 132 | 133 | /** 134 | * Retrieves the alias part of the email, which is the portion after the `+` symbol 135 | * in the username, if it exists. 136 | * 137 | * If the username contains a `+`, this method returns the part of the username after the `+`. 138 | * If there is no `+` in the username, it returns `undefined`. 139 | * 140 | * @returns The alias part of the email, or `undefined` if no alias is present. 141 | * 142 | * @example 143 | * const email = Email.from("user+alias@example.com"); 144 | * console.log(email.alias); // "alias" 145 | * 146 | * const email2 = Email.from("user@example.com"); 147 | * console.log(email2.alias); // undefined 148 | */ 149 | get alias(): string | undefined { 150 | return this.username.split("+").at(1); 151 | } 152 | 153 | /** 154 | * Sets the username of the email. 155 | * 156 | * @param value - The new username to set. 157 | */ 158 | public set username(value: string) { 159 | this.value = `${value}@${this.hostname}`; 160 | } 161 | 162 | /** 163 | * Sets the domain (hostname) of the email. 164 | * 165 | * @param value - The new domain to set. 166 | */ 167 | public set hostname(value: string) { 168 | this.value = `${this.username}@${value}`; 169 | } 170 | 171 | /** 172 | * Sets or updates the alias part of the email (the portion after the `+` symbol in the username). 173 | * If `undefined` or an empty string is provided, the alias is removed. 174 | * 175 | * @param alias - The new alias to set. If `undefined` or empty, the alias will be removed. 176 | * 177 | * @example 178 | * const email = Email.from("user@example.com"); 179 | * email.alias = "alias"; 180 | * console.log(email.toString()); // "user+alias@example.com" 181 | * 182 | * const email2 = Email.from("user+alias@example.com"); 183 | * email2.alias = undefined; 184 | * console.log(email2.toString()); // "user@example.com" 185 | */ 186 | set alias(alias: string | undefined) { 187 | let [username] = this.username.split("+"); 188 | if (!username) throw new Error("Missing username"); 189 | 190 | // Set the new alias 191 | if (alias) this.username = `${username}+${alias}`; 192 | // Remove the alias if it's undefined or empty 193 | else this.username = username; 194 | } 195 | 196 | /** 197 | * Verifies the email address using an external API. 198 | * 199 | * @throws {InvalidEmailError} If the email is not valid according to the API. 200 | */ 201 | public async verify() { 202 | return await this.verifier.verify(this); 203 | } 204 | 205 | /** 206 | * Checks if the email's username contains a `+` followed by additional text, 207 | * commonly referred to as an alias or "plus addressing". 208 | * 209 | * @returns `true` if the email contains an alias, otherwise `false`. 210 | * 211 | * @example 212 | * const email = Email.from("user+alias@example.com"); 213 | * email.hasAlias(); // true 214 | * 215 | * const email2 = Email.from("user@example.com"); 216 | * email2.hasAlias(); // false 217 | */ 218 | public hasAlias(): boolean { 219 | return typeof this.alias === "string"; 220 | } 221 | 222 | /** 223 | * Validates the format of the provided email string. 224 | * 225 | * @param emailAddress - The email address to validate. 226 | * @returns `true` if the email format is valid, otherwise `false`. 227 | */ 228 | private isValid(emailAddress: string): boolean { 229 | return /^.+@.+\..+$/.test(emailAddress); 230 | } 231 | } 232 | 233 | export namespace Email { 234 | export interface Verifier { 235 | verify(email: Email): Promise; 236 | } 237 | } 238 | -------------------------------------------------------------------------------- /src/lib/values/ip-address.test.ts: -------------------------------------------------------------------------------- 1 | import { describe, expect, test } from "bun:test"; 2 | 3 | import { Request } from "@cloudflare/workers-types"; 4 | import { IPAddress } from "./ip-address"; 5 | 6 | describe(IPAddress.name, () => { 7 | test("#constructor with string", () => { 8 | let ip = new IPAddress("127.0.0.1"); 9 | expect(ip).toBeInstanceOf(IPAddress); 10 | }); 11 | 12 | test("#constructor with IPAddress", () => { 13 | let ip = new IPAddress("127.0.0.1"); 14 | let ip2 = new IPAddress(ip); 15 | expect(ip2).toBeInstanceOf(IPAddress); 16 | expect(ip).not.toBe(ip2); 17 | }); 18 | 19 | test(".from", () => { 20 | let ip = IPAddress.from("127.0.0.1"); 21 | expect(ip).toBeInstanceOf(IPAddress); 22 | }); 23 | 24 | test(".fromRequest", () => { 25 | let request = new globalThis.Request("https://example.com", { 26 | headers: { "CF-Connecting-IP": "127.0.0.1" }, 27 | }); 28 | 29 | let ip = IPAddress.fromRequest(request as unknown as Request); 30 | 31 | expect(ip).toBeInstanceOf(IPAddress); 32 | }); 33 | 34 | test(".canParse with string", () => { 35 | expect(IPAddress.canParse("127.0.0.1")).toBe(true); 36 | }); 37 | 38 | test(".canParse with IPAddress", () => { 39 | let ip = IPAddress.from("127.0.0.1"); 40 | expect(IPAddress.canParse(ip)).toBe(true); 41 | }); 42 | 43 | test("get version", () => { 44 | expect(IPAddress.from("127.0.0.1").version).toBe(4); 45 | expect( 46 | IPAddress.from("2001:0db8:85a3:0000:0000:8a2e:0370:7334").version, 47 | ).toBe(6); 48 | }); 49 | 50 | test("get isV4", () => { 51 | expect(IPAddress.from("127.0.0.1").isV4).toBeTrue(); 52 | }); 53 | 54 | test("get isV6", () => { 55 | expect( 56 | IPAddress.from("2001:0db8:85a3:0000:0000:8a2e:0370:7334").isV6, 57 | ).toBeTrue(); 58 | }); 59 | 60 | test("#toString", () => { 61 | let ip = IPAddress.from("127.0.0.1"); 62 | expect(ip.toString()).toBe("127.0.0.1"); 63 | }); 64 | 65 | test("toJSON", () => { 66 | let ip = IPAddress.from("127.0.0.1"); 67 | expect(ip.toJSON()).toBe("127.0.0.1"); 68 | }); 69 | 70 | test("#valueOf", () => { 71 | let ip = IPAddress.from("127.0.0.1"); 72 | expect(ip.valueOf()).toBe("127.0.0.1"); 73 | }); 74 | }); 75 | -------------------------------------------------------------------------------- /src/lib/values/ip-address.ts: -------------------------------------------------------------------------------- 1 | import type { Request } from "@cloudflare/workers-types"; 2 | import { ipVersion, isIP } from "is-ip"; 3 | 4 | /** 5 | * The `IPAddress` class represents an IP address, providing methods to 6 | * validate, parse, and determine the version of the IP address (IPv4 or IPv6). 7 | * 8 | * The class supports constructing from a string or another `IPAddress` 9 | * instance, as well as extracting an IP address from a request header. It also 10 | * provides utility methods for checking IP version and serializing the IP 11 | * address. 12 | * 13 | * @example 14 | * const ip = IPAddress.from("192.168.1.1"); 15 | * console.log(ip.isV4); // true 16 | * console.log(ip.version); // 4 17 | */ 18 | export class IPAddress { 19 | private value: string; 20 | 21 | /** 22 | * Constructs an `IPAddress` instance from a string or another `IPAddress`. 23 | * 24 | * @param ip - The IP address as a string or another `IPAddress` instance. 25 | * @throws {TypeError} If the provided IP address is invalid. 26 | */ 27 | constructor(ip: string | IPAddress) { 28 | if (ip instanceof IPAddress) { 29 | this.value = ip.toString(); 30 | } else if (this.isValid(ip)) { 31 | this.value = ip.trim(); 32 | } else { 33 | throw new TypeError(`Invalid IP address ${ip}`); 34 | } 35 | } 36 | 37 | /** 38 | * Creates a new `IPAddress` instance from a string or another `IPAddress`. 39 | * 40 | * @param ip - The IP address to parse, either as a string or an `IPAddress` instance. 41 | * @returns A new `IPAddress` instance. 42 | */ 43 | static from(ip: string | IPAddress) { 44 | return new IPAddress(ip); 45 | } 46 | 47 | /** 48 | * Extracts an IP address from a `Request` object by checking the 49 | * `CF-Connecting-IP` header. 50 | * 51 | * @param request - The incoming request containing headers. 52 | * @returns A new `IPAddress` instance if the IP is present in the headers, otherwise `null`. 53 | */ 54 | static fromRequest(request: Request) { 55 | let header = request.headers.get("CF-Connecting-IP"); 56 | if (!header) return null; 57 | return IPAddress.from(header); 58 | } 59 | 60 | /** 61 | * Checks if a value can be parsed as a valid `IPAddress`. 62 | * 63 | * @param ip - The IP address to check, either as a string or an `IPAddress` instance. 64 | * @returns `true` if the IP address can be parsed, otherwise `false`. 65 | */ 66 | static canParse(ip: string | IPAddress) { 67 | if (ip instanceof IPAddress) return true; 68 | try { 69 | IPAddress.from(ip); 70 | return true; 71 | } catch { 72 | return false; 73 | } 74 | } 75 | 76 | /** 77 | * Gets the version of the IP address (4 for IPv4 or 6 for IPv6). 78 | * 79 | * @returns `4` if the IP is IPv4, `6` if IPv6. 80 | * @throws {Error} If the IP address is invalid. 81 | */ 82 | get version(): 6 | 4 { 83 | let version = ipVersion(this.value); 84 | if (version) return version; 85 | throw new Error(`Invalid IP address: ${this.value}`); 86 | } 87 | 88 | /** 89 | * Checks if the IP address is IPv4. 90 | * 91 | * @returns `true` if the IP address is IPv4, otherwise `false`. 92 | */ 93 | get isV4() { 94 | return this.version === 4; 95 | } 96 | 97 | /** 98 | * Checks if the IP address is IPv6. 99 | * 100 | * @returns `true` if the IP address is IPv6, otherwise `false`. 101 | */ 102 | get isV6() { 103 | return this.version === 6; 104 | } 105 | 106 | /** 107 | * Validates whether the given string is a valid IP address. 108 | * 109 | * @param ip - The IP address as a string. 110 | * @returns `true` if the IP address is valid, otherwise `false`. 111 | */ 112 | private isValid(ip: string) { 113 | return isIP(ip); 114 | } 115 | 116 | /** 117 | * Returns the IP address as a string. 118 | * 119 | * @returns The IP address. 120 | */ 121 | toString() { 122 | return this.value; 123 | } 124 | 125 | /** 126 | * Serializes the IP address to a JSON-compatible format. 127 | * 128 | * @returns The IP address as a string. 129 | */ 130 | toJSON() { 131 | return this.value; 132 | } 133 | 134 | /** 135 | * Returns the primitive value of the IP address. 136 | * 137 | * @returns The IP address as a string. 138 | */ 139 | valueOf() { 140 | return this.value; 141 | } 142 | } 143 | -------------------------------------------------------------------------------- /src/lib/values/password.test.ts: -------------------------------------------------------------------------------- 1 | import { afterAll, beforeAll, describe, expect, mock, test } from "bun:test"; 2 | 3 | import { http } from "msw"; 4 | import { setupServer } from "msw/native"; 5 | import { Password } from "./password"; 6 | 7 | mock.module("edgekitjs", () => { 8 | return { 9 | env() { 10 | return { 11 | fetch(key: string): string { 12 | return key; 13 | }, 14 | }; 15 | }, 16 | }; 17 | }); 18 | 19 | let server = setupServer(); 20 | 21 | describe(Password.name, () => { 22 | beforeAll(() => server.listen()); 23 | afterAll(() => server.close()); 24 | 25 | test(".from() using string", () => { 26 | let password = Password.from("password"); 27 | expect(password).toBeInstanceOf(Password); 28 | }); 29 | 30 | test("#hash with default", () => { 31 | let password = Password.from("password"); 32 | expect(password.hash()).resolves.toBeString(); 33 | }); 34 | 35 | test("#hash with salt rounds", () => { 36 | let password = Password.from("password"); 37 | expect(password.hash(5)).resolves.toBeString(); 38 | }); 39 | 40 | test("#compare", async () => { 41 | let password = Password.from("password"); 42 | let hashed = await password.hash(); 43 | expect(password.compare(hashed)).resolves.toBeTrue(); 44 | }); 45 | 46 | test("#isWeak with short password", async () => { 47 | let password = Password.from("pass"); 48 | expect(password.isStrong()).rejects.toThrowError( 49 | "Password must be at least 8 characters long", 50 | ); 51 | }); 52 | 53 | test("#isWeak with no lowercase", async () => { 54 | let password = Password.from("PASSWORD"); 55 | expect(password.isStrong()).rejects.toThrowError( 56 | "Password must contain at least one lowercase letter", 57 | ); 58 | }); 59 | 60 | test("#isWeak with no uppercase", async () => { 61 | let password = Password.from("password"); 62 | expect(password.isStrong()).rejects.toThrowError( 63 | "Password must contain at least one uppercase letter", 64 | ); 65 | }); 66 | 67 | test("#isWeak with no number", async () => { 68 | let password = Password.from("Password"); 69 | expect(password.isStrong()).rejects.toThrowError( 70 | "Password must contain at least one number", 71 | ); 72 | }); 73 | 74 | test("#isWeak with no special character", async () => { 75 | let password = Password.from("Password1"); 76 | expect(password.isStrong()).rejects.toThrowError( 77 | "Password must contain at least one special character", 78 | ); 79 | }); 80 | 81 | test("#isWeak with pwned password", async () => { 82 | let password = Password.from("abcDEF123!@#"); 83 | 84 | server.resetHandlers( 85 | http.get("https://api.pwnedpasswords.com/range/42a48", () => { 86 | return new Response("d2f5c131c7ab9fbc431622225e430a49ccd"); 87 | }), 88 | ); 89 | 90 | expect(password.isStrong()).rejects.toThrowError( 91 | "Password is included in a data breach", 92 | ); 93 | }); 94 | 95 | test("#isWeak with strong password", async () => { 96 | let password = Password.from("abcDEF123!@#"); 97 | 98 | server.resetHandlers( 99 | http.get("https://api.pwnedpasswords.com/range/42a48", () => { 100 | return new Response("1da2f5c1331c7ab39fbc431622225e4f30a49ccd"); 101 | }), 102 | ); 103 | 104 | expect(password.isStrong()).resolves.toBeUndefined(); 105 | }); 106 | 107 | test("#toJSON", () => { 108 | let password = Password.from("password"); 109 | expect(password.toJSON()).toBe(""); 110 | expect(JSON.stringify({ password })).toMatchInlineSnapshot( 111 | `"{"password":""}"`, 112 | ); 113 | }); 114 | }); 115 | -------------------------------------------------------------------------------- /src/lib/values/password.ts: -------------------------------------------------------------------------------- 1 | import bcrypt from "bcryptjs"; 2 | import { PwnedPasswords } from "../clients/pwned-passwords.js"; 3 | 4 | const MIN_LENGTH = 8; 5 | const SALT_ROUNDS = 10; 6 | 7 | /** 8 | * The `Password` class provides methods for securely hashing, comparing, and 9 | * checking the strength of passwords. It integrates with `bcrypt` for hashing 10 | * and comparison and also checks for weak or compromised passwords using 11 | * both strength rules and the Pwned Passwords API. 12 | */ 13 | export class Password { 14 | /** 15 | * Static factory method to create a `Password` instance. 16 | * 17 | * @param value - The plain text password. 18 | * @returns A new `Password` instance. 19 | */ 20 | public static from(value: string) { 21 | return new Password(value); 22 | } 23 | 24 | /** 25 | * Private constructor for the `Password` class. 26 | * 27 | * @param value - The plain text password. 28 | */ 29 | private constructor(private value: string) {} 30 | 31 | /** 32 | * Hashes the password using `bcrypt` with a customizable salt rounds factor. 33 | * 34 | * @param salt - The number of salt rounds to use. Defaults to 10. 35 | * @returns A promise that resolves to the hashed password. 36 | */ 37 | public hash(salt = SALT_ROUNDS) { 38 | return bcrypt.hash(this.value, salt); 39 | } 40 | 41 | /** 42 | * Compares the plain text password with a hashed password using `bcrypt`. 43 | * 44 | * @param hashed - The hashed password to compare against. 45 | * @returns A promise that resolves to `true` if the passwords match, otherwise `false`. 46 | */ 47 | public compare(hashed: string) { 48 | return bcrypt.compare(this.value, hashed); 49 | } 50 | 51 | /** 52 | * Checks if the password is weak. A password is considered weak if it does 53 | * not meet the following criteria: 54 | * - Minimum length of 8 characters (configured with a constant). 55 | * - Contains at least one lowercase letter. 56 | * - Contains at least one uppercase letter. 57 | * - Contains at least one number. 58 | * - Contains at least one special character. 59 | * - Not found in the Pwned Passwords database. 60 | * 61 | * @throws {WeakPasswordError} If the password is considered weak. 62 | * @returns A promise that resolves if the password is strong, otherwise throws an error. 63 | */ 64 | public async isStrong() { 65 | if (this.value.length < MIN_LENGTH) { 66 | throw new WeakPasswordError( 67 | `Password must be at least ${MIN_LENGTH} characters long`, 68 | ); 69 | } 70 | 71 | if (!/[a-z]/.test(this.value)) { 72 | throw new WeakPasswordError( 73 | "Password must contain at least one lowercase letter", 74 | ); 75 | } 76 | 77 | if (!/[A-Z]/.test(this.value)) { 78 | throw new WeakPasswordError( 79 | "Password must contain at least one uppercase letter", 80 | ); 81 | } 82 | 83 | if (!/\d/.test(this.value)) { 84 | throw new WeakPasswordError("Password must contain at least one number"); 85 | } 86 | 87 | if (!/([^\dA-Za-z]+)/g.test(this.value)) { 88 | throw new WeakPasswordError( 89 | "Password must contain at least one special character", 90 | ); 91 | } 92 | 93 | if (await this.isPwned()) { 94 | throw new WeakPasswordError("Password is included in a data breach"); 95 | } 96 | } 97 | 98 | /** 99 | * Private method to check if the password has been compromised using the 100 | * Pwned Passwords API. 101 | * 102 | * @returns A promise that resolves to `true` if the password has been found 103 | * in a data breach, otherwise `false`. 104 | */ 105 | private async isPwned() { 106 | let hash = PwnedPasswords.hash(this.value); 107 | return await new PwnedPasswords().isPwned(hash); 108 | } 109 | 110 | toJSON() { 111 | return ""; 112 | } 113 | } 114 | 115 | /** 116 | * The `WeakPasswordError` is thrown when a password fails the strength 117 | * requirements or is found in a known data breach. 118 | */ 119 | export class WeakPasswordError extends Error { 120 | override name = "WeakPasswordError"; 121 | } 122 | -------------------------------------------------------------------------------- /src/lib/values/user-agent.ts: -------------------------------------------------------------------------------- 1 | import type { Request } from "@cloudflare/workers-types"; 2 | import Bowser from "bowser"; 3 | 4 | export class UserAgent { 5 | #parsed: Bowser.Parser.ParsedResult; 6 | 7 | constructor(private value: string) { 8 | this.#parsed = Bowser.parse(this.value); 9 | } 10 | 11 | static from(value: string | UserAgent): UserAgent { 12 | if (value instanceof UserAgent) return new UserAgent(value.toString()); 13 | return new UserAgent(value); 14 | } 15 | 16 | static fromRequest(request: Request): UserAgent | null { 17 | let header = request.headers.get("user-agent"); 18 | if (!header) return null; 19 | return UserAgent.from(header); 20 | } 21 | 22 | static canParse(value: string) { 23 | try { 24 | Bowser.parse(value); 25 | return true; 26 | } catch { 27 | return false; 28 | } 29 | } 30 | 31 | get browser() { 32 | return this.#parsed.browser; 33 | } 34 | 35 | get engine() { 36 | return this.#parsed.engine; 37 | } 38 | 39 | get os() { 40 | return this.#parsed.os; 41 | } 42 | 43 | get platform() { 44 | return this.#parsed.platform; 45 | } 46 | 47 | toString(): string { 48 | return this.value; 49 | } 50 | } 51 | -------------------------------------------------------------------------------- /src/mocks/cf.ts: -------------------------------------------------------------------------------- 1 | import { mock } from "bun:test"; 2 | import type { 3 | KVNamespace, 4 | KVNamespaceListOptions, 5 | KVNamespaceListResult, 6 | MessageSendRequest, 7 | Queue, 8 | QueueSendBatchOptions, 9 | QueueSendOptions, 10 | R2ListOptions, 11 | R2ObjectBody, 12 | } from "@cloudflare/workers-types"; 13 | import type { Jsonifiable } from "type-fest"; 14 | 15 | export class MockKVNamespace implements KVNamespace { 16 | #data: Map; 17 | 18 | constructor( 19 | initialData?: readonly [ 20 | string, 21 | { value: Jsonifiable; metadata?: unknown }, 22 | ][], 23 | ) { 24 | if (initialData === undefined) this.#data = new Map(); 25 | else { 26 | let data = initialData.map(([key, { value, metadata }]) => { 27 | return [key, { value: this.toArrayBuffer(value), metadata }] as const; 28 | }); 29 | this.#data = new Map(data); 30 | } 31 | } 32 | 33 | get = mock().mockImplementation((key: string, type: "text") => { 34 | let entry = this.#data.get(key); 35 | if (!entry) return Promise.resolve(null); 36 | 37 | let { value } = entry; 38 | let text = new TextDecoder().decode(value); 39 | return Promise.resolve(text); 40 | }); 41 | 42 | put = mock().mockImplementation((key, value, options) => { 43 | this.#data.set(key, { 44 | value: 45 | typeof value === "string" || typeof value === "object" 46 | ? this.toArrayBuffer(value as Jsonifiable) 47 | : value, 48 | metadata: options?.metadata, 49 | }); 50 | return Promise.resolve(); 51 | }); 52 | 53 | delete = mock().mockImplementation((key: string) => { 54 | this.#data.delete(key); 55 | return Promise.resolve(); 56 | }); 57 | 58 | list = mock().mockImplementation((options: KVNamespaceListOptions) => { 59 | let keys = Array.from(this.#data.entries()).map(([key, { metadata }]) => ({ 60 | name: key, 61 | meta: metadata, 62 | })); 63 | let total = keys.length; 64 | 65 | if (options?.prefix) { 66 | keys = keys.filter((object) => 67 | // biome-ignore lint/style/noNonNullAssertion: This is a test 68 | object.name.startsWith(options.prefix!), 69 | ); 70 | total = keys.length; 71 | } 72 | 73 | if (options?.limit) { 74 | let startAt = Number(options?.cursor ?? 0); 75 | let endAt = startAt + options.limit; 76 | keys = keys.slice(startAt, endAt); 77 | } 78 | 79 | let done = this.getDone( 80 | total, 81 | keys.length, 82 | options?.cursor ?? undefined, 83 | options?.limit, 84 | ); 85 | 86 | let cursor = this.getCursor( 87 | done, 88 | options?.cursor ?? undefined, 89 | options?.limit, 90 | ); 91 | 92 | return { 93 | list_complete: done, 94 | keys, 95 | cursor, 96 | cacheStatus: null, 97 | } as KVNamespaceListResult; 98 | }); 99 | 100 | getWithMetadata = mock().mockImplementation((key: string, type: "json") => { 101 | let result = this.#data.get(key); 102 | if (!result) return { data: null, meta: null }; 103 | return Promise.resolve({ 104 | value: JSON.parse(new TextDecoder().decode(result.value)), 105 | metadata: result.metadata, 106 | }); 107 | }); 108 | 109 | private toArrayBuffer(value: Jsonifiable) { 110 | let encoded = new TextEncoder().encode(JSON.stringify(value)); 111 | let arrayBuffer = new ArrayBuffer(encoded.length); 112 | new Uint8Array(arrayBuffer).set(encoded); 113 | return arrayBuffer; 114 | } 115 | 116 | private getDone(total: number, length: number, cursor?: string, limit = 10) { 117 | if (total === length) return true; 118 | if (cursor) return Number(cursor) + limit >= total; 119 | return false; 120 | } 121 | 122 | private getCursor(done: boolean, cursor?: string, limit = 10) { 123 | if (done) return null; 124 | if (cursor) return String(Number(cursor) + limit); 125 | return String(limit); 126 | } 127 | } 128 | 129 | export class MockR2Bucket implements R2Bucket { 130 | #data: Map; 131 | 132 | constructor(initialData?: readonly [string, R2ObjectBody][]) { 133 | this.#data = new Map(initialData); 134 | } 135 | 136 | createMultipartUpload = mock(); 137 | 138 | delete = mock().mockImplementation((key) => { 139 | if (Array.isArray(key)) { 140 | for (let k of key) { 141 | this.#data.delete(k); 142 | } 143 | } else this.#data.delete(key); 144 | return Promise.resolve(); 145 | }); 146 | 147 | get = mock().mockImplementation((key) => { 148 | return Promise.resolve(this.#data.get(key) ?? null); 149 | }); 150 | 151 | head = mock().mockImplementation((key) => { 152 | return Promise.resolve(this.#data.get(key) ?? null); 153 | }); 154 | 155 | list = mock().mockImplementation((options) => { 156 | let objects = Array.from(this.#data.values()); 157 | let total = objects.length; 158 | 159 | if (options?.prefix) { 160 | objects = objects.filter((object) => 161 | // biome-ignore lint/style/noNonNullAssertion: This is a test 162 | object.key.startsWith(options.prefix!), 163 | ); 164 | total = objects.length; 165 | } 166 | 167 | if (options?.limit) { 168 | let startAt = Number(options?.cursor ?? 0); 169 | let endAt = startAt + options.limit; 170 | objects = objects.slice(startAt, endAt); 171 | } 172 | 173 | let done = this.getDone( 174 | total, 175 | objects.length, 176 | options?.cursor, 177 | options?.limit, 178 | ); 179 | 180 | if (done) { 181 | return Promise.resolve({ 182 | objects, 183 | truncated: false, 184 | cursor: null, 185 | delimitedPrefixes: [], 186 | }); 187 | } 188 | 189 | let cursor = this.getCursor(options?.cursor, options?.limit); 190 | 191 | return Promise.resolve({ 192 | objects, 193 | truncated: true, 194 | cursor, 195 | delimitedPrefixes: [], 196 | }); 197 | }); 198 | 199 | put = mock().mockImplementation((key, value, options) => { 200 | let objectBody: R2ObjectBody = { 201 | key, 202 | size: this.getSize(value), 203 | etag: "mock-etag", 204 | httpMetadata: options?.httpMetadata as R2HTTPMetadata | undefined, 205 | customMetadata: options?.customMetadata, 206 | uploaded: new Date(), 207 | version: "mock-version", 208 | httpEtag: "mock-httpEtag", 209 | checksums: { 210 | toJSON: () => ({}), 211 | }, 212 | storageClass: "mock-storageClass", 213 | writeHttpMetadata: () => void 0, 214 | body: new ReadableStream(), 215 | bodyUsed: false, 216 | arrayBuffer() { 217 | throw new Error("Method not implemented."); 218 | }, 219 | text(): Promise { 220 | throw new Error("Function not implemented."); 221 | }, 222 | json(): Promise { 223 | throw new Error("Function not implemented."); 224 | }, 225 | blob(): Promise { 226 | throw new Error("Function not implemented."); 227 | }, 228 | }; 229 | 230 | this.#data.set(key, objectBody); 231 | 232 | return Promise.resolve(objectBody); 233 | }); 234 | 235 | resumeMultipartUpload = mock< 236 | R2Bucket["resumeMultipartUpload"] 237 | >().mockImplementation((key, uploadId) => { 238 | return { 239 | key, 240 | uploadId, 241 | abort: () => Promise.resolve(), 242 | complete: () => 243 | Promise.resolve({ 244 | key, 245 | size: 0, 246 | etag: "mock-etag", 247 | httpMetadata: undefined, 248 | customMetadata: {}, 249 | uploaded: new Date(), 250 | version: "mock-version", 251 | httpEtag: "mock-httpEtag", 252 | checksums: { 253 | toJSON: () => ({}), 254 | }, 255 | storageClass: "mock-storageClass", 256 | writeHttpMetadata: () => void 0, 257 | body: new ReadableStream(), 258 | bodyUsed: false, 259 | arrayBuffer() { 260 | throw new Error("Method not implemented."); 261 | }, 262 | text(): Promise { 263 | throw new Error("Function not implemented."); 264 | }, 265 | json(): Promise { 266 | throw new Error("Function not implemented."); 267 | }, 268 | blob(): Promise { 269 | throw new Error("Function not implemented."); 270 | }, 271 | }), 272 | uploadPart: () => 273 | Promise.resolve({ 274 | partNumber: 1, 275 | etag: "mock-etag", 276 | }), 277 | }; 278 | }); 279 | 280 | private getDone(total: number, length: number, cursor?: string, limit = 10) { 281 | if (total === length) return true; 282 | if (cursor) return Number(cursor) + limit >= total; 283 | return false; 284 | } 285 | 286 | private getCursor(cursor?: string, limit = 10) { 287 | if (cursor) return String(Number(cursor) + limit); 288 | return String(limit); 289 | } 290 | 291 | private getSize( 292 | value: 293 | | string 294 | | ReadableStream 295 | | ArrayBufferView 296 | | ArrayBuffer 297 | | Blob 298 | | null, 299 | ) { 300 | if (typeof value === "string") return value.length; 301 | if (value instanceof ArrayBuffer) return value.byteLength; 302 | if (ArrayBuffer.isView(value)) return value.byteLength; 303 | if (value instanceof Blob) return value.size; 304 | return 0; 305 | } 306 | } 307 | 308 | export class MockQueue implements Queue { 309 | send = mock().mockImplementation( 310 | (message: unknown, options?: QueueSendOptions) => { 311 | throw new Error("Method not implemented."); 312 | }, 313 | ); 314 | 315 | sendBatch = mock().mockImplementation( 316 | ( 317 | messages: Iterable>, 318 | options?: QueueSendBatchOptions, 319 | ) => { 320 | throw new Error("Method not implemented."); 321 | }, 322 | ); 323 | } 324 | -------------------------------------------------------------------------------- /src/worker.ts: -------------------------------------------------------------------------------- 1 | import type { 2 | ExportedHandler, 3 | Message, 4 | MessageBatch, 5 | Request, 6 | Response, 7 | ScheduledController, 8 | } from "@cloudflare/workers-types"; 9 | import type { Data } from "@edgefirst-dev/data"; 10 | import type { WorkerKVRateLimit } from "@edgefirst-dev/worker-kv-rate-limit"; 11 | import type { Logger } from "drizzle-orm"; 12 | import type { Job } from "./lib/jobs/job.js"; 13 | import { JobsManager } from "./lib/jobs/manager.js"; 14 | import { storage } from "./lib/storage/storage.js"; 15 | import { TaskManager } from "./lib/tasks/manager.js"; 16 | import type { Task } from "./lib/tasks/task.js"; 17 | import type { DatabaseSchema, Environment } from "./lib/types.js"; 18 | 19 | export function bootstrap( 20 | options: bootstrap.Options, 21 | ): ExportedHandler { 22 | return { 23 | async fetch(request, env, ctx) { 24 | return storage.setup({ request, env, ctx, options }, () => { 25 | return options.onRequest(request, env, ctx); 26 | }); 27 | }, 28 | 29 | scheduled(event, env, ctx) { 30 | return storage.setup({ env, ctx, options }, () => { 31 | if (options.onSchedule) { 32 | return options.onSchedule(event, env, ctx); 33 | } 34 | 35 | let manager = new TaskManager(options.tasks?.() ?? []); 36 | manager.process(event); 37 | }); 38 | }, 39 | 40 | queue(batch, env, ctx) { 41 | return storage.setup({ env, ctx, options }, () => { 42 | if (options.onQueue) return options.onQueue(batch, env, ctx); 43 | 44 | let manager = new JobsManager(options.jobs?.() ?? []); 45 | 46 | manager.processBatch(batch, (error, message) => { 47 | console.info(error); 48 | message.retry(); 49 | }); 50 | }); 51 | }, 52 | }; 53 | } 54 | 55 | export namespace bootstrap { 56 | export interface Options { 57 | /** The options for the ORM. */ 58 | orm?: { 59 | /** The database schema for the ORM. */ 60 | schema: DatabaseSchema; 61 | /** The logger for the ORM. */ 62 | logger?: Logger; 63 | }; 64 | 65 | /** The options for the rate limit. */ 66 | rateLimit?: WorkerKVRateLimit.Options; 67 | 68 | /** A function that returns the list of jobs to register */ 69 | jobs?(): Job[]; 70 | 71 | /** A function that returns the list of tasks to register */ 72 | tasks?(): Task[]; 73 | 74 | /** A function that will run if a job failed */ 75 | onJobError?(error: unknown, message: Message): void; 76 | 77 | /** A function that will run if a task failed */ 78 | onTaskError?(error: unknown, task: Task): void; 79 | 80 | /** The function that will run every time a new request comes in */ 81 | onRequest( 82 | request: Request, 83 | env: Environment, 84 | ctx: ExecutionContext, 85 | ): Promise; 86 | 87 | /** The function that will run every time a scheduled task is executed */ 88 | onSchedule?( 89 | event: ScheduledController, 90 | env: Environment, 91 | ctx: ExecutionContext, 92 | ): Promise; 93 | 94 | /** The function that will run every time a queue message is consumed */ 95 | onQueue?( 96 | batch: MessageBatch, 97 | env: Environment, 98 | ctx: ExecutionContext, 99 | ): Promise; 100 | } 101 | } 102 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | /* Change to `@total-typescript/tsconfig/tsc/dom/library` for DOM usage */ 3 | "extends": "@total-typescript/tsconfig/tsc/no-dom/library", 4 | "include": ["src/**/*"], 5 | "exclude": ["src/**/*.test.*"], 6 | "compilerOptions": { 7 | "outDir": "./build", 8 | "types": ["@cloudflare/workers-types", "@types/bun"] 9 | } 10 | } 11 | -------------------------------------------------------------------------------- /typedoc.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://typedoc.org/schema.json", 3 | "includeVersion": true, 4 | "entryPoints": ["./src/index.ts", "./src/worker.ts"], 5 | "projectDocuments": ["./docs/**.md", "./docs/**/index.md"], 6 | "out": "pages", 7 | "json": "pages/index.json", 8 | "cleanOutputDir": true, 9 | "plugin": ["typedoc-plugin-mdn-links"], 10 | "categorizeByGroup": true, 11 | "excludeInternal": true, 12 | "navigationLinks": { 13 | "GitHub": "https://github.com/edgefirst-dev/kit" 14 | } 15 | } 16 | --------------------------------------------------------------------------------