Compare commits
10 Commits
62d9f5a631
...
44649ef89a
| Author | SHA1 | Date | |
|---|---|---|---|
| 44649ef89a | |||
| 74cd00e62b | |||
| cafb669fd1 | |||
| 94a1ea1e8a | |||
| ad14560a2c | |||
| f0ec7a1f00 | |||
| 64519e11ff | |||
| cbb18d516d | |||
| 97a5cdf654 | |||
| e555186537 |
5
deno.lock
generated
5
deno.lock
generated
@ -673,6 +673,9 @@
|
||||
]
|
||||
}
|
||||
},
|
||||
"redirects": {
|
||||
"https://deno.land/x/sleep/mod.ts": "https://deno.land/x/sleep@v1.3.0/mod.ts"
|
||||
},
|
||||
"remote": {
|
||||
"https://deno.land/std@0.203.0/assert/assert.ts": "9a97dad6d98c238938e7540736b826440ad8c1c1e54430ca4c4e623e585607ee",
|
||||
"https://deno.land/std@0.203.0/assert/assertion_error.ts": "4d0bde9b374dfbcbe8ac23f54f567b77024fb67dbb1906a852d67fe050d42f56",
|
||||
@ -686,6 +689,8 @@
|
||||
"https://deno.land/std@0.203.0/async/pool.ts": "47c1841cfa9c036144943d11747ddd44064f5baf8cb7ece25473ba873c6aceb0",
|
||||
"https://deno.land/std@0.203.0/async/retry.ts": "296fb9c323e1325a69bee14ba947e7da7409a8dd9dd646d70cb51ea0d301f24e",
|
||||
"https://deno.land/std@0.203.0/async/tee.ts": "47e42d35f622650b02234d43803d0383a89eb4387e1b83b5a40106d18ae36757",
|
||||
"https://deno.land/x/sleep@v1.3.0/mod.ts": "e9955ecd3228a000e29d46726cd6ab14b65cf83904e9b365f3a8d64ec61c1af3",
|
||||
"https://deno.land/x/sleep@v1.3.0/sleep.ts": "b6abaca093b094b0c2bba94f287b19a60946a8d15764d168f83fcf555f5bb59e",
|
||||
"https://wilsonl.in/minify-html/deno/0.15.0/index.js": "8e7ee5067ca84fb5d5a1f33118cac4998de0b7d80b3f56cc5c6728b84e6bfb70"
|
||||
},
|
||||
"workspace": {
|
||||
|
||||
110
server/api.ts
Normal file
110
server/api.ts
Normal file
@ -0,0 +1,110 @@
|
||||
import { Api } from "@src/lib/apiValidator.ts";
|
||||
import { createValidationError, z } from "@shared/utils/validator.ts";
|
||||
import {
|
||||
adminPasswordAlreadySetErrorSchema,
|
||||
adminPasswordNotSetErrorSchema,
|
||||
commandExecutionErrorSchema,
|
||||
failedToParseRequestAsJSONErrorSchema,
|
||||
invalidPasswordErrorSchema,
|
||||
passwordsMustMatchErrorSchema,
|
||||
queryExecutionErrorSchema,
|
||||
requestValidationErrorSchema,
|
||||
tooManyRequestsErrorSchema,
|
||||
unauthorizedErrorSchema,
|
||||
usbipUnknownErrorSchema,
|
||||
} from "@src/lib/errors.ts";
|
||||
|
||||
const loginApiSchema = {
|
||||
req: z.obj({
|
||||
password: z.string(),
|
||||
}),
|
||||
res: z.result(
|
||||
z.void(),
|
||||
z.union([
|
||||
adminPasswordNotSetErrorSchema,
|
||||
queryExecutionErrorSchema,
|
||||
failedToParseRequestAsJSONErrorSchema,
|
||||
requestValidationErrorSchema,
|
||||
tooManyRequestsErrorSchema,
|
||||
invalidPasswordErrorSchema,
|
||||
]),
|
||||
),
|
||||
};
|
||||
|
||||
export const loginApi = new Api("/login", "POST", loginApiSchema);
|
||||
|
||||
const passwordSetupApiSchema = {
|
||||
req: z.obj({
|
||||
password: z.string().min(
|
||||
10,
|
||||
"Password must be at least 10 characters long",
|
||||
).regex(
|
||||
/^[a-zA-Z0-9]+$/,
|
||||
"Password must consist of lower or upper case latin letters and numbers",
|
||||
),
|
||||
passwordRepeat: z.string(),
|
||||
}).addCheck((v) => {
|
||||
if (v.passwordRepeat !== v.password) {
|
||||
return createValidationError(v, {
|
||||
kind: "general",
|
||||
msg: "Passwords must match",
|
||||
});
|
||||
}
|
||||
}),
|
||||
res: z.result(
|
||||
z.void(),
|
||||
z.union([
|
||||
passwordsMustMatchErrorSchema,
|
||||
adminPasswordAlreadySetErrorSchema,
|
||||
queryExecutionErrorSchema,
|
||||
failedToParseRequestAsJSONErrorSchema,
|
||||
requestValidationErrorSchema,
|
||||
tooManyRequestsErrorSchema,
|
||||
]),
|
||||
),
|
||||
};
|
||||
|
||||
export const passwordSetupApi = new Api(
|
||||
"/setup",
|
||||
"POST",
|
||||
passwordSetupApiSchema,
|
||||
);
|
||||
|
||||
const updateDevicesApiSchema = {
|
||||
req: z.void(),
|
||||
res: z.result(
|
||||
z.void(),
|
||||
z.union([
|
||||
queryExecutionErrorSchema,
|
||||
tooManyRequestsErrorSchema,
|
||||
unauthorizedErrorSchema,
|
||||
commandExecutionErrorSchema,
|
||||
usbipUnknownErrorSchema,
|
||||
]),
|
||||
),
|
||||
};
|
||||
|
||||
export const updateDevicesApi = new Api(
|
||||
"/api/updateDevices",
|
||||
"POST",
|
||||
updateDevicesApiSchema,
|
||||
);
|
||||
|
||||
const versionApiSchema = {
|
||||
req: z.void(),
|
||||
res: z.result(
|
||||
z.obj({
|
||||
app: z.literal("Keyborg"),
|
||||
version: z.string(),
|
||||
}),
|
||||
z.union([
|
||||
tooManyRequestsErrorSchema,
|
||||
]),
|
||||
),
|
||||
};
|
||||
|
||||
export const versionApi = new Api(
|
||||
"/version",
|
||||
"POST",
|
||||
versionApiSchema,
|
||||
);
|
||||
@ -7,11 +7,12 @@ await esbuild.build({
|
||||
plugins: [
|
||||
...denoPlugins(),
|
||||
],
|
||||
entryPoints: ["../shared/utils/index.ts"],
|
||||
entryPoints: ["./src/js/shared.bundle.ts"],
|
||||
outfile: "./public/js/shared.bundle.js",
|
||||
bundle: true,
|
||||
minify: true,
|
||||
format: "esm",
|
||||
treeShaking: true,
|
||||
});
|
||||
esbuild.stop();
|
||||
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
{
|
||||
"tasks": {
|
||||
"dev": "deno run --allow-read --allow-write --allow-sys --allow-env --allow-run ./autoBundler.ts & deno serve --allow-read --allow-write --allow-sys --allow-env --allow-ffi --watch -R main.ts"
|
||||
"dev": "deno run --allow-read --allow-write --allow-sys --allow-env --allow-run ./autoBundler.ts & deno serve --allow-read --allow-write --allow-sys --allow-env --allow-ffi --watch --allow-run -R main.ts"
|
||||
},
|
||||
"imports": {
|
||||
"@db/sqlite": "jsr:@db/sqlite@^0.12.0",
|
||||
|
||||
203
server/main.ts
203
server/main.ts
@ -3,16 +3,33 @@ import { Eta } from "@eta-dev/eta";
|
||||
import { serveFile } from "jsr:@std/http/file-server";
|
||||
import rateLimitMiddleware from "@src/middleware/rateLimiter.ts";
|
||||
import authMiddleware from "@src/middleware/auth.ts";
|
||||
import { ok, ResultFromJSON } from "@shared/utils/result.ts";
|
||||
import { ResultResponseFromJSON } from "@src/lib/context.ts";
|
||||
import admin from "@src/lib/admin.ts";
|
||||
import UsbipManager from "@shared/utils/usbip.ts";
|
||||
import loggerMiddleware from "@src/middleware/logger.ts";
|
||||
import {
|
||||
loginApi,
|
||||
passwordSetupApi,
|
||||
updateDevicesApi,
|
||||
versionApi,
|
||||
} from "./api.ts";
|
||||
import { err, ok } from "@shared/utils/result.ts";
|
||||
import admin from "@src/lib/admin.ts";
|
||||
import { Context } from "@src/lib/context.ts";
|
||||
import {
|
||||
FailedToParseRequestAsJSONError,
|
||||
invalidPasswordError,
|
||||
passwordsMustMatchError,
|
||||
QueryExecutionError,
|
||||
queryExecutionError,
|
||||
RequestValidationError,
|
||||
} from "@src/lib/errors.ts";
|
||||
import devices from "@src/lib/devices.ts";
|
||||
|
||||
const AUTH_COOKIE_NAME = "token";
|
||||
const VERSION = "0.1.0";
|
||||
|
||||
const router = new HttpRouter();
|
||||
|
||||
const views = Deno.cwd() + "/views/";
|
||||
const eta = new Eta({ views });
|
||||
export const eta = new Eta({ views });
|
||||
|
||||
router.use(loggerMiddleware);
|
||||
router.use(rateLimitMiddleware);
|
||||
@ -23,45 +40,183 @@ const cache: Map<string, Response> = new Map();
|
||||
router.get("/public/*", async (c) => {
|
||||
const filePath = "." + c.path;
|
||||
|
||||
const cached = cache.get(filePath);
|
||||
|
||||
if (cached) {
|
||||
return cached.clone();
|
||||
}
|
||||
//const cached = cache.get(filePath);
|
||||
//
|
||||
//if (cached) {
|
||||
// return cached.clone();
|
||||
//}
|
||||
|
||||
const res = await serveFile(c.req, filePath);
|
||||
|
||||
cache.set(filePath, res.clone());
|
||||
//cache.set(filePath, res.clone());
|
||||
|
||||
return res;
|
||||
});
|
||||
|
||||
router
|
||||
.get(["", "/index.html"], (c) => {
|
||||
return c.html(eta.render("./index.html", {}));
|
||||
const devicesList = devices.list().unwrap().unwrap();
|
||||
|
||||
return c.html(eta.render("./index.html", { devices: devicesList }));
|
||||
})
|
||||
.get(["/login", "/login.html"], (c) => {
|
||||
return c.html(eta.render("./login.html", {}));
|
||||
const isSet = admin.isPasswordSet();
|
||||
|
||||
if (isSet.isErr()) {
|
||||
return c.html(eta.render("./internal_error.html", {}));
|
||||
}
|
||||
|
||||
if (!isSet.value) {
|
||||
return c.redirect("/setup");
|
||||
}
|
||||
|
||||
const alreadyLoggedIn = c.cookies.get("token").map((token) =>
|
||||
admin.sessions.verifyToken(token)
|
||||
)
|
||||
.toBoolean();
|
||||
|
||||
console.log(alreadyLoggedIn);
|
||||
|
||||
return c.html(eta.render("./login.html", { alreadyLoggedIn }));
|
||||
})
|
||||
.post("/login", async (c) => {
|
||||
const r = await ResultFromJSON<{ password: string }>(
|
||||
await c.req.text(),
|
||||
.get("/setup", (c) => {
|
||||
return admin.isPasswordSet()
|
||||
.match(
|
||||
(isSet) => {
|
||||
if (isSet) {
|
||||
return c.redirect("/login");
|
||||
} else {
|
||||
return c.html(eta.render("./setup.html", {}));
|
||||
}
|
||||
},
|
||||
(e) => c.html(eta.render("./internal_error.html", {})),
|
||||
);
|
||||
});
|
||||
|
||||
router
|
||||
.get("/user/:id/:name/*", (c) => {
|
||||
return c.html(
|
||||
`id = ${c.params.id}, name = ${c.params.name}, rest = ${c.params.restOfThePath}`,
|
||||
);
|
||||
router.get("ws", (c) => {
|
||||
if (c.req.headers.get("upgrade") != "websocket") {
|
||||
return new Response(null, { status: 501 });
|
||||
}
|
||||
|
||||
const { socket, response } = Deno.upgradeWebSocket(c.req);
|
||||
|
||||
socket.addEventListener("open", () => {
|
||||
console.log("a client connected!");
|
||||
});
|
||||
|
||||
socket.addEventListener("close", () => {
|
||||
console.log("client disconnected");
|
||||
});
|
||||
|
||||
socket.addEventListener("message", (event) => {
|
||||
if (event.data === "ping") {
|
||||
console.log("pinged!");
|
||||
socket.send("pong");
|
||||
}
|
||||
});
|
||||
|
||||
return response;
|
||||
});
|
||||
|
||||
router
|
||||
.get("/user/:idButDifferent", (c) => {
|
||||
return c.html(
|
||||
`idButDifferent = ${c.params.idButDifferent}`,
|
||||
.api(loginApi, async (c) => {
|
||||
const r = await c
|
||||
.parseBody()
|
||||
.andThenAsync(
|
||||
({ password }) => admin.verifyPassword(password),
|
||||
);
|
||||
|
||||
if (r.isErr()) {
|
||||
if (r.error.type === "AdminPasswordNotSetError") {
|
||||
return c.json400(
|
||||
err({
|
||||
type: r.error.type,
|
||||
info: r.error.info,
|
||||
}),
|
||||
);
|
||||
}
|
||||
return handleCommonErrors(c, r.error);
|
||||
}
|
||||
|
||||
const isMatch = r.value;
|
||||
|
||||
if (isMatch) {
|
||||
return admin.sessions.create()
|
||||
.map(({ value, expires }) => {
|
||||
c.cookies.set({
|
||||
name: AUTH_COOKIE_NAME,
|
||||
value,
|
||||
expires,
|
||||
});
|
||||
return ok();
|
||||
}).match(
|
||||
(v) => c.json(v),
|
||||
(e) => handleCommonErrors(c, e),
|
||||
);
|
||||
} else {
|
||||
return c.json(
|
||||
err(invalidPasswordError("Invalid login or password")),
|
||||
);
|
||||
}
|
||||
})
|
||||
.api(passwordSetupApi, async (c) => {
|
||||
const r = await c.parseBody();
|
||||
|
||||
if (r.isErr()) {
|
||||
return handleCommonErrors(c, r.error);
|
||||
}
|
||||
|
||||
const v = r.value;
|
||||
|
||||
if (v.password !== v.passwordRepeat) {
|
||||
return c.json400(
|
||||
err(passwordsMustMatchError("Passwords must match")),
|
||||
);
|
||||
}
|
||||
|
||||
return admin.setPassword(v.password).match(
|
||||
() => c.json(ok()),
|
||||
(e) => c.json400(err(e)),
|
||||
);
|
||||
})
|
||||
.api(updateDevicesApi, (c) => {
|
||||
return devices.updateDevices().match(
|
||||
() => c.json(ok()),
|
||||
(e) => c.json500(err(e)),
|
||||
);
|
||||
})
|
||||
.api(versionApi, (c) => {
|
||||
return c.json(ok({
|
||||
app: "Keyborg",
|
||||
version: VERSION,
|
||||
}));
|
||||
});
|
||||
|
||||
function handleCommonErrors(
|
||||
c: Context<any, any, any>,
|
||||
error:
|
||||
| QueryExecutionError
|
||||
| FailedToParseRequestAsJSONError
|
||||
| RequestValidationError,
|
||||
): Response {
|
||||
switch (error.type) {
|
||||
case "QueryExecutionError":
|
||||
return c.json(
|
||||
err(queryExecutionError("Server failed to execute query")),
|
||||
{ status: 500 },
|
||||
);
|
||||
case "FailedToParseRequestAsJSONError":
|
||||
return c.json(
|
||||
err(error),
|
||||
{ status: 400 },
|
||||
);
|
||||
case "RequestValidationError":
|
||||
return c.json(
|
||||
err(error),
|
||||
{ status: 400 },
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export default {
|
||||
async fetch(req, connInfo) {
|
||||
|
||||
1
server/public/js/index.js
Normal file
1
server/public/js/index.js
Normal file
@ -0,0 +1 @@
|
||||
class c{ws=null;url;reconnectInterval;maxReconnectInterval;reconnectDecay;timeout;forcedClose=!1;onmessage;constructor(e,t={}){this.url=e,this.reconnectInterval=t.reconnectInterval??1e3,this.maxReconnectInterval=t.maxReconnectInterval??3e4,this.reconnectDecay=t.reconnectDecay??1.5,this.timeout=t.timeout??2e3,this.connect()}connect(e=!1){console.log(`Connecting to ${this.url}...`),this.ws=new WebSocket(this.url);let t=setTimeout(()=>{console.warn("Connection timeout, closing socket."),this.ws?.close()},this.timeout);this.ws.onopen=n=>{clearTimeout(t),console.log("WebSocket connected."),this.onmessage&&this.ws?.addEventListener("message",this.onmessage)},this.ws.onerror=n=>{console.error("WebSocket error:",n)},this.ws.onclose=n=>{clearTimeout(t),console.log("WebSocket closed:",n.reason),this.forcedClose||setTimeout(()=>{this.reconnectInterval=Math.min(this.reconnectInterval*this.reconnectDecay,this.maxReconnectInterval),this.connect(!0)},this.reconnectInterval)}}onMessage(e){this.ws&&this.ws.addEventListener("message",e),this.onmessage=e}send(e){this.ws&&this.ws.readyState===WebSocket.OPEN?this.ws.send(e):console.error("WebSocket is not open. Message not sent.")}close(){this.forcedClose=!0,this.ws?.close()}}const s=new c("/ws");s.onMessage(o=>{console.log(o.data)});const i=document.getElementById("ping");i.onclick=()=>{s.send("ping")};
|
||||
@ -1 +1 @@
|
||||
import{ok as n}from"./shared.bundle.js";const s=document.getElementById("loginForm"),a=document.getElementById("passwordInput");s.addEventListener("submit",async t=>{t.preventDefault();const o=a.value,e=JSON.stringify(n({password:o}).toJSON()),r=await(await fetch("/login",{method:"POST",headers:{accept:"application/json"},body:e})).json(),c=8});
|
||||
import{loginApi as o}from"./shared.bundle.js";const r=document.getElementById("loginForm"),s=document.getElementById("passwordInput"),i=document.getElementById("errDiv");r.addEventListener("submit",async t=>{t.preventDefault();const n=s.value,e=(await o.makeRequest({password:n},{})).flatten();e.isErr()?i.innerText=e.error.info:window.location.href="/"});
|
||||
|
||||
1
server/public/js/setup.js
Normal file
1
server/public/js/setup.js
Normal file
@ -0,0 +1 @@
|
||||
import{passwordSetupApi as o}from"./shared.bundle.js";const r=document.getElementById("passwordSetupForm"),a=document.getElementById("passwordInput"),p=document.getElementById("passwordRepeatInput"),d=document.getElementById("errDiv");r.addEventListener("submit",async t=>{t.preventDefault();const n=a.value,s=p.value,e=(await o.makeRequest({password:n,passwordRepeat:s},{})).flatten();e.isErr()?d.innerText=e.error.info:window.location.href="/login"});
|
||||
File diff suppressed because one or more lines are too long
103
server/src/js/index.ts
Normal file
103
server/src/js/index.ts
Normal file
@ -0,0 +1,103 @@
|
||||
interface ReconnectOptions {
|
||||
reconnectInterval?: number; // Initial reconnect delay (ms)
|
||||
maxReconnectInterval?: number; // Maximum delay (ms)
|
||||
reconnectDecay?: number; // Exponential backoff multiplier
|
||||
timeout?: number; // Connection timeout (ms)
|
||||
}
|
||||
|
||||
class ReconnectingWebSocketClient {
|
||||
private ws: WebSocket | null = null;
|
||||
private url: string;
|
||||
private reconnectInterval: number;
|
||||
private maxReconnectInterval: number;
|
||||
private reconnectDecay: number;
|
||||
private timeout: number;
|
||||
private forcedClose: boolean = false;
|
||||
private onmessage?: (ev: MessageEvent) => any;
|
||||
|
||||
constructor(
|
||||
url: string,
|
||||
options: ReconnectOptions = {},
|
||||
) {
|
||||
this.url = url;
|
||||
this.reconnectInterval = options.reconnectInterval ?? 1000; // 1 second
|
||||
this.maxReconnectInterval = options.maxReconnectInterval ?? 30000; // 30 seconds
|
||||
this.reconnectDecay = options.reconnectDecay ?? 1.5;
|
||||
this.timeout = options.timeout ?? 2000; // 2 seconds
|
||||
this.connect();
|
||||
}
|
||||
|
||||
private connect(isReconnect: boolean = false): void {
|
||||
console.log(`Connecting to ${this.url}...`);
|
||||
|
||||
this.ws = new WebSocket(this.url);
|
||||
let connectionTimeout = setTimeout(() => {
|
||||
console.warn("Connection timeout, closing socket.");
|
||||
this.ws?.close();
|
||||
}, this.timeout);
|
||||
|
||||
this.ws.onopen = (event: Event) => {
|
||||
clearTimeout(connectionTimeout);
|
||||
console.log("WebSocket connected.");
|
||||
|
||||
if (this.onmessage) {
|
||||
this.ws?.addEventListener("message", this.onmessage);
|
||||
}
|
||||
|
||||
// On connection, send login credentials
|
||||
// Optionally, if this is a reconnection, you could dispatch a custom event or handle state changes.
|
||||
};
|
||||
|
||||
this.ws.onerror = (event: Event) => {
|
||||
console.error("WebSocket error:", event);
|
||||
};
|
||||
|
||||
this.ws.onclose = (event: CloseEvent) => {
|
||||
clearTimeout(connectionTimeout);
|
||||
console.log("WebSocket closed:", event.reason);
|
||||
if (!this.forcedClose) {
|
||||
// Schedule reconnection with exponential backoff
|
||||
setTimeout(() => {
|
||||
this.reconnectInterval = Math.min(
|
||||
this.reconnectInterval * this.reconnectDecay,
|
||||
this.maxReconnectInterval,
|
||||
);
|
||||
this.connect(true);
|
||||
}, this.reconnectInterval);
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
public onMessage(fn: (e: MessageEvent) => void) {
|
||||
if (this.ws) {
|
||||
this.ws.addEventListener("message", fn);
|
||||
}
|
||||
|
||||
this.onmessage = fn;
|
||||
}
|
||||
|
||||
public send(data: any): void {
|
||||
if (this.ws && this.ws.readyState === WebSocket.OPEN) {
|
||||
this.ws.send(data);
|
||||
} else {
|
||||
console.error("WebSocket is not open. Message not sent.");
|
||||
}
|
||||
}
|
||||
|
||||
public close(): void {
|
||||
this.forcedClose = true;
|
||||
this.ws?.close();
|
||||
}
|
||||
}
|
||||
|
||||
const ws = new ReconnectingWebSocketClient("/ws");
|
||||
|
||||
ws.onMessage((e) => {
|
||||
console.log(e.data);
|
||||
});
|
||||
|
||||
const pingBtn = document.getElementById("ping") as HTMLButtonElement;
|
||||
|
||||
pingBtn.onclick = () => {
|
||||
ws.send("ping");
|
||||
};
|
||||
@ -1,30 +1,23 @@
|
||||
/// <reference lib="dom" />
|
||||
|
||||
import { ok } from "./shared.bundle.ts";
|
||||
import { loginApi } from "./shared.bundle.ts";
|
||||
|
||||
const form = document.getElementById("loginForm") as HTMLFormElement;
|
||||
const passwordInput = document.getElementById(
|
||||
"passwordInput",
|
||||
) as HTMLInputElement;
|
||||
const errDiv = document.getElementById("errDiv") as HTMLDivElement;
|
||||
|
||||
form.addEventListener("submit", async (e) => {
|
||||
e.preventDefault();
|
||||
|
||||
const password = passwordInput.value;
|
||||
|
||||
const bodyReq = JSON.stringify(
|
||||
ok({
|
||||
password: password,
|
||||
}).toJSON(),
|
||||
);
|
||||
const res = (await loginApi.makeRequest({ password }, {})).flatten();
|
||||
|
||||
const response = await fetch("/login", {
|
||||
method: "POST",
|
||||
headers: { accept: "application/json" },
|
||||
body: bodyReq,
|
||||
});
|
||||
|
||||
const body = await response.json();
|
||||
|
||||
const a = 8;
|
||||
if (res.isErr()) {
|
||||
errDiv.innerText = res.error.info;
|
||||
} else {
|
||||
window.location.href = "/";
|
||||
}
|
||||
});
|
||||
|
||||
29
server/src/js/setup.ts
Normal file
29
server/src/js/setup.ts
Normal 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";
|
||||
}
|
||||
});
|
||||
@ -1 +0,0 @@
|
||||
../../../shared/utils/index.ts
|
||||
5
server/src/js/shared.bundle.ts
Normal file
5
server/src/js/shared.bundle.ts
Normal file
@ -0,0 +1,5 @@
|
||||
export * from "@shared/utils/option.ts";
|
||||
export * from "@shared/utils/result.ts";
|
||||
export * from "@shared/utils/resultasync.ts";
|
||||
export * from "@shared/utils/validator.ts";
|
||||
export * from "../../api.ts";
|
||||
@ -1,7 +1,11 @@
|
||||
import { Option, some } from "@shared/utils/option.ts";
|
||||
import db from "@lib/db/index.ts";
|
||||
import { ok, Result } from "@shared/utils/result.ts";
|
||||
import { AdminPasswordNotSetError, QueryExecutionError } from "@lib/errors.ts";
|
||||
import {
|
||||
AdminPasswordNotSetError,
|
||||
adminPasswordNotSetError,
|
||||
QueryExecutionError,
|
||||
} from "@lib/errors.ts";
|
||||
import { AdminRaw, AdminSessionRaw } from "@lib/db/types/index.ts";
|
||||
import { generateRandomString, passwd } from "@lib/utils.ts";
|
||||
import { errAsync, ResultAsync } from "@shared/utils/resultasync.ts";
|
||||
@ -52,7 +56,7 @@ class Admin {
|
||||
const result = this.getPasswordHash().flattenOption(
|
||||
() => {
|
||||
log.warn("Tried to verify password when it is not set");
|
||||
return new AdminPasswordNotSetError(
|
||||
return adminPasswordNotSetError(
|
||||
"Admin password is not set",
|
||||
);
|
||||
},
|
||||
@ -131,13 +135,20 @@ class AdminSessions {
|
||||
}, EXPIRED_TOKENS_DELETION_INTERVAL);
|
||||
}
|
||||
|
||||
public create(expiresAt?: Date): Result<string, QueryExecutionError> {
|
||||
public create(
|
||||
expiresAt?: Date,
|
||||
): Result<{ value: string; expires: Date }, QueryExecutionError> {
|
||||
const token = generateRandomString(TOKEN_LENGTH);
|
||||
|
||||
if (expiresAt) {
|
||||
return this.statements
|
||||
.insertSessionTokenWithExpiry(token, expiresAt.toISOString())
|
||||
.map(() => token);
|
||||
.map(() => {
|
||||
return {
|
||||
value: token,
|
||||
expires: expiresAt,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
const now = new Date();
|
||||
@ -148,7 +159,12 @@ class AdminSessions {
|
||||
|
||||
return this.statements
|
||||
.insertSessionTokenWithExpiry(token, expiresAtDefault.toISOString())
|
||||
.map(() => token);
|
||||
.map(() => {
|
||||
return {
|
||||
value: token,
|
||||
expires: expiresAtDefault,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
public verifyToken(token: string): Result<boolean, QueryExecutionError> {
|
||||
|
||||
@ -1,10 +1,101 @@
|
||||
import { Result } from "@shared/utils/result.ts";
|
||||
import {
|
||||
InferSchemaType,
|
||||
Schema,
|
||||
SchemaValidationError,
|
||||
} from "@shared/utils/validator.ts";
|
||||
import {
|
||||
RequestValidationError,
|
||||
requestValidationError,
|
||||
ResponseValidationError,
|
||||
responseValidationError,
|
||||
} from "@src/lib/errors.ts";
|
||||
import { ResultAsync } from "@shared/utils/resultasync.ts";
|
||||
|
||||
class Api<Req extends object, Res extends object> {
|
||||
client = {
|
||||
validate(res: Response): Result<Req, any>,
|
||||
};
|
||||
server = {
|
||||
validate(req: Request): Result<Res, any>,
|
||||
};
|
||||
export type ExtractRouteParams<T extends string> = T extends string
|
||||
? T extends `${infer _Start}:${infer Param}/${infer Rest}`
|
||||
? Param | ExtractRouteParams<Rest>
|
||||
: T extends `${infer _Start}:${infer Param}` ? Param
|
||||
: never
|
||||
: never;
|
||||
|
||||
type ApiError =
|
||||
| RequestValidationError
|
||||
| ResponseValidationError;
|
||||
|
||||
export class Api<
|
||||
Path extends string,
|
||||
ReqSchema extends Schema<any>,
|
||||
ResSchema extends Schema<any>,
|
||||
> {
|
||||
private readonly pathSplitted: string[];
|
||||
private readonly paramIndexes: Record<string, number>;
|
||||
|
||||
constructor(
|
||||
public readonly path: Path,
|
||||
public readonly method: string,
|
||||
public readonly schema: {
|
||||
req: ReqSchema;
|
||||
res: ResSchema;
|
||||
},
|
||||
) {
|
||||
this.pathSplitted = path.split("/");
|
||||
this.paramIndexes = this.pathSplitted.reduce<Record<string, number>>(
|
||||
(acc, segment, index) => {
|
||||
if (segment.startsWith(":")) {
|
||||
acc[segment.slice(1)] = index;
|
||||
}
|
||||
return acc;
|
||||
},
|
||||
{},
|
||||
);
|
||||
}
|
||||
|
||||
makeRequest(
|
||||
reqBody: InferSchemaType<ReqSchema>,
|
||||
params: { [K in ExtractRouteParams<Path>]: string },
|
||||
): ResultAsync<InferSchemaType<ResSchema>, ApiError> {
|
||||
return this.schema.req
|
||||
.parse(reqBody)
|
||||
.toAsync()
|
||||
.mapErr((e) => requestValidationError(e.info))
|
||||
.andThenAsync(async (data) => {
|
||||
const pathSplitted = this.pathSplitted;
|
||||
for (const [key, value] of Object.entries(params)) {
|
||||
pathSplitted[this.paramIndexes[key]] = value as string;
|
||||
}
|
||||
const path = pathSplitted.join("/");
|
||||
|
||||
console.log(data);
|
||||
|
||||
const response = await fetch(
|
||||
path,
|
||||
{
|
||||
method: this.method,
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
"Accept": "application/json; charset=utf-8",
|
||||
},
|
||||
body: JSON.stringify(data),
|
||||
},
|
||||
);
|
||||
|
||||
const resBody = await response.json();
|
||||
|
||||
return this.schema.res.parse(resBody).toAsync()
|
||||
.map((v) => v as InferSchemaType<ResSchema>)
|
||||
.mapErr((e) => responseValidationError(e.info));
|
||||
});
|
||||
}
|
||||
|
||||
public makeSafeRequest(
|
||||
reqBody: InferSchemaType<ReqSchema>,
|
||||
params: { [K in ExtractRouteParams<Path>]: string },
|
||||
): ResultAsync<InferSchemaType<ResSchema>, ResponseValidationError> {
|
||||
return this.makeRequest(reqBody, params).mapErr((e) => {
|
||||
if (e.type === "RequestValidationError") {
|
||||
throw "Failed to validate request";
|
||||
}
|
||||
return e;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@ -3,7 +3,20 @@ import { type ExtractRouteParams } from "@lib/router.ts";
|
||||
import { fromNullableVal, none, Option, some } from "@shared/utils/option.ts";
|
||||
import { deleteCookie, getCookies, setCookie } from "@std/http/cookie";
|
||||
import { type Cookie } from "@std/http/cookie";
|
||||
import { Err, Ok, type Result, ResultFromJSON } from "@shared/utils/result.ts";
|
||||
import { getMessageFromError, ok } from "@shared/utils/result.ts";
|
||||
import {
|
||||
InferSchemaType,
|
||||
Schema,
|
||||
SchemaValidationError,
|
||||
} from "@shared/utils/validator.ts";
|
||||
import { okAsync, ResultAsync } from "@shared/utils/resultasync.ts";
|
||||
import log from "@shared/utils/logger.ts";
|
||||
import {
|
||||
FailedToParseRequestAsJSONError,
|
||||
failedToParseRequestAsJSONError,
|
||||
} from "@src/lib/errors.ts";
|
||||
import { RequestValidationError } from "@src/lib/errors.ts";
|
||||
import { requestValidationError } from "@src/lib/errors.ts";
|
||||
|
||||
// https://cheatsheetseries.owasp.org/cheatsheets/HTTP_Headers_Cheat_Sheet.html
|
||||
const SECURITY_HEADERS: Headers = new Headers({
|
||||
@ -17,32 +30,38 @@ const SECURITY_HEADERS: Headers = new Headers({
|
||||
//"Content-Security-Policy":
|
||||
// "default-src 'self'; script-src 'self' 'unsafe-inline'",
|
||||
});
|
||||
const HTML_CONTENT_TYPE: [string, string] = [
|
||||
"Content-Type",
|
||||
"text/html; charset=UTF-8",
|
||||
];
|
||||
const JSON_CONTENT_TYPE: [string, string] = [
|
||||
"Content-Type",
|
||||
"application/json; charset=utf-8",
|
||||
];
|
||||
|
||||
const HTML_CONTENT_TYPE: string = "text/html; charset=UTF-8";
|
||||
const JSON_CONTENT_TYPE: string = "application/json; charset=utf-8";
|
||||
|
||||
function mergeHeaders(...headers: Headers[]): Headers {
|
||||
const mergedHeaders = new Headers();
|
||||
for (const _headers of headers) {
|
||||
for (const [key, value] of _headers.entries()) {
|
||||
mergedHeaders.set(key, value);
|
||||
const merged = new Headers();
|
||||
for (const hdr of headers) {
|
||||
hdr.forEach((value, key) => merged.set(key, value));
|
||||
}
|
||||
}
|
||||
return mergedHeaders;
|
||||
return merged;
|
||||
}
|
||||
|
||||
export class Context<S extends string = string> {
|
||||
export type ContextWithSchema<
|
||||
C extends Context<string, any, any>,
|
||||
ReqSchema extends Schema<any>,
|
||||
ResSchema extends Schema<any>,
|
||||
> = C extends Context<infer S, any, any> ? Context<S, ReqSchema, ResSchema> & {
|
||||
schema: { req: ReqSchema; res: ResSchema };
|
||||
}
|
||||
: never;
|
||||
|
||||
export class Context<
|
||||
S extends string = string,
|
||||
ReqSchema extends Schema<any> = Schema<unknown>,
|
||||
ResSchema extends Schema<any> = Schema<unknown>,
|
||||
> {
|
||||
private _url?: URL;
|
||||
private _hostname?: string;
|
||||
private _port?: number;
|
||||
private _cookies?: Record<string, string>;
|
||||
private _responseHeaders: Headers = new Headers();
|
||||
public res: Response = new Response();
|
||||
|
||||
public res = new Response();
|
||||
|
||||
constructor(
|
||||
public readonly req: Request,
|
||||
@ -50,6 +69,61 @@ export class Context<S extends string = string> {
|
||||
public readonly params: Params<ExtractRouteParams<S>>,
|
||||
) {}
|
||||
|
||||
public schema?: { req: ReqSchema; res: ResSchema };
|
||||
|
||||
public setSchema<
|
||||
Req extends Schema<any>,
|
||||
Res extends Schema<any>,
|
||||
>(
|
||||
schema: { req: Req; res: Res },
|
||||
): Context<S, Req, Res> & { schema: { req: Req; res: Res } } {
|
||||
const ctx = new Context<S, Req, Res>(this.req, this.info, this.params);
|
||||
ctx._url = this._url;
|
||||
ctx._hostname = this._hostname;
|
||||
ctx._port = this._port;
|
||||
ctx._cookies = this._cookies;
|
||||
ctx.res = this.res;
|
||||
ctx.schema = schema;
|
||||
return ctx as Context<S, Req, Res> & { schema: { req: Req; res: Res } };
|
||||
}
|
||||
|
||||
public setParams(
|
||||
params: Params<string>,
|
||||
): Context<S, ReqSchema, ResSchema> {
|
||||
const ctx = new Context<S, ReqSchema, ResSchema>(
|
||||
this.req,
|
||||
this.info,
|
||||
params,
|
||||
);
|
||||
ctx._url = this._url;
|
||||
ctx._hostname = this._hostname;
|
||||
ctx._port = this._port;
|
||||
ctx._cookies = this._cookies;
|
||||
ctx.res = this.res;
|
||||
ctx.schema = this.schema;
|
||||
return ctx as Context<S, ReqSchema, ResSchema>;
|
||||
}
|
||||
|
||||
public parseBody(): ResultAsync<
|
||||
InferSchemaType<ReqSchema>,
|
||||
RequestValidationError | FailedToParseRequestAsJSONError
|
||||
> {
|
||||
return ResultAsync
|
||||
.fromPromise(
|
||||
this.req.json(),
|
||||
(e) => failedToParseRequestAsJSONError(getMessageFromError(e)),
|
||||
)
|
||||
.andThen((data: unknown) => {
|
||||
if (!this.schema) {
|
||||
return ok(data);
|
||||
}
|
||||
|
||||
return this.schema?.req.parse(data).mapErr((e) =>
|
||||
requestValidationError(e.info)
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
get url(): URL {
|
||||
return this._url ?? (this._url = new URL(this.req.url));
|
||||
}
|
||||
@ -59,30 +133,34 @@ export class Context<S extends string = string> {
|
||||
}
|
||||
|
||||
get preferredType(): Option<"json" | "html"> {
|
||||
const headers = new Headers(this.req.headers);
|
||||
|
||||
return fromNullableVal(headers.get("accept")).andThen(
|
||||
(types_header) => {
|
||||
const types = types_header.split(";")[0].trim().split(",");
|
||||
|
||||
for (const type of types) {
|
||||
if (type === "text/html") {
|
||||
return some("html");
|
||||
}
|
||||
if (type === "application/json") {
|
||||
return some("json");
|
||||
}
|
||||
}
|
||||
const accept = this.req.headers.get("accept");
|
||||
if (!accept) return none;
|
||||
const types = accept
|
||||
.split(",")
|
||||
.map((t) => t.split(";")[0].trim());
|
||||
if (types.includes("text/html")) return some("html");
|
||||
if (types.includes("application/json")) return some("json");
|
||||
return none;
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
matchPreferredType(
|
||||
html: () => Response,
|
||||
json: () => Response,
|
||||
other: () => Response,
|
||||
): Response {
|
||||
switch (this.preferredType.unwrapOr("other")) {
|
||||
case "json":
|
||||
return json();
|
||||
case "html":
|
||||
return html();
|
||||
case "other":
|
||||
return other();
|
||||
}
|
||||
}
|
||||
|
||||
get hostname(): Option<string> {
|
||||
if (this._hostname) return some(this._hostname);
|
||||
|
||||
const remoteAddr = this.info.remoteAddr;
|
||||
|
||||
if (remoteAddr.transport === "tcp" || remoteAddr.transport === "udp") {
|
||||
this._hostname = remoteAddr.hostname;
|
||||
return some(remoteAddr.hostname);
|
||||
@ -92,9 +170,7 @@ export class Context<S extends string = string> {
|
||||
|
||||
get port(): Option<number> {
|
||||
if (this._port) return some(this._port);
|
||||
|
||||
const remoteAddr = this.info.remoteAddr;
|
||||
|
||||
if (remoteAddr.transport === "tcp" || remoteAddr.transport === "udp") {
|
||||
this._port = remoteAddr.port;
|
||||
return some(remoteAddr.port);
|
||||
@ -102,16 +178,27 @@ export class Context<S extends string = string> {
|
||||
return none;
|
||||
}
|
||||
|
||||
public json(body?: object | string, init: ResponseInit = {}): Response {
|
||||
const headers = mergeHeaders(
|
||||
private buildHeaders(
|
||||
initHeaders?: HeadersInit,
|
||||
contentType?: string,
|
||||
): Headers {
|
||||
const merged = mergeHeaders(
|
||||
SECURITY_HEADERS,
|
||||
this._responseHeaders,
|
||||
new Headers(init.headers),
|
||||
this.res.headers,
|
||||
new Headers(initHeaders),
|
||||
);
|
||||
headers.set(...JSON_CONTENT_TYPE);
|
||||
let status = init.status || 200;
|
||||
if (contentType) merged.set("Content-Type", contentType);
|
||||
return merged;
|
||||
}
|
||||
|
||||
public json(
|
||||
body?: ResSchema extends Schema<infer T> ? T : object | string,
|
||||
init: ResponseInit = {},
|
||||
): Response {
|
||||
const headers = this.buildHeaders(init.headers, JSON_CONTENT_TYPE);
|
||||
let status = init.status ?? 200;
|
||||
let responseBody: BodyInit | null = null;
|
||||
|
||||
if (typeof body === "string") {
|
||||
responseBody = body;
|
||||
} else if (body !== undefined) {
|
||||
@ -126,74 +213,55 @@ export class Context<S extends string = string> {
|
||||
}
|
||||
}
|
||||
|
||||
return new Response(responseBody, {
|
||||
this.res = new Response(responseBody, {
|
||||
status,
|
||||
headers,
|
||||
});
|
||||
return this.res;
|
||||
}
|
||||
|
||||
public json400(
|
||||
body?: ResSchema extends Schema<infer T> ? T : object | string,
|
||||
init: ResponseInit = {},
|
||||
): Response {
|
||||
return this.json(body, { ...init, status: 400 });
|
||||
}
|
||||
|
||||
public json500(
|
||||
body?: ResSchema extends Schema<infer T> ? T : object | string,
|
||||
init: ResponseInit = {},
|
||||
) {
|
||||
return this.json(body, { ...init, status: 500 });
|
||||
}
|
||||
|
||||
public html(body?: BodyInit | null, init: ResponseInit = {}): Response {
|
||||
const headers = mergeHeaders(
|
||||
SECURITY_HEADERS,
|
||||
this._responseHeaders,
|
||||
new Headers(init.headers),
|
||||
);
|
||||
headers.set(...HTML_CONTENT_TYPE);
|
||||
const headers = this.buildHeaders(init.headers, HTML_CONTENT_TYPE);
|
||||
const status = init.status ?? 200;
|
||||
|
||||
return new Response(body ?? null, {
|
||||
status,
|
||||
headers,
|
||||
});
|
||||
this.res = new Response(body ?? null, { status, headers });
|
||||
return this.res;
|
||||
}
|
||||
|
||||
public redirect(url: string, permanent = false): Response {
|
||||
const headers = mergeHeaders(
|
||||
this._responseHeaders,
|
||||
this.res.headers,
|
||||
new Headers({ location: url }),
|
||||
);
|
||||
|
||||
return new Response(null, {
|
||||
this.res = new Response(null, {
|
||||
status: permanent ? 301 : 302,
|
||||
headers,
|
||||
});
|
||||
return this.res;
|
||||
}
|
||||
|
||||
public cookies = (() => {
|
||||
const self = this;
|
||||
|
||||
public get cookies() {
|
||||
return {
|
||||
get(name: string): Option<string> {
|
||||
if (!self._cookies) {
|
||||
self._cookies = getCookies(self.req.headers);
|
||||
}
|
||||
|
||||
return fromNullableVal(self._cookies[name]);
|
||||
},
|
||||
|
||||
set(cookie: Cookie) {
|
||||
setCookie(self._responseHeaders, cookie);
|
||||
},
|
||||
|
||||
delete(name: string) {
|
||||
deleteCookie(self._responseHeaders, name);
|
||||
get: (name: string): Option<string> => {
|
||||
this._cookies ??= getCookies(this.req.headers);
|
||||
return fromNullableVal(this._cookies[name]);
|
||||
},
|
||||
set: (cookie: Cookie) => setCookie(this.res.headers, cookie),
|
||||
delete: (name: string) => deleteCookie(this.res.headers, name),
|
||||
};
|
||||
})();
|
||||
|
||||
static setParams<S extends string>(
|
||||
ctx: Context<string>,
|
||||
params: Params<ExtractRouteParams<S>>,
|
||||
): Context<S> {
|
||||
const newCtx = new Context(ctx.req, ctx.info, params);
|
||||
|
||||
newCtx._url = ctx._url;
|
||||
newCtx._hostname = ctx._hostname;
|
||||
newCtx._port = ctx._port;
|
||||
newCtx._cookies = ctx._cookies;
|
||||
newCtx._responseHeaders = ctx._responseHeaders;
|
||||
|
||||
return newCtx;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
import { Database, RestBindParameters } from "@db/sqlite";
|
||||
import { err, getMessageFromError, ok, Result } from "@shared/utils/result.ts";
|
||||
import { QueryExecutionError } from "@lib/errors.ts";
|
||||
import { QueryExecutionError, queryExecutionError } from "@lib/errors.ts";
|
||||
import { fromNullableVal, none, Option, some } from "@shared/utils/option.ts";
|
||||
import log from "@shared/utils/logger.ts";
|
||||
|
||||
@ -13,7 +13,7 @@ export class DatabaseClient {
|
||||
} catch (e) {
|
||||
const message = getMessageFromError(e);
|
||||
log.error(`Failed to execute sql! Error: ${e}`);
|
||||
return err(new QueryExecutionError(message));
|
||||
return err(queryExecutionError(message));
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
128
server/src/lib/devices.ts
Normal file
128
server/src/lib/devices.ts
Normal file
@ -0,0 +1,128 @@
|
||||
import usbip, { DeviceDetailed } from "@src/lib/usbip.ts";
|
||||
import { none, Option, some } from "@shared/utils/option.ts";
|
||||
import { InferSchemaType, z } from "@shared/utils/validator.ts";
|
||||
import log from "@shared/utils/logger.ts";
|
||||
import { ResultAsync } from "@shared/utils/resultasync.ts";
|
||||
import { err, Ok, ok, Result } from "@shared/utils/result.ts";
|
||||
import {
|
||||
CommandExecutionError,
|
||||
DeviceDoesNotExistError,
|
||||
deviceDoesNotExistError,
|
||||
UsbipUnknownError,
|
||||
} from "@src/lib/errors.ts";
|
||||
|
||||
type FailedToAccessDevices = CommandExecutionError | UsbipUnknownError;
|
||||
|
||||
class Devices {
|
||||
private devices: Result<
|
||||
Map<string, Device>,
|
||||
CommandExecutionError | UsbipUnknownError
|
||||
> = ok(new Map());
|
||||
|
||||
public update(
|
||||
busid: string,
|
||||
update: Partial<DeviceMutables>,
|
||||
): Result<void, DeviceDoesNotExistError | FailedToAccessDevices> {
|
||||
return this.devices.andThen((devices) => {
|
||||
const device = devices.get(busid);
|
||||
|
||||
if (device === undefined) {
|
||||
return err(
|
||||
deviceDoesNotExistError(
|
||||
`Device with busid ${busid} does not exist`,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
for (const key of Object.keys(update)) {
|
||||
device[key as keyof typeof update] =
|
||||
update[key as keyof typeof update] || none;
|
||||
}
|
||||
|
||||
return ok();
|
||||
});
|
||||
}
|
||||
|
||||
public updateDevices(): ResultAsync<
|
||||
void,
|
||||
FailedToAccessDevices
|
||||
> {
|
||||
return usbip.getDevicesDetailed()
|
||||
.mapErr((e) => {
|
||||
log.error("Failed to update devices!");
|
||||
this.devices = err(e);
|
||||
return e;
|
||||
})
|
||||
.map((d) => d.unwrapOr([] as DeviceDetailed[]))
|
||||
.map(
|
||||
(devices) => {
|
||||
const current = new Set(devices.map((d) => d.busid));
|
||||
const old = new Set(
|
||||
this.devices.unwrapOrElse(() => {
|
||||
this.devices = ok(new Map());
|
||||
return this.devices.unwrap();
|
||||
}).keys(),
|
||||
);
|
||||
|
||||
const connected = current.difference(old);
|
||||
const disconnected = old.difference(current);
|
||||
|
||||
for (const device of devices) {
|
||||
if (connected.has(device.busid)) {
|
||||
this.devices.unwrap().set(
|
||||
device.busid,
|
||||
this.deviceFromDetailed(device),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
for (const device of disconnected) {
|
||||
this.devices.unwrap().delete(device);
|
||||
}
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
private deviceFromDetailed(d: DeviceDetailed): Device {
|
||||
return {
|
||||
busid: d.busid,
|
||||
usbid: d.usbid,
|
||||
vendor: d.vendor,
|
||||
name: d.name,
|
||||
displayName: none,
|
||||
description: none,
|
||||
connectedAt: new Date(),
|
||||
};
|
||||
}
|
||||
|
||||
public list(): Result<Option<Device[]>, FailedToAccessDevices> {
|
||||
return this.devices.map((devices) => devices.values().toArray()).map((
|
||||
devices,
|
||||
) => devices.length > 0 ? some(devices) : none);
|
||||
}
|
||||
}
|
||||
|
||||
export const deviceSchema = z.obj({
|
||||
busid: z.string(),
|
||||
usbid: z.option(z.string()),
|
||||
vendor: z.option(z.string()),
|
||||
name: z.option(z.string()),
|
||||
displayName: z.option(z.string()),
|
||||
description: z.option(z.string()),
|
||||
connectedAt: z.date(),
|
||||
}).strict();
|
||||
|
||||
export const deviceMutablesSchema = deviceSchema.pick({
|
||||
displayName: true,
|
||||
description: true,
|
||||
});
|
||||
|
||||
export type DeviceMutables = InferSchemaType<typeof deviceMutablesSchema>;
|
||||
|
||||
export type Device = InferSchemaType<typeof deviceSchema>;
|
||||
|
||||
const devices = new Devices();
|
||||
|
||||
devices.updateDevices();
|
||||
|
||||
export default devices;
|
||||
@ -1,50 +1,166 @@
|
||||
import log from "@shared/utils/logger.ts";
|
||||
import { InferSchemaType, z } from "@shared/utils/validator.ts";
|
||||
import { createErrorFactory, defineError } from "@shared/utils/errors.ts";
|
||||
|
||||
export class ErrorBase extends Error {
|
||||
constructor(message: string = "An unknown error has occurred") {
|
||||
super(message);
|
||||
this.name = this.constructor.name;
|
||||
}
|
||||
}
|
||||
export const queryExecutionErrorSchema = defineError(
|
||||
"QueryExecutionError",
|
||||
);
|
||||
export const queryExecutionError = createErrorFactory(
|
||||
queryExecutionErrorSchema,
|
||||
);
|
||||
export type QueryExecutionError = InferSchemaType<
|
||||
typeof queryExecutionErrorSchema
|
||||
>;
|
||||
|
||||
export class QueryExecutionError extends ErrorBase {
|
||||
public readonly code = "QueryExecutionError";
|
||||
constructor(message: string) {
|
||||
super(message);
|
||||
}
|
||||
}
|
||||
export const noAdminEntryErrorSchema = defineError("NoAdminEntryError");
|
||||
export const noAdminEntryError = createErrorFactory(noAdminEntryErrorSchema);
|
||||
export type NoAdminEntryError = InferSchemaType<typeof noAdminEntryErrorSchema>;
|
||||
|
||||
export class NoAdminEntryError extends ErrorBase {
|
||||
public readonly code = "NoAdminEntry";
|
||||
constructor(message: string) {
|
||||
super(message);
|
||||
}
|
||||
}
|
||||
export const failedToReadFileErrorSchema = defineError("FailedToReadFileError");
|
||||
export const failedToReadFileError = createErrorFactory(
|
||||
failedToReadFileErrorSchema,
|
||||
);
|
||||
export type FailedToReadFileError = InferSchemaType<
|
||||
typeof failedToReadFileErrorSchema
|
||||
>;
|
||||
|
||||
export class FailedToReadFileError extends ErrorBase {
|
||||
public readonly code = "FailedToReadFileError";
|
||||
constructor(message: string) {
|
||||
super(message);
|
||||
}
|
||||
}
|
||||
export const invalidSyntaxErrorSchema = defineError("InvalidSyntaxError");
|
||||
export const invalidSyntaxError = createErrorFactory(invalidSyntaxErrorSchema);
|
||||
export type InvalidSyntaxError = InferSchemaType<
|
||||
typeof invalidSyntaxErrorSchema
|
||||
>;
|
||||
|
||||
export class InvalidSyntaxError extends ErrorBase {
|
||||
public readonly code = "InvalidSyntax";
|
||||
constructor(message: string) {
|
||||
super(message);
|
||||
}
|
||||
}
|
||||
export const invalidPathErrorSchema = defineError("InvalidPathError");
|
||||
export const invalidPathError = createErrorFactory(invalidPathErrorSchema);
|
||||
export type InvalidPathError = InferSchemaType<typeof invalidPathErrorSchema>;
|
||||
|
||||
export class InvalidPathError extends ErrorBase {
|
||||
public readonly code = "InvalidPath";
|
||||
constructor(message: string) {
|
||||
super(message);
|
||||
}
|
||||
}
|
||||
export const adminPasswordNotSetErrorSchema = defineError(
|
||||
"AdminPasswordNotSetError",
|
||||
);
|
||||
export const adminPasswordNotSetError = createErrorFactory(
|
||||
adminPasswordNotSetErrorSchema,
|
||||
);
|
||||
export type AdminPasswordNotSetError = InferSchemaType<
|
||||
typeof adminPasswordNotSetErrorSchema
|
||||
>;
|
||||
|
||||
export class AdminPasswordNotSetError extends ErrorBase {
|
||||
public readonly code = "AdminPasswordNotSetError";
|
||||
constructor(message: string) {
|
||||
super(message);
|
||||
}
|
||||
}
|
||||
export const requestValidationErrorSchema = defineError(
|
||||
"RequestValidationError",
|
||||
);
|
||||
export const requestValidationError = createErrorFactory(
|
||||
requestValidationErrorSchema,
|
||||
);
|
||||
export type RequestValidationError = InferSchemaType<
|
||||
typeof requestValidationErrorSchema
|
||||
>;
|
||||
|
||||
export const responseValidationErrorSchema = defineError(
|
||||
"ResponseValidationError",
|
||||
);
|
||||
export const responseValidationError = createErrorFactory(
|
||||
responseValidationErrorSchema,
|
||||
);
|
||||
export type ResponseValidationError = InferSchemaType<
|
||||
typeof responseValidationErrorSchema
|
||||
>;
|
||||
|
||||
export const failedToParseRequestAsJSONErrorSchema = defineError(
|
||||
"FailedToParseRequestAsJSONError",
|
||||
);
|
||||
export const failedToParseRequestAsJSONError = createErrorFactory(
|
||||
failedToParseRequestAsJSONErrorSchema,
|
||||
);
|
||||
export type FailedToParseRequestAsJSONError = InferSchemaType<
|
||||
typeof failedToParseRequestAsJSONErrorSchema
|
||||
>;
|
||||
|
||||
export const tooManyRequestsErrorSchema = defineError(
|
||||
"TooManyRequestsError",
|
||||
);
|
||||
export const tooManyRequestsError = createErrorFactory(
|
||||
tooManyRequestsErrorSchema,
|
||||
);
|
||||
export type TooManyRequestsError = InferSchemaType<
|
||||
typeof tooManyRequestsErrorSchema
|
||||
>;
|
||||
|
||||
export const unauthorizedErrorSchema = defineError(
|
||||
"UnauthorizedError",
|
||||
);
|
||||
export const unauthorizedError = createErrorFactory(unauthorizedErrorSchema);
|
||||
export type UnauthorizedError = InferSchemaType<typeof unauthorizedErrorSchema>;
|
||||
|
||||
export const invalidPasswordErrorSchema = defineError("InvalidPasswordError");
|
||||
export const invalidPasswordError = createErrorFactory(
|
||||
invalidPasswordErrorSchema,
|
||||
);
|
||||
export type InvalidPasswordError = InferSchemaType<
|
||||
typeof invalidPasswordErrorSchema
|
||||
>;
|
||||
|
||||
export const adminPasswordAlreadySetErrorSchema = defineError(
|
||||
"AdminPasswordAlreadySetError",
|
||||
);
|
||||
export const adminPasswordAlreadySetError = createErrorFactory(
|
||||
adminPasswordAlreadySetErrorSchema,
|
||||
);
|
||||
export type AdminPasswordAlreadySetError = InferSchemaType<
|
||||
typeof adminPasswordAlreadySetErrorSchema
|
||||
>;
|
||||
|
||||
export const passwordsMustMatchErrorSchema = defineError(
|
||||
"PasswordsMustMatchError",
|
||||
);
|
||||
export const passwordsMustMatchError = createErrorFactory(
|
||||
passwordsMustMatchErrorSchema,
|
||||
);
|
||||
export type PasswordsMustMatchError = InferSchemaType<
|
||||
typeof passwordsMustMatchErrorSchema
|
||||
>;
|
||||
|
||||
export const commandExecutionErrorSchema = defineError("CommandExecutionError");
|
||||
export const commandExecutionError = createErrorFactory(
|
||||
commandExecutionErrorSchema,
|
||||
);
|
||||
export type CommandExecutionError = InferSchemaType<
|
||||
typeof commandExecutionErrorSchema
|
||||
>;
|
||||
|
||||
export const deviceDoesNotExistErrorSchema = defineError(
|
||||
"DeviceDoesNotExistError",
|
||||
);
|
||||
export const deviceDoesNotExistError = createErrorFactory(
|
||||
deviceDoesNotExistErrorSchema,
|
||||
);
|
||||
export type DeviceDoesNotExistError = InferSchemaType<
|
||||
typeof deviceDoesNotExistErrorSchema
|
||||
>;
|
||||
|
||||
export const deviceAlreadyBoundErrorSchema = defineError(
|
||||
"DeviceAlreadyBoundError",
|
||||
);
|
||||
export const deviceAlreadyBoundError = createErrorFactory(
|
||||
deviceAlreadyBoundErrorSchema,
|
||||
);
|
||||
export type DeviceAlreadyBoundError = InferSchemaType<
|
||||
typeof deviceAlreadyBoundErrorSchema
|
||||
>;
|
||||
|
||||
export const deviceNotBoundErrorSchema = defineError("DeviceNotBoundError");
|
||||
export const deviceNotBoundError = createErrorFactory(
|
||||
deviceNotBoundErrorSchema,
|
||||
);
|
||||
export type DeviceNotBoundError = InferSchemaType<
|
||||
typeof deviceNotBoundErrorSchema
|
||||
>;
|
||||
|
||||
export const usbipUnknownErrorSchema = defineError("UsbipUnknownError");
|
||||
export const usbipUnknownError = createErrorFactory(usbipUnknownErrorSchema);
|
||||
export type UsbipUnknownError = InferSchemaType<typeof usbipUnknownErrorSchema>;
|
||||
|
||||
export const notFoundErrorSchema = defineError("NotFoundError");
|
||||
export const notFoundError = createErrorFactory(notFoundErrorSchema);
|
||||
export type NotFoundError = InferSchemaType<typeof notFoundErrorSchema>;
|
||||
|
||||
export const notAllowedErrorSchema = defineError("NotAllowedError");
|
||||
export const notAllowedError = createErrorFactory(notAllowedErrorSchema);
|
||||
export type NotAllowedError = InferSchemaType<typeof notAllowedErrorSchema>;
|
||||
|
||||
@ -1,62 +1,90 @@
|
||||
import { RouterTree } from "@lib/routerTree.ts";
|
||||
import { none, Option, some } from "@shared/utils/option.ts";
|
||||
import { Context } from "@lib/context.ts";
|
||||
import { RouterTree } from "@src/lib/routerTree.ts";
|
||||
import { none, some } from "@shared/utils/option.ts";
|
||||
import { Context } from "@src/lib/context.ts";
|
||||
import { Schema } from "@shared/utils/validator.ts";
|
||||
import { Api } from "@src/lib/apiValidator.ts";
|
||||
import { notAllowedError, notFoundError } from "@src/lib/errors.ts";
|
||||
import { err } from "@shared/utils/result.ts";
|
||||
|
||||
type RequestHandler<
|
||||
S extends string,
|
||||
ReqSchema extends Schema<any> = Schema<unknown>,
|
||||
ResSchema extends Schema<any> = Schema<unknown>,
|
||||
> = (c: Context<S, ReqSchema, ResSchema>) => Promise<Response> | Response;
|
||||
|
||||
type RequestHandler<S extends string> = (
|
||||
c: Context<S>,
|
||||
) => Promise<Response> | Response;
|
||||
export type Middleware = (
|
||||
c: Context<string>,
|
||||
next: () => Promise<void>,
|
||||
) => Promise<Response | void> | Response | void;
|
||||
|
||||
type MethodHandler<S extends string> = {
|
||||
handler: RequestHandler<S>;
|
||||
schema?: { req: Schema<any>; res: Schema<any> };
|
||||
};
|
||||
|
||||
type MethodHandlers<S extends string> = Partial<
|
||||
Record<string, RequestHandler<S>>
|
||||
Record<string, MethodHandler<S>>
|
||||
>;
|
||||
|
||||
const DEFAULT_NOT_FOUND_HANDLER = () => new Response("404 Not found");
|
||||
const DEFAULT_NOT_FOUND_HANDLER =
|
||||
(() => new Response("404 Not found", { status: 404 })) as RequestHandler<
|
||||
any
|
||||
>;
|
||||
|
||||
class HttpRouter {
|
||||
routerTree = new RouterTree<MethodHandlers<any>>();
|
||||
pathPreprocessor?: (path: string) => string;
|
||||
middlewares: Middleware[] = [];
|
||||
defaultNotFoundHandler: RequestHandler<string> = DEFAULT_NOT_FOUND_HANDLER;
|
||||
public readonly routerTree = new RouterTree<MethodHandlers<any>>();
|
||||
public pathTransformer?: (path: string) => string;
|
||||
private middlewares: Middleware[] = [];
|
||||
public defaultNotFoundHandler: RequestHandler<string> =
|
||||
DEFAULT_NOT_FOUND_HANDLER;
|
||||
|
||||
setPathProcessor(processor: (path: string) => string) {
|
||||
this.pathPreprocessor = processor;
|
||||
}
|
||||
|
||||
use(mw: Middleware): HttpRouter {
|
||||
this.middlewares.push(mw);
|
||||
public setPathTransformer(transformer: (path: string) => string) {
|
||||
this.pathTransformer = transformer;
|
||||
return this;
|
||||
}
|
||||
|
||||
add<S extends string>(
|
||||
public use(middleware: Middleware): this {
|
||||
this.middlewares.push(middleware);
|
||||
return this;
|
||||
}
|
||||
|
||||
public add<
|
||||
S extends string,
|
||||
ReqSchema extends Schema<any> = Schema<unknown>,
|
||||
ResSchema extends Schema<any> = Schema<unknown>,
|
||||
>(
|
||||
path: S,
|
||||
method: string,
|
||||
handler: RequestHandler<S>,
|
||||
handler: RequestHandler<S, ReqSchema, ResSchema>,
|
||||
schema?: { req: ReqSchema; res: ResSchema },
|
||||
): HttpRouter;
|
||||
add<S extends string>(
|
||||
public add<
|
||||
S extends string,
|
||||
ReqSchema extends Schema<any> = Schema<unknown>,
|
||||
ResSchema extends Schema<any> = Schema<unknown>,
|
||||
>(
|
||||
path: S[],
|
||||
method: string,
|
||||
handler: RequestHandler<string>,
|
||||
handler: RequestHandler<string, ReqSchema, ResSchema>,
|
||||
schema?: { req: ReqSchema; res: ResSchema },
|
||||
): HttpRouter;
|
||||
add(
|
||||
public add(
|
||||
path: string | string[],
|
||||
method: string,
|
||||
handler: RequestHandler<string>,
|
||||
schema?: { req: Schema<any>; res: Schema<any> },
|
||||
): HttpRouter {
|
||||
const paths = Array.isArray(path) ? path : [path];
|
||||
|
||||
for (const p of paths) {
|
||||
this.routerTree.getHandler(p).match(
|
||||
(mth) => {
|
||||
mth[method] = handler;
|
||||
(existingHandlers) => {
|
||||
existingHandlers[method] = { handler, schema };
|
||||
},
|
||||
() => {
|
||||
const mth: MethodHandlers<string> = {};
|
||||
mth[method] = handler;
|
||||
this.routerTree.add(p, mth);
|
||||
const newHandlers: MethodHandlers<string> = {};
|
||||
newHandlers[method] = { handler, schema };
|
||||
this.routerTree.add(p, newHandlers);
|
||||
},
|
||||
);
|
||||
}
|
||||
@ -64,63 +92,147 @@ class HttpRouter {
|
||||
return this;
|
||||
}
|
||||
|
||||
// Overload signatures for 'get'
|
||||
get<S extends string>(path: S, handler: RequestHandler<S>): HttpRouter;
|
||||
get<S extends string>(
|
||||
public get<S extends string>(
|
||||
path: S,
|
||||
handler: RequestHandler<S>,
|
||||
): HttpRouter;
|
||||
public get<S extends string>(
|
||||
path: S[],
|
||||
handler: RequestHandler<string>,
|
||||
): HttpRouter;
|
||||
|
||||
// Non-generic implementation for 'get'
|
||||
get(path: string | string[], handler: RequestHandler<string>): HttpRouter {
|
||||
public get(
|
||||
path: string | string[],
|
||||
handler: RequestHandler<string>,
|
||||
): HttpRouter {
|
||||
if (Array.isArray(path)) {
|
||||
return this.add(path, "GET", handler);
|
||||
}
|
||||
return this.add(path, "GET", handler);
|
||||
}
|
||||
|
||||
post<S extends string>(path: S, handler: RequestHandler<S>): HttpRouter;
|
||||
post<S extends string>(
|
||||
public post<S extends string>(
|
||||
path: S,
|
||||
handler: RequestHandler<S>,
|
||||
): HttpRouter;
|
||||
public post<S extends string>(
|
||||
path: string[],
|
||||
handler: RequestHandler<string>,
|
||||
): HttpRouter;
|
||||
|
||||
post(path: string | string[], handler: RequestHandler<string>): HttpRouter {
|
||||
public post(
|
||||
path: string | string[],
|
||||
handler: RequestHandler<string>,
|
||||
): HttpRouter {
|
||||
if (Array.isArray(path)) {
|
||||
return this.add(path, "POST", handler);
|
||||
}
|
||||
return this.add(path, "POST", handler);
|
||||
}
|
||||
|
||||
public api<
|
||||
Path extends string,
|
||||
ReqSchema extends Schema<any>,
|
||||
ResSchema extends Schema<any>,
|
||||
>(
|
||||
api: Api<Path, ReqSchema, ResSchema>,
|
||||
handler: RequestHandler<Path, ReqSchema, ResSchema>,
|
||||
): HttpRouter {
|
||||
return this.add(api.path, api.method, handler, api.schema);
|
||||
}
|
||||
|
||||
async handleRequest(
|
||||
req: Request,
|
||||
connInfo: Deno.ServeHandlerInfo<Deno.Addr>,
|
||||
): Promise<Response> {
|
||||
const c = new Context(req, connInfo, {});
|
||||
let ctx = new Context(req, connInfo, {});
|
||||
const path = this.pathTransformer
|
||||
? this.pathTransformer(ctx.path)
|
||||
: ctx.path;
|
||||
|
||||
const path = this.pathPreprocessor
|
||||
? this.pathPreprocessor(c.path)
|
||||
: c.path;
|
||||
|
||||
let params: string[] = [];
|
||||
let routeParams: Record<string, string> = {};
|
||||
|
||||
const handler = this.routerTree
|
||||
.find(path)
|
||||
.andThen((routeMatch) => {
|
||||
const { value: handlers, params: paramsMatched } = routeMatch;
|
||||
params = paramsMatched;
|
||||
const handler = handlers[req.method];
|
||||
return handler ? some(handler) : none;
|
||||
})
|
||||
.unwrapOrElse(() => this.defaultNotFoundHandler);
|
||||
.andThen((match) => {
|
||||
const { value: methodHandler, params: params } = match;
|
||||
routeParams = params;
|
||||
|
||||
const cf = await this.executeMiddlewareChain(
|
||||
let route = methodHandler[req.method];
|
||||
|
||||
if (!route) {
|
||||
if (req.method === "HEAD") {
|
||||
const getHandler = methodHandler["GET"];
|
||||
if (!getHandler) {
|
||||
return none;
|
||||
}
|
||||
route = getHandler;
|
||||
} else if (
|
||||
ctx.preferredType.map((v) => v === "json")
|
||||
.toBoolean() &&
|
||||
req.method !== "GET"
|
||||
) {
|
||||
return some(
|
||||
(() =>
|
||||
ctx.json(
|
||||
err(notAllowedError(
|
||||
"405 Not allowed",
|
||||
)),
|
||||
{
|
||||
status: 405,
|
||||
},
|
||||
)) as RequestHandler<any>,
|
||||
);
|
||||
}
|
||||
return none;
|
||||
}
|
||||
if (route.schema) {
|
||||
ctx = ctx.setSchema(route.schema);
|
||||
}
|
||||
const handler = route.handler;
|
||||
|
||||
return some(handler);
|
||||
})
|
||||
.unwrapOrElse(() => {
|
||||
switch (ctx.preferredType.unwrapOr("other")) {
|
||||
case "json":
|
||||
return (() =>
|
||||
ctx.json(err(notFoundError("404 Not found")), {
|
||||
status: 404,
|
||||
})) as RequestHandler<any>;
|
||||
case "html":
|
||||
return (() =>
|
||||
ctx.html("404 Not found", {
|
||||
status: 404,
|
||||
})) as RequestHandler<any>;
|
||||
case "other":
|
||||
return DEFAULT_NOT_FOUND_HANDLER;
|
||||
}
|
||||
});
|
||||
|
||||
const res = (await this.executeMiddlewareChain(
|
||||
this.middlewares,
|
||||
handler,
|
||||
Context.setParams(c, params),
|
||||
);
|
||||
ctx = ctx.setParams(routeParams),
|
||||
)).res;
|
||||
|
||||
return cf.res;
|
||||
if (req.method === "HEAD") {
|
||||
const headers = new Headers(res.headers);
|
||||
headers.set("Content-Length", "0");
|
||||
return new Response(null, {
|
||||
headers,
|
||||
status: res.status,
|
||||
statusText: res.statusText,
|
||||
});
|
||||
}
|
||||
|
||||
return res;
|
||||
}
|
||||
|
||||
private resolveRoute(
|
||||
ctx: Context,
|
||||
req: Request,
|
||||
path: string,
|
||||
): { handler: RequestHandler<any>; params: Record<string, string> } {
|
||||
const routeOption = this.routerTree.find(path);
|
||||
}
|
||||
|
||||
private async executeMiddlewareChain<S extends string>(
|
||||
@ -151,10 +263,6 @@ class HttpRouter {
|
||||
|
||||
return c;
|
||||
}
|
||||
|
||||
private setParams(path: string, params: string[]): Params<string> {
|
||||
path.split("/").filter((segmet) => segmet.startsWith(":"));
|
||||
}
|
||||
}
|
||||
|
||||
export type ExtractRouteParams<T extends string> = T extends string
|
||||
|
||||
@ -1,9 +1,16 @@
|
||||
import { fromNullableVal, none, Option, some } from "@shared/utils/option.ts";
|
||||
|
||||
const DEFAULT_WILDCARD_SYMBOL = "*";
|
||||
const DEFAULT_WILDCARD = "*";
|
||||
const DEFAULT_PARAM_PREFIX = ":";
|
||||
const DEFAULT_PATH_SEPARATOR = "/";
|
||||
|
||||
export type Params = Record<string, string>;
|
||||
|
||||
interface RouteMatch<T> {
|
||||
value: T;
|
||||
params: Params;
|
||||
}
|
||||
|
||||
interface Node<T> {
|
||||
handler: Option<T>;
|
||||
paramNames: string[];
|
||||
@ -29,52 +36,52 @@ class StaticNode<T> implements Node<T> {
|
||||
this.handler = fromNullableVal(handler);
|
||||
}
|
||||
|
||||
addStaticChild(segment: string, handler?: T): StaticNode<T> {
|
||||
private addStaticChild(segment: string, handler?: T): StaticNode<T> {
|
||||
const child = new StaticNode(handler);
|
||||
this.staticChildren.set(segment, child);
|
||||
return child;
|
||||
}
|
||||
|
||||
setDynamicChild(handler?: T): DynamicNode<T> {
|
||||
private createDynamicChild(handler?: T): DynamicNode<T> {
|
||||
const child = new DynamicNode(handler);
|
||||
this.dynamicChild = some(child);
|
||||
return child;
|
||||
}
|
||||
|
||||
setWildcardNode(handler?: T): WildcardNode<T> {
|
||||
private createWildcardNode(handler?: T): WildcardNode<T> {
|
||||
const child = new WildcardNode(handler);
|
||||
this.wildcardChild = some(child);
|
||||
return child;
|
||||
}
|
||||
|
||||
addChild(
|
||||
public addChild(
|
||||
segment: string,
|
||||
wildcardSymbol: string,
|
||||
paramPrefixSymbol: string,
|
||||
handler?: T,
|
||||
): Node<T> {
|
||||
if (segment === wildcardSymbol) {
|
||||
return this.setWildcardNode(handler);
|
||||
return this.createWildcardNode(handler);
|
||||
}
|
||||
if (segment.startsWith(paramPrefixSymbol)) {
|
||||
return this.setDynamicChild(handler);
|
||||
return this.createDynamicChild(handler);
|
||||
}
|
||||
return this.addStaticChild(segment, handler);
|
||||
}
|
||||
|
||||
getStaticChild(segment: string): Option<StaticNode<T>> {
|
||||
private getStaticChild(segment: string): Option<StaticNode<T>> {
|
||||
return fromNullableVal(this.staticChildren.get(segment));
|
||||
}
|
||||
|
||||
getDynamicChild(): Option<DynamicNode<T>> {
|
||||
public getDynamicChild(): Option<DynamicNode<T>> {
|
||||
return this.dynamicChild;
|
||||
}
|
||||
|
||||
getWildcardChild(): Option<WildcardNode<T>> {
|
||||
public getWildcardChild(): Option<WildcardNode<T>> {
|
||||
return this.wildcardChild;
|
||||
}
|
||||
|
||||
getChild(segment: string): Option<Node<T>> {
|
||||
public getChild(segment: string): Option<Node<T>> {
|
||||
return this.getStaticChild(segment)
|
||||
.orElse(() => this.getWildcardChild())
|
||||
.orElse(() => this.getDynamicChild());
|
||||
@ -89,7 +96,6 @@ class StaticNode<T> implements Node<T> {
|
||||
}
|
||||
}
|
||||
|
||||
// TODO: get rid of fixed param name
|
||||
class DynamicNode<T> extends StaticNode<T> implements Node<T> {
|
||||
constructor(
|
||||
handler?: T,
|
||||
@ -112,7 +118,7 @@ class WildcardNode<T> implements Node<T> {
|
||||
|
||||
// Override to prevent adding children to a wildcard node
|
||||
public addChild(): Node<T> {
|
||||
throw new Error("Cannot add child to a WildcardNode.");
|
||||
throw new Error("Cannot add child to a wildcard (catch-all) node.");
|
||||
}
|
||||
|
||||
public getChild(): Option<Node<T>> {
|
||||
@ -128,16 +134,13 @@ class WildcardNode<T> implements Node<T> {
|
||||
}
|
||||
}
|
||||
|
||||
// Using Node<T> as the unified type for tree nodes.
|
||||
type TreeNode<T> = Node<T>;
|
||||
|
||||
export class RouterTree<T> {
|
||||
public readonly root: StaticNode<T>;
|
||||
|
||||
constructor(
|
||||
handler?: T,
|
||||
private readonly wildcardSymbol: string = DEFAULT_WILDCARD_SYMBOL,
|
||||
private readonly paramPrefixSymbol: string = DEFAULT_PARAM_PREFIX,
|
||||
private readonly wildcardSymbol: string = DEFAULT_WILDCARD,
|
||||
private readonly paramPrefix: string = DEFAULT_PARAM_PREFIX,
|
||||
private readonly pathSeparator: string = DEFAULT_PATH_SEPARATOR,
|
||||
) {
|
||||
this.root = new StaticNode(handler);
|
||||
@ -145,104 +148,84 @@ export class RouterTree<T> {
|
||||
|
||||
public add(path: string, handler: T): void {
|
||||
const segments = this.splitPath(path);
|
||||
const paramNames: string[] = this.extractParams(segments);
|
||||
let current: TreeNode<T> = this.root;
|
||||
const paramNames: string[] = this.extractParamNames(segments);
|
||||
const node: Node<T> = this.traverseOrCreate(segments);
|
||||
|
||||
for (const segment of segments) {
|
||||
current = current
|
||||
.getChild(segment)
|
||||
.unwrapOrElse(() =>
|
||||
current.addChild(
|
||||
segment,
|
||||
this.wildcardSymbol,
|
||||
this.paramPrefixSymbol,
|
||||
)
|
||||
);
|
||||
|
||||
if (current.isWildcardNode()) {
|
||||
current.paramNames = paramNames;
|
||||
current.paramNames.push("restOfThePath");
|
||||
current.handler = some(handler);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
current.paramNames = paramNames;
|
||||
current.handler = some(handler);
|
||||
node.paramNames = node.isWildcardNode()
|
||||
? [...paramNames, "restOfThePath"]
|
||||
: paramNames;
|
||||
node.handler = some(handler);
|
||||
}
|
||||
|
||||
public find(path: string): Option<RouteMatch<T>> {
|
||||
const segments = this.splitPath(path);
|
||||
const paramValues: string[] = [];
|
||||
let current: TreeNode<T> = this.root;
|
||||
let i = 0;
|
||||
|
||||
for (; i < segments.length; i++) {
|
||||
const segment = segments[i];
|
||||
if (current.isWildcardNode()) break;
|
||||
|
||||
const nextNode = current.getChild(segment).ifSome((child) => {
|
||||
if (child.isDynamicNode()) {
|
||||
paramValues.push(segment);
|
||||
}
|
||||
current = child;
|
||||
});
|
||||
|
||||
if (nextNode.isNone()) return none;
|
||||
}
|
||||
|
||||
if (current.isWildcardNode()) {
|
||||
const rest = segments.slice(i - 1);
|
||||
if (rest.length > 0) {
|
||||
paramValues.push(rest.join(this.pathSeparator));
|
||||
}
|
||||
}
|
||||
|
||||
return this.traverse(path).andThen(({ node, paramValues }) => {
|
||||
const params: Params = {};
|
||||
|
||||
for (let i = 0; i < paramValues.length; i++) {
|
||||
params[current.paramNames[i]] = paramValues[i];
|
||||
for (
|
||||
let i = 0;
|
||||
i < Math.min(paramValues.length, node.paramNames.length);
|
||||
i++
|
||||
) {
|
||||
params[node.paramNames[i]] = paramValues[i];
|
||||
}
|
||||
|
||||
return current.handler.map((value) => ({ value, params }));
|
||||
return node.handler.map((handler) => ({ value: handler, params }));
|
||||
});
|
||||
}
|
||||
|
||||
public getHandler(path: string): Option<T> {
|
||||
const segments = this.splitPath(path);
|
||||
let current: TreeNode<T> = this.root;
|
||||
|
||||
for (const segment of segments) {
|
||||
if (current.isWildcardNode()) break;
|
||||
|
||||
const child = current.getChild(segment).ifSome((child) => {
|
||||
current = child;
|
||||
});
|
||||
|
||||
if (child.isNone()) return none;
|
||||
return this.traverse(path).andThen(({ node }) => node.handler);
|
||||
}
|
||||
|
||||
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[] {
|
||||
const trimmed = path.trim().replace(/^\/+/, "").replace(/\/+$/, "");
|
||||
return trimmed ? trimmed.split(this.pathSeparator) : [];
|
||||
return path
|
||||
.trim()
|
||||
.split(this.pathSeparator)
|
||||
.filter((segment) => segment.length > 0);
|
||||
}
|
||||
|
||||
public extractParams(segments: string[]): string[] {
|
||||
public extractParamNames(segments: string[]): string[] {
|
||||
return segments.filter((segment) =>
|
||||
segment.startsWith(this.paramPrefixSymbol)
|
||||
).map((segment) => this.stripParamPrefix(segment));
|
||||
segment.startsWith(this.paramPrefix)
|
||||
).map((segment) => this.removeParamPrefix(segment));
|
||||
}
|
||||
|
||||
public stripParamPrefix(segment: string): string {
|
||||
return segment.slice(this.paramPrefixSymbol.length);
|
||||
public removeParamPrefix(segment: string): string {
|
||||
return segment.slice(this.paramPrefix.length);
|
||||
}
|
||||
}
|
||||
|
||||
export type Params = Record<string, string>;
|
||||
|
||||
interface RouteMatch<T> {
|
||||
value: T;
|
||||
params: Params;
|
||||
}
|
||||
|
||||
@ -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>[];
|
||||
@ -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>;
|
||||
}
|
||||
@ -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();
|
||||
|
||||
|
||||
@ -1,28 +1,58 @@
|
||||
import { Middleware } from "@lib/router.ts";
|
||||
import admin from "@lib/admin.ts";
|
||||
import {
|
||||
queryExecutionError,
|
||||
tooManyRequestsError,
|
||||
unauthorizedError,
|
||||
} from "@src/lib/errors.ts";
|
||||
import { err, ok } from "@shared/utils/result.ts";
|
||||
import { eta } from "../../main.ts";
|
||||
|
||||
const LOGIN_PATH = "/login";
|
||||
const EXCLUDE = new Set(["/login", "/setup", "/version"]);
|
||||
|
||||
const authMiddleware: Middleware = async (c, next) => {
|
||||
const token = c.cookies.get("token");
|
||||
const isValid = token
|
||||
.map((token) => admin.sessions.verifyToken(token))
|
||||
.toBoolean();
|
||||
.map((token) => admin.sessions.verifyToken(token)).match(
|
||||
(r) => r,
|
||||
() => ok(false),
|
||||
);
|
||||
|
||||
if (isValid.isErr()) {
|
||||
return c.matchPreferredType(
|
||||
() => c.html(eta.render("./internal_error.html", {})),
|
||||
() =>
|
||||
c.json(
|
||||
err(queryExecutionError("Server failed to execute query")),
|
||||
),
|
||||
() => new Response("500 Internal server error", { status: 500 }),
|
||||
);
|
||||
}
|
||||
|
||||
const path = c.path;
|
||||
|
||||
if (path.startsWith("/public")) {
|
||||
await next();
|
||||
} else {
|
||||
if (path !== LOGIN_PATH && !isValid) {
|
||||
if (
|
||||
!isValid.value && !path.startsWith("/public") && !EXCLUDE.has(path)
|
||||
) {
|
||||
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");
|
||||
}
|
||||
|
||||
if (path === LOGIN_PATH && isValid) {
|
||||
return c.redirect("");
|
||||
}
|
||||
|
||||
await next();
|
||||
}
|
||||
};
|
||||
|
||||
export default authMiddleware;
|
||||
|
||||
@ -1,5 +1,7 @@
|
||||
import { Middleware } from "@lib/router.ts";
|
||||
import log from "@shared/utils/logger.ts";
|
||||
import { err } from "@shared/utils/result.ts";
|
||||
import { tooManyRequestsError } from "@src/lib/errors.ts";
|
||||
|
||||
const requestCounts: Partial<
|
||||
Record<string, { count: number; lastReset: number }>
|
||||
@ -31,9 +33,7 @@ const rateLimitMiddleware: Middleware = async (c, next) => {
|
||||
}
|
||||
case "json": {
|
||||
return c.json(
|
||||
{
|
||||
err: "429 Too Many Requests",
|
||||
},
|
||||
err(tooManyRequestsError("Too many request")),
|
||||
{
|
||||
status: 429,
|
||||
},
|
||||
|
||||
@ -1,3 +1,13 @@
|
||||
<% layout("./layouts/layout.html") %>
|
||||
devices:
|
||||
<% it.devices.forEach(function(device){ %>
|
||||
<div>
|
||||
name: <%= device.name %> | <%= device.vendor %>
|
||||
busid: <%= device.busid %>
|
||||
</div>
|
||||
<%= device.busid %>
|
||||
<% }) %>
|
||||
|
||||
this is an index.html
|
||||
<button id="ping">ping</button>
|
||||
|
||||
<script src="/public/js/index.js" defer></script>
|
||||
|
||||
3
server/src/views/internal_error.html
Normal file
3
server/src/views/internal_error.html
Normal file
@ -0,0 +1,3 @@
|
||||
<% layout("./layouts/layout.html") %>
|
||||
|
||||
Internal error occurred
|
||||
@ -1,7 +1,17 @@
|
||||
<% 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>
|
||||
<% } %>
|
||||
|
||||
10
server/src/views/setup.html
Normal file
10
server/src/views/setup.html
Normal 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>
|
||||
BIN
server/test.db
BIN
server/test.db
Binary file not shown.
@ -1 +1 @@
|
||||
<% layout("./layouts/layout.html") %> this is an index.html
|
||||
<% layout("./layouts/layout.html") %> devices: <% it.devices.forEach(function(device){ %> <div>name: <%= device.name %> | <%= device.vendor %> busid: <%= device.busid %></div> <%= device.busid %> <% }) %> <button id=ping>ping</button><script defer src=/public/js/index.js></script>
|
||||
1
server/views/internal_error.html
Normal file
1
server/views/internal_error.html
Normal file
@ -0,0 +1 @@
|
||||
<% layout("./layouts/layout.html") %> Internal error occurred
|
||||
@ -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
1
server/views/setup.html
Normal 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>
|
||||
@ -1,18 +1,51 @@
|
||||
import { Result } from "@shared/utils/result.ts";
|
||||
import { type Result } from "@shared/utils/result.ts";
|
||||
import {
|
||||
type InferSchema,
|
||||
InferSchemaType,
|
||||
Schema,
|
||||
} from "@shared/utils/validator.ts";
|
||||
|
||||
class ValidationError extends BaseError {
|
||||
code = "ValidationError";
|
||||
constructor(msg: string) {
|
||||
super(msg);
|
||||
export type ExtractRouteParams<T extends string> = T extends string
|
||||
? T extends `${infer _Start}:${infer Param}/${infer Rest}`
|
||||
? Param | ExtractRouteParams<Rest>
|
||||
: T extends `${infer _Start}:${infer Param}` ? Param
|
||||
: never
|
||||
: never;
|
||||
|
||||
class ClientApi<
|
||||
Path extends string,
|
||||
ReqSchema extends Schema<any>,
|
||||
ResSchema extends Schema<any>,
|
||||
> {
|
||||
private readonly path: string[];
|
||||
private readonly paramsIndexes: Record<string, number>;
|
||||
|
||||
constructor(
|
||||
path: Path,
|
||||
public readonly reqSchema: ReqSchema,
|
||||
public readonly resSchema: ResSchema,
|
||||
) {
|
||||
this.path = path.split("/");
|
||||
this.paramsIndexes = this.path.reduce<Record<number, string>>(
|
||||
(acc, segment, index) => {
|
||||
if (segment.startsWith(":")) {
|
||||
acc[index] = segment.slice(1);
|
||||
}
|
||||
return acc;
|
||||
},
|
||||
{},
|
||||
);
|
||||
}
|
||||
|
||||
class ClientApi<Req, Res> {
|
||||
constructor(path: string, method: string) {}
|
||||
validate(res: Response): ResultAsync<Res> {
|
||||
const body = await res.json();
|
||||
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 ServerApi<Req, Res> {
|
||||
}
|
||||
|
||||
38
shared/utils/errors.ts
Normal file
38
shared/utils/errors.ts
Normal 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,
|
||||
};
|
||||
};
|
||||
}
|
||||
@ -1,3 +1,4 @@
|
||||
export * from "@shared/utils/option.ts";
|
||||
export * from "@shared/utils/result.ts";
|
||||
export * from "@shared/utils/resultasync.ts";
|
||||
export * from "@shared/utils/validator.ts";
|
||||
|
||||
@ -20,7 +20,9 @@ interface IResult<T, E> {
|
||||
mapErr<U>(fn: (err: E) => U): Result<T, U>;
|
||||
mapErrAsync<U>(fn: (err: E) => Promise<U>): ResultAsync<T, U>;
|
||||
andThen<U, F>(fn: (value: T) => Result<U, F>): Result<U, E | F>;
|
||||
andThenAsync<U, F>(fn: (value: T) => ResultAsync<U, F>): ResultAsync<U, F>;
|
||||
andThenAsync<U, F>(
|
||||
fn: (value: T) => ResultAsync<U, F>,
|
||||
): ResultAsync<U, E | F>;
|
||||
flatten(): FlattenResult<Result<T, E>>;
|
||||
flattenOption<U>(errFn: () => U): Result<UnwrapOption<T>, U | E>;
|
||||
flattenOptionOr<D = UnwrapOption<T>>(
|
||||
@ -119,12 +121,14 @@ export class Ok<T, E> implements IResult<T, E> {
|
||||
return fn(this.value) as Result<U, E | F>;
|
||||
}
|
||||
|
||||
andThenAsync<U, F>(fn: (value: T) => ResultAsync<U, F>): ResultAsync<U, F> {
|
||||
andThenAsync<U, F>(
|
||||
fn: (value: T) => ResultAsync<U, F>,
|
||||
): ResultAsync<U, E | F> {
|
||||
return fn(this.value);
|
||||
}
|
||||
|
||||
mapErr<U>(fn: (err: E) => U): Result<T, U> {
|
||||
return new Ok<T, U>(this.value);
|
||||
return ok<T, U>(this.value);
|
||||
}
|
||||
|
||||
mapErrAsync<U>(fn: (err: E) => Promise<U>): ResultAsync<T, U> {
|
||||
@ -236,8 +240,7 @@ export class Err<T, E> implements IResult<T, E> {
|
||||
return errAsync(this.error);
|
||||
}
|
||||
mapErr<U>(fn: (err: E) => U): Result<T, U> {
|
||||
const mappedError = fn(this.error);
|
||||
return new Err<T, U>(mappedError);
|
||||
return new Err<T, U>(fn(this.error));
|
||||
}
|
||||
mapErrAsync<U>(fn: (err: E) => Promise<U>): ResultAsync<T, U> {
|
||||
return ResultAsync.fromPromise(
|
||||
@ -255,6 +258,11 @@ export class Err<T, E> implements IResult<T, E> {
|
||||
andThen<U, F>(fn: (value: T) => Result<U, F>): Result<U, E | F> {
|
||||
return new Err<U, E | F>(this.error);
|
||||
}
|
||||
andThenAsync<U, F>(
|
||||
fn: (value: T) => ResultAsync<U, F>,
|
||||
): ResultAsync<U, E | F> {
|
||||
return new Err<U, E | F>(this.error).toAsync();
|
||||
}
|
||||
flatten(): FlattenResult<Result<T, E>> {
|
||||
return flattenResult(this);
|
||||
}
|
||||
@ -359,8 +367,12 @@ export function flattenResult<R extends Result<any, any>>(
|
||||
): FlattenResult<R> {
|
||||
let currentResult = nestedResult;
|
||||
|
||||
while (currentResult instanceof Ok) {
|
||||
currentResult = currentResult.value;
|
||||
while (
|
||||
currentResult instanceof Ok &&
|
||||
(currentResult.value instanceof Ok ||
|
||||
currentResult.value instanceof Err)
|
||||
) {
|
||||
currentResult = currentResult.value as R;
|
||||
}
|
||||
|
||||
return currentResult as FlattenResult<R>;
|
||||
|
||||
@ -150,7 +150,7 @@ export class ResultAsync<T, E> implements PromiseLike<Result<T, E>> {
|
||||
}
|
||||
|
||||
andThenAsync<U, F>(
|
||||
fn: (value: T) => ResultAsync<U, F>,
|
||||
fn: (value: T) => ResultAsync<U, E | F> | Promise<Result<U, E | F>>,
|
||||
): ResultAsync<U, E | F> {
|
||||
return new ResultAsync(
|
||||
this._promise.then(
|
||||
@ -254,13 +254,12 @@ export function errAsync<E, T = never>(err: E): ResultAsync<T, E> {
|
||||
return new ResultAsync(Promise.resolve(new Err<T, E>(err)));
|
||||
}
|
||||
|
||||
export type FlattenResultAsync<R> = R extends ResultAsync<infer T, infer E>
|
||||
? T extends ResultAsync<any, any>
|
||||
? FlattenResultAsync<T> extends ResultAsync<infer V, infer innerE>
|
||||
? ResultAsync<V, E | innerE>
|
||||
: never
|
||||
type FlattenResultAsync<R> = R extends
|
||||
ResultAsync<infer Inner, infer OuterError>
|
||||
? Inner extends ResultAsync<infer T, infer InnerError>
|
||||
? ResultAsync<T, OuterError | InnerError>
|
||||
: R
|
||||
: never;
|
||||
: R;
|
||||
|
||||
type UnwrapPromise<Pr extends Promise<unknown>> = Pr extends Promise<infer U>
|
||||
? U
|
||||
|
||||
@ -9,42 +9,42 @@ import {
|
||||
some,
|
||||
} from "@shared/utils/option.ts";
|
||||
|
||||
class CommandExecutionError extends Error {
|
||||
code = "CommandExecutionError";
|
||||
export class CommandExecutionError extends Error {
|
||||
type = "CommandExecutionError";
|
||||
constructor(msg: string) {
|
||||
super(msg);
|
||||
}
|
||||
}
|
||||
|
||||
class DeviceDoesNotExistError extends Error {
|
||||
code = "DeviceDoesNotExist";
|
||||
export class DeviceDoesNotExistError extends Error {
|
||||
type = "DeviceDoesNotExist";
|
||||
constructor(msg: string) {
|
||||
super(msg);
|
||||
}
|
||||
}
|
||||
|
||||
class DeviceAlreadyBoundError extends Error {
|
||||
code = "DeviceAlreadyBound";
|
||||
export class DeviceAlreadyBoundError extends Error {
|
||||
type = "DeviceAlreadyBound";
|
||||
constructor(msg: string) {
|
||||
super(msg);
|
||||
}
|
||||
}
|
||||
|
||||
class DeviceNotBound extends Error {
|
||||
code = "DeviceNotBound";
|
||||
export class DeviceNotBound extends Error {
|
||||
type = "DeviceNotBound";
|
||||
constructor(msg: string) {
|
||||
super(msg);
|
||||
}
|
||||
}
|
||||
|
||||
class UsbipUknownError extends Error {
|
||||
code = "UsbipUknownError";
|
||||
export class UsbipUnknownError extends Error {
|
||||
type = "UsbipUknownError";
|
||||
constructor(msg: string) {
|
||||
super(msg);
|
||||
}
|
||||
}
|
||||
|
||||
type UsbipCommonError = DeviceDoesNotExistError | UsbipUknownError;
|
||||
type UsbipCommonError = DeviceDoesNotExistError | UsbipUnknownError;
|
||||
|
||||
class UsbipManager {
|
||||
private readonly listDeatiledCmd = new Deno.Command("usbip", {
|
||||
@ -84,7 +84,7 @@ class UsbipManager {
|
||||
return new DeviceDoesNotExistError(stderr);
|
||||
}
|
||||
|
||||
return new UsbipUknownError(stderr);
|
||||
return new UsbipUnknownError(stderr);
|
||||
}
|
||||
|
||||
private parseDetailedList(stdout: string): Option<DeviceDetailed[]> {
|
||||
@ -140,7 +140,7 @@ class UsbipManager {
|
||||
|
||||
public getDevicesDetailed(): ResultAsync<
|
||||
Option<DeviceDetailed[]>,
|
||||
CommandExecutionError | UsbipUknownError
|
||||
CommandExecutionError | UsbipUnknownError
|
||||
> {
|
||||
return this.executeCommand(this.listDeatiledCmd).andThen(
|
||||
({ stdout, stderr, success }) => {
|
||||
@ -153,7 +153,7 @@ class UsbipManager {
|
||||
return ok(this.parseDetailedList(stdout));
|
||||
}
|
||||
|
||||
return err(new UsbipUknownError(stderr));
|
||||
return err(new UsbipUnknownError(stderr));
|
||||
},
|
||||
);
|
||||
}
|
||||
@ -193,7 +193,7 @@ class UsbipManager {
|
||||
|
||||
public getDevices(): ResultAsync<
|
||||
Option<Device[]>,
|
||||
CommandExecutionError | UsbipUknownError
|
||||
CommandExecutionError | UsbipUnknownError
|
||||
> {
|
||||
return this.executeCommand(this.listParsableCmd).andThenAsync(
|
||||
({ stdout, stderr, success }) => {
|
||||
@ -205,7 +205,7 @@ class UsbipManager {
|
||||
}
|
||||
return okAsync(this.parseParsableList(stdout));
|
||||
}
|
||||
return errAsync(new UsbipUknownError(stderr));
|
||||
return errAsync(new UsbipUnknownError(stderr));
|
||||
},
|
||||
);
|
||||
}
|
||||
@ -268,7 +268,7 @@ class CommandOutput {
|
||||
}
|
||||
}
|
||||
|
||||
interface DeviceDetailed {
|
||||
export interface DeviceDetailed {
|
||||
busid: string;
|
||||
usbid: Option<string>;
|
||||
vendor: Option<string>;
|
||||
|
||||
@ -1,7 +1,8 @@
|
||||
import { err, ok, Result } from "@shared/utils/result.ts";
|
||||
|
||||
import { err, Result } from "@shared/utils/result.ts";
|
||||
import { ok } from "@shared/utils/index.ts";
|
||||
import { None, none, Option, some } from "@shared/utils/option.ts";
|
||||
// ── Error Types ─────────────────────────────────────────────────────
|
||||
type ValidationErrorDetail =
|
||||
export type ValidationErrorDetail =
|
||||
| {
|
||||
kind: "typeMismatch";
|
||||
expected: string;
|
||||
@ -32,14 +33,18 @@ type ValidationErrorDetail =
|
||||
}
|
||||
| { kind: "general"; mark?: string; msg: string };
|
||||
|
||||
class SchemaValidationError extends Error {
|
||||
public readonly type = "SchemaValidationError";
|
||||
export class SchemaValidationError extends Error {
|
||||
public readonly type = "SchemaValiationError";
|
||||
|
||||
constructor(
|
||||
public readonly input: unknown,
|
||||
public readonly detail: ValidationErrorDetail,
|
||||
) {
|
||||
super(detail.msg || "Schema validation error");
|
||||
super(
|
||||
SchemaValidationError.getBestMsg(detail) ||
|
||||
"Schema validation error",
|
||||
);
|
||||
this.name = "SchemaValidationError";
|
||||
}
|
||||
|
||||
public format(): Record<string, unknown> {
|
||||
@ -49,7 +54,7 @@ class SchemaValidationError extends Error {
|
||||
};
|
||||
}
|
||||
|
||||
get msg(): string {
|
||||
get info(): string {
|
||||
return SchemaValidationError.getBestMsg(this.detail);
|
||||
}
|
||||
|
||||
@ -60,7 +65,7 @@ class SchemaValidationError extends Error {
|
||||
case "missingProperties":
|
||||
case "general":
|
||||
case "unionValidation":
|
||||
return SchemaValidationError.formMsg(detail);
|
||||
return SchemaValidationError.formatMsg(detail);
|
||||
case "propertyValidation":
|
||||
case "arrayElement":
|
||||
return detail.msg ||
|
||||
@ -74,7 +79,7 @@ class SchemaValidationError extends Error {
|
||||
switch (detail.kind) {
|
||||
case "general":
|
||||
case "typeMismatch":
|
||||
return SchemaValidationError.formMsg(detail);
|
||||
return SchemaValidationError.formatMsg(detail);
|
||||
case "propertyValidation":
|
||||
return {
|
||||
[detail.property]: detail.msg ||
|
||||
@ -82,25 +87,28 @@ class SchemaValidationError extends Error {
|
||||
};
|
||||
case "unexpectedProperties":
|
||||
case "missingProperties": {
|
||||
const resObj: Record<string, string> = {};
|
||||
const msg = detail.msg ||
|
||||
(detail.kind === "unexpectedProperties"
|
||||
? "Property is not allowed in a strict schema object"
|
||||
: "Property is required, but missing");
|
||||
|
||||
for (const key of detail.keys) {
|
||||
resObj[key] = msg;
|
||||
}
|
||||
|
||||
return resObj;
|
||||
return detail.keys.reduce<Record<string, string>>(
|
||||
(acc, key) => {
|
||||
acc[key] = msg;
|
||||
return acc;
|
||||
},
|
||||
{},
|
||||
);
|
||||
}
|
||||
case "arrayElement": {
|
||||
const obj: Record<string, string> = {};
|
||||
const detailObj: Record<string, string> = {};
|
||||
if (detail.msg) {
|
||||
obj["msg"] = detail.msg;
|
||||
detailObj["msg"] = detail.msg;
|
||||
}
|
||||
obj[`index_${detail.index}`] = this.formatDetail(detail.detail);
|
||||
return obj;
|
||||
detailObj[`index_${detail.index}`] = this.formatDetail(
|
||||
detail.detail,
|
||||
);
|
||||
return detailObj;
|
||||
}
|
||||
case "unionValidation": {
|
||||
const arr: unknown[] = detail.details?.map(
|
||||
@ -116,7 +124,7 @@ class SchemaValidationError extends Error {
|
||||
}
|
||||
}
|
||||
|
||||
private static formMsg(detail: ValidationErrorDetail): string {
|
||||
private static formatMsg(detail: ValidationErrorDetail): string {
|
||||
if (detail.msg || detail.kind === "general") {
|
||||
return detail.msg || "Unknown error";
|
||||
}
|
||||
@ -124,22 +132,18 @@ class SchemaValidationError extends Error {
|
||||
case "typeMismatch":
|
||||
return `Expected ${detail.expected}, but received ${detail.received}`;
|
||||
case "unexpectedProperties":
|
||||
return `Properties are not allowed in a strict object schema: ${
|
||||
detail.keys.join(", ")
|
||||
}`;
|
||||
return `Properties not allowed: ${detail.keys.join(", ")}`;
|
||||
case "missingProperties":
|
||||
return `Missing required properties: ${detail.keys.join(", ")}`;
|
||||
case "unionValidation":
|
||||
return `Input did not match any of the union member`;
|
||||
case "propertyValidation":
|
||||
case "arrayElement":
|
||||
return `Input did not match any union member`;
|
||||
default:
|
||||
return "Unknown error";
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function createValidationError(
|
||||
export function createValidationError(
|
||||
input: unknown,
|
||||
error: ValidationErrorDetail,
|
||||
) {
|
||||
@ -212,14 +216,11 @@ export abstract class BaseSchema<T> implements Schema<T> {
|
||||
}
|
||||
|
||||
protected static isNullishSchema(schema: Schema<any>): boolean {
|
||||
if (schema.parse(null).isOk() || schema.parse(undefined).isOk()) {
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
return schema.parse(null).isOk() || schema.parse(undefined).isOk();
|
||||
}
|
||||
}
|
||||
|
||||
class StringSchema extends BaseSchema<string> {
|
||||
export class StringSchema extends BaseSchema<string> {
|
||||
private static readonly emailRegex =
|
||||
/^(([^<>()[\]\.,;:\s@\"]+(\.[^<>()[\]\.,;:\s@\"]+)*)|(\".+\"))@(([^<>()[\]\.,;:\s@\"]+\.)+[^<>()[\]\.,;:\s@\"]{2,})$/i; // https://stackoverflow.com/questions/46155/how-can-i-validate-an-email-address-in-javascript
|
||||
|
||||
@ -284,7 +285,7 @@ class StringSchema extends BaseSchema<string> {
|
||||
}
|
||||
}
|
||||
|
||||
class LiteralSchema<L extends string> extends BaseSchema<L> {
|
||||
export class LiteralSchema<L extends string> extends BaseSchema<L> {
|
||||
constructor(
|
||||
public readonly literal: L,
|
||||
msg?: string,
|
||||
@ -644,9 +645,7 @@ class NeverSchema extends BaseSchema<never> {
|
||||
}
|
||||
}
|
||||
|
||||
type InferSchemaType<S> = S extends Schema<infer T> ? T : never;
|
||||
|
||||
class ObjectSchema<S extends Record<string, Schema<any>>>
|
||||
export class ObjectSchema<S extends Record<string, Schema<any>>>
|
||||
extends BaseSchema<{ [K in keyof S]: InferSchemaType<S[K]> }> {
|
||||
private strictMode: boolean = false;
|
||||
private objectMsg?;
|
||||
@ -675,6 +674,7 @@ class ObjectSchema<S extends Record<string, Schema<any>>>
|
||||
this.objectMsg = objectMsg;
|
||||
}
|
||||
|
||||
// TODO: Simplify it a bit
|
||||
protected override validateInput(
|
||||
input: unknown,
|
||||
): Result<
|
||||
@ -698,7 +698,7 @@ class ObjectSchema<S extends Record<string, Schema<any>>>
|
||||
);
|
||||
}
|
||||
|
||||
let resultObj: Record<string, unknown> = {};
|
||||
const resultObj: Record<string, unknown> = {};
|
||||
const expectedKeys = new Set(Object.keys(this.shape));
|
||||
|
||||
for (const key of Object.keys(obj)) {
|
||||
@ -768,11 +768,40 @@ class ObjectSchema<S extends Record<string, Schema<any>>>
|
||||
});
|
||||
}
|
||||
|
||||
strict(): this {
|
||||
public strict(): this {
|
||||
this.strictMode = true;
|
||||
return this;
|
||||
}
|
||||
|
||||
public pick<
|
||||
P extends Partial<
|
||||
Record<keyof InferSchemaType<this>, boolean>
|
||||
>,
|
||||
>(
|
||||
keys: P,
|
||||
): ObjectPick<this, P> {
|
||||
const o: Record<string, Schema<any>> = {};
|
||||
|
||||
for (const key of Object.keys(keys)) {
|
||||
if (keys[key as keyof P]) {
|
||||
o[key] = this.shape[key];
|
||||
}
|
||||
}
|
||||
|
||||
return z.obj(o) as unknown as ObjectPick<this, P>;
|
||||
}
|
||||
}
|
||||
|
||||
type PickedKeys<T> = {
|
||||
[K in keyof T]: T[K] extends true ? K : never;
|
||||
}[keyof T];
|
||||
|
||||
type ObjectPick<
|
||||
O extends ObjectSchema<any>,
|
||||
P extends Partial<Record<keyof InferSchemaType<O>, boolean>>,
|
||||
> = O extends ObjectSchema<infer T>
|
||||
? ObjectSchema<{ [K in PickedKeys<P> & keyof T]: T[K] }>
|
||||
: never;
|
||||
|
||||
type InferUnionSchemaType<U extends Schema<any>[]> = U[number] extends
|
||||
Schema<infer T> ? T : never;
|
||||
@ -825,19 +854,19 @@ class UnionSchema<U extends Schema<any>[]>
|
||||
input: unknown,
|
||||
): Result<InferUnionSchemaType<U>, SchemaValidationError> {
|
||||
const errors: ValidationErrorDetail[] = [];
|
||||
let typeMismatch = true;
|
||||
let allTypeMismatch = true;
|
||||
|
||||
for (const schema of this.schemas) {
|
||||
const result = schema.parse(input);
|
||||
if (result.isOk()) {
|
||||
return ok(result.value as InferUnionSchemaType<U>);
|
||||
}
|
||||
typeMismatch = result.error.detail?.kind === "typeMismatch" &&
|
||||
typeMismatch;
|
||||
allTypeMismatch = result.error.detail?.kind === "typeMismatch" &&
|
||||
allTypeMismatch;
|
||||
errors.push(result.error.detail);
|
||||
}
|
||||
|
||||
if (typeMismatch) {
|
||||
if (allTypeMismatch) {
|
||||
return err(createValidationError(input, {
|
||||
kind: "typeMismatch",
|
||||
expected: this.schemas.map((s) =>
|
||||
@ -853,7 +882,8 @@ class UnionSchema<U extends Schema<any>[]>
|
||||
return err(
|
||||
createValidationError(input, {
|
||||
kind: "unionValidation",
|
||||
msg: this.msg || this.unionMsg?.unionValidation ||
|
||||
msg: this.msg ||
|
||||
this.unionMsg?.unionValidation ||
|
||||
"Input did not match any union member",
|
||||
details: errors,
|
||||
}),
|
||||
@ -882,6 +912,7 @@ class ArraySchema<S extends Schema<any>>
|
||||
}
|
||||
|
||||
super(mismatchMsg);
|
||||
// TODO: abstract complex schemas in a separate type with thos messages
|
||||
this.arrayMsg = arrayMsg;
|
||||
}
|
||||
|
||||
@ -973,7 +1004,183 @@ class NullishSchema<S extends Schema<any>>
|
||||
}
|
||||
}
|
||||
|
||||
const z = {
|
||||
export class ResultSchema<T extends Schema<any>, E extends Schema<any>>
|
||||
extends BaseSchema<Result<InferSchemaType<T>, InferSchemaType<E>>> {
|
||||
constructor(
|
||||
private readonly okSchema: T,
|
||||
private readonly errSchema: E,
|
||||
) {
|
||||
super();
|
||||
}
|
||||
|
||||
protected override validateInput(
|
||||
input: unknown,
|
||||
): Result<
|
||||
Result<InferSchemaType<T>, InferSchemaType<E>>,
|
||||
SchemaValidationError
|
||||
> {
|
||||
return BaseSchema.validatePrimitive<object>(input, "object").andThen(
|
||||
(
|
||||
obj,
|
||||
): Result<
|
||||
Result<InferSchemaType<T>, InferSchemaType<E>>,
|
||||
SchemaValidationError
|
||||
> => {
|
||||
if ("tag" in obj) {
|
||||
switch (obj.tag) {
|
||||
case "ok": {
|
||||
if ("value" in obj) {
|
||||
return this.okSchema.parse(
|
||||
obj.value,
|
||||
).match(
|
||||
(v) => ok(ok(v as InferSchemaType<T>)),
|
||||
(e) =>
|
||||
err(createValidationError(input, {
|
||||
kind: "propertyValidation",
|
||||
property: "value",
|
||||
detail: e.detail,
|
||||
})),
|
||||
);
|
||||
} else if (
|
||||
BaseSchema.isNullishSchema(this.okSchema)
|
||||
) {
|
||||
return ok(
|
||||
ok() as Result<
|
||||
InferSchemaType<T>,
|
||||
InferSchemaType<E>
|
||||
>,
|
||||
);
|
||||
}
|
||||
return err(createValidationError(input, {
|
||||
kind: "missingProperties",
|
||||
keys: ["value"],
|
||||
msg: "If tag is set to 'ok', than result must contain a 'value' property",
|
||||
}));
|
||||
}
|
||||
case "err": {
|
||||
if (
|
||||
"error" in obj
|
||||
) {
|
||||
return this.errSchema.parse(
|
||||
obj.error,
|
||||
).match(
|
||||
(e) => ok(err(e as InferSchemaType<E>)),
|
||||
(e) =>
|
||||
err(createValidationError(input, {
|
||||
kind: "propertyValidation",
|
||||
property: "error",
|
||||
detail: e.detail,
|
||||
})),
|
||||
);
|
||||
} else if (
|
||||
BaseSchema.isNullishSchema(this.errSchema)
|
||||
) {
|
||||
return ok(
|
||||
err() as Result<
|
||||
InferSchemaType<T>,
|
||||
InferSchemaType<E>
|
||||
>,
|
||||
);
|
||||
}
|
||||
return err(createValidationError(input, {
|
||||
kind: "missingProperties",
|
||||
keys: ["error"],
|
||||
msg: "If tag is set to 'err', than result must contain a 'error' property",
|
||||
}));
|
||||
}
|
||||
default:
|
||||
return err(createValidationError(input, {
|
||||
kind: "propertyValidation",
|
||||
property: "tag",
|
||||
detail: {
|
||||
kind: "typeMismatch",
|
||||
expected: "'ok' or 'err'",
|
||||
received: `'${obj.tag}'`,
|
||||
},
|
||||
}));
|
||||
}
|
||||
} else {
|
||||
return err(createValidationError(input, {
|
||||
kind: "missingProperties",
|
||||
keys: ["tag"],
|
||||
msg: "Result must contain a tag property",
|
||||
}));
|
||||
}
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export class OptionSchema<T extends Schema<any>>
|
||||
extends BaseSchema<Option<InferSchemaType<T>>> {
|
||||
constructor(
|
||||
private readonly schema: T,
|
||||
) {
|
||||
super();
|
||||
}
|
||||
|
||||
protected override validateInput(
|
||||
input: unknown,
|
||||
): Result<Option<InferSchemaType<T>>, SchemaValidationError> {
|
||||
return BaseSchema.validatePrimitive<object>(input, "object").andThen(
|
||||
(
|
||||
obj,
|
||||
): Result<Option<InferSchemaType<T>>, SchemaValidationError> => {
|
||||
if ("tag" in obj) {
|
||||
switch (obj.tag) {
|
||||
case "some": {
|
||||
if ("value" in obj) {
|
||||
return this.schema.parse(
|
||||
obj.value,
|
||||
).match(
|
||||
(v) => ok(some(v as InferSchemaType<T>)),
|
||||
(e) =>
|
||||
err(createValidationError(input, {
|
||||
kind: "propertyValidation",
|
||||
property: "value",
|
||||
detail: e.detail,
|
||||
})),
|
||||
);
|
||||
} else if (
|
||||
BaseSchema.isNullishSchema(this.schema)
|
||||
) {
|
||||
return ok(some() as Option<InferSchemaType<T>>);
|
||||
}
|
||||
return err(createValidationError(input, {
|
||||
kind: "missingProperties",
|
||||
keys: ["value"],
|
||||
msg: "If tag is set to 'some', than option must contain a 'value' property",
|
||||
}));
|
||||
}
|
||||
case "none": {
|
||||
return ok(none);
|
||||
}
|
||||
default:
|
||||
return err(createValidationError(input, {
|
||||
kind: "propertyValidation",
|
||||
property: "tag",
|
||||
detail: {
|
||||
kind: "typeMismatch",
|
||||
expected: "'some' or 'none'",
|
||||
received: `'${obj.tag}'`,
|
||||
},
|
||||
}));
|
||||
}
|
||||
} else {
|
||||
return err(createValidationError(input, {
|
||||
kind: "missingProperties",
|
||||
keys: ["tag"],
|
||||
msg: "Option must contain a tag property",
|
||||
}));
|
||||
}
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/* ── Helper Object for Schema Creation (z) ───────────────────────────────────── */
|
||||
|
||||
export const z = {
|
||||
string: (msg?: string) => new StringSchema(msg),
|
||||
literal: <L extends string>(lit: L, msg?: string) =>
|
||||
new LiteralSchema<L>(lit, msg),
|
||||
@ -981,30 +1188,53 @@ const z = {
|
||||
bigint: (msg?: string) => new BigintSchema(msg),
|
||||
boolean: (msg?: string) => new BooleanSchema(msg),
|
||||
date: (msg?: string) => new DateSchema(msg),
|
||||
symbol: (msg?: string) => new StringSchema(msg),
|
||||
symbol: (msg?: string) => new SymbolSchema(msg),
|
||||
undefined: (msg?: string) => new UndefinedSchema(msg),
|
||||
null: (msg?: string) => new NullSchema(msg),
|
||||
void: (msg?: string) => new VoidSchema(msg),
|
||||
any: (msg?: string) => new AnySchema(msg),
|
||||
unknown: (msg?: string) => new UnknownSchema(msg),
|
||||
never: (msg?: string) => new NeverSchema(msg),
|
||||
obj: <S extends Record<string, Schema<any>>>(schema: S, msg?: string) =>
|
||||
new ObjectSchema<S>(schema, msg),
|
||||
union: <U extends Schema<any>[]>(schemas: U, msg?: string) =>
|
||||
new UnionSchema<U>(schemas, msg),
|
||||
obj: <S extends Record<string, Schema<any>>>(
|
||||
schema: S,
|
||||
msg?: string | {
|
||||
mismatch?: string;
|
||||
nullObject?: string;
|
||||
unexpectedProperty?: string;
|
||||
propertyValidation?: string;
|
||||
missingProperty?: string;
|
||||
},
|
||||
) => new ObjectSchema<S>(schema, msg),
|
||||
union: <U extends Schema<any>[]>(
|
||||
schemas: U,
|
||||
msg?: string | {
|
||||
mismatch?: string;
|
||||
unionValidation?: string;
|
||||
},
|
||||
) => new UnionSchema<U>(schemas, msg),
|
||||
array: <S extends Schema<any>>(
|
||||
schema: S,
|
||||
msg?: string | { mismatch?: string; element?: string },
|
||||
) => new ArraySchema<S>(schema, msg),
|
||||
optional: <S extends Schema<any>>(
|
||||
schema: S,
|
||||
msg?: string,
|
||||
) => new OptionalSchema<S>(schema, msg),
|
||||
nullable: <S extends Schema<any>>(
|
||||
schema: S,
|
||||
msg?: string,
|
||||
) => new NullableSchema<S>(schema, msg),
|
||||
nullish: <S extends Schema<any>>(
|
||||
schema: S,
|
||||
msg?: string,
|
||||
) => new NullishSchema<S>(schema, msg),
|
||||
result: <T extends Schema<any>, E extends Schema<any>>(
|
||||
okSchema: T,
|
||||
errSchema: E,
|
||||
) => new ResultSchema<T, E>(okSchema, errSchema),
|
||||
option: <T extends Schema<any>>(
|
||||
schema: T,
|
||||
) => new OptionSchema<T>(schema),
|
||||
};
|
||||
|
||||
const schema = z.obj({
|
||||
login: z.string().regex(
|
||||
/^[A-Za-z0-9]+$/,
|
||||
"Only lower/upper case latin characters and numbers are allowed",
|
||||
),
|
||||
password: z.string().max(255),
|
||||
}).strict();
|
||||
|
||||
const result = schema.parse({
|
||||
login: "testLogin ",
|
||||
password: "veryStrongPassword",
|
||||
});
|
||||
|
||||
console.log(result.unwrapErr().unwrap().format());
|
||||
export type InferSchemaType<S> = S extends Schema<infer T> ? T : never;
|
||||
3
test1.ts
3
test1.ts
@ -132,7 +132,6 @@ export abstract class PrimitiveSchema<T> extends BaseSchema<T> {
|
||||
}
|
||||
}
|
||||
|
||||
// Example: StringSchema with Improved Error Handling
|
||||
export class StringSchema extends PrimitiveSchema<string> {
|
||||
private static readonly emailRegex =
|
||||
/^(([^<>()[\]\.,;:\s@\"]+(\.[^<>()[\]\.,;:\s@\"]+)*)|(\".+\"))@(([^<>()[\]\.,;:\s@\"]+\.)+[^<>()[\]\.,;:\s@\"]{2,})$/i;
|
||||
@ -345,7 +344,7 @@ if (res.isErr()) {
|
||||
}
|
||||
|
||||
// Utility Types
|
||||
type InferSchema<S> = S extends Schema<infer T> ? T : never;
|
||||
export type InferSchema<S> = S extends Schema<infer T> ? T : never;
|
||||
type InferSchemaUnion<S extends Schema<any>[]> = S[number] extends
|
||||
Schema<infer U> ? U : never;
|
||||
type NestedArray<T> = T | NestedArray<T>[];
|
||||
|
||||
2
vendor/deno.land/x/sleep@v1.3.0/mod.ts
vendored
Normal file
2
vendor/deno.land/x/sleep@v1.3.0/mod.ts
vendored
Normal file
@ -0,0 +1,2 @@
|
||||
export * from "./sleep.ts"
|
||||
|
||||
11
vendor/deno.land/x/sleep@v1.3.0/sleep.ts
vendored
Normal file
11
vendor/deno.land/x/sleep@v1.3.0/sleep.ts
vendored
Normal 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
|
||||
}
|
||||
6
vendor/manifest.json
vendored
6
vendor/manifest.json
vendored
@ -1,5 +1,11 @@
|
||||
{
|
||||
"modules": {
|
||||
"https://deno.land/x/sleep/mod.ts": {
|
||||
"headers": {
|
||||
"location": "/x/sleep@v1.3.0/mod.ts",
|
||||
"x-deno-warning": "Implicitly using latest version (v1.3.0) for https://deno.land/x/sleep/mod.ts"
|
||||
}
|
||||
},
|
||||
"https://jsr.io/@std/crypto/1.0.3/_wasm/lib/deno_std_wasm_crypto.generated.d.mts": {},
|
||||
"https://jsr.io/@std/crypto/1.0.3/_wasm/lib/deno_std_wasm_crypto.generated.mjs": {},
|
||||
"https://jsr.io/@std/net/1.0.4/unstable_get_network_address.ts": {}
|
||||
|
||||
Reference in New Issue
Block a user