From a321f83f40ebf772aae5c3dc5ef552e92631e33f Mon Sep 17 00:00:00 2001 From: Dimitri Bouniol Date: Mon, 23 Dec 2024 02:29:43 -0800 Subject: [PATCH 1/3] Added missing documentation throughout the package --- Package.swift | 9 +++++++-- Sources/WebPush/Errors/BadSubscriberError.swift | 1 + Sources/WebPush/Errors/Base64URLDecodingError.swift | 1 + Sources/WebPush/Errors/HTTPError.swift | 4 ++++ Sources/WebPush/Errors/MessageTooLargeError.swift | 1 + Sources/WebPush/Errors/UserAgentKeyMaterialError.swift | 8 +++++++- Sources/WebPush/Errors/VAPIDConfigurationError.swift | 4 ++++ .../WebPush/Helpers/DataProtocol+Base64URLCoding.swift | 3 +++ Sources/WebPush/Helpers/HTTPClientProtocol.swift | 3 +++ Sources/WebPush/Helpers/PrintLogHandler.swift | 1 + Sources/WebPush/VAPID/VAPID.swift | 1 + Sources/WebPush/VAPID/VAPIDConfiguration.swift | 2 ++ Sources/WebPush/VAPID/VAPIDKey.swift | 2 ++ Sources/WebPush/VAPID/VAPIDToken.swift | 2 ++ Sources/WebPush/WebPushManager.swift | 1 + 15 files changed, 40 insertions(+), 3 deletions(-) diff --git a/Package.swift b/Package.swift index c724a58..ab5a6a5 100644 --- a/Package.swift +++ b/Package.swift @@ -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( diff --git a/Sources/WebPush/Errors/BadSubscriberError.swift b/Sources/WebPush/Errors/BadSubscriberError.swift index f17f8e9..670e65a 100644 --- a/Sources/WebPush/Errors/BadSubscriberError.swift +++ b/Sources/WebPush/Errors/BadSubscriberError.swift @@ -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? { diff --git a/Sources/WebPush/Errors/Base64URLDecodingError.swift b/Sources/WebPush/Errors/Base64URLDecodingError.swift index ad4c8ae..ae78472 100644 --- a/Sources/WebPush/Errors/Base64URLDecodingError.swift +++ b/Sources/WebPush/Errors/Base64URLDecodingError.swift @@ -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? { diff --git a/Sources/WebPush/Errors/HTTPError.swift b/Sources/WebPush/Errors/HTTPError.swift index 50d8907..8317fbf 100644 --- a/Sources/WebPush/Errors/HTTPError.swift +++ b/Sources/WebPush/Errors/HTTPError.swift @@ -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)" diff --git a/Sources/WebPush/Errors/MessageTooLargeError.swift b/Sources/WebPush/Errors/MessageTooLargeError.swift index 360330d..41475bf 100644 --- a/Sources/WebPush/Errors/MessageTooLargeError.swift +++ b/Sources/WebPush/Errors/MessageTooLargeError.swift @@ -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? { diff --git a/Sources/WebPush/Errors/UserAgentKeyMaterialError.swift b/Sources/WebPush/Errors/UserAgentKeyMaterialError.swift index 984c356..92e8e5c 100644 --- a/Sources/WebPush/Errors/UserAgentKeyMaterialError.swift +++ b/Sources/WebPush/Errors/UserAgentKeyMaterialError.swift @@ -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 { diff --git a/Sources/WebPush/Errors/VAPIDConfigurationError.swift b/Sources/WebPush/Errors/VAPIDConfigurationError.swift index bffb78a..38f0e6a 100644 --- a/Sources/WebPush/Errors/VAPIDConfigurationError.swift +++ b/Sources/WebPush/Errors/VAPIDConfigurationError.swift @@ -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. diff --git a/Sources/WebPush/Helpers/DataProtocol+Base64URLCoding.swift b/Sources/WebPush/Helpers/DataProtocol+Base64URLCoding.swift index 835ddbc..e645bba 100644 --- a/Sources/WebPush/Helpers/DataProtocol+Base64URLCoding.swift +++ b/Sources/WebPush/Helpers/DataProtocol+Base64URLCoding.swift @@ -9,6 +9,7 @@ import Foundation extension DataProtocol { + /// The receiver as a Base64 URL encoded string. @_disfavoredOverload @usableFromInline func base64URLEncodedString() -> String { @@ -21,6 +22,7 @@ extension DataProtocol { } extension ContiguousBytes { + /// The receiver as a Base64 URL encoded string. @usableFromInline func base64URLEncodedString() -> String { withUnsafeBytes { bytes in @@ -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: "/") diff --git a/Sources/WebPush/Helpers/HTTPClientProtocol.swift b/Sources/WebPush/Helpers/HTTPClientProtocol.swift index f95443d..3526a17 100644 --- a/Sources/WebPush/Helpers/HTTPClientProtocol.swift +++ b/Sources/WebPush/Helpers/HTTPClientProtocol.swift @@ -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 } diff --git a/Sources/WebPush/Helpers/PrintLogHandler.swift b/Sources/WebPush/Helpers/PrintLogHandler.swift index 7f1ebfe..b7f489b 100644 --- a/Sources/WebPush/Helpers/PrintLogHandler.swift +++ b/Sources/WebPush/Helpers/PrintLogHandler.swift @@ -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 diff --git a/Sources/WebPush/VAPID/VAPID.swift b/Sources/WebPush/VAPID/VAPID.swift index 58ccdca..b361d4f 100644 --- a/Sources/WebPush/VAPID/VAPID.swift +++ b/Sources/WebPush/VAPID/VAPID.swift @@ -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. diff --git a/Sources/WebPush/VAPID/VAPIDConfiguration.swift b/Sources/WebPush/VAPID/VAPIDConfiguration.swift index 21d6b28..9cda94b 100644 --- a/Sources/WebPush/VAPID/VAPIDConfiguration.swift +++ b/Sources/WebPush/VAPID/VAPIDConfiguration.swift @@ -157,6 +157,7 @@ extension VoluntaryApplicationServerIdentification { } extension VAPID.Configuration: Codable { + /// The coding keys used to encode a VAPID configuration. public enum CodingKeys: CodingKey { case primaryKey case keys @@ -220,6 +221,7 @@ extension VAPID.Configuration { /// An email-based contact method. case email(String) + /// The string that representa the contact information as a fully-qualified URL. var urlString: String { switch self { case .url(let url): url.absoluteURL.absoluteString diff --git a/Sources/WebPush/VAPID/VAPIDKey.swift b/Sources/WebPush/VAPID/VAPIDKey.swift index 6858719..3ca349e 100644 --- a/Sources/WebPush/VAPID/VAPIDKey.swift +++ b/Sources/WebPush/VAPID/VAPIDKey.swift @@ -68,8 +68,10 @@ extension VAPID.Key: Identifiable { /// /// - SeeAlso: [Push API Working Draft §7.2. `PushSubscriptionOptions` Interface](https://www.w3.org/TR/push-api/#pushsubscriptionoptions-interface) public struct ID: Hashable, Comparable, Codable, Sendable, CustomStringConvertible { + /// The raw string that represents the ID. private var rawValue: String + /// Initialize an ID with a raw string. init(_ rawValue: String) { self.rawValue = rawValue } diff --git a/Sources/WebPush/VAPID/VAPIDToken.swift b/Sources/WebPush/VAPID/VAPIDToken.swift index ac0fbae..460e702 100644 --- a/Sources/WebPush/VAPID/VAPIDToken.swift +++ b/Sources/WebPush/VAPID/VAPIDToken.swift @@ -16,6 +16,7 @@ extension VAPID { /// - SeeAlso: [RFC 7515 JSON Web Signature (JWS)](https://datatracker.ietf.org/doc/html/rfc7515) ///- SeeAlso: [RFC 7519 JSON Web Token (JWT)](https://datatracker.ietf.org/doc/html/rfc7519) struct Token: Hashable, Codable, Sendable { + /// The coding keys used to encode the token. enum CodingKeys: String, CodingKey { case audience = "aud" case subject = "sub" @@ -118,6 +119,7 @@ extension VAPID { } protocol VAPIDKeyProtocol: Identifiable, Sendable { + /// The signature type used by this key. associatedtype Signature: ContiguousBytes /// Returns a JWS signature for the message. diff --git a/Sources/WebPush/WebPushManager.swift b/Sources/WebPush/WebPushManager.swift index 63ce326..c5cbb02 100644 --- a/Sources/WebPush/WebPushManager.swift +++ b/Sources/WebPush/WebPushManager.swift @@ -134,6 +134,7 @@ public actor WebPushManager: Sendable { self.executor = executor } + /// Shutdown the client if it hasn't already been stopped. deinit { if !didShutdown, case let .httpClient(httpClient, _) = executor { try? httpClient.syncShutdown() From c4b47f850ad1505486be9e7238120c1bccd4b421 Mon Sep 17 00:00:00 2001 From: Dimitri Bouniol Date: Mon, 23 Dec 2024 03:39:12 -0800 Subject: [PATCH 2/3] Added usage documentation --- README.md | 205 ++++++++++++++++++++++++++++++++++++++++++++++++++++-- 1 file changed, 201 insertions(+), 4 deletions(-) diff --git a/README.md b/README.md index 0e740ef..cb2dc66 100644 --- a/README.md +++ b/README.md @@ -96,6 +96,7 @@ A **Push Service** is run by browsers to coordinate delivery of messages to subs + ### 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: @@ -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} @@ -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) From 80c36f52a5de9b6c2a1aa035dd07fe23f38452a2 Mon Sep 17 00:00:00 2001 From: Dimitri Bouniol Date: Mon, 23 Dec 2024 03:39:51 -0800 Subject: [PATCH 3/3] Updated suggested version in readme --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index cb2dc66..80fd045 100644 --- a/README.md +++ b/README.md @@ -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") ), ], ...