A library designed to facilitate the maintenance of networking code in monorepos
Consider a scenario where you maintain a codebase following the monorepo pattern, with an IPC-esque communication mechanism between sidesβsimilar to FiveM's Scripting SDK or Figma's Plugin API. In such cases, you may encounter excessive boilerplate code just to ensure that the correct data is sent under the appropriate title. This library aims to simplify that process by abstracting transport strategies between sides, thereby standardizing communication.
- Simple Example: with 3 mockup sides: midware "Client", HTTP "Server" and React "UI"
- Figma Plugin Example: with 2 sides: figma "Plugin", and the renderer "UI"
- FiveM Server Example: with 3 sides: resource "Server", resource "Client", and the "NUI"
This library assumes your codebase is a monorepo that has distinct sides sharing some code. For simplicity, tutorial ahead will use a folder structure like so:
|- common
|- packages
| |- ui
| |- client
| |- server
Start by creating sides and defining the events they can receive.
// ./common/networkSides.ts
import { Networker } from "monorepo-networker";
export const UI = Networker.createSide("UI-side").listens<{
focusOnSelected(): void;
focusOnElement(elementId: string): void;
}>();
export const CLIENT = Networker.createSide("Client-side").listens<{
hello(text: string): void;
getClientTime(): number;
createRectangle(width: number, height: number): void;
execute(script: string): void;
}>();
export const SERVER = Networker.createSide("Server-side").listens<{
hello(text: string): void;
getServerTime(): number;
fetchUser(userId: string): { id: string; name: string };
markPresence(online: boolean): void;
}>();
Caution
Side objects created here are supposed to be used across different side runtimes. Make sure NOT to use anything side-dependent in here.
Create the channels for each side. Channels are responsible of communicating with other sides and listening to incoming messages using the registered strategies.
(Only the code for CLIENT side is shown, for simplicity.)
// ./packages/client/networkChannel.ts
import { CLIENT, SERVER, UI } from "@common/networkSides";
export const CLIENT_CHANNEL = CLIENT.channelBuilder()
.emitsTo(UI, (message) => {
// We're declaring how CLIENT sends a message to UI
parent.postMessage({ pluginMessage: message }, "*");
})
.emitsTo(SERVER, (message) => {
// We're declaring how CLIENT sends a message to SERVER
fetch("server://", { method: "POST", body: JSON.stringify(message) });
})
.receivesFrom(UI, (next) => {
// We're declaring how CLIENT receives a message from UI
const listener = (event: MessageEvent) => {
if (event.data?.pluginId == null) return;
next(event.data.pluginMessage);
};
window.addEventListener("message", listener);
return () => {
window.removeEventListener("message", listener);
};
})
.startListening();
// ----------- Declare how an incoming message is handled
CLIENT_CHANNEL.registerMessageHandler("hello", (text, from) => {
console.log(from.name, "said:", text);
});
CLIENT_CHANNEL.registerMessageHandler("getClientTime", () => {
// Returning a value will make this event "request-able"
return Date.now();
});
CLIENT_CHANNEL.registerMessageHandler("execute", async (script) => {
// It also supports Async handlers!
return new Promise<void>((resolve) => {
setTimeout(() => resolve(eval(script)), 5000);
});
});
Initialize each side in their entry point. And enjoy the standardized messaging api!
Channel::emit
will emit given event to the given sideChannel::request
will emit given event to the given side, and wait for a response from the target side.Channel::subscribe
will subscribe a listener for incoming messages on this side. (Note: subscribed listener cannot "respond" to them. UseChannel::registerMessageHandler
to create a proper responder.)
// ./packages/server/main.ts
import { Networker } from "monorepo-networker";
import { SERVER, CLIENT } from "@common/networkSides";
import { SERVER_CHANNEL } from "@server/networkChannel";
async function bootstrap() {
Networker.initialize(SERVER, SERVER_CHANNEL);
console.log("We are at", Networker.getCurrentSide().name);
// ... Omitted code that bootstraps the server
SERVER_CHANNEL.emit(CLIENT, "hello", ["Hi there, client!"]);
// Event though CLIENT's `createRectangle` returns void, we can still await on its acknowledgement.
await SERVER_CHANNEL.request(CLIENT, "createRectangle", [100, 200]);
}
bootstrap();
// ./packages/client/main.ts
import { Networker, NetworkError } from "monorepo-networker";
import { CLIENT, SERVER } from "@common/networkSides";
import { CLIENT_CHANNEL } from "@client/networkChannel";
import React, { useEffect, useRef } from "react";
import ReactDOM from "react-dom/client";
Networker.initialize(CLIENT, CLIENT_CHANNEL);
console.log("We are @", Networker.getCurrentSide().name);
CLIENT_CHANNEL.emit(SERVER, "hello", ["Hi there, server!"]);
// This one corresponds to SERVER's `getServerTime(): number;` event
CLIENT_CHANNEL.request(SERVER, "getServerTime", [])
.then((serverTime) => {
console.log('Server responded with "' + serverTime + '" !');
})
.catch((err) => {
if (err instanceof NetworkError) {
console.log("Server failed to respond..", { message: err.message });
}
});
const rootElement = document.getElementById("root") as HTMLElement;
const root = ReactDOM.createRoot(rootElement);
root.render(
<React.StrictMode>
<App />
</React.StrictMode>
);
function App() {
const rectangles = useRef<{ w: number; h: number }[]>([]);
useEffect(() => {
const unsubscribe = CLIENT_CHANNEL.subscribe(
"createRectangle",
(width, height, from) => {
console.log(from.name, "asked for a rectangle!");
rectangles.current.push({ w: width, h: height });
}
);
return () => unsubscribe();
}, []);
return <main>{/* ... Omitted for simplicity */}</main>;
}
- @thediaval: For his endless support and awesome memes.
Β© 2024 Taha AnΔ±lcan Metinyurt (iGoodie)
For any part of this work for which the license is applicable, this work is licensed under the Attribution-ShareAlike 4.0 International license. (See LICENSE).