easy-ping 0.5.0 → 0.6.1
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 +548 -508
- package/dist/adapters/mongodb.cjs +142 -0
- package/dist/adapters/mongodb.cjs.map +1 -1
- package/dist/adapters/mongodb.d.cts +39 -1
- package/dist/adapters/mongodb.d.ts +39 -1
- package/dist/adapters/mongodb.js +73 -1
- package/dist/adapters/mongodb.js.map +1 -1
- package/dist/adapters/postgres.cjs +146 -0
- package/dist/adapters/postgres.cjs.map +1 -1
- package/dist/adapters/postgres.d.cts +36 -1
- package/dist/adapters/postgres.d.ts +36 -1
- package/dist/adapters/postgres.js +78 -3
- package/dist/adapters/postgres.js.map +1 -1
- package/dist/adapters/sqlite.js +1 -1
- package/dist/{chunk-GJNLOQJS.js → chunk-JKTMUMQK.js} +31 -6
- package/dist/chunk-JKTMUMQK.js.map +1 -0
- package/dist/{chunk-BECMTNK7.js → chunk-JOVIZXO6.js} +142 -6
- package/dist/chunk-JOVIZXO6.js.map +1 -0
- package/dist/chunk-ORZKH224.js +75 -0
- package/dist/chunk-ORZKH224.js.map +1 -0
- package/dist/client.cjs +297 -17
- package/dist/client.cjs.map +1 -1
- package/dist/client.d.cts +59 -1
- package/dist/client.d.ts +59 -1
- package/dist/client.js +297 -17
- package/dist/client.js.map +1 -1
- package/dist/index.cjs +293 -10
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +46 -1
- package/dist/index.d.ts +46 -1
- package/dist/index.js +57 -7
- package/dist/index.js.map +1 -1
- package/dist/node.cjs +26 -5
- package/dist/node.cjs.map +1 -1
- package/dist/node.d.cts +1 -1
- package/dist/node.d.ts +1 -1
- package/dist/node.js +28 -6
- package/dist/node.js.map +1 -1
- package/dist/plugins/preferences.cjs +2 -2
- package/dist/plugins/preferences.cjs.map +1 -1
- package/dist/plugins/preferences.js +2 -1
- package/dist/plugins/preferences.js.map +1 -1
- package/dist/plugins/push.cjs +2 -2
- package/dist/plugins/push.cjs.map +1 -1
- package/dist/plugins/push.js +3 -2
- package/dist/plugins/push.js.map +1 -1
- package/dist/react.cjs +320 -19
- package/dist/react.cjs.map +1 -1
- package/dist/react.d.cts +58 -0
- package/dist/react.d.ts +58 -0
- package/dist/react.js +320 -19
- package/dist/react.js.map +1 -1
- package/dist/schema.js +1 -1
- package/dist/signals-kPmOlKDX.d.cts +27 -0
- package/dist/signals-kPmOlKDX.d.ts +27 -0
- package/dist/sw.cjs +39 -0
- package/dist/sw.cjs.map +1 -0
- package/dist/sw.d.cts +52 -0
- package/dist/sw.d.ts +52 -0
- package/dist/sw.js +36 -0
- package/dist/sw.js.map +1 -0
- package/package.json +9 -3
- package/dist/chunk-BECMTNK7.js.map +0 -1
- package/dist/chunk-GJNLOQJS.js.map +0 -1
package/dist/client.d.cts
CHANGED
|
@@ -19,14 +19,70 @@ type NotifyState = {
|
|
|
19
19
|
isLoading: boolean;
|
|
20
20
|
error: Error | null;
|
|
21
21
|
};
|
|
22
|
+
type Transport = "auto" | "sse" | "poll";
|
|
23
|
+
type TransportStatus = {
|
|
24
|
+
/** "leader" holds this browser's single connection; "follower" mirrors it; "solo" has no Web Locks. */
|
|
25
|
+
role: "leader" | "follower" | "solo";
|
|
26
|
+
/** What the leader is using right now. */
|
|
27
|
+
transport: "sse" | "poll";
|
|
28
|
+
/** True while the event stream has said `ready` and not since closed. */
|
|
29
|
+
connected: boolean;
|
|
30
|
+
};
|
|
31
|
+
type LockManagerLike = {
|
|
32
|
+
request(name: string, options: {
|
|
33
|
+
mode: "exclusive";
|
|
34
|
+
signal?: AbortSignal;
|
|
35
|
+
}, callback: () => Promise<void>): Promise<void>;
|
|
36
|
+
};
|
|
37
|
+
type ChannelLike = {
|
|
38
|
+
postMessage(message: unknown): void;
|
|
39
|
+
addEventListener(type: "message", handler: (event: {
|
|
40
|
+
data: unknown;
|
|
41
|
+
}) => void): void;
|
|
42
|
+
removeEventListener(type: "message", handler: (event: {
|
|
43
|
+
data: unknown;
|
|
44
|
+
}) => void): void;
|
|
45
|
+
close(): void;
|
|
46
|
+
};
|
|
47
|
+
type MessageTargetLike = {
|
|
48
|
+
addEventListener(type: string, handler: (event: {
|
|
49
|
+
data?: unknown;
|
|
50
|
+
}) => void): void;
|
|
51
|
+
removeEventListener(type: string, handler: (event: {
|
|
52
|
+
data?: unknown;
|
|
53
|
+
}) => void): void;
|
|
54
|
+
};
|
|
22
55
|
type NotifyClientOptions = {
|
|
23
56
|
baseUrl?: string | undefined;
|
|
24
57
|
fetch?: typeof globalThis.fetch | undefined;
|
|
25
58
|
limit?: number | undefined;
|
|
59
|
+
/** Poll interval while the user is active and no stream is connected. Default 15 s. */
|
|
26
60
|
pollIntervalMs?: number | undefined;
|
|
61
|
+
/** Ceiling the idle backoff grows to. Default 10 min. */
|
|
27
62
|
maxPollIntervalMs?: number | undefined;
|
|
63
|
+
/** Poll interval while the event stream is connected: a safety net. Default 5 min. */
|
|
64
|
+
safetyNetMs?: number | undefined;
|
|
65
|
+
/** How long after user input polling stays at the base interval. Default 60 s. */
|
|
66
|
+
activeWindowMs?: number | undefined;
|
|
67
|
+
/** "auto" streams when a DOM is present, "poll" never streams, "sse" always tries. */
|
|
68
|
+
transport?: Transport | undefined;
|
|
28
69
|
/** Injectable so the poller can be tested without a DOM. */
|
|
29
70
|
isDocumentHidden?: (() => boolean) | undefined;
|
|
71
|
+
/** Web Locks, for one connection per browser. Defaults to navigator.locks; null disables. */
|
|
72
|
+
locks?: LockManagerLike | null | undefined;
|
|
73
|
+
/** Cross-tab channel factory. Defaults to BroadcastChannel; null disables. */
|
|
74
|
+
channel?: (() => ChannelLike) | null | undefined;
|
|
75
|
+
/** Where push relays arrive. Defaults to navigator.serviceWorker; null disables. */
|
|
76
|
+
serviceWorker?: MessageTargetLike | null | undefined;
|
|
77
|
+
/** Where user activity is observed. Defaults to window; null disables. */
|
|
78
|
+
activityTarget?: MessageTargetLike | null | undefined;
|
|
79
|
+
now?: (() => number) | undefined;
|
|
80
|
+
/**
|
|
81
|
+
* Separates browsers' tab groups when identity is not a cookie: pass the
|
|
82
|
+
* current user's id (or session id) so tabs signed in as different users
|
|
83
|
+
* never share a leader or mirror each other's inbox.
|
|
84
|
+
*/
|
|
85
|
+
scope?: string | undefined;
|
|
30
86
|
};
|
|
31
87
|
declare function createNotifyClient(options?: NotifyClientOptions): {
|
|
32
88
|
subscribe: (listener: (state: NotifyState) => void) => () => void;
|
|
@@ -35,8 +91,10 @@ declare function createNotifyClient(options?: NotifyClientOptions): {
|
|
|
35
91
|
markAsRead: (id: string) => Promise<void>;
|
|
36
92
|
markAllRead: () => Promise<void>;
|
|
37
93
|
markSeen: () => Promise<void>;
|
|
94
|
+
instrument: (inner: typeof globalThis.fetch) => typeof globalThis.fetch;
|
|
38
95
|
getState: () => NotifyState;
|
|
96
|
+
getTransport: () => TransportStatus;
|
|
39
97
|
};
|
|
40
98
|
type NotifyClient = ReturnType<typeof createNotifyClient>;
|
|
41
99
|
|
|
42
|
-
export { type NotificationView, type NotifyClient, type NotifyClientOptions, type NotifyState, createNotifyClient };
|
|
100
|
+
export { type NotificationView, type NotifyClient, type NotifyClientOptions, type NotifyState, type Transport, type TransportStatus, createNotifyClient };
|
package/dist/client.d.ts
CHANGED
|
@@ -19,14 +19,70 @@ type NotifyState = {
|
|
|
19
19
|
isLoading: boolean;
|
|
20
20
|
error: Error | null;
|
|
21
21
|
};
|
|
22
|
+
type Transport = "auto" | "sse" | "poll";
|
|
23
|
+
type TransportStatus = {
|
|
24
|
+
/** "leader" holds this browser's single connection; "follower" mirrors it; "solo" has no Web Locks. */
|
|
25
|
+
role: "leader" | "follower" | "solo";
|
|
26
|
+
/** What the leader is using right now. */
|
|
27
|
+
transport: "sse" | "poll";
|
|
28
|
+
/** True while the event stream has said `ready` and not since closed. */
|
|
29
|
+
connected: boolean;
|
|
30
|
+
};
|
|
31
|
+
type LockManagerLike = {
|
|
32
|
+
request(name: string, options: {
|
|
33
|
+
mode: "exclusive";
|
|
34
|
+
signal?: AbortSignal;
|
|
35
|
+
}, callback: () => Promise<void>): Promise<void>;
|
|
36
|
+
};
|
|
37
|
+
type ChannelLike = {
|
|
38
|
+
postMessage(message: unknown): void;
|
|
39
|
+
addEventListener(type: "message", handler: (event: {
|
|
40
|
+
data: unknown;
|
|
41
|
+
}) => void): void;
|
|
42
|
+
removeEventListener(type: "message", handler: (event: {
|
|
43
|
+
data: unknown;
|
|
44
|
+
}) => void): void;
|
|
45
|
+
close(): void;
|
|
46
|
+
};
|
|
47
|
+
type MessageTargetLike = {
|
|
48
|
+
addEventListener(type: string, handler: (event: {
|
|
49
|
+
data?: unknown;
|
|
50
|
+
}) => void): void;
|
|
51
|
+
removeEventListener(type: string, handler: (event: {
|
|
52
|
+
data?: unknown;
|
|
53
|
+
}) => void): void;
|
|
54
|
+
};
|
|
22
55
|
type NotifyClientOptions = {
|
|
23
56
|
baseUrl?: string | undefined;
|
|
24
57
|
fetch?: typeof globalThis.fetch | undefined;
|
|
25
58
|
limit?: number | undefined;
|
|
59
|
+
/** Poll interval while the user is active and no stream is connected. Default 15 s. */
|
|
26
60
|
pollIntervalMs?: number | undefined;
|
|
61
|
+
/** Ceiling the idle backoff grows to. Default 10 min. */
|
|
27
62
|
maxPollIntervalMs?: number | undefined;
|
|
63
|
+
/** Poll interval while the event stream is connected: a safety net. Default 5 min. */
|
|
64
|
+
safetyNetMs?: number | undefined;
|
|
65
|
+
/** How long after user input polling stays at the base interval. Default 60 s. */
|
|
66
|
+
activeWindowMs?: number | undefined;
|
|
67
|
+
/** "auto" streams when a DOM is present, "poll" never streams, "sse" always tries. */
|
|
68
|
+
transport?: Transport | undefined;
|
|
28
69
|
/** Injectable so the poller can be tested without a DOM. */
|
|
29
70
|
isDocumentHidden?: (() => boolean) | undefined;
|
|
71
|
+
/** Web Locks, for one connection per browser. Defaults to navigator.locks; null disables. */
|
|
72
|
+
locks?: LockManagerLike | null | undefined;
|
|
73
|
+
/** Cross-tab channel factory. Defaults to BroadcastChannel; null disables. */
|
|
74
|
+
channel?: (() => ChannelLike) | null | undefined;
|
|
75
|
+
/** Where push relays arrive. Defaults to navigator.serviceWorker; null disables. */
|
|
76
|
+
serviceWorker?: MessageTargetLike | null | undefined;
|
|
77
|
+
/** Where user activity is observed. Defaults to window; null disables. */
|
|
78
|
+
activityTarget?: MessageTargetLike | null | undefined;
|
|
79
|
+
now?: (() => number) | undefined;
|
|
80
|
+
/**
|
|
81
|
+
* Separates browsers' tab groups when identity is not a cookie: pass the
|
|
82
|
+
* current user's id (or session id) so tabs signed in as different users
|
|
83
|
+
* never share a leader or mirror each other's inbox.
|
|
84
|
+
*/
|
|
85
|
+
scope?: string | undefined;
|
|
30
86
|
};
|
|
31
87
|
declare function createNotifyClient(options?: NotifyClientOptions): {
|
|
32
88
|
subscribe: (listener: (state: NotifyState) => void) => () => void;
|
|
@@ -35,8 +91,10 @@ declare function createNotifyClient(options?: NotifyClientOptions): {
|
|
|
35
91
|
markAsRead: (id: string) => Promise<void>;
|
|
36
92
|
markAllRead: () => Promise<void>;
|
|
37
93
|
markSeen: () => Promise<void>;
|
|
94
|
+
instrument: (inner: typeof globalThis.fetch) => typeof globalThis.fetch;
|
|
38
95
|
getState: () => NotifyState;
|
|
96
|
+
getTransport: () => TransportStatus;
|
|
39
97
|
};
|
|
40
98
|
type NotifyClient = ReturnType<typeof createNotifyClient>;
|
|
41
99
|
|
|
42
|
-
export { type NotificationView, type NotifyClient, type NotifyClientOptions, type NotifyState, createNotifyClient };
|
|
100
|
+
export { type NotificationView, type NotifyClient, type NotifyClientOptions, type NotifyState, type Transport, type TransportStatus, createNotifyClient };
|
package/dist/client.js
CHANGED
|
@@ -8,22 +8,63 @@ var INITIAL = {
|
|
|
8
8
|
error: null
|
|
9
9
|
};
|
|
10
10
|
var countUnread = (rows) => rows.reduce((total, row) => row.readAt === null ? total + 1 : total, 0);
|
|
11
|
+
var SHORT_STREAM_MS = 15e3;
|
|
12
|
+
var SHORT_STREAMS_BEFORE_GIVING_UP = 3;
|
|
11
13
|
function createNotifyClient(options = {}) {
|
|
12
14
|
const baseUrl = (options.baseUrl ?? "/api/notifications").replace(/\/$/, "");
|
|
13
15
|
const doFetch = options.fetch ?? ((...args) => globalThis.fetch(...args));
|
|
14
16
|
const limit = options.limit ?? 20;
|
|
15
17
|
const pollIntervalMs = options.pollIntervalMs ?? 15e3;
|
|
16
|
-
const maxPollIntervalMs = options.maxPollIntervalMs ??
|
|
18
|
+
const maxPollIntervalMs = options.maxPollIntervalMs ?? 10 * 6e4;
|
|
19
|
+
const safetyNetMs = options.safetyNetMs ?? 5 * 6e4;
|
|
20
|
+
const activeWindowMs = options.activeWindowMs ?? 6e4;
|
|
21
|
+
const now = options.now ?? (() => Date.now());
|
|
22
|
+
const groupKey = options.scope ? `${baseUrl}#${options.scope}` : baseUrl;
|
|
23
|
+
const hasDom = typeof window !== "undefined" && typeof document !== "undefined";
|
|
24
|
+
const transport = options.transport ?? "auto";
|
|
25
|
+
const wantsStream = transport === "sse" || transport === "auto" && hasDom;
|
|
17
26
|
const isHidden = options.isDocumentHidden ?? (() => typeof document !== "undefined" && document.visibilityState === "hidden");
|
|
27
|
+
const locks = options.locks === void 0 ? typeof navigator !== "undefined" ? navigator.locks ?? null : null : options.locks;
|
|
28
|
+
const makeChannel = options.channel === void 0 ? typeof BroadcastChannel !== "undefined" ? () => new BroadcastChannel(`easy-ping:${groupKey}`) : null : options.channel;
|
|
29
|
+
const serviceWorker = options.serviceWorker === void 0 ? typeof navigator !== "undefined" ? navigator.serviceWorker ?? null : null : options.serviceWorker;
|
|
30
|
+
const activityTarget = options.activityTarget === void 0 ? hasDom ? window : null : options.activityTarget;
|
|
18
31
|
let state = INITIAL;
|
|
19
32
|
const listeners = /* @__PURE__ */ new Set();
|
|
33
|
+
let role = "solo";
|
|
34
|
+
let running = false;
|
|
35
|
+
let channel;
|
|
36
|
+
let releaseLock;
|
|
37
|
+
let lockAbort;
|
|
20
38
|
let timer;
|
|
21
39
|
let consecutiveFailures = 0;
|
|
40
|
+
let idleTicks = 0;
|
|
41
|
+
let activeUntil = 0;
|
|
42
|
+
let lastRefreshAt = 0;
|
|
43
|
+
let refreshing;
|
|
44
|
+
let refreshAgain = false;
|
|
45
|
+
let streamAbort;
|
|
46
|
+
let streamConnected = false;
|
|
47
|
+
let streamDisabled = !wantsStream;
|
|
48
|
+
let shortStreams = 0;
|
|
49
|
+
let streamFailures = 0;
|
|
22
50
|
let generation = 0;
|
|
51
|
+
const localReads = /* @__PURE__ */ new Set();
|
|
52
|
+
let localSeen = false;
|
|
53
|
+
const stamp = () => (/* @__PURE__ */ new Date()).toISOString();
|
|
54
|
+
function emit() {
|
|
55
|
+
for (const listener of listeners) listener(state);
|
|
56
|
+
}
|
|
23
57
|
function set(patch) {
|
|
24
58
|
state = { ...state, ...patch };
|
|
25
59
|
state = { ...state, unreadCount: countUnread(state.notifications) };
|
|
26
|
-
|
|
60
|
+
emit();
|
|
61
|
+
if (role === "leader") post({ type: "state", state });
|
|
62
|
+
}
|
|
63
|
+
function post(message) {
|
|
64
|
+
try {
|
|
65
|
+
channel?.postMessage(message);
|
|
66
|
+
} catch {
|
|
67
|
+
}
|
|
27
68
|
}
|
|
28
69
|
async function request(path, init) {
|
|
29
70
|
const response = await doFetch(`${baseUrl}${path}`, {
|
|
@@ -34,7 +75,7 @@ function createNotifyClient(options = {}) {
|
|
|
34
75
|
throw new Error(`${init?.method ?? "GET"} ${path} failed: ${response.status}`);
|
|
35
76
|
return await response.json();
|
|
36
77
|
}
|
|
37
|
-
async function
|
|
78
|
+
async function fetchPage() {
|
|
38
79
|
const startedAt = generation;
|
|
39
80
|
const page = await request(`/?limit=${limit}`);
|
|
40
81
|
const unseenCount = page.unseenCount ?? (await request("/count")).unseen;
|
|
@@ -47,6 +88,24 @@ function createNotifyClient(options = {}) {
|
|
|
47
88
|
error: null
|
|
48
89
|
});
|
|
49
90
|
}
|
|
91
|
+
function refresh() {
|
|
92
|
+
if (refreshing) {
|
|
93
|
+
refreshAgain = true;
|
|
94
|
+
return refreshing;
|
|
95
|
+
}
|
|
96
|
+
refreshing = (async () => {
|
|
97
|
+
try {
|
|
98
|
+
do {
|
|
99
|
+
refreshAgain = false;
|
|
100
|
+
await fetchPage();
|
|
101
|
+
lastRefreshAt = now();
|
|
102
|
+
} while (refreshAgain);
|
|
103
|
+
} finally {
|
|
104
|
+
refreshing = void 0;
|
|
105
|
+
}
|
|
106
|
+
})();
|
|
107
|
+
return refreshing;
|
|
108
|
+
}
|
|
50
109
|
let loadingMore;
|
|
51
110
|
async function loadMore() {
|
|
52
111
|
if (!state.nextCursor) return;
|
|
@@ -75,30 +134,39 @@ function createNotifyClient(options = {}) {
|
|
|
75
134
|
set({ notifications: apply(state.notifications), ...patch });
|
|
76
135
|
try {
|
|
77
136
|
await send();
|
|
137
|
+
if (role === "follower") post({ type: "mutated" });
|
|
78
138
|
} catch (error) {
|
|
79
139
|
generation += 1;
|
|
80
140
|
state = previous;
|
|
81
|
-
|
|
141
|
+
emit();
|
|
82
142
|
throw error;
|
|
83
143
|
}
|
|
84
144
|
}
|
|
85
|
-
const stamp = () => (/* @__PURE__ */ new Date()).toISOString();
|
|
86
145
|
async function markAsRead(id) {
|
|
146
|
+
if (role === "follower") localReads.add(id);
|
|
87
147
|
await optimistic(
|
|
88
148
|
(rows) => rows.map((row) => row.id === id ? { ...row, readAt: row.readAt ?? stamp() } : row),
|
|
89
149
|
{},
|
|
90
150
|
() => request("/read", { method: "POST", body: JSON.stringify({ ids: [id] }) })
|
|
91
|
-
)
|
|
151
|
+
).catch((error) => {
|
|
152
|
+
localReads.delete(id);
|
|
153
|
+
throw error;
|
|
154
|
+
});
|
|
92
155
|
}
|
|
93
156
|
async function markAllRead() {
|
|
157
|
+
if (role === "follower") for (const row of state.notifications) localReads.add(row.id);
|
|
94
158
|
await optimistic(
|
|
95
159
|
(rows) => rows.map((row) => ({ ...row, readAt: row.readAt ?? stamp() })),
|
|
96
160
|
{},
|
|
97
161
|
() => request("/read-all", { method: "POST" })
|
|
98
|
-
)
|
|
162
|
+
).catch((error) => {
|
|
163
|
+
localReads.clear();
|
|
164
|
+
throw error;
|
|
165
|
+
});
|
|
99
166
|
}
|
|
100
167
|
async function markSeen() {
|
|
101
168
|
const newest = state.notifications[0]?.createdAt;
|
|
169
|
+
if (role === "follower") localSeen = true;
|
|
102
170
|
await optimistic(
|
|
103
171
|
(rows) => rows.map((row) => ({ ...row, seenAt: row.seenAt ?? stamp() })),
|
|
104
172
|
{ unseenCount: 0 },
|
|
@@ -106,13 +174,55 @@ function createNotifyClient(options = {}) {
|
|
|
106
174
|
method: "POST",
|
|
107
175
|
body: JSON.stringify(newest ? { before: newest } : {})
|
|
108
176
|
})
|
|
109
|
-
)
|
|
177
|
+
).catch((error) => {
|
|
178
|
+
localSeen = false;
|
|
179
|
+
throw error;
|
|
180
|
+
});
|
|
181
|
+
}
|
|
182
|
+
function applyRemoteState(remote) {
|
|
183
|
+
const notifications = remote.notifications.map((row) => {
|
|
184
|
+
if (!localReads.has(row.id)) return row;
|
|
185
|
+
if (row.readAt) {
|
|
186
|
+
localReads.delete(row.id);
|
|
187
|
+
return row;
|
|
188
|
+
}
|
|
189
|
+
return { ...row, readAt: stamp() };
|
|
190
|
+
});
|
|
191
|
+
if (localSeen && remote.unseenCount === 0) localSeen = false;
|
|
192
|
+
state = {
|
|
193
|
+
...remote,
|
|
194
|
+
notifications,
|
|
195
|
+
unseenCount: localSeen ? 0 : remote.unseenCount,
|
|
196
|
+
unreadCount: countUnread(notifications)
|
|
197
|
+
};
|
|
198
|
+
emit();
|
|
199
|
+
}
|
|
200
|
+
function onTabMessage(event) {
|
|
201
|
+
const message = event.data;
|
|
202
|
+
if (!message || typeof message !== "object") return;
|
|
203
|
+
if (role === "leader") {
|
|
204
|
+
if (message.type === "hello") post({ type: "state", state });
|
|
205
|
+
else if (message.type === "mutated") void refresh().catch(() => {
|
|
206
|
+
});
|
|
207
|
+
return;
|
|
208
|
+
}
|
|
209
|
+
if (message.type === "state") applyRemoteState(message.state);
|
|
110
210
|
}
|
|
111
211
|
function schedule(delay) {
|
|
112
212
|
clearTimeout(timer);
|
|
113
213
|
timer = setTimeout(tick, delay);
|
|
114
214
|
}
|
|
215
|
+
function nextDelay() {
|
|
216
|
+
if (consecutiveFailures > 0) {
|
|
217
|
+
return Math.min(pollIntervalMs * 2 ** consecutiveFailures, maxPollIntervalMs);
|
|
218
|
+
}
|
|
219
|
+
if (streamConnected) return safetyNetMs;
|
|
220
|
+
if (now() < activeUntil) return pollIntervalMs;
|
|
221
|
+
idleTicks += 1;
|
|
222
|
+
return Math.min(pollIntervalMs * 2 ** idleTicks, maxPollIntervalMs);
|
|
223
|
+
}
|
|
115
224
|
async function tick() {
|
|
225
|
+
if (!running) return;
|
|
116
226
|
if (isHidden()) {
|
|
117
227
|
schedule(pollIntervalMs);
|
|
118
228
|
return;
|
|
@@ -124,20 +234,184 @@ function createNotifyClient(options = {}) {
|
|
|
124
234
|
consecutiveFailures += 1;
|
|
125
235
|
set({ isLoading: false, error: error instanceof Error ? error : new Error(String(error)) });
|
|
126
236
|
}
|
|
127
|
-
schedule(
|
|
128
|
-
|
|
129
|
-
|
|
237
|
+
if (running) schedule(nextDelay());
|
|
238
|
+
}
|
|
239
|
+
function onActivity() {
|
|
240
|
+
if (!running || role === "follower") return;
|
|
241
|
+
activeUntil = now() + activeWindowMs;
|
|
242
|
+
idleTicks = 0;
|
|
243
|
+
if (!isHidden() && now() - lastRefreshAt > pollIntervalMs) {
|
|
244
|
+
void refresh().catch(() => {
|
|
245
|
+
});
|
|
246
|
+
schedule(nextDelay());
|
|
247
|
+
}
|
|
248
|
+
}
|
|
249
|
+
const onVisibility = () => {
|
|
250
|
+
if (!isHidden()) onActivity();
|
|
251
|
+
};
|
|
252
|
+
const ACTIVITY_EVENTS = ["focus", "pointerdown", "keydown", "online"];
|
|
253
|
+
function changed() {
|
|
254
|
+
if (!running) return;
|
|
255
|
+
void refresh().catch(() => {
|
|
256
|
+
});
|
|
257
|
+
}
|
|
258
|
+
async function readStream(signal) {
|
|
259
|
+
const openedAt = now();
|
|
260
|
+
const response = await doFetch(`${baseUrl}/events`, {
|
|
261
|
+
headers: { accept: "text/event-stream" },
|
|
262
|
+
signal
|
|
263
|
+
});
|
|
264
|
+
if (!response.ok || !response.body) throw new Error(`GET /events failed: ${response.status}`);
|
|
265
|
+
const reader = response.body.getReader();
|
|
266
|
+
const decoder = new TextDecoder();
|
|
267
|
+
let buffer = "";
|
|
268
|
+
let eventName = "";
|
|
269
|
+
try {
|
|
270
|
+
for (; ; ) {
|
|
271
|
+
const { done, value } = await reader.read();
|
|
272
|
+
if (done) break;
|
|
273
|
+
buffer += decoder.decode(value, { stream: true });
|
|
274
|
+
let newline = buffer.indexOf("\n");
|
|
275
|
+
while (newline >= 0) {
|
|
276
|
+
const line = buffer.slice(0, newline).replace(/\r$/, "");
|
|
277
|
+
buffer = buffer.slice(newline + 1);
|
|
278
|
+
if (line === "") {
|
|
279
|
+
if (eventName === "ready") {
|
|
280
|
+
streamConnected = true;
|
|
281
|
+
streamFailures = 0;
|
|
282
|
+
schedule(nextDelay());
|
|
283
|
+
} else if (eventName === "changed") changed();
|
|
284
|
+
eventName = "";
|
|
285
|
+
} else if (line.startsWith("event:")) eventName = line.slice(6).trim();
|
|
286
|
+
newline = buffer.indexOf("\n");
|
|
287
|
+
}
|
|
288
|
+
}
|
|
289
|
+
} finally {
|
|
290
|
+
streamConnected = false;
|
|
291
|
+
if (now() - openedAt < SHORT_STREAM_MS) shortStreams += 1;
|
|
292
|
+
else shortStreams = 0;
|
|
293
|
+
}
|
|
294
|
+
}
|
|
295
|
+
const isFollower = () => role === "follower";
|
|
296
|
+
async function streamLoop() {
|
|
297
|
+
while (running && !streamDisabled && !isFollower()) {
|
|
298
|
+
streamAbort = new AbortController();
|
|
299
|
+
try {
|
|
300
|
+
await readStream(streamAbort.signal);
|
|
301
|
+
} catch {
|
|
302
|
+
if (!running) return;
|
|
303
|
+
streamFailures += 1;
|
|
304
|
+
}
|
|
305
|
+
if (!running || isFollower()) return;
|
|
306
|
+
if (shortStreams >= SHORT_STREAMS_BEFORE_GIVING_UP) {
|
|
307
|
+
streamDisabled = true;
|
|
308
|
+
schedule(nextDelay());
|
|
309
|
+
return;
|
|
310
|
+
}
|
|
311
|
+
await new Promise(
|
|
312
|
+
(resolve) => setTimeout(resolve, Math.min(3e3 * 2 ** streamFailures, 6e4))
|
|
313
|
+
);
|
|
314
|
+
}
|
|
315
|
+
}
|
|
316
|
+
function becomeLeader() {
|
|
317
|
+
role = "leader";
|
|
318
|
+
post({ type: "leader" });
|
|
319
|
+
post({ type: "state", state });
|
|
320
|
+
void tick();
|
|
321
|
+
if (!streamDisabled) void streamLoop();
|
|
322
|
+
}
|
|
323
|
+
function becomeFollower() {
|
|
324
|
+
role = "follower";
|
|
325
|
+
post({ type: "hello" });
|
|
326
|
+
}
|
|
327
|
+
function acquireLeadership() {
|
|
328
|
+
if (!locks || !channel) {
|
|
329
|
+
role = "solo";
|
|
330
|
+
void tick();
|
|
331
|
+
if (!streamDisabled) void streamLoop();
|
|
332
|
+
return;
|
|
333
|
+
}
|
|
334
|
+
becomeFollower();
|
|
335
|
+
lockAbort = new AbortController();
|
|
336
|
+
locks.request(
|
|
337
|
+
`easy-ping:leader:${groupKey}`,
|
|
338
|
+
{ mode: "exclusive", signal: lockAbort.signal },
|
|
339
|
+
() => new Promise((resolve) => {
|
|
340
|
+
releaseLock = resolve;
|
|
341
|
+
if (running) becomeLeader();
|
|
342
|
+
else resolve();
|
|
343
|
+
})
|
|
344
|
+
).catch(() => {
|
|
345
|
+
});
|
|
346
|
+
}
|
|
347
|
+
function start() {
|
|
348
|
+
running = true;
|
|
349
|
+
channel = makeChannel ? makeChannel() : void 0;
|
|
350
|
+
channel?.addEventListener("message", onTabMessage);
|
|
351
|
+
serviceWorker?.addEventListener("message", onRelay);
|
|
352
|
+
if (activityTarget) {
|
|
353
|
+
for (const name of ACTIVITY_EVENTS) activityTarget.addEventListener(name, onActivity);
|
|
354
|
+
}
|
|
355
|
+
if (typeof document !== "undefined") {
|
|
356
|
+
document.addEventListener("visibilitychange", onVisibility);
|
|
357
|
+
}
|
|
358
|
+
activeUntil = now() + activeWindowMs;
|
|
359
|
+
acquireLeadership();
|
|
360
|
+
}
|
|
361
|
+
function stop() {
|
|
362
|
+
running = false;
|
|
363
|
+
clearTimeout(timer);
|
|
364
|
+
timer = void 0;
|
|
365
|
+
streamAbort?.abort();
|
|
366
|
+
streamAbort = void 0;
|
|
367
|
+
lockAbort?.abort();
|
|
368
|
+
lockAbort = void 0;
|
|
369
|
+
releaseLock?.();
|
|
370
|
+
releaseLock = void 0;
|
|
371
|
+
channel?.removeEventListener("message", onTabMessage);
|
|
372
|
+
channel?.close();
|
|
373
|
+
channel = void 0;
|
|
374
|
+
serviceWorker?.removeEventListener("message", onRelay);
|
|
375
|
+
if (activityTarget) {
|
|
376
|
+
for (const name of ACTIVITY_EVENTS) activityTarget.removeEventListener(name, onActivity);
|
|
377
|
+
}
|
|
378
|
+
if (typeof document !== "undefined") {
|
|
379
|
+
document.removeEventListener("visibilitychange", onVisibility);
|
|
380
|
+
}
|
|
381
|
+
role = "solo";
|
|
382
|
+
}
|
|
383
|
+
function onRelay(event) {
|
|
384
|
+
const data = event.data;
|
|
385
|
+
if (data?.source === "easy-ping" && data.type === "changed") changed();
|
|
386
|
+
}
|
|
387
|
+
let lastInboxVersion = null;
|
|
388
|
+
const sameOrigin = (response) => {
|
|
389
|
+
if (!response.url || typeof location === "undefined") return true;
|
|
390
|
+
try {
|
|
391
|
+
return new URL(response.url).origin === location.origin;
|
|
392
|
+
} catch {
|
|
393
|
+
return false;
|
|
394
|
+
}
|
|
395
|
+
};
|
|
396
|
+
function instrument(inner) {
|
|
397
|
+
return (async (...args) => {
|
|
398
|
+
const response = await inner(...args);
|
|
399
|
+
const version = sameOrigin(response) ? response.headers.get("x-easy-ping-inbox") : null;
|
|
400
|
+
if (version !== null && version !== lastInboxVersion) {
|
|
401
|
+
const first = lastInboxVersion === null;
|
|
402
|
+
lastInboxVersion = version;
|
|
403
|
+
if (!first && role !== "follower") changed();
|
|
404
|
+
}
|
|
405
|
+
return response;
|
|
406
|
+
});
|
|
130
407
|
}
|
|
131
408
|
function subscribe(listener) {
|
|
132
409
|
listeners.add(listener);
|
|
133
410
|
listener(state);
|
|
134
|
-
if (listeners.size === 1)
|
|
411
|
+
if (listeners.size === 1) start();
|
|
135
412
|
return () => {
|
|
136
413
|
listeners.delete(listener);
|
|
137
|
-
if (listeners.size === 0)
|
|
138
|
-
clearTimeout(timer);
|
|
139
|
-
timer = void 0;
|
|
140
|
-
}
|
|
414
|
+
if (listeners.size === 0) stop();
|
|
141
415
|
};
|
|
142
416
|
}
|
|
143
417
|
return {
|
|
@@ -147,7 +421,13 @@ function createNotifyClient(options = {}) {
|
|
|
147
421
|
markAsRead,
|
|
148
422
|
markAllRead,
|
|
149
423
|
markSeen,
|
|
150
|
-
|
|
424
|
+
instrument,
|
|
425
|
+
getState: () => state,
|
|
426
|
+
getTransport: () => ({
|
|
427
|
+
role,
|
|
428
|
+
transport: streamDisabled ? "poll" : "sse",
|
|
429
|
+
connected: streamConnected
|
|
430
|
+
})
|
|
151
431
|
};
|
|
152
432
|
}
|
|
153
433
|
|