Skip to content

The script contract

Every script defines one function, handle(input, ctx). HappyView calls it each time the script’s trigger fires. input is the trigger’s payload. ctx is what HappyView knows about this invocation. What handle returns decides what happens next.

Code at file scope runs while the script loads, before handle is called, so neither argument exists there. Put imports, constants and helper functions at file scope, and read input and ctx inside handle or in functions it calls.

A global function named handle. Load libraries and built-in modules with require at the top of the file.

local db = require("happyview.db")
local log = require("internal.logging")
function handle(input, ctx)
log.info("looking up", { q = input.q, who = ctx.caller_did })
return db.records("app.example.post"):limit(input.limit or 10):run()
end

A library call blocks until it answers. To run several calls at once, wrap each in a function and pass them to all from the built-in internal.async module. all returns each function’s first value in argument order:

local async = require("internal.async")
local db = require("happyview.db")
local http = require("happyview.http")
function handle(input, ctx)
local posts, profile = async.all(
function() return db.records("app.example.post"):limit(10):run() end,
function() return http.get("https://example.com/" .. input.did) end
)
return { posts = posts, profile = profile }
end
Triggerinput
xrpc.query:<nsid>The query-string parameters
xrpc.procedure:<nsid>The request body
record.index:<nsid>, record.create:<nsid>, record.update:<nsid>, record.delete:<nsid>{ action, uri, did, collection, rkey, record }. action is "create", "update" or "delete". record is null on a delete
labeler.apply:<nsid>, labeler.apply:_actor{ src, uri, val, neg, cts, exp }. exp is absent when the label doesn’t expire
job.run:<type>The input the job was created with

In Lua, a JSON null in input arrives as a sentinel value rather than nil, and the sentinel is truthy. Test input.action, not input.record, to tell a delete apart.

Every invocation carries these four fields:

FieldValue
triggerThe script’s trigger string, such as xrpc.query:com.example.getThing
caller_didQuery: the caller, or absent when anonymous. Procedure: the authenticated caller. Record event: the DID of the repo the event came from. Job: the DID that created the job. Label: absent
has_pds_authtrue when the script can write to a PDS as caller_did: a procedure whose caller has a session, or a job created with { auth: true }. false for queries, record events and labels
envThe instance’s script variables, keyed by name

Some script types carry more:

Script typeExtra ctx fields
Querymethod, collection
Proceduremethod, collection, params, delegate_did
Record eventcollection
Labelnone
Jobjob
  • method is the XRPC method NSID.
  • collection is the lexicon’s target collection for a query or procedure, and the record’s collection for a record event.
  • params is a procedure’s query-string parameters. Its body is input.
  • delegate_did is the account the caller is delegated to write for, when there is one.
  • A query or procedure on a space-scoped request also carries space: { uri, id, did, authority_did, spaceType, skey }.
  • job is { id, progress(data), should_stop(), wait(seconds) }. progress saves a progress snapshot that the dashboard shows. should_stop returns true once the job is being cancelled or paused; checking it is up to the script. wait sleeps for up to 3600 seconds. In JavaScript and TypeScript wait returns a promise, so write await ctx.job.wait(5).

A field that doesn’t apply is absent rather than set to an empty value. In JavaScript, "method" in ctx is false for a record event.

Script typeWhat handle returns
Query, procedureThe response body, serialized as JSON, unless it’s one of the two forms below
Record eventNothing to skip indexing. An object to store in place of the record. Anything else, such as true, to store the record as it arrived
LabelNothing to drop the label. An object whose fields are merged over the label. Anything else, such as true, to keep the label as it arrived
JobThe job’s result

A query or procedure can answer in two other ways:

  • A declared error. An object whose error is a name the method’s lexicon lists under errors is sent as JSON with status 400. An error the lexicon doesn’t list is an ordinary 200 body.
  • Bytes. When the method’s lexicon declares an output encoding other than JSON, return a blob ref naming a blob stored on the instance, or an object with $bytes holding base64. A lexicon that names one encoding sets the content type. Under */*, mimeType sets it: a $bytes object must carry one, and a blob ref without one uses the type stored with the blob. Any other return value fails the run.

“Nothing” is nil in Lua and undefined or null in JavaScript and TypeScript. A function that ends without a return returns nothing, which for a record script means the record is not indexed. If a record script only has side effects, end it with return input.record or true (Lua) or return input.record ?? true (JavaScript and TypeScript).

An uncaught error fails the invocation. For a query or procedure the caller gets the error back:

{
"error": "script_error",
"errorType": "runtime",
"message": "boom",
"method": "com.example.getThing",
"line": 12,
"file": null
}

errorType is one of syntax, runtime, timeout, memory or missing_handle. The status is 408 for timeout and 500 for the rest. line is the script line that raised the error. For TypeScript it is the line in your TypeScript source. file is the project file the error is in, and null for a script saved from the dashboard. A timeout carries HappyView’s own message, script exceeded execution time limit.

A message containing AUTH_ERROR: is answered as 401, and a library’s refusal of the caller’s cursor as 400 with InvalidRequest. A STORAGE_UNAVAILABLE failure from record storage is answered as 503 with ServiceUnavailable. Because message reaches the caller, never put a secret or a caller’s data in an error. For an expected condition, such as a missing record, return an error the lexicon declares instead: { error: "NotFound", message: "no such record" }.

missing_handle means the script defines no handle: no global handle function in Lua, or no default export in JavaScript and TypeScript (“script must export a default function”).

In JavaScript and TypeScript, a failed library call rejects with an Error that also has code and retryable properties. In Lua a library failure is a Lua error, prefixed with the library and function that failed. Catch expected failures with try/catch or pcall.

Record and label scripts are retried on failure and then dead-lettered. A failed job is marked failed, with the error type and the interpreter’s error text as its error. See Script types and triggers.

Every interpreter comes with the internal.* modules, which need nothing installed: internal.logging, internal.time, internal.tids, internal.json and, in Lua, internal.async. A published plugin can’t take a name under internal.. Built-in modules lists their functions.

Each interpreter runs scripts with a reduced standard library and no file, process or network access of its own. What each language keeps is on Languages: see Lua specifics and JavaScript specifics.

In every language, the guest has no timezone, so dates are read and written as UTC.

A script saved from the dashboard has no project around it, so a relative import such as ./helpers is refused when you save it. Relative imports work in scripts that belong to a project.