diff --git a/deno.lock b/deno.lock index 5a998f9..9ce5662 100644 --- a/deno.lock +++ b/deno.lock @@ -41,6 +41,7 @@ "npm:@minify-html/wasm@*": "0.15.0", "npm:@tauri-apps/api@2": "2.2.0", "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:esbuild-plugin-tsc@*": "0.4.0_typescript@5.7.3", "npm:esbuild-plugin-tsc@0.4": "0.4.0_typescript@5.7.3", diff --git a/server/api.ts b/server/api.ts index a79a6b9..f10eee0 100644 --- a/server/api.ts +++ b/server/api.ts @@ -85,8 +85,8 @@ const updateDevicesApiSchema = { }; export const updateDevicesApi = new Api( - "/api/updateDevices", - "POST", + "/api/devices/detect", + "GET", updateDevicesApiSchema, ); @@ -102,9 +102,8 @@ const versionApiSchema = { ]), ), }; - export const versionApi = new Api( - "/version", - "POST", + "/api/version", + "GET", versionApiSchema, ); diff --git a/server/main.ts b/server/main.ts index 74924c9..82876d9 100644 --- a/server/main.ts +++ b/server/main.ts @@ -1,4 +1,4 @@ -import HttpRouter from "@lib/router.ts"; +import HttpRouter from "@src/lib/router.ts"; import { Eta } from "@eta-dev/eta"; import { serveFile } from "jsr:@std/http/file-server"; import rateLimitMiddleware from "@src/middleware/rateLimiter.ts"; @@ -24,7 +24,7 @@ import { import devices from "@src/lib/devices.ts"; const AUTH_COOKIE_NAME = "token"; -const VERSION = "0.1.0"; +const VERSION = "0.1.0-a.1"; 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") { return new Response(null, { status: 501 }); } @@ -110,7 +110,7 @@ router.get("ws", (c) => { socket.addEventListener("message", (event) => { if (event.data === "ping") { - console.log("pinged!"); + console.log("ping"); socket.send("pong"); } }); @@ -193,7 +193,7 @@ router }); function handleCommonErrors( - c: Context, + c: Context, error: | QueryExecutionError | FailedToParseRequestAsJSONError diff --git a/server/public/js/index.js b/server/public/js/index.js index cdd291e..b137e85 100644 --- a/server/public/js/index.js +++ b/server/public/js/index.js @@ -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(); diff --git a/server/public/js/login.js b/server/public/js/login.js index 4fdf68c..66fc458 100644 --- a/server/public/js/login.js +++ b/server/public/js/login.js @@ -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"); diff --git a/server/public/js/shared.bundle.js b/server/public/js/shared.bundle.js index f561e17..4344410 100644 --- a/server/public/js/shared.bundle.js +++ b/server/public/js/shared.bundle.js @@ -1 +1 @@ -var R=class t{constructor(e){this._promise=e;this._promise=e}static fromPromise(e,r){let n=e.then(s=>new m(s)).catch(s=>new y(r(s)));return new t(n)}static fromSafePromise(e){let r=e.then(n=>new m(n));return new t(r)}static fromThrowable(e,r){return(...n)=>t.fromPromise(e(n),s=>r?r(s):s)}async unwrap(){let e=await this._promise;if(e.isErr())throw e.error;return e.value}async match(e,r){let n=await this._promise;return n.isErr()?r(n.error):e(n.value)}map(e){return new t(this._promise.then(r=>r.isErr()?new y(r.error):new m(e(r.value))))}mapAsync(e){return new t(this._promise.then(async r=>r.isErr()?x(r.error):new m(await e(r.value))))}mapErr(e){return new t(this._promise.then(r=>r.isErr()?new y(e(r.error)):new m(r.value)))}mapErrAsync(e){return new t(this._promise.then(async r=>r.isErr()?x(await e(r.error)):new m(r.value)))}andThen(e){return new t(this._promise.then(r=>r.isErr()?x(r.error):e(r.value).toAsync()))}andThenAsync(e){return new t(this._promise.then(r=>r.isErr()?x(r.error):e(r.value)))}nullableToOption(){return this.map(e=>e?f(e):v)}flatten(){return new t(this._promise.then(e=>e.flatten()))}flattenOption(e){return new t(this._promise.then(r=>r.flattenOption(e)))}flattenOptionOrDefault(e){return new t(this._promise.then(r=>r.flattenOptionOrDefault(e)))}matchOption(e,r){return new t(this._promise.then(n=>n.matchOption(e,r)))}matchOptionAndFlatten(e,r){return new t(this._promise.then(n=>n.matchOptionAndFlatten(e,r)))}then(e,r){return this._promise.then(e,r)}};function I(t){return new R(Promise.resolve(new m(t)))}function x(t){return new R(Promise.resolve(new y(t)))}var m=class t{constructor(e){this.value=e;this.value=e,Object.defineProperties(this,{tag:{writable:!1,enumerable:!1}})}tag="ok";isErr(){return!1}ifErr(e){return this}isErrOrNone(){return this.value instanceof T}isOk(){return!0}ifOk(e){return e(this.value),this}unwrap(){return this.value}unwrapOr(e){return this.value}unwrapOrElse(e){return this.value}unwrapErr(){return v}match(e,r){return e(this.value)}map(e){let r=e(this.value);return new t(r)}mapAsync(e){return R.fromSafePromise(e(this.value))}mapOption(e){return this.value instanceof T||this.value instanceof S?u(this.value.map(e)):u(f(e(this.value)))}andThen(e){return e(this.value)}andThenAsync(e){return e(this.value)}mapErr(e){return u(this.value)}mapErrAsync(e){return I(this.value)}flatten(){return ue(this)}flattenOption(e){return this.value instanceof T||this.value instanceof S?this.value.okOrElse(e):new t(this.value)}flattenOptionOr(e){return this.value instanceof T||this.value instanceof S?this.value.unwrapOr(e):new t(this.value)}matchOption(e,r){return this.value instanceof T||this.value instanceof S?u(this.value.match(e,r)):u(e(this.value))}toNullable(){return this.value}toAsync(){return I(this.value)}void(){return u()}toJSON(){return{tag:"ok",value:this.value}}},y=class t{constructor(e){this.error=e;this.error=e,Object.defineProperties(this,{tag:{writable:!1,configurable:!1,enumerable:!1}})}tag="err";isErr(){return!0}ifErr(e){return e(this.error),this}isOk(){return!1}ifOk(e){return this}isErrOrNone(){return!0}unwrap(){let e=`Tried to unwrap error: ${ae(this.error)}`;throw new Error(e)}unwrapOr(e){return e}unwrapOrElse(e){return e()}unwrapErr(){return f(this.error)}match(e,r){return r(this.error)}map(e){return new t(this.error)}mapAsync(e){return x(this.error)}mapErr(e){return new t(e(this.error))}mapErrAsync(e){return R.fromPromise(new Promise(()=>{throw""}),()=>e(this.error))}mapOption(e){return o(this.error)}andThen(e){return new t(this.error)}andThenAsync(e){return new t(this.error).toAsync()}flatten(){return ue(this)}flattenOption(e){return new t(this.error)}flattenOptionOr(e){return new t(this.error)}matchOption(e,r){return o(this.error)}toNullable(){return null}toAsync(){return x(this.error)}void(){return o(this.error)}toJSON(){return{tag:"err",error:this.error}}};function u(t){return new m(t)}function o(t){return new y(t)}function Be(t,e){return(...r)=>{try{let n=t(...r);return u(n)}catch(n){return o(e?e(n):n)}}}function ae(t){if(t instanceof Error)return t.message?t.message:"code"in t&&typeof t.code=="string"?t.code:"An unknown error occurred";if(typeof t=="string")return t;if(typeof t=="object"&&t!==null&&"message"in t){let e=t;return typeof e.message=="string"?e.message:String(e.message)}return"An unknown error occurred"}function ue(t){let e=t;for(;e instanceof m&&(e.value instanceof m||e.value instanceof y);)e=e.value;return e}var O=class extends Error{constructor(e){super(`Failed to parse ${e} as result`)}};function De(t){let e;if(typeof t=="string")try{e=JSON.parse(t)}catch(n){return o(new O(ae(n)))}else e=t;if(typeof e!="object"||e===null)return o(new O("Expected an object but received type ${typeof data}."));let r=e;if("tag"in r)switch(r.tag){case"ok":{if("value"in r)return u(r.value);break}case"err":{if("error"in r)return o(r.error);break}}return o(new O("Object does not contain 'tag' and 'value' or 'error' property"))}var S=class t{constructor(e){this.value=e;Object.defineProperties(this,{tag:{writable:!1,enumerable:!1}})}tag="some";isSome(){return!0}ifSome(e){return e(this.value),this}isNone(){return!1}ifNone(e){return this}map(e){return new t(e(this.value))}flatMap(e){return e(this.value)}andThen(e){return e(this.value)}unwrap(){return this.value}unwrapOr(e){return this.value}unwrapOrElse(e){return this.value}or(e){return this}orElse(e){return this}match(e,r){return e(this.value)}toString(){return`Some(${this.value})`}toJSON(){return{tag:"some",value:this.value}}toNullable(){return this.value}toBoolean(){return!0}okOrElse(e){return u(this.value)}},T=class t{tag="none";constructor(){Object.defineProperties(this,{tag:{writable:!1,enumerable:!1}})}isSome(){return!1}ifSome(e){return this}isNone(){return!0}ifNone(e){return e(),this}map(e){return new t}andThen(e){return v}flatMap(e){return new t}unwrap(){throw new Error("Tried to unwrap a non-existent value")}unwrapOr(e){return e}unwrapOrElse(e){return e()}or(e){return e}orElse(e){return e()}match(e,r){return r()}toString(){return"None"}toJSON(){return{tag:"none"}}toNullable(){return null}toBoolean(){return!1}okOrElse(e){return o(e())}};function f(t){return new S(t)}var v=new T;function Ce(t){return t?f(t):v}var V=class t extends Error{constructor(r,n){super(t.getBestMsg(n)||"Schema validation error");this.input=r;this.detail=n;this.name="SchemaValidationError"}type="SchemaValiationError";format(){return{input:this.input,error:t.formatDetail(this.detail)}}get info(){return t.getBestMsg(this.detail)}static getBestMsg(r){switch(r.kind){case"typeMismatch":case"unexpectedProperties":case"missingProperties":case"general":case"unionValidation":return t.formatMsg(r);case"propertyValidation":case"arrayElement":return r.msg||t.getBestMsg(r.detail);default:return"Unknown error"}}static formatDetail(r){switch(r.kind){case"general":case"typeMismatch":return t.formatMsg(r);case"propertyValidation":return{[r.property]:r.msg||this.formatDetail(r.detail)};case"unexpectedProperties":case"missingProperties":{let n=r.msg||(r.kind==="unexpectedProperties"?"Property is not allowed in a strict schema object":"Property is required, but missing");return r.keys.reduce((s,c)=>(s[c]=n,s),{})}case"arrayElement":{let n={};return r.msg&&(n.msg=r.msg),n[`index_${r.index}`]=this.formatDetail(r.detail),n}case"unionValidation":{let n=r.details?.map(s=>this.formatDetail(s));return r.msg&&n.unshift("Msg: "+r.msg),n}default:return"Unknown error type"}}static formatMsg(r){if(r.msg||r.kind==="general")return r.msg||"Unknown error";switch(r.kind){case"typeMismatch":return`Expected ${r.expected}, but received ${r.received}`;case"unexpectedProperties":return`Properties not allowed: ${r.keys.join(", ")}`;case"missingProperties":return`Missing required properties: ${r.keys.join(", ")}`;case"unionValidation":return"Input did not match any union member";default:return"Unknown error"}}};function a(t,e){return new V(t,e)}var i=class{constructor(e){this.msg=e}checks=[];parse(e){return this.validateInput(e).andThen(r=>this.applyValidationChecks(r))}static validatePrimitive(e,r,n){let s=typeof e;return s===r?u(e):o(a(e,{kind:"typeMismatch",expected:r,received:s,msg:n}))}addCheck(e){return this.checks.push(e),this}applyValidationChecks(e){for(let r of this.checks){let n=r(e);if(n)return o(n)}return u(e)}static isNullishSchema(e){return e.parse(null).isOk()||e.parse(void 0).isOk()}},N=class t extends i{static emailRegex=/^(([^<>()[\]\.,;:\s@\"]+(\.[^<>()[\]\.,;:\s@\"]+)*)|(\".+\"))@(([^<>()[\]\.,;:\s@\"]+\.)+[^<>()[\]\.,;:\s@\"]{2,})$/i;static ipRegex=/^(25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)\.(25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)\.(25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)\.(25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)$/;validateInput(e){return i.validatePrimitive(e,"string",this.msg)}max(e,r){return this.addCheck(n=>{if(n.length>e)return a(n,{kind:"general",msg:r||`String must be at most ${e} characters long`})})}min(e,r){return this.addCheck(n=>{if(n.length{if(!e.test(n))return a(n,{kind:"general",msg:r||`String must match pattern ${String(e)}`})})}email(e){return this.regex(t.emailRegex,e||"String must be a valid email address")}ip(e){return this.regex(t.ipRegex,e||"String must be a valid ip address")}},F=class extends i{constructor(r,n){super(n);this.literal=r}validateInput(r){return i.validatePrimitive(r,"string",this.msg).andThen(n=>n===this.literal?u(n):o(a(r,{kind:"typeMismatch",expected:this.literal,received:n,msg:this.msg})))}},M=class extends i{validateInput(e){return i.validatePrimitive(e,"number",this.msg)}gt(e,r){return this.addCheck(n=>{if(n<=e)return a(n,{kind:"general",msg:r||`Number must be greater than ${e}`})})}gte(e,r){return this.addCheck(n=>{if(n{if(n>=e)return a(n,{kind:"general",msg:r||`Number must be less than ${e}`})})}lte(e,r){return this.addCheck(n=>{if(n>e)return a(n,{kind:"general",msg:r||`Number must be less than or equal to ${e}`})})}int(e){return this.addCheck(r=>{if(!Number.isInteger(r))return a(r,{kind:"general",msg:e||"Number must be an integer"})})}positive(e){return this.gt(0,e||"Number must be positive")}nonnegative(e){return this.gte(0,e||"Number must be nonnegative")}negative(e){return this.lt(0,e||"Number must be negative")}nonpositive(e){return this.lte(0,e||"Number must be nonpositive")}finite(e){return this.addCheck(r=>{if(!Number.isFinite(r))return a(r,{kind:"general",msg:e||"Number must be an integer"})})}safe(e){return this.addCheck(r=>{if(!Number.isSafeInteger(r))return a(r,{kind:"general",msg:e||"Number must be an integer"})})}multipleOf(e,r){return this.addCheck(n=>{if(n%e!==0)return a(n,{kind:"general",msg:r||`Number must be a multiple of ${e}`})})}},B=class extends i{validateInput(e){return i.validatePrimitive(e,"bigint",this.msg)}},D=class extends i{validateInput(e){return i.validatePrimitive(e,"boolean",this.msg)}},j=class extends i{validateInput(e){return i.validatePrimitive(e,"object",this.msg).andThen(r=>{if(r instanceof Date)return u(r);let n=r?.constructor?.name??"unknown";return o(a(e,{kind:"typeMismatch",expected:"Date instance",received:n,msg:this.msg}))})}},q=class extends i{validateInput(e){return i.validatePrimitive(e,"symbol",this.msg)}},$=class extends i{validateInput(e){return i.validatePrimitive(e,"undefined",this.msg)}},C=class extends i{validateInput(e){if(e===null)return u(e);let r=typeof e=="object"?e?.constructor?.name??"unknown":typeof e;return o(a(e,{kind:"typeMismatch",expected:"null",received:r,msg:this.msg}))}},J=class extends i{validateInput(e){if(e==null)return u();let r=typeof e=="object"?e?.constructor?.name??"unknown":typeof e;return o(a(e,{kind:"typeMismatch",expected:"void (undefined/null)",received:r,msg:this.msg}))}},K=class extends i{validateInput(e){return u(e)}},L=class extends i{validateInput(e){return u(e)}},z=class extends i{validateInput(e){return o(a(e,{kind:"typeMismatch",expected:"never",received:typeof e,msg:this.msg}))}},Q=class extends i{constructor(r,n){let s,c;typeof n=="string"?s=n:typeof n=="object"&&(c=n);super(s);this.shape=r;this.objectMsg=c}strictMode=!1;objectMsg;validateInput(r){return i.validatePrimitive(r,"object",this.msg||this.objectMsg?.mismatch).andThen(n=>{if(n===null)return o(a(r,{kind:"typeMismatch",expected:"Non-null object",received:"null",msg:this.msg||this.objectMsg?.nullObject}));let s={},c=new Set(Object.keys(this.shape));for(let E of Object.keys(n)){let g=this.shape[E];if(g===void 0){if(this.strictMode){let pe=new Set(Object.keys(n)).difference(new Set(Object.keys(this.shape))).keys().toArray();return o(a(r,{kind:"unexpectedProperties",keys:pe,msg:this.msg||this.objectMsg?.unexpectedProperty}))}continue}let k=g.parse(n[E]);if(k.isErr())return o(a(r,{kind:"propertyValidation",property:E,detail:k.error.detail,msg:this.msg||this.objectMsg?.propertyValidation}));c.delete(E),s[E]=k.value}let h=c.keys().filter(E=>!i.isNullishSchema(this.shape[E])).toArray();return h.length>0?o(a(r,{kind:"missingProperties",keys:h,msg:this.msg||this.objectMsg?.missingProperty})):u(s)})}strict(){return this.strictMode=!0,this}pick(r){let n={};for(let s of Object.keys(r))r[s]&&(n[s]=this.shape[s]);return l.obj(n)}},W=class t extends i{constructor(r,n){let s,c;typeof n=="string"?s=n:typeof n=="object"&&(c=n);super(s);this.schemas=r;this.unionMsg=c}unionMsg;static getTypeFromSchemaName(r){switch(r){case"StringSchema":case"LiteralSchema":return"string";case"NumberSchema":return"number";case"BigintSchema":return"bigint";case"BooleanSchema":return"boolean";case"UndefinedSchema":return"undefined";case"SymbolSchema":return"symbol";default:return"object"}}validateInput(r){let n=[],s=!0;for(let c of this.schemas){let h=c.parse(r);if(h.isOk())return u(h.value);s=h.error.detail?.kind==="typeMismatch"&&s,n.push(h.error.detail)}return s?o(a(r,{kind:"typeMismatch",expected:this.schemas.map(c=>t.getTypeFromSchemaName(c.constructor.name)).join(" | "),received:typeof r,msg:this.msg||this.unionMsg?.mismatch})):o(a(r,{kind:"unionValidation",msg:this.msg||this.unionMsg?.unionValidation||"Input did not match any union member",details:n}))}},Z=class extends i{constructor(r,n){let s,c;typeof n=="string"?s=n:typeof n=="object"&&(c=n);super(s);this.schema=r;this.arrayMsg=c}arrayMsg;validateInput(r){if(!Array.isArray(r))return o(a(r,{kind:"typeMismatch",expected:"Array",received:"Non-array",msg:this.msg||this.arrayMsg?.mismatch}));for(let n=0;n{if("tag"in n)switch(n.tag){case"ok":return"value"in n?this.okSchema.parse(n.value).match(s=>u(u(s)),s=>o(a(r,{kind:"propertyValidation",property:"value",detail:s.detail}))):i.isNullishSchema(this.okSchema)?u(u()):o(a(r,{kind:"missingProperties",keys:["value"],msg:"If tag is set to 'ok', than result must contain a 'value' property"}));case"err":return"error"in n?this.errSchema.parse(n.error).match(s=>u(o(s)),s=>o(a(r,{kind:"propertyValidation",property:"error",detail:s.detail}))):i.isNullishSchema(this.errSchema)?u(o()):o(a(r,{kind:"missingProperties",keys:["error"],msg:"If tag is set to 'err', than result must contain a 'error' property"}));default:return o(a(r,{kind:"propertyValidation",property:"tag",detail:{kind:"typeMismatch",expected:"'ok' or 'err'",received:`'${n.tag}'`}}))}else return o(a(r,{kind:"missingProperties",keys:["tag"],msg:"Result must contain a tag property"}))})}},_=class extends i{constructor(r){super();this.schema=r}validateInput(r){return i.validatePrimitive(r,"object").andThen(n=>{if("tag"in n)switch(n.tag){case"some":return"value"in n?this.schema.parse(n.value).match(s=>u(f(s)),s=>o(a(r,{kind:"propertyValidation",property:"value",detail:s.detail}))):i.isNullishSchema(this.schema)?u(f()):o(a(r,{kind:"missingProperties",keys:["value"],msg:"If tag is set to 'some', than option must contain a 'value' property"}));case"none":return u(v);default:return o(a(r,{kind:"propertyValidation",property:"tag",detail:{kind:"typeMismatch",expected:"'some' or 'none'",received:`'${n.tag}'`}}))}else return o(a(r,{kind:"missingProperties",keys:["tag"],msg:"Option must contain a tag property"}))})}},l={string:t=>new N(t),literal:(t,e)=>new F(t,e),number:t=>new M(t),bigint:t=>new B(t),boolean:t=>new D(t),date:t=>new j(t),symbol:t=>new q(t),undefined:t=>new $(t),null:t=>new C(t),void:t=>new J(t),any:t=>new K(t),unknown:t=>new L(t),never:t=>new z(t),obj:(t,e)=>new Q(t,e),union:(t,e)=>new W(t,e),array:(t,e)=>new Z(t,e),optional:(t,e)=>new G(t,e),nullable:(t,e)=>new H(t,e),nullish:(t,e)=>new X(t,e),result:(t,e)=>new Y(t,e),option:t=>new _(t)};function p(t,e){return l.obj({type:l.literal(t),info:e??l.string()})}function d(t){return e=>({type:t.shape.type.literal,info:e})}var b=p("QueryExecutionError"),lr=d(b),de=p("NoAdminEntryError"),cr=d(de),me=p("FailedToReadFileError"),pr=d(me),he=p("InvalidSyntaxError"),dr=d(he),Ee=p("InvalidPathError"),mr=d(Ee),ee=p("AdminPasswordNotSetError"),hr=d(ee),A=p("RequestValidationError"),le=d(A),ye=p("ResponseValidationError"),ce=d(ye),P=p("FailedToParseRequestAsJSONError"),Er=d(P),U=p("TooManyRequestsError"),yr=d(U),re=p("UnauthorizedError"),fr=d(re),te=p("InvalidPasswordError"),Tr=d(te),ne=p("AdminPasswordAlreadySetError"),gr=d(ne),se=p("PasswordsMustMatchError"),vr=d(se),oe=p("CommandExecutionError"),Sr=d(oe),fe=p("DeviceDoesNotExistError"),xr=d(fe),Te=p("DeviceAlreadyBoundError"),Rr=d(Te),ge=p("DeviceNotBoundError"),wr=d(ge),ie=p("UsbipUnknownError"),Ur=d(ie),ve=p("NotFoundError"),kr=d(ve),Se=p("NotAllowedError"),Or=d(Se);var w=class{constructor(e,r,n){this.path=e;this.method=r;this.schema=n;this.pathSplitted=e.split("/"),this.paramIndexes=this.pathSplitted.reduce((s,c,h)=>(c.startsWith(":")&&(s[c.slice(1)]=h),s),{})}pathSplitted;paramIndexes;makeRequest(e,r){return this.schema.req.parse(e).toAsync().mapErr(n=>le(n.info)).andThenAsync(async n=>{let s=this.pathSplitted;for(let[g,k]of Object.entries(r))s[this.paramIndexes[g]]=k;let c=s.join("/");console.log(n);let E=await(await fetch(c,{method:this.method,headers:{"Content-Type":"application/json",Accept:"application/json; charset=utf-8"},body:JSON.stringify(n)})).json();return this.schema.res.parse(E).toAsync().map(g=>g).mapErr(g=>ce(g.info))})}makeSafeRequest(e,r){return this.makeRequest(e,r).mapErr(n=>{if(n.type==="RequestValidationError")throw"Failed to validate request";return n})}};var xe={req:l.obj({password:l.string()}),res:l.result(l.void(),l.union([ee,b,P,A,U,te]))},Br=new w("/login","POST",xe),Re={req:l.obj({password:l.string().min(10,"Password must be at least 10 characters long").regex(/^[a-zA-Z0-9]+$/,"Password must consist of lower or upper case latin letters and numbers"),passwordRepeat:l.string()}).addCheck(t=>{if(t.passwordRepeat!==t.password)return a(t,{kind:"general",msg:"Passwords must match"})}),res:l.result(l.void(),l.union([se,ne,b,P,A,U]))},Dr=new w("/setup","POST",Re),we={req:l.void(),res:l.result(l.void(),l.union([b,U,re,oe,ie]))},jr=new w("/api/updateDevices","POST",we),Ue={req:l.void(),res:l.result(l.obj({app:l.literal("Keyborg"),version:l.string()}),l.union([U]))},qr=new w("/version","POST",Ue);export{i as BaseSchema,y as Err,F as LiteralSchema,T as None,Q as ObjectSchema,m as Ok,_ as OptionSchema,R as ResultAsync,De as ResultFromJSON,Y as ResultSchema,V as SchemaValidationError,S as Some,N as StringSchema,a as createValidationError,o as err,x as errAsync,ue as flattenResult,Ce as fromNullableVal,Be as fromThrowable,ae as getMessageFromError,Br as loginApi,v as none,u as ok,I as okAsync,Dr as passwordSetupApi,f as some,jr as updateDevicesApi,qr as versionApi,l as z}; +var f=class t{constructor(e){this._promise=e;this._promise=e}static fromPromise(e,r){let n=e.then(s=>new h(s)).catch(s=>new S(r(s)));return new t(n)}static fromSafePromise(e){let r=e.then(n=>new h(n));return new t(r)}static fromThrowable(e,r){return(...n)=>t.fromPromise(e(n),s=>r?r(s):s)}static from(e){return t.fromPromise(new Promise(e),r=>r)}async unwrap(){let e=await this._promise;if(e.isErr())throw e.error;return e.value}async match(e,r){let n=await this._promise;return n.isErr()?r(n.error):e(n.value)}map(e){return new t(this._promise.then(r=>r.isErr()?new S(r.error):new h(e(r.value))))}mapAsync(e){return new t(this._promise.then(async r=>r.isErr()?v(r.error):new h(await e(r.value))))}mapErr(e){return new t(this._promise.then(r=>r.isErr()?new S(e(r.error)):new h(r.value)))}mapErrAsync(e){return new t(this._promise.then(async r=>r.isErr()?v(await e(r.error)):new h(r.value)))}andThen(e){return new t(this._promise.then(r=>r.isErr()?v(r.error):e(r.value).toAsync()))}andThenAsync(e){return new t(this._promise.then(r=>r.isErr()?v(r.error):e(r.value)))}nullableToOption(){return this.map(e=>e?T(e):E)}flatten(){return new t(this._promise.then(e=>e.flatten()))}flattenOption(e){return new t(this._promise.then(r=>r.flattenOption(e)))}flattenOptionOrDefault(e){return new t(this._promise.then(r=>r.flattenOptionOrDefault(e)))}matchOption(e,r){return new t(this._promise.then(n=>n.matchOption(e,r)))}matchOptionAndFlatten(e,r){return new t(this._promise.then(n=>n.matchOptionAndFlatten(e,r)))}then(e,r){return this._promise.then(e,r)}};function b(t){return new f(Promise.resolve(new h(t)))}function v(t){return new f(Promise.resolve(new S(t)))}var h=class t{constructor(e){this.value=e;this.value=e,Object.defineProperties(this,{tag:{writable:!1,enumerable:!1}})}tag="ok";isErr(){return!1}ifErr(e){return this}isErrOrNone(){return this.value instanceof x}isOk(){return!0}ifOk(e){return e(this.value),this}unwrap(){return this.value}unwrapOr(e){return this.value}unwrapOrElse(e){return this.value}unwrapErr(){return E}match(e,r){return e(this.value)}map(e){let r=e(this.value);return new t(r)}mapAsync(e){return f.fromSafePromise(e(this.value))}mapOption(e){return this.value instanceof x||this.value instanceof w?u(this.value.map(e)):u(T(e(this.value)))}andThen(e){return e(this.value)}andThenAsync(e){return e(this.value)}mapErr(e){return u(this.value)}mapErrAsync(e){return b(this.value)}flatten(){return ce(this)}flattenOption(e){return this.value instanceof x||this.value instanceof w?this.value.okOrElse(e):new t(this.value)}flattenOptionOr(e){return this.value instanceof x||this.value instanceof w?this.value.unwrapOr(e):new t(this.value)}matchOption(e,r){return this.value instanceof x||this.value instanceof w?u(this.value.match(e,r)):u(e(this.value))}toNullable(){return this.value}toAsync(){return b(this.value)}void(){return u()}toJSON(){return{tag:"ok",value:this.value}}},S=class t{constructor(e){this.error=e;this.error=e,Object.defineProperties(this,{tag:{writable:!1,configurable:!1,enumerable:!1}})}tag="err";isErr(){return!0}ifErr(e){return e(this.error),this}isOk(){return!1}ifOk(e){return this}isErrOrNone(){return!0}unwrap(){let e=`Tried to unwrap error: ${ue(this.error)}`;throw new Error(e)}unwrapOr(e){return e}unwrapOrElse(e){return e()}unwrapErr(){return T(this.error)}match(e,r){return r(this.error)}map(e){return new t(this.error)}mapAsync(e){return v(this.error)}mapErr(e){return new t(e(this.error))}mapErrAsync(e){return f.fromPromise(new Promise(()=>{throw""}),()=>e(this.error))}mapOption(e){return o(this.error)}andThen(e){return new t(this.error)}andThenAsync(e){return new t(this.error).toAsync()}flatten(){return ce(this)}flattenOption(e){return new t(this.error)}flattenOptionOr(e){return new t(this.error)}matchOption(e,r){return o(this.error)}toNullable(){return null}toAsync(){return v(this.error)}void(){return o(this.error)}toJSON(){return{tag:"err",error:this.error}}};function u(t){return new h(t)}function o(t){return new S(t)}function Ke(t,e){return(...r)=>{try{let n=t(...r);return u(n)}catch(n){return o(e?e(n):n)}}}function ue(t){if(t instanceof Error)return t.message?t.message:"code"in t&&typeof t.code=="string"?t.code:"An unknown error occurred";if(typeof t=="string")return t;if(typeof t=="object"&&t!==null&&"message"in t){let e=t;return typeof e.message=="string"?e.message:String(e.message)}return"An unknown error occurred"}function ce(t){let e=t;for(;e instanceof h&&(e.value instanceof h||e.value instanceof S);)e=e.value;return e}var O=class extends Error{constructor(e){super(`Failed to parse ${e} as result`)}};function We(t){let e;if(typeof t=="string")try{e=JSON.parse(t)}catch(n){return o(new O(ue(n)))}else e=t;if(typeof e!="object"||e===null)return o(new O("Expected an object but received type ${typeof data}."));let r=e;if("tag"in r)switch(r.tag){case"ok":{if("value"in r)return u(r.value);break}case"err":{if("error"in r)return o(r.error);break}}return o(new O("Object does not contain 'tag' and 'value' or 'error' property"))}var w=class t{constructor(e){this.value=e;Object.defineProperties(this,{tag:{writable:!1,enumerable:!1}})}tag="some";isSome(){return!0}ifSome(e){return e(this.value),this}isNone(){return!1}ifNone(e){return this}map(e){return new t(e(this.value))}flatMap(e){return e(this.value)}andThen(e){return e(this.value)}unwrap(){return this.value}unwrapOr(e){return this.value}unwrapOrElse(e){return this.value}or(e){return this}orElse(e){return this}match(e,r){return e(this.value)}toString(){return`Some(${this.value})`}toJSON(){return{tag:"some",value:this.value}}toNullable(){return this.value}toBoolean(){return!0}okOrElse(e){return u(this.value)}},x=class t{tag="none";constructor(){Object.defineProperties(this,{tag:{writable:!1,enumerable:!1}})}isSome(){return!1}ifSome(e){return this}isNone(){return!0}ifNone(e){return e(),this}map(e){return new t}andThen(e){return E}flatMap(e){return new t}unwrap(){throw new Error("Tried to unwrap a non-existent value")}unwrapOr(e){return e}unwrapOrElse(e){return e()}or(e){return e}orElse(e){return e()}match(e,r){return r()}toString(){return"None"}toJSON(){return{tag:"none"}}toNullable(){return null}toBoolean(){return!1}okOrElse(e){return o(e())}};function T(t){return new w(t)}var E=new x;function Xe(t){return t?T(t):E}var N=class t extends Error{constructor(r,n){super(t.getBestMsg(n)||"Schema validation error");this.input=r;this.detail=n;this.name="SchemaValidationError"}type="SchemaValiationError";format(){return{input:this.input,error:t.formatDetail(this.detail)}}get info(){return t.getBestMsg(this.detail)}static getBestMsg(r){switch(r.kind){case"typeMismatch":case"unexpectedProperties":case"missingProperties":case"general":case"unionValidation":return t.formatMsg(r);case"propertyValidation":case"arrayElement":return r.msg||t.getBestMsg(r.detail);default:return"Unknown error"}}static formatDetail(r){switch(r.kind){case"general":case"typeMismatch":return t.formatMsg(r);case"propertyValidation":return{[r.property]:r.msg||this.formatDetail(r.detail)};case"unexpectedProperties":case"missingProperties":{let n=r.msg||(r.kind==="unexpectedProperties"?"Property is not allowed in a strict schema object":"Property is required, but missing");return r.keys.reduce((s,c)=>(s[c]=n,s),{})}case"arrayElement":{let n={};return r.msg&&(n.msg=r.msg),n[`index_${r.index}`]=this.formatDetail(r.detail),n}case"unionValidation":{let n=r.details?.map(s=>this.formatDetail(s));return r.msg&&n.unshift("Msg: "+r.msg),n}default:return"Unknown error type"}}static formatMsg(r){if(r.msg||r.kind==="general")return r.msg||"Unknown error";switch(r.kind){case"typeMismatch":return`Expected ${r.expected}, but received ${r.received}`;case"unexpectedProperties":return`Properties not allowed: ${r.keys.join(", ")}`;case"missingProperties":return`Missing required properties: ${r.keys.join(", ")}`;case"unionValidation":return"Input did not match any union member";default:return"Unknown error"}}};function a(t,e){return new N(t,e)}var i=class{constructor(e){this.msg=e}checks=[];parse(e){return this.validateInput(e).andThen(r=>this.applyValidationChecks(r))}static validatePrimitive(e,r,n){let s=typeof e;return s===r?u(e):o(a(e,{kind:"typeMismatch",expected:r,received:s,msg:n}))}addCheck(e){return this.checks.push(e),this}applyValidationChecks(e){for(let r of this.checks){let n=r(e);if(n)return o(n)}return u(e)}static isNullishSchema(e){return e.parse(null).isOk()||e.parse(void 0).isOk()}},V=class t extends i{static emailRegex=/^(([^<>()[\]\.,;:\s@\"]+(\.[^<>()[\]\.,;:\s@\"]+)*)|(\".+\"))@(([^<>()[\]\.,;:\s@\"]+\.)+[^<>()[\]\.,;:\s@\"]{2,})$/i;static ipRegex=/^(25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)\.(25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)\.(25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)\.(25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)$/;validateInput(e){return i.validatePrimitive(e,"string",this.msg)}max(e,r){return this.addCheck(n=>{if(n.length>e)return a(n,{kind:"general",msg:r||`String must be at most ${e} characters long`})})}min(e,r){return this.addCheck(n=>{if(n.length{if(!e.test(n))return a(n,{kind:"general",msg:r||`String must match pattern ${String(e)}`})})}email(e){return this.regex(t.emailRegex,e||"String must be a valid email address")}ip(e){return this.regex(t.ipRegex,e||"String must be a valid ip address")}},M=class extends i{constructor(r,n){super(n);this.literal=r}validateInput(r){return i.validatePrimitive(r,"string",this.msg).andThen(n=>n===this.literal?u(n):o(a(r,{kind:"typeMismatch",expected:this.literal,received:n,msg:this.msg})))}},F=class extends i{validateInput(e){return i.validatePrimitive(e,"number",this.msg)}gt(e,r){return this.addCheck(n=>{if(n<=e)return a(n,{kind:"general",msg:r||`Number must be greater than ${e}`})})}gte(e,r){return this.addCheck(n=>{if(n{if(n>=e)return a(n,{kind:"general",msg:r||`Number must be less than ${e}`})})}lte(e,r){return this.addCheck(n=>{if(n>e)return a(n,{kind:"general",msg:r||`Number must be less than or equal to ${e}`})})}int(e){return this.addCheck(r=>{if(!Number.isInteger(r))return a(r,{kind:"general",msg:e||"Number must be an integer"})})}positive(e){return this.gt(0,e||"Number must be positive")}nonnegative(e){return this.gte(0,e||"Number must be nonnegative")}negative(e){return this.lt(0,e||"Number must be negative")}nonpositive(e){return this.lte(0,e||"Number must be nonpositive")}finite(e){return this.addCheck(r=>{if(!Number.isFinite(r))return a(r,{kind:"general",msg:e||"Number must be an integer"})})}safe(e){return this.addCheck(r=>{if(!Number.isSafeInteger(r))return a(r,{kind:"general",msg:e||"Number must be an integer"})})}multipleOf(e,r){return this.addCheck(n=>{if(n%e!==0)return a(n,{kind:"general",msg:r||`Number must be a multiple of ${e}`})})}},B=class extends i{validateInput(e){return i.validatePrimitive(e,"bigint",this.msg)}},j=class extends i{validateInput(e){return i.validatePrimitive(e,"boolean",this.msg)}},D=class extends i{validateInput(e){return i.validatePrimitive(e,"object",this.msg).andThen(r=>{if(r instanceof Date)return u(r);let n=r?.constructor?.name??"unknown";return o(a(e,{kind:"typeMismatch",expected:"Date instance",received:n,msg:this.msg}))})}},C=class extends i{validateInput(e){return i.validatePrimitive(e,"symbol",this.msg)}},q=class extends i{validateInput(e){return i.validatePrimitive(e,"undefined",this.msg)}},$=class extends i{validateInput(e){if(e===null)return u(e);let r=typeof e=="object"?e?.constructor?.name??"unknown":typeof e;return o(a(e,{kind:"typeMismatch",expected:"null",received:r,msg:this.msg}))}},L=class extends i{validateInput(e){if(e==null)return u();let r=typeof e=="object"?e?.constructor?.name??"unknown":typeof e;return o(a(e,{kind:"typeMismatch",expected:"void (undefined/null)",received:r,msg:this.msg}))}},J=class extends i{validateInput(e){return u(e)}},K=class extends i{validateInput(e){return u(e)}},W=class extends i{validateInput(e){return o(a(e,{kind:"typeMismatch",expected:"never",received:typeof e,msg:this.msg}))}},z=class extends i{constructor(r,n){let s,c;typeof n=="string"?s=n:typeof n=="object"&&(c=n);super(s);this.shape=r;this.objectMsg=c}strictMode=!1;objectMsg;validateInput(r){return i.validatePrimitive(r,"object",this.msg||this.objectMsg?.mismatch).andThen(n=>{if(n===null)return o(a(r,{kind:"typeMismatch",expected:"Non-null object",received:"null",msg:this.msg||this.objectMsg?.nullObject}));let s={},c=new Set(Object.keys(this.shape));for(let y of Object.keys(n)){let g=this.shape[y];if(g===void 0){if(this.strictMode){let me=new Set(Object.keys(n)).difference(new Set(Object.keys(this.shape))).keys().toArray();return o(a(r,{kind:"unexpectedProperties",keys:me,msg:this.msg||this.objectMsg?.unexpectedProperty}))}continue}let k=g.parse(n[y]);if(k.isErr())return o(a(r,{kind:"propertyValidation",property:y,detail:k.error.detail,msg:this.msg||this.objectMsg?.propertyValidation}));c.delete(y),s[y]=k.value}let m=c.keys().filter(y=>!i.isNullishSchema(this.shape[y])).toArray();return m.length>0?o(a(r,{kind:"missingProperties",keys:m,msg:this.msg||this.objectMsg?.missingProperty})):u(s)})}strict(){return this.strictMode=!0,this}pick(r){let n={};for(let s of Object.keys(r))r[s]&&(n[s]=this.shape[s]);return l.obj(n)}},G=class t extends i{constructor(r,n){let s,c;typeof n=="string"?s=n:typeof n=="object"&&(c=n);super(s);this.schemas=r;this.unionMsg=c}unionMsg;static getTypeFromSchemaName(r){switch(r){case"StringSchema":case"LiteralSchema":return"string";case"NumberSchema":return"number";case"BigintSchema":return"bigint";case"BooleanSchema":return"boolean";case"UndefinedSchema":return"undefined";case"SymbolSchema":return"symbol";default:return"object"}}validateInput(r){let n=[],s=!0;for(let c of this.schemas){let m=c.parse(r);if(m.isOk())return u(m.value);s=m.error.detail?.kind==="typeMismatch"&&s,n.push(m.error.detail)}return s?o(a(r,{kind:"typeMismatch",expected:this.schemas.map(c=>t.getTypeFromSchemaName(c.constructor.name)).join(" | "),received:typeof r,msg:this.msg||this.unionMsg?.mismatch})):o(a(r,{kind:"unionValidation",msg:this.msg||this.unionMsg?.unionValidation||"Input did not match any union member",details:n}))}},Q=class extends i{constructor(r,n){let s,c;typeof n=="string"?s=n:typeof n=="object"&&(c=n);super(s);this.schema=r;this.arrayMsg=c}arrayMsg;validateInput(r){if(!Array.isArray(r))return o(a(r,{kind:"typeMismatch",expected:"Array",received:"Non-array",msg:this.msg||this.arrayMsg?.mismatch}));for(let n=0;n{if("tag"in n)switch(n.tag){case"ok":return"value"in n?this.okSchema.parse(n.value).match(s=>u(u(s)),s=>o(a(r,{kind:"propertyValidation",property:"value",detail:s.detail}))):i.isNullishSchema(this.okSchema)?u(u()):o(a(r,{kind:"missingProperties",keys:["value"],msg:"If tag is set to 'ok', than result must contain a 'value' property"}));case"err":return"error"in n?this.errSchema.parse(n.error).match(s=>u(o(s)),s=>o(a(r,{kind:"propertyValidation",property:"error",detail:s.detail}))):i.isNullishSchema(this.errSchema)?u(o()):o(a(r,{kind:"missingProperties",keys:["error"],msg:"If tag is set to 'err', than result must contain a 'error' property"}));default:return o(a(r,{kind:"propertyValidation",property:"tag",detail:{kind:"typeMismatch",expected:"'ok' or 'err'",received:`'${n.tag}'`}}))}else return o(a(r,{kind:"missingProperties",keys:["tag"],msg:"Result must contain a tag property"}))})}},_=class extends i{constructor(r){super();this.schema=r}validateInput(r){return i.validatePrimitive(r,"object").andThen(n=>{if("tag"in n)switch(n.tag){case"some":return"value"in n?this.schema.parse(n.value).match(s=>u(T(s)),s=>o(a(r,{kind:"propertyValidation",property:"value",detail:s.detail}))):i.isNullishSchema(this.schema)?u(T()):o(a(r,{kind:"missingProperties",keys:["value"],msg:"If tag is set to 'some', than option must contain a 'value' property"}));case"none":return u(E);default:return o(a(r,{kind:"propertyValidation",property:"tag",detail:{kind:"typeMismatch",expected:"'some' or 'none'",received:`'${n.tag}'`}}))}else return o(a(r,{kind:"missingProperties",keys:["tag"],msg:"Option must contain a tag property"}))})}},ee=class extends i{constructor(r,n){super(n);this.entries=r}validateInput(r){for(let n of this.entries)if(r===n)return u(r);return o(a(r,{kind:"typeMismatch",expected:this.entries.map(n=>typeof n=="string"?`"${n}"`:n).join(" | "),received:String(r),msg:this.msg}))}},l={string:t=>new V(t),literal:(t,e)=>new M(t,e),number:t=>new F(t),bigint:t=>new B(t),boolean:t=>new j(t),date:t=>new D(t),symbol:t=>new C(t),undefined:t=>new q(t),null:t=>new $(t),void:t=>new L(t),any:t=>new J(t),unknown:t=>new K(t),never:t=>new W(t),obj:(t,e)=>new z(t,e),union:(t,e)=>new G(t,e),array:(t,e)=>new Q(t,e),optional:(t,e)=>new X(t,e),nullable:(t,e)=>new H(t,e),nullish:(t,e)=>new Z(t,e),result:(t,e)=>new Y(t,e),option:t=>new _(t),enum:(t,e)=>new ee(t,e)};function p(t,e){return l.obj({type:l.literal(t),info:e??l.string()})}function d(t){return e=>({type:t.shape.type.literal,info:e})}var A=p("QueryExecutionError"),fr=d(A),he=p("NoAdminEntryError"),Tr=d(he),Ee=p("FailedToReadFileError"),gr=d(Ee),ye=p("InvalidSyntaxError"),vr=d(ye),fe=p("InvalidPathError"),Sr=d(fe),re=p("AdminPasswordNotSetError"),xr=d(re),P=p("RequestValidationError"),le=d(P),Te=p("ResponseValidationError"),pe=d(Te),I=p("FailedToParseRequestAsJSONError"),wr=d(I),U=p("TooManyRequestsError"),Rr=d(U),te=p("UnauthorizedError"),Ur=d(te),ne=p("InvalidPasswordError"),kr=d(ne),se=p("AdminPasswordAlreadySetError"),br=d(se),oe=p("PasswordsMustMatchError"),Or=d(oe),ie=p("CommandExecutionError"),Ar=d(ie),ge=p("DeviceDoesNotExistError"),Pr=d(ge),ve=p("DeviceAlreadyBoundError"),Ir=d(ve),Se=p("DeviceNotBoundError"),Nr=d(Se),ae=p("UsbipUnknownError"),Vr=d(ae),xe=p("NotFoundError"),Mr=d(xe),we=p("NotAllowedError"),Fr=d(we),Re=p("tooManyConnectionError"),Br=d(Re);var R=class{constructor(e,r,n){this.path=e;this.method=r;this.schema=n;this.pathSplitted=e.split("/"),this.paramIndexes=this.pathSplitted.reduce((s,c,m)=>(c.startsWith(":")&&(s[c.slice(1)]=m),s),{})}pathSplitted;paramIndexes;makeRequest(e,r){return this.schema.req.parse(e).toAsync().mapErr(n=>le(n.info)).andThenAsync(async n=>{let s=this.pathSplitted;for(let[g,k]of Object.entries(r))s[this.paramIndexes[g]]=k;let c=s.join("/");console.log(n);let y=await(await fetch(c,{method:this.method,headers:{"Content-Type":"application/json",Accept:"application/json; charset=utf-8"},body:JSON.stringify(n)})).json();return this.schema.res.parse(y).toAsync().map(g=>g).mapErr(g=>pe(g.info))})}makeSafeRequest(e,r){return this.makeRequest(e,r).mapErr(n=>{if(n.type==="RequestValidationError")throw"Failed to validate request";return n})}};var Ue={req:l.obj({password:l.string()}),res:l.result(l.void(),l.union([re,A,I,P,U,ne]))},Wr=new R("/login","POST",Ue),ke={req:l.obj({password:l.string().min(10,"Password must be at least 10 characters long").regex(/^[a-zA-Z0-9]+$/,"Password must consist of lower or upper case latin letters and numbers"),passwordRepeat:l.string()}).addCheck(t=>{if(t.passwordRepeat!==t.password)return a(t,{kind:"general",msg:"Passwords must match"})}),res:l.result(l.void(),l.union([oe,se,A,I,P,U]))},zr=new R("/setup","POST",ke),be={req:l.void(),res:l.result(l.void(),l.union([A,U,te,ie,ae]))},Gr=new R("/api/devices/detect","GET",be),Oe={req:l.void(),res:l.result(l.obj({app:l.literal("Keyborg"),version:l.string()}),l.union([U]))},Qr=new R("/api/version","GET",Oe);var Ae=2e3,Pe=1e3,Ie=15e3,Ne=5,Ve=5,de=class{constructor(e,r=Ae,n=Pe){this.url=e;this.timeout=r;this.pingInterval=n}_ws=E;get ws(){return this._ws}set ws(e){this._ws=e,e.isSome()?(e.value.addEventListener("close",this.handleWebSocketClose),e.value.addEventListener("error",this.handleWebSocketError),e.value.addEventListener("message",this.onMessage),this.onConnectSucc(),this.pingAndWait()):this.onDisconnect()}handleWebSocketClose=()=>{this._ws=E,this.connect()};handleWebSocketError=()=>{this._ws=E,this.connect()};pingAndWait=async()=>{(await this.ping()).isErr()&&(clearTimeout(this.pingTimer),this.ws=E),this.pingTimer=setTimeout(this.pingAndWait,Ie)};pingTimer;onConnectInit;onConnectSucc;onConnectFail;onDisconnect;onMessage;isConnecting=!1;ping(){if(this.ws.isNone())return v();let e=this.ws.value;return f.from((r,n)=>{let s,c=g=>{g.data==="pong"&&(e.removeEventListener("message",c),clearTimeout(s),r())};e.addEventListener("message",c);let m=0,y=()=>{++m>Ne&&n(),e.send("ping"),s=setTimeout(y,this.pingInterval)};y()})}createWebSocketConnection(){let e=new WebSocket(this.url);return f.from((r,n)=>{let s=()=>{n()};e.addEventListener("open",()=>{e.removeEventListener("error",s),r(e)}),e.addEventListener("error",s)})}connect(){return this.isConnecting?v():f.fromSafePromise(this.ping().match(()=>b(),()=>{this.isConnecting=!0,this.onConnectInit(),this.tryReconnect()})).flatten()}tryReconnect(){return f.from((e,r)=>{let n=0,s,c=async()=>{if(console.log(`attempt ${n+1}`),++n>=Ve){this.onConnectFail(),this.isConnecting=!1,console.error("Failed to connect"),r();return}let m=await this.createWebSocketConnection();m.isOk()?(this.ws=T(m.value),clearTimeout(s),this.isConnecting=!1,e()):s=setTimeout(c,this.timeout)};c()})}};export{i as BaseSchema,ee as EnumSchema,S as Err,M as LiteralSchema,x as None,z as ObjectSchema,h as Ok,_ as OptionSchema,f as ResultAsync,We as ResultFromJSON,Y as ResultSchema,N as SchemaValidationError,w as Some,V as StringSchema,de as WebSocketWrapper,a as createValidationError,o as err,v as errAsync,ce as flattenResult,Xe as fromNullableVal,Ke as fromThrowable,ue as getMessageFromError,Wr as loginApi,E as none,u as ok,b as okAsync,zr as passwordSetupApi,T as some,Gr as updateDevicesApi,Qr as versionApi,l as z}; diff --git a/server/src/js/index.ts b/server/src/js/index.ts index 064a1c1..5223436 100644 --- a/server/src/js/index.ts +++ b/server/src/js/index.ts @@ -1,103 +1,138 @@ -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); -}); +import { WebSocketWrapper } from "./shared.bundle.ts"; +//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 reconBtn = document.getElementById("reconnect") as HTMLButtonElement; +const infoDiv = document.getElementById("info") as HTMLDivElement; -pingBtn.onclick = () => { - ws.send("ping"); +pingBtn.onclick = async () => { + 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(); diff --git a/server/src/js/login.ts b/server/src/js/login.ts index 4919719..7d2ae12 100644 --- a/server/src/js/login.ts +++ b/server/src/js/login.ts @@ -21,3 +21,5 @@ form.addEventListener("submit", async (e) => { window.location.href = "/"; } }); + +const ws = new WebSocket("api/admin/ws"); diff --git a/server/src/js/shared.bundle.ts b/server/src/js/shared.bundle.ts index 8156480..513c346 100644 --- a/server/src/js/shared.bundle.ts +++ b/server/src/js/shared.bundle.ts @@ -3,3 +3,4 @@ export * from "@shared/utils/result.ts"; export * from "@shared/utils/resultasync.ts"; export * from "@shared/utils/validator.ts"; export * from "../../api.ts"; +export * from "@src/lib/wsClient.ts"; diff --git a/server/src/lib/devices.ts b/server/src/lib/devices.ts index 48795ad..a28ee68 100644 --- a/server/src/lib/devices.ts +++ b/server/src/lib/devices.ts @@ -13,6 +13,8 @@ import { type FailedToAccessDevices = CommandExecutionError | UsbipUnknownError; +const DEFAULT_STATE = 0; + class Devices { private devices: Result< Map, @@ -34,9 +36,20 @@ class Devices { ); } - for (const key of Object.keys(update)) { - device[key as keyof typeof update] = - update[key as keyof typeof update] || none; + for (const key of Object.keys(update) as (keyof DeviceMutables)[]) { + if (update[key] !== undefined) { + 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(); @@ -89,6 +102,7 @@ class Devices { usbid: d.usbid, vendor: d.vendor, name: d.name, + status: DEFAULT_STATE, displayName: none, description: none, connectedAt: new Date(), @@ -107,12 +121,14 @@ export const deviceSchema = z.obj({ usbid: z.option(z.string()), vendor: 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()), description: z.option(z.string()), connectedAt: z.date(), }).strict(); export const deviceMutablesSchema = deviceSchema.pick({ + status: true, displayName: true, description: true, }); diff --git a/server/src/lib/errors.ts b/server/src/lib/errors.ts index a150b44..438ebef 100644 --- a/server/src/lib/errors.ts +++ b/server/src/lib/errors.ts @@ -164,3 +164,13 @@ export type NotFoundError = InferSchemaType; export const notAllowedErrorSchema = defineError("NotAllowedError"); export const notAllowedError = createErrorFactory(notAllowedErrorSchema); export type NotAllowedError = InferSchemaType; + +export const tooManyConnectionErrorSchema = defineError( + "tooManyConnectionError", +); +export const tooManyConnectionError = createErrorFactory( + tooManyConnectionErrorSchema, +); +export type TooManyConnectionError = InferSchemaType< + typeof tooManyConnectionErrorSchema +>; diff --git a/server/src/lib/router.ts b/server/src/lib/router.ts index efef64b..7702529 100644 --- a/server/src/lib/router.ts +++ b/server/src/lib/router.ts @@ -1,5 +1,4 @@ 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"; @@ -7,9 +6,9 @@ import { notAllowedError, notFoundError } from "@src/lib/errors.ts"; import { err } from "@shared/utils/result.ts"; type RequestHandler< - S extends string, - ReqSchema extends Schema = Schema, - ResSchema extends Schema = Schema, + S extends string = string, + ReqSchema extends Schema = Schema, + ResSchema extends Schema = Schema, > = (c: Context) => Promise | Response; export type Middleware = ( @@ -19,24 +18,29 @@ export type Middleware = ( type MethodHandler = { handler: RequestHandler; - schema?: { req: Schema; res: Schema }; + schema?: { req: Schema; res: Schema }; }; -type MethodHandlers = Partial< +type MethodHandlers = Partial< Record> >; const DEFAULT_NOT_FOUND_HANDLER = - (() => new Response("404 Not found", { status: 404 })) as RequestHandler< - any - >; + (() => new Response("404 Not found", { status: 404 })) as RequestHandler; + +const DEFAULT_NOT_ALLOWED_HANDLER = + ((c) => + c.json(err(notAllowedError("405 Not allowed")), { + status: 405, + })) as RequestHandler; class HttpRouter { - public readonly routerTree = new RouterTree>(); + public readonly routerTree = new RouterTree(); public pathTransformer?: (path: string) => string; private middlewares: Middleware[] = []; - public defaultNotFoundHandler: RequestHandler = - DEFAULT_NOT_FOUND_HANDLER; + public notFoundHandler: RequestHandler = DEFAULT_NOT_FOUND_HANDLER; + public methodNotAllowedHandler: RequestHandler = + DEFAULT_NOT_ALLOWED_HANDLER; public setPathTransformer(transformer: (path: string) => string) { this.pathTransformer = transformer; @@ -50,8 +54,8 @@ class HttpRouter { public add< S extends string, - ReqSchema extends Schema = Schema, - ResSchema extends Schema = Schema, + ReqSchema extends Schema = Schema, + ResSchema extends Schema = Schema, >( path: S, method: string, @@ -60,8 +64,8 @@ class HttpRouter { ): HttpRouter; public add< S extends string, - ReqSchema extends Schema = Schema, - ResSchema extends Schema = Schema, + ReqSchema extends Schema = Schema, + ResSchema extends Schema = Schema, >( path: S[], method: string, @@ -72,7 +76,7 @@ class HttpRouter { path: string | string[], method: string, handler: RequestHandler, - schema?: { req: Schema; res: Schema }, + schema?: { req: Schema; res: Schema }, ): HttpRouter { const paths = Array.isArray(path) ? path : [path]; @@ -82,7 +86,7 @@ class HttpRouter { existingHandlers[method] = { handler, schema }; }, () => { - const newHandlers: MethodHandlers = {}; + const newHandlers: MethodHandlers = {}; newHandlers[method] = { handler, schema }; this.routerTree.add(p, newHandlers); }, @@ -98,11 +102,11 @@ class HttpRouter { ): HttpRouter; public get( path: S[], - handler: RequestHandler, + handler: RequestHandler, ): HttpRouter; public get( path: string | string[], - handler: RequestHandler, + handler: RequestHandler, ): HttpRouter { if (Array.isArray(path)) { return this.add(path, "GET", handler); @@ -114,9 +118,9 @@ class HttpRouter { path: S, handler: RequestHandler, ): HttpRouter; - public post( + public post( path: string[], - handler: RequestHandler, + handler: RequestHandler, ): HttpRouter; public post( path: string | string[], @@ -130,8 +134,8 @@ class HttpRouter { public api< Path extends string, - ReqSchema extends Schema, - ResSchema extends Schema, + ReqSchema extends Schema, + ResSchema extends Schema, >( api: Api, handler: RequestHandler, @@ -148,120 +152,77 @@ class HttpRouter { ? this.pathTransformer(ctx.path) : ctx.path; - let routeParams: Record = {}; + const { handler, params } = this.resolveRoute(ctx, path); + ctx = ctx.setParams(params); - const handler = this.routerTree - .find(path) - .andThen((match) => { - const { value: methodHandler, params: params } = match; - routeParams = params; - - 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, - ); - } - return none; - } - if (route.schema) { - ctx = ctx.setSchema(route.schema); - } - const handler = route.handler; - - return some(handler); - }) - .unwrapOrElse(() => { - switch (ctx.preferredType.unwrapOr("other")) { - case "json": - return (() => - ctx.json(err(notFoundError("404 Not found")), { - status: 404, - })) as RequestHandler; - case "html": - return (() => - ctx.html("404 Not found", { - status: 404, - })) as RequestHandler; - case "other": - return DEFAULT_NOT_FOUND_HANDLER; - } - }); - - const res = (await this.executeMiddlewareChain( - this.middlewares, - handler, - 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; + const res = + (await this.runMiddlewares(this.middlewares, handler, ctx)).res; + return req.method === "HEAD" ? this.removeBodyFromResponse(res) : res; } private resolveRoute( ctx: Context, - req: Request, path: string, - ): { handler: RequestHandler; params: Record } { + ): { handler: RequestHandler; params: Record } { const routeOption = this.routerTree.find(path); + + if (routeOption.isSome()) { + const { value: methodHandlers, params } = routeOption.value; + let route = methodHandlers[ctx.req.method]; + + 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 }; + } + } + if (route) { + if (route.schema) { + ctx = ctx.setSchema(route.schema); + } + return { handler: route.handler, params }; + } + } + return { handler: this.notFoundHandler, params: {} }; } - private async executeMiddlewareChain( + private removeBodyFromResponse(res: Response): Response { + const headers = new Headers(res.headers); + headers.set("Content-Length", "0"); + return new Response(null, { + headers, + status: res.status, + statusText: res.statusText, + }); + } + + private async runMiddlewares( middlewares: Middleware[], - handler: RequestHandler, - c: Context, + handler: RequestHandler, + ctx: Context, ) { - let currentIndex = -1; - const dispatch = async (index: number): Promise => { - currentIndex = index; - if (index < middlewares.length) { const middleware = middlewares[index]; - - const result = await middleware(c, () => dispatch(index + 1)); - + const result = await middleware(ctx, () => dispatch(index + 1)); if (result !== undefined) { - c.res = await Promise.resolve(result); + ctx.res = await Promise.resolve(result); } } else { - const res = await handler(c); - c.res = res; + ctx.res = await handler(ctx); } }; - 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 }), + ); } } diff --git a/server/src/lib/websocket.ts b/server/src/lib/websocket.ts new file mode 100644 index 0000000..f388400 --- /dev/null +++ b/server/src/lib/websocket.ts @@ -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 = new Map(); + private userSockets: Map = new Map(); + private connectionsCounter: number = 0; + + public addAdminClient( + token: string, + socket: WebSocket, + ): Result { + 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")); + } + } +} diff --git a/server/src/lib/wsClient.ts b/server/src/lib/wsClient.ts new file mode 100644 index 0000000..be403f5 --- /dev/null +++ b/server/src/lib/wsClient.ts @@ -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 = Schema, + S extends Schema = Schema, +> { + private _ws: Option = none; + get ws(): Option { + return this._ws; + } + set ws(ws: Option) { + 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) => 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 { + if (this.ws.isNone()) { + return errAsync(); + } + + const ws = this.ws.value; + + return ResultAsync.from((resolve, reject) => { + let timer: number; + + const listener = (e: MessageEvent) => { + 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 { + 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 { + if (this.isConnecting) { + return errAsync(); + } + + return ResultAsync.fromSafePromise( + this.ping().match( + () => okAsync(), + () => { + this.isConnecting = true; + this.onConnectInit!(); + this.tryReconnect(); + }, + ), + ).flatten(); + } + + private tryReconnect(): ResultAsync { + 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): ResultAsync { + if (this.ws.isNone()) { + return errAsync(); + } + } +} + +const sendSchema = z.obj({ + id: z.number(), + kind: z.enum(["up"]), +}); diff --git a/server/src/views/index.html b/server/src/views/index.html index 55f6df4..58651ef 100644 --- a/server/src/views/index.html +++ b/server/src/views/index.html @@ -1,13 +1,10 @@ <% layout("./layouts/layout.html") %> - devices: - <% it.devices.forEach(function(device){ %> -
- name: <%= device.name %> | <%= device.vendor %> - busid: <%= device.busid %> -
- <%= device.busid %> - <% }) %> +
+
- +
- + + + + diff --git a/server/test.db b/server/test.db index 7d2fd6d..9257314 100644 Binary files a/server/test.db and b/server/test.db differ diff --git a/server/views/index.html b/server/views/index.html index ecdd061..e0d2bf8 100644 --- a/server/views/index.html +++ b/server/views/index.html @@ -1 +1 @@ -<% layout("./layouts/layout.html") %> devices: <% it.devices.forEach(function(device){ %>
name: <%= device.name %> | <%= device.vendor %> busid: <%= device.busid %>
<%= device.busid %> <% }) %> \ No newline at end of file +<% layout("./layouts/layout.html") %>
\ No newline at end of file diff --git a/shared/utils/resultasync.ts b/shared/utils/resultasync.ts index 8ecb43c..b4dc764 100644 --- a/shared/utils/resultasync.ts +++ b/shared/utils/resultasync.ts @@ -62,6 +62,18 @@ export class ResultAsync implements PromiseLike> { }; } + static from< + T = void, + E = void, + >( + executor: ( + resolve: (value: T | PromiseLike) => void, + reject: (reason?: E) => void, + ) => void, + ): ResultAsync { + return ResultAsync.fromPromise(new Promise(executor), (e) => e as E); + } + async unwrap(): Promise { const result = await this._promise; if (result.isErr()) { diff --git a/shared/utils/validator.ts b/shared/utils/validator.ts index f5f26d9..df81103 100644 --- a/shared/utils/validator.ts +++ b/shared/utils/validator.ts @@ -1178,6 +1178,34 @@ export class OptionSchema> } } +export class EnumSchema + extends BaseSchema { + constructor( + public readonly entries: E, + msg?: string, + ) { + super(msg); + } + + protected override validateInput( + input: unknown, + ): Result { + 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) ───────────────────────────────────── */ export const z = { @@ -1235,6 +1263,8 @@ export const z = { option: >( schema: T, ) => new OptionSchema(schema), + enum: (e: E, msg?: string) => + new EnumSchema(e, msg), }; export type InferSchemaType = S extends Schema ? T : never;