@zaisma/game-server 1.11.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.
Files changed (44) hide show
  1. package/README.md +9 -0
  2. package/bench/brand/README.md +22 -17
  3. package/bench/brand/app-icon-1024.png +0 -0
  4. package/bench/brand/app-icon-192.png +0 -0
  5. package/bench/brand/app-icon-512.png +0 -0
  6. package/bench/brand/app-icon-dark-512.png +0 -0
  7. package/bench/brand/app-icon-maskable-512.png +0 -0
  8. package/bench/brand/apple-touch-icon.png +0 -0
  9. package/bench/brand/favicon-32.png +0 -0
  10. package/bench/brand/favicon.ico +0 -0
  11. package/bench/brand/favicon.svg +1 -1
  12. package/bench/brand/manifest.webmanifest +26 -0
  13. package/bench/brand/share-card-dark.png +0 -0
  14. package/bench/brand/share-card-dark.svg +1 -0
  15. package/bench/brand/share-card-light.png +0 -0
  16. package/bench/brand/share-card-light.svg +1 -0
  17. package/bench/brand/stripe-icon.png +0 -0
  18. package/bench/brand/zaisma-icon-dark.svg +1 -0
  19. package/bench/brand/zaisma-icon-light.svg +1 -0
  20. package/bench/brand/zaisma-mark-glass-black-2048.png +0 -0
  21. package/bench/brand/zaisma-mark-glass-black.svg +1 -0
  22. package/bench/brand/zaisma-mark-glass-white-2048.png +0 -0
  23. package/bench/brand/zaisma-mark-glass-white.svg +1 -0
  24. package/bench/brand/zaisma-wordmark-glass-black-2400.png +0 -0
  25. package/bench/brand/zaisma-wordmark-glass-black.svg +1 -0
  26. package/bench/brand/zaisma-wordmark-glass-white-2400.png +0 -0
  27. package/bench/brand/zaisma-wordmark-glass-white.svg +1 -0
  28. package/bench/glass.js +44 -21
  29. package/bin/fast-lane.js +212 -0
  30. package/bin/local-play-certificate.js +11 -6
  31. package/bin/node-parts.js +9 -1
  32. package/bin/node.d.ts +12 -0
  33. package/bin/node.js +59 -15
  34. package/package.json +13 -1
  35. package/sdk/zaisma.d.ts +27 -7
  36. package/sdk/zaisma.js +166 -7
  37. package/src/room.d.ts +18 -3
  38. package/src/room.js +12 -12
  39. package/bench/brand/zaisma-app-icon.svg +0 -1
  40. package/bench/brand/zaisma-mark-metal-2048.png +0 -0
  41. package/bench/brand/zaisma-mark-metal.svg +0 -1
  42. package/bench/brand/zaisma-mark-tricolor.svg +0 -1
  43. package/bench/brand/zaisma-wordmark-metal-2400.png +0 -0
  44. package/bench/brand/zaisma-wordmark-metal.svg +0 -1
@@ -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
- function integer(bytes) {
24
- // A positive integer: a leading zero byte when the first bit is set.
25
- return der(0x02, bytes[0] & 0x80 ? Buffer.concat([Buffer.from([0]), bytes]) : bytes);
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 = randomBytes(16);
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 + CERTIFICATE_DURATION_DAYS * DAY_MS)),
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
- * Answers { close(), httpServer }: close() saves every room first and finishes once the server has stopped.
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
- send: (id, text) => sockets.get(id)?.send(text),
260
- sendBytes: (id, frame) => sockets.get(id)?.send(frame, { binary: true }),
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
- const address = /^\/v1\/rooms\/([a-z0-9-]{1,40})\/([A-Z0-9]{6})$/.exec(request.url ?? "");
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(request.headers.origin)) return refuse("bad_origin");
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.11.0",
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
  *
@@ -823,6 +823,18 @@ declare namespace Zaisma {
823
823
  * is allowed to connect to it.
824
824
  */
825
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";
826
838
  /**
827
839
  * true: a new room that shows in room lists, for others to find. By default false: a room only its
828
840
  * code and link reach, like a private match ({@link Rooms.quickJoin} lists its new rooms unless given
@@ -878,10 +890,10 @@ declare namespace Zaisma {
878
890
  type Region = "AF" | "AN" | "AS" | "EU" | "NA" | "OC" | "SA";
879
891
 
880
892
  /**
881
- * What {@link Rooms.quickJoin} takes: joinRoom's options but roomCode, isSpectator and serverUrl (it
882
- * 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.
883
895
  */
884
- interface QuickJoinOptions extends Omit<JoinOptions, "roomCode" | "isSpectator" | "serverUrl"> {
896
+ interface QuickJoinOptions extends Omit<JoinOptions, "roomCode" | "isSpectator" | "serverUrl" | "transport"> {
885
897
  /** Look for a room in this region only, like "EU", as in {@link RoomList.regions}. */
886
898
  region?: Region;
887
899
  /** Look for a room with these labels, like { Mode: "Classic" }; a new room gets them. */
@@ -896,8 +908,8 @@ declare namespace Zaisma {
896
908
  labels?: Record<string, string>;
897
909
  }
898
910
 
899
- /** What {@link Move.join} takes: joinRoom's options but roomCode and serverUrl, which are the move's own, and isSpectator (moves are for players). */
900
- 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"> {}
901
913
 
902
914
  /** What {@link Room.kick} takes. */
903
915
  interface KickOptions {
@@ -986,7 +998,8 @@ declare namespace Zaisma {
986
998
  /**
987
999
  * "reliable" (the default) reaches the game server in order. "latest_only" may be dropped: while
988
1000
  * this player's connection is backed up, a newer send with the same latestKey replaces it, and the
989
- * newest goes once it catches up. For input sent every frame, like where the player aims. Anything
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
990
1003
  * else throws error.code "bad_send_option".
991
1004
  */
992
1005
  delivery?: "reliable" | "latest_only";
@@ -1031,6 +1044,11 @@ declare namespace Zaisma {
1031
1044
  readonly spectatorCount: number;
1032
1045
  /** Where the socket stands; the "connectionStatus" event says when it changes. Away is awayReason, not this. */
1033
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";
1034
1052
  /**
1035
1053
  * Why this player is out of the match: "idle" (no input until the "Still there?" ran out) or "hidden"
1036
1054
  * (the game hidden too long), from the "away" event until "back"; null while they play.
@@ -1688,6 +1706,7 @@ declare namespace Zaisma {
1688
1706
  * - bad_delay: {@link Room.smoothedState}'s delayMs isn't from 0 to 1000.
1689
1707
  * - bad_region: a region that isn't a continent code ({@link Region}).
1690
1708
  * - bad_idle_timeout: an idleTimeoutSec that isn't from 30 to 86400.
1709
+ * - bad_transport: a transport that isn't "websocket" or "webtransport".
1691
1710
  * - request_failed: Zaisma didn't answer (no connection, or a problem on Zaisma's side); try again.
1692
1711
  * - no_game: {@link Items.buy} named a game that isn't open to play here.
1693
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}).
@@ -1726,6 +1745,7 @@ declare namespace Zaisma {
1726
1745
  | "bad_delay"
1727
1746
  | "bad_region"
1728
1747
  | "bad_idle_timeout"
1748
+ | "bad_transport"
1729
1749
  | "bad_server_url"
1730
1750
  | "unlisted_server"
1731
1751
  | "unknown_link"