Skip to content

Documentation #45

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 3 commits into from
Dec 23, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 7 additions & 2 deletions Package.swift
Original file line number Diff line number Diff line change
Expand Up @@ -16,11 +16,16 @@ let package = Package(
.library(name: "WebPushTesting", targets: ["WebPush", "WebPushTesting"]),
],
dependencies: [
/// Core dependency that allows us to sign Authorization tokens and encrypt push messages per subscriber before delivery.
.package(url: "https://github.com/apple/swift-crypto.git", "3.10.0"..<"5.0.0"),
/// Logging integration allowing runtime API missuse warnings and push status tracing.
.package(url: "https://github.com/apple/swift-log.git", from: "1.6.2"),
.package(url: "https://github.com/apple/swift-nio.git", from: "2.77.0"),
.package(url: "https://github.com/swift-server/async-http-client.git", from: "1.24.0"),
/// Service lifecycle integration for clean shutdowns in a server environment.
.package(url: "https://github.com/swift-server/swift-service-lifecycle.git", from: "2.6.2"),
/// Internal dependency allowing push message delivery over HTTP/2.
.package(url: "https://github.com/swift-server/async-http-client.git", from: "1.24.0"),
/// Internal dependency for event loop coordination and shared HTTP types.
.package(url: "https://github.com/apple/swift-nio.git", from: "2.77.0"),
],
targets: [
.target(
Expand Down
207 changes: 202 additions & 5 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,7 @@ Please check the [releases](https://github.com/mochidev/swift-webpush/releases)
dependencies: [
.package(
url: "https://github.com/mochidev/swift-webpush.git",
.upToNextMinor(from: "0.3.0")
.upToNextMinor(from: "0.3.3")
),
],
...
Expand Down Expand Up @@ -96,6 +96,7 @@ A **Push Service** is run by browsers to coordinate delivery of messages to subs

</details>


### Generating Keys

Before integrating WebPush into your server, you must generate one time VAPID keys to identify your server to push services with. To help we this, we provide `vapid-key-generator`, which you can install and use as needed:
Expand All @@ -116,7 +117,7 @@ To update the generator, uninstall it and re-install it after pulling from main:
% swift package experimental-install
```

Once installed, a new configuration can be generated as needed:
Once installed, a new configuration can be generated as needed. Here, we generate a configuration with `https://example.com` as our support URL for push service administrators to use to contact us when issues occur:
```
% ~/.swiftpm/bin/vapid-key-generator https://example.com
VAPID.Configuration: {"contactInformation":"https://example.com","expirationDuration":79200,"primaryKey":"6PSSAJiMj7uOvtE4ymNo5GWcZbT226c5KlV6c+8fx5g=","validityDuration":72000}
Expand Down Expand Up @@ -162,22 +163,218 @@ OPTIONS:
> [!TIP]
> If you prefer, you can also generate keys in your own code by calling `VAPID.Key()`, but remember, the key should be persisted and re-used from that point forward!


### Setup

TBD
During the setup stage of your application server, decode the VAPID configuration you created above and initialize a `WebPushManager` with it:

```swift
import WebPush

...

guard
let rawVAPIDConfiguration = ProcessInfo.processInfo.environment["VAPID-CONFIG"],
let vapidConfiguration = try? JSONDecoder().decode(VAPID.Configuration.self, from: Data(rawVAPIDConfiguration.utf8))
else { fatalError("VAPID keys are unavailable, please generate one and add it to the environment.") }

let manager = WebPushManager(
vapidConfiguration: vapidConfiguration,
backgroundActivityLogger: logger
/// If you customized the event loop group your app uses, you can set it here:
// eventLoopGroupProvider: .shared(app.eventLoopGroup)
)

try await ServiceGroup(
services: [
/// Your other services here
manager
],
gracefulShutdownSignals: [.sigint],
logger: logger
).run()
```

If you are not yet using [Swift Service Lifecycle](https://github.com/swift-server/swift-service-lifecycle), you can skip adding it to the service group, and it'll shut down on deinit instead. This however may be too late to finish sending over all in-flight messages, so prefer to use a ServiceGroup for all your services if you can.

You'll also want to serve a `serviceWorker.mjs` file at the root of your server (it can be anywhere, but there are scoping restrictions that are simplified by serving it at the root) to handle incoming notifications:

```js
self.addEventListener('push', function(event) {
const data = event.data?.json() ?? {};
event.waitUntil((async () => {
/// Try parsing the data, otherwise use fallback content. DO NOT skip sending the notification, as you must display one for every push message that is received or your subscription will be dropped.
let title = data?.title ?? "Your App Name";
const body = data?.body ?? "New Content Available!";

await self.registration.showNotification(title, {
body,
icon: "/notification-icon.png", /// Only some browsers support this.
data
});
})());
});
```

> [!NOTE]
> `.mjs` here allows your code to import other js modules as needed. If you are not using Vapor, please make sure your server uses the correct mime type for this file extension.


### Registering Subscribers

TBD
To register a subscriber, you'll need backend code to provide your VAPID key, and frontend code to ask the browser for a subscription on behalf of the user.

On the backend (we are assuming Vapor here), register a route that returns your VAPID public key:

```swift
import WebPush

...

/// Listen somewhere for a VAPID key request. This path can be anything you want, and should be available to all parties you with to serve push messages to.
app.get("vapidKey", use: loadVapidKey)

...

/// A wrapper for the VAPID key that Vapor can encode.
struct WebPushOptions: Codable, Content, Hashable, Sendable {
static let defaultContentType = HTTPMediaType(type: "application", subType: "webpush-options+json")

var vapid: VAPID.Key.ID
}

/// The route handler, usually part of a route controller.
@Sendable func loadVapidKey(request: Request) async throws -> WebPushOptions {
WebPushOptions(vapid: manager.nextVAPIDKeyID)
}
```

Also register a route for persisting `Subscriber`'s:

```swift
import WebPush

...

/// Listen somewhere for new registrations. This path can be anything you want, and should be available to all parties you with to serve push messages to.
app.get("registerSubscription", use: registerSubscription)

...

/// A custom type for communicating the status of your subscription. Fill this out with any options you'd like to communicate back to the user.
struct SubscriptionStatus: Codable, Content, Hashable, Sendable {
var subscribed = true
}

/// The route handler, usually part of a route controller.
@Sendable func registerSubscription(request: Request) async throws -> SubscriptionStatus {
let subscriptionRequest = try request.content.decode(Subscriber.self, as: .jsonAPI)

// TODO: Persist subscriptionRequest!

return SubscriptionStatus()
}
```

> [!NOTE]
> `WebPushManager` (`manager` here) is fully sendable, and should be shared with your controllers using dependency injection. This allows you to fully test your application server by relying on the provided `WebPushTesting` library in your unit tests to mock keys, verify delivery, and simulate errors.

On the frontend, register your service worker, fetch your vapid key, and subscribe on behalf of the user:

```js
const serviceRegistration = await navigator.serviceWorker?.register("/serviceWorker.mjs", { type: "module" });
let subscription = await registration?.pushManager?.getSubscription();

/// Wait for the user to interact with the page to request a subscription.
document.getElementById("notificationsSwitch").addEventListener("click", async ({ currentTarget }) => {
try {
/// If we couldn't load a subscription, now's the time to ask for one.
if (!subscription) {
const applicationServerKey = await loadVAPIDKey();
subscription = await serviceRegistration.pushManager.subscribe({
userVisibleOnly: true,
applicationServerKey,
});
}

/// It is safe to re-register the same subscription.
const subscriptionStatusResponse = await registerSubscription(subscription);

/// Do something with your registration. Some may use it to store notification settings and display those back to the user.
...
} catch (error) {
/// Tell the user something went wrong here.
console.error(error);
}
}
});

...

async function loadVAPIDKey() {
/// Make sure this is the same route used above.
const httpResponse = await fetch(`/vapidKey`);

const webPushOptions = await httpResponse.json();
if (httpResponse.status != 200) throw new Error(webPushOptions.reason);

return webPushOptions.vapid;
}

export async function registerSubscription(subscription) {
/// Make sure this is the same route used above.
const subscriptionStatusResponse = await fetch(`/registerSubscription`, {
method: "POST",
body: {
...subscription.toJSON(),
/// It is good practice to provide the applicationServerKey back here so we can track which one was used if multiple were provided during configuration.
applicationServerKey: subscription.options.applicationServerKey,
}
});

/// Do something with your registration. Some may use it to store notification settings and display those back to the user.
...
}
```


### Sending Messages

TBD
To send a message, call one of the `send()` methods on `WebPushManager` with a `Subscriber`:

```swift
import WebPush

...

do {
try await manager.send(
json: ["title": "Test Notification", "body": "Hello, World!"
/// If sent from a request, pass the request's logger here to maintain its metadata.
// logger: request.logger
)
} catch BadSubscriberError() {
/// The subscription is no longer valid and should be removed.
} catch MessageTooLargeError() {
/// The message was too long and should be shortened.
} catch let error as HTTPError {
/// The push service ran into trouble. error.response may help here.
} catch {
/// An unknown error occurred.
}
```

Your service worker will receive this message, decode it, and present it to the user.

> [!NOTE]
> Although the spec supports it, most browsers do not support silent notifications, and will drop a subscription if they are used.


### Testing

The `WebPushTesting` module can be used to obtain a mocked `WebPushManager` instance that allows you to capture all messages that are sent out, or throw your own errors to validate your code functions appropriately. Only import `WebPushTesting` in your testing targets.


## Specifications

- [RFC 7515 JSON Web Signature (JWS)](https://datatracker.ietf.org/doc/html/rfc7515)
Expand Down
1 change: 1 addition & 0 deletions Sources/WebPush/Errors/BadSubscriberError.swift
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import Foundation
///
/// - Warning: Do not continue to send notifications to invalid subscriptions or you'll risk being rate limited by push services.
public struct BadSubscriberError: LocalizedError, Hashable, Sendable {
/// Create a new bad subscriber error.
public init() {}

public var errorDescription: String? {
Expand Down
1 change: 1 addition & 0 deletions Sources/WebPush/Errors/Base64URLDecodingError.swift
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import Foundation

/// An error encountered while decoding Base64 data.
public struct Base64URLDecodingError: LocalizedError, Hashable, Sendable {
/// Create a new base 64 decoding error.
public init() {}

public var errorDescription: String? {
Expand Down
4 changes: 4 additions & 0 deletions Sources/WebPush/Errors/HTTPError.swift
Original file line number Diff line number Diff line change
Expand Up @@ -15,9 +15,13 @@ import Foundation
/// - SeeAlso: [RFC 8292 Voluntary Application Server Identification (VAPID) for Web Push](https://datatracker.ietf.org/doc/html/rfc8292)
/// - SeeAlso: [Sending web push notifications in web apps and browsers — Review responses for push notification errors](https://developer.apple.com/documentation/usernotifications/sending-web-push-notifications-in-web-apps-and-browsers#Review-responses-for-push-notification-errors)
public struct HTTPError: LocalizedError, Sendable {
/// The HTTP response that was returned from the push service..
public let response: HTTPClientResponse

/// A cached description from the response that won't change over the lifetime of the error.
let capturedResponseDescription: String

/// Create a new http error.
public init(response: HTTPClientResponse) {
self.response = response
self.capturedResponseDescription = "\(response)"
Expand Down
1 change: 1 addition & 0 deletions Sources/WebPush/Errors/MessageTooLargeError.swift
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import Foundation
///
/// - SeeAlso: ``WebPushManager/maximumMessageSize``
public struct MessageTooLargeError: LocalizedError, Hashable, Sendable {
/// Create a new message too large error.
public init() {}

public var errorDescription: String? {
Expand Down
8 changes: 7 additions & 1 deletion Sources/WebPush/Errors/UserAgentKeyMaterialError.swift
Original file line number Diff line number Diff line change
Expand Up @@ -10,13 +10,19 @@ import Foundation

/// An error encountered during ``VAPID/Configuration`` initialization or decoding.
public struct UserAgentKeyMaterialError: LocalizedError, Sendable {
/// The kind of error that occured.
enum Kind {
/// The public key was invalid.
case invalidPublicKey
/// The authentication secret was invalid.
case invalidAuthenticationSecret
}

/// The kind of error that occured.
var kind: Kind
var underlyingError: any Error

/// The underlying error that caused this one.
public var underlyingError: any Error

/// The public key was invalid.
public static func invalidPublicKey(underlyingError: Error) -> Self {
Expand Down
4 changes: 4 additions & 0 deletions Sources/WebPush/Errors/VAPIDConfigurationError.swift
Original file line number Diff line number Diff line change
Expand Up @@ -11,11 +11,15 @@ import Foundation
extension VAPID {
/// An error encountered during ``VAPID/Configuration`` initialization or decoding.
public struct ConfigurationError: LocalizedError, Hashable, Sendable {
/// The kind of error that occured.
enum Kind {
/// VAPID keys not found during initialization.
case keysNotProvided
/// A VAPID key for the subscriber was not found.
case matchingKeyNotFound
}

/// The kind of error that occured.
var kind: Kind

/// VAPID keys not found during initialization.
Expand Down
3 changes: 3 additions & 0 deletions Sources/WebPush/Helpers/DataProtocol+Base64URLCoding.swift
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@
import Foundation

extension DataProtocol {
/// The receiver as a Base64 URL encoded string.
@_disfavoredOverload
@usableFromInline
func base64URLEncodedString() -> String {
Expand All @@ -21,6 +22,7 @@ extension DataProtocol {
}

extension ContiguousBytes {
/// The receiver as a Base64 URL encoded string.
@usableFromInline
func base64URLEncodedString() -> String {
withUnsafeBytes { bytes in
Expand All @@ -30,6 +32,7 @@ extension ContiguousBytes {
}

extension DataProtocol where Self: RangeReplaceableCollection {
/// Initialize data using a Base64 URL encoded string.
@usableFromInline
init?(base64URLEncoded string: some StringProtocol) {
var base64String = string.replacingOccurrences(of: "-", with: "+").replacingOccurrences(of: "_", with: "/")
Expand Down
3 changes: 3 additions & 0 deletions Sources/WebPush/Helpers/HTTPClientProtocol.swift
Original file line number Diff line number Diff line change
Expand Up @@ -10,13 +10,16 @@ import AsyncHTTPClient
import Logging
import NIOCore

/// A protocol abstracting HTTP request execution.
package protocol HTTPClientProtocol: Sendable {
/// Execute the request.
func execute(
_ request: HTTPClientRequest,
deadline: NIODeadline,
logger: Logger?
) async throws -> HTTPClientResponse

/// Shuts down the client.
func syncShutdown() throws
}

Expand Down
1 change: 1 addition & 0 deletions Sources/WebPush/Helpers/PrintLogHandler.swift
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@
import Foundation
import Logging

/// A simple log handler that uses formatted print statements.
package struct PrintLogHandler: LogHandler {
private let label: String

Expand Down
1 change: 1 addition & 0 deletions Sources/WebPush/VAPID/VAPID.swift
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@

import Foundation

/// The fully qualified name for VAPID.
public typealias VoluntaryApplicationServerIdentification = VAPID

/// A set of types for Voluntary Application Server Identification, also known as VAPID.
Expand Down
Loading
Loading