# fastfn Parte 1: Eu Tinha um Problema (e Introduzi Lua na Minha Vida)


## Capítulo 1: A Dor, ou "Por Que um Hello World Tem Sete Arquivos?"

A coisa toda começou com uma reclamação que é quase constrangedor dizer em voz alta em 2026: eu queria largar um arquivo Python no disco e que ele fosse um endpoint HTTP. Só isso. Sem Dockerfile. Sem `requirements.txt` a menos que eu quisesse um. Sem boilerplate de `app = FastAPI()`, sem invocação de `uvicorn`, sem assistente de "criar um projeto" que deixa para trás dezessete arquivos de configuração que vou passar as próximas três tardes deletando. E — esta é a parte em que fiquei mais exigente — eu queria um cold start razoável. Não Lambda-cold, em que a primeira requisição da manhã parece um 404 com passos extras. Mornozinho. Em escala humana.

Eu estava cansado da forma dos frameworks web modernos. Eles são lindos, escalam, têm ecossistemas, e também pedem que você compile mentalmente um modelo mental inteiro do mundo deles antes que sua primeira rota responda com `{"hello": "world"}`. Para uma ferramenta interna descartável, isso é um imposto que você paga em atenção. Eu vinha pagando esse imposto havia uma década. Eu queria parar.

A outra coisa que eu queria — e este é o recurso que silenciosamente conduz o resto da história — era **poliglota por padrão**. Não poliglota no sentido de "microsserviços em linguagens diferentes conversando por gRPC". Poliglota no sentido de que a mesma árvore de URLs pode ter `get.users.py`, `post.orders.js` e `get.health.go`, lado a lado na mesma pasta, atrás do mesmo gateway. Roteamento baseado em arquivos do Next.js para handlers baseados em arquivos, agnóstico de runtime. Esse era o sonho.

Então o alvo estava claro: uma coisa Function-as-a-Service, mas local-first, com uma CLI como interface principal, e uma árvore de arquivos como banco de dados. Eu o chamei de `fastfn`. O README descreve a ambição em uma linha: "Start with one file, a friendly CLI, and a route tree that can grow into a real API or SPA without a rewrite later" (`README.md:8`). O resto deste post é a história de como essa única frase se transformou em um gateway em Lua, um daemon Python persistente e um protocolo de fio com um prefixo de comprimento de 4 bytes. É uma história sobre descobrir, lentamente e com algum constrangimento, que eu estava reinventando o FastCGI.

## Capítulo 2: Uma Breve, Ligeiramente Injusta História do CGI

Antes de chegar ao que `fastfn` é, tenho que falar sobre com o que ele rima.

### CGI: o serverless original

No início havia o CGI. O Common Gateway Interface era serverless antes de serverless ser uma marca. Você colocava um script em `/cgi-bin/`, o servidor web fazia `fork` e `exec` dele a cada requisição, ele lia a requisição de variáveis de ambiente e do stdin, escrevia a resposta no stdout e saía. O SO limpava tudo. Toda requisição era um processo. Todo processo era um universo que existia por 40 milissegundos e então morria.

Isso é maravilhoso. Isso também é um crime de desempenho.

```
CGI request model (what dies for you every time)

  client                 web server                 /cgi-bin/hello.pl
    |   HTTP GET /hello     |                            .
    | --------------------> | fork()                     .
    |                       | exec("perl hello.pl")  --> [ cold Perl VM ]
    |                       |                            [ parse script ]
    |                       |                            [ run handler ]
    |                       | <-- stdout --              [ die ]
    | <-- HTTP 200 --       |                            .
    |                       |                            .
```

Toda requisição paga pela criação do processo, aquecimento do interpretador, importação de bibliotecas e desmontagem. Numa máquina de 1996 isso doía. Numa máquina de 2026 ainda dói, só que de forma diferente: o Python cache-cold gasta uma fatia não trivial de tempo só importando sua própria biblioteca padrão antes que seu handler escreva um único byte. Eu não fiz o profiling disso de ponta a ponta especificamente para o `fastfn`, mas a ordem de grandeza é grande o suficiente para que o pool persistente no estilo FastCGI exista precisamente para amortizá-la.

### FastCGI: a correção, e a forma que acabei copiando

O FastCGI foi inventado para corrigir exatamente isso. A ideia é quase óbvia em retrospecto: não mate o handler depois de cada requisição. Mantenha um pequeno pool de processos handler vivos, deixe o servidor web conversar com eles por um socket Unix, e enquadre as requisições para poder multiplexar de forma limpa. O servidor web é o front-end; o pool de handlers é o back-end; entre eles flui um fluxo de registros prefixados por comprimento.

```
FastCGI request model (what doesn't die for you)

  client         web server           unix socket          handler pool
    |  HTTP GET    |                       |                    .
    | -----------> | pack record           |                    .
    |              | -------- [len|payload] ------------>       [ already warm ]
    |              |                       |                    [ run handler ]
    |              | <------- [len|payload] ------------        [ sleep ]
    | <--- HTTP -- |                       |                    .
```

Os handlers são de vida longa. O interpretador está quente. O estado global do seu handler sobrevive entre requisições (para o bem e para o mal). O transporte é um socket entediante com registros enquadrados. Você troca o isolamento processo-por-requisição por algo mais próximo de uma chamada-pela-rede. É uma troca muito boa e é essencialmente o que os servidores WSGI modernos do Python, o PHP-FPM e — sejamos honestos — o pool de warm-start do AWS Lambda estão todos fazendo internamente.

Eu não me propus a construir um FastCGI. Me propus a construir uma coisa serverless em que você larga um arquivo e ele vira uma rota. Acontece que, uma vez que você quer warm starts e handlers poliglotas e uma árvore de rotas endereçável por sistema de arquivos, o espaço de design o afunila em direção a algo que se parece estranhamente com o FastCGI vestindo um sobretudo de JSON. Mais sobre o sobretudo depois.

## Capítulo 3: Eu Tinha um Problema… e Introduzi Lua na Minha Vida

Aqui está a parte em que o título faz sentido.

O gateway — a peça que termina o HTTP, lê a requisição, descobre qual arquivo no disco deve tratá-la, e encaminha a chamada — é a peça mais importante e mais irritante de qualquer FaaS. Ele tem que ser rápido. Tem que fazer hot-reload quando você salva um arquivo. Tem que fazer roteamento, autenticação, cookies, CORS, OpenAPI, e tem que fazer tudo isso sem se tornar um processo Node de 30 MB com um cold start de 400 ms.

Tentei escrevê-lo em Go. Estava ok. Também era muita código para o que é essencialmente "pegue uma requisição, procure um arquivo, abra um socket, escreva, leia, escreva a resposta". Então, uma noite, lembrei que o OpenResty — nginx com Lua embutido — já faz as partes difíceis (parsing de HTTP, TLS, epoll, memória compartilhada) e simplesmente me deixa programar a camada de política em uma linguagem de script com startup sub-milissegundo. Você não inicializa o OpenResty por requisição. O OpenResty inicializa uma vez, no início do processo, e então seu Lua roda dentro dos hooks de fase da requisição. Pense nisso como o servidor web convidando seu código para viver dentro do seu event loop como hóspede.

Então introduzi Lua na minha vida. Não foi tanto uma decisão quanto uma árvore interna de opções que continuava se ramificando: a cada terceira coisa que eu precisava, acabava sendo "ah, posso simplesmente fazer isso em Lua e funciona". Descoberta de rotas? Lua percorrendo um diretório. Tabela de roteamento em memória compartilhada? `ngx.shared.DICT`. Hot reload ao salvar arquivo? Um pequeno timer em Lua que faz stat no diretório de funções e reconstrói uma tabela de rotas em um shared dict. Parsing de cookie de sessão? Doze linhas:

```lua {title="openresty/lua/fastfn/http/gateway.lua:70-82"}
local function parse_cookies(cookie_header)
  local cookies = {}
  if type(cookie_header) ~= "string" or cookie_header == "" then
    return cookies
  end
  for pair in cookie_header:gmatch("[^;]+") do
    local k, v = pair:match("^%s*([^=]+)%s*=%s*(.-)%s*$")
    if k and k ~= "" then
      cookies[k] = v or ""
    end
  end
  return cookies
end
```
Doze linhas. Sem `npm install cookie-parser`. Sem `go get github.com/gorilla/sessions`. Sem dependência transitiva que foi abandonada em 2019 e agora é mantida por um bot. Doze linhas de Lua que rodam dentro da fase de requisição do nginx, e o gateway agora entende cookies de sessão.

{{< admonition type="info" title="Uma nota sobre Lua" open=true >}}
Lua é uma linguagem estranha. É indexada a partir de 1. Tem uma única estrutura de dados (a tabela) e nenhum sistema de módulos de verdade até você apertar os olhos. Sua biblioteca padrão é quase agressivamente minimalista. Nada disso realmente atrapalha aqui — o gateway é curto, os hot paths ficam no LuaJIT, e as coisas que Lua *não* tem (um runtime enorme, um ecossistema de pacotes, um sistema de tipos complicado) são exatamente as coisas que você não quer num hot path de requisição dentro do nginx.
{{< /admonition >}}

E é perfeita para este trabalho. O runtime é pequeno, o código é pequeno, a latência é pequena, e o gateway inteiro — roteamento, parsing de sessão, marshalling da requisição, un-marshalling da resposta, endpoint OpenAPI, servir o Swagger UI — vive em um punhado de arquivos Lua sob `openresty/lua/fastfn/`. A subárvore `http/` tem exatamente os módulos que você esperaria de um gateway de verdade: `gateway.lua`, `assets.lua`, `catalog.lua`, `openapi_endpoint.lua`, `swagger_ui.lua`, `reload.lua`. O `gateway.lua` principal tem 1341 linhas; o `client.lua` inteiro que implementa o protocolo de fio tem 110 linhas. Vou citar a maior parte dele em um minuto.

### A tabela de roteamento é um shared dict

O truque que torna o gateway em Lua rápido não é esperto. É que a tabela de rotas não vive em um banco de dados, não vive no Redis, não vive nem mesmo na memória por worker. Ela vive em `ngx.shared.fn_cache`, uma zona de memória compartilhada do nginx legível por todo processo worker. Quando `fastfn dev` inicia, Lua percorre o diretório de funções, constrói um índice — "`GET /hello` → `/functions/get.hello.py` → runtime `python`" — e o enfia no shared dict. Em mudança de arquivo, um chunk de reload em Lua o reconstrói. As requisições fazem uma única consulta no shared dict na fase `access_by_lua` e então despacham. Não há "framework". Há uma hash table e uma convenção.

## Capítulo 4: FastCGI Vestindo um Sobretudo de JSON

Agora o protocolo. Esta é a parte em que reconstruí o FastCGI por acidente.

Quando o gateway em Lua decidiu que `GET /hello` deve ser servido pelo runtime Python, ele precisa levar a requisição até o daemon Python. O daemon Python é um processo de vida longa escutando em um socket Unix (padrão `/tmp/fastfn/fn-python.sock`, configurável via `FN_PY_SOCKET`, que você pode ver em `srv/fn/runtimes/python-daemon.py:25`). O gateway abre o socket, escreve a requisição e lê a resposta.

O formato do frame é deliberadamente entediante:

```
  4 bytes            N bytes
  ---------------    ----------------------------------------
  | big-endian  |    |                                      |
  | uint32 len  |    |  JSON payload (request or response)  |
  ---------------    ----------------------------------------
```

Quatro bytes de comprimento big-endian. Então essa quantidade de bytes de JSON. Esse é o protocolo inteiro. É o enquadramento de registros do FastCGI, simplificado para um único tipo de registro, com o corpo do registro sendo JSON em vez da codificação binária chave-valor do FastCGI. Se o FastCGI vestisse um sobretudo e tentasse se passar por uma API REST moderna, é assim que ele se vestiria.

O lado Lua do fio é pequeno o suficiente para ser citado por completo. Aqui está o cliente que envia uma requisição e faz o parse de uma resposta:

```lua {title="openresty/lua/fastfn/core/client.lua:22-106"}
function M.call_unix(socket_uri, req_obj, timeout_ms)
  local payload = cjson.encode(req_obj)
  if not payload then
    return nil, "invalid_request", "failed to encode request"
  end

  local connect_timeout = math.max(50, math.floor(timeout_ms * 0.2))
  local io_timeout = math.max(50, timeout_ms)

  local sock = ngx.socket.tcp()
  sock:settimeouts(connect_timeout, io_timeout, io_timeout)

  local ok, err = sock:connect(socket_uri)
  if not ok then
    if is_timeout(err) then
      return nil, "timeout", "connect timeout"
    end
    return nil, "connect_error", tostring(err)
  end

  local sent, send_err = sock:send(pack_u32(#payload) .. payload)
  if not sent then
    sock:close()
    if is_timeout(send_err) then
      return nil, "timeout", "send timeout"
    end
    return nil, "send_error", tostring(send_err)
  end

  local header, header_err = sock:receive(4)
  if not header then
    sock:close()
    return nil, "receive_error", tostring(header_err)
  end

  local body_len = unpack_u32(header)
  if body_len <= 0 or body_len > 10 * 1024 * 1024 then
    sock:close()
    return nil, "invalid_response", "invalid frame length"
  end

  local body, body_err = sock:receive(body_len)
  sock:close()
  -- decode, validate, return
end
```
A função manual `pack_u32` é um bom lembrete de que o Lua 5.1 (que o OpenResty usa) não vem com `string.pack`, então eu a implemento eu mesmo:

```lua {title="openresty/lua/fastfn/core/client.lua:5-11"}
local function pack_u32(n)
  local b1 = math.floor(n / 16777216) % 256
  local b2 = math.floor(n / 65536) % 256
  local b3 = math.floor(n / 256) % 256
  local b4 = n % 256
  return string.char(b1, b2, b3, b4)
end
```
O limite do frame é 10 MB (`body_len > 10 * 1024 * 1024`). O lado Python impõe um limite simétrico via `FN_MAX_FRAME_BYTES`, que tem padrão de 2 MB (`srv/fn/runtimes/python-daemon.py:26`). Sim, os dois padrões não batem. Isso é de propósito: o gateway é um pouco mais permissivo que o daemon para que um daemon mal configurado falhe no daemon, e não em uma confusa leitura pela metade no gateway. É o tipo de assimetria que parece errada em um code review e parece certa quando você está depurando um incidente em produção às 2 da manhã.

### O ciclo de vida da requisição, com milissegundos aproximados

Uma vez que você tem o protocolo de fio, o ciclo de vida inteiro da requisição é fácil de desenhar. Aqui está uma requisição quente, com tempos aproximados que medi em um laptop de meados de 2024 rodando Linux:

```
  t=0.00 ms   client sends HTTP GET /hello
  t=0.10 ms   nginx accept, parse HTTP headers
  t=0.20 ms   Lua access_by_lua: route lookup in ngx.shared.fn_cache
  t=0.30 ms   Lua: cjson.encode(req_obj) -> JSON payload
  t=0.40 ms   Lua: ngx.socket.tcp():connect("/tmp/fastfn/fn-python.sock")
  t=0.50 ms   Lua: send [4B len][JSON]
  t=0.60 ms   Python daemon: recv 4 bytes, decode length
  t=0.70 ms   Python daemon: recv N bytes, json.loads
  t=0.80 ms   Python daemon: dispatch to handler from _HANDLER_CACHE
  t=1.10 ms   handler(event) returns {"status": 200, "body": "..."}
  t=1.20 ms   Python daemon: json.dumps, send [4B len][JSON]
  t=1.30 ms   Lua: recv header, recv body, cjson.decode
  t=1.40 ms   Lua content_by_lua: ngx.say(resp.body)
  t=1.50 ms   nginx flushes HTTP response to client
```

Esses números são aproximados — eu não publiquei um benchmark de ponta a ponta para o caminho puro de função por si só, então pegue o detalhamento de milissegundos por linha como estimativa, não como evangelho. A forma está certa mesmo que os números exatos variem. Cold starts são uma conversa muito diferente e vou chegar lá.

## Capítulo 5: Runtimes Poliglotas, ou "Lambda Sem a Amazon"

O protocolo de fio é agnóstico de linguagem de propósito. O daemon Python é uma implementação. Há um daemon Node. Há um caminho para handlers em Rust, Go, PHP e Lua também. Todo daemon de runtime faz a mesma promessa: abrir um socket Unix, falar o protocolo de 4-bytes-de-comprimento + JSON, e quando uma requisição chega, despachar para uma função com a assinatura que o AWS Lambda tornou famosa.

Aqui está um handler de verdade, dos exemplos do tutorial poliglota:

```python {title="examples/functions/polyglot-tutorial/step-2/index.py:1-18"}
import json


def handler(event):
    query = event.get("query") or {}
    name = query.get("name") or "friend"
    return {
        "status": 200,
        "headers": {"Content-Type": "application/json"},
        "body": json.dumps(
            {
                "step": 2,
                "message": f"Hello {name} from Python.",
                "runtime": "python",
                "name": name,
            }
        ),
    }
```
Esse é o contrato inteiro. `def handler(event): return {...}`. Forma-Lambda sem a Amazon. Forma-Cloudflare-Workers sem a Cloudflare. É também, se você apertar os olhos, apenas CGI com as variáveis-de-ambiente-e-stdin substituídas por um envelope JSON e um socket quente. A forma tem sido estável desde os anos 1990 porque é a forma certa: uma requisição é um dict, uma resposta é um dict, e o handler é uma função pura de um para o outro.

O daemon suporta três adaptadores de invocação explicitamente (`srv/fn/runtimes/python-daemon.py:51-53`):

```python
_INVOKE_ADAPTER_NATIVE = "native"
_INVOKE_ADAPTER_AWS_LAMBDA = "aws-lambda"
_INVOKE_ADAPTER_CLOUDFLARE_WORKER = "cloudflare-worker"
```

"native" é minha forma padrão. As outras duas existem para que você possa levantar um Lambda ou Cloudflare Worker existente para dentro do fastfn praticamente sem mudanças. Essa interoperabilidade não foi de graça; me custou um cuidadoso tradutor de formato de evento. Mas significa que você pode pegar código que você já tem rodando no serverless de outra pessoa e rodá-lo localmente com `fastfn dev`, o que é genuinamente útil quando o console AWS do seu empregador está passando por uma de suas semanas.

### Um pool de workers dentro do daemon

O daemon Python não apenas despacha na thread principal. Ele tem um `ThreadPoolExecutor` na frente de um pool de workers handler, controlado por um orçamento de slots. Os botões são todos variáveis de ambiente:

```python {title="srv/fn/runtimes/python-daemon.py:38-40"}
RUNTIME_POOL_ACQUIRE_TIMEOUT_MS = int(os.environ.get("FN_PY_POOL_ACQUIRE_TIMEOUT_MS", "5000"))
RUNTIME_POOL_IDLE_TTL_MS = int(os.environ.get("FN_PY_POOL_IDLE_TTL_MS", "300000"))
RUNTIME_POOL_REAPER_INTERVAL_MS = int(os.environ.get("FN_PY_POOL_REAPER_INTERVAL_MS", "2000"))
```
Então: timeout de aquisição de 5 segundos, TTL ocioso de 5 minutos, o reaper roda a cada 2 segundos. Esses são números de pool FastCGI vestidos com roupas Python. A thread reaper percorre o pool, mata workers ociosos além de seu TTL, e enxuga o footprint. O timeout de aquisição é o sinal de "estou na capacidade máxima, recue".

## Capítulo 6: Lua Até o Fundo

O Capítulo 3 foi o momento em que introduzi Lua na minha vida. O que eu não apreciei então é quão rapidamente ela colonizaria todo canto do plano de controle. Eu me propunha a resolver um problema em Lua — "deixe-me fazer o parse de um cookie" — e ao fim da tarde a tabela de roteamento, o gerador de OpenAPI, o limitador de taxa e o dashboard de admin eram todos Lua também, rodando no mesmo worker do nginx, compartilhando as mesmas zonas `ngx.shared.DICT`, sem nenhum salto interprocesso entre eles.

Este é o capítulo em que listo os lugares onde Lua roda. Lua não é uma arma secreta. É uma linguagem pequena com um JIT rápido, embutida em um servidor web que já faz as partes difíceis, e por acaso se encaixa neste problema como chave numa fechadura.

### Roteamento e descoberta: a árvore de arquivos *é* o banco de dados

O primeiro lugar onde Lua venceu foi o roteamento. As rotas não vivem em um arquivo YAML ou um objeto de configuração; elas vivem no sistema de arquivos. `core/routes.lua` percorre o diretório de funções no boot, lê metadados de cada arquivo de handler, e constrói um catálogo de "`GET /hello` → Python → `get.hello.py`". Ele então cacheia o catálogo em `ngx.shared.fn_cache` para que todo worker tenha a mesma visão de graça. O ponto de entrada da descoberta é só isto:

```lua {title="openresty/lua/fastfn/core/routes.lua:2228-2237"}
function M.discover_functions(force)
  if not force then
    local raw = CACHE:get("catalog:raw")
    if raw then
      local parsed = cjson.decode(raw)
      if parsed then
        return parsed
      end
    end
  end
  -- ... walk functions_root, populate catalog.runtimes / mapped_routes ...
end
```
`core/routes.lua` é o grandão — cerca de 3.245 linhas — porque o roteamento é onde toda preocupação eventualmente se encontra: métodos, prefixos reservados, conflitos, ranks de fonte. Ao lado dele fica `core/fs.lua` (cerca de 392 linhas), um pequeno wrapper FFI em torno de `stat`, `opendir`, `readdir` e amigos. Ele existe porque o FFI do LuaJIT me permite pular o módulo C LuaFileSystem e chamar a libc diretamente, mantendo a imagem do OpenResty enxuta:

```lua {title="openresty/lua/fastfn/core/fs.lua:263-280"}
function M.list_dirs_recursive(path, skip_fn)
  local out = {}
  if not M.is_dir(path) then
    return out
  end
  local function walk(dir)
    if type(skip_fn) == "function" and skip_fn(dir) then
      return
    end
    out[#out + 1] = dir
    for _, child in ipairs(M.list_dirs(dir)) do
      walk(child)
    end
  end
  walk(path)
  table.sort(out)
  return out
end
```
Esse é o walk do sistema de arquivos. `readdir` no mundo Lua, com um `skip_fn` plugável para podar `.git`, `node_modules`, `__pycache__` e `.fastfn`. Ele retorna uma lista ordenada de diretórios na qual tanto `routes.lua` quanto `watchdog.lua` se apoiam. O que me fez pensar: se as rotas são uma árvore no disco, então a spec OpenAPI é uma projeção dessa mesma árvore, e o dashboard é apenas uma UI sobre ela.

### OpenAPI, de graça

Aquela ideia de "projeção da mesma árvore" se transformou em `core/openapi.lua`, um módulo de 1.309 linhas que pega o catálogo produzido por `routes.lua` e emite um documento OpenAPI 3. Não há decorators nas funções de handler. Não há `@app.route("/hello")`. O handler é apenas `def handler(event): return {...}`, e a spec é gerada a partir da árvore de rotas mais uma fina camada de metadados por função (`fn.config.json`, se o handler se deu ao trabalho de escrever um).

O lado HTTP disso é um arquivo Lua de 101 linhas que liga tudo:

```lua {title="openresty/lua/fastfn/http/openapi_endpoint.lua:92-101"}
local spec = openapi.build(catalog, {
  server_url = server_url,
  runtime_order = routes_mod.get_runtime_order(),
  include_internal = env_bool("FN_OPENAPI_INCLUDE_INTERNAL", false),
  invoke_meta_lookup = invoke_meta_lookup,
})

ngx.status = 200
ngx.header["Content-Type"] = "application/json"
ngx.say(cjson.encode(spec))
```
Esse é o endpoint `/openapi.json` inteiro. Recarregue seu handler, acesse a URL, a spec o reflete. O Swagger UI (`http/swagger_ui.lua`, 119 linhas) serve uma página estática que aponta para aquele JSON e você tem docs interativas sem rodar um servidor de docs separado. O gerador de OpenAPI também se apoia em `core/invoke_rules.lua` para normalizar a política de invocação por função — quais métodos são permitidos, quais rotas são reservadas:

```lua {title="openresty/lua/fastfn/core/invoke_rules.lua:4-14"}
M.ALLOWED_METHODS = {
  GET = true,
  POST = true,
  PUT = true,
  PATCH = true,
  DELETE = true,
}
M.RESERVED_ROUTE_PREFIXES = {
  "/_fn",
  "/console",
}
```
O que me fez perceber quanto das ferramentas de API modernas é casamento de strings e allowlists. A "spec" não é uma spec, é uma visão.

### Observabilidade e limites: taxa, concorrência, saúde

O truque de memória compartilhada que torna o roteamento barato é o mesmo truque que torna os limites de concorrência por função baratos. `core/limits.lua` tem 133 linhas, a maioria delas boilerplate, e o mecanismo inteiro é uma dança de `incr`/`decr` em uma zona `ngx.shared.fn_conc`:

```lua {title="openresty/lua/fastfn/core/limits.lua:22-39"}
function M.try_acquire(dict, fn_key, limit)
  if not limit or limit <= 0 then
    return true
  end

  local key = key_for(fn_key)
  local current, err = dict:incr(key, 1, 0)
  if not current then
    return false, "counter_error:" .. tostring(err)
  end

  if current > limit then
    dict:incr(key, -1, 0)
    return false, "busy"
  end

  return true
end
```
Essa função é a primitiva inteira de "não mais que N invocações em andamento da função X em todo o gateway". `dict:incr` é atômico entre workers porque `ngx.shared.DICT` é memória compartilhada com um lock por baixo. Não há round trip ao Redis. Não há pod de admission-controller. É um contador na RAM, e ele está correto porque o nginx me dá ele correto.

Sobre os limites fica `core/watchdog.lua` (299 linhas), um watcher `inotify` do Linux escrito em LuaJIT FFI. Ele abre `inotify_init1(IN_NONBLOCK | IN_CLOEXEC)`, percorre a árvore de funções, adiciona um watch em todo subdiretório, e agenda um reload com debounce sempre que um arquivo muda:

```lua {title="openresty/lua/fastfn/core/watchdog.lua:268-284"}
local ok_poll, poll_err = ngx.timer.every(poll_interval, function(premature)
  if premature then
    return
  end
  local changed, read_err = drain_events()
  -- ...
  if pending_since and (ngx.now() - pending_since) >= debounce_s then
    pending_since = nil
    schedule_reload()
  end
end)
```
Um debounce de 150 ms, um callback de reload compartilhado, e a tabela de roteamento se reconstrói no lugar. Salve um arquivo, e a rota está no ar antes de eu voltar para o navegador.

### Agendamento in-process: `ngx.timer.at` como cron

Se eu posso agendar um reload com `ngx.timer`, posso agendar qualquer coisa. `core/scheduler.lua` (1.712 linhas) e `core/jobs.lua` (993 linhas) juntos implementam um executor de jobs ao estilo cron, ciente de retentativas, opcionalmente persistido, que vive *dentro* do worker do nginx. Sem daemon `cron` separado, sem Celery, sem RabbitMQ:

```lua {title="openresty/lua/fastfn/core/scheduler.lua:1659-1667"}
local ok, err = ngx.timer.every(interval, function(premature)
  if premature then
    return
  end
  local ok2, err2 = pcall(tick_once)
  if not ok2 then
    ngx.log(ngx.ERR, "scheduler tick failed: ", tostring(err2))
  end
end)
```
Esse único `ngx.timer.every` é o loop de tick. A cada tick ele percorre os agendamentos ativos, verifica as expressões cron contra o relógio de parede, e enfileira execuções via `ngx.timer.at(0, ...)`. Retentativas com backoff exponencial saem da mesma primitiva. A persistência é um blob JSON escrito no disco a cada 15 segundos. É bastante código para uma ideia pequena: *o event loop que eu já tenho é agendador suficiente para um FaaS de nó único.*

#### Um exemplo concreto: um digest de IA do Telegram que roda a cada hora

O scheduler é abstrato até você ver uma função que o usa. O mais limpo no repositório é [`examples/functions/node/telegram-ai-digest`](https://github.com/misaelzapata/fastfn/tree/main/examples/functions/node/telegram-ai-digest) — uma função Node que puxa mensagens de um grupo do Telegram, as resume com a OpenAI, e envia o digest de volta a um chat. O que a torna interessante para esta seção é o bloco `schedule` em sua config:

```json {title="examples/functions/node/telegram-ai-digest/fn.config.json"}
{
  "timeout_ms": 30000,
  "invoke": {
    "summary": "Telegram AI digest: fetches group messages, summarizes with OpenAI, sends digest",
    "methods": ["GET"],
    "content_type": "application/json"
  },
  "schedule": {
    "enabled": true,
    "every_seconds": 3600,
    "method": "GET"
  }
}
```
Essa é a interface inteira de "transforme isto em um cron job": uma estrofe `schedule` de três linhas na própria config da função, ao lado do handler. Sem crontab externo, sem passo de registro separado, sem pipeline YAML. Quando o fastfn inicializa, o scheduler lê o `fn.config.json` de cada função, vê que esta tem um `schedule.enabled = true`, e a adiciona ao loop de tick com `every_seconds = 3600`. A cada hora, o scheduler emite uma requisição `GET` interna para a função como se um cliente a tivesse chamado — mesmo roteamento, mesmo handler, mesmo logging — e a função faz seu trabalho.

O mesmo padrão aparece nos exemplos irmãos: [`telegram-ai-reply`](https://github.com/misaelzapata/fastfn/tree/main/examples/functions/node/telegram-ai-reply) é um webhook (handler `POST`, sem schedule — o Telegram o chama quando uma mensagem chega), [`telegram-send`](https://github.com/misaelzapata/fastfn/tree/main/examples/functions/node/telegram-send) é uma função no estilo biblioteca que você pode invocar de outros handlers para enviar uma mensagem (`dry_run` por padrão, que é o tipo de detalhe de segurança que aprendi a adicionar depois do segundo bot que deveria ser silencioso). Três funções, três ciclos de vida — webhook, chamada de biblioteca, cron — e todos eles são apenas arquivos em `functions/`. A única coisa que torna o digest "um cron" são aquelas três linhas de config.

Gosto desta forma porque o "modelo de deploy" da função e seu "modelo de invocação" estão no mesmo lugar. Se um colega de equipe se pergunta "como isto roda?", ele abre `fn.config.json`, vê `schedule.every_seconds = 3600`, e sabe. Sem caçar por um arquivo cron em um servidor ao qual ele não tem acesso ssh.

#### Config e tokens: `fn.env.json` e a flag `is_secret`

A função de digest agendada é inútil sem dois segredos e um identificador: um token de bot do Telegram, uma chave de API da OpenAI, e o ID do chat para o qual postar. Este é o ponto em todo tutorial de FaaS em que você ou enrola sobre variáveis de ambiente ou cola um parágrafo sobre o Vault. O fastfn não faz nenhum dos dois. Ele coloca a config ao lado da função, em um arquivo irmão chamado `fn.env.json`, com uma pequena quantidade de estrutura que torna o *significado* de cada valor explícito:

```json {title="examples/functions/node/telegram-ai-digest/fn.env.json"}
{
  "TELEGRAM_BOT_TOKEN": {
    "value": "<set-me>",
    "is_secret": true
  },
  "TELEGRAM_CHAT_ID": {
    "value": "<set-me>",
    "is_secret": false
  },
  "OPENAI_API_KEY": {
    "value": "<set-me>",
    "is_secret": true
  }
}
```
Duas coisas a notar. Primeiro, toda chave é um objeto, não uma string nua — o valor vive em `.value`, e os metadados vivem ao lado dele. Segundo, aquele booleano `is_secret` é carga estrutural. O runtime o usa para decidir se um valor é mascarado nos logs e no dashboard de admin, se ele pode ser ecoado de volta por um endpoint `_fn/ui_state`, e se o botão "view" do dashboard tem permissão para revelá-lo em texto claro. `TELEGRAM_CHAT_ID` não é um segredo — é apenas um número, e você vai querer vê-lo na UI enquanto está depurando "por que meu digest não apareceu". `TELEGRAM_BOT_TOKEN` é — e se você acidentalmente o vazar para os logs, a reação do Telegram é invalidar o token, então o scheduler pararia de funcionar silenciosamente até você perceber. A flag `is_secret` é a diferença inteira entre esses dois resultados.

O valor `"<set-me>"` também não é um bug — é um padrão que o repositório usa deliberadamente. O handler em `core.js` trata `<set-me>`, `set-me`, `changeme`, `<changeme>` e `replace-me` como sentinelas "não definidos":

```javascript {title="examples/functions/node/telegram-ai-digest/core.js:1-8"}
function isUnsetConfigValue(value) {
  if (value === undefined || value === null) return true;
  const s = String(value).trim();
  if (!s) return true;
  const l = s.toLowerCase();
  return l === "<set-me>" || l === "set-me" || l === "changeme"
      || l === "<changeme>" || l === "replace-me";
}
```
Isso significa que você pode commitar `fn.env.json` no git com placeholders `"<set-me>"`, e a função falha de forma limpa em vez de fingir rodar com uma string vazia. Deploys reais substituem esses placeholders (no dashboard, através da API, ou editando o arquivo em um host controlado) por valores reais; entradas `is_secret: true` são mascaradas imediatamente ao salvar.

Dentro do handler, o runtime entrega esses valores em `event.env`, ao lado do `event.method`, `event.query`, etc. com escopo de requisição. O `core.js` do digest os lê com um pequeno fallback que prefere o env local da função, recorrendo ao `process.env` ambiente que o daemon colocou na allowlist no startup:

```javascript {title="examples/functions/node/telegram-ai-digest/core.js:86-88"}
const botToken = chooseConfigValue(env.TELEGRAM_BOT_TOKEN, process.env.TELEGRAM_BOT_TOKEN);
const chatId   = chooseConfigValue(env.TELEGRAM_CHAT_ID,   process.env.TELEGRAM_CHAT_ID);
const apiKey   = chooseConfigValue(env.OPENAI_API_KEY,     process.env.OPENAI_API_KEY);
```
Esta é a forma de config à qual eu sempre volto: um arquivo JSON ao lado do handler, um valor por segredo, uma flag `is_secret` explícita que dirige o mascaramento em toda superfície downstream, e um `event.env` ao nível da linguagem que torna a dependência do handler nesses valores trivialmente grepável. É, novamente, não uma ideia nova — é basicamente a mesma forma das config vars do Heroku ou de um par `Secret`/`ConfigMap` do Kubernetes. Mas vive na mesma pasta que o código que o usa, é versionável com placeholders seguros, e não requer um gerenciador de segredos externo para começar.

Coloque o scheduler, a config e o handler em um diretório, e o digest do Telegram passa de "um vago cron job em algum lugar" para "quatro arquivos que posso ler em um minuto e entregar a um colega de equipe".

### O dashboard é Lua

O lugar onde Lua mais me surpreendeu foi o console de admin. Eu não me propus a escrever uma aplicação web em Lua. Eu assumia que eventualmente conectaria um pequeno SPA, provavelmente Vue ou Svelte, porque "todo mundo faz isso". Então escrevi `console/login_endpoint.lua` em uma tarde e percebi que não precisava do SPA.

O endpoint de login tem 95 linhas e faz tudo que um endpoint de login deve fazer: verificação de método, rate limit em `ngx.shared.fn_cache`, comparação em tempo constante, verificação de senha PBKDF2, cookie de sessão. Aqui está a fatia do rate-limit:

```lua {title="openresty/lua/fastfn/console/login_endpoint.lua:38-46"}
local client_ip = ngx.var.remote_addr or "unknown"
local rate_store = ngx.shared.fn_cache
if rate_store then
  local fail_count = rate_store:get(login_rate_key(client_ip))
  if LOGIN_MAX_ATTEMPTS > 0
     and type(fail_count) == "number"
     and fail_count >= LOGIN_MAX_ATTEMPTS then
    guard.write_json(429, { error = "too many login attempts, try again later" })
    return
  end
end
```
Cinco segundos de matemática de shared-dict e eu tenho um lockout funcional. `console/auth.lua` (484 linhas) lida com o cookie de sessão, PBKDF2 com um mínimo de 100.000 iterações, segredos-de-arquivo opcionais para a senha do admin, e um TTL de 12 horas. `console/guard.lua` (387 linhas) é o middleware que todo endpoint do dashboard chama primeiro para impor autenticação, limites de corpo, CSRF e write-gates. `console/data.lua` é o grandão — cerca de 2.623 linhas — e é a "camada de serviço" de respaldo na qual os endpoints do dashboard delegam: listar funções, ler o código de uma função, definir código, listar versões, ler logs, ler agendamentos, agregar métricas do dashboard.

Os endpoints em si são minúsculos. O endpoint de métricas do dashboard tem 21 linhas:

```lua {title="openresty/lua/fastfn/console/dashboard_endpoint.lua:9-21"}
local method = ngx.req.get_method()
if method ~= "GET" then
  guard.write_json(405, { error = "method not allowed" })
  return
end

-- Aggregate metrics from shared dicts or external systems
local metrics = console.get_dashboard_metrics()
guard.write_json(200, metrics or {
  invocations = {},
  errors = {},
  latency = {}
})
```
Ao redor dele vivem `functions_endpoint.lua` (34 linhas), `logout_endpoint.lua` (15 linhas), `ui_state_endpoint.lua` (50 linhas), `secrets_endpoint.lua` (70 linhas), `packs.lua` (77 linhas), `ui.lua` (66 linhas), e o mais gordinho `invoke_endpoint.lua` (526 linhas) que alimenta o console "invoque esta função a partir do navegador". Não há aplicação web de admin separada. Não há `npm run dev`. Não há segundo sistema de build. Há Lua, renderizando HTML e JSON a partir do mesmo worker do nginx que serve o tráfego do gateway.

### Lua como runtime, e um cliente HTTP muito minúsculo

Também adicionei `core/lua_runtime.lua` (399 linhas), que permite que arquivos Lua no diretório de funções *sejam* handlers — mesmo contrato `handler(event) -> response` que Python e Node, exceto que não há salto entre processos porque o handler roda no mesmo worker. Eu não publiquei um benchmark para este caminho, então não vou colocar um número de milissegundos nele. Para handlers que precisam alcançar para fora, `core/http_client.lua` (356 linhas) envolve `ngx.socket.tcp` com parsing de URL, keepalive e timeouts, para que os handlers chamem APIs upstream sem puxar `luasocket` para dentro da imagem. `core/home.lua` (234 linhas) serve a página de aterrissagem padrão em `/`, e `http/function_code.lua` / `http/function_file_content.lua` (50 e 76 linhas) fazem o ida-e-volta do código-fonte da função entre o editor do dashboard e o disco.

### Os tradeoffs honestos

Então — Lua é magnífica aqui? Sim, com comprovantes. Os comprovantes são:

- **O cold start é essencialmente de graça.** O OpenResty inicializa uma vez. Meu código Lua é um conjunto de módulos carregados no init do worker. O arquivo `gateway.lua` tem 1.341 linhas; carregá-lo adiciona praticamente nada à latência da requisição porque os hooks de fase da requisição já estão JIT-compilados quando a primeira requisição chega. Não tenho um número limpo de "tempo de carregamento do módulo Lua" para citar aqui, então não vou colocar uma cifra em milissegundos — mas todo `p50` de hot-path publicado no repositório, em todas as cargas, está nos milissegundos de um único dígito de ponta a ponta através deste gateway em Lua, e esse é o único número que importa para um usuário.
- **A memória compartilhada é quase de graça.** `ngx.shared.DICT` é uma hash table no nginx, protegida por um spinlock. Eu não pago por um container Redis para guardar contadores.
- **O módulo por preocupação é pequeno.** Tirando o core de descoberta de roteamento, a maioria dos módulos do plano de controle está na faixa de 100–500 linhas. `limits.lua` tem 133 linhas. `watchdog.lua` tem 299. `login_endpoint.lua` tem 95. Esses são números à distância de leitura. Posso segurar qualquer um deles na cabeça.

E os custos, porque há custos:

- **Esquisitices numéricas do Lua 5.1.** O LuaJIT do OpenResty é Lua-5.1-ish com algumas extensões 5.2/5.3. Não há `string.pack` nativo, que é por que o `pack_u32` no Capítulo 4 existe. A confusão entre inteiro-versus-double me mordeu duas vezes enquanto escrevia o limitador de taxa; `ngx.shared.DICT:incr` retorna números que são doubles, e tive que ter cuidado com `math.floor` em contadores que eu planejava comparar contra valores `tonumber(env)`.
- **Disciplina de `pcall` em todo lugar.** Um erro não capturado em um callback de `ngx.timer` mata o timer silenciosamente e loga no `error.log`. Todo timer que escrevo está embrulhado em `pcall`, e o tick do scheduler, o callback do watchdog e o escritor de persistência seguem todos o mesmo padrão. Pule essa disciplina e você ganha um scheduler que misteriosamente para de fazer tick às 3 da manhã.
- **Tudo é uma string, até não ser.** Shared dicts guardam strings e números, não tabelas, o que significa encode/decode de JSON na fronteira. Eu me apoio em `cjson.safe` para que entrada ruim retorne `nil, err` em vez de lançar, e ainda recebo o ocasional `cannot encode sparse array` quando uma tabela tem buracos numéricos.

Nenhum desses é um impeditivo. Eles são o preço de escolher uma linguagem pequena com um JIT rápido embutido em um servidor web, e essa troca continua vencendo. O plano de controle inteiro — roteamento, OpenAPI, limites, watchdog, scheduler, console, auth — é um punhado de arquivos que posso ler em uma tarde. A maioria das preocupações é de 20 a 40 KB de Lua legível cada. O diretório `core/` inteiro tem cerca de 304 KB no disco; o diretório `console/` inteiro tem cerca de 156 KB. Isso é menos Lua, por uma margem ampla, do que a quantidade de JavaScript que um único SPA moderno entrega antes de chegar à primeira rota. E a Lua é *o produto*, não o andaime.

O que me traz à parte em que tento puxar as lições deste monte.

## Capítulo 7: Lições que Só Esperava pela Metade

### Sempre coloque o env do host em allowlist

Todo FaaS tem a mesma tentação: passar as variáveis de ambiente do host para dentro do handler. É conveniente. É também como segredos vazam. Se seu pipeline de CI/CD exporta `AWS_SECRET_ACCESS_KEY` para scripts de deploy, e seu daemon Python herda esse env para dentro de todo handler, parabéns, você acabou de entregar a todo handler na máquina uma credencial root da nuvem.

A correção é uma allowlist, não uma blocklist. Descobri isso do jeito chato: lendo relatórios de incidente de outras pessoas e decidindo que eu não queria escrever o meu próprio. O daemon vem com uma lista conservadora de chaves de env ambiente que os handlers podem ver (`srv/fn/runtimes/python-daemon.py:59-103`):

```python
_ALLOWED_WORKER_ENV_KEYS = {
    "PATH",
    "HOME",
    "USER",
    "LOGNAME",
    # ... language runtimes
    "PYTHONHOME",
    "PYTHONPATH",
    "GOPATH",
    "GOCACHE",
    "CARGO_HOME",
    "RUSTUP_HOME",
    # ... CA bundles
    "SSL_CERT_FILE",
    "SSL_CERT_DIR",
    "REQUESTS_CA_BUNDLE",
    # ... locale
    "LANG",
    "TZ",
}
_ALLOWED_WORKER_ENV_PREFIXES = ("LC_",)
```

O comentário acima dela diz a parte silenciosa em voz alta: *"User-defined secrets should come from fn.env.json or request-scoped event.env, not ambient host env."* A allowlist é a aplicação. Tudo que não está nesse conjunto, ou que começa com `LC_`, é limpo antes que o handler rode. É higiene de segurança, não teatro de segurança.

### Precedência de config: flag > env > config > padrão

Este é o tipo de regra que soa óbvia e mesmo assim está errada em metade das CLIs que já usei. Minha própria CLI acerta porque errei na primeira vez. De `cli/cmd/run.go:64-73`:

```go
// Resolve hot-reload: flag > env > config > default (true)
hotReload := true
if runHotReload {
    // Explicit --hot-reload flag always wins
    hotReload = true
} else if envVal := os.Getenv("FN_HOT_RELOAD"); envVal != "" {
    hotReload = envVal != "0" && envVal != "false" && envVal != "off" && envVal != "no"
} else if viper.IsSet("hot-reload") {
    hotReload = viper.GetBool("hot-reload")
}
```

Note o comentário na linha 64. Ele não está lá para mim — está lá para a próxima pessoa que tentar "consertar" isto fazendo o arquivo de config ganhar do env. Todo sistema de config que já inverteu essa ordem causou um incidente em produção no qual alguém definiu uma variável de ambiente no CI, e então se perguntou por que o container continuava lendo um valor obsoleto de um arquivo commitado.

### Tudo vive no sistema de arquivos

Sem banco de dados. Sem registry. Sem etcd. As rotas são arquivos. A config é `fastfn.json`. O env é `fn.env.json`. Os overrides locais da função são `fn.config.json`. O estado de dependência é `.fastfn-deps-state.json`.

Esta decisão foi tomada por preguiça e acabou sendo um recurso. Um FaaS filesystem-first pode ser versionado, rsyncado, snapshotado e diffado com ferramentas que eu já tenho. Você não precisa de uma "plataforma" para inspecioná-lo. `ls` é a UI de admin. `grep` é o depurador. Esta é a mesma lição que o `inetd` aprendeu em 1986: às vezes o plano de controle deveria ser um arquivo de texto.

## Capítulo 8: A Moral da História (Parte 1)

Eu me propus a construir "largue um arquivo Python, ganhe uma URL". Acabei reconstruindo o FastCGI de propósito, com um gateway em Lua porque o OpenResty é genuinamente a ferramenta certa para este trabalho, um protocolo de fio em JSON porque formatos de registro binário são um mau tradeoff para um sistema local-first em 2026, e um contrato de runtime poliglota porque `def handler(event): return {...}` é a abstração certa e tem sido desde que o Lambda foi lançado.

A coisa que eu não esperava é quantas das decisões de design já tinham sido pré-feitas para mim pela história. O custo de fork-por-requisição que matou o CGI é o mesmo custo que faz o serverless container-por-requisição parecer lento hoje. O pool persistente + socket enquadrado do FastCGI é a mesma forma na qual as plataformas serverless modernas convergem internamente. Os Cloudflare Workers são, em sua essência, FastCGI com um isolado v8 em vez de um processo. Os pools de warm-start do AWS Lambda são FastCGI com um API Gateway na frente. `fastfn` — pelo menos na forma que acabei de descrever — é FastCGI com frames JSON e um gateway OpenResty.

Vou fechar a Parte 1 com o único arrependimento que de fato tenho: eu poderia ter começado com Lua um ano antes. Toda vez que escrevi "só um gatewayzinho em Go" eu estava escrevendo uma versão pior do que o nginx já faz, com um cold start maior, mais código e menos observabilidade. A lição — e não é uma lição nova, que é a parte constrangedora — é que quando a forma do seu problema combina com uma peça de infraestrutura existente e bem-amada, você deveria simplesmente usar essa infraestrutura. O FastCGI me disse como o data plane deveria parecer em 1996. O OpenResty me disse como o plano de controle deveria parecer em 2010. O Lambda me disse como o contrato de handler deveria parecer em 2014. Tudo que a Parte 1 do `fastfn` fez foi escutar.

Há um segundo ramo desta história que este post deliberadamente não cobre: o que acontece quando uma função não é a forma certa — quando você precisa de um serviço de vida longa como um banco de dados, uma app Flask, ou um frontend Next.js vivendo dentro do mesmo gateway. Esse trabalho está atualmente em andamento em um branch de feature e ainda não foi publicado, então vou deixá-lo para a Parte 2, onde o isolamento de microVM Firecracker, a config `apps` / `workloads`, e a rede vsock entre peers todos ganham o espaço de que precisam. As funções são a Parte 1. Os serviços são a Parte 2.

{{< admonition type="tip" title="Continue lendo" open=true >}}
Parte 2 — [**fastfn Parte 2: Quando uma Função Não é Suficiente**]({{< ref "/posts/fastfn-services-when-functions-arent-enough" >}}) — retoma onde este post termina: serviços, workloads, microVMs Firecracker, e a rede vsock que permite a uma função alcançar uma VM Postgres em `postgres.internal` sem nunca expor uma porta no host.
{{< /admonition >}}

