Unreleased [Not released yet]
Part 2 of 11. Previous part
Added (62)
- The crawl of
wisp buildandwisp build --staticchecks the#idof a link, ashandleMissingIdof SvelteKit. A link such as/docs#introor#introto a page that the crawl wrote must name anidof that page, or thenameof 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(ormod server) can share a path, as in SvelteKit. A GET, HEAD or form post from a browser (acceptnamestext/html) or from Wisp's client (x-wisp) gets the page. Other requests, and everyput,patchanddelete, get the endpoint. Both answers sendvary: accept, x-wisp, andCACHEkeeps 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 agetbeside a page, and apostbeside a page with actions. Only such routes run the test. - Mode files for
.env, as Next.js and Vite have:wisp::envandpublic_envread.env, then.env.development(dev mode) or.env.production(other modes), then.env.local, then.env.development.localor.env.production.local. A later file wins, and a variable of the process wins over all of them. Dev mode isWISP_DEV. The build reads theproductionfiles in a release build. A scaffold already ignores.env.*. - A kept
CACHE_PUBLICanswer sendscache-control: public, s-maxage=<CACHE>, stale-while-revalidate=<CACHE_STALE>, so a CDN can keep it, as the ISR header of Next.js. WithCACHE_STALE0 it has nostale-while-revalidate. A page whose locale comes from a cookie orAccept-Languagegets no such header. No such header when the app has abeforeorafterhook, or when the page or its layouts have a guard (SIGNED_IN,MIDDLEWARE,RATE_LIMIT,CORS). The same holds when a+server.rshas abeforefunction or a guard. Those run on each request and a CDN skips them. Set your owncache-controlthen. There is also no such header when the answer depends onaccept: a page that shares its path with an endpoint, or a#[derive(Rest)]list. A CDN that skipsvarycan send one answer to the wrong client. The app's owncache-controlwins. A plainCACHEpage 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 pagesCACHEkeeps under a tag as stale and does not delete them, asrevalidateTag(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 itsCACHEandCACHE_STALEtime ends. It deletes thewisp::cache_taggedanswers 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, astemplate.jsof 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.wispand an@slot. An app with no template has no server cost. Gzip bytes (gatejsstep): 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">insrc/app.html: each page loads/_app/c/errors.js, which sends uncaught errors and unhandled promise rejections to that route as JSON withsendBeacon, as the clienthandleErrorhook of SvelteKit. A report haskind,message,source,line,col,stackandpath. URLs inmessage,sourceandstacklose 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.shandbench/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 againstperf statand marks each fit OK or FAIL.CALLGRAPH=1adds 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.xmlwith child files/sitemap-1.xmlto/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 --staticwrites 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, asplaceholder="blur"of next/image.wisp buildwrites a WebP of at most 16 pixels with cwebp. A release build puts it instyleas abackgrounddata URI of at most 300 characters. The request does no work for it, and the page gets no script. Ablurtag with nostatic/orsrc/libfile is a build error.- Conditions on
redirects,rewritesandheadersin[package.metadata.wisp], ashasandmissingof 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 addsVaryon a header rule. An app with no conditions runs the same code as before. wisp::cache_tagged(key, secs, &["posts"], || async { .. }): the same aswisp::cache, andwisp::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 devswaps an edit of a number orboolliteral 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 thependingsnippet of Svelte's<svelte:boundary>. Later awaits show their own pending branch. The server renders{:await}. A misplaced{:await}is build errorW0120.import { building, settled } from 'wisp'in browser code.buildingis 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 browserloadof the layout, as+page.jsis the page's. A load getsparent(), which gives the data of the+layout.jsloads above, merged outermost first ({}with none). A load that does not callparent()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()orinvalidateAll()hit it, or a layout above ran again and it readparent(). Else it keeps its last data. Only an app with a+layout.jsloads/_app/c/parent.js.$types.d.tshasLayoutLoad.#[remote(prerender)]:wisp buildandwisp build --staticrun 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 = truein[package.metadata.wisp]: the server reads thePUBLIC_*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 withimport { env } from 'wisp'. An edge target refuses the key.version_poll = 60in[package.metadata.wisp]: a release build serves/_app/version.json, and its pages read it every 60 seconds while they show and setupdatedwhen a new build is out. The poll module loads only with this key.#[remote(form)]: a form command ofsrc/*.rsthat browser code gets as a form object.<form action="?/add" method="post" use:add>posts with JSON answers.add.pending,add.resultandadd.fields.text.issues()show the post. Without JavaScript, the form posts as an action form.wisp devloads a page withoutwisp.js(CSR = false,scripts = false) whole after a rebuild. Before, it stayed old. A form redirect givesbeforeNavigatethe typegotoinuse:enhancetoo, 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_selfkeeps it. wisp newwritesrust-toolchain.toml, which pins the Rust version of the Docker image. The deploy page has a "Reproducible builds" section, and a new page/docs/profilingcovers 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.tsvnames 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 stepcargo run -q -p wisp-gate stampschecks the stamps, the hashes, the docs pages and the repo texts.stamps --stalelists the suites to measure again, and the release workflow runs it. - Browser API:
beforeNavigategetstype(link,goto,popstate,leave) andwillUnload.disableScrollHandling(),getAbortSignal(), and the transition eventsintrostart,introend,outrostart,outroend(only to an element with anon:handler for them).wisp devkeeps 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 (
W0101toW0120) and ahelp:line under the caret. The codes are on /docs/errors. Thewisp_checkMCP tool returns thecode. - A rustc error in generated template code shows at the
.wispline, also when its own line has no mark, with agenerated code atnote. A dev panic in generated code names the.wispline the same way. wisp_shared::semver:Version,newest_twoandstamp, 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 returnsfalsefor 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 { .. }])inbuild.rsdoes the same with a Rust function. The plugins run in order, so an output can be the input of a later plugin. An output insrc/orstatic/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, changeversion=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, andwisp devkeeps the last good build.wisp devrebuilds when a file in a glob folder changes. The server runs none of this. The example isexamples/plugins. wisp deploy init tor --unixmakes Tor connect to the app over a Unix socket.torrcgetsHiddenServicePort 80 unix:/run/<app>/app.sock, the env file getsWISP_LISTEN=unix:/run/<app>/app.sockin place ofHOSTandPORT, anddeploy/tor/<app>-unix.confis a systemd drop-in (RuntimeDirectory,Group=debian-tor,UMask=0007). The default stays a local port. Onlytortakes the flag.- The
torrcofwisp deploy init torlistsHiddenServiceEnableIntroDoSDefense,HiddenServiceMaxStreams,HiddenServiceMaxStreamsCloseCircuitandHiddenServicePoWDefensesEnabledas 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.rsneeds nomod tests;line. The build adds it as#[cfg(test)] mod testsat the crate root, souse crate::App;works in it. Whenmain.rsorlib.rsdeclaresmod tests, the build does not add it again. Withwisp::app!()inlib.rs, the library has the tests. Amain.rsthat also callswisp::app!()has them too. Amod x;in the added file issrc/x.rs, notsrc/tests/x.rs. So whensrc/tests.rsdeclares amod x;with no#[path]at its top level, the build does not add the module and shows a warning.wisp devandwisp checkshow it too. Withstrict = trueit is an error. A foldersrc/tests/of fixtures changes nothing. Then declaremod tests;in the crate root. Before, the build compiled asrc/tests.rsthat the crate root did not declare as a module of the app, also in release builds.- On Linux, a page or
Body::Bytesbody of 16 KiB or more goes in one send with its head: onesendmsgon epoll, oneOP_SENDMSGon io_uring. Before, the head and the body went in two sends. On/listof the native-rank app (34092 bytes, loopback, kernel 6.8.12), each response took 1tcp_sendmsgcall 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 listsOP_SENDMSGand sends a test head and body with it. When either fails, the two sends stay, and the stderr line adds, no OP_SENDMSG (<cause>). prefixesof[package.metadata.wisp]takes only a string for each value, asprefixdoes. A bare value (a = b) is no value. Before, it read as the textb.- 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 devalways 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 saysThe 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, orWISP_DRAINand 23 s when that is longer. The front passesHost,Originand theX-Forwarded-*headers to the app as the client sent them, and names the client inx-wisp-peerwith a token of the run. A debug build takes that header only with the token and from a loopback peer, socx.peer()is the client. For the app, the front no longer addsX-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:headis notHEAD, so the front reads the body of its answer. Under--https, the front addsx-forwarded-proto: httpsfor a client that is not loopback or that sends a forwarding header. The front serves only aHostthat is an IP address,localhost, a*.localhostname or one inWISP_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::spawnloops, 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, thenWISP_DRAINand 3 s. Underwisp devwith aHOSTother 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,strictandtypechecktake onlytrueorfalse. 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_polltakes only a number with no quotes. Before,scripts,public_envandversion_pollaccepted a quoted value, andstrictandtypecheckdid not. - A page of an
fsstore list can hold fewer thanlimitkeys while more exist, when the scan finds expired keys. Walk until the cursor isNone. Before, a page heldlimitkeys when that many live keys existed. wisp lspshows each parse error of a.wispfile, 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 buildandwisp checkstill 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.maxand/proc/meminfoat start with onereadeach. Before,/proc/meminfotook 8 reads and each file took astatxand 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: thewispapp ofbench/native-rankin WSL2 on 4 CPUs, start to first response. Page faults (perf stat -e page-faults, median of 5 runs, with thetasksetthat 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).