patx/relay-lang
An experimental async web first programming language.
$ git clone https://gitman.io/git/patx/relay-lang
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
- Quick Start
- Example Prereqs
- Installation
- Learning Path
- Language Guide
- Syntax Fundamentals
- Data Types
- Control Flow
- Functions
- Async Model
- API Reference
- Core Functions
- Async & Concurrency
- File System
- HTTP Client
- Email (SMTP)
- Web Server
- Database (MongoDB)
- Examples
- How It Works
- Best Practices
- Troubleshooting
- Contributing
Features
- Implicit Async: No
awaitkeyword 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/deleteand request/response headers - Email (SMTP): Async SMTP mail sending with secure-by-default TLS and template helpers
- Multipart Uploads: Parse
multipart/form-databodies 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_idin handler context andx-request-idresponse 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/exceptblocks for graceful runtime error recovery - Concurrency Primitives:
spawn,all,race,timeout, andcancelfor 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:
- Start with Quick Start to run your first script and web app.
- Work through Language Guide for syntax, types, control flow, modules, and async behavior.
- Use API Reference as the complete built-in/runtime reference.
- Run scripts in
examples/to see complete services and integrations. - 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:
- Used in an expression:
result = deferred_value + 10
- Passed to a function:
print(deferred_value)
- Returned from a function:
fn get_data()
return http.get("https://api.example.com")
- 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": "[email protected]"}
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 stringresp.json()- Parse response body as JSONresp.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 <[email protected]>",
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,bccacceptstrorlist[str]- at least one of
textorhtmlis required fromcan be passed per-call or configured inEmail(...)attachmentsaccepts a single attachment dict or list of attachment dicts
email = Email(
host="smtp.example.com",
username="smtp-user",
password="smtp-pass",
from="Relay <[email protected]>"
)
result = email.send(
to=["[email protected]"],
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="[email protected]",
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="[email protected]", 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 protected]
@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 (orNonewhen 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 schemarequire_query(schema)- Validate query params in handlersrequire_body(schema)- Validate form body in handlersrequire_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:
bash
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:[email protected]/")
// 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": "[email protected]"})
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": "[email protected]"},
{"name": "Grace", "email": "[email protected]"}
]
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": "[email protected]"})
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": "[email protected]"},
{"$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": "[email protected]"})
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 <[email protected]>"
)
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", "[email protected]")
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
- Lexer: Tokenizes source code with indentation-aware parsing
- Parser: Builds an Abstract Syntax Tree (AST)
- 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:
- Used in operations:
x = sleep(1000, 10)
y = x + 5 // Waits for x to resolve
- Passed to functions:
result = sleep(1000, 42)
print(result) // Waits before printing
- Used in control flow:
data = http.get("https://api.example.com")
if (data.status == 200) // Waits before comparison
print("Success")
- 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:
- Route Registration: Decorators like
@app.get("/path")register handlers - Request Handling: Incoming requests are matched against registered routes
- Parameter Binding: Path/query/body parameters are extracted and bound to handler parameters
- Type Coercion: Type hints trigger automatic type conversion
- 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
- 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
- Check async resolution: If something seems to hang, check if you're waiting for a
Deferredvalue
// 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)
-
Verify MongoDB connection: Test your connection string in the MongoDB shell first
-
Check file paths: File operations use paths relative to where you run the
relaycommand -
Inspect request objects: Log the request object to debug handler issues
@app.post("/debug")
fn debug()
print(request)
return "OK"
Performance Tips
- Batch database operations: Use
insert_manyinstead of multipleinsert_onecalls
// Slow
for (item in items)
collection.insert_one(item)
// Fast
collection.insert_many(items)
- Use
spawnfor 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"))
])
- 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
- Lexer changes: Modify the
Lexerstruct andtokenize()method - Parser changes: Update the
Parserstruct and AST types - Runtime changes: Modify the
Evaluatorandinstall_stdlib()function - Testing: Add runnable
.ryscripts and corresponding docs snippets
Coding Standards
- Follow Rust conventions and
rustfmtformatting - 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): - [x] Multiple file support and imports - [x] List comprehensions - [x] Destructuring assignment - [x] Error handling with try/except - [x] WebSocket support - [X] Static file serving - [X] 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)).
- [x] 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 withexamples/type_hint_validation.ryandexamples/pastebin.ry) - Issues: GitHub Issues
- Discussions: GitHub Discussions