@mjasnikovs/pi-task 0.13.6 → 0.13.7

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.
@@ -11,10 +11,16 @@ export interface VapidKeys {
11
11
  publicKey: string;
12
12
  privateKey: string;
13
13
  }
14
- /** Where the VAPID keypair is persisted. Follows the repo's XDG convention
15
- * (see workers/docs-core.ts), but under data-home so it survives cache clears —
16
- * losing these keys invalidates every existing browser subscription. */
14
+ /** Where the VAPID keypair is persisted losing these keys invalidates every
15
+ * existing browser subscription. */
17
16
  export declare function vapidStorePath(): string;
17
+ /** Where browser push subscriptions are mirrored to disk (next to vapid.json).
18
+ * Without this, a server restart — e.g. after a rebuild — silently drops every
19
+ * device: the in-memory store is empty, and a backgrounded/suspended PWA won't
20
+ * re-register until the user next foregrounds it, which is exactly when the push
21
+ * is no longer useful. The in-memory store stays authoritative within a process;
22
+ * this is its durable mirror. */
23
+ export declare function subscriptionsStorePath(): string;
18
24
  /** Diagnostic log file. Defaults to /tmp for easy tailing; override with
19
25
  * PI_REMOTE_PUSH_LOG. (The VAPID key stays in its durable XDG location.) */
20
26
  export declare function pushLogPath(): string;
@@ -30,6 +36,9 @@ export declare function logPush(line: string): void;
30
36
  /** Load the persisted VAPID keypair, generating and saving one on first use or
31
37
  * if the stored file is missing/corrupt. Stable across restarts. */
32
38
  export declare function loadOrCreateVapidKeys(file?: string): VapidKeys;
39
+ /** Load persisted subscriptions into the in-memory store. Best-effort: a missing
40
+ * or corrupt file just leaves the store as-is. Returns the resulting count. */
41
+ export declare function loadSubscriptions(file?: string): number;
33
42
  export declare function addSubscription(sub: PushSubscriptionJSON): void;
34
43
  export declare function removeSubscription(endpoint: string): void;
35
44
  export declare function getSubscriptions(): PushSubscriptionJSON[];
@@ -2,12 +2,25 @@ import { appendFileSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs'
2
2
  import os from 'node:os';
3
3
  import path from 'node:path';
4
4
  import webpush from 'web-push';
5
- /** Where the VAPID keypair is persisted. Follows the repo's XDG convention
6
- * (see workers/docs-core.ts), but under data-home so it survives cache clears —
7
- * losing these keys invalidates every existing browser subscription. */
5
+ /** Resolve the XDG data-home base. Under data-home (not cache) so the files it
6
+ * roots survive cache clears. Follows the repo's XDG convention; see
7
+ * workers/docs-core.ts. */
8
+ function dataHome() {
9
+ return process.env.XDG_DATA_HOME?.trim() || path.join(os.homedir(), '.local', 'share');
10
+ }
11
+ /** Where the VAPID keypair is persisted — losing these keys invalidates every
12
+ * existing browser subscription. */
8
13
  export function vapidStorePath() {
9
- const base = process.env.XDG_DATA_HOME?.trim() || path.join(os.homedir(), '.local', 'share');
10
- return path.join(base, 'pi-task', 'vapid.json');
14
+ return path.join(dataHome(), 'pi-task', 'vapid.json');
15
+ }
16
+ /** Where browser push subscriptions are mirrored to disk (next to vapid.json).
17
+ * Without this, a server restart — e.g. after a rebuild — silently drops every
18
+ * device: the in-memory store is empty, and a backgrounded/suspended PWA won't
19
+ * re-register until the user next foregrounds it, which is exactly when the push
20
+ * is no longer useful. The in-memory store stays authoritative within a process;
21
+ * this is its durable mirror. */
22
+ export function subscriptionsStorePath() {
23
+ return path.join(dataHome(), 'pi-task', 'subscriptions.json');
11
24
  }
12
25
  /** Diagnostic log file. Defaults to /tmp for easy tailing; override with
13
26
  * PI_REMOTE_PUSH_LOG. (The VAPID key stays in its durable XDG location.) */
@@ -69,26 +82,67 @@ export function loadOrCreateVapidKeys(file = vapidStorePath()) {
69
82
  return keys;
70
83
  }
71
84
  // In-memory subscription store, keyed by endpoint. Persisted on globalThis so it
72
- // survives jiti re-evaluation on session switches (same pattern as broadcast.ts).
73
- // Subscriptions themselves are not written to disk: the browser re-subscribes on
74
- // every page load, so an in-memory set is sufficient and self-healing.
85
+ // survives jiti re-evaluation on session switches (same pattern as broadcast.ts),
86
+ // and mirrored to disk (subscriptionsStorePath) so it also survives a full
87
+ // process restart. A re-subscribe on page load can't be relied on for that: the
88
+ // devices that need server push are backgrounded/suspended and won't reload.
75
89
  const g = globalThis;
90
+ const freshStore = !g.__piRemoteSubs;
76
91
  if (!g.__piRemoteSubs)
77
92
  g.__piRemoteSubs = new Map();
78
93
  const subs = g.__piRemoteSubs;
94
+ // Hydrate once per process: a restarted server reloads the devices it knew so it
95
+ // can keep reaching them without waiting for each to re-register.
96
+ if (freshStore)
97
+ loadSubscriptions();
98
+ function isSubscription(x) {
99
+ return (typeof x === 'object'
100
+ && x !== null
101
+ && typeof x.endpoint === 'string');
102
+ }
103
+ /** Load persisted subscriptions into the in-memory store. Best-effort: a missing
104
+ * or corrupt file just leaves the store as-is. Returns the resulting count. */
105
+ export function loadSubscriptions(file = subscriptionsStorePath()) {
106
+ try {
107
+ const parsed = JSON.parse(readFileSync(file, 'utf8'));
108
+ if (Array.isArray(parsed)) {
109
+ for (const s of parsed)
110
+ if (isSubscription(s))
111
+ subs.set(s.endpoint, s);
112
+ }
113
+ }
114
+ catch {
115
+ // missing or corrupt — keep whatever is already in memory
116
+ }
117
+ return subs.size;
118
+ }
119
+ /** Mirror the current store to disk. Best-effort; never throws, so a failed write
120
+ * can't break a subscribe request or a push. */
121
+ function saveSubscriptions(file = subscriptionsStorePath()) {
122
+ try {
123
+ mkdirSync(path.dirname(file), { recursive: true });
124
+ writeFileSync(file, JSON.stringify([...subs.values()]), 'utf8');
125
+ }
126
+ catch {
127
+ // persistence is best-effort; the in-memory store remains authoritative
128
+ }
129
+ }
79
130
  export function addSubscription(sub) {
80
131
  if (!sub.endpoint)
81
132
  return;
82
133
  subs.set(sub.endpoint, sub);
134
+ saveSubscriptions();
83
135
  }
84
136
  export function removeSubscription(endpoint) {
85
- subs.delete(endpoint);
137
+ if (subs.delete(endpoint))
138
+ saveSubscriptions();
86
139
  }
87
140
  export function getSubscriptions() {
88
141
  return [...subs.values()];
89
142
  }
90
143
  export function clearSubscriptions() {
91
144
  subs.clear();
145
+ saveSubscriptions();
92
146
  }
93
147
  /** True when the push service says the subscription is permanently gone and
94
148
  * should be dropped (404 Not Found, 410 Gone). */
@@ -0,0 +1,3 @@
1
+ /** The remote web client script (vanilla JS shipped as a string).
2
+ * `wsUrl` is the LAN/Tailscale fallback baked in as FALLBACK_WS_URL. */
3
+ export declare function clientScript(wsUrl: string): string;