Packages, bundles, budgets

There is no node_modules and no bundler to set up. sluurp add downloads a package into the app's vendor/ folder and records it in sluurp-deps.json (import map, versions, and a hash of every file). The server bundles everything at startup.

# add a package and its dependencies to vendor/
sluurp add npm:d3-force@3

# a JSR package, through JSR's npm registry
sluurp add jsr:@std/path@^1

# show wanted and latest versions, like pnpm
sluurp outdated

# upgrade, and drop files only the old version used
sluurp update --latest

# check every file against npm's tarballs
sluurp vendor verify
sluurp remove d3-force

# show the shared cache location and size; `clear` empties it
sluurp cache
  • Packages are vendored as source, not as a CDN’s build. CommonJS is converted to ES modules with rolldown at vendoring time.
  • Types are vendored too (the package’s own or DefinitelyTyped’s), and tsconfig.json is set up so the editor finds them.
  • A shared, content-addressed cache makes adding the same package to a second app instant. npm’s and pnpm’s caches are used when they have the file, and --offline works from caches alone.

What you can add

SpecWhat it is
npm:date-fns, npm:date-fns@4An npm package from its own sources, file by file, as the registry has it. Its dependencies come too.
jsr:@std/path@^1A JSR package, through JSR’s npm registry.
canvas-confetti, three@0.170An npm package as one browser-ready module per package, from jsDelivr’s +esm build.
https://esm.sh/d3@7Any URL, from any CDN.
font:inter:400,600A font, from Fontsource, with its CSS.

Then import it by name, as you would with npm:

import { format } from "date-fns";
import confetti from "canvas-confetti";

The name is resolved by the import map in sluurp-deps.json. A package’s own imports of other packages are resolved there too, each in its own scope, so two versions of one package can live side by side.

Compared with npm and package.json

npm with package.jsonSluurp
The list of what you usepackage.jsonsluurp-deps.json: the import map, versions, and a hash of every file
The lock filepackage-lock.json, pnpm-lock.yamlthe same sluurp-deps.json
Where the code livesnode_modules/, installed on each machine, not committedvendor/, committed with your app
What’s keptthe whole tarball of every dependencyonly the files your pages can reach
Installing after a clonenpm install, which needs the networknothing: the files are already there
Running ita bundler (Vite, webpack) turns it into browser codethe browser imports the files; the server bundles them when it starts
CommonJSthe bundler converts it on every buildconverted to ES modules once, when the package is added
Install scriptspostinstall runs code on your machinenever run
Checking the filesnpm audit checks versionssluurp vendor verify checks every file against the registry’s tarball

The idea is Deno’s: import by name, with no node_modules and no install step. The difference is that the files are kept with your app. A clone runs as it is, offline, and what runs in production is byte for byte what you tested.

Compared with Node, Deno and Bun

Node, Deno and Bun are JavaScript runtimes: you write the server in JavaScript and they run it. Sluurp is the server, written in Rust. Your JavaScript is the app: its pages and components, and the small pieces of server logic it needs.

Node, Deno, BunSluurp
What you writethe whole server: routing, database access, sign-in, uploadsyour app; the database, API, sign-in, files, realtime, jobs and admin are built in
Server coderuns in V8 or JavaScriptCore, with the runtime’s APIsserver functions, hooks and jobs run in a fresh QuickJS sandbox per call
What server code can reachthe file system, the network, process, native modulesctx: your data, with its rules; the web’s standard globals, fetch to public addresses among them; no file system, no process
TypeScriptDeno and Bun run it; Node strips typescompiled as it’s served, with no configuration
Packagesnpm, and JSR in Denonpm and JSR, vendored for the browser
Deployingthe runtime, your code, node_modules and a databaseone binary and your app folder

Limitations

  • Packages are for the browser. sluurp add vendors code that runs in pages and islands. A package written for Node doesn’t work, whether it needs fs, net, child_process or Buffer, or is a native addon.
  • Server code isn’t Node. Server functions, hooks and jobs run in QuickJS, with 64 MB and 5 seconds per call. There’s no Node API, no file system and no WebAssembly; fetch reaches public addresses only. QuickJS interprets JavaScript rather than compiling it, so heavy computation is several times slower than in V8. See QuickJS: small, safe, fast enough.
  • No install scripts. A package that builds something when it’s installed (postinstall, node-gyp) won’t have what it built.
  • No package.json workflow. There are no scripts, no workspaces and no npm run. Sluurp’s commands do the work: serve, test, static, push.
  • Some packages resist ES modules. A CommonJS package that loads its code at run time, with require(variable), can’t be converted fully. Use its ES module build, or a CDN’s +esm build.
  • Packages come from the public registries. npm and JSR by name, and anything else by URL; there’s no private registry support yet.

For code the browser shouldn’t carry and QuickJS can’t run, keep it as a program of its own, in whatever runtime suits it. It can read and write your data through the REST API and tell Sluurp what happened with events.

In production

At startup the server bundles each page’s modules with rolldown. Bundles are code-split and minified, and include workers and new URL(…) assets. The result is cached on disk by content and precompressed with brotli and gzip. In development, your own modules are served unbundled; libraries are pre-bundled (see Hot reload).

A budget

budget.json
{ "first-load-kb": 320, "pages": { "/signup.html": 140 } }

/_sluurp/bundle/report.json shows how much each page downloads (compressed) before it can run, and flags pages over budget. Load code that only one page needs with import() from that page.