Changelog

All versions and downloads Jump to Added | Changed | Fixed | Security

Unreleased [Not released yet]

Part 2 of 11. Previous part

Added (62)

  • The crawl of wisp build and wisp build --static checks the #id of a link, as handleMissingId of SvelteKit. A link such as /docs#intro or #intro to a page that the crawl wrote must name an id of that page, or the name of an <a>. If not, the build fails: error /page links to /docs#intro, but no element has the id intro. Text after :~: in the fragment is cut off. A page and an id give one line. Tag and attribute names in any case count, so <A HREF> is a link too. #top (in any case), an empty fragment and a link to a page that the crawl did not write pass. --allow-dead-links (WISP_DEAD_LINKS=warn) only warns. The check runs at build, so a request does no added work.
  • A page and a +server.rs (or mod server) can share a path, as in SvelteKit. A GET, HEAD or form post from a browser (accept names text/html) or from Wisp's client (x-wisp) gets the page. Other requests, and every put, patch and delete, get the endpoint. Both answers send vary: accept, x-wisp, and CACHE keeps them apart, with no CDN header. Every endpoint method of the route passes the page's guards, and the layouts' guards and its own guards too. Before, the build refused a get beside a page, and a post beside a page with actions. Only such routes run the test.
  • Mode files for .env, as Next.js and Vite have: wisp::env and public_env read .env, then .env.development (dev mode) or .env.production (other modes), then .env.local, then .env.development.local or .env.production.local. A later file wins, and a variable of the process wins over all of them. Dev mode is WISP_DEV. The build reads the production files in a release build. A scaffold already ignores .env.*.
  • A kept CACHE_PUBLIC answer sends cache-control: public, s-maxage=<CACHE>, stale-while-revalidate=<CACHE_STALE>, so a CDN can keep it, as the ISR header of Next.js. With CACHE_STALE 0 it has no stale-while-revalidate. A page whose locale comes from a cookie or Accept-Language gets no such header. No such header when the app has a before or after hook, or when the page or its layouts have a guard (SIGNED_IN, MIDDLEWARE, RATE_LIMIT, CORS). The same holds when a +server.rs has a before function or a guard. Those run on each request and a CDN skips them. Set your own cache-control then. There is also no such header when the answer depends on accept: a page that shares its path with an endpoint, or a #[derive(Rest)] list. A CDN that skips vary can send one answer to the wrong client. The app's own cache-control wins. A plain CACHE page sends no such header, because a CDN that keys on the URL can send its copy to a signed-in visitor. Wisp writes the header once when it keeps the answer, so a request does no added work.
  • wisp::stale_tag("posts"): marks the pages CACHE keeps under a tag as stale and does not delete them, as revalidateTag(tag, "max") of Next.js. The next request gets the stale page at once and starts one refresh per page and worker thread in the background. On an error, the stale page stays until its CACHE and CACHE_STALE time ends. It deletes the wisp::cache_tagged answers and PPR shells with the tag. A lookup has no added cost.
  • +template.wisp: a layout inside the layout of its folder that the browser makes fresh on each navigation to another path, as template.js of Next.js. The server writes it in <wisp-template style="display:contents" id="wt…:/path">, wisp.js replaces the element when the id changes, and live.js starts the islands in it again. A form answer on the same path keeps it. A folder cannot hold a +template.wisp and an @slot. An app with no template has no server cost. Gzip bytes (gate js step): live.js 12_321 (-3: the template check adds bytes, and the console hint about an effect loop is shorter), wisp.js 10_180 (no change).
  • <meta name="wisp-errors" content="/errors"> in src/app.html: each page loads /_app/c/errors.js, which sends uncaught errors and unhandled promise rejections to that route as JSON with sendBeacon, as the client handleError hook of SvelteKit. A report has kind, message, source, line, col, stack and path. URLs in message, source and stack lose their query and hash, and the report has no cookies or form values. A page load sends at most 5 reports, one per message and place. An app without the meta loads no file for it.
  • bench/edge/deno-prof.sh and bench/edge/deno-unit.sh: Linux helpers that count the instructions of each request of Deno, by bucket and by unit cost. Each run checks itself against perf stat and marks each fit OK or FAIL. CALLGRAPH=1 adds the callers of memmove, the inclusive cost of each wasm function (by index) and the bytes of one request and its answer.
  • bench/edge/patch-ab.sh: an instruction A/B of one patch on Deno /. MIX=<path>,<path> runs one oha for each path at the same time. A mix row has a spread of about 4%, so it cannot resolve a change under about 1%.
  • First paint works out a ?? b (Js::nullish). Before, a hole with ?? painted nothing on the server, and the browser drew it.
  • A sitemap over 50 000 addresses or 50 MiB is a sitemap index at /sitemap.xml with child files /sitemap-1.xml to /sitemap-10.xml (the protocol of sitemaps.org). The index lists at most 10 files (500 000 addresses). An edge build makes one file of 50 000 addresses, with no index. wisp build --static writes the child files. A sitemap under the limit is the same as before.
  • <img blur>: a blurred copy of the image shows while it loads, as placeholder="blur" of next/image. wisp build writes a WebP of at most 16 pixels with cwebp. A release build puts it in style as a background data URI of at most 300 characters. The request does no work for it, and the page gets no script. A blur tag with no static/ or src/lib file is a build error.
  • Conditions on redirects, rewrites and headers in [package.metadata.wisp], as has and missing of Next.js: has:cookie:name, has:header:name=value, has:host:example.com, missing:query:name. A rule applies only when all of its conditions hold. A redirect with a cookie, header or host condition must use 302 or 307. Such a condition makes a rewritten page private, and adds Vary on a header rule. An app with no conditions runs the same code as before.
  • wisp::cache_tagged(key, secs, &["posts"], || async { .. }): the same as wisp::cache, and wisp::revalidate_tag("posts") also forgets the answer. An answer made across that call is not kept.
  • The sitemap logs once when it skips addresses over the limit of 10 files (500 000 addresses). The log line names the count it skips, and the route that went over first when the count of addresses is the cause. On an edge build the sitemap skips them with no log.
  • wisp dev swaps an edit of a number or bool literal in the app's Rust into the running app with no compile, as for string literals. An integer with no suffix swaps up to 127, an integer with a suffix in the range of its type, a float when it is finite. Any other value compiles.
  • {:#try}…{:await}…{:catch e}…{:/try}: {:await} shows while an {:#await} in the try body waits on the first draw, as the pending snippet of Svelte's <svelte:boundary>. Later awaits show their own pending branch. The server renders {:await}. A misplaced {:await} is build error W0120.
  • import { building, settled } from 'wisp' in browser code. building is always false, because no browser code runs where the build evaluates a page. settled() returns a promise that resolves after the pending state updates are drawn. Both are filled in at build: an app that does not import them pays no byte.
  • +layout.js (or +layout.ts) next to a +layout.wisp: the browser load of the layout, as +page.js is the page's. A load gets parent(), which gives the data of the +layout.js loads above, merged outermost first ({} with none). A load that does not call parent() does not wait. A layout that stays across a navigation runs its load again only when a route param or a part of the URL that it read changed, invalidate() or invalidateAll() hit it, or a layout above ran again and it read parent(). Else it keeps its last data. Only an app with a +layout.js loads /_app/c/parent.js. $types.d.ts has LayoutLoad.
  • #[remote(prerender)]: wisp build and wisp build --static run the function once per input (fn name_inputs()) and write the answers as JSON files. A call reads the file of its argument. The server does no work per call. Every answer is public, so the function goes in src/*.rs (a page block is a build error).
  • public_env = true in [package.metadata.wisp]: the server reads the PUBLIC_* variables once at its start and writes them in each page head as JSON (16 KiB at most, else the server does not start). Browser code reads them with import { env } from 'wisp'. An edge target refuses the key.
  • version_poll = 60 in [package.metadata.wisp]: a release build serves /_app/version.json, and its pages read it every 60 seconds while they show and set updated when a new build is out. The poll module loads only with this key.
  • #[remote(form)]: a form command of src/*.rs that browser code gets as a form object. <form action="?/add" method="post" use:add> posts with JSON answers. add.pending, add.result and add.fields.text.issues() show the post. Without JavaScript, the form posts as an action form.
  • wisp dev loads a page without wisp.js (CSR = false, scripts = false) whole after a rebuild. Before, it stayed old. A form redirect gives beforeNavigate the type goto in use:enhance too, as in a form object.
  • wisp.js leaves a link or post form with a target, or a <base target>, to the browser. target="" or _self keeps it.
  • wisp new writes rust-toolchain.toml, which pins the Rust version of the Docker image. The deploy page has a "Reproducible builds" section, and a new page /docs/profiling covers Server-Timing, the devtools, wisp build --analyze, CPU profiling and instructions per request. Tests generate the code twice and export the test app twice and check the bytes.
  • wispweb.dev shows benchmark and token numbers of the newest release only. bench/suites.tsv names each suite, the summary file that carries its stamp (release and commit) and a hash of the numbers the site shows. A suite not measured at the newest release shows "Not measured at X yet." The gate step cargo run -q -p wisp-gate stamps checks the stamps, the hashes, the docs pages and the repo texts. stamps --stale lists the suites to measure again, and the release workflow runs it.
  • Browser API: beforeNavigate gets type (link, goto, popstate, leave) and willUnload. disableScrollHandling(), getAbortSignal(), and the transition events introstart, introend, outrostart, outroend (only to an element with an on: handler for them). wisp dev keeps the scroll across a reload.
  • The client router loads a page whose head has <meta name="wisp-csr" content="off"> whole.
  • Template errors have a stable code (W0101 to W0120) and a help: line under the caret. The codes are on /docs/errors. The wisp_check MCP tool returns the code.
  • A rustc error in generated template code shows at the .wisp line, also when its own line has no mark, with a generated code at note. A dev panic in generated code names the .wisp line the same way.
  • wisp_shared::semver: Version, newest_two and stamp, the one version order and stamp reader of the CLI, the gate and the docs site build.
  • Queue::drop_dead(id) deletes a dead job from a queue. It returns false for a job that is not dead or not there. Before, the queue had no call to delete a dead job.
  • Build plugins make files from other files before the app compiles. build_plugins = ["name glob out [ext=E] [timeout=S] [version=V] -- program args"] in [package.metadata.wisp] runs an outside tool on each file that the glob matches. {in} and {out} in the arguments are file paths. With no {in} the tool reads the file on stdin, and with no {out} it writes the result on stdout. wisp_build::run_with(&[wisp_build::Plugin { .. }]) in build.rs does the same with a Rust function. The plugins run in order, so an output can be the input of a later plugin. An output in src/ or static/ is part of the app. The build keeps each output in .wisp/plugins/ and runs a tool again only when its input, its path or its command changed. After a tool upgrade, change version= or delete .wisp/plugins. A plugin never overwrites a file that exists and that no plugin made, and two plugins cannot make one output. A command has a time limit of 30 seconds (timeout= sets 1 to 600). A failed plugin stops the build with a message that names the plugin and the file, and wisp dev keeps the last good build. wisp dev rebuilds when a file in a glob folder changes. The server runs none of this. The example is examples/plugins.
  • wisp deploy init tor --unix makes Tor connect to the app over a Unix socket. torrc gets HiddenServicePort 80 unix:/run/<app>/app.sock, the env file gets WISP_LISTEN=unix:/run/<app>/app.sock in place of HOST and PORT, and deploy/tor/<app>-unix.conf is a systemd drop-in (RuntimeDirectory, Group=debian-tor, UMask=0007). The default stays a local port. Only tor takes the flag.
  • The torrc of wisp deploy init tor lists HiddenServiceEnableIntroDoSDefense, HiddenServiceMaxStreams, HiddenServiceMaxStreamsCloseCircuit and HiddenServicePoWDefensesEnabled as comments. Each block names its page in the Tor docs. Wisp has no proof-of-work code: tor does that work.

Changed (61)

  • src/tests.rs needs no mod tests; line. The build adds it as #[cfg(test)] mod tests at the crate root, so use crate::App; works in it. When main.rs or lib.rs declares mod tests, the build does not add it again. With wisp::app!() in lib.rs, the library has the tests. A main.rs that also calls wisp::app!() has them too. A mod x; in the added file is src/x.rs, not src/tests/x.rs. So when src/tests.rs declares a mod x; with no #[path] at its top level, the build does not add the module and shows a warning. wisp dev and wisp check show it too. With strict = true it is an error. A folder src/tests/ of fixtures changes nothing. Then declare mod tests; in the crate root. Before, the build compiled a src/tests.rs that the crate root did not declare as a module of the app, also in release builds.
  • On Linux, a page or Body::Bytes body of 16 KiB or more goes in one send with its head: one sendmsg on epoll, one OP_SENDMSG on io_uring. Before, the head and the body went in two sends. On /list of the native-rank app (34092 bytes, loopback, kernel 6.8.12), each response took 1 tcp_sendmsg call and 1 TCP segment, not 2. After a short send, the next send starts at the first byte that did not go. At start, io_uring checks that the kernel lists OP_SENDMSG and sends a test head and body with it. When either fails, the two sends stay, and the stderr line adds , no OP_SENDMSG (<cause>).
  • prefixes of [package.metadata.wisp] takes only a string for each value, as prefix does. A bare value (a = b) is no value. Before, it read as the text b.
  • The Pages warning for static/ names the cause of each entry that reaches the worker: more than 99 entries, or a name over 100 characters. Only the first cause asks for folders.
  • wisp dev always listens on the dev port and sends each request to the app, which listens on a loopback port of its own. A save that compiles starts the new app beside the old one. The front sends new requests to the new app once it answers. Then the old app stops after the requests it has (WISP_DRAIN). Before, a save stopped the app first, and requests during the restart failed. A new app that fails to start leaves the old one, and the terminal says The app of the build before still serves the requests. An app with saved tables opens its data folder only after the old app stopped, and the front holds new requests meanwhile. A held request waits at most 70 s, or WISP_DRAIN and 23 s when that is longer. The front passes Host, Origin and the X-Forwarded-* headers to the app as the client sent them, and names the client in x-wisp-peer with a token of the run. A debug build takes that header only with the token and from a loopback peer, so cx.peer() is the client. For the app, the front no longer adds X-Forwarded-* headers. A request for the app with no body keeps both connections open for the next request when the answer has a length. The front keeps at most 8 idle connections to the app. It keeps one only when no byte and no close wait on it after the end of the answer. Method names are case-sensitive, as in the app: head is not HEAD, so the front reads the body of its answer. Under --https, the front adds x-forwarded-proto: https for a client that is not loopback or that sends a forwarding header. The front serves only a Host that is an IP address, localhost, a *.localhost name or one in WISP_DEV_ORIGIN, else 403 (against DNS rebinding). This holds for the app, its WebSockets and event streams, and each proxy target. Init side effects (timers, wisp::spawn loops, jobs) run in both apps while they overlap, from the start of the new app until the old app stops. After the new app listens, that is up to 10 s for the health check, then WISP_DRAIN and 3 s. Under wisp dev with a HOST other than loopback, the app signs with a key of its own process, not .wisp/secret (WISP_DEV_FRONT). [package.metadata.wisp.dev.proxy] rules apply on the next build, with no restart.
  • In [package.metadata.wisp], scripts, public_env, strict and typecheck take only true or false. A quoted value such as "false" is an error with the same text for each key: Cargo.toml: scripts = "false" in [package.metadata.wisp]: write true or false (true is the default). version_poll takes only a number with no quotes. Before, scripts, public_env and version_poll accepted a quoted value, and strict and typecheck did not.
  • A page of an fs store list can hold fewer than limit keys while more exist, when the scan finds expired keys. Walk until the cursor is None. Before, a page held limit keys when that many live keys existed.
  • wisp lsp shows each parse error of a .wisp file, up to 100, not only the first. The parse continues at the next tag or block tag after an error, and the editor shows each block that is never closed. A later parse error may be a result of an earlier one. wisp build and wisp check still stop at the first error, with the same text. Before, a parse error stopped the parse, so it was the only problem of the file.
  • The server reads /proc/sys/fs/nr_open, /proc/self/cgroup, memory.max and /proc/meminfo at start with one read each. Before, /proc/meminfo took 8 reads and each file took a statx and a second read. The io_uring start proof checks the large request against one 32881-byte pattern. Before, it built a copy of the request and a copy of the response (320 KiB). The proof checks the same bytes. Method: the wisp app of bench/native-rank in WSL2 on 4 CPUs, start to first response. Page faults (perf stat -e page-faults, median of 5 runs, with the taskset that pins the CPUs): 451 before, 377 after. System calls (strace -f, one request): 638 before, 622 after. Cold start (60 alternating pairs): median 2.357 ms before, 2.309 ms after. The new start was faster in 37 pairs. The instructions of a warm request (bench/native-rank/ir.sh, 3 runs) stay the same: 2183.0 for / and 2381.0 for /json (the build of commit a67cafa97).

Next part (3 of 11)

Source: CHANGELOG.md