build API

build

package

API reference for the build package.

T
type

delivery

delivery names how the client bundle reaches the browser. It is orthogonal to
build.type: type decides what is linked into the bundle, delivery decides how
the bundle is transferred.

cmd/rfw/build/delivery.go:17-17
type delivery string
T
type

sscTransport

sscTransport names the runtime used for the client-to-host SSC connection.
It is independent of delivery: an embedded application may still use the
browser WebSocket, while a Capacitor application can opt into the native
URLSession bridge when its authenticated cookie cannot cross WKWebView’s
origin boundary.

cmd/rfw/build/delivery.go:24-24
type sscTransport string
T
type

hostTransport

hostTransport names the browser-to-host wire transport used by SSC. It is
orthogonal to sscTransport: the browser runtime can use WebSocket or
StreamBus, while the Capacitor runtime keeps using its native bridge.

cmd/rfw/build/delivery.go:29-29
type hostTransport string
F
function

parseDelivery

parseDelivery resolves the build.delivery value from rfw.json. An empty value
keeps the network default, so a manifest written before this setting existed
builds exactly as it did.

Parameters

value
string

Returns

error
cmd/rfw/build/delivery.go:53-67
func parseDelivery(value string) (delivery, error)

{
	switch mode := delivery(strings.TrimSpace(value)); mode {
	case "":
		return deliveryNetwork, nil
	case deliveryNetwork, deliveryEmbedded:
		return mode, nil
	default:
		return "", fmt.Errorf(
			"rfw.json: build.delivery %q is not a delivery mode; use %q "+
				"(the default: compressed artifacts fetched over the network) or %q "+
				"(the raw bundle packaged with the application)",
			value, deliveryNetwork, deliveryEmbedded,
		)
	}
}
F
function

parseSSCTransport

Parameters

value
string

Returns

error
cmd/rfw/build/delivery.go:69-82
func parseSSCTransport(value string) (sscTransport, error)

{
	switch transport := sscTransport(strings.TrimSpace(value)); transport {
	case "", sscTransportBrowser:
		return sscTransportBrowser, nil
	case sscTransportCapacitor:
		return transport, nil
	default:
		return "", fmt.Errorf(
			"rfw.json: build.sscTransport %q is not an SSC transport; use %q "+
				"(the default browser WebSocket) or %q (the RFW Capacitor plugin)",
			value, sscTransportBrowser, sscTransportCapacitor,
		)
	}
}
F
function

parseHostTransport

Parameters

value
string

Returns

cmd/rfw/build/delivery.go:84-98
func parseHostTransport(value string) (hostTransport, error)

{
	switch transport := hostTransport(strings.ToLower(strings.TrimSpace(value))); transport {
	case "", hostTransportWebSocket, "ws":
		return hostTransportWebSocket, nil
	case hostTransportStreamBus, "webtransport", "warp-streambus":
		return hostTransportStreamBus, nil
	case hostTransportAuto:
		return hostTransportAuto, nil
	default:
		return "", fmt.Errorf(
			"rfw.json: transport %q is not a host transport; use %q, %q or %q",
			value, hostTransportWebSocket, hostTransportStreamBus, hostTransportAuto,
		)
	}
}
S
struct

buildShape

buildShape is the delivery-relevant view of rfw.json. It exists so the two
settings that shape a build stay separable: “static” decides whether the SSC
client is linked, “embedded” decides how the bundle is delivered, and neither
implies the other.

cmd/rfw/build/delivery.go:104-111
type buildShape struct

Methods

negotiates
Method

negotiates reports whether the client can leave the choice of content coding to the server. A static origin does not negotiate, and the local asset handler that serves packaged assets does not either.

Returns

bool
func (buildShape) negotiates() bool
{
	return !s.static && s.delivery == deliveryNetwork
}
compresses
Method

compresses reports whether the build should write compressed artifacts. Packaging them would ship bytes nothing will ever request.

Returns

bool
func (buildShape) compresses() bool
{
	return s.delivery == deliveryNetwork
}

Fields

Name Type Description
static bool
host string
delivery delivery
transport sscTransport
hostWire hostTransport
plugins map[string]json.RawMessage
F
function

decodeBuildShape

decodeBuildShape reads rfw.json. A manifest that does not parse falls back to
the defaults, as it always has, but a manifest that parses and names an
unknown delivery mode fails the build rather than quietly ignoring it.

Parameters

data
[]byte

Returns

error
cmd/rfw/build/delivery.go:129-163
func decodeBuildShape(data []byte) (buildShape, error)

{
	var manifest struct {
		Transport string `json:"transport"`
		Build     struct {
			Type         string `json:"type"`
			Host         string `json:"host"`
			Delivery     string `json:"delivery"`
			SSCTransport string `json:"sscTransport"`
		} `json:"build"`
		Plugins map[string]json.RawMessage `json:"plugins"`
	}
	if err := json.Unmarshal(data, &manifest); err != nil {
		return buildShape{delivery: deliveryNetwork, transport: sscTransportBrowser, hostWire: hostTransportWebSocket}, nil
	}
	mode, err := parseDelivery(manifest.Build.Delivery)
	if err != nil {
		return buildShape{}, err
	}
	transport, err := parseSSCTransport(manifest.Build.SSCTransport)
	if err != nil {
		return buildShape{}, err
	}
	hostWire, err := parseHostTransport(manifest.Transport)
	if err != nil {
		return buildShape{}, err
	}
	return buildShape{
		static:    manifest.Build.Type == "static",
		host:      manifest.Build.Host,
		delivery:  mode,
		transport: transport,
		hostWire:  hostWire,
		plugins:   manifest.Plugins,
	}, nil
}
F
function

TestParseDelivery

A manifest written before build.delivery existed keeps building exactly as it
did, and a value that is not a delivery mode fails the build with something
the author can act on.

Parameters

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

{
	for _, tc := range []struct {
		name  string
		value string
		want  delivery
	}{
		{name: "absent", value: "", want: deliveryNetwork},
		{name: "explicit network", value: "network", want: deliveryNetwork},
		{name: "embedded", value: "embedded", want: deliveryEmbedded},
		{name: "surrounding whitespace", value: "  embedded\n", want: deliveryEmbedded},
	} {
		t.Run(tc.name, func(t *testing.T) {
			got, err := parseDelivery(tc.value)
			if err != nil {
				t.Fatalf("parseDelivery(%q): %v", tc.value, err)
			}
			if got != tc.want {
				t.Fatalf("parseDelivery(%q) = %q, want %q", tc.value, got, tc.want)
			}
		})
	}
}
F
function

TestParseDeliveryRejectsUnknownModes

Parameters

cmd/rfw/build/delivery_test.go:36-49
func TestParseDeliveryRejectsUnknownModes(t *testing.T)

{
	for _, value := range []string{"Embedded", "local", "offline", "true", "net work"} {
		_, err := parseDelivery(value)
		if err == nil {
			t.Fatalf("parseDelivery(%q) was accepted", value)
		}
		message := err.Error()
		for _, want := range []string{"build.delivery", value, string(deliveryNetwork), string(deliveryEmbedded)} {
			if !strings.Contains(message, want) {
				t.Fatalf("error %q does not mention %q", message, want)
			}
		}
	}
}
F
function

TestParseSSCTransport

Parameters

cmd/rfw/build/delivery_test.go:51-68
func TestParseSSCTransport(t *testing.T)

{
	for _, tc := range []struct {
		value string
		want  sscTransport
	}{
		{value: "", want: sscTransportBrowser},
		{value: "browser", want: sscTransportBrowser},
		{value: " capacitor ", want: sscTransportCapacitor},
	} {
		got, err := parseSSCTransport(tc.value)
		if err != nil {
			t.Fatalf("parseSSCTransport(%q): %v", tc.value, err)
		}
		if got != tc.want {
			t.Fatalf("parseSSCTransport(%q) = %q, want %q", tc.value, got, tc.want)
		}
	}
}
F
function

TestParseSSCTransportRejectsUnknownModes

Parameters

cmd/rfw/build/delivery_test.go:70-83
func TestParseSSCTransportRejectsUnknownModes(t *testing.T)

{
	for _, value := range []string{"native", "Capacitor", "auto", "true"} {
		_, err := parseSSCTransport(value)
		if err == nil {
			t.Fatalf("parseSSCTransport(%q) was accepted", value)
		}
		message := err.Error()
		for _, want := range []string{"build.sscTransport", value, string(sscTransportBrowser), string(sscTransportCapacitor)} {
			if !strings.Contains(message, want) {
				t.Fatalf("error %q does not mention %q", message, want)
			}
		}
	}
}
F
function

TestDecodeBuildShapeKeepsDeliveryAndTypeSeparate

Delivery mode and type are separate decisions. An embedded SSC application
still links the host client and still configures its WebSocket endpoint; an
embedded static application still drops both.

Parameters

cmd/rfw/build/delivery_test.go:88-147
func TestDecodeBuildShapeKeepsDeliveryAndTypeSeparate(t *testing.T)

{
	for _, tc := range []struct {
		name     string
		manifest string
		want     buildShape
	}{
		{
			name:     "ssc defaults to network delivery",
			manifest: `{"build":{"type":"ssc","host":"wss://api.example.com/ws"}}`,
			want:     buildShape{host: "wss://api.example.com/ws", delivery: deliveryNetwork, transport: sscTransportBrowser},
		},
		{
			name:     "embedded ssc keeps the host client and the host url",
			manifest: `{"build":{"type":"ssc","host":"wss://api.example.com/ws","delivery":"embedded"}}`,
			want:     buildShape{host: "wss://api.example.com/ws", delivery: deliveryEmbedded, transport: sscTransportBrowser},
		},
		{
			name:     "embedded static stays static",
			manifest: `{"build":{"type":"static","delivery":"embedded"}}`,
			want:     buildShape{static: true, delivery: deliveryEmbedded, transport: sscTransportBrowser},
		},
		{
			name:     "an empty manifest is a network build",
			manifest: `{}`,
			want:     buildShape{delivery: deliveryNetwork, transport: sscTransportBrowser},
		},
		{
			name:     "embedded SSC can select the Capacitor transport",
			manifest: `{"build":{"type":"ssc","host":"wss://api.example.com/ws","delivery":"embedded","sscTransport":"capacitor"}}`,
			want:     buildShape{host: "wss://api.example.com/ws", delivery: deliveryEmbedded, transport: sscTransportCapacitor, hostWire: hostTransportWebSocket},
		},
		{
			name:     "SSC can select StreamBus independently from its runtime",
			manifest: `{"transport":"streambus","build":{"type":"ssc","host":"https://api.example.com"}}`,
			want:     buildShape{host: "https://api.example.com", delivery: deliveryNetwork, transport: sscTransportBrowser, hostWire: hostTransportStreamBus},
		},
	} {
		t.Run(tc.name, func(t *testing.T) {
			got, err := decodeBuildShape([]byte(tc.manifest))
			if err != nil {
				t.Fatalf("decodeBuildShape: %v", err)
			}
			if got.static != tc.want.static {
				t.Fatalf("static = %t, want %t", got.static, tc.want.static)
			}
			if got.host != tc.want.host {
				t.Fatalf("host = %q, want %q", got.host, tc.want.host)
			}
			if got.delivery != tc.want.delivery {
				t.Fatalf("delivery = %q, want %q", got.delivery, tc.want.delivery)
			}
			if got.transport != tc.want.transport {
				t.Fatalf("transport = %q, want %q", got.transport, tc.want.transport)
			}
			if got.hostWire != tc.want.hostWire && tc.want.hostWire != "" {
				t.Fatalf("host wire transport = %q, want %q", got.hostWire, tc.want.hostWire)
			}
		})
	}
}
F
function

TestDecodeBuildShapeRejectsAnUnknownDelivery

Parameters

cmd/rfw/build/delivery_test.go:149-153
func TestDecodeBuildShapeRejectsAnUnknownDelivery(t *testing.T)

{
	if _, err := decodeBuildShape([]byte(`{"build":{"type":"ssc","delivery":"capacitor"}}`)); err == nil {
		t.Fatal("an unknown delivery mode built without an error")
	}
}
F
function

TestDecodeBuildShapeRejectsAnUnknownSSCTransport

Parameters

cmd/rfw/build/delivery_test.go:155-159
func TestDecodeBuildShapeRejectsAnUnknownSSCTransport(t *testing.T)

{
	if _, err := decodeBuildShape([]byte(`{"build":{"type":"ssc","sscTransport":"native"}}`)); err == nil {
		t.Fatal("an unknown SSC transport built without an error")
	}
}
F
function

TestDecodeBuildShapeRejectsAnUnknownHostTransport

Parameters

cmd/rfw/build/delivery_test.go:161-165
func TestDecodeBuildShapeRejectsAnUnknownHostTransport(t *testing.T)

{
	if _, err := decodeBuildShape([]byte(`{"transport":"telepathy"}`)); err == nil {
		t.Fatal("an unknown host transport built without an error")
	}
}
F
function

TestDecodeBuildShapeToleratesAnUnparsableManifest

An unreadable manifest has always fallen back to the defaults rather than
failing the build, and it still does.

Parameters

cmd/rfw/build/delivery_test.go:169-177
func TestDecodeBuildShapeToleratesAnUnparsableManifest(t *testing.T)

{
	got, err := decodeBuildShape([]byte("not json"))
	if err != nil {
		t.Fatalf("decodeBuildShape: %v", err)
	}
	if got.delivery != deliveryNetwork || got.transport != sscTransportBrowser || got.hostWire != hostTransportWebSocket || got.static || got.plugins != nil {
		t.Fatalf("shape = %+v, want the defaults", got)
	}
}
F
function

TestBuildShapeNegotiates

Negotiation needs a live host on the other end. A static origin has none, and
packaged assets come from a local asset handler that negotiates nothing.

Parameters

cmd/rfw/build/delivery_test.go:181-195
func TestBuildShapeNegotiates(t *testing.T)

{
	for _, tc := range []struct {
		shape buildShape
		want  bool
	}{
		{shape: buildShape{delivery: deliveryNetwork}, want: true},
		{shape: buildShape{static: true, delivery: deliveryNetwork}, want: false},
		{shape: buildShape{delivery: deliveryEmbedded}, want: false},
		{shape: buildShape{static: true, delivery: deliveryEmbedded}, want: false},
	} {
		if got := tc.shape.negotiates(); got != tc.want {
			t.Fatalf("%+v negotiates = %t, want %t", tc.shape, got, tc.want)
		}
	}
}
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
}
T
type

Encoding

Encoding names an HTTP content coding the build can produce for the wasm
bundle, in the order a client should prefer them.

cmd/rfw/build/wasm_artifacts.go:22-22
type Encoding string
F
function

prepareWasmArtifacts

prepareWasmArtifacts writes the artifacts a build will advertise and reports
the encodings that exist on disk afterwards. Compression is the production
default; a dev build never recompresses, and an embedded build has no
network transfer to compress. Both of those cases drop artifacts an earlier
build left behind, because the loader prefers a compressed artifact over the
raw one and a stale copy would be served under an immutable cache header.

Parameters

wasmPath
string
shape
isDev
bool

Returns

error
cmd/rfw/build/wasm_artifacts.go:61-73
func prepareWasmArtifacts(wasmPath string, shape buildShape, isDev bool) ([]Encoding, error)

{
	if isDev || !shape.compresses() {
		if err := removeStaleArtifacts(wasmPath); err != nil {
			return nil, fmt.Errorf("failed to remove stale wasm artifacts: %w", err)
		}
		return nil, nil
	}
	encodings, err := compressWasm(wasmPath)
	if err != nil {
		return nil, fmt.Errorf("failed to compress wasm: %w", err)
	}
	return encodings, nil
}
F
function

compressWasm

compressWasm writes every production artifact next to src and reports the
encodings that exist afterwards. A build that produces no compressed
artifact is reported as such rather than being papered over, because the
client configuration has to describe what is actually on disk.

Parameters

src
string

Returns

error
cmd/rfw/build/wasm_artifacts.go:79-88
func compressWasm(src string) ([]Encoding, error)

{
	produced := make([]Encoding, 0, len(artifactExt))
	for _, encoding := range []Encoding{EncodingBrotli, EncodingGzip} {
		if err := compressWasmAs(src, encoding); err != nil {
			return nil, err
		}
		produced = append(produced, encoding)
	}
	return produced, nil
}
F
function

compressWasmAs

compressWasmAs writes one compressed copy of src.

Parameters

src
string
encoding

Returns

err
error
cmd/rfw/build/wasm_artifacts.go:91-139
func compressWasmAs(src string, encoding Encoding) (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 + artifactExt[encoding])
	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 := compressors[encoding](out)
	if _, err := io.Copy(writer, in); err != nil {
		if closeErr := writer.Close(); closeErr != nil {
			return closeErr
		}
		return err
	}
	return writer.Close()
}
F
function

removeStaleArtifacts

removeStaleArtifacts drops compressed copies left by an earlier build. A dev
build never recompresses, so a stale artifact would otherwise be served under
an immutable cache header and shadow the fresh bundle.

Parameters

src
string

Returns

err
error
cmd/rfw/build/wasm_artifacts.go:144-164
func removeStaleArtifacts(src string) (err error)

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

quotedScripts

quotedScripts builds the alternation of bootstrap file names with their
metacharacters escaped, so a dot matches a dot.

Returns

string
cmd/rfw/build/wasm_artifacts.go:179-185
func quotedScripts() string

{
	quoted := make([]string, len(versionedScripts))
	for i, name := range versionedScripts {
		quoted[i] = regexp.QuoteMeta(name)
	}
	return strings.Join(quoted, "|")
}
F
function

stampBootstrapScripts

stampBootstrapScripts rewrites the bootstrap script tags in html so each
carries the current build version. It reports whether anything changed.

Parameters

html
string
version
string

Returns

string
bool
cmd/rfw/build/wasm_artifacts.go:189-192
func stampBootstrapScripts(html, version string) (string, bool)

{
	stamped := scriptTag.ReplaceAllString(html, `${1}${2}${3}?v=`+version+`${5}`)
	return stamped, stamped != html
}
F
function

stampIndexHTML

stampIndexHTML applies stampBootstrapScripts to the built index.html.

Parameters

path
string
version
string

Returns

error
cmd/rfw/build/wasm_artifacts.go:195-208
func stampIndexHTML(path, version string) error

{
	data, err := readFile(path)
	if err != nil {
		if errors.Is(err, os.ErrNotExist) {
			return nil
		}
		return err
	}
	stamped, changed := stampBootstrapScripts(string(data), version)
	if !changed {
		return nil
	}
	return writePublicFile(path, []byte(stamped))
}
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:35-37
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:42-47
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:50-210
func Build() error

{
	shape := buildShape{delivery: deliveryNetwork, transport: sscTransportBrowser, hostWire: hostTransportWebSocket}
	if data, readErr := os.ReadFile("rfw.json"); readErr == nil {
		decoded, err := decodeBuildShape(data)
		if err != nil {
			return err
		}
		shape = decoded
	}
	if configured := strings.TrimSpace(os.Getenv("RFW_TRANSPORT")); configured != "" {
		transport, err := parseHostTransport(configured)
		if err != nil {
			return err
		}
		shape.hostWire = transport
	}
	if err := plugins.Configure(pluginsConfig(shape.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)
	}

	// A non-static build links the SSC client so component registration keeps
	// working. core no longer imports hostclient, so nothing else would pull it
	// in, and a "type": "static" project drops the websocket and net/http
	// stacks from its bundle.
	if !shape.static {
		sscFile, err := writeSSCImport()
		if err != nil {
			return fmt.Errorf("failed to write SSC import: %w", err)
		}
		defer func() { _ = os.Remove(sscFile) }()
	}

	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"
	encodings, err := prepareWasmArtifacts(wasmPath, shape, isDev)
	if err != nil {
		return 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 !shape.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 := writeWasmLoader(clientDir); err != nil {
		return err
	}

	wasm, err := readFile(wasmPath)
	if err != nil {
		return fmt.Errorf("failed to read wasm for client config: %w", err)
	}
	wasmHash := sha256.Sum256(wasm)
	version := fmt.Sprintf("%x", wasmHash[:8])
	// A live host negotiates the encoding from Accept-Encoding. A static build
	// is served by something that will not, so the client picks an artifact by
	// name instead, and an embedded build is read from the container's local
	// asset handler, which negotiates nothing either.
	if err := writeClientConfig(clientDir, shape.host, version, encodings, shape.negotiates(), !isDev, shape.delivery, shape.transport, shape.hostWire); err != nil {
		return fmt.Errorf("failed to write client config: %w", err)
	}
	if err := stampIndexHTML(filepath.Join(clientDir, "index.html"), version); err != nil {
		return fmt.Errorf("failed to version the bootstrap scripts: %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

writeWasmLoader

Parameters

clientDir
string

Returns

error
cmd/rfw/build/build.go:212-231
func writeWasmLoader(clientDir string) error

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

	loader, err := initproj.TemplatesFS.ReadFile("template/wasm_loader.js")
	if err != nil {
		return fmt.Errorf("failed to read the framework wasm loader: %w", err)
	}
	if err := writePublicFile(destination, loader); err != nil {
		return fmt.Errorf("failed to write the framework wasm loader: %w", err)
	}
	return nil
}
F
function

writeSSCImport

writeSSCImport emits a blank import of the hostclient package into the
project root so linking it installs SSC component registration into core.
It returns the generated path so the caller can remove it.

Returns

string
error
cmd/rfw/build/build.go:240-256
func writeSSCImport() (string, error)

{
	const src = `//go:build js && wasm

// Code generated by rfw build. DO NOT EDIT.

package main

// Linking hostclient installs SSC component registration into core. A project
// built with "type": "static" skips this file, which keeps the websocket and
// net/http stacks out of the bundle.
import _ "github.com/rfwlab/rfw/v2/hostclient"
`
	if err := os.WriteFile(sscImportFile, []byte(src), 0o600); err != nil {
		return "", err
	}
	return sscImportFile, 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:261-301
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.
writeClientConfig describes the build to the loader. Every name it emits is
defined: a loader that reads an undefined global cannot tell “off” from
“absent”, which is how an application ends up silently downloading the raw
bundle it has a compressed copy of. That is also why the delivery mode is
stamped explicitly instead of being inferred from an empty encoding list,
which a failed compression step would produce too.

Parameters

clientDir
string
host
string
wasmVersion
string
encodings
negotiated
bool
production
bool
deliveryMode
transport
hostWire

Returns

error
cmd/rfw/build/build.go:314-336
func writeClientConfig(clientDir, host, wasmVersion string, encodings []Encoding, negotiated, production bool, deliveryMode delivery, transport sscTransport, hostWire hostTransport) 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)
	fmt.Fprintf(&b, "window.RFW_WASM_DELIVERY = %q;\n", string(deliveryMode))
	names := make([]string, 0, len(encodings))
	for _, encoding := range encodings {
		names = append(names, strconv.Quote(string(encoding)))
	}
	fmt.Fprintf(&b, "window.RFW_WASM_ENCODINGS = [%s];\n", strings.Join(names, ", "))
	fmt.Fprintf(&b, "window.RFW_WASM_NEGOTIATED = %t;\n", negotiated)
	fmt.Fprintf(&b, "window.RFW_SSC_TRANSPORT = %q;\n", string(transport))
	fmt.Fprintf(&b, "window.RFW_TRANSPORT = %q;\n", string(hostWire))
	mode := "development"
	if production {
		mode = "production"
	}
	fmt.Fprintf(&b, "window.RFW_BUILD_MODE = %q;\n", mode)
	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:338-352
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:354-371
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:373-382
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:384-402
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:404-429
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:431-467
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

TestBrowserLoaderScenarios

Parameters

cmd/rfw/build/build_test.go:22-40
func TestBrowserLoaderScenarios(t *testing.T)

{
	_, err := exec.LookPath("node")
	if err != nil {
		t.Skip("node is required to exercise the browser loader")
	}
	loader, err := filepath.Abs(filepath.Join("..", "initproj", "template", "wasm_loader.js"))
	if err != nil {
		t.Fatalf("resolve loader: %v", err)
	}
	script, err := filepath.Abs(filepath.Join("testdata", "wasm_loader_test.mjs"))
	if err != nil {
		t.Fatalf("resolve loader test: %v", err)
	}
	// #nosec G204 -- both script paths are resolved from this repository.
	cmd := exec.Command("node", script, loader)
	if output, err := cmd.CombinedOutput(); err != nil {
		t.Fatalf("browser loader scenarios failed: %v\n%s", err, output)
	}
}
F
function

TestWriteWasmLoaderUsesFrameworkDefault

Parameters

cmd/rfw/build/build_test.go:42-64
func TestWriteWasmLoaderUsesFrameworkDefault(t *testing.T)

{
	dir := t.TempDir()
	t.Chdir(dir)
	clientDir := filepath.Join("build", "client")
	if err := makePublicDir(clientDir); err != nil {
		t.Fatal(err)
	}
	if err := writeWasmLoader(clientDir); err != nil {
		t.Fatalf("write default loader: %v", err)
	}
	// #nosec G304 -- clientDir is a test-owned temporary directory.
	got, err := os.ReadFile(filepath.Join(clientDir, "wasm_loader.js"))
	if err != nil {
		t.Fatalf("read default loader: %v", err)
	}
	want, err := initproj.TemplatesFS.ReadFile("template/wasm_loader.js")
	if err != nil {
		t.Fatalf("read embedded loader: %v", err)
	}
	if string(got) != string(want) {
		t.Fatal("build did not write the framework loader")
	}
}
F
function

TestWriteWasmLoaderPreservesProjectOverride

Parameters

cmd/rfw/build/build_test.go:66-87
func TestWriteWasmLoaderPreservesProjectOverride(t *testing.T)

{
	dir := t.TempDir()
	t.Chdir(dir)
	if err := os.WriteFile("wasm_loader.js", []byte("project loader"), 0o600); err != nil {
		t.Fatal(err)
	}
	clientDir := filepath.Join("build", "client")
	if err := makePublicDir(clientDir); err != nil {
		t.Fatal(err)
	}
	if err := writeWasmLoader(clientDir); err != nil {
		t.Fatalf("write project loader: %v", err)
	}
	// #nosec G304 -- clientDir is a test-owned temporary directory.
	got, err := os.ReadFile(filepath.Join(clientDir, "wasm_loader.js"))
	if err != nil {
		t.Fatalf("read project loader: %v", err)
	}
	if string(got) != "project loader" {
		t.Fatalf("loader = %q, want project override", got)
	}
}
F
function

TestRepositoryLoaderCopiesMatchTheFrameworkLoader

The examples and the benchmark carry their own copy of the loader, which is
what they actually run. A copy left behind by an earlier change would keep
passing every test while implementing a different delivery contract, so the
repository owns one loader and every copy of it is that one.

Parameters

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

{
	want, err := initproj.TemplatesFS.ReadFile("template/wasm_loader.js")
	if err != nil {
		t.Fatalf("read embedded loader: %v", err)
	}
	root, err := os.OpenRoot(filepath.Join("..", "..", ".."))
	if err != nil {
		t.Fatalf("open the repository root: %v", err)
	}
	defer func() {
		if closeErr := root.Close(); closeErr != nil {
			t.Errorf("close the repository root: %v", closeErr)
		}
	}()
	// Walking and reading through the root keeps every path relative to the
	// repository, so no read can reach outside the checkout.
	repo := root.FS()
	copies := 0
	if err := fs.WalkDir(repo, ".", func(path string, entry fs.DirEntry, err error) error {
		if err != nil {
			return err
		}
		if entry.IsDir() {
			// build directories hold generated copies and node_modules holds
			// somebody else's files.
			switch entry.Name() {
			case ".git", "build", "node_modules":
				return fs.SkipDir
			}
			return nil
		}
		if entry.Name() != "wasm_loader.js" {
			return nil
		}
		copies++
		got, err := fs.ReadFile(repo, path)
		if err != nil {
			return err
		}
		if string(got) != string(want) {
			t.Errorf("%s has diverged from the framework loader", path)
		}
		return nil
	}); err != nil {
		t.Fatalf("walk the repository: %v", err)
	}
	if copies < 2 {
		t.Fatalf("found %d loaders, expected the framework loader and the copies that use it", copies)
	}
}
F
function

TestCopyFile

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

Parameters

cmd/rfw/build/build_test.go:146-167
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

TestRemoveStaleArtifacts

Parameters

cmd/rfw/build/build_test.go:169-193
func TestRemoveStaleArtifacts(t *testing.T)

{
	dir := t.TempDir()
	t.Chdir(dir)
	src := "app.wasm"
	artifacts := []string{src + ".br", src + ".gz"}
	for _, path := range artifacts {
		if err := os.WriteFile(path, []byte("stale"), 0o600); err != nil {
			t.Fatalf("write stale %s: %v", path, err)
		}
	}

	if err := removeStaleArtifacts(src); err != nil {
		t.Fatalf("removeStaleArtifacts: %v", err)
	}
	for _, path := range artifacts {
		if _, err := os.Stat(path); !os.IsNotExist(err) {
			t.Fatalf("expected %s removed, stat err=%v", path, err)
		}
	}

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

TestCompressWasmWritesEveryArtifact

A production build writes every artifact it will later advertise, and each
one has to round-trip: a truncated or mislabelled artifact would be served
under an immutable cache header.

Parameters

cmd/rfw/build/build_test.go:198-240
func TestCompressWasmWritesEveryArtifact(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)
	}

	encodings, err := compressWasm(src)
	if err != nil {
		t.Fatalf("compressWasm: %v", err)
	}
	if len(encodings) != 2 || encodings[0] != EncodingBrotli || encodings[1] != EncodingGzip {
		t.Fatalf("encodings = %v, want brotli then gzip", encodings)
	}

	decoders := map[Encoding]func(io.Reader) (io.Reader, error){
		EncodingBrotli: func(r io.Reader) (io.Reader, error) { return brotli.NewReader(r), nil },
		EncodingGzip:   func(r io.Reader) (io.Reader, error) { return gzip.NewReader(r) },
	}
	for _, encoding := range encodings {
		path := src + artifactExt[encoding]
		f, err := os.Open(path)
		if err != nil {
			t.Fatalf("open %s: %v", path, err)
		}
		decoder, err := decoders[encoding](f)
		if err != nil {
			t.Fatalf("decode %s: %v", path, err)
		}
		decompressed, err := io.ReadAll(decoder)
		if err != nil {
			t.Fatalf("read %s: %v", path, err)
		}
		if err := f.Close(); err != nil {
			t.Errorf("close %s: %v", path, err)
		}
		if string(decompressed) != string(content) {
			t.Fatalf("%s did not round-trip", path)
		}
	}
}
F
function

TestPrepareWasmArtifacts

A production build advertises what it wrote. Embedded delivery writes only
the raw bundle, and it has to leave the directory in that state even when an
earlier network build filled it with compressed copies: a packaged
application would otherwise ship megabytes nothing will ever request.

Parameters

cmd/rfw/build/build_test.go:246-326
func TestPrepareWasmArtifacts(t *testing.T)

{
	wasm := []byte(strings.Repeat("rfw wasm", 32))
	for _, tc := range []struct {
		name       string
		shape      buildShape
		isDev      bool
		want       []Encoding
		compressed bool
	}{
		{
			name:       "network production compresses",
			shape:      buildShape{delivery: deliveryNetwork},
			want:       []Encoding{EncodingBrotli, EncodingGzip},
			compressed: true,
		},
		{
			name:  "embedded production packages only the raw bundle",
			shape: buildShape{delivery: deliveryEmbedded},
		},
		{
			name:  "embedded static behaves like embedded ssc",
			shape: buildShape{static: true, delivery: deliveryEmbedded},
		},
		{
			name:  "development never compresses",
			shape: buildShape{delivery: deliveryNetwork},
			isDev: true,
		},
	} {
		t.Run(tc.name, func(t *testing.T) {
			dir := t.TempDir()
			t.Chdir(dir)
			src := "app.wasm"
			if err := os.WriteFile(src, wasm, 0o600); err != nil {
				t.Fatalf("write wasm: %v", err)
			}
			for _, ext := range []string{".br", ".gz"} {
				if err := os.WriteFile(src+ext, []byte("stale"), 0o600); err != nil {
					t.Fatalf("write stale %s: %v", ext, err)
				}
			}

			got, err := prepareWasmArtifacts(src, tc.shape, tc.isDev)
			if err != nil {
				t.Fatalf("prepareWasmArtifacts: %v", err)
			}
			if len(got) != len(tc.want) {
				t.Fatalf("encodings = %v, want %v", got, tc.want)
			}
			for i, encoding := range tc.want {
				if got[i] != encoding {
					t.Fatalf("encodings = %v, want %v", got, tc.want)
				}
			}
			for _, ext := range []string{".br", ".gz"} {
				// #nosec G304 -- src is a test-owned temporary file.
				data, err := os.ReadFile(src + ext)
				switch {
				case tc.compressed:
					if err != nil {
						t.Fatalf("read %s: %v", src+ext, err)
					}
					if string(data) == "stale" {
						t.Fatalf("%s was left from the previous build", src+ext)
					}
				case !os.IsNotExist(err):
					t.Fatalf("expected %s removed, stat err=%v", src+ext, err)
				}
			}
			// The raw bundle is the artifact an embedded build ships, so it
			// has to survive untouched.
			raw, err := os.ReadFile(src)
			if err != nil {
				t.Fatalf("read wasm: %v", err)
			}
			if string(raw) != string(wasm) {
				t.Fatal("the raw wasm was rewritten")
			}
		})
	}
}
F
function

TestWriteClientConfig

Parameters

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

{
	dir := t.TempDir()
	t.Chdir(dir)
	encodings := []Encoding{EncodingBrotli, EncodingGzip}
	if err := writeClientConfig(".", "wss://example.com/rfw", "abc123", encodings, true, true, deliveryNetwork, sscTransportBrowser, hostTransportStreamBus); 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)
	for _, want := range []string{
		`window.RFW_HOST_URL = "wss://example.com/rfw";`,
		`window.RFW_WASM_VERSION = "abc123";`,
		`window.RFW_WASM_DELIVERY = "network";`,
		`window.RFW_WASM_ENCODINGS = ["br", "gzip"];`,
		`window.RFW_WASM_NEGOTIATED = true;`,
		`window.RFW_SSC_TRANSPORT = "browser";`,
		`window.RFW_TRANSPORT = "streambus";`,
		`window.RFW_BUILD_MODE = "production";`,
	} {
		if !strings.Contains(got, want) {
			t.Fatalf("client config is missing %q:\n%s", want, got)
		}
	}
}
F
function

TestWriteClientConfigDescribesAnUncompressedBuild

A build that produced no compressed artifact must say so. An empty list is a
fact the loader can act on; an absent global is indistinguishable from an
older build and is what let an application silently serve the raw bundle.

Parameters

cmd/rfw/build/build_test.go:360-383
func TestWriteClientConfigDescribesAnUncompressedBuild(t *testing.T)

{
	dir := t.TempDir()
	t.Chdir(dir)
	if err := writeClientConfig(".", "", "devbuild", nil, false, false, deliveryNetwork, sscTransportBrowser, hostTransportWebSocket); 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)
	for _, want := range []string{
		`window.RFW_WASM_ENCODINGS = [];`,
		`window.RFW_WASM_NEGOTIATED = false;`,
		`window.RFW_BUILD_MODE = "development";`,
	} {
		if !strings.Contains(got, want) {
			t.Fatalf("client config is missing %q:\n%s", want, got)
		}
	}
	if strings.Contains(got, "RFW_HOST_URL") {
		t.Fatalf("an unset host emitted a host URL:\n%s", got)
	}
}
F
function

TestWriteClientConfigDescribesAnEmbeddedBuild

An embedded production build has no artifact to advertise and nothing to
negotiate with, but it is still a production build: the mode stays honest and
the delivery global is what tells the loader why the encoding list is empty.

Parameters

cmd/rfw/build/build_test.go:388-412
func TestWriteClientConfigDescribesAnEmbeddedBuild(t *testing.T)

{
	dir := t.TempDir()
	t.Chdir(dir)
	if err := writeClientConfig(".", "wss://api.example.com/ws", "abc123", nil, false, true, deliveryEmbedded, sscTransportCapacitor, hostTransportWebSocket); 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)
	for _, want := range []string{
		// An embedded SSC application still talks to its remote host.
		`window.RFW_HOST_URL = "wss://api.example.com/ws";`,
		`window.RFW_WASM_DELIVERY = "embedded";`,
		`window.RFW_WASM_ENCODINGS = [];`,
		`window.RFW_WASM_NEGOTIATED = false;`,
		`window.RFW_SSC_TRANSPORT = "capacitor";`,
		`window.RFW_BUILD_MODE = "production";`,
	} {
		if !strings.Contains(got, want) {
			t.Fatalf("client config is missing %q:\n%s", want, got)
		}
	}
}
F
function

TestWriteClientConfigDefinesEveryGlobalTheLoaderReads

Every global the loader reads has to be defined by the config, or the loader
cannot tell a disabled feature from a stale build.

Parameters

cmd/rfw/build/build_test.go:416-436
func TestWriteClientConfigDefinesEveryGlobalTheLoaderReads(t *testing.T)

{
	dir := t.TempDir()
	t.Chdir(dir)
	if err := writeClientConfig(".", "", "abc123", []Encoding{EncodingGzip}, false, true, deliveryNetwork, sscTransportBrowser, hostTransportWebSocket); err != nil {
		t.Fatalf("writeClientConfig: %v", err)
	}
	config, err := os.ReadFile("rfw_config.js")
	if err != nil {
		t.Fatalf("read client config: %v", err)
	}
	loader, err := os.ReadFile(filepath.Join("..", "..", "initproj", "template", "wasm_loader.js"))
	if err != nil {
		t.Skipf("loader template unavailable: %v", err)
	}
	defined := string(config)
	for _, global := range regexp.MustCompile(`window\.(RFW_[A-Z_]+)`).FindAllStringSubmatch(string(loader), -1) {
		if !strings.Contains(defined, "window."+global[1]+" =") {
			t.Fatalf("the loader reads window.%s but the config never defines it", global[1])
		}
	}
}
F
function

TestStampBootstrapScripts

Parameters

cmd/rfw/build/build_test.go:438-468
func TestStampBootstrapScripts(t *testing.T)

{
	cases := map[string]struct{ html, want string }{
		"unversioned": {
			html: `<script src="/rfw_config.js"></script>`,
			want: `<script src="/rfw_config.js?v=abc123"></script>`,
		},
		"stale version is replaced": {
			html: `<script src="/rfw_config.js?v=2"></script>`,
			want: `<script src="/rfw_config.js?v=abc123"></script>`,
		},
		"relative path": {
			html: `<script src="wasm_exec.js"></script>`,
			want: `<script src="wasm_exec.js?v=abc123"></script>`,
		},
		"extra attributes are preserved": {
			html: `<script defer src="/wasm_loader.js" data-x="1"></script>`,
			want: `<script defer src="/wasm_loader.js?v=abc123" data-x="1"></script>`,
		},
	}
	for name, tc := range cases {
		t.Run(name, func(t *testing.T) {
			got, changed := stampBootstrapScripts(tc.html, "abc123")
			if !changed {
				t.Fatalf("stamping reported no change for %q", tc.html)
			}
			if got != tc.want {
				t.Fatalf("stamped = %q, want %q", got, tc.want)
			}
		})
	}
}
F
function

TestStampBootstrapScriptsLeavesUnrelatedMarkupAlone

Application markup the build does not recognise is left exactly as written.
Rewriting it would be worse than not versioning it.

Parameters

cmd/rfw/build/build_test.go:472-483
func TestStampBootstrapScriptsLeavesUnrelatedMarkupAlone(t *testing.T)

{
	for _, html := range []string{
		`<script src="/vendor/analytics.js"></script>`,
		`<script>const go = new Go();</script>`,
		`<link rel="stylesheet" href="/rfw_config.js" />`,
	} {
		got, changed := stampBootstrapScripts(html, "abc123")
		if changed || got != html {
			t.Fatalf("stamping rewrote unrelated markup %q into %q", html, got)
		}
	}
}
F
function

TestStampIndexHTMLVersionsEveryBootstrapScript

A release has to be able to invalidate a cached loader, so every bootstrap
script in the built page carries the build version.

Parameters

cmd/rfw/build/build_test.go:487-514
func TestStampIndexHTMLVersionsEveryBootstrapScript(t *testing.T)

{
	dir := t.TempDir()
	t.Chdir(dir)
	page := `<html><body><div id="app"></div>` +
		`<script src="/rfw_config.js?v=2"></script>` +
		`<script src="/wasm_exec.js"></script>` +
		`<script src="/wasm_loader.js"></script>` +
		`</body></html>`
	if err := os.WriteFile("index.html", []byte(page), 0o600); err != nil {
		t.Fatalf("write index: %v", err)
	}
	if err := stampIndexHTML("index.html", "deadbeef"); err != nil {
		t.Fatalf("stampIndexHTML: %v", err)
	}
	stamped, err := os.ReadFile("index.html")
	if err != nil {
		t.Fatalf("read index: %v", err)
	}
	for _, script := range versionedScripts {
		want := script + "?v=deadbeef"
		if !strings.Contains(string(stamped), want) {
			t.Fatalf("index.html is missing %q:\n%s", want, stamped)
		}
	}
	if strings.Contains(string(stamped), "?v=2") {
		t.Fatalf("a stale version survived the stamp:\n%s", stamped)
	}
}
F
function

TestStampIndexHTMLIgnoresAMissingPage

A missing page is not an error: a static build may ship no index.html.

Parameters

cmd/rfw/build/build_test.go:517-523
func TestStampIndexHTMLIgnoresAMissingPage(t *testing.T)

{
	dir := t.TempDir()
	t.Chdir(dir)
	if err := stampIndexHTML("index.html", "abc123"); err != nil {
		t.Fatalf("stampIndexHTML on a missing page: %v", err)
	}
}
F
function

TestWriteSSCImport

TestWriteSSCImport checks the generated file carries the wasm build tag and
the hostclient blank import, and that it parses as Go. core no longer
imports hostclient, so this file is what keeps SSC registration wired in a
non-static build.

Parameters

cmd/rfw/build/build_test.go:529-558
func TestWriteSSCImport(t *testing.T)

{
	dir := t.TempDir()
	t.Chdir(dir)

	path, err := writeSSCImport()
	if err != nil {
		t.Fatalf("writeSSCImport error: %v", err)
	}
	if path != sscImportFile {
		t.Fatalf("expected %q, got %q", sscImportFile, path)
	}

	data, err := os.ReadFile("rfw_ssc_gen.go")
	if err != nil {
		t.Fatalf("read generated file: %v", err)
	}
	src := string(data)
	if !strings.HasPrefix(src, "//go:build js && wasm\n") {
		t.Fatalf("generated file must open with the wasm build tag, got:\n%s", src)
	}
	if !strings.Contains(src, `import _ "github.com/rfwlab/rfw/v2/hostclient"`) {
		t.Fatalf("generated file is missing the hostclient blank import:\n%s", src)
	}
	if !strings.Contains(src, "DO NOT EDIT.") {
		t.Fatalf("generated file is missing the generated marker:\n%s", src)
	}
	if _, err := parser.ParseFile(token.NewFileSet(), path, data, parser.SkipObjectResolution); err != nil {
		t.Fatalf("generated file does not parse: %v", err)
	}
}