The web:: Library​

Host libraries, not language. The jaiscript runner and jai_spider register both libraries on this page. An engine embedded in another host has neither until that host calls jai::stdlib::register_web(engine) (which register_all leaves out) and, for the client, jai::stdlib::register_spider(engine) with an HTTP stack and the network permission (The HTTP transport).

jai_spider is the web server host: it runs a script as a site, calling the script's handler once per HTTP request and sending back what the handler returns. Its routes, requests, sessions and SQLite are on its own page; this page covers the two libraries a script calls: web:: text functions and the spider:: HTTP client. Pages made from files are HTML Templates.

Part 1: web:: text functions​

Every web:: function is pure, so all of them run in parallel bodies.

HTML text​

web::html_escape(s) writes & < > " ' as &amp; &lt; &gt; &quot; &#39; (PHP's htmlspecialchars with ENT_QUOTES) and nothing else. The result is safe in element text and in a quoted attribute, and nowhere else (where escaping is not enough).

web::html_unescape(s) reads character references as a browser does in text: the 2231 HTML5 names, the legacy names without their ; (longest match wins, so &notit; is ¬it;), and numeric references with the ; optional. A reference to 0, a surrogate or past U+10FFFF becomes U+FFFD; 0x80 to 0x9F read as windows-1252. Anything else stays as written, so unescaping an escaped string gives the string back. Both functions return the argument's kind: a utf8_string in, a utf8_string out.

jaiscript
string name = "Tom & Jerry's <b>show</b>";
print(`<p title="${web::html_escape(name)}">`);
print(web::html_unescape("caf&eacute; &copy 2026 &#x1F642; &#150; &#0; &bogus; AT&T"));
print(web::html_unescape(web::html_escape(name)) == name);
utf8_string word = "café";
print(type_of(web::html_escape(word)));
output
<p title="Tom &amp; Jerry&#39;s &lt;b&gt;show&lt;/b&gt;">
café © 2026 🙂 – � &bogus; AT&T
true
utf8_string

Percent-encoding​

web::url_encode(s) encodes a path segment or any other URL part (RFC 3986, a space is %20); web::form_encode(s) encodes a form body or query string (a space is +). web::url_decode(s) and web::form_decode(s) read them back. The exact byte sets: encoding and query strings.

Decoding never fails: a % without two hex digits after it is kept as written. The decoded bytes need not be valid UTF-8.

jaiscript
string title = "Snap Jaw/Social? 50% off & more";
print(web::url_encode(title));
print(web::form_encode(title));
print(web::url_decode("100%25 sure, 100% sure"));
print(web::url_decode("a+b"), " ", web::form_decode("a+b"));
output
Snap%20Jaw%2FSocial%3F%2050%25%20off%20%26%20more
Snap+Jaw%2FSocial%3F+50%25+off+%26+more
100% sure, 100% sure
a+b a b

Query strings​

web::parse_query(s) reads a query into a map<string, string> where a repeated key keeps its last value, web::parse_query_all(s) keeps every value as an array, and web::build_query(m) form-encodes a map in its key order. Every rule: encoding and query strings.

jaiscript
print(to_json(web::parse_query("?page=2&sort=new+first&tag=a&tag=b&draft")));
print(to_json(web::parse_query_all("tag=a&tag=b&tag=c+d")));
print(web::build_query({"q": "jai script", "page": 2, "tags": ["a", "b c"], "draft": false, "skip": null}));
output
{"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

URLs​

web::parse_url(s) splits an RFC 3986 URI reference into a web::url value with the fields scheme, user, password, host, port (an int, 0 when absent), path, query and fragment.

  • Nothing is decoded going in or encoded coming out: encode a path segment with web::url_encode and a query with web::build_query before storing them.
  • A reference without a scheme or host (/search?q=jai, //cdn.example/x, page.html#top) parses too. An IPv6 host keeps its brackets.
  • A port outside 0 to 65535, or a bracket that does not close, raises the error below.
  • to_string() joins the fields 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.
jaiscript
web::url u = web::parse_url("https://ada:pw@[::1]:8443/posts/7?sort=new#comments");
print(to_json([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());
try { web::parse_url("http://example.org:99999/"); } catch (e) { print(e); }
output
["https","ada","pw","[::1]",8443,"/posts/7","sort=new","comments"]
https://[::1]:8443/posts/8?page=2&sort=old#comments
web::parse_url: 'http://example.org:99999/' has a malformed host or port

Part 2: HTTP with spider::​

Each call runs one request to its whole answer and returns a spider::Response. It blocks the script until then, inside a coroutine too, and a parallel body cannot call it. The two sending calls take their arguments in opposite orders:

CallArgument orderDefault method
spider::catch(url[, options])URLGET
spider::get(url[, options])the same call, plain nameGET
spider::sling(content, url[, options])content, then URLPOST
spider::post(url, content[, options])URL, then contentPOST

This sample reaches the network, so it is shown without output:

jaiscript
spider::Response page = spider::catch("https://api.example.org/scores?game=snapjaw");
if (page.ok()) { print("top score: ", page.json()[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});

Text content is sent as it is (text/plain; charset=utf-8); any other value (a map, an array, a number, a bool, null) is sent as to_json writes it (application/json). The options map takes:

method, headers, timeout (seconds, default 30), type and redirects (default 5). Their values: request options.

spider::Response​

An HTTP answer: what a client call returns, and what a jai_spider handler builds to answer a request. An error status is an answer, not an error: ok() tells them apart. A response is a value: assigning one copies its headers and cookies.

MemberWhat it is
status, ok()the status code (int); true for 2xx
body, json()the body's bytes; from_json of them
header(name)a header by any spelling of its name, "" when absent
set_cookie(name, value[, options])adds a Set-Cookie line, the value percent-encoded

Every member and cookie option: spider::Response members.

Cookies and sessions: what is and is not protected​

jaiscript
spider::Response r = spider::Response(200, "hi");
r.set_cookie("session", "abc 1", {"max_age": 3600});
r.set_cookie("theme", "dark", {"secure": true, "http_only": false, "same_site": "Strict"});
for (auto line : r.cookies) { print(line); }
output
session=abc%201; Path=/; Max-Age=3600; HttpOnly; SameSite=Lax
theme=dark; Path=/; Secure; SameSite=Strict
  • On by default: every cookie, the jai_spider session cookie included, is HttpOnly (page script cannot read it) and SameSite=Lax (a browser leaves it off cross-site form posts and subrequests).
  • Off by default: Secure. Pass "secure": true (on a session store, secure_cookie = true) once the site is served over HTTPS.
  • No CSRF tokens. Neither spider:: nor jai_spider issues or checks one. SameSite=Lax still sends the cookie on a cross-site link or other top-level GET, so a GET handler must not change state, and a form that needs more than Lax carries a token the application makes and checks.
  • A handler's plain string is served as HTML, unescaped. jai_spider sends a returned string as text/html, and a backtick string's ${...} does not escape. Return a template(...), whose splices are escaped, or pass each value through web::html_escape.

Redirects and errors​

A 301, 302, 303, 307 or 308 answer with a Location is followed up to redirects times, a relative Location resolving against the URL that answered. As in a browser:

  • 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;
  • a redirect from https to http is refused.

Every failure is a catchable error that starts with the call's name (the reference has each full text):

  • Before anything is sent: an unknown or wrong-kind option (spider::catch: unknown option 'retries' (method, headers, timeout, type, redirects)); a header name that is not a token, or a value holding a line break or NUL; a header the transport writes itself (Host, Content-Length, Transfer-Encoding, Connection); a URL that is not absolute http or https; a host that installed no transport; Insufficient privilege: network. without the permission.
  • During the call: spider::catch: http://example.org/slow: timed out after 5 s, more than 5 redirects, a redirect from https to http (http://example.org/) is refused, or the transport's own reason (cannot connect to 127.0.0.1:1: no connection, an unresolved host, a certificate that did not verify, an answer cut short).