workind on websocket
This commit is contained in:
1
deno.lock
generated
1
deno.lock
generated
@ -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",
|
||||||
|
|||||||
@ -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,
|
||||||
);
|
);
|
||||||
|
|||||||
@ -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";
|
||||||
@ -24,7 +24,7 @@ import {
|
|||||||
import devices from "@src/lib/devices.ts";
|
import devices from "@src/lib/devices.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();
|
const router = new HttpRouter();
|
||||||
|
|
||||||
@ -93,7 +93,7 @@ router
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
router.get("ws", (c) => {
|
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 });
|
||||||
}
|
}
|
||||||
@ -110,7 +110,7 @@ router.get("ws", (c) => {
|
|||||||
|
|
||||||
socket.addEventListener("message", (event) => {
|
socket.addEventListener("message", (event) => {
|
||||||
if (event.data === "ping") {
|
if (event.data === "ping") {
|
||||||
console.log("pinged!");
|
console.log("ping");
|
||||||
socket.send("pong");
|
socket.send("pong");
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
@ -193,7 +193,7 @@ router
|
|||||||
});
|
});
|
||||||
|
|
||||||
function handleCommonErrors(
|
function handleCommonErrors(
|
||||||
c: Context<any, any, any>,
|
c: Context,
|
||||||
error:
|
error:
|
||||||
| QueryExecutionError
|
| QueryExecutionError
|
||||||
| FailedToParseRequestAsJSONError
|
| FailedToParseRequestAsJSONError
|
||||||
|
|||||||
@ -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();
|
||||||
|
|||||||
@ -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
@ -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();
|
||||||
|
|||||||
@ -21,3 +21,5 @@ form.addEventListener("submit", async (e) => {
|
|||||||
window.location.href = "/";
|
window.location.href = "/";
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const ws = new WebSocket("api/admin/ws");
|
||||||
|
|||||||
@ -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";
|
||||||
|
|||||||
@ -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,
|
||||||
});
|
});
|
||||||
|
|||||||
@ -164,3 +164,13 @@ 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
|
||||||
|
>;
|
||||||
|
|||||||
@ -1,5 +1,4 @@
|
|||||||
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";
|
||||||
@ -7,9 +6,9 @@ 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 RequestHandler<
|
||||||
S extends string,
|
S extends string = 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>,
|
||||||
> = (c: Context<S, ReqSchema, ResSchema>) => Promise<Response> | Response;
|
> = (c: Context<S, ReqSchema, ResSchema>) => Promise<Response> | Response;
|
||||||
|
|
||||||
export type Middleware = (
|
export type Middleware = (
|
||||||
@ -19,24 +18,29 @@ export type Middleware = (
|
|||||||
|
|
||||||
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
|
|
||||||
>;
|
const DEFAULT_NOT_ALLOWED_HANDLER =
|
||||||
|
((c) =>
|
||||||
|
c.json(err(notAllowedError("405 Not allowed")), {
|
||||||
|
status: 405,
|
||||||
|
})) as RequestHandler;
|
||||||
|
|
||||||
class HttpRouter {
|
class HttpRouter {
|
||||||
public readonly routerTree = new RouterTree<MethodHandlers<any>>();
|
public readonly routerTree = new RouterTree<MethodHandlers>();
|
||||||
public pathTransformer?: (path: string) => string;
|
public pathTransformer?: (path: string) => string;
|
||||||
private middlewares: Middleware[] = [];
|
private middlewares: Middleware[] = [];
|
||||||
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;
|
||||||
@ -50,8 +54,8 @@ class HttpRouter {
|
|||||||
|
|
||||||
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,
|
||||||
@ -60,8 +64,8 @@ class HttpRouter {
|
|||||||
): HttpRouter;
|
): HttpRouter;
|
||||||
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,
|
||||||
@ -72,7 +76,7 @@ class HttpRouter {
|
|||||||
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 {
|
): HttpRouter {
|
||||||
const paths = Array.isArray(path) ? path : [path];
|
const paths = Array.isArray(path) ? path : [path];
|
||||||
|
|
||||||
@ -82,7 +86,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);
|
||||||
},
|
},
|
||||||
@ -98,11 +102,11 @@ class HttpRouter {
|
|||||||
): HttpRouter;
|
): HttpRouter;
|
||||||
public get<S extends string>(
|
public get<S extends string>(
|
||||||
path: S[],
|
path: S[],
|
||||||
handler: RequestHandler<string>,
|
handler: RequestHandler,
|
||||||
): HttpRouter;
|
): HttpRouter;
|
||||||
public get(
|
public get(
|
||||||
path: string | string[],
|
path: string | string[],
|
||||||
handler: RequestHandler<string>,
|
handler: RequestHandler,
|
||||||
): HttpRouter {
|
): HttpRouter {
|
||||||
if (Array.isArray(path)) {
|
if (Array.isArray(path)) {
|
||||||
return this.add(path, "GET", handler);
|
return this.add(path, "GET", handler);
|
||||||
@ -114,9 +118,9 @@ class HttpRouter {
|
|||||||
path: S,
|
path: S,
|
||||||
handler: RequestHandler<S>,
|
handler: RequestHandler<S>,
|
||||||
): HttpRouter;
|
): HttpRouter;
|
||||||
public post<S extends string>(
|
public post(
|
||||||
path: string[],
|
path: string[],
|
||||||
handler: RequestHandler<string>,
|
handler: RequestHandler,
|
||||||
): HttpRouter;
|
): HttpRouter;
|
||||||
public post(
|
public post(
|
||||||
path: string | string[],
|
path: string | string[],
|
||||||
@ -130,8 +134,8 @@ 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>,
|
||||||
@ -148,73 +152,42 @@ class HttpRouter {
|
|||||||
? this.pathTransformer(ctx.path)
|
? this.pathTransformer(ctx.path)
|
||||||
: ctx.path;
|
: ctx.path;
|
||||||
|
|
||||||
let routeParams: Record<string, string> = {};
|
const { handler, params } = this.resolveRoute(ctx, path);
|
||||||
|
ctx = ctx.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> } {
|
||||||
|
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 };
|
||||||
}
|
}
|
||||||
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 };
|
||||||
|
}
|
||||||
return some(handler);
|
}
|
||||||
})
|
return { handler: this.notFoundHandler, params: {} };
|
||||||
.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 +197,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 }),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
38
server/src/lib/websocket.ts
Normal file
38
server/src/lib/websocket.ts
Normal file
@ -0,0 +1,38 @@
|
|||||||
|
import {
|
||||||
|
TooManyConnectionError,
|
||||||
|
tooManyConnectionError,
|
||||||
|
} from "@src/lib/errors.ts";
|
||||||
|
import { err, ok, Result } from "@shared/utils/result.ts";
|
||||||
|
|
||||||
|
const MAX_CONNECTIONS_PER_TOKEN = 2;
|
||||||
|
const MAX_CONNECTIONS = 500;
|
||||||
|
|
||||||
|
class WebSocketManager {
|
||||||
|
private adminSockets: Map<string, WebSocket[]> = new Map();
|
||||||
|
private userSockets: Map<string, WebSocket[]> = new Map();
|
||||||
|
private connectionsCounter: number = 0;
|
||||||
|
|
||||||
|
public addAdminClient(
|
||||||
|
token: string,
|
||||||
|
socket: WebSocket,
|
||||||
|
): Result<void, TooManyConnectionError> {
|
||||||
|
if (this.connectionsCounter > MAX_CONNECTIONS) {
|
||||||
|
return err(tooManyConnectionError("Too many connections"));
|
||||||
|
}
|
||||||
|
|
||||||
|
const sockets = this.adminSockets.get(token);
|
||||||
|
|
||||||
|
if (!sockets) {
|
||||||
|
const sockets = [socket];
|
||||||
|
this.adminSockets.set(token, sockets);
|
||||||
|
this.connectionsCounter++;
|
||||||
|
return ok();
|
||||||
|
} else if (sockets.length < MAX_CONNECTIONS_PER_TOKEN) {
|
||||||
|
sockets.push(socket);
|
||||||
|
this.connectionsCounter++;
|
||||||
|
return ok();
|
||||||
|
} else {
|
||||||
|
return err(tooManyConnectionError("Too many connections"));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
186
server/src/lib/wsClient.ts
Normal file
186
server/src/lib/wsClient.ts
Normal 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 = 1000;
|
||||||
|
const PING_CHECK_INTERVAL_MS = 15000;
|
||||||
|
const MAX_PING_ATTEMPTS = 5;
|
||||||
|
const MAX_RECONNECTION_ATTEMPTS = 5;
|
||||||
|
|
||||||
|
export class WebSocketWrapper<
|
||||||
|
R extends Schema<any> = Schema<unknown>,
|
||||||
|
S extends Schema<any> = 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<any>) => 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"]),
|
||||||
|
});
|
||||||
@ -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>
|
||||||
|
|||||||
BIN
server/test.db
BIN
server/test.db
Binary file not shown.
@ -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>
|
||||||
@ -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()) {
|
||||||
|
|||||||
@ -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;
|
||||||
|
|||||||
Reference in New Issue
Block a user