The web:: Library
The text a web page and a URL are made of: HTML escaping, percent-encoding, query strings and URLs, as plain functions over strings. The library is opt-in: a host registers it with jai::stdlib::register_web(engine), apart from register_all, so an engine that never serves a page carries none of it. The standalone runner and the jai_spider host register it, so every sample on this page runs as it is (the spider:: samples reach the network, so they show their calls without running them).
HTML text
web::html_escape(s) makes text safe to put in a page: it writes & < > " ' as & < > " ' (the set PHP's htmlspecialchars escapes with ENT_QUOTES), so the result holds in element text and in a quoted attribute alike, and leaves every other byte, UTF-8 included, as it is. web::html_unescape(s) reads character references the way a browser reads them in text: the 2231 HTML5 named references (é, ≪⃒), the legacy names a browser also takes without the ; (©, & the longest name that fits wins, so ¬it; is ¬it;), and numeric references, é or é with the ; optional, written out as UTF-8. A reference to 0, a surrogate or a value past U+10FFFF becomes U+FFFD, and 0x80 to 0x9F read as their windows-1252 characters (– is –), as in a browser. An unknown name, a & that starts no reference and anything else stay exactly as written, so web::html_unescape(web::html_escape(s)) == s for every s. Both return the argument's kind: a utf8_string in, a utf8_string out.
string name = "Tom & Jerry's <b>show</b>";
print(`<p title="${web::html_escape(name)}">${web::html_escape(name)}</p>`);
print(web::html_unescape("café © 2026 🙂 – � &bogus; AT&T"));
print(web::html_unescape(web::html_escape(name)) == name);
utf8_string word = "café";
print(type_of(web::html_escape(word)));<p title="Tom & Jerry's <b>show</b>">Tom & Jerry's <b>show</b></p>
café © 2026 🙂 – � &bogus; AT&T
true
utf8_stringPercent-encoding
Two encodings, each with its decoder. web::url_encode(s) is RFC 3986 percent-encoding, for a path segment or any other part of a URL: the unreserved characters A-Z a-z 0-9 - . _ ~ stay as they are and every other byte of the string, each byte of a UTF-8 character included, is written %XX in upper-case hex (PHP's rawurlencode). web::form_encode(s) is the application/x-www-form-urlencoded encoding a form submits and a query string carries: A-Z a-z 0-9 * - . _ stay, a space is +, and every other byte is %XX (PHP's urlencode). web::url_decode turns each %XX back into its byte, and web::form_decode does that and reads + as a space. A % that is not followed by two hex digits is kept as written, as a browser keeps it, so decoding never fails; the result is the decoded bytes, which need not be valid UTF-8.
string title = "Snap Jaw/Social? 50% off & more";
print(web::url_encode(title));
print(web::url_decode(web::url_encode(title)) == title);
print(web::url_decode("100%25 sure, 100% sure"));
print(web::form_encode(title));
print(web::form_decode("two+words+%26+more"));
print(web::url_decode("a+b"), " ", web::form_decode("a+b"));Snap%20Jaw%2FSocial%3F%2050%25%20off%20%26%20more
true
100% sure, 100% sure
Snap+Jaw%2FSocial%3F+50%25+off+%26+more
two words & more
a+b a bQuery strings
web::parse_query(s) reads a=1&b=two+words (a leading ? is skipped) into a map<string, string>: each key and value is form-decoded, a key without = has an empty value, an empty key is dropped, and a key that repeats keeps its last value. web::parse_query_all(s) keeps every value instead, a map<string, var> of array<string> in the order they came. web::build_query(m) writes a map back: keys and values form-encoded, joined with &, in the map's key order (a map orders its keys, so the same map always builds the same query). A value is a string, a number or a bool (written as to_string writes it); an array repeats its key once per element; a null value leaves its key out. Any other value raises web::build_query: the value of 'm' is a map (or ... is not text, a number, a bool or an array of them).
map<string, string> q = web::parse_query("?page=2&sort=new+first&tag=a&tag=b&draft");
print(to_json(q));
map<string, var> all = web::parse_query_all("tag=a&tag=b&tag=c+d");
print(to_json(all));
print(web::build_query({"q": "jai script", "page": 2, "tags": ["a", "b c"], "draft": false, "skip": null}));
print(to_json(web::parse_query(web::build_query({"k&": "v=1", "x": "a b"}))));{"draft":"","page":"2","sort":"new first","tag":"b"}
{"tag":["a","b","c d"]}
draft=false&page=2&q=jai+script&tags=a&tags=b+c
{"k&":"v=1","x":"a b"}URLs
web::parse_url(s) splits an RFC 3986 URI reference into a web::url: scheme, user, password, host, port (an int, 0 when the URL names none), path, query and fragment, each as written, nothing decoded. A reference without a scheme or a host (/search?q=jai, //cdn.example/x, page.html#top) parses too, and an IPv6 host keeps its brackets. A port that is not a number from 0 to 65535, or a bracket that does not close, raises web::parse_url: 'http://example.org:99999/' has a malformed host or port. A web::url is a value: change its fields and to_string() joins them back, writing // and the authority only when there is a host, a user or a port, and ? and # only before a non-empty query and fragment. It writes the parts as they are: encode a path segment with web::url_encode and a query with web::build_query first.
web::url u = web::parse_url("https://ada:pw@snapjaw.example:8443/posts/7?sort=new#comments");
print(u.scheme, " | ", u.user, " | ", u.password, " | ", u.host, " | ", u.port, " | ", u.path, " | ", u.query, " | ", u.fragment);
u.path = "/posts/8";
u.query = web::build_query({"sort": "old", "page": 2});
u.user = "";
u.password = "";
print(u.to_string());
web::url relative = web::parse_url("/search?q=jai#top");
print("[", relative.scheme, "] [", relative.host, "] ", relative.port, " ", relative.path, " ", relative.query, " ", relative.fragment);
web::url v6 = web::parse_url("http://[::1]:8080/");
print(v6.host, " ", v6.port);
web::url built;
built.scheme = "https";
built.host = "example.org";
built.path = "/a b";
print(built.to_string());
try { web::parse_url("http://example.org:99999/"); } catch (e) { print("caught: ", e); }https | ada | pw | snapjaw.example | 8443 | /posts/7 | sort=new | comments
https://snapjaw.example:8443/posts/8?page=2&sort=old#comments
[] [] 0 /search q=jai top
[::1] 8080
https://example.org/a b
caught: web::parse_url: 'http://example.org:99999/' has a malformed host or portHTTP with spider::
A script fetches from a URL with spider::catch and sends to one with spider::sling. Each call runs one request to its whole answer and returns a spider::Response. Like web::, the client is opt-in: a host registers it with jai::stdlib::register_spider(engine), installs the HTTP stack it runs on and grants the network permission (The HTTP transport). The standalone runner and jai_spider do all three.
spider::Response page = spider::catch("https://api.example.org/scores?game=snapjaw");
if (page.ok()) {
var scores = page.json();
print("top score: ", scores[0]["points"]);
}
spider::Response saved = spider::sling({"player": "ada", "points": 420}, "https://api.example.org/scores");
print(saved.status, " ", saved.header("Location"));
spider::Response gone = spider::catch("https://api.example.org/scores/7",
{"method": "DELETE", "headers": {"Authorization": "Bearer " + token}, "timeout": 5});spider::catch(url[, options]) sends a GET; spider::sling(content, url[, options]) sends a POST carrying content. Both have plain names, the same calls with the URL first as most HTTP clients spell them:
spider::get(url[, options])isspider::catch(url[, options]).spider::post(url, content[, options])isspider::sling(content, url[, options]).
The content is sent as it is when it is text (text/plain; charset=utf-8), and as JSON otherwise (application/json: a map, an array, a number, a bool or null, written as to_json writes it). The options map takes:
| Option | Meaning |
|---|---|
method | GET, HEAD, POST, PUT, PATCH, DELETE or OPTIONS, any case (catch defaults to GET, sling to POST) |
headers | a map of header names to text, number or bool values; it overrides the defaults (User-Agent: jaiscript-spider/VERSION, Accept: */*, the content's type) |
timeout | seconds for the whole call, redirects included (default 30) |
type | how the content is sent: "json", "text", "form" (a map as application/x-www-form-urlencoded, as web::build_query spells it) or a media type such as "text/csv" for text content |
redirects | how many redirects to follow, 0 to 100 (default 5); 0 answers the redirect itself |
An unknown key, or a value of the wrong kind, raises before anything is sent (spider::catch: unknown option 'retries' (method, headers, timeout, type, redirects)), and so does a header name that is not a token, a header value holding a line break or NUL, and a header the transport writes itself (Host, Content-Length, Transfer-Encoding, Connection: spider::catch: header 'Host' is the transport's to set).
spider::Response
An HTTP answer: what a client call returns, and what a jai_spider handler answers with. An error status is an answer like any other, not an error: ok() tells them apart.
| Member | What it is |
|---|---|
status | the status code (int) |
headers, header(name) | a map<string, string>; a received answer's names are lowercased, and a repeated header is joined with ", "; header(name) reads any spelling, "" when absent |
cookies | the Set-Cookie lines, one per cookie (array<string>) |
body, json() | the body's bytes; from_json of them |
ok() | true for a 2xx status |
url | the URL that answered, after any redirects ("" for a response a script built) |
spider::Response(status, body) | a new one (both optional: 200 and an empty body) |
header(name, value) | sets a header, replacing one of the same name in any spelling; returns the response, so calls chain. A name that is not a token, or a value holding a line break or NUL, raises Response.header: 'N' is not a header name, or its value holds a line break or NUL |
set_cookie(name, value[, options]), clear_cookie(name[, options]) | adds a Set-Cookie line, the value percent-encoded; options max_age, expires (unix seconds), path ("/"), domain, secure, http_only (true), same_site ("Lax", "" leaves it out); clear_cookie sets one with Max-Age=0. The name is a token and path, domain and same_site hold no ; or control character, so no option adds an attribute of its own (Response.set_cookie: option 'path' holds a ';' or a control character) |
A response is a value: assigning one copies its headers and cookies, and r.headers["X-Trace"] = id writes that response's own map.
Redirects, timeouts and errors
A 301, 302, 303, 307 or 308 answer with a Location is followed, up to redirects times, against the URL that answered (a relative Location resolves as a browser resolves it). As browsers do, a 303 turns any method but HEAD into a GET without a body, a 301 or 302 turns a POST into a GET, and a 307 or 308 sends the same request again. Authorization and Cookie headers are dropped when a redirect leaves the origin they were given for, and a redirect from https to http is refused. The answer's url says where the call ended.
What stops a call raises a catchable error naming the call, the URL and the reason:
| Error | When |
|---|---|
spider::catch: http://example.org/slow: timed out after 5 s | the call took longer than its timeout |
spider::catch: http://127.0.0.1:1/: cannot connect to 127.0.0.1:1 | the transport's reason: no connection, an unresolved host, a certificate that did not verify, an answer cut short (the words depend on the transport) |
spider::catch: http://example.org/a: more than 5 redirects | the redirects outnumbered the option |
spider::catch: https://example.org/: a redirect from https to http (http://example.org/) is refused | a redirect would drop TLS |
spider::catch: 'ftp://example.org/' is not an http or https URL | the URL is relative or of another scheme |
spider::catch: no HTTP transport installed: the host provides one with engine::http_transport() | the host registered the client and installed no transport |
Insufficient privilege: network. | the host has not granted the network permission |
A call blocks the script until its answer arrives, inside a coroutine too; a host that must not wait gives the script a short timeout. A parallel body cannot call spider:: (each call touches the network the host shares).
In parallel bodies
Every web:: function is pure: it reads its argument and returns a new value. They all run in parallel bodies, each worker calling its own copy, and give the same results as the serial loop.
array<string> titles = ["one & two", "<three>", "four five"];
array<string> slugs = parallel_transform(titles, [](string t) { return web::url_encode(web::html_escape(t)); });
print(to_json(slugs));["one%20%26amp%3B%20two","%26lt%3Bthree%26gt%3B","four%20five"]