Skip to content

Commit e96d19e

Browse files
authored
Merge pull request #1 from dereuromark/docs/queue-integration-and-ddev-setup
Add DDEV setup guide and Queue integration cookbook
2 parents e03f0fe + 63960f2 commit e96d19e

File tree

1 file changed

+354
-0
lines changed

1 file changed

+354
-0
lines changed

README.md

Lines changed: 354 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ Push real-time updates to clients using the Mercure protocol.
1717
- [Installation](#installation)
1818
- [Installing the Plugin](#installing-the-plugin)
1919
- [Running a Mercure Hub](#running-a-mercure-hub)
20+
- [Custom DDEV Setup with Nginx Proxy](#custom-ddev-setup-with-nginx-proxy)
2021
- [Configuration](#configuration)
2122
- [Basic Usage](#basic-usage)
2223
- [Choosing Your Authorization Strategy](#choosing-your-authorization-strategy)
@@ -47,6 +48,11 @@ Push real-time updates to clients using the Mercure protocol.
4748
- [JsonUpdate](#jsonupdate)
4849
- [ViewUpdate](#viewupdate)
4950
- [MercureDiscoveryMiddleware](#mercurediscoverymiddleware)
51+
- [Cookbook: Queue Job Progress Tracking](#cookbook-queue-job-progress-tracking)
52+
- [Creating a Progress Trait](#creating-a-progress-trait)
53+
- [Using the Trait in Queue Tasks](#using-the-trait-in-queue-tasks)
54+
- [Frontend: Real-time Progress Display](#frontend-real-time-progress-display)
55+
- [Controller for Triggering Jobs](#controller-for-triggering-jobs)
5056
- [Contributing](#contributing)
5157
- [License](#license)
5258

@@ -125,6 +131,75 @@ For more information, see the [DDEV Mercure add-on](https://addons.ddev.com/addo
125131

126132
The hub will be available at `http://localhost:3000/.well-known/mercure`.
127133

134+
### Custom DDEV Setup with Nginx Proxy
135+
136+
For environments with dynamic ports (like DDEV), you can set up a custom Mercure container with an nginx proxy. This allows using relative URLs that work regardless of the port.
137+
138+
Create `.ddev/docker-compose.mercure.yaml`:
139+
140+
```yaml
141+
services:
142+
mercure:
143+
image: dunglas/mercure
144+
container_name: ddev-${DDEV_SITENAME}-mercure
145+
restart: "no"
146+
expose:
147+
- "80"
148+
environment:
149+
SERVER_NAME: ':80'
150+
MERCURE_PUBLISHER_JWT_KEY: '!ChangeThisMercureHubJWTSecretKey!'
151+
MERCURE_SUBSCRIBER_JWT_KEY: '!ChangeThisMercureHubJWTSecretKey!'
152+
MERCURE_EXTRA_DIRECTIVES: |
153+
anonymous
154+
cors_origins *
155+
labels:
156+
com.ddev.site-name: ${DDEV_SITENAME}
157+
com.ddev.approot: ${DDEV_APPROOT}
158+
159+
web:
160+
environment:
161+
- MERCURE_URL=http://mercure/.well-known/mercure
162+
- MERCURE_PUBLIC_URL=/mercure-hub
163+
- MERCURE_JWT_SECRET=!ChangeThisMercureHubJWTSecretKey!
164+
```
165+
166+
Create `.ddev/nginx/mercure.conf`:
167+
168+
```nginx
169+
location /mercure-hub {
170+
rewrite ^/mercure-hub(.*)$ /.well-known/mercure$1 break;
171+
proxy_pass http://mercure:80;
172+
proxy_http_version 1.1;
173+
proxy_set_header Connection '';
174+
proxy_set_header Cache-Control 'no-cache';
175+
proxy_set_header X-Accel-Buffering 'no';
176+
proxy_buffering off;
177+
chunked_transfer_encoding off;
178+
proxy_set_header Host $host;
179+
proxy_set_header X-Real-IP $remote_addr;
180+
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
181+
proxy_set_header X-Forwarded-Proto $scheme;
182+
proxy_read_timeout 24h;
183+
proxy_connect_timeout 1h;
184+
}
185+
```
186+
187+
Then configure relative URLs in `config/app_custom.php`:
188+
189+
```php
190+
'Mercure' => [
191+
'url' => env('MERCURE_URL', 'http://mercure/.well-known/mercure'),
192+
'public_url' => env('MERCURE_PUBLIC_URL', '/mercure-hub'),
193+
'jwt' => [
194+
'secret' => env('MERCURE_JWT_SECRET', '!ChangeThisMercureHubJWTSecretKey!'),
195+
'algorithm' => 'HS256',
196+
'publish' => ['*'],
197+
],
198+
],
199+
```
200+
201+
Run `ddev restart` to apply the changes. Using a relative `public_url` like `/mercure-hub` means the browser will automatically use the current origin, making it work regardless of DDEV's dynamic port assignment.
202+
128203
> [!TIP]
129204
> **Using FrankenPHP?** You're good to go! FrankenPHP has Mercure built in—no separate hub needed. See the [FrankenPHP Mercure documentation](https://frankenphp.dev/docs/mercure/) for details.
130205

@@ -1267,6 +1342,285 @@ This allows clients to automatically discover the Mercure hub URL without hardco
12671342

12681343
For more information about the Mercure protocol, visit [mercure.rocks](https://mercure.rocks/).
12691344

1345+
## Cookbook: Queue Job Progress Tracking
1346+
1347+
A common use case for Mercure is providing real-time progress updates for background queue jobs. This cookbook shows how to integrate Mercure with [dereuromark/cakephp-queue](https://github.com/dereuromark/cakephp-queue) to display live progress bars and status updates.
1348+
1349+
### Creating a Progress Trait
1350+
1351+
First, create a reusable trait for publishing progress updates from queue tasks:
1352+
1353+
```php
1354+
<?php
1355+
declare(strict_types=1);
1356+
1357+
namespace App\Queue\Task;
1358+
1359+
use Cake\Log\Log;
1360+
use Mercure\Publisher;
1361+
use Mercure\Update\JsonUpdate;
1362+
use Throwable;
1363+
1364+
/**
1365+
* Trait for publishing real-time updates from queue tasks via Mercure.
1366+
*/
1367+
trait MercureProgressTrait
1368+
{
1369+
protected string $mercureTopicPrefix = '/queue/job';
1370+
1371+
/**
1372+
* Publish a progress update for a queue job.
1373+
*/
1374+
protected function publishProgress(int $jobId, string $status, array $data = []): void
1375+
{
1376+
try {
1377+
$topic = sprintf('%s/%d', $this->mercureTopicPrefix, $jobId);
1378+
$payload = array_merge(['status' => $status], $data);
1379+
1380+
Publisher::publish(JsonUpdate::create(
1381+
topics: $topic,
1382+
data: $payload,
1383+
));
1384+
} catch (Throwable $e) {
1385+
// Log but don't fail the job if Mercure is unavailable
1386+
Log::write('warning', sprintf(
1387+
'Failed to publish Mercure update for job `%d`: %s',
1388+
$jobId,
1389+
$e->getMessage(),
1390+
));
1391+
}
1392+
}
1393+
1394+
/**
1395+
* Publish a "started" status update.
1396+
*/
1397+
protected function publishStarted(int $jobId, string $message = '', array $data = []): void
1398+
{
1399+
$payload = $data;
1400+
if ($message) {
1401+
$payload['message'] = $message;
1402+
}
1403+
$this->publishProgress($jobId, 'started', $payload);
1404+
}
1405+
1406+
/**
1407+
* Publish a progress update with percentage.
1408+
*/
1409+
protected function publishProgressPercent(int $jobId, int $current, int $total, array $data = []): void
1410+
{
1411+
$percent = $total > 0 ? round(($current / $total) * 100, 1) : 0;
1412+
$payload = array_merge([
1413+
'current' => $current,
1414+
'total' => $total,
1415+
'percent' => $percent,
1416+
], $data);
1417+
1418+
$this->publishProgress($jobId, 'progress', $payload);
1419+
}
1420+
1421+
/**
1422+
* Publish a "completed" status update.
1423+
*/
1424+
protected function publishCompleted(int $jobId, string $message = '', array $data = []): void
1425+
{
1426+
$payload = $data;
1427+
if ($message) {
1428+
$payload['message'] = $message;
1429+
}
1430+
$this->publishProgress($jobId, 'completed', $payload);
1431+
}
1432+
1433+
/**
1434+
* Publish a "failed" status update.
1435+
*/
1436+
protected function publishFailed(int $jobId, string $error, array $data = []): void
1437+
{
1438+
$payload = array_merge(['error' => $error], $data);
1439+
$this->publishProgress($jobId, 'failed', $payload);
1440+
}
1441+
}
1442+
```
1443+
1444+
### Using the Trait in Queue Tasks
1445+
1446+
Use the trait in your queue task classes:
1447+
1448+
```php
1449+
<?php
1450+
declare(strict_types=1);
1451+
1452+
namespace App\Queue\Task;
1453+
1454+
use Queue\Queue\Task;
1455+
1456+
class ImportProductsTask extends Task
1457+
{
1458+
use MercureProgressTrait;
1459+
1460+
public function run(array $data, int $jobId): void
1461+
{
1462+
$this->publishStarted($jobId, 'Starting product import...');
1463+
1464+
$products = $this->fetchProducts($data['source']);
1465+
$total = count($products);
1466+
$saved = 0;
1467+
1468+
foreach ($products as $index => $product) {
1469+
$this->saveProduct($product);
1470+
$saved++;
1471+
1472+
// Publish progress every 10 items to avoid flooding
1473+
if ($index % 10 === 0) {
1474+
$this->publishProgressPercent($jobId, $index + 1, $total, [
1475+
'message' => sprintf('Processing %d of %d products...', $index + 1, $total),
1476+
'saved' => $saved,
1477+
]);
1478+
}
1479+
}
1480+
1481+
$this->publishCompleted($jobId, sprintf('Imported %d products', $saved), [
1482+
'total' => $total,
1483+
'saved' => $saved,
1484+
]);
1485+
}
1486+
}
1487+
```
1488+
1489+
### Frontend: Real-time Progress Display
1490+
1491+
Create a template that subscribes to job updates and displays progress:
1492+
1493+
```php
1494+
<?php
1495+
// templates/Admin/Jobs/monitor.php
1496+
$this->loadHelper('Mercure.Mercure');
1497+
?>
1498+
<div class="job-monitor">
1499+
<h2>Job Progress</h2>
1500+
<div id="job-<?= $jobId ?>" class="job-card">
1501+
<div class="job-header">
1502+
<strong><?= h($jobTask) ?></strong>
1503+
<span class="badge" id="status-badge">Pending</span>
1504+
</div>
1505+
<div class="progress">
1506+
<div class="progress-bar" id="progress-bar" style="width: 0%"></div>
1507+
</div>
1508+
<div id="job-message" class="message">Waiting for updates...</div>
1509+
<div id="job-stats" class="stats"></div>
1510+
</div>
1511+
</div>
1512+
1513+
<script>
1514+
document.addEventListener('DOMContentLoaded', function() {
1515+
const jobId = <?= $jobId ?>;
1516+
const topic = `/queue/job/${jobId}`;
1517+
1518+
// Use the Mercure helper to get the hub URL
1519+
// For relative URLs, combine with current origin
1520+
const hubUrl = '<?= $this->Mercure->url() ?>';
1521+
const url = new URL(hubUrl, window.location.origin);
1522+
url.searchParams.append('topic', topic);
1523+
1524+
const eventSource = new EventSource(url.toString());
1525+
1526+
eventSource.onmessage = function(event) {
1527+
const data = JSON.parse(event.data);
1528+
updateJobDisplay(data);
1529+
};
1530+
1531+
eventSource.onerror = function() {
1532+
console.error('EventSource connection error');
1533+
};
1534+
1535+
function updateJobDisplay(data) {
1536+
const statusBadge = document.getElementById('status-badge');
1537+
const progressBar = document.getElementById('progress-bar');
1538+
const message = document.getElementById('job-message');
1539+
const stats = document.getElementById('job-stats');
1540+
1541+
// Update status badge
1542+
statusBadge.textContent = data.status.charAt(0).toUpperCase() + data.status.slice(1);
1543+
statusBadge.className = 'badge badge-' + getStatusColor(data.status);
1544+
1545+
// Update progress bar
1546+
if (data.percent !== undefined) {
1547+
progressBar.style.width = data.percent + '%';
1548+
}
1549+
1550+
// Update message
1551+
if (data.message) {
1552+
message.textContent = data.message;
1553+
}
1554+
1555+
// Update stats
1556+
let statsHtml = '';
1557+
if (data.current !== undefined) statsHtml += `<span>Current: ${data.current}</span> `;
1558+
if (data.total !== undefined) statsHtml += `<span>Total: ${data.total}</span> `;
1559+
if (data.saved !== undefined) statsHtml += `<span>Saved: ${data.saved}</span> `;
1560+
if (statsHtml) stats.innerHTML = statsHtml;
1561+
1562+
// Close connection on completion or failure
1563+
if (data.status === 'completed' || data.status === 'failed') {
1564+
progressBar.style.width = '100%';
1565+
eventSource.close();
1566+
}
1567+
}
1568+
1569+
function getStatusColor(status) {
1570+
switch (status) {
1571+
case 'started': return 'info';
1572+
case 'progress': return 'warning';
1573+
case 'completed': return 'success';
1574+
case 'failed': return 'danger';
1575+
default: return 'secondary';
1576+
}
1577+
}
1578+
});
1579+
</script>
1580+
```
1581+
1582+
### Controller for Triggering Jobs
1583+
1584+
```php
1585+
<?php
1586+
declare(strict_types=1);
1587+
1588+
namespace App\Controller\Admin;
1589+
1590+
use App\Controller\AppController;
1591+
use Queue\Model\Table\QueuedJobsTable;
1592+
1593+
class JobsController extends AppController
1594+
{
1595+
public function monitor(int $id)
1596+
{
1597+
$job = $this->fetchTable('Queue.QueuedJobs')->get($id);
1598+
1599+
$this->set([
1600+
'jobId' => $job->id,
1601+
'jobTask' => $job->job_task,
1602+
]);
1603+
}
1604+
1605+
public function trigger()
1606+
{
1607+
/** @var QueuedJobsTable $queuedJobsTable */
1608+
$queuedJobsTable = $this->fetchTable('Queue.QueuedJobs');
1609+
1610+
$job = $queuedJobsTable->createJob(
1611+
'ImportProducts',
1612+
['source' => 'api'],
1613+
);
1614+
1615+
// Redirect to monitor page with job ID
1616+
return $this->redirect(['action' => 'monitor', $job->id]);
1617+
}
1618+
}
1619+
```
1620+
1621+
> [!TIP]
1622+
> **Throttling Updates:** For jobs that process thousands of items, avoid publishing on every iteration. Instead, publish every N items (e.g., every 10 or 100) or use time-based throttling to prevent flooding the Mercure hub.
1623+
12701624
## Contributing
12711625
12721626
Contributions are welcome! Please follow these guidelines:

0 commit comments

Comments
 (0)