Skip to content

patx/relay-lang

Folders and files

NameName
Last commit message
Last commit date

Latest commit

 

History

72 Commits
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

Relay Programming Language

Version: 0.1 License: MIT

A networking-first, async-by-default programming language with Python-like syntax and Node.js-style non-blocking semantics.

Relay is designed for building high-performance web services, APIs, and network applications with minimal boilerplate. Everything is asynchronous by default, but there's no await keyword—async operations start immediately and only block when their values are actually needed.

Table of Contents

Features

  • Implicit Async: No await keyword needed—async operations resolve automatically when values are accessed
  • Python-like Syntax: Clean, indentation-based syntax that's easy to read and write
  • Built-in Web Server: Flask/FastAPI-style decorators with automatic routing
  • Route Groups + OpenAPI: Prefix route groups and expose generated /openapi.json
  • Static File Serving: Mount directories directly in your web app (app.static("/assets", "./public"))
  • Middleware Hooks: Run request middleware before handlers (app.use(auth_middleware))
  • HTTP Client: Async HTTP client with get/post/put/patch/delete and request/response headers
  • Email (SMTP): Async SMTP mail sending with secure-by-default TLS and template helpers
  • Multipart Uploads: Parse multipart/form-data bodies with file metadata/bytes
  • Upload Guards: Configure max request/file size limits and MIME allowlists via app.uploads(...)
  • MongoDB Integration: Native async MongoDB support
  • Session Management: Built-in session handling with HttpOnly cookies
  • Pluggable Session Backends: Use in-memory sessions or custom load/save callbacks
  • Authentication Helpers: Password hashing/verification and pluggable auth stores
  • Structured API Errors: HTTPError(...) helper with consistent JSON error envelope
  • Request IDs: Per-request request_id in handler context and x-request-id response header
  • Template Rendering: MiniJinja-powered templates available in any string expression ({{ variable }})
  • Type Hints: Optional runtime type checking for function parameters
  • List Comprehensions: Python-style inline list transforms with optional filtering
  • Destructuring Assignment: Unpack lists/strings/dicts into multiple variables
  • Error Handling: try/except blocks for graceful runtime error recovery
  • Concurrency Primitives: spawn, all, race, timeout, and cancel for parallel execution

Quick Start

Installation

# Clone the repository
git clone https://github.com/patx/relay-lang.git
cd relay-lang

# Install the Relay CLI
cargo install --path .

# Run a Relay program
relay path/to/app.ry

Run Included Examples

# Small language/runtime example
relay examples/type_hint_validation.ry

# Full web app examples
relay examples/param_types.ry
relay examples/pastebin.ry          # requires local MongoDB
relay examples/url_shortener.ry     # requires local MongoDB

Example Prereqs

Example Needs
examples/type_hint_validation.ry None
examples/param_types.ry None
examples/pastebin.ry Local MongoDB at mongodb://localhost:27017
examples/url_shortener.ry Local MongoDB at mongodb://localhost:27017
examples/upload_email_attachment.ry SMTP credentials + template files in examples/templates/
examples/email_smtp.ry SMTP credentials

Hello World

print("Hello, Relay!")

Async Hello World

sleep(2000, print("world"))
print("hello")

Output:

hello
world

Notice how "hello" prints immediately while "world" waits 2 seconds—all without blocking the main thread.

Simple Web Server

app = WebApp()
server = WebServer()

fn auth()
    if (request.path == "/private" && session["user"] == None)
        return app.redirect("/login")

app.use(auth)
app.static("/assets", "./public")

@app.get("/")
fn index()
    return "Hello, Relay!"

server.run(app)

Visit http://127.0.0.1:8080/ to see your response.

Installation

Prerequisites

  • Rust 1.70 or later
  • Cargo (included with Rust)

From Source

# Clone the repository
git clone https://github.com/patx/relay-lang.git
cd relay-lang

# Build and install
cargo install --path .

# Run a Relay program
relay path/to/app.ry

Development Mode

If you're developing the Relay compiler itself:

cargo run -- path/to/file.ry

Environment Variables

  • RELAY_BIND: Override the default bind address for web servers (default: 127.0.0.1:8080)

Example:

RELAY_BIND=0.0.0.0:8080 relay server.ry

Learning Path

If your goal is to learn Relay end-to-end, use this order:

  1. Start with Quick Start to run your first script and web app.
  2. Work through Language Guide for syntax, types, control flow, modules, and async behavior.
  3. Use API Reference as the complete built-in/runtime reference.
  4. Run scripts in examples/ to see complete services and integrations.
  5. Revisit Best Practices and Troubleshooting as you build real apps.

Language Guide

Syntax Fundamentals

Relay uses indentation-based syntax with 4 spaces per indentation level. Tabs are not allowed.

fn example()
    x = 10
    if (x > 5)
        print("x is greater than 5")
    else
        print("x is 5 or less")

Key Rules:

  • Indentation must be exactly 4 spaces per level
  • No tabs allowed
  • Function bodies, control flow blocks, and loops all require indentation
  • Comments start with // and continue to the end of the line
// This is a comment
x = 42  // This is also a comment

Modules and Imports

Relay supports loading code from multiple .ry files with import:

import utils
import web.routes
import shared/helpers.ry

How imports resolve:

  • import utils loads utils.ry
  • import web.routes loads web/routes.ry
  • Relative paths are resolved from the importing file's directory
  • A module is loaded only once per run (duplicate imports are ignored)

The module filenames above are illustrative examples of resolution behavior.

Imported modules execute in the same global scope, so functions and variables they define become directly available.

Data Types

Primitives

// Integers
age = 25
count = -10

// Floats
pi = 3.14159
temperature = -273.15

// Strings
name = "Relay"
message = "Hello, world!"

// Booleans
is_active = True
is_complete = False

// None
result = None

Collections

Lists:

numbers = [1, 2, 3, 4, 5]
mixed = [1, "two", 3.0, True]
nested = [[1, 2], [3, 4]]

// List comprehensions
squares = [n * n for n in numbers]
evens = [n for n in numbers if n % 2 == 0]

// Access elements
first = numbers[0]      // 1
last = numbers[4]       // 5

// Lists are mutable
numbers[0] = 10

Dictionaries:

user = {"name": "Ada", "age": 30, "active": True}

// Access values
name = user["name"]     // "Ada"
age = user["age"]       // 30

// Keys are always strings
// Values can be any type

Important: Dictionary keys are automatically stringified. {1: "value"} becomes {"1": "value"}.

Destructuring Assignment

Relay supports destructuring assignment for iterables such as lists, strings, and dictionaries (dictionary keys are unpacked):

a, b, c = [10, 20, 30]
x, y = "hi"           // x = "h", y = "i"
k1, k2 = {"a": 1, "b": 2}

The number of variables must match the number of unpacked values.

Control Flow

If-Else

if (condition)
    // then block
    print("condition is true")
if (x > 0)
    print("positive")
else
    print("zero or negative")

While Loops

i = 0
while (i < 5)
    print(i)
    i = i + 1

Augmented assignment operators are supported:

i = 0
while (i < 5)
    print(i)
    i =+ 1  // equivalent to i = i + 1

For Loops

// Iterate over lists
for (item in [1, 2, 3, 4, 5])
    print(item)

// Iterate over dictionary keys
user = {"name": "Ada", "age": 30}
for (key in user)
    print(key + ": " + str(user[key]))

Note: For loops iterate over collection elements or dictionary keys.

break and continue

i = 0
while (i < 6)
    i =+ 1
    if (i == 2)
        continue
    if (i == 5)
        break
    print(i)

Error Handling (try/except)

Use try/except to catch runtime errors and continue execution:

try
    value = int("not-a-number")
    print(value)
except
    print("Could not parse integer")

You can also bind the error message:

try
    result = missing_name + 1
except(err)
    print("Error:", err)

Functions

Basic Functions

fn greet(name)
    return "Hello, " + name

message = greet("World")
print(message)  // Hello, World

Default Parameters

fn greet(name: str = "World")
    return "Hello, " + name

print(greet())          // Hello, World
print(greet("Relay"))   // Hello, Relay

Type Hints

Relay supports runtime type checking for parameters:

fn add(a: int, b: int)
    return a + b

result = add(5, 10)         // OK
result = add("5", "10")     // Type error!

Type hints on regular functions are strict validation (no implicit coercion). Unknown type hints raise a runtime type error.

Supported Types:

  • str: String
  • int: Integer
  • float: Float
  • bool: Boolean
  • json or Json: JSON object
fn process_data(data: json)
    return data["key"]

Return Values

Functions can return any value:

fn get_user()
    return {"name": "Ada", "id": 1}

fn calculate()
    return 42

fn do_work()
    // No explicit return = returns None
    print("Working...")

Async Model

Relay's async model is unique: there is no await keyword. Instead, async operations return Deferred values that automatically resolve when you try to use them.

Expression Statements Don't Block

When you call an async function without using its return value, it runs in the background:

sleep(1000, print("delayed"))
print("immediate")

Output:

immediate
delayed

Deferred Values

When you assign the result of an async operation, you get a Deferred value. The operation starts immediately but doesn't block:

x = sleep(1000, 10)     // Returns immediately with Deferred<10>
y = sleep(1000, 20)     // Returns immediately with Deferred<20>
print(x + y)            // Waits for both, then prints 30

Both sleeps start at the same time, so this takes ~1 second, not 2.

How Deferred Resolution Works

A Deferred value automatically resolves (waits for the async operation to complete) when:

  1. Used in an expression:
result = deferred_value + 10
  1. Passed to a function:
print(deferred_value)
  1. Returned from a function:
fn get_data()
    return http.get("https://api.example.com")
  1. Used in a comparison:
if (deferred_value > 10)
    print("greater than 10")

Concurrency Primitives

spawn(expr) - Run an expression in parallel:

fn work(n)
    sleep(500, n * 2)

task1 = spawn(work(5))
task2 = spawn(work(10))
result1 = task1.join()  // Wait for completion
result2 = task2.join()

all(tasks) - Wait for all tasks to complete:

tasks = [spawn(work(1)), spawn(work(2)), spawn(work(3))]
results = all(tasks)    // [2, 4, 6]

race(tasks) - Wait for the first task to complete:

tasks = [spawn(sleep(1000, "slow")), spawn(sleep(100, "fast"))]
winner = race(tasks)    // "fast"

timeout(expr, ms) - Add a timeout to any operation:

result = timeout(http.get("https://slow-api.com"), 5000)

cancel(task) - Cancel a running task:

task = spawn(long_operation())
cancel(task)

API Reference

Core Functions

print(value, ...)

Print values to stdout. Multiple arguments are printed space-separated.

print("Hello")                  // Hello
print("x =", 42)                // x = 42
print("a", "b", "c")            // a b c

str(value)

Convert any value to a string.

str(123)        // "123"
str(3.14)       // "3.14"
str(True)       // "True"
str([1, 2, 3])  // "[1, 2, 3]"

int(value)

Convert a value to an integer.

int("42")       // 42
int(3.9)        // 3
int(True)       // 1
int("invalid")  // Runtime error

float(value)

Convert a value to a float.

float("3.14")   // 3.14
float(42)       // 42.0
float("2.5e3")  // 2500.0

Async & Concurrency

sleep(milliseconds, value=None)

Returns: Deferred<value>

Sleep for the specified duration, then resolve to value.

sleep(1000, print("done"))          // Print after 1 second
result = sleep(2000, 42)            // Wait 2s, result = 42

spawn(expression)

Returns: Task

Execute an expression in parallel. Returns a Task object.

task = spawn(expensive_computation())
// Do other work...
result = task.join()

Task Methods:

  • task.join() - Wait for the task to complete and return its value

all(tasks)

Returns: List of results

Wait for all tasks to complete. Returns results in order.

tasks = [spawn(work(1)), spawn(work(2)), spawn(work(3))]
results = all(tasks)  // Wait for all, returns [result1, result2, result3]

race(tasks)

Returns: First completed result

Wait for the first task to complete, return its result.

tasks = [
    spawn(http.get("https://api1.com")),
    spawn(http.get("https://api2.com"))
]
fastest = race(tasks)  // Returns whichever completes first

timeout(expression, milliseconds)

Returns: Deferred<value> or timeout error

Add a timeout to any async operation.

result = timeout(http.get("https://slow.com"), 5000)  // 5 second timeout

If the timeout is exceeded, a runtime error is raised.

cancel(task)

Cancel a running task.

task = spawn(long_running_operation())
cancel(task)

File System

read_file(path)

Returns: Deferred<string>

Read a file's contents as a UTF-8 string.

content = read_file("data.txt")
print(content)

save_file(content, path)

Returns: Deferred<None>

Write a string to a file.

save_file("Hello, World!", "output.txt")

read_json(path)

Returns: Deferred<dict>

Read and parse a JSON file.

data = read_json("config.json")
print(data["api_key"])

save_json(data, path)

Returns: Deferred<None>

Serialize data to JSON and write to a file.

config = {"host": "localhost", "port": 8080}
save_json(config, "config.json")

HTTP Client

Http()

Create an HTTP client instance.

http = Http()

http.get(url, headers=None)

Returns: Deferred<Response>

Send a GET request.

http = Http()
resp = http.get(
    "https://api.example.com/users",
    headers={"authorization": "Bearer token"}
)
print(resp.status)  // 200
print(resp.text)    // Response body as string

http.post(url, data=None, headers=None)

http.put(url, data=None, headers=None)

http.patch(url, data=None, headers=None)

http.delete(url, data=None, headers=None)

Returns: Deferred<Response>

Send requests with optional payloads and headers.

http = Http()
payload = {"name": "Ada", "email": "ada@example.com"}
resp = http.post(
    "https://api.example.com/users",
    payload,
    headers={"content-type": "application/json"}
)

Response Object

HTTP responses have the following properties:

  • resp.status - HTTP status code (int)
  • resp.text - Response body as string
  • resp.json() - Parse response body as JSON
  • resp.headers - Response headers (dict)
http = Http()
resp = http.get("https://api.github.com/users/octocat")

print(resp.status)  // 200
data = resp.json()
print(data["login"])                  // octocat

Email (SMTP)

Email(host, port=587, username=None, password=None, from=None, tls="starttls")

Create an SMTP email client instance.

email = Email(
    "smtp.example.com",
    587,
    "smtp-user",
    "smtp-pass",
    "Relay <noreply@example.com>",
    tls="starttls"
)

TLS modes:

  • "starttls" (default) - requires STARTTLS upgrade
  • "wrapper" - implicit TLS/SMTPS (commonly port 465)
  • "insecure" - plain SMTP (only for local/dev)

email.send(to, subject, text=None, html=None, cc=None, bcc=None, reply_to=None, from=None, headers=None, attachments=None)

Returns: Deferred<dict>

Send an email asynchronously over SMTP.

  • to, cc, bcc accept str or list[str]
  • at least one of text or html is required
  • from can be passed per-call or configured in Email(...)
  • attachments accepts a single attachment dict or list of attachment dicts
email = Email(
    host="smtp.example.com",
    username="smtp-user",
    password="smtp-pass",
    from="Relay <noreply@example.com>"
)

result = email.send(
    to=["ada@example.com"],
    subject="Welcome",
    text="Welcome to Relay!"
)

print(result["ok"])          // True
print(result["smtp_code"])   // e.g. 250

Attachment dict format:

  • filename (required) - attachment file name
  • bytes or content or data (required) - file payload (bytes, str, or list[int])
  • content_type (optional) - MIME type (default: application/octet-stream)
result = email.send(
    to="ada@example.com",
    subject="Monthly report",
    text="See attached report.",
    attachments=[
        {
            "filename": "report.txt",
            "content_type": "text/plain",
            "bytes": "Report body"
        }
    ]
)

Send result fields:

  • ok - always True on success
  • transport - "smtp"
  • host / port - resolved transport target
  • message_id - message id when available, else None
  • envelope_to - recipient list used for SMTP envelope
  • smtp_code / smtp_message - provider response details when available

email.render(template, data=None)

Returns: string

Render an email template string with MiniJinja variables.

body = email.render("Hi {{ name }}, welcome!", {"name": "Ada"})

email.render_file(path, data=None)

Returns: Deferred<string>

Load and render a template file asynchronously.

html = email.render_file("templates/welcome.html", {"name": "Ada"})
email.send(to="ada@example.com", subject="Welcome", html=html)

Web Server

WebApp()

Create a web application instance.

app = WebApp()

Route Decorators

Define HTTP endpoints using decorators:

  • @app.get(path)
  • @app.post(path)
  • @app.put(path)
  • @app.patch(path)
  • @app.delete(path)
  • @app.ws(path)
app = WebApp()

@app.get("/")
fn index()
    return {"message": "Welcome to Relay"}

@app.post("/users")
fn create_user(name: str, email: str)
    return {"id": 123, "name": name, "email": email}

Decorator schema options:

  • validate=... - Validate/coerce body payload (JSON first, then form)
  • query=... - Validate/coerce query parameters
  • body=... - Validate/coerce form body
  • json=... - Validate/coerce JSON body
@app.get("/search", query={"limit": "int", "q?": "str"})
fn search(limit, q = None)
    return {"limit": limit, "q": q}

@app.post("/users", json={"name": "str", "age?": "int"})
fn create_user(name, age = None)
    return {"name": name, "age": age}

Route Groups and OpenAPI

Use grouped route prefixes to organize larger APIs:

app = WebApp()
api = app.group("/api")
v1 = api.group("/v1")

@v1.get("/users/<user_id>")
fn get_user(user_id)
    return {"id": user_id}

Enable generated OpenAPI docs:

app.openapi(title="Relay API", version="1.0.0")
// Exposes GET /openapi.json

WebSocket Routes

Use @app.ws(path) for WebSocket upgrade endpoints. WebSocket handlers receive a socket object with:

  • socket.recv() → returns text (str), binary (bytes), or None on close
  • socket.send(value) → sends text (or binary when bytes)
  • socket.close() → closes the connection
@app.ws("/chat")
fn chat_room()
    while True
        msg = socket.recv()
        if (msg == None)
            break
        socket.send("echo: " + str(msg))

Path Parameters

Use <name> syntax to capture path segments:

@app.get("/users/<user_id>")
fn get_user(user_id)
    return {"id": user_id, "name": "Ada"}

@app.get("/posts/<post_id>/comments/<comment_id>")
fn get_comment(post_id, comment_id)
    return {"post": post_id, "comment": comment_id}

Handler Parameters

Handler parameters are automatically bound from:

  1. Path parameters (highest priority)
  2. Request body form fields (application/x-www-form-urlencoded)
  3. Query parameters (lowest priority)
// GET /search?q=relay&limit=10
@app.get("/search")
fn search(q: str, limit: int = 20)
    return {"query": q, "limit": limit}

// POST /users with form body: name=Ada&email=ada@example.com
@app.post("/users")
fn create_user(name: str, email: str)
    return {"name": name, "email": email}

// GET /users/123
@app.get("/users/<user_id>")
fn get_user(user_id)
    return {"id": user_id}

JSON bodies are available as the data parameter (default name) or by typing a handler param as Json.

// POST /events with JSON body {"type":"signup","user":"ada"}
@app.post("/events")
fn create_event(data: Json)
    return {"event_type": data["type"], "user": data["user"]}

For zero-arg handlers (or explicit access), use request helpers: get_query(), get_body(), and get_json().

Middleware

Register middleware with app.use(fn).

Middleware functions can be:

  • fn middleware() (legacy style)
  • fn middleware(ctx) where ctx contains request fields
  • fn middleware(ctx, next) with full chain semantics

If middleware returns a non-None value, Relay short-circuits and sends that response.

next() runs the remainder of the middleware chain and then the handler.

fn audit(ctx, next)
    print("before:", ctx["path"])
    result = next()
    print("after:", ctx["path"])
    return result

Type Hints in Handlers

Use type hints to enforce parameter types and enable automatic coercion:

@app.post("/calculate")
fn calculate(a: int, b: int)
    return {"result": a + b}

// POST /calculate with form body a=5&b=10
// Automatically converts strings to ints: {"result": 15}

Supported types:

  • str - String
  • int - Integer
  • float - Float
  • bool - Boolean
  • json or Json - Full JSON body (for POST/PUT/PATCH)

Request Object

Every handler has access to a request dictionary:

@app.get("/debug")
fn debug_request()
    print(request["method"])    // GET
    print(request["path"])      // /debug
    print(request["request_id"])// rid_...
    print(request["query"])     // Query parameters dict
    print(request["form"])      // Form fields dict (if present)
    print(request["json"])      // JSON body (if present)
    print(request["headers"])   // Headers dict
    print(request["cookies"])   // Cookies dict
    return "OK"

Request fields:

  • method - HTTP method (string)
  • path - Request path (string)
  • request_id - Request identifier string
  • query - Query parameters (dict)
  • form - Parsed form body fields (dict, when present)
    • URL-encoded fields become strings
    • Multipart text fields become strings
    • Multipart file fields become dicts:
      • filename (str or None)
      • content_type (str or None)
      • size (int)
      • bytes (bytes)
  • headers - Request headers (dict)
  • cookies - Cookies (dict)
  • json - Parsed JSON body (if present)

Request Helpers

Use helpers when you want payload access without binding handler parameters:

  • get_query() - Query parameters as a dict (empty dict when unavailable)
  • get_body() - Parsed form fields as a dict (empty dict when unavailable)
  • get_json() - Parsed JSON body (or None when unavailable)
@app.get("/search")
fn search()
    query = get_query()
    return {"q": query["q"]}

@app.post("/submit")
fn submit()
    form = get_body()
    payload = get_json()
    return {"form": form, "json": payload}

Validation Helpers

Use schema helpers for explicit request validation:

  • validate(data, schema) - Validate/coerce an object against schema
  • require_query(schema) - Validate query params in handlers
  • require_body(schema) - Validate form body in handlers
  • require_json(schema) - Validate JSON body in handlers

Schema format:

  • "field": "type" for required fields
  • "field?": "type" for optional fields
  • "field": {"type": "int", "required": True, "default": 10} for explicit rules
@app.get("/search")
fn search()
    params = require_query({"limit": "int", "q?": "str"})
    return {"limit": params["limit"], "q": params["q"]}

Cookies

Access cookies via the cookies dict:

@app.get("/")
fn index()
    user_id = cookies["user_id"]
    return "User ID: " + user_id

Sessions

Relay provides built-in session management with HttpOnly cookies:

@app.get("/login")
fn login(username: str)
    session["user"] = username
    session["logged_in"] = True
    return "Logged in"

@app.get("/profile")
fn profile()
    if (session["logged_in"] == True)
        return "Welcome, " + session["user"]
    else
        return app.redirect("/login")

Session features:

  • Automatically persisted across requests
  • Stored server-side (not in cookies)
  • Uses relay_sid cookie (HttpOnly, SameSite=Lax, Secure automatically on HTTPS)
  • Session data is a dictionary that persists modifications

Customize cookie policy:

app.session(secure=True, http_only=True, same_site="Lax")

Use a custom session backend (for any database/service):

session_db = {}

fn load_session(sid)
    return session_db[sid]

fn save_session(sid, data)
    session_db[sid] = data

app.session_backend(load_session, save_session)

Upload Limits and MIME Allowlist

Configure upload safety controls per app:

app.uploads(
    max_body_bytes=10 * 1024 * 1024,   // default 10 MiB
    max_file_bytes=5 * 1024 * 1024,    // default 5 MiB per file field
    allowed_mime_types=["image/png", "application/pdf"]  // optional
)

Parameters:

  • max_body_bytes - Maximum raw request body size accepted
  • max_file_bytes - Maximum size for each multipart file field
  • allowed_mime_types - Optional MIME allowlist for multipart file fields (None disables allowlist)

When limits are exceeded or a MIME type is disallowed, Relay returns 400 bad_request.

Response Types

Handlers can return various types:

JSON (automatic):

@app.get("/api/user")
fn get_user()
    return {"name": "Ada", "id": 123}  // Auto-serialized to JSON

Plain text:

@app.get("/")
fn index()
    return "Hello, World!"  // Content-Type: text/plain

HTML:

@app.get("/")
fn index()
    return "<h1>Welcome</h1>"  // Content-Type: text/html

Custom Response:

@app.get("/custom")
fn custom()
    return Response(
        {"error": "Not found"},
        status=404,
        content_type="application/json"
    )

Redirect:

@app.post("/old-path")
fn old_endpoint()
    return app.redirect("/new-path")

Response(body, status=200, content_type=None)

Create a custom HTTP response.

@app.get("/xml")
fn get_xml()
    xml = "<root><item>data</item></root>"
    return Response(xml, status=200, content_type="application/xml")

Parameters:

  • body - Response body (string, dict, list, or bytes)
  • status - HTTP status code (default: 200)
  • content_type - Content-Type header (auto-detected if not specified)

HTTPError(status, code, message, details=None)

Create a structured API error response. When called inside a handler, Relay also includes request_id in the error payload.

@app.post("/users")
fn create_user(name)
    if (name == None)
        return HTTPError(400, "validation_error", "Missing name", {"field": "name"})
    return {"ok": True}

Authentication Helpers

auth_hash_password(password)

Returns: password hash string

auth_verify_password(password, hash)

Returns: bool

hash = auth_hash_password("super-secret")
is_valid = auth_verify_password("super-secret", hash)   // True

AuthStore(load_fn=None, save_fn=None)

Create an authentication store.

  • AuthStore() uses in-memory storage.
  • AuthStore(load_fn, save_fn) uses custom callbacks for pluggable backends.

Available methods:

  • store.register(username, password) - hashes and stores password
  • store.verify(username, password) - verifies against stored hash
  • store.get_hash(username) - returns stored hash or None
  • store.set_hash(username, hash) - stores precomputed hash
store = AuthStore()
store.register("ada", "pw")
print(store.verify("ada", "pw"))  // True

auth_db = {}
fn load_user(name)
    return auth_db[name]

fn save_user(name, hash)
    auth_db[name] = hash

custom = AuthStore(load_user, save_user)
custom.register("bob", "pw2")
print(custom.verify("bob", "pw2"))  // True

app.redirect(url)

Returns: Response with 302 status

Create a redirect response.

@app.post("/submit")
fn submit(data)
    // Process data...
    return app.redirect("/success")

Template Rendering (MiniJinja)

Relay uses MiniJinja (Rust implementation of Jinja2) for template interpolation in strings.

Template strings are evaluated anywhere in the interpreter (not only in web handlers) when a string contains both {{ and }}.

name = "Relay"
version = "0.1"

title = "{{ name }} v{{ version }}"
print(title)  // Relay v0.1

items = ["a", "b", "c"]
print("Count: {{ items | length }}")

Web handlers use the same engine:

@app.get("/")
fn index()
    return "<h1>{{ name }} v{{ version }}</h1>"

Templates can reference values in the current scope and support MiniJinja expressions/filters.

Use app.render_template(path, ...kwargs) when you want explicit template rendering from a file without relying on implicit {{ ... }} string evaluation:

app = WebApp()
html = app.render_template("templates/welcome.html", name="Ada", plan="Pro")

WebServer()

Create a web server instance.

server = WebServer()

server.run(app)

Start the web server.

app = WebApp()
server = WebServer()

@app.get("/")
fn index()
    return "Hello, World!"

server.run(app)  // Starts server on 127.0.0.1:8080

Configuration:

  • Default bind address: 127.0.0.1:8080
  • Override with RELAY_BIND environment variable:
    RELAY_BIND=0.0.0.0:8080 relay server.ry

Database (MongoDB)

Mongo(connection_string)

Create a MongoDB client.

mongo = Mongo("mongodb://localhost:27017")

Connection string format:

mongodb://[username:password@]host[:port][/database]

Examples:

// Local MongoDB
mongo = Mongo("mongodb://localhost:27017")

// MongoDB Atlas
mongo = Mongo("mongodb+srv://user:pass@cluster.mongodb.net/")

// With authentication
mongo = Mongo("mongodb://admin:password@localhost:27017")

mongo.db(database_name)

Returns: Database instance

Access a database.

mongo = Mongo("mongodb://localhost:27017")
db = mongo.db("my_app")

db.collection(collection_name)

Returns: Collection instance

Access a collection.

users = db.collection("users")
posts = db.collection("posts")

collection.insert_one(document)

Returns: Deferred<dict> with inserted_id

Insert a single document.

users = db.collection("users")
result = users.insert_one({"name": "Ada", "email": "ada@example.com"})
print(result["inserted_id"])  // ObjectId as string

collection.insert_many(documents)

Returns: Deferred<dict> with inserted_ids

Insert multiple documents.

users = db.collection("users")
docs = [
    {"name": "Ada", "email": "ada@example.com"},
    {"name": "Grace", "email": "grace@example.com"}
]
result = users.insert_many(docs)
print(result["inserted_ids"])  // Dict of index -> ObjectId string

collection.find_one(filter)

Returns: Deferred<dict> or None

Find a single document matching the filter.

users = db.collection("users")
user = users.find_one({"email": "ada@example.com"})
if (user != None)
    print(user["name"])

Filter examples:

// Exact match
user = users.find_one({"name": "Ada"})

// Multiple conditions (implicit AND)
user = users.find_one({"name": "Ada", "active": True})

// By ObjectId
user = users.find_one({"_id": "507f1f77bcf86cd799439011"})

collection.find(filter)

Returns: Deferred<list> of documents

Find all documents matching the filter.

users = db.collection("users")
active_users = users.find({"active": True})
for (user in active_users)
    print(user["name"])

Find all documents:

all_users = users.find({})

collection.update_one(filter, update)

Returns: Deferred<dict> with matched_count and modified_count

Update a single document.

users = db.collection("users")
result = users.update_one(
    {"email": "ada@example.com"},
    {"$set": {"active": True}}
)
print(result["modified_count"])  // 1

Update operators:

// Set fields
users.update_one({"_id": id}, {"$set": {"status": "active"}})

// Increment
users.update_one({"_id": id}, {"$inc": {"login_count": 1}})

// Unset fields
users.update_one({"_id": id}, {"$unset": {"temp_field": ""}})

collection.update_many(filter, update)

Returns: Deferred<dict> with matched_count and modified_count

Update multiple documents.

users = db.collection("users")
result = users.update_many(
    {"active": False},
    {"$set": {"status": "inactive"}}
)
print(result["modified_count"])

collection.delete_one(filter)

Returns: Deferred<dict> with deleted_count

Delete a single document.

users = db.collection("users")
result = users.delete_one({"email": "ada@example.com"})
print(result["deleted_count"])  // 1 or 0

collection.delete_many(filter)

Returns: Deferred<dict> with deleted_count

Delete multiple documents.

users = db.collection("users")
result = users.delete_many({"active": False})
print(result["deleted_count"])  // Number of deleted documents

Examples

1. Hello World (Async)

sleep(2000, print("world"))
print("hello")

Output:

hello
world

2. Simple Web API

app = WebApp()
server = WebServer()

@app.get("/health")
fn health()
    return {"status": "ok", "service": "relay-api"}

@app.get("/users/<user_id>")
fn get_user(user_id)
    return {"id": user_id, "name": "Ada Lovelace"}

server.run(app)

3. Pastebin Service

app = WebApp()
server = WebServer()
mongo = Mongo("mongodb://localhost:27017")
db = mongo.db("pastebin")
pastes = db.collection("pastes")

fn find_paste(paste_id)
    return pastes.find_one({"_id": paste_id})

@app.get("/")
fn index()
    return read_file("examples/static/index.html")

@app.post("/")
fn create_paste(content = None)
    if (content == None)
        return Response("Missing content", status=400)
    result = pastes.insert_one({"content": content})
    paste_id = str(result["inserted_id"])
    return app.redirect("/" + paste_id)

@app.get("/<paste_id>")
fn view_paste(paste_id)
    paste = find_paste(paste_id)
    if (paste == None)
        return Response("Not found", status=404)
    return read_file("examples/static/paste.html")

@app.get("/api/paste/<paste_id>")
fn get_paste(paste_id)
    paste = find_paste(paste_id)
    if (paste == None)
        return Response("Not found", status=404)
    return {"id": paste_id, "content": "{{ paste[\"content\"] }}"}

server.run(app)

Template files used by this example:

  • examples/static/index.html for paste creation form
  • examples/static/paste.html for <pre><code> viewer UI and syntax highlighting

4. Concurrent HTTP Requests

http = Http()

fn fetch_user(user_id)
    resp = http.get("https://api.example.com/users/" + str(user_id))
    return resp.json()

// Fetch 5 users concurrently
tasks = []
i = 1
while (i <= 5)
    tasks =+ [spawn(fetch_user(i))]
    i =+ 1

users = all(tasks)
for (user in users)
    print(user["name"])

5. File Processing Pipeline

fn process_file(filename)
    content = read_file(filename)
    lines = len(content.split("\n"))
    return {"file": filename, "lines": lines}

files = ["data1.txt", "data2.txt", "data3.txt"]
tasks = []
for (f in files)
    tasks =+ [spawn(process_file(f))]

results = all(tasks)
save_json(results, "report.json")
print("Processing complete!")

6. Session-based Authentication

app = WebApp()
server = WebServer()
mongo = Mongo("mongodb://localhost:27017")
db = mongo.db("auth_demo")
users = db.collection("users")

@app.get("/")
fn index()
    if (session["authenticated"] == True)
        return "Welcome, " + session["username"]
    return app.redirect("/login")

@app.post("/login")
fn login(username: str, password: str)
    user = users.find_one({"username": username})
    if (user == None)
        return Response("Invalid credentials", status=401)
    
    // In production, use proper password hashing!
    if (user["password"] == password)
        session["authenticated"] = True
        session["username"] = username
        return app.redirect("/")
    
    return Response("Invalid credentials", status=401)

@app.get("/logout")
fn logout()
    session["authenticated"] = False
    session["username"] = None
    return app.redirect("/login")

server.run(app)

7. REST API with MongoDB

app = WebApp()
server = WebServer()
mongo = Mongo("mongodb://localhost:27017")
db = mongo.db("blog")
posts = db.collection("posts")

@app.get("/posts")
fn list_posts()
    all_posts = posts.find({})
    return all_posts

@app.post("/posts")
fn create_post(title: str, content: str, author: str)
    result = posts.insert_one({
        "title": title,
        "content": content,
        "author": author
    })
    return {"id": str(result["inserted_id"])}

@app.get("/posts/<post_id>")
fn get_post(post_id)
    post = posts.find_one({"_id": post_id})
    if (post == None)
        return Response("Post not found", status=404)
    return post

@app.put("/posts/<post_id>")
fn update_post(post_id, title: str, content: str)
    result = posts.update_one(
        {"_id": post_id},
        {"$set": {"title": title, "content": content}}
    )
    if (result["matched_count"] == 0)
        return Response("Post not found", status=404)
    return {"updated": True}

@app.delete("/posts/<post_id>")
fn delete_post(post_id)
    result = posts.delete_one({"_id": post_id})
    if (result["deleted_count"] == 0)
        return Response("Post not found", status=404)
    return {"deleted": True}

server.run(app)

8. Timeout and Error Handling

http = Http()

fn fetch_with_timeout(url)
    return timeout(http.get(url), 5000)

// Try to fetch with 5 second timeout
result = fetch_with_timeout("https://slow-api.com/data")
print(result.text)

9. Race Condition Example

http = Http()

// Fetch from multiple mirrors, use whichever responds first
mirrors = [
    "https://mirror1.example.com/data",
    "https://mirror2.example.com/data",
    "https://mirror3.example.com/data"
]

tasks = []
for (url in mirrors)
    tasks =+ [spawn(http.get(url))]

fastest = race(tasks)
print("Fastest mirror returned:", fastest.text)

10. Background Task Processing

fn process_item(item)
    sleep(1000, print("Processed: " + str(item)))

items = [1, 2, 3, 4, 5]

// Spawn all tasks without waiting
for (item in items)
    spawn(process_item(item))

print("All tasks started, continuing...")
// Tasks run in background

11. SMTP Welcome Email with Templates

email = Email(
    host="smtp.example.com",
    username="smtp-user",
    password="smtp-pass",
    from="Relay <noreply@example.com>"
)

fn send_welcome(name, to)
    html = email.render(
        "<h1>Welcome {{ name }}</h1><p>Thanks for joining Relay.</p>",
        {"name": name}
    )
    return email.send(
        to=to,
        subject="Welcome to Relay",
        text="Welcome " + name + "!",
        html=html
    )

result = send_welcome("Ada", "ada@example.com")
print("Email queued:", result["ok"])

How It Works

Architecture Overview

Relay is built on:

  • Rust: The interpreter is written in Rust for performance and safety
  • Tokio: Async runtime for non-blocking I/O
  • Axum: High-performance web framework for the built-in server
  • Lettre: Async SMTP transport for built-in email delivery
  • MongoDB driver: Native async MongoDB support

Compilation Pipeline

  1. Lexer: Tokenizes source code with indentation-aware parsing
  2. Parser: Builds an Abstract Syntax Tree (AST)
  3. Evaluator: Interprets the AST with async/await translation

The Async Model in Detail

Deferred Values

When you call an async function, Relay immediately starts the operation and returns a Deferred value:

// This starts the HTTP request immediately
response = http.get("https://api.example.com")
// response is Deferred<Response>

// The request is already in-flight here
print("Request started")

// Only when we access response.status does it wait
print(response.status)  // <-- Blocks here if not complete

Auto-Resolution

Deferred values automatically resolve when:

  1. Used in operations:
x = sleep(1000, 10)
y = x + 5  // Waits for x to resolve
  1. Passed to functions:
result = sleep(1000, 42)
print(result)  // Waits before printing
  1. Used in control flow:
data = http.get("https://api.example.com")
if (data.status == 200)  // Waits before comparison
    print("Success")
  1. Indexed:
resp = http.get("https://api.example.com")
json_data = resp.json()
print(json_data["key"])  // Waits for json() before indexing

Expression Statements

Expression statements (expressions not assigned to variables) run without blocking:

// This starts the sleep but doesn't wait
sleep(1000, print("delayed"))

// This prints immediately
print("immediate")

// Output:
// immediate
// delayed (after 1 second)

Concurrency Model

Relay uses Tokio's work-stealing scheduler to run tasks concurrently:

// Start 3 HTTP requests concurrently
task1 = spawn(http.get("https://api1.com"))
task2 = spawn(http.get("https://api2.com"))
task3 = spawn(http.get("https://api3.com"))

// Wait for all to complete
results = all([task1, task2, task3])

All three requests run in parallel, completing in the time of the slowest request (not 3× the time).

Web Server Architecture

The web server uses Axum's routing system:

  1. Route Registration: Decorators like @app.get("/path") register handlers
  2. Request Handling: Incoming requests are matched against registered routes
  3. Parameter Binding: Path/query/body parameters are extracted and bound to handler parameters
  4. Type Coercion: Type hints trigger automatic type conversion
  5. Response Generation: Return values are automatically serialized to appropriate content types

Session Storage

Sessions are stored server-side in an in-memory hash map:

  • Session ID is generated by Relay at runtime
  • relay_sid cookie stores the session ID (HttpOnly, SameSite=Lax, Secure on HTTPS)
  • Session data persists across requests for the same session ID
  • Sessions are stored in memory (cleared on server restart)

Every web request is also assigned a request_id and echoed as the x-request-id response header.

Note: In production, you'd want to persist sessions to a database.

MongoDB Integration

MongoDB operations return Deferred values that resolve when the database operation completes:

// This starts the query immediately
users = collection.find({"active": True})

// The query is running in the background here
print("Query started")

// Only when we iterate do we wait for results
for (user in users)  // <-- Blocks here
    print(user["name"])

Best Practices

1. Leverage Concurrent Execution

Instead of:

// Sequential (slow)
result1 = http.get("https://api1.com")
result2 = http.get("https://api2.com")
result3 = http.get("https://api3.com")

Do:

// Concurrent (fast)
tasks = [
    spawn(http.get("https://api1.com")),
    spawn(http.get("https://api2.com")),
    spawn(http.get("https://api3.com"))
]
results = all(tasks)

2. Use Type Hints for API Handlers

Type hints provide automatic validation and coercion:

@app.post("/calculate")
fn calculate(a: int, b: int, operation: str = "add")
    if (operation == "add")
        return {"result": a + b}
    else
        return {"result": a - b}

3. Handle Missing Data Gracefully

Always check for None when querying databases or processing optional parameters:

@app.get("/users/<user_id>")
fn get_user(user_id)
    user = users.find_one({"_id": user_id})
    if (user == None)
        return Response("User not found", status=404)
    return user

4. Use Sessions for State Management

Don't try to maintain state in global variables. Use sessions:

// Bad
current_user = None

@app.post("/login")
fn login(username)
    current_user = username  // Won't work across requests

// Good
@app.post("/login")
fn login(username)
    session["user"] = username

5. Implement Timeouts for External Calls

Always add timeouts to external HTTP requests:

fn fetch_data(url)
    return timeout(http.get(url), 10000)  // 10 second timeout

6. Structure Large Applications

Split handlers into logical groups:

app = WebApp()
server = WebServer()

// Auth routes
@app.post("/auth/login")
fn login(username, password)
    // ...

@app.post("/auth/logout")
fn logout()
    // ...

// User routes
@app.get("/users/<user_id>")
fn get_user(user_id)
    // ...

@app.post("/users")
fn create_user(name, email)
    // ...

// Post routes
@app.get("/posts")
fn list_posts()
    // ...

server.run(app)

7. Use Augmented Assignment

For cleaner counter increments:

// Instead of
i = i + 1

// Use
i =+ 1

8. Return Early for Error Cases

Structure handlers with early returns for error cases:

@app.get("/posts/<post_id>")
fn get_post(post_id)
    post = posts.find_one({"_id": post_id})
    if (post == None)
        return Response("Not found", status=404)
    
    if (post["published"] == False)
        return Response("Not published", status=403)
    
    return post

Troubleshooting

Common Errors

"Indentation must be 4 spaces per level"

Cause: Relay requires exactly 4 spaces per indentation level.

Fix: Ensure all indentation uses 4 spaces (not tabs, not 2 spaces).

// Wrong
fn example()
  print("hello")  // 2 spaces

// Right
fn example()
    print("hello")  // 4 spaces

"Tabs are not allowed (spaces only)"

Cause: Relay does not support tabs for indentation.

Fix: Configure your editor to use spaces instead of tabs.

"Type error: Cannot add int and str"

Cause: Attempting to use incompatible types in an operation.

Fix: Use explicit type conversion:

// Wrong
x = 10 + "5"

// Right
x = 10 + int("5")

"Name error: Undefined variable 'x'"

Cause: Using a variable before it's defined.

Fix: Ensure variables are assigned before use:

// Wrong
print(x)
x = 10

// Right
x = 10
print(x)

"Runtime error: Index out of bounds"

Cause: Accessing a list index that doesn't exist.

Fix: Check list length before accessing:

items = [1, 2, 3]
if (len(items) > 5)
    print(items[5])

Debugging Tips

  1. Use print statements: Relay's simplest debugging tool
fn process_data(data)
    print("Processing:", data)  // Debug output
    result = transform(data)
    print("Result:", result)   // Debug output
    return result
  1. Check async resolution: If something seems to hang, check if you're waiting for a Deferred value
// This might hang if the HTTP request never completes
result = http.get("https://unreachable.com")
print(result.status)

// Add a timeout:
result = timeout(http.get("https://unreachable.com"), 5000)
  1. Verify MongoDB connection: Test your connection string in the MongoDB shell first

  2. Check file paths: File operations use paths relative to where you run the relay command

  3. Inspect request objects: Log the request object to debug handler issues

@app.post("/debug")
fn debug()
    print(request)
    return "OK"

Performance Tips

  1. Batch database operations: Use insert_many instead of multiple insert_one calls
// Slow
for (item in items)
    collection.insert_one(item)

// Fast
collection.insert_many(items)
  1. Use spawn for I/O-heavy tasks: Parallelize independent operations
// Serial: 5 seconds total
sleep(1000, "a")
sleep(1000, "b")
sleep(1000, "c")
sleep(1000, "d")
sleep(1000, "e")

// Parallel: 1 second total
all([
    spawn(sleep(1000, "a")),
    spawn(sleep(1000, "b")),
    spawn(sleep(1000, "c")),
    spawn(sleep(1000, "d")),
    spawn(sleep(1000, "e"))
])
  1. Minimize synchronous operations: Keep handlers fast to avoid blocking the event loop

Contributing

Contributions are welcome! Here's how to get started:

Development Setup

# Clone the repo
git clone https://github.com/patx/relay-lang.git
cd relay-lang

# Build in debug mode
cargo build

# Run tests
cargo test

# Run the sample app
cargo run -- examples/type_hint_validation.ry

Adding Features

  1. Lexer changes: Modify the Lexer struct and tokenize() method
  2. Parser changes: Update the Parser struct and AST types
  3. Runtime changes: Modify the Evaluator and install_stdlib() function
  4. Testing: Add runnable .ry scripts and corresponding docs snippets

Coding Standards

  • Follow Rust conventions and rustfmt formatting
  • Add comments for complex logic
  • Core interpreter remains in a single Rust file (v0.1) while Relay scripts support multi-file imports
  • Update this README for any user-facing changes

Reporting Issues

Found a bug? Open an issue with:

  • Relay version
  • Operating system
  • Minimal reproducible example
  • Expected vs. actual behavior

Roadmap

v0.2 (Planned):

  • Multiple file support and imports
  • List comprehensions
  • Destructuring assignment
  • Error handling with try/except
  • WebSocket support
  • Static file serving
  • Middleware support

v0.3 (Future):

  • Package manager
  • Standard library expansion
  • SQL database support (PostgreSQL, SQLite)
  • Redis integration
  • Interactive REPL with syntax highlighting
  • Web framework improvements (routing groups, validation)
  • Worker processes for CPU-heavy tasks

Known Gaps (Identified in v0.1):

  • JSON request key binding to scalar handler args (e.g. bind {"name":"Ada"} directly to fn create(name)).
  • HTTP client parity for put, patch, delete, request headers, and response header access.
  • First-class CLI flags (--help, --version) for better install verification and discoverability.
  • Built-in HTML escaping helper for safely rendering user content directly in server-side templates.

License

MIT License

Copyright (c) 2026 Harrison Erd

Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:

The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.

THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.

Support

  • Documentation: This README
  • Examples: examples/ (start with examples/type_hint_validation.ry and examples/pastebin.ry)
  • Issues: GitHub Issues
  • Discussions: GitHub Discussions

About

Very Experimental Programming Language with Python-like syntax and Node-style non-blocking semantics (annd a lot of vibes)

Resources

License

Stars

Watchers

Forks

Releases

No releases published

Packages

 
 
 

Contributors

Languages