
workers-go
workers-go is fork of syumai's workers β€οΈ β a lightweight package for building and running Go on Cloudflare Workers using WebAssembly (WASM).
Powered by Vite and
Cloudflare Wrangler
π GitHub (mirror) π Codeberg (source)
Contents
Requirements
- Go 1.21
- NodeJS 24
NOTE: Newer Golang versions have improved performance, but it also increases significantly the binary size, and for this reason this project is made using Go 1.21
Install
This library has only been tested on Go 1.21 with NodeJS 24
go get codeberg.org/darckfast/workers-go or bun create from the templates below that suits you better
Templates
# ultimate express (nodejs)
bun create cloudflare@latest --template=codeberg.org/darckfast/workers-go/_apps/_ultimate-express
# hono
bun create cloudflare@latest --template=codeberg.org/darckfast/workers-go/_apps/_hono
# bun
bun create cloudflare@latest --template=codeberg.org/darckfast/workers-go/_apps/_bun
# deno
deno create cloudflare@latest --template=codeberg.org/darckfast/workers-go/_apps/_deno
# full worker
bun create cloudflare@latest --template=codeberg.org/darckfast/workers-go/_apps/_worker
# minimal queue consumer worker
bun create cloudflare@latest --template=codeberg.org/darckfast/workers-go/_apps/_minimal_queues_consumer Making HTTP Requests
r, _ := http.NewRequest("GET", "https://google.com", nil)
c := http.Client{
Timeout: 5 * time.Second,
}
// or to use the `cf` property
//c := fetch.Client{
// Timeout: 5 * time.Second,
// CF: &RequestInitCF{
// // cf attributes
// }
//}
// Timeouts return an error
rs, err := c.Do(r)
if err != nil {
// do error handling
}
defer rs.Body.Close()
b, _ := io.ReadAll(rs.Body)
fmt.Println(string(b)) For all options available in RequestInitCF check Cloudflare documentation
Cloudflare
This section contains documentation and examples for running this package within Cloudflare Workers platform, also binding for Cloudflare services like D1, R2, Cache API and more
Project structure
Here is a sample of a worker structure
βββ bin # contains the compiled wasm binary + wasm_exec.js
β βββ app.wasm
β βββ wasm_exec.js
βββ main.ts # main worker's entrypoint
βββ migrations # d1 migrations
β βββ 0001_add_aimple_table.sql
βββ package.json
βββ pkg # folder for your Go's code
β βββ main.go
βββ tsconfig.json
βββ types # default typescript types
β βββ global.d.ts
β βββ go.d.ts
β βββ wasm.d.ts
β βββ worker-configuration.d.ts
βββ vite.config.ts
βββ wrangler.toml Worker entrypoint
The main.ts is the entrypoint, declared in the wrangler.toml, and its where the wasm binary
will be loaded and used
Check worker main.ts for a detailed example
Handlers
Use all available Cloudflare Wokers handlers, plus other services like KV, D1, R2, Durable Objects, Containers, Sockets, Cache API, Queue producer and more
Each handler implemented in Go will make a specific callback available in the JavaScriptβs global scope
| Go implementation | JS callback |
|---|---|
fetch.ServeNonBlock() | cf.fetch() |
cron.ScheduleTaskNonBlock() | cf.scheduled() |
queues.ConsumeNonBlock() | cf.queue() |
tail.ConsumeNonBlock() | cf.tail() |
email.ConsumeNonBlock() | cf.email() |
Below are codes snippets showing how to implement each handler in Go
RPC stubs
The snippets below shows how to create .echo() and .echoStream() stub in a worker. These functions only accepts []UInt8Array and returns []UInt8Array, and in order to send or receive data,
you can usenew TextEncoder().encode() and new TextDecoder().decode() respectively.
let dataArray = await worker.echo(new TextEncoder().encode("my data"))
for (let i = 0; i < dataArray.length; i++) {
console.log(new TextDecoder().decode(dataArray[i]))
} //go:build js && wasm
package main
import "codeberg.org/darckfast/workers-go/platform/cloudflare/rpc"
func main() {
/*
* RPCStub must be called to instantiate the RPC function, and make
* globalThis.cf.<stub-name>() defined on JS global scope
*/
rpc.RPCStub("echo", func(c context.Context, args [][]byte) [][]byte {
// your logic
return [][]byte{}
})
/*
* RPCStubStream works similar to RPCStub, using ReadableStream and Writers
* to handle better large payloads, but its slower
*/
rpc.RPCStubStream("echoStream", func(c context.Context, w http.ResponseWriter, body io.ReadCloser, args [][]byte) {
// your logic
})
<-make(chan struct{}) // required
} export default class extends WorkerEntrypoint {
constructor(ctx, env) {
super(ctx, env);
// Required to init tne `env` and `ctx` variables
// and populate this class's prototype with RPC stubs
globalThis.workerapp = this;
init();
}
} fetch()
You can use any router on ServeNonBlock(), and for simple services and operations,
itβs recommend sticking to either net/http or httprouter. As for why check the binary size comparison for more details
//go:build js && wasm
package main
import "codeberg.org/darckfast/workers-go/platform/cloudflare/fetch"
func main() {
http.HandleFunc("/hello", func (w http.ResponseWriter, req *http.Request) {
//...
})
fetch.ServeNonBlock(nil)// if nil is given, http.DefaultServeMux is used.
<-make(chan struct{}) // required
} async function fetch(req: Request, env: Env, ctx: ExecutionContext) {
init();
return await cf.fetch(req, env, ctx);
}
export default {
fetch,
} satisfies ExportedHandler<Env>; scheduled()
//go:build js && wasm
package main
import "codeberg.org/darckfast/workers-go/platform/cloudflare/scheduled"
func main() {
cron.ScheduleTaskNonBlock(func(ctx context.Context, event *cron.CronEvent) error {
// ... my scheduled task
return nil
})
<-make(chan struct{})// required
} async function scheduled(
message: ForwardableEmailMessage,
env: Env,
ctx: ExecutionContext,
) {
init();
return await cf.scheduled(message, env, ctx);
}
export default {
scheduled,
} satisfies ExportedHandler<Env>; queue()
//go:build js && wasm
package main
import "codeberg.org/darckfast/workers-go/platform/cloudflare/queues"
func main() {
queues.ConsumeNonBlock(func(ctx context.Context, batch *queues.MessageBatch) error {
for _, msg := range batch.Messages {
// ... process the message
msg.Ack()
}
return nil
})
<-make(chan struct{})// required
} async function queue(batch: MessageBatch, env: Env, ctx: ExecutionContext) {
init();
return await cf.queue(batch, env, ctx);
}
export default {
queue,
} satisfies ExportedHandler<Env>; tail()
//go:build js && wasm
package main
import "codeberg.org/darckfast/workers-go/platform/cloudflare/tail"
func main() {
tail.ConsumeNonBlock(func(ctx context.Context, f *[]tail.TailItem) error {
// ... process tail trace events
return nil
})
<-make(chan struct{})// required
} async function tail(events: TraceItem[], env: Env, ctx: ExecutionContext) {
init();
return await cf.tail(events, env, ctx);
}
export default {
tail,
} satisfies ExportedHandler<Env>; email()
//go:build js && wasm
package main
import "codeberg.org/darckfast/workers-go/platform/cloudflare/email"
func main() {
email.ConsumeNonBlock(func(ctx context.Context, f *email.ForwardableEmailMessage) error {
// ... process the email
return nil
})
<-make(chan struct{})// required
} async function email(
message: ForwardableEmailMessage,
env: Env,
ctx: ExecutionContext,
) {
init();
return await cf.email(message, env, ctx);
}
export default {
email,
} satisfies ExportedHandler<Env>; Envs
All Cloudflare Workerβs envs are copied into Golang os.Environ(), making them available at runtime through os.Getenv(). Only string typed values are copied
PS: Envs are only loaded after the cf handler is called, meaning os.Getenv() will return empty if called inside the main() or init() block
KV
//go:build js && wasm
package main
import (
"codeberg.org/darckfast/workers-go/platform/cloudflare/kv"
)
func main() {
// NewNamespace is the first step to interact with KV
// it gets the KV binding from the js runtime
kvStore, _ := kv.NewNamespace("TEST_NAMESPACE")
keyList, _ := kvStore.List(nil)
// Get always expect a list of keys
// and returns a map[string]string using the "text" option
values, _ := kvStore.Get([]string{"key1", "key2"}, 0)
valuesWithMetadata, _ := kvStore.GetWithMetadata(key, 0)
// this uses the "stream" option
// and returns a ReadCloser
valueAsReader, _ := kvStore.GetAsReader(key, 0)
kvStore.Delete(key)
// All values with be stringified using
// before being saved
kvStore.Put("key", "value as string", &kv.PutOptions{
Metadata: map[string]any{
"ver": "0.0.1",
},
})
// uses a readablestream on put
kvStore.PutReader("key", /* ReadCloser */ , nil)
} D1
//go:build js && wasm
package main
import (
"codeberg.org/darckfast/workers-go/platform/cloudflare/v2/d1"
)
func main() {
db, _ := d1.GetDB("DB_BINDING_NAME")
// also supports First() and Raw()
result, err := db.Prepare("SELECT id, data, created_at, updated_at FROM Testing WHERE id = ?").
Bind(1).
Run()
stmt1 := db.Prepare("INSERT INTO Testing (data) VALUES (?)").Bind("entry 1")
stmt2 := db.Prepare("INSERT INTO Testing (data) VALUES (?)").Bind("entry 2")
batchResult, err := db.Batch([]d1.D1PreparedStatment{*stmt1, *stmt2})
execResult, err := db.Exec("UPDATE Testing SET data = null WHERE id = 1")
session := db.WithSession("MY_SESSION")
} NOTE: Itβs used JSON to transfer data between the JavaScript runtime and Golang, meaning BLOB are not supported
Cache API
//go:build js && wasm
package main
import (
"codeberg.org/darckfast/workers-go/platform/cloudflare/cache"
)
func main() {
c := cache.New()
res, _ := c.Match(r, nil)
if res == nil {
// the response must contain a http.header cache control
_ = c.Put(r, res)
}
}
R2
//go:build js && wasm
package main
import (
"codeberg.org/darckfast/workers-go/platform/cloudflare/r2"
)
func main() {
bucket, _ := r2.NewBucket("R2_BINDING_NAME")
// Put only accepts ReadCloser, you can use the io.NopCloser(bytes.NewReader(data))
// to create a nop reader
result, err := bucket.Put("count", reader, 1024, nil)
data, err := bucket.Get("count")
list, err := bucket.List()
err := bucket.Delete("key")
head, err := bucket.Head("count")
}
Durable objects
//go:build js && wasm
package main
import (
"codeberg.org/darckfast/workers-go/platform/cloudflare/durableobjects"
)
func main() {
n, _ := durableobjects.NewDurableObjectNamespace("DURABLE_OBJECT_BINDING")
objId := n.IdFromName("my-id")
stub, _ := n.Get(objId)
result, err := stub.Call("sayHello")
} import { DurableObject } from "cloudflare:workers";
export class MyDurableObject extends DurableObject<Env> {
constructor(ctx: DurableObjectState, env: Env) {
// Required, as we're extending the base class.
super(ctx, env);
}
// This is what will be called within Go
async sayHello(): Promise<string> {
const result = this.ctx.storage.sql
.exec("SELECT 'Hello, World!' as greeting")
.one();
return result.greeting.toString();
}
} NOTE: At the moment, only durable objectβs stubs can be called within Go
To do a fetch request to an durable object, check Service binding, as it works for both service binding and durable objects
Containers
//go:build js && wasm
package main
import (
"codeberg.org/darckfast/workers-go/platform/cloudflare/durableobjects"
)
func main() {
c, err := durableobjects.GetContainer("GO_CONTAINER", "test")
rs, _ := c.ContainerFetch(r)
} NOTE: Containers on Cloudflare still in Beta, and their API still not solidified, therefore only the
containerFetchhas been implemented so far
Service binding
//go:build js && wasm
package main
import (
"codeberg.org/darckfast/workers-go/platform/cloudflare/fetch"
)
func main() {
c := fetch.Client{
Timeout: 5 * time.Second,
CF: &RequestInitCF{
// cf attributes
}
}
c.WithBinding("SERVICE_BINDING") // the fetch will be made using this service binding
r, _ := http.NewRequest("GET", "https://google.com", nil)
rs, err := c.Do(r)
} Building and deploying
To build locally, you can run
vite build This will compile the wasm binary, and flat the JS dependencies into a single file, the out dir is dist or ./worker/dist
To deploy, run
wrangler build The wrangler CLI will auto detect, build the project and compress the final gzip file
If this is your first Cloudflare Worker, check their documentation to setup your first worker
To decrease our binary size, we do small tweaks to remove the debug information from the binary by passing some flags to the build command
GOOS=js GOARCH=wasm go build -ldflags='-s -w' -o ./bin/app.wasm ./pkg/main.go Use wrangler to deploy
wrangler deploy β οΈ Caveats
C Binding
If you use any library or package that depends on or use any C bindings, your worker will either not compile or panic at runtime. See for more details golang/go#55351
Some examples
Not all WASM package will work within as specific runtime, for example https://github.com/kleisauke/wasm-vips is a
WASM wrapper around lib https://github.com/libvips/libvips, a image processing library made in C, wasm-vips works within NodeJS, Done and Browser runtime
but it tries to access Services Workers, which is unavailable in some edge runtimes like workerd (Cloudflare)
Errors
Although we can wrap JavaScript errors in Go, at the moment there is no source maps available in wasm, meaning we can get errors messages, but not a useful stack trace
Build constraint
For gopls to show syscall/js methodβs signature and auto complete, either export GOOS=js && export GOARCH=wasm or add the comment //go:build js && wasm at the top of your Go files
Binary size
Compiling the below sample of code, using each respective router
package main
import (
"encoding/json"
"net/http"
)
func main() {
mux := http.NewServeMux()
mux.HandleFunc("POST /echo", func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("content-type", "application/json")
var payload map[string]any
defer r.Body.Close()
json.NewDecoder(r.Body).Decode(&payload)
payload["received"] = true
json.NewEncoder(w).Encode(payload)
})
http.ListenAndServe(":1234", mux)
} The binary was compiled using the following command, on Go 1.24
GOOS=js GOARCH=wasm go build -o routername | Router | Size |
|---|---|
| httprouter | 9.96Mb |
| net/http | 9.98Mb |
| gorilla | 10.45Mb |
| chi | 10.46Mb |
| echo | 11.01Mb |
| fiber | 15.60Mb |
This is issue is also relevant for this golang/go#6853
Benchmarks
This was executed on Intel(R) Core(TM) i5-8400 CPU @ 2.80GHz with 16GB, over a 1 GB network, sending a 8892 bytes JSON
The base-* benchmark are a pure implementation of the decoding and encoding process
β main.bench.ts > fetch API 605660ms
name hz min max mean p75 p99 p995 p999 rme samples
Β· hono 245.16 2.3600 51.6503 4.0790 3.5229 28.0297 35.1208 43.1204 Β±1.62% 14710
Β· bun 210.98 2.6192 52.5068 4.7398 4.6830 28.0895 34.9992 42.5008 Β±1.58% 12660
Β· deno 199.15 3.0239 64.7448 5.0214 4.1712 30.7462 37.1329 44.4168 Β±1.64% 11950
Β· uws 257.32 2.3944 49.6653 3.8862 3.2256 27.7476 35.0269 44.2044 Β±1.66% 15440
Β· worker 109.79 6.5274 68.6385 9.1086 7.5164 45.2377 50.0607 54.5459 Β±1.78% 6588
Β· base-hono 620.99 0.7385 14.3146 1.6103 1.8478 4.4886 5.4324 7.6507 Β±0.47% 37260
Β· base-bun 643.29 0.6318 16.8005 1.5545 1.6276 4.1087 4.9794 7.2247 Β±0.42% 38598
Β· base-deno 514.00 0.7979 20.2011 1.9455 2.1224 4.9305 6.3600 11.7732 Β±0.46% 30840
Β· base-uws 598.07 0.6438 16.4845 1.6721 1.7938 4.3736 5.1509 7.3597 Β±0.42% 35885
Β· base-go 377.88 0.8424 44.0451 2.6463 2.7044 5.5928 6.6506 10.2452 Β±0.39% 22673
base-bun - main.bench.ts > fetch API
1.04x faster than base-hono
1.08x faster than base-uws
1.25x faster than base-deno
1.70x faster than base-go
2.50x faster than uws
2.62x faster than hono
3.05x faster than bun
3.23x faster than deno
5.86x faster than worker