Génération PDF asynchrone : jobs avec polling + progression
All checks were successful
Deploy / deploy (push) Successful in 20s

- Nouveau système de jobs : POST /jobs (async), GET /jobs/{id} (status), GET /jobs/{id}/result (PDF), DELETE /jobs/{id}
- worker.php spawné en arrière-plan via nohup, lit la sortie pagedjs-cli ligne par ligne via proc_open et écrit la progression dans tmp/job_{id}.status.json
- Migration de pagedjs-cli en install local (node_modules) pour persister le patch protocolTimeout via patch-package
- CI : déploie package.json, worker.php, patches/, et lance npm install (qui réapplique les patches via postinstall)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
isUnknown 2026-05-04 09:37:00 +02:00
parent a005c982bd
commit 0b954ed494
10 changed files with 506 additions and 6 deletions

View file

@ -0,0 +1,120 @@
<?php
namespace Web2Print\Controllers;
use Web2Print\Services\JobManager;
class JobsController
{
private JobManager $jobs;
private array $config;
public function __construct(JobManager $jobs, array $config)
{
$this->jobs = $jobs;
$this->config = $config;
}
public function create(): void
{
if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
$this->sendError(405, 'Method not allowed');
return;
}
$input = file_get_contents('php://input');
if (strlen($input) > $this->config['max_html_size']) {
$this->sendError(413, 'Request too large');
return;
}
$data = json_decode($input, true);
if (json_last_error() !== JSON_ERROR_NONE) {
$this->sendError(400, 'Invalid JSON');
return;
}
if (empty($data['url'])) {
$this->sendError(400, 'url required (only URL mode is supported for async jobs)');
return;
}
$jobId = $this->jobs->create([
'url' => $data['url'],
'filename' => !empty($data['filename']) ? basename($data['filename']) : 'document.pdf',
'options' => $data['options'] ?? [],
]);
http_response_code(202);
header('Content-Type: application/json');
echo json_encode(['job_id' => $jobId]);
}
public function status(string $jobId): void
{
if ($_SERVER['REQUEST_METHOD'] !== 'GET') {
$this->sendError(405, 'Method not allowed');
return;
}
$status = $this->jobs->getStatus($jobId);
if ($status === null) {
$this->sendError(404, 'Job not found');
return;
}
header('Content-Type: application/json');
echo json_encode($status);
}
public function result(string $jobId): void
{
if ($_SERVER['REQUEST_METHOD'] !== 'GET') {
$this->sendError(405, 'Method not allowed');
return;
}
$status = $this->jobs->getStatus($jobId);
if ($status === null) {
$this->sendError(404, 'Job not found');
return;
}
if (($status['status'] ?? null) !== 'done') {
$this->sendError(409, 'Job not ready (status: ' . ($status['status'] ?? 'unknown') . ')');
return;
}
$path = $this->jobs->getResultPath($jobId);
if ($path === null) {
$this->sendError(500, 'Result file missing');
return;
}
$request = $this->jobs->getRequest($jobId);
$filename = $request['filename'] ?? 'document.pdf';
header('Content-Type: application/pdf');
header('Content-Disposition: attachment; filename="' . $filename . '"');
header('Content-Length: ' . filesize($path));
readfile($path);
}
public function delete(string $jobId): void
{
if ($_SERVER['REQUEST_METHOD'] !== 'DELETE') {
$this->sendError(405, 'Method not allowed');
return;
}
$this->jobs->delete($jobId);
http_response_code(204);
}
private function sendError(int $code, string $message): void
{
http_response_code($code);
header('Content-Type: application/json');
echo json_encode(['error' => $message]);
}
}

104
src/Services/JobManager.php Normal file
View file

@ -0,0 +1,104 @@
<?php
namespace Web2Print\Services;
class JobManager
{
private array $config;
public function __construct(array $config)
{
$this->config = $config;
}
public function create(array $request): string
{
$jobId = bin2hex(random_bytes(16));
$requestPath = $this->path($jobId, 'request.json');
$statusPath = $this->path($jobId, 'status.json');
file_put_contents($requestPath, json_encode($request, JSON_UNESCAPED_SLASHES));
$this->writeStatus($jobId, ['status' => 'pending', 'createdAt' => time()]);
$cmd = sprintf(
'nohup %s %s %s > /dev/null 2>&1 &',
escapeshellcmd($this->config['php_bin']),
escapeshellarg(__DIR__ . '/../../worker.php'),
escapeshellarg($jobId)
);
exec($cmd);
return $jobId;
}
public function getStatus(string $jobId): ?array
{
$path = $this->path($jobId, 'status.json');
if (!file_exists($path)) {
return null;
}
$raw = @file_get_contents($path);
$data = json_decode($raw, true);
return is_array($data) ? $data : null;
}
public function writeStatus(string $jobId, array $status): void
{
$status['updatedAt'] = time();
file_put_contents(
$this->path($jobId, 'status.json'),
json_encode($status, JSON_UNESCAPED_SLASHES)
);
}
public function getRequest(string $jobId): ?array
{
$path = $this->path($jobId, 'request.json');
if (!file_exists($path)) {
return null;
}
$data = json_decode(@file_get_contents($path), true);
return is_array($data) ? $data : null;
}
public function getResultPath(string $jobId): ?string
{
$path = $this->path($jobId, 'pdf');
return file_exists($path) ? $path : null;
}
public function pdfPath(string $jobId): string
{
return $this->path($jobId, 'pdf');
}
public function delete(string $jobId): void
{
foreach (glob($this->config['tmp_dir'] . '/job_' . $jobId . '.*') ?: [] as $file) {
@unlink($file);
}
}
public function cleanOrphans(): int
{
$maxAge = $this->config['job_max_age'] ?? 3600;
$cutoff = time() - $maxAge;
$count = 0;
foreach (glob($this->config['tmp_dir'] . '/job_*.status.json') ?: [] as $statusFile) {
if (filemtime($statusFile) < $cutoff) {
if (preg_match('/job_([a-f0-9]+)\.status\.json$/', $statusFile, $m)) {
$this->delete($m[1]);
$count++;
}
}
}
return $count;
}
private function path(string $jobId, string $suffix): string
{
return $this->config['tmp_dir'] . '/job_' . $jobId . '.' . $suffix;
}
}

View file

@ -44,6 +44,7 @@ class PdfGenerator
// Nettoyer les fichiers temporaires
@unlink($htmlFile);
@unlink($pdfFile);
$this->cleanChromiumTmp();
}
}
@ -80,6 +81,98 @@ class PdfGenerator
} finally {
@unlink($pdfFile);
$this->cleanChromiumTmp();
}
}
public function generateFromUrlToFile(string $url, string $pdfFile, array $options = [], ?callable $onProgress = null): void
{
$cmd = 'TMPDIR=' . escapeshellarg($this->config['tmp_dir']) . ' ';
$cmd .= escapeshellcmd($this->config['pagedjs_bin']);
$cmd .= ' ' . escapeshellarg($url);
$cmd .= ' -o ' . escapeshellarg($pdfFile);
if (!empty($options['timeout'])) {
$cmd .= ' --timeout ' . (int)$options['timeout'];
} else {
$cmd .= ' --timeout ' . ($this->config['pagedjs_timeout'] * 1000);
}
// proc_open pour streamer stdout/stderr ligne par ligne
$descriptors = [
0 => ['pipe', 'r'],
1 => ['pipe', 'w'],
2 => ['pipe', 'w'],
];
$proc = proc_open($cmd, $descriptors, $pipes);
if (!is_resource($proc)) {
throw new \Exception('Failed to spawn pagedjs-cli');
}
fclose($pipes[0]);
stream_set_blocking($pipes[1], false);
stream_set_blocking($pipes[2], false);
$stderrBuffer = '';
$partial = ['', ''];
try {
while (true) {
$status = proc_get_status($proc);
$read = [$pipes[1], $pipes[2]];
$w = null; $e = null;
$changed = @stream_select($read, $w, $e, 1);
if ($changed > 0) {
foreach ($read as $stream) {
$idx = ($stream === $pipes[1]) ? 0 : 1;
$chunk = fread($stream, 4096);
if ($chunk === '' || $chunk === false) continue;
if ($idx === 1) $stderrBuffer .= $chunk;
$partial[$idx] .= $chunk;
// Découpe par ligne (\n ou \r — Ora utilise \r pour les spinners)
while (preg_match('/^([^\r\n]*)[\r\n](.*)$/s', $partial[$idx], $m)) {
$line = $m[1];
$partial[$idx] = $m[2];
if ($line !== '' && $onProgress !== null) {
$onProgress($line);
}
}
}
}
if (!$status['running']) {
// Vider les buffers restants
foreach ([$pipes[1], $pipes[2]] as $i => $stream) {
$idx = ($stream === $pipes[1]) ? 0 : 1;
while (($chunk = fread($stream, 4096)) !== false && $chunk !== '') {
if ($idx === 1) $stderrBuffer .= $chunk;
$partial[$idx] .= $chunk;
}
if ($partial[$idx] !== '' && $onProgress !== null) {
foreach (preg_split('/[\r\n]+/', $partial[$idx]) as $line) {
if ($line !== '') $onProgress($line);
}
}
}
break;
}
}
fclose($pipes[1]);
fclose($pipes[2]);
$exitCode = proc_close($proc);
if ($exitCode !== 0) {
$this->log('Paged.js CLI error (URL streaming): ' . $stderrBuffer);
throw new \Exception('PDF generation failed: ' . trim($stderrBuffer));
}
if (!file_exists($pdfFile)) {
throw new \Exception('PDF file not created');
}
} finally {
$this->cleanChromiumTmp();
}
}
@ -130,6 +223,30 @@ class PdfGenerator
return $cmd;
}
private function cleanChromiumTmp(): void
{
$tmpDir = $this->config['tmp_dir'];
foreach (glob($tmpDir . '/org.chromium.Chromium.*') ?: [] as $dir) {
$this->rmdirRecursive($dir);
}
foreach (glob($tmpDir . '/puppeteer_dev_chrome_profile-*') ?: [] as $dir) {
$this->rmdirRecursive($dir);
}
}
private function rmdirRecursive(string $path): void
{
if (!is_dir($path)) {
@unlink($path);
return;
}
foreach (scandir($path) as $item) {
if ($item === '.' || $item === '..') continue;
$this->rmdirRecursive($path . '/' . $item);
}
@rmdir($path);
}
private function log(string $message): void
{
$timestamp = date('Y-m-d H:i:s');