ข้ามไปยังเนื้อหา

เรนเดอร์ที่เอดจ์ด้วย Cloudflare พร้อม fallback ภายในเครื่อง

Cloudflare bridge ส่ง HTML ของคุณไปยัง render endpoint ของ Cloudflare Worker แล้วคืนผลลัพธ์เป็น PDF การเรนเดอร์ทำงานที่เอดจ์ คุณจึงไม่ต้องดูแลโปรเซสเบราว์เซอร์ที่ทำงานต่อเนื่องยาวนาน คุณสร้าง config ที่ใช้เฉพาะ HTTPS เชื่อมต่อไคลเอนต์ PHP Standards Recommendation (PSR)-18 และ factory แบบ PSR-17 เรียกใช้ render() และสามารถเพิ่ม renderer ภายในเครื่องสำหรับกรณีที่เข้าถึง Worker ไม่ได้ คู่มือนี้แสดงการเรียก render เส้นทาง fallback และมาตรการควบคุม Server-Side Request Forgery (SSRF) การโจมตีแบบ Domain Name System (DNS)-rebinding และ Transport Layer Security (TLS) public-key-pinning ที่ bridge บังคับใช้ก่อนที่คำขอใดๆจะออกจากโปรเซส

ข้อกำหนดเบื้องต้นโดยสรุปก่อนเริ่ม:

  • ติดตั้ง NextPDF core และ nextpdf/cloudflare แล้ว
  • endpoint ของ Worker ให้บริการ render contract ผ่าน HTTPS และรับ bearer token โดย bridge จะปฏิเสธ URL ของ Worker ที่ไม่ใช่ HTTPS ก่อนส่งสิ่งใดออกไป
  • มีไคลเอนต์ PSR-18 (เช่น Guzzle 7) และ request กับ stream factory แบบ PSR-17 พร้อมใช้งาน หากต้องใช้ pinned cURL transport ต้องจัดเตรียม response factory แบบ PSR-17 และ ext-curl ด้วย
  • สำหรับ fallback ภายในเครื่อง ต้องมี nextpdf/artisan (หรือ renderer ภายในเครื่องอื่น) พร้อมใช้งาน

คู่มือนี้เป็น how-to สำหรับการเรนเดอร์ครั้งแรกที่รันได้จริง ให้เริ่มจาก Cloudflare quickstart

ติดตั้ง bridge พร้อมไคลเอนต์ PSR-18 และ factory แบบ PSR-17

Terminal window
composer require nextpdf/cloudflare guzzlehttp/guzzle

สำหรับ fallback ภายในเครื่อง ให้ติดตั้ง renderer ภายในเครื่องที่ bridge สามารถเรียกใช้ได้

Terminal window
composer require nextpdf/artisan

โหลด bearer token ของ Worker และข้อมูลรับรอง R2 ใดๆจาก environment variable หรือ secrets manager ห้าม commit ข้อมูลเหล่านี้โดยเด็ดขาด

CloudflareHtmlRenderer::render() ตรวจสอบความถูกต้องของ HTML และปลายทาง ส่ง POST ที่ผ่านการยืนยันตัวตนไปยัง Worker แล้วแยกวิเคราะห์การตอบกลับ Worker จะคืนค่าเป็น byte ดิบของ PDF (Content-Type: application/pdf) หรือ body แบบ JSON ที่มีฟิลด์ pdf ซึ่งเข้ารหัสแบบ base64 renderer แมปการตอบกลับไปยัง final readonly CloudflareRenderResult ซึ่งบรรจุ byte ของ PDF ความกว้างที่ร้องขอ ความสูง ตำแหน่งการเรนเดอร์ (ได้มาจากเฮดเดอร์ CF-Ray) และเวลาที่ใช้เรนเดอร์

bridge แยกความล้มเหลวออกเป็นสองคลาสอย่างชัดเจน:

  • CloudflareRenderException — Worker ตอบกลับแล้วแต่การเรนเดอร์ล้มเหลว (ไม่ว่าจะเป็นข้อผิดพลาด HTTP หรือ body ที่ไม่ได้ขึ้นต้นด้วย %PDF) กรณีนี้คือความล้มเหลวในการเรนเดอร์ และจะ ไม่มี การลองใหม่ด้วย fallback
  • CloudflareNotAvailableException — ไม่สามารถเข้าถึงเอดจ์ได้ และไม่มี fallback ที่ใช้งานได้

fallback ภายในเครื่องครอบคลุมกรณีที่สอง เมื่อเข้าถึง Worker ไม่ได้และ fallbackToLocal เป็น true bridge จะเรียกใช้ LocalRendererFactoryInterface ที่คุณจัดเตรียมไว้ โดยทำงานแบบ lazy: create() ของ factory จะทำงานเฉพาะบนเส้นทาง fallback เท่านั้น เมื่อเรนเดอร์ผ่าน fallback ค่า renderLocation ในผลลัพธ์จะเป็นสตริงตรงตัวว่า local

bridge ปกป้องขอบเขตเครือข่ายก่อนที่คำขอใดๆจะออกจาก PHP โดยปฏิเสธ URL ของ Worker ที่ไม่ใช่ HTTPS และปฏิเสธโฮสต์ของ Worker ที่ resolve ไปยังพื้นที่แอดเดรสแบบ private หรือ reserved โดยตรวจสอบเรกคอร์ด A และ AAAA ทั้งหมด ไม่ใช่เพียงเรกคอร์ดแรกเท่านั้น นอกจากนี้ยัง resolve โฮสต์ใหม่ทันทีก่อนเชื่อมต่อ เพื่อปิดช่องโหว่ time-of-check/time-of-use (TOCTOU) ที่ใช้โจมตีแบบ DNS rebinding เมื่อคุณจัดเตรียม response factory แบบ PSR-17 พร้อมชุด IP ที่ resolve แล้ว หรือ pin แบบ Subject Public Key Info (SPKI) อย่างใดอย่างหนึ่ง bridge จะใช้ pinned cURL transport โดย transport ดังกล่าวผูกการเชื่อมต่อไว้กับ IP ที่ผ่านการตรวจสอบแล้ว (CURLOPT_RESOLVE) บังคับใช้ TLS public-key pinning (CURLOPT_PINNEDPUBLICKEY) ตรวจสอบ peer และโฮสต์ และไม่ติดตาม 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() มีค่าเริ่มต้นเป็นความกว้างขนาด A4 (595.28 point) และความสูงที่ตรวจหาอัตโนมัติ (heightPt: 0) สำหรับรายการฟิลด์ทั้งหมดและ key map ของ fromArray() โปรดดูหน้าการกำหนดค่า Cloudflare ในส่วนดูเพิ่มเติม

สร้าง config และ renderer จากนั้นเรนเดอร์และเขียน byte

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 มาจาก environment และห้าม hard-code โดยเด็ดขาด workerUrl ต้องใช้ HTTPS โดย bridge จะปฏิเสธ URL แบบ http:// ก่อนที่จะส่งคำขอใดๆ

ในโปรดักชัน ให้เชื่อมต่อ local renderer factory เพื่อให้กรณีที่เข้าถึง Worker ไม่ได้เปลี่ยนไปใช้ fallback แทนการทำให้คำขอล้มเหลว กำหนดค่า TLS pin พร้อม backup pin create() ของ factory จะทำงานเฉพาะบนเส้นทาง 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();
}
};
}
}

เชื่อมต่อ factory และ pin เข้ากับ 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,
);

เมื่อ fallback ทำงาน ค่า renderLocation ในผลลัพธ์จะเป็น local และ heightPt จะเป็น 0.0 โดย bridge จะบันทึก log ของ fallback ที่ระดับ warning แล้วตามด้วย info ให้กำหนดค่า backup pin ก่อนการหมุนเวียนใบรับรองเสมอ เพื่อไม่ให้การหมุนเวียนที่วางแผนไว้ทำให้ bridge ถูกล็อกออกจาก endpoint

  • ข้อผิดพลาดของ Worker ไม่ใช่ความล้มเหลวด้านการเข้าถึง Worker ที่คืนค่าข้อผิดพลาด HTTP หรือ body ที่ผิดรูปแบบจะทำให้เกิด CloudflareRenderException และจะไม่มีการลองใหม่ด้วย fallback เฉพาะเอดจ์ที่เข้าถึงไม่ได้เท่านั้นที่จะใช้ fallback ให้แยก catch ทั้งสองส่วนออกจากกันอย่างชัดเจน
  • fallback ต้องมีทั้ง flag และ factory หากตั้ง fallbackToLocal: true แต่ไม่ได้เชื่อมต่อ factory ไว้ Worker ที่เข้าถึงไม่ได้จะทำให้เกิด CloudflareNotAvailableException และระบุชื่อ factory ที่ขาดหายไป จึงต้องเชื่อมต่อ factory
  • isAvailable() เป็นเพียงคำใบ้ ไม่ใช่การรับประกัน เมธอดนี้ส่ง HEAD ที่ผ่านการยืนยันตัวตน และคืนค่า true สำหรับสถานะที่ต่ำกว่า 500 แต่ POST ที่ตามมายังคงล้มเหลวได้ อย่าถือว่าค่านี้เป็น contract
  • การ pinning เป็นแบบเลือกเปิดใช้ ชุด pin ที่ว่างเปล่าจะปิดใช้งานการ pinning ใช้ชุดที่ว่างเปล่าเฉพาะกับ certificate chain ที่เสถียรและเป็นที่รู้จักเท่านั้น และเมื่อ pin แล้วให้เก็บ backup pin ไว้เสมอ
  • fontFiles ต้องมี R2 bucket อาร์กิวเมนต์ fontFiles จะมีผลเฉพาะเมื่อ config ตั้งค่า r2FontBucket เท่านั้น มิฉะนั้นจะไม่มีผลใดๆ
  • bridge ไม่ทำการเซ็น bridge จะคืนค่าเป็น byte ของ PDF ให้เรนเดอร์ที่เอดจ์ แล้วจึงเซ็นในโปรเซสของคุณเอง เพื่อให้คีย์สำหรับเซ็นไม่ข้ามขอบเขตของเอดจ์โดยเด็ดขาด

การเรนเดอร์ที่เอดจ์ย้ายต้นทุนของเบราว์เซอร์ออกจากโฮสต์ของคุณ คุณยังคงมีต้นทุนจาก HTTPS round trip หนึ่งครั้งไปยัง Worker บวกกับเวลาที่ Worker ใช้เรนเดอร์ ซึ่งผลลัพธ์รายงานเป็น renderTimeMs bridge ใช้ timeout ที่กำหนดค่าไว้ผ่าน pinned transport ให้ตั้งค่าจาก latency ของ Worker ที่วัดได้พร้อมเผื่อ headroom และให้ค่านี้ต่ำกว่า timeout ของ gateway ต้นทางใดๆ แพ็กเกจระบุเฉพาะขีดจำกัดที่ตัวมันเองบังคับใช้เท่านั้น แพ็กเกจไม่ได้กล่าวอ้างเกี่ยวกับเพดาน CPU หน่วยความจำ หรือ request-body ของแพลตฟอร์ม Cloudflare สำหรับขีดจำกัดเหล่านั้น โปรดดูเอกสารของ Cloudflare และ Worker ของคุณ

  • ปลายทางจะได้รับการตรวจสอบความถูกต้องก่อนที่คำขอจะออกจาก PHP URL ที่ไม่ใช่ HTTPS จะถูกปฏิเสธ โฮสต์ที่ resolve ไปยังพื้นที่แอดเดรสแบบ private หรือ reserved จะถูกปฏิเสธในทุกเรกคอร์ด A และ AAAA และโฮสต์จะถูก resolve ใหม่ทันทีก่อนเชื่อมต่อ เพื่อป้องกันการโจมตีแบบ DNS rebinding
  • pinned transport ผูก DNS และ TLS เมื่อกำหนดค่า response factory และ pin ไว้ bridge จะผูกการเชื่อมต่อไว้กับ IP ที่ผ่านการตรวจสอบแล้ว บังคับใช้การ pinning แบบ SPKI ตรวจสอบ peer และโฮสต์ และปฏิเสธการติดตาม redirect ไปยังโฮสต์ที่ยังไม่ผ่านการตรวจสอบ
  • อินพุตถูกจำกัดขอบเขต HTML ที่เกิน maxHtmlSize (ค่าเริ่มต้น 5 MB) data URI แบบ base64 ที่มีขนาดใหญ่เกินไป และแท็ก <meta http-equiv="refresh"> ใดๆจะถูกปฏิเสธก่อนที่จะส่งคำขอ
  • ความลับถูกปกปิดและไม่สามารถเปลี่ยนแปลงได้ apiToken และคีย์ R2 มี #[SensitiveParameter] กำกับ ดังนั้น stack trace จึงปกปิดค่าเหล่านี้ และอ็อบเจกต์ config เป็น final readonly โหลดความลับจาก environment หรือ secrets manager และห้าม commit ความลับเหล่านี้โดยเด็ดขาด
  • อย่าเขียนบล็อก catch ที่ว่างเปล่าโดยเด็ดขาด แต่ละตัวอย่างจับ exception ชนิดที่เฉพาะเจาะจง แล้วบันทึก log หรือออกด้วยโค้ดที่กำหนดไว้

โมเดลความปลอดภัยฉบับเต็มอยู่ในหน้า security-and-operations ของ Cloudflare ในส่วนดูเพิ่มเติม หน้าดังกล่าวครอบคลุมการป้องกัน SSRF และ DNS-rebinding การปฏิบัติของ pinning การจัดการความลับ และข้อกำหนดของ OWASP และ RFC 7469 ที่เกี่ยวข้อง

คู่มือนี้ไม่ได้กล่าวอ้างมาตรฐานเชิงบรรทัดฐานใดๆด้วยตัวเอง ในหน้า security-and-operations และการกำหนดค่าต้นทางของ Cloudflare การ resolve DNS แบบครบทุกเรกคอร์ดและการตรวจสอบ TOCTOU ซ้ำของ bridge สอดคล้องกับแนวทางการป้องกัน SSRF ของ OWASP และการ pinning แบบ TLS-public-key พร้อมการกู้คืนด้วย backup-pin สอดคล้องกับ RFC 7469 หน้า cookbook นี้กล่าวซ้ำถึงวิธีใช้งานและอ้างอิงการกล่าวอ้างเหล่านั้นไปยังหน้าดังกล่าว bridge ไม่ดำเนินการเซ็นใดๆ และไม่กล่าวอ้างความสอดคล้องของลายเซ็น