@crowdedkingdoms/crowdyjs 17.8.0-test.1 → 17.9.0-dev.1
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 +35 -0
- package/dist/crowdy-client.d.ts +3 -0
- package/dist/crowdy-client.d.ts.map +1 -1
- package/dist/crowdy-client.js +2 -0
- package/dist/default-origin.d.ts +6 -6
- package/dist/default-origin.d.ts.map +1 -1
- package/dist/default-origin.js +6 -6
- package/dist/domains/exec.d.ts +225 -0
- package/dist/domains/exec.d.ts.map +1 -0
- package/dist/domains/exec.js +563 -0
- package/dist/generated/graphql.d.ts +73 -0
- package/dist/generated/graphql.d.ts.map +1 -1
- package/dist/generated/graphql.js +2 -0
- package/dist/grid-scope.d.ts.map +1 -1
- package/dist/index.d.ts +2 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +2 -1
- package/dist/kit/npcs.d.ts.map +1 -1
- package/dist/kit/social.d.ts.map +1 -1
- package/package.json +2 -1
|
@@ -0,0 +1,563 @@
|
|
|
1
|
+
import { decode, encode } from '@msgpack/msgpack';
|
|
2
|
+
import { CrowdyError } from '../errors.js';
|
|
3
|
+
import { ExecConnectDocument, ExecDeployDocument, } from '../generated/graphql.js';
|
|
4
|
+
/**
|
|
5
|
+
* ck-exec, the hub-and-spoke execution service (dev-tier preview).
|
|
6
|
+
*
|
|
7
|
+
* An app's code runs as **hubs** (stateful nodes, one per key, one handler at a
|
|
8
|
+
* time) and **spokes** (stateless, replicated) on execution hosts. A player
|
|
9
|
+
* connects to one host's gateway and calls any node of the app through it:
|
|
10
|
+
*
|
|
11
|
+
* ```ts
|
|
12
|
+
* const exec = await client.exec.connect(appId, { nodeType: 'arena', key: 'm1' });
|
|
13
|
+
* const hp = await exec.call('arena', 'm1', 'state');
|
|
14
|
+
* const stop = await exec.subscribe('arena', 'm1', 'hp', (push) => render(push.value));
|
|
15
|
+
* ```
|
|
16
|
+
*
|
|
17
|
+
* `connect` asks the game API for a host (`execConnect`, with the app-scoped token
|
|
18
|
+
* of `appId` as the session token) and opens a WebSocket to its gateway. Frames are
|
|
19
|
+
* ck-exec's client protocol (`ckx-proto/src/client.rs`); payloads are MessagePack.
|
|
20
|
+
* The connection recovers by itself: a closed socket or a `Moved` reply asks for a
|
|
21
|
+
* host again, renews every subscription, and retries the call once.
|
|
22
|
+
*/
|
|
23
|
+
/** Call statuses as the gateway sends them (`ckx_proto::Status`), by wire value. */
|
|
24
|
+
export const EXEC_STATUSES = [
|
|
25
|
+
'Ok',
|
|
26
|
+
'AppError',
|
|
27
|
+
'Busy',
|
|
28
|
+
'Moved',
|
|
29
|
+
'NotFound',
|
|
30
|
+
'DeadlineExceeded',
|
|
31
|
+
'Denied',
|
|
32
|
+
'RateLimited',
|
|
33
|
+
'Unavailable',
|
|
34
|
+
'Internal',
|
|
35
|
+
'Trapped',
|
|
36
|
+
'BadRequest',
|
|
37
|
+
];
|
|
38
|
+
/** The status name for a wire value. */
|
|
39
|
+
export function execStatus(value) {
|
|
40
|
+
return EXEC_STATUSES[value] ?? 'Unknown';
|
|
41
|
+
}
|
|
42
|
+
const RETRYABLE = new Set([
|
|
43
|
+
'Busy',
|
|
44
|
+
'Moved',
|
|
45
|
+
'Unavailable',
|
|
46
|
+
'RateLimited',
|
|
47
|
+
]);
|
|
48
|
+
/**
|
|
49
|
+
* A call, subscription or connection that ck-exec refused or could not complete.
|
|
50
|
+
* `status` is the platform's (`AppError` carries the handler's own message);
|
|
51
|
+
* `retryable` says whether trying again later can succeed.
|
|
52
|
+
*/
|
|
53
|
+
export class CrowdyExecError extends CrowdyError {
|
|
54
|
+
constructor(status, message, cause) {
|
|
55
|
+
super({ message: `${status}: ${message}`, cause });
|
|
56
|
+
this.status = status;
|
|
57
|
+
this.retryable = RETRYABLE.has(status);
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
const utf8 = new TextEncoder();
|
|
61
|
+
const fromUtf8 = new TextDecoder('utf-8', { fatal: true });
|
|
62
|
+
class FrameWriter {
|
|
63
|
+
constructor() {
|
|
64
|
+
this.parts = [];
|
|
65
|
+
}
|
|
66
|
+
u8(v) {
|
|
67
|
+
this.parts.push(v & 0xff);
|
|
68
|
+
return this;
|
|
69
|
+
}
|
|
70
|
+
u16(v) {
|
|
71
|
+
return this.u8(v).u8(v >>> 8);
|
|
72
|
+
}
|
|
73
|
+
u32(v) {
|
|
74
|
+
return this.u8(v).u8(v >>> 8).u8(v >>> 16).u8(v >>> 24);
|
|
75
|
+
}
|
|
76
|
+
str8(s, what) {
|
|
77
|
+
const b = utf8.encode(s);
|
|
78
|
+
if (b.length > 0xff)
|
|
79
|
+
throw new RangeError(`${what} is longer than 255 bytes`);
|
|
80
|
+
this.u8(b.length);
|
|
81
|
+
for (const x of b)
|
|
82
|
+
this.parts.push(x);
|
|
83
|
+
return this;
|
|
84
|
+
}
|
|
85
|
+
str16(s, what) {
|
|
86
|
+
const b = utf8.encode(s);
|
|
87
|
+
if (b.length > 0xffff)
|
|
88
|
+
throw new RangeError(`${what} is longer than 65535 bytes`);
|
|
89
|
+
this.u16(b.length);
|
|
90
|
+
for (const x of b)
|
|
91
|
+
this.parts.push(x);
|
|
92
|
+
return this;
|
|
93
|
+
}
|
|
94
|
+
finish(rest) {
|
|
95
|
+
const out = new Uint8Array(this.parts.length + (rest?.length ?? 0));
|
|
96
|
+
out.set(this.parts, 0);
|
|
97
|
+
if (rest)
|
|
98
|
+
out.set(rest, this.parts.length);
|
|
99
|
+
return out;
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
/** Encodes a client frame, as `ckx_proto::client::ClientMsg::encode` does. */
|
|
103
|
+
export function encodeExecFrame(f) {
|
|
104
|
+
const w = new FrameWriter();
|
|
105
|
+
switch (f.kind) {
|
|
106
|
+
case 'call':
|
|
107
|
+
return w
|
|
108
|
+
.u8(0x01)
|
|
109
|
+
.u32(f.rid)
|
|
110
|
+
.str8(f.nodeType, 'node type')
|
|
111
|
+
.str16(f.key, 'key')
|
|
112
|
+
.str8(f.method, 'method')
|
|
113
|
+
.finish(f.payload);
|
|
114
|
+
case 'subscribe':
|
|
115
|
+
case 'unsubscribe':
|
|
116
|
+
return w
|
|
117
|
+
.u8(f.kind === 'subscribe' ? 0x02 : 0x03)
|
|
118
|
+
.u32(f.rid)
|
|
119
|
+
.str8(f.nodeType, 'node type')
|
|
120
|
+
.str16(f.key, 'key')
|
|
121
|
+
.str8(f.topic, 'topic')
|
|
122
|
+
.finish();
|
|
123
|
+
case 'ping':
|
|
124
|
+
return w.u8(0x04).u32(f.nonce).finish();
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
/** Decodes a gateway frame, as `ckx_proto::client::ServerMsg::decode` does. */
|
|
128
|
+
export function decodeExecFrame(bytes) {
|
|
129
|
+
let at = 0;
|
|
130
|
+
const need = (n) => {
|
|
131
|
+
if (at + n > bytes.length)
|
|
132
|
+
throw new CrowdyExecError('BadRequest', 'truncated frame from the gateway');
|
|
133
|
+
};
|
|
134
|
+
const u8 = () => {
|
|
135
|
+
need(1);
|
|
136
|
+
return bytes[at++];
|
|
137
|
+
};
|
|
138
|
+
const u16 = () => {
|
|
139
|
+
need(2);
|
|
140
|
+
const v = bytes[at] | (bytes[at + 1] << 8);
|
|
141
|
+
at += 2;
|
|
142
|
+
return v;
|
|
143
|
+
};
|
|
144
|
+
const u32 = () => {
|
|
145
|
+
need(4);
|
|
146
|
+
const v = (bytes[at] | (bytes[at + 1] << 8) | (bytes[at + 2] << 16) | (bytes[at + 3] << 24)) >>> 0;
|
|
147
|
+
at += 4;
|
|
148
|
+
return v;
|
|
149
|
+
};
|
|
150
|
+
const str = (len) => {
|
|
151
|
+
need(len);
|
|
152
|
+
const s = fromUtf8.decode(bytes.subarray(at, at + len));
|
|
153
|
+
at += len;
|
|
154
|
+
return s;
|
|
155
|
+
};
|
|
156
|
+
const rest = () => bytes.slice(at);
|
|
157
|
+
const tag = u8();
|
|
158
|
+
switch (tag) {
|
|
159
|
+
case 0x81: {
|
|
160
|
+
const rid = u32();
|
|
161
|
+
const status = u8();
|
|
162
|
+
return { kind: 'reply', rid, status, payload: rest() };
|
|
163
|
+
}
|
|
164
|
+
case 0x82: {
|
|
165
|
+
const nodeType = str(u8());
|
|
166
|
+
const key = str(u16());
|
|
167
|
+
const topic = str(u8());
|
|
168
|
+
return { kind: 'push', nodeType, key, topic, payload: rest() };
|
|
169
|
+
}
|
|
170
|
+
case 0x84:
|
|
171
|
+
return { kind: 'pong', nonce: u32() };
|
|
172
|
+
default:
|
|
173
|
+
throw new CrowdyExecError('BadRequest', `unknown frame type 0x${tag.toString(16)} from the gateway`);
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
const DEFAULT_CALL_TIMEOUT_MS = 10000;
|
|
177
|
+
const OPEN_TIMEOUT_MS = 10000;
|
|
178
|
+
const MAX_BACKOFF_MS = 5000;
|
|
179
|
+
function subKey(nodeType, key, topic) {
|
|
180
|
+
return `${nodeType}\u0000${key}\u0000${topic}`;
|
|
181
|
+
}
|
|
182
|
+
/**
|
|
183
|
+
* One player's connection to a ck-exec host. Calls go to any node of the app;
|
|
184
|
+
* the host routes them. Subscriptions survive reconnects.
|
|
185
|
+
*/
|
|
186
|
+
export class ExecConnection {
|
|
187
|
+
/** @internal Use {@link ExecAPI.connect} or {@link ExecConnection.open}. */
|
|
188
|
+
constructor(dial, options = {}) {
|
|
189
|
+
this.dial = dial;
|
|
190
|
+
this.options = options;
|
|
191
|
+
this.ws = null;
|
|
192
|
+
this.endpoint = null;
|
|
193
|
+
this.ready = null;
|
|
194
|
+
this.pending = new Map();
|
|
195
|
+
this.subs = new Map();
|
|
196
|
+
this.nextRid = 1;
|
|
197
|
+
this.closed = false;
|
|
198
|
+
this.backoffMs = 250;
|
|
199
|
+
this.reconnectListeners = new Set();
|
|
200
|
+
const ctor = options.WebSocket ?? globalThis.WebSocket;
|
|
201
|
+
if (!ctor) {
|
|
202
|
+
throw new CrowdyExecError('Unavailable', 'no WebSocket implementation: pass options.WebSocket (e.g. the `ws` package) on this runtime');
|
|
203
|
+
}
|
|
204
|
+
this.WS = ctor;
|
|
205
|
+
this.callTimeoutMs = options.callTimeoutMs ?? DEFAULT_CALL_TIMEOUT_MS;
|
|
206
|
+
}
|
|
207
|
+
/**
|
|
208
|
+
* Opens a connection to a known gateway with a connect token, without the game API
|
|
209
|
+
* (tools and tests; a game uses {@link ExecAPI.connect}). It does not reconnect.
|
|
210
|
+
*/
|
|
211
|
+
static async open(gatewayUrl, token, options = {}) {
|
|
212
|
+
const c = new ExecConnection(async () => ({ gatewayUrl, token, host: '' }), {
|
|
213
|
+
...options,
|
|
214
|
+
reconnect: false,
|
|
215
|
+
});
|
|
216
|
+
await c.connect();
|
|
217
|
+
return c;
|
|
218
|
+
}
|
|
219
|
+
/** The execution host this connection is on. */
|
|
220
|
+
get host() {
|
|
221
|
+
return this.endpoint?.host ?? '';
|
|
222
|
+
}
|
|
223
|
+
/** Called with the new host after every reconnect. */
|
|
224
|
+
onReconnect(listener) {
|
|
225
|
+
this.reconnectListeners.add(listener);
|
|
226
|
+
return () => this.reconnectListeners.delete(listener);
|
|
227
|
+
}
|
|
228
|
+
/** @internal */
|
|
229
|
+
async connect() {
|
|
230
|
+
if (!this.ready) {
|
|
231
|
+
this.ready = this.dialOnce().catch((e) => {
|
|
232
|
+
this.ready = null;
|
|
233
|
+
throw e;
|
|
234
|
+
});
|
|
235
|
+
}
|
|
236
|
+
return this.ready;
|
|
237
|
+
}
|
|
238
|
+
async dialOnce() {
|
|
239
|
+
const endpoint = await this.dial();
|
|
240
|
+
const url = `${endpoint.gatewayUrl.replace(/\/+$/, '')}/v1/connect?token=${encodeURIComponent(endpoint.token)}`;
|
|
241
|
+
const ws = new this.WS(url);
|
|
242
|
+
ws.binaryType = 'arraybuffer';
|
|
243
|
+
await new Promise((resolve, reject) => {
|
|
244
|
+
const timer = setTimeout(() => {
|
|
245
|
+
reject(new CrowdyExecError('Unavailable', `could not reach ${endpoint.gatewayUrl} in ${OPEN_TIMEOUT_MS} ms`));
|
|
246
|
+
try {
|
|
247
|
+
ws.close();
|
|
248
|
+
}
|
|
249
|
+
catch {
|
|
250
|
+
/* already closed */
|
|
251
|
+
}
|
|
252
|
+
}, OPEN_TIMEOUT_MS);
|
|
253
|
+
ws.onopen = () => {
|
|
254
|
+
clearTimeout(timer);
|
|
255
|
+
resolve();
|
|
256
|
+
};
|
|
257
|
+
ws.onerror = (ev) => {
|
|
258
|
+
clearTimeout(timer);
|
|
259
|
+
reject(new CrowdyExecError('Unavailable', `connecting to ${endpoint.gatewayUrl} failed`, ev));
|
|
260
|
+
};
|
|
261
|
+
ws.onclose = (ev) => {
|
|
262
|
+
clearTimeout(timer);
|
|
263
|
+
reject(new CrowdyExecError(ev.code === 4401 ? 'Denied' : 'Unavailable', `the gateway closed the connection (${ev.code}${ev.reason ? `: ${ev.reason}` : ''})`));
|
|
264
|
+
};
|
|
265
|
+
});
|
|
266
|
+
this.ws = ws;
|
|
267
|
+
this.endpoint = endpoint;
|
|
268
|
+
this.backoffMs = 250;
|
|
269
|
+
ws.onmessage = (ev) => this.onMessage(ev.data);
|
|
270
|
+
ws.onerror = () => { };
|
|
271
|
+
ws.onclose = () => this.onClosed(ws);
|
|
272
|
+
}
|
|
273
|
+
onMessage(data) {
|
|
274
|
+
let frame;
|
|
275
|
+
try {
|
|
276
|
+
const bytes = data instanceof ArrayBuffer
|
|
277
|
+
? new Uint8Array(data)
|
|
278
|
+
: ArrayBuffer.isView(data)
|
|
279
|
+
? new Uint8Array(data.buffer, data.byteOffset, data.byteLength)
|
|
280
|
+
: null;
|
|
281
|
+
if (!bytes)
|
|
282
|
+
return;
|
|
283
|
+
frame = decodeExecFrame(bytes);
|
|
284
|
+
}
|
|
285
|
+
catch {
|
|
286
|
+
return;
|
|
287
|
+
}
|
|
288
|
+
if (frame.kind === 'reply' || frame.kind === 'pong') {
|
|
289
|
+
const rid = frame.kind === 'reply' ? frame.rid : frame.nonce;
|
|
290
|
+
const p = this.pending.get(rid);
|
|
291
|
+
if (!p)
|
|
292
|
+
return;
|
|
293
|
+
this.pending.delete(rid);
|
|
294
|
+
clearTimeout(p.timer);
|
|
295
|
+
p.resolve(frame.kind === 'reply'
|
|
296
|
+
? { status: frame.status, payload: frame.payload, ws: p.ws }
|
|
297
|
+
: { status: 0, payload: new Uint8Array(), ws: p.ws });
|
|
298
|
+
return;
|
|
299
|
+
}
|
|
300
|
+
const handlers = this.subs.get(subKey(frame.nodeType, frame.key, frame.topic));
|
|
301
|
+
if (!handlers)
|
|
302
|
+
return;
|
|
303
|
+
let value;
|
|
304
|
+
try {
|
|
305
|
+
value = decode(frame.payload, this.options.decode);
|
|
306
|
+
}
|
|
307
|
+
catch {
|
|
308
|
+
value = undefined;
|
|
309
|
+
}
|
|
310
|
+
for (const h of handlers) {
|
|
311
|
+
try {
|
|
312
|
+
h({ nodeType: frame.nodeType, key: frame.key, topic: frame.topic, value, payload: frame.payload });
|
|
313
|
+
}
|
|
314
|
+
catch {
|
|
315
|
+
/* a handler's exception is its own */
|
|
316
|
+
}
|
|
317
|
+
}
|
|
318
|
+
}
|
|
319
|
+
rejectPending(ws, why) {
|
|
320
|
+
for (const [rid, p] of this.pending) {
|
|
321
|
+
if (p.ws !== ws)
|
|
322
|
+
continue;
|
|
323
|
+
this.pending.delete(rid);
|
|
324
|
+
clearTimeout(p.timer);
|
|
325
|
+
p.reject(new CrowdyExecError('Unavailable', why));
|
|
326
|
+
}
|
|
327
|
+
}
|
|
328
|
+
onClosed(ws) {
|
|
329
|
+
this.rejectPending(ws, 'the connection to the execution host closed');
|
|
330
|
+
if (this.ws !== ws)
|
|
331
|
+
return;
|
|
332
|
+
this.ws = null;
|
|
333
|
+
this.ready = null;
|
|
334
|
+
if (!this.closed && this.options.reconnect !== false)
|
|
335
|
+
void this.reconnect();
|
|
336
|
+
}
|
|
337
|
+
async reconnect() {
|
|
338
|
+
while (!this.closed) {
|
|
339
|
+
try {
|
|
340
|
+
await this.connect();
|
|
341
|
+
await this.renewSubscriptions();
|
|
342
|
+
for (const l of this.reconnectListeners)
|
|
343
|
+
l(this.host);
|
|
344
|
+
return;
|
|
345
|
+
}
|
|
346
|
+
catch {
|
|
347
|
+
await new Promise((r) => setTimeout(r, this.backoffMs));
|
|
348
|
+
this.backoffMs = Math.min(this.backoffMs * 2, MAX_BACKOFF_MS);
|
|
349
|
+
}
|
|
350
|
+
}
|
|
351
|
+
}
|
|
352
|
+
/**
|
|
353
|
+
* Leaves the host `from` for the one the game API picks now, unless another
|
|
354
|
+
* caller already has. Calls still waiting on `from` fail as Unavailable.
|
|
355
|
+
*/
|
|
356
|
+
async redial(from) {
|
|
357
|
+
if (this.ws === from) {
|
|
358
|
+
this.ws = null;
|
|
359
|
+
this.ready = null;
|
|
360
|
+
from.onclose = null;
|
|
361
|
+
this.rejectPending(from, 'the connection moved to another host');
|
|
362
|
+
try {
|
|
363
|
+
from.close();
|
|
364
|
+
}
|
|
365
|
+
catch {
|
|
366
|
+
/* already closed */
|
|
367
|
+
}
|
|
368
|
+
await this.connect();
|
|
369
|
+
await this.renewSubscriptions();
|
|
370
|
+
for (const l of this.reconnectListeners)
|
|
371
|
+
l(this.host);
|
|
372
|
+
return;
|
|
373
|
+
}
|
|
374
|
+
await this.connect();
|
|
375
|
+
}
|
|
376
|
+
async renewSubscriptions() {
|
|
377
|
+
for (const k of this.subs.keys()) {
|
|
378
|
+
const [nodeType, key, topic] = k.split('\u0000');
|
|
379
|
+
await this.request({ kind: 'subscribe', rid: 0, nodeType, key, topic });
|
|
380
|
+
}
|
|
381
|
+
}
|
|
382
|
+
rid() {
|
|
383
|
+
const rid = this.nextRid;
|
|
384
|
+
this.nextRid = this.nextRid >= 0xffffffff ? 1 : this.nextRid + 1;
|
|
385
|
+
return rid;
|
|
386
|
+
}
|
|
387
|
+
/** Sends one frame and waits for the reply with its rid. */
|
|
388
|
+
async request(frame, timeoutMs = this.callTimeoutMs) {
|
|
389
|
+
if (this.closed)
|
|
390
|
+
throw new CrowdyExecError('Unavailable', 'the connection is closed');
|
|
391
|
+
await this.connect();
|
|
392
|
+
const ws = this.ws;
|
|
393
|
+
if (!ws)
|
|
394
|
+
throw new CrowdyExecError('Unavailable', 'not connected');
|
|
395
|
+
const rid = this.rid();
|
|
396
|
+
const f = frame.kind === 'ping' ? { ...frame, nonce: rid } : { ...frame, rid };
|
|
397
|
+
return new Promise((resolve, reject) => {
|
|
398
|
+
const timer = setTimeout(() => {
|
|
399
|
+
this.pending.delete(rid);
|
|
400
|
+
reject(new CrowdyExecError('DeadlineExceeded', `no reply in ${timeoutMs} ms`));
|
|
401
|
+
}, timeoutMs);
|
|
402
|
+
this.pending.set(rid, { ws, resolve, reject, timer });
|
|
403
|
+
try {
|
|
404
|
+
ws.send(encodeExecFrame(f));
|
|
405
|
+
}
|
|
406
|
+
catch (e) {
|
|
407
|
+
this.pending.delete(rid);
|
|
408
|
+
clearTimeout(timer);
|
|
409
|
+
reject(new CrowdyExecError('Unavailable', 'sending to the execution host failed', e));
|
|
410
|
+
}
|
|
411
|
+
});
|
|
412
|
+
}
|
|
413
|
+
/**
|
|
414
|
+
* Calls a node's endpoint with raw bytes and returns the reply's bytes. A call
|
|
415
|
+
* whose connection closes, or that is answered `Moved`, is tried once more on a
|
|
416
|
+
* fresh connection.
|
|
417
|
+
* @throws {CrowdyExecError} for any status but `Ok`.
|
|
418
|
+
*/
|
|
419
|
+
async callRaw(nodeType, key, method, payload = new Uint8Array(), options = {}) {
|
|
420
|
+
const frame = { kind: 'call', rid: 0, nodeType, key, method, payload };
|
|
421
|
+
const recovers = this.options.reconnect !== false;
|
|
422
|
+
let reply;
|
|
423
|
+
try {
|
|
424
|
+
reply = await this.request(frame, options.timeoutMs);
|
|
425
|
+
}
|
|
426
|
+
catch (e) {
|
|
427
|
+
if (!recovers || !(e instanceof CrowdyExecError) || e.status !== 'Unavailable')
|
|
428
|
+
throw e;
|
|
429
|
+
reply = await this.request(frame, options.timeoutMs);
|
|
430
|
+
}
|
|
431
|
+
if (recovers && execStatus(reply.status) === 'Moved') {
|
|
432
|
+
await this.redial(reply.ws);
|
|
433
|
+
reply = await this.request(frame, options.timeoutMs);
|
|
434
|
+
}
|
|
435
|
+
const status = execStatus(reply.status);
|
|
436
|
+
if (status !== 'Ok') {
|
|
437
|
+
throw new CrowdyExecError(status, fromUtf8Lossy(reply.payload));
|
|
438
|
+
}
|
|
439
|
+
return reply.payload;
|
|
440
|
+
}
|
|
441
|
+
/**
|
|
442
|
+
* Calls a node's endpoint: `args` go as MessagePack, the reply comes back decoded.
|
|
443
|
+
* @throws {CrowdyExecError} for any status but `Ok`; `AppError` carries the handler's message.
|
|
444
|
+
*/
|
|
445
|
+
async call(nodeType, key, method, args, options = {}) {
|
|
446
|
+
const payload = encode(args ?? null, { useBigInt64: true });
|
|
447
|
+
const reply = await this.callRaw(nodeType, key, method, payload, options);
|
|
448
|
+
return decode(reply, this.options.decode);
|
|
449
|
+
}
|
|
450
|
+
/**
|
|
451
|
+
* Receives what a node publishes on `topic`. Returns a function that stops
|
|
452
|
+
* this handler (and the subscription, when it was the last one).
|
|
453
|
+
*/
|
|
454
|
+
async subscribe(nodeType, key, topic, onPush) {
|
|
455
|
+
const k = subKey(nodeType, key, topic);
|
|
456
|
+
let handlers = this.subs.get(k);
|
|
457
|
+
const first = !handlers;
|
|
458
|
+
if (!handlers) {
|
|
459
|
+
handlers = new Set();
|
|
460
|
+
this.subs.set(k, handlers);
|
|
461
|
+
}
|
|
462
|
+
handlers.add(onPush);
|
|
463
|
+
if (first) {
|
|
464
|
+
try {
|
|
465
|
+
const reply = await this.request({ kind: 'subscribe', rid: 0, nodeType, key, topic });
|
|
466
|
+
const status = execStatus(reply.status);
|
|
467
|
+
if (status !== 'Ok')
|
|
468
|
+
throw new CrowdyExecError(status, fromUtf8Lossy(reply.payload));
|
|
469
|
+
}
|
|
470
|
+
catch (e) {
|
|
471
|
+
handlers.delete(onPush);
|
|
472
|
+
if (handlers.size === 0)
|
|
473
|
+
this.subs.delete(k);
|
|
474
|
+
throw e;
|
|
475
|
+
}
|
|
476
|
+
}
|
|
477
|
+
return async () => {
|
|
478
|
+
const set = this.subs.get(k);
|
|
479
|
+
if (!set?.delete(onPush) || set.size > 0)
|
|
480
|
+
return;
|
|
481
|
+
this.subs.delete(k);
|
|
482
|
+
if (this.ws) {
|
|
483
|
+
await this.request({ kind: 'unsubscribe', rid: 0, nodeType, key, topic }).catch(() => undefined);
|
|
484
|
+
}
|
|
485
|
+
};
|
|
486
|
+
}
|
|
487
|
+
/** Round trip to the gateway, in milliseconds. */
|
|
488
|
+
async ping() {
|
|
489
|
+
const t = Date.now();
|
|
490
|
+
await this.request({ kind: 'ping', nonce: 0 });
|
|
491
|
+
return Date.now() - t;
|
|
492
|
+
}
|
|
493
|
+
/** Closes the connection; it does not reconnect. */
|
|
494
|
+
close() {
|
|
495
|
+
this.closed = true;
|
|
496
|
+
this.ws?.close();
|
|
497
|
+
this.ws = null;
|
|
498
|
+
this.ready = null;
|
|
499
|
+
}
|
|
500
|
+
}
|
|
501
|
+
function fromUtf8Lossy(b) {
|
|
502
|
+
return new TextDecoder().decode(b);
|
|
503
|
+
}
|
|
504
|
+
function base64(bytes) {
|
|
505
|
+
let s = '';
|
|
506
|
+
for (let i = 0; i < bytes.length; i += 0x8000) {
|
|
507
|
+
s += String.fromCharCode(...bytes.subarray(i, i + 0x8000));
|
|
508
|
+
}
|
|
509
|
+
return btoa(s);
|
|
510
|
+
}
|
|
511
|
+
async function sha256Hex(bytes) {
|
|
512
|
+
const d = new Uint8Array(await crypto.subtle.digest('SHA-256', bytes));
|
|
513
|
+
return Array.from(d, (x) => x.toString(16).padStart(2, '0')).join('');
|
|
514
|
+
}
|
|
515
|
+
/** `client.exec`: connecting players to ck-exec, and deploying an app's nodes. */
|
|
516
|
+
export class ExecAPI {
|
|
517
|
+
constructor(graphql) {
|
|
518
|
+
this.graphql = graphql;
|
|
519
|
+
}
|
|
520
|
+
/** A host for this player and its connect token (valid for about a minute). */
|
|
521
|
+
async endpoint(appId, options = {}) {
|
|
522
|
+
const data = await this.graphql.request(ExecConnectDocument, {
|
|
523
|
+
appId,
|
|
524
|
+
nodeType: options.nodeType,
|
|
525
|
+
key: options.key,
|
|
526
|
+
});
|
|
527
|
+
return data.execConnect;
|
|
528
|
+
}
|
|
529
|
+
/**
|
|
530
|
+
* Connects the signed-in player to ck-exec for `appId`. The session token must be
|
|
531
|
+
* that app's app-scoped token. With `nodeType` (and `key`) the player lands on the
|
|
532
|
+
* host that runs that instance.
|
|
533
|
+
*/
|
|
534
|
+
async connect(appId, options = {}) {
|
|
535
|
+
const c = new ExecConnection(() => this.endpoint(appId, { nodeType: options.nodeType, key: options.key }), options);
|
|
536
|
+
await c.connect();
|
|
537
|
+
return c;
|
|
538
|
+
}
|
|
539
|
+
/**
|
|
540
|
+
* Deploys a new version of the app's nodes and makes it active: the manifest, and
|
|
541
|
+
* each distinct module once. Running instances pick it up when they next start.
|
|
542
|
+
* Requires the org `manage_compute` permission.
|
|
543
|
+
*/
|
|
544
|
+
async deploy(options) {
|
|
545
|
+
const types = {};
|
|
546
|
+
const artifacts = new Map();
|
|
547
|
+
for (const [name, t] of Object.entries(options.types)) {
|
|
548
|
+
const { wasm, ...spec } = t;
|
|
549
|
+
const digest = await sha256Hex(wasm);
|
|
550
|
+
if (!artifacts.has(digest))
|
|
551
|
+
artifacts.set(digest, base64(wasm));
|
|
552
|
+
types[name] = { ...spec, digest };
|
|
553
|
+
}
|
|
554
|
+
const data = await this.graphql.request(ExecDeployDocument, {
|
|
555
|
+
input: {
|
|
556
|
+
appId: options.appId,
|
|
557
|
+
manifestJson: JSON.stringify({ root: options.root, types }),
|
|
558
|
+
artifacts: [...artifacts].map(([digest, wasmBase64]) => ({ digest, wasmBase64 })),
|
|
559
|
+
},
|
|
560
|
+
});
|
|
561
|
+
return data.execDeploy;
|
|
562
|
+
}
|
|
563
|
+
}
|
|
@@ -3619,6 +3619,40 @@ export type ExchangePortalCodeInput = {
|
|
|
3619
3619
|
/** PKCE code verifier matching the challenge supplied when the code was created. Required when the code was created with a challenge. */
|
|
3620
3620
|
codeVerifier?: InputMaybe<Scalars['String']['input']>;
|
|
3621
3621
|
};
|
|
3622
|
+
/** One compiled module for a ck-exec deploy. */
|
|
3623
|
+
export type ExecArtifactInput = {
|
|
3624
|
+
/** SHA-256 of the WASM, 64 hex characters; the manager checks it. */
|
|
3625
|
+
digest: Scalars['String']['input'];
|
|
3626
|
+
/** The WASM module (wasm32-unknown-unknown, built with ckx-sdk), base64. */
|
|
3627
|
+
wasmBase64: Scalars['String']['input'];
|
|
3628
|
+
};
|
|
3629
|
+
/** Where and how a game client connects to ck-exec, the hub-and-spoke execution service (dev-tier preview). Open a WebSocket to `{gatewayUrl}/v1/connect?token={token}` before `expiresAt`. */
|
|
3630
|
+
export type ExecConnection = {
|
|
3631
|
+
__typename?: 'ExecConnection';
|
|
3632
|
+
/** When the token stops being accepted; the open socket is not closed then. */
|
|
3633
|
+
expiresAt: Scalars['DateTime']['output'];
|
|
3634
|
+
/** The execution host’s gateway, e.g. wss://ckx-or-1.exec.dev.crowdedkingdoms.com. */
|
|
3635
|
+
gatewayUrl: Scalars['String']['output'];
|
|
3636
|
+
/** The execution host id the token is bound to. */
|
|
3637
|
+
host: Scalars['String']['output'];
|
|
3638
|
+
/** Short-lived Ed25519 connect token for this player, this app and this host only. */
|
|
3639
|
+
token: Scalars['String']['output'];
|
|
3640
|
+
};
|
|
3641
|
+
/** A new version of an app’s ck-exec topology. */
|
|
3642
|
+
export type ExecDeployInput = {
|
|
3643
|
+
/** The app to deploy. */
|
|
3644
|
+
appId: Scalars['BigInt']['input'];
|
|
3645
|
+
/** Modules the manifest names that this app has not uploaded before (at most 64). A request body is limited to 10 MB, so about 7 MB of modules per call. */
|
|
3646
|
+
artifacts?: InputMaybe<Array<ExecArtifactInput>>;
|
|
3647
|
+
/** The manifest as JSON: `{ "root": "<hub type>", "types": { "<name>": { "kind": "hub"|"spoke", "parent", "digest", "client", "calls", ... } } }`. See the ck-exec repo’s `ckx-control` crate for every field and its platform bounds. */
|
|
3648
|
+
manifestJson: Scalars['String']['input'];
|
|
3649
|
+
};
|
|
3650
|
+
/** The result of a ck-exec deploy. */
|
|
3651
|
+
export type ExecDeployResult = {
|
|
3652
|
+
__typename?: 'ExecDeployResult';
|
|
3653
|
+
/** The version now active for the app. */
|
|
3654
|
+
version: Scalars['Int']['output'];
|
|
3655
|
+
};
|
|
3622
3656
|
/** An org's free shared app slot quota usage. */
|
|
3623
3657
|
export type FreeAppQuota = {
|
|
3624
3658
|
__typename?: 'FreeAppQuota';
|
|
@@ -5490,6 +5524,10 @@ export type Mutation = {
|
|
|
5490
5524
|
disconnectUdpProxy: Scalars['Boolean']['output'];
|
|
5491
5525
|
/** Exchange a one-time portal authorization code (with the matching PKCE verifier) for an app-scoped gameplay token. Public (the code + verifier authorize the call); called by the destination game at its own origin so the game never sees the player's session token. */
|
|
5492
5526
|
exchangePortalCode: AppTokenResponse;
|
|
5527
|
+
/** Connect a player to ck-exec (dev-tier preview): the execution manager picks a host (the one running `nodeType`/`key` when given, placing it if needed) and this returns its gateway and a 60-second connect token bound to this player, this app and that host. Requires the app-scoped token of the app named. */
|
|
5528
|
+
execConnect: ExecConnection;
|
|
5529
|
+
/** Deploy a new version of an app's ck-exec topology (dev-tier preview): the manifest and any modules it names that were not uploaded before. The execution manager checks the manifest's tree and bounds and each module's digest, stores them, and makes the version active; running instances pick it up when they next start. Requires the org 'manage_compute' permission. */
|
|
5530
|
+
execDeploy: ExecDeployResult;
|
|
5493
5531
|
/** ADMIN/DESTRUCTIVE: revokes ALL of the target user’s sessions by deleting every game_token row, forcing re-authentication on every device. Returns true if at least one session was revoked. Requires a super-admin bearer game token (and the management API enabled). */
|
|
5494
5532
|
forceLogoutUser: Scalars['Boolean']['output'];
|
|
5495
5533
|
/** Operator only (is_operator). Deletes the stored deliverability rows for one address (email_status and email_events) and returns how many rows went. Exists so a verification run is not reading the previous run's events, and so an address suppressed by a bounce that has since been fixed can be given another chance. Returns 0 when there was nothing stored. */
|
|
@@ -6101,6 +6139,14 @@ export type MutationDeleteUserAppStateArgs = {
|
|
|
6101
6139
|
export type MutationExchangePortalCodeArgs = {
|
|
6102
6140
|
input: ExchangePortalCodeInput;
|
|
6103
6141
|
};
|
|
6142
|
+
export type MutationExecConnectArgs = {
|
|
6143
|
+
appId: Scalars['BigInt']['input'];
|
|
6144
|
+
key?: InputMaybe<Scalars['String']['input']>;
|
|
6145
|
+
nodeType?: InputMaybe<Scalars['String']['input']>;
|
|
6146
|
+
};
|
|
6147
|
+
export type MutationExecDeployArgs = {
|
|
6148
|
+
input: ExecDeployInput;
|
|
6149
|
+
};
|
|
6104
6150
|
export type MutationForceLogoutUserArgs = {
|
|
6105
6151
|
userId: Scalars['BigInt']['input'];
|
|
6106
6152
|
};
|
|
@@ -13904,6 +13950,31 @@ export type CrowdyStudioProjectImportFileMutation = {
|
|
|
13904
13950
|
}>;
|
|
13905
13951
|
};
|
|
13906
13952
|
};
|
|
13953
|
+
export type ExecConnectMutationVariables = Exact<{
|
|
13954
|
+
appId: Scalars['BigInt']['input'];
|
|
13955
|
+
nodeType?: InputMaybe<Scalars['String']['input']>;
|
|
13956
|
+
key?: InputMaybe<Scalars['String']['input']>;
|
|
13957
|
+
}>;
|
|
13958
|
+
export type ExecConnectMutation = {
|
|
13959
|
+
__typename?: 'Mutation';
|
|
13960
|
+
execConnect: {
|
|
13961
|
+
__typename?: 'ExecConnection';
|
|
13962
|
+
gatewayUrl: string;
|
|
13963
|
+
token: string;
|
|
13964
|
+
host: string;
|
|
13965
|
+
expiresAt: string;
|
|
13966
|
+
};
|
|
13967
|
+
};
|
|
13968
|
+
export type ExecDeployMutationVariables = Exact<{
|
|
13969
|
+
input: ExecDeployInput;
|
|
13970
|
+
}>;
|
|
13971
|
+
export type ExecDeployMutation = {
|
|
13972
|
+
__typename?: 'Mutation';
|
|
13973
|
+
execDeploy: {
|
|
13974
|
+
__typename?: 'ExecDeployResult';
|
|
13975
|
+
version: number;
|
|
13976
|
+
};
|
|
13977
|
+
};
|
|
13907
13978
|
export type GridOwnershipFieldsFragment = {
|
|
13908
13979
|
__typename?: 'GridOwnership';
|
|
13909
13980
|
gridOwnershipId: string;
|
|
@@ -20201,6 +20272,8 @@ export declare const CrowdyStudioLibraryFilesDocument: DocumentNode<CrowdyStudio
|
|
|
20201
20272
|
export declare const CrowdyStudioLibrarySaveDocument: DocumentNode<CrowdyStudioLibrarySaveMutation, CrowdyStudioLibrarySaveMutationVariables>;
|
|
20202
20273
|
export declare const CrowdyStudioCommonFilesDocument: DocumentNode<CrowdyStudioCommonFilesQuery, CrowdyStudioCommonFilesQueryVariables>;
|
|
20203
20274
|
export declare const CrowdyStudioProjectImportFileDocument: DocumentNode<CrowdyStudioProjectImportFileMutation, CrowdyStudioProjectImportFileMutationVariables>;
|
|
20275
|
+
export declare const ExecConnectDocument: DocumentNode<ExecConnectMutation, ExecConnectMutationVariables>;
|
|
20276
|
+
export declare const ExecDeployDocument: DocumentNode<ExecDeployMutation, ExecDeployMutationVariables>;
|
|
20204
20277
|
export declare const GridOwnershipDocument: DocumentNode<GridOwnershipQuery, GridOwnershipQueryVariables>;
|
|
20205
20278
|
export declare const AssignGridOwnershipDocument: DocumentNode<AssignGridOwnershipMutation, AssignGridOwnershipMutationVariables>;
|
|
20206
20279
|
export declare const TransferGridOwnershipDocument: DocumentNode<TransferGridOwnershipMutation, TransferGridOwnershipMutationVariables>;
|