hearald 0.0.4 → 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.
@@ -1,13 +1,15 @@
1
1
  // Express middleware for hearald event tracking
2
2
  import express from "express";
3
- import log from "./logger.js";
4
- import { nowAsPstDate } from "../shared/utils.js";
5
- import { writeEvent, closeEventFiles } from "./serverEvents.js";
6
- import { createAnalyticsRouter } from "./analytics.js";
7
3
 
8
- export async function logEvent(time, event) {
9
- return writeEvent(time, event);
10
- }
4
+ import hearaldConfiguration from "./configuration.js";
5
+ import log from "./logger.js";
6
+ import {
7
+ writeEvent,
8
+ closeEventFiles,
9
+ VISIT_EVENT,
10
+ ERROR_EVENT,
11
+ } from "./serverEvents.js";
12
+ import { validateEvent } from "./validate.js";
11
13
 
12
14
  export async function shutDown() {
13
15
  await closeEventFiles();
@@ -18,9 +20,12 @@ export async function shutDown() {
18
20
  * @param {Object} options
19
21
  * @param {string} [options.url='/e'] - The URL to listen for events.
20
22
  * @param {function} [options.parseBody] - Custom request body parser (req => {e, u, p}).
23
+ * @param {Object} [options.schema] - Allowed events and their parameters (see validate.js). When
24
+ * given, events not in it, and parameters not listed, are dropped. Strongly recommended for any
25
+ * endpoint that is open to the internet.
21
26
  * @returns {function} Express middleware
22
27
  */
23
- export function eventEndpointMiddleware({ url = "/e", parseBody } = {}) {
28
+ export function eventEndpointMiddleware({ url = "/e", parseBody, schema } = {}) {
24
29
  const router = express.Router();
25
30
  router.post(url, async (req, res) => {
26
31
  try {
@@ -29,11 +34,25 @@ export function eventEndpointMiddleware({ url = "/e", parseBody } = {}) {
29
34
  ({ e, u, p } = parseBody(req));
30
35
  } else {
31
36
  ({ e = null, u = null, p = {} } = req.body || {});
32
- if (e === "visit") {
33
- p.ip = req.ip;
37
+ if (p === null || typeof p !== "object" || Array.isArray(p)) p = {};
38
+ if (e === VISIT_EVENT) {
39
+ p = { ...p, ip: req.ip };
40
+ }
41
+ }
42
+ if (schema) {
43
+ const clean = validateEvent(schema, { e, u, p });
44
+ if (!clean) {
45
+ // Answer the same as for a good event, so the endpoint reveals nothing about the schema.
46
+ res.status(204).send();
47
+ return;
34
48
  }
49
+ ({ e, u, p } = clean);
35
50
  }
36
- await logEvent(nowAsPstDate(), { e, u, p });
51
+ if (typeof e !== "string" || !e) {
52
+ res.status(204).send();
53
+ return;
54
+ }
55
+ await writeEvent(hearaldConfiguration.nowFn(), { e, u, p });
37
56
  } catch (err) {
38
57
  // eslint-disable-next-line no-console
39
58
  log.error("error writing event to event log", err);
@@ -42,3 +61,72 @@ export function eventEndpointMiddleware({ url = "/e", parseBody } = {}) {
42
61
  });
43
62
  return router;
44
63
  }
64
+
65
+ export async function trackServerEvent({
66
+ event,
67
+ time = null,
68
+ userId,
69
+ params = {},
70
+ }) {
71
+ await writeEvent(time || hearaldConfiguration.nowFn(), {
72
+ e: event,
73
+ u: userId,
74
+ p: params,
75
+ });
76
+ }
77
+
78
+ // JSON.stringify throws on circular structures (and on BigInt); an error handler must not.
79
+ function safeStringify(value) {
80
+ try {
81
+ return JSON.stringify(value);
82
+ } catch (e) {
83
+ return String(value);
84
+ }
85
+ }
86
+
87
+ export async function trackServerError({
88
+ error,
89
+ userId = null,
90
+ extraParams = {},
91
+ }) {
92
+ let message = "";
93
+ let stack = "";
94
+ if (error instanceof Error) {
95
+ message = error.message;
96
+ stack = error.stack || "";
97
+ } else if (typeof error === "string") {
98
+ message = error;
99
+ } else if (error && typeof error === "object") {
100
+ message = error.message || safeStringify(error);
101
+ stack = error.stack || "";
102
+ }
103
+ const params = {
104
+ message,
105
+ stack,
106
+ ...extraParams,
107
+ };
108
+ await writeEvent(hearaldConfiguration.nowFn(), {
109
+ e: ERROR_EVENT,
110
+ u: userId,
111
+ p: params,
112
+ });
113
+ }
114
+
115
+ // Express error-handling middleware
116
+ export function errorTrackingMiddleware({}) {
117
+ // eslint-disable-next-line no-unused-vars
118
+ return function (err, req, res, next) {
119
+ // Recording the error must never stop it reaching the app's own handler, nor crash the process
120
+ // (a failed write is a rejected promise nobody awaits).
121
+ try {
122
+ trackServerError({
123
+ error: err,
124
+ userId: hearaldConfiguration.getUserId(req),
125
+ extraParams: { url: req.originalUrl, method: req.method, ip: req.ip },
126
+ }).catch((e) => log.error("could not record the error", e));
127
+ } catch (e) {
128
+ log.error("could not record the error", e);
129
+ }
130
+ next(err);
131
+ };
132
+ }
@@ -1,72 +1,122 @@
1
- import { readFile, open as openFile, mkdir } from "fs/promises";
1
+ import {
2
+ readFile,
3
+ readdir,
4
+ unlink,
5
+ stat,
6
+ open as openFile,
7
+ mkdir,
8
+ } from "fs/promises";
9
+ import { join } from "path";
2
10
 
11
+ import hearaldConfiguration from "./configuration.js";
3
12
  import log from "./logger.js";
4
- import { iso } from "../shared/utils.js";
13
+
14
+ export const VISIT_EVENT = "visit";
15
+ export const ERROR_EVENT = "error";
5
16
 
6
17
  const eventFilePromises = new Map();
7
18
  const lastWriteTimeouts = new Map();
8
19
 
20
+ // Events live in hearaldConfiguration.eventsDir (default "events", relative to the working directory).
21
+ export const eventFilePath = (fileName) =>
22
+ join(hearaldConfiguration.eventsDir, `${fileName}.log`);
23
+
24
+ // A crash can leave the last line unfinished. Appending straight after it would glue the next event
25
+ // onto the broken line and lose both, so start a fresh line first.
26
+ const endWithNewline = async (file) => {
27
+ const { size } = await file.stat();
28
+ if (size === 0) return;
29
+ const last = Buffer.alloc(1);
30
+ await file.read(last, 0, 1, size - 1);
31
+ if (last[0] !== 0x0a) await file.write("\n");
32
+ };
33
+
9
34
  export const openEventFile = async (fileName) => {
10
- await mkdir("events", { recursive: true });
11
- return openFile(`events/${fileName}.log`, "a");
35
+ await mkdir(hearaldConfiguration.eventsDir, { recursive: true });
36
+ // "a+" appends like "a" but lets us look at the end of the file first
37
+ const file = await openFile(eventFilePath(fileName), "a+");
38
+ try {
39
+ await endWithNewline(file);
40
+ } catch (e) {
41
+ await file.close();
42
+ throw e;
43
+ }
44
+ return file;
12
45
  };
13
46
 
14
- export const writeEvent = async (time, event, isoFn = iso) => {
47
+ export const writeEvent = async (time, event) => {
15
48
  let fileName;
16
49
 
17
- const dateString = isoFn(time);
50
+ const dateString = hearaldConfiguration.isoFn(time);
18
51
 
19
- if (event.e === "visit") {
52
+ if (event.e === VISIT_EVENT) {
20
53
  fileName = `${dateString}-visits`;
21
- } else if (event.e === "error") {
54
+ } else if (event.e === ERROR_EVENT) {
22
55
  fileName = `${dateString}-errors`;
23
56
  } else {
24
57
  fileName = dateString;
25
58
  }
26
59
 
27
- if (!eventFilePromises.has(fileName)) {
28
- eventFilePromises.set(fileName, openEventFile(fileName));
60
+ const key = eventFilePath(fileName);
61
+ if (!eventFilePromises.has(key)) {
62
+ const opening = openEventFile(fileName);
63
+ eventFilePromises.set(key, opening);
64
+ // A failed open must not stay cached: every later write would fail with it, and closing it
65
+ // from the idle timer would be an unhandled rejection that kills the process.
66
+ opening.catch(() => {
67
+ if (eventFilePromises.get(key) === opening) {
68
+ eventFilePromises.delete(key);
69
+ clearIdleTimer(key);
70
+ }
71
+ });
29
72
  }
30
73
 
31
- if (lastWriteTimeouts.has(fileName)) {
32
- clearTimeout(lastWriteTimeouts.get(fileName));
33
- }
34
- lastWriteTimeouts.set(
35
- fileName,
36
- setTimeout(() => closeEventFile(fileName), 60 * 1000)
74
+ clearIdleTimer(key);
75
+ // Close by the key captured now: the events directory may have changed by the time this fires.
76
+ const timer = setTimeout(
77
+ () => closeByKey(key),
78
+ hearaldConfiguration.idleCloseMs
37
79
  );
80
+ timer.unref(); // an idle file handle must not keep a script alive
81
+ lastWriteTimeouts.set(key, timer);
38
82
 
39
- const eventFile = await eventFilePromises.get(fileName);
83
+ const eventFile = await eventFilePromises.get(key);
40
84
  return eventFile.write(`${JSON.stringify({ t: time, ...event })}\n`);
41
85
  };
42
86
 
43
- export const closeEventFile = async (fileName) => {
44
- const eventFilePromise = eventFilePromises.get(fileName);
45
- eventFilePromises.delete(fileName);
87
+ function clearIdleTimer(key) {
88
+ const timeout = lastWriteTimeouts.get(key);
89
+ lastWriteTimeouts.delete(key);
90
+ if (timeout) clearTimeout(timeout);
91
+ }
46
92
 
47
- const timeout = lastWriteTimeouts.get(fileName);
48
- lastWriteTimeouts.delete(fileName);
49
- if (timeout) {
50
- clearTimeout(timeout);
93
+ // Never rejects: it runs from timers and shutdown, where nobody could catch it. A failed open was
94
+ // already reported to the writer that asked for it.
95
+ async function closeByKey(key) {
96
+ const eventFilePromise = eventFilePromises.get(key);
97
+ eventFilePromises.delete(key);
98
+ clearIdleTimer(key);
99
+ if (!eventFilePromise) return;
100
+ try {
101
+ await (await eventFilePromise).close();
102
+ } catch (e) {
103
+ log.debug(`could not close ${key}: ${e.message}`);
51
104
  }
105
+ }
52
106
 
53
- if (eventFilePromise) {
54
- return (await eventFilePromise).close();
55
- }
56
- return undefined;
57
- };
107
+ // How many event files are open or opening (for tests and diagnostics).
108
+ export const openEventFileCount = () => eventFilePromises.size;
109
+
110
+ export const closeEventFile = (fileName) =>
111
+ closeByKey(eventFilePath(fileName));
58
112
 
59
113
  export const closeEventFiles = async () => {
60
- return Promise.all(
61
- Array.from(eventFilePromises.keys()).map(async (fileName) =>
62
- closeEventFile(fileName)
63
- )
64
- );
114
+ await Promise.all(Array.from(eventFilePromises.keys()).map(closeByKey));
65
115
  };
66
116
 
67
117
  export const readEventsFromFile = async (fileName) => {
68
118
  try {
69
- return (await readFile(`events/${fileName}.log`))
119
+ return (await readFile(eventFilePath(fileName)))
70
120
  .toString("utf8")
71
121
  .split("\n")
72
122
  .filter((r) => r.trim())
@@ -80,9 +130,9 @@ export const readEventsFromFile = async (fileName) => {
80
130
  })
81
131
  .filter(Boolean);
82
132
  } catch (e) {
83
- if (e.message && e.message.indexOf("ENOENT") !== -1) {
84
- // eslint-disable-next-line no-console
85
- log.warn(`file not found: events/${fileName}.log`);
133
+ if (e.code === "ENOENT") {
134
+ // a day with no events has no file; that is normal
135
+ log.debug(`no events file: ${eventFilePath(fileName)}`);
86
136
  } else {
87
137
  // eslint-disable-next-line no-console
88
138
  log.error(e);
@@ -91,16 +141,77 @@ export const readEventsFromFile = async (fileName) => {
91
141
  }
92
142
  };
93
143
 
94
- export const loadEventsOverWindow = async (d, n, fileSuffix, isoFn = iso) => {
95
- let events = [];
144
+ // The file names of the n days ending on d (UTC fields, like `iso`).
145
+ export const windowFileNames = (d, n, fileSuffix) => {
146
+ const names = [];
96
147
  for (let i = 0; i < n; i++) {
148
+ // `iso` reads the UTC fields, so step back in UTC too (local-time maths skips or repeats a day
149
+ // around daylight saving changes on servers not set to UTC)
97
150
  const newDate = new Date(d);
98
- newDate.setDate(d.getDate() - i);
99
- let fileName = isoFn(newDate);
151
+ newDate.setUTCDate(d.getUTCDate() - i);
152
+ let fileName = hearaldConfiguration.isoFn(newDate);
100
153
  if (fileSuffix) {
101
154
  fileName += `-${fileSuffix}`;
102
155
  }
103
- events = [...events, ...(await readEventsFromFile(fileName))];
156
+ names.push(fileName);
157
+ }
158
+ return names;
159
+ };
160
+
161
+ // Total size in bytes of the event files for a window, so a window too big for memory can be refused
162
+ // before anything is read.
163
+ export const windowBytes = async (d, n, fileSuffix) => {
164
+ let total = 0;
165
+ for (const fileName of windowFileNames(d, n, fileSuffix)) {
166
+ try {
167
+ total += (await stat(eventFilePath(fileName))).size;
168
+ } catch (e) {
169
+ if (e.code !== "ENOENT") throw e;
170
+ }
171
+ }
172
+ return total;
173
+ };
174
+
175
+ export const loadEventsOverWindow = async (d, n, fileSuffix) => {
176
+ const events = [];
177
+ for (const fileName of windowFileNames(d, n, fileSuffix)) {
178
+ // push, not spreading into a new array each day, which is quadratic
179
+ for (const event of await readEventsFromFile(fileName)) events.push(event);
104
180
  }
105
181
  return events;
106
182
  };
183
+
184
+ // Deletes event logs and cached summaries older than `days` days (by the date in the file name; a
185
+ // file dated exactly `days` days ago is kept). Counts days with the configured clock. Returns the number of files removed. Run it nightly to honour a retention period.
186
+ export const purgeOldEvents = async ({
187
+ days,
188
+ now = hearaldConfiguration.nowFn(),
189
+ } = {}) => {
190
+ if (!Number.isInteger(days) || days < 1) {
191
+ throw new Error("purgeOldEvents needs a whole number of days, 1 or more");
192
+ }
193
+ // UTC fields, like `isoFn`; local-time maths could be off by a day around daylight saving changes
194
+ const cutoff = new Date(now);
195
+ cutoff.setUTCDate(cutoff.getUTCDate() - days);
196
+ const cutoffIso = hearaldConfiguration.isoFn(cutoff);
197
+
198
+ let names;
199
+ try {
200
+ names = await readdir(hearaldConfiguration.eventsDir);
201
+ } catch (e) {
202
+ if (e.code === "ENOENT") return 0;
203
+ throw e;
204
+ }
205
+ let removed = 0;
206
+ for (const name of names) {
207
+ const match =
208
+ /^(\d{4}-\d{2}-\d{2})(?:-[a-z]+)?\.log$/.exec(name) ||
209
+ /^summary:(\d{4}-\d{2}-\d{2}):\d+(?::[0-9a-f]+)?\.json$/.exec(name);
210
+ if (match && match[1] < cutoffIso) {
211
+ await closeEventFile(name.replace(/\.log$/, ""));
212
+ await unlink(join(hearaldConfiguration.eventsDir, name));
213
+ removed += 1;
214
+ }
215
+ }
216
+ return removed;
217
+ };
@@ -0,0 +1,56 @@
1
+ // Validates events arriving on the public event endpoint against a schema, so the endpoint cannot
2
+ // be used to store arbitrary text or unbounded data.
3
+ //
4
+ // schema = {
5
+ // completion_added: {
6
+ // platform: { type: "string", enum: ["web", "ios"], required: true },
7
+ // goal_type: { type: "string", enum: ["binary", "counter", "monthly"] },
8
+ // count: { type: "number", integer: true, min: 0, max: 1000 },
9
+ // },
10
+ // };
11
+ //
12
+ // Events not in the schema, and parameters not listed for their event, are dropped. An event with a
13
+ // missing required parameter or a parameter of the wrong type is rejected whole.
14
+
15
+ const MAX_USER_ID_LENGTH = 64;
16
+ const DEFAULT_MAX_STRING = 64;
17
+
18
+ function checkParam(spec, value) {
19
+ switch (spec.type) {
20
+ case "string":
21
+ if (typeof value !== "string") return { ok: false };
22
+ if (value.length > (spec.maxLength ?? DEFAULT_MAX_STRING)) return { ok: false };
23
+ if (spec.enum && !spec.enum.includes(value)) return { ok: false };
24
+ return { ok: true, value };
25
+ case "number":
26
+ if (typeof value !== "number" || !Number.isFinite(value)) return { ok: false };
27
+ if (spec.integer && !Number.isInteger(value)) return { ok: false };
28
+ if (spec.min !== undefined && value < spec.min) return { ok: false };
29
+ if (spec.max !== undefined && value > spec.max) return { ok: false };
30
+ return { ok: true, value };
31
+ case "boolean":
32
+ return typeof value === "boolean" ? { ok: true, value } : { ok: false };
33
+ default:
34
+ throw new Error(`unknown parameter type "${spec.type}"`);
35
+ }
36
+ }
37
+
38
+ // Returns the cleaned { e, u, p }, or null when the event should be dropped.
39
+ export function validateEvent(schema, { e, u, p } = {}) {
40
+ if (typeof e !== "string" || !Object.hasOwn(schema, e)) return null;
41
+ if (u !== null && u !== undefined) {
42
+ if (typeof u !== "string" || !u || u.length > MAX_USER_ID_LENGTH) return null;
43
+ }
44
+ const given = p && typeof p === "object" && !Array.isArray(p) ? p : {};
45
+ const clean = {};
46
+ for (const [name, spec] of Object.entries(schema[e])) {
47
+ if (!Object.hasOwn(given, name)) {
48
+ if (spec.required) return null;
49
+ continue;
50
+ }
51
+ const result = checkParam(spec, given[name]);
52
+ if (!result.ok) return null;
53
+ clean[name] = result.value;
54
+ }
55
+ return { e, u: u ?? null, p: clean };
56
+ }
@@ -10,21 +10,36 @@ export const pacificTimeFormatter = new Intl.DateTimeFormat("en-US", {
10
10
  export const pacificTimeOffsetHours = (date) =>
11
11
  pacificTimeFormatter.format(date || new Date()).endsWith("PDT") ? -7 : -8;
12
12
 
13
- export const dateStringToPstDate = (dateString) => {
14
- const rawDate = dateString ? new Date(dateString) : new Date();
15
- const offset = pacificTimeOffsetHours(rawDate);
16
- const pstDate = new Date();
17
- pstDate.setUTCFullYear(
18
- rawDate.getUTCFullYear(),
19
- rawDate.getUTCMonth(),
20
- rawDate.getUTCDate()
13
+ // A Date whose UTC fields hold the current Pacific wall-clock time, so `iso()` (which reads UTC
14
+ // fields) names the Pacific day. The instant it represents is not "now"; it is only for day buckets.
15
+ export const nowAsPstDate = (now = new Date()) => {
16
+ const parts = Object.fromEntries(
17
+ new Intl.DateTimeFormat("en-US", {
18
+ timeZone: "America/Los_Angeles",
19
+ hourCycle: "h23",
20
+ year: "numeric",
21
+ month: "numeric",
22
+ day: "numeric",
23
+ hour: "numeric",
24
+ minute: "numeric",
25
+ second: "numeric",
26
+ })
27
+ .formatToParts(now)
28
+ .map((p) => [p.type, p.value])
29
+ );
30
+ return new Date(
31
+ Date.UTC(
32
+ Number(parts.year),
33
+ Number(parts.month) - 1,
34
+ Number(parts.day),
35
+ Number(parts.hour),
36
+ Number(parts.minute),
37
+ Number(parts.second),
38
+ now.getMilliseconds()
39
+ )
21
40
  );
22
- pstDate.setUTCHours(-1 * offset, 0, 0, 0);
23
- return pstDate;
24
41
  };
25
42
 
26
- export const nowAsPstDate = () => dateStringToPstDate();
27
-
28
43
  export function omitProperties(object, ...keys) {
29
44
  let rest = object;
30
45
  keys.forEach((key) => {
@@ -43,9 +58,10 @@ export function iso(time) {
43
58
  ].join("-");
44
59
  }
45
60
 
46
- export function defaultEndDate() {
47
- const yesterday = nowAsPstDate();
48
- yesterday.setDate(yesterday.getDate() - 1);
49
- yesterday.setHours(0, 0, 0, 0);
61
+ // Midnight (UTC fields) at the start of yesterday, in the same day buckets as nowAsPstDate.
62
+ export function defaultEndDate(now = nowAsPstDate()) {
63
+ const yesterday = new Date(now);
64
+ yesterday.setUTCDate(yesterday.getUTCDate() - 1);
65
+ yesterday.setUTCHours(0, 0, 0, 0);
50
66
  return yesterday;
51
67
  }
package/.eslintrc DELETED
@@ -1,14 +0,0 @@
1
- {
2
- "env": {
3
- "browser": true,
4
- "node": true,
5
- "es2020": true
6
- },
7
- "parserOptions": {
8
- "ecmaVersion": 2020,
9
- "sourceType": "module"
10
- },
11
- "globals": {
12
- "Intl": "readonly"
13
- }
14
- }
package/TODO.md DELETED
@@ -1,34 +0,0 @@
1
- # hearald TODO
2
-
3
- ## Status (August 2025)
4
-
5
- - Event bus/evented logic, event file helpers, emit logic, admin stats, analytics helpers, and error tracking utilities have all been extracted and modularized.
6
- - hearald now uses client/server/shared subfolders and has a working .eslintrc for Intl.
7
- - Lint passes and all functionality is verified.
8
- - Migration of all helpers to client/server/shared is nearly complete.
9
-
10
- ## Next Steps for hearald
11
-
12
- 1. **Tracking Custom Events**
13
-
14
- - Make the event schema/config easy to extend (document how to define custom events)
15
- - Provide a clear API for emitting custom events (e.g., `trackEvent('my_event', {...})`)
16
-
17
- 2. **Generating Statistical Summary Views**
18
-
19
- - Make stat definitions configurable (allow user to provide their own stat_definition.json)
20
- - Expose a summary API (e.g., `generateStats(endDate, timeWindow)`)
21
- - Document how to add new metrics and computed stats
22
-
23
- 3. **Admin UI to See Stats**
24
- - Provide a basic, embeddable React/Preact/Vue component for admin stats (or a template)
25
- - Document how to use the endpoint and client fetcher in any admin UI
26
- - Optionally, provide a standalone admin page as part of the package
27
-
28
- ## General Improvements
29
-
30
- - Write clear documentation and usage examples
31
- - Add configuration options for stat definitions and event types
32
- - Provide ready-to-use middleware and client helpers for common event types
33
- - Package a basic admin UI or template
34
- - Test in a clean, new project to validate ease of adoption
package/rollup.config.js DELETED
@@ -1,59 +0,0 @@
1
- import fs from "fs";
2
- import path from "path";
3
- import resolve from "@rollup/plugin-node-resolve";
4
- import postcss from "rollup-plugin-postcss";
5
-
6
- // Custom plugin to inline JS and CSS into HTML
7
- function inlineAssetsPlugin({ htmlFile, outputFile }) {
8
- return {
9
- name: "inline-assets",
10
- async writeBundle(options, bundle) {
11
- const htmlPath = path.resolve(htmlFile);
12
- let html = fs.readFileSync(htmlPath, "utf8");
13
-
14
- // Find JS and CSS output from bundle
15
- const jsBundle = Object.values(bundle).find(
16
- (file) => file.type === "chunk" && file.isEntry
17
- );
18
- const cssBundle = Object.values(bundle).find(
19
- (file) => file.type === "asset" && file.fileName.endsWith(".css")
20
- );
21
-
22
- const js = jsBundle ? jsBundle.code : "";
23
- const css = cssBundle ? cssBundle.source : "";
24
-
25
- // Replace <script src=...> and <link rel="stylesheet" ...> with inline tags
26
- html = html.replace(
27
- /<script\s+src="[^"]*"[^>]*><\/script>/,
28
- `<script>${js}</script>`
29
- );
30
- // Replace any <link rel="stylesheet" ...> (with any attributes)
31
- html = html.replace(
32
- /<link\s+rel=["']stylesheet["'][^>]*>/gi,
33
- `<style>${css}</style>`
34
- );
35
-
36
- // Write the inlined HTML
37
- fs.writeFileSync(path.resolve(options.dir, outputFile), html);
38
- },
39
- };
40
- }
41
-
42
- export default {
43
- input: "src/public/js/index.js", // Use JS entry point
44
- output: {
45
- dir: "dist",
46
- format: "iife",
47
- },
48
- plugins: [
49
- resolve(),
50
- postcss({
51
- inject: false,
52
- extract: true,
53
- }),
54
- inlineAssetsPlugin({
55
- htmlFile: "src/public/analytics.html",
56
- outputFile: "analytics.html",
57
- }),
58
- ],
59
- };
@@ -1,47 +0,0 @@
1
- // hearald/src/serverTrackError.js
2
- // Server-side utility to track errors
3
-
4
- export async function trackServerError({
5
- error,
6
- logEvent,
7
- userId = null,
8
- extraParams = {},
9
- nowFn = () => new Date(),
10
- }) {
11
- let message = "";
12
- let stack = "";
13
- if (error instanceof Error) {
14
- message = error.message;
15
- stack = error.stack || "";
16
- } else if (typeof error === "string") {
17
- message = error;
18
- } else if (error && typeof error === "object") {
19
- message = error.message || JSON.stringify(error);
20
- stack = error.stack || "";
21
- }
22
- const params = {
23
- message,
24
- stack,
25
- ...extraParams,
26
- };
27
- await logEvent(nowFn(), { e: "error", u: userId, p: params });
28
- }
29
-
30
- // Express error-handling middleware
31
- export function errorTrackingMiddleware({
32
- logEvent,
33
- nowFn = () => new Date(),
34
- userIdExtractor = (req) => req.user?.id,
35
- }) {
36
- // eslint-disable-next-line no-unused-vars
37
- return function (err, req, res, next) {
38
- trackServerError({
39
- error: err,
40
- logEvent,
41
- userId: userIdExtractor(req),
42
- extraParams: { url: req.originalUrl, method: req.method, ip: req.ip },
43
- nowFn,
44
- });
45
- next(err);
46
- };
47
- }