build API

build

package

API reference for the build package.

F
function

TestPluginsConfigDefaultsWhenMissing

TestPluginsConfigDefaultsWhenMissing verifies that a missing “plugins” key
falls back to the default set (pages), so file-based routing works out of the
box, while an explicit block is honored verbatim.

Parameters

cmd/rfw/build/plugins_config_test.go:13-34
func TestPluginsConfigDefaultsWhenMissing(t *testing.T)

{
	got := pluginsConfig(nil)
	if _, ok := got["pages"]; !ok {
		t.Fatalf("nil config should enable the pages plugin by default, got %v", keys(got))
	}

	// An explicit empty block opts out of every plugin.
	empty := pluginsConfig(map[string]json.RawMessage{})
	if len(empty) != 0 {
		t.Fatalf("explicit empty config should stay empty, got %v", keys(empty))
	}

	// An explicit block is passed through unchanged.
	explicit := map[string]json.RawMessage{"tailwind": json.RawMessage("{}")}
	out := pluginsConfig(explicit)
	if _, ok := out["pages"]; ok {
		t.Fatalf("explicit config must not gain the default pages plugin, got %v", keys(out))
	}
	if _, ok := out["tailwind"]; !ok {
		t.Fatalf("explicit config should be preserved, got %v", keys(out))
	}
}
F
function

keys

Parameters

m
map[string]json.RawMessage

Returns

[]string
cmd/rfw/build/plugins_config_test.go:36-42
func keys(m map[string]json.RawMessage) []string

{
	out := make([]string, 0, len(m))
	for k := range m {
		out = append(out, k)
	}
	return out
}
F
function

defaultPlugins

defaultPlugins is the plugin set activated when rfw.json omits the “plugins”
key. It enables file-based routing (the pages plugin) so a scaffolded project
routes without extra configuration. Other plugins stay opt-in.

Returns

map[string]json.RawMessage
cmd/rfw/build/build.go:34-36
func defaultPlugins() map[string]json.RawMessage

{
	return map[string]json.RawMessage{"pages": json.RawMessage("{}")}
}
F
function

pluginsConfig

pluginsConfig resolves the plugin configuration to apply. A missing “plugins”
key (nil) falls back to defaultPlugins; an explicit block, including an empty
one, is honored as written, so “plugins”: {} opts out of every plugin.

Parameters

explicit
map[string]json.RawMessage

Returns

map[string]json.RawMessage
cmd/rfw/build/build.go:41-46
func pluginsConfig(explicit map[string]json.RawMessage) map[string]json.RawMessage

{
	if explicit == nil {
		return defaultPlugins()
	}
	return explicit
}
F
function

Build

Build compiles the configured application and runs its build plugins.

Returns

error
cmd/rfw/build/build.go:49-193
func Build() error

{
	var manifest struct {
		Build struct {
			Type string `json:"type"`
			Host string `json:"host"`
		} `json:"build"`
		Plugins map[string]json.RawMessage `json:"plugins"`
	}
	if data, err := os.ReadFile("rfw.json"); err == nil {
		_ = json.Unmarshal(data, &manifest)
	}
	if err := plugins.Configure(pluginsConfig(manifest.Plugins)); err != nil {
		return fmt.Errorf("failed to configure plugins: %w", err)
	}
	if err := plugins.PreBuild(); err != nil {
		return fmt.Errorf("pre build failed: %w", err)
	}

	clientDir := filepath.Join("build", "client")
	hostDir := filepath.Join("build", "host")
	staticDir := filepath.Join("build", "static")
	if err := makePublicDir(clientDir); err != nil {
		return fmt.Errorf("failed to create client build directory: %w", err)
	}
	if err := makePublicDir(staticDir); err != nil {
		return fmt.Errorf("failed to create static build directory: %w", err)
	}

	wasmExec, err := readWasmExec()
	if err != nil {
		return err
	}
	if err := writePublicFile(filepath.Join(clientDir, "wasm_exec.js"), wasmExec); err != nil {
		return fmt.Errorf("failed to copy wasm_exec.js: %w", err)
	}

	devBuild := os.Getenv("RFW_DEV_BUILD") == "1"
	skipOptimize := devBuild || utils.IsDebug() || os.Getenv("RFW_SKIP_STRIP") == "1"
	wasmPath := filepath.Join(clientDir, "app.wasm")
	var cmd *exec.Cmd
	switch {
	case devBuild:
		cmd = exec.Command("go", "build", "-tags=rfwdev", "-o", "build/client/app.wasm", ".")
	case skipOptimize:
		cmd = exec.Command("go", "build", "-o", "build/client/app.wasm", ".")
	default:
		cmd = exec.Command("go", "build", "-trimpath", "-ldflags=-s -w", "-o", "build/client/app.wasm", ".")
	}
	cmd.Env = append(os.Environ(), "GOARCH=wasm", "GOOS=js")
	output, err := cmd.CombinedOutput()
	if err != nil {
		return fmt.Errorf("failed to build project: %s: %w", output, err)
	}

	isDev := utils.IsDebug() || os.Getenv("RFW_DEV_BUILD") == "1"
	if !isDev {
		if err := compressWasmBrotli(wasmPath); err != nil {
			return fmt.Errorf("failed to brotli-compress wasm: %w", err)
		}
	} else if err := removeStaleBrotli(wasmPath); err != nil {
		// Dev never recompresses the wasm, but wasm_loader.js still prefers
		// app.wasm.br over app.wasm. A .br left by an earlier production build
		// would be served stale (and, in dev, under an immutable cache header),
		// so drop it and let the fresh uncompressed wasm be what loads.
		return fmt.Errorf("failed to remove stale brotli wasm: %w", err)
	}

	// Build the host binary for SSC when a host directory exists. A static
	// (client-only) build skips it, so the output is a pure static bundle that
	// can be served from a CDN with no live host.
	if manifest.Build.Type != "static" {
		if _, err := os.Stat("host"); err == nil {
			if err := makePublicDir(hostDir); err != nil {
				return fmt.Errorf("failed to create host build directory: %w", err)
			}
			hostCmd := exec.Command("go", "build", "-o", "build/host/host", "./host")
			if hostOutput, err := hostCmd.CombinedOutput(); err != nil {
				if !isDev {
					return fmt.Errorf("failed to build host components: %s: %w", hostOutput, err)
				}
				fmt.Fprintf(os.Stderr, "warning: host build failed (dev mode, continuing): %s\n", hostOutput)
			}
		}
	}
	if err := plugins.Build(); err != nil {
		return fmt.Errorf("failed to run plugins: %w", err)
	}

	// Copy plugin-generated assets (e.g. tailwind.css) to client build dir.
	for _, name := range []string{"tailwind.css", "input.css"} {
		if data, err := readFile(name); err == nil {
			if err := writePublicFile(filepath.Join(clientDir, name), data); err != nil {
				return fmt.Errorf("failed to copy %s to client dir: %w", name, err)
			}
		}
	}
	if _, err := os.Stat("index.html"); err == nil {
		if err := copyFile("index.html", filepath.Join(clientDir, "index.html")); err != nil {
			return fmt.Errorf("failed to copy index.html: %w", err)
		}
	}

	if _, err := os.Stat("wasm_loader.js"); err == nil {
		if err := copyFile("wasm_loader.js", filepath.Join(clientDir, "wasm_loader.js")); err != nil {
			return fmt.Errorf("failed to copy wasm_loader.js: %w", err)
		}
	}

	wasm, err := readFile(wasmPath)
	if err != nil {
		return fmt.Errorf("failed to read wasm for client config: %w", err)
	}
	wasmHash := sha256.Sum256(wasm)
	if err := writeClientConfig(clientDir, manifest.Build.Host, fmt.Sprintf("%x", wasmHash[:8])); err != nil {
		return fmt.Errorf("failed to write client config: %w", err)
	}

	if _, err := os.Stat("static"); err == nil {
		if err := filepath.Walk("static", func(path string, info os.FileInfo, err error) error {
			if err != nil {
				return err
			}
			if info.IsDir() {
				return nil
			}
			rel, err := filepath.Rel("static", path)
			if err != nil {
				return err
			}
			dst := filepath.Join(staticDir, rel)
			if err := makePublicDir(filepath.Dir(dst)); err != nil {
				return err
			}
			return copyFile(path, dst)
		}); err != nil {
			return fmt.Errorf("failed to copy static assets: %w", err)
		}
	}

	if err := plugins.PostBuild(); err != nil {
		return fmt.Errorf("post build failed: %w", err)
	}

	return nil
}
F
function

readWasmExec

readWasmExec reads wasm_exec.js from the active Go toolchain.
It tries the canonical Go 1.21+ path ($GOROOT/lib/wasm/), then the
legacy path ($GOROOT/misc/wasm/), and finally a project-local copy.

Returns

[]byte
error
cmd/rfw/build/build.go:198-238
func readWasmExec() ([]byte, error)

{
	goRootOutput, err := exec.Command("go", "env", "GOROOT").Output()
	if err != nil {
		return nil, fmt.Errorf("find Go root: %w", err)
	}
	goRootPath := strings.TrimSpace(string(goRootOutput))
	candidates := []struct {
		root string
		file string
	}{
		{root: filepath.Join(goRootPath, "lib"), file: filepath.Join("wasm", "wasm_exec.js")},
		{root: filepath.Join(goRootPath, "misc"), file: filepath.Join("wasm", "wasm_exec.js")},
	}
	if goRootPath != "" {
		for _, candidate := range candidates {
			resolvedRoot, resolveErr := filepath.EvalSymlinks(candidate.root)
			if resolveErr != nil {
				continue
			}
			goRoot, openErr := os.OpenRoot(resolvedRoot)
			if openErr != nil {
				continue
			}
			data, readErr := goRoot.ReadFile(candidate.file)
			closeErr := goRoot.Close()
			if readErr == nil && closeErr == nil {
				return data, nil
			}
			if readErr == nil {
				return nil, closeErr
			}
		}
	}
	if data, err := readFile("wasm_exec.js"); err == nil {
		return data, nil
	}
	return nil, fmt.Errorf(
		"wasm_exec.js not found in GOROOT (%s) or project root; reinstall Go or run 'rfw init'",
		goRootPath,
	)
}
F
function

writeClientConfig

writeClientConfig emits build/client/rfw_config.js, which the client loads
before the wasm to learn its host endpoint for the client-to-host WebSocket.
It is always written so the index.html include never 404s; the global is set
only when a host is configured in rfw.json (build.host). The value may be a
full URL (ws, wss, http, https) or a bare host[:port] with an optional path.

Parameters

clientDir
string
host
string
wasmVersion
string

Returns

error
cmd/rfw/build/build.go:245-253
func writeClientConfig(clientDir, host, wasmVersion string) error

{
	var b strings.Builder
	b.WriteString("// Generated by rfw build. Do not edit.\n")
	if h := strings.TrimSpace(host); h != "" {
		fmt.Fprintf(&b, "window.RFW_HOST_URL = %q;\n", h)
	}
	fmt.Fprintf(&b, "window.RFW_WASM_VERSION = %q;\n", wasmVersion)
	return writePublicFile(filepath.Join(clientDir, "rfw_config.js"), []byte(b.String()))
}
F
function

readFile

Parameters

path
string

Returns

data
[]byte
err
error
cmd/rfw/build/build.go:255-269
func readFile(path string) (data []byte, err error)

{
	root, file, err := openFile(path)
	if err != nil {
		return nil, err
	}
	defer func() {
		if closeErr := file.Close(); err == nil {
			err = closeErr
		}
		if closeErr := root.Close(); err == nil {
			err = closeErr
		}
	}()
	return io.ReadAll(file)
}
F
function

openFile

Parameters

path
string

Returns

error
cmd/rfw/build/build.go:271-288
func openFile(path string) (*os.Root, *os.File, error)

{
	cleaned, err := projectPath(path)
	if err != nil {
		return nil, nil, err
	}
	root, err := os.OpenRoot(".")
	if err != nil {
		return nil, nil, err
	}
	file, err := root.Open(cleaned)
	if err != nil {
		if closeErr := root.Close(); closeErr != nil {
			return nil, nil, closeErr
		}
		return nil, nil, err
	}
	return root, file, nil
}
F
function

projectPath

Parameters

path
string

Returns

string
error
cmd/rfw/build/build.go:290-299
func projectPath(path string) (string, error)

{
	if path == "" || filepath.IsAbs(path) {
		return "", fmt.Errorf("path %q must be relative to the project", path)
	}
	cleaned := filepath.Clean(path)
	if cleaned == ".." || strings.HasPrefix(cleaned, ".."+string(filepath.Separator)) {
		return "", fmt.Errorf("path %q escapes the project", path)
	}
	return cleaned, nil
}
F
function

makePublicDir

Parameters

path
string

Returns

err
error
cmd/rfw/build/build.go:301-319
func makePublicDir(path string) (err error)

{
	cleaned, err := projectPath(path)
	if err != nil {
		return err
	}
	root, err := os.OpenRoot(".")
	if err != nil {
		return err
	}
	defer func() {
		if closeErr := root.Close(); err == nil {
			err = closeErr
		}
	}()
	if err := root.MkdirAll(cleaned, 0o755); err != nil {
		return err
	}
	return nil
}
F
function

writePublicFile

Parameters

path
string
data
[]byte

Returns

err
error
cmd/rfw/build/build.go:321-346
func writePublicFile(path string, data []byte) (err error)

{
	cleaned, err := projectPath(path)
	if err != nil {
		return err
	}
	root, err := os.OpenRoot(".")
	if err != nil {
		return err
	}
	defer func() {
		if closeErr := root.Close(); err == nil {
			err = closeErr
		}
	}()
	if err := root.Remove(cleaned); err != nil && !errors.Is(err, os.ErrNotExist) {
		return err
	}
	file, err := root.OpenFile(cleaned, os.O_CREATE|os.O_EXCL|os.O_WRONLY, 0o644)
	if err != nil {
		return err
	}
	if _, err = file.Write(data); err != nil {
		return errors.Join(err, file.Close())
	}
	return file.Close()
}
F
function

copyFile

Parameters

src
string
dst
string

Returns

err
error
cmd/rfw/build/build.go:348-384
func copyFile(src, dst string) (err error)

{
	srcRoot, in, err := openFile(src)
	if err != nil {
		return err
	}
	defer func() {
		if closeErr := in.Close(); err == nil {
			err = closeErr
		}
		if closeErr := srcRoot.Close(); err == nil {
			err = closeErr
		}
	}()

	cleanedDestination, err := projectPath(dst)
	if err != nil {
		return err
	}
	dstRoot, err := os.OpenRoot(".")
	if err != nil {
		return err
	}
	defer func() {
		if closeErr := dstRoot.Close(); err == nil {
			err = closeErr
		}
	}()
	if err := dstRoot.Remove(cleanedDestination); err != nil && !errors.Is(err, os.ErrNotExist) {
		return err
	}
	out, err := dstRoot.OpenFile(cleanedDestination, os.O_CREATE|os.O_EXCL|os.O_WRONLY, 0o644)
	if err != nil {
		return err
	}
	_, copyErr := io.Copy(out, in)
	return errors.Join(copyErr, out.Close())
}
F
function

removeStaleBrotli

removeStaleBrotli deletes src+“.br” if present. Dev builds skip brotli
compression, so a .br from a prior production build would otherwise linger and
be served in place of the freshly built .wasm. Absence of the file is fine.

Parameters

src
string

Returns

err
error
cmd/rfw/build/build.go:389-407
func removeStaleBrotli(src string) (err error)

{
	dst, err := projectPath(src + ".br")
	if err != nil {
		return err
	}
	root, err := os.OpenRoot(".")
	if err != nil {
		return err
	}
	defer func() {
		if closeErr := root.Close(); err == nil {
			err = closeErr
		}
	}()
	if removeErr := root.Remove(dst); removeErr != nil && !errors.Is(removeErr, os.ErrNotExist) {
		return removeErr
	}
	return nil
}
F
function

compressWasmBrotli

Parameters

src
string

Returns

err
error
cmd/rfw/build/build.go:409-460
func compressWasmBrotli(src string) (err error)

{
	srcRoot, in, err := openFile(src)
	if err != nil {
		return err
	}
	defer func() {
		if closeErr := in.Close(); err == nil {
			err = closeErr
		}
		if closeErr := srcRoot.Close(); err == nil {
			err = closeErr
		}
	}()

	dst, err := projectPath(src + ".br")
	if err != nil {
		return err
	}
	outputRoot, err := os.OpenRoot(".")
	if err != nil {
		return err
	}
	defer func() {
		if closeErr := outputRoot.Close(); err == nil {
			err = closeErr
		}
	}()
	if err := outputRoot.Remove(dst); err != nil && !errors.Is(err, os.ErrNotExist) {
		return err
	}
	out, err := outputRoot.OpenFile(dst, os.O_CREATE|os.O_EXCL|os.O_WRONLY, 0o644)
	if err != nil {
		return err
	}
	defer func() {
		if closeErr := out.Close(); err == nil {
			err = closeErr
		}
	}()

	writer := brotli.NewWriterLevel(out, brotli.BestCompression)
	if _, err := io.Copy(writer, in); err != nil {
		if closeErr := writer.Close(); closeErr != nil {
			return closeErr
		}
		return err
	}
	if err := writer.Close(); err != nil {
		return err
	}
	return nil
}
F
function

TestCopyFile

TestCopyFile ensures copyFile replicates the source file’s contents at the
destination path.

Parameters

cmd/rfw/build/build_test.go:16-37
func TestCopyFile(t *testing.T)

{
	dir := t.TempDir()
	t.Chdir(dir)
	src := "src.txt"
	dst := "dst.txt"
	content := []byte("hello world")
	if err := os.WriteFile(src, content, 0o600); err != nil {
		t.Fatalf("write src: %v", err)
	}

	if err := copyFile(src, dst); err != nil {
		t.Fatalf("copyFile error: %v", err)
	}

	got, err := os.ReadFile(dst)
	if err != nil {
		t.Fatalf("read dst: %v", err)
	}
	if string(got) != string(content) {
		t.Fatalf("expected %q, got %q", content, got)
	}
}
F
function

TestRemoveStaleBrotli

Parameters

cmd/rfw/build/build_test.go:39-59
func TestRemoveStaleBrotli(t *testing.T)

{
	dir := t.TempDir()
	t.Chdir(dir)
	src := "app.wasm"
	brPath := src + ".br"
	if err := os.WriteFile(brPath, []byte("stale"), 0o600); err != nil {
		t.Fatalf("write stale br: %v", err)
	}

	if err := removeStaleBrotli(src); err != nil {
		t.Fatalf("removeStaleBrotli: %v", err)
	}
	if _, err := os.Stat(brPath); !os.IsNotExist(err) {
		t.Fatalf("expected %s removed, stat err=%v", brPath, err)
	}

	// Absence of the file must be a no-op, not an error.
	if err := removeStaleBrotli(src); err != nil {
		t.Fatalf("removeStaleBrotli on missing file: %v", err)
	}
}
F
function

TestCompressWasmBrotli

Parameters

cmd/rfw/build/build_test.go:61-93
func TestCompressWasmBrotli(t *testing.T)

{
	dir := t.TempDir()
	t.Chdir(dir)
	src := "app.wasm"
	content := []byte(strings.Repeat("rfw wasm", 32))
	if err := os.WriteFile(src, content, 0o600); err != nil {
		t.Fatalf("write wasm: %v", err)
	}

	if err := compressWasmBrotli(src); err != nil {
		t.Fatalf("compressWasmBrotli: %v", err)
	}

	brPath := src + ".br"
	f, err := os.Open(brPath)
	if err != nil {
		t.Fatalf("open brotli file: %v", err)
	}
	defer func() {
		if err := f.Close(); err != nil {
			t.Errorf("close brotli file: %v", err)
		}
	}()

	reader := brotli.NewReader(f)
	decompressed, err := io.ReadAll(reader)
	if err != nil {
		t.Fatalf("read brotli: %v", err)
	}
	if string(decompressed) != string(content) {
		t.Fatalf("unexpected decompressed content")
	}
}
F
function

TestWriteClientConfig

Parameters

cmd/rfw/build/build_test.go:95-113
func TestWriteClientConfig(t *testing.T)

{
	dir := t.TempDir()
	t.Chdir(dir)
	if err := writeClientConfig(".", "wss://example.com/rfw", "abc123"); err != nil {
		t.Fatalf("writeClientConfig: %v", err)
	}

	config, err := os.ReadFile("rfw_config.js")
	if err != nil {
		t.Fatalf("read client config: %v", err)
	}
	got := string(config)
	if !strings.Contains(got, `window.RFW_HOST_URL = "wss://example.com/rfw";`) {
		t.Fatalf("host URL missing from client config: %q", got)
	}
	if !strings.Contains(got, `window.RFW_WASM_VERSION = "abc123";`) {
		t.Fatalf("wasm version missing from client config: %q", got)
	}
}