feat: initial commit
This commit is contained in:
1
.gitignore
vendored
Normal file
1
.gitignore
vendored
Normal file
@@ -0,0 +1 @@
|
||||
node_modules
|
||||
10
.vscode/settings.json
vendored
Normal file
10
.vscode/settings.json
vendored
Normal file
@@ -0,0 +1,10 @@
|
||||
{
|
||||
"deno.enable": true,
|
||||
"editor.formatOnSave": true,
|
||||
"editor.codeActionsOnSave": {
|
||||
"source.fixAll.eslint": "explicit"
|
||||
},
|
||||
"[typescript]": {
|
||||
"editor.defaultFormatter": "esbenp.prettier-vscode"
|
||||
}
|
||||
}
|
||||
16
LICENSE
Normal file
16
LICENSE
Normal file
@@ -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.
|
||||
87
README.md
Normal file
87
README.md
Normal file
@@ -0,0 +1,87 @@
|
||||
<p align="center">
|
||||
<img src="https://user-images.githubusercontent.com/1998130/229430454-ca0f2811-d874-4314-b13d-c558de8eec7e.svg" />
|
||||
</p>
|
||||
|
||||
# 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
|
||||
```
|
||||
16
adapters/http.ts
Normal file
16
adapters/http.ts
Normal file
@@ -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;
|
||||
},
|
||||
};
|
||||
23
deno.json
Normal file
23
deno.json
Normal file
@@ -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"
|
||||
}
|
||||
731
deno.lock
generated
Normal file
731
deno.lock
generated
Normal file
@@ -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"
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
30
eslint.config.mjs
Normal file
30
eslint.config.mjs
Normal file
@@ -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: "^_",
|
||||
}],
|
||||
},
|
||||
},
|
||||
];
|
||||
63
libraries/action.ts
Normal file
63
libraries/action.ts
Normal file
@@ -0,0 +1,63 @@
|
||||
import z, { ZodObject, ZodRawShape } from "zod";
|
||||
|
||||
export class Action<TActionState extends ActionState = ActionState> {
|
||||
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<TInput extends ZodRawShape>(input: TInput): Action<Omit<TActionState, "input"> & { input: ZodObject<TInput> }> {
|
||||
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<TOutput extends ZodRawShape>(output: TOutput): Action<Omit<TActionState, "output"> & { output: ZodObject<TOutput> }> {
|
||||
return new Action({ ...this.state, output: z.object(output) as any });
|
||||
}
|
||||
|
||||
/**
|
||||
* Add handler method to the action.
|
||||
*
|
||||
* @param handle - Handler method.
|
||||
*/
|
||||
handle<THandleFn extends ActionHandlerFn<this["state"]["input"], this["state"]["output"]>>(
|
||||
handle: THandleFn,
|
||||
): Action<Omit<TActionState, "handle"> & { 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 = any, TOutput = any> = TInput extends ZodObject
|
||||
? (input: z.infer<TInput>) => TOutput extends ZodObject ? Promise<z.infer<TOutput>> : Promise<void>
|
||||
: () => TOutput extends ZodObject ? Promise<z.infer<TOutput>> : Promise<void>;
|
||||
227
libraries/errors.ts
Normal file
227
libraries/errors.ts
Normal file
@@ -0,0 +1,227 @@
|
||||
export abstract class RelayError<D = unknown> 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<D = unknown> extends RelayError<D> {
|
||||
/**
|
||||
* 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<D = unknown> extends RelayError<D> {
|
||||
/**
|
||||
* 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<D = unknown> extends RelayError<D> {
|
||||
/**
|
||||
* 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<D = unknown> extends RelayError<D> {
|
||||
/**
|
||||
* 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<D = unknown> extends RelayError<D> {
|
||||
/**
|
||||
* 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<D = unknown> extends RelayError<D> {
|
||||
/**
|
||||
* 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<D = unknown> extends RelayError<D> {
|
||||
/**
|
||||
* 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<D = unknown> extends RelayError<D> {
|
||||
/**
|
||||
* 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<D = unknown> extends RelayError<D> {
|
||||
/**
|
||||
* 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<D = unknown> extends RelayError<D> {
|
||||
/**
|
||||
* 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);
|
||||
}
|
||||
}
|
||||
449
libraries/relay.ts
Normal file
449
libraries/relay.ts
Normal file
@@ -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<TRoutes extends Route[]> {
|
||||
/**
|
||||
* 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<string>();
|
||||
|
||||
/**
|
||||
* Route index in the '${method} ${path}' format allowing for quick access to
|
||||
* a specific route.
|
||||
*/
|
||||
readonly #index = new Map<string, Route>();
|
||||
|
||||
/**
|
||||
* 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<TRoutes[number], { state: { method: TMethod } }>["state"]["path"],
|
||||
TRoute extends Extract<TRoutes[number], { state: { method: TMethod; path: TPath } }>,
|
||||
>(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<TRoutes[number], { state: { method: "POST" } }>["state"]["path"],
|
||||
TRoute extends Extract<TRoutes[number], { state: { method: "POST"; path: TPath } }>,
|
||||
>(path: TPath, ...args: TRoute["args"]): Promise<RelayResponse<TRoute>> {
|
||||
return this.#send("POST", path, args) as RelayResponse<TRoute>;
|
||||
}
|
||||
|
||||
/**
|
||||
* 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<TRoutes[number], { state: { method: "GET" } }>["state"]["path"],
|
||||
TRoute extends Extract<TRoutes[number], { state: { method: "GET"; path: TPath } }>,
|
||||
>(path: TPath, ...args: TRoute["args"]): Promise<RelayResponse<TRoute>> {
|
||||
return this.#send("GET", path, args) as RelayResponse<TRoute>;
|
||||
}
|
||||
|
||||
/**
|
||||
* 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<TRoutes[number], { state: { method: "PUT" } }>["state"]["path"],
|
||||
TRoute extends Extract<TRoutes[number], { state: { method: "PUT"; path: TPath } }>,
|
||||
>(path: TPath, ...args: TRoute["args"]): Promise<RelayResponse<TRoute>> {
|
||||
return this.#send("PUT", path, args) as RelayResponse<TRoute>;
|
||||
}
|
||||
|
||||
/**
|
||||
* 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<TRoutes[number], { state: { method: "PATCH" } }>["state"]["path"],
|
||||
TRoute extends Extract<TRoutes[number], { state: { method: "PATCH"; path: TPath } }>,
|
||||
>(path: TPath, ...args: TRoute["args"]): Promise<RelayResponse<TRoute>> {
|
||||
return this.#send("PATCH", path, args) as RelayResponse<TRoute>;
|
||||
}
|
||||
|
||||
/**
|
||||
* 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<TRoutes[number], { state: { method: "DELETE" } }>["state"]["path"],
|
||||
TRoute extends Extract<TRoutes[number], { state: { method: "DELETE"; path: TPath } }>,
|
||||
>(path: TPath, ...args: TRoute["args"]): Promise<RelayResponse<TRoute>> {
|
||||
return this.#send("DELETE", path, args) as RelayResponse<TRoute>;
|
||||
}
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------------
|
||||
| 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<string, unknown> = {};
|
||||
|
||||
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<string, string> = {};
|
||||
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 extends Route> = TRoute["state"]["output"] extends ZodType ? z.infer<TRoute["state"]["output"]> : void;
|
||||
|
||||
type RelayConfig = {
|
||||
adapter: RelayAdapter;
|
||||
};
|
||||
|
||||
export type RelayAdapter = {
|
||||
fetch(input: RequestInput): Promise<unknown>;
|
||||
};
|
||||
|
||||
export type RequestInput = {
|
||||
method: RouteMethod;
|
||||
url: string;
|
||||
search: string;
|
||||
body?: string;
|
||||
};
|
||||
332
libraries/route.ts
Normal file
332
libraries/route.ts
Normal file
@@ -0,0 +1,332 @@
|
||||
import z, { ZodObject, ZodRawShape, ZodType } from "zod";
|
||||
|
||||
import { Action } from "./action.ts";
|
||||
|
||||
export class Route<TRouteState extends RouteState = RouteState> {
|
||||
#pattern?: URLPattern;
|
||||
|
||||
declare readonly args: RouteArgs<TRouteState>;
|
||||
declare readonly context: RouteContext<TRouteState>;
|
||||
|
||||
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<TRouteState["params"]> : 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<TParams extends ZodRawShape>(params: TParams): Route<Omit<TRouteState, "params"> & { params: ZodObject<TParams> }> {
|
||||
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<TSearch extends ZodRawShape>(search: TSearch): Route<Omit<TRouteState, "search"> & { search: ZodObject<TSearch> }> {
|
||||
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<TBody extends ZodObject>(body: TBody): Route<Omit<TRouteState, "body"> & { 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<TAction extends Action>(actions: TAction[]): Route<Omit<TRouteState, "actions"> & { 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<TResponse extends ZodType>(output: TResponse): Route<Omit<TRouteState, "output"> & { output: TResponse }> {
|
||||
return new Route({ ...this.state, output });
|
||||
}
|
||||
|
||||
/**
|
||||
* Server handler callback method.
|
||||
*
|
||||
* @param handle - Handle function to trigger when the route is executed.
|
||||
*/
|
||||
handle<THandleFn extends HandleFn<this["context"], this["state"]["output"]>>(handle: THandleFn): Route<Omit<TRouteState, "handle"> & { 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<TPath extends string>(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<TPath extends string>(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<TPath extends string>(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<TPath extends string>(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<TPath extends string>(path: TPath) {
|
||||
return new Route({ method: "DELETE", path });
|
||||
},
|
||||
};
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------------
|
||||
| Types
|
||||
|--------------------------------------------------------------------------------
|
||||
*/
|
||||
|
||||
type RouteState = {
|
||||
method: RouteMethod;
|
||||
path: string;
|
||||
params?: ZodObject;
|
||||
search?: ZodObject;
|
||||
body?: ZodObject;
|
||||
actions?: Array<Action>;
|
||||
output?: ZodType;
|
||||
handle?: HandleFn;
|
||||
};
|
||||
|
||||
export type RouteMethod = "POST" | "GET" | "PUT" | "PATCH" | "DELETE";
|
||||
|
||||
export type HandleFn<TContext = any, TResponse = any> = (context: TContext) => TResponse extends ZodType ? Promise<z.infer<TResponse>> : Promise<void>;
|
||||
|
||||
type RouteContext<TRouteState extends RouteState = RouteState> = (TRouteState["params"] extends ZodObject ? z.infer<TRouteState["params"]> : object) &
|
||||
(TRouteState["search"] extends ZodObject ? z.infer<TRouteState["search"]> : object) &
|
||||
(TRouteState["body"] extends ZodObject ? z.infer<TRouteState["body"]> : object) &
|
||||
(TRouteState["actions"] extends Array<Action> ? UnionToIntersection<MergeAction<TRouteState["actions"]>> : object);
|
||||
|
||||
type RouteArgs<TRouteState extends RouteState = RouteState> = [
|
||||
...TupleIfZod<TRouteState["params"]>,
|
||||
...TupleIfZod<TRouteState["search"]>,
|
||||
...TupleIfZod<TRouteState["body"]>,
|
||||
];
|
||||
|
||||
type TupleIfZod<TState> = TState extends ZodObject ? [z.infer<TState>] : [];
|
||||
|
||||
type MergeAction<TActions extends Array<Action>> =
|
||||
TActions[number] extends Action<infer TActionState> ? (TActionState["output"] extends ZodObject ? z.infer<TActionState["output"]> : object) : object;
|
||||
|
||||
type UnionToIntersection<U> = (U extends any ? (k: U) => void : never) extends (k: infer I) => void ? I : never;
|
||||
4
mod.ts
Normal file
4
mod.ts
Normal file
@@ -0,0 +1,4 @@
|
||||
export * from "./libraries/action.ts";
|
||||
export * from "./libraries/errors.ts";
|
||||
export * from "./libraries/relay.ts";
|
||||
export * from "./libraries/route.ts";
|
||||
13
package.json
Normal file
13
package.json
Normal file
@@ -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"
|
||||
}
|
||||
}
|
||||
13
tests/mocks/actions.ts
Normal file
13
tests/mocks/actions.ts
Normal file
@@ -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,
|
||||
};
|
||||
});
|
||||
23
tests/mocks/relay.ts
Normal file
23
tests/mocks/relay.ts
Normal file
@@ -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()) }),
|
||||
]);
|
||||
32
tests/mocks/server.ts
Normal file
32
tests/mocks/server.ts
Normal file
@@ -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);
|
||||
});
|
||||
9
tests/mocks/user.ts
Normal file
9
tests/mocks/user.ts
Normal file
@@ -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<typeof UserSchema>;
|
||||
14
tests/route.test.ts
Normal file
14
tests/route.test.ts
Normal file
@@ -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");
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user