Repository navigation
Releases: cuonggt/tug
Release list
v0.39.0
Browser sessions: an app made by tug new -auth keeps a row of each login, which its session needs at every request, so its security page lists the browsers an account is logged in from, and logs one out, or every other, with no new password; and a login from a browser the account hasn't been logged in from before is told of, in the bell and by mail. A login lived in its browser's cookie alone, and one on a lost laptop, which "Remember me" keeps for 30 days, ended only with a new password. Laravel's Jetstream lists the browsers an account is logged in from, and GitHub and Google mail the owner of a login from a new one.
The security page has, after the password, the passkeys and two-factor logins:
Browsers
Where you're logged in. Log out of one you don't know, or of every one but this.
Chrome on macOS This browser
127.0.0.1, logged in Oct 2, 2026, last seen Oct 2, 2026
Firefox on Windows [Log out of Firefox on Windows]
127.0.0.1, logged in Oct 2, 2026, last seen Oct 2, 2026
[Log out of every other browser]
and a login from the Firefox, new to the account, rang the bell, "A new login, from Firefox on Windows.", and mailed its owner:
Subject: A new login to your blog account
Hi Ann Lee,
Your account was logged in to from Firefox on Windows, at 127.0.0.1: a browser it hadn't been logged in from before.
See where you're logged in:
http://localhost:8080/settings/security
If that wasn't you, log it out there, and change your password: someone else knows it.
blog
What's new:
auth.LoginID:auth.Logingives each login an ID of its own, 26 random characters, new at each login, whichLoginIDreads: a cookie can't be taken back, so an app that lists its logins, and ends one from another browser, keeps them by their IDs, and logs out a session whose login it no longer has. A session logged in before has none.Session.Lifetime: how long a session lasts without a request: its own, asSetLifetimeset it, or else the Store's.- The auth starter's logins: a row of each, in a
loginstable, by the SHA-256 of its ID, with its browser's User-Agent, its address and its session's lifetime, whicha.userreads at each request: a session whose login has no row is logged out, at its next request. Every way in, the password, its code, a passkey, registering and a new password, logs in througha.logIn, inlogins.go. Logging out deletes its row, a new password every row of the account's, andprune-loginsthe ended ones, every hour. - The browsers on the security page: in React, Vue and Svelte, the browsers an account is logged in from, this one first, by
browserName, a few lines that tell Chrome, Edge, Firefox, Safari and Opera on macOS, Windows, iOS, Android, ChromeOS and Linux apart, with their addresses, and when each logged in and was last seen. One logs out by its button, or every other at once, behind the password asked again, as the page is. - A login from a new browser: told of, in the bell and by mail, as a new password is, in the login's own transaction. A browser is told apart by a cookie of its own,
tug_browser, a random ID it keeps for a year, logged in or out, which thebrowserstable keeps the hash of; the same browser on another address is no new one, and registering, a reset and a new password are no logins to tell of. - The guide: Accounts, the browsers, their page and their notification, and the logins before going live; package auth's
LoginID; Forms, a session'sLifetime; and the README.
Upgrading: nothing an app calls changed: go get github.com/cuonggt/tug@v0.39.0. An app made by tug new -auth before keeps its logins in their cookies alone, as it did; to list them, it takes the browsers from an app tug new -auth makes now: logins.go and logins_test.go, its database's logins_db.go and migrations/20261002053504_create_logins.sql, and the changes to auth.go, settings.go, twofactor.go, passkeys.go, notifications.go, main.go and the security page, with its frontend's LoginSettings. Its sessions from before have no login's ID, and log in again, once.
The guide is in docs/. tug needs Go 1.26.
v0.38.0
A development mailbox: under tug dev, with no MAIL_HOST, the mail an app sends is kept in a mailbox the app shows at /_tug/mail, each mail as a mail program would show it, where it was written out to the terminal as text alone: who it's from and to, its subject, and its text, with the link that verifies an email to copy out of the log. Its HTML, which a person's mail program shows, wasn't seen, nor its files, nor the message a mail server would take. Phoenix keeps its app's mail at /dev/mailbox, and Laravel's Sail and Herd run Mailpit beside the app: tug's mailbox needs nothing beside it.
Registering in an app made by tug new -auth, run as tug dev runs it, puts one line in its log:
mail, kept in the mailbox (MAIL_HOST isn't set): "Verify your email for blog" to ann@example.com: http://localhost:8080/_tug/mail/01M3XFRBZWEBFY0J00VADJQB31
and the mail's page has, as text, its frame of HTML aside, and its boundary cut short:
← Mailbox
Mail · Fri 2 Oct, 12:00:26
Verify your email for blog
HEADERS
To <ann@example.com>
Subject Verify your email for blog
Date Fri, 02 Oct 2026 12:00:26 +0700
Message-ID <YR2FDDMSODUU6PMX6YJEWC2CFP@localhost>
MIME-Version 1.0
Content-Type multipart/alternative; boundary=225c68997f…
HTML
[the mail's HTML, as a mail program shows it, its button opening the link in a tab of its own]
TEXT
Hi Ann Lee,
Follow this link to verify that ann@example.com is yours, and it's where blog will reach you.
Verify my email:
http://localhost:8080/verify-email/1/tmbf8q.Cvw-CkVU76YLr9ofgz1YSA
The link works for a day. If you didn't make an account, someone typed your email by mistake: there's nothing to do.
blog
SOURCE
The message as a mail server takes it
What's new:
- A mailbox under tug dev: with no
MAIL_HOST,mail.FromEnvreturns amail.Mailboxundertug dev, which setsTUG_DEV. It keeps each mail asSMTPwould send it, in.tug/mail, which git leaves out, the newest 100, and writes a line to the log for each: whom it's to,Bcctoo, its subject, and its link, atAPP_URL. WithoutTUG_DEV, as a deploy and a test run, mail is written out byLog, as before. - The mailbox's pages:
/_tug/mail, which the App answers underConfig.DevTools, before its own middleware and routes, as it does the endpoints of Inertia's DevTools: the mail, newest first, and each mail's page, with its headers, itsBcc, which no header has, its HTML, its text, its links each a link, its files, to download, and its source. They run no script, and a reload shows the mail that came since. - The HTML in a sandboxed frame: nothing in a mail runs, and its links open in a tab of their own, as the link that verifies an email does, into the app, in the browser the developer is logged in with.
mail.Mailbox: a Mailer, asLogis, for an app that makes its own:Dir,.tug/mailby default, where the App reads it;URL, the app's address, for the links; andW, the log, the standard error by default. It refuses whatSMTPwould, but for a mail with noFrom.- The guide: Accounts, the mail while developing, and package mail's
Mailbox; CLI, whatTUG_DEVturns on; Deployment,MAIL_HOSTandTUG_DEV; Getting started; and the README.
Upgrading: nothing an app calls changed: go get github.com/cuonggt/tug@v0.38.0. An app that takes its mailer from mail.FromEnv, as one made by tug new -auth does, keeps its mail in the mailbox under tug dev from then on, with no change of its own, and its log has a line for each mail where it had the mail. A deploy, the app's tests, and anything run without TUG_DEV write mail out as before, and MAIL_HOST still sends it, under tug dev too, as to a Mailpit.
The guide is in docs/. tug needs Go 1.26.
v0.37.0
Inertia 3.8: a page's head from Go, and whole numbers past JavaScript's safe range as BigInts. tug spoke Inertia's protocol as v3.0.0 had it, and the client has since added two things the server takes part in. A page's <Head> gave it its title in the browser, once its scripts had run, so without SSR, a search engine's crawler and the app that makes a link's preview, which run no script, saw the root template's title on every page: now a handler gives its page its head from Go, which the client keeps in the document's head, and which a first visit's HTML has too. And a whole number past 2^53 - 1, as a snowflake ID, was rounded on its way to the page: an inertia.BigInt goes as a JavaScript BigInt, which tug gen types bigint.
The post page of examples/inertia:
func (p *posts) show(c *tug.Ctx) error {
post, err := p.find(c)
if err != nil {
return err
}
// The post's title and words, in the first visit's HTML too, for a
// search engine, and a link's preview.
c.Head(inertia.Title(post.Title), inertia.Meta("description", post.Body))
return PostsShow.Render(c, PostsShowProps{Post: post})
}has them in its first visit's HTML, rendered in the browser, with no Node, its title as inertia.Config.Title says it, the root template's own after it:
<title>Hello, tug · tug</title>
<meta data-inertia="description" name="description" content="Pages rendered by React, with props from Go handlers.">
<title>tug</title>and in its head prop, which the client, with serverHead: true, keeps in the document's head on each visit, its title said by its title callback:
[
"<title data-inertia=\"title\">Hello, tug</title>",
"<meta data-inertia=\"description\" name=\"description\" content=\"Pages rendered by React, with props from Go handlers.\">"
]A page's props with a BigInt:
type Order struct {
ID inertia.BigInt `json:"id"`
Total int64 `json:"total"`
}go out as the protocol's marker, on a page that says it has one:
{"component":"Orders/Show","props":{"errors":{},"order":{"id":{"$bigint":"900719925474099988"},"total":4200}},"url":"/orders/1","version":"c4b1e0","preserveBigIntegers":true}and tug gen writes:
export interface Order {
id: bigint
total: number
}What's new:
- A page's head, from Go:
c.Head(...)gives the page a request renders the elements of its<head>:inertia.Title;inertia.Meta, by name, as the description;inertia.Property, Open Graph's, asog:image; andinertia.Link, as the canonical one. Each is written from its parts, each value escaped, and no HTML is taken from the app, as the client puts them in the page as HTML.inertia.WithHeadis the same for middleware, as one that gives every page the site's image, and for any router. - Keyed: each element carries
data-inertia, its key:title, the meta tag's name or property, the link's rel. The client matches elements by it from page to page, and a page's<Head>element with thathead-keywins over the server's. A later element of a key replaces the earlier, the handler's middleware's, andKeygives one a key of its own, as a secondog:imageneeds. - In the client's head: the elements go out as the page's
headprop, which Inertia's client, from 3.5.0, keeps in the document's head with itsserverHeadoption, as a visit goes to another page, and back. A partial reload leaves it out unless it asks for it. It isn't a shared prop, which an instant visit takes on to the next page, and a page's ownheadprop wins. An error page has middleware's head, not the one its handler gave the page it meant to render. - In the first visit's HTML:
{{ .InertiaHead }}has the head of a page rendered in the browser, its title asinertia.Config.Titlesays it, as the client'stitlecallback does, so a search engine and a link's preview see each page's own, with no Node. The title has no key: React's and Vue's adapters replace it with their own, the same, and Svelte puts the document's title in it. A page rendered on the server has the head from its client's code, withserverHead, and tug adds none. - Big integers:
inertia.BigInt, anint64, goes out as the protocol's{"$bigint": "..."}, a small one too, in props of any type and in flash data, and its page sayspreserveBigIntegers, for Inertia's client, from 3.8.0, to make each aBigInt. tug gen types itbigint, in props, flash data and a route's input. A page without one goes as it did, and anint64is anumber, as before. - Read back: the client sends a
BigIntback as its digits, whichBindreads into aninertia.BigIntfrom JSON, a form, the query or the path, as it reads anint64, with "must be a whole number" for one that isn't. tugtest'sProps,PropandFlashread one from a page as the number it is, into aninertia.BigInt, anint64or ajson.Number. - The starters, on Inertia 3.8:
^3.8.0,serverHeadon,{{ .InertiaHead }}in every root template, not only an SSR one's, its own<title>without a key,pageTitleforinertia.Config.Title, and the home page's title and description from its handler. Svelte'sserverHeadis a function that leaves the title out, as Svelte puts the document's title in the first<title>, which its adapter's head would take away as a visit leaves the page: a Svelte page, the home page too, has its title fromHead.svelte.examples/inertia's post page has its title and words from its handler. - The guide: Pages, a section on a page's head, and one on numbers past JavaScript's safe range; TypeScript,
bigint; Testing, a BigInt read back; SSR and Accounts, the head; and the README.
Upgrading: nothing an app calls changed: go get github.com/cuonggt/tug@v0.37.0, and an app that gives no head from Go and has no inertia.BigInt goes as it did, with the same types, and its tests pass as they did. To give its pages a head from Go, an app made before puts {{ .InertiaHead }} in app.html, before its <title>, as an app made with -ssr has it, and takes that <title>'s data-inertia off, which Svelte needs; turns on serverHead: true in createInertiaApp, on Inertia 3.5.0 or later, or in Svelte, a function that leaves the title out, as a new app's resources/js/inertia.ts has it; and gives inertia.Config a Title that says a title as its title callback does. An inertia.BigInt needs Inertia 3.8.0, as a new app's package.json has it.
The guide is in docs/. tug needs Go 1.26.
v0.36.0
The route list: tug routes prints every route of an app's, one a line: its method, path and name, the struct it takes, the file and line of the app's that added it, and its handler, as that line has it, wrappers and all. An app's routes were the lines its newApp adds them on, and tug gen wrote the named ones into routes.ts, but nothing listed them: which handler answers POST /login, what it takes, which routes a path has, and where each was added. A route's function, as Go names it, is often a wrapper's closure, main.newApp.(*app).guestsOnly.func15, which says nothing of the handler inside it; the line that added the route says it, and tug routes reads it there.
In an app made with tug new -auth at v0.36.0, which has 50 routes:
$ tug routes login
METHOD PATH NAME TAKES ADDED HANDLER
GET /login login main.go:391 a.guestsOnly(loginPage)
POST /login login.store LoginInput main.go:392 a.guestsOnly(a.login)
POST /login/passkey login.passkey PasskeyInput main.go:394 a.guestsOnly(a.passkeyLogin)
POST /login/passkey/options login.passkey.options main.go:393 a.guestsOnly(a.passkeyLoginOptions)
GET /two-factor-challenge two-factor.login main.go:395 a.guestsOnly(twoFactorChallengePage)
POST /two-factor-challenge two-factor.login.store TwoFactorChallengeInput main.go:396 a.guestsOnly(a.twoFactorChallenge)
and POST /login as tug routes -json writes it, for a script:
{
"method": "POST",
"path": "/login",
"name": "login.store",
"handler": "a.guestsOnly(a.login)",
"function": "main.newApp.(*app).guestsOnly.func15",
"takes": "LoginInput",
"file": "main.go",
"line": 392
}What's new:
tug routes: builds the app and runs it, astug gendoes, and prints its routes, by path, then method, in columns:METHOD,PATH,NAME,TAKES, the struct the routeTakes,ADDED, the file and line of the app's that added it, andHANDLER, last, as its width varies most.- Every route of the app's: named or not, as
/up, a group's, and one of any method, asANY; not tug's own, as the misses' catch-all, or the endpoints of Inertia's DevTools. - The handler as the app wrote it: read with
go/parserfrom the line that added the route, the innermost of the router's calls over it, so a route added over several lines is read whole, and printed on one line, a function literal without its body. A route a helper of the app's adds, whose line gives the helper's own variable, is listed by its function's name, at the helper's line. - A filter:
tug routes loginlists the routes whose path or name hasloginin it, in any case; a text no route has is an error. -json: the same as JSON, with the handler's function, as Go names it, beside its expression.- The routes the app has as it runs: the ones
mainadds as it starts, with the app's.env, from the runtug genandtug langmake, without the database, whichtug.Generatingleaves out. A route the environment leaves out, as the auth starter's/fileswithout a disk of its own, is left out here too. - The guide: CLI,
tug routes; Routing, a pointer to it; and the README.
Upgrading: nothing an app calls changed: go get github.com/cuonggt/tug@v0.36.0, and go install github.com/cuonggt/tug/cmd/tug@v0.36.0 for the CLI. The app lists its routes itself, in the run tug gen makes, so tug routes needs the app on v0.36.0: one on an older tug says "the app has no routes". tug gen writes the same types as before, and an app's tests pass as they did.
The guide is in docs/. tug needs Go 1.26.
v0.35.0
A debug error page: with APP_DEBUG on, a server error is shown on a page of what tug knows of it, where it was the error as plain text, with a panic's stack after it, in a browser, and in the modal Inertia's client opens for a visit that failed. The page has the error and each error it wraps, with its Go type; a panic's stack from the frame that panicked, the app's frames open with the lines of source around them, and tug's and the standard library's folded; the route that answered, and the lines of the app's that added it, which say where an error a handler returns, with no stack, came from; and the request, its secrets [REDACTED].
A handler of an app made with v0.35.0, with a bug:
// stats counts the visits to each page.
func stats(c *tug.Ctx) error {
var visits map[string]int
visits[c.Request().URL.Path]++
return c.JSON(200, visits)
}and the page a browser gets for GET /stats, as text, its file's path shortened and its request's headers left out:
500 INTERNAL SERVER ERROR
panic: assignment to entry in nil map
GET /stats · stats
THE ERROR
*tug.PanicError panic: assignment to entry in nil map
runtime.plainError assignment to entry in nil map
THE STACK
main.stats
/home/you/blog/main.go:148
145 // stats counts the visits to each page.
146 func stats(c *tug.Ctx) error {
147 var visits map[string]int
148 visits[c.Request().URL.Path]++ ← marked
149 return c.JSON(200, visits)
150 }
▸ 27 frames of tug and the standard library
THE ROUTE
Route GET /stats
Name stats
Handler main.stats
Added /home/you/blog/main.go:114
113 app.Post("/hello", hello).Name("hello").Takes(HelloInput{})
114 app.Get("/stats", stats).Name("stats") ← marked
115 app.Get("/report", report).Name("report")
THE REQUEST
Method GET
URL /stats
Request ID ZKDUJ5KKFMGW3LAPSE3ROMJKIV
Go 1.26.0 · tug v0.35.0. This page is shown as APP_DEBUG is on, which a deployed app leaves off.
An error a handler returns, fmt.Errorf("reading today's report: %w", err), as a client that asks for JSON gets it:
{
"message": "reading today's report: open reports/today.csv: no such file or directory",
"errors": [
{ "type": "*fmt.wrapError", "message": "reading today's report: open reports/today.csv: no such file or directory" },
{ "type": "*fs.PathError", "message": "open reports/today.csv: no such file or directory" },
{ "type": "syscall.Errno", "message": "no such file or directory" }
],
"route": "GET /report"
}What's new:
- A page for a server error, while debugging: under
Config.Debug, whichAPP_DEBUGturns on, astug new's.envhas it,DefaultErrorHandleranswers a 5xx with an HTML page, to a client that takes HTML, as a browser does, and Inertia's client, which shows it in its modal over the page the visit left. With Debug off, nothing changes. - The error and what it wraps: each error of its chain, with its Go type, and those
errors.Joinjoins a level further in. - A panic's stack: from the frame that panicked, the app's frames open, each with the lines of source around it, its own marked, and tug's, the standard library's and the dependencies' folded between them.
- Where a returned error came from: an error a handler returns has no stack, so the page shows the route that answered, its name, its handler, and the lines of the app's that added it.
- The request: its method, URL, the route's values, its ID, and its headers, with
Cookie,Authorization, and the tokens and passwords in its query[REDACTED], as Inertia's DevTools keep them. Config.Editor, fromAPP_EDITOR: each frame's file a link that opens it in the editor, at its line:vscode,cursor,zed,golandorsublime, or a link of the editor's with{file}and{line}in it.- JSON and text too: a client that asks for JSON first gets the error, what it wraps, a panic's frames and the route as JSON, and one that takes no HTML, as curl, the text, as before.
- No script: the page is HTML and CSS alone, as Inertia's modal shows it under the Content-Security-Policy of the page the visit left, and its style carries the response's nonce, for a policy that asks for one.
- The guide: Routing, a section on the page, While debugging; Pages, Deployment and Getting started, what
APP_DEBUGshows; and the README.
Upgrading: nothing an app calls changed: go get github.com/cuonggt/tug@v0.35.0, and an app with APP_DEBUG on shows the page from then on, one made before too, with no change of its own, and its tests pass as they did. With it off, as a deployed app has it, its errors are answered as before. APP_EDITOR=vscode in the .env makes the frames links to the editor. A client of a debugging app that read a server error's JSON gets errors, stack and route beside its message, and the message without a panic's stack after it.
The guide is in docs/. tug needs Go 1.26.
v0.34.0
Request IDs in jobs: a job takes the ID of the request that pushed it, and the queue's log lines of the job say it, so a job that fails, later and on any instance, is found with the request it came from. A request's ID, which middleware.RequestID gives it and middleware.Logger logs, went no further than the request: the auth starter's verify mail, pushed as a user registers, said nothing in its log lines of where it came from, nor in the list of the jobs that failed for good. A queue's Carry lists what a job takes from the context it's pushed from and gives back to the one it runs in, and middleware.CarryRequestID is the request's ID.
The auth starter's queue, in an app made with v0.34.0:
q := queue.New(queue.Config{Store: &jobs{db: db}, Workers: workers, Carry: []queue.Carrier{middleware.CarryRequestID}})registering while the mail server is down:
2026/10/01 21:47:15 INFO request method=POST path=/register status=303 size=0 duration=33.874166ms route="POST /register" request_id=KMXUPJRLQHB2DAYIAQ5YBQZQGJ
2026/10/01 21:47:15 WARN a job failed, and will run again kind=verify-mail job=6 attempt=1 at=2026-10-01T21:47:16.197+07:00 err="mail: dial tcp 127.0.0.1:1: connect: connection refused" request_id=KMXUPJRLQHB2DAYIAQ5YBQZQGJ
and once the job has failed for good:
$ ./blog jobs
1 job failed for good, and is kept for a month:
6 verify-mail, which failed at 2026-10-01 14:47:19 UTC after 10 attempts, pushed by request KMXUPJRLQHB2DAYIAQ5YBQZQGJ
{"user":1,"email":"ann@example.com"}
mail: dial tcp 127.0.0.1:1: connect: connection refused
./blog jobs retry <id> runs one again, and ./blog jobs retry all runs them all.
What's new:
queue.Carrier: what a job takes from the context it's pushed from, and gives back to the context it runs in:Carry(ctx, into)andRestore(ctx, from), over amap[string]string. It's the shape of OpenTelemetry's propagators, so an app's traces go on into its jobs in a few lines, as the guide shows.queue.Config'sCarrylists them.- Carried however it's pushed:
Push,PushAt, a unique kind's and a latest one's, andIn's, in a handler's transaction, each from the context it's given; kept through the job's retries, and when it's run again from the failed; and given back to the handler's context, to aRate's limiter's, and toOnFail's. A schedule's runs carry nothing. A unique push that pushes nothing, or a latest one that moves the job that waits, leaves the job what its own push carried. It's a few strings, by name, at most 4 KB as JSON: a push that would carry more fails. - In the queue's log lines of a job: each has what the job carried, by name, after its kind, ID and the rest, as
request_idabove. Job.Carried, andqueue.CarryStore: a Store keeps what a job carried when it's aCarryStore, an extra, asAtOnceStoreis, with a marker method,KeepsCarried: its pushes keep it, and its claims andFailedgive it back. A job pushed to a Store that isn't one carries nothing, and runs as before.queuetest.TestStorechecks the promise of a Store that makes it, andqueuetest.Memorydoes.middleware.CarryRequestID: the Carrier of the IDRequestIDgives a request, asrequest_id, which gives back only an ID as plain asRequestIDkeeps one, as it's been in the database; andmiddleware.WithRequestID, which puts an ID in a context, forRequestIDFromto read.- The auth starter's queue carries the request's ID: in a
carriedcolumn of its jobs table, which a migration adds in each database, and itsjobscommand, and its admins' page of the jobs that failed, in all three frontends, say which request pushed each. - The guide: Background jobs, a section on what a job carries; Deployment, a job's log lines matched to its request's; Routing and Accounts, where the ID goes; and the README.
Upgrading: nothing an app calls changed: go get github.com/cuonggt/tug@v0.34.0, and a queue without Carry runs as it did. An app made with tug new -auth before keeps its jobs table and its Store as they are, and its tests pass, as TestStore checks what's carried only of a CarryStore. To carry the request's ID, its queue's Carry lists middleware.CarryRequestID, and its jobs table keeps what's carried: a migration adds the column, ALTER TABLE jobs ADD COLUMN carried TEXT;, and the Store writes it as it pushes, reads it back as it claims and lists the failed, and says so with KeepsCarried, as a new app's jobs.go and jobs_db.go do. Carry without the Store's part carries nothing.
The guide is in docs/. tug needs Go 1.26.
v0.33.1
The flash shows on the page it was left for, once, whatever else the browser asks for meanwhile. A partial reload, as the auth starter's bell's, could come while a form's redirect, in the same tab or another, was loading the page it goes back to, both with one session cookie, and both read the flash the form left: a token made in one tab showed up in the other, as a run of the browser suite caught, and a reload that came first took the flash and a form's errors from the page they were left for. v0.31.1 had the bell wait for a visit in flight in its own tab; a page can't see another tab's, so it's mended on the server:
POST /settings/tokens tab B makes a token 303, the token flashed
GET /settings/tokens tab A, the bell's reload shows none of it, and writes no cookie
GET /settings/tokens tab B, the redirect shows the token, once
- A partial reload leaves the flash: one of the page it renders shows none of the session's flash data, or a form's errors, only what it flashed itself, and leaves them for the page they were left for, whichever comes first.
session.Session.Pass: leaves the session as the request came with it, and writes no cookie for a request that changes nothing and flashes nothing, so no response can bring back a flash another has shown. One that changes the session still writes it, with what the request before flashed kept for the next.- The guide: Forms, what a partial reload does with the flash.
Upgrading: go get github.com/cuonggt/tug@v0.33.1 mends an app made before with no change of its own. A poll, a deferred prop's fetch, and router.reload({ only: [...] }) no longer show a flash that was waiting: the next visit does.
The guide is in docs/. tug needs Go 1.26.
v0.33.0
Typed flash: tug gen types the flash data a handler leaves for the next page, from the keys the app declares, so a toast's message, a new token or the recovery codes are read in the frontend by the keys and types the Go has. The starters kept them by hand, in resources/js/types.ts, which a key added in Go, or a value of another type, left behind with nothing to say so. tug.Flash[T](key) declares a key and the type of its value, as tug.Page[P] declares a page, and tug gen writes FlashData, as Inertia's flashDataType, beside the shared props' type, which types usePage().flash and the flash event.
The auth starter's flash.go, from an app made with v0.33.0:
var (
Success = tug.Flash[string]("success")
Failure = tug.Flash[string]("error")
RecoveryCodes = tug.Flash[[]string]("recoveryCodes")
NewToken = tug.Flash[string]("token")
)
// in createToken
Success.Set(c, "Token made. Copy it now: it won't be shown again.")
NewToken.Set(c, token)what tug gen writes from it, in pages.ts:
export interface FlashData {
error?: string
recoveryCodes?: string[]
success?: string
token?: string
}
declare module '@inertiajs/core' {
export interface InertiaConfig {
sharedPageProps: SharedProps
flashDataType: FlashData
}
}and the tokens page with the key misspelled:
resources/js/pages/Settings/Tokens.tsx(25,15): error TS2551: Property 'tokn' does not exist on type 'FlashData'. Did you mean 'token'?
What's new:
tug.Flash[T](key): declares a flash key and the type of its value, and returns aFlashOf[T], whoseSet(c, v)flashes it, asc.Flash(key, v)does, with a value of its type alone, and whoseKey()is the key. A key declared twice of two types panics as the app starts; twice of one type is the same key.c.Flashchecked: a declared key flashed byc.Flashwith a value of another type panics, a 500 whose log saystug: flash key "success" is declared of string, not int, as the frontend's type would say otherwise. A key declared of an interface, asFlash[any], takes what implements it, nil is any key's, and a key not declared flashes as before, untyped.FlashData, which tug gen writes inpages.ts: each declared key, optional, as a page shows the flash of the request before it, some keys or none, its value typed as encoding/json writes it; andflashDataType: FlashDatain Inertia'sInertiaConfig, once the app declares a key.- The starters' flash: declared in
flash.go,success,error,recoveryCodesandtokenin the auth starter, andsuccessin the plain one, set through their declarations at the 29 places the handlers flash, and their hand-kepttypes.tsgone, asexamples/inertia's is. - The guide: Forms, flash messages by a declared key; TypeScript, a section on flash data; Getting started and Accounts,
flash.goin place oftypes.ts; and the README.
Upgrading: nothing an app calls changed: go get github.com/cuonggt/tug@v0.33.0, and c.Flash flashes as it did. tug gen writes flashDataType only once the app declares a key, so an app made before keeps its resources/js/types.ts as it is until then. To type its flash, it declares its keys with tug.Flash, as the starters' flash.go does, sets them through the declarations, runs tug gen, and deletes its own flashDataType: one with the same keys of the same types is let through beside tug gen's, and any other is error TS2717: Subsequent property declarations must have the same type.
The guide is in docs/. tug needs Go 1.26.
v0.32.0
Typed forms: tug gen types what a form sends, the struct its route's handler binds. A route says it with Takes, as the auth starter's login does, and routes.ts gets the struct, Inputs, each route that takes one by its name, and form(), a route's path and method as a form's action. React's <Form> takes the type of what it sends, and then only its keys, for errors, resetOnError and clearErrors, so a field misspelled, or renamed in Go and not in the form, is a type error, where it was a form that sent what its handler didn't read, and an error never shown.
type LoginInput struct {
Email string `json:"email" validate:"required,email"`
Password string `json:"password" validate:"required"`
Remember bool `json:"remember"`
}
app.Post("/login", a.guestsOnly(a.login)).Name("login.store").Takes(LoginInput{})export interface LoginInput {
email: string
password: string
remember: boolean
}
export interface Inputs {
'login.store': LoginInput
// …
}<Form<Inputs['login.store']> action={form('login.store')} resetOnError={['password']}>
{({ errors }) => <InputError message={errors.email} />}
</Form>The same form in an app made with v0.32.0, with a slip in each name:
resources/js/pages/Auth/Login.tsx(61,24): error TS2820: Type '"passwrd"' is not assignable to type 'keyof LoginInput'. Did you mean '"password"'?
resources/js/pages/Auth/Login.tsx(78,43): error TS2551: Property 'emial' does not exist on type 'FormDataErrors<LoginInput>'. Did you mean 'email'?
What's new:
Route.Takes: declares the struct a route's handler binds, by a value of it, which a wrapper around the handler, as the auth starter'sa.guestsOnly(a.login), keeps tug from seeing. The route needs its name first,Name(...).Takes(...), as the types are by name:Takespanics on a route with none, and on a value that isn't a struct, as the app adds its routes.- Checked as it's bound:
Bind, andBindValid, fail, as a 500 whose error names both, a handler that binds a struct of body fields other than the one its route takes, so aTakesthe handler has moved on from shows at its first request, in the app's tests:tug: route "posts.store" takes main.PostInput, by Takes, but its handler binds main.DraftInput. A struct of query or path fields alone, as a list's filters, or a post's ID from its path, is let through. Inputsandform(), inroutes.ts, which tug gen writes. An input's keys are the namesvalidategives its errors: a field's json name, else its form tag's, else its own, which is howBindreads a form too. A field whose form or query tag names it otherwise stops tug gen, as the form would send it by one name and its errors would come back by another. A path field is the route's, inform()'s params, and an upload is aFile, orFile[], as the auth starter'sPhotoInputhasphoto: File | null.form()is the route's method as it's declared,'put'for aPut, which a form can't now come apart from.- The starters' forms: their 16 routes that bind input take it, every form's action is
form()'s, and React's forms are typed by their route's input, as<Form<Inputs['login.store']>>. Vue's and Svelte's<Form>take no type, in Inertia 3.7.1, so theirs takeform(), and their errors are any string's;useForm<Inputs['login.store']>types a form's data in all three.examples/inertia's post form is typed too. - The guide: TypeScript, a section on forms; Forms, its example with
Takesandform(); Routing,Takes; and the README.
Upgrading: nothing an app calls changed, and a route that takes nothing is as it was: go get github.com/cuonggt/tug@v0.32.0. tug gen writes Inputs and form() into routes.ts from then on, which the app commits as before. To type a form, its route Takes what its handler binds, after its name, and the form's action is form()'s, in place of route() and a method; in React, <Form<Inputs['the.route']>>. A route at a time does, and the starters' forms show each kind. A struct whose json and form tags name a field two ways is the one change tug gen can ask of an app, once the struct is taken.
The guide is in docs/. tug needs Go 1.26.
v0.31.1
The auth starter shows a form's toast once. A change to an account that notifies, as turning two-factor logins off or making an API token does, rings the bell in the header, which reloads its count as the notification's event comes. When the event came as the form's visit was still loading the page it goes back to, the two requests ran at once, as Inertia's reloads are async, both read the session with the flash the form left, and the page showed it twice: two toasts of "Two-factor logins are off.", as a run of the browser suite caught. listen, in the starter's resources/js/lib/broadcasts.ts, which the bell, the notifications' page and the verify page share, now holds their reloads while a request of Inertia's is in flight, counted from the inertia:start and inertia:finish events it fires on the document for each, and runs each once the last finishes. The flash couldn't be kept from the reload on the server: the two requests carry one cookie.
Upgrading: tug's Go is as it was, so go get github.com/cuonggt/tug@v0.31.1 changes nothing in an app. The fix is in the auth starter's frontend, the same file in React, Vue and Svelte: an app made with tug new -auth from v0.31.1, go install github.com/cuonggt/tug/cmd/tug@v0.31.1, has it, and one made before takes its resources/js/lib/broadcasts.ts as it is.
The guide is in docs/. tug needs Go 1.26.