@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
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.bearerMatches = bearerMatches;
|
|
4
|
+
const node_crypto_1 = require("node:crypto");
|
|
5
|
+
const digest = (s) => (0, node_crypto_1.createHash)("sha256").update(s, "utf8").digest();
|
|
6
|
+
function bearerMatches(header, token) {
|
|
7
|
+
return (0, node_crypto_1.timingSafeEqual)(digest(header ?? ""), digest(`Bearer ${token}`));
|
|
8
|
+
}
|
|
@@ -4,6 +4,7 @@ exports.runConformance = runConformance;
|
|
|
4
4
|
exports.assertConformance = assertConformance;
|
|
5
5
|
const node_util_1 = require("node:util");
|
|
6
6
|
const version_1 = require("./version");
|
|
7
|
+
const conformance_1 = require("./live/conformance");
|
|
7
8
|
function participantIds(n) {
|
|
8
9
|
return Array.from({ length: n }, (_, i) => `00000000-0000-4000-8000-${String(i + 1).padStart(12, "0")}`);
|
|
9
10
|
}
|
|
@@ -83,6 +84,11 @@ async function runConformance(service, context, options = {}) {
|
|
|
83
84
|
expect(out.payload !== undefined && out.payload !== null, "payload is missing");
|
|
84
85
|
});
|
|
85
86
|
}
|
|
87
|
+
if (options.live) {
|
|
88
|
+
const live = await (0, conformance_1.runLiveConformance)(options.live, { gameType: metadata.id, ...options.liveOptions });
|
|
89
|
+
report.passed.push(...live.passed);
|
|
90
|
+
report.failed.push(...live.failed);
|
|
91
|
+
}
|
|
86
92
|
return report;
|
|
87
93
|
}
|
|
88
94
|
async function assertConformance(service, context, options = {}) {
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
"use strict";
|
|
2
2
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
-
exports.isVersionConflict = exports.BlobStoreError = exports.blobOwner = exports.casUpdate = exports.createBlobClient = exports.runtimeVersion = exports.PLUGIN_PROTOCOL_VERSION = exports.assertConformance = exports.runConformance = exports.createRegistrationLoop = exports.createPluginApp = exports.servePlugin = exports.createGamePluginService = exports.mountSsdkHandler = void 0;
|
|
3
|
+
exports.MAX_LINK_FRAME_BYTES = exports.LinkCloseCode = exports.LIVE_PATH = exports.LIVE_PROTOCOL_VERSION = exports.assertLiveConformance = exports.runLiveConformance = exports.loadRoomCheckpoint = exports.createLiveHarness = exports.attachLive = exports.isVersionConflict = exports.BlobStoreError = exports.blobOwner = exports.casUpdate = exports.createBlobClient = exports.runtimeVersion = exports.PLUGIN_PROTOCOL_VERSION = exports.assertConformance = exports.runConformance = exports.createRegistrationLoop = exports.createPluginApp = exports.servePlugin = exports.createGamePluginService = exports.mountSsdkHandler = void 0;
|
|
4
4
|
var express_1 = require("./express");
|
|
5
5
|
Object.defineProperty(exports, "mountSsdkHandler", { enumerable: true, get: function () { return express_1.mountSsdkHandler; } });
|
|
6
6
|
var defaultService_1 = require("./defaultService");
|
|
@@ -22,3 +22,17 @@ Object.defineProperty(exports, "casUpdate", { enumerable: true, get: function ()
|
|
|
22
22
|
Object.defineProperty(exports, "blobOwner", { enumerable: true, get: function () { return blobClient_1.blobOwner; } });
|
|
23
23
|
Object.defineProperty(exports, "BlobStoreError", { enumerable: true, get: function () { return blobClient_1.BlobStoreError; } });
|
|
24
24
|
Object.defineProperty(exports, "isVersionConflict", { enumerable: true, get: function () { return blobClient_1.isVersionConflict; } });
|
|
25
|
+
var server_1 = require("./live/server");
|
|
26
|
+
Object.defineProperty(exports, "attachLive", { enumerable: true, get: function () { return server_1.attachLive; } });
|
|
27
|
+
var harness_1 = require("./live/harness");
|
|
28
|
+
Object.defineProperty(exports, "createLiveHarness", { enumerable: true, get: function () { return harness_1.createLiveHarness; } });
|
|
29
|
+
var checkpoint_1 = require("./live/checkpoint");
|
|
30
|
+
Object.defineProperty(exports, "loadRoomCheckpoint", { enumerable: true, get: function () { return checkpoint_1.loadRoomCheckpoint; } });
|
|
31
|
+
var conformance_2 = require("./live/conformance");
|
|
32
|
+
Object.defineProperty(exports, "runLiveConformance", { enumerable: true, get: function () { return conformance_2.runLiveConformance; } });
|
|
33
|
+
Object.defineProperty(exports, "assertLiveConformance", { enumerable: true, get: function () { return conformance_2.assertLiveConformance; } });
|
|
34
|
+
var protocol_1 = require("./live/protocol");
|
|
35
|
+
Object.defineProperty(exports, "LIVE_PROTOCOL_VERSION", { enumerable: true, get: function () { return protocol_1.LIVE_PROTOCOL_VERSION; } });
|
|
36
|
+
Object.defineProperty(exports, "LIVE_PATH", { enumerable: true, get: function () { return protocol_1.LIVE_PATH; } });
|
|
37
|
+
Object.defineProperty(exports, "LinkCloseCode", { enumerable: true, get: function () { return protocol_1.LinkCloseCode; } });
|
|
38
|
+
Object.defineProperty(exports, "MAX_LINK_FRAME_BYTES", { enumerable: true, get: function () { return protocol_1.MAX_LINK_FRAME_BYTES; } });
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.loadRoomCheckpoint = loadRoomCheckpoint;
|
|
4
|
+
const blobClient_1 = require("../blobClient");
|
|
5
|
+
async function loadRoomCheckpoint(client, owner, key, opts = {}) {
|
|
6
|
+
const blob = await client.get(owner, key);
|
|
7
|
+
let value = blob.version === 0 ? undefined : blob.value;
|
|
8
|
+
let version = blob.version;
|
|
9
|
+
return {
|
|
10
|
+
get value() {
|
|
11
|
+
return value;
|
|
12
|
+
},
|
|
13
|
+
get version() {
|
|
14
|
+
return version;
|
|
15
|
+
},
|
|
16
|
+
async save(next) {
|
|
17
|
+
try {
|
|
18
|
+
const written = await client.put(owner, key, next, version);
|
|
19
|
+
version = written.version;
|
|
20
|
+
value = next;
|
|
21
|
+
return true;
|
|
22
|
+
}
|
|
23
|
+
catch (e) {
|
|
24
|
+
if (!(0, blobClient_1.isVersionConflict)(e))
|
|
25
|
+
throw e;
|
|
26
|
+
opts.room?.close("another room took over this scope");
|
|
27
|
+
return false;
|
|
28
|
+
}
|
|
29
|
+
},
|
|
30
|
+
};
|
|
31
|
+
}
|
|
@@ -0,0 +1,215 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.runLiveConformance = runLiveConformance;
|
|
4
|
+
exports.assertLiveConformance = assertLiveConformance;
|
|
5
|
+
const tslib_1 = require("tslib");
|
|
6
|
+
const node_http_1 = require("node:http");
|
|
7
|
+
const node_crypto_1 = require("node:crypto");
|
|
8
|
+
const ws_1 = tslib_1.__importDefault(require("ws"));
|
|
9
|
+
const protocol_1 = require("./protocol");
|
|
10
|
+
const server_1 = require("./server");
|
|
11
|
+
function connect(url, headers, onSend = () => undefined) {
|
|
12
|
+
return new Promise((resolve, reject) => {
|
|
13
|
+
const ws = new ws_1.default(url, { headers });
|
|
14
|
+
const frames = [];
|
|
15
|
+
const listeners = new Set();
|
|
16
|
+
let open = false;
|
|
17
|
+
const closed = new Promise((r) => ws.once("close", (code) => { open = false; r(code); }));
|
|
18
|
+
ws.on("message", (data) => {
|
|
19
|
+
try {
|
|
20
|
+
frames.push(JSON.parse(data.toString()));
|
|
21
|
+
}
|
|
22
|
+
catch {
|
|
23
|
+
frames.push(data.toString());
|
|
24
|
+
}
|
|
25
|
+
for (const l of listeners)
|
|
26
|
+
l();
|
|
27
|
+
});
|
|
28
|
+
ws.once("unexpected-response", (_req, res) => {
|
|
29
|
+
resolve({ status: res.statusCode ?? 0 });
|
|
30
|
+
ws.terminate();
|
|
31
|
+
});
|
|
32
|
+
ws.once("error", (e) => { if (!open)
|
|
33
|
+
reject(e); });
|
|
34
|
+
ws.once("open", () => {
|
|
35
|
+
open = true;
|
|
36
|
+
resolve({
|
|
37
|
+
ws,
|
|
38
|
+
frames,
|
|
39
|
+
closed,
|
|
40
|
+
isOpen: () => open,
|
|
41
|
+
send: (frame) => {
|
|
42
|
+
onSend();
|
|
43
|
+
ws.send(typeof frame === "string" ? frame : (0, protocol_1.encodeFrame)(frame));
|
|
44
|
+
},
|
|
45
|
+
waitFor: (pred, ms) => new Promise((ok, fail) => {
|
|
46
|
+
const check = () => {
|
|
47
|
+
if (frames.some(pred)) {
|
|
48
|
+
listeners.delete(check);
|
|
49
|
+
clearTimeout(timer);
|
|
50
|
+
ok();
|
|
51
|
+
}
|
|
52
|
+
};
|
|
53
|
+
const timer = setTimeout(() => {
|
|
54
|
+
listeners.delete(check);
|
|
55
|
+
fail(new Error(`nothing matching within ${ms} ms; got ${JSON.stringify(frames).slice(0, 200)}`));
|
|
56
|
+
}, ms);
|
|
57
|
+
listeners.add(check);
|
|
58
|
+
check();
|
|
59
|
+
}),
|
|
60
|
+
});
|
|
61
|
+
});
|
|
62
|
+
});
|
|
63
|
+
}
|
|
64
|
+
async function runLiveConformance(live, options = {}) {
|
|
65
|
+
const report = { passed: [], failed: [] };
|
|
66
|
+
const gameType = options.gameType ?? "conformance";
|
|
67
|
+
const scope = options.scope ?? `game:${(0, node_crypto_1.randomUUID)()}`;
|
|
68
|
+
const timeout = options.timeoutMs ?? 5_000;
|
|
69
|
+
const token = (0, node_crypto_1.randomUUID)();
|
|
70
|
+
const errors = [];
|
|
71
|
+
const quiet = { log: () => undefined, error: () => undefined };
|
|
72
|
+
const server = (0, node_http_1.createServer)((_req, res) => res.writeHead(404).end());
|
|
73
|
+
await new Promise((r) => server.listen(0, r));
|
|
74
|
+
const liveServer = (0, server_1.attachLive)(server, {
|
|
75
|
+
gameType,
|
|
76
|
+
authToken: token,
|
|
77
|
+
log: quiet,
|
|
78
|
+
live: {
|
|
79
|
+
...live,
|
|
80
|
+
shutdownTimeoutMs: Math.min(live.shutdownTimeoutMs ?? timeout, timeout),
|
|
81
|
+
onError: (e, where) => {
|
|
82
|
+
errors.push(`${where.handler} threw: ${e?.message ?? String(e)}`);
|
|
83
|
+
live.onError?.(e, where);
|
|
84
|
+
},
|
|
85
|
+
},
|
|
86
|
+
});
|
|
87
|
+
const url = `ws://127.0.0.1:${server.address().port}${protocol_1.LIVE_PATH}`;
|
|
88
|
+
const headers = (auth, scopeKey = scope) => ({
|
|
89
|
+
...(auth === undefined ? {} : { "X-Plugin-Auth": auth }),
|
|
90
|
+
"X-Live-Scope": scopeKey,
|
|
91
|
+
"X-Live-Epoch": (0, node_crypto_1.randomUUID)(),
|
|
92
|
+
"X-Game-Type": gameType,
|
|
93
|
+
"X-Live-Protocol": String(protocol_1.LIVE_PROTOCOL_VERSION),
|
|
94
|
+
});
|
|
95
|
+
const check = async (name, body) => {
|
|
96
|
+
errors.length = 0;
|
|
97
|
+
try {
|
|
98
|
+
await body();
|
|
99
|
+
if (errors.length > 0)
|
|
100
|
+
throw new Error(errors.join("; "));
|
|
101
|
+
report.passed.push(name);
|
|
102
|
+
}
|
|
103
|
+
catch (e) {
|
|
104
|
+
report.failed.push({ check: name, reason: e.message });
|
|
105
|
+
}
|
|
106
|
+
};
|
|
107
|
+
const expect = (cond, reason) => {
|
|
108
|
+
if (!cond)
|
|
109
|
+
throw new Error(reason);
|
|
110
|
+
};
|
|
111
|
+
let sent = 0;
|
|
112
|
+
const settle = async () => {
|
|
113
|
+
const deadline = Date.now() + timeout;
|
|
114
|
+
while (liveServer.framesReceived() < sent) {
|
|
115
|
+
if (Date.now() > deadline)
|
|
116
|
+
throw new Error(`the plugin read ${liveServer.framesReceived()} of ${sent} frames within ${timeout} ms`);
|
|
117
|
+
await new Promise((r) => setTimeout(r, 5));
|
|
118
|
+
}
|
|
119
|
+
await Promise.race([
|
|
120
|
+
liveServer.settled(),
|
|
121
|
+
new Promise((_, fail) => setTimeout(() => fail(new Error(`room did not settle within ${timeout} ms`)), timeout)),
|
|
122
|
+
]);
|
|
123
|
+
};
|
|
124
|
+
const links = [];
|
|
125
|
+
try {
|
|
126
|
+
await check("live: a bad or missing X-Plugin-Auth is refused with 401", async () => {
|
|
127
|
+
const bad = await connect(url, headers("Bearer not-the-token"));
|
|
128
|
+
expect("status" in bad && bad.status === 401, `a bad token got ${JSON.stringify("status" in bad ? bad.status : "an open link")}`);
|
|
129
|
+
const none = await connect(url, headers(undefined));
|
|
130
|
+
expect("status" in none && none.status === 401, `no token got ${JSON.stringify("status" in none ? none.status : "an open link")}`);
|
|
131
|
+
});
|
|
132
|
+
let link;
|
|
133
|
+
await check("live: the room answers ready", async () => {
|
|
134
|
+
const opened = await connect(url, headers(`Bearer ${token}`), () => void sent++);
|
|
135
|
+
expect(!("status" in opened), `the upgrade was refused with ${"status" in opened ? opened.status : ""}`);
|
|
136
|
+
link = opened;
|
|
137
|
+
links.push(link);
|
|
138
|
+
await link.waitFor((f) => f?.t === "ready", timeout);
|
|
139
|
+
});
|
|
140
|
+
if (!link)
|
|
141
|
+
return report;
|
|
142
|
+
const l = link;
|
|
143
|
+
await check("live: unknown and malformed frames are ignored", async () => {
|
|
144
|
+
l.send({ t: "from-a-future-version", x: 1 });
|
|
145
|
+
l.send("not json at all");
|
|
146
|
+
l.send({ t: "join" });
|
|
147
|
+
l.send({ t: "sig", conn: "never-joined", participantId: "x", d: {} });
|
|
148
|
+
await settle();
|
|
149
|
+
l.send(join("probe"));
|
|
150
|
+
l.send({ t: "leave", conn: "probe", reason: "closed" });
|
|
151
|
+
await settle();
|
|
152
|
+
expect(l.isOpen(), "the link was closed");
|
|
153
|
+
expect(liveServer.room(scope) !== undefined, "the room is gone");
|
|
154
|
+
});
|
|
155
|
+
await check("live: survives a join/leave storm", async () => {
|
|
156
|
+
const n = options.stormSize ?? 100;
|
|
157
|
+
for (let i = 0; i < n; i++) {
|
|
158
|
+
l.send(join(`s${i}`, i % 7 === 0 ? "SPECTATOR" : "PARTICIPANT"));
|
|
159
|
+
if (i % 3 === 0)
|
|
160
|
+
l.send({ t: "sig", conn: `s${i}`, participantId: `p-s${i}`, d: options.signal ?? {} });
|
|
161
|
+
if (i % 5 === 0)
|
|
162
|
+
l.send({ t: "presence", conn: `s${i}`, state: "AWAY" });
|
|
163
|
+
if (i >= 2)
|
|
164
|
+
l.send({ t: "leave", conn: `s${i - 2}`, reason: i % 2 ? "closed" : "timeout" });
|
|
165
|
+
}
|
|
166
|
+
l.send({ t: "leave", conn: `s${n - 2}`, reason: "closed" });
|
|
167
|
+
l.send({ t: "leave", conn: `s${n - 1}`, reason: "closed" });
|
|
168
|
+
l.send({ t: "game", gameId: scope.slice(scope.indexOf(":") + 1), revision: 1 });
|
|
169
|
+
await settle();
|
|
170
|
+
expect(l.isOpen(), "the link was closed");
|
|
171
|
+
const room = liveServer.room(scope);
|
|
172
|
+
expect(room !== undefined, "the room is gone");
|
|
173
|
+
expect(room.conns().length === 0, `${room.conns().length} connections still in the room after every one left`);
|
|
174
|
+
});
|
|
175
|
+
await check("live: a second link for the same scope is the same room", async () => {
|
|
176
|
+
const second = await connect(url, headers(`Bearer ${token}`), () => void sent++);
|
|
177
|
+
expect(!("status" in second), "the second link was refused");
|
|
178
|
+
links.push(second);
|
|
179
|
+
await second.waitFor((f) => f?.t === "ready", timeout);
|
|
180
|
+
expect(liveServer.rooms().length === 1, `${liveServer.rooms().length} rooms for one scope`);
|
|
181
|
+
});
|
|
182
|
+
await check("live: shutdown closes every link with 1012", async () => {
|
|
183
|
+
await liveServer.close();
|
|
184
|
+
const codes = await Promise.all(links.map((x) => x.closed));
|
|
185
|
+
expect(codes.every((c) => c === protocol_1.LinkCloseCode.RESTART), `closed with ${codes.join(", ")}`);
|
|
186
|
+
});
|
|
187
|
+
}
|
|
188
|
+
finally {
|
|
189
|
+
await liveServer.close();
|
|
190
|
+
for (const x of links)
|
|
191
|
+
x.ws.terminate();
|
|
192
|
+
await new Promise((r) => server.close(() => r()));
|
|
193
|
+
}
|
|
194
|
+
return report;
|
|
195
|
+
}
|
|
196
|
+
function join(conn, role = "PARTICIPANT") {
|
|
197
|
+
return {
|
|
198
|
+
t: "join",
|
|
199
|
+
conn,
|
|
200
|
+
participantId: `p-${conn}`,
|
|
201
|
+
playerId: `u-${conn}`,
|
|
202
|
+
displayName: conn,
|
|
203
|
+
role,
|
|
204
|
+
gameId: "conformance",
|
|
205
|
+
hidden: false,
|
|
206
|
+
state: "ACTIVE",
|
|
207
|
+
};
|
|
208
|
+
}
|
|
209
|
+
async function assertLiveConformance(live, options = {}) {
|
|
210
|
+
const report = await runLiveConformance(live, options);
|
|
211
|
+
if (report.failed.length > 0) {
|
|
212
|
+
throw new Error(`live room contract violations:\n` + report.failed.map((f) => ` - ${f.check}: ${f.reason}`).join("\n"));
|
|
213
|
+
}
|
|
214
|
+
return report;
|
|
215
|
+
}
|
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.createLiveHarness = createLiveHarness;
|
|
4
|
+
const protocol_1 = require("./protocol");
|
|
5
|
+
const rooms_1 = require("./rooms");
|
|
6
|
+
function createLiveHarness(live, opts = {}) {
|
|
7
|
+
const host = new rooms_1.RoomHost({ gameType: opts.gameType ?? "harness", live, log: opts.log });
|
|
8
|
+
let epochs = 0;
|
|
9
|
+
const openLink = (scopeText, linkOpts = {}) => {
|
|
10
|
+
const scope = (0, protocol_1.parseScope)(scopeText);
|
|
11
|
+
if (!scope)
|
|
12
|
+
throw new Error(`not a scope: ${scopeText} (want game:{id} or space:{id})`);
|
|
13
|
+
const clients = new Map();
|
|
14
|
+
let nextConn = 0;
|
|
15
|
+
let ready = false;
|
|
16
|
+
let closedByPlugin;
|
|
17
|
+
let closed = false;
|
|
18
|
+
const frames = [];
|
|
19
|
+
const handle = host.attach({ scope, epoch: linkOpts.epoch ?? `epoch-${++epochs}` }, {
|
|
20
|
+
write: (text, done) => {
|
|
21
|
+
const decoded = (0, protocol_1.decodePluginFrame)(text);
|
|
22
|
+
if (!decoded.ok)
|
|
23
|
+
throw new Error(`the runtime wrote a frame the platform cannot read: ${text}`);
|
|
24
|
+
const frame = decoded.frame;
|
|
25
|
+
frames.push(frame);
|
|
26
|
+
if (frame.t === "ready")
|
|
27
|
+
ready = true;
|
|
28
|
+
else if (frame.t === "send") {
|
|
29
|
+
const targets = frame.to === "all" ? [...clients.values()] : frame.to.map((c) => clients.get(c)).filter((c) => !!c);
|
|
30
|
+
for (const c of targets) {
|
|
31
|
+
if (!c.open)
|
|
32
|
+
continue;
|
|
33
|
+
const message = { from: "host", d: JSON.parse(JSON.stringify(frame.d)) };
|
|
34
|
+
if (frame.k !== undefined)
|
|
35
|
+
message.k = frame.k;
|
|
36
|
+
c.received.push(message);
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
else if (frame.t === "kick") {
|
|
40
|
+
const c = clients.get(frame.conn);
|
|
41
|
+
if (c?.open) {
|
|
42
|
+
c.open = false;
|
|
43
|
+
c.kicked = { code: frame.code ?? 4403, reason: frame.reason };
|
|
44
|
+
clients.delete(c.conn);
|
|
45
|
+
queueMicrotask(() => deliver({ t: "leave", conn: c.conn, reason: "kicked" }));
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
queueMicrotask(done);
|
|
49
|
+
},
|
|
50
|
+
close: (code, reason) => {
|
|
51
|
+
if (closed)
|
|
52
|
+
return;
|
|
53
|
+
closed = true;
|
|
54
|
+
closedByPlugin = { code, reason };
|
|
55
|
+
for (const c of clients.values())
|
|
56
|
+
c.open = false;
|
|
57
|
+
queueMicrotask(() => handle.closed(code));
|
|
58
|
+
},
|
|
59
|
+
});
|
|
60
|
+
const deliver = (frame) => {
|
|
61
|
+
if (closed)
|
|
62
|
+
return;
|
|
63
|
+
handle.receive((0, protocol_1.encodeFrame)(frame));
|
|
64
|
+
};
|
|
65
|
+
return {
|
|
66
|
+
scope: scope.key,
|
|
67
|
+
get ready() {
|
|
68
|
+
return ready;
|
|
69
|
+
},
|
|
70
|
+
get closedByPlugin() {
|
|
71
|
+
return closedByPlugin;
|
|
72
|
+
},
|
|
73
|
+
frames,
|
|
74
|
+
join(spec) {
|
|
75
|
+
if (closed)
|
|
76
|
+
throw new Error("link is closed");
|
|
77
|
+
const conn = spec.conn ?? `c${++nextConn}`;
|
|
78
|
+
const state = { conn, participantId: spec.participantId, received: [], kicked: undefined, open: true };
|
|
79
|
+
clients.set(conn, state);
|
|
80
|
+
deliver({
|
|
81
|
+
t: "join",
|
|
82
|
+
conn,
|
|
83
|
+
participantId: spec.participantId,
|
|
84
|
+
playerId: spec.playerId ?? `player-${spec.participantId}`,
|
|
85
|
+
displayName: spec.displayName ?? spec.participantId,
|
|
86
|
+
role: spec.role ?? "PARTICIPANT",
|
|
87
|
+
gameId: spec.gameId ?? (scope.kind === "game" ? scope.id : `game-of-${spec.participantId}`),
|
|
88
|
+
hidden: spec.hidden ?? false,
|
|
89
|
+
state: spec.state ?? "ACTIVE",
|
|
90
|
+
});
|
|
91
|
+
return {
|
|
92
|
+
conn,
|
|
93
|
+
participantId: spec.participantId,
|
|
94
|
+
get received() {
|
|
95
|
+
return state.received;
|
|
96
|
+
},
|
|
97
|
+
get kicked() {
|
|
98
|
+
return state.kicked;
|
|
99
|
+
},
|
|
100
|
+
get open() {
|
|
101
|
+
return state.open;
|
|
102
|
+
},
|
|
103
|
+
signal(d) {
|
|
104
|
+
if (!state.open)
|
|
105
|
+
return;
|
|
106
|
+
deliver({ t: "sig", conn, participantId: spec.participantId, d });
|
|
107
|
+
},
|
|
108
|
+
presence(s) {
|
|
109
|
+
if (state.open)
|
|
110
|
+
deliver({ t: "presence", conn, state: s });
|
|
111
|
+
},
|
|
112
|
+
leave(reason = "closed") {
|
|
113
|
+
if (!state.open)
|
|
114
|
+
return;
|
|
115
|
+
state.open = false;
|
|
116
|
+
clients.delete(conn);
|
|
117
|
+
deliver({ t: "leave", conn, reason });
|
|
118
|
+
},
|
|
119
|
+
};
|
|
120
|
+
},
|
|
121
|
+
game: (gameId, revision) => deliver({ t: "game", gameId, revision }),
|
|
122
|
+
members: (members) => deliver({ t: "members", members }),
|
|
123
|
+
raw: (text) => {
|
|
124
|
+
if (!closed)
|
|
125
|
+
handle.receive(text);
|
|
126
|
+
},
|
|
127
|
+
close: (code = protocol_1.LinkCloseCode.NORMAL) => {
|
|
128
|
+
if (closed)
|
|
129
|
+
return;
|
|
130
|
+
closed = true;
|
|
131
|
+
for (const c of clients.values())
|
|
132
|
+
c.open = false;
|
|
133
|
+
handle.closed(code);
|
|
134
|
+
},
|
|
135
|
+
};
|
|
136
|
+
};
|
|
137
|
+
return {
|
|
138
|
+
openLink,
|
|
139
|
+
settled: () => host.settled(),
|
|
140
|
+
room: (scope) => host.room(scope),
|
|
141
|
+
rooms: () => host.list(),
|
|
142
|
+
shutdown: () => host.shutdown(),
|
|
143
|
+
};
|
|
144
|
+
}
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.Outbox = exports.DEFAULT_MAX_QUEUED_FRAMES = exports.DEFAULT_HIGH_WATER_BYTES = void 0;
|
|
4
|
+
exports.DEFAULT_HIGH_WATER_BYTES = 1024 * 1024;
|
|
5
|
+
exports.DEFAULT_MAX_QUEUED_FRAMES = 1024;
|
|
6
|
+
class Outbox {
|
|
7
|
+
writer;
|
|
8
|
+
highWater;
|
|
9
|
+
maxQueued;
|
|
10
|
+
queue = [];
|
|
11
|
+
inflight = 0;
|
|
12
|
+
pumping = false;
|
|
13
|
+
closed = false;
|
|
14
|
+
stats = { queued: 0, coalesced: 0, dropped: 0 };
|
|
15
|
+
constructor(writer, options = {}) {
|
|
16
|
+
this.writer = writer;
|
|
17
|
+
this.highWater = options.highWaterBytes ?? exports.DEFAULT_HIGH_WATER_BYTES;
|
|
18
|
+
this.maxQueued = options.maxQueuedFrames ?? exports.DEFAULT_MAX_QUEUED_FRAMES;
|
|
19
|
+
}
|
|
20
|
+
push(text, opts) {
|
|
21
|
+
if (this.closed)
|
|
22
|
+
return;
|
|
23
|
+
const entry = { text, bytes: Buffer.byteLength(text, "utf8"), key: opts.key, droppable: opts.droppable };
|
|
24
|
+
if (entry.key !== undefined) {
|
|
25
|
+
const i = this.queue.findIndex((e) => e.key === entry.key);
|
|
26
|
+
if (i >= 0) {
|
|
27
|
+
this.queue.splice(i, 1);
|
|
28
|
+
this.stats.coalesced++;
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
this.queue.push(entry);
|
|
32
|
+
if (this.queue.length > this.maxQueued) {
|
|
33
|
+
const victim = this.queue.findIndex((e) => e.droppable);
|
|
34
|
+
if (victim >= 0) {
|
|
35
|
+
this.queue.splice(victim, 1);
|
|
36
|
+
this.stats.dropped++;
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
this.pump();
|
|
40
|
+
}
|
|
41
|
+
close() {
|
|
42
|
+
this.closed = true;
|
|
43
|
+
this.queue.length = 0;
|
|
44
|
+
this.stats.queued = 0;
|
|
45
|
+
}
|
|
46
|
+
pump() {
|
|
47
|
+
if (this.pumping)
|
|
48
|
+
return;
|
|
49
|
+
this.pumping = true;
|
|
50
|
+
try {
|
|
51
|
+
while (!this.closed && this.queue.length > 0 && this.inflight < this.highWater) {
|
|
52
|
+
const entry = this.queue.shift();
|
|
53
|
+
this.inflight += entry.bytes;
|
|
54
|
+
let finished = false;
|
|
55
|
+
this.writer.write(entry.text, () => {
|
|
56
|
+
if (finished)
|
|
57
|
+
return;
|
|
58
|
+
finished = true;
|
|
59
|
+
this.inflight -= entry.bytes;
|
|
60
|
+
this.pump();
|
|
61
|
+
});
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
finally {
|
|
65
|
+
this.pumping = false;
|
|
66
|
+
this.stats.queued = this.queue.length;
|
|
67
|
+
}
|
|
68
|
+
if (!this.closed && this.queue.length > 0 && this.inflight < this.highWater)
|
|
69
|
+
this.pump();
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
exports.Outbox = Outbox;
|