在 Cloudflare 边缘渲染并提供本地回退
Cloudflare 桥接会将你的 HTML 发送到 Cloudflare Worker 渲染端点,并返回 PDF。渲染在边缘运行,你无需运维任何长期运行的浏览器进程。你需要构建一个仅限 HTTPS 的配置,接入 PSR-18 客户端和若干 PSR-17 工厂,调用 render(),并可选择接入本地渲染器,供桥接在 Worker 不可达时回退。本指南涵盖渲染调用、回退决策,以及桥接在任何请求离开进程前强制执行的 SSRF、DNS 重绑定与 TLS 公钥固定控制。
先列出先决条件:
- 已安装 NextPDF 核心与
nextpdf/cloudflare。 - 一个 Worker 端点通过 HTTPS 提供渲染契约,并接受 Bearer 令牌。桥接会在发送任何内容前拒绝非 HTTPS 的 Worker URL。
- 运行环境中可用一个 PSR-18 客户端(例如 Guzzle 7)以及 PSR-17 请求工厂和流工厂。若要使用固定的 cURL 传输,还需提供一个 PSR-17 响应工厂和
ext-curl。 - 若需本地回退,请准备
nextpdf/artisan(或另一个本地渲染器)。
这是一篇操作指南。如果想先完成一次可运行的渲染,请阅读 Cloudflare 快速入门。
安装桥接、PSR-18 客户端以及若干 PSR-17 工厂。
composer require nextpdf/cloudflare guzzlehttp/guzzle若需本地回退,请安装一个桥接可委派给它的本地渲染器。
composer require nextpdf/artisan从环境变量或密钥管理器获取 Worker 的 Bearer 令牌以及任何 R2 凭据。切勿将它们提交到版本库。
概念性概述
标题为“概念性概述”的章节CloudflareHtmlRenderer::render() 会校验 HTML 和目标地址,向 Worker 发送经过认证的 POST,并解析响应。Worker 返回的要么是原始 PDF 字节(Content-Type: application/pdf),要么是带有 base64 pdf 字段的 JSON 主体。渲染器会将结果映射为 final readonly CloudflareRenderResult,其中包含字节、请求的宽度和高度、渲染位置(由 CF-Ray 标头推导)以及渲染时间。
桥接刻意区分两类失败:
CloudflareRenderException—— Worker 已响应,但渲染失败(HTTP 错误,或主体并非以%PDF开头)。这是一次渲染失败,绝不会通过回退重试。CloudflareNotAvailableException—— 无法到达边缘,并且没有可用回退。
本地回退弥补的是第二类缺口。当无法到达 Worker 且 fallbackToLocal 为 true 时,桥接会调用你提供的 LocalRendererFactoryInterface,并以惰性方式执行:工厂的 create() 只会在回退路径上运行。在一次回退渲染中,结果的 renderLocation 是字面字符串 local。
桥接会在任何请求离开 PHP 之前守护网络边界。它会拒绝非 HTTPS 的 Worker URL;拒绝解析到私有或保留地址空间的 Worker 主机,并检查所有 A 与 AAAA 记录,而不仅仅是第一条;还会在连接之前立即重新解析主机,从而关闭 DNS 重绑定可利用的 time-of-check/time-of-use 窗口。当你提供 PSR-17 响应工厂,以及已解析的 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 配置页面。
代码示例 —— 快速入门
标题为“代码示例 —— 快速入门”的章节构建配置,构建渲染器,执行渲染,并写出字节。
<?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);}令牌从环境中读取,绝不硬编码。workerUrl 必须是 HTTPS;桥接会在发送任何请求前拒绝 http:// URL。
代码示例 —— 生产环境
标题为“代码示例 —— 生产环境”的章节在生产环境中,接入本地渲染器工厂,让不可达的 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(); } }; }}将工厂和固定值接入渲染器。
<?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 级别记录回退。务必在证书轮换前配置备用固定值,避免一次有计划的轮换把桥接锁在端点之外。
边缘情形与陷阱
标题为“边缘情形与陷阱”的章节- Worker 错误不是可达性失败。 一个以 HTTP 错误或畸形主体响应的 Worker 会引发
CloudflareRenderException,且绝不会通过回退重试。只有边缘不可达时才会回退。务必保持两个 catch 分支的区分。 - 回退同时需要标志和工厂。 当
fallbackToLocal: true但未接入工厂时,不可达的 Worker 会引发CloudflareNotAvailableException,并指出缺失的工厂。请接入工厂。 isAvailable()是一种提示,而非保证。 它会发送经过认证的HEAD,并在状态码低于500时返回true;随后的POST仍可能失败。不要把它当作契约。- 固定是可选启用的。 空的固定集合会禁用固定。只有在证书链稳定且已知的情况下才使用空集合;一旦启用固定,就保留一个备用固定值。
fontFiles需要一个 R2 存储桶。 只有在配置设置了r2FontBucket时,fontFiles参数才会生效;否则它没有任何效果。- 桥接不进行签名。 它返回 PDF 字节。先在边缘渲染,然后在你自己的进程中签名,让签名密钥绝不跨越边缘边界。
边缘渲染会把浏览器成本完全从你的主机移走。相应代价是一次到 Worker 的 HTTPS 往返,加上 Worker 自身的渲染时间;结果会以 renderTimeMs 报告。桥接会通过固定传输应用所配置的超时。请根据实测的 Worker 延迟并留出余量来设置它,同时确保它低于任何上游网关超时。该软件包只声明它自身强制执行的限制;它对 Cloudflare 平台的 CPU、内存或请求体上限不作任何声明。关于这些限制,请查阅 Cloudflare 文档和你的 Worker。
安全说明
标题为“安全说明”的章节- 目标地址会在请求离开 PHP 之前被校验。 非 HTTPS 的 URL 会被拒绝。解析到私有或保留地址空间的主机会在所有 A 与 AAAA 记录上被拒绝。主机会在连接前立即重新解析,以防御 DNS 重绑定。
- 固定传输会绑定 DNS 与 TLS。 配置响应工厂和固定值后,桥接会将连接绑定到经核验的 IP,强制执行 SPKI 固定,校验对端与主机,并拒绝跟随重定向到未经核验的主机。
- 输入是有界的。 超过
maxHtmlSize(默认 5 MB)的 HTML、过大的 base64 data URI,以及任何<meta http-equiv="refresh">标签,都会在请求发送前被拒绝。 - 机密会被脱敏且不可变。
apiToken和 R2 密钥带有#[SensitiveParameter],因此它们会从堆栈跟踪中被脱敏,且配置对象是final readonly。从环境或密钥管理器获取机密;切勿将它们提交到版本库。 - 切勿编写空的
catch块。 每个示例都会捕获特定的异常类型,并记录日志或以确定的退出码退出。
完整的安全模型 —— SSRF 与 DNS 重绑定防御、固定的运营指南,以及机密处理姿态 —— 见“另请参阅”中链接的 Cloudflare security-and-operations 页面,该页面锚定了相关的 OWASP 与 RFC 7469 条款。
合规性
标题为“合规性”的章节本指南本身不作任何规范性标准主张。上游的 Cloudflare security-and-operations 与配置页面将桥接的全记录 DNS 解析和 TOCTOU 复检映射到 OWASP SSRF 防护指南,并将其 TLS 公钥固定与备用固定值恢复映射到 RFC 7469。本菜谱页面只重述用法,并把这些引用保留在那些页面中。桥接不执行任何签名,也不作任何签名合规性主张。
另请参阅
标题为“另请参阅”的章节- 使用 Artisan Chrome 渲染器将 HTML 渲染为 PDF —— 这里用作本地回退的进程内渲染器。
- Cloudflare 快速入门 —— 第一次边缘渲染与结果模型。
- Cloudflare 安全与运营 —— SSRF、DNS 重绑定、固定以及机密轮换。
- Cloudflare 生产环境用法 —— 回退接入、遥测、R2 归档以及 API 保护。