在 Cloudflare 邊緣算繪並提供本機備援
Cloudflare 橋接會將你的 HTML 送到 Cloudflare Worker 算繪 endpoint,再回傳 PDF。算繪在邊緣執行,因此不需要維護長時間執行的瀏覽器程序。你會建立一份僅限 HTTPS 的組態、接上一個 PSR-18 用戶端與多個 PSR-17 工廠、呼叫 render(),並可選擇接上一個本機 renderer,讓橋接在 Worker 無法連線時改用它。本指南涵蓋算繪呼叫、備援決策,以及橋接在任何請求離開程序之前強制執行的 SSRF、DNS 重新繫結與 TLS 公開金鑰釘選控制。
先確認前置需求:
- 已安裝 NextPDF 核心與
nextpdf/cloudflare。 - 一個 Worker endpoint 透過 HTTPS 提供算繪合約,並接受 bearer token。橋接會在送出任何內容之前,先拒絕非 HTTPS 的 Worker URL。
- 執行路徑上已備妥一個 PSR-18 用戶端(例如 Guzzle 7),以及 PSR-17 的 request 與 stream 工廠。若要使用釘選的 cURL 傳輸,還需額外提供一個 PSR-17 response 工廠與
ext-curl。 - 若要使用本機備援,需備妥
nextpdf/artisan(或另一個本機 renderer)。
這是一份操作指南。若要完成第一次可執行的算繪,請參閱 Cloudflare 快速入門。
安裝橋接、一個 PSR-18 用戶端,以及多個 PSR-17 工廠。
composer require nextpdf/cloudflare guzzlehttp/guzzle若要使用本機備援,請安裝一個可供橋接委派的本機 renderer。
composer require nextpdf/artisan從環境變數或 secrets 管理器取得 Worker 的 bearer token 與任何 R2 憑證。切勿將它們提交到版本控制。
概念說明
標題為「概念說明」的區段CloudflareHtmlRenderer::render() 會驗證 HTML 與目的地,向 Worker 送出一個已驗證的 POST,並剖析回應。Worker 會回傳原始 PDF 位元組(Content-Type: application/pdf),或一個含有 base64 pdf 欄位的 JSON 內文。renderer 會把結果對映到一個 final readonly CloudflareRenderResult,其中包含位元組、要求的寬度、高度、算繪位置(由 CF-Ray 標頭推導而來)以及算繪時間。
橋接刻意區分兩種失敗類別:
CloudflareRenderException——Worker 已回應,但算繪失敗(HTTP 錯誤,或內文開頭不是%PDF)。這屬於算繪失敗,絕不會以備援重試。CloudflareNotAvailableException——無法連到邊緣,且沒有可用的備援。
本機備援可補足第二個缺口。當 Worker 無法連線且 fallbackToLocal 為 true 時,橋接會以延遲方式呼叫你提供的 LocalRendererFactoryInterface——工廠的 create() 只會在備援路徑上執行。走備援算繪時,結果的 renderLocation 會是字面字串 local。
在任何請求離開 PHP 之前,橋接就會先防護網路邊界。它會拒絕非 HTTPS 的 Worker URL,也會拒絕 resolve(解析)到私有或保留位址空間的 Worker 主機,並檢查所有 A 與 AAAA 記錄,而不是只看第一筆。此外,橋接會在連線前的最後一刻重新解析主機,藉此關閉針對 DNS 重新繫結的 time-of-check/time-of-use 空窗。當你提供一個 PSR-17 response 工廠,並提供一組已解析出的 IP 集合或 SPKI 釘選值時,橋接就會改用釘選的 cURL 傳輸。該傳輸會把連線繫結到已審核的 IP(CURLOPT_RESOLVE)、強制執行 TLS 公開金鑰釘選(CURLOPT_PINNEDPUBLICKEY)、驗證對等端與主機,且不跟隨重新導向。
API 介面
標題為「API 介面」的區段// 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 = []): CloudflareRenderResultCloudflareHtmlRenderer::isAvailable(): boolrender() 預設使用 A4 寬度(595.28 點)與自動偵測的高度(heightPt: 0)。完整的欄位參考與 fromArray() 鍵對映,請參閱「另請參閱」下方連結的 Cloudflare 組態頁面。
程式碼範例——快速入門
標題為「程式碼範例——快速入門」的區段建立組態、建構 renderer、執行算繪,再寫出位元組。
<?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);}token 從環境變數讀取,絕不寫死。workerUrl 必須是 HTTPS;橋接會在送出任何請求之前先拒絕 http:// URL。
程式碼範例——正式環境
標題為「程式碼範例——正式環境」的區段在正式環境中,請接上一個本機 renderer 工廠,讓橋接在 Worker 無法連線時改用備援,而不是讓請求失敗,並為 TLS 釘選設定一個備用釘選值。工廠的 create() 只會在備援路徑上執行。
<?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(); } }; }}將工廠與釘選值接進 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,);備援執行時,結果的 renderLocation 會是 local,而 heightPt 會是 0.0。橋接會先以 warning、再以 info 等級記錄備援流程。請務必在憑證輪替之前設定好備用釘選值,讓排定的輪替不會把橋接鎖在 endpoint 之外。
邊界情況與陷阱
標題為「邊界情況與陷阱」的區段- Worker 錯誤不等於連線失敗。 Worker 若回應 HTTP 錯誤或格式不正確的內文,會引發
CloudflareRenderException,且絕不會以備援重試。只有在邊緣無法連線時才會走備援。請讓這兩個 catch 分支保持清楚區隔。 - 備援同時需要旗標與工廠。 若
fallbackToLocal: true但沒有接上工廠,Worker 無法連線時會引發一個指明缺少工廠的CloudflareNotAvailableException。請接上工廠。 isAvailable()是提示,不是保證。 它會送出一個已驗證的HEAD;只要狀態碼低於500就回傳true,但後續的POST仍可能失敗。別把它當成合約。- 釘選採選擇性啟用。 空的釘選集合會停用釘選。只有在憑證鏈穩定且已知時才使用空集合,而且一旦開始釘選就保留一個備用釘選值。
fontFiles需要一個 R2 儲存桶。fontFiles引數只有在組態設定了r2FontBucket時才有意義;否則不會有任何作用。- 橋接不負責簽章。 它回傳的是 PDF 位元組。請在邊緣算繪,然後在你自己的程序中簽章,讓簽章金鑰絕不跨越邊緣邊界。
邊緣算繪能把瀏覽器成本完全移出你的主機。代價則是一趟到 Worker 的 HTTPS 來回,加上 Worker 本身的算繪時間;這段耗時會以 renderTimeMs 回報。橋接會透過釘選的傳輸套用所設定的逾時。請依據實測的 Worker 延遲加上餘裕來設定逾時,並讓它低於任何上游閘道的逾時。套件只陳述自身強制執行的限制,不會對 Cloudflare 平台的 CPU、記憶體或請求內文上限做出任何聲明。關於這些限制,請查閱 Cloudflare 的文件與你的 Worker。
安全性注意事項
標題為「安全性注意事項」的區段- 目的地會在請求離開 PHP 之前先經過驗證。 非 HTTPS 的 URL 會被拒絕。若主機解析到私有或保留位址空間,會在所有 A 與 AAAA 記錄上被拒絕。主機會在連線前的最後一刻重新解析,以防範 DNS 重新繫結。
- 釘選的傳輸會繫結 DNS 與 TLS。 設定 response 工廠與釘選值後,橋接會把連線繫結到已審核的 IP、強制執行 SPKI 釘選、驗證對等端與主機,並拒絕跟隨重新導向至未審核的主機。
- 輸入有上限。 超過
maxHtmlSize(預設 5 MB)的 HTML、過大的 base64 data URI,以及任何<meta http-equiv="refresh">標籤,都會在請求送出之前被拒絕。 - 祕密會被遮蔽且不可變更。
apiToken與 R2 金鑰帶有#[SensitiveParameter],因此會從堆疊追蹤中被遮蔽,而組態物件則是final readonly。請從環境或 secrets 管理器取得祕密;切勿將它們提交到版本控制。 - 切勿寫出空的
catch區塊。 每個範例都會捕捉特定的例外類型,並記錄日誌或以已定義的代碼結束程式。
完整的安全模型——SSRF 與 DNS 重新繫結防護、釘選的操作指引,以及祕密處理的姿態——請見「另請參閱」下方連結的 Cloudflare 安全與維運頁面;該頁面已釘選相關的 OWASP 與 RFC 7469 條款。
符合性
標題為「符合性」的區段本指南本身不提出任何規範性標準聲明。在上游的 Cloudflare 安全與維運及組態頁面上,橋接的全記錄 DNS 解析與 TOCTOU 重新檢查被對映到 OWASP SSRF 防護指引,而其 TLS 公開金鑰釘選與備用釘選復原機制則被對映到 RFC 7469。本 cookbook 頁面只重述用法,相關引用則延伸至那些頁面。橋接不執行任何簽章,也不提出任何簽章符合性聲明。
另請參閱
標題為「另請參閱」的區段- 使用 Artisan Chrome renderer 把 HTML 算繪成 PDF——此處作為本機備援的程序內 renderer。
- Cloudflare 快速入門——第一次邊緣算繪與結果模型。
- Cloudflare 安全與維運——SSRF、DNS 重新繫結、釘選與祕密輪替。
- Cloudflare 正式環境用法——備援串接、遙測、R2 封存與 API 防護。