Guides
Calling Go from the frontend
Bound services, channels, typed events and the generated TypeScript client.
The frontend calls Go through bound services: Go values whose exported
methods pages can call, and which may stream values back through
channels. Go reaches pages with typed events. mygo generate turns
them into a TypeScript client, so calls, channels and events are checked by
the compiler on both sides and documented in your editor.
Bind a service
Pass a value to mygo.Bind before App.Run:
// Notes stores the user's notes.
type Notes struct {
mu sync.Mutex
items []Note
}
// Note is a note.
type Note struct {
ID int64 `json:"id"`
Title string `json:"title"`
Body string `json:"body,omitempty"`
}
// Add creates a note and returns it.
func (n *Notes) Add(title string) (Note, error) {
if title == "" {
return Note{}, errors.New("a note needs a title")
}
n.mu.Lock()
defer n.mu.Unlock()
note := Note{ID: int64(len(n.items) + 1), Title: title}
n.items = append(n.items, note)
return note, nil
}
// List returns all notes.
func (n *Notes) List() []Note {
n.mu.Lock()
defer n.mu.Unlock()
return slices.Clone(n.items)
}
func main() {
mygo.Bind(&Notes{})
// ...
}The service is named after its type, Notes, and every exported method of
the value is callable, here as Notes.add(title) and Notes.list().
mygo.BindAs("Store", v) names it explicitly, for types whose names collide.
Bind panics when a name is taken twice or a method uses a type JSON cannot
encode, such as a channel or a function, so mistakes show at startup.
A method may:
- take any number of JSON-encodable arguments, including a variadic one,
optionally after a
context.Context; - take
*mygo.Channel[T]parameters, to stream values to the caller while it runs (see channels); - return nothing, a value, an error, or a value and an error.
Every call runs on its own goroutine, so methods may block, for example on a dialog or a network request, and several may run at once: guard shared state with a mutex, as above. A panic in a method is recovered, logged and reported to the caller like an error.
The context and the calling window
A method whose first parameter is a context.Context gets one that is
canceled when the calling page navigates away or its window closes: pass it
on to slow work. mygo.CallerWindow(ctx) returns the window that called,
for example to attach a dialog to it:
// Export asks where to save the notes and writes them there. It returns the
// chosen path, or "" when the user canceled.
func (n *Notes) Export(ctx context.Context) (string, error) {
path, err := mygo.Dialog.Save(mygo.SaveDialogOptions{
Parent: mygo.CallerWindow(ctx), // a sheet of that window on macOS
DefaultPath: "notes.json",
})
if err != nil || path == "" {
return "", err
}
// ...
return path, nil
}The context is not a parameter on the TypeScript side:
Notes.export(): Promise<string>.
The generated client
mygo generate writes the client to the path of bindings in mygo.config.ts,
src/mygo.ts in new projects. mygo dev regenerates it whenever the Go
code changes and mygo build before it builds the frontend, so you rarely
run it yourself. For the service above it writes:
// Code generated by `mygo generate`. DO NOT EDIT.
import { call, event } from "mygo-runtime";
/** Note is a note. */
export interface Note {
id: number;
title: string;
body?: string;
}
/** Notes stores the user's notes. */
export const Notes = {
/** Add creates a note and returns it. */
add(title: string): Promise<Note> {
return call("Notes.Add", title);
},
/** List returns all notes. */
list(): Promise<Note[]> {
return call("Notes.List");
},
// ...
} as const;Methods become lower camel case (GetUserByID → getUserByID, URLFor →
urlFor), parameters keep their Go names, and Go doc comments become
TSDoc. The client imports mygo-runtime, which the frontend depends on.
To generate the client, mygo generate builds the app and runs it with
MYGO_GENERATE set: App.Run then writes the client and returns without
opening a window. So main runs up to App.Run; keep work that must not
happen then, such as migrating a database, after the app is ready, in
App.WhenReady.
Types
Values cross between Go and JavaScript as JSON, following Go's
encoding/json/v2 rules, and the client declares the matching types:
| Go | TypeScript |
|---|---|
bool |
boolean |
string |
string |
int, uint8, float64 and the other numbers |
number |
[]byte |
string, base64 encoded |
time.Time |
string, RFC 3339 |
time.Duration |
number, in nanoseconds |
[]T, [N]T |
T[]; a nil slice arrives as [] |
map[string]T |
Record<string, T>; Record<number, T> for number keys |
| a struct | an interface of the same name |
*T |
T | null |
any and other interfaces |
unknown |
| a named string or number type with constants | the union of their values |
a type with a MarshalJSON method |
unknown |
a type with a MarshalText method |
string |
Struct fields are named by their json tags, and a field without a tag
keeps its Go name, Title: give fields tags such as json:"title". Fields
tagged omitempty or omitzero are optional (body?: string), and -
leaves a field out. JavaScript numbers are exact up to 2⁵³, so encode
larger IDs as strings with the string option, json:"id,string", which
makes them string in TypeScript.
Named string types with constants become unions:
// Filter selects which notes are listed.
type Filter string
const (
FilterAll Filter = "all"
FilterDone Filter = "done"
)/** Filter selects which notes are listed. */
export type Filter = "all" | "done";Errors
A method that returns a non-nil error rejects the promise with a
CallError, whose message is the error's text:
import { isCallError } from "mygo-runtime";
try {
await Notes.add("");
} catch (err) {
if (isCallError(err)) showError(err.message); // "a note needs a title"
else throw err;
}err.method names the method that failed, "Notes.Add".
Events
Events carry values from Go to pages. Declare them at package level with
the type of their payload, so that mygo generate finds them:
// Progress is sent while an export runs.
var Progress = mygo.NewEvent[ExportProgress]("progress")
// ExportProgress is how far an export got.
type ExportProgress struct {
Done int `json:"done"`
Total int `json:"total"`
}Emit sends an event to the page of one window and Broadcast to every
window:
Progress.Emit(win, ExportProgress{Done: 3, Total: 10})
Progress.Broadcast(ExportProgress{Done: 10, Total: 10})Events emitted before the page's DOM is ready are delivered once it is.
Emit returns an error when the window was closed, which ends loops that
feed a window:
go func() {
for t := range time.Tick(time.Second) {
if Tick.Emit(win, t) != nil {
return // the window was closed
}
}
}()The client lists events under events, named in camel case ("progress" →
events.progress, "notes:changed" → events.notesChanged):
import { events } from "./mygo";
const off = events.progress.on(({ done, total }) => {
bar.value = done / total;
});
// later
off();
events.progress.once((p) => console.log("first progress", p));Event names must be unique; names starting with mygo: are reserved.
Channels
A method that produces values over time, such as the lines a command
prints, the tokens of a model's answer or the progress of a download,
streams them to its caller through a *mygo.Channel[T] parameter, like a
response that arrives in parts:
// Tail runs a command and sends the lines it prints.
func (Shell) Tail(ctx context.Context, command string, lines *mygo.Channel[string]) error {
cmd := exec.CommandContext(ctx, "sh", "-c", command)
out, err := cmd.StdoutPipe()
if err != nil {
return err
}
if err := cmd.Start(); err != nil {
return err
}
scanner := bufio.NewScanner(out)
for scanner.Scan() {
if err := lines.Send(scanner.Text()); err != nil {
return err // the page stopped listening
}
}
return cmd.Wait()
}The client types the parameter as a Channel of mygo-runtime, which the
page creates and passes in its place, then iterates:
import { Channel } from "mygo-runtime";
import { Shell } from "./mygo";
const lines = new Channel<string>();
const done = Shell.tail("ping -c 3 example.com", lines);
for await (const line of lines) output.append(line + "\n");
await done; // rejects if Tail returned an errorInstead of iterating, pass a function, new Channel<string>((line) => output.append(line)), or set lines.onmessage.
- Values arrive in order, all of them before the call's promise settles.
- The channel closes when the method returns, or calls
Close: the loop ends once it took the values sent before, andoncloseis called. - The page stops the stream with
lines.close(), or by breaking out of the loop:Sendthen fails withmygo.ErrChannelClosedand the method's context is canceled, which here kills the command. Navigating away and closing the window do the same. Sendwaits while the page has more than a MiB of values yet to take, so a method faster than the page does not pile them up in memory. On the main thread, in an event listener for example, it never waits.
A channel serves one call. Channels are parameters only: Bind rejects
methods that return one or take one inside another value.
Events or channels? An event goes to every listener of a page, for as long as it lives; a channel carries the answer of one call and ends with it.
Without the generated client
mygo-runtime calls methods and subscribes to events by name, and pages
without a build step reach the same functions on window.mygo:
import { call, on } from "mygo-runtime";
const note = await call<Note>("Notes.Add", "Groceries");
on<ExportProgress>("progress", (p) => console.log(p));<script>
mygo.call("Notes.Add", "Groceries").then((note) => console.log(note));
// mygo.channel() creates a channel.
const lines = mygo.channel((line) => console.log(line));
mygo.call("Shell.Tail", "ls", lines);
</script>Missing arguments arrive in Go as zero values.
Who may call
Only the app's own pages may call Go methods: pages of its frontend and of
custom protocols (see the frontend), file: and about:
pages, and in development builds pages served from localhost, such as the
dev server. A window that shows other sites, https://example.com for
example, keeps them from calling Go unless WindowOptions.TrustedOrigins
lists their origin:
mygo.NewWindow(mygo.WindowOptions{
URL: "https://app.example.com",
TrustedOrigins: []string{"https://app.example.com"},
})"*" trusts every origin, including any site the page navigates to: avoid
it in windows that show content you do not control. Frames embedded in a
page never call Go, whatever their origin; only the top-level page of a
window does.
From Go to the page
Events are the way to tell pages about changes. To run code in a page,
typically to automate or test it, use Window.Eval, which returns the
value of an expression, awaiting promises, decoded from JSON, or
mygo.EvalAs[T] to decode it into a Go type:
title, err := mygo.EvalAs[string](win, "document.title")
count, err := mygo.EvalAs[int](win, "const items = document.querySelectorAll('li'); return items.length")Statements run as the body of an async function, so return produces the
value. A script that throws returns an *mygo.EvalError.