Search docs
Search guides, modules and symbols
Async & networking

Async & networking

s2script integrates Promises with the game frame. Timers and network I/O never block the main thread. Native work has finite admission: Promise APIs reject with named capacity errors, and socket sends return a boolean.

Timers#

import { delay, nextTick, nextFrame, threadSleep, after, every } from '@s2script/sdk/timers';

await nextTick(); // after current microtasks
await nextFrame(); // next game frame
await delay(1000); // ~1s, frame-integrated
await threadSleep(50); // worker-thread sleep; settles on a later game frame

delay, nextTick, nextFrame, and threadSleep reject on overload. after and every throw AsyncQueueFull instead of returning a timer. Existing timer cancellation is unchanged. threadSleep returns Promise<void> and settles on a later game frame.

HTTP#

import { fetch } from '@s2script/sdk/http';

const res = await fetch('https://example.com/api', {
	method: 'POST',
	headers: { 'content-type': 'application/json' },
	body: JSON.stringify({ steamId }),
	timeoutMs: 10_000
});

if (res.ok) {
	const data = await res.json();
}

HTTP status errors (4xx/5xx) resolve with ok: false (web-fetch parity — useful on the join path). Network failures and timeouts reject. Admission rejects with AsyncQueueFull or AsyncPayloadTooLarge before the request is accepted. A response whose UTF-8 body plus retained headers exceeds the configured http_body_bytes limit (default 10 MiB) rejects with HttpResponseTooLarge.

WebSocket#

import { WebSocket } from '@s2script/sdk/ws';

const ws = await WebSocket.connect('wss://example.com/ws');
ws.onMessage((text) => console.log(text));
ws.onClose((code) => console.log('closed', code));
if (!ws.send('hello')) {
	// closed, oversized, or admission full — nothing queued
}
ws.close();

Subscribe to onMessage synchronously after await connect so early frames are not missed. Connections are owner-scoped and closed on plugin unload.

WebSocket.send returns boolean. false means closed, oversized, or full admission — nothing was accepted; retry later or close. true means queued, not delivered. Existing callers may ignore the return; structural mocks that returned void must return boolean. Host apiVersion 2 remains compatible.

TCP / UDP#

import { Net } from '@s2script/sdk/net';

const sock = await Net.connectTcp('127.0.0.1', 9000);
sock.onData((bytes) => console.log(bytes.length));
if (!sock.send('hello')) {
	// closed, oversized, or admission full — nothing queued
}

const udp = await Net.udp();
if (!udp.sendTo('127.0.0.1', 9000, 'ping')) {
	// same contract
}

TcpSocket.send and UdpSocket.sendTo use the same boolean admission as WebSocket.send.

Database#

import { Database } from '@s2script/sdk/db';

const db = await Database.open('myplugin');
await db.execute('CREATE TABLE IF NOT EXISTS boots (id INTEGER PRIMARY KEY)');
const rows = await db.query('SELECT * FROM boots');
await db.close();

The API is Promise-based (SQLite today; MySQL/Postgres via the same Database seam). Handles are opaque, owner-scoped, and ledgered.

query / execute reject with AsyncQueueFull or AsyncPayloadTooLarge before acceptance. A result that exceeds db_result_rows / db_result_bytes rejects with DatabaseResultTooLarge.

Named errors#

Promise APIs reject with an Error whose .name is one of:

NameWhen
AsyncQueueFullA count or queue slot is exhausted
AsyncPayloadTooLargeAn input exceeds the configured item/aggregate byte limit
AsyncCancelledThe producer was cancelled before completion
HttpResponseTooLargeHTTP body + retained headers exceed http_body_bytes
DatabaseResultTooLargeQuery rows/bytes exceed db_result_rows / db_result_bytes

Check err.name, not a message substring.

Frame budgets#

Each game frame drains a bounded batch (frame_items, frame_bytes, frame_poll_items, frame_soft_us). Promise and timer work share that budget with cookie and socket callbacks. The elapsed-time check is soft and runs between units: one JavaScript callback, V8 materialization, or microtask checkpoint can exceed it. One oversized delivery can run alone so work still makes progress.

These are not a whole-process RSS bound. Counts and bytes cover application-owned retention only — allocator overhead, SQLite page caches, protocol/TLS/kernel buffers, and V8 objects retained by JavaScript sit outside the partitions.

Operator configuration

Set S2SCRIPT_ASYNC_LIMITS_JSON on the game process before start. It accepts a JSON object with any subset of the fields below. Unknown fields, zero values, impossible relations, or overflow invalidate the entire override, print a warning, and use defaults. The policy is read once per process — it survives plugin unload and V8 reinitialization. Example: {"frame_items":64,"frame_bytes":1048576}.

Byte units in the table are binary KiB/MiB; the JSON itself uses integers in bytes. These are starting defaults, not measured production bandwidth targets. Tune from rejection and retained-byte metrics during soak. Do not change policy in place while producers from an earlier configuration are still alive.

FieldsDefaultsScope
jobs_global, jobs_per_owner512, 64Admitted producers and retained completions; includes live sockets
input_bytes, owner_input_bytes, input_item_bytes32 MiB, 8 MiB, 8 MiBJob input aggregate, owner aggregate, single input
completion_bytes, failure_bytes16 MiB, 4096Aggregate result reservations; reserved failure allowance per job
sockets_global, sockets_per_owner128, 16Actual socket lifetimes, including shutdown in progress
sqlite_global, sqlite_per_owner32, 4Actual SQLite actor lifetimes
pools_global, pools_per_owner16, 4Actual remote pool lifetimes; each pool has at most four backend connections
socket_out_items, socket_out_bytes, outbound_bytes256, 1 MiB, 16 MiBPer-socket queued items/bytes (also per-send ceiling), global outbound bytes
inbound_items, inbound_bytes2048, 16 MiBAggregate socket data events retained by producers, queues, and dispatch
timers_global, timers_per_owner4096, 512Pending and selected timers
worker_queue_items, sqlite_queue_items256, 64Waiting jobs in the pool / each SQLite actor
http_body_bytes10 MiBFinal UTF-8 body plus retained response headers
db_result_rows, db_result_bytes10,000, 8 MiBRows and normalized columns/cells plus conservative row metadata
cookie_versions, cookie_bytes, cookie_write_bytes4096, 4 MiB, 64 KiBCookie outbox: retained versions, aggregate bytes, single write — Cookies
frame_items, frame_bytes, frame_poll_items, frame_soft_us256, 2 MiB, 256, 2000Logical deliveries, delivered bytes, source polls, soft elapsed-time stop

Plugin s2script.config is unrelated — that is the typed per-plugin overlay. See Config.

Diagnostics#

__s2_async_stats() is a native helper (no job). It returns a JSON string; parse it:

const stats = JSON.parse(__s2_async_stats());

Gauges are diagnostic and can change concurrently — not one transactional snapshot. Counters persist across isolate reinitialization. rejected counts failed partition acquisitions/growth; early argument-validation errors are not partition rejections.

jobs, completion, sockets, sqlite, pools, inbound, outbound, timers:
  { items: number, bytes: number, rejected: number }
queued: { worker, http, db, ws, net: number }
staged: { timers, ws, net, cookies, http, db: number }
cache: { accounts, entries, bytes: number }
frame: null | { items, bytes, polls: number }
timerExamined: number
lastNs: number
maxNs: number

queued vs staged is in-flight vs ready-to-deliver. Duration counters (lastNs, maxNs) cover the async drain through the callback phase.

Browse modules: timers, http, ws, net, db.