Перейти к содержимому

Отрисовка на периферии через Cloudflare с локальным резервным вариантом

Мост Cloudflare отправляет ваш HTML на конечную точку отрисовки Cloudflare Worker и возвращает PDF. Отрисовка выполняется на периферии, поэтому вам не нужно поддерживать постоянно запущенный процесс браузера. Вы создаёте конфигурацию, допускающую только HTTPS, подключаете клиент PSR-18 и фабрики PSR-17, вызываете render() и можете добавить локальный отрисовщик на случай недоступности Workers. В этом руководстве показаны вызов отрисовки, сценарий резервного варианта, а также механизмы защиты от подделки запросов на стороне сервера (SSRF), перепривязки Domain Name System (DNS) и закрепления открытого ключа Transport Layer Security (TLS), которые мост применяет до того, как какой-либо запрос покинет процесс.

Сразу о предварительных требованиях:

  • Установлены ядро NextPDF и nextpdf/cloudflare.
  • Конечная точка Worker реализует контракт отрисовки по HTTPS и принимает токен типа bearer. Мост отклоняет URL Worker без HTTPS ещё до отправки любых данных.
  • Доступны клиент 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

Загружайте токен типа bearer для Worker и любые учётные данные R2 из переменных окружения или менеджера секретов. Никогда не добавляйте их в систему контроля версий.

CloudflareHtmlRenderer::render() проверяет HTML и назначение, отправляет аутентифицированный POST на Worker и обрабатывает ответ. Worker возвращает необработанные байты PDF (Content-Type: application/pdf) либо тело JSON с полем pdf в формате base64. Отрисовщик сопоставляет ответ с объектом final readonly CloudflareRenderResult, который содержит байты PDF, запрошенные ширину и высоту, место отрисовки (полученное из заголовка CF-Ray) и время отрисовки.

Мост разделяет сбои на два явных класса:

  • CloudflareRenderException — Worker ответил, но отрисовка не удалась (ошибка HTTP или тело, не начинающееся с %PDF). Это сбой отрисовки, и он никогда не повторяется через резервный вариант.
  • CloudflareNotAvailableException — периферия недоступна, а пригодного резервного варианта не было.

Локальный резервный вариант покрывает второй случай. Когда Worker недоступен, а fallbackToLocal равно true, мост вызывает предоставленный вами LocalRendererFactoryInterface. Фабрика вызывается отложенно: create() выполняется только на пути резервного варианта. При отрисовке через резервный вариант renderLocation результата равен строковому литералу local.

Мост защищает сетевую границу до того, как какой-либо запрос покинет PHP. Он отклоняет URL Worker без HTTPS. Он отклоняет узел Worker, который разрешается в частное или зарезервированное адресное пространство, проверяя все записи A и AAAA, а не только первую. Он также повторно разрешает узел непосредственно перед подключением, что закрывает окно time-of-check/time-of-use (TOCTOU) для перепривязки DNS. Если вы предоставляете фабрику ответов PSR-17 и либо набор разрешённых IP-адресов, либо закрепления Subject Public Key Info (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; мост отклоняет URL вида http:// до отправки любого запроса.

В продакшене подключите фабрику локального отрисовщика, чтобы при недоступности 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,
);

Когда срабатывает резервный вариант, renderLocation результата равен local, а значение heightPt равно 0.0. Мост журналирует переход на резервный вариант: сначала на уровне warning, затем на уровне info. Всегда настраивайте резервное закрепление до ротации сертификата, чтобы плановая ротация не отрезала мост от конечной точки.

  • Ошибка Worker — это не сбой доступности. Worker, который возвращает ошибку HTTP или некорректное тело, вызывает CloudflareRenderException и никогда не повторяется через резервный вариант. Переключение на резервный вариант выполняется только при недоступности периферии. Держите две ветви catch раздельными.
  • Резервному варианту нужны и флаг, и фабрика. При fallbackToLocal: true, но без переданной фабрики недоступный Worker вызывает CloudflareNotAvailableException и указывает на отсутствующую фабрику. Подключите фабрику.
  • isAvailable() — это подсказка, а не гарантия. Метод отправляет аутентифицированный HEAD и возвращает true для статуса ниже 500; следующий POST всё равно может завершиться сбоем. Не рассматривайте его как контракт.
  • Закрепление необязательно. Пустой набор закреплений отключает закрепление. Используйте пустой набор только при стабильной, известной цепочке сертификатов, а при включённом закреплении держите резервное закрепление.
  • fontFiles требует bucket R2. Аргумент fontFiles имеет значение только тогда, когда в конфигурации задан r2FontBucket; иначе он не действует.
  • Мост не выполняет подписание. Он возвращает байты PDF. Выполняйте отрисовку на периферии, а подписание — в собственном процессе, чтобы ключ подписания никогда не пересекал границу периферии.

Отрисовка на периферии переносит браузерные затраты с ваших узлов. При этом вы по-прежнему оплачиваете один полный обмен по HTTPS с Worker плюс время отрисовки Worker, которое в результате указано как renderTimeMs. Мост применяет настроенный тайм-аут через транспорт с закреплением. Задавайте его с запасом относительно измеренной задержки Worker и держите ниже любого тайм-аута вышестоящего шлюза. Пакет сообщает только об ограничениях, которые применяет сам. Он не делает никаких утверждений о пределах платформы Cloudflare по CPU, памяти или размеру тела запроса. По этим ограничениям обращайтесь к документации Cloudflare и к вашему Worker.

  • Назначение проверяется до того, как запрос покинет PHP. URL-адреса без HTTPS отклоняются. Узел, который разрешается в частное или зарезервированное адресное пространство, отклоняется по всем записям A и AAAA. Узел повторно разрешается непосредственно перед подключением для защиты от перепривязки DNS.
  • Транспорт с закреплением связывает DNS и TLS. Если настроены фабрика ответов и закрепления, мост привязывает соединение к проверенным IP-адресам, применяет закрепление SPKI, проверяет узел и имя хоста и отказывается следовать за перенаправлениями на непроверенный хост.
  • Ввод ограничивается. HTML, превышающий maxHtmlSize (по умолчанию 5 МБ), слишком большой data URI в формате base64 и любой тег <meta http-equiv="refresh"> отклоняются до отправки запроса.
  • Секреты скрываются, конфигурация неизменяема. apiToken и ключи R2 помечены #[SensitiveParameter], поэтому они скрываются в трассировках стека, а объекты конфигурации объявлены как final readonly. Загружайте секреты из окружения или менеджера секретов; никогда не добавляйте их в систему контроля версий.
  • Никогда не пишите пустой блок catch. Каждый пример перехватывает конкретный тип исключения и записывает его в журнал или завершается с определённым кодом.

Полная модель безопасности приведена на странице безопасности и эксплуатации Cloudflare в разделе “См. также”. Она охватывает защиту от SSRF и перепривязки DNS, операции закрепления, обработку секретов, а также соответствующие пункты OWASP и RFC 7469.

Это руководство не содержит собственных нормативных заявлений о стандартах. На связанных страницах безопасности и эксплуатации, а также конфигурации Cloudflare указано, что разрешение DNS по всем записям и повторная проверка TOCTOU моста соответствуют рекомендациям OWASP по предотвращению SSRF, а закрепление открытого ключа TLS и восстановление через резервное закрепление соответствуют RFC 7469. Эта страница cookbook повторяет порядок действий и оставляет эти ссылки на упомянутых страницах. Мост не выполняет подписание и не делает заявлений о соответствии в области подписей.