@babylonjs-toolkit/server 0.0.0-stage → 9.35.1

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.
Files changed (236) hide show
  1. package/CUSTOM.md +1690 -0
  2. package/DEPLOY.md +2012 -0
  3. package/README.md +1081 -2
  4. package/lib/AbuseLimits.d.ts +64 -0
  5. package/lib/AbuseLimits.js +121 -0
  6. package/lib/Authentication.d.ts +86 -0
  7. package/lib/Authentication.js +272 -0
  8. package/lib/BootSessionOwner.d.ts +86 -0
  9. package/lib/BootSessionOwner.js +116 -0
  10. package/lib/ColyseusLoggerAdapter.d.ts +32 -0
  11. package/lib/ColyseusLoggerAdapter.js +73 -0
  12. package/lib/DedicatedSceneRegistry.d.ts +52 -0
  13. package/lib/DedicatedSceneRegistry.js +74 -0
  14. package/lib/DedicatedSession.d.ts +152 -0
  15. package/lib/DedicatedSession.js +173 -0
  16. package/lib/DualTransport.d.ts +112 -0
  17. package/lib/DualTransport.js +197 -0
  18. package/lib/GameServer.d.ts +64 -0
  19. package/lib/GameServer.js +647 -0
  20. package/lib/GameServerConfig.d.ts +387 -0
  21. package/lib/GameServerConfig.js +1059 -0
  22. package/lib/GameServerLobbyRoom.d.ts +33 -0
  23. package/lib/GameServerLobbyRoom.js +56 -0
  24. package/lib/GameServerLogger.d.ts +111 -0
  25. package/lib/GameServerLogger.js +190 -0
  26. package/lib/GameServerMetrics.d.ts +158 -0
  27. package/lib/GameServerMetrics.js +276 -0
  28. package/lib/GameServerState.d.ts +164 -0
  29. package/lib/GameServerState.js +26 -0
  30. package/lib/HeadlessRuntime.d.ts +316 -0
  31. package/lib/HeadlessRuntime.js +380 -0
  32. package/lib/JoinGate.d.ts +34 -0
  33. package/lib/JoinGate.js +221 -0
  34. package/lib/NetworkAddresses.d.ts +33 -0
  35. package/lib/NetworkAddresses.js +90 -0
  36. package/lib/PlayerTokens.d.ts +17 -0
  37. package/lib/PlayerTokens.js +24 -0
  38. package/lib/RoomServerTransport.d.ts +73 -0
  39. package/lib/RoomServerTransport.js +90 -0
  40. package/lib/ServerEndpoints.d.ts +133 -0
  41. package/lib/ServerEndpoints.js +247 -0
  42. package/lib/ServerInit.d.ts +28 -0
  43. package/lib/ServerInit.js +113 -0
  44. package/lib/SessionRoom.d.ts +391 -0
  45. package/lib/SessionRoom.js +930 -0
  46. package/lib/TlsCertificates.d.ts +35 -0
  47. package/lib/TlsCertificates.js +130 -0
  48. package/lib/admin/AccountRoutes.d.ts +8 -0
  49. package/lib/admin/AccountRoutes.js +502 -0
  50. package/lib/admin/AdminAccounts.d.ts +329 -0
  51. package/lib/admin/AdminAccounts.js +607 -0
  52. package/lib/admin/AdminApiRouter.d.ts +202 -0
  53. package/lib/admin/AdminApiRouter.js +465 -0
  54. package/lib/admin/AdminApiTypes.d.ts +1782 -0
  55. package/lib/admin/AdminApiTypes.js +4 -0
  56. package/lib/admin/AdminAudit.d.ts +113 -0
  57. package/lib/admin/AdminAudit.js +266 -0
  58. package/lib/admin/AdminCli.d.ts +7 -0
  59. package/lib/admin/AdminCli.js +351 -0
  60. package/lib/admin/AdminClock.d.ts +51 -0
  61. package/lib/admin/AdminClock.js +10 -0
  62. package/lib/admin/AdminConsole.d.ts +39 -0
  63. package/lib/admin/AdminConsole.js +325 -0
  64. package/lib/admin/AdminConstants.d.ts +147 -0
  65. package/lib/admin/AdminConstants.js +167 -0
  66. package/lib/admin/AdminCrypto.d.ts +19 -0
  67. package/lib/admin/AdminCrypto.js +29 -0
  68. package/lib/admin/AdminExtensionsHost.d.ts +176 -0
  69. package/lib/admin/AdminExtensionsHost.js +307 -0
  70. package/lib/admin/AdminPasswords.d.ts +33 -0
  71. package/lib/admin/AdminPasswords.js +92 -0
  72. package/lib/admin/AdminPermissions.d.ts +28 -0
  73. package/lib/admin/AdminPermissions.js +74 -0
  74. package/lib/admin/AdminRecords.d.ts +196 -0
  75. package/lib/admin/AdminRecords.js +3 -0
  76. package/lib/admin/AdminRequestBodies.d.ts +47 -0
  77. package/lib/admin/AdminRequestBodies.js +84 -0
  78. package/lib/admin/AdminRequestGate.d.ts +38 -0
  79. package/lib/admin/AdminRequestGate.js +59 -0
  80. package/lib/admin/AdminResponses.d.ts +103 -0
  81. package/lib/admin/AdminResponses.js +164 -0
  82. package/lib/admin/AdminServices.d.ts +99 -0
  83. package/lib/admin/AdminServices.js +4 -0
  84. package/lib/admin/AdminStaticFiles.d.ts +29 -0
  85. package/lib/admin/AdminStaticFiles.js +97 -0
  86. package/lib/admin/AdminStore.d.ts +152 -0
  87. package/lib/admin/AdminStore.js +46 -0
  88. package/lib/admin/AlertChannels.d.ts +43 -0
  89. package/lib/admin/AlertChannels.js +120 -0
  90. package/lib/admin/AlertEngine.d.ts +231 -0
  91. package/lib/admin/AlertEngine.js +681 -0
  92. package/lib/admin/AlertRoutes.d.ts +8 -0
  93. package/lib/admin/AlertRoutes.js +180 -0
  94. package/lib/admin/ApiKeyRoutes.d.ts +8 -0
  95. package/lib/admin/ApiKeyRoutes.js +53 -0
  96. package/lib/admin/ApiKeys.d.ts +63 -0
  97. package/lib/admin/ApiKeys.js +167 -0
  98. package/lib/admin/AuditRoutes.d.ts +8 -0
  99. package/lib/admin/AuditRoutes.js +106 -0
  100. package/lib/admin/AuthToolRoutes.d.ts +13 -0
  101. package/lib/admin/AuthToolRoutes.js +164 -0
  102. package/lib/admin/BanList.d.ts +87 -0
  103. package/lib/admin/BanList.js +152 -0
  104. package/lib/admin/CborDecoder.d.ts +18 -0
  105. package/lib/admin/CborDecoder.js +201 -0
  106. package/lib/admin/ColyseusCloudClient.d.ts +313 -0
  107. package/lib/admin/ColyseusCloudClient.js +272 -0
  108. package/lib/admin/ColyseusCloudRoutes.d.ts +8 -0
  109. package/lib/admin/ColyseusCloudRoutes.js +91 -0
  110. package/lib/admin/ColyseusCloudService.d.ts +180 -0
  111. package/lib/admin/ColyseusCloudService.js +476 -0
  112. package/lib/admin/ColyseusCloudSettings.d.ts +16 -0
  113. package/lib/admin/ColyseusCloudSettings.js +29 -0
  114. package/lib/admin/ColyseusCloudViews.d.ts +105 -0
  115. package/lib/admin/ColyseusCloudViews.js +371 -0
  116. package/lib/admin/CommonPasswords.d.ts +6 -0
  117. package/lib/admin/CommonPasswords.js +19 -0
  118. package/lib/admin/ConfigOverrides.d.ts +174 -0
  119. package/lib/admin/ConfigOverrides.js +406 -0
  120. package/lib/admin/ConfigRoutes.d.ts +8 -0
  121. package/lib/admin/ConfigRoutes.js +91 -0
  122. package/lib/admin/ConsoleSessions.d.ts +112 -0
  123. package/lib/admin/ConsoleSessions.js +201 -0
  124. package/lib/admin/DedicatedRoutes.d.ts +21 -0
  125. package/lib/admin/DedicatedRoutes.js +254 -0
  126. package/lib/admin/ExtensionRoutes.d.ts +8 -0
  127. package/lib/admin/ExtensionRoutes.js +145 -0
  128. package/lib/admin/FileAdminStore.d.ts +157 -0
  129. package/lib/admin/FileAdminStore.js +349 -0
  130. package/lib/admin/FleetDirectory.d.ts +323 -0
  131. package/lib/admin/FleetDirectory.js +489 -0
  132. package/lib/admin/FleetLeader.d.ts +43 -0
  133. package/lib/admin/FleetLeader.js +89 -0
  134. package/lib/admin/FleetRoutes.d.ts +55 -0
  135. package/lib/admin/FleetRoutes.js +465 -0
  136. package/lib/admin/HealthWarnings.d.ts +10 -0
  137. package/lib/admin/HealthWarnings.js +95 -0
  138. package/lib/admin/LeaderboardRoutes.d.ts +8 -0
  139. package/lib/admin/LeaderboardRoutes.js +569 -0
  140. package/lib/admin/LogBuffer.d.ts +60 -0
  141. package/lib/admin/LogBuffer.js +92 -0
  142. package/lib/admin/LogRedaction.d.ts +15 -0
  143. package/lib/admin/LogRedaction.js +72 -0
  144. package/lib/admin/LogRoutes.d.ts +45 -0
  145. package/lib/admin/LogRoutes.js +307 -0
  146. package/lib/admin/MaintenanceMode.d.ts +41 -0
  147. package/lib/admin/MaintenanceMode.js +74 -0
  148. package/lib/admin/MaintenanceWindows.d.ts +91 -0
  149. package/lib/admin/MaintenanceWindows.js +207 -0
  150. package/lib/admin/MetricsRoutes.d.ts +8 -0
  151. package/lib/admin/MetricsRoutes.js +209 -0
  152. package/lib/admin/MetricsSampler.d.ts +124 -0
  153. package/lib/admin/MetricsSampler.js +211 -0
  154. package/lib/admin/OneTimeCodes.d.ts +60 -0
  155. package/lib/admin/OneTimeCodes.js +162 -0
  156. package/lib/admin/OpenIdConnect.d.ts +146 -0
  157. package/lib/admin/OpenIdConnect.js +335 -0
  158. package/lib/admin/Passkeys.d.ts +130 -0
  159. package/lib/admin/Passkeys.js +432 -0
  160. package/lib/admin/PlayerRoutes.d.ts +20 -0
  161. package/lib/admin/PlayerRoutes.js +676 -0
  162. package/lib/admin/RedisAdminStore.d.ts +114 -0
  163. package/lib/admin/RedisAdminStore.js +258 -0
  164. package/lib/admin/RolloutCoordinator.d.ts +99 -0
  165. package/lib/admin/RolloutCoordinator.js +321 -0
  166. package/lib/admin/ServerEvents.d.ts +57 -0
  167. package/lib/admin/ServerEvents.js +89 -0
  168. package/lib/admin/SessionRoutes.d.ts +113 -0
  169. package/lib/admin/SessionRoutes.js +386 -0
  170. package/lib/admin/UserRoutes.d.ts +8 -0
  171. package/lib/admin/UserRoutes.js +214 -0
  172. package/lib/cli.d.ts +2 -0
  173. package/lib/cli.js +221 -0
  174. package/lib/console/assets/index-CI5N7c-T.js +21 -0
  175. package/lib/console/assets/index-DPitvzLp.css +1 -0
  176. package/lib/console/favicon.svg +1 -0
  177. package/lib/console/index.html +16 -0
  178. package/lib/discord/DiscordOAuth.d.ts +57 -0
  179. package/lib/discord/DiscordOAuth.js +127 -0
  180. package/lib/discord/DiscordSessions.d.ts +79 -0
  181. package/lib/discord/DiscordSessions.js +135 -0
  182. package/lib/discord/DiscordSignInConstants.d.ts +54 -0
  183. package/lib/discord/DiscordSignInConstants.js +55 -0
  184. package/lib/discord/DiscordSignInRoutes.d.ts +51 -0
  185. package/lib/discord/DiscordSignInRoutes.js +359 -0
  186. package/lib/index.d.ts +24 -0
  187. package/lib/index.js +18 -0
  188. package/lib/leaderboards/FileLeaderboardRankStore.d.ts +293 -0
  189. package/lib/leaderboards/FileLeaderboardRankStore.js +808 -0
  190. package/lib/leaderboards/LeaderboardConstants.d.ts +63 -0
  191. package/lib/leaderboards/LeaderboardConstants.js +70 -0
  192. package/lib/leaderboards/LeaderboardError.d.ts +15 -0
  193. package/lib/leaderboards/LeaderboardError.js +20 -0
  194. package/lib/leaderboards/LeaderboardIdentity.d.ts +44 -0
  195. package/lib/leaderboards/LeaderboardIdentity.js +72 -0
  196. package/lib/leaderboards/LeaderboardPublicApi.d.ts +17 -0
  197. package/lib/leaderboards/LeaderboardPublicApi.js +389 -0
  198. package/lib/leaderboards/LeaderboardRankStore.d.ts +296 -0
  199. package/lib/leaderboards/LeaderboardRankStore.js +18 -0
  200. package/lib/leaderboards/LeaderboardRules.d.ts +122 -0
  201. package/lib/leaderboards/LeaderboardRules.js +472 -0
  202. package/lib/leaderboards/LeaderboardScheduler.d.ts +43 -0
  203. package/lib/leaderboards/LeaderboardScheduler.js +103 -0
  204. package/lib/leaderboards/LeaderboardService.d.ts +669 -0
  205. package/lib/leaderboards/LeaderboardService.js +1667 -0
  206. package/lib/leaderboards/LeaderboardTransfer.d.ts +35 -0
  207. package/lib/leaderboards/LeaderboardTransfer.js +580 -0
  208. package/lib/leaderboards/RedisLeaderboardRankStore.d.ts +282 -0
  209. package/lib/leaderboards/RedisLeaderboardRankStore.js +691 -0
  210. package/package.json +64 -4
  211. package/templates/init/Caddyfile +6 -0
  212. package/templates/init/DEPLOY.md +233 -0
  213. package/templates/init/Dockerfile +12 -0
  214. package/templates/init/app.json +21 -0
  215. package/templates/init/aws/README.md +118 -0
  216. package/templates/init/aws/ecs-fargate-alb.yml +207 -0
  217. package/templates/init/aws/ecs-fargate-nlb-quic.yml +210 -0
  218. package/templates/init/babylon-game-server.config.mjs +158 -0
  219. package/templates/init/colyseus-cloud/README.md +133 -0
  220. package/templates/init/colyseus-cloud/babylon-game-server.config.mjs +36 -0
  221. package/templates/init/colyseus-cloud/ecosystem.config.cjs +35 -0
  222. package/templates/init/colyseus-cloud/gitignore +6 -0
  223. package/templates/init/colyseus-cloud/package.json +15 -0
  224. package/templates/init/digitalocean-app.yaml +30 -0
  225. package/templates/init/docker-compose.full.yml +45 -0
  226. package/templates/init/docker-compose.websocket.yml +41 -0
  227. package/templates/init/fly.toml +56 -0
  228. package/templates/init/heroku.yml +11 -0
  229. package/templates/init/kubernetes/deployment.yaml +71 -0
  230. package/templates/init/kubernetes/redis.yaml +73 -0
  231. package/templates/init/kubernetes/service.yaml +24 -0
  232. package/templates/init/monitoring/grafana-dashboard.json +539 -0
  233. package/templates/init/monitoring/prometheus-alerts.yml +51 -0
  234. package/templates/init/monitoring/prometheus-scrape.yml +17 -0
  235. package/templates/init/railway.json +13 -0
  236. package/templates/init/render.yaml +19 -0
@@ -0,0 +1,465 @@
1
+ // The fleet routes (game-server-admin-console DR §6.11 overview, instances and maintenance rows, DR §7.6, §7.7): the
2
+ // overview, the instance list and pages, drain from the console and maintenance mode, plus this instance's handlers of
3
+ // the setMaintenance, clearMaintenance and drain fleet commands (R15).
4
+ import { matchMaker } from "colyseus";
5
+ import { randomUUID } from "node:crypto";
6
+ import { getLogger } from "../GameServerLogger.js";
7
+ import { AdminRequestError } from "./AdminApiRouter.js";
8
+ import { NOTICE_TEXT_MAX_LENGTH } from "./AdminConstants.js";
9
+ import { bodyObject, booleanField, numberField, optionalStringField, stringListField } from "./AdminRequestBodies.js";
10
+ import { computeHealthWarnings } from "./HealthWarnings.js";
11
+ import { MAINTENANCE_SETTINGS_ID } from "./MaintenanceMode.js";
12
+ /** HTTP status of a successful read or change. */
13
+ const HTTP_OK = 200;
14
+ /** HTTP status of an invalid request. */
15
+ const HTTP_BAD_REQUEST = 400;
16
+ /** HTTP status of an unknown instance. */
17
+ const HTTP_NOT_FOUND = 404;
18
+ /** HTTP status of a fleet-only action without Redis (R33). */
19
+ const HTTP_CONFLICT = 409;
20
+ /** The Colyseus room type of sessions. */
21
+ const SESSION_ROOM_NAME = "fusion_session";
22
+ /** The game mode the overview counts a session under before its first join. */
23
+ const PENDING_GAME_MODE = "Pending";
24
+ /** How many refusal reasons the overview lists. */
25
+ const TOP_REFUSAL_REASON_COUNT = 5;
26
+ /** Seconds per minute, for per-minute rates. */
27
+ const SECONDS_PER_MINUTE = 60;
28
+ /** Milliseconds per second. */
29
+ const MILLISECONDS_PER_SECOND = 1000;
30
+ /** The scope value of fleet-wide maintenance and notices. */
31
+ const FLEET_SCOPE = "fleet";
32
+ /** Activity items the overview shows. */
33
+ const OVERVIEW_ACTIVITY_COUNT = 10;
34
+ /** The refusal of a fleet-only action on a single instance (R33). */
35
+ const NEEDS_REDIS_MESSAGE = "needs Redis to reach other instances";
36
+ /**
37
+ * Registers the overview, instances/* and maintenance routes.
38
+ * @param router The admin API router.
39
+ * @param services The shared services.
40
+ */
41
+ export function registerFleetRoutes(router, services) {
42
+ const routes = [
43
+ { method: "GET", pattern: "overview", permission: "fleet.view", requiresStepUp: false, auditAction: null, handler: async (request) => ({ status: HTTP_OK, body: await buildOverview(services, request) }) },
44
+ { method: "GET", pattern: "instances", permission: "fleet.view", requiresStepUp: false, auditAction: null, handler: async () => ({ status: HTTP_OK, body: await services.fleet.listInstances() }) },
45
+ { method: "GET", pattern: "instances/:processId", permission: "fleet.view", requiresStepUp: false, auditAction: null, handler: async (request) => ({ status: HTTP_OK, body: await describeInstance(services, request.params.processId) }) },
46
+ { method: "POST", pattern: "instances/:processId/drain", permission: "fleet.manage", requiresStepUp: true, auditAction: "instance.drain", handler: async (request) => drainInstance(services, request) },
47
+ { method: "GET", pattern: "maintenance", permission: "fleet.view", requiresStepUp: false, auditAction: null, handler: async () => ({ status: HTTP_OK, body: await buildMaintenanceView(services) }) },
48
+ { method: "PUT", pattern: "maintenance", permission: "fleet.manage", requiresStepUp: isFleetScope, auditAction: "maintenance.set", handler: async (request) => setMaintenance(services, request) }
49
+ ];
50
+ for (const route of routes)
51
+ router.register(route);
52
+ }
53
+ /**
54
+ * Registers this instance's handlers of the setMaintenance, clearMaintenance and drain fleet commands (R15).
55
+ * @param services The shared services.
56
+ */
57
+ export function registerFleetCommands(services) {
58
+ services.fleet.handle("setMaintenance", async (maintenance) => {
59
+ services.maintenance.setInstance(maintenance);
60
+ services.serverEvents.record("maintenance on (this instance): " + maintenance.message, services.processId());
61
+ return null;
62
+ });
63
+ services.fleet.handle("clearMaintenance", async () => {
64
+ services.maintenance.setInstance(null);
65
+ services.serverEvents.record("maintenance off (this instance)", services.processId());
66
+ return null;
67
+ });
68
+ services.fleet.handle("drain", async (request) => drainThisInstance(services, request));
69
+ }
70
+ /**
71
+ * A notice to players (DR §6.9) with a fresh id and the server's time.
72
+ * @param services The shared services (the clock).
73
+ * @param text The text (at most NOTICE_TEXT_MAX_LENGTH characters).
74
+ * @param severity How prominent it is.
75
+ * @param countdownSeconds Seconds until the announced event, or null.
76
+ * @returns The notice.
77
+ */
78
+ export function createNoticePayload(services, text, severity, countdownSeconds) {
79
+ return { noticeId: randomUUID(), text: text, severity: severity, countdownSeconds: countdownSeconds, sentAtMilliseconds: services.clock.now() };
80
+ }
81
+ /**
82
+ * Sends a notice to the sessions of this instance.
83
+ * @param state The server state.
84
+ * @param roomId One room, or null for every room of this instance.
85
+ * @param playerId One player of that room, or 0 for everyone.
86
+ * @param notice The notice.
87
+ * @returns How many players were targeted.
88
+ */
89
+ export function sendLocalNotice(state, roomId, playerId, notice) {
90
+ const rooms = Array.from(state.sessionRooms).filter((room) => roomId == null || room.roomId === roomId);
91
+ return rooms.reduce((total, room) => total + room.adminSendNotice(notice, playerId), 0);
92
+ }
93
+ /**
94
+ * Refuses a fleet-only action on a single instance (DR §9, R33).
95
+ * @param services The shared services.
96
+ * @throws AdminRequestError 409 without Redis.
97
+ */
98
+ export function requireScaledOut(services) {
99
+ if (!services.fleet.isScaledOut)
100
+ throw new AdminRequestError(HTTP_CONFLICT, NEEDS_REDIS_MESSAGE);
101
+ }
102
+ /**
103
+ * An optional notice or maintenance text, at most NOTICE_TEXT_MAX_LENGTH characters.
104
+ * @param body The body.
105
+ * @param name The field.
106
+ * @returns The text, or null.
107
+ * @throws AdminRequestError 400 when it is not text or too long.
108
+ */
109
+ export function noticeTextField(body, name) {
110
+ const value = optionalStringField(body, name);
111
+ if (value != null && value.length > NOTICE_TEXT_MAX_LENGTH)
112
+ throw new AdminRequestError(HTTP_BAD_REQUEST, name + " is longer than " + NOTICE_TEXT_MAX_LENGTH + " characters");
113
+ return value;
114
+ }
115
+ /**
116
+ * A number field that may not be negative.
117
+ * @param body The body.
118
+ * @param name The field.
119
+ * @returns The number.
120
+ * @throws AdminRequestError 400 when it is not a number or negative.
121
+ */
122
+ export function nonNegativeNumberField(body, name) {
123
+ const value = numberField(body, name);
124
+ if (value < 0)
125
+ throw new AdminRequestError(HTTP_BAD_REQUEST, name + " may not be negative");
126
+ return value;
127
+ }
128
+ /**
129
+ * Whether a request's body names the fleet scope (step-up applies to fleet maintenance only, R8).
130
+ * @param request The request.
131
+ * @returns True for scope "fleet".
132
+ */
133
+ function isFleetScope(request) {
134
+ return bodyObject(request.body).scope === FLEET_SCOPE;
135
+ }
136
+ /**
137
+ * GET overview: the fleet at a glance (spec 2).
138
+ * @param services The shared services.
139
+ * @param request The request (its security decides consoleOverHttp).
140
+ * @returns The overview.
141
+ */
142
+ async function buildOverview(services, request) {
143
+ const records = await services.fleet.listInstanceRecords();
144
+ const instances = records.map((record) => record.view);
145
+ const upRecords = records.filter((record) => record.view.status === "up");
146
+ const upInstances = upRecords.map((record) => record.view);
147
+ const rooms = await matchMaker.query({ name: SESSION_ROOM_NAME });
148
+ const gameModeTotals = totalsByGameMode(rooms);
149
+ return {
150
+ instancesUp: upInstances.length,
151
+ instancesLost: instances.length - upInstances.length,
152
+ players: sumOf(upInstances, (instance) => instance.players),
153
+ sessions: sumOf(upInstances, (instance) => instance.sessions),
154
+ playersByGameMode: gameModeTotals.players,
155
+ sessionsByGameMode: gameModeTotals.sessions,
156
+ dedicatedSessions: sumOf(upInstances, (instance) => instance.dedicatedSessions),
157
+ joinsPerMinute: sumOf(upRecords, (record) => perMinute(record, "joinsAccepted")),
158
+ refusalsPerMinute: sumOf(upRecords, (record) => perMinute(record, "joinsRefused")),
159
+ topRefusalReasons: topRefusalReasons(upRecords),
160
+ authFailuresPerMinute: sumOf(upRecords, (record) => perMinute(record, "authFailures")),
161
+ transportShare: transportShare(upInstances),
162
+ versions: versionsOf(upInstances),
163
+ certificateDaysLeft: soonestCertificateExpiry(upInstances),
164
+ redis: await redisStateOf(services),
165
+ maintenance: maintenanceSummary(upInstances),
166
+ draining: upInstances.filter((instance) => instance.draining).length,
167
+ scaledOut: services.fleet.isScaledOut,
168
+ warnings: computeHealthWarnings(services, instances, request.isSecure),
169
+ customPanels: services.extensions.panelViews("overview"),
170
+ activity: await services.audit.activity(OVERVIEW_ACTIVITY_COUNT)
171
+ };
172
+ }
173
+ /**
174
+ * GET instances/:processId: the instance, its sessions and its dedicated sessions.
175
+ * @param services The shared services.
176
+ * @param processId The instance.
177
+ * @returns The instance page.
178
+ * @throws AdminRequestError 404 for an unknown instance.
179
+ */
180
+ async function describeInstance(services, processId) {
181
+ const instances = await services.fleet.listInstances();
182
+ const instance = instances.find((candidate) => candidate.processId === processId);
183
+ if (instance == null)
184
+ throw new AdminRequestError(HTTP_NOT_FOUND, "no such instance");
185
+ const sessions = (instance.status === "up") ? await services.fleet.runOn(processId, "describeSessions", null) : [];
186
+ // Dedicated session rows arrive with the dedicated sessions page (T9).
187
+ return { instance: instance, sessions: sessions, dedicated: [] };
188
+ }
189
+ /**
190
+ * POST instances/:processId/drain (DR §7.7).
191
+ * @param services The shared services.
192
+ * @param request The request.
193
+ * @returns The drain deadline.
194
+ */
195
+ async function drainInstance(services, request) {
196
+ const body = bodyObject(request.body);
197
+ const noticeText = noticeTextField(body, "noticeText");
198
+ const noticeLeadSeconds = (body.noticeLeadSeconds == null) ? 0 : nonNegativeNumberField(body, "noticeLeadSeconds");
199
+ const processId = request.params.processId;
200
+ const started = await services.fleet.runOn(processId, "drain", { noticeText: noticeText, noticeLeadSeconds: noticeLeadSeconds });
201
+ return { status: HTTP_OK, body: started, audit: { target: processId, parameters: { noticeText: noticeText, noticeLeadSeconds: noticeLeadSeconds } } };
202
+ }
203
+ /**
204
+ * The drain command on this instance (DR §7.7): the notice goes to every session first, the drain starts after
205
+ * noticeLeadSeconds, and drain keeps its one-way meaning. A second drain answers the running drain's deadline.
206
+ * @param services The shared services.
207
+ * @param request The notice and its lead time.
208
+ * @returns When the remaining sessions will be closed.
209
+ */
210
+ async function drainThisInstance(services, request) {
211
+ const runningDeadline = services.routeContext.drainDeadline();
212
+ if (runningDeadline != null)
213
+ return { drainDeadline: runningDeadline };
214
+ const leadSeconds = Math.max(0, request.noticeLeadSeconds);
215
+ const closesInSeconds = leadSeconds + services.config.drainSeconds;
216
+ if (request.noticeText != null && request.noticeText !== "") {
217
+ sendLocalNotice(services.state, null, 0, createNoticePayload(services, request.noticeText, "warning", closesInSeconds));
218
+ }
219
+ if (leadSeconds === 0) {
220
+ startDrain(services);
221
+ return { drainDeadline: services.routeContext.drainDeadline() ?? isoAfter(services, closesInSeconds) };
222
+ }
223
+ services.clock.setTimeout(() => startDrain(services), leadSeconds * MILLISECONDS_PER_SECOND);
224
+ return { drainDeadline: isoAfter(services, closesInSeconds) };
225
+ }
226
+ /**
227
+ * Starts this instance's drain in the background; a failure is logged.
228
+ * @param services The shared services.
229
+ */
230
+ function startDrain(services) {
231
+ services.serverEvents.record("drain started", services.processId());
232
+ services.routeContext.drain().catch((error) => getLogger().error("drain failed", { error: String(error) }));
233
+ }
234
+ /**
235
+ * GET maintenance (and the answer of PUT maintenance).
236
+ * @param services The shared services.
237
+ * @returns The fleet setting and each instance's maintenance.
238
+ */
239
+ async function buildMaintenanceView(services) {
240
+ const stored = await services.store.getDocument("settings", MAINTENANCE_SETTINGS_ID);
241
+ const instances = await services.fleet.listInstances();
242
+ const rows = instances.map((instance) => ({
243
+ processId: instance.processId,
244
+ // This instance answers with its state now; the others with their last heartbeat.
245
+ maintenance: instance.isThisInstance ? services.maintenance.current() : instance.maintenance
246
+ }));
247
+ return { fleet: stored?.value ?? null, instances: rows, windows: await services.maintenanceWindows.list() };
248
+ }
249
+ /**
250
+ * PUT maintenance (DR §7.6): fleet scope stores the setting every instance applies (Redis only, after an optional
251
+ * warning notice); an instance scope sets that instance's own maintenance.
252
+ * @param services The shared services.
253
+ * @param request The request.
254
+ * @returns The maintenance view.
255
+ */
256
+ async function setMaintenance(services, request) {
257
+ const body = bodyObject(request.body);
258
+ const scope = requiredText(body, "scope");
259
+ const enabled = booleanField(body, "enabled");
260
+ const settings = {
261
+ enabled: enabled,
262
+ message: noticeTextField(body, "message") ?? "",
263
+ allowUserIds: (body.allowUserIds == null) ? [] : stringListField(body, "allowUserIds"),
264
+ allowAddresses: (body.allowAddresses == null) ? [] : stringListField(body, "allowAddresses"),
265
+ leaveReadiness: (body.leaveReadiness == null) ? false : booleanField(body, "leaveReadiness")
266
+ };
267
+ const noticeSeconds = (body.noticeSeconds == null) ? null : nonNegativeNumberField(body, "noticeSeconds");
268
+ if (scope === FLEET_SCOPE)
269
+ await setFleetMaintenance(services, settings, noticeSeconds);
270
+ else
271
+ await setInstanceMaintenance(services, scope, settings);
272
+ const by = request.caller?.name ?? "unknown";
273
+ services.extensions.callHook("onMaintenanceChanged", { enabled: enabled, scope: scope, message: enabled ? settings.message : null, by: by });
274
+ const parameters = { ...settings, noticeSeconds: noticeSeconds };
275
+ return { status: HTTP_OK, body: await buildMaintenanceView(services), audit: { target: scope, parameters: parameters } };
276
+ }
277
+ /**
278
+ * Turns fleet maintenance on or off: stores (or deletes) settings/maintenance and announces it, so every instance
279
+ * applies it. This instance applies it at once, so the answer already shows it.
280
+ * @param services The shared services.
281
+ * @param settings The setting.
282
+ * @param noticeSeconds Seconds of warning notice sent to every player first, or null.
283
+ * @throws AdminRequestError 409 without Redis (R33).
284
+ */
285
+ async function setFleetMaintenance(services, settings, noticeSeconds) {
286
+ requireScaledOut(services);
287
+ if (settings.enabled && noticeSeconds != null && noticeSeconds > 0) {
288
+ const notice = createNoticePayload(services, settings.message, "warning", noticeSeconds);
289
+ await services.fleet.runOnAll("sendNotice", { roomId: null, playerId: 0, notice: notice });
290
+ }
291
+ if (settings.enabled)
292
+ await services.store.putDocument("settings", MAINTENANCE_SETTINGS_ID, settings, null);
293
+ else
294
+ await services.store.deleteDocument("settings", MAINTENANCE_SETTINGS_ID);
295
+ await services.maintenance.reloadFleetSetting(services.store);
296
+ await services.fleet.announceChange("settings");
297
+ services.serverEvents.record(settings.enabled ? "maintenance on (fleet): " + settings.message : "maintenance off (fleet)", null);
298
+ }
299
+ /**
300
+ * Turns one instance's own maintenance on or off through the fleet bus.
301
+ * @param services The shared services.
302
+ * @param processId The instance.
303
+ * @param settings The setting.
304
+ */
305
+ async function setInstanceMaintenance(services, processId, settings) {
306
+ if (!settings.enabled) {
307
+ await services.fleet.runOn(processId, "clearMaintenance", null);
308
+ return;
309
+ }
310
+ const maintenance = {
311
+ message: settings.message,
312
+ allowUserIds: settings.allowUserIds,
313
+ allowAddresses: settings.allowAddresses,
314
+ leaveReadiness: settings.leaveReadiness,
315
+ scope: "instance",
316
+ endsAt: null
317
+ };
318
+ await services.fleet.runOn(processId, "setMaintenance", maintenance);
319
+ }
320
+ /**
321
+ * Players and sessions per game mode across the fleet, from the matchmaker's listing.
322
+ * @param rooms The session rooms of every instance.
323
+ * @returns The totals, keyed by game mode name.
324
+ */
325
+ function totalsByGameMode(rooms) {
326
+ const players = {};
327
+ const sessions = {};
328
+ for (const room of rooms) {
329
+ const gameMode = (typeof room.metadata?.gameMode === "string") ? room.metadata.gameMode : PENDING_GAME_MODE;
330
+ players[gameMode] = (players[gameMode] ?? 0) + room.clients;
331
+ sessions[gameMode] = (sessions[gameMode] ?? 0) + 1;
332
+ }
333
+ return { players: players, sessions: sessions };
334
+ }
335
+ /**
336
+ * A counter's per-minute rate between an instance's last two heartbeats (DR §8): never negative.
337
+ * @param record The instance with its earlier heartbeat.
338
+ * @param counter The counter.
339
+ * @returns The rate, or 0 without an earlier heartbeat.
340
+ */
341
+ function perMinute(record, counter) {
342
+ return rateBetween(record.previous, record.view, (heartbeat) => heartbeat[counter]);
343
+ }
344
+ /**
345
+ * The per-minute rate of a value between two heartbeats; a drop (a restart) counts as 0.
346
+ * @param previous The earlier heartbeat, or null.
347
+ * @param current The newer heartbeat.
348
+ * @param read Reads the counter.
349
+ * @returns The rate per minute.
350
+ */
351
+ function rateBetween(previous, current, read) {
352
+ if (previous == null)
353
+ return 0;
354
+ const seconds = (Date.parse(current.sentAt) - Date.parse(previous.sentAt)) / MILLISECONDS_PER_SECOND;
355
+ const growth = read(current) - read(previous);
356
+ if (seconds <= 0 || growth <= 0)
357
+ return 0;
358
+ return (growth / seconds) * SECONDS_PER_MINUTE;
359
+ }
360
+ /**
361
+ * The refusal reasons with the highest fleet rate.
362
+ * @param upRecords The live instances.
363
+ * @returns At most TOP_REFUSAL_REASON_COUNT reasons with a rate above 0, highest first.
364
+ */
365
+ function topRefusalReasons(upRecords) {
366
+ const rates = new Map();
367
+ for (const record of upRecords) {
368
+ for (const reason of Object.keys(record.view.refusalsByReason)) {
369
+ const rate = rateBetween(record.previous, record.view, (heartbeat) => heartbeat.refusalsByReason[reason] ?? 0);
370
+ rates.set(reason, (rates.get(reason) ?? 0) + rate);
371
+ }
372
+ }
373
+ const reasons = Array.from(rates, ([reason, perMinuteRate]) => ({ reason: reason, perMinute: perMinuteRate }));
374
+ return reasons.filter((entry) => entry.perMinute > 0).sort((left, right) => right.perMinute - left.perMinute).slice(0, TOP_REFUSAL_REASON_COUNT);
375
+ }
376
+ /**
377
+ * The share of frames per transport across the fleet (fractions that add up to 1, or both 0 before any frame).
378
+ * @param upInstances The live instances.
379
+ * @returns The shares.
380
+ */
381
+ function transportShare(upInstances) {
382
+ const websocket = sumOf(upInstances, (instance) => instance.framesWebsocket);
383
+ const webtransport = sumOf(upInstances, (instance) => instance.framesWebtransport);
384
+ const total = websocket + webtransport;
385
+ if (total === 0)
386
+ return { websocket: 0, webtransport: 0 };
387
+ return { websocket: websocket / total, webtransport: webtransport / total };
388
+ }
389
+ /**
390
+ * The server and protocol versions the live instances run.
391
+ * @param upInstances The live instances.
392
+ * @returns One row per version pair, with its instance count.
393
+ */
394
+ function versionsOf(upInstances) {
395
+ const rows = new Map();
396
+ for (const instance of upInstances) {
397
+ const key = instance.serverVersion + "/" + instance.protocolVersion;
398
+ const row = rows.get(key) ?? { serverVersion: instance.serverVersion, protocolVersion: instance.protocolVersion, instances: 0 };
399
+ row.instances += 1;
400
+ rows.set(key, row);
401
+ }
402
+ return Array.from(rows.values());
403
+ }
404
+ /**
405
+ * The fewest certificate days left among the live instances.
406
+ * @param upInstances The live instances.
407
+ * @returns The days, or null when no instance serves TLS.
408
+ */
409
+ function soonestCertificateExpiry(upInstances) {
410
+ const days = upInstances.map((instance) => instance.certificateDaysLeft).filter((value) => value != null);
411
+ return (days.length > 0) ? Math.min(...days) : null;
412
+ }
413
+ /**
414
+ * Whether Redis connects the fleet and answers.
415
+ * @param services The shared services.
416
+ * @returns "not configured", "reachable" or "unreachable".
417
+ */
418
+ async function redisStateOf(services) {
419
+ if (services.config.redis.url == null)
420
+ return "not configured";
421
+ return (await services.routeContext.isRedisReachable()) ? "reachable" : "unreachable";
422
+ }
423
+ /**
424
+ * Which maintenance is in force across the live instances.
425
+ * @param upInstances The live instances.
426
+ * @returns "fleet", "some instances" or "off".
427
+ */
428
+ function maintenanceSummary(upInstances) {
429
+ if (upInstances.some((instance) => instance.maintenance?.scope === "fleet"))
430
+ return "fleet";
431
+ if (upInstances.some((instance) => instance.maintenance != null))
432
+ return "some instances";
433
+ return "off";
434
+ }
435
+ /**
436
+ * Sums a value over items.
437
+ * @param items The items.
438
+ * @param read Reads each item's value.
439
+ * @returns The sum.
440
+ */
441
+ function sumOf(items, read) {
442
+ return items.reduce((total, item) => total + read(item), 0);
443
+ }
444
+ /**
445
+ * An ISO time some seconds from the admin clock's now.
446
+ * @param services The shared services.
447
+ * @param seconds Seconds from now.
448
+ * @returns The ISO time.
449
+ */
450
+ function isoAfter(services, seconds) {
451
+ return new Date(services.clock.now() + seconds * MILLISECONDS_PER_SECOND).toISOString();
452
+ }
453
+ /**
454
+ * A required non-empty text field.
455
+ * @param body The body.
456
+ * @param name The field.
457
+ * @returns The text.
458
+ * @throws AdminRequestError 400 when it is missing or empty.
459
+ */
460
+ function requiredText(body, name) {
461
+ const value = optionalStringField(body, name);
462
+ if (value == null || value === "")
463
+ throw new AdminRequestError(HTTP_BAD_REQUEST, name + " must be text");
464
+ return value;
465
+ }
@@ -0,0 +1,10 @@
1
+ import type { HealthWarningView, InstanceView } from "./AdminApiTypes.js";
2
+ import type { AdminServices } from "./AdminServices.js";
3
+ /**
4
+ * The health warnings of the fleet as the requesting console sees it.
5
+ * @param services The shared services.
6
+ * @param instances Every known instance.
7
+ * @param requestIsSecure True when the console request arrived over TLS (R9).
8
+ * @returns The warnings, most severe first.
9
+ */
10
+ export declare function computeHealthWarnings(services: AdminServices, instances: InstanceView[], requestIsSecure: boolean): HealthWarningView[];
@@ -0,0 +1,95 @@
1
+ // The overview's health warnings (game-server-admin-console spec 2, DR §6.11 HealthWarningView, DR §9): each names a
2
+ // risky setting or a fleet problem and the console page that fixes it.
3
+ import { isProductionEnvironment } from "../GameServerConfig.js";
4
+ /** Fewer days than this before a certificate expires raises certificateExpiring (DR §8 alert default). */
5
+ const CERTIFICATE_WARNING_DAYS = 14;
6
+ /** Console route of the instances page. */
7
+ const INSTANCES_ROUTE = "#/instances";
8
+ /**
9
+ * The health warnings of the fleet as the requesting console sees it.
10
+ * @param services The shared services.
11
+ * @param instances Every known instance.
12
+ * @param requestIsSecure True when the console request arrived over TLS (R9).
13
+ * @returns The warnings, most severe first.
14
+ */
15
+ export function computeHealthWarnings(services, instances, requestIsSecure) {
16
+ const upInstances = instances.filter((instance) => instance.status === "up");
17
+ const candidates = [
18
+ authOffWarning(services),
19
+ lostInstanceWarning(instances),
20
+ originsOpenWarning(services),
21
+ metricsWarning(services),
22
+ requestIsSecure ? null : { code: "consoleOverHttp", severity: "warning", message: "this console is open over plain HTTP; serve it over HTTPS", fixRoute: "#/config?path=tls.enabled" },
23
+ certificateWarning(upInstances),
24
+ mixedVersionsWarning(upInstances),
25
+ services.store.isPersistent ? null : { code: "storeNotPersistent", severity: "warning", message: "admin data is stored inside the container and will be lost on restart", fixRoute: "#/config?path=admin.dataDir" }
26
+ ];
27
+ return candidates.filter((warning) => warning != null);
28
+ }
29
+ /**
30
+ * authOffInProduction: NODE_ENV=production with player authentication off.
31
+ * @param services The shared services.
32
+ * @returns The warning, or null.
33
+ */
34
+ function authOffWarning(services) {
35
+ if (!isProductionEnvironment(process.env) || services.config.auth.enabled)
36
+ return null;
37
+ return { code: "authOffInProduction", severity: "critical", message: "player authentication is off on a production server", fixRoute: "#/config?path=auth.enabled" };
38
+ }
39
+ /**
40
+ * instanceLost: an instance stopped sending heartbeats.
41
+ * @param instances Every known instance.
42
+ * @returns The warning, or null.
43
+ */
44
+ function lostInstanceWarning(instances) {
45
+ const lostCount = instances.filter((instance) => instance.status === "lost").length;
46
+ if (lostCount === 0)
47
+ return null;
48
+ return { code: "instanceLost", severity: "critical", message: lostCount + " instance(s) stopped sending heartbeats", fixRoute: INSTANCES_ROUTE };
49
+ }
50
+ /**
51
+ * originsOpen: limits.allowedOrigins is empty, so any website can connect players.
52
+ * @param services The shared services.
53
+ * @returns The warning, or null.
54
+ */
55
+ function originsOpenWarning(services) {
56
+ if (services.config.limits.allowedOrigins.length > 0)
57
+ return null;
58
+ return { code: "originsOpen", severity: "warning", message: "any website can connect players to this server; list your game's origins", fixRoute: "#/config?path=limits.allowedOrigins" };
59
+ }
60
+ /**
61
+ * metricsUnprotected: the Prometheus endpoint is on and needs no admin token.
62
+ * @param services The shared services.
63
+ * @returns The warning, or null.
64
+ */
65
+ function metricsWarning(services) {
66
+ if (!services.config.metrics.enabled || services.config.metrics.requireAdminToken)
67
+ return null;
68
+ return { code: "metricsUnprotected", severity: "warning", message: "the Prometheus metrics at /metrics are open to anyone", fixRoute: "#/config?path=metrics.requireAdminToken" };
69
+ }
70
+ /**
71
+ * certificateExpiring: an up instance's certificate has fewer than CERTIFICATE_WARNING_DAYS days left.
72
+ * @param upInstances The live instances.
73
+ * @returns The warning, or null.
74
+ */
75
+ function certificateWarning(upInstances) {
76
+ const daysLeft = upInstances.map((instance) => instance.certificateDaysLeft).filter((days) => days != null);
77
+ if (daysLeft.length === 0)
78
+ return null;
79
+ const soonest = Math.min(...daysLeft);
80
+ if (soonest >= CERTIFICATE_WARNING_DAYS)
81
+ return null;
82
+ const message = (soonest < 0) ? "a TLS certificate has expired" : "a TLS certificate expires in " + soonest + " day(s)";
83
+ return { code: "certificateExpiring", severity: (soonest < 0) ? "critical" : "warning", message: message, fixRoute: INSTANCES_ROUTE };
84
+ }
85
+ /**
86
+ * mixedVersions: the live instances run more than one server version.
87
+ * @param upInstances The live instances.
88
+ * @returns The warning, or null.
89
+ */
90
+ function mixedVersionsWarning(upInstances) {
91
+ const versions = new Set(upInstances.map((instance) => instance.serverVersion));
92
+ if (versions.size <= 1)
93
+ return null;
94
+ return { code: "mixedVersions", severity: "warning", message: "instances run different server versions: " + Array.from(versions).join(", "), fixRoute: INSTANCES_ROUTE };
95
+ }
@@ -0,0 +1,8 @@
1
+ import type { AdminApiRouter } from "./AdminApiRouter.js";
2
+ import type { AdminServices } from "./AdminServices.js";
3
+ /**
4
+ * Registers every leaderboards/* route (DR §6 admin table).
5
+ * @param router The admin API router.
6
+ * @param services The shared services.
7
+ */
8
+ export declare function registerLeaderboardRoutes(router: AdminApiRouter, services: AdminServices): void;