Lewati ke konten

Render di edge dengan Cloudflare dan fallback lokal

Bridge Cloudflare mengirim HTML Anda ke endpoint render Cloudflare Worker dan mengembalikan PDF. Proses render berjalan di edge, sehingga Anda tidak perlu menjalankan proses browser berumur panjang. Anda membuat konfigurasi yang hanya menerima HTTPS, menyambungkan klien PHP Standards Recommendation (PSR)-18 dan factory PSR-17, memanggil render(), dan dapat menambahkan renderer lokal ketika Worker tidak dapat dijangkau. Panduan ini memperlihatkan pemanggilan render, jalur fallback, serta kontrol Server-Side Request Forgery (SSRF), DNS-rebinding (Domain Name System), dan public-key-pinning Transport Layer Security (TLS) yang diberlakukan bridge sebelum permintaan apa pun keluar dari proses.

Prasyarat awal:

  • Core NextPDF dan nextpdf/cloudflare telah terpasang.
  • Sebuah endpoint Worker melayani kontrak render melalui HTTPS dan menerima bearer token. Bridge menolak URL Worker non-HTTPS sebelum mengirim apa pun.
  • Klien PSR-18 (misalnya Guzzle 7) serta factory request dan stream PSR-17 tersedia. Untuk transport cURL yang di-pin, sediakan juga response factory PSR-17 dan ext-curl.
  • Untuk fallback lokal, nextpdf/artisan (atau renderer lokal lain) tersedia.

Ini adalah panduan praktis. Untuk render pertama yang dapat dijalankan, mulailah dengan quickstart Cloudflare.

Pasang bridge, klien PSR-18, dan factory PSR-17.

Terminal window
composer require nextpdf/cloudflare guzzlehttp/guzzle

Untuk fallback lokal, pasang renderer lokal yang dapat dipanggil oleh bridge.

Terminal window
composer require nextpdf/artisan

Muat bearer token Worker dan kredensial R2 yang diperlukan dari variabel lingkungan atau pengelola rahasia. Jangan pernah meng-commit nilai tersebut.

CloudflareHtmlRenderer::render() memvalidasi HTML dan tujuan, mengirim POST terautentikasi ke Worker, lalu mem-parsing respons. Worker mengembalikan byte PDF mentah (Content-Type: application/pdf) atau body JSON dengan field pdf base64. Renderer memetakan respons menjadi final readonly CloudflareRenderResult yang membawa byte, lebar yang diminta, tinggi, lokasi render (diturunkan dari header CF-Ray), dan waktu render.

Bridge membagi kegagalan ke dalam dua kelas eksplisit:

  • CloudflareRenderException — Worker menjawab tetapi proses render gagal (galat HTTP atau body yang tidak diawali dengan %PDF). Ini adalah kegagalan render dan tidak pernah dicoba ulang dengan fallback.
  • CloudflareNotAvailableException — edge tidak dapat dijangkau dan tidak ada fallback yang dapat digunakan.

Fallback lokal menangani kasus kedua. Ketika Worker tidak dapat dijangkau dan fallbackToLocal bernilai true, bridge memanggil LocalRendererFactoryInterface yang Anda sediakan. Proses ini dilakukan secara lazy: create() milik factory hanya berjalan pada jalur fallback. Pada render fallback, renderLocation pada hasil adalah string literal local.

Bridge melindungi batas jaringan sebelum permintaan apa pun keluar dari PHP. Bridge menolak URL Worker non-HTTPS. Bridge menolak host Worker yang hasil resolve-nya berada di ruang alamat privat atau dicadangkan, dengan memeriksa semua record A dan AAAA, bukan hanya record pertama. Bridge juga melakukan resolve ulang host tepat sebelum menyambung, sehingga menutup jendela time-of-check/time-of-use (TOCTOU) terhadap DNS rebinding. Ketika Anda menyediakan response factory PSR-17 dan salah satu dari kumpulan IP hasil resolve atau pin Subject Public Key Info (SPKI), bridge menggunakan transport cURL yang di-pin. Transport tersebut mengikat koneksi ke IP yang telah diverifikasi (CURLOPT_RESOLVE), memberlakukan public-key pinning TLS (CURLOPT_PINNEDPUBLICKEY), memverifikasi peer dan host, serta tidak mengikuti redirect.

// 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() secara default menggunakan lebar A4 (595.28 poin) dan tinggi yang dideteksi otomatis (heightPt: 0). Untuk referensi lengkap tentang field dan peta kunci fromArray(), lihat halaman konfigurasi Cloudflare pada Lihat juga.

Buat konfigurasi, buat renderer, render, lalu tulis byte-nya.

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 diambil dari lingkungan dan tidak pernah di-hard-code. workerUrl harus menggunakan HTTPS; bridge menolak URL http:// sebelum mengirim permintaan apa pun.

Di produksi, sambungkan factory renderer lokal agar permintaan melakukan fallback ketika Worker tidak dapat dijangkau, bukan langsung gagal. Konfigurasikan pin TLS dengan pin cadangan. create() milik factory hanya berjalan pada jalur fallback.

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

Hubungkan factory dan pin ke 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,
);

Ketika fallback berjalan, renderLocation pada hasil bernilai local dan heightPt bernilai 0.0. Bridge mencatat fallback pada level warning, lalu info. Selalu konfigurasikan pin cadangan sebelum rotasi sertifikat, agar rotasi terencana tidak membuat bridge terkunci dari endpoint.

  • Galat Worker bukan kegagalan keterjangkauan. Worker yang mengembalikan galat HTTP atau body malformed memunculkan CloudflareRenderException dan tidak pernah dicoba ulang dengan fallback. Fallback hanya dipakai ketika edge tidak dapat dijangkau. Pertahankan kedua cabang catch tetap terpisah.
  • Fallback membutuhkan flag sekaligus factory. Dengan fallbackToLocal: true tetapi tanpa factory yang tersambung, kondisi Worker yang tidak dapat dijangkau memunculkan CloudflareNotAvailableException dan menyebutkan factory yang hilang. Sambungkan factory.
  • isAvailable() adalah petunjuk, bukan jaminan. Metode ini mengirim HEAD terautentikasi dan mengembalikan true untuk status di bawah 500; POST berikutnya tetap dapat gagal. Jangan memperlakukannya sebagai kontrak.
  • Pinning bersifat opt-in. Kumpulan pin kosong menonaktifkan pinning. Gunakan kumpulan kosong hanya dengan rantai sertifikat yang stabil dan diketahui, dan sediakan pin cadangan begitu Anda melakukan pinning.
  • fontFiles membutuhkan bucket R2. Argumen fontFiles hanya berpengaruh ketika konfigurasi menetapkan r2FontBucket; jika tidak, argumen ini tidak memiliki efek.
  • Bridge tidak menandatangani. Ia mengembalikan byte PDF. Render dilakukan di edge, lalu tandatangani dalam proses Anda sendiri, sehingga kunci penanda tangan tidak pernah melintasi batas edge.

Rendering di edge memindahkan beban browser dari host Anda. Anda tetap menanggung satu round trip HTTPS ke Worker ditambah waktu render Worker, yang dilaporkan oleh hasil sebagai renderTimeMs. Bridge menerapkan timeout yang dikonfigurasi melalui transport yang di-pin. Tetapkan nilainya berdasarkan latensi Worker yang terukur dengan margin, dan jaga agar tetap di bawah timeout gateway upstream mana pun. Paket ini hanya menyatakan batas yang diberlakukannya sendiri. Paket ini tidak membuat klaim apa pun tentang batas atas CPU, memori, atau body permintaan pada platform Cloudflare. Untuk batas tersebut, rujuk dokumentasi Cloudflare dan Worker Anda.

  • Tujuan divalidasi sebelum permintaan keluar dari PHP. URL non-HTTPS ditolak. Host yang hasil resolve-nya berada di ruang alamat privat atau dicadangkan ditolak di seluruh record A dan AAAA. Host di-resolve ulang tepat sebelum menyambung untuk bertahan terhadap DNS rebinding.
  • Transport yang di-pin mengikat DNS dan TLS. Dengan response factory dan pin yang terkonfigurasi, bridge mengikat koneksi ke IP yang telah diverifikasi, memberlakukan pinning SPKI, memverifikasi peer dan host, serta menolak mengikuti redirect ke host yang belum diverifikasi.
  • Input dibatasi. HTML yang melebihi maxHtmlSize (default 5 MB), data URI base64 yang terlalu besar, dan tag <meta http-equiv="refresh"> apa pun ditolak sebelum permintaan dikirim.
  • Rahasia diredaksi dan bersifat immutable. apiToken dan kunci R2 membawa #[SensitiveParameter], sehingga stack trace meredaksi nilai tersebut, dan objek konfigurasi bersifat final readonly. Muat rahasia dari lingkungan atau pengelola rahasia; jangan pernah meng-commit rahasia tersebut.
  • Jangan pernah menulis blok catch yang kosong. Setiap contoh menangkap tipe exception spesifik lalu mencatat log atau keluar dengan kode yang terdefinisi.

Model keamanan lengkap tersedia pada halaman security-and-operations Cloudflare di bagian Lihat juga. Halaman tersebut membahas pertahanan SSRF dan DNS-rebinding, operasi pinning, penanganan rahasia, serta klausa OWASP dan RFC 7469 yang relevan.

Panduan ini tidak membuat klaim standar normatif tersendiri. Pada halaman security-and-operations dan konfigurasi Cloudflare upstream, resolusi DNS untuk seluruh record dan pemeriksaan ulang TOCTOU milik bridge dipetakan ke pedoman pencegahan SSRF OWASP, sedangkan public-key pinning TLS serta pemulihan pin cadangannya dipetakan ke RFC 7469. Halaman cookbook ini menyatakan ulang cara penggunaannya dan menyerahkan kutipan tersebut ke halaman-halaman itu. Bridge tidak melakukan penandatanganan dan tidak membuat klaim konformansi tanda tangan.