astwerk

Scripts & WebAssembly

Compiling Go to wasm as part of the build, with no JS toolchain.

A node with CompileFrom compiles a scripts directory into the output. No bundler, no package.json — a .go file becomes a .wasm module, a .ts file is handed to tsc.

Documentation

CompileFrom

"scripts": {CompileFrom: "scripts"},

The field wires a directory into the build. It runs ssg.CompileScripts(srcDir, outDir), which looks at each top-level entry:

Entry Output
a .go file with //go:build js && wasm <name>.wasm
a subdirectory containing at least one such file <dirname>.wasm
.ts files handed to tsc in one batch

Anything else is ignored, so helper packages and non-wasm Go files can live in the same directory.

How the build runs

Compilation runs from the entry's parent directory, so a script can import your project's own packages. When at least one .wasm is produced, Go's own wasm_exec.js glue is copied in beside it. Each .wasm is built by its own go build process in parallel, and .ts files are handed to tsc in one batch.

The build constraint

//go:build js && wasm

package main

func main() {
	// …
	select {} // keep the module alive
}

The constraint is required — without it, go build ./... at your project root would also try to compile the script for the host platform, where syscall/js doesn't exist.

Loading a module

templ WasmScript(c ssg.Ctx, name string) {
	<script src={ c.Asset("scripts/wasm_exec.js") }></script>
	<script data-wasm={ c.Asset("scripts/" + name + ".wasm") }>
		(() => {
			const src = document.currentScript.dataset.wasm;
			const go = new Go();
			WebAssembly.instantiateStreaming(fetch(src), go.importObject)
				.then(res => go.run(res.instance));
		})();
	</script>
}

Two details in there are load-bearing: the path travels in a data- attribute, because templ treats <script> contents as raw text and would emit an interpolated expression literally; and document.currentScript must be read synchronously, because it's null once a promise resolves. wasm_exec.js defines globalThis.Go, so it has to load first — a plain script tag, not a module import.

starter/wasm/wasm_script.templ ships this component ready to copy.

Size

A Go wasm binary starts around 2 MB, and a script using reactive lands nearer 5 MB. That's the Go runtime, and it compresses to roughly a quarter of that over the wire. If a page only needs a nav toggle, ten lines of hand-written JavaScript is genuinely the better engineering choice. Reach for wasm when the logic is substantial enough that writing it in Go pays for the runtime.

Example

//go:build js && wasm

package main

import "github.com/LukasDerBaum42/astwerk/wasmwrap"

func main() {
	wasmwrap.Query("#toggle").On("click", func(e wasmwrap.Event) {
		e.PreventDefault()
		wasmwrap.Query("#nav").Class().Toggle("open")
	})
	select {}
}

Dropped in scripts/ and wired with CompileFrom, this builds to scripts/nav.wasm and is loaded by the component above.