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>
293 lines
11 KiB
Markdown
293 lines
11 KiB
Markdown
# 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](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::find` when nothing matches) return `()`. Test with
|
|
`if v == () { ... }`.
|
|
- **`bool` for predicates.** Yes/no questions return `bool`.
|
|
- **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](https://rhai.rs/book/lib/index.html) 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 — `replace` mutates in place and returns `()`.** Rhai's
|
|
> `String.replace(from, to)` edits the receiver and returns unit, *not*
|
|
> a new string. `let t = auth.replace("Bearer ", "")` sets `t` to `()`,
|
|
> so a downstream `users::verify(t)` fails with
|
|
> `Function not found: users::verify (())`. To strip a known prefix,
|
|
> slice instead:
|
|
> ```rhai
|
|
> let token = if auth.starts_with("Bearer ") { auth.sub_string(7) } else { auth };
|
|
> ```
|
|
> Or call `replace` purely 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+` ``).
|
|
|
|
```rhai
|
|
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. |
|
|
|
|
```rhai
|
|
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. |
|
|
|
|
```rhai
|
|
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). |
|
|
|
|
```rhai
|
|
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. |
|
|
|
|
```rhai
|
|
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. |
|
|
|
|
```rhai
|
|
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.
|
|
|
|
```rhai
|
|
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. |
|
|
|
|
```rhai
|
|
// 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`:
|
|
|
|
```rhai
|
|
// 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:
|
|
|
|
```rhai
|
|
// 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](../serverless_cloud_blueprint.md).
|