What changed in every version of Wisp, and where to get the CLI, the crates and the editor packages.
Unreleased
[Not released yet][You are on this version]
Added
wisp::fetch_stream_public(req): fetch_stream for a URL a visitor named; connects only to public addresses; at most 8 open at once, apart from fetch_public's 16 turns (503 too many public fetch streams at once).
wisp::fetch_public(req, hops): fetch_follow for a URL a visitor named; every call, the first too, goes only to public addresses, at most 16 at once.
wisp::Hop: the on_fetch hook is fn(&mut Request, Hop), told which hop of a chain it sees (hop.n) and whether a redirect took it to another origin (hop.moved).
Prerender and export follow links: a PRERENDER page that a prerendered page links to is rendered too (its params need no entries()), and a link answering 400 or more fails the build (--allow-dead-links or WISP_DEAD_LINKS=warn warns, --no-crawl or WISP_NO_CRAWL=1 follows no link).
Changed
json::Value is a 16-byte struct, no longer an enum: numbers and strings of up to 14 bytes sit inline, an empty array or object holds no heap, and the parser makes each array and object at its final size. A 1 MB body of "":0, peaked at 22 MB (21x), now under 8 MB; [0,0,...] 42x to 8x (under 16x for nested one-item arrays and one-member objects, [[0],...], [{"":0},...]); a 10 KB body of records 7.5x to 4.2x, parsed in the same time (86 us, was 88). Read it with as_*, as_number, as_object, as_array_mut/as_object_mut, into_array/into_object or match v.kind() (json::Kind); make one with Value::from, Value::number or Value::NULL. A number is always JSON number text now (Value::number checks it).
wisp::on_fetch takes |req, hop| (was |req|).
Upstream::respond() passes a 3xx location on; a 1xx or a status outside 200 to 599 becomes a 502, and a 204, 205 or 304 goes out with no body.
wisp::state::<T>() returns Result<&T>: a type nobody provided is a logged 500 (wisp::state::<Db>()?), not a panic.
wisp::every with a zero period logs it and runs every second instead of panicking.
wisp-gate has a text step (also in fast and the full gate): a raw control byte other than tab, LF or CR in a tracked text file of either repo fails it. Escaping them fixed two regexes that held a raw backspace where b was meant: bench/rank/rank.mjs (its replace never matched and now runs) and the docs tests/tips.test.mjs (now skips hidden sr elements only).
Fixed
The dev access log and the 5xx log line show the status that is sent: a status no client reads (0, an interim 1xx, 1000 up) is logged as the 500 that goes out.
If-None-Match takes one W/ prefix only (RFC 9110 8.8.3): W/W/"x" no longer matches "x", and W/* is not * (it answered a 304).
A multipart boundary quoted on one side only ("a or a") is refused (RFC 2046 5.1.1); it was read as a.
Security
The docs site sends strict-transport-security: max-age=31536000 (no includeSubDomains) from static/_headers.
fetch_stream and fetch_stream_public: at most 256 streams open at once (503 too many fetch streams at once); fetch_stream docs say it checks no address. fetch_stream asks for accept-encoding: identity; Upstream::respond() keeps a coding the upstream sends anyway, with vary: accept-encoding, on the built-in server, so a gzip body is no longer served as if plain.
wisp::fetch_follow: a 307 or 308 that would send a body to another origin is a 502 (the body was for the first server). The on_fetch hook learns hop.moved, so a key it adds no longer follows a redirect to another server; the docs' example adds it only when !hop.moved.
wisp::fetch reads a chunked reply as it arrives, each byte once: a server splitting its reply into pieces that end in an empty line no longer makes the reader decode it all again per piece (quadratic CPU). The head is parsed once; a sized reply is held in one allocation of its size; chunk sizes are checked with no sum that can wrap on 32-bit targets.
Calls to addresses a visitor named (/_img remote pictures, fetch_public, redirects to another origin) run at most 16 at once, so visitors cannot make the process hold more than 16 replies of 16 MB.
The public-address check refuses every IANA special-purpose range that is not globally reachable, IPv6 forms that reach IPv4 included: 6to4 2002::/16, Teredo and the rest of 2001::/23, NAT64 local-use 64:ff9b:1::/48, 192.88.99.0/24, site-local fec0::/10, 100::/64, 3fff::/20, 5f00::/16.
Upstream::respond() sends x-content-type-options: nosniff, and content-security-policy: sandbox for any upstream type but event streams, JSON, NDJSON, plain text, bytes, PDF and raster images (not SVG), so upstream HTML or SVG never runs script on the app's origin.
JSON bodies hold at most 200 000 values (was 1 000 000), counted on the derive's direct path too, so a body of tiny values ([[],[],...]) stays near 15 MB of memory.
Edge fetch: fetch ids skip ones a stream still reads after they wrap; a status outside 100 to 599 is a 502.
Header values with a control byte other than tab (C0 controls, DEL) are refused in responses (dropped and logged) and in HTTP/2 requests (stream reset, as HTTP/1 already answered 400), pseudo-headers included.
HTTP/2: a WINDOW_UPDATE that grants room when nothing was sent since the last one spends the idle-frame budget (a flood ends with ENHANCE_YOUR_CALM); the DATA byte count behind the body-rate check is a u64, so it cannot wrap on 32-bit targets.
WebSocket: a message sent in pieces (a frame or fragments) must make progress every 10 s and come whole at 1 KB/s after that, or the socket closes with 1008, on the built-in server, HTTP/2 and raw edge connections.
redirect(..) with a control byte (but tab) left inside the location is a 500, not a response with a bad location.
A kept (CACHE) answer with its own content-encoding says vary: accept-encoding.
OAuth: Provider::redirect must be https (or loopback http) with no fragment; in dev without ORIGIN the callback uses the Host only when it is loopback; an https OIDC issuer's discovered endpoints must be https.
Dev mode on an address other than loopback (a debug build with HOST=0.0.0.0) signs with a key of its own process, never the .wisp/secret a checkout or image may carry.
Cookies are Secure (the session, flash and OAuth ones __Host-) with no setting, except in dev, for plain http to localhost or a loopback address, an onion, or a proxy named by WISP_CLIENT_IP_HEADER that sends x-forwarded-proto: http; one warning names a Secure cookie that plain http will not return.
x-forwarded-proto and x-forwarded-host are read from the trusted end of the header (WISP_CLIENT_IP_DEPTH, counted from the right), and x-forwarded-proto is believed only when WISP_CLIENT_IP_HEADER names a proxy.
HTTP/2: a host header beside :authority must name the same host, or the request is malformed.
Backoff (login lockout) keeps its lockouts under a flood of other keys: when the table is full, unlocked keys go first and then the newest lockouts, so a flood of made-up names drops its own, never an older lockout.
Wisp 1.0.0-pre.3
[You are on this version]
Brotli for assets and baked pages, assets from other sites by URL with exact-URL script CSP, {@json}, wisp::fetch_follow, cors rules, wisp dev --https, WISP_ALLOWED_HOSTS, safe SVG and more upload kinds, edge host speed work, wisp upgrade, and security hardening across the runtime, templates and uploads.
Added
wisp upgrade [--check]: installs the newest release and, in an app, sets the tag of its Wisp git dependencies and updates Cargo.lock. wisp dev, wisp build and wisp new print one line when the CLI or the app's locked wisp-web-rt is older than the newest release. The lookup runs at most once a day on a background thread, only in the CLI (never in the server), and is off in CI and with WISP_NO_UPDATE_CHECK=1.
wisp new pins the Wisp dependencies of a new app to the release tag of the CLI.
The workspace version is 1.0.0-pre.3, so the CLI and the runtime report it.
CLI by noun and verb: wisp route, component, recipe, npm (add, list, remove), wisp deploy init docker, wisp client, wisp component list. A bare group prints its help and a wrong verb suggests the nearest one.
Offline all mode: the service worker fetches ahead every page that is the same for everyone, without cookies, when idle (5 MB cap, not on data saver), drops changed pages on update and removes itself when /service-worker.js is gone.
Release builds leave out the static export, the prerenderer and the Lambda runtime unless built for them (wisp build --static, --target lambda).
Benchmarks: TechEmpower database workloads on PostgreSQL, web workload tests (form, multipart, table, stream, cookie, csrf, errors, hooks, gzip, sse, file, routes, h2), a js-framework-benchmark entry, Lighthouse and Core Web Vitals across the token apps.
Forgejo Actions (.forgejo/workflows, mirrored to .github/workflows): CI, publish dry run, release, docs deploy and scheduled audit, advisory and MSRV checks. deny.toml, CHANGELOG.md, issue and pull request templates.
Brotli: build-time br copies of embedded text assets, wisp.js, live.js, the PWA worker and manifest, and baked pages, served when Accept-Encoding takes br. accept-encoding is a known header, read without a scan.
Assets from other sites by URL: fonts.txt takes cdn and https sources, and fonts, scripts, styles and media named by https URL in templates, the shell, CSS and module imports add their origins to the matching CSP directive at build. Scripts are allowed by exact URL, not by CDN origin (App::CSP_SOURCES). A script src with an empty or trailing-slash path adds nothing.
{@json expr} as the whole body of a script the browser does not run (JSON-LD, application/json, importmap).
URL holes after a written https://host/ in <script src>, an SVG script's href and <base href>: each hole is one percent-encoded piece of the path or query.
wisp::fetch_follow(req, n): follows up to n redirects, each as its own call with the same limits.
cors = [...] in [package.metadata.wisp]: CORS by path ("/api/[...p] https://app.example.com"), preflight answered whatever the route says, no const CORS needed.
Response::file_in_code(dir, name): file_in for directories only the app writes, serving .js, .css and .wasm with their own types.
wisp dev --https and https dev-proxy targets (a private root certificate from SSL_CERT_FILE).
Lint a11y-inline-handler: wisp check and the build warn on inline event handler attributes; use on:click.
on:load on <script src type="wisp/idle"> and wisp/interaction scripts runs when the script has loaded.
The nearest +error.wisp answers unmatched paths, with the layout guards of its folder run first.
WISP_ALLOWED_HOSTS and wisp::allow_hosts: a request with another Host, or none, is a 421 (DNS rebinding). Nothing is checked when unset, and the check shares one test with request_id.
Tauri v2 compatibility: docs for a static frontend (wisp build --spa) and for serve_on inside Tauri's runtime; the service worker registration fails quietly where the webview has none.
Uploads: Svg (strict parser, allowlist re-serialize, sandboxed response); more kinds sniffed by bytes (HEIC/HEIF, BMP, ICO, TIFF, JXL, FLAC, M4A, MOV, MKV, Office, ODF, EPUB with macro kinds, JSON, CSV); Image takes BMP and ICO and caps pixels.
scripts = false in [package.metadata.wisp]: pages name no script, script-src 'none', and the build refuses browser code. wisp deploy init tor writes torrc lines and an env file for tor with systemd.
WISP_LISTEN=unix:<path>: listen on a unix socket. WISP_SPOOL_LIMIT caps the upload spool. The Lambda bootstrap.zip is deflated when smaller.
Edge: the bridge hands the wasm its request head, and a fixed short reply's text is reused, not decoded again. The Workers bridge sends a reply with content-encoding as encoded, so workerd does not compress it twice. Faster Bun.serve, Deno raw and node:http paths.
Release builds minify component, lib and load JavaScript modules (token-checked, falls back to the source).
CI: a Forgejo host runner with timeouts and a fork-PR guard, a release crates job that skips without CARGO_REGISTRY_TOKEN, and cargo-deny per published crate.
Changed
The wisp CLI prints plain technical output (help, new, build, dev, deploy, ui, scaffold page).
Built-in UI restyled on the docs site palette (OKLCH tokens, light and dark). The fallback error page is self-contained at 1.6 KB of CSS (was 3.7 KB).
Critical CSS inlines the rules of the whole page, :hover and :active too.
The release profile strips symbols: the demo is 2,104,920 to 1,770,184 B on Linux, same machine code.
Dev JavaScript, the dialog CSS and the dev error page CSS are only in debug builds.
An empty value counts as unset for every environment knob (WISP_IO= no longer stops the server).
Install command and app dependency URL: https://wisp.ar0.eu/, which redirects to the Forgejo repository.
Speed: small integers below 100 written inline, the short-text escape, a JSON array read, #[validate(email)] in one table pass (GET /page 33,121 to 26,657 instructions per request, callgrind on the VPS); io_uring keep-alive connections hold no buffer while idle (RSS after load 14.5 to 8.9 MB).
The gate prints per-step times and runs the test binaries side by side after one build.
The dev dialog has one top row (kind, version, Close), one hover wash, no bar edge and no Esc key. Its output is a named region.
io_uring: requests to routes that never wait are answered on the driver, as on epoll; receive buffers are lent by need and grow by a quarter; idle spare write buffers are freed; the sweep timer is set once a second.
Multipart: the splitter moves a cursor (no memmove of the rest at each part edge) and skips 16 bytes at a time looking for the boundary. A part head is scanned once.
Startup: the fsync thread starts at the first durable write, and a ring registers its buffer ring before mapping its queues, so a refused probe costs less.
Table retry waits by http::retry_clock (monotonic natively, the unix second on the edge).
Deno and Bun: the startup check asks the app over an in-memory raw connection, so no request-path code compiles at startup.
A static/ made after the first build is watched, and an empty static/ counts as no static files.
A query argument of type String reads ?q=123 as text, on refresh as on GET.
Fixed
wisp dev recovers from a corrupt PDB (LNK1207) and from a broken package of the shared build cache (E0463, E0786, E0460, E0514).
wisp route add removes every folder it made, refuses a clashing route and writes files that fmt accepts. wisp new rejects contradictory flags and non-ASCII names. Undoing a broken edit clears the dev overlay.
Template style attribute holes are filtered and no ${} is allowed in <style>. The offline queue keeps no password. The service worker caches no redirected response and no reply that varies on more than Accept-Encoding. A cache storage error on a fetched-ahead page falls back to the network.
ui: Progress guards max 0 and clamps the meter; Radio sets aria-invalid on its inputs.
Dev dialog output: one box as wide as the longest line, scrollbars, focus ring, a named region.
Runtime: Range applies to GET only; many tiny chunks no longer give 413; a path past the head gives 414 and another HTTP version 505; no 100 Continue for a request with no host; the connection lingers on a pipelined close; reroute and rewrite keep params and the slash fallback; vary on CORS replies without an origin; HEAD gzips as GET; required params never match an empty segment; table device names.
Streams: finite streams drain on stop, stream tasks end when the client leaves, an SSE kind is read before the headers are cleared, Replay streams its lines, and the upload temp sweep runs on its own thread and covers per-process upload folders.
Wasm: the date header is the host's unix second (it was 1970). A failed table load retries on the edge, where http::seconds is 0.
Build and CLI: each-if borrows places; check_dist uses the app base; wisp dev watches target and dotfiles; crud model names and rest ids are validated; OpenAPI page operationIds; SPA rules after optional segments; deploy host patterns; seo lints read unquoted attributes and skip code in .md pages; a build backdates a static/ it made, so the next build does not rebuild.
wisp upgrade renames the running exe on Windows. An app on a path dependency gets no out-of-date notice.
io_uring: a sweep time that fired is dropped (the driver no longer turns on a ready timer).
A store whose fsync thread fails to start logs once and retries at the next write.
Bridge: a 400 from the raw path fails the startup check only when the app's own handle answers otherwise.
Security
Edge hosts: a reply whose head the app does not keep (one with Set-Cookie, or past the 64th) came to JS as -1, not u32::MAX, so the first such head was cached and sent with every later one (another user's Set-Cookie and status); each now carries its own. Same fix for wisp_current (a trap with no task fails the requests in flight), wisp::sleep past 24.8 days, wisp::edge::fetch (no redirect followed, as native wisp::fetch; error text cuts every quoted URL to its host; a stream read dropped mid-wait no longer loses or reorders a piece), and wisp::platform::call / wisp:<kind> (own names only).
Template holes: a style attribute hole, and a spread's style pair, keep only plain declarations; ${} is refused in <style>; data: and blob: documents are blocked in expression-chosen URLs on document tags.
The error page for unmatched paths runs the layout guards of its folder, so a guarded section no longer shows its error page unguarded.
wisp::fetch_follow calls a redirect to another origin only if it resolves to public addresses (a redirect to loopback, link-local or a private network is a 502), and sends only accept, accept-language, accept-encoding and user-agent (and content-type with a kept body) to the new origin.
URL holes cannot pick the host, scheme or user, and dot segments in a hole are encoded.
A JSON body in a script whose code holds the end tag is an error.
Response::file_in sends uploaded .js, .mjs, .css and .wasm files as application/octet-stream, and build and deploy refuse a symlink out of static/.
Signed cookies under WISP_BASE keep their name and base path (a __Host- name would reach every app on the host) and are readable over HTTPS.
Uploads: a zip kind always comes from the central directory (an ODF mimetype never hides Office or macro parts), any .bin under word/, xl/ or ppt/ is a macro part, Image refuses an unreadable size, spooled fds are closed and the spool is capped.
HTTP/2: a SETTINGS id applies once, and the owe anchor never resets on a little progress, so a dribbled window cannot pin a body. Stalled sends time out.
The private files of wisp dev --https are created new and never followed (mode 0600 holds). A live unix socket is never replaced, and only our own file is unlinked.
Release check: strict tag validation (no quote or flag from the API reaches Cargo.toml or cargo) and a 1 MB cap on the lookup. curl gets -- before the URL.
Dependencies: rustls 0.23.46, rustls-webpki 0.103.16, webpki-roots 1.0.11, tokio 1.54.1 (an advisory on AsyncFd in 1.53.1), ctutils 0.4.5 in the TFB database app (RUSTSEC-2026-0385).
A guarded form names a missing WISP_SECRET once on stderr and shows visitors a neutral message.
DNS rebinding: WISP_ALLOWED_HOSTS / wisp::allow_hosts (see Added).
Wisp 1.0.0-pre.2
[You are on this version]
The first public pre-release of Wisp: file routes, .wisp templates compiled to Rust, form actions, one binary. It installs from this repository by git; the crates are not on crates.io yet. The release page has the install steps. Changes after it are listed above.
Wisp is a pre-release: it installs from source with cargo today. The version, the tag and the archive names below come from the changelog and the release page, so they change with a release.
The wisp command builds a C library (ring), so every system needs Rust 1.89 or later and a C compiler and linker.
Windows
Prerequisites
Rust, from rustup-init.exe. Rust on Windows links with the Microsoft C++ build tools by default: install Build Tools for Visual Studio with the workload Desktop development with C++ (MSVC and a Windows SDK). Or use the GNU toolchain with MinGW-w64 (for example from MSYS2) on your PATH, and switch with rustup default stable-x86_64-pc-windows-gnu.
Install
Works now. Build it from source with cargo. It takes a few minutes.
cargo install --git https://wisp.ar0.eu/ wisp-web
To pin a release, add its tag (the newest tag is v1.0.0-pre.2).
wisp upgrade installs the newest release and sets the tag of an app's Wisp dependencies (from 1.0.0-pre.3). wisp changelog prints what changed since your version. Windows cannot replace a running wisp.exe ("Access is denied"): stop wisp dev and wisp lsp first, or rename it with ren %USERPROFILE%\.cargo\bin\wisp.exe wisp-old.exe and delete the old file later.
Uninstall
cargo uninstall wisp-web
Editor
Install wisp first, then pick your editor in Editors (VS Code builds from a clone).
Prebuilt archive
Not attached to a release yet. The release workflow builds wisp-x86_64-pc-windows-gnu.zip (the GNU ABI, not MSVC) from the next tag.
Not yet.scripts/install.ps1 is in the repository but not served at wispweb.dev/install.ps1, and it reads a release that does not exist yet.
Linux
Prerequisites
Rust, from rustup. A C compiler and linker: build-essential on Debian and Ubuntu (sudo apt install build-essential curl), gcc on Fedora (sudo dnf install gcc), or your distribution's equivalent.
curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh
Install
Works now. Build it from source with cargo. It takes a few minutes.
cargo install --git https://wisp.ar0.eu/ wisp-web
To pin a release, add its tag (the newest tag is v1.0.0-pre.2).
It installs the newest release and sets the tag of an app's Wisp dependencies (from 1.0.0-pre.3). wisp changelog prints what changed since your version.
Uninstall
cargo uninstall wisp-web
Editor
Install wisp first, then pick your editor in Editors.
Prebuilt archive
Not attached to a release yet. The release workflow builds wisp-x86_64-unknown-linux-musl.tar.gz from the next tag.
Not attached to a release yet. The release workflow builds wisp-aarch64-unknown-linux-musl.tar.gz from the next tag.
Not yet.scripts/install.sh is in the repository but not served at wispweb.dev/install, and it reads a release that does not exist yet.
macOS
Prerequisites
Rust, from rustup. The C compiler and linker come with the Xcode Command Line Tools.
xcode-select --install
curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh
Install
Works now. Build it from source with cargo. It takes a few minutes.
cargo install --git https://wisp.ar0.eu/ wisp-web
To pin a release, add its tag (the newest tag is v1.0.0-pre.2).
It installs the newest release and sets the tag of an app's Wisp dependencies (from 1.0.0-pre.3). wisp changelog prints what changed since your version.
Uninstall
cargo uninstall wisp-web
Editor
Install wisp first, then pick your editor in Editors.
Prebuilt archive
Not attached to a release yet. The release workflow tries to build wisp-x86_64-apple-darwin.tar.gz (best effort: the macOS link can fail, and then the release has no macOS archive).
Not attached to a release yet. The release workflow tries to build wisp-aarch64-apple-darwin.tar.gz (best effort: the macOS link can fail, and then the release has no macOS archive).
Not yet.scripts/install.sh is in the repository but not served at wispweb.dev/install, and it reads a release that does not exist yet.
Other install routes
Each archive of a release has a .sha256, and the release has one SHA256SUMS. See RELEASING.md.
State
What
Not yet
cargo binstall wisp-web reads the prebuilt archives, so it works once they exist.
Not yet
Homebrew, Scoop, winget. Draft manifests are in packaging/, unpublished.
Crates
Not on crates.io. They publish with the 1.0 release. Until then an app depends on this repository by git, and wisp new writes these lines for you.
Every editor uses wisp lsp (problems, hovers, completion, formatting), so install the command first. No editor package is in a store or registry yet: each installs from a clone of the repository, folder editors/. Editors has the detail.
Neovim add editors/nvim to the runtime path (a lazy.nvim snippet is in its README), then :TSInstall wisp rust javascript typescript css.
From source
Helix merge editors/helix/languages.toml, run hx --grammar fetch and hx --grammar build, then copy the queries (steps).
From source
Zed run zed: install dev extension and pick editors/zed.
From source
Sublime Text copy editors/sublime into a Wisp package folder and install the LSP package.
From source
JetBrains install LSP4IJ from the plugin marketplace, then import editors/jetbrains/lsp4ij-template.
From source
Emacs add editors/emacs to load-path, require wisp-ts-mode, then install the grammar with treesit-install-language-grammar.
From source
tree-sitter grammareditors/tree-sitter-wisp, the parser behind Neovim, Helix, Zed and Emacs.
Not published
Prettier plugineditors/prettier-plugin-wisp is not on npm yet, so npm install prettier-plugin-wisp does not work. It only runs wisp fmt --stdin, so use wisp fmt directly.