Addresses findings from an independent review of the gap-closing commits: - R1 (regression): add #[serde(default)] to HttpDispatchPayload.method so async-HTTP outbox rows enqueued before the field existed still decode after upgrade instead of dead-lettering on "missing field `method`". Adds a regression test for the missing-key path. - method case: uppercase ctx.request.method at the orchestrator boundary so it honors its documented "uppercased" contract for extension verbs. Route matching is already case-insensitive, so matching is unaffected. - CLI hardening: percent-encode free-form path segments (app slug, topic name, ids, secret name) in the reqwest client so a value containing `/ ? #` can't break out of its URL segment. Adds a `seg()` helper + unit test and applies it uniformly across all path-interpolating methods (new and pre-existing). - S1 (docs): correct the users::email_available enumeration framing — it adds no new vector vs. create's uniqueness error, but is cheaper and unthrottled; there is no built-in throttle/CAPTCHA primitive, so the honest mitigation is a kv-based counter. Updated SDK doc-comments, trait docs, and stdlib-reference. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
11 KiB
Rhai stdlib reference
Everything in this document is callable from any user script without imports — Rhai's built-in standard library plus the seven PiCloud utility modules added in v1.1.0. Stateful service modules (KV, docs, HTTP, …) ship in subsequent v1.1.x releases and are documented separately.
For the architectural shape (why some modules are stateless and register at engine build, why others are per-call), see sdk-shape.md.
Conventions
- Throw on failure. Every function throws a Rhai runtime error on
bad input (invalid pattern, invalid encoding, out-of-range arg). Use
try { ... } catch (e) { ... }if you want to handle it. ()for absent. Functions that semantically may have no result (e.g.regex::findwhen nothing matches) return(). Test withif v == () { ... }.boolfor predicates. Yes/no questions returnbool.- UTC, milliseconds, lowercase hex, RFC 3986. Defaults chosen once, not per call.
Rhai built-ins (free with every script)
These come with the Rhai engine itself. See the Rhai book for full signatures.
Math: + - * / %, min, max, abs, sqrt, pow, floor,
ceil, round, to_int, to_float, sin, cos, tan, asin,
acos, atan, exp, ln, log, PI(), E().
String: len, is_empty, contains, starts_with, ends_with,
index_of, split, trim, to_lower, to_upper, replace, chars,
pad, sub_string, crop, + (concatenation).
Footgun —
replacemutates in place and returns(). Rhai'sString.replace(from, to)edits the receiver and returns unit, not a new string.let t = auth.replace("Bearer ", "")setstto(), so a downstreamusers::verify(t)fails withFunction not found: users::verify (()). To strip a known prefix, slice instead:let token = if auth.starts_with("Bearer ") { auth.sub_string(7) } else { auth };Or call
replacepurely for its side effect:let t = auth; t.replace("Bearer ", "");.
Array: push, pop, shift, insert, remove, len, clear,
truncate, extend, filter, map, reduce, reduce_rev, find,
find_map, any, all, index_of, contains, sort, reverse,
dedup, chunks, splice, [] indexing.
Map: len, is_empty, contains, keys, values, mixin,
remove, clear, fill_with, + (merge), [] and . access.
Blob: len, push, pop, clear, as_string, parse_le_int,
write_*, [] indexing. Blobs are Vec<u8> at the Rust layer.
Logging: log::trace, log::info, log::warn, log::error —
each takes a message and optionally a structured-data map. (Documented
with the SDK contract; mentioned here for completeness.)
regex:: — regular expressions
Linear-time, no backtracking (powered by the Rust regex crate).
Patterns compile per call.
| Function | Description |
|---|---|
regex::is_match(pattern, text) -> bool |
Whether text contains a match. |
regex::find(pattern, text) -> String | () |
First match or () if none. |
regex::find_all(pattern, text) -> Array |
All matches as String array. |
regex::replace(pattern, text, replacement) -> String |
Replace first match only. |
regex::replace_all(pattern, text, replacement) -> String |
Replace every match. |
regex::split(pattern, text) -> Array |
Split text on matches. |
regex::captures(pattern, text) -> Array | () |
[full, group1, group2, ...] from the first match; unmatched optional groups appear as (). |
Invalid patterns throw. Use \\ to escape inside Rhai string literals
("\\d+") or backtick strings to skip escaping (`\d+`).
if regex::is_match(`^/api/v\d+/`, ctx.request.path) {
let cap = regex::captures(`/api/v(\d+)/(.+)`, ctx.request.path);
let version = cap[1]; // "1"
let rest = cap[2]; // "users"
}
random:: — cryptographically-secure randomness
All randomness comes from OsRng. There is deliberately no "fast
non-crypto" variant — scripts shouldn't have to pick.
| Function | Description |
|---|---|
random::int(min, max) -> i64 |
Uniform integer in [min, max] (inclusive). Throws if min > max. |
random::float() -> f64 |
Uniform float in [0.0, 1.0). |
random::bytes(n) -> Blob |
n random bytes. n in 0..=65536. |
random::string(n) -> String |
n random alphanumeric chars (A-Za-z0-9). n in 0..=4096. |
random::uuid() -> String |
UUID v4 in canonical 8-4-4-4-12 form. |
let token = random::uuid();
let salt = random::bytes(16);
let pin = random::int(100000, 999999);
time:: — UTC time
Canonical time value is milliseconds since the Unix epoch as i64.
ISO 8601 / RFC 3339 strings are for I/O. UTC only — no timezone support.
| Function | Description |
|---|---|
time::now() -> String |
Current UTC time as ISO 8601 with ms (e.g. "2026-05-30T20:15:00.123Z"). |
time::now_ms() -> i64 |
Current ms since Unix epoch. |
time::parse(iso) -> i64 |
Parse RFC 3339 / ISO 8601 string to ms. Throws on bad input. |
time::format(ms) -> String |
Format ms-since-epoch as ISO 8601 with ms precision. |
time::add_seconds(ms, secs) -> i64 |
ms + secs*1000, with overflow check. |
time::diff_seconds(a_ms, b_ms) -> i64 |
(b_ms - a_ms) / 1000, truncated. |
let started_at = time::now_ms();
// ... do work ...
let elapsed = time::diff_seconds(started_at, time::now_ms());
let deadline = time::format(time::add_seconds(time::now_ms(), 3600));
json:: — JSON parse and stringify
| Function | Description |
|---|---|
json::parse(s) -> Dynamic |
Parse a JSON string. Returns Rhai maps, arrays, scalars, or () for null. Throws on invalid JSON. |
json::stringify(v) -> String |
Compact JSON. |
json::stringify_pretty(v) -> String |
Pretty-printed (2-space indent). |
let payload = json::parse(ctx.request.body); // if body came in as a string
let body_str = json::stringify(#{ ok: true, items: [1, 2, 3] });
Note: ctx.request.body is already parsed when the request body is
Content-Type: application/json — only call json::parse on raw
strings.
base64:: — standard and URL-safe Base64
Two alphabets: standard (with = padding) and URL-safe (no padding).
Encoders accept both String and Blob; decoders always return Blob.
| Function | Description |
|---|---|
base64::encode(input) -> String |
Standard alphabet, padded. input is String or Blob. |
base64::decode(s) -> Blob |
Decode standard alphabet. Throws on invalid. |
base64::encode_url(input) -> String |
URL-safe alphabet, no padding. |
base64::decode_url(s) -> Blob |
Decode URL-safe alphabet. Throws on invalid. |
let token = base64::encode_url(random::bytes(32)); // URL-safe session token
let raw = base64::decode("aGVsbG8=");
hex:: — hexadecimal
Encode produces lowercase. Decode accepts mixed case.
| Function | Description |
|---|---|
hex::encode(input) -> String |
Lowercase hex. input is String or Blob. |
hex::decode(s) -> Blob |
Decode hex (case-insensitive). Throws on invalid. |
let fingerprint = hex::encode(random::bytes(20));
url:: — percent-encoding
Unreserved set per RFC 3986 (A-Z, a-z, 0-9, -, _, ., ~)
is preserved; everything else is percent-encoded.
| Function | Description |
|---|---|
url::encode(s) -> String |
Percent-encode a component value. |
url::decode(s) -> String |
Percent-decode. Throws on invalid UTF-8 in the decoded output. |
url::encode_query(map) -> String |
Build k1=v1&k2=v2 from a Map. Both keys and values are percent-encoded. Non-string values are coerced via to_string(). |
url::encode_query emits keys in the Map's natural order, which is
alphabetical (Rhai's Map is a BTreeMap). RFC 3986 leaves query
parameter ordering unspecified, so this is fine for any conforming
consumer; if you need a specific ordering, build the string by hand.
let qs = url::encode_query(#{ q: "rust regex", page: 2 });
// → "page=2&q=rust%20regex"
Request context (ctx) and the response envelope
Every script sees a ctx map describing the invocation. The request
fields under ctx.request are:
| Field | Type | Notes |
|---|---|---|
ctx.request.path |
String |
Request path. |
ctx.request.method |
String |
Uppercased HTTP verb ("GET", "POST", …). Empty for non-HTTP triggers. Branch on it to serve several verbs from one script. |
ctx.request.headers |
Map |
Lowercased header names → values. |
ctx.request.body |
parsed | Already-parsed JSON when Content-Type: application/json; otherwise a string. |
ctx.request.params |
Map |
Captures from :name route segments. |
ctx.request.query |
Map |
Query-string parameters. |
ctx.request.rest |
String |
Suffix captured by a prefix (/*) route. |
// One script, many verbs:
let m = ctx.request.method;
if m == "GET" { /* list */ }
else if m == "POST" { /* create */ }
else { return #{ statusCode: 405, body: #{ error: "method not allowed" } }; }
The response envelope is statusCode-gated
A returned map is unwrapped into {statusCode, headers, body} only when
it contains a statusCode key. Without statusCode, the entire
returned map becomes the response body, with an implicit 200:
// WRONG — no statusCode, so the whole map is the body:
#{ body: #{ token: t } } // → HTTP 200 {"body":{"token":"…"}} (double-nested!)
// RIGHT — statusCode present, so it's treated as the envelope:
#{ statusCode: 200, body: #{ token: t } } // → HTTP 200 {"token":"…"}
Always include statusCode when you mean to set headers or a specific
body shape. Returning a bare value (42, #{ ok: true }) is fine — it
becomes a 200 body as-is.
Security note — users::email_available and account enumeration
users::email_available(email) -> bool is the anonymous-safe way to
pre-check an email during self-serve registration (users::find_by_email
is forbidden for anonymous public scripts to prevent enumeration). The
probe returns only true/false — the same bit a register form already
leaks via "email already taken" (and via create's own uniqueness error),
so it adds no new enumeration vector — but it is cheap (no password
hash), has no side effect, and is not rate limited, which makes bulk
probing faster than going through create.
There is no built-in per-route throttle or CAPTCHA primitive today (only
the global PICLOUD_MAX_CONCURRENT_EXECUTIONS cap). If account enumeration
matters for your app, the available mitigation is a kv-based counter
keyed on the client (e.g. an IP/email bucket you increment and check) —
the same pattern you'd use for any custom rate limit:
// Pre-check, then create. Gate behind your own kv counter if enumeration
// is a concern — the platform does not rate-limit this probe for you.
if !users::email_available(email) {
return #{ statusCode: 409, body: #{ error: "email already registered" } };
}
What's not here
- Crypto (sha256/hmac/argon2/encryption) — deferred to a focused later PR.
- Timezones — UTC only in v1.1.0. Format with an offset upstream if you need local time.
- JWT, YAML, XML, CSV, Markdown — not planned for v1.1.x.
- Stateful services (KV, docs, HTTP, cron, files, pubsub, secrets, email, users, queue, invoke) — land per the v1.1.x roadmap in the blueprint §12.