@uekichinos/sentinel 0.1.0 → 0.2.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/CHANGELOG.md CHANGED
@@ -2,6 +2,13 @@
2
2
 
3
3
  All notable changes to `@uekichinos/sentinel` are documented here.
4
4
 
5
+ ## [0.2.0] - 2026-04-12
6
+ ### Added
7
+ - `sentinel.getRemainingMs()` — returns milliseconds until idle; useful for countdown indicators and progress bars
8
+ - `notify.body` factory function — `body` can now be `() => unknown`, evaluated at idle time rather than at init; useful for capturing dynamic state (e.g. current user ID or session ID)
9
+ - Async `notify.headers` support — `headers` function can now return `Promise<Record<string, string>>`, enabling async token refresh before the idle request fires
10
+ - 17 new tests covering the above additions (45 total)
11
+
5
12
  ## [0.1.0] - 2026-04-12
6
13
  ### Added
7
14
  - Initial release
package/README.md CHANGED
@@ -66,6 +66,18 @@ Restarts the idle countdown from zero. If currently idle, transitions back to ac
66
66
 
67
67
  Returns `true` if the user is currently idle.
68
68
 
69
+ ### `sentinel.getRemainingMs()`
70
+
71
+ Returns the number of milliseconds remaining until the user is considered idle. Returns `0` when already idle or when the sentinel has not been started.
72
+
73
+ Useful for building countdown indicators or progress bars:
74
+
75
+ ```js
76
+ setInterval(() => {
77
+ progressBar.style.width = `${(sentinel.getRemainingMs() / timeoutMs) * 100}%`
78
+ }, 100)
79
+ ```
80
+
69
81
  ---
70
82
 
71
83
  ## Options
@@ -119,17 +131,26 @@ const sentinel = createSentinel({
119
131
  |--------|------|---------|-------------|
120
132
  | `url` | `string` | — | Endpoint to call |
121
133
  | `method` | `string` | `'POST'` | HTTP method |
122
- | `headers` | `Record<string, string> \| () => Record<string, string>` | — | Static or dynamic headers |
123
- | `body` | `unknown` | — | Request body — serialised to JSON |
134
+ | `headers` | `Record<string, string> \| () => Record<string, string> \| Promise<Record<string, string>>` | — | Static or dynamic headers (sync or async) |
135
+ | `body` | `unknown \| () => unknown` | — | Request body — serialised to JSON, or a factory evaluated at idle time |
124
136
 
125
- **Headers as a function** — the function is called at the moment idle fires, not at init. This ensures you always send a fresh token rather than one captured when the page loaded.
137
+ **Headers as a function** — the function is called at the moment idle fires, not at init. Supports both sync and async functions. This ensures you always send a fresh token rather than one captured when the page loaded.
126
138
 
127
139
  ```js
128
140
  // Token captured at init — may be stale after a refresh
129
141
  headers: { Authorization: `Bearer ${getToken()}` }
130
142
 
131
- // Token evaluated at idle time — always fresh
143
+ // Token evaluated at idle time — always fresh (sync)
132
144
  headers: () => ({ Authorization: `Bearer ${getToken()}` })
145
+
146
+ // Async token refresh — awaited before the request fires
147
+ headers: async () => ({ Authorization: `Bearer ${await refreshToken()}` })
148
+ ```
149
+
150
+ **Body as a function** — like headers, a body factory is evaluated at idle time rather than at init. Useful for capturing dynamic state:
151
+
152
+ ```js
153
+ body: () => ({ userId: store.user.id, sessionId: store.session.id })
133
154
  ```
134
155
 
135
156
  The notify request fails silently on network error — `onIdle` always fires regardless.
@@ -181,6 +202,20 @@ pollInterval = setInterval(fetchData, 5000)
181
202
  sentinel.start()
182
203
  ```
183
204
 
205
+ ### Countdown indicator
206
+
207
+ ```js
208
+ const TIMEOUT_MS = 15 * 60 * 1000 // 15m in ms
209
+
210
+ const sentinel = createSentinel({ timeout: TIMEOUT_MS })
211
+ sentinel.start()
212
+
213
+ setInterval(() => {
214
+ const pct = (sentinel.getRemainingMs() / TIMEOUT_MS) * 100
215
+ progressBar.style.width = `${pct}%`
216
+ }, 200)
217
+ ```
218
+
184
219
  ### Stop on page unload
185
220
 
186
221
  ```js
package/dist/index.cjs CHANGED
@@ -45,13 +45,18 @@ function parseTtl(ttl) {
45
45
  // src/index.ts
46
46
  var DEFAULT_EVENTS = ["mousemove", "keydown", "scroll", "click", "touchstart"];
47
47
  var DEFAULT_THROTTLE = 500;
48
- function resolveHeaders(headers) {
48
+ async function resolveHeaders(headers) {
49
49
  if (!headers) return {};
50
- return typeof headers === "function" ? headers() : headers;
50
+ const result = typeof headers === "function" ? headers() : headers;
51
+ return result instanceof Promise ? await result : result;
52
+ }
53
+ function resolveBody(body) {
54
+ return typeof body === "function" ? body() : body;
51
55
  }
52
56
  async function fireNotify(notify) {
53
- const headers = resolveHeaders(notify.headers);
54
- const hasBody = notify.body !== void 0;
57
+ const headers = await resolveHeaders(notify.headers);
58
+ const body = resolveBody(notify.body);
59
+ const hasBody = body !== void 0;
55
60
  try {
56
61
  await fetch(notify.url, {
57
62
  method: notify.method ?? "POST",
@@ -59,7 +64,7 @@ async function fireNotify(notify) {
59
64
  ...hasBody ? { "Content-Type": "application/json" } : {},
60
65
  ...headers
61
66
  },
62
- ...hasBody ? { body: JSON.stringify(notify.body) } : {}
67
+ ...hasBody ? { body: JSON.stringify(body) } : {}
63
68
  });
64
69
  } catch {
65
70
  }
@@ -72,13 +77,16 @@ function createSentinel(options) {
72
77
  const throttleMs = options.throttle ?? DEFAULT_THROTTLE;
73
78
  const watchVisibility = options.watchVisibility ?? true;
74
79
  let timer = null;
80
+ let timerTarget = null;
75
81
  let idle = false;
76
82
  let started = false;
77
83
  let lastActivity = 0;
78
84
  function scheduleIdle() {
79
85
  if (timer !== null) clearTimeout(timer);
86
+ timerTarget = Date.now() + timeoutMs;
80
87
  timer = setTimeout(() => {
81
88
  timer = null;
89
+ timerTarget = null;
82
90
  idle = true;
83
91
  options.onIdle?.();
84
92
  if (options.notify) fireNotify(options.notify);
@@ -143,6 +151,10 @@ function createSentinel(options) {
143
151
  },
144
152
  isIdle() {
145
153
  return idle;
154
+ },
155
+ getRemainingMs() {
156
+ if (idle || !started || timerTarget === null) return 0;
157
+ return Math.max(0, timerTarget - Date.now());
146
158
  }
147
159
  };
148
160
  }
package/dist/index.d.cts CHANGED
@@ -6,11 +6,14 @@ type NotifyOptions = {
6
6
  method?: string;
7
7
  /**
8
8
  * Headers to include in the request.
9
- * Pass a function to evaluate headers at call time — useful for dynamic tokens.
9
+ * Pass a function (sync or async) to evaluate headers at call time — useful for dynamic or refreshed tokens.
10
10
  */
11
- headers?: Record<string, string> | (() => Record<string, string>);
12
- /** Optional request body — serialised to JSON */
13
- body?: unknown;
11
+ headers?: Record<string, string> | (() => Record<string, string> | Promise<Record<string, string>>);
12
+ /**
13
+ * Optional request body — serialised to JSON.
14
+ * Pass a function to evaluate the body at idle time — useful for capturing dynamic state.
15
+ */
16
+ body?: unknown | (() => unknown);
14
17
  };
15
18
  type SentinelOptions = {
16
19
  /** How long before the user is considered idle */
@@ -37,6 +40,8 @@ type SentinelInstance = {
37
40
  reset(): void;
38
41
  /** Returns true if the user is currently idle */
39
42
  isIdle(): boolean;
43
+ /** Returns milliseconds remaining until idle. Returns 0 when already idle or not started. */
44
+ getRemainingMs(): number;
40
45
  };
41
46
 
42
47
  /**
package/dist/index.d.ts CHANGED
@@ -6,11 +6,14 @@ type NotifyOptions = {
6
6
  method?: string;
7
7
  /**
8
8
  * Headers to include in the request.
9
- * Pass a function to evaluate headers at call time — useful for dynamic tokens.
9
+ * Pass a function (sync or async) to evaluate headers at call time — useful for dynamic or refreshed tokens.
10
10
  */
11
- headers?: Record<string, string> | (() => Record<string, string>);
12
- /** Optional request body — serialised to JSON */
13
- body?: unknown;
11
+ headers?: Record<string, string> | (() => Record<string, string> | Promise<Record<string, string>>);
12
+ /**
13
+ * Optional request body — serialised to JSON.
14
+ * Pass a function to evaluate the body at idle time — useful for capturing dynamic state.
15
+ */
16
+ body?: unknown | (() => unknown);
14
17
  };
15
18
  type SentinelOptions = {
16
19
  /** How long before the user is considered idle */
@@ -37,6 +40,8 @@ type SentinelInstance = {
37
40
  reset(): void;
38
41
  /** Returns true if the user is currently idle */
39
42
  isIdle(): boolean;
43
+ /** Returns milliseconds remaining until idle. Returns 0 when already idle or not started. */
44
+ getRemainingMs(): number;
40
45
  };
41
46
 
42
47
  /**
@@ -45,13 +45,18 @@ var Sentinel = (() => {
45
45
  // src/index.ts
46
46
  var DEFAULT_EVENTS = ["mousemove", "keydown", "scroll", "click", "touchstart"];
47
47
  var DEFAULT_THROTTLE = 500;
48
- function resolveHeaders(headers) {
48
+ async function resolveHeaders(headers) {
49
49
  if (!headers) return {};
50
- return typeof headers === "function" ? headers() : headers;
50
+ const result = typeof headers === "function" ? headers() : headers;
51
+ return result instanceof Promise ? await result : result;
52
+ }
53
+ function resolveBody(body) {
54
+ return typeof body === "function" ? body() : body;
51
55
  }
52
56
  async function fireNotify(notify) {
53
- const headers = resolveHeaders(notify.headers);
54
- const hasBody = notify.body !== void 0;
57
+ const headers = await resolveHeaders(notify.headers);
58
+ const body = resolveBody(notify.body);
59
+ const hasBody = body !== void 0;
55
60
  try {
56
61
  await fetch(notify.url, {
57
62
  method: notify.method ?? "POST",
@@ -59,7 +64,7 @@ var Sentinel = (() => {
59
64
  ...hasBody ? { "Content-Type": "application/json" } : {},
60
65
  ...headers
61
66
  },
62
- ...hasBody ? { body: JSON.stringify(notify.body) } : {}
67
+ ...hasBody ? { body: JSON.stringify(body) } : {}
63
68
  });
64
69
  } catch {
65
70
  }
@@ -72,13 +77,16 @@ var Sentinel = (() => {
72
77
  const throttleMs = options.throttle ?? DEFAULT_THROTTLE;
73
78
  const watchVisibility = options.watchVisibility ?? true;
74
79
  let timer = null;
80
+ let timerTarget = null;
75
81
  let idle = false;
76
82
  let started = false;
77
83
  let lastActivity = 0;
78
84
  function scheduleIdle() {
79
85
  if (timer !== null) clearTimeout(timer);
86
+ timerTarget = Date.now() + timeoutMs;
80
87
  timer = setTimeout(() => {
81
88
  timer = null;
89
+ timerTarget = null;
82
90
  idle = true;
83
91
  options.onIdle?.();
84
92
  if (options.notify) fireNotify(options.notify);
@@ -143,6 +151,10 @@ var Sentinel = (() => {
143
151
  },
144
152
  isIdle() {
145
153
  return idle;
154
+ },
155
+ getRemainingMs() {
156
+ if (idle || !started || timerTarget === null) return 0;
157
+ return Math.max(0, timerTarget - Date.now());
146
158
  }
147
159
  };
148
160
  }
package/dist/index.js CHANGED
@@ -19,13 +19,18 @@ function parseTtl(ttl) {
19
19
  // src/index.ts
20
20
  var DEFAULT_EVENTS = ["mousemove", "keydown", "scroll", "click", "touchstart"];
21
21
  var DEFAULT_THROTTLE = 500;
22
- function resolveHeaders(headers) {
22
+ async function resolveHeaders(headers) {
23
23
  if (!headers) return {};
24
- return typeof headers === "function" ? headers() : headers;
24
+ const result = typeof headers === "function" ? headers() : headers;
25
+ return result instanceof Promise ? await result : result;
26
+ }
27
+ function resolveBody(body) {
28
+ return typeof body === "function" ? body() : body;
25
29
  }
26
30
  async function fireNotify(notify) {
27
- const headers = resolveHeaders(notify.headers);
28
- const hasBody = notify.body !== void 0;
31
+ const headers = await resolveHeaders(notify.headers);
32
+ const body = resolveBody(notify.body);
33
+ const hasBody = body !== void 0;
29
34
  try {
30
35
  await fetch(notify.url, {
31
36
  method: notify.method ?? "POST",
@@ -33,7 +38,7 @@ async function fireNotify(notify) {
33
38
  ...hasBody ? { "Content-Type": "application/json" } : {},
34
39
  ...headers
35
40
  },
36
- ...hasBody ? { body: JSON.stringify(notify.body) } : {}
41
+ ...hasBody ? { body: JSON.stringify(body) } : {}
37
42
  });
38
43
  } catch {
39
44
  }
@@ -46,13 +51,16 @@ function createSentinel(options) {
46
51
  const throttleMs = options.throttle ?? DEFAULT_THROTTLE;
47
52
  const watchVisibility = options.watchVisibility ?? true;
48
53
  let timer = null;
54
+ let timerTarget = null;
49
55
  let idle = false;
50
56
  let started = false;
51
57
  let lastActivity = 0;
52
58
  function scheduleIdle() {
53
59
  if (timer !== null) clearTimeout(timer);
60
+ timerTarget = Date.now() + timeoutMs;
54
61
  timer = setTimeout(() => {
55
62
  timer = null;
63
+ timerTarget = null;
56
64
  idle = true;
57
65
  options.onIdle?.();
58
66
  if (options.notify) fireNotify(options.notify);
@@ -117,6 +125,10 @@ function createSentinel(options) {
117
125
  },
118
126
  isIdle() {
119
127
  return idle;
128
+ },
129
+ getRemainingMs() {
130
+ if (idle || !started || timerTarget === null) return 0;
131
+ return Math.max(0, timerTarget - Date.now());
120
132
  }
121
133
  };
122
134
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@uekichinos/sentinel",
3
- "version": "0.1.0",
3
+ "version": "0.2.0",
4
4
  "description": "Lightweight idle detection for the browser. Fires callbacks and optionally notifies a backend when the user goes inactive. Supports TTL timeouts, tab visibility pausing, activity throttling, and dynamic auth headers. Zero dependencies.",
5
5
  "keywords": [
6
6
  "uekichinos",