diff --git a/Sources/WebPush/Push Message/Notification.swift b/Sources/WebPush/Push Message/Notification.swift index 753c1f2..236ccac 100644 --- a/Sources/WebPush/Push Message/Notification.swift +++ b/Sources/WebPush/Push Message/Notification.swift @@ -278,6 +278,17 @@ extension PushMessage.Notification: Encodable { try messageContainer.encodeIfPresent(appBadgeCount, forKey: .appBadgeCount) if isMutable { try messageContainer.encode(isMutable, forKey: .isMutable) } } + + /// Check to see if a notification is potentially too large to be sent to a push service. + /// + /// - Note: _Some_ push services may still accept larger messages, so you can only truly know if a message is _too_ large by attempting to send it and checking for a ``MessageTooLargeError`` error. However, if a message passes this check, it is guaranteed to not fail for this reason, assuming the push service implements the minimum requirements of the spec, which you can assume for all major browsers. For these reasons, unless you are sending the same message to multiple subscribers, it's often faster to just try sending the message rather than checking before sending. + /// + /// - Throws: ``MessageTooLargeError`` if the message is too large. Throws another error if encoding fails. + @inlinable + public func checkMessageSize() throws { + guard try WebPushManager.messageEncoder.encode(self).count <= WebPushManager.maximumMessageSize + else { throw MessageTooLargeError() } + } } extension PushMessage.Notification: Decodable where Contents: Decodable { diff --git a/Sources/WebPush/WebPushManager.swift b/Sources/WebPush/WebPushManager.swift index 186f1cc..a321f2d 100644 --- a/Sources/WebPush/WebPushManager.swift +++ b/Sources/WebPush/WebPushManager.swift @@ -43,6 +43,15 @@ public actor WebPushManager: Sendable { /// This is currently set to 3,993 plaintext bytes. See the discussion for ``maximumEncryptedPayloadSize`` for more information. public static let maximumMessageSize = maximumEncryptedPayloadSize - 103 + /// The encoder used when serializing JSON messages. + public static let messageEncoder: JSONEncoder = { + let encoder = JSONEncoder() + encoder.dateEncodingStrategy = .millisecondsSince1970 + encoder.outputFormatting = [.withoutEscapingSlashes] + + return encoder + }() + /// The internal logger to use when reporting misconfiguration and background activity. nonisolated let backgroundActivityLogger: Logger @@ -366,6 +375,19 @@ public actor WebPushManager: Sendable { ) } + /// Check to see if a message is potentially too large to be sent to a push service. + /// + /// - Note: _Some_ push services may still accept larger messages, so you can only truly know if a message is _too_ large by attempting to send it and checking for a ``MessageTooLargeError`` error. However, if a message passes this check, it is guaranteed to not fail for this reason, assuming the push service implements the minimum requirements of the spec, which you can assume for all major browsers. + /// + /// - Parameters: + /// - message: The message to send as raw data. + /// - Throws: ``MessageTooLargeError`` if the message is too large. + @inlinable + public nonisolated func checkMessageSize(data message: some DataProtocol) throws(MessageTooLargeError) { + guard message.count <= Self.maximumMessageSize + else { throw MessageTooLargeError() } + } + /// Send a push message as a string. /// /// The service worker you registered is expected to know how to decode the string you send. @@ -428,6 +450,19 @@ public actor WebPushManager: Sendable { ) } + /// Check to see if a message is potentially too large to be sent to a push service. + /// + /// - Note: _Some_ push services may still accept larger messages, so you can only truly know if a message is _too_ large by attempting to send it and checking for a ``MessageTooLargeError`` error. However, if a message passes this check, it is guaranteed to not fail for this reason, assuming the push service implements the minimum requirements of the spec, which you can assume for all major browsers. For these reasons, unless you are sending the same message to multiple subscribers, it's often faster to just try sending the message rather than checking before sending. + /// + /// - Parameters: + /// - message: The message to send as a string. + /// - Throws: ``MessageTooLargeError`` if the message is too large. + @inlinable + public nonisolated func checkMessageSize(string message: some StringProtocol) throws(MessageTooLargeError) { + guard message.utf8.count <= Self.maximumMessageSize + else { throw MessageTooLargeError() } + } + /// Send a push message as encoded JSON. /// /// The service worker you registered is expected to know how to decode the JSON you send. Note that dates are encoded using ``/Foundation/JSONEncoder/DateEncodingStrategy/millisecondsSince1970``, and data is encoded using ``/Foundation/JSONEncoder/DataEncodingStrategy/base64``. @@ -490,6 +525,18 @@ public actor WebPushManager: Sendable { ) } + /// Check to see if a message is potentially too large to be sent to a push service. + /// + /// - Note: _Some_ push services may still accept larger messages, so you can only truly know if a message is _too_ large by attempting to send it and checking for a ``MessageTooLargeError`` error. However, if a message passes this check, it is guaranteed to not fail for this reason, assuming the push service implements the minimum requirements of the spec, which you can assume for all major browsers. For these reasons, unless you are sending the same message to multiple subscribers, it's often faster to just try sending the message rather than checking before sending. + /// + /// - Parameters: + /// - message: The message to send as JSON. + /// - Throws: ``MessageTooLargeError`` if the message is too large. Throws another error if encoding fails. + @inlinable + public nonisolated func checkMessageSize(json message: some Encodable&Sendable) throws { + try _Message.json(message).checkMessageSize() + } + /// Send a push notification. /// /// If you provide ``PushMessage/Notification/data``, the service worker you registered is expected to know how to decode it. Note that dates are encoded using ``/Foundation/JSONEncoder/DateEncodingStrategy/millisecondsSince1970``, and data is encoded using ``/Foundation/JSONEncoder/DataEncodingStrategy/base64``. @@ -549,6 +596,18 @@ public actor WebPushManager: Sendable { ) } + /// Check to see if a message is potentially too large to be sent to a push service. + /// + /// - Note: _Some_ push services may still accept larger messages, so you can only truly know if a message is _too_ large by attempting to send it and checking for a ``MessageTooLargeError`` error. However, if a message passes this check, it is guaranteed to not fail for this reason, assuming the push service implements the minimum requirements of the spec, which you can assume for all major browsers. For these reasons, unless you are sending the same message to multiple subscribers, it's often faster to just try sending the message rather than checking before sending. + /// + /// - Parameters: + /// - notification: The ``PushMessage/Notification`` push notification. + /// - Throws: ``MessageTooLargeError`` if the message is too large. Throws another error if encoding fails. + @inlinable + public nonisolated func checkMessageSize(notification: PushMessage.Notification) throws { + try notification.checkMessageSize() + } + /// Route a message to the current executor. /// - Parameters: /// - message: The message to send. @@ -1067,6 +1126,7 @@ extension WebPushManager { case json(any Encodable&Sendable) /// The message, encoded as data. + @usableFromInline var data: Data { get throws { switch self { @@ -1076,15 +1136,13 @@ extension WebPushManager { var string = string return string.withUTF8 { Data($0) } case .json(let json): - let encoder = JSONEncoder() - encoder.dateEncodingStrategy = .millisecondsSince1970 - encoder.outputFormatting = [.withoutEscapingSlashes] - return try encoder.encode(json) + return try WebPushManager.messageEncoder.encode(json) } } } /// The string value from a ``string(_:)`` message. + @inlinable public var string: String? { guard case let .string(string) = self else { return nil } @@ -1092,12 +1150,14 @@ extension WebPushManager { } /// The json value from a ``json(_:)`` message. + @inlinable public func json(as: JSON.Type = JSON.self) -> JSON? { guard case let .json(json) = self else { return nil } return json as? JSON } + @inlinable public var description: String { switch self { case .data(let data): @@ -1108,6 +1168,26 @@ extension WebPushManager { return ".json(\(json))" } } + + /// Check to see if a message is potentially too large to be sent to a push service. + /// + /// - Note: _Some_ push services may still accept larger messages, so you can only truly know if a message is _too_ large by attempting to send it and checking for a ``MessageTooLargeError`` error. However, if a message passes this check, it is guaranteed to not fail for this reason, assuming the push service implements the minimum requirements of the spec, which you can assume for all major browsers. For these reasons, unless you are sending the same message to multiple subscribers, it's often faster to just try sending the message rather than checking before sending. + /// + /// - Throws: ``MessageTooLargeError`` if the message is too large. Throws another error if encoding fails. + @inlinable + public func checkMessageSize() throws { + switch self { + case .data(let data): + guard data.count <= WebPushManager.maximumMessageSize + else { throw MessageTooLargeError() } + case .string(let string): + guard string.utf8.count <= WebPushManager.maximumMessageSize + else { throw MessageTooLargeError() } + case .json(let json): + guard try WebPushManager.messageEncoder.encode(json).count <= WebPushManager.maximumMessageSize + else { throw MessageTooLargeError() } + } + } } /// An internal type representing the executor for a push message. diff --git a/Sources/WebPushTesting/WebPushManager+Testing.swift b/Sources/WebPushTesting/WebPushManager+Testing.swift index 3bf7f4e..61ba0d7 100644 --- a/Sources/WebPushTesting/WebPushManager+Testing.swift +++ b/Sources/WebPushTesting/WebPushManager+Testing.swift @@ -38,7 +38,7 @@ extension WebPushManager { vapidConfiguration: VAPID.Configuration = .mockedConfiguration, // TODO: Add networkConfiguration for proxy, number of simultaneous pushes, etc… backgroundActivityLogger: Logger? = .defaultWebPushPrintLogger, - messageHandler: @escaping MessageHandler + messageHandler: @escaping MessageHandler = { _, _, _, _, _ in } ) -> WebPushManager { let backgroundActivityLogger = backgroundActivityLogger ?? .defaultWebPushNoOpLogger diff --git a/Tests/WebPushTests/MessageSizeTests.swift b/Tests/WebPushTests/MessageSizeTests.swift new file mode 100644 index 0000000..af2ef89 --- /dev/null +++ b/Tests/WebPushTests/MessageSizeTests.swift @@ -0,0 +1,103 @@ +// +// MessageSizeTests.swift +// swift-webpush +// +// Created by Dimitri Bouniol on 2025-03-02. +// Copyright © 2024 Mochi Development, Inc. All rights reserved. +// + +#if canImport(FoundationEssentials) +import FoundationEssentials +#else +import Foundation +#endif +import Testing +@testable import WebPush +@testable import WebPushTesting + +@Suite("Message Size Tetss") +struct MessageSizeTests { + @Test func dataMessages() async throws { + let webPushManager = WebPushManager.makeMockedManager() + try webPushManager.checkMessageSize(data: Data(repeating: 0, count: 42)) + try webPushManager.checkMessageSize(data: Data(repeating: 0, count: 3993)) + #expect(throws: MessageTooLargeError()) { + try webPushManager.checkMessageSize(data: Data(repeating: 0, count: 3994)) + } + + try WebPushManager._Message.data(Data(repeating: 0, count: 42)).checkMessageSize() + try WebPushManager._Message.data(Data(repeating: 0, count: 3993)).checkMessageSize() + #expect(throws: MessageTooLargeError()) { + try WebPushManager._Message.data(Data(repeating: 0, count: 3994)).checkMessageSize() + } + } + + @Test func stringMessages() async throws { + let webPushManager = WebPushManager.makeMockedManager() + try webPushManager.checkMessageSize(string: String(repeating: "A", count: 42)) + try webPushManager.checkMessageSize(string: String(repeating: "A", count: 3993)) + #expect(throws: MessageTooLargeError()) { + try webPushManager.checkMessageSize(string: String(repeating: "A", count: 3994)) + } + + try WebPushManager._Message.string(String(repeating: "A", count: 42)).checkMessageSize() + try WebPushManager._Message.string(String(repeating: "A", count: 3993)).checkMessageSize() + #expect(throws: MessageTooLargeError()) { + try WebPushManager._Message.string(String(repeating: "A", count: 3994)).checkMessageSize() + } + } + + @Test func jsonMessages() async throws { + let webPushManager = WebPushManager.makeMockedManager() + try webPushManager.checkMessageSize(json: ["key" : String(repeating: "A", count: 42)]) + try webPushManager.checkMessageSize(json: ["key" : String(repeating: "A", count: 3983)]) + #expect(throws: MessageTooLargeError()) { + try webPushManager.checkMessageSize(json: ["key" : String(repeating: "A", count: 3984)]) + } + + try WebPushManager._Message.json(["key" : String(repeating: "A", count: 42)]).checkMessageSize() + try WebPushManager._Message.json(["key" : String(repeating: "A", count: 3983)]).checkMessageSize() + #expect(throws: MessageTooLargeError()) { + try WebPushManager._Message.json(["key" : String(repeating: "A", count: 3984)]).checkMessageSize() + } + } + + @Test func notificationMessages() async throws { + let webPushManager = WebPushManager.makeMockedManager() + try webPushManager.checkMessageSize(notification: PushMessage.Notification( + destination: URL(string: "https://example.com")!, + title: String(repeating: "A", count: 42), + timestamp: Date(timeIntervalSince1970: 1_000_000_000) + )) + try webPushManager.checkMessageSize(notification: PushMessage.Notification( + destination: URL(string: "https://example.com")!, + title: String(repeating: "A", count: 3889), + timestamp: Date(timeIntervalSince1970: 1_000_000_000) + )) + #expect(throws: MessageTooLargeError()) { + try webPushManager.checkMessageSize(notification: PushMessage.Notification( + destination: URL(string: "https://example.com")!, + title: String(repeating: "A", count: 3890), + timestamp: Date(timeIntervalSince1970: 1_000_000_000) + )) + } + + try PushMessage.Notification( + destination: URL(string: "https://example.com")!, + title: String(repeating: "A", count: 42), + timestamp: Date(timeIntervalSince1970: 1_000_000_000) + ).checkMessageSize() + try PushMessage.Notification( + destination: URL(string: "https://example.com")!, + title: String(repeating: "A", count: 3889), + timestamp: Date(timeIntervalSince1970: 1_000_000_000) + ).checkMessageSize() + #expect(throws: MessageTooLargeError()) { + try PushMessage.Notification( + destination: URL(string: "https://example.com")!, + title: String(repeating: "A", count: 3890), + timestamp: Date(timeIntervalSince1970: 1_000_000_000) + ).checkMessageSize() + } + } +}