Compare commits

..

3 Commits

Author SHA1 Message Date
e1cc82f7bd working on websocket 2025-04-26 17:08:53 +03:00
ea308b2f1a adding variables to router 2025-03-19 20:15:48 +03:00
b8d705a805 workind on websocket 2025-02-28 19:39:03 +03:00
26 changed files with 723 additions and 301 deletions

3
README.md Normal file
View File

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

1
deno.lock generated
View File

@ -41,6 +41,7 @@
"npm:@minify-html/wasm@*": "0.15.0", "npm:@minify-html/wasm@*": "0.15.0",
"npm:@tauri-apps/api@2": "2.2.0", "npm:@tauri-apps/api@2": "2.2.0",
"npm:@tauri-apps/cli@2": "2.2.5", "npm:@tauri-apps/cli@2": "2.2.5",
"npm:@tauri-apps/cli@2.2.5": "2.2.5",
"npm:@tauri-apps/plugin-shell@2": "2.2.0", "npm:@tauri-apps/plugin-shell@2": "2.2.0",
"npm:esbuild-plugin-tsc@*": "0.4.0_typescript@5.7.3", "npm:esbuild-plugin-tsc@*": "0.4.0_typescript@5.7.3",
"npm:esbuild-plugin-tsc@0.4": "0.4.0_typescript@5.7.3", "npm:esbuild-plugin-tsc@0.4": "0.4.0_typescript@5.7.3",

View File

@ -85,8 +85,8 @@ const updateDevicesApiSchema = {
}; };
export const updateDevicesApi = new Api( export const updateDevicesApi = new Api(
"/api/updateDevices", "/api/devices/detect",
"POST", "GET",
updateDevicesApiSchema, updateDevicesApiSchema,
); );
@ -102,9 +102,8 @@ const versionApiSchema = {
]), ]),
), ),
}; };
export const versionApi = new Api( export const versionApi = new Api(
"/version", "/api/version",
"POST", "GET",
versionApiSchema, versionApiSchema,
); );

View File

@ -1,4 +1,4 @@
import HttpRouter from "@lib/router.ts"; import HttpRouter from "@src/lib/router.ts";
import { Eta } from "@eta-dev/eta"; import { Eta } from "@eta-dev/eta";
import { serveFile } from "jsr:@std/http/file-server"; import { serveFile } from "jsr:@std/http/file-server";
import rateLimitMiddleware from "@src/middleware/rateLimiter.ts"; import rateLimitMiddleware from "@src/middleware/rateLimiter.ts";
@ -22,11 +22,17 @@ import {
RequestValidationError, RequestValidationError,
} from "@src/lib/errors.ts"; } from "@src/lib/errors.ts";
import devices from "@src/lib/devices.ts"; import devices from "@src/lib/devices.ts";
import { WebSocketClientsGroup } from "@src/lib/websocket.ts";
import { Option } from "@shared/utils/option.ts";
const AUTH_COOKIE_NAME = "token"; const AUTH_COOKIE_NAME = "token";
const VERSION = "0.1.0"; const VERSION = "0.1.0-a.1";
const router = new HttpRouter(); export type Variables = {
token: string;
};
const router = new HttpRouter<Variables>();
const views = Deno.cwd() + "/views/"; const views = Deno.cwd() + "/views/";
export const eta = new Eta({ views }); export const eta = new Eta({ views });
@ -40,8 +46,8 @@ 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();
// } // }
@ -75,8 +81,6 @@ router
) )
.toBoolean(); .toBoolean();
console.log(alreadyLoggedIn);
return c.html(eta.render("./login.html", { alreadyLoggedIn })); return c.html(eta.render("./login.html", { alreadyLoggedIn }));
}) })
.get("/setup", (c) => { .get("/setup", (c) => {
@ -93,27 +97,22 @@ router
); );
}); });
router.get("ws", (c) => { const group = new WebSocketClientsGroup();
group.onmessage = (e) => {
group.sendToAll("pong");
console.log("ping");
};
router.get("/api/admin/ws", (c) => {
if (c.req.headers.get("upgrade") != "websocket") { if (c.req.headers.get("upgrade") != "websocket") {
return new Response(null, { status: 501 }); return new Response(null, { status: 501 });
} }
const { socket, response } = Deno.upgradeWebSocket(c.req); const token = c.var.get("token");
socket.addEventListener("open", () => { let { socket, response } = Deno.upgradeWebSocket(c.req);
console.log("a client connected!");
});
socket.addEventListener("close", () => { socket = group.addClient(token, socket).unwrap();
console.log("client disconnected");
});
socket.addEventListener("message", (event) => {
if (event.data === "ping") {
console.log("pinged!");
socket.send("pong");
}
});
return response; return response;
}); });
@ -193,7 +192,7 @@ router
}); });
function handleCommonErrors( function handleCommonErrors(
c: Context<any, any, any>, c: Context,
error: error:
| QueryExecutionError | QueryExecutionError
| FailedToParseRequestAsJSONError | FailedToParseRequestAsJSONError

View File

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

View File

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

File diff suppressed because one or more lines are too long

View File

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

View File

@ -21,3 +21,5 @@ form.addEventListener("submit", async (e) => {
window.location.href = "/"; window.location.href = "/";
} }
}); });
const ws = new WebSocket("api/admin/ws");

View File

@ -3,3 +3,4 @@ export * from "@shared/utils/result.ts";
export * from "@shared/utils/resultasync.ts"; export * from "@shared/utils/resultasync.ts";
export * from "@shared/utils/validator.ts"; export * from "@shared/utils/validator.ts";
export * from "../../api.ts"; export * from "../../api.ts";
export * from "@src/lib/wsClient.ts";

View File

@ -4,13 +4,8 @@ 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 { getMessageFromError, ok } from "@shared/utils/result.ts"; import { getMessageFromError, ok } from "@shared/utils/result.ts";
import { import { InferSchemaType, Schema } from "@shared/utils/validator.ts";
InferSchemaType, import { ResultAsync } from "@shared/utils/resultasync.ts";
Schema,
SchemaValidationError,
} from "@shared/utils/validator.ts";
import { okAsync, ResultAsync } from "@shared/utils/resultasync.ts";
import log from "@shared/utils/logger.ts";
import { import {
FailedToParseRequestAsJSONError, FailedToParseRequestAsJSONError,
failedToParseRequestAsJSONError, failedToParseRequestAsJSONError,
@ -55,6 +50,10 @@ export class Context<
S extends string = string, S extends string = string,
ReqSchema extends Schema<any> = Schema<unknown>, ReqSchema extends Schema<any> = Schema<unknown>,
ResSchema extends Schema<any> = Schema<unknown>, ResSchema extends Schema<any> = Schema<unknown>,
Variables extends Record<string | number, any> = Record<
string | number,
any
>,
> { > {
private _url?: URL; private _url?: URL;
private _hostname?: string; private _hostname?: string;
@ -84,6 +83,7 @@ export class Context<
ctx._cookies = this._cookies; ctx._cookies = this._cookies;
ctx.res = this.res; ctx.res = this.res;
ctx.schema = schema; ctx.schema = schema;
ctx._var = this._var;
return ctx as Context<S, Req, Res> & { schema: { req: Req; res: Res } }; return ctx as Context<S, Req, Res> & { schema: { req: Req; res: Res } };
} }
@ -101,6 +101,7 @@ export class Context<
ctx._cookies = this._cookies; ctx._cookies = this._cookies;
ctx.res = this.res; ctx.res = this.res;
ctx.schema = this.schema; ctx.schema = this.schema;
ctx._var = this._var;
return ctx as Context<S, ReqSchema, ResSchema>; return ctx as Context<S, ReqSchema, ResSchema>;
} }
@ -263,6 +264,19 @@ export class Context<
delete: (name: string) => deleteCookie(this.res.headers, name), delete: (name: string) => deleteCookie(this.res.headers, name),
}; };
} }
private _var: Variables = {} as Variables;
public get var() {
return {
set: <K extends keyof Variables>(key: K, value: Variables[K]) => {
this._var[key] = value;
},
get: <K extends keyof Variables>(key: K): Variables[K] => {
return this._var[key];
},
};
}
} }
type ExtractPath<S extends string> = S extends type ExtractPath<S extends string> = S extends

View File

@ -13,6 +13,8 @@ import {
type FailedToAccessDevices = CommandExecutionError | UsbipUnknownError; type FailedToAccessDevices = CommandExecutionError | UsbipUnknownError;
const DEFAULT_STATE = 0;
class Devices { class Devices {
private devices: Result< private devices: Result<
Map<string, Device>, Map<string, Device>,
@ -34,9 +36,20 @@ class Devices {
); );
} }
for (const key of Object.keys(update)) { for (const key of Object.keys(update) as (keyof DeviceMutables)[]) {
device[key as keyof typeof update] = if (update[key] !== undefined) {
update[key as keyof typeof update] || none; switch (key) {
case "status":
device.status = update.status ?? device.status;
break;
case "displayName":
device.displayName = update.displayName ?? none;
break;
case "description":
device.description = update.description ?? none;
break;
}
}
} }
return ok(); return ok();
@ -89,6 +102,7 @@ class Devices {
usbid: d.usbid, usbid: d.usbid,
vendor: d.vendor, vendor: d.vendor,
name: d.name, name: d.name,
status: DEFAULT_STATE,
displayName: none, displayName: none,
description: none, description: none,
connectedAt: new Date(), connectedAt: new Date(),
@ -107,12 +121,14 @@ export const deviceSchema = z.obj({
usbid: z.option(z.string()), usbid: z.option(z.string()),
vendor: z.option(z.string()), vendor: z.option(z.string()),
name: z.option(z.string()), name: z.option(z.string()),
status: z.enum([0, 1, 2]), // 0 - private, 1 - public, 2 - exported
displayName: z.option(z.string()), displayName: z.option(z.string()),
description: z.option(z.string()), description: z.option(z.string()),
connectedAt: z.date(), connectedAt: z.date(),
}).strict(); }).strict();
export const deviceMutablesSchema = deviceSchema.pick({ export const deviceMutablesSchema = deviceSchema.pick({
status: true,
displayName: true, displayName: true,
description: true, description: true,
}); });

View File

@ -164,3 +164,21 @@ export type NotFoundError = InferSchemaType<typeof notFoundErrorSchema>;
export const notAllowedErrorSchema = defineError("NotAllowedError"); export const notAllowedErrorSchema = defineError("NotAllowedError");
export const notAllowedError = createErrorFactory(notAllowedErrorSchema); export const notAllowedError = createErrorFactory(notAllowedErrorSchema);
export type NotAllowedError = InferSchemaType<typeof notAllowedErrorSchema>; export type NotAllowedError = InferSchemaType<typeof notAllowedErrorSchema>;
export const tooManyConnectionErrorSchema = defineError(
"tooManyConnectionError",
);
export const tooManyConnectionError = createErrorFactory(
tooManyConnectionErrorSchema,
);
export type TooManyConnectionError = InferSchemaType<
typeof tooManyConnectionErrorSchema
>;
export const webSocketMsgSendErrorSchema = defineError("WebSocketMsgSendError");
export const webSocketMsgSendError = createErrorFactory(
webSocketMsgSendErrorSchema,
);
export type WebSocketMsgSendError = InferSchemaType<
typeof webSocketMsgSendErrorSchema
>;

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

View File

@ -1,79 +1,100 @@
import { RouterTree } from "@src/lib/routerTree.ts"; import { RouterTree } from "@src/lib/routerTree.ts";
import { none, some } from "@shared/utils/option.ts";
import { Context } from "@src/lib/context.ts"; import { Context } from "@src/lib/context.ts";
import { Schema } from "@shared/utils/validator.ts"; import { Schema } from "@shared/utils/validator.ts";
import { Api } from "@src/lib/apiValidator.ts"; import { Api } from "@src/lib/apiValidator.ts";
import { notAllowedError, notFoundError } from "@src/lib/errors.ts"; import { notAllowedError, notFoundError } from "@src/lib/errors.ts";
import { err } from "@shared/utils/result.ts"; import { err } from "@shared/utils/result.ts";
type RequestHandler< type VariablesType = Record<string | number, any>;
S extends string,
ReqSchema extends Schema<any> = Schema<unknown>,
ResSchema extends Schema<any> = Schema<unknown>,
> = (c: Context<S, ReqSchema, ResSchema>) => Promise<Response> | Response;
export type Middleware = ( type RequestHandler<
c: Context<string>, S extends string = string,
ReqSchema extends Schema<unknown> = Schema<unknown>,
ResSchema extends Schema<unknown> = Schema<unknown>,
Variables extends VariablesType = Record<
string | number,
any
>,
> = (
c: Context<S, ReqSchema, ResSchema, Variables>,
) => Promise<Response> | Response;
export type Middleware<
Variables extends VariablesType = Partial<Record<string | number, any>>,
> = (
c: Context<string, any, any, Variables>,
next: () => Promise<void>, next: () => Promise<void>,
) => Promise<Response | void> | Response | void; ) => Promise<Response | void> | Response | void;
type MethodHandler<S extends string> = { type MethodHandler<S extends string> = {
handler: RequestHandler<S>; handler: RequestHandler<S>;
schema?: { req: Schema<any>; res: Schema<any> }; schema?: { req: Schema<unknown>; res: Schema<unknown> };
}; };
type MethodHandlers<S extends string> = Partial< type MethodHandlers<S extends string = string> = Partial<
Record<string, MethodHandler<S>> Record<string, MethodHandler<S>>
>; >;
const DEFAULT_NOT_FOUND_HANDLER = const DEFAULT_NOT_FOUND_HANDLER =
(() => new Response("404 Not found", { status: 404 })) as RequestHandler< (() => new Response("404 Not found", { status: 404 })) as RequestHandler;
any
>;
class HttpRouter { const DEFAULT_NOT_ALLOWED_HANDLER =
public readonly routerTree = new RouterTree<MethodHandlers<any>>(); ((c) =>
c.json(err(notAllowedError("405 Not allowed")), {
status: 405,
})) as RequestHandler;
class HttpRouter<
Variables extends VariablesType = Partial<
Record<
string | number,
any
>
>,
> {
public readonly routerTree = new RouterTree<MethodHandlers>();
public pathTransformer?: (path: string) => string; public pathTransformer?: (path: string) => string;
private middlewares: Middleware[] = []; private middlewares: Middleware<Variables>[] = [];
public defaultNotFoundHandler: RequestHandler<string> = public notFoundHandler: RequestHandler = DEFAULT_NOT_FOUND_HANDLER;
DEFAULT_NOT_FOUND_HANDLER; public methodNotAllowedHandler: RequestHandler =
DEFAULT_NOT_ALLOWED_HANDLER;
public setPathTransformer(transformer: (path: string) => string) { public setPathTransformer(transformer: (path: string) => string) {
this.pathTransformer = transformer; this.pathTransformer = transformer;
return this; return this;
} }
public use(middleware: Middleware): this { public use(middleware: Middleware<Variables>): this {
this.middlewares.push(middleware); this.middlewares.push(middleware);
return this; return this;
} }
public add< public add<
S extends string, S extends string,
ReqSchema extends Schema<any> = Schema<unknown>, ReqSchema extends Schema<unknown> = Schema<unknown>,
ResSchema extends Schema<any> = Schema<unknown>, ResSchema extends Schema<unknown> = Schema<unknown>,
>( >(
path: S, path: S,
method: string, method: string,
handler: RequestHandler<S, ReqSchema, ResSchema>, handler: RequestHandler<S, ReqSchema, ResSchema, Variables>,
schema?: { req: ReqSchema; res: ResSchema }, schema?: { req: ReqSchema; res: ResSchema },
): HttpRouter; ): this;
public add< public add<
S extends string, S extends string,
ReqSchema extends Schema<any> = Schema<unknown>, ReqSchema extends Schema<unknown> = Schema<unknown>,
ResSchema extends Schema<any> = Schema<unknown>, ResSchema extends Schema<unknown> = Schema<unknown>,
>( >(
path: S[], path: S[],
method: string, method: string,
handler: RequestHandler<string, ReqSchema, ResSchema>, handler: RequestHandler<string, ReqSchema, ResSchema, Variables>,
schema?: { req: ReqSchema; res: ResSchema }, schema?: { req: ReqSchema; res: ResSchema },
): HttpRouter; ): this;
public 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> }, schema?: { req: Schema<unknown>; res: Schema<unknown> },
): HttpRouter { ): this {
const paths = Array.isArray(path) ? path : [path]; const paths = Array.isArray(path) ? path : [path];
for (const p of paths) { for (const p of paths) {
@ -82,7 +103,7 @@ class HttpRouter {
existingHandlers[method] = { handler, schema }; existingHandlers[method] = { handler, schema };
}, },
() => { () => {
const newHandlers: MethodHandlers<string> = {}; const newHandlers: MethodHandlers = {};
newHandlers[method] = { handler, schema }; newHandlers[method] = { handler, schema };
this.routerTree.add(p, newHandlers); this.routerTree.add(p, newHandlers);
}, },
@ -94,16 +115,16 @@ class HttpRouter {
public get<S extends string>( public get<S extends string>(
path: S, path: S,
handler: RequestHandler<S>, handler: RequestHandler<S, any, any, Variables>,
): HttpRouter; ): this;
public get<S extends string>( public get<S extends string>(
path: S[], path: S[],
handler: RequestHandler<string>, handler: RequestHandler<S, any, any, Variables>,
): HttpRouter; ): this;
public get( public get(
path: string | string[], path: string | string[],
handler: RequestHandler<string>, handler: RequestHandler<string, any, any, Variables>,
): HttpRouter { ): this {
if (Array.isArray(path)) { if (Array.isArray(path)) {
return this.add(path, "GET", handler); return this.add(path, "GET", handler);
} }
@ -112,15 +133,15 @@ class HttpRouter {
public post<S extends string>( public post<S extends string>(
path: S, path: S,
handler: RequestHandler<S>, handler: RequestHandler<S, any, any, Variables>,
): HttpRouter; ): HttpRouter;
public post<S extends string>( public post(
path: string[], path: string[],
handler: RequestHandler<string>, handler: RequestHandler<string, any, any, Variables>,
): HttpRouter; ): HttpRouter;
public post( public post(
path: string | string[], path: string | string[],
handler: RequestHandler<string>, handler: RequestHandler<string, any, any, Variables>,
): HttpRouter { ): HttpRouter {
if (Array.isArray(path)) { if (Array.isArray(path)) {
return this.add(path, "POST", handler); return this.add(path, "POST", handler);
@ -130,11 +151,11 @@ class HttpRouter {
public api< public api<
Path extends string, Path extends string,
ReqSchema extends Schema<any>, ReqSchema extends Schema<unknown>,
ResSchema extends Schema<any>, ResSchema extends Schema<unknown>,
>( >(
api: Api<Path, ReqSchema, ResSchema>, api: Api<Path, ReqSchema, ResSchema>,
handler: RequestHandler<Path, ReqSchema, ResSchema>, handler: RequestHandler<Path, ReqSchema, ResSchema, Variables>,
): HttpRouter { ): HttpRouter {
return this.add(api.path, api.method, handler, api.schema); return this.add(api.path, api.method, handler, api.schema);
} }
@ -148,73 +169,50 @@ class HttpRouter {
? this.pathTransformer(ctx.path) ? this.pathTransformer(ctx.path)
: ctx.path; : ctx.path;
let routeParams: Record<string, string> = {}; const { handler, params, ctx: routeCtx } = this.resolveRoute(ctx, path);
ctx = routeCtx.setParams(params);
const handler = this.routerTree const res =
.find(path) (await this.runMiddlewares(this.middlewares, handler, ctx)).res;
.andThen((match) => { return req.method === "HEAD" ? this.removeBodyFromResponse(res) : res;
const { value: methodHandler, params: params } = match; }
routeParams = params;
let route = methodHandler[req.method]; private resolveRoute(
ctx: Context,
path: string,
): {
handler: RequestHandler;
params: Record<string, string>;
ctx: Context<any>;
} {
const routeOption = this.routerTree.find(path);
if (!route) { if (routeOption.isSome()) {
if (req.method === "HEAD") { const { value: methodHandlers, params } = routeOption.value;
const getHandler = methodHandler["GET"]; let route = methodHandlers[ctx.req.method];
if (!getHandler) {
return none; if (!route && ctx.req.method === "HEAD") {
route = methodHandlers["GET"];
} else if (!route && ctx.req.method !== "GET") {
if (ctx.preferredType.map((v) => v === "json").toBoolean()) {
return {
handler: this.methodNotAllowedHandler,
params,
ctx,
};
} }
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) {
if (route.schema) { if (route.schema) {
ctx = ctx.setSchema(route.schema); ctx = ctx.setSchema(route.schema);
} }
const handler = route.handler; return { handler: route.handler, params, ctx };
}
return some(handler); }
}) return { handler: this.notFoundHandler, params: {}, ctx };
.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( private removeBodyFromResponse(res: Response): Response {
this.middlewares,
handler,
ctx = ctx.setParams(routeParams),
)).res;
if (req.method === "HEAD") {
const headers = new Headers(res.headers); const headers = new Headers(res.headers);
headers.set("Content-Length", "0"); headers.set("Content-Length", "0");
return new Response(null, { return new Response(null, {
@ -224,44 +222,32 @@ class HttpRouter {
}); });
} }
return res; private async runMiddlewares(
}
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[], middlewares: Middleware[],
handler: RequestHandler<S>, handler: RequestHandler,
c: Context<S>, ctx: Context,
) { ) {
let currentIndex = -1;
const dispatch = async (index: number): Promise<void> => { const dispatch = async (index: number): Promise<void> => {
currentIndex = index;
if (index < middlewares.length) { if (index < middlewares.length) {
const middleware = middlewares[index]; const middleware = middlewares[index];
const result = await middleware(ctx, () => dispatch(index + 1));
const result = await middleware(c, () => dispatch(index + 1));
if (result !== undefined) { if (result !== undefined) {
c.res = await Promise.resolve(result); ctx.res = await Promise.resolve(result);
} }
} else { } else {
const res = await handler(c); ctx.res = await handler(ctx);
c.res = res;
} }
}; };
await dispatch(0); await dispatch(0);
return ctx;
}
return c; private buildNotFoundHandler(c: Context) {
return c.matchPreferredType(
() => c.html("404 Not found", { status: 404 }),
() => c.json(err(notFoundError("404 Not found")), { status: 404 }),
() => new Response("404 Not found", { status: 404 }),
);
} }
} }

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

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

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

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

View File

@ -1,16 +1,12 @@
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 { import { queryExecutionError, unauthorizedError } from "@lib/errors.ts";
queryExecutionError,
tooManyRequestsError,
unauthorizedError,
} from "@src/lib/errors.ts";
import { err, ok } from "@shared/utils/result.ts"; import { err, ok } from "@shared/utils/result.ts";
import { eta } from "../../main.ts"; import { eta, Variables } from "../../main.ts";
const EXCLUDE = new Set(["/login", "/setup", "/version"]); const EXCLUDE = new Set(["/login", "/setup", "/version"]);
const authMiddleware: Middleware = async (c, next) => { const authMiddleware: Middleware<Variables> = async (c, next) => {
const token = c.cookies.get("token"); const token = c.cookies.get("token");
const isValid = token const isValid = token
.map((token) => admin.sessions.verifyToken(token)).match( .map((token) => admin.sessions.verifyToken(token)).match(
@ -52,6 +48,9 @@ const authMiddleware: Middleware = async (c, next) => {
return c.redirect("/login"); return c.redirect("/login");
} }
} }
c.var.set("token", token.unwrapOr(""));
await next(); await next();
}; };

View File

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

View File

@ -1,7 +1,8 @@
import { Middleware } from "@lib/router.ts"; import { Middleware } from "@lib/router.ts";
import log from "@shared/utils/logger.ts"; import log from "@shared/utils/logger.ts";
import { err } from "@shared/utils/result.ts"; import { err } from "@shared/utils/result.ts";
import { tooManyRequestsError } from "@src/lib/errors.ts"; import { tooManyRequestsError } from "@lib/errors.ts";
import { Variables } from "../../main.ts";
const requestCounts: Partial< const requestCounts: Partial<
Record<string, { count: number; lastReset: number }> Record<string, { count: number; lastReset: number }>
@ -10,7 +11,7 @@ const requestCounts: Partial<
const MAX_REQUESTS_PER_WINDOW = 300; const MAX_REQUESTS_PER_WINDOW = 300;
const RATE_LIMIT_WINDOW = 60000; const RATE_LIMIT_WINDOW = 60000;
const rateLimitMiddleware: Middleware = async (c, next) => { const rateLimitMiddleware: Middleware<Variables> = async (c, next) => {
const hostnameOpt = c.hostname; const hostnameOpt = c.hostname;
if (hostnameOpt.isSome()) { if (hostnameOpt.isSome()) {

View File

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

Binary file not shown.

View File

@ -1 +1 @@
<% 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> <% layout("./layouts/layout.html") %> <div id=devices_grid></div><div id=info></div><button id=ping>ping</button><button id=reconnect>reconnect</button><script defer src=/public/js/index.js type=module></script>

View File

@ -62,6 +62,18 @@ export class ResultAsync<T, E> implements PromiseLike<Result<T, E>> {
}; };
} }
static from<
T = void,
E = void,
>(
executor: (
resolve: (value: T | PromiseLike<T>) => void,
reject: (reason?: E) => void,
) => void,
): ResultAsync<T, E> {
return ResultAsync.fromPromise(new Promise(executor), (e) => e as E);
}
async unwrap(): Promise<T> { async unwrap(): Promise<T> {
const result = await this._promise; const result = await this._promise;
if (result.isErr()) { if (result.isErr()) {

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

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

View File

@ -1178,6 +1178,34 @@ export class OptionSchema<T extends Schema<any>>
} }
} }
export class EnumSchema<E extends (number | string)[]>
extends BaseSchema<E[number]> {
constructor(
public readonly entries: E,
msg?: string,
) {
super(msg);
}
protected override validateInput(
input: unknown,
): Result<E[number], SchemaValidationError> {
for (const entry of this.entries) {
if (input === entry) {
return ok(input as E[number]);
}
}
return err(createValidationError(input, {
kind: "typeMismatch",
expected: this.entries.map((e) =>
typeof e === "string" ? `"${e}"` : e
).join(" | "),
received: String(input),
msg: this.msg,
}));
}
}
/* ── Helper Object for Schema Creation (z) ───────────────────────────────────── */ /* ── Helper Object for Schema Creation (z) ───────────────────────────────────── */
export const z = { export const z = {
@ -1235,6 +1263,8 @@ export const z = {
option: <T extends Schema<any>>( option: <T extends Schema<any>>(
schema: T, schema: T,
) => new OptionSchema<T>(schema), ) => new OptionSchema<T>(schema),
enum: <E extends (number | string)[]>(e: E, msg?: string) =>
new EnumSchema(e, msg),
}; };
export type InferSchemaType<S> = S extends Schema<infer T> ? T : never; export type InferSchemaType<S> = S extends Schema<infer T> ? T : never;