Core

Async and promises

A plugin waits the way JavaScript does: async functions, await, Promise. While one function waits - for a web request, for a few seconds - the server and the rest of the plugin go on.

server
.
addCommand
("/weather", async ({
player
}) => {
print
(
player
, "asking...");
const
response
= await
fetch
("https://wttr.in/?format=3");
if (!
response
.
ok
) return;
print
(
player
, await
response
.
text
());
}); async function countdown(
player
: Player) {
for (let
i
= 3;
i
> 0;
i
--) {
print
(
player
, `${
i
}`);
await
sleep
(1000);
}
print
(
player
, "Go!");
}

async and await

  • An async function returns a Promise at once and runs up to its first await. What it returns is what the promise is fulfilled with: async function count() { return 3; } gives a Promise<number>.
  • A call without await does not wait: countdown(player) runs on its own, and the line after the call runs straight away.
  • await promise gives the promise's value once there is one. The rest of the function runs later - on the frame the value comes in - while the server gets on with everything else. Even a promise that has settled already lets the running code finish first, as in JavaScript.
  • await works in async functions, arrows and methods.
  • An async arrow uses the variables around it before and after an await, as any closure does.

sleep

await 
sleep
(1000); // one second
await
sleep
(5000, {
signal
: AbortSignal.timeout(2000) }); // rejected after 2 s

A promise fulfilled after that many milliseconds - setTimeout for an async function. Like every AMX Mod X timer, it waits at least 0.1 second and fires on a server frame: sleep(10) takes about 100 ms.

Async listeners and commands

An async function goes wherever a listener goes: server.addCommand, server.addEventListener, game.addEventListener, Forward.subscribe and a cvar's "change". What it tells the game counts only before its first await: the answer a game listener returns, event.preventDefault(). Once it waits, the game has moved on; the function simply goes on later.

game
.
addEventListener
("fallDamage", async (
event
) => {
if (
event
.
player
.
isBot
) return 0; // counts: no fall damage for bots
await
sleep
(100);
return 0; // too late - the damage is done });

Promise

The global Promise works as in JavaScript: then, catch, finally, Promise.resolve, Promise.reject, Promise.all, Promise.allSettled, Promise.race and Promise.any.

async function compare(
a
: string,
b
: string) {
const [
first
,
second
] = await
Promise
.
all
([
fetch
(
a
),
fetch
(
b
)]);
console
.
log
(`${
first
.
status
} ${
second
.
status
}`);
}
fetch
("https://example.com/")
.
then
((
response
) =>
console
.
log
(`${
response
.
status
}`))
.
catch
((
error
) =>
console
.
error
(
error
.message));

A .then callback returns a plain value. To follow one request with another, await them one after the other.

Making a promise

new Promise((resolve, reject) => ...) turns something that calls back into a promise. The function given to it runs at once, and what it sets up calls resolve or reject later:

function delay(
ms
: number) {
return new
Promise
<void>((
resolve
) => {
setTimeout
(() =>
resolve
(),
ms
);
}); }

Where the answer comes from somewhere else - a menu the player picks from, a chat line - keep resolve until then:

let 
answer
: ((
value
: string) => void) | null = null;
function askName(
player
: Player) {
print
(
player
, "Type your clan name in chat");
return new
Promise
<string>((
resolve
) => {
answer
=
resolve
;
}); } function onChatLine(
text
: string) {
const
resolve
=
answer
;
if (
resolve
== null) return;
answer
= null;
resolve
(
text
);
}

Promise.all and allSettled

Promises of different types give a tuple - each value with its own type - as in TypeScript:

const 
url
= "https://example.com/stats";
const [
response
,
_
] = await
Promise
.
all
([
fetch
(
url
),
sleep
(1000)]); // Response, void
const [
count
,
name
] = await
Promise
.
all
([countAsync(), nameAsync()]); // number, string
const [
page
,
rank
] = await
Promise
.
allSettled
([
fetch
(a), rankAsync()]);
if (
page
.
status
== "fulfilled")
print
(
player
, await
page
.
value
.
text
());
if (
rank
.
status
== "rejected")
console
.
error
(
rank
.
reason
.message);
  • Promises of one type, or a list in a variable, give an array: (await Promise.all(list)).length.
  • A tuple is read by destructuring, or by an index written as a number: results[0]. An index past its end is an error, as in TypeScript. for...of, .map and .length are for arrays.
  • One call takes up to 8 promises of different types.
  • A plain value is taken as a promise settled with it: Promise.all([load(), 5]), and Promise.all(numbers) of a number[].
  • allSettled is fulfilled once every promise has settled, either way. Each result has a status: "fulfilled" with value, or "rejected" with reason, an Error.

Promise.race and any

race settles like the first promise to settle. any takes the first value and passes rejections by; when every promise is rejected, it rejects with an AggregateError whose errors hold each reason.

const 
url
= "https://example.com/stats";
const
response
= await
Promise
.
race
([
fetch
(
url
),
sleep
(3000)]); // Response | null
if (!
response
) return; // the sleep won
print
(
player
, await
response
.
text
());
const
fastest
= await
Promise
.
any
([
fetch
(mirrorA),
fetch
(mirrorB)]);

The result has one type, so the promises need one:

  • the same type - the result is of that type;
  • a Promise<void> (a sleep, an async function that returns nothing) beside an object or a string - the result is that type or null: check it with if (!response);
  • classes with a common base class - the result is of that class (Promise.race([cat(), dog()]) is an Animal).

Anything else - a number and a string, a number and a sleep - does not build: Promise.race of 'number' and 'string': their values share no type. Await them one by one instead.

Errors

An error in an async function - a throw, or one from what it calls, an index out of range included - rejects its promise with that error, as in JavaScript. A rejected promise carries the error:

  • try/catch around the await gets it:
    const 
    url
    = "https://example.com/stats";
    try { const
    response
    = await
    fetch
    (
    url
    );
    print
    (
    player
    , await
    response
    .
    text
    ());
    } catch (
    error
    ) {
    console
    .
    log
    (`the request failed: ${
    error
    .message}`);
    }
  • .catch gets it.
  • await of a rejected promise outside a try ends the awaiting function there, and that function's own promise is rejected with the same error - up to whoever catches it.
  • Nobody handling it prints one line in the server console: Unhandled promise rejection: <message>. A cancellation - an error named AbortError or TimeoutError - is expected and is not reported.

Outside an async function, reject with reject(new Error("message")) in a new Promise, or Promise.reject(new Error("message")). error.message and error.name read it back.

A crash that is not an error - a stack overflow - ends the async function: the console names the plugin and the crash, and the plugin and the server run on. Whoever awaits it gets an error named AbortError with the message the async function crashed.

Cancelling: AbortController and AbortSignal

As in the browser:

const 
url
= "https://example.com/stats";
const
controller
= new AbortController();
fetch
(
url
, {
signal
:
controller
.signal }).
catch
((
error
) =>
console
.
log
(
error
.name)); // "AbortError"
controller
.abort();
controller
.signal.aborted; // true
controller
.signal.reason; // the Error it was aborted with
controller
.signal.addEventListener("abort", () =>
console
.
log
("aborted"));
await
fetch
(
url
, {
signal
: AbortSignal.timeout(3000) }); // "TimeoutError" after 3 s
  • fetch(url, { signal }) cancels the request and rejects with the signal's reason - an Error named AbortError, unless abort() was given another.
  • sleep(ms, { signal }) stops waiting and rejects the same way.
  • AbortSignal.timeout(ms) aborts by itself, with an Error named TimeoutError; AbortSignal.abort() is aborted already; AbortSignal.any([a, b]) aborts with whichever aborts first.

When the player leaves

player.signal aborts when that player leaves the server; the next player in the slot gets a new one:

const 
url
= "https://example.com/stats";
const
stats
= await
fetch
(
url
, {
signal
:
player
.
signal
});

An async command handler, or a listener of an event about a player (putInServer, connect, authorized and the others with an event.player), does not even need that: it runs under that player's signal. Every await in it - a request, a sleep, any promise - ends quietly when the player leaves, with nothing in the console and nothing to clean up:

server
.
addCommand
("/rank", async ({
player
}) => {
const
rank
= await
fetch
(`https://example.com/rank/${
player
.
steamId
}`); // cancelled if he leaves
print
(
player
, await
rank
.
text
()); // never runs then
});

Async functions called from such a handler run under the same signal, and a signal passed by hand works alongside it: whichever aborts first. Game event listeners and the events of a player leaving do not get one.

After an await

A function that awaited resumes on a later frame, and much may have changed: the round ended, the player died - or left. Under a player's signal his leaving ends the function for you; anywhere else, check player.isConnected after an await before you use the player again.

Limits

  • Async generators (async function*) do not build; for await walks a list of promises or values.
  • An async function keeps up to 4 KB of its own state while it waits; one that needs more is stopped, with a message in the console naming the plugin.
  • Code that can reach an await runs somewhat slower in a tight loop. A plugin without async functions pays nothing.

What else amxts cannot do yet is on Limitations.