@jgengine/ws 0.7.0 → 0.8.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/CHANGELOG.md +47 -0
- package/dist/createWsBackend.d.ts +21 -2
- package/dist/createWsBackend.js +97 -44
- package/dist/host.d.ts +85 -0
- package/dist/host.js +425 -0
- package/dist/hostRouter.d.ts +42 -0
- package/dist/hostRouter.js +493 -0
- package/dist/peer.d.ts +53 -0
- package/dist/peer.js +237 -0
- package/dist/pipe.d.ts +11 -0
- package/dist/pipe.js +16 -0
- package/dist/protocol.d.ts +60 -17
- package/dist/protocol.js +39 -2
- package/dist/readsHandler.d.ts +12 -0
- package/dist/readsHandler.js +65 -0
- package/dist/socketIoPipe.d.ts +14 -0
- package/dist/socketIoPipe.js +49 -0
- package/llms.txt +1482 -0
- package/package.json +4 -3
package/dist/peer.js
ADDED
|
@@ -0,0 +1,237 @@
|
|
|
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({ ...options.router, host });
|
|
97
|
+
const backend = createWsBackend({
|
|
98
|
+
userId: options.userId,
|
|
99
|
+
token: options.token,
|
|
100
|
+
pipe: loopbackPipe(router),
|
|
101
|
+
});
|
|
102
|
+
const configuration = options.rtc?.configuration ?? {};
|
|
103
|
+
const iceGatherTimeoutMs = options.rtc?.iceGatherTimeoutMs ?? DEFAULT_ICE_GATHER_TIMEOUT_MS;
|
|
104
|
+
const peerConnections = new Set();
|
|
105
|
+
const accept = async (offerCode) => {
|
|
106
|
+
const payload = decodePeerSignal(offerCode);
|
|
107
|
+
if (payload === null)
|
|
108
|
+
throw new Error("Invalid signal code");
|
|
109
|
+
const pc = new RTCPeerConnection(configuration);
|
|
110
|
+
peerConnections.add(pc);
|
|
111
|
+
pc.ondatachannel = (event) => {
|
|
112
|
+
const channel = event.channel;
|
|
113
|
+
const connection = router.connect({
|
|
114
|
+
send: (data) => {
|
|
115
|
+
if (channel.readyState === "open")
|
|
116
|
+
channel.send(data);
|
|
117
|
+
},
|
|
118
|
+
close: () => channel.close(),
|
|
119
|
+
});
|
|
120
|
+
channel.onmessage = (messageEvent) => {
|
|
121
|
+
connection.handleRaw(typeof messageEvent.data === "string" ? messageEvent.data : String(messageEvent.data));
|
|
122
|
+
};
|
|
123
|
+
channel.onclose = () => connection.close();
|
|
124
|
+
};
|
|
125
|
+
await pc.setRemoteDescription({ type: payload.type, sdp: payload.sdp });
|
|
126
|
+
const answer = await pc.createAnswer();
|
|
127
|
+
await pc.setLocalDescription(answer);
|
|
128
|
+
await waitForIceGathering(pc, iceGatherTimeoutMs);
|
|
129
|
+
const description = localDescriptionOrThrow(pc);
|
|
130
|
+
return encodePeerSignal({ type: "answer", sdp: description.sdp });
|
|
131
|
+
};
|
|
132
|
+
return {
|
|
133
|
+
backend,
|
|
134
|
+
host,
|
|
135
|
+
router,
|
|
136
|
+
accept,
|
|
137
|
+
close: () => {
|
|
138
|
+
for (const pc of peerConnections)
|
|
139
|
+
pc.close();
|
|
140
|
+
peerConnections.clear();
|
|
141
|
+
backend.close();
|
|
142
|
+
router.close();
|
|
143
|
+
if (ownsHost)
|
|
144
|
+
void host.stop();
|
|
145
|
+
},
|
|
146
|
+
};
|
|
147
|
+
}
|
|
148
|
+
export function createPeerGuest(options) {
|
|
149
|
+
const configuration = options.rtc?.configuration ?? {};
|
|
150
|
+
const iceGatherTimeoutMs = options.rtc?.iceGatherTimeoutMs ?? DEFAULT_ICE_GATHER_TIMEOUT_MS;
|
|
151
|
+
const pc = new RTCPeerConnection(configuration);
|
|
152
|
+
const channel = pc.createDataChannel("jg");
|
|
153
|
+
const backend = createWsBackend({
|
|
154
|
+
userId: options.userId,
|
|
155
|
+
token: options.token,
|
|
156
|
+
pipe: guestPipeFactory(channel),
|
|
157
|
+
});
|
|
158
|
+
return {
|
|
159
|
+
backend,
|
|
160
|
+
offer: async () => {
|
|
161
|
+
const offerDescription = await pc.createOffer();
|
|
162
|
+
await pc.setLocalDescription(offerDescription);
|
|
163
|
+
await waitForIceGathering(pc, iceGatherTimeoutMs);
|
|
164
|
+
const description = localDescriptionOrThrow(pc);
|
|
165
|
+
return encodePeerSignal({ type: "offer", sdp: description.sdp });
|
|
166
|
+
},
|
|
167
|
+
connect: async (answerCode) => {
|
|
168
|
+
const payload = decodePeerSignal(answerCode);
|
|
169
|
+
if (payload === null)
|
|
170
|
+
throw new Error("Invalid signal code");
|
|
171
|
+
await pc.setRemoteDescription({ type: payload.type, sdp: payload.sdp });
|
|
172
|
+
},
|
|
173
|
+
close: () => {
|
|
174
|
+
backend.close();
|
|
175
|
+
pc.close();
|
|
176
|
+
},
|
|
177
|
+
};
|
|
178
|
+
}
|
|
179
|
+
function isSignalEnvelope(value) {
|
|
180
|
+
return (isRecord(value) &&
|
|
181
|
+
(value.t === "offer" || value.t === "answer") &&
|
|
182
|
+
typeof value.id === "string" &&
|
|
183
|
+
typeof value.code === "string");
|
|
184
|
+
}
|
|
185
|
+
export function broadcastChannelSignaling(room) {
|
|
186
|
+
const channel = new BroadcastChannel(room);
|
|
187
|
+
const pendingAnswers = new Map();
|
|
188
|
+
let currentAnswer = null;
|
|
189
|
+
channel.onmessage = (event) => {
|
|
190
|
+
const envelope = event.data;
|
|
191
|
+
if (!isSignalEnvelope(envelope))
|
|
192
|
+
return;
|
|
193
|
+
if (envelope.t === "offer") {
|
|
194
|
+
const answer = currentAnswer;
|
|
195
|
+
if (answer === null)
|
|
196
|
+
return;
|
|
197
|
+
void answer(envelope.code).then((answerCode) => {
|
|
198
|
+
channel.postMessage({ t: "answer", id: envelope.id, code: answerCode });
|
|
199
|
+
});
|
|
200
|
+
return;
|
|
201
|
+
}
|
|
202
|
+
const resolve = pendingAnswers.get(envelope.id);
|
|
203
|
+
if (resolve === undefined)
|
|
204
|
+
return;
|
|
205
|
+
pendingAnswers.delete(envelope.id);
|
|
206
|
+
resolve(envelope.code);
|
|
207
|
+
};
|
|
208
|
+
return {
|
|
209
|
+
publishOffer: (offerCode) => {
|
|
210
|
+
const id = crypto.randomUUID();
|
|
211
|
+
return new Promise((resolve) => {
|
|
212
|
+
pendingAnswers.set(id, resolve);
|
|
213
|
+
channel.postMessage({ t: "offer", id, code: offerCode });
|
|
214
|
+
});
|
|
215
|
+
},
|
|
216
|
+
onOffer: (answer) => {
|
|
217
|
+
currentAnswer = answer;
|
|
218
|
+
return () => {
|
|
219
|
+
if (currentAnswer === answer)
|
|
220
|
+
currentAnswer = null;
|
|
221
|
+
};
|
|
222
|
+
},
|
|
223
|
+
close: () => {
|
|
224
|
+
pendingAnswers.clear();
|
|
225
|
+
channel.close();
|
|
226
|
+
},
|
|
227
|
+
};
|
|
228
|
+
}
|
|
229
|
+
export function announcePeerHost(host, signaling) {
|
|
230
|
+
return signaling.onOffer(host.accept);
|
|
231
|
+
}
|
|
232
|
+
export async function joinPeerSession(guest, signaling) {
|
|
233
|
+
const offerCode = await guest.offer();
|
|
234
|
+
const answerCode = await signaling.publishOffer(offerCode);
|
|
235
|
+
await guest.connect(answerCode);
|
|
236
|
+
return guest.backend;
|
|
237
|
+
}
|
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
|
+
}
|
package/dist/protocol.d.ts
CHANGED
|
@@ -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
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
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
|
-
|
|
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;
|
package/dist/protocol.js
CHANGED
|
@@ -24,7 +24,16 @@ function parseVersioned(raw) {
|
|
|
24
24
|
return parsed;
|
|
25
25
|
}
|
|
26
26
|
function isWsChannel(value) {
|
|
27
|
-
return value === "server" ||
|
|
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"));
|
|
28
37
|
}
|
|
29
38
|
function isPose(value) {
|
|
30
39
|
return (isRecord(value) &&
|
|
@@ -32,7 +41,8 @@ function isPose(value) {
|
|
|
32
41
|
typeof value.y === "number" &&
|
|
33
42
|
typeof value.z === "number" &&
|
|
34
43
|
typeof value.rotationY === "number" &&
|
|
35
|
-
typeof value.rotationPitch === "number"
|
|
44
|
+
typeof value.rotationPitch === "number" &&
|
|
45
|
+
(value.appearance === undefined || isAppearance(value.appearance)));
|
|
36
46
|
}
|
|
37
47
|
export function decodeWsClientMessage(raw) {
|
|
38
48
|
const message = parseVersioned(raw);
|
|
@@ -89,6 +99,33 @@ export function decodeWsClientMessage(raw) {
|
|
|
89
99
|
return typeof message.serverId === "string" && isPose(message.pose)
|
|
90
100
|
? message
|
|
91
101
|
: null;
|
|
102
|
+
case "chatSend":
|
|
103
|
+
return typeof message.id === "number" &&
|
|
104
|
+
typeof message.serverId === "string" &&
|
|
105
|
+
typeof message.channelId === "string" &&
|
|
106
|
+
typeof message.body === "string"
|
|
107
|
+
? message
|
|
108
|
+
: null;
|
|
109
|
+
case "voiceJoin":
|
|
110
|
+
return typeof message.id === "number" &&
|
|
111
|
+
typeof message.serverId === "string" &&
|
|
112
|
+
typeof message.channelId === "string" &&
|
|
113
|
+
(message.streamId === undefined || typeof message.streamId === "string")
|
|
114
|
+
? message
|
|
115
|
+
: null;
|
|
116
|
+
case "voiceLeave":
|
|
117
|
+
return typeof message.id === "number" &&
|
|
118
|
+
typeof message.serverId === "string" &&
|
|
119
|
+
typeof message.channelId === "string"
|
|
120
|
+
? message
|
|
121
|
+
: null;
|
|
122
|
+
case "voicePublish":
|
|
123
|
+
return typeof message.id === "number" &&
|
|
124
|
+
typeof message.serverId === "string" &&
|
|
125
|
+
typeof message.channelId === "string" &&
|
|
126
|
+
typeof message.streamId === "string"
|
|
127
|
+
? message
|
|
128
|
+
: null;
|
|
92
129
|
default:
|
|
93
130
|
return null;
|
|
94
131
|
}
|
|
@@ -0,0 +1,12 @@
|
|
|
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 ReadsHandlerOptions = {
|
|
5
|
+
persistence: ReadsPersistence | (() => Promise<ReadsPersistence>);
|
|
6
|
+
basePath?: string;
|
|
7
|
+
listOpenServers?: (args: {
|
|
8
|
+
gameId: string;
|
|
9
|
+
limit?: number;
|
|
10
|
+
}) => Promise<ServerListing[]>;
|
|
11
|
+
};
|
|
12
|
+
export declare function createReadsHandler(options: ReadsHandlerOptions): ReadsHandler;
|
|
@@ -0,0 +1,65 @@
|
|
|
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
|
+
let persistence = null;
|
|
11
|
+
const getPersistence = () => {
|
|
12
|
+
persistence ??=
|
|
13
|
+
typeof options.persistence === "function" ? options.persistence() : Promise.resolve(options.persistence);
|
|
14
|
+
return persistence;
|
|
15
|
+
};
|
|
16
|
+
return async (request) => {
|
|
17
|
+
const url = new URL(request.url);
|
|
18
|
+
if (url.pathname !== basePath && !url.pathname.startsWith(`${basePath}/`)) {
|
|
19
|
+
return json({ error: "not found" }, 404);
|
|
20
|
+
}
|
|
21
|
+
if (request.method !== "GET") {
|
|
22
|
+
return json({ error: "method not allowed" }, 405);
|
|
23
|
+
}
|
|
24
|
+
const segments = url.pathname.slice(basePath.length).split("/").filter(Boolean).map(decodeURIComponent);
|
|
25
|
+
const gameId = url.searchParams.get("gameId") ?? "";
|
|
26
|
+
const limitParam = url.searchParams.get("limit");
|
|
27
|
+
const limit = limitParam === null ? undefined : Number(limitParam);
|
|
28
|
+
const [head, tail] = segments;
|
|
29
|
+
if (head === "servers" && tail === undefined) {
|
|
30
|
+
if (options.listOpenServers !== undefined) {
|
|
31
|
+
return json(await options.listOpenServers({ gameId, limit }));
|
|
32
|
+
}
|
|
33
|
+
const records = await (await getPersistence()).listServers(gameId);
|
|
34
|
+
return json(toOpenServerListings(records.map(toServerListing), limit));
|
|
35
|
+
}
|
|
36
|
+
if (head === "leaderboard" && tail !== undefined && segments.length === 2) {
|
|
37
|
+
const scope = url.searchParams.get("scope") ?? "global";
|
|
38
|
+
if (!isScope(scope)) {
|
|
39
|
+
return json({ error: "scope must be global | server | profile" }, 400);
|
|
40
|
+
}
|
|
41
|
+
return json(await (await getPersistence()).getLeaderboardTop({
|
|
42
|
+
gameId,
|
|
43
|
+
stat: tail,
|
|
44
|
+
scope,
|
|
45
|
+
serverId: url.searchParams.get("serverId") ?? undefined,
|
|
46
|
+
limit,
|
|
47
|
+
}));
|
|
48
|
+
}
|
|
49
|
+
if (head === "leaderboard-profile" && tail !== undefined && segments.length === 2) {
|
|
50
|
+
return json(await (await getPersistence()).getLeaderboardProfile({ gameId, userId: tail }));
|
|
51
|
+
}
|
|
52
|
+
if (head === "profile" && tail !== undefined && segments.length === 2) {
|
|
53
|
+
const profile = await (await getPersistence()).loadProfile({ userId: tail, gameId });
|
|
54
|
+
return json(profile === null
|
|
55
|
+
? null
|
|
56
|
+
: {
|
|
57
|
+
userId: profile.userId,
|
|
58
|
+
gameId: profile.gameId,
|
|
59
|
+
playerState: profile.playerState,
|
|
60
|
+
updatedAt: profile.updatedAt,
|
|
61
|
+
});
|
|
62
|
+
}
|
|
63
|
+
return json({ error: "not found" }, 404);
|
|
64
|
+
};
|
|
65
|
+
}
|
|
@@ -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
|
+
}
|