From 462f3cb79b39ee768ee1f6cc110bf1c66e27ca10 Mon Sep 17 00:00:00 2001 From: Robin Tail Date: Fri, 9 Feb 2024 13:27:57 +0100 Subject: [PATCH 01/12] Client metadata draft. --- src/action.spec.ts | 18 +++++++++++++++--- src/action.ts | 28 ++++++++++++++++++---------- src/actions-factory.ts | 13 ++++++++----- src/attach.spec.ts | 14 ++++++++++++++ src/attach.ts | 16 ++++++++++------ src/config.ts | 20 +++++++++++++++++--- src/emission.ts | 20 +++++++++++--------- src/metadata.ts | 5 +++++ 8 files changed, 98 insertions(+), 36 deletions(-) create mode 100644 src/metadata.ts diff --git a/src/action.spec.ts b/src/action.spec.ts index 4ea1fd13..f9582544 100644 --- a/src/action.spec.ts +++ b/src/action.spec.ts @@ -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({ @@ -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(); @@ -65,10 +69,12 @@ describe("Action", () => { getRooms: getAllRoomsMock, }, client: { + id: "ID", emit: emitMock, isConnected: isConnectedMock, getRooms: getRoomsMock, - id: "ID", + getData: getDataMock, + setData: setDataMock, }, }); }); @@ -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(); @@ -99,6 +107,8 @@ describe("Action", () => { getRooms: getRoomsMock, isConnected: isConnectedMock, emit: emitMock, + getData: getDataMock, + setData: setDataMock, }, all: { broadcast: broadcastMock, @@ -128,6 +138,8 @@ describe("Action", () => { getRooms: getRoomsMock, isConnected: isConnectedMock, emit: emitMock, + getData: getDataMock, + setData: setDataMock, }, }); expect(loggerMock.error).toHaveBeenCalled(); diff --git a/src/action.ts b/src/action.ts index ae2c4483..bffb575b 100644 --- a/src/action.ts +++ b/src/action.ts @@ -4,9 +4,10 @@ import { z } from "zod"; import { ActionNoAckDef, ActionWithAckDef } from "./actions-factory"; import { Broadcaster, EmissionMap, Emitter, RoomService } from "./emission"; import { AbstractLogger } from "./logger"; +import { Metadata } from "./metadata"; import { RemoteClient } from "./remote-client"; -export interface Client { +export interface Client { /** @alias Socket.connected */ isConnected: () => boolean; /** @alias Socket.id */ @@ -15,12 +16,14 @@ export interface Client { getRooms: () => string[]; /** @desc Sends a new event to the client (this is not acknowledgement) */ emit: Emitter; + getData: () => Readonly>; + setData: (next: z.input) => void; } -export interface HandlingFeatures { +export interface HandlingFeatures { logger: AbstractLogger; /** @desc The scope of the owner of the received event */ - client: Client; + client: Client; /** @desc The global scope */ all: { /** @desc Emits to everyone */ @@ -34,10 +37,10 @@ export interface HandlingFeatures { withRooms: RoomService; } -export type Handler = ( +export type Handler = ( params: { input: IN; - } & HandlingFeatures, + } & HandlingFeatures, ) => Promise; export abstract class AbstractAction { @@ -45,7 +48,7 @@ export abstract class AbstractAction { params: { event: string; params: unknown[]; - } & HandlingFeatures, + } & HandlingFeatures, ): Promise; } @@ -59,12 +62,17 @@ export class Action< > extends AbstractAction { readonly #inputSchema: IN; readonly #outputSchema: OUT | undefined; - readonly #handler: Handler, z.input | void, EmissionMap>; + readonly #handler: Handler< + z.output, + z.input | void, + EmissionMap, + Metadata + >; public constructor( action: - | ActionWithAckDef - | ActionNoAckDef, + | ActionWithAckDef + | ActionNoAckDef, ) { super(); this.#inputSchema = action.input; @@ -102,7 +110,7 @@ export class Action< }: { event: string; params: unknown[]; - } & HandlingFeatures): Promise { + } & HandlingFeatures): Promise { try { const input = this.#parseInput(params); logger.debug( diff --git a/src/actions-factory.ts b/src/actions-factory.ts index 1c22f4aa..7f680d5d 100644 --- a/src/actions-factory.ts +++ b/src/actions-factory.ts @@ -2,35 +2,38 @@ import { z } from "zod"; import { Action, Handler } from "./action"; import { Config } from "./config"; import { EmissionMap } from "./emission"; +import { Metadata } from "./metadata"; export interface ActionNoAckDef< IN extends z.AnyZodTuple, E extends EmissionMap, + D extends Metadata, > { /** @desc The incoming event payload validation schema (no acknowledgement) */ input: IN; /** @desc No output schema => no returns => no acknowledgement */ - handler: Handler, void, E>; + handler: Handler, void, E, D>; } export interface ActionWithAckDef< IN extends z.AnyZodTuple, OUT extends z.AnyZodTuple, E extends EmissionMap, + D extends Metadata, > { /** @desc The incoming event payload (excl. acknowledgement) validation schema */ input: IN; /** @desc The acknowledgement validation schema */ output: OUT; /** @desc The returns become an Acknowledgement */ - handler: Handler, z.input, E>; + handler: Handler, z.input, E, D>; } -export class ActionsFactory { - constructor(protected config: Config) {} +export class ActionsFactory { + constructor(protected config: Config) {} public build( - def: ActionNoAckDef | ActionWithAckDef, + def: ActionNoAckDef | ActionWithAckDef, ): Action { return new Action(def); } diff --git a/src/attach.spec.ts b/src/attach.spec.ts index 947acfde..0ed3d4e0 100644 --- a/src/attach.spec.ts +++ b/src/attach.spec.ts @@ -92,6 +92,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), @@ -125,6 +127,18 @@ describe("Attach", () => { { id: "ID", rooms: ["room1", "room2"] }, { id: "other", rooms: ["room3"] }, ]); + + // 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", + }); }); }); }); diff --git a/src/attach.ts b/src/attach.ts index 05d69c96..e98dda6d 100644 --- a/src/attach.ts +++ b/src/attach.ts @@ -8,9 +8,10 @@ import { makeEmitter, makeRoomService, } from "./emission"; +import { Metadata, defaultMeta } from "./metadata"; import { getRemoteClients } from "./remote-client"; -export const attachSockets = ({ +export const attachSockets = ({ io, actions, target, @@ -38,11 +39,11 @@ export const attachSockets = ({ * */ target: http.Server; /** @desc The configuration describing the emission (outgoing events) */ - config: Config; + config: Config; /** @desc A place for emitting events unrelated to the incoming events */ - onConnection?: Handler<[], void, E>; - onDisconnect?: Handler<[], void, E>; - onAnyEvent?: Handler<[string], void, E>; + onConnection?: Handler<[], void, E, D>; + onDisconnect?: Handler<[], void, E, D>; + onAnyEvent?: Handler<[string], void, E, D>; }): Server => { config.logger.info("ZOD-SOCKETS", target.address()); const rootNS = io.of("/"); @@ -53,12 +54,15 @@ export const attachSockets = ({ const emit = makeEmitter({ socket, config }); const broadcast = makeBroadcaster({ socket, config }); const withRooms = makeRoomService({ socket, config }); - const commons: HandlingFeatures = { + const commons: HandlingFeatures = { client: { emit, id: socket.id, isConnected: () => socket.connected, getRooms: () => Array.from(socket.rooms), + getData: () => socket.data, + setData: (next) => + (socket.data = (config.metadata || defaultMeta).parse(next)), }, all: { broadcast, diff --git a/src/config.ts b/src/config.ts index 08c83648..0dcc09e7 100644 --- a/src/config.ts +++ b/src/config.ts @@ -1,11 +1,25 @@ import { EmissionMap } from "./emission"; import { AbstractLogger } from "./logger"; +import { Metadata } from "./metadata"; -export interface Config { +export interface Config { + /** + * @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; + /** + * @desc The schema of the client's metadata + * @example z.object({ username: z.string() }) + * @default z.object({}).passthrough() + * */ + metadata?: D; } -export const createConfig = (config: Config) => - config; +export const createConfig = ( + config: Config, +) => config; diff --git a/src/emission.ts b/src/emission.ts index 3422a3d4..4b3c4732 100644 --- a/src/emission.ts +++ b/src/emission.ts @@ -2,6 +2,7 @@ import assert from "node:assert/strict"; import type { Socket } from "socket.io"; import { z } from "zod"; import { Config } from "./config"; +import { Metadata } from "./metadata"; import { RemoteClient, getRemoteClients } from "./remote-client"; export interface Emission { @@ -44,7 +45,7 @@ const makeGenericEmitter = target, config: { logger, emission, timeout }, }: { - config: Config; + config: Config; target: Socket | Socket["broadcast"]; }) => async (event: string, ...args: unknown[]) => { @@ -65,26 +66,27 @@ const makeGenericEmitter = return (isSocket ? ack : ack.array()).parse(response); }; -interface MakerParams { +interface MakerParams { socket: Socket; - config: Config; + config: Config; } -export const makeEmitter = ({ +export const makeEmitter = ({ socket: target, ...rest -}: MakerParams) => makeGenericEmitter({ ...rest, target }) as Emitter; +}: MakerParams) => makeGenericEmitter({ ...rest, target }) as Emitter; -export const makeBroadcaster = ({ +export const makeBroadcaster = ({ socket: { broadcast: target }, ...rest -}: MakerParams) => makeGenericEmitter({ ...rest, target }) as Broadcaster; +}: MakerParams) => + makeGenericEmitter({ ...rest, target }) as Broadcaster; export const makeRoomService = - ({ + ({ socket, ...rest - }: MakerParams): RoomService => + }: MakerParams): RoomService => (rooms) => ({ getClients: async () => getRemoteClients(await socket.in(rooms).fetchSockets()), diff --git a/src/metadata.ts b/src/metadata.ts new file mode 100644 index 00000000..d0be9a4c --- /dev/null +++ b/src/metadata.ts @@ -0,0 +1,5 @@ +import { z } from "zod"; + +export type Metadata = z.SomeZodObject; + +export const defaultMeta = z.object({}).passthrough(); From 662bb3be44591bcaed6454087c5eed291dc48015 Mon Sep 17 00:00:00 2001 From: Robin Tail Date: Fri, 9 Feb 2024 13:44:08 +0100 Subject: [PATCH 02/12] Equipping the remote clients too. --- coverage.svg | 2 +- src/action.ts | 4 ++-- src/attach.spec.ts | 14 ++++++++++++-- src/attach.ts | 9 ++++----- src/emission.spec.ts | 14 ++++++++++++-- src/emission.ts | 12 ++++++++---- src/metadata.ts | 5 +++++ src/remote-client.ts | 20 +++++++++++++++----- 8 files changed, 59 insertions(+), 21 deletions(-) diff --git a/coverage.svg b/coverage.svg index 5bb55be2..424ee9c0 100644 --- a/coverage.svg +++ b/coverage.svg @@ -1 +1 @@ -Coverage: 100%Coverage100% \ No newline at end of file +Coverage: 97.61%Coverage97.61% \ No newline at end of file diff --git a/src/action.ts b/src/action.ts index bffb575b..e2cb2fb4 100644 --- a/src/action.ts +++ b/src/action.ts @@ -31,10 +31,10 @@ export interface HandlingFeatures { /** @desc Returns the list of available rooms */ getRooms: () => string[]; /** @desc Returns the list of familiar clients */ - getClients: () => Promise; + getClients: () => Promise[]>; }; /** @desc Provides room(s)-scope methods */ - withRooms: RoomService; + withRooms: RoomService; } export type Handler = ( diff --git a/src/attach.spec.ts b/src/attach.spec.ts index 0ed3d4e0..af0b491d 100644 --- a/src/attach.spec.ts +++ b/src/attach.spec.ts @@ -124,8 +124,18 @@ 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), + setData: expect.any(Function), + }, + { + id: "other", + rooms: ["room3"], + getData: expect.any(Function), + setData: expect.any(Function), + }, ]); // client.setData: diff --git a/src/attach.ts b/src/attach.ts index e98dda6d..45a0756c 100644 --- a/src/attach.ts +++ b/src/attach.ts @@ -8,7 +8,7 @@ import { makeEmitter, makeRoomService, } from "./emission"; -import { Metadata, defaultMeta } from "./metadata"; +import { Metadata, parseMeta } from "./metadata"; import { getRemoteClients } from "./remote-client"; export const attachSockets = ({ @@ -49,7 +49,7 @@ export const attachSockets = ({ const rootNS = io.of("/"); const getAllRooms = () => Array.from(rootNS.adapter.rooms.keys()); const getAllClients = async () => - getRemoteClients(await rootNS.fetchSockets()); + getRemoteClients(await rootNS.fetchSockets(), config.metadata); io.on("connection", async (socket) => { const emit = makeEmitter({ socket, config }); const broadcast = makeBroadcaster({ socket, config }); @@ -60,9 +60,8 @@ export const attachSockets = ({ id: socket.id, isConnected: () => socket.connected, getRooms: () => Array.from(socket.rooms), - getData: () => socket.data, - setData: (next) => - (socket.data = (config.metadata || defaultMeta).parse(next)), + getData: () => parseMeta(socket.data, config.metadata), + setData: (next) => (socket.data = parseMeta(next, config.metadata)), }, all: { broadcast, diff --git a/src/emission.spec.ts b/src/emission.spec.ts index aedb5d09..966c24d1 100644 --- a/src/emission.spec.ts +++ b/src/emission.spec.ts @@ -103,8 +103,18 @@ 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), + setData: expect.any(Function), + }, + { + id: "other", + rooms: ["room3"], + getData: expect.any(Function), + setData: expect.any(Function), + }, ]); }, ); diff --git a/src/emission.ts b/src/emission.ts index 4b3c4732..c6532cf5 100644 --- a/src/emission.ts +++ b/src/emission.ts @@ -29,11 +29,13 @@ export type Broadcaster = ( ...args: z.input ) => Promise>>; -export type RoomService = (rooms: string | string[]) => { +export type RoomService = ( + rooms: string | string[], +) => { broadcast: Broadcaster; join: () => void | Promise; leave: () => void | Promise; - getClients: () => Promise; + getClients: () => Promise[]>; }; /** @@ -85,11 +87,12 @@ export const makeBroadcaster = ({ export const makeRoomService = ({ socket, + config, ...rest - }: MakerParams): RoomService => + }: MakerParams): RoomService => (rooms) => ({ getClients: async () => - getRemoteClients(await socket.in(rooms).fetchSockets()), + getRemoteClients(await socket.in(rooms).fetchSockets(), config.metadata), join: () => socket.join(rooms), leave: () => typeof rooms === "string" @@ -97,6 +100,7 @@ export const makeRoomService = : Promise.all(rooms.map((room) => socket.leave(room))).then(() => {}), broadcast: makeGenericEmitter({ ...rest, + config, target: socket.to(rooms), }) as Broadcaster, }); diff --git a/src/metadata.ts b/src/metadata.ts index d0be9a4c..d4a4ec6c 100644 --- a/src/metadata.ts +++ b/src/metadata.ts @@ -3,3 +3,8 @@ import { z } from "zod"; export type Metadata = z.SomeZodObject; export const defaultMeta = z.object({}).passthrough(); + +export const parseMeta = ( + data: unknown, + schema: D | undefined, +): z.output => (schema || defaultMeta).parse(data); diff --git a/src/remote-client.ts b/src/remote-client.ts index 3f92f1d5..c9b367d1 100644 --- a/src/remote-client.ts +++ b/src/remote-client.ts @@ -1,11 +1,21 @@ import { RemoteSocket } from "socket.io"; +import { z } from "zod"; +import { Metadata, parseMeta } from "./metadata"; -export interface RemoteClient { +export interface RemoteClient { id: string; rooms: string[]; + getData: () => Readonly>; + setData: (next: z.input) => void; } -export const getRemoteClients = ( - sockets: RemoteSocket<{}, unknown>[], -): RemoteClient[] => - sockets.map(({ id, rooms }) => ({ id, rooms: Array.from(rooms) })); +export const getRemoteClients = ( + sockets: RemoteSocket<{}, z.output>[], + metaSchema: D | undefined, +): RemoteClient[] => + sockets.map(({ id, rooms, data }) => ({ + id, + rooms: Array.from(rooms), + getData: () => parseMeta(data, metaSchema), + setData: (next) => (data = parseMeta(next, metaSchema)), + })); From 1014bd27709c2248d1f8a71577348d92d5ee6cdf Mon Sep 17 00:00:00 2001 From: Robin Tail Date: Fri, 9 Feb 2024 13:53:22 +0100 Subject: [PATCH 03/12] Fix: remote one is readonly. --- coverage.svg | 2 +- src/attach.spec.ts | 2 -- src/emission.spec.ts | 2 -- src/remote-client.spec.ts | 28 ++++++++++++++++++++++++++++ src/remote-client.ts | 4 +--- 5 files changed, 30 insertions(+), 8 deletions(-) create mode 100644 src/remote-client.spec.ts diff --git a/coverage.svg b/coverage.svg index 424ee9c0..5bb55be2 100644 --- a/coverage.svg +++ b/coverage.svg @@ -1 +1 @@ -Coverage: 97.61%Coverage97.61% \ No newline at end of file +Coverage: 100%Coverage100% \ No newline at end of file diff --git a/src/attach.spec.ts b/src/attach.spec.ts index af0b491d..41856ac7 100644 --- a/src/attach.spec.ts +++ b/src/attach.spec.ts @@ -128,13 +128,11 @@ describe("Attach", () => { id: "ID", rooms: ["room1", "room2"], getData: expect.any(Function), - setData: expect.any(Function), }, { id: "other", rooms: ["room3"], getData: expect.any(Function), - setData: expect.any(Function), }, ]); diff --git a/src/emission.spec.ts b/src/emission.spec.ts index 966c24d1..c74c5693 100644 --- a/src/emission.spec.ts +++ b/src/emission.spec.ts @@ -107,13 +107,11 @@ describe("Emission", () => { id: "ID", rooms: ["room1", "room2"], getData: expect.any(Function), - setData: expect.any(Function), }, { id: "other", rooms: ["room3"], getData: expect.any(Function), - setData: expect.any(Function), }, ]); }, diff --git a/src/remote-client.spec.ts b/src/remote-client.spec.ts new file mode 100644 index 00000000..c0743c96 --- /dev/null +++ b/src/remote-client.spec.ts @@ -0,0 +1,28 @@ +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" } }, + ]; + + test("should map RemoteSockets to RemoteClients", () => { + const clients = getRemoteClients( + socketsMock as RemoteSocket[], + undefined, + ); + expect(clients).toEqual([ + { + id: "ONE", + rooms: ["room1"], + getData: expect.any(Function), + }, + ]); + + // getData: + expect(clients[0].getData()).toEqual({ name: "TEST" }); + }); + }); +}); diff --git a/src/remote-client.ts b/src/remote-client.ts index c9b367d1..884e7ce1 100644 --- a/src/remote-client.ts +++ b/src/remote-client.ts @@ -6,7 +6,6 @@ export interface RemoteClient { id: string; rooms: string[]; getData: () => Readonly>; - setData: (next: z.input) => void; } export const getRemoteClients = ( @@ -14,8 +13,7 @@ export const getRemoteClients = ( metaSchema: D | undefined, ): RemoteClient[] => sockets.map(({ id, rooms, data }) => ({ - id, + id: id, rooms: Array.from(rooms), getData: () => parseMeta(data, metaSchema), - setData: (next) => (data = parseMeta(next, metaSchema)), })); From af7a099baf9ddbffc74f5db0582679dc44cc9d7a Mon Sep 17 00:00:00 2001 From: Robin Tail Date: Fri, 9 Feb 2024 19:00:21 +0100 Subject: [PATCH 04/12] More JSdoc. --- src/action.ts | 20 ++++++++++++++++++-- src/emission.ts | 5 +++++ 2 files changed, 23 insertions(+), 2 deletions(-) diff --git a/src/action.ts b/src/action.ts index e2cb2fb4..0037e7b2 100644 --- a/src/action.ts +++ b/src/action.ts @@ -14,9 +14,21 @@ export interface Client { 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; + /** + * @desc Returns the client metadata according to the schema specified in config + * @throws z.ZodError on validation + * */ getData: () => Readonly>; + /** + * @desc Sets the client metadata according to the schema specified in config + * @throws z.ZodError on validation + * */ setData: (next: z.input) => void; } @@ -26,7 +38,11 @@ export interface HandlingFeatures { client: Client; /** @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; /** @desc Returns the list of available rooms */ getRooms: () => string[]; diff --git a/src/emission.ts b/src/emission.ts index c6532cf5..ec69eb63 100644 --- a/src/emission.ts +++ b/src/emission.ts @@ -32,6 +32,11 @@ export type Broadcaster = ( export type RoomService = ( 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; join: () => void | Promise; leave: () => void | Promise; From 8a590b03c7c47bee71c46af91703aade0f0e20cf Mon Sep 17 00:00:00 2001 From: Robin Tail Date: Fri, 9 Feb 2024 19:17:32 +0100 Subject: [PATCH 05/12] Add fallback empty object to parseMeta(). --- src/metadata.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/metadata.ts b/src/metadata.ts index d4a4ec6c..ed011d2e 100644 --- a/src/metadata.ts +++ b/src/metadata.ts @@ -7,4 +7,4 @@ export const defaultMeta = z.object({}).passthrough(); export const parseMeta = ( data: unknown, schema: D | undefined, -): z.output => (schema || defaultMeta).parse(data); +): z.output => (schema || defaultMeta).parse(data || {}); From 6676204fbbc09dd422ac0cb60fd99d835fc03ff2 Mon Sep 17 00:00:00 2001 From: Robin Tail Date: Fri, 9 Feb 2024 19:36:12 +0100 Subject: [PATCH 06/12] Rev: no metadata schema and no parsing: getData() and setData() accept type argument instead. --- src/action.ts | 34 ++++++++++++++-------------------- src/actions-factory.ts | 13 +++++-------- src/attach.ts | 19 +++++++++---------- src/config.ts | 14 +++----------- src/emission.ts | 30 ++++++++++++------------------ src/metadata.ts | 10 ---------- src/remote-client.spec.ts | 5 +---- src/remote-client.ts | 15 ++++++--------- 8 files changed, 50 insertions(+), 90 deletions(-) delete mode 100644 src/metadata.ts diff --git a/src/action.ts b/src/action.ts index 0037e7b2..af1802e9 100644 --- a/src/action.ts +++ b/src/action.ts @@ -4,10 +4,9 @@ import { z } from "zod"; import { ActionNoAckDef, ActionWithAckDef } from "./actions-factory"; import { Broadcaster, EmissionMap, Emitter, RoomService } from "./emission"; import { AbstractLogger } from "./logger"; -import { Metadata } from "./metadata"; import { RemoteClient } from "./remote-client"; -export interface Client { +export interface Client { /** @alias Socket.connected */ isConnected: () => boolean; /** @alias Socket.id */ @@ -24,18 +23,18 @@ export interface Client { * @desc Returns the client metadata according to the schema specified in config * @throws z.ZodError on validation * */ - getData: () => Readonly>; + getData: () => Readonly; /** * @desc Sets the client metadata according to the schema specified in config * @throws z.ZodError on validation * */ - setData: (next: z.input) => void; + setData: (value: D) => void; } -export interface HandlingFeatures { +export interface HandlingFeatures { logger: AbstractLogger; /** @desc The scope of the owner of the received event */ - client: Client; + client: Client; /** @desc The global scope */ all: { /** @@ -47,16 +46,16 @@ export interface HandlingFeatures { /** @desc Returns the list of available rooms */ getRooms: () => string[]; /** @desc Returns the list of familiar clients */ - getClients: () => Promise[]>; + getClients: () => Promise; }; /** @desc Provides room(s)-scope methods */ - withRooms: RoomService; + withRooms: RoomService; } -export type Handler = ( +export type Handler = ( params: { input: IN; - } & HandlingFeatures, + } & HandlingFeatures, ) => Promise; export abstract class AbstractAction { @@ -64,7 +63,7 @@ export abstract class AbstractAction { params: { event: string; params: unknown[]; - } & HandlingFeatures, + } & HandlingFeatures, ): Promise; } @@ -78,17 +77,12 @@ export class Action< > extends AbstractAction { readonly #inputSchema: IN; readonly #outputSchema: OUT | undefined; - readonly #handler: Handler< - z.output, - z.input | void, - EmissionMap, - Metadata - >; + readonly #handler: Handler, z.input | void, EmissionMap>; public constructor( action: - | ActionWithAckDef - | ActionNoAckDef, + | ActionWithAckDef + | ActionNoAckDef, ) { super(); this.#inputSchema = action.input; @@ -126,7 +120,7 @@ export class Action< }: { event: string; params: unknown[]; - } & HandlingFeatures): Promise { + } & HandlingFeatures): Promise { try { const input = this.#parseInput(params); logger.debug( diff --git a/src/actions-factory.ts b/src/actions-factory.ts index 7f680d5d..1c22f4aa 100644 --- a/src/actions-factory.ts +++ b/src/actions-factory.ts @@ -2,38 +2,35 @@ import { z } from "zod"; import { Action, Handler } from "./action"; import { Config } from "./config"; import { EmissionMap } from "./emission"; -import { Metadata } from "./metadata"; export interface ActionNoAckDef< IN extends z.AnyZodTuple, E extends EmissionMap, - D extends Metadata, > { /** @desc The incoming event payload validation schema (no acknowledgement) */ input: IN; /** @desc No output schema => no returns => no acknowledgement */ - handler: Handler, void, E, D>; + handler: Handler, void, E>; } export interface ActionWithAckDef< IN extends z.AnyZodTuple, OUT extends z.AnyZodTuple, E extends EmissionMap, - D extends Metadata, > { /** @desc The incoming event payload (excl. acknowledgement) validation schema */ input: IN; /** @desc The acknowledgement validation schema */ output: OUT; /** @desc The returns become an Acknowledgement */ - handler: Handler, z.input, E, D>; + handler: Handler, z.input, E>; } -export class ActionsFactory { - constructor(protected config: Config) {} +export class ActionsFactory { + constructor(protected config: Config) {} public build( - def: ActionNoAckDef | ActionWithAckDef, + def: ActionNoAckDef | ActionWithAckDef, ): Action { return new Action(def); } diff --git a/src/attach.ts b/src/attach.ts index 45a0756c..0b4c84c8 100644 --- a/src/attach.ts +++ b/src/attach.ts @@ -8,10 +8,9 @@ import { makeEmitter, makeRoomService, } from "./emission"; -import { Metadata, parseMeta } from "./metadata"; import { getRemoteClients } from "./remote-client"; -export const attachSockets = ({ +export const attachSockets = ({ io, actions, target, @@ -39,29 +38,29 @@ export const attachSockets = ({ * */ target: http.Server; /** @desc The configuration describing the emission (outgoing events) */ - config: Config; + config: Config; /** @desc A place for emitting events unrelated to the incoming events */ - onConnection?: Handler<[], void, E, D>; - onDisconnect?: Handler<[], void, E, D>; - onAnyEvent?: Handler<[string], void, E, D>; + onConnection?: Handler<[], void, E>; + onDisconnect?: Handler<[], void, E>; + onAnyEvent?: Handler<[string], void, E>; }): Server => { config.logger.info("ZOD-SOCKETS", target.address()); const rootNS = io.of("/"); const getAllRooms = () => Array.from(rootNS.adapter.rooms.keys()); const getAllClients = async () => - getRemoteClients(await rootNS.fetchSockets(), config.metadata); + getRemoteClients(await rootNS.fetchSockets()); io.on("connection", async (socket) => { const emit = makeEmitter({ socket, config }); const broadcast = makeBroadcaster({ socket, config }); const withRooms = makeRoomService({ socket, config }); - const commons: HandlingFeatures = { + const commons: HandlingFeatures = { client: { emit, id: socket.id, isConnected: () => socket.connected, getRooms: () => Array.from(socket.rooms), - getData: () => parseMeta(socket.data, config.metadata), - setData: (next) => (socket.data = parseMeta(next, config.metadata)), + getData: () => socket.data, + setData: (value) => (socket.data = value), }, all: { broadcast, diff --git a/src/config.ts b/src/config.ts index 0dcc09e7..e46d47dd 100644 --- a/src/config.ts +++ b/src/config.ts @@ -1,8 +1,7 @@ import { EmissionMap } from "./emission"; import { AbstractLogger } from "./logger"; -import { Metadata } from "./metadata"; -export interface Config { +export interface Config { /** * @desc The instance of a logger * @example console @@ -12,14 +11,7 @@ export interface Config { timeout: number; /** @desc The events that the server can emit */ emission: E; - /** - * @desc The schema of the client's metadata - * @example z.object({ username: z.string() }) - * @default z.object({}).passthrough() - * */ - metadata?: D; } -export const createConfig = ( - config: Config, -) => config; +export const createConfig = (config: Config) => + config; diff --git a/src/emission.ts b/src/emission.ts index ec69eb63..a8c2e5ef 100644 --- a/src/emission.ts +++ b/src/emission.ts @@ -2,7 +2,6 @@ import assert from "node:assert/strict"; import type { Socket } from "socket.io"; import { z } from "zod"; import { Config } from "./config"; -import { Metadata } from "./metadata"; import { RemoteClient, getRemoteClients } from "./remote-client"; export interface Emission { @@ -29,9 +28,7 @@ export type Broadcaster = ( ...args: z.input ) => Promise>>; -export type RoomService = ( - rooms: string | string[], -) => { +export type RoomService = (rooms: string | string[]) => { /** * @desc Emits an event to everyone in the specified room(s) * @throws z.ZodError on validation @@ -40,7 +37,7 @@ export type RoomService = ( broadcast: Broadcaster; join: () => void | Promise; leave: () => void | Promise; - getClients: () => Promise[]>; + getClients: () => Promise; }; /** @@ -52,7 +49,7 @@ const makeGenericEmitter = target, config: { logger, emission, timeout }, }: { - config: Config; + config: Config; target: Socket | Socket["broadcast"]; }) => async (event: string, ...args: unknown[]) => { @@ -73,31 +70,29 @@ const makeGenericEmitter = return (isSocket ? ack : ack.array()).parse(response); }; -interface MakerParams { +interface MakerParams { socket: Socket; - config: Config; + config: Config; } -export const makeEmitter = ({ +export const makeEmitter = ({ socket: target, ...rest -}: MakerParams) => makeGenericEmitter({ ...rest, target }) as Emitter; +}: MakerParams) => makeGenericEmitter({ ...rest, target }) as Emitter; -export const makeBroadcaster = ({ +export const makeBroadcaster = ({ socket: { broadcast: target }, ...rest -}: MakerParams) => - makeGenericEmitter({ ...rest, target }) as Broadcaster; +}: MakerParams) => makeGenericEmitter({ ...rest, target }) as Broadcaster; export const makeRoomService = - ({ + ({ socket, - config, ...rest - }: MakerParams): RoomService => + }: MakerParams): RoomService => (rooms) => ({ getClients: async () => - getRemoteClients(await socket.in(rooms).fetchSockets(), config.metadata), + getRemoteClients(await socket.in(rooms).fetchSockets()), join: () => socket.join(rooms), leave: () => typeof rooms === "string" @@ -105,7 +100,6 @@ export const makeRoomService = : Promise.all(rooms.map((room) => socket.leave(room))).then(() => {}), broadcast: makeGenericEmitter({ ...rest, - config, target: socket.to(rooms), }) as Broadcaster, }); diff --git a/src/metadata.ts b/src/metadata.ts deleted file mode 100644 index ed011d2e..00000000 --- a/src/metadata.ts +++ /dev/null @@ -1,10 +0,0 @@ -import { z } from "zod"; - -export type Metadata = z.SomeZodObject; - -export const defaultMeta = z.object({}).passthrough(); - -export const parseMeta = ( - data: unknown, - schema: D | undefined, -): z.output => (schema || defaultMeta).parse(data || {}); diff --git a/src/remote-client.spec.ts b/src/remote-client.spec.ts index c0743c96..db590d39 100644 --- a/src/remote-client.spec.ts +++ b/src/remote-client.spec.ts @@ -9,10 +9,7 @@ describe("RemoteClient", () => { ]; test("should map RemoteSockets to RemoteClients", () => { - const clients = getRemoteClients( - socketsMock as RemoteSocket[], - undefined, - ); + const clients = getRemoteClients(socketsMock as RemoteSocket[]); expect(clients).toEqual([ { id: "ONE", diff --git a/src/remote-client.ts b/src/remote-client.ts index 884e7ce1..4efb7614 100644 --- a/src/remote-client.ts +++ b/src/remote-client.ts @@ -1,19 +1,16 @@ import { RemoteSocket } from "socket.io"; -import { z } from "zod"; -import { Metadata, parseMeta } from "./metadata"; -export interface RemoteClient { +export interface RemoteClient { id: string; rooms: string[]; - getData: () => Readonly>; + getData: () => Readonly; } -export const getRemoteClients = ( - sockets: RemoteSocket<{}, z.output>[], - metaSchema: D | undefined, -): RemoteClient[] => +export const getRemoteClients = ( + sockets: RemoteSocket<{}, unknown>[], +): RemoteClient[] => sockets.map(({ id, rooms, data }) => ({ id: id, rooms: Array.from(rooms), - getData: () => parseMeta(data, metaSchema), + getData: () => data as D, })); From bb7ee7bb3abf06b4d695ef1f5da6cda95dd7360f Mon Sep 17 00:00:00 2001 From: Robin Tail Date: Fri, 9 Feb 2024 19:39:16 +0100 Subject: [PATCH 07/12] Ref: getRemoteClients() return type. --- src/remote-client.ts | 8 +++----- 1 file changed, 3 insertions(+), 5 deletions(-) diff --git a/src/remote-client.ts b/src/remote-client.ts index 4efb7614..1eb96a25 100644 --- a/src/remote-client.ts +++ b/src/remote-client.ts @@ -6,11 +6,9 @@ export interface RemoteClient { getData: () => Readonly; } -export const getRemoteClients = ( - sockets: RemoteSocket<{}, unknown>[], -): RemoteClient[] => - sockets.map(({ id, rooms, data }) => ({ +export const getRemoteClients = (sockets: RemoteSocket<{}, unknown>[]) => + sockets.map(({ id, rooms, data }) => ({ id: id, rooms: Array.from(rooms), - getData: () => data as D, + getData: () => data as D, })); From e7371ea84d477339f2e5415068d1a60cf42eacfd Mon Sep 17 00:00:00 2001 From: Robin Tail Date: Fri, 9 Feb 2024 19:45:55 +0100 Subject: [PATCH 08/12] Logging metadata by default and fallback object values for getters. --- src/attach.spec.ts | 16 +++++++--------- src/attach.ts | 14 +++++++------- src/remote-client.ts | 2 +- 3 files changed, 15 insertions(+), 17 deletions(-) diff --git a/src/attach.spec.ts b/src/attach.spec.ts index 41856ac7..c0f780b8 100644 --- a/src/attach.spec.ts +++ b/src/attach.spec.ts @@ -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( @@ -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"); diff --git a/src/attach.ts b/src/attach.ts index 0b4c84c8..7745c97f 100644 --- a/src/attach.ts +++ b/src/attach.ts @@ -15,12 +15,12 @@ export const attachSockets = ({ 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 @@ -59,7 +59,7 @@ export const attachSockets = ({ id: socket.id, isConnected: () => socket.connected, getRooms: () => Array.from(socket.rooms), - getData: () => socket.data, + getData: () => socket.data || {}, setData: (value) => (socket.data = value), }, all: { diff --git a/src/remote-client.ts b/src/remote-client.ts index 1eb96a25..4be4796f 100644 --- a/src/remote-client.ts +++ b/src/remote-client.ts @@ -10,5 +10,5 @@ export const getRemoteClients = (sockets: RemoteSocket<{}, unknown>[]) => sockets.map(({ id, rooms, data }) => ({ id: id, rooms: Array.from(rooms), - getData: () => data as D, + getData: () => (data as D) || {}, })); From c138b7041147bece4a301a7e5f335e420a0d8c15 Mon Sep 17 00:00:00 2001 From: Robin Tail Date: Fri, 9 Feb 2024 19:55:15 +0100 Subject: [PATCH 09/12] Example: message count metadata with constraints. --- example/actions/chat.ts | 4 ++++ example/config.ts | 6 ++++++ 2 files changed, 10 insertions(+) diff --git a/example/actions/chat.ts b/example/actions/chat.ts index f155bf04..e7a48bd3 100644 --- a/example/actions/chat.ts +++ b/example/actions/chat.ts @@ -1,4 +1,5 @@ import { z } from "zod"; +import { Metadata } from "../config"; import { actionsFactory } from "../factories"; export const onChat = actionsFactory.build({ @@ -6,6 +7,9 @@ export const onChat = actionsFactory.build({ handler: async ({ input: [message], client, all, logger }) => { try { await all.broadcast("chat", message, { from: client.id }); + client.setData({ + msgCount: (client.getData().msgCount || 0) + 1, + }); } catch (error) { logger.error("Failed to broadcast", error); } diff --git a/example/config.ts b/example/config.ts index e9350b53..28bd8b50 100644 --- a/example/config.ts +++ b/example/config.ts @@ -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, From 5eb0b17cb3d4aeaf83db7cd3d2a3faa421a39f10 Mon Sep 17 00:00:00 2001 From: Robin Tail Date: Fri, 9 Feb 2024 19:57:16 +0100 Subject: [PATCH 10/12] Fallback coverage from RemoteClient::getData(). --- src/remote-client.spec.ts | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/src/remote-client.spec.ts b/src/remote-client.spec.ts index db590d39..2a3e2397 100644 --- a/src/remote-client.spec.ts +++ b/src/remote-client.spec.ts @@ -6,6 +6,7 @@ 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", () => { @@ -16,10 +17,16 @@ describe("RemoteClient", () => { 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({}); }); }); }); From 1f780151d49d462336b7c0b05490a7e1e5c2fb50 Mon Sep 17 00:00:00 2001 From: Robin Tail Date: Fri, 9 Feb 2024 20:00:59 +0100 Subject: [PATCH 11/12] Marking getData() returns as partial to emphasize that it's an empty object initially. --- example/config.ts | 2 +- src/action.ts | 7 ++----- src/remote-client.ts | 2 +- 3 files changed, 4 insertions(+), 7 deletions(-) diff --git a/example/config.ts b/example/config.ts index 28bd8b50..428d483a 100644 --- a/example/config.ts +++ b/example/config.ts @@ -4,7 +4,7 @@ import { createConfig } from "../src"; /** @desc Client metadata */ export interface Metadata { /** @desc Number of messages sent using the chat event */ - msgCount?: number; + msgCount: number; } export const config = createConfig({ diff --git a/src/action.ts b/src/action.ts index af1802e9..5ec8c450 100644 --- a/src/action.ts +++ b/src/action.ts @@ -19,11 +19,8 @@ export interface Client { * @throws Error on ack timeout * */ emit: Emitter; - /** - * @desc Returns the client metadata according to the schema specified in config - * @throws z.ZodError on validation - * */ - getData: () => Readonly; + /** @desc Returns the client metadata according to the specified type or empty object */ + getData: () => Readonly>; /** * @desc Sets the client metadata according to the schema specified in config * @throws z.ZodError on validation diff --git a/src/remote-client.ts b/src/remote-client.ts index 4be4796f..ff74a7ac 100644 --- a/src/remote-client.ts +++ b/src/remote-client.ts @@ -3,7 +3,7 @@ import { RemoteSocket } from "socket.io"; export interface RemoteClient { id: string; rooms: string[]; - getData: () => Readonly; + getData: () => Readonly>; } export const getRemoteClients = (sockets: RemoteSocket<{}, unknown>[]) => From ff026eb1df4084cf5dbe7120a4c9e2bf4e8f8685 Mon Sep 17 00:00:00 2001 From: Anna Bocharova Date: Fri, 9 Feb 2024 20:01:58 +0100 Subject: [PATCH 12/12] Update src/action.ts --- src/action.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/action.ts b/src/action.ts index 5ec8c450..eb2433b6 100644 --- a/src/action.ts +++ b/src/action.ts @@ -22,7 +22,7 @@ export interface Client { /** @desc Returns the client metadata according to the specified type or empty object */ getData: () => Readonly>; /** - * @desc Sets the client metadata according to the schema specified in config + * @desc Sets the client metadata according to the specified type * @throws z.ZodError on validation * */ setData: (value: D) => void;