Skip to content

Commit

Permalink
Client metadata (#19)
Browse files Browse the repository at this point in the history
* Client metadata draft.

* Equipping the remote clients too.

* Fix: remote one is readonly.

* More JSdoc.

* Add fallback empty object to parseMeta().

* Rev: no metadata schema and no parsing: getData() and setData() accept type argument instead.

* Ref: getRemoteClients() return type.

* Logging metadata by default and fallback object values for getters.

* Example: message count metadata with constraints.

* Fallback coverage from RemoteClient::getData().

* Marking getData() returns as partial to emphasize that it's an empty object initially.

* Update src/action.ts
  • Loading branch information
RobinTail authored Feb 9, 2024
1 parent 74a619f commit be42031
Show file tree
Hide file tree
Showing 11 changed files with 141 additions and 28 deletions.
4 changes: 4 additions & 0 deletions example/actions/chat.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,15 @@
import { z } from "zod";
import { Metadata } from "../config";
import { actionsFactory } from "../factories";

export const onChat = actionsFactory.build({
input: z.tuple([z.string()]),
handler: async ({ input: [message], client, all, logger }) => {
try {
await all.broadcast("chat", message, { from: client.id });
client.setData<Metadata>({
msgCount: (client.getData<Metadata>().msgCount || 0) + 1,
});
} catch (error) {
logger.error("Failed to broadcast", error);
}
Expand Down
6 changes: 6 additions & 0 deletions example/config.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,12 @@
import { z } from "zod";
import { createConfig } from "../src";

/** @desc Client metadata */
export interface Metadata {
/** @desc Number of messages sent using the chat event */
msgCount: number;
}

export const config = createConfig({
timeout: 2000,
logger: console,
Expand Down
18 changes: 15 additions & 3 deletions src/action.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,8 @@ describe("Action", () => {
const getRoomsMock = vi.fn();
const getAllRoomsMock = vi.fn();
const getAllClientsMock = vi.fn();
const getDataMock = vi.fn();
const setDataMock = vi.fn();

test("should handle simple action", async () => {
await simpleAction.execute({
Expand All @@ -48,10 +50,12 @@ describe("Action", () => {
getRooms: getAllRoomsMock,
},
client: {
id: "ID",
emit: emitMock,
getRooms: getRoomsMock,
isConnected: isConnectedMock,
id: "ID",
getData: getDataMock,
setData: setDataMock,
},
});
expect(loggerMock.error).not.toHaveBeenCalled();
Expand All @@ -65,10 +69,12 @@ describe("Action", () => {
getRooms: getAllRoomsMock,
},
client: {
id: "ID",
emit: emitMock,
isConnected: isConnectedMock,
getRooms: getRoomsMock,
id: "ID",
getData: getDataMock,
setData: setDataMock,
},
});
});
Expand All @@ -86,10 +92,12 @@ describe("Action", () => {
getRooms: getAllRoomsMock,
},
client: {
id: "ID",
emit: emitMock,
getRooms: getRoomsMock,
isConnected: isConnectedMock,
id: "ID",
getData: getDataMock,
setData: setDataMock,
},
});
expect(loggerMock.error).not.toHaveBeenCalled();
Expand All @@ -99,6 +107,8 @@ describe("Action", () => {
getRooms: getRoomsMock,
isConnected: isConnectedMock,
emit: emitMock,
getData: getDataMock,
setData: setDataMock,
},
all: {
broadcast: broadcastMock,
Expand Down Expand Up @@ -128,6 +138,8 @@ describe("Action", () => {
getRooms: getRoomsMock,
isConnected: isConnectedMock,
emit: emitMock,
getData: getDataMock,
setData: setDataMock,
},
});
expect(loggerMock.error).toHaveBeenCalled();
Expand Down
19 changes: 17 additions & 2 deletions src/action.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,8 +13,19 @@ export interface Client<E extends EmissionMap> {
id: Socket["id"];
/** @desc Returns the list of the rooms the client in */
getRooms: () => string[];
/** @desc Sends a new event to the client (this is not acknowledgement) */
/**
* @desc Sends a new event to the client (this is not acknowledgement)
* @throws z.ZodError on validation
* @throws Error on ack timeout
* */
emit: Emitter<E>;
/** @desc Returns the client metadata according to the specified type or empty object */
getData: <D extends object>() => Readonly<Partial<D>>;
/**
* @desc Sets the client metadata according to the specified type
* @throws z.ZodError on validation
* */
setData: <D extends object>(value: D) => void;
}

export interface HandlingFeatures<E extends EmissionMap> {
Expand All @@ -23,7 +34,11 @@ export interface HandlingFeatures<E extends EmissionMap> {
client: Client<E>;
/** @desc The global scope */
all: {
/** @desc Emits to everyone */
/**
* @desc Emits to everyone
* @throws z.ZodError on validation
* @throws Error on ack timeout
* */
broadcast: Broadcaster<E>;
/** @desc Returns the list of available rooms */
getRooms: () => string[];
Expand Down
42 changes: 31 additions & 11 deletions src/attach.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -56,10 +56,9 @@ describe("Attach", () => {

// on connection:
await ioMock.on.mock.lastCall![1](socketMock);
expect(loggerMock.debug).toHaveBeenLastCalledWith(
"Client connected",
"ID",
);
expect(loggerMock.debug).toHaveBeenLastCalledWith("Client connected", {
id: "ID",
});
expect(socketMock.onAny).toHaveBeenLastCalledWith(expect.any(Function));
expect(socketMock.on).toHaveBeenCalledWith("test", expect.any(Function));
expect(socketMock.on).toHaveBeenLastCalledWith(
Expand All @@ -69,14 +68,13 @@ describe("Attach", () => {

// on disconnect:
socketMock.on.mock.lastCall![1]();
expect(loggerMock.debug).toHaveBeenLastCalledWith(
"Client disconnected",
"ID",
);
expect(loggerMock.debug).toHaveBeenLastCalledWith("Client disconnected", {
id: "ID",
});

// on any event:
socketMock.onAny.mock.lastCall![0]("test");
expect(loggerMock.debug).toHaveBeenLastCalledWith("test from ID");
expect(loggerMock.debug).toHaveBeenLastCalledWith("test from ID", {});

// on the listened event:
const call = socketMock.on.mock.calls.find(([evt]) => evt === "test");
Expand All @@ -92,6 +90,8 @@ describe("Attach", () => {
isConnected: expect.any(Function),
getRooms: expect.any(Function),
emit: expect.any(Function),
getData: expect.any(Function),
setData: expect.any(Function),
},
all: {
broadcast: expect.any(Function),
Expand Down Expand Up @@ -122,9 +122,29 @@ describe("Attach", () => {
await expect(
actionsMock.test.execute.mock.lastCall[0].all.getClients(),
).resolves.toEqual([
{ id: "ID", rooms: ["room1", "room2"] },
{ id: "other", rooms: ["room3"] },
{
id: "ID",
rooms: ["room1", "room2"],
getData: expect.any(Function),
},
{
id: "other",
rooms: ["room3"],
getData: expect.any(Function),
},
]);

// client.setData:
actionsMock.test.execute.mock.lastCall[0].client.setData({
name: "user",
});

// client.getData:
expect(
actionsMock.test.execute.mock.lastCall[0].client.getData(),
).toEqual({
name: "user",
});
});
});
});
14 changes: 8 additions & 6 deletions src/attach.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,12 +15,12 @@ export const attachSockets = <E extends EmissionMap>({
actions,
target,
config,
onConnection = ({ client }) =>
config.logger.debug("Client connected", client.id),
onDisconnect = ({ client }) =>
config.logger.debug("Client disconnected", client.id),
onAnyEvent = ({ input: [event], client }) =>
config.logger.debug(`${event} from ${client.id}`),
onConnection = ({ client: { id, getData } }) =>
config.logger.debug("Client connected", { ...getData(), id }),
onDisconnect = ({ client: { id, getData } }) =>
config.logger.debug("Client disconnected", { ...getData(), id }),
onAnyEvent = ({ input: [event], client: { id, getData } }) =>
config.logger.debug(`${event} from ${id}`, getData()),
}: {
/**
* @desc The Socket.IO server
Expand Down Expand Up @@ -59,6 +59,8 @@ export const attachSockets = <E extends EmissionMap>({
id: socket.id,
isConnected: () => socket.connected,
getRooms: () => Array.from(socket.rooms),
getData: () => socket.data || {},
setData: (value) => (socket.data = value),
},
all: {
broadcast,
Expand Down
6 changes: 6 additions & 0 deletions src/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,14 @@ import { EmissionMap } from "./emission";
import { AbstractLogger } from "./logger";

export interface Config<E extends EmissionMap> {
/**
* @desc The instance of a logger
* @example console
* */
logger: AbstractLogger;
/** @desc The acknowledgment awaiting timeout */
timeout: number;
/** @desc The events that the server can emit */
emission: E;
}

Expand Down
12 changes: 10 additions & 2 deletions src/emission.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -103,8 +103,16 @@ describe("Emission", () => {
}
}
await expect(getClients()).resolves.toEqual([
{ id: "ID", rooms: ["room1", "room2"] },
{ id: "other", rooms: ["room3"] },
{
id: "ID",
rooms: ["room1", "room2"],
getData: expect.any(Function),
},
{
id: "other",
rooms: ["room3"],
getData: expect.any(Function),
},
]);
},
);
Expand Down
5 changes: 5 additions & 0 deletions src/emission.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,11 @@ export type Broadcaster<E extends EmissionMap> = <K extends keyof E>(
) => Promise<z.output<TuplesOrTrue<E[K]["ack"]>>>;

export type RoomService<E extends EmissionMap> = (rooms: string | string[]) => {
/**
* @desc Emits an event to everyone in the specified room(s)
* @throws z.ZodError on validation
* @throws Error on ack timeout
* */
broadcast: Broadcaster<E>;
join: () => void | Promise<void>;
leave: () => void | Promise<void>;
Expand Down
32 changes: 32 additions & 0 deletions src/remote-client.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
import { RemoteSocket } from "socket.io";
import { describe, expect, test } from "vitest";
import { getRemoteClients } from "./remote-client";

describe("RemoteClient", () => {
describe("getRemoteClients()", () => {
const socketsMock = [
{ id: "ONE", rooms: new Set(["room1"]), data: { name: "TEST" } },
{ id: "TWO", rooms: new Set(["room2"]) },
];

test("should map RemoteSockets to RemoteClients", () => {
const clients = getRemoteClients(socketsMock as RemoteSocket<any, any>[]);
expect(clients).toEqual([
{
id: "ONE",
rooms: ["room1"],
getData: expect.any(Function),
},
{
id: "TWO",
rooms: ["room2"],
getData: expect.any(Function),
},
]);

// getData:
expect(clients[0].getData()).toEqual({ name: "TEST" });
expect(clients[1].getData()).toEqual({});
});
});
});
11 changes: 7 additions & 4 deletions src/remote-client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,9 +3,12 @@ import { RemoteSocket } from "socket.io";
export interface RemoteClient {
id: string;
rooms: string[];
getData: <D extends object>() => Readonly<Partial<D>>;
}

export const getRemoteClients = (
sockets: RemoteSocket<{}, unknown>[],
): RemoteClient[] =>
sockets.map(({ id, rooms }) => ({ id, rooms: Array.from(rooms) }));
export const getRemoteClients = (sockets: RemoteSocket<{}, unknown>[]) =>
sockets.map<RemoteClient>(({ id, rooms, data }) => ({
id: id,
rooms: Array.from(rooms),
getData: <D extends object>() => (data as D) || {},
}));

0 comments on commit be42031

Please sign in to comment.