Biblioteca estándar
Cliente HTTP
Cada request (http* y fetch) está gateado por net(host) — deny-by-default, incluso bajo run. HTTPS funciona de fábrica (rustls + root CAs del SO).
-- Doc example: the HTTP client is gated by net(host). A request to an undeclared
-- host is refused at the capability check — before any network call happens. Every
-- test below stops before the network: nothing here needs a server.
intent: "doc example: HTTP client capability gating"
require net("api.allowed.com")
require net("http://localhost:8545") -- a URL as the grant: this host, ONLY this port
task reach_undeclared()
give http_get("https://evil.example.com/data") -- host not declared → denied
task reach_other_port()
give http_get("http://localhost:9999/") -- same host, another port → denied
-- The message of an error, or nothing when the call did not fail.
task error_of(f)
try
f()
recover e
give e
give nothing
test "a request to an undeclared host is blocked (deny-by-default)"
assert_error(reach_undeclared)
test "an explicit port is part of the grant"
assert(contains(error_of(reach_other_port), "net(\"localhost:9999\")"))
test "credentials in a URL never reach an error message"
let e be error_of(() => http_get("https://ana:hunter2@evil.example.com/"))
assert(contains(e, "net(\"evil.example.com\")"))
assert(not contains(e, "hunter2"))
let e2 be error_of(() => http_get("http://ana:hunter2@/rpc"))
assert(contains(e2, "the URL has no host"))
assert(not contains(e2, "hunter2"))
test "an extra argument is an error, not silently dropped"
let e be error_of(() => http_get("https://api.allowed.com/", nothing, nothing, 5, "extra"))
assert(contains(e, "http_get() takes at most 4 arguments, got 5"))
test "multipart_encode builds the body and its content type (pure)"
let m be multipart_encode([
{"name": "title", "value": "Q3 report"},
{"name": "file", "filename": "q3.bin", "content_type": "application/octet-stream", "bytes": bytes([1, 2, 3])}])
assert_eq(keys(m), ["body", "content_type"])
assert_eq(type_of(body of m), "bytes")
assert(starts_with(content_type of m, "multipart/form-data; boundary="))
assert(contains(error_of(() => multipart_encode([{"name": "x"}])), "needs \"value\" or \"bytes\""))
test "fetch takes its options in a map: unknown keys and a trailing argument are errors"
let e be error_of(() => fetch("https://api.allowed.com/", {"pin": "00"}))
assert(contains(e, "unknown option \"pin\" (valid options: method, headers, body, form, timeout, tls_pin, attested)"))
let e2 be error_of(() => fetch("https://api.allowed.com/", {"timeout": 5}, 30))
assert(contains(e2, "the options map is the last argument"))
test "tls_pin must be a public key, checked before any connection"
let e be error_of(() => fetch("https://api.allowed.com/", {"tls_pin": "abcd"}))
assert(contains(e, "tls_pin is not a DER SubjectPublicKeyInfo"))
test "named arguments: leave out what you do not need (engine v0.6.45+)"
-- The host is not declared, so each call stops at the capability check — after its
-- arguments were read. A bad name or a duplicate fails before that.
let e be error_of(() => http_get("https://evil.example.com/", headers = {"accept": "application/json"}, timeout = 5))
assert(contains(e, "net(\"evil.example.com\")"))
let e2 be error_of(() => http(method = "PUT", url = "https://evil.example.com/x", body = {"a": 1}))
assert(contains(e2, "net(\"evil.example.com\")"))
assert(starts_with(error_of(() => http_get("https://api.allowed.com/", timout = 5)), "unknown parameter 'timout'"))
assert_eq(error_of(() => http_get("https://api.allowed.com/", {"a": "b"}, headers = {"a": "b"})), "duplicate argument 'headers'")
test "header() finds a header regardless of case"
let r be {"status": 429, "ok": false, "body": "", "json": nothing, "headers": {"Retry-After": "5"}}
assert_eq(header(r, "retry-after"), "5")
assert_eq(header({"content-type": "text/html"}, "Content-Type"), "text/html")
assert_eq(header(r, "x-missing"), nothing)
test "url_encode / url_decode follow RFC 3986 and decode strictly"
assert_eq(url_encode("café & tea?"), "caf%C3%A9%20%26%20tea%3F")
assert_eq(url_encode("a-b.c_d~e"), "a-b.c_d~e")
assert_eq(url_decode("caf%C3%A9%20%26%20tea%3F"), "café & tea?")
assert_eq(url_decode("a+b"), "a+b")
assert(contains(error_of(() => url_decode("%zz")), "malformed escape"))
assert(contains(error_of(() => url_decode("%FF")), "not valid UTF-8"))
test "html_select / html_text / html_unescape read real-world HTML"
let page be "<ul id=menu><li class=item data-id=7>Tea & cake<li class=item data-id=9>Coffee</ul><script>track()</script><p>Open<br>daily"
let items be html_select(page, "li.item")
assert_eq(length(items), 2)
assert_eq(tag of items[0], "li")
assert_eq((attrs of items[0])["data-id"], "7")
assert_eq(text of items[0], "Tea & cake")
assert_eq(html of items[0], "Tea & cake")
assert_eq(html_select(page, "table"), [])
assert_eq(html_text(page), "Tea & cake\nCoffee\nOpen\ndaily")
assert_eq(html_unescape("<b> & é"), "<b> & é")
assert(contains(error_of(() => html_select(page, "td[[")), "invalid CSS selector"))Hacer requests§
require net("api.store.com")
let r be http_get("https://api.store.com/products")
let url be "https://api.store.com/products"
let r2 be http_get(url, headers = {"x-api-key": secret("API_KEY")}, query = {"page": "1"})
let r3 be http_post(url, {"name": "Alice"}, headers = {"Authorization": bearer(secret("API_KEY"))}) -- a map → JSON body + Content-Type
let r4 be http(method = "PATCH", url = url, body = {"stock": 3}, timeout = 10) -- full control
Argumentos con nombre (motor v0.6.45+)§
Los builtins HTTP toman sus argumentos por nombre: http_get(url, headers = {…}, query = {…}, timeout = 10), http_post(url, body, headers = {…}, timeout = 30), http(method = "PUT", url = u, body = b), y lo mismo para http_bytes, http_put y http_delete. Dejá afuera lo que no necesitás — sin nothing de relleno. Los nombres son los de las firmas de abajo (method, url, headers, query, body, timeout, y form desde v0.6.46).
- Las llamadas posicionales siguen andando, y podés mezclar: primero los posicionales, después los nombres (
http_post(url, body, timeout = 5)). Un argumento posicional después de uno con nombre es el errorpositional argument after named argument. - Un nombre desconocido es un error que lista los válidos (
unknown parameter 'timout' — did you mean 'timeout'?; the parameters are: url, headers, query, timeout), y también pasar el mismo argumento por posición y por nombre (http_get(url, {…}, headers = {…})→duplicate argument 'headers'). - Más allá de dos argumentos opcionales posicionales, usá nombres.
http_get(url, nothing, nothing, 5)sigue corriendo, perosynsema checkavisahttp_get() takes at most two optional arguments by position; pass timeout by name (timeout = …) — the positional form goes away in v1. Lo mismo vale parahttp,http_bytes,http_postyhttp_put. No hay un mapa de opciones como el defetchporque acá sería ambiguo: el argumento después de la URL ya es un mapa — los headers, o un body JSON.
El body viaja según su tipo (engine v0.6.20+). Un map o una lista salen como JSON conContent-Type: application/json(salvo que mandes tu propioContent-Type); un texto sale tal cual, sin agregar content type; bytes salen crudos. Antes de v0.6.20 un map viajaba como su texto de display sin header y escribíasjson_encode(map)más el header a mano — eso sigue funcionando igual.
Las credenciales van en headers (un secret se materializa solo en el socket); un secret ligado a un host (require secret("K") for net("api.example.com"), motor v0.6.46+) también puede ir en la URL, la query, el body o un form — ver Secretos.
Hosts, puertos y credenciales en la URL (engine v0.6.29+)§
- Una URL es un grant válido, y un puerto explícito es parte de él.
require net("http://localhost:8545")se guarda comonet("localhost:8545")y cubre solo ese puerto;net("localhost")sigue cubriendo cualquier puerto. Esquema, path, query y credenciales se descartan del scope:require net("https://user:pw@api.x.com/v1?k=1")esnet("api.x.com"). Los hosts IPv6 funcionan (http://[::1]:8545/). user:pass@en la URL mandaAuthorization: Basic …, como curl y requests. Un headerAuthorizationque pases vos gana.- Las credenciales nunca llegan a un mensaje ni a la auditoría. Un request denegado nombra solo el host (
Capability not granted: net("evil.example.com")), y una URL sin host es el errorthe URL has no host, que no repite la URL. - Una URL con query y sin path (
http://h:8080?x=1) y una URL con#fragmentfuncionan; el headerHostlleva el puerto cuando no es el default.
Los tests del ejemplo comprueban el grant, los mensajes y la cantidad de argumentos sin tocar la red.
Timeout§
Cada builtin HTTP acepta un timeout en segundos opcional — timeout = … por nombre, o la clave timeout en el mapa de opciones de fetch — (default 30; ausente o inválido cae a 30 — nunca es error):
require net("api.store.com")
let url be "https://api.store.com/report"
let r be fetch(url, {"timeout": 60}) -- fetch takes its options in a map
let r2 be http(method = "GET", url = url, timeout = 120) -- slow API: wait up to 2 min
let r3 be http_get(url, timeout = 5) -- fail fast: 5s
Firmas (cada argumento de http* se puede pasar también como nombre = valor): fetch(url, {method, headers, body, form, timeout, tls_pin, attested}) · http(method, url, headers?, query?, body?, timeout?, form?) · http_get(url, headers?, query?, timeout?) · http_post(url, body?, headers?, timeout?, form?) · http_put(url, body?, headers?, timeout?, form?) · http_delete(url, headers?, timeout?) · http_bytes(method, url, headers?, query?, body?, timeout?, form?). Al vencer el timeout la respuesta vuelve con ok: false y el error del SO en error of r.
fetch con un mapa (motor v0.6.43+) es la forma principal: todas las claves son opcionales — method (default "GET"), headers, body (texto, bytes, o un mapa/lista que viaja como JSON), form (v0.6.46+, abajo), timeout (segundos, default 30), tls_pin y attested, con los mismos tipos y defaults que la forma posicional; una clave en nothing cuenta como ausente, salvo tls_pin y attested: ahí nothing es error, así un valor que falta nunca apaga el chequeo en silencio (para la validación de siempre se omite la clave). Después del mapa no va nada (fetch(url, {…}, 30) es error) y una clave desconocida es un error que lista las válidas.
La posicional fetch(url, method, headers, body, timeout) está deprecada (motor v0.6.45) y se va en v1. Hasta entonces sigue andando, y la primera llamada de un proceso imprime un aviso en stderr: [synsema] deprecated: fetch(url, method, headers, body, timeout) goes away in v1 — write fetch(url, {"method": …, "headers": …, "body": …, "timeout": …}). synsema check señala cada llamada cuyo segundo argumento es un texto literal o nothing, así las arreglás antes de correr.
tls_pin fija la clave del servidor: su SubjectPublicKeyInfo en hex, bytes o PEM PUBLIC KEY. En vez de validar el certificado contra las raíces del SO y el nombre del host, la conexión exige que la clave que presenta el servidor sea exactamente ésa (la firma del handshake se sigue verificando con ella); otra clave es el error tls_pin: the server public key (SPKI) does not match the pinned key. Necesita una URL https://. Así habla un cliente con un serve --attested, cuyo certificado está autofirmado con la clave atestada — ver Attestation. ws_connect acepta el mismo tls_pin en sus opciones (wss://).
attested ({program_sha, formats, measurements?, now?}) se conecta a un serve --attested sin conocer su clave: en una sola conexión lee /.well-known/attestation, exige que su clave sea la del handshake, verifica cada documento y el program_sha, y recién ahí manda tu request — que no sale nunca si algo falla. La respuesta trae attested. Los pasos están en Attestation; ws_connect también lo acepta.
En el embebido WebAssembly (synsema-wasm-web, donde el HTTP lo hace el host) tls_pin y attested fallan cerrado con un error: el transporte del host no puede fijar una clave ni leer la identidad por la conexión del handshake.
Un argumento de más es un error desde engine v0.6.29 (http_get() takes at most 4 arguments, got 5); antes se descartaba sin aviso.
La respuesta§
Un request devuelve un map:
status of r -- 200
ok of r -- true (200–299)
body of r -- raw text — always text (a binary body is lossy: see http_bytes)
json of r -- the parsed body when the response is JSON (its content type says so and it parses); otherwise nothing (v0.6.20+)
headers of r -- response headers, names exactly as the server sent them
error of r -- ONLY present when the transport failed (DNS, refused, timeout)
Exactamente esas claves — keys(r) en una respuesta normal es [status, ok, body, json, headers] (json llegó en v0.6.20 — en engines anteriores la clave no existe). Cuando falla el transporte, el map es {status: 0, ok: false, body: "", json: nothing, error} — con error y sin headers:
json of res el body parseado solo cuando el servidor dijo JSON y parseó; una respuesta de texto, HTML o binaria danothing, nunca un error. Decidí con eso, o seguí conjson_decode(body of r)bajotry/recoverpara un servidor que miente sobre su content type.json of rse lee con el mismo parser quejson_decode(engine v0.6.29+): un entero de cualquier tamaño queda exacto (un saldo uint256, un id de 64 bits — sin redondeo a float),-0es0, y un byte order mark al principio se saltea.NaN,Infinityy1e400no son JSON, así que un body que los trae dajson: nothing(el texto sigue enbody of r).error of rno existe en una respuesta normal, ni siquiera en un 404 o un 500 — leerlo esMap has no key 'error'. Decidí conok of r/status of r, o leelo conget(r, "error", nothing)(engine v0.6.29+), que danothingcuando la clave falta.- Los nombres de los headers conservan la capitalización del servidor (
Content-Typeen GitHub,content-typeen tu propioserve), así queget(headers of r, "Content-Type", nothing)lo encuentra solo si la capitalización coincide. Buscá uno conheader(r, name)(motor v0.6.45+), que ignora mayúsculas y minúsculas. Toma una respuesta o un mapa de headers (request.headersen un handler deserve) y danothingcuando el header no está:
let r be {"status": 429, "ok": false, "body": "", "json": nothing, "headers": {"Retry-After": "5"}}
print(header(r, "retry-after")) -- 5
print(header({"content-type": "text/html"}, "Content-Type")) -- text/html (a map of headers works too)
print(header(r, "x-missing")) -- nothing
Antes de v0.6.45 el manual mostraba un task header(r, name) escrito a mano; un task tuyo con ese nombre tapa al builtin, así que borralo cuando actualices.
Cuerpos binarios — http_bytes (engine v0.6.20+)§
body of r es siempre texto, y se pierde en binario. Para un PDF, una imagen o un protobuf, http_bytes(method, url, headers?, query?, body?, timeout?) tiene la firma de http y devuelve {status, ok, bytes, headers} — los bytes exactos que mandó el servidor, sin clave body. Mismo gate net(host).
Subir archivos — multipart_encode (engine v0.6.20+)§
let m be multipart_encode([
{"name": "title", "value": "Q3 report"},
{"name": "file", "filename": "q3.pdf", "content_type": "application/pdf", "bytes": pdf}])
let r be http_post(url, body of m, {"Content-Type": content_type of m})
multipart_encode(parts) → {body: bytes, content_type: "multipart/form-data; boundary=…"}; una parte es {name, value} (un campo de texto) o {name, filename, content_type?, bytes} (un archivo). Puro y determinista (el boundary se deriva de las partes). Un serve de Synsema del otro lado lo lee como form of request. No cubre streaming de archivos enormes — el body se arma en memoria.
Bodies de formulario — form = {…} (motor v0.6.46+)§
http, http_bytes, http_post, http_put y fetch aceptan un form: un map que se manda como body application/x-www-form-urlencoded (el codificador de oauth_basic), con ese Content-Type salvo que pongas el tuyo. Es lo que esperan los endpoints de token de OAuth y los formularios HTML clásicos.
require net("auth.example.com")
let r be http_post("https://auth.example.com/token", form = {"grant_type": "client_credentials", "scope": "read write", "n": 1, "renew": true})
let r2 be fetch("https://auth.example.com/token", {"method": "POST", "form": {"grant_type": "refresh_token"}})
-- sent as: grant_type=client_credentials&scope=read+write&n=1&renew=true
- Cada campo es texto, un número, un bool o un secret ligado (ver Secretos). Otros valores son errores:
form field "a" must be text, a number or a secret, got list;form field "a" is nothing; pass a value (a missing env var or map key?), or leave it out; un secret sin ligar da la línearequire secret("K") for net("…"). formes el body:bodyyformjuntos son el errorpass body or form, not both (form is the body, encoded as a form).http_post(url, form = {…})no necesitabody.- Pasalo por nombre.
http_getyhttp_deleteno lo aceptan (unknown parameter 'form'); para un form con otro método usáhttp(method = "…", url = u, form = {…}).
Las redirecciones no se siguen§
El cliente devuelve una redirección (3xx) como la respuesta — status y el header Location (header(r, "location")) — y tu programa decide si ir (con su propio permiso net). Una redirección nunca lleva un header ni un secret a un host que el programa no nombró. Un host que embebe el motor e implementa http recibe redirect: "error" cuando la request lleva un secret (motor v0.6.46+), y debería respetarlo — ver WebAssembly.
Un comodín sólo llega a direcciones públicas (motor v0.6.46+)§
require net sin scope, net("") y net(".example.com") llegan sólo a direcciones IP públicas. Quedan afuera loopback (127.0.0.0/8, ::1, 0.0.0.0), las redes privadas (10/8, 172.16/12, 192.168/16, CGNAT 100.64/10, ULA fc00::/7), link-local (169.254/16 con el endpoint de metadata de la nube 169.254.169.254, fe80::/10), multicast y los rangos de documentación (192.0.2/24, 198.51.100/24, 203.0.113/24). Antes de v0.6.46 net comparaba sólo el nombre, así que 127.0.0.1.nip.io — o cualquier nombre que resolviera adentro — llegaba a tus servicios internos (SSRF). El cliente resuelve una vez, chequea la dirección que va a usar y se conecta a esa, así que una respuesta de DNS no puede cambiar en el medio; un IPv4 mapeado o traducido en IPv6 (::ffff:127.0.0.1, 64:ff9b::7f00:1) cuenta como ese IPv4.
Para llegar a una dirección privada, nombrala:
require env("CONTROL_URL")
require net("10.0.0.0/8") -- a private range, named: covers 10.x.x.x and names that resolve inside
require net("db.internal:5432") -- an exact host reaches it wherever it resolves
require net(env("CONTROL_URL")) -- the host of a configured URL, named from there
require net("*") -- any host, but only public addresses
- Un host exacto (
net("db.internal"),net("localhost")) llega a donde resuelva. - Una dirección literal (
net("10.0.0.5")) o un CIDR (net("10.0.0.0/8")) cubre las direcciones escritas adentro. Un nombre que resuelve dentro de un CIDR se alcanza cuando además hay un permiso con comodín (net("*")+net("10.0.0.0/8")); un CIDR solo no cubre nombres. - Cuando la URL viene de la configuración, nombrala desde ahí:
require net(env("CONTROL_URL")).
Un comodín que se topa con una dirección privada no se conecta. Los builtins HTTP lo devuelven como cualquier falla de transporte — {status: 0, ok: false, …, error} — con la línea a escribir en error of r: `http_get: 127.0.0.1.nip.io resolved to 127.0.0.1, a private address; a net grant with a wildcard reaches only public addresses. Name it: require net("127.0.0.1.nip.io:47100"), its address or its CIDR (the scope can come from the configuration: require net(env("…_URL"))). tcp_connect, ws_connect y los builtins de RPC de blockchain lanzan el mismo mensaje como error. Cuando el comodín está en una capa por encima del programa, el mensaje nombra esa capa: the host ceiling (--sandbox/--cap-set) reaches only public addresses for it. The program cannot fix this; the host must name it: --cap-set net=127.0.0.1:47100, o the enclosing sandbox under block reaches only public addresses for it: name it there (net("127.0.0.1:47100"))`. Cada capa que tiene un comodín tiene que nombrar el host privado.
-- Doc example: a `net` grant with a wildcard reaches only public addresses (engine v0.6.46+).
-- Nothing here leaves the machine: a private address is refused before connecting, and the
-- named range points at a closed local port.
intent: "doc example: wildcard net grants and private addresses"
require net("*") -- any host, public addresses only
require net("127.0.0.0/8") -- loopback, named: its literal addresses (and, with the wildcard, names inside)
test "a wildcard does not reach loopback, private networks or the cloud metadata endpoint"
each u in ["http://10.0.0.5/", "http://192.168.1.1/", "http://169.254.169.254/latest/", "http://[::1]:8080/", "http://[fd00::1]/"]
let r be http_get(u, timeout = 2)
assert_eq(r.status, 0) -- a transport failure, never a connection
assert(contains(r.error, "a private address; a `net` grant with a wildcard reaches only public addresses"))
-- the message gives the line to write
assert(contains(http_get("http://10.0.0.5:5432/").error, "Name it: `require net(\"10.0.0.5:5432\")`"))
-- an IPv4 mapped in IPv6 counts as that IPv4
assert(contains(http_get("http://[::ffff:10.0.0.5]/").error, "resolved to 10.0.0.5"))
test "a named range reaches the address: the request is attempted"
let r be http_get("http://127.0.0.1:9/", timeout = 2) -- port 9 (discard): closed here
assert_eq(r.status, 0)
assert(not contains(r.error, "private address")) -- refused by the OS, not by the grantAplica a http/fetch y los builtins http_*, ws_connect (y cada reconexión), tcp_connect, los builtins de RPC de blockchain, push_send y proxy to (ver serve). Un secret ligado a un host va a una dirección privada sólo si su ligadura también la nombra. Las conexiones a bases de datos (postgres, mysql, mongo, redis) se habilitan con db, no con net, y no se ven afectadas.
En Windowslocalhostpuede resolver primero a::1, y unserveescucha en0.0.0.0(IPv4): el cliente se conecta a la única dirección que chequeó, así quehttp://localhost:8080/puede ser rechazado mientrashttp://127.0.0.1:8080/responde. Usá127.0.0.1para un servidor local.
Codificar una URL — url_encode / url_decode (motor v0.6.45+)§
url_encode(text) codifica con % todo menos A-Z a-z 0-9 - . _ ~ (RFC 3986), así el resultado es seguro en un segmento del path o en un valor de query. url_decode(text) es estricto: un % que no sigue con dos dígitos hex es el error url_decode: malformed escape at position N …, bytes que no son UTF-8 son url_decode: the decoded bytes are not valid UTF-8, y + queda como + (es un espacio solo en los bodies de formularios HTML, que form of request ya decodifica). Para una query string no necesitás ninguno de los dos: query = {…} codifica los valores.
require net("api.store.com")
let q be url_encode("café & tea?")
print(q) -- caf%C3%A9%20%26%20tea%3F
print(url_decode(q)) -- café & tea?
print(url_decode("a+b")) -- a+b (a + is not a space)
let r be http_get("https://api.store.com/search", query = {"q": "café & tea"}) -- query = … encodes for you
Leer HTML — html_select, html_text, html_unescape (motor v0.6.45+)§
Una página que bajaste es HTML, no JSON. Estos tres la parsean como un navegador (parsing WHATWG: tags sin cerrar, atributos sin comillas y entidades andan bien):
html_select(html, css)→ una lista de{tag, attrs, text, html}, uno por elemento que matchea el selector CSS, en el orden del documento:attrses un mapa con sus atributos,textsu texto con las entidades decodificadas,htmlsu HTML interno. Sin coincidencias es[]; un selector que no parsea es el errorhtml_select: invalid CSS selector "…".html_text(html)→ el texto legible de la página: sin scripts ni estilos, entidades decodificadas, una línea por elemento de bloque (<p>,<li>,<br>, títulos…). Las celdas de una fila de tabla quedan juntas en una línea.html_unescape(text)→ el texto con sus entidades HTML decodificadas (&,é,A,B).
Los tres son puros (sin capability) y lo que devuelven conserva los labels de la entrada: leer una página privada no hace públicos sus contenidos (ver Labels).
let page be "<ul id=menu><li class=item data-id=7>Tea & cake<li class=item data-id=9>Coffee</ul><script>track()</script><p>Open<br>daily"
each m in html_select(page, "li.item")
print((attrs of m)["data-id"] + ": " + text of m) -- 7: Tea & cake / 9: Coffee
print(html_text(page)) -- Tea & cake, Coffee, Open, daily: one line each
print(html_unescape("<b> & é")) -- <b> & é
require net / net("") permiten cualquier host, y net(".x.com") matchea subdominios — sólo en direcciones públicas (motor v0.6.46+, arriba).