@mainframework/timer 1.0.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/README.md +167 -0
- package/dist/react.js +2 -0
- package/dist/react.js.map +1 -0
- package/dist/types/hooks/useTimer.d.ts +8 -0
- package/dist/types/hooks/useTimer.d.ts.map +1 -0
- package/dist/types/index.d.ts +4 -0
- package/dist/types/index.d.ts.map +1 -0
- package/dist/types/react.d.ts +2 -0
- package/dist/types/react.d.ts.map +1 -0
- package/dist/types/types/index.d.ts +50 -0
- package/dist/types/types/index.d.ts.map +1 -0
- package/dist/types/utils/routes.d.ts +3 -0
- package/dist/types/utils/routes.d.ts.map +1 -0
- package/dist/types/worker/createWorker.d.ts +17 -0
- package/dist/types/worker/createWorker.d.ts.map +1 -0
- package/dist/types/worker/timer.worker.d.ts +2 -0
- package/dist/types/worker/timer.worker.d.ts.map +1 -0
- package/dist/vanilla/index.js +2 -0
- package/dist/vanilla/index.js.map +1 -0
- package/dist/worker/timer.worker.js +2 -0
- package/dist/worker/timer.worker.js.map +1 -0
- package/package.json +81 -0
package/README.md
ADDED
|
@@ -0,0 +1,167 @@
|
|
|
1
|
+
# @mainframework/timer
|
|
2
|
+
|
|
3
|
+
Countdown and count-up timers for the browser, backed by a shared Web Worker. Timers tick off the main thread so your UI stays responsive.
|
|
4
|
+
|
|
5
|
+
## Important: client-side only
|
|
6
|
+
|
|
7
|
+
This library is a **browser-only, client-side** package. It depends on `window`, `Worker`, and `window.location`.
|
|
8
|
+
|
|
9
|
+
- **Do not use** in Node.js, Express, or other server-side runtimes.
|
|
10
|
+
- **Not for SSR timer execution.** You may import it in SSR frameworks (e.g. Next.js), but timers only run after client hydration. On the server, `useTimer` returns the initial `durationSeconds` in `"down"` mode and `0` in `"up"` mode; it does not tick.
|
|
11
|
+
- `createWorker()` returns `null` when `window` is undefined, so imports are safe in isomorphic bundles — the timer simply does not run until the browser.
|
|
12
|
+
|
|
13
|
+
## Requirements
|
|
14
|
+
|
|
15
|
+
- A modern browser with Web Worker support
|
|
16
|
+
- An ESM-capable bundler that resolves worker URLs via `import.meta.url` (Vite, webpack 5, etc.)
|
|
17
|
+
- React >= 19 (only when using `@mainframework/timer/react`)
|
|
18
|
+
|
|
19
|
+
This package is **not** a drop-in for plain `<script>` tags without a bundler.
|
|
20
|
+
|
|
21
|
+
## Installation
|
|
22
|
+
|
|
23
|
+
```bash
|
|
24
|
+
npm install @mainframework/timer
|
|
25
|
+
```
|
|
26
|
+
|
|
27
|
+
```bash
|
|
28
|
+
yarn add @mainframework/timer
|
|
29
|
+
```
|
|
30
|
+
|
|
31
|
+
React is an optional peer dependency. Install React separately if you use the hook entry:
|
|
32
|
+
|
|
33
|
+
```bash
|
|
34
|
+
npm install react
|
|
35
|
+
```
|
|
36
|
+
|
|
37
|
+
## React usage
|
|
38
|
+
|
|
39
|
+
Import `useTimer` from the React entry point. The React entry ships with a `"use client"` directive, so it works in Next.js App Router without an extra directive on your component.
|
|
40
|
+
|
|
41
|
+
```tsx
|
|
42
|
+
import { useTimer } from "@mainframework/timer/react";
|
|
43
|
+
|
|
44
|
+
export function Countdown() {
|
|
45
|
+
const secondsLeft = useTimer(60);
|
|
46
|
+
return <span>{secondsLeft}s</span>;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export function Stopwatch() {
|
|
50
|
+
const secondsElapsed = useTimer(0, undefined, "up");
|
|
51
|
+
return <span>{secondsElapsed}s</span>;
|
|
52
|
+
}
|
|
53
|
+
```
|
|
54
|
+
|
|
55
|
+
### `useTimer(durationSeconds, routeKey?, mode?)`
|
|
56
|
+
|
|
57
|
+
| Parameter | Type | Description |
|
|
58
|
+
| ----------------- | ---------------- | ------------------------------------------------------------------------------ |
|
|
59
|
+
| `durationSeconds` | `number` | For `"down"`: countdown length. For `"up"`: ignored (pass `0`). |
|
|
60
|
+
| `routeKey` | `string?` | Optional. Defaults to `window.location.pathname`. Scopes the timer to a route. |
|
|
61
|
+
| `mode` | `"down" \| "up"` | Optional. Default `"down"`. |
|
|
62
|
+
|
|
63
|
+
Returns seconds remaining in `"down"` mode, or seconds elapsed in `"up"` mode. Emits ticks every second until the countdown reaches zero (`"down"` only).
|
|
64
|
+
|
|
65
|
+
## Vanilla JS usage
|
|
66
|
+
|
|
67
|
+
The main entry exports low-level utilities. You register timers, listen for messages, and unregister yourself.
|
|
68
|
+
|
|
69
|
+
### Countdown
|
|
70
|
+
|
|
71
|
+
```js
|
|
72
|
+
import { createWorker, getDefaultRouteKey } from "@mainframework/timer";
|
|
73
|
+
|
|
74
|
+
const worker = createWorker();
|
|
75
|
+
if (!worker) throw new Error("Timer requires a browser environment");
|
|
76
|
+
|
|
77
|
+
const timerId = crypto.randomUUID();
|
|
78
|
+
const routeKey = getDefaultRouteKey();
|
|
79
|
+
|
|
80
|
+
worker.onmessage = (e) => {
|
|
81
|
+
const msg = e.data;
|
|
82
|
+
if (msg.id !== timerId) return;
|
|
83
|
+
if (msg.type === "tick" && msg.mode === "down") console.log(msg.secondsLeft);
|
|
84
|
+
if (msg.type === "expired") console.log("done");
|
|
85
|
+
};
|
|
86
|
+
|
|
87
|
+
worker.postMessage({
|
|
88
|
+
type: "register",
|
|
89
|
+
routeKey,
|
|
90
|
+
id: timerId,
|
|
91
|
+
mode: "down",
|
|
92
|
+
durationSeconds: 60,
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
// cleanup when done
|
|
96
|
+
worker.postMessage({ type: "unregister", routeKey, id: timerId });
|
|
97
|
+
```
|
|
98
|
+
|
|
99
|
+
### Count-up
|
|
100
|
+
|
|
101
|
+
```js
|
|
102
|
+
worker.onmessage = (e) => {
|
|
103
|
+
const msg = e.data;
|
|
104
|
+
if (msg.id !== timerId) return;
|
|
105
|
+
if (msg.type === "tick" && msg.mode === "up") console.log(msg.secondsElapsed);
|
|
106
|
+
};
|
|
107
|
+
|
|
108
|
+
worker.postMessage({
|
|
109
|
+
type: "register",
|
|
110
|
+
routeKey,
|
|
111
|
+
id: timerId,
|
|
112
|
+
mode: "up",
|
|
113
|
+
});
|
|
114
|
+
```
|
|
115
|
+
|
|
116
|
+
### Route management (SPAs)
|
|
117
|
+
|
|
118
|
+
When navigating between routes, notify the worker of the active route to purge timers from inactive routes:
|
|
119
|
+
|
|
120
|
+
```js
|
|
121
|
+
worker.postMessage({ type: "route", activeRoute: "/dashboard" });
|
|
122
|
+
```
|
|
123
|
+
|
|
124
|
+
The `useTimer` hook does not send route messages today; use this directly when managing timers with `createWorker`.
|
|
125
|
+
|
|
126
|
+
## API reference
|
|
127
|
+
|
|
128
|
+
### `@mainframework/timer`
|
|
129
|
+
|
|
130
|
+
| Export | Description |
|
|
131
|
+
| ---------------------------- | ------------------------------------------------------------------------ |
|
|
132
|
+
| `createWorker()` | Returns the singleton `Worker`, or `null` outside a browser environment. |
|
|
133
|
+
| `getDefaultRouteKey()` | Returns `window.location.pathname`, or `""` on the server. |
|
|
134
|
+
| `TimerMode` | Type: `"down" \| "up"`. |
|
|
135
|
+
| `TimerWorkerIncomingMessage` | Type for main thread → worker messages. |
|
|
136
|
+
| `TimerWorkerMessage` | Type for worker → main thread messages. |
|
|
137
|
+
|
|
138
|
+
### `@mainframework/timer/react`
|
|
139
|
+
|
|
140
|
+
| Export | Description |
|
|
141
|
+
| --------------------------------------------- | ---------------------------------------------------------------------- |
|
|
142
|
+
| `useTimer(durationSeconds, routeKey?, mode?)` | React hook returning seconds remaining (`"down"`) or elapsed (`"up"`). |
|
|
143
|
+
|
|
144
|
+
### Worker message protocol
|
|
145
|
+
|
|
146
|
+
**Main thread → worker (`TimerWorkerIncomingMessage`):**
|
|
147
|
+
|
|
148
|
+
| Type | Payload |
|
|
149
|
+
| ------------ | -------------------------------------------------------------------------------------------------------- |
|
|
150
|
+
| `register` | `{ routeKey, id, mode, durationSeconds? }` — `durationSeconds` required for `"down"`, omitted for `"up"` |
|
|
151
|
+
| `unregister` | `{ routeKey, id }` |
|
|
152
|
+
| `route` | `{ activeRoute }` |
|
|
153
|
+
|
|
154
|
+
**Worker → main thread (`TimerWorkerMessage`):**
|
|
155
|
+
|
|
156
|
+
| Type | Payload |
|
|
157
|
+
| ------------- | ------------------------------------ |
|
|
158
|
+
| `tick` (down) | `{ id, mode: "down", secondsLeft }` |
|
|
159
|
+
| `tick` (up) | `{ id, mode: "up", secondsElapsed }` |
|
|
160
|
+
| `expired` | `{ id }` — countdown only |
|
|
161
|
+
|
|
162
|
+
## License
|
|
163
|
+
|
|
164
|
+
MIT — see [package.json](./package.json).
|
|
165
|
+
|
|
166
|
+
- Repository: [github.com/TerrySlack/mainframework-timer](https://github.com/TerrySlack/mainframework-timer)
|
|
167
|
+
- Issues: [github.com/TerrySlack/mainframework-timer/issues](https://github.com/TerrySlack/mainframework-timer/issues)
|
package/dist/react.js
ADDED
|
@@ -0,0 +1,2 @@
|
|
|
1
|
+
"use client";import{useId as e,useRef as t,useState as o,useEffect as n}from"react";import{createWorker as r,getDefaultRouteKey as s}from"./vanilla/index.js";const d=r(),c=new Map;d&&!d.onmessage&&(d.onmessage=e=>{const t=e.data;t?.id&&c.get(t.id)?.(t)});const i=(r,i,u="down")=>{const a=e(),p=t(null),[m,y]=o("down"===u?r:0),g=t(i??s());if(d){c.set(a,e=>{"tick"===e.type&&"down"===e.mode&&y(e.secondsLeft),"tick"===e.type&&"up"===e.mode&&y(e.secondsElapsed),"expired"===e.type&&y(0)});const e=p.current;e&&e.durationSeconds===r&&e.routeKey===g.current&&e.mode===u||(p.current={durationSeconds:r,routeKey:g.current,mode:u},d.postMessage({type:"register",routeKey:g.current,id:a,mode:u,durationSeconds:r}))}return n(()=>()=>{c.delete(a),d?.postMessage({type:"unregister",routeKey:i??s(),id:a})},[a,i]),m};export{i as useTimer};
|
|
2
|
+
//# sourceMappingURL=react.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"react.js","sources":["../src/hooks/useTimer.ts"],"sourcesContent":["import { useEffect, useId, useRef, useState } from \"react\";\r\n\r\nimport { createWorker } from \"../worker/createWorker\";\r\nimport { getDefaultRouteKey } from \"../utils/routes\";\r\nimport type { TimerMode, TimerWorkerMessage } from \"../types\";\r\n\r\nconst worker = createWorker();\r\n// One shared dispatcher so every hook instance gets its own messages\r\nconst listeners = new Map<string, (msg: TimerWorkerMessage) => void>();\r\n\r\nif (worker && !worker.onmessage) {\r\n worker.onmessage = (e: MessageEvent<TimerWorkerMessage>): void => {\r\n const msg = e.data;\r\n if (!msg?.id) return;\r\n listeners.get(msg.id)?.(msg);\r\n };\r\n}\r\n\r\n/**\r\n * Drives a timer via the shared worker.\r\n * mode \"down\": durationSeconds counts down to 0. Returns seconds remaining.\r\n * mode \"up\": durationSeconds is ignored. Returns seconds elapsed since mount/registration.\r\n */\r\nexport const useTimer = (durationSeconds: number, routeKey?: string, mode: TimerMode = \"down\"): number => {\r\n const id = useId();\r\n const lastRegister = useRef<{ durationSeconds: number; routeKey: string; mode: TimerMode } | null>(null);\r\n const [value, setValue] = useState(mode === \"down\" ? durationSeconds : 0);\r\n const keyRef = useRef<string>(routeKey ?? getDefaultRouteKey());\r\n\r\n if (worker) {\r\n listeners.set(id, (msg: TimerWorkerMessage): void => {\r\n if (msg.type === \"tick\" && msg.mode === \"down\") setValue(msg.secondsLeft);\r\n if (msg.type === \"tick\" && msg.mode === \"up\") setValue(msg.secondsElapsed);\r\n if (msg.type === \"expired\") setValue(0);\r\n });\r\n\r\n const prev = lastRegister.current;\r\n if (!prev || prev.durationSeconds !== durationSeconds || prev.routeKey !== keyRef.current || prev.mode !== mode) {\r\n lastRegister.current = { durationSeconds, routeKey: keyRef.current, mode };\r\n worker.postMessage({\r\n type: \"register\",\r\n routeKey: keyRef.current,\r\n id,\r\n mode,\r\n durationSeconds,\r\n });\r\n }\r\n }\r\n\r\n useEffect(() => {\r\n return () => {\r\n listeners.delete(id);\r\n worker?.postMessage({\r\n type: \"unregister\",\r\n routeKey: routeKey ?? getDefaultRouteKey(),\r\n id,\r\n });\r\n };\r\n }, [id, routeKey]);\r\n\r\n return value;\r\n};\r\n"],"names":["worker","createWorker","listeners","Map","onmessage","e","msg","data","id","get","useTimer","durationSeconds","routeKey","mode","useId","lastRegister","useRef","value","setValue","useState","keyRef","getDefaultRouteKey","set","type","secondsLeft","secondsElapsed","prev","current","postMessage","useEffect","delete"],"mappings":"8JAMA,MAAMA,EAASC,IAETC,EAAY,IAAIC,IAElBH,IAAWA,EAAOI,YACpBJ,EAAOI,UAAaC,IAClB,MAAMC,EAAMD,EAAEE,KACTD,GAAKE,IACVN,EAAUO,IAAIH,EAAIE,GAAlBN,GAAwBI,KASrB,MAAMI,EAAW,CAACC,EAAyBC,EAAmBC,EAAkB,UACrF,MAAML,EAAKM,IACLC,EAAeC,EAA8E,OAC5FC,EAAOC,GAAYC,EAAkB,SAATN,EAAkBF,EAAkB,GACjES,EAASJ,EAAeJ,GAAYS,KAE1C,GAAIrB,EAAQ,CACVE,EAAUoB,IAAId,EAAKF,IACA,SAAbA,EAAIiB,MAAgC,SAAbjB,EAAIO,MAAiBK,EAASZ,EAAIkB,aAC5C,SAAblB,EAAIiB,MAAgC,OAAbjB,EAAIO,MAAeK,EAASZ,EAAImB,gBAC1C,YAAbnB,EAAIiB,MAAoBL,EAAS,KAGvC,MAAMQ,EAAOX,EAAaY,QACrBD,GAAQA,EAAKf,kBAAoBA,GAAmBe,EAAKd,WAAaQ,EAAOO,SAAWD,EAAKb,OAASA,IACzGE,EAAaY,QAAU,CAAEhB,kBAAiBC,SAAUQ,EAAOO,QAASd,QACpEb,EAAO4B,YAAY,CACjBL,KAAM,WACNX,SAAUQ,EAAOO,QACjBnB,KACAK,OACAF,oBAGN,CAaA,OAXAkB,EAAU,IACD,KACL3B,EAAU4B,OAAOtB,GACjBR,GAAQ4B,YAAY,CAClBL,KAAM,aACNX,SAAUA,GAAYS,IACtBb,QAGH,CAACA,EAAII,IAEDK"}
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
import type { TimerMode } from "../types";
|
|
2
|
+
/**
|
|
3
|
+
* Drives a timer via the shared worker.
|
|
4
|
+
* mode "down": durationSeconds counts down to 0. Returns seconds remaining.
|
|
5
|
+
* mode "up": durationSeconds is ignored. Returns seconds elapsed since mount/registration.
|
|
6
|
+
*/
|
|
7
|
+
export declare const useTimer: (durationSeconds: number, routeKey?: string, mode?: TimerMode) => number;
|
|
8
|
+
//# sourceMappingURL=useTimer.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"useTimer.d.ts","sourceRoot":"","sources":["../../../src/hooks/useTimer.ts"],"names":[],"mappings":"AAIA,OAAO,KAAK,EAAE,SAAS,EAAsB,MAAM,UAAU,CAAC;AAc9D;;;;GAIG;AACH,eAAO,MAAM,QAAQ,GAAI,iBAAiB,MAAM,EAAE,WAAW,MAAM,EAAE,OAAM,SAAkB,KAAG,MAsC/F,CAAC"}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../src/index.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,YAAY,EAAE,MAAM,uBAAuB,CAAC;AACrD,OAAO,EAAE,kBAAkB,EAAE,MAAM,gBAAgB,CAAC;AACpD,YAAY,EAAE,SAAS,EAAE,kBAAkB,EAAE,0BAA0B,EAAE,MAAM,SAAS,CAAC"}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"react.d.ts","sourceRoot":"","sources":["../../src/react.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,QAAQ,EAAE,MAAM,kBAAkB,CAAC"}
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
/** Direction a timer counts */
|
|
2
|
+
export type TimerMode = "down" | "up";
|
|
3
|
+
/** Internal worker row state */
|
|
4
|
+
export interface TimerRow {
|
|
5
|
+
mode: TimerMode;
|
|
6
|
+
/** countdown: the epoch ms it ends at. count-up: the epoch ms it started at. */
|
|
7
|
+
anchorEpochMs: number;
|
|
8
|
+
lastEmittedSecond: number;
|
|
9
|
+
}
|
|
10
|
+
/** Per-route bucket: timer id → row */
|
|
11
|
+
export interface RouteTimerState {
|
|
12
|
+
timerCount: number;
|
|
13
|
+
timers: Map<string, TimerRow>;
|
|
14
|
+
}
|
|
15
|
+
/** Full worker store: routeKey → route bucket */
|
|
16
|
+
export interface TimerStore {
|
|
17
|
+
timerCount: number;
|
|
18
|
+
timers: Map<string, RouteTimerState>;
|
|
19
|
+
}
|
|
20
|
+
/** Worker → main thread */
|
|
21
|
+
export type TimerWorkerMessage = {
|
|
22
|
+
type: "tick";
|
|
23
|
+
id: string;
|
|
24
|
+
mode: "down";
|
|
25
|
+
secondsLeft: number;
|
|
26
|
+
} | {
|
|
27
|
+
type: "tick";
|
|
28
|
+
id: string;
|
|
29
|
+
mode: "up";
|
|
30
|
+
secondsElapsed: number;
|
|
31
|
+
} | {
|
|
32
|
+
type: "expired";
|
|
33
|
+
id: string;
|
|
34
|
+
};
|
|
35
|
+
/** Main thread → worker */
|
|
36
|
+
export type TimerWorkerIncomingMessage = {
|
|
37
|
+
type: "route";
|
|
38
|
+
activeRoute: string;
|
|
39
|
+
} | {
|
|
40
|
+
type: "register";
|
|
41
|
+
routeKey: string;
|
|
42
|
+
id: string;
|
|
43
|
+
mode: TimerMode;
|
|
44
|
+
durationSeconds?: number;
|
|
45
|
+
} | {
|
|
46
|
+
type: "unregister";
|
|
47
|
+
routeKey: string;
|
|
48
|
+
id: string;
|
|
49
|
+
};
|
|
50
|
+
//# sourceMappingURL=index.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../../src/types/index.ts"],"names":[],"mappings":"AAAA,+BAA+B;AAC/B,MAAM,MAAM,SAAS,GAAG,MAAM,GAAG,IAAI,CAAC;AAEtC,gCAAgC;AAChC,MAAM,WAAW,QAAQ;IACvB,IAAI,EAAE,SAAS,CAAC;IAChB,gFAAgF;IAChF,aAAa,EAAE,MAAM,CAAC;IACtB,iBAAiB,EAAE,MAAM,CAAC;CAC3B;AAED,uCAAuC;AACvC,MAAM,WAAW,eAAe;IAC9B,UAAU,EAAE,MAAM,CAAC;IACnB,MAAM,EAAE,GAAG,CAAC,MAAM,EAAE,QAAQ,CAAC,CAAC;CAC/B;AAED,iDAAiD;AACjD,MAAM,WAAW,UAAU;IACzB,UAAU,EAAE,MAAM,CAAC;IACnB,MAAM,EAAE,GAAG,CAAC,MAAM,EAAE,eAAe,CAAC,CAAC;CACtC;AAED,2BAA2B;AAC3B,MAAM,MAAM,kBAAkB,GAC1B;IAAE,IAAI,EAAE,MAAM,CAAC;IAAC,EAAE,EAAE,MAAM,CAAC;IAAC,IAAI,EAAE,MAAM,CAAC;IAAC,WAAW,EAAE,MAAM,CAAA;CAAE,GAC/D;IAAE,IAAI,EAAE,MAAM,CAAC;IAAC,EAAE,EAAE,MAAM,CAAC;IAAC,IAAI,EAAE,IAAI,CAAC;IAAC,cAAc,EAAE,MAAM,CAAA;CAAE,GAChE;IAAE,IAAI,EAAE,SAAS,CAAC;IAAC,EAAE,EAAE,MAAM,CAAA;CAAE,CAAC;AAEpC,2BAA2B;AAC3B,MAAM,MAAM,0BAA0B,GAClC;IAAE,IAAI,EAAE,OAAO,CAAC;IAAC,WAAW,EAAE,MAAM,CAAA;CAAE,GACtC;IAAE,IAAI,EAAE,UAAU,CAAC;IAAC,QAAQ,EAAE,MAAM,CAAC;IAAC,EAAE,EAAE,MAAM,CAAC;IAAC,IAAI,EAAE,SAAS,CAAC;IAAC,eAAe,CAAC,EAAE,MAAM,CAAA;CAAE,GAC7F;IAAE,IAAI,EAAE,YAAY,CAAC;IAAC,QAAQ,EAAE,MAAM,CAAC;IAAC,EAAE,EAAE,MAAM,CAAA;CAAE,CAAC"}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"routes.d.ts","sourceRoot":"","sources":["../../../src/utils/routes.ts"],"names":[],"mappings":"AAAA,6FAA6F;AAC7F,eAAO,MAAM,kBAAkB,QAAO,MACyB,CAAC"}
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Lazily creates and returns the singleton timer worker. Safe to call in
|
|
3
|
+
* SSR contexts — returns null until called in a browser environment.
|
|
4
|
+
*
|
|
5
|
+
* This is the only piece of shared plumbing: creating the worker instance.
|
|
6
|
+
* Registering timers, listening for ticks, and filtering messages by id
|
|
7
|
+
* is left to the caller — the hook does its own, a vanilla JS consumer
|
|
8
|
+
* does its own, directly against the returned Worker.
|
|
9
|
+
*
|
|
10
|
+
* NOTE (bundler dependency): `new URL("../worker/timer.worker.js", import.meta.url)`
|
|
11
|
+
* requires the consuming app's bundler to resolve worker files this way
|
|
12
|
+
* (webpack 5, Vite, Next all do). If you need this package to work in
|
|
13
|
+
* bundler-agnostic environments, swap this for a Blob-constructed worker
|
|
14
|
+
* built from the worker source as a string instead.
|
|
15
|
+
*/
|
|
16
|
+
export declare const createWorker: () => Worker | null;
|
|
17
|
+
//# sourceMappingURL=createWorker.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"createWorker.d.ts","sourceRoot":"","sources":["../../../src/worker/createWorker.ts"],"names":[],"mappings":"AAEA;;;;;;;;;;;;;;GAcG;AACH,eAAO,MAAM,YAAY,QAAO,MAAM,GAAG,IAQxC,CAAC"}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"timer.worker.d.ts","sourceRoot":"","sources":["../../../src/worker/timer.worker.ts"],"names":[],"mappings":""}
|
|
@@ -0,0 +1,2 @@
|
|
|
1
|
+
let e=null;const n=()=>"undefined"==typeof window?null:(e||(e=new Worker(new URL("../worker/timer.worker.js",import.meta.url),{type:"module"})),e),o=()=>"undefined"==typeof window?"":window.location.pathname;export{n as createWorker,o as getDefaultRouteKey};
|
|
2
|
+
//# sourceMappingURL=index.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"index.js","sources":["../../src/worker/createWorker.ts","../../src/utils/routes.ts"],"sourcesContent":["let worker: Worker | null = null;\r\n\r\n/**\r\n * Lazily creates and returns the singleton timer worker. Safe to call in\r\n * SSR contexts — returns null until called in a browser environment.\r\n *\r\n * This is the only piece of shared plumbing: creating the worker instance.\r\n * Registering timers, listening for ticks, and filtering messages by id\r\n * is left to the caller — the hook does its own, a vanilla JS consumer\r\n * does its own, directly against the returned Worker.\r\n *\r\n * NOTE (bundler dependency): `new URL(\"../worker/timer.worker.js\", import.meta.url)`\r\n * requires the consuming app's bundler to resolve worker files this way\r\n * (webpack 5, Vite, Next all do). If you need this package to work in\r\n * bundler-agnostic environments, swap this for a Blob-constructed worker\r\n * built from the worker source as a string instead.\r\n */\r\nexport const createWorker = (): Worker | null => {\r\n if (typeof window === \"undefined\") return null;\r\n if (!worker) {\r\n worker = new Worker(new URL(\"../worker/timer.worker.js\", import.meta.url), {\r\n type: \"module\",\r\n });\r\n }\r\n return worker;\r\n};\r\n","/** Convenience default — current pathname. Callers can supply their own routeKey instead. */\r\nexport const getDefaultRouteKey = (): string =>\r\n typeof window === \"undefined\" ? \"\" : window.location.pathname;\r\n"],"names":["worker","createWorker","window","Worker","URL","url","type","getDefaultRouteKey","location","pathname"],"mappings":"AAAA,IAAIA,EAAwB,KAiBrB,MAAMC,EAAe,IACJ,oBAAXC,OAA+B,MACrCF,IACHA,EAAS,IAAIG,OAAO,IAAIC,IAAI,wCAAyCC,KAAM,CACzEC,KAAM,YAGHN,GCvBIO,EAAqB,IACd,oBAAXL,OAAyB,GAAKA,OAAOM,SAASC"}
|
|
@@ -0,0 +1,2 @@
|
|
|
1
|
+
const e={store:{timerCount:0,timers:new Map}};let t=null,o=null;const s=(t,o)=>{const s=e.store.timers.get(t);s&&s.timers.has(o)&&(s.timers.delete(o),s.timerCount--,e.store.timerCount--,0===s.timerCount&&e.store.timers.delete(t))},r=()=>{0===e.store.timerCount&&null!==t&&(clearInterval(t),t=null)},n=()=>{const t=Date.now();for(const[o,r]of e.store.timers)for(const[e,n]of r.timers)if("down"===n.mode){const r=Math.max(0,Math.ceil((n.anchorEpochMs-t)/1e3));if(0===r){0!==n.lastEmittedSecond&&(n.lastEmittedSecond=0,self.postMessage({type:"expired",id:e}),s(o,e));continue}r!==n.lastEmittedSecond&&(n.lastEmittedSecond=r,self.postMessage({type:"tick",id:e,mode:"down",secondsLeft:r}))}else{const o=Math.max(0,Math.floor((t-n.anchorEpochMs)/1e3));o!==n.lastEmittedSecond&&(n.lastEmittedSecond=o,self.postMessage({type:"tick",id:e,mode:"up",secondsElapsed:o}))}r()};self.onmessage=i=>{const{data:d}=i;if("route"===d.type)return o=d.activeRoute,(()=>{if(o)for(const[t,s]of e.store.timers)t!==o&&(e.store.timerCount-=s.timerCount,e.store.timers.delete(t))})(),void r();if("register"===d.type){const o="down"===d.mode?Date.now()+1e3*(d.durationSeconds??0):Date.now();return((t,o,s,r)=>{let n=e.store.timers.get(t);n||(n={timerCount:0,timers:new Map},e.store.timers.set(t,n));const i=n.timers.get(o);i?i.anchorEpochMs===r&&i.mode===s||(i.mode=s,i.anchorEpochMs=r,i.lastEmittedSecond=-1):(n.timers.set(o,{mode:s,anchorEpochMs:r,lastEmittedSecond:-1}),n.timerCount++,e.store.timerCount++)})(d.routeKey,d.id,d.mode,o),null===t&&(t=setInterval(n,1e3)),void n()}"unregister"===d.type&&(s(d.routeKey,d.id),r())};
|
|
2
|
+
//# sourceMappingURL=timer.worker.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"timer.worker.js","sources":["../../src/worker/timer.worker.ts"],"sourcesContent":["/// <reference lib=\"webworker\" />\r\n\r\nimport type { TimerMode, TimerRow, TimerStore, TimerWorkerIncomingMessage } from \"../types\";\r\n\r\nconst state: { store: TimerStore } = {\r\n store: {\r\n timerCount: 0,\r\n timers: new Map(),\r\n },\r\n};\r\n\r\nlet intervalId: ReturnType<typeof setInterval> | null = null;\r\nlet activeRoute: string | null = null;\r\n\r\nconst getCount = (): number => state.store.timerCount;\r\n\r\nconst addTimer = (routeKey: string, id: string, mode: TimerMode, anchorEpochMs: number): void => {\r\n let route = state.store.timers.get(routeKey);\r\n if (!route) {\r\n route = { timerCount: 0, timers: new Map<string, TimerRow>() };\r\n state.store.timers.set(routeKey, route);\r\n }\r\n\r\n const existing = route.timers.get(id);\r\n if (existing) {\r\n if (existing.anchorEpochMs !== anchorEpochMs || existing.mode !== mode) {\r\n existing.mode = mode;\r\n existing.anchorEpochMs = anchorEpochMs;\r\n existing.lastEmittedSecond = -1;\r\n }\r\n return;\r\n }\r\n\r\n route.timers.set(id, { mode, anchorEpochMs, lastEmittedSecond: -1 });\r\n route.timerCount++;\r\n state.store.timerCount++;\r\n};\r\n\r\nconst deleteTimer = (routeKey: string, id: string): void => {\r\n const route = state.store.timers.get(routeKey);\r\n if (!route || !route.timers.has(id)) return;\r\n\r\n route.timers.delete(id);\r\n route.timerCount--;\r\n state.store.timerCount--;\r\n\r\n if (route.timerCount === 0) state.store.timers.delete(routeKey);\r\n};\r\n\r\nconst stopLoopIfIdle = (): void => {\r\n if (getCount() === 0 && intervalId !== null) {\r\n clearInterval(intervalId);\r\n intervalId = null;\r\n }\r\n};\r\n\r\nconst cleanupNonActiveRoutes = (): void => {\r\n if (!activeRoute) return;\r\n for (const [routeKey, route] of state.store.timers) {\r\n if (routeKey === activeRoute) continue;\r\n state.store.timerCount -= route.timerCount;\r\n state.store.timers.delete(routeKey);\r\n }\r\n};\r\n\r\nconst tickOnce = (): void => {\r\n const now = Date.now();\r\n for (const [routeKey, route] of state.store.timers) {\r\n for (const [id, row] of route.timers) {\r\n if (row.mode === \"down\") {\r\n const secondsLeft = Math.max(0, Math.ceil((row.anchorEpochMs - now) / 1000));\r\n if (secondsLeft === 0) {\r\n if (row.lastEmittedSecond !== 0) {\r\n row.lastEmittedSecond = 0;\r\n self.postMessage({ type: \"expired\", id });\r\n deleteTimer(routeKey, id);\r\n }\r\n continue;\r\n }\r\n if (secondsLeft !== row.lastEmittedSecond) {\r\n row.lastEmittedSecond = secondsLeft;\r\n self.postMessage({ type: \"tick\", id, mode: \"down\", secondsLeft });\r\n }\r\n } else {\r\n const secondsElapsed = Math.max(0, Math.floor((now - row.anchorEpochMs) / 1000));\r\n if (secondsElapsed !== row.lastEmittedSecond) {\r\n row.lastEmittedSecond = secondsElapsed;\r\n self.postMessage({ type: \"tick\", id, mode: \"up\", secondsElapsed });\r\n }\r\n }\r\n }\r\n }\r\n stopLoopIfIdle();\r\n};\r\n\r\nconst startLoop = (): void => {\r\n if (intervalId !== null) return;\r\n intervalId = setInterval(tickOnce, 1000);\r\n};\r\n\r\nself.onmessage = (e: MessageEvent<TimerWorkerIncomingMessage>): void => {\r\n const { data } = e;\r\n if (data.type === \"route\") {\r\n activeRoute = data.activeRoute;\r\n cleanupNonActiveRoutes();\r\n stopLoopIfIdle();\r\n return;\r\n }\r\n if (data.type === \"register\") {\r\n const anchorEpochMs = data.mode === \"down\" ? Date.now() + (data.durationSeconds ?? 0) * 1000 : Date.now();\r\n addTimer(data.routeKey, data.id, data.mode, anchorEpochMs);\r\n startLoop();\r\n tickOnce();\r\n return;\r\n }\r\n if (data.type === \"unregister\") {\r\n deleteTimer(data.routeKey, data.id);\r\n stopLoopIfIdle();\r\n }\r\n};\r\n"],"names":["state","store","timerCount","timers","Map","intervalId","activeRoute","deleteTimer","routeKey","id","route","get","has","delete","stopLoopIfIdle","clearInterval","tickOnce","now","Date","row","mode","secondsLeft","Math","max","ceil","anchorEpochMs","lastEmittedSecond","self","postMessage","type","secondsElapsed","floor","onmessage","e","data","cleanupNonActiveRoutes","durationSeconds","set","existing","addTimer","setInterval"],"mappings":"AAIA,MAAMA,EAA+B,CACnCC,MAAO,CACLC,WAAY,EACZC,OAAQ,IAAIC,MAIhB,IAAIC,EAAoD,KACpDC,EAA6B,KAEjC,MAwBMC,EAAc,CAACC,EAAkBC,KACrC,MAAMC,EAAQV,EAAMC,MAAME,OAAOQ,IAAIH,GAChCE,GAAUA,EAAMP,OAAOS,IAAIH,KAEhCC,EAAMP,OAAOU,OAAOJ,GACpBC,EAAMR,aACNF,EAAMC,MAAMC,aAEa,IAArBQ,EAAMR,YAAkBF,EAAMC,MAAME,OAAOU,OAAOL,KAGlDM,EAAiB,KACF,IApCUd,EAAMC,MAAMC,YAoCF,OAAfG,IACtBU,cAAcV,GACdA,EAAa,OAaXW,EAAW,KACf,MAAMC,EAAMC,KAAKD,MACjB,IAAK,MAAOT,EAAUE,KAAUV,EAAMC,MAAME,OAC1C,IAAK,MAAOM,EAAIU,KAAQT,EAAMP,OAC5B,GAAiB,SAAbgB,EAAIC,KAAiB,CACvB,MAAMC,EAAcC,KAAKC,IAAI,EAAGD,KAAKE,MAAML,EAAIM,cAAgBR,GAAO,MACtE,GAAoB,IAAhBI,EAAmB,CACS,IAA1BF,EAAIO,oBACNP,EAAIO,kBAAoB,EACxBC,KAAKC,YAAY,CAAEC,KAAM,UAAWpB,OACpCF,EAAYC,EAAUC,IAExB,QACF,CACIY,IAAgBF,EAAIO,oBACtBP,EAAIO,kBAAoBL,EACxBM,KAAKC,YAAY,CAAEC,KAAM,OAAQpB,KAAIW,KAAM,OAAQC,gBAEvD,KAAO,CACL,MAAMS,EAAiBR,KAAKC,IAAI,EAAGD,KAAKS,OAAOd,EAAME,EAAIM,eAAiB,MACtEK,IAAmBX,EAAIO,oBACzBP,EAAIO,kBAAoBI,EACxBH,KAAKC,YAAY,CAAEC,KAAM,OAAQpB,KAAIW,KAAM,KAAMU,mBAErD,CAGJhB,KAQFa,KAAKK,UAAaC,IAChB,MAAMC,KAAEA,GAASD,EACjB,GAAkB,UAAdC,EAAKL,KAIP,OAHAvB,EAAc4B,EAAK5B,YA/CQ,MAC7B,GAAKA,EACL,IAAK,MAAOE,EAAUE,KAAUV,EAAMC,MAAME,OACtCK,IAAaF,IACjBN,EAAMC,MAAMC,YAAcQ,EAAMR,WAChCF,EAAMC,MAAME,OAAOU,OAAOL,KA2C1B2B,QACArB,IAGF,GAAkB,aAAdoB,EAAKL,KAAqB,CAC5B,MAAMJ,EAA8B,SAAdS,EAAKd,KAAkBF,KAAKD,MAAsC,KAA7BiB,EAAKE,iBAAmB,GAAYlB,KAAKD,MAIpG,MAjGa,EAACT,EAAkBC,EAAYW,EAAiBK,KAC/D,IAAIf,EAAQV,EAAMC,MAAME,OAAOQ,IAAIH,GAC9BE,IACHA,EAAQ,CAAER,WAAY,EAAGC,OAAQ,IAAIC,KACrCJ,EAAMC,MAAME,OAAOkC,IAAI7B,EAAUE,IAGnC,MAAM4B,EAAW5B,EAAMP,OAAOQ,IAAIF,GAC9B6B,EACEA,EAASb,gBAAkBA,GAAiBa,EAASlB,OAASA,IAChEkB,EAASlB,KAAOA,EAChBkB,EAASb,cAAgBA,EACzBa,EAASZ,mBAAoB,IAKjChB,EAAMP,OAAOkC,IAAI5B,EAAI,CAAEW,OAAMK,gBAAeC,mBAAmB,IAC/DhB,EAAMR,aACNF,EAAMC,MAAMC,eA2EVqC,CAASL,EAAK1B,SAAU0B,EAAKzB,GAAIyB,EAAKd,KAAMK,GAd3B,OAAfpB,IACJA,EAAamC,YAAYxB,EAAU,WAejCA,GAEF,CACkB,eAAdkB,EAAKL,OACPtB,EAAY2B,EAAK1B,SAAU0B,EAAKzB,IAChCK"}
|
package/package.json
ADDED
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@mainframework/timer",
|
|
3
|
+
"version": "1.0.0",
|
|
4
|
+
"description": "a timer wrapper for setInterval",
|
|
5
|
+
"author": "Terry Slack <tslack@mainframework.com>",
|
|
6
|
+
"license": "MIT",
|
|
7
|
+
"type": "module",
|
|
8
|
+
"main": "dist/vanilla/index.js",
|
|
9
|
+
"types": "dist/types/index.d.ts",
|
|
10
|
+
"exports": {
|
|
11
|
+
".": {
|
|
12
|
+
"types": "./dist/types/index.d.ts",
|
|
13
|
+
"import": "./dist/vanilla/index.js"
|
|
14
|
+
},
|
|
15
|
+
"./react": {
|
|
16
|
+
"types": "./dist/types/react.d.ts",
|
|
17
|
+
"import": "./dist/react.js"
|
|
18
|
+
}
|
|
19
|
+
},
|
|
20
|
+
"repository": {
|
|
21
|
+
"type": "git",
|
|
22
|
+
"url": "git+https://github.com/TerrySlack/mainframework-timer.git"
|
|
23
|
+
},
|
|
24
|
+
"bugs": {
|
|
25
|
+
"url": "https://github.com/TerrySlack/mainframework-timer/issues"
|
|
26
|
+
},
|
|
27
|
+
"homepage": "https://github.com/TerrySlack/mainframework-timer",
|
|
28
|
+
"files": [
|
|
29
|
+
"dist"
|
|
30
|
+
],
|
|
31
|
+
"peerDependencies": {
|
|
32
|
+
"react": ">=19"
|
|
33
|
+
},
|
|
34
|
+
"peerDependenciesMeta": {
|
|
35
|
+
"react": {
|
|
36
|
+
"optional": true
|
|
37
|
+
}
|
|
38
|
+
},
|
|
39
|
+
"lint-staged": {
|
|
40
|
+
"*.{js,jsx,ts,tsx}": [
|
|
41
|
+
"eslint --max-warnings=0 --no-warn-ignored"
|
|
42
|
+
],
|
|
43
|
+
"*.{js,jsx,ts,tsx,json,css,md}": [
|
|
44
|
+
"prettier --write"
|
|
45
|
+
]
|
|
46
|
+
},
|
|
47
|
+
"scripts": {
|
|
48
|
+
"prepare": "husky",
|
|
49
|
+
"clean": "rimraf dist tsconfig.tsbuildinfo -g src/**/*.js -g src/**/*.js.map",
|
|
50
|
+
"lint-staged": "lint-staged",
|
|
51
|
+
"lint": "eslint . --max-warnings=0 --no-warn-ignored",
|
|
52
|
+
"prebuild": "yarn clean",
|
|
53
|
+
"build": "rollup -c && tsc --emitDeclarationOnly --declarationDir dist/types",
|
|
54
|
+
"tscheck": "tsc"
|
|
55
|
+
},
|
|
56
|
+
"devDependencies": {
|
|
57
|
+
"@babel/core": "^8.0.1",
|
|
58
|
+
"@babel/preset-typescript": "^8.0.1",
|
|
59
|
+
"@eslint/js": "^10.0.1",
|
|
60
|
+
"@rollup/plugin-babel": "^7.1.0",
|
|
61
|
+
"@rollup/plugin-node-resolve": "^16.0.3",
|
|
62
|
+
"@rollup/plugin-terser": "^1.0.0",
|
|
63
|
+
"@rollup/plugin-typescript": "^12.3.0",
|
|
64
|
+
"@types/react": "^19.2.17",
|
|
65
|
+
"babel-plugin-react-compiler": "^1.0.0",
|
|
66
|
+
"eslint": "^10.7.0",
|
|
67
|
+
"eslint-plugin-react": "^7.37.5",
|
|
68
|
+
"eslint-plugin-react-compiler": "^19.1.0-rc.2",
|
|
69
|
+
"eslint-plugin-react-hooks": "^7.1.1",
|
|
70
|
+
"globals": "^17.7.0",
|
|
71
|
+
"husky": "^9.1.7",
|
|
72
|
+
"lint-staged": "^17.0.8",
|
|
73
|
+
"prettier": "^3.9.5",
|
|
74
|
+
"react": "^19.2.7",
|
|
75
|
+
"rimraf": "^6.1.3",
|
|
76
|
+
"rollup": "^4.62.2",
|
|
77
|
+
"tslib": "^2.8.1",
|
|
78
|
+
"typescript": "^6.0.3",
|
|
79
|
+
"typescript-eslint": "^8.64.0"
|
|
80
|
+
}
|
|
81
|
+
}
|