跳转到内容

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

Terminal window
composer require nextpdf/cloudflare guzzlehttp/guzzle

若需本地回退,请安装一个桥接可委派给它的本地渲染器。

Terminal window
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 且 fallbackToLocaltrue 时,桥接会调用你提供的 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),校验对端与主机,并且不跟随重定向。

// 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 配置页面。

构建配置,构建渲染器,执行渲染,并写出字节。

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

令牌从环境中读取,绝不硬编码。workerUrl 必须是 HTTPS;桥接会在发送任何请求前拒绝 http:// URL。

在生产环境中,接入本地渲染器工厂,让不可达的 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();
}
};
}
}

将工厂和固定值接入渲染器。

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

当回退运行时,结果的 renderLocationlocal,且 heightPt0.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。本菜谱页面只重述用法,并把这些引用保留在那些页面中。桥接不执行任何签名,也不作任何签名合规性主张。