Advanced: ship a userland app (worker + dist by hash)
Build, publish, and use a realtime custom-worker app on tinkaria over NATS — no operator, no redeploy. Upload artifacts by content hash, then author a descriptor that points at them.
This page teaches you to ship a userland app end to end: a custom backend worker
(server-side compute) and a custom dist (browser bundle), delivered to production by
content hash over NATS — no operator action, no image rebuild, no redeploy. You upload
each artifact, get back its sha256 id, then author one JSON descriptor that references
those ids. The platform adopts it live.
This is distinct from Author a platform app, which describes an app served by the platform's built-in static surface (no custom code). Use this page when you want your own server-side logic and/or your own frontend bundle.
Everything below travels over NATS (the sole channel). HTTP is used only to log in and to vend short-lived NATS credentials — it never mutates app state.
The browser half of your app is a shell view. The platform serves a
credential-holding tinkaview shell as the top document; your dist runs inside it as a
sandboxed, null-origin <iframe> view that talks to the shell over postMessage. Your
view holds no credential, imports no nats.ws, fetches no /config, and has no
reconnect / re-vend / expiry code — the platform shell owns the credential and the single
NATS connection. You write a dumb renderer; the platform owns the wire.
You do not need an operator to provision a namespace. The first descriptor you author into an unclaimed namespace auto-claims it to your account and provisions its NATS account (with a default JetStream quota so artifact upload works). Pick a fresh namespace name and it is yours.
The shape of the system
For a backend-backed app named <ns>/<app>:
- Everyone (worker + every browser) collaborates inside one session subtree:
app.<app>.session.main.>(the session id for a backend app is always the literalmain). - The worker subscribes
app.<app>.session.main.input.>and publishes state onapp.<app>.session.main.view.<region>(use regionmain). - The platform shell (which holds the credential) subscribes
app.<app>.session.main.view.>and publishes inputs onapp.<app>.session.main.input.<verb>, relaying each to/from your view aspostMessage. You never name a NATS subject — the shell owns every subject, inside the session grant ceiling.
The worker holds no credential: it reaches NATS only through a local bridge socket
that tinkaria injects. Your view holds no credential either: the platform shell
holds the short-lived anonymous credential it vends at /<ns>/<app>/config, owns the single
NATS connection, and re-vends the credential before it expires — your view never touches
it.
The view contract (postMessage)
The platform serves a tinkaview shell as the top document. That shell (the same one that
backs the platform's own adminconsole/homeapps) holds the anonymous credential, owns the
single NATS connection, and re-vends the credential before it expires. It hosts your dist
inside a sandboxed, null-origin <iframe> VIEW (sandbox="allow-scripts", deliberately
no allow-same-origin). State crosses the iframe boundary only as postMessage.
Your view talks to the shell with exactly these four messages (the shipped contract — read
the shell runtime internal/tinkaview/assets/tinkaview.js and the reference view
frontends/harness/ui/tinkaview/transport.ts):
| Direction | Message (event.data) | Meaning |
|---|---|---|
| shell → view | { type: "tinkaview:render", region, payload } | a view.<region> update. payload is the string the worker published on app.<app>.session.main.view.<region> — parse it yourself (e.g. JSON.parse). |
| shell → view | { type: "tinkaview:status", status } | connection-status string ("connecting…", "live", an error). Optional to display. |
| view → shell | { type: "tinkaview:input", event, payload } | the shell publishes this on app.<app>.session.main.input.<event> with body String(payload). |
| view → shell | { type: "tinkaview:ready" } | post on mount → the shell re-sends the latest render for every region (re-prime). Post it last, after your message listener is attached, or you miss the re-prime and the view stays blank. |
- The shell subscribes
app.<app>.session.main.view.>and maps each region into atinkaview:render; it publishes eachtinkaview:inputonapp.<app>.session.main.input.<event>. You never name a NATS subject — the shell owns every subject, inside the session grant ceiling. payloadis always a string, both ways (it is the raw NATS message body). Encode structured state as JSON in the worker andJSON.parseit in the view; send structured input as aJSON.stringify'd string.
The worker is server-authoritative and unchanged by the view model: it subscribes
app.<app>.session.main.input.<verb> and publishes app.<app>.session.main.view.<region>.
The shell announces input.connect once when it connects, so a worker that re-sends its
current state on any input hydrates a late joiner with no extra worker code.
The rest of this page ships the reference app — a shared counter — end to end: Step 0 builds the worker (0a) and the view dist (0b); Steps 1–4 log in, upload, author, and reach it.
Step 0 — build the two artifacts FIRST
Ordering is load-bearing. Upload BOTH artifacts and record their sha256 ids BEFORE
you author the descriptor. If you author a descriptor that references an artifact id that
is not uploaded yet, the backend reconcile fails "artifact not found" and stays broken
until the next descriptor change. Always: upload → get ids → author.
0a. The worker binary (a statically-linked Linux ELF)
The worker artifact format is a single statically-linked Linux ELF (CGO_ENABLED=0). The
child runs credential-less: it dials the bridge named by TINKALET_BRIDGE_SOCKET,
authenticates with TINKALET_BRIDGE_TOKEN, and derives its subtree root from
TINKARIA_BACKEND_APP + TINKARIA_BACKEND_SESSION. The bridge speaks a tiny
newline-delimited JSON protocol; because platform internals are not importable from your
module, the worker below includes a complete ~40-line bridge client of its own.
This example is a shared counter: any input.tap increments it; the new count is pushed
to every viewer.
worker/go.mod:
module counterworker
go 1.23worker/main.go:
package main
import (
"bufio"
"encoding/base64"
"encoding/json"
"net"
"os"
"strconv"
"strings"
"sync"
"sync/atomic"
)
// ---- minimal tinkalet bridge client (newline-delimited JSON frames) ----
// The bridge accepts client->server frames {op,id,sid,subject,data} and emits
// server->client frames {type,id,sid,subject,data,error}. `data` is base64.
// The MANDATORY first frame is op="hello" carrying the per-child token.
type frame struct {
Op string `json:"op,omitempty"`
Type string `json:"type,omitempty"`
ID string `json:"id,omitempty"`
SID string `json:"sid,omitempty"`
Subject string `json:"subject,omitempty"`
Data string `json:"data,omitempty"` // base64
Token string `json:"token,omitempty"`
Error string `json:"error,omitempty"`
}
type bridge struct {
conn net.Conn
mu sync.Mutex // serialises writes
seq atomic.Uint64
subs sync.Map // sid -> func(subject string, data []byte)
acks sync.Map // id -> chan frame
}
func dial(sock, token string) (*bridge, error) {
c, err := net.Dial("unix", sock)
if err != nil {
return nil, err
}
b := &bridge{conn: c}
go b.readLoop()
// Handshake: send hello, wait for its ack.
_, err = b.roundtrip(frame{Op: "hello", Token: token})
return b, err
}
func (b *bridge) readLoop() {
sc := bufio.NewScanner(b.conn)
sc.Buffer(make([]byte, 0, 64*1024), 16*1024*1024)
for sc.Scan() {
var f frame
if json.Unmarshal(sc.Bytes(), &f) != nil {
continue
}
if f.Type == "msg" {
if h, ok := b.subs.Load(f.SID); ok {
data, _ := base64.StdEncoding.DecodeString(f.Data)
h.(func(string, []byte))(f.Subject, data)
}
continue
}
if ch, ok := b.acks.LoadAndDelete(f.ID); ok {
ch.(chan frame) <- f
}
}
}
func (b *bridge) write(f frame) error {
buf, _ := json.Marshal(f)
b.mu.Lock()
defer b.mu.Unlock()
if _, err := b.conn.Write(append(buf, '\n')); err != nil {
return err
}
return nil
}
func (b *bridge) roundtrip(f frame) (frame, error) {
id := strconv.FormatUint(b.seq.Add(1), 10)
f.ID = id
ch := make(chan frame, 1)
b.acks.Store(id, ch)
if err := b.write(f); err != nil {
return frame{}, err
}
return <-ch, nil
}
// PublishNoAck fires a publish WITHOUT waiting for an ack. This is REQUIRED when
// publishing from inside a subscription handler: the read loop dispatches handlers
// inline, so a blocking publish-ack would deadlock the loop until timeout.
func (b *bridge) PublishNoAck(subject string, data []byte) {
_ = b.write(frame{Op: "pub", Subject: subject, Data: base64.StdEncoding.EncodeToString(data)})
}
func (b *bridge) Subscribe(subject string, h func(subject string, data []byte)) error {
sid := "s" + strconv.FormatUint(b.seq.Add(1), 10)
b.subs.Store(sid, h)
resp, err := b.roundtrip(frame{Op: "sub", SID: sid, Subject: subject})
if err != nil {
return err
}
if resp.Error != "" {
return &strErr{resp.Error}
}
return nil
}
type strErr struct{ s string }
func (e *strErr) Error() string { return e.s }
// ---- the app: a shared counter ----
func main() {
sock := os.Getenv("TINKALET_BRIDGE_SOCKET")
token := os.Getenv("TINKALET_BRIDGE_TOKEN")
app := os.Getenv("TINKARIA_BACKEND_APP")
session := os.Getenv("TINKARIA_BACKEND_SESSION") // "main"
root := "app." + app + ".session." + session
b, err := dial(sock, token)
if err != nil {
panic(err)
}
var count atomic.Int64
publish := func() {
state, _ := json.Marshal(map[string]any{"count": count.Load()})
b.PublishNoAck(root+".view.main", state)
}
// Handle inputs. Subject is root+".input.<verb>".
err = b.Subscribe(root+".input.>", func(subject string, data []byte) {
verb := strings.TrimPrefix(subject, root+".input.")
switch verb {
case "tap":
count.Add(1) // "connect" changes nothing — a joiner just needs the current state
}
// publish() runs for EVERY input: "tap" projects the new count, "connect"
// re-sends current state so a late joiner isn't blank. Fire-and-forget —
// never block on an ack inside a handler (see Pitfall 1).
publish()
})
if err != nil {
panic(err)
}
publish() // initial state
select {} // run forever; tinkaria supervises + restarts on exit
}Build it (the id is the sha256 of the binary):
cd worker
CGO_ENABLED=0 GOFLAGS=-mod=mod go build -trimpath -buildvcs=false -o worker.bin .
file worker.bin # must say: ELF ... statically linked
sha256sum worker.bin # <- WORKER_ID (record this)0b. The view dist (a .tar.gz with index.html at the archive ROOT)
The dist artifact is a gzipped tar whose index.html sits at the archive root (not
inside a subdirectory). It is your view: a dumb postMessage renderer with no
nats.ws, no /config, and no connection code — the shell owns all of that. Build
it with any tool; below is a minimal Vite app. Set base: "./" so
asset URLs are relative (the view is served under /<ns>/<app>/view/).
web/package.json (note: no nats.ws dependency — the view imports nothing):
{
"name": "counter-view",
"private": true,
"type": "module",
"scripts": { "build": "vite build" },
"devDependencies": { "vite": "^5.4.0" }
}web/vite.config.js:
import { defineConfig } from "vite";
export default defineConfig({ base: "./" });web/index.html:
<!doctype html>
<meta charset="utf-8" />
<title>counter</title>
<div id="count">…</div>
<button id="tap">tap</button>
<script type="module" src="/view.js"></script>web/view.js — this renders the shared count and taps it. It is the whole frontend;
there is no NATS code because the view has nothing to connect to:
// A tinkaview SHELL VIEW: a dumb renderer. It holds NO credential, imports NO
// nats.ws, fetches NO /config, and has NO reconnect/re-vend/expiry code. The shell
// (parent) owns the credential and the single NATS connection; this view only renders
// what the shell posts in, and posts input back out.
const countEl = document.getElementById("count");
// RENDER: the shell forwards every view.<region> update as a window 'message'.
// event.data = { type:"tinkaview:render", region, payload }. `payload` is the STRING
// the worker published on app.<app>.session.main.view.<region>; parse it yourself.
window.addEventListener("message", (ev) => {
const msg = ev.data || {};
if (msg.type === "tinkaview:render" && msg.region === "main") {
const state = JSON.parse(msg.payload); // the worker published {"count": N}
countEl.textContent = state.count;
}
// msg.type === "tinkaview:status" carries the connection-status string; ignore it
// unless you want to show "connecting…" / "live".
});
// INPUT: post to the parent shell, which publishes it on
// app.<app>.session.main.input.<event> with body String(payload).
const send = (event, payload = "") =>
window.parent.postMessage({ type: "tinkaview:input", event, payload }, "*");
document.getElementById("tap").onclick = () => send("tap");
// READY: announce mount so the shell re-primes us with the current state (it re-sends
// the latest render for every region on "ready"). Post it LAST — after the message
// listener above exists — or the re-prime is missed and the view stays blank.
window.parent.postMessage({ type: "tinkaview:ready" }, "*");The view sandbox has no allow-forms — an HTML <form> submit is BLOCKED. The view
runs at sandbox="allow-scripts" (null origin) with no allow-forms, so submitting a
<form> silently does nothing. Wire inputs with a button click and an Enter
keydown handler instead of a form submit:
const textEl = document.getElementById("text");
const add = () => { send("add", textEl.value); textEl.value = ""; };
document.getElementById("add").onclick = add;
textEl.addEventListener("keydown", (e) => { if (e.key === "Enter") add(); });Build and pack (index.html at the archive root):
cd web
npm install
npm run build # emits dist/
tar -C dist -czf dist.tar.gz . # index.html is now at the tar ROOT
sha256sum dist.tar.gz # <- DIST_ID (record this)Step 1 — create an account and log in
Both credential vends below are cookie-authenticated. Register once, then log in to set
the tinka_home cookie.
BASE=https://your-tinkaria-host # the platform origin
# Register (idempotent handle; 409 if taken).
curl -sX POST "$BASE/register" -H 'content-type: application/json' \
-d '{"handle":"alice","secret":"correct horse battery staple"}'
# Log in — save the cookie jar.
curl -sX POST "$BASE/login" -c cookies.txt -H 'content-type: application/json' \
-d '{"handle":"alice","secret":"correct horse battery staple"}'Every subsequent vend must send that cookie (-b cookies.txt). Your owner id is your
handle (alice).
Step 2 — upload the two artifacts over NATS
Get an upload credential (cookie-auth), then run the begin/chunk/commit conversation. The credential is confined to your own owner subtree, and the responder gates the target namespace live against its current owner — so you can only upload into a namespace you already own.
Brand-new namespace? Claim it first. Ownership is established by the first descriptor
.put (Step 3), not by upload. So if your namespace has never been claimed, the very first
upload is rejected with not authorized to upload into this namespace. When that happens,
follow this exact order:
- Author a placeholder descriptor now (Step 3's request, but
backend.type: "static"and noartifactRef/promotion— a static app owns nothing to fetch). This auto-claims the namespace to you and provisions its NATS account. - Come back and upload both artifacts (below) — now accepted.
- Author the real descriptor (Step 3) referencing the uploaded hashes.
The placeholder descriptor JSON:
{
"namespace": "myns",
"appId": "counter",
"displayName": "Counter (claiming)",
"authMode": "anonymous",
"backend": { "type": "static" }
}If the namespace is already yours, ignore this and use the normal order: upload → get hashes → author.
curl -sX POST "$BASE/apps/upload-cred" -b cookies.txt > upcred.json
# upcred.json = {wss, user_jwt, seed, inbox_prefix, subject_prefix, owner, ...}
# subject_prefix = tenant.artifact-upload.session.<owner>.artifactUpload each file with this Node script (upload.mjs). It reproduces the exact protocol:
- begin — request on
<subject_prefix>.beginwith body{"namespace":"<ns>"}→ reply{upload_id, chunk_size}. - chunk — for each slice of
chunk_sizebytes (default 524288), request on<subject_prefix>.chunkwith the raw bytes as the body and two headers:Upload-Id: <upload_id>andSeq: <0-based index>→ reply{seq}. - commit — request on
<subject_prefix>.commitwith body{"upload_id":..., "sha256":<hex of the whole file>, "total_chunks":<count>}→ reply{artifact_id}(equals your sha256).
// upload.mjs — usage: node upload.mjs <cred.json> <namespace> <file>
import { readFileSync } from "node:fs";
import { createHash } from "node:crypto";
import { connect, jwtAuthenticator, StringCodec, headers } from "nats.ws";
const [, , credPath, ns, file] = process.argv;
const cred = JSON.parse(readFileSync(credPath, "utf8"));
const bytes = readFileSync(file);
const sc = StringCodec();
const sha256 = createHash("sha256").update(bytes).digest("hex");
const nc = await connect({
servers: cred.wss,
authenticator: jwtAuthenticator(cred.user_jwt, new TextEncoder().encode(cred.seed)),
inboxPrefix: cred.inbox_prefix,
});
const P = cred.subject_prefix; // tenant.artifact-upload.session.<owner>.artifact
// begin
const begin = JSON.parse(
sc.decode((await nc.request(`${P}.begin`, sc.encode(JSON.stringify({ namespace: ns })), { timeout: 30000 })).data),
);
if (begin.error) throw new Error(`begin: ${begin.error}`);
const chunkSize = begin.chunk_size || 524288;
// chunk*
let seq = 0;
for (let off = 0; off < bytes.length; off += chunkSize, seq++) {
const h = headers();
h.set("Upload-Id", begin.upload_id);
h.set("Seq", String(seq));
const slice = bytes.subarray(off, Math.min(off + chunkSize, bytes.length));
const ack = JSON.parse(sc.decode((await nc.request(`${P}.chunk`, slice, { headers: h, timeout: 30000 })).data));
if (ack.error) throw new Error(`chunk ${seq}: ${ack.error}`);
}
// commit
const commit = JSON.parse(
sc.decode(
(await nc.request(`${P}.commit`, sc.encode(JSON.stringify({ upload_id: begin.upload_id, sha256, total_chunks: seq })), { timeout: 30000 })).data,
),
);
if (commit.error) throw new Error(`commit: ${commit.error}`);
console.log(commit.artifact_id); // == sha256
await nc.close();Run it for both artifacts (record each printed id — they equal the sha256 you computed):
npm i nats.ws
WORKER_ID=$(node upload.mjs upcred.json myns worker/worker.bin)
DIST_ID=$(node upload.mjs upcred.json myns web/dist.tar.gz)(If this is a brand-new namespace and the upload was refused, do the placeholder-claim first — see the warning at the top of this step.)
Step 3 — author the descriptor over NATS
Get an author credential (cookie-auth) and publish the descriptor. Only now — after both ids exist — do you reference them.
curl -sX POST "$BASE/apps/author-cred" -b cookies.txt > authcred.json
# authcred.json = {wss, user_jwt, seed, inbox_prefix, subject_prefix, owner, ...}
# subject_prefix = tenant.appreg-write.session.<owner>.appregThe descriptor JSON (validated field names):
{
"namespace": "myns",
"appId": "counter",
"displayName": "Shared Counter",
"authMode": "anonymous",
"backend": { "type": "bwrap", "artifactRef": "<WORKER_ID>" },
"promotion": { "distRef": "<DIST_ID>" }
}A descriptor that names both a backend worker and a promotion dist is served as a
shell view by default — the platform serves the credential-holding shell as the top
document and hosts your dist as the null-origin view. You do not set any viewMode
field; shell delivery is the default for a worker + dist app.
Field rules that will bite you if wrong:
authModemust be one ofanonymous,all-members,invited,namespace-only. Useanonymousfor an open app.backend.artifactRefandpromotion.distRefmust each be a lowercase-hex sha256 (64 chars). They are mutually exclusive with their operator-slug siblings: do not also setbackend.runnerorpromotion.distSlug.backend.typefor a custom worker isbwrap(the sandboxed exec-by-hash runner).namespace/appIdare literal subject tokens ([a-z0-9-]-ish); no dots, no slashes.
Publish it with a request on <subject_prefix>.put, body {"descriptor": <the JSON above>}.
Reply is {"ok":true,"hash":"<content hash>"} or {"ok":false,"reason":"<why>"}.
// author.mjs — usage: node author.mjs <cred.json> <descriptor.json>
import { readFileSync } from "node:fs";
import { connect, jwtAuthenticator, StringCodec } from "nats.ws";
const [, , credPath, descPath] = process.argv;
const cred = JSON.parse(readFileSync(credPath, "utf8"));
const descriptor = JSON.parse(readFileSync(descPath, "utf8"));
const sc = StringCodec();
const nc = await connect({
servers: cred.wss,
authenticator: jwtAuthenticator(cred.user_jwt, new TextEncoder().encode(cred.seed)),
inboxPrefix: cred.inbox_prefix,
});
const reply = JSON.parse(
sc.decode(
(await nc.request(`${cred.subject_prefix}.put`, sc.encode(JSON.stringify({ descriptor })), { timeout: 30000 })).data,
),
);
console.log(reply); // { ok: true, hash: ... } OR { ok: false, reason: ... }
await nc.close();
if (!reply.ok) process.exit(1);node author.mjs authcred.json descriptor.jsonThe platform adopts the descriptor live (zero restart): it fetches the worker by hash into
a sandbox, starts it, and serves the dist by hash as your view. Other verbs on the same
subtree: <subject_prefix>.delete (body {"namespace","app"}) and <subject_prefix>.list
(body {"namespace"} → {ok,keys}).
Step 4 — use it
Open https://your-tinkaria-host/myns/counter/ in a browser. The platform serves the
credential-holding shell, which hosts your view at /myns/counter/view/, holds the
anonymous credential, owns the single NATS connection, and drives your view over NATS. It
renders the count, and each tap increments the shared counter for every connected viewer
in realtime. Open a second tab to see both update together.
GET /myns/counter/→ the tinkaview shell (holds the credential + the NATS connection)GET /myns/counter/view/→ your dist'sindex.html, hosted as the null-origin sandboxed viewGET /myns/counter/config→ the anonymous session credential the shell fetches (your view never touches it){wss, namespace, app, session, user_jwt, seed, inbox_prefix, dist}
To update the app, upload new artifact(s) and re-author the descriptor with the new id(s). Open tabs pick up the new dist automatically — the shell owns the reconnect and reload.
Reactive patterns from your worker
You can build reaction-like behavior in your own worker without registering a platform chain
reaction. Watch your app's KV through the bridge SDK (KVWatch), compute from each delivered
update, then write results back with KVPut: watch → compute → write.
That loop is your worker's responsibility. After a reconnect or worker restart, a watch can hydrate or re-deliver state, so make the writes idempotent (for example, derive output keys from the input key/revision, or compare the current output before writing). The same durable-state gate applies: if the host has not enabled bridge KV, watch/write calls are denied, so fall back to an ephemeral path instead of crashing.
Durable state (survives a worker restart)
The counter above is in-memory: re-authoring the descriptor (or any restart) resets it to
0, and a worker that crashed forgets everything. Durable per-app state fixes that. It is a
small key/value store scoped to your one app: values you kvput survive a worker restart,
and on boot the worker kvgets them back (re-hydration), so a late joiner reads the current
value instead of a blank.
How the confinement works (read this — it is the security contract). The worker still holds
no KV/JetStream credential. It speaks KV ops over the same bridge socket it already uses
for pub/sub; the bridge server — which alone holds the state-bucket connection — routes every
op to the platform state authority under this app's own (namespace, app), derived from the
worker's identity. The worker never puts a namespace, app, or bucket name on the wire — only a
key. So a worker can address only its own app's state and structurally cannot reach another
app's or another tenant's state. (The authority additionally re-checks the namespace's current
owner on every single op and fails closed on a reclaim, so state never leaks across an ownership
change.)
Durable state is operator-gated. An app gets durable KV only when the host is started
with TINKAHOST_STATE_ENABLE=1 (like namespace provisioning, this is an operator switch). On
a host without it, the bridge serves no state and every KV op is denied with an ack
error: bridge denied kv-put: this bridge serves no durable state. Design an opt-in app to
degrade gracefully when a kvput/kvget returns that error (fall back to ephemeral
in-memory state) rather than crash.
Key rules (a kvput with a bad key is denied)
- A key must be non-empty and a legal NATS KV key: only the characters
a–z A–Z 0–9 - / _ = .(any other character — space,:,*, etc. — is rejected). - A key must not start with
$(the reserved$-system space) or with_tinkr_(the store's own reserved bookkeeping prefix, where the owner fingerprint lives). Both are denied unconditionally — akvput/kvget/kvdelon such a key returns an ack errorstatestore: reserved or invalid state key. kvlistnever returns the store's reserved keys — you see only your own keys.
The KV wire ops (extend the Step 0a bridge client)
The KV ops reuse the exact same frame protocol as pub/sub — a client→server frame carrying
an op, and a server→client ack (correlated by id) carrying data and/or error. data
is base64 (same as pub/sub payloads). Add these five ops to the frame struct and client from
Step 0a:
Op (op) | Client sends | Server replies (type:"ack", same id) | Meaning |
|---|---|---|---|
kvget | {op:"kvget", id, key} | {type:"ack", id, data} — or {…, error} if absent/denied | read this app's key |
kvput | {op:"kvput", id, key, data} | {type:"ack", id} — or {…, error} | durably store data under key |
kvdel | {op:"kvdel", id, key} | {type:"ack", id} (deleting a missing key is a no-op) | delete key |
kvlist | {op:"kvlist", id} | {type:"ack", id, data} — data is a JSON array of key strings | list this app's keys |
kvwatch | {op:"kvwatch", id, sid} | {type:"ack", id}, then a stream of kvupdate frames | subscribe to live state changes |
kvunwatch | {op:"kvunwatch", sid} | (no ack) | stop a watch |
For kvwatch, after the ack the server pushes type:"kvupdate" frames on your sid:
{type:"kvupdate", sid, key, data, rev, deleted, init}. The initial hydration snapshot
(current state for a late joiner) arrives as a run of frames with init:true, then live changes
arrive with init:false; deleted:true marks a removed key; rev is the KV revision. A
terminal {type:"kvupdate", sid, final:true, error} ends the watch (empty error = clean stop;
a non-empty error = failed closed, e.g. the namespace was reclaimed).
Extend the frame struct and add the two methods the counter needs (Get + Put):
// add these fields to the Step 0a `frame` struct:
// Key string `json:"key,omitempty"`
// Rev uint64 `json:"rev,omitempty"` // on kvupdate
// Deleted bool `json:"deleted,omitempty"` // on kvupdate
// Init bool `json:"init,omitempty"` // on kvupdate: hydration snapshot
// Final bool `json:"final,omitempty"` // on kvupdate: terminal frame
// KVGet reads this app's durable state key. Returns (nil, err) if absent or denied.
// SAFE to call from the main goroutine (see the deadlock note below — do NOT call it
// from inside a subscription handler in this minimal inline-dispatch client).
func (b *bridge) KVGet(key string) ([]byte, error) {
resp, err := b.roundtrip(frame{Op: "kvget", Key: key})
if err != nil {
return nil, err
}
if resp.Error != "" {
return nil, &strErr{resp.Error}
}
return base64.StdEncoding.DecodeString(resp.Data)
}
// KVPut durably stores value under key for this app. Survives a worker restart.
func (b *bridge) KVPut(key string, value []byte) error {
resp, err := b.roundtrip(frame{Op: "kvput", Key: key, Data: base64.StdEncoding.EncodeToString(value)})
if err != nil {
return err
}
if resp.Error != "" {
return &strErr{resp.Error}
}
return nil
}Same deadlock trap as Pitfall 1. In this minimal client the read loop dispatches
subscription handlers inline, and KVGet/KVPut are round-trips that wait for an ack
the read loop must deliver. So calling KVPut directly inside a Subscribe handler
deadlocks the loop — exactly like a blocking publish-ack. Fix: hand the input off to a
separate state-owner goroutine (below) that owns the count and does the blocking KV
round-trips off the read path. (The platform SDK avoids this by dispatching handlers on their
own goroutine; your hand-written client does not, so keep KV round-trips off the read loop.)
The durable-counter worker (replace Step 0a's main)
This is the Step 0a counter made durable: it hydrates count from KV on boot, and
persists it on every tap. Restart the worker (or re-author the descriptor) and the count
resumes where it left off; a late joiner reads the current value. It keeps all KV round-trips on
a single state-owner goroutine (off the read loop), so there is no deadlock.
func main() {
sock := os.Getenv("TINKALET_BRIDGE_SOCKET")
token := os.Getenv("TINKALET_BRIDGE_TOKEN")
app := os.Getenv("TINKARIA_BACKEND_APP")
session := os.Getenv("TINKARIA_BACKEND_SESSION") // "main"
root := "app." + app + ".session." + session
b, err := dial(sock, token)
if err != nil {
panic(err)
}
// HYDRATE from durable state (main goroutine — not the read loop, so the blocking
// round-trip is safe). Absent key -> start at 0. If durable state is operator-disabled
// the Get returns an error; degrade to ephemeral 0 rather than crash.
var count int64
if v, err := b.KVGet("count"); err == nil {
count, _ = strconv.ParseInt(string(v), 10, 64)
}
publish := func(c int64) {
state, _ := json.Marshal(map[string]any{"count": c})
b.PublishNoAck(root+".view.main", state)
}
// STATE-OWNER goroutine: the ONLY place that reads/mutates/persists count. Running it
// off the read loop makes the blocking KVPut round-trip safe (see the deadlock Callout).
inputs := make(chan string, 64)
go func() {
for verb := range inputs {
if verb == "tap" {
count++
if err := b.KVPut("count", []byte(strconv.FormatInt(count, 10))); err != nil {
// Durable state disabled/denied: keep the in-memory count so the app
// still works ephemerally (it just won't survive a restart).
}
}
publish(count) // "tap" projects the new count; "connect" re-sends current state
}
}()
// Handle inputs. Never touch count or KV here — hand off to the state owner.
err = b.Subscribe(root+".input.>", func(subject string, data []byte) {
inputs <- strings.TrimPrefix(subject, root+".input.")
})
if err != nil {
panic(err)
}
publish(count) // initial state (already hydrated from KV)
select {} // run forever; tinkaria supervises + restarts on exit
}The frontend does not change — it still taps input.tap and renders view.main. Durability
is entirely worker-side: the view can't tell the count is persisted, only that it no longer
resets on a redeploy.
Pitfalls (each of these has bitten someone — read before shipping)
1. Blocking publish-ack inside a subscription handler → the worker stalls
Symptom: the app is "very slow to turn"; most taps do nothing (~1s stall then dropped
inputs). Cause: the bridge read loop dispatches your subscription handler inline, so
if the handler publishes and then waits for the ack, the loop can't read the ack — it
deadlocks until timeout. Fix: publish fire-and-forget from inside a handler (the
PublishNoAck in the worker above), or publish from a separate goroutine (a game-loop
that owns state and ticks). Never round-trip a publish from within the read path.
2. Late joiner sees a blank screen → no retained state
Symptom: the first load of a view shows nothing until someone taps. Cause: core NATS
publishes are not retained; a viewer that connects after the last view publish missed
it. Fix (two parts, both required): (a) in the view, post tinkaview:ready last —
after your message listener is attached — so the shell re-primes you with the latest render
for every region; (b) in the worker, re-publish current state on input.connect (the shell
announces input.connect once on connect), as the counter worker does — so a late joiner
reprojects state the worker still holds in memory. This reprojection is orthogonal to
durability: it does not help if the worker itself forgot the state (a restart). For state
that must survive a worker restart, the worker persists it in durable per-app KV and
re-hydrates from it on boot — see
Durable state.
State can now be durable. A worker holds no $KV/$JS.API credential of its own,
but it can still keep durable per-app state: it speaks KV ops over the same bridge
(kvget/kvput/kvdel/kvlist/kvwatch), and the bridge server — which alone holds the
state-bucket connection — mediates every op under this app's own (namespace, app). So
state survives a worker restart and a late joiner reads the current value. Durable KV is
operator-gated (TINKAHOST_STATE_ENABLE=1); on a host without it every KV op is denied,
so an app that opts in should degrade gracefully. Full API + a durable-counter worker:
Durable state.
3. Author-before-upload → "artifact not found"
Symptom: descriptor accepted but the backend never serves; logs say the artifact is
missing. Cause: you referenced a sha256 that wasn't uploaded yet; the reconcile fails
and won't retry until the next descriptor change. Fix: always upload both artifacts
first, record the ids, then author. (See Step 0's warning.)
4. Wrong dist archive layout / absolute asset URLs
Symptom: blank page or 404 assets under /<ns>/<app>/view/. Cause: index.html nested
in a subdirectory of the tarball, or Vite emitting absolute /assets/... URLs. Fix: tar -C dist -czf dist.tar.gz . so index.html is at the archive root, and set Vite base: "./".
Where each capability lives (recap)
| Action | HTTP (vend only) | NATS subject |
|---|---|---|
| Register / log in | POST /register, POST /login (sets tinka_home) | — |
| Upload credential | POST /apps/upload-cred | — |
| Upload artifact | — | tenant.artifact-upload.session.<owner>.artifact.{begin,chunk,commit} |
| Author credential | POST /apps/author-cred | — |
| Write descriptor | — | tenant.appreg-write.session.<owner>.appreg.{put,delete,list} |
| Vend browser session (the shell fetches this) | GET /<ns>/<app>/config | — |
| Serve the app (shell view — the default for a worker + dist app) | GET /<ns>/<app>/ (credential-holding shell) + /<ns>/<app>/view/* (null-origin view) | — |
| App realtime state | — | app.<app>.session.main.{view.<region>,input.<verb>} |
| Durable per-app state | — | bridge KV ops kvget/kvput/kvdel/kvlist/kvwatch (operator-gated, own app only) |