MyGo
Calling Go from the frontend

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 error

Instead 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, and onclose is called.
  • The page stops the stream with lines.close(), or by breaking out of the loop: Send then fails with mygo.ErrChannelClosed and the method's context is canceled, which here kills the command. Navigating away and closing the window do the same.
  • Send waits 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.