Skip to main content

Luat 0.2.0: Packages, Hashed Assets and a Safer Engine

· 5 min read
Senol Tas
Founder & Principal Engineer, Maravilla Labs

It's been quiet around Luat since we open-sourced it in January. Today we're releasing Luat 0.2.0, and it's the biggest update since then.

We've been building real sites with Luat, and a lot of what's in this release came straight out of that work: things we missed, things that bit us, and things we wanted to share between projects. Here's what's new.

Packages​

Sharing components between Luat projects used to mean copying files around. Not anymore. Luat now has a package manager and a package registry:

luat add @acme/ui            # latest version
luat add @acme/ui@^1.2 # a version range

A package is just components, Lua modules and assets. You require them by name:

<script>
local Card = require("@acme/ui/Card")
local format = require("@acme/ui") -- the package's init.lua
</script>

<Card title="Hello">{format.date(props.when)}</Card>

Packages are compiled into your bundle, so production builds don't need anything installed. Publishing your own is luat login and luat publish. Versions are immutable, and a broken release gets yanked instead of deleted, so existing lockfiles keep working.

The full guide is in the packages docs.

Client Assets with Hashed File Names​

This one came from a very practical problem. Until now, luat build wrote your CSS and JavaScript to fixed paths like public/js/app.js. That means the URL never changes between deploys, so you either cache short and the browser downloads everything again and again, or you cache long and people keep running yesterday's JavaScript.

SvelteKit and Vite solved this years ago with content hashes in the file names. Now Luat does the same. List what the browser loads in luat.toml:

[frontend]
enabled = ["tailwind", "esbuild"]
entries = ["src/client/app.js", "src/client/app.css"]
  • JavaScript and TypeScript entries are bundled by esbuild. npm imports are bundled in, so import htmx from "htmx.org" just works, and no more copying files into a vendor/ folder.
  • Dynamic import()s become chunks of their own, loaded only on pages that need them.
  • CSS entries go through Tailwind.

The output lands in _luat/immutable/ with the hash in every name (app-5EVC5ZLM.css), and luat serve caches those files forever. A new version is simply a new URL.

In your templates, asset() gives you the URL:

<link rel="stylesheet" href={asset("src/client/app.css")}>
<script type="module" src={asset("src/client/app.js")}></script>

If you use an app.html shell, you don't even need that: %luat.head% already contains the tags for all entries. luat dev rebuilds on every change. Files that need a fixed URL, like your favicon or robots.txt, stay in public/. More in the toolchain docs.

Headers, Status and the Request​

Server code got a few things we kept reaching for:

function load(ctx)
ctx.setHeader("Cache-Control", "public, max-age=300")
ctx.appendHeader("Vary", "Accept-Language")

local post = find_post(ctx.params.slug)
if not post then
ctx.setStatus(404)
end
return { post = post }
end

ctx.path, ctx.search and ctx.href give you the request without parsing URLs yourself, and cookies support partitioned = true.

A Safer Engine​

If you embed Luat in your own Rust server, this release is for you. The embedding guide shows all of it with working code:

  • Execution limits. Memory, an instruction budget and a deadline per request (EngineLimits). A runaway loop in a template no longer takes your server with it, and Lua pattern matching counts against the same limits.
  • Async host functions. Server code runs as a coroutine, so the modules you register with Engine::register_module can be async: fetch data, query a database, and the template simply waits for it.
  • A real sandbox. require only resolves modules inside the bundle, installed packages and what the host registers. We closed several ways out of the sandbox and made sure concurrent requests can't see each other's state.
  • One build for every host. Bundles are built and loaded by the library, with one router, so your server routes and renders the same way luat serve does.

Smaller Things That Matter​

  • Attributes with nil or false are left out, and true renders a bare attribute: <button disabled={is_busy}> just works.
  • JSON null from your load functions arrives as nil, so props.title or "Untitled" does what you'd expect.
  • $nextTick(...) and friends inside a Lua string in <script> are left alone, so Alpine expressions survive.
  • Module names resolve case-exactly on every filesystem, so macOS behaves like your Linux server.
  • $lib/ imports always resolve to the file you wrote, and spreading a nil table no longer breaks a render.

Upgrading from 0.1​

A few things need your attention:

  • Rebuild your bundles. 0.2 doesn't load bundles built by 0.1.
  • require stays inside your project. If you relied on Lua's search path, move those modules into your project or a package.
  • Attribute rendering changed as described above. If you rendered false into an attribute on purpose, render the string "false" instead.

The changelog has the complete list.

Get It​

npm install -g @maravilla-labs/luat
luat init my-app
cd my-app
luat dev

Or grab a binary from the GitHub release. Binaries for Intel Macs and Linux on ARM are coming with the next release.

What's Next​

Next on our list:

  • Editor support for 0.2. luat-tools already gives you syntax highlighting, completions, go-to-definition and diagnostics in VS Code. Next it learns what's new in this release, like asset() and the new ctx functions.

As always, issues and pull requests are very welcome.


Follow @thelabertasch on X for updates, or star the repository on GitHub to stay in the loop.