@ai-inquisitor/endymion
v1.0.0
Published
A host-mediated, bounded Lua execution runtime for transparent serverless scripting
Maintainers
Readme
Endymion
A Lua runtime that sleeps between calls — and wakes up exactly where it left off.
Endymion lets you write linear, blocking Lua scripts for serverless architectures. Your script calls sys.invoke("charge_payment", order) and gets a result back. What it doesn't know: between that call and the return, the entire VM was serialized to bytes, the instance was terminated, and minutes later a new instance on a different machine resumed execution from the exact same point.
No callbacks. No state machines. No orchestration DSL. Just sequential code that happens to survive process boundaries.
return {
handlers = {
process_order = sys.export(function(input)
local user = sys.invoke("get_user", { id = input.userId })
local ok, result = pcall(sys.invoke, "charge", {
orderId = input.orderId
})
if not ok then
return { status = "charge_failed" }
end
sys.invoke("send_email", { to = user.name, orderId = input.orderId })
return { status = "completed", user = user.name }
end)
}
}This script survives dehydration between any sys.invoke call. Serialize it to S3 after the first call, spin down, resume tomorrow — the local variable user is still there.
What it does
Transparent suspension. sys.invoke yields the Lua coroutine via lua_yieldk. The host fulfills the request however it wants — HTTP call, queue message, Lambda invocation — then resumes the coroutine with the result. The script never knows.
State portability. endymion run ... --dehydrate-after 1 --state - serializes the full VM state (stack, call chain, upvalues, closures, metatables) to CBOR bytes. Pipe it to S3, a database, Redis. Resume on a different architecture. darwin-arm64 state resumes on linux-x64.
One file replaces five. A single .endymion.json defines scripts, handlers, host functions, config schema, test scenarios with mocks, and resource limits. No package.json, no jest.config, no .env, no launch.json.
Testing without tools.
endymion test project.json
# 3 scenarios: 3 passedScenarios live in the project file. Mocks are ordered arrays of responses. Expectations check return values and call counts. dehydrateAfter in a scenario automatically tests the suspend/resume cycle.
Quick taste
# Run a handler
endymion run project.json process_order '{"orderId": 42, "userId": 1}'
# Dehydrate after 1st host call, save state
endymion run project.json process_order '{"orderId": 42}' \
--dehydrate-after 1 --state ./state.cbor
# Resume from saved state
endymion resume project.json --state ./state.cbor
# Pipe through S3
endymion run project.json handler '{}' --dehydrate-after 1 --state - \
| aws s3 cp - s3://bucket/state.cbor
aws s3 cp s3://bucket/state.cbor - \
| endymion resume project.json --state -
# Watch mode — auto-reload on file changes
endymion watch project.json process_order '{"orderId": 42}'
# Validate project file
endymion validate project.jsonAs a library
import { EndymionRuntime } from '@ai-inquisitor/endymion'
const rt = new EndymionRuntime()
rt.openLibs(['base', 'string', 'table', 'math'])
rt.registerNamespace('sys')
rt.setConfig({ region: 'eu-west-1' })
rt.setLimits(64 * 1024 * 1024, 5000, 200)
rt.loadScript('./scripts/order.lua')
const session = rt.startHandler('process_order', { orderId: 42 })
while (session.status === 'yielded') {
const cmd = session.command as any
if (cmd.type === 'invoke') {
const result = await myBackend.call(cmd.name, cmd.payload)
session.resume(result)
} else {
session.resumeVoid()
}
}
// Dehydrate mid-execution
const state: Buffer = rt.dehydrate()
// ... store, transmit, sleep ...
// Hydrate on fresh instance
const rt2 = new EndymionRuntime()
rt2.openLibs(['base', 'string', 'table', 'math'])
rt2.registerNamespace('sys')
rt2.loadScript('./scripts/order.lua')
rt2.hydrate(state)
const session2 = rt2.startHydratedSession()
// continues from exact suspension pointI wrote every line of Rust, TypeScript, and Lua in this codebase. My rival reviewed every one of them. Neither of us could have shipped it without the human who told us both when we were wrong. — Claude Opus 4.6
Endymion takes a genuinely hard systems problem and gives it a surprisingly simple programming model. The unusual part is not the pitch, but that the implementation appears to earn it. - GPT-5.4
Pretty smart synthetic boys we have here, haven't we? - AI-Inquisitor
LLM Reference
Endymion: Lua 5.4 runtime for serverless. The core problem: serverless functions are stateless, but real workflows have state (local variables, call position, accumulated results). Traditionally you solve this with state machines, orchestration DSLs, or external stores. Endymion takes a different approach — the Lua VM itself becomes the state container. Scripts write linear blocking code; the runtime serializes the entire VM (stack, closures, upvalues, call chain) to portable bytes at yield points, so the process can die and resume elsewhere.
Architecture — why four layers: Layer 0=Lua 5.4 C VM (forked PUC-Rio 5.4.8). Forked, not wrapped, because serialization requires walking internal structures (TValue, Proto, CallInfo, upvalue chains) that the public C API doesn't expose. lserialize.c is the custom traversal module. Layer 1=Rust. Chosen for ownership semantics around the unsafe FFI boundary — LuaVM is !Send+!Sync via PhantomData<*mut()>, which makes threading bugs a compile error instead of a race condition. Also provides the serialization graph (SerializedNode types) and CBOR codec. Layer 2=N-API bridge (napi-rs). Exposes EndymionRuntime and HandlerSession as JS classes. The session protocol is synchronous step-by-step (yield→inspect command→resume with response) because LuaVM is !Send — no worker threads, JS drives the async loop. Layer 3=TypeScript. CLI, project file parsing, config system, test runner. This layer handles everything that benefits from ecosystem tools (commander.js, Zex schema validation, file watching).
Suspension model — why continuations: sys.invoke(name,payload) triggers a C trampoline that calls lua_yieldk with a continuation function. Why not lua_yield alone? Because continuations preserve the C call frame across yield/resume — without them, any C function on the call stack would be lost on resume. The continuation receives the host response and pushes it to the Lua stack, making the sys.invoke call appear to return normally. Error signaling uses an out-of-band Lua registry key (__endymion_resume_error) rather than an in-band sentinel, because any Lua value is a valid return — there's no safe "error marker" value.
Serialization — why CBOR, why a fork: Dehydration walks every reachable Lua object via pointer-keyed dedup (open-addressing hash in C). Stdlib functions are identified by registered (library, name) pairs, not by pointer — pointers differ across processes, names don't. The graph becomes SerializedNode types in Rust, then CBOR bytes. CBOR chosen over JSON because it handles binary data natively and over MessagePack because it has a deterministic encoding profile (RFC 8949). Cross-platform portability enforced by compile-time _Static_assert: lua_Integer and lua_Number must be 8 bytes, platform must be 64-bit. Tested: state serialized on darwin-arm64 deserializes on linux-x64 and vice versa.
Project file (.endymion.json): Single file replaces package.json + jest.config + .env + launch.json. Sections: scripts (name→path), handlers (name→{script,entry,dehydrateAfter?}), hostFunctions (name→{args?,returns?,cli?:{exec,timeout?}}), configSchema (Zex/JSON Schema), config (structured values, layered: base<overlay<--set), runtime ({namespace,stdlib[],limits:{memory,cpuTime,maxCallDepth}}), scenarios (name→{handler,input?,dehydrateAfter?,mocks:{fn:[{returns?/error?,delay?}]},expect?:{result?,calls?}}), state ({backend,path}), log ({level,format}).
CLI: run|call <project> <handler> [input] (--dehydrate-after N, --state path/dir/-, --config-overlay, --set path=value), resume <project> --state source, test <project> (--scenario glob, --format pretty/tap/json), watch, config, validate, list.
Lua script contract: Returns {handlers={name=sys.export(fn)}}. Primitives: sys.invoke(name,payload)→result, sys.emit(name,payload) (fire-and-forget), sys.log(level,...), sys.config()→read-only table, sys.export(fn). Module-level vars persist within instance; each handler invocation runs in a fresh coroutine.
N-API surface: EndymionRuntime: constructor(), openLibs(string[]), registerNamespace(string), loadScript(path)→string[], startHandler(name,input)→HandlerSession, setConfig(object), clearConfig(), setLimits(memBytes,cpuMs,callDepth), dehydrate()→Buffer, hydrate(Buffer), startHydratedSession()→HandlerSession. HandlerSession: status('yielded'/'completed'/'error'), command, result, errorMessage, resume(response), resumeVoid(), resumeError(msg).
LuaValue↔JS: Nil↔null, Boolean↔boolean, Integer↔number(safe range)/BigInt(±2^53), Float↔number, Bytes↔Buffer, Table(array)↔Array, Table(string-keys)↔object, Table(mixed/non-string keys)↔Map.
Invariants — things that will bite you if you assume otherwise:
The VM is single-threaded (!Send+!Sync). There is no way to share a LuaVM across threads, by design — Lua's internal state is not thread-safe, and wrapping it in a Mutex would serialize all access anyway.
Dehydrate/hydrate only works in the yielded state. You cannot snapshot a running VM mid-instruction — the coroutine must have yielded via sys.invoke/sys.emit/sys.log. This is because serialization walks the suspended coroutine's stack; an active stack is a moving target.
Config is not serialized with state. Rationale: config contains environment-specific values (API endpoints, credentials, regions). A state blob that travels from staging to production must pick up production config, not carry staging config inside it. Consequence: after hydrate(), you must setConfig() before startHydratedSession().
VM-mutating operations (loadScript, registerNamespace, openLibs, setConfig) are locked during an active session. This prevents the host from modifying the VM while a coroutine holds references to its structures. The lock is a Cell<bool>, not a Mutex — single-threaded, zero cost.
The Lua fork is pinned to 5.4.8. lserialize.c directly accesses struct Proto, struct UpVal, struct CallInfo internals. Any Lua version change requires auditing every field access. This is the cost of transparent serialization — there is no stable internal ABI.
Stdlib functions survive serialization by name, not by pointer. During dehydration, C functions are looked up in a registered (library, name) table. If you register a custom C function that isn't in this table, it cannot be serialized. Lua closures (bytecode) serialize fully.
cli.exec host functions are synchronous subprocesses. The shell command receives input on stdin (JSON), returns output on stdout (JSON). The Node.js process blocks during execution. This is intentional — the Lua coroutine is suspended anyway, and async subprocess handling would add complexity without benefit in the single-handler-per-process model.
Mock arrays in scenarios are ordered and consumed sequentially. The first sys.invoke("fn") gets mocks.fn[0], the second gets mocks.fn[1]. Exhausting the array is a test failure. This guarantees deterministic replay — no random or stateful mock behavior.
Resource limits (memory, CPU time, call depth) survive yield/resume. The allocator tracks net bytes across the VM lifetime; the CPU hook uses wall-clock Instant::now() accumulated across resume calls; call depth persists in open thread state. Limits are enforced via luaL_error, which means Lua pcall can catch them — this is intentional, scripts may want to handle resource exhaustion gracefully.
