@sametnurel/game-server 0.0.0-stage → 1.0.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 +29 -0
- package/README.md +160 -2
- package/dist/command.d.ts +25 -0
- package/dist/command.js +15 -0
- package/dist/connection-manager.d.ts +24 -0
- package/dist/connection-manager.js +60 -0
- package/dist/connection.d.ts +22 -0
- package/dist/connection.js +39 -0
- package/dist/events.d.ts +19 -0
- package/dist/events.js +14 -0
- package/dist/game-manager.d.ts +16 -0
- package/dist/game-manager.js +57 -0
- package/dist/game.d.ts +29 -0
- package/dist/game.js +38 -0
- package/dist/ids.d.ts +24 -0
- package/dist/ids.js +47 -0
- package/dist/index.d.ts +16 -0
- package/dist/index.js +37 -0
- package/dist/logger.d.ts +19 -0
- package/dist/logger.js +42 -0
- package/dist/matchmaking.d.ts +22 -0
- package/dist/matchmaking.js +63 -0
- package/dist/player.d.ts +16 -0
- package/dist/player.js +23 -0
- package/dist/rooms.d.ts +11 -0
- package/dist/rooms.js +42 -0
- package/dist/server.d.ts +88 -0
- package/dist/server.js +224 -0
- package/dist/sessions.d.ts +39 -0
- package/dist/sessions.js +78 -0
- package/dist/transport/memory.d.ts +41 -0
- package/dist/transport/memory.js +108 -0
- package/dist/transport/socket-io.d.ts +17 -0
- package/dist/transport/socket-io.js +52 -0
- package/dist/transport/types.d.ts +31 -0
- package/dist/transport/types.js +2 -0
- package/dist/transport/ws.d.ts +28 -0
- package/dist/transport/ws.js +120 -0
- package/dist/turn-timer.d.ts +15 -0
- package/dist/turn-timer.js +39 -0
- package/package.json +74 -4
package/dist/sessions.js
ADDED
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.SessionManager = void 0;
|
|
4
|
+
/**
|
|
5
|
+
* Keeps a client's identity across reconnects. A session is keyed by a stable
|
|
6
|
+
* identifier (device or account id). When its connection drops, the session is
|
|
7
|
+
* kept for `reconnectWindowMs`; attaching the same identifier again resumes it.
|
|
8
|
+
*/
|
|
9
|
+
class SessionManager {
|
|
10
|
+
sessions = new Map();
|
|
11
|
+
timers = new Map();
|
|
12
|
+
windowMs;
|
|
13
|
+
onExpire;
|
|
14
|
+
constructor(options = {}) {
|
|
15
|
+
this.windowMs = options.reconnectWindowMs ?? 30_000;
|
|
16
|
+
this.onExpire = options.onExpire;
|
|
17
|
+
}
|
|
18
|
+
/** Binds a connection to an identifier. `resumed` is true if it came back in time. */
|
|
19
|
+
attach(identifier, connection) {
|
|
20
|
+
const existing = this.sessions.get(identifier);
|
|
21
|
+
if (existing) {
|
|
22
|
+
const resumed = existing.disconnectedAt !== undefined;
|
|
23
|
+
this.clearTimer(identifier);
|
|
24
|
+
existing.connection = connection;
|
|
25
|
+
existing.disconnectedAt = undefined;
|
|
26
|
+
return { session: existing, resumed };
|
|
27
|
+
}
|
|
28
|
+
const session = { identifier, connection, state: undefined, disconnectedAt: undefined };
|
|
29
|
+
this.sessions.set(identifier, session);
|
|
30
|
+
return { session, resumed: false };
|
|
31
|
+
}
|
|
32
|
+
/** Marks the session of this connection as disconnected and starts the reconnect window. */
|
|
33
|
+
detach(connection, now = Date.now()) {
|
|
34
|
+
const identifier = connection.identifier;
|
|
35
|
+
if (!identifier)
|
|
36
|
+
return undefined;
|
|
37
|
+
const session = this.sessions.get(identifier);
|
|
38
|
+
if (!session || session.connection !== connection)
|
|
39
|
+
return undefined;
|
|
40
|
+
session.connection = undefined;
|
|
41
|
+
session.disconnectedAt = now;
|
|
42
|
+
this.clearTimer(identifier);
|
|
43
|
+
const timer = setTimeout(() => {
|
|
44
|
+
this.timers.delete(identifier);
|
|
45
|
+
if (this.sessions.get(identifier) !== session || session.disconnectedAt === undefined)
|
|
46
|
+
return;
|
|
47
|
+
this.sessions.delete(identifier);
|
|
48
|
+
this.onExpire?.(session);
|
|
49
|
+
}, this.windowMs);
|
|
50
|
+
timer.unref?.();
|
|
51
|
+
this.timers.set(identifier, timer);
|
|
52
|
+
return session;
|
|
53
|
+
}
|
|
54
|
+
get(identifier) {
|
|
55
|
+
return this.sessions.get(identifier);
|
|
56
|
+
}
|
|
57
|
+
/** Ends a session for good (e.g. after the game is over). */
|
|
58
|
+
end(identifier) {
|
|
59
|
+
this.clearTimer(identifier);
|
|
60
|
+
this.sessions.delete(identifier);
|
|
61
|
+
}
|
|
62
|
+
clear() {
|
|
63
|
+
for (const timer of this.timers.values())
|
|
64
|
+
clearTimeout(timer);
|
|
65
|
+
this.timers.clear();
|
|
66
|
+
this.sessions.clear();
|
|
67
|
+
}
|
|
68
|
+
get size() {
|
|
69
|
+
return this.sessions.size;
|
|
70
|
+
}
|
|
71
|
+
clearTimer(identifier) {
|
|
72
|
+
const timer = this.timers.get(identifier);
|
|
73
|
+
if (timer)
|
|
74
|
+
clearTimeout(timer);
|
|
75
|
+
this.timers.delete(identifier);
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
exports.SessionManager = SessionManager;
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
import type { ClientSocket, Transport, TransportContext, TransportHandlers } from './types';
|
|
2
|
+
interface Received {
|
|
3
|
+
event: string;
|
|
4
|
+
data: unknown;
|
|
5
|
+
}
|
|
6
|
+
type Listener = (data: unknown) => void;
|
|
7
|
+
/**
|
|
8
|
+
* In-process client: for bots and tests. Speaks the same events as a network
|
|
9
|
+
* client. Server-to-client messages are delivered asynchronously (microtask).
|
|
10
|
+
*/
|
|
11
|
+
export declare class MemoryClient {
|
|
12
|
+
readonly id: string;
|
|
13
|
+
private readonly handlers;
|
|
14
|
+
private readonly socket;
|
|
15
|
+
readonly received: Received[];
|
|
16
|
+
private readonly listeners;
|
|
17
|
+
private connected;
|
|
18
|
+
constructor(id: string, handlers: TransportHandlers, socket: ClientSocket);
|
|
19
|
+
/** Client -> server. */
|
|
20
|
+
send(event: string, data?: unknown): void;
|
|
21
|
+
on(event: string, listener: Listener): () => void;
|
|
22
|
+
/** Resolves with the next payload of `event`, or rejects after `timeoutMs`. */
|
|
23
|
+
waitFor<T = unknown>(event: string, timeoutMs?: number): Promise<T>;
|
|
24
|
+
disconnect(reason?: string): void;
|
|
25
|
+
get isConnected(): boolean;
|
|
26
|
+
/** @internal Server -> client delivery. */
|
|
27
|
+
deliver(event: string, data: unknown): void;
|
|
28
|
+
/** @internal Server closed the connection. */
|
|
29
|
+
closedByServer(): void;
|
|
30
|
+
}
|
|
31
|
+
export declare class MemoryTransport implements Transport {
|
|
32
|
+
readonly name = "memory";
|
|
33
|
+
readonly needsHttp = false;
|
|
34
|
+
private handlers;
|
|
35
|
+
private counter;
|
|
36
|
+
private readonly clients;
|
|
37
|
+
attach(context: TransportContext): void;
|
|
38
|
+
connect(): MemoryClient;
|
|
39
|
+
close(): Promise<void>;
|
|
40
|
+
}
|
|
41
|
+
export {};
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.MemoryTransport = exports.MemoryClient = void 0;
|
|
4
|
+
/**
|
|
5
|
+
* In-process client: for bots and tests. Speaks the same events as a network
|
|
6
|
+
* client. Server-to-client messages are delivered asynchronously (microtask).
|
|
7
|
+
*/
|
|
8
|
+
class MemoryClient {
|
|
9
|
+
id;
|
|
10
|
+
handlers;
|
|
11
|
+
socket;
|
|
12
|
+
received = [];
|
|
13
|
+
listeners = new Map();
|
|
14
|
+
connected = true;
|
|
15
|
+
constructor(id, handlers, socket) {
|
|
16
|
+
this.id = id;
|
|
17
|
+
this.handlers = handlers;
|
|
18
|
+
this.socket = socket;
|
|
19
|
+
}
|
|
20
|
+
/** Client -> server. */
|
|
21
|
+
send(event, data) {
|
|
22
|
+
if (!this.connected)
|
|
23
|
+
throw new Error(`MemoryClient ${this.id} is disconnected`);
|
|
24
|
+
this.handlers.onMessage(this.socket, event, data);
|
|
25
|
+
}
|
|
26
|
+
on(event, listener) {
|
|
27
|
+
let set = this.listeners.get(event);
|
|
28
|
+
if (!set) {
|
|
29
|
+
set = new Set();
|
|
30
|
+
this.listeners.set(event, set);
|
|
31
|
+
}
|
|
32
|
+
set.add(listener);
|
|
33
|
+
return () => set.delete(listener);
|
|
34
|
+
}
|
|
35
|
+
/** Resolves with the next payload of `event`, or rejects after `timeoutMs`. */
|
|
36
|
+
waitFor(event, timeoutMs = 1000) {
|
|
37
|
+
return new Promise((resolve, reject) => {
|
|
38
|
+
const timer = setTimeout(() => {
|
|
39
|
+
off();
|
|
40
|
+
reject(new Error(`MemoryClient ${this.id}: timed out waiting for '${event}'`));
|
|
41
|
+
}, timeoutMs);
|
|
42
|
+
const off = this.on(event, (data) => {
|
|
43
|
+
clearTimeout(timer);
|
|
44
|
+
off();
|
|
45
|
+
resolve(data);
|
|
46
|
+
});
|
|
47
|
+
});
|
|
48
|
+
}
|
|
49
|
+
disconnect(reason = 'client disconnect') {
|
|
50
|
+
if (!this.connected)
|
|
51
|
+
return;
|
|
52
|
+
this.connected = false;
|
|
53
|
+
this.handlers.onDisconnect(this.socket, reason);
|
|
54
|
+
}
|
|
55
|
+
get isConnected() {
|
|
56
|
+
return this.connected;
|
|
57
|
+
}
|
|
58
|
+
/** @internal Server -> client delivery. */
|
|
59
|
+
deliver(event, data) {
|
|
60
|
+
if (!this.connected)
|
|
61
|
+
return;
|
|
62
|
+
queueMicrotask(() => {
|
|
63
|
+
if (!this.connected)
|
|
64
|
+
return;
|
|
65
|
+
this.received.push({ event, data });
|
|
66
|
+
for (const listener of [...(this.listeners.get(event) ?? [])])
|
|
67
|
+
listener(data);
|
|
68
|
+
});
|
|
69
|
+
}
|
|
70
|
+
/** @internal Server closed the connection. */
|
|
71
|
+
closedByServer() {
|
|
72
|
+
this.disconnect('server disconnect');
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
exports.MemoryClient = MemoryClient;
|
|
76
|
+
class MemoryTransport {
|
|
77
|
+
name = 'memory';
|
|
78
|
+
needsHttp = false;
|
|
79
|
+
handlers = null;
|
|
80
|
+
counter = 0;
|
|
81
|
+
clients = new Set();
|
|
82
|
+
attach(context) {
|
|
83
|
+
this.handlers = context.handlers;
|
|
84
|
+
}
|
|
85
|
+
connect() {
|
|
86
|
+
const handlers = this.handlers;
|
|
87
|
+
if (!handlers)
|
|
88
|
+
throw new Error('MemoryTransport is not attached to a server yet');
|
|
89
|
+
const id = `mem-${++this.counter}`;
|
|
90
|
+
let client;
|
|
91
|
+
const socket = {
|
|
92
|
+
id,
|
|
93
|
+
transport: this.name,
|
|
94
|
+
send: (event, data) => client?.deliver(event, data),
|
|
95
|
+
close: () => client?.closedByServer(),
|
|
96
|
+
};
|
|
97
|
+
client = new MemoryClient(id, handlers, socket);
|
|
98
|
+
this.clients.add(client);
|
|
99
|
+
handlers.onConnect(socket);
|
|
100
|
+
return client;
|
|
101
|
+
}
|
|
102
|
+
async close() {
|
|
103
|
+
for (const client of this.clients)
|
|
104
|
+
client.closedByServer();
|
|
105
|
+
this.clients.clear();
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
exports.MemoryTransport = MemoryTransport;
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import { Server, type ServerOptions } from 'socket.io';
|
|
2
|
+
import type { Transport, TransportContext } from './types';
|
|
3
|
+
/**
|
|
4
|
+
* Socket.IO transport. Events map 1:1 to Socket.IO events.
|
|
5
|
+
* Import from '@sametnurel/game-server/socket-io' (needs the `socket.io` package).
|
|
6
|
+
*/
|
|
7
|
+
export declare class SocketIOTransport implements Transport {
|
|
8
|
+
private readonly options;
|
|
9
|
+
readonly name = "socket.io";
|
|
10
|
+
readonly needsHttp = true;
|
|
11
|
+
private io;
|
|
12
|
+
constructor(options?: Partial<ServerOptions>);
|
|
13
|
+
attach({ httpServer, handlers, cors }: TransportContext): void;
|
|
14
|
+
/** The underlying Socket.IO server, for advanced use. */
|
|
15
|
+
get server(): Server | null;
|
|
16
|
+
close(): Promise<void>;
|
|
17
|
+
}
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.SocketIOTransport = void 0;
|
|
4
|
+
const socket_io_1 = require("socket.io");
|
|
5
|
+
/**
|
|
6
|
+
* Socket.IO transport. Events map 1:1 to Socket.IO events.
|
|
7
|
+
* Import from '@sametnurel/game-server/socket-io' (needs the `socket.io` package).
|
|
8
|
+
*/
|
|
9
|
+
class SocketIOTransport {
|
|
10
|
+
options;
|
|
11
|
+
name = 'socket.io';
|
|
12
|
+
needsHttp = true;
|
|
13
|
+
io = null;
|
|
14
|
+
constructor(options = {}) {
|
|
15
|
+
this.options = options;
|
|
16
|
+
}
|
|
17
|
+
attach({ httpServer, handlers, cors }) {
|
|
18
|
+
if (!httpServer)
|
|
19
|
+
throw new Error('SocketIOTransport needs an HTTP server');
|
|
20
|
+
this.io = new socket_io_1.Server(httpServer, { cors, ...this.options });
|
|
21
|
+
this.io.on('connection', (raw) => {
|
|
22
|
+
const socket = {
|
|
23
|
+
id: raw.id,
|
|
24
|
+
transport: this.name,
|
|
25
|
+
send: (event, data) => {
|
|
26
|
+
raw.emit(event, data);
|
|
27
|
+
},
|
|
28
|
+
close: () => {
|
|
29
|
+
raw.disconnect(true);
|
|
30
|
+
},
|
|
31
|
+
};
|
|
32
|
+
handlers.onConnect(socket);
|
|
33
|
+
raw.onAny((event, data) => handlers.onMessage(socket, event, data));
|
|
34
|
+
raw.on('disconnect', (reason) => handlers.onDisconnect(socket, reason));
|
|
35
|
+
});
|
|
36
|
+
}
|
|
37
|
+
/** The underlying Socket.IO server, for advanced use. */
|
|
38
|
+
get server() {
|
|
39
|
+
return this.io;
|
|
40
|
+
}
|
|
41
|
+
async close() {
|
|
42
|
+
const io = this.io;
|
|
43
|
+
this.io = null;
|
|
44
|
+
if (!io)
|
|
45
|
+
return;
|
|
46
|
+
io.disconnectSockets(true);
|
|
47
|
+
// Do not let Socket.IO close the shared HTTP server; GameServer owns it.
|
|
48
|
+
io.engine.close();
|
|
49
|
+
io.removeAllListeners();
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
exports.SocketIOTransport = SocketIOTransport;
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import type { Server as HttpServer } from 'node:http';
|
|
2
|
+
/** One client connection, independent of the wire protocol. */
|
|
3
|
+
export interface ClientSocket {
|
|
4
|
+
readonly id: string;
|
|
5
|
+
/** Name of the transport that created it, e.g. 'socket.io', 'ws', 'memory'. */
|
|
6
|
+
readonly transport: string;
|
|
7
|
+
send(event: string, data?: unknown): void;
|
|
8
|
+
close(): void;
|
|
9
|
+
}
|
|
10
|
+
/** Callbacks a transport reports to. Implemented by GameServer. */
|
|
11
|
+
export interface TransportHandlers {
|
|
12
|
+
onConnect(socket: ClientSocket): void;
|
|
13
|
+
onMessage(socket: ClientSocket, event: string, data: unknown): void;
|
|
14
|
+
onDisconnect(socket: ClientSocket, reason: string): void;
|
|
15
|
+
}
|
|
16
|
+
export interface CorsOptions {
|
|
17
|
+
origin: string | string[] | boolean;
|
|
18
|
+
}
|
|
19
|
+
export interface TransportContext {
|
|
20
|
+
/** Null for transports that do not use HTTP (memory). */
|
|
21
|
+
httpServer: HttpServer | null;
|
|
22
|
+
handlers: TransportHandlers;
|
|
23
|
+
cors?: CorsOptions;
|
|
24
|
+
}
|
|
25
|
+
export interface Transport {
|
|
26
|
+
readonly name: string;
|
|
27
|
+
/** True when the transport needs an HTTP server. */
|
|
28
|
+
readonly needsHttp: boolean;
|
|
29
|
+
attach(context: TransportContext): void;
|
|
30
|
+
close(): Promise<void>;
|
|
31
|
+
}
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import type { Transport, TransportContext } from './types';
|
|
2
|
+
export interface WebSocketTransportOptions {
|
|
3
|
+
/** URL path clients connect to. Default '/ws'. */
|
|
4
|
+
path?: string;
|
|
5
|
+
/** Max incoming frame size in bytes. Default 64 KiB. */
|
|
6
|
+
maxPayload?: number;
|
|
7
|
+
/** Ping interval to drop dead connections, in ms. 0 disables. Default 30000. */
|
|
8
|
+
heartbeatMs?: number;
|
|
9
|
+
}
|
|
10
|
+
/**
|
|
11
|
+
* Plain WebSocket transport (the `ws` package). Every frame is JSON:
|
|
12
|
+
* { "event": string, "data"?: any }
|
|
13
|
+
* in both directions. Works with any WebSocket client (browser, Godot, mobile).
|
|
14
|
+
* Import from '@sametnurel/game-server/ws'.
|
|
15
|
+
*/
|
|
16
|
+
export declare class WebSocketTransport implements Transport {
|
|
17
|
+
readonly name = "ws";
|
|
18
|
+
readonly needsHttp = true;
|
|
19
|
+
private wss;
|
|
20
|
+
private heartbeat;
|
|
21
|
+
private detachUpgrade;
|
|
22
|
+
private readonly path;
|
|
23
|
+
private readonly maxPayload;
|
|
24
|
+
private readonly heartbeatMs;
|
|
25
|
+
constructor(options?: WebSocketTransportOptions);
|
|
26
|
+
attach({ httpServer, handlers, cors }: TransportContext): void;
|
|
27
|
+
close(): Promise<void>;
|
|
28
|
+
}
|
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.WebSocketTransport = void 0;
|
|
4
|
+
const node_crypto_1 = require("node:crypto");
|
|
5
|
+
const ws_1 = require("ws");
|
|
6
|
+
const events_1 = require("../events");
|
|
7
|
+
/**
|
|
8
|
+
* Plain WebSocket transport (the `ws` package). Every frame is JSON:
|
|
9
|
+
* { "event": string, "data"?: any }
|
|
10
|
+
* in both directions. Works with any WebSocket client (browser, Godot, mobile).
|
|
11
|
+
* Import from '@sametnurel/game-server/ws'.
|
|
12
|
+
*/
|
|
13
|
+
class WebSocketTransport {
|
|
14
|
+
name = 'ws';
|
|
15
|
+
needsHttp = true;
|
|
16
|
+
wss = null;
|
|
17
|
+
heartbeat = null;
|
|
18
|
+
detachUpgrade = null;
|
|
19
|
+
path;
|
|
20
|
+
maxPayload;
|
|
21
|
+
heartbeatMs;
|
|
22
|
+
constructor(options = {}) {
|
|
23
|
+
this.path = options.path ?? '/ws';
|
|
24
|
+
this.maxPayload = options.maxPayload ?? 64 * 1024;
|
|
25
|
+
this.heartbeatMs = options.heartbeatMs ?? 30_000;
|
|
26
|
+
}
|
|
27
|
+
attach({ httpServer, handlers, cors }) {
|
|
28
|
+
if (!httpServer)
|
|
29
|
+
throw new Error('WebSocketTransport needs an HTTP server');
|
|
30
|
+
const wss = new ws_1.WebSocketServer({ noServer: true, maxPayload: this.maxPayload });
|
|
31
|
+
this.wss = wss;
|
|
32
|
+
const alive = new WeakMap();
|
|
33
|
+
// Only take upgrades for our path; leave the rest (e.g. Socket.IO) alone.
|
|
34
|
+
const onUpgrade = (req, stream, head) => {
|
|
35
|
+
const pathname = new URL(req.url ?? '/', 'http://localhost').pathname;
|
|
36
|
+
if (pathname !== this.path)
|
|
37
|
+
return;
|
|
38
|
+
if (!originAllowed(cors, req.headers.origin)) {
|
|
39
|
+
stream.end('HTTP/1.1 403 Forbidden\r\n\r\n');
|
|
40
|
+
return;
|
|
41
|
+
}
|
|
42
|
+
wss.handleUpgrade(req, stream, head, (ws) => wss.emit('connection', ws, req));
|
|
43
|
+
};
|
|
44
|
+
httpServer.on('upgrade', onUpgrade);
|
|
45
|
+
this.detachUpgrade = () => httpServer.off('upgrade', onUpgrade);
|
|
46
|
+
wss.on('connection', (ws) => {
|
|
47
|
+
const socket = {
|
|
48
|
+
id: (0, node_crypto_1.randomUUID)(),
|
|
49
|
+
transport: this.name,
|
|
50
|
+
send: (event, data) => {
|
|
51
|
+
if (ws.readyState === ws_1.WebSocket.OPEN)
|
|
52
|
+
ws.send(JSON.stringify({ event, data }));
|
|
53
|
+
},
|
|
54
|
+
close: () => ws.close(1000, 'server close'),
|
|
55
|
+
};
|
|
56
|
+
alive.set(ws, true);
|
|
57
|
+
ws.on('pong', () => alive.set(ws, true));
|
|
58
|
+
handlers.onConnect(socket);
|
|
59
|
+
ws.on('message', (raw, isBinary) => {
|
|
60
|
+
const frame = isBinary ? undefined : parseFrame(raw.toString());
|
|
61
|
+
if (!frame) {
|
|
62
|
+
socket.send(events_1.SystemEvents.Error, { code: events_1.ErrorCodes.BadFrame, message: 'Expected JSON {"event","data"}' });
|
|
63
|
+
return;
|
|
64
|
+
}
|
|
65
|
+
handlers.onMessage(socket, frame.event, frame.data);
|
|
66
|
+
});
|
|
67
|
+
ws.on('close', (code) => handlers.onDisconnect(socket, `close ${code}`));
|
|
68
|
+
ws.on('error', () => ws.terminate());
|
|
69
|
+
});
|
|
70
|
+
if (this.heartbeatMs > 0) {
|
|
71
|
+
this.heartbeat = setInterval(() => {
|
|
72
|
+
for (const ws of wss.clients) {
|
|
73
|
+
if (alive.get(ws) === false) {
|
|
74
|
+
ws.terminate();
|
|
75
|
+
continue;
|
|
76
|
+
}
|
|
77
|
+
alive.set(ws, false);
|
|
78
|
+
ws.ping();
|
|
79
|
+
}
|
|
80
|
+
}, this.heartbeatMs);
|
|
81
|
+
this.heartbeat.unref();
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
async close() {
|
|
85
|
+
if (this.heartbeat)
|
|
86
|
+
clearInterval(this.heartbeat);
|
|
87
|
+
this.heartbeat = null;
|
|
88
|
+
this.detachUpgrade?.();
|
|
89
|
+
this.detachUpgrade = null;
|
|
90
|
+
const wss = this.wss;
|
|
91
|
+
this.wss = null;
|
|
92
|
+
if (!wss)
|
|
93
|
+
return;
|
|
94
|
+
for (const ws of wss.clients)
|
|
95
|
+
ws.terminate();
|
|
96
|
+
await new Promise((resolve) => wss.close(() => resolve()));
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
exports.WebSocketTransport = WebSocketTransport;
|
|
100
|
+
function parseFrame(text) {
|
|
101
|
+
try {
|
|
102
|
+
const value = JSON.parse(text);
|
|
103
|
+
if (typeof value !== 'object' || value === null)
|
|
104
|
+
return undefined;
|
|
105
|
+
const { event, data } = value;
|
|
106
|
+
return typeof event === 'string' && event.length > 0 ? { event, data } : undefined;
|
|
107
|
+
}
|
|
108
|
+
catch {
|
|
109
|
+
return undefined;
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
function originAllowed(cors, origin) {
|
|
113
|
+
if (!cors || cors.origin === true || cors.origin === '*')
|
|
114
|
+
return true;
|
|
115
|
+
if (cors.origin === false)
|
|
116
|
+
return origin === undefined;
|
|
117
|
+
if (origin === undefined)
|
|
118
|
+
return true; // non-browser clients (Godot, native) send no Origin
|
|
119
|
+
return Array.isArray(cors.origin) ? cors.origin.includes(origin) : cors.origin === origin;
|
|
120
|
+
}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* One countdown, e.g. a player's turn. Send `deadline` to clients so they can
|
|
3
|
+
* show the remaining time without trusting their own clock for the rule.
|
|
4
|
+
*/
|
|
5
|
+
export declare class TurnTimer {
|
|
6
|
+
private timer;
|
|
7
|
+
private deadlineAt;
|
|
8
|
+
/** Starts (or restarts) the countdown. */
|
|
9
|
+
start(durationMs: number, onExpire: () => void, now?: number): void;
|
|
10
|
+
stop(): void;
|
|
11
|
+
get running(): boolean;
|
|
12
|
+
/** Epoch ms when the countdown ends, or null when stopped. */
|
|
13
|
+
get deadline(): number | null;
|
|
14
|
+
remainingMs(now?: number): number;
|
|
15
|
+
}
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.TurnTimer = void 0;
|
|
4
|
+
/**
|
|
5
|
+
* One countdown, e.g. a player's turn. Send `deadline` to clients so they can
|
|
6
|
+
* show the remaining time without trusting their own clock for the rule.
|
|
7
|
+
*/
|
|
8
|
+
class TurnTimer {
|
|
9
|
+
timer = null;
|
|
10
|
+
deadlineAt = null;
|
|
11
|
+
/** Starts (or restarts) the countdown. */
|
|
12
|
+
start(durationMs, onExpire, now = Date.now()) {
|
|
13
|
+
this.stop();
|
|
14
|
+
this.deadlineAt = now + durationMs;
|
|
15
|
+
this.timer = setTimeout(() => {
|
|
16
|
+
this.timer = null;
|
|
17
|
+
this.deadlineAt = null;
|
|
18
|
+
onExpire();
|
|
19
|
+
}, durationMs);
|
|
20
|
+
this.timer.unref?.();
|
|
21
|
+
}
|
|
22
|
+
stop() {
|
|
23
|
+
if (this.timer)
|
|
24
|
+
clearTimeout(this.timer);
|
|
25
|
+
this.timer = null;
|
|
26
|
+
this.deadlineAt = null;
|
|
27
|
+
}
|
|
28
|
+
get running() {
|
|
29
|
+
return this.timer !== null;
|
|
30
|
+
}
|
|
31
|
+
/** Epoch ms when the countdown ends, or null when stopped. */
|
|
32
|
+
get deadline() {
|
|
33
|
+
return this.deadlineAt;
|
|
34
|
+
}
|
|
35
|
+
remainingMs(now = Date.now()) {
|
|
36
|
+
return this.deadlineAt === null ? 0 : Math.max(0, this.deadlineAt - now);
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
exports.TurnTimer = TurnTimer;
|
package/package.json
CHANGED
|
@@ -1,6 +1,76 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@sametnurel/game-server",
|
|
3
|
-
"version": "
|
|
4
|
-
"
|
|
5
|
-
"
|
|
6
|
-
|
|
3
|
+
"version": "1.0.0",
|
|
4
|
+
"description": "Lightweight, transport-agnostic multiplayer game server: commands, rooms, sessions with reconnect, matchmaking and turn timers over Socket.IO or plain WebSocket.",
|
|
5
|
+
"keywords": [
|
|
6
|
+
"game",
|
|
7
|
+
"server",
|
|
8
|
+
"multiplayer",
|
|
9
|
+
"socket.io",
|
|
10
|
+
"websocket",
|
|
11
|
+
"matchmaking",
|
|
12
|
+
"nodejs"
|
|
13
|
+
],
|
|
14
|
+
"homepage": "https://github.com/snurel/game-server#readme",
|
|
15
|
+
"bugs": {
|
|
16
|
+
"url": "https://github.com/snurel/game-server/issues"
|
|
17
|
+
},
|
|
18
|
+
"repository": {
|
|
19
|
+
"type": "git",
|
|
20
|
+
"url": "git+https://github.com/snurel/game-server.git"
|
|
21
|
+
},
|
|
22
|
+
"license": "ISC",
|
|
23
|
+
"author": "Samet Nurel",
|
|
24
|
+
"main": "dist/index.js",
|
|
25
|
+
"types": "dist/index.d.ts",
|
|
26
|
+
"exports": {
|
|
27
|
+
".": {
|
|
28
|
+
"types": "./dist/index.d.ts",
|
|
29
|
+
"default": "./dist/index.js"
|
|
30
|
+
},
|
|
31
|
+
"./socket-io": {
|
|
32
|
+
"types": "./dist/transport/socket-io.d.ts",
|
|
33
|
+
"default": "./dist/transport/socket-io.js"
|
|
34
|
+
},
|
|
35
|
+
"./ws": {
|
|
36
|
+
"types": "./dist/transport/ws.d.ts",
|
|
37
|
+
"default": "./dist/transport/ws.js"
|
|
38
|
+
},
|
|
39
|
+
"./package.json": "./package.json"
|
|
40
|
+
},
|
|
41
|
+
"files": [
|
|
42
|
+
"dist",
|
|
43
|
+
"README.md",
|
|
44
|
+
"CHANGELOG.md"
|
|
45
|
+
],
|
|
46
|
+
"engines": {
|
|
47
|
+
"node": ">=18"
|
|
48
|
+
},
|
|
49
|
+
"scripts": {
|
|
50
|
+
"build": "rm -rf dist && tsc -p tsconfig.build.json",
|
|
51
|
+
"typecheck": "tsc --noEmit",
|
|
52
|
+
"test": "vitest run",
|
|
53
|
+
"prepublishOnly": "npm run typecheck && npm test && npm run build"
|
|
54
|
+
},
|
|
55
|
+
"peerDependencies": {
|
|
56
|
+
"socket.io": "^4.8.0",
|
|
57
|
+
"ws": "^8.18.0"
|
|
58
|
+
},
|
|
59
|
+
"peerDependenciesMeta": {
|
|
60
|
+
"socket.io": {
|
|
61
|
+
"optional": true
|
|
62
|
+
},
|
|
63
|
+
"ws": {
|
|
64
|
+
"optional": true
|
|
65
|
+
}
|
|
66
|
+
},
|
|
67
|
+
"devDependencies": {
|
|
68
|
+
"@types/node": "^24.19.2",
|
|
69
|
+
"@types/ws": "^8.18.2",
|
|
70
|
+
"socket.io": "^4.8.4",
|
|
71
|
+
"socket.io-client": "^4.8.4",
|
|
72
|
+
"typescript": "^7.0.2",
|
|
73
|
+
"vitest": "^5.1.0",
|
|
74
|
+
"ws": "^8.22.1"
|
|
75
|
+
}
|
|
76
|
+
}
|