@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 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,4 @@
1
+ export { createWorker } from "./worker/createWorker";
2
+ export { getDefaultRouteKey } from "./utils/routes";
3
+ export type { TimerMode, TimerWorkerMessage, TimerWorkerIncomingMessage } from "./types";
4
+ //# sourceMappingURL=index.d.ts.map
@@ -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,2 @@
1
+ export { useTimer } from "./hooks/useTimer";
2
+ //# sourceMappingURL=react.d.ts.map
@@ -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,3 @@
1
+ /** Convenience default — current pathname. Callers can supply their own routeKey instead. */
2
+ export declare const getDefaultRouteKey: () => string;
3
+ //# sourceMappingURL=routes.d.ts.map
@@ -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,2 @@
1
+ export {};
2
+ //# sourceMappingURL=timer.worker.d.ts.map
@@ -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
+ }