build
packageAPI reference for the build
package.
Imports
(22)encoding/json
STD
testing
STD
crypto/sha256
STD
errors
STD
fmt
STD
io
STD
os
STD
os/exec
STD
path/filepath
STD
strings
PKG
github.com/andybalholm/brotli
INT
github.com/rfwlab/rfw/v2/cmd/rfw/plugins
INT
github.com/rfwlab/rfw/v2/cmd/rfw/plugins/assets
INT
github.com/rfwlab/rfw/v2/cmd/rfw/plugins/bundler
INT
github.com/rfwlab/rfw/v2/cmd/rfw/plugins/copy
INT
github.com/rfwlab/rfw/v2/cmd/rfw/plugins/docs
INT
github.com/rfwlab/rfw/v2/cmd/rfw/plugins/env
INT
github.com/rfwlab/rfw/v2/cmd/rfw/plugins/pages
INT
github.com/rfwlab/rfw/v2/cmd/rfw/plugins/seo
INT
github.com/rfwlab/rfw/v2/cmd/rfw/plugins/tailwind
INT
github.com/rfwlab/rfw/v2/cmd/rfw/plugins/test
INT
github.com/rfwlab/rfw/v2/cmd/rfw/utils
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
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))
}
}
keys
Parameters
Returns
func keys(m map[string]json.RawMessage) []string
{
out := make([]string, 0, len(m))
for k := range m {
out = append(out, k)
}
return out
}
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
func defaultPlugins() map[string]json.RawMessage
{
return map[string]json.RawMessage{"pages": json.RawMessage("{}")}
}
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
Returns
func pluginsConfig(explicit map[string]json.RawMessage) map[string]json.RawMessage
{
if explicit == nil {
return defaultPlugins()
}
return explicit
}
Build
Build compiles the configured application and runs its build plugins.
Returns
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
}
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
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,
)
}
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
Returns
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()))
}
readFile
Parameters
Returns
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)
}
openFile
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
}
projectPath
Parameters
Returns
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
}
makePublicDir
Parameters
Returns
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
}
writePublicFile
Parameters
Returns
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()
}
copyFile
Parameters
Returns
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())
}
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
Returns
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
}
compressWasmBrotli
Parameters
Returns
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
}
TestCopyFile
TestCopyFile ensures copyFile replicates the source file’s contents at the
destination path.
Parameters
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)
}
}
TestRemoveStaleBrotli
Parameters
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)
}
}
TestCompressWasmBrotli
Parameters
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")
}
}
TestWriteClientConfig
Parameters
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)
}
}