Skip to content

Commit

Permalink
feat: add support for typed events (socketio#3822)
Browse files Browse the repository at this point in the history
Syntax:

```ts
interface ClientToServerEvents {
  "my-event": (a: number, b: string, c: number[]) => void;
}

interface ServerToClientEvents {
  hello: (message: string) => void;
}

const io = new Server<ClientToServerEvents, ServerToClientEvents>(httpServer);

io.emit("hello", "world");

io.on("connection", (socket) => {
  socket.on("my-event", (a, b, c) => {
    // ...
  });

  socket.emit("hello", "again");
});
```

The events are not typed by default (inferred as any), so this change
is backward compatible.

Note: we could also have reused the method here ([1]) to add types to
the EventEmitter, instead of creating a StrictEventEmitter class.

Related: socketio#3742

[1]: https://github.com/binier/tiny-typed-emitter
  • Loading branch information
MaximeKjaer authored and darrachequesne committed Mar 9, 2021
1 parent b116dfa commit 9f5a872
Show file tree
Hide file tree
Showing 11 changed files with 1,956 additions and 117 deletions.
46 changes: 30 additions & 16 deletions lib/broadcast-operator.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,15 @@ import type { BroadcastFlags, Room, SocketId } from "socket.io-adapter";
import { Handshake, RESERVED_EVENTS, Socket } from "./socket";
import { PacketType } from "socket.io-parser";
import type { Adapter } from "socket.io-adapter";
import type {
EventParams,
EventNames,
EventsMap,
TypedEventBroadcaster,
} from "./typed-events";

export class BroadcastOperator {
export class BroadcastOperator<EmitEvents extends EventsMap>
implements TypedEventBroadcaster<EmitEvents> {
constructor(
private readonly adapter: Adapter,
private readonly rooms: Set<Room> = new Set<Room>(),
Expand All @@ -18,7 +25,7 @@ export class BroadcastOperator {
* @return a new BroadcastOperator instance
* @public
*/
public to(room: Room | Room[]): BroadcastOperator {
public to(room: Room | Room[]): BroadcastOperator<EmitEvents> {
const rooms = new Set(this.rooms);
if (Array.isArray(room)) {
room.forEach((r) => rooms.add(r));
Expand All @@ -40,7 +47,7 @@ export class BroadcastOperator {
* @return a new BroadcastOperator instance
* @public
*/
public in(room: Room | Room[]): BroadcastOperator {
public in(room: Room | Room[]): BroadcastOperator<EmitEvents> {
return this.to(room);
}

Expand All @@ -51,7 +58,7 @@ export class BroadcastOperator {
* @return a new BroadcastOperator instance
* @public
*/
public except(room: Room | Room[]): BroadcastOperator {
public except(room: Room | Room[]): BroadcastOperator<EmitEvents> {
const exceptRooms = new Set(this.exceptRooms);
if (Array.isArray(room)) {
room.forEach((r) => exceptRooms.add(r));
Expand All @@ -73,7 +80,7 @@ export class BroadcastOperator {
* @return a new BroadcastOperator instance
* @public
*/
public compress(compress: boolean): BroadcastOperator {
public compress(compress: boolean): BroadcastOperator<EmitEvents> {
const flags = Object.assign({}, this.flags, { compress });
return new BroadcastOperator(
this.adapter,
Expand All @@ -91,7 +98,7 @@ export class BroadcastOperator {
* @return a new BroadcastOperator instance
* @public
*/
public get volatile(): BroadcastOperator {
public get volatile(): BroadcastOperator<EmitEvents> {
const flags = Object.assign({}, this.flags, { volatile: true });
return new BroadcastOperator(
this.adapter,
Expand All @@ -107,7 +114,7 @@ export class BroadcastOperator {
* @return a new BroadcastOperator instance
* @public
*/
public get local(): BroadcastOperator {
public get local(): BroadcastOperator<EmitEvents> {
const flags = Object.assign({}, this.flags, { local: true });
return new BroadcastOperator(
this.adapter,
Expand All @@ -123,18 +130,21 @@ export class BroadcastOperator {
* @return Always true
* @public
*/
public emit(ev: string | Symbol, ...args: any[]): true {
public emit<Ev extends EventNames<EmitEvents>>(
ev: Ev,
...args: EventParams<EmitEvents, Ev>
): true {
if (RESERVED_EVENTS.has(ev)) {
throw new Error(`"${ev}" is a reserved event name`);
}
// set up packet object
args.unshift(ev);
const data = [ev, ...args];
const packet = {
type: PacketType.EVENT,
data: args,
data: data,
};

if ("function" == typeof args[args.length - 1]) {
if ("function" == typeof data[data.length - 1]) {
throw new Error("Callbacks are not supported when broadcasting");
}

Expand Down Expand Up @@ -166,7 +176,7 @@ export class BroadcastOperator {
*
* @public
*/
public fetchSockets(): Promise<RemoteSocket[]> {
public fetchSockets(): Promise<RemoteSocket<EmitEvents>[]> {
return this.adapter
.fetchSockets({
rooms: this.rooms,
Expand All @@ -176,7 +186,7 @@ export class BroadcastOperator {
return sockets.map((socket) => {
if (socket instanceof Socket) {
// FIXME the TypeScript compiler complains about missing private properties
return (socket as unknown) as RemoteSocket;
return (socket as unknown) as RemoteSocket<EmitEvents>;
} else {
return new RemoteSocket(this.adapter, socket as SocketDetails);
}
Expand Down Expand Up @@ -246,13 +256,14 @@ interface SocketDetails {
/**
* Expose of subset of the attributes and methods of the Socket class
*/
export class RemoteSocket {
export class RemoteSocket<EmitEvents extends EventsMap>
implements TypedEventBroadcaster<EmitEvents> {
public readonly id: SocketId;
public readonly handshake: Handshake;
public readonly rooms: Set<Room>;
public readonly data: any;

private readonly operator: BroadcastOperator;
private readonly operator: BroadcastOperator<EmitEvents>;

constructor(adapter: Adapter, details: SocketDetails) {
this.id = details.id;
Expand All @@ -262,7 +273,10 @@ export class RemoteSocket {
this.operator = new BroadcastOperator(adapter, new Set([this.id]));
}

public emit(ev: string, ...args: any[]): boolean {
public emit<Ev extends EventNames<EmitEvents>>(
ev: Ev,
...args: EventParams<EmitEvents, Ev>
): true {
return this.operator.emit(ev, ...args);
}

Expand Down
18 changes: 11 additions & 7 deletions lib/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,20 +3,24 @@ import debugModule = require("debug");
import url = require("url");
import type { IncomingMessage } from "http";
import type { Namespace, Server } from "./index";
import type { EventsMap } from "./typed-events";
import type { Socket } from "./socket";
import type { SocketId } from "socket.io-adapter";

const debug = debugModule("socket.io:client");

export class Client {
export class Client<
ListenEvents extends EventsMap,
EmitEvents extends EventsMap
> {
public readonly conn;

private readonly id: string;
private readonly server: Server;
private readonly server: Server<ListenEvents, EmitEvents>;
private readonly encoder: Encoder;
private readonly decoder: Decoder;
private sockets: Map<SocketId, Socket> = new Map();
private nsps: Map<string, Socket> = new Map();
private sockets: Map<SocketId, Socket<ListenEvents, EmitEvents>> = new Map();
private nsps: Map<string, Socket<ListenEvents, EmitEvents>> = new Map();
private connectTimeout?: NodeJS.Timeout;

/**
Expand All @@ -26,7 +30,7 @@ export class Client {
* @param conn
* @package
*/
constructor(server: Server, conn: Socket) {
constructor(server: Server<ListenEvents, EmitEvents>, conn: any) {
this.server = server;
this.conn = conn;
this.encoder = server.encoder;
Expand Down Expand Up @@ -87,7 +91,7 @@ export class Client {
this.server._checkNamespace(
name,
auth,
(dynamicNspName: Namespace | false) => {
(dynamicNspName: Namespace<ListenEvents, EmitEvents> | false) => {
if (dynamicNspName) {
debug("dynamic namespace %s was created", dynamicNspName);
this.doConnect(name, auth);
Expand Down Expand Up @@ -145,7 +149,7 @@ export class Client {
*
* @private
*/
_remove(socket: Socket): void {
_remove(socket: Socket<ListenEvents, EmitEvents>): void {
if (this.sockets.has(socket.id)) {
const nsp = this.sockets.get(socket.id)!.nsp.name;
this.sockets.delete(socket.id);
Expand Down
59 changes: 41 additions & 18 deletions lib/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,11 @@ import path = require("path");
import engine = require("engine.io");
import { Client } from "./client";
import { EventEmitter } from "events";
import { ExtendedError, Namespace } from "./namespace";
import {
ExtendedError,
Namespace,
NamespaceReservedEventsMap,
} from "./namespace";
import { ParentNamespace } from "./parent-namespace";
import { Adapter, Room, SocketId } from "socket.io-adapter";
import * as parser from "socket.io-parser";
Expand All @@ -17,6 +21,12 @@ import { Socket } from "./socket";
import type { CookieSerializeOptions } from "cookie";
import type { CorsOptions } from "cors";
import type { BroadcastOperator, RemoteSocket } from "./broadcast-operator";
import {
EventsMap,
DefaultEventsMap,
EventParams,
StrictEventEmitter,
} from "./typed-events";

const debug = debugModule("socket.io:server");

Expand Down Expand Up @@ -156,8 +166,15 @@ interface ServerOptions extends EngineAttachOptions {
connectTimeout: number;
}

export class Server extends EventEmitter {
public readonly sockets: Namespace;
export class Server<
ListenEvents extends EventsMap = DefaultEventsMap,
EmitEvents extends EventsMap = ListenEvents
> extends StrictEventEmitter<
{},
EmitEvents,
NamespaceReservedEventsMap<ListenEvents, EmitEvents>
> {
public readonly sockets: Namespace<ListenEvents, EmitEvents>;

/** @private */
readonly _parser: typeof parser;
Expand All @@ -167,8 +184,11 @@ export class Server extends EventEmitter {
/**
* @private
*/
_nsps: Map<string, Namespace> = new Map();
private parentNsps: Map<ParentNspNameMatchFn, ParentNamespace> = new Map();
_nsps: Map<string, Namespace<ListenEvents, EmitEvents>> = new Map();
private parentNsps: Map<
ParentNspNameMatchFn,
ParentNamespace<ListenEvents, EmitEvents>
> = new Map();
private _adapter?: typeof Adapter;
private _serveClient: boolean;
private opts: Partial<EngineOptions>;
Expand Down Expand Up @@ -248,7 +268,7 @@ export class Server extends EventEmitter {
_checkNamespace(
name: string,
auth: { [key: string]: any },
fn: (nsp: Namespace | false) => void
fn: (nsp: Namespace<ListenEvents, EmitEvents> | false) => void
): void {
if (this.parentNsps.size === 0) return fn(false);

Expand Down Expand Up @@ -557,8 +577,8 @@ export class Server extends EventEmitter {
*/
public of(
name: string | RegExp | ParentNspNameMatchFn,
fn?: (socket: Socket) => void
): Namespace {
fn?: (socket: Socket<ListenEvents, EmitEvents>) => void
): Namespace<ListenEvents, EmitEvents> {
if (typeof name === "function" || name instanceof RegExp) {
const parentNsp = new ParentNamespace(this);
debug("initializing parent namespace %s", parentNsp.name);
Expand Down Expand Up @@ -616,7 +636,10 @@ export class Server extends EventEmitter {
* @public
*/
public use(
fn: (socket: Socket, next: (err?: ExtendedError) => void) => void
fn: (
socket: Socket<ListenEvents, EmitEvents>,
next: (err?: ExtendedError) => void
) => void
): this {
this.sockets.use(fn);
return this;
Expand All @@ -629,7 +652,7 @@ export class Server extends EventEmitter {
* @return self
* @public
*/
public to(room: Room | Room[]): BroadcastOperator {
public to(room: Room | Room[]): BroadcastOperator<EmitEvents> {
return this.sockets.to(room);
}

Expand All @@ -640,7 +663,7 @@ export class Server extends EventEmitter {
* @return self
* @public
*/
public in(room: Room | Room[]): BroadcastOperator {
public in(room: Room | Room[]): BroadcastOperator<EmitEvents> {
return this.sockets.in(room);
}

Expand All @@ -651,7 +674,7 @@ export class Server extends EventEmitter {
* @return self
* @public
*/
public except(name: Room | Room[]): Server {
public except(name: Room | Room[]): Server<ListenEvents, EmitEvents> {
this.sockets.except(name);
return this;
}
Expand All @@ -662,7 +685,7 @@ export class Server extends EventEmitter {
* @return self
* @public
*/
public send(...args: readonly any[]): this {
public send(...args: EventParams<EmitEvents, "message">): this {
this.sockets.emit("message", ...args);
return this;
}
Expand All @@ -673,7 +696,7 @@ export class Server extends EventEmitter {
* @return self
* @public
*/
public write(...args: readonly any[]): this {
public write(...args: EventParams<EmitEvents, "message">): this {
this.sockets.emit("message", ...args);
return this;
}
Expand All @@ -694,7 +717,7 @@ export class Server extends EventEmitter {
* @return self
* @public
*/
public compress(compress: boolean): BroadcastOperator {
public compress(compress: boolean): BroadcastOperator<EmitEvents> {
return this.sockets.compress(compress);
}

Expand All @@ -706,7 +729,7 @@ export class Server extends EventEmitter {
* @return self
* @public
*/
public get volatile(): BroadcastOperator {
public get volatile(): BroadcastOperator<EmitEvents> {
return this.sockets.volatile;
}

Expand All @@ -716,7 +739,7 @@ export class Server extends EventEmitter {
* @return self
* @public
*/
public get local(): BroadcastOperator {
public get local(): BroadcastOperator<EmitEvents> {
return this.sockets.local;
}

Expand All @@ -725,7 +748,7 @@ export class Server extends EventEmitter {
*
* @public
*/
public fetchSockets(): Promise<RemoteSocket[]> {
public fetchSockets(): Promise<RemoteSocket<EmitEvents>[]> {
return this.sockets.fetchSockets();
}

Expand Down
Loading

0 comments on commit 9f5a872

Please sign in to comment.