Ir al contenido

Renderizado en el edge con Cloudflare, con respaldo local

El puente de Cloudflare envía el HTML a un endpoint de renderizado de Cloudflare Worker y devuelve el PDF. El renderizado se ejecuta en el edge, sin tener que operar ningún proceso de navegador de larga duración. Se construye una configuración solo HTTPS, se conectan un cliente PSR-18 y factorías PSR-17, se llama a render() y, opcionalmente, se conecta un renderer local al que el puente recurre cuando no se puede alcanzar el Worker. Esta guía cubre la llamada de renderizado, la decisión de respaldo y los controles de SSRF, reataque de DNS y fijación de clave pública TLS que el puente impone antes de que cualquier solicitud salga del proceso.

Requisitos previos:

  • El núcleo de NextPDF y nextpdf/cloudflare están instalados.
  • Un endpoint de Worker sirve el contrato de renderizado sobre HTTPS y acepta un token de portador. El puente rechaza una URL de Worker que no sea HTTPS antes de enviar nada.
  • Un cliente PSR-18 (por ejemplo, Guzzle 7) y las factorías PSR-17 de solicitud y de stream están disponibles en el path. Para usar el transporte cURL con fijación, se deben proporcionar también una factoría de respuesta PSR-17 y ext-curl.
  • Para el respaldo local, nextpdf/artisan (u otro renderer local) debe estar disponible.

Esta es una guía práctica. Para obtener un primer renderizado ejecutable, consultar el inicio rápido de Cloudflare.

Instalar el puente, un cliente PSR-18 y factorías PSR-17.

Ventana de terminal
composer require nextpdf/cloudflare guzzlehttp/guzzle

Para el respaldo local, instalar un renderer local al que el puente pueda delegar.

Ventana de terminal
composer require nextpdf/artisan

Obtener el token de portador del Worker y cualquier credencial de R2 desde variables de entorno o desde un gestor de secretos. Nunca incluirlos en commits del repositorio.

CloudflareHtmlRenderer::render() valida el HTML y el destino, envía un POST autenticado al Worker y procesa la respuesta. El Worker devuelve bytes de PDF en bruto (Content-Type: application/pdf) o bien un cuerpo JSON con un campo pdf en base64. El renderer encapsula el resultado en un final readonly CloudflareRenderResult que contiene los bytes, el ancho solicitado, el alto, la ubicación del renderizado (derivada de la cabecera CF-Ray) y el tiempo de renderizado.

El puente distingue deliberadamente dos tipos de fallo:

  • CloudflareRenderException — el Worker respondió, pero el renderizado falló (un error HTTP o un cuerpo que no empieza por %PDF). Esto es un fallo de renderizado y nunca se reintenta con un respaldo.
  • CloudflareNotAvailableException — no se pudo alcanzar el edge y no había ningún respaldo utilizable disponible.

El respaldo local cubre la segunda situación. Cuando no se puede alcanzar el Worker y fallbackToLocal es true, el puente llama a un LocalRendererFactoryInterface proporcionado por la aplicación, y lo hace de forma diferida: el create() de la factoría solo se ejecuta en la ruta de respaldo. En un renderizado de respaldo, el renderLocation del resultado es la cadena literal local.

El puente protege el límite de red antes de que cualquier solicitud salga de PHP. Rechaza una URL de Worker que no sea HTTPS. Rechaza un host de Worker que se resuelva en un espacio de direcciones privado o reservado, comprobando todos los registros A y AAAA en lugar de solo el primero. También vuelve a resolver el host justo antes de conectar, lo que cierra la ventana de time-of-check/time-of-use frente al reataque de DNS. Cuando se proporciona una factoría de respuesta PSR-17 y, o bien un conjunto de IP resueltas, o bien pines SPKI, el puente usa un transporte cURL con fijación. Ese transporte vincula la conexión a las IP verificadas (CURLOPT_RESOLVE), impone la fijación de clave pública TLS (CURLOPT_PINNEDPUBLICKEY), verifica el par y el host, y no sigue redirecciones.

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

Por defecto, render() usa el ancho A4 (595.28 puntos) y un alto detectado automáticamente (heightPt: 0). Para ver la referencia completa de campos y el mapa de claves de fromArray(), consultar la página de configuración de Cloudflare enlazada en Véase también.

Construir la configuración, crear el renderer, renderizar y escribir los 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);
}

El token se lee desde el entorno; nunca se codifica directamente. workerUrl debe ser HTTPS; el puente rechaza una URL http:// antes de enviar cualquier solicitud.

En producción, conectar una factoría de renderer local para que, si no se puede alcanzar el Worker, se use el respaldo en lugar de fallar la solicitud, y configurar los pines TLS con un pin de reserva. El create() de la factoría solo se ejecuta en la ruta de respaldo.

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();
}
};
}
}

Conectar la factoría y los pines al renderer.

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,
);

Cuando se ejecuta el respaldo, el renderLocation del resultado es local y el heightPt es 0.0. El puente registra el respaldo primero en nivel warning y después en info. Configurar siempre un pin de reserva antes de una rotación de certificado, para que una rotación planificada no deje al puente sin acceso al endpoint.

  • Un error del Worker no es un fallo de alcanzabilidad. Un Worker que responde con un error HTTP o con un cuerpo mal formado lanza CloudflareRenderException y nunca se reintenta con el respaldo. Solo se recurre al respaldo cuando no se puede alcanzar el edge. Mantener separadas las dos ramas catch.
  • El respaldo necesita tanto el indicador como una factoría. Con fallbackToLocal: true pero sin una factoría conectada, un Worker inalcanzable lanza CloudflareNotAvailableException que nombra la factoría faltante. Conectar la factoría.
  • isAvailable() es una pista, no una garantía. Envía un HEAD autenticado y devuelve true para un estado por debajo de 500; el POST siguiente aún puede fallar. No tratarlo como un contrato.
  • La fijación es opcional. Un conjunto de pines vacío deshabilita la fijación. Usar un conjunto vacío solo con una cadena de certificados estable y conocida, y conservar un pin de reserva en cuanto se active la fijación.
  • fontFiles necesita un bucket de R2. El argumento fontFiles solo importa cuando la configuración establece r2FontBucket; de lo contrario, no tiene efecto.
  • El puente no firma. Devuelve bytes de PDF. Renderizar en el edge y luego firmar en el propio proceso, para que la clave de firma nunca cruce el límite del edge.

El renderizado en el edge traslada por completo el coste del navegador fuera de los hosts propios. El coste a cambio es un recorrido de ida y vuelta HTTPS al Worker más el propio tiempo de renderizado del Worker, que el resultado reporta como renderTimeMs. El puente aplica el tiempo de espera configurado a través del transporte con fijación. Establecerlo a partir de la latencia medida del Worker con margen, y mantenerlo por debajo de cualquier tiempo de espera del gateway de entrada. El paquete declara solo los límites que él mismo impone. No hace ninguna afirmación sobre los topes de CPU, de memoria ni de cuerpo de solicitud de la plataforma Cloudflare. Para esos límites, consultar la documentación de Cloudflare y el Worker.

  • El destino se valida antes de que la solicitud salga de PHP. Las URL que no son HTTPS se rechazan. Un host que se resuelve en un espacio de direcciones privado o reservado se rechaza en todos los registros A y AAAA. El host se vuelve a resolver justo antes de conectar, para defenderse del reataque de DNS.
  • El transporte con fijación vincula DNS y TLS. Con una factoría de respuesta y pines configurados, el puente vincula la conexión a las IP verificadas, impone la fijación SPKI, verifica el par y el host, y rechaza seguir redirecciones hacia un host no verificado.
  • La entrada está acotada. El HTML que supera maxHtmlSize (5 MB de forma predeterminada), un data URI base64 de tamaño excesivo y cualquier etiqueta <meta http-equiv="refresh"> se rechazan antes de enviar la solicitud.
  • Los secretos se enmascaran y son inmutables. apiToken y las claves de R2 llevan #[SensitiveParameter], de modo que se enmascaran en las trazas de pila, y los objetos de configuración son final readonly. Obtener los secretos del entorno o de un gestor de secretos; nunca incluirlos en commits del repositorio.
  • Nunca escribir un bloque catch vacío. Cada ejemplo captura el tipo de excepción específico y registra o termina con un código definido.

El modelo de seguridad completo —la defensa contra SSRF y reataque de DNS, la guía operativa de fijación y la postura de manejo de secretos— está en la página de seguridad y operaciones de Cloudflare enlazada en Véase también, que identifica las cláusulas pertinentes de OWASP y de RFC 7469.

Esta guía no formula por sí misma ninguna afirmación normativa sobre estándares. En las páginas de origen de seguridad y operaciones y de configuración de Cloudflare, la resolución de DNS de todos los registros del puente y la nueva comprobación TOCTOU se asignan a la guía de prevención de SSRF de OWASP, y su fijación de clave pública TLS y la recuperación con pin de reserva se asignan a RFC 7469. Esta página del cookbook reformula el uso y remite esas citas a esas páginas. El puente no realiza ninguna firma y no hace ninguna afirmación de conformidad de firma.