@jgengine/ws 0.7.0 → 0.9.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/peer.js ADDED
@@ -0,0 +1,241 @@
1
+ import { createGameHost, memoryPersistence } from "./host.js";
2
+ import { createHostRouter, loopbackPipe } from "./hostRouter.js";
3
+ import { createWsBackend } from "./createWsBackend.js";
4
+ const DEFAULT_ICE_GATHER_TIMEOUT_MS = 2_000;
5
+ function isRecord(value) {
6
+ return typeof value === "object" && value !== null;
7
+ }
8
+ export function encodePeerSignal(payload) {
9
+ const base64 = btoa(JSON.stringify(payload));
10
+ return base64.replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
11
+ }
12
+ export function decodePeerSignal(code) {
13
+ try {
14
+ const base64 = code.replace(/-/g, "+").replace(/_/g, "/");
15
+ const padded = base64 + "=".repeat((4 - (base64.length % 4)) % 4);
16
+ const parsed = JSON.parse(atob(padded));
17
+ if (!isRecord(parsed))
18
+ return null;
19
+ if (parsed.type !== "offer" && parsed.type !== "answer")
20
+ return null;
21
+ if (typeof parsed.sdp !== "string")
22
+ return null;
23
+ return { type: parsed.type, sdp: parsed.sdp };
24
+ }
25
+ catch {
26
+ return null;
27
+ }
28
+ }
29
+ function waitForIceGathering(pc, timeoutMs) {
30
+ if (pc.iceGatheringState === "complete")
31
+ return Promise.resolve();
32
+ return new Promise((resolve) => {
33
+ let settled = false;
34
+ const finish = () => {
35
+ if (settled)
36
+ return;
37
+ settled = true;
38
+ pc.onicegatheringstatechange = null;
39
+ clearTimeout(timer);
40
+ resolve();
41
+ };
42
+ pc.onicegatheringstatechange = () => {
43
+ if (pc.iceGatheringState === "complete")
44
+ finish();
45
+ };
46
+ const timer = setTimeout(finish, timeoutMs);
47
+ });
48
+ }
49
+ function localDescriptionOrThrow(pc) {
50
+ const description = pc.localDescription;
51
+ if (description === null)
52
+ throw new Error("No local description");
53
+ return description;
54
+ }
55
+ function bindDataChannelPipe(channel, handlers) {
56
+ if (channel.readyState === "open") {
57
+ queueMicrotask(() => handlers.onOpen());
58
+ }
59
+ else {
60
+ channel.onopen = () => handlers.onOpen();
61
+ }
62
+ channel.onmessage = (event) => {
63
+ handlers.onMessage(typeof event.data === "string" ? event.data : String(event.data));
64
+ };
65
+ channel.onclose = () => handlers.onClose();
66
+ }
67
+ function guestPipeFactory(channel) {
68
+ let bound = false;
69
+ return (handlers) => {
70
+ if (bound) {
71
+ queueMicrotask(() => handlers.onClose());
72
+ return { send: () => undefined, close: () => undefined };
73
+ }
74
+ bound = true;
75
+ bindDataChannelPipe(channel, handlers);
76
+ return {
77
+ send: (data) => {
78
+ if (channel.readyState !== "open")
79
+ return;
80
+ channel.send(data);
81
+ },
82
+ close: () => channel.close(),
83
+ };
84
+ };
85
+ }
86
+ export function createPeerHost(options) {
87
+ const ownsHost = options.host === undefined;
88
+ const host = options.host ??
89
+ createGameHost({
90
+ runtimes: options.runtimes,
91
+ persistence: options.persistence ?? memoryPersistence(),
92
+ tickMs: options.tickMs,
93
+ });
94
+ if (ownsHost)
95
+ host.start();
96
+ const router = createHostRouter({
97
+ allowAnonymous: true,
98
+ ...options.router,
99
+ host,
100
+ });
101
+ const backend = createWsBackend({
102
+ userId: options.userId,
103
+ token: options.token,
104
+ pipe: loopbackPipe(router),
105
+ });
106
+ const configuration = options.rtc?.configuration ?? {};
107
+ const iceGatherTimeoutMs = options.rtc?.iceGatherTimeoutMs ?? DEFAULT_ICE_GATHER_TIMEOUT_MS;
108
+ const peerConnections = new Set();
109
+ const accept = async (offerCode) => {
110
+ const payload = decodePeerSignal(offerCode);
111
+ if (payload === null)
112
+ throw new Error("Invalid signal code");
113
+ const pc = new RTCPeerConnection(configuration);
114
+ peerConnections.add(pc);
115
+ pc.ondatachannel = (event) => {
116
+ const channel = event.channel;
117
+ const connection = router.connect({
118
+ send: (data) => {
119
+ if (channel.readyState === "open")
120
+ channel.send(data);
121
+ },
122
+ close: () => channel.close(),
123
+ });
124
+ channel.onmessage = (messageEvent) => {
125
+ connection.handleRaw(typeof messageEvent.data === "string" ? messageEvent.data : String(messageEvent.data));
126
+ };
127
+ channel.onclose = () => connection.close();
128
+ };
129
+ await pc.setRemoteDescription({ type: payload.type, sdp: payload.sdp });
130
+ const answer = await pc.createAnswer();
131
+ await pc.setLocalDescription(answer);
132
+ await waitForIceGathering(pc, iceGatherTimeoutMs);
133
+ const description = localDescriptionOrThrow(pc);
134
+ return encodePeerSignal({ type: "answer", sdp: description.sdp });
135
+ };
136
+ return {
137
+ backend,
138
+ host,
139
+ router,
140
+ accept,
141
+ close: () => {
142
+ for (const pc of peerConnections)
143
+ pc.close();
144
+ peerConnections.clear();
145
+ backend.close();
146
+ router.close();
147
+ if (ownsHost)
148
+ void host.stop();
149
+ },
150
+ };
151
+ }
152
+ export function createPeerGuest(options) {
153
+ const configuration = options.rtc?.configuration ?? {};
154
+ const iceGatherTimeoutMs = options.rtc?.iceGatherTimeoutMs ?? DEFAULT_ICE_GATHER_TIMEOUT_MS;
155
+ const pc = new RTCPeerConnection(configuration);
156
+ const channel = pc.createDataChannel("jg");
157
+ const backend = createWsBackend({
158
+ userId: options.userId,
159
+ token: options.token,
160
+ pipe: guestPipeFactory(channel),
161
+ });
162
+ return {
163
+ backend,
164
+ offer: async () => {
165
+ const offerDescription = await pc.createOffer();
166
+ await pc.setLocalDescription(offerDescription);
167
+ await waitForIceGathering(pc, iceGatherTimeoutMs);
168
+ const description = localDescriptionOrThrow(pc);
169
+ return encodePeerSignal({ type: "offer", sdp: description.sdp });
170
+ },
171
+ connect: async (answerCode) => {
172
+ const payload = decodePeerSignal(answerCode);
173
+ if (payload === null)
174
+ throw new Error("Invalid signal code");
175
+ await pc.setRemoteDescription({ type: payload.type, sdp: payload.sdp });
176
+ },
177
+ close: () => {
178
+ backend.close();
179
+ pc.close();
180
+ },
181
+ };
182
+ }
183
+ function isSignalEnvelope(value) {
184
+ return (isRecord(value) &&
185
+ (value.t === "offer" || value.t === "answer") &&
186
+ typeof value.id === "string" &&
187
+ typeof value.code === "string");
188
+ }
189
+ export function broadcastChannelSignaling(room) {
190
+ const channel = new BroadcastChannel(room);
191
+ const pendingAnswers = new Map();
192
+ let currentAnswer = null;
193
+ channel.onmessage = (event) => {
194
+ const envelope = event.data;
195
+ if (!isSignalEnvelope(envelope))
196
+ return;
197
+ if (envelope.t === "offer") {
198
+ const answer = currentAnswer;
199
+ if (answer === null)
200
+ return;
201
+ void answer(envelope.code).then((answerCode) => {
202
+ channel.postMessage({ t: "answer", id: envelope.id, code: answerCode });
203
+ });
204
+ return;
205
+ }
206
+ const resolve = pendingAnswers.get(envelope.id);
207
+ if (resolve === undefined)
208
+ return;
209
+ pendingAnswers.delete(envelope.id);
210
+ resolve(envelope.code);
211
+ };
212
+ return {
213
+ publishOffer: (offerCode) => {
214
+ const id = crypto.randomUUID();
215
+ return new Promise((resolve) => {
216
+ pendingAnswers.set(id, resolve);
217
+ channel.postMessage({ t: "offer", id, code: offerCode });
218
+ });
219
+ },
220
+ onOffer: (answer) => {
221
+ currentAnswer = answer;
222
+ return () => {
223
+ if (currentAnswer === answer)
224
+ currentAnswer = null;
225
+ };
226
+ },
227
+ close: () => {
228
+ pendingAnswers.clear();
229
+ channel.close();
230
+ },
231
+ };
232
+ }
233
+ export function announcePeerHost(host, signaling) {
234
+ return signaling.onOffer(host.accept);
235
+ }
236
+ export async function joinPeerSession(guest, signaling) {
237
+ const offerCode = await guest.offer();
238
+ const answerCode = await signaling.publishOffer(offerCode);
239
+ await guest.connect(answerCode);
240
+ return guest.backend;
241
+ }
package/dist/pipe.d.ts ADDED
@@ -0,0 +1,11 @@
1
+ export type TransportPipe = {
2
+ send: (data: string) => void;
3
+ close: () => void;
4
+ };
5
+ export type TransportPipeHandlers = {
6
+ onOpen: () => void;
7
+ onMessage: (data: string) => void;
8
+ onClose: () => void;
9
+ };
10
+ export type TransportPipeFactory = (handlers: TransportPipeHandlers) => TransportPipe;
11
+ export declare function webSocketPipe(url: string, webSocketFactory?: (url: string) => WebSocket): TransportPipeFactory;
package/dist/pipe.js ADDED
@@ -0,0 +1,16 @@
1
+ export function webSocketPipe(url, webSocketFactory = (target) => new WebSocket(target)) {
2
+ return (handlers) => {
3
+ const socket = webSocketFactory(url);
4
+ socket.onopen = () => handlers.onOpen();
5
+ socket.onmessage = (event) => handlers.onMessage(typeof event.data === "string" ? event.data : String(event.data));
6
+ socket.onclose = () => handlers.onClose();
7
+ return {
8
+ send: (data) => {
9
+ if (socket.readyState !== 1)
10
+ return;
11
+ socket.send(data);
12
+ },
13
+ close: () => socket.close(),
14
+ };
15
+ };
16
+ }
@@ -1,25 +1,27 @@
1
- import type { GameRuntimePlayerView, GameRuntimeServerView, JoinServerResult, TransportRunCommandResult } from "@jgengine/core/runtime/transport";
1
+ import type { GameRuntimePlayerView, GameRuntimeServerView, JoinServerResult, PresencePoseRow, TransportRunCommandResult } from "@jgengine/core/runtime/transport";
2
+ import type { PlayerPose } from "@jgengine/core/multiplayer/poseSyncGate";
2
3
  import type { SessionAttributes } from "@jgengine/core/runtime/hostPersistence";
3
4
  import type { MatchFilter, SessionListing } from "@jgengine/core/multiplayer/matchmaking";
4
5
  export declare const WS_PROTOCOL_VERSION = 1;
5
- export type WsChannel = "server" | "player" | "feed" | "presence";
6
- export type WsPose = {
7
- x: number;
8
- y: number;
9
- z: number;
10
- rotationY: number;
11
- rotationPitch: number;
6
+ export type WsChannel = "server" | "player" | "feed" | "presence" | "chat" | "voice";
7
+ /** Client-set cosmetic/state tags carried alongside a pose (skin, mount, emote, ...). Primitive values only. */
8
+ export type WsAppearance = Record<string, string | number | boolean>;
9
+ export type WsPose = PlayerPose & {
10
+ appearance?: WsAppearance;
12
11
  };
13
- export type WsPresenceRow = {
12
+ export type WsPresenceRow = PresencePoseRow & {
13
+ appearance?: WsAppearance;
14
+ };
15
+ export type WsChatMessage = {
16
+ id: string;
17
+ channelId: string;
18
+ fromUserId: string;
19
+ body: string;
20
+ at: number;
21
+ };
22
+ export type WsVoiceParticipant = {
14
23
  userId: string;
15
- position: {
16
- x: number;
17
- y: number;
18
- z: number;
19
- };
20
- rotationY: number;
21
- rotationPitch: number;
22
- lastSeenAt: number;
24
+ streamId?: string;
23
25
  };
24
26
  export type WsClientMessage = {
25
27
  v: 1;
@@ -85,6 +87,33 @@ export type WsClientMessage = {
85
87
  t: "pose";
86
88
  serverId: string;
87
89
  pose: WsPose;
90
+ } | {
91
+ v: 1;
92
+ t: "chatSend";
93
+ id: number;
94
+ serverId: string;
95
+ channelId: string;
96
+ body: string;
97
+ } | {
98
+ v: 1;
99
+ t: "voiceJoin";
100
+ id: number;
101
+ serverId: string;
102
+ channelId: string;
103
+ streamId?: string;
104
+ } | {
105
+ v: 1;
106
+ t: "voiceLeave";
107
+ id: number;
108
+ serverId: string;
109
+ channelId: string;
110
+ } | {
111
+ v: 1;
112
+ t: "voicePublish";
113
+ id: number;
114
+ serverId: string;
115
+ channelId: string;
116
+ streamId: string;
88
117
  };
89
118
  export type WsUpdateMessage = {
90
119
  v: 1;
@@ -111,6 +140,20 @@ export type WsUpdateMessage = {
111
140
  channel: "presence";
112
141
  serverId: string;
113
142
  data: WsPresenceRow[];
143
+ } | {
144
+ v: 1;
145
+ t: "update";
146
+ channel: "chat";
147
+ serverId: string;
148
+ action: string;
149
+ data: WsChatMessage[];
150
+ } | {
151
+ v: 1;
152
+ t: "update";
153
+ channel: "voice";
154
+ serverId: string;
155
+ action: string;
156
+ data: WsVoiceParticipant[];
114
157
  };
115
158
  export type WsServerMessage = {
116
159
  v: 1;
@@ -130,6 +173,11 @@ export type WsRunCommandResult = TransportRunCommandResult;
130
173
  export type WsBrowseResult = SessionListing[];
131
174
  export type WsJoinByCodeResult = JoinServerResult | null;
132
175
  export declare function encodeWsMessage(message: WsClientMessage | WsServerMessage): string;
176
+ export type WsDecodeFailure = {
177
+ reason: string;
178
+ id?: number;
179
+ };
180
+ export declare function inspectWsDecodeFailure(raw: unknown): WsDecodeFailure;
133
181
  export declare function decodeWsClientMessage(raw: unknown): WsClientMessage | null;
134
182
  export declare function decodeWsServerMessage(raw: unknown): WsServerMessage | null;
135
183
  export declare function subscriptionKey(channel: WsChannel, serverId: string, action?: string): string;
package/dist/protocol.js CHANGED
@@ -24,15 +24,49 @@ function parseVersioned(raw) {
24
24
  return parsed;
25
25
  }
26
26
  function isWsChannel(value) {
27
- return value === "server" || value === "player" || value === "feed" || value === "presence";
27
+ return (value === "server" ||
28
+ value === "player" ||
29
+ value === "feed" ||
30
+ value === "presence" ||
31
+ value === "chat" ||
32
+ value === "voice");
33
+ }
34
+ function isAppearance(value) {
35
+ return (isRecord(value) &&
36
+ Object.values(value).every((v) => typeof v === "string" || typeof v === "number" || typeof v === "boolean"));
37
+ }
38
+ function isFiniteNumber(value) {
39
+ return typeof value === "number" && Number.isFinite(value);
28
40
  }
29
41
  function isPose(value) {
30
42
  return (isRecord(value) &&
31
- typeof value.x === "number" &&
32
- typeof value.y === "number" &&
33
- typeof value.z === "number" &&
34
- typeof value.rotationY === "number" &&
35
- typeof value.rotationPitch === "number");
43
+ isFiniteNumber(value.x) &&
44
+ isFiniteNumber(value.y) &&
45
+ isFiniteNumber(value.z) &&
46
+ isFiniteNumber(value.rotationY) &&
47
+ isFiniteNumber(value.rotationPitch) &&
48
+ (value.appearance === undefined || isAppearance(value.appearance)));
49
+ }
50
+ export function inspectWsDecodeFailure(raw) {
51
+ if (typeof raw !== "string")
52
+ return { reason: "Invalid message framing" };
53
+ let parsed;
54
+ try {
55
+ parsed = JSON.parse(raw);
56
+ }
57
+ catch {
58
+ return { reason: "Invalid JSON" };
59
+ }
60
+ if (!isRecord(parsed))
61
+ return { reason: "Invalid message" };
62
+ const id = typeof parsed.id === "number" ? parsed.id : undefined;
63
+ if (parsed.v !== WS_PROTOCOL_VERSION) {
64
+ return { reason: "Protocol version mismatch", id };
65
+ }
66
+ if (typeof parsed.t !== "string") {
67
+ return { reason: "Invalid message type", id };
68
+ }
69
+ return { reason: "Malformed message", id };
36
70
  }
37
71
  export function decodeWsClientMessage(raw) {
38
72
  const message = parseVersioned(raw);
@@ -89,6 +123,33 @@ export function decodeWsClientMessage(raw) {
89
123
  return typeof message.serverId === "string" && isPose(message.pose)
90
124
  ? message
91
125
  : null;
126
+ case "chatSend":
127
+ return typeof message.id === "number" &&
128
+ typeof message.serverId === "string" &&
129
+ typeof message.channelId === "string" &&
130
+ typeof message.body === "string"
131
+ ? message
132
+ : null;
133
+ case "voiceJoin":
134
+ return typeof message.id === "number" &&
135
+ typeof message.serverId === "string" &&
136
+ typeof message.channelId === "string" &&
137
+ (message.streamId === undefined || typeof message.streamId === "string")
138
+ ? message
139
+ : null;
140
+ case "voiceLeave":
141
+ return typeof message.id === "number" &&
142
+ typeof message.serverId === "string" &&
143
+ typeof message.channelId === "string"
144
+ ? message
145
+ : null;
146
+ case "voicePublish":
147
+ return typeof message.id === "number" &&
148
+ typeof message.serverId === "string" &&
149
+ typeof message.channelId === "string" &&
150
+ typeof message.streamId === "string"
151
+ ? message
152
+ : null;
92
153
  default:
93
154
  return null;
94
155
  }
@@ -0,0 +1,15 @@
1
+ import type { HostPersistence, ServerListing } from "@jgengine/core/runtime/hostPersistence";
2
+ export type ReadsPersistence = Pick<HostPersistence, "listServers" | "loadProfile" | "getLeaderboardTop" | "getLeaderboardProfile">;
3
+ export type ReadsHandler = (request: Request) => Promise<Response>;
4
+ export type ReadsAuthenticate = (request: Request) => Promise<string | null> | string | null;
5
+ export type ReadsHandlerOptions = {
6
+ persistence: ReadsPersistence | (() => Promise<ReadsPersistence>);
7
+ basePath?: string;
8
+ listOpenServers?: (args: {
9
+ gameId: string;
10
+ limit?: number;
11
+ }) => Promise<ServerListing[]>;
12
+ authenticate?: ReadsAuthenticate;
13
+ allowPublicProfiles?: boolean;
14
+ };
15
+ export declare function createReadsHandler(options: ReadsHandlerOptions): ReadsHandler;
@@ -0,0 +1,84 @@
1
+ import { toOpenServerListings, toServerListing } from "@jgengine/core/runtime/hostPersistence";
2
+ function isScope(value) {
3
+ return value === "global" || value === "server" || value === "profile";
4
+ }
5
+ function json(body, status = 200) {
6
+ return Response.json(body, { status });
7
+ }
8
+ export function createReadsHandler(options) {
9
+ const basePath = (options.basePath ?? "/api").replace(/\/$/, "");
10
+ const allowPublicProfiles = options.allowPublicProfiles === true;
11
+ let persistence = null;
12
+ const getPersistence = () => {
13
+ persistence ??=
14
+ typeof options.persistence === "function" ? options.persistence() : Promise.resolve(options.persistence);
15
+ return persistence;
16
+ };
17
+ return async (request) => {
18
+ const url = new URL(request.url);
19
+ if (url.pathname !== basePath && !url.pathname.startsWith(`${basePath}/`)) {
20
+ return json({ error: "not found" }, 404);
21
+ }
22
+ if (request.method !== "GET") {
23
+ return json({ error: "method not allowed" }, 405);
24
+ }
25
+ let segments;
26
+ try {
27
+ segments = url.pathname.slice(basePath.length).split("/").filter(Boolean).map(decodeURIComponent);
28
+ }
29
+ catch {
30
+ return json({ error: "malformed path" }, 400);
31
+ }
32
+ const gameId = url.searchParams.get("gameId") ?? "";
33
+ const limitParam = url.searchParams.get("limit");
34
+ if (limitParam !== null && !Number.isFinite(Number(limitParam))) {
35
+ return json({ error: "limit must be a number" }, 400);
36
+ }
37
+ const limit = limitParam === null ? undefined : Number(limitParam);
38
+ const [head, tail] = segments;
39
+ if (head === "servers" && tail === undefined) {
40
+ if (options.listOpenServers !== undefined) {
41
+ return json(await options.listOpenServers({ gameId, limit }));
42
+ }
43
+ const records = await (await getPersistence()).listServers(gameId);
44
+ return json(toOpenServerListings(records.map((record) => toServerListing(record)), limit));
45
+ }
46
+ if (head === "leaderboard" && tail !== undefined && segments.length === 2) {
47
+ const scope = url.searchParams.get("scope") ?? "global";
48
+ if (!isScope(scope)) {
49
+ return json({ error: "scope must be global | server | profile" }, 400);
50
+ }
51
+ return json(await (await getPersistence()).getLeaderboardTop({
52
+ gameId,
53
+ stat: tail,
54
+ scope,
55
+ serverId: url.searchParams.get("serverId") ?? undefined,
56
+ limit,
57
+ }));
58
+ }
59
+ if (head === "leaderboard-profile" && tail !== undefined && segments.length === 2) {
60
+ return json(await (await getPersistence()).getLeaderboardProfile({ gameId, userId: tail }));
61
+ }
62
+ if (head === "profile" && tail !== undefined && segments.length === 2) {
63
+ if (!allowPublicProfiles) {
64
+ if (options.authenticate === undefined) {
65
+ return json({ error: "unauthorized" }, 401);
66
+ }
67
+ const actorUserId = await options.authenticate(request);
68
+ if (actorUserId === null || actorUserId !== tail) {
69
+ return json({ error: "unauthorized" }, 401);
70
+ }
71
+ }
72
+ const profile = await (await getPersistence()).loadProfile({ userId: tail, gameId });
73
+ return json(profile === null
74
+ ? null
75
+ : {
76
+ userId: profile.userId,
77
+ gameId: profile.gameId,
78
+ playerState: profile.playerState,
79
+ updatedAt: profile.updatedAt,
80
+ });
81
+ }
82
+ return json({ error: "not found" }, 404);
83
+ };
84
+ }
@@ -0,0 +1,14 @@
1
+ import { type WsBackend, type WsBackendOptions } from "./createWsBackend.js";
2
+ import type { TransportPipeFactory } from "./pipe.js";
3
+ export type SocketIoLikeSocket = {
4
+ connected: boolean;
5
+ on: (event: string, listener: (payload: string) => void) => unknown;
6
+ off: (event: string, listener: (payload: string) => void) => unknown;
7
+ send: (data: string) => unknown;
8
+ disconnect: () => unknown;
9
+ };
10
+ export declare function socketIoPipe(socket: SocketIoLikeSocket): TransportPipeFactory;
11
+ export type SocketIoBackendOptions = Omit<WsBackendOptions, "url" | "pipe" | "webSocketFactory"> & {
12
+ socket: SocketIoLikeSocket;
13
+ };
14
+ export declare function createSocketIoBackend(options: SocketIoBackendOptions): WsBackend;
@@ -0,0 +1,49 @@
1
+ import { createWsBackend } from "./createWsBackend.js";
2
+ export function socketIoPipe(socket) {
3
+ return (handlers) => {
4
+ let closed = false;
5
+ const onConnect = () => {
6
+ if (closed)
7
+ return;
8
+ handlers.onOpen();
9
+ };
10
+ const onMessage = (payload) => {
11
+ if (closed)
12
+ return;
13
+ handlers.onMessage(typeof payload === "string" ? payload : String(payload));
14
+ };
15
+ const onDisconnect = () => {
16
+ if (closed)
17
+ return;
18
+ handlers.onClose();
19
+ };
20
+ socket.on("connect", onConnect);
21
+ socket.on("message", onMessage);
22
+ socket.on("disconnect", onDisconnect);
23
+ if (socket.connected) {
24
+ queueMicrotask(() => {
25
+ if (!closed)
26
+ handlers.onOpen();
27
+ });
28
+ }
29
+ return {
30
+ send: (data) => {
31
+ if (!socket.connected)
32
+ return;
33
+ socket.send(data);
34
+ },
35
+ close: () => {
36
+ if (closed)
37
+ return;
38
+ closed = true;
39
+ socket.off("connect", onConnect);
40
+ socket.off("message", onMessage);
41
+ socket.off("disconnect", onDisconnect);
42
+ },
43
+ };
44
+ };
45
+ }
46
+ export function createSocketIoBackend(options) {
47
+ const { socket, ...rest } = options;
48
+ return createWsBackend({ ...rest, pipe: socketIoPipe(socket) });
49
+ }