reworking router somewhat

This commit is contained in:
2025-02-14 02:37:30 +03:00
parent 74cd00e62b
commit 44649ef89a
14 changed files with 420 additions and 151 deletions

View File

@ -3,12 +3,15 @@ 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 = {
@ -66,3 +69,42 @@ export const passwordSetupApi = new Api(
"POST",
passwordSetupApiSchema,
);
const updateDevicesApiSchema = {
req: z.void(),
res: z.result(
z.void(),
z.union([
queryExecutionErrorSchema,
tooManyRequestsErrorSchema,
unauthorizedErrorSchema,
commandExecutionErrorSchema,
usbipUnknownErrorSchema,
]),
),
};
export const updateDevicesApi = new Api(
"/api/updateDevices",
"POST",
updateDevicesApiSchema,
);
const versionApiSchema = {
req: z.void(),
res: z.result(
z.obj({
app: z.literal("Keyborg"),
version: z.string(),
}),
z.union([
tooManyRequestsErrorSchema,
]),
),
};
export const versionApi = new Api(
"/version",
"POST",
versionApiSchema,
);

View File

@ -4,8 +4,12 @@ import { serveFile } from "jsr:@std/http/file-server";
import rateLimitMiddleware from "@src/middleware/rateLimiter.ts";
import authMiddleware from "@src/middleware/auth.ts";
import loggerMiddleware from "@src/middleware/logger.ts";
import { SchemaValidationError, z } from "@shared/utils/validator.ts";
import { loginApi, passwordSetupApi } from "./api.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";
@ -20,6 +24,7 @@ import {
import devices from "@src/lib/devices.ts";
const AUTH_COOKIE_NAME = "token";
const VERSION = "0.1.0";
const router = new HttpRouter();
@ -50,9 +55,9 @@ router.get("/public/*", async (c) => {
router
.get(["", "/index.html"], (c) => {
console.log(devices.list());
const devicesList = devices.list().unwrap().unwrap();
return c.html(eta.render("./index.html", {}));
return c.html(eta.render("./index.html", { devices: devicesList }));
})
.get(["/login", "/login.html"], (c) => {
const isSet = admin.isPasswordSet();
@ -88,7 +93,33 @@ router
);
});
router.api(loginApi, async (c) => {
router.get("ws", (c) => {
if (c.req.headers.get("upgrade") != "websocket") {
return new Response(null, { status: 501 });
}
const { socket, response } = Deno.upgradeWebSocket(c.req);
socket.addEventListener("open", () => {
console.log("a client connected!");
});
socket.addEventListener("close", () => {
console.log("client disconnected");
});
socket.addEventListener("message", (event) => {
if (event.data === "ping") {
console.log("pinged!");
socket.send("pong");
}
});
return response;
});
router
.api(loginApi, async (c) => {
const r = await c
.parseBody()
.andThenAsync(
@ -123,11 +154,12 @@ router.api(loginApi, async (c) => {
(e) => handleCommonErrors(c, e),
);
} else {
return c.json(err(invalidPasswordError("Invalid login or password")));
return c.json(
err(invalidPasswordError("Invalid login or password")),
);
}
});
router.api(passwordSetupApi, async (c) => {
})
.api(passwordSetupApi, async (c) => {
const r = await c.parseBody();
if (r.isErr()) {
@ -137,14 +169,28 @@ router.api(passwordSetupApi, async (c) => {
const v = r.value;
if (v.password !== v.passwordRepeat) {
return c.json400(err(passwordsMustMatchError("Passwords must match")));
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>,

View File

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

File diff suppressed because one or more lines are too long

View File

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

View File

@ -65,6 +65,8 @@ export class Api<
}
const path = pathSplitted.join("/");
console.log(data);
const response = await fetch(
path,
{

View File

@ -1,15 +1,15 @@
import usbip, {
CommandExecutionError,
DeviceDetailed,
DeviceDoesNotExistError,
deviceDoesNotExistError,
UsbipUnknownError,
} from "@src/lib/usbip.ts";
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;

View File

@ -116,3 +116,51 @@ export const passwordsMustMatchError = createErrorFactory(
export type PasswordsMustMatchError = InferSchemaType<
typeof passwordsMustMatchErrorSchema
>;
export const commandExecutionErrorSchema = defineError("CommandExecutionError");
export const commandExecutionError = createErrorFactory(
commandExecutionErrorSchema,
);
export type CommandExecutionError = InferSchemaType<
typeof commandExecutionErrorSchema
>;
export const deviceDoesNotExistErrorSchema = defineError(
"DeviceDoesNotExistError",
);
export const deviceDoesNotExistError = createErrorFactory(
deviceDoesNotExistErrorSchema,
);
export type DeviceDoesNotExistError = InferSchemaType<
typeof deviceDoesNotExistErrorSchema
>;
export const deviceAlreadyBoundErrorSchema = defineError(
"DeviceAlreadyBoundError",
);
export const deviceAlreadyBoundError = createErrorFactory(
deviceAlreadyBoundErrorSchema,
);
export type DeviceAlreadyBoundError = InferSchemaType<
typeof deviceAlreadyBoundErrorSchema
>;
export const deviceNotBoundErrorSchema = defineError("DeviceNotBoundError");
export const deviceNotBoundError = createErrorFactory(
deviceNotBoundErrorSchema,
);
export type DeviceNotBoundError = InferSchemaType<
typeof deviceNotBoundErrorSchema
>;
export const usbipUnknownErrorSchema = defineError("UsbipUnknownError");
export const usbipUnknownError = createErrorFactory(usbipUnknownErrorSchema);
export type UsbipUnknownError = InferSchemaType<typeof usbipUnknownErrorSchema>;
export const notFoundErrorSchema = defineError("NotFoundError");
export const notFoundError = createErrorFactory(notFoundErrorSchema);
export type NotFoundError = InferSchemaType<typeof notFoundErrorSchema>;
export const notAllowedErrorSchema = defineError("NotAllowedError");
export const notAllowedError = createErrorFactory(notAllowedErrorSchema);
export type NotAllowedError = InferSchemaType<typeof notAllowedErrorSchema>;

View File

@ -1,8 +1,10 @@
import { RouterTree } from "@lib/routerTree.ts";
import { none, Option, some } from "@shared/utils/option.ts";
import { Context } from "@lib/context.ts";
import { RouterTree } from "@src/lib/routerTree.ts";
import { none, some } from "@shared/utils/option.ts";
import { Context } from "@src/lib/context.ts";
import { Schema } from "@shared/utils/validator.ts";
import { Api } from "@src/lib/apiValidator.ts";
import { notAllowedError, notFoundError } from "@src/lib/errors.ts";
import { err } from "@shared/utils/result.ts";
type RequestHandler<
S extends string,
@ -10,44 +12,39 @@ type RequestHandler<
ResSchema extends Schema<any> = Schema<unknown>,
> = (c: Context<S, ReqSchema, ResSchema>) => Promise<Response> | Response;
type RequestHandlerWithSchema<S extends string> = {
handler: RequestHandler<S>;
schema?: {
res: Schema<any>;
req: Schema<any>;
};
};
export type Middleware = (
c: Context<string>,
next: () => Promise<void>,
) => Promise<Response | void> | Response | void;
type MethodHandlers<S extends string> = Partial<
Record<string, {
type MethodHandler<S extends string> = {
handler: RequestHandler<S>;
schema?: {
res: Schema<any>;
req: Schema<any>;
};
}>
schema?: { req: Schema<any>; res: Schema<any> };
};
type MethodHandlers<S extends string> = Partial<
Record<string, MethodHandler<S>>
>;
const DEFAULT_NOT_FOUND_HANDLER = () => new Response("404 Not found");
const DEFAULT_NOT_FOUND_HANDLER =
(() => new Response("404 Not found", { status: 404 })) as RequestHandler<
any
>;
class HttpRouter {
public readonly routerTree = new RouterTree<MethodHandlers<any>>();
public pathPreprocessor?: (path: string) => string;
public pathTransformer?: (path: string) => string;
private middlewares: Middleware[] = [];
public defaultNotFoundHandler: RequestHandler<string> =
DEFAULT_NOT_FOUND_HANDLER;
public setPathProcessor(processor: (path: string) => string) {
this.pathPreprocessor = processor;
public setPathTransformer(transformer: (path: string) => string) {
this.pathTransformer = transformer;
return this;
}
public use(mw: Middleware): this {
this.middlewares.push(mw);
public use(middleware: Middleware): this {
this.middlewares.push(middleware);
return this;
}
@ -61,7 +58,6 @@ class HttpRouter {
handler: RequestHandler<S, ReqSchema, ResSchema>,
schema?: { req: ReqSchema; res: ResSchema },
): HttpRouter;
public add<
S extends string,
ReqSchema extends Schema<any> = Schema<unknown>,
@ -72,7 +68,6 @@ class HttpRouter {
handler: RequestHandler<string, ReqSchema, ResSchema>,
schema?: { req: ReqSchema; res: ResSchema },
): HttpRouter;
public add(
path: string | string[],
method: string,
@ -83,13 +78,13 @@ class HttpRouter {
for (const p of paths) {
this.routerTree.getHandler(p).match(
(mth) => {
mth[method] = { handler, schema };
(existingHandlers) => {
existingHandlers[method] = { handler, schema };
},
() => {
const mth: MethodHandlers<string> = {};
mth[method] = { handler, schema };
this.routerTree.add(p, mth);
const newHandlers: MethodHandlers<string> = {};
newHandlers[method] = { handler, schema };
this.routerTree.add(p, newHandlers);
},
);
}
@ -149,19 +144,46 @@ class HttpRouter {
connInfo: Deno.ServeHandlerInfo<Deno.Addr>,
): Promise<Response> {
let ctx = new Context(req, connInfo, {});
const path = this.pathTransformer
? this.pathTransformer(ctx.path)
: ctx.path;
let routeParams: Record<string, string> = {};
const path = this.pathPreprocessor
? this.pathPreprocessor(ctx.path)
: ctx.path;
const handler = this.routerTree
.find(path)
.andThen((match) => {
const { value: methodHandler, params: params } = match;
routeParams = params;
const route = methodHandler[req.method];
if (!route) return none;
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);
}
@ -169,7 +191,22 @@ class HttpRouter {
return some(handler);
})
.unwrapOrElse(() => this.defaultNotFoundHandler);
.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,
@ -177,9 +214,27 @@ class HttpRouter {
ctx = ctx.setParams(routeParams),
)).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>(
middlewares: Middleware[],
handler: RequestHandler<S>,

View File

@ -8,48 +8,18 @@ import {
type Option,
some,
} from "@shared/utils/option.ts";
import { createErrorFactory, defineError } from "@shared/utils/errors.ts";
import { InferSchemaType } from "@shared/utils/validator.ts";
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>;
import {
CommandExecutionError,
commandExecutionError,
DeviceAlreadyBoundError,
deviceAlreadyBoundError,
DeviceDoesNotExistError,
deviceDoesNotExistError,
DeviceNotBoundError,
deviceNotBoundError,
UsbipUnknownError,
usbipUnknownError,
} from "@src/lib/errors.ts";
type UsbipCommonError = DeviceDoesNotExistError | UsbipUnknownError;

View File

@ -8,8 +8,7 @@ import {
import { err, ok } from "@shared/utils/result.ts";
import { eta } from "../../main.ts";
const LOGIN_PATH = "/login";
const SETUP_PATH = "/setup";
const EXCLUDE = new Set(["/login", "/setup", "/version"]);
const authMiddleware: Middleware = async (c, next) => {
const token = c.cookies.get("token");
@ -33,8 +32,7 @@ const authMiddleware: Middleware = async (c, next) => {
const path = c.path;
if (
!isValid.value && !path.startsWith("/public") && path !== LOGIN_PATH &&
path !== SETUP_PATH
!isValid.value && !path.startsWith("/public") && !EXCLUDE.has(path)
) {
if (!isValid.value) {
c.cookies.delete("token");

View File

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

Binary file not shown.

View File

@ -1,6 +1 @@
<% layout("./layouts/layout.html") %>
devices:
<div id="Devices"></div>
<script defer src=/public/js/index.js></script>
<% 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>