Compare commits

..

10 Commits

Author SHA1 Message Date
44649ef89a reworking router somewhat 2025-02-14 02:37:30 +03:00
74cd00e62b working admin auth 2025-02-13 18:31:44 +03:00
cafb669fd1 reworking errors and fixing router 2025-02-12 18:26:34 +03:00
94a1ea1e8a reworking errors 2025-02-12 16:31:36 +03:00
ad14560a2c refactored context and router 2025-02-12 14:15:41 +03:00
f0ec7a1f00 refactoring everything before moving on 2025-02-11 16:47:32 +03:00
64519e11ff working on api 2025-02-10 22:15:47 +03:00
cbb18d516d working on api validator interface 2025-02-04 15:03:39 +03:00
97a5cdf654 validator is readygit add . 2025-02-03 23:49:42 +03:00
e555186537 validator reworked 2025-02-03 18:18:20 +03:00
47 changed files with 2055 additions and 1349 deletions

5
deno.lock generated
View File

@ -673,6 +673,9 @@
]
}
},
"redirects": {
"https://deno.land/x/sleep/mod.ts": "https://deno.land/x/sleep@v1.3.0/mod.ts"
},
"remote": {
"https://deno.land/std@0.203.0/assert/assert.ts": "9a97dad6d98c238938e7540736b826440ad8c1c1e54430ca4c4e623e585607ee",
"https://deno.land/std@0.203.0/assert/assertion_error.ts": "4d0bde9b374dfbcbe8ac23f54f567b77024fb67dbb1906a852d67fe050d42f56",
@ -686,6 +689,8 @@
"https://deno.land/std@0.203.0/async/pool.ts": "47c1841cfa9c036144943d11747ddd44064f5baf8cb7ece25473ba873c6aceb0",
"https://deno.land/std@0.203.0/async/retry.ts": "296fb9c323e1325a69bee14ba947e7da7409a8dd9dd646d70cb51ea0d301f24e",
"https://deno.land/std@0.203.0/async/tee.ts": "47e42d35f622650b02234d43803d0383a89eb4387e1b83b5a40106d18ae36757",
"https://deno.land/x/sleep@v1.3.0/mod.ts": "e9955ecd3228a000e29d46726cd6ab14b65cf83904e9b365f3a8d64ec61c1af3",
"https://deno.land/x/sleep@v1.3.0/sleep.ts": "b6abaca093b094b0c2bba94f287b19a60946a8d15764d168f83fcf555f5bb59e",
"https://wilsonl.in/minify-html/deno/0.15.0/index.js": "8e7ee5067ca84fb5d5a1f33118cac4998de0b7d80b3f56cc5c6728b84e6bfb70"
},
"workspace": {

110
server/api.ts Normal file
View File

@ -0,0 +1,110 @@
import { Api } from "@src/lib/apiValidator.ts";
import { createValidationError, z } from "@shared/utils/validator.ts";
import {
adminPasswordAlreadySetErrorSchema,
adminPasswordNotSetErrorSchema,
commandExecutionErrorSchema,
failedToParseRequestAsJSONErrorSchema,
invalidPasswordErrorSchema,
passwordsMustMatchErrorSchema,
queryExecutionErrorSchema,
requestValidationErrorSchema,
tooManyRequestsErrorSchema,
unauthorizedErrorSchema,
usbipUnknownErrorSchema,
} from "@src/lib/errors.ts";
const loginApiSchema = {
req: z.obj({
password: z.string(),
}),
res: z.result(
z.void(),
z.union([
adminPasswordNotSetErrorSchema,
queryExecutionErrorSchema,
failedToParseRequestAsJSONErrorSchema,
requestValidationErrorSchema,
tooManyRequestsErrorSchema,
invalidPasswordErrorSchema,
]),
),
};
export const loginApi = new Api("/login", "POST", loginApiSchema);
const passwordSetupApiSchema = {
req: z.obj({
password: z.string().min(
10,
"Password must be at least 10 characters long",
).regex(
/^[a-zA-Z0-9]+$/,
"Password must consist of lower or upper case latin letters and numbers",
),
passwordRepeat: z.string(),
}).addCheck((v) => {
if (v.passwordRepeat !== v.password) {
return createValidationError(v, {
kind: "general",
msg: "Passwords must match",
});
}
}),
res: z.result(
z.void(),
z.union([
passwordsMustMatchErrorSchema,
adminPasswordAlreadySetErrorSchema,
queryExecutionErrorSchema,
failedToParseRequestAsJSONErrorSchema,
requestValidationErrorSchema,
tooManyRequestsErrorSchema,
]),
),
};
export const passwordSetupApi = new Api(
"/setup",
"POST",
passwordSetupApiSchema,
);
const updateDevicesApiSchema = {
req: z.void(),
res: z.result(
z.void(),
z.union([
queryExecutionErrorSchema,
tooManyRequestsErrorSchema,
unauthorizedErrorSchema,
commandExecutionErrorSchema,
usbipUnknownErrorSchema,
]),
),
};
export const updateDevicesApi = new Api(
"/api/updateDevices",
"POST",
updateDevicesApiSchema,
);
const versionApiSchema = {
req: z.void(),
res: z.result(
z.obj({
app: z.literal("Keyborg"),
version: z.string(),
}),
z.union([
tooManyRequestsErrorSchema,
]),
),
};
export const versionApi = new Api(
"/version",
"POST",
versionApiSchema,
);

View File

@ -7,11 +7,12 @@ await esbuild.build({
plugins: [
...denoPlugins(),
],
entryPoints: ["../shared/utils/index.ts"],
entryPoints: ["./src/js/shared.bundle.ts"],
outfile: "./public/js/shared.bundle.js",
bundle: true,
minify: true,
format: "esm",
treeShaking: true,
});
esbuild.stop();

View File

@ -1,6 +1,6 @@
{
"tasks": {
"dev": "deno run --allow-read --allow-write --allow-sys --allow-env --allow-run ./autoBundler.ts & deno serve --allow-read --allow-write --allow-sys --allow-env --allow-ffi --watch -R main.ts"
"dev": "deno run --allow-read --allow-write --allow-sys --allow-env --allow-run ./autoBundler.ts & deno serve --allow-read --allow-write --allow-sys --allow-env --allow-ffi --watch --allow-run -R main.ts"
},
"imports": {
"@db/sqlite": "jsr:@db/sqlite@^0.12.0",

View File

@ -3,16 +3,33 @@ import { Eta } from "@eta-dev/eta";
import { serveFile } from "jsr:@std/http/file-server";
import rateLimitMiddleware from "@src/middleware/rateLimiter.ts";
import authMiddleware from "@src/middleware/auth.ts";
import { ok, ResultFromJSON } from "@shared/utils/result.ts";
import { ResultResponseFromJSON } from "@src/lib/context.ts";
import admin from "@src/lib/admin.ts";
import UsbipManager from "@shared/utils/usbip.ts";
import loggerMiddleware from "@src/middleware/logger.ts";
import {
loginApi,
passwordSetupApi,
updateDevicesApi,
versionApi,
} from "./api.ts";
import { err, ok } from "@shared/utils/result.ts";
import admin from "@src/lib/admin.ts";
import { Context } from "@src/lib/context.ts";
import {
FailedToParseRequestAsJSONError,
invalidPasswordError,
passwordsMustMatchError,
QueryExecutionError,
queryExecutionError,
RequestValidationError,
} from "@src/lib/errors.ts";
import devices from "@src/lib/devices.ts";
const AUTH_COOKIE_NAME = "token";
const VERSION = "0.1.0";
const router = new HttpRouter();
const views = Deno.cwd() + "/views/";
const eta = new Eta({ views });
export const eta = new Eta({ views });
router.use(loggerMiddleware);
router.use(rateLimitMiddleware);
@ -23,46 +40,184 @@ const cache: Map<string, Response> = new Map();
router.get("/public/*", async (c) => {
const filePath = "." + c.path;
const cached = cache.get(filePath);
if (cached) {
return cached.clone();
}
//const cached = cache.get(filePath);
//
//if (cached) {
// return cached.clone();
//}
const res = await serveFile(c.req, filePath);
cache.set(filePath, res.clone());
//cache.set(filePath, res.clone());
return res;
});
router
.get(["", "/index.html"], (c) => {
return c.html(eta.render("./index.html", {}));
const devicesList = devices.list().unwrap().unwrap();
return c.html(eta.render("./index.html", { devices: devicesList }));
})
.get(["/login", "/login.html"], (c) => {
return c.html(eta.render("./login.html", {}));
const isSet = admin.isPasswordSet();
if (isSet.isErr()) {
return c.html(eta.render("./internal_error.html", {}));
}
if (!isSet.value) {
return c.redirect("/setup");
}
const alreadyLoggedIn = c.cookies.get("token").map((token) =>
admin.sessions.verifyToken(token)
)
.toBoolean();
console.log(alreadyLoggedIn);
return c.html(eta.render("./login.html", { alreadyLoggedIn }));
})
.post("/login", async (c) => {
const r = await ResultFromJSON<{ password: string }>(
await c.req.text(),
);
.get("/setup", (c) => {
return admin.isPasswordSet()
.match(
(isSet) => {
if (isSet) {
return c.redirect("/login");
} else {
return c.html(eta.render("./setup.html", {}));
}
},
(e) => c.html(eta.render("./internal_error.html", {})),
);
});
router
.get("/user/:id/:name/*", (c) => {
return c.html(
`id = ${c.params.id}, name = ${c.params.name}, rest = ${c.params.restOfThePath}`,
);
router.get("ws", (c) => {
if (c.req.headers.get("upgrade") != "websocket") {
return new Response(null, { status: 501 });
}
const { socket, response } = Deno.upgradeWebSocket(c.req);
socket.addEventListener("open", () => {
console.log("a client connected!");
});
router
.get("/user/:idButDifferent", (c) => {
return c.html(
`idButDifferent = ${c.params.idButDifferent}`,
);
socket.addEventListener("close", () => {
console.log("client disconnected");
});
socket.addEventListener("message", (event) => {
if (event.data === "ping") {
console.log("pinged!");
socket.send("pong");
}
});
return response;
});
router
.api(loginApi, async (c) => {
const r = await c
.parseBody()
.andThenAsync(
({ password }) => admin.verifyPassword(password),
);
if (r.isErr()) {
if (r.error.type === "AdminPasswordNotSetError") {
return c.json400(
err({
type: r.error.type,
info: r.error.info,
}),
);
}
return handleCommonErrors(c, r.error);
}
const isMatch = r.value;
if (isMatch) {
return admin.sessions.create()
.map(({ value, expires }) => {
c.cookies.set({
name: AUTH_COOKIE_NAME,
value,
expires,
});
return ok();
}).match(
(v) => c.json(v),
(e) => handleCommonErrors(c, e),
);
} else {
return c.json(
err(invalidPasswordError("Invalid login or password")),
);
}
})
.api(passwordSetupApi, async (c) => {
const r = await c.parseBody();
if (r.isErr()) {
return handleCommonErrors(c, r.error);
}
const v = r.value;
if (v.password !== v.passwordRepeat) {
return c.json400(
err(passwordsMustMatchError("Passwords must match")),
);
}
return admin.setPassword(v.password).match(
() => c.json(ok()),
(e) => c.json400(err(e)),
);
})
.api(updateDevicesApi, (c) => {
return devices.updateDevices().match(
() => c.json(ok()),
(e) => c.json500(err(e)),
);
})
.api(versionApi, (c) => {
return c.json(ok({
app: "Keyborg",
version: VERSION,
}));
});
function handleCommonErrors(
c: Context<any, any, any>,
error:
| QueryExecutionError
| FailedToParseRequestAsJSONError
| RequestValidationError,
): Response {
switch (error.type) {
case "QueryExecutionError":
return c.json(
err(queryExecutionError("Server failed to execute query")),
{ status: 500 },
);
case "FailedToParseRequestAsJSONError":
return c.json(
err(error),
{ status: 400 },
);
case "RequestValidationError":
return c.json(
err(error),
{ status: 400 },
);
}
}
export default {
async fetch(req, connInfo) {
return await router.handleRequest(req, connInfo);

View File

@ -0,0 +1 @@
class c{ws=null;url;reconnectInterval;maxReconnectInterval;reconnectDecay;timeout;forcedClose=!1;onmessage;constructor(e,t={}){this.url=e,this.reconnectInterval=t.reconnectInterval??1e3,this.maxReconnectInterval=t.maxReconnectInterval??3e4,this.reconnectDecay=t.reconnectDecay??1.5,this.timeout=t.timeout??2e3,this.connect()}connect(e=!1){console.log(`Connecting to ${this.url}...`),this.ws=new WebSocket(this.url);let t=setTimeout(()=>{console.warn("Connection timeout, closing socket."),this.ws?.close()},this.timeout);this.ws.onopen=n=>{clearTimeout(t),console.log("WebSocket connected."),this.onmessage&&this.ws?.addEventListener("message",this.onmessage)},this.ws.onerror=n=>{console.error("WebSocket error:",n)},this.ws.onclose=n=>{clearTimeout(t),console.log("WebSocket closed:",n.reason),this.forcedClose||setTimeout(()=>{this.reconnectInterval=Math.min(this.reconnectInterval*this.reconnectDecay,this.maxReconnectInterval),this.connect(!0)},this.reconnectInterval)}}onMessage(e){this.ws&&this.ws.addEventListener("message",e),this.onmessage=e}send(e){this.ws&&this.ws.readyState===WebSocket.OPEN?this.ws.send(e):console.error("WebSocket is not open. Message not sent.")}close(){this.forcedClose=!0,this.ws?.close()}}const s=new c("/ws");s.onMessage(o=>{console.log(o.data)});const i=document.getElementById("ping");i.onclick=()=>{s.send("ping")};

View File

@ -1 +1 @@
import{ok as n}from"./shared.bundle.js";const s=document.getElementById("loginForm"),a=document.getElementById("passwordInput");s.addEventListener("submit",async t=>{t.preventDefault();const o=a.value,e=JSON.stringify(n({password:o}).toJSON()),r=await(await fetch("/login",{method:"POST",headers:{accept:"application/json"},body:e})).json(),c=8});
import{loginApi as o}from"./shared.bundle.js";const r=document.getElementById("loginForm"),s=document.getElementById("passwordInput"),i=document.getElementById("errDiv");r.addEventListener("submit",async t=>{t.preventDefault();const n=s.value,e=(await o.makeRequest({password:n},{})).flatten();e.isErr()?i.innerText=e.error.info:window.location.href="/"});

View File

@ -0,0 +1 @@
import{passwordSetupApi as o}from"./shared.bundle.js";const r=document.getElementById("passwordSetupForm"),a=document.getElementById("passwordInput"),p=document.getElementById("passwordRepeatInput"),d=document.getElementById("errDiv");r.addEventListener("submit",async t=>{t.preventDefault();const n=a.value,s=p.value,e=(await o.makeRequest({password:n,passwordRepeat:s},{})).flatten();e.isErr()?d.innerText=e.error.info:window.location.href="/login"});

File diff suppressed because one or more lines are too long

103
server/src/js/index.ts Normal file
View File

@ -0,0 +1,103 @@
interface ReconnectOptions {
reconnectInterval?: number; // Initial reconnect delay (ms)
maxReconnectInterval?: number; // Maximum delay (ms)
reconnectDecay?: number; // Exponential backoff multiplier
timeout?: number; // Connection timeout (ms)
}
class ReconnectingWebSocketClient {
private ws: WebSocket | null = null;
private url: string;
private reconnectInterval: number;
private maxReconnectInterval: number;
private reconnectDecay: number;
private timeout: number;
private forcedClose: boolean = false;
private onmessage?: (ev: MessageEvent) => any;
constructor(
url: string,
options: ReconnectOptions = {},
) {
this.url = url;
this.reconnectInterval = options.reconnectInterval ?? 1000; // 1 second
this.maxReconnectInterval = options.maxReconnectInterval ?? 30000; // 30 seconds
this.reconnectDecay = options.reconnectDecay ?? 1.5;
this.timeout = options.timeout ?? 2000; // 2 seconds
this.connect();
}
private connect(isReconnect: boolean = false): void {
console.log(`Connecting to ${this.url}...`);
this.ws = new WebSocket(this.url);
let connectionTimeout = setTimeout(() => {
console.warn("Connection timeout, closing socket.");
this.ws?.close();
}, this.timeout);
this.ws.onopen = (event: Event) => {
clearTimeout(connectionTimeout);
console.log("WebSocket connected.");
if (this.onmessage) {
this.ws?.addEventListener("message", this.onmessage);
}
// On connection, send login credentials
// Optionally, if this is a reconnection, you could dispatch a custom event or handle state changes.
};
this.ws.onerror = (event: Event) => {
console.error("WebSocket error:", event);
};
this.ws.onclose = (event: CloseEvent) => {
clearTimeout(connectionTimeout);
console.log("WebSocket closed:", event.reason);
if (!this.forcedClose) {
// Schedule reconnection with exponential backoff
setTimeout(() => {
this.reconnectInterval = Math.min(
this.reconnectInterval * this.reconnectDecay,
this.maxReconnectInterval,
);
this.connect(true);
}, this.reconnectInterval);
}
};
}
public onMessage(fn: (e: MessageEvent) => void) {
if (this.ws) {
this.ws.addEventListener("message", fn);
}
this.onmessage = fn;
}
public send(data: any): void {
if (this.ws && this.ws.readyState === WebSocket.OPEN) {
this.ws.send(data);
} else {
console.error("WebSocket is not open. Message not sent.");
}
}
public close(): void {
this.forcedClose = true;
this.ws?.close();
}
}
const ws = new ReconnectingWebSocketClient("/ws");
ws.onMessage((e) => {
console.log(e.data);
});
const pingBtn = document.getElementById("ping") as HTMLButtonElement;
pingBtn.onclick = () => {
ws.send("ping");
};

View File

@ -1,30 +1,23 @@
/// <reference lib="dom" />
import { ok } from "./shared.bundle.ts";
import { loginApi } from "./shared.bundle.ts";
const form = document.getElementById("loginForm") as HTMLFormElement;
const passwordInput = document.getElementById(
"passwordInput",
) as HTMLInputElement;
const errDiv = document.getElementById("errDiv") as HTMLDivElement;
form.addEventListener("submit", async (e) => {
e.preventDefault();
const password = passwordInput.value;
const bodyReq = JSON.stringify(
ok({
password: password,
}).toJSON(),
);
const res = (await loginApi.makeRequest({ password }, {})).flatten();
const response = await fetch("/login", {
method: "POST",
headers: { accept: "application/json" },
body: bodyReq,
});
const body = await response.json();
const a = 8;
if (res.isErr()) {
errDiv.innerText = res.error.info;
} else {
window.location.href = "/";
}
});

29
server/src/js/setup.ts Normal file
View File

@ -0,0 +1,29 @@
/// <reference lib="dom" />
import { passwordSetupApi } from "./shared.bundle.ts";
const form = document.getElementById("passwordSetupForm") as HTMLFormElement;
const passwordInput = document.getElementById(
"passwordInput",
) as HTMLInputElement;
const passwordRepeatInput = document.getElementById(
"passwordRepeatInput",
) as HTMLInputElement;
const errDiv = document.getElementById("errDiv") as HTMLDivElement;
form.addEventListener("submit", async (e) => {
e.preventDefault();
const password = passwordInput.value;
const passwordRepeat = passwordRepeatInput.value;
const res =
(await passwordSetupApi.makeRequest({ password, passwordRepeat }, {}))
.flatten();
if (res.isErr()) {
errDiv.innerText = res.error.info;
} else {
window.location.href = "/login";
}
});

View File

@ -1 +0,0 @@
../../../shared/utils/index.ts

View File

@ -0,0 +1,5 @@
export * from "@shared/utils/option.ts";
export * from "@shared/utils/result.ts";
export * from "@shared/utils/resultasync.ts";
export * from "@shared/utils/validator.ts";
export * from "../../api.ts";

View File

@ -1,7 +1,11 @@
import { Option, some } from "@shared/utils/option.ts";
import db from "@lib/db/index.ts";
import { ok, Result } from "@shared/utils/result.ts";
import { AdminPasswordNotSetError, QueryExecutionError } from "@lib/errors.ts";
import {
AdminPasswordNotSetError,
adminPasswordNotSetError,
QueryExecutionError,
} from "@lib/errors.ts";
import { AdminRaw, AdminSessionRaw } from "@lib/db/types/index.ts";
import { generateRandomString, passwd } from "@lib/utils.ts";
import { errAsync, ResultAsync } from "@shared/utils/resultasync.ts";
@ -52,7 +56,7 @@ class Admin {
const result = this.getPasswordHash().flattenOption(
() => {
log.warn("Tried to verify password when it is not set");
return new AdminPasswordNotSetError(
return adminPasswordNotSetError(
"Admin password is not set",
);
},
@ -131,13 +135,20 @@ class AdminSessions {
}, EXPIRED_TOKENS_DELETION_INTERVAL);
}
public create(expiresAt?: Date): Result<string, QueryExecutionError> {
public create(
expiresAt?: Date,
): Result<{ value: string; expires: Date }, QueryExecutionError> {
const token = generateRandomString(TOKEN_LENGTH);
if (expiresAt) {
return this.statements
.insertSessionTokenWithExpiry(token, expiresAt.toISOString())
.map(() => token);
.map(() => {
return {
value: token,
expires: expiresAt,
};
});
}
const now = new Date();
@ -148,7 +159,12 @@ class AdminSessions {
return this.statements
.insertSessionTokenWithExpiry(token, expiresAtDefault.toISOString())
.map(() => token);
.map(() => {
return {
value: token,
expires: expiresAtDefault,
};
});
}
public verifyToken(token: string): Result<boolean, QueryExecutionError> {

View File

@ -1,10 +1,101 @@
import { Result } from "@shared/utils/result.ts";
import {
InferSchemaType,
Schema,
SchemaValidationError,
} from "@shared/utils/validator.ts";
import {
RequestValidationError,
requestValidationError,
ResponseValidationError,
responseValidationError,
} from "@src/lib/errors.ts";
import { ResultAsync } from "@shared/utils/resultasync.ts";
class Api<Req extends object, Res extends object> {
client = {
validate(res: Response): Result<Req, any>,
};
server = {
validate(req: Request): Result<Res, any>,
};
export type ExtractRouteParams<T extends string> = T extends string
? T extends `${infer _Start}:${infer Param}/${infer Rest}`
? Param | ExtractRouteParams<Rest>
: T extends `${infer _Start}:${infer Param}` ? Param
: never
: never;
type ApiError =
| RequestValidationError
| ResponseValidationError;
export class Api<
Path extends string,
ReqSchema extends Schema<any>,
ResSchema extends Schema<any>,
> {
private readonly pathSplitted: string[];
private readonly paramIndexes: Record<string, number>;
constructor(
public readonly path: Path,
public readonly method: string,
public readonly schema: {
req: ReqSchema;
res: ResSchema;
},
) {
this.pathSplitted = path.split("/");
this.paramIndexes = this.pathSplitted.reduce<Record<string, number>>(
(acc, segment, index) => {
if (segment.startsWith(":")) {
acc[segment.slice(1)] = index;
}
return acc;
},
{},
);
}
makeRequest(
reqBody: InferSchemaType<ReqSchema>,
params: { [K in ExtractRouteParams<Path>]: string },
): ResultAsync<InferSchemaType<ResSchema>, ApiError> {
return this.schema.req
.parse(reqBody)
.toAsync()
.mapErr((e) => requestValidationError(e.info))
.andThenAsync(async (data) => {
const pathSplitted = this.pathSplitted;
for (const [key, value] of Object.entries(params)) {
pathSplitted[this.paramIndexes[key]] = value as string;
}
const path = pathSplitted.join("/");
console.log(data);
const response = await fetch(
path,
{
method: this.method,
headers: {
"Content-Type": "application/json",
"Accept": "application/json; charset=utf-8",
},
body: JSON.stringify(data),
},
);
const resBody = await response.json();
return this.schema.res.parse(resBody).toAsync()
.map((v) => v as InferSchemaType<ResSchema>)
.mapErr((e) => responseValidationError(e.info));
});
}
public makeSafeRequest(
reqBody: InferSchemaType<ReqSchema>,
params: { [K in ExtractRouteParams<Path>]: string },
): ResultAsync<InferSchemaType<ResSchema>, ResponseValidationError> {
return this.makeRequest(reqBody, params).mapErr((e) => {
if (e.type === "RequestValidationError") {
throw "Failed to validate request";
}
return e;
});
}
}

View File

@ -3,7 +3,20 @@ import { type ExtractRouteParams } from "@lib/router.ts";
import { fromNullableVal, none, Option, some } from "@shared/utils/option.ts";
import { deleteCookie, getCookies, setCookie } from "@std/http/cookie";
import { type Cookie } from "@std/http/cookie";
import { Err, Ok, type Result, ResultFromJSON } from "@shared/utils/result.ts";
import { getMessageFromError, ok } from "@shared/utils/result.ts";
import {
InferSchemaType,
Schema,
SchemaValidationError,
} from "@shared/utils/validator.ts";
import { okAsync, ResultAsync } from "@shared/utils/resultasync.ts";
import log from "@shared/utils/logger.ts";
import {
FailedToParseRequestAsJSONError,
failedToParseRequestAsJSONError,
} from "@src/lib/errors.ts";
import { RequestValidationError } from "@src/lib/errors.ts";
import { requestValidationError } from "@src/lib/errors.ts";
// https://cheatsheetseries.owasp.org/cheatsheets/HTTP_Headers_Cheat_Sheet.html
const SECURITY_HEADERS: Headers = new Headers({
@ -17,32 +30,38 @@ const SECURITY_HEADERS: Headers = new Headers({
//"Content-Security-Policy":
// "default-src 'self'; script-src 'self' 'unsafe-inline'",
});
const HTML_CONTENT_TYPE: [string, string] = [
"Content-Type",
"text/html; charset=UTF-8",
];
const JSON_CONTENT_TYPE: [string, string] = [
"Content-Type",
"application/json; charset=utf-8",
];
const HTML_CONTENT_TYPE: string = "text/html; charset=UTF-8";
const JSON_CONTENT_TYPE: string = "application/json; charset=utf-8";
function mergeHeaders(...headers: Headers[]): Headers {
const mergedHeaders = new Headers();
for (const _headers of headers) {
for (const [key, value] of _headers.entries()) {
mergedHeaders.set(key, value);
}
const merged = new Headers();
for (const hdr of headers) {
hdr.forEach((value, key) => merged.set(key, value));
}
return mergedHeaders;
return merged;
}
export class Context<S extends string = string> {
export type ContextWithSchema<
C extends Context<string, any, any>,
ReqSchema extends Schema<any>,
ResSchema extends Schema<any>,
> = C extends Context<infer S, any, any> ? Context<S, ReqSchema, ResSchema> & {
schema: { req: ReqSchema; res: ResSchema };
}
: never;
export class Context<
S extends string = string,
ReqSchema extends Schema<any> = Schema<unknown>,
ResSchema extends Schema<any> = Schema<unknown>,
> {
private _url?: URL;
private _hostname?: string;
private _port?: number;
private _cookies?: Record<string, string>;
private _responseHeaders: Headers = new Headers();
public res: Response = new Response();
public res = new Response();
constructor(
public readonly req: Request,
@ -50,6 +69,61 @@ export class Context<S extends string = string> {
public readonly params: Params<ExtractRouteParams<S>>,
) {}
public schema?: { req: ReqSchema; res: ResSchema };
public setSchema<
Req extends Schema<any>,
Res extends Schema<any>,
>(
schema: { req: Req; res: Res },
): Context<S, Req, Res> & { schema: { req: Req; res: Res } } {
const ctx = new Context<S, Req, Res>(this.req, this.info, this.params);
ctx._url = this._url;
ctx._hostname = this._hostname;
ctx._port = this._port;
ctx._cookies = this._cookies;
ctx.res = this.res;
ctx.schema = schema;
return ctx as Context<S, Req, Res> & { schema: { req: Req; res: Res } };
}
public setParams(
params: Params<string>,
): Context<S, ReqSchema, ResSchema> {
const ctx = new Context<S, ReqSchema, ResSchema>(
this.req,
this.info,
params,
);
ctx._url = this._url;
ctx._hostname = this._hostname;
ctx._port = this._port;
ctx._cookies = this._cookies;
ctx.res = this.res;
ctx.schema = this.schema;
return ctx as Context<S, ReqSchema, ResSchema>;
}
public parseBody(): ResultAsync<
InferSchemaType<ReqSchema>,
RequestValidationError | FailedToParseRequestAsJSONError
> {
return ResultAsync
.fromPromise(
this.req.json(),
(e) => failedToParseRequestAsJSONError(getMessageFromError(e)),
)
.andThen((data: unknown) => {
if (!this.schema) {
return ok(data);
}
return this.schema?.req.parse(data).mapErr((e) =>
requestValidationError(e.info)
);
});
}
get url(): URL {
return this._url ?? (this._url = new URL(this.req.url));
}
@ -59,30 +133,34 @@ export class Context<S extends string = string> {
}
get preferredType(): Option<"json" | "html"> {
const headers = new Headers(this.req.headers);
const accept = this.req.headers.get("accept");
if (!accept) return none;
const types = accept
.split(",")
.map((t) => t.split(";")[0].trim());
if (types.includes("text/html")) return some("html");
if (types.includes("application/json")) return some("json");
return none;
}
return fromNullableVal(headers.get("accept")).andThen(
(types_header) => {
const types = types_header.split(";")[0].trim().split(",");
for (const type of types) {
if (type === "text/html") {
return some("html");
}
if (type === "application/json") {
return some("json");
}
}
return none;
},
);
matchPreferredType(
html: () => Response,
json: () => Response,
other: () => Response,
): Response {
switch (this.preferredType.unwrapOr("other")) {
case "json":
return json();
case "html":
return html();
case "other":
return other();
}
}
get hostname(): Option<string> {
if (this._hostname) return some(this._hostname);
const remoteAddr = this.info.remoteAddr;
if (remoteAddr.transport === "tcp" || remoteAddr.transport === "udp") {
this._hostname = remoteAddr.hostname;
return some(remoteAddr.hostname);
@ -92,9 +170,7 @@ export class Context<S extends string = string> {
get port(): Option<number> {
if (this._port) return some(this._port);
const remoteAddr = this.info.remoteAddr;
if (remoteAddr.transport === "tcp" || remoteAddr.transport === "udp") {
this._port = remoteAddr.port;
return some(remoteAddr.port);
@ -102,16 +178,27 @@ export class Context<S extends string = string> {
return none;
}
public json(body?: object | string, init: ResponseInit = {}): Response {
const headers = mergeHeaders(
private buildHeaders(
initHeaders?: HeadersInit,
contentType?: string,
): Headers {
const merged = mergeHeaders(
SECURITY_HEADERS,
this._responseHeaders,
new Headers(init.headers),
this.res.headers,
new Headers(initHeaders),
);
headers.set(...JSON_CONTENT_TYPE);
let status = init.status || 200;
if (contentType) merged.set("Content-Type", contentType);
return merged;
}
public json(
body?: ResSchema extends Schema<infer T> ? T : object | string,
init: ResponseInit = {},
): Response {
const headers = this.buildHeaders(init.headers, JSON_CONTENT_TYPE);
let status = init.status ?? 200;
let responseBody: BodyInit | null = null;
if (typeof body === "string") {
responseBody = body;
} else if (body !== undefined) {
@ -126,74 +213,55 @@ export class Context<S extends string = string> {
}
}
return new Response(responseBody, {
this.res = new Response(responseBody, {
status,
headers,
});
return this.res;
}
public json400(
body?: ResSchema extends Schema<infer T> ? T : object | string,
init: ResponseInit = {},
): Response {
return this.json(body, { ...init, status: 400 });
}
public json500(
body?: ResSchema extends Schema<infer T> ? T : object | string,
init: ResponseInit = {},
) {
return this.json(body, { ...init, status: 500 });
}
public html(body?: BodyInit | null, init: ResponseInit = {}): Response {
const headers = mergeHeaders(
SECURITY_HEADERS,
this._responseHeaders,
new Headers(init.headers),
);
headers.set(...HTML_CONTENT_TYPE);
const headers = this.buildHeaders(init.headers, HTML_CONTENT_TYPE);
const status = init.status ?? 200;
return new Response(body ?? null, {
status,
headers,
});
this.res = new Response(body ?? null, { status, headers });
return this.res;
}
public redirect(url: string, permanent = false): Response {
const headers = mergeHeaders(
this._responseHeaders,
this.res.headers,
new Headers({ location: url }),
);
return new Response(null, {
this.res = new Response(null, {
status: permanent ? 301 : 302,
headers,
});
return this.res;
}
public cookies = (() => {
const self = this;
public get cookies() {
return {
get(name: string): Option<string> {
if (!self._cookies) {
self._cookies = getCookies(self.req.headers);
}
return fromNullableVal(self._cookies[name]);
},
set(cookie: Cookie) {
setCookie(self._responseHeaders, cookie);
},
delete(name: string) {
deleteCookie(self._responseHeaders, name);
get: (name: string): Option<string> => {
this._cookies ??= getCookies(this.req.headers);
return fromNullableVal(this._cookies[name]);
},
set: (cookie: Cookie) => setCookie(this.res.headers, cookie),
delete: (name: string) => deleteCookie(this.res.headers, name),
};
})();
static setParams<S extends string>(
ctx: Context<string>,
params: Params<ExtractRouteParams<S>>,
): Context<S> {
const newCtx = new Context(ctx.req, ctx.info, params);
newCtx._url = ctx._url;
newCtx._hostname = ctx._hostname;
newCtx._port = ctx._port;
newCtx._cookies = ctx._cookies;
newCtx._responseHeaders = ctx._responseHeaders;
return newCtx;
}
}

View File

@ -1,6 +1,6 @@
import { Database, RestBindParameters } from "@db/sqlite";
import { err, getMessageFromError, ok, Result } from "@shared/utils/result.ts";
import { QueryExecutionError } from "@lib/errors.ts";
import { QueryExecutionError, queryExecutionError } from "@lib/errors.ts";
import { fromNullableVal, none, Option, some } from "@shared/utils/option.ts";
import log from "@shared/utils/logger.ts";
@ -13,7 +13,7 @@ export class DatabaseClient {
} catch (e) {
const message = getMessageFromError(e);
log.error(`Failed to execute sql! Error: ${e}`);
return err(new QueryExecutionError(message));
return err(queryExecutionError(message));
}
}

128
server/src/lib/devices.ts Normal file
View File

@ -0,0 +1,128 @@
import usbip, { DeviceDetailed } from "@src/lib/usbip.ts";
import { none, Option, some } from "@shared/utils/option.ts";
import { InferSchemaType, z } from "@shared/utils/validator.ts";
import log from "@shared/utils/logger.ts";
import { ResultAsync } from "@shared/utils/resultasync.ts";
import { err, Ok, ok, Result } from "@shared/utils/result.ts";
import {
CommandExecutionError,
DeviceDoesNotExistError,
deviceDoesNotExistError,
UsbipUnknownError,
} from "@src/lib/errors.ts";
type FailedToAccessDevices = CommandExecutionError | UsbipUnknownError;
class Devices {
private devices: Result<
Map<string, Device>,
CommandExecutionError | UsbipUnknownError
> = ok(new Map());
public update(
busid: string,
update: Partial<DeviceMutables>,
): Result<void, DeviceDoesNotExistError | FailedToAccessDevices> {
return this.devices.andThen((devices) => {
const device = devices.get(busid);
if (device === undefined) {
return err(
deviceDoesNotExistError(
`Device with busid ${busid} does not exist`,
),
);
}
for (const key of Object.keys(update)) {
device[key as keyof typeof update] =
update[key as keyof typeof update] || none;
}
return ok();
});
}
public updateDevices(): ResultAsync<
void,
FailedToAccessDevices
> {
return usbip.getDevicesDetailed()
.mapErr((e) => {
log.error("Failed to update devices!");
this.devices = err(e);
return e;
})
.map((d) => d.unwrapOr([] as DeviceDetailed[]))
.map(
(devices) => {
const current = new Set(devices.map((d) => d.busid));
const old = new Set(
this.devices.unwrapOrElse(() => {
this.devices = ok(new Map());
return this.devices.unwrap();
}).keys(),
);
const connected = current.difference(old);
const disconnected = old.difference(current);
for (const device of devices) {
if (connected.has(device.busid)) {
this.devices.unwrap().set(
device.busid,
this.deviceFromDetailed(device),
);
}
}
for (const device of disconnected) {
this.devices.unwrap().delete(device);
}
},
);
}
private deviceFromDetailed(d: DeviceDetailed): Device {
return {
busid: d.busid,
usbid: d.usbid,
vendor: d.vendor,
name: d.name,
displayName: none,
description: none,
connectedAt: new Date(),
};
}
public list(): Result<Option<Device[]>, FailedToAccessDevices> {
return this.devices.map((devices) => devices.values().toArray()).map((
devices,
) => devices.length > 0 ? some(devices) : none);
}
}
export const deviceSchema = z.obj({
busid: z.string(),
usbid: z.option(z.string()),
vendor: z.option(z.string()),
name: z.option(z.string()),
displayName: z.option(z.string()),
description: z.option(z.string()),
connectedAt: z.date(),
}).strict();
export const deviceMutablesSchema = deviceSchema.pick({
displayName: true,
description: true,
});
export type DeviceMutables = InferSchemaType<typeof deviceMutablesSchema>;
export type Device = InferSchemaType<typeof deviceSchema>;
const devices = new Devices();
devices.updateDevices();
export default devices;

View File

@ -1,50 +1,166 @@
import log from "@shared/utils/logger.ts";
import { InferSchemaType, z } from "@shared/utils/validator.ts";
import { createErrorFactory, defineError } from "@shared/utils/errors.ts";
export class ErrorBase extends Error {
constructor(message: string = "An unknown error has occurred") {
super(message);
this.name = this.constructor.name;
}
}
export const queryExecutionErrorSchema = defineError(
"QueryExecutionError",
);
export const queryExecutionError = createErrorFactory(
queryExecutionErrorSchema,
);
export type QueryExecutionError = InferSchemaType<
typeof queryExecutionErrorSchema
>;
export class QueryExecutionError extends ErrorBase {
public readonly code = "QueryExecutionError";
constructor(message: string) {
super(message);
}
}
export const noAdminEntryErrorSchema = defineError("NoAdminEntryError");
export const noAdminEntryError = createErrorFactory(noAdminEntryErrorSchema);
export type NoAdminEntryError = InferSchemaType<typeof noAdminEntryErrorSchema>;
export class NoAdminEntryError extends ErrorBase {
public readonly code = "NoAdminEntry";
constructor(message: string) {
super(message);
}
}
export const failedToReadFileErrorSchema = defineError("FailedToReadFileError");
export const failedToReadFileError = createErrorFactory(
failedToReadFileErrorSchema,
);
export type FailedToReadFileError = InferSchemaType<
typeof failedToReadFileErrorSchema
>;
export class FailedToReadFileError extends ErrorBase {
public readonly code = "FailedToReadFileError";
constructor(message: string) {
super(message);
}
}
export const invalidSyntaxErrorSchema = defineError("InvalidSyntaxError");
export const invalidSyntaxError = createErrorFactory(invalidSyntaxErrorSchema);
export type InvalidSyntaxError = InferSchemaType<
typeof invalidSyntaxErrorSchema
>;
export class InvalidSyntaxError extends ErrorBase {
public readonly code = "InvalidSyntax";
constructor(message: string) {
super(message);
}
}
export const invalidPathErrorSchema = defineError("InvalidPathError");
export const invalidPathError = createErrorFactory(invalidPathErrorSchema);
export type InvalidPathError = InferSchemaType<typeof invalidPathErrorSchema>;
export class InvalidPathError extends ErrorBase {
public readonly code = "InvalidPath";
constructor(message: string) {
super(message);
}
}
export const adminPasswordNotSetErrorSchema = defineError(
"AdminPasswordNotSetError",
);
export const adminPasswordNotSetError = createErrorFactory(
adminPasswordNotSetErrorSchema,
);
export type AdminPasswordNotSetError = InferSchemaType<
typeof adminPasswordNotSetErrorSchema
>;
export class AdminPasswordNotSetError extends ErrorBase {
public readonly code = "AdminPasswordNotSetError";
constructor(message: string) {
super(message);
}
}
export const requestValidationErrorSchema = defineError(
"RequestValidationError",
);
export const requestValidationError = createErrorFactory(
requestValidationErrorSchema,
);
export type RequestValidationError = InferSchemaType<
typeof requestValidationErrorSchema
>;
export const responseValidationErrorSchema = defineError(
"ResponseValidationError",
);
export const responseValidationError = createErrorFactory(
responseValidationErrorSchema,
);
export type ResponseValidationError = InferSchemaType<
typeof responseValidationErrorSchema
>;
export const failedToParseRequestAsJSONErrorSchema = defineError(
"FailedToParseRequestAsJSONError",
);
export const failedToParseRequestAsJSONError = createErrorFactory(
failedToParseRequestAsJSONErrorSchema,
);
export type FailedToParseRequestAsJSONError = InferSchemaType<
typeof failedToParseRequestAsJSONErrorSchema
>;
export const tooManyRequestsErrorSchema = defineError(
"TooManyRequestsError",
);
export const tooManyRequestsError = createErrorFactory(
tooManyRequestsErrorSchema,
);
export type TooManyRequestsError = InferSchemaType<
typeof tooManyRequestsErrorSchema
>;
export const unauthorizedErrorSchema = defineError(
"UnauthorizedError",
);
export const unauthorizedError = createErrorFactory(unauthorizedErrorSchema);
export type UnauthorizedError = InferSchemaType<typeof unauthorizedErrorSchema>;
export const invalidPasswordErrorSchema = defineError("InvalidPasswordError");
export const invalidPasswordError = createErrorFactory(
invalidPasswordErrorSchema,
);
export type InvalidPasswordError = InferSchemaType<
typeof invalidPasswordErrorSchema
>;
export const adminPasswordAlreadySetErrorSchema = defineError(
"AdminPasswordAlreadySetError",
);
export const adminPasswordAlreadySetError = createErrorFactory(
adminPasswordAlreadySetErrorSchema,
);
export type AdminPasswordAlreadySetError = InferSchemaType<
typeof adminPasswordAlreadySetErrorSchema
>;
export const passwordsMustMatchErrorSchema = defineError(
"PasswordsMustMatchError",
);
export const passwordsMustMatchError = createErrorFactory(
passwordsMustMatchErrorSchema,
);
export type PasswordsMustMatchError = InferSchemaType<
typeof passwordsMustMatchErrorSchema
>;
export const commandExecutionErrorSchema = defineError("CommandExecutionError");
export const commandExecutionError = createErrorFactory(
commandExecutionErrorSchema,
);
export type CommandExecutionError = InferSchemaType<
typeof commandExecutionErrorSchema
>;
export const deviceDoesNotExistErrorSchema = defineError(
"DeviceDoesNotExistError",
);
export const deviceDoesNotExistError = createErrorFactory(
deviceDoesNotExistErrorSchema,
);
export type DeviceDoesNotExistError = InferSchemaType<
typeof deviceDoesNotExistErrorSchema
>;
export const deviceAlreadyBoundErrorSchema = defineError(
"DeviceAlreadyBoundError",
);
export const deviceAlreadyBoundError = createErrorFactory(
deviceAlreadyBoundErrorSchema,
);
export type DeviceAlreadyBoundError = InferSchemaType<
typeof deviceAlreadyBoundErrorSchema
>;
export const deviceNotBoundErrorSchema = defineError("DeviceNotBoundError");
export const deviceNotBoundError = createErrorFactory(
deviceNotBoundErrorSchema,
);
export type DeviceNotBoundError = InferSchemaType<
typeof deviceNotBoundErrorSchema
>;
export const usbipUnknownErrorSchema = defineError("UsbipUnknownError");
export const usbipUnknownError = createErrorFactory(usbipUnknownErrorSchema);
export type UsbipUnknownError = InferSchemaType<typeof usbipUnknownErrorSchema>;
export const notFoundErrorSchema = defineError("NotFoundError");
export const notFoundError = createErrorFactory(notFoundErrorSchema);
export type NotFoundError = InferSchemaType<typeof notFoundErrorSchema>;
export const notAllowedErrorSchema = defineError("NotAllowedError");
export const notAllowedError = createErrorFactory(notAllowedErrorSchema);
export type NotAllowedError = InferSchemaType<typeof notAllowedErrorSchema>;

View File

@ -1,62 +1,90 @@
import { RouterTree } from "@lib/routerTree.ts";
import { none, Option, some } from "@shared/utils/option.ts";
import { Context } from "@lib/context.ts";
import { RouterTree } from "@src/lib/routerTree.ts";
import { none, some } from "@shared/utils/option.ts";
import { Context } from "@src/lib/context.ts";
import { Schema } from "@shared/utils/validator.ts";
import { Api } from "@src/lib/apiValidator.ts";
import { notAllowedError, notFoundError } from "@src/lib/errors.ts";
import { err } from "@shared/utils/result.ts";
type RequestHandler<
S extends string,
ReqSchema extends Schema<any> = Schema<unknown>,
ResSchema extends Schema<any> = Schema<unknown>,
> = (c: Context<S, ReqSchema, ResSchema>) => Promise<Response> | Response;
type RequestHandler<S extends string> = (
c: Context<S>,
) => Promise<Response> | Response;
export type Middleware = (
c: Context<string>,
next: () => Promise<void>,
) => Promise<Response | void> | Response | void;
type MethodHandler<S extends string> = {
handler: RequestHandler<S>;
schema?: { req: Schema<any>; res: Schema<any> };
};
type MethodHandlers<S extends string> = Partial<
Record<string, RequestHandler<S>>
Record<string, MethodHandler<S>>
>;
const DEFAULT_NOT_FOUND_HANDLER = () => new Response("404 Not found");
const DEFAULT_NOT_FOUND_HANDLER =
(() => new Response("404 Not found", { status: 404 })) as RequestHandler<
any
>;
class HttpRouter {
routerTree = new RouterTree<MethodHandlers<any>>();
pathPreprocessor?: (path: string) => string;
middlewares: Middleware[] = [];
defaultNotFoundHandler: RequestHandler<string> = DEFAULT_NOT_FOUND_HANDLER;
public readonly routerTree = new RouterTree<MethodHandlers<any>>();
public pathTransformer?: (path: string) => string;
private middlewares: Middleware[] = [];
public defaultNotFoundHandler: RequestHandler<string> =
DEFAULT_NOT_FOUND_HANDLER;
setPathProcessor(processor: (path: string) => string) {
this.pathPreprocessor = processor;
}
use(mw: Middleware): HttpRouter {
this.middlewares.push(mw);
public setPathTransformer(transformer: (path: string) => string) {
this.pathTransformer = transformer;
return this;
}
add<S extends string>(
public use(middleware: Middleware): this {
this.middlewares.push(middleware);
return this;
}
public add<
S extends string,
ReqSchema extends Schema<any> = Schema<unknown>,
ResSchema extends Schema<any> = Schema<unknown>,
>(
path: S,
method: string,
handler: RequestHandler<S>,
handler: RequestHandler<S, ReqSchema, ResSchema>,
schema?: { req: ReqSchema; res: ResSchema },
): HttpRouter;
add<S extends string>(
public add<
S extends string,
ReqSchema extends Schema<any> = Schema<unknown>,
ResSchema extends Schema<any> = Schema<unknown>,
>(
path: S[],
method: string,
handler: RequestHandler<string>,
handler: RequestHandler<string, ReqSchema, ResSchema>,
schema?: { req: ReqSchema; res: ResSchema },
): HttpRouter;
add(
public add(
path: string | string[],
method: string,
handler: RequestHandler<string>,
schema?: { req: Schema<any>; res: Schema<any> },
): HttpRouter {
const paths = Array.isArray(path) ? path : [path];
for (const p of paths) {
this.routerTree.getHandler(p).match(
(mth) => {
mth[method] = handler;
(existingHandlers) => {
existingHandlers[method] = { handler, schema };
},
() => {
const mth: MethodHandlers<string> = {};
mth[method] = handler;
this.routerTree.add(p, mth);
const newHandlers: MethodHandlers<string> = {};
newHandlers[method] = { handler, schema };
this.routerTree.add(p, newHandlers);
},
);
}
@ -64,63 +92,147 @@ class HttpRouter {
return this;
}
// Overload signatures for 'get'
get<S extends string>(path: S, handler: RequestHandler<S>): HttpRouter;
get<S extends string>(
public get<S extends string>(
path: S,
handler: RequestHandler<S>,
): HttpRouter;
public get<S extends string>(
path: S[],
handler: RequestHandler<string>,
): HttpRouter;
// Non-generic implementation for 'get'
get(path: string | string[], handler: RequestHandler<string>): HttpRouter {
public get(
path: string | string[],
handler: RequestHandler<string>,
): HttpRouter {
if (Array.isArray(path)) {
return this.add(path, "GET", handler);
}
return this.add(path, "GET", handler);
}
post<S extends string>(path: S, handler: RequestHandler<S>): HttpRouter;
post<S extends string>(
public post<S extends string>(
path: S,
handler: RequestHandler<S>,
): HttpRouter;
public post<S extends string>(
path: string[],
handler: RequestHandler<string>,
): HttpRouter;
post(path: string | string[], handler: RequestHandler<string>): HttpRouter {
public post(
path: string | string[],
handler: RequestHandler<string>,
): HttpRouter {
if (Array.isArray(path)) {
return this.add(path, "POST", handler);
}
return this.add(path, "POST", handler);
}
public api<
Path extends string,
ReqSchema extends Schema<any>,
ResSchema extends Schema<any>,
>(
api: Api<Path, ReqSchema, ResSchema>,
handler: RequestHandler<Path, ReqSchema, ResSchema>,
): HttpRouter {
return this.add(api.path, api.method, handler, api.schema);
}
async handleRequest(
req: Request,
connInfo: Deno.ServeHandlerInfo<Deno.Addr>,
): Promise<Response> {
const c = new Context(req, connInfo, {});
let ctx = new Context(req, connInfo, {});
const path = this.pathTransformer
? this.pathTransformer(ctx.path)
: ctx.path;
const path = this.pathPreprocessor
? this.pathPreprocessor(c.path)
: c.path;
let params: string[] = [];
let routeParams: Record<string, string> = {};
const handler = this.routerTree
.find(path)
.andThen((routeMatch) => {
const { value: handlers, params: paramsMatched } = routeMatch;
params = paramsMatched;
const handler = handlers[req.method];
return handler ? some(handler) : none;
})
.unwrapOrElse(() => this.defaultNotFoundHandler);
.andThen((match) => {
const { value: methodHandler, params: params } = match;
routeParams = params;
const cf = await this.executeMiddlewareChain(
let route = methodHandler[req.method];
if (!route) {
if (req.method === "HEAD") {
const getHandler = methodHandler["GET"];
if (!getHandler) {
return none;
}
route = getHandler;
} else if (
ctx.preferredType.map((v) => v === "json")
.toBoolean() &&
req.method !== "GET"
) {
return some(
(() =>
ctx.json(
err(notAllowedError(
"405 Not allowed",
)),
{
status: 405,
},
)) as RequestHandler<any>,
);
}
return none;
}
if (route.schema) {
ctx = ctx.setSchema(route.schema);
}
const handler = route.handler;
return some(handler);
})
.unwrapOrElse(() => {
switch (ctx.preferredType.unwrapOr("other")) {
case "json":
return (() =>
ctx.json(err(notFoundError("404 Not found")), {
status: 404,
})) as RequestHandler<any>;
case "html":
return (() =>
ctx.html("404 Not found", {
status: 404,
})) as RequestHandler<any>;
case "other":
return DEFAULT_NOT_FOUND_HANDLER;
}
});
const res = (await this.executeMiddlewareChain(
this.middlewares,
handler,
Context.setParams(c, params),
);
ctx = ctx.setParams(routeParams),
)).res;
return cf.res;
if (req.method === "HEAD") {
const headers = new Headers(res.headers);
headers.set("Content-Length", "0");
return new Response(null, {
headers,
status: res.status,
statusText: res.statusText,
});
}
return res;
}
private resolveRoute(
ctx: Context,
req: Request,
path: string,
): { handler: RequestHandler<any>; params: Record<string, string> } {
const routeOption = this.routerTree.find(path);
}
private async executeMiddlewareChain<S extends string>(
@ -151,10 +263,6 @@ class HttpRouter {
return c;
}
private setParams(path: string, params: string[]): Params<string> {
path.split("/").filter((segmet) => segmet.startsWith(":"));
}
}
export type ExtractRouteParams<T extends string> = T extends string

View File

@ -1,9 +1,16 @@
import { fromNullableVal, none, Option, some } from "@shared/utils/option.ts";
const DEFAULT_WILDCARD_SYMBOL = "*";
const DEFAULT_WILDCARD = "*";
const DEFAULT_PARAM_PREFIX = ":";
const DEFAULT_PATH_SEPARATOR = "/";
export type Params = Record<string, string>;
interface RouteMatch<T> {
value: T;
params: Params;
}
interface Node<T> {
handler: Option<T>;
paramNames: string[];
@ -29,52 +36,52 @@ class StaticNode<T> implements Node<T> {
this.handler = fromNullableVal(handler);
}
addStaticChild(segment: string, handler?: T): StaticNode<T> {
private addStaticChild(segment: string, handler?: T): StaticNode<T> {
const child = new StaticNode(handler);
this.staticChildren.set(segment, child);
return child;
}
setDynamicChild(handler?: T): DynamicNode<T> {
private createDynamicChild(handler?: T): DynamicNode<T> {
const child = new DynamicNode(handler);
this.dynamicChild = some(child);
return child;
}
setWildcardNode(handler?: T): WildcardNode<T> {
private createWildcardNode(handler?: T): WildcardNode<T> {
const child = new WildcardNode(handler);
this.wildcardChild = some(child);
return child;
}
addChild(
public addChild(
segment: string,
wildcardSymbol: string,
paramPrefixSymbol: string,
handler?: T,
): Node<T> {
if (segment === wildcardSymbol) {
return this.setWildcardNode(handler);
return this.createWildcardNode(handler);
}
if (segment.startsWith(paramPrefixSymbol)) {
return this.setDynamicChild(handler);
return this.createDynamicChild(handler);
}
return this.addStaticChild(segment, handler);
}
getStaticChild(segment: string): Option<StaticNode<T>> {
private getStaticChild(segment: string): Option<StaticNode<T>> {
return fromNullableVal(this.staticChildren.get(segment));
}
getDynamicChild(): Option<DynamicNode<T>> {
public getDynamicChild(): Option<DynamicNode<T>> {
return this.dynamicChild;
}
getWildcardChild(): Option<WildcardNode<T>> {
public getWildcardChild(): Option<WildcardNode<T>> {
return this.wildcardChild;
}
getChild(segment: string): Option<Node<T>> {
public getChild(segment: string): Option<Node<T>> {
return this.getStaticChild(segment)
.orElse(() => this.getWildcardChild())
.orElse(() => this.getDynamicChild());
@ -89,7 +96,6 @@ class StaticNode<T> implements Node<T> {
}
}
// TODO: get rid of fixed param name
class DynamicNode<T> extends StaticNode<T> implements Node<T> {
constructor(
handler?: T,
@ -112,7 +118,7 @@ class WildcardNode<T> implements Node<T> {
// Override to prevent adding children to a wildcard node
public addChild(): Node<T> {
throw new Error("Cannot add child to a WildcardNode.");
throw new Error("Cannot add child to a wildcard (catch-all) node.");
}
public getChild(): Option<Node<T>> {
@ -128,16 +134,13 @@ class WildcardNode<T> implements Node<T> {
}
}
// Using Node<T> as the unified type for tree nodes.
type TreeNode<T> = Node<T>;
export class RouterTree<T> {
public readonly root: StaticNode<T>;
constructor(
handler?: T,
private readonly wildcardSymbol: string = DEFAULT_WILDCARD_SYMBOL,
private readonly paramPrefixSymbol: string = DEFAULT_PARAM_PREFIX,
private readonly wildcardSymbol: string = DEFAULT_WILDCARD,
private readonly paramPrefix: string = DEFAULT_PARAM_PREFIX,
private readonly pathSeparator: string = DEFAULT_PATH_SEPARATOR,
) {
this.root = new StaticNode(handler);
@ -145,104 +148,84 @@ export class RouterTree<T> {
public add(path: string, handler: T): void {
const segments = this.splitPath(path);
const paramNames: string[] = this.extractParams(segments);
let current: TreeNode<T> = this.root;
const paramNames: string[] = this.extractParamNames(segments);
const node: Node<T> = this.traverseOrCreate(segments);
for (const segment of segments) {
current = current
.getChild(segment)
.unwrapOrElse(() =>
current.addChild(
segment,
this.wildcardSymbol,
this.paramPrefixSymbol,
)
);
if (current.isWildcardNode()) {
current.paramNames = paramNames;
current.paramNames.push("restOfThePath");
current.handler = some(handler);
return;
}
}
current.paramNames = paramNames;
current.handler = some(handler);
node.paramNames = node.isWildcardNode()
? [...paramNames, "restOfThePath"]
: paramNames;
node.handler = some(handler);
}
public find(path: string): Option<RouteMatch<T>> {
const segments = this.splitPath(path);
const paramValues: string[] = [];
let current: TreeNode<T> = this.root;
let i = 0;
for (; i < segments.length; i++) {
const segment = segments[i];
if (current.isWildcardNode()) break;
const nextNode = current.getChild(segment).ifSome((child) => {
if (child.isDynamicNode()) {
paramValues.push(segment);
}
current = child;
});
if (nextNode.isNone()) return none;
}
if (current.isWildcardNode()) {
const rest = segments.slice(i - 1);
if (rest.length > 0) {
paramValues.push(rest.join(this.pathSeparator));
return this.traverse(path).andThen(({ node, paramValues }) => {
const params: Params = {};
for (
let i = 0;
i < Math.min(paramValues.length, node.paramNames.length);
i++
) {
params[node.paramNames[i]] = paramValues[i];
}
}
const params: Params = {};
for (let i = 0; i < paramValues.length; i++) {
params[current.paramNames[i]] = paramValues[i];
}
return current.handler.map((value) => ({ value, params }));
return node.handler.map((handler) => ({ value: handler, params }));
});
}
public getHandler(path: string): Option<T> {
const segments = this.splitPath(path);
let current: TreeNode<T> = this.root;
return this.traverse(path).andThen(({ node }) => node.handler);
}
private traverseOrCreate(segments: string[]): Node<T> {
let node: Node<T> = this.root;
for (const segment of segments) {
if (current.isWildcardNode()) break;
if (node.isWildcardNode()) break;
node = node.getChild(segment).unwrapOrElse(() =>
node.addChild(segment, this.wildcardSymbol, this.paramPrefix)
);
}
return node;
}
const child = current.getChild(segment).ifSome((child) => {
current = child;
});
private traverse(
path: string,
): Option<{ node: Node<T>; paramValues: string[] }> {
const segments = this.splitPath(path);
const paramValues: string[] = [];
let node: Node<T> = this.root;
if (child.isNone()) return none;
for (let i = 0; i < segments.length; i++) {
if (node.isWildcardNode()) {
const remaining = segments.slice(i).join(this.pathSeparator);
if (remaining) paramValues.push(remaining);
return some({ node, paramValues });
}
const childOpt = node.getChild(segments[i]);
if (childOpt.isNone()) return none;
node = childOpt.unwrap();
if (node.isDynamicNode()) {
paramValues.push(segments[i]);
}
}
return current.handler;
return some({ node, paramValues });
}
private splitPath(path: string): string[] {
const trimmed = path.trim().replace(/^\/+/, "").replace(/\/+$/, "");
return trimmed ? trimmed.split(this.pathSeparator) : [];
return path
.trim()
.split(this.pathSeparator)
.filter((segment) => segment.length > 0);
}
public extractParams(segments: string[]): string[] {
public extractParamNames(segments: string[]): string[] {
return segments.filter((segment) =>
segment.startsWith(this.paramPrefixSymbol)
).map((segment) => this.stripParamPrefix(segment));
segment.startsWith(this.paramPrefix)
).map((segment) => this.removeParamPrefix(segment));
}
public stripParamPrefix(segment: string): string {
return segment.slice(this.paramPrefixSymbol.length);
public removeParamPrefix(segment: string): string {
return segment.slice(this.paramPrefix.length);
}
}
export type Params = Record<string, string>;
interface RouteMatch<T> {
value: T;
params: Params;
}

View File

@ -1,845 +0,0 @@
import { err, ok, Result } from "@shared/utils/result.ts";
import { none, Option, some } from "@shared/utils/option.ts";
class ParseError extends Error {
type = "ParseError";
public trace: NestedArray<string> = [];
constructor(
public input: any,
trace: NestedArray<string> | string,
public readonly msg: string,
) {
super(msg);
if (Array.isArray(trace)) {
this.trace = trace;
} else {
this.trace = [trace];
}
}
stackParseErr(trace: string, input: any): ParseError {
this.trace = [trace, this.trace];
this.input = input;
return this;
}
}
function pe(input: unknown, trace: NestedArray<string>, msg: string) {
return new ParseError(input, trace, msg);
}
export interface Schema<T> {
parse(input: unknown): Result<T, ParseError>;
checkIfValid(input: unknown): boolean;
nullable(): NullableSchema<Schema<T>>;
option(): OptionSchema<Schema<T>>;
or<S extends Schema<any>[]>(...schema: S): UnionSchema<[this, ...S]>;
}
type CheckFunction<T> = (input: T) => ParseError | void;
export abstract class BaseSchema<T> implements Schema<T> {
protected checks: CheckFunction<T>[] = [];
public addCheck(check: CheckFunction<T>): this {
this.checks.push(check);
return this;
}
protected runChecks(input: T): Result<T, ParseError> {
for (const check of this.checks) {
const error = check(input);
if (error) {
return err(error);
}
}
return ok(input);
}
checkIfValid(input: unknown): boolean {
return this.parse(input).isOk();
}
nullable(): NullableSchema<Schema<T>> {
return new NullableSchema(this);
}
or<S extends Schema<any>[]>(...schema: S): UnionSchema<[this, ...S]> {
return new UnionSchema(this, ...schema);
}
option(): OptionSchema<Schema<T>> {
return new OptionSchema(this);
}
abstract parse(input: unknown): Result<T, ParseError>;
}
export abstract class PrimitiveSchema<T> extends BaseSchema<T> {
protected abstract initialCheck(input: unknown): Result<T, ParseError>;
protected checkPrimitive<U = T>(
input: unknown,
type:
| "string"
| "number"
| "boolean"
| "bigint"
| "undefined"
| "object"
| "symbol"
| "funciton",
): Result<U, ParseError> {
const inputType = typeof input;
if (inputType === type) {
return ok(input as U);
}
return err(
pe(input, `Expected '${type}', received '${inputType}'`),
);
}
public parse(input: unknown): Result<T, ParseError> {
return this.initialCheck(input).andThen((input) => {
for (const check of this.checks) {
const e = check(input);
if (e) {
return err(e);
}
}
return ok(input);
});
}
}
export class StringSchema extends PrimitiveSchema<string> {
private static readonly emailRegex =
/^(([^<>()[\]\.,;:\s@\"]+(\.[^<>()[\]\.,;:\s@\"]+)*)|(\".+\"))@(([^<>()[\]\.,;:\s@\"]+\.)+[^<>()[\]\.,;:\s@\"]{2,})$/i; // https://stackoverflow.com/questions/46155/how-can-i-validate-an-email-address-in-javascript
private static readonly ipRegex =
/^(25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)\.(25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)\.(25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)\.(25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)$/; // https://stackoverflow.com/questions/4460586/javascript-regular-expression-to-check-for-ip-addresses
protected override initialCheck(
input: unknown,
): Result<string, ParseError> {
return this.checkPrimitive(input, "string");
}
public max(
length: number,
msg?: string,
): this {
const trace = `String length must be at most ${length} characters long`;
return this.addCheck((input) =>
input.length <= length ? undefined : pe(input, trace, msg)
);
}
public min(
length: number,
msg?: string,
): this {
const trace =
`String length must be at least ${length} characters long`;
return this.addCheck((input) =>
input.length >= length ? undefined : pe(input, trace, msg)
);
}
public regex(
pattern: RegExp,
msg?: string,
): this {
const trace = `String length must match the pattern ${String(pattern)}`;
return this.addCheck((input) =>
pattern.test(input) ? undefined : pe(input, trace, msg)
);
}
public email(
msg?: string,
): this {
const trace = `String must be a valid email address`;
return this.addCheck((input) =>
StringSchema.emailRegex.test(input)
? undefined
: pe(input, trace, msg)
);
}
public ip(
msg?: string,
): this {
const trace = `String must be a valid ip address`;
return this.addCheck((input) =>
StringSchema.ipRegex.test(input) ? undefined : pe(input, trace, msg)
);
}
}
export class NumberSchema extends PrimitiveSchema<number> {
protected override initialCheck(
input: unknown,
): Result<number, ParseError> {
return this.checkPrimitive(input, "number");
}
public gt(
num: number,
msg?: string,
): this {
const trace = `Number must be greates than ${num}`;
return this.addCheck((input) =>
input > num ? undefined : pe(input, trace, msg)
);
}
public gte(
num: number,
msg?: string,
): this {
const trace = `Number must be greates than or equal to ${num}`;
return this.addCheck((input) =>
input >= num ? undefined : pe(input, trace, msg)
);
}
public lt(
num: number,
msg?: string,
): this {
const trace = `Number must be less than ${num}`;
return this.addCheck((input) =>
input < num ? undefined : pe(input, trace, msg)
);
}
public lte(
num: number,
msg?: string,
): this {
const trace = `Number must be less than or equal to ${num}`;
return this.addCheck((input) =>
input <= num ? undefined : pe(input, trace, msg)
);
}
public int(
msg?: string,
): this {
const trace = `Number must be an integer`;
return this.addCheck((input) =>
Number.isInteger(input) ? undefined : pe(input, trace, msg)
);
}
public positive(
msg?: string,
): this {
const trace = `Number must be positive`;
return this.addCheck((input) =>
input > 0 ? undefined : pe(input, trace, msg)
);
}
public nonnegative(
msg?: string,
): this {
const trace = `Number must be nonnegative`;
return this.addCheck((input) =>
input >= 0 ? undefined : pe(input, trace, msg)
);
}
public negative(
msg?: string,
): this {
const trace = `Number must be negative`;
return this.addCheck((input) =>
input < 0 ? undefined : pe(input, trace, msg)
);
}
public nonpositive(
msg?: string,
): this {
const trace = `Number must be nonpositive`;
return this.addCheck((input) =>
input < 0 ? undefined : pe(input, trace, msg)
);
}
public finite(
msg?: string,
): this {
const trace = `Number must be finite`;
return this.addCheck((input) =>
Number.isFinite(input) ? undefined : pe(input, trace, msg)
);
}
public safe(
msg?: string,
): this {
const trace = `Number must be a safe integer`;
return this.addCheck((input) =>
Number.isSafeInteger(input) ? undefined : pe(input, trace, msg)
);
}
public multipleOf(
num: number,
msg?: string,
): this {
const trace = `Number must be a multiple of ${num}`;
return this.addCheck((input) =>
input % num === 0 ? undefined : pe(input, trace, msg)
);
}
}
export class BigintSchema extends PrimitiveSchema<bigint> {
protected override initialCheck(
input: unknown,
): Result<bigint, ParseError> {
return this.checkPrimitive(input, "bigint");
}
public gt(
num: number | bigint,
msg?: string,
): this {
const trace = `Bigint must be greates than ${num}`;
return this.addCheck((input) =>
input > num ? undefined : pe(input, trace, msg)
);
}
public gte(
num: number | bigint,
msg?: string,
): this {
const trace = `Bigint must be greates than or equal to ${num}`;
return this.addCheck((input) =>
input >= num ? undefined : pe(input, trace, msg)
);
}
public lt(
num: number | bigint,
msg?: string,
): this {
const trace = `Bigint must be less than ${num}`;
return this.addCheck((input) =>
input < num ? undefined : pe(input, trace, msg)
);
}
public lte(
num: number | bigint,
msg?: string,
): this {
const trace = `Bigint must be less than or equal to ${num}`;
return this.addCheck((input) =>
input <= num ? undefined : pe(input, trace, msg)
);
}
public int(
msg?: string,
): this {
const trace = `Bigint must be an integer`;
return this.addCheck((input) =>
Number.isInteger(input) ? undefined : pe(input, trace, msg)
);
}
public positive(
msg?: string,
): this {
const trace = `Bigint must be positive`;
return this.addCheck((input) =>
input > 0 ? undefined : pe(input, trace, msg)
);
}
public nonnegative(
msg?: string,
): this {
const trace = `Bigint must be nonnegative`;
return this.addCheck((input) =>
input >= 0 ? undefined : pe(input, trace, msg)
);
}
public negative(
msg?: string,
): this {
const trace = `Bigint must be negative`;
return this.addCheck((input) =>
input < 0 ? undefined : pe(input, trace, msg)
);
}
public nonpositive(
msg?: string,
): this {
const trace = `Bigint must be nonpositive`;
return this.addCheck((input) =>
input < 0 ? undefined : pe(input, trace, msg)
);
}
public finite(
msg?: string,
): this {
const trace = `Bigint must be finite`;
return this.addCheck((input) =>
Number.isFinite(input) ? undefined : pe(input, trace, msg)
);
}
public safe(
msg?: string,
): this {
const trace = `Bigint must be a safe integer`;
return this.addCheck((input) =>
Number.isSafeInteger(input) ? undefined : pe(input, trace, msg)
);
}
public multipleOf(
num: bigint,
msg?: string,
): this {
const trace = `Bigint must be a multiple of ${num}`;
return this.addCheck((input) =>
input % num === BigInt(0) ? undefined : pe(input, trace, msg)
);
}
}
export class BooleanSchema extends PrimitiveSchema<boolean> {
protected override initialCheck(
input: unknown,
): Result<boolean, ParseError> {
return this.checkPrimitive(input, "boolean");
}
}
export class DateSchema extends PrimitiveSchema<object> {
protected override initialCheck(
input: unknown,
): Result<Date, ParseError> {
return this.checkPrimitive(input, "object").andThen((obj) => {
if (obj instanceof Date) {
return ok(obj);
}
return err(
pe(
input,
`Expected instance of Date, received ${obj.constructor.name}`,
),
);
});
}
public min(
date: Date,
msg?: string,
) {
const trace = `Date must be after ${date.toLocaleString()}`;
return this.addCheck((input) =>
input >= date ? undefined : pe(input, trace, msg)
);
}
public max(
date: Date,
msg?: string,
) {
const trace = `Date must be before ${date.toLocaleString()}`;
return this.addCheck((input) =>
input <= date ? undefined : pe(input, trace, msg)
);
}
}
class UndefinedSchema extends PrimitiveSchema<undefined> {
protected override initialCheck(
input: unknown,
): Result<undefined, ParseError> {
return this.checkPrimitive(input, "undefined");
}
}
class NullSchema extends PrimitiveSchema<null> {
protected override initialCheck(
input: unknown,
): Result<null, ParseError> {
if (input === null) {
return ok(input);
}
return err(pe(input, "Expected 'null', received '${typeof input}'"));
}
}
class VoidSchema extends PrimitiveSchema<void> {
protected override initialCheck(input: unknown): Result<void, ParseError> {
if (input !== undefined && input !== null) {
return err(
pe(input, `Expected 'void', received '${typeof input}'`),
);
}
return ok();
}
}
class AnySchema extends PrimitiveSchema<any> {
protected override initialCheck(input: any): Result<any, ParseError> {
return ok(input);
}
}
class UnknownSchema extends PrimitiveSchema<unknown> {
protected override initialCheck(
input: unknown,
): Result<unknown, ParseError> {
return ok(input);
}
}
class ObjectSchema<O extends Record<string, Schema<any>>>
extends PrimitiveSchema<{ [K in keyof O]: InferSchema<O[K]> }> {
private strict: boolean = false;
constructor(
private readonly schema: O,
) {
super();
}
protected override initialCheck(
input: unknown,
): Result<{ [K in keyof O]: InferSchema<O[K]> }, ParseError> {
return this.checkPrimitive<object>(input, "object").andThen(
(objPrimitive) => {
let obj = objPrimitive as Record<string, any>;
let parsedObj: Record<string, any> = {};
for (const [key, schema] of Object.entries(this.schema)) {
const value = obj[key];
const checkResult = schema.parse(value);
if (checkResult.isErr()) {
return err(
checkResult.error.stackParseErr(
`Failed to parse '${key}' attribute`,
input,
),
);
}
parsedObj[key] = checkResult.value;
}
return ok(parsedObj as { [K in keyof O]: InferSchema<O[K]> });
},
);
}
}
class NullableSchema<S extends Schema<any>>
extends PrimitiveSchema<InferSchema<S> | void> {
private static readonly voidSchema = new VoidSchema();
constructor(
private readonly schema: S,
) {
super();
}
protected override initialCheck(
input: unknown,
): Result<void | InferSchema<S>, ParseError> {
if (NullableSchema.voidSchema.checkIfValid(input)) {
return ok();
}
return this.schema.parse(input);
}
}
class LiteralSchema<L extends string> extends PrimitiveSchema<L> {
constructor(
private readonly literal: L,
) {
super();
}
protected override initialCheck(input: unknown): Result<L, ParseError> {
if (input === this.literal) {
return ok(this.literal);
}
return err(pe(input, `Input must match literal '${this.literal}'`));
}
}
type InferSchemaUnion<S extends Schema<any>[]> = S[number] extends
Schema<infer U> ? U : never;
class UnionSchema<S extends Schema<any>[]>
extends PrimitiveSchema<InferSchemaUnion<S>> {
private static readonly schemasTypes: Partial<
Record<string, TypeOfString>
> = {
StringSchema: "string",
LiteralSchema: "string",
NumberSchema: "number",
BigintSchema: "bigint",
BooleanSchema: "boolean",
UndefinedSchema: "undefined",
VoidSchema: "undefined",
};
private readonly primitiveTypesMap: Map<TypeOfString, Schema<any>[]> =
new Map();
private readonly othersTypes: Schema<any>[] = [];
constructor(...schemas: S) {
super();
for (const schema of schemas) {
const type = UnionSchema.schemasTypes[schema.constructor.name];
if (type !== undefined) {
if (!this.primitiveTypesMap.has(type)) {
this.primitiveTypesMap.set(type, []);
}
const schemasForType = this.primitiveTypesMap.get(type);
schemasForType?.push(schema);
} else {
this.othersTypes.push(schema);
}
}
}
protected override initialCheck(
input: unknown,
): Result<InferSchemaUnion<S>, ParseError> {
const schemas = this.primitiveTypesMap.get(typeof input) ||
this.othersTypes;
const errors: string[] = [];
for (const schema of schemas) {
const checkResult = schema.parse(input);
if (checkResult.isOk()) {
return ok(checkResult.value);
}
errors.push(
`${schema.constructor.name} - ${
checkResult.error.trace.join("\n")
}`,
);
}
const type = typeof input;
return err(
pe(
input,
[
`UnionSchema (${
this.primitiveTypesMap.keys().toArray().join(" | ")
}${
this.othersTypes.length > 0
? "object"
: ""
}) - failed to parse input as any of the schemas:`,
errors.join("\n"),
].join("\n"),
"Failed to match union",
),
);
}
}
class ArraySchema<S extends Schema<any>>
extends PrimitiveSchema<InferSchema<S>[]> {
constructor(
private readonly schema: S,
) {
super();
}
protected override initialCheck(
input: unknown[],
): Result<InferSchema<S>[], ParseError> {
const parsed = [];
for (let i = 0; i < input.length; i++) {
const r = this.schema.parse(input[i]);
if (r.isErr()) {
return err(
pe(
input,
`Array. Failed to parse element at index ${i}:\n${r.error.trace}`,
),
);
}
parsed.push(r.value);
}
return ok(parsed);
}
}
class ResultSchema<T, E> extends PrimitiveSchema<Result<T, E>> {
private schema;
constructor(
private readonly valueSchema: Schema<T>,
private readonly errorSchema: Schema<E>,
) {
super();
this.schema = new UnionSchema(
new ObjectSchema({
tag: new LiteralSchema("ok"),
value: valueSchema,
}),
new ObjectSchema({
tag: new LiteralSchema("err"),
error: errorSchema,
}),
);
}
protected override initialCheck(
input: unknown,
): Result<Result<T, E>, ParseError> {
return this.schema.parse(input).map((result) => {
switch (result.tag) {
case "ok":
return ok(result.value);
case "err":
return err(result.error);
}
});
}
}
class OptionSchema<S extends Schema<any>>
extends PrimitiveSchema<Option<InferSchema<S>>> {
private schema;
constructor(private readonly valueSchema: S) {
super();
this.schema = new UnionSchema(
new ObjectSchema({
tag: new LiteralSchema("some"),
value: valueSchema,
}),
new ObjectSchema({
tag: new LiteralSchema("none"),
}),
);
}
protected override initialCheck(
input: unknown,
): Result<Option<T>, ParseError> {
return this.schema.parse(input).map((option) => {
switch (option.tag) {
case "some":
return some(option.value);
case "none":
return none;
}
});
}
}
class Validator {
string(): StringSchema {
return new StringSchema();
}
literal<L extends string>(literal: L): LiteralSchema<L> {
return new LiteralSchema(literal);
}
number(): NumberSchema {
return new NumberSchema();
}
bigint(): BigintSchema {
return new BigintSchema();
}
boolean(): BooleanSchema {
return new BooleanSchema();
}
date(): DateSchema {
return new DateSchema();
}
undefined(): UndefinedSchema {
return new UndefinedSchema();
}
null(): NullSchema {
return new NullSchema();
}
void(): VoidSchema {
return new VoidSchema();
}
any(): AnySchema {
return new AnySchema();
}
unknown(): UnknownSchema {
return new UnknownSchema();
}
union<S extends Schema<any>[]>(...schemas: S): UnionSchema<S> {
return new UnionSchema(...schemas);
}
array<S extends Schema<any>>(elementSchema: S): ArraySchema<S> {
return new ArraySchema(elementSchema);
}
result<T, E>(
valueSchema: Schema<T>,
errorSchema: Schema<E>,
): ResultSchema<T, E> {
return new ResultSchema(valueSchema, errorSchema);
}
}
const v = new Validator();
const r = v.string().max(4, "too long").or(v.number());
const res = r.parse(some("11234"));
console.log(res);
type InferSchema<S> = S extends Schema<infer T> ? T : never;
type NestedArray<T> = T | NestedArray<T>[];

View File

@ -1,19 +0,0 @@
class ParseError extends Error {
type = "ParseError";
public trace: NestedArray<string> = [];
constructor(
public input: any,
trace: NestedArray<string> | string,
public readonly msg: string,
) {
super(msg);
}
}
type NestedArray<T> = T | NestedArray<T>[];
export interface Schema<T> {
parse(input: unknown): Result<T, ParseError>;
}

View File

@ -1,4 +1,261 @@
import UsbipManager from "@shared/utils/usbip.ts";
import { okAsync, ResultAsync } from "@shared/utils/resultasync.ts";
import { err, getMessageFromError, ok } from "@shared/utils/result.ts";
import { errAsync } from "@shared/utils/index.ts";
import log from "@shared/utils/logger.ts";
import {
fromNullableVal,
none,
type Option,
some,
} from "@shared/utils/option.ts";
import {
CommandExecutionError,
commandExecutionError,
DeviceAlreadyBoundError,
deviceAlreadyBoundError,
DeviceDoesNotExistError,
deviceDoesNotExistError,
DeviceNotBoundError,
deviceNotBoundError,
UsbipUnknownError,
usbipUnknownError,
} from "@src/lib/errors.ts";
type UsbipCommonError = DeviceDoesNotExistError | UsbipUnknownError;
class UsbipManager {
private readonly listDeatiledCmd = new Deno.Command("usbip", {
args: ["list", "-l"],
});
private readonly listParsableCmd = new Deno.Command("usbip", {
args: ["list", "-pl"],
});
private readonly decoder = new TextDecoder();
private readonly usbidRegex = /[0-9abcdef]{4}:[0-9abcdef]{4}/;
private readonly busidRegex =
/(?:[0-9]+(?:\.[0-9]+)*-)*[0-9]+(?:\.[0-9]+)*/;
private executeCommand(
cmd: Deno.Command,
): ResultAsync<CommandOutput, CommandExecutionError> {
const promise = cmd.output();
return ResultAsync.fromPromise(
promise,
(e) => commandExecutionError(getMessageFromError(e)),
)
.map(({ stdout, stderr, code }) =>
new CommandOutput(
this.decoder.decode(stdout).trim(),
this.decoder.decode(stderr).trim(),
code,
)
);
}
private handleCommonErrors(stderr: string): UsbipCommonError {
if (
stderr.includes("device with the specified bus ID does not exist")
) {
return deviceDoesNotExistError(stderr);
}
return usbipUnknownError(stderr);
}
private parseDetailedList(stdout: string): Option<DeviceDetailed[]> {
const devices: DeviceDetailed[] = [];
const deviceEntries = stdout.trim().split("\n\n");
for (const deviceEntry of deviceEntries) {
const busid = deviceEntry.match(this.busidRegex)?.shift();
if (!busid) {
log.error(
`Failed to parse busid of a device:\n ${deviceEntry}`,
);
continue;
}
const usbid = fromNullableVal(
deviceEntry.match(this.usbidRegex)?.shift(),
);
const [_, line2] = deviceEntry.split("\n");
const [vendorVal, nameVal] = line2
? line2.split(" : ").map((s) => s.trim())
: [undefined, undefined];
const vendor = fromNullableVal(vendorVal);
const name = nameVal
? some(
nameVal.replace(
usbid.isSome() ? usbid.value : this.usbidRegex,
"",
).replace("()", "")
.trim(),
)
: none;
[["usbid", usbid], ["vendor", vendor], ["name", name]].filter((v) =>
(v[1] as Option<string>).isNone()
).map((v) => log.warn(`Failed to parse ${v[0]}:\n ${deviceEntry}`));
devices.push({
busid,
usbid,
vendor,
name,
});
}
return devices.length > 0 ? some(devices) : none;
}
public getDevicesDetailed(): ResultAsync<
Option<DeviceDetailed[]>,
CommandExecutionError | UsbipUnknownError
> {
return this.executeCommand(this.listDeatiledCmd).andThen(
({ stdout, stderr, success }) => {
if (success) {
if (stderr) {
log.warn(
`usbip list -l succeeded but encountered an error: ${stderr}`,
);
}
return ok(this.parseDetailedList(stdout));
}
return err(usbipUnknownError(stderr));
},
);
}
private parseParsableList(stdout: string): Option<Device[]> {
const devices: Device[] = [];
const devicesEntries = stdout.trim().split("\n");
for (const deviceEntry of devicesEntries) {
const [busid, usbid] = deviceEntry
.slice(0, -1)
.split("#")
.map((v) => v.split("=")[1].trim() || undefined);
if (!busid) {
log.error(
`Failed to parse busid of a device:\n ${deviceEntry}`,
);
continue;
}
if (!usbid) {
log.warn(
`Failed to parse usbid of a device:\n ${deviceEntry}`,
);
}
devices.push({
busid,
usbid: fromNullableVal(usbid),
});
}
return devices.length > 0 ? some(devices) : none;
}
public getDevices(): ResultAsync<
Option<Device[]>,
CommandExecutionError | UsbipUnknownError
> {
return this.executeCommand(this.listParsableCmd).andThenAsync(
({ stdout, stderr, success }) => {
if (success) {
if (stderr) {
log.warn(
`usbip list -lp succeeded but encountered an error: ${stderr}`,
);
}
return okAsync(this.parseParsableList(stdout));
}
return errAsync(usbipUnknownError(stderr));
},
);
}
public bindDevice(
busid: string,
): ResultAsync<
string,
UsbipCommonError | DeviceAlreadyBoundError | CommandExecutionError
> {
const cmd = new Deno.Command("usbip", { args: ["bind", "-b", busid] });
return this.executeCommand(cmd).andThen(
({ stderr, success }) => {
if (success) {
return ok(stderr.trim() || "Device bound successfully");
}
if (stderr.includes("is already bound to usbip-host")) {
return err(deviceAlreadyBoundError(stderr));
}
return err(this.handleCommonErrors(stderr));
},
);
}
public unbindDevice(
busid: string,
): ResultAsync<
string,
CommandExecutionError | DeviceNotBoundError | UsbipCommonError
> {
const cmd = new Deno.Command("usbip", {
args: ["unbind", "-b", busid],
});
return this.executeCommand(cmd).andThen(({ stderr, success }) => {
if (success) {
return ok(stderr.trim() || "Device unbound successfully");
}
if (stderr.includes("device is not bound to usbip-host driver")) {
return err(deviceNotBoundError(stderr));
}
return err(this.handleCommonErrors(stderr));
});
}
}
class CommandOutput {
constructor(
public readonly stdout: string,
public readonly stderr: string,
public readonly code: number,
) {}
get success(): boolean {
return this.code === 0;
}
}
export interface DeviceDetailed {
busid: string;
usbid: Option<string>;
vendor: Option<string>;
name: Option<string>;
}
interface Device {
busid: string;
usbid: Option<string>;
}
const usbip = new UsbipManager();

View File

@ -1,28 +1,58 @@
import { Middleware } from "@lib/router.ts";
import admin from "@lib/admin.ts";
import {
queryExecutionError,
tooManyRequestsError,
unauthorizedError,
} from "@src/lib/errors.ts";
import { err, ok } from "@shared/utils/result.ts";
import { eta } from "../../main.ts";
const LOGIN_PATH = "/login";
const EXCLUDE = new Set(["/login", "/setup", "/version"]);
const authMiddleware: Middleware = async (c, next) => {
const token = c.cookies.get("token");
const isValid = token
.map((token) => admin.sessions.verifyToken(token))
.toBoolean();
.map((token) => admin.sessions.verifyToken(token)).match(
(r) => r,
() => ok(false),
);
if (isValid.isErr()) {
return c.matchPreferredType(
() => c.html(eta.render("./internal_error.html", {})),
() =>
c.json(
err(queryExecutionError("Server failed to execute query")),
),
() => new Response("500 Internal server error", { status: 500 }),
);
}
const path = c.path;
if (path.startsWith("/public")) {
await next();
} else {
if (path !== LOGIN_PATH && !isValid) {
return c.redirect("/login");
if (
!isValid.value && !path.startsWith("/public") && !EXCLUDE.has(path)
) {
if (!isValid.value) {
c.cookies.delete("token");
}
if (path === LOGIN_PATH && isValid) {
return c.redirect("");
if (c.preferredType.isNone()) {
return new Response("401 unautorized", { status: 401 });
}
await next();
switch (c.preferredType.value) {
case "json":
return c.json(
err(unauthorizedError("Unauthorized")),
{ status: 401 },
);
case "html":
return c.redirect("/login");
}
}
await next();
};
export default authMiddleware;

View File

@ -1,5 +1,7 @@
import { Middleware } from "@lib/router.ts";
import log from "@shared/utils/logger.ts";
import { err } from "@shared/utils/result.ts";
import { tooManyRequestsError } from "@src/lib/errors.ts";
const requestCounts: Partial<
Record<string, { count: number; lastReset: number }>
@ -31,9 +33,7 @@ const rateLimitMiddleware: Middleware = async (c, next) => {
}
case "json": {
return c.json(
{
err: "429 Too Many Requests",
},
err(tooManyRequestsError("Too many request")),
{
status: 429,
},

View File

@ -1,3 +1,13 @@
<% layout("./layouts/layout.html") %>
devices:
<% it.devices.forEach(function(device){ %>
<div>
name: <%= device.name %> | <%= device.vendor %>
busid: <%= device.busid %>
</div>
<%= device.busid %>
<% }) %>
this is an index.html
<button id="ping">ping</button>
<script src="/public/js/index.js" defer></script>

View File

@ -0,0 +1,3 @@
<% layout("./layouts/layout.html") %>
Internal error occurred

View File

@ -1,7 +1,17 @@
<% layout("./layouts/basic.html") %>
<main>
<form id=loginForm method=POST>
<p>password</p><input id=passwordInput name=password type=password><input value="sign in" type=submit>
</form>
</main>
<script defer src=/public/js/login.js type=module></script>
<% if (!it.alreadyLoggedIn) { %>
<main>
<form id=loginForm method=POST>
<p>password</p><input id=passwordInput name=password type=password><input value="sign in" type=submit>
<div id="errDiv"></div>
</form>
</main>
<script defer src=/public/js/login.js type=module></script>
<% } else { %>
<main>
You are already logged in!
</main>
<script>
setTimeout(() => {window.location.href = "/"}, 1500)
</script>
<% } %>

View File

@ -0,0 +1,10 @@
<% layout("./layouts/basic.html") %>
<main>
<form id=passwordSetupForm method=POST>
<p>password</p><input id=passwordInput name=password type=password>
<p>password repeat</p><input id=passwordRepeatInput name=passwordRepeat type=password><input value="sign in"
type=submit>
<div id="errDiv"></div>
</form>
</main>
<script defer src=/public/js/setup.js type=module></script>

Binary file not shown.

View File

@ -1 +1 @@
<% layout("./layouts/layout.html") %> this is an index.html
<% layout("./layouts/layout.html") %> devices: <% it.devices.forEach(function(device){ %> <div>name: <%= device.name %> | <%= device.vendor %> busid: <%= device.busid %></div> <%= device.busid %> <% }) %> <button id=ping>ping</button><script defer src=/public/js/index.js></script>

View File

@ -0,0 +1 @@
<% layout("./layouts/layout.html") %> Internal error occurred

View File

@ -1 +1 @@
<% layout("./layouts/basic.html") %> <main><form id=loginForm method=POST><p>password</p><input id=passwordInput name=password type=password><input value="sign in" type=submit></form></main><script defer src=/public/js/login.js type=module></script>
<% layout("./layouts/basic.html") %> <% if (!it.alreadyLoggedIn) { %> <main><form id=loginForm method=POST><p>password</p><input id=passwordInput name=password type=password><input value="sign in" type=submit><div id=errDiv></div></form></main><script defer src=/public/js/login.js type=module></script> <% } else { %> <main>You are already logged in!</main><script>setTimeout(() => {window.location.href = "/"}, 1500)</script> <% } %>

1
server/views/setup.html Normal file
View File

@ -0,0 +1 @@
<% layout("./layouts/basic.html") %> <main><form id=passwordSetupForm method=POST><p>password</p><input id=passwordInput name=password type=password><p>password repeat</p><input id=passwordRepeatInput name=passwordRepeat type=password><input value="sign in" type=submit><div id=errDiv></div></form></main><script defer src=/public/js/setup.js type=module></script>

View File

@ -1,18 +1,51 @@
import { Result } from "@shared/utils/result.ts";
import { type Result } from "@shared/utils/result.ts";
import {
type InferSchema,
InferSchemaType,
Schema,
} from "@shared/utils/validator.ts";
class ValidationError extends BaseError {
code = "ValidationError";
constructor(msg: string) {
super(msg);
export type ExtractRouteParams<T extends string> = T extends string
? T extends `${infer _Start}:${infer Param}/${infer Rest}`
? Param | ExtractRouteParams<Rest>
: T extends `${infer _Start}:${infer Param}` ? Param
: never
: never;
class ClientApi<
Path extends string,
ReqSchema extends Schema<any>,
ResSchema extends Schema<any>,
> {
private readonly path: string[];
private readonly paramsIndexes: Record<string, number>;
constructor(
path: Path,
public readonly reqSchema: ReqSchema,
public readonly resSchema: ResSchema,
) {
this.path = path.split("/");
this.paramsIndexes = this.path.reduce<Record<number, string>>(
(acc, segment, index) => {
if (segment.startsWith(":")) {
acc[index] = segment.slice(1);
}
return acc;
},
{},
);
}
makeRequest(
reqBody: InferSchemaType<ReqSchema>,
params?: { [K in ExtractRouteParams<Path>]: string },
) {
const path = this.path.slice().reduce<string>((acc, cur) => {});
if (params) {
for (const param of Object.keys(params)) {
pathSplitted[this.paramsIndexes[param]] = param;
}
}
}
}
class ClientApi<Req, Res> {
constructor(path: string, method: string) {}
validate(res: Response): ResultAsync<Res> {
const body = await res.json();
}
}
class ServerApi<Req, Res> {
}

38
shared/utils/errors.ts Normal file
View File

@ -0,0 +1,38 @@
import {
InferSchemaType,
LiteralSchema,
ObjectSchema,
Schema,
StringSchema,
z,
} from "@shared/utils/validator.ts";
type ErrorDefinition<T extends string, I extends Schema<any>> = ObjectSchema<
{ type: LiteralSchema<T>; info: I }
>;
export function defineError<
T extends string,
I extends Schema<any> = StringSchema,
>(
type: T,
info?: I,
): ErrorDefinition<T, I> {
return z.obj({
type: z.literal(type),
info: (info ?? z.string()) as I,
});
}
export function createErrorFactory<
T extends string,
I extends Schema<any>,
>(
errorDefinition: ErrorDefinition<T, I>,
): (info: InferSchemaType<I>) => InferSchemaType<ErrorDefinition<T, I>> {
return (info: InferSchemaType<I>) => {
return {
type: errorDefinition.shape.type.literal,
info,
};
};
}

View File

@ -1,3 +1,4 @@
export * from "@shared/utils/option.ts";
export * from "@shared/utils/result.ts";
export * from "@shared/utils/resultasync.ts";
export * from "@shared/utils/validator.ts";

View File

@ -20,7 +20,9 @@ interface IResult<T, E> {
mapErr<U>(fn: (err: E) => U): Result<T, U>;
mapErrAsync<U>(fn: (err: E) => Promise<U>): ResultAsync<T, U>;
andThen<U, F>(fn: (value: T) => Result<U, F>): Result<U, E | F>;
andThenAsync<U, F>(fn: (value: T) => ResultAsync<U, F>): ResultAsync<U, F>;
andThenAsync<U, F>(
fn: (value: T) => ResultAsync<U, F>,
): ResultAsync<U, E | F>;
flatten(): FlattenResult<Result<T, E>>;
flattenOption<U>(errFn: () => U): Result<UnwrapOption<T>, U | E>;
flattenOptionOr<D = UnwrapOption<T>>(
@ -119,12 +121,14 @@ export class Ok<T, E> implements IResult<T, E> {
return fn(this.value) as Result<U, E | F>;
}
andThenAsync<U, F>(fn: (value: T) => ResultAsync<U, F>): ResultAsync<U, F> {
andThenAsync<U, F>(
fn: (value: T) => ResultAsync<U, F>,
): ResultAsync<U, E | F> {
return fn(this.value);
}
mapErr<U>(fn: (err: E) => U): Result<T, U> {
return new Ok<T, U>(this.value);
return ok<T, U>(this.value);
}
mapErrAsync<U>(fn: (err: E) => Promise<U>): ResultAsync<T, U> {
@ -236,8 +240,7 @@ export class Err<T, E> implements IResult<T, E> {
return errAsync(this.error);
}
mapErr<U>(fn: (err: E) => U): Result<T, U> {
const mappedError = fn(this.error);
return new Err<T, U>(mappedError);
return new Err<T, U>(fn(this.error));
}
mapErrAsync<U>(fn: (err: E) => Promise<U>): ResultAsync<T, U> {
return ResultAsync.fromPromise(
@ -255,6 +258,11 @@ export class Err<T, E> implements IResult<T, E> {
andThen<U, F>(fn: (value: T) => Result<U, F>): Result<U, E | F> {
return new Err<U, E | F>(this.error);
}
andThenAsync<U, F>(
fn: (value: T) => ResultAsync<U, F>,
): ResultAsync<U, E | F> {
return new Err<U, E | F>(this.error).toAsync();
}
flatten(): FlattenResult<Result<T, E>> {
return flattenResult(this);
}
@ -359,8 +367,12 @@ export function flattenResult<R extends Result<any, any>>(
): FlattenResult<R> {
let currentResult = nestedResult;
while (currentResult instanceof Ok) {
currentResult = currentResult.value;
while (
currentResult instanceof Ok &&
(currentResult.value instanceof Ok ||
currentResult.value instanceof Err)
) {
currentResult = currentResult.value as R;
}
return currentResult as FlattenResult<R>;

View File

@ -150,7 +150,7 @@ export class ResultAsync<T, E> implements PromiseLike<Result<T, E>> {
}
andThenAsync<U, F>(
fn: (value: T) => ResultAsync<U, F>,
fn: (value: T) => ResultAsync<U, E | F> | Promise<Result<U, E | F>>,
): ResultAsync<U, E | F> {
return new ResultAsync(
this._promise.then(
@ -254,13 +254,12 @@ export function errAsync<E, T = never>(err: E): ResultAsync<T, E> {
return new ResultAsync(Promise.resolve(new Err<T, E>(err)));
}
export type FlattenResultAsync<R> = R extends ResultAsync<infer T, infer E>
? T extends ResultAsync<any, any>
? FlattenResultAsync<T> extends ResultAsync<infer V, infer innerE>
? ResultAsync<V, E | innerE>
: never
type FlattenResultAsync<R> = R extends
ResultAsync<infer Inner, infer OuterError>
? Inner extends ResultAsync<infer T, infer InnerError>
? ResultAsync<T, OuterError | InnerError>
: R
: never;
: R;
type UnwrapPromise<Pr extends Promise<unknown>> = Pr extends Promise<infer U>
? U

View File

@ -9,42 +9,42 @@ import {
some,
} from "@shared/utils/option.ts";
class CommandExecutionError extends Error {
code = "CommandExecutionError";
export class CommandExecutionError extends Error {
type = "CommandExecutionError";
constructor(msg: string) {
super(msg);
}
}
class DeviceDoesNotExistError extends Error {
code = "DeviceDoesNotExist";
export class DeviceDoesNotExistError extends Error {
type = "DeviceDoesNotExist";
constructor(msg: string) {
super(msg);
}
}
class DeviceAlreadyBoundError extends Error {
code = "DeviceAlreadyBound";
export class DeviceAlreadyBoundError extends Error {
type = "DeviceAlreadyBound";
constructor(msg: string) {
super(msg);
}
}
class DeviceNotBound extends Error {
code = "DeviceNotBound";
export class DeviceNotBound extends Error {
type = "DeviceNotBound";
constructor(msg: string) {
super(msg);
}
}
class UsbipUknownError extends Error {
code = "UsbipUknownError";
export class UsbipUnknownError extends Error {
type = "UsbipUknownError";
constructor(msg: string) {
super(msg);
}
}
type UsbipCommonError = DeviceDoesNotExistError | UsbipUknownError;
type UsbipCommonError = DeviceDoesNotExistError | UsbipUnknownError;
class UsbipManager {
private readonly listDeatiledCmd = new Deno.Command("usbip", {
@ -84,7 +84,7 @@ class UsbipManager {
return new DeviceDoesNotExistError(stderr);
}
return new UsbipUknownError(stderr);
return new UsbipUnknownError(stderr);
}
private parseDetailedList(stdout: string): Option<DeviceDetailed[]> {
@ -140,7 +140,7 @@ class UsbipManager {
public getDevicesDetailed(): ResultAsync<
Option<DeviceDetailed[]>,
CommandExecutionError | UsbipUknownError
CommandExecutionError | UsbipUnknownError
> {
return this.executeCommand(this.listDeatiledCmd).andThen(
({ stdout, stderr, success }) => {
@ -153,7 +153,7 @@ class UsbipManager {
return ok(this.parseDetailedList(stdout));
}
return err(new UsbipUknownError(stderr));
return err(new UsbipUnknownError(stderr));
},
);
}
@ -193,7 +193,7 @@ class UsbipManager {
public getDevices(): ResultAsync<
Option<Device[]>,
CommandExecutionError | UsbipUknownError
CommandExecutionError | UsbipUnknownError
> {
return this.executeCommand(this.listParsableCmd).andThenAsync(
({ stdout, stderr, success }) => {
@ -205,7 +205,7 @@ class UsbipManager {
}
return okAsync(this.parseParsableList(stdout));
}
return errAsync(new UsbipUknownError(stderr));
return errAsync(new UsbipUnknownError(stderr));
},
);
}
@ -268,7 +268,7 @@ class CommandOutput {
}
}
interface DeviceDetailed {
export interface DeviceDetailed {
busid: string;
usbid: Option<string>;
vendor: Option<string>;

View File

@ -1,7 +1,8 @@
import { err, ok, Result } from "@shared/utils/result.ts";
import { err, Result } from "@shared/utils/result.ts";
import { ok } from "@shared/utils/index.ts";
import { None, none, Option, some } from "@shared/utils/option.ts";
// ── Error Types ─────────────────────────────────────────────────────
type ValidationErrorDetail =
export type ValidationErrorDetail =
| {
kind: "typeMismatch";
expected: string;
@ -32,14 +33,18 @@ type ValidationErrorDetail =
}
| { kind: "general"; mark?: string; msg: string };
class SchemaValidationError extends Error {
public readonly type = "SchemaValidationError";
export class SchemaValidationError extends Error {
public readonly type = "SchemaValiationError";
constructor(
public readonly input: unknown,
public readonly detail: ValidationErrorDetail,
) {
super(detail.msg || "Schema validation error");
super(
SchemaValidationError.getBestMsg(detail) ||
"Schema validation error",
);
this.name = "SchemaValidationError";
}
public format(): Record<string, unknown> {
@ -49,7 +54,7 @@ class SchemaValidationError extends Error {
};
}
get msg(): string {
get info(): string {
return SchemaValidationError.getBestMsg(this.detail);
}
@ -60,7 +65,7 @@ class SchemaValidationError extends Error {
case "missingProperties":
case "general":
case "unionValidation":
return SchemaValidationError.formMsg(detail);
return SchemaValidationError.formatMsg(detail);
case "propertyValidation":
case "arrayElement":
return detail.msg ||
@ -74,7 +79,7 @@ class SchemaValidationError extends Error {
switch (detail.kind) {
case "general":
case "typeMismatch":
return SchemaValidationError.formMsg(detail);
return SchemaValidationError.formatMsg(detail);
case "propertyValidation":
return {
[detail.property]: detail.msg ||
@ -82,25 +87,28 @@ class SchemaValidationError extends Error {
};
case "unexpectedProperties":
case "missingProperties": {
const resObj: Record<string, string> = {};
const msg = detail.msg ||
(detail.kind === "unexpectedProperties"
? "Property is not allowed in a strict schema object"
: "Property is required, but missing");
for (const key of detail.keys) {
resObj[key] = msg;
}
return resObj;
return detail.keys.reduce<Record<string, string>>(
(acc, key) => {
acc[key] = msg;
return acc;
},
{},
);
}
case "arrayElement": {
const obj: Record<string, string> = {};
const detailObj: Record<string, string> = {};
if (detail.msg) {
obj["msg"] = detail.msg;
detailObj["msg"] = detail.msg;
}
obj[`index_${detail.index}`] = this.formatDetail(detail.detail);
return obj;
detailObj[`index_${detail.index}`] = this.formatDetail(
detail.detail,
);
return detailObj;
}
case "unionValidation": {
const arr: unknown[] = detail.details?.map(
@ -116,7 +124,7 @@ class SchemaValidationError extends Error {
}
}
private static formMsg(detail: ValidationErrorDetail): string {
private static formatMsg(detail: ValidationErrorDetail): string {
if (detail.msg || detail.kind === "general") {
return detail.msg || "Unknown error";
}
@ -124,22 +132,18 @@ class SchemaValidationError extends Error {
case "typeMismatch":
return `Expected ${detail.expected}, but received ${detail.received}`;
case "unexpectedProperties":
return `Properties are not allowed in a strict object schema: ${
detail.keys.join(", ")
}`;
return `Properties not allowed: ${detail.keys.join(", ")}`;
case "missingProperties":
return `Missing required properties: ${detail.keys.join(", ")}`;
case "unionValidation":
return `Input did not match any of the union member`;
case "propertyValidation":
case "arrayElement":
return `Input did not match any union member`;
default:
return "Unknown error";
}
}
}
function createValidationError(
export function createValidationError(
input: unknown,
error: ValidationErrorDetail,
) {
@ -212,14 +216,11 @@ export abstract class BaseSchema<T> implements Schema<T> {
}
protected static isNullishSchema(schema: Schema<any>): boolean {
if (schema.parse(null).isOk() || schema.parse(undefined).isOk()) {
return true;
}
return false;
return schema.parse(null).isOk() || schema.parse(undefined).isOk();
}
}
class StringSchema extends BaseSchema<string> {
export class StringSchema extends BaseSchema<string> {
private static readonly emailRegex =
/^(([^<>()[\]\.,;:\s@\"]+(\.[^<>()[\]\.,;:\s@\"]+)*)|(\".+\"))@(([^<>()[\]\.,;:\s@\"]+\.)+[^<>()[\]\.,;:\s@\"]{2,})$/i; // https://stackoverflow.com/questions/46155/how-can-i-validate-an-email-address-in-javascript
@ -284,7 +285,7 @@ class StringSchema extends BaseSchema<string> {
}
}
class LiteralSchema<L extends string> extends BaseSchema<L> {
export class LiteralSchema<L extends string> extends BaseSchema<L> {
constructor(
public readonly literal: L,
msg?: string,
@ -644,9 +645,7 @@ class NeverSchema extends BaseSchema<never> {
}
}
type InferSchemaType<S> = S extends Schema<infer T> ? T : never;
class ObjectSchema<S extends Record<string, Schema<any>>>
export class ObjectSchema<S extends Record<string, Schema<any>>>
extends BaseSchema<{ [K in keyof S]: InferSchemaType<S[K]> }> {
private strictMode: boolean = false;
private objectMsg?;
@ -675,6 +674,7 @@ class ObjectSchema<S extends Record<string, Schema<any>>>
this.objectMsg = objectMsg;
}
// TODO: Simplify it a bit
protected override validateInput(
input: unknown,
): Result<
@ -698,7 +698,7 @@ class ObjectSchema<S extends Record<string, Schema<any>>>
);
}
let resultObj: Record<string, unknown> = {};
const resultObj: Record<string, unknown> = {};
const expectedKeys = new Set(Object.keys(this.shape));
for (const key of Object.keys(obj)) {
@ -768,12 +768,41 @@ class ObjectSchema<S extends Record<string, Schema<any>>>
});
}
strict(): this {
public strict(): this {
this.strictMode = true;
return this;
}
public pick<
P extends Partial<
Record<keyof InferSchemaType<this>, boolean>
>,
>(
keys: P,
): ObjectPick<this, P> {
const o: Record<string, Schema<any>> = {};
for (const key of Object.keys(keys)) {
if (keys[key as keyof P]) {
o[key] = this.shape[key];
}
}
return z.obj(o) as unknown as ObjectPick<this, P>;
}
}
type PickedKeys<T> = {
[K in keyof T]: T[K] extends true ? K : never;
}[keyof T];
type ObjectPick<
O extends ObjectSchema<any>,
P extends Partial<Record<keyof InferSchemaType<O>, boolean>>,
> = O extends ObjectSchema<infer T>
? ObjectSchema<{ [K in PickedKeys<P> & keyof T]: T[K] }>
: never;
type InferUnionSchemaType<U extends Schema<any>[]> = U[number] extends
Schema<infer T> ? T : never;
@ -825,19 +854,19 @@ class UnionSchema<U extends Schema<any>[]>
input: unknown,
): Result<InferUnionSchemaType<U>, SchemaValidationError> {
const errors: ValidationErrorDetail[] = [];
let typeMismatch = true;
let allTypeMismatch = true;
for (const schema of this.schemas) {
const result = schema.parse(input);
if (result.isOk()) {
return ok(result.value as InferUnionSchemaType<U>);
}
typeMismatch = result.error.detail?.kind === "typeMismatch" &&
typeMismatch;
allTypeMismatch = result.error.detail?.kind === "typeMismatch" &&
allTypeMismatch;
errors.push(result.error.detail);
}
if (typeMismatch) {
if (allTypeMismatch) {
return err(createValidationError(input, {
kind: "typeMismatch",
expected: this.schemas.map((s) =>
@ -853,7 +882,8 @@ class UnionSchema<U extends Schema<any>[]>
return err(
createValidationError(input, {
kind: "unionValidation",
msg: this.msg || this.unionMsg?.unionValidation ||
msg: this.msg ||
this.unionMsg?.unionValidation ||
"Input did not match any union member",
details: errors,
}),
@ -882,6 +912,7 @@ class ArraySchema<S extends Schema<any>>
}
super(mismatchMsg);
// TODO: abstract complex schemas in a separate type with thos messages
this.arrayMsg = arrayMsg;
}
@ -973,7 +1004,183 @@ class NullishSchema<S extends Schema<any>>
}
}
const z = {
export class ResultSchema<T extends Schema<any>, E extends Schema<any>>
extends BaseSchema<Result<InferSchemaType<T>, InferSchemaType<E>>> {
constructor(
private readonly okSchema: T,
private readonly errSchema: E,
) {
super();
}
protected override validateInput(
input: unknown,
): Result<
Result<InferSchemaType<T>, InferSchemaType<E>>,
SchemaValidationError
> {
return BaseSchema.validatePrimitive<object>(input, "object").andThen(
(
obj,
): Result<
Result<InferSchemaType<T>, InferSchemaType<E>>,
SchemaValidationError
> => {
if ("tag" in obj) {
switch (obj.tag) {
case "ok": {
if ("value" in obj) {
return this.okSchema.parse(
obj.value,
).match(
(v) => ok(ok(v as InferSchemaType<T>)),
(e) =>
err(createValidationError(input, {
kind: "propertyValidation",
property: "value",
detail: e.detail,
})),
);
} else if (
BaseSchema.isNullishSchema(this.okSchema)
) {
return ok(
ok() as Result<
InferSchemaType<T>,
InferSchemaType<E>
>,
);
}
return err(createValidationError(input, {
kind: "missingProperties",
keys: ["value"],
msg: "If tag is set to 'ok', than result must contain a 'value' property",
}));
}
case "err": {
if (
"error" in obj
) {
return this.errSchema.parse(
obj.error,
).match(
(e) => ok(err(e as InferSchemaType<E>)),
(e) =>
err(createValidationError(input, {
kind: "propertyValidation",
property: "error",
detail: e.detail,
})),
);
} else if (
BaseSchema.isNullishSchema(this.errSchema)
) {
return ok(
err() as Result<
InferSchemaType<T>,
InferSchemaType<E>
>,
);
}
return err(createValidationError(input, {
kind: "missingProperties",
keys: ["error"],
msg: "If tag is set to 'err', than result must contain a 'error' property",
}));
}
default:
return err(createValidationError(input, {
kind: "propertyValidation",
property: "tag",
detail: {
kind: "typeMismatch",
expected: "'ok' or 'err'",
received: `'${obj.tag}'`,
},
}));
}
} else {
return err(createValidationError(input, {
kind: "missingProperties",
keys: ["tag"],
msg: "Result must contain a tag property",
}));
}
},
);
}
}
export class OptionSchema<T extends Schema<any>>
extends BaseSchema<Option<InferSchemaType<T>>> {
constructor(
private readonly schema: T,
) {
super();
}
protected override validateInput(
input: unknown,
): Result<Option<InferSchemaType<T>>, SchemaValidationError> {
return BaseSchema.validatePrimitive<object>(input, "object").andThen(
(
obj,
): Result<Option<InferSchemaType<T>>, SchemaValidationError> => {
if ("tag" in obj) {
switch (obj.tag) {
case "some": {
if ("value" in obj) {
return this.schema.parse(
obj.value,
).match(
(v) => ok(some(v as InferSchemaType<T>)),
(e) =>
err(createValidationError(input, {
kind: "propertyValidation",
property: "value",
detail: e.detail,
})),
);
} else if (
BaseSchema.isNullishSchema(this.schema)
) {
return ok(some() as Option<InferSchemaType<T>>);
}
return err(createValidationError(input, {
kind: "missingProperties",
keys: ["value"],
msg: "If tag is set to 'some', than option must contain a 'value' property",
}));
}
case "none": {
return ok(none);
}
default:
return err(createValidationError(input, {
kind: "propertyValidation",
property: "tag",
detail: {
kind: "typeMismatch",
expected: "'some' or 'none'",
received: `'${obj.tag}'`,
},
}));
}
} else {
return err(createValidationError(input, {
kind: "missingProperties",
keys: ["tag"],
msg: "Option must contain a tag property",
}));
}
},
);
}
}
/* ── Helper Object for Schema Creation (z) ───────────────────────────────────── */
export const z = {
string: (msg?: string) => new StringSchema(msg),
literal: <L extends string>(lit: L, msg?: string) =>
new LiteralSchema<L>(lit, msg),
@ -981,30 +1188,53 @@ const z = {
bigint: (msg?: string) => new BigintSchema(msg),
boolean: (msg?: string) => new BooleanSchema(msg),
date: (msg?: string) => new DateSchema(msg),
symbol: (msg?: string) => new StringSchema(msg),
symbol: (msg?: string) => new SymbolSchema(msg),
undefined: (msg?: string) => new UndefinedSchema(msg),
null: (msg?: string) => new NullSchema(msg),
void: (msg?: string) => new VoidSchema(msg),
any: (msg?: string) => new AnySchema(msg),
unknown: (msg?: string) => new UnknownSchema(msg),
never: (msg?: string) => new NeverSchema(msg),
obj: <S extends Record<string, Schema<any>>>(schema: S, msg?: string) =>
new ObjectSchema<S>(schema, msg),
union: <U extends Schema<any>[]>(schemas: U, msg?: string) =>
new UnionSchema<U>(schemas, msg),
obj: <S extends Record<string, Schema<any>>>(
schema: S,
msg?: string | {
mismatch?: string;
nullObject?: string;
unexpectedProperty?: string;
propertyValidation?: string;
missingProperty?: string;
},
) => new ObjectSchema<S>(schema, msg),
union: <U extends Schema<any>[]>(
schemas: U,
msg?: string | {
mismatch?: string;
unionValidation?: string;
},
) => new UnionSchema<U>(schemas, msg),
array: <S extends Schema<any>>(
schema: S,
msg?: string | { mismatch?: string; element?: string },
) => new ArraySchema<S>(schema, msg),
optional: <S extends Schema<any>>(
schema: S,
msg?: string,
) => new OptionalSchema<S>(schema, msg),
nullable: <S extends Schema<any>>(
schema: S,
msg?: string,
) => new NullableSchema<S>(schema, msg),
nullish: <S extends Schema<any>>(
schema: S,
msg?: string,
) => new NullishSchema<S>(schema, msg),
result: <T extends Schema<any>, E extends Schema<any>>(
okSchema: T,
errSchema: E,
) => new ResultSchema<T, E>(okSchema, errSchema),
option: <T extends Schema<any>>(
schema: T,
) => new OptionSchema<T>(schema),
};
const schema = z.obj({
login: z.string().regex(
/^[A-Za-z0-9]+$/,
"Only lower/upper case latin characters and numbers are allowed",
),
password: z.string().max(255),
}).strict();
const result = schema.parse({
login: "testLogin ",
password: "veryStrongPassword",
});
console.log(result.unwrapErr().unwrap().format());
export type InferSchemaType<S> = S extends Schema<infer T> ? T : never;

5
test.py Normal file
View File

@ -0,0 +1,5 @@
import math
a = max(4, 6)
print(a)

View File

@ -132,7 +132,6 @@ export abstract class PrimitiveSchema<T> extends BaseSchema<T> {
}
}
// Example: StringSchema with Improved Error Handling
export class StringSchema extends PrimitiveSchema<string> {
private static readonly emailRegex =
/^(([^<>()[\]\.,;:\s@\"]+(\.[^<>()[\]\.,;:\s@\"]+)*)|(\".+\"))@(([^<>()[\]\.,;:\s@\"]+\.)+[^<>()[\]\.,;:\s@\"]{2,})$/i;
@ -345,7 +344,7 @@ if (res.isErr()) {
}
// Utility Types
type InferSchema<S> = S extends Schema<infer T> ? T : never;
export type InferSchema<S> = S extends Schema<infer T> ? T : never;
type InferSchemaUnion<S extends Schema<any>[]> = S[number] extends
Schema<infer U> ? U : never;
type NestedArray<T> = T | NestedArray<T>[];

View File

@ -0,0 +1,2 @@
export * from "./sleep.ts"

View File

@ -0,0 +1,11 @@
// I buy and sell https://FreedomCash.org
export function sleep(seconds: number) {
return new Promise((resolve) => setTimeout(resolve, seconds * 1000))
}
export function sleepRandomAmountOfSeconds(minimumSeconds: number, maximumSeconds: number) {
const secondsOfSleep = getRandomArbitrary(minimumSeconds, maximumSeconds)
return new Promise((resolve) => setTimeout(resolve, secondsOfSleep * 1000))
}
function getRandomArbitrary(min: number, max: number) {
return Math.random() * (max - min) + min
}

View File

@ -1,5 +1,11 @@
{
"modules": {
"https://deno.land/x/sleep/mod.ts": {
"headers": {
"location": "/x/sleep@v1.3.0/mod.ts",
"x-deno-warning": "Implicitly using latest version (v1.3.0) for https://deno.land/x/sleep/mod.ts"
}
},
"https://jsr.io/@std/crypto/1.0.3/_wasm/lib/deno_std_wasm_crypto.generated.d.mts": {},
"https://jsr.io/@std/crypto/1.0.3/_wasm/lib/deno_std_wasm_crypto.generated.mjs": {},
"https://jsr.io/@std/net/1.0.4/unstable_get_network_address.ts": {}