@crowdedkingdoms/crowdyjs 17.8.0-test.1 → 17.10.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.
@@ -0,0 +1,631 @@
1
+ import { decode, encode } from '@msgpack/msgpack';
2
+ import { CrowdyError } from '../errors.js';
3
+ import { ExecActivateVersionDocument, ExecAppStatusDocument, ExecConnectAsDeveloperDocument, ExecConnectDocument, ExecDeployDocument, ExecInstancesDocument, ExecLogsDocument, ExecSetEnabledDocument, ExecVersionsDocument, } 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
+ function strip(v) {
516
+ const { __typename: _, ...rest } = v;
517
+ return rest;
518
+ }
519
+ /**
520
+ * `client.exec`: connecting players to ck-exec, deploying an app's nodes, and operating
521
+ * them (logs, instances, versions, rollback, the kill switch, developer connections).
522
+ */
523
+ export class ExecAPI {
524
+ constructor(graphql) {
525
+ this.graphql = graphql;
526
+ }
527
+ /**
528
+ * A host and a developer connect token for `appId` (valid for about a minute). The
529
+ * session's calls arrive as `Caller::Developer` with your user id and may reach any
530
+ * node type, not only `client` ones. Requires the org `manage_compute` permission and
531
+ * your own session token, not an app token.
532
+ */
533
+ async developerEndpoint(appId, options = {}) {
534
+ const data = await this.graphql.request(ExecConnectAsDeveloperDocument, {
535
+ appId,
536
+ nodeType: options.nodeType,
537
+ key: options.key,
538
+ });
539
+ return data.execConnectAsDeveloper;
540
+ }
541
+ /**
542
+ * Connects as one of the app's developers, for studio tools, manual runs and admin
543
+ * endpoints. The same connection as {@link connect}, reconnecting with a fresh
544
+ * developer token.
545
+ */
546
+ async connectAsDeveloper(appId, options = {}) {
547
+ const c = new ExecConnection(() => this.developerEndpoint(appId, { nodeType: options.nodeType, key: options.key }), options);
548
+ await c.connect();
549
+ return c;
550
+ }
551
+ /** Guest log lines, newest first, kept for 24 hours. Requires `view_compute_diagnostics`. */
552
+ async logs(appId, options = {}) {
553
+ const data = await this.graphql.request(ExecLogsDocument, { appId, ...options });
554
+ return data.execLogs.map(strip);
555
+ }
556
+ /** What the manager has placed for the app. Requires `view_compute_diagnostics`. */
557
+ async instances(appId) {
558
+ const data = await this.graphql.request(ExecInstancesDocument, { appId });
559
+ return data.execInstances.map(strip);
560
+ }
561
+ /** The app's versions, newest first. Requires `view_compute_diagnostics`. */
562
+ async versions(appId) {
563
+ const data = await this.graphql.request(ExecVersionsDocument, { appId });
564
+ return data.execVersions.map(strip);
565
+ }
566
+ /** The active version and the switches. Requires `view_compute_diagnostics`. */
567
+ async status(appId) {
568
+ const data = await this.graphql.request(ExecAppStatusDocument, { appId });
569
+ return strip(data.execAppStatus);
570
+ }
571
+ /**
572
+ * Makes an earlier version active again, a rollback; instances pick it up when they
573
+ * next start. Requires `manage_compute`.
574
+ */
575
+ async activateVersion(appId, version) {
576
+ const data = await this.graphql.request(ExecActivateVersionDocument, { appId, version });
577
+ return strip(data.execActivateVersion);
578
+ }
579
+ /**
580
+ * The kill switch, for the whole app or one node type. Off: nothing of it is placed,
581
+ * what runs is persisted and stopped, and calls are refused with `Denied`. Requires
582
+ * `manage_compute`.
583
+ */
584
+ async setEnabled(appId, enabled, nodeType) {
585
+ const data = await this.graphql.request(ExecSetEnabledDocument, { appId, enabled, nodeType });
586
+ return strip(data.execSetEnabled);
587
+ }
588
+ /** A host for this player and its connect token (valid for about a minute). */
589
+ async endpoint(appId, options = {}) {
590
+ const data = await this.graphql.request(ExecConnectDocument, {
591
+ appId,
592
+ nodeType: options.nodeType,
593
+ key: options.key,
594
+ });
595
+ return data.execConnect;
596
+ }
597
+ /**
598
+ * Connects the signed-in player to ck-exec for `appId`. The session token must be
599
+ * that app's app-scoped token. With `nodeType` (and `key`) the player lands on the
600
+ * host that runs that instance.
601
+ */
602
+ async connect(appId, options = {}) {
603
+ const c = new ExecConnection(() => this.endpoint(appId, { nodeType: options.nodeType, key: options.key }), options);
604
+ await c.connect();
605
+ return c;
606
+ }
607
+ /**
608
+ * Deploys a new version of the app's nodes and makes it active: the manifest, and
609
+ * each distinct module once. Running instances pick it up when they next start.
610
+ * Requires the org `manage_compute` permission.
611
+ */
612
+ async deploy(options) {
613
+ const types = {};
614
+ const artifacts = new Map();
615
+ for (const [name, t] of Object.entries(options.types)) {
616
+ const { wasm, ...spec } = t;
617
+ const digest = await sha256Hex(wasm);
618
+ if (!artifacts.has(digest))
619
+ artifacts.set(digest, base64(wasm));
620
+ types[name] = { ...spec, digest };
621
+ }
622
+ const data = await this.graphql.request(ExecDeployDocument, {
623
+ input: {
624
+ appId: options.appId,
625
+ manifestJson: JSON.stringify({ root: options.root, types }),
626
+ artifacts: [...artifacts].map(([digest, wasmBase64]) => ({ digest, wasmBase64 })),
627
+ },
628
+ });
629
+ return data.execDeploy;
630
+ }
631
+ }