weifuwu 0.17.4 → 0.17.6

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 CHANGED
@@ -1146,6 +1146,56 @@ await deploy(config)
1146
1146
 
1147
1147
  ---
1148
1148
 
1149
+ ## Analytics
1150
+
1151
+ In-memory page view tracking with a built-in dashboard. Zero extra dependencies.
1152
+
1153
+ ```ts
1154
+ import { analytics } from 'weifuwu'
1155
+
1156
+ app.use(analytics()) // mounts middleware + /analytics + /__analytics/data
1157
+ ```
1158
+
1159
+ | Endpoint | Description |
1160
+ |----------|-------------|
1161
+ | `GET /analytics` | Dashboard — PV trend, top pages, referrers, device breakdown |
1162
+ | `GET /__analytics/data?days=7` | Raw JSON data for custom dashboards |
1163
+
1164
+ Excluded paths (not recorded): `/__analytics/*`, `__wfw/*`, `/static/*`, `/analytics`.
1165
+
1166
+ ### Dashboard
1167
+
1168
+ The built-in `/analytics` page renders a server-generated HTML dashboard with:
1169
+
1170
+ - **Summary cards** — total PV, unique pages, mobile/desktop ratio
1171
+ - **Bar chart** — daily page views for the selected period
1172
+ - **Top pages table** — most visited paths ranked by views
1173
+ - **Referrers table** — top referring domains
1174
+
1175
+ ### JSON API
1176
+
1177
+ ```ts
1178
+ // GET /__analytics/data?days=30
1179
+ {
1180
+ "total_pv": 2847,
1181
+ "total_uv": 1231,
1182
+ "daily": [{ "date": "2026-06-05", "pv": 520, "uv": 310 }],
1183
+ "top_pages": [{ "path": "/tools/uppercase", "pv": 1284 }],
1184
+ "referrers": [{ "domain": "google.com", "count": 380 }],
1185
+ "devices": { "mobile": 45.2, "desktop": 54.8 }
1186
+ }
1187
+ ```
1188
+
1189
+ ### Options
1190
+
1191
+ ```ts
1192
+ app.use(analytics({
1193
+ excluded: ['/admin', '/api'], // custom exclude patterns (defaults listed above)
1194
+ }))
1195
+ ```
1196
+
1197
+ ---
1198
+
1149
1199
  ## Health check
1150
1200
 
1151
1201
  ```ts
@@ -0,0 +1,8 @@
1
+ import type { Handler, Middleware } from './types.ts';
2
+ export interface AnalyticsOptions {
3
+ excluded?: string[];
4
+ }
5
+ export declare function analytics(options?: AnalyticsOptions): {
6
+ middleware: Middleware;
7
+ handler: Handler;
8
+ };
package/dist/index.d.ts CHANGED
@@ -54,6 +54,8 @@ export { opencode } from './opencode/index.ts';
54
54
  export type { OpencodeOptions, OpencodeModule, SkillDef, OpencodePermissions, Session as OpencodeSession, } from './opencode/types.ts';
55
55
  export { health } from './health.ts';
56
56
  export type { HealthOptions } from './health.ts';
57
+ export { analytics } from './analytics.ts';
58
+ export type { AnalyticsOptions } from './analytics.ts';
57
59
  export { preferences } from './preferences.ts';
58
60
  export type { PrefOptions } from './preferences.ts';
59
61
  export { seo, seoMiddleware, seoTags } from './seo.ts';
package/dist/index.js CHANGED
@@ -6798,6 +6798,164 @@ function health(options) {
6798
6798
  return r;
6799
6799
  }
6800
6800
 
6801
+ // analytics.ts
6802
+ var DEFAULT_EXCLUDED = ["/__analytics", "/__wfw", "/static", "/analytics"];
6803
+ var MemStore = class {
6804
+ days = /* @__PURE__ */ new Map();
6805
+ pages = /* @__PURE__ */ new Map();
6806
+ refs = /* @__PURE__ */ new Map();
6807
+ record(path2, date, refDomain, mobile) {
6808
+ let day = this.days.get(date);
6809
+ if (!day) {
6810
+ day = { pv: 0, uv: /* @__PURE__ */ new Set(), mobile: 0, desktop: 0 };
6811
+ this.days.set(date, day);
6812
+ }
6813
+ day.pv++;
6814
+ day.uv.add(path2);
6815
+ if (mobile) {
6816
+ day.mobile++;
6817
+ } else {
6818
+ day.desktop++;
6819
+ }
6820
+ let page = this.pages.get(path2);
6821
+ if (!page) {
6822
+ page = { count: 0 };
6823
+ this.pages.set(path2, page);
6824
+ }
6825
+ page.count++;
6826
+ if (refDomain) {
6827
+ let refs = this.refs.get(date);
6828
+ if (!refs) {
6829
+ refs = /* @__PURE__ */ new Map();
6830
+ this.refs.set(date, refs);
6831
+ }
6832
+ refs.set(refDomain, (refs.get(refDomain) || 0) + 1);
6833
+ }
6834
+ }
6835
+ query(days) {
6836
+ const since = /* @__PURE__ */ new Date();
6837
+ since.setDate(since.getDate() - days);
6838
+ const sinceStr = since.toISOString().slice(0, 10);
6839
+ const daily = [];
6840
+ let totalPv = 0;
6841
+ let totalMobile = 0;
6842
+ let totalDesktop = 0;
6843
+ const pageMap = /* @__PURE__ */ new Map();
6844
+ const allUv = /* @__PURE__ */ new Set();
6845
+ for (const [date, day] of this.days) {
6846
+ if (date < sinceStr) continue;
6847
+ daily.push({ date, pv: day.pv, uv: day.uv.size });
6848
+ totalPv += day.pv;
6849
+ totalMobile += day.mobile;
6850
+ totalDesktop += day.desktop;
6851
+ for (const p of day.uv) allUv.add(p);
6852
+ }
6853
+ for (const [path2, page] of this.pages) {
6854
+ pageMap.set(path2, page.count);
6855
+ }
6856
+ const topPages = [...pageMap.entries()].sort((a, b) => b[1] - a[1]).slice(0, 20).map(([path2, count]) => ({ path: path2, pv: count }));
6857
+ const refMap = /* @__PURE__ */ new Map();
6858
+ for (const [date, refs] of this.refs) {
6859
+ if (date < sinceStr) continue;
6860
+ for (const [domain, count] of refs) refMap.set(domain, (refMap.get(domain) || 0) + count);
6861
+ }
6862
+ const referrers = [...refMap.entries()].sort((a, b) => b[1] - a[1]).slice(0, 10).map(([domain, count]) => ({ domain, count }));
6863
+ const total = totalMobile + totalDesktop || 1;
6864
+ return {
6865
+ total_pv: totalPv,
6866
+ total_uv: allUv.size,
6867
+ daily,
6868
+ top_pages: topPages,
6869
+ referrers,
6870
+ devices: { mobile: Math.round(totalMobile / total * 1e3) / 10, desktop: Math.round(totalDesktop / total * 1e3) / 10 }
6871
+ };
6872
+ }
6873
+ handler() {
6874
+ return async (req) => {
6875
+ const url = new URL(req.url);
6876
+ const days = Math.min(Math.max(Number(url.searchParams.get("days")) || 7, 1), 365);
6877
+ const data = this.query(days);
6878
+ if (url.pathname === "/__analytics/data") {
6879
+ return Response.json(data);
6880
+ }
6881
+ return new Response(this.renderDashboard(days, data), {
6882
+ headers: { "content-type": "text/html; charset=utf-8" }
6883
+ });
6884
+ };
6885
+ }
6886
+ renderDashboard(days, data) {
6887
+ const { total_pv, total_uv, daily, top_pages, referrers, devices } = data;
6888
+ const maxPv = Math.max(...daily.map((d) => d.pv), 1);
6889
+ const bars = daily.map(
6890
+ (d) => `<div class="bar-wrap"><div class="bar" style="height:${d.pv / maxPv * 100}%"></div><span class="bar-label">${d.date.slice(5)}</span></div>`
6891
+ ).join("");
6892
+ const rows = top_pages.map(
6893
+ (p, i) => `<tr><td class="num">${i + 1}</td><td class="path">${p.path}</td><td class="num">${p.pv}</td></tr>`
6894
+ ).join("");
6895
+ const refRows = referrers.map(
6896
+ (r) => `<tr><td>${r.domain}</td><td class="num">${r.count}</td></tr>`
6897
+ ).join("");
6898
+ return `<!DOCTYPE html>
6899
+ <html lang="en">
6900
+ <head><meta charset="utf-8"/><meta name="viewport" content="width=device-width,initial-scale=1"/><title>Analytics - weifuwu</title>
6901
+ <style>
6902
+ *,:before,:after{box-sizing:border-box;margin:0;padding:0}
6903
+ body{font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,sans-serif;background:#f8f9fa;color:#333;padding:24px;max-width:960px;margin:0 auto}
6904
+ h1{font-size:24px;font-weight:700;margin-bottom:24px}
6905
+ .cards{display:grid;grid-template-columns:repeat(auto-fit,minmax(140px,1fr));gap:12px;margin-bottom:24px}
6906
+ .card{background:#fff;border-radius:10px;padding:16px;box-shadow:0 1px 3px rgba(0,0,0,.08)}
6907
+ .card .val{font-size:28px;font-weight:700;color:#2563eb}
6908
+ .card .lbl{font-size:12px;color:#888;margin-top:4px}
6909
+ .section{background:#fff;border-radius:10px;padding:20px;margin-bottom:16px;box-shadow:0 1px 3px rgba(0,0,0,.08)}
6910
+ .section h2{font-size:14px;font-weight:600;color:#888;text-transform:uppercase;letter-spacing:.05em;margin-bottom:16px}
6911
+ .chart{display:flex;align-items:flex-end;gap:4px;height:160px;padding-top:8px}
6912
+ .bar-wrap{flex:1;display:flex;flex-direction:column;align-items:center;height:100%;justify-content:flex-end}
6913
+ .bar{width:100%;background:#2563eb;border-radius:4px 4px 0 0;min-height:2px}
6914
+ .bar-label{font-size:10px;color:#888;margin-top:6px;white-space:nowrap}
6915
+ table{width:100%;border-collapse:collapse;font-size:13px}
6916
+ th{text-align:left;padding:6px 8px;color:#888;font-weight:500;border-bottom:1px solid #eee}
6917
+ td{padding:6px 8px;border-bottom:1px solid #f0f0f0}
6918
+ .num{text-align:right;font-variant-numeric:tabular-nums}
6919
+ .path{font-family:ui-monospace,SFMono-Regular,monospace;font-size:12px}
6920
+ tr:hover td{background:#f8faff}
6921
+ </style></head>
6922
+ <body>
6923
+ <h1>Analytics</h1>
6924
+ <div class="cards">
6925
+ <div class="card"><div class="val">${total_pv}</div><div class="lbl">Page Views (${days}d)</div></div>
6926
+ <div class="card"><div class="val">${total_uv}</div><div class="lbl">Unique Pages</div></div>
6927
+ <div class="card"><div class="val">${devices.mobile}%</div><div class="lbl">Mobile</div></div>
6928
+ <div class="card"><div class="val">${devices.desktop}%</div><div class="lbl">Desktop</div></div>
6929
+ </div>
6930
+ <div class="section"><h2>Daily Page Views</h2><div class="chart">${bars}</div></div>
6931
+ <div class="section"><h2>Top Pages</h2>
6932
+ <table><thead><tr><th style="width:32px">#</th><th>Path</th><th style="width:64px">Views</th></tr></thead><tbody>${rows}</tbody></table></div>
6933
+ ${referrers.length ? `<div class="section"><h2>Referrers</h2><table><thead><tr><th>Domain</th><th style="width:64px">Views</th></tr></thead><tbody>${refRows}</tbody></table></div>` : ""}
6934
+ </body></html>`;
6935
+ }
6936
+ };
6937
+ function analytics(options) {
6938
+ const excluded = options?.excluded ?? DEFAULT_EXCLUDED;
6939
+ const store = new MemStore();
6940
+ const middleware = async (req, ctx, next) => {
6941
+ const url = new URL(req.url);
6942
+ const path2 = url.pathname;
6943
+ if (!excluded.some((e) => path2.startsWith(e))) {
6944
+ const date = (/* @__PURE__ */ new Date()).toISOString().slice(0, 10);
6945
+ const ref = req.headers.get("referer") || "";
6946
+ const refDomain = ref ? new URL(ref).hostname.replace(/^www\./, "") : "";
6947
+ const ua = req.headers.get("user-agent") || "";
6948
+ const mobile = /mobile|android|iphone|ipad/i.test(ua);
6949
+ store.record(path2, date, refDomain, mobile);
6950
+ }
6951
+ return next(req, ctx);
6952
+ };
6953
+ return {
6954
+ middleware,
6955
+ handler: store.handler()
6956
+ };
6957
+ }
6958
+
6801
6959
  // preferences.ts
6802
6960
  import { readFile as readFile2 } from "node:fs/promises";
6803
6961
  import { existsSync as existsSync4 } from "node:fs";
@@ -8272,6 +8430,7 @@ export {
8272
8430
  TsxContext,
8273
8431
  agent,
8274
8432
  aiStream,
8433
+ analytics,
8275
8434
  auth,
8276
8435
  compress,
8277
8436
  cors,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "weifuwu",
3
- "version": "0.17.4",
3
+ "version": "0.17.6",
4
4
  "description": "Web-standard HTTP framework for Node.js — (req, ctx) => Response",
5
5
  "main": "./dist/index.js",
6
6
  "types": "./dist/index.d.ts",