@drkostas/expo-ntfy 0.1.0

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/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Konstantinos Georgiou
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,101 @@
1
+ # @drkostas/expo-ntfy
2
+
3
+ This package gets messages from a self-hosted [ntfy](https://ntfy.sh) server to an Android phone running an Expo app, without Firebase. I wrote it because the JavaScript socket in my app stopped receiving the moment the phone locked, so notifications only reached a phone that someone was already looking at.
4
+
5
+ It has three parts.
6
+
7
+ - A config plugin that adds a native Android foreground service. The service holds the ntfy WebSocket while the app is closed and posts every message to the tray itself.
8
+ - The wire logic (`proto` and `poll`), with no Expo or React Native imports, so it runs in Node and is tested there.
9
+ - The Expo glue (`notify` and `background`) for permissions, local notifications, the socket while the app is open, taps, and a background poll.
10
+
11
+ ## Install
12
+
13
+ ```bash
14
+ npm install @drkostas/expo-ntfy
15
+ ```
16
+
17
+ It expects `expo`, `expo-notifications`, `react` and `react-native` in the app, and `expo-background-task` and `expo-task-manager` if you use the background poll.
18
+
19
+ ## The config plugin
20
+
21
+ ```json
22
+ {
23
+ "expo": {
24
+ "scheme": "myapp",
25
+ "android": { "package": "org.example.myapp" },
26
+ "plugins": [
27
+ ["@drkostas/expo-ntfy", { "alertChannelName": "My app" }]
28
+ ]
29
+ }
30
+ }
31
+ ```
32
+
33
+ The topic URL comes from `EXPO_PUBLIC_NTFY_URL` at prebuild time, or from the `url` option. There is no default address. The Android prebuild stops with an error when the URL is missing, is not http(s), or is a loopback address (localhost, 127.0.0.1, ::1), because a phone given that address connects to itself and never receives anything. Reading the app config (`expo start`, a web build) does not need the URL.
34
+
35
+ | option | default |
36
+ |---|---|
37
+ | `url` | the value of `urlEnv` |
38
+ | `urlEnv` | `EXPO_PUBLIC_NTFY_URL` |
39
+ | `package` | `expo.android.package` |
40
+ | `buildConfigField` | `NTFY_TOPIC_URL` |
41
+ | `defaultTitle` | `expo.name` |
42
+ | `linkPrefix` | `<expo.scheme>://`, or no links when there is no scheme |
43
+ | `alertChannelId`, `alertChannelName`, `alertChannelDescription` | `ntfy_messages`, `expo.name`, a short text |
44
+ | `ongoingChannelId`, `ongoingChannelName`, `ongoingChannelDescription` | `ntfy_connection`, `Connection`, a short text |
45
+ | `ongoingTitle`, `ongoingText` | `<expo.name> is connected`, `waiting for messages` |
46
+ | `logTag` | `NtfyService` |
47
+
48
+ What it writes at prebuild.
49
+
50
+ - `NtfyService.kt` and `BootReceiver` next to `MainActivity`, in the app's package.
51
+ - The URL as a BuildConfig field. It is replaced on every build, so building again with a different URL corrects a wrong one.
52
+ - A call in `MainActivity.onCreate` that starts the service.
53
+ - The manifest entries for the service (type `dataSync`), the receiver, and the permissions `FOREGROUND_SERVICE`, `FOREGROUND_SERVICE_DATA_SYNC`, `RECEIVE_BOOT_COMPLETED` and `WAKE_LOCK`.
54
+
55
+ A message's click URL opens the app only when it starts with `linkPrefix`. A notification is data from the network, so any other link is shown in the text but never followed.
56
+
57
+ ## The glue
58
+
59
+ ```ts
60
+ import { installForegroundHandler, raiseLocalNotification, useNtfy } from "@drkostas/expo-ntfy/notify";
61
+ import { defineTopicPollTask, registerTopicPoll } from "@drkostas/expo-ntfy/background";
62
+ import { pollTopicOnce } from "@drkostas/expo-ntfy";
63
+
64
+ const TOPIC = process.env.EXPO_PUBLIC_NTFY_URL;
65
+ const text = { appName: "My app", linkPrefix: "myapp://" };
66
+
67
+ installForegroundHandler();
68
+
69
+ // while a screen is open
70
+ const { connected } = useNtfy(TOPIC, () => refetch(), { text });
71
+
72
+ // while the app is closed, on a schedule the OS decides
73
+ defineTopicPollTask("topic-poll", () =>
74
+ pollTopicOnce({ topicUrl: TOPIC!, readMark, commitMark, text,
75
+ announce: (n) => raiseLocalNotification(n.title, n.body, n.url) }));
76
+ await registerTopicPoll("topic-poll", 15);
77
+ ```
78
+
79
+ ## Things I learned on real phones
80
+
81
+ - Only one part of the app may post to the tray. With the native service posting and JavaScript posting too, every message arrived twice. On Android the glue stays out of the tray by default (`TRAY_IS_NATIVE`), and keeps the in-app updates.
82
+ - Android 14 and later need the foreground service type in the `startForeground` call as well as in the manifest, or the service crashes when it starts.
83
+ - Installing an update stops the service, and nothing starts it again until someone opens the app. The receiver listens for `MY_PACKAGE_REPLACED` for that reason.
84
+ - Some Android skins (ColorOS on OPPO, for example) stop even a foreground service unless the app's battery setting allows background activity. The app cannot change that setting itself. The person has to choose "Allow background activity" in the app's battery settings.
85
+ - A device that has never watched the topic has not missed anything. On the first read the watermark takes the newest position instead of announcing the whole backlog.
86
+ - ntfy stamps whole seconds, so the watermark keeps the ids seen at its second, and no message from the same second is skipped or shown twice.
87
+ - ntfy parses `since` as a Go duration, which has no day unit. `7d` gets a 400, `24h` and `all` work.
88
+ - The background poll interval is a request. Android decides when it runs, so the poll is a floor under the socket and the service, not a replacement.
89
+
90
+ ## Tests
91
+
92
+ ```bash
93
+ npm ci
94
+ npm test
95
+ ```
96
+
97
+ The plugin test copies a fixture Android project to a temporary folder, runs the plugin through `@expo/config-plugins`, and reads the Kotlin, Gradle and manifest files it wrote.
98
+
99
+ ## License
100
+
101
+ MIT
package/app.plugin.js ADDED
@@ -0,0 +1 @@
1
+ module.exports = require("./plugin/withNtfyForegroundService");
package/package.json ADDED
@@ -0,0 +1,80 @@
1
+ {
2
+ "name": "@drkostas/expo-ntfy",
3
+ "version": "0.1.0",
4
+ "description": "Phone notifications from a self-hosted ntfy server in an Expo app, without Firebase: a config plugin that adds a native Android foreground service, plus the wire logic and the notification glue",
5
+ "license": "MIT",
6
+ "author": "Konstantinos Georgiou",
7
+ "repository": {
8
+ "type": "git",
9
+ "url": "git+https://github.com/drkostas/claude-human.git",
10
+ "directory": "js"
11
+ },
12
+ "homepage": "https://github.com/drkostas/claude-human/tree/main/js#readme",
13
+ "keywords": [
14
+ "expo",
15
+ "expo-config-plugin",
16
+ "ntfy",
17
+ "notifications",
18
+ "android",
19
+ "foreground-service",
20
+ "react-native"
21
+ ],
22
+ "main": "src/index.ts",
23
+ "types": "src/index.ts",
24
+ "react-native": "src/index.ts",
25
+ "exports": {
26
+ ".": "./src/index.ts",
27
+ "./proto": "./src/proto.ts",
28
+ "./poll": "./src/poll.ts",
29
+ "./notify": "./src/notify.ts",
30
+ "./background": "./src/background.ts",
31
+ "./plugin": "./app.plugin.js",
32
+ "./app.plugin.js": "./app.plugin.js",
33
+ "./package.json": "./package.json"
34
+ },
35
+ "files": [
36
+ "src",
37
+ "plugin",
38
+ "app.plugin.js",
39
+ "README.md",
40
+ "LICENSE"
41
+ ],
42
+ "scripts": {
43
+ "test": "vitest run",
44
+ "typecheck": "tsc --noEmit"
45
+ },
46
+ "peerDependencies": {
47
+ "expo": "*",
48
+ "expo-background-task": "*",
49
+ "expo-notifications": "*",
50
+ "expo-task-manager": "*",
51
+ "react": "*",
52
+ "react-native": "*"
53
+ },
54
+ "peerDependenciesMeta": {
55
+ "expo": {
56
+ "optional": true
57
+ },
58
+ "expo-background-task": {
59
+ "optional": true
60
+ },
61
+ "expo-notifications": {
62
+ "optional": true
63
+ },
64
+ "expo-task-manager": {
65
+ "optional": true
66
+ },
67
+ "react": {
68
+ "optional": true
69
+ },
70
+ "react-native": {
71
+ "optional": true
72
+ }
73
+ },
74
+ "devDependencies": {
75
+ "@expo/config-plugins": "^57.0.9",
76
+ "@types/node": "^22.20.5",
77
+ "typescript": "^5.6.0",
78
+ "vitest": "^2.1.0"
79
+ }
80
+ }
@@ -0,0 +1,413 @@
1
+ /**
2
+ * Expo config plugin that adds a native Android foreground service holding an ntfy WebSocket.
3
+ *
4
+ * Without Firebase, a socket opened from JavaScript lives only while the app is in the foreground:
5
+ * Android suspends it seconds after the screen locks. A foreground service can keep it open, which
6
+ * is what ntfy's own Android app does for self-hosted servers. This plugin writes a small Kotlin
7
+ * service that owns the socket and posts each message to the tray itself, a receiver that starts
8
+ * it again after a reboot or an app update, and the manifest entries they need.
9
+ *
10
+ * The source and the manifest changes are written at prebuild time, because `expo prebuild
11
+ * --clean` regenerates android/ and anything edited there by hand is lost.
12
+ *
13
+ * Options (all optional):
14
+ * url the topic URL (default: the environment variable named by urlEnv)
15
+ * urlEnv default "EXPO_PUBLIC_NTFY_URL"
16
+ * package Kotlin package and manifest namespace (default: expo.android.package)
17
+ * buildConfigField name of the BuildConfig field that carries the URL (default "NTFY_TOPIC_URL")
18
+ * defaultTitle title for a message without one (default: expo.name)
19
+ * linkPrefix a message's click URL opens the app only when it starts with this
20
+ * (default: "<expo.scheme>://", or no links when the app has no scheme)
21
+ * ongoingChannelId, ongoingChannelName, ongoingChannelDescription, ongoingTitle, ongoingText
22
+ * the low-importance channel and notification a foreground service must show
23
+ * alertChannelId, alertChannelName, alertChannelDescription
24
+ * the channel messages are posted on
25
+ * logTag Android log tag (default "NtfyService")
26
+ *
27
+ * The Android prebuild fails when the URL is missing or points at the build machine itself (localhost,
28
+ * 127.0.0.1, ::1): a phone given that address connects to itself and never receives anything.
29
+ */
30
+ const fs = require("fs");
31
+ const path = require("path");
32
+
33
+ function loadConfigPlugins(config) {
34
+ const roots = [config && config._internal && config._internal.projectRoot, process.cwd(), __dirname]
35
+ .filter(Boolean);
36
+ for (const name of ["expo/config-plugins", "@expo/config-plugins"]) {
37
+ try {
38
+ return require(require.resolve(name, { paths: roots }));
39
+ } catch (e) {
40
+ // try the next one
41
+ }
42
+ }
43
+ throw new Error("expo-ntfy: cannot find expo/config-plugins. Install expo in the app.");
44
+ }
45
+
46
+ function isLoopbackHost(host) {
47
+ const h = String(host || "").replace(/^\[|\]$/g, "").toLowerCase();
48
+ return h === "" || h === "localhost" || h.endsWith(".localhost") || h === "::1" || h === "0.0.0.0"
49
+ || /^127\.\d+\.\d+\.\d+$/.test(h);
50
+ }
51
+
52
+ /** The topic URL, checked. Throws when it is missing, invalid or loopback. */
53
+ function checkTopicUrl(url, source) {
54
+ let parsed = null;
55
+ try {
56
+ parsed = new URL(url);
57
+ } catch (e) {
58
+ parsed = null;
59
+ }
60
+ if (!url) {
61
+ throw new Error(`expo-ntfy: no ntfy topic URL. Set ${source} or pass the "url" option.`);
62
+ }
63
+ if (!parsed || !/^https?:$/.test(parsed.protocol)) {
64
+ throw new Error(`expo-ntfy: ${JSON.stringify(url)} is not an http(s) URL.`);
65
+ }
66
+ if (isLoopbackHost(parsed.hostname)) {
67
+ throw new Error(`expo-ntfy: refusing to build with ntfy at ${url}. A phone cannot reach the ` +
68
+ "build machine as localhost. Use an address of the ntfy server that the phone can reach.");
69
+ }
70
+ if (/["\\$\s]/.test(url)) {
71
+ throw new Error(`expo-ntfy: the topic URL contains a character that cannot go into BuildConfig: ${url}`);
72
+ }
73
+ return url;
74
+ }
75
+
76
+ function resolveOptions(config, options = {}, env = process.env) {
77
+ const name = config.name || "App";
78
+ const urlEnv = options.urlEnv || "EXPO_PUBLIC_NTFY_URL";
79
+ const pkg = options.package || (config.android && config.android.package);
80
+ if (!pkg || !/^[A-Za-z_][A-Za-z0-9_]*(\.[A-Za-z_][A-Za-z0-9_]*)+$/.test(pkg)) {
81
+ throw new Error(`expo-ntfy: set expo.android.package or the "package" option (got ${JSON.stringify(pkg)})`);
82
+ }
83
+ const scheme = Array.isArray(config.scheme) ? config.scheme[0] : config.scheme;
84
+ const o = {
85
+ url: checkTopicUrl(options.url || env[urlEnv], options.url ? 'the "url" option' : urlEnv),
86
+ package: pkg,
87
+ buildConfigField: options.buildConfigField || "NTFY_TOPIC_URL",
88
+ defaultTitle: options.defaultTitle || name,
89
+ linkPrefix: options.linkPrefix !== undefined ? options.linkPrefix : (scheme ? `${scheme}://` : ""),
90
+ ongoingChannelId: options.ongoingChannelId || "ntfy_connection",
91
+ ongoingChannelName: options.ongoingChannelName || "Connection",
92
+ ongoingChannelDescription: options.ongoingChannelDescription ||
93
+ "Keeps the app connected so messages arrive while it is closed",
94
+ ongoingTitle: options.ongoingTitle || `${name} is connected`,
95
+ ongoingText: options.ongoingText || "waiting for messages",
96
+ alertChannelId: options.alertChannelId || "ntfy_messages",
97
+ alertChannelName: options.alertChannelName || name,
98
+ alertChannelDescription: options.alertChannelDescription || "Messages from the server",
99
+ logTag: options.logTag || "NtfyService",
100
+ };
101
+ if (!/^[A-Z_][A-Z0-9_]*$/.test(o.buildConfigField)) {
102
+ throw new Error(`expo-ntfy: buildConfigField must be an upper case identifier (got ${o.buildConfigField})`);
103
+ }
104
+ for (const k of ["ongoingChannelId", "alertChannelId"]) {
105
+ if (!/^[A-Za-z0-9_.-]+$/.test(o[k])) throw new Error(`expo-ntfy: invalid ${k} ${JSON.stringify(o[k])}`);
106
+ }
107
+ return o;
108
+ }
109
+
110
+ /** A Kotlin string literal. */
111
+ function kt(s) {
112
+ return '"' + String(s).replace(/\\/g, "\\\\").replace(/"/g, '\\"').replace(/\$/g, "\\$")
113
+ .replace(/\n/g, "\\n") + '"';
114
+ }
115
+
116
+ function renderKotlin(o) {
117
+ const linkCheck = o.linkPrefix
118
+ ? `click.startsWith(${kt(o.linkPrefix)})`
119
+ : "false";
120
+ return `package ${o.package}
121
+
122
+ import android.app.*
123
+ import android.content.*
124
+ import android.os.Build
125
+ import android.os.IBinder
126
+ import android.util.Log
127
+ import androidx.core.app.NotificationCompat
128
+ import okhttp3.*
129
+ import org.json.JSONObject
130
+ import java.util.concurrent.TimeUnit
131
+
132
+ /**
133
+ * Holds the ntfy topic's WebSocket open natively, so messages arrive while the phone is locked and
134
+ * the JavaScript side is suspended. It posts what it receives to the tray itself and does not talk
135
+ * to JavaScript. Written by the expo-ntfy config plugin; regenerated on every prebuild.
136
+ */
137
+ class NtfyService : Service() {
138
+ companion object {
139
+ const val ONGOING_CHANNEL = ${kt(o.ongoingChannelId)}
140
+ const val ALERT_CHANNEL = ${kt(o.alertChannelId)}
141
+ const val ONGOING_ID = 4711
142
+ private const val TAG = ${kt(o.logTag)}
143
+ var topicUrl: String = BuildConfig.${o.buildConfigField}
144
+ }
145
+
146
+ private var client: OkHttpClient? = null
147
+ private var ws: WebSocket? = null
148
+ private var stopping = false
149
+
150
+ override fun onBind(intent: Intent?): IBinder? = null
151
+
152
+ override fun onCreate() {
153
+ super.onCreate()
154
+ createChannels()
155
+ // Android 14 and later throw unless the service type is also given here, not only in the
156
+ // manifest.
157
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) {
158
+ startForeground(
159
+ ONGOING_ID, ongoingNotification(),
160
+ android.content.pm.ServiceInfo.FOREGROUND_SERVICE_TYPE_DATA_SYNC
161
+ )
162
+ } else {
163
+ startForeground(ONGOING_ID, ongoingNotification())
164
+ }
165
+ connect()
166
+ }
167
+
168
+ override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int = START_STICKY
169
+
170
+ override fun onDestroy() {
171
+ stopping = true
172
+ ws?.close(1000, "service stopping")
173
+ super.onDestroy()
174
+ }
175
+
176
+ private fun createChannels() {
177
+ if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) return
178
+ val mgr = getSystemService(NotificationManager::class.java)
179
+ // The connection notice is required for a foreground service. It is not a message, so its
180
+ // channel has the lowest importance.
181
+ mgr.createNotificationChannel(
182
+ NotificationChannel(ONGOING_CHANNEL, ${kt(o.ongoingChannelName)}, NotificationManager.IMPORTANCE_MIN)
183
+ .apply { description = ${kt(o.ongoingChannelDescription)} }
184
+ )
185
+ mgr.createNotificationChannel(
186
+ NotificationChannel(ALERT_CHANNEL, ${kt(o.alertChannelName)}, NotificationManager.IMPORTANCE_HIGH)
187
+ .apply { description = ${kt(o.alertChannelDescription)} }
188
+ )
189
+ }
190
+
191
+ private fun ongoingNotification(): Notification =
192
+ NotificationCompat.Builder(this, ONGOING_CHANNEL)
193
+ .setContentTitle(${kt(o.ongoingTitle)})
194
+ .setContentText(${kt(o.ongoingText)})
195
+ .setSmallIcon(applicationInfo.icon)
196
+ .setOngoing(true)
197
+ .setPriority(NotificationCompat.PRIORITY_MIN)
198
+ .setContentIntent(
199
+ PendingIntent.getActivity(
200
+ this, 0,
201
+ packageManager.getLaunchIntentForPackage(packageName),
202
+ PendingIntent.FLAG_IMMUTABLE
203
+ )
204
+ )
205
+ .build()
206
+
207
+ private fun wsUrl(): String {
208
+ var base = topicUrl.trimEnd('/')
209
+ base = base.replaceFirst("https://", "wss://").replaceFirst("http://", "ws://")
210
+ return base + "/ws"
211
+ }
212
+
213
+ private fun connect() {
214
+ if (stopping) return
215
+ val c = OkHttpClient.Builder()
216
+ .readTimeout(0, TimeUnit.MILLISECONDS)
217
+ .pingInterval(45, TimeUnit.SECONDS) // keeps NAT mappings and tunnels open
218
+ .build()
219
+ client = c
220
+ ws = c.newWebSocket(Request.Builder().url(wsUrl()).build(), object : WebSocketListener() {
221
+ override fun onMessage(webSocket: WebSocket, text: String) = handle(text)
222
+ override fun onFailure(webSocket: WebSocket, t: Throwable, response: Response?) {
223
+ Log.w(TAG, "socket failed: " + t.message)
224
+ reconnectSoon()
225
+ }
226
+ override fun onClosed(webSocket: WebSocket, code: Int, reason: String) = reconnectSoon()
227
+ })
228
+ }
229
+
230
+ private fun reconnectSoon() {
231
+ if (stopping) return
232
+ Thread {
233
+ Thread.sleep(5000)
234
+ if (!stopping) connect()
235
+ }.start()
236
+ }
237
+
238
+ /** Only message frames are shown. Open and keepalive frames are not for a person. */
239
+ private fun handle(text: String) {
240
+ try {
241
+ val f = JSONObject(text)
242
+ if (f.optString("event") != "message") return
243
+ val title = f.optString("title").ifBlank { ${kt(o.defaultTitle)} }
244
+ val body = f.optString("message").ifBlank { title }
245
+ val click = f.optString("click")
246
+ val open = packageManager.getLaunchIntentForPackage(packageName)?.apply {
247
+ // only links into this app are followed; a link from the network is data
248
+ if (${linkCheck}) {
249
+ action = Intent.ACTION_VIEW
250
+ data = android.net.Uri.parse(click)
251
+ }
252
+ }
253
+ val n = NotificationCompat.Builder(this, ALERT_CHANNEL)
254
+ .setContentTitle(title)
255
+ .setContentText(body)
256
+ .setStyle(NotificationCompat.BigTextStyle().bigText(body))
257
+ .setSmallIcon(applicationInfo.icon)
258
+ .setAutoCancel(true)
259
+ .setPriority(NotificationCompat.PRIORITY_HIGH)
260
+ .apply {
261
+ if (open != null) setContentIntent(
262
+ PendingIntent.getActivity(
263
+ this@NtfyService, click.hashCode(), open,
264
+ PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT
265
+ )
266
+ )
267
+ }
268
+ .build()
269
+ getSystemService(NotificationManager::class.java)
270
+ .notify(f.optString("id").hashCode(), n)
271
+ } catch (e: Exception) {
272
+ Log.w(TAG, "bad frame: " + e.message)
273
+ }
274
+ }
275
+ }
276
+
277
+ /**
278
+ * Starts the service after a reboot and after the app is updated. Installing a new version stops
279
+ * the app and its services, and without MY_PACKAGE_REPLACED nothing would start the service again
280
+ * until someone opened the app, so messages would stop arriving after every update.
281
+ */
282
+ class BootReceiver : BroadcastReceiver() {
283
+ override fun onReceive(context: Context, intent: Intent) {
284
+ if (intent.action == Intent.ACTION_BOOT_COMPLETED ||
285
+ intent.action == Intent.ACTION_MY_PACKAGE_REPLACED) {
286
+ val i = Intent(context, NtfyService::class.java)
287
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) context.startForegroundService(i)
288
+ else context.startService(i)
289
+ }
290
+ }
291
+ }
292
+ `;
293
+ }
294
+
295
+ const DEPS_MARKER = "expo-ntfy-deps";
296
+
297
+ /** app/build.gradle with the dependencies and the BuildConfig field. The field is replaced on every
298
+ * build, so building again with a different URL corrects it. */
299
+ function patchAppGradle(src, o) {
300
+ let g = src;
301
+ if (!g.includes(DEPS_MARKER)) {
302
+ g = g.replace(/dependencies\s*\{/, `dependencies {
303
+ // ${DEPS_MARKER}
304
+ implementation("com.squareup.okhttp3:okhttp:4.12.0")
305
+ implementation("androidx.core:core-ktx:1.13.1")`);
306
+ }
307
+ const field = `buildConfigField("String", "${o.buildConfigField}", "\\"${o.url}\\"")`;
308
+ const existing = new RegExp(`buildConfigField\\("String", "${o.buildConfigField}", "\\\\"[^"\\\\]*\\\\""\\)`);
309
+ if (existing.test(g)) {
310
+ g = g.replace(existing, field);
311
+ } else {
312
+ g = g.replace(/defaultConfig\s*\{/, `defaultConfig {\n ${field}`);
313
+ if (/buildFeatures\s*\{/.test(g)) {
314
+ if (!/buildConfig\s+true/.test(g)) g = g.replace(/buildFeatures\s*\{/, "buildFeatures {\n buildConfig true");
315
+ } else {
316
+ g = g.replace(/android\s*\{/, "android {\n buildFeatures { buildConfig true }");
317
+ }
318
+ }
319
+ return g;
320
+ }
321
+
322
+ /** MainActivity.kt starting the service in onCreate, or the service would only run after a reboot. */
323
+ function patchMainActivity(src) {
324
+ if (src.includes("NtfyService::class.java")) return src;
325
+ return src.replace(/(super\.onCreate\([^)]*\))/, `$1
326
+ // hold the ntfy socket natively (expo-ntfy): the JavaScript socket stops when the screen locks
327
+ try {
328
+ val svc = android.content.Intent(this, NtfyService::class.java)
329
+ if (android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.O)
330
+ startForegroundService(svc) else startService(svc)
331
+ } catch (e: Exception) { android.util.Log.w("NtfyService", "start: " + e.message) }`);
332
+ }
333
+
334
+ const PERMISSIONS = [
335
+ "android.permission.FOREGROUND_SERVICE",
336
+ "android.permission.FOREGROUND_SERVICE_DATA_SYNC",
337
+ "android.permission.RECEIVE_BOOT_COMPLETED",
338
+ "android.permission.WAKE_LOCK",
339
+ ];
340
+
341
+ /** The manifest (as Expo's parsed XML object) with the permissions, the service and the receiver. */
342
+ function addManifestEntries(manifest, app) {
343
+ manifest["uses-permission"] = manifest["uses-permission"] || [];
344
+ for (const name of PERMISSIONS) {
345
+ if (!manifest["uses-permission"].some((p) => p.$["android:name"] === name)) {
346
+ manifest["uses-permission"].push({ $: { "android:name": name } });
347
+ }
348
+ }
349
+ app.service = app.service || [];
350
+ if (!app.service.some((s) => s.$["android:name"] === ".NtfyService")) {
351
+ app.service.push({
352
+ $: {
353
+ "android:name": ".NtfyService",
354
+ "android:exported": "false",
355
+ "android:foregroundServiceType": "dataSync",
356
+ },
357
+ });
358
+ }
359
+ app.receiver = app.receiver || [];
360
+ if (!app.receiver.some((r) => r.$["android:name"] === ".BootReceiver")) {
361
+ app.receiver.push({
362
+ $: { "android:name": ".BootReceiver", "android:exported": "true" },
363
+ "intent-filter": [
364
+ { action: [{ $: { "android:name": "android.intent.action.BOOT_COMPLETED" } }] },
365
+ { action: [{ $: { "android:name": "android.intent.action.MY_PACKAGE_REPLACED" } }] },
366
+ ],
367
+ });
368
+ }
369
+ return manifest;
370
+ }
371
+
372
+ function withNtfyForegroundService(config, options = {}) {
373
+ const { withDangerousMod, withAndroidManifest, AndroidConfig } = loadConfigPlugins(config);
374
+
375
+ config = withDangerousMod(config, [
376
+ "android",
377
+ (cfg) => {
378
+ // Resolved here and not when the plugin is loaded, so that reading the app config (expo
379
+ // start, the web build) never needs the phone's ntfy address.
380
+ const o = resolveOptions(cfg, options);
381
+ const root = cfg.modRequest.platformProjectRoot;
382
+ const dir = path.join(root, "app/src/main/java", ...o.package.split("."));
383
+ fs.mkdirSync(dir, { recursive: true });
384
+ fs.writeFileSync(path.join(dir, "NtfyService.kt"), renderKotlin(o));
385
+
386
+ const gradle = path.join(root, "app/build.gradle");
387
+ fs.writeFileSync(gradle, patchAppGradle(fs.readFileSync(gradle, "utf8"), o));
388
+
389
+ const mainActivity = path.join(dir, "MainActivity.kt");
390
+ if (fs.existsSync(mainActivity)) {
391
+ fs.writeFileSync(mainActivity, patchMainActivity(fs.readFileSync(mainActivity, "utf8")));
392
+ }
393
+ return cfg;
394
+ },
395
+ ]);
396
+
397
+ config = withAndroidManifest(config, (cfg) => {
398
+ const app = AndroidConfig.Manifest.getMainApplicationOrThrow(cfg.modResults);
399
+ addManifestEntries(cfg.modResults.manifest, app);
400
+ return cfg;
401
+ });
402
+ return config;
403
+ }
404
+
405
+ module.exports = withNtfyForegroundService;
406
+ module.exports.withNtfyForegroundService = withNtfyForegroundService;
407
+ module.exports.resolveOptions = resolveOptions;
408
+ module.exports.checkTopicUrl = checkTopicUrl;
409
+ module.exports.isLoopbackHost = isLoopbackHost;
410
+ module.exports.renderKotlin = renderKotlin;
411
+ module.exports.patchAppGradle = patchAppGradle;
412
+ module.exports.patchMainActivity = patchMainActivity;
413
+ module.exports.addManifestEntries = addManifestEntries;
@@ -0,0 +1,37 @@
1
+ /** Read the topic while the app is not open, on a schedule the OS controls.
2
+ *
3
+ * The foreground socket in notify.ts stops when the app is backgrounded. This registers a
4
+ * background task that runs `pollTopicOnce` (poll.ts) on each wake. The interval is a request to
5
+ * the OS, not a promise: Android decides, and aggressive battery managers can defer it for a long
6
+ * time. It is a floor under the live socket and the native service, not a replacement for them.
7
+ *
8
+ * `defineTopicPollTask` must run at module top level (Expo's TaskManager requires tasks to be
9
+ * defined in the global scope), so call it from a module the app imports at startup. */
10
+ import * as BackgroundTask from "expo-background-task";
11
+ import * as TaskManager from "expo-task-manager";
12
+ import { Platform } from "react-native";
13
+
14
+ /** Define the task `name` to run `pass` on every OS wake. */
15
+ export function defineTopicPollTask(name: string, pass: () => Promise<unknown>): void {
16
+ TaskManager.defineTask(name, async () => {
17
+ try {
18
+ await pass();
19
+ return BackgroundTask.BackgroundTaskResult.Success;
20
+ } catch {
21
+ return BackgroundTask.BackgroundTaskResult.Failed;
22
+ }
23
+ });
24
+ }
25
+
26
+ /** Ask the OS to wake the task about every `minutes`. False on web, or when registration fails. */
27
+ export async function registerTopicPoll(name: string, minutes = 15): Promise<boolean> {
28
+ if (Platform.OS === "web") return false;
29
+ try {
30
+ if (!(await TaskManager.isTaskRegisteredAsync(name))) {
31
+ await BackgroundTask.registerTaskAsync(name, { minimumInterval: minutes });
32
+ }
33
+ return true;
34
+ } catch {
35
+ return false;
36
+ }
37
+ }
package/src/index.ts ADDED
@@ -0,0 +1,5 @@
1
+ /** The parts that run anywhere (Node included). The Expo glue is imported from
2
+ * "@drkostas/expo-ntfy/notify" and "@drkostas/expo-ntfy/background", and the config plugin is
3
+ * listed in app.json as "@drkostas/expo-ntfy". */
4
+ export * from "./proto";
5
+ export * from "./poll";
package/src/notify.ts ADDED
@@ -0,0 +1,176 @@
1
+ /** Expo and React Native glue that turns an ntfy topic into notifications on this device, without
2
+ * Firebase. The app holds its own WebSocket to the topic while it is open and raises a local
3
+ * notification per message. The wire logic is in proto.ts.
4
+ *
5
+ * On Android the config plugin adds a native foreground service that holds the socket while the
6
+ * app is closed and posts to the tray itself. Only one part of the app may own the tray, or every
7
+ * message appears twice, so on Android this module stays out of the tray by default (see
8
+ * TRAY_IS_NATIVE). */
9
+ import { useEffect, useRef, useState } from "react";
10
+ import { Linking, Platform } from "react-native";
11
+ import * as Notifications from "expo-notifications";
12
+
13
+ import { frameToNotification, parseFrame, wsUrlFrom, type NotificationText, type NtfyFrame } from "./proto";
14
+
15
+ /** True on Android, where the plugin's native service posts every message. Set the `trayIsNative`
16
+ * option to false if the app does not use the config plugin. */
17
+ export const TRAY_IS_NATIVE = Platform.OS === "android";
18
+
19
+ /** Show notifications that arrive while the app is in the foreground. Call once at startup. */
20
+ export function installForegroundHandler(): void {
21
+ if (Platform.OS === "web") return;
22
+ Notifications.setNotificationHandler({
23
+ handleNotification: async () => ({
24
+ shouldShowBanner: true,
25
+ shouldShowList: true,
26
+ shouldPlaySound: true,
27
+ shouldSetBadge: false,
28
+ }),
29
+ });
30
+ }
31
+
32
+ /** Ask for notification permission once. Always false on web, which has no OS tray here. */
33
+ export async function ensureNotificationPermission(): Promise<boolean> {
34
+ if (Platform.OS === "web") return false;
35
+ const cur = await Notifications.getPermissionsAsync();
36
+ if (cur.granted) return true;
37
+ const req = await Notifications.requestPermissionsAsync();
38
+ return req.granted;
39
+ }
40
+
41
+ /** Raise a local notification now. `url` rides in its data, so a tap can open it. Does nothing on
42
+ * web, and nothing on Android while the native service owns the tray. */
43
+ export async function raiseLocalNotification(
44
+ title: string,
45
+ body: string,
46
+ url?: string,
47
+ opts: { trayIsNative?: boolean } = {},
48
+ ): Promise<void> {
49
+ if (Platform.OS === "web") return;
50
+ if (opts.trayIsNative ?? TRAY_IS_NATIVE) return;
51
+ await Notifications.scheduleNotificationAsync({
52
+ content: { title, body, data: url ? { url } : {} },
53
+ trigger: null,
54
+ });
55
+ }
56
+
57
+ export interface ResponseHandlers {
58
+ /** a notification raised by this module was tapped. `url` is its click URL, if it had one. */
59
+ onTap?: (url: string | undefined) => void;
60
+ /** the app was opened by a link (the native service opens the app with the click URL as a deep
61
+ * link), including the link that launched it from cold */
62
+ onLink?: (url: string) => void;
63
+ }
64
+
65
+ /** Listen for taps on notifications and for incoming links, including the ones that launched the
66
+ * app from cold, which are read once at startup. */
67
+ export function useNotificationResponses(handlers: ResponseHandlers): void {
68
+ const ref = useRef(handlers);
69
+ useEffect(() => {
70
+ ref.current = handlers;
71
+ }, [handlers]);
72
+ useEffect(() => {
73
+ if (Platform.OS === "web") return;
74
+ let alive = true;
75
+ const handle = (resp: Notifications.NotificationResponse | null) => {
76
+ if (!alive || !resp) return;
77
+ ref.current.onTap?.(resp.notification.request.content.data?.url as string | undefined);
78
+ };
79
+ void Notifications.getLastNotificationResponseAsync().then(handle);
80
+ const sub = Notifications.addNotificationResponseReceivedListener(handle);
81
+ void Linking.getInitialURL().then((u) => {
82
+ if (alive && u) ref.current.onLink?.(u);
83
+ });
84
+ const link = Linking.addEventListener("url", (e) => {
85
+ if (alive) ref.current.onLink?.(e.url);
86
+ });
87
+ return () => {
88
+ alive = false;
89
+ sub.remove();
90
+ link.remove();
91
+ };
92
+ }, []);
93
+ }
94
+
95
+ export interface NtfyState {
96
+ connected: boolean;
97
+ last: NtfyFrame | null;
98
+ }
99
+
100
+ export interface UseNtfyOptions {
101
+ /** how a frame reads as a notification */
102
+ text?: NotificationText;
103
+ /** see raiseLocalNotification */
104
+ trayIsNative?: boolean;
105
+ /** delay before reconnecting a dropped socket, in ms (default 3000) */
106
+ retryMs?: number;
107
+ }
108
+
109
+ /** Hold the topic's WebSocket open while the component is mounted, reconnecting when it drops.
110
+ * Each message raises a local notification and calls `onMessage`. With no `topicUrl` nothing
111
+ * connects.
112
+ *
113
+ * This socket lives only while the app is in the foreground. Android suspends it seconds after
114
+ * the app is backgrounded, which is why the native service and the background poll exist. */
115
+ export function useNtfy(
116
+ topicUrl: string | undefined,
117
+ onMessage?: (f: NtfyFrame) => void,
118
+ opts: UseNtfyOptions = {},
119
+ ): NtfyState {
120
+ const [connected, setConnected] = useState(false);
121
+ const [last, setLast] = useState<NtfyFrame | null>(null);
122
+ const cb = useRef(onMessage);
123
+ const options = useRef(opts);
124
+ useEffect(() => {
125
+ cb.current = onMessage;
126
+ options.current = opts;
127
+ });
128
+
129
+ useEffect(() => {
130
+ if (!topicUrl) return;
131
+ let closed = false;
132
+ let ws: WebSocket | null = null;
133
+ let retry: ReturnType<typeof setTimeout> | undefined;
134
+
135
+ void ensureNotificationPermission();
136
+
137
+ const connect = () => {
138
+ if (closed) return;
139
+ ws = new WebSocket(wsUrlFrom(topicUrl));
140
+ ws.onopen = () => setConnected(true);
141
+ ws.onclose = () => {
142
+ setConnected(false);
143
+ if (!closed) retry = setTimeout(connect, options.current.retryMs ?? 3000);
144
+ };
145
+ ws.onerror = () => {
146
+ try {
147
+ ws?.close();
148
+ } catch {
149
+ // onclose reconnects
150
+ }
151
+ };
152
+ ws.onmessage = (ev) => {
153
+ const raw = (ev as { data?: unknown }).data;
154
+ const frame = parseFrame(typeof raw === "string" ? raw : "");
155
+ if (!frame) return;
156
+ setLast(frame);
157
+ const n = frameToNotification(frame, options.current.text);
158
+ void raiseLocalNotification(n.title, n.body, n.url, { trayIsNative: options.current.trayIsNative });
159
+ cb.current?.(frame);
160
+ };
161
+ };
162
+
163
+ connect();
164
+ return () => {
165
+ closed = true;
166
+ if (retry) clearTimeout(retry);
167
+ try {
168
+ ws?.close();
169
+ } catch {
170
+ // unmounting
171
+ }
172
+ };
173
+ }, [topicUrl]);
174
+
175
+ return { connected, last };
176
+ }
package/src/poll.ts ADDED
@@ -0,0 +1,52 @@
1
+ /** One read of the topic's backlog, announcing what is new since a watermark.
2
+ *
3
+ * This has no Expo or React Native imports. background.ts runs it from an OS-scheduled task, and
4
+ * an app can run the same pass when it returns to the foreground. */
5
+ import {
6
+ frameToNotification,
7
+ newSince,
8
+ parseBacklog,
9
+ pollUrlFrom,
10
+ ZERO_WATERMARK,
11
+ type NotificationText,
12
+ type NtfyFrame,
13
+ type Watermark,
14
+ } from "./proto";
15
+
16
+ export interface PollOptions {
17
+ /** the topic URL, for example https://ntfy.example.org/alerts */
18
+ topicUrl: string;
19
+ /** the stored position. A number is a time in seconds; null means "never watched". */
20
+ readMark: () => Promise<Watermark | number | null>;
21
+ /** store the new position. Called once per pass, after every message was announced. */
22
+ commitMark?: (mark: Watermark) => Promise<void>;
23
+ /** show one message, for example with raiseLocalNotification */
24
+ announce: (n: { title: string; body: string; url?: string }, frame: NtfyFrame) => Promise<void>;
25
+ /** how a frame reads as a notification */
26
+ text?: NotificationText;
27
+ /** backlog window, a Go duration or "all" (default "all") */
28
+ since?: string;
29
+ fetch?: typeof fetch;
30
+ }
31
+
32
+ /** Read the backlog once, announce the new messages oldest first, then commit the watermark.
33
+ * Returns how many were announced.
34
+ *
35
+ * The mark is committed after the read, never before. Either order leaves a gap of a few
36
+ * milliseconds around the read. Committing after can miss a message published inside it, while
37
+ * committing before would show it twice, and the live socket covers the gap anyway. */
38
+ export async function pollTopicOnce(o: PollOptions): Promise<number> {
39
+ const stored = await o.readMark();
40
+ const mark: Watermark =
41
+ stored == null ? ZERO_WATERMARK : typeof stored === "number" ? { time: stored, idsAtTime: [] } : stored;
42
+ const get = o.fetch ?? fetch;
43
+ const r = await get(pollUrlFrom(o.topicUrl, o.since));
44
+ if (!r.ok) throw new Error(`HTTP ${r.status}`);
45
+ const frames = parseBacklog(await r.text());
46
+ const next = newSince(frames, mark);
47
+ for (const f of next.fresh.slice().reverse()) {
48
+ await o.announce(frameToNotification(f, o.text), f);
49
+ }
50
+ await o.commitMark?.(next.mark);
51
+ return next.fresh.length;
52
+ }
package/src/proto.ts ADDED
@@ -0,0 +1,158 @@
1
+ /** The wire logic of subscribing to a self-hosted ntfy topic.
2
+ *
3
+ * Nothing here imports React, Expo or React Native, so it runs and is tested in plain Node. The
4
+ * device glue (permissions, local notifications, the socket lifecycle) is in notify.ts. */
5
+
6
+ /** One frame from ntfy's WebSocket or JSON stream. ntfy sends open, keepalive and message frames.
7
+ * Only message frames carry something a person should see. */
8
+ export interface NtfyFrame {
9
+ id: string;
10
+ time?: number;
11
+ event: "open" | "keepalive" | "message" | "poll_request" | string;
12
+ topic?: string;
13
+ message?: string;
14
+ title?: string;
15
+ priority?: number;
16
+ tags?: string[];
17
+ /** ntfy's Click: where the message is about, often a deep link into the app */
18
+ click?: string;
19
+ /** ntfy's Attach (a URL) or an uploaded attachment */
20
+ attach?: string;
21
+ /** file name of the attachment, when ntfy sent one */
22
+ filename?: string;
23
+ /** ntfy's Actions: view (open a URL), http (call an endpoint), broadcast */
24
+ actions?: NtfyAction[];
25
+ }
26
+
27
+ export interface NtfyAction {
28
+ action: "view" | "http" | "broadcast" | string;
29
+ label: string;
30
+ url?: string;
31
+ method?: string;
32
+ body?: string;
33
+ headers?: Record<string, string>;
34
+ clear?: boolean;
35
+ }
36
+
37
+ /** The topic's backlog URL. `since` is parsed as a Go duration, which has no day unit: "7d" makes
38
+ * the server answer 400, while "24h", "all" or a Unix time work. */
39
+ export function pollUrlFrom(topicUrl: string, since = "all"): string {
40
+ return `${topicUrl.replace(/\/+$/, "")}/json?poll=1&since=${since}`;
41
+ }
42
+
43
+ /** https://host/topic -> wss://host/topic/ws (and http -> ws). */
44
+ export function wsUrlFrom(topicUrl: string): string {
45
+ const base = topicUrl.replace(/\/+$/, "");
46
+ return base.replace(/^http(s?):/, "ws$1:") + "/ws";
47
+ }
48
+
49
+ /** One raw frame, or null. Only `message` frames are returned (never open or keepalive), and
50
+ * invalid JSON gives null, so one bad frame cannot stop a socket loop. */
51
+ export function parseFrame(raw: string): NtfyFrame | null {
52
+ let f: NtfyFrame;
53
+ try {
54
+ f = JSON.parse(raw) as NtfyFrame;
55
+ } catch {
56
+ return null;
57
+ }
58
+ if (!f || f.event !== "message") return null;
59
+ return f;
60
+ }
61
+
62
+ /** ndjson (one frame per line) -> the message frames, newest first. */
63
+ export function parseBacklog(body: string): NtfyFrame[] {
64
+ return body
65
+ .split("\n")
66
+ .map((l) => parseFrame(l))
67
+ .filter((f): f is NtfyFrame => f !== null)
68
+ .reverse();
69
+ }
70
+
71
+ export interface NotificationText {
72
+ /** title used when the frame has none (default "Notification") */
73
+ appName?: string;
74
+ /** body used when the frame has neither a message nor a title */
75
+ emptyBody?: string;
76
+ /** a click URL is kept only when it starts with this prefix (for example "myapp://").
77
+ * Without a prefix no click URL is kept. A notification is data from the network, and a link
78
+ * in it should not be followed unless it points into the app. */
79
+ linkPrefix?: string;
80
+ }
81
+
82
+ /** How a message frame should read in the notification tray. */
83
+ export function frameToNotification(
84
+ f: NtfyFrame,
85
+ opts: NotificationText = {},
86
+ ): { title: string; body: string; url?: string } {
87
+ const appName = opts.appName ?? "Notification";
88
+ const title = (f.title && f.title.trim()) || appName;
89
+ const body = (f.message && f.message.trim()) || f.title || opts.emptyBody || appName;
90
+ const url = opts.linkPrefix && f.click && f.click.startsWith(opts.linkPrefix) ? f.click : undefined;
91
+ return url ? { title, body, url } : { title, body };
92
+ }
93
+
94
+ /** How far through the topic a device has already announced.
95
+ *
96
+ * A timestamp alone is not enough, because ntfy stamps whole seconds and several messages can
97
+ * share one. The ids seen at that second are kept too, so none is skipped or shown twice. */
98
+ export interface Watermark {
99
+ time: number;
100
+ idsAtTime: string[];
101
+ }
102
+
103
+ export const ZERO_WATERMARK: Watermark = { time: 0, idsAtTime: [] };
104
+
105
+ /** The frames not announced yet, and the watermark after them.
106
+ *
107
+ * On a zero watermark nothing is announced and the position is adopted instead. A device that
108
+ * never watched has not missed anything, and announcing the topic's whole history on first launch
109
+ * would bury the person in old messages. */
110
+ export function newSince(
111
+ frames: NtfyFrame[],
112
+ mark: Watermark,
113
+ ): { fresh: NtfyFrame[]; mark: Watermark } {
114
+ const at = (f: NtfyFrame) => f.time ?? 0;
115
+ if (mark.time === 0 && mark.idsAtTime.length === 0) {
116
+ const maxTime = frames.reduce((m, f) => Math.max(m, at(f)), 0);
117
+ return {
118
+ fresh: [],
119
+ mark: { time: maxTime, idsAtTime: frames.filter((f) => at(f) === maxTime).map((f) => f.id) },
120
+ };
121
+ }
122
+ const fresh = frames.filter(
123
+ (f) => at(f) > mark.time || (at(f) === mark.time && !mark.idsAtTime.includes(f.id)),
124
+ );
125
+ const maxTime = frames.reduce((m, f) => Math.max(m, at(f)), mark.time);
126
+ const idsAtMax = frames.filter((f) => at(f) === maxTime).map((f) => f.id);
127
+ return {
128
+ fresh,
129
+ mark: {
130
+ time: maxTime,
131
+ idsAtTime: maxTime === mark.time ? [...new Set([...mark.idsAtTime, ...idsAtMax])] : idsAtMax,
132
+ },
133
+ };
134
+ }
135
+
136
+ /** Throws when a topic URL is one a phone cannot reach (empty, invalid, or loopback). A phone
137
+ * that is given "localhost" connects to itself and never receives anything. */
138
+ export function assertReachableFromPhone(url: string | undefined): string {
139
+ let host = "";
140
+ try {
141
+ host = new URL(url ?? "").hostname;
142
+ } catch {
143
+ host = "";
144
+ }
145
+ if (isLoopbackHost(host)) {
146
+ throw new Error(
147
+ `ntfy topic URL ${JSON.stringify(url ?? "")} cannot be reached from a phone. ` +
148
+ "Use an address of the server that the phone can reach (not localhost).",
149
+ );
150
+ }
151
+ return url as string;
152
+ }
153
+
154
+ export function isLoopbackHost(host: string): boolean {
155
+ const h = host.replace(/^\[|\]$/g, "").toLowerCase();
156
+ return h === "" || h === "localhost" || h.endsWith(".localhost") || h === "::1" || h === "0.0.0.0"
157
+ || /^127\.\d+\.\d+\.\d+$/.test(h);
158
+ }