craft your forge, build your project, grow your community freely
1# Modifying the API in the SDK Plugin
2 3The last feature for version 0.4 is about adding `mv` (move) and `cp` (copy) functions to the SDK plugin. I'm taking advantage of this development to explain how the plumbing works under the hood.
4 5Right now, the SDK plugin is undocumented because there are a lot of breaking changes coming. Every time I develop a new plugin, I discover better ways to do things, and the SDK needs to adapt.
6 7For example, when I added `apex_code` (a plugin that takes code as a string and returns syntax-highlighted HTML), I wondered: "Is this a standalone plugin?". Not really, because there's no file to manipulate, neither in input nor output. Instead, it manages blocks inside an existing file. The SDK wasn't originally built with that use case in mind.
8 9Anyway, I think explaining how the SDK works through a concrete example will be helpful, if not for you, at least for me.
10 11## What is the SDK's job?
12 13The SDK exists to pass data from GitRoot (the host) to the plugin (the guest). As you know, GitRoot uses WebAssembly (WASM) to manage plugins. Unfortunately, WASM is still a bit low-level to implement: between the host and the guest, only integers can travel.
14 15Wait, only integers?
16 17Yes. So, how do we send a string? The host can store any kind of data in the guest's memory. GitRoot stores a string, retrieves the pointer (the memory address) and the size, and sends those two integers to the guest. The guest then retrieves the string from its own memory. It's a complex dance just for a simple string. The SDK is here to hide all that mess.
18 19When you build a plugin, you just implement an interface. The SDK handles the rest: you receive objects and strings, and you send back data without ever worrying about the underlying WASM pointers.
20 21## What does a concrete implementation look like?
22 23When I add a new function, I always start with the host side. All functions are located in [runtime.go](../app/server/plugin/runtime.go).
24 25We need to register the function in [wazero](https://github.com/wazero/wazero), our WASM runtime. I add two versions because I haven't found a clean way to bridge the difference between AssemblyScript and Go/Rust yet. Hopefully, I'll figure that out one day.
26 27```go
28r.wazRun.
29NewFunctionBuilder().WithFunc(r.MoveFileBuilder(false)).Export("moveFile").
30NewFunctionBuilder().WithFunc(r.MoveFileBuilder(true)).Export("moveFileAS")
31``` 32 33Then I implement the builder:
34 35```go
36func (r *runtime) MoveFileBuilder(forAS bool) interface{} {
37if !forAS {
38returnfunc(_ context.Context, m api.Module, ptr, size, ptr2, size2 uint32) {
39// from ptr/size to string or object
40 r.moveFile(fromPath, toPath)
41 }
42 } else {
43returnfunc(_ context.Context, m api.Module, ptr, ptr2 uint32) {
44// from ptr to string or object
45 r.moveFile(fromPath, toPath)
46 }
47 }
48}
49``` 50 51> Yeah, I know. As a Gopher, seeing `interface{}` as a return type and all these uint32 feels wrong. But hey, we're not here for a code review, we're here to add an API. If you have a cleaner way, feel free to submit a graft!
52 53We want to allow any plugin to move a file. Let's define the internal `moveFile` function:
54 55```go
56func (r *runtime) moveFile(fromPath, toPath string) error {
57//need to do something...
58}
59``` 60 61Actually, there's a catch, GitRoot uses three internal filesystems: one for the git worktree, one for web content, and one for the cache. Even if they are abstracted under one root, the code often expects a specific FS. I'll probably clean this up later, but for now:
62 63```go
64func (r *runtime) moveFile(fromFs, fromPath, toFs, toPath string) error {
65//need to do something...
66}
67``` 68 69Now your plugin can call `moveFile("cache", "artifacts/myBin", "web", "releases/myBin-v1.0")` to publish an artifact, or `moveFile("web", "index.html", "web", "old/index.html")` to back up a file.
70 71I won't bore you with the implementation logic here. If you're curious, check the [code](../app/server/plugin/runtime.go).
72 73Here is how the final builder looks, converting pointers to strings:
74 75```go
76func (r *runtime) MoveFileBuilder(forAS bool) interface{} {
77if !forAS {
78returnfunc(_ context.Context, m api.Module, fromFsPtr, fromFsSize, fromPathPtr, fromGlobPathSize, toFsPtr, toFsSize, toPathPtr, toPathSize uint32) uint64 {
79 fromFs, err := r.readString(m, "MoveFile fromFs", fromFsPtr, fromFsSize)
80if err !=nil {
81return r.sendEmptyOrError(m, err)
82 }
83 fromPath, err := r.readString(m, "MoveFile fromGlobPath", fromPathPtr, fromGlobPathSize)
84if err !=nil {
85return r.sendEmptyOrError(m, err)
86 }
87 toFs, err := r.readString(m, "MoveFile toFs", toFsPtr, toFsSize)
88if err !=nil {
89return r.sendEmptyOrError(m, err)
90 }
91 toPath, err := r.readString(m, "MoveFile toPath", toPathPtr, toPathSize)
92if err !=nil {
93return r.sendEmptyOrError(m, err)
94 }
95return r.sendEmptyOrError(m, r.moveFile(fromFs, fromPath, toFs, toPath))
96 }
97 } else {
98returnfunc(_ context.Context, m api.Module, fromFsPtr, fromPathPtr, toFsPtr, toPathPtr uint32) uint64 {
99 fromFs, err := r.readASString(m, "MoveFileAS fromFs", fromFsPtr)
100if err !=nil {
101return r.sendEmptyOrError(m, err)
102 }
103 fromPath, err := r.readASString(m, "MoveFileAS fromGlobPath", fromPathPtr)
104if err !=nil {
105return r.sendEmptyOrError(m, err)
106 }
107 toFs, err := r.readASString(m, "MoveFileAS toFs", toFsPtr)
108if err !=nil {
109return r.sendEmptyOrError(m, err)
110 }
111 toPath, err := r.readASString(m, "MoveFileAS toPath", toPathPtr)
112if err !=nil {
113return r.sendEmptyOrError(m, err)
114 }
115return r.sendEmptyOrError(m, r.moveFile(fromFs, fromPath, toFs, toPath))
116 }
117 }
118}
119```120121When I need to pass complex objects, I use `json.Marshal` in GitRoot and `json.Unmarshal` in the SDK. It works, but don't get too attached to it, I might change the serialization method in the future.
122123## Inside the SDK
124125Now that the host is ready, we need to implement the other side: the SDK itself. I usually start with Go, then move to Rust and AssemblyScript. Sometimes I forget the others... shame on me! I'm planning to automate this with some boilerplate generation later.
126127In the [server](../app/libs/golang/plugin/model/server.go) interface:
128129```go
130type Server interface {
131//...
132MoveFile(fromFs, fromPath, toFs, toPath string) error133//...
134}
135```136137To make it more "user-friendly", I use a struct to represent the filesystem in [model/fs.go](../app/libs/golang/plugin/model/fs.go):
138139```go
140func (g *GrFs) MoveFile(fromPath string, toPath string) error {
141return g.server.MoveFile(g.base, fromPath, g.base, toPath)
142}
143144func (g *GrFs) MoveFileToFs(fromPath string, toFs *GrFs, toPath string) error {
145return g.server.MoveFile(g.base, fromPath, toFs.base, toPath)
146}
147```148149The actual WASM call happens in [plugin/server.go](../app/libs/golang/plugin/server.go), where we transform strings back into pointers:
150151```go
152// MoveFile implements [model.ServerNeeded].
153func (s *pServer) MoveFile(fromFs model.FsBase, fromPath string, toFs model.FsBase, toPath string) error {
154 ptr, size :=stringToPtr(string(fromFs))
155 ptr2, size2 :=stringToPtr(fromPath)
156 ptr3, size3 :=stringToPtr(string(toFs))
157 ptr4, size4 :=stringToPtr(toPath)
158 resPtrSize :=_moveFile(ptr, size, ptr2, size2, ptr3, size3, ptr4, size4)
159 runtime.KeepAlive(fromFs)
160 runtime.KeepAlive(fromPath)
161 runtime.KeepAlive(toFs)
162 runtime.KeepAlive(toPath)
163returnptrSizeToError(resPtrSize)
164}
165```166167And finally, the link to the host via `go:wasmimport` in [plugin/imports.go](../app/libs/golang/plugin/imports.go):
168169```go
170//go:wasmimport gitroot moveFile
171func_moveFile(fromFsPtr, fromFsSize, fromPathPtr, fromPathSize, toFsPtr, toFsSize, toPathPtr, toPathSize uint32) (ptrSize uint64)
172```173174## How to use it?
175176Now, any plugin can use this. Here's a simple Go plugin that triggers a build whenever a `.go` file is modified and moves the binary to the web folder.
177178```go
179package main
180181import (
182"fmt"183"path/filepath"184"strings"185186 gitroot "gitroot.dev/libs/golang/plugin"187"gitroot.dev/libs/golang/plugin/model"188)
189190var defaultRun = []model.PluginRun{{
191 Path: "**/*.go",
192 When: model.PluginRunWhenAll,
193 Branch: []string{"main"},
194 Write: model.PluginWrite{
195 Web: []model.PluginWriteRight{{
196 Path: "releases/main",
197 Can: model.PluginWriteRightCanAll,
198 }},
199 },
200}}
201202type Plugin struct {
203 server model.Server
204 needToBuild bool205}
206207func (p *Plugin) Init(repoName string, confHasChanged bool, serializedConf string) error {
208 p.needToBuild = false209returnnil210}
211212func (p *Plugin) StartCommit(commit model.Commit) error { returnnil }
213214func (p *Plugin) AddFile(file model.File) error {
215 p.needToBuild = true216returnnil217}
218219func (p *Plugin) ModFile(file model.File) error {
220 p.needToBuild = true221returnnil222}
223224func (p *Plugin) DelFile(file model.File) error {
225 p.needToBuild = true226returnnil227}
228229func (p *Plugin) EndCommit(commit model.Commit) error { returnnil }
230231func (p *Plugin) Finish() error {
232if p.needToBuild {
233 res, _ := p.server.Exec(model.Exec{
234 Cmds: []model.Cmd{
235 {Cmd: "go", Args: []string{"build"}},
236 },
237 Artifacts: []string{"main"},
238 })
239return p.server.Cache().MoveFileToFs(res.Artifacts[0], p.server.Webcontent(), filepath.Join("releases", "main"))
240 }
241returnnil242}
243244funcBuild(server model.Server) model.Plugin {
245return&Plugin{
246 server: server,
247 }
248}
249250//go:wasmexport install
251funcmain() {
252 gitroot.Register(defaultRun, Build)
253}
254```255256## Is that all?
257258Nope. I still need to update the [Rust](../app/libs/rust/plugin/src/fs.rs) and [AssemblyScript](../app/libs/ts/plugin/fs.ts) SDKs and, of course, add [tests](../app/libs/golang/plugin/test/fakeserver.go). But this should give you a good idea of how the plumbing works.
259260If anything is unclear, [let me know](../contact.md)!