---
title: FFI guide
description: 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-filetree
proa-playground-<target>/
  proa-playground
  run.sh
  ffi/
    libproa_demo.dylib
    proa.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](/docs/ffi/packaging#docker) for a Linux container |

## Docker

Use the **Linux x86_64 download**, even when building on macOS or Windows.
Add the [Dockerfile from Packaging](/docs/ffi/packaging#docker) to the extracted
Linux package; that archive currently omits it. Then run:

```bash
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`.

```c
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.

```c
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:

```c
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](/docs/ffi/playground) covers the interactive demo, load testing UI, package contents, and API endpoints.
- [ABI contract](/docs/ffi/abi) documents exact signatures, return codes, and WASM helpers.
- [Memory ownership](/docs/ffi/memory) explains when to call `proa_free`, how to size buffers, and what the host owns.
- [Deployment](/docs/ffi/packaging) covers shared-library loading, Docker packaging, and build outputs.

## Python

### Minimal example

```python
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

```python
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:

```python
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:

```python
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

```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

```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:

```bash
# 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](/docs/ffi/packaging#dynamic-loader-paths) for details.

```go
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](/docs/ffi/examples#travel-home): render_airbnb_home
- [Airbnb Search](/docs/ffi/examples#travel-search): render_airbnb_search
- [Airbnb Listing](/docs/ffi/examples#travel-listing): render_airbnb_listing_json
- [Footwear Catalog](/docs/ffi/examples#catalog): render_nike_catalog_json
- [Footwear Product Detail](/docs/ffi/examples#product-detail): render_nike_pdp_json

