build
packageAPI reference for the build
package.
Imports
(29)encoding/json
STD
fmt
STD
strings
STD
testing
STD
compress/gzip
STD
errors
STD
io
STD
os
STD
regexp
PKG
github.com/andybalholm/brotli
STD
crypto/sha256
STD
os/exec
STD
path/filepath
STD
strconv
INT
github.com/rfwlab/rfw/v2/cmd/rfw/initproj
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
STD
go/parser
STD
go/token
STD
io/fs
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.
type delivery string
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.
type sscTransport string
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.
type hostTransport string
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
Returns
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,
)
}
}
Uses
parseSSCTransport
Parameters
Returns
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,
)
}
}
Uses
parseHostTransport
Parameters
Returns
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,
)
}
}
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.
type buildShape struct
Methods
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
func (buildShape) negotiates() bool
{
return !s.static && s.delivery == deliveryNetwork
}
compresses reports whether the build should write compressed artifacts. Packaging them would ship bytes nothing will ever request.
Returns
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 |
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
Returns
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
}
Uses
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
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)
}
})
}
}
TestParseDeliveryRejectsUnknownModes
Parameters
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)
}
}
}
}
TestParseSSCTransport
Parameters
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)
}
}
}
TestParseSSCTransportRejectsUnknownModes
Parameters
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)
}
}
}
}
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
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)
}
})
}
}
TestDecodeBuildShapeRejectsAnUnknownDelivery
Parameters
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")
}
}
TestDecodeBuildShapeRejectsAnUnknownSSCTransport
Parameters
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")
}
}
TestDecodeBuildShapeRejectsAnUnknownHostTransport
Parameters
func TestDecodeBuildShapeRejectsAnUnknownHostTransport(t *testing.T)
{
if _, err := decodeBuildShape([]byte(`{"transport":"telepathy"}`)); err == nil {
t.Fatal("an unknown host transport built without an error")
}
}
TestDecodeBuildShapeToleratesAnUnparsableManifest
An unreadable manifest has always fallen back to the defaults rather than
failing the build, and it still does.
Parameters
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)
}
}
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
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)
}
}
}
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
}
Encoding
Encoding names an HTTP content coding the build can produce for the wasm
bundle, in the order a client should prefer them.
type Encoding string
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
Returns
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
}
Uses
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
Returns
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
}
compressWasmAs
compressWasmAs writes one compressed copy of src.
Parameters
Returns
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()
}
Uses
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
Returns
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
}
quotedScripts
quotedScripts builds the alternation of bootstrap file names with their
metacharacters escaped, so a dot matches a dot.
Returns
func quotedScripts() string
{
quoted := make([]string, len(versionedScripts))
for i, name := range versionedScripts {
quoted[i] = regexp.QuoteMeta(name)
}
return strings.Join(quoted, "|")
}
stampBootstrapScripts
stampBootstrapScripts rewrites the bootstrap script tags in html so each
carries the current build version. It reports whether anything changed.
Parameters
Returns
func stampBootstrapScripts(html, version string) (string, bool)
{
stamped := scriptTag.ReplaceAllString(html, `${1}${2}${3}?v=`+version+`${5}`)
return stamped, stamped != html
}
stampIndexHTML
stampIndexHTML applies stampBootstrapScripts to the built index.html.
Parameters
Returns
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))
}
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
{
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
}
writeWasmLoader
Parameters
Returns
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
}
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
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
}
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.
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
Returns
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()))
}
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())
}
TestBrowserLoaderScenarios
Parameters
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)
}
}
TestWriteWasmLoaderUsesFrameworkDefault
Parameters
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")
}
}
TestWriteWasmLoaderPreservesProjectOverride
Parameters
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)
}
}
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
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)
}
}
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)
}
}
TestRemoveStaleArtifacts
Parameters
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)
}
}
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
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)
}
}
}
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
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")
}
})
}
}
TestWriteClientConfig
Parameters
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)
}
}
}
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
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)
}
}
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
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)
}
}
}
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
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])
}
}
}
TestStampBootstrapScripts
Parameters
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)
}
})
}
}
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
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)
}
}
TestStampIndexHTMLIgnoresAMissingPage
A missing page is not an error: a static build may ship no index.html.
Parameters
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)
}
}
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
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)
}
}