diff --git a/client/src/App.tsx b/client/src/App.tsx index 8c8ca105..fe30e7e6 100644 --- a/client/src/App.tsx +++ b/client/src/App.tsx @@ -10,6 +10,7 @@ import {WelcomeButtons} from "./components/WelcomeButtons"; import {FlightLobby} from "./components/FlightLobby"; const DocLayout = lazy(() => import("./docs")); +const Config = lazy(() => import("./pages/Config")); const MainPage = () => { // const {netSend} = useThorium(); @@ -64,6 +65,7 @@ function AppRoutes() { } /> } /> }> + }> } /> ); diff --git a/client/src/context/AppContext.tsx b/client/src/context/AppContext.tsx index 7790d91c..b74c0689 100644 --- a/client/src/context/AppContext.tsx +++ b/client/src/context/AppContext.tsx @@ -6,6 +6,7 @@ import useEasterEgg from "../hooks/useEasterEgg"; import {ErrorBoundary, FallbackProps} from "react-error-boundary"; import bg from "../images/background.jpg"; import {FaSpinner} from "react-icons/fa"; +import {NetRequestData} from "./useNetRequest"; const Fallback: React.FC = ({error}) => { return ( @@ -60,6 +61,7 @@ export default function AppContext({children}: {children: ReactNode}) { }> + {children} diff --git a/client/src/context/useNetRequest.ts b/client/src/context/useNetRequest.ts new file mode 100644 index 00000000..6a35f705 --- /dev/null +++ b/client/src/context/useNetRequest.ts @@ -0,0 +1,71 @@ +import {useEffect} from "react"; +import { + AllRequestNames, + AllRequestParams, + AllRequestReturns, +} from "server/src/netRequests"; +import {proxy, useSnapshot} from "valtio"; +import {NetResponseData} from "../hooks/useDataConnection"; +import {stableValueHash} from "../utils/stableValueHash"; +import {useThorium} from "./ThoriumContext"; +import {useErrorHandler} from "react-error-boundary"; +const netRequestProxy = proxy>({}); +const netRequestPromises: {[requestId: string]: (value: unknown) => void} = {}; + +export function NetRequestData() { + useNetRequestData(); + return null; +} +function useNetRequestData() { + const {socket} = useThorium(); + const handleError = useErrorHandler(); + + if (!socket) throw new Promise(() => {}); + useEffect(() => { + function handleNetRequestData(data: NetResponseData) { + try { + if (typeof data !== "object") { + throw new Error(`netResponse data must be an object. Got "${data}"`); + } + if ("error" in data) { + throw new Error(data.error); + } + if (!("requestId" in data && "response" in data)) { + const dataString = JSON.stringify(data, null, 2); + throw new Error( + `netResponse data must include a requestId and a response. Got ${dataString}` + ); + } + netRequestProxy[data.requestId] = data.response; + netRequestPromises[data.requestId]?.(null); + } catch (err) { + handleError(err); + } + } + socket.on("netRequestData", handleNetRequestData); + return () => { + socket.off("netRequestData", handleNetRequestData); + }; + }, [socket, handleError]); +} + +export function useNetRequest< + T extends AllRequestNames, + R extends AllRequestReturns[T] +>(requestName: T, params?: AllRequestParams[T]): R { + const requestId = stableValueHash({requestName, params}); + const data = useSnapshot(netRequestProxy); + const {socket} = useThorium(); + if (!socket) throw new Promise(() => {}); + + if (!data[requestId]) { + if (!netRequestPromises[requestId]) { + socket.send("netRequest", {requestName, params, requestId}); + } + throw new Promise(res => { + netRequestPromises[requestId] = res; + }); + } + + return data[requestId]; +} diff --git a/client/src/pages/Config/index.tsx b/client/src/pages/Config/index.tsx new file mode 100644 index 00000000..5201f660 --- /dev/null +++ b/client/src/pages/Config/index.tsx @@ -0,0 +1,161 @@ +import {usePrompt} from "@thorium/ui/AlertDialog"; +import Button from "@thorium/ui/Button"; +import InfoTip from "@thorium/ui/InfoTip"; +import Input from "@thorium/ui/Input"; +import Menubar from "@thorium/ui/Menubar"; +import SearchableList from "@thorium/ui/SearchableList"; +import TagInput from "@thorium/ui/TagInput"; +import UploadWell from "@thorium/ui/UploadWell"; +import {useNetSend} from "client/src/context/ThoriumContext"; +import {useNetRequest} from "client/src/context/useNetRequest"; +import {useState} from "react"; +import {FaEdit} from "react-icons/fa"; +import {NavLink, useNavigate, useParams} from "react-router-dom"; + +let plugin: null | { + id: string; + name: string; + description: string; + tags: string[]; +} = null; +const setName = (params: any) => {}; +const setDescription = (params: any) => {}; +const setTags = (params: any) => {}; +export default function Config() { + const [error, setError] = useState(false); + const data = useNetRequest("pluginsList"); + const netSend = useNetSend(); + const navigate = useNavigate(); + const params = useParams(); + const prompt = usePrompt(); + return ( +
+ +
+

Plugin Config

+ +
+
+ + + ({ + id: d.id, + name: d.name, + description: d.description, + tags: d.tags, + author: d.author, + }))} + searchKeys={["name", "author", "tags"]} + selectedItem={params.pluginId || null} + setSelectedItem={id => navigate(`/config/${id}`)} + renderItem={c => ( +
+
+ {c.name} +
+ {c.author} +
+
+ e.stopPropagation()} + > + + +
+ )} + /> +
+
+ setError(false)} + onBlur={(e: React.FocusEvent) => { + const target = e.target as HTMLInputElement; + plugin && target.value + ? setName({ + variables: {id: plugin.id, name: target.value}, + }) + : setError(true); + }} + /> + setError(false)} + onBlur={(e: React.FocusEvent) => { + const target = e.target as HTMLInputElement; + plugin && target.value + ? setDescription({ + variables: {id: plugin.id, description: target.value}, + }) + : setError(true); + }} + /> + { + if (plugin?.tags.includes(tag) || !plugin) return; + setTags({ + variables: {id: plugin.id, tags: plugin.tags.concat(tag)}, + }); + }} + onRemove={tag => { + if (!plugin) return; + setTags({ + variables: { + id: plugin.id, + tags: plugin.tags.filter(t => t !== tag), + }, + }); + }} + /> +
+
+ +
+
+
+
+ ); +} diff --git a/server/src/classes/Client.ts b/server/src/classes/Client.ts index ae02bbb4..6f0c4474 100644 --- a/server/src/classes/Client.ts +++ b/server/src/classes/Client.ts @@ -1,6 +1,5 @@ import {ServerChannel} from "@geckos.io/server"; import {OfflineStates, UnionToIntersection} from "../utils/types"; -import uniqid from "@thorium/uniqid"; import randomWords from "@thorium/random-words"; import {DataContext} from "../utils/DataContext"; import inputs, {AllInputNames} from "../inputs"; @@ -15,12 +14,17 @@ import {Entity} from "../utils/ecs"; import {SnapshotInterpolation} from "@geckos.io/snapshot-interpolation"; import {encode} from "@msgpack/msgpack"; import {SocketStream} from "fastify-websocket"; +import requests, {AllRequestNames} from "../netRequests"; class BaseClient { constructor(public id: string) {} } type NetSendData = {inputName: AllInputNames; params: any; requestId: string}; - +type NetRequestData = { + requestName: AllRequestNames; + params: any; + requestId: string; +}; const channels: Record = {}; const sockets: Record = {}; /** @@ -69,21 +73,32 @@ export class ServerClient extends BaseClient { this.clientContext = new DataContext(this.id, database); sockets[this.id] = connection; - await this.initNetSend(connection); - await this.initNetRequest(connection); + await this.initRequests(connection); await this.initSubscriptions(connection); } - private async initNetSend(socket: SocketStream) { + private async initRequests(socket: SocketStream) { if (!socket) throw new Error( "NetSend cannot be initialized before the socket is established." ); + const netRequestList: { + [requestId: string]: { + params: any; + requestName: AllRequestNames; + subscriptionId: number; + }; + } = {}; + socket.socket.on("close", () => { + for (let requestId in netRequestList) { + pubsub.unsubscribe(netRequestList[requestId]?.subscriptionId); + } + }); // Set up the whole netSend process for calling input functions socket.socket.on("message", async data => { try { - if (typeof data === "string") { - const messageData = JSON.parse(data); - if (messageData.type === "netSend") { + const messageData = JSON.parse(data.toString()); + switch (messageData.type) { + case "netSend": { const {inputName, params, requestId} = messageData.data as NetSendData; try { @@ -118,23 +133,96 @@ export class ServerClient extends BaseClient { }) ); } + break; + } + case "netRequest": { + const {requestName, params, requestId} = + messageData.data as NetRequestData; + + function handleNetRequestError(err: unknown) { + if (err === null) return; + let message = err; + if (err instanceof Error) { + message = err.message; + } + console.error(`Error in input ${requestName}: ${message}`); + if (err instanceof Error) console.error(err.stack); + socket.socket.send( + JSON.stringify({ + type: "netRequestData", + data: { + requestId: requestId, + error: message, + }, + }) + ); + pubsub.unsubscribe(netRequestList[requestId]?.subscriptionId); + delete netRequestList[requestId]; + } + + try { + const requestFunction = requests[requestName]; + // Create the subscription + const subscriptionId = await pubsub.subscribe( + requestName, + (payload: any, context: DataContext) => { + try { + const data = requestFunction(context, params, payload); + socket.socket.send( + JSON.stringify({ + type: "netRequestData", + data: { + requestId: requestId, + response: data, + }, + }) + ); + return data; + } catch (err) { + handleNetRequestError(err); + } + }, + this.clientContext + ); + netRequestList[requestId] = { + params, + requestName, + subscriptionId, + }; + // Collect and send the initial data + const response = + (await requestFunction(this.clientContext, params, null)) || {}; + + socket.socket.send( + JSON.stringify({ + type: "netRequestData", + data: { + requestId: requestId, + response, + }, + }) + ); + } catch (err) { + handleNetRequestError(err); + } + break; + } + case "netRequestEnd": { + const {requestId} = messageData.data as {requestId: string}; + pubsub.unsubscribe(netRequestList[requestId]?.subscriptionId); + delete netRequestList[requestId]; + break; } } } catch (err) { throw new Error( - `Client ${this.id} sent invalid NetSend data:${ + `Client ${this.id} sent invalid request data:${ typeof data === "object" ? JSON.stringify(data) : data }` ); } - if (typeof data === "object" && "inputName" in data) { - } else { - } }); } - private async initNetRequest(channel: SocketStream) { - // TODO: September 1, 2021 Set up net requests through the data channel - } get cards() { // TODO Aug 28, 2021 Populate this list with the dynamic list of cards assigned to the client. // Also, there needs to be some way to unsubscribe and re-subscribe whenever the client's diff --git a/server/src/classes/Plugins/index.ts b/server/src/classes/Plugins/index.ts new file mode 100644 index 00000000..0a18e977 --- /dev/null +++ b/server/src/classes/Plugins/index.ts @@ -0,0 +1,49 @@ +import uniqid from "@thorium/uniqid"; +import {pubsub} from "server/src/utils/pubsub"; + +export function pluginPublish(plugin: BasePlugin) { + pubsub.publish("pluginsList", { + id: plugin.id, + }); + pubsub.publish("plugin", { + pluginId: plugin.id, + }); +} + +export default class BasePlugin { + id: string; + name: string; + author: string; + description: string; + assetPath(asset: string) { + return asset ? `/plugins/${this.name}/${asset}` : ""; + } + get coverImage() { + return this.assetPath(this._coverImage); + } + set coverImage(value) { + this._coverImage = value; + } + _coverImage!: string; + + tags: string[]; + + constructor(params: Partial = {}) { + this.id = params.id || uniqid(); + this.name = params.name || "New Plugin"; + this.author = params.author || ""; + this.description = params.description || "A great plugin"; + this.coverImage = params._coverImage || ""; + this.tags = params.tags || []; + } + async writeFile(force?: boolean) {} + async removeFile(force?: boolean) {} + + save() { + this.writeFile(true); + } + serialize() { + const data = {...this}; + return data; + } +} diff --git a/server/src/classes/ServerDataModel.ts b/server/src/classes/ServerDataModel.ts index b0ec4d7a..844b99f9 100644 --- a/server/src/classes/ServerDataModel.ts +++ b/server/src/classes/ServerDataModel.ts @@ -1,9 +1,11 @@ import {ServerClient} from "./Client"; +import BasePlugin from "./Plugins"; export class ServerDataModel { clients: Record; thoriumId: string; activeFlightName: string | null; + plugins: BasePlugin[] = []; constructor(params: ServerDataModel) { this.clients = Object.fromEntries( Object.entries(params.clients).map(([id, client]) => [ @@ -13,5 +15,6 @@ export class ServerDataModel { ); this.thoriumId = params.thoriumId; this.activeFlightName = params.activeFlightName; + this.plugins = params.plugins?.map(plugin => new BasePlugin(plugin)) || []; } } diff --git a/server/src/index.ts b/server/src/index.ts index 9c259e0b..6d0d3fa0 100644 --- a/server/src/index.ts +++ b/server/src/index.ts @@ -25,6 +25,7 @@ export async function startServer() { clients: {}, thoriumId: randomWords(3).join("-"), activeFlightName: null, + plugins: [], }, serialize: ({clients, ...data}) => ({ ...data, diff --git a/server/src/init/httpServer.ts b/server/src/init/httpServer.ts index b59ab57a..95204e48 100644 --- a/server/src/init/httpServer.ts +++ b/server/src/init/httpServer.ts @@ -4,6 +4,7 @@ import staticServe from "fastify-static"; import cors from "fastify-cors"; import path from "path"; import {promises as fs} from "fs"; +import {thoriumPath} from "../utils/appPaths"; export default function buildHTTPServer({ staticRoot = path.join(__dirname, "public"), @@ -30,6 +31,11 @@ export default function buildHTTPServer({ }); app.register(staticServe, {root: `${staticRoot}/assets`, prefix: "/assets"}); + app.register(staticServe, { + root: `${thoriumPath}/plugins`, + prefix: "/plugins", + decorateReply: false, + }); app.get("/*", async (_req, reply) => { // Return a slightly different index.html for headless servers. diff --git a/server/src/inputs/list.ts b/server/src/inputs/list.ts index e691dc21..192534c8 100644 --- a/server/src/inputs/list.ts +++ b/server/src/inputs/list.ts @@ -2,3 +2,4 @@ export {clientInputs} from "./client"; export {flightInputs} from "./flight"; export {serverInputs} from "./server"; export {dotsInputs} from "./dots"; +export {pluginInputs} from "./plugins"; diff --git a/server/src/inputs/plugins.ts b/server/src/inputs/plugins.ts new file mode 100644 index 00000000..f91e5d96 --- /dev/null +++ b/server/src/inputs/plugins.ts @@ -0,0 +1,13 @@ +import BasePlugin from "../classes/Plugins"; +import {DataContext} from "../utils/DataContext"; +import {pubsub} from "../utils/pubsub"; + +export const pluginInputs = { + pluginCreate(context: DataContext, params: {name: string}) { + const plugin = new BasePlugin(params); + context.server.plugins.push(plugin); + pubsub.publish("pluginsList"); + pubsub.publish("plugin", {pluginId: plugin.id}); + return plugin.id; + }, +}; diff --git a/server/src/netRequests/index.ts b/server/src/netRequests/index.ts new file mode 100644 index 00000000..9ba5c200 --- /dev/null +++ b/server/src/netRequests/index.ts @@ -0,0 +1,22 @@ +import { + InputParams, + InputReturns, + RequestPublishParams, + UnionToIntersection, +} from "../utils/types"; +import * as allRequests from "./list"; + +export type AllRequests = UnionToIntersection< + typeof allRequests[keyof typeof allRequests] +>; +export type AllRequestNames = keyof AllRequests; +export type AllRequestParams = InputParams; +export type AllRequestPublishParams = RequestPublishParams; +export type AllRequestReturns = InputReturns; + +const flattenedRequests: AllRequests = Object.entries(allRequests).reduce( + (prev: any, [_, inputs]) => ({...prev, ...inputs}), + {} +); + +export default flattenedRequests; diff --git a/server/src/netRequests/list.ts b/server/src/netRequests/list.ts new file mode 100644 index 00000000..152065d8 --- /dev/null +++ b/server/src/netRequests/list.ts @@ -0,0 +1 @@ +export {pluginsRequest as allRequests} from "./plugins"; diff --git a/server/src/netRequests/plugins.ts b/server/src/netRequests/plugins.ts new file mode 100644 index 00000000..cf6430e6 --- /dev/null +++ b/server/src/netRequests/plugins.ts @@ -0,0 +1,15 @@ +import {DataContext} from "../utils/DataContext"; + +export const pluginsRequest = { + pluginsList(context: DataContext) { + return context.server.plugins; + }, + plugin( + context: DataContext, + params: {pluginId: string}, + publishParams: {pluginId: string} | null + ) { + if (publishParams && params.pluginId !== publishParams.pluginId) throw null; + return context.server.plugins.find(plugin => plugin.id === params.pluginId); + }, +}; diff --git a/server/src/systems/__test__/TimerSystem.test.ts b/server/src/systems/__test__/TimerSystem.test.ts index b52d8daf..624936bd 100644 --- a/server/src/systems/__test__/TimerSystem.test.ts +++ b/server/src/systems/__test__/TimerSystem.test.ts @@ -1,7 +1,7 @@ import {ECS, Entity} from "server/src/utils/ecs"; import {TimerSystem} from "../TimerSystem"; -const server = {clients: {}, activeFlightName: "", thoriumId: ""}; +const server = {clients: {}, activeFlightName: "", thoriumId: "", plugins: []}; describe("TimerSystem", () => { let ecs: ECS; let timerSystem: TimerSystem; diff --git a/server/src/utils/ecs/__test__/ecs.test.ts b/server/src/utils/ecs/__test__/ecs.test.ts index 2ccdd868..30871c6d 100644 --- a/server/src/utils/ecs/__test__/ecs.test.ts +++ b/server/src/utils/ecs/__test__/ecs.test.ts @@ -2,7 +2,7 @@ import ECS from "../ecs"; import Entity from "../entity"; import System from "../system"; -const server = {clients: {}, activeFlightName: "", thoriumId: ""}; +const server = {clients: {}, activeFlightName: "", thoriumId: "", plugins: []}; describe("ECS", () => { it("should initialize", () => { let ecs = new ECS(server); diff --git a/server/src/utils/pubsub.ts b/server/src/utils/pubsub.ts index 13a17789..c5172dce 100644 --- a/server/src/utils/pubsub.ts +++ b/server/src/utils/pubsub.ts @@ -4,6 +4,11 @@ import { SubscriptionReturn, } from "client/src/utils/cardData"; import {EventEmitter} from "events"; +import { + AllRequestNames, + AllRequestPublishParams, + AllRequestReturns, +} from "../netRequests"; import {DataContext} from "./DataContext"; class PubSub { @@ -19,17 +24,17 @@ class PubSub { } public publish< - TriggerName extends SubscriptionNames, - Payload extends SubscriptionParams[TriggerName] + TriggerName extends SubscriptionNames | AllRequestNames, + Payload extends (SubscriptionParams & AllRequestPublishParams)[TriggerName] >(triggerName: TriggerName, payload?: Payload): Promise { this.ee.emit(triggerName, payload); return Promise.resolve(); } public subscribe< - TriggerName extends SubscriptionNames, - Payload extends SubscriptionParams[TriggerName], - Return extends SubscriptionReturn[TriggerName] + TriggerName extends SubscriptionNames | AllRequestNames, + Payload extends (SubscriptionParams & AllRequestPublishParams)[TriggerName], + Return extends (SubscriptionReturn & AllRequestReturns)[TriggerName] >( triggerName: TriggerName, onMessage: (payload: Payload, context: DataContext) => Return, diff --git a/server/src/utils/types.ts b/server/src/utils/types.ts index 1eaa24cc..2470f251 100644 --- a/server/src/utils/types.ts +++ b/server/src/utils/types.ts @@ -24,10 +24,21 @@ type SecondParam any> = Func extends ( ) => any ? R : never; +type ThirdParam any> = Func extends ( + first: any, + second: any, + third: infer R, + ...args: any +) => any + ? R + : never; type AnyFunc = (...args: any) => any; export type InputParams> = { [Property in keyof Inputs]: SecondParam; }; +export type RequestPublishParams> = { + [Property in keyof Requests]: ThirdParam; +}; export type InputReturns> = { [Property in keyof Inputs]: ReturnType; };