Skip to content

Commit 6b206ab

Browse files
committed
chore: Optimize WASM builds using Brotli compression
1 parent 1c4e964 commit 6b206ab

File tree

16 files changed

+446
-42
lines changed

16 files changed

+446
-42
lines changed

cmd/rfw/build/build.go

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ import (
99
"path/filepath"
1010
"strings"
1111

12+
"github.com/andybalholm/brotli"
1213
"github.com/rfwlab/rfw/cmd/rfw/plugins"
1314
_ "github.com/rfwlab/rfw/cmd/rfw/plugins/assets"
1415
_ "github.com/rfwlab/rfw/cmd/rfw/plugins/bundler"
@@ -103,6 +104,10 @@ func Build() error {
103104
return fmt.Errorf("failed to build project: %s: %w", output, err)
104105
}
105106

107+
if err := compressWasmBrotli(wasmPath); err != nil {
108+
return fmt.Errorf("failed to brotli-compress wasm: %w", err)
109+
}
110+
106111
if err := os.MkdirAll(hostDir, 0o755); err != nil {
107112
return fmt.Errorf("failed to create host build directory: %w", err)
108113
}
@@ -170,3 +175,43 @@ func copyFile(src, dst string) error {
170175
}
171176
return out.Close()
172177
}
178+
179+
func compressWasmBrotli(src string) (err error) {
180+
in, err := os.Open(src)
181+
if err != nil {
182+
return err
183+
}
184+
defer in.Close()
185+
186+
dst := src + ".br"
187+
tmp, err := os.CreateTemp(filepath.Dir(dst), filepath.Base(dst)+".*")
188+
if err != nil {
189+
return err
190+
}
191+
tmpName := tmp.Name()
192+
defer func() {
193+
if tmp != nil {
194+
tmp.Close()
195+
}
196+
if err != nil {
197+
_ = os.Remove(tmpName)
198+
}
199+
}()
200+
201+
writer := brotli.NewWriterLevel(tmp, brotli.BestCompression)
202+
if _, err := io.Copy(writer, in); err != nil {
203+
writer.Close()
204+
return err
205+
}
206+
if err := writer.Close(); err != nil {
207+
return err
208+
}
209+
if err := tmp.Close(); err != nil {
210+
return err
211+
}
212+
tmp = nil
213+
if err := os.Rename(tmpName, dst); err != nil {
214+
return err
215+
}
216+
return nil
217+
}

cmd/rfw/build/build_test.go

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,13 @@
11
package build
22

33
import (
4+
"io"
45
"os"
56
"path/filepath"
7+
"strings"
68
"testing"
9+
10+
"github.com/andybalholm/brotli"
711
)
812

913
// TestCopyFile ensures copyFile replicates the source file's contents at the
@@ -29,3 +33,32 @@ func TestCopyFile(t *testing.T) {
2933
t.Fatalf("expected %q, got %q", content, got)
3034
}
3135
}
36+
37+
func TestCompressWasmBrotli(t *testing.T) {
38+
dir := t.TempDir()
39+
src := filepath.Join(dir, "app.wasm")
40+
content := []byte(strings.Repeat("rfw wasm", 32))
41+
if err := os.WriteFile(src, content, 0o644); err != nil {
42+
t.Fatalf("write wasm: %v", err)
43+
}
44+
45+
if err := compressWasmBrotli(src); err != nil {
46+
t.Fatalf("compressWasmBrotli: %v", err)
47+
}
48+
49+
brPath := src + ".br"
50+
f, err := os.Open(brPath)
51+
if err != nil {
52+
t.Fatalf("open brotli file: %v", err)
53+
}
54+
defer f.Close()
55+
56+
reader := brotli.NewReader(f)
57+
decompressed, err := io.ReadAll(reader)
58+
if err != nil {
59+
t.Fatalf("read brotli: %v", err)
60+
}
61+
if string(decompressed) != string(content) {
62+
t.Fatalf("unexpected decompressed content")
63+
}
64+
}

cmd/rfw/initproj/template/wasm_loader.js

Lines changed: 62 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -15,33 +15,87 @@
1515
transition: "width 0.3s ease",
1616
});
1717
document.body.appendChild(bar);
18+
let interval;
1819
return {
1920
start() {
2021
let progress = 0;
21-
this.interval = setInterval(() => {
22+
interval = setInterval(() => {
2223
progress = Math.min(progress + Math.random() * 10, 90);
2324
bar.style.width = progress + "%";
2425
}, 200);
2526
},
26-
finish() {
27-
clearInterval(this.interval);
28-
bar.style.width = "100%";
29-
setTimeout(() => bar.remove(), 300);
27+
finish(success = true) {
28+
if (interval) {
29+
clearInterval(interval);
30+
}
31+
if (success) {
32+
bar.style.width = "100%";
33+
setTimeout(() => bar.remove(), 300);
34+
} else {
35+
bar.remove();
36+
}
3037
},
3138
};
3239
}
40+
41+
function buildCandidates(url) {
42+
const trimmed = url.trim();
43+
if (!trimmed) return [];
44+
const queryIndex = trimmed.indexOf("?");
45+
const base = queryIndex === -1 ? trimmed : trimmed.slice(0, queryIndex);
46+
const query = queryIndex === -1 ? "" : trimmed.slice(queryIndex);
47+
const candidates = [];
48+
if (base.endsWith(".wasm") && !base.endsWith(".wasm.br")) {
49+
candidates.push(`${base}.br${query}`);
50+
}
51+
candidates.push(trimmed);
52+
return candidates;
53+
}
54+
55+
async function fetchWithFallback(candidates) {
56+
let lastError;
57+
for (const candidate of candidates) {
58+
try {
59+
const resp = await fetch(candidate);
60+
if (!resp.ok) {
61+
lastError = new Error(`unexpected status ${resp.status}`);
62+
continue;
63+
}
64+
return resp;
65+
} catch (err) {
66+
lastError = err;
67+
}
68+
}
69+
throw lastError || new Error("no wasm candidates provided");
70+
}
71+
3372
async function load(url, { go, color, height, blur, skipLoader } = {}) {
73+
const candidates = buildCandidates(url);
74+
if (candidates.length === 0) {
75+
throw new Error("wasm url is empty");
76+
}
77+
3478
let bar;
3579
if (!skipLoader) {
3680
bar = createBar({ color, height, blur });
3781
bar.start();
3882
}
39-
const resp = await fetch(url);
40-
const bytes = await resp.arrayBuffer();
41-
if (bar) bar.finish();
83+
84+
let response;
85+
try {
86+
response = await fetchWithFallback(candidates);
87+
} catch (err) {
88+
if (bar) bar.finish(false);
89+
console.error("Failed to load Wasm bundle", candidates, err);
90+
throw err;
91+
}
92+
93+
const bytes = await response.arrayBuffer();
94+
if (bar) bar.finish(true);
4295
const result = await WebAssembly.instantiate(bytes, go.importObject);
4396
go.run(result.instance);
4497
return result;
4598
}
99+
46100
global.WasmLoader = { load };
47101
})(window);

docs/articles/api/wasmloader.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,3 +6,5 @@ Minimal helper for loading WebAssembly modules.
66
| --- | --- |
77
| `Load(url string, opts Options)` | Fetch and instantiate a WebAssembly module. |
88

9+
The loader inspects the provided `url` and automatically checks for a Brotli-compressed `.wasm.br` variant before falling back to the original path. Query strings are preserved, so cache-busting patterns like `app.wasm?` work without changes.
10+

docs/articles/essentials/creating-application.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -66,6 +66,8 @@ The compiled `main.go` becomes a WebAssembly bundle. To run it, load the Go runt
6666
</script>
6767
```
6868

69+
The loader automatically looks for `/app.wasm.br` first, preserving cache-busting query strings, and falls back to `/app.wasm` when the compressed asset is unavailable.
70+
6971
> TypeScript support is not yet available. A future release will expose a global `rfw` object to call APIs directly from JavaScript.
7072
7173
---

docs/articles/getting-started/quick-start.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -92,7 +92,7 @@ rfw dev --debug
9292

9393
Features:
9494

95-
* Compiles Go sources to `app.wasm`
95+
* Compiles Go sources to `app.wasm` (served as the Brotli-compressed `app.wasm.br` bundle)
9696
* Serves static files under `/`
9797
* Rebuilds and reloads on file changes
9898
* Runs host components from `host/` if present

docs/articles/guide/cli.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -66,7 +66,7 @@ Build the project for production. Generates an optimized Wasm bundle and host bi
6666

6767
Artifacts:
6868

69-
* `build/client/` – client bundle
69+
* `build/client/` – client bundle (`app.wasm` and the Brotli variant `app.wasm.br`)
7070
* `build/host/` – host binary
7171
* `build/static/` – copied static files
7272

docs/articles/guide/ssc.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -123,7 +123,7 @@ func main() {
123123
## Hydration: how the page becomes interactive
124124

125125
1. The host responds with **fully rendered HTML**.
126-
2. The browser downloads `app.wasm` and initializes rfw.
126+
2. The browser downloads the Brotli-compressed `app.wasm.br` (falling back to `app.wasm` when necessary) and initializes rfw.
127127
3. rfw **hydrates** the server markup: attaches event handlers and reactive bindings.
128128
4. A WebSocket connects; `h:` values and commands synchronize with the host.
129129

docs/articles/guide/wasm-loader.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,8 @@
22

33
The default project template includes a Go-powered loader that exposes a global `WasmLoader` helper. By default, it shows a red progress bar while the WebAssembly bundle downloads and initializes.
44

5+
> **Note:** When the build pipeline emits a Brotli-compressed bundle (e.g. `app.wasm.br`), the loader automatically tries the `.wasm.br` asset before falling back to the plain `.wasm` file, so existing calls don’t need to change.
6+
57
---
68

79
## Why a Loader?

docs/wasm_loader.js

Lines changed: 62 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -15,33 +15,87 @@
1515
transition: "width 0.3s ease",
1616
});
1717
document.body.appendChild(bar);
18+
let interval;
1819
return {
1920
start() {
2021
let progress = 0;
21-
this.interval = setInterval(() => {
22+
interval = setInterval(() => {
2223
progress = Math.min(progress + Math.random() * 10, 90);
2324
bar.style.width = progress + "%";
2425
}, 200);
2526
},
26-
finish() {
27-
clearInterval(this.interval);
28-
bar.style.width = "100%";
29-
setTimeout(() => bar.remove(), 300);
27+
finish(success = true) {
28+
if (interval) {
29+
clearInterval(interval);
30+
}
31+
if (success) {
32+
bar.style.width = "100%";
33+
setTimeout(() => bar.remove(), 300);
34+
} else {
35+
bar.remove();
36+
}
3037
},
3138
};
3239
}
40+
41+
function buildCandidates(url) {
42+
const trimmed = url.trim();
43+
if (!trimmed) return [];
44+
const queryIndex = trimmed.indexOf("?");
45+
const base = queryIndex === -1 ? trimmed : trimmed.slice(0, queryIndex);
46+
const query = queryIndex === -1 ? "" : trimmed.slice(queryIndex);
47+
const candidates = [];
48+
if (base.endsWith(".wasm") && !base.endsWith(".wasm.br")) {
49+
candidates.push(`${base}.br${query}`);
50+
}
51+
candidates.push(trimmed);
52+
return candidates;
53+
}
54+
55+
async function fetchWithFallback(candidates) {
56+
let lastError;
57+
for (const candidate of candidates) {
58+
try {
59+
const resp = await fetch(candidate);
60+
if (!resp.ok) {
61+
lastError = new Error(`unexpected status ${resp.status}`);
62+
continue;
63+
}
64+
return resp;
65+
} catch (err) {
66+
lastError = err;
67+
}
68+
}
69+
throw lastError || new Error("no wasm candidates provided");
70+
}
71+
3372
async function load(url, { go, color, height, blur, skipLoader } = {}) {
73+
const candidates = buildCandidates(url);
74+
if (candidates.length === 0) {
75+
throw new Error("wasm url is empty");
76+
}
77+
3478
let bar;
3579
if (!skipLoader) {
3680
bar = createBar({ color, height, blur });
3781
bar.start();
3882
}
39-
const resp = await fetch(url);
40-
const bytes = await resp.arrayBuffer();
41-
if (bar) bar.finish();
83+
84+
let response;
85+
try {
86+
response = await fetchWithFallback(candidates);
87+
} catch (err) {
88+
if (bar) bar.finish(false);
89+
console.error("Failed to load Wasm bundle", candidates, err);
90+
throw err;
91+
}
92+
93+
const bytes = await response.arrayBuffer();
94+
if (bar) bar.finish(true);
4295
const result = await WebAssembly.instantiate(bytes, go.importObject);
4396
go.run(result.instance);
4497
return result;
4598
}
99+
46100
global.WasmLoader = { load };
47101
})(window);

0 commit comments

Comments
 (0)