webspresso 0.0.52 → 0.0.54

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "webspresso",
3
- "version": "0.0.52",
3
+ "version": "0.0.54",
4
4
  "description": "Minimal, production-ready SSR framework for Node.js with file-based routing, Nunjucks templating, built-in i18n, and CLI tooling",
5
5
  "main": "index.js",
6
6
  "bin": {
@@ -218,6 +218,7 @@ var AnalyticsPage = {
218
218
  analyticsApi('top-pages', days),
219
219
  analyticsApi('bot-activity', days),
220
220
  analyticsApi('countries', days),
221
+ analyticsApi('client-errors', days),
221
222
  analyticsApi('recent', days),
222
223
  ]).then(function(results) {
223
224
  vnode.state.stats = results[0];
@@ -225,7 +226,8 @@ var AnalyticsPage = {
225
226
  vnode.state.topPages = results[2];
226
227
  vnode.state.botActivity = results[3];
227
228
  vnode.state.countries = results[4];
228
- vnode.state.recent = results[5];
229
+ vnode.state.clientErrors = results[5];
230
+ vnode.state.recent = results[6];
229
231
  vnode.state.loading = false;
230
232
 
231
233
  if (chartInstance) {
@@ -384,6 +386,33 @@ var AnalyticsPage = {
384
386
  ]),
385
387
  ]),
386
388
 
389
+ // Client Errors
390
+ m('div.bg-white.rounded-lg.shadow.mb-6', [
391
+ m('div.px-5.py-4.border-b.border-gray-100.flex.items-center.justify-between', [
392
+ m('h3.text-sm.font-semibold.text-gray-900.flex.items-center.gap-2', [
393
+ m('span', '⚠️'),
394
+ 'Client Errors',
395
+ ]),
396
+ m('span.text-xs.text-gray-400', 'Last ' + s.days + ' days'),
397
+ ]),
398
+ m('div.p-4.max-h-64.overflow-y-auto', [
399
+ !s.clientErrors || s.clientErrors.length === 0
400
+ ? m('p.text-gray-400.text-sm.text-center.py-6', 'No client errors')
401
+ : s.clientErrors.slice(0, 15).map(function(err) {
402
+ return m('div.border-b.border-gray-50.pb-3.mb-3.last:border-0.last:mb-0.last:pb-0', [
403
+ m('div.flex.items-start.gap-2', [
404
+ m('span.text-xs.px-1.5.py-0.5.rounded.bg-red-100.text-red-700', err.error_type || 'error'),
405
+ m('span.text-xs.text-gray-500', relativeTime(err.created_at)),
406
+ ]),
407
+ m('p.text-sm.text-gray-800.font-mono.break-all.mt-1', {
408
+ title: err.stack || err.message,
409
+ }, (err.message || '').slice(0, 120) + (err.message && err.message.length > 120 ? '…' : '')),
410
+ err.path && m('p.text-xs.text-gray-500.mt-0.5', err.path),
411
+ ]);
412
+ }),
413
+ ]),
414
+ ]),
415
+
387
416
  // Country Stats
388
417
  m('div.bg-white.rounded-lg.shadow.mb-6', [
389
418
  m('div.px-5.py-4.border-b.border-gray-100.flex.items-center.justify-between', [
@@ -11,7 +11,11 @@
11
11
  * @param {string} [options.tableName='analytics_page_views']
12
12
  */
13
13
  function createAnalyticsApiHandlers(options) {
14
- const { knex, tableName = 'analytics_page_views' } = options;
14
+ const {
15
+ knex,
16
+ tableName = 'analytics_page_views',
17
+ errorsTableName = 'analytics_client_errors',
18
+ } = options;
15
19
 
16
20
  function parseDays(req) {
17
21
  const days = parseInt(req.query.days) || 30;
@@ -208,6 +212,32 @@ function createAnalyticsApiHandlers(options) {
208
212
  }
209
213
  }
210
214
 
215
+ /**
216
+ * GET /client-errors - Recent client-side JS errors
217
+ */
218
+ async function getClientErrors(req, res) {
219
+ try {
220
+ const days = parseDays(req);
221
+ const since = sinceDate(days);
222
+ const limit = Math.min(parseInt(req.query.limit) || 50, 200);
223
+
224
+ const hasTable = await knex.schema.hasTable(errorsTableName);
225
+ if (!hasTable) {
226
+ return res.json([]);
227
+ }
228
+
229
+ const rows = await knex(errorsTableName)
230
+ .select('id', 'error_type', 'message', 'stack', 'path', 'created_at')
231
+ .where('created_at', '>=', since)
232
+ .orderBy('created_at', 'desc')
233
+ .limit(limit);
234
+
235
+ res.json(rows);
236
+ } catch (e) {
237
+ res.status(500).json({ error: e.message });
238
+ }
239
+ }
240
+
211
241
  /**
212
242
  * GET /recent - Recent page views
213
243
  */
@@ -233,6 +263,7 @@ function createAnalyticsApiHandlers(options) {
233
263
  getTopPages,
234
264
  getBotActivity,
235
265
  getCountries,
266
+ getClientErrors,
236
267
  getRecent,
237
268
  };
238
269
  }
@@ -0,0 +1,84 @@
1
+ /**
2
+ * Client Error Report Handler
3
+ * Receives error reports from browser and stores in DB
4
+ * @module plugins/site-analytics/client-error-handler
5
+ */
6
+
7
+ const DEFAULT_TABLE = 'analytics_client_errors';
8
+
9
+ /**
10
+ * Create the client errors table
11
+ */
12
+ async function ensureErrorsTable(knex, tableName = DEFAULT_TABLE) {
13
+ const exists = await knex.schema.hasTable(tableName);
14
+ if (!exists) {
15
+ await knex.schema.createTable(tableName, (table) => {
16
+ table.bigIncrements('id').primary();
17
+ table.string('error_type', 50).index(); // 'error' | 'unhandledrejection'
18
+ table.string('message', 500).index();
19
+ table.text('stack');
20
+ table.string('path', 500).index();
21
+ table.string('referrer', 500);
22
+ table.text('user_agent');
23
+ table.string('source', 500); // script url
24
+ table.integer('line');
25
+ table.integer('column');
26
+ table.timestamp('created_at').defaultTo(knex.fn.now()).index();
27
+ });
28
+ }
29
+ }
30
+
31
+ /**
32
+ * Create error report handler
33
+ * @param {Object} options
34
+ * @param {Object} options.knex - Knex instance
35
+ * @param {string} [options.tableName='analytics_client_errors']
36
+ */
37
+ function createErrorReportHandler(options) {
38
+ const { knex, tableName = DEFAULT_TABLE } = options;
39
+ let tableReady = false;
40
+
41
+ return async function errorReportHandler(req, res) {
42
+ if (req.method !== 'POST') {
43
+ return res.status(405).json({ error: 'Method not allowed' });
44
+ }
45
+
46
+ try {
47
+ if (!tableReady) {
48
+ await ensureErrorsTable(knex, tableName);
49
+ tableReady = true;
50
+ }
51
+
52
+ const body = req.body || {};
53
+ const { type, message, stack, path, referrer, userAgent, source, line, column } = body;
54
+
55
+ if (!message && !stack) {
56
+ return res.status(400).json({ error: 'message or stack required' });
57
+ }
58
+
59
+ await knex(tableName).insert({
60
+ error_type: type || 'error',
61
+ message: String(message || '').slice(0, 500),
62
+ stack: stack ? String(stack).slice(0, 5000) : null,
63
+ path: path ? String(path).slice(0, 500) : null,
64
+ referrer: referrer ? String(referrer).slice(0, 500) : null,
65
+ user_agent: userAgent ? String(userAgent).slice(0, 1000) : null,
66
+ source: source ? String(source).slice(0, 500) : null,
67
+ line: line != null ? parseInt(line) : null,
68
+ column: column != null ? parseInt(column) : null,
69
+ created_at: knex.fn.now(),
70
+ });
71
+
72
+ res.status(202).json({ ok: true });
73
+ } catch (e) {
74
+ console.error('[site-analytics] Client error report failed:', e.message);
75
+ res.status(500).json({ error: 'Report failed' });
76
+ }
77
+ };
78
+ }
79
+
80
+ module.exports = {
81
+ createErrorReportHandler,
82
+ ensureErrorsTable,
83
+ DEFAULT_TABLE,
84
+ };
@@ -0,0 +1,69 @@
1
+ /**
2
+ * Client Error Tracker
3
+ * Generates inline script to catch JS errors and unhandled rejections, reports to backend
4
+ * @module plugins/site-analytics/client-error-tracker
5
+ */
6
+
7
+ /**
8
+ * Generate client-side error tracking script
9
+ * @param {Object} options
10
+ * @param {string} [options.endpoint='/_analytics/report-error'] - POST endpoint for error reports
11
+ * @returns {string} Inline script content
12
+ */
13
+ function generateErrorTrackerScript(options = {}) {
14
+ const endpoint = options.endpoint || '/_analytics/report-error';
15
+
16
+ return `
17
+ (function() {
18
+ var endpoint = ${JSON.stringify(endpoint)};
19
+ var reported = {};
20
+ var MAX_STACK = 2000;
21
+
22
+ function report(type, message, stack, extra) {
23
+ var key = type + ':' + (message || '').slice(0, 100);
24
+ if (reported[key]) return;
25
+ reported[key] = true;
26
+
27
+ var payload = {
28
+ type: type,
29
+ message: String(message || 'Unknown error').slice(0, 500),
30
+ stack: stack ? String(stack).slice(0, MAX_STACK) : null,
31
+ path: window.location.pathname || '/',
32
+ referrer: document.referrer || null,
33
+ userAgent: navigator.userAgent || null
34
+ };
35
+ if (extra) {
36
+ for (var k in extra) payload[k] = extra[k];
37
+ }
38
+
39
+ try {
40
+ fetch(endpoint, {
41
+ method: 'POST',
42
+ headers: { 'Content-Type': 'application/json' },
43
+ body: JSON.stringify(payload),
44
+ keepalive: true
45
+ }).catch(function(){});
46
+ } catch (e) {}
47
+ }
48
+
49
+ window.onerror = function(msg, url, line, col, err) {
50
+ report('error', err ? err.message : msg, err ? err.stack : null, { line: line, column: col, source: url });
51
+ return false;
52
+ };
53
+
54
+ window.addEventListener('unhandledrejection', function(e) {
55
+ var msg = e.reason;
56
+ var stack = null;
57
+ if (msg && typeof msg === 'object') {
58
+ stack = msg.stack;
59
+ msg = msg.message || String(msg);
60
+ } else {
61
+ msg = String(msg);
62
+ }
63
+ report('unhandledrejection', msg, stack);
64
+ });
65
+ })();
66
+ `.trim();
67
+ }
68
+
69
+ module.exports = { generateErrorTrackerScript };
@@ -7,6 +7,8 @@
7
7
  const { createTrackingMiddleware } = require('./tracking');
8
8
  const { createAnalyticsApiHandlers } = require('./api-handlers');
9
9
  const { generateAnalyticsComponent } = require('./admin-component');
10
+ const { generateErrorTrackerScript } = require('./client-error-tracker');
11
+ const { createErrorReportHandler } = require('./client-error-handler');
10
12
 
11
13
  /**
12
14
  * Site Analytics Plugin Factory
@@ -15,6 +17,10 @@ const { generateAnalyticsComponent } = require('./admin-component');
15
17
  * @param {string[]} [options.excludePaths=[]] - Extra paths to exclude from tracking
16
18
  * @param {boolean} [options.trackBots=true] - Record bot visits (still counted separately)
17
19
  * @param {string} [options.tableName='analytics_page_views'] - DB table name
20
+ * @param {number} [options.batchSize=20] - Flush page view queue when it reaches this size
21
+ * @param {number} [options.flushIntervalMs=3000] - Flush interval for low traffic
22
+ * @param {boolean} [options.trackClientErrors=true] - Capture and report client-side JS errors
23
+ * @param {string} [options.errorsTableName='analytics_client_errors'] - Client errors table
18
24
  * @returns {Object} Plugin definition
19
25
  */
20
26
  function siteAnalyticsPlugin(options = {}) {
@@ -23,6 +29,8 @@ function siteAnalyticsPlugin(options = {}) {
23
29
  excludePaths = [],
24
30
  trackBots = true,
25
31
  tableName = 'analytics_page_views',
32
+ trackClientErrors = true,
33
+ errorsTableName = 'analytics_client_errors',
26
34
  } = options;
27
35
 
28
36
  if (!db) {
@@ -48,12 +56,26 @@ function siteAnalyticsPlugin(options = {}) {
48
56
  excludePaths,
49
57
  trackBots,
50
58
  tableName,
59
+ batchSize: options.batchSize ?? 20,
60
+ flushIntervalMs: options.flushIntervalMs ?? 3000,
51
61
  });
52
62
 
53
63
  ctx.app.use(trackingMiddleware);
64
+
65
+ if (trackClientErrors) {
66
+ const script = generateErrorTrackerScript({ endpoint: '/_analytics/report-error' });
67
+ ctx.injectBody(`<script>${script}</script>`, { id: 'site-analytics-error-tracker', priority: 5 });
68
+ }
54
69
  },
55
70
 
56
71
  onRoutesReady(ctx) {
72
+ // Client error report endpoint (must be in onRoutesReady - addRoute only mounts there)
73
+ if (trackClientErrors) {
74
+ const knex = db.knex || db;
75
+ const errorHandler = createErrorReportHandler({ knex, tableName: errorsTableName });
76
+ ctx.addRoute('post', '/_analytics/report-error', errorHandler);
77
+ }
78
+
57
79
  const adminApi = ctx.usePlugin('admin-panel');
58
80
  if (!adminApi) {
59
81
  console.warn('[site-analytics] admin-panel plugin not found, skipping admin page registration');
@@ -61,7 +83,11 @@ function siteAnalyticsPlugin(options = {}) {
61
83
  }
62
84
 
63
85
  const knex = db.knex || db;
64
- const handlers = createAnalyticsApiHandlers({ knex, tableName });
86
+ const handlers = createAnalyticsApiHandlers({
87
+ knex,
88
+ tableName,
89
+ errorsTableName,
90
+ });
65
91
 
66
92
  adminApi.registerModule({
67
93
  id: 'analytics',
@@ -90,6 +116,7 @@ function siteAnalyticsPlugin(options = {}) {
90
116
  { method: 'get', path: '/top-pages', handler: handlers.getTopPages },
91
117
  { method: 'get', path: '/bot-activity', handler: handlers.getBotActivity },
92
118
  { method: 'get', path: '/countries', handler: handlers.getCountries },
119
+ { method: 'get', path: '/client-errors', handler: handlers.getClientErrors },
93
120
  { method: 'get', path: '/recent', handler: handlers.getRecent },
94
121
  ],
95
122
  },
@@ -36,6 +36,8 @@ function getClientIp(req) {
36
36
  * @param {string[]} [options.excludePaths] - Paths to exclude from tracking
37
37
  * @param {boolean} [options.trackBots=true] - Whether to record bot visits
38
38
  * @param {string} [options.tableName='analytics_page_views'] - DB table name
39
+ * @param {number} [options.batchSize=20] - Flush when queue reaches this size
40
+ * @param {number} [options.flushIntervalMs=3000] - Flush interval for low traffic
39
41
  */
40
42
  function createTrackingMiddleware(options) {
41
43
  const {
@@ -43,10 +45,14 @@ function createTrackingMiddleware(options) {
43
45
  excludePaths = [],
44
46
  trackBots = true,
45
47
  tableName = 'analytics_page_views',
48
+ batchSize = 20,
49
+ flushIntervalMs = 3000,
46
50
  } = options;
47
51
 
48
52
  const allExcludes = [...DEFAULT_EXCLUDE, ...excludePaths];
49
53
  let tableReady = false;
54
+ const queue = [];
55
+ let flushScheduled = false;
50
56
 
51
57
  async function ensureTable() {
52
58
  if (tableReady) return;
@@ -101,6 +107,37 @@ function createTrackingMiddleware(options) {
101
107
  return sessionId;
102
108
  }
103
109
 
110
+ async function flushQueue() {
111
+ if (queue.length === 0) return;
112
+ const batch = queue.splice(0, queue.length);
113
+
114
+ try {
115
+ await ensureTable();
116
+ for (const row of batch) {
117
+ row.created_at = knex.fn.now();
118
+ }
119
+ await knex(tableName).insert(batch);
120
+ } catch (e) {
121
+ console.error('[site-analytics] Batch insert failed:', e.message);
122
+ // Re-queue failed items (optional - could drop to avoid loops)
123
+ queue.unshift(...batch);
124
+ }
125
+ }
126
+
127
+ function scheduleFlush() {
128
+ if (flushScheduled || queue.length === 0) return;
129
+ flushScheduled = true;
130
+ setTimeout(() => {
131
+ flushScheduled = false;
132
+ flushQueue().catch(() => {});
133
+ }, flushIntervalMs);
134
+ }
135
+
136
+ // Periodic flush (catches low-traffic case)
137
+ setInterval(() => {
138
+ if (queue.length > 0) flushQueue().catch(() => {});
139
+ }, flushIntervalMs).unref();
140
+
104
141
  return async function trackingMiddleware(req, res, next) {
105
142
  next();
106
143
 
@@ -111,8 +148,6 @@ function createTrackingMiddleware(options) {
111
148
  if (allExcludes.some(prefix => path.startsWith(prefix))) return;
112
149
 
113
150
  try {
114
- await ensureTable();
115
-
116
151
  const userAgent = req.headers['user-agent'] || '';
117
152
  const ip = getClientIp(req);
118
153
  const { isBot, botName } = detectBot(userAgent);
@@ -125,7 +160,7 @@ function createTrackingMiddleware(options) {
125
160
  const country = detectCountry(req);
126
161
  const referrer = req.headers['referer'] || req.headers['referrer'] || null;
127
162
 
128
- await knex(tableName).insert({
163
+ queue.push({
129
164
  session_id: sessionId,
130
165
  visitor_id: visitorId,
131
166
  path,
@@ -135,8 +170,13 @@ function createTrackingMiddleware(options) {
135
170
  country,
136
171
  is_bot: isBot,
137
172
  bot_name: botName,
138
- created_at: knex.fn.now(),
139
173
  });
174
+
175
+ if (queue.length >= batchSize) {
176
+ flushQueue().catch(() => {});
177
+ } else {
178
+ scheduleFlush();
179
+ }
140
180
  } catch (e) {
141
181
  // Non-blocking: don't let tracking errors affect the app
142
182
  }