I'm creating a Typescript library (full repository here) whose goal is to be able to call socket.io events as regular functions:
Server side demo:
import { Server } from "socket.io";
import {
type NamespaceProxyTarget,
type ServerSentStartEndEvents,
useSocketEvents,
} from "socket-call-server";
const io = new Server();
user(io);
io.listen(3000);
type SessionData = {
user?: {
username: string;
};
};
type UserServerSentEvents = {
showServerMessage: (message: string) => void;
};
const listenEvents = (services: UserServices) => ({
login: async (username: string) => {
services._socket.data.user = { username };
console.log(`User ${username} logged in`);
setInterval(() => {
services.showServerMessage(`You're still logged in ${username}!`)
}, 1000);
return `You are now logged in ${username}!`;
},
});
type UserServices = NamespaceProxyTarget<
Socket<typeof listenEvents, UserServerSentEvents, object, SessionData>,
UserServerSentEvents
>;
const { client, server } = useSocketEvents<
typeof listenEvents,
UserServerSentEvents,
Record<string, never>,
SessionData
>('/user', {
listenEvents,
middlewares: [],
});
export type ClientEmitEvents = (typeof client)["emitEvents"];
export type ClientListenEvents = (typeof client)["listenEventsInterfaces"];
Client side demo:
import { SocketClient } from 'socket-call-client';
import {
type ClientListenEvents as UserListenEvents,
type ClientEmitEvents as UserEmitEvents,
} from "../server/user.ts";
const socket = new SocketClient("http://localhost:3000");
const user = socket.addNamespace<UserEmitEvents, UserListenEvents>(
'/user'
);
user.login(username.value).then((message) => {
console.log('Server acked with', message);
});
user.showServerMessage = (message) => {
console.log('Server sent us the message', message);
}
The library server itself:
import type { Namespace, Server, Socket } from "socket.io";
type AsyncEventsMap = {
[event: string]: (...args: any[]) => Promise<any>;
};
type EventsMap = {
[event: string]: (...args: any[]) => any;
};
export type ScopedError<ErrorKey extends string = string> = {
error: ErrorKey;
message: string;
selector: string;
};
export type EitherOr<A, B> = A | B extends object
?
| (A & Partial<Record<Exclude<keyof B, keyof A>, never>>)
| (B & Partial<Record<Exclude<keyof A, keyof B>, never>>)
: A | B;
export type Errorable<T, ErrorKey extends string> = EitherOr<
T,
EitherOr<{ error: ErrorKey; errorDetails?: string }, ScopedError<ErrorKey>>
>;
export type WithoutError<T> = T extends { error: any; errorDetails?: any }
? never
: T extends { error: any }
? never
: T;
export type EventOutput<
ClientEvents extends ReturnType<
typeof useSocketEvents
>["client"]["emitEvents"],
EventName extends keyof ClientEvents
> = Awaited<ReturnType<ClientEvents[EventName]>>;
export type SuccessfulEventOutput<
ClientEvents extends ReturnType<
typeof useSocketEvents
>["client"]["emitEvents"],
EventName extends keyof ClientEvents
> = WithoutError<EventOutput<ClientEvents, EventName>>;
type ServerSentEndEvents<Events extends { [event: string]: any }> = {
[K in keyof Events & string as `${K}End`]: Events[K];
};
type NamespaceProxyTargetInternal<Socket> = {
_socket: Socket;
};
export type NamespaceProxyTarget<
Socket,
EmitEvents extends EventsMap
> = EmitEvents & NamespaceProxyTargetInternal<Socket>;
const getProxy = <S extends Socket, EmitEvents extends EventsMap>(socket: S) =>
new Proxy({} as NamespaceProxyTarget<S, EmitEvents>, {
get: <
EventNameOrSpecialProperty extends "_socket" | (keyof EmitEvents & string)
>(
target: NamespaceProxyTarget<S, EmitEvents>,
prop: EventNameOrSpecialProperty
): EventNameOrSpecialProperty extends "_socket"
? typeof socket
: (
...args: Parameters<EmitEvents[EventNameOrSpecialProperty]>
) => boolean => {
if (prop === "_socket") {
return socket as any; // TODO improve typing
}
return ((...args: any[]) => socket.emit(prop, ...args)) as any; // TODO improve typing
},
});
export type ServerSentStartEndEvents<Events extends { [event: string]: any }> =
Events & ServerSentEndEvents<Events>;
export const useSocketEvents = <
ListenEvents extends (
services: NamespaceProxyTarget<Socket, EmitEvents>
) => AsyncEventsMap,
EmitEvents extends EventsMap = EventsMap,
ServerSideEvents extends EventsMap = EventsMap,
SocketData extends object = object
>(
endpoint: Parameters<Server["of"]>[0],
options: {
listenEvents: ListenEvents;
middlewares: Parameters<
Namespace<
ReturnType<ListenEvents>,
EmitEvents,
ServerSideEvents,
SocketData
>["use"]
>[0][];
}
) => ({
server: (io: Server) => {
const namespace = io.of(endpoint);
for (const middleware of options?.middlewares ?? []) {
namespace.use(middleware);
}
namespace.on("connection", (socket) => {
const socketEventImplementations = options.listenEvents(
getProxy<typeof socket, EmitEvents>(socket)
);
for (const eventName in socketEventImplementations) {
socket.on(eventName, async (...args: unknown[]) => {
const callback = args.pop() as Function;
const output = await socketEventImplementations[eventName](...args);
callback(output);
});
}
});
},
client: {
emitEvents: {} as unknown as ReturnType<ListenEvents>,
listenEventsInterfaces: {} as unknown as EmitEvents,
},
});
And the library client:
// @ts-ignore Optional peer dependency
import type { CacheOptions } from "axios-cache-interceptor";
import { io, type Socket } from "socket.io-client";
import { Ref, ref } from "vue";
type SocketCacheOptions<Events extends EventsMap> = Pick<
CacheOptions,
"storage"
> & {
ttl: number | ((event: StringKeyOf<Events>, args: unknown[]) => number);
};
// eslint-disable-next-line @typescript-eslint/no-explicit-any
type EventsMap = Record<string, any>;
type StringKeyOf<T> = keyof T & string;
type SpecialProperties = "_socket" | "_connect" | "_ongoingCalls";
type NamespaceProxyTargetInternal = {
_socket: Socket | undefined;
_connect: () => void;
_ongoingCalls: Ref<string[]>;
};
type NamespaceProxyTarget<
Events extends EventsMap,
ServerSentEvents extends EventsMap = object
> = Events & ServerSentEvents & NamespaceProxyTargetInternal;
export class SocketClient {
constructor(private socketRootUrl: string) {}
public cacheHydrator = {
state: ref<{
mode: "LOAD_CACHE" | "HYDRATE";
cachedCallsDone: string[];
hydratedCallsDoneAmount: number;
}>(),
run: async (
loadCachedDataFn: () => Promise<void>,
loadRealDataFn: () => void
) => {
this.cacheHydrator.state.value = {
mode: "LOAD_CACHE",
cachedCallsDone: [],
hydratedCallsDoneAmount: 0,
};
console.debug("loading cache...");
await loadCachedDataFn();
this.cacheHydrator.state.value.mode = "HYDRATE";
this.cacheHydrator.state.value.hydratedCallsDoneAmount = 0;
console.debug("Hydrating...");
loadRealDataFn();
},
};
public onConnectError = (
e: Error,
namespace: string,
_eventName?: string
) => {
console.error(`${namespace}: connect_error: ${e}`);
};
public onConnected = (namespace: string) => {
console.info(`${namespace}: connected`);
};
public addNamespace<
Events extends EventsMap,
ServerSentEvents extends EventsMap = object
>(
namespaceName: string,
namespaceOptions: {
onConnectError?: (e: Error, namespace: string) => void;
onConnected?: (namespace: string) => void;
session?: {
getToken: () => Promise<string | null | undefined>;
clearSession: () => Promise<void> | void;
sessionExists: () => Promise<boolean>;
};
cache?: Required<SocketCacheOptions<Events>> & {
disableCache?: (eventName: StringKeyOf<Events>) => boolean;
};
} = {}
): NamespaceProxyTarget<Events, ServerSentEvents> {
const { session, cache } = namespaceOptions;
let socket: Socket | undefined;
let isOffline: boolean | undefined;
const ongoingCalls = ref<string[]>([]);
const connect = () => {
console.log("connect");
console.log(
`connecting to ${namespaceName} at ${new Date().toISOString()}`
);
socket = io(this.socketRootUrl + namespaceName, {
extraHeaders: {
"X-Namespace": namespaceName,
},
timeout: 1000,
transports: ["websocket"],
multiplex: false,
auth: async (cb) => {
const token = await session?.getToken();
cb(token ? { token } : {});
},
})
.onAny((event, ...args) => {
if (!["connect", "connect_error"].includes(event)) {
console.debug(`${namespaceName}/${event} received`, args);
}
})
.on("connect_error", (e) => {
isOffline = true;
console.log("connect_error", namespaceName, e);
this.onConnectError(e, namespaceName);
})
.on("connect", () => {
isOffline = false;
console.log(
`connected to ${namespaceName} at ${new Date().toISOString()}`
);
this.onConnected(namespaceName);
});
};
type ProxyTarget = NamespaceProxyTarget<Events, ServerSentEvents>;
return new Proxy({} as ProxyTarget, {
set: <EventName extends StringKeyOf<ServerSentEvents>>(
_: never,
event: EventName,
callback: ServerSentEvents[EventName]
) => {
socket?.on(event, callback);
return true;
},
get: <
EventNameOrSpecialProperty extends
| SpecialProperties
| StringKeyOf<Events>
>(
_: never,
prop: EventNameOrSpecialProperty
): EventNameOrSpecialProperty extends SpecialProperties
? ProxyTarget[EventNameOrSpecialProperty]
: (
...args: Parameters<Events[EventNameOrSpecialProperty]>
) => Promise<
Awaited<ReturnType<Events[EventNameOrSpecialProperty]> | undefined>
> => {
switch (prop) {
case "_socket":
return socket as ProxyTarget["_socket"];
case "_connect":
return connect as ProxyTarget["_connect"];
case "_ongoingCalls":
return ongoingCalls as ProxyTarget["_ongoingCalls"];
case "__proto__":
case "toJSON":
return null as any;
}
// @ts-expect-error Unsure how to type this
return async (
...args: Parameters<Events[EventNameOrSpecialProperty]>
) => {
if (!socket) {
connect();
}
const startTime = Date.now();
const shortEventConsoleString = `${prop}(${JSON.stringify(
args
).replace(/[\[\]]/g, "")})` as const;
const eventConsoleString = `${namespaceName}/${shortEventConsoleString}`;
const debugCall = async (post: boolean = false, cached = false) => {
const token = await session?.getToken();
if (prop !== "toJSON") {
if (cached) {
console.debug(`${eventConsoleString} served from cache`);
} else {
console.debug(
`${eventConsoleString} ${
post
? `responded in ${Date.now() - startTime}ms`
: `called ${token ? "with token" : "without token"}`
} at ${new Date().toISOString()}`
);
if (post) {
ongoingCalls.value = ongoingCalls.value.filter(
(call) => call !== shortEventConsoleString
);
} else {
ongoingCalls.value = ongoingCalls.value.concat(
shortEventConsoleString
);
}
}
}
};
let isCacheUsed = false;
let cacheKey;
if (cache) {
console.log(prop);
cacheKey = `${namespaceName}/${prop} ${JSON.stringify(args)}`;
const cacheData = await cache.storage.get(cacheKey, {
cache: {
ttl:
isOffline ||
this.cacheHydrator.state.value?.mode === "LOAD_CACHE"
? undefined
: typeof cache.ttl === "function"
? cache.ttl(prop, args)
: cache.ttl,
},
});
isCacheUsed =
cacheData !== undefined &&
!(typeof cacheData === "object" && cacheData.state === "empty");
if (isCacheUsed) {
debugCall(true, true);
if (this.cacheHydrator.state.value) {
switch (this.cacheHydrator.state.value.mode) {
case "LOAD_CACHE":
this.cacheHydrator.state.value.cachedCallsDone.push(
eventConsoleString
);
break;
case "HYDRATE":
if (
this.cacheHydrator.state.value.cachedCallsDone.includes(
eventConsoleString
)
) {
this.cacheHydrator.state.value.hydratedCallsDoneAmount++;
}
break;
}
}
return cacheData as any;
}
}
socket!.on("connect_error", (e) => {
isOffline = true;
this.onConnectError(
e.message === "websocket error"
? {
message: "offline_no_cache",
name: "offline_no_cache",
}
: e,
namespaceName,
prop
);
});
await debugCall();
const data = await socket!.emitWithAck(prop, ...args);
if (data && typeof data === "object" && "error" in data) {
throw data;
}
await debugCall(true);
if (cache && cacheKey) {
cache.storage.set(cacheKey, data, {
timeout:
typeof cache.ttl === "function"
? cache.ttl(prop, args)
: cache.ttl,
});
}
if (
this.cacheHydrator.state.value?.mode === "HYDRATE" &&
this.cacheHydrator.state.value.cachedCallsDone.includes(
eventConsoleString
)
) {
this.cacheHydrator.state.value.hydratedCallsDoneAmount++;
}
return data;
}
},
});
}
}
export type { AxiosStorage } from "axios-cache-interceptor";
export { buildStorage, buildWebStorage } from "axios-cache-interceptor";
So basically the library allows to declare functions corresponding to the events to listen to, and to call functions when we want to emit events (these functions being defined on the client side for events that we emit from the server, and vice-versa).
Technically this is done with the use of a proxy, where setting the property showServerMessage on the proxy means that we want to listen to an event called showServerMessage , and retrieving the property login on the proxy means that we want to emit an event called login.
My Typescript skills are, I would say, intermediate and I would like some guidance to improve the work that I did so far. Here are a few examples of parts of the code that I'm not very happy with:
When calling the
useSocketEventsfunction on the server side (function definition here), I didn't find a way to avoid passing both thelistenEventsargument andlistenEvents's type as a generic parameter.In the
getmethod of the client-side proxy, I return a function that users can call in order to emit events; however, I didn't manage to type it properly and ended up putting a disappointingts-expect-errorcommentStill on the client side, I used a Vue ref to be able to monitor the state of the cache loading. There is probably a better way to do this without needing to require the Vue dependency, but being used to Vue I didn't find one.