跳到內容

在 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 工廠。

Terminal window
composer require nextpdf/cloudflare guzzlehttp/guzzle

若要使用本機備援,請安裝一個可供橋接委派的本機 renderer。

Terminal window
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 無法連線且 fallbackToLocaltrue 時,橋接會以延遲方式呼叫你提供的 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)、驗證對等端與主機,且不跟隨重新導向。

// 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() 預設使用 A4 寬度(595.28 點)與自動偵測的高度(heightPt: 0)。完整的欄位參考與 fromArray() 鍵對映,請參閱「另請參閱」下方連結的 Cloudflare 組態頁面。

建立組態、建構 renderer、執行算繪,再寫出位元組。

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

token 從環境變數讀取,絕不寫死。workerUrl 必須是 HTTPS;橋接會在送出任何請求之前先拒絕 http:// URL。

在正式環境中,請接上一個本機 renderer 工廠,讓橋接在 Worker 無法連線時改用備援,而不是讓請求失敗,並為 TLS 釘選設定一個備用釘選值。工廠的 create() 只會在備援路徑上執行。

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

將工廠與釘選值接進 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,
);

備援執行時,結果的 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 頁面只重述用法,相關引用則延伸至那些頁面。橋接不執行任何簽章,也不提出任何簽章符合性聲明。