@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 +46 -16
- package/android/NotificationsPlugin.kt +37 -10
- package/apple/NotificationsPlugin.swift +39 -14
- package/package.json +5 -3
- package/src/index.ts +2 -2
- package/worker/index.ts +22 -0
package/README.md
CHANGED
|
@@ -119,36 +119,66 @@ Every field is optional.
|
|
|
119
119
|
|
|
120
120
|
### Data-only messages
|
|
121
121
|
|
|
122
|
-
|
|
123
|
-
|
|
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
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
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
|
|
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
|
|
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
|
|
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.
|
|
116
|
-
*
|
|
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
|
-
|
|
125
|
-
|
|
126
|
-
.
|
|
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
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
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
|
|
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
|
-
///
|
|
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
|
-
|
|
156
|
+
deliverPush(fields.message, attempt: 1, deadline: Date() + pushDeadline) { result in
|
|
154
157
|
switch result {
|
|
155
|
-
case .success(
|
|
156
|
-
self.show(
|
|
157
|
-
case .
|
|
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
|
|
260
|
-
private func show(_
|
|
261
|
-
|
|
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)
|
|
266
|
-
|
|
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.
|
|
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.
|
|
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:
|
|
25
|
+
readonly data: Data;
|
|
26
26
|
}
|
|
27
27
|
|
|
28
28
|
export interface OpenedNotification extends Message {
|
package/worker/index.ts
ADDED
|
@@ -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
|
+
}
|