jsonrpc

package
v1.10.0-alpha.3 Latest Latest
Warning

This package is not in the latest version of its module.

Go to latest
Published: Oct 1, 2026 License: MIT Imports: 20 Imported by: 0

README

JSON-RPC 2.0 in Loom

Loom provides first-class, type-safe support for JSON-RPC 2.0, enabling you to build robust RPC services with the same powerful DSL used for REST and gRPC. This implementation handles all protocol complexities while preserving Loom's design-first philosophy.

Table of Contents

Quick Start

Define a simple JSON-RPC calculator service:

// design/design.go
package design

import . "github.com/CaliLuke/loom/dsl"

var _ = API("calculator", func() {
    Title("Calculator Service")
    Description("A simple calculator exposed via JSON-RPC")
})

var _ = Service("calc", func() {
    Description("The calc service performs basic arithmetic")
    
    // Enable JSON-RPC for this service at /rpc endpoint
    JSONRPC(func() {
        POST("/rpc")
    })
    
    // Define an add method
    Method("add", func() {
        Description("Add two numbers")
        Payload(func() {
            Attribute("a", Float64, "First operand")
            Attribute("b", Float64, "Second operand")
            Required("a", "b")
        })
        Result(Float64)
        
        // Expose this method via JSON-RPC
        JSONRPC(func() {})
    })
    
    // Define a divide method with error handling
    Method("divide", func() {
        Description("Divide two numbers")
        Payload(func() {
            Field(1, "dividend", Float64, "The dividend")
            Field(2, "divisor", Float64, "The divisor")
            Required("dividend", "divisor")
        })
        Result(Float64)
        Error("division_by_zero")
        
        JSONRPC(func() {
            Response("division_by_zero", func() {
                Code(-32001) // Custom error code
            })
        })
    })
})

Generate the code:

loom gen calculator/design

Create non-overwriting response-contract scenarios after generation:

loom test-scaffold calculator/design

Generated JSON-RPC server packages publish stable success, error, and notification-suppression cases. Fill each scaffold callback with a request to the real generated handler. Return a jsonrpc.ResponseContractObservation so Loom can check the result envelope, error code and typed data, or absence of a notification response. Server-SSE contracts also check final-response behavior. WebSocket, client-streaming, and bidirectional completion shapes currently produce an explicit generation diagnostic.

Implement the service:

// calc.go
package calcapi

import (
    "context"
    calc "calculator/gen/calc"
)

type calcService struct{}

func NewCalc() calc.Service {
    return &calcService{}
}

func (s *calcService) Add(ctx context.Context, p *calc.AddPayload) (float64, error) {
    return p.A + p.B, nil
}

func (s *calcService) Divide(ctx context.Context, p *calc.DividePayload) (float64, error) {
    if p.Divisor == 0 {
        return 0, calc.MakeDivisionByZero("cannot divide by zero")
    }
    return p.Dividend / p.Divisor, nil
}

Core Concepts

Protocol Fundamentals

JSON-RPC 2.0 is a stateless, lightweight remote procedure call protocol that uses JSON for encoding. Key characteristics:

  1. Transport Agnostic: While commonly used over HTTP, the protocol itself doesn't specify transport
  2. Simple Message Format: All communication uses a consistent JSON structure
  3. Bidirectional: Supports both client-to-server and server-to-client communication
  4. Batch Support: Multiple calls can be sent in a single request

Message structure:

// Request
{
    "jsonrpc": "2.0",
    "method": "add",
    "params": {"a": 5, "b": 3},
    "id": 1
}

// Response
{
    "jsonrpc": "2.0",
    "result": 8,
    "id": 1
}

Every success response has a result member, over HTTP, as the final response of an SSE stream and over WebSocket. A method without a result answers with "result": null, and empty results such as "", [] and {} keep their value. An error response has an error member and no result member.

Single Endpoint Architecture

Unlike REST where each resource has its own URL, JSON-RPC services multiplex all methods through a single endpoint:

  • REST: /users (GET), /users/{id} (GET/PUT/DELETE), /products (GET/POST)
  • JSON-RPC: /rpc (all methods)

This design provides several benefits:

  1. Simplified Routing: No complex URL patterns to manage
  2. Protocol Consistency: All methods follow the same calling convention
  3. Connection Efficiency: WebSocket/SSE connections can handle multiple methods
  4. Easy Versioning: Version the entire API at once

The method field in the JSON-RPC payload determines which service method to invoke:

{"jsonrpc": "2.0", "method": "add", "params": {"a": 5, "b": 3}, "id": 1}
{"jsonrpc": "2.0", "method": "divide", "params": {"dividend": 10, "divisor": 2}, "id": 2}
Request vs Notification

JSON-RPC distinguishes between two types of messages based on the presence of an ID:

Requests (with ID) expect a response:

{"jsonrpc": "2.0", "method": "process", "params": {"data": "hello"}, "id": "req-123"}
// Server MUST send a response with matching ID

Notifications (without ID) are fire-and-forget:

{"jsonrpc": "2.0", "method": "log", "params": {"message": "user logged in"}}
// Server MUST NOT send a response

This behavior is determined at runtime by the client, not design time. The same method can be called as either a request or notification.

Defining Services

Service Configuration

Enable JSON-RPC at the service level to define the shared endpoint:

Service("myservice", func() {
    Description("A service exposed via JSON-RPC")
    
    // Define the JSON-RPC endpoint
    JSONRPC(func() {
        POST("/jsonrpc")  // For HTTP and SSE
        // OR
        GET("/ws")        // For WebSocket
    })
    
    // Define error mappings for all methods
    Error("unauthorized", func() {
        Description("Unauthorized access")
    })
    
    JSONRPC(func() {
        Response("unauthorized", func() {
            Code(-32000)  // Map to JSON-RPC error code
        })
    })
})
Method Configuration

Each method needs its own JSONRPC() block to be exposed:

Method("process", func() {
    Description("Process data")
    
    Payload(func() {
        Attribute("data", String, "Data to process")
        Attribute("priority", Int, "Processing priority")
        Required("data")
    })
    
    Result(func() {
        Attribute("output", String, "Processed output")
        Attribute("duration", Int, "Processing time in ms")
        Required("output", "duration")
    })
    
    // Enable JSON-RPC for this method
    JSONRPC(func() {
        // Method-specific error mappings (optional)
        Response("invalid_data", func() {
            Code(-32002)
        })
    })
})
ID Field Mapping

Control how JSON-RPC message IDs map to your payload and result types:

Method("track", func() {
    Payload(func() {
        ID("request_id", String, "Tracking ID")  // Maps to JSON-RPC request ID
        Attribute("action", String)
        Required("request_id", "action")
    })
    
    Result(func() {
        ID("request_id", String, "Tracking ID")  // Optional; if empty the
                                                   // response uses the request id
        Attribute("status", String)
        Required("request_id", "status")
    })
    
    JSONRPC(func() {})
})

The ID() function marks which field receives the JSON-RPC message ID. Rules:

  1. ID fields must be String type
  2. Result can only have an ID if Payload has one
  3. For non-streaming methods, the response id is the original request id. A result ID field does not override protocol correlation.
  4. Missing ID at runtime means the message is a notification
ID Semantics

How IDs behave across transports and shapes:

  • Design-time type

    • ID() marks the field that carries the JSON-RPC ID; it must be String in the design.
  • Runtime type

    • JSON-RPC allows string or number IDs. Loom accepts either on the wire and normalizes to string when assigning to your ID() fields.
  • HTTP (request/response)

    • Client
      • If the payload has an ID field and it is non-empty, the client sends it as id (request). If empty (or nil pointer), the client omits id (notification). The server sends no response to a notification, so the client returns the zero result and no error when the HTTP response has a 2xx status and an empty body. It returns an error for another status, a body that is not a JSON-RPC response, or a JSON-RPC error response, such as the Invalid Request error of a server that could not read the notification. A service error of a notification never reaches the client.
      • If the payload has no ID field, the client generates a string id and sends a request (never a notification).
    • Server
      • The response envelope id equals the original request id. The server does not inject that ID into your result struct or accept a result field as a correlation override.
  • SSE (server streaming)

    • Send(ctx, event): emits a JSON-RPC notification (no id).
    • SendAndClose(ctx, result): sends a JSON-RPC response only when the stream was opened by a request carrying an id. ID-less notifications and raw GET listeners close without a final frame; send every value that must reach those clients with Send.
  • WebSocket (streaming)

    • Server replies use the original request id automatically. Use SendNotification for server-initiated messages (no id).
    • Client generates a string id per request in bidirectional or recv-only patterns. When receiving, if your result has an ID field and it is empty, the client populates it from the envelope id for convenience.
  • When to use ID() in the DSL

    • Non-streaming: put ID() in the payload to receive request IDs in your handler; add it to the result only if you need to surface the ID in your result type.
    • Streaming (WebSocket bidirectional): include ID() in both streaming payload and result to correlate messages at the type level.
    • Notifications: omit ID() (no id is sent or expected).

Transport Options

HTTP: Request-Response

Standard synchronous RPC over HTTP. Best for:

  • Simple request-response patterns
  • Stateless operations
  • RESTful service migration
Service("api", func() {
    JSONRPC(func() {
        POST("/rpc")
    })
    
    Method("query", func() {
        Payload(func() {
            Attribute("sql", String)
            Required("sql")
        })
        Result(ArrayOf(map[string]any))
        JSONRPC(func() {})
    })
})

Client usage:

client := api.NewClient("http", "localhost:8080", http.DefaultClient,
    loomhttp.RequestEncoder, loomhttp.ResponseDecoder, false)

result, err := client.Query(ctx, &api.QueryPayload{SQL: "SELECT * FROM users"})

Wire format:

POST /rpc HTTP/1.1
Content-Type: application/json

{"jsonrpc":"2.0","method":"query","params":{"sql":"SELECT * FROM users"},"id":1}

How it works internally:

  • The generated server inspects the first byte of the body to route batch ([ starts a JSON array) vs single requests, then decodes a jsonrpc.RawRequest and validates jsonrpc:"2.0", method, and params.
  • Dispatch is by the method field to the corresponding generated handler for your service method. The handler decodes the typed payload, invokes your implementation, and encodes a typed JSON-RPC response via MakeSuccessResponse(id, result).
  • If the incoming message has no id (a notification), the server does not send a response, per the spec.
  • Absent params decode as {}, except when Body("name") selects an optional payload attribute as the params, other than a primitive with a default value. The client then omits params when that attribute is nil or absent, and the server decodes absent params as a nil or absent attribute. For a non-nullable union, "params": null is still rejected with -32602; for a non-nullable object, it decodes like {} and the object is validated; for a non-nullable primitive, array, or map, it decodes like the zero value of a primitive and like a nil array or map; for a nullable or Any attribute, it decodes as a null value.
  • Clients omit params only when a request sets none: a method without payload, or a nil or absent optional attribute selected with Body("name"). Empty strings, arrays and objects and null values are sent, as are the params of notifications.
  • Loom params may be any JSON value, including null, strings, and numbers. JSON-RPC 2.0 allows only arrays and objects, so a strict third-party JSON-RPC 2.0 server may reject such params.
  • Absent params are also kept absent when the params are a nullable or Any payload, or a required nullable or Any payload attribute selected with Body("name"). Such params are required, so the server rejects absent params with -32602 and a missing_payload error instead of decoding them as {}.
  • Batch requests are decoded to []jsonrpc.RawRequest and each entry is processed independently; responses are streamed into a JSON array.
Server-Sent Events: Server Streaming

Intermediate SSE sends are JSON-RPC notifications. Configure their protocol method inside ServerSentEvents when a client expects a defined notification:

ServerSentEvents(func() {
    SSENotificationMethod("notifications/progress")
})

Without an explicit method, Loom uses the namespaced <service>/stream.event default; it never reuses the request method name.

Unidirectional streaming from server to client. Perfect for:

  • Progress updates
  • Live notifications
  • Real-time feeds
  • Long-running operations
Service("monitor", func() {
    JSONRPC(func() {
        POST("/events")  // SSE uses POST for initial payload
    })
    
    Method("watch", func() {
        Description("Watch system metrics")
        
        Payload(func() {
            Attribute("metrics", ArrayOf(String), "Metrics to watch")
            Required("metrics")
        })
        
        StreamingResult(func() {
            Attribute("metric", String)
            Attribute("value", Float64)
            Attribute("timestamp", String, func() {
                Format(FormatDateTime)
            })
            Required("metric", "value", "timestamp")
        })
        
        JSONRPC(func() {
            ServerSentEvents(func() {
                SSEEventType("metric")  // SSE event type field
            })
        })
    })
})

Server implementation:

func (s *monitorSvc) Watch(ctx context.Context, p *monitor.WatchPayload, 
    stream monitor.WatchServerStream) error {
    
    ticker := time.NewTicker(1 * time.Second)
    defer ticker.Stop()
    
    for {
        select {
        case <-ctx.Done():
            return nil
        case <-ticker.C:
            for _, metric := range p.Metrics {
                err := stream.Send(ctx, &monitor.WatchResult{
                    Metric:    metric,
                    Value:     getMetricValue(metric),
                    Timestamp: time.Now().Format(time.RFC3339),
                })
                if err != nil {
                    return err
                }
            }
        }
    }
}

Client usage:

httpClient := monitorjsonrpc.NewClient(/* ... */)
stream, err := httpClient.Watch(ctx, &monitor.WatchPayload{
    Metrics: []string{"cpu", "memory"},
})

for {
    result, err := stream.Recv()
    if err == io.EOF {
        break
    }
    log.Printf("%s: %f", result.Metric, result.Value)
}

How it works internally:

  • SSE uses a regular HTTP POST to deliver the initial JSON-RPC request. The generated handler decodes a jsonrpc.RawRequest, validates it, and dispatches to the method-specific SSE handler.

  • The SSE response is a long-lived HTTP response with Content-Type: text/event-stream. JSON-RPC notifications, final success responses, and error responses all use the SSE event type message; the JSON-RPC envelope in data: identifies the frame kind.

  • The server stream interface exposes:

    • Send(ctx, event): writes a JSON-RPC notification as an SSE event (no response expected). Use this for progress or updates.
    • SendAndClose(ctx, result): closes the stream and, for an ID-bearing request, sends the final JSON-RPC response using the original request ID. For an ID-less notification or raw GET listener, it discards the value and emits the stream_final_response_suppressed transport event.
    • SendError(ctx, id, err): writes the final JSON-RPC error response and closes the stream. For an ID-less request it closes without a frame.
    • SendAndClose and SendError are terminal. The stream serializes every write with the terminal response, so no notification, comment, or second response can follow it, even from concurrent goroutines. Every later Send, SendAndClose, SendError, SendComment, or Open returns loomhttp.ErrSSEStreamClosed. If the service method returns an error after a terminal response, Loom does not write a second response. It reports an error that wraps both loomhttp.ErrSSEStreamClosed and the original service error to the error handler.
    • Each write checks the ctx passed to the call and the request context before it starts. While the write runs, cancellation of the call ctx and the configured stream write policy bound it.
  • Event types: Send and SendAndClose take the generated <Method>Event type. When the result is a user type declared in the service package, <Method>Event is an interface that the result type implements. For primitive, collection, Any, and struct:pkg:path results, Go cannot add methods to the result type, so <Method>Event is an alias of the result type and the service sends plain values such as a string or []byte. The service-level Event type becomes any in that case, and its Send rejects values of other types at run time. Every value is JSON encoded in the envelope. Strings stay JSON strings, so CR and LF characters cannot break SSE framing. Bytes values are base64 strings.

  • Notifications vs responses:

    • Notifications omit id; their method is the designed SSENotificationMethod, or <service>/stream.event by default, and their params contain the projected event body.
    • Final responses and errors carry normal JSON-RPC response envelopes in data:. Loom does not duplicate the JSON-RPC ID into the SSE id: field.
  • Example on-the-wire frames (simplified):

    event: message
    data: {"jsonrpc":"2.0","method":"notifications/progress","params":{"metric":"cpu","value":0.9}}
    
    event: message
    data: {"jsonrpc":"2.0","result":{"metric":"cpu","value":0.9},"id":"7"}
    
    
Raw events/stream GET Listener

When the designed JSON-RPC method is literally events/stream, Loom also mounts a bodyless GET listener on the service endpoint. Send Accept: text/event-stream; Loom synthesizes the method dispatch without a JSON-RPC request body and eagerly opens the SSE response so the client can observe readiness before the first domain event.

The listener has no JSON-RPC request ID. Every value that must reach the client must therefore use Send. Calling SendAndClose closes the listener but suppresses its value, because sending a JSON-RPC response without a request ID would violate the notification contract.

WebSocket: Bidirectional Streaming

Full-duplex, persistent connections for real-time communication. Ideal for:

  • Chat applications
  • Collaborative editing
  • Gaming
  • Live bidirectional data exchange
Service("chat", func() {
    JSONRPC(func() {
        GET("/ws")  // WebSocket upgrade
    })
    
    // Client-to-server notifications
    Method("send", func() {
        StreamingPayload(func() {
            Attribute("message", String)
            Required("message")
        })
        JSONRPC(func() {})
    })
    
    // Server-to-client notifications
    Method("broadcast", func() {
        StreamingResult(func() {
            Attribute("from", String)
            Attribute("message", String)
            Required("from", "message")
        })
        JSONRPC(func() {})
    })
    
    // Bidirectional request-response
    Method("echo", func() {
        StreamingPayload(func() {
            ID("msg_id", String)
            Attribute("text", String)
            Required("msg_id", "text")
        })
        StreamingResult(func() {
            ID("msg_id", String)
            Attribute("echo", String)
            Required("msg_id", "echo")
        })
        JSONRPC(func() {})
    })
})

Server implementation:

type chatSvc struct {
    connections map[string]chat.BroadcastServerStream
    mu          sync.RWMutex
}

func (s *chatSvc) HandleStream(ctx context.Context, stream chat.Stream) error {
    // Register connection
    connID := generateConnID()
    s.mu.Lock()
    s.connections[connID] = stream.(chat.BroadcastServerStream)
    s.mu.Unlock()
    
    defer func() {
        s.mu.Lock()
        delete(s.connections, connID)
        s.mu.Unlock()
        stream.Close()
    }()
    
    // Handle incoming messages
    for {
        if err := stream.Recv(ctx); err != nil {
            if errors.Is(err, io.EOF) {
                return nil // client closed normally
            }
            return err
        }
        // Messages are automatically dispatched to method handlers
    }
}

func (s *chatSvc) Send(ctx context.Context, p *chat.SendPayload) error {
    // Broadcast to all connections
    s.mu.RLock()
    defer s.mu.RUnlock()
    
    for _, conn := range s.connections {
        conn.SendNotification(ctx, &chat.BroadcastResult{
            From:    "user",
            Message: p.Message,
        })
    }
    return nil
}

func (s *chatSvc) Echo(ctx context.Context, p *chat.EchoPayload, 
    stream chat.EchoServerStream) error {
    
    return stream.SendResponse(ctx, &chat.EchoResult{
        MsgID: p.MsgID,
        Echo:  "Echo: " + p.Text,
    })
}

How it works internally:

  • Connection lifecycle:
    • The generated server upgrades the HTTP request to a WebSocket and constructs a Stream implementation, then calls your HandleStream(ctx, stream).
    • Generated client and server streams wrap the raw Gorilla socket in loomhttp.WebSocketStream, which owns context-bound JSON frame I/O, close-control writes, and idempotent close behavior.
    • Your HandleStream should defer stream.Close() and typically loop on stream.Recv(ctx), which reads a JSON-RPC message and dispatches it to the appropriate generated handler based on its method.
  • Dispatch and method invocation:
    • For non-streaming methods, Recv decodes the payload, invokes your method, and sends the typed JSON-RPC success response via the stream.
    • For streaming methods, Recv creates a method-specific stream wrapper that implements your generated XServerStream interface and calls your method implementation with it.
  • Sending from your methods:
    • In server or bidirectional streaming, your method receives a stream wrapper providing:
      • SendNotification(ctx, result): sends a JSON-RPC notification (no id).
      • SendResponse(ctx, result): sends a JSON-RPC success response using the original request id. You do not need to pass the id; the wrapper holds it for you.
      • SendError(ctx, err): sends a JSON-RPC error response correlated to the original request id when present.
  • Notifications and responses:
    • Messages without id are notifications. Use SendNotification for server-initiated messages that should not expect a response.
    • When replying to a client request that had an id, use SendResponse to correlate via that id automatically.
  • Error handling:
    • A received message that is not valid JSON gets a Parse error (-32700) frame, and Recv returns nil so the loop keeps reading. Invalid requests and unknown methods get error responses when an id is present; otherwise they are ignored to keep the connection alive.
    • A normal closure (1000) from the client makes Recv return io.EOF. Every other close code (including 1001 and 1006), network or deadline failure, and context error is returned unchanged, with no frame written. A failed connection read is terminal: later Recv calls return the same error without reading the connection again. A context canceled during a read closes the stream, so later calls return loomhttp.ErrWebSocketStreamClosed. Return from HandleStream on any error.
  • Generated client:
    • Every stream opened on one generated client shares the client's connection. The client reads the connection with one goroutine and routes each response to the stream and request with the same id. Request ids are unique on the connection, so streams never receive each other's responses. The runtime types are jsonrpc.WebSocketClientConn and jsonrpc.WebSocketClientStream.
    • Closing a stream, or canceling the context it was opened with, ends only that stream: its waiting requests fail, and late responses to them are reported as orphaned. The connection closes when its last stream ends or when the client is closed. While the client remains open, the next stream dials a new connection. Closing the client is permanent: subsequent attempts to open a stream fail without dialing. A dial already in progress can finish, but Close closes its connection before returning.
    • Match explicit stream or client closure with errors.Is(err, jsonrpc.ErrStreamClosed). This works for Send, Recv, notifications, a receive already waiting for a response, and opening a stream after client closure. The more specific jsonrpc.ErrWebSocketClientStreamClosed and jsonrpc.ErrWebSocketClientConnClosed also remain matchable. A write interrupted by closure preserves its underlying socket error too. Independent context cancellation and network failures retain their own errors. A successful response already available may still win a race with closure.
    • When the connection fails, every waiting request of every stream returns the read error, and later sends fail.
    • All events go to the one handler configured with jsonrpc.WithErrorHandler, but with different contexts:
      • Connection events have no stream, so the handler gets context.Background(): the read failure (StreamErrorConnection), responses with an unknown id (StreamErrorOrphaned), server notifications (StreamErrorNotification), and a failure to close an unusable connection.
      • Stream events get the context the stream was opened with: error responses (StreamErrorProtocol), undecodable results (StreamErrorParsing), timeouts (StreamErrorTimeout) and write failures (StreamErrorConnection).
    • Behavior that differs from clients generated before this design:
      • Closing a stream or canceling its context no longer closes the connection for the other streams.
      • Protocol and parsing errors are reported when Recv (or the server-streaming receive) consumes the response, not when it arrives. An error response to a request that is never received is not reported.
      • A stream whose context is canceled returns context.Canceled from later calls instead of a connection read error.
      • A Send that races Close can report a StreamErrorConnection write failure wrapping jsonrpc.ErrStreamClosed and the socket error.
Mixed Transports: Content Negotiation

Combine HTTP and SSE in a single service using automatic content negotiation:

Service("hybrid", func() {
    JSONRPC(func() {
        POST("/api")
    })
    
    // Standard HTTP method
    Method("status", func() {
        Result(func() {
            Attribute("healthy", Boolean)
            Required("healthy")
        })
        JSONRPC(func() {})
    })
    
    // SSE streaming method
    Method("monitor", func() {
        StreamingResult(func() {
            Attribute("event", String)
            Attribute("data", Any)
        })
        JSONRPC(func() {
            ServerSentEvents(func() {
                SSEEventType("update")
            })
        })
    })
    
    // Mixed results with content negotiation
    Method("flexible", func() {
        Payload(func() {
            Attribute("resource", String)
            Required("resource")
        })
        
        // Return simple result for HTTP
        Result(func() {
            Attribute("data", String)
            Required("data")
        })
        
        // Return stream for SSE
        StreamingResult(func() {
            Attribute("chunk", String)
            Attribute("progress", Int)
        })
        
        JSONRPC(func() {
            ServerSentEvents(func() {
                SSEEventType("progress")
            })
        })
    })
})

For a method with both result shapes, the server negotiates as follows:

  • Accept: application/json → HTTP handler → Result
  • Accept: text/event-stream → SSE handler → StreamingResult

Accept: text/event-stream is necessary but not sufficient for SSE routing. The generated server dispatches the JSON-RPC method first and invokes an SSE handler only for a method designed with ServerSentEvents and a streaming result. Ordinary methods, including protocol methods such as initialize, remain on the JSON response path even when a client sends the SSE accept type.

Advanced Features

Browser Cross-Origin Access

Generated JSON-RPC servers reject browser cross-origin requests by default through Go's http.CrossOriginProtection. Configure CORS in API or service JSONRPC scope to publish an explicit browser policy; service policy overrides API policy. Loom then mounts the OPTIONS preflight route, writes matching CORS response headers, and replaces the default origin protector with the designed policy.

API("calculator", func() {
    JSONRPC(func() {
        CORS(func() {
            Origin("https://app.example.com", func() {
                Methods("POST")
                Headers("Content-Type", "Authorization")
                Credentials()
            })
        })
    })
})

Use Origin("*") without Credentials() as the explicit opt-out when every browser origin should be allowed. Non-browser JSON-RPC clients are unaffected by the secure default.

If origins are deployment-specific, use RuntimeCORS() at the same API or service scope. Build a loomhttp.CORSPolicy from application configuration, validate it with loomhttp.NewRuntimeCORSPolicy, and pass the immutable result to the generated server constructor. Generated preflight and actual-response handling stays consistent across JSON-RPC POST, SSE-only, and mixed HTTP/SSE servers; configuration values are not embedded in generated code or OpenAPI.

Batch Processing

JSON-RPC supports sending multiple requests in a single HTTP call:

[
    {"jsonrpc": "2.0", "method": "add", "params": {"a": 1, "b": 2}, "id": 1},
    {"jsonrpc": "2.0", "method": "multiply", "params": {"a": 3, "b": 4}, "id": 2},
    {"jsonrpc": "2.0", "method": "divide", "params": {"dividend": 10, "divisor": 2}, "id": 3}
]

The server processes each request independently and returns an array of responses:

[
    {"jsonrpc": "2.0", "result": 3, "id": 1},
    {"jsonrpc": "2.0", "result": 12, "id": 2},
    {"jsonrpc": "2.0", "result": 5, "id": 3}
]

Batch processing is automatic - no special configuration needed.

Error Handling

Loom provides comprehensive error handling with standard JSON-RPC error codes:

Service("api", func() {
    // Define service-level errors
    Error("unauthorized", func() {
        Description("User is not authorized")
    })
    Error("rate_limited", func() {
        Description("Too many requests")
    })
    
    JSONRPC(func() {
        // Map errors to JSON-RPC codes
        Response("unauthorized", func() {
            Code(-32001)  // Custom application code
        })
        Response("rate_limited", func() {
            Code(-32002)
        })
    })
    
    Method("secure", func() {
        // ... method definition ...
        Error("unauthorized")  // Method can return this error
        Error("invalid_token")  // Method-specific error
        
        JSONRPC(func() {
            Response("invalid_token", func() {
                Code(-32003)
            })
        })
    })
})

Standard error codes:

  • -32700: Parse error
  • -32600: Invalid request
  • -32601: Method not found
  • -32602: Invalid params
  • -32603: Internal error
  • -32000 to -32099: Reserved for implementation
Selecting the error.data schema

Only an effective JSON-RPC Response(...) mapping creates a typed error.data contract. An HTTP mapping with the same error name has no effect on JSON-RPC.

For an explicit mapping, the generated server projects the concrete service error into the designed response body. The generated client validates that body and reconstructs the designed service error. The envelope code and message remain separate from required body fields.

Use a required ErrorName field when two names share one custom error type. The generated client uses this field when those names also share one numeric code.

Without an explicit JSON-RPC mapping, the server uses generic jsonrpc.ErrorData. The generated client returns *jsonrpc.RawErrorResponse, including the raw code, message, and data. Protocol failures use their protocol or generic data shape. They do not use a designed application body.

Generic jsonrpc.ErrorData includes Loom remedy metadata when available. A typed mapping does not replace its designed body with generic metadata. Add the required remedy fields to the custom error type when clients need them.

Streaming Patterns
Client Streaming (WebSocket only)
Method("upload", func() {
    StreamingPayload(func() {
        Attribute("chunk", Bytes)
        Attribute("offset", Int64)
        Required("chunk", "offset")
    })
    Result(func() {
        Attribute("size", Int64)
        Attribute("checksum", String)
    })
    JSONRPC(func() {})
})
Server Streaming (SSE or WebSocket)
Method("download", func() {
    Payload(func() {
        Attribute("file", String)
        Required("file")
    })
    StreamingResult(func() {
        Attribute("chunk", Bytes)
        Attribute("offset", Int64)
        Required("chunk", "offset")
    })
    JSONRPC(func() {
        ServerSentEvents(func() {})  // Or use WebSocket
    })
})
Bidirectional Streaming (WebSocket only)
Method("transform", func() {
    StreamingPayload(func() {
        ID("seq", String)
        Attribute("input", String)
        Required("seq", "input")
    })
    StreamingResult(func() {
        ID("seq", String)
        Attribute("output", String)
        Required("seq", "output")
    })
    JSONRPC(func() {})
})
Mixed Results

Support different response types based on content negotiation:

Method("report", func() {
    Payload(func() {
        Attribute("query", String)
        Required("query")
    })
    
    // Simple result for synchronous HTTP
    Result(func() {
        Attribute("summary", String)
        Attribute("count", Int)
        Required("summary", "count")
    })
    
    // Streaming result for SSE
    StreamingResult(func() {
        Attribute("row", Map(String, Any))
        Attribute("progress", Float64)
    })
    
    JSONRPC(func() {
        ServerSentEvents(func() {
            SSEEventType("row")
        })
    })
})

Implementation:

// Called for Accept: application/json
func (s *svc) Report(ctx context.Context, p *ReportPayload) (*ReportResult, error) {
    summary, count := generateReport(p.Query)
    return &ReportResult{Summary: summary, Count: count}, nil
}

// Called for Accept: text/event-stream
func (s *svc) ReportStream(ctx context.Context, p *ReportPayload, 
    stream ReportServerStream) error {
    
    rows := queryRows(p.Query)
    for i, row := range rows {
        err := stream.Send(ctx, &ReportStreamingResult{
            Row:      row,
            Progress: float64(i) / float64(len(rows)),
        })
        if err != nil {
            return err
        }
    }
    return nil
}
Generated Server Boundary

The generated server's exported ServeHTTP method is the effective public handler. It includes constructor policy wrappers and middleware registered via Server.Use; mount or extend that handler instead of calling generated raw HTTP, SSE, or WebSocket internals directly. This keeps JSON-RPC envelope validation, mixed-transport dispatch, CORS, and middleware ordering intact.

Generated protocol code lives below gen/jsonrpc/<service>/. Service interfaces and endpoints remain transport-neutral below gen/<service>/. Treat both trees as generated output: change the design or framework generator and regenerate instead of editing either tree by hand.

Best Practices

1. Service Design

DO:

  • Group related methods in the same service
  • Use consistent naming conventions
  • Define clear error codes and messages
  • Document expected behavior

DON'T:

  • Mix WebSocket with HTTP endpoints in the same service
  • Use deeply nested payload structures
  • Rely on transport-specific features
2. Error Handling

DO:

  • Map application errors to appropriate JSON-RPC codes
  • Provide meaningful error messages
  • Use standard codes when applicable
  • Include error data when helpful

DON'T:

  • Use reserved error code ranges
  • Return stack traces in production
  • Ignore validation errors
3. Streaming

DO:

  • Use SSE for server-push scenarios
  • Use WebSocket for bidirectional needs
  • Implement proper cleanup in stream handlers
  • Handle connection failures gracefully

DON'T:

  • Keep streams open indefinitely
  • Send large payloads in single messages
  • Ignore backpressure
4. Performance

DO:

  • Use batch requests for multiple operations
  • Implement connection pooling for clients
  • Cache frequently accessed data
  • Monitor message sizes

DON'T:

  • Create new connections per request
  • Send unnecessary notifications
  • Block stream handlers
Supporting Multiple Transports

Expose the same service over multiple protocols:

Service("universal", func() {
    // JSON-RPC configuration
    JSONRPC(func() {
        POST("/rpc")
    })
    
    Method("process", func() {
        Payload(func() {
            Attribute("data", String)
            Required("data")
        })
        Result(func() {
            Attribute("output", String)
            Required("output")
        })
        
        // Available via JSON-RPC
        JSONRPC(func() {})
        
        // Also available via HTTP REST
        HTTP(func() {
            POST("/process")
        })
        
        // And via gRPC
        GRPC(func() {})
    })
})

Additional Resources

Summary

Loom's JSON-RPC implementation provides:

  • Type Safety: Full compile-time type checking
  • Code Generation: Automatic client/server code from DSL
  • Protocol Compliance: Complete JSON-RPC 2.0 support
  • Transport Flexibility: HTTP, SSE, and WebSocket options
  • Streaming Support: Unidirectional and bidirectional patterns
  • Error Handling: Comprehensive error mapping and codes
  • Content Negotiation: Mixed results based on Accept headers
  • Batch Processing: Automatic batch request handling

The implementation seamlessly integrates with Loom's existing features while maintaining clean separation of concerns and enabling powerful real-time communication patterns.

Documentation

Overview

Package jsonrpc provides constructs and utilities for building JSON-RPC 2.0 services with Loom. This package contains the core types, client and server implementations, and code generation support for services that communicate using the JSON-RPC protocol.

JSON-RPC is a stateless, light-weight remote procedure call (RPC) protocol. This package implements the JSON-RPC 2.0 specification as defined in https://www.jsonrpc.org/specification.

The package supports:

  • Request/response method calls
  • Notification requests (fire-and-forget)
  • Batch requests for multiple calls
  • Structured error handling with error codes
  • HTTP, Server-Sent Events (SSE) and WebSocket transports

Code generated by Loom uses this package to create JSON-RPC clients and servers that seamlessly integrate with Loom's design-first approach and provide type-safe method invocation.

Index

Constants

This section is empty.

Variables

View Source
var (
	// ErrStreamClosed identifies operations ended by explicitly closing a
	// JSON-RPC WebSocket client stream or its client connection. Independent
	// context cancellation and network failures retain their own errors.
	ErrStreamClosed = errors.New("stream closed")

	// ErrWebSocketClientConnClosed is the error of the requests that were
	// waiting on a WebSocketClientConn when it was closed, and of the
	// requests sent after. It wraps ErrStreamClosed.
	ErrWebSocketClientConnClosed = fmt.Errorf("jsonrpc: websocket client connection closed: %w", ErrStreamClosed)

	// ErrWebSocketClientStreamClosed is returned by the operations of a
	// WebSocketClientStream after Close. It wraps ErrStreamClosed.
	ErrWebSocketClientStreamClosed = fmt.Errorf("%w", ErrStreamClosed)
)

Functions

func CompleteStream added in v1.8.0

func CompleteStream(
	ctx context.Context,
	hasID bool,
	id any,
	result any,
	send func(*Response) error,
) error

CompleteStream sends a final JSON-RPC success response when the initiating request has an ID. Notification and GET-listener completions are suppressed.

func CompleteStreamError added in v1.8.0

func CompleteStreamError(ctx context.Context, hasID bool, send func() error) error

CompleteStreamError sends a final JSON-RPC error response when the initiating request has an ID. Notification errors are suppressed.

func DecodeNotificationResponse

func DecodeNotificationResponse(svc, method string, resp *http.Response) error

DecodeNotificationResponse checks the HTTP response to a notification, a request without an ID, sent by the generated HTTP client of the method named method of the service named svc. A server sends no JSON-RPC response to a notification, so a 2xx response whose body holds no JSON value is a success. A response that holds a JSON-RPC error, such as the Invalid Request error of a server that could not read the notification, returns that error as a *RawErrorResponse; any other response body is ignored. A response status other than 2xx returns loomhttp.ErrInvalidResponse and a body that is not a JSON-RPC response returns loomhttp.ErrDecodingError. DecodeNotificationResponse reads and closes the body, then replaces it with the bytes that it read.

func IDToString

func IDToString(id any) string

IDToString converts a JSON-RPC ID to a string. JSON unmarshaling produces string or float64 for numeric values.

func NewErrorData

func NewErrorData(err error) any

NewErrorData returns structured JSON-RPC error data for err when Loom error metadata is available. It returns nil when err carries no machine-usable Loom error information.

func NewHTTPHandler added in v1.8.0

func NewHTTPHandler(spec HTTPHandlerSpec) http.Handler

NewHTTPHandler creates a JSON-RPC HTTP handler. The handler owns envelope validation, batch framing, notification suppression, and request observation.

func NewRequestID added in v1.7.0

func NewRequestID() (string, error)

NewRequestID returns a random RFC 9562 version 4 UUID for a JSON-RPC request.

func ReceiveWebSocketRequest added in v1.8.0

func ReceiveWebSocketRequest(
	ctx context.Context,
	stream *loomhttp.WebSocketStream,
	matches WebSocketMethodMatcher,
	dispatch WebSocketDispatch,
	sendError WebSocketErrorSender,
) error

ReceiveWebSocketRequest reads, validates, and dispatches one JSON-RPC WebSocket request frame.

It returns io.EOF when the peer closed the connection with a normal closure (1000). It returns every other connection, close, or context failure unchanged and writes no frame. A failed connection read is terminal: later calls return the same failure without reading the connection again. A context canceled during a read closes the stream, so later calls return loomhttp.ErrWebSocketStreamClosed; a context that is already done returns its error without reading. A received message that is not valid JSON gets a Parse error (-32700) frame and a nil return, so the caller can keep reading.

func ServeHTTP added in v1.8.0

func ServeHTTP(w http.ResponseWriter, r *http.Request, spec HTTPHandlerSpec)

ServeHTTP executes one JSON-RPC HTTP request with the supplied service adapters.

func ServeMixed added in v1.8.0

func ServeMixed(w http.ResponseWriter, r *http.Request, spec MixedHandlerSpec)

ServeMixed negotiates JSON-RPC HTTP or SSE handling for one route.

func ServeSSE added in v1.8.0

func ServeSSE(w http.ResponseWriter, r *http.Request, spec SSEHandlerSpec)

ServeSSE executes one JSON-RPC SSE request with generated typed adapters.

func ServeWebSocket added in v1.8.0

func ServeWebSocket(w http.ResponseWriter, r *http.Request, spec WebSocketHandlerSpec)

ServeWebSocket upgrades one HTTP request and runs a generated typed stream.

func ValidateResponseContract added in v1.8.0

func ValidateResponseContract(observation *ResponseContractObservation, contract ResponseContractCase) error

ValidateResponseContract validates transport-owned JSON-RPC wire invariants.

Types

type Code

type Code int

Code is a JSON-RPC error code, see JSON-RPC 2.0 section 5.1

const (
	ParseError     Code = -32700
	InvalidRequest Code = -32600
	MethodNotFound Code = -32601
	InvalidParams  Code = -32602
	InternalError  Code = -32603
)

func CodeForServiceError added in v1.8.0

func CodeForServiceError(err *loom.ServiceError) Code

CodeForServiceError maps framework validation failures to JSON-RPC codes.

type ErrorData

type ErrorData struct {
	// Name is the Loom error name when available.
	Name string `json:"name,omitempty"`
	// ID is the unique Loom service error instance identifier.
	ID string `json:"id,omitempty"`
	// Temporary reports whether the error is temporary.
	Temporary bool `json:"temporary,omitempty"`
	// Timeout reports whether the error is a timeout.
	Timeout bool `json:"timeout,omitempty"`
	// Fault reports whether the error is a server-side fault.
	Fault bool `json:"fault,omitempty"`
	// Remedy contains optional remediation guidance.
	Remedy *loom.ErrorRemedy `json:"remedy,omitempty"`
}

ErrorData is the default structured JSON-RPC error data emitted for Loom errors. It is intended for machine consumers and carries transport-neutral error characteristics plus optional remediation guidance.

type ErrorResponse

type ErrorResponse struct {
	Code    Code   `json:"code"`
	Message string `json:"message"`
	Data    any    `json:"data,omitempty"`
}

ErrorResponse represents a JSON-RPC error response.

func (*ErrorResponse) Error

func (e *ErrorResponse) Error() string

Error returns a string representation of the error.

type HTTPDispatch added in v1.8.0

type HTTPDispatch func(
	context.Context,
	*http.Request,
	*RawRequest,
	http.ResponseWriter,
) (matched bool, err error)

HTTPDispatch calls the generated typed adapter for request. matched is false when the service does not define the requested method.

type HTTPHandlerSpec added in v1.8.0

type HTTPHandlerSpec struct {
	// Service is the designed service name.
	Service string
	// Decoder returns the decoder for one HTTP request.
	Decoder func(*http.Request) loomhttp.Decoder
	// Encoder returns the encoder for one JSON-RPC response.
	Encoder func(context.Context, http.ResponseWriter) loomhttp.Encoder
	// Dispatch calls the generated typed method adapter.
	Dispatch HTTPDispatch
	// HandleFailure receives transport failures that cannot become JSON-RPC
	// responses.
	HandleFailure func(context.Context, http.ResponseWriter, error)
}

HTTPHandlerSpec defines the service adapters used by the JSON-RPC HTTP protocol runtime.

type MixedHandlerSpec added in v1.8.0

type MixedHandlerSpec struct {
	// HTTP defines the unary and batch HTTP runtime.
	HTTP HTTPHandlerSpec
	// SSE defines the SSE runtime.
	SSE SSEHandlerSpec
	// SupportsGET reports whether events/stream is designed.
	SupportsGET bool
}

MixedHandlerSpec defines HTTP and SSE adapters for a service that negotiates both JSON-RPC transports on one route.

type RawErrorResponse

type RawErrorResponse struct {
	Code    int            `json:"code"`
	Message string         `json:"message"`
	Data    jsontext.Value `json:"data,omitempty"`
}

RawErrorResponse represents a JSON-RPC error response with marshalled data.

func (*RawErrorResponse) Error

func (e *RawErrorResponse) Error() string

Error returns a string representation of the error.

type RawRequest

type RawRequest struct {
	JSONRPC string         `json:"jsonrpc"`
	Method  string         `json:"method"`
	Params  jsontext.Value `json:"params,omitempty"`
	ID      any            `json:"id"`
	// HasID is true when the "id" key is present in the incoming JSON (even if null).
	// It is consumed by generated templates (WebSocket/SSE/HTTP) to decide whether
	// to send a response for this request. Do not remove even if unused by this package.
	HasID bool `json:"-"`
	// Invalid is true when the JSON value is syntactically valid but not a
	// valid JSON-RPC request envelope.
	Invalid bool `json:"-"`
}

RawRequest represents a JSON-RPC request with a marshalled params.

func (*RawRequest) UnmarshalJSON

func (r *RawRequest) UnmarshalJSON(data []byte) error

UnmarshalJSON decodes RawRequest and records whether the id field was present.

func (*RawRequest) UnmarshalJSONFrom added in v1.9.0

func (r *RawRequest) UnmarshalJSONFrom(decoder *jsontext.Decoder) error

UnmarshalJSONFrom decodes RawRequest directly from a JSON v2 token stream.

type RawResponse

type RawResponse struct {
	JSONRPC string            `json:"jsonrpc"`
	Result  jsontext.Value    `json:"result,omitempty"`
	Error   *RawErrorResponse `json:"error,omitempty"`
	ID      any               `json:"id,omitempty"`
	// View names the Loom result view used for this message. It is omitted
	// for ordinary messages and carried as the loom_view extension.
	View ResultView `json:"loom_view,omitempty"`
}

RawResponse represents a JSON-RPC response with a marshalled result and error.

type Request

type Request struct {
	JSONRPC string `json:"jsonrpc"`
	Method  string `json:"method"`
	Params  any    `json:"params,omitzero"`
	ID      any    `json:"id,omitempty"`
	// View names the Loom result view used for this message. It is omitted
	// for ordinary messages and carried as the loom_view extension.
	View ResultView `json:"loom_view,omitempty"`
}

Request represents a JSON-RPC request. Its JSON form has a "params" member unless Params is nil, so empty strings, arrays and objects and null values are sent. It has an "id" member unless ID is nil or empty, as for a notification.

func MakeNotification

func MakeNotification(method string, params any) *Request

MakeNotification creates a notification.

type Response

type Response struct {
	JSONRPC string         `json:"jsonrpc"`
	Result  any            `json:"result,omitempty"`
	Error   *ErrorResponse `json:"error,omitempty"`
	ID      any            `json:"id"`
	// View names the Loom result view used for this message. It is omitted
	// for ordinary messages and error responses. Success responses carry it
	// as the loom_view extension.
	View ResultView `json:"loom_view,omitempty"`
}

Response represents a JSON-RPC response. A response without an Error is a success response: its JSON form always has a "result" member, which is null when Result is nil, as JSON-RPC 2.0 requires. The JSON form of a response with an Error has no "result" member.

func MakeErrorResponse

func MakeErrorResponse(id any, code Code, message string, data any) *Response

MakeErrorResponse creates an error response.

func MakeSuccessResponse

func MakeSuccessResponse(id any, result any) *Response

MakeSuccessResponse creates a success response.

func (Response) MarshalJSONTo

func (r Response) MarshalJSONTo(enc *jsontext.Encoder) error

MarshalJSONTo encodes r with the options of enc. A success response always has a "result" member and an error response has none; see Response.

type ResponseContractCase added in v1.8.0

type ResponseContractCase struct {
	// ID is stable while the service contract is unchanged.
	ID string
	// Kind identifies a success, service error, or notification.
	Kind ResponseContractCaseKind
	// ResultType is the designed success result type.
	ResultType string
	// HasResult reports whether the success envelope has a result member.
	HasResult bool
	// ErrorCode is the declared JSON-RPC error code.
	ErrorCode int
	// ErrorName is the declared service error name.
	ErrorName string
	// ErrorDataType is the designed JSON error-data type.
	ErrorDataType string
	// Stream describes a supported streaming terminal contract.
	Stream *StreamingResponseContract
}

ResponseContractCase describes one generated JSON-RPC wire-response branch.

type ResponseContractCaseKind added in v1.8.0

type ResponseContractCaseKind string

ResponseContractCaseKind identifies a JSON-RPC response branch.

const (
	// ResponseContractSuccess identifies a successful response.
	ResponseContractSuccess ResponseContractCaseKind = "success"
	// ResponseContractError identifies a service error.
	ResponseContractError ResponseContractCaseKind = "error"
	// ResponseContractNotification identifies response suppression for an ID-less request.
	ResponseContractNotification ResponseContractCaseKind = "notification"
)

type ResponseContractEvent added in v1.8.0

type ResponseContractEvent struct {
	// Type is the transport event type.
	Type string
	// Data is the JSON event payload.
	Data jsontext.Value
}

ResponseContractEvent is one parsed streaming JSON-RPC event.

type ResponseContractObservation added in v1.8.0

type ResponseContractObservation struct {
	// Response is the HTTP response for the JSON-RPC request or stream handshake.
	Response *http.Response
	// Events contains parsed server-SSE events in wire order.
	Events []ResponseContractEvent
	// TerminalError is the final server-SSE read error.
	TerminalError error
}

ResponseContractObservation contains values observed from a generated handler.

type ResultView

type ResultView string

ResultView names the view used to project a streamed result. Its JSON value must be a string; explicit null and other token types are invalid. An empty value lets generated clients use the method's fixed or default view.

func (*ResultView) UnmarshalJSONFrom

func (v *ResultView) UnmarshalJSONFrom(dec *jsontext.Decoder) error

UnmarshalJSONFrom decodes a string view name, rejecting non-string metadata.

type SSEDispatch added in v1.8.0

type SSEDispatch func(
	context.Context,
	*http.Request,
	*RawRequest,
	http.ResponseWriter,
) (matched bool, unary bool, err error)

SSEDispatch calls a generated typed SSE method adapter. unary reports whether an ID-less successful request must finish with HTTP 204.

type SSEErrorSender added in v1.8.0

type SSEErrorSender func(
	context.Context,
	*http.Request,
	http.ResponseWriter,
	any,
	Code,
	string,
	any,
) error

SSEErrorSender writes one JSON-RPC error as an SSE event.

type SSEHandlerSpec added in v1.8.0

type SSEHandlerSpec struct {
	// Service is the designed service name.
	Service string
	// Decoder returns the decoder for one HTTP request.
	Decoder func(*http.Request) loomhttp.Decoder
	// Dispatch calls a generated typed SSE method adapter.
	Dispatch SSEDispatch
	// SendError writes one protocol error event.
	SendError SSEErrorSender
	// HandleFailure receives transport failures.
	HandleFailure func(context.Context, http.ResponseWriter, error)
}

SSEHandlerSpec defines the generated adapters used by the JSON-RPC SSE protocol runtime.

type StreamConfig

type StreamConfig struct {
	// Timeouts
	RequestTimeout    time.Duration // Timeout for individual requests (default: 30s)
	ConnectionTimeout time.Duration // Timeout for establishing connections (default: 10s)
	CloseTimeout      time.Duration // Timeout for graceful stream closure (default: 5s)

	// Buffer Sizes
	ResultChannelBuffer int // Buffer size for result channels (default: 1)
	WriteBufferSize     int // WebSocket write buffer size (default: 4096)
	ReadBufferSize      int // WebSocket read buffer size (default: 4096)

	// Retry Configuration
	MaxRetries       int           // Maximum number of connection retries (default: 3)
	RetryBackoffBase time.Duration // Base delay for exponential backoff (default: 1s)
	RetryBackoffMax  time.Duration // Maximum retry delay (default: 30s)

	// Advanced Options
	EnableCompression bool          // Enable WebSocket compression (default: false)
	PingInterval      time.Duration // Interval for sending ping frames (default: 30s)

	// Error Handling
	ErrorHandler StreamErrorHandler // Optional error handler for stream events (default: nil)
}

StreamConfig contains configuration options for WebSocket streams

func NewStreamConfig

func NewStreamConfig(opts ...StreamConfigOption) *StreamConfig

NewStreamConfig creates a StreamConfig with the given options

func (*StreamConfig) Validate

func (c *StreamConfig) Validate() *StreamConfig

Validate checks the configuration and applies constraints

type StreamConfigOption

type StreamConfigOption func(*StreamConfig)

StreamConfigOption is a function that modifies StreamConfig

func WithCloseTimeout

func WithCloseTimeout(timeout time.Duration) StreamConfigOption

WithCloseTimeout sets the timeout for graceful stream closure

func WithCompression

func WithCompression(enabled bool) StreamConfigOption

WithCompression enables or disables WebSocket compression

func WithConnectionTimeout

func WithConnectionTimeout(timeout time.Duration) StreamConfigOption

WithConnectionTimeout sets the timeout for establishing connections

func WithErrorHandler

func WithErrorHandler(handler StreamErrorHandler) StreamConfigOption

WithErrorHandler sets the error handler for stream events

func WithPingInterval

func WithPingInterval(interval time.Duration) StreamConfigOption

WithPingInterval sets the interval for sending ping frames

func WithRequestTimeout

func WithRequestTimeout(timeout time.Duration) StreamConfigOption

WithRequestTimeout sets the timeout for individual requests

func WithResultChannelBuffer

func WithResultChannelBuffer(size int) StreamConfigOption

WithResultChannelBuffer sets the buffer size for result channels

func WithRetryConfig

func WithRetryConfig(maxRetries int, baseDelay, maxDelay time.Duration) StreamConfigOption

WithRetryConfig sets retry behavior parameters

func WithWebSocketBuffers

func WithWebSocketBuffers(readSize, writeSize int) StreamConfigOption

WithWebSocketBuffers sets both read and write buffer sizes

type StreamErrorHandler

type StreamErrorHandler func(ctx context.Context, errorType StreamErrorType, err error, response *RawResponse)

StreamErrorHandler allows users to handle stream errors

type StreamErrorType

type StreamErrorType int

StreamErrorType represents different types of WebSocket stream errors

const (
	StreamErrorConnection   StreamErrorType = iota // WebSocket connection errors
	StreamErrorProtocol                            // Invalid JSON-RPC protocol
	StreamErrorParsing                             // Failed to parse/decode response
	StreamErrorOrphaned                            // Response with no matching request
	StreamErrorTimeout                             // Request timeout
	StreamErrorNotification                        // Server-initiated notification received
)

type StreamingResponseContract added in v1.8.0

type StreamingResponseContract struct {
	// Transport identifies the streaming wire protocol.
	Transport string
	// Terminal identifies the expected terminal behavior.
	Terminal string
}

StreamingResponseContract describes a selected JSON-RPC stream terminal.

type WebSocketClientConn

type WebSocketClientConn struct {
	// contains filtered or unexported fields
}

WebSocketClientConn is the WebSocket connection that a generated JSON-RPC client shares between its streams. One goroutine reads the connection and routes every response to the request with the same id, whichever stream sent it, because gorilla/websocket allows only one concurrent reader. Request ids are unique on the connection. Writes go through the write lock of the wrapped WebSocketStream.

When the read fails, every request still waiting gets the read error and no request can be sent any more. Close and the release of the last stream close the connection the same way. The read failure, a response with an unknown id and a server notification are reported, with context.Background(), to the handler given to NewWebSocketClientConn, as StreamErrorConnection, StreamErrorOrphaned and StreamErrorNotification.

A stream reports its own errors (protocol, parsing, timeout, write failure) to its StreamConfig handler with its context. Protocol and parsing errors are reported when Recv or Call consumes the response, so an error response to a request that is never received is not reported.

The formal model of this design is jsonrpc/tla/WebSocketClientDemux.tla.

func NewWebSocketClientConn

func NewWebSocketClientConn(ws *loomhttp.WebSocketStream, handler StreamErrorHandler) *WebSocketClientConn

NewWebSocketClientConn starts routing the responses read from ws and returns the connection. handler, which may be nil, receives the events of the connection itself: the read failure (StreamErrorConnection), responses with an unknown id (StreamErrorOrphaned), server notifications (StreamErrorNotification), and a failure to close a connection that Acquire refused. They belong to no stream, so handler gets context.Background() rather than a stream context. Generated clients pass the ErrorHandler of their StreamConfig, the handler their streams report to.

func (*WebSocketClientConn) Acquire

func (c *WebSocketClientConn) Acquire() bool

Acquire takes a reference on the connection for a new stream. It pings the server first and returns false, without a reference, when the ping fails or the connection is closed. A refused connection that no stream holds is closed, so its socket is released; a failure to close it is reported to the handler. A connection also closes when its last reference is released.

func (*WebSocketClientConn) Close

func (c *WebSocketClientConn) Close() error

Close closes the connection: the requests waiting on it fail with ErrWebSocketClientConnClosed and no request can be sent any more. Later calls return the result of the first one.

func (*WebSocketClientConn) Done

func (c *WebSocketClientConn) Done() <-chan struct{}

Done returns a channel that is closed once the connection stopped reading and failed the requests waiting on it.

func (*WebSocketClientConn) Err

func (c *WebSocketClientConn) Err() error

Err returns the error that ended the connection, or nil while it is usable.

func (*WebSocketClientConn) Release

func (c *WebSocketClientConn) Release() error

Release returns a reference taken with Acquire. Releasing the last one closes the connection and returns the result of closing the socket. The connection is marked closed in the same critical section that drops the last reference, so a concurrent Acquire either takes its reference first or is refused.

type WebSocketClientStream

type WebSocketClientStream struct {
	// contains filtered or unexported fields
}

WebSocketClientStream is one stream of a generated JSON-RPC WebSocket client on a shared WebSocketClientConn. Every request sent with Send gets its own response, which Recv returns in send order; a response that arrives before Recv is called is held until Recv returns it or the stream ends. Generated code decodes the results.

The stream ends when it is closed or when the context it was created with is done: its waiting requests fail, their late responses become orphans, and it releases the connection. The other streams on the connection are not affected.

func NewWebSocketClientStream

func NewWebSocketClientStream(ctx context.Context, conn *WebSocketClientConn, method string, config *StreamConfig) *WebSocketClientStream

NewWebSocketClientStream returns a stream that sends the requests of the JSON-RPC method on conn. The caller must hold a reference on conn from Acquire, which the stream releases when it ends. The stream ends when ctx is done, and config sets its request timeout and error handler.

func (*WebSocketClientStream) Call

func (s *WebSocketClientStream) Call(ctx context.Context, params any) (*RawResponse, error)

Call writes a request with params and waits for its response, which it returns as Recv does. When ctx is done first, the request is forgotten.

func (*WebSocketClientStream) Close

func (s *WebSocketClientStream) Close() error

Close ends the stream and releases its connection reference. It returns the result of closing the connection when the stream held its last reference, and nil otherwise. Later calls return the same result.

func (*WebSocketClientStream) Context

func (s *WebSocketClientStream) Context() context.Context

Context returns the context the stream was created with.

func (*WebSocketClientStream) Notify

func (s *WebSocketClientStream) Notify(ctx context.Context, params any) error

Notify writes a request with params and without an id, which the server does not answer.

func (*WebSocketClientStream) Recv

Recv waits for the response to the oldest request sent with Send and not received yet. It returns the response when it has no error and the error of the response otherwise, reporting it to the error handler with StreamErrorProtocol. When ctx is done first, the request stays the oldest one; when the request times out, it is forgotten and its late response is reported as orphaned.

func (*WebSocketClientStream) ReportError

func (s *WebSocketClientStream) ReportError(errorType StreamErrorType, err error, response *RawResponse)

ReportError passes a stream error to the error handler of the stream configuration, if any, with the stream context.

func (*WebSocketClientStream) Send

func (s *WebSocketClientStream) Send(ctx context.Context, params any) error

Send writes a request with params and queues it for Recv. A failed write ends the connection for the stream: later calls return the error.

type WebSocketDispatch added in v1.8.0

type WebSocketDispatch func(context.Context, *RawRequest) error

WebSocketDispatch calls a generated typed WebSocket method adapter.

type WebSocketErrorSender added in v1.8.0

type WebSocketErrorSender func(context.Context, any, Code, string, any) error

WebSocketErrorSender writes one JSON-RPC error frame.

type WebSocketHandlerSpec added in v1.8.0

type WebSocketHandlerSpec struct {
	// Upgrader upgrades the HTTP connection.
	Upgrader loomhttp.Upgrader
	// Configure optionally configures the upgraded connection.
	Configure loomhttp.ConnConfigureFunc
	// WritePolicy bounds WebSocket writes.
	WritePolicy loomhttp.StreamWritePolicy
	// Run constructs and runs the generated typed service stream.
	Run func(context.Context, context.CancelFunc, *http.Request, http.ResponseWriter, *loomhttp.WebSocketStream) error
	// HandleFailure receives upgrade, stream, and close failures.
	HandleFailure func(context.Context, http.ResponseWriter, error)
}

WebSocketHandlerSpec defines the generated adapters used to establish a JSON-RPC WebSocket service stream.

type WebSocketMethodMatcher added in v1.8.0

type WebSocketMethodMatcher func(string) bool

WebSocketMethodMatcher reports whether a generated WebSocket service defines a method.

Directories

Path Synopsis
Package codegen generates JSON-RPC 2.0 servers and clients from an evaluated Loom design.
Package codegen generates JSON-RPC 2.0 servers and clients from an evaluated Loom design.

Jump to

Keyboard shortcuts

? : This menu
/ : Search site
f or F : Jump to
y or Y : Canonical URL