Skip to content

Languages

Each language is an interpreter plugin. Add the ones you want to write scripts in. You can use all three in one project.

Languagescript_typePluginFile extensions
Lualuahappyview-lua.lua
JavaScriptjavascripthappyview-javascript.js, .mjs
TypeScripttypescripthappyview-typescript.ts, .mts

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.LUA is 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.

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 create preselects Lua when it asks which interpreters to add.
  • The admin API stores a script as lua when a create request leaves script_type out.
  • The dashboard starts a new script in Lua when the Lua interpreter is installed.

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:

Terminal window
hpy happyview add typescript

This 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.

  1. Open Integrations > Plugins in the dashboard and click Add plugin.
  2. Pick happyview-lua, happyview-javascript or happyview-typescript from the registry.
  3. Choose a Channel. The dashboard selects latest when the project publishes it, and otherwise the first channel the project has. While v3 is in alpha, the interpreters are published on the alpha channel only, so that is the channel to pick.
  4. 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.

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.

Terminal window
HAPPYVIEW_DEFAULT_PLUGINS=https://example.com/plugins/happyview-lua/manifest.json
Terminal window
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.

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.

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.update and record.delete.
  • Trigger for everything else: xrpc.query, xrpc.procedure and labeler.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.

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 input
end
// 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;
}
// 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;
}

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 true
end
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;
}
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;
}

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 }
end
// 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 };
}
// 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 scripts run in PUC Lua 5.4 with a cut-down standard library:

  • Kept: string, table, math, coroutine, utf8, and from os only os.time, os.date and os.difftime. The full list is in Lua standard libraries.
  • Absent: io, debug and package, the rest of os (including os.clock), and the base functions load, loadfile, dofile and collectgarbage.
  • require is HappyView’s. Lua’s own is replaced by one that loads installed libraries (happyview.*) and the built-in internal.* modules.
  • Reading a removed v2 global raises an error naming it, such as params or db, with the way to migrate. Any other unknown global is nil, as in plain Lua.
  • Limits can’t be caught away. pcall can catch the error a script’s instruction or time limit raises, but the run still ends. See Limits.

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, namespace with values, and parameter properties are compiled, not just stripped.
  • Some syntax is refused with a syntax error on its line: decorators and accessor fields, import x = require("...") and export =, 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.