weifuwu 0.17.4 → 0.17.5

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 { Router } from './router.ts';
2
+ export interface AnalyticsOptions {
3
+ pg?: {
4
+ sql: any;
5
+ };
6
+ excluded?: string[];
7
+ }
8
+ export declare function analytics(options?: AnalyticsOptions): Router;
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,179 @@ 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, dates: /* @__PURE__ */ new Set() };
6823
+ this.pages.set(path2, page);
6824
+ }
6825
+ page.count++;
6826
+ page.dates.add(date);
6827
+ if (refDomain) {
6828
+ let refs = this.refs.get(date);
6829
+ if (!refs) {
6830
+ refs = /* @__PURE__ */ new Map();
6831
+ this.refs.set(date, refs);
6832
+ }
6833
+ refs.set(refDomain, (refs.get(refDomain) || 0) + 1);
6834
+ }
6835
+ }
6836
+ query(days) {
6837
+ const since = /* @__PURE__ */ new Date();
6838
+ since.setDate(since.getDate() - days);
6839
+ const sinceStr = since.toISOString().slice(0, 10);
6840
+ const daily = [];
6841
+ let totalPv = 0;
6842
+ let totalMobile = 0;
6843
+ let totalDesktop = 0;
6844
+ const pageMap = /* @__PURE__ */ new Map();
6845
+ const allUv = /* @__PURE__ */ new Set();
6846
+ for (const [date, day] of this.days) {
6847
+ if (date < sinceStr) continue;
6848
+ daily.push({ date, pv: day.pv, uv: day.uv.size });
6849
+ totalPv += day.pv;
6850
+ totalMobile += day.mobile;
6851
+ totalDesktop += day.desktop;
6852
+ for (const p of day.uv) allUv.add(p);
6853
+ }
6854
+ for (const [path2, page] of this.pages) {
6855
+ let count = 0;
6856
+ for (const d of page.dates) if (d >= sinceStr) count += page.count / page.dates.size;
6857
+ pageMap.set(path2, page.count);
6858
+ }
6859
+ const topPages = [...pageMap.entries()].sort((a, b) => b[1] - a[1]).slice(0, 20).map(([path2, count]) => ({ path: path2, pv: count }));
6860
+ const refMap = /* @__PURE__ */ new Map();
6861
+ for (const [date, refs] of this.refs) {
6862
+ if (date < sinceStr) continue;
6863
+ for (const [domain, count] of refs) refMap.set(domain, (refMap.get(domain) || 0) + count);
6864
+ }
6865
+ const referrers = [...refMap.entries()].sort((a, b) => b[1] - a[1]).slice(0, 10).map(([domain, count]) => ({ domain, count }));
6866
+ const total = totalMobile + totalDesktop || 1;
6867
+ return {
6868
+ total_pv: totalPv,
6869
+ total_uv: allUv.size,
6870
+ daily,
6871
+ top_pages: topPages,
6872
+ referrers,
6873
+ devices: { mobile: Math.round(totalMobile / total * 1e3) / 10, desktop: Math.round(totalDesktop / total * 1e3) / 10 }
6874
+ };
6875
+ }
6876
+ };
6877
+ function analytics(options) {
6878
+ const excluded = options?.excluded ?? DEFAULT_EXCLUDED;
6879
+ const store = new MemStore();
6880
+ function shouldExclude(path2) {
6881
+ return excluded.some((e) => path2.startsWith(e));
6882
+ }
6883
+ const middleware = async (req, ctx, next) => {
6884
+ const url = new URL(req.url);
6885
+ const path2 = url.pathname;
6886
+ if (!shouldExclude(path2)) {
6887
+ const date = (/* @__PURE__ */ new Date()).toISOString().slice(0, 10);
6888
+ const ref = req.headers.get("referer") || "";
6889
+ const refDomain = ref ? new URL(ref).hostname.replace(/^www\./, "") : "";
6890
+ const ua = req.headers.get("user-agent") || "";
6891
+ const mobile = /mobile|android|iphone|ipad/i.test(ua);
6892
+ store.record(path2, date, refDomain, mobile);
6893
+ }
6894
+ return next(req, ctx);
6895
+ };
6896
+ const router = new Router();
6897
+ router.use(middleware);
6898
+ router.get("/__analytics/data", async (req) => {
6899
+ const url = new URL(req.url);
6900
+ const days = Math.min(Math.max(Number(url.searchParams.get("days")) || 7, 1), 365);
6901
+ return Response.json(store.query(days));
6902
+ });
6903
+ router.get("/analytics", async (req) => {
6904
+ const url = new URL(req.url);
6905
+ const days = Math.min(Math.max(Number(url.searchParams.get("days")) || 7, 1), 365);
6906
+ const data = store.query(days);
6907
+ return new Response(renderDashboard(days, data), {
6908
+ headers: { "content-type": "text/html; charset=utf-8" }
6909
+ });
6910
+ });
6911
+ return router;
6912
+ }
6913
+ function renderDashboard(days, data) {
6914
+ const { total_pv, total_uv, daily, top_pages, referrers, devices } = data;
6915
+ const maxPv = Math.max(...daily.map((d) => d.pv), 1);
6916
+ const bars = daily.map(
6917
+ (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>`
6918
+ ).join("");
6919
+ const rows = top_pages.map(
6920
+ (p, i) => `<tr><td class="num">${i + 1}</td><td class="path">${p.path}</td><td class="num">${p.pv}</td></tr>`
6921
+ ).join("");
6922
+ const refRows = referrers.map(
6923
+ (r) => `<tr><td>${r.domain}</td><td class="num">${r.count}</td></tr>`
6924
+ ).join("");
6925
+ return `<!DOCTYPE html>
6926
+ <html lang="en">
6927
+ <head>
6928
+ <meta charset="utf-8"/>
6929
+ <meta name="viewport" content="width=device-width, initial-scale=1"/>
6930
+ <title>Analytics - weifuwu</title>
6931
+ <style>
6932
+ *,:before,:after{box-sizing:border-box;margin:0;padding:0}
6933
+ body{font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,sans-serif;background:#f8f9fa;color:#333;padding:24px;max-width:960px;margin:0 auto}
6934
+ h1{font-size:24px;font-weight:700;margin-bottom:24px}
6935
+ .cards{display:grid;grid-template-columns:repeat(auto-fit,minmax(140px,1fr));gap:12px;margin-bottom:24px}
6936
+ .card{background:#fff;border-radius:10px;padding:16px;box-shadow:0 1px 3px rgba(0,0,0,.08)}
6937
+ .card .val{font-size:28px;font-weight:700;color:#2563eb}
6938
+ .card .lbl{font-size:12px;color:#888;margin-top:4px}
6939
+ .section{background:#fff;border-radius:10px;padding:20px;margin-bottom:16px;box-shadow:0 1px 3px rgba(0,0,0,.08)}
6940
+ .section h2{font-size:14px;font-weight:600;color:#888;text-transform:uppercase;letter-spacing:.05em;margin-bottom:16px}
6941
+ .chart{display:flex;align-items:flex-end;gap:4px;height:160px;padding-top:8px}
6942
+ .bar-wrap{flex:1;display:flex;flex-direction:column;align-items:center;height:100%;justify-content:flex-end}
6943
+ .bar{width:100%;background:#2563eb;border-radius:4px 4px 0 0;min-height:2px;transition:height .3s}
6944
+ .bar-label{font-size:10px;color:#888;margin-top:6px;white-space:nowrap}
6945
+ table{width:100%;border-collapse:collapse;font-size:13px}
6946
+ th{text-align:left;padding:6px 8px;color:#888;font-weight:500;border-bottom:1px solid #eee}
6947
+ td{padding:6px 8px;border-bottom:1px solid #f0f0f0}
6948
+ .num{text-align:right;font-variant-numeric:tabular-nums}
6949
+ .path{font-family:ui-monospace,SFMono-Regular,monospace;font-size:12px}
6950
+ tr:hover td{background:#f8faff}
6951
+ </style>
6952
+ </head>
6953
+ <body>
6954
+ <h1>Analytics</h1>
6955
+ <div class="cards">
6956
+ <div class="card"><div class="val">${total_pv}</div><div class="lbl">Page Views (${days}d)</div></div>
6957
+ <div class="card"><div class="val">${total_uv}</div><div class="lbl">Unique Pages</div></div>
6958
+ <div class="card"><div class="val">${devices.mobile}%</div><div class="lbl">Mobile</div></div>
6959
+ <div class="card"><div class="val">${devices.desktop}%</div><div class="lbl">Desktop</div></div>
6960
+ </div>
6961
+ <div class="section">
6962
+ <h2>Daily Page Views</h2>
6963
+ <div class="chart">${bars}</div>
6964
+ </div>
6965
+ <div class="section">
6966
+ <h2>Top Pages</h2>
6967
+ <table><thead><tr><th style="width:32px">#</th><th>Path</th><th style="width:64px">Views</th></tr></thead><tbody>${rows}</tbody></table>
6968
+ </div>
6969
+ ${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>` : ""}
6970
+ </body>
6971
+ </html>`;
6972
+ }
6973
+
6801
6974
  // preferences.ts
6802
6975
  import { readFile as readFile2 } from "node:fs/promises";
6803
6976
  import { existsSync as existsSync4 } from "node:fs";
@@ -8272,6 +8445,7 @@ export {
8272
8445
  TsxContext,
8273
8446
  agent,
8274
8447
  aiStream,
8448
+ analytics,
8275
8449
  auth,
8276
8450
  compress,
8277
8451
  cors,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "weifuwu",
3
- "version": "0.17.4",
3
+ "version": "0.17.5",
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",