web

package module
v0.20.3 Latest Latest
Warning

This package is not in the latest version of its module.

Go to latest
Published: Oct 12, 2026 License: MIT Imports: 52 Imported by: 0

README

web

A web framework for Vuka: routes, views, services and controllers are declared with decorators, parameters bind by name, and web.New() gathers everything into an app.

package main

import (
	"github.com/vuka-lang/ui"
	"github.com/vuka-lang/web"
)

@web.Service
type PetStore struct{ db *DB }            // built from its fields, a lazy singleton

@web.Get("/pets")
@web.Template("views/pets.templ")         // a templ component renders the result
func ListPets(store *PetStore) Result[PetList] { … }

@web.Get("/pets/{id}")
@web.Template("views/pet.html")           // html/template, the result as data
func ShowPet(id int, store *PetStore) Result[Pet] { return store.Find(id) }

@web.Get("/")
func Home(r *http.Request) ui.Node {    // JSX
	return <Layout title="Home"><h1>Hi</h1></Layout>
}

@web.Post("/api/pets")
func CreatePet(in NewPet, store *PetStore) Result[Pet] { … }   // JSON in, JSON out

@web.Controller("/admin/pets")
type PetAdmin struct{ store *PetStore }

@web.Get("/")
func (a *PetAdmin) Index() Result[[]Pet] { return a.store.All() }

func main() {
	app := web.New()       // every route, service and controller declared at init
	app.Provide(OpenDB)    // more constructors, fx-style
	app.Run(":8080")       // graceful shutdown on SIGINT/SIGTERM
}

A complete app is in examples/pets; live components in examples/live.

go get github.com/vuka-lang/web

Declaring

@web.Get(path), Post, Put, Patch, Delete, Handle(method, path) a route; works on functions and controller methods
@web.Template(file [, name]) render the result with a view (below); before or after the route decorator
@web.Use(mw…) HTTP middleware for this route, func(http.Handler) http.Handler
@web.Service a struct becomes a dependency
@web.Controller(prefix) a struct becomes a dependency whose route methods are served under prefix
@web.Live(path, opts…) a stateful component becomes a live page (below); web.Style, web.Script, web.CSS, web.JS give it styles and scripts (below)
@web.Cron(spec) the function runs on a schedule (below)

Paths use {name} for a segment and {name...} for the rest of the path, and match exactly (/pets doesn't match /pets/); a request whose path differs from a route's only by a trailing slash is redirected to it (308, method and query kept), unless something else the app serves answers that path (web.ExactPaths() turns this off). Under a controller, "/" is the prefix itself.

Route decorators are Vuka declarers (func(*vuka.Decl)): they run once at init and record the function with its parameter names. Other decorators on the same function still wrap it — @audited below runs on every call.

The app

web.New(opts…) takes a snapshot of what has been declared, so several apps (in tests, say) each get their own container and singletons.

Option
web.Provide(ctors…) constructors for the container (also app.Provide)
web.Eager(ctors…) constructors built and started even when nothing needs them (also app.Eager)
web.OnStart(fn), web.OnStop(fn) lifecycle hooks (also app.OnStart/app.OnStop; below)
web.Check(fn) a check Build runs with its own (also app.Check)
web.Options(opts…), web.OptionFunc(f) several options as one; an option written as code (below)
web.Use(mw…) app-wide middleware (also app.Use); runs for every request, 404s included
web.WithRouter(r) another router (below)
web.Only(pkgs…) take only the declarations of these packages (import paths; "main")
web.Dev() show internal error messages; also VUKA_ENV=development
web.DotEnv(files…), web.NoDotEnv(), web.Require(keys…) env files and required variables (below)
web.OnError(f) replace the error page / JSON error body
web.LiveLayout(f), LiveKey(k), LiveOrigins(p…), LiveTokenTTL(d), WithRelay(r) live pages (below)
web.LiveJoinTimeout(d), LiveIdleTimeout(d), LiveMaxConns(n), LiveMaxConnsPerIP(n), LiveMaxConnsPerPage(n), LiveCheckEvery(d) live connections' limits (below)
web.NoTailwind(), web.Tailwind(path) no stylesheet; or the project's Tailwind stylesheet at another path (below)
web.Style(paths…), Script(paths…), CSS(src), JS(src) the app's own stylesheets and scripts, unscoped (below)
web.NoToolbar() no development toolbar (below)
web.NoCrons(), web.CronClock(c) don't schedule the crons; run them by another clock (below)
web.CSRF(trusted…) refuse cross-origin form posts and the like (below)
web.WrapRoutes, ParamFrom, LiveCheck, GuardLiveEvents hooks for libraries guarding routes and live events (below)
web.Frontend(fsys, root, opts…) a single-page app built by Vite (or anything else) served from the binary (below)
web.ExactPaths() no trailing-slash redirect (above)
web.Headers(h), web.NoSecurityHeaders() the security headers every response carries (below)
web.TrustedProxies(cidrs…) the proxies whose X-Forwarded-For/Forwarded/X-Forwarded-Proto count (below)
web.MaxRequestBody(n) the largest request body, 32 MiB by default; a route's own with @web.MaxBody(n) (below)

app.Build() resolves everything — every parameter of every route, every result and view, the whole dependency graph — and returns all problems at once, each naming the route, its source position and the parameter:

web: GET /pets (main.ListPets at pages.vuka:24): component PetsPage: main.PetList has no field for parameter title
web: GET /admin (main.Admin at admin.vuka:9): parameter db *sql.DB: no provider for *sql.DB, needed by main.Admin at admin.vuka:9
web: dependency cycle: *main.A -> *main.B -> *main.A
Options from libraries

A library hands the app one option that does all it needs: web.Options groups options, and web.OptionFunc runs code against the app inside New, before Build, where every App method is open to it.

func Cache(cfg Config) web.Option {
	return web.Options(
		web.Provide(cfg, NewCache),
		web.OptionFunc(func(a *web.App) {
			a.Use(cacheHeaders)
			a.Get("/cache/stats", (*Cache).Stats)
			a.OnStop(func(ctx context.Context) error { return flush(ctx) })
		}),
		web.Check(func(*web.App) error { return cfg.Validate() }),
	)
}

app := web.New(Cache(cfg), web.Dev())

A check's error is one of Build's, reported with the routes' and the container's.

app.Handler() (an http.Handler to mount anywhere; it panics when Build fails), app.Start(ctx) and app.Run(addr) call Build first, so nothing is served from a broken app. Register everything before the first of them. app.Run("") listens on :$PORT, else :8080. web.Resolve[T](app) returns a dependency, for tests and main.

.env files

web.New() first reads env files into the process environment, so every constructor, Start method and option sees their values through os.Getenv or web.Env. By VUKA_ENV, the first file listed winning:

VUKA_ENV files
development, staging, … .env.<env>.local, .env.local, .env.<env>, .env
test (also when unset under go test) .env.test, .env
unset .env.local, .env

The real environment wins over every file: a key is set only when the process doesn't have it yet. .env.local files hold one machine's settings and are skipped in tests, so test runs don't depend on the developer's box. Keys starting with VUKA_ (VUKA_ENV, VUKA_DEV_DIR, VUKA_DEV_LIVE_KEY…) come from the real environment only: an env file setting one is ignored (and logged), so a file next to a deployed binary never switches it into development. web.IsDev() says whether the process runs in development.

Files are read from the working directory or, when it has none of them, from the nearest parent up to the module root (the directory with go.mod), so go test ./pkg/... finds the project's .env. Without a go.mod in the working directory or above it — a deployed binary — only the working directory is read. Missing files are fine. web.DotEnv("a.env", "b.env") replaces the list (first wins), web.NoDotEnv() reads none, and web.LoadEnv(files…) loads them earlier — for options built from the environment; New reading the same files afterwards changes nothing. With VUKA_ENV=development the app logs which files it read.

Syntax:

# a comment
PORT=8080
export HOST=localhost              # export is allowed; " #" starts a comment
NAME=two words                     # unquoted values are trimmed
RAW='no $expansion, no \n escapes'  # single quotes (and `backticks`) are literal
KEY="-----BEGIN KEY-----
multi-line, with \n \t \" \\ and \$ escapes"
DATABASE_URL=postgres://${HOST}:5432/app
REPLICA_URL="${REPLICA_HOST:-$HOST}"   # :- gives a default for unset or empty
app.name=pets                      # keys: [A-Za-z_][A-Za-z0-9_.]*

$VAR and ${VAR} expand in unquoted and double-quoted values from the real environment, then the keys defined above (in this file or a lower-precedence one); an undefined variable is empty. A malformed line is a Build error with the file and line; nothing of that file is set.

Reading:

port := web.Env("PORT", 8080)                   // int; the default when unset or empty
debug := web.Env[bool]("DEBUG")                 // true/false, 1/0, yes/no, on/off
ttl := web.Env("CACHE_TTL", 5*time.Minute)      // time.Duration
hosts := web.Env[[]string]("ALLOWED_HOSTS")     // comma-separated (any slice of these)
db := web.Env[*url.URL]("DATABASE_URL")         // absolute URLs; also encoding.TextUnmarshaler types
key := web.MustEnv[string]("SECRET_KEY")        // panics when unset or invalid

app := web.New(web.Require("DATABASE_URL", "SECRET_KEY"))

A value that doesn't convert gives the default, and the next app.Build() (or app.Start, for constructors) fails naming the variable. web.Require fails Build listing every missing variable, the files looked for and the keys of .env.example that are unset too:

web: missing environment variables: SECRET_KEY (read .env; looked for .env.local, .env); also unset, listed in .env.example: SMTP_HOST

Errors and logs name variables and files, never values. Keep .env and .env*.local out of git (this repository's .gitignore does) and commit an .env.example with the keys and harmless values instead; in production, set real environment variables.

Parameters

Each parameter binds by its type and name:

parameter from
*http.Request, http.ResponseWriter, context.Context the request (the context is the request's)
a provided type the container
a scalar: string, ints, floats, bool, a encoding.TextUnmarshaler; *T of one (nil when absent), []T (repeated) the path segment of the same name, else the query parameter ?name=; absent → zero value
one struct, or pointer to a struct that isn't provided (with exported fields; else it is a missing dependency) the body: JSON for application/json, else the form (urlencoded, multipart) or query; then path segments into fields of the same name

Struct fields bind by their form tag, else json tag, else name, matched case-insensitively; validate:"required" rejects a zero value. A malformed value answers 400 with a message (id: "x" is not an integer).

A request body is at most 32 MiB (web.MaxRequestBody(n); negative for none), uploads included — a multipart form spills to temporary files past 10 MiB, and never past the limit. A longer body answers 413, whoever reads it: the binding, or a handler reading r.Body. One route sets its own: @web.MaxBody(512 << 20), or web.Attrs(web.MaxBody(n)) from Go.

A call decorator reaches the request through the handler's context.Context argument: web.Request(c.Context()), web.ResponseWriter(c.Context()).

decorator audited(c) {
	r := web.Request(c.Context())
	c.Next()
	log.Println(r.Method, r.URL.Path, c.Err())
}

@web.Delete("/{id}")
@web.Use(requireToken)
@audited
func (a *PetAdmin) Remove(ctx context.Context, id int) error { … }

Results

A handler returns nothing, error, T, (T, error) or Result[T].

result response
nothing, a nil error 204 — unless the handler wrote the response itself through its http.ResponseWriter
ui.Node (JSX, templ) HTML
T with @web.Template the view, HTML
T without JSON, 200
web.Redirect(url) (as a value or an error) 302 for GET/HEAD, else 303
*web.Error, wrapped or not its status and message
any other error 500; its message only in dev

Errors are an HTML page on HTML routes (a view, a ui.Node or a redirect result) and {"error": …, "status": …} elsewhere. web.NotFound, Unauthorized, Forbidden, Conflict, web.BadRequest(msg), web.Status(code, format, args…); errors.Is(err, web.NotFound) matches by status. web.Redirect is also a ui.Node, so a page handler can return it. Panics answer 500. A handler that wrote the response and then returns an error can't change it any more: the error is logged.

Forms that fail. A page answering a POST (or PUT, PATCH, DELETE) is the fragment the handler rendered — for htmx, fetch and the like — unless the browser shows it as its document: a form posted without script (Sec-Fetch-Dest: document, or, from a browser that doesn't send it, an Accept: text/html request without HX-Request). That one is a whole page, as GET would serve it — stylesheet, scripts, toolbar — so a handler can re-render the form with its errors and what was typed instead of redirecting. Such a page isn't live (a reconnect would rebuild it with GET): its JS commands run, its stateful components render but don't connect.

@web.Post("/signup")
func SignUp(ctx context.Context, in Signup, users *Users) ui.Node {
	if err := users.Create(ctx, in); err != nil {
		return <SignupPage form={in} problem={err.Error()} />
	}
	return web.Redirect("/welcome")
}

Views

templ. @web.Template("views/pets.templ") uses the file's one exported component; with several, name one: @web.Template("cards.templ", "Card"). The result fills the component's parameters: whole, when it has one parameter the result is assignable to; otherwise the result is a struct (or pointer to one) and each parameter takes the field of its name (exactly, or with the first letter's case changed: title ↔ Title). Layouts are templ's own composition (@Layout(title) { … }).

templ PetsPage(title string, pets []Pet) { @Layout(title) { … } }
type PetList struct { Title string; Pets []Pet }

html/template. Any other file is parsed once at Build and executed with the result; a second argument names a {{define}}d template to execute.

JSX / render functions. Return a ui.Node; no view needed.

Views are embedded at compile time (vuka.File), so the binary carries them, from the handler's package directory or below it. A .templ file beside the handler is part of its package; one in a subfolder is that folder's package, as Go has it — views/pets.templ starts with package views — and Vuka compiles it and imports it for you:

examples/pets/
  pages.vuka        @web.Template("views/pets.templ"), @web.Template("views/pet.html")
  model/pet.go      package model: Pet, shared by handlers and views
  views/layout.templ  package views: Layout
  views/pets.templ    package views: PetsPage, composed with @Layout
  views/pet.html

Only the subfolder's exported components can be views, and its types can't come from the handler's package (package main can't be imported): put shared types in a package of their own, like model here. A JSX page uses a subfolder's component through its import, <views.Layout title="Home">.

Live components

A stateful component (a struct embedding ui.Live) keeps its state on the server and answers its elements' events over a WebSocket; the page changes in place.

@web.Live("/counter")
type Counter struct {
	ui.Live
	Start int // ?start=3
	n     int
}

func (c *Counter) Mount() { c.n = c.Start }
func (c *Counter) Inc()   { c.n++ }

func (c *Counter) Render() ui.Node {
	return <div><output>{c.n}</output><button onClick={c.Inc}>+</button></div>
}

How a page becomes live. Two ways, one mechanism:

  • @web.Live(path) on a stateful component serves it as a GET page. Its exported fields of provided types are injected; the others that hold text bind from the path segment or query parameter of their name (form tag, else the field name, ignoring case). The page is the component inside the layout web.Layout(f, assets…) names, else the app's web.LiveLayout(f, assets…), else a bare document — f is func(page ui.Node) ui.Node. web.Use(mw…) adds middleware. From Go: app.Live("/counter", &Counter{Start: 1}) (the value's fields are the defaults).

  • Any GET route whose page — a ui.Node result, or a templ view — renders a stateful component or an event handler is live by itself:

    @web.Get("/")
    func Home() ui.Node { return <Shell title="Home"><Counter Start={10} /></Shell> }
    

    A Vuka component rendered from a templ view is live too: templ passes the context on. html/template views and JSON are never live.

The first, HTTP render is complete HTML, and before </body> goes <script src="/_vuka/live.js" data-vk-token="…" defer>. The script opens /_vuka/live?t=<token> and sends join; the server runs the same route again — a GET to the URI in the token, with the WebSocket request's headers (cookies, auth), through the app's and the route's middleware, with its dependencies — and the page it renders becomes the connection's live.Session. That second run mounts the instances that keep state (LiveView's two-phase mount). A join the route answers with a redirect sends redirect (the browser follows it); any other non-page answer (a 404, JSON) is an error, and the connection closes.

The route's handler runs once per connection: its result is the page, and the page is rendered again after every event. State belongs in the components.

Side effects in a live page's handler. The route runs twice for one visit — the GET, then the join (and again on each reconnect) — so what it does besides building the page, sending mail or counting a visit, happens twice. web.IsLiveJoin(ctx) is true only on the join's run; skip them there:

@web.Get("/welcome")
func Welcome(ctx context.Context, m *mail.Mailer) (ui.Node, error) {
	if !web.IsLiveJoin(ctx) {
		if err := m.Send(ctx, welcomeMail); err != nil {
			return nil, err
		}
	}
	return <WelcomePage />, nil
}

A navigation over the connection (navigate) is a visit of its own and isn't a join. Web marks its own run, so a client sending X-Vuka-Live can't make it true; events after the join see false too.

Events. onClick={c.Inc} renders data-vk-on-click="c1:0"; the runtime listens on the document for each event type a page names and sends the nearest handler's id, the element's value (a checkbox's "true"/"false"), the key of a keyboard event, and a submitted form's fields. What a handler receives follows its type (Vuka's rules: nothing, the value, ui.Event, url.Values, a struct bound from the form). Two attributes tune it:

attribute
data-vk-debounce="150" send the element's events (not submits) 150ms after the last one
data-vk-key="Enter Escape" send a keyboard event only for these keys

Submits are preventDefaulted; so are clicks on links and on buttons of a form without a submit handler. A form whose submit handler succeeds is reset to what the server rendered; one whose handler returns an error keeps what the user typed. The element of a click or submit carries aria-busy="true" until the reply.

Patches. The reply carries the HTML of each component whose output changed (the page itself is c0). The runtime morphs the element in place: elements are matched by id, else data-vk-id, else position and tag; a focused field keeps its value and caret; another field takes the server's value when it changed. Give list rows an id for stable identity across inserts and removals. data-vk-ignore (with an id) leaves an element as the browser has it. A whole-document c0 patch morphs the <body> and sets the title.

Browser hooks. <html data-vk-state> is connecting, connected, disconnected, idle or closed (style a banner on it). The document receives vk:patched (detail.ids) after each render or patch and vk:error (detail.message) for a handler's error or a failed message; errors are also logged to the console.

Errors. What the browser reads of an error follows HTTP: a *web.Error's message (web.BadRequest("a name, please"), web.Status(409, …)) as it is, and a field that doesn't parse; any other error — a handler's, Mount's or Update's, or a panic — is Internal Server Error, the error itself going to the log (slog, with the page and the handler). In development (web.Dev(), VUKA_ENV=development) the browser gets every error's text.

Broadcasts. web.Broadcast(ctx, topic, msg) — from an event handler, an Update, or any request — delivers msg to every component subscribed to topic (c.Subscribe(topic) in Mount), in every connected page; each takes it in the Update overload of its type and its page gets the patch. app.Broadcast(ctx, topic, msg) does the same from outside a request. Delivery is in-process; web.WithRelay(r) sends broadcasts through a Relay (Publish(ctx, topic, msg) / Subscribe(deliver)) to reach every replica.

@web.Live("/chat/{room}")
type Chat struct {
	ui.Live
	Room  string   // the path segment
	Log   *ChatLog // injected
	lines []string
}

func (c *Chat) Mount()          { c.Subscribe("chat:" + c.Room) }
func (c *Chat) Update(m Said)   { c.lines = append(c.lines, m.Who+": "+m.Text) }
func (c *Chat) Update(j Joined) { c.lines = append(c.lines, j.Who+" joined") }

func (c *Chat) Send(ctx context.Context, f ChatForm) error {
	return web.Broadcast(ctx, "chat:"+c.Room, Said{c.name, f.Text})
}

Connections. One session per connection; its events and broadcasts are handled one at a time, in order, by the connection's goroutine. The server pings every 25 seconds; a closed connection unmounts its components. app.Stop (and Run's shutdown) closes every connection as "going away". The runtime reconnects with jittered exponential backoff (up to ~10s) and joins again — the route runs again and the state starts over; what was not saved elsewhere is lost.

Limits. A socket must join within LiveJoinTimeout (10s); a joined page the browser sends nothing for LiveIdleTimeout (30m; broadcasts don't count) closes with code 4002, dropping its session, and the runtime joins again at once if the page is in view, else when it is shown. At most LiveMaxConns (10,000) sockets are open, LiveMaxConnsPerIP (100) from one client address (web.ClientIP, so through TrustedProxies) and LiveMaxConnsPerPage (4) with one page token; a socket beyond a cap is refused (503, 429) and the runtime retries. A page is checked when it mounts — its join and each navigation run its route through the app's middleware and gates — and its events and broadcasts trust what it mounted with. LiveChecks run on each joined page every LiveCheckEvery (30s): one failing closes the page with 4001, and it reloads; app.CloseLive(match) closes the pages whose context match picks at once. Each limit is an option; 0 lifts it.

Security. The token is the page's URI and an expiry (LiveTokenTTL, 12h by default), HMAC-SHA256-signed with the app's key: a connection can only rebuild a page the app rendered, and it does so with the connecting browser's own cookies, through the route's own middleware. Each app draws a random key; replicas behind one hostname need a shared web.LiveKey(key). A bad or expired token closes the connection with code 4001 and the runtime reloads the page (at most once in 10 seconds) for a fresh one. WebSocket upgrades only from the page's own origin, unless web.LiveOrigins(patterns…) allows more ("*.example.com").

Navigation

A paged list, tabs kept in the URL, a list and its detail pages: moving between them shouldn't reload the document, drop the connection or lose the scroll position of the page you come back to. ui.Link is a link a live page follows over its connection:

<ui.Link href="/books/7">Dune</ui.Link>                 // navigate: another page
<ui.Link href={shelfURL(s.page + 1)} patch>Next</ui.Link> // patch: this page, other parameters

It renders <a href="…" data-vk-link> (="patch"), so it stays a plain link without JavaScript, on a page that isn't live, in a new tab (target, a modifier key, the middle button) and to another origin — the runtime leaves those clicks to the browser.

  • Patch moves to another URL of the same page. The route runs again for the new URL (its middleware too). A Live page whose type has an Update overload taking a web.URLParams struct stays mounted with its state: its props are bound from the URL again, and that Update gets the new params first — bound the same way, from the path segment or query parameter of each field's name. Then the usual patch is sent. The server decides, not the link: a page without such an Update mounts again, Mount seeing the new URL, as a navigate. So Update must repeat every check Mount makes of what the URL names (that the visitor may see order {id}): an error it returns fails the patch, nothing stays mounted, and the browser loads the URL, the route answering as for any request.
  • Navigate moves to another page: its route runs as a page load does (middleware, gates, dependencies, layout), its components mount on the same connection, and the old ones unmount. Under protocol v2 the page arrives as a change to the trees the connection holds: a shared layout isn't sent again.
type ShelfParams struct {
	web.URLParams
	Page int // ?page=
}

func (s *Shelf) Mount()                { s.show(s.Page) }   // the first URL
func (s *Shelf) Update(p ShelfParams)  { s.show(p.Page) }   // each patch

From a handler, c.PushPatch(href) and c.PushNavigate(href) move the page once the reply is applied; in a chain, ui.JS.Patch(href) and ui.JS.Navigate(href) do it from the browser. A URL whose route answers anything but a page — a redirect, an error, JSON, another origin — is loaded as usual, and so is any navigation while the page isn't connected, or one that fails. Only paths and http and https URLs are followed: the runtime refuses to load a javascript:, data: or other URL, wherever it comes from (a command, a fallback, the server), and the server sends no redirect to one (an app's open redirect to javascript: is an error).

In the browser: the address changes with pushState, and back and forward patch or navigate to match (patch within one page's entries); a navigation scrolls to the top or to the URL's #hash, back restores where the page was, and focus moves to the new page's [autofocus] element, else its <main>, else its first component. The page's <title> follows, and its <head> elements the document lacks (stylesheets, scripts, meta tags) are added once; scripts in the new page's body don't run. <html data-vk-navigating> is set while a navigation is in flight, and vk:navigate fires on window when one completes (detail: {url, patch}). Events aren't sent while the page is on its way out, and a new page drops the optimistic pushes still waiting for the old one, without their hooks. A reconnect joins the page the connection moved to.

JS commands

Opening a menu, showing a dialog, moving focus or copying a snippet needs no server: a chain of JS commands as an on… value runs in the browser, at once, and a Push step in it calls a handler when the server should know too.

func (c *Chores) Render() ui.Node {
	return <section>
		<button onClick={ui.JS.RemoveClass(ui.Ref("menu"), "hidden")}>Pick one</button>
		<ul ref="menu" class="menu hidden">
			{for _, ch := range c.chores {
				<li><button value={ch} onClick={ui.JS.AddClass(ui.Ref("menu"), "hidden").Push(c.Pick)}>{ch}</button></li>
			}}
		</ul>
		{for _, ch := range c.chores {
			<p>{ch} <button value={ch} onClick={ui.JS.Confirm("Delete " + ch + "?").Push(c.Delete)}>delete</button></p>
		}}
		<p ref="status">{len(c.chores)} left</p>
	</section>
}

func (c *Chores) Delete(e ui.Event) {
	// …
	c.PushJS(ui.JS.On(ui.Ref("status")).AddClass("flash")) // runs once the reply is applied
}

A chain renders as data-vk-js-<event>="[[name, args], …]" (the live protocol's "JS commands"). The runtime runs it when the event reaches the nearest element with commands or a handler for it: classes and attributes, Show/Hide/Toggle (the style attribute's display, with optional transition classes during/from/to), Transition, Focus, FocusFirst, a focus stack (PushFocus/PopFocus, for dialogs), Dispatch (a CustomEvent), Exec (the commands held in another attribute), Copy/CopyText (the element gets data-copied for 1.5s), ScrollTo, SetValue (fires input and change), and the steps that steer the chain: Key("Escape") (stop for other keys), Confirm(msg) (stop on cancel), Debounce/Throttle (the steps after them, per element and event; a debounced step waiting in a form runs before the form's submit), and If/ElseIf/Else/End over a target's checked state, value, attribute, selector match or existence. Targets: the element itself, ui.Ref(name) (the refed element of the same component instance, not of the instances inside it), ui.Closest(sel), ui.Inner(sel), or a selector. A submit's commands preventDefault it; a push also does what a handler would (links, a form's buttons). An element with both commands and a handler for one event runs the commands first, and a declined Confirm keeps the event from the server. The attribute commands change no event handler attribute (on…) or srcdoc, and set no URL attribute (href, src, action, formaction, xlink:href, poster, data) to a javascript:, data: or vbscript: URL: ui.JS.SetAttr and its kin panic on such a name when the chain is built and sanitize such a value as an href in markup is, and the runtime refuses either in commands from elsewhere (an Exec's attribute).

Pushed commands. c.PushJS(chain) in a handler, an Update or Mount rides the reply ("js":[{"id","ops"}] on render and patch, both protocol versions) and runs after it is applied, on the component's root element.

Reconciliation. What a command changed — a class, an attribute, the style display — stays across the server's patches while the server keeps rendering that attribute as it did when the command ran: an open menu stays open while the list in it updates. When the server renders a different value, the server wins and the browser's change is dropped. A running transition's classes survive a patch until it ends.

Optimistic updates. A like should count at once and a deleted row should go at once, without waiting for the server — and come back if the server refuses. The steps before a Push in a chain are its predictions: they show immediately, and the push's reply settles them.

<button
	class={likedClass(p.liked)}
	onClick={ui.JS.AddClass(ui.This, "liked").SetText(ui.Ref("likes"), fmt.Sprint(p.likes+1)).
		Push(p.Like).OnError(ui.JS.Show(ui.Ref("like-error")))}
>
	Like
</button>
<b ref="likes">{p.likes}</b>
<p ref="like-error" hidden>Couldn't save your like.</p>
  • Success (a reply without an error): the predictions stay, and reconciliation goes on as for any command — wherever the server renders something else (the count with everyone else's likes), the server wins. The OnSuccess chain runs.
  • Failure (the handler returned an error, an error message, no reply within the push's timeout — 10s by default — or the connection dropping before the reply): each prediction is undone, latest first, except where the server has rendered a different value since or a later command changed it. The OnError chain runs, and vk:error fires as for any error. A push made while the page isn't connected isn't sent (the runtime doesn't queue events) and fails at once.
  • Push(h, ui.KeepOnError()) never undoes (the hooks still run); ui.Timeout(d) sets how long a push waits. A reply that comes after its push timed out is applied as usual, without running hooks again.

What counts: classes, attributes, the style display (Show/Hide, a hidden attribute), the text SetText sets — reconciled like an attribute: it stays while the server renders the same text — and a field's value or checked state from SetValue. Each push in a chain settles only the steps since the previous push; a push a branch skips ends its segment too. Hooks run on the element the chain ran for, or the component's root once that element is gone; a server-pushed chain's pushes work the same way. While a push waits, its element has data-vk-pending; after a failure it has data-vk-failed until its next push — hooks for CSS:

[data-vk-pending] { opacity: .6; }
[data-vk-failed] { outline: 1px solid crimson; }

Calling JavaScript. A chart to redraw, confetti on a save, a date picker to open: Call("name", args…) calls a page function with ctx — {el, event, root, target(spec)} — and the arguments, which may be reads of the page at that moment (ui.Ref("q").Value(), .Checked(), .Attr("name")). Register functions with vuka.register; the first line lets a script run before the runtime (its registrations wait in vuka.q):

<script>
  window.vuka ||= { q: [], register(name, fn) { this.q.push([name, fn]); } };
  vuka.register("confetti", (ctx, n) => fire(ctx.el, n));
</script>

Otherwise the name is a dotted path on window ("Picker.refresh", called with Picker as this), never evaluated as code. A returned promise holds the rest of the chain until it settles; false stops the chain as a declined Confirm does. An unknown function, a throw or a rejection stops the chain and fires vk:error on the element (detail: {error, fn}); when it happens among a push's predictions, that push fails without being sent — its predictions are undone and its OnError chain runs. (A chain that waits on a promise doesn't hold back a data-vk-on-… handler on the same element; put the Push in the chain.)

Without a connection. Commands need no session: a page whose only data-vk- attributes are commands gets the runtime script without a token, runs its commands, and never opens a WebSocket — a static page can have menus and dialogs. A Push on such a page does nothing (the console warns once). A page is live when it renders a stateful component, a handler, or a command that pushes to one.

Protocol versions

The runtime joins asking for protocol v2 ({"type":"join","v":2}) and follows whatever the server answers:

  • v2, render trees. Each component instance arrives as a tree: its statics (the markup the JSX fixes) around its dynamics (what Go expressions render), loops as item frames keyed by key, nested instances as references. Statics travel once per connection and are referred to by id; long strings are kept by the connection too. After an event or a broadcast the server sends, for each instance whose output changed, only the dynamics that changed: a new text or attribute, loop steps (keep, skip, insert, move a keyed item, change one in place), a token patch of a long string — or the instance's plain HTML when that is smaller. The runtime applies the changes to the trees it holds, renders the changed instances' HTML from them and morphs the page with it exactly as in v1, so focus, caret, typed values, busy states and data-vk-ignore behave the same. A counter's click is ~70 bytes instead of ~250; a todo list's events ~3–4× less than v1 (the examples' browser tests log both). A reply the runtime can't apply to its trees makes it join again.
  • v1, HTML. Each changed instance's whole HTML (updates), morphed in. A Vuka session that doesn't speak v2 answers a v2 join in v1, and the page works as before.

web.LiveProtocol(1) makes the app answer in v1 whatever the page asks (LiveProtocol(2), the default, uses v2 when the Vuka session speaks it). The format is Vuka's protocol v2; <html data-vk-protocol="2"> says which one a page got.

The wire, beyond Vuka's protocol

Vuka's wire protocol as is, plus:

GET /_vuka/live?t=<token> the WebSocket; the token in the URL, so a bad one is refused before any message
GET /_vuka/live.js?v=<hash> the runtime, immutable for its hash (ETag otherwise)
join runs the route; a second join on the same connection starts over; its v picks the protocol (above)
redirect sent for a join the route redirects; the connection then closes normally (1000); never to a URL that isn't http or https (an error instead)
navigate runs the URL's route (X-Vuka-Live: navigate) and makes its page the session's; the patch reply carries url and token, the new page's join token; a route that doesn't answer with a page gets redirect (its Location, else the URL); patch: true asks to keep the page mounted, which the server does only for an Update taking web.URLParams; a failed Mount, Update or render answers error, nothing staying mounted
close 4001 invalid or expired token, or a LiveCheck failed: reload
close 4002 the page sat idle (LiveIdleTimeout): reconnect once the page is in view
close 1008 no join within LiveJoinTimeout
close 4004 the route isn't a live page: the error message precedes it; no retry
close 1001 the server is stopping: reconnect
request header X-Vuka-Live: join on the route's run for a join (navigate for a navigation's)

A broadcast arriving before join is dropped (the join renders fresh).

Dependency injection

The container holds constructors — func(deps…) T or func(deps…) (T, error) — and ready values (anything that isn't a function). @web.Service and @web.Controller structs are constructed from their fields (Vuka's vuka.Type.New; tag a field inject:"-" to leave it out). Every value is a lazy singleton per app; dependencies match by exact type (provide a constructor returning the interface to inject an interface). Missing providers, cycles and duplicates are Build errors.

Lifecycle

app.Start (and Run) builds what the routes need, then runs the start sequence; app.Stop runs the stop sequence, its reverse. The sequence holds, in order:

  • ready values given to Provide, and hooks added with OnStart/OnStop, in the order they were added — a ready value was given explicitly, so it starts even when nothing needs it;
  • values built from constructors, in dependency order — only those something needs (a route, a live page, another value), and those provided with web.Eager.

A value implementing Start(context.Context) error is started and one implementing Stop(context.Context) error is stopped. The first failing start ends Start with its error; every stop runs, and their errors are joined.

app := web.New(
	web.Provide(cfg),                 // cfg.Start, if any, runs first
	web.OnStart(migrate),
	web.Eager(NewMailer),             // started though no route takes it
)

Crons

A function runs on a schedule with @web.Cron. It takes a context.Context and dependencies, like a handler, and returns nothing or an error:

@web.Cron("@every 10m")
func forgetUnconfirmed(ctx context.Context, owners *Owners) error { … }

@web.Cron("CRON_TZ=Africa/Dar_es_Salaam 0 7 * * 1-5")
func (r *Reports) Morning(ctx context.Context) error { … }   // a method of a service

app.Cron("digest", "0 8 * * *", sendDigest)                  // from Go
spec
*/15 * * * * five fields: minute, hour, day of the month, month, day of the week; *, a-b, a,b, /step, JAN, MON; Sunday is 0 or 7
@hourly, @daily (@midnight), @weekly, @monthly, @yearly the usual shorthands
@every 30s at each multiple of the duration — :00 and :30 here, on every replica and after a restart
CRON_TZ=Europe/Paris 0 9 * * * the fields read in that zone; else the process's local time

When both day fields are restricted a day matching either runs. Across daylight-saving changes a time the clock skips runs at the jump, and a time it passes twice runs once — unless the hour field is *, when the job keeps its rhythm by the clock. Package web/cron parses specs and computes the next run on its own.

app.Start schedules the crons after building everything they need; Stop stops scheduling, waits for the runs in progress until its context is done, then cancels their context. A run never overlaps the previous one: a tick that finds it still running is skipped and logged. Missed ticks (the machine slept) aren't run late. A panic is recovered and logged with its stack, and errors go to slog with the cron's name. An invalid spec, a parameter that isn't provided or a cron declared twice is a Build error:

web: @web.Cron("61 * * * *") on main.cleanUp at jobs.vuka:12: cron: "61 * * * *": minute: 61 is out of range 0-59

A cron's name is its function's (forgetUnconfirmed; jobs.cleanUp from another package; Reports.Morning). app.RunCron(ctx, name) runs one now and returns its error, for tests and commands; web.NoCrons() serves without scheduling them.

Several replicas. Each process schedules its crons, so every replica runs every tick. Provide a web.CronLock and only the replica that claims a tick runs it:

type CronLock interface {
	Claim(ctx context.Context, name string, tick time.Time) (bool, error)
}

Every replica asks for the same name and tick, so a lock is a unique key — an INSERT of (name, tick) into a table with a unique index (false on a conflict), or Redis's SET cron:name:tick 1 NX EX 3600. Without one the app uses web.NewMemoryCronLock(), which claims each tick once in its process.

Testing. crontest.Run(app, name) runs a cron with its dependencies. crontest.NewClock(t) is a virtual clock (web.CronClock(clock)): nothing fires until the test moves it.

clock := crontest.NewClock(time.Date(2026, 1, 5, 6, 59, 0, 0, time.UTC))
app := web.New(web.CronClock(clock), web.Provide(NewReports))
app.Start(ctx)
clock.WaitTimers(1)          // the scheduler waits for 07:00
clock.Advance(time.Minute)   // the cron runs
crontest.Wait(app)           // and has finished

Mail

web/mail sends email. Provide a *mail.Mailer and inject it:

app := web.New(mail.Provide())   // configured from the environment

@web.Post("/signup")
func SignUp(ctx context.Context, in Signup, m *mail.Mailer) error {
	return m.Send(ctx, mail.Message{
		To:      []string{in.Email},
		Subject: "Welcome to Pets",
		HTML:    <WelcomeEmail name={in.Name} />,   // JSX or templ; the text part is made from it
	})
}
variable
MAIL_DRIVER log (the default: log the message, send nothing) or smtp
MAIL_HOST, MAIL_PORT the SMTP server; the port defaults to 587, 465 with tls, 25 with none
MAIL_USERNAME, MAIL_PASSWORD PLAIN authentication, when set
MAIL_ENCRYPTION starttls (the default; fails when the server can't), tls (from the start), none (local catchers)
MAIL_FROM, MAIL_FROM_NAME the sender when a message names none

mail.Provide(mail.Config{…}) configures it in code instead (also a Transport of your own, a TLS config, timeouts). A Message has From, To, Cc, Bcc, ReplyTo, Subject, Text, HTML (a ui.Node; ui.Safe(s) for a string), Headers and Attachments. Text and HTML go as multipart/alternative, attachments in multipart/mixed, non-ASCII headers and file names encoded. Addresses are parsed, and a line break in any header value is refused before anything is sent (*mail.InvalidError). SMTP is net/smtp with a dial timeout and a deadline; no other dependency.

m.SendLater(ctx, msg) checks and renders the message now and sends it in the background, retrying a temporary failure (a 4xx answer, a failed connection) 3 times, waiting 2s, 4s, 8s; the last failure is logged. The app's Stop waits for the queue within its context. The queue holds at most Config.Queue messages (1000 by default); past it, SendLater returns mail.ErrQueueFull.

Testing. The log driver keeps what it was given: m.Sent() (each with its rendered HTML, Text, Recipients and Raw MIME). mailtest.New() is a quiet one to provide; mailtest.Last(t, m), mailtest.To(t, m, addr) read it, waiting for SendLater first.

A live page's route runs again when its socket joins: mail sent from it goes once with if !web.IsLiveJoin(ctx) { … } around the send (see "Side effects in a live page's handler").

Router

The default router is net/http's ServeMux with Go 1.22 patterns. Any router plugs in through

type Router interface {
	http.Handler
	Handle(method, pattern string, h http.Handler)   // canonical {name} / {name...} patterns
	Param(r *http.Request, name string) string
}

with web.New(web.WithRouter(r)). A chi adapter is a dozen lines (Handle → r.Method(method, pattern, h), Param → chi.URLParam); the tests carry a tiny one.

Tailwind

Write Tailwind classes anywhere — JSX, .templ and .html views, the class names a JS command adds — and they are styled, with no setup:

@web.Get("/")
func Home() ui.Node {
	return <Layout title="Home">
		<button class="rounded bg-emerald-500 px-4 py-2 text-white"
			onClick={ui.JS.AddClass(ui.Ref("note"), "opacity-50")}>Save</button>
		<p ref="note" class="mt-2 text-sm text-gray-500">Unsaved changes</p>
	</Layout>
}

The vuka command builds the stylesheet with the Tailwind CLI (brew install tailwindcss, or npm i -D @tailwindcss/cli) from the module's .vuka, .templ, .html and .go files and those of the dependencies that ship components (github.com/vuka-lang/ui), and every full page the app serves links it: <link rel="stylesheet" href="/_vuka/app.css?v=…"> before </head>, unless the page links it already. Fragments, JSON and live updates don't.

  • vuka run keeps it rebuilt (tailwindcss --watch, into .vuka/tailwind/) and the app serves it from there; open pages swap in each new build without reloading, live state kept.
  • vuka build builds it once, minified, and embeds it in the binary: served as /_vuka/app.css?v=<hash>, cached for good; revalidated by ETag without the hash. vuka gen writes the file that embeds it (vuka_tailwind_gen.go in the main package) for plain go build.

Your own theme, plugins or components go in app.css or styles/app.css at the module root — any file with @import "tailwindcss"; it replaces the built-in one, which is just that line. Elsewhere, name it: web.New(web.Tailwind("assets/site.css")) (a string literal: the vuka command reads it from the source).

web.NoTailwind() turns it all off, as does VUKA_TAILWIND=off for the vuka command. Without the Tailwind CLI, vuka run says how to install it and serves pages unstyled; vuka build fails.

web.RegisterCSS(name, css) serves another stylesheet at /_vuka/<name>; the file vuka build generates registers app.css with it.

Styles and scripts

A component's own stylesheet and script — a keyframe animation, a canvas drawn on mount — are declared beside it, and the vuka command compiles them:

@web.Live("/counter",
	web.Layout(counterPage, web.Style("layout.css")),
	web.Style("counter.css", "parts/*.css"),
	web.Script("counter.ts"),
)
type Counter struct { … }
  • web.Style(paths…), web.Script(paths…), web.CSS(`…`) and web.JS(`…`) are options of @web.Live (the component's), of web.Layout and web.LiveLayout (the layout's own markup) and of web.New (the app's, unscoped). web.Global among the arguments makes one unscoped. Paths are string literals relative to the .vuka file; globs work.
  • Scoped CSS. Every element a scoped component's markup writes carries data-vk-s="<scope>", fixed markup the transpiler adds (a templ layout's too), and its selectors are rewritten to match only those: :global(…) and :root stay unscoped, :deep(…) reaches into child components, :scope is the root, keyframes are the component's own. They compile into the app's Tailwind app.css, so @apply and variants work.
  • Scripts (TypeScript or JavaScript, bundled with esbuild inside the vuka command) are one module per component under /_vuka/c/; every full page of an app with scripts loads a small loader (/_vuka/c/loader.js), which imports a component's module the first time it renders and runs its hooks: mounted(el, ctx) on load, join or arrival, updated after a patch that keeps it, destroyed when it leaves (live navigation too). ctx.push("Inc", payload) calls a Go method of the component as an event — only those its scripts name in ctx.push("…") literals, which the vuka command lists in the manifest and the app allows (live.AllowPush); any other name is refused, ctx.on(name, fn) receives c.PushEvent(name, payload), ctx.ref(name) finds its ref element. ui.JS.Call("bump") reaches the component's own methods before vuka.register's and window's.
  • vuka run keeps them compiled in .vuka/assets (VUKA_ASSETS): a saved stylesheet restyles open pages, a saved script loads on the next mount. vuka build embeds them with a generated vuka_assets_gen.go, which calls web.RegisterAssets(manifest) and web.RegisterScript(name, js).
  • The development toolbar's Assets panel lists the page's scoped components and their files.

Options given from Go (app.Live(…, web.Style(…))) do nothing: the vuka command reads them from .vuka files.

Development toolbar

Why is this page slow, which statements did it run, what did that click do? In development every full page answers on the spot: a handle on its right edge shows the request's time and statement count, and opens a drawer of panels for that request.

  • Request: method, path, the route's pattern, the handler (its name, file and line), the status, the arguments the handler was called with and where each came from (path, query, body, dependency), and the request's and response's headers (cookies and credentials hidden).
  • SQL: each statement in order, with its arguments, rows and time as a bar. A statement run more than once is amber; run three times or more with different arguments it is named as an N+1, one statement per row of an earlier result. A failed or refused statement is red.
  • Timeline: the app's and the route's middleware, each call decorator (@timed), the handler, the render and the statements, as bars against the request's time.
  • Logs: what slog's default logger wrote for the request — with its context (slog.InfoContext(ctx, …)) or from the request's goroutine. The console gets it as before.
  • Live: on a live page, each message its connection carried — the join, each event (Counter.Inc (click)), navigation and broadcast — with its time, statements, reply size and what it ran. A message that failed is red, and so are failures only the browser saw (a push timing out, a dropped connection).
  • Crons: the app's crons, their next and last runs, and a Run now button. Each run is a record of its own, listed under Cron runs: its statements, timeline, logs and error, like a request's.
  • Mail: with web/mail, the messages the request (or cron run) sent or queued — headers, attachments, state — and the HTML in a sandboxed preview.

The list at the top holds the page and each fetch and XHR call it made since it loaded: every response of a development app carries its record's id in X-Vuka-Toolbar. Under vuka run -watch the handle follows the next build: building… 1.2s while it compiles (the last good build still answering), build failed with the compiler's output in the drawer.

It is on under vuka run, web.Dev() or VUKA_ENV=development, and off in a test binary (so tests see the pages the app serves), with web.NoToolbar(), and in any other app: no recording, no routes, no header, no hooks. The toolbar draws itself in a shadow DOM, so the page's styles and its own don't meet, light or dark as the system prefers. The page's HTML is the same but for its script tag (in <head>, beside the stylesheet's link); live updates never carry it. The app keeps its latest 100 requests in memory and serves them under /_vuka/toolbar/.

Statements. orm's ormweb feeds the SQL panel by itself. Another database layer reports its statements with web.NoteStatement(ctx, web.Statement{SQL, Args, DB, Start, Duration, Rows, Err}); app.Toolbar() says whether to bother.

Panels of your own. web.Note(ctx, panel, v) records a value for a panel from request code — a no-op outside development, so it can stay — and web.AddPanel(name, render) draws it:

func (c *Cache) Get(ctx context.Context, key string) ([]byte, bool) {
	v, ok := c.m[key]
	web.Note(ctx, "Cache", Lookup{key, ok})
	return v, ok
}

web.AddPanel("Cache", func(notes []any) ui.Node {
	return <ul>{for _, n := range notes { <li>{n.(Lookup).Key}: {n.(Lookup).Hit}</li> }}</ul>
})

A panel noted without AddPanel lists its values. A panel's markup may use the toolbar's classes: kv and grid tables, dim, errtext, status, and preview for an <iframe> (the Mail panel's).

Authentication

web/auth signs users in and guards the app. The app writes one interface — where users and their passwords live — and every route, live page and live event then needs a sign-in unless marked public:

@web.Service
type Users struct{ db *DB }

func (u *Users) FindLogin(ctx context.Context, login string) (*auth.Identity, string, error) { … } // + the encoded password
func (u *Users) Load(ctx context.Context, id string) (*auth.Identity, error) { … }

@web.Get("/login")
@auth.Public
func Login(r *http.Request) ui.Node { return <auth.LoginPage request={r} /> }

@web.Get("/orders")
@auth.Requires("orders.read")
func Orders(me *auth.Identity, store *Store) Result[[]Order] { return store.For(me.ID) }

@auth.Requires("orders.delete")
func (b *Board) Clear(ctx context.Context) error { … }  // a live event: refused to anyone else, however reached

func main() {
	web.New(auth.Module(auth.Config{Endpoints: "/auth"})).Run("")
}

Cookie sessions for pages and live pages (rotated on sign-in, HttpOnly, SameSite=Lax, Secure under HTTPS), opaque bearer tokens for APIs (POST /auth/token), JWTs issued elsewhere, Argon2id hashes (bcrypt ones upgraded at login; another system's encodings read by Config.Hashers), one error for an unknown login and a wrong password, throttled failures, "remember me" (RememberMe), auth.RevokeUser closing the user's live pages, and authtest for tests. A live event's gate is checked before its handler runs, and again by each gated method it reaches — given the page's context, on any goroutine. A gated call no identity accounts for is refused (LaxGates lets one outside any request run); main and jobs mark their context with auth.System(ctx), and crons are marked by themselves. A failed login takes at least FailedLoginTime (300ms), whatever failed, and throttling counts the account a login finds, not only its spelling. A live page's sign-in is checked when the page mounts (its join, each navigation), its events trusting the identity it mounted with; a sign-in ended through auth (SignOut, Revoke, RevokeUser, a new sign-in, a session found expired) closes its pages at once, and one ended behind auth's back (a store row deleted, another process) is caught within web.LiveCheckEvery (30s), when its identity is also loaded again if LiveRecheck (60s) has passed. Its sessions and tokens are kept in memory unless the app gives it stores. An app's own login handler re-renders its page on a failure (see Forms that fail) with auth.LoginMessage(err); auth.LoginPage keeps the posted login and next. See the auth guide and examples/auth.

CSRF. web.CSRF() (which auth.Module turns on) answers 403 to a POST, PUT, PATCH or DELETE a browser sends from another site's page, read from Sec-Fetch-Site and Origin by net/http's CrossOriginProtection: no token in forms. Requests with Authorization: Bearer pass, as browsers never add one. Live connections check their origin themselves.

Hooks for libraries. What auth builds on, open to any library:

web.WrapRoutes(name, f) f(web.RouteInfo) returns middleware for each route (its method, path, attributes, whether it is a page or a Live page, and a Live page's type); an attribute implementing RouteWrapper() string fails Build when no WrapRoutes of that name is installed
web.Attrs(attrs…) typed attributes for a route or App.Live page added in code
web.ParamFrom(f) parameters of f's result type bind with f(r)
web.Implementation[I](app) the provided value implementing interface I
web.LiveCheck(f) f(ctx) on each joined live page every LiveCheckEvery (30s); an error closes it (4001) and the page reloads
app.CloseLive(match) closes (4001, reload) the joined live pages whose context match(ctx) picks, at once: a library ending a session closes its pages
web.GuardLiveEvents(f) f(ctx, web.LiveHandler) before each live event's handler runs, with the page's context: the handler's name, its component's type, its receiver type and method, its declaration's attributes (Vuka's registry: vuka.FuncOf), whether it is a function literal; an error refuses the event as a handler's error would
web.CurrentLiveEvent() the live event the goroutine handles: its context, its handler's name, Handling() (its handler, or what the handler calls, runs; not the render after it), and Refuse(err) — for a decorator of a handler without a context.Context
web.LiveOrigin() on a goroutine a live connection's goroutine started (one a handler starts), that page's context; nil elsewhere
web.IsLiveJoin(ctx) whether the route runs again for a live page's join
web.TrackRequests(), web.CurrentRequest() whether the calling goroutine serves a request (a live connection's included), and the context of the route it runs
web.CronContext(f) every cron runs with its context passed through f
web.ClientIP(r), web.IsHTTPS(r) the client's address and scheme, through web.TrustedProxies only
web.NoteRequest(ctx, note) a row in the toolbar's Request panel
app.WriteError(w, r, err, html) answer as a handler's error is answered

Frontends

A single-page app — React, Vue, Svelte, anything that builds to static files — is served from the binary beside the app's routes:

//go:embed all:frontend/dist
var dist embed.FS

app := web.New(web.Frontend(dist, "frontend/dist"))
a path naming a file (/assets/index-B3x9kQa1.js, /robots.txt) that file, or 404
any other GET path (/, /pets/7) index.html, so client-side routes survive a reload
the app's routes, live pages, /_vuka/ matched first
.vite/ never served

Files Vite's manifest (.vite/manifest.json) lists as build output, with a content hash in their names, are cached for good (Cache-Control: immutable); everything else, index.html included, is revalidated by its ETag and answered with a 304 when unchanged. Outside development a root without index.html fails Build ("build it first").

web.FrontendAt("/admin") serves it under a prefix (build it with Vite's base: "/admin/"); web.FrontendEnv("APP_NAME", "API_URL") passes those variables' values (the environment's, .env's) to the page: index.html sets them before any module runs, and import.meta.env.APP_NAME reads them. Only the keys named reach the browser.

Development. In development (web.Dev(), VUKA_ENV=development, vuka run) the files are read from disk, and a running Vite dev server takes over: the Vite plugin in vite/vuka.js (vuka() in vite.config) writes <dist>/.vite/vuka-hot.json with the dev server's origin and pid, and the app's pages load their modules from it — the browser stays on the app's address and gets hot module replacement, with no proxy to configure. Other files redirect to the dev server, which has the current public/ ones. A hot file whose process is gone is ignored. With neither a build nor a dev server, pages say how to build. vuka run starts the frontend's Vite (and installs its dependencies first), vuka build runs vite build before embedding; both copy the plugin into frontend/vuka/vite.js. A frontend without a package.json is served as it is. The development toolbar is added to the page; the app's Tailwind stylesheet isn't. An example is in examples/spa.

Static files

app.Static("/assets", fsys) serves an fs.FS (an embed.FS sub-tree, os.DirFS(…)). A directory is its index.html, else 404 — never a listing — and dotfiles and dot directories (.env, .git/) are never served, but .well-known/. web.Frontend serves the same way.

Security headers

Every response carries, unless its handler sets them itself:

header default
X-Content-Type-Options nosniff
Referrer-Policy strict-origin-when-cross-origin
X-Frame-Options DENY
Content-Security-Policy frame-ancestors 'none' ('self' with SAMEORIGIN)
Strict-Transport-Security max-age=31536000, on HTTPS requests only

web.Headers(web.SecurityHeaders{FrameOptions: "SAMEORIGIN", HSTS: -1}) changes them ("-" leaves one out; a negative HSTS too), and web.NoSecurityHeaders() turns them off.

Behind a proxy, web.TrustedProxies("10.0.0.0/8") names it: from those addresses only, web.ClientIP(r) reads the client's address from X-Forwarded-For (else Forwarded) — the nearest one that isn't a trusted proxy — and X-Forwarded-Proto: https counts as HTTPS. Without it, web.ClientIP(r) is the connection's peer and those headers are ignored. Login throttling and the per-address live connection cap count by it.

Plain Go

Everything works from Go without decorators. Reflection can't see parameter names, so scalar parameters need web.Params; structs, request values and dependencies bind as usual.

app := web.New(web.Provide(NewStore))
app.Get("/pets/{id}", func(s *Store, id int) (Pet, error) { … }, web.Params("s", "id"))
app.Post("/pets", func(in NewPet, s *Store) (Pet, error) { … })
app.Get("/page", page, web.View(vuka.File("views/page.html")), web.Wrap(mw))

From Go, vuka.File("…") reads from disk relative to the working directory.

Testing

go test ./...                     # the package
vuka test ./...                   # also examples/ and internal/vukatest, written in Vuka

For an app's crons and mail, see crontest and mailtest above.

The browser tests (examples/live, TestBrowserReconnect, TestBrowserJS…) drive a headless Chrome over the DevTools protocol (internal/chrome) and skip when none is found; VUKA_CHROME names the binary. The runtime's JS commands and navigation also run in node over a small DOM (testdata/livejs/js.js, TestLiveJSCommands), skipped without node.

Limits

  • One struct parameter per handler; scalars bind from the path or query only (use *http.Request for headers and cookies).
  • Views live in the handler's package directory or below it (go:embed doesn't reach up); ../shared/x.templ is an error.
  • A vuka.File literal is embedded only where a decorator or attribute takes one, so there is no web.Layout(file) option; compose layouts in templ, or {{define}} them in one html file.
  • Dependencies match by exact type; no named values or groups.
  • Status codes for success are fixed (200/204); write through http.ResponseWriter for anything else.
  • Live patches are whole components' HTML, morphed in the browser (Vuka's phase 1); a live connection's state starts over when it reconnects, and events sent while disconnected are dropped. The live endpoints are at /_vuka/… from the root: an app mounted under a path prefix needs them routed there.
  • Event handlers of a live page get the join's context: web.Request is the join's request, and writing to web.ResponseWriter goes nowhere.

License

MIT

Documentation

Overview

Package web is a web framework for Vuka: routes, views and services are declared with decorators, and web.New gathers them into an app.

@web.Get("/pets/{id}")
@web.Template("pet.html")
func ShowPet(id int, store *PetStore) Result[Pet] { … }

func main() { web.New().Run(":8080") }

Everything a decorator declares can also be registered from plain Go (App.Get, App.Provide, …); see the README.

Index

Constants

View Source
const (

	// ToolbarHeader names the toolbar record of a development app's
	// response; the toolbar lists a page's fetch and XHR calls by it.
	ToolbarHeader = "X-Vuka-Toolbar"
	// DevDirEnv names the session directory vuka run passes the app: the
	// toolbar is on, and with -watch the directory's build.json says how
	// the next build is going.
	DevDirEnv = "VUKA_DEV_DIR"
)
View Source
const (

	// AssetsEnv names the directory vuka run keeps the compiled assets in:
	// manifest.json and the modules.
	AssetsEnv = "VUKA_ASSETS"
)
View Source
const (

	// CSSEnv names the stylesheet file vuka run keeps up to date.
	CSSEnv = "VUKA_TAILWIND_CSS"
)
View Source
const Global = ":global"

Global, among an asset's arguments, makes it unscoped: web.Style("print.css", web.Global) styles the whole page, and a global script's hooks run once on the page, el being document.body.

Variables

View Source
var (
	NotFound     = &Error{Status: http.StatusNotFound, Message: "not found"}
	Unauthorized = &Error{Status: http.StatusUnauthorized, Message: "unauthorized"}
	Forbidden    = &Error{Status: http.StatusForbidden, Message: "forbidden"}
	Conflict     = &Error{Status: http.StatusConflict, Message: "conflict"}
)
View Source
var ErrBodyTooLarge = &Error{Status: http.StatusRequestEntityTooLarge, Message: "request body too large"}

ErrBodyTooLarge answers a request whose body is over its limit.

View Source
var ErrCronRunning = errors.New("the previous run is still running")

ErrCronRunning is RunCron's error when the cron's previous run hasn't finished: runs never overlap.

Functions

func AcceptsHTML added in v0.11.0

func AcceptsHTML(r *http.Request) bool

AcceptsHTML reports whether r asks for an HTML page (its Accept header names text/html): a browser's navigation or form post, not a script's fetch.

func AddPanel added in v0.9.0

func AddPanel(name string, render func(notes []any) ui.Node)

AddPanel adds a panel to the development toolbar: render draws it for a request from the values the request noted for it (Note), in order. A panel added again under the same name replaces it.

func Broadcast added in v0.2.0

func Broadcast(ctx context.Context, topic string, msg any) error

Broadcast delivers msg to the Update methods of every live component subscribed to topic, in every connected page of the app serving ctx (a request's, or a live event's), and sends each page what changed. A topic of "" reaches every component.

func ClientIP added in v0.15.0

func ClientIP(r *http.Request) string

ClientIP is the address r comes from: its connection's peer, or, when that is one of the app's TrustedProxies, the nearest address in X-Forwarded-For (else Forwarded) that isn't. Throttles and connection caps count by it.

func Controller

func Controller(prefix string) func(*vuka.Type)

Controller makes a struct a service whose route methods are served under prefix.

func Cron added in v0.11.0

func Cron(spec string) func(*vuka.Decl)

Cron runs the function on a schedule (see package cron for the syntax):

@web.Cron("@every 30s")
@web.Cron("0 7 * * *")
@web.Cron("CRON_TZ=Africa/Dar_es_Salaam 0 7 * * 1-5")
func cleanUp(ctx context.Context, store *PetStore) error { … }

Its parameters are a context.Context and provided types; on a method, the receiver is provided too. The cron's name is the function's ("cleanUp"; "jobs.cleanUp" outside package main; "PetStore.Refresh" for a method). An invalid spec fails Build, naming the function and where it is declared.

func CurrentRequest added in v0.15.0

func CurrentRequest() (ctx context.Context, serving bool)

CurrentRequest reports whether the calling goroutine serves an HTTP request of an app with TrackRequests — a live page's connection included — rather than being one the app's own work runs on (a cron's, a job's, main), and the context of the route it is running: a handler's, with its middleware's values; nil outside a route (in app-wide middleware, on a live connection between its routes' runs).

func Delete

func Delete(path string) func(*vuka.Decl)

func Env added in v0.2.0

func Env[T any](key string, def ...T) T

Env returns the environment variable key as a T: a string, bool, integer, float, time.Duration, *url.URL (absolute), a type implementing encoding.TextUnmarshaler, or a slice of those (comma-separated). When key is unset or empty it returns def (or the zero value); when the value doesn't convert, it returns def too and the next Build or Start reports the error.

func Get

func Get(path string) func(*vuka.Decl)

func Handle

func Handle(method, path string) func(*vuka.Decl)

Handle declares a route for method and path: @web.Handle("OPTIONS", "/x"). Path segments in braces bind parameters of the same name: "/pets/{id}"; "{rest...}" matches the rest of the path.

func Implementation added in v0.11.0

func Implementation[T any](a *App) (T, error)

Implementation returns the app's value implementing the interface T: the one provided as T, else the one provided type implementing it. It is an error when none does, or several do. Unlike Resolve it doesn't build the app, so a Check may call it.

func IsDev added in v0.15.0

func IsDev() bool

IsDev reports whether the process runs in development: VUKA_ENV is "development", or vuka run names its session directory (VUKA_DEV_DIR). Both come from the real environment only — an env file can't set them — so a deployed binary is never switched into development by a file next to it. (An app made with the Dev option is in development whatever IsDev says.)

func IsHTTPS added in v0.15.0

func IsHTTPS(r *http.Request) bool

IsHTTPS reports whether r came over HTTPS: its own TLS, or a trusted proxy's X-Forwarded-Proto.

func IsLiveJoin added in v0.13.0

func IsLiveJoin(ctx context.Context) bool

IsLiveJoin reports whether ctx is a live page's route running again for its connection's join: the page was served already (its GET), and the connection rebuilds it — on the first join, and on each reconnect. A handler with side effects — sending mail, counting a visit — skips them then, so they happen once per visit:

if !web.IsLiveJoin(ctx) {
	mailer.Send(ctx, welcome)
}

It is false for the GET itself, for a navigation over the connection (a visit of its own, replacing an HTTP load), and for the page's events and renders after the join, though their context descends from the join's. A client can't make it true: web marks its own run, not a header.

The route runs for every join whether or not the join adopts the GET's session (LiveAdopt) — middleware, gates and the handler itself, which sees IsLiveJoin true either way. Adoption only skips mounting the page's components again, so Mount runs once per view when the join arrives in time; an expired window, a restart or a reconnect mounts them again.

func Live added in v0.2.0

func Live(path string, opts ...LiveOption) func(*vuka.Type)

Live serves a stateful component as a page at path, a GET route:

@web.Live("/rooms/{room}")
type Chat struct {
	vuka.Live
	Room  string     // a prop bound from the path segment, else ?room=
	Store *ChatStore // a prop of a provided type, injected
	log   []string   // state
}

Its exported fields of provided types are injected; the others that hold text bind from the path segment or query parameter of their name. The page is the component inside the app's layout (LiveLayout), or the one Layout names, or else a bare HTML document. Use adds middleware.

func LiveOrigin added in v0.13.0

func LiveOrigin() context.Context

LiveOrigin is the context of the live page whose connection started the calling goroutine — the page's route's, carrying its identity — when a goroutine the connection's own goroutine started calls it: a goroutine an event's handler starts (go b.Clear()), or one a decorator runs a call on. It is nil on any other goroutine: one started further down (by such a goroutine), a pool's worker, a request's, a cron's; and once the page's connection has closed. It reads the goroutine's stack (a few microseconds): a check that has a context.Context asks it instead.

func LivePage added in v0.15.0

func LivePage() context.Context

LivePage is the context of the live page whose connection the calling goroutine serves — the page's route's, carrying its identity — whatever it is doing: handling an event, rendering, delivering a broadcast. It is nil on any other goroutine, and before the connection's first join.

func LoadEnv added in v0.2.0

func LoadEnv(files ...string) error

LoadEnv reads env files into the process environment, as New does: for code that needs their values before New, such as options built from them. No files means the default list (see the README); a key is set only when the environment doesn't have it yet, and the first file listed wins over the later ones. Loading the same files again is a no-op, so New reading them afterwards changes nothing.

func MaxBody added in v0.15.0

func MaxBody(n int64) vuka.Bundle

MaxBody bounds the request body of one route, in bytes (negative for none), in place of the app's MaxRequestBody:

@web.Post("/videos")
@web.MaxBody(512 << 20)
func Upload(r *http.Request) error { … }

From Go: app.Post("/videos", h, web.Attrs(web.MaxBody(512 << 20))).

func MustEnv added in v0.2.0

func MustEnv[T any](key string) T

MustEnv is Env for values that must be there: it panics, naming key, when the variable is unset, empty or doesn't convert.

func Note added in v0.9.0

func Note(ctx context.Context, panel string, v any)

Note records v for the toolbar's panel named panel, drawn by the function AddPanel registered for it, else as a list of the values. It does nothing for a context that no development toolbar watches, so it can stay in production code.

func NoteRequest added in v0.11.0

func NoteRequest(ctx context.Context, n RequestNote)

NoteRequest adds a row to the development toolbar's Request panel for the request (or live message) of ctx: what a library decided about it — who is signed in, which gate let it through. It does nothing for a context that no toolbar watches. Never note a secret.

func NoteStatement added in v0.9.0

func NoteStatement(ctx context.Context, q Statement)

NoteStatement records a statement the request of ctx (or its live page's socket message) ran, for the toolbar's SQL panel. It does nothing for a context that no development toolbar watches.

func Patch

func Patch(path string) func(*vuka.Decl)

func Post

func Post(path string) func(*vuka.Decl)

func Put

func Put(path string) func(*vuka.Decl)

func RegisterAssets added in v0.10.0

func RegisterAssets(manifest []byte)

RegisterAssets registers the manifest of the app's compiled styles and scripts. vuka build calls it from a file it generates in the main package.

func RegisterCSS added in v0.6.0

func RegisterCSS(name string, css []byte)

RegisterCSS registers a stylesheet the app serves at /_vuka/<name>; the one named "app.css" is linked from every full page. vuka build calls it from a file it generates in the main package, with the Tailwind CSS built for the binary.

func RegisterScript added in v0.10.0

func RegisterScript(name string, js []byte)

RegisterScript registers a component's module, served at /_vuka/c/<name> and cached for good: its name carries its content's hash. vuka build calls it from the file it generates.

func Request

func Request(ctx context.Context) *http.Request

Request is the request being served, from a context.Context a handler received: call decorators reach it through c.Context().

func Resolve

func Resolve[T any](a *App) (T, error)

Resolve returns the app's T, building it and its dependencies.

func ResponseWriter

func ResponseWriter(ctx context.Context) http.ResponseWriter

ResponseWriter is the response being written, from a handler's context.

func Service

func Service(t *vuka.Type)

Service makes a struct a dependency: a lazy singleton built from its fields, each injected by type.

func Template

func Template(f vuka.File, name ...string) func(*vuka.Decl)

Template renders a route's result with a view: a .templ file of the same package (its one exported component, or the one named) or an html/template file (its main template, or the one named). The order of @web.Template and the route's method decorator doesn't matter.

Types

type App

type App struct {
	// contains filtered or unexported fields
}

App is a web application: the routes, services and controllers declared with decorators, and whatever was added in code.

func New

func New(opts ...Option) *App

New makes an app of every route, service and controller declared so far (each app takes its own snapshot, with its own singletons). It first reads the env files into the process environment (see LoadEnv), so constructors and Start methods see their values through os.Getenv.

func (*App) Broadcast added in v0.2.0

func (a *App) Broadcast(ctx context.Context, topic string, msg any) error

Broadcast delivers msg to the components subscribed to topic, as the package's Broadcast does.

func (*App) Build

func (a *App) Build() error

Build resolves the app: every route's parameters, results and view, and the dependency graph. It reports every problem at once, before anything is served; Handler, Start and Run call it.

func (*App) Check added in v0.2.0

func (a *App) Check(fn func(*App) error) *App

Check adds fn to the checks Build runs once the routes are compiled; its error is reported with Build's own.

func (*App) CloseLive added in v0.17.0

func (a *App) CloseLive(match func(ctx context.Context) bool) int

CloseLive closes the app's joined live pages whose context — the one their route ran with, as LiveCheck gets it — match reports true for, with code 4001: each page reloads, its route running again for the browser. It returns how many it closes. A page closes before it handles another message or broadcast; one handling a message closes once that is done. match runs on the caller's goroutine, once per joined page: a library ending a session closes the pages signed in with it this way.

func (*App) Cron added in v0.11.0

func (a *App) Cron(name, spec string, fn any) *App

Cron runs fn on a schedule, as @web.Cron does; name names it for RunCron, the logs and the toolbar. fn takes a context.Context and provided types.

func (*App) Delete

func (a *App) Delete(path string, fn any, opts ...RouteOption) *App

func (*App) Eager added in v0.2.0

func (a *App) Eager(ctors ...any) *App

Eager adds constructors whose values Start builds (and starts, when they implement Starter) even when nothing needs them. Provided values that aren't functions always start; constructors otherwise only when needed.

func (*App) Get

func (a *App) Get(path string, fn any, opts ...RouteOption) *App

func (*App) Handle

func (a *App) Handle(method, path string, fn any, opts ...RouteOption) *App

Handle registers fn for method and path, bound as a decorated handler is.

func (*App) Handler

func (a *App) Handler() http.Handler

Handler is the app as an http.Handler, to serve or mount anywhere. It panics when Build fails.

func (*App) Live added in v0.2.0

func (a *App) Live(path string, component ui.Stateful, opts ...LiveOption) *App

Live serves component (a pointer to a stateful component, whose fields are the defaults) as a page at path, as the Live decorator does.

func (*App) OnStart added in v0.2.0

func (a *App) OnStart(fn func(context.Context) error) *App

OnStart adds fn to the app's start sequence, after the values supplied and the hooks added before it and before the values Start constructs.

func (*App) OnStop added in v0.2.0

func (a *App) OnStop(fn func(context.Context) error) *App

OnStop adds fn to the app's stop sequence, which runs in the reverse order of the start sequence: fn runs after everything started after it was added has stopped.

func (*App) Patch

func (a *App) Patch(path string, fn any, opts ...RouteOption) *App

func (*App) Post

func (a *App) Post(path string, fn any, opts ...RouteOption) *App

func (*App) Provide

func (a *App) Provide(ctors ...any) *App

Provide adds constructors (or ready values) to the app's container.

func (*App) Put

func (a *App) Put(path string, fn any, opts ...RouteOption) *App

func (*App) Run

func (a *App) Run(addr string) error

Run serves the app on addr (":" + $PORT, else ":8080", when empty) until SIGINT or SIGTERM, then shuts down gracefully and stops the services.

func (*App) RunCron added in v0.11.0

func (a *App) RunCron(ctx context.Context, name string) error

RunCron runs the cron name now, in the caller's goroutine with ctx, and returns its error (a panic's too). It doesn't wait for a run in progress: it returns ErrCronRunning. For tests, and commands running a cron by hand; the schedule's lock isn't asked.

func (*App) Start

func (a *App) Start(ctx context.Context) error

Start builds every dependency the routes and crons need and every Eager one, then runs the start sequence: each supplied or built value implementing Starter and each OnStart hook, in dependency order (supplied values and hooks as added, before the values built from constructors), and last the crons' scheduler, which Stop stops first.

func (*App) Static

func (a *App) Static(prefix string, fsys fs.FS) *App

Static serves the files of fsys under prefix: a directory as its index.html, never as a listing, and no dotfile or dot directory but .well-known.

func (*App) Stop

func (a *App) Stop(ctx context.Context) error

Stop runs the stop sequence: each value implementing Stopper and each OnStop hook, in the reverse order of the start sequence.

func (*App) Toolbar added in v0.9.0

func (a *App) Toolbar() bool

Toolbar reports whether the app shows the development toolbar: for a library feeding it (NoteStatement) only then.

func (*App) Use

func (a *App) Use(mw ...Middleware) *App

Use adds app-wide middleware; the first added runs first.

func (*App) WaitCrons added in v0.11.0

func (a *App) WaitCrons(ctx context.Context) error

WaitCrons waits until no run the schedule (or the toolbar) started is in progress, or ctx is done: for tests moving a virtual clock, before they move it again — a tick finding the previous run still going is skipped.

func (*App) WriteError added in v0.11.0

func (a *App) WriteError(w http.ResponseWriter, r *http.Request, err error, html bool)

WriteError answers r with err as a handler's error is answered: its status and message (a *web.Error), an HTML page when html is set, JSON otherwise, or what OnError writes.

type Asset added in v0.10.0

type Asset struct {
	// contains filtered or unexported fields
}

Asset is a stylesheet or script for a page: an option of @web.Live, web.Layout, web.LiveLayout and web.New. The vuka command reads it from the source and compiles it; the app itself only serves what it compiled.

func CSS added in v0.10.0

func CSS(src ...string) Asset

CSS is a stylesheet written inline, as Style's files are: web.CSS(`output { … }`).

func JS added in v0.10.0

func JS(src ...string) Asset

JS is a hook module written inline, as Script's files are.

func Script added in v0.10.0

func Script(paths ...string) Asset

Script names hook modules (TypeScript or JavaScript) for the component the option belongs to, as Style does stylesheets. They are bundled in order into one module, loaded when the component first renders on a page; each may export default { mounted(el, ctx), updated(el, ctx), destroyed(el, ctx), …methods }. ui.JS.Call("name") reaches the component's own methods before vuka.register's and window's.

func Style added in v0.10.0

func Style(paths ...string) Asset

Style names stylesheets for the component the option belongs to: the live component, or the layout's own markup (app-wide, for web.New). Paths are relative to the .vuka file and may be globs (parts/*.css, styles/**/*.css), expanded in name order; they must be string literals, since the vuka command reads them from the source. Selectors match only the elements the component's own markup renders: :global(…) and :root stay unscoped, :deep(…) reaches into child components, :scope is the component's root element. The stylesheets compile with the app's Tailwind CSS, in the order listed (later ones win), so @apply, theme() and variants work.

type BodyLimit added in v0.15.0

type BodyLimit int64

BodyLimit is the attribute MaxBody puts on a route.

type CronLock added in v0.11.0

type CronLock interface {
	Claim(ctx context.Context, name string, tick time.Time) (bool, error)
}

CronLock decides which process runs a scheduled tick. Each process of an app schedules its crons by itself, so with several replicas every one runs every tick; provide a CronLock (backed by a database or Redis) and only the replica that claims a tick runs it. Every replica asks for the same name and tick — @every ticks too are aligned to a fixed origin — so a lock is a unique key: an INSERT of (name, tick) into a table with a unique index, or Redis's SET name:tick NX with an expiry. Without one, the app uses NewMemoryCronLock, which claims every tick. RunCron doesn't ask.

func NewMemoryCronLock added in v0.11.0

func NewMemoryCronLock() CronLock

NewMemoryCronLock is the lock of a single process: it claims each tick of a cron once.

type Error

type Error struct {
	Status  int
	Message string
	Err     error // the cause, never shown
}

Error is an error with an HTTP status. Returned by a handler (wrapped or not), it answers with that status and message: an HTML page for an HTML route, JSON otherwise.

func BadRequest

func BadRequest(msg string) *Error

BadRequest is a 400 error with msg.

func Status

func Status(status int, format string, args ...any) *Error

Status is an error answering with status and the formatted message.

func (*Error) Error

func (e *Error) Error() string

func (*Error) Is

func (e *Error) Is(target error) bool

Is matches another *Error of the same status: errors.Is(err, web.NotFound).

func (*Error) PublicMessage added in v0.15.0

func (e *Error) PublicMessage() string

PublicMessage is the message the browser reads (live.PublicError): a live page's handler returning an *Error shows its message, as an HTTP route's answer does, where any other error's text stays in the log.

func (*Error) Unwrap

func (e *Error) Unwrap() error

type FrontendOption added in v0.11.0

type FrontendOption func(*frontend)

FrontendOption configures a Frontend.

func FrontendAt added in v0.11.0

func FrontendAt(prefix string) FrontendOption

FrontendAt serves the frontend under prefix ("/admin") instead of the root: build it with the same base (Vite's base: "/admin/").

func FrontendEnv added in v0.11.0

func FrontendEnv(keys ...string) FrontendOption

FrontendEnv passes the values of these environment variables (and .env keys, which New has loaded) to the frontend's pages: index.html sets them on a global before any module runs, and the Vite plugin turns import.meta.env.KEY into a read of it. Only the keys named are passed; they reach every visitor, so name only public values. Unset keys are left out.

type LiveEvent added in v0.11.0

type LiveEvent struct {
	// contains filtered or unexported fields
}

LiveEvent is the live event a goroutine is handling: CurrentLiveEvent returns it to the event's handler and to its decorators, which, unlike the handler, may have no context.Context to read.

func CurrentLiveEvent added in v0.11.0

func CurrentLiveEvent() *LiveEvent

CurrentLiveEvent is the live event the calling goroutine handles, nil outside one: web's connections handle an event's handler, and every decorator around it, on the connection's goroutine.

func (*LiveEvent) Context added in v0.11.0

func (e *LiveEvent) Context() context.Context

Context is the context the page's route ran with (the join's, or the latest navigation's): its request's values, cookies and middleware's.

func (*LiveEvent) Handler added in v0.11.0

func (e *LiveEvent) Handler() string

Handler names the handler the event targets: "Admin.Delete", or "Admin.Render.func1" for a function literal.

func (*LiveEvent) Handling added in v0.11.0

func (e *LiveEvent) Handling() bool

Handling reports whether the event's handler is running — the handler itself, or anything it calls, a function literal's calls included — rather than the render of what it changed, which follows it on the same goroutine. A gate refusing an event checks calls made while Handling.

func (*LiveEvent) Refuse added in v0.11.0

func (e *LiveEvent) Refuse(err error)

Refuse makes err the event's error, which the page receives as a handler's (vk:error); for a decorator that doesn't let a handler without an error result run.

type LiveHandler added in v0.13.0

type LiveHandler struct {
	Name string // "Board.Clear"; "Board.Render.func1" for a function literal
	// Component is the struct type of the component whose event it is; nil
	// for a handler of a page that is no component.
	Component reflect.Type
	// Recv and Method are a method handler's receiver type (Board for
	// *Board) and name; Recv is nil for a type Vuka registered nothing for.
	Recv   reflect.Type
	Method string
	// Attrs are the handler's typed attributes, written on its declaration
	// (vuka.FuncOf); none for a function literal or a function without.
	// The types' own are vuka.TypeAttrs(Component) and
	// vuka.TypeAttrs(Recv).
	Attrs []any
	// Literal says the handler is a function literal (onClick={func() { … }}):
	// it has no attributes, and the methods it calls are checked by their
	// own decorators when it runs.
	Literal bool
}

LiveHandler describes the handler a live event targets, for a guard (GuardLiveEvents): what Vuka's registry knows of its declaration and of the component it belongs to.

type LiveOption added in v0.2.0

type LiveOption interface {
	// contains filtered or unexported methods
}

LiveOption configures a page declared with Live.

func Layout added in v0.2.0

func Layout(layout func(page ui.Node) ui.Node, assets ...Asset) LiveOption

Layout wraps a Live page's component: the page is layout(component). Styles and scripts (Style, Script, CSS, JS) given with it belong to the layout's own markup.

type Middleware

type Middleware = func(http.Handler) http.Handler

Middleware wraps an http.Handler.

type Option

type Option interface {
	// contains filtered or unexported methods
}

Option configures an App.

func CSRF added in v0.11.0

func CSRF(trusted ...string) Option

CSRF refuses cross-origin requests that change state: a POST, PUT, PATCH or DELETE a browser sends from another site's page (a form posted to the app, a fetch) answers 403, so a signed-in user's cookie can't be used by a page they happen to visit. Browsers say where a request comes from (Sec-Fetch-Site, else Origin), and net/http's CrossOriginProtection reads that: no token to put in every form. GET, HEAD and OPTIONS are never refused, so keep them free of side effects.

trusted are other origins allowed to send such requests ("https://admin.example.com"). A request carrying an Authorization: Bearer header is let through: a browser never adds one by itself, so it can't be forged by another site. Requests that say nothing of where they come from (curl, servers) are let through too. Live connections check their origin themselves (LiveOrigins).

func Check added in v0.2.0

func Check(fn func(*App) error) Option

Check adds a check Build runs; see App.Check.

func CronClock added in v0.11.0

func CronClock(c cron.Clock) Option

CronClock runs the app's crons by clock instead of the system's: a virtual one in tests (crontest.Clock).

func CronContext added in v0.15.0

func CronContext(f func(context.Context) context.Context) Option

CronContext has every cron run with its context passed through f: a library marking crons as the app's own work.

func Dev

func Dev() Option

Dev shows the messages of internal errors in responses; so does the environment variable VUKA_ENV=development.

func DotEnv added in v0.2.0

func DotEnv(files ...string) Option

DotEnv replaces the env files New reads (see LoadEnv); the first listed wins. Relative paths are read from the working directory, or the nearest parent up to the module root holding one of them.

func Eager added in v0.2.0

func Eager(ctors ...any) Option

Eager provides constructors whose values are built and started by Start even when no route needs them (workers, schedulers).

func ExactPaths added in v0.14.0

func ExactPaths() Option

ExactPaths turns off the trailing-slash redirect: a path differing from a route only by a trailing slash is then a 404 (or whatever else answers it, a frontend's index.html).

func Frontend added in v0.11.0

func Frontend(fsys fs.FS, root string, opts ...FrontendOption) Option

Frontend serves a built single-page app from fsys, the files under root:

//go:embed all:frontend/dist
var dist embed.FS

app := web.New(web.Frontend(dist, "frontend/dist"))

A GET path naming a file (its last segment has an extension) is that file, or 404; any other GET path is index.html, so the app's client-side routes work on a reload and from a link. The app's own routes, live pages and /_vuka/ are matched first. Files Vite's manifest lists as build output whose names carry a content hash are cached for good; everything else, index.html included, is revalidated by its ETag. The .vite directory is never served.

Outside development a root without index.html is a Build error. In development (Dev, VUKA_ENV=development, or vuka run) the files are read from disk when root is found there (beside the source calling Frontend, else in the working directory), a running Vite dev server serves the page's modules, and a frontend not built yet shows a page saying how to build it.

func GuardLiveEvents added in v0.13.0

func GuardLiveEvents(f func(ctx context.Context, h LiveHandler) error) Option

GuardLiveEvents has f decide each live event before its handler runs, given the page's context (the identity it joined with) and the handler: an error refuses the event — the handler doesn't run, and the page receives the error as a handler's (vk:error). A library gating events reads the handler's attributes and its component's type's here, where web dispatches, rather than only inside the handler's call.

func Headers added in v0.15.0

func Headers(h SecurityHeaders) Option

Headers replaces the defaults of the security headers every response carries (see SecurityHeaders): a field left zero keeps its default.

func LiveAdopt added in v0.16.0

func LiveAdopt(window time.Duration) Option

LiveAdopt is how long after a live page's GET its join may adopt the session the render mounted, instead of mounting the page's components again: 10 seconds by default, enough for the page to load and connect. 0 (or less) turns adoption off: every join mounts fresh, and renders park nothing. The join still runs the page's route — middleware, gates and the handler, with the connection's own cookies — whether or not it adopts; only the mounting of components it skips.

func LiveCheck added in v0.11.0

func LiveCheck(f func(ctx context.Context) error) Option

LiveCheck has each joined live page checked with f every LiveCheckEvery (30 seconds by default), given the context the page's route ran with: an error closes the connection with code 4001 and the page reloads, its route running again for the browser. A page is checked when it mounts by its route itself — the join and each navigation run it through the app's middleware and gates — and its events and broadcasts trust what it mounted with; LiveCheck is the net for what changes while it sits open without the app hearing of it (a session deleted by another process). A library that ends sessions itself closes their pages at once with CloseLive.

func LiveCheckEvery added in v0.15.0

func LiveCheckEvery(d time.Duration) Option

LiveCheckEvery runs the app's LiveChecks on each joined page this often: 30 seconds by default; 0 never runs them. A page some check fails closes with code 4001 and reloads, so a session ended where the app doesn't hear of it (another process, a store edited by hand) or a revoked permission reaches pages that sit open; an ending the app hears of closes them at once (CloseLive).

func LiveIdentity added in v0.16.0

func LiveIdentity(f func(ctx context.Context) string) Option

LiveIdentity tells live adoption who a context is signed in as: a stable key for the identity ("" for no one). A join adopts a parked session only when every registered function returns for the join's context what it returned for the render's — the same user, or anonymous both times. A library that signs visitors in registers one, so one visitor's page is never adopted by another's connection.

func LiveIdleTimeout added in v0.15.0

func LiveIdleTimeout(d time.Duration) Option

LiveIdleTimeout is how long a joined page may send nothing before its connection closes and its session, with its components' state, is dropped: 30 minutes by default; 0 never closes one. The runtime connects again, rebuilding the page's session, at once when the page is in view and otherwise when it is shown again; broadcasts don't keep a page from being idle.

func LiveJoinTimeout added in v0.15.0

func LiveJoinTimeout(d time.Duration) Option

LiveJoinTimeout is how long a live connection may stay open without joining its page: 10 seconds by default. A socket that sends nothing, or anything but its join, is closed when it runs out.

func LiveKey added in v0.2.0

func LiveKey(key []byte) Option

LiveKey is the key signing live pages' join tokens. Without it each app draws a random one, so a page rendered by one process can't join another: set it when several replicas serve the app.

func LiveLayout added in v0.2.0

func LiveLayout(layout func(page ui.Node) ui.Node, assets ...Asset) Option

LiveLayout is the layout of every Live page without its own. Styles and scripts given with it belong to the layout's own markup, as Layout's do.

func LiveMaxConns added in v0.15.0

func LiveMaxConns(n int) Option

LiveMaxConns caps the app's live connections: 10,000 by default. A socket beyond it is refused with 503 Service Unavailable before it opens, and the runtime tries again later; 0 sets no cap.

func LiveMaxConnsPerIP added in v0.15.0

func LiveMaxConnsPerIP(n int) Option

LiveMaxConnsPerIP caps the live connections from one client address: 100 by default (a building behind one address has many visitors). A socket beyond it is refused with 429 Too Many Requests; 0 sets no cap.

func LiveMaxConnsPerPage added in v0.15.0

func LiveMaxConnsPerPage(n int) Option

LiveMaxConnsPerPage caps the live connections open with the same page token, one rendered page's: 4 by default, room for a reconnect overlapping its old socket. A socket beyond it is refused with 429; 0 sets no cap.

func LiveOrigins added in v0.2.0

func LiveOrigins(patterns ...string) Option

LiveOrigins lets pages of other origins open live connections: host patterns as path.Match takes them ("app.example.com", "*.example.com"). By default only the app's own origin may.

func LiveProtocol added in v0.3.0

func LiveProtocol(v int) Option

LiveProtocol caps the live protocol version the app speaks: 2 (render trees: a page gets each component's statics once, then only the dynamics that changed) by default, or 1 (each changed component's HTML). A page asks for the newest it knows and gets at most this; v2 also needs a Vuka live session that speaks it, else pages get v1.

func LiveTokenTTL added in v0.2.0

func LiveTokenTTL(d time.Duration) Option

LiveTokenTTL is how long a rendered live page may (re)join; 12 hours by default. A page older than that reloads when it reconnects.

func MaxRequestBody added in v0.15.0

func MaxRequestBody(n int64) Option

MaxRequestBody bounds every request's body, in bytes: a body longer gets 413. 32 MiB by default; negative for none. A route sets its own with @web.MaxBody.

func NoCrons added in v0.11.0

func NoCrons() Option

NoCrons keeps Start from scheduling the crons, for a replica that only serves requests; RunCron still runs them.

func NoDotEnv added in v0.2.0

func NoDotEnv() Option

NoDotEnv keeps New from reading env files.

func NoSecurityHeaders added in v0.15.0

func NoSecurityHeaders() Option

NoSecurityHeaders turns the default security headers off: a proxy in front of the app sets them, say.

func NoTailwind added in v0.6.0

func NoTailwind() Option

NoTailwind turns the app's stylesheet off: pages get no link to /_vuka/app.css and the vuka command builds none.

func NoToolbar added in v0.9.0

func NoToolbar() Option

NoToolbar turns the development toolbar off.

func OnError

func OnError(f func(w http.ResponseWriter, r *http.Request, e *Error, html bool)) Option

OnError replaces the error page and JSON error body.

func OnStart added in v0.2.0

func OnStart(fn func(context.Context) error) Option

OnStart adds a hook Start runs; see App.OnStart.

func OnStop added in v0.2.0

func OnStop(fn func(context.Context) error) Option

OnStop adds a hook Stop runs; see App.OnStop.

func Only

func Only(pkgs ...string) Option

Only limits the declared routes, services and controllers an app takes to those of the given packages (import paths; "main" for the main package).

func Options added in v0.2.0

func Options(opts ...Option) Option

Options is one Option applying opts in order; nil options are skipped.

func ParamFrom added in v0.11.0

func ParamFrom[T any](f func(r *http.Request) (T, error)) Option

ParamFrom binds handler parameters of type T with f, before any other rule: f's error answers the request as a handler's would. A library gives handlers its own values this way — the signed-in user, say.

func Provide

func Provide(ctors ...any) Option

Provide adds constructors to the app's container: func(deps…) T or func(deps…) (T, error). A value that isn't a function is provided as is.

func Require added in v0.2.0

func Require(keys ...string) Option

Require fails Build when one of the environment variables is unset or empty, naming each missing one and the env files looked for.

func Tailwind added in v0.6.0

func Tailwind(path string) Option

Tailwind names the project's Tailwind stylesheet, relative to the module root, when it isn't app.css or styles/app.css. The vuka command reads the path from the source, so it must be a string literal.

func TrackRequests added in v0.15.0

func TrackRequests() Option

TrackRequests has the app note the goroutine serving each request, and the context of the route it runs, for CurrentRequest: a library checking work a request's goroutine does without being given the request's context.

func TrustedProxies added in v0.15.0

func TrustedProxies(cidrs ...string) Option

TrustedProxies names the proxies in front of the app, as CIDRs ("10.0.0.0/8", "fd00::/8") or single addresses: from them, and only from them, ClientIP reads X-Forwarded-For (else Forwarded), and an HTTPS request is told by X-Forwarded-Proto. An entry that doesn't parse fails Build.

func WithRelay added in v0.2.0

func WithRelay(r Relay) Option

WithRelay sends the app's broadcasts through r instead of delivering them in-process only.

func WithRouter

func WithRouter(r Router) Option

WithRouter serves the app's routes with r instead of net/http's ServeMux.

func WrapRoutes added in v0.11.0

func WrapRoutes(name string, f func(RouteInfo) Middleware) Option

WrapRoutes has Build call f with each route of the app (decorated or added in code, Live pages included); the middleware f returns, if not nil, wraps the route outside its own (@web.Use) middleware and inside the app's. Functions added first wrap outermost. name is what a RouteAttr names.

type OptionFunc added in v0.2.0

type OptionFunc func(*App)

OptionFunc is an Option written as a function of the App, for libraries defining their own options: it runs inside New, before Build, and may call any of the App's methods (Provide, Use, Handle, OnStart, Check, …).

type Redirect

type Redirect string

Redirect, returned by a handler as its result or as its error, redirects to the URL: 302 for GET and HEAD, 303 See Other otherwise. It is also a vuka.Node, so a handler returning a page can redirect instead.

func (Redirect) Error

func (r Redirect) Error() string

func (Redirect) Render

func (r Redirect) Render(context.Context, io.Writer) error

type Relay added in v0.2.0

type Relay interface {
	Publish(ctx context.Context, topic string, msg any) error
	Subscribe(deliver func(topic string, msg any))
}

Relay carries broadcasts between the replicas of an app: Publish sends a message to every replica, and each calls the function Subscribe was given with what arrives (its own messages included). Encoding the messages, whose dynamic types pick the Update methods they reach, is the relay's business.

type RequestNote added in v0.11.0

type RequestNote struct {
	Section string // the heading it goes under: "Auth"
	Name    string
	Value   string
	Refused bool // shown as a failure
}

RequestNote is a row of the development toolbar's Request panel.

type RouteAttr added in v0.11.0

type RouteAttr interface{ RouteWrapper() string }

A RouteAttr is a typed attribute that only a WrapRoutes function gives a meaning to — a permission a route requires, say. Build fails when a route carries one and the app has no WrapRoutes of the name RouteWrapper returns, so a route never goes unguarded because the library guarding it wasn't installed.

type RouteInfo added in v0.11.0

type RouteInfo struct {
	Method, Path string
	Where        string // "main.ShowPet at pages.vuka:24", "live page main.Counter"
	// Attrs are the typed attributes of the route's declaration, after those
	// of its controller's type: the attributes of a @web.Live page's type,
	// or those given with web.Attrs from Go.
	Attrs []any
	Live  bool // a Live page
	// LiveType is a Live page's component type (the struct, not a
	// pointer); nil for other routes.
	LiveType reflect.Type
	// HTML says the route answers with a page (a ui.Node, a view or a Live
	// page): its errors are pages, and a browser may be sent elsewhere.
	HTML bool
}

RouteInfo describes a route for the function WrapRoutes registers.

type RouteOption

type RouteOption func(*routeSpec)

RouteOption configures a route registered in code.

func Attrs added in v0.11.0

func Attrs(attrs ...any) RouteOption

Attrs gives a route registered in code typed attributes, as attributes written on a decorated handler are: they reach WrapRoutes. A vuka.Bundle (a composed decorator, such as one a library's attribute function returns) gives its attributes. It also goes to App.Live.

func Params

func Params(names ...string) RouteOption

Params names a handler's parameters in order, which reflection can't see: scalar parameters bind by name.

func View

func View(f vuka.File, name ...string) RouteOption

View renders the route's result with a view, as @web.Template does.

func Wrap

func Wrap(mw ...Middleware) RouteOption

Wrap adds middleware to the route, as @web.Use does.

type Router

type Router interface {
	http.Handler
	Handle(method, pattern string, h http.Handler)
	Param(r *http.Request, name string) string
}

Router matches requests to routes. Patterns use the canonical syntax: "/pets/{id}" binds a segment, "/files/{path...}" the rest of the path, and a pattern matches its path exactly. Param returns a bound value for a request the router dispatched.

type SecurityHeaders added in v0.15.0

type SecurityHeaders struct {
	// ContentTypeOptions: "nosniff" by default, so a browser never runs
	// an upload or a JSON answer as a script or a page.
	ContentTypeOptions string
	// ReferrerPolicy: "strict-origin-when-cross-origin" by default, so a
	// URL's path and query stay on the site.
	ReferrerPolicy string
	// FrameOptions: "DENY" by default, or "SAMEORIGIN": who may show the
	// app's pages in a frame. The CSP's frame-ancestors follows it.
	FrameOptions string
	// CSP is the Content-Security-Policy: "frame-ancestors 'none'" by
	// default ('self' with FrameOptions "SAMEORIGIN"; left out with "-").
	CSP string
	// HSTS is the Strict-Transport-Security max-age sent on HTTPS requests
	// (r.TLS, or X-Forwarded-Proto: https from a trusted proxy); 365 days
	// by default. HSTSSubdomains adds includeSubDomains.
	HSTS           time.Duration
	HSTSSubdomains bool
}

SecurityHeaders are the headers every response of an app carries unless its handler sets them itself. The zero value of a field is its default; "-" (or, for HSTS, a negative duration) leaves the header out.

type ServeMux

type ServeMux struct {
	// contains filtered or unexported fields
}

ServeMux is the default Router: net/http's ServeMux with Go 1.22 patterns.

func NewServeMux

func NewServeMux() *ServeMux

func (*ServeMux) Handle

func (m *ServeMux) Handle(method, pattern string, h http.Handler)

func (*ServeMux) Param

func (m *ServeMux) Param(r *http.Request, name string) string

func (*ServeMux) ServeHTTP

func (m *ServeMux) ServeHTTP(w http.ResponseWriter, r *http.Request)

type Starter

type Starter interface{ Start(context.Context) error }

Starter is a provided value with work to do when the app starts: Run calls Start in dependency order.

type Statement added in v0.9.0

type Statement struct {
	SQL      string
	Args     []any
	DB       string // the database's name
	Start    time.Time
	Duration time.Duration
	Rows     int64 // rows affected; -1 for a query
	Err      error
	// Refused is a statement the database layer refused before sending it;
	// Err says why.
	Refused bool
}

Statement is a statement for the toolbar's SQL panel: what a database layer passes NoteStatement. orm's ormweb reports the ORM's statements by itself.

type Stopper

type Stopper interface{ Stop(context.Context) error }

Stopper is a provided value with work to do when the app stops: Stop is called in the reverse order of construction.

type URLParams added in v0.7.0

type URLParams struct{}

URLParams, embedded in a struct, makes it the parameters a Live page's Update method takes when the page is patched — moved to another URL of its route, the component staying mounted:

type ShelfParams struct {
	web.URLParams
	Page int // ?page=
}

func (s *Shelf) Update(p ShelfParams) { s.load(p.Page) }

Its fields bind from the new URL as the component's props do (path segment, else query parameter, of their name); the component's props are bound again too. Update runs before the patch renders. Mount, not Update, sees the first URL.

Only a page with such an Update stays mounted across a patch, whatever the browser asks: any other mounts again, Mount seeing the new URL. So Update must make every check Mount makes of what the URL names — that the visitor may see order {id}, say. An error it returns fails the patch: nothing of the page stays mounted, and the browser loads the URL, the route answering as for any request.

type Uses

type Uses func(*vuka.Decl)

Uses is what Use returns: a declarer adding middleware to one route, and an Option adding it to the whole app.

func Use

func Use(mw ...Middleware) Uses

Use adds HTTP middleware, the first given outermost: @web.Use(mw) on a route, or web.New(web.Use(mw)) for every request of the app.

Directories

Path Synopsis
Package auth signs users in to a web app and guards its routes, live pages and live events.
Package auth signs users in to a web app and guards its routes, live pages and live events.
authtest
Package authtest helps test apps using auth: requests and live sessions signed in as anyone, without a login, and Users kept in memory.
Package authtest helps test apps using auth: requests and live sessions signed in as anyone, without a login, and Users kept in memory.
Package cron parses cron schedules and computes their next run: five fields (minute hour day-of-month month day-of-week), the descriptors @yearly, @monthly, @weekly, @daily, @hourly and @every <duration>, and a CRON_TZ= prefix naming the time zone the fields are read in.
Package cron parses cron schedules and computes their next run: five fields (minute hour day-of-month month day-of-week), the descriptors @yearly, @monthly, @weekly, @daily, @hourly and @every <duration>, and a CRON_TZ= prefix naming the time zone the fields are read in.
Package crontest tests an app's crons: Run runs one by name with its dependencies, and Clock is a virtual clock to test schedules with — time stands still until the test moves it.
Package crontest tests an app's crons: Run runs one by name with its dependencies, and Clock is a virtual clock to test schedules with — time stands still until the test moves it.
examples
pets/model
Package model holds the pets app's data types, shared by its handlers and its views.
Package model holds the pets app's data types, shared by its handlers and its views.
internal
chrome
Package chrome drives a headless Chrome over the DevTools protocol, for the tests of live pages: open pages, click, type, press keys and evaluate JavaScript.
Package chrome drives a headless Chrome over the DevTools protocol, for the tests of live pages: open pages, click, type, press keys and evaluate JavaScript.
liveproto
Package liveproto is the seam between the web framework's live transport and the Vuka live session it drives: a Session speaks the protocol version its page joined with (v1: HTML, v2: render trees) and answers each client message with the message to send back.
Package liveproto is the seam between the web framework's live transport and the Vuka live session it drives: a Session speaks the protocol version its page joined with (v1: HTML, v2: render trees) and answers each client message with the message to send back.
livetree
Package livetree is a stand-in for the Vuka live session's protocol v2 until the session speaks it: it runs a v1 session and turns the HTML of each changed instance into render trees and their changes, in the format of Vuka's "Protocol v2: render trees" (docs/features/components.md).
Package livetree is a stand-in for the Vuka live session's protocol v2 until the session speaks it: it runs a v1 session and turns the HTML of each changed instance into render trees and their changes, in the format of Vuka's "Protocol v2: render trees" (docs/features/components.md).
Package mail sends email from a web app: a Mailer service apps inject, configured from the environment (or .env), with a log driver for development and tests and an SMTP driver for production.
Package mail sends email from a web app: a Mailer service apps inject, configured from the environment (or .env), with a log driver for development and tests and an SMTP driver for production.
mailtest
Package mailtest tests the mail an app sends: a quiet log-driver Mailer to provide, and helpers reading what it kept.
Package mailtest tests the mail an app sends: a quiet log-driver Mailer to provide, and helpers reading what it kept.

Jump to

Keyboard shortcuts

? : This menu
/ : Search site
f or F : Jump to
y or Y : Canonical URL