express-sequelize-traffic 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.
@@ -0,0 +1 @@
1
+ *,:before,:after{--tw-border-spacing-x: 0;--tw-border-spacing-y: 0;--tw-translate-x: 0;--tw-translate-y: 0;--tw-rotate: 0;--tw-skew-x: 0;--tw-skew-y: 0;--tw-scale-x: 1;--tw-scale-y: 1;--tw-pan-x: ;--tw-pan-y: ;--tw-pinch-zoom: ;--tw-scroll-snap-strictness: proximity;--tw-gradient-from-position: ;--tw-gradient-via-position: ;--tw-gradient-to-position: ;--tw-ordinal: ;--tw-slashed-zero: ;--tw-numeric-figure: ;--tw-numeric-spacing: ;--tw-numeric-fraction: ;--tw-ring-inset: ;--tw-ring-offset-width: 0px;--tw-ring-offset-color: #fff;--tw-ring-color: rgb(59 130 246 / .5);--tw-ring-offset-shadow: 0 0 #0000;--tw-ring-shadow: 0 0 #0000;--tw-shadow: 0 0 #0000;--tw-shadow-colored: 0 0 #0000;--tw-blur: ;--tw-brightness: ;--tw-contrast: ;--tw-grayscale: ;--tw-hue-rotate: ;--tw-invert: ;--tw-saturate: ;--tw-sepia: ;--tw-drop-shadow: ;--tw-backdrop-blur: ;--tw-backdrop-brightness: ;--tw-backdrop-contrast: ;--tw-backdrop-grayscale: ;--tw-backdrop-hue-rotate: ;--tw-backdrop-invert: ;--tw-backdrop-opacity: ;--tw-backdrop-saturate: ;--tw-backdrop-sepia: ;--tw-contain-size: ;--tw-contain-layout: ;--tw-contain-paint: ;--tw-contain-style: }::backdrop{--tw-border-spacing-x: 0;--tw-border-spacing-y: 0;--tw-translate-x: 0;--tw-translate-y: 0;--tw-rotate: 0;--tw-skew-x: 0;--tw-skew-y: 0;--tw-scale-x: 1;--tw-scale-y: 1;--tw-pan-x: ;--tw-pan-y: ;--tw-pinch-zoom: ;--tw-scroll-snap-strictness: proximity;--tw-gradient-from-position: ;--tw-gradient-via-position: ;--tw-gradient-to-position: ;--tw-ordinal: ;--tw-slashed-zero: ;--tw-numeric-figure: ;--tw-numeric-spacing: ;--tw-numeric-fraction: ;--tw-ring-inset: ;--tw-ring-offset-width: 0px;--tw-ring-offset-color: #fff;--tw-ring-color: rgb(59 130 246 / .5);--tw-ring-offset-shadow: 0 0 #0000;--tw-ring-shadow: 0 0 #0000;--tw-shadow: 0 0 #0000;--tw-shadow-colored: 0 0 #0000;--tw-blur: ;--tw-brightness: ;--tw-contrast: ;--tw-grayscale: ;--tw-hue-rotate: ;--tw-invert: ;--tw-saturate: ;--tw-sepia: ;--tw-drop-shadow: ;--tw-backdrop-blur: ;--tw-backdrop-brightness: ;--tw-backdrop-contrast: ;--tw-backdrop-grayscale: ;--tw-backdrop-hue-rotate: ;--tw-backdrop-invert: ;--tw-backdrop-opacity: ;--tw-backdrop-saturate: ;--tw-backdrop-sepia: ;--tw-contain-size: ;--tw-contain-layout: ;--tw-contain-paint: ;--tw-contain-style: }*,:before,:after{box-sizing:border-box;border-width:0;border-style:solid;border-color:#e5e7eb}:before,:after{--tw-content: ""}html,:host{line-height:1.5;-webkit-text-size-adjust:100%;-moz-tab-size:4;-o-tab-size:4;tab-size:4;font-family:Space Grotesk,Segoe UI,sans-serif;font-feature-settings:normal;font-variation-settings:normal;-webkit-tap-highlight-color:transparent}body{margin:0;line-height:inherit}hr{height:0;color:inherit;border-top-width:1px}abbr:where([title]){-webkit-text-decoration:underline dotted;text-decoration:underline dotted}h1,h2,h3,h4,h5,h6{font-size:inherit;font-weight:inherit}a{color:inherit;text-decoration:inherit}b,strong{font-weight:bolder}code,kbd,samp,pre{font-family:ui-monospace,SFMono-Regular,Menlo,Monaco,Consolas,Liberation Mono,Courier New,monospace;font-feature-settings:normal;font-variation-settings:normal;font-size:1em}small{font-size:80%}sub,sup{font-size:75%;line-height:0;position:relative;vertical-align:baseline}sub{bottom:-.25em}sup{top:-.5em}table{text-indent:0;border-color:inherit;border-collapse:collapse}button,input,optgroup,select,textarea{font-family:inherit;font-feature-settings:inherit;font-variation-settings:inherit;font-size:100%;font-weight:inherit;line-height:inherit;letter-spacing:inherit;color:inherit;margin:0;padding:0}button,select{text-transform:none}button,input:where([type=button]),input:where([type=reset]),input:where([type=submit]){-webkit-appearance:button;background-color:transparent;background-image:none}:-moz-focusring{outline:auto}:-moz-ui-invalid{box-shadow:none}progress{vertical-align:baseline}::-webkit-inner-spin-button,::-webkit-outer-spin-button{height:auto}[type=search]{-webkit-appearance:textfield;outline-offset:-2px}::-webkit-search-decoration{-webkit-appearance:none}::-webkit-file-upload-button{-webkit-appearance:button;font:inherit}summary{display:list-item}blockquote,dl,dd,h1,h2,h3,h4,h5,h6,hr,figure,p,pre{margin:0}fieldset{margin:0;padding:0}legend{padding:0}ol,ul,menu{list-style:none;margin:0;padding:0}dialog{padding:0}textarea{resize:vertical}input::-moz-placeholder,textarea::-moz-placeholder{opacity:1;color:#9ca3af}input::placeholder,textarea::placeholder{opacity:1;color:#9ca3af}button,[role=button]{cursor:pointer}:disabled{cursor:default}img,svg,video,canvas,audio,iframe,embed,object{display:block;vertical-align:middle}img,video{max-width:100%;height:auto}[hidden]:where(:not([hidden=until-found])){display:none}:root{color:#0f172a;background:radial-gradient(circle at top left,rgba(38,64,168,.12),transparent 24rem),linear-gradient(180deg,#f6f9ff,#edf3ff)}*{box-sizing:border-box}body{margin:0;min-width:320px;min-height:100vh;font-family:Space Grotesk,Segoe UI,sans-serif;color:#0f172a;background:radial-gradient(circle at top left,rgba(38,64,168,.12),transparent 24rem),linear-gradient(180deg,#f6f9ff,#edf3ff)}#root{min-height:100vh}.absolute{position:absolute}.relative{position:relative}.inset-x-0{left:0;right:0}.top-0{top:0}.mx-auto{margin-left:auto;margin-right:auto}.mb-4{margin-bottom:1rem}.mt-2{margin-top:.5rem}.mt-3{margin-top:.75rem}.mt-4{margin-top:1rem}.mt-8{margin-top:2rem}.flex{display:flex}.table{display:table}.grid{display:grid}.h-11{height:2.75rem}.h-2\.5{height:.625rem}.h-4{height:1rem}.h-40{height:10rem}.h-5{height:1.25rem}.h-72{height:18rem}.h-80{height:20rem}.min-h-screen{min-height:100vh}.w-11{width:2.75rem}.w-2\.5{width:.625rem}.w-4{width:1rem}.w-5{width:1.25rem}.min-w-full{min-width:100%}.max-w-3xl{max-width:48rem}.max-w-7xl{max-width:80rem}.flex-col{flex-direction:column}.flex-wrap{flex-wrap:wrap}.place-items-center{place-items:center}.items-center{align-items:center}.justify-between{justify-content:space-between}.gap-2{gap:.5rem}.gap-3{gap:.75rem}.gap-4{gap:1rem}.gap-6{gap:1.5rem}.space-y-6>:not([hidden])~:not([hidden]){--tw-space-y-reverse: 0;margin-top:calc(1.5rem * calc(1 - var(--tw-space-y-reverse)));margin-bottom:calc(1.5rem * var(--tw-space-y-reverse))}.overflow-hidden{overflow:hidden}.overflow-x-auto{overflow-x:auto}.rounded-2xl{border-radius:1rem}.rounded-3xl{border-radius:1.5rem}.rounded-\[24px\]{border-radius:24px}.rounded-\[28px\]{border-radius:28px}.rounded-full{border-radius:9999px}.border{border-width:1px}.border-b{border-bottom-width:1px}.border-t{border-top-width:1px}.border-slate-200\/70{border-color:#e2e8f0b3}.border-white\/10{border-color:#ffffff1a}.bg-emerald-100{--tw-bg-opacity: 1;background-color:rgb(209 250 229 / var(--tw-bg-opacity, 1))}.bg-ink{--tw-bg-opacity: 1;background-color:rgb(9 17 31 / var(--tw-bg-opacity, 1))}.bg-rose-100{--tw-bg-opacity: 1;background-color:rgb(255 228 230 / var(--tw-bg-opacity, 1))}.bg-rose-50{--tw-bg-opacity: 1;background-color:rgb(255 241 242 / var(--tw-bg-opacity, 1))}.bg-slate-100{--tw-bg-opacity: 1;background-color:rgb(241 245 249 / var(--tw-bg-opacity, 1))}.bg-slate-50{--tw-bg-opacity: 1;background-color:rgb(248 250 252 / var(--tw-bg-opacity, 1))}.bg-white{--tw-bg-opacity: 1;background-color:rgb(255 255 255 / var(--tw-bg-opacity, 1))}.bg-white\/80{background-color:#fffc}.bg-gradient-to-br{background-image:linear-gradient(to bottom right,var(--tw-gradient-stops))}.from-slateBlue\/40{--tw-gradient-from: rgb(38 64 168 / .4) var(--tw-gradient-from-position);--tw-gradient-to: rgb(38 64 168 / 0) var(--tw-gradient-to-position);--tw-gradient-stops: var(--tw-gradient-from), var(--tw-gradient-to)}.from-white{--tw-gradient-from: #fff var(--tw-gradient-from-position);--tw-gradient-to: rgb(255 255 255 / 0) var(--tw-gradient-to-position);--tw-gradient-stops: var(--tw-gradient-from), var(--tw-gradient-to)}.to-emerald-50{--tw-gradient-to: #ecfdf5 var(--tw-gradient-to-position)}.to-mist{--tw-gradient-to: #eef4ff var(--tw-gradient-to-position)}.to-rose-50{--tw-gradient-to: #fff1f2 var(--tw-gradient-to-position)}.to-transparent{--tw-gradient-to: transparent var(--tw-gradient-to-position)}.p-5{padding:1.25rem}.px-2\.5{padding-left:.625rem;padding-right:.625rem}.px-4{padding-left:1rem;padding-right:1rem}.px-5{padding-left:1.25rem;padding-right:1.25rem}.py-1{padding-top:.25rem;padding-bottom:.25rem}.py-10{padding-top:2.5rem;padding-bottom:2.5rem}.py-2{padding-top:.5rem;padding-bottom:.5rem}.py-3{padding-top:.75rem;padding-bottom:.75rem}.py-4{padding-top:1rem;padding-bottom:1rem}.py-5{padding-top:1.25rem;padding-bottom:1.25rem}.py-6{padding-top:1.5rem;padding-bottom:1.5rem}.text-left{text-align:left}.text-3xl{font-size:1.875rem;line-height:2.25rem}.text-lg{font-size:1.125rem;line-height:1.75rem}.text-sm{font-size:.875rem;line-height:1.25rem}.text-xs{font-size:.75rem;line-height:1rem}.font-medium{font-weight:500}.font-semibold{font-weight:600}.uppercase{text-transform:uppercase}.leading-6{line-height:1.5rem}.tracking-\[0\.24em\]{letter-spacing:.24em}.tracking-tight{letter-spacing:-.025em}.text-emerald-700{--tw-text-opacity: 1;color:rgb(4 120 87 / var(--tw-text-opacity, 1))}.text-rose-700{--tw-text-opacity: 1;color:rgb(190 18 60 / var(--tw-text-opacity, 1))}.text-slate-200{--tw-text-opacity: 1;color:rgb(226 232 240 / var(--tw-text-opacity, 1))}.text-slate-300{--tw-text-opacity: 1;color:rgb(203 213 225 / var(--tw-text-opacity, 1))}.text-slate-400{--tw-text-opacity: 1;color:rgb(148 163 184 / var(--tw-text-opacity, 1))}.text-slate-500{--tw-text-opacity: 1;color:rgb(100 116 139 / var(--tw-text-opacity, 1))}.text-slate-600{--tw-text-opacity: 1;color:rgb(71 85 105 / var(--tw-text-opacity, 1))}.text-slate-700{--tw-text-opacity: 1;color:rgb(51 65 85 / var(--tw-text-opacity, 1))}.text-slate-900{--tw-text-opacity: 1;color:rgb(15 23 42 / var(--tw-text-opacity, 1))}.text-slate-950{--tw-text-opacity: 1;color:rgb(2 6 23 / var(--tw-text-opacity, 1))}.text-white{--tw-text-opacity: 1;color:rgb(255 255 255 / var(--tw-text-opacity, 1))}.shadow-lg{--tw-shadow: 0 10px 15px -3px rgb(0 0 0 / .1), 0 4px 6px -4px rgb(0 0 0 / .1);--tw-shadow-colored: 0 10px 15px -3px var(--tw-shadow-color), 0 4px 6px -4px var(--tw-shadow-color);box-shadow:var(--tw-ring-offset-shadow, 0 0 #0000),var(--tw-ring-shadow, 0 0 #0000),var(--tw-shadow)}.shadow-sm{--tw-shadow: 0 1px 2px 0 rgb(0 0 0 / .05);--tw-shadow-colored: 0 1px 2px 0 var(--tw-shadow-color);box-shadow:var(--tw-ring-offset-shadow, 0 0 #0000),var(--tw-ring-shadow, 0 0 #0000),var(--tw-shadow)}.shadow-slate-950\/20{--tw-shadow-color: rgb(2 6 23 / .2);--tw-shadow: var(--tw-shadow-colored)}.ring-1{--tw-ring-offset-shadow: var(--tw-ring-inset) 0 0 0 var(--tw-ring-offset-width) var(--tw-ring-offset-color);--tw-ring-shadow: var(--tw-ring-inset) 0 0 0 calc(1px + var(--tw-ring-offset-width)) var(--tw-ring-color);box-shadow:var(--tw-ring-offset-shadow),var(--tw-ring-shadow),var(--tw-shadow, 0 0 #0000)}.ring-white\/10{--tw-ring-color: rgb(255 255 255 / .1)}.blur-3xl{--tw-blur: blur(64px);filter:var(--tw-blur) var(--tw-brightness) var(--tw-contrast) var(--tw-grayscale) var(--tw-hue-rotate) var(--tw-invert) var(--tw-saturate) var(--tw-sepia) var(--tw-drop-shadow)}.filter{filter:var(--tw-blur) var(--tw-brightness) var(--tw-contrast) var(--tw-grayscale) var(--tw-hue-rotate) var(--tw-invert) var(--tw-saturate) var(--tw-sepia) var(--tw-drop-shadow)}.transition-all{transition-property:all;transition-timing-function:cubic-bezier(.4,0,.2,1);transition-duration:.15s}.duration-200{transition-duration:.2s}.surface-border{border:1px solid rgba(148,163,184,.18)}.surface-glow{box-shadow:0 20px 45px #0f172a14,inset 0 1px #fff9}.hover\:text-white:hover{--tw-text-opacity: 1;color:rgb(255 255 255 / var(--tw-text-opacity, 1))}@media(min-width:640px){.sm\:flex-row{flex-direction:row}.sm\:items-end{align-items:flex-end}.sm\:justify-between{justify-content:space-between}.sm\:px-6{padding-left:1.5rem;padding-right:1.5rem}}@media(min-width:768px){.md\:grid-cols-2{grid-template-columns:repeat(2,minmax(0,1fr))}}@media(min-width:1024px){.lg\:grid{display:grid}.lg\:grid-cols-\[288px_minmax\(0\,1fr\)\]{grid-template-columns:288px minmax(0,1fr)}.lg\:px-6{padding-left:1.5rem;padding-right:1.5rem}.lg\:px-8{padding-left:2rem;padding-right:2rem}.lg\:py-8{padding-top:2rem;padding-bottom:2rem}}@media(min-width:1280px){.xl\:grid-cols-4{grid-template-columns:repeat(4,minmax(0,1fr))}.xl\:grid-cols-\[minmax\(0\,1\.1fr\)_minmax\(0\,1fr\)\]{grid-template-columns:minmax(0,1.1fr) minmax(0,1fr)}.xl\:grid-cols-\[minmax\(0\,1\.5fr\)_minmax\(320px\,0\.9fr\)\]{grid-template-columns:minmax(0,1.5fr) minmax(320px,.9fr)}}
@@ -0,0 +1,13 @@
1
+ <!doctype html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="UTF-8" />
5
+ <meta name="viewport" content="width=device-width, initial-scale=1.0" />
6
+ <title>Express Sequelize Traffic</title>
7
+ <script type="module" crossorigin src="./assets/index-CaWHQ-tp.js"></script>
8
+ <link rel="stylesheet" crossorigin href="./assets/index-iT93XJlh.css">
9
+ </head>
10
+ <body>
11
+ <div id="root"></div>
12
+ </body>
13
+ </html>
@@ -0,0 +1,132 @@
1
+ import fs from "node:fs";
2
+ import path from "node:path";
3
+ import express from "express";
4
+ import { fileURLToPath } from "node:url";
5
+ import { createAnalyticsRouter } from "./routes/analyticsRoutes.js";
6
+ import { createBasicAuthMiddleware } from "./utils/dashboardAuth.js";
7
+
8
+ const __filename = fileURLToPath(import.meta.url);
9
+ const __dirname = path.dirname(__filename);
10
+ const dashboardBuildDirectory = path.resolve(__dirname, "dashboard-public");
11
+ const dashboardIndexFile = path.join(dashboardBuildDirectory, "index.html");
12
+
13
+ function createMissingBuildPage() {
14
+ return `<!doctype html>
15
+ <html lang="en">
16
+ <head>
17
+ <meta charset="utf-8" />
18
+ <meta name="viewport" content="width=device-width, initial-scale=1" />
19
+ <title>Traffic Dashboard Build Missing</title>
20
+ <style>
21
+ body {
22
+ margin: 0;
23
+ font-family: "Segoe UI", sans-serif;
24
+ background: linear-gradient(135deg, #0f172a, #1e293b);
25
+ color: #e2e8f0;
26
+ min-height: 100vh;
27
+ display: grid;
28
+ place-items: center;
29
+ padding: 24px;
30
+ }
31
+
32
+ .panel {
33
+ max-width: 640px;
34
+ background: rgba(15, 23, 42, 0.9);
35
+ border: 1px solid rgba(148, 163, 184, 0.2);
36
+ border-radius: 24px;
37
+ padding: 32px;
38
+ box-shadow: 0 24px 60px rgba(15, 23, 42, 0.45);
39
+ }
40
+
41
+ h1 {
42
+ margin-top: 0;
43
+ font-size: 2rem;
44
+ }
45
+
46
+ code {
47
+ background: rgba(148, 163, 184, 0.16);
48
+ padding: 2px 6px;
49
+ border-radius: 6px;
50
+ }
51
+ </style>
52
+ </head>
53
+ <body>
54
+ <div class="panel">
55
+ <h1>Dashboard build not found</h1>
56
+ <p>
57
+ The analytics APIs are available, but the Vite dashboard has not been built
58
+ into <code>src/dashboard-public</code> yet.
59
+ </p>
60
+ <p>Run <code>npm run build:dashboard</code> in this package to generate the static dashboard assets.</p>
61
+ </div>
62
+ </body>
63
+ </html>`;
64
+ }
65
+
66
+ export function createDisabledDashboardRouter() {
67
+ const router = express.Router();
68
+
69
+ router.use("/api", (_req, res) => {
70
+ res.status(404).json({ error: "Dashboard is disabled." });
71
+ });
72
+
73
+ router.use((_req, res) => {
74
+ res
75
+ .status(404)
76
+ .send("The traffic dashboard is disabled for this tracker instance.");
77
+ });
78
+
79
+ return router;
80
+ }
81
+
82
+ export function createDashboardRouter({
83
+ analyticsService,
84
+ dashboard = {},
85
+ debug = false,
86
+ }) {
87
+ const router = express.Router();
88
+
89
+ router.use(createBasicAuthMiddleware(dashboard));
90
+ router.use("/api", createAnalyticsRouter({ analyticsService, debug }));
91
+
92
+ const hasBuiltDashboard = fs.existsSync(dashboardIndexFile);
93
+
94
+ if (hasBuiltDashboard) {
95
+ router.get("/", (req, res, next) => {
96
+ if (!req.originalUrl.endsWith("/")) {
97
+ res.redirect(302, `${req.baseUrl}/`);
98
+ return;
99
+ }
100
+
101
+ next();
102
+ });
103
+
104
+ router.use(
105
+ express.static(dashboardBuildDirectory, {
106
+ index: false,
107
+ }),
108
+ );
109
+
110
+ router.get("*", (req, res, next) => {
111
+ if (req.path.startsWith("/api")) {
112
+ next();
113
+ return;
114
+ }
115
+
116
+ res.sendFile(dashboardIndexFile);
117
+ });
118
+
119
+ return router;
120
+ }
121
+
122
+ router.get("*", (req, res, next) => {
123
+ if (req.path.startsWith("/api")) {
124
+ next();
125
+ return;
126
+ }
127
+
128
+ res.status(503).send(createMissingBuildPage());
129
+ });
130
+
131
+ return router;
132
+ }
package/src/index.js ADDED
@@ -0,0 +1,67 @@
1
+ import { defineTrafficLogModel } from "./models/TrafficLog.js";
2
+ import { createTrackingMiddleware } from "./middleware.js";
3
+ import { createAnalyticsService } from "./services/analyticsService.js";
4
+ import {
5
+ createDashboardRouter,
6
+ createDisabledDashboardRouter,
7
+ } from "./dashboard.js";
8
+ import { createRealtimeBridge } from "./realtime.js";
9
+
10
+ export function createTrafficTracker(options = {}) {
11
+ const {
12
+ sequelize,
13
+ getUserId,
14
+ getSessionId,
15
+ slowRouteThresholdMs = 1000,
16
+ ignoredRoutes = [],
17
+ trackIp = false,
18
+ trackUserAgent = true,
19
+ dashboard = {},
20
+ debug = false,
21
+ } = options;
22
+
23
+ if (!sequelize) {
24
+ throw new Error(
25
+ "createTrafficTracker requires a Sequelize instance via options.sequelize.",
26
+ );
27
+ }
28
+
29
+ const TrafficLog = defineTrafficLogModel(sequelize);
30
+ const analyticsService = createAnalyticsService(TrafficLog);
31
+ const realtimeBridge = createRealtimeBridge({ dashboard, debug });
32
+
33
+ return {
34
+ middleware: createTrackingMiddleware({
35
+ TrafficLog,
36
+ getUserId,
37
+ getSessionId,
38
+ slowRouteThresholdMs,
39
+ ignoredRoutes,
40
+ trackIp,
41
+ trackUserAgent,
42
+ debug,
43
+ realtimeBridge,
44
+ }),
45
+ dashboard: dashboard.enabled
46
+ ? createDashboardRouter({
47
+ analyticsService,
48
+ dashboard,
49
+ debug,
50
+ })
51
+ : createDisabledDashboardRouter(),
52
+ attachRealtime(server) {
53
+ return realtimeBridge.attachRealtime(server);
54
+ },
55
+ async sync(syncOptions = {}) {
56
+ return TrafficLog.sync(syncOptions);
57
+ },
58
+ getModel() {
59
+ return TrafficLog;
60
+ },
61
+ getSocketPath() {
62
+ return realtimeBridge.getSocketPath();
63
+ },
64
+ };
65
+ }
66
+
67
+ export { defineTrafficLogModel };
@@ -0,0 +1,111 @@
1
+ import { safeAsync } from "./utils/safeAsync.js";
2
+ import { resolveTrackedRoute, shouldIgnoreRoute } from "./utils/routeMatcher.js";
3
+
4
+ function resolveOptionalValue(getter, req, debug, label) {
5
+ if (typeof getter !== "function") {
6
+ return null;
7
+ }
8
+
9
+ try {
10
+ return getter(req) ?? null;
11
+ } catch (error) {
12
+ if (debug) {
13
+ console.error(
14
+ `[express-sequelize-traffic] Failed to resolve ${label}.`,
15
+ error,
16
+ );
17
+ }
18
+
19
+ return null;
20
+ }
21
+ }
22
+
23
+ function createLogPayload({
24
+ req,
25
+ res,
26
+ startedAt,
27
+ endedAt,
28
+ slowRouteThresholdMs,
29
+ trackIp,
30
+ trackUserAgent,
31
+ getUserId,
32
+ getSessionId,
33
+ debug,
34
+ }) {
35
+ const route = resolveTrackedRoute(req);
36
+ const durationMs = Math.max(0, endedAt.getTime() - startedAt.getTime());
37
+
38
+ return {
39
+ userId: resolveOptionalValue(getUserId, req, debug, "userId"),
40
+ sessionId: resolveOptionalValue(getSessionId, req, debug, "sessionId"),
41
+ method: req.method,
42
+ route,
43
+ originalUrl: req.originalUrl || route,
44
+ statusCode: res.statusCode,
45
+ durationMs,
46
+ isSlow: durationMs >= slowRouteThresholdMs,
47
+ ip: trackIp ? req.ip || req.socket?.remoteAddress || null : null,
48
+ userAgent: trackUserAgent ? req.get("user-agent") || null : null,
49
+ startedAt,
50
+ endedAt,
51
+ };
52
+ }
53
+
54
+ export function createTrackingMiddleware({
55
+ TrafficLog,
56
+ getUserId,
57
+ getSessionId,
58
+ slowRouteThresholdMs = 1000,
59
+ ignoredRoutes = [],
60
+ trackIp = false,
61
+ trackUserAgent = true,
62
+ debug = false,
63
+ realtimeBridge,
64
+ }) {
65
+ return (req, res, next) => {
66
+ const startedAt = new Date();
67
+
68
+ res.once("finish", () => {
69
+ const endedAt = new Date();
70
+ const route = resolveTrackedRoute(req);
71
+ const originalUrl = req.originalUrl || route;
72
+
73
+ if (shouldIgnoreRoute({ route, originalUrl, ignoredRoutes })) {
74
+ return;
75
+ }
76
+
77
+ void safeAsync(
78
+ async () => {
79
+ const payload = createLogPayload({
80
+ req,
81
+ res,
82
+ startedAt,
83
+ endedAt,
84
+ slowRouteThresholdMs,
85
+ trackIp,
86
+ trackUserAgent,
87
+ getUserId,
88
+ getSessionId,
89
+ debug,
90
+ });
91
+
92
+ const createdLog = await TrafficLog.create(payload);
93
+
94
+ realtimeBridge?.emitNewRequest(createdLog.get({ plain: true }));
95
+ },
96
+ {
97
+ onError: (error) => {
98
+ if (debug) {
99
+ console.error(
100
+ "[express-sequelize-traffic] Failed to persist traffic log.",
101
+ error,
102
+ );
103
+ }
104
+ },
105
+ },
106
+ );
107
+ });
108
+
109
+ next();
110
+ };
111
+ }
@@ -0,0 +1,83 @@
1
+ import { DataTypes } from "sequelize";
2
+
3
+ export function defineTrafficLogModel(sequelize) {
4
+ if (!sequelize) {
5
+ throw new Error("A Sequelize instance is required to define TrafficLog.");
6
+ }
7
+
8
+ if (sequelize.models.TrafficLog) {
9
+ return sequelize.models.TrafficLog;
10
+ }
11
+
12
+ return sequelize.define(
13
+ "TrafficLog",
14
+ {
15
+ id: {
16
+ type: DataTypes.BIGINT,
17
+ autoIncrement: true,
18
+ primaryKey: true,
19
+ },
20
+ userId: {
21
+ type: DataTypes.STRING,
22
+ allowNull: true,
23
+ },
24
+ sessionId: {
25
+ type: DataTypes.STRING,
26
+ allowNull: true,
27
+ },
28
+ method: {
29
+ type: DataTypes.STRING(16),
30
+ allowNull: false,
31
+ },
32
+ route: {
33
+ type: DataTypes.STRING,
34
+ allowNull: false,
35
+ },
36
+ originalUrl: {
37
+ type: DataTypes.STRING,
38
+ allowNull: false,
39
+ },
40
+ statusCode: {
41
+ type: DataTypes.INTEGER,
42
+ allowNull: false,
43
+ },
44
+ durationMs: {
45
+ type: DataTypes.INTEGER,
46
+ allowNull: false,
47
+ },
48
+ isSlow: {
49
+ type: DataTypes.BOOLEAN,
50
+ allowNull: false,
51
+ defaultValue: false,
52
+ },
53
+ ip: {
54
+ type: DataTypes.STRING,
55
+ allowNull: true,
56
+ },
57
+ userAgent: {
58
+ type: DataTypes.TEXT,
59
+ allowNull: true,
60
+ },
61
+ startedAt: {
62
+ type: DataTypes.DATE,
63
+ allowNull: false,
64
+ },
65
+ endedAt: {
66
+ type: DataTypes.DATE,
67
+ allowNull: false,
68
+ },
69
+ },
70
+ {
71
+ tableName: "traffic_logs",
72
+ indexes: [
73
+ { fields: ["userId"] },
74
+ { fields: ["sessionId"] },
75
+ { fields: ["route"] },
76
+ { fields: ["method"] },
77
+ { fields: ["statusCode"] },
78
+ { fields: ["durationMs"] },
79
+ { fields: ["createdAt"] },
80
+ ],
81
+ },
82
+ );
83
+ }
@@ -0,0 +1,71 @@
1
+ import { Server } from "socket.io";
2
+ import { createSocketAuthMiddleware } from "./utils/dashboardAuth.js";
3
+
4
+ function normalizeSocketPath(dashboard = {}) {
5
+ const mountPath = dashboard.mountPath || "/traffic-dashboard";
6
+ const sanitizedMountPath = mountPath.startsWith("/")
7
+ ? mountPath
8
+ : `/${mountPath}`;
9
+
10
+ return `${sanitizedMountPath.replace(/\/$/, "")}/socket.io`;
11
+ }
12
+
13
+ function buildRealtimePayload(log) {
14
+ return {
15
+ userId: log.userId,
16
+ sessionId: log.sessionId,
17
+ method: log.method,
18
+ route: log.route,
19
+ originalUrl: log.originalUrl,
20
+ statusCode: Number(log.statusCode),
21
+ durationMs: Number(log.durationMs),
22
+ isSlow: Boolean(log.isSlow),
23
+ createdAt: log.createdAt,
24
+ };
25
+ }
26
+
27
+ export function createRealtimeBridge({ dashboard = {}, debug = false } = {}) {
28
+ let io = null;
29
+ const socketPath = normalizeSocketPath(dashboard);
30
+
31
+ return {
32
+ attachRealtime(server) {
33
+ if (!server) {
34
+ throw new Error("An HTTP server instance is required for realtime.");
35
+ }
36
+
37
+ if (io) {
38
+ return io;
39
+ }
40
+
41
+ io = new Server(server, {
42
+ path: socketPath,
43
+ serveClient: false,
44
+ });
45
+
46
+ io.use(createSocketAuthMiddleware(dashboard));
47
+
48
+ if (debug) {
49
+ io.on("connection", (socket) => {
50
+ console.info(
51
+ `[express-sequelize-traffic] Dashboard realtime connected: ${socket.id}`,
52
+ );
53
+ });
54
+ }
55
+
56
+ return io;
57
+ },
58
+
59
+ emitNewRequest(log) {
60
+ if (!io) {
61
+ return;
62
+ }
63
+
64
+ io.emit("traffic:new-request", buildRealtimePayload(log));
65
+ },
66
+
67
+ getSocketPath() {
68
+ return socketPath;
69
+ },
70
+ };
71
+ }
@@ -0,0 +1,83 @@
1
+ import express from "express";
2
+ import { safeAsync } from "../utils/safeAsync.js";
3
+
4
+ function sendApiError(res, message) {
5
+ res.status(500).json({ error: message });
6
+ }
7
+
8
+ export function createAnalyticsRouter({ analyticsService, debug = false }) {
9
+ const router = express.Router();
10
+
11
+ const logApiError = (scope) => (error) => {
12
+ if (debug) {
13
+ console.error(`[express-sequelize-traffic] ${scope}`, error);
14
+ }
15
+ };
16
+
17
+ router.get("/overview", async (_req, res) => {
18
+ const data = await safeAsync(() => analyticsService.getOverview(), {
19
+ onError: logApiError("Failed to load overview analytics."),
20
+ });
21
+
22
+ if (!data) {
23
+ sendApiError(res, "Unable to load overview analytics.");
24
+ return;
25
+ }
26
+
27
+ res.json(data);
28
+ });
29
+
30
+ router.get("/live", async (_req, res) => {
31
+ const data = await safeAsync(() => analyticsService.getLive(), {
32
+ onError: logApiError("Failed to load live traffic logs."),
33
+ });
34
+
35
+ if (!data) {
36
+ sendApiError(res, "Unable to load live traffic logs.");
37
+ return;
38
+ }
39
+
40
+ res.json(data);
41
+ });
42
+
43
+ router.get("/routes", async (_req, res) => {
44
+ const data = await safeAsync(() => analyticsService.getRoutes(), {
45
+ onError: logApiError("Failed to load route analytics."),
46
+ });
47
+
48
+ if (!data) {
49
+ sendApiError(res, "Unable to load route analytics.");
50
+ return;
51
+ }
52
+
53
+ res.json(data);
54
+ });
55
+
56
+ router.get("/users", async (_req, res) => {
57
+ const data = await safeAsync(() => analyticsService.getUsers(), {
58
+ onError: logApiError("Failed to load user analytics."),
59
+ });
60
+
61
+ if (!data) {
62
+ sendApiError(res, "Unable to load user analytics.");
63
+ return;
64
+ }
65
+
66
+ res.json(data);
67
+ });
68
+
69
+ router.get("/errors", async (_req, res) => {
70
+ const data = await safeAsync(() => analyticsService.getErrors(), {
71
+ onError: logApiError("Failed to load error analytics."),
72
+ });
73
+
74
+ if (!data) {
75
+ sendApiError(res, "Unable to load error analytics.");
76
+ return;
77
+ }
78
+
79
+ res.json(data);
80
+ });
81
+
82
+ return router;
83
+ }