Languages
Each language is an interpreter plugin. Add the ones you want to write scripts in. You can use all three in one project.
| Language | script_type | Plugin | File extensions |
|---|---|---|---|
| Lua | lua | happyview-lua | .lua |
| JavaScript | javascript | happyview-javascript | .js, .mjs |
| TypeScript | typescript | happyview-typescript | .ts, .mts |
Choose a language in a project
Section titled “Choose a language in a project”In a project, a script’s file extension picks its language. src/jobs/export.lua runs in Lua, and src/jobs/export.ts runs in TypeScript. Where the file sits picks its trigger; see Name a script file. Scripts in different languages can sit side by side.
- An extension counts only when an installed interpreter claims it. A script whose extension none claims fails the build, and the error lists the extensions that are claimed.
- Extensions match exactly.
format.LUAis ignored, with a warning that tells you to rename it to.lua. - Elsewhere under
src/, a file with a claimed extension is a module that scripts can import, and a file with any other extension is ignored.
Which interpreter a new instance has
Section titled “Which interpreter a new instance has”A self-hosted instance starts with no interpreter. HappyView does not bundle one, so a self-hosted instance can’t save or run a script until you install one.
Hosts can preinstall interpreters with HAPPYVIEW_DEFAULT_PLUGINS. The plugins it names are installed in the background on the first boot whose variable lists them, so they may take a moment to appear. HappyProto’s provisioner preinstalls Lua on every HappyProto instance.
Lua is also the default language in three places:
hpy happyview createpreselects Lua when it asks which interpreters to add.- The admin API stores a script as
luawhen a create request leavesscript_typeout. - The dashboard starts a new script in Lua when the Lua interpreter is installed.
Install an interpreter
Section titled “Install an interpreter”In a project
Section titled “In a project”hpy happyview create asks which interpreters to add, with Lua preselected, or takes them from --interpreter. It needs at least one: without an interpreter, no script can run. To add one later, name its language:
hpy happyview add typescriptThis adds it under [interpreters] in happyview.toml and to happyview.lock. Restart hpy happyview dev to load it. hpy happyview deploy installs the locked version on an instance. See Plugins and libraries.
From the dashboard
Section titled “From the dashboard”- Open Integrations > Plugins in the dashboard and click Add plugin.
- Pick
happyview-lua,happyview-javascriptorhappyview-typescriptfrom the registry. - Choose a Channel. The dashboard selects
latestwhen the project publishes it, and otherwise the first channel the project has. While v3 is in alpha, the interpreters are published on thealphachannel only, so that is the channel to pick. - Review the capabilities the plugin asks for, then install it.
The JavaScript and TypeScript interpreters ask for the same capabilities: library:call, script:host, script:test, wasi:clock, wasi:random and wasi:stdio.
On first boot
Section titled “On first boot”HAPPYVIEW_DEFAULT_PLUGINS installs a comma-separated list of plugin sources on the first boot that names them. Each source is a URL, such as a release’s manifest.json. Listing a source consents to every capability its manifest declares. Installs run in the background and never delay boot. A source that installs is recorded and never installed again, so a plugin you later uninstall stays uninstalled. A source that fails is retried on the next boot.
HAPPYVIEW_DEFAULT_PLUGINS=https://example.com/plugins/happyview-lua/manifest.jsonCheck what is installed
Section titled “Check what is installed”curl "http://127.0.0.1:3000/admin/scripts/languages" -H "$AUTH"# ["javascript","lua","typescript"]GET /admin/scripts/languages returns the script_type of every installed interpreter, sorted. The scripts list marks a script as not runnable when no installed interpreter claims its language.
Pick a language in the dashboard
Section titled “Pick a language in the dashboard”When you create a script in the dashboard, from Integrations > Scripts and then New script, the form shows a Language picker if more than one interpreter is installed. With only one installed, there is nothing to pick and the form uses that language.
- The picker lists only installed languages.
- A new script starts in Lua when Lua is installed, and otherwise in the first installed language.
- The editor label and syntax highlighting follow the language, for example “TypeScript body”.
- Switching language replaces the body with the new language’s starter, but only while the body is still an untouched starter. Once you edit it, switching keeps your text.
- A saved script keeps its language. The script’s detail page has no picker.
The scripts list has a Language column so you can see what each script is written in.
Starter scripts
Section titled “Starter scripts”The new-script form fills the body with a starter for the chosen language and trigger. There are three starters per language:
- Job for
job.run:<type>. - Record event for
record.index,record.create,record.updateandrecord.delete. - Trigger for everything else:
xrpc.query,xrpc.procedureandlabeler.apply.
HappyView’s test suite saves and runs each JavaScript and TypeScript starter against the real interpreters, so they are known to work as written.
Trigger starter
Section titled “Trigger starter”Logs the invocation and returns input. For a query, that echoes the query parameters back.
-- Trigger script: `input` is the trigger's payload; `ctx` describes-- this invocation (caller, environment, trigger id, and more). Return-- a transformed value, or `nil` to skip the operation.---- require("internal.*") and installed libraries provide the rest.
local log = require("internal.logging")
function handle(input, ctx) log.info("script fired", { trigger = ctx.trigger, caller = ctx.caller_did }) return inputendJavaScript
Section titled “JavaScript”// Trigger script: `input` is the trigger's payload; `ctx` describes this// invocation (caller, environment, trigger id, and more). Return a value, or// nothing to skip the operation.//// Imports from "internal.*" and installed libraries provide the rest.
import log from "internal.logging";
export default function handle(input, ctx) { log.info("script fired", { trigger: ctx.trigger, caller: ctx.caller_did }); return input;}TypeScript
Section titled “TypeScript”// Trigger script: `input` is the trigger's payload; `ctx` describes this// invocation (caller, environment, trigger id, and more). Return a value, or// nothing to skip the operation.//// Imports from "internal.*" and installed libraries provide the rest. Types// are erased rather than checked, so declare the shapes the script reads.
import log from "internal.logging";
interface Context { trigger: string; caller_did?: string;}
export default function handle(input: unknown, ctx: Context) { log.info("script fired", { trigger: ctx.trigger, caller: ctx.caller_did }); return input;}Record event starter
Section titled “Record event starter”Logs the event and lets it through. It returns the record rather than input, because a returned object replaces the stored record. A delete carries no record, so it returns true there to let the delete proceed.
local log = require("internal.logging")
function handle(input, ctx) if input.action == "delete" then log.info("record deleted", { uri = input.uri }) else log.info("record " .. input.action, { uri = input.uri, did = input.did }) end
-- A nil return skips the operation, and a delete carries no record, -- so `true` is what lets a delete through. return input.record or trueendJavaScript
Section titled “JavaScript”import log from "internal.logging";
export default function handle(input, ctx) { if (input.action === "delete") { log.info("record deleted", { uri: input.uri }); } else { log.info(`record ${input.action}`, { uri: input.uri, did: input.did }); }
// Returning nothing skips the operation, and a delete carries no record, // so `true` is what lets a delete through. return input.record ?? true;}TypeScript
Section titled “TypeScript”import log from "internal.logging";
interface RecordEvent { action: "create" | "update" | "delete"; uri: string; did: string; collection: string; rkey: string; record?: Record<string, unknown>;}
export default function handle(input: RecordEvent) { if (input.action === "delete") { log.info("record deleted", { uri: input.uri }); } else { log.info(`record ${input.action}`, { uri: input.uri, did: input.did }); }
// Returning nothing skips the operation, and a delete carries no record, // so `true` is what lets a delete through. return input.record ?? true;}Job starter
Section titled “Job starter”Reports progress, checks for a stop request, and returns a result. The JavaScript and TypeScript versions are async, because ctx.job.wait returns a promise there.
-- Job runner: executes as a background job. `input` is the job's-- input table; `ctx.job` exposes the job's id, progress(data),-- should_stop(), and wait(seconds). Return value becomes the job's result.---- require("internal.*") and installed libraries provide the rest.
local log = require("internal.logging")
function handle(input, ctx) log.info("job started", { job_id = ctx.job.id })
ctx.job.progress({ status = "working" })
if ctx.job.should_stop() then return { partial = true } end
return { done = true }endJavaScript
Section titled “JavaScript”// Job runner: executes as a background job. `input` is the job's input;// `ctx.job` exposes the job's id, progress(data), should_stop(), and// wait(seconds). The return value becomes the job's result.//// Imports from "internal.*" and installed libraries provide the rest.
import log from "internal.logging";
export default async function handle(input, ctx) { log.info("job started", { job_id: ctx.job.id });
ctx.job.progress({ status: "working" });
if (ctx.job.should_stop()) { return { partial: true }; }
return { done: true };}TypeScript
Section titled “TypeScript”// Job runner: executes as a background job. `input` is the job's input;// `ctx.job` exposes the job's id, progress(data), should_stop(), and// wait(seconds). The return value becomes the job's result.//// Imports from "internal.*" and installed libraries provide the rest. Types// are erased rather than checked, so declare the shapes the script reads.
import log from "internal.logging";
interface Job { id: string; progress(data: unknown): void; should_stop(): boolean; wait(seconds: number): Promise<void>;}
export default async function handle(input: unknown, ctx: { job: Job }) { log.info("job started", { job_id: ctx.job.id });
ctx.job.progress({ status: "working" });
if (ctx.job.should_stop()) { return { partial: true }; }
return { done: true };}Lua specifics
Section titled “Lua specifics”Lua scripts run in PUC Lua 5.4 with a cut-down standard library:
- Kept:
string,table,math,coroutine,utf8, and fromosonlyos.time,os.dateandos.difftime. The full list is in Lua standard libraries. - Absent:
io,debugandpackage, the rest ofos(includingos.clock), and the base functionsload,loadfile,dofileandcollectgarbage. requireis HappyView’s. Lua’s own is replaced by one that loads installed libraries (happyview.*) and the built-ininternal.*modules.- Reading a removed v2 global raises an error naming it, such as
paramsordb, with the way to migrate. Any other unknown global isnil, as in plain Lua. - Limits can’t be caught away.
pcallcan catch the error a script’s instruction or time limit raises, but the run still ends. See Limits.
TypeScript specifics
Section titled “TypeScript specifics”TypeScript is compiled to JavaScript and then run by the same engine as JavaScript, so everything in the contract applies unchanged. What TypeScript adds:
- Types are erased, not checked. A type error never stops a save or a run. Declare the shapes your script reads, as the starters do, or use
any. - Runtime TypeScript works.
enum,namespacewith values, and parameter properties are compiled, not just stripped. - Some syntax is refused with a
syntaxerror on its line: decorators andaccessorfields,import x = require("...")andexport =, and JSX. - Errors point at your TypeScript. Line numbers in errors are mapped back through a source map to the line you wrote.
- Type-only imports are removed. A library you import only for its types doesn’t need to be installed.

