@stackwright-pro/pulse 0.1.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 +348 -0
- package/dist/index.d.mts +233 -0
- package/dist/index.d.ts +233 -0
- package/dist/index.js +366 -0
- package/dist/index.mjs +329 -0
- package/package.json +41 -0
package/dist/index.mjs
ADDED
|
@@ -0,0 +1,329 @@
|
|
|
1
|
+
// src/components/Pulse.tsx
|
|
2
|
+
import { useState as useState2, useEffect as useEffect2 } from "react";
|
|
3
|
+
|
|
4
|
+
// src/hooks/usePulse.ts
|
|
5
|
+
import { useState, useCallback, useEffect } from "react";
|
|
6
|
+
import { useQuery } from "@tanstack/react-query";
|
|
7
|
+
function usePulse(options) {
|
|
8
|
+
const {
|
|
9
|
+
fetcher,
|
|
10
|
+
interval = 5e3,
|
|
11
|
+
schema,
|
|
12
|
+
enabled = true,
|
|
13
|
+
refetchOnWindowFocus = true,
|
|
14
|
+
retryCount = 3
|
|
15
|
+
} = options;
|
|
16
|
+
const [lastSuccess, setLastSuccess] = useState(null);
|
|
17
|
+
const [errorCount, setErrorCount] = useState(0);
|
|
18
|
+
const validatedFetcher = useCallback(async () => {
|
|
19
|
+
const data2 = await fetcher();
|
|
20
|
+
if (schema) {
|
|
21
|
+
return schema.parse(data2);
|
|
22
|
+
}
|
|
23
|
+
return data2;
|
|
24
|
+
}, [fetcher, schema]);
|
|
25
|
+
const queryKey = ["pulse", fetcher.toString()];
|
|
26
|
+
const { data, isLoading, isFetching, isSuccess, isError, error, refetch } = useQuery({
|
|
27
|
+
queryKey,
|
|
28
|
+
queryFn: validatedFetcher,
|
|
29
|
+
refetchInterval: enabled ? Math.max(interval, 1e3) : false,
|
|
30
|
+
refetchOnWindowFocus,
|
|
31
|
+
retry: retryCount,
|
|
32
|
+
retryDelay: (attemptIndex) => Math.min(1e3 * 2 ** attemptIndex, 1e4),
|
|
33
|
+
staleTime: interval / 2,
|
|
34
|
+
throwOnError: false
|
|
35
|
+
});
|
|
36
|
+
useEffect(() => {
|
|
37
|
+
if (isSuccess && data !== void 0) {
|
|
38
|
+
setLastSuccess(/* @__PURE__ */ new Date());
|
|
39
|
+
setErrorCount(0);
|
|
40
|
+
}
|
|
41
|
+
}, [isSuccess, data]);
|
|
42
|
+
useEffect(() => {
|
|
43
|
+
if (isError) {
|
|
44
|
+
setErrorCount((c) => c + 1);
|
|
45
|
+
}
|
|
46
|
+
}, [isError]);
|
|
47
|
+
let state = "loading";
|
|
48
|
+
if (isLoading && data === void 0) {
|
|
49
|
+
state = "loading";
|
|
50
|
+
} else if (error && data === void 0) {
|
|
51
|
+
state = "error";
|
|
52
|
+
} else if (isFetching) {
|
|
53
|
+
state = "stale";
|
|
54
|
+
} else {
|
|
55
|
+
state = "live";
|
|
56
|
+
}
|
|
57
|
+
const meta = {
|
|
58
|
+
lastUpdated: lastSuccess ?? /* @__PURE__ */ new Date(0),
|
|
59
|
+
isStale: state === "stale" || state === "error",
|
|
60
|
+
isError: state === "error",
|
|
61
|
+
isLoading: isFetching && data === void 0,
|
|
62
|
+
errorCount,
|
|
63
|
+
refetch,
|
|
64
|
+
state
|
|
65
|
+
};
|
|
66
|
+
return {
|
|
67
|
+
data,
|
|
68
|
+
meta,
|
|
69
|
+
state,
|
|
70
|
+
error
|
|
71
|
+
};
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
// src/components/Pulse.tsx
|
|
75
|
+
import { Fragment, jsx, jsxs } from "react/jsx-runtime";
|
|
76
|
+
function Pulse({
|
|
77
|
+
fetcher,
|
|
78
|
+
interval,
|
|
79
|
+
staleThreshold,
|
|
80
|
+
maxStaleAge,
|
|
81
|
+
schema,
|
|
82
|
+
enabled,
|
|
83
|
+
refetchOnWindowFocus,
|
|
84
|
+
retryCount,
|
|
85
|
+
children,
|
|
86
|
+
loadingState,
|
|
87
|
+
errorState,
|
|
88
|
+
emptyState,
|
|
89
|
+
showStaleDataOnError = true
|
|
90
|
+
}) {
|
|
91
|
+
const { data, meta, state } = usePulse({
|
|
92
|
+
fetcher,
|
|
93
|
+
interval,
|
|
94
|
+
staleThreshold,
|
|
95
|
+
maxStaleAge,
|
|
96
|
+
schema,
|
|
97
|
+
enabled,
|
|
98
|
+
refetchOnWindowFocus,
|
|
99
|
+
retryCount
|
|
100
|
+
});
|
|
101
|
+
if (state === "loading" && data === void 0) {
|
|
102
|
+
return /* @__PURE__ */ jsx(Fragment, { children: loadingState ?? /* @__PURE__ */ jsx(DefaultLoading, {}) });
|
|
103
|
+
}
|
|
104
|
+
if (state === "error") {
|
|
105
|
+
const errorUi = typeof errorState === "function" ? errorState(meta) : errorState ?? /* @__PURE__ */ jsx(DefaultError, { meta });
|
|
106
|
+
if (showStaleDataOnError && data !== void 0) {
|
|
107
|
+
return /* @__PURE__ */ jsxs(Fragment, { children: [
|
|
108
|
+
errorUi,
|
|
109
|
+
children(data, meta)
|
|
110
|
+
] });
|
|
111
|
+
}
|
|
112
|
+
return /* @__PURE__ */ jsx(Fragment, { children: errorUi });
|
|
113
|
+
}
|
|
114
|
+
if (data === null || data === void 0 || Array.isArray(data) && data.length === 0) {
|
|
115
|
+
return /* @__PURE__ */ jsx(Fragment, { children: emptyState ?? /* @__PURE__ */ jsx(DefaultEmpty, {}) });
|
|
116
|
+
}
|
|
117
|
+
return /* @__PURE__ */ jsx(Fragment, { children: children(data, meta) });
|
|
118
|
+
}
|
|
119
|
+
function useSecondsAgo(lastUpdated) {
|
|
120
|
+
const [secondsAgo, setSecondsAgo] = useState2(
|
|
121
|
+
() => Math.round((Date.now() - lastUpdated.getTime()) / 1e3)
|
|
122
|
+
);
|
|
123
|
+
useEffect2(() => {
|
|
124
|
+
const interval = setInterval(() => {
|
|
125
|
+
setSecondsAgo(Math.round((Date.now() - lastUpdated.getTime()) / 1e3));
|
|
126
|
+
}, 1e3);
|
|
127
|
+
return () => clearInterval(interval);
|
|
128
|
+
}, [lastUpdated]);
|
|
129
|
+
return secondsAgo;
|
|
130
|
+
}
|
|
131
|
+
function DefaultLoading() {
|
|
132
|
+
return /* @__PURE__ */ jsxs("div", { className: "pulse--loading", role: "status", "aria-live": "polite", "aria-atomic": "true", children: [
|
|
133
|
+
/* @__PURE__ */ jsx("span", { className: "pulse--spinner", "aria-hidden": "true" }),
|
|
134
|
+
/* @__PURE__ */ jsx("span", { className: "pulse--loading-text", children: "Loading..." })
|
|
135
|
+
] });
|
|
136
|
+
}
|
|
137
|
+
function DefaultError({ meta }) {
|
|
138
|
+
const secondsAgo = useSecondsAgo(meta.lastUpdated);
|
|
139
|
+
return /* @__PURE__ */ jsxs("div", { className: "pulse--error", role: "alert", "aria-live": "assertive", "aria-atomic": "true", children: [
|
|
140
|
+
"\u26A0\uFE0F Connection lost. Last updated: ",
|
|
141
|
+
secondsAgo,
|
|
142
|
+
"s ago"
|
|
143
|
+
] });
|
|
144
|
+
}
|
|
145
|
+
function DefaultEmpty() {
|
|
146
|
+
return /* @__PURE__ */ jsx("div", { className: "pulse--empty", children: "No data available" });
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
// src/components/PulseIndicator.tsx
|
|
150
|
+
import { useState as useState3, useEffect as useEffect3 } from "react";
|
|
151
|
+
import { jsx as jsx2, jsxs as jsxs2 } from "react/jsx-runtime";
|
|
152
|
+
function PulseIndicator({
|
|
153
|
+
meta,
|
|
154
|
+
showSeconds = true,
|
|
155
|
+
className,
|
|
156
|
+
labels = {}
|
|
157
|
+
}) {
|
|
158
|
+
const [secondsAgo, setSecondsAgo] = useState3(
|
|
159
|
+
() => Math.round((Date.now() - meta.lastUpdated.getTime()) / 1e3)
|
|
160
|
+
);
|
|
161
|
+
useEffect3(() => {
|
|
162
|
+
const interval = setInterval(() => {
|
|
163
|
+
setSecondsAgo(Math.round((Date.now() - meta.lastUpdated.getTime()) / 1e3));
|
|
164
|
+
}, 1e3);
|
|
165
|
+
return () => clearInterval(interval);
|
|
166
|
+
}, [meta.lastUpdated]);
|
|
167
|
+
let status;
|
|
168
|
+
let label;
|
|
169
|
+
let dotClass;
|
|
170
|
+
if (meta.isLoading) {
|
|
171
|
+
status = "syncing";
|
|
172
|
+
label = labels.syncing ?? "Syncing...";
|
|
173
|
+
dotClass = "dot--syncing";
|
|
174
|
+
} else if (meta.isError) {
|
|
175
|
+
status = "error";
|
|
176
|
+
label = labels.error ?? `Offline${showSeconds ? ` \u2022 ${formatSeconds(secondsAgo)}` : ""}`;
|
|
177
|
+
dotClass = "dot--error";
|
|
178
|
+
} else if (meta.isStale) {
|
|
179
|
+
status = "stale";
|
|
180
|
+
label = labels.stale ?? `Stale${showSeconds ? ` \u2022 ${formatSeconds(secondsAgo)}` : ""}`;
|
|
181
|
+
dotClass = "dot--stale";
|
|
182
|
+
} else {
|
|
183
|
+
status = "live";
|
|
184
|
+
label = showSeconds ? labels.live ?? `Live \u2022 ${formatSeconds(secondsAgo)}` : labels.live ?? "Live";
|
|
185
|
+
dotClass = "dot--live";
|
|
186
|
+
}
|
|
187
|
+
return /* @__PURE__ */ jsxs2(
|
|
188
|
+
"div",
|
|
189
|
+
{
|
|
190
|
+
className: `pulse-indicator ${status} ${className ?? ""}`,
|
|
191
|
+
"data-status": status,
|
|
192
|
+
role: "status",
|
|
193
|
+
"aria-live": "polite",
|
|
194
|
+
"aria-atomic": "true",
|
|
195
|
+
children: [
|
|
196
|
+
/* @__PURE__ */ jsx2("span", { className: `pulse-indicator__dot ${dotClass}`, "aria-hidden": "true" }),
|
|
197
|
+
/* @__PURE__ */ jsx2("span", { className: "pulse-indicator__label", children: label })
|
|
198
|
+
]
|
|
199
|
+
}
|
|
200
|
+
);
|
|
201
|
+
}
|
|
202
|
+
function formatSeconds(seconds) {
|
|
203
|
+
if (seconds < 60) return `${seconds}s ago`;
|
|
204
|
+
const minutes = Math.floor(seconds / 60);
|
|
205
|
+
if (minutes < 60) return `${minutes}m ago`;
|
|
206
|
+
const hours = Math.floor(minutes / 60);
|
|
207
|
+
return `${hours}h ago`;
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
// src/components/PulseStates.tsx
|
|
211
|
+
import { useState as useState4, useEffect as useEffect4 } from "react";
|
|
212
|
+
import { jsx as jsx3, jsxs as jsxs3 } from "react/jsx-runtime";
|
|
213
|
+
function PulseLoadingState({
|
|
214
|
+
className,
|
|
215
|
+
text = "Loading..."
|
|
216
|
+
}) {
|
|
217
|
+
return /* @__PURE__ */ jsxs3("div", { className: `pulse--loading ${className ?? ""}`, role: "status", "aria-live": "polite", children: [
|
|
218
|
+
/* @__PURE__ */ jsx3("span", { className: "pulse--spinner", "aria-hidden": "true" }),
|
|
219
|
+
/* @__PURE__ */ jsx3("span", { className: "pulse--loading-text", children: text })
|
|
220
|
+
] });
|
|
221
|
+
}
|
|
222
|
+
function useSecondsAgo2(lastUpdated) {
|
|
223
|
+
const [secondsAgo, setSecondsAgo] = useState4(
|
|
224
|
+
() => Math.round((Date.now() - lastUpdated.getTime()) / 1e3)
|
|
225
|
+
);
|
|
226
|
+
useEffect4(() => {
|
|
227
|
+
const interval = setInterval(() => {
|
|
228
|
+
setSecondsAgo(Math.round((Date.now() - lastUpdated.getTime()) / 1e3));
|
|
229
|
+
}, 1e3);
|
|
230
|
+
return () => clearInterval(interval);
|
|
231
|
+
}, [lastUpdated]);
|
|
232
|
+
return secondsAgo;
|
|
233
|
+
}
|
|
234
|
+
function PulseErrorState({
|
|
235
|
+
meta,
|
|
236
|
+
className,
|
|
237
|
+
customMessage
|
|
238
|
+
}) {
|
|
239
|
+
const secondsAgo = useSecondsAgo2(meta.lastUpdated);
|
|
240
|
+
return /* @__PURE__ */ jsxs3("div", { className: `pulse--error ${className ?? ""}`, role: "alert", children: [
|
|
241
|
+
"\u26A0\uFE0F ",
|
|
242
|
+
customMessage ?? `Connection lost. Last updated: ${secondsAgo}s ago`
|
|
243
|
+
] });
|
|
244
|
+
}
|
|
245
|
+
function PulseEmptyState({
|
|
246
|
+
className,
|
|
247
|
+
text = "No data available"
|
|
248
|
+
}) {
|
|
249
|
+
return /* @__PURE__ */ jsx3("div", { className: `pulse--empty ${className ?? ""}`, children: text });
|
|
250
|
+
}
|
|
251
|
+
function PulseStaleState({
|
|
252
|
+
meta,
|
|
253
|
+
className,
|
|
254
|
+
customMessage
|
|
255
|
+
}) {
|
|
256
|
+
const secondsAgo = useSecondsAgo2(meta.lastUpdated);
|
|
257
|
+
return /* @__PURE__ */ jsxs3("div", { className: `pulse--stale ${className ?? ""}`, role: "status", children: [
|
|
258
|
+
"\u23F1\uFE0F ",
|
|
259
|
+
customMessage ?? `Data may be outdated. Last updated: ${secondsAgo}s ago`
|
|
260
|
+
] });
|
|
261
|
+
}
|
|
262
|
+
function PulseSyncingState({
|
|
263
|
+
className,
|
|
264
|
+
text = "Syncing..."
|
|
265
|
+
}) {
|
|
266
|
+
return /* @__PURE__ */ jsxs3("div", { className: `pulse--syncing ${className ?? ""}`, role: "status", "aria-live": "polite", children: [
|
|
267
|
+
/* @__PURE__ */ jsx3("span", { className: "pulse--spinner", "aria-hidden": "true" }),
|
|
268
|
+
/* @__PURE__ */ jsx3("span", { className: "pulse--syncing-text", children: text })
|
|
269
|
+
] });
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
// src/hooks/useStreaming.ts
|
|
273
|
+
function useStreaming(_options) {
|
|
274
|
+
return {
|
|
275
|
+
data: null,
|
|
276
|
+
isConnected: false,
|
|
277
|
+
isConnecting: false,
|
|
278
|
+
error: null
|
|
279
|
+
};
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
// src/validation/pulseValidator.ts
|
|
283
|
+
function createPulseValidator(schema) {
|
|
284
|
+
return {
|
|
285
|
+
/**
|
|
286
|
+
* Validates data, throwing on failure
|
|
287
|
+
*/
|
|
288
|
+
parse: (data) => {
|
|
289
|
+
return schema.parse(data);
|
|
290
|
+
},
|
|
291
|
+
/**
|
|
292
|
+
* Safe parse - returns null on failure
|
|
293
|
+
*/
|
|
294
|
+
safeParse: (data) => {
|
|
295
|
+
const result = schema.safeParse(data);
|
|
296
|
+
return result.success ? result.data : null;
|
|
297
|
+
}
|
|
298
|
+
};
|
|
299
|
+
}
|
|
300
|
+
var PulseValidationError = class extends Error {
|
|
301
|
+
constructor(message, issues) {
|
|
302
|
+
super(message);
|
|
303
|
+
this.issues = issues;
|
|
304
|
+
this.name = "PulseValidationError";
|
|
305
|
+
}
|
|
306
|
+
/**
|
|
307
|
+
* Get formatted error messages
|
|
308
|
+
*/
|
|
309
|
+
getFormattedErrors() {
|
|
310
|
+
return this.issues.map((issue) => ({
|
|
311
|
+
path: issue.path.join("."),
|
|
312
|
+
message: issue.message,
|
|
313
|
+
code: issue.code
|
|
314
|
+
}));
|
|
315
|
+
}
|
|
316
|
+
};
|
|
317
|
+
export {
|
|
318
|
+
Pulse,
|
|
319
|
+
PulseEmptyState,
|
|
320
|
+
PulseErrorState,
|
|
321
|
+
PulseIndicator,
|
|
322
|
+
PulseLoadingState,
|
|
323
|
+
PulseStaleState,
|
|
324
|
+
PulseSyncingState,
|
|
325
|
+
PulseValidationError,
|
|
326
|
+
createPulseValidator,
|
|
327
|
+
usePulse,
|
|
328
|
+
useStreaming
|
|
329
|
+
};
|
package/package.json
ADDED
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@stackwright-pro/pulse",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Source-agnostic real-time data polling for Stackwright Pro",
|
|
5
|
+
"license": "PROPRIETARY",
|
|
6
|
+
"main": "./dist/index.js",
|
|
7
|
+
"module": "./dist/index.mjs",
|
|
8
|
+
"types": "./dist/index.d.ts",
|
|
9
|
+
"exports": {
|
|
10
|
+
".": {
|
|
11
|
+
"types": "./dist/index.d.ts",
|
|
12
|
+
"import": "./dist/index.mjs",
|
|
13
|
+
"require": "./dist/index.js"
|
|
14
|
+
}
|
|
15
|
+
},
|
|
16
|
+
"files": [
|
|
17
|
+
"dist"
|
|
18
|
+
],
|
|
19
|
+
"dependencies": {
|
|
20
|
+
"@tanstack/react-query": "^5.0.0",
|
|
21
|
+
"zod": "^4.3.6"
|
|
22
|
+
},
|
|
23
|
+
"peerDependencies": {
|
|
24
|
+
"react": "^18.0.0",
|
|
25
|
+
"react-dom": "^18.0.0"
|
|
26
|
+
},
|
|
27
|
+
"devDependencies": {
|
|
28
|
+
"@types/react": "^18.3.0",
|
|
29
|
+
"@types/react-dom": "^18.3.0",
|
|
30
|
+
"@testing-library/react": "^16.0.0",
|
|
31
|
+
"jsdom": "^25.0.0",
|
|
32
|
+
"tsup": "^8.5.0",
|
|
33
|
+
"typescript": "^5.8.3",
|
|
34
|
+
"vitest": "^4.0.18"
|
|
35
|
+
},
|
|
36
|
+
"scripts": {
|
|
37
|
+
"build": "tsup src/index.ts --format cjs,esm --dts --clean",
|
|
38
|
+
"dev": "tsup src/index.ts --format cjs,esm --dts --watch",
|
|
39
|
+
"test": "vitest"
|
|
40
|
+
}
|
|
41
|
+
}
|