Getting started: a web app
scaffold a full-stack web app, WebAssembly client plus OpenResty server in one repository: every file toku init --web produces, the configure hook, and the dev loop
A santoku web project is one repository holding three programs: a client compiled to WebAssembly and run in the browser, a server running under OpenResty, and shared migrations both use. Everything here assumes toku is installed and its mandatory one-time toku setup has run, which is the Install tab: setup-toku.sh does both, and toku doctor confirms it. Beyond toku, decide how you get the rest of the toolchain: install it on your machine, or run toku from the container image santoku-make ships, with your code mounted in. Both are covered first below, and everything after that is identical either way. Then scaffold, build and start the project before changing anything. The example project is called my-app. What it scaffolds is a working todo app with a client-side SQLite database, tag parsing, JSON export and a real sync endpoint.
Option one: install the toolchain
A web project compiles Lua to WebAssembly and serves it from OpenResty, so the toolchain is larger than for a library. The first line is the mandatory one-time step; setup-toku.sh from the Install tab already ran it. The second gives toku a managed OpenResty: from then on every toku command puts it on PATH and sets OPENRESTY_DIR for itself, so you install and export nothing, and toku doctor reports "web builds use: the managed openresty". The rest must be on PATH. Missing tools fail at the first one the build cannot find, with the error coming from that tool rather than from toku.
toku setup # mandatory, once; done by setup-toku.sh (the Install tab)
toku setup --resty # once; builds the managed OpenResty (or sh setup-toku.sh --resty)
emcc, emmake # Emscripten, compiles the client to WebAssembly. Install emsdk:
# git clone https://github.com/emscripten-core/emsdk.git
# cd emsdk && ./emsdk install latest && ./emsdk activate latest
# source ./emsdk_env.sh
# source emsdk_env.sh from bash: under dash, which is sh on
# Debian, it puts nothing on PATH, so a detached sh -c build
# fails with emmake: No such file or directory
# emsdk needs python3 and xz-utils, and the first download is
# around 300MB
OPENRESTY_DIR # optional: set it only to use your own OpenResty instead of the
# managed one. It names the install prefix, such as
# /usr/local/openresty, and that install's openresty must be on PATH
node # runs the bundler's post-processing; emsdk ships one, so
# sourcing emsdk_env.sh above usually satisfies this too
clang or gcc # builds C rocks for the host during dependency install
curl # the build vendors pinned assets over the network
tailwindcss # v4 specifically: the build passes --cwd, which v3 rejects.
# The standalone binary from the tailwindcss releases page works
rsvg-convert # rasterises icons. Debian and Ubuntu: librsvg2-bin,
# Alpine: librsvg
luarocks # invoked by toku; the managed toolchain ships its own
git # toku init runs git init unless told not to
inotifywait # toku test --iterate needs it and errors without it
# a first build is slow: expect ten minutes or more of emscripten
# compilation on modest hardware. Later builds are incremental.
Option two: run toku from the container image
santoku-make ships an image carrying that whole toolchain, so the alternative to the list above is installing nothing but a container runtime. Your code stays on your machine and is mounted at /app; only the tooling lives in the container, so the build tree lands in your working directory exactly as it would locally. Build the image once from a lua-santoku-make checkout, then use the wrapper anywhere you would type toku. Everything after the double dash goes to toku, and anything before it goes to the container runtime, which is how you publish a port. Podman keeps file ownership yours automatically; docker does not, so pass -u if root-owned build output would bother you. toku-web is the image for anything with a client directory; a library project wants the smaller toku-lib, covered in the library tab. The image sets TOKU_FG=1, which makes start stay in the foreground. Backgrounding OpenResty and exiting would let toku, as PID 1 under --rm, stop the container along with the server, and nothing would report an error while the published port never answers. In a container you build yourself, set TOKU_FG or pass --fg.
$ docker build -t toku-web -f toku-web.dockerfile .
$ ./toku-web.sh -- build --test
$ ./toku-web.sh -- test
$ ./toku-web.sh -p 8080:8080 -p 8443:8443 -- start --test
# before the --, the wrapper's own flags first, then runtime flags
# -c docker | -c podman force a runtime, otherwise docker then podman
# -n print the command it would run, and exit
# -u "$(id -u):$(id -g)" passed through; docker only, keeps output yours
# -i <image> override the image name (or set TOKU_IMAGE)
Deploying in a container: toku-web-deployment
toku-web is a build image; none of that toolchain is needed to serve the built tree. toku-web-deployment is the runtime base: debian:bookworm-slim with openresty from the upstream repo, ca-certificates kept, gnupg and wget purged, OPENRESTY_DIR set, WORKDIR /app, and a default CMD of sh -c "umask 002; exec ./run.sh --fg". Build it from the lua-santoku-make checkout root, since it copies a file out of the build context. It carries a non-root system user worker (uid 10001, gid 0, no home, nologin) and sets no USER directive, so the nginx master starts as whatever the runtime assigns and the config's own user directive drops the workers. apt still works, but the package lists are stripped: run apt-get update before any apt-get install and remove the lists afterwards. A downstream image adds three things: the built tree, one toku-deploy-setup call, and a WORKDIR at the dist directory. toku-deploy-setup takes the build tree (dist defaults to <build-tree>/main/dist), deletes the .o, .a and .link install intermediates, applies the arbitrary-uid permission convention, marks run.sh executable, points the access and error logs at stdout and stderr, and runs ldconfig.
$ docker build -t toku-web-deployment -f toku-web-deployment.dockerfile .
FROM toku-web AS builder
WORKDIR /app
COPY . .
RUN MY_APP_SSL_CERT=/home/app/cert.pem \
MY_APP_SSL_KEY=/home/app/privkey.pem \
toku build --env prod
FROM toku-web-deployment
COPY --from=builder /app/build/prod /app/build/prod
RUN toku-deploy-setup /app/build/prod
WORKDIR /app/build/prod/main/dist
# adding native libraries downstream: update first, strip the lists again
FROM toku-web-deployment
RUN apt-get update \
&& apt-get -y install --no-install-recommends libsqlite3-0 \
&& rm -rf /var/lib/apt/lists/*
Configuration is baked at build time, not at container start
SSL certificate and key paths, ports and the domain belong in the builder stage. The configure hook reads them while rendering nginx.conf, and the generated run.sh exports only the values baked in at render time before exec'ing openresty against a static config. Nothing re-renders when the container starts, so the same variables set in the runtime stage have no effect. The names come from your variable_prefix, so a project named my-app reads MY_APP_SSL_CERT rather than SSL_CERT. Building with --env prod also requires a make.prod.lua, which no scaffold ships; the smallest one that works returns a table merged over make.lua.
return { env = { server = { host = "example.com" } } }
Scaffold a web project
The result builds and runs unmodified. The client keeps its data in an OPFS-backed SQLite database running in a dedicated worker, the main thread talks to it through a proxy, and the server exposes one /sync endpoint over its own SQLite database. The santoku-web tab's santoku.web.sqlite.proxy card covers that proxy, including how arguments and results cross to the worker. The client's queries live in store.tk.lua, a function of an open database that returns the closures; db.tk.lua opens the OPFS database in the worker and serves store(db), and the client spec requires the same store over open_memory(), so the spec tests the code the worker runs. It runs git init unless you pass git = false through the API. Use --here to scaffold into the current directory, which must be empty. --here takes the project name from the directory, so toku init rejects it beside --name.
$ toku init --web --name my-app
Created web project: my-app
Next steps:
cd my-app
toku build --test # Build for testing
toku start --test # Start development server
$ find my-app -type f -not -path "*/.git/*" | sort
my-app/.gitignore
my-app/client/bin/bundle.lua
my-app/client/lib/my-app/db.tk.lua
my-app/client/lib/my-app/main.lua
my-app/client/lib/my-app/store.tk.lua
my-app/client/res/pre.tk.js
my-app/client/static/index.html
my-app/client/test/spec/my-app.lua
my-app/make.lua
my-app/res/client/migrations/0.0.1.sql
my-app/res/server/migrations/0.0.1.sql
my-app/server/lib/my-app/db.tk.lua
my-app/server/lib/my-app/web/init.lua
my-app/server/lib/my-app/web/init_worker.lua
my-app/server/lib/my-app/web/sync.lua
my-app/server/nginx.tk.conf
my-app/server/test/spec/my-app.lua
Adding a rock to a web project
make.lua keeps a dependency list per side: client.dependencies for the WebAssembly client, server.dependencies for the OpenResty server, and server.test.dependencies for rocks only the server specs use. Each side installs into its own tree, so a rock goes on every side that requires it, and the rocks in toku's own tree are not visible to either. Find a rock's current version with toku luarocks search and pin it to that major version. lua-cjson is how server specs decode JSON; the OpenResty server already carries cjson.
-- $ toku luarocks search santoku-markdown
client = {
dependencies = {
"lua == 5.1",
"santoku >= 2.0.0, < 3.0.0",
"santoku-web >= 2.0.1, < 3.0.0",
"santoku-markdown >= 2.2.4, < 3.0.0",
},
},
server = {
dependencies = { ... },
test = {
dependencies = {
"lua-cjson >= 2.1.0",
},
},
},
Build it and start it
toku test --iterate --show-logs is the expected day-to-day loop for a web project, and the one the framework is developed with. It builds, starts a test server, runs the suites, tails the server's access and error logs alongside the run, then waits for changes and repeats, printing a line telling you it is waiting. Leave it running in one pane and edit in another. The individual commands below are what it composes; run them against the fresh scaffold before you change anything. Two things that will otherwise cost you time: do not pipe toku start, because it backgrounds OpenResty and the pipe never closes, and plain stop stops both environments, while stop --test stops only the test server. toku test runs its own server: it stops any running one, starts the test server, and stops it again when the suites finish, so a server you started for a browser is gone after a test run; start it again afterwards. A passing run prints a Test: line per spec file and a luacheck total, and nothing else; a failure prints the assertion and a traceback and exits nonzero. Client specs are compiled to WebAssembly and run under node, and they can require any module under client/lib. The server listens on nginx.port from make.lua, "8080" in the scaffold; edit that value to move it, since toku start takes no port flag.
$ toku test --iterate --show-logs # the expected dev loop: leave this running
# what it composes, for when you need one directly:
$ toku build --test # render client wasm + server tree, test environment;
# when the client tree rebuilds, it also runs the
# client specs and luacheck, and a failing client
# spec fails the build
$ toku start --test # run OpenResty against it on nginx.port
$ toku test # client and server suites
$ toku test --client # one suite at a time: --root, --client, --server
$ toku stop # stops both environments' servers
$ toku start --fg # foreground, error log on stderr
--pre-js and wrap_events, required by the sqlite worker
The client's sqlite worker talks to the main thread through handlers named Module.start and Module.on_message. Those are defined by santoku.web.pwa.wrap_events, which has to be injected into the emscripten bundle at link time with --pre-js. It is one rule in the descriptor pointing at one rendered file, and without it the worker loads and then does nothing, with no error that names the cause. The pattern key matches the bundle target name, so it applies to the client entry point only. The file it points at, client/res/pre.tk.js, is a one-line template whose entire content is the wrap_events JavaScript; you do not edit it, you only have to know it must be wired up. A client without the sqlite worker doesn't need it: drop the worker, the rule and client/res/pre.tk.js together. client.files, which the scaffold sets to false, picks how the client's Lua reaches the browser. false (or unset) merges every module into one chunk, compiles it with luac -s and links it into the bundle, which is smallest and keeps no line numbers. true embeds each .lua file into emscripten's virtual filesystem under its own path and loads it by require, so tracebacks name files and lines; turn it on while debugging and off for production.
-- in make.lua, under client:
rules = {
["bundle$"] = {
ldflags = { "--pre-js", "res/pre.js" },
},
},
Going to production: TLS
The scaffold serves plain HTTP on one port, which is all you need in development because browsers treat localhost as a secure context, so OPFS and workers behave normally without a certificate. Production needs real TLS, and the setup below is what the framework expects: the descriptor carries ssl_cert and ssl_key, and your configure hook decides where they come from. Take them from the environment when they are set, which is the production path, and otherwise generate a self-signed pair into the server work directory for local HTTPS testing. Generate it once and reuse it, or every build hands the browser a new certificate. A real certificate usually needs its intermediate chain too, which is what ssl_ca_bundle is for. The scaffold writes port = "8080" as a literal; the env.var form below is what lets a PORT variable override it.
-- make.lua
nginx = {
domain = env.var("DOMAIN", "localhost"),
port = env.var("PORT", "8080"),
ssl_port = env.var("SSL_PORT", "8443"),
ssl_redirect_port = env.var("SSL_REDIRECT_PORT", ":8443"),
ssl_self_signed = true,
...
},
configure = function (submake, envs)
local server_env = envs.server
local nginx_cfg = envs.root.nginx
if not server_env then return end
-- configure runs once per environment and all environments share one
-- nginx table, so an unguarded assignment is overwritten by the last
-- call. Key anything environment-specific off this:
local is_test = server_env.environment == "test"
local cert = env.var("MY_APP_SSL_CERT", nil)
local key = env.var("MY_APP_SSL_KEY", nil)
local ca = env.var("MY_APP_SSL_CA_BUNDLE", nil)
if ca then nginx_cfg.ssl_ca_bundle = ca end
if cert and key then
nginx_cfg.ssl_cert, nginx_cfg.ssl_key = cert, key
elseif nginx_cfg.ssl_self_signed then
local dir = fs.join(server_env.work_dir, "ssl")
local c, k = fs.join(dir, "localhost.crt"), fs.join(dir, "localhost.key")
if not (fs.exists(c) and fs.exists(k)) then
fs.mkdirp(dir)
sys.execute({ "openssl", "req", "-x509", "-nodes", "-days", "365",
"-newkey", "rsa:2048", "-keyout", k, "-out", c,
"-subj", "/CN=localhost/O=DEV ONLY - NOT FOR PRODUCTION",
"-addext", "subjectAltName=DNS:localhost,IP:127.0.0.1" })
end
nginx_cfg.ssl_cert, nginx_cfg.ssl_key = c, k
end
end,
-- then in server/nginx.tk.conf, a second server block:
-- listen <% return n.ssl_port %> ssl;
-- listen [::]:<% return n.ssl_port %> ssl;
-- ssl_certificate <% return n.ssl_cert %>;
-- ssl_certificate_key <% return n.ssl_key %>;
-- add_header Strict-Transport-Security
-- "max-age=31536000; includeSubDomains" always;
--
-- and a redirect from the plain port. Include the port, or local
-- HTTPS testing bounces you to 443 and appears broken; set it empty
-- in production where the site is on the default port:
-- return 301 https://$host<% return n.ssl_redirect_port %>$request_uri;
--
-- Do NOT emit that redirect in the test environment: the scaffold's own
-- server spec posts to http://localhost:$PORT/sync and a 301 fails it.
Going to production: the service worker
Offline support is not in the scaffold, and adding it is mostly a matter of putting the generated JavaScript somewhere the browser can fetch. This is the single most important thing to get right: santoku.web.pwa.sw is a BUILD-TIME generator that returns JavaScript as a string. It is not a runtime module. Put it in client/static as a .tk.js template so the rendered output is a served file, and register it with sw in your document. Putting it under client/lib instead produces a Lua module nobody fetches, and the result is an app that builds cleanly and never goes offline. Derive the nonce from the content rather than the clock, so an unchanged build does not invalidate every client's cache.
-- client/static/serviceworker.tk.js, rendered to /serviceworker.js
<%
local arr = require("santoku.array")
local str = require("santoku.string")
local sw = require("santoku.web.pwa.sw")
-- everything the app needs to boot with no network
-- the scaffold's CSS is inline, so there is no /index.css to list, and
-- the wasm is required or the app cannot boot offline. A precache entry
-- that 404s makes install throw and the worker never activates.
local precache = { "/bundle.bc0cd9639874.js", "/bundle.c9f9a1d0041f.wasm" }
-- content-derived, so an identical build keeps the same cache
local nonce = str.to_base64(str.sha256(arr.concat(precache, "\n")
.. "\n" .. str.sha256(app_html))):sub(1, 22)
return sw({
nonce = nonce,
precache = precache,
index_html = app_html, -- answers navigations while offline
no_cache = { "^/sync$" }, -- never serve these from cache
})
%>
Going to production: the document and manifest
santoku.web.pwa.index renders the document that the service worker will serve for navigations, so the two are built together: generate the HTML, hand it to sw as index_html, and point the document at the worker with sw. Both renders need the same app_html, so build it once where both can reach it: put the index(...) call in a helper under res/ and load it from the serviceworker template with fs.runfile(depend(fs.join(root_dir, "res/app_html.lua"))), so editing the helper rebuilds serviceworker.js. initial = true is required, since only that branch emits the script that registers the worker. Set csp = true and the policy is built from the hashes of the inline scripts actually present in the output, which is why it must run after any transforms. manifest points at a rendered manifest.json, and any file you add to the public directory here must also be tied to client_env.static_files_ok so hashing does not race it.
local index = require("santoku.web.pwa.index")
local app_html = index({
title = "my-app",
description = "...",
theme_color = "#1e293b",
manifest = "/manifest.20e5f4daaa85.json",
sw = "/serviceworker.js",
initial = true,
head = [=[<meta name="bundle-js" content="/bundle.bc0cd9639874.js">
<script src="/bundle.bc0cd9639874.js"></script>]=],
})
-- Point sw at a STABLE url, not at the hashed worker file. pwa.index
-- hashes the inline registration script for the CSP; if that url is
-- itself a hashed asset, the asset-hash pass rewrites it afterwards,
-- the declared hash no longer matches, and the browser silently blocks
-- the page's own scripts. Serve the worker from a fixed location:
--
-- location = /sw.js { try_files /<% return hashed("serviceworker.js") %> =404; }
--
-- A service worker needs a stable url anyway: its scope and update
-- checks are keyed off it, so a hashed worker url breaks updates.