Aller au contenu

Effectuer le rendu en périphérie avec Cloudflare, avec repli local

Le pont Cloudflare envoie ton HTML à un endpoint de rendu Cloudflare Worker et renvoie le PDF. Le rendu s’exécute en périphérie, sans processus de navigateur de longue durée à gérer. Tu construis une configuration exclusivement HTTPS, câbles un client PSR-18 et des fabriques PSR-17, appelles render(), puis câbles éventuellement un renderer local vers lequel le pont se replie quand le Worker est injoignable. Ce guide couvre l’appel de rendu, la décision de repli et les contrôles SSRF, anti-DNS-rebinding et d’épinglage de clé publique TLS que le pont impose avant qu’aucune requête ne quitte le processus.

Les prérequis, d’emblée :

  • Le cœur NextPDF et nextpdf/cloudflare sont installés.
  • Un endpoint Worker sert le contrat de rendu en HTTPS et accepte un jeton bearer. Le pont rejette toute URL de Worker non HTTPS avant d’envoyer quoi que ce soit.
  • Un client PSR-18 (par exemple Guzzle 7) ainsi que des fabriques de requête et de flux PSR-17 sont disponibles dans le chemin de classes. Pour le transport cURL épinglé, fournis aussi une fabrique de réponse PSR-17 et ext-curl.
  • Pour le repli local, nextpdf/artisan (ou un autre renderer local) est disponible.

Ceci est un guide pratique. Pour un premier rendu exécutable, lis le démarrage rapide Cloudflare.

Installe le pont, un client PSR-18 et des fabriques PSR-17.

Fenêtre de terminal
composer require nextpdf/cloudflare guzzlehttp/guzzle

Pour le repli local, installe un renderer local auquel le pont pourra déléguer.

Fenêtre de terminal
composer require nextpdf/artisan

Récupère le jeton bearer du Worker et les éventuels identifiants R2 depuis des variables d’environnement ou un gestionnaire de secrets. Ne les commite jamais.

CloudflareHtmlRenderer::render() valide le HTML et la destination, envoie un POST authentifié au Worker, puis analyse la réponse. Le Worker renvoie soit des octets PDF bruts (Content-Type: application/pdf), soit un corps JSON contenant un champ pdf encodé en base64. Le renderer mappe le résultat vers un final readonly CloudflareRenderResult qui contient les octets, la largeur demandée, la hauteur, l’emplacement de rendu (dérivé de l’en-tête CF-Ray) et le temps de rendu.

Le pont distingue volontairement deux classes d’échec :

  • CloudflareRenderException — le Worker a répondu, mais le rendu a échoué (une erreur HTTP ou un corps qui ne commence pas par %PDF). C’est un échec de rendu et il n’est jamais retenté avec un repli.
  • CloudflareNotAvailableException — la périphérie n’a pas pu être jointe et aucun repli utilisable n’était disponible.

Le repli local couvre ce second cas. Quand le Worker ne peut pas être joint et que fallbackToLocal vaut true, le pont appelle un LocalRendererFactoryInterface que tu fournis, et il le fait paresseusement : le create() de la fabrique ne s’exécute que sur le chemin de repli. Lors d’un rendu de repli, le renderLocation du résultat est la chaîne littérale local.

Le pont défend la frontière réseau avant qu’aucune requête ne quitte PHP. Il rejette toute URL de Worker non HTTPS. Il rejette un hôte de Worker qui se résout vers un espace d’adressage privé ou réservé en vérifiant tous les enregistrements A et AAAA, pas seulement le premier. Il résout aussi l’hôte à nouveau juste avant de se connecter, ce qui ferme la fenêtre time-of-check/time-of-use contre le DNS rebinding. Quand tu fournis une fabrique de réponse PSR-17 ainsi qu’un ensemble d’IP résolues ou des épingles SPKI, le pont utilise un transport cURL épinglé. Ce transport lie la connexion aux IP vérifiées (CURLOPT_RESOLVE), impose l’épinglage de clé publique TLS (CURLOPT_PINNEDPUBLICKEY), vérifie le pair et l’hôte, et ne suit pas les redirections.

// 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() utilise par défaut la largeur A4 (595.28 points) et une hauteur détectée automatiquement (heightPt: 0). Pour la référence complète des champs et le tableau des clés de fromArray(), consulte la page de configuration Cloudflare liée sous Voir aussi.

Construis la configuration, instancie le renderer, lance le rendu et écris les octets.

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

Le jeton est lu depuis l’environnement, jamais codé en dur. workerUrl doit être en HTTPS ; le pont rejette une URL http:// avant d’envoyer la moindre requête.

En production, câble une fabrique de renderer local pour qu’un Worker injoignable déclenche un repli au lieu de faire échouer la requête, et configure les épingles TLS avec une épingle de secours. Le create() de la fabrique ne s’exécute que sur le chemin de repli.

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

Câble la fabrique et les épingles dans le 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,
);

Quand le repli s’exécute, le renderLocation du résultat vaut local et heightPt vaut 0.0. Le pont journalise le repli en warning, puis en info. Configure toujours une épingle de secours avant une rotation de certificat, afin qu’une rotation planifiée ne coupe pas le pont de l’endpoint.

  • Une erreur du Worker n’est pas un échec d’accessibilité. Un Worker qui répond avec une erreur HTTP ou un corps malformé lève CloudflareRenderException et n’est jamais retenté avec le repli. Seule une périphérie injoignable déclenche le repli. Garde les deux branches catch distinctes.
  • Le repli nécessite à la fois le drapeau et une fabrique. Avec fallbackToLocal: true mais sans fabrique câblée, un Worker injoignable lève CloudflareNotAvailableException qui indique la fabrique manquante. Câble la fabrique.
  • isAvailable() est un indice, pas une garantie. Il envoie un HEAD authentifié et renvoie true pour un statut inférieur à 500 ; le POST qui suit peut quand même échouer. Ne le traite pas comme un contrat.
  • L’épinglage est optionnel. Un ensemble d’épingles vide désactive l’épinglage. N’utilise un ensemble vide qu’avec une chaîne de certificats stable et connue, et garde une épingle de secours dès que tu actives l’épinglage.
  • fontFiles nécessite un bucket R2. L’argument fontFiles n’est pris en compte que lorsque la configuration définit r2FontBucket ; sinon il n’a aucun effet.
  • Le pont ne signe pas. Il renvoie des octets PDF. Fais le rendu en périphérie, puis signe dans ton propre processus afin que la clé de signature ne franchisse jamais la frontière de la périphérie.

Le rendu en périphérie déplace entièrement le coût du navigateur hors de tes hôtes. Ce que tu paies à la place, c’est un aller-retour HTTPS vers le Worker plus le temps de rendu propre au Worker, que le résultat expose comme renderTimeMs. Le pont applique le délai d’expiration configuré via le transport épinglé. Règle-le à partir de la latence mesurée du Worker, avec une marge, et garde-le inférieur à tout délai d’expiration de passerelle en amont. Le package n’énonce que les limites qu’il impose lui-même. Il ne fait aucune affirmation sur les plafonds de CPU, de mémoire ou de corps de requête de la plateforme Cloudflare. Pour ceux-là, consulte la documentation de Cloudflare et ton Worker.

  • La destination est validée avant que la requête ne quitte PHP. Les URL non HTTPS sont rejetées. Un hôte qui se résout vers un espace d’adressage privé ou réservé est rejeté sur l’ensemble des enregistrements A et AAAA. L’hôte est résolu à nouveau juste avant de se connecter, pour se défendre contre le DNS rebinding.
  • Le transport épinglé lie le DNS et le TLS. Avec une fabrique de réponse et des épingles configurées, le pont lie la connexion aux IP vérifiées, impose l’épinglage SPKI, vérifie le pair et l’hôte, et refuse de suivre les redirections vers un hôte non vérifié.
  • L’entrée est bornée. Un HTML dépassant maxHtmlSize (5 Mo par défaut), une URI de données base64 surdimensionnée et toute balise <meta http-equiv="refresh"> sont rejetés avant l’envoi de la requête.
  • Les secrets sont expurgés et immuables. apiToken et les clés R2 portent #[SensitiveParameter], donc ils sont expurgés des traces de pile, et les objets de configuration sont final readonly. Récupère les secrets depuis l’environnement ou un gestionnaire de secrets ; ne les commite jamais.
  • N’écris jamais un bloc catch vide. Chaque exemple intercepte le type d’exception spécifique et journalise ou sort avec un code défini.

Le modèle de sécurité complet — la défense SSRF et anti-DNS-rebinding, le guide opérationnel d’épinglage et la posture de gestion des secrets — se trouve sur la page sécurité et opérations Cloudflare liée sous Voir aussi, avec les clauses OWASP et RFC 7469 pertinentes.

Ce guide ne formule de lui-même aucune affirmation normative de conformité à une norme. Dans les pages amont Cloudflare sécurité et opérations et configuration, la résolution DNS du pont sur tous les enregistrements et sa re-vérification TOCTOU sont mappées au guide de prévention SSRF de l’OWASP, et son épinglage de clé publique TLS ainsi que sa récupération par épingle de secours sont mappés au RFC 7469. Cette page du Cookbook réénonce l’usage et s’en remet à ces pages pour les citations. Le pont n’effectue aucune signature et ne formule aucune affirmation de conformité de signature.