@zaisma/game-server 1.10.0 → 1.12.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/README.md +9 -0
- package/bench/brand/README.md +22 -17
- package/bench/brand/app-icon-1024.png +0 -0
- package/bench/brand/app-icon-192.png +0 -0
- package/bench/brand/app-icon-512.png +0 -0
- package/bench/brand/app-icon-dark-512.png +0 -0
- package/bench/brand/app-icon-maskable-512.png +0 -0
- package/bench/brand/apple-touch-icon.png +0 -0
- package/bench/brand/favicon-32.png +0 -0
- package/bench/brand/favicon.ico +0 -0
- package/bench/brand/favicon.svg +1 -1
- package/bench/brand/manifest.webmanifest +26 -0
- package/bench/brand/share-card-dark.png +0 -0
- package/bench/brand/share-card-dark.svg +1 -0
- package/bench/brand/share-card-light.png +0 -0
- package/bench/brand/share-card-light.svg +1 -0
- package/bench/brand/stripe-icon.png +0 -0
- package/bench/brand/zaisma-icon-dark.svg +1 -0
- package/bench/brand/zaisma-icon-light.svg +1 -0
- package/bench/brand/zaisma-mark-glass-black-2048.png +0 -0
- package/bench/brand/zaisma-mark-glass-black.svg +1 -0
- package/bench/brand/zaisma-mark-glass-white-2048.png +0 -0
- package/bench/brand/zaisma-mark-glass-white.svg +1 -0
- package/bench/brand/zaisma-wordmark-glass-black-2400.png +0 -0
- package/bench/brand/zaisma-wordmark-glass-black.svg +1 -0
- package/bench/brand/zaisma-wordmark-glass-white-2400.png +0 -0
- package/bench/brand/zaisma-wordmark-glass-white.svg +1 -0
- package/bench/glass.js +3904 -1907
- package/bin/fast-lane.js +212 -0
- package/bin/local-play-certificate.js +11 -6
- package/bin/node-parts.js +9 -1
- package/bin/node.d.ts +12 -0
- package/bin/node.js +59 -15
- package/package.json +13 -1
- package/sdk/zaisma.d.ts +53 -7
- package/sdk/zaisma.js +196 -8
- package/src/qr-code.d.ts +5 -4
- package/src/qr-code.js +11 -10
- package/src/room.d.ts +18 -3
- package/src/room.js +12 -12
- package/bench/brand/zaisma-app-icon.svg +0 -1
- package/bench/brand/zaisma-mark-metal-2048.png +0 -0
- package/bench/brand/zaisma-mark-metal.svg +0 -1
- package/bench/brand/zaisma-mark-tricolor.svg +0 -1
- package/bench/brand/zaisma-wordmark-metal-2400.png +0 -0
- package/bench/brand/zaisma-wordmark-metal.svg +0 -1
package/bin/fast-lane.js
ADDED
|
@@ -0,0 +1,212 @@
|
|
|
1
|
+
// The fast lane to your own server: WebTransport (HTTP/3 over UDP) beside the WebSocket, for games that join
|
|
2
|
+
// with transport: "webtransport". Latest-only sends go as datagrams, so a late one is dropped instead of holding
|
|
3
|
+
// up what comes after it; everything else rides one stream, framed as the wire comment in src/room.ts says
|
|
4
|
+
// (above ServerMessage). @fails-components/webtransport serves it: an optional peer dependency, which developers
|
|
5
|
+
// who use the fast lane install themselves, loaded only when the runner is given one, so nobody else installs or loads it.
|
|
6
|
+
import { EventEmitter } from "node:events";
|
|
7
|
+
import { randomBytes } from "node:crypto";
|
|
8
|
+
import { GameServerError } from "../src/internal.js";
|
|
9
|
+
|
|
10
|
+
const FAST_LANE_PACKAGES = ["@fails-components/webtransport", "@fails-components/webtransport-transport-http3-quiche"];
|
|
11
|
+
const TEXT_KIND_NUMBER = 0;
|
|
12
|
+
const BYTES_KIND_NUMBER = 1;
|
|
13
|
+
/** A stream frame's header: its payload's length (4 bytes, big-endian), then its kind. */
|
|
14
|
+
const FRAME_HEADER_SIZE_BYTES = 5;
|
|
15
|
+
/** A session's datagram size when it doesn't say: under the 1200 bytes every QUIC path carries, less QUIC's and HTTP/3's own. */
|
|
16
|
+
const DEFAULT_MAXIMUM_DATAGRAM_SIZE_BYTES = 1100;
|
|
17
|
+
/** A closing session waits this long for what it sent before it to go out, then closes anyway. */
|
|
18
|
+
const CLOSE_WAIT_MS = 2000;
|
|
19
|
+
const ROOM_PATH = /^\/v1\/rooms\//;
|
|
20
|
+
|
|
21
|
+
const ignore = () => {};
|
|
22
|
+
|
|
23
|
+
/** The error for a fast lane whose package isn't there: the runner exits with it, and startServer's fastLaneReady rejects with it. */
|
|
24
|
+
function notInstalled() {
|
|
25
|
+
return new GameServerError("bad_option", "webtransportPort", `The fast lane needs ${FAST_LANE_PACKAGES.join(" and ")}, which aren't installed.`, `Install them (npm install ${FAST_LANE_PACKAGES.join(" ")}) for --webtransport-port, or leave the fast lane out.`);
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Listen for WebTransport sessions on a UDP port, with the server's certificate (PEM). Each session at
|
|
30
|
+
* /v1/rooms/... whose client opens its stream within helloMs goes to onConnection(socket, path, origin),
|
|
31
|
+
* where socket has the surface the runner uses on a ws WebSocket. Answers { sockets, close() } once it listens.
|
|
32
|
+
*/
|
|
33
|
+
export async function startFastLane({ port, certificatePem, keyPem, helloMs, maximumFrameSizeBytes, onConnection }) {
|
|
34
|
+
let webtransport;
|
|
35
|
+
try {
|
|
36
|
+
webtransport = await import(FAST_LANE_PACKAGES[0]);
|
|
37
|
+
// The HTTP/3 binary comes in a package of its own, which the first loads only when a server starts.
|
|
38
|
+
await webtransport.quicheLoaded;
|
|
39
|
+
} catch {
|
|
40
|
+
throw notInstalled();
|
|
41
|
+
}
|
|
42
|
+
const listen = async (host) => {
|
|
43
|
+
const server = new webtransport.Http3Server({ port, host, secret: randomBytes(32).toString("hex"), cert: certificatePem, privKey: keyPem });
|
|
44
|
+
server.startServer();
|
|
45
|
+
await server.ready;
|
|
46
|
+
return server;
|
|
47
|
+
};
|
|
48
|
+
// IPv6 and IPv4 together where the machine has IPv6, else IPv4 alone.
|
|
49
|
+
const server = await listen("::").catch(() => listen("0.0.0.0"));
|
|
50
|
+
const sockets = new Set();
|
|
51
|
+
// Every room address goes to one stream of sessions, carrying its own path; the room checks it as for the WebSocket.
|
|
52
|
+
server.setRequestCallback(async ({ header }) => {
|
|
53
|
+
const path = String(header[":path"] ?? "");
|
|
54
|
+
if (!ROOM_PATH.test(path)) return { header, path, status: 404 };
|
|
55
|
+
return { header: { ...header, ":path": "/v1/rooms" }, path: "/v1/rooms", userData: { path }, status: 200 };
|
|
56
|
+
});
|
|
57
|
+
const sessions = server.sessionStream("/v1/rooms").getReader();
|
|
58
|
+
void (async () => {
|
|
59
|
+
for (;;) {
|
|
60
|
+
const { value: session, done } = await sessions.read();
|
|
61
|
+
if (done) return;
|
|
62
|
+
take(session).catch(ignore);
|
|
63
|
+
}
|
|
64
|
+
})();
|
|
65
|
+
|
|
66
|
+
async function take(session) {
|
|
67
|
+
await session.ready;
|
|
68
|
+
// The client opens its stream first, with its hello: a session that doesn't is closed.
|
|
69
|
+
const streams = session.incomingBidirectionalStreams.getReader();
|
|
70
|
+
let timer;
|
|
71
|
+
const stream = await Promise.race([streams.read().then((read) => read.value), new Promise((resolve) => (timer = setTimeout(resolve, helloMs)))]);
|
|
72
|
+
clearTimeout(timer);
|
|
73
|
+
if (!stream) return void session.close({ closeCode: 4001, reason: "no_hello" });
|
|
74
|
+
// One stream is all a player has; any other is turned away.
|
|
75
|
+
void (async () => {
|
|
76
|
+
for (;;) {
|
|
77
|
+
const { value: other, done } = await streams.read().catch(() => ({ done: true }));
|
|
78
|
+
if (done) return;
|
|
79
|
+
other.readable.cancel().catch(ignore);
|
|
80
|
+
other.writable.abort().catch(ignore);
|
|
81
|
+
}
|
|
82
|
+
})();
|
|
83
|
+
const socket = new FastLaneSocket(session, stream, maximumFrameSizeBytes);
|
|
84
|
+
sockets.add(socket);
|
|
85
|
+
socket.once("close", () => sockets.delete(socket));
|
|
86
|
+
onConnection(socket, session.userData?.path ?? "", typeof session.header?.origin === "string" ? session.header.origin : undefined);
|
|
87
|
+
socket.start();
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
return {
|
|
91
|
+
sockets,
|
|
92
|
+
close() {
|
|
93
|
+
try {
|
|
94
|
+
server.stopServer();
|
|
95
|
+
} catch {}
|
|
96
|
+
},
|
|
97
|
+
};
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/**
|
|
101
|
+
* One player's fast-lane session, with what the runner uses on a ws WebSocket: send(message, { binary, delivery }),
|
|
102
|
+
* close(code, reason), terminate(), bufferedAmount, and "message" (payload, isBinary, isDatagram) and "close" (code) events.
|
|
103
|
+
*/
|
|
104
|
+
class FastLaneSocket extends EventEmitter {
|
|
105
|
+
constructor(session, stream, maximumFrameSizeBytes) {
|
|
106
|
+
super();
|
|
107
|
+
this.session = session;
|
|
108
|
+
this.stream = stream;
|
|
109
|
+
this.maximumFrameSizeBytes = maximumFrameSizeBytes;
|
|
110
|
+
this.writer = stream.writable.getWriter();
|
|
111
|
+
const datagrams = session.datagrams;
|
|
112
|
+
this.datagramWriter = (typeof datagrams.createWritable === "function" ? datagrams.createWritable() : datagrams.writable).getWriter();
|
|
113
|
+
/** Bytes handed to the stream and not yet taken by it, as a WebSocket's bufferedAmount. */
|
|
114
|
+
this.bufferedAmount = 0;
|
|
115
|
+
this.isOpen = true;
|
|
116
|
+
this.isClosed = false;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/** Read the stream and the datagrams, and hear the session close: after onConnection has its listeners on. */
|
|
120
|
+
start() {
|
|
121
|
+
this.session.closed.then((closeInfo) => this.end(closeInfo?.closeCode ?? 1005), () => this.end(1006));
|
|
122
|
+
this.readStream().catch(() => this.terminate());
|
|
123
|
+
this.readDatagrams().catch(ignore);
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
async readStream() {
|
|
127
|
+
const reader = this.stream.readable.getReader();
|
|
128
|
+
let buffered = Buffer.alloc(0);
|
|
129
|
+
for (;;) {
|
|
130
|
+
const { value: chunk, done } = await reader.read();
|
|
131
|
+
if (done) return;
|
|
132
|
+
buffered = buffered.length ? Buffer.concat([buffered, chunk]) : Buffer.from(chunk);
|
|
133
|
+
while (buffered.length >= FRAME_HEADER_SIZE_BYTES) {
|
|
134
|
+
const payloadSizeBytes = buffered.readUInt32BE(0);
|
|
135
|
+
const kindNumber = buffered[4];
|
|
136
|
+
// Over the limit, or of a kind the wire doesn't have: this connection ends, as a WebSocket's does.
|
|
137
|
+
if (payloadSizeBytes > this.maximumFrameSizeBytes || (kindNumber !== TEXT_KIND_NUMBER && kindNumber !== BYTES_KIND_NUMBER)) return this.terminate();
|
|
138
|
+
if (buffered.length < FRAME_HEADER_SIZE_BYTES + payloadSizeBytes) break;
|
|
139
|
+
const payload = Buffer.from(buffered.subarray(FRAME_HEADER_SIZE_BYTES, FRAME_HEADER_SIZE_BYTES + payloadSizeBytes));
|
|
140
|
+
buffered = buffered.subarray(FRAME_HEADER_SIZE_BYTES + payloadSizeBytes);
|
|
141
|
+
if (!this.isClosed) this.emit("message", payload, kindNumber === BYTES_KIND_NUMBER, false);
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
async readDatagrams() {
|
|
147
|
+
const reader = this.session.datagrams.readable.getReader();
|
|
148
|
+
for (;;) {
|
|
149
|
+
const { value: datagram, done } = await reader.read();
|
|
150
|
+
if (done || this.isClosed) return;
|
|
151
|
+
const kindNumber = datagram[0];
|
|
152
|
+
// A datagram that isn't one of the wire's is dropped, as a lost one would be.
|
|
153
|
+
if (datagram.length < 1 || (kindNumber !== TEXT_KIND_NUMBER && kindNumber !== BYTES_KIND_NUMBER)) continue;
|
|
154
|
+
this.emit("message", Buffer.from(datagram.subarray(1)), kindNumber === BYTES_KIND_NUMBER, true);
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
/** The most a datagram carries on this session. */
|
|
159
|
+
maximumDatagramSizeBytes() {
|
|
160
|
+
const maximumSizeBytes = this.session.datagrams.maxDatagramSize;
|
|
161
|
+
return typeof maximumSizeBytes === "number" && maximumSizeBytes > 0 ? maximumSizeBytes : DEFAULT_MAXIMUM_DATAGRAM_SIZE_BYTES;
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
/** A text, or a binary frame with { binary: true }. A latest-only one goes as a datagram when it fits; the rest on the stream, in order. */
|
|
165
|
+
send(message, options = {}) {
|
|
166
|
+
if (!this.isOpen) return;
|
|
167
|
+
const kindNumber = options.binary ? BYTES_KIND_NUMBER : TEXT_KIND_NUMBER;
|
|
168
|
+
const payload = typeof message === "string" ? Buffer.from(message, "utf8") : message;
|
|
169
|
+
if (options.delivery === "latest_only" && 1 + payload.length <= this.maximumDatagramSizeBytes()) {
|
|
170
|
+
const datagram = new Uint8Array(1 + payload.length);
|
|
171
|
+
datagram[0] = kindNumber;
|
|
172
|
+
datagram.set(payload, 1);
|
|
173
|
+
this.datagramWriter.write(datagram).catch(ignore);
|
|
174
|
+
return;
|
|
175
|
+
}
|
|
176
|
+
const frame = Buffer.alloc(FRAME_HEADER_SIZE_BYTES + payload.length);
|
|
177
|
+
frame.writeUInt32BE(payload.length, 0);
|
|
178
|
+
frame[4] = kindNumber;
|
|
179
|
+
frame.set(payload, FRAME_HEADER_SIZE_BYTES);
|
|
180
|
+
this.bufferedAmount += frame.length;
|
|
181
|
+
this.writer.write(frame).then(() => (this.bufferedAmount -= frame.length), ignore);
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
/** Close with a code and reason, after what was sent before goes out (as a WebSocket's close frame waits behind it). */
|
|
185
|
+
close(closeCode = 1000, reason = "") {
|
|
186
|
+
if (!this.isOpen) return;
|
|
187
|
+
this.isOpen = false;
|
|
188
|
+
let timer;
|
|
189
|
+
Promise.race([this.writer.close(), new Promise((resolve) => (timer = setTimeout(resolve, CLOSE_WAIT_MS)))])
|
|
190
|
+
.catch(ignore)
|
|
191
|
+
.finally(() => {
|
|
192
|
+
clearTimeout(timer);
|
|
193
|
+
this.end(closeCode, reason);
|
|
194
|
+
});
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
/** Close now, dropping what hasn't gone out. */
|
|
198
|
+
terminate() {
|
|
199
|
+
this.isOpen = false;
|
|
200
|
+
this.end(1006, "");
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
end(closeCode, reason = "") {
|
|
204
|
+
if (this.isClosed) return;
|
|
205
|
+
this.isOpen = false;
|
|
206
|
+
this.isClosed = true;
|
|
207
|
+
try {
|
|
208
|
+
this.session.close({ closeCode, reason });
|
|
209
|
+
} catch {}
|
|
210
|
+
this.emit("close", closeCode);
|
|
211
|
+
}
|
|
212
|
+
}
|
|
@@ -20,9 +20,12 @@ const explicit = (tagNumber, content) => der(0xa0 + tagNumber, content);
|
|
|
20
20
|
const octets = (bytes) => der(0x04, bytes);
|
|
21
21
|
const bits = (bytes) => der(0x03, Buffer.concat([Buffer.from([0]), bytes]));
|
|
22
22
|
const utf8 = (text) => der(0x0c, Buffer.from(text, "utf8"));
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
23
|
+
/** A positive integer, in the fewest bytes DER allows: leading zero bytes dropped, then one back when the first bit is set. */
|
|
24
|
+
export function integer(bytes) {
|
|
25
|
+
let zeroByteCount = 0;
|
|
26
|
+
while (zeroByteCount < bytes.length - 1 && bytes[zeroByteCount] === 0) zeroByteCount += 1;
|
|
27
|
+
const minimal = bytes.subarray(zeroByteCount);
|
|
28
|
+
return der(0x02, minimal[0] & 0x80 ? Buffer.concat([Buffer.from([0]), minimal]) : minimal);
|
|
26
29
|
}
|
|
27
30
|
function objectId(dotted) {
|
|
28
31
|
const [first, second, ...rest] = dotted.split(".").map(Number);
|
|
@@ -56,8 +59,10 @@ function addressBytes(address) {
|
|
|
56
59
|
/**
|
|
57
60
|
* A self-signed certificate for these names and addresses ("localhost", "192.168.1.20"), with its key, as PEM text:
|
|
58
61
|
* { certificatePem, keyPem }. ECDSA P-256 and SHA-256, for server authentication, as phones ask of a server's certificate.
|
|
62
|
+
* The serial is 16 random bytes unless serialBytes is given (a test's way to try the serials that are hard to write).
|
|
63
|
+
* It lasts durationDays from now (and a day before, for clocks a little behind).
|
|
59
64
|
*/
|
|
60
|
-
export function makeCertificate(names, nowMs = Date.now()) {
|
|
65
|
+
export function makeCertificate(names, nowMs = Date.now(), serialBytes = randomBytes(16), durationDays = CERTIFICATE_DURATION_DAYS) {
|
|
61
66
|
const { privateKey, publicKey } = generateKeyPairSync("ec", { namedCurve: "prime256v1" });
|
|
62
67
|
const name = sequence(set(sequence(objectId("2.5.4.3"), utf8("Zaisma test bench"))));
|
|
63
68
|
const signatureAlgorithm = sequence(objectId("1.2.840.10045.4.3.2"));
|
|
@@ -69,14 +74,14 @@ export function makeCertificate(names, nowMs = Date.now()) {
|
|
|
69
74
|
// Basic constraints: not a certificate authority.
|
|
70
75
|
sequence(objectId("2.5.29.19"), octets(sequence())),
|
|
71
76
|
));
|
|
72
|
-
const serial =
|
|
77
|
+
const serial = Buffer.from(serialBytes);
|
|
73
78
|
serial[0] &= 0x7f;
|
|
74
79
|
const toBeSigned = sequence(
|
|
75
80
|
explicit(0, integer(Buffer.from([2]))),
|
|
76
81
|
integer(serial),
|
|
77
82
|
signatureAlgorithm,
|
|
78
83
|
name,
|
|
79
|
-
sequence(utcTime(nowMs - DAY_MS), utcTime(nowMs +
|
|
84
|
+
sequence(utcTime(nowMs - DAY_MS), utcTime(nowMs + durationDays * DAY_MS)),
|
|
80
85
|
name,
|
|
81
86
|
publicKey.export({ type: "spki", format: "der" }),
|
|
82
87
|
extensions,
|
package/bin/node-parts.js
CHANGED
|
@@ -289,7 +289,7 @@ export function secretsFromEnvironment() {
|
|
|
289
289
|
return Object.freeze(Object.fromEntries(Object.entries(process.env).filter(([, environmentValue]) => typeof environmentValue === "string")));
|
|
290
290
|
}
|
|
291
291
|
|
|
292
|
-
const OPTION_NAMES = ["gameServer", "gameSlug", "port", "origins", "issuerUrl", "loadPassKeys", "loadReceiptKeys", "loadProfile", "database", "secrets", "webhookSigningSecret", "roomStateFile", "zaismaKey", "developerApiUrl"];
|
|
292
|
+
const OPTION_NAMES = ["gameServer", "gameSlug", "port", "origins", "issuerUrl", "loadPassKeys", "loadReceiptKeys", "loadProfile", "database", "secrets", "webhookSigningSecret", "roomStateFile", "zaismaKey", "developerApiUrl", "webtransportPort", "certificatePath", "certificateKeyPath"];
|
|
293
293
|
|
|
294
294
|
/**
|
|
295
295
|
* Check what startServer was given: unknown options and options of the wrong kind are a GameServerError,
|
|
@@ -325,5 +325,13 @@ export function checkedStartServerOptions(given, issuerUrlDefault) {
|
|
|
325
325
|
if (options.roomStateFile !== undefined && options.roomStateFile !== null && !isText(options.roomStateFile)) throw bad("roomStateFile", "a file path", "Give a file path as text, like \"zaisma-room-states.sqlite\", or null to keep no states.");
|
|
326
326
|
if (options.zaismaKey !== undefined && !isText(options.zaismaKey)) throw bad("zaismaKey", "an API key", "Give an API key with purchase_access write for this game as text, or leave it out to use ZAISMA_KEY.");
|
|
327
327
|
if (options.developerApiUrl !== undefined && (!isText(options.developerApiUrl) || !URL.canParse(options.developerApiUrl))) throw bad("developerApiUrl", "a URL", 'Give the address of Zaisma\'s developer API, like "https://zaisma.com/api/dev".');
|
|
328
|
+
// The fast lane (WebTransport): its UDP port and the certificate it holds itself, all three or none.
|
|
329
|
+
const fastLaneFields = ["webtransportPort", "certificatePath", "certificateKeyPath"];
|
|
330
|
+
const missingFastLaneFields = fastLaneFields.filter((field) => options[field] === undefined);
|
|
331
|
+
if (missingFastLaneFields.length && missingFastLaneFields.length < fastLaneFields.length) throw fail("bad_option", missingFastLaneFields[0], `The fast lane needs ${missingFastLaneFields.join(" and ")} too.`, `Give ${fastLaneFields.join(", ")} together, or none of them.`);
|
|
332
|
+
if (options.webtransportPort !== undefined && !(Number.isInteger(options.webtransportPort) && options.webtransportPort >= 0 && options.webtransportPort <= 65535)) throw bad("webtransportPort", "a port number", "Give the UDP port for the fast lane, a whole number from 0 to 65535 (0 picks a free one), like 443.");
|
|
333
|
+
for (const field of ["certificatePath", "certificateKeyPath"]) {
|
|
334
|
+
if (options[field] !== undefined && !isText(options[field])) throw bad(field, "a file path", `Give the path of a PEM file as text, like "${field === "certificatePath" ? "fullchain.pem" : "privkey.pem"}".`);
|
|
335
|
+
}
|
|
328
336
|
return options;
|
|
329
337
|
}
|
package/bin/node.d.ts
CHANGED
|
@@ -31,6 +31,16 @@ export interface StartServerOptions {
|
|
|
31
31
|
zaismaKey?: string;
|
|
32
32
|
/** Zaisma's developer API, which marks purchases handled. Default: https://zaisma.com/api/dev. */
|
|
33
33
|
developerApiUrl?: string;
|
|
34
|
+
/**
|
|
35
|
+
* The fast lane: the UDP port where WebTransport sessions come, for games that join with transport: "webtransport".
|
|
36
|
+
* Give it with certificatePath and certificateKeyPath, or none of the three. It needs the optional packages
|
|
37
|
+
* @fails-components/webtransport and @fails-components/webtransport-transport-http3-quiche.
|
|
38
|
+
*/
|
|
39
|
+
webtransportPort?: number;
|
|
40
|
+
/** The fast lane's certificate for the server's name, a PEM file (Let's Encrypt's fullchain.pem): WebTransport can't sit behind a TLS proxy, so the runner holds it. */
|
|
41
|
+
certificatePath?: string;
|
|
42
|
+
/** The fast lane's certificate key, a PEM file (Let's Encrypt's privkey.pem). */
|
|
43
|
+
certificateKeyPath?: string;
|
|
34
44
|
}
|
|
35
45
|
|
|
36
46
|
export interface RunningServer {
|
|
@@ -38,6 +48,8 @@ export interface RunningServer {
|
|
|
38
48
|
close(): Promise<void>;
|
|
39
49
|
/** The HTTP server, for its address (httpServer.address()). */
|
|
40
50
|
httpServer: Server;
|
|
51
|
+
/** Settles once the fast lane listens (at once without one), or rejects with a GameServerError saying why it can't start. */
|
|
52
|
+
fastLaneReady: Promise<void>;
|
|
41
53
|
}
|
|
42
54
|
|
|
43
55
|
/**
|
package/bin/node.js
CHANGED
|
@@ -17,13 +17,16 @@
|
|
|
17
17
|
// Its rooms' states are another, --room-state-file, so a restarted server brings its matches back (onRestored).
|
|
18
18
|
// Its secrets (room.secrets, server.secrets) are this process's environment variables, and it calls
|
|
19
19
|
// other servers with plain fetch(), with no list: on your own server nothing stands in between.
|
|
20
|
+
// With --webtransport-port, --certificate-path and --certificate-key-path, it also serves the fast lane:
|
|
21
|
+
// WebTransport on that UDP port, for games that join with transport: "webtransport" (bin/fast-lane.js).
|
|
20
22
|
import { WebSocketServer } from "ws";
|
|
21
23
|
import { createServer } from "node:http";
|
|
22
24
|
import { fileURLToPath, pathToFileURL } from "node:url";
|
|
23
25
|
import { resolve } from "node:path";
|
|
24
|
-
import { realpathSync } from "node:fs";
|
|
26
|
+
import { readFileSync, realpathSync } from "node:fs";
|
|
25
27
|
import { BUSY_CLOSE_CODE, ENDED_CLOSE_CODE, ERROR_TEXT, NO_ANSWER_CLOSE_CODE, PASS_ISSUER_URL, PassError, ReceiptError, REMOVED_CLOSE_CODE, REQUEST_ERROR_HTTP_STATUS, REQUEST_LIMITS, ROOM_LIMITS, ROOM_TUNING, WEBHOOK_LIMITS, GameServerError, RoomCore, chatSettingsOf, checkChat, readChatMessage, restoreInfoOf, passesAreFriends, passesHaveBlockBetween, answerRequest, startSchedules, checkRoomData, clientIdle, defineGameServer, errorMessage, isRoomCode, readRequestBody, relay, requestError, runWebhook, tidyRoomInfo, verifyPass, verifyReceipt, verifyWebhook } from "../src/internal.js";
|
|
26
28
|
import { OutsideCallError } from "../src/outside-calls.js";
|
|
29
|
+
import { startFastLane } from "./fast-lane.js";
|
|
27
30
|
import { checkedStartServerOptions, localDatabase, localRoomStates, passKeysFrom, profilesFrom, receiptKeysFrom, secretsFromEnvironment } from "./node-parts.js";
|
|
28
31
|
|
|
29
32
|
// Code that names OutsideCallError, as Zaisma's sandbox lets it, moves unchanged: nothing here refuses a call, so instanceof is simply false.
|
|
@@ -69,16 +72,28 @@ const CATCH_UP_MS = 50;
|
|
|
69
72
|
* rooms save their states, so a restart brings matches back; by default zaisma-room-states.sqlite, null to keep none),
|
|
70
73
|
* zaismaKey (an API key with purchase_access write for this game, which onPurchase and onRefund need; by default
|
|
71
74
|
* the ZAISMA_KEY environment variable) and developerApiUrl (Zaisma's developer API; by default https://zaisma.com/api/dev).
|
|
72
|
-
*
|
|
75
|
+
* webtransportPort (a UDP port), certificatePath and certificateKeyPath (the server's certificate and its key,
|
|
76
|
+
* PEM files), all three or none, also serve the fast lane (WebTransport) beside the WebSocket.
|
|
77
|
+
* Answers { close(), httpServer, fastLaneReady }: close() saves every room first and finishes once the server
|
|
78
|
+
* has stopped; fastLaneReady settles once the fast lane listens (at once without one), or rejects saying why it can't.
|
|
73
79
|
*/
|
|
74
80
|
export function startServer(options) {
|
|
75
|
-
const { gameServer, gameSlug, port: portNumber, origins, issuerUrl, loadPassKeys = passKeysFrom(`${issuerUrl}/.well-known/jwks.json`), loadReceiptKeys = receiptKeysFrom(`${issuerUrl}/.well-known/receipt-jwks.json`), loadProfile = profilesFrom(issuerUrl), database = localDatabase("zaisma-database.sqlite"), secrets = secretsFromEnvironment(), webhookSigningSecret = process.env.ZAISMA_WEBHOOK_SIGNING_SECRET, roomStateFile = "zaisma-room-states.sqlite", zaismaKey = process.env.ZAISMA_KEY, developerApiUrl = "https://zaisma.com/api/dev" } = checkedStartServerOptions(options, PASS_ISSUER_URL);
|
|
81
|
+
const { gameServer, gameSlug, port: portNumber, origins, issuerUrl, loadPassKeys = passKeysFrom(`${issuerUrl}/.well-known/jwks.json`), loadReceiptKeys = receiptKeysFrom(`${issuerUrl}/.well-known/receipt-jwks.json`), loadProfile = profilesFrom(issuerUrl), database = localDatabase("zaisma-database.sqlite"), secrets = secretsFromEnvironment(), webhookSigningSecret = process.env.ZAISMA_WEBHOOK_SIGNING_SECRET, roomStateFile = "zaisma-room-states.sqlite", zaismaKey = process.env.ZAISMA_KEY, developerApiUrl = "https://zaisma.com/api/dev", webtransportPort, certificatePath, certificateKeyPath } = checkedStartServerOptions(options, PASS_ISSUER_URL);
|
|
76
82
|
const definition = defineGameServer(gameServer);
|
|
77
83
|
// Exactly once needs Zaisma's record of what's handled: without a key, purchases would come twice, so it doesn't start.
|
|
78
84
|
if ((definition.onPurchase || definition.onRefund) && !zaismaKey) {
|
|
79
85
|
const hook = definition.onPurchase ? "onPurchase" : "onRefund";
|
|
80
86
|
throw new GameServerError("bad_option", "zaismaKey", `${hook} needs zaismaKey, so each purchase is delivered once.`, "Give an API key with purchase_access write for this game as zaismaKey, or set ZAISMA_KEY.");
|
|
81
87
|
}
|
|
88
|
+
// The fast lane's certificate, read before anything listens, so a wrong path says so at once.
|
|
89
|
+
const readPem = (path, field) => {
|
|
90
|
+
try {
|
|
91
|
+
return readFileSync(path, "utf8");
|
|
92
|
+
} catch (error) {
|
|
93
|
+
throw new GameServerError("bad_option", field, `${field} ${path} couldn't be read (${error.code ?? error.message}).`, "Give the path of a PEM file this process can read, like the fullchain.pem and privkey.pem Let's Encrypt makes.");
|
|
94
|
+
}
|
|
95
|
+
};
|
|
96
|
+
const fastLaneCertificate = webtransportPort === undefined ? null : { certificatePem: readPem(certificatePath, "certificatePath"), keyPem: readPem(certificateKeyPath, "certificateKeyPath") };
|
|
82
97
|
const purchaseHandling = definition.onPurchase || definition.onRefund ? ownServerPurchaseHandling(gameSlug, zaismaKey, developerApiUrl) : undefined;
|
|
83
98
|
const roomStates = roomStateFile === null ? null : localRoomStates(roomStateFile);
|
|
84
99
|
const stateSaveIntervalMs = (definition.stateSaveIntervalSec ?? ROOM_LIMITS.defaultStateSaveIntervalSec) * 1000;
|
|
@@ -88,6 +103,9 @@ export function startServer(options) {
|
|
|
88
103
|
const httpServer = createServer((request, response) => answerHttp(request, response));
|
|
89
104
|
const wss = new WebSocketServer({ server: httpServer, maxPayload: MAXIMUM_SOCKET_MESSAGE_SIZE_BYTES });
|
|
90
105
|
httpServer.listen(portNumber);
|
|
106
|
+
// The fast lane's players come in the same way as the WebSocket's (connected), on a socket with the same surface.
|
|
107
|
+
const fastLaneReady = fastLaneCertificate ? startFastLane({ port: webtransportPort, ...fastLaneCertificate, helloMs: HELLO_MS, maximumFrameSizeBytes: MAXIMUM_SOCKET_MESSAGE_SIZE_BYTES, onConnection: connected }) : Promise.resolve(null);
|
|
108
|
+
fastLaneReady.catch(() => {});
|
|
91
109
|
|
|
92
110
|
/** room.verifyReceipt and server.verifyReceipt: a receipt for this game from Zaisma's receipt issuer, checked against its keys (fetched again once when its key is new). */
|
|
93
111
|
async function receiptClaims(receipt) {
|
|
@@ -256,8 +274,9 @@ export function startServer(options) {
|
|
|
256
274
|
secrets,
|
|
257
275
|
verifyReceipt: receiptClaims,
|
|
258
276
|
purchaseHandling,
|
|
259
|
-
|
|
260
|
-
|
|
277
|
+
// delivery reaches a fast-lane socket, which sends latest-only ones as datagrams; ws doesn't read it.
|
|
278
|
+
send: (id, text, delivery) => sockets.get(id)?.send(text, { delivery }),
|
|
279
|
+
sendBytes: (id, frame, delivery) => sockets.get(id)?.send(frame, { binary: true, delivery }),
|
|
261
280
|
isBackedUp: (id) => (sockets.get(id)?.bufferedAmount ?? 0) >= BACKED_UP_SIZE_BYTES,
|
|
262
281
|
close: (id, code, reason) => {
|
|
263
282
|
const ws = sockets.get(id);
|
|
@@ -485,8 +504,11 @@ export function startServer(options) {
|
|
|
485
504
|
}, CATCH_UP_MS);
|
|
486
505
|
catcher.unref?.();
|
|
487
506
|
|
|
488
|
-
wss.on("connection", (ws, request) =>
|
|
489
|
-
|
|
507
|
+
wss.on("connection", (ws, request) => connected(ws, request.url ?? "", request.headers.origin));
|
|
508
|
+
|
|
509
|
+
/** A player's connection to /v1/rooms/<game>/<code>: a ws WebSocket, or a fast-lane socket with the same surface. */
|
|
510
|
+
function connected(ws, url, origin) {
|
|
511
|
+
const address = /^\/v1\/rooms\/([a-z0-9-]{1,40})\/([A-Z0-9]{6})$/.exec(url);
|
|
490
512
|
const refuse = (code, message) => {
|
|
491
513
|
ws.send(errorMessage(code, message));
|
|
492
514
|
// Busy is a try-again (1013); every other refusal is final (4001).
|
|
@@ -494,14 +516,16 @@ export function startServer(options) {
|
|
|
494
516
|
};
|
|
495
517
|
// Refused before the hello too, with a code the SDK reads: a browser never sees why a socket closed otherwise.
|
|
496
518
|
if (!address || address[1] !== gameSlug || !isRoomCode(address[2])) return refuse("game_unavailable");
|
|
497
|
-
if (origins.length && !origins.includes(
|
|
519
|
+
if (origins.length && !origins.includes(origin)) return refuse("bad_origin");
|
|
498
520
|
if (typeof definition.setup !== "function") return refuse("game_unavailable", "This game server has no setup(), so it runs no rooms: it only answers requests, runs on schedules or takes webhooks.");
|
|
499
521
|
const code = address[2];
|
|
500
522
|
let playerId = null;
|
|
501
523
|
const helloTimer = setTimeout(() => playerId || refuse("no_hello"), HELLO_MS);
|
|
502
524
|
ws.zaismaSeenAtMs = Date.now();
|
|
503
525
|
|
|
504
|
-
const onMessage = async (received, isBinary) => {
|
|
526
|
+
const onMessage = async (received, isBinary, isDatagram) => {
|
|
527
|
+
// Datagrams carry only latest-only sends, from a player already in.
|
|
528
|
+
if (isDatagram && !playerId) return;
|
|
505
529
|
const text = isBinary ? null : received.toString();
|
|
506
530
|
ws.zaismaSeenAtMs = Date.now();
|
|
507
531
|
// Games ping every 25 seconds or so, so a connection that stops answering can be dropped.
|
|
@@ -587,8 +611,8 @@ export function startServer(options) {
|
|
|
587
611
|
pace(code, room);
|
|
588
612
|
};
|
|
589
613
|
// Whatever a message hits, it ends that connection or that message, never the process and the matches on it.
|
|
590
|
-
ws.on("message", (received, isBinary) => {
|
|
591
|
-
onMessage(received, isBinary).catch((error) => {
|
|
614
|
+
ws.on("message", (received, isBinary, isDatagram = false) => {
|
|
615
|
+
onMessage(received, isBinary, isDatagram).catch((error) => {
|
|
592
616
|
console.error("Couldn't handle a message:", error);
|
|
593
617
|
if (!playerId) refuse("busy");
|
|
594
618
|
});
|
|
@@ -608,7 +632,7 @@ export function startServer(options) {
|
|
|
608
632
|
pace(code, room);
|
|
609
633
|
closeIfEmpty(code, room);
|
|
610
634
|
});
|
|
611
|
-
}
|
|
635
|
+
}
|
|
612
636
|
|
|
613
637
|
// Every room saves as the server stops, so a clean restart loses nothing.
|
|
614
638
|
const close = () =>
|
|
@@ -625,10 +649,15 @@ export function startServer(options) {
|
|
|
625
649
|
setTimeout(() => {
|
|
626
650
|
for (const ws of wss.clients) ws.terminate();
|
|
627
651
|
}, 2000).unref?.();
|
|
652
|
+
void fastLaneReady.then((fastLane) => {
|
|
653
|
+
if (!fastLane) return;
|
|
654
|
+
for (const socket of fastLane.sockets) socket.close(1012, "Server restarting");
|
|
655
|
+
setTimeout(() => fastLane.close(), 2000).unref?.();
|
|
656
|
+
}, () => {});
|
|
628
657
|
wss.close(() => httpServer.close(() => done()));
|
|
629
658
|
httpServer.closeAllConnections?.();
|
|
630
659
|
});
|
|
631
|
-
return { close, httpServer };
|
|
660
|
+
return { close, httpServer, fastLaneReady: fastLaneReady.then(() => undefined) };
|
|
632
661
|
}
|
|
633
662
|
|
|
634
663
|
/** A saved state's key: its JSON, who was in the match and whether the room is kept, since any of them changing is worth a save. */
|
|
@@ -720,8 +749,10 @@ function binaryBytes(received) {
|
|
|
720
749
|
return new Uint8Array(received.buffer.slice(received.byteOffset, received.byteOffset + received.byteLength));
|
|
721
750
|
}
|
|
722
751
|
|
|
723
|
-
const USAGE = "Usage: zaisma-game-server <server.ts | relay> --game <slug> [--port 8080] [--origin <url>,<url>] [--issuer-url https://rooms.zaisma.com] [--database-file zaisma-database.sqlite] [--room-state-file zaisma-room-states.sqlite]\n or: zaisma-game-server play-locally [folder] [--port 4800] [--game <slug>] [--on-network] [--no-open] [--json]";
|
|
724
|
-
const FLAGS = ["game", "port", "origin", "issuer-url", "database-file", "room-state-file"];
|
|
752
|
+
const USAGE = "Usage: zaisma-game-server <server.ts | relay> --game <slug> [--port 8080] [--origin <url>,<url>] [--issuer-url https://rooms.zaisma.com] [--database-file zaisma-database.sqlite] [--room-state-file zaisma-room-states.sqlite] [--webtransport-port 443 --certificate-path fullchain.pem --certificate-key-path privkey.pem]\n or: zaisma-game-server play-locally [folder] [--port 4800] [--game <slug>] [--on-network] [--no-open] [--json]";
|
|
753
|
+
const FLAGS = ["game", "port", "origin", "issuer-url", "database-file", "room-state-file", "webtransport-port", "certificate-path", "certificate-key-path"];
|
|
754
|
+
/** The fast lane's flags: all three or none. */
|
|
755
|
+
const FAST_LANE_FLAGS = ["webtransport-port", "certificate-path", "certificate-key-path"];
|
|
725
756
|
|
|
726
757
|
/** The command line's flags by name, or null for one this runner doesn't take (or one missing its value). */
|
|
727
758
|
function flagsFrom(argumentList) {
|
|
@@ -807,6 +838,11 @@ async function runCommand() {
|
|
|
807
838
|
console.log(USAGE);
|
|
808
839
|
process.exit(1);
|
|
809
840
|
}
|
|
841
|
+
const missingFastLaneFlags = FAST_LANE_FLAGS.filter((name) => flags[name] === undefined);
|
|
842
|
+
if (missingFastLaneFlags.length && missingFastLaneFlags.length < FAST_LANE_FLAGS.length) {
|
|
843
|
+
console.log(`The fast lane needs ${missingFastLaneFlags.map((name) => `--${name}`).join(" and ")} too: give all three of ${FAST_LANE_FLAGS.map((name) => `--${name}`).join(", ")}, or none.\n${USAGE}`);
|
|
844
|
+
process.exit(1);
|
|
845
|
+
}
|
|
810
846
|
const gameServer = file === "relay" ? relay : (await import(pathToFileURL(resolve(file)).href)).default;
|
|
811
847
|
const portNumber = Number(flags.port ?? "8080");
|
|
812
848
|
let running;
|
|
@@ -819,6 +855,7 @@ async function runCommand() {
|
|
|
819
855
|
...(flags["issuer-url"] !== undefined ? { issuerUrl: flags["issuer-url"] } : {}),
|
|
820
856
|
...(flags["database-file"] !== undefined ? { database: localDatabase(resolve(flags["database-file"])) } : {}),
|
|
821
857
|
roomStateFile: resolve(flags["room-state-file"] ?? "zaisma-room-states.sqlite"),
|
|
858
|
+
...(flags["webtransport-port"] !== undefined ? { webtransportPort: Number(flags["webtransport-port"]), certificatePath: resolve(flags["certificate-path"]), certificateKeyPath: resolve(flags["certificate-key-path"]) } : {}),
|
|
822
859
|
});
|
|
823
860
|
} catch (error) {
|
|
824
861
|
// A bad flag, or an option the runner refuses: said plainly, before anything listens.
|
|
@@ -829,6 +866,13 @@ async function runCommand() {
|
|
|
829
866
|
for (const signal of ["SIGTERM", "SIGINT"]) process.once(signal, () => running.close().then(() => process.exit(0)));
|
|
830
867
|
const scheduleNames = (gameServer.schedules ?? []).map((schedule) => `${schedule.name} (${schedule.cron})`);
|
|
831
868
|
const webhookNote = ` Webhooks come to POST /v1/webhooks${process.env.ZAISMA_WEBHOOK_SIGNING_SECRET ? "" : " once ZAISMA_WEBHOOK_SIGNING_SECRET is set"}${typeof gameServer.onWebhook === "function" ? "" : " (account_deleted only: it erases the player from the database)"}.`;
|
|
869
|
+
running.fastLaneReady.then(
|
|
870
|
+
() => flags["webtransport-port"] !== undefined && console.log(`Fast lane (WebTransport) on UDP port ${flags["webtransport-port"]}, for games that join with transport: "webtransport".`),
|
|
871
|
+
(error) => {
|
|
872
|
+
console.error(error instanceof GameServerError ? error.message : error);
|
|
873
|
+
process.exit(1);
|
|
874
|
+
},
|
|
875
|
+
);
|
|
832
876
|
console.log(`Game server for "${flags.game}" on port ${portNumber}. Players connect to /v1/rooms/${flags.game}/<code>; requests come to POST /v1/requests/${flags.game}.${scheduleNames.length ? ` Schedules, in UTC: ${scheduleNames.join(", ")}.` : ""}${webhookNote}`);
|
|
833
877
|
}
|
|
834
878
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@zaisma/game-server",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.12.0",
|
|
4
4
|
"description": "Run your Zaisma game server: the same rooms on Zaisma or on your own server.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"homepage": "https://zaisma.com",
|
|
@@ -38,5 +38,17 @@
|
|
|
38
38
|
},
|
|
39
39
|
"dependencies": {
|
|
40
40
|
"ws": "8.21.3"
|
|
41
|
+
},
|
|
42
|
+
"peerDependencies": {
|
|
43
|
+
"@fails-components/webtransport": "1.6.11",
|
|
44
|
+
"@fails-components/webtransport-transport-http3-quiche": "1.6.11"
|
|
45
|
+
},
|
|
46
|
+
"peerDependenciesMeta": {
|
|
47
|
+
"@fails-components/webtransport": {
|
|
48
|
+
"optional": true
|
|
49
|
+
},
|
|
50
|
+
"@fails-components/webtransport-transport-http3-quiche": {
|
|
51
|
+
"optional": true
|
|
52
|
+
}
|
|
41
53
|
}
|
|
42
54
|
}
|
package/sdk/zaisma.d.ts
CHANGED
|
@@ -140,7 +140,7 @@ declare namespace Zaisma {
|
|
|
140
140
|
* refused (the game server's canJoin said no; the message is its refusalMessage), hosting_paused (the
|
|
141
141
|
* game's hosting is paused this month: its free allowance or its developer's budget or credit is used). No player pass: unchecked, or account_needed for a
|
|
142
142
|
* guest when the game needs accounts (see Zaisma.player.getPass()). The
|
|
143
|
-
* call is set up wrong: unknown_option, bad_room_code, bad_server_url, not_on_zaisma (the game isn't hosted
|
|
143
|
+
* call is set up wrong: unknown_option, bad_room_code, bad_server_url, bad_transport, not_on_zaisma (the game isn't hosted
|
|
144
144
|
* on Zaisma, serverUrl or not; serverUrl points a game hosted on Zaisma at your own server). Or connection_closed (the connection closed first, say the game left or the player
|
|
145
145
|
* joined from another tab) and request_failed (Zaisma didn't answer). {@link ErrorCode} has them all.
|
|
146
146
|
*
|
|
@@ -566,6 +566,32 @@ declare namespace Zaisma {
|
|
|
566
566
|
*/
|
|
567
567
|
const versions: Versions;
|
|
568
568
|
|
|
569
|
+
/**
|
|
570
|
+
* Whether Zaisma's page covers the game right now, so a game can pause or hold the player's input while it does.
|
|
571
|
+
* coveredBy is "sheet" while one of Zaisma's sheets is open over the game (buying, Players, Report: the player is
|
|
572
|
+
* busy with it), "card" while one of its cards is over the game's corner (Still there?, Keep your progress; the
|
|
573
|
+
* game stays in view and playable), and null otherwise. Always null outside Zaisma's page (your own site, a
|
|
574
|
+
* local test). Listen for "coveredBy" to hear each change.
|
|
575
|
+
* @example
|
|
576
|
+
* Zaisma.page.on("coveredBy", (coveredBy) => {
|
|
577
|
+
* if (coveredBy === "sheet") pauseGame();
|
|
578
|
+
* else resumeGame();
|
|
579
|
+
* });
|
|
580
|
+
*/
|
|
581
|
+
const page: Page;
|
|
582
|
+
|
|
583
|
+
/** Whether Zaisma's page covers the game ({@link page}). */
|
|
584
|
+
interface Page {
|
|
585
|
+
/** "sheet", "card" or null: what of Zaisma's is over the game now. Read-only. */
|
|
586
|
+
readonly coveredBy: CoveredBy;
|
|
587
|
+
/** Listen for "coveredBy": called with the new value each time it changes. A wrong name fails with error.code "unknown_event". */
|
|
588
|
+
on(event: "coveredBy", listener: (coveredBy: CoveredBy) => void): Page;
|
|
589
|
+
off(event: "coveredBy", listener: (coveredBy: CoveredBy) => void): Page;
|
|
590
|
+
}
|
|
591
|
+
|
|
592
|
+
/** What of Zaisma's covers the game ({@link page}): one of its sheets, one of its cards, or nothing. */
|
|
593
|
+
type CoveredBy = "sheet" | "card" | null;
|
|
594
|
+
|
|
569
595
|
/** A version published "now" ({@link versions}). */
|
|
570
596
|
interface Versions {
|
|
571
597
|
/** Listen for "update". A wrong name fails with error.code "unknown_event". */
|
|
@@ -797,6 +823,18 @@ declare namespace Zaisma {
|
|
|
797
823
|
* is allowed to connect to it.
|
|
798
824
|
*/
|
|
799
825
|
serverUrl?: string;
|
|
826
|
+
/**
|
|
827
|
+
* How to reach a room on your own server (serverUrl, wss:// or https://): "websocket" (the default), or
|
|
828
|
+
* "webtransport", the fast lane: WebTransport, where sends with delivery "latest_only" go as datagrams, so a
|
|
829
|
+
* late one is dropped instead of holding up what comes after it, as PC shooters play. Your server has to
|
|
830
|
+
* serve it (the Node runner's --webtransport-port). Where WebTransport isn't there or doesn't connect in
|
|
831
|
+
* 3 seconds (a network that blocks UDP), the room uses the WebSocket by itself; {@link Room.transport} says
|
|
832
|
+
* which. Zaisma rooms use the WebSocket either way. Anything else is refused with error.code "bad_transport".
|
|
833
|
+
*
|
|
834
|
+
* @example
|
|
835
|
+
* const room = await Zaisma.joinRoom({ serverUrl: "wss://play.example.com", transport: "webtransport" });
|
|
836
|
+
*/
|
|
837
|
+
transport?: "websocket" | "webtransport";
|
|
800
838
|
/**
|
|
801
839
|
* true: a new room that shows in room lists, for others to find. By default false: a room only its
|
|
802
840
|
* code and link reach, like a private match ({@link Rooms.quickJoin} lists its new rooms unless given
|
|
@@ -852,10 +890,10 @@ declare namespace Zaisma {
|
|
|
852
890
|
type Region = "AF" | "AN" | "AS" | "EU" | "NA" | "OC" | "SA";
|
|
853
891
|
|
|
854
892
|
/**
|
|
855
|
-
* What {@link Rooms.quickJoin} takes: joinRoom's options but roomCode, isSpectator and
|
|
856
|
-
* finds Zaisma rooms), and region.
|
|
893
|
+
* What {@link Rooms.quickJoin} takes: joinRoom's options but roomCode, isSpectator, serverUrl and transport
|
|
894
|
+
* (it finds Zaisma rooms), and region.
|
|
857
895
|
*/
|
|
858
|
-
interface QuickJoinOptions extends Omit<JoinOptions, "roomCode" | "isSpectator" | "serverUrl"> {
|
|
896
|
+
interface QuickJoinOptions extends Omit<JoinOptions, "roomCode" | "isSpectator" | "serverUrl" | "transport"> {
|
|
859
897
|
/** Look for a room in this region only, like "EU", as in {@link RoomList.regions}. */
|
|
860
898
|
region?: Region;
|
|
861
899
|
/** Look for a room with these labels, like { Mode: "Classic" }; a new room gets them. */
|
|
@@ -870,8 +908,8 @@ declare namespace Zaisma {
|
|
|
870
908
|
labels?: Record<string, string>;
|
|
871
909
|
}
|
|
872
910
|
|
|
873
|
-
/** What {@link Move.join} takes: joinRoom's options but roomCode and
|
|
874
|
-
interface MoveJoinOptions extends Omit<JoinOptions, "roomCode" | "serverUrl" | "isSpectator"> {}
|
|
911
|
+
/** What {@link Move.join} takes: joinRoom's options but roomCode, serverUrl and transport, which are the move's own, and isSpectator (moves are for players). */
|
|
912
|
+
interface MoveJoinOptions extends Omit<JoinOptions, "roomCode" | "serverUrl" | "transport" | "isSpectator"> {}
|
|
875
913
|
|
|
876
914
|
/** What {@link Room.kick} takes. */
|
|
877
915
|
interface KickOptions {
|
|
@@ -960,7 +998,8 @@ declare namespace Zaisma {
|
|
|
960
998
|
/**
|
|
961
999
|
* "reliable" (the default) reaches the game server in order. "latest_only" may be dropped: while
|
|
962
1000
|
* this player's connection is backed up, a newer send with the same latestKey replaces it, and the
|
|
963
|
-
* newest goes once it catches up. For input sent every frame, like where the player aims.
|
|
1001
|
+
* newest goes once it catches up. For input sent every frame, like where the player aims. On the fast
|
|
1002
|
+
* lane to your own server ({@link JoinOptions.transport}), it goes as a datagram when it fits one. Anything
|
|
964
1003
|
* else throws error.code "bad_send_option".
|
|
965
1004
|
*/
|
|
966
1005
|
delivery?: "reliable" | "latest_only";
|
|
@@ -1005,6 +1044,11 @@ declare namespace Zaisma {
|
|
|
1005
1044
|
readonly spectatorCount: number;
|
|
1006
1045
|
/** Where the socket stands; the "connectionStatus" event says when it changes. Away is awayReason, not this. */
|
|
1007
1046
|
readonly connectionStatus: ConnectionStatus;
|
|
1047
|
+
/**
|
|
1048
|
+
* What this connection uses now: "webtransport" on the fast lane to your own server
|
|
1049
|
+
* ({@link JoinOptions.transport}), else "websocket", including when the fast lane didn't connect.
|
|
1050
|
+
*/
|
|
1051
|
+
readonly transport: "websocket" | "webtransport";
|
|
1008
1052
|
/**
|
|
1009
1053
|
* Why this player is out of the match: "idle" (no input until the "Still there?" ran out) or "hidden"
|
|
1010
1054
|
* (the game hidden too long), from the "away" event until "back"; null while they play.
|
|
@@ -1662,6 +1706,7 @@ declare namespace Zaisma {
|
|
|
1662
1706
|
* - bad_delay: {@link Room.smoothedState}'s delayMs isn't from 0 to 1000.
|
|
1663
1707
|
* - bad_region: a region that isn't a continent code ({@link Region}).
|
|
1664
1708
|
* - bad_idle_timeout: an idleTimeoutSec that isn't from 30 to 86400.
|
|
1709
|
+
* - bad_transport: a transport that isn't "websocket" or "webtransport".
|
|
1665
1710
|
* - request_failed: Zaisma didn't answer (no connection, or a problem on Zaisma's side); try again.
|
|
1666
1711
|
* - no_game: {@link Items.buy} named a game that isn't open to play here.
|
|
1667
1712
|
* - chat_stopped, chat_not_allowed, chat_empty, chat_too_long, chat_too_fast, chat_not_here: {@link Room.chat} didn't send ({@link ChatErrorCode}).
|
|
@@ -1700,6 +1745,7 @@ declare namespace Zaisma {
|
|
|
1700
1745
|
| "bad_delay"
|
|
1701
1746
|
| "bad_region"
|
|
1702
1747
|
| "bad_idle_timeout"
|
|
1748
|
+
| "bad_transport"
|
|
1703
1749
|
| "bad_server_url"
|
|
1704
1750
|
| "unlisted_server"
|
|
1705
1751
|
| "unknown_link"
|