Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 11 additions & 0 deletions .github/PULL_REQUEST_TEMPLATE.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
## Goal of this PR

<!-- A brief description of the change being made with this pull request. -->

<!--
Fixes #
-->

## How did I test it?

<!-- A brief description the steps taken to test this pull request. -->
49 changes: 31 additions & 18 deletions .github/workflows/test.yml
Original file line number Diff line number Diff line change
Expand Up @@ -6,35 +6,48 @@ on:

name: run tests
jobs:
test:

lint:
runs-on: ubuntu-latest
env:
GOOS: js
GOOS: wasip1
GOARCH: wasm
GO_VERSION: "1.21"
GOLANGCI_LINT_VERSION: v1.55.0
GOLANGCI_LINT_VERSION: v2.11.4

steps:
- name: Checkout code
uses: actions/checkout@v6

- name: Install Go
if: success()
id: install-go
uses: actions/setup-go@v6
with:
go-version: ${{ env.GO_VERSION }}
go-version-file: 'go.mod'

- name: Download dependencies
run: go mod download
if: steps.install-go.outputs.cache-hit != 'true'

- name: Run linter
uses: golangci/golangci-lint-action@v9
with:
version: ${{ env.GOLANGCI_LINT_VERSION }}

test:
runs-on: ubuntu-latest

steps:
- name: Checkout code
uses: actions/checkout@v6

- name: Cache Go modules
uses: actions/cache@v4
- name: Install Go
id: install-go
uses: actions/setup-go@v6
with:
path: ~/go/pkg/mod
key: ${{ runner.os }}-go-${{ hashFiles('**/go.sum') }}
restore-keys: |
${{ runner.os }}-go-
go-version-file: 'go.mod'

- name: Run linter
uses: golangci/golangci-lint-action@v6
with:
version: ${{ env.GOLANGCI_LINT_VERSION }}
skip-pkg-cache: true
- name: Download dependencies
run: go mod download
if: steps.install-go.outputs.cache-hit != 'true'

- name: Run Tests
run: go test -cover ./...
64 changes: 35 additions & 29 deletions .golangci.yml
Original file line number Diff line number Diff line change
@@ -1,48 +1,54 @@
version: "2"
run:
tests: false
deadline: 5m

linters-settings:
gofumpt:
extra-rules: true
formatters:
enable:
- gci
- gofmt
- gofumpt
- goimports
settings:
gofumpt:
extra-rules: true
exclusions:
generated: lax

linters:
enable-all: true
default: all
disable:
- interfacebloat
- sqlclosecheck # not relevant (SQL)
- rowserrcheck # not relevant (SQL)
- execinquery # not relevant (SQL)
- interfacer # deprecated
- scopelint # deprecated
- maligned # deprecated
- golint # deprecated
- deadcode # deprecated
- exhaustivestruct # deprecated
- ifshort # deprecated
- nosnakecase # deprecated
- structcheck # deprecated
- varcheck # deprecated
- cyclop # duplicate of gocyclo
- depguard
- err113
- exhaustive
- exhaustruct
- forcetypeassert
- funcorder
- funlen
- gochecknoglobals
- gochecknoinits
- gocognit
- gocyclo
- goerr113
- gomnd
- gomoddirectives
- ireturn
- nestif
- mnd
- musttag
- nilerr
- nlreturn
- noinlineerr
- nonamedreturns
- tagliatelle
- varnamelen
- wrapcheck
- wsl

issues:
exclude-use-default: false
- wsl_v5
settings:
cyclop:
max-complexity: 20
gosec:
excludes:
- G103
- G115
- G304
lll:
line-length: 160
perfsprint:
error-format: false
exclusions:
generated: lax
2 changes: 1 addition & 1 deletion Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -25,5 +25,5 @@ test:

# Lint the project
lint:
@golangci-lint run ./...
@GOOS=wasip1 GOARCH=wasm golangci-lint run ./...
.PHONY: lint
181 changes: 180 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,185 @@
[![GitHub release](https://img.shields.io/github/release/glasslabs/client-go.svg)](https://github.com/glasslabs/client-go/releases)
[![GitHub license](https://img.shields.io/badge/license-MIT-blue.svg)](https://raw.githubusercontent.com/glasslabs/client-go/main/LICENSE)

`client-go` bridges the gap between WASM modules and looking glass.
`client-go` is the Go library for writing [looking-glass](https://github.com/glasslabs/looking-glass) modules. Modules are compiled to WebAssembly (`GOARCH=wasm GOOS=wasip1`) and run inside the looking-glass host. This library provides everything a module needs: identity, configuration, asset access, rendering, HTTP, and logging.

**Note:** This is in active development, the API may change.

---

## How a module works

A looking-glass module is a standard Go `main` package compiled to a WASI binary. The host launches one instance per module slot, injects configuration via environment variables, mounts an assets directory, and exposes a small set of host functions for rendering and HTTP. The module runs its own event loop and calls `mod.Content(widget)` whenever the display should update.

## Writing a module

### 1. Initialise the module

`client.NewModule()` reads the `MODULE_NAME` environment variable set by the host. It must be called before any other client functions that require the module identity.

```go
//go:build wasip1

package main

import "github.com/glasslabs/client-go"

func main() {
log := client.NewLogger()

mod, err := client.NewModule()
if err != nil {
log.Error("Could not create module", "error", err.Error())
return
}
// ...
}
```

### 2. Parse configuration

The host encodes the module's configuration map as JSON and exposes it via `MODULE_CONFIG`. Call `mod.ParseConfig` with a pointer to your config struct; fields are populated from the JSON keys that match the struct tags. Set defaults before calling `ParseConfig` so that omitted keys retain sensible values.

```go
type Config struct {
Interval int `json:"interval"`
Label string `json:"label"`
}

cfg := Config{Interval: 60} // set defaults first
if err := mod.ParseConfig(&cfg); err != nil {
log.Error("Could not parse config", "error", err.Error())
return
}
```

### 3. Load assets

Static files (templates, images, SVG) are placed alongside the module binary. The host mounts them at `/assets` inside the WASM sandbox. Use `mod.Asset(path)` to read them; `path` is relative to that directory.

```go
data, err := mod.Asset("index.html")
```

### 4. Render content

Call `mod.Content(widget)` to push a new widget tree to the display. The host replaces the module's current content with the new tree on every call — there is no diffing. Call it once on startup and again whenever the display needs to change.

Widget trees are built from the composable types in this package:

| Widget | Behaviour |
|--------|-----------|
| `NewText(s, ...opts)` | A single styled line of text. |
| `NewSVG(markup)` | Raw SVG markup rasterised into the slot. |
| `NewVStack(children...)` | Lays out children vertically, top to bottom. |
| `NewHStack(children...)` | Lays out children horizontally, left to right. |
| `NewSpacer()` | Flexible empty space that fills remaining room in a stack. |
| `NewCanvas(w, h, ops...)` | A fixed logical viewport scaled to fit, drawn with `DrawOp` commands. |

`Text` can be styled with option functions: `WithColor`, `WithFontSize`, `WithLight`, `WithBold`, `WithItalic`, `WithCondensed`, and `WithAlign`.

```go
func render(mod *client.Module, label string) {
mod.Content(client.NewVStack(
client.NewText(label,
client.WithColor("#ffffff"),
client.WithFontSize(48),
client.WithLight(),
),
client.NewSpacer(),
))
}
```

#### Canvas drawing

`Canvas` renders a list of `DrawOp` commands within a logical coordinate space. The viewport is scaled uniformly to fit the allocated slot.

| DrawOp | Behaviour |
|--------|-----------|
| `NewRect(x, y, w, h, ...opts)` | Filled and/or stroked rectangle. Options: `WithFill`, `WithStroke`, `WithCornerRadius`. |
| `NewArc(cx, cy, radius, startAngle, sweepAngle, strokeWidth, color)` | Circular arc stroke. Angles are in degrees; 0° is right, clockwise positive. |
| `NewLabel(x, y, align, runs...)` | Baseline-aligned multi-run text. `align` is `"start"`, `"middle"`, or `"end"`. |
| `NewPath(x, y, scale, d, fill)` | SVG path `d` string placed at an offset with optional uniform scale. |

`Label` is built from `TextRun` segments created with `NewRun(content, ...opts)`. Options: `WithRunFontSize`, `WithRunBaselineShift`, `WithRunColor`.

### 5. Make HTTP requests

The host provides an HTTP transport that is automatically installed into `http.DefaultClient` when the module starts. Use the standard `net/http` package directly — no special client is required.

```go
resp, err := http.Get("https://example.com/api/data")
```

The transport supports streaming response bodies, so Server-Sent Events and other long-lived streams work as expected.

### 6. Log messages

`client.NewLogger()` returns a logger that writes logfmt lines to stderr. The host captures these lines and forwards them to its structured logging pipeline.

```go
log := client.NewLogger()
log.Info("Module ready", "module", mod.Name())
log.Error("Something failed", "error", err.Error())
```

Methods: `Debug`, `Info`, `Warn`, `Error`. Each accepts a message followed by alternating key/value string pairs.

---

## Building a module

Modules must be compiled for the `wasip1` target:

```bash
GOARCH=wasm GOOS=wasip1 go build -o my-module.wasm .
```

All source files that use `client-go` should carry the `//go:build wasip1` build constraint, as the host functions are only available inside the WASM sandbox.

## Minimal example

```go
//go:build wasip1

package main

import (
"time"

"github.com/glasslabs/client-go"
)

type Config struct {
Format string `json:"format"`
}

func main() {
log := client.NewLogger()

mod, err := client.NewModule()
if err != nil {
log.Error("Could not create module", "error", err.Error())
return
}

cfg := Config{Format: "15:04:05"}
if err = mod.ParseConfig(&cfg); err != nil {
log.Error("Could not parse config", "error", err.Error())
return
}

log.Info("Module ready", "module", mod.Name())

for {
mod.Content(client.NewText(
time.Now().Format(cfg.Format),
client.WithColor("#ffffff"),
client.WithFontSize(48),
))
time.Sleep(time.Second)
}
}
```

Loading
Loading