Pular para o conteúdo

Renderize na edge com Cloudflare e fallback local

A ponte Cloudflare envia o HTML para um endpoint de renderização do Cloudflare Worker e retorna o PDF. A renderização roda na edge, então você não precisa operar um processo de navegador de longa duração. Você cria uma configuração somente HTTPS, conecta um cliente PHP Standards Recommendation (PSR)-18 e factories PSR-17, chama render() e pode adicionar um renderizador local para Workers inacessíveis. Este guia mostra a chamada de renderização, o caminho de fallback e os controles contra Server-Side Request Forgery (SSRF), rebinding de Domain Name System (DNS) e fixação de chave pública de Transport Layer Security (TLS) que a ponte aplica antes que qualquer requisição saia do processo.

Pré-requisitos, de antemão:

  • O core do NextPDF e o nextpdf/cloudflare estão instalados.
  • Um endpoint do Worker serve o contrato de renderização sobre HTTPS e aceita um token bearer. A ponte rejeita uma URL de Worker que não seja HTTPS antes de enviar qualquer coisa.
  • Um cliente PSR-18 (por exemplo, Guzzle 7) e factories PSR-17 de requisição e stream estão disponíveis. Para o transporte cURL fixado, forneça também uma factory de resposta PSR-17 e a ext-curl.
  • Para o fallback local, o nextpdf/artisan (ou outro renderizador local) está disponível.

Este é um guia prático. Para a primeira renderização executável, comece pelo quickstart da Cloudflare.

Instale a ponte, um cliente PSR-18 e factories PSR-17.

Terminal window
composer require nextpdf/cloudflare guzzlehttp/guzzle

Para o fallback local, instale um renderizador local que a ponte possa chamar.

Terminal window
composer require nextpdf/artisan

Carregue o token bearer do Worker e quaisquer credenciais do R2 a partir de variáveis de ambiente ou de um gerenciador de segredos. Nunca faça commit deles.

CloudflareHtmlRenderer::render() valida o HTML e o destino, envia um POST autenticado para o Worker e analisa a resposta. O Worker retorna bytes brutos de PDF (Content-Type: application/pdf) ou um corpo JSON com um campo pdf em base64. O renderizador mapeia a resposta para um final readonly CloudflareRenderResult que contém os bytes, a largura solicitada, a altura, a localização de renderização (derivada do cabeçalho CF-Ray) e o tempo de renderização.

A ponte separa as falhas em duas classes explícitas:

  • CloudflareRenderException — o Worker respondeu, mas a renderização falhou (um erro HTTP ou um corpo que não começa com %PDF). Esta é uma falha de renderização e nunca é repetida com fallback.
  • CloudflareNotAvailableException — a edge não pôde ser alcançada e nenhum fallback utilizável estava disponível.

O fallback local cobre o segundo caso. Quando o Worker não pode ser alcançado e fallbackToLocal é true, a ponte chama a LocalRendererFactoryInterface que você fornece. Ela faz isso de forma preguiçosa: o create() da factory roda apenas no caminho de fallback. Em uma renderização de fallback, o renderLocation do resultado é a string literal local.

A ponte protege o limite de rede antes que qualquer requisição saia do PHP. Ela rejeita uma URL de Worker que não seja HTTPS. Ela rejeita um host de Worker que resolve para espaço de endereço privado ou reservado, verificando todos os registros A e AAAA em vez de apenas o primeiro. Ela também resolve o host novamente imediatamente antes de conectar, fechando a janela time-of-check/time-of-use (TOCTOU) contra rebinding de DNS. Quando você fornece uma factory de resposta PSR-17 e um conjunto de IPs resolvidos ou pins de Subject Public Key Info (SPKI), a ponte usa um transporte cURL fixado. Esse transporte vincula a conexão aos IPs verificados (CURLOPT_RESOLVE), aplica a fixação de chave pública de TLS (CURLOPT_PINNEDPUBLICKEY), verifica o peer e o host e não segue redirecionamentos.

// Configuration (final readonly):
new CloudflareRendererConfig(
string $workerUrl, // required, must be HTTPS
string $apiToken, // required, #[SensitiveParameter]
int $renderTimeout = 30,
string $defaultCss = '',
int $maxHtmlSize = 5_000_000,
?string $r2FontBucket = null,
bool $fallbackToLocal = true,
list<string> $pinnedPublicKeys = [], // sha256/<base64>
list<string> $backupPublicKeys = [],
)
CloudflareRendererConfig::fromArray(array $config): self
// The renderer:
new CloudflareHtmlRenderer(
CloudflareRendererConfig $config,
ClientInterface $httpClient, // PSR-18
RequestFactoryInterface $requestFactory, // PSR-17
StreamFactoryInterface $streamFactory, // PSR-17
?LoggerInterface $logger = null, // PSR-3
?LocalRendererFactoryInterface $localRendererFactory = null,
?HtmlSecurityPolicyInterface $htmlSecurityPolicy = null,
?ResponseFactoryInterface $responseFactory = null, // enables pinned transport
)
CloudflareHtmlRenderer::render(string $html, float $widthPt = 595.28, float $heightPt = 0.0, list<string> $fontFiles = []): CloudflareRenderResult
CloudflareHtmlRenderer::isAvailable(): bool

render() usa por padrão a largura A4 (595.28 pontos) e a altura detectada automaticamente (heightPt: 0). Para a referência completa de campos e o mapa de chaves do fromArray(), consulte a página de configuração da Cloudflare em Veja também.

Crie a configuração, construa o renderizador, renderize e grave os bytes.

edge-quickstart.php
<?php
declare(strict_types=1);
require __DIR__ . '/vendor/autoload.php';
use GuzzleHttp\Client;
use GuzzleHttp\Psr7\HttpFactory;
use NextPDF\Cloudflare\CloudflareHtmlRenderer;
use NextPDF\Cloudflare\CloudflareRendererConfig;
use NextPDF\Cloudflare\Exception\CloudflareNotAvailableException;
use NextPDF\Cloudflare\Exception\CloudflareRenderException;
$config = new CloudflareRendererConfig(
workerUrl: 'https://pdf-renderer.example.workers.dev/render',
apiToken: getenv('CF_PDF_TOKEN') ?: throw new RuntimeException('CF_PDF_TOKEN not set'),
);
$httpFactory = new HttpFactory();
$renderer = new CloudflareHtmlRenderer(
config: $config,
httpClient: new Client(),
requestFactory: $httpFactory,
streamFactory: $httpFactory,
responseFactory: $httpFactory, // enables the pinned cURL transport
);
try {
$result = $renderer->render('<h1>Hello from the edge</h1>');
if (!$result->isValid()) {
throw new RuntimeException('Worker did not return a valid PDF');
}
file_put_contents('output.pdf', $result->pdfData);
} catch (CloudflareRenderException $exception) {
// Worker answered but the render failed. Not retried with fallback.
fwrite(STDERR, 'Render failed: ' . $exception->getMessage() . PHP_EOL);
exit(1);
} catch (CloudflareNotAvailableException $exception) {
// Edge unreachable and no usable fallback.
fwrite(STDERR, 'Edge unavailable: ' . $exception->getMessage() . PHP_EOL);
exit(2);
}

O token vem do ambiente e nunca é codificado de forma fixa. O workerUrl deve usar HTTPS; a ponte rejeita uma URL http:// antes de enviar qualquer requisição.

Em produção, conecte uma factory de renderizador local para que um Worker inacessível use o fallback em vez de fazer a requisição falhar. Configure os pins de TLS com um pin de backup. O create() da factory roda apenas no caminho de fallback.

ArtisanLocalRendererFactory.php
<?php
declare(strict_types=1);
use NextPDF\Artisan\ChromeHtmlRenderer;
use NextPDF\Cloudflare\Contract\LocalRendererFactoryInterface;
use NextPDF\Cloudflare\Contract\LocalRendererInterface;
final readonly class ArtisanLocalRendererFactory implements LocalRendererFactoryInterface
{
public function __construct(private ChromeHtmlRenderer $chrome) {}
public function create(): LocalRendererInterface
{
return new readonly class($this->chrome) implements LocalRendererInterface {
public function __construct(private ChromeHtmlRenderer $chrome) {}
/** @param array<string, mixed> $options */
public function render(string $html, array $options = []): string
{
$widthPt = (float) ($options['widthPt'] ?? 595.28); // A4 width
$heightPt = (float) ($options['heightPt'] ?? 0.0); // 0 = auto-fit
return $this->chrome->render($html, $widthPt, $heightPt)->getPdfData();
}
};
}
}

Conecte a factory e os pins ao renderizador.

build the production renderer
<?php
declare(strict_types=1);
use NextPDF\Cloudflare\CloudflareHtmlRenderer;
use NextPDF\Cloudflare\CloudflareRendererConfig;
$config = CloudflareRendererConfig::fromArray([
'worker_url' => getenv('CF_WORKER_URL') ?: '',
'api_token' => getenv('CF_PDF_TOKEN') ?: '',
'render_timeout' => 60,
'fallback_to_local' => true,
'pinned_public_keys' => ['sha256/YLh1dUR9y6Kja30RrAn7JKnbQG/uEtLMkBgFF2Fuihg='],
'backup_public_keys' => ['sha256/Vjs8r4z+80wjNcr1YKepWQboSIRi63WsWXhIMN+eWys='],
]);
$renderer = new CloudflareHtmlRenderer(
config: $config,
httpClient: $httpClient,
requestFactory: $httpFactory,
streamFactory: $httpFactory,
logger: $logger,
localRendererFactory: new ArtisanLocalRendererFactory($chrome),
responseFactory: $httpFactory,
);

Quando o fallback roda, o renderLocation do resultado é local e heightPt é 0.0. A ponte registra o fallback em warning e depois em info. Sempre configure um pin de backup antes da rotação de certificado, para que uma rotação planejada não bloqueie o acesso da ponte ao endpoint.

  • Um erro do Worker não é uma falha de acessibilidade. Um Worker que retorna um erro HTTP ou um corpo malformado levanta CloudflareRenderException e nunca é repetido com fallback. Apenas uma edge inacessível recorre ao fallback. Mantenha os dois braços de catch distintos.
  • O fallback precisa tanto da flag quanto de uma factory. Com fallbackToLocal: true, mas sem uma factory conectada, um Worker inacessível levanta CloudflareNotAvailableException e nomeia a factory ausente. Conecte a factory.
  • O isAvailable() é uma indicação, não uma garantia. Ele envia um HEAD autenticado e retorna true para um status abaixo de 500; o POST seguinte ainda pode falhar. Não o trate como um contrato.
  • A fixação é opcional. Um conjunto vazio de pins desativa a fixação. Use um conjunto vazio apenas com uma cadeia de certificados estável e conhecida, e mantenha um pin de backup depois de fixar.
  • O fontFiles precisa de um bucket R2. O argumento fontFiles só importa quando a configuração define r2FontBucket; caso contrário, não tem efeito.
  • A ponte não assina. Ela retorna bytes de PDF. Renderize na edge e, em seguida, assine no seu próprio processo, para que a chave de assinatura nunca atravesse o limite da edge.

A renderização na edge remove dos seus hosts o custo do navegador. Você ainda paga por uma ida e volta HTTPS até o Worker, além do tempo de renderização do Worker, que o resultado informa como renderTimeMs. A ponte aplica o timeout configurado por meio do transporte fixado. Defina-o com base na latência medida do Worker, com folga, e mantenha-o abaixo de qualquer timeout de gateway upstream. O pacote declara apenas os limites que ele próprio aplica. Ele não faz nenhuma afirmação sobre os tetos de CPU, memória ou corpo de requisição da plataforma Cloudflare. Para esses limites, consulte a documentação da Cloudflare e o seu Worker.

  • O destino é validado antes que a requisição saia do PHP. URLs que não sejam HTTPS são rejeitadas. Um host que resolve para espaço de endereço privado ou reservado é rejeitado em todos os registros A e AAAA. O host é resolvido novamente imediatamente antes de conectar, como defesa contra rebinding de DNS.
  • O transporte fixado vincula DNS e TLS. Com uma factory de resposta e pins configurados, a ponte vincula a conexão aos IPs verificados, aplica a fixação de SPKI, verifica o peer e o host e se recusa a seguir redirecionamentos para um host não verificado.
  • A entrada é limitada. HTML acima de maxHtmlSize (padrão 5 MB), um data URI base64 superdimensionado e qualquer tag <meta http-equiv="refresh"> são rejeitados antes de a requisição ser enviada.
  • Os segredos são ocultados e imutáveis. O apiToken e as chaves do R2 carregam #[SensitiveParameter], de modo que os rastreamentos de pilha os ocultam, e os objetos de configuração são final readonly. Carregue os segredos do ambiente ou de um gerenciador de segredos; nunca faça commit deles.
  • Nunca escreva um bloco catch vazio. Cada exemplo captura o tipo de exceção específico e registra ou sai com um código definido.

O modelo de segurança completo está na página de segurança e operações da Cloudflare em Veja também. Ela cobre a defesa contra SSRF e rebinding de DNS, as operações de fixação, o manuseio de segredos e as cláusulas relevantes do OWASP e da RFC 7469.

Este guia não faz nenhuma afirmação normativa de padrões por conta própria. Nas páginas upstream de segurança e operações e de configuração da Cloudflare, a resolução de DNS de todos os registros e a reverificação TOCTOU da ponte correspondem às orientações de prevenção de SSRF do OWASP, e a fixação de chave pública de TLS e a recuperação por pin de backup correspondem à RFC 7469. Esta página do cookbook reafirma o uso e remete essas citações a essas páginas. A ponte não realiza assinatura e não faz nenhuma afirmação de conformidade de assinatura.