Nuvaka › Developer docs › Extension servers
Extension servers
An extension can ship server code in its package. The code starts as a server instance that runs continuously on a separate Nuvaka machine; users join these servers from inside the app. Since the server makes the decisions, you don't have to trust the client.
Version: arrives with Nuvaka 0.3.1 and SDK 1.3.0 in the developer kit; 1.3.1 is recommended (its mock server and the init --server template follow the join order below). Put "minAppVersion": "0.3.1" in your manifest.
Use cases:
- a game world that's online 24/7;
- a backend for a social app;
- a cheat-proof game where the server decides;
- a match server started by a user.
Code that hasn't passed review never runs. A server can't be a public website: players only come in with a Nuvaka account, from inside the app. For rooms without server code that only relay: Realtime rooms.
Quick start: a publisher (always-on) server
- Create the skeleton
nuvaka-ext init game --username your-username --server # or: npm create @nuvaka/extension -- --serverThe template creates
server/main.js(a server that counts clicks and stores them in SQLite), a UI that uses it and a test; it adds theserverblock (e.g.{ "entry": "server/main.js", "tickHz": 5, "maxPlayers": 100 }) and thenuvaka.realtimepermission to the manifest. The template requires SDK^1.3.0.--serveralso sets up the UI; it can't be combined with a data addon (--kind data). - Write the server code and try it locally
Write
nuvaka.on('start' | 'join' | 'message' | 'tick' | 'leave' | 'stop', …)handlers inserver/main.js(Server code). Test with the SDK's mock server:nuvaka-ext test(Testing). - Publish
nuvaka-ext publish --changelog "Server"Every version with
servergoes to review and is published once approved. Servers always run the code of the extension's published version. - Start the server
After approval, define the server. In the app: Profile › Developer › Servers › pick your extension › New server (details). Or via the API, with a user token or an
nvd_developer token:POST https://generalappapi.nuvaka.com/api/ext/v1/dev/extensions/<id>/servers Authorization: Bearer nvd_xxxxxxxx Content-Type: application/json { "name": "Main world", "visibility": "public", "maxPlayers": 100 } → { "success": true, "id": "<serverId>", "started": true, "error": null }If you get
started: false, the reason is inerror; logs are behind the Logs button in the app or atGET …/servers/<serverId>/logs. - The supervisor does the rest
From then on the server stays up: if it crashes or a new version is approved, the supervisor restarts it within 20 s. See its state with
GET …/servers(status,players,limits); change settings withPATCH …/servers/<id>, restart withPOST …/<id>/restart, remove withDELETE(Publisher API). - The app side
nuvaka.servers.list()fetches the list; join withjoin(serverId, password?), write withsend, listen withonMessage(Client API).
Manifest
"server": { "entry": "server/main.js", "tickHz": 5, "maxPlayers": 16, "memoryMB": 64, "userServers": true },
"permissions": [{ "nuvaka.realtime": true, "reason": { "tr": "Çok oyunculu", "en": "Multiplayer" } }]
| Field | Rule |
|---|---|
entry | required; a .js or .mjs file in the package (at most 200 characters). Can't be the UI file (entry.ui). Server code goes through the normal scan too (eval, new Function … are forbidden). |
tickHz | integer 0–20; 0 = no tick |
maxPlayers | integer 1–1000; default 16. No server's player limit can exceed it. |
memoryMB | integer 16–128; default 64. Memory limit per instance. |
userServers | true/false. With true, users can also start servers from the app; otherwise only the publisher's always-on servers run. |
- The
nuvaka.realtimepermission is required because players connect to the server over the realtime channel. - Unknown fields are rejected. A data addon can't have
server. - Every version with
servergoes to Nuvaka review (Review).
Server code
Server code runs as a classic script in isolated V8 (isolated-vm). There is no require, import/export, process, fetch, timers (setTimeout/setInterval), file system or network; your code must be a single file (bundle it if needed). It touches the world only through the global nuvaka object. nuvaka-ext lint warns if it sees any of these in server code.
Types are in the @nuvaka/extension-sdk/server module (types only; no runtime code):
/** @type {import('@nuvaka/extension-sdk/server').ServerNuvaka} */
const nv = globalThis.nuvaka
Events
Listen with nuvaka.on(event, fn). Each event has a single handler; a later on replaces the earlier one.
| Event | When | Time limit |
|---|---|---|
start() | as the server starts | 2 s |
join(player) | as a player joins; returning false or a string (reason) rejects, anything else accepts | 200 ms |
leave(player) | as a player leaves or their connection drops | 200 ms |
message(player, data) | when a player sends a message | 50 ms |
tick(dtMs) | tickHz times per second; dtMs is the time since the previous tick | 50 ms |
stop() | as the server shuts down | 1 s |
player = { username, displayName }. For a player with a private profile displayName is null; it may be missing in leave.
Join order
A player is added to the players() list before the join handler runs. So inside join, send(player.username, …) and broadcast also reach the joiner: send them the initial state right there. If the handler rejects, the player is removed from the list again.
nv.on('join', (player) => {
if (isBanned(player.username)) return 'You are banned from this server' // reject; the reason goes to the player
nv.send(player.username, { t: 'hello', world: snapshot() }) // reaches the joiner
nv.broadcast({ t: 'joined', username: player.username }, player.username)
return true
})
Tools
| Tool | Description |
|---|---|
send(username, data) | to one player; the player gets server.message. data ≤ 16 KB of JSON. |
broadcast(data, exceptUsername?) | to everyone (optionally except one); ≤ 16 KB |
kick(username, reason?) | kicks the player; the player gets server.kicked { reason } |
players() | connected players: [{ username, displayName }] |
setInfo(obj) | info shown in the server list (map, mode …); ≤ 1 KB of JSON |
log(...args) | goes to the publisher's log screen; last 200 lines, at most 2000 characters per line |
instance | { id, name, maxPlayers, extensionId, version } |
db | SQLite per extension (below) |
Errors and crashes
- A timeout or an error thrown in a handler doesn't bring the server down; the error is written to the log.
- If the memory limit (
memoryMB) is exceeded, or after 200 errors, the instance shuts down. A publisher server is restarted within 20 s. - Server code has no network access, but don't use data from players without validating it either: check every field in
message.
nuvaka.db: SQLite per extension
Each extension has its own SQLite database on the server side; all of the extension's server instances use the same database. Calls are synchronous.
| Method | Returns |
|---|---|
exec(sql) | null; multiple statements (schema, migrations) |
run(sql, ...params) | { changes, lastInsertRowid } |
get(sql, ...params) | the first row or null |
all(sql, ...params) | rows; at most 5000 |
- Parameters are passed with
?. Objects and arrays are stored as JSON text;undefinedbecomesNULL. ATTACH,DETACH,load_extension,VACUUM INTOand PRAGMAs are forbidden; onlytable_info,index_list,foreign_key_listanduser_versioncan be read.- Quota:
50 MB + installs / 10MB, at most 10 GB; an administrator can grant extra space (below). The database is for shared, small state; for large user data see below. - Deleting a server doesn't delete the database.
nv.on('start', () => {
nv.db.exec('CREATE TABLE IF NOT EXISTS wins (username TEXT PRIMARY KEY, wins INTEGER NOT NULL DEFAULT 0)')
})
nv.db.run('INSERT INTO wins (username, wins) VALUES (?, 1) ON CONFLICT (username) DO UPDATE SET wins = wins + 1', player.username)
const top = nv.db.all('SELECT username, wins FROM wins ORDER BY wins DESC LIMIT 10')
Large or personal data: the user's storage, not the database
The server database is for shared, small state: lobbies, leaderboards, matchmaking, room and list info. Large data that belongs to a user (files, photos, long history) doesn't go into the server database; it lives in the user's own Nuvaka storage:
- App space (
nuvaka.storage): the space reserved for your extension in each user's account; manifeststorage.quotaMBat most 100. No permission needed; syncs across devices. - My Files (
nuvaka.cloud): the folders the user opened to your extension, on the user's own quota. This is where large data goes. Writing needsnuvaka.cloud: write; this critical permission is confirmed on every use and every version requesting it goes to review. - Encryption: encrypt sensitive data on the client (WebCrypto, e.g. AES-GCM; the key comes from the user or your extension's local space) and upload with
encrypted: true. Nuvaka only sees encrypted bytes; files marked encrypted get no preview and the assistant doesn't read them. - Keep only references in the server database: which user's which file (
fileId). The large content doesn't live there.
// UI: encrypt with AES-GCM on the client, upload encrypted to My Files, send only the fileId to the server
async function saveEncrypted(folderId, name, bytes, key) { // key: a WebCrypto AES-GCM CryptoKey
const iv = crypto.getRandomValues(new Uint8Array(12))
const ct = new Uint8Array(await crypto.subtle.encrypt({ name: 'AES-GCM', iv }, key, bytes))
const blob = new Uint8Array(iv.length + ct.length)
blob.set(iv)
blob.set(ct, iv.length)
const { file } = await nuvaka.cloud.upload(folderId, name, blob, 'application/octet-stream', { encrypted: true })
await nuvaka.servers.send(serverId, { t: 'attach', fileId: file.fileId })
return file.fileId
}
async function loadEncrypted(fileId, key) {
const blob = await nuvaka.cloud.read(fileId) // Uint8Array
const plain = await crypto.subtle.decrypt({ name: 'AES-GCM', iv: blob.slice(0, 12) }, key, blob.slice(12))
return new Uint8Array(plain)
}
// server/main.js: store only the reference
nv.on('message', (player, data) => {
if (data?.t === 'attach' && typeof data.fileId === 'string' && data.fileId.length <= 100)
nv.db.run('INSERT INTO attachments (username, file_id) VALUES (?, ?)', player.username, data.fileId)
})
Server kinds
| Publisher server (always on) | User server | |
|---|---|---|
| Started by | The publisher: Profile › Developer › Servers in the app, or the API | A user, from inside the app (nuvaka.servers.create); needs userServers: true in the manifest |
| Lifetime | A supervisor keeps it up and restarts it after a crash; when a new approved version comes out it restarts it on the new version | Closes 30 min after nobody is connected, after 12 hours at most |
| Limit | 3 + installs/100, at most 20 | 2 at once per person; 100 + installs/2 at once per extension, at most 2000 |
| In the list | first; also shown while down (stopped/failed) | only while running |
Status (status)
| Value | Meaning |
|---|---|
running | running |
stopped | stopped; the supervisor restarts publisher servers |
failed | couldn't start or crashed; the reason is in lastError |
disabled | stopped by the publisher; the supervisor doesn't start it |
Show any other value as "pending".
Visibility
| Value | Who can join |
|---|---|
public | everyone using the app (default) |
friends | the server owner's friends |
password | everyone sees it; joining needs a password (4–100 characters; stored with bcrypt) |
invite | only friends the owner invited |
The server owner joins without being asked for a password. Changing the password: if the visibility is already password, PATCH { password } changes only the password; PATCH { visibility: "password", password } makes the server password-protected; switching to another visibility deletes the password.
Managing in the app: Profile › Developer › Servers
- Pick your extension; publisher servers and servers started by users are listed together (kind, status, player count, start time).
- New server: name (1–60 characters), visibility, password, max players (can't exceed
server.maxPlayers). It starts once created. - Edit: the same fields plus "Stopped" (the supervisor won't start it). Leave the password empty to keep it unchanged.
- Restart, Delete (connected players are removed; the database isn't deleted) and Logs (
log(...)output and caught errors, last 200 lines, kept in memory). - Quotas are shown at the top: always-on servers, open user servers, the per-person limit, the database quota. If the runner can't be reached, servers can't be started; try again a bit later.
Publisher API
With a user token or an nvd_ developer token, at /api/ext/v1/dev/extensions/{id}/servers:
| Endpoint | Description |
|---|---|
GET | { items: [{ id, kind, name, visibility, maxPlayers, version, status, lastError, info, players, createdAt, startedAt }], limits, runnerAvailable } |
POST { name, visibility?, password?, maxPlayers? } | { id, started, error }. Quota full: 429 quota; no server in the published version: 400 no_server_code. |
PATCH /{sid} { name?, visibility?, password?, maxPlayers?, disabled? } | edit; password rules above |
POST /{sid}/restart | restart |
DELETE /{sid} | delete |
GET /{sid}/logs | last 200 log lines |
Client API: nuvaka.servers
Requires the nuvaka.realtime permission. Private copies can't use it (not_for_copies); it's disabled in a secure session.
| Call | Where | Returns |
|---|---|---|
list() | both | servers you can join; the publisher's first, then by player count |
create({ name, visibility?, password?, maxPlayers? }) | both | { success, serverId }; starts a user server. Call join afterwards to enter. |
delete(serverId) | both | closes only a server you started; everyone connected gets server.closed (closed_by_owner) |
invite(serverId, username) | both | owner only, friends only |
join(serverId, password?) | UI | { joined, serverId, info, username }; username is your username |
send(serverId, data) | UI | null; the server receives it in message(player, data) |
leave(serverId) | UI | null; no effect if you haven't joined |
invited() | UI | the server id if opened from an invite link (ctx.server), otherwise null |
A list() item: { serverId, kind: 'publisher' | 'user', name, visibility, needsPassword, owner, mine, status, players, maxPlayers, info, createdAt }. needsPassword is true for a password-protected server that isn't yours; owner is null for publisher servers; info is what the server code set with setInfo. The list only contains servers you can join: a friends server is visible to the owner's friends, an invite server to invitees (the owner always sees it).
create rules: name 1–60 characters; visibility defaults to public; password only for password visibility, 4–100 characters; maxPlayers is clamped to the manifest's server.maxPlayers.
Events
| Event | Shortcut | Payload |
|---|---|---|
server.message | onMessage | { serverId, data } — from the server code's send/broadcast |
server.kicked | onKicked | { serverId, reason } |
server.closed | onClosed | { serverId, reason } — the owner closed it (closed_by_owner), the publisher deleted/stopped/restarted it, it crashed or sat idle, or rejoining failed after the connection came back (reason = the error code) |
server.invite | onInvite | { serverId } — a server invite was tapped while the app was open |
realtime.status | onStatus | { connected, reconnecting?, error? } — the connection shared with realtime rooms |
If the connection drops, the shell reconnects and rejoins the server automatically; if that fails you get server.closed { reason: <error code> }.
Invites and links
The server owner invites a friend with invite(serverId, username). The invitee gets an ext_server_invite notification in their own language; its link has the form /apps?id=<extension>&server=<serverId>. Tapping it opens your app with ctx.server in the context (nuvaka.servers.invited()); if the app is already open, onInvite fires. Only people invited this way can join an invite server.
Lobby pattern
Nuvaka doesn't show a built-in server picker: info (map, mode …) is specific to your app, and the lobby's look is part of your design. Draw the list, dim servers that aren't running, and ask for passwords in your own dialog.
Error codes
| Code | Meaning |
|---|---|
server_not_found | no such server |
server_not_running | the server isn't running |
friends_only | only the owner's friends can join |
bad_password | wrong or missing password (in create: password isn't 4–100 characters) |
invite_only | only invitees can join |
server_full | the server is full |
rejected | the server code's join handler rejected you; reason in e.data.reason |
server_unavailable | the server runner can't be reached |
not_joined | send without joining |
message_too_large | message is over 16 KB |
rate_limited | over 20 messages per second |
user_servers_disabled | (403) no userServers in the manifest |
too_many_servers | (429) at most 2 user servers at once per person |
extension_quota | (429) the extension's open user server quota is full |
start_failed | (502) the server couldn't start |
not_owner | only the server's owner can close it or invite |
not_friend | only friends can be invited |
bad_name, bad_visibility | name isn't 1–60 characters / invalid visibility |
not_for_copies, secure_session, ui_only | private copy / secure session / join, send, leave in the background |
Limits
| Limit | |
|---|---|
tickHz | 0–20 |
maxPlayers (players per server) | 1–1000 (default 16) |
memoryMB | 16–128 (default 64) |
| Message (both directions) | ≤ 16 KB of JSON |
| Player sends | 20 per second |
setInfo | ≤ 1 KB |
| Log | last 200 lines |
| Handler time | start 2 s, join/leave 200 ms, message/tick 50 ms, stop 1 s |
| Publisher servers | 3 + installs/100, at most 20 |
| User servers (per extension, at once) | 100 + installs/2, at most 2000 |
| User servers per person | 2 |
| Database | 50 MB + installs/10, at most 10 GB; all ≤ 5000 rows |
There are no small fixed caps; quotas grow with the install count. The real limit is the machine's resources, which the supervisor watches. Current values come back in the limits field of the publisher API's GET …/servers response (including the allowed maxPlayers).
More database space: a Nuvaka administrator can grant an extension extra server database space; running servers pick it up when they restart. If you need it, write to [email protected] with the extension id and your reason. Read the large data section below first: user data shouldn't go into the database in the first place.
Review
- Every version with
servergoes to Nuvaka review, even for verified publishers and non-first versions. Because server code runs on Nuvaka's machine, this rule isn't relaxed. - Until approval, servers stay on the old version. Once a new version is approved, the supervisor restarts publisher servers on it; connected players get
server.closed, and your client should go back to the lobby and rejoin.
Security and privacy
- Server code runs on a separate Nuvaka machine in an unprivileged service; each instance starts in its own isolate with its own memory limit.
- Traffic between the Nuvaka API and the runner is TLS-encrypted and every request is signed. The package is uploaded to the runner with digest verification.
- Players reach the server code with only their username and display name; email and user ids aren't given.
Testing: the mock nuvaka
@nuvaka/extension-sdk/testing simulates the Nuvaka API and the runner in memory; your server code really runs in-process. Visibility, the 2-per-person limit, capacity, rejected and send-rate rules apply.
| Tool | Description |
|---|---|
servers: { script, userServers, maxPlayers, tickHz, db, invitedServer } | option. script: (nv) => { … } is the server code; each instance runs it at start with a mock ServerNuvaka. db: a nuvaka.db implementation (without it calls throw unsupported). Username and friends come from the realtime option. |
m.serverHost.create({ name, kind?, owner?, visibility?, password?, maxPlayers?, script? }) | starts a server (default: a public publisher server); returns its id |
m.serverHost.join(id, username) | another player joins: true when accepted, the reason or false when rejected |
m.serverHost.send / leave / tick / stop / invite | another player's message, their leaving, one tick (default 1000 / tickHz ms), the server shutting down, inviting this user |
m.serverHost.server(id) | internals: players, info, logs, errors, outbox |
m.log.servers, m.log.serverInvites | what was sent to servers and invites |
Provide your own nuvaka.db implementation, e.g. a small wrapper over node:sqlite. Since server/main.js is a classic script that reads globalThis.nuvaka as it loads, collect its handlers through a proxy and attach them to each mock instance, as the init --server template does (see the example).
Full example: number guess
The server picks a number between 1 and 100; players guess and the server says "up/down". The first to guess it wins, the win is written to SQLite, and a round ends after 60 seconds. Only the server knows the number, so clients can't cheat.
nuvaka.json
{
"id": "your-username.guess",
"name": "Number guess",
"version": "1.0.0",
"apiVersion": 1,
"minAppVersion": "0.3.1",
"description": { "tr": "1–100 arası sayıyı ilk bilen kazanır", "en": "First to guess the number between 1 and 100 wins" },
"storeCategory": "games",
"icon": "icon.svg",
"entry": { "ui": "index.html" },
"pages": [{ "id": "main", "title": { "tr": "Sayı tahmini", "en": "Number guess" } }],
"permissions": [
{ "nuvaka.realtime": true, "reason": { "tr": "Oyun sunucusuna bağlanmak için", "en": "To connect to the game server" } }
],
"server": { "entry": "server/main.js", "tickHz": 1, "maxPlayers": 32, "memoryMB": 32, "userServers": true }
}
server/main.js
// Number guess: the server decides. Classic script; no import/require, fetch or timers.
/** @type {import('@nuvaka/extension-sdk/server').ServerNuvaka} */
const nv = globalThis.nuvaka
const ROUND_MS = 60_000
let secret = 0
let left = ROUND_MS
let round = 0
function newRound() {
secret = 1 + Math.floor(Math.random() * 100)
left = ROUND_MS
round++
nv.setInfo({ round }) // shown in the server list
nv.broadcast({ t: 'round', round, seconds: ROUND_MS / 1000 })
}
const top = () => nv.db.all('SELECT username, wins FROM wins ORDER BY wins DESC LIMIT 10')
nv.on('start', () => {
nv.db.exec(`
CREATE TABLE IF NOT EXISTS wins (username TEXT PRIMARY KEY, wins INTEGER NOT NULL DEFAULT 0);
CREATE TABLE IF NOT EXISTS bans (username TEXT PRIMARY KEY);
`)
newRound()
nv.log('started:', nv.instance.name, nv.instance.version)
})
nv.on('join', (player) => {
if (nv.db.get('SELECT 1 AS x FROM bans WHERE username = ?', player.username)) return 'You are banned from this server'
// The player is in the list before this handler runs: send the initial state straight to them
nv.send(player.username, { t: 'hello', round, secondsLeft: Math.ceil(left / 1000), top: top() })
nv.broadcast({ t: 'joined', username: player.username }, player.username)
return true
})
nv.on('message', (player, data) => {
if (!data || data.t !== 'guess' || !Number.isInteger(data.n) || data.n < 1 || data.n > 100) return
if (data.n !== secret) return nv.send(player.username, { t: 'hint', n: data.n, hint: data.n < secret ? 'up' : 'down' })
nv.db.run('INSERT INTO wins (username, wins) VALUES (?, 1) ON CONFLICT (username) DO UPDATE SET wins = wins + 1', player.username)
nv.broadcast({ t: 'won', username: player.username, n: secret, top: top() })
newRound()
})
nv.on('tick', (dtMs) => {
left -= dtMs
if (left > 0) return
nv.broadcast({ t: 'timeout', n: secret })
newRound()
})
nv.on('leave', (player) => nv.broadcast({ t: 'left', username: player.username }))
nv.on('stop', () => nv.log('stopping; round', round))
app.js (lobby and game)
UI functions such as showLobby, showGame, showHint, showTop, askPassword and toast are yours.
let joined = null
async function lobby(message) {
joined = null
const servers = await nuvaka.servers.list() // the publisher's first
showLobby(message, servers.map((s) => ({
id: s.serverId,
label: `${s.name} · ${s.players}/${s.maxPlayers} · round ${s.info?.round ?? '-'}`,
locked: s.needsPassword,
dim: s.status !== 'running',
})))
}
async function enter(serverId, password = null) {
try {
const { info } = await nuvaka.servers.join(serverId, password)
joined = serverId
showGame(info)
} catch (e) {
if (e.code === 'bad_password') return askPassword(serverId) // your own password dialog, then enter(id, password)
if (e.code === 'rejected') return lobby(e.data?.reason ?? 'Rejected')
lobby(e.code) // server_full, invite_only, friends_only …
}
}
nuvaka.servers.onMessage(({ serverId, data }) => {
if (serverId !== joined) return
if (data.t === 'hello') { showGame({ round: data.round }); showTop(data.top) }
else if (data.t === 'hint') showHint(data.n, data.hint)
else if (data.t === 'won') { toast(`${data.username} got it: ${data.n}`); showTop(data.top) }
else if (data.t === 'timeout') toast(`Time's up, the number was ${data.n}`)
})
nuvaka.servers.onKicked(({ reason }) => lobby(reason || 'You were removed from the server'))
nuvaka.servers.onClosed(({ reason }) => lobby(reason === 'closed_by_owner' ? 'The owner closed the server' : 'The server closed'))
nuvaka.servers.onInvite(({ serverId }) => enter(serverId))
nuvaka.servers.onStatus(({ connected, reconnecting }) => setBanner(connected ? '' : reconnecting ? 'Reconnecting…' : 'Offline'))
const guess = (n) => nuvaka.servers.send(joined, { t: 'guess', n })
const back = async () => { await nuvaka.servers.leave(joined); lobby() }
// userServers: start your own server, invite a friend, close it
async function hostOwn(name) {
const { serverId } = await nuvaka.servers.create({ name, visibility: 'invite', maxPlayers: 8 })
await enter(serverId)
}
const inviteFriend = (username) => nuvaka.servers.invite(joined, username)
const closeOwn = () => nuvaka.servers.delete(joined)
nuvaka.ready.then(() => {
const invited = nuvaka.servers.invited() // opened from a notification: go straight in
return invited ? enter(invited) : lobby()
})
test/server.test.js
import { it, expect } from 'vitest'
import { DatabaseSync } from 'node:sqlite'
import { createMockNuvaka, installMockNuvaka } from '@nuvaka/extension-sdk/testing'
// node:sqlite in place of nuvaka.db
function memoryDb() {
const sql = new DatabaseSync(':memory:')
return {
exec: (s) => { sql.exec(s); return null },
run: (s, ...p) => { const r = sql.prepare(s).run(...p); return { changes: Number(r.changes), lastInsertRowid: Number(r.lastInsertRowid) } },
get: (s, ...p) => sql.prepare(s).get(...p) ?? null,
all: (s, ...p) => sql.prepare(s).all(...p),
}
}
// server/main.js is a classic script that reads globalThis.nuvaka as it loads → collect the handlers, attach them to each mock instance
async function loadServer() {
let srv
const handlers = {}
const prev = globalThis.nuvaka
globalThis.nuvaka = new Proxy({}, { get: (_, k) => (k === 'on' ? (ev, fn) => { handlers[ev] = fn } : srv[k]) })
await import('../server/main.js')
globalThis.nuvaka = prev
return (nv) => { srv = nv; for (const [ev, fn] of Object.entries(handlers)) nv.on(ev, fn) }
}
it('guesses, leaderboard, rejection and timeout', async () => {
const db = memoryDb()
const m = installMockNuvaka(createMockNuvaka({
grants: ['nuvaka.realtime'], realtime: { username: 'ayse' },
servers: { db, tickHz: 1, script: await loadServer() },
}))
const sid = m.serverHost.create({ name: 'Europe 1' })
const got = []
m.servers.onMessage(({ data }) => { got.push(data) })
expect((await m.servers.join(sid)).info).toEqual({ round: 1 })
expect(got[0].t).toBe('hello') // the send inside join reached the joiner
// binary search with "up/down": at most 7 guesses
let lo = 1, hi = 100, won = null
while (!won) {
const n = Math.floor((lo + hi) / 2)
await m.servers.send(sid, { t: 'guess', n })
won = got.find((d) => d.t === 'won')
const hint = got.at(-1)
if (hint.t === 'hint') hint.hint === 'up' ? (lo = n + 1) : (hi = n - 1)
}
expect(won.top).toEqual([{ username: 'ayse', wins: 1 }])
expect(m.serverHost.server(sid).info).toEqual({ round: 2 })
db.run('INSERT INTO bans (username) VALUES (?)', 'veli')
expect(await m.serverHost.join(sid, 'veli')).toBe('You are banned from this server')
for (let i = 0; i < 60; i++) await m.serverHost.tick(sid) // 60 × 1000 ms
expect(got.some((d) => d.t === 'timeout')).toBe(true)
})
Nuvaka Apps API v1 · last updated 2026-09-29