Compare commits

...

13 Commits

Author SHA1 Message Date
e1cc82f7bd working on websocket 2025-04-26 17:08:53 +03:00
ea308b2f1a adding variables to router 2025-03-19 20:15:48 +03:00
b8d705a805 workind on websocket 2025-02-28 19:39:03 +03:00
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
53 changed files with 2505 additions and 1377 deletions

3
README.md Normal file
View File

@ -0,0 +1,3 @@
# Keyborg
A Dockerized USB-over-IP server + Tauri-powered client for seamless USB device export and control.

6
deno.lock generated
View File

@ -41,6 +41,7 @@
"npm:@minify-html/wasm@*": "0.15.0", "npm:@minify-html/wasm@*": "0.15.0",
"npm:@tauri-apps/api@2": "2.2.0", "npm:@tauri-apps/api@2": "2.2.0",
"npm:@tauri-apps/cli@2": "2.2.5", "npm:@tauri-apps/cli@2": "2.2.5",
"npm:@tauri-apps/cli@2.2.5": "2.2.5",
"npm:@tauri-apps/plugin-shell@2": "2.2.0", "npm:@tauri-apps/plugin-shell@2": "2.2.0",
"npm:esbuild-plugin-tsc@*": "0.4.0_typescript@5.7.3", "npm:esbuild-plugin-tsc@*": "0.4.0_typescript@5.7.3",
"npm:esbuild-plugin-tsc@0.4": "0.4.0_typescript@5.7.3", "npm:esbuild-plugin-tsc@0.4": "0.4.0_typescript@5.7.3",
@ -673,6 +674,9 @@
] ]
} }
}, },
"redirects": {
"https://deno.land/x/sleep/mod.ts": "https://deno.land/x/sleep@v1.3.0/mod.ts"
},
"remote": { "remote": {
"https://deno.land/std@0.203.0/assert/assert.ts": "9a97dad6d98c238938e7540736b826440ad8c1c1e54430ca4c4e623e585607ee", "https://deno.land/std@0.203.0/assert/assert.ts": "9a97dad6d98c238938e7540736b826440ad8c1c1e54430ca4c4e623e585607ee",
"https://deno.land/std@0.203.0/assert/assertion_error.ts": "4d0bde9b374dfbcbe8ac23f54f567b77024fb67dbb1906a852d67fe050d42f56", "https://deno.land/std@0.203.0/assert/assertion_error.ts": "4d0bde9b374dfbcbe8ac23f54f567b77024fb67dbb1906a852d67fe050d42f56",
@ -686,6 +690,8 @@
"https://deno.land/std@0.203.0/async/pool.ts": "47c1841cfa9c036144943d11747ddd44064f5baf8cb7ece25473ba873c6aceb0", "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/retry.ts": "296fb9c323e1325a69bee14ba947e7da7409a8dd9dd646d70cb51ea0d301f24e",
"https://deno.land/std@0.203.0/async/tee.ts": "47e42d35f622650b02234d43803d0383a89eb4387e1b83b5a40106d18ae36757", "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" "https://wilsonl.in/minify-html/deno/0.15.0/index.js": "8e7ee5067ca84fb5d5a1f33118cac4998de0b7d80b3f56cc5c6728b84e6bfb70"
}, },
"workspace": { "workspace": {

109
server/api.ts Normal file
View File

@ -0,0 +1,109 @@
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/devices/detect",
"GET",
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(
"/api/version",
"GET",
versionApiSchema,
);

View File

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

View File

@ -1,6 +1,6 @@
{ {
"tasks": { "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": { "imports": {
"@db/sqlite": "jsr:@db/sqlite@^0.12.0", "@db/sqlite": "jsr:@db/sqlite@^0.12.0",

View File

@ -1,18 +1,41 @@
import HttpRouter from "@lib/router.ts"; import HttpRouter from "@src/lib/router.ts";
import { Eta } from "@eta-dev/eta"; import { Eta } from "@eta-dev/eta";
import { serveFile } from "jsr:@std/http/file-server"; import { serveFile } from "jsr:@std/http/file-server";
import rateLimitMiddleware from "@src/middleware/rateLimiter.ts"; import rateLimitMiddleware from "@src/middleware/rateLimiter.ts";
import authMiddleware from "@src/middleware/auth.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 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";
import { WebSocketClientsGroup } from "@src/lib/websocket.ts";
import { Option } from "@shared/utils/option.ts";
const router = new HttpRouter(); const AUTH_COOKIE_NAME = "token";
const VERSION = "0.1.0-a.1";
export type Variables = {
token: string;
};
const router = new HttpRouter<Variables>();
const views = Deno.cwd() + "/views/"; const views = Deno.cwd() + "/views/";
const eta = new Eta({ views }); export const eta = new Eta({ views });
router.use(loggerMiddleware); router.use(loggerMiddleware);
router.use(rateLimitMiddleware); router.use(rateLimitMiddleware);
@ -25,43 +48,174 @@ router.get("/public/*", async (c) => {
const cached = cache.get(filePath); const cached = cache.get(filePath);
if (cached) { // if (cached) {
return cached.clone(); // return cached.clone();
} // }
const res = await serveFile(c.req, filePath); const res = await serveFile(c.req, filePath);
cache.set(filePath, res.clone()); // cache.set(filePath, res.clone());
return res; return res;
}); });
router router
.get(["", "/index.html"], (c) => { .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) => { .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();
return c.html(eta.render("./login.html", { alreadyLoggedIn }));
}) })
.post("/login", async (c) => { .get("/setup", (c) => {
const r = await ResultFromJSON<{ password: string }>( return admin.isPasswordSet()
await c.req.text(), .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 const group = new WebSocketClientsGroup();
.get("/user/:id/:name/*", (c) => { group.onmessage = (e) => {
return c.html( group.sendToAll("pong");
`id = ${c.params.id}, name = ${c.params.name}, rest = ${c.params.restOfThePath}`, console.log("ping");
); };
});
router.get("/api/admin/ws", (c) => {
if (c.req.headers.get("upgrade") != "websocket") {
return new Response(null, { status: 501 });
}
const token = c.var.get("token");
let { socket, response } = Deno.upgradeWebSocket(c.req);
socket = group.addClient(token, socket).unwrap();
return response;
});
router router
.get("/user/:idButDifferent", (c) => { .api(loginApi, async (c) => {
return c.html( const r = await c
`idButDifferent = ${c.params.idButDifferent}`, .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,
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 { export default {
async fetch(req, connInfo) { async fetch(req, connInfo) {

View File

@ -0,0 +1 @@
import{WebSocketWrapper as o}from"./shared.bundle.js";const c=document.getElementById("ping"),i=document.getElementById("reconnect"),e=document.getElementById("info");c.onclick=async()=>{await n.ping()},i.onclick=async()=>{console.log(await n.connect())};const n=new o("api/admin/ws");n.onConnectInit=()=>{e.innerText="Connecting..."},n.onConnectSucc=()=>{e.innerText="Connected!"},n.onConnectFail=()=>{e.innerText="Failed to reconnect"},n.onDisconnect=()=>{e.innerText="Connection lost"},n.onMessage=t=>{console.log(t.data)},await n.connect();

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 s=document.getElementById("loginForm"),r=document.getElementById("passwordInput"),i=document.getElementById("errDiv");s.addEventListener("submit",async t=>{t.preventDefault();const n=r.value,e=(await o.makeRequest({password:n},{})).flatten();e.isErr()?i.innerText=e.error.info:window.location.href="/"});const m=new WebSocket("api/admin/ws");

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

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

@ -0,0 +1,138 @@
import { WebSocketWrapper } from "./shared.bundle.ts";
//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.dispatchEvent;
// 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;
const reconBtn = document.getElementById("reconnect") as HTMLButtonElement;
const infoDiv = document.getElementById("info") as HTMLDivElement;
pingBtn.onclick = async () => {
await wrapper.ping();
};
reconBtn.onclick = async () => {
console.log(await wrapper.connect());
};
const wrapper = new WebSocketWrapper(
"api/admin/ws",
);
wrapper.onConnectInit = () => {
infoDiv.innerText = "Connecting...";
};
wrapper.onConnectSucc = () => {
infoDiv.innerText = "Connected!";
};
wrapper.onConnectFail = () => {
infoDiv.innerText = "Failed to reconnect";
};
wrapper.onDisconnect = () => {
infoDiv.innerText = "Connection lost";
};
wrapper.onMessage = (ev) => {
console.log(ev.data);
};
await wrapper.connect();

View File

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

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,6 @@
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";
export * from "@src/lib/wsClient.ts";

View File

@ -1,7 +1,11 @@
import { Option, some } from "@shared/utils/option.ts"; import { Option, some } from "@shared/utils/option.ts";
import db from "@lib/db/index.ts"; import db from "@lib/db/index.ts";
import { ok, Result } from "@shared/utils/result.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 { AdminRaw, AdminSessionRaw } from "@lib/db/types/index.ts";
import { generateRandomString, passwd } from "@lib/utils.ts"; import { generateRandomString, passwd } from "@lib/utils.ts";
import { errAsync, ResultAsync } from "@shared/utils/resultasync.ts"; import { errAsync, ResultAsync } from "@shared/utils/resultasync.ts";
@ -52,7 +56,7 @@ class Admin {
const result = this.getPasswordHash().flattenOption( const result = this.getPasswordHash().flattenOption(
() => { () => {
log.warn("Tried to verify password when it is not set"); log.warn("Tried to verify password when it is not set");
return new AdminPasswordNotSetError( return adminPasswordNotSetError(
"Admin password is not set", "Admin password is not set",
); );
}, },
@ -131,13 +135,20 @@ class AdminSessions {
}, EXPIRED_TOKENS_DELETION_INTERVAL); }, 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); const token = generateRandomString(TOKEN_LENGTH);
if (expiresAt) { if (expiresAt) {
return this.statements return this.statements
.insertSessionTokenWithExpiry(token, expiresAt.toISOString()) .insertSessionTokenWithExpiry(token, expiresAt.toISOString())
.map(() => token); .map(() => {
return {
value: token,
expires: expiresAt,
};
});
} }
const now = new Date(); const now = new Date();
@ -148,7 +159,12 @@ class AdminSessions {
return this.statements return this.statements
.insertSessionTokenWithExpiry(token, expiresAtDefault.toISOString()) .insertSessionTokenWithExpiry(token, expiresAtDefault.toISOString())
.map(() => token); .map(() => {
return {
value: token,
expires: expiresAtDefault,
};
});
} }
public verifyToken(token: string): Result<boolean, QueryExecutionError> { 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> { export type ExtractRouteParams<T extends string> = T extends string
client = { ? T extends `${infer _Start}:${infer Param}/${infer Rest}`
validate(res: Response): Result<Req, any>, ? Param | ExtractRouteParams<Rest>
}; : T extends `${infer _Start}:${infer Param}` ? Param
server = { : never
validate(req: Request): Result<Res, any>, : 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,15 @@ import { type ExtractRouteParams } from "@lib/router.ts";
import { fromNullableVal, none, Option, some } from "@shared/utils/option.ts"; import { fromNullableVal, none, Option, some } from "@shared/utils/option.ts";
import { deleteCookie, getCookies, setCookie } from "@std/http/cookie"; import { deleteCookie, getCookies, setCookie } from "@std/http/cookie";
import { type Cookie } 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 } from "@shared/utils/validator.ts";
import { ResultAsync } from "@shared/utils/resultasync.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 // https://cheatsheetseries.owasp.org/cheatsheets/HTTP_Headers_Cheat_Sheet.html
const SECURITY_HEADERS: Headers = new Headers({ const SECURITY_HEADERS: Headers = new Headers({
@ -17,32 +25,42 @@ const SECURITY_HEADERS: Headers = new Headers({
//"Content-Security-Policy": //"Content-Security-Policy":
// "default-src 'self'; script-src 'self' 'unsafe-inline'", // "default-src 'self'; script-src 'self' 'unsafe-inline'",
}); });
const HTML_CONTENT_TYPE: [string, string] = [
"Content-Type", const HTML_CONTENT_TYPE: string = "text/html; charset=UTF-8";
"text/html; charset=UTF-8", const JSON_CONTENT_TYPE: string = "application/json; charset=utf-8";
];
const JSON_CONTENT_TYPE: [string, string] = [
"Content-Type",
"application/json; charset=utf-8",
];
function mergeHeaders(...headers: Headers[]): Headers { function mergeHeaders(...headers: Headers[]): Headers {
const mergedHeaders = new Headers(); const merged = new Headers();
for (const _headers of headers) { for (const hdr of headers) {
for (const [key, value] of _headers.entries()) { hdr.forEach((value, key) => merged.set(key, value));
mergedHeaders.set(key, value);
} }
} return merged;
return mergedHeaders;
} }
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>,
Variables extends Record<string | number, any> = Record<
string | number,
any
>,
> {
private _url?: URL; private _url?: URL;
private _hostname?: string; private _hostname?: string;
private _port?: number; private _port?: number;
private _cookies?: Record<string, string>; private _cookies?: Record<string, string>;
private _responseHeaders: Headers = new Headers();
public res: Response = new Response(); public res = new Response();
constructor( constructor(
public readonly req: Request, public readonly req: Request,
@ -50,6 +68,63 @@ export class Context<S extends string = string> {
public readonly params: Params<ExtractRouteParams<S>>, 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;
ctx._var = this._var;
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;
ctx._var = this._var;
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 { get url(): URL {
return this._url ?? (this._url = new URL(this.req.url)); return this._url ?? (this._url = new URL(this.req.url));
} }
@ -59,30 +134,34 @@ export class Context<S extends string = string> {
} }
get preferredType(): Option<"json" | "html"> { get preferredType(): Option<"json" | "html"> {
const headers = new Headers(this.req.headers); const accept = this.req.headers.get("accept");
if (!accept) return none;
return fromNullableVal(headers.get("accept")).andThen( const types = accept
(types_header) => { .split(",")
const types = types_header.split(";")[0].trim().split(","); .map((t) => t.split(";")[0].trim());
if (types.includes("text/html")) return some("html");
for (const type of types) { if (types.includes("application/json")) return some("json");
if (type === "text/html") {
return some("html");
}
if (type === "application/json") {
return some("json");
}
}
return none; 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> { get hostname(): Option<string> {
if (this._hostname) return some(this._hostname); if (this._hostname) return some(this._hostname);
const remoteAddr = this.info.remoteAddr; const remoteAddr = this.info.remoteAddr;
if (remoteAddr.transport === "tcp" || remoteAddr.transport === "udp") { if (remoteAddr.transport === "tcp" || remoteAddr.transport === "udp") {
this._hostname = remoteAddr.hostname; this._hostname = remoteAddr.hostname;
return some(remoteAddr.hostname); return some(remoteAddr.hostname);
@ -92,9 +171,7 @@ export class Context<S extends string = string> {
get port(): Option<number> { get port(): Option<number> {
if (this._port) return some(this._port); if (this._port) return some(this._port);
const remoteAddr = this.info.remoteAddr; const remoteAddr = this.info.remoteAddr;
if (remoteAddr.transport === "tcp" || remoteAddr.transport === "udp") { if (remoteAddr.transport === "tcp" || remoteAddr.transport === "udp") {
this._port = remoteAddr.port; this._port = remoteAddr.port;
return some(remoteAddr.port); return some(remoteAddr.port);
@ -102,16 +179,27 @@ export class Context<S extends string = string> {
return none; return none;
} }
public json(body?: object | string, init: ResponseInit = {}): Response { private buildHeaders(
const headers = mergeHeaders( initHeaders?: HeadersInit,
contentType?: string,
): Headers {
const merged = mergeHeaders(
SECURITY_HEADERS, SECURITY_HEADERS,
this._responseHeaders, this.res.headers,
new Headers(init.headers), new Headers(initHeaders),
); );
headers.set(...JSON_CONTENT_TYPE); if (contentType) merged.set("Content-Type", contentType);
let status = init.status || 200; 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; let responseBody: BodyInit | null = null;
if (typeof body === "string") { if (typeof body === "string") {
responseBody = body; responseBody = body;
} else if (body !== undefined) { } else if (body !== undefined) {
@ -126,74 +214,68 @@ export class Context<S extends string = string> {
} }
} }
return new Response(responseBody, { this.res = new Response(responseBody, {
status, status,
headers, 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 { public html(body?: BodyInit | null, init: ResponseInit = {}): Response {
const headers = mergeHeaders( const headers = this.buildHeaders(init.headers, HTML_CONTENT_TYPE);
SECURITY_HEADERS,
this._responseHeaders,
new Headers(init.headers),
);
headers.set(...HTML_CONTENT_TYPE);
const status = init.status ?? 200; const status = init.status ?? 200;
this.res = new Response(body ?? null, { status, headers });
return new Response(body ?? null, { return this.res;
status,
headers,
});
} }
public redirect(url: string, permanent = false): Response { public redirect(url: string, permanent = false): Response {
const headers = mergeHeaders( const headers = mergeHeaders(
this._responseHeaders, this.res.headers,
new Headers({ location: url }), new Headers({ location: url }),
); );
this.res = new Response(null, {
return new Response(null, {
status: permanent ? 301 : 302, status: permanent ? 301 : 302,
headers, headers,
}); });
return this.res;
} }
public cookies = (() => { public get cookies() {
const self = this;
return { return {
get(name: string): Option<string> { get: (name: string): Option<string> => {
if (!self._cookies) { this._cookies ??= getCookies(this.req.headers);
self._cookies = getCookies(self.req.headers); return fromNullableVal(this._cookies[name]);
},
set: (cookie: Cookie) => setCookie(this.res.headers, cookie),
delete: (name: string) => deleteCookie(this.res.headers, name),
};
} }
return fromNullableVal(self._cookies[name]); private _var: Variables = {} as Variables;
},
set(cookie: Cookie) { public get var() {
setCookie(self._responseHeaders, cookie); return {
set: <K extends keyof Variables>(key: K, value: Variables[K]) => {
this._var[key] = value;
}, },
get: <K extends keyof Variables>(key: K): Variables[K] => {
delete(name: string) { return this._var[key];
deleteCookie(self._responseHeaders, 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 { Database, RestBindParameters } from "@db/sqlite";
import { err, getMessageFromError, ok, Result } from "@shared/utils/result.ts"; 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 { fromNullableVal, none, Option, some } from "@shared/utils/option.ts";
import log from "@shared/utils/logger.ts"; import log from "@shared/utils/logger.ts";
@ -13,7 +13,7 @@ export class DatabaseClient {
} catch (e) { } catch (e) {
const message = getMessageFromError(e); const message = getMessageFromError(e);
log.error(`Failed to execute sql! Error: ${e}`); log.error(`Failed to execute sql! Error: ${e}`);
return err(new QueryExecutionError(message)); return err(queryExecutionError(message));
} }
} }

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

@ -0,0 +1,144 @@
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;
const DEFAULT_STATE = 0;
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) as (keyof DeviceMutables)[]) {
if (update[key] !== undefined) {
switch (key) {
case "status":
device.status = update.status ?? device.status;
break;
case "displayName":
device.displayName = update.displayName ?? none;
break;
case "description":
device.description = update.description ?? none;
break;
}
}
}
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,
status: DEFAULT_STATE,
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()),
status: z.enum([0, 1, 2]), // 0 - private, 1 - public, 2 - exported
displayName: z.option(z.string()),
description: z.option(z.string()),
connectedAt: z.date(),
}).strict();
export const deviceMutablesSchema = deviceSchema.pick({
status: true,
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,184 @@
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 { export const queryExecutionErrorSchema = defineError(
constructor(message: string = "An unknown error has occurred") { "QueryExecutionError",
super(message); );
this.name = this.constructor.name; export const queryExecutionError = createErrorFactory(
} queryExecutionErrorSchema,
} );
export type QueryExecutionError = InferSchemaType<
typeof queryExecutionErrorSchema
>;
export class QueryExecutionError extends ErrorBase { export const noAdminEntryErrorSchema = defineError("NoAdminEntryError");
public readonly code = "QueryExecutionError"; export const noAdminEntryError = createErrorFactory(noAdminEntryErrorSchema);
constructor(message: string) { export type NoAdminEntryError = InferSchemaType<typeof noAdminEntryErrorSchema>;
super(message);
}
}
export class NoAdminEntryError extends ErrorBase { export const failedToReadFileErrorSchema = defineError("FailedToReadFileError");
public readonly code = "NoAdminEntry"; export const failedToReadFileError = createErrorFactory(
constructor(message: string) { failedToReadFileErrorSchema,
super(message); );
} export type FailedToReadFileError = InferSchemaType<
} typeof failedToReadFileErrorSchema
>;
export class FailedToReadFileError extends ErrorBase { export const invalidSyntaxErrorSchema = defineError("InvalidSyntaxError");
public readonly code = "FailedToReadFileError"; export const invalidSyntaxError = createErrorFactory(invalidSyntaxErrorSchema);
constructor(message: string) { export type InvalidSyntaxError = InferSchemaType<
super(message); typeof invalidSyntaxErrorSchema
} >;
}
export class InvalidSyntaxError extends ErrorBase { export const invalidPathErrorSchema = defineError("InvalidPathError");
public readonly code = "InvalidSyntax"; export const invalidPathError = createErrorFactory(invalidPathErrorSchema);
constructor(message: string) { export type InvalidPathError = InferSchemaType<typeof invalidPathErrorSchema>;
super(message);
}
}
export class InvalidPathError extends ErrorBase { export const adminPasswordNotSetErrorSchema = defineError(
public readonly code = "InvalidPath"; "AdminPasswordNotSetError",
constructor(message: string) { );
super(message); export const adminPasswordNotSetError = createErrorFactory(
} adminPasswordNotSetErrorSchema,
} );
export type AdminPasswordNotSetError = InferSchemaType<
typeof adminPasswordNotSetErrorSchema
>;
export class AdminPasswordNotSetError extends ErrorBase { export const requestValidationErrorSchema = defineError(
public readonly code = "AdminPasswordNotSetError"; "RequestValidationError",
constructor(message: string) { );
super(message); 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>;
export const tooManyConnectionErrorSchema = defineError(
"tooManyConnectionError",
);
export const tooManyConnectionError = createErrorFactory(
tooManyConnectionErrorSchema,
);
export type TooManyConnectionError = InferSchemaType<
typeof tooManyConnectionErrorSchema
>;
export const webSocketMsgSendErrorSchema = defineError("WebSocketMsgSendError");
export const webSocketMsgSendError = createErrorFactory(
webSocketMsgSendErrorSchema,
);
export type WebSocketMsgSendError = InferSchemaType<
typeof webSocketMsgSendErrorSchema
>;

0
server/src/lib/events.ts Normal file
View File

View File

@ -1,62 +1,111 @@
import { RouterTree } from "@lib/routerTree.ts"; import { RouterTree } from "@src/lib/routerTree.ts";
import { none, Option, some } from "@shared/utils/option.ts"; import { Context } from "@src/lib/context.ts";
import { Context } from "@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> = ( type VariablesType = Record<string | number, any>;
c: Context<S>,
type RequestHandler<
S extends string = string,
ReqSchema extends Schema<unknown> = Schema<unknown>,
ResSchema extends Schema<unknown> = Schema<unknown>,
Variables extends VariablesType = Record<
string | number,
any
>,
> = (
c: Context<S, ReqSchema, ResSchema, Variables>,
) => Promise<Response> | Response; ) => Promise<Response> | Response;
export type Middleware = (
c: Context<string>, export type Middleware<
Variables extends VariablesType = Partial<Record<string | number, any>>,
> = (
c: Context<string, any, any, Variables>,
next: () => Promise<void>, next: () => Promise<void>,
) => Promise<Response | void> | Response | void; ) => Promise<Response | void> | Response | void;
type MethodHandlers<S extends string> = Partial< type MethodHandler<S extends string> = {
Record<string, RequestHandler<S>> handler: RequestHandler<S>;
schema?: { req: Schema<unknown>; res: Schema<unknown> };
};
type MethodHandlers<S extends string = string> = Partial<
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;
class HttpRouter { const DEFAULT_NOT_ALLOWED_HANDLER =
routerTree = new RouterTree<MethodHandlers<any>>(); ((c) =>
pathPreprocessor?: (path: string) => string; c.json(err(notAllowedError("405 Not allowed")), {
middlewares: Middleware[] = []; status: 405,
defaultNotFoundHandler: RequestHandler<string> = DEFAULT_NOT_FOUND_HANDLER; })) as RequestHandler;
setPathProcessor(processor: (path: string) => string) { class HttpRouter<
this.pathPreprocessor = processor; Variables extends VariablesType = Partial<
} Record<
string | number,
any
>
>,
> {
public readonly routerTree = new RouterTree<MethodHandlers>();
public pathTransformer?: (path: string) => string;
private middlewares: Middleware<Variables>[] = [];
public notFoundHandler: RequestHandler = DEFAULT_NOT_FOUND_HANDLER;
public methodNotAllowedHandler: RequestHandler =
DEFAULT_NOT_ALLOWED_HANDLER;
use(mw: Middleware): HttpRouter { public setPathTransformer(transformer: (path: string) => string) {
this.middlewares.push(mw); this.pathTransformer = transformer;
return this; return this;
} }
add<S extends string>( public use(middleware: Middleware<Variables>): this {
this.middlewares.push(middleware);
return this;
}
public add<
S extends string,
ReqSchema extends Schema<unknown> = Schema<unknown>,
ResSchema extends Schema<unknown> = Schema<unknown>,
>(
path: S, path: S,
method: string, method: string,
handler: RequestHandler<S>, handler: RequestHandler<S, ReqSchema, ResSchema, Variables>,
): HttpRouter; schema?: { req: ReqSchema; res: ResSchema },
add<S extends string>( ): this;
public add<
S extends string,
ReqSchema extends Schema<unknown> = Schema<unknown>,
ResSchema extends Schema<unknown> = Schema<unknown>,
>(
path: S[], path: S[],
method: string, method: string,
handler: RequestHandler<string>, handler: RequestHandler<string, ReqSchema, ResSchema, Variables>,
): HttpRouter; schema?: { req: ReqSchema; res: ResSchema },
add( ): this;
public add(
path: string | string[], path: string | string[],
method: string, method: string,
handler: RequestHandler<string>, handler: RequestHandler<string>,
): HttpRouter { schema?: { req: Schema<unknown>; res: Schema<unknown> },
): this {
const paths = Array.isArray(path) ? path : [path]; const paths = Array.isArray(path) ? path : [path];
for (const p of paths) { for (const p of paths) {
this.routerTree.getHandler(p).match( this.routerTree.getHandler(p).match(
(mth) => { (existingHandlers) => {
mth[method] = handler; existingHandlers[method] = { handler, schema };
}, },
() => { () => {
const mth: MethodHandlers<string> = {}; const newHandlers: MethodHandlers = {};
mth[method] = handler; newHandlers[method] = { handler, schema };
this.routerTree.add(p, mth); this.routerTree.add(p, newHandlers);
}, },
); );
} }
@ -64,96 +113,141 @@ class HttpRouter {
return this; return this;
} }
// Overload signatures for 'get' public get<S extends string>(
get<S extends string>(path: S, handler: RequestHandler<S>): HttpRouter; path: S,
get<S extends string>( handler: RequestHandler<S, any, any, Variables>,
): this;
public get<S extends string>(
path: S[], path: S[],
handler: RequestHandler<string>, handler: RequestHandler<S, any, any, Variables>,
): HttpRouter; ): this;
public get(
// Non-generic implementation for 'get' path: string | string[],
get(path: string | string[], handler: RequestHandler<string>): HttpRouter { handler: RequestHandler<string, any, any, Variables>,
): this {
if (Array.isArray(path)) { if (Array.isArray(path)) {
return this.add(path, "GET", handler); return this.add(path, "GET", handler);
} }
return this.add(path, "GET", handler); return this.add(path, "GET", handler);
} }
post<S extends string>(path: S, handler: RequestHandler<S>): HttpRouter; public post<S extends string>(
post<S extends string>( path: S,
handler: RequestHandler<S, any, any, Variables>,
): HttpRouter;
public post(
path: string[], path: string[],
handler: RequestHandler<string>, handler: RequestHandler<string, any, any, Variables>,
): HttpRouter; ): HttpRouter;
public post(
post(path: string | string[], handler: RequestHandler<string>): HttpRouter { path: string | string[],
handler: RequestHandler<string, any, any, Variables>,
): HttpRouter {
if (Array.isArray(path)) { if (Array.isArray(path)) {
return this.add(path, "POST", handler); return this.add(path, "POST", handler);
} }
return this.add(path, "POST", handler); return this.add(path, "POST", handler);
} }
public api<
Path extends string,
ReqSchema extends Schema<unknown>,
ResSchema extends Schema<unknown>,
>(
api: Api<Path, ReqSchema, ResSchema>,
handler: RequestHandler<Path, ReqSchema, ResSchema, Variables>,
): HttpRouter {
return this.add(api.path, api.method, handler, api.schema);
}
async handleRequest( async handleRequest(
req: Request, req: Request,
connInfo: Deno.ServeHandlerInfo<Deno.Addr>, connInfo: Deno.ServeHandlerInfo<Deno.Addr>,
): Promise<Response> { ): 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 const { handler, params, ctx: routeCtx } = this.resolveRoute(ctx, path);
? this.pathPreprocessor(c.path) ctx = routeCtx.setParams(params);
: c.path;
let params: string[] = []; const res =
(await this.runMiddlewares(this.middlewares, handler, ctx)).res;
const handler = this.routerTree return req.method === "HEAD" ? this.removeBodyFromResponse(res) : res;
.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);
const cf = await this.executeMiddlewareChain(
this.middlewares,
handler,
Context.setParams(c, params),
);
return cf.res;
} }
private async executeMiddlewareChain<S extends string>( private resolveRoute(
ctx: Context,
path: string,
): {
handler: RequestHandler;
params: Record<string, string>;
ctx: Context<any>;
} {
const routeOption = this.routerTree.find(path);
if (routeOption.isSome()) {
const { value: methodHandlers, params } = routeOption.value;
let route = methodHandlers[ctx.req.method];
if (!route && ctx.req.method === "HEAD") {
route = methodHandlers["GET"];
} else if (!route && ctx.req.method !== "GET") {
if (ctx.preferredType.map((v) => v === "json").toBoolean()) {
return {
handler: this.methodNotAllowedHandler,
params,
ctx,
};
}
}
if (route) {
if (route.schema) {
ctx = ctx.setSchema(route.schema);
}
return { handler: route.handler, params, ctx };
}
}
return { handler: this.notFoundHandler, params: {}, ctx };
}
private removeBodyFromResponse(res: Response): Response {
const headers = new Headers(res.headers);
headers.set("Content-Length", "0");
return new Response(null, {
headers,
status: res.status,
statusText: res.statusText,
});
}
private async runMiddlewares(
middlewares: Middleware[], middlewares: Middleware[],
handler: RequestHandler<S>, handler: RequestHandler,
c: Context<S>, ctx: Context,
) { ) {
let currentIndex = -1;
const dispatch = async (index: number): Promise<void> => { const dispatch = async (index: number): Promise<void> => {
currentIndex = index;
if (index < middlewares.length) { if (index < middlewares.length) {
const middleware = middlewares[index]; const middleware = middlewares[index];
const result = await middleware(ctx, () => dispatch(index + 1));
const result = await middleware(c, () => dispatch(index + 1));
if (result !== undefined) { if (result !== undefined) {
c.res = await Promise.resolve(result); ctx.res = await Promise.resolve(result);
} }
} else { } else {
const res = await handler(c); ctx.res = await handler(ctx);
c.res = res;
} }
}; };
await dispatch(0); await dispatch(0);
return ctx;
return c;
} }
private setParams(path: string, params: string[]): Params<string> { private buildNotFoundHandler(c: Context) {
path.split("/").filter((segmet) => segmet.startsWith(":")); return c.matchPreferredType(
() => c.html("404 Not found", { status: 404 }),
() => c.json(err(notFoundError("404 Not found")), { status: 404 }),
() => new Response("404 Not found", { status: 404 }),
);
} }
} }

View File

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

111
server/src/lib/websocket.ts Normal file
View File

@ -0,0 +1,111 @@
import {
TooManyConnectionError,
tooManyConnectionError,
WebSocketMsgSendError,
webSocketMsgSendError,
} from "@src/lib/errors.ts";
import { err, getMessageFromError, ok, Result } from "@shared/utils/result.ts";
import {
InferSchemaType,
Schema,
SchemaValidationError,
z,
} from "@shared/utils/validator.ts";
import log from "@shared/utils/logger.ts";
const MAX_CONNECTIONS_PER_TOKEN = 2;
const MAX_CONNECTIONS = 500;
export class WebSocketClientsGroup<
ReceiveSchema extends Schema<unknown> = Schema<unknown>,
SendSchema extends Schema<unknown> = Schema<unknown>,
> {
private clients: Map<string, Map<string, WebSocket>> = new Map();
private connectionsCounter: number = 0;
constructor(
public schemas: {
onReceive: ReceiveSchema;
onSend: SendSchema;
} = {
onReceive: z.unknown() as Schema<unknown> as ReceiveSchema,
onSend: z.unknown() as Schema<unknown> as SendSchema,
},
public onopen?: EventListenerOrEventListenerObject,
public onclose?: EventListenerOrEventListenerObject,
public onerror?: EventListenerOrEventListenerObject,
public onmessage?: (e: MessageEvent) => any,
) {}
public addClient(
token: string,
socket: WebSocket,
lifetime?: Date,
): Result<WebSocket, TooManyConnectionError> {
if (this.connectionsCounter > MAX_CONNECTIONS) {
return err(tooManyConnectionError("Too many connections"));
}
let clientConnections = this.clients.get(token);
if (!clientConnections) {
this.clients.set(token, new Map());
clientConnections = this.clients.get(token) as Map<
string,
WebSocket
>;
} else if (clientConnections.size >= MAX_CONNECTIONS_PER_TOKEN) {
return err(tooManyConnectionError("Too many connections"));
}
const uuid = crypto.randomUUID();
clientConnections.set(uuid, socket);
socket.addEventListener("close", () => {
clientConnections.delete(uuid);
this.connectionsCounter--;
});
socket.addEventListener("error", () => {
clientConnections.delete(uuid);
this.connectionsCounter--;
});
this.connectionsCounter++;
if (this.onopen) {
socket.addEventListener("open", this.onopen);
}
if (this.onclose) {
socket.addEventListener("close", this.onclose);
}
if (this.onerror) {
socket.addEventListener("error", this.onerror);
}
if (this.onmessage) {
socket.addEventListener("message", this.onmessage);
}
return ok(socket);
}
sendToAll(
msg: InferSchemaType<SendSchema>,
): Result<void, SchemaValidationError | WebSocketMsgSendError[]> {
return this.schemas.onSend.parse(msg)
.andThen((msg) => {
const errors = [];
for (const client of this.clients.values()) {
for (const connection of client.values()) {
try {
connection.send(JSON.stringify(msg));
} catch (e) {
log.error("Failed to send messages to all clients");
errors.push(
webSocketMsgSendError(getMessageFromError(e)),
);
}
}
}
return errors.length === 0 ? ok() : err(errors);
});
}
}

186
server/src/lib/wsClient.ts Normal file
View File

@ -0,0 +1,186 @@
import { none, type Option, some } from "@shared/utils/option.ts";
import { errAsync, okAsync, ResultAsync } from "@shared/utils/resultasync.ts";
import { InferSchemaType, Schema, z } from "@shared/utils/validator.ts";
const CONNECTION_TIMEOUT_MS = 2000;
const PING_INTERVAL_MS = 5000;
const PING_CHECK_INTERVAL_MS = 15000;
const MAX_PING_ATTEMPTS = 5;
const MAX_RECONNECTION_ATTEMPTS = 5;
export class WebSocketWrapper<
R extends Schema<unknown> = Schema<unknown>,
S extends Schema<unknown> = Schema<unknown>,
> {
private _ws: Option<WebSocket> = none;
get ws(): Option<WebSocket> {
return this._ws;
}
set ws(ws: Option<WebSocket>) {
this._ws = ws;
if (ws.isSome()) {
ws.value.addEventListener("close", this.handleWebSocketClose);
ws.value.addEventListener("error", this.handleWebSocketError);
ws.value.addEventListener("message", this.onMessage!);
this.onConnectSucc!();
this.pingAndWait();
} else {
this.onDisconnect!();
}
}
private handleWebSocketClose = () => {
this._ws = none;
this.connect();
};
private handleWebSocketError = () => {
this._ws = none;
this.connect();
};
private pingAndWait = async () => {
const r = await this.ping();
if (r.isErr()) {
clearTimeout(this.pingTimer);
this.ws = none;
}
this.pingTimer = setTimeout(this.pingAndWait, PING_CHECK_INTERVAL_MS);
};
private pingTimer?: number;
public onConnectInit?: () => void;
public onConnectSucc?: () => void;
public onConnectFail?: () => void;
public onDisconnect?: () => void;
public onMessage?: (ev: MessageEvent<unknown>) => void;
private isConnecting = false;
constructor(
public readonly url: string,
public readonly schema: {
receive: R;
send: S;
},
private readonly timeout = CONNECTION_TIMEOUT_MS,
private readonly pingInterval = PING_INTERVAL_MS,
) {}
public ping(): ResultAsync<void, void> {
if (this.ws.isNone()) {
return errAsync();
}
const ws = this.ws.value;
return ResultAsync.from((resolve, reject) => {
let timer: number;
const listener = (e: MessageEvent<any>) => {
if (e.data === "pong") {
ws.removeEventListener("message", listener);
clearTimeout(timer);
resolve();
}
};
ws.addEventListener("message", listener);
let attempts = 0;
const pingAndWait = () => {
if (++attempts > MAX_PING_ATTEMPTS) reject();
ws.send("ping");
timer = setTimeout(pingAndWait, this.pingInterval);
};
pingAndWait();
});
}
private createWebSocketConnection(): ResultAsync<WebSocket, void> {
const ws = new WebSocket(this.url);
return ResultAsync.from((resolve, reject) => {
const handleError = () => {
reject();
};
ws.addEventListener("open", () => {
ws.removeEventListener("error", handleError);
resolve(ws);
});
ws.addEventListener("error", handleError);
});
}
public connect(): ResultAsync<void, void> {
if (this.isConnecting) {
return errAsync();
}
return ResultAsync.fromSafePromise(
this.ping().match(
() => okAsync(),
() => {
this.isConnecting = true;
this.onConnectInit!();
this.tryReconnect();
},
),
).flatten();
}
private tryReconnect(): ResultAsync<void, void> {
return ResultAsync.from((resolve, reject) => {
let attempt = 0;
let timer: number;
const tryConnect = async () => {
console.log(`attempt ${attempt + 1}`);
if (++attempt >= MAX_RECONNECTION_ATTEMPTS) {
this.onConnectFail!();
this.isConnecting = false;
console.error("Failed to connect");
reject();
return;
}
const ws = await this.createWebSocketConnection();
if (ws.isOk()) {
this.ws = some(ws.value);
clearTimeout(timer);
this.isConnecting = false;
resolve();
} else {
timer = setTimeout(
tryConnect,
this.timeout,
);
}
};
tryConnect();
});
}
send(data: InferSchemaType<S>): ResultAsync<void, void> {
if (this.ws.isNone()) {
return errAsync();
}
}
}
const sendSchema = z.obj({
id: z.number(),
kind: z.enum(["up"]),
});

View File

@ -1,28 +1,57 @@
import { Middleware } from "@lib/router.ts"; import { Middleware } from "@lib/router.ts";
import admin from "@lib/admin.ts"; import admin from "@lib/admin.ts";
import { queryExecutionError, unauthorizedError } from "@lib/errors.ts";
import { err, ok } from "@shared/utils/result.ts";
import { eta, Variables } from "../../main.ts";
const LOGIN_PATH = "/login"; const EXCLUDE = new Set(["/login", "/setup", "/version"]);
const authMiddleware: Middleware = async (c, next) => { const authMiddleware: Middleware<Variables> = async (c, next) => {
const token = c.cookies.get("token"); const token = c.cookies.get("token");
const isValid = token const isValid = token
.map((token) => admin.sessions.verifyToken(token)) .map((token) => admin.sessions.verifyToken(token)).match(
.toBoolean(); (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; const path = c.path;
if (path.startsWith("/public")) { if (
await next(); !isValid.value && !path.startsWith("/public") && !EXCLUDE.has(path)
} else { ) {
if (path !== LOGIN_PATH && !isValid) { if (!isValid.value) {
c.cookies.delete("token");
}
if (c.preferredType.isNone()) {
return new Response("401 unautorized", { status: 401 });
}
switch (c.preferredType.value) {
case "json":
return c.json(
err(unauthorizedError("Unauthorized")),
{ status: 401 },
);
case "html":
return c.redirect("/login"); return c.redirect("/login");
} }
if (path === LOGIN_PATH && isValid) {
return c.redirect("");
} }
c.var.set("token", token.unwrapOr(""));
await next(); await next();
}
}; };
export default authMiddleware; export default authMiddleware;

View File

@ -1,6 +1,7 @@
import { Middleware } from "@lib/router.ts"; import { Middleware } from "@lib/router.ts";
import { Variables } from "../../main.ts";
const loggerMiddleware: Middleware = async (c, next) => { const loggerMiddleware: Middleware<Variables> = async (c, next) => {
console.log("", c.req.method, c.path); console.log("", c.req.method, c.path);
await next(); await next();
console.log("", c.res.status, "\n"); console.log("", c.res.status, "\n");

View File

@ -1,5 +1,8 @@
import { Middleware } from "@lib/router.ts"; import { Middleware } from "@lib/router.ts";
import log from "@shared/utils/logger.ts"; import log from "@shared/utils/logger.ts";
import { err } from "@shared/utils/result.ts";
import { tooManyRequestsError } from "@lib/errors.ts";
import { Variables } from "../../main.ts";
const requestCounts: Partial< const requestCounts: Partial<
Record<string, { count: number; lastReset: number }> Record<string, { count: number; lastReset: number }>
@ -8,7 +11,7 @@ const requestCounts: Partial<
const MAX_REQUESTS_PER_WINDOW = 300; const MAX_REQUESTS_PER_WINDOW = 300;
const RATE_LIMIT_WINDOW = 60000; const RATE_LIMIT_WINDOW = 60000;
const rateLimitMiddleware: Middleware = async (c, next) => { const rateLimitMiddleware: Middleware<Variables> = async (c, next) => {
const hostnameOpt = c.hostname; const hostnameOpt = c.hostname;
if (hostnameOpt.isSome()) { if (hostnameOpt.isSome()) {
@ -31,9 +34,7 @@ const rateLimitMiddleware: Middleware = async (c, next) => {
} }
case "json": { case "json": {
return c.json( return c.json(
{ err(tooManyRequestsError("Too many request")),
err: "429 Too Many Requests",
},
{ {
status: 429, status: 429,
}, },

View File

@ -1,3 +1,10 @@
<% layout("./layouts/layout.html") %> <% layout("./layouts/layout.html") %>
<div id="devices_grid">
</div>
this is an index.html <div id="info"></div>
<button id="ping">ping</button>
<button id="reconnect">reconnect</button>
<script src="/public/js/index.js" type="module" 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") %> <% layout("./layouts/basic.html") %>
<% if (!it.alreadyLoggedIn) { %>
<main> <main>
<form id=loginForm method=POST> <form id=loginForm method=POST>
<p>password</p><input id=passwordInput name=password type=password><input value="sign in" type=submit> <p>password</p><input id=passwordInput name=password type=password><input value="sign in" type=submit>
<div id="errDiv"></div>
</form> </form>
</main> </main>
<script defer src=/public/js/login.js type=module></script> <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") %> <div id=devices_grid></div><div id=info></div><button id=ping>ping</button><button id=reconnect>reconnect</button><script defer src=/public/js/index.js type=module></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 { export type ExtractRouteParams<T extends string> = T extends string
code = "ValidationError"; ? T extends `${infer _Start}:${infer Param}/${infer Rest}`
constructor(msg: string) { ? Param | ExtractRouteParams<Rest>
super(msg); : 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/option.ts";
export * from "@shared/utils/result.ts"; export * from "@shared/utils/result.ts";
export * from "@shared/utils/resultasync.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>; mapErr<U>(fn: (err: E) => U): Result<T, U>;
mapErrAsync<U>(fn: (err: E) => Promise<U>): ResultAsync<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>; 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>>; flatten(): FlattenResult<Result<T, E>>;
flattenOption<U>(errFn: () => U): Result<UnwrapOption<T>, U | E>; flattenOption<U>(errFn: () => U): Result<UnwrapOption<T>, U | E>;
flattenOptionOr<D = UnwrapOption<T>>( 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>; 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); return fn(this.value);
} }
mapErr<U>(fn: (err: E) => U): Result<T, U> { 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> { 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); return errAsync(this.error);
} }
mapErr<U>(fn: (err: E) => U): Result<T, U> { mapErr<U>(fn: (err: E) => U): Result<T, U> {
const mappedError = fn(this.error); return new Err<T, U>(fn(this.error));
return new Err<T, U>(mappedError);
} }
mapErrAsync<U>(fn: (err: E) => Promise<U>): ResultAsync<T, U> { mapErrAsync<U>(fn: (err: E) => Promise<U>): ResultAsync<T, U> {
return ResultAsync.fromPromise( 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> { andThen<U, F>(fn: (value: T) => Result<U, F>): Result<U, E | F> {
return new Err<U, E | F>(this.error); 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>> { flatten(): FlattenResult<Result<T, E>> {
return flattenResult(this); return flattenResult(this);
} }
@ -359,8 +367,12 @@ export function flattenResult<R extends Result<any, any>>(
): FlattenResult<R> { ): FlattenResult<R> {
let currentResult = nestedResult; let currentResult = nestedResult;
while (currentResult instanceof Ok) { while (
currentResult = currentResult.value; currentResult instanceof Ok &&
(currentResult.value instanceof Ok ||
currentResult.value instanceof Err)
) {
currentResult = currentResult.value as R;
} }
return currentResult as FlattenResult<R>; return currentResult as FlattenResult<R>;

View File

@ -62,6 +62,18 @@ export class ResultAsync<T, E> implements PromiseLike<Result<T, E>> {
}; };
} }
static from<
T = void,
E = void,
>(
executor: (
resolve: (value: T | PromiseLike<T>) => void,
reject: (reason?: E) => void,
) => void,
): ResultAsync<T, E> {
return ResultAsync.fromPromise(new Promise(executor), (e) => e as E);
}
async unwrap(): Promise<T> { async unwrap(): Promise<T> {
const result = await this._promise; const result = await this._promise;
if (result.isErr()) { if (result.isErr()) {
@ -150,7 +162,7 @@ export class ResultAsync<T, E> implements PromiseLike<Result<T, E>> {
} }
andThenAsync<U, F>( 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> { ): ResultAsync<U, E | F> {
return new ResultAsync( return new ResultAsync(
this._promise.then( this._promise.then(
@ -254,13 +266,12 @@ export function errAsync<E, T = never>(err: E): ResultAsync<T, E> {
return new ResultAsync(Promise.resolve(new Err<T, E>(err))); return new ResultAsync(Promise.resolve(new Err<T, E>(err)));
} }
export type FlattenResultAsync<R> = R extends ResultAsync<infer T, infer E> type FlattenResultAsync<R> = R extends
? T extends ResultAsync<any, any> ResultAsync<infer Inner, infer OuterError>
? FlattenResultAsync<T> extends ResultAsync<infer V, infer innerE> ? Inner extends ResultAsync<infer T, infer InnerError>
? ResultAsync<V, E | innerE> ? ResultAsync<T, OuterError | InnerError>
: never
: R : R
: never; : R;
type UnwrapPromise<Pr extends Promise<unknown>> = Pr extends Promise<infer U> type UnwrapPromise<Pr extends Promise<unknown>> = Pr extends Promise<infer U>
? U ? U

11
shared/utils/test.ts Normal file
View File

@ -0,0 +1,11 @@
import { z } from "@shared/utils/validator.ts";
const schema = z.obj({
password: z.string(),
});
console.log(
schema.parse({
passwor: "string",
}),
);

View File

@ -9,42 +9,42 @@ import {
some, some,
} from "@shared/utils/option.ts"; } from "@shared/utils/option.ts";
class CommandExecutionError extends Error { export class CommandExecutionError extends Error {
code = "CommandExecutionError"; type = "CommandExecutionError";
constructor(msg: string) { constructor(msg: string) {
super(msg); super(msg);
} }
} }
class DeviceDoesNotExistError extends Error { export class DeviceDoesNotExistError extends Error {
code = "DeviceDoesNotExist"; type = "DeviceDoesNotExist";
constructor(msg: string) { constructor(msg: string) {
super(msg); super(msg);
} }
} }
class DeviceAlreadyBoundError extends Error { export class DeviceAlreadyBoundError extends Error {
code = "DeviceAlreadyBound"; type = "DeviceAlreadyBound";
constructor(msg: string) { constructor(msg: string) {
super(msg); super(msg);
} }
} }
class DeviceNotBound extends Error { export class DeviceNotBound extends Error {
code = "DeviceNotBound"; type = "DeviceNotBound";
constructor(msg: string) { constructor(msg: string) {
super(msg); super(msg);
} }
} }
class UsbipUknownError extends Error { export class UsbipUnknownError extends Error {
code = "UsbipUknownError"; type = "UsbipUknownError";
constructor(msg: string) { constructor(msg: string) {
super(msg); super(msg);
} }
} }
type UsbipCommonError = DeviceDoesNotExistError | UsbipUknownError; type UsbipCommonError = DeviceDoesNotExistError | UsbipUnknownError;
class UsbipManager { class UsbipManager {
private readonly listDeatiledCmd = new Deno.Command("usbip", { private readonly listDeatiledCmd = new Deno.Command("usbip", {
@ -84,7 +84,7 @@ class UsbipManager {
return new DeviceDoesNotExistError(stderr); return new DeviceDoesNotExistError(stderr);
} }
return new UsbipUknownError(stderr); return new UsbipUnknownError(stderr);
} }
private parseDetailedList(stdout: string): Option<DeviceDetailed[]> { private parseDetailedList(stdout: string): Option<DeviceDetailed[]> {
@ -140,7 +140,7 @@ class UsbipManager {
public getDevicesDetailed(): ResultAsync< public getDevicesDetailed(): ResultAsync<
Option<DeviceDetailed[]>, Option<DeviceDetailed[]>,
CommandExecutionError | UsbipUknownError CommandExecutionError | UsbipUnknownError
> { > {
return this.executeCommand(this.listDeatiledCmd).andThen( return this.executeCommand(this.listDeatiledCmd).andThen(
({ stdout, stderr, success }) => { ({ stdout, stderr, success }) => {
@ -153,7 +153,7 @@ class UsbipManager {
return ok(this.parseDetailedList(stdout)); return ok(this.parseDetailedList(stdout));
} }
return err(new UsbipUknownError(stderr)); return err(new UsbipUnknownError(stderr));
}, },
); );
} }
@ -193,7 +193,7 @@ class UsbipManager {
public getDevices(): ResultAsync< public getDevices(): ResultAsync<
Option<Device[]>, Option<Device[]>,
CommandExecutionError | UsbipUknownError CommandExecutionError | UsbipUnknownError
> { > {
return this.executeCommand(this.listParsableCmd).andThenAsync( return this.executeCommand(this.listParsableCmd).andThenAsync(
({ stdout, stderr, success }) => { ({ stdout, stderr, success }) => {
@ -205,7 +205,7 @@ class UsbipManager {
} }
return okAsync(this.parseParsableList(stdout)); 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; busid: string;
usbid: Option<string>; usbid: Option<string>;
vendor: 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 ───────────────────────────────────────────────────── // ── Error Types ─────────────────────────────────────────────────────
type ValidationErrorDetail = export type ValidationErrorDetail =
| { | {
kind: "typeMismatch"; kind: "typeMismatch";
expected: string; expected: string;
@ -32,14 +33,18 @@ type ValidationErrorDetail =
} }
| { kind: "general"; mark?: string; msg: string }; | { kind: "general"; mark?: string; msg: string };
class SchemaValidationError extends Error { export class SchemaValidationError extends Error {
public readonly type = "SchemaValidationError"; public readonly type = "SchemaValiationError";
constructor( constructor(
public readonly input: unknown, public readonly input: unknown,
public readonly detail: ValidationErrorDetail, 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> { public format(): Record<string, unknown> {
@ -49,7 +54,7 @@ class SchemaValidationError extends Error {
}; };
} }
get msg(): string { get info(): string {
return SchemaValidationError.getBestMsg(this.detail); return SchemaValidationError.getBestMsg(this.detail);
} }
@ -60,7 +65,7 @@ class SchemaValidationError extends Error {
case "missingProperties": case "missingProperties":
case "general": case "general":
case "unionValidation": case "unionValidation":
return SchemaValidationError.formMsg(detail); return SchemaValidationError.formatMsg(detail);
case "propertyValidation": case "propertyValidation":
case "arrayElement": case "arrayElement":
return detail.msg || return detail.msg ||
@ -74,7 +79,7 @@ class SchemaValidationError extends Error {
switch (detail.kind) { switch (detail.kind) {
case "general": case "general":
case "typeMismatch": case "typeMismatch":
return SchemaValidationError.formMsg(detail); return SchemaValidationError.formatMsg(detail);
case "propertyValidation": case "propertyValidation":
return { return {
[detail.property]: detail.msg || [detail.property]: detail.msg ||
@ -82,25 +87,28 @@ class SchemaValidationError extends Error {
}; };
case "unexpectedProperties": case "unexpectedProperties":
case "missingProperties": { case "missingProperties": {
const resObj: Record<string, string> = {};
const msg = detail.msg || const msg = detail.msg ||
(detail.kind === "unexpectedProperties" (detail.kind === "unexpectedProperties"
? "Property is not allowed in a strict schema object" ? "Property is not allowed in a strict schema object"
: "Property is required, but missing"); : "Property is required, but missing");
for (const key of detail.keys) { return detail.keys.reduce<Record<string, string>>(
resObj[key] = msg; (acc, key) => {
} acc[key] = msg;
return acc;
return resObj; },
{},
);
} }
case "arrayElement": { case "arrayElement": {
const obj: Record<string, string> = {}; const detailObj: Record<string, string> = {};
if (detail.msg) { if (detail.msg) {
obj["msg"] = detail.msg; detailObj["msg"] = detail.msg;
} }
obj[`index_${detail.index}`] = this.formatDetail(detail.detail); detailObj[`index_${detail.index}`] = this.formatDetail(
return obj; detail.detail,
);
return detailObj;
} }
case "unionValidation": { case "unionValidation": {
const arr: unknown[] = detail.details?.map( 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") { if (detail.msg || detail.kind === "general") {
return detail.msg || "Unknown error"; return detail.msg || "Unknown error";
} }
@ -124,22 +132,18 @@ class SchemaValidationError extends Error {
case "typeMismatch": case "typeMismatch":
return `Expected ${detail.expected}, but received ${detail.received}`; return `Expected ${detail.expected}, but received ${detail.received}`;
case "unexpectedProperties": case "unexpectedProperties":
return `Properties are not allowed in a strict object schema: ${ return `Properties not allowed: ${detail.keys.join(", ")}`;
detail.keys.join(", ")
}`;
case "missingProperties": case "missingProperties":
return `Missing required properties: ${detail.keys.join(", ")}`; return `Missing required properties: ${detail.keys.join(", ")}`;
case "unionValidation": case "unionValidation":
return `Input did not match any of the union member`; return `Input did not match any union member`;
case "propertyValidation":
case "arrayElement":
default: default:
return "Unknown error"; return "Unknown error";
} }
} }
} }
function createValidationError( export function createValidationError(
input: unknown, input: unknown,
error: ValidationErrorDetail, error: ValidationErrorDetail,
) { ) {
@ -212,14 +216,11 @@ export abstract class BaseSchema<T> implements Schema<T> {
} }
protected static isNullishSchema(schema: Schema<any>): boolean { protected static isNullishSchema(schema: Schema<any>): boolean {
if (schema.parse(null).isOk() || schema.parse(undefined).isOk()) { return schema.parse(null).isOk() || schema.parse(undefined).isOk();
return true;
}
return false;
} }
} }
class StringSchema extends BaseSchema<string> { export class StringSchema extends BaseSchema<string> {
private static readonly emailRegex = private static readonly emailRegex =
/^(([^<>()[\]\.,;:\s@\"]+(\.[^<>()[\]\.,;:\s@\"]+)*)|(\".+\"))@(([^<>()[\]\.,;:\s@\"]+\.)+[^<>()[\]\.,;:\s@\"]{2,})$/i; // https://stackoverflow.com/questions/46155/how-can-i-validate-an-email-address-in-javascript /^(([^<>()[\]\.,;:\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( constructor(
public readonly literal: L, public readonly literal: L,
msg?: string, msg?: string,
@ -644,9 +645,7 @@ class NeverSchema extends BaseSchema<never> {
} }
} }
type InferSchemaType<S> = S extends Schema<infer T> ? T : never; export class ObjectSchema<S extends Record<string, Schema<any>>>
class ObjectSchema<S extends Record<string, Schema<any>>>
extends BaseSchema<{ [K in keyof S]: InferSchemaType<S[K]> }> { extends BaseSchema<{ [K in keyof S]: InferSchemaType<S[K]> }> {
private strictMode: boolean = false; private strictMode: boolean = false;
private objectMsg?; private objectMsg?;
@ -675,6 +674,7 @@ class ObjectSchema<S extends Record<string, Schema<any>>>
this.objectMsg = objectMsg; this.objectMsg = objectMsg;
} }
// TODO: Simplify it a bit
protected override validateInput( protected override validateInput(
input: unknown, input: unknown,
): Result< ): 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)); const expectedKeys = new Set(Object.keys(this.shape));
for (const key of Object.keys(obj)) { 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; this.strictMode = true;
return this; 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 type InferUnionSchemaType<U extends Schema<any>[]> = U[number] extends
Schema<infer T> ? T : never; Schema<infer T> ? T : never;
@ -825,19 +854,19 @@ class UnionSchema<U extends Schema<any>[]>
input: unknown, input: unknown,
): Result<InferUnionSchemaType<U>, SchemaValidationError> { ): Result<InferUnionSchemaType<U>, SchemaValidationError> {
const errors: ValidationErrorDetail[] = []; const errors: ValidationErrorDetail[] = [];
let typeMismatch = true; let allTypeMismatch = true;
for (const schema of this.schemas) { for (const schema of this.schemas) {
const result = schema.parse(input); const result = schema.parse(input);
if (result.isOk()) { if (result.isOk()) {
return ok(result.value as InferUnionSchemaType<U>); return ok(result.value as InferUnionSchemaType<U>);
} }
typeMismatch = result.error.detail?.kind === "typeMismatch" && allTypeMismatch = result.error.detail?.kind === "typeMismatch" &&
typeMismatch; allTypeMismatch;
errors.push(result.error.detail); errors.push(result.error.detail);
} }
if (typeMismatch) { if (allTypeMismatch) {
return err(createValidationError(input, { return err(createValidationError(input, {
kind: "typeMismatch", kind: "typeMismatch",
expected: this.schemas.map((s) => expected: this.schemas.map((s) =>
@ -853,7 +882,8 @@ class UnionSchema<U extends Schema<any>[]>
return err( return err(
createValidationError(input, { createValidationError(input, {
kind: "unionValidation", kind: "unionValidation",
msg: this.msg || this.unionMsg?.unionValidation || msg: this.msg ||
this.unionMsg?.unionValidation ||
"Input did not match any union member", "Input did not match any union member",
details: errors, details: errors,
}), }),
@ -882,6 +912,7 @@ class ArraySchema<S extends Schema<any>>
} }
super(mismatchMsg); super(mismatchMsg);
// TODO: abstract complex schemas in a separate type with thos messages
this.arrayMsg = arrayMsg; this.arrayMsg = arrayMsg;
} }
@ -973,7 +1004,211 @@ 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",
}));
}
},
);
}
}
export class EnumSchema<E extends (number | string)[]>
extends BaseSchema<E[number]> {
constructor(
public readonly entries: E,
msg?: string,
) {
super(msg);
}
protected override validateInput(
input: unknown,
): Result<E[number], SchemaValidationError> {
for (const entry of this.entries) {
if (input === entry) {
return ok(input as E[number]);
}
}
return err(createValidationError(input, {
kind: "typeMismatch",
expected: this.entries.map((e) =>
typeof e === "string" ? `"${e}"` : e
).join(" | "),
received: String(input),
msg: this.msg,
}));
}
}
/* ── Helper Object for Schema Creation (z) ───────────────────────────────────── */
export const z = {
string: (msg?: string) => new StringSchema(msg), string: (msg?: string) => new StringSchema(msg),
literal: <L extends string>(lit: L, msg?: string) => literal: <L extends string>(lit: L, msg?: string) =>
new LiteralSchema<L>(lit, msg), new LiteralSchema<L>(lit, msg),
@ -981,30 +1216,55 @@ const z = {
bigint: (msg?: string) => new BigintSchema(msg), bigint: (msg?: string) => new BigintSchema(msg),
boolean: (msg?: string) => new BooleanSchema(msg), boolean: (msg?: string) => new BooleanSchema(msg),
date: (msg?: string) => new DateSchema(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), undefined: (msg?: string) => new UndefinedSchema(msg),
null: (msg?: string) => new NullSchema(msg), null: (msg?: string) => new NullSchema(msg),
void: (msg?: string) => new VoidSchema(msg), void: (msg?: string) => new VoidSchema(msg),
any: (msg?: string) => new AnySchema(msg), any: (msg?: string) => new AnySchema(msg),
unknown: (msg?: string) => new UnknownSchema(msg), unknown: (msg?: string) => new UnknownSchema(msg),
never: (msg?: string) => new NeverSchema(msg), never: (msg?: string) => new NeverSchema(msg),
obj: <S extends Record<string, Schema<any>>>(schema: S, msg?: string) => obj: <S extends Record<string, Schema<any>>>(
new ObjectSchema<S>(schema, msg), schema: S,
union: <U extends Schema<any>[]>(schemas: U, msg?: string) => msg?: string | {
new UnionSchema<U>(schemas, msg), 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),
enum: <E extends (number | string)[]>(e: E, msg?: string) =>
new EnumSchema(e, msg),
}; };
const schema = z.obj({ export type InferSchemaType<S> = S extends Schema<infer T> ? T : never;
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());

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> { export class StringSchema extends PrimitiveSchema<string> {
private static readonly emailRegex = private static readonly emailRegex =
/^(([^<>()[\]\.,;:\s@\"]+(\.[^<>()[\]\.,;:\s@\"]+)*)|(\".+\"))@(([^<>()[\]\.,;:\s@\"]+\.)+[^<>()[\]\.,;:\s@\"]{2,})$/i; /^(([^<>()[\]\.,;:\s@\"]+(\.[^<>()[\]\.,;:\s@\"]+)*)|(\".+\"))@(([^<>()[\]\.,;:\s@\"]+\.)+[^<>()[\]\.,;:\s@\"]{2,})$/i;
@ -345,7 +344,7 @@ if (res.isErr()) {
} }
// Utility Types // 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 type InferSchemaUnion<S extends Schema<any>[]> = S[number] extends
Schema<infer U> ? U : never; Schema<infer U> ? U : never;
type NestedArray<T> = T | NestedArray<T>[]; 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": { "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.d.mts": {},
"https://jsr.io/@std/crypto/1.0.3/_wasm/lib/deno_std_wasm_crypto.generated.mjs": {}, "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": {} "https://jsr.io/@std/net/1.0.4/unstable_get_network_address.ts": {}