diff --git a/CHANGELOG.md b/CHANGELOG.md index 70166d6..8ed95f8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,6 +13,11 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Added request-local `baseUrl()` support for relative request and download URLs. - Added `FetchResponse::statusIs()` and named helpers for common HTTP statuses. - Added application-wide Laravel listeners for the existing request started, completed, and failed events. +- Added opt-in named terminal events for delivering request, upload, and download results to the active NativePHP screen. +- Added active-request snapshots, per-request activity checks, and bulk cancellation for native operations. +- Added live transfer progress and retry-waiting state to active-request snapshots. +- Added throttled `FetchActiveRequestsChanged` component events for reactive activity screens. +- Added a version-organized What's New documentation page with quick examples and links to full guides. ### Changed diff --git a/README.md b/README.md index 5d82644..113b1d0 100644 --- a/README.md +++ b/README.md @@ -15,6 +15,8 @@ Laravel's HTTP client instead. - JSON, form-encoded, raw, and multipart request bodies - Multiple attachments and repeated multipart field names - Upload and download progress events +- Named terminal events delivered to the active screen +- Active-request inspection and bulk cancellation - Streaming file downloads - Opt-in retries with exponential backoff - Per-attempt timeouts and explicit cancellation @@ -33,6 +35,8 @@ excludes future major versions. Read the complete documentation at [victorycodedev.github.io/nativephp-fetch](https://victorycodedev.github.io/nativephp-fetch/). +See [what's new](https://victorycodedev.github.io/nativephp-fetch/whats-new) for short, versioned examples of recently added APIs. + ## Installation See the [installation guide](https://victorycodedev.github.io/nativephp-fetch/getting-started). @@ -51,6 +55,8 @@ See the [JavaScript client guide](https://victorycodedev.github.io/nativephp-fet - [Uploads](https://victorycodedev.github.io/nativephp-fetch/uploads) - [Downloads](https://victorycodedev.github.io/nativephp-fetch/downloads) - [Events and responses](https://victorycodedev.github.io/nativephp-fetch/events) +- [Shared events](https://victorycodedev.github.io/nativephp-fetch/shared-events) +- [Active requests](https://victorycodedev.github.io/nativephp-fetch/active-requests) - [Retries and cancellation](https://victorycodedev.github.io/nativephp-fetch/retries-cancellation) - [API reference](https://victorycodedev.github.io/nativephp-fetch/api-reference) diff --git a/composer.json b/composer.json index 38c061b..596fc88 100644 --- a/composer.json +++ b/composer.json @@ -44,6 +44,7 @@ } }, "require-dev": { + "illuminate/container": "^11.0 || ^12.0 || ^13.0", "laravel/pint": "^1.30", "pestphp/pest": "^4.0", "phpstan/phpstan": "^2.2" @@ -59,4 +60,4 @@ "pestphp/pest-plugin": true } } -} \ No newline at end of file +} diff --git a/docs/.vitepress/config.mjs b/docs/.vitepress/config.mjs index 7a2cdb9..c58e026 100644 --- a/docs/.vitepress/config.mjs +++ b/docs/.vitepress/config.mjs @@ -12,6 +12,7 @@ export default defineConfig({ search: { provider: "local" }, nav: [ { text: "Guide", link: "/getting-started" }, + { text: "What's New", link: "/whats-new" }, { text: "API", link: "/api-reference" }, { text: "GitHub", link: "https://github.com/victorycodedev/nativephp-fetch" }, ], @@ -20,6 +21,7 @@ export default defineConfig({ text: "Getting started", items: [ { text: "Introduction", link: "/" }, + { text: "What's new", link: "/whats-new" }, { text: "Installation", link: "/getting-started" }, { text: "Making requests", link: "/requests" }, ], @@ -29,6 +31,8 @@ export default defineConfig({ items: [ { text: "Uploads", link: "/uploads" }, { text: "Downloads", link: "/downloads" }, + { text: "Shared events", link: "/shared-events" }, + { text: "Active requests", link: "/active-requests" }, { text: "NativeComponent example", link: "/native-component" }, { text: "Retries and cancellation", link: "/retries-cancellation" }, { text: "Events and responses", link: "/events" }, diff --git a/docs/active-requests.md b/docs/active-requests.md new file mode 100644 index 0000000..c6b8956 --- /dev/null +++ b/docs/active-requests.md @@ -0,0 +1,98 @@ +# Active requests + +Fetch can inspect the logical requests currently owned by the native networking layer. This is useful for showing an activity screen, preventing duplicate work, or cancelling operations that began on another screen. + +## List active work + +```php +use Victorycodedev\NativephpFetch\Facades\Fetch; + +$requests = Fetch::active(); + +foreach ($requests as $request) { + $request->requestId; + $request->method; + $request->url; + $request->type; // request or download + $request->attempt; // current attempt, starting at 1 + $request->state; // running or waiting_retry + $request->bytesTransferred; + $request->bytesTotal; // null when unknown + $request->progress; // null or 0.0–1.0 + $request->nextRetryAt; // Unix time in milliseconds, or null +} +``` + +Each entry is an immutable `ActiveRequest` snapshot. Uploads have the `request` type because they use the normal request lifecycle. Downloads use the `download` type. + +Ordinary requests generally have `null` progress because their response body is not exposed as a streaming download. Uploads and downloads update their byte and progress fields while transferring. During native retry backoff, `state` becomes `waiting_retry` and `nextRetryAt` indicates when the next attempt is scheduled. + +## Activity-screen example + +```php +use Native\Mobile\Attributes\On; +use Victorycodedev\NativephpFetch\Events\FetchActiveRequestsChanged; + +public function mount(): void +{ + $this->refreshTransfers(); +} + +#[On(FetchActiveRequestsChanged::class)] +public function activeRequestsChanged(string $requestId, string $change): void +{ + $this->refreshTransfers(); +} + +public function refreshTransfers(): void +{ + $this->transfers = array_map( + fn ($request) => [ + 'id' => $request->requestId, + 'label' => $request->type === 'download' ? 'Downloading' : 'Uploading', + 'progress' => $request->progress, + 'waiting' => $request->state === 'waiting_retry', + ], + Fetch::active(), + ); +} +``` + +Call `refreshTransfers()` when the screen opens so work started on another screen appears immediately. `FetchActiveRequestsChanged` then refreshes the active screen when an operation starts, progresses, enters or leaves retry waiting, or finishes. + +The event contains `requestId` and `change`. The change is `started`, `progress`, `waiting_retry`, `running`, or `finished`. Progress notifications are throttled natively to at most one every 250 milliseconds per request, while lifecycle changes are immediate. `active()` still returns a point-in-time snapshot; it does not continuously mutate PHP state. + +## Check one request + +```php +if (Fetch::isActive($requestId)) { + // The native operation or one of its retry delays is still active. +} +``` + +## Cancel everything + +```php +$cancelled = Fetch::cancelAll(); +``` + +`cancelAll()` returns the number of logical operations that accepted cancellation. Every cancelled operation still emits its existing `FetchRequestCancelled` event and its named terminal event when `shared()` was configured. + +## JavaScript + +```javascript +const requests = await Fetch.active(); +const running = await Fetch.isActive(requestId); +const cancelled = await Fetch.cancelAll(); +``` + +JavaScript receives the same snapshot fields as plain objects. + +## Lifecycle semantics + +- A request appears after native preparation succeeds and remains listed during retry delays. +- Retries update `attempt`; they do not create duplicate entries. +- Transfer counters describe the current attempt and reset when a retry begins. +- Completed, failed, and cancelled operations are removed. +- The registry is held by the running native application process. It is not durable after application termination. +- Fetch fakes complete synchronously, so `active()` is empty and `cancelAll()` returns `0` while faking. diff --git a/docs/api-reference.md b/docs/api-reference.md index c71dce7..7ca67c5 100644 --- a/docs/api-reference.md +++ b/docs/api-reference.md @@ -1,93 +1,218 @@ # API reference -## Fetch and PendingRequest +## Fetch facade -Facade configuration calls create a new pending request. Calls on an existing -pending request mutate and return that same request. +Every configuration method called on `Fetch` creates a fresh `PendingRequest`. +Configuration is therefore isolated between requests. + +### Request creation and configuration + +| Method | Returns | Description | +| --- | --- | --- | +| `request()` | `PendingRequest` | Create a request builder with a pre-generated UUIDv7 request ID. | +| `baseUrl(string $url)` | `PendingRequest` | Resolve relative request and download URLs against a request-local base URL. | +| `withHeader(string $name, string $value)` | `PendingRequest` | Add or case-insensitively replace one header. | +| `withHeaders(array $headers)` | `PendingRequest` | Add or replace multiple headers. | +| `withToken(string $token, string $type = 'Bearer')` | `PendingRequest` | Set the `Authorization` header. | +| `acceptJson()` | `PendingRequest` | Set `Accept: application/json`. | +| `asJson()` | `PendingRequest` | Select JSON request-body encoding. | +| `asForm()` | `PendingRequest` | Select RFC 1738 form request-body encoding. | +| `withBody(string $body, string $contentType = 'text/plain')` | `PendingRequest` | Select a raw string body and content type. | +| `timeout(int $seconds)` | `PendingRequest` | Set the per-attempt native timeout. The minimum is one second. | +| `shared(string $event)` | `PendingRequest` | Emit one named terminal event to whichever NativePHP screen is active. | +| `retry(int $times = 3, int $delay = 500, float $multiplier = 2.0, ?int $maxDelay = 30000, array $statuses = [])` | `PendingRequest` | Enable native retries. `times` means retries after the first attempt; delays are milliseconds. | +| `attach(string $name, string $path, ?string $filename = null, ?string $mimeType = null)` | `PendingRequest` | Append one multipart file. Repeated field names are supported. | +| `attachMany(array $attachments)` | `PendingRequest` | Atomically validate and append multiple multipart files. | + +### Requests and transfer control + +All request-starting methods return the stable request ID as a `string`. + +| Method | Returns | Description | +| --- | --- | --- | +| `get(string $url, array $query = [])` | `string` | Start a GET request with query parameters. | +| `post(string $url, array $data = [])` | `string` | Start a POST request. | +| `put(string $url, array $data = [])` | `string` | Start a PUT request. | +| `patch(string $url, array $data = [])` | `string` | Start a PATCH request. | +| `delete(string $url, array $data = [])` | `string` | Start a DELETE request. | +| `download(string $url, string $destination, array $query = [], bool $overwrite = false)` | `string` | Stream a response to a local file. | +| `cancel(string $requestId)` | `bool` | Cancel active network work or a pending retry delay. | +| `active()` | `list` | Return snapshots of active native operations. | +| `isActive(string $requestId)` | `bool` | Determine whether a logical operation is active. | +| `cancelAll()` | `int` | Cancel all active operations and return the number accepted for cancellation. | + +### Fakes and assertions + +| Method | Returns | Description | +| --- | --- | --- | +| `fake(array $responses = [])` | `FakeFetch` | Replace native bridge execution with deterministic synchronous responses. | +| `restore()` | `void` | Disable the current fake. | +| `isFaking()` | `bool` | Determine whether Fetch is currently faked. | +| `fakeInstance()` | `?FakeFetch` | Return the current fake for inspecting requests and recorded events. | +| `assertSent(Closure $callback)` | `void` | Assert that a recorded request satisfies the callback. | +| `assertNotSent(?Closure $callback = null)` | `void` | Assert that no request, or no matching request, was recorded. | +| `assertSentCount(int $count)` | `void` | Assert the exact number of recorded requests. | +| `assertShared(string $event, ?Closure $callback = null)` | `void` | Assert that a named shared event and optional payload match were recorded. | + +### Macros + +The Fetch manager uses Laravel's `Macroable` trait. | Method | Description | | --- | --- | -| `request()` | Create a request with a pre-generated UUIDv7 ID. | -| `id()` | Read the stable request ID before execution. | -| `baseUrl(url)` | Resolve relative request and download URLs against a request-local base URL. | -| `withHeader(name, value)` | Add or case-insensitively replace one header. | -| `withHeaders(headers)` | Add or replace multiple headers. | -| `withToken(token, type = 'Bearer')` | Set authorization. | -| `acceptJson()` | Request a JSON response. | -| `asJson()` | Select JSON body mode. | -| `asForm()` | Select RFC 1738 form body mode. | -| `withBody(body, contentType = 'text/plain')` | Select a raw string body. | -| `timeout(seconds)` | Set the per-attempt native timeout. | -| `retry(times = 3, delay = 500, multiplier = 2.0, maxDelay = 30000, statuses = [])` | Enable and configure native retries. `delay` and `maxDelay` are in milliseconds. | -| `attach(...)` | Append one multipart file. | -| `attachMany(attachments)` | Validate and append multiple files. | -| `get(url, query = [])` | Start a GET request. | -| `post(url, data = [])` | Start a POST request. | -| `put(url, data = [])` | Start a PUT request. | -| `patch(url, data = [])` | Start a PATCH request. | -| `delete(url, data = [])` | Start a DELETE request. | -| `download(url, destination, query = [], overwrite = false)` | Stream to a file. | -| `cancel(requestId)` | Cancel active work or a retry delay. | - -The facade also provides `fake()`, `restore()`, `isFaking()`, `fakeInstance()`, -`assertSent()`, `assertNotSent()`, and `assertSentCount()`. - -The Fetch manager uses Laravel's `Macroable` trait. Use `macro(name, callable)` -or `mixin(object)` to register extensions, `hasMacro(name)` to inspect them, -and `flushMacros()` to clear them. A request macro should return a newly -configured pending request so request state is not shared. +| `macro(string $name, object|callable $macro)` | Register a Fetch macro. | +| `mixin(object $mixin, bool $replace = true)` | Register methods from a mixin object. | +| `hasMacro(string $name)` | Determine whether a macro is registered. | +| `flushMacros()` | Remove every registered macro. | + +Macros are process-wide static state. In persistent runtimes, register them once +in a service provider and return a fresh request builder from request macros. + +## PendingRequest + +`PendingRequest` supports the same configuration and request methods listed +above. Configuration methods mutate and return that same builder, so fluent +chains retain their state. It additionally exposes: + +| Method | Returns | Description | +| --- | --- | --- | +| `id()` | `string` | Read the stable request ID before starting the operation. | +| `cancel(string $requestId)` | `bool` | Cancel the supplied request ID. Prefer `Fetch::cancel($requestId)` when cancelling a different builder's request. | + +## ActiveRequest + +Each item returned by `Fetch::active()` is an immutable `ActiveRequest` value +object with these public properties: + +| Property | Type | Description | +| --- | --- | --- | +| `requestId` | `string` | Stable logical request identifier. | +| `method` | `string` | HTTP method. Downloads report `GET`. | +| `url` | `string` | Resolved request URL. | +| `type` | `string` | `request` or `download`. | +| `attempt` | `int` | Current attempt number, beginning at 1. | +| `state` | `string` | `running` or `waiting_retry`. | +| `bytesTransferred` | `int` | Bytes uploaded or downloaded so far. | +| `bytesTotal` | `?int` | Total bytes when native networking can determine it. | +| `progress` | `?float` | Progress from `0.0` to `1.0`, or `null` when total size is unknown. | +| `nextRetryAt` | `?int` | Unix time in milliseconds for the next attempt, or `null`. | + +See [Active requests](/active-requests) for refresh and lifecycle semantics. ## FetchResponse +### Construction and response data + +| Method | Returns | Description | +| --- | --- | --- | +| `from(string $requestId, int $status, array $headers = [], string $body = '')` | `FetchResponse` | Build from native completion-event arguments. | +| `fromEvent(FetchRequestCompleted $event)` | `FetchResponse` | Build from a completed event object. | +| `make(int $status = 200, array|string|null $body = '', array $headers = [])` | `FetchResponse` | Build a response, primarily for fakes and tests. Array bodies are JSON encoded. | +| `withRequestId(string $requestId)` | `FetchResponse` | Return an immutable copy with a different request ID. | +| `requestId()` | `string` | Return the associated request ID. | +| `status()` | `int` | Return the HTTP status code. | +| `headers()` | `array` | Return every response header. | +| `header(string $name, mixed $default = null)` | `mixed` | Perform a case-insensitive header lookup. | +| `body()` | `string` | Return the raw response body. | +| `json(?string $key = null, mixed $default = null)` | `mixed` | Decode JSON and optionally read a dot-notated key. | + +### Status helpers + +| Method | Matches | +| --- | --- | +| `statusIs(int $status)` | Exact supplied status | +| `ok()` | 200 | +| `created()` | 201 | +| `accepted()` | 202 | +| `noContent()` | 204 | +| `movedPermanently()` | 301 | +| `found()` | 302 | +| `badRequest()` | 400 | +| `unauthorized()` | 401 | +| `paymentRequired()` | 402 | +| `forbidden()` | 403 | +| `notFound()` | 404 | +| `methodNotAllowed()` | 405 | +| `requestTimeout()` | 408 | +| `conflict()` | 409 | +| `gone()` | 410 | +| `unprocessableEntity()` | 422 | +| `tooManyRequests()` | 429 | +| `internalServerError()` | 500 | +| `serviceUnavailable()` | 503 | +| `successful()` | 200–299 | +| `redirect()` | 300–399 | +| `failed()` | 400 or greater | +| `clientError()` | 400–499 | +| `serverError()` | 500–599 | + +## Testing value objects + +### FakeFetch + +The object returned by `Fetch::fake()` provides: + +| Method | Description | +| --- | --- | +| `assertSent(Closure $callback)` | Assert a matching request was recorded. | +| `assertNotSent(?Closure $callback = null)` | Assert no request or no matching request was recorded. | +| `assertSentCount(int $count)` | Assert the request count. | +| `assertShared(string $event, ?Closure $callback = null)` | Assert a shared event was recorded. | +| `requests()` | Return all `RecordedRequest` instances. | +| `events()` | Return native-shaped started and terminal event objects. | +| `sharedEvents()` | Return all `RecordedSharedEvent` instances. | + +### RecordedRequest + | Method | Description | | --- | --- | -| `from(requestId, status, headers = [], body = '')` | Build from event arguments. | -| `fromEvent(event)` | Build from `FetchRequestCompleted`. | -| `make(status = 200, body = '', headers = [])` | Build a response for tests. | -| `withRequestId(requestId)` | Return a copy with a new request ID. | -| `requestId()` | Return the associated request ID. | -| `status()` | Return the HTTP status. | -| `headers()` | Return every response header. | -| `header(name, default = null)` | Case-insensitive header lookup. | -| `body()` | Return the raw response body. | -| `json(key = null, default = null)` | Decode JSON and optionally read a dot key. | -| `statusIs(status)` | Status exactly matches the given integer. | -| `ok()` | Status is exactly 200. | -| `created()` | Status is exactly 201. | -| `accepted()` | Status is exactly 202. | -| `noContent()` | Status is exactly 204. | -| `movedPermanently()` | Status is exactly 301. | -| `found()` | Status is exactly 302. | -| `badRequest()` | Status is exactly 400. | -| `unauthorized()` | Status is exactly 401. | -| `paymentRequired()` | Status is exactly 402. | -| `forbidden()` | Status is exactly 403. | -| `notFound()` | Status is exactly 404. | -| `methodNotAllowed()` | Status is exactly 405. | -| `requestTimeout()` | Status is exactly 408. | -| `conflict()` | Status is exactly 409. | -| `gone()` | Status is exactly 410. | -| `unprocessableEntity()` | Status is exactly 422. | -| `tooManyRequests()` | Status is exactly 429. | -| `internalServerError()` | Status is exactly 500. | -| `serviceUnavailable()` | Status is exactly 503. | -| `successful()` | Status is 200–299. | -| `redirect()` | Status is 300–399. | -| `failed()` | Status is 400 or greater. | -| `clientError()` | Status is 400–499. | -| `serverError()` | Status is 500–599. | - -## JavaScript exports - -The module exports `Fetch`, `PendingRequest`, and named helpers for every -configuration and request method. - -## Application-wide events - -`FetchRequestStarted`, `FetchRequestCompleted`, and `FetchRequestFailed` can be -consumed either by a NativeComponent using `#[On]` or application-wide using -Laravel's `Event::listen()`. Their constructors and payloads are identical in -both cases. - -The remaining progress, retry, cancellation, and download-completed events are -component-only. +| `requestId()` | Stable request ID. | +| `method()` | HTTP method. | +| `url()` | Resolved request URL. | +| `headers()` | Configured headers. | +| `body()` | Normalized body payload, or `null`. | +| `payload()` | Complete normalized bridge payload. | +| `hasHeader(string $name, ?string $value = null)` | Check a header name and optionally its value. | + +`RecordedSharedEvent` exposes public readonly `name` and `payload` properties. +See [Testing](/testing) for complete examples. + +## Events + +| Event | Public data | Delivery | +| --- | --- | --- | +| `FetchRequestStarted` | `requestId`, `method`, `url` | Component and application-wide | +| `FetchRequestCompleted` | `requestId`, `status`, `headers`, `body` | Component and application-wide | +| `FetchRequestFailed` | `requestId`, `message`, nullable `code` | Component and application-wide | +| `FetchRequestCancelled` | `requestId` | Component only | +| `FetchRequestRetrying` | `requestId`, `attempt`, `maxAttempts`, `delayMs`, `reason`, nullable `status` | Component only | +| `FetchUploadProgress` | `requestId`, `bytesSent`, `bytesTotal`, `progress` | Component only | +| `FetchDownloadProgress` | `requestId`, `bytesReceived`, nullable `bytesTotal`, nullable `progress` | Component only | +| `FetchDownloadCompleted` | `requestId`, `status`, `headers`, `path`, `bytesReceived` | Component only | +| `FetchActiveRequestsChanged` | `requestId`, `change` | Component only | + +See [Events and responses](/events) and [Shared events](/shared-events) for +delivery rules and examples. + +## JavaScript client + +The package exports `Fetch` (also available as `fetch` and the default export), +`PendingRequest`, and these named helpers: + +| Export | Description | +| --- | --- | +| `request()` | Create an independent JavaScript `PendingRequest`. | +| `withHeaders(headers)`, `withHeader(name, value)` | Configure headers. | +| `withToken(token, type = 'Bearer')`, `acceptJson()` | Configure authorization and accepted content. | +| `asJson()`, `asForm()`, `withBody(body, contentType)` | Select a request body mode. | +| `timeout(seconds)` | Set the per-attempt timeout. | +| `retry(options = 3)` | Configure retries using a count or `{ times, delay, multiplier, maxDelay, statuses }`. | +| `shared(event)` | Configure an active-screen terminal event. | +| `attach(...)`, `attachMany(attachments)` | Configure multipart uploads. | +| `get(...)`, `post(...)`, `put(...)`, `patch(...)`, `delete(...)` | Start HTTP requests and resolve to their request ID. | +| `download(url, destination, options = {})` | Start a download and resolve to its request ID. Options include `query` and `overwrite`. | +| `cancel(requestId)` | Cancel one request. A builder also provides `request.cancel()` for its own ID. | +| `active()`, `isActive(requestId)`, `cancelAll()` | Inspect and control active native operations. | + +The JavaScript client currently does not expose PHP-only manager features such +as Laravel macros, Fetch fakes, `FetchResponse`, or `baseUrl()`. diff --git a/docs/events.md b/docs/events.md index 28ec3be..af93f14 100644 --- a/docs/events.md +++ b/docs/events.md @@ -1,5 +1,7 @@ # Events and responses +Need a request to notify a different screen after navigation? See [Shared events](/shared-events). + NativePHP component events are handled by the active NativeComponent with `#[On]` or `$this->on()`. The started, completed, and failed lifecycle events are also broadcast through Laravel so application-wide listeners may consume @@ -84,9 +86,12 @@ to `FetchRequestFailed` without first dispatching `FetchRequestStarted`. | `FetchUploadProgress` | `requestId`, `bytesSent`, `bytesTotal`, `progress` | | `FetchDownloadProgress` | `requestId`, `bytesReceived`, nullable `bytesTotal`, nullable `progress` | | `FetchDownloadCompleted` | `requestId`, `status`, `headers`, `path`, `bytesReceived` | +| `FetchActiveRequestsChanged` | `requestId`, `change` | Only `FetchRequestStarted`, `FetchRequestCompleted`, and `FetchRequestFailed` are globally observable. The other events in the table remain component-only. +`FetchActiveRequestsChanged` is a lightweight invalidation signal for refreshing +`Fetch::active()`; its progress notifications are throttled to 250 milliseconds. ## Terminal events diff --git a/docs/javascript.md b/docs/javascript.md index b5cf24b..1bfe5cf 100644 --- a/docs/javascript.md +++ b/docs/javascript.md @@ -56,6 +56,18 @@ await request.post(url, data); await Fetch.cancel(requestId); ``` +## Shared events + +Use `shared()` when the terminal result should reach whichever screen is active +after navigation: + +```javascript +const requestId = await Fetch.shared('profile-saved').post(url, data); +``` + +The named event has the same completed, failed, and cancelled payload described +in the [shared events guide](/shared-events). + ## Downloads ```javascript @@ -65,6 +77,16 @@ const requestId = await Fetch.download(url, destination, { }); ``` +## Active requests + +```javascript +const requests = await Fetch.active(); +const running = await Fetch.isActive(requestId); +const cancelled = await Fetch.cancelAll(); +``` + +See [Active requests](/active-requests) for snapshot fields and lifecycle semantics. + ## Multiple attachments ```javascript diff --git a/docs/package-lock.json b/docs/package-lock.json index 52c749a..75b1d7f 100644 --- a/docs/package-lock.json +++ b/docs/package-lock.json @@ -4,6 +4,7 @@ "requires": true, "packages": { "": { + "name": "docs", "devDependencies": { "vitepress": "2.0.0-alpha.11" } @@ -29,9 +30,9 @@ } }, "node_modules/@babel/parser": { - "version": "7.29.8", - "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.8.tgz", - "integrity": "sha512-E8lTAYNB1KW+FH+VGJuZM1ioAx2E6oVlvQFRrf5P8ZZmsiJXYAD9vTFV7yyEURNzgh1dFqMZuO6tUwcARbqFCA==", + "version": "7.29.9", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.9.tgz", + "integrity": "sha512-CjXrNHTnvqBVqHgdBysY3vk2T8tpJHb5/RMeHJBTyVa9xgugCB0CJTx/3oO8RV2QRQP391RWpB7D6hLjm8V9uA==", "dev": true, "license": "MIT", "dependencies": { @@ -532,9 +533,9 @@ "license": "MIT" }, "node_modules/@jridgewell/sourcemap-codec": { - "version": "1.5.5", - "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", - "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.6.0.tgz", + "integrity": "sha512-T7jf+5zgsZHwNJ4lvQ7/aezbyk0nNX+zJVWpmHA7VYsEx7a7qr5Rg5IbtJFqkgze5Y2sruq1RUY8Q837Od7iFw==", "dev": true, "license": "MIT" }, @@ -1088,57 +1089,57 @@ } }, "node_modules/@vue/compiler-core": { - "version": "3.5.41", - "resolved": "https://registry.npmjs.org/@vue/compiler-core/-/compiler-core-3.5.41.tgz", - "integrity": "sha512-q0Xtv/F9w2YO/7htQhtiL+Ev2WCJbe5N2hc+XfgyKkEKqWpSxknmT8QOuGdEKNdjPq0c3F7rNpFkTo3Kfrm7pg==", + "version": "3.5.44", + "resolved": "https://registry.npmjs.org/@vue/compiler-core/-/compiler-core-3.5.44.tgz", + "integrity": "sha512-k34nCytbK8PQ1gR2mGROYOw6Pnv/mumqIdrZ3x2tbyXZDWm7ViKGLTfdB+DRrWqEQsLgGJvn0WN9jBif5zCTLQ==", "dev": true, "license": "MIT", "dependencies": { - "@babel/parser": "^7.29.8", - "@vue/shared": "3.5.41", + "@babel/parser": "^7.29.9", + "@vue/shared": "3.5.44", "entities": "^7.0.1", "estree-walker": "^2.0.2", - "source-map-js": "^1.2.1" + "source-map-js": "^1.2.2" } }, "node_modules/@vue/compiler-dom": { - "version": "3.5.41", - "resolved": "https://registry.npmjs.org/@vue/compiler-dom/-/compiler-dom-3.5.41.tgz", - "integrity": "sha512-oKacVfNglLvGjnS6BXOlGL7EyG2h8X03pqXCjzotRZUaXGjbrTJUnVAQjrCqUnS+lyu31nwQjZY/d817GmCnfw==", + "version": "3.5.44", + "resolved": "https://registry.npmjs.org/@vue/compiler-dom/-/compiler-dom-3.5.44.tgz", + "integrity": "sha512-W5AoBFWdw1lQAKiHowkfgFKFab82oSRT6sGsPfw/q1GRjsy/ZJFLHGhJyBRIFN9APLTUduDH9U2JAiPOIyKuYg==", "dev": true, "license": "MIT", "dependencies": { - "@vue/compiler-core": "3.5.41", - "@vue/shared": "3.5.41" + "@vue/compiler-core": "3.5.44", + "@vue/shared": "3.5.44" } }, "node_modules/@vue/compiler-sfc": { - "version": "3.5.41", - "resolved": "https://registry.npmjs.org/@vue/compiler-sfc/-/compiler-sfc-3.5.41.tgz", - "integrity": "sha512-XJhip7R2wy6vX3knCxdZN4KracFaZUef58s1KYewqluedHIJaPIVfXoYT7MF1F8nCvv6k8bWWxDC8opMkg1VTQ==", + "version": "3.5.44", + "resolved": "https://registry.npmjs.org/@vue/compiler-sfc/-/compiler-sfc-3.5.44.tgz", + "integrity": "sha512-dzHmQ4jXTpdr5FjIQUeykVakN/uzqFbUo9/C5ikBFg3a3PeM6uRxeczx1ZbXBML0/zJstEh3ygYipzR5Z7b2Pw==", "dev": true, "license": "MIT", "dependencies": { - "@babel/parser": "^7.29.8", - "@vue/compiler-core": "3.5.41", - "@vue/compiler-dom": "3.5.41", - "@vue/compiler-ssr": "3.5.41", - "@vue/shared": "3.5.41", + "@babel/parser": "^7.29.9", + "@vue/compiler-core": "3.5.44", + "@vue/compiler-dom": "3.5.44", + "@vue/compiler-ssr": "3.5.44", + "@vue/shared": "3.5.44", "estree-walker": "^2.0.2", "magic-string": "^0.30.21", - "postcss": "^8.5.19", - "source-map-js": "^1.2.1" + "postcss": "^8.5.29", + "source-map-js": "^1.2.2" } }, "node_modules/@vue/compiler-ssr": { - "version": "3.5.41", - "resolved": "https://registry.npmjs.org/@vue/compiler-ssr/-/compiler-ssr-3.5.41.tgz", - "integrity": "sha512-U3v5OejKEGqOI0Wy0+Sz7hGuIFZHA4LSXzrNM3IMIeDyJEBBfTpX26n3SDgToRpP2bLc9FfI2j/kSgcJ8Emq5A==", + "version": "3.5.44", + "resolved": "https://registry.npmjs.org/@vue/compiler-ssr/-/compiler-ssr-3.5.44.tgz", + "integrity": "sha512-xr5gb1JVHlfXJwSlRz1FeP6uxoazdzHuMTVLE7up+4Vrqxk219CvxLPxD9NXBt47yvZW7+p4DXfyp4O1fkByIA==", "dev": true, "license": "MIT", "dependencies": { - "@vue/compiler-dom": "3.5.41", - "@vue/shared": "3.5.41" + "@vue/compiler-dom": "3.5.44", + "@vue/shared": "3.5.44" } }, "node_modules/@vue/devtools-api": { @@ -1172,55 +1173,55 @@ "license": "MIT" }, "node_modules/@vue/reactivity": { - "version": "3.5.41", - "resolved": "https://registry.npmjs.org/@vue/reactivity/-/reactivity-3.5.41.tgz", - "integrity": "sha512-rznsqKM0np0x18EjzF8x88MpEhdNsffbvFbckLL5+oUKz1BxAImEmO7J1ArRYSyo6aQaVoBDp7jEkT91OOxydA==", + "version": "3.5.44", + "resolved": "https://registry.npmjs.org/@vue/reactivity/-/reactivity-3.5.44.tgz", + "integrity": "sha512-u+QJdKeBBEADkPdevsL+6E12QlYVfBD8Zcw5qjDQKtamI6l14KyqazJMobhCKelhWN5zpyKUiEDoTyHE93cqWw==", "dev": true, "license": "MIT", "dependencies": { - "@vue/shared": "3.5.41" + "@vue/shared": "3.5.44" } }, "node_modules/@vue/runtime-core": { - "version": "3.5.41", - "resolved": "https://registry.npmjs.org/@vue/runtime-core/-/runtime-core-3.5.41.tgz", - "integrity": "sha512-Vcry58hiAKwGen9Z1jUZE0feFsNArPCMOImYI8el48A9Idf6DuQYD0U05zZIF2Iad1hGhPSvcbBbAOhNr55fhg==", + "version": "3.5.44", + "resolved": "https://registry.npmjs.org/@vue/runtime-core/-/runtime-core-3.5.44.tgz", + "integrity": "sha512-uEa/ndwjhrrhnOfb2p+sZy6zeoamJOISqaLSJGk/8R6nABT1ZldpzcJoPzKj+zHVomVBPcmQYyC2r4LtvDV6CQ==", "dev": true, "license": "MIT", "dependencies": { - "@vue/reactivity": "3.5.41", - "@vue/shared": "3.5.41" + "@vue/reactivity": "3.5.44", + "@vue/shared": "3.5.44" } }, "node_modules/@vue/runtime-dom": { - "version": "3.5.41", - "resolved": "https://registry.npmjs.org/@vue/runtime-dom/-/runtime-dom-3.5.41.tgz", - "integrity": "sha512-3vVBahVBS9+U6cmXBLyb8nE6/yYo4J/CGI9eVFs3KiMc0YHuudwKyShTD65jtJy/L9PUUxNAFu4cj4LiJ0UFbw==", + "version": "3.5.44", + "resolved": "https://registry.npmjs.org/@vue/runtime-dom/-/runtime-dom-3.5.44.tgz", + "integrity": "sha512-3fc4ARcXVhVBaJ3ujWiNZJARD6pkyRcwG+ZYhMzjKtKUp7MAY0HLwr7r3Smep3Li6ZaWT6ewdmx8XqdaD5oNZg==", "dev": true, "license": "MIT", "dependencies": { - "@vue/reactivity": "3.5.41", - "@vue/runtime-core": "3.5.41", - "@vue/shared": "3.5.41", + "@vue/reactivity": "3.5.44", + "@vue/runtime-core": "3.5.44", + "@vue/shared": "3.5.44", "csstype": "^3.2.3" } }, "node_modules/@vue/server-renderer": { - "version": "3.5.41", - "resolved": "https://registry.npmjs.org/@vue/server-renderer/-/server-renderer-3.5.41.tgz", - "integrity": "sha512-n6hx/pNFfbD6SuyeuMVkvqox8bwf/ET9JlA/kAz/imw8sw++wkqKe2mHX5KutjPpbKE4Z56yTHszoOjGMI9igQ==", + "version": "3.5.44", + "resolved": "https://registry.npmjs.org/@vue/server-renderer/-/server-renderer-3.5.44.tgz", + "integrity": "sha512-R4S1UoH4h5Frrauv8Z5XE5TxV9oBiUSgWIC4TyVK1FK+RocRnOLwFA5Xcmo2011RlgJp+AbZCnw6afrhlgRuUQ==", "dev": true, "license": "MIT", "dependencies": { - "@vue/compiler-ssr": "3.5.41", - "@vue/runtime-dom": "3.5.41", - "@vue/shared": "3.5.41" + "@vue/compiler-ssr": "3.5.44", + "@vue/runtime-dom": "3.5.44", + "@vue/shared": "3.5.44" } }, "node_modules/@vue/shared": { - "version": "3.5.41", - "resolved": "https://registry.npmjs.org/@vue/shared/-/shared-3.5.41.tgz", - "integrity": "sha512-IOnwSCma8j+9xJT6b8H0dEYidC80NsYmNMlZxRsukYcSoGaDBohog5hDxzeUXdFeGWFA++vWvxqOmrr96VlqMA==", + "version": "3.5.44", + "resolved": "https://registry.npmjs.org/@vue/shared/-/shared-3.5.44.tgz", + "integrity": "sha512-H82XLH0DjmSOdbEwO15x3QTo1f5PWEtv237eLpYKc0mqLu+B8hnq16lFWmuBmNurkeiG3wr+oM1FhOll/YVFRg==", "dev": true, "license": "MIT" }, @@ -1719,9 +1720,9 @@ "license": "MIT" }, "node_modules/nanoid": { - "version": "3.3.18", - "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.18.tgz", - "integrity": "sha512-DTg4MJbGMWkfi6VZFdNt2/caMbQy4Ou+Op/hJQvGEWcnVfoA1QA+xzRKAzw9jD6+GVOOeYr/mIcuDSdug6F6+w==", + "version": "3.3.20", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.20.tgz", + "integrity": "sha512-uKdg2G3GNCKQn9byYOpxbGqrT2fGO5KRt5J/8b3pok8rT6qxGWF6hxMyJiEYtAf+FVyYuD9hRaDqX5uPFYJ4ZQ==", "dev": true, "funding": [ { @@ -1784,9 +1785,9 @@ } }, "node_modules/postcss": { - "version": "8.5.26", - "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.26.tgz", - "integrity": "sha512-u82N74LFzG8ca+dD8puPnplTXoGH4fTPpVGuIbt36G3qvNlkvfD0lEAZSxaly3KX8TS/L1A1gsCEmvKmBcVbkQ==", + "version": "8.5.31", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.31.tgz", + "integrity": "sha512-YUbWYCgErwnMAVkjUnYa+KlMBzGpHwiIt/DE7f0G33AuX2LH9UcTk6KgWxd3zRU+pN3u/C+Xl3EnMLM4Hea7Sg==", "dev": true, "funding": [ { @@ -1804,9 +1805,9 @@ ], "license": "MIT", "dependencies": { - "nanoid": "^3.3.17", + "nanoid": "^3.3.20", "picocolors": "^1.1.1", - "source-map-js": "^1.2.1" + "source-map-js": "^1.2.2" }, "engines": { "node": "^10 || ^12 || >=14" @@ -1914,9 +1915,9 @@ } }, "node_modules/source-map-js": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", - "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.2.tgz", + "integrity": "sha512-KGj/8Y43x35aZVDtt+J4mK1hoLGHULMYfSkODJNQjNDC3oW1PqPoxMwo0pLUsWM/UEGzON/NxeHywEfNXNP3Vw==", "dev": true, "license": "BSD-3-Clause", "engines": { @@ -2088,9 +2089,9 @@ } }, "node_modules/vite": { - "version": "7.3.6", - "resolved": "https://registry.npmjs.org/vite/-/vite-7.3.6.tgz", - "integrity": "sha512-4XP60spRGjSZFf1qYH+dJIkK2znL3zQfl9KkOV9MkkRR/3Dls0dxaBsQPTloEc5BLXWPL9vsOxopxyKoMmDueg==", + "version": "7.3.7", + "resolved": "https://registry.npmjs.org/vite/-/vite-7.3.7.tgz", + "integrity": "sha512-AHEzTORMbi7/7aXp7w7KZ5+DCUdSeLsJz0grY8xHpc9DHdYb0vQu6DqJfm2tOw3gDExNgPgnJTXxM6syYFHaCA==", "dev": true, "license": "MIT", "dependencies": { @@ -2209,17 +2210,17 @@ } }, "node_modules/vue": { - "version": "3.5.41", - "resolved": "https://registry.npmjs.org/vue/-/vue-3.5.41.tgz", - "integrity": "sha512-2laE0p+aK+/AOPG/XL/WepOs/GlK755LJ1XECi9kDUrz1FKNw8rb2Xzlw9JS1rqEV55nb0ttsKxVlTCcd+R5cg==", + "version": "3.5.44", + "resolved": "https://registry.npmjs.org/vue/-/vue-3.5.44.tgz", + "integrity": "sha512-dvvmaOCG7lAMkJTPqPwWeGMsPS6DIsFsfZDUgBp9V6mHf9b3+/wyVwYR4Es+WV6uJC0FKRxp81Obcq0ZwUcGFQ==", "dev": true, "license": "MIT", "dependencies": { - "@vue/compiler-dom": "3.5.41", - "@vue/compiler-sfc": "3.5.41", - "@vue/runtime-dom": "3.5.41", - "@vue/server-renderer": "3.5.41", - "@vue/shared": "3.5.41" + "@vue/compiler-dom": "3.5.44", + "@vue/compiler-sfc": "3.5.44", + "@vue/runtime-dom": "3.5.44", + "@vue/server-renderer": "3.5.44", + "@vue/shared": "3.5.44" }, "peerDependencies": { "typescript": "*" diff --git a/docs/shared-events.md b/docs/shared-events.md new file mode 100644 index 0000000..d73e50b --- /dev/null +++ b/docs/shared-events.md @@ -0,0 +1,99 @@ +# Shared events + +Shared events let a request notify whichever NativePHP screen is active when it reaches a terminal state. This is useful when a user starts an upload or download and navigates elsewhere before it finishes. + +## Send a shared event + +Choose an application-specific event name with `shared()`: + +```php +use Victorycodedev\NativephpFetch\Facades\Fetch; + +public function uploadAvatar(string $path): void +{ + Fetch::attach('avatar', $path, mimeType: 'image/jpeg') + ->shared('avatar-uploaded') + ->post('https://api.example.com/avatar'); +} +``` + +The normal Fetch lifecycle events still fire. `shared()` adds one named event when the logical request completes, fails, or is cancelled; internal retries do not emit additional shared events. + +## Listen on the active screen + +Any screen that is active when the request finishes can listen for the chosen name: + +```php +use Native\Mobile\Attributes\On; + +#[On('avatar-uploaded')] +public function avatarUploaded( + string $requestId, + string $status, + ?int $httpStatus = null, + array $headers = [], + string $body = '', + ?string $message = null, + ?string $code = null, +): void { + if ($status === 'completed') { + // Refresh the avatar shown on this screen. + } +} +``` + +`status` is always `completed`, `failed`, or `cancelled`. Completed HTTP requests also provide `httpStatus`, `headers`, and `body`. A server response such as 404 or 422 is still a completed HTTP response unless that status was explicitly configured for retries and every attempt was exhausted. + +## Downloads + +Downloads use the same API. Their completed payload contains `path` and `bytesReceived` instead of `body`: + +```php +Fetch::shared('report-ready')->download( + 'https://api.example.com/reports/monthly', + $destination, +); + +#[On('report-ready')] +public function reportReady( + string $requestId, + string $status, + ?int $httpStatus = null, + array $headers = [], + ?string $path = null, + ?int $bytesReceived = null, + ?string $message = null, + ?string $code = null, +): void { + // +} +``` + +## Delivery semantics + +- The named event goes to the screen that is active when the operation finishes. +- It is not queued for an inactive or destroyed screen and is not replayed later. +- It is not durable across application termination. +- It fires once per logical request, after retries have finished. +- Progress remains available through the existing typed Fetch events. + +Use persistent application state when a result must survive process termination or be recovered later. + +## Testing + +Shared events are recorded separately by the Fetch fake, so existing event assertions are unchanged: + +```php +Fetch::fake([ + '*' => FetchResponse::make(201, ['saved' => true]), +]); + +Fetch::shared('profile-saved')->post($url, $data); + +Fetch::assertShared('profile-saved', function (array $payload): bool { + return $payload['status'] === 'completed' + && $payload['httpStatus'] === 201; +}); +``` + +This follows NativePHP's active-screen shared event pattern described in its [async tasks documentation](https://nativephp.com/docs/mobile/4/digging-deeper/async-tasks). diff --git a/docs/testing.md b/docs/testing.md index d894c7f..ff9c6a1 100644 --- a/docs/testing.md +++ b/docs/testing.md @@ -26,6 +26,16 @@ Fetch::assertSentCount(1); Fetch::restore(); ``` +Requests using `shared()` can be asserted by event name and payload: + +```php +Fetch::shared('profile-saved')->post($url, $data); + +Fetch::assertShared('profile-saved', fn (array $payload) => + $payload['status'] === 'completed' +); +``` + Wildcards use Laravel's `Str::is` matching. A closure can calculate a response from the recorded request: @@ -42,6 +52,10 @@ The fake records events for deterministic assertions through event dispatcher because production global dispatch occurs when an event arrives through NativePHP's event bridge. +Named shared events are kept separately in +`Fetch::fakeInstance()->sharedEvents()` so existing typed-event counts remain +backward compatible. + `isFaking()` returns whether a fake is currently active. `assertNotSent()` fails when any recorded request matches the given closure (or when any request was sent, if no closure is given). diff --git a/docs/whats-new.md b/docs/whats-new.md new file mode 100644 index 0000000..024bcf5 --- /dev/null +++ b/docs/whats-new.md @@ -0,0 +1,55 @@ +# What's new + +## 1.2.0 (upcoming) + +### Shared terminal events + +Send one named completion, failure, or cancellation event to whichever NativePHP screen is active when the request finishes: + +```php +Fetch::shared('profile-saved')->post($url, $data); +``` + +See [Shared events](/shared-events). + +### Active request inspection + +Inspect and control logical operations owned by the native networking layer: + +```php +$requests = Fetch::active(); +$running = Fetch::isActive($requestId); +$cancelled = Fetch::cancelAll(); +``` + +### Live state and progress snapshots + +Build an activity or transfer screen from the current native snapshot: + +```php +foreach (Fetch::active() as $request) { + $request->state; // running or waiting_retry + $request->progress; // null or 0.0–1.0 + $request->nextRetryAt; // Unix milliseconds or null +} +``` + +See [Active requests](/active-requests) for all snapshot fields and lifecycle semantics. + +### Reactive activity screens + +Refresh an activity screen without polling continuously: + +```php +#[On(FetchActiveRequestsChanged::class)] +public function refreshActivity(): void +{ + $this->requests = Fetch::active(); +} +``` + +The event covers starts, throttled progress, retry state changes, and terminal removal. See [Active requests](/active-requests) for the complete example. + +### Reliability and compatibility + +This release also hardens retry snapshot resets, terminal cleanup, temporary upload cleanup, and Android/iOS lifecycle parity. Existing request methods, bridge names, event classes, event payloads, retry behavior, and NativePHP `#[On(...)]` listeners remain backward compatible. diff --git a/nativephp.json b/nativephp.json index ea9dd9c..96c7c62 100644 --- a/nativephp.json +++ b/nativephp.json @@ -43,6 +43,18 @@ "android": "com.victorycodedev.plugins.nativephp_fetch.FetchFunctions.Download", "ios": "FetchFunctions.Download", "description": "Stream an HTTP response to an application-writable file" + }, + { + "name": "Fetch.Active", + "android": "com.victorycodedev.plugins.nativephp_fetch.FetchFunctions.Active", + "ios": "FetchFunctions.Active", + "description": "List active native Fetch operations" + }, + { + "name": "Fetch.CancelAll", + "android": "com.victorycodedev.plugins.nativephp_fetch.FetchFunctions.CancelAll", + "ios": "FetchFunctions.CancelAll", + "description": "Cancel all active native Fetch operations" } ], "android": { @@ -83,7 +95,8 @@ "Victorycodedev\\NativephpFetch\\Events\\FetchUploadProgress", "Victorycodedev\\NativephpFetch\\Events\\FetchRequestRetrying", "Victorycodedev\\NativephpFetch\\Events\\FetchDownloadProgress", - "Victorycodedev\\NativephpFetch\\Events\\FetchDownloadCompleted" + "Victorycodedev\\NativephpFetch\\Events\\FetchDownloadCompleted", + "Victorycodedev\\NativephpFetch\\Events\\FetchActiveRequestsChanged" ], "service_provider": "Victorycodedev\\NativephpFetch\\FetchServiceProvider", "hooks": { diff --git a/phpstan.neon.dist b/phpstan.neon.dist index 2a359ce..6432684 100644 --- a/phpstan.neon.dist +++ b/phpstan.neon.dist @@ -4,6 +4,7 @@ parameters: - src/Exceptions - src/Facades - src/Fetch.php + - src/ActiveRequest.php - src/FetchResponse.php - src/FetchServiceProvider.php - src/PendingRequest.php diff --git a/resources/android/src/FetchFunctions.kt b/resources/android/src/FetchFunctions.kt index 21c032d..75d2a48 100644 --- a/resources/android/src/FetchFunctions.kt +++ b/resources/android/src/FetchFunctions.kt @@ -45,6 +45,10 @@ private object FetchClient { private val retryOperations = ConcurrentHashMap() + private val announcedActiveRequests = ConcurrentHashMap.newKeySet() + + private val activeProgressTimes = ConcurrentHashMap() + private val retryScheduler = Executors.newSingleThreadScheduledExecutor() fun start( @@ -57,6 +61,7 @@ private object FetchClient { body: Map?, timeoutSeconds: Long, retryPolicy: RetryPolicy?, + sharedEvent: String?, ) { val parsedUrl = url.toHttpUrlOrNull() ?: run { @@ -65,6 +70,7 @@ private object FetchClient { requestId = requestId, message = "The supplied URL is invalid.", code = "invalid_url", + sharedEvent = sharedEvent, ) return } @@ -103,6 +109,7 @@ private object FetchClient { activity = activity, requestId = requestId, body = body, + sharedEvent = sharedEvent, ) if (bodyResult.failed) { @@ -143,6 +150,7 @@ private object FetchClient { requestId = requestId, message = "HTTP method $method is not supported.", code = "unsupported_method", + sharedEvent = sharedEvent, ) return } @@ -157,16 +165,21 @@ private object FetchClient { .writeTimeout(timeoutSeconds, TimeUnit.SECONDS) .build() + val operation = RetryOperation( + requestId, + retryPolicy, + sharedEvent, + request.method, + request.url.toString(), + ) + operation.activity = activity + retryOperations[requestId] = operation emitStarted( activity = activity, requestId = requestId, method = method.uppercase(), url = request.url.toString(), ) - - val operation = RetryOperation(requestId, retryPolicy) - operation.activity = activity - retryOperations[requestId] = operation enqueueStandardAttempt(activity, operation, timedClient, request) } @@ -176,9 +189,20 @@ private object FetchClient { client: OkHttpClient, request: Request, ) { - synchronized(operation) { + val resumed = synchronized(operation) { if (operation.cancelled || operation.terminal) return + val isRetry = operation.attempt > 0 operation.attempt++ + operation.state = "running" + operation.nextRetryAt = null + operation.bytesTransferred = 0L + operation.bytesTotal = null + operation.retryFuture = null + isRetry + } + + if (resumed) { + emitActiveRequestsChanged(activity, operation.requestId, "running") } val call = client.newCall(request) @@ -189,7 +213,7 @@ private object FetchClient { override fun onFailure(call: Call, exception: IOException) { // OkHttp also marks a call as canceled when its call timeout fires. // Only our logical flag proves that Fetch.Cancel was requested. - if (operation.cancelled) { + if (isRetryCancelled(operation)) { finishRetryCancelled(activity, operation) return } @@ -210,7 +234,7 @@ private object FetchClient { override fun onResponse(call: Call, response: Response) { response.use { - if (operation.cancelled) { + if (isRetryCancelled(operation)) { finishRetryCancelled(activity, operation) return } @@ -254,6 +278,19 @@ private object FetchClient { put("body", response.body?.string().orEmpty()) } NativeEventDispatcher.dispatch(activity, FetchEvents.COMPLETED, payload) + emitSharedCompleted( + activity = activity, + sharedEvent = operation.sharedEvent, + requestId = operation.requestId, + httpStatus = response.code, + headers = responseHeaders, + body = payload.getString("body"), + ) + emitActiveRequestsChanged( + activity, + operation.requestId, + "finished", + ) } } }) @@ -270,6 +307,7 @@ private object FetchClient { timeoutSeconds: Long, overwrite: Boolean, retryPolicy: RetryPolicy?, + sharedEvent: String?, ) { val parsedUrl = url.toHttpUrlOrNull() ?: throw DownloadStartException( @@ -396,6 +434,8 @@ private object FetchClient { partial = partial, overwrite = overwrite, policy = retryPolicy, + sharedEvent = sharedEvent, + url = request.url.toString(), ) state.attempt = 1 @@ -579,6 +619,7 @@ private object FetchClient { response, destination.path, bytesReceived, + state.sharedEvent, ) } } catch (exception: Exception) { @@ -625,6 +666,8 @@ private object FetchClient { state.partial.delete() val value = retryDelay(policy, state.attempt, retryAfter) state.attempt++ + state.state = "waiting_retry" + state.nextRetryAt = System.currentTimeMillis() + value value to policy.maxAttempts } @@ -643,15 +686,67 @@ private object FetchClient { synchronized(state) { if (state.cancelled || state.terminal) return@schedule state.partial.delete() + state.state = "running" + state.nextRetryAt = null + state.bytesTransferred = 0L + state.bytesTotal = null + state.retryFuture = null val call = client.newCall(request) state.call = call calls[state.requestId] = call + emitActiveRequestsChanged(activity, state.requestId, "running") call.enqueue(callback) } }, delay.toLong(), TimeUnit.MILLISECONDS) return true } + fun activeRequests(): List> { + val requests = retryOperations.values.mapNotNull { operation -> + synchronized(operation) { + if (operation.terminal || operation.cancelled) null else mapOf( + "requestId" to operation.requestId, + "method" to operation.method, + "url" to operation.url, + "type" to "request", + "attempt" to operation.attempt.coerceAtLeast(1), + "state" to operation.state, + "bytesTransferred" to operation.bytesTransferred, + "bytesTotal" to (operation.bytesTotal ?: JSONObject.NULL), + "progress" to progressValue( + operation.bytesTransferred, + operation.bytesTotal, + ), + "nextRetryAt" to (operation.nextRetryAt ?: JSONObject.NULL), + ) + } + }.toMutableList() + + downloads.values.mapNotNullTo(requests) { state -> + synchronized(state) { + if (state.terminal || state.cancelled) null else mapOf( + "requestId" to state.requestId, + "method" to "GET", + "url" to state.url, + "type" to "download", + "attempt" to state.attempt.coerceAtLeast(1), + "state" to state.state, + "bytesTransferred" to state.bytesTransferred, + "bytesTotal" to (state.bytesTotal ?: JSONObject.NULL), + "progress" to progressValue(state.bytesTransferred, state.bytesTotal), + "nextRetryAt" to (state.nextRetryAt ?: JSONObject.NULL), + ) + } + } + + return requests.sortedBy { it["requestId"].toString() } + } + + fun cancelAll(): Int { + val requestIds = (retryOperations.keys + downloads.keys).distinct() + return requestIds.count(::cancel) + } + fun cancel(requestId: String): Boolean { retryOperations[requestId]?.let { operation -> synchronized(operation) { @@ -776,13 +871,14 @@ private object FetchClient { cleanupDownload(call, state, removePartial = true) if (cancelled || state.cancelled) { - emitCancelled(activity, state.requestId) + emitCancelled(activity, state.requestId, state.sharedEvent) } else { emitFailure( activity, state.requestId, failureMessage, failureCode, + state.sharedEvent, ) } } @@ -792,6 +888,10 @@ private object FetchClient { state: DownloadState, removePartial: Boolean, ) { + state.retryFuture?.cancel(false) + state.retryFuture = null + state.call = null + if (call != null) { calls.remove(state.requestId, call) } else { @@ -809,6 +909,7 @@ private object FetchClient { activity: FragmentActivity, requestId: String, body: Map?, + sharedEvent: String?, ): BodyBuildResult { if (body == null) { return BodyBuildResult() @@ -847,6 +948,7 @@ private object FetchClient { activity = activity, requestId = requestId, body = body, + sharedEvent = sharedEvent, ) } else -> BodyBuildResult() @@ -857,6 +959,7 @@ private object FetchClient { activity: FragmentActivity, requestId: String, body: Map, + sharedEvent: String?, ): BodyBuildResult { val builder = MultipartBody.Builder() .setType(MultipartBody.FORM) @@ -882,6 +985,7 @@ private object FetchClient { requestId, "Multipart file is missing its field name.", "invalid_file", + sharedEvent, ) val path = @@ -891,6 +995,7 @@ private object FetchClient { requestId, "Multipart file is missing its path.", "invalid_file", + sharedEvent, ) val filename = @@ -909,6 +1014,7 @@ private object FetchClient { requestId, "Upload file does not exist or is not readable: $path", "file_not_found", + sharedEvent, ) } @@ -944,12 +1050,14 @@ private object FetchClient { requestId: String, message: String, code: String, + sharedEvent: String?, ): BodyBuildResult { emitFailure( activity = activity, requestId = requestId, message = message, code = code, + sharedEvent = sharedEvent, ) return BodyBuildResult( @@ -974,6 +1082,8 @@ private object FetchClient { eventClass = FetchEvents.STARTED, payload = payload, ) + announcedActiveRequests.add(requestId) + emitActiveRequestsChanged(activity, requestId, "started") } private fun emitFailure( @@ -981,6 +1091,7 @@ private object FetchClient { requestId: String, message: String, code: String?, + sharedEvent: String? = null, ) { val payload = JSONObject().apply { put("requestId", requestId) @@ -995,11 +1106,15 @@ private object FetchClient { eventClass = FetchEvents.FAILED, payload = payload, ) + + emitSharedTerminal(activity, sharedEvent, "failed", payload) + emitActiveRequestsChanged(activity, requestId, "finished") } private fun emitCancelled( activity: FragmentActivity, requestId: String, + sharedEvent: String? = null, ) { val payload = JSONObject().apply { put("requestId", requestId) @@ -1010,6 +1125,9 @@ private object FetchClient { eventClass = FetchEvents.CANCELLED, payload = payload, ) + + emitSharedTerminal(activity, sharedEvent, "cancelled", payload) + emitActiveRequestsChanged(activity, requestId, "finished") } private fun emitUploadProgress( @@ -1031,6 +1149,13 @@ private object FetchClient { 0.0 } + retryOperations[requestId]?.let { operation -> + synchronized(operation) { + operation.bytesTransferred = safeSent + operation.bytesTotal = safeTotal.takeIf { it > 0L } + } + } + val payload = JSONObject().apply { put("requestId", requestId) put("bytesSent", safeSent) @@ -1043,6 +1168,7 @@ private object FetchClient { eventClass = FetchEvents.UPLOAD_PROGRESS, payload = payload, ) + emitActiveRequestsChanged(activity, requestId, "progress", throttle = true) } private fun emitDownloadProgress( @@ -1062,6 +1188,13 @@ private object FetchClient { else -> null } + downloads[requestId]?.let { state -> + synchronized(state) { + state.bytesTransferred = safeReceived + state.bytesTotal = bytesTotal + } + } + val payload = JSONObject().apply { put("requestId", requestId) put("bytesReceived", safeReceived) @@ -1070,6 +1203,7 @@ private object FetchClient { } NativeEventDispatcher.dispatch(activity, FetchEvents.DOWNLOAD_PROGRESS, payload) + emitActiveRequestsChanged(activity, requestId, "progress", throttle = true) } private fun emitDownloadCompleted( @@ -1079,6 +1213,7 @@ private object FetchClient { response: Response, path: String, bytesReceived: Long, + sharedEvent: String?, ) { val responseHeaders = JSONObject() response.headers.names().forEach { name -> @@ -1099,6 +1234,55 @@ private object FetchClient { } NativeEventDispatcher.dispatch(activity, FetchEvents.DOWNLOAD_COMPLETED, payload) + emitSharedCompleted( + activity = activity, + sharedEvent = sharedEvent, + requestId = requestId, + httpStatus = status, + headers = responseHeaders, + path = path, + bytesReceived = bytesReceived, + ) + emitActiveRequestsChanged(activity, requestId, "finished") + } + + private fun emitSharedCompleted( + activity: FragmentActivity, + sharedEvent: String?, + requestId: String, + httpStatus: Int, + headers: JSONObject, + body: String? = null, + path: String? = null, + bytesReceived: Long? = null, + ) { + val payload = JSONObject().apply { + put("requestId", requestId) + put("status", "completed") + put("httpStatus", httpStatus) + put("headers", headers) + body?.let { put("body", it) } + path?.let { put("path", it) } + bytesReceived?.let { put("bytesReceived", it) } + } + + emitSharedTerminal(activity, sharedEvent, "completed", payload) + } + + private fun emitSharedTerminal( + activity: FragmentActivity, + sharedEvent: String?, + status: String, + source: JSONObject, + ) { + val event = sharedEvent?.takeIf { it.isNotBlank() } ?: return + val payload = JSONObject(source.toString()).apply { + put("status", status) + if (status == "failed" && !has("code")) { + put("code", JSONObject.NULL) + } + } + NativeEventDispatcher.dispatch(activity, event, payload) } private fun scheduleRetry( @@ -1115,7 +1299,10 @@ private object FetchClient { operation.attempt >= policy.maxAttempts ) return false - retryDelay(policy, operation.attempt, retryAfter) + retryDelay(policy, operation.attempt, retryAfter).also { + operation.state = "waiting_retry" + operation.nextRetryAt = System.currentTimeMillis() + it + } } emitRetrying( @@ -1137,6 +1324,13 @@ private object FetchClient { return true } + private fun progressValue(bytesTransferred: Long, bytesTotal: Long?): Any = + if (bytesTotal != null && bytesTotal > 0L) { + (bytesTransferred.toDouble() / bytesTotal.toDouble()).coerceIn(0.0, 1.0) + } else { + JSONObject.NULL + } + private fun isRetryableNetwork(code: String): Boolean = code in setOf( "timeout", "offline", @@ -1154,6 +1348,11 @@ private object FetchClient { } } + private fun isRetryCancelled(operation: RetryOperation): Boolean = + synchronized(operation) { + operation.cancelled + } + private fun finishRetryFailure( activity: FragmentActivity, operation: RetryOperation, @@ -1161,9 +1360,12 @@ private object FetchClient { code: String, ) { if (!markRetryTerminal(operation)) return + operation.retryFuture?.cancel(false) + operation.retryFuture = null + operation.call = null calls.remove(operation.requestId) retryOperations.remove(operation.requestId, operation) - emitFailure(activity, operation.requestId, message, code) + emitFailure(activity, operation.requestId, message, code, operation.sharedEvent) } private fun finishRetryCancelled( @@ -1175,10 +1377,12 @@ private object FetchClient { operation.cancelled = true operation.terminal = true operation.retryFuture?.cancel(false) + operation.retryFuture = null + operation.call = null } calls.remove(operation.requestId) retryOperations.remove(operation.requestId, operation) - emitCancelled(activity, operation.requestId) + emitCancelled(activity, operation.requestId, operation.sharedEvent) } private fun emitRetrying( @@ -1199,6 +1403,45 @@ private object FetchClient { put("status", status ?: JSONObject.NULL) } NativeEventDispatcher.dispatch(activity, FetchEvents.RETRYING, payload) + emitActiveRequestsChanged(activity, requestId, "waiting_retry") + } + + private fun emitActiveRequestsChanged( + activity: FragmentActivity, + requestId: String, + change: String, + throttle: Boolean = false, + ) { + if (change == "finished") { + if (!announcedActiveRequests.remove(requestId)) return + activeProgressTimes.remove(requestId) + } else if (!announcedActiveRequests.contains(requestId) && change != "started") { + return + } + + if (throttle) { + val now = System.currentTimeMillis() + var shouldEmit = false + activeProgressTimes.compute(requestId) { _, previous -> + if (previous == null || now - previous >= 250L) { + shouldEmit = true + now + } else { + previous + } + } + if (!shouldEmit) return + } + + val payload = JSONObject().apply { + put("requestId", requestId) + put("change", change) + } + NativeEventDispatcher.dispatch( + activity, + FetchEvents.ACTIVE_REQUESTS_CHANGED, + payload, + ) } private fun failureCode( @@ -1279,6 +1522,8 @@ object FetchFunctions { val body = bridgeMap(parameters["body"]) val retryPolicy = bridgeRetryPolicy(parameters["retry"]) + val sharedEvent = (parameters["shared_event"] as? String) + ?.takeIf { it.isNotBlank() } return try { FetchClient.start( @@ -1291,6 +1536,7 @@ object FetchFunctions { body = body, timeoutSeconds = timeout, retryPolicy = retryPolicy, + sharedEvent = sharedEvent, ) BridgeResponse.success( @@ -1335,6 +1581,28 @@ object FetchFunctions { } } + class Active( + private val context: Context, + ) : BridgeFunction { + + override fun execute( + parameters: Map, + ): Map = BridgeResponse.success( + mapOf("requests" to FetchClient.activeRequests()) + ) + } + + class CancelAll( + private val context: Context, + ) : BridgeFunction { + + override fun execute( + parameters: Map, + ): Map = BridgeResponse.success( + mapOf("cancelled" to FetchClient.cancelAll()) + ) + } + class Download( private val activity: FragmentActivity, ) : BridgeFunction { @@ -1363,6 +1631,8 @@ object FetchFunctions { val headers = rawHeaders.mapValues { (_, value) -> value.toString() } val query = bridgeMap(parameters["query"]) ?: emptyMap() val retryPolicy = bridgeRetryPolicy(parameters["retry"]) + val sharedEvent = (parameters["shared_event"] as? String) + ?.takeIf { it.isNotBlank() } return try { FetchClient.download( @@ -1375,6 +1645,7 @@ object FetchFunctions { timeout, overwrite, retryPolicy, + sharedEvent, ) BridgeResponse.success( diff --git a/resources/android/src/com/victorycodedev/plugins/nativephp_fetch/download/DownloadState.kt b/resources/android/src/com/victorycodedev/plugins/nativephp_fetch/download/DownloadState.kt index 6e389cb..b853a33 100644 --- a/resources/android/src/com/victorycodedev/plugins/nativephp_fetch/download/DownloadState.kt +++ b/resources/android/src/com/victorycodedev/plugins/nativephp_fetch/download/DownloadState.kt @@ -17,9 +17,15 @@ internal data class DownloadState( val partial: File, val overwrite: Boolean, val policy: RetryPolicy?, + val sharedEvent: String?, + val url: String, @Volatile var cancelled: Boolean = false, @Volatile var terminal: Boolean = false, @Volatile var attempt: Int = 0, @Volatile var call: Call? = null, @Volatile var retryFuture: ScheduledFuture<*>? = null, + @Volatile var state: String = "running", + @Volatile var bytesTransferred: Long = 0L, + @Volatile var bytesTotal: Long? = null, + @Volatile var nextRetryAt: Long? = null, ) diff --git a/resources/android/src/com/victorycodedev/plugins/nativephp_fetch/events/FetchEvents.kt b/resources/android/src/com/victorycodedev/plugins/nativephp_fetch/events/FetchEvents.kt index e6bd6d6..45324b1 100644 --- a/resources/android/src/com/victorycodedev/plugins/nativephp_fetch/events/FetchEvents.kt +++ b/resources/android/src/com/victorycodedev/plugins/nativephp_fetch/events/FetchEvents.kt @@ -9,4 +9,5 @@ internal object FetchEvents { const val DOWNLOAD_PROGRESS = "Victorycodedev\\NativephpFetch\\Events\\FetchDownloadProgress" const val DOWNLOAD_COMPLETED = "Victorycodedev\\NativephpFetch\\Events\\FetchDownloadCompleted" const val RETRYING = "Victorycodedev\\NativephpFetch\\Events\\FetchRequestRetrying" + const val ACTIVE_REQUESTS_CHANGED = "Victorycodedev\\NativephpFetch\\Events\\FetchActiveRequestsChanged" } diff --git a/resources/android/src/com/victorycodedev/plugins/nativephp_fetch/retry/RetryState.kt b/resources/android/src/com/victorycodedev/plugins/nativephp_fetch/retry/RetryState.kt index eed234e..79db882 100644 --- a/resources/android/src/com/victorycodedev/plugins/nativephp_fetch/retry/RetryState.kt +++ b/resources/android/src/com/victorycodedev/plugins/nativephp_fetch/retry/RetryState.kt @@ -4,11 +4,21 @@ import androidx.fragment.app.FragmentActivity import okhttp3.Call import java.util.concurrent.ScheduledFuture -internal class RetryOperation(val requestId: String, val policy: RetryPolicy?) { +internal class RetryOperation( + val requestId: String, + val policy: RetryPolicy?, + val sharedEvent: String?, + val method: String, + val url: String, +) { lateinit var activity: FragmentActivity var attempt = 0 var cancelled = false var terminal = false var call: Call? = null var retryFuture: ScheduledFuture<*>? = null + var state = "running" + var bytesTransferred = 0L + var bytesTotal: Long? = null + var nextRetryAt: Long? = null } diff --git a/resources/boost/guidelines/core.blade.php b/resources/boost/guidelines/core.blade.php index 013ea3f..1a01532 100644 --- a/resources/boost/guidelines/core.blade.php +++ b/resources/boost/guidelines/core.blade.php @@ -126,6 +126,12 @@ `#[On(...)]` in v4 NativeComponents. A timeout is failure code `timeout`; only explicit cancellation emits the cancelled event. +For a reactive transfer screen, load `Fetch::active()` on mount and listen for +`FetchActiveRequestsChanged(requestId, change)`. Its changes are `started`, +`progress`, `waiting_retry`, `running`, and `finished`. Progress invalidations +are throttled natively to 250 milliseconds; refresh `Fetch::active()` in the +listener instead of maintaining a second request-state model. + For ordinary PHP tests call `Fetch::fake(['url/*' => FetchResponse::make(...)])`, then use `assertSent`, `assertNotSent`, `assertSentCount`, and `restore`. Assertion callbacks receive `RecordedRequest` diff --git a/resources/ios/Sources/Download/DownloadState.swift b/resources/ios/Sources/Download/DownloadState.swift index edf1c31..f558822 100644 --- a/resources/ios/Sources/Download/DownloadState.swift +++ b/resources/ios/Sources/Download/DownloadState.swift @@ -8,14 +8,19 @@ final class DownloadState { let partial: URL let overwrite: Bool let policy: RetryPolicy? + let sharedEvent: String? var cancelled = false var terminal = false var lastProgressAt: TimeInterval = 0 var attempt = 0 weak var task: URLSessionTask? var retryWorkItem: DispatchWorkItem? + var state = "running" + var bytesTransferred: Int64 = 0 + var bytesTotal: Int64? + var nextRetryAt: Int64? - init(requestId: String, request: URLRequest, destination: URL, destinationKey: String, partial: URL, overwrite: Bool, policy: RetryPolicy?) { + init(requestId: String, request: URLRequest, destination: URL, destinationKey: String, partial: URL, overwrite: Bool, policy: RetryPolicy?, sharedEvent: String?) { self.requestId = requestId self.request = request self.destination = destination @@ -23,5 +28,6 @@ final class DownloadState { self.partial = partial self.overwrite = overwrite self.policy = policy + self.sharedEvent = sharedEvent } } diff --git a/resources/ios/Sources/Events/FetchEvents.swift b/resources/ios/Sources/Events/FetchEvents.swift index 52f4a23..4fb64a4 100644 --- a/resources/ios/Sources/Events/FetchEvents.swift +++ b/resources/ios/Sources/Events/FetchEvents.swift @@ -9,4 +9,5 @@ enum FetchEvents { static let downloadProgress = "Victorycodedev\\NativephpFetch\\Events\\FetchDownloadProgress" static let downloadCompleted = "Victorycodedev\\NativephpFetch\\Events\\FetchDownloadCompleted" static let retrying = "Victorycodedev\\NativephpFetch\\Events\\FetchRequestRetrying" + static let activeRequestsChanged = "Victorycodedev\\NativephpFetch\\Events\\FetchActiveRequestsChanged" } diff --git a/resources/ios/Sources/FetchFunctions.swift b/resources/ios/Sources/FetchFunctions.swift index 0a2164e..9edd6a8 100644 --- a/resources/ios/Sources/FetchFunctions.swift +++ b/resources/ios/Sources/FetchFunctions.swift @@ -12,8 +12,6 @@ private final class FetchClient: NSObject, private var requestIdsByTaskIdentifier: [Int: String] = [:] - private var temporaryUploadFiles: [String: URL] = [:] - private var uploadProgressTimes: [String: TimeInterval] = [:] private var downloads: [String: DownloadState] = [:] @@ -22,6 +20,10 @@ private final class FetchClient: NSObject, private var retryOperations: [String: StandardRetryState] = [:] + private var announcedActiveRequests: Set = [] + + private var activeProgressTimes: [String: TimeInterval] = [:] + private lazy var session: URLSession = { URLSession( configuration: .default, @@ -42,7 +44,8 @@ private final class FetchClient: NSObject, query: [String: Any], body: [String: Any]?, timeout: TimeInterval, - retryPolicy: RetryPolicy? + retryPolicy: RetryPolicy?, + sharedEvent: String? ) throws { guard var components = URLComponents(string: urlString) else { @@ -96,6 +99,13 @@ private final class FetchClient: NSObject, code: "unsupported_method" ) + emitSharedFailure( + sharedEvent: sharedEvent, + requestId: requestId, + message: "HTTP method \(method) is not supported.", + code: "unsupported_method" + ) + return } @@ -131,9 +141,10 @@ private final class FetchClient: NSObject, requestId: requestId, request: request, uploadFile: multipart.fileURL, - policy: retryPolicy + policy: retryPolicy, + sharedEvent: sharedEvent ) - retryOperations[requestId] = state + storeStandardState(state) emitStarted( requestId: requestId, @@ -157,9 +168,10 @@ private final class FetchClient: NSObject, requestId: requestId, request: request, uploadFile: nil, - policy: retryPolicy + policy: retryPolicy, + sharedEvent: sharedEvent ) - retryOperations[requestId] = state + storeStandardState(state) emitStarted( requestId: requestId, @@ -176,9 +188,22 @@ private final class FetchClient: NSObject, lock.unlock() return } + let resumed = state.attempt > 0 state.attempt += 1 + state.state = "running" + state.nextRetryAt = nil + state.bytesTransferred = 0 + state.bytesTotal = nil + state.retryWorkItem = nil lock.unlock() + if resumed { + emitActiveRequestsChanged( + requestId: state.requestId, + change: "running" + ) + } + let completion: (Data?, URLResponse?, Error?) -> Void = { [weak self, weak state] data, response, error in guard let self, let state else { return } @@ -217,6 +242,12 @@ private final class FetchClient: NSObject, task.resume() } + private func storeStandardState(_ state: StandardRetryState) { + lock.lock() + retryOperations[state.requestId] = state + lock.unlock() + } + func download( requestId: String, urlString: String, @@ -225,7 +256,8 @@ private final class FetchClient: NSObject, query: [String: Any], timeout: TimeInterval, overwrite: Bool, - retryPolicy: RetryPolicy? + retryPolicy: RetryPolicy?, + sharedEvent: String? ) throws { guard !destinationPath.trimmingCharacters( in: .whitespacesAndNewlines @@ -360,7 +392,8 @@ private final class FetchClient: NSObject, destinationKey: destinationKey, partial: partial, overwrite: overwrite, - policy: retryPolicy + policy: retryPolicy, + sharedEvent: sharedEvent ) state.task = task state.attempt = 1 @@ -396,6 +429,76 @@ private final class FetchClient: NSObject, } } + func activeRequests() -> [[String: Any]] { + lock.lock() + defer { lock.unlock() } + + let standard = retryOperations.values.compactMap { state -> [String: Any]? in + guard !state.terminal, !state.cancelled else { return nil } + + return [ + "requestId": state.requestId, + "method": state.request.httpMethod ?? "GET", + "url": state.request.url?.absoluteString ?? "", + "type": "request", + "attempt": max(state.attempt, 1), + "state": state.state, + "bytesTransferred": state.bytesTransferred, + "bytesTotal": state.bytesTotal.map { $0 as Any } ?? NSNull(), + "progress": snapshotProgress( + transferred: state.bytesTransferred, + total: state.bytesTotal + ), + "nextRetryAt": state.nextRetryAt.map { $0 as Any } ?? NSNull(), + ] + } + + let download = downloads.values.compactMap { state -> [String: Any]? in + guard !state.terminal, !state.cancelled else { return nil } + + return [ + "requestId": state.requestId, + "method": "GET", + "url": state.request.url?.absoluteString ?? "", + "type": "download", + "attempt": max(state.attempt, 1), + "state": state.state, + "bytesTransferred": state.bytesTransferred, + "bytesTotal": state.bytesTotal.map { $0 as Any } ?? NSNull(), + "progress": snapshotProgress( + transferred: state.bytesTransferred, + total: state.bytesTotal + ), + "nextRetryAt": state.nextRetryAt.map { $0 as Any } ?? NSNull(), + ] + } + + return (standard + download).sorted { + ($0["requestId"] as? String ?? "") < ($1["requestId"] as? String ?? "") + } + } + + private func snapshotProgress( + transferred: Int64, + total: Int64? + ) -> Any { + guard let total, total > 0 else { return NSNull() } + + return min(max(Double(transferred) / Double(total), 0), 1) + } + + func cancelAll() -> Int { + lock.lock() + let requestIds = Set(retryOperations.keys).union(downloads.keys) + lock.unlock() + + return requestIds.reduce(into: 0) { count, requestId in + if cancel(requestId: requestId) { + count += 1 + } + } + } + func cancel( requestId: String ) -> Bool { @@ -476,6 +579,13 @@ private final class FetchClient: NSObject, uploadProgressTimes[requestId] = now } + if let requestId, let state = retryOperations[requestId] { + state.bytesTransferred = max(totalBytesSent, 0) + state.bytesTotal = totalBytesExpectedToSend > 0 + ? totalBytesExpectedToSend + : nil + } + lock.unlock() guard let requestId, shouldEmit else { @@ -532,6 +642,9 @@ private final class FetchClient: NSObject, } ?? false let shouldEmit = finished || now - state.lastProgressAt >= 0.1 + state.bytesTransferred = max(totalBytesWritten, 0) + state.bytesTotal = total + if shouldEmit { state.lastProgressAt = now } @@ -647,6 +760,14 @@ private final class FetchClient: NSObject, path: state.destination.path, bytesReceived: bytesReceived ) + emitSharedDownloadCompleted( + sharedEvent: state.sharedEvent, + requestId: requestId, + httpStatus: response.statusCode, + headers: responseHeaders(response), + path: state.destination.path, + bytesReceived: bytesReceived + ) } catch let error as FetchNativeError { lock.unlock() @@ -680,10 +801,10 @@ private final class FetchClient: NSObject, ) { lock.lock() let requestId = requestIdsByTaskIdentifier[task.taskIdentifier] - let isDownload = requestId.flatMap { downloads[$0] } != nil + let downloadState = requestId.flatMap { downloads[$0] } lock.unlock() - guard isDownload, let requestId else { + guard let requestId, let downloadState else { return } @@ -696,7 +817,7 @@ private final class FetchClient: NSObject, } else { let reason = failureCode(urlError) if isRetryableNetwork(reason), scheduleDownloadRetry( - state: downloads[requestId], + state: downloadState, reason: reason ) { return @@ -709,7 +830,7 @@ private final class FetchClient: NSObject, } } else if let error { if scheduleDownloadRetry( - state: downloads[requestId], + state: downloadState, reason: "network_error" ) { return @@ -734,7 +855,11 @@ private final class FetchClient: NSObject, response: URLResponse?, error: Error? ) { - if state.cancelled { + lock.lock() + let cancelled = state.cancelled + lock.unlock() + + if cancelled { finishStandardCancelled(state) return } @@ -818,6 +943,13 @@ private final class FetchClient: NSObject, headers: responseHeaders(httpResponse), body: body ) + emitSharedCompleted( + sharedEvent: state.sharedEvent, + requestId: state.requestId, + httpStatus: httpResponse.statusCode, + headers: responseHeaders(httpResponse), + body: body + ) } private func scheduleStandardRetry( @@ -841,6 +973,9 @@ private final class FetchClient: NSObject, retryAfter: retryAfter ) let nextAttempt = state.attempt + 1 + state.state = "waiting_retry" + state.nextRetryAt = Int64(Date().timeIntervalSince1970 * 1000) + + Int64(delay) let item = DispatchWorkItem { [weak self, weak state] in guard let self, let state else { return } self.startStandardAttempt(state) @@ -881,6 +1016,12 @@ private final class FetchClient: NSObject, removeStandardStateLocked(state) lock.unlock() emitFailure(requestId: state.requestId, message: message, code: code) + emitSharedFailure( + sharedEvent: state.sharedEvent, + requestId: state.requestId, + message: message, + code: code + ) } private func finishStandardCancelled(_ state: StandardRetryState) { @@ -895,9 +1036,16 @@ private final class FetchClient: NSObject, removeStandardStateLocked(state) lock.unlock() emitCancelled(requestId: state.requestId) + emitSharedCancelled( + sharedEvent: state.sharedEvent, + requestId: state.requestId + ) } private func removeStandardStateLocked(_ state: StandardRetryState) { + state.retryWorkItem?.cancel() + state.retryWorkItem = nil + if let task = state.task { requestIdsByTaskIdentifier.removeValue(forKey: task.taskIdentifier) } @@ -909,84 +1057,6 @@ private final class FetchClient: NSObject, } } - private func handleCompletion( - requestId: String, - data: Data?, - response: URLResponse?, - error: Error? - ) { - cleanupTask( - requestId: requestId - ) - - if let urlError = error as? URLError { - - if urlError.code == .cancelled { - emitCancelled( - requestId: requestId - ) - - return - } - - emitFailure( - requestId: requestId, - message: urlError.localizedDescription, - code: failureCode(urlError) - ) - - return - } - - if let error { - emitFailure( - requestId: requestId, - message: error.localizedDescription, - code: "network_error" - ) - - return - } - - guard let httpResponse = - response as? HTTPURLResponse - else { - emitFailure( - requestId: requestId, - message: "The server returned an invalid HTTP response.", - code: "invalid_response" - ) - - return - } - - let responseBody: String - - if let data { - responseBody = String( - data: data, - encoding: .utf8 - ) ?? "" - } else { - responseBody = "" - } - - var responseHeaders: [String: String] = [:] - - for (key, value) in httpResponse.allHeaderFields { - responseHeaders[ - String(describing: key) - ] = String(describing: value) - } - - emitCompleted( - requestId: requestId, - status: httpResponse.statusCode, - headers: responseHeaders, - body: responseBody - ) - } - private func applyBody( _ body: [String: Any], to request: inout URLRequest @@ -1212,65 +1282,6 @@ private final class FetchClient: NSObject, ) } - private func storeTask( - _ task: URLSessionTask, - requestId: String, - temporaryUploadFile: URL? = nil - ) { - lock.lock() - - tasks[requestId] = task - - requestIdsByTaskIdentifier[ - task.taskIdentifier - ] = requestId - - if let temporaryUploadFile { - temporaryUploadFiles[ - requestId - ] = temporaryUploadFile - } - - lock.unlock() - } - - private func cleanupTask( - requestId: String - ) { - lock.lock() - - let task = - tasks.removeValue( - forKey: requestId - ) - - if let task { - requestIdsByTaskIdentifier - .removeValue( - forKey: task.taskIdentifier - ) - } - - let temporaryURL = - temporaryUploadFiles - .removeValue( - forKey: requestId - ) - - uploadProgressTimes.removeValue( - forKey: requestId - ) - - lock.unlock() - - if let temporaryURL { - try? FileManager.default - .removeItem( - at: temporaryURL - ) - } - } - private func promoteDownload( location: URL, state: DownloadState @@ -1356,12 +1367,22 @@ private final class FetchClient: NSObject, if cancelled || state.cancelled { emitCancelled(requestId: requestId) + emitSharedCancelled( + sharedEvent: state.sharedEvent, + requestId: requestId + ) } else { emitFailure( requestId: requestId, message: message, code: code ) + emitSharedFailure( + sharedEvent: state.sharedEvent, + requestId: requestId, + message: message, + code: code + ) } } @@ -1389,6 +1410,9 @@ private final class FetchClient: NSObject, ) let nextAttempt = state.attempt + 1 state.attempt = nextAttempt + state.state = "waiting_retry" + state.nextRetryAt = Int64(Date().timeIntervalSince1970 * 1000) + + Int64(delay) try? FileManager.default.removeItem(at: state.partial) let item = DispatchWorkItem { [weak self, weak state] in @@ -1399,11 +1423,20 @@ private final class FetchClient: NSObject, return } try? FileManager.default.removeItem(at: state.partial) + state.state = "running" + state.nextRetryAt = nil + state.bytesTransferred = 0 + state.bytesTotal = nil + state.retryWorkItem = nil let task = self.session.downloadTask(with: state.request) state.task = task self.tasks[state.requestId] = task self.requestIdsByTaskIdentifier[task.taskIdentifier] = state.requestId self.lock.unlock() + self.emitActiveRequestsChanged( + requestId: state.requestId, + change: "running" + ) task.resume() } state.retryWorkItem = item @@ -1433,6 +1466,8 @@ private final class FetchClient: NSObject, taskIdentifier: Int?, destinationKey: String ) { + downloads[requestId]?.retryWorkItem?.cancel() + downloads[requestId]?.retryWorkItem = nil tasks.removeValue(forKey: requestId) downloads.removeValue(forKey: requestId) activeDownloadDestinations.removeValue(forKey: destinationKey) @@ -1482,6 +1517,10 @@ private final class FetchClient: NSObject, "url": url, ] ) + lock.lock() + announcedActiveRequests.insert(requestId) + lock.unlock() + emitActiveRequestsChanged(requestId: requestId, change: "started") } private func emitCompleted( @@ -1499,6 +1538,7 @@ private final class FetchClient: NSObject, "body": body, ] ) + emitActiveRequestsChanged(requestId: requestId, change: "finished") } private func emitFailure( @@ -1519,6 +1559,7 @@ private final class FetchClient: NSObject, name: FetchEvents.failed, payload: payload ) + emitActiveRequestsChanged(requestId: requestId, change: "finished") } private func emitCancelled( @@ -1530,6 +1571,7 @@ private final class FetchClient: NSObject, "requestId": requestId ] ) + emitActiveRequestsChanged(requestId: requestId, change: "finished") } private func emitUploadProgress( @@ -1547,6 +1589,11 @@ private final class FetchClient: NSObject, "progress": progress, ] ) + emitActiveRequestsChanged( + requestId: requestId, + change: "progress", + throttle: true + ) } private func emitDownloadProgress( @@ -1582,6 +1629,11 @@ private final class FetchClient: NSObject, "progress": progressValue, ] ) + emitActiveRequestsChanged( + requestId: requestId, + change: "progress", + throttle: true + ) } private func emitDownloadCompleted( @@ -1601,6 +1653,87 @@ private final class FetchClient: NSObject, "bytesReceived": bytesReceived, ] ) + emitActiveRequestsChanged(requestId: requestId, change: "finished") + } + + private func emitSharedCompleted( + sharedEvent: String?, + requestId: String, + httpStatus: Int, + headers: [String: String], + body: String + ) { + emitShared( + name: sharedEvent, + payload: [ + "requestId": requestId, + "status": "completed", + "httpStatus": httpStatus, + "headers": headers, + "body": body, + ] + ) + } + + private func emitSharedDownloadCompleted( + sharedEvent: String?, + requestId: String, + httpStatus: Int, + headers: [String: String], + path: String, + bytesReceived: Int64 + ) { + emitShared( + name: sharedEvent, + payload: [ + "requestId": requestId, + "status": "completed", + "httpStatus": httpStatus, + "headers": headers, + "path": path, + "bytesReceived": bytesReceived, + ] + ) + } + + private func emitSharedFailure( + sharedEvent: String?, + requestId: String, + message: String, + code: String? + ) { + emitShared( + name: sharedEvent, + payload: [ + "requestId": requestId, + "status": "failed", + "message": message, + "code": code ?? NSNull(), + ] + ) + } + + private func emitSharedCancelled( + sharedEvent: String?, + requestId: String + ) { + emitShared( + name: sharedEvent, + payload: [ + "requestId": requestId, + "status": "cancelled", + ] + ) + } + + private func emitShared(name: String?, payload: [String: Any]) { + guard let name, !name.trimmingCharacters( + in: .whitespacesAndNewlines + ).isEmpty else { + return + } + + dispatchEvent(name: name, payload: payload) } private func emitRetrying( @@ -1622,6 +1755,49 @@ private final class FetchClient: NSObject, "status": status.map { $0 } ?? NSNull(), ] ) + emitActiveRequestsChanged( + requestId: requestId, + change: "waiting_retry" + ) + } + + private func emitActiveRequestsChanged( + requestId: String, + change: String, + throttle: Bool = false + ) { + lock.lock() + + if change == "finished" { + guard announcedActiveRequests.remove(requestId) != nil else { + lock.unlock() + return + } + activeProgressTimes.removeValue(forKey: requestId) + } else if change != "started", + !announcedActiveRequests.contains(requestId) { + lock.unlock() + return + } + + if throttle { + let now = Date().timeIntervalSince1970 + if let previous = activeProgressTimes[requestId], + now - previous < 0.25 { + lock.unlock() + return + } + activeProgressTimes[requestId] = now + } + + lock.unlock() + dispatchEvent( + name: FetchEvents.activeRequestsChanged, + payload: [ + "requestId": requestId, + "change": change, + ] + ) } private func retryDelay( @@ -1777,6 +1953,8 @@ enum FetchFunctions { parameters["body"] as? [String: Any] let retry = retryPolicy(from: parameters["retry"]) + let sharedEvent = (parameters["shared_event"] as? String) + .flatMap { $0.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty ? nil : $0 } do { try FetchClient.shared.start( @@ -1787,7 +1965,8 @@ enum FetchFunctions { query: query, body: body, timeout: timeout, - retryPolicy: retry + retryPolicy: retry, + sharedEvent: sharedEvent ) return BridgeResponse.success( @@ -1873,6 +2052,32 @@ enum FetchFunctions { } } + class Active: BridgeFunction { + + func execute( + parameters: [String: Any] + ) throws -> [String: Any] { + BridgeResponse.success( + data: [ + "requests": FetchClient.shared.activeRequests() + ] + ) + } + } + + class CancelAll: BridgeFunction { + + func execute( + parameters: [String: Any] + ) throws -> [String: Any] { + BridgeResponse.success( + data: [ + "cancelled": FetchClient.shared.cancelAll() + ] + ) + } + } + class Download: BridgeFunction { func execute( @@ -1904,6 +2109,8 @@ enum FetchFunctions { let overwrite = parameters["overwrite"] as? Bool ?? false let retry = retryPolicy(from: parameters["retry"]) + let sharedEvent = (parameters["shared_event"] as? String) + .flatMap { $0.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty ? nil : $0 } let query = parameters["query"] as? [String: Any] ?? [:] var headers: [String: String] = [:] @@ -1923,7 +2130,8 @@ enum FetchFunctions { query: query, timeout: timeout, overwrite: overwrite, - retryPolicy: retry + retryPolicy: retry, + sharedEvent: sharedEvent ) return BridgeResponse.success( diff --git a/resources/ios/Sources/Retry/RetryState.swift b/resources/ios/Sources/Retry/RetryState.swift index 1907aa1..b99a7c0 100644 --- a/resources/ios/Sources/Retry/RetryState.swift +++ b/resources/ios/Sources/Retry/RetryState.swift @@ -5,16 +5,22 @@ final class StandardRetryState { let request: URLRequest let uploadFile: URL? let policy: RetryPolicy? + let sharedEvent: String? var attempt = 0 var cancelled = false var terminal = false weak var task: URLSessionTask? var retryWorkItem: DispatchWorkItem? + var state = "running" + var bytesTransferred: Int64 = 0 + var bytesTotal: Int64? + var nextRetryAt: Int64? - init(requestId: String, request: URLRequest, uploadFile: URL?, policy: RetryPolicy?) { + init(requestId: String, request: URLRequest, uploadFile: URL?, policy: RetryPolicy?, sharedEvent: String?) { self.requestId = requestId self.request = request self.uploadFile = uploadFile self.policy = policy + self.sharedEvent = sharedEvent } } diff --git a/resources/js/PendingRequest.js b/resources/js/PendingRequest.js index d17daee..a5e434d 100644 --- a/resources/js/PendingRequest.js +++ b/resources/js/PendingRequest.js @@ -11,6 +11,7 @@ export class PendingRequest { this.bodyMode = "json"; this.rawBody = null; this.retryPolicy = null; + this.sharedEvent = null; } id() { @@ -72,6 +73,12 @@ export class PendingRequest { this.retryPolicy = normalizeRetry(options); return this; } + shared(event) { + if (typeof event !== "string" || event.trim() === "") + throw new TypeError("Fetch shared event name cannot be empty."); + this.sharedEvent = event.trim(); + return this; + } attach(name, path, filename = null, mimeType = null) { if (this.bodyMode === "form" || this.bodyMode === "raw") throw new TypeError( @@ -142,6 +149,7 @@ export class PendingRequest { timeout: this.timeoutSeconds, overwrite: Boolean(overwrite), retry: this.retryPolicy ? { ...this.retryPolicy } : null, + shared_event: this.sharedEvent, }).then(() => this.requestId); } cancel() { @@ -157,6 +165,7 @@ export class PendingRequest { timeout: this.timeoutSeconds, body: this.bodyPayload(method, data), retry: this.retryPolicy ? { ...this.retryPolicy } : null, + shared_event: this.sharedEvent, }).then(() => this.requestId); } bodyPayload(method, data) { diff --git a/resources/js/bridge.js b/resources/js/bridge.js index 4231681..e584f5e 100644 --- a/resources/js/bridge.js +++ b/resources/js/bridge.js @@ -43,3 +43,14 @@ export async function cancel(id) { const response = await bridgeCall("Fetch.Cancel", { request_id: id }); return Boolean(response?.cancelled); } +export async function active() { + const response = await bridgeCall("Fetch.Active"); + return Array.isArray(response?.requests) ? response.requests : []; +} +export async function isActive(id) { + return (await active()).some((request) => request?.requestId === id); +} +export async function cancelAll() { + const response = await bridgeCall("Fetch.CancelAll"); + return Math.max(0, Number(response?.cancelled) || 0); +} diff --git a/resources/js/fetch.js b/resources/js/fetch.js index 9fcfe6b..06d4a56 100644 --- a/resources/js/fetch.js +++ b/resources/js/fetch.js @@ -1,8 +1,25 @@ /** Official JavaScript client for victorycodedev/nativephp-fetch. */ import { PendingRequest } from "./PendingRequest.js"; -import { bridgeCall, cancel, downloadNative, start } from "./bridge.js"; +import { + active, + bridgeCall, + cancel, + cancelAll, + downloadNative, + isActive, + start, +} from "./bridge.js"; -export { PendingRequest, bridgeCall, cancel, downloadNative, start }; +export { + PendingRequest, + active, + bridgeCall, + cancel, + cancelAll, + downloadNative, + isActive, + start, +}; export const request = () => new PendingRequest(); export const withHeaders = (headers) => request().withHeaders(headers); export const withHeader = (name, value) => request().withHeader(name, value); @@ -15,6 +32,7 @@ export const withBody = (body, contentType = "text/plain") => request().withBody(body, contentType); export const timeout = (seconds) => request().timeout(seconds); export const retry = (options = 3) => request().retry(options); +export const shared = (event) => request().shared(event); export const attach = (name, path, filename = null, mimeType = null) => request().attach(name, path, filename, mimeType); export const attachMany = (attachments) => request().attachMany(attachments); @@ -38,6 +56,7 @@ export const Fetch = { withBody, timeout, retry, + shared, attach, attachMany, get, @@ -47,6 +66,9 @@ export const Fetch = { delete: del, download, cancel, + active, + isActive, + cancelAll, start, downloadNative, }; diff --git a/resources/js/fetch.test.js b/resources/js/fetch.test.js index da9b386..cbb70e0 100644 --- a/resources/js/fetch.test.js +++ b/resources/js/fetch.test.js @@ -85,6 +85,45 @@ test("builds downloads and cancellation with a pre-existing ID", async () => { }); }); +test("lists and cancels active native requests", async () => { + bridgeResponse = { + ok: true, + json: async () => ({ + data: { + requests: [ + { + requestId: "request-1", + method: "POST", + url: "https://example.test", + type: "request", + attempt: 1, + state: "running", + bytesTransferred: 50, + bytesTotal: 100, + progress: 0.5, + nextRetryAt: null, + }, + ], + }, + }), + }; + + const activeRequests = await Fetch.active(); + assert.equal(activeRequests[0].requestId, "request-1"); + assert.equal(activeRequests[0].progress, 0.5); + assert.equal(await Fetch.isActive("request-1"), true); + assert.equal(await Fetch.isActive("missing"), false); + + bridgeResponse = { + ok: true, + json: async () => ({ data: { cancelled: 1 } }), + }; + + assert.equal(await Fetch.cancelAll(), 1); + assert.equal(calls[0].method, "Fetch.Active"); + assert.equal(calls[3].method, "Fetch.CancelAll"); +}); + test("passes default and custom retry policies to native requests", async () => { await Fetch.retry().get("https://example.test"); await Fetch.retry({ @@ -119,6 +158,18 @@ test("passes retry to downloads without leaking into new requests", async () => assert.equal(calls[1].params.retry, null); }); +test("passes shared terminal event aliases to requests and downloads", async () => { + await Fetch.shared(" profile-saved ").post("https://example.test/profile"); + await Fetch.shared("report-ready").download( + "https://example.test/report", + "/app/report.pdf", + ); + + assert.equal(calls[0].params.shared_event, "profile-saved"); + assert.equal(calls[1].params.shared_event, "report-ready"); + assert.throws(() => Fetch.shared(" "), /cannot be empty/); +}); + test("validates retry policies", () => { assert.throws(() => Fetch.retry(-1), /times/); assert.throws(() => Fetch.retry({ delay: -1 }), /delay/); diff --git a/src/ActiveRequest.php b/src/ActiveRequest.php new file mode 100644 index 0000000..2937de1 --- /dev/null +++ b/src/ActiveRequest.php @@ -0,0 +1,35 @@ + active() + * @method static bool isActive(string $requestId) + * @method static int cancelAll() * * @see \Victorycodedev\NativephpFetch\Fetch */ diff --git a/src/Fetch.php b/src/Fetch.php index 590b8dd..ca5ab33 100644 --- a/src/Fetch.php +++ b/src/Fetch.php @@ -47,6 +47,11 @@ public function assertSentCount(int $count): void $this->requireFake()->assertSentCount($count); } + public function assertShared(string $event, ?Closure $callback = null): void + { + $this->requireFake()->assertShared($event, $callback); + } + public function request(): PendingRequest { return new PendingRequest($this->fake); @@ -99,6 +104,11 @@ public function timeout(int $seconds): PendingRequest return $this->request()->timeout($seconds); } + public function shared(string $event): PendingRequest + { + return $this->request()->shared($event); + } + public function retry( int $times = 3, int $delay = 500, @@ -188,6 +198,22 @@ public function cancel(string $requestId): bool return $this->request()->cancel($requestId); } + /** @return list */ + public function active(): array + { + return $this->request()->active(); + } + + public function isActive(string $requestId): bool + { + return $this->request()->isActive($requestId); + } + + public function cancelAll(): int + { + return $this->request()->cancelAll(); + } + protected function requireFake(): FakeFetch { return $this->fake ?? throw new \LogicException('Fetch assertions require Fetch::fake().'); diff --git a/src/PendingRequest.php b/src/PendingRequest.php index 89f46fd..baefebf 100644 --- a/src/PendingRequest.php +++ b/src/PendingRequest.php @@ -25,6 +25,8 @@ class PendingRequest protected ?array $retry = null; + protected ?string $sharedEvent = null; + public function __construct(protected ?FakeFetch $fake = null) { $this->requestId = Uuid::uuid7()->toString(); @@ -139,6 +141,19 @@ public function timeout(int $seconds): static return $this; } + public function shared(string $event): static + { + $event = trim($event); + + if ($event === '') { + throw new FetchException('Fetch shared event name cannot be empty.'); + } + + $this->sharedEvent = $event; + + return $this; + } + public function retry( int $times = 3, int $delay = 500, @@ -374,6 +389,7 @@ public function download( 'timeout' => $this->timeout, 'overwrite' => $overwrite, 'retry' => $this->retry, + 'shared_event' => $this->sharedEvent, ], ); @@ -409,6 +425,41 @@ public function cancel(string $requestId): bool return (bool) ($response['cancelled'] ?? false); } + /** @return list */ + public function active(): array + { + $response = $this->bridgeCall('Fetch.Active', []); + + if ($this->isErrorResponse($response)) { + throw new FetchException( + $response['message'] ?? 'Fetch was unable to inspect active requests.' + ); + } + + return array_values(array_map( + static fn (array $request): ActiveRequest => ActiveRequest::fromArray($request), + array_filter($response['requests'] ?? [], 'is_array'), + )); + } + + public function isActive(string $requestId): bool + { + return collect($this->active())->contains( + fn (ActiveRequest $request): bool => $request->requestId === $requestId + ); + } + + public function cancelAll(): int + { + $response = $this->bridgeCall('Fetch.CancelAll', []); + + if ($this->isErrorResponse($response)) { + return 0; + } + + return max(0, (int) ($response['cancelled'] ?? 0)); + } + protected function send( string $method, string $url, @@ -429,6 +480,7 @@ protected function send( data: $data, ), 'retry' => $this->retry, + 'shared_event' => $this->sharedEvent, ]; $response = $this->bridgeCall( diff --git a/src/Testing/FakeFetch.php b/src/Testing/FakeFetch.php index ab5c4fc..449c362 100644 --- a/src/Testing/FakeFetch.php +++ b/src/Testing/FakeFetch.php @@ -20,6 +20,9 @@ final class FakeFetch /** @var list */ private array $events = []; + /** @var list */ + private array $sharedEvents = []; + public function __construct(array $responses = []) { $this->responses = $responses; @@ -32,6 +35,14 @@ public function handle(string $bridge, array $payload): array return ['status' => 'success', 'cancelled' => false]; } + if ($bridge === 'Fetch.Active') { + return ['status' => 'success', 'requests' => []]; + } + + if ($bridge === 'Fetch.CancelAll') { + return ['status' => 'success', 'cancelled' => 0]; + } + $request = new RecordedRequest($bridge, $payload); $this->requests[] = $request; $this->recordEvent(new FetchRequestStarted($request->requestId(), $request->method(), $request->url())); @@ -45,6 +56,15 @@ public function handle(string $bridge, array $payload): array (string) ($payload['destination'] ?? ''), strlen($response->body()), )); + + $this->recordSharedEvent($payload, [ + 'requestId' => $request->requestId(), + 'status' => 'completed', + 'httpStatus' => $response->status(), + 'headers' => $response->headers(), + 'path' => (string) ($payload['destination'] ?? ''), + 'bytesReceived' => strlen($response->body()), + ]); } else { $this->recordEvent(new FetchRequestCompleted( $response->requestId(), @@ -52,6 +72,14 @@ public function handle(string $bridge, array $payload): array $response->headers(), $response->body(), )); + + $this->recordSharedEvent($payload, [ + 'requestId' => $response->requestId(), + 'status' => 'completed', + 'httpStatus' => $response->status(), + 'headers' => $response->headers(), + 'body' => $response->body(), + ]); } return ['status' => 'success', 'accepted' => true]; @@ -89,6 +117,23 @@ public function events(): array return $this->events; } + public function sharedEvents(): array + { + return $this->sharedEvents; + } + + public function assertShared(string $event, ?Closure $callback = null): void + { + $matched = collect($this->sharedEvents)->contains( + fn (RecordedSharedEvent $recorded) => $recorded->name === $event + && ($callback === null || $callback($recorded->payload) === true) + ); + + if (! $matched) { + throw new \RuntimeException("The expected shared Fetch event [{$event}] was not recorded."); + } + } + private function responseFor(RecordedRequest $request): FetchResponse { foreach ($this->responses as $pattern => $response) { @@ -111,4 +156,13 @@ private function recordEvent(object $event): void { $this->events[] = $event; } + + private function recordSharedEvent(array $requestPayload, array $eventPayload): void + { + $name = $requestPayload['shared_event'] ?? null; + + if (is_string($name) && $name !== '') { + $this->sharedEvents[] = new RecordedSharedEvent($name, $eventPayload); + } + } } diff --git a/src/Testing/RecordedSharedEvent.php b/src/Testing/RecordedSharedEvent.php new file mode 100644 index 0000000..76398ac --- /dev/null +++ b/src/Testing/RecordedSharedEvent.php @@ -0,0 +1,11 @@ + 'success', + 'requests' => [ + [ + 'requestId' => 'request-1', + 'method' => 'POST', + 'url' => 'https://example.test/users', + 'type' => 'request', + 'attempt' => 2, + 'state' => 'waiting_retry', + 'bytesTransferred' => 512, + 'bytesTotal' => 1024, + 'progress' => 0.5, + 'nextRetryAt' => 1790000000000, + ], + [ + 'requestId' => 'download-1', + 'method' => 'GET', + 'url' => 'https://example.test/report', + 'type' => 'download', + 'attempt' => 1, + 'state' => 'running', + 'bytesTransferred' => 2048, + 'bytesTotal' => null, + 'progress' => null, + 'nextRetryAt' => null, + ], + ], + ]; +}); + +it('returns typed active request snapshots', function () { + $requests = (new PendingRequest)->active(); + + expect($GLOBALS['fetch_bridge_calls'][0]['function'])->toBe('Fetch.Active') + ->and($requests)->toHaveCount(2) + ->and($requests[0])->toBeInstanceOf(ActiveRequest::class) + ->and($requests[0]->requestId)->toBe('request-1') + ->and($requests[0]->attempt)->toBe(2) + ->and($requests[0]->state)->toBe('waiting_retry') + ->and($requests[0]->bytesTransferred)->toBe(512) + ->and($requests[0]->bytesTotal)->toBe(1024) + ->and($requests[0]->progress)->toBe(0.5) + ->and($requests[0]->nextRetryAt)->toBe(1790000000000) + ->and($requests[1]->type)->toBe('download'); +}); + +it('normalizes incomplete and unsafe snapshot values', function () { + $request = ActiveRequest::fromArray([ + 'requestId' => 'request-1', + 'progress' => 2, + 'bytesTransferred' => -5, + ]); + + expect($request->state)->toBe('running') + ->and($request->attempt)->toBe(1) + ->and($request->bytesTransferred)->toBe(0) + ->and($request->bytesTotal)->toBeNull() + ->and($request->progress)->toBe(1.0) + ->and($request->nextRetryAt)->toBeNull(); +}); + +it('checks whether a logical request is active', function () { + $request = new PendingRequest; + + expect($request->isActive('download-1'))->toBeTrue() + ->and($request->isActive('missing'))->toBeFalse(); +}); + +it('cancels all active requests and returns the native count', function () { + $GLOBALS['fetch_bridge_response'] = [ + 'status' => 'success', + 'cancelled' => 2, + ]; + + expect((new PendingRequest)->cancelAll())->toBe(2) + ->and($GLOBALS['fetch_bridge_calls'][0]['function'])->toBe('Fetch.CancelAll'); +}); + +it('keeps active request fake behavior deterministic', function () { + $fetch = new Fetch; + $fetch->fake(); + + expect($fetch->active())->toBe([]) + ->and($fetch->isActive('request-1'))->toBeFalse() + ->and($fetch->cancelAll())->toBe(0); +}); + +it('keeps live state and progress tracking aligned on both platforms', function () { + $root = dirname(__DIR__); + $android = file_get_contents($root.'/resources/android/src/FetchFunctions.kt'); + $ios = file_get_contents($root.'/resources/ios/Sources/FetchFunctions.swift'); + + foreach (['waiting_retry', 'bytesTransferred', 'bytesTotal', 'nextRetryAt'] as $field) { + expect($android)->toContain($field) + ->and($ios)->toContain($field); + } +}); + +it('exposes reactive active request changes as a component event', function () { + $event = new FetchActiveRequestsChanged('request-1', 'waiting_retry'); + $root = dirname(__DIR__); + $android = file_get_contents($root.'/resources/android/src/FetchFunctions.kt'); + $ios = file_get_contents($root.'/resources/ios/Sources/FetchFunctions.swift'); + + expect($event->requestId)->toBe('request-1') + ->and($event->change)->toBe('waiting_retry') + ->and($android)->toContain('ACTIVE_REQUESTS_CHANGED', '250L') + ->and($ios)->toContain('activeRequestsChanged', '0.25'); + + foreach (['started', 'progress', 'waiting_retry', 'running', 'finished'] as $change) { + expect($android)->toContain("\"{$change}\"") + ->and($ios)->toContain("\"{$change}\""); + } + + expect($android) + ->toContain('activeProgressTimes.compute(requestId)') + ->not->toContain('activeProgressTimes.put(requestId, now)'); +}); diff --git a/tests/ContractTest.php b/tests/ContractTest.php index 42a6ed8..621f6db 100644 --- a/tests/ContractTest.php +++ b/tests/ContractTest.php @@ -13,6 +13,7 @@ 'acceptJson', 'asJson', 'timeout', + 'shared', 'retry', 'attach', 'attachMany', @@ -23,6 +24,9 @@ 'delete', 'download', 'cancel', + 'active', + 'isActive', + 'cancelAll', ]; $managerMethods = collect((new ReflectionClass(Fetch::class))->getMethods( @@ -56,6 +60,7 @@ 'acceptJson', 'asJson', 'timeout', + 'shared', 'retry', 'attach', 'attachMany', @@ -66,6 +71,9 @@ 'delete', 'download', 'cancel', + 'active', + 'isActive', + 'cancelAll', ] as $method) { expect($facade)->toContain(" {$method}("); } @@ -137,7 +145,10 @@ ); expect($android) - ->toContain('if (operation.cancelled)') + ->toContain( + 'private fun isRetryCancelled(operation: RetryOperation)', + 'if (isRetryCancelled(operation))', + ) ->toContain('val cancelled = state.cancelled') ->not->toContain('if (call.isCanceled() || operation.cancelled)') ->not->toContain('val cancelled = call?.isCanceled() == true || state.cancelled'); diff --git a/tests/DownloadTest.php b/tests/DownloadTest.php index 8294309..8b376d1 100644 --- a/tests/DownloadTest.php +++ b/tests/DownloadTest.php @@ -45,6 +45,7 @@ 'timeout' => 60, 'overwrite' => true, 'retry' => null, + 'shared_event' => null, ], ]); }); diff --git a/tests/FakeFetchTest.php b/tests/FakeFetchTest.php index 856c9f8..d5aa2a9 100644 --- a/tests/FakeFetchTest.php +++ b/tests/FakeFetchTest.php @@ -6,6 +6,7 @@ use Victorycodedev\NativephpFetch\Fetch; use Victorycodedev\NativephpFetch\FetchResponse; use Victorycodedev\NativephpFetch\Testing\RecordedRequest; +use Victorycodedev\NativephpFetch\Testing\RecordedSharedEvent; beforeEach(function () { $GLOBALS['fetch_bridge_calls'] = []; @@ -60,3 +61,39 @@ ->not->toContain('Illuminate\\Container\\Container') ->not->toContain("make('events')"); }); + +it('records shared terminal events separately from existing native events', function () { + $fetch = new Fetch; + $fake = $fetch->fake([ + '*' => FetchResponse::make(201, ['saved' => true], ['X-Fake' => 'yes']), + ]); + + $requestId = $fetch->shared('profile-saved')->post('https://example.test/profile'); + + expect($fake->events())->toHaveCount(2) + ->and($fake->sharedEvents())->toHaveCount(1) + ->and($fake->sharedEvents()[0])->toBeInstanceOf(RecordedSharedEvent::class) + ->and($fake->sharedEvents()[0]->name)->toBe('profile-saved') + ->and($fake->sharedEvents()[0]->payload)->toMatchArray([ + 'requestId' => $requestId, + 'status' => 'completed', + 'httpStatus' => 201, + ]); + + $fetch->assertShared('profile-saved', fn (array $payload) => $payload['requestId'] === $requestId); +}); + +it('records shared download completion payloads', function () { + $fetch = new Fetch; + $fake = $fetch->fake(['*' => FetchResponse::make(200, 'file')]); + + $fetch->shared('report-ready')->download( + 'https://example.test/report', + '/app/report.pdf', + ); + + $fetch->assertShared('report-ready', fn (array $payload) => $payload['path'] === '/app/report.pdf' + && $payload['bytesReceived'] === 4); + + expect($fake->sharedEvents())->toHaveCount(1); +}); diff --git a/tests/GlobalEventsTest.php b/tests/GlobalEventsTest.php index baa641f..3d8634d 100644 --- a/tests/GlobalEventsTest.php +++ b/tests/GlobalEventsTest.php @@ -1,8 +1,10 @@ toBeTrue(); } - foreach ([FetchRequestCancelled::class, FetchRequestRetrying::class, FetchUploadProgress::class, FetchDownloadProgress::class, FetchDownloadCompleted::class] as $event) { + foreach ([FetchRequestCancelled::class, FetchRequestRetrying::class, FetchUploadProgress::class, FetchDownloadProgress::class, FetchDownloadCompleted::class, FetchActiveRequestsChanged::class] as $event) { expect(is_subclass_of($event, BroadcastsGlobally::class))->toBeFalse(); } }); diff --git a/tests/PluginTest.php b/tests/PluginTest.php index d620506..8f97c09 100644 --- a/tests/PluginTest.php +++ b/tests/PluginTest.php @@ -213,6 +213,67 @@ 'Event::listen(FetchRequestCompleted::class', ); }); + + it('lists every supported API surface in the API reference', function () { + $reference = file_get_contents($this->pluginPath.'/docs/api-reference.md'); + + $phpMethods = [ + 'request(', 'baseUrl(', 'withHeader(', 'withHeaders(', 'withToken(', + 'acceptJson(', 'asJson(', 'asForm(', 'withBody(', 'timeout(', + 'shared(', 'retry(', 'attach(', 'attachMany(', 'get(', 'post(', + 'put(', 'patch(', 'delete(', 'download(', 'cancel(', 'active(', + 'isActive(', 'cancelAll(', 'fake(', 'restore(', 'isFaking(', + 'fakeInstance(', 'assertSent(', 'assertNotSent(', 'assertSentCount(', + 'assertShared(', 'macro(', 'mixin(', 'hasMacro(', 'flushMacros(', + ]; + + $responseMethods = [ + 'from(', 'fromEvent(', 'make(', 'withRequestId(', 'requestId(', + 'status(', 'headers(', 'header(', 'body(', 'json(', 'statusIs(', + 'ok(', 'created(', 'accepted(', 'noContent(', 'movedPermanently(', + 'found(', 'badRequest(', 'unauthorized(', 'paymentRequired(', + 'forbidden(', 'notFound(', 'methodNotAllowed(', 'requestTimeout(', + 'conflict(', 'gone(', 'unprocessableEntity(', 'tooManyRequests(', + 'internalServerError(', 'serviceUnavailable(', 'successful(', + 'redirect(', 'failed(', 'clientError(', 'serverError(', + ]; + + $events = [ + 'FetchRequestStarted', 'FetchRequestCompleted', 'FetchRequestFailed', + 'FetchRequestCancelled', 'FetchRequestRetrying', 'FetchUploadProgress', + 'FetchDownloadProgress', 'FetchDownloadCompleted', + 'FetchActiveRequestsChanged', + ]; + + expect($reference) + ->toContain(...$phpMethods) + ->toContain(...$responseMethods) + ->toContain(...$events) + ->toContain( + '## ActiveRequest', + '## Testing value objects', + '### RecordedRequest', + '## JavaScript client', + ) + ->not->toContain( + '`bridge()`', + 'bridgeCall(method, params)', + 'start(params)', + 'downloadNative(params)', + ); + }); + + it('starts the whats new history with the upcoming release and feature guides', function () { + $whatsNew = file_get_contents($this->pluginPath.'/docs/whats-new.md'); + $config = file_get_contents($this->pluginPath.'/docs/.vitepress/config.mjs'); + + expect($whatsNew) + ->toContain('## 1.2.0') + ->toContain('[Shared events](/shared-events)') + ->toContain('[Active requests](/active-requests)') + ->not->toContain('## 1.1.0', '## 1.0.0') + ->and($config)->toContain('/whats-new'); + }); }); describe('Composer Configuration', function () { diff --git a/tests/PublicApiTest.php b/tests/PublicApiTest.php index 96f14ec..2f64092 100644 --- a/tests/PublicApiTest.php +++ b/tests/PublicApiTest.php @@ -286,6 +286,7 @@ ->and($manager->acceptJson())->toBeInstanceOf(PendingRequest::class) ->and($manager->asJson())->toBeInstanceOf(PendingRequest::class) ->and($manager->timeout(1))->toBeInstanceOf(PendingRequest::class) + ->and($manager->shared('profile-updated'))->toBeInstanceOf(PendingRequest::class) ->and($manager->retry(0))->toBeInstanceOf(PendingRequest::class) ->and($manager->attach('file', '/app/file'))->toBeInstanceOf(PendingRequest::class) ->and($manager->attachMany([]))->toBeInstanceOf(PendingRequest::class); diff --git a/tests/ReliabilityTest.php b/tests/ReliabilityTest.php new file mode 100644 index 0000000..07418e5 --- /dev/null +++ b/tests/ReliabilityTest.php @@ -0,0 +1,84 @@ +toBeGreaterThanOrEqual(2) + ->and(substr_count($android, 'bytesTotal = null'))->toBeGreaterThanOrEqual(2) + ->and(substr_count($ios, 'bytesTransferred = 0'))->toBeGreaterThanOrEqual(2) + ->and(substr_count($ios, 'bytesTotal = nil'))->toBeGreaterThanOrEqual(2); +}); + +it('releases retry task and temporary transfer state at terminal cleanup', function () { + $android = nativeSource('/resources/android/src/FetchFunctions.kt'); + $ios = nativeSource('/resources/ios/Sources/FetchFunctions.swift'); + + expect($android) + ->toContain( + 'operation.retryFuture?.cancel(false)', + 'operation.retryFuture = null', + 'operation.call = null', + 'state.retryFuture?.cancel(false)', + 'state.retryFuture = null', + 'state.call = null', + ) + ->and($ios) + ->toContain( + 'state.retryWorkItem?.cancel()', + 'state.retryWorkItem = nil', + 'uploadProgressTimes.removeValue(forKey: state.requestId)', + 'try? FileManager.default.removeItem(at: uploadFile)', + ); +}); + +it('guards mutable native request state across callback threads', function () { + $android = nativeSource('/resources/android/src/FetchFunctions.kt'); + $ios = nativeSource('/resources/ios/Sources/FetchFunctions.swift'); + + expect($android) + ->toContain( + 'private fun isRetryCancelled(operation: RetryOperation)', + 'if (isRetryCancelled(operation))', + ) + ->and(substr_count($ios, 'storeStandardState(state)')) + ->toBe(2) + ->and($ios) + ->toContain( + 'private func storeStandardState(_ state: StandardRetryState)', + 'retryOperations[state.requestId] = state', + 'let downloadState = requestId.flatMap { downloads[$0] }', + 'guard let requestId, let downloadState else', + ); +}); + +it('contains no obsolete ios lifecycle implementation or diagnostic logging', function () { + $android = nativeSource('/resources/android/src/FetchFunctions.kt'); + $ios = nativeSource('/resources/ios/Sources/FetchFunctions.swift'); + + expect($ios)->not->toContain( + 'temporaryUploadFiles', + 'private func handleCompletion(', + 'private func storeTask(', + 'private func cleanupTask(', + 'print(', + 'NSLog(', + )->and($android)->not->toContain( + 'Log.d(', + 'Log.e(', + 'println(', + ); +}); + +it('starts the whats new history with the upcoming release', function () { + $whatsNew = nativeSource('/docs/whats-new.md'); + + expect($whatsNew) + ->toContain('## 1.2.0 (upcoming)', '### Reliability and compatibility') + ->not->toContain('## 1.1.0', '## 1.0.0'); +}); diff --git a/tests/SharedEventsTest.php b/tests/SharedEventsTest.php new file mode 100644 index 0000000..1f9aa9c --- /dev/null +++ b/tests/SharedEventsTest.php @@ -0,0 +1,51 @@ + 'success', + 'accepted' => true, + ]; +}); + +it('passes a shared event alias to requests and downloads', function () { + (new PendingRequest)->shared('profile-saved')->post('https://example.test/profile'); + (new PendingRequest)->shared('report-ready')->download( + 'https://example.test/report', + '/app/report.pdf', + ); + + expect($GLOBALS['fetch_bridge_calls'][0]['payload']['shared_event'])->toBe('profile-saved') + ->and($GLOBALS['fetch_bridge_calls'][1]['payload']['shared_event'])->toBe('report-ready'); +}); + +it('does not opt requests into shared events by default', function () { + (new PendingRequest)->get('https://example.test'); + + expect($GLOBALS['fetch_bridge_calls'][0]['payload']['shared_event'])->toBeNull(); +}); + +it('rejects empty shared event names', function (string $event) { + expect(fn () => (new PendingRequest)->shared($event)) + ->toThrow(FetchException::class, 'cannot be empty'); +})->with(['', ' ']); + +it('keeps dynamic shared events outside the native manifest', function () { + $manifest = file_get_contents(dirname(__DIR__).'/nativephp.json'); + + expect($manifest)->not->toContain('profile-saved', 'shared_event'); +}); + +it('keeps shared terminal dispatches in native guarded completion paths', function () { + $root = dirname(__DIR__); + $android = file_get_contents($root.'/resources/android/src/FetchFunctions.kt'); + $ios = file_get_contents($root.'/resources/ios/Sources/FetchFunctions.swift'); + + expect($android) + ->toContain('operation.sharedEvent', 'state.sharedEvent', 'emitSharedTerminal') + ->and($ios) + ->toContain('state.sharedEvent', 'emitSharedCompleted', 'emitSharedFailure', 'emitSharedCancelled'); +});