Zum Inhalt springen

Rendering am Edge mit Cloudflare und lokalem Fallback

Die Cloudflare-Brücke sendet Ihr HTML an einen Cloudflare-Worker-Render-Endpunkt und gibt das PDF zurück. Das Rendering läuft am Edge, ohne dass Sie einen langlebigen Browser-Prozess betreiben müssen. Sie erstellen eine reine HTTPS-Konfiguration, binden einen PSR-18-Client und PSR-17-Factories ein, rufen render() auf und richten optional einen lokalen Renderer ein, auf den die Brücke zurückfällt, wenn der Worker nicht erreichbar ist. Dieser Leitfaden behandelt den Render-Aufruf, die Fallback-Entscheidung und die SSRF-, DNS-Rebinding- und TLS-Public-Key-Pinning-Kontrollen, die die Brücke erzwingt, bevor eine Anfrage den Prozess verlässt.

Vorab die Voraussetzungen:

  • NextPDF core und nextpdf/cloudflare sind installiert.
  • Ein Worker-Endpunkt erfüllt den Render-Kontrakt über HTTPS und akzeptiert ein Bearer-Token. Die Brücke weist eine Worker-URL ohne HTTPS ab, bevor sie etwas sendet.
  • Ein PSR-18-Client (zum Beispiel Guzzle 7) sowie PSR-17-Request- und -Stream-Factories sind verfügbar. Für den gepinnten cURL-Transport stellen Sie zusätzlich eine PSR-17-Response-Factory und ext-curl bereit.
  • Für den lokalen Fallback steht nextpdf/artisan (oder ein anderer lokaler Renderer) bereit.

Dies ist eine Anleitung. Für ein erstes lauffähiges Rendering lesen Sie den Cloudflare-Quickstart.

Installieren Sie die Brücke, einen PSR-18-Client und PSR-17-Factories.

Terminal-Fenster
composer require nextpdf/cloudflare guzzlehttp/guzzle

Installieren Sie für den lokalen Fallback einen lokalen Renderer, an den die Brücke delegieren kann.

Terminal-Fenster
composer require nextpdf/artisan

Beziehen Sie das Worker-Bearer-Token und etwaige R2-Anmeldedaten aus Umgebungsvariablen oder einem Secrets-Manager. Übernehmen Sie sie niemals in einen Commit.

CloudflareHtmlRenderer::render() validiert das HTML und das Ziel, sendet einen authentifizierten POST an den Worker und parst die Antwort. Der Worker gibt entweder rohe PDF-Bytes zurück (Content-Type: application/pdf) oder einen JSON-Body mit einem base64-codierten pdf-Feld. Der Renderer bildet das Ergebnis auf ein final readonly CloudflareRenderResult ab, das die Bytes, die angeforderte Breite, die Höhe, den aus dem CF-Ray-Header abgeleiteten Render-Ort und die Render-Zeit enthält.

Die Brücke unterscheidet bewusst zwischen zwei Fehlerklassen:

  • CloudflareRenderException — der Worker hat geantwortet, aber das Rendern ist fehlgeschlagen (ein HTTP-Fehler oder ein Body, der nicht mit %PDF beginnt). Das ist ein Render-Fehler und wird niemals mit einem Fallback wiederholt.
  • CloudflareNotAvailableException — der Edge war nicht erreichbar und es stand kein nutzbarer Fallback bereit.

Der lokale Fallback deckt den zweiten Fall ab. Wenn der Worker nicht erreichbar ist und fallbackToLocal auf true steht, ruft die Brücke ein von Ihnen bereitgestelltes LocalRendererFactoryInterface auf, und zwar lazy — das create() der Factory läuft nur auf dem Fallback-Pfad. Bei einem Fallback-Render ist die renderLocation des Ergebnisses der literale String local.

Die Brücke schützt die Netzwerkgrenze, bevor eine Anfrage PHP verlässt. Sie weist eine Worker-URL ohne HTTPS ab. Sie weist einen Worker-Host ab, der in privaten oder reservierten Adressraum auflöst, und prüft dabei alle A- und AAAA-Records statt nur den ersten. Außerdem löst sie den Host unmittelbar vor dem Verbinden erneut auf, was das time-of-check/time-of-use-Fenster gegen DNS-Rebinding schließt. Wenn Sie eine PSR-17-Response-Factory und entweder ein aufgelöstes IP-Set oder SPKI-Pins bereitstellen, nutzt die Brücke einen gepinnten cURL-Transport. Dieser Transport bindet die Verbindung an die geprüften IPs (CURLOPT_RESOLVE), erzwingt TLS-Public-Key-Pinning (CURLOPT_PINNEDPUBLICKEY), verifiziert Peer und Host und folgt keinen Redirects.

// 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() verwendet standardmäßig die A4-Breite (595.28 Punkt) und eine automatisch ermittelte Höhe (heightPt: 0). Die vollständige Feldreferenz und die fromArray()-Schlüsselzuordnung findest du auf der unter „Siehe auch“ verlinkten Cloudflare-Konfigurationsseite.

Konstruieren Sie die Config, bauen Sie den Renderer, rendern Sie und schreiben Sie die 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);
}

Das Token wird aus der Umgebung gelesen und niemals fest im Code hinterlegt. workerUrl muss HTTPS sein; die Brücke weist eine http://-URL ab, bevor sie eine Anfrage sendet.

Binden Sie in der Produktion eine lokale Renderer-Factory ein, damit bei einem nicht erreichbaren Worker auf den lokalen Renderer zurückgefallen wird, statt die Anfrage scheitern zu lassen, und konfigurieren Sie TLS-Pins mit einem Backup-Pin. Das create() der Factory läuft nur auf dem Fallback-Pfad.

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

Binden Sie die Factory und die Pins in den Renderer ein.

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

Wenn der Fallback läuft, ist die renderLocation des Ergebnisses local und heightPt ist 0.0. Die Brücke protokolliert den Fallback zuerst auf warning, dann auf info. Konfigurieren Sie vor einer Zertifikatsrotation stets einen Backup-Pin, damit eine geplante Rotation die Brücke nicht vom Endpunkt aussperrt.

  • Ein Worker-Fehler ist kein Erreichbarkeitsfehler. Ein Worker, der mit einem HTTP-Fehler oder einem fehlerhaften Body antwortet, löst CloudflareRenderException aus und wird niemals mit dem Fallback wiederholt. Nur bei einem nicht erreichbaren Edge wird zurückgefallen. Halten Sie die beiden catch-Zweige getrennt.
  • Der Fallback braucht sowohl das Flag als auch eine Factory. Bei fallbackToLocal: true, aber ohne eingebundene Factory löst ein nicht erreichbarer Worker eine CloudflareNotAvailableException aus, die die fehlende Factory benennt. Binden Sie die Factory ein.
  • isAvailable() ist ein Hinweis, keine Garantie. Sie sendet einen authentifizierten HEAD und gibt true für einen Status unter 500 zurück; der folgende POST kann dennoch fehlschlagen. Behandeln Sie dies nicht als Kontrakt.
  • Pinning ist opt-in. Ein leeres Pin-Set deaktiviert das Pinning. Verwenden Sie ein leeres Set nur mit einer stabilen, bekannten Zertifikatskette und halten Sie einen Backup-Pin vor, sobald Sie pinnen.
  • fontFiles braucht einen R2-Bucket. Das fontFiles-Argument ist nur relevant, wenn die Config r2FontBucket setzt; andernfalls hat es keine Wirkung.
  • Die Brücke signiert nicht. Sie gibt PDF-Bytes zurück. Rendern Sie am Edge und signieren Sie anschließend im eigenen Prozess, damit der Signaturschlüssel niemals die Edge-Grenze überschreitet.

Edge-Rendering verlagert die Browser-Kosten vollständig von Ihren Hosts weg. Was stattdessen anfällt, ist ein HTTPS-Round-Trip zum Worker plus die eigentliche Render-Zeit des Workers, die das Ergebnis als renderTimeMs meldet. Die Brücke wendet das konfigurierte Timeout über den gepinnten Transport an. Setzen Sie es anhand der gemessenen Worker-Latenz mit Puffer und halten Sie es unter jedem vorgelagerten Gateway-Timeout. Das Paket nennt nur die Limits, die es selbst erzwingt. Es trifft keine Aussage über CPU-, Speicher- oder Request-Body-Obergrenzen der Cloudflare-Plattform. Konsultieren Sie dafür die Dokumentation von Cloudflare und Ihren Worker.

  • Das Ziel wird validiert, bevor die Anfrage PHP verlässt. URLs ohne HTTPS werden abgewiesen. Ein Host, der in privaten oder reservierten Adressraum auflöst, wird über alle A- und AAAA-Records hinweg abgewiesen. Der Host wird unmittelbar vor dem Verbinden erneut aufgelöst, um vor DNS-Rebinding zu schützen.
  • Der gepinnte Transport bindet DNS und TLS. Mit konfigurierter Response-Factory und Pins bindet die Brücke die Verbindung an die geprüften IPs, erzwingt SPKI-Pinning, verifiziert Peer und Host und folgt keinen Redirects zu einem ungeprüften Host.
  • Die Eingabe ist begrenzt. HTML über maxHtmlSize (Standard 5 MB), ein übergroßer base64-Data-URI und jedes <meta http-equiv="refresh">-Tag werden abgewiesen, bevor die Anfrage gesendet wird.
  • Secrets werden geschwärzt und sind unveränderlich. apiToken und R2-Schlüssel tragen #[SensitiveParameter], sodass sie aus Stack-Traces geschwärzt werden, und die Config-Objekte sind final readonly. Beziehen Sie Secrets aus der Umgebung oder einem Secrets-Manager; übernehmen Sie sie niemals in einen Commit.
  • Schreiben Sie niemals einen leeren catch-Block. Jedes Beispiel fängt den spezifischen Exception-Typ ab und protokolliert oder beendet mit einem definierten Code.

Das vollständige Sicherheitsmodell — die SSRF- und DNS-Rebinding-Abwehr, die operative Pinning-Anleitung und der Umgang mit Secrets — finden Sie auf der unter „Siehe auch“ verlinkten Cloudflare-Seite zu Sicherheit und Betrieb, die die relevanten OWASP- und RFC 7469-Klauseln verankert.

Dieser Leitfaden erhebt keinen eigenen normativen Standardsanspruch. Die vorgelagerten Cloudflare-Seiten zu Sicherheit und Betrieb sowie zur Konfiguration ordnen die DNS-Auflösung über alle Records und die TOCTOU-Neuprüfung der Brücke der OWASP-SSRF-Präventionsanleitung zu; ihr TLS-Public-Key-Pinning und die Backup-Pin-Wiederherstellung ordnen sie RFC 7469 zu. Diese Cookbook-Seite beschreibt die Verwendung und überlässt jene Zitate diesen Seiten. Die Brücke führt keine Signierung durch und erhebt keinen Signaturkonformitätsanspruch.