Docs
FFI guide
Call Proa's SSR engine from Python, Ruby, Go, or any language with C FFI support
Proa compiles HTML templates into optimized byte sequences at build time. The playground FFI library exposes selected showcase renders as plain C functions so Python, Ruby, Go, C, and other host runtimes can call them without embedding a JavaScript SSR server.
What's in the package
proa-playground-<target>/proa-playgroundrun.shffi/libproa_demo.dylibproa.h| File | Description |
|---|---|
proa-playground | Interactive demo server |
run.sh | Launch script (run.bat on Windows) |
libproa_demo.dylib | FFI shared library (.so on Linux, .dll on Windows) |
proa.h | C header |
Dockerfile | Add the Packaging example for a Linux container |
Docker
Use the Linux x86_64 download, even when building on macOS or Windows. Add the Dockerfile from Packaging to the extracted Linux package; that archive currently omits it. Then run:
docker build --platform linux/amd64 -t proa/playground .
docker run --rm --platform linux/amd64 -p 127.0.0.1:3001:3001 proa/playground
Open http://localhost:3001. The image includes Node and Bun for the comparison
engines. A native macOS or Windows executable cannot run in this Linux image.
API at a glance
Every render function follows one of two patterns.
Allocating
The library allocates the output buffer. You must free it with proa_free.
int32_t render_airbnb_home(uint8_t **out_ptr, uintptr_t *out_len);
void proa_free(uint8_t *ptr, uintptr_t len);
Returns 0 on success. The HTML is UTF-8 encoded at *out_ptr with length *out_len.
Caller-provided buffer
You provide a pre-allocated output buffer and the library copies the rendered HTML into it.
int32_t render_airbnb_home_into(uint8_t *buf_ptr, uintptr_t buf_cap, uintptr_t *out_len);
Returns 0 on success, -1 if the buffer is too small. When the buffer is too small, *out_len receives the required size.
The current demo _into functions avoid returning a new FFI-owned allocation, which is useful for host memory management. They are not a guarantee that the demo implementation performs no internal Rust allocation.
JSON props
Some functions accept custom data as a JSON string:
int32_t render_airbnb_listing_json(
const uint8_t *json_ptr, uintptr_t json_len,
uint8_t **out_ptr, uintptr_t *out_len
);
Returns 0 on success, -2 if the JSON is invalid.
Available functions
| Function | Description |
|---|---|
render_airbnb_home | Airbnb home page |
render_airbnb_search | Airbnb search results |
render_airbnb_listing | Airbnb listing detail |
render_airbnb_listing_json | Airbnb listing with custom props |
render_nike_catalog | Footwear catalog, the Nike showcase |
render_nike_catalog_json | Footwear catalog with custom props |
render_nike_pdp | Footwear product detail, the Nike showcase |
render_nike_pdp_json | Footwear product detail with custom props |
render_*_into | Caller-provided-buffer variants for default-prop pages |
proa_free | Free a buffer from any allocating render |
Contract Pages
- Playground covers the interactive demo, load testing UI, package contents, and API endpoints.
- ABI contract documents exact signatures, return codes, and WASM helpers.
- Memory ownership explains when to call
proa_free, how to size buffers, and what the host owns. - Deployment covers shared-library loading, Docker packaging, and build outputs.
Python
Minimal example
import ctypes
import time
lib = ctypes.CDLL("./ffi/libproa_demo.dylib") # .so on Linux, .dll on Windows
lib.render_airbnb_home.restype = ctypes.c_int32
lib.render_airbnb_home.argtypes = [
ctypes.POINTER(ctypes.POINTER(ctypes.c_uint8)),
ctypes.POINTER(ctypes.c_size_t),
]
lib.proa_free.restype = None
lib.proa_free.argtypes = [ctypes.POINTER(ctypes.c_uint8), ctypes.c_size_t]
ptr = ctypes.POINTER(ctypes.c_uint8)()
length = ctypes.c_size_t(0)
start = time.perf_counter_ns()
rc = lib.render_airbnb_home(ctypes.byref(ptr), ctypes.byref(length))
if rc != 0:
raise RuntimeError(f"render_airbnb_home failed: {rc}")
elapsed_us = (time.perf_counter_ns() - start) / 1000
try:
html = ctypes.string_at(ptr, length.value).decode("utf-8")
finally:
lib.proa_free(ptr, length)
print(f"Rendered {length.value:,} bytes in {elapsed_us:.1f} µs")
Reusable wrapper
import ctypes
import json as json_mod
class Proa:
"""Thin wrapper around the Proa FFI library."""
def __init__(self, lib_path="./ffi/libproa_demo.dylib"):
self._lib = ctypes.CDLL(lib_path)
self._lib.proa_free.restype = None
self._lib.proa_free.argtypes = [
ctypes.POINTER(ctypes.c_uint8), ctypes.c_size_t
]
# Register allocating render functions
for name in [
"render_airbnb_home", "render_airbnb_search",
"render_airbnb_listing", "render_nike_catalog", "render_nike_pdp",
]:
fn = getattr(self._lib, name)
fn.restype = ctypes.c_int32
fn.argtypes = [
ctypes.POINTER(ctypes.POINTER(ctypes.c_uint8)),
ctypes.POINTER(ctypes.c_size_t),
]
# Register JSON render functions
for name in [
"render_airbnb_listing_json",
"render_nike_pdp_json",
"render_nike_catalog_json",
]:
fn = getattr(self._lib, name)
fn.restype = ctypes.c_int32
fn.argtypes = [
ctypes.POINTER(ctypes.c_uint8), ctypes.c_size_t,
ctypes.POINTER(ctypes.POINTER(ctypes.c_uint8)),
ctypes.POINTER(ctypes.c_size_t),
]
def _render(self, func_name):
fn = getattr(self._lib, func_name)
ptr = ctypes.POINTER(ctypes.c_uint8)()
length = ctypes.c_size_t(0)
rc = fn(ctypes.byref(ptr), ctypes.byref(length))
if rc != 0:
raise RuntimeError(f"{func_name} failed: {rc}")
try:
return ctypes.string_at(ptr, length.value).decode("utf-8")
finally:
self._lib.proa_free(ptr, length)
def _render_json(self, func_name, json_str):
fn = getattr(self._lib, func_name)
json_bytes = json_str.encode("utf-8")
json_ptr = (ctypes.c_uint8 * len(json_bytes))(*json_bytes)
ptr = ctypes.POINTER(ctypes.c_uint8)()
length = ctypes.c_size_t(0)
rc = fn(json_ptr, len(json_bytes), ctypes.byref(ptr), ctypes.byref(length))
if rc == -2:
raise ValueError("Invalid JSON")
if rc != 0:
raise RuntimeError(f"{func_name} failed: {rc}")
try:
return ctypes.string_at(ptr, length.value).decode("utf-8")
finally:
self._lib.proa_free(ptr, length)
def airbnb_home(self):
return self._render("render_airbnb_home")
def airbnb_search(self):
return self._render("render_airbnb_search")
def airbnb_listing(self, json=None):
if json is not None:
return self._render_json("render_airbnb_listing_json", json)
return self._render("render_airbnb_listing")
def nike_catalog(self, json=None):
if json is not None:
return self._render_json("render_nike_catalog_json", json)
return self._render("render_nike_catalog")
def nike_pdp(self, json=None):
if json is not None:
return self._render_json("render_nike_pdp_json", json)
return self._render("render_nike_pdp")
Usage:
from pathlib import Path
proa = Proa()
# Static render — default props
html = proa.airbnb_home()
# Custom props via JSON
defaults = json_mod.loads(Path("showcase-props.json").read_text())
listing = defaults["listing"].copy()
listing["title"] = "My Beach House"
listing["nightly_rate"] = 350
html = proa.airbnb_listing(json_mod.dumps(listing))
Caller-provided buffer from Python
To avoid accepting an FFI-owned output allocation, pre-allocate a buffer and reuse it across renders:
buf = (ctypes.c_uint8 * (256 * 1024))() # Starting capacity; handle -1 below
written = ctypes.c_size_t(0)
lib.render_airbnb_home_into.restype = ctypes.c_int32
lib.render_airbnb_home_into.argtypes = [
ctypes.POINTER(ctypes.c_uint8), ctypes.c_size_t, ctypes.POINTER(ctypes.c_size_t)
]
rc = lib.render_airbnb_home_into(buf, len(buf), ctypes.byref(written))
if rc == 0:
html = bytes(buf[:written.value]).decode("utf-8")
elif rc == -1:
print(f"Buffer too small, need {written.value} bytes")
Ruby
require 'fiddle'
require 'fiddle/import'
module Proa
extend Fiddle::Importer
dlload './ffi/libproa_demo.dylib' # .so on Linux
extern 'int render_airbnb_home(void**, size_t*)'
extern 'void proa_free(void*, size_t)'
end
ptr = Fiddle::Pointer.new(0)
len = [0].pack('J')
ptr_ref = Fiddle::Pointer[ptr.ref]
len_ref = Fiddle::Pointer[len]
start = Process.clock_gettime(Process::CLOCK_MONOTONIC, :microsecond)
rc = Proa.render_airbnb_home(ptr_ref, len_ref)
raise "render_airbnb_home failed: #{rc}" unless rc == 0
elapsed = Process.clock_gettime(Process::CLOCK_MONOTONIC, :microsecond) - start
size = len.unpack1('J')
output = Fiddle::Pointer.new(ptr_ref.ptr.to_i)
begin
html = output.to_str(size).force_encoding('UTF-8')
ensure
Proa.proa_free(output, size)
end
puts "Rendered #{html.bytesize} bytes in #{elapsed} µs"
C
#include <stdio.h>
#include "ffi/proa.h"
int main(void) {
uint8_t *ptr;
size_t len;
// Allocating variant
if (render_airbnb_home(&ptr, &len) == 0) {
printf("Rendered %zu bytes\n", len);
proa_free(ptr, len);
}
// Caller-provided buffer variant: reuse a host buffer across requests
uint8_t buf[256 * 1024];
size_t written;
if (render_airbnb_home_into(buf, sizeof(buf), &written) == 0) {
printf("Rendered %zu bytes into caller buffer\n", written);
}
return 0;
}
Compile and run:
# macOS: normalize the current archive's build-machine install name first.
install_name_tool -id @rpath/libproa_demo.dylib ./ffi/libproa_demo.dylib
cc -o demo demo.c -L./ffi -lproa_demo -Wl,-rpath,@loader_path/ffi
# Linux
cc -o demo demo.c -L./ffi -lproa_demo -Wl,-rpath,'$ORIGIN/ffi'
Go
On macOS, run the install_name_tool repair in the C example before linking.
The linker paths below resolve from the Go source directory. See
Dynamic loader paths for details.
package main
/*
#cgo LDFLAGS: -L${SRCDIR}/ffi -lproa_demo
#cgo darwin LDFLAGS: -Wl,-rpath,${SRCDIR}/ffi
#cgo linux LDFLAGS: -Wl,-rpath,${SRCDIR}/ffi
#include "ffi/proa.h"
#include <stdlib.h>
*/
import "C"
import (
"fmt"
"time"
"unsafe"
)
func renderAirbnbHome() (string, error) {
var ptr *C.uint8_t
var length C.uintptr_t
rc := C.render_airbnb_home(&ptr, &length)
if rc != 0 {
return "", fmt.Errorf("render_airbnb_home failed: %d", rc)
}
defer C.proa_free(ptr, length)
bytes := unsafe.Slice((*byte)(unsafe.Pointer(ptr)), int(length))
return string(bytes), nil
}
func main() {
start := time.Now()
html, err := renderAirbnbHome()
if err != nil {
panic(err)
}
elapsed := time.Since(start)
fmt.Printf("Rendered %d bytes in %v\n", len(html), elapsed)
}
Measuring an integration
Measure your exact library revision, props, build profile, machine, and host runtime. Keep rendering, JSON decoding, FFI calls, output copying, and deallocation visible as separate costs. The Python example times the allocating FFI call with a wall clock; its copy and UTF-8 decode happen afterward.
Verify the output before timing. Compare allocating and caller-buffer exports with the same inputs, warm up the process, and report repeated samples. The demo's _into exports reuse host output storage but still render an intermediate Rust String, so they do not establish allocation-free rendering.
JSON prop schemas
The _json variants accept JSON matching the structures in showcase-props.json (included in the playground download). Full default props for each page:
- Airbnb Home: render_airbnb_home
- Airbnb Search: render_airbnb_search
- Airbnb Listing: render_airbnb_listing_json
- Footwear Catalog: render_nike_catalog_json
- Footwear Product Detail: render_nike_pdp_json