@jgengine/node 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.
@@ -0,0 +1,21 @@
1
+ import { type HostRouterOptions, type RewoundPosition } from "@jgengine/ws/hostRouter";
2
+ export type { RewoundPosition };
3
+ export type SocketIoLikeServerSocket = {
4
+ on: (event: string, listener: (payload: string) => void) => unknown;
5
+ send: (data: string) => unknown;
6
+ disconnect: (close?: boolean) => unknown;
7
+ };
8
+ export type SocketIoLikeServer = {
9
+ on: (event: "connection", listener: (socket: SocketIoLikeServerSocket) => void) => unknown;
10
+ };
11
+ export type GameSocketIoServerOptions = HostRouterOptions & {
12
+ io: SocketIoLikeServer;
13
+ };
14
+ export type GameSocketIoServer = {
15
+ rewind: (args: {
16
+ serverId: string;
17
+ atMs: number;
18
+ }) => RewoundPosition[];
19
+ close: () => void;
20
+ };
21
+ export declare function attachGameSocketIoServer(options: GameSocketIoServerOptions): GameSocketIoServer;
@@ -0,0 +1,16 @@
1
+ import { createHostRouter } from "@jgengine/ws/hostRouter";
2
+ export function attachGameSocketIoServer(options) {
3
+ const router = createHostRouter(options);
4
+ options.io.on("connection", (socket) => {
5
+ const connection = router.connect({
6
+ send: (data) => socket.send(data),
7
+ close: () => socket.disconnect(true),
8
+ });
9
+ socket.on("message", (raw) => connection.handleRaw(raw));
10
+ socket.on("disconnect", () => connection.close());
11
+ });
12
+ return {
13
+ rewind: router.rewind,
14
+ close: () => router.close(),
15
+ };
16
+ }
@@ -0,0 +1,5 @@
1
+ import type { IncomingMessage, ServerResponse } from "node:http";
2
+ export type WebHandler = (request: Request) => Promise<Response>;
3
+ export type NodeHandler = (req: IncomingMessage, res: ServerResponse) => void;
4
+ export declare function toWebRequest(req: IncomingMessage): Request;
5
+ export declare function toNodeHandler(handler: WebHandler): NodeHandler;
@@ -0,0 +1,27 @@
1
+ export function toWebRequest(req) {
2
+ const host = req.headers.host ?? "localhost";
3
+ const headers = new Headers();
4
+ for (const [key, value] of Object.entries(req.headers)) {
5
+ if (typeof value === "string")
6
+ headers.set(key, value);
7
+ else if (Array.isArray(value))
8
+ for (const entry of value)
9
+ headers.append(key, entry);
10
+ }
11
+ return new Request(`http://${host}${req.url ?? "/"}`, { method: req.method, headers });
12
+ }
13
+ export function toNodeHandler(handler) {
14
+ return (req, res) => {
15
+ void handler(toWebRequest(req))
16
+ .then(async (response) => {
17
+ res.statusCode = response.status;
18
+ response.headers.forEach((value, key) => res.setHeader(key, value));
19
+ res.end(Buffer.from(await response.arrayBuffer()));
20
+ })
21
+ .catch((error) => {
22
+ res.statusCode = 500;
23
+ res.setHeader("content-type", "application/json");
24
+ res.end(JSON.stringify({ error: error instanceof Error ? error.message : "internal error" }));
25
+ });
26
+ };
27
+ }
@@ -1,25 +1,11 @@
1
1
  import type { Server as HttpServer } from "node:http";
2
2
  import { WebSocketServer } from "ws";
3
- import type { PoseSyncRules } from "@jgengine/core/multiplayer/presenceModel";
4
- import type { GameHost } from "./host.js";
5
- export type GameWsServerOptions = {
6
- host: GameHost;
3
+ import { type HostRouterOptions, type RewoundPosition } from "@jgengine/ws/hostRouter";
4
+ export type { RewoundPosition };
5
+ export type GameWsServerOptions = HostRouterOptions & {
7
6
  server?: HttpServer;
8
7
  port?: number;
9
8
  path?: string;
10
- authenticate?: (args: {
11
- userId: string;
12
- token?: string;
13
- }) => Promise<string | null> | string | null;
14
- poseRules?: PoseSyncRules;
15
- positionHistoryMs?: number;
16
- now?: () => number;
17
- };
18
- export type RewoundPosition = {
19
- userId: string;
20
- x: number;
21
- y: number;
22
- z: number;
23
9
  };
24
10
  export type GameWsServer = {
25
11
  wss: WebSocketServer;
package/dist/wsServer.js CHANGED
@@ -1,256 +1,21 @@
1
1
  import { WebSocketServer } from "ws";
2
- import { decidePoseSync } from "@jgengine/core/multiplayer/presenceModel";
3
- import { createPositionHistory } from "@jgengine/core/multiplayer/lagCompensation";
4
- import { decodeWsClientMessage, encodeWsMessage, subscriptionKey, } from "@jgengine/ws/protocol";
5
- const DEFAULT_POSE_RULES = {
6
- maxSpeed: 12,
7
- maxVerticalOffset: 3,
8
- minElapsedSec: 0.05,
9
- maxElapsedSec: 0.5,
10
- keepAliveRefreshMs: 10_000,
11
- };
2
+ import { createHostRouter } from "@jgengine/ws/hostRouter";
12
3
  export function createGameWsServer(options) {
13
- const host = options.host;
14
- const now = options.now ?? Date.now;
15
- const poseRules = options.poseRules ?? DEFAULT_POSE_RULES;
16
- const authenticate = options.authenticate ?? (({ userId }) => userId);
4
+ const router = createHostRouter(options);
17
5
  const wss = options.server !== undefined
18
6
  ? new WebSocketServer({ server: options.server, path: options.path })
19
7
  : new WebSocketServer({ port: options.port ?? 0, path: options.path });
20
- const connections = new Set();
21
- const presence = new Map();
22
- const positionHistoryMs = options.positionHistoryMs ?? 1_000;
23
- const histories = new Map();
24
- const historyFor = (serverId) => {
25
- let history = histories.get(serverId);
26
- if (history === undefined) {
27
- history = createPositionHistory({ historyMs: positionHistoryMs });
28
- histories.set(serverId, history);
29
- }
30
- return history;
31
- };
32
- const send = (connection, message) => {
33
- if (connection.socket.readyState !== connection.socket.OPEN)
34
- return;
35
- connection.socket.send(encodeWsMessage(message));
36
- };
37
- const reply = (connection, id, result) => {
38
- send(connection, { v: 1, t: "reply", id, ok: true, result });
39
- };
40
- const replyError = (connection, id, reason) => {
41
- send(connection, { v: 1, t: "reply", id, ok: false, reason });
42
- };
43
- const presenceRows = (serverId) => {
44
- const rows = presence.get(serverId);
45
- if (rows === undefined)
46
- return [];
47
- return [...rows.entries()].map(([userId, pose]) => ({
48
- userId,
49
- position: pose.position,
50
- rotationY: pose.rotationY,
51
- rotationPitch: pose.rotationPitch ?? 0,
52
- lastSeenAt: pose.lastSeenAtMs ?? 0,
53
- }));
54
- };
55
- const broadcastPresence = (serverId) => {
56
- const key = subscriptionKey("presence", serverId);
57
- const data = presenceRows(serverId);
58
- for (const connection of connections) {
59
- if (!connection.subscriptions.has(key))
60
- continue;
61
- send(connection, { v: 1, t: "update", channel: "presence", serverId, data });
62
- }
63
- };
64
- const pushSubscription = async (connection, channel, serverId, action) => {
65
- if (connection.userId === null)
66
- return;
67
- if (channel === "server") {
68
- const data = await host.getServerView({ userId: connection.userId, serverId });
69
- send(connection, { v: 1, t: "update", channel, serverId, data });
70
- }
71
- else if (channel === "player") {
72
- const data = await host.getPlayerView({ userId: connection.userId, serverId });
73
- send(connection, { v: 1, t: "update", channel, serverId, data });
74
- }
75
- else if (channel === "feed") {
76
- const data = await host.getFeed({ userId: connection.userId, serverId, action: action ?? "" });
77
- send(connection, { v: 1, t: "update", channel, serverId, action: action ?? "", data });
78
- }
79
- else {
80
- send(connection, { v: 1, t: "update", channel, serverId, data: presenceRows(serverId) });
81
- }
82
- };
83
- const onHostEvent = (event) => {
84
- for (const connection of connections) {
85
- if (connection.userId === null)
86
- continue;
87
- if (event.type === "server") {
88
- if (connection.subscriptions.has(subscriptionKey("server", event.serverId))) {
89
- void pushSubscription(connection, "server", event.serverId);
90
- }
91
- }
92
- else if (event.type === "player") {
93
- if (connection.userId === event.userId &&
94
- connection.subscriptions.has(subscriptionKey("player", event.serverId))) {
95
- void pushSubscription(connection, "player", event.serverId);
96
- }
97
- }
98
- else {
99
- if (connection.subscriptions.has(subscriptionKey("feed", event.serverId, event.action))) {
100
- void pushSubscription(connection, "feed", event.serverId, event.action);
101
- }
102
- }
103
- }
104
- };
105
- const unsubscribeHost = host.subscribe(onHostEvent);
106
- const handlePose = (connection, serverId, pose) => {
107
- if (connection.userId === null)
108
- return;
109
- const rows = presence.get(serverId) ?? new Map();
110
- presence.set(serverId, rows);
111
- const timestamp = now();
112
- const current = rows.get(connection.userId);
113
- if (current === undefined) {
114
- rows.set(connection.userId, {
115
- position: { x: pose.x, y: pose.y, z: pose.z },
116
- rotationY: pose.rotationY,
117
- rotationPitch: pose.rotationPitch,
118
- lastSeenAtMs: timestamp,
119
- });
120
- historyFor(serverId).record(connection.userId, timestamp, { x: pose.x, y: pose.y, z: pose.z });
121
- broadcastPresence(serverId);
122
- return;
123
- }
124
- const decision = decidePoseSync(current, { position: { x: pose.x, y: pose.y, z: pose.z }, rotationY: pose.rotationY, rotationPitch: pose.rotationPitch }, poseRules, timestamp);
125
- if (decision.changed || decision.refreshKeepAlive) {
126
- rows.set(connection.userId, {
127
- position: decision.position,
128
- rotationY: decision.rotationY,
129
- rotationPitch: decision.rotationPitch,
130
- lastSeenAtMs: timestamp,
131
- });
132
- historyFor(serverId).record(connection.userId, timestamp, decision.position);
133
- }
134
- if (decision.changed)
135
- broadcastPresence(serverId);
136
- };
137
- const dropPresence = (connection) => {
138
- if (connection.userId === null)
139
- return;
140
- for (const [serverId, rows] of presence) {
141
- if (rows.delete(connection.userId)) {
142
- if (rows.size === 0)
143
- presence.delete(serverId);
144
- broadcastPresence(serverId);
145
- }
146
- }
147
- };
148
- const handleMessage = async (connection, message) => {
149
- if (message.t === "hello") {
150
- const userId = await authenticate({ userId: message.userId, token: message.token });
151
- if (userId === null) {
152
- replyError(connection, message.id, "Not authenticated");
153
- connection.socket.close();
154
- return;
155
- }
156
- connection.userId = userId;
157
- reply(connection, message.id, { userId });
158
- return;
159
- }
160
- if (message.t === "pose") {
161
- handlePose(connection, message.serverId, message.pose);
162
- return;
163
- }
164
- if (connection.userId === null) {
165
- replyError(connection, message.id, "Not authenticated");
166
- return;
167
- }
168
- const userId = connection.userId;
169
- try {
170
- switch (message.t) {
171
- case "join": {
172
- const result = await host.joinServer({
173
- userId,
174
- gameId: message.gameId,
175
- serverId: message.serverId,
176
- attributes: message.attributes,
177
- });
178
- reply(connection, message.id, result);
179
- return;
180
- }
181
- case "joinByCode": {
182
- const result = await host.joinByCode({
183
- userId,
184
- gameId: message.gameId,
185
- code: message.code,
186
- });
187
- reply(connection, message.id, result);
188
- return;
189
- }
190
- case "browse": {
191
- const result = await host.browseServers({
192
- gameId: message.gameId,
193
- filter: message.filter,
194
- limit: message.limit,
195
- });
196
- reply(connection, message.id, result);
197
- return;
198
- }
199
- case "leave": {
200
- await host.leaveServer({ userId, serverId: message.serverId });
201
- reply(connection, message.id, null);
202
- return;
203
- }
204
- case "runCommand": {
205
- const result = await host.runCommand({
206
- userId,
207
- serverId: message.serverId,
208
- command: message.command,
209
- input: message.input,
210
- });
211
- reply(connection, message.id, result);
212
- return;
213
- }
214
- case "pushFeed": {
215
- await host.pushFeedEntry({
216
- userId,
217
- serverId: message.serverId,
218
- action: message.action,
219
- entry: message.entry,
220
- });
221
- reply(connection, message.id, null);
222
- return;
223
- }
224
- case "subscribe": {
225
- connection.subscriptions.add(subscriptionKey(message.channel, message.serverId, message.action));
226
- reply(connection, message.id, null);
227
- await pushSubscription(connection, message.channel, message.serverId, message.action);
228
- return;
229
- }
230
- case "unsubscribe": {
231
- connection.subscriptions.delete(subscriptionKey(message.channel, message.serverId, message.action));
232
- reply(connection, message.id, null);
233
- return;
234
- }
235
- }
236
- }
237
- catch (error) {
238
- replyError(connection, message.id, error instanceof Error ? error.message : "Internal error");
239
- }
240
- };
241
8
  wss.on("connection", (socket) => {
242
- const connection = { socket, userId: null, subscriptions: new Set() };
243
- connections.add(connection);
244
- socket.on("message", (raw) => {
245
- const message = decodeWsClientMessage(raw.toString());
246
- if (message === null)
247
- return;
248
- void handleMessage(connection, message);
249
- });
250
- socket.on("close", () => {
251
- connections.delete(connection);
252
- dropPresence(connection);
9
+ const connection = router.connect({
10
+ send: (data) => {
11
+ if (socket.readyState !== socket.OPEN)
12
+ return;
13
+ socket.send(data);
14
+ },
15
+ close: () => socket.close(),
253
16
  });
17
+ socket.on("message", (raw) => connection.handleRaw(raw.toString()));
18
+ socket.on("close", () => connection.close());
254
19
  });
255
20
  return {
256
21
  wss,
@@ -261,23 +26,11 @@ export function createGameWsServer(options) {
261
26
  }
262
27
  return address.port;
263
28
  },
264
- rewind: ({ serverId, atMs }) => {
265
- const history = histories.get(serverId);
266
- if (history === undefined)
267
- return [];
268
- const positions = [];
269
- for (const userId of history.entities()) {
270
- const sample = history.sampleAt(userId, atMs);
271
- if (sample !== null) {
272
- positions.push({ userId, x: sample.x, y: sample.y, z: sample.z });
273
- }
274
- }
275
- return positions;
276
- },
29
+ rewind: router.rewind,
277
30
  close: () => new Promise((resolve) => {
278
- unsubscribeHost();
279
- for (const connection of connections) {
280
- connection.socket.terminate();
31
+ router.close();
32
+ for (const client of wss.clients) {
33
+ client.terminate();
281
34
  }
282
35
  let settled = false;
283
36
  const finish = () => {