The server library

Functions, hooks, jobs and agents run with the same globals as Node, Deno, Bun and browsers, plus a Sluurp object for what the web platform leaves out. Nothing needs importing, and code written for those runtimes mostly runs unchanged.

Every section below is a global, ready in any server file:

functions/hello.ts
export default async function () {
  const page = await fetch("https://example.com").then((r) => r.text());
  const hash = await crypto.subtle.digest("SHA-256", new TextEncoder().encode(page));
  await Sluurp.files.write("pages/example.html", page);
  return { bytes: page.length, sha256: Sluurp.encoding.hex.encode(hash) };
}

Compared with Node, Deno and Bun

NodeDenoBunSluurp
fetch, Request, Response, HeadersYesYesYesYes, public addresses only
Streams (ReadableStream and the rest)YesYesYesYes
CompressionStreamYesYesYesYes, plus brotli
URL, TextEncoder, atob, Blob, FileYesYesYesYes
crypto.subtleEvery algorithmEvery algorithmEvery algorithmThe common ones; see Crypto
Timers, structuredClone, consoleYesYesYesYes
Filesnode:fs, the machine’s diskDeno.readFile, with permissionsBun.file, the machine’s diskSluurp.files, the app’s own, in its database
YAML, TOML, CSVFrom npm@std/yaml, @std/toml, @std/csvBun.YAML, Bun.TOMLSluurp.YAML, Sluurp.TOML, Sluurp.CSV
JSON5, JSONLFrom npm@std/jsonc, from npmBun.JSON5, Bun.JSONLSluurp.JSON5, Sluurp.JSONL
tar archivesFrom npm@std/tarBun.ArchiveSluurp.Archive
Password hashingFrom npmFrom npmBun.passwordSluurp.password
gzip in one callnode:zlibFrom npmBun.gzipSyncSluurp.compress
hex, base32, base64urlBuffer@std/encodingBufferSluurp.encoding
CSRF tokensFrom npmFrom npmBun.CSRFSluurp.CSRF
A databaseFrom npmFrom npmbun:sqlitectx, with rules; see Functions
Node’s modules (node:fs, Buffer, process)YesMostMostNo
Child processes, sockets, workersYesYesYesNo
npm packagesYesYesYesThose that use only the web’s APIs; see Packages
How code runsOne long processOne long processOne long processA fresh sandbox per call: about 1 ms and 300 KB
What code can reachThe machineWhat you allowThe machineYour data, public addresses, its own files

The last two rows are the design. Node, Deno and Bun give code the machine, and you decide how far to trust it. Sluurp gives each call a new sandbox that can reach only your data, the public internet and its own files, so a mistake in one function can’t leak into another, or into the server. For speed, see QuickJS: small, safe, fast enough.

Network

fetch(input, init) works as it does in browsers. Requests run side by side, and a response’s body is a stream read as it arrives; see the event loop.

const [a, b] = await Promise.all([fetch(urlA), fetch(urlB)]);
const controller = new AbortController();
setTimeout(() => controller.abort(), 10_000);
const big = await fetch(url, { signal: controller.signal });
for await (const chunk of big.body) process(chunk);
  • It reaches public addresses only, never this machine or your private network. A host listed in the project’s fetch.allow setting, one per line, is the exception.
  • Redirects are followed, and each one is checked the same way.
  • Request, Response and Headers are the standard classes. Response.json(data) makes a JSON response.
  • AbortController, AbortSignal.timeout(ms) and AbortSignal.any(signals) cancel requests.

Streams

ReadableStream, WritableStream and TransformStream, with pipeThrough, pipeTo, tee, getReader, for await and ReadableStream.from(iterable).

const gzipped = new Response(text).body.pipeThrough(new CompressionStream("gzip"));
const bytes = new Uint8Array(await new Response(gzipped).arrayBuffer());

CompressionStream and DecompressionStream take gzip, deflate, deflate-raw or, beyond the standard, br for brotli.

Text, bytes and addresses

TextEncoder, TextDecoderUTF-8 to bytes and back.
atob, btoaBase64 of Latin-1 text, as in browsers. For bytes, use Sluurp.encoding.base64.
Blob, FileBytes with a type, and a name for a File: .text(), .arrayBuffer(), .bytes(), .slice().
URL, URLSearchParamsParse and build addresses and query strings.
structuredCloneA deep copy that keeps dates, maps, sets and typed arrays.

Crypto

crypto.getRandomValues(array) and crypto.randomUUID() use the operating system’s randomness.

crypto.subtle has:

AlgorithmsMethods
HashesSHA-1, SHA-256, SHA-384, SHA-512digest
MACsHMACsign, verify
SignaturesECDSA (P-256 with SHA-256, P-384 with SHA-384), Ed25519, RSASSA-PKCS1-v1_5, RSA-PSSsign, verify
EncryptionAES-GCM, 128- or 256-bit keys, 12-byte IVencrypt, decrypt
Key derivationPBKDF2, HKDFderiveBits, deriveKey

Keys come in through importKey and go out through exportKey as raw, pkcs8, spki or jwk. generateKey makes HMAC, AES-GCM, ECDSA and Ed25519 keys.

const key = await crypto.subtle.generateKey({ name: "AES-GCM", length: 256 }, true, ["encrypt", "decrypt"]);
const iv = crypto.getRandomValues(new Uint8Array(12));
const sealed = await crypto.subtle.encrypt({ name: "AES-GCM", iv }, key, new TextEncoder().encode("secret"));

Not here: RSA key generation (make keys elsewhere and import them), RSA-OAEP, AES-CBC, AES-CTR, ECDH and X25519.

Time and the console

setTimeout, setInterval, setImmediate, their clear… twins, queueMicrotask and performance.now(). Timers fire while a call is still running; when it returns, its timers end with it.

console.log, info, warn, error and debug write to the server’s log, where the admin’s Logs screen shows them.

Sluurp.files

Files server code keeps, by path. They live in the app’s database, so they’re in its backups and replicas, and a path can’t reach anything outside the app. For files people upload, use a file field.

await Sluurp.files.write("exports/orders.csv", Sluurp.CSV.stringify(rows));
const csv = await Sluurp.files.text("exports/orders.csv");
write(path, data)A string, bytes, a Blob or a Response. Gives the size written.
read(path)The bytes, or null when there’s no such file.
text(path), json(path)Read as text, or parsed as JSON; null when missing.
exists(path)Whether there’s a file there.
stat(path){ path, size, modified }, or null.
list(folder)Every file under a folder, with sizes and times. Leave the folder out for all of them.
rename(from, to)Moves a file, or a folder with everything in it. Gives how many moved.
remove(path)Removes a file, or a folder with everything in it. Gives how many went.

Paths use /. A leading / and doubled slashes are ignored, and .. is refused.

Sluurp.CSV

const rows = Sluurp.CSV.parse(text, { header: true }); // [{ name: "Ana", city: "Cluj" }, …]
const out = Sluurp.CSV.stringify(rows);                  // with a header row from the keys
  • parse(text, { header, separator }) gives arrays of strings, or objects when header is true. Quoted fields, doubled quotes and line breaks inside quotes all work, and a byte-order mark is skipped.
  • stringify(rows, { header, separator }) takes arrays or objects. Fields that need quoting get it, and lines end with \r\n, as spreadsheets expect.

Sluurp.YAML, TOML, JSON5 and JSONL

Each has parse(text) and stringify(value):

const config = Sluurp.YAML.parse("name: shop\nports: [80, 443]");
const toml = Sluurp.TOML.stringify({ server: { port: 8080 } });
const loose = Sluurp.JSON5.parse("{ unquoted: 'keys', trailing: [1, 2,], }");
const events = Sluurp.JSONL.parse(logText); // one value per line; a bad line is named by number

Sluurp.Archive

Reads and writes tar files, gzipped or not.

const archive = new Sluurp.Archive({ "readme.txt": "Hello", "data/rows.json": JSON.stringify(rows) }, { compress: "gzip" });
await Sluurp.files.write("backups/export.tar.gz", await archive.bytes());

const read = new Sluurp.Archive(await Sluurp.files.read("backups/export.tar.gz"));
for (const [name, file] of await read.files("data/*.json")) console.log(name, await file.text());
  • new Sluurp.Archive(files, { compress: "gzip" }) makes one from an object or a Map of names to strings or bytes.
  • new Sluurp.Archive(bytes) reads one. A gzipped one is recognised.
  • files(glob) gives the files as Files by name. * matches within a folder and ** across folders.
  • bytes() and blob() give the archive.

Sluurp.compress and decompress

Whole buffers in one call: await Sluurp.compress(data, format) and await Sluurp.decompress(data, format). The format is gzip (the default), deflate, deflate-raw or br. The data may be a string, bytes or a Blob. For streams, use CompressionStream.

Sluurp.password

const hash = await Sluurp.password.hash(password); // "$argon2id$…"
const ok = await Sluurp.password.verify(password, hash);

Argon2id, as Sluurp’s own sign-in uses, with a new salt each time. verify takes the same time whether or not the password matches.

Sluurp.encoding

hex, base64, base64url and base32, each with encode(data) and decode(text). encode takes a string (as UTF-8) or bytes. decode gives bytes.

Sluurp.encoding.hex.encode("hi");        // "6869"
Sluurp.encoding.base64url.encode(bytes); // no padding, URL-safe
Sluurp.encoding.base32.decode("MZXW6YTBOI======");

Sluurp.CSRF

Tokens that prove a form came from your own page:

const token = Sluurp.CSRF.generate(secret, { sessionId, expiresIn: 60 * 60 * 1000 });
const valid = Sluurp.CSRF.verify(token, { secret, sessionId });

A token is signed with HMAC-SHA-256 and carries when it was made. verify checks the signature, the session and the age, and returns false rather than throwing.