Skip to content

Latest commit

 

History

17 Commits

Folders and files

NameName
Last commit message
Last commit date
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

Voker

A minimal, modern AWS Lambda runtime for Go that focuses on performance, simplicity, and type safety.

Overview

Voker is a simplified alternative to aws-lambda-go that maintains full compatibility with the AWS Lambda Runtime API. It uses Go generics to provide compile-time type safety with a clean, single-function-signature design. It supports structured logging with slog and proper log levels for errors.

Optional subpackages (each imported only when you use it):

  • vokerhttp — serve Lambda HTTP events (Function URLs, API Gateway, ALB) with a standard http.Handler, including response streaming
  • vokercfn — type-safe CloudFormation custom resources
  • vokerslog — a slog.Handler tuned for AWS Lambda advanced logging controls

Installation

go get github.com/hotsock/voker

Voker requires Go 1.27 or later and processes handler input and output with the encoding/json/v2 standard library package. Compared to the classic encoding/json semantics, this means:

  • Unmarshaling matches JSON object names to struct fields case-sensitively.
  • Payloads containing duplicate object names or invalid UTF-8 are rejected with a Runtime.UnmarshalError instead of being silently accepted.
  • Nil slices marshal as [] and nil maps as {} (instead of null). Types that implement their own marshaling (json.MarshalerTo, or the classic json.Marshaler) are unaffected and keep full control of their output, including emitting null.

Usage

Basic Handler

package main

import (
    "context"
    "github.com/hotsock/voker"
)

type MyEvent struct {
    Name string `json:"name"`
}

type MyResponse struct {
    Message string `json:"message"`
}

func handler(ctx context.Context, event MyEvent) (MyResponse, error) {
    return MyResponse{
        Message: "Hello, " + event.Name,
    }, nil
}

func main() {
    voker.Start(handler)
}

Accessing Lambda Context

func handler(ctx context.Context, event MyEvent) (MyResponse, error) {
    lc, ok := voker.FromContext(ctx)
    if ok {
        log.Printf("Request ID: %s", lc.AwsRequestID)
        log.Printf("Function ARN: %s", lc.InvokedFunctionArn)
        log.Printf("X-Ray trace ID: %s", lc.TraceID)
    }

    deadline, _ := ctx.Deadline()
    log.Printf("Function deadline: %s", deadline)

    return MyResponse{Message: "success"}, nil
}

Error Handling

func handler(ctx context.Context, event MyEvent) (MyResponse, error) {
    if event.Name == "" {
        return MyResponse{}, fmt.Errorf("name is required")
    }

    return MyResponse{Message: "Hello, " + event.Name}, nil
}

Return a *voker.ErrorResponse when the function error needs a stable, application-specific error type. Voker preserves its errorType, errorMessage, and optional stack trace in the Runtime API error payload:

return MyResponse{}, &voker.ErrorResponse{
    Type:    "Application.ValidationError",
    Message: "name is required",
}

Simple Event Types

Any JSON-deserializable type works as the event. For HTTP event sources (Function URLs, API Gateway, ALB), use the vokerhttp adapters instead of decoding the event yourself.

// Handle SQS events
type SQSEvent struct {
    Records []SQSRecord `json:"Records"`
}

type SQSRecord struct {
    Body string `json:"body"`
}

func handler(ctx context.Context, event SQSEvent) (string, error) {
    for _, record := range event.Records {
        log.Printf("Processing: %s", record.Body)
    }
    return "ok", nil
}

Handler Signature

Voker supports only one handler signature:

func(context.Context, TIn) (TOut, error)

Where:

  • context.Context is required (provides deadline, cancellation, Lambda metadata)
  • TIn is your input type (must be JSON-deserializable)
  • TOut is your output type (JSON-serializable, or an io.Reader for streaming)
  • error is required for error handling

Lambda Managed Instances

Voker automatically supports Lambda Managed Instances. At startup it reads AWS_LAMBDA_MAX_CONCURRENCY and starts that many Runtime API workers; when the variable is missing or invalid it preserves standard Lambda's serial behavior. voker.MaxConcurrency() returns the effective worker count.

Managed Instances can call the same handler concurrently within one process. Handlers must protect mutable globals and shared caches, coordinate access to shared /tmp paths, and use clients that permit concurrent calls. Each handler receives an independent context, deadline, request ID, and LambdaContext.TraceID. Handlers use LambdaContext.TraceID in both standard Lambda and Managed Instances, keeping trace propagation invocation-scoped.

Lambda does not forcibly stop timed-out Managed Instances handlers. Watch ctx.Done() and leave enough deadline margin to stop the next unit of work before performing further side effects. Managed Instances also require JSON logging; Voker's default logger honors Lambda's AWS_LAMBDA_LOG_FORMAT=JSON setting. A logger supplied through WithLogger must likewise emit JSON.

Internal extensions are rejected during Managed Instances initialization because the Extensions API does not support invocation events in that compute mode. Use the Telemetry API's platform events when invocation reporting is needed.

See examples/managed-instances for a self-contained SAM stack and live concurrency probe.

Raw payloads

Declare TIn as jsontext.Value (or its alias json.RawMessage) to receive the invocation payload verbatim. Voker skips unmarshaling — and JSON validation — and hands the raw bytes straight to your handler, which is then responsible for decoding them:

import (
    "encoding/json/jsontext"
    json "encoding/json/v2"
)

func handler(ctx context.Context, payload jsontext.Value) (Response, error) {
    // payload is the raw request bytes, aliased (not copied) from the
    // invocation buffer. Decode it yourself however you like.
    var event MyEvent
    if err := json.Unmarshal(payload, &event); err != nil {
        return Response{}, err
    }
    // ...
}

This is useful for handlers that work with large payloads and want to measure or control their own decoding rather than paying for an unmarshal up front. Because validation is skipped, the handler also sees empty or malformed payloads as-is instead of voker rejecting them.

net/http handlers (vokerhttp)

The vokerhttp subpackage serves Lambda HTTP events with a standard http.Handler. An adapter converts the event source's payload into an *http.Request, runs your handler, and converts the response back — including automatic base64 encoding for binary or compressed (Content-Encoding) bodies and Content-Type sniffing to match net/http server behavior.

import (
    "net/http"

    "github.com/hotsock/voker/vokerhttp"
)

func main() {
    mux := http.NewServeMux()
    mux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
        w.Write([]byte("Hello from Lambda!"))
    })

    vokerhttp.Start(mux, &vokerhttp.FunctionURL{})
}

Built-in adapters:

Adapter Event source
vokerhttp.FunctionURL{} Lambda Function URL (payload format 2.0)
vokerhttp.APIGatewayV2{} API Gateway v2 HTTP API (payload format 2.0)
vokerhttp.APIGatewayV1{} API Gateway v1 REST API Lambda proxy
vokerhttp.ALB{} Application Load Balancer Lambda target group

For ALB target groups with the lambda.multi_value_headers.enabled attribute, set &vokerhttp.ALB{MultiValueHeaders: true}. Without multi-value headers, ALB responses cannot carry repeated headers, so only the last Set-Cookie survives.

The original Lambda event remains available from the request context:

func handler(w http.ResponseWriter, r *http.Request) {
    event, ok := vokerhttp.EventFromContext[vokerhttp.FunctionURLRequest](r.Context())
    // event.RequestContext.HTTP.SourceIP, event.RequestContext.Authorizer, ...
}

Custom event sources can implement the vokerhttp.Adapter interface.

Internal extensions

Register an internal extension with voker.WithInternalExtension to run code in the handler process on Lambda lifecycle events:

voker.Start(handler, voker.WithInternalExtension(voker.InternalExtension{
    Name: "my-extension",
    OnInit: func() error {
        // Runs during initialization. An error or panic fails init.
        return nil
    },
    OnInvoke: func(ctx context.Context, event voker.ExtensionEventPayload) {
        // Runs for each INVOKE event; ctx carries the event deadline.
    },
    OnSIGTERM: func(ctx context.Context) {
        // Lambda sends SIGTERM ~600ms before SIGKILL when extensions are
        // registered; ctx has a 500ms deadline for cleanup.
    },
}))

INVOKE is the only Extensions API event available to internal extensions; Lambda delivers SHUTDOWN only to external extensions, so voker exposes shutdown via OnSIGTERM instead. Internal extensions are not supported on Lambda Managed Instances. See examples/extension for a complete example.

Response streaming

Return an io.Reader to stream bytes through the Lambda Runtime API instead of JSON-encoding the response. If the returned value also implements ContentType() string, voker propagates that content type; otherwise it uses application/octet-stream. If it also implements io.Closer, voker closes it after the Runtime API finishes consuming the response, including when the stream fails.

func handler(ctx context.Context, event MyEvent) (io.Reader, error) {
    reader, writer := io.Pipe()
    go func() {
        defer writer.Close()
        _, _ = io.WriteString(writer, "first\n")
        time.Sleep(time.Second)
        _, _ = io.WriteString(writer, "second\n")
    }()
    return reader, nil
}

For net/http handlers, use vokerhttp.StartStreaming. Its response writer implements http.Flusher and preserves HTTP status, headers, repeated headers, and cookies in Lambda's streaming metadata prelude.

vokerhttp.StartStreaming(mux, &vokerhttp.FunctionURL{})
vokerhttp.StartStreaming(mux, &vokerhttp.APIGatewayV1{})
Ingress Buffered Streaming
Lambda Function URL Yes Yes (RESPONSE_STREAM)
API Gateway v1 REST API Yes Yes (ResponseTransferMode: STREAM)
API Gateway v2 HTTP API Yes No
Application Load Balancer Yes No

Streaming REST integrations must also use API Gateway's response-streaming-invocations integration URI. See the complete deployable matrix in examples/aws-ingress-probe. For live Runtime API regression coverage—including buffered/streaming mode selection, stream errors and cleanup, custom error payloads, and initialization failure reporting—see examples/runtime-probe.

CloudFormation custom resources

Use vokercfn.Start to run a type-safe CloudFormation custom resource. It handles the presigned response URL protocol, reports handler errors as CloudFormation failures, and supplies safe physical resource ID fallbacks.

package main

import (
    "context"
    "fmt"

    "github.com/hotsock/voker/vokercfn"
)

type Properties struct {
    Name string `json:"Name"`
}

type Data struct {
    ARN string `json:"Arn"`
}

func handler(ctx context.Context, event vokercfn.Event[Properties]) (vokercfn.Result[Data], error) {
    switch event.RequestType {
    case vokercfn.RequestCreate:
        return vokercfn.Result[Data]{
            PhysicalResourceID: "thing-123",
            Data: Data{ARN: "arn:example:thing-123"},
        }, nil
    case vokercfn.RequestUpdate, vokercfn.RequestDelete:
        return vokercfn.Result[Data]{
            PhysicalResourceID: event.PhysicalResourceID,
        }, nil
    default:
        return vokercfn.Result[Data]{}, fmt.Errorf("unknown request type %q", event.RequestType)
    }
}

func main() {
    vokercfn.Start(handler)
}

Result.Data values are available through Fn::GetAtt. Set Result.NoEcho to mask them in CloudFormation responses. Returning a different physical resource ID from an update tells CloudFormation the resource was replaced. Responses that cannot be encoded or exceed CloudFormation's 4096-byte limit are converted to compact FAILED responses so stack operations do not wait for a timeout. See examples/cloudformation for a deployable example validated against Create, Update, and Delete events in AWS.

Lambda Context

The LambdaContext type contains metadata about the invocation:

type LambdaContext struct {
    AwsRequestID       string          // Unique request ID
    InvokedFunctionArn string          // ARN of the invoked function
    TraceID            string          // Invocation-scoped X-Ray trace header
    TenantID           string          // Tenant ID (tenant isolation mode)
    Identity           CognitoIdentity // Cognito identity (if present)
    ClientContext      ClientContext   // Client context (if present)
}

Access it using voker.FromContext(ctx). TenantID carries the value of the Lambda-Runtime-Aws-Tenant-Id header for functions using Lambda tenant isolation mode and is empty otherwise.

Logging

Voker logs with the standard library's log/slog. By default it creates a logger from AWS_LAMBDA_LOG_FORMAT and AWS_LAMBDA_LOG_LEVEL using slog's built-in JSON or text handlers. Provide your own with voker.WithLogger.

For ideal Lambda logging behavior, the optional vokerslog subpackage offers a slog.Handler tuned for AWS Lambda advanced logging controls. It is opt-in (import it only when you want it) and adds no extra dependency — the request ID is read from voker.FromContext rather than aws-lambda-go.

import (
    "log/slog"
    "os"

    "github.com/hotsock/voker"
    "github.com/hotsock/voker/vokerslog"
)

func main() {
    logger := slog.New(vokerslog.NewHandler(os.Stdout))
    slog.SetDefault(logger)

    voker.Start(handler, voker.WithLogger(logger))
}

NewHandler auto-configures format (JSON or text) and level from AWS_LAMBDA_LOG_FORMAT and AWS_LAMBDA_LOG_LEVEL, and enriches every record with Lambda metadata (function name, version, and the request ID from the invocation context). Options override the environment values:

Option Description
WithJSON() Output in JSON format
WithText() Output in text format
WithLevel(slog.Leveler) Set the minimum log level
WithSource() Include source file, function, and line number
WithType(string) Set the type field (default: "app.log")
WithoutTime() Omit the timestamp

In addition to the standard slog levels, the handler maps Lambda's TRACE (slog.LevelDebug - 4) and FATAL (slog.LevelError + 4) levels. A JSON record looks like:

{
  "level": "INFO",
  "msg": "Lambda Invoked",
  "record": {
    "functionName": "my-func",
    "version": "$LATEST",
    "requestId": "abc-123"
  },
  "type": "app.log"
}

The type field

The type + record envelope mirrors the shape of AWS Lambda Telemetry API events, so type works best as a low-cardinality category for filtering and routing logs (e.g. filter type = "app.request" in CloudWatch Logs Insights) rather than for per-request data. AWS reserves function, extension, and platform.*; a dotted app.<category> namespace avoids collisions and matches AWS's style — for example app.log (the default), app.request, or app.audit.

The default comes from WithType (or "app.log"). A single record can override it with a top-level string attribute keyed vokerslog.TypeKey ("type"); the attribute sets the record's type instead of being emitted normally. Set it via With to tag every record from a logger, or per call:

// All records from this logger are tagged "app.request".
requests := slog.New(handler).With(vokerslog.TypeKey, "app.request")

// Or override a single record (this wins over any With value):
slog.InfoContext(ctx, "audit event", vokerslog.TypeKey, "app.audit")

Setting the type to "" omits the field for that record. An attribute keyed type inside a group is left untouched and emitted normally.

Error Handling

Voker automatically handles errors and panics:

Regular Errors

func handler(ctx context.Context, event MyEvent) (MyResponse, error) {
    return MyResponse{}, errors.New("something went wrong")
}
// Returns: {"errorMessage":"something went wrong","errorType":"HandlerError"}

The reported errorType is the Go type name of the returned error, so a custom error type surfaces under its own name:

type PaymentDeclinedError struct{ /* ... */ }

func (e *PaymentDeclinedError) Error() string { return "payment declined" }

// Returns: {"errorMessage":"payment declined","errorType":"PaymentDeclinedError"}

Errors with no meaningful type name — errors.New, fmt.Errorf, errors.Join, and anonymous types — report the stable name HandlerError. A *voker.ErrorResponse anywhere in the error chain (including wrapped with fmt.Errorf("...: %w", err)) is preserved verbatim, so use it when you need full control of the reported type. Voker also reports the error type in the Lambda-Runtime-Function-Error-Type header on Runtime API error posts.

Panics

func handler(ctx context.Context, event MyEvent) (MyResponse, error) {
	a := []string{"hey"}
	fmt.Println(a[1]) // panic

    // ...
}

The panic is reported as the invocation's error response with the panic type, message, and a stack trace, and then the process exits (matching official AWS runtime behavior, so the execution environment is not reused after a panic):

{
  "errorType": "Runtime.Panic.boundsError",
  "errorMessage": "runtime error: index out of range [1] with length 1",
  "stackTrace": [
    {
      "path": "/usr/local/go/src/runtime/panic.go",
      "line": 859,
      "label": "gopanic"
    },
    {
      "path": "/usr/local/go/src/runtime/panic.go",
      "line": 236,
      "label": "panicBounds64"
    },
    {
      "path": "/usr/local/go/src/runtime/asm_arm64.s",
      "line": 1357,
      "label": "panicBounds"
    },
    {
      "path": "Code/voker/examples/error/main.go",
      "line": 27,
      "label": "handler"
    },
    {
      "path": "Code/voker/voker.go",
      "line": 347,
      "label": "callHandler[...]"
    },
    {
      "path": "Code/voker/voker.go",
      "line": 286,
      "label": "handleInvocationContext[...]"
    },
    {
      "path": "Code/voker/voker.go",
      "line": 113,
      "label": "Start[...].func1"
    },
    {
      "path": "Code/voker/voker.go",
      "line": 222,
      "label": "runInvocationWorkers.func1"
    },
    {
      "path": "/usr/local/go/src/sync/waitgroup.go",
      "line": 258,
      "label": "(*WaitGroup).Go.func1"
    },
    {
      "path": "/usr/local/go/src/runtime/asm_arm64.s",
      "line": 1039,
      "label": "goexit"
    }
  ]
}

Testing Your Handler

func TestHandler(t *testing.T) {
    event := MyEvent{Name: "World"}
    response, err := handler(context.Background(), event)

    assert.NoError(t, err)
    assert.Equal(t, "Hello, World", response.Message)
}

No mocking required - your handler is just a function!

Building and Deploying

Build for Lambda

GOOS=linux GOARCH=arm64 go build -o bootstrap main.go
zip function.zip bootstrap

Using with AWS SAM

AWSTemplateFormatVersion: "2010-09-09"
Transform: AWS::Serverless-2016-10-31

Resources:
  MyFunction:
    Type: AWS::Serverless::Function
    Properties:
      CodeUri: .
      Handler: bootstrap
      Runtime: provided.al2023
      Architectures: [arm64]

Using with AWS CDK

lambda.NewFunction(stack, jsii.String("MyFunction"), &lambda.FunctionProps{
    Runtime: lambda.Runtime_PROVIDED_AL2023(),
    Handler: jsii.String("bootstrap"),
    Code:    lambda.Code_FromAsset(jsii.String("./function.zip"), nil),
    Architecture: lambda.Architecture_ARM_64(),
})

Migration from aws-lambda-go

Before (aws-lambda-go)

import "github.com/aws/aws-lambda-go/lambda"

func handler(ctx context.Context, event MyEvent) (MyResponse, error) {
    // ...
}

func main() {
    lambda.StartHandlerFunc(handler)
}

After (Voker)

import "github.com/hotsock/voker"

func handler(ctx context.Context, event MyEvent) (MyResponse, error) {
    // ...
}

func main() {
    voker.Start(handler)
}

That's it! If you were using the standard func(context.Context, TIn) (TOut, error) signature, it's a drop-in replacement.

If you were using lambdacontext.LambdaContext (most likely lambdacontext.FromContext(ctx) in your code), switch those calls to voker.FromContext(ctx).

If you were using aws-lambda-go/events types for HTTP event sources or net/http adapters like aws-lambda-go-api-proxy, see vokerhttp.

License

See LICENSE.

About

AWS Lambda runtime for Go applications

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages