@babylonjs-toolkit/server 0.0.0-stage → 9.35.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/CUSTOM.md +1690 -0
- package/DEPLOY.md +2012 -0
- package/README.md +1081 -2
- package/lib/AbuseLimits.d.ts +64 -0
- package/lib/AbuseLimits.js +121 -0
- package/lib/Authentication.d.ts +86 -0
- package/lib/Authentication.js +272 -0
- package/lib/BootSessionOwner.d.ts +86 -0
- package/lib/BootSessionOwner.js +116 -0
- package/lib/ColyseusLoggerAdapter.d.ts +32 -0
- package/lib/ColyseusLoggerAdapter.js +73 -0
- package/lib/DedicatedSceneRegistry.d.ts +52 -0
- package/lib/DedicatedSceneRegistry.js +74 -0
- package/lib/DedicatedSession.d.ts +152 -0
- package/lib/DedicatedSession.js +173 -0
- package/lib/DualTransport.d.ts +112 -0
- package/lib/DualTransport.js +197 -0
- package/lib/GameServer.d.ts +64 -0
- package/lib/GameServer.js +647 -0
- package/lib/GameServerConfig.d.ts +387 -0
- package/lib/GameServerConfig.js +1059 -0
- package/lib/GameServerLobbyRoom.d.ts +33 -0
- package/lib/GameServerLobbyRoom.js +56 -0
- package/lib/GameServerLogger.d.ts +111 -0
- package/lib/GameServerLogger.js +190 -0
- package/lib/GameServerMetrics.d.ts +158 -0
- package/lib/GameServerMetrics.js +276 -0
- package/lib/GameServerState.d.ts +164 -0
- package/lib/GameServerState.js +26 -0
- package/lib/HeadlessRuntime.d.ts +316 -0
- package/lib/HeadlessRuntime.js +380 -0
- package/lib/JoinGate.d.ts +34 -0
- package/lib/JoinGate.js +221 -0
- package/lib/NetworkAddresses.d.ts +33 -0
- package/lib/NetworkAddresses.js +90 -0
- package/lib/PlayerTokens.d.ts +17 -0
- package/lib/PlayerTokens.js +24 -0
- package/lib/RoomServerTransport.d.ts +73 -0
- package/lib/RoomServerTransport.js +90 -0
- package/lib/ServerEndpoints.d.ts +133 -0
- package/lib/ServerEndpoints.js +247 -0
- package/lib/ServerInit.d.ts +28 -0
- package/lib/ServerInit.js +113 -0
- package/lib/SessionRoom.d.ts +391 -0
- package/lib/SessionRoom.js +930 -0
- package/lib/TlsCertificates.d.ts +35 -0
- package/lib/TlsCertificates.js +130 -0
- package/lib/admin/AccountRoutes.d.ts +8 -0
- package/lib/admin/AccountRoutes.js +502 -0
- package/lib/admin/AdminAccounts.d.ts +329 -0
- package/lib/admin/AdminAccounts.js +607 -0
- package/lib/admin/AdminApiRouter.d.ts +202 -0
- package/lib/admin/AdminApiRouter.js +465 -0
- package/lib/admin/AdminApiTypes.d.ts +1782 -0
- package/lib/admin/AdminApiTypes.js +4 -0
- package/lib/admin/AdminAudit.d.ts +113 -0
- package/lib/admin/AdminAudit.js +266 -0
- package/lib/admin/AdminCli.d.ts +7 -0
- package/lib/admin/AdminCli.js +351 -0
- package/lib/admin/AdminClock.d.ts +51 -0
- package/lib/admin/AdminClock.js +10 -0
- package/lib/admin/AdminConsole.d.ts +39 -0
- package/lib/admin/AdminConsole.js +325 -0
- package/lib/admin/AdminConstants.d.ts +147 -0
- package/lib/admin/AdminConstants.js +167 -0
- package/lib/admin/AdminCrypto.d.ts +19 -0
- package/lib/admin/AdminCrypto.js +29 -0
- package/lib/admin/AdminExtensionsHost.d.ts +176 -0
- package/lib/admin/AdminExtensionsHost.js +307 -0
- package/lib/admin/AdminPasswords.d.ts +33 -0
- package/lib/admin/AdminPasswords.js +92 -0
- package/lib/admin/AdminPermissions.d.ts +28 -0
- package/lib/admin/AdminPermissions.js +74 -0
- package/lib/admin/AdminRecords.d.ts +196 -0
- package/lib/admin/AdminRecords.js +3 -0
- package/lib/admin/AdminRequestBodies.d.ts +47 -0
- package/lib/admin/AdminRequestBodies.js +84 -0
- package/lib/admin/AdminRequestGate.d.ts +38 -0
- package/lib/admin/AdminRequestGate.js +59 -0
- package/lib/admin/AdminResponses.d.ts +103 -0
- package/lib/admin/AdminResponses.js +164 -0
- package/lib/admin/AdminServices.d.ts +99 -0
- package/lib/admin/AdminServices.js +4 -0
- package/lib/admin/AdminStaticFiles.d.ts +29 -0
- package/lib/admin/AdminStaticFiles.js +97 -0
- package/lib/admin/AdminStore.d.ts +152 -0
- package/lib/admin/AdminStore.js +46 -0
- package/lib/admin/AlertChannels.d.ts +43 -0
- package/lib/admin/AlertChannels.js +120 -0
- package/lib/admin/AlertEngine.d.ts +231 -0
- package/lib/admin/AlertEngine.js +681 -0
- package/lib/admin/AlertRoutes.d.ts +8 -0
- package/lib/admin/AlertRoutes.js +180 -0
- package/lib/admin/ApiKeyRoutes.d.ts +8 -0
- package/lib/admin/ApiKeyRoutes.js +53 -0
- package/lib/admin/ApiKeys.d.ts +63 -0
- package/lib/admin/ApiKeys.js +167 -0
- package/lib/admin/AuditRoutes.d.ts +8 -0
- package/lib/admin/AuditRoutes.js +106 -0
- package/lib/admin/AuthToolRoutes.d.ts +13 -0
- package/lib/admin/AuthToolRoutes.js +164 -0
- package/lib/admin/BanList.d.ts +87 -0
- package/lib/admin/BanList.js +152 -0
- package/lib/admin/CborDecoder.d.ts +18 -0
- package/lib/admin/CborDecoder.js +201 -0
- package/lib/admin/ColyseusCloudClient.d.ts +313 -0
- package/lib/admin/ColyseusCloudClient.js +272 -0
- package/lib/admin/ColyseusCloudRoutes.d.ts +8 -0
- package/lib/admin/ColyseusCloudRoutes.js +91 -0
- package/lib/admin/ColyseusCloudService.d.ts +180 -0
- package/lib/admin/ColyseusCloudService.js +476 -0
- package/lib/admin/ColyseusCloudSettings.d.ts +16 -0
- package/lib/admin/ColyseusCloudSettings.js +29 -0
- package/lib/admin/ColyseusCloudViews.d.ts +105 -0
- package/lib/admin/ColyseusCloudViews.js +371 -0
- package/lib/admin/CommonPasswords.d.ts +6 -0
- package/lib/admin/CommonPasswords.js +19 -0
- package/lib/admin/ConfigOverrides.d.ts +174 -0
- package/lib/admin/ConfigOverrides.js +406 -0
- package/lib/admin/ConfigRoutes.d.ts +8 -0
- package/lib/admin/ConfigRoutes.js +91 -0
- package/lib/admin/ConsoleSessions.d.ts +112 -0
- package/lib/admin/ConsoleSessions.js +201 -0
- package/lib/admin/DedicatedRoutes.d.ts +21 -0
- package/lib/admin/DedicatedRoutes.js +254 -0
- package/lib/admin/ExtensionRoutes.d.ts +8 -0
- package/lib/admin/ExtensionRoutes.js +145 -0
- package/lib/admin/FileAdminStore.d.ts +157 -0
- package/lib/admin/FileAdminStore.js +349 -0
- package/lib/admin/FleetDirectory.d.ts +323 -0
- package/lib/admin/FleetDirectory.js +489 -0
- package/lib/admin/FleetLeader.d.ts +43 -0
- package/lib/admin/FleetLeader.js +89 -0
- package/lib/admin/FleetRoutes.d.ts +55 -0
- package/lib/admin/FleetRoutes.js +465 -0
- package/lib/admin/HealthWarnings.d.ts +10 -0
- package/lib/admin/HealthWarnings.js +95 -0
- package/lib/admin/LeaderboardRoutes.d.ts +8 -0
- package/lib/admin/LeaderboardRoutes.js +569 -0
- package/lib/admin/LogBuffer.d.ts +60 -0
- package/lib/admin/LogBuffer.js +92 -0
- package/lib/admin/LogRedaction.d.ts +15 -0
- package/lib/admin/LogRedaction.js +72 -0
- package/lib/admin/LogRoutes.d.ts +45 -0
- package/lib/admin/LogRoutes.js +307 -0
- package/lib/admin/MaintenanceMode.d.ts +41 -0
- package/lib/admin/MaintenanceMode.js +74 -0
- package/lib/admin/MaintenanceWindows.d.ts +91 -0
- package/lib/admin/MaintenanceWindows.js +207 -0
- package/lib/admin/MetricsRoutes.d.ts +8 -0
- package/lib/admin/MetricsRoutes.js +209 -0
- package/lib/admin/MetricsSampler.d.ts +124 -0
- package/lib/admin/MetricsSampler.js +211 -0
- package/lib/admin/OneTimeCodes.d.ts +60 -0
- package/lib/admin/OneTimeCodes.js +162 -0
- package/lib/admin/OpenIdConnect.d.ts +146 -0
- package/lib/admin/OpenIdConnect.js +335 -0
- package/lib/admin/Passkeys.d.ts +130 -0
- package/lib/admin/Passkeys.js +432 -0
- package/lib/admin/PlayerRoutes.d.ts +20 -0
- package/lib/admin/PlayerRoutes.js +676 -0
- package/lib/admin/RedisAdminStore.d.ts +114 -0
- package/lib/admin/RedisAdminStore.js +258 -0
- package/lib/admin/RolloutCoordinator.d.ts +99 -0
- package/lib/admin/RolloutCoordinator.js +321 -0
- package/lib/admin/ServerEvents.d.ts +57 -0
- package/lib/admin/ServerEvents.js +89 -0
- package/lib/admin/SessionRoutes.d.ts +113 -0
- package/lib/admin/SessionRoutes.js +386 -0
- package/lib/admin/UserRoutes.d.ts +8 -0
- package/lib/admin/UserRoutes.js +214 -0
- package/lib/cli.d.ts +2 -0
- package/lib/cli.js +221 -0
- package/lib/console/assets/index-CI5N7c-T.js +21 -0
- package/lib/console/assets/index-DPitvzLp.css +1 -0
- package/lib/console/favicon.svg +1 -0
- package/lib/console/index.html +16 -0
- package/lib/discord/DiscordOAuth.d.ts +57 -0
- package/lib/discord/DiscordOAuth.js +127 -0
- package/lib/discord/DiscordSessions.d.ts +79 -0
- package/lib/discord/DiscordSessions.js +135 -0
- package/lib/discord/DiscordSignInConstants.d.ts +54 -0
- package/lib/discord/DiscordSignInConstants.js +55 -0
- package/lib/discord/DiscordSignInRoutes.d.ts +51 -0
- package/lib/discord/DiscordSignInRoutes.js +359 -0
- package/lib/index.d.ts +24 -0
- package/lib/index.js +18 -0
- package/lib/leaderboards/FileLeaderboardRankStore.d.ts +293 -0
- package/lib/leaderboards/FileLeaderboardRankStore.js +808 -0
- package/lib/leaderboards/LeaderboardConstants.d.ts +63 -0
- package/lib/leaderboards/LeaderboardConstants.js +70 -0
- package/lib/leaderboards/LeaderboardError.d.ts +15 -0
- package/lib/leaderboards/LeaderboardError.js +20 -0
- package/lib/leaderboards/LeaderboardIdentity.d.ts +44 -0
- package/lib/leaderboards/LeaderboardIdentity.js +72 -0
- package/lib/leaderboards/LeaderboardPublicApi.d.ts +17 -0
- package/lib/leaderboards/LeaderboardPublicApi.js +389 -0
- package/lib/leaderboards/LeaderboardRankStore.d.ts +296 -0
- package/lib/leaderboards/LeaderboardRankStore.js +18 -0
- package/lib/leaderboards/LeaderboardRules.d.ts +122 -0
- package/lib/leaderboards/LeaderboardRules.js +472 -0
- package/lib/leaderboards/LeaderboardScheduler.d.ts +43 -0
- package/lib/leaderboards/LeaderboardScheduler.js +103 -0
- package/lib/leaderboards/LeaderboardService.d.ts +669 -0
- package/lib/leaderboards/LeaderboardService.js +1667 -0
- package/lib/leaderboards/LeaderboardTransfer.d.ts +35 -0
- package/lib/leaderboards/LeaderboardTransfer.js +580 -0
- package/lib/leaderboards/RedisLeaderboardRankStore.d.ts +282 -0
- package/lib/leaderboards/RedisLeaderboardRankStore.js +691 -0
- package/package.json +64 -4
- package/templates/init/Caddyfile +6 -0
- package/templates/init/DEPLOY.md +233 -0
- package/templates/init/Dockerfile +12 -0
- package/templates/init/app.json +21 -0
- package/templates/init/aws/README.md +118 -0
- package/templates/init/aws/ecs-fargate-alb.yml +207 -0
- package/templates/init/aws/ecs-fargate-nlb-quic.yml +210 -0
- package/templates/init/babylon-game-server.config.mjs +158 -0
- package/templates/init/colyseus-cloud/README.md +133 -0
- package/templates/init/colyseus-cloud/babylon-game-server.config.mjs +36 -0
- package/templates/init/colyseus-cloud/ecosystem.config.cjs +35 -0
- package/templates/init/colyseus-cloud/gitignore +6 -0
- package/templates/init/colyseus-cloud/package.json +15 -0
- package/templates/init/digitalocean-app.yaml +30 -0
- package/templates/init/docker-compose.full.yml +45 -0
- package/templates/init/docker-compose.websocket.yml +41 -0
- package/templates/init/fly.toml +56 -0
- package/templates/init/heroku.yml +11 -0
- package/templates/init/kubernetes/deployment.yaml +71 -0
- package/templates/init/kubernetes/redis.yaml +73 -0
- package/templates/init/kubernetes/service.yaml +24 -0
- package/templates/init/monitoring/grafana-dashboard.json +539 -0
- package/templates/init/monitoring/prometheus-alerts.yml +51 -0
- package/templates/init/monitoring/prometheus-scrape.yml +17 -0
- package/templates/init/railway.json +13 -0
- package/templates/init/render.yaml +19 -0
|
@@ -0,0 +1,647 @@
|
|
|
1
|
+
import { createRouter, defineRoom, defineServer, matchMaker, RedisDriver, RedisPresence } from "colyseus";
|
|
2
|
+
import { WebSocketTransport } from "@colyseus/ws-transport";
|
|
3
|
+
import { Redis } from "ioredis";
|
|
4
|
+
import { createServer as createHttpsServer } from "node:https";
|
|
5
|
+
import { createServer as createNetServer } from "node:net";
|
|
6
|
+
import path from "node:path";
|
|
7
|
+
import { installClientAddressHeaders, JoinRateLimiter } from "./AbuseLimits.js";
|
|
8
|
+
import { createTokenVerifier } from "./Authentication.js";
|
|
9
|
+
import { BOOT_SESSION_LEASE_NAME, BootSessionOwner } from "./BootSessionOwner.js";
|
|
10
|
+
import { DedicatedSceneRegistry } from "./DedicatedSceneRegistry.js";
|
|
11
|
+
import { DedicatedSession, SESSION_ROOM_NAME } from "./DedicatedSession.js";
|
|
12
|
+
import { colyseusLoggerAdapter } from "./ColyseusLoggerAdapter.js";
|
|
13
|
+
import { createDualTransport } from "./DualTransport.js";
|
|
14
|
+
import { CONFIG_OPTIONS, effectiveLogFormat, resolveGameServerConfig } from "./GameServerConfig.js";
|
|
15
|
+
import { GameServerLobbyRoom } from "./GameServerLobbyRoom.js";
|
|
16
|
+
import { GameServerMetrics } from "./GameServerMetrics.js";
|
|
17
|
+
import { GameServerLogger, getLogger, setLogger } from "./GameServerLogger.js";
|
|
18
|
+
import { findGameServerState, setGameServerState } from "./GameServerState.js";
|
|
19
|
+
import { SessionRoom } from "./SessionRoom.js";
|
|
20
|
+
import { allowScriptUrlPrefixes, loadToolkitRuntime, resetScriptUrlAllowList } from "./HeadlessRuntime.js";
|
|
21
|
+
import { createGameServerRoutes } from "./ServerEndpoints.js";
|
|
22
|
+
import { loadTlsMaterial, stopWatchingTlsFiles, watchTlsFiles } from "./TlsCertificates.js";
|
|
23
|
+
import { SYSTEM_CLOCK } from "./admin/AdminClock.js";
|
|
24
|
+
import { AdminExtensionsHost } from "./admin/AdminExtensionsHost.js";
|
|
25
|
+
import { createAdminConsole } from "./admin/AdminConsole.js";
|
|
26
|
+
import { FLEET_HEARTBEAT_INTERVAL_MS, LEADER_TICK_MS, METRICS_SAMPLE_INTERVAL_MS, PAGERDUTY_EVENTS_URL } from "./admin/AdminConstants.js";
|
|
27
|
+
import { openAdminStore } from "./admin/AdminStore.js";
|
|
28
|
+
import { KickBlockList } from "./admin/BanList.js";
|
|
29
|
+
import { LogBuffer } from "./admin/LogBuffer.js";
|
|
30
|
+
import { openLeaderboardRankStore } from "./leaderboards/LeaderboardRankStore.js";
|
|
31
|
+
import { LeaderboardService } from "./leaderboards/LeaderboardService.js";
|
|
32
|
+
import { createLeaderboardRoutes } from "./leaderboards/LeaderboardPublicApi.js";
|
|
33
|
+
import { LEADERBOARD_SCHEDULER_TICK_MS } from "./leaderboards/LeaderboardConstants.js";
|
|
34
|
+
import { DiscordSessions } from "./discord/DiscordSessions.js";
|
|
35
|
+
import { discordEndpoints } from "./discord/DiscordOAuth.js";
|
|
36
|
+
import { DISCORD_API_BASE_URL } from "./discord/DiscordSignInConstants.js";
|
|
37
|
+
import { createDiscordSignInRoutes } from "./discord/DiscordSignInRoutes.js";
|
|
38
|
+
/** Milliseconds between WebSocket keep-alive pings. */
|
|
39
|
+
const PING_INTERVAL_MS = 10000;
|
|
40
|
+
/** Bind addresses that mean "every interface"; URLs show them as localhost. */
|
|
41
|
+
const WILDCARD_HOSTS = ["0.0.0.0", "::"];
|
|
42
|
+
/** Host name the URLs show for a wildcard bind address. */
|
|
43
|
+
const LOCAL_HOST_NAME = "localhost";
|
|
44
|
+
/** CORS header naming the origin allowed to read a response. */
|
|
45
|
+
const ALLOW_ORIGIN_HEADER = "Access-Control-Allow-Origin";
|
|
46
|
+
/** Seconds the drain watchdog allows beyond drainSeconds before it exits with code 1 (D17). */
|
|
47
|
+
const DRAIN_EXIT_MARGIN_SECONDS = 5;
|
|
48
|
+
/** Milliseconds between the drain's checks for remaining session rooms. */
|
|
49
|
+
const DRAIN_POLL_INTERVAL_MS = 250;
|
|
50
|
+
/** Key the readiness probe asks Redis about; it never exists, the answer only proves Redis replies (D18). */
|
|
51
|
+
const READINESS_PROBE_KEY = "babylon-game-server:readiness-probe";
|
|
52
|
+
/** Milliseconds Redis has to answer the readiness probe. */
|
|
53
|
+
const REDIS_PROBE_TIMEOUT_MS = 1000;
|
|
54
|
+
/** Set by PM2 to each process's number. Hosts that do not number their processes run boot sessions directly. */
|
|
55
|
+
const PROCESS_NUMBER_VARIABLE = "NODE_APP_INSTANCE";
|
|
56
|
+
/** Milliseconds per second. */
|
|
57
|
+
const MILLISECONDS_PER_SECOND = 1000;
|
|
58
|
+
/** The name better-call gives the errors an endpoint throws on purpose (answered with their status, not failures). */
|
|
59
|
+
const ROUTER_API_ERROR_NAME = "APIError";
|
|
60
|
+
/** The signals the CLI drains on. */
|
|
61
|
+
const DRAIN_SIGNALS = ["SIGTERM", "SIGINT"];
|
|
62
|
+
/** Exit code after a drain that an uncaught error started, so the process manager records a crash. */
|
|
63
|
+
const CRASH_EXIT_CODE = 1;
|
|
64
|
+
/**
|
|
65
|
+
* Presence set of the processes that are draining. The matchmaker never creates a room on them, because Colyseus
|
|
66
|
+
* re-lists a process in its room counts on every room change until its final shutdown, and a drain lasts drainSeconds.
|
|
67
|
+
*/
|
|
68
|
+
const DRAINING_PROCESSES_KEY = "babylon-game-server:draining";
|
|
69
|
+
/** ISO time the running drain closes the remaining sessions, or null while no drain runs (one server per process). */
|
|
70
|
+
let drainDeadline = null;
|
|
71
|
+
/**
|
|
72
|
+
* Starts the Babylon Game Server: the lobby room "fusion_lobby" and the session room "fusion_session", filtered by
|
|
73
|
+
* session name and listed in the lobby in real time. The toolkit runtime is loaded first so rooms run the browser's
|
|
74
|
+
* session logic. Programmatic callers pass overrides of the defaults; the CLI passes its loaded config (D3). Once it
|
|
75
|
+
* listens, the config's boot dedicated sessions start (D19), and with handleSignals SIGTERM/SIGINT or an uncaught error
|
|
76
|
+
* drain it (D17). A draining process gets no new rooms from the matchmaker of any process.
|
|
77
|
+
* @param options The config overrides, hooks, extra routes and signal handling.
|
|
78
|
+
* @returns The running server.
|
|
79
|
+
* @throws GameServerConfigError when the config is invalid; Error "admin extension <name>: <problem>" for a bad
|
|
80
|
+
* options.admin definition; Error when a boot dedicated session cannot start (the server is stopped again first).
|
|
81
|
+
*/
|
|
82
|
+
export async function createGameServer(options = {}) {
|
|
83
|
+
if (findGameServerState() != null)
|
|
84
|
+
throw new Error("a Babylon Game Server is already running in this process");
|
|
85
|
+
const config = resolveGameServerConfig(options.config ?? {});
|
|
86
|
+
// Extension definitions are checked before anything starts (D25).
|
|
87
|
+
const extensions = new AdminExtensionsHost(options.admin ?? {});
|
|
88
|
+
drainDeadline = null;
|
|
89
|
+
// Admin tests set their own logger first to read what the server logs (the setup link); only they keep it.
|
|
90
|
+
if (options.adminTesting == null)
|
|
91
|
+
setLogger(new GameServerLogger(effectiveLogFormat(config), config.logLevel));
|
|
92
|
+
if (!config.admin.console)
|
|
93
|
+
return startConfiguredServer(options, config, extensions, null);
|
|
94
|
+
// The console's log buffer captures from here on, so every line of this server is in it (D21).
|
|
95
|
+
const logBuffer = new LogBuffer(config.admin.logBufferEntries);
|
|
96
|
+
getLogger().addSink(logBuffer.sink);
|
|
97
|
+
try {
|
|
98
|
+
return await startConfiguredServer(options, config, extensions, logBuffer);
|
|
99
|
+
}
|
|
100
|
+
catch (error) {
|
|
101
|
+
getLogger().removeSink(logBuffer.sink);
|
|
102
|
+
throw error;
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
/**
|
|
106
|
+
* Starts a server of a resolved config (createGameServer's work once the logger and the log buffer are in place).
|
|
107
|
+
* @param options The options of createGameServer.
|
|
108
|
+
* @param config The resolved config.
|
|
109
|
+
* @param extensions The checked admin extensions (D25).
|
|
110
|
+
* @param logBuffer The console's log buffer (already attached to the logger), or null while the console is off.
|
|
111
|
+
* @returns The running server.
|
|
112
|
+
* @throws Error when the server cannot start (see createGameServer).
|
|
113
|
+
*/
|
|
114
|
+
async function startConfiguredServer(options, config, extensions, logBuffer) {
|
|
115
|
+
const detachLogBuffer = () => {
|
|
116
|
+
if (logBuffer != null)
|
|
117
|
+
getLogger().removeSink(logBuffer.sink);
|
|
118
|
+
};
|
|
119
|
+
await loadToolkitRuntime();
|
|
120
|
+
const state = createServerState(config, options.hooks ?? {}, options.configSources ?? codeConfigSources());
|
|
121
|
+
state.logBuffer = logBuffer;
|
|
122
|
+
const metrics = config.metrics.enabled ? new GameServerMetrics(() => state) : null;
|
|
123
|
+
state.metrics = metrics;
|
|
124
|
+
const requestedPort = await resolveRequestedPort(config);
|
|
125
|
+
const tlsMaterial = await loadTlsMaterial(config.tls, config.udp.enabled);
|
|
126
|
+
state.tlsMaterial = tlsMaterial;
|
|
127
|
+
const transport = await createTransport(config, tlsMaterial);
|
|
128
|
+
// Only the configured scene roots and scripts may load from now on; close turns the allow-list off again (D19).
|
|
129
|
+
allowScriptUrlPrefixes(state.registry.allowedUrlPrefixes());
|
|
130
|
+
let gameServer = null;
|
|
131
|
+
let listening = false;
|
|
132
|
+
const requireGameServer = () => {
|
|
133
|
+
if (gameServer == null)
|
|
134
|
+
throw new Error("the game server is not listening");
|
|
135
|
+
return gameServer;
|
|
136
|
+
};
|
|
137
|
+
// One ioredis client serves presence and the admin store; RedisPresence.shutdown() quits it (R4).
|
|
138
|
+
const redisClient = (config.redis.url != null) ? new Redis(config.redis.url) : null;
|
|
139
|
+
const redisPresence = (redisClient != null) ? new RedisPresence(redisClient) : null;
|
|
140
|
+
const routeContext = {
|
|
141
|
+
state: () => state,
|
|
142
|
+
listeningPort: () => listeningPort(transport, requestedPort),
|
|
143
|
+
webTransportCertificateHash: () => transport.fingerprint ?? null,
|
|
144
|
+
isListening: () => listening,
|
|
145
|
+
urls: () => buildUrls(config, listeningPort(transport, requestedPort)),
|
|
146
|
+
metrics: () => metrics,
|
|
147
|
+
isRedisReachable: (redisPresence != null) ? () => isRedisReachable(redisPresence) : async () => true,
|
|
148
|
+
drain: () => requireGameServer().drain(),
|
|
149
|
+
drainDeadline: () => drainDeadline,
|
|
150
|
+
startDedicatedSession: (sessionName, sceneName, startedBy) => startConfiguredDedicatedSession(state.registry, sessionName, sceneName, startedBy)
|
|
151
|
+
};
|
|
152
|
+
const adminTesting = options.adminTesting ?? {};
|
|
153
|
+
const adminStore = await openAdminStore(config, redisClient, adminTesting.clock ?? SYSTEM_CLOCK);
|
|
154
|
+
state.discordSessions = config.discordSignIn.enabled ? await DiscordSessions.open(adminStore, adminTesting.clock ?? SYSTEM_CLOCK, config.discordSignIn.sessionDays) : null;
|
|
155
|
+
const leaderboardRankStore = await openLeaderboardRankStore(path.resolve(config.admin.dataDir), redisClient, adminStore.isPersistent);
|
|
156
|
+
const leaderboards = await LeaderboardService.open({ config: config, state: state, adminStore: adminStore, rankStore: leaderboardRankStore, clock: adminTesting.clock ?? SYSTEM_CLOCK, processId: () => matchMaker.processId, schedulerTickMs: adminTesting.leaderboardSchedulerTickMs ?? LEADERBOARD_SCHEDULER_TICK_MS });
|
|
157
|
+
const adminConsole = config.admin.console ? await createAdminConsole(createAdminServices(state, adminStore, routeContext, adminTesting, extensions, leaderboards)) : null;
|
|
158
|
+
const server = defineServer({
|
|
159
|
+
transport: transport,
|
|
160
|
+
gracefullyShutdown: false,
|
|
161
|
+
presence: redisPresence ?? undefined,
|
|
162
|
+
logger: colyseusLoggerAdapter,
|
|
163
|
+
driver: (config.redis.url != null) ? new RedisDriver(config.redis.url) : undefined,
|
|
164
|
+
publicAddress: config.publicAddress ?? undefined,
|
|
165
|
+
selectProcessIdToCreateRoom: selectProcessForNewRoom,
|
|
166
|
+
rooms: {
|
|
167
|
+
fusion_lobby: defineRoom(GameServerLobbyRoom),
|
|
168
|
+
fusion_session: defineRoom(SessionRoom).filterBy(["sessionName"]).enableRealtimeListing()
|
|
169
|
+
},
|
|
170
|
+
routes: createRouter({ ...createGameServerRoutes(routeContext), ...createLeaderboardRoutes(leaderboards, state), ...createDiscordSignInRoutes({ state: () => state, store: adminStore, leaderboards: leaderboards, clock: adminTesting.clock ?? SYSTEM_CLOCK, endpoints: discordEndpoints(options.discordTesting?.apiBaseUrl ?? DISCORD_API_BASE_URL) }), ...(adminConsole?.routes() ?? {}), ...(options.routes ?? {}) }, { onRequest: (request) => adminConsole?.redirectBarePath(request), onError: logRouterError })
|
|
171
|
+
});
|
|
172
|
+
const restoreCorsHeaders = installCorsOverride(config.limits);
|
|
173
|
+
setGameServerState(state);
|
|
174
|
+
try {
|
|
175
|
+
await server.listen(requestedPort, config.host);
|
|
176
|
+
}
|
|
177
|
+
catch (error) {
|
|
178
|
+
setGameServerState(null);
|
|
179
|
+
resetScriptUrlAllowList();
|
|
180
|
+
restoreCorsHeaders();
|
|
181
|
+
await leaderboards.stop();
|
|
182
|
+
throw error;
|
|
183
|
+
}
|
|
184
|
+
listening = true;
|
|
185
|
+
installClientAddressHeaders(transport.server, config.trustProxy);
|
|
186
|
+
watchTlsFiles(config.tls, (material) => {
|
|
187
|
+
// The console's certificate expiry reads the served certificate (game-server-admin-console T10).
|
|
188
|
+
state.tlsMaterial = material;
|
|
189
|
+
applyRenewedCertificate(transport.server, material, config.udp.enabled);
|
|
190
|
+
adminConsole?.recordServerEvent("certificate reloaded");
|
|
191
|
+
});
|
|
192
|
+
await adminConsole?.start();
|
|
193
|
+
await leaderboards.start();
|
|
194
|
+
const port = listeningPort(transport, requestedPort);
|
|
195
|
+
let removeSignalHandlers = () => undefined;
|
|
196
|
+
let drainPromise = null;
|
|
197
|
+
let stopPromise = null;
|
|
198
|
+
const bootSessionOwner = createBootSessionOwner(config, state, adminStore);
|
|
199
|
+
const shutDown = async () => {
|
|
200
|
+
listening = false;
|
|
201
|
+
removeSignalHandlers();
|
|
202
|
+
stopWatchingTlsFiles();
|
|
203
|
+
bootSessionOwner?.stop();
|
|
204
|
+
// The console stops first, so its timers never touch presence or the store after they close (R4).
|
|
205
|
+
await adminConsole?.stop();
|
|
206
|
+
await leaderboards.stop();
|
|
207
|
+
await forgetDrainingProcess();
|
|
208
|
+
await server.gracefullyShutdown(false);
|
|
209
|
+
await adminStore.close();
|
|
210
|
+
restoreCorsHeaders();
|
|
211
|
+
resetScriptUrlAllowList();
|
|
212
|
+
setGameServerState(null);
|
|
213
|
+
detachLogBuffer();
|
|
214
|
+
};
|
|
215
|
+
// close() after (or during) a drain, or a second close(), waits for the one shutdown instead of starting another.
|
|
216
|
+
const stopServer = () => {
|
|
217
|
+
stopPromise ??= shutDown();
|
|
218
|
+
return stopPromise;
|
|
219
|
+
};
|
|
220
|
+
gameServer = {
|
|
221
|
+
config: config,
|
|
222
|
+
port: port,
|
|
223
|
+
urls: buildUrls(config, port),
|
|
224
|
+
close: stopServer,
|
|
225
|
+
drain: () => {
|
|
226
|
+
drainPromise ??= drainServer(state, stopServer);
|
|
227
|
+
return drainPromise;
|
|
228
|
+
},
|
|
229
|
+
startDedicatedSession: (sessionName, sceneName) => startConfiguredDedicatedSession(state.registry, sessionName, sceneName, "code")
|
|
230
|
+
};
|
|
231
|
+
// Before the boot sessions: on Colyseus Cloud players are routed here as soon as the socket listens.
|
|
232
|
+
if (options.handleSignals === true)
|
|
233
|
+
removeSignalHandlers = installProcessHandlers(gameServer);
|
|
234
|
+
try {
|
|
235
|
+
if (bootSessionOwner != null)
|
|
236
|
+
await bootSessionOwner.start();
|
|
237
|
+
else
|
|
238
|
+
await startBootSessions(config, state.registry);
|
|
239
|
+
}
|
|
240
|
+
catch (error) {
|
|
241
|
+
await stopServer();
|
|
242
|
+
throw error;
|
|
243
|
+
}
|
|
244
|
+
return gameServer;
|
|
245
|
+
}
|
|
246
|
+
/**
|
|
247
|
+
* Drains the server (D17): new joins are refused with GameClosed and readiness turns 503 at once; running sessions get
|
|
248
|
+
* drainSeconds to end by themselves (the deadline is kept for routeContext.drainDeadline, game-server-admin-console
|
|
249
|
+
* §7.7); the rooms still open then tell their players GameClosed and disconnect with
|
|
250
|
+
* CloseCode.SERVER_SHUTDOWN; finally the server stops.
|
|
251
|
+
* @param state The server state.
|
|
252
|
+
* @param stopServer Stops the transport, presence and driver and clears the state.
|
|
253
|
+
* @returns Resolves once the server stopped.
|
|
254
|
+
*/
|
|
255
|
+
async function drainServer(state, stopServer) {
|
|
256
|
+
state.draining = true;
|
|
257
|
+
await rememberDrainingProcess();
|
|
258
|
+
getLogger().info("waiting for sessions to end", { sessions: state.sessionRooms.size, drainSeconds: state.config.drainSeconds });
|
|
259
|
+
const deadline = Date.now() + state.config.drainSeconds * MILLISECONDS_PER_SECOND;
|
|
260
|
+
drainDeadline = new Date(deadline).toISOString();
|
|
261
|
+
while (state.sessionRooms.size > 0 && Date.now() < deadline)
|
|
262
|
+
await delay(DRAIN_POLL_INTERVAL_MS);
|
|
263
|
+
const remainingRooms = Array.from(state.sessionRooms);
|
|
264
|
+
await Promise.all(remainingRooms.map((room) => room.closeForShutdown()));
|
|
265
|
+
await stopServer();
|
|
266
|
+
getLogger().info("drained", { closedSessions: remainingRooms.length });
|
|
267
|
+
}
|
|
268
|
+
/**
|
|
269
|
+
* Picks the process a new room is created on: the one with the fewest rooms among those that are not draining, as
|
|
270
|
+
* Colyseus' default does among all of them. When every process drains, all of them stay candidates, so creating a room
|
|
271
|
+
* behaves as before (a draining process refuses the join with GameClosed).
|
|
272
|
+
* @returns The chosen process id.
|
|
273
|
+
*/
|
|
274
|
+
async function selectProcessForNewRoom() {
|
|
275
|
+
const drainingIds = new Set(await matchMaker.presence.smembers(DRAINING_PROCESSES_KEY));
|
|
276
|
+
const processes = await matchMaker.stats.fetchAll();
|
|
277
|
+
const candidates = processes.filter((entry) => !drainingIds.has(entry.processId));
|
|
278
|
+
const pool = (candidates.length > 0) ? candidates : processes;
|
|
279
|
+
pool.sort((first, second) => first.roomCount - second.roomCount);
|
|
280
|
+
return pool[0]?.processId ?? matchMaker.processId;
|
|
281
|
+
}
|
|
282
|
+
/**
|
|
283
|
+
* Lists this process in DRAINING_PROCESSES_KEY, so no other process creates a room on it. A presence that cannot be
|
|
284
|
+
* reached is logged and the drain goes on.
|
|
285
|
+
*/
|
|
286
|
+
async function rememberDrainingProcess() {
|
|
287
|
+
try {
|
|
288
|
+
await matchMaker.presence.sadd(DRAINING_PROCESSES_KEY, matchMaker.processId);
|
|
289
|
+
}
|
|
290
|
+
catch (error) {
|
|
291
|
+
getLogger().warn("could not mark the process as draining", { error: String(error) });
|
|
292
|
+
}
|
|
293
|
+
}
|
|
294
|
+
/**
|
|
295
|
+
* Takes this process out of DRAINING_PROCESSES_KEY at shutdown (process ids are never reused, so this only keeps the
|
|
296
|
+
* set small). A presence that cannot be reached is ignored.
|
|
297
|
+
*/
|
|
298
|
+
async function forgetDrainingProcess() {
|
|
299
|
+
try {
|
|
300
|
+
await matchMaker.presence.srem(DRAINING_PROCESSES_KEY, matchMaker.processId);
|
|
301
|
+
}
|
|
302
|
+
catch {
|
|
303
|
+
// Redis is down or already closed; the stale id matches no live process.
|
|
304
|
+
}
|
|
305
|
+
}
|
|
306
|
+
/**
|
|
307
|
+
* Drains on SIGTERM or SIGINT and exits 0, and drains on an uncaught error (an unhandled rejection included) and exits
|
|
308
|
+
* CRASH_EXIT_CODE (D17). A watchdog exits 1 if the drain takes longer than drainSeconds plus DRAIN_EXIT_MARGIN_SECONDS.
|
|
309
|
+
* Only the first stop drains; a later uncaught error is logged. Colyseus' own handlers stay off (gracefullyShutdown:
|
|
310
|
+
* false), which also leaves the process without any uncaught-error handler of Colyseus.
|
|
311
|
+
* @param gameServer The running server.
|
|
312
|
+
* @returns Removes the handlers (close).
|
|
313
|
+
*/
|
|
314
|
+
function installProcessHandlers(gameServer) {
|
|
315
|
+
let stopping = false;
|
|
316
|
+
const drainAndExit = (exitCode) => {
|
|
317
|
+
stopping = true;
|
|
318
|
+
const watchdogMilliseconds = (gameServer.config.drainSeconds + DRAIN_EXIT_MARGIN_SECONDS) * MILLISECONDS_PER_SECOND;
|
|
319
|
+
setTimeout(() => {
|
|
320
|
+
getLogger().error("drain did not finish in time");
|
|
321
|
+
process.exit(1);
|
|
322
|
+
}, watchdogMilliseconds).unref();
|
|
323
|
+
gameServer.drain().then(() => process.exit(exitCode), (error) => {
|
|
324
|
+
getLogger().error("drain failed", { error: String(error) });
|
|
325
|
+
process.exit(1);
|
|
326
|
+
});
|
|
327
|
+
};
|
|
328
|
+
const onSignal = (signalName) => {
|
|
329
|
+
getLogger().info("draining", { signal: signalName });
|
|
330
|
+
if (!stopping)
|
|
331
|
+
drainAndExit(0);
|
|
332
|
+
};
|
|
333
|
+
const onUncaughtError = (error) => {
|
|
334
|
+
getLogger().error("uncaught error", { error: String(error), stack: error?.stack ?? null, draining: !stopping });
|
|
335
|
+
if (!stopping)
|
|
336
|
+
drainAndExit(CRASH_EXIT_CODE);
|
|
337
|
+
};
|
|
338
|
+
for (const signalName of DRAIN_SIGNALS)
|
|
339
|
+
process.once(signalName, onSignal);
|
|
340
|
+
process.on("uncaughtException", onUncaughtError);
|
|
341
|
+
return () => {
|
|
342
|
+
for (const signalName of DRAIN_SIGNALS)
|
|
343
|
+
process.removeListener(signalName, onSignal);
|
|
344
|
+
process.removeListener("uncaughtException", onUncaughtError);
|
|
345
|
+
};
|
|
346
|
+
}
|
|
347
|
+
/**
|
|
348
|
+
* The router's onError hook (game-server-admin-console D21): a handler that failed is logged, so it reaches the console's
|
|
349
|
+
* log buffer; errors an endpoint throws on purpose are answered with their status and not logged. The router then
|
|
350
|
+
* answers 500 as before.
|
|
351
|
+
* @param error What the handler threw.
|
|
352
|
+
*/
|
|
353
|
+
function logRouterError(error) {
|
|
354
|
+
if (error?.name === ROUTER_API_ERROR_NAME)
|
|
355
|
+
return;
|
|
356
|
+
getLogger().error("router handler failed", { error: String(error) });
|
|
357
|
+
}
|
|
358
|
+
/**
|
|
359
|
+
* Whether Redis answers within REDIS_PROBE_TIMEOUT_MS (D18).
|
|
360
|
+
* @param presence The Redis presence.
|
|
361
|
+
* @returns True when the probe answered in time.
|
|
362
|
+
*/
|
|
363
|
+
async function isRedisReachable(presence) {
|
|
364
|
+
try {
|
|
365
|
+
return await Promise.race([presence.exists(READINESS_PROBE_KEY).then(() => true), delay(REDIS_PROBE_TIMEOUT_MS).then(() => false)]);
|
|
366
|
+
}
|
|
367
|
+
catch {
|
|
368
|
+
return false;
|
|
369
|
+
}
|
|
370
|
+
}
|
|
371
|
+
/**
|
|
372
|
+
* Resolves after a while.
|
|
373
|
+
* @param milliseconds How long to wait.
|
|
374
|
+
* @returns Resolves after the wait.
|
|
375
|
+
*/
|
|
376
|
+
function delay(milliseconds) {
|
|
377
|
+
return new Promise((resolve) => setTimeout(resolve, milliseconds));
|
|
378
|
+
}
|
|
379
|
+
/**
|
|
380
|
+
* The transport of a config (§Control flow): HTTP/3 + HTTPS + WSS on one port when UDP is on, WSS on an HTTPS server
|
|
381
|
+
* when only TLS is on, else plain WebSocket.
|
|
382
|
+
* @param config The config.
|
|
383
|
+
* @param tlsMaterial The HTTPS certificate and key, or null (TLS off, or the pinned HTTP/3 certificate serves HTTPS).
|
|
384
|
+
* @returns The transport.
|
|
385
|
+
* @throws Error when UDP is on and @colyseus/h3-transport is missing.
|
|
386
|
+
*/
|
|
387
|
+
async function createTransport(config, tlsMaterial) {
|
|
388
|
+
const maxPayload = config.limits.maxMessageBytes;
|
|
389
|
+
if (config.udp.enabled) {
|
|
390
|
+
return createDualTransport({ cert: tlsMaterial?.cert ?? null, key: tlsMaterial?.key ?? null, pinHttp3Certificate: config.tls.developmentCertificate, maxPayload: maxPayload });
|
|
391
|
+
}
|
|
392
|
+
if (tlsMaterial == null)
|
|
393
|
+
return new WebSocketTransport({ pingInterval: PING_INTERVAL_MS, maxPayload: maxPayload });
|
|
394
|
+
const httpsServer = createHttpsServer({ cert: tlsMaterial.cert, key: tlsMaterial.key });
|
|
395
|
+
return new WebSocketTransport({ server: httpsServer, pingInterval: PING_INTERVAL_MS, maxPayload: maxPayload });
|
|
396
|
+
}
|
|
397
|
+
/**
|
|
398
|
+
* Puts a renewed certificate on the HTTPS server without a restart (D9). HTTP/3 has no reload API, so with UDP on it
|
|
399
|
+
* keeps the old certificate until the next restart, which is logged.
|
|
400
|
+
* @param httpsServer The transport's HTTPS server.
|
|
401
|
+
* @param material The renewed certificate and key.
|
|
402
|
+
* @param udpEnabled True when HTTP/3 runs.
|
|
403
|
+
*/
|
|
404
|
+
function applyRenewedCertificate(httpsServer, material, udpEnabled) {
|
|
405
|
+
httpsServer.setSecureContext({ cert: material.cert, key: material.key });
|
|
406
|
+
getLogger().info("certificate reloaded");
|
|
407
|
+
if (udpEnabled)
|
|
408
|
+
getLogger().warn("HTTP/3 uses the new certificate after a restart");
|
|
409
|
+
}
|
|
410
|
+
/**
|
|
411
|
+
* The port to listen on. TCP and UDP must share one port (D8), so with UDP on, port 0 becomes one free TCP port picked
|
|
412
|
+
* up front (HTTP/3 would otherwise pick its own, different UDP port).
|
|
413
|
+
* @param config The config.
|
|
414
|
+
* @returns The configured port, or the free port picked for 0 with UDP on.
|
|
415
|
+
*/
|
|
416
|
+
async function resolveRequestedPort(config) {
|
|
417
|
+
if (config.port !== 0 || !config.udp.enabled)
|
|
418
|
+
return config.port;
|
|
419
|
+
return new Promise((resolve, reject) => {
|
|
420
|
+
const probe = createNetServer();
|
|
421
|
+
probe.once("error", reject);
|
|
422
|
+
probe.listen(0, config.host, () => {
|
|
423
|
+
const address = probe.address();
|
|
424
|
+
const freePort = (address != null && typeof address === "object") ? address.port : 0;
|
|
425
|
+
probe.close(() => resolve(freePort));
|
|
426
|
+
});
|
|
427
|
+
});
|
|
428
|
+
}
|
|
429
|
+
/**
|
|
430
|
+
* The process state the join gate and the rooms read (D10). createGameServer sets its metrics when they are on (D16).
|
|
431
|
+
* @param config The resolved config.
|
|
432
|
+
* @param hooks The server's hooks.
|
|
433
|
+
* @param configSources Where each option came from.
|
|
434
|
+
* @returns The state.
|
|
435
|
+
* @throws GameServerConfigError when auth mode "custom" has no verifyToken hook.
|
|
436
|
+
*/
|
|
437
|
+
function createServerState(config, hooks, configSources) {
|
|
438
|
+
const customVerifier = (hooks.verifyToken != null) ? hooks.verifyToken.bind(hooks) : null;
|
|
439
|
+
const verifier = config.auth.enabled ? createTokenVerifier(config.auth, customVerifier) : null;
|
|
440
|
+
return {
|
|
441
|
+
config: config,
|
|
442
|
+
hooks: hooks,
|
|
443
|
+
verifier: verifier,
|
|
444
|
+
discordSessions: null,
|
|
445
|
+
configSources: configSources,
|
|
446
|
+
rateLimiter: new JoinRateLimiter(config.limits.joinRequestsPerMinutePerAddress),
|
|
447
|
+
metrics: null,
|
|
448
|
+
registry: new DedicatedSceneRegistry(config.dedicated.scenes, process.cwd()),
|
|
449
|
+
draining: false,
|
|
450
|
+
maintenance: null,
|
|
451
|
+
sessionRooms: new Set(),
|
|
452
|
+
lobbyRooms: new Set(),
|
|
453
|
+
banList: null,
|
|
454
|
+
kickBlocks: new KickBlockList(() => matchMaker.presence),
|
|
455
|
+
onPlayerSessionEvent: null,
|
|
456
|
+
onLeaderboardSessionPresence: null,
|
|
457
|
+
logBuffer: null,
|
|
458
|
+
tlsMaterial: null,
|
|
459
|
+
startedAt: new Date().toISOString()
|
|
460
|
+
};
|
|
461
|
+
}
|
|
462
|
+
/**
|
|
463
|
+
* The sources of a server made from code (game-server-admin-console D20): every option counts as "code".
|
|
464
|
+
* @returns The sources.
|
|
465
|
+
*/
|
|
466
|
+
function codeConfigSources() {
|
|
467
|
+
return Object.fromEntries(CONFIG_OPTIONS.map((definition) => [definition.path, "code"]));
|
|
468
|
+
}
|
|
469
|
+
/**
|
|
470
|
+
* The core services of the admin console (game-server-admin-console DR §6.3, R5, R10): the defaults, or the test values
|
|
471
|
+
* of adminTesting.
|
|
472
|
+
* @param state The server state.
|
|
473
|
+
* @param store The admin data store.
|
|
474
|
+
* @param routeContext What the legacy routes read.
|
|
475
|
+
* @param adminTesting The test overrides (empty outside tests).
|
|
476
|
+
* @param extensions The checked admin extensions (D25).
|
|
477
|
+
* @param leaderboards The leaderboard service.
|
|
478
|
+
* @returns The services.
|
|
479
|
+
*/
|
|
480
|
+
function createAdminServices(state, store, routeContext, adminTesting, extensions, leaderboards) {
|
|
481
|
+
return {
|
|
482
|
+
config: state.config,
|
|
483
|
+
state: state,
|
|
484
|
+
store: store,
|
|
485
|
+
routeContext: routeContext,
|
|
486
|
+
processId: () => matchMaker.processId,
|
|
487
|
+
clock: adminTesting.clock ?? SYSTEM_CLOCK,
|
|
488
|
+
heartbeatIntervalMs: adminTesting.heartbeatIntervalMs ?? FLEET_HEARTBEAT_INTERVAL_MS,
|
|
489
|
+
metricsSampleIntervalMs: adminTesting.metricsSampleIntervalMs ?? METRICS_SAMPLE_INTERVAL_MS,
|
|
490
|
+
leaderTickMs: adminTesting.leaderTickMs ?? LEADER_TICK_MS,
|
|
491
|
+
pagerDutyUrl: adminTesting.pagerDutyUrl ?? PAGERDUTY_EVENTS_URL,
|
|
492
|
+
colyseusCloudRefreshed: adminTesting.colyseusCloudRefreshed ?? ignoreRefreshPass,
|
|
493
|
+
extensions: extensions,
|
|
494
|
+
leaderboards: leaderboards
|
|
495
|
+
};
|
|
496
|
+
}
|
|
497
|
+
/** The default Colyseus Cloud refresh hook: nothing to report outside tests. */
|
|
498
|
+
function ignoreRefreshPass() {
|
|
499
|
+
// Nothing to report outside tests (colyseus-cloud-console D17).
|
|
500
|
+
}
|
|
501
|
+
/**
|
|
502
|
+
* Replaces Colyseus' CORS answer with the origin allow-list (D14). Core merges its defaults (Allow-Origin "*") before
|
|
503
|
+
* this function's result, so the result always names an origin: the request's when it is allowed, else the first
|
|
504
|
+
* allowed origin, which a browser on any other origin rejects.
|
|
505
|
+
* @param limits The limits config.
|
|
506
|
+
* @returns Puts the original function back (close).
|
|
507
|
+
*/
|
|
508
|
+
function installCorsOverride(limits) {
|
|
509
|
+
const originalCorsHeaders = matchMaker.controller.getCorsHeaders;
|
|
510
|
+
matchMaker.controller.getCorsHeaders = (headers) => {
|
|
511
|
+
const requestHeaders = headers ?? new Headers();
|
|
512
|
+
const originalAnswer = originalCorsHeaders.call(matchMaker.controller, requestHeaders);
|
|
513
|
+
if (limits.allowedOrigins.length === 0)
|
|
514
|
+
return originalAnswer;
|
|
515
|
+
const origin = requestHeaders.get("origin");
|
|
516
|
+
const allowedOrigin = (origin != null && limits.allowedOrigins.includes(origin)) ? origin : limits.allowedOrigins[0];
|
|
517
|
+
return Object.assign({}, originalAnswer, { [ALLOW_ORIGIN_HEADER]: allowedOrigin, Vary: "Origin" });
|
|
518
|
+
};
|
|
519
|
+
return () => {
|
|
520
|
+
matchMaker.controller.getCorsHeaders = originalCorsHeaders;
|
|
521
|
+
};
|
|
522
|
+
}
|
|
523
|
+
/**
|
|
524
|
+
* The URLs of a server: the public URL when configured, else derived from the bind address, port and TLS.
|
|
525
|
+
* @param config The config.
|
|
526
|
+
* @param port The listening port.
|
|
527
|
+
* @returns The URLs.
|
|
528
|
+
*/
|
|
529
|
+
function buildUrls(config, port) {
|
|
530
|
+
const secure = config.tls.enabled;
|
|
531
|
+
const displayHost = WILDCARD_HOSTS.includes(config.host) ? LOCAL_HOST_NAME : config.host;
|
|
532
|
+
const derivedHost = (displayHost.includes(":") ? "[" + displayHost + "]" : displayHost) + ":" + port;
|
|
533
|
+
const webSocket = config.publicUrl ?? (secure ? "wss" : "ws") + "://" + derivedHost;
|
|
534
|
+
const http = webSocket.replace(/^ws/, "http");
|
|
535
|
+
// UDP requires TLS, so the HTTP URL is already https:// and names the same host and port as HTTP/3.
|
|
536
|
+
return { webSocket: webSocket, http: http, webTransport: config.udp.enabled ? http : null };
|
|
537
|
+
}
|
|
538
|
+
/**
|
|
539
|
+
* Starts a dedicated session of a configured scene (D19).
|
|
540
|
+
* @param registry The configured dedicated scenes.
|
|
541
|
+
* @param sessionName The session name clients join.
|
|
542
|
+
* @param sceneName The configured scene.
|
|
543
|
+
* @param startedBy Who starts it, as the console shows it (game-server-admin-console T9).
|
|
544
|
+
* @param createOnThisProcess True creates the room here rather than on the process the matchmaker picks.
|
|
545
|
+
* @returns The room id.
|
|
546
|
+
* @throws Error("no dedicated scene named <name>") for an unknown scene; Error when the scene cannot be loaded.
|
|
547
|
+
*/
|
|
548
|
+
function startConfiguredDedicatedSession(registry, sessionName, sceneName, startedBy, createOnThisProcess = false) {
|
|
549
|
+
const scene = registry.find(sceneName);
|
|
550
|
+
if (scene == null)
|
|
551
|
+
return Promise.reject(new Error("no dedicated scene named " + sceneName));
|
|
552
|
+
return DedicatedSession.start({
|
|
553
|
+
sessionName: sessionName,
|
|
554
|
+
sceneRootUrl: scene.sceneRootUrl,
|
|
555
|
+
sceneFile: scene.sceneFile,
|
|
556
|
+
playerCount: scene.maxPlayers,
|
|
557
|
+
projectScripts: scene.projectScripts,
|
|
558
|
+
sceneName: sceneName,
|
|
559
|
+
startedBy: startedBy,
|
|
560
|
+
createOnThisProcess: createOnThisProcess
|
|
561
|
+
});
|
|
562
|
+
}
|
|
563
|
+
/**
|
|
564
|
+
* Starts the config's boot dedicated sessions, in order, once the server listens (§Control flow step 9), on a host that
|
|
565
|
+
* does not number its processes.
|
|
566
|
+
* @param config The resolved config.
|
|
567
|
+
* @param registry The configured dedicated scenes.
|
|
568
|
+
* @throws Error when a boot session cannot start.
|
|
569
|
+
*/
|
|
570
|
+
async function startBootSessions(config, registry) {
|
|
571
|
+
for (const boot of config.dedicated.boot)
|
|
572
|
+
await startConfiguredDedicatedSession(registry, boot.sessionName, boot.sceneName, "boot");
|
|
573
|
+
}
|
|
574
|
+
/**
|
|
575
|
+
* The owner of the boot sessions on a host that numbers its processes, where a rolling deploy can restart any of them.
|
|
576
|
+
* @param config The resolved config.
|
|
577
|
+
* @param state The server state.
|
|
578
|
+
* @param adminStore The store holding the lease.
|
|
579
|
+
* @returns The owner, not started yet; null when startBootSessions applies or there are no boot sessions.
|
|
580
|
+
*/
|
|
581
|
+
function createBootSessionOwner(config, state, adminStore) {
|
|
582
|
+
if (process.env[PROCESS_NUMBER_VARIABLE] === undefined || config.dedicated.boot.length === 0)
|
|
583
|
+
return null;
|
|
584
|
+
return new BootSessionOwner({
|
|
585
|
+
tryAcquireLease: (holderId, ttlMilliseconds) => adminStore.tryAcquireLease(BOOT_SESSION_LEASE_NAME, holderId, ttlMilliseconds),
|
|
586
|
+
startSessions: createLeasedBootStarter(config, state.registry),
|
|
587
|
+
holderId: () => matchMaker.processId,
|
|
588
|
+
isDraining: () => state.draining,
|
|
589
|
+
clock: SYSTEM_CLOCK
|
|
590
|
+
});
|
|
591
|
+
}
|
|
592
|
+
/**
|
|
593
|
+
* The start function of a BootSessionOwner. A session whose name already runs waits: during a rolling deploy the draining
|
|
594
|
+
* process keeps its boot session until it ends, a second room of that name would split its joiners, and the matchmaker
|
|
595
|
+
* does not refuse one. Each call skips the sessions an earlier call started. Rooms are created on this process, so a
|
|
596
|
+
* session never lands on a draining process, and it ends with the process that holds the lease, which lets the next
|
|
597
|
+
* holder start it again.
|
|
598
|
+
* @param config The resolved config.
|
|
599
|
+
* @param registry The configured dedicated scenes.
|
|
600
|
+
* @returns Starts what can start; resolves true when every boot session has started, false when one must wait.
|
|
601
|
+
*/
|
|
602
|
+
function createLeasedBootStarter(config, registry) {
|
|
603
|
+
const startedSessionNames = new Set();
|
|
604
|
+
const waitingSessionNames = new Set();
|
|
605
|
+
return async () => {
|
|
606
|
+
let mustWait = false;
|
|
607
|
+
for (const boot of config.dedicated.boot) {
|
|
608
|
+
if (startedSessionNames.has(boot.sessionName))
|
|
609
|
+
continue;
|
|
610
|
+
if (await isSessionRunning(boot.sessionName)) {
|
|
611
|
+
mustWait = true;
|
|
612
|
+
// A wait can last the whole drain of the old process, so it logs once rather than on every tick.
|
|
613
|
+
if (!waitingSessionNames.has(boot.sessionName))
|
|
614
|
+
getLogger().info("boot dedicated session \"" + boot.sessionName + "\" waits for the running session of that name to end", { sessionName: boot.sessionName });
|
|
615
|
+
waitingSessionNames.add(boot.sessionName);
|
|
616
|
+
continue;
|
|
617
|
+
}
|
|
618
|
+
await startConfiguredDedicatedSession(registry, boot.sessionName, boot.sceneName, "boot", true);
|
|
619
|
+
startedSessionNames.add(boot.sessionName);
|
|
620
|
+
}
|
|
621
|
+
return !mustWait;
|
|
622
|
+
};
|
|
623
|
+
}
|
|
624
|
+
/**
|
|
625
|
+
* Whether a live process runs a session room of a name. A listing whose process no longer answers is a crash leftover:
|
|
626
|
+
* the health check removes it, and it must not hold the session back.
|
|
627
|
+
* @param sessionName The session name.
|
|
628
|
+
* @returns True when this process or another one that answers lists it, locked or not.
|
|
629
|
+
*/
|
|
630
|
+
async function isSessionRunning(sessionName) {
|
|
631
|
+
const conditions = { name: SESSION_ROOM_NAME, sessionName: sessionName };
|
|
632
|
+
for (const room of await matchMaker.query(conditions)) {
|
|
633
|
+
if (room.processId === matchMaker.processId || await matchMaker.healthCheckProcessId(room.processId) === true)
|
|
634
|
+
return true;
|
|
635
|
+
}
|
|
636
|
+
return false;
|
|
637
|
+
}
|
|
638
|
+
/**
|
|
639
|
+
* The port the transport actually listens on (the requested one, or the free port picked for 0).
|
|
640
|
+
* @param transport The transport.
|
|
641
|
+
* @param requestedPort The port passed to listen.
|
|
642
|
+
* @returns The listening port.
|
|
643
|
+
*/
|
|
644
|
+
function listeningPort(transport, requestedPort) {
|
|
645
|
+
const address = transport.server?.address();
|
|
646
|
+
return (address != null && typeof address === "object") ? address.port : requestedPort;
|
|
647
|
+
}
|