Skip to content

Commit 15e4340

Browse files
committed
Add WASM build
Based on Goal's. The given index.html is changed slightly to show the version (which shows that ari is loaded in the Goal evaluation context) and adjust styling a little. This commit also separates those Goal verbs defined in Ari that currently work in all (universal) environments and those which do not, to prevent trying to use verbs that will only break when called via WebAssembly.
1 parent 8a8c3d5 commit 15e4340

File tree

10 files changed

+351
-13
lines changed

10 files changed

+351
-13
lines changed

.gitignore

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,5 @@
11
coverage.txt
22
dist/
33
.ari.history
4+
wasm_exec.js
5+
*.wasm

README.md

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -86,6 +86,16 @@ To publish a new version of Ari:
8686
./script/release vx.y.z
8787
```
8888

89+
### WASM
90+
91+
Use the `./script/build-wasm` script to generate a `./cmd/wasm/goal.wasm` file from the `./cmd/wasm/main.go` entry-point.
92+
93+
You then need to locate the `wasm_exec.js` file for your specific Go version and copy that to the `./cmd/wasm` folder. Depending on your system installation, you might find it there under a `lib` or `misc` folder. If not, you can download a source tarball for your specific Go version from the Go downloads which includes this file.
94+
95+
JavaScript that controls the user interface in `./cmd/wasm/index.html` is written in Go in the `./cmd/wasm/main.go` file.
96+
97+
See [Go Wiki: WebAssembly](https://go.dev/wiki/WebAssembly) for more information.
98+
8999
## Background
90100

91101
I stumbled into a fairly flexible, powerful setup using Julia and DuckDB to do data analysis.

cmd/wasm/help.go

Lines changed: 5 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

cmd/wasm/index.html

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,47 @@
1+
<!doctype html>
2+
<html>
3+
4+
<head>
5+
<meta http-equiv="Content-type" content="text/html; charset=utf-8" />
6+
<meta name="viewport" content="initial-scale=1">
7+
<title>Try Goal in the Browser</title>
8+
<link rel="stylesheet" type="text/css" href="style.css" />
9+
</head>
10+
11+
<body>
12+
<script src="wasm_exec.js"></script>
13+
<div class="menu">
14+
<!-- <a class="back" title="back to main page" href="../index.html">Back</a> -->
15+
<button id="eval" title="run Goal code (shortcut: ctrl-enter)">eval</button>
16+
<button id="link" title="copy link to clipboard">link</button>
17+
<button id="help" title="display help (shortcut: F1)">help</button>
18+
<div class="goalVersion">
19+
<label><strong>Version</strong></label>
20+
<input type="text" disabled="disabled" id="goalVersionInput" />
21+
</div>
22+
</div>
23+
<div class="fl fr">
24+
<textarea id="in" class="fl" autofocus="" spellcheck="false"></textarea>
25+
<textarea id="out" class="fl" readonly="" placeholder="output (ctrl-enter to eval)"
26+
spellcheck="false"></textarea>
27+
</div>
28+
<script>
29+
if (!WebAssembly.instantiateStreaming) { // polyfill
30+
WebAssembly.instantiateStreaming = async (resp, importObject) => {
31+
const source = await (await resp).arrayBuffer();
32+
return await WebAssembly.instantiate(source, importObject);
33+
};
34+
}
35+
const go = new Go();
36+
let mod, inst;
37+
WebAssembly.instantiateStreaming(fetch("goal.wasm"), go.importObject).then((result) => {
38+
mod = result.module;
39+
inst = result.instance;
40+
go.run(inst);
41+
}).catch((err) => {
42+
console.error(err);
43+
});
44+
</script>
45+
</body>
46+
47+
</html>

cmd/wasm/main.go

Lines changed: 158 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,158 @@
1+
//go:build js && wasm
2+
3+
package main
4+
5+
import (
6+
"compress/zlib"
7+
"encoding/base64"
8+
"fmt"
9+
"io"
10+
"log"
11+
"runtime/debug"
12+
"strings"
13+
"syscall/js"
14+
15+
"codeberg.org/anaseto/goal"
16+
"github.com/semperos/ari"
17+
)
18+
19+
func getEltById(id string) js.Value {
20+
return js.Global().Get("document").Call("getElementById", id)
21+
}
22+
23+
func evalTextArea() {
24+
defer func() {
25+
if r := recover(); r != nil {
26+
log.Printf("Caught panic: %v\nStack Trace:\n", r)
27+
debug.PrintStack()
28+
out := getEltById("out")
29+
out.Set("value", fmt.Sprintf("Caught panic: %v\n", r))
30+
}
31+
}()
32+
in := getEltById("in").Get("value").String()
33+
out := getEltById("out")
34+
ariCtx, err := ari.NewUniversalContext()
35+
if err != nil {
36+
out.Set("value", err.Error())
37+
}
38+
ctx := ariCtx.GoalContext
39+
var sb strings.Builder
40+
ctx.Log = &sb
41+
x, err := ctx.Eval(in)
42+
if err != nil {
43+
if e, ok := err.(*goal.Panic); ok {
44+
out.Set("value", sb.String()+e.ErrorStack())
45+
} else {
46+
out.Set("value", sb.String()+err.Error())
47+
}
48+
} else {
49+
out.Set("value", sb.String()+x.Sprint(ctx, false))
50+
}
51+
updateHash()
52+
}
53+
54+
func updateHash() {
55+
var sb strings.Builder
56+
in := getEltById("in").Get("value").String()
57+
b64w := base64.NewEncoder(base64.URLEncoding, &sb)
58+
zw := zlib.NewWriter(b64w)
59+
sb.WriteByte('#')
60+
fmt.Fprint(zw, in)
61+
zw.Flush()
62+
zw.Close()
63+
b64w.Close()
64+
location := js.Global().Get("window").Get("location")
65+
location.Set("hash", sb.String())
66+
}
67+
68+
func updateTextArea() {
69+
hash := js.Global().Get("window").Get("location").Get("hash").String()
70+
in := getEltById("in")
71+
if hash == "" {
72+
in.Set("value", "")
73+
return
74+
}
75+
r := base64.NewDecoder(base64.URLEncoding, strings.NewReader(hash[1:]))
76+
zr, err := zlib.NewReader(r)
77+
if err != nil {
78+
log.Printf("zlib reader: %v", err)
79+
log.Printf("hash: %s", hash)
80+
return
81+
}
82+
var sb strings.Builder
83+
_, err = io.Copy(&sb, zr)
84+
if err != nil {
85+
log.Printf("decoding hash: %v", err)
86+
log.Printf("hash: %s", hash)
87+
return
88+
}
89+
zr.Close()
90+
log.Print(sb.String())
91+
in.Set("value", sb.String())
92+
}
93+
94+
func main() {
95+
updateTextArea()
96+
97+
goalVersion := getEltById("goalVersionInput")
98+
ariCtx, err := ari.NewUniversalContext()
99+
if err != nil {
100+
goalVersion.Set("value", err.Error())
101+
}
102+
ctx := ariCtx.GoalContext
103+
var sb strings.Builder
104+
ctx.Log = &sb
105+
x, err := ctx.Eval(`rt.get"v"`)
106+
if err != nil {
107+
if e, ok := err.(*goal.Panic); ok {
108+
log.Printf(sb.String() + e.ErrorStack())
109+
} else {
110+
log.Printf(sb.String() + e.Error())
111+
}
112+
} else {
113+
goalVersion.Set("value", sb.String()+x.Sprint(ctx, false))
114+
}
115+
116+
eval := getEltById("eval")
117+
evalFunc := js.FuncOf(func(this js.Value, args []js.Value) any {
118+
evalTextArea()
119+
return nil
120+
})
121+
eval.Call("addEventListener", "click", evalFunc)
122+
textIn := getEltById("in")
123+
textInFunc := js.FuncOf(func(this js.Value, args []js.Value) any {
124+
e := args[0]
125+
key := e.Get("key").String()
126+
if e.Get("ctrlKey").Bool() && key == "Enter" {
127+
e.Call("preventDefault")
128+
evalTextArea()
129+
} else if key == "F1" {
130+
out := getEltById("out")
131+
out.Set("value", helpString)
132+
}
133+
return nil
134+
})
135+
textIn.Call("addEventListener", "keydown", textInFunc)
136+
link := getEltById("link")
137+
linkFunc := js.FuncOf(func(this js.Value, args []js.Value) any {
138+
updateHash()
139+
href := js.Global().Get("window").Get("location").Get("href")
140+
js.Global().Get("navigator").Get("clipboard").Call("writeText", href)
141+
return nil
142+
})
143+
link.Call("addEventListener", "click", linkFunc)
144+
help := getEltById("help")
145+
helpFunc := js.FuncOf(func(this js.Value, args []js.Value) any {
146+
out := getEltById("out")
147+
out.Set("value", helpString)
148+
return nil
149+
})
150+
help.Call("addEventListener", "click", helpFunc)
151+
hashFunc := js.FuncOf(func(this js.Value, args []js.Value) any {
152+
updateTextArea()
153+
return nil
154+
})
155+
js.Global().Get("window").Call("addEventListener", "hashchange", hashFunc)
156+
wait := make(chan bool)
157+
<-wait
158+
}

cmd/wasm/style.css

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
/* Colors from https://github.com/jan-warchol/selenized/blob/master/the-values.md */
2+
@viewport { zoom: 1.0; width: device-width; }
3+
html { background-color: #ece3cc; color: #53676d; }
4+
body { min-height: 100vh; display: flex; flex-direction: column; }
5+
div.menu { padding: 4px; display: flex; align-items: flex-start; }
6+
div.menu .goalVersion { margin-left: auto; }
7+
.goalVersion input { border: none; border-color: transparent; outline: none; }
8+
button { margin-right: 4px; cursor: pointer; background-color: #fbf3db; color: #53676d; }
9+
textarea { font-family: monospace; resize: none; background-color: #fbf3db; color: #53676d; margin: 0 2px; border: 0; }
10+
textarea::placeholder { color: #909995; }
11+
.fl { display: flex; flex: 1 }
12+
.fr { display: flex; flex-direction: row }
13+
a.back { margin-right: 4px; color: #0072d4; }
14+
#out { color: forestgreen; }

context.go

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -91,6 +91,18 @@ func NewGoalContext(ariContext *Context, help Help, sqlDatabase *SQLDatabase) (*
9191
return goalContext, nil
9292
}
9393

94+
// Initialize a Goal language context with Ari's extensions.
95+
func NewUniversalGoalContext(help Help) (*goal.Context, error) {
96+
goalContext := goal.NewContext()
97+
goalContext.Log = os.Stderr
98+
goalRegisterUniversalVariadics(goalContext, help)
99+
err := goalLoadExtendedPreamble(goalContext)
100+
if err != nil {
101+
return nil, err
102+
}
103+
return goalContext, nil
104+
}
105+
94106
// Initialize SQL struct, but don't open the DB yet.
95107
//
96108
// Call SQLDatabase.open to open the database.
@@ -142,3 +154,29 @@ func NewContext(dataSourceName string) (*Context, error) {
142154
ctx.Help = help
143155
return &ctx, nil
144156
}
157+
158+
// Initialize a new Context that can be used across platforms, including WASM.
159+
func NewUniversalContext() (*Context, error) {
160+
ctx := Context{}
161+
helpDictionary := NewHelp()
162+
ariHelpFunc := func(s string) string {
163+
goalHelp, ok := helpDictionary["goal"]
164+
if !ok {
165+
panic(`Developer Error: Dictionary in Help must have a \"goal\" entry.`)
166+
}
167+
help, found := goalHelp[s]
168+
if found {
169+
return help
170+
}
171+
return ""
172+
}
173+
helpFunc := help.Wrap(ariHelpFunc, help.HelpFunc())
174+
help := Help{Dictionary: helpDictionary, Func: helpFunc}
175+
goalContext, err := NewUniversalGoalContext(help)
176+
if err != nil {
177+
return nil, err
178+
}
179+
ctx.GoalContext = goalContext
180+
ctx.Help = help
181+
return &ctx, nil
182+
}

goal.go

Lines changed: 18 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -162,14 +162,11 @@ func goalNewDictEmpty() *goal.D {
162162

163163
// Integration with other parts of Ari
164164

165-
func goalRegisterVariadics(ariContext *Context, goalContext *goal.Context, help Help, sqlDatabase *SQLDatabase) {
166-
// From Goal itself, os lib imported without prefix
165+
func goalRegisterUniversalVariadics(goalContext *goal.Context, help Help) {
166+
// From Goal itself, os lib imported without prefix. Includes 'say' verb, which works even in WASM.
167167
gos.Import(goalContext, "")
168-
// Ari
169168
goalContext.RegisterExtension("ari", AriVersion)
170169
// Monads
171-
goalContext.RegisterMonad("sql.close", VFSqlClose)
172-
goalContext.RegisterMonad("sql.open", VFSqlOpen)
173170
goalContext.RegisterMonad("time.day", VFTimeDay)
174171
goalContext.RegisterMonad("time.hour", VFTimeHour)
175172
goalContext.RegisterMonad("time.loadlocation", VFTimeLoadLocation)
@@ -195,6 +192,22 @@ func goalRegisterVariadics(ariContext *Context, goalContext *goal.Context, help
195192
goalContext.RegisterMonad("url.encode", VFUrlEncode)
196193
// Dyads
197194
goalContext.RegisterDyad("help", VFGoalHelp(help))
195+
goalContext.RegisterDyad("time.add", VFTimeAdd)
196+
goalContext.RegisterDyad("time.date", VFTimeDate)
197+
goalContext.RegisterDyad("time.fixedzone", VFTimeFixedZone)
198+
goalContext.RegisterDyad("time.format", VFTimeFormat)
199+
goalContext.RegisterDyad("time.parse", VFTimeParse)
200+
goalContext.RegisterDyad("time.sub", VFTimeSub)
201+
// Globals
202+
registerTimeGlobals(goalContext)
203+
}
204+
205+
func goalRegisterVariadics(ariContext *Context, goalContext *goal.Context, help Help, sqlDatabase *SQLDatabase) {
206+
goalRegisterUniversalVariadics(goalContext, help)
207+
// Monads
208+
goalContext.RegisterMonad("sql.close", VFSqlClose)
209+
goalContext.RegisterMonad("sql.open", VFSqlOpen)
210+
// Dyads
198211
goalContext.RegisterDyad("http.client", VFHTTPClientFn())
199212
goalContext.RegisterDyad("http.delete", VFHTTPMaker(ariContext, "DELETE"))
200213
goalContext.RegisterDyad("http.get", VFHTTPMaker(ariContext, "GET"))
@@ -206,14 +219,6 @@ func goalRegisterVariadics(ariContext *Context, goalContext *goal.Context, help
206219
goalContext.RegisterDyad("http.serve", VFServe)
207220
goalContext.RegisterDyad("sql.q", VFSqlQFn(sqlDatabase))
208221
goalContext.RegisterDyad("sql.exec", VFSqlExecFn(sqlDatabase))
209-
goalContext.RegisterDyad("time.add", VFTimeAdd)
210-
goalContext.RegisterDyad("time.date", VFTimeDate)
211-
goalContext.RegisterDyad("time.fixedzone", VFTimeFixedZone)
212-
goalContext.RegisterDyad("time.format", VFTimeFormat)
213-
goalContext.RegisterDyad("time.parse", VFTimeParse)
214-
goalContext.RegisterDyad("time.sub", VFTimeSub)
215-
// Globals
216-
registerTimeGlobals(goalContext)
217222
}
218223

219224
//nolint:funlen

0 commit comments

Comments
 (0)