Skip to content

Latest commit

 

History

13 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, including an optional vokerslog handler tuned for AWS Lambda.

Installation

go get github.com/hotsock/voker

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

// Handle API Gateway events
func handler(ctx context.Context, event map[string]any) (map[string]any, error) {
    return map[string]any{
        "statusCode": 200,
        "body":       "Hello from Lambda!",
    }, nil
}

// 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 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:

func handler(ctx context.Context, payload json.RawMessage) (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.

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

    // ...
}

Returns the following (and process exits after panic):

{
  "errorType": "Runtime.Panic.boundsError",
  "errorMessage": "runtime error: index out of range [1] with length 1",
  "stackTrace": [
    {
      "label": "gopanic",
      "line": 783,
      "path": "/usr/local/go/src/runtime/panic.go"
    },
    {
      "label": "goPanicIndex",
      "line": 115,
      "path": "/usr/local/go/src/runtime/panic.go"
    },
    {
      "label": "handler",
      "line": 30,
      "path": "/Users/me/Code/voker/examples/error/main.go"
    },
    {
      "label": "callHandler[...]",
      "line": 198,
      "path": "Code/voker/voker.go"
    },
    {
      "label": "handleInvocation[...]",
      "line": 170,
      "path": "Code/voker/voker.go"
    },
    {
      "label": "Start[...]",
      "line": 120,
      "path": "Code/voker/voker.go"
    },
    {
      "label": "main",
      "line": 21,
      "path": "/Users/me/Code/voker/examples/error/main.go"
    },
    {
      "label": "main",
      "line": 285,
      "path": "/usr/local/go/src/runtime/proc.go"
    },
    {
      "label": "goexit",
      "line": 1268,
      "path": "/usr/local/go/src/runtime/asm_arm64.s"
    }
  ]
}

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 to voker.FromContext(ctx)).

License

See LICENSE.

About

AWS Lambda runtime for Go applications

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages