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