From 7df57522d2198911e435b6e3a4d4ec5fa86e078b Mon Sep 17 00:00:00 2001 From: kodemon Date: Fri, 18 Apr 2025 20:18:50 +0000 Subject: [PATCH] feat: initial commit --- .gitignore | 1 + .npmrc | 1 + .vscode/settings.json | 10 + LICENSE | 16 + README.md | 87 +++++ adapters/http.ts | 16 + deno.json | 23 ++ deno.lock | 731 +++++++++++++++++++++++++++++++++++++++++ eslint.config.mjs | 30 ++ libraries/action.ts | 63 ++++ libraries/errors.ts | 227 +++++++++++++ libraries/relay.ts | 449 +++++++++++++++++++++++++ libraries/route.ts | 332 +++++++++++++++++++ mod.ts | 4 + package.json | 13 + tests/mocks/actions.ts | 13 + tests/mocks/relay.ts | 23 ++ tests/mocks/server.ts | 32 ++ tests/mocks/user.ts | 9 + tests/route.test.ts | 14 + 20 files changed, 2094 insertions(+) create mode 100644 .gitignore create mode 100644 .npmrc create mode 100644 .vscode/settings.json create mode 100644 LICENSE create mode 100644 README.md create mode 100644 adapters/http.ts create mode 100644 deno.json create mode 100644 deno.lock create mode 100644 eslint.config.mjs create mode 100644 libraries/action.ts create mode 100644 libraries/errors.ts create mode 100644 libraries/relay.ts create mode 100644 libraries/route.ts create mode 100644 mod.ts create mode 100644 package.json create mode 100644 tests/mocks/actions.ts create mode 100644 tests/mocks/relay.ts create mode 100644 tests/mocks/server.ts create mode 100644 tests/mocks/user.ts create mode 100644 tests/route.test.ts diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..b512c09 --- /dev/null +++ b/.gitignore @@ -0,0 +1 @@ +node_modules \ No newline at end of file diff --git a/.npmrc b/.npmrc new file mode 100644 index 0000000..691d217 --- /dev/null +++ b/.npmrc @@ -0,0 +1 @@ +@jsr:registry=https://npm.jsr.io \ No newline at end of file diff --git a/.vscode/settings.json b/.vscode/settings.json new file mode 100644 index 0000000..b2bb4f6 --- /dev/null +++ b/.vscode/settings.json @@ -0,0 +1,10 @@ +{ + "deno.enable": true, + "editor.formatOnSave": true, + "editor.codeActionsOnSave": { + "source.fixAll.eslint": "explicit" + }, + "[typescript]": { + "editor.defaultFormatter": "esbenp.prettier-vscode" + } +} diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..ed5c97f --- /dev/null +++ b/LICENSE @@ -0,0 +1,16 @@ +MIT License + +Copyright 2025 Christoffer Rødvik. + +Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the +Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the +Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. + +Commercial use is permitted, provided the Software is not sold, relicensed, or distributed as a stand-alone solution, whether in original or minimally modified form. +Use as part of a larger work, integrated product, or service is allowed. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A +PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION +OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. diff --git a/README.md b/README.md new file mode 100644 index 0000000..a9c7246 --- /dev/null +++ b/README.md @@ -0,0 +1,87 @@ +

+ +

+ +# Relay + +Relay is a full stack protocol for communicating between client and server. It is also built around the major HTTP methods allowing for creating public API endpoints. + +## Quick Start + +For this quick start guide we assume the following project setup: + +``` + api/ + relay/ + web/ +``` + +### Relay + +First we want to set up our relay space, from the structure above lets start by defining our route. + +```ts +import { route } from "@valkyr/relay"; +import z from "zod"; + +export default route + .post("/users") + .body( + z.object({ + name: z.string(), + email: z.string().check(z.email()), + }) + ) + .response(z.string()); +``` + +After creating our first route we mount it onto our relay instance. + +```ts +import { Relay } from "@valkyr/relay"; + +import route from "./path/to/route.ts"; + +export const relay = new Relay([ + route +]); +``` + +We have now finished defining our initial relay setup which we can now utilize in our `api` and `web` spaces. + +### API + +To be able to successfully execute our user create route we need to attach a handler in our `api`. Lets start off by defining our handler. + +```ts +import { UnprocessableContentError } from "@valkyr/relay"; + +import { relay } from "~project/relay/mod.ts"; + +relay + .route("POST", "/users") + .handle(async ({ name, email }) => { + const user = await db.users.insert({ name, email }); + if (user === undefined) { + return new UnprocessableContentError(); + } + return user.id; + }); +``` + +We now have a `POST` handler for the `/users` path. + +### Web + +Now that we have both our relay and api ready to recieve requests we can trigger a user creation request in our web application. + +```ts +import { relay } from "~project/relay/mod.ts" + +const userId = await relay.post("/users", { + name: "John Doe", + email: "john.doe@fixture.none" +}); + +console.log(userId); // => string +``` diff --git a/adapters/http.ts b/adapters/http.ts new file mode 100644 index 0000000..17a7a6c --- /dev/null +++ b/adapters/http.ts @@ -0,0 +1,16 @@ +import { RequestInput } from "../libraries/relay.ts"; +import { RelayAdapter } from "../mod.ts"; + +export const http: RelayAdapter = { + async fetch({ method, url, search, body }: RequestInput) { + const res = await fetch(`${url}${search}`, { method, body }); + const data = await res.text(); + if (res.status >= 400) { + throw new Error(data); + } + if (res.headers.get("content-type")?.includes("json")) { + return JSON.parse(data); + } + return data; + }, +}; diff --git a/deno.json b/deno.json new file mode 100644 index 0000000..9a19b9a --- /dev/null +++ b/deno.json @@ -0,0 +1,23 @@ +{ + "name": "@valkyr/relay", + "version": "0.1.0", + "exports": { + ".": "./mod.ts" + }, + "publish": { + "exclude": [ + ".github", + ".vscode", + ".gitignore", + "tests" + ] + }, + "tasks": { + "check": "deno check ./mod.ts", + "lint": "npx eslint -c eslint.config.mjs .", + "test": "deno test --allow-all", + "test:publish": "deno publish --dry-run", + "ncu": "npx ncu -u -p npm" + }, + "nodeModulesDir": "auto" +} diff --git a/deno.lock b/deno.lock new file mode 100644 index 0000000..a54e3c5 --- /dev/null +++ b/deno.lock @@ -0,0 +1,731 @@ +{ + "version": "4", + "specifiers": { + "npm:@jsr/std__assert@1.0.12": "1.0.12", + "npm:@jsr/std__testing@1.0.11": "1.0.11", + "npm:eslint-plugin-simple-import-sort@12.1.1": "12.1.1_eslint@9.24.0", + "npm:eslint@9.24.0": "9.24.0", + "npm:prettier@3.5.3": "3.5.3", + "npm:typescript-eslint@8.30.1": "8.30.1_eslint@9.24.0_typescript@5.8.3_@typescript-eslint+parser@8.30.1__eslint@9.24.0__typescript@5.8.3", + "npm:zod@next": "4.0.0-beta.20250417T043022" + }, + "npm": { + "@eslint-community/eslint-utils@4.6.1_eslint@9.24.0": { + "integrity": "sha512-KTsJMmobmbrFLe3LDh0PC2FXpcSYJt/MLjlkh/9LEnmKYLSYmT/0EW9JWANjeoemiuZrmogti0tW5Ch+qNUYDw==", + "dependencies": [ + "eslint", + "eslint-visitor-keys@3.4.3" + ] + }, + "@eslint-community/regexpp@4.12.1": { + "integrity": "sha512-CCZCDJuduB9OUkFkY2IgppNZMi2lBQgD2qzwXkEia16cge2pijY/aXi96CJMquDMn3nJdlPV1A5KrJEXwfLNzQ==" + }, + "@eslint/config-array@0.20.0": { + "integrity": "sha512-fxlS1kkIjx8+vy2SjuCB94q3htSNrufYTXubwiBFeaQHbH6Ipi43gFJq2zCMt6PHhImH3Xmr0NksKDvchWlpQQ==", + "dependencies": [ + "@eslint/object-schema", + "debug", + "minimatch@3.1.2" + ] + }, + "@eslint/config-helpers@0.2.1": { + "integrity": "sha512-RI17tsD2frtDu/3dmI7QRrD4bedNKPM08ziRYaC5AhkGrzIAJelm9kJU1TznK+apx6V+cqRz8tfpEeG3oIyjxw==" + }, + "@eslint/core@0.12.0": { + "integrity": "sha512-cmrR6pytBuSMTaBweKoGMwu3EiHiEC+DoyupPmlZ0HxBJBtIxwe+j/E4XPIKNx+Q74c8lXKPwYawBf5glsTkHg==", + "dependencies": [ + "@types/json-schema" + ] + }, + "@eslint/core@0.13.0": { + "integrity": "sha512-yfkgDw1KR66rkT5A8ci4irzDysN7FRpq3ttJolR88OqQikAWqwA8j5VZyas+vjyBNFIJ7MfybJ9plMILI2UrCw==", + "dependencies": [ + "@types/json-schema" + ] + }, + "@eslint/eslintrc@3.3.1": { + "integrity": "sha512-gtF186CXhIl1p4pJNGZw8Yc6RlshoePRvE0X91oPGb3vZ8pM3qOS9W9NGPat9LziaBV7XrJWGylNQXkGcnM3IQ==", + "dependencies": [ + "ajv", + "debug", + "espree", + "globals", + "ignore", + "import-fresh", + "js-yaml", + "minimatch@3.1.2", + "strip-json-comments" + ] + }, + "@eslint/js@9.24.0": { + "integrity": "sha512-uIY/y3z0uvOGX8cp1C2fiC4+ZmBhp6yZWkojtHL1YEMnRt1Y63HB9TM17proGEmeG7HeUY+UP36F0aknKYTpYA==" + }, + "@eslint/object-schema@2.1.6": { + "integrity": "sha512-RBMg5FRL0I0gs51M/guSAj5/e14VQ4tpZnQNWwuDT66P14I43ItmPfIZRhO9fUVIPOAQXU47atlywZ/czoqFPA==" + }, + "@eslint/plugin-kit@0.2.8": { + "integrity": "sha512-ZAoA40rNMPwSm+AeHpCq8STiNAwzWLJuP8Xv4CHIc9wv/PSuExjMrmjfYNj682vW0OOiZ1HKxzvjQr9XZIisQA==", + "dependencies": [ + "@eslint/core@0.13.0", + "levn" + ] + }, + "@humanfs/core@0.19.1": { + "integrity": "sha512-5DyQ4+1JEUzejeK1JGICcideyfUbGixgS9jNgex5nqkW+cY7WZhxBigmieN5Qnw9ZosSNVC9KQKyb+GUaGyKUA==" + }, + "@humanfs/node@0.16.6": { + "integrity": "sha512-YuI2ZHQL78Q5HbhDiBA1X4LmYdXCKCMQIfw0pw7piHJwyREFebJUvrQN4cMssyES6x+vfUbx1CIpaQUKYdQZOw==", + "dependencies": [ + "@humanfs/core", + "@humanwhocodes/retry@0.3.1" + ] + }, + "@humanwhocodes/module-importer@1.0.1": { + "integrity": "sha512-bxveV4V8v5Yb4ncFTT3rPSgZBOpCkjfK0y4oVVVJwIuDVBRMDXrPyXRL988i5ap9m9bnyEEjWfm5WkBmtffLfA==" + }, + "@humanwhocodes/retry@0.3.1": { + "integrity": "sha512-JBxkERygn7Bv/GbN5Rv8Ul6LVknS+5Bp6RgDC/O8gEBU/yeH5Ui5C/OlWrTb6qct7LjjfT6Re2NxB0ln0yYybA==" + }, + "@humanwhocodes/retry@0.4.2": { + "integrity": "sha512-xeO57FpIu4p1Ri3Jq/EXq4ClRm86dVF2z/+kvFnyqVYRavTZmaFaUBbWCOuuTh0o/g7DSsk6kc2vrS4Vl5oPOQ==" + }, + "@jsr/std__assert@1.0.12": { + "integrity": "sha512-9pmgjJhuljZCmLlbvsRV6aLT5+YCmhX/yIjaWYav7R7Vup2DOLAgpUOs4JkzRbwn7fdKYrwHT8+DjqPr7Ti8mg==", + "dependencies": [ + "@jsr/std__internal" + ] + }, + "@jsr/std__async@1.0.12": { + "integrity": "sha512-NUaSOcwMetVeVkIqet2Ammy2A5YxG8ViFxryBbTaC4h7l/cgAkU59U3zF58ek4Y8HZ0Nx5De7qBptPfp62kcgw==" + }, + "@jsr/std__data-structures@1.0.6": { + "integrity": "sha512-Ejc8mHLuoYxXLu2zPquvqijdgQ19OV+1DdVDrLc/Cg+tiuGh4Dq2FSnLiPINh4lO1AJ3XcZcYPx38RxdsZcCOg==" + }, + "@jsr/std__fs@1.0.16": { + "integrity": "sha512-xnqp8XqEFN+ttkERg9GG+AxyipSd+rfCquLPviF5ZSwN6oCV1TM0ZNoKHXNk/EJAsz28YjF4sfgdJt8XwTV2UQ==", + "dependencies": [ + "@jsr/std__path" + ] + }, + "@jsr/std__internal@1.0.6": { + "integrity": "sha512-1NLtCx9XAL44nt56gzmRSCgXjIthHVzK62fTkJdq8/XsP7eN9a21AZDpc0EGJ/cgvmmOB52UGh46OuKrrY7eVg==" + }, + "@jsr/std__path@1.0.8": { + "integrity": "sha512-eNBGlh/8ZVkMxtFH4bwIzlAeKoHYk5in4wrBZhi20zMdOiuX4QozP4+19mIXBT2lzHDjhuVLyECbhFeR304iDg==" + }, + "@jsr/std__testing@1.0.11": { + "integrity": "sha512-pqQDYtIsaDf+x4NHQ+WiixRJ8DfhgFQRdlHWWssFAzIYwleR+VHLTNlgsgg+AH3mIIR+gTkBmKk21hTkM/WbMQ==", + "dependencies": [ + "@jsr/std__assert", + "@jsr/std__async", + "@jsr/std__data-structures", + "@jsr/std__fs", + "@jsr/std__internal", + "@jsr/std__path" + ] + }, + "@nodelib/fs.scandir@2.1.5": { + "integrity": "sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==", + "dependencies": [ + "@nodelib/fs.stat", + "run-parallel" + ] + }, + "@nodelib/fs.stat@2.0.5": { + "integrity": "sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A==" + }, + "@nodelib/fs.walk@1.2.8": { + "integrity": "sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg==", + "dependencies": [ + "@nodelib/fs.scandir", + "fastq" + ] + }, + "@types/estree@1.0.7": { + "integrity": "sha512-w28IoSUCJpidD/TGviZwwMJckNESJZXFu7NBZ5YJ4mEUnNraUn9Pm8HSZm/jDF1pDWYKspWE7oVphigUPRakIQ==" + }, + "@types/json-schema@7.0.15": { + "integrity": "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==" + }, + "@typescript-eslint/eslint-plugin@8.30.1_@typescript-eslint+parser@8.30.1__eslint@9.24.0__typescript@5.8.3_eslint@9.24.0_typescript@5.8.3": { + "integrity": "sha512-v+VWphxMjn+1t48/jO4t950D6KR8JaJuNXzi33Ve6P8sEmPr5k6CEXjdGwT6+LodVnEa91EQCtwjWNUCPweo+Q==", + "dependencies": [ + "@eslint-community/regexpp", + "@typescript-eslint/parser", + "@typescript-eslint/scope-manager", + "@typescript-eslint/type-utils", + "@typescript-eslint/utils", + "@typescript-eslint/visitor-keys", + "eslint", + "graphemer", + "ignore", + "natural-compare", + "ts-api-utils", + "typescript" + ] + }, + "@typescript-eslint/parser@8.30.1_eslint@9.24.0_typescript@5.8.3": { + "integrity": "sha512-H+vqmWwT5xoNrXqWs/fesmssOW70gxFlgcMlYcBaWNPIEWDgLa4W9nkSPmhuOgLnXq9QYgkZ31fhDyLhleCsAg==", + "dependencies": [ + "@typescript-eslint/scope-manager", + "@typescript-eslint/types", + "@typescript-eslint/typescript-estree", + "@typescript-eslint/visitor-keys", + "debug", + "eslint", + "typescript" + ] + }, + "@typescript-eslint/scope-manager@8.30.1": { + "integrity": "sha512-+C0B6ChFXZkuaNDl73FJxRYT0G7ufVPOSQkqkpM/U198wUwUFOtgo1k/QzFh1KjpBitaK7R1tgjVz6o9HmsRPg==", + "dependencies": [ + "@typescript-eslint/types", + "@typescript-eslint/visitor-keys" + ] + }, + "@typescript-eslint/type-utils@8.30.1_eslint@9.24.0_typescript@5.8.3": { + "integrity": "sha512-64uBF76bfQiJyHgZISC7vcNz3adqQKIccVoKubyQcOnNcdJBvYOILV1v22Qhsw3tw3VQu5ll8ND6hycgAR5fEA==", + "dependencies": [ + "@typescript-eslint/typescript-estree", + "@typescript-eslint/utils", + "debug", + "eslint", + "ts-api-utils", + "typescript" + ] + }, + "@typescript-eslint/types@8.30.1": { + "integrity": "sha512-81KawPfkuulyWo5QdyG/LOKbspyyiW+p4vpn4bYO7DM/hZImlVnFwrpCTnmNMOt8CvLRr5ojI9nU1Ekpw4RcEw==" + }, + "@typescript-eslint/typescript-estree@8.30.1_typescript@5.8.3": { + "integrity": "sha512-kQQnxymiUy9tTb1F2uep9W6aBiYODgq5EMSk6Nxh4Z+BDUoYUSa029ISs5zTzKBFnexQEh71KqwjKnRz58lusQ==", + "dependencies": [ + "@typescript-eslint/types", + "@typescript-eslint/visitor-keys", + "debug", + "fast-glob", + "is-glob", + "minimatch@9.0.5", + "semver", + "ts-api-utils", + "typescript" + ] + }, + "@typescript-eslint/utils@8.30.1_eslint@9.24.0_typescript@5.8.3": { + "integrity": "sha512-T/8q4R9En2tcEsWPQgB5BQ0XJVOtfARcUvOa8yJP3fh9M/mXraLxZrkCfGb6ChrO/V3W+Xbd04RacUEqk1CFEQ==", + "dependencies": [ + "@eslint-community/eslint-utils", + "@typescript-eslint/scope-manager", + "@typescript-eslint/types", + "@typescript-eslint/typescript-estree", + "eslint", + "typescript" + ] + }, + "@typescript-eslint/visitor-keys@8.30.1": { + "integrity": "sha512-aEhgas7aJ6vZnNFC7K4/vMGDGyOiqWcYZPpIWrTKuTAlsvDNKy2GFDqh9smL+iq069ZvR0YzEeq0B8NJlLzjFA==", + "dependencies": [ + "@typescript-eslint/types", + "eslint-visitor-keys@4.2.0" + ] + }, + "@zod/core@0.6.2": { + "integrity": "sha512-KdH7bT0BRG1CvJ1LWH8oyNnkvLpjVZ5qVGpRu7Vq8WsFTKRDWfdr3rFfBYh8atZJSWDgD0ibhOyff1AyRvG1DA==" + }, + "acorn-jsx@5.3.2_acorn@8.14.1": { + "integrity": "sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==", + "dependencies": [ + "acorn" + ] + }, + "acorn@8.14.1": { + "integrity": "sha512-OvQ/2pUDKmgfCg++xsTX1wGxfTaszcHVcTctW4UJB4hibJx2HXxxO5UmVgyjMa+ZDsiaf5wWLXYpRWMmBI0QHg==" + }, + "ajv@6.12.6": { + "integrity": "sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==", + "dependencies": [ + "fast-deep-equal", + "fast-json-stable-stringify", + "json-schema-traverse", + "uri-js" + ] + }, + "ansi-styles@4.3.0": { + "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", + "dependencies": [ + "color-convert" + ] + }, + "argparse@2.0.1": { + "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==" + }, + "balanced-match@1.0.2": { + "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==" + }, + "brace-expansion@1.1.11": { + "integrity": "sha512-iCuPHDFgrHX7H2vEI/5xpz07zSHB00TpugqhmYtVmMO6518mCuRMoOYFldEBl0g187ufozdaHgWKcYFb61qGiA==", + "dependencies": [ + "balanced-match", + "concat-map" + ] + }, + "brace-expansion@2.0.1": { + "integrity": "sha512-XnAIvQ8eM+kC6aULx6wuQiwVsnzsi9d3WxzV3FpWTGA19F621kwdbsAcFKXgKUHZWsy+mY6iL1sHTxWEFCytDA==", + "dependencies": [ + "balanced-match" + ] + }, + "braces@3.0.3": { + "integrity": "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==", + "dependencies": [ + "fill-range" + ] + }, + "callsites@3.1.0": { + "integrity": "sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==" + }, + "chalk@4.1.2": { + "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", + "dependencies": [ + "ansi-styles", + "supports-color" + ] + }, + "color-convert@2.0.1": { + "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", + "dependencies": [ + "color-name" + ] + }, + "color-name@1.1.4": { + "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==" + }, + "concat-map@0.0.1": { + "integrity": "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==" + }, + "cross-spawn@7.0.6": { + "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", + "dependencies": [ + "path-key", + "shebang-command", + "which" + ] + }, + "debug@4.4.0": { + "integrity": "sha512-6WTZ/IxCY/T6BALoZHaE4ctp9xm+Z5kY/pzYaCHRFeyVhojxlrm+46y68HA6hr0TcwEssoxNiDEUJQjfPZ/RYA==", + "dependencies": [ + "ms" + ] + }, + "deep-is@0.1.4": { + "integrity": "sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==" + }, + "escape-string-regexp@4.0.0": { + "integrity": "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==" + }, + "eslint-plugin-simple-import-sort@12.1.1_eslint@9.24.0": { + "integrity": "sha512-6nuzu4xwQtE3332Uz0to+TxDQYRLTKRESSc2hefVT48Zc8JthmN23Gx9lnYhu0FtkRSL1oxny3kJ2aveVhmOVA==", + "dependencies": [ + "eslint" + ] + }, + "eslint-scope@8.3.0": { + "integrity": "sha512-pUNxi75F8MJ/GdeKtVLSbYg4ZI34J6C0C7sbL4YOp2exGwen7ZsuBqKzUhXd0qMQ362yET3z+uPwKeg/0C2XCQ==", + "dependencies": [ + "esrecurse", + "estraverse" + ] + }, + "eslint-visitor-keys@3.4.3": { + "integrity": "sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag==" + }, + "eslint-visitor-keys@4.2.0": { + "integrity": "sha512-UyLnSehNt62FFhSwjZlHmeokpRK59rcz29j+F1/aDgbkbRTk7wIc9XzdoasMUbRNKDM0qQt/+BJ4BrpFeABemw==" + }, + "eslint@9.24.0": { + "integrity": "sha512-eh/jxIEJyZrvbWRe4XuVclLPDYSYYYgLy5zXGGxD6j8zjSAxFEzI2fL/8xNq6O2yKqVt+eF2YhV+hxjV6UKXwQ==", + "dependencies": [ + "@eslint-community/eslint-utils", + "@eslint-community/regexpp", + "@eslint/config-array", + "@eslint/config-helpers", + "@eslint/core@0.12.0", + "@eslint/eslintrc", + "@eslint/js", + "@eslint/plugin-kit", + "@humanfs/node", + "@humanwhocodes/module-importer", + "@humanwhocodes/retry@0.4.2", + "@types/estree", + "@types/json-schema", + "ajv", + "chalk", + "cross-spawn", + "debug", + "escape-string-regexp", + "eslint-scope", + "eslint-visitor-keys@4.2.0", + "espree", + "esquery", + "esutils", + "fast-deep-equal", + "file-entry-cache", + "find-up", + "glob-parent@6.0.2", + "ignore", + "imurmurhash", + "is-glob", + "json-stable-stringify-without-jsonify", + "lodash.merge", + "minimatch@3.1.2", + "natural-compare", + "optionator" + ] + }, + "espree@10.3.0_acorn@8.14.1": { + "integrity": "sha512-0QYC8b24HWY8zjRnDTL6RiHfDbAWn63qb4LMj1Z4b076A4une81+z03Kg7l7mn/48PUTqoLptSXez8oknU8Clg==", + "dependencies": [ + "acorn", + "acorn-jsx", + "eslint-visitor-keys@4.2.0" + ] + }, + "esquery@1.6.0": { + "integrity": "sha512-ca9pw9fomFcKPvFLXhBKUK90ZvGibiGOvRJNbjljY7s7uq/5YO4BOzcYtJqExdx99rF6aAcnRxHmcUHcz6sQsg==", + "dependencies": [ + "estraverse" + ] + }, + "esrecurse@4.3.0": { + "integrity": "sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag==", + "dependencies": [ + "estraverse" + ] + }, + "estraverse@5.3.0": { + "integrity": "sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==" + }, + "esutils@2.0.3": { + "integrity": "sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==" + }, + "fast-deep-equal@3.1.3": { + "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==" + }, + "fast-glob@3.3.3": { + "integrity": "sha512-7MptL8U0cqcFdzIzwOTHoilX9x5BrNqye7Z/LuC7kCMRio1EMSyqRK3BEAUD7sXRq4iT4AzTVuZdhgQ2TCvYLg==", + "dependencies": [ + "@nodelib/fs.stat", + "@nodelib/fs.walk", + "glob-parent@5.1.2", + "merge2", + "micromatch" + ] + }, + "fast-json-stable-stringify@2.1.0": { + "integrity": "sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==" + }, + "fast-levenshtein@2.0.6": { + "integrity": "sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==" + }, + "fastq@1.19.1": { + "integrity": "sha512-GwLTyxkCXjXbxqIhTsMI2Nui8huMPtnxg7krajPJAjnEG/iiOS7i+zCtWGZR9G0NBKbXKh6X9m9UIsYX/N6vvQ==", + "dependencies": [ + "reusify" + ] + }, + "file-entry-cache@8.0.0": { + "integrity": "sha512-XXTUwCvisa5oacNGRP9SfNtYBNAMi+RPwBFmblZEF7N7swHYQS6/Zfk7SRwx4D5j3CH211YNRco1DEMNVfZCnQ==", + "dependencies": [ + "flat-cache" + ] + }, + "fill-range@7.1.1": { + "integrity": "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==", + "dependencies": [ + "to-regex-range" + ] + }, + "find-up@5.0.0": { + "integrity": "sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==", + "dependencies": [ + "locate-path", + "path-exists" + ] + }, + "flat-cache@4.0.1": { + "integrity": "sha512-f7ccFPK3SXFHpx15UIGyRJ/FJQctuKZ0zVuN3frBo4HnK3cay9VEW0R6yPYFHC0AgqhukPzKjq22t5DmAyqGyw==", + "dependencies": [ + "flatted", + "keyv" + ] + }, + "flatted@3.3.3": { + "integrity": "sha512-GX+ysw4PBCz0PzosHDepZGANEuFCMLrnRTiEy9McGjmkCQYwRq4A/X786G/fjM/+OjsWSU1ZrY5qyARZmO/uwg==" + }, + "glob-parent@5.1.2": { + "integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==", + "dependencies": [ + "is-glob" + ] + }, + "glob-parent@6.0.2": { + "integrity": "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==", + "dependencies": [ + "is-glob" + ] + }, + "globals@14.0.0": { + "integrity": "sha512-oahGvuMGQlPw/ivIYBjVSrWAfWLBeku5tpPE2fOPLi+WHffIWbuh2tCjhyQhTBPMf5E9jDEH4FOmTYgYwbKwtQ==" + }, + "graphemer@1.4.0": { + "integrity": "sha512-EtKwoO6kxCL9WO5xipiHTZlSzBm7WLT627TqC/uVRd0HKmq8NXyebnNYxDoBi7wt8eTWrUrKXCOVaFq9x1kgag==" + }, + "has-flag@4.0.0": { + "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==" + }, + "ignore@5.3.2": { + "integrity": "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==" + }, + "import-fresh@3.3.1": { + "integrity": "sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ==", + "dependencies": [ + "parent-module", + "resolve-from" + ] + }, + "imurmurhash@0.1.4": { + "integrity": "sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==" + }, + "is-extglob@2.1.1": { + "integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==" + }, + "is-glob@4.0.3": { + "integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==", + "dependencies": [ + "is-extglob" + ] + }, + "is-number@7.0.0": { + "integrity": "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==" + }, + "isexe@2.0.0": { + "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==" + }, + "js-yaml@4.1.0": { + "integrity": "sha512-wpxZs9NoxZaJESJGIZTyDEaYpl0FKSA+FB9aJiyemKhMwkxQg63h4T1KJgUGHpTqPDNRcmmYLugrRjJlBtWvRA==", + "dependencies": [ + "argparse" + ] + }, + "json-buffer@3.0.1": { + "integrity": "sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ==" + }, + "json-schema-traverse@0.4.1": { + "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==" + }, + "json-stable-stringify-without-jsonify@1.0.1": { + "integrity": "sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw==" + }, + "keyv@4.5.4": { + "integrity": "sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==", + "dependencies": [ + "json-buffer" + ] + }, + "levn@0.4.1": { + "integrity": "sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==", + "dependencies": [ + "prelude-ls", + "type-check" + ] + }, + "locate-path@6.0.0": { + "integrity": "sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==", + "dependencies": [ + "p-locate" + ] + }, + "lodash.merge@4.6.2": { + "integrity": "sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==" + }, + "merge2@1.4.1": { + "integrity": "sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==" + }, + "micromatch@4.0.8": { + "integrity": "sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==", + "dependencies": [ + "braces", + "picomatch" + ] + }, + "minimatch@3.1.2": { + "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==", + "dependencies": [ + "brace-expansion@1.1.11" + ] + }, + "minimatch@9.0.5": { + "integrity": "sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow==", + "dependencies": [ + "brace-expansion@2.0.1" + ] + }, + "ms@2.1.3": { + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==" + }, + "natural-compare@1.4.0": { + "integrity": "sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==" + }, + "optionator@0.9.4": { + "integrity": "sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==", + "dependencies": [ + "deep-is", + "fast-levenshtein", + "levn", + "prelude-ls", + "type-check", + "word-wrap" + ] + }, + "p-limit@3.1.0": { + "integrity": "sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==", + "dependencies": [ + "yocto-queue" + ] + }, + "p-locate@5.0.0": { + "integrity": "sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==", + "dependencies": [ + "p-limit" + ] + }, + "parent-module@1.0.1": { + "integrity": "sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==", + "dependencies": [ + "callsites" + ] + }, + "path-exists@4.0.0": { + "integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==" + }, + "path-key@3.1.1": { + "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==" + }, + "picomatch@2.3.1": { + "integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==" + }, + "prelude-ls@1.2.1": { + "integrity": "sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==" + }, + "prettier@3.5.3": { + "integrity": "sha512-QQtaxnoDJeAkDvDKWCLiwIXkTgRhwYDEQCghU9Z6q03iyek/rxRh/2lC3HB7P8sWT2xC/y5JDctPLBIGzHKbhw==" + }, + "punycode@2.3.1": { + "integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==" + }, + "queue-microtask@1.2.3": { + "integrity": "sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==" + }, + "resolve-from@4.0.0": { + "integrity": "sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==" + }, + "reusify@1.1.0": { + "integrity": "sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw==" + }, + "run-parallel@1.2.0": { + "integrity": "sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==", + "dependencies": [ + "queue-microtask" + ] + }, + "semver@7.7.1": { + "integrity": "sha512-hlq8tAfn0m/61p4BVRcPzIGr6LKiMwo4VM6dGi6pt4qcRkmNzTcWq6eCEjEh+qXjkMDvPlOFFSGwQjoEa6gyMA==" + }, + "shebang-command@2.0.0": { + "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", + "dependencies": [ + "shebang-regex" + ] + }, + "shebang-regex@3.0.0": { + "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==" + }, + "strip-json-comments@3.1.1": { + "integrity": "sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig==" + }, + "supports-color@7.2.0": { + "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", + "dependencies": [ + "has-flag" + ] + }, + "to-regex-range@5.0.1": { + "integrity": "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==", + "dependencies": [ + "is-number" + ] + }, + "ts-api-utils@2.1.0_typescript@5.8.3": { + "integrity": "sha512-CUgTZL1irw8u29bzrOD/nH85jqyc74D6SshFgujOIA7osm2Rz7dYH77agkx7H4FBNxDq7Cjf+IjaX/8zwFW+ZQ==", + "dependencies": [ + "typescript" + ] + }, + "type-check@0.4.0": { + "integrity": "sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==", + "dependencies": [ + "prelude-ls" + ] + }, + "typescript-eslint@8.30.1_eslint@9.24.0_typescript@5.8.3_@typescript-eslint+parser@8.30.1__eslint@9.24.0__typescript@5.8.3": { + "integrity": "sha512-D7lC0kcehVH7Mb26MRQi64LMyRJsj3dToJxM1+JVTl53DQSV5/7oUGWQLcKl1C1KnoVHxMMU2FNQMffr7F3Row==", + "dependencies": [ + "@typescript-eslint/eslint-plugin", + "@typescript-eslint/parser", + "@typescript-eslint/utils", + "eslint", + "typescript" + ] + }, + "typescript@5.8.3": { + "integrity": "sha512-p1diW6TqL9L07nNxvRMM7hMMw4c5XOo/1ibL4aAIGmSAt9slTE1Xgw5KWuof2uTOvCg9BY7ZRi+GaF+7sfgPeQ==" + }, + "uri-js@4.4.1": { + "integrity": "sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==", + "dependencies": [ + "punycode" + ] + }, + "which@2.0.2": { + "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", + "dependencies": [ + "isexe" + ] + }, + "word-wrap@1.2.5": { + "integrity": "sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==" + }, + "yocto-queue@0.1.0": { + "integrity": "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==" + }, + "zod@4.0.0-beta.20250417T043022": { + "integrity": "sha512-zjfYudLXPgHvRdCWzy/iJqhB6suE8tBqnGubbFHSkMvcknI4iexEP53QCO13FoC/EIALseuZReVykCY8yd/skA==", + "dependencies": [ + "@zod/core" + ] + } + }, + "workspace": { + "packageJson": { + "dependencies": [ + "npm:@jsr/std__assert@1.0.12", + "npm:@jsr/std__testing@1.0.11", + "npm:eslint-plugin-simple-import-sort@12.1.1", + "npm:eslint@9.24.0", + "npm:prettier@3.5.3", + "npm:typescript-eslint@8.30.1", + "npm:zod@next" + ] + } + } +} diff --git a/eslint.config.mjs b/eslint.config.mjs new file mode 100644 index 0000000..ff5356c --- /dev/null +++ b/eslint.config.mjs @@ -0,0 +1,30 @@ +import simpleImportSort from "eslint-plugin-simple-import-sort"; +import tseslint from "typescript-eslint"; + +export default [ + ...tseslint.configs.recommended, + { + plugins: { + "simple-import-sort": simpleImportSort, + }, + rules: { + "simple-import-sort/imports": "error", + "simple-import-sort/exports": "error", + }, + }, + { + files: ["**/*.ts"], + rules: { + "@typescript-eslint/ban-ts-comment": ["error", { + "ts-expect-error": "allow-with-description", + minimumDescriptionLength: 10, + }], + "@typescript-eslint/ban-types": "off", + "@typescript-eslint/no-explicit-any": "off", + "@typescript-eslint/no-unused-vars": ["error", { + argsIgnorePattern: "^_", + varsIgnorePattern: "^_", + }], + }, + }, +]; diff --git a/libraries/action.ts b/libraries/action.ts new file mode 100644 index 0000000..5a69064 --- /dev/null +++ b/libraries/action.ts @@ -0,0 +1,63 @@ +import z, { ZodObject, ZodRawShape } from "zod"; + +export class Action { + constructor(readonly state: TActionState) {} + + /** + * Input object required by the action to fulfill its function. + * + * @param input - Schema defining the input requirements of the action. + */ + input(input: TInput): Action & { input: ZodObject }> { + return new Action({ ...this.state, input: z.object(input) as any }); + } + + /** + * Output object defining the result shape of the action. + * + * @param output - Schema defining the result shape. + */ + output(output: TOutput): Action & { output: ZodObject }> { + return new Action({ ...this.state, output: z.object(output) as any }); + } + + /** + * Add handler method to the action. + * + * @param handle - Handler method. + */ + handle>( + handle: THandleFn, + ): Action & { handle: THandleFn }> { + return new Action({ ...this.state, handle }); + } +} + +/* + |-------------------------------------------------------------------------------- + | Factory + |-------------------------------------------------------------------------------- + */ + +export const action = { + make(name: string) { + return new Action({ name }); + }, +}; + +/* + |-------------------------------------------------------------------------------- + | Types + |-------------------------------------------------------------------------------- + */ + +type ActionState = { + name: string; + input?: ZodObject; + output?: ZodObject; + handle?: ActionHandlerFn; +}; + +type ActionHandlerFn = TInput extends ZodObject + ? (input: z.infer) => TOutput extends ZodObject ? Promise> : Promise + : () => TOutput extends ZodObject ? Promise> : Promise; diff --git a/libraries/errors.ts b/libraries/errors.ts new file mode 100644 index 0000000..9fd6683 --- /dev/null +++ b/libraries/errors.ts @@ -0,0 +1,227 @@ +export abstract class RelayError extends Error { + constructor( + message: string, + readonly status: number, + readonly data?: D, + ) { + super(message); + } + + toJSON() { + return { + status: this.status, + message: this.message, + data: this.data, + }; + } +} + +export class BadRequestError extends RelayError { + /** + * Instantiate a new BadRequestError. + * + * The **HTTP 400 Bad Request** response status code indicates that the server + * cannot or will not process the request due to something that is perceived to + * be a client error. + * + * @param data - Optional data to send with the error. + */ + constructor(message = "Bad Request", data?: D) { + super(message, 400, data); + } +} + +export class UnauthorizedError extends RelayError { + /** + * Instantiate a new UnauthorizedError. + * + * The **HTTP 401 Unauthorized** response status code indicates that the client + * request has not been completed because it lacks valid authentication + * credentials for the requested resource. + * + * This status code is sent with an HTTP WWW-Authenticate response header that + * contains information on how the client can request for the resource again after + * prompting the user for authentication credentials. + * + * This status code is similar to the **403 Forbidden** status code, except that + * in situations resulting in this status code, user authentication can allow + * access to the resource. + * + * @see https://developer.mozilla.org/en-US/docs/Web/HTTP/Status/401 + * + * @param message - Optional message to send with the error. Default: "Unauthorized". + * @param data - Optional data to send with the error. + */ + constructor(message = "Unauthorized", data?: D) { + super(message, 401, data); + } +} + +export class ForbiddenError extends RelayError { + /** + * Instantiate a new ForbiddenError. + * + * The **HTTP 403 Forbidden** response status code indicates that the server + * understands the request but refuses to authorize it. + * + * This status is similar to **401**, but for the **403 Forbidden** status code + * re-authenticating makes no difference. The access is permanently forbidden and + * tied to the application logic, such as insufficient rights to a resource. + * + * @see https://developer.mozilla.org/en-US/docs/Web/HTTP/Status/403 + * + * @param message - Optional message to send with the error. Default: "Forbidden". + * @param data - Optional data to send with the error. + */ + constructor(message = "Forbidden", data?: D) { + super(message, 403, data); + } +} + +export class NotFoundError extends RelayError { + /** + * Instantiate a new NotFoundError. + * + * The **HTTP 404 Not Found** response status code indicates that the server + * cannot find the requested resource. Links that lead to a 404 page are often + * called broken or dead links and can be subject to link rot. + * + * A 404 status code only indicates that the resource is missing: not whether the + * absence is temporary or permanent. If a resource is permanently removed, + * use the **410 _(Gone)_** status instead. + * + * @see https://developer.mozilla.org/en-US/docs/Web/HTTP/Status/404 + * + * @param message - Optional message to send with the error. Default: "Not Found". + * @param data - Optional data to send with the error. + */ + constructor(message = "Not Found", data?: D) { + super(message, 404, data); + } +} + +export class NotAcceptableError extends RelayError { + /** + * Instantiate a new NotAcceptableError. + * + * The **HTTP 406 Not Acceptable** client error response code indicates that the + * server cannot produce a response matching the list of acceptable values + * defined in the request, and that the server is unwilling to supply a default + * representation. + * + * @param message - Optional message to send with the error. Default: "Not Acceptable". + * @param data - Optional data to send with the error. + */ + constructor(message = "Not Acceptable", data?: D) { + super(message, 406, data); + } +} + +export class ConflictError extends RelayError { + /** + * Instantiate a new ConflictError. + * + * The **HTTP 409 Conflict** response status code indicates a request conflict + * with the current state of the target resource. + * + * Conflicts are most likely to occur in response to a PUT request. For example, + * you may get a 409 response when uploading a file that is older than the + * existing one on the server, resulting in a version control conflict. + * + * @see https://developer.mozilla.org/en-US/docs/Web/HTTP/Status/409 + * + * @param message - Optional message to send with the error. Default: "Conflict". + * @param data - Optional data to send with the error. + */ + constructor(message = "Conflict", data?: D) { + super(message, 409, data); + } +} + +export class GoneError extends RelayError { + /** + * Instantiate a new GoneError. + * + * The **HTTP 410 Gone** indicates that the target resource is no longer + * available at the origin server and that this condition is likely to be + * permanent. A 410 response is cacheable by default. + * + * Clients should not repeat requests for resources that return a 410 response, + * and website owners should remove or replace links that return this code. If + * server owners don't know whether this condition is temporary or permanent, + * a 404 status code should be used instead. + * + * @see https://developer.mozilla.org/en-US/docs/Web/HTTP/Status/410 + * + * @param message - Optional message to send with the error. Default: "Gone". + * @param data - Optional data to send with the error. + */ + constructor(message = "Gone", data?: D) { + super(message, 410, data); + } +} + +export class UnprocessableContentError extends RelayError { + /** + * Instantiate a new UnprocessableContentError. + * + * The **HTTP 422 Unprocessable Content** client error response status code + * indicates that the server understood the content type of the request entity, + * and the syntax of the request entity was correct, but it was unable to + * process the contained instructions. + * + * Clients that receive a 422 response should expect that repeating the request + * without modification will fail with the same error. + * + * @see https://developer.mozilla.org/en-US/docs/Web/HTTP/Status/422 + * + * @param message - Optional message to send with the error. Default: "Unprocessable Content". + * @param data - Optional data to send with the error. + */ + constructor(message = "Unprocessable Content", data?: D) { + super(message, 422, data); + } +} + +export class InternalServerError extends RelayError { + /** + * Instantiate a new InternalServerError. + * + * The **HTTP 500 Internal Server Error** server error response code indicates that + * the server encountered an unexpected condition that prevented it from fulfilling + * the request. + * + * This error response is a generic "catch-all" response. Usually, this indicates + * the server cannot find a better 5xx error code to response. Sometimes, server + * administrators log error responses like the 500 status code with more details + * about the request to prevent the error from happening again in the future. + * + * @see https://developer.mozilla.org/en-US/docs/Web/HTTP/Status/500 + * + * @param message - Optional message to send with the error. Default: "Internal Server Error". + * @param data - Optional data to send with the error. + */ + constructor(message = "Internal Server Error", data?: D) { + super(message, 500, data); + } +} + +export class ServiceUnavailableError extends RelayError { + /** + * Instantiate a new ServiceUnavailableError. + * + * The **HTTP 503 Service Unavailable** server error response status code indicates + * that the server is not ready to handle the request. + * + * This response should be used for temporary conditions and the Retry-After HTTP header + * should contain the estimated time for the recovery of the service, if possible. + * + * @see https://developer.mozilla.org/en-US/docs/Web/HTTP/Status/503 + * + * @param message - Optional message to send with the error. Default: "Service Unavailable". + * @param data - Optional data to send with the error. + */ + constructor(message = "Service Unavailable", data?: D) { + super(message, 503, data); + } +} diff --git a/libraries/relay.ts b/libraries/relay.ts new file mode 100644 index 0000000..75b0526 --- /dev/null +++ b/libraries/relay.ts @@ -0,0 +1,449 @@ +import z, { ZodType } from "zod"; + +import { BadRequestError, NotFoundError, RelayError } from "./errors.ts"; +import { Route, RouteMethod } from "./route.ts"; + +const SUPPORTED_MEHODS = ["GET", "POST", "PUT", "PATCH", "DELETE"]; + +export class Relay { + /** + * Route maps funneling registered routes to the specific methods supported by + * the relay instance. + */ + readonly routes: Routes = { + POST: [], + GET: [], + PUT: [], + PATCH: [], + DELETE: [], + }; + + /** + * List of paths in the '${method} ${path}' format allowing us to quickly throw + * errors if a duplicate route path is being added. + */ + readonly #paths = new Set(); + + /** + * Route index in the '${method} ${path}' format allowing for quick access to + * a specific route. + */ + readonly #index = new Map(); + + /** + * Instantiate a new Relay instance. + * + * @param config - Relay configuration to apply to the instance. + * @param routes - Routes to register with the instance. + */ + constructor( + readonly config: RelayConfig, + routes: TRoutes, + ) { + const methods: (keyof typeof this.routes)[] = []; + for (const route of routes) { + this.#validateRoutePath(route); + this.routes[route.method].push(route); + methods.push(route.method); + this.#index.set(`${route.method} ${route.path}`, route); + } + for (const method of methods) { + this.routes[method].sort(byStaticPriority); + } + } + + /* + |-------------------------------------------------------------------------------- + | Agnostic + |-------------------------------------------------------------------------------- + */ + + /** + * Retrieve a route for the given method/path combination which can be further extended + * for serving incoming third party requests. + * + * @param method - Method the route is registered for. + * @param path - Path the route is registered under. + * + * @examples + * + * ```ts + * const relay = new Relay([ + * route + * .post("/users") + * .body( + * z.object({ + * name: z.object({ family: z.string(), given: z.string() }), + * email: z.string().check(z.email()), + * }) + * ) + * ]); + * + * relay + * .route("POST", "/users") + * .actions([hasSessionUser, hasAccess("users", "create")]) + * .handle(async ({ name, email, sessionUserId }) => { + * // await db.users.insert({ name, email, createdBy: sessionUserId }); + * }) + * ``` + */ + route< + TMethod extends RouteMethod, + TPath extends Extract["state"]["path"], + TRoute extends Extract, + >(method: TMethod, path: TPath): TRoute { + const route = this.#index.get(`${method} ${path}`); + if (route === undefined) { + throw new Error(`Relay > Route not found at '${method} ${path}' index`); + } + return route as TRoute; + } + + /* + |-------------------------------------------------------------------------------- + | Client + |-------------------------------------------------------------------------------- + */ + + /** + * Send a "POST" request through the relay `fetch` adapter. + * + * @param path - Path to send request to. + * @param args - List of request arguments. + */ + async post< + TPath extends Extract["state"]["path"], + TRoute extends Extract, + >(path: TPath, ...args: TRoute["args"]): Promise> { + return this.#send("POST", path, args) as RelayResponse; + } + + /** + * Send a "GET" request through the relay `fetch` adapter. + * + * @param path - Path to send request to. + * @param args - List of request arguments. + */ + async get< + TPath extends Extract["state"]["path"], + TRoute extends Extract, + >(path: TPath, ...args: TRoute["args"]): Promise> { + return this.#send("GET", path, args) as RelayResponse; + } + + /** + * Send a "PUT" request through the relay `fetch` adapter. + * + * @param path - Path to send request to. + * @param args - List of request arguments. + */ + async put< + TPath extends Extract["state"]["path"], + TRoute extends Extract, + >(path: TPath, ...args: TRoute["args"]): Promise> { + return this.#send("PUT", path, args) as RelayResponse; + } + + /** + * Send a "PATCH" request through the relay `fetch` adapter. + * + * @param path - Path to send request to. + * @param args - List of request arguments. + */ + async patch< + TPath extends Extract["state"]["path"], + TRoute extends Extract, + >(path: TPath, ...args: TRoute["args"]): Promise> { + return this.#send("PATCH", path, args) as RelayResponse; + } + + /** + * Send a "DELETE" request through the relay `fetch` adapter. + * + * @param path - Path to send request to. + * @param args - List of request arguments. + */ + async delete< + TPath extends Extract["state"]["path"], + TRoute extends Extract, + >(path: TPath, ...args: TRoute["args"]): Promise> { + return this.#send("DELETE", path, args) as RelayResponse; + } + + /* + |-------------------------------------------------------------------------------- + | Server + |-------------------------------------------------------------------------------- + */ + + /** + * Handle a incoming fetch request. + * + * @param request - Fetch request to pass to a route handler. + */ + async handle(request: Request) { + const url = new URL(request.url); + + const matched = this.#resolve(request.method, request.url); + if (matched === undefined) { + return toResponse( + new NotFoundError(`Invalid routing path provided for ${request.url}`, { + method: request.method, + url: request.url, + }), + ); + } + + const { route, params } = matched; + + // ### Context + // Context is passed to every route handler and provides a suite of functionality + // and request data. + + const context = { + ...params, + ...toSearch(url.searchParams), + }; + + // ### Params + // If the route has params we want to coerce the values to the expected types. + + if (route.state.params !== undefined) { + const result = await route.state.params.safeParseAsync(context.params); + if (result.success === false) { + return toResponse(new BadRequestError("Invalid request params", z.prettifyError(result.error))); + } + context.params = result.data; + } + + // ### Query + // If the route has a query schema we need to validate and parse the query. + + if (route.state.search !== undefined) { + const result = await route.state.search.safeParseAsync(context.query ?? {}); + if (result.success === false) { + return toResponse(new BadRequestError("Invalid request query", z.prettifyError(result.error))); + } + context.query = result.data; + } + + // ### Body + // If the route has a body schema we need to validate and parse the body. + + const body: Record = {}; + + if (route.state.body !== undefined) { + const result = await route.state.body.safeParseAsync(body); + if (result.success === false) { + return toResponse(new BadRequestError("Invalid request body", z.prettifyError(result.error))); + } + context.body = result.data; + } + + // ### Actions + // Run through all assigned actions for the route. + + if (route.state.actions !== undefined) { + for (const action of route.state.actions) { + const result = (await action.state.input?.safeParseAsync(context)) ?? { success: true, data: {} }; + if (result.success === false) { + return toResponse(new BadRequestError("Invalid action input", z.prettifyError(result.error))); + } + const output = (await action.state.handle?.(result.data)) ?? {}; + for (const key in output) { + context[key] = output[key]; + } + } + } + + // ### Handler + // Execute the route handler and apply the result. + + return toResponse(await route.state.handle?.(context).catch((error) => error)); + } + + /** + * Attempt to resolve a route based on the given method and pathname. + * + * @param method - HTTP method. + * @param url - HTTP request url. + */ + #resolve(method: string, url: string): ResolvedRoute | undefined { + this.#assertMethod(method); + for (const route of this.routes[method]) { + if (route.match(url) === true) { + return { route, params: route.getParsedParams(url) }; + } + } + } + + #validateRoutePath(route: Route): void { + const path = `${route.method} ${route.path}`; + if (this.#paths.has(path)) { + throw new Error(`Router > Path ${path} already exists`); + } + this.#paths.add(path); + } + + async #send(method: RouteMethod, url: string, args: any[]) { + const route = this.route(method, url); + + // ### Input + + const input: RequestInput = { method, url, search: "" }; + + let index = 0; // argument incrementor + + if (route.state.params !== undefined) { + const params = args[index++] as { [key: string]: string }; + for (const key in params) { + input.url = input.url.replace(`:${key}`, params[key]); + } + } + + if (route.state.search !== undefined) { + const search = args[index++] as { [key: string]: string }; + const pieces: string[] = []; + for (const key in search) { + pieces.push(`${key}=${search[key]}`); + } + if (pieces.length > 0) { + input.search = `?${pieces.join("&")}`; + } + } + + if (route.state.body !== undefined) { + input.body = JSON.stringify(args[index++]); + } + + // ### Fetch + + const data = await this.config.adapter.fetch(input); + if (route.state.output !== undefined) { + return route.state.output.parse(data); + } + return data; + } + + #assertMethod(method: string): asserts method is RouteMethod { + if (!SUPPORTED_MEHODS.includes(method)) { + throw new Error(`Router > Unsupported method '${method}'`); + } + } +} + +/* + |-------------------------------------------------------------------------------- + | Helpers + |-------------------------------------------------------------------------------- + */ + +/** + * Sorting method for routes to ensure that static properties takes precedence + * for when a route is matched against incoming requests. + * + * @param a - Route A + * @param b - Route B + */ +function byStaticPriority(a: Route, b: Route) { + const aSegments = a.path.split("/"); + const bSegments = b.path.split("/"); + + const maxLength = Math.max(aSegments.length, bSegments.length); + + for (let i = 0; i < maxLength; i++) { + const aSegment = aSegments[i] || ""; + const bSegment = bSegments[i] || ""; + + const isADynamic = aSegment.startsWith(":"); + const isBDynamic = bSegment.startsWith(":"); + + if (isADynamic !== isBDynamic) { + return isADynamic ? 1 : -1; + } + + if (isADynamic === false && aSegment !== bSegment) { + return aSegment.localeCompare(bSegment); + } + } + + return a.path.localeCompare(b.path); +} + +/** + * Resolve and return query object from the provided search parameters, or undefined + * if the search parameters does not have any entries. + * + * @param searchParams - Search params to create a query object from. + */ +function toSearch(searchParams: URLSearchParams): object | undefined { + if (searchParams.size === 0) { + return undefined; + } + const result: Record = {}; + for (const [key, value] of searchParams.entries()) { + result[key] = value; + } + return result; +} + +/** + * Takes a server side request result and returns a fetch Response. + * + * @param result - Result to send back as a Response. + */ +function toResponse(result: object | RelayError | Response | void): Response { + if (result instanceof Response) { + return result; + } + if (result instanceof RelayError) { + return new Response(result.message, { + status: result.status, + }); + } + if (result === undefined) { + return new Response(null, { status: 204 }); + } + return new Response(JSON.stringify(result), { + status: 200, + headers: { + "content-type": "application/json", + }, + }); +} + +/* + |-------------------------------------------------------------------------------- + | Types + |-------------------------------------------------------------------------------- + */ + +type Routes = { + POST: Route[]; + GET: Route[]; + PUT: Route[]; + PATCH: Route[]; + DELETE: Route[]; +}; + +type ResolvedRoute = { + route: Route; + params: any; +}; + +type RelayResponse = TRoute["state"]["output"] extends ZodType ? z.infer : void; + +type RelayConfig = { + adapter: RelayAdapter; +}; + +export type RelayAdapter = { + fetch(input: RequestInput): Promise; +}; + +export type RequestInput = { + method: RouteMethod; + url: string; + search: string; + body?: string; +}; diff --git a/libraries/route.ts b/libraries/route.ts new file mode 100644 index 0000000..dbbc0e7 --- /dev/null +++ b/libraries/route.ts @@ -0,0 +1,332 @@ +import z, { ZodObject, ZodRawShape, ZodType } from "zod"; + +import { Action } from "./action.ts"; + +export class Route { + #pattern?: URLPattern; + + declare readonly args: RouteArgs; + declare readonly context: RouteContext; + + constructor(readonly state: TRouteState) {} + + /** + * HTTP Method + */ + get method(): RouteMethod { + return this.state.method; + } + + /** + * URL pattern of the route. + */ + get pattern(): URLPattern { + if (this.#pattern === undefined) { + this.#pattern = new URLPattern({ pathname: this.path }); + } + return this.#pattern; + } + + /** + * URL path + */ + get path(): string { + return this.state.path; + } + + /** + * Check if the provided URL matches the route pattern. + * + * @param url - HTTP request.url + */ + match(url: string): boolean { + return this.pattern.test(url); + } + + /** + * Extract parameters from the provided URL based on the route pattern. + * + * @param url - HTTP request.url + */ + getParsedParams(url: string): TRouteState["params"] extends ZodObject ? z.infer : object { + const params = this.pattern.exec(url)?.pathname.groups; + if (params === undefined) { + return {}; + } + return this.state.params?.parse(params) ?? params; + } + + /** + * Params allows for custom casting of URL parameters. If a parameter does not + * have a corresponding zod schema the default param type is "string". + * + * @param params - URL params. + * + * @examples + * + * ```ts + * route + * .post("/foo/:bar") + * .params({ + * bar: z.number({ coerce: true }) + * }) + * .handle(async ({ params: { bar } }) => { + * console.log(typeof bar); // => number + * }); + * ``` + */ + params(params: TParams): Route & { params: ZodObject }> { + return new Route({ ...this.state, params }) as any; + } + + /** + * Search allows for custom casting of URL search parameters. If a parameter does + * not have a corresponding zod schema the default param type is "string". + * + * @param search - URL search arguments. + * + * @examples + * + * ```ts + * route + * .post("/foo") + * .search({ + * bar: z.number({ coerce: true }) + * }) + * .handle(async ({ search: { bar } }) => { + * console.log(typeof bar); // => number + * }); + * ``` + */ + search(search: TSearch): Route & { search: ZodObject }> { + return new Route({ ...this.state, search }) as any; + } + + /** + * Shape of the body this route expects to receive. This is used by all + * mutator routes and has no effect when defined on "GET" methods. + * + * @param body - Body the route expects. + * + * @examples + * + * ```ts + * route + * .post("/foo") + * .body( + * z.object({ + * bar: z.number() + * }) + * ) + * .handle(async ({ bar }) => { + * console.log(typeof bar); // => number + * }); + * ``` + */ + body(body: TBody): Route & { body: TBody }> { + return new Route({ ...this.state, body }); + } + + /** + * List of route level middleware action to execute before running the + * route handler. + * + * @param actions - Actions to execute on this route. + * + * @examples + * + * ```ts + * const hasFooBar = action + * .make("hasFooBar") + * .response(z.object({ foobar: z.number() })) + * .handle(async () => { + * return { + * foobar: 1, + * }; + * }); + * + * route + * .post("/foo") + * .actions([hasFooBar]) + * .handle(async ({ foobar }) => { + * console.log(typeof foobar); // => number + * }); + * ``` + */ + actions(actions: TAction[]): Route & { actions: TAction[] }> { + return new Route({ ...this.state, actions }); + } + + /** + * Shape of the response this route produces. This is used by the transform + * tools to ensure the client receives parsed data. + * + * @param response - Response shape of the route. + * + * @examples + * + * ```ts + * route + * .post("/foo") + * .response( + * z.object({ + * bar: z.number() + * }) + * ) + * .handle(async () => { + * return { + * bar: 1 + * } + * }); + * ``` + */ + response(output: TResponse): Route & { output: TResponse }> { + return new Route({ ...this.state, output }); + } + + /** + * Server handler callback method. + * + * @param handle - Handle function to trigger when the route is executed. + */ + handle>(handle: THandleFn): Route & { handle: THandleFn }> { + return new Route({ ...this.state, handle }); + } +} + +/* + |-------------------------------------------------------------------------------- + | Factories + |-------------------------------------------------------------------------------- + */ + +/** + * Route factories allowing for easy generation of relay compliant routes. + */ +export const route = { + /** + * Create a new "POST" route for the given path. + * + * @param path - Path to generate route for. + * + * @examples + * + * ```ts + * route + * .post("/foo") + * .body( + * z.object({ bar: z.string() }) + * ); + * ``` + */ + post(path: TPath) { + return new Route({ method: "POST", path }); + }, + + /** + * Create a new "GET" route for the given path. + * + * @param path - Path to generate route for. + * + * @examples + * + * ```ts + * route.get("/foo"); + * ``` + */ + get(path: TPath) { + return new Route({ method: "GET", path }); + }, + + /** + * Create a new "PUT" route for the given path. + * + * @param path - Path to generate route for. + * + * @examples + * + * ```ts + * route + * .put("/foo") + * .body( + * z.object({ bar: z.string() }) + * ); + * ``` + */ + put(path: TPath) { + return new Route({ method: "PUT", path }); + }, + + /** + * Create a new "PATCH" route for the given path. + * + * @param path - Path to generate route for. + * + * @examples + * + * ```ts + * route + * .patch("/foo") + * .body( + * z.object({ bar: z.string() }) + * ); + * ``` + */ + patch(path: TPath) { + return new Route({ method: "PATCH", path }); + }, + + /** + * Create a new "DELETE" route for the given path. + * + * @param path - Path to generate route for. + * + * @examples + * + * ```ts + * route.delete("/foo"); + * ``` + */ + delete(path: TPath) { + return new Route({ method: "DELETE", path }); + }, +}; + +/* + |-------------------------------------------------------------------------------- + | Types + |-------------------------------------------------------------------------------- + */ + +type RouteState = { + method: RouteMethod; + path: string; + params?: ZodObject; + search?: ZodObject; + body?: ZodObject; + actions?: Array; + output?: ZodType; + handle?: HandleFn; +}; + +export type RouteMethod = "POST" | "GET" | "PUT" | "PATCH" | "DELETE"; + +export type HandleFn = (context: TContext) => TResponse extends ZodType ? Promise> : Promise; + +type RouteContext = (TRouteState["params"] extends ZodObject ? z.infer : object) & + (TRouteState["search"] extends ZodObject ? z.infer : object) & + (TRouteState["body"] extends ZodObject ? z.infer : object) & + (TRouteState["actions"] extends Array ? UnionToIntersection> : object); + +type RouteArgs = [ + ...TupleIfZod, + ...TupleIfZod, + ...TupleIfZod, +]; + +type TupleIfZod = TState extends ZodObject ? [z.infer] : []; + +type MergeAction> = + TActions[number] extends Action ? (TActionState["output"] extends ZodObject ? z.infer : object) : object; + +type UnionToIntersection = (U extends any ? (k: U) => void : never) extends (k: infer I) => void ? I : never; diff --git a/mod.ts b/mod.ts new file mode 100644 index 0000000..e965e70 --- /dev/null +++ b/mod.ts @@ -0,0 +1,4 @@ +export * from "./libraries/action.ts"; +export * from "./libraries/errors.ts"; +export * from "./libraries/relay.ts"; +export * from "./libraries/route.ts"; diff --git a/package.json b/package.json new file mode 100644 index 0000000..c7dee05 --- /dev/null +++ b/package.json @@ -0,0 +1,13 @@ +{ + "dependencies": { + "zod": "next" + }, + "devDependencies": { + "@std/assert": "npm:@jsr/std__assert@1.0.12", + "@std/testing": "npm:@jsr/std__testing@1.0.11", + "eslint": "9.24.0", + "eslint-plugin-simple-import-sort": "12.1.1", + "prettier": "3.5.3", + "typescript-eslint": "8.30.1" + } +} diff --git a/tests/mocks/actions.ts b/tests/mocks/actions.ts new file mode 100644 index 0000000..036904f --- /dev/null +++ b/tests/mocks/actions.ts @@ -0,0 +1,13 @@ +import z from "zod"; + +import { action } from "../../libraries/action.ts"; + +export const addTwoNumbers = action + .make("addTwoNumbers") + .input({ a: z.number(), b: z.number() }) + .output({ added: z.number() }) + .handle(async ({ a, b }) => { + return { + added: a + b, + }; + }); diff --git a/tests/mocks/relay.ts b/tests/mocks/relay.ts new file mode 100644 index 0000000..b660c0c --- /dev/null +++ b/tests/mocks/relay.ts @@ -0,0 +1,23 @@ +import z from "zod"; + +import { http } from "../../adapters/http.ts"; +import { Relay } from "../../libraries/relay.ts"; +import { route } from "../../libraries/route.ts"; +import { UserSchema } from "./user.ts"; + +export const relay = new Relay({ adapter: http }, [ + route + .post("/users") + .body(UserSchema.omit({ id: true })) + .response(z.string()), + route.get("/users").response(z.array(UserSchema)), + route + .get("/users/:userId") + .params({ userId: z.string().check(z.uuid()) }) + .response(UserSchema.or(z.undefined())), + route + .put("/users/:userId") + .params({ userId: z.string().check(z.uuid()) }) + .body(UserSchema.omit({ id: true })), + route.delete("/users/:userId").params({ userId: z.string().check(z.uuid()) }), +]); diff --git a/tests/mocks/server.ts b/tests/mocks/server.ts new file mode 100644 index 0000000..564f15a --- /dev/null +++ b/tests/mocks/server.ts @@ -0,0 +1,32 @@ +import { relay } from "./relay.ts"; +import { User } from "./user.ts"; + +export let users: User[] = []; + +relay.route("POST", "/users").handle(async ({ name, email }) => { + const id = crypto.randomUUID(); + users.push({ id, name, email }); + return id; +}); + +relay.route("GET", "/users").handle(async () => { + return users; +}); + +relay.route("GET", "/users/:userId").handle(async ({ userId }) => { + return users.find((user) => user.id === userId); +}); + +relay.route("PUT", "/users/:userId").handle(async ({ userId, name, email }) => { + for (const user of users) { + if (user.id === userId) { + user.name = name; + user.email = email; + break; + } + } +}); + +relay.route("DELETE", "/users/:userId").handle(async ({ userId }) => { + users = users.filter((user) => user.id === userId); +}); diff --git a/tests/mocks/user.ts b/tests/mocks/user.ts new file mode 100644 index 0000000..e9cc50f --- /dev/null +++ b/tests/mocks/user.ts @@ -0,0 +1,9 @@ +import z from "zod"; + +export const UserSchema = z.object({ + id: z.string().check(z.uuid()), + name: z.string(), + email: z.string().check(z.email()), +}); + +export type User = z.infer; diff --git a/tests/route.test.ts b/tests/route.test.ts new file mode 100644 index 0000000..800dbba --- /dev/null +++ b/tests/route.test.ts @@ -0,0 +1,14 @@ +import { assertEquals } from "@std/assert"; +import { describe, it } from "@std/testing/bdd"; + +import { relay } from "./mocks/relay.ts"; + +describe("Relay", () => { + it("should create a new user", async () => { + const userId = await relay.post("/users", { name: "John Doe", email: "john.doe@fixture.none" }); + + console.log({ userId }); + + assertEquals(typeof userId, "string"); + }); +});