Template
1
0

feat: initial commit

This commit is contained in:
2025-04-18 20:18:50 +00:00
commit 7df57522d2
20 changed files with 2094 additions and 0 deletions

1
.gitignore vendored Normal file
View File

@@ -0,0 +1 @@
node_modules

1
.npmrc Normal file
View File

@@ -0,0 +1 @@
@jsr:registry=https://npm.jsr.io

10
.vscode/settings.json vendored Normal file
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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");
});
});