Skip to content

Releases: cuonggt/tug

v0.39.0

Choose a tag to compare

@cuonggt cuonggt released this 02 Oct 06:20

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.Login gives each login an ID of its own, 26 random characters, new at each login, which LoginID reads: 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, as SetLifetime set it, or else the Store's.
  • The auth starter's logins: a row of each, in a logins table, by the SHA-256 of its ID, with its browser's User-Agent, its address and its session's lifetime, which a.user reads 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 through a.logIn, in logins.go. Logging out deletes its row, a new password every row of the account's, and prune-logins the 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 the browsers table 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's Lifetime; 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

Choose a tag to compare

@cuonggt cuonggt released this 02 Oct 05:06

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.FromEnv returns a mail.Mailbox under tug dev, which sets TUG_DEV. It keeps each mail as SMTP would 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, Bcc too, its subject, and its link, at APP_URL. Without TUG_DEV, as a deploy and a test run, mail is written out by Log, as before.
  • The mailbox's pages: /_tug/mail, which the App answers under Config.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, its Bcc, 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, as Log is, for an app that makes its own: Dir, .tug/mail by default, where the App reads it; URL, the app's address, for the links; and W, the log, the standard error by default. It refuses what SMTP would, but for a mail with no From.
  • The guide: Accounts, the mail while developing, and package mail's Mailbox; CLI, what TUG_DEV turns on; Deployment, MAIL_HOST and TUG_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

Choose a tag to compare

@cuonggt cuonggt released this 02 Oct 04:11

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, as og:image; and inertia.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.WithHead is 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 that head-key wins over the server's. A later element of a key replaces the earlier, the handler's middleware's, and Key gives one a key of its own, as a second og:image needs.
  • In the client's head: the elements go out as the page's head prop, which Inertia's client, from 3.5.0, keeps in the document's head with its serverHead option, 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 own head prop 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 as inertia.Config.Title says it, as the client's title callback 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, with serverHead, and tug adds none.
  • Big integers: inertia.BigInt, an int64, goes out as the protocol's {"$bigint": "..."}, a small one too, in props of any type and in flash data, and its page says preserveBigIntegers, for Inertia's client, from 3.8.0, to make each a BigInt. tug gen types it bigint, in props, flash data and a route's input. A page without one goes as it did, and an int64 is a number, as before.
  • Read back: the client sends a BigInt back as its digits, which Bind reads into an inertia.BigInt from JSON, a form, the query or the path, as it reads an int64, with "must be a whole number" for one that isn't. tugtest's Props, Prop and Flash read one from a page as the number it is, into an inertia.BigInt, an int64 or a json.Number.
  • The starters, on Inertia 3.8: ^3.8.0, serverHead on, {{ .InertiaHead }} in every root template, not only an SSR one's, its own <title> without a key, pageTitle for inertia.Config.Title, and the home page's title and description from its handler. Svelte's serverHead is 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 from Head.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

Choose a tag to compare

@cuonggt cuonggt released this 02 Oct 02:30

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, as tug gen does, and prints its routes, by path, then method, in columns: METHOD, PATH, NAME, TAKES, the struct the route Takes, ADDED, the file and line of the app's that added it, and HANDLER, 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, as ANY; 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/parser from 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 login lists the routes whose path or name has login in 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 main adds as it starts, with the app's .env, from the run tug gen and tug lang make, without the database, which tug.Generating leaves out. A route the environment leaves out, as the auth starter's /files without 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

Choose a tag to compare

@cuonggt cuonggt released this 01 Oct 17:54

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, which APP_DEBUG turns on, as tug new's .env has it, DefaultErrorHandler answers 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.Join joins 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, from APP_EDITOR: each frame's file a link that opens it in the editor, at its line: vscode, cursor, zed, goland or sublime, 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_DEBUG shows; 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

Choose a tag to compare

@cuonggt cuonggt released this 01 Oct 15:15

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) and Restore(ctx, from), over a map[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's Carry lists them.
  • Carried however it's pushed: Push, PushAt, a unique kind's and a latest one's, and In'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 a Rate's limiter's, and to OnFail'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_id above.
  • Job.Carried, and queue.CarryStore: a Store keeps what a job carried when it's a CarryStore, an extra, as AtOnceStore is, with a marker method, KeepsCarried: its pushes keep it, and its claims and Failed give it back. A job pushed to a Store that isn't one carries nothing, and runs as before. queuetest.TestStore checks the promise of a Store that makes it, and queuetest.Memory does.
  • middleware.CarryRequestID: the Carrier of the ID RequestID gives a request, as request_id, which gives back only an ID as plain as RequestID keeps one, as it's been in the database; and middleware.WithRequestID, which puts an ID in a context, for RequestIDFrom to read.
  • The auth starter's queue carries the request's ID: in a carried column of its jobs table, which a migration adds in each database, and its jobs command, 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

Choose a tag to compare

@cuonggt cuonggt released this 01 Oct 11:36

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

Choose a tag to compare

@cuonggt cuonggt released this 01 Oct 11:18

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 a FlashOf[T], whose Set(c, v) flashes it, as c.Flash(key, v) does, with a value of its type alone, and whose Key() is the key. A key declared twice of two types panics as the app starts; twice of one type is the same key.
  • c.Flash checked: a declared key flashed by c.Flash with a value of another type panics, a 500 whose log says tug: flash key "success" is declared of string, not int, as the frontend's type would say otherwise. A key declared of an interface, as Flash[any], takes what implements it, nil is any key's, and a key not declared flashes as before, untyped.
  • FlashData, which tug gen writes in pages.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; and flashDataType: FlashData in Inertia's InertiaConfig, once the app declares a key.
  • The starters' flash: declared in flash.go, success, error, recoveryCodes and token in the auth starter, and success in the plain one, set through their declarations at the 29 places the handlers flash, and their hand-kept types.ts gone, as examples/inertia's is.
  • The guide: Forms, flash messages by a declared key; TypeScript, a section on flash data; Getting started and Accounts, flash.go in place of types.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

Choose a tag to compare

@cuonggt cuonggt released this 01 Oct 10:12

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's a.guestsOnly(a.login), keeps tug from seeing. The route needs its name first, Name(...).Takes(...), as the types are by name: Takes panics 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, and BindValid, 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 a Takes the 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.
  • Inputs and form(), in routes.ts, which tug gen writes. An input's keys are the names validate gives its errors: a field's json name, else its form tag's, else its own, which is how Bind reads 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, in form()'s params, and an upload is a File, or File[], as the auth starter's PhotoInput has photo: File | null. form() is the route's method as it's declared, 'put' for a Put, 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 take form(), 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 Takes and form(); 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

Choose a tag to compare

@cuonggt cuonggt released this 01 Oct 08:52

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.