Pushduck
Pushduck// S3 uploads for any framework

Go

Use pushduck from net/http, chi, gin, echo or gorilla — with the same JavaScript client

The Go package is a server. Your frontend keeps using the JavaScript client unchanged — it only speaks the wire protocol, so it neither knows nor cares which language answered.

go get github.com/abhay-ramesh/pushduck-go

Defining routes

upload.go
package main

import (
    "net/http"
    "os"

    "github.com/abhay-ramesh/pushduck-go/pushduck"
)

config := pushduck.Config{
    Bucket:          os.Getenv("AWS_S3_BUCKET"),
    Region:          os.Getenv("AWS_REGION"),
    AccessKeyID:     os.Getenv("AWS_ACCESS_KEY_ID"),
    SecretAccessKey: os.Getenv("AWS_SECRET_ACCESS_KEY"),
    SessionToken:    os.Getenv("AWS_SESSION_TOKEN"), // ECS, EKS IRSA, OIDC
}

router := pushduck.NewRouter(config, pushduck.Routes{
    "imageUpload": pushduck.Image(
        pushduck.MaxSize("5MB"),
        pushduck.WithMetadata(requireUser),
        pushduck.OnComplete(saveToDatabase),
    ),
    "documentUpload": pushduck.File(
        pushduck.MaxSize("50MB"),
        pushduck.AllowTypes("application/pdf"),
    ),
})

A metadata hook authenticates the request and returns the upload's metadata:

func requireUser(r *http.Request, file pushduck.FileMeta) (map[string]any, error) {
    user, err := authenticate(r)
    if err != nil {
        return nil, pushduck.NewError("UNAUTHORIZED", "Sign in to upload")
    }
    return map[string]any{"userId": user.ID}, nil
}

Return a *pushduck.Error to control the status. Any other error becomes a 500 with its message withheld, because a driver error routinely carries hostnames, credentials and query fragments that nobody vetted for disclosure.

Mounting

http.Handler is the interface net/http, chi and gorilla already speak, and gin and echo wrap it in a line. Unlike the JavaScript package — where every framework needs its own adapter — the router is the integration.

http.Handle("/api/upload", router)
log.Fatal(http.ListenAndServe(":8080", nil))
r := chi.NewRouter()
r.Handle("/api/upload", router)
r := gin.Default()
r.Any("/api/upload", gin.WrapH(router))
e := echo.New()
e.Any("/api/upload", echo.WrapHandler(router))
r := mux.NewRouter()
r.Handle("/api/upload", router)

The http.Handler mountings are executed by pushduck/mounting_test.go, including behind wrapping middleware and mounted at four different paths — because a handler that consulted its own mount path would break exactly one of those and pass the rest.

gin.WrapH and echo.WrapHandler are those frameworks' own documented adapters for an http.Handler; adding two web frameworks as test dependencies to a package that has none would cost more than it proves.

The frontend is unchanged

app/upload.ts
import { createUploadClient } from "pushduck/client";

const upload = createUploadClient({ endpoint: "/api/upload" });

Multipart, resume, progress and typed errors all work exactly as they do against the TypeScript server, because the client only speaks the protocol. This is verified end to end: cross-language.test.ts drives the real JavaScript client against this Go server through the full multipart handshake and reads the bytes back byte-identical.

What is supported

Presign, complete, introspectionyes
Multipart uploadsyes — init, sign, complete, abort, list
Resume after interruptionyes, verified against real storage
Completion tokensyes
Temporary credentials (STS)yes
S3-compatible providersyes — R2, MinIO, Spaces, path-style addressing
Resume across server restartsno server-side session store yet

Behaviour is pinned by the shared conformance suite, which this server passes in full — the same 23 cases the TypeScript and Python servers pass. SigV4 signatures are byte-identical across all three, so a client can presign against one server and complete against another.