@rilong/grammyjs-conversations-esm 2.0.2

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/out/plugin.js ADDED
@@ -0,0 +1,578 @@
1
+ import { Conversation } from "./conversation.js";
2
+ import { Api, Composer, Context, HttpError, } from "./deps.node.js";
3
+ import { ReplayEngine, } from "./engine.js";
4
+ import { youTouchYouDie } from "./nope.js";
5
+ import { uniformStorage } from "./storage.js";
6
+ const internalRecursionDetection = Symbol("conversations.recursion");
7
+ const internalState = Symbol("conversations.state");
8
+ const internalCompletenessMarker = Symbol("conversations.completeness");
9
+ function controls(getData, isParallel, enter, exit, canSave) {
10
+ async function fireExit(events) {
11
+ if (exit === undefined)
12
+ return;
13
+ const len = events.length;
14
+ for (let i = 0; i < len; i++) {
15
+ await exit(events[i]);
16
+ }
17
+ }
18
+ return {
19
+ async enter(name, ...args) {
20
+ var _a, _b;
21
+ if (!canSave()) {
22
+ throw new Error("The middleware has already completed so it is \
23
+ no longer possible to enter a conversation");
24
+ }
25
+ const data = getData();
26
+ if (Object.keys(data).length > 0 && !isParallel(name)) {
27
+ throw new Error(`A conversation was already entered and '${name}' \
28
+ is not a parallel conversation. Make sure to exit all active conversations \
29
+ before entering a new one, or specify { parallel: true } for '${name}' \
30
+ if you want it to run in parallel.`);
31
+ }
32
+ (_a = data[name]) !== null && _a !== void 0 ? _a : (data[name] = []);
33
+ const result = await enter(name, ...args);
34
+ if (!canSave()) {
35
+ throw new Error("The middleware has completed before conversation was fully \
36
+ entered so the conversations plugin cannot persist data anymore, did you forget \
37
+ to use `await`?");
38
+ }
39
+ switch (result.status) {
40
+ case "complete":
41
+ return;
42
+ case "error":
43
+ throw result.error;
44
+ case "handled":
45
+ case "skipped": {
46
+ const args = result.args === undefined
47
+ ? {}
48
+ : { args: result.args };
49
+ const state = {
50
+ ...args,
51
+ interrupts: result.interrupts,
52
+ replay: result.replay,
53
+ };
54
+ (_b = data[name]) === null || _b === void 0 ? void 0 : _b.push(state);
55
+ return;
56
+ }
57
+ }
58
+ },
59
+ async exitAll() {
60
+ if (!canSave()) {
61
+ throw new Error("The middleware has already completed so it is no longer possible to exit all conversations");
62
+ }
63
+ const data = getData();
64
+ const keys = Object.keys(data);
65
+ const events = keys.flatMap((key) => Array(data[key].length).fill(key));
66
+ keys.forEach((key) => delete data[key]);
67
+ await fireExit(events);
68
+ },
69
+ async exit(name) {
70
+ if (!canSave()) {
71
+ throw new Error(`The middleware has already completed so it is no longer possible to exit any conversations named '${name}'`);
72
+ }
73
+ const data = getData();
74
+ if (data[name] === undefined)
75
+ return;
76
+ const events = Array(data[name].length).fill(name);
77
+ delete data[name];
78
+ await fireExit(events);
79
+ },
80
+ async exitOne(name, index) {
81
+ if (!canSave()) {
82
+ throw new Error(`The middleware has already completed so it is no longer possible to exit the conversation '${name}'`);
83
+ }
84
+ const data = getData();
85
+ if (data[name] === undefined ||
86
+ index < 0 || data[name].length <= index)
87
+ return;
88
+ data[name].splice(index, 1);
89
+ await fireExit([name]);
90
+ },
91
+ // deno-lint-ignore no-explicit-any
92
+ active(name) {
93
+ var _a, _b;
94
+ const data = getData();
95
+ return name === undefined
96
+ ? Object.fromEntries(Object.entries(data)
97
+ .map(([name, states]) => [name, states.length]))
98
+ : (_b = (_a = data[name]) === null || _a === void 0 ? void 0 : _a.length) !== null && _b !== void 0 ? _b : 0;
99
+ },
100
+ };
101
+ }
102
+ /**
103
+ * Middleware for the conversations plugin.
104
+ *
105
+ * This is the main thing you have to install in order to use this plugin. It
106
+ * performs various setup tasks for each context object, and it reads and writes
107
+ * to the data storage if provided. This middleware has to be installed before
108
+ * you can install `createConversation` with your conversation builder function.
109
+ *
110
+ * You can pass {@link ConversationOptions | an options object} to the plugin.
111
+ * The most important option is called `storage`. It can be used to persist
112
+ * conversations durably in any storage backend of your choice. That way, the
113
+ * conversations can survive restarts of your server.
114
+ *
115
+ * ```ts
116
+ * conversations({
117
+ * storage: {
118
+ * type: "key",
119
+ * version: 0, // change the version when you change your code
120
+ * adapter: new FileAdapter("/home/bot/data"),
121
+ * },
122
+ * });
123
+ * ```
124
+ *
125
+ * A list of known storage adapters can be found
126
+ * [here](https://github.com/grammyjs/storages/tree/main/packages#grammy-storages).
127
+ *
128
+ * It is advisable to version your data when you persist it. Every time you
129
+ * change your conversation function, you can increment the version. That way,
130
+ * the conversations plugin can make sure to avoid any data corruption caused by
131
+ * mismatches between state and implementation.
132
+ *
133
+ * Note that the plugin takes two different type parameters. The first type
134
+ * parameter should corresopnd with the context type of the outside middleware
135
+ * tree. The second type parameter should correspond with the custom context
136
+ * type used inside all conversations. If you may want to use different context
137
+ * types for different conversations, you can simply use `Context` here, and
138
+ * adjust the type for each conversation individually.
139
+ *
140
+ * Be sure to read [the documentation about the conversations
141
+ * plugin](https://grammy.dev/plugins/conversations) to learn more about how to
142
+ * use it.
143
+ *
144
+ * @param options Optional options for the conversations plugin
145
+ * @typeParam OC Custom context type of the outside middleware
146
+ * @typeParam C Custom context type used inside conversations
147
+ */
148
+ export function conversations(options = {}) {
149
+ const createStorage = uniformStorage(options.storage);
150
+ return async (ctx, next) => {
151
+ var _a, _b;
152
+ if (internalRecursionDetection in ctx) {
153
+ throw new Error("Cannot install the conversations plugin on context objects created by the conversations plugin!");
154
+ }
155
+ if (internalState in ctx) {
156
+ throw new Error("Cannot install conversations plugin twice!");
157
+ }
158
+ const storage = createStorage(ctx);
159
+ let read = false;
160
+ const state = (_a = await storage.read()) !== null && _a !== void 0 ? _a : {};
161
+ const empty = Object.keys(state).length === 0;
162
+ function getData() {
163
+ read = true;
164
+ return state; // will be mutated by conversations
165
+ }
166
+ const index = new Map();
167
+ async function enter(id, ...args) {
168
+ var _a;
169
+ const entry = index.get(id);
170
+ if (entry === undefined) {
171
+ const known = Array.from(index.keys())
172
+ .map((id) => `'${id}'`)
173
+ .join(", ");
174
+ throw new Error(`The conversation '${id}' has not been registered! Known conversations are: ${known}`);
175
+ }
176
+ const { builder, plugins, maxMillisecondsToWait } = entry;
177
+ await ((_a = options.onEnter) === null || _a === void 0 ? void 0 : _a.call(options, id, ctx));
178
+ const base = {
179
+ update: ctx.update,
180
+ api: ctx.api,
181
+ me: ctx.me,
182
+ };
183
+ return await enterConversation(builder, base, {
184
+ args,
185
+ ctx,
186
+ plugins,
187
+ maxMillisecondsToWait,
188
+ });
189
+ }
190
+ const exit = options.onExit !== undefined
191
+ ? async (name) => {
192
+ var _a;
193
+ await ((_a = options.onExit) === null || _a === void 0 ? void 0 : _a.call(options, name, ctx));
194
+ }
195
+ : undefined;
196
+ function isParallel(name) {
197
+ var _a, _b;
198
+ return (_b = (_a = index.get(name)) === null || _a === void 0 ? void 0 : _a.parallel) !== null && _b !== void 0 ? _b : true;
199
+ }
200
+ function canSave() {
201
+ return !(internalCompletenessMarker in ctx);
202
+ }
203
+ const internal = {
204
+ getMutableData: getData,
205
+ index,
206
+ defaultPlugins: (_b = options.plugins) !== null && _b !== void 0 ? _b : [],
207
+ exitHandler: exit,
208
+ };
209
+ Object.defineProperty(ctx, internalState, { value: internal });
210
+ ctx.conversation = controls(getData, isParallel, enter, exit, canSave);
211
+ try {
212
+ await next();
213
+ }
214
+ finally {
215
+ Object.defineProperty(ctx, internalCompletenessMarker, {
216
+ value: true,
217
+ });
218
+ if (read) {
219
+ // In case of bad usage of async/await, it is possible that
220
+ // `next` resolves while an enter call is still running. It then
221
+ // may not have cleaned up its data, leaving behind empty arrays
222
+ // on the state. Instead of delegating the cleanup
223
+ // responsibility to enter calls which are unable to do this
224
+ // reliably, we purge empty arrays ourselves before persisting
225
+ // the state. That way, we don't store useless data even when
226
+ // bot developers mess up.
227
+ const keys = Object.keys(state);
228
+ const len = keys.length;
229
+ let del = 0;
230
+ for (let i = 0; i < len; i++) {
231
+ const key = keys[i];
232
+ if (state[key].length === 0) {
233
+ delete state[key];
234
+ del++;
235
+ }
236
+ }
237
+ if (len !== del) { // len - del > 0
238
+ await storage.write(state);
239
+ }
240
+ else if (!empty) {
241
+ await storage.delete();
242
+ }
243
+ }
244
+ }
245
+ };
246
+ }
247
+ /**
248
+ * Takes a {@link ConversationBuilder | conversation builder function}, and
249
+ * turns it into middleware that can be installed on your bot. This middleware
250
+ * registers the conversation on the context object. Downstream handlers can
251
+ * then enter the conversation using `ctx.conversation.enter`.
252
+ *
253
+ * When an update reaches this middleware and the given conversation is
254
+ * currently active, then it will receive the update and process it. This
255
+ * advances the conversation.
256
+ *
257
+ * If the conversation is marked as parallel, downstream middleware will be
258
+ * called if this conversation decides to skip the update.
259
+ *
260
+ * You can pass a second parameter of type string to this function in order to
261
+ * give a different identifier to the conversation. By default, [the name of the
262
+ * function](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Function/name)
263
+ * is used.
264
+ *
265
+ * ```ts
266
+ * bot.use(createConversation(example, "new-name"))
267
+ * ```
268
+ *
269
+ * Optionally, instead of passing an identifier string as a second argument, you
270
+ * can pass an options object. It lets you configure the conversation. For example, this is how you can mark a conversation as parallel.
271
+ *
272
+ * ```ts
273
+ * bot.use(createConversation(example, {
274
+ * id: "new-name",
275
+ * parallel: true,
276
+ * }))
277
+ * ```
278
+ *
279
+ * Note that this function takes two different type parameters. The first type
280
+ * parameter should corresopnd with the context type of the outside middleware
281
+ * tree. The second type parameter should correspond with the custom context
282
+ * type used inside the given conversation. These two custom context types can
283
+ * never be identical because the outside middleware must have
284
+ * {@link ConversationFlavor} installed, but the custom context type used in the
285
+ * conversation must never have this type installed.
286
+ *
287
+ * @param builder A conversation builder function
288
+ * @param options A different name for the conversation, or an options object
289
+ * @typeParam OC Custom context type of the outside middleware
290
+ * @typeParam C Custom context type used inside this conversation
291
+ */
292
+ export function createConversation(builder, options) {
293
+ const { id = builder.name, plugins = [], maxMillisecondsToWait = undefined, parallel = false, } = typeof options === "string" ? { id: options } : options !== null && options !== void 0 ? options : {};
294
+ if (!id) {
295
+ throw new Error("Cannot register a conversation without a name!");
296
+ }
297
+ return async (ctx, next) => {
298
+ if (!(internalState in ctx)) {
299
+ throw new Error("Cannot register a conversation without installing the conversations plugin first!");
300
+ }
301
+ const { index, defaultPlugins, getMutableData, exitHandler } = ctx[internalState];
302
+ if (index.has(id)) {
303
+ throw new Error(`Duplicate conversation identifier '${id}'!`);
304
+ }
305
+ const combinedPlugins = [...defaultPlugins, ...plugins];
306
+ index.set(id, {
307
+ builder,
308
+ plugins: combinedPlugins,
309
+ maxMillisecondsToWait,
310
+ parallel,
311
+ });
312
+ const onHalt = async () => {
313
+ await (exitHandler === null || exitHandler === void 0 ? void 0 : exitHandler(id));
314
+ };
315
+ const mutableData = getMutableData();
316
+ const base = {
317
+ update: ctx.update,
318
+ api: ctx.api,
319
+ me: ctx.me,
320
+ };
321
+ const options = {
322
+ ctx,
323
+ plugins: combinedPlugins,
324
+ onHalt,
325
+ maxMillisecondsToWait,
326
+ parallel,
327
+ };
328
+ const result = await runParallelConversations(builder, base, id, mutableData, // will be mutated on ctx
329
+ options);
330
+ switch (result.status) {
331
+ case "complete":
332
+ case "skipped":
333
+ if (result.next)
334
+ await next();
335
+ return;
336
+ case "error":
337
+ throw result.error;
338
+ case "handled":
339
+ return;
340
+ }
341
+ };
342
+ }
343
+ /**
344
+ * Takes a conversation builder function and some state and runs all parallel
345
+ * instances of it until a conversation result was produced.
346
+ *
347
+ * This is used internally to run a conversation, but bots typically don't have
348
+ * to call this method.
349
+ *
350
+ * @param builder A conversation builder function
351
+ * @param base Context base data containing the incoming update
352
+ * @param id The identifier of the conversation
353
+ * @param data The state of execution of all parallel conversations
354
+ * @param options Additional configuration options
355
+ * @typeParam OC Custom context type of the outside middleware
356
+ * @typeParam C Custom context type used inside this conversation
357
+ */
358
+ export async function runParallelConversations(builder, base, id, data, options) {
359
+ if (!(id in data))
360
+ return { status: "skipped", next: true };
361
+ const states = data[id];
362
+ const len = states.length;
363
+ for (let i = 0; i < len; i++) {
364
+ const state = states[i];
365
+ const result = await resumeConversation(builder, base, state, options);
366
+ switch (result.status) {
367
+ case "skipped":
368
+ if (result.next)
369
+ continue;
370
+ else
371
+ return { status: "skipped", next: false };
372
+ case "handled":
373
+ states[i].replay = result.replay;
374
+ states[i].interrupts = result.interrupts;
375
+ return result;
376
+ case "complete":
377
+ states.splice(i, 1);
378
+ if (states.length === 0)
379
+ delete data[id];
380
+ if (result.next)
381
+ continue;
382
+ else
383
+ return result;
384
+ case "error":
385
+ states.splice(i, 1);
386
+ if (states.length === 0)
387
+ delete data[id];
388
+ return result;
389
+ }
390
+ }
391
+ return { status: "skipped", next: true };
392
+ }
393
+ /**
394
+ * Begins a new execution of a conversation builder function from scratch until
395
+ * a result was produced.
396
+ *
397
+ * This is used internally to enter a conversation, but bots typically don't have
398
+ * to call this method.
399
+ *
400
+ * @param conversation A conversation builder function
401
+ * @param base Context base data containing the incoming update
402
+ * @param options Additional configuration options
403
+ * @typeParam OC Custom context type of the outside middleware
404
+ * @typeParam C Custom context type used inside this conversation
405
+ */
406
+ export async function enterConversation(conversation, base, options) {
407
+ const { args = [], ...opts } = options !== null && options !== void 0 ? options : {};
408
+ const [initialState, int] = ReplayEngine.open("wait");
409
+ const packedArgs = args.length === 0 ? {} : { args: JSON.stringify(args) };
410
+ const state = {
411
+ ...packedArgs,
412
+ replay: initialState,
413
+ interrupts: [int],
414
+ };
415
+ const result = await resumeConversation(conversation, base, state, opts);
416
+ switch (result.status) {
417
+ case "complete":
418
+ case "error":
419
+ return result;
420
+ case "handled":
421
+ return { ...packedArgs, ...result };
422
+ case "skipped":
423
+ return {
424
+ ...packedArgs,
425
+ replay: initialState,
426
+ interrupts: state.interrupts,
427
+ ...result,
428
+ };
429
+ }
430
+ }
431
+ /**
432
+ * Resumes an execution of a conversation builder function until a result was
433
+ * produced.
434
+ *
435
+ * This is used internally to resume a conversation, but bots typically don't
436
+ * have to call this method.
437
+ *
438
+ * @param conversation A conversation builder function
439
+ * @param base Context base data containing the incoming update
440
+ * @param state Previous state of the conversation
441
+ * @param options Additional configuration options
442
+ * @typeParam OC Custom context type of the outside middleware
443
+ * @typeParam C Custom context type used inside this conversation
444
+ */
445
+ export async function resumeConversation(conversation, base, state, options) {
446
+ const { update, api, me } = base;
447
+ const args = state.args === undefined ? [] : JSON.parse(state.args);
448
+ const { ctx = youTouchYouDie("The conversation was advanced from an event so there is no access to an outside context object"), plugins = [], onHalt, maxMillisecondsToWait, parallel, } = options !== null && options !== void 0 ? options : {};
449
+ const middleware = new Composer(...plugins).middleware();
450
+ // deno-lint-ignore no-explicit-any
451
+ const escape = (fn) => fn(ctx);
452
+ const engine = new ReplayEngine(async (controls) => {
453
+ const hydrate = hydrateContext(controls, api, me);
454
+ const convo = new Conversation(controls, hydrate, escape, middleware, {
455
+ onHalt,
456
+ maxMillisecondsToWait,
457
+ parallel,
458
+ });
459
+ const ctx = await convo.wait({ maxMilliseconds: undefined });
460
+ await conversation(convo, ctx, ...args);
461
+ });
462
+ const replayState = state.replay;
463
+ // The last execution may have completed with a number of interrupts
464
+ // (parallel wait calls, floating promises basically). We replay the
465
+ // conversation once for each of these interrupts until one of them does not
466
+ // skip the update (actually handles it in a meaningful way).
467
+ const ints = state.interrupts;
468
+ const len = ints.length;
469
+ let next = true;
470
+ INTERRUPTS: for (let i = 0; i < len; i++) {
471
+ const int = ints[i];
472
+ const checkpoint = ReplayEngine.supply(replayState, int, update);
473
+ let rewind;
474
+ do {
475
+ rewind = false;
476
+ const result = await engine.replay(replayState);
477
+ switch (result.type) {
478
+ case "returned":
479
+ // tell caller that we are done, all good
480
+ return { status: "complete", next: false };
481
+ case "thrown":
482
+ // tell caller that an error was thrown, it should leave the
483
+ // conversation and rethrow the error
484
+ return { status: "error", error: result.error };
485
+ case "interrupted":
486
+ // tell caller that we handled the update and updated the
487
+ // state accordingly
488
+ return {
489
+ status: "handled",
490
+ replay: result.state,
491
+ interrupts: result.interrupts,
492
+ };
493
+ // TODO: disable lint until the following issue is fixed:
494
+ // https://github.com/denoland/deno_lint/issues/1331
495
+ // deno-lint-ignore no-fallthrough
496
+ case "canceled":
497
+ // check the type of interrupt by inspecting its message
498
+ if (Array.isArray(result.message)) {
499
+ const c = result.message;
500
+ ReplayEngine.reset(replayState, c);
501
+ rewind = true;
502
+ break;
503
+ }
504
+ switch (result.message) {
505
+ case "skip":
506
+ // current interrupt was skipped, replay again with
507
+ // the next interrupt from the list
508
+ ReplayEngine.reset(replayState, checkpoint);
509
+ next = true;
510
+ continue INTERRUPTS;
511
+ case "drop":
512
+ // current interrupt was skipped, replay again with
513
+ // the next and if this was the last iteration of
514
+ // the loop, then tell the caller that downstream
515
+ // middleware must be called
516
+ ReplayEngine.reset(replayState, checkpoint);
517
+ next = false;
518
+ continue INTERRUPTS;
519
+ case "halt":
520
+ // tell caller that we are done, all good
521
+ return { status: "complete", next: false };
522
+ case "kill":
523
+ // tell the called that we are done and that
524
+ // downstream middleware must be called
525
+ return { status: "complete", next: true };
526
+ default:
527
+ throw new Error("invalid cancel message received"); // cannot happen
528
+ }
529
+ default:
530
+ // cannot happen
531
+ throw new Error("engine returned invalid replay result type");
532
+ }
533
+ } while (rewind);
534
+ }
535
+ // tell caller that we want to skip the update and did not modify the state
536
+ return { status: "skipped", next };
537
+ }
538
+ function hydrateContext(controls, protoApi, me) {
539
+ return (update) => {
540
+ const api = new Api(protoApi.token, protoApi.options);
541
+ api.config.use(async (prev, method, payload, signal) => {
542
+ // Prepare values before storing them
543
+ async function action() {
544
+ try {
545
+ const res = await prev(method, payload, signal);
546
+ return { ok: true, res }; // directly return successful responses
547
+ }
548
+ catch (e) {
549
+ if (e instanceof HttpError) { // dismantle HttpError instances
550
+ return {
551
+ ok: false,
552
+ err: {
553
+ message: e.message,
554
+ error: JSON.stringify(e.error),
555
+ },
556
+ };
557
+ }
558
+ else {
559
+ throw new Error(`Unknown error thrown in conversation while calling '${method}'`,
560
+ // @ts-ignore not available on old Node versions
561
+ { cause: e });
562
+ }
563
+ }
564
+ }
565
+ const ret = await controls.action(action, method);
566
+ // Recover values after loading them
567
+ if (ret.ok) {
568
+ return ret.res;
569
+ }
570
+ else {
571
+ throw new HttpError("Error inside conversation: " + ret.err.message, new Error(JSON.parse(ret.err.error)));
572
+ }
573
+ });
574
+ const ctx = new Context(update, api, me);
575
+ Object.defineProperty(ctx, internalRecursionDetection, { value: true });
576
+ return ctx;
577
+ };
578
+ }
@@ -0,0 +1,43 @@
1
+ /**
2
+ * A resolver wraps a promise so that it can be resolved by an outside event. It
3
+ * is a container for two things:
4
+ *
5
+ * - a promise which you can `await`
6
+ * - a function `resolve` which you can call
7
+ *
8
+ * Once you call `resolve`, the contained promise will resolve.
9
+ *
10
+ * The status flag `isResolved` indicates if `resolve` has been called or not.
11
+ *
12
+ * @typeParam T The type of value to which the promise resolves
13
+ */
14
+ export interface Resolver<T> {
15
+ /** The promise which can be resolved by calling `resolve` */
16
+ promise: Promise<T>;
17
+ /** Value of the promise, if is it resolved, and undefined otherwise */
18
+ value?: T;
19
+ /**
20
+ * Resolves the promise of this resolver.
21
+ *
22
+ * Does nothing if called repeatedly.
23
+ */
24
+ resolve(t?: T): void;
25
+ /**
26
+ * A flag indicating whether `resolve` has been called, i.e. whether the
27
+ * promise has been resolved. Returns `false` until `resolve` is called, and
28
+ * returns `true` afterwards.
29
+ */
30
+ isResolved(): this is {
31
+ value: T;
32
+ };
33
+ }
34
+ /**
35
+ * Creates a new resolver.
36
+ *
37
+ * Optionally accepts a value to which the promise will resolve when `resolve`
38
+ * is called without arguments. Note that the value will be discarded if
39
+ * `resolve` is called with an argument.
40
+ *
41
+ * @param value An optional default to resolve to when calling `resolve`
42
+ */
43
+ export declare function resolver<T>(value?: T): Resolver<T>;
package/out/resolve.js ADDED
@@ -0,0 +1,21 @@
1
+ /**
2
+ * Creates a new resolver.
3
+ *
4
+ * Optionally accepts a value to which the promise will resolve when `resolve`
5
+ * is called without arguments. Note that the value will be discarded if
6
+ * `resolve` is called with an argument.
7
+ *
8
+ * @param value An optional default to resolve to when calling `resolve`
9
+ */
10
+ export function resolver(value) {
11
+ const rsr = { value, isResolved: () => false };
12
+ rsr.promise = new Promise((resolve) => {
13
+ rsr.resolve = (t = value) => {
14
+ rsr.isResolved = () => true;
15
+ rsr.value = t;
16
+ resolve(t); // cast to handle void
17
+ rsr.resolve = () => { }; // double resolve is no-op
18
+ };
19
+ });
20
+ return rsr;
21
+ }