Skip to content
Merged
Show file tree
Hide file tree
Changes from all 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: 6 additions & 21 deletions docs/docs/network/network-client-api.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -14,35 +14,20 @@ description: "API reference for the client networking package"
Reliable channels need `SERVER_TCP_PORT` and unreliable ones `SERVER_UDP_PORT`.
Using a channel whose port is not configured throws.

The client connects during `__init`, so its channels are open once the game runs.
TCP connects first, then UDP with the TCP session token, so both share one client
id. `__init` rejects when a transport fails, or when its channels are not open
within 10 seconds.

```ts
import { Channel } from "@nanoforge-dev/network/client";

await ctx.network.connect({ channels: [Channel.ReliableOrdered, Channel.UnreliableOrdered] });

ctx.network.sendData({ type: "join" }); // Channel.ReliableOrdered
ctx.network.sendData({ x: 1, y: 2 }, { channel: Channel.UnreliableOrdered });

const messages = ctx.network.getReceivedPackets().map((packet) => packet.json()); // every channel
```

## connect(options?)

Connect to the server and resolve once the channels are open. Only the
transports the channels need are opened: TCP for reliable channels, UDP for
unreliable ones. TCP connects first, then UDP with the TCP session token, so both
share one client id. Calling it again for channels that are already open resolves
right away.

Option `channels`: `Channel[]`, the channels to open and wait for. Every
configured channel by default.

Option `timeout`: `number`, milliseconds before giving up. `10000` by default.

Rejects when a channel's port is not configured, when a transport fails to
connect, or when the channels are not open before the timeout.

Returns: `Promise<void>`

## sendData(data, options?)

Send a payload to the server.
Expand Down Expand Up @@ -140,7 +125,7 @@ Returns: `boolean`
## clientId

Client id assigned by the server, shared by TCP and UDP. `undefined` until
`connect` has been welcomed by the server. It is only kept in memory: a page
the server has welcomed the client. It is only kept in memory: a page
reload gets a new id.

Type: `string | undefined`
Expand Down
3 changes: 0 additions & 3 deletions docs/docs/network/network-client.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -23,9 +23,6 @@ four [channels](packet-framing), picked with the `channel` option:
```javascript
import { Channel } from "@nanoforge-dev/network/client";

// Open the channels the game uses and wait until they are ready
await network.connect({ channels: [Channel.ReliableOrdered, Channel.UnreliableOrdered] });

// Send a value as JSON, on Channel.ReliableOrdered
network.sendData({ hello: "world" });

Expand Down
5 changes: 1 addition & 4 deletions example/pong-network/apps/client/src/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ import { EcsLibrary } from "@nanoforge-dev/ecs/client";
import { Circle, Graphics2DLibrary, Layer, Rect } from "@nanoforge-dev/graphics-2d";
import { InputEnum, InputLibrary } from "@nanoforge-dev/input";
import { ControllerLibrary } from "@nanoforge-dev/input/controller";
import { Channel, NetworkClientLibrary } from "@nanoforge-dev/network/client";
import { NetworkClientLibrary } from "@nanoforge-dev/network/client";

import { AssignId, Play } from "../../../shared/protocol";
import {
Expand Down Expand Up @@ -105,9 +105,6 @@ export const main = async (options: ClientRunOptions): Promise<void> => {
registry.addSystem(controlPlayer);
registry.addSystem(draw);

await network.connect({
channels: [Channel.ReliableOrdered, Channel.UnreliableOrdered, Channel.UnreliableUnordered],
});
network.packets.send(Play);

await app.run();
Expand Down
11 changes: 4 additions & 7 deletions modules/network/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -164,9 +164,6 @@ import { Channel, NetworkClientLibrary } from "@nanoforge-dev/network/client";

app.use(new NetworkClientLibrary());

// Once, at game start: open the channels the game uses and wait for them.
await ctx.network.connect({ channels: [Channel.ReliableOrdered, Channel.UnreliableOrdered] });

// Every frame:
ctx.network.sendData({ type: "join" }); // Channel.ReliableOrdered
ctx.network.sendData({ x: 1, y: 2 }, { channel: Channel.UnreliableOrdered });
Expand All @@ -189,9 +186,9 @@ ctx.network.sendToAll(snapshot, { channel: Channel.UnreliableOrdered });
ctx.network.sendToClient(clientId, { type: "event" }); // Channel.ReliableOrdered
```

`connect` only opens the transports its channels need, and rejects when a
transport fails or the channels are not open within `timeout` milliseconds
(10 seconds by default). The server starts listening during `__init`.
The client connects during `__init` and its channels are open before the game
starts: `__init` rejects when a transport fails or the channels are not open
within 10 seconds. The server starts listening during `__init`.

### Typed packets

Expand Down Expand Up @@ -239,7 +236,7 @@ ctx.network.clients.onDisconnect((info) => console.log(`${info.id} left`));
const info = ctx.network.getClientInfo(clientId);
```

On the client, `ctx.network.clientId` holds the id once `connect` has been welcomed. A page reload
On the client, `ctx.network.clientId` holds the id once the server has welcomed the client. A page reload
starts a new session with a new id.

Using a channel whose port wasn't configured on that side throws.
Expand Down
126 changes: 50 additions & 76 deletions modules/network/src/client/client.network.library.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,6 @@ import {
CONNECT_TIMEOUT_MS,
type Channel,
type ChannelOptions,
type ConnectOptions,
DEFAULT_CHANNEL,
isReliableChannel,
} from "../shared/channels";
Expand All @@ -34,18 +33,18 @@ import type {
import { TCPClient } from "./tcp.client.network";
import { UDPClient } from "./udp.client.network";

/** Delay between two checks of the channels while `connect` waits. */
/** Delay between two checks of the channels while `__init` waits for them. */
const CONNECT_POLL_MS = 50;

/**
* Built-in network library for client-side applications.
*
* @remarks
* Reads network configuration from the environment via `ClientConfigNetwork`
* during `__init`. Call `connect` to open the connections: TCP (WebSocket)
* carries the reliable channels and UDP (WebRTC data channels) the unreliable
* ones. TCP connects first; once welcomed, UDP presents the session token so
* both transports share one client id.
* and opens the connections during `__init`, so the channels are open before
* the game runs: TCP (WebSocket) carries the reliable channels and UDP (WebRTC
* data channels) the unreliable ones. TCP connects first; once welcomed, UDP
* presents the session token so both transports share one client id.
*
* Configuration (via environment variables):
* - `SERVER_ADDRESS` — hostname or IP of the server (required).
Expand All @@ -56,7 +55,6 @@ const CONNECT_POLL_MS = 50;
*
* @example
* ```ts
* await ctx.network.connect({ channels: [Channel.ReliableOrdered, Channel.UnreliableOrdered] });
* ctx.network.sendData({ type: "join" }); // Channel.ReliableOrdered
* ctx.network.sendData({ x: 1, y: 2 }, { channel: Channel.UnreliableOrdered });
* const messages = ctx.network.getReceivedPackets().map((packet) => packet.json()); // every channel
Expand All @@ -68,8 +66,6 @@ export class NetworkClientLibrary extends Library implements EditorAwareLibrary

private _tcp?: TCPClient;
private _udp?: UDPClient;
/** Pending or settled connection of each transport, so it is only opened once. */
private readonly _connecting = new Map<TCPClient | UDPClient, Promise<void>>();
private readonly _session: ClientSession = {};
private readonly _dispatcher = new PacketDispatcher<ClientPacketInfo>();
/** Typed packets drained from the transports, dispatched by the next `__run`. */
Expand Down Expand Up @@ -125,60 +121,8 @@ export class NetworkClientLibrary extends Library implements EditorAwareLibrary
this._session,
);
}
}

/**
* Connect to the server and wait until `channels` are open.
*
* @remarks
* Only opens the transports the channels need: TCP for reliable channels,
* UDP for unreliable ones. Calling it again for already open channels
* resolves right away.
*
* @param options - `channels` to wait for (every configured channel by
* default) and a `timeout` in milliseconds (10 seconds by default).
* @throws When a channel's port was not configured, a transport fails to
* connect, or the channels are not open before the timeout.
*/
public async connect({
channels = this.configuredChannels(),
timeout = CONNECT_TIMEOUT_MS,
}: ConnectOptions = {}): Promise<void> {
if (!this._tcp && !this._udp) {
throw new Error("NetworkClientLibrary: connect() was called before __init");
}
const deadline = Date.now() + timeout;
const reliable = channels.find(isReliableChannel);
const unreliable = channels.find((channel) => !isReliableChannel(channel));

// TCP first, so UDP joins its session with the token from the TCP welcome.
const transports: [string, TCPClient | UDPClient][] = [];
if (reliable !== undefined) transports.push(["TCP", this.requireTcp(reliable)]);
if (unreliable !== undefined) transports.push(["UDP", this.requireUdp(unreliable)]);

const failures: unknown[] = [];
for (const [name, transport] of transports) {
try {
await this.connectTransport(name, transport);
} catch (error) {
failures.push(error);
}
}
if (failures.length > 0) {
throw new Error("NetworkClientLibrary: could not connect to the server", {
cause: failures.length === 1 ? failures[0] : failures,
});
}

while (!channels.every((channel) => this.isConnected({ channel }))) {
if (Date.now() >= deadline) {
const pending = channels.filter((channel) => !this.isConnected({ channel }));
throw new Error(
`NetworkClientLibrary: channels ${pending.join(", ")} not open after ${timeout}ms`,
);
}
await new Promise((resolve) => setTimeout(resolve, CONNECT_POLL_MS));
}
await this.openChannels();
}

/**
Expand Down Expand Up @@ -259,7 +203,6 @@ export class NetworkClientLibrary extends Library implements EditorAwareLibrary
// eslint-disable-next-line @typescript-eslint/no-this-alias
const library = this;
return {
connect: (options) => library.connect(options),
sendData: (data, options) => library.sendData(data, options),
getReceivedPackets: (options) => library.getReceivedPackets(options),
isConnected: (options) => library.isConnected(options),
Expand All @@ -272,6 +215,50 @@ export class NetworkClientLibrary extends Library implements EditorAwareLibrary
};
}

/**
* Connect every configured transport and wait until its channels are open.
*
* @remarks
* Channels cannot be opened once the game runs, and connection errors are
* best caught at start, so this runs during `__init`.
*
* @throws When a transport fails to connect, or the channels are not open
* within `CONNECT_TIMEOUT_MS`.
*/
private async openChannels(): Promise<void> {
const deadline = Date.now() + CONNECT_TIMEOUT_MS;
const channels = this.configuredChannels();

// TCP first, so UDP joins its session with the token from the TCP welcome.
const transports: [string, TCPClient | UDPClient][] = [];
if (this._tcp) transports.push(["TCP", this._tcp]);
if (this._udp) transports.push(["UDP", this._udp]);

const failures: unknown[] = [];
for (const [name, transport] of transports) {
try {
await transport.connect();
} catch (error) {
failures.push(new Error(`${name} connection failed`, { cause: error }));
}
}
if (failures.length > 0) {
throw new Error("NetworkClientLibrary: could not connect to the server", {
cause: failures.length === 1 ? failures[0] : failures,
});
}

while (!channels.every((channel) => this.isConnected({ channel }))) {
if (Date.now() >= deadline) {
const pending = channels.filter((channel) => !this.isConnected({ channel }));
throw new Error(
`NetworkClientLibrary: channels ${pending.join(", ")} not open after ${CONNECT_TIMEOUT_MS}ms`,
);
}
await new Promise((resolve) => setTimeout(resolve, CONNECT_POLL_MS));
}
}

private configuredChannels(): Channel[] {
return CHANNELS.filter((channel) =>
isReliableChannel(channel) ? this._tcp !== undefined : this._udp !== undefined,
Expand Down Expand Up @@ -301,17 +288,4 @@ export class NetworkClientLibrary extends Library implements EditorAwareLibrary
if (!this._udp) throw new Error(`Channel ${channel} needs SERVER_UDP_PORT to be set`);
return this._udp;
}

private connectTransport(name: string, transport: TCPClient | UDPClient): Promise<void> {
let connecting = this._connecting.get(transport);
if (!connecting) {
connecting = transport.connect().catch((error: unknown) => {
// Forget the failure so a later `connect` can retry.
this._connecting.delete(transport);
throw new Error(`${name} connection failed`, { cause: error });
});
this._connecting.set(transport, connecting);
}
return connecting;
}
}
2 changes: 1 addition & 1 deletion modules/network/src/client/index.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import "./context-augmentation";

export type { NetworkConfig } from "../shared/config.network";
export { Channel, type ChannelOptions, type ConnectOptions } from "../shared/channels";
export { Channel, type ChannelOptions } from "../shared/channels";
export { NetworkData, type NetworkPayload } from "../shared/network-data";
export {
type PacketArgs,
Expand Down
7 changes: 1 addition & 6 deletions modules/network/src/client/network-client-context.type.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import type { Channel, ChannelOptions, ConnectOptions } from "../shared/channels";
import type { Channel, ChannelOptions } from "../shared/channels";
import type { NetworkData, NetworkPayload } from "../shared/network-data";
import type { PacketArgs, PacketDefinition, PacketListener } from "../shared/packet";

Expand Down Expand Up @@ -59,11 +59,6 @@ export interface NetworkClientContextApi {
readonly packets: ClientPacketsApi;
/** Client id assigned by the server, shared by TCP and UDP. `undefined` until welcomed. */
readonly clientId: string | undefined;
/**
* Connect to the server and resolve once `channels` (every configured
* channel by default) are open. Rejects on failure or after `timeout` ms.
*/
connect(options?: ConnectOptions): Promise<void>;
/**
* Send bytes, a string or a JSON value to the server, on
* `Channel.ReliableOrdered` unless another `channel` is given.
Expand Down
23 changes: 1 addition & 22 deletions modules/network/src/shared/channels.ts
Original file line number Diff line number Diff line change
Expand Up @@ -52,30 +52,9 @@ export type ChannelOptions = {
channel?: Channel;
};

/** How long `connect` waits for its channels by default. */
/** How long the client waits for its channels to open during `__init`. */
export const CONNECT_TIMEOUT_MS = 10_000;

/**
* Options of the client's `connect`.
*
* @example
* ```ts
* await ctx.network.connect({
* channels: [Channel.ReliableOrdered, Channel.UnreliableUnordered],
* });
* ```
*/
export type ConnectOptions = {
/** Channels to open and wait for, every configured channel by default. */
channels?: Channel[];
/**
* Give up after this many milliseconds.
*
* @default 10000
*/
timeout?: number;
};

/**
* Tell whether a channel is carried by the TCP WebSocket.
*
Expand Down
Loading
Loading