@anweather/game-plugin-ssdk 1.16.0 → 1.17.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/dist-cjs/runtime/auth.js +8 -0
- package/dist-cjs/runtime/conformance.js +6 -0
- package/dist-cjs/runtime/index.js +15 -1
- package/dist-cjs/runtime/live/checkpoint.js +31 -0
- package/dist-cjs/runtime/live/conformance.js +215 -0
- package/dist-cjs/runtime/live/harness.js +144 -0
- package/dist-cjs/runtime/live/outbox.js +72 -0
- package/dist-cjs/runtime/live/protocol.js +227 -0
- package/dist-cjs/runtime/live/rooms.js +579 -0
- package/dist-cjs/runtime/live/server.js +105 -0
- package/dist-cjs/runtime/servePlugin.js +8 -2
- package/dist-cjs/tsconfig.cjs.tsbuildinfo +1 -1
- package/dist-es/runtime/auth.js +5 -0
- package/dist-es/runtime/conformance.js +6 -0
- package/dist-es/runtime/index.js +5 -0
- package/dist-es/runtime/live/checkpoint.js +28 -0
- package/dist-es/runtime/live/conformance.js +210 -0
- package/dist-es/runtime/live/harness.js +141 -0
- package/dist-es/runtime/live/outbox.js +68 -0
- package/dist-es/runtime/live/protocol.js +217 -0
- package/dist-es/runtime/live/rooms.js +575 -0
- package/dist-es/runtime/live/server.js +102 -0
- package/dist-es/runtime/servePlugin.js +8 -2
- package/dist-es/tsconfig.es.tsbuildinfo +1 -1
- package/dist-types/runtime/auth.d.ts +3 -0
- package/dist-types/runtime/conformance.d.ts +6 -0
- package/dist-types/runtime/index.d.ts +11 -0
- package/dist-types/runtime/live/checkpoint.d.ts +15 -0
- package/dist-types/runtime/live/conformance.d.ts +24 -0
- package/dist-types/runtime/live/harness.d.ts +71 -0
- package/dist-types/runtime/live/outbox.d.ts +39 -0
- package/dist-types/runtime/live/protocol.d.ts +169 -0
- package/dist-types/runtime/live/rooms.d.ts +302 -0
- package/dist-types/runtime/live/server.d.ts +36 -0
- package/dist-types/runtime/servePlugin.d.ts +8 -0
- package/package.json +6 -3
|
@@ -1,9 +1,15 @@
|
|
|
1
1
|
import type { GamePluginService } from "../src/server/GamePluginService";
|
|
2
|
+
import { type LiveConformanceOptions } from "./live/conformance";
|
|
3
|
+
import type { LiveOptions } from "./live/rooms";
|
|
2
4
|
export interface ConformanceOptions {
|
|
3
5
|
/** Participant ids to seat. Defaults to metadata.minPlayers generated ids. */
|
|
4
6
|
participants?: string[];
|
|
5
7
|
/** InitializeGame config. Defaults to {}. */
|
|
6
8
|
config?: unknown;
|
|
9
|
+
/** The plugin's servePlugin `live` option: adds the room checks (a real
|
|
10
|
+
* /v1/live on a throwaway port; see live/conformance.ts). */
|
|
11
|
+
live?: LiveOptions;
|
|
12
|
+
liveOptions?: LiveConformanceOptions;
|
|
7
13
|
}
|
|
8
14
|
export interface ConformanceReport {
|
|
9
15
|
passed: string[];
|
|
@@ -10,3 +10,14 @@ export type { ConformanceOptions, ConformanceReport } from "./conformance";
|
|
|
10
10
|
export { PLUGIN_PROTOCOL_VERSION, runtimeVersion } from "./version";
|
|
11
11
|
export { createBlobClient, casUpdate, blobOwner, BlobStoreError, isVersionConflict } from "./blobClient";
|
|
12
12
|
export type { BlobClient, BlobClientOptions, BlobOwnerKey, Blob, BlobItem, BlobInfo, BlobWrite, BlobErrorType, } from "./blobClient";
|
|
13
|
+
export { attachLive } from "./live/server";
|
|
14
|
+
export type { AttachLiveOptions, LiveServer } from "./live/server";
|
|
15
|
+
export type { LiveOptions, Room, RoomHandlers, Conn, RoomCloseReason, RoomTimer, SendOptions, BroadcastOptions, KickOptions, } from "./live/rooms";
|
|
16
|
+
export { createLiveHarness } from "./live/harness";
|
|
17
|
+
export { loadRoomCheckpoint } from "./live/checkpoint";
|
|
18
|
+
export type { RoomCheckpoint } from "./live/checkpoint";
|
|
19
|
+
export type { LiveHarness, FakeLink, FakeClient, JoinSpec, HarnessMessage } from "./live/harness";
|
|
20
|
+
export { runLiveConformance, assertLiveConformance } from "./live/conformance";
|
|
21
|
+
export type { LiveConformanceOptions, LiveConformanceReport } from "./live/conformance";
|
|
22
|
+
export { LIVE_PROTOCOL_VERSION, LIVE_PATH, LinkCloseCode, MAX_LINK_FRAME_BYTES } from "./live/protocol";
|
|
23
|
+
export type { RoomScope, SpaceMember, PresenceState, Role } from "./live/protocol";
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import { type BlobClient, type BlobOwnerKey } from "../blobClient";
|
|
2
|
+
import type { Room } from "./rooms";
|
|
3
|
+
export interface RoomCheckpoint<T> {
|
|
4
|
+
/** What onRoom loaded (or the last successful save); undefined if none. */
|
|
5
|
+
readonly value: T | undefined;
|
|
6
|
+
/** The blob version this room owns; 0 before the first write. */
|
|
7
|
+
readonly version: number;
|
|
8
|
+
/** Write `value` if nobody else has written since this room's last read
|
|
9
|
+
* or write. False on a conflict: another room took over the scope (and,
|
|
10
|
+
* when a room was given, this one is closed). Other errors throw. */
|
|
11
|
+
save(value: T): Promise<boolean>;
|
|
12
|
+
}
|
|
13
|
+
export declare function loadRoomCheckpoint<T>(client: BlobClient, owner: BlobOwnerKey, key: string, opts?: {
|
|
14
|
+
room?: Room;
|
|
15
|
+
}): Promise<RoomCheckpoint<T>>;
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import type { LiveOptions } from "./rooms";
|
|
2
|
+
export interface LiveConformanceOptions {
|
|
3
|
+
/** The game type to claim on the link. Default "conformance". */
|
|
4
|
+
gameType?: string;
|
|
5
|
+
/** Scope to open. Default a fresh "game:{uuid}". */
|
|
6
|
+
scope?: string;
|
|
7
|
+
/** The payload of every Signal the storm sends. Default {}: a room must
|
|
8
|
+
* treat client data as untrusted and must not throw on it. */
|
|
9
|
+
signal?: unknown;
|
|
10
|
+
/** Connections in the join/leave storm. Default 100. */
|
|
11
|
+
stormSize?: number;
|
|
12
|
+
/** How long to wait for `ready` and for the room to settle. Default 5 s. */
|
|
13
|
+
timeoutMs?: number;
|
|
14
|
+
}
|
|
15
|
+
export interface LiveConformanceReport {
|
|
16
|
+
passed: string[];
|
|
17
|
+
failed: {
|
|
18
|
+
check: string;
|
|
19
|
+
reason: string;
|
|
20
|
+
}[];
|
|
21
|
+
}
|
|
22
|
+
export declare function runLiveConformance(live: LiveOptions, options?: LiveConformanceOptions): Promise<LiveConformanceReport>;
|
|
23
|
+
/** runLiveConformance, throwing one error naming every failed check. */
|
|
24
|
+
export declare function assertLiveConformance(live: LiveOptions, options?: LiveConformanceOptions): Promise<LiveConformanceReport>;
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
import { type PresenceState, type Role, type SpaceMember } from "./protocol";
|
|
2
|
+
import { type LiveOptions, type Room } from "./rooms";
|
|
3
|
+
export interface HarnessMessage {
|
|
4
|
+
/** Always "host": the only sender a Hosted client hears from. */
|
|
5
|
+
from: "host";
|
|
6
|
+
k?: string;
|
|
7
|
+
d: unknown;
|
|
8
|
+
}
|
|
9
|
+
export interface FakeClient {
|
|
10
|
+
readonly conn: string;
|
|
11
|
+
readonly participantId: string;
|
|
12
|
+
/** Signals the room sent this connection, oldest first. */
|
|
13
|
+
readonly received: HarnessMessage[];
|
|
14
|
+
/** Set once the room kicked this connection. */
|
|
15
|
+
readonly kicked: {
|
|
16
|
+
code: number;
|
|
17
|
+
reason?: string;
|
|
18
|
+
} | undefined;
|
|
19
|
+
readonly open: boolean;
|
|
20
|
+
signal(d: unknown): void;
|
|
21
|
+
presence(state: PresenceState): void;
|
|
22
|
+
leave(reason?: string): void;
|
|
23
|
+
}
|
|
24
|
+
export interface JoinSpec {
|
|
25
|
+
participantId: string;
|
|
26
|
+
playerId?: string;
|
|
27
|
+
displayName?: string;
|
|
28
|
+
role?: Role;
|
|
29
|
+
gameId?: string;
|
|
30
|
+
hidden?: boolean;
|
|
31
|
+
state?: PresenceState;
|
|
32
|
+
/** Defaults to a fresh "c1", "c2"... per link. */
|
|
33
|
+
conn?: string;
|
|
34
|
+
}
|
|
35
|
+
export interface FakeLink {
|
|
36
|
+
readonly scope: string;
|
|
37
|
+
/** True once the room answered `ready`. */
|
|
38
|
+
readonly ready: boolean;
|
|
39
|
+
/** Set once the plugin closed the link. */
|
|
40
|
+
readonly closedByPlugin: {
|
|
41
|
+
code: number;
|
|
42
|
+
reason: string;
|
|
43
|
+
} | undefined;
|
|
44
|
+
/** Every plugin frame on this link, decoded, oldest first. */
|
|
45
|
+
readonly frames: unknown[];
|
|
46
|
+
join(spec: JoinSpec): FakeClient;
|
|
47
|
+
game(gameId: string, revision: number): void;
|
|
48
|
+
members(members: SpaceMember[]): void;
|
|
49
|
+
/** Push any text frame, well-formed or not (conformance, fuzzing). */
|
|
50
|
+
raw(text: string): void;
|
|
51
|
+
/** The platform closes the link: 1000 idle, 4410 the game ended, 1012 a
|
|
52
|
+
* platform restart... */
|
|
53
|
+
close(code?: number): void;
|
|
54
|
+
}
|
|
55
|
+
export interface LiveHarness {
|
|
56
|
+
/** Open a room link for `scope` ("game:{id}" or "space:{id}"). A second
|
|
57
|
+
* link for a scope already open is the deploy-overlap case. */
|
|
58
|
+
openLink(scope: string, opts?: {
|
|
59
|
+
epoch?: string;
|
|
60
|
+
}): FakeLink;
|
|
61
|
+
/** Resolves once every room has loaded and handled everything sent so far. */
|
|
62
|
+
settled(): Promise<void>;
|
|
63
|
+
room(scope: string): Room | undefined;
|
|
64
|
+
rooms(): Room[];
|
|
65
|
+
/** Plugin shutdown: onClose("shutdown") everywhere, links closed with 1012. */
|
|
66
|
+
shutdown(): Promise<void>;
|
|
67
|
+
}
|
|
68
|
+
export declare function createLiveHarness(live: LiveOptions, opts?: {
|
|
69
|
+
gameType?: string;
|
|
70
|
+
log?: Pick<Console, "log" | "error">;
|
|
71
|
+
}): LiveHarness;
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
export interface LinkWriter {
|
|
2
|
+
/** Hand `text` to the socket; call `done` once it has been flushed (or
|
|
3
|
+
* failed: the link is going away and its close handles the rest). */
|
|
4
|
+
write(text: string, done: () => void): void;
|
|
5
|
+
}
|
|
6
|
+
export interface OutboxOptions {
|
|
7
|
+
/** Bytes in flight (written, not yet flushed) before frames queue here. */
|
|
8
|
+
highWaterBytes?: number;
|
|
9
|
+
/** Queued frames before the oldest droppable one is discarded. */
|
|
10
|
+
maxQueuedFrames?: number;
|
|
11
|
+
}
|
|
12
|
+
export interface OutboxStats {
|
|
13
|
+
/** Frames waiting here right now. */
|
|
14
|
+
queued: number;
|
|
15
|
+
/** Frames replaced by a newer one with the same key. */
|
|
16
|
+
coalesced: number;
|
|
17
|
+
/** Frames discarded because the queue was full. */
|
|
18
|
+
dropped: number;
|
|
19
|
+
}
|
|
20
|
+
export declare const DEFAULT_HIGH_WATER_BYTES: number;
|
|
21
|
+
export declare const DEFAULT_MAX_QUEUED_FRAMES = 1024;
|
|
22
|
+
export declare class Outbox {
|
|
23
|
+
private readonly writer;
|
|
24
|
+
private readonly highWater;
|
|
25
|
+
private readonly maxQueued;
|
|
26
|
+
private readonly queue;
|
|
27
|
+
private inflight;
|
|
28
|
+
private pumping;
|
|
29
|
+
private closed;
|
|
30
|
+
readonly stats: OutboxStats;
|
|
31
|
+
constructor(writer: LinkWriter, options?: OutboxOptions);
|
|
32
|
+
push(text: string, opts: {
|
|
33
|
+
key?: string;
|
|
34
|
+
droppable: boolean;
|
|
35
|
+
}): void;
|
|
36
|
+
/** Stop writing; whatever is queued is abandoned with the link. */
|
|
37
|
+
close(): void;
|
|
38
|
+
private pump;
|
|
39
|
+
}
|
|
@@ -0,0 +1,169 @@
|
|
|
1
|
+
/** The X-Live-Protocol value this runtime speaks. */
|
|
2
|
+
export declare const LIVE_PROTOCOL_VERSION = 1;
|
|
3
|
+
/** The upgrade path, beside the RPC operations under /v1. */
|
|
4
|
+
export declare const LIVE_PATH = "/v1/live";
|
|
5
|
+
export declare const LIVE_HEADERS: {
|
|
6
|
+
readonly auth: "x-plugin-auth";
|
|
7
|
+
readonly scope: "x-live-scope";
|
|
8
|
+
readonly epoch: "x-live-epoch";
|
|
9
|
+
readonly gameType: "x-game-type";
|
|
10
|
+
readonly protocol: "x-live-protocol";
|
|
11
|
+
};
|
|
12
|
+
/** The biggest frame either side may put on the link. A client Signal is at
|
|
13
|
+
* most 16 KiB (the Hosted client ceiling, design §10.1), so a platform frame
|
|
14
|
+
* carrying one fits with room for the envelope; a plugin `send` over this is
|
|
15
|
+
* refused by the runtime before it is written. */
|
|
16
|
+
export declare const MAX_LINK_FRAME_BYTES: number;
|
|
17
|
+
/** Close codes on the room link (PROTOCOL.md §6). */
|
|
18
|
+
export declare const LinkCloseCode: {
|
|
19
|
+
/** Platform: the channel has been idle for its grace; the room is done.
|
|
20
|
+
* Plugin: `room.close()`, the room is discarded (the platform reopens a
|
|
21
|
+
* fresh link if clients remain). */
|
|
22
|
+
readonly NORMAL: 1000;
|
|
23
|
+
/** Platform: going away without a restart planned. */
|
|
24
|
+
readonly GOING_AWAY: 1001;
|
|
25
|
+
/** Malformed handshake follow-up or repeated protocol abuse. */
|
|
26
|
+
readonly POLICY: 1008;
|
|
27
|
+
/** A frame over MAX_LINK_FRAME_BYTES. */
|
|
28
|
+
readonly TOO_BIG: 1009;
|
|
29
|
+
/** The plugin's room failed (setup threw, or it fell too far behind). */
|
|
30
|
+
readonly INTERNAL_ERROR: 1011;
|
|
31
|
+
/** Either side is restarting; the platform reopens with backoff. */
|
|
32
|
+
readonly RESTART: 1012;
|
|
33
|
+
/** Platform: the link fell behind its inbound bound (design §10.3). */
|
|
34
|
+
readonly TOO_SLOW: 4008;
|
|
35
|
+
/** Plugin: a newer link with the same X-Live-Epoch replaced this one
|
|
36
|
+
* (PROTOCOL.md §5.4). The platform has already moved to the new link. */
|
|
37
|
+
readonly SUPERSEDED: 4409;
|
|
38
|
+
/** Platform: the scope ended (its Game completed or was abandoned). */
|
|
39
|
+
readonly GONE: 4410;
|
|
40
|
+
};
|
|
41
|
+
export type Role = "PARTICIPANT" | "SPECTATOR";
|
|
42
|
+
export type PresenceState = "ACTIVE" | "AWAY";
|
|
43
|
+
/** One entry of a Space's membership, the same shape as config.space.members. */
|
|
44
|
+
export interface SpaceMember {
|
|
45
|
+
playerId: string;
|
|
46
|
+
displayName: string;
|
|
47
|
+
role: string;
|
|
48
|
+
status: string;
|
|
49
|
+
}
|
|
50
|
+
export type RoomScope = {
|
|
51
|
+
kind: "game";
|
|
52
|
+
id: string;
|
|
53
|
+
key: string;
|
|
54
|
+
} | {
|
|
55
|
+
kind: "space";
|
|
56
|
+
id: string;
|
|
57
|
+
key: string;
|
|
58
|
+
};
|
|
59
|
+
export interface JoinFrame {
|
|
60
|
+
t: "join";
|
|
61
|
+
conn: string;
|
|
62
|
+
participantId: string;
|
|
63
|
+
playerId: string;
|
|
64
|
+
displayName: string;
|
|
65
|
+
role: Role;
|
|
66
|
+
gameId: string;
|
|
67
|
+
hidden: boolean;
|
|
68
|
+
state: PresenceState;
|
|
69
|
+
}
|
|
70
|
+
export interface LeaveFrame {
|
|
71
|
+
t: "leave";
|
|
72
|
+
conn: string;
|
|
73
|
+
reason: string;
|
|
74
|
+
}
|
|
75
|
+
export interface SigFrame {
|
|
76
|
+
t: "sig";
|
|
77
|
+
conn: string;
|
|
78
|
+
participantId: string;
|
|
79
|
+
d: unknown;
|
|
80
|
+
}
|
|
81
|
+
export interface PresenceFrame {
|
|
82
|
+
t: "presence";
|
|
83
|
+
conn: string;
|
|
84
|
+
state: PresenceState;
|
|
85
|
+
}
|
|
86
|
+
export interface GameFrame {
|
|
87
|
+
t: "game";
|
|
88
|
+
gameId: string;
|
|
89
|
+
revision: number;
|
|
90
|
+
}
|
|
91
|
+
export interface MembersFrame {
|
|
92
|
+
t: "members";
|
|
93
|
+
members: SpaceMember[];
|
|
94
|
+
}
|
|
95
|
+
export type PlatformFrame = JoinFrame | LeaveFrame | SigFrame | PresenceFrame | GameFrame | MembersFrame;
|
|
96
|
+
export interface SendFrame {
|
|
97
|
+
t: "send";
|
|
98
|
+
to: "all" | string[];
|
|
99
|
+
k?: string;
|
|
100
|
+
d: unknown;
|
|
101
|
+
}
|
|
102
|
+
export interface KickFrame {
|
|
103
|
+
t: "kick";
|
|
104
|
+
conn: string;
|
|
105
|
+
code: number;
|
|
106
|
+
reason?: string;
|
|
107
|
+
}
|
|
108
|
+
export interface ReadyFrame {
|
|
109
|
+
t: "ready";
|
|
110
|
+
}
|
|
111
|
+
export type PluginFrame = SendFrame | KickFrame | ReadyFrame;
|
|
112
|
+
export type Decoded<F> = {
|
|
113
|
+
ok: true;
|
|
114
|
+
frame: F;
|
|
115
|
+
}
|
|
116
|
+
/** Well-formed JSON object with a `t` this version doesn't know: ignore. */
|
|
117
|
+
| {
|
|
118
|
+
ok: false;
|
|
119
|
+
unknown: true;
|
|
120
|
+
t: string;
|
|
121
|
+
}
|
|
122
|
+
/** Not a JSON object, or a known frame missing/mistyping a field: drop. */
|
|
123
|
+
| {
|
|
124
|
+
ok: false;
|
|
125
|
+
unknown: false;
|
|
126
|
+
error: string;
|
|
127
|
+
};
|
|
128
|
+
/** A platform -> plugin frame, as the runtime reads it off the link. */
|
|
129
|
+
export declare function decodePlatformFrame(text: string): Decoded<PlatformFrame>;
|
|
130
|
+
/** A plugin -> platform frame, as the platform (or the loopback harness)
|
|
131
|
+
* reads it. The runtime never needs this itself; it is here so the harness
|
|
132
|
+
* and the tests decode exactly what the platform will. */
|
|
133
|
+
export declare function decodePluginFrame(text: string): Decoded<PluginFrame>;
|
|
134
|
+
/** A WebSocket close reason is at most 123 bytes; 120 characters of the
|
|
135
|
+
* plugin's own leaves room for nothing to be cut mid-character in practice. */
|
|
136
|
+
export declare const MAX_KICK_REASON = 120;
|
|
137
|
+
/** 4403 keeps the client out (terminal, design §6.3); 4900-4999 are the
|
|
138
|
+
* plugin's own, and the client reconnects after them like any other
|
|
139
|
+
* non-terminal close. Nothing else: a plugin must not be able to fake the
|
|
140
|
+
* platform's own meanings (4401 auth, 4409 superseded, 4410 game over...). */
|
|
141
|
+
export declare function isKickCode(v: unknown): v is number;
|
|
142
|
+
/** Encode either direction. `undefined` fields are dropped by JSON itself. */
|
|
143
|
+
export declare function encodeFrame(frame: PluginFrame | PlatformFrame): string;
|
|
144
|
+
export declare function byteLength(text: string): number;
|
|
145
|
+
/** "game:{gameId}" or "space:{spaceId}"; anything else is null. */
|
|
146
|
+
export declare function parseScope(header: string | undefined): RoomScope | null;
|
|
147
|
+
export interface HandshakeExpectations {
|
|
148
|
+
gameType: string;
|
|
149
|
+
/** Absent: no token configured (local dev), every caller is accepted --
|
|
150
|
+
* the same rule as the RPC routes. */
|
|
151
|
+
authToken?: string;
|
|
152
|
+
}
|
|
153
|
+
export type HandshakeResult = {
|
|
154
|
+
ok: true;
|
|
155
|
+
scope: RoomScope;
|
|
156
|
+
epoch: string;
|
|
157
|
+
protocol: number;
|
|
158
|
+
} | {
|
|
159
|
+
ok: false;
|
|
160
|
+
status: 400 | 401 | 404;
|
|
161
|
+
message: string;
|
|
162
|
+
};
|
|
163
|
+
type Headers = Record<string, string | string[] | undefined>;
|
|
164
|
+
/** Check an upgrade request's headers (lower-cased, as node gives them).
|
|
165
|
+
* The order is the order a platform engineer would want the errors in:
|
|
166
|
+
* auth first, so nothing about the plugin leaks to an unauthenticated
|
|
167
|
+
* caller. */
|
|
168
|
+
export declare function checkHandshake(headers: Headers, expect: HandshakeExpectations): HandshakeResult;
|
|
169
|
+
export {};
|
|
@@ -0,0 +1,302 @@
|
|
|
1
|
+
import { type PlatformFrame, type PluginFrame, type PresenceState, type Role, type RoomScope, type SpaceMember } from "./protocol";
|
|
2
|
+
import { Outbox, type LinkWriter, type OutboxOptions, type OutboxStats } from "./outbox";
|
|
3
|
+
type Awaitable<T> = T | Promise<T>;
|
|
4
|
+
/** Why a room was discarded, as RoomHandlers.onClose hears it. */
|
|
5
|
+
export type RoomCloseReason =
|
|
6
|
+
/** The platform closed the last link with 1000: no client for its idle grace. */
|
|
7
|
+
"idle"
|
|
8
|
+
/** The platform closed with 4410: the Game behind the scope is over. */
|
|
9
|
+
| "ended"
|
|
10
|
+
/** This process is stopping (SIGTERM or RunningPlugin.close()). The
|
|
11
|
+
* checkpoint hook: clients are still connected and will get a fresh room
|
|
12
|
+
* from the next container (design §8.4). */
|
|
13
|
+
| "shutdown"
|
|
14
|
+
/** Every link dropped without a planned close, and none came back within
|
|
15
|
+
* reattachGraceMs. */
|
|
16
|
+
| "lost"
|
|
17
|
+
/** The room called room.close() itself. */
|
|
18
|
+
| "closed"
|
|
19
|
+
/** onRoom threw, or the room fell maxPendingEvents behind. */
|
|
20
|
+
| "error";
|
|
21
|
+
export interface Conn {
|
|
22
|
+
/** Unique within the room (the platform's conn id, namespaced by link). */
|
|
23
|
+
readonly id: string;
|
|
24
|
+
readonly participantId: string;
|
|
25
|
+
readonly playerId: string;
|
|
26
|
+
readonly displayName: string;
|
|
27
|
+
readonly role: Role;
|
|
28
|
+
/** The participant's own Game; in a Space room each player has their own. */
|
|
29
|
+
readonly gameId: string;
|
|
30
|
+
/** The X-Live-Epoch of the link that carries this connection: the
|
|
31
|
+
* platform channel's incarnation. A new epoch means the platform
|
|
32
|
+
* restarted and every client is resyncing. */
|
|
33
|
+
readonly epoch: string;
|
|
34
|
+
/** The player appears offline (design §3.2): a room must not reveal them
|
|
35
|
+
* to the other members. Fixed for the connection's life: a player who
|
|
36
|
+
* toggles "Appear offline" reconnects, and the new conn carries the new
|
|
37
|
+
* value. */
|
|
38
|
+
readonly hidden: boolean;
|
|
39
|
+
readonly presence: PresenceState;
|
|
40
|
+
/** False once the connection has left. Sends to it are then dropped. */
|
|
41
|
+
readonly open: boolean;
|
|
42
|
+
send(data: unknown, opts?: SendOptions): void;
|
|
43
|
+
kick(opts: KickOptions): void;
|
|
44
|
+
}
|
|
45
|
+
export interface SendOptions {
|
|
46
|
+
/** Coalescing key: under backpressure a newer frame with the same key
|
|
47
|
+
* replaces an undelivered older one, here and on the platform. */
|
|
48
|
+
k?: string;
|
|
49
|
+
}
|
|
50
|
+
export interface BroadcastOptions extends SendOptions {
|
|
51
|
+
/** Connections to leave out (typically the sender of what is echoed). */
|
|
52
|
+
except?: Conn | readonly Conn[];
|
|
53
|
+
/** Leave spectators out. */
|
|
54
|
+
participantsOnly?: boolean;
|
|
55
|
+
}
|
|
56
|
+
export interface KickOptions {
|
|
57
|
+
/** Required, so "may this client come back" is always an explicit
|
|
58
|
+
* choice: 4403 keeps it out (terminal); 4900-4999 are the plugin's own
|
|
59
|
+
* and the client reconnects after them. */
|
|
60
|
+
code: number;
|
|
61
|
+
reason?: string;
|
|
62
|
+
}
|
|
63
|
+
export interface RoomTimer {
|
|
64
|
+
cancel(): void;
|
|
65
|
+
}
|
|
66
|
+
export interface Room {
|
|
67
|
+
readonly scope: RoomScope;
|
|
68
|
+
readonly gameType: string;
|
|
69
|
+
/** True once the room has been discarded; every call becomes a no-op. */
|
|
70
|
+
readonly closed: boolean;
|
|
71
|
+
/** Open connections, in join order. */
|
|
72
|
+
conns(): readonly Conn[];
|
|
73
|
+
/** Participant ids with at least one open connection (spectators excluded). */
|
|
74
|
+
participants(): string[];
|
|
75
|
+
connsOf(participantId: string): readonly Conn[];
|
|
76
|
+
/** The latest `members` frame (Space scope only), else undefined. */
|
|
77
|
+
members(): readonly SpaceMember[] | undefined;
|
|
78
|
+
broadcast(data: unknown, opts?: BroadcastOptions): void;
|
|
79
|
+
send(to: Conn | readonly Conn[], data: unknown, opts?: SendOptions): void;
|
|
80
|
+
/** Every open connection of one participant (several tabs are allowed). */
|
|
81
|
+
sendToParticipant(participantId: string, data: unknown, opts?: SendOptions): void;
|
|
82
|
+
kick(conn: Conn, opts: KickOptions): void;
|
|
83
|
+
/** Runs `fn` through the room's queue after `ms`; cancelled with the room. */
|
|
84
|
+
setTimeout(fn: () => Awaitable<void>, ms: number): RoomTimer;
|
|
85
|
+
/** Runs `fn` through the queue every `ms`. A tick is skipped while the
|
|
86
|
+
* previous one is still queued or running, so a slow room never builds a
|
|
87
|
+
* backlog of ticks. */
|
|
88
|
+
setInterval(fn: () => Awaitable<void>, ms: number): RoomTimer;
|
|
89
|
+
/** Runs `fn` through the room's queue as soon as it is free: the way to act
|
|
90
|
+
* on the room from outside a handler (a blob read finishing, a message
|
|
91
|
+
* from elsewhere in the process) without racing its handlers. Dropped if
|
|
92
|
+
* the room has closed. */
|
|
93
|
+
post(fn: () => Awaitable<void>): void;
|
|
94
|
+
/** Discard this room: onClose("closed"), then its links close with 1000.
|
|
95
|
+
* The platform reopens a link while clients remain, and that gets a fresh
|
|
96
|
+
* room -- so this is "restart from durable state", not "end the game"
|
|
97
|
+
* (only a Move ends a Game). */
|
|
98
|
+
close(reason?: string): void;
|
|
99
|
+
}
|
|
100
|
+
export interface RoomHandlers {
|
|
101
|
+
onJoin?(conn: Conn): Awaitable<void>;
|
|
102
|
+
/** `reason` is the platform's (closed, kicked, timeout, removed...), or
|
|
103
|
+
* the runtime's own: "link" when the link that carried the connection
|
|
104
|
+
* closed or was superseded, "rejoined" when the platform sent a second
|
|
105
|
+
* `join` for a conn id it had already joined (the old Conn leaves, the new
|
|
106
|
+
* one joins). Not debounced: apply a grace with room.setTimeout if a party
|
|
107
|
+
* should survive a blip. */
|
|
108
|
+
onLeave?(conn: Conn, reason: string): Awaitable<void>;
|
|
109
|
+
/** A client's Signal. `data` is untrusted: the sender is `conn`, anything
|
|
110
|
+
* inside `data` is a claim (design §7.2). In a Space room, a signal from a
|
|
111
|
+
* member removed a moment ago can still be queued ahead of their `leave`;
|
|
112
|
+
* check room.members() before letting it change shared state. */
|
|
113
|
+
onMessage?(conn: Conn, data: unknown): Awaitable<void>;
|
|
114
|
+
onPresence?(conn: Conn, state: PresenceState): Awaitable<void>;
|
|
115
|
+
/** A Move was committed on a Game in this scope. */
|
|
116
|
+
onGame?(gameId: string, revision: number): Awaitable<void>;
|
|
117
|
+
/** Space membership changed. Removed members' connections leave right after. */
|
|
118
|
+
onMembers?(members: readonly SpaceMember[]): Awaitable<void>;
|
|
119
|
+
/** The room is being discarded; the last chance to write a checkpoint. */
|
|
120
|
+
onClose?(reason: RoomCloseReason): Awaitable<void>;
|
|
121
|
+
}
|
|
122
|
+
export interface LiveOptions {
|
|
123
|
+
/** Called once per room, when the first link for its scope arrives. Load
|
|
124
|
+
* state here (from blobs); the runtime answers `ready` when it resolves and
|
|
125
|
+
* holds every event until then. Per-room state lives in this closure. */
|
|
126
|
+
onRoom(room: Room): Awaitable<RoomHandlers | void>;
|
|
127
|
+
/** How long a room whose links all dropped unexpectedly (a platform
|
|
128
|
+
* restart, a network cut) is kept for a new link to the same scope.
|
|
129
|
+
* Default 60 s. */
|
|
130
|
+
reattachGraceMs?: number;
|
|
131
|
+
/** Events queued behind a slow room before it is failed with 1011 (the
|
|
132
|
+
* platform then opens a fresh one). Default 4096. */
|
|
133
|
+
maxPendingEvents?: number;
|
|
134
|
+
/** How long a discarded room gets to finish the handler in flight, drain
|
|
135
|
+
* and run onClose, whatever the reason (shutdown included); also how long
|
|
136
|
+
* a successor room for the same scope waits for that before loading
|
|
137
|
+
* anyway. A handler that never resolves can delay a scope by this much,
|
|
138
|
+
* never brick it. Default 5 s. */
|
|
139
|
+
shutdownTimeoutMs?: number;
|
|
140
|
+
/** A handler still running after this long is reported through onError
|
|
141
|
+
* (once per call) as a stall. It is not cancelled: JS can't. Default 10 s;
|
|
142
|
+
* 0 turns the watchdog off. */
|
|
143
|
+
stallWarningMs?: number;
|
|
144
|
+
/** Plugin -> platform backpressure, per link. */
|
|
145
|
+
outbox?: OutboxOptions;
|
|
146
|
+
/** A handler threw or stalled. Default: log it. The room carries on. An
|
|
147
|
+
* onError that throws is itself caught and logged. */
|
|
148
|
+
onError?(error: unknown, where: {
|
|
149
|
+
scope: string;
|
|
150
|
+
handler: string;
|
|
151
|
+
}): void;
|
|
152
|
+
}
|
|
153
|
+
export interface LinkInfo {
|
|
154
|
+
scope: RoomScope;
|
|
155
|
+
epoch: string;
|
|
156
|
+
}
|
|
157
|
+
/** The socket side of a link, supplied by server.ts or the harness. */
|
|
158
|
+
export interface LinkTransport extends LinkWriter {
|
|
159
|
+
close(code: number, reason: string): void;
|
|
160
|
+
}
|
|
161
|
+
/** What the transport calls back into. */
|
|
162
|
+
export interface LinkHandle {
|
|
163
|
+
receive(text: string): void;
|
|
164
|
+
/** The socket closed (either side). Idempotent. */
|
|
165
|
+
closed(code: number): void;
|
|
166
|
+
}
|
|
167
|
+
export interface RoomHostOptions {
|
|
168
|
+
gameType: string;
|
|
169
|
+
live: LiveOptions;
|
|
170
|
+
log?: Pick<Console, "log" | "error">;
|
|
171
|
+
}
|
|
172
|
+
declare class Link {
|
|
173
|
+
readonly serial: number;
|
|
174
|
+
readonly info: LinkInfo;
|
|
175
|
+
readonly transport: LinkTransport;
|
|
176
|
+
readonly outbox: Outbox;
|
|
177
|
+
readonly conns: Map<string, ConnImpl>;
|
|
178
|
+
ready: boolean;
|
|
179
|
+
detached: boolean;
|
|
180
|
+
constructor(serial: number, info: LinkInfo, transport: LinkTransport, outbox: OutboxOptions | undefined);
|
|
181
|
+
write(frame: PluginFrame, key?: string): void;
|
|
182
|
+
}
|
|
183
|
+
declare class ConnImpl implements Conn {
|
|
184
|
+
readonly link: Link;
|
|
185
|
+
readonly wire: string;
|
|
186
|
+
readonly participantId: string;
|
|
187
|
+
readonly playerId: string;
|
|
188
|
+
readonly displayName: string;
|
|
189
|
+
readonly role: Role;
|
|
190
|
+
readonly gameId: string;
|
|
191
|
+
readonly hidden: boolean;
|
|
192
|
+
private readonly room;
|
|
193
|
+
open: boolean;
|
|
194
|
+
presence: PresenceState;
|
|
195
|
+
constructor(link: Link, wire: string, participantId: string, playerId: string, displayName: string, role: Role, gameId: string, hidden: boolean, presence: PresenceState, room: RoomImpl);
|
|
196
|
+
get epoch(): string;
|
|
197
|
+
get id(): string;
|
|
198
|
+
send(data: unknown, opts?: SendOptions): void;
|
|
199
|
+
kick(opts: KickOptions): void;
|
|
200
|
+
}
|
|
201
|
+
declare class RoomImpl implements Room {
|
|
202
|
+
readonly scope: RoomScope;
|
|
203
|
+
readonly gameType: string;
|
|
204
|
+
private readonly host;
|
|
205
|
+
private readonly live;
|
|
206
|
+
readonly links: Set<Link>;
|
|
207
|
+
private readonly order;
|
|
208
|
+
private handlers;
|
|
209
|
+
private latestMembers;
|
|
210
|
+
private readonly queue;
|
|
211
|
+
private processing;
|
|
212
|
+
private loaded;
|
|
213
|
+
private disposing;
|
|
214
|
+
private readonly timers;
|
|
215
|
+
private graceTimer;
|
|
216
|
+
private idleWaiters;
|
|
217
|
+
private loading;
|
|
218
|
+
closed: boolean;
|
|
219
|
+
constructor(scope: RoomScope, gameType: string, host: RoomHost, live: LiveOptions);
|
|
220
|
+
load(previous: Promise<void> | undefined): Promise<void>;
|
|
221
|
+
private runLoad;
|
|
222
|
+
linkWithEpoch(epoch: string): Link | undefined;
|
|
223
|
+
markReady(link: Link): void;
|
|
224
|
+
attach(link: Link): void;
|
|
225
|
+
/** A newer link with the same epoch replaced `old` (PROTOCOL.md §5.4): the
|
|
226
|
+
* platform has abandoned it, so close it now and let its conns leave. */
|
|
227
|
+
supersede(old: Link): void;
|
|
228
|
+
/** A link's socket went away. Its connections leave through the queue. */
|
|
229
|
+
detach(link: Link, code: number): void;
|
|
230
|
+
/** Discard the room: stop timers, let pending events finish (unless
|
|
231
|
+
* failing), run onClose, close any remaining links. Idempotent.
|
|
232
|
+
*
|
|
233
|
+
* Everything after the timers stop is bounded by closeTimeout(): a
|
|
234
|
+
* handler or onClose that never resolves delays the links' close by that
|
|
235
|
+
* much, never forever. A failing room ("error") closes its links first,
|
|
236
|
+
* at once, so the platform can open a fresh room while this one winds
|
|
237
|
+
* down. */
|
|
238
|
+
dispose(reason: RoomCloseReason, code?: number, closeReason?: string, opts?: {
|
|
239
|
+
runOnClose?: boolean;
|
|
240
|
+
drainFirst?: boolean;
|
|
241
|
+
}): Promise<void>;
|
|
242
|
+
private closeLinks;
|
|
243
|
+
closeTimeout(): number;
|
|
244
|
+
enqueue(name: string, run: () => Awaitable<void>): void;
|
|
245
|
+
private drain;
|
|
246
|
+
/** Resolves when nothing is queued or running. */
|
|
247
|
+
whenIdle(): Promise<void>;
|
|
248
|
+
isIdle(): boolean;
|
|
249
|
+
/** Loaded (or discarded) with nothing queued or running: for the harness. */
|
|
250
|
+
isSettled(): boolean;
|
|
251
|
+
private call;
|
|
252
|
+
onFrame(link: Link, frame: PlatformFrame): void;
|
|
253
|
+
private removeConn;
|
|
254
|
+
conns(): readonly Conn[];
|
|
255
|
+
participants(): string[];
|
|
256
|
+
connsOf(participantId: string): readonly Conn[];
|
|
257
|
+
members(): readonly SpaceMember[] | undefined;
|
|
258
|
+
broadcast(data: unknown, opts?: BroadcastOptions): void;
|
|
259
|
+
send(to: Conn | readonly Conn[], data: unknown, opts?: SendOptions): void;
|
|
260
|
+
sendToParticipant(participantId: string, data: unknown, opts?: SendOptions): void;
|
|
261
|
+
kick(conn: Conn, opts: KickOptions): void;
|
|
262
|
+
setTimeout(fn: () => Awaitable<void>, ms: number): RoomTimer;
|
|
263
|
+
setInterval(fn: () => Awaitable<void>, ms: number): RoomTimer;
|
|
264
|
+
close(reason?: string): void;
|
|
265
|
+
post(fn: () => Awaitable<void>): void;
|
|
266
|
+
private timer;
|
|
267
|
+
private assertReady;
|
|
268
|
+
private write;
|
|
269
|
+
outboxStats(): OutboxStats[];
|
|
270
|
+
}
|
|
271
|
+
/** Every room this process hosts. One per servePlugin({live}). */
|
|
272
|
+
export declare class RoomHost {
|
|
273
|
+
private readonly options;
|
|
274
|
+
private readonly rooms;
|
|
275
|
+
/** Rooms being discarded, by scope, so a successor waits for onClose. */
|
|
276
|
+
private readonly disposing;
|
|
277
|
+
private serial;
|
|
278
|
+
private shuttingDown;
|
|
279
|
+
private readonly log;
|
|
280
|
+
constructor(options: RoomHostOptions);
|
|
281
|
+
get gameType(): string;
|
|
282
|
+
/** A new link. Returns the callbacks its socket drives. */
|
|
283
|
+
attach(info: LinkInfo, transport: LinkTransport): LinkHandle;
|
|
284
|
+
/** Rooms currently hosted (not ones being discarded). */
|
|
285
|
+
list(): Room[];
|
|
286
|
+
room(scopeKey: string): Room | undefined;
|
|
287
|
+
/** Every room's outbox counters, for metrics and tests. */
|
|
288
|
+
outboxStats(scopeKey: string): OutboxStats[];
|
|
289
|
+
/** Resolves once every room has loaded and has nothing queued or
|
|
290
|
+
* running, and every discarded room has finished its onClose. For tests:
|
|
291
|
+
* real code never needs to wait for a room. */
|
|
292
|
+
settled(): Promise<void>;
|
|
293
|
+
/** SIGTERM: every room checkpoints (onClose "shutdown", bounded), then its
|
|
294
|
+
* links close with 1012 so the platform reopens them on the next task. */
|
|
295
|
+
shutdown(): Promise<void>;
|
|
296
|
+
/** @internal A room is being discarded: a new link for its scope gets a
|
|
297
|
+
* fresh room, which waits for `done` before loading. */
|
|
298
|
+
forget(room: RoomImpl, done: Promise<void>): void;
|
|
299
|
+
/** @internal */
|
|
300
|
+
reportError(error: unknown, scope: string, handler: string): void;
|
|
301
|
+
}
|
|
302
|
+
export {};
|