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