@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.
- 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,321 @@
|
|
|
1
|
+
// Rolling updates (game-server-admin-console D24, DR §7.8): the fleet leader drains one instance at a time and waits for
|
|
2
|
+
// a replacement (a new instance, not in the rollout's starting set, ready and at least the starting version) before the
|
|
3
|
+
// next step; a step without a replacement within perInstanceTimeoutMinutes fails and pauses the rollout. The state lives
|
|
4
|
+
// in the "rollouts" collection, so a new leader continues where the old one stopped. Machines are never started or
|
|
5
|
+
// stopped here: the platform replaces drained instances.
|
|
6
|
+
import { randomUUID } from "node:crypto";
|
|
7
|
+
import { AdminRequestError } from "./AdminApiRouter.js";
|
|
8
|
+
import { AdminConflictError } from "./AdminStore.js";
|
|
9
|
+
/** HTTP statuses of the refusals. */
|
|
10
|
+
const HTTP_BAD_REQUEST = 400;
|
|
11
|
+
const HTTP_NOT_FOUND = 404;
|
|
12
|
+
const HTTP_CONFLICT = 409;
|
|
13
|
+
/** Milliseconds per second and per minute. */
|
|
14
|
+
const MILLISECONDS_PER_SECOND = 1000;
|
|
15
|
+
const MILLISECONDS_PER_MINUTE = 60000;
|
|
16
|
+
/** The refusal of a rollout on a single instance (DR §7.8). */
|
|
17
|
+
const NEEDS_TWO_INSTANCES_MESSAGE = "a rolling update needs at least two instances; use maintenance and drain instead";
|
|
18
|
+
/** Rollout states that still hold the fleet. */
|
|
19
|
+
const UNFINISHED_STATES = new Set(["running", "paused"]);
|
|
20
|
+
/** Step states that are finished. */
|
|
21
|
+
const FINISHED_STEP_STATES = new Set(["replaced", "failed"]);
|
|
22
|
+
/** Starts, steers and drives the fleet's rolling updates. */
|
|
23
|
+
export class RolloutCoordinator {
|
|
24
|
+
/** The shared core services. */
|
|
25
|
+
services;
|
|
26
|
+
/** The fleet directory (instances and the drain command). */
|
|
27
|
+
fleet;
|
|
28
|
+
/** The activity feed's server events. */
|
|
29
|
+
serverEvents;
|
|
30
|
+
/**
|
|
31
|
+
* Creates the coordinator; nothing runs until the leader ticks.
|
|
32
|
+
* @param services The shared core services.
|
|
33
|
+
* @param fleet The fleet directory.
|
|
34
|
+
* @param serverEvents The activity feed's server events.
|
|
35
|
+
*/
|
|
36
|
+
constructor(services, fleet, serverEvents) {
|
|
37
|
+
this.services = services;
|
|
38
|
+
this.fleet = fleet;
|
|
39
|
+
this.serverEvents = serverEvents;
|
|
40
|
+
}
|
|
41
|
+
/**
|
|
42
|
+
* Every rollout, newest first.
|
|
43
|
+
* @returns The rollouts.
|
|
44
|
+
*/
|
|
45
|
+
async list() {
|
|
46
|
+
const records = (await this.storedRollouts()).map((stored) => stored.value);
|
|
47
|
+
return records.sort((left, right) => right.startedAt.localeCompare(left.startedAt)).map(rolloutView);
|
|
48
|
+
}
|
|
49
|
+
/**
|
|
50
|
+
* Starts a rollout over the live instances (POST rollouts). The instance that starts it goes last, so it keeps
|
|
51
|
+
* answering the console for as long as possible.
|
|
52
|
+
* @param request The notice and timeouts.
|
|
53
|
+
* @param startedBy Who starts it.
|
|
54
|
+
* @returns The rollout.
|
|
55
|
+
* @throws AdminRequestError 409 with fewer than two live instances or while another rollout is unfinished, 400 for
|
|
56
|
+
* negative times.
|
|
57
|
+
*/
|
|
58
|
+
async start(request, startedBy) {
|
|
59
|
+
if (request.noticeLeadSeconds < 0 || request.perInstanceTimeoutMinutes <= 0)
|
|
60
|
+
throw new AdminRequestError(HTTP_BAD_REQUEST, "noticeLeadSeconds may not be negative and perInstanceTimeoutMinutes must be above 0");
|
|
61
|
+
const upInstances = (await this.fleet.listInstances()).filter((instance) => instance.status === "up");
|
|
62
|
+
if (upInstances.length < 2)
|
|
63
|
+
throw new AdminRequestError(HTTP_CONFLICT, NEEDS_TWO_INSTANCES_MESSAGE);
|
|
64
|
+
const unfinished = (await this.list()).some((rollout) => UNFINISHED_STATES.has(rollout.state));
|
|
65
|
+
if (unfinished)
|
|
66
|
+
throw new AdminRequestError(HTTP_CONFLICT, "a rolling update is already running; pause, resume or cancel it first");
|
|
67
|
+
const ordered = upInstances.filter((instance) => !instance.isThisInstance).concat(upInstances.filter((instance) => instance.isThisInstance));
|
|
68
|
+
const record = {
|
|
69
|
+
rolloutId: randomUUID(),
|
|
70
|
+
state: "running",
|
|
71
|
+
startedAt: new Date(this.services.clock.now()).toISOString(),
|
|
72
|
+
startedBy: startedBy,
|
|
73
|
+
startingProcessIds: ordered.map((instance) => instance.processId),
|
|
74
|
+
startingVersion: lowestVersion(ordered.map((instance) => instance.serverVersion)),
|
|
75
|
+
noticeText: request.noticeText,
|
|
76
|
+
noticeLeadSeconds: request.noticeLeadSeconds,
|
|
77
|
+
perInstanceTimeoutMinutes: request.perInstanceTimeoutMinutes,
|
|
78
|
+
steps: ordered.map((instance) => ({ processId: instance.processId, state: "waiting", replacementProcessId: null, message: null })),
|
|
79
|
+
stepStartedAt: null,
|
|
80
|
+
message: null
|
|
81
|
+
};
|
|
82
|
+
await this.services.store.putDocument("rollouts", record.rolloutId, record, 0);
|
|
83
|
+
return rolloutView(record);
|
|
84
|
+
}
|
|
85
|
+
/**
|
|
86
|
+
* Pauses, resumes or cancels a rollout. Resuming a failed step gives it another perInstanceTimeoutMinutes.
|
|
87
|
+
* @param rolloutId The rollout.
|
|
88
|
+
* @param command The command.
|
|
89
|
+
* @returns The rollout.
|
|
90
|
+
* @throws AdminRequestError 404 for an unknown rollout or command, 409 when the rollout cannot take the command.
|
|
91
|
+
*/
|
|
92
|
+
async command(rolloutId, command) {
|
|
93
|
+
const stored = await this.services.store.getDocument("rollouts", rolloutId);
|
|
94
|
+
if (stored == null)
|
|
95
|
+
throw new AdminRequestError(HTTP_NOT_FOUND, "no such rolling update");
|
|
96
|
+
const record = structuredClone(stored.value);
|
|
97
|
+
if (command === "pause")
|
|
98
|
+
pauseRollout(record);
|
|
99
|
+
else if (command === "resume")
|
|
100
|
+
this.resumeRollout(record);
|
|
101
|
+
else if (command === "cancel")
|
|
102
|
+
cancelRollout(record);
|
|
103
|
+
else
|
|
104
|
+
throw new AdminRequestError(HTTP_NOT_FOUND, "no such rolling update command");
|
|
105
|
+
await this.services.store.putDocument("rollouts", rolloutId, record, stored.revision);
|
|
106
|
+
return rolloutView(record);
|
|
107
|
+
}
|
|
108
|
+
/** The leader tick: moves every running rollout on by one decision. */
|
|
109
|
+
async tick() {
|
|
110
|
+
for (const stored of await this.storedRollouts()) {
|
|
111
|
+
if (stored.value.state !== "running")
|
|
112
|
+
continue;
|
|
113
|
+
const record = structuredClone(stored.value);
|
|
114
|
+
await this.advance(record);
|
|
115
|
+
await this.saveIfChanged(stored, record);
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
/**
|
|
119
|
+
* Runs the current step of a running rollout (DR §7.8 steps 1–4), or finishes the rollout.
|
|
120
|
+
* @param record The rollout, changed in place.
|
|
121
|
+
*/
|
|
122
|
+
async advance(record) {
|
|
123
|
+
const step = record.steps.find((candidate) => !FINISHED_STEP_STATES.has(candidate.state));
|
|
124
|
+
if (step == null) {
|
|
125
|
+
record.state = "done";
|
|
126
|
+
record.stepStartedAt = null;
|
|
127
|
+
this.serverEvents.record("rolling update finished", null);
|
|
128
|
+
return;
|
|
129
|
+
}
|
|
130
|
+
const now = this.services.clock.now();
|
|
131
|
+
if (step.state === "waiting") {
|
|
132
|
+
await this.startDrain(record, step, now);
|
|
133
|
+
return;
|
|
134
|
+
}
|
|
135
|
+
const stepStartedAt = Date.parse(record.stepStartedAt ?? new Date(now).toISOString());
|
|
136
|
+
if (step.state === "noticeSent" && now >= stepStartedAt + record.noticeLeadSeconds * MILLISECONDS_PER_SECOND)
|
|
137
|
+
step.state = "draining";
|
|
138
|
+
await this.checkReplacement(record, step, now, stepStartedAt);
|
|
139
|
+
}
|
|
140
|
+
/**
|
|
141
|
+
* Sends a step's drain (with the rollout's notice) to its instance.
|
|
142
|
+
* @param record The rollout.
|
|
143
|
+
* @param step The step.
|
|
144
|
+
* @param now The clock time.
|
|
145
|
+
*/
|
|
146
|
+
async startDrain(record, step, now) {
|
|
147
|
+
record.stepStartedAt = new Date(now).toISOString();
|
|
148
|
+
try {
|
|
149
|
+
await this.fleet.runOn(step.processId, "drain", { noticeText: record.noticeText, noticeLeadSeconds: record.noticeLeadSeconds });
|
|
150
|
+
}
|
|
151
|
+
catch (error) {
|
|
152
|
+
// An instance that is already gone needs no drain: its replacement is all the step waits for.
|
|
153
|
+
if (!(error instanceof AdminRequestError && error.status === HTTP_NOT_FOUND)) {
|
|
154
|
+
failStep(record, step, "the drain could not start: " + ((error instanceof Error) ? error.message : String(error)));
|
|
155
|
+
return;
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
const hasNotice = record.noticeText != null && record.noticeText !== "" && record.noticeLeadSeconds > 0;
|
|
159
|
+
step.state = hasNotice ? "noticeSent" : "draining";
|
|
160
|
+
this.serverEvents.record("rolling update drains " + step.processId, step.processId);
|
|
161
|
+
}
|
|
162
|
+
/**
|
|
163
|
+
* Marks a step replaced once its instance is gone and a replacement is ready, or fails it after the timeout.
|
|
164
|
+
* @param record The rollout.
|
|
165
|
+
* @param step The step.
|
|
166
|
+
* @param now The clock time.
|
|
167
|
+
* @param stepStartedAt When the step's drain started, in milliseconds.
|
|
168
|
+
*/
|
|
169
|
+
async checkReplacement(record, step, now, stepStartedAt) {
|
|
170
|
+
const instances = await this.fleet.listInstances();
|
|
171
|
+
const target = instances.find((instance) => instance.processId === step.processId);
|
|
172
|
+
const isGone = target == null || target.status === "lost";
|
|
173
|
+
const replacement = isGone ? this.findReplacement(record, instances) : undefined;
|
|
174
|
+
if (replacement != null) {
|
|
175
|
+
step.state = "replaced";
|
|
176
|
+
step.replacementProcessId = replacement.processId;
|
|
177
|
+
record.stepStartedAt = null;
|
|
178
|
+
return;
|
|
179
|
+
}
|
|
180
|
+
if (now - stepStartedAt > record.perInstanceTimeoutMinutes * MILLISECONDS_PER_MINUTE) {
|
|
181
|
+
failStep(record, step, "no replacement for " + step.processId + " within " + record.perInstanceTimeoutMinutes + " minutes");
|
|
182
|
+
this.serverEvents.record("rolling update paused: " + (step.message ?? ""), step.processId);
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
/**
|
|
186
|
+
* A ready instance that joined after the rollout started, runs at least its starting version and replaces no other
|
|
187
|
+
* step yet.
|
|
188
|
+
* @param record The rollout.
|
|
189
|
+
* @param instances The fleet now.
|
|
190
|
+
* @returns The replacement, or undefined.
|
|
191
|
+
*/
|
|
192
|
+
findReplacement(record, instances) {
|
|
193
|
+
const usedReplacements = new Set(record.steps.map((step) => step.replacementProcessId));
|
|
194
|
+
return instances.find((instance) => instance.status === "up" && instance.ready && !record.startingProcessIds.includes(instance.processId) && !usedReplacements.has(instance.processId)
|
|
195
|
+
&& compareVersions(instance.serverVersion, record.startingVersion) >= 0);
|
|
196
|
+
}
|
|
197
|
+
/**
|
|
198
|
+
* Resumes a paused rollout; its failed step waits for a replacement again.
|
|
199
|
+
* @param record The rollout, changed in place.
|
|
200
|
+
* @throws AdminRequestError 409 unless it is paused.
|
|
201
|
+
*/
|
|
202
|
+
resumeRollout(record) {
|
|
203
|
+
if (record.state !== "paused")
|
|
204
|
+
throw new AdminRequestError(HTTP_CONFLICT, "only a paused rolling update can be resumed");
|
|
205
|
+
record.state = "running";
|
|
206
|
+
record.message = null;
|
|
207
|
+
const failed = record.steps.find((step) => step.state === "failed");
|
|
208
|
+
if (failed == null)
|
|
209
|
+
return;
|
|
210
|
+
failed.state = "draining";
|
|
211
|
+
failed.message = null;
|
|
212
|
+
record.stepStartedAt = new Date(this.services.clock.now()).toISOString();
|
|
213
|
+
}
|
|
214
|
+
/**
|
|
215
|
+
* Stores a rollout the tick changed; a pause or cancel stored meanwhile wins and the change is dropped.
|
|
216
|
+
* @param stored The rollout as read.
|
|
217
|
+
* @param record The rollout after the tick.
|
|
218
|
+
*/
|
|
219
|
+
async saveIfChanged(stored, record) {
|
|
220
|
+
if (JSON.stringify(record) === JSON.stringify(stored.value))
|
|
221
|
+
return;
|
|
222
|
+
try {
|
|
223
|
+
await this.services.store.putDocument("rollouts", stored.id, record, stored.revision);
|
|
224
|
+
}
|
|
225
|
+
catch (error) {
|
|
226
|
+
if (!(error instanceof AdminConflictError))
|
|
227
|
+
throw error;
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
/**
|
|
231
|
+
* Every stored rollout.
|
|
232
|
+
* @returns The documents.
|
|
233
|
+
*/
|
|
234
|
+
storedRollouts() {
|
|
235
|
+
return this.services.store.listDocuments("rollouts");
|
|
236
|
+
}
|
|
237
|
+
}
|
|
238
|
+
/**
|
|
239
|
+
* Fails a step and pauses its rollout.
|
|
240
|
+
* @param record The rollout, changed in place.
|
|
241
|
+
* @param step The step.
|
|
242
|
+
* @param message Why.
|
|
243
|
+
*/
|
|
244
|
+
function failStep(record, step, message) {
|
|
245
|
+
step.state = "failed";
|
|
246
|
+
step.message = message;
|
|
247
|
+
record.state = "paused";
|
|
248
|
+
record.message = message;
|
|
249
|
+
}
|
|
250
|
+
/**
|
|
251
|
+
* Pauses a running rollout.
|
|
252
|
+
* @param record The rollout, changed in place.
|
|
253
|
+
* @throws AdminRequestError 409 unless it is running.
|
|
254
|
+
*/
|
|
255
|
+
function pauseRollout(record) {
|
|
256
|
+
if (record.state !== "running")
|
|
257
|
+
throw new AdminRequestError(HTTP_CONFLICT, "only a running rolling update can be paused");
|
|
258
|
+
record.state = "paused";
|
|
259
|
+
}
|
|
260
|
+
/**
|
|
261
|
+
* Cancels an unfinished rollout; instances already draining keep draining.
|
|
262
|
+
* @param record The rollout, changed in place.
|
|
263
|
+
* @throws AdminRequestError 409 when it is already finished.
|
|
264
|
+
*/
|
|
265
|
+
function cancelRollout(record) {
|
|
266
|
+
if (!UNFINISHED_STATES.has(record.state))
|
|
267
|
+
throw new AdminRequestError(HTTP_CONFLICT, "this rolling update is already finished");
|
|
268
|
+
record.state = "cancelled";
|
|
269
|
+
record.stepStartedAt = null;
|
|
270
|
+
}
|
|
271
|
+
/**
|
|
272
|
+
* A rollout as the console sees it.
|
|
273
|
+
* @param record The stored rollout.
|
|
274
|
+
* @returns The view.
|
|
275
|
+
*/
|
|
276
|
+
function rolloutView(record) {
|
|
277
|
+
return {
|
|
278
|
+
rolloutId: record.rolloutId,
|
|
279
|
+
state: record.state,
|
|
280
|
+
startedAt: record.startedAt,
|
|
281
|
+
startedBy: record.startedBy,
|
|
282
|
+
steps: record.steps.map((step) => Object.assign({}, step)),
|
|
283
|
+
message: record.message
|
|
284
|
+
};
|
|
285
|
+
}
|
|
286
|
+
/**
|
|
287
|
+
* Compares two dotted versions by their numeric parts ("9.30.1" < "9.31.0"); a non-numeric part counts as 0.
|
|
288
|
+
* @param left One version.
|
|
289
|
+
* @param right The other.
|
|
290
|
+
* @returns A negative number, 0 or a positive number.
|
|
291
|
+
*/
|
|
292
|
+
export function compareVersions(left, right) {
|
|
293
|
+
const leftParts = versionParts(left);
|
|
294
|
+
const rightParts = versionParts(right);
|
|
295
|
+
for (let i = 0; i < Math.max(leftParts.length, rightParts.length); i++) {
|
|
296
|
+
const difference = (leftParts[i] ?? 0) - (rightParts[i] ?? 0);
|
|
297
|
+
if (difference !== 0)
|
|
298
|
+
return difference;
|
|
299
|
+
}
|
|
300
|
+
return 0;
|
|
301
|
+
}
|
|
302
|
+
/**
|
|
303
|
+
* The numeric parts of a version (anything after "-" or "+" is ignored).
|
|
304
|
+
* @param version The version.
|
|
305
|
+
* @returns The parts.
|
|
306
|
+
*/
|
|
307
|
+
function versionParts(version) {
|
|
308
|
+
const core = version.split(/[-+]/)[0];
|
|
309
|
+
return core.split(".").map((part) => {
|
|
310
|
+
const value = Number.parseInt(part, 10);
|
|
311
|
+
return Number.isFinite(value) ? value : 0;
|
|
312
|
+
});
|
|
313
|
+
}
|
|
314
|
+
/**
|
|
315
|
+
* The lowest of some versions.
|
|
316
|
+
* @param versions At least one version.
|
|
317
|
+
* @returns The lowest.
|
|
318
|
+
*/
|
|
319
|
+
function lowestVersion(versions) {
|
|
320
|
+
return versions.reduce((lowest, version) => (compareVersions(version, lowest) < 0) ? version : lowest);
|
|
321
|
+
}
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
import type { AdminAccounts } from "./AdminAccounts.js";
|
|
2
|
+
import type { InstanceView } from "./AdminApiTypes.js";
|
|
3
|
+
import type { AdminClock } from "./AdminClock.js";
|
|
4
|
+
import type { AdminStore } from "./AdminStore.js";
|
|
5
|
+
/** Appends server events to the activity feed, under the record-keeping rule of R3. */
|
|
6
|
+
export declare class ServerEvents {
|
|
7
|
+
/** The admin data store. */
|
|
8
|
+
private readonly store;
|
|
9
|
+
/** The clock of each event's time. */
|
|
10
|
+
private readonly clock;
|
|
11
|
+
/** The accounts, which decide whether a file store keeps records yet. */
|
|
12
|
+
private readonly accounts;
|
|
13
|
+
/**
|
|
14
|
+
* Creates the recorder.
|
|
15
|
+
* @param store The admin data store.
|
|
16
|
+
* @param clock The clock.
|
|
17
|
+
* @param accounts The admin accounts.
|
|
18
|
+
*/
|
|
19
|
+
constructor(store: AdminStore, clock: AdminClock, accounts: AdminAccounts);
|
|
20
|
+
/**
|
|
21
|
+
* Whether the console writes records nobody asked for (legacy call audits, player history, server events): always
|
|
22
|
+
* on Redis, and on the file store once an admin account exists, so token-only deployments never create the data
|
|
23
|
+
* folder (R3).
|
|
24
|
+
* @returns True when such records are kept.
|
|
25
|
+
*/
|
|
26
|
+
isRecordKeepingOn(): Promise<boolean>;
|
|
27
|
+
/**
|
|
28
|
+
* Appends a server event in the background; a store failure is logged, never thrown.
|
|
29
|
+
* @param text What happened, e.g. "instance abc lost".
|
|
30
|
+
* @param processId The instance it concerns, or null for the fleet.
|
|
31
|
+
*/
|
|
32
|
+
record(text: string, processId: string | null): void;
|
|
33
|
+
/**
|
|
34
|
+
* Appends a server event when record keeping is on.
|
|
35
|
+
* @param text What happened.
|
|
36
|
+
* @param processId The instance it concerns, or null.
|
|
37
|
+
*/
|
|
38
|
+
private append;
|
|
39
|
+
}
|
|
40
|
+
/** Records instances joining, getting lost and leaving, from the leader's view of the heartbeats. */
|
|
41
|
+
export declare class InstanceEventTracker {
|
|
42
|
+
/** The activity feed's server events. */
|
|
43
|
+
private readonly serverEvents;
|
|
44
|
+
/** Each known instance's status at the last observation, or null before the first one. */
|
|
45
|
+
private lastStatuses;
|
|
46
|
+
/**
|
|
47
|
+
* Creates the tracker.
|
|
48
|
+
* @param serverEvents The activity feed's server events.
|
|
49
|
+
*/
|
|
50
|
+
constructor(serverEvents: ServerEvents);
|
|
51
|
+
/**
|
|
52
|
+
* Compares the instances with the last observation and records what changed. The first observation only notes the
|
|
53
|
+
* fleet as it is, so a new leader never reports instances that were already there.
|
|
54
|
+
* @param instances The fleet now.
|
|
55
|
+
*/
|
|
56
|
+
observe(instances: InstanceView[]): void;
|
|
57
|
+
}
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
// Server events of the activity feed (game-server-admin-console T14 step 5): instance joined or lost, drain started,
|
|
2
|
+
// alert fired or resolved, certificate reloaded and maintenance on or off are appended to the "events" stream with kind
|
|
3
|
+
// "server" by whichever instance observes them. Admin actions reach the same stream through AdminAudit.
|
|
4
|
+
import { getLogger } from "../GameServerLogger.js";
|
|
5
|
+
/** Appends server events to the activity feed, under the record-keeping rule of R3. */
|
|
6
|
+
export class ServerEvents {
|
|
7
|
+
/** The admin data store. */
|
|
8
|
+
store;
|
|
9
|
+
/** The clock of each event's time. */
|
|
10
|
+
clock;
|
|
11
|
+
/** The accounts, which decide whether a file store keeps records yet. */
|
|
12
|
+
accounts;
|
|
13
|
+
/**
|
|
14
|
+
* Creates the recorder.
|
|
15
|
+
* @param store The admin data store.
|
|
16
|
+
* @param clock The clock.
|
|
17
|
+
* @param accounts The admin accounts.
|
|
18
|
+
*/
|
|
19
|
+
constructor(store, clock, accounts) {
|
|
20
|
+
this.store = store;
|
|
21
|
+
this.clock = clock;
|
|
22
|
+
this.accounts = accounts;
|
|
23
|
+
}
|
|
24
|
+
/**
|
|
25
|
+
* Whether the console writes records nobody asked for (legacy call audits, player history, server events): always
|
|
26
|
+
* on Redis, and on the file store once an admin account exists, so token-only deployments never create the data
|
|
27
|
+
* folder (R3).
|
|
28
|
+
* @returns True when such records are kept.
|
|
29
|
+
*/
|
|
30
|
+
async isRecordKeepingOn() {
|
|
31
|
+
return this.store.kind === "redis" || await this.accounts.hasUsers();
|
|
32
|
+
}
|
|
33
|
+
/**
|
|
34
|
+
* Appends a server event in the background; a store failure is logged, never thrown.
|
|
35
|
+
* @param text What happened, e.g. "instance abc lost".
|
|
36
|
+
* @param processId The instance it concerns, or null for the fleet.
|
|
37
|
+
*/
|
|
38
|
+
record(text, processId) {
|
|
39
|
+
this.append(text, processId).catch((error) => getLogger().warn("a server event could not be recorded", { text: text, error: String(error) }));
|
|
40
|
+
}
|
|
41
|
+
/**
|
|
42
|
+
* Appends a server event when record keeping is on.
|
|
43
|
+
* @param text What happened.
|
|
44
|
+
* @param processId The instance it concerns, or null.
|
|
45
|
+
*/
|
|
46
|
+
async append(text, processId) {
|
|
47
|
+
if (!(await this.isRecordKeepingOn()))
|
|
48
|
+
return;
|
|
49
|
+
const item = { at: new Date(this.clock.now()).toISOString(), kind: "server", text: text, processId: processId };
|
|
50
|
+
await this.store.appendRecord("events", item);
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
/** Records instances joining, getting lost and leaving, from the leader's view of the heartbeats. */
|
|
54
|
+
export class InstanceEventTracker {
|
|
55
|
+
/** The activity feed's server events. */
|
|
56
|
+
serverEvents;
|
|
57
|
+
/** Each known instance's status at the last observation, or null before the first one. */
|
|
58
|
+
lastStatuses = null;
|
|
59
|
+
/**
|
|
60
|
+
* Creates the tracker.
|
|
61
|
+
* @param serverEvents The activity feed's server events.
|
|
62
|
+
*/
|
|
63
|
+
constructor(serverEvents) {
|
|
64
|
+
this.serverEvents = serverEvents;
|
|
65
|
+
}
|
|
66
|
+
/**
|
|
67
|
+
* Compares the instances with the last observation and records what changed. The first observation only notes the
|
|
68
|
+
* fleet as it is, so a new leader never reports instances that were already there.
|
|
69
|
+
* @param instances The fleet now.
|
|
70
|
+
*/
|
|
71
|
+
observe(instances) {
|
|
72
|
+
const statuses = new Map(instances.map((instance) => [instance.processId, instance.status]));
|
|
73
|
+
const previous = this.lastStatuses;
|
|
74
|
+
this.lastStatuses = statuses;
|
|
75
|
+
if (previous == null)
|
|
76
|
+
return;
|
|
77
|
+
for (const [processId, status] of statuses) {
|
|
78
|
+
const before = previous.get(processId);
|
|
79
|
+
if (status === "up" && before !== "up")
|
|
80
|
+
this.serverEvents.record("instance " + processId + " joined", processId);
|
|
81
|
+
if (status === "lost" && before === "up")
|
|
82
|
+
this.serverEvents.record("instance " + processId + " lost", processId);
|
|
83
|
+
}
|
|
84
|
+
for (const [processId, before] of previous) {
|
|
85
|
+
if (!statuses.has(processId) && before === "up")
|
|
86
|
+
this.serverEvents.record("instance " + processId + " left", processId);
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
}
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
import { type AdminApiRouter, type AdminRequest } from "./AdminApiRouter.js";
|
|
2
|
+
import type { AdminNoticePayload, DedicatedRestartOptions, PageView, SessionDetailView, SessionInspectorView, SessionSummaryView } from "./AdminApiTypes.js";
|
|
3
|
+
import type { AdminServices } from "./AdminServices.js";
|
|
4
|
+
/** The only SessionRoom methods the admin API may call through remoteRoomCall (D11). */
|
|
5
|
+
export declare const SESSION_ROOM_ADMIN_METHODS: {
|
|
6
|
+
readonly describe: "adminDescribe";
|
|
7
|
+
readonly inspect: "adminInspect";
|
|
8
|
+
readonly kick: "adminKickPlayer";
|
|
9
|
+
readonly close: "adminClose";
|
|
10
|
+
readonly lock: "adminSetLocked";
|
|
11
|
+
readonly notice: "adminSendNotice";
|
|
12
|
+
readonly stop: "adminStopDedicated";
|
|
13
|
+
readonly disconnectBanned: "adminDisconnectBanned";
|
|
14
|
+
};
|
|
15
|
+
/** Every room action of the API, on whichever instance owns the room (R17). Only fixed method names are passed (D11). */
|
|
16
|
+
export declare class RoomAdminClient {
|
|
17
|
+
/**
|
|
18
|
+
* A session's page.
|
|
19
|
+
* @param roomId The room.
|
|
20
|
+
* @param includeAddresses False hides player addresses.
|
|
21
|
+
* @returns The page.
|
|
22
|
+
*/
|
|
23
|
+
describe(roomId: string, includeAddresses: boolean): Promise<SessionDetailView>;
|
|
24
|
+
/**
|
|
25
|
+
* A session's network objects.
|
|
26
|
+
* @param roomId The room.
|
|
27
|
+
* @returns The inspector view.
|
|
28
|
+
*/
|
|
29
|
+
inspect(roomId: string): Promise<SessionInspectorView>;
|
|
30
|
+
/**
|
|
31
|
+
* Kicks a player (D12).
|
|
32
|
+
* @param roomId The room.
|
|
33
|
+
* @param playerId The player.
|
|
34
|
+
* @param message Shown to the player.
|
|
35
|
+
* @param allowRejoin True to skip the kick block.
|
|
36
|
+
* @returns False when the room has no connected player with that id.
|
|
37
|
+
*/
|
|
38
|
+
kick(roomId: string, playerId: number, message: string, allowRejoin: boolean): Promise<boolean>;
|
|
39
|
+
/**
|
|
40
|
+
* Closes a session, telling its players GameClosed (D12).
|
|
41
|
+
* @param roomId The room.
|
|
42
|
+
* @param message Shown to the players.
|
|
43
|
+
*/
|
|
44
|
+
close(roomId: string, message: string): Promise<void>;
|
|
45
|
+
/**
|
|
46
|
+
* Locks or unlocks a session.
|
|
47
|
+
* @param roomId The room.
|
|
48
|
+
* @param locked True to lock.
|
|
49
|
+
*/
|
|
50
|
+
setLocked(roomId: string, locked: boolean): Promise<void>;
|
|
51
|
+
/**
|
|
52
|
+
* Sends a notice to one player of a session or to all of them.
|
|
53
|
+
* @param roomId The room.
|
|
54
|
+
* @param notice The notice.
|
|
55
|
+
* @param playerId The player, or 0 for everyone.
|
|
56
|
+
* @returns How many players were targeted.
|
|
57
|
+
*/
|
|
58
|
+
sendNotice(roomId: string, notice: AdminNoticePayload, playerId: number): Promise<number>;
|
|
59
|
+
/**
|
|
60
|
+
* Stops a dedicated session, telling its players GameClosed (D23). The room answers before it disconnects (R17).
|
|
61
|
+
* @param roomId The room.
|
|
62
|
+
* @param message Shown to the players.
|
|
63
|
+
* @returns What a restart under the same name needs.
|
|
64
|
+
* @throws AdminRequestError 404 when the room runs no dedicated runner.
|
|
65
|
+
*/
|
|
66
|
+
stopDedicated(roomId: string, message: string): Promise<DedicatedRestartOptions>;
|
|
67
|
+
/**
|
|
68
|
+
* Calls a fixed admin method of a room on its instance.
|
|
69
|
+
* @param roomId The room.
|
|
70
|
+
* @param method The method (from SESSION_ROOM_ADMIN_METHODS).
|
|
71
|
+
* @param methodArguments Its arguments.
|
|
72
|
+
* @param timeoutMs How long its instance has to answer (ROOM_CALL_TIMEOUT_MS unless the call says).
|
|
73
|
+
* @returns The method's result.
|
|
74
|
+
* @throws AdminRequestError 404 for an unknown room, 504 when its instance does not answer.
|
|
75
|
+
*/
|
|
76
|
+
private call;
|
|
77
|
+
}
|
|
78
|
+
/**
|
|
79
|
+
* Registers the sessions/* and notices routes.
|
|
80
|
+
* @param router The admin API router.
|
|
81
|
+
* @param services The shared services.
|
|
82
|
+
*/
|
|
83
|
+
export declare function registerSessionRoutes(router: AdminApiRouter, services: AdminServices): void;
|
|
84
|
+
/**
|
|
85
|
+
* Registers this instance's describeSessions and sendNotice fleet commands (R15).
|
|
86
|
+
* @param services The shared services.
|
|
87
|
+
*/
|
|
88
|
+
export declare function registerSessionCommands(services: AdminServices): void;
|
|
89
|
+
/**
|
|
90
|
+
* Every session of the fleet (R18), described by each instance in-process.
|
|
91
|
+
* @param services The shared services.
|
|
92
|
+
* @returns The sessions, unsorted.
|
|
93
|
+
*/
|
|
94
|
+
export declare function describeFleetSessions(services: AdminServices): Promise<SessionSummaryView[]>;
|
|
95
|
+
/**
|
|
96
|
+
* Whether the caller may see player addresses.
|
|
97
|
+
* @param request The request.
|
|
98
|
+
* @returns True with players.viewAddresses.
|
|
99
|
+
*/
|
|
100
|
+
export declare function canViewAddresses(request: AdminRequest): boolean;
|
|
101
|
+
/**
|
|
102
|
+
* The page number of a list query (1-based).
|
|
103
|
+
* @param request The request.
|
|
104
|
+
* @returns The page, 1 when missing or invalid.
|
|
105
|
+
*/
|
|
106
|
+
export declare function pageOf(request: AdminRequest): number;
|
|
107
|
+
/**
|
|
108
|
+
* One page of a list (ADMIN_PAGE_SIZE rows).
|
|
109
|
+
* @param items Every row, sorted.
|
|
110
|
+
* @param page The 1-based page.
|
|
111
|
+
* @returns The page and the total.
|
|
112
|
+
*/
|
|
113
|
+
export declare function pageItems<Item>(items: Item[], page: number): PageView<Item>;
|