Docs

FFI guide

Call Proa's SSR engine from Python, Ruby, Go, or any language with C FFI support

Open Markdown

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-playground
run.sh
ffi/
libproa_demo.dylib
proa.h
FileDescription
proa-playgroundInteractive demo server
run.shLaunch script (run.bat on Windows)
libproa_demo.dylibFFI shared library (.so on Linux, .dll on Windows)
proa.hC header
DockerfileAdd 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

FunctionDescription
render_airbnb_homeAirbnb home page
render_airbnb_searchAirbnb search results
render_airbnb_listingAirbnb listing detail
render_airbnb_listing_jsonAirbnb listing with custom props
render_nike_catalogFootwear catalog, the Nike showcase
render_nike_catalog_jsonFootwear catalog with custom props
render_nike_pdpFootwear product detail, the Nike showcase
render_nike_pdp_jsonFootwear product detail with custom props
render_*_intoCaller-provided-buffer variants for default-prop pages
proa_freeFree a buffer from any allocating render

Contract Pages

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:

Search

Type at least 2 characters