workers-go banner

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 implementationJS 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 containerFetch has 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

PackageCompatible
https://github.com/anthonynsimon/bildβœ…
https://github.com/nfnt/resizeβœ…
https://github.com/bamiaux/rezβœ…
https://github.com/kolesa-team/go-webp❌
https://github.com/Kagami/go-avif❌
https://github.com/h2non/bimg❌
https://github.com/davidbyttow/govips❌
https://github.com/gographics/imagick❌

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
RouterSize
httprouter9.96Mb
net/http9.98Mb
gorilla10.45Mb
chi10.46Mb
echo11.01Mb
fiber15.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