Wisp 1.0.0-pre.3
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 (32)
wisp upgrade [--check | --undo | --force-undo]: installs the newest release and, in an app, sets thetagof its Wisp git dependencies, updates all four Wisp crates inCargo.lock, then runswisp checkandwisp update-docswith the new CLI. It savesCargo.toml,Cargo.lockandAGENTS.mdtotarget/wisp-upgradefirst and puts them back on any failure.wisp upgrade --undoputs them back later and refuses whenCargo.tomlchanged after the upgrade (--force-undoskips that check).cargo cleandeletes the saved files.--checkexits 1 when a newer release is out.WISP_RELEASES_URLsets the release list URL.wisp dev,wisp buildandwisp newprint one line when the CLI or the app's lockedwisp-web-rtis 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 withWISP_NO_UPDATE_CHECK=1.wisp newpins 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. wisp uicomponents: skeleton, table, datatable, empty, kbd, scroll, radio, progress, alert.- Offline
allmode: 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.jsis 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
brcopies of embedded text assets,wisp.js,live.js, the PWA worker and manifest, and baked pages, served whenAccept-Encodingtakesbr.accept-encodingis a known header, read without a scan. - Assets from other sites by URL:
fonts.txttakescdnand 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 scriptsrcwith 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'shrefand<base href>: each hole is one percent-encoded piece of the path or query. wisp::fetch_follow(req, n): follows up tonredirects, 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, noconst CORSneeded.Response::file_in_code(dir, name):file_infor directories only the app writes, serving.js,.cssand.wasmwith their own types.wisp dev --httpsand https dev-proxy targets (a private root certificate fromSSL_CERT_FILE).- Lint
a11y-inline-handler:wisp checkand the build warn on inline event handler attributes; useon:click. on:loadon<script src type="wisp/idle">andwisp/interactionscripts runs when the script has loaded.- The nearest
+error.wispanswers unmatched paths, with the layout guards of its folder run first. WISP_ALLOWED_HOSTSandwisp::allow_hosts: a request with anotherHost, or none, is a 421 (DNS rebinding). Nothing is checked when unset, and the check shares one test withrequest_id.- Tauri v2 compatibility: docs for a static frontend (
wisp build --spa) and forserve_oninside 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);Imagetakes BMP and ICO and caps pixels. scripts = falsein[package.metadata.wisp]: pages name no script,script-src 'none', and the build refuses browser code.wisp deploy init torwrites torrc lines and an env file for tor with systemd.WISP_LISTEN=unix:<path>: listen on a unix socket.WISP_SPOOL_LIMITcaps the upload spool. The Lambdabootstrap.zipis 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-encodingas 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
cratesjob that skips withoutCARGO_REGISTRY_TOKEN, andcargo-denyper published crate. wisp::fetch_stream_public(req):fetch_streamfor a URL a visitor named; connects only to public addresses; at most 8 open at once, apart fromfetch_public's 16 turns (503too many public fetch streams at once).wisp::fetch_public(req, hops):fetch_followfor a URL a visitor named; every call, the first too, goes only to public addresses, at most 16 at once.wisp::Hop: theon_fetchhook isfn(&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
PRERENDERpage that a prerendered page links to is rendered too (its params need noentries()), and a link answering 400 or more fails the build (--allow-dead-linksorWISP_DEAD_LINKS=warnwarns,--no-crawlorWISP_NO_CRAWL=1follows no link).
Changed (26)
- Docs: a
pub constthat many routes use sets one cache time for all of them, ascacheLifeprofiles of Next.js:const CACHE: u32 = crate::conf::HOURS;. - Known: the Cloudflare wasm of the bench app (
bench/rank/out/wisp-cf/app.wasm) is 463,900 bytes at 2362ec0d. The bench app routes/formand/tableadd 50,562 bytes at 1ecfd7d6 and 56,772 bytes at 2362ec0d (VPS Selfhosting2, rustc 1.99.1). Most of it is the code ofwisp::Table(Table::write, itsBTreeMaps, the store'ssaveandload). From 1ecfd7d6 to 2362ec0d the app without them grows 2,663 bytes (404,801 to 407,464), the app with them 8,873 bytes (455,363 to 464,236). An earlier line said 0 bytes at 1ecfd7d6: that build script did not readROUTES. - The
wispCLI 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,
:hoverand:activetoo. - 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 emptystatic/counts as no static files. - A query argument of type
Stringreads?q=123as text, on refresh as on GET. json::Valueis 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 withas_*,as_number,as_object,as_array_mut/as_object_mut,into_array/into_objectormatch v.kind()(json::Kind); make one withValue::from,Value::numberorValue::NULL. A number is always JSON number text now (Value::numberchecks it).wisp::on_fetchtakes|req, hop|(was|req|).Upstream::respond()passes a 3xxlocationon; 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>()returnsResult<&T>: a type nobody provided is a logged 500 (wisp::state::<Db>()?), not a panic.wisp::everywith a zero period logs it and runs every second instead of panicking.wisp-gatehas atextstep (also infastand 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 wherebwas meant:bench/rank/rank.mjs(its replace never matched and now runs) and the docstests/tips.test.mjs(now skips hiddensrelements only).wisp-gatehas anrt all-featuresstep (also infast):cargo test -p wisp-web-rt --all-features --lib. The testseo::tests::a_feed_and_og_tagsnow expects a PNG name with theog-pngfeature.
Fixed (21)
wisp devrecovers from a corrupt PDB (LNK1207) and from a broken package of the shared build cache (E0463, E0786, E0460, E0514).wisp route addremoves every folder it made, refuses a clashing route and writes files thatfmtaccepts.wisp newrejects contradictory flags and non-ASCII names. Undoing a broken edit clears the dev overlay.- Template
styleattribute 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 thanAccept-Encoding. A cache storage error on a fetched-ahead page falls back to the network. ui: Progress guardsmax 0and clamps the meter; Radio setsaria-invalidon its inputs.- Dev dialog output: one box as wide as the longest line, scrollbars, focus ring, a named region.
- Runtime:
Rangeapplies to GET only; many tiny chunks no longer give 413; a path past the head gives 414 and another HTTP version 505; no100 Continuefor a request with no host; the connection lingers on a pipelined close; reroute and rewrite keep params and the slash fallback;varyon 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,
Replaystreams its lines, and the upload temp sweep runs on its own thread and covers per-process upload folders. - Wasm: the
dateheader is the host's unix second (it was 1970). A failed table load retries on the edge, wherehttp::secondsis 0. - Build and CLI:
each-ifborrows places;check_distuses the app base;wisp devwatchestargetand dotfiles; crud model names andrestids are validated; OpenAPI pageoperationIds; SPA rules after optional segments; deploy host patterns;seolints read unquoted attributes and skip code in.mdpages; a build backdates astatic/it made, so the next build does not rebuild. wisp upgraderenames 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.
- 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.
wisp devno longer replaces the running app with an older binary.If-None-Matchtakes oneW/prefix only (RFC 9110 8.8.3):W/W/"x"no longer matches"x", andW/*is not*(it answered a 304).- A multipart
boundaryquoted on one side only ("aora") is refused (RFC 2046 5.1.1); it was read asa. - CI: the
checkjob ofci.ymland theverifyjob ofrelease.ymlinstall thewasm32-unknown-unknowntarget (scripts/ci-setup.sh wasm32-unknown-unknown). Without it, fivewisp-web --test clitests failed inwisp-gate faston the runner. - The
docs-deployworkflow cloneswisp-docswithgit clone. The checkout action could not find the default branch of that repository with the job token. - Release: the
aarch64-unknown-linux-muslarchive links again.scripts/ci-setup.shinstalls cargo-zigbuild 0.23.0, which skips the--fix-cortex-a53-843419linker argument that zig rejects. Thedocs-deployworkflow checks out Wisp atwisp, the path that thewisp-docsmanifests use. wisp dev --httpsand the HTTPS dev proxy lose no bytes when one socket read ends a full TLS record and holds more records. The session plaintext buffer filled, and the rest of the read was skipped, so the connection closed early.
Security (35)
- 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 forwisp_current(a trap with no task fails the requests in flight),wisp::sleeppast 24.8 days,wisp::edge::fetch(no redirect followed, as nativewisp::fetch; error text cuts every quoted URL to its host; a stream read dropped mid-wait no longer loses or reorders a piece), andwisp::platform::call/wisp:<kind>(own names only). - Template holes: a
styleattribute hole, and a spread's style pair, keep only plain declarations;${}is refused in<style>;data:andblob: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_followcalls 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 onlyaccept,accept-language,accept-encodinganduser-agent(andcontent-typewith 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_insends uploaded.js,.mjs,.cssand.wasmfiles asapplication/octet-stream, andbuildanddeployrefuse a symlink out ofstatic/.- Signed cookies under
WISP_BASEkeep 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
.binunderword/,xl/orppt/is a macro part,Imagerefuses 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 --httpsare 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.tomlor cargo) and a 1 MB cap on the lookup.curlgets--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
AsyncFdin 1.53.1), ctutils 0.4.5 in the TFB database app (RUSTSEC-2026-0385). - A guarded form names a missing
WISP_SECRETonce on stderr and shows visitors a neutral message. - DNS rebinding:
WISP_ALLOWED_HOSTS/wisp::allow_hosts(see Added). - The docs site sends
strict-transport-security: max-age=31536000(noincludeSubDomains) fromstatic/_headers. fetch_streamandfetch_stream_public: at most 256 streams open at once (503too many fetch streams at once);fetch_streamdocs say it checks no address.fetch_streamasks foraccept-encoding: identity;Upstream::respond()keeps a coding the upstream sends anyway, withvary: 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). Theon_fetchhook learnshop.moved, so a key it adds no longer follows a redirect to another server; the docs' example adds it only when!hop.moved.wisp::fetchreads 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 (
/_imgremote 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 of2001::/23, NAT64 local-use64:ff9b:1::/48,192.88.99.0/24, site-localfec0::/10,100::/64,3fff::/20,5f00::/16. Upstream::respond()sendsx-content-type-options: nosniff, andcontent-security-policy: sandboxfor 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.