Skip to content

Commit

Permalink
feat: add support for typed events
Browse files Browse the repository at this point in the history
Syntax:

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

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

const socket: Socket<ServerToClientEvents, ClientToServerEvents> = io();

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

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

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

Related: socketio/socket.io#3742
  • Loading branch information
darrachequesne committed Mar 10, 2021
1 parent 78ec5a6 commit 5902365
Show file tree
Hide file tree
Showing 7 changed files with 1,581 additions and 29 deletions.
43 changes: 31 additions & 12 deletions lib/manager.ts
@@ -1,10 +1,14 @@
import * as eio from "engine.io-client";
import { Socket, SocketOptions } from "./socket";
import Emitter = require("component-emitter");
import * as parser from "socket.io-parser";
import { Decoder, Encoder, Packet } from "socket.io-parser";
import { on } from "./on";
import * as Backoff from "backo2";
import {
DefaultEventsMap,
EventsMap,
StrictEventEmitter,
} from "./typed-events";

const debug = require("debug")("socket.io-client:manager");

Expand Down Expand Up @@ -258,7 +262,22 @@ export interface ManagerOptions extends EngineOptions {
parser: any;
}

export class Manager extends Emitter {
interface ManagerReservedEvents {
open: () => void;
error: (err: Error) => void;
ping: () => void;
packet: (packet: Packet) => void;
close: (reason: string) => void;
reconnect_failed: () => void;
reconnect_attempt: (attempt: number) => void;
reconnect_error: (err: Error) => void;
reconnect: (attempt: number) => void;
}

export class Manager<
ListenEvents extends EventsMap = DefaultEventsMap,
EmitEvents extends EventsMap = ListenEvents
> extends StrictEventEmitter<{}, {}, ManagerReservedEvents> {
/**
* The Engine.IO client instance
*
Expand Down Expand Up @@ -487,7 +506,7 @@ export class Manager extends Emitter {
debug("error");
self.cleanup();
self._readyState = "closed";
super.emit("error", err);
this.emitReserved("error", err);
if (fn) {
fn(err);
} else {
Expand Down Expand Up @@ -546,7 +565,7 @@ export class Manager extends Emitter {

// mark as open
this._readyState = "open";
super.emit("open");
this.emitReserved("open");

// add new subs
const socket = this.engine;
Expand All @@ -565,7 +584,7 @@ export class Manager extends Emitter {
* @private
*/
private onping(): void {
super.emit("ping");
this.emitReserved("ping");
}

/**
Expand All @@ -583,7 +602,7 @@ export class Manager extends Emitter {
* @private
*/
private ondecoded(packet): void {
super.emit("packet", packet);
this.emitReserved("packet", packet);
}

/**
Expand All @@ -593,7 +612,7 @@ export class Manager extends Emitter {
*/
private onerror(err): void {
debug("error", err);
super.emit("error", err);
this.emitReserved("error", err);
}

/**
Expand Down Expand Up @@ -701,7 +720,7 @@ export class Manager extends Emitter {
this.cleanup();
this.backoff.reset();
this._readyState = "closed";
super.emit("close", reason);
this.emitReserved("close", reason);

if (this._reconnection && !this.skipReconnect) {
this.reconnect();
Expand All @@ -721,7 +740,7 @@ export class Manager extends Emitter {
if (this.backoff.attempts >= this._reconnectionAttempts) {
debug("reconnect failed");
this.backoff.reset();
super.emit("reconnect_failed");
this.emitReserved("reconnect_failed");
this._reconnecting = false;
} else {
const delay = this.backoff.duration();
Expand All @@ -732,7 +751,7 @@ export class Manager extends Emitter {
if (self.skipReconnect) return;

debug("attempting reconnect");
super.emit("reconnect_attempt", self.backoff.attempts);
this.emitReserved("reconnect_attempt", self.backoff.attempts);

// check again for the case socket closed in above events
if (self.skipReconnect) return;
Expand All @@ -742,7 +761,7 @@ export class Manager extends Emitter {
debug("reconnect attempt error");
self._reconnecting = false;
self.reconnect();
super.emit("reconnect_error", err);
this.emitReserved("reconnect_error", err);
} else {
debug("reconnect success");
self.onreconnect();
Expand All @@ -765,6 +784,6 @@ export class Manager extends Emitter {
const attempt = this.backoff.attempts;
this._reconnecting = false;
this.backoff.reset();
super.emit("reconnect", attempt);
this.emitReserved("reconnect", attempt);
}
}
3 changes: 2 additions & 1 deletion lib/on.ts
@@ -1,7 +1,8 @@
import type * as Emitter from "component-emitter";
import { StrictEventEmitter } from "./typed-events";

export function on(
obj: Emitter,
obj: Emitter | StrictEventEmitter<any, any>,
ev: string,
fn: (err?: any) => any
): VoidFunction {
Expand Down
37 changes: 27 additions & 10 deletions lib/socket.ts
@@ -1,7 +1,13 @@
import { Packet, PacketType } from "socket.io-parser";
import Emitter = require("component-emitter");
import { on } from "./on";
import { Manager } from "./manager";
import {
DefaultEventsMap,
EventNames,
EventParams,
EventsMap,
StrictEventEmitter,
} from "./typed-events";

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

Expand Down Expand Up @@ -31,8 +37,17 @@ interface Flags {
volatile?: boolean;
}

export class Socket extends Emitter {
public readonly io: Manager;
interface SocketReservedEvents {
connect: () => void;
connect_error: (err: Error) => void;
disconnect: (reason: Socket.DisconnectReason) => void;
}

export class Socket<
ListenEvents extends EventsMap = DefaultEventsMap,
EmitEvents extends EventsMap = ListenEvents
> extends StrictEventEmitter<ListenEvents, EmitEvents, SocketReservedEvents> {
public readonly io: Manager<ListenEvents, EmitEvents>;

public id: string;
public connected: boolean;
Expand Down Expand Up @@ -133,11 +148,13 @@ export class Socket extends Emitter {
* Override `emit`.
* If the event is in `events`, it's emitted normally.
*
* @param ev - event name
* @return self
* @public
*/
public emit(ev: string, ...args: any[]): this {
public emit<Ev extends EventNames<EmitEvents>>(
ev: Ev,
...args: EventParams<EmitEvents, Ev>
): this {
if (RESERVED_EVENTS.hasOwnProperty(ev)) {
throw new Error('"' + ev + '" is a reserved event name');
}
Expand Down Expand Up @@ -213,7 +230,7 @@ export class Socket extends Emitter {
*/
private onerror(err: Error): void {
if (!this.connected) {
super.emit("connect_error", err);
this.emitReserved("connect_error", err);
}
}

Expand All @@ -228,7 +245,7 @@ export class Socket extends Emitter {
this.connected = false;
this.disconnected = true;
delete this.id;
super.emit("disconnect", reason);
this.emitReserved("disconnect", reason);
}

/**
Expand All @@ -248,7 +265,7 @@ export class Socket extends Emitter {
const id = packet.data.sid;
this.onconnect(id);
} else {
super.emit(
this.emitReserved(
"connect_error",
new Error(
"It seems you are trying to reach a Socket.IO server in v2.x with a v3.x client, but they are not compatible (more information here: https://socket.io/docs/v3/migrating-from-2-x-to-3-0/)"
Expand Down Expand Up @@ -281,7 +298,7 @@ export class Socket extends Emitter {
const err = new Error(packet.data.message);
// @ts-ignore
err.data = packet.data.data;
super.emit("connect_error", err);
this.emitReserved("connect_error", err);
break;
}
}
Expand Down Expand Up @@ -367,7 +384,7 @@ export class Socket extends Emitter {
this.id = id;
this.connected = true;
this.disconnected = false;
super.emit("connect");
this.emitReserved("connect");
this.emitBuffered();
}

Expand Down
144 changes: 144 additions & 0 deletions lib/typed-events.ts
@@ -0,0 +1,144 @@
import Emitter = require("component-emitter");

/**
* An events map is an interface that maps event names to their value, which
* represents the type of the `on` listener.
*/
export interface EventsMap {
[event: string]: any;
}

/**
* The default events map, used if no EventsMap is given. Using this EventsMap
* is equivalent to accepting all event names, and any data.
*/
export interface DefaultEventsMap {
[event: string]: (...args: any[]) => void;
}

/**
* Returns a union type containing all the keys of an event map.
*/
export type EventNames<Map extends EventsMap> = keyof Map & (string | symbol);

/** The tuple type representing the parameters of an event listener */
export type EventParams<
Map extends EventsMap,
Ev extends EventNames<Map>
> = Parameters<Map[Ev]>;

/**
* The event names that are either in ReservedEvents or in UserEvents
*/
export type ReservedOrUserEventNames<
ReservedEventsMap extends EventsMap,
UserEvents extends EventsMap
> = EventNames<ReservedEventsMap> | EventNames<UserEvents>;

/**
* Type of a listener of a user event or a reserved event. If `Ev` is in
* `ReservedEvents`, the reserved event listener is returned.
*/
export type ReservedOrUserListener<
ReservedEvents extends EventsMap,
UserEvents extends EventsMap,
Ev extends ReservedOrUserEventNames<ReservedEvents, UserEvents>
> = Ev extends EventNames<ReservedEvents>
? ReservedEvents[Ev]
: Ev extends EventNames<UserEvents>
? UserEvents[Ev]
: never;

/**
* Strictly typed version of an `EventEmitter`. A `TypedEventEmitter` takes type
* parameters for mappings of event names to event data types, and strictly
* types method calls to the `EventEmitter` according to these event maps.
*
* @typeParam ListenEvents - `EventsMap` of user-defined events that can be
* listened to with `on` or `once`
* @typeParam EmitEvents - `EventsMap` of user-defined events that can be
* emitted with `emit`
* @typeParam ReservedEvents - `EventsMap` of reserved events, that can be
* emitted by socket.io with `emitReserved`, and can be listened to with
* `listen`.
*/
export abstract class StrictEventEmitter<
ListenEvents extends EventsMap,
EmitEvents extends EventsMap,
ReservedEvents extends EventsMap = {}
> extends Emitter {
/**
* Adds the `listener` function as an event listener for `ev`.
*
* @param ev Name of the event
* @param listener Callback function
*/
on<Ev extends ReservedOrUserEventNames<ReservedEvents, ListenEvents>>(
ev: Ev,
listener: ReservedOrUserListener<ReservedEvents, ListenEvents, Ev>
): this {
super.on(ev as string, listener);
return this;
}

/**
* Adds a one-time `listener` function as an event listener for `ev`.
*
* @param ev Name of the event
* @param listener Callback function
*/
once<Ev extends ReservedOrUserEventNames<ReservedEvents, ListenEvents>>(
ev: Ev,
listener: ReservedOrUserListener<ReservedEvents, ListenEvents, Ev>
): this {
super.once(ev as string, listener);
return this;
}

/**
* Emits an event.
*
* @param ev Name of the event
* @param args Values to send to listeners of this event
*/
emit<Ev extends EventNames<EmitEvents>>(
ev: Ev,
...args: EventParams<EmitEvents, Ev>
): this {
super.emit(ev as string, ...args);
return this;
}

/**
* Emits a reserved event.
*
* This method is `protected`, so that only a class extending
* `StrictEventEmitter` can emit its own reserved events.
*
* @param ev Reserved event name
* @param args Arguments to emit along with the event
*/
protected emitReserved<Ev extends EventNames<ReservedEvents>>(
ev: Ev,
...args: EventParams<ReservedEvents, Ev>
): this {
super.emit(ev as string, ...args);
return this;
}

/**
* Returns the listeners listening to an event.
*
* @param event Event name
* @returns Array of listeners subscribed to `event`
*/
listeners<Ev extends ReservedOrUserEventNames<ReservedEvents, ListenEvents>>(
event: Ev
): ReservedOrUserListener<ReservedEvents, ListenEvents, Ev>[] {
return super.listeners(event as string) as ReservedOrUserListener<
ReservedEvents,
ListenEvents,
Ev
>[];
}
}

0 comments on commit 5902365

Please sign in to comment.