Appearance
Custom app server API
A custom app can have a server API: a Lua script that runs on the CoCoCo server. Use it for work that should not happen in the browser, such as combining several API calls into one, validating input before a write, or keeping data that belongs to the app.
Export functions, call them with rpc()
Define each callable function on the global exports table:
lua
function exports.greet(name)
return "Hello, " .. name .. "!"
endCall it from the app's script with rpc(method, ...args):
js
const greeting = await rpc('greet', 'World') // "Hello, World!"- The arguments of
rpc()become the Lua function's parameters, in order. - The function's return value is what the promise resolves to. Return JSON-compatible values: tables, strings, numbers, booleans or
nil. - A method name may contain letters, digits and underscores, must not start with a digit, and can be at most 64 characters long.
- If the function raises an error (
error("...")), or the name is not exported, the promise rejects with anErrorwhose message says what went wrong. rpc()rejects straight away if the app has no server API (APP_CONTEXT.hasServerApiisfalse).
How a call runs
Each call starts fresh. The script is loaded again for every call, so values you keep in Lua variables between calls are lost. Store anything that must last in the app's data container (below).
It runs as the signed-in user.
ctx.graphqlcalls carry that user's authorization and tenant, so the server API cannot read or change anything the user could not.Service calls return two values. Functions that reach a platform service return
ok, result. Whenokisfalse,resultis the error message. Bind both values and checkokbefore using the result:luafunction exports.jobName(jobId) local ok, res = ctx.graphql.query([[ query GetJob($id: JobID!) { getJob(id: $id) { name } } ]], { id = jobId }) if not ok then error(res) end local data = res.data if data == nil then error("no data returned") end local job = data.getJob if job == nil then error("job not found") end return job.name end
Store app data
ctx.dataContainer is persistent key-value storage that belongs to the app. A server API can reach only its own app's records.
| Function | Result |
|---|---|
ctx.dataContainer.get(name) | ok, record: the stored record, or nil if there is none. The value is in record.data. |
ctx.dataContainer.put(name, value) | ok, record: creates or replaces the record |
ctx.dataContainer.delete(name) | ok, result: removes the record |
Each record is a CustomDataContainerState.
Example: shift notes
This server API keeps a list of notes and counts jobs by status:
lua
local NOTES_KEY = "notes"
local function loadNotes()
local ok, record = ctx.dataContainer.get(NOTES_KEY)
if not ok then error(record) end
if record == nil then return {} end
return record.data or {}
end
function exports.listNotes()
return loadNotes()
end
function exports.addNote(text)
if type(text) ~= "string" or text == "" then
error("text must be a non-empty string")
end
local notes = loadNotes()
table.insert(notes, { text = text, at = ctx.time.nowIso() })
local ok, err = ctx.dataContainer.put(NOTES_KEY, notes)
if not ok then error(err) end
return notes
end
function exports.countJobs(status)
local ok, res = ctx.graphql.query([[
query CountJobs($status: String!) {
listJobs(first: 1, filter: { status: { eq: $status } }) { totalCount }
}
]], { status = status })
if not ok then error(res) end
if res.errors then error(res.errors[1].message) end
local data = res.data
if data == nil then error("no data returned") end
return data.listJobs.totalCount
endThe GraphQL it sends:
graphql
query CountJobs($status: String!) {
listJobs(first: 1, filter: { status: { eq: $status } }) {
totalCount
}
}The client script and template that use it:
js
const notes = ref([])
const draft = ref('')
const confirmed = ref(0)
const errorMessage = ref('')
async function load() {
errorMessage.value = ''
try {
notes.value = await rpc('listNotes')
confirmed.value = await rpc('countJobs', 'CONFIRMED')
} catch (error) {
errorMessage.value = error.message
}
}
async function addNote() {
if (!draft.value.trim()) return
try {
notes.value = await rpc('addNote', draft.value.trim())
draft.value = ''
} catch (error) {
errorMessage.value = error.message
}
}
onMounted(load)
const setupReturn = { notes, draft, confirmed, errorMessage, addNote }html
<div class="p-6 space-y-4">
<p class="text-sm text-base-content/70">Confirmed jobs: {{ confirmed }}</p>
<div v-if="errorMessage" class="alert alert-error">{{ errorMessage }}</div>
<div class="flex gap-2">
<input v-model="draft" class="input input-bordered flex-1"
placeholder="Add a note" @keydown.enter="addNote" />
<button class="btn btn-primary" @click="addNote">Add</button>
</div>
<ul class="space-y-2">
<li v-for="note in notes" :key="note.at" class="card bg-base-100 p-3">
<span>{{ note.text }}</span>
<span class="text-xs text-base-content/50">{{ note.at }}</span>
</li>
</ul>
</div>What else the server API can use
Besides ctx.graphql and ctx.dataContainer, the server API has the same Lua environment as other CoCoCo scripts. It includes the extended string, table and time helpers, ctx.json, ctx.time, ctx.sql (read-only reporting SQL), ctx.cache, ctx.config, ctx.notify and ctx.script.load. See the Lua API overview.
To check a script against exactly what is available, pass it to the validateLua mutation with role: CUSTOM_APP. It reports unknown functions and type errors without saving anything.
Engine versions
New apps run on engine version 2, which is what this page describes: exported functions on exports, called with rpc(). Apps created on engine version 1 used an older script shape. The app's engineVersion field tells you which one it runs, and setCustomAppEngineVersion upgrades an app to version 2.