@tokamakdev/plugin-notifications 0.1.0-beta.50 → 0.1.0-beta.51

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -119,36 +119,66 @@ Every field is optional.
119
119
 
120
120
  ### Data-only messages
121
121
 
122
- A message without a title or body runs the Worker's `push` handler in native
123
- builds, whether the app is in the foreground, in the background, or started by
124
- the message:
122
+ In native builds, the plugin posts a message without a title or body to the
123
+ Worker's `/tokamak/push` endpoint, whether the app is in the foreground, in the
124
+ background, or started by the message. `handlePushRequest` serves the endpoint.
125
+ Route `POST /tokamak/push` to it, in a plain Worker:
126
+
127
+ ```ts
128
+ import { handlePushRequest } from "@tokamakdev/plugin-notifications/worker";
125
129
 
126
- ```js
127
130
  export default {
128
131
  async fetch(request, env, ctx) {
132
+ if (new URL(request.url).pathname === "/tokamak/push") {
133
+ return handlePushRequest<{ itemId: string }>(request, async (message) => {
134
+ const item = await fetch(`https://api.example.com/items/${message.data.itemId}`);
135
+ // Return a notification to show it, or return nothing.
136
+ return { id: `item-${message.data.itemId}`, title: "New item", body: (await item.json()).summary };
137
+ });
138
+ }
129
139
  // ...
130
140
  },
131
-
132
- async push(message, env, ctx) {
133
- const item = await fetch(`https://api.example.com/items/${message.data.id}`);
134
- // Return a notification to show it, or return nothing.
135
- return { id: `item-${message.data.id}`, title: "New item", body: (await item.json()).summary };
136
- },
137
141
  };
138
142
  ```
139
143
 
140
- `message` is the `Message`; `env` and `ctx` are the Worker's usual arguments.
141
- A returned notification is shown as `show` shows one. The page also receives
142
- the message through `onMessage` while it is loaded. Cloudflare never calls
143
- `push`, and `tok dev` does not run it.
144
+ or in a framework's server route, such as `src/pages/tokamak/push.ts` in Astro:
145
+
146
+ ```ts
147
+ import type { APIRoute } from "astro";
148
+ import { handlePushRequest } from "@tokamakdev/plugin-notifications/worker";
149
+
150
+ export const POST: APIRoute = ({ request }) =>
151
+ handlePushRequest<{ itemId: string }>(request, (message) => {
152
+ // ...
153
+ });
154
+ ```
155
+
156
+ `message` is the `Message`, with `data` of the type argument's type, which is
157
+ not checked; FCM delivers every `data` value as a string. A returned
158
+ notification is shown as `show` shows one. The page also receives the message
159
+ through `onMessage` while it is loaded.
160
+
161
+ `handlePushRequest` accepts requests in a tokamak app, where
162
+ `process.env.TOKAMAK_RUNTIME` is `"true"`, and in development, where
163
+ `process.env.NODE_ENV` is `development`, as in the development server `tok dev`
164
+ delivers messages to. It responds 404 otherwise, so the endpoint serves nothing
165
+ on Cloudflare. A build made with `NODE_ENV=development`, or a Worker that
166
+ declares `TOKAMAK_RUNTIME` itself, accepts every request.
167
+
168
+ Each attempt waits up to 2 seconds for a 200 response. After a failure, the
169
+ plugin tries again 1 second later, up to three attempts, while an attempt can
170
+ finish within the plugin's time for the message. A handler can run more than
171
+ once for one message, and an attempt that timed out keeps running. The plugin
172
+ logs each failed attempt to the device log as
173
+ `push notification failed: <reason>`.
144
174
 
145
175
  The platforms limit background delivery:
146
176
 
147
177
  | Platform | The server sends | Limits |
148
178
  |---|---|---|
149
- | iOS | `content-available: 1` with `apns-push-type: background` and `apns-priority: 5` | The system throttles delivery and may drop messages, and delivers nothing after the user force-quits the app until they open it. The plugin stops the handler after 25 seconds, within the system's 30. |
179
+ | iOS | `content-available: 1` with `apns-push-type: background` and `apns-priority: 5` | The system throttles delivery and may drop messages, and delivers nothing after the user force-quits the app until they open it. The plugin's time for a message is 25 seconds, within the system's 30. |
150
180
  | macOS | As iOS | Delivered while the app is running. |
151
- | Android | A `data` message with `priority: high` | Nothing is delivered after the app is force-stopped. The plugin stops the handler after 8 seconds, within FCM's 10, which include starting the app. |
181
+ | Android | A `data` message with `priority: high` | Nothing is delivered after the app is force-stopped. The plugin's time for a message is 8 seconds, within FCM's 10, which include starting the app. |
152
182
 
153
183
  The web does not support data-only messages: browsers require every push
154
184
  message to show a notification.
@@ -5,6 +5,7 @@ import android.content.Context
5
5
  import android.content.Intent
6
6
  import android.content.pm.PackageManager
7
7
  import android.os.Build
8
+ import android.os.SystemClock
8
9
  import android.util.Log
9
10
  import com.google.firebase.FirebaseApp
10
11
  import com.google.firebase.messaging.FirebaseMessaging
@@ -24,7 +25,10 @@ private const val SHOW_IN_FOREGROUND = "show-in-foreground"
24
25
  private const val PERMISSION_REQUEST = 0x4E07
25
26
 
26
27
  /** FCM allows about 10 seconds for a message, including starting the app. */
27
- private const val PUSH_TIMEOUT_MILLIS = 8_000L
28
+ private const val PUSH_DEADLINE_MILLIS = 8_000L
29
+ private const val PUSH_ATTEMPTS = 3
30
+ private const val PUSH_ATTEMPT_TIMEOUT_MILLIS = 2_000L
31
+ private const val PUSH_RETRY_DELAY_MILLIS = 1_000L
28
32
  private const val FCM_MESSAGE_ID_EXTRA = "google.message_id"
29
33
  private val LISTENERS = setOf("onMessage", "onNotificationOpened", "onSubscriptionChange")
30
34
 
@@ -112,8 +116,8 @@ class TokamakNotificationsPlugin(
112
116
  }
113
117
 
114
118
  /**
115
- * Delivers an FCM message to the page. Runs the Worker's `push` handler for a data-only
116
- * message and shows the notification it returns. FCM calls this off the main thread.
119
+ * Delivers an FCM message to the page. Posts a data-only message to the Worker's push
120
+ * endpoint and shows the notification it returns. FCM calls this off the main thread.
117
121
  */
118
122
  internal fun onMessageReceived(message: JSONObject, visible: Boolean) {
119
123
  context.mainExecutor.execute { emit("onMessage", message) }
@@ -121,15 +125,38 @@ class TokamakNotificationsPlugin(
121
125
  if (preferences.getBoolean(SHOW_IN_FOREGROUND, false)) notifier.post(content(message), "push")
122
126
  return
123
127
  }
124
- runCatching { host.dispatch("push", message.toString(), PUSH_TIMEOUT_MILLIS) }
125
- .onSuccess { result -> result?.let(::showReturned) }
126
- .onFailure { Log.w("tokamak", "the Worker's push handler failed", it) }
128
+ val deadline = SystemClock.elapsedRealtime() + PUSH_DEADLINE_MILLIS
129
+ runCatching { deliverPush(message.toString(), attempt = 1, deadline = deadline) }
130
+ .onSuccess(::showReturned)
127
131
  }
128
132
 
129
- private fun showReturned(result: String) {
130
- val returned = JSONTokener(result).nextValue() as? JSONObject ?: return
131
- runCatching { notifier.post(Content.parse(returned, scheduled = false), "local") }
132
- .onFailure { Log.w("tokamak", "the Worker's push handler returned an invalid notification", it) }
133
+ /**
134
+ * Posts [body] to `/tokamak/push`, logging each failed attempt and retrying while another
135
+ * attempt can finish before [deadline].
136
+ */
137
+ private fun deliverPush(
138
+ body: String,
139
+ attempt: Int,
140
+ deadline: Long,
141
+ ): String =
142
+ try {
143
+ host.call("push", body, PUSH_ATTEMPT_TIMEOUT_MILLIS)
144
+ } catch (error: Exception) {
145
+ Log.w("tokamak", "push notification failed: ${error.message}")
146
+ val retryEnds = SystemClock.elapsedRealtime() + PUSH_RETRY_DELAY_MILLIS + PUSH_ATTEMPT_TIMEOUT_MILLIS
147
+ if (attempt == PUSH_ATTEMPTS || retryEnds > deadline) throw error
148
+ Thread.sleep(PUSH_RETRY_DELAY_MILLIS)
149
+ deliverPush(body, attempt + 1, deadline)
150
+ }
151
+
152
+ /** Shows the notification a push response returns, if any. */
153
+ private fun showReturned(response: String) {
154
+ if (response.isEmpty()) return
155
+ runCatching {
156
+ val returned = JSONTokener(response).nextValue()
157
+ if (returned == JSONObject.NULL) return
158
+ notifier.post(Content.parse(returned as JSONObject, scheduled = false), "local")
159
+ }.onFailure { Log.w("tokamak", "the push response is not a notification", it) }
133
160
  }
134
161
 
135
162
  private fun emit(method: String, value: JSONObject) {
@@ -12,7 +12,10 @@ private let showInForegroundKey = "tokamak.notifications.show-in-foreground"
12
12
  /// The system keeps only the soonest 64 pending requests.
13
13
  private let pendingLimit = 64
14
14
  /// The system allows about 30 seconds for a background remote notification.
15
- private let pushTimeout: TimeInterval = 25
15
+ private let pushDeadline: TimeInterval = 25
16
+ private let pushAttempts = 3
17
+ private let pushAttemptTimeout: TimeInterval = 2
18
+ private let pushRetryDelay: TimeInterval = 1
16
19
  private let shown: UNNotificationPresentationOptions = [.banner, .list, .sound]
17
20
 
18
21
  #if os(iOS)
@@ -138,7 +141,7 @@ final class TokamakNotificationsPlugin: NSObject, TokamakPlugin,
138
141
  }
139
142
  }
140
143
 
141
- /// Runs the Worker's `push` handler for a data-only message, showing the
144
+ /// Posts a data-only message to the Worker's push endpoint, showing the
142
145
  /// notification it returns.
143
146
  func didReceiveRemoteNotification(
144
147
  _ userInfo: [AnyHashable: Any],
@@ -150,19 +153,39 @@ final class TokamakNotificationsPlugin: NSObject, TokamakPlugin,
150
153
  return
151
154
  }
152
155
  emit("onMessage", fields.message)
153
- host.dispatch(event: "push", payload: fields.message, timeout: pushTimeout) { result in
156
+ deliverPush(fields.message, attempt: 1, deadline: Date() + pushDeadline) { result in
154
157
  switch result {
155
- case .success(.handled(let returned)):
156
- self.show(returned) { completion(.newData) }
157
- case .success(.unhandled):
158
- completion(.noData)
159
- case .failure(let error):
160
- print("tokamak push handler failed: \(error)")
158
+ case .success(let response):
159
+ self.show(response) { completion(.newData) }
160
+ case .failure:
161
161
  completion(.failed)
162
162
  }
163
163
  }
164
164
  }
165
165
 
166
+ /// Posts `message` to `/tokamak/push`, logging each failed attempt and
167
+ /// retrying while another attempt can finish before `deadline`.
168
+ private func deliverPush(
169
+ _ message: [String: Any],
170
+ attempt: Int,
171
+ deadline: Date,
172
+ completion: @escaping (Result<Data, Error>) -> Void
173
+ ) {
174
+ host.call("push", body: message, timeout: pushAttemptTimeout) { result in
175
+ if case .failure(let error) = result {
176
+ print("push notification failed: \(error)")
177
+ }
178
+ let retryEnds = Date() + pushRetryDelay + pushAttemptTimeout
179
+ guard case .failure = result, attempt < pushAttempts, retryEnds <= deadline else {
180
+ completion(result)
181
+ return
182
+ }
183
+ DispatchQueue.main.asyncAfter(deadline: .now() + pushRetryDelay) {
184
+ self.deliverPush(message, attempt: attempt + 1, deadline: deadline, completion: completion)
185
+ }
186
+ }
187
+ }
188
+
166
189
  func userNotificationCenter(
167
190
  _ center: UNUserNotificationCenter,
168
191
  willPresent notification: UNNotification,
@@ -256,14 +279,16 @@ final class TokamakNotificationsPlugin: NSObject, TokamakPlugin,
256
279
  }
257
280
  }
258
281
 
259
- /// Shows a notification a Worker handler returned, then calls `completion`.
260
- private func show(_ returned: Any, completion: @escaping () -> Void) {
261
- guard !(returned is NSNull) else {
282
+ /// Shows the notification a push response returns, if any.
283
+ private func show(_ response: Data, completion: @escaping () -> Void) {
284
+ let returned = try? JSONSerialization.jsonObject(with: response, options: .fragmentsAllowed)
285
+ guard !response.isEmpty, !(returned is NSNull) else {
262
286
  completion()
263
287
  return
264
288
  }
265
- guard let request = try? TokamakNotificationRequest(returned, scheduled: false) else {
266
- print("tokamak push handler returned an invalid notification")
289
+ guard let returned, let request = try? TokamakNotificationRequest(returned, scheduled: false)
290
+ else {
291
+ print("tokamak push response is not a notification")
267
292
  completion()
268
293
  return
269
294
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tokamakdev/plugin-notifications",
3
- "version": "0.1.0-beta.50",
3
+ "version": "0.1.0-beta.51",
4
4
  "repository": {
5
5
  "type": "git",
6
6
  "url": "git+https://github.com/mantty/tokamak.git",
@@ -9,10 +9,11 @@
9
9
  "type": "module",
10
10
  "exports": {
11
11
  ".": "./src/index.ts",
12
- "./service-worker": "./service-worker/index.ts"
12
+ "./service-worker": "./service-worker/index.ts",
13
+ "./worker": "./worker/index.ts"
13
14
  },
14
15
  "dependencies": {
15
- "@tokamakdev/plugin": "0.1.0-beta.50"
16
+ "@tokamakdev/plugin": "0.1.0-beta.51"
16
17
  },
17
18
  "files": [
18
19
  "android",
@@ -20,6 +21,7 @@
20
21
  "service-worker",
21
22
  "src",
22
23
  "web",
24
+ "worker",
23
25
  "tokamak-plugin.json",
24
26
  "README.md"
25
27
  ],
package/src/index.ts CHANGED
@@ -18,11 +18,11 @@ export interface ScheduledNotification extends NotificationContent {
18
18
  }
19
19
 
20
20
  /** A received push message. A data-only message has no title or body. */
21
- export interface Message {
21
+ export interface Message<Data extends object = Record<string, unknown>> {
22
22
  readonly id: string;
23
23
  readonly title: string | null;
24
24
  readonly body: string | null;
25
- readonly data: Record<string, unknown>;
25
+ readonly data: Data;
26
26
  }
27
27
 
28
28
  export interface OpenedNotification extends Message {
@@ -0,0 +1,22 @@
1
+ import { acceptsRuntimeCalls } from "@tokamakdev/plugin/worker";
2
+
3
+ import type { Message, NotificationContent } from "../src/index.js";
4
+
5
+ /** Runs for a data-only push message and returns a notification to show, or nothing. */
6
+ export type PushHandler<Data extends object = Record<string, unknown>> =
7
+ | ((message: Message<Data>) => NotificationContent | undefined | Promise<NotificationContent | undefined>)
8
+ | ((message: Message<Data>) => void | Promise<void>);
9
+
10
+ /**
11
+ * Serves `POST /tokamak/push`: runs `handler` with the data-only push message the tokamak
12
+ * runtime delivers and responds with the notification it returns. Responds 404 outside a
13
+ * tokamak app and development.
14
+ */
15
+ export async function handlePushRequest<Data extends object = Record<string, unknown>>(
16
+ request: Request,
17
+ handler: PushHandler<Data>,
18
+ ): Promise<Response> {
19
+ if (!acceptsRuntimeCalls()) return new Response(null, { status: 404 });
20
+ const message = (await request.json()) as Message<Data>;
21
+ return Response.json((await handler(message)) ?? null);
22
+ }