Storage and cache

Storage

useStorage(mount) gives a key-value store. A mount is a name; the config says which driver backs it, and a mount that the config does not name is kept in memory, except data.

data is a file store by default: without a data mount, useStorage("data") keeps its items in .data/kv under silgi dev and on the bun, node and static presets (silgi init lists .data/ in .gitignore). On other presets it is in memory and logs once per process storage "data" is in memory on vercel; configure storage.data.

To use another driver only in development and while prerendering, name the mount in devStorage: it replaces the storage mount of the same name there, so a redis cache in production can be fs on your machine and opens no connection (details).

import { defineConfig } from "silgi";

export default defineConfig({
  framework: "react",
  storage: {
    cache: { driver: "fs", base: ".data/cache" },
    sessions: { driver: "sqlite", path: ".data/sessions.db" },
  },
});
import { defineHandler, useStorage } from "silgi/server";

export const user = defineHandler({
  route: "/api/user",
  method: "GET",
  async handler() {
    const kv = useStorage("cache");
    await kv.setItem("user:1", { name: "Ada", joined: new Date() }, { ttl: 60 });
    const user = await kv.getItem<{ name: string; joined: Date }>("user:1");
    const keys = await kv.getKeys("user");
    return { user, keys };
  },
});

The store has getItem, setItem(key, value, { ttl }), hasItem, removeItem, getKeys(prefix), clear(prefix), and getItemRaw and setItemRaw for bytes. Values go through the same serializer as loader data, so a Date or a Map comes back as it went in. ttl is in seconds and must be positive: 0 throws a TypeError. Setting a key to undefined removes it.

Keys are separated by :; / and \ separate them too, so user/1 is the same key as user:1. A prefix matches whole segments: getKeys("user") returns user, user:1 and user:2:x, not users:1. A segment that is ., .. or contains a NUL byte throws.

Metadata, counters and batches

  • getMeta(key) gives what the backend knows (mtime and size on memory, fs, sqlite, s3 and db; expires, in ms, where an expiry is stored) merged with the fields setMeta(key, { … }) stored; a missing key gives {}. removeMeta(key) clears the fields. Custom fields are kept under <key>$ with the item's expiry, so they end with the item. setMeta of a missing item throws TypeError: storage "x": setMeta of "k" needs an existing item, and a key whose last segment ends in $ is refused (Storage key "a$" ends in "$", which marks metadata).
  • increment(key, by = 1, { ttl }) adds an integer and gives the new value. A missing key starts at 0, and ttl applies only when this call creates the key, which makes a fixed window for a rate limit. It is atomic on memory, sqlite, redis, db and planetscale; on every other driver it runs under a lock of this process and logs once per mount increment on <driver> is not atomic across instances. Values and results must be safe integers.
  • getItems(keys) gives { key, value } in the given order, value undefined for a missing key, and setItems([{ key, value, options }]) writes many (a value of undefined removes the key). They are one MGET on redis, one query on sqlite, db and planetscale, and the single calls together elsewhere; a refused key throws before anything is written. getKeys(prefix, { maxDepth }) counts segments below the prefix: a:1 and a:b:2 under a with maxDepth: 1 give a:1.
import { createError, defineHandler, useStorage } from "silgi/server";

export const hit = defineHandler({
  route: "/api/hit",
  method: "POST",
  async handler() {
    const limits = useStorage("limits");
    const count = await limits.increment("hits", 1, { ttl: 60 });
    if (count > 100) throw createError({ status: 429, message: "Too many hits" });
    await limits.setMeta("hits", { lastHit: new Date().toISOString() });
    const items = await limits.getItems(["hits", "missing"]);
    return { count, meta: await limits.getMeta("hits"), values: items.map((item) => item.value) };
  },
});

Watching changes

watch(callback) calls callback("update" | "remove", key) after every change made through this process (setItem, setItems, increment, setMeta, removeItem, clear once per key) and gives the function that stops it. The fs driver also reports files that another process changed, with a recursive fs.watch debounced for 50 ms, and does not report its own writes twice. A callback that throws is logged at error and does not stop the others.

import { definePlugin, useStorage } from "silgi/server";

export default definePlugin((server) => {
  const unwatch = useStorage("content").watch((event, key) => {
    console.log(`content ${event}: ${key}`);
  });
  server.hooks.hook("close", unwatch);
});

Snapshots

snapshot(storage, prefix?) reads every item at or below a prefix into an object and restoreSnapshot(storage, data, prefix?) writes one back. Keys are relative to the prefix, so a snapshot can move to another prefix or mount. Expired items are left out, and a TTL and metadata are not carried. Restoring adds to the storage and removes nothing.

import { restoreSnapshot, snapshot, useStorage } from "silgi/server";

export async function backup() {
  const copy = await snapshot(useStorage("data"), "users");
  await restoreSnapshot(useStorage("data"), copy, "users-backup");
  return Object.keys(copy).length;
}

Prefix views and typed keys

prefixStorage(storage, "users") is a full Storage whose keys are relative to users:: getKeys() strips the prefix, clear() clears only below it, and a key cannot leave the view with ... useStorage<Schema>(name) takes an object type from key patterns to value types; getItem, setItem, getItems and setItems are then typed by key, a literal key that matches no pattern fails tsc, and prefixStorage keeps the schema.

import { prefixStorage, useStorage } from "silgi/server";

interface User {
  name: string;
  joined: Date;
}

interface Schema {
  [key: `user:${string}`]: User;
  visits: number;
}

export async function demo() {
  const data = useStorage<Schema>("data");
  await data.setItem("user:1", { name: "Ada", joined: new Date() });
  await data.setItem("visits", 1);
  const users = prefixStorage(data, "user");
  return users.getItem("1");
}

Development-only mounts

devStorage replaces the storage mount of the same name, options and all, under silgi dev and in the prerender of every preset. A $development.storage block applies first, then devStorage on top; devStorage is the key that also covers the prerender, where a platform driver such as cloudflare-kv cannot run. A mount replaced this way imports no driver and opens no connection. The build checks storage drivers against the preset, not devStorage drivers, and a Cloudflare bundle carries no devStorage driver.

Mounting at run time and custom drivers

mountStorage(name, driver) replaces a mount of the same name, and unmountStorage(name) removes one and waits for its driver's dispose(); after it, useStorage(name) is the default mount for that name: a fresh memory mount, or for data the .data/kv file store on bun, node and static. Calls already running finish first, a Storage you kept refuses new calls (storage "x" was unmounted; call useStorage("x") for the current mount), and the drain disposes every driver once.

import { definePlugin, mountStorage, useStorage } from "silgi/server";
import { mapDriver } from "./storage-mount-driver.ts";

export default definePlugin(async () => {
  mountStorage("scratch", mapDriver({ label: "scratch" }));
  await useStorage("scratch").setItem("started", Date.now());
});

A driver is an object of functions over bytes: getItem, setItem(key, value, expires?), hasItem, removeItem, getKeys(prefix) and clear(prefix), and optionally getItems, setItems, getMeta, increment, watch and dispose. A driver that leaves the optional ones out gets the fallbacks. A prefix ending in : matches only keys that start with it. defineStorageDriver types a factory and gives it back as it is:

import { defineStorageDriver } from "silgi/server";

export const mapDriver = defineStorageDriver((options: { label?: string } = {}) => {
  const items = new Map<string, { value: Uint8Array; expires?: number }>();
  const live = (key: string) => {
    const item = items.get(key);
    if (item?.expires !== undefined && item.expires <= Date.now()) return undefined;
    return item;
  };
  const under = (prefix: string, key: string) => !prefix || key === prefix || key.startsWith(prefix.endsWith(":") ? prefix : `${prefix}:`);

  return {
    async getItem(key) {
      return live(key)?.value;
    },
    async setItem(key, value, expires) {
      items.set(key, { value, expires });
    },
    async hasItem(key) {
      return live(key) !== undefined;
    },
    async removeItem(key) {
      items.delete(key);
    },
    async getKeys(prefix) {
      return [...items.keys()].filter((key) => live(key) && under(prefix, key));
    },
    async clear(prefix) {
      for (const key of [...items.keys()]) if (under(prefix, key)) items.delete(key);
    },
    dispose() {
      items.clear();
      console.log(`${options.label ?? "map"} driver disposed`);
    },
  };
});

storage.<name>.driver can also be a path (./server/drivers/mine.ts, relative to the app root) or a package whose default export is a factory; the mount's other keys are its options. The build bundles it, a package with the preset's export conditions, and does not check it against the preset. A driver that does not resolve fails the build (Cannot find storage driver "./x.ts" for mount "y" (built-in: memory, fs, redis, …)), and one whose default export is not a function stops the server at start. A module that wants a checked driver adds it with addStorageDriver (Modules).

Drivers

DriverRuns onOptions
memoryevery presetmax, the most entries held, and maxBytes, the most bytes held across all values, both positive integers; the oldest writes go first and a value larger than maxBytes is not stored (the cache mount you do not configure holds 10000 entries and 100 MB); lost when the process ends
fsbun, node, staticbase (required), the directory; readOnly, noClear, ignore (see below)
sqlitebun, staticpath (required), table
redisbun, node, cloudflare, staticurl, base (a key prefix)
s3every presetbucket, region, endpoint, accessKeyId, secretAccessKey, sessionToken, virtualHostedStyle, base
cloudflare-kvcloudflarebinding (required), base
cloudflare-r2cloudflarebinding (required), base (a key prefix)
cloudflare-cachecloudflarename (for caches.open; default caches.default)
netlify-blobsnetlifyname (required), deployScoped
vercel-blobvercelbase (a key prefix)
azure-blobevery presetaccountName, containerName (required), accountKey or sasToken, base, endpoint
planetscaleevery preseturl (required, with credentials), table
vercel-runtime-cachevercelnone
overlayevery presetlayers, a list of mount configs, topmost first (see below)
nullevery presetnone
githubevery presetrepo (required, owner/name), branch (default main), dir, token, ttl (seconds the file list is cached, default 600); read-only
httpevery presetbase (required), the absolute URL of a createStorageHandler route; headers, sent with every request
dbevery preset the connection's connector runs onconnection (default default), table (default silgi_storage); needs the @silgi/db module, see Database
a path or packagenot checkedthe factory's own, see above

static runs the server on Bun while it builds, to prerender, so it takes what bun takes. A mount whose driver the preset cannot run is a build error that names the mount. On vercel and netlify the memory driver is per function instance; s3, netlify-blobs (on Netlify), vercel-blob and vercel-runtime-cache (on Vercel) are shared.

fs takes three more options. With readOnly: true, a write, a removal or clear throws TypeError: storage "<mount>" is read-only; reads work. With noClear: true, clear does nothing. ignore is a list of glob patterns, matched against the key with : read as /, so ["tmp/**", "**/*.bak"] leaves tmp:a and b:c.bak out of getKeys; getItem and hasItem still find them. The patterns understand * (anything but /), ? (one character), ** (anything, / included; **/ may match nothing), {a,b} (one of the alternatives) and \ to take the next character literally. ! and [...] are ordinary characters, names starting with a dot match like any other, and matching is case-sensitive on every system.

The overlay driver stacks mounts. layers is a list of mount configs, each with its own driver and options, and each is bundled and checked against the preset like a mount of its own. A read tries the layers in order and takes the first value it finds. A write goes to the first layer only. removeItem writes a marker in the first layer, so the key reads as missing even though a lower layer still holds it, and the lower layer is not touched; clear does the same for every key it holds under the prefix. This puts a writable layer over files you ship:

import { defineConfig } from "silgi";

export default defineConfig({
  framework: "react",
  storage: {
    content: {
      driver: "overlay",
      layers: [
        { driver: "fs", base: ".data/content" },
        { driver: "fs", base: "content", readOnly: true },
      ],
    },
  },
});

The null driver drops every write and reads as empty, so storage: { cache: { driver: "null" } } turns the cache off: a cached handler runs on every request.

The github driver reads the files of a repository, so a mount can serve content that lives in Git: storage: { docs: { driver: "github", repo: "acme/site", dir: "content", token: { runtimeConfig: "githubToken" } } }. The path a/b/c.json is the key a:b:c.json. It lists the files with one call to the GitHub API and keeps the list for ttl seconds, so a new commit shows after that long; a file is fetched when you read it. Without a token GitHub allows 60 requests an hour per IP address, and a private repository is not found. Files are read as server assets are: getItem parses a .json file, gives other text files (.md, .txt, .yaml, …) as a string and any other file as bytes, and getItemRaw gives the bytes of any file. A read gets the file of the commit the list came from, even if the branch has moved since. dir makes the API list only that directory. A symlink, a submodule, a file whose name ends in $ and one whose name has a : or \ are not listed. getMeta has the size and no mtime. Every write, removeItem and clear throws TypeError: github driver is read-only. A repository with more than 100000 files, or a list over 7 MB, fails with github tree of "owner/name" at "main" is truncated by the API, and a rate limit with github rate limit exceeded and the time to try again.

The redis driver uses url (redis:// or rediss:// for TLS), else VALKEY_URL or REDIS_URL, else localhost:6379. On bun it uses Bun.redis; on node and cloudflare it uses a client of Silgi's own over node:net/node:tls and cloudflare:sockets, with the same commands, so a mount behaves the same on all three. The connection is made at the first use. A command sent on a connection that drops is rejected, never repeated; the next command opens a new connection. On Cloudflare each request opens its own socket. The variables come from process.env on node and from the Worker's env on cloudflare. The s3 driver reads what you leave out from the S3_* and AWS_* variables. It runs on every preset: Bun's client on bun, and a SigV4 signer of Silgi's over fetch everywhere else. An item is an object <base><key> that holds its expiry in front of the bytes, so it is not a plain file; use @silgi/blob for files, and presigned URLs, which a storage value cannot be. For cloudflare-kv, binding is the name of a kv_namespaces entry in the Worker configuration. Put that entry in a wrangler.json or wrangler.jsonc in the app root, not in .output/wrangler.json: every build rewrites that file, and an edit there is lost. The build merges the root file into it; see Deploy.

The netlify-blobs driver talks to the Netlify Blobs HTTP API with the site's NETLIFY_BLOBS_CONTEXT, which Netlify sets for functions; without it the first use fails with netlify-blobs needs NETLIFY_BLOBS_CONTEXT (runs on Netlify). name is the store's name (at most 64 bytes, no /); with deployScoped the items belong to the current deploy and a new deploy does not see them. A key is at most 599 bytes. Reads go to the uncached edge when the context has one, so a written item is read back at once. A 429 or 5xx answer is retried up to five times. Expired items are not deleted on read, as on cloudflare-r2, and increment is not atomic across function instances.

For cloudflare-r2, binding is the name of an r2_buckets entry in the Worker configuration, kept in the app root like the KV one; a missing binding fails the first read or write with its name. Each item is one object, <base><key>. The cloudflare-cache driver keeps items in the Cloudflare Cache API: one cache per data center, so a value written in one region is not read in another, and the Cache API works only on a custom domain, not on workers.dev. It can evict an item before its ttl. It cannot list keys: getKeys and clear throw TypeError: cloudflare-cache cannot list keys. Use it for caches, not for data you need back.

The planetscale driver talks to PlanetScale's HTTP SQL API with fetch, so it runs wherever your server does. url is https://<username>:<password>@<host> or the mysql://<username>:<password>@<host> string PlanetScale shows for a password (http:// is accepted only for localhost); keep it in an environment variable, not in the config file. Items live in one table, silgi_storage unless table says otherwise, created at first use: give each planetscale mount its own table. A branch with safe migrations refuses that CREATE TABLE; create the table through a deploy request (key VARBINARY(1024) NOT NULL PRIMARY KEY, value LONGBLOB NOT NULL, expires BIGINT, mtime BIGINT) and the driver uses it. A key is at most 1023 bytes and a value at most 30 MiB: a larger one throws planetscale storage value of key … is too large, naming the limit. increment is atomic across instances: it runs in a transaction under the row's lock, in five requests. The API takes no parameters, so the driver writes keys and values as hex literals; nothing you store can change the statement. A failed request throws PlanetScale query failed: <the API's message>, never with the url or the password.

On Vercel, vercel-blob reads BLOB_READ_WRITE_TOKEN, which Vercel sets when a Blob store is connected to the project, and keeps one private blob per item at <base><key>, read from the origin so a read sees the last write. It has no access option: a public blob is served from a CDN cache that cannot be bypassed, so a mount could read an old value. Use @silgi/blob for public files. vercel-runtime-cache keeps items in the Vercel Runtime Cache of the function's region. Vercel can evict an item before its ttl, and it cannot list keys: getKeys and clear throw TypeError: vercel-runtime-cache cannot list keys. Outside a Vercel Function it throws vercel-runtime-cache runs only on Vercel Functions. Mounts on vercel-runtime-cache share one cache, so give a key a prefix of its own when two mounts use it.

The http driver reads and writes a mount of another app over HTTP. That app serves the mount with createStorageHandler(mount, { authorize }) from silgi/server in a raw handler on a catch-all route, and authorize decides who may read and who may write: throw to refuse, a createError({ status: 403 }) to answer that status, anything else answers 401, and returning false answers 403. It also learns whether a request is for one item or for everything below a prefix (prefix: true), so a rule for one key does not open the keys below it. On a case-insensitive file system (macOS, Windows) the fs driver keeps SECRET and secret in one file, so with an fs mount there, write authorize rules that do not depend on case, such as comparing key.toLowerCase(). The driver sends headers with every request, so a token goes there. Values travel as the stored bytes with their expiry in x-silgi-expires, so a ttl set through the driver ends at the same time on the other side, and metadata works as on a local mount. Both apps' clocks must agree, since the expiry is an absolute time. A refused or failed request throws http storage: GET "a:b" failed with 401 Unauthorized; the headers are never in the message, and a page that hits it answers 500, not the remote's 401. Redirects are not followed, so the headers never go to another host. increment is not atomic across instances on this driver. The protocol is in the API reference.

import { createError, createStorageHandler, defineHandler, useRuntimeConfig } from "silgi/server";

const storageHandler = createStorageHandler("shared", {
  authorize(event) {
    const token = (useRuntimeConfig(event) as { storageToken?: string }).storageToken;
    if (!token || event.request.headers.get("authorization") !== `Bearer ${token}`) throw createError({ status: 401 });
  },
});

export const storage = defineHandler({
  method: "*",
  route: "/_storage/**:key?",
  raw: true,
  handler: ({ key }: { key?: string[] }, { event }) => storageHandler(event, key),
});
import { defineConfig } from "silgi";

export default defineConfig({
  framework: "react",
  storage: {
    shared: {
      driver: "http",
      base: "https://data.example.com/_storage",
      headers: { authorization: `Bearer ${process.env.STORAGE_TOKEN}` },
    },
  },
});
{
  "name": "my-app",
  "kv_namespaces": [{ "binding": "CACHE", "id": "<namespace id>" }],
  "vars": { "ENVIRONMENT": "production" }
}

azure-blob keeps one block blob per item at <base><key> in a container that already exists, over the Blob REST API with fetch, so it runs on every preset. Give accountName, containerName and either accountKey (the base64 key, signed with Shared Key by crypto.subtle) or sasToken; AZURE_STORAGE_ACCOUNT, AZURE_STORAGE_KEY and AZURE_STORAGE_SAS_TOKEN fill what the config leaves out. endpoint points the driver at Azurite (http://127.0.0.1:10000/devstoreaccount1) or a sovereign cloud. A refused request throws Azure Blob GET <container>/<blob> failed: 403 AuthenticationFailed: … with the service's code and message. Table, Cosmos DB, Key Vault and App Configuration have no built-in driver: write one with defineStorageDriver (see the server API reference, mountStorage, unmountStorage, defineStorageDriver).

An expired item reads as missing. The fs, s3, cloudflare-r2, vercel-blob and azure-blob drivers do not delete it when it is read, so it stays until you write the key again, call removeItem or clear, or (on S3 and R2) a lifecycle rule on the bucket removes it. cloudflare-kv asks KV to expire every item with a ttl, but not sooner than 60 seconds ahead.

Secrets and encrypt

Any string option of a mount can be read from runtime config instead of the config file: password: { runtimeConfig: "redisPassword" }. Declare the key in runtimeConfig (runtimeConfig: { redisPassword: "" }) and set SILGI_REDIS_PASSWORD where the server runs; a key the config does not declare fails the build with storage.cache.password reads runtimeConfig.redisPassword, which the config does not declare; add runtimeConfig: { redisPassword: "" } and set SILGI_REDIS_PASSWORD. The value is read when the mount is first used, so on Cloudflare, Vercel and Netlify it comes from the first request's environment, and the variable's text is used as written, even 12345678 or true. An empty or missing value fails that first use with storage "cache" option password reads runtimeConfig.redisPassword, which is not set.

encrypt: { runtimeConfig: "storageKey" } on a mount seals every value, raw values included, with AES-GCM before the driver sees it:

import { defineConfig } from "silgi";

export default defineConfig({
  framework: "react",
  runtimeConfig: { redisUrl: "", storageKey: "" },
  storage: {
    sessions: { driver: "redis", url: { runtimeConfig: "redisUrl" }, encrypt: { runtimeConfig: "storageKey" } },
  },
});

The key is a string of at least 32 characters, or a list of up to 8 for rotation (SILGI_STORAGE_KEY='["new…","old…"]'): the first one seals new writes and every one opens, so you add the new key in front, and drop the old one once its values are rewritten. A missing or short key fails the mount's first use with storage "sessions" encrypt needs runtimeConfig.storageKey (at least 32 characters). A value that does not open, because it was changed, written without the key, copied from another key or another mount, or sealed with a key you removed, reads as undefined, and the first one per mount logs a warning. encrypt goes on the mount, not on an overlay layer (that is a config error), and a driver that reads files, such as github, cannot be encrypted. increment on an encrypted mount reads, adds and writes the value again, so it is not atomic across instances on any driver. createStorageHandler serves an encrypted mount's stored bytes, that is the ciphertext.

What encrypt does not protect: keys, expiry times, setMeta fields, and whether an item exists and how large it is are stored in plain and are not authenticated. Someone who can write to the store can change an item's expiry or metadata without it being noticed, and can put back an older sealed value of the same key, which still opens. Keep secrets in the value, not in metadata, and when an expiry or a version matters (a session), store it inside the value too and check it when you read.

Server assets

Files a server reads and never serves by URL go in server/assets/ of the app and its layers (the app's file wins by path). They are the read-only mount useStorage("assets:server"); a module adds assets:<name> with addServerAssets. A path becomes a key with / turned into :, so server/assets/data/a.json is data:a.json. getItem parses a .json file, gives other text files as a string and anything else as bytes, getItemRaw gives the bytes, and getMeta gives size and mtime. A write throws TypeError: assets:server is read-only, and a storage mount named assets:… fails at load.

On bun, node, vercel and netlify a file up to 1 MB is inlined in the bundle and a larger one is copied next to it. On cloudflare every file is inlined, because the assets binding serves public files and a server asset there would be public; the build warns when the assets pass 32 MiB, half of the 64 MiB a Worker may be. readAsset(url) from silgi/server reads an imported asset URL as a Response.

Recipes

KV and cache as mounts

NuxtHub's kv and cache are two storages. In Silgi each is a mount: name it in storage, keep a cheap driver for development in devStorage, and call useStorage.

import { defineConfig } from "silgi";

export default defineConfig({
  framework: "react",
  storage: {
    kv: { driver: "cloudflare-kv", binding: "KV" },
    cache: { driver: "cloudflare-kv", binding: "CACHE" },
  },
  devStorage: {
    kv: { driver: "fs", base: ".data/kv" },
    cache: { driver: "memory" },
  },
});
import { defineCachedFunction, defineHandler, useStorage } from "silgi/server";

const price = defineCachedFunction(async (sku: string) => ({ sku, cents: 1999 }), { maxAge: 60, base: "cache" });

export const prices = defineHandler({
  route: "/api/prices/:sku",
  method: "GET",
  async handler({ sku }: { sku: string }) {
    const kv = useStorage("kv");
    await kv.setItem(`seen:${sku}`, true, { ttl: 3600 });
    return price(sku);
  },
});
NuxtHubSilgi
hubKV().get(key)useStorage("kv").getItem(key)
hubKV().set(key, value, { ttl })useStorage("kv").setItem(key, value, { ttl }), ttl in seconds, positive
hubKV().has(key), .del(key)hasItem(key), removeItem(key)
hubKV().keys(prefix), .clear(prefix)getKeys(prefix), clear(prefix)
hubCache(), cached functions and handlersdefineCachedFunction, defineCachedHandler, or cache on a GET handler; they keep entries in the cache mount, see Cache

cloudflare-kv needs a kv_namespaces entry for each binding in your wrangler.json, or a namespace id the build can write (Deploy). KV does not expire an item sooner than 60 seconds ahead; Silgi still ends the item at its exact time when it reads it.

MongoDB

There is no built-in MongoDB driver, and none is needed: a driver is one file over your own mongodb package. This one stores an item per document with the key as _id, and lets MongoDB's TTL index delete expired items:

import { Binary, MongoClient } from "mongodb";
import { defineStorageDriver } from "silgi/server";

interface Entry {
  _id: string;
  value: Binary;
  expiresAt: Date | null;
}

const escape = (text: string) => text.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");

export default defineStorageDriver((options: { url: string; database: string; collection?: string }) => {
  const client = new MongoClient(options.url);
  let ready: ReturnType<typeof open> | undefined;
  const open = async () => {
    const collection = client.db(options.database).collection<Entry>(options.collection ?? "silgi_storage");
    await collection.createIndex({ expiresAt: 1 }, { expireAfterSeconds: 0 });
    return collection;
  };
  const entries = () => (ready ??= open());
  const live = { $or: [{ expiresAt: null }, { expiresAt: { $gt: new Date() } }] };
  const under = (prefix: string) =>
    !prefix
      ? {}
      : prefix.endsWith(":")
        ? { _id: { $regex: `^${escape(prefix)}` } }
        : { $or: [{ _id: prefix }, { _id: { $regex: `^${escape(prefix)}:` } }] };

  return {
    async getItem(key) {
      const entry = await (await entries()).findOne({ _id: key, ...live });
      return entry ? new Uint8Array(entry.value.buffer) : undefined;
    },
    async setItem(key, value, expires) {
      await (await entries()).replaceOne({ _id: key }, { value: new Binary(value), expiresAt: expires ? new Date(expires) : null }, { upsert: true });
    },
    async hasItem(key) {
      return (await (await entries()).countDocuments({ _id: key, ...live }, { limit: 1 })) > 0;
    },
    async removeItem(key) {
      await (await entries()).deleteOne({ _id: key });
    },
    async getKeys(prefix) {
      const found = await (await entries()).find({ $and: [live, under(prefix)] }, { projection: { _id: 1 } }).toArray();
      return found.map((entry) => entry._id);
    },
    async clear(prefix) {
      await (await entries()).deleteMany(under(prefix));
    },
    dispose: () => client.close(),
  };
});
import { defineConfig } from "silgi";

export default defineConfig({
  framework: "react",
  runtimeConfig: { mongoUrl: "" },
  storage: {
    notes: { driver: "./server/drivers/mongodb.ts", url: { runtimeConfig: "mongoUrl" }, database: "app" },
    files: { driver: "./server/drivers/uploadthing.ts" },
  },
  devStorage: {
    notes: { driver: "fs", base: ".data/notes" },
    files: { driver: "memory" },
  },
});

The mongodb package is your app's dependency, not Silgi's. The driver is not checked against the preset, so it is yours to keep off a preset whose runtime cannot open the connection; devStorage swaps it for fs while you develop. dispose closes the client when the mount is replaced or the server drains. Without increment the driver gets the generic one, which is not atomic across instances.

UploadThing

The same shape works for UploadThing, with its UTApi from the uploadthing package. A mount maps a key to a file with that key as its customId:

import { UTApi, UTFile } from "uploadthing/server";
import { defineStorageDriver } from "silgi/server";

export default defineStorageDriver((options: { token?: string }) => {
  const api = new UTApi({ token: options.token });
  const urlOf = async (key: string) => (await api.getFileUrls(key, { keyType: "customId" })).data[0]?.url;
  const everyKey = async () => {
    const keys: string[] = [];
    for (let offset = 0; ; offset += 500) {
      const { files, hasMore } = await api.listFiles({ limit: 500, offset });
      for (const file of files) if (file.customId && file.status === "Uploaded") keys.push(file.customId);
      if (!hasMore) return keys;
    }
  };
  const under = (prefix: string, keys: string[]) => keys.filter((key) => !prefix || key === prefix || key.startsWith(prefix.endsWith(":") ? prefix : `${prefix}:`));

  return {
    async getItem(key) {
      const url = await urlOf(key);
      const response = url ? await fetch(url) : undefined;
      return response?.ok ? new Uint8Array(await response.arrayBuffer()) : undefined;
    },
    async setItem(key, value, expires) {
      if (expires) throw new TypeError("The UploadThing driver keeps files without an expiry; do not pass a ttl");
      await api.deleteFiles(key, { keyType: "customId" });
      await api.uploadFiles(new UTFile([value as BlobPart], key, { customId: key }));
    },
    async hasItem(key) {
      return (await urlOf(key)) !== undefined;
    },
    async removeItem(key) {
      await api.deleteFiles(key, { keyType: "customId" });
    },
    async getKeys(prefix) {
      return under(prefix, await everyKey());
    },
    async clear(prefix) {
      const keys = under(prefix, await everyKey());
      if (keys.length) await api.deleteFiles(keys, { keyType: "customId" });
    },
  };
});

UploadThing does not expire files, so this driver refuses a ttl with a TypeError. It reads through the file's URL, so it suits few, large, rarely changed values. The calls are typed by uploadthing/server; run the driver against your account before you rely on how the service treats a missing customId. For user uploads, @silgi/blob with a presigned or multipart upload is the better tool (Blob).

Cache

Three tools cache, and all keep their entries in storage, in the cache mount unless base names another.

  • cache on a GET handler caches its answer. This is the way to cache a route or a call.
  • defineCachedFunction(fn, options) caches what a function returns.
  • defineCachedHandler(handler, options) caches the Response of a function of the event. It returns an event handler; a raw handler calls its handler(event), since a raw handler cannot take cache.
import { defineHandler } from "silgi/server";

export const prices = defineHandler({
  route: "/prices",
  method: "GET",
  cache: { maxAge: 60, staleMaxAge: 600, varies: ["accept-language"] },
  async handler() {
    const response = await fetch("https://example.com/prices.json");
    return (await response.json()) as Record<string, number>;
  },
});
import { defineCachedHandler, defineHandler } from "silgi/server";

const prices = defineCachedHandler(
  async () => {
    const response = await fetch("https://example.com/prices.json");
    return response.json();
  },
  { maxAge: 60, staleMaxAge: 600, varies: ["accept-language"] },
);

export const priceList = defineHandler({
  route: "/prices",
  method: "GET",
  raw: true,
  handler: (_, { event }) => prices.handler(event),
});
OptionMeaning
maxAgeseconds an entry is fresh; required, and positive: 0 throws a TypeError
swrserve a stale entry at once and refresh it in the background; default true, and false for the cache of a handler
staleMaxAgeseconds past maxAge a stale entry may still be served; without it, no limit
getKeythe cache key; see below. The cache of a handler gets (input, event) and can only widen its own entries
variesrequest header names whose values are part of the key (handlers only)
maxResolveTimeseconds a fill may run; default 30; see Fill deadline
basethe storage mount, cache by default

A fresh entry is returned as it is. A stale one is returned and refreshed once through event.waitUntil. Once it is too old, or missing, the caller waits for a new value. Several callers that miss on the same key share one call, and an error reaches all of them and is not stored.

For a defineCachedHandler the default key is the host, the path and the query, plus the values of the varies headers: a.test/x?q=1 and b.test/x?q=1 are two entries. Only GET and HEAD are cached. The handler runs on a cache fork, a copy of the request that carries only the varies headers and no client address. A handler that reads the cookie or authorization, as a session does, gets nothing unless those names are in varies; without them it always runs as an anonymous visitor. A response is stored only when its status is 2xx (not 206), it sets no cookie, and its cache-control has no no-store, no-cache or private. Other responses are sent to the caller that ran the handler and are not stored. Used directly like this, defineCachedHandler adds no etag, age or cache-control header. invalidate(url) on the cached handler removes the entries of an absolute URL, every varies variant included, and invalidate(...args) of a cached function removes the entry of those arguments.

Range requests

A request with a Range header never reads or writes the cache, in defineCachedHandler, in the cache of a handler and in the cache and swr route rules. The handler runs on the real request and its answer (206, 200 or 416) goes out as it is, with no entry read, none written and no wait on a fill in progress. Without this a stored 200 would answer Range: bytes=0-9 with the whole body, and a stored 206 would be sent as the whole resource to later visitors. A page answer to a Range request also gets no CDN Cache-Control.

Fill deadline

Callers that miss on the same key share one fill. maxResolveTime is the number of seconds that fill may run, 30 by default. If the handler has not finished by then, every caller waiting for it gets a SilgiError with status 504 and the message Cache fill timed out after 30s, nothing is stored, and the next request runs the handler again. A value that arrives after the deadline is dropped. The request that the handler receives carries an abort signal for the deadline, so a handler that passes event.request.signal to fetch stops at the deadline. defineCachedFunction has no request and only stops waiting.

import { defineHandler } from "silgi/server";

export const prices = defineHandler({
  route: "/prices",
  method: "GET",
  cache: { maxAge: 60, maxResolveTime: 10 },
  async handler() {
    const response = await fetch("https://example.com/prices.json");
    return (await response.json()) as Record<string, number>;
  },
});

0 or Infinity waits without a limit. Any other value that is not a non-negative number is a TypeError for a handler and a config error for a route rule. On a route rule, set it in cache (Configuration); a rule written as swr: 600 uses the default. A 504 in a background swr refresh is logged and the stale entry stays.

import { defineCachedFunction } from "silgi/server";

export const getStars = defineCachedFunction(
  async (repo: string) => {
    const response = await fetch(`https://api.github.com/repos/${repo}`);
    const data = (await response.json()) as { stargazers_count: number };
    return data.stargazers_count;
  },
  { maxAge: 300, getKey: (repo) => repo },
);

For a function the default key is the serialized arguments, so an argument that is an event, or anything the serializer cannot hold, needs a getKey. Entries are named after the function and a hash of its source, so two functions with the same source and different closures share entries unless getKey tells them apart.

Route rules

A route rule applies the same cache to pages. swr: 600 is a maxAge of 600 seconds with stale-while-revalidate; cache takes { maxAge, swr, staleMaxAge, varies }. The same rule caches a GET handler route at that path. cache: false turns both off. A redirect, a reload or an error answer is not stored. swr: true and a cache without maxAge both mean 1 second.

The key holds the build id, the host, the path and the query, and the values of the varies headers, nothing else. The cookie is not in it, and the page renders on a cache fork, so a page whose data depends on the user needs varies: ["cookie"]. A request with an authorization header is neither served from the rule cache nor stored in it, unless varies lists authorization. A WebSocket upgrade is never cached. invalidateRouteCache(url) from silgi/server removes the page and a GET handler route that the rules stored for an absolute URL.

import { defineConfig } from "silgi";

export default defineConfig({
  framework: "react",
  routeRules: {
    "/blog/**": { swr: 600 },
    "/pricing": { cache: { maxAge: 60, staleMaxAge: 3600 } },
  },
});

CDN headers

A route rule also tells a CDN how long it may keep the answer. A cached 2xx answer to a GET or HEAD that sets no cookie and has no cache-control of its own gets

Cache-Control: public, s-maxage=<maxAge>, stale-while-revalidate=<staleMaxAge, else 31536000>

stale-while-revalidate is left out when swr is false. cache.varies is added to Vary. On Netlify the same value is also sent as Netlify-CDN-Cache-Control. A response that sets a cookie or its own cache-control, an event stream and a 206 are left as they are. This is the rule cache only; defineCachedHandler used directly adds no header.

import { defineConfig } from "silgi";

export default defineConfig({
  framework: "react",
  routeRules: {
    "/blog/**": { swr: 600 },
    "/prices": { cache: { maxAge: 60, swr: false, varies: ["accept-language"] } },
    "/account/**": { cache: false },
  },
});

Here /blog/** answers public, s-maxage=600, stale-while-revalidate=31536000, and /prices answers public, s-maxage=60 with Vary: accept-language.