Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
40 changes: 29 additions & 11 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -246,7 +246,7 @@ This string will make the vendor show to the user only the last notification of

#### batchSize

If you send tens of thousands notifications at a time, you may get memory overflows due to how endpoints are called in Guzzle.
If you send tens of thousands notifications at a time, you may get memory overflows depending on your HTTP client.
In order to fix this, WebPush sends notifications in batches. The default size is 1000. Depending on your server configuration (memory), you may want
to decrease this number. Do this while instantiating WebPush or calling `setDefaultOptions`. Or, if you want to customize this for a specific flush, give
it as a parameter : `$webPush->flush($batchSize)`.
Expand Down Expand Up @@ -340,23 +340,40 @@ $webPush->setAutomaticPadding(true); // enable automatic padding to default maxi

### Customizing the HTTP client

WebPush uses [Guzzle](https://github.com/guzzle/guzzle). It will use the most appropriate client it finds,
and most of the time it will be `MultiCurl`, which allows to send multiple notifications in parallel.
WebPush is HTTP-client-agnostic: it depends only on [PSR-18](https://www.php-fig.org/psr/psr-18/) (`psr/http-client`)
and [PSR-17](https://www.php-fig.org/psr/psr-17/) (`psr/http-factory`) interfaces, not on any specific implementation.

You can customize the default request options and timeout when instantiating WebPush:
If you don't inject one explicitly, WebPush uses [php-http/discovery](https://github.com/php-http/discovery) to
auto-detect a suitable client/factories among your installed dependencies (Guzzle, Symfony HttpClient, Nyholm PSR-7,
etc).

To customize the client (timeout, redirects, proxy, etc.), configure your PSR-18 client instance directly and inject
it into WebPush:

```php
<?php

use Minishlink\WebPush\WebPush;

$timeout = 20; // seconds
$clientOptions = [
$client = new \GuzzleHttp\Client([
'timeout' => 20,
\GuzzleHttp\RequestOptions::ALLOW_REDIRECTS => false,
]; // see \GuzzleHttp\RequestOptions
$webPush = new WebPush([], [], $timeout, $clientOptions);
]); // see \GuzzleHttp\RequestOptions, or use any other PSR-18 client
$webPush = new WebPush([], [], $client);
```

#### Concurrent sending (`flushPooled()`)

`flushPooled()` requires an [HTTPlug](https://github.com/php-http/httplug) async client (`Http\Client\HttpAsyncClient`)
to send notifications concurrently as PSR-18 itself is strictly synchronous. Install an HTTPlug adapter, e.g. for Guzzle:

```bash
composer require php-http/guzzle7-adapter
```

It will be auto-discovered, same as the PSR-18 client. If none is available, `flushPooled()` throws a `\LogicException`.
Use `flush()` if you don't need concurrency.

## Common questions (FAQ)

### Is there any plugin/bundle/extension for my favorite PHP framework?
Expand All @@ -383,9 +400,10 @@ Internally, WebPush uses the [WebToken](https://github.com/web-token) framework
Here are some ideas:

1. Make sure MultiCurl is available on your server
2. Find the right balance for your needs between security and performance (see above)
3. Find the right batch size (set it in `defaultOptions` or as parameter to `flush()`)
4. Use `flushPooled()` instead of `flush()`. The former uses concurrent requests, accelerating the process and often doubling the speed of the requests.
2. Install an HTTPlug async adapter, e.g. `php-http/guzzle7-adapter` (see "Customizing the HTTP client" above)
3. Find the right balance for your needs between security and performance (see above)
4. Find the right batch size (set it in `defaultOptions` or as parameter to `flush()`)
5. Use `flushPooled()` instead of `flush()`. The former uses concurrent requests, accelerating the process and often doubling the speed of the requests.

### How to solve "SSL certificate problem: unable to get local issuer certificate"?

Expand Down
17 changes: 15 additions & 2 deletions composer.json
Original file line number Diff line number Diff line change
Expand Up @@ -33,18 +33,26 @@
"ext-json": "*",
"ext-mbstring": "*",
"ext-openssl": "*",
"guzzlehttp/guzzle": "^7.9.2",
"php-http/discovery": "^1.19",
"php-http/httplug": "^2.4",
"psr/http-client": "^1.0",
"psr/http-factory": "^1.0",
"psr/http-message": "^1.1|^2.0",
"psr/log": "^2.0|^3.0",
"spomky-labs/base64url": "^2.0.4",
"symfony/polyfill-php83": "^1.33",
"web-token/jwt-library": "^3.4.9|^4.0.6"
},
"suggest": {
"ext-bcmath": "Optional for performance.",
"ext-gmp": "Optional for performance."
"ext-gmp": "Optional for performance.",
"php-http/guzzle7-adapter": "Enables concurrent sending via WebPush::flushPooled() if you use Guzzle."
},
"require-dev": {
"friendsofphp/php-cs-fixer": "^v3.92.2",
"guzzlehttp/guzzle": "^7.9.2",
"guzzlehttp/psr7": "^2.7",
"php-http/guzzle7-adapter": "^1.1",
"phpstan/phpstan": "^2.1.33",
"phpstan/phpstan-deprecation-rules": "^2.0",
"phpstan/phpstan-phpunit": "^2.0",
Expand All @@ -56,5 +64,10 @@
"psr-4": {
"Minishlink\\WebPush\\": "src"
}
},
"config": {
"allow-plugins": {
"php-http/discovery": true
}
}
}
143 changes: 92 additions & 51 deletions src/WebPush.php
Original file line number Diff line number Diff line change
Expand Up @@ -11,18 +11,25 @@
namespace Minishlink\WebPush;

use Base64Url\Base64Url;
use GuzzleHttp\Client;
use GuzzleHttp\Pool;
use GuzzleHttp\Exception\ConnectException;
use GuzzleHttp\Exception\RequestException;
use GuzzleHttp\Psr7\Request;
use Http\Client\Exception\HttpException;
use Http\Client\HttpAsyncClient;
use Http\Discovery\HttpAsyncClientDiscovery;
use Http\Discovery\Psr17FactoryDiscovery;
use Http\Discovery\Psr18ClientDiscovery;
use Psr\Http\Client\ClientExceptionInterface;
use Psr\Http\Client\ClientInterface;
use Psr\Http\Message\RequestFactoryInterface;
use Psr\Http\Message\RequestInterface;
use Psr\Http\Message\ResponseInterface;
use Psr\Http\Message\StreamFactoryInterface;
use Psr\Log\LoggerInterface;

class WebPush
{
protected Client $client;
protected ClientInterface $client;
protected RequestFactoryInterface $requestFactory;
protected StreamFactoryInterface $streamFactory;
protected ?HttpAsyncClient $asyncClient;
protected array $auth;
protected ?LoggerInterface $logger;

Expand Down Expand Up @@ -56,16 +63,21 @@ class WebPush
*
* @param array $auth Some servers need authentication
* @param array $defaultOptions TTL, urgency, topic, batchSize, requestConcurrency
* @param int|null $timeout Timeout of POST request
* @param ClientInterface|null $client PSR-18 HTTP client. Defaults to an auto-discovered client (e.g. Guzzle, if installed). Configure timeouts/proxies/redirects directly on this client instance.
* @param RequestFactoryInterface|null $requestFactory PSR-17 request factory. Defaults to an auto-discovered factory.
* @param StreamFactoryInterface|null $streamFactory PSR-17 stream factory. Defaults to an auto-discovered factory.
* @param HttpAsyncClient|null $asyncClient Optional HTTPlug async client, required for concurrent sending via flushPooled(). Defaults to an auto-discovered async client, if any is installed.
* @param LoggerInterface|null $logger Optional PSR-3 logger; if provided, replaces trigger_error() calls
*
* @throws \ErrorException
*/
public function __construct(
array $auth = [],
array $defaultOptions = [],
?int $timeout = 30,
array $clientOptions = [],
?ClientInterface $client = null,
?RequestFactoryInterface $requestFactory = null,
?StreamFactoryInterface $streamFactory = null,
?HttpAsyncClient $asyncClient = null,
?LoggerInterface $logger = null
) {
$this->logger = $logger;
Expand All @@ -80,10 +92,19 @@ public function __construct(

$this->setDefaultOptions($defaultOptions);

if (!array_key_exists('timeout', $clientOptions) && isset($timeout)) {
$clientOptions['timeout'] = $timeout;
$this->client = $client ?? Psr18ClientDiscovery::find();
$this->requestFactory = $requestFactory ?? Psr17FactoryDiscovery::findRequestFactory();
$this->streamFactory = $streamFactory ?? Psr17FactoryDiscovery::findStreamFactory();

if ($asyncClient) {
$this->asyncClient = $asyncClient;
} else {
try {
$this->asyncClient = HttpAsyncClientDiscovery::find();
} catch (\Throwable) {
$this->asyncClient = null;
}
}
$this->client = new Client($clientOptions);
}

/**
Expand Down Expand Up @@ -157,19 +178,13 @@ public function flush(?int $batchSize = null): \Generator
// for each endpoint server type
$requests = $this->prepare($batch);

$promises = [];

foreach ($requests as $request) {
$promises[] = $this->client->sendAsync($request)
->then(function ($response) use ($request) {
/** @var ResponseInterface $response **/
return new MessageSentReport($request, $response);
})
->otherwise(fn($reason) => $this->createRejectedReport($reason));
}

foreach ($promises as $promise) {
yield $promise->wait();
try {
$response = $this->client->sendRequest($request);
yield $this->createReport($request, $response);
} catch (ClientExceptionInterface $reason) {
yield $this->createRejectedReport($request, $reason);
}
}
}

Expand All @@ -181,59 +196,81 @@ public function flush(?int $batchSize = null): \Generator
/**
* Flush notifications. Triggers concurrent requests.
*
* Requires an HTTPlug async client (e.g. via `php-http/guzzle7-adapter`), injected in the
* constructor or auto-discovered. See the "Customizing the HTTP client" section of the README.
*
* @param callable(MessageSentReport): void $callback Callback for each notification
* @param null|int $batchSize Defaults the value defined in defaultOptions during instantiation (which defaults to 1000).
* @param null|int $requestConcurrency Defaults the value defined in defaultOptions during instantiation (which defaults to 100).
* @param null|int $requestConcurrency Unused. Concurrency is now controlled by the underlying async client's own configuration.
*
* @throws \LogicException If no HTTPlug async client is available
*/
public function flushPooled(callable $callback, ?int $batchSize = null, ?int $requestConcurrency = null): void
{
if (empty($this->notifications)) {
return;
}

if (null === $batchSize) {
$batchSize = $this->defaultOptions['batchSize'];
if (!$this->asyncClient) {
throw new \LogicException('flushPooled() requires an HTTPlug async client for concurrent sending. Install one, e.g. "composer require php-http/guzzle7-adapter", or use flush() for sequential sending.');
}

if (null === $requestConcurrency) {
$requestConcurrency = $this->defaultOptions['requestConcurrency'];
if (null === $batchSize) {
$batchSize = $this->defaultOptions['batchSize'];
}

$batches = array_chunk($this->notifications, $batchSize);
$this->notifications = [];

foreach ($batches as $batch) {
$batch = $this->prepare($batch);
$pool = new Pool($this->client, $batch, [
'concurrency' => $requestConcurrency,
'fulfilled' => function (ResponseInterface $response, int $index) use ($callback, $batch): void {
/** @var RequestInterface $request **/
$request = $batch[$index];
$callback(new MessageSentReport($request, $response));
},
'rejected' => function ($reason) use ($callback): void {
$callback($this->createRejectedReport($reason));
},
]);

$promise = $pool->promise();
$promise->wait();
$requests = $this->prepare($batch);

$promises = [];
foreach ($requests as $request) {
$promises[] = $this->asyncClient->sendAsyncRequest($request)
->then(
function (ResponseInterface $response) use ($callback, $request): void {
$callback($this->createReport($request, $response));
},
function (\Throwable $reason) use ($callback, $request): void {
$callback($this->createRejectedReport($request, $reason));
}
);
}

foreach ($promises as $promise) {
$promise->wait();
}
}

if ($this->reuseVAPIDHeaders) {
$this->vapidHeaders = [];
}
}

protected function createRejectedReport(RequestException|ConnectException $reason): MessageSentReport
/**
* PSR-18 clients only throw for transport-level failures (DNS, connection refused, ...);
* HTTP error status codes (4xx, 5xx) are returned as a normal response and must be
* classified here.
*/
protected function createReport(RequestInterface $request, ResponseInterface $response): MessageSentReport
{
if ($reason instanceof RequestException) {
$response = $reason->getResponse();
} else {
$response = null;
$statusCode = $response->getStatusCode();
if ($statusCode >= 400) {
$reasonPhrase = $response->getReasonPhrase();
$reason = '' !== $reasonPhrase ? $reasonPhrase : 'Push service responded with status code '.$statusCode;

return new MessageSentReport($request, $response, false, $reason);
}

return new MessageSentReport($reason->getRequest(), $response, false, $reason->getMessage());
return new MessageSentReport($request, $response);
}

protected function createRejectedReport(RequestInterface $request, \Throwable $reason): MessageSentReport
{
$response = $reason instanceof HttpException ? $reason->getResponse() : null;

return new MessageSentReport($request, $response, false, $reason->getMessage());
}

/**
Expand Down Expand Up @@ -315,7 +352,11 @@ protected function prepare(array $notifications): array
}
}

$requests[] = new Request('POST', $endpoint, $headers, $content);
$request = $this->requestFactory->createRequest('POST', $endpoint);
foreach ($headers as $name => $value) {
$request = $request->withHeader($name, $value);
}
$requests[] = $request->withBody($this->streamFactory->createStream($content));
}

return $requests;
Expand Down