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.
- Features
- Quick Start
- Example Prereqs
- Installation
- Learning Path
- Language Guide
- API Reference
- Examples
- How It Works
- Best Practices
- Troubleshooting
- Contributing
- 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
# 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# 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 | 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 |
print("Hello, Relay!")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.
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.
- Rust 1.70 or later
- Cargo (included with Rust)
# 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.ryIf you're developing the Relay compiler itself:
cargo run -- path/to/file.ryRELAY_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.ryIf 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.
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 commentRelay supports loading code from multiple .ry files with import:
import utils
import web.routes
import shared/helpers.ryHow imports resolve:
import utilsloadsutils.ryimport web.routesloadsweb/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.
// 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 = NoneLists:
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] = 10Dictionaries:
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 typeImportant: Dictionary keys are automatically stringified. {1: "value"} becomes {"1": "value"}.
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.
if (condition)
// then block
print("condition is true")if (x > 0)
print("positive")
else
print("zero or negative")i = 0
while (i < 5)
print(i)
i = i + 1Augmented assignment operators are supported:
i = 0
while (i < 5)
print(i)
i =+ 1 // equivalent to i = i + 1// 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.
i = 0
while (i < 6)
i =+ 1
if (i == 2)
continue
if (i == 5)
break
print(i)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)fn greet(name)
return "Hello, " + name
message = greet("World")
print(message) // Hello, Worldfn greet(name: str = "World")
return "Hello, " + name
print(greet()) // Hello, World
print(greet("Relay")) // Hello, RelayRelay 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: Stringint: Integerfloat: Floatbool: BooleanjsonorJson: JSON object
fn process_data(data: json)
return data["key"]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...")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.
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
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 30Both sleeps start at the same time, so this takes ~1 second, not 2.
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")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)Print values to stdout. Multiple arguments are printed space-separated.
print("Hello") // Hello
print("x =", 42) // x = 42
print("a", "b", "c") // a b cConvert any value to a string.
str(123) // "123"
str(3.14) // "3.14"
str(True) // "True"
str([1, 2, 3]) // "[1, 2, 3]"Convert a value to an integer.
int("42") // 42
int(3.9) // 3
int(True) // 1
int("invalid") // Runtime errorConvert a value to a float.
float("3.14") // 3.14
float(42) // 42.0
float("2.5e3") // 2500.0Returns: 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 = 42Returns: 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
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]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 firstReturns: Deferred<value> or timeout error
Add a timeout to any async operation.
result = timeout(http.get("https://slow.com"), 5000) // 5 second timeoutIf the timeout is exceeded, a runtime error is raised.
Cancel a running task.
task = spawn(long_running_operation())
cancel(task)Returns: Deferred<string>
Read a file's contents as a UTF-8 string.
content = read_file("data.txt")
print(content)Returns: Deferred<None>
Write a string to a file.
save_file("Hello, World!", "output.txt")Returns: Deferred<dict>
Read and parse a JSON file.
data = read_json("config.json")
print(data["api_key"])Returns: Deferred<None>
Serialize data to JSON and write to a file.
config = {"host": "localhost", "port": 8080}
save_json(config, "config.json")Create an HTTP client instance.
http = Http()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 stringReturns: 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"}
)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"]) // octocatCreate 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,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 <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. 250Attachment dict format:
filename(required) - attachment file namebytesorcontentordata(required) - file payload (bytes,str, orlist[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- alwaysTrueon successtransport-"smtp"host/port- resolved transport targetmessage_id- message id when available, elseNoneenvelope_to- recipient list used for SMTP envelopesmtp_code/smtp_message- provider response details when available
Returns: string
Render an email template string with MiniJinja variables.
body = email.render("Hi {{ name }}, welcome!", {"name": "Ada"})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)Create a web application instance.
app = WebApp()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 parametersbody=...- Validate/coerce form bodyjson=...- 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}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.jsonUse @app.ws(path) for WebSocket upgrade endpoints.
WebSocket handlers receive a socket object with:
socket.recv()→ returns text (str), binary (bytes), orNoneon closesocket.send(value)→ sends text (or binary whenbytes)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))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 are automatically bound from:
- Path parameters (highest priority)
- Request body form fields (
application/x-www-form-urlencoded) - 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().
Register middleware with app.use(fn).
Middleware functions can be:
fn middleware()(legacy style)fn middleware(ctx)wherectxcontains request fieldsfn 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 resultUse 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- Stringint- Integerfloat- Floatbool- BooleanjsonorJson- Full JSON body (for POST/PUT/PATCH)
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 stringquery- 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(strorNone)content_type(strorNone)size(int)bytes(bytes)
headers- Request headers (dict)cookies- Cookies (dict)json- Parsed JSON body (if present)
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}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"]}Access cookies via the cookies dict:
@app.get("/")
fn index()
user_id = cookies["user_id"]
return "User ID: " + user_idRelay 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_sidcookie (HttpOnly,SameSite=Lax,Secureautomatically 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)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 acceptedmax_file_bytes- Maximum size for each multipart file fieldallowed_mime_types- Optional MIME allowlist for multipart file fields (Nonedisables allowlist)
When limits are exceeded or a MIME type is disallowed, Relay returns 400 bad_request.
Handlers can return various types:
JSON (automatic):
@app.get("/api/user")
fn get_user()
return {"name": "Ada", "id": 123} // Auto-serialized to JSONPlain text:
@app.get("/")
fn index()
return "Hello, World!" // Content-Type: text/plainHTML:
@app.get("/")
fn index()
return "<h1>Welcome</h1>" // Content-Type: text/htmlCustom 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")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)
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}Returns: password hash string
Returns: bool
hash = auth_hash_password("super-secret")
is_valid = auth_verify_password("super-secret", hash) // TrueCreate 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 passwordstore.verify(username, password)- verifies against stored hashstore.get_hash(username)- returns stored hash orNonestore.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")) // TrueReturns: Response with 302 status
Create a redirect response.
@app.post("/submit")
fn submit(data)
// Process data...
return app.redirect("/success")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")Create a web server instance.
server = WebServer()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:8080Configuration:
- Default bind address:
127.0.0.1:8080 - Override with
RELAY_BINDenvironment variable:RELAY_BIND=0.0.0.0:8080 relay server.ry
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")Returns: Database instance
Access a database.
mongo = Mongo("mongodb://localhost:27017")
db = mongo.db("my_app")Returns: Collection instance
Access a collection.
users = db.collection("users")
posts = db.collection("posts")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 stringReturns: 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 stringReturns: 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"})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({})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"]) // 1Update 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": ""}})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"])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 0Returns: 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 documentssleep(2000, print("world"))
print("hello")Output:
hello
world
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)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.htmlfor paste creation formexamples/static/paste.htmlfor<pre><code>viewer UI and syntax highlighting
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"])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!")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)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)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)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)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 backgroundemail = 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"])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
- Lexer: Tokenizes source code with indentation-aware parsing
- Parser: Builds an Abstract Syntax Tree (AST)
- Evaluator: Interprets the AST with async/await translation
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 completeDeferred 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 indexingExpression 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)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).
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
Sessions are stored server-side in an in-memory hash map:
- Session ID is generated by Relay at runtime
relay_sidcookie stores the session ID (HttpOnly,SameSite=Lax,Secureon 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 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"])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)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}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 userDon'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"] = usernameAlways add timeouts to external HTTP requests:
fn fetch_data(url)
return timeout(http.get(url), 10000) // 10 second timeoutSplit 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)For cleaner counter increments:
// Instead of
i = i + 1
// Use
i =+ 1Structure 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 postCause: 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 spacesCause: Relay does not support tabs for indentation.
Fix: Configure your editor to use spaces instead of tabs.
Cause: Attempting to use incompatible types in an operation.
Fix: Use explicit type conversion:
// Wrong
x = 10 + "5"
// Right
x = 10 + int("5")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)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])- 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"- 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
Contributions are welcome! Here's how to get started:
# 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- 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
- 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
Found a bug? Open an issue with:
- Relay version
- Operating system
- Minimal reproducible example
- Expected vs. actual behavior
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 tofn 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.
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.
- Documentation: This README
- Examples:
examples/(start withexamples/type_hint_validation.ryandexamples/pastebin.ry) - Issues: GitHub Issues
- Discussions: GitHub Discussions