Skip to content
Merged
Show file tree
Hide file tree
Changes from 9 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
27 changes: 24 additions & 3 deletions docs/conformance.md
Original file line number Diff line number Diff line change
Expand Up @@ -251,6 +251,25 @@ Room cleanup, the reason each side reports, and what happens to a pending ack.
- [serverSocket.disconnect() reports io server disconnect to the client and server namespace disconnect to the server](../src/disconnect.test.ts#L171)
- [disconnecting carries the same reason and fires before disconnect](../src/disconnect.test.ts#L187)

### Emitter return values

What `emit` and the listener methods hand back, and which of them chain.

- [the client emit returns the socket, so it chains](../src/emitter-returns.test.ts#L14)
- [a buffered emit returns the socket too, before the connection completes](../src/emitter-returns.test.ts#L19)
- [the server socket emit returns true rather than the socket](../src/emitter-returns.test.ts#L27)
- [the server emit returns true](../src/emitter-returns.test.ts#L34)
- [a namespace emit returns true](../src/emitter-returns.test.ts#L39)
- [a broadcast emit returns true](../src/emitter-returns.test.ts#L44)
- [a timed broadcast emit returns true](../src/emitter-returns.test.ts#L50)
- [a timed server socket emit returns true, where the client one chains](../src/emitter-returns.test.ts#L61)
- [a volatile emit follows its own side: true on the server, the socket on the client](../src/emitter-returns.test.ts#L72)
- [a dropped volatile emit still returns the emitter it was called on](../src/emitter-returns.test.ts#L79)
- [the client listener methods return the socket, so they chain](../src/emitter-returns.test.ts#L91)
- [the server socket listener methods return the socket, so they chain](../src/emitter-returns.test.ts#L104)
- [chained registrations both take effect](../src/emitter-returns.test.ts#L117)
- [a namespace on returns the namespace, so it chains](../src/emitter-returns.test.ts#L132)

## smocket only

These have no oracle to compare against: they cover the API smocket adds
Expand All @@ -275,6 +294,7 @@ Resolving a url to a server, and what the url contributes to the handshake.
- [the url's path selects the namespace](../src/connect-url.test.ts#L121)
- [a relative url resolves against location.origin](../src/connect-url.test.ts#L130)
- [connect(url) to an unregistered origin fires connect_error, without throwing](../src/connect-url.test.ts#L148)
- [the socket from a failed connect still chains](../src/connect-url.test.ts#L166)

### Adapter API

Expand Down Expand Up @@ -309,9 +329,10 @@ The encoder behind the id shape the dual run pins.

What the package exports, including the `io` name the substitution path needs.

- [connecting pairs the client and server socket with the same id](../src/index.test.ts#L22)
- [exports `io` as socket.io-client's name for connect, so a module swap works](../src/index.test.ts#L32)
- [exports the contract types, so the swap keeps an app annotations to use](../src/index.test.ts#L44)
- [connecting pairs the client and server socket with the same id](../src/index.test.ts#L24)
- [exports `io` as socket.io-client's name for connect, so a module swap works](../src/index.test.ts#L34)
- [exports the contract types, so the swap keeps an app annotations to use](../src/index.test.ts#L46)
- [exports a server type that keeps the two smocket-only members](../src/index.test.ts#L88)

<!-- conformance:generated end -->

Expand Down
27 changes: 15 additions & 12 deletions docs/decisions/0019-what-counts-as-a-breaking-change.md
Original file line number Diff line number Diff line change
Expand Up @@ -26,20 +26,23 @@ break.

The first row that matches wins.

| Change | Bump |
| -------------------------------------------------------------------------- | --------------- |
| A correction toward measured real behaviour that changes what is delivered | minor |
| A correction toward measured real behaviour with no observable change | patch |
| Newly covered socket.io surface | minor |
| Removing or altering a [`differences.md`](../differences.md) §A entry | major |
| Adding a `differences.md` §A entry | none |
| Changing something the documentation marks unspecified | patch |
| A public type change that still compiles at existing call sites, else | minor, major |
| Raising `engines.node`, lowering it | major, minor |
| A change to a smocket-only API (§B) | ordinary semver |
| Change | Bump |
| --------------------------------------------------------------------- | --------------- |
| A correction toward measured real behaviour with an observable change | minor |
| A correction toward measured real behaviour with no observable change | patch |
| Newly covered socket.io surface | minor |
| Removing or altering a [`differences.md`](../differences.md) §A entry | major |
| Adding a `differences.md` §A entry | none |
| Changing something the documentation marks unspecified | patch |
| A public type change that still compiles at existing call sites, else | minor, major |
| Raising `engines.node`, lowering it | major, minor |
Comment thread
coderabbitai[bot] marked this conversation as resolved.
| A change to a smocket-only API (§B) | ordinary semver |

The first row does not ride in a patch. The old result was not a promise, but a green
suite can turn red, and a patch is understood as safe to take without reading anything.
suite can turn red, and a patch is understood as safe to take without reading anything. It
says observable rather than delivered because the two are not the same set: the return value
of `emit` changes nothing about who receives an event, and a caller still reads it. Anything
a user's code can see belongs on this row, whether or not it moved a packet.

Before 1.0.0 the rules apply one place to the right, as npm reads a `0.x` range: major
becomes minor, minor and patch become patch. They apply from now rather than from the tag,
Expand Down
5 changes: 5 additions & 0 deletions scripts/conformance-report.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -146,6 +146,11 @@ const AREAS = [
title: 'Public entry points',
blurb: 'What the package exports, including the `io` name the substitution path needs.',
},
{
file: 'src/emitter-returns.test.ts',
title: 'Emitter return values',
blurb: 'What `emit` and the listener methods hand back, and which of them chain.',
},
];

/** Run one vitest project and return its JSON report. */
Expand Down
20 changes: 20 additions & 0 deletions src/connect-url.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -162,3 +162,23 @@ it('connect(url) to an unregistered origin fires connect_error, without throwing
consoleError.mockRestore();
}
});

it('the socket from a failed connect still chains', async () => {
// The socket a failed connect hands back is inert (0005), but it is still a client
// socket, and the client emitters chain. App code that wrote `socket.emit(a).emit(b)`
// should not start throwing because no server was registered for the origin.
const consoleError = vi.spyOn(console, 'error').mockImplementation(() => {});
try {
const client = connect('http://localhost:9998');

expect(client.emit('a', 1)).toBe(client);

const timed = client.timeout(50);
expect(timed.emit('a', 1)).toBe(timed);

const volatile = client.volatile;
expect(volatile.emit('a', 1)).toBe(volatile);
} finally {
consoleError.mockRestore();
Comment thread
coderabbitai[bot] marked this conversation as resolved.
}
});
103 changes: 74 additions & 29 deletions src/contract.ts
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,7 @@ type Listener = (...args: never[]) => void;
* `to` / `in` / `except` / `timeout` compose in any order (#137).
*/
export interface BroadcastContract {
emit(event: string, ...args: unknown[]): void;
emit(event: string, ...args: unknown[]): boolean;
to(room: string | string[]): BroadcastContract;
/** An alias of `to`, as at the entry points. */
in(room: string | string[]): BroadcastContract;
Expand All @@ -50,11 +50,15 @@ export interface BroadcastContract {
* the race, and a lone `Error('operation has timed out')` when the timer wins, with a
* late ack then dropped so the callback fires exactly once. A callback-less `emit` is a
* plain emit that arms no timer. `emitWithAck` is the same race as a promise, resolving
* with the response and rejecting with that same timeout `Error`. Real socket.io returns
* the decorated socket here; the subset it is held to is just these two emit forms.
* with the response and rejecting with that same timeout `Error`.
*
* This is the client side of `timeout(ms)`, where the emit returns the socket so the call
* chains. The server side returns `true` instead and is {@link SocketTimeoutContract};
* the two used to share this interface, which stopped working once the return types were
* measured rather than left as `void`.
*/
export interface TimeoutEmitterContract {
emit(event: string, ...args: unknown[]): void;
emit(event: string, ...args: unknown[]): this;
emitWithAck(event: string, ...args: unknown[]): Promise<unknown>;
}

Expand All @@ -69,7 +73,7 @@ export interface TimeoutEmitterContract {
* `io.timeout(ms).to(a).except(b)` collects from the survivors only (#137).
*/
export interface TimeoutBroadcastContract {
emit(event: string, ...args: unknown[]): void;
emit(event: string, ...args: unknown[]): boolean;
to(room: string | string[]): TimeoutBroadcastContract;
/** An alias of `to`, as at the entry points. */
in(room: string | string[]): TimeoutBroadcastContract;
Expand All @@ -83,8 +87,15 @@ export interface TimeoutBroadcastContract {
* first still reaches `socket.timeout(ms).to(room)` / `.broadcast` / `.except(room)`, each
* an ack-collecting {@link TimeoutBroadcastContract}. Real socket.io returns the socket
* itself here, so this is a subset of its surface and the `Ensure<>` guards below hold.
*
* It declares the two emit forms rather than extending {@link TimeoutEmitterContract},
* because the server's timed `emit` returns `true` where the client's returns the socket.
* An interface cannot narrow an inherited return type to an unrelated one, so the shared
* parent went away when the two were measured apart.
*/
export interface SocketTimeoutContract extends TimeoutEmitterContract {
export interface SocketTimeoutContract {
emit(event: string, ...args: unknown[]): boolean;
emitWithAck(event: string, ...args: unknown[]): Promise<unknown>;
broadcast: TimeoutBroadcastContract;
to(room: string | string[]): TimeoutBroadcastContract;
except(room: string | string[]): TimeoutBroadcastContract;
Expand All @@ -99,7 +110,7 @@ export interface SocketTimeoutContract extends TimeoutEmitterContract {
* carry the volatile flag through the same routing.
*/
export interface VolatileServerSocket {
emit(event: string, ...args: unknown[]): void;
emit(event: string, ...args: unknown[]): boolean;
emitWithAck(event: string, ...args: unknown[]): Promise<unknown>;
broadcast: BroadcastContract;
to(room: string | string[]): BroadcastContract;
Expand All @@ -108,7 +119,7 @@ export interface VolatileServerSocket {

/** The client-side counterpart of {@link VolatileServerSocket}; a client has no broadcast surface. */
export interface VolatileClientSocket {
emit(event: string, ...args: unknown[]): void;
emit(event: string, ...args: unknown[]): this;
emitWithAck(event: string, ...args: unknown[]): Promise<unknown>;
}

Expand Down Expand Up @@ -150,13 +161,13 @@ export interface NamespaceContract {
* for connections on that namespace. `io.on('connection')` is the `/` case of
* this, so both go through the same surface.
*/
on(event: string, listener: Listener): void;
on(event: string, listener: Listener): this;
/**
* Register a connection middleware on this namespace; see {@link ConnectionMiddleware}.
* Called once per incoming connection here, in registration order.
*/
use(middleware: ConnectionMiddleware): void;
emit(event: string, ...args: unknown[]): void;
emit(event: string, ...args: unknown[]): boolean;
to(room: string | string[]): BroadcastContract;
/** A timed broadcast to this namespace; see {@link TimeoutBroadcastContract}. */
timeout(ms: number): TimeoutBroadcastContract;
Expand Down Expand Up @@ -230,14 +241,22 @@ export interface ServerContract {
* each new server-side socket, socket.io's primary way to wire per-socket
* handlers. The `nextConnection` harness path resolves the same socket; this is
* the on-based path code written for real socket.io actually uses.
*
* The return stays `void` while every other `on` in this file narrowed to `this`,
* because this is the one position where socket.io disagrees with itself. Its
* declaration says `this`, so the type promises the `Server` back, and at runtime it
* hands back `io.of('/')`, a `Namespace`. Both chain, so nobody notices, but a contract
* cannot name a single return that is honest about both. Narrowing to `NamespaceContract`
* fails the `Ensure<>` proof below, since socket.io's declared `Server` has no `name`,
* and narrowing to `this` would copy a promise its own runtime does not keep.
*/
on(event: string, listener: Listener): void;
/**
* `io.use` is the default namespace's `use`: it registers a connection middleware for
* connections on `/`, exactly as `io.of('/').use` would. See {@link ConnectionMiddleware}.
*/
use(middleware: ConnectionMiddleware): void;
emit(event: string, ...args: unknown[]): void;
emit(event: string, ...args: unknown[]): boolean;
to(room: string | string[]): BroadcastContract;
in(room: string | string[]): BroadcastContract;
except(room: string | string[]): BroadcastContract;
Expand All @@ -248,6 +267,32 @@ export interface ServerContract {
of(namespace: string): NamespaceContract;
}

/**
* `ServerContract` plus the two server members socket.io has no equivalent for, so an
* application can annotate a smocket server without losing them. `new Server(url)` already
* carries both; this is the name to write down when that value goes into a typed position.
*
* They cannot join `ServerContract` itself. That interface is the subset real socket.io is
* verified against, and the `Ensure<>` proofs at the bottom of this file stop compiling the
* moment it names a member socket.io lacks, so widening it would trade the proof for a
* convenience. The smocket-only surface extends it from outside instead, and deliberately
* gets no `Ensure<>` line of its own: there is nothing on socket.io's side to prove it
* against, which is why both members sit in `differences.md` section B.
*/
export interface SmocketServer extends ServerContract {
/**
* Replace the routing adapter for every namespace on this server. See
* [adapter-registration.md](../docs/adapter-registration.md) and {@link AdapterFactory}.
*/
adapter(factory: AdapterFactory): void;
/**
* Resolve with the server-side socket of the next client to connect on `namespace`,
* which defaults to `/`. Pairs a connect with its server side when the caller drives
* the connection itself rather than through a helper.
*/
nextConnection(namespace?: string): Promise<ServerSocketContract>;
}

/**
* The connection [handshake](../docs/glossary.md#handshake), read as
* `socket.handshake`. Only the fields a mock has a source for are declared (0006):
Expand Down Expand Up @@ -282,21 +327,21 @@ export interface ServerSocketContract {
* client, and tied to the socket: a reconnection is a fresh socket with a fresh `data`.
*/
data: Record<string, unknown>;
on(event: string, listener: Listener): void;
once(event: string, listener: Listener): void;
on(event: string, listener: Listener): this;
once(event: string, listener: Listener): this;
/** Remove one registration. The server is Node's emitter, so a listener is required (0017). */
off(event: string, listener: (...args: unknown[]) => void): void;
off(event: string, listener: (...args: unknown[]) => void): this;
/** Remove every listener for `event`, or all of them when called with no argument. */
removeAllListeners(event?: string): void;
removeAllListeners(event?: string): this;
/** Catch-all for incoming events; the listener receives the event name then its args. */
onAny(listener: (...args: unknown[]) => void): void;
onAny(listener: (...args: unknown[]) => void): this;
/** Remove one catch-all listener, or all of them when called with no argument. */
offAny(listener?: (...args: unknown[]) => void): void;
offAny(listener?: (...args: unknown[]) => void): this;
/** Catch-all for outgoing events this socket sends; receives the event name then its args. */
onAnyOutgoing(listener: (...args: unknown[]) => void): void;
onAnyOutgoing(listener: (...args: unknown[]) => void): this;
/** Remove one outgoing catch-all, or all of them when called with no argument. */
offAnyOutgoing(listener?: (...args: unknown[]) => void): void;
emit(event: string, ...args: unknown[]): void;
offAnyOutgoing(listener?: (...args: unknown[]) => void): this;
emit(event: string, ...args: unknown[]): boolean;
emitWithAck(event: string, ...args: unknown[]): Promise<unknown>;
/**
* Arm a per-emit ack timer. Its `emit` / `emitWithAck` are the single-ack forms
Expand Down Expand Up @@ -325,24 +370,24 @@ export interface ClientSocketContract {
id: string | undefined;
/** The shared Manager; compared only by identity across namespaces. */
io: unknown;
on(event: string, listener: Listener): void;
once(event: string, listener: Listener): void;
on(event: string, listener: Listener): this;
once(event: string, listener: Listener): this;
/**
* The client is component-emitter's: `off()` clears every listener, `off(event)`
* clears that event, and `off(event, listener)` removes one. No form throws (0017).
*/
off(event?: string, listener?: (...args: unknown[]) => void): void;
off(event?: string, listener?: (...args: unknown[]) => void): this;
/** Remove every listener for `event`, or all of them when called with no argument. */
removeAllListeners(event?: string): void;
removeAllListeners(event?: string): this;
/** Catch-all for incoming events; the listener receives the event name then its args. */
onAny(listener: (...args: unknown[]) => void): void;
onAny(listener: (...args: unknown[]) => void): this;
/** Remove one catch-all listener, or all of them when called with no argument. */
offAny(listener?: (...args: unknown[]) => void): void;
offAny(listener?: (...args: unknown[]) => void): this;
/** Catch-all for outgoing events this socket sends; receives the event name then its args. */
onAnyOutgoing(listener: (...args: unknown[]) => void): void;
onAnyOutgoing(listener: (...args: unknown[]) => void): this;
/** Remove one outgoing catch-all, or all of them when called with no argument. */
offAnyOutgoing(listener?: (...args: unknown[]) => void): void;
emit(event: string, ...args: unknown[]): void;
offAnyOutgoing(listener?: (...args: unknown[]) => void): this;
emit(event: string, ...args: unknown[]): this;
emitWithAck(event: string, ...args: unknown[]): Promise<unknown>;
/** Arm a per-emit ack timer on the next emit; see {@link TimeoutEmitterContract}. */
timeout(ms: number): TimeoutEmitterContract;
Expand Down
Loading