dsh-ntfy-notifier 1.2.0 → 1.3.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.
Files changed (4) hide show
  1. package/README.md +30 -7
  2. package/client.js +109 -3
  3. package/index.js +129 -11
  4. package/package.json +1 -1
package/README.md CHANGED
@@ -17,7 +17,8 @@
17
17
  - 支持 `title`、`message`、`priority`
18
18
  - 优先级 4/5 可选用系统闹钟铃声,其他优先级使用普通提示音
19
19
  - 消息通过 base64 JSON 传给 PowerShell,避免命令行和 XML 注入
20
- - 支持 Bearer Token
20
+ - 支持全局 Bearer Token,也支持每个服务器节点单独配置并覆盖全局 Token
21
+ - 配置页按频道显示运行状态:等待、订阅完成、心跳正常、连接失败
21
22
  - 插件停用或 DSH 退出时关闭连接和通知进程
22
23
 
23
24
  ## 环境要求
@@ -54,16 +55,19 @@
54
55
  ```text
55
56
  服务器 1
56
57
  服务器地址: [http://127.0.0.1:2586]
58
+ 访问令牌: [留空则使用全局令牌]
57
59
  频道: [message] [alerts] [+ 添加频道]
58
60
 
59
61
  服务器 2
60
62
  服务器地址: [https://ntfy.example.com]
63
+ 访问令牌: [可填写该服务器自己的令牌]
61
64
  频道: [system] [+ 添加频道]
62
65
 
63
66
  [+ 添加服务器] [保存]
64
67
  ```
65
68
 
66
69
  - 每个服务器地址是一个输入框
70
+ - 每个服务器可以单独填写访问令牌;留空时使用全局 `accessToken`
67
71
  - 每个频道也是一个独立输入框
68
72
  - 点 **+ 添加频道** 给当前服务器增加频道输入框
69
73
  - 点 **+ 添加服务器** 增加一组服务器和频道输入框
@@ -83,10 +87,12 @@
83
87
  topics:
84
88
  - message
85
89
  - alerts
90
+ accessToken: ''
86
91
  - url: 'https://ntfy.example.com'
87
92
  topics:
88
93
  - system
89
- accessToken: ''
94
+ accessToken: 'server-token'
95
+ accessToken: 'global-token'
90
96
  appId: 'ntfy'
91
97
  sound: true
92
98
  highPriorityAlarm: true
@@ -96,8 +102,9 @@
96
102
 
97
103
  | 字段 | 默认值 | 说明 |
98
104
  |---|---|---|
99
- | `servers` | 一个本地服务器和一个 `message` 频道 | 服务器列表,每项包含 `url` 和该服务器的 `topics` |
100
- | `accessToken` | 空 | 可选,ntfy Bearer Token;所有服务器共用 |
105
+ | `servers` | 一个本地服务器和一个 `message` 频道 | 服务器列表,每项包含 `url`、该服务器的 `topics` 和可选的 `accessToken` |
106
+ | `servers[].accessToken` | 空 | 可选,该服务器自己的 Bearer Token;非空时覆盖全局 `accessToken` |
107
+ | `accessToken` | 空 | 可选,全局 Bearer Token;服务器节点没有填写 `accessToken` 时使用 |
101
108
  | `appId` | `ntfy` | Windows Toast 的 AUMID;未注册时自动回退到 `Microsoft.Windows.PowerShell` |
102
109
  | `sound` | `true` | 是否播放通知声音 |
103
110
  | `highPriorityAlarm` | `true` | 优先级 4/5 是否使用系统闹钟铃声 |
@@ -106,6 +113,21 @@
106
113
 
107
114
  频道名不要包含空格或斜杠。如果使用反向代理子路径,`url` 填到子路径为止,例如 `https://ntfy.example.com/ntfy`,插件会请求 `https://ntfy.example.com/ntfy/<channel>/json`。
108
115
 
116
+ ### 运行状态
117
+
118
+ 配置页的每个频道旁会显示当前运行状态:
119
+
120
+ | 状态 | 颜色 | 含义 |
121
+ |---|---|---|
122
+ | 等待 | 黄色 | 正在连接,或等待重连 |
123
+ | 订阅完成 | 绿色 | 已收到 ntfy 的 `open` 事件 |
124
+ | 心跳正常 | 绿色 | 已收到 keepalive 或消息 |
125
+ | 连接失败 | 红色 | 连续 3 次心跳检查没有收到任何数据 |
126
+
127
+ 心跳检查每 45 秒执行一次;连续 3 次没有活动(约 135 秒)会标记为连接失败并触发重连。任何 keepalive 或消息都会重置心跳计数。
128
+
129
+ 状态由 Host 提供给配置页,不包含 Token、Authorization 头或完整服务器地址。
130
+
109
131
  ## 工作原理
110
132
 
111
133
  1. 对每个「服务器 + 频道」组合请求 `<server>/<channel>/json`,读取换行分隔的 JSON 事件。
@@ -118,7 +140,8 @@
118
140
 
119
141
  - 不使用外部 `ntfy.exe`,减少一个可执行文件依赖。
120
142
  - 消息内容不拼接进 PowerShell 命令行,也不直接拼进 XML,避免命令和 XML 注入。
121
- - `accessToken` 会以明文保存在 DSH 的 profile 配置中;请确保本机账号和 profile 文件权限可信。
143
+ - `accessToken` 和 `servers[].accessToken` 会以明文保存在 DSH 的 profile 配置中;请确保本机账号和 profile 文件权限可信。
144
+ - 未配置服务器级 `accessToken` 的服务器会使用全局 `accessToken`;如果某台服务器不受信,建议不要设置全局令牌,或为每个服务器配置独立令牌。
122
145
  - 局域网 HTTP 不加密。跨网络部署建议使用 HTTPS,并配置访问令牌。
123
146
  - 插件只读取 ntfy 消息并将其显示为本机通知,不执行消息内容。
124
147
 
@@ -155,7 +178,7 @@ powershell.exe -NoProfile -ExecutionPolicy Bypass -File .\notify.ps1 -PayloadBas
155
178
 
156
179
  - **配置页看不到**:刷新 Web GUI;确认插件行是 `ntfy-notifier` 且处于启用状态;确认配置页的客户端模块已经加载。
157
180
  - **没有通知**:检查 Windows 通知权限、专注助手/勿扰模式,以及 `appId` 是否被系统接受;可先用上面的助手命令测试。
158
- - **连接失败**:确认服务器地址、频道名、反向代理路径和 `accessToken`;查看 DSH Host 日志中的 `ntfy:` 前缀。
181
+ - **连接失败**:确认服务器地址、频道名、反向代理路径,以及该服务器的 `servers[].accessToken` 或全局 `accessToken`;查看 DSH Host 日志中的 `ntfy:` 前缀。
159
182
  - **频道重复**:同一个 URL 只会建立一条连接,重复频道会被去重。
160
183
 
161
184
  ## 限制
@@ -163,7 +186,7 @@ powershell.exe -NoProfile -ExecutionPolicy Bypass -File .\notify.ps1 -PayloadBas
163
186
  - 插件仅在 DSH 运行期间监听,不是常驻 Windows 服务。
164
187
  - 当前只实现 Windows Toast;其他平台会记录警告但不会弹窗。
165
188
  - 首次连接只接收新消息,不补发历史消息。
166
- - 所有服务器共用同一个 `accessToken`。
189
+ - 服务器节点没有填写 `accessToken` 时,会回退到全局 `accessToken`。
167
190
  - 配置页依赖 DSH 的 `plugins.row.config` 和 `settings` 服务;也可以通过 YAML 配置。
168
191
 
169
192
  ## 许可证
package/client.js CHANGED
@@ -11,6 +11,9 @@ window.__ModuleLoader__.load({
11
11
  server: 'Server',
12
12
  serverLabel: 'Server address',
13
13
  serverHint: 'Base address, for example http://127.0.0.1:2586. A reverse-proxy path such as https://ntfy.example.com/ntfy is allowed.',
14
+ accessTokenLabel: 'Bearer Token',
15
+ accessTokenPlaceholder: 'Example: tk_**** (example only; enter your own)',
16
+ accessTokenHint: 'Optional. Leave empty to use the global Bearer Token (empty by default); a value here overrides it for this server.',
14
17
  channelsLabel: 'Channels',
15
18
  channelsHint: 'Each input is one channel. Use + to add another channel to this server.',
16
19
  addServer: 'Add server',
@@ -27,7 +30,12 @@ window.__ModuleLoader__.load({
27
30
  errorChannels: 'Enter at least one channel for this server.',
28
31
  errorChannel: 'Channel names cannot contain spaces or slashes.',
29
32
  saveFailed: 'The Host did not accept the configuration. The previous values are still active.',
30
- noChannels: 'No channels'
33
+ noChannels: 'No channels',
34
+ statusWaiting: 'Waiting',
35
+ statusSubscribed: 'Subscribed',
36
+ statusHealthy: 'Heartbeat OK',
37
+ statusFailed: 'Connection failed',
38
+ statusUnavailable: 'Runtime status is unavailable.'
31
39
  }
32
40
 
33
41
  const zh = {
@@ -35,6 +43,9 @@ window.__ModuleLoader__.load({
35
43
  server: '服务器',
36
44
  serverLabel: '服务器地址',
37
45
  serverHint: '填写基础地址,例如 http://127.0.0.1:2586;反向代理子路径也可以,例如 https://ntfy.example.com/ntfy。',
46
+ accessTokenLabel: 'Bearer Token',
47
+ accessTokenPlaceholder: '示例:tk_****(仅示例,需手动填写)',
48
+ accessTokenHint: '可选。留空时使用全局 Bearer Token(全局默认为空);填写后将覆盖该服务器的全局令牌。',
38
49
  channelsLabel: '频道',
39
50
  channelsHint: '每个输入框填一个频道;点 + 可以给当前服务器继续添加频道。',
40
51
  addServer: '添加服务器',
@@ -51,7 +62,12 @@ window.__ModuleLoader__.load({
51
62
  errorChannels: '请给这个服务器至少填写一个频道。',
52
63
  errorChannel: '频道名不能包含空格或斜杠。',
53
64
  saveFailed: 'Host 没有接受这次配置,当前仍使用原来的值。',
54
- noChannels: '未配置频道'
65
+ noChannels: '未配置频道',
66
+ statusWaiting: '等待',
67
+ statusSubscribed: '订阅完成',
68
+ statusHealthy: '心跳正常',
69
+ statusFailed: '连接失败',
70
+ statusUnavailable: '运行状态暂不可用。'
55
71
  }
56
72
 
57
73
  const styles = {
@@ -186,6 +202,17 @@ window.__ModuleLoader__.load({
186
202
  color: 'var(--dsw-alias-label-secondary)',
187
203
  fontSize: '12px',
188
204
  lineHeight: 1.5
205
+ },
206
+ statusBadge: {
207
+ display: 'inline-flex',
208
+ alignItems: 'center',
209
+ padding: '2px 7px',
210
+ border: '1px solid',
211
+ borderRadius: '999px',
212
+ background: 'transparent',
213
+ fontSize: '11px',
214
+ lineHeight: 1.4,
215
+ whiteSpace: 'nowrap'
189
216
  }
190
217
  }
191
218
 
@@ -240,6 +267,7 @@ window.__ModuleLoader__.load({
240
267
  const item = plain(entry) || {}
241
268
  return {
242
269
  url: typeof item.url === 'string' ? item.url : '',
270
+ accessToken: typeof item.accessToken === 'string' ? item.accessToken : '',
243
271
  topics: Array.isArray(item.topics)
244
272
  ? item.topics.filter((topic) => typeof topic === 'string' && topic.trim()).map((topic) => topic.trim())
245
273
  : []
@@ -247,7 +275,7 @@ window.__ModuleLoader__.load({
247
275
  })
248
276
  .filter((entry) => entry.url || entry.topics.length > 0)
249
277
  : []
250
- return { servers: servers.length > 0 ? servers : [{ url: '', topics: [] }] }
278
+ return { servers: servers.length > 0 ? servers : [{ url: '', accessToken: '', topics: [] }] }
251
279
  }
252
280
 
253
281
  function nextId() {
@@ -263,6 +291,7 @@ window.__ModuleLoader__.load({
263
291
  return {
264
292
  id: nextId(),
265
293
  url: typeof server?.url === 'string' ? server.url : '',
294
+ accessToken: typeof server?.accessToken === 'string' ? server.accessToken : '',
266
295
  channels: topics.length > 0 ? topics.map(channelDraft) : [channelDraft('')]
267
296
  }
268
297
  }
@@ -278,12 +307,45 @@ window.__ModuleLoader__.load({
278
307
  const [error, setError] = React.useState('')
279
308
  const [saved, setSaved] = React.useState(false)
280
309
  const [saving, setSaving] = React.useState(false)
310
+ const [runtimeStatus, setRuntimeStatus] = React.useState([])
311
+ const [statusError, setStatusError] = React.useState(false)
281
312
 
282
313
  React.useEffect(() => {
283
314
  setServers(initial.servers.map(serverDraft))
284
315
  setError('')
285
316
  }, [initial])
286
317
 
318
+ React.useEffect(() => {
319
+ if (view !== 'page') return
320
+ let cancelled = false
321
+ let timer
322
+
323
+ const poll = async () => {
324
+ try {
325
+ const response = await fetch('/ntfy-notifier/status', { cache: 'no-store' })
326
+ if (!response.ok) throw new Error(`HTTP ${response.status}`)
327
+ const data = await response.json()
328
+ if (!cancelled) {
329
+ setRuntimeStatus(Array.isArray(data?.subscriptions) ? data.subscriptions : [])
330
+ setStatusError(false)
331
+ }
332
+ } catch {
333
+ if (!cancelled) {
334
+ setRuntimeStatus([])
335
+ setStatusError(true)
336
+ }
337
+ } finally {
338
+ if (!cancelled) timer = setTimeout(poll, 2_000)
339
+ }
340
+ }
341
+
342
+ poll()
343
+ return () => {
344
+ cancelled = true
345
+ clearTimeout(timer)
346
+ }
347
+ }, [view])
348
+
287
349
  if (view === 'summary') {
288
350
  const description = initial.servers
289
351
  .map((server) => {
@@ -300,6 +362,29 @@ window.__ModuleLoader__.load({
300
362
 
301
363
  const disabled = !state.writable || saving
302
364
 
365
+ function statusFor(serverIndex, topic) {
366
+ if (!topic) return undefined
367
+ return runtimeStatus.find((item) => item.serverIndex === serverIndex && item.topic === topic)
368
+ }
369
+
370
+ function statusLabel(phase) {
371
+ switch (phase) {
372
+ case 'subscribed': return t('statusSubscribed')
373
+ case 'healthy': return t('statusHealthy')
374
+ case 'failed': return t('statusFailed')
375
+ default: return t('statusWaiting')
376
+ }
377
+ }
378
+
379
+ function statusStyle(phase) {
380
+ const color = phase === 'failed'
381
+ ? 'var(--dsw-alias-state-error-primary)'
382
+ : phase === 'waiting'
383
+ ? 'var(--dsw-alias-state-warn-primary)'
384
+ : 'var(--dsw-alias-state-success-primary)'
385
+ return { ...styles.statusBadge, color, borderColor: color }
386
+ }
387
+
303
388
  function updateServer(id, update) {
304
389
  setServers((current) => current.map((server) => (server.id === id ? { ...server, ...update } : server)))
305
390
  setSaved(false)
@@ -356,6 +441,7 @@ window.__ModuleLoader__.load({
356
441
  try {
357
442
  return {
358
443
  url: normalizeServer(server.url, t),
444
+ accessToken: String(server.accessToken || '').trim(),
359
445
  topics: validateChannels(server.channels.map((channel) => channel.value), t)
360
446
  }
361
447
  } catch (cause) {
@@ -381,6 +467,7 @@ window.__ModuleLoader__.load({
381
467
 
382
468
  return h('div', { style: styles.root },
383
469
  h('p', { style: styles.intro }, t('intro')),
470
+ statusError ? h('p', { style: styles.hint }, t('statusUnavailable')) : null,
384
471
  servers.map((server, index) => h('section', {
385
472
  key: server.id,
386
473
  style: styles.serverCard
@@ -410,6 +497,20 @@ window.__ModuleLoader__.load({
410
497
  })
411
498
  ),
412
499
  h('p', { style: styles.hint }, t('serverHint')),
500
+ h('label', { style: styles.label },
501
+ t('accessTokenLabel'),
502
+ h('input', {
503
+ type: 'password',
504
+ style: styles.input,
505
+ value: server.accessToken,
506
+ disabled,
507
+ spellCheck: false,
508
+ autoComplete: 'off',
509
+ placeholder: t('accessTokenPlaceholder'),
510
+ onChange: (event) => updateServer(server.id, { accessToken: event.target.value })
511
+ })
512
+ ),
513
+ h('p', { style: styles.hint }, t('accessTokenHint')),
413
514
  h('div', { style: styles.serverHeader },
414
515
  h('span', { style: styles.channelLabel }, t('channelsLabel')),
415
516
  h('button', {
@@ -434,6 +535,11 @@ window.__ModuleLoader__.load({
434
535
  'aria-label': `${t('channelsLabel')} ${channelIndex + 1}`,
435
536
  onChange: (event) => updateChannel(server.id, channel.id, event.target.value)
436
537
  }),
538
+ channel.value.trim()
539
+ ? h('span', {
540
+ style: statusStyle(statusFor(index, channel.value.trim())?.phase || 'waiting')
541
+ }, statusLabel(statusFor(index, channel.value.trim())?.phase || 'waiting'))
542
+ : null,
437
543
  server.channels.length > 1
438
544
  ? h('button', {
439
545
  type: 'button',
package/index.js CHANGED
@@ -19,10 +19,13 @@ import { fileURLToPath } from 'node:url'
19
19
  import z from '@deepseek-ai/schemastery'
20
20
 
21
21
  const NOTIFY_SCRIPT = join(dirname(fileURLToPath(import.meta.url)), 'notify.ps1')
22
- const USER_AGENT = 'dsh-ntfy-notifier/1.2.0'
22
+ const USER_AGENT = 'dsh-ntfy-notifier/1.3.0'
23
23
  const SEEN_LIMIT = 300
24
24
  const QUEUE_LIMIT = 50
25
- const REQUEST_IDLE_TIMEOUT_MS = 90_000
25
+ const HEARTBEAT_INTERVAL_MS = 45_000
26
+ const HEARTBEAT_MISS_LIMIT = 3
27
+ const HEARTBEAT_CHECK_MS = 45_000
28
+ const REQUEST_IDLE_TIMEOUT_MS = HEARTBEAT_INTERVAL_MS * HEARTBEAT_MISS_LIMIT + 30_000
26
29
  const TOAST_TIMEOUT_MS = 20_000
27
30
  const MAX_TITLE = 120
28
31
  const MAX_BODY = 1_200
@@ -31,15 +34,16 @@ export const Config = z.object({
31
34
  servers: z
32
35
  .array(z.object({
33
36
  url: z.string(),
34
- topics: z.array(z.string()).default([])
37
+ topics: z.array(z.string()).default([]),
38
+ accessToken: z.string().default('')
35
39
  }))
36
40
  .default([])
37
41
  .volatile()
38
- .description('ntfy 服务器列表,每项包含服务器基础地址 url 和要订阅的频道名数组 topics。'),
42
+ .description('ntfy 服务器列表,每项包含服务器基础地址 url、频道名数组 topics 和可选的服务器级 accessToken。'),
39
43
  accessToken: z
40
44
  .string()
41
45
  .default('')
42
- .description('可选:ntfy 访问令牌。填写后以 Authorization: Bearer <token> 发送;公开主题留空。'),
46
+ .description('可选:所有服务器默认使用的 ntfy 访问令牌。服务器节点填写 accessToken 时优先使用节点值。'),
43
47
  appId: z
44
48
  .string()
45
49
  .default('ntfy')
@@ -133,6 +137,7 @@ function resolveTargets(config, logger) {
133
137
 
134
138
  const targets = []
135
139
  const seen = new Set()
140
+ const globalToken = typeof config.accessToken === 'string' ? config.accessToken.trim() : ''
136
141
  rawServers.forEach((entry, index) => {
137
142
  const value = configValue(entry) || {}
138
143
  let server
@@ -150,6 +155,8 @@ function resolveTargets(config, logger) {
150
155
  logger.warn(`ntfy: 服务器 ${index + 1} 没有频道,已跳过`)
151
156
  return
152
157
  }
158
+ const serverToken = typeof value.accessToken === 'string' ? value.accessToken.trim() : ''
159
+ const token = serverToken || globalToken
153
160
  for (const topic of topics) {
154
161
  try {
155
162
  const url = topicUrlFor(server, topic)
@@ -158,7 +165,13 @@ function resolveTargets(config, logger) {
158
165
  continue
159
166
  }
160
167
  seen.add(url.href)
161
- targets.push({ url, label: `${server.host}/${topicLabel(url)}` })
168
+ targets.push({
169
+ url,
170
+ label: `${server.host}/${topicLabel(url)}`,
171
+ accessToken: token,
172
+ serverIndex: index,
173
+ topic
174
+ })
162
175
  } catch (error) {
163
176
  logger.warn(`ntfy: 跳过无效频道「${topic}」:${error instanceof Error ? error.message : String(error)}`)
164
177
  }
@@ -290,7 +303,7 @@ function createToastSender(logger) {
290
303
  }
291
304
 
292
305
  /** One independent ntfy subscription and its reconnect state. */
293
- function createSubscription({ topicUrl, config, logger, toast }) {
306
+ function createSubscription({ topicUrl, accessToken, status, config, logger, toast }) {
294
307
  const label = topicLabel(topicUrl)
295
308
  const logPrefix = `ntfy[${label}]`
296
309
  const seen = new Set()
@@ -300,11 +313,51 @@ function createSubscription({ topicUrl, config, logger, toast }) {
300
313
  timer: undefined,
301
314
  active: undefined,
302
315
  lastId: '',
303
- attempt: 0
316
+ attempt: 0,
317
+ heartbeatTimer: undefined
304
318
  }
305
319
  const baseDelay = config.reconnectDelaySeconds * 1_000
306
320
  const maxDelay = Math.max(baseDelay, config.maxReconnectDelaySeconds * 1_000)
307
- const token = config.accessToken.trim()
321
+ const token = typeof accessToken === 'string' ? accessToken.trim() : ''
322
+
323
+ function setPhase(phase) {
324
+ if (state.disposed) return
325
+ status.phase = phase
326
+ status.updatedAt = Date.now()
327
+ }
328
+
329
+ function markActivity() {
330
+ if (state.disposed) return
331
+ status.lastActivity = Date.now()
332
+ status.missed = 0
333
+ status.updatedAt = status.lastActivity
334
+ }
335
+
336
+ function stopHeartbeat() {
337
+ if (state.heartbeatTimer) clearInterval(state.heartbeatTimer)
338
+ state.heartbeatTimer = undefined
339
+ }
340
+
341
+ function startHeartbeat() {
342
+ stopHeartbeat()
343
+ markActivity()
344
+ state.heartbeatTimer = setInterval(() => {
345
+ if (state.disposed) return
346
+ const idle = Date.now() - status.lastActivity
347
+ if (idle < HEARTBEAT_INTERVAL_MS) {
348
+ status.missed = 0
349
+ return
350
+ }
351
+ status.missed += 1
352
+ status.updatedAt = Date.now()
353
+ if (status.missed < HEARTBEAT_MISS_LIMIT) return
354
+ setPhase('failed')
355
+ stopHeartbeat()
356
+ logger.warn(`${logPrefix}: 连续 ${HEARTBEAT_MISS_LIMIT} 次心跳失败,重连`)
357
+ state.active?.req?.destroy()
358
+ }, HEARTBEAT_CHECK_MS)
359
+ state.heartbeatTimer.unref?.()
360
+ }
308
361
 
309
362
  function remember(id) {
310
363
  if (!id) return true
@@ -330,6 +383,8 @@ function createSubscription({ topicUrl, config, logger, toast }) {
330
383
  function finish(reason, req, res) {
331
384
  if (state.active?.req !== req) return
332
385
  state.active = undefined
386
+ stopHeartbeat()
387
+ setPhase('failed')
333
388
  if (res && !res.destroyed) res.destroy()
334
389
  if (req && !req.destroyed) req.destroy()
335
390
  scheduleReconnect(reason)
@@ -374,16 +429,26 @@ function createSubscription({ topicUrl, config, logger, toast }) {
374
429
  if (payload.event === 'open') {
375
430
  logger.info(`${logPrefix}: 订阅已建立`)
376
431
  state.attempt = 0
432
+ setPhase('subscribed')
433
+ startHeartbeat()
434
+ return
435
+ }
436
+ if (payload.event === 'keepalive') {
437
+ markActivity()
438
+ setPhase('healthy')
377
439
  return
378
440
  }
379
- if (payload.event === 'keepalive') return
380
441
  if (payload.event === 'message' || (payload.event === undefined && ('message' in payload || 'title' in payload))) {
442
+ markActivity()
443
+ setPhase('healthy')
381
444
  notify(payload)
382
445
  }
383
446
  }
384
447
 
385
448
  function connect() {
386
449
  if (state.disposed) return
450
+ stopHeartbeat()
451
+ setPhase('waiting')
387
452
  const url = streamUrlFrom(topicUrl, state.lastId)
388
453
  const transport = url.protocol === 'https:' ? httpsRequest : httpRequest
389
454
  let settled = false
@@ -393,6 +458,9 @@ function createSubscription({ topicUrl, config, logger, toast }) {
393
458
  url,
394
459
  {
395
460
  method: 'GET',
461
+ // Node's global HTTP agent enables TCP keepalive at 1s intervals,
462
+ // which produces unnecessary per-second probe packets on this stream.
463
+ agent: false,
396
464
  headers: {
397
465
  accept: 'application/x-ntfy/stream, application/json, text/event-stream',
398
466
  'user-agent': USER_AGENT,
@@ -441,6 +509,7 @@ function createSubscription({ topicUrl, config, logger, toast }) {
441
509
  let buffer = ''
442
510
  res.setEncoding('utf8')
443
511
  res.on('data', (chunk) => {
512
+ markActivity()
444
513
  buffer += chunk
445
514
  if (buffer.length > 2_000_000) {
446
515
  buffer = ''
@@ -491,6 +560,7 @@ function createSubscription({ topicUrl, config, logger, toast }) {
491
560
  function dispose() {
492
561
  if (state.disposed) return
493
562
  state.disposed = true
563
+ stopHeartbeat()
494
564
  if (state.timer) clearTimeout(state.timer)
495
565
  state.timer = undefined
496
566
  const active = state.active
@@ -527,11 +597,48 @@ export function apply(ctx, config) {
527
597
 
528
598
  const toast = createToastSender(logger)
529
599
  let subscriptions = []
600
+ let statuses = []
530
601
  let stopped = false
531
602
 
603
+ function serveStatus(req, res) {
604
+ if (req.method !== 'GET') {
605
+ res.writeHead(405, { allow: 'GET' })
606
+ res.end()
607
+ return
608
+ }
609
+ res.writeHead(200, {
610
+ 'content-type': 'application/json; charset=utf-8',
611
+ 'cache-control': 'no-store'
612
+ })
613
+ res.end(JSON.stringify({
614
+ subscriptions: statuses.map((status) => ({
615
+ id: status.id,
616
+ serverIndex: status.serverIndex,
617
+ topic: status.topic,
618
+ phase: status.phase,
619
+ missed: status.missed,
620
+ lastActivity: status.lastActivity,
621
+ updatedAt: status.updatedAt
622
+ }))
623
+ }))
624
+ }
625
+
626
+ try {
627
+ ctx.inject(['webServer'], (scope) => {
628
+ scope.effect(() => scope.webServer.register({
629
+ kind: 'exact',
630
+ path: '/ntfy-notifier/status',
631
+ handler: serveStatus
632
+ }), 'ntfy-notifier status route')
633
+ })
634
+ } catch (error) {
635
+ logger.warn(`ntfy: status route registration failed: ${String(error)}`)
636
+ }
637
+
532
638
  function stopSubscriptions() {
533
639
  for (const subscription of subscriptions) subscription.dispose()
534
640
  subscriptions = []
641
+ statuses = []
535
642
  }
536
643
 
537
644
  function startSubscriptions() {
@@ -550,8 +657,19 @@ export function apply(ctx, config) {
550
657
  return
551
658
  }
552
659
 
553
- subscriptions = targets.map((target) => createSubscription({
660
+ statuses = targets.map((target) => ({
661
+ id: `${target.serverIndex}:${target.topic}`,
662
+ serverIndex: target.serverIndex,
663
+ topic: target.topic,
664
+ phase: 'waiting',
665
+ missed: 0,
666
+ lastActivity: 0,
667
+ updatedAt: Date.now()
668
+ }))
669
+ subscriptions = targets.map((target, index) => createSubscription({
554
670
  topicUrl: target.url,
671
+ accessToken: target.accessToken,
672
+ status: statuses[index],
555
673
  config,
556
674
  logger,
557
675
  toast
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-ntfy-notifier",
3
- "version": "1.2.0",
3
+ "version": "1.3.0",
4
4
  "license": "MIT",
5
5
  "description": "Subscribe to configurable ntfy servers and channels and turn each message into a local Windows toast notification.",
6
6
  "keywords": [