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:
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
| Node | Deno | Bun | Sluurp | |
|---|---|---|---|---|
fetch, Request, Response, Headers | Yes | Yes | Yes | Yes, public addresses only |
Streams (ReadableStream and the rest) | Yes | Yes | Yes | Yes |
CompressionStream | Yes | Yes | Yes | Yes, plus brotli |
URL, TextEncoder, atob, Blob, File | Yes | Yes | Yes | Yes |
crypto.subtle | Every algorithm | Every algorithm | Every algorithm | The common ones; see Crypto |
Timers, structuredClone, console | Yes | Yes | Yes | Yes |
| Files | node:fs, the machine’s disk | Deno.readFile, with permissions | Bun.file, the machine’s disk | Sluurp.files, the app’s own, in its database |
| YAML, TOML, CSV | From npm | @std/yaml, @std/toml, @std/csv | Bun.YAML, Bun.TOML | Sluurp.YAML, Sluurp.TOML, Sluurp.CSV |
| JSON5, JSONL | From npm | @std/jsonc, from npm | Bun.JSON5, Bun.JSONL | Sluurp.JSON5, Sluurp.JSONL |
| tar archives | From npm | @std/tar | Bun.Archive | Sluurp.Archive |
| Password hashing | From npm | From npm | Bun.password | Sluurp.password |
| gzip in one call | node:zlib | From npm | Bun.gzipSync | Sluurp.compress |
| hex, base32, base64url | Buffer | @std/encoding | Buffer | Sluurp.encoding |
| CSRF tokens | From npm | From npm | Bun.CSRF | Sluurp.CSRF |
| A database | From npm | From npm | bun:sqlite | ctx, with rules; see Functions |
Node’s modules (node:fs, Buffer, process) | Yes | Most | Most | No |
| Child processes, sockets, workers | Yes | Yes | Yes | No |
| npm packages | Yes | Yes | Yes | Those that use only the web’s APIs; see Packages |
| How code runs | One long process | One long process | One long process | A fresh sandbox per call: about 1 ms and 300 KB |
| What code can reach | The machine | What you allow | The machine | Your 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.allowsetting, one per line, is the exception. - Redirects are followed, and each one is checked the same way.
Request,ResponseandHeadersare the standard classes.Response.json(data)makes a JSON response.AbortController,AbortSignal.timeout(ms)andAbortSignal.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, TextDecoder | UTF-8 to bytes and back. |
atob, btoa | Base64 of Latin-1 text, as in browsers. For bytes, use Sluurp.encoding.base64. |
Blob, File | Bytes with a type, and a name for a File: .text(), .arrayBuffer(), .bytes(), .slice(). |
URL, URLSearchParams | Parse and build addresses and query strings. |
structuredClone | A 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:
| Algorithms | Methods | |
|---|---|---|
| Hashes | SHA-1, SHA-256, SHA-384, SHA-512 | digest |
| MACs | HMAC | sign, verify |
| Signatures | ECDSA (P-256 with SHA-256, P-384 with SHA-384), Ed25519, RSASSA-PKCS1-v1_5, RSA-PSS | sign, verify |
| Encryption | AES-GCM, 128- or 256-bit keys, 12-byte IV | encrypt, decrypt |
| Key derivation | PBKDF2, HKDF | deriveBits, 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 keysparse(text, { header, separator })gives arrays of strings, or objects whenheaderis 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 numberSluurp.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 aMapof names to strings or bytes.new Sluurp.Archive(bytes)reads one. A gzipped one is recognised.files(glob)gives the files asFiles by name.*matches within a folder and**across folders.bytes()andblob()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.