@strifeapp/astro 1.3.0 → 1.4.1

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
@@ -10,6 +10,7 @@ Official [Strife](https://strife.app) integration for [Astro](https://astro.buil
10
10
  - [Requirements](#requirements)
11
11
  - [Usage](#usage)
12
12
  - [Reading content](#reading-content)
13
+ - [Telemetry: `<Insights />`](#telemetry-insights)
13
14
  - [TypeScript: typing `strife:store`](#typescript-typing-strifestore)
14
15
  - [Direct Vite plugin](#direct-vite-plugin)
15
16
  - [Configuration](#configuration)
@@ -76,6 +77,29 @@ const page = await session
76
77
  ---
77
78
  ```
78
79
 
80
+ ### Telemetry: `<Insights />`
81
+
82
+ Drop-in Web Vitals + accessibility telemetry. Add it once to your layout `<head>`, the same way you'd add Astro's `<ClientRouter />`:
83
+
84
+ ```astro
85
+ ---
86
+ import Insights from '@strifeapp/astro/Insights.astro';
87
+ ---
88
+ <head>
89
+
90
+ <Insights />
91
+ </head>
92
+ ```
93
+
94
+ On every page load it reports Core Web Vitals (LCP, INP, CLS, FCP, TTFB) and, when the browser is idle, an [axe-core](https://github.com/dequelabs/axe-core) accessibility audit (re-running on Astro View Transitions). Both are sent with `navigator.sendBeacon` to the hosted Strife insights API and stored as RavenDB time series on the page's content document. The beacon carries your **public team id** — resolved server-side from your Strife env, so no secret reaches the browser — letting the API attribute metrics to your workspace and validate the request against your registered domains.
95
+
96
+ | Prop | Type | Default | Description |
97
+ | --- | --- | --- | --- |
98
+ | `endpoint` | `string` | `https://api.strife.app/insights/collect` | Override the collection endpoint. |
99
+ | `accessibility` | `boolean` | `true` | Set `false` to skip the axe-core audit (Web Vitals still reported). |
100
+
101
+ > The API only stores a beacon when its `Origin` is one of the team's registered domains. For local development, run the API in `Development` and add `localhost` to your team's `Domains`.
102
+
79
103
  ### TypeScript: typing `strife:store`
80
104
 
81
105
  Typed ambient declarations for the `strife:store` virtual module are not bundled in this release. Until a typed surface is published, add your own declaration (e.g. in `src/env.d.ts`):
@@ -0,0 +1,247 @@
1
+ ---
2
+ /**
3
+ * Strife Insights — drop-in Web Vitals + accessibility telemetry.
4
+ *
5
+ * Add once to your layout <head>, the same way you'd add Astro's <ClientRouter />:
6
+ *
7
+ * ---
8
+ * import Insights from '@strifeapp/astro/Insights.astro';
9
+ * ---
10
+ * <head>…<Insights /></head>
11
+ *
12
+ * Real visitors report Core Web Vitals (LCP, INP, CLS, FCP, TTFB), batched into a
13
+ * single beacon when the page is backgrounded. The heavier axe-core accessibility
14
+ * audit runs ONLY inside the Strife live preview (edit mode), so axe-core is never
15
+ * downloaded or run by a real visitor. Both are beaconed to the central Strife
16
+ * insights endpoint and stored as time series, tagged by device class (mobile/desktop).
17
+ *
18
+ * The beacon carries a PUBLIC `site` id (this team's id, resolved server-side from
19
+ * the same Strife env the integration already reads) so the endpoint can attribute
20
+ * metrics to your workspace and validate the request Origin against your registered
21
+ * domains. No secret is ever exposed to the browser.
22
+ */
23
+ // @ts-ignore — `astro:env/server` is a virtual module Astro provides in the
24
+ // consumer's runtime/build (see the integration's env schema).
25
+ import { getSecret } from 'astro:env/server';
26
+ import { decodeSecrets } from '@strifeapp/strife/secrets';
27
+
28
+ export interface Props {
29
+ /** Override the collection endpoint. Defaults to the hosted Strife API. */
30
+ endpoint?: string;
31
+ /** Set false to skip the axe-core accessibility audit (Web Vitals still reported). */
32
+ accessibility?: boolean;
33
+ }
34
+
35
+ const {
36
+ endpoint = 'https://api.strife.app/insights/collect',
37
+ accessibility = true,
38
+ } = Astro.props;
39
+
40
+ /**
41
+ * Resolve this team's PUBLIC id from the same sources the integration reads: the
42
+ * consolidated STRIFE_SECRET blob (teamId), the legacy TEAM_ID env, or — last
43
+ * resort — the database name (`wieldy_{id}`). Returns null when no Strife env is
44
+ * configured, in which case the component renders nothing.
45
+ */
46
+ function resolveSiteId(): string | null {
47
+ const blob = getSecret('STRIFE_SECRET');
48
+ if (blob) {
49
+ try {
50
+ const packed = decodeSecrets(blob);
51
+ if (packed?.teamId) return packed.teamId;
52
+ } catch {
53
+ // Malformed blob — fall through to the individual vars.
54
+ }
55
+ }
56
+ const teamId = getSecret('TEAM_ID');
57
+ if (teamId) return teamId;
58
+ const database = getSecret('STRIFE_DATABASE');
59
+ const match = database?.match(/^wieldy_(.+)$/);
60
+ return match ? match[1] : null;
61
+ }
62
+
63
+ const site = resolveSiteId();
64
+
65
+ // `editMode` is true inside the Strife live preview (set by the edit-mode
66
+ // middleware). We split the work by audience: Web Vitals run only for REAL visitors
67
+ // (they need real-user device/network/interaction variance), while the heavy
68
+ // axe-core accessibility audit runs only in the editor's live preview — a11y is
69
+ // deterministic per content version, so the editor is the right place for it, and it
70
+ // keeps axe-core off every real visitor entirely. Without the middleware, editMode
71
+ // defaults to false, so plain sites get Web Vitals exactly as before.
72
+ const editMode = Astro.locals.editMode ?? false;
73
+ ---
74
+
75
+ {site && (
76
+ <Fragment>
77
+ <script is:inline define:vars={{ site, endpoint, accessibility, editMode }}>
78
+ window.__strifeInsights = { site, endpoint, accessibility, editMode };
79
+ </script>
80
+ <script>
81
+ import { onLCP, onINP, onCLS, onFCP, onTTFB } from 'web-vitals';
82
+
83
+ const cfg = (window as any).__strifeInsights || {};
84
+ const ENDPOINT: string = cfg.endpoint;
85
+ const SITE: string = cfg.site;
86
+ const RUN_A11Y: boolean = cfg.accessibility !== false;
87
+ const EDIT_MODE: boolean = cfg.editMode === true;
88
+
89
+ /** Beacon a payload to the collection endpoint (fire-and-forget). */
90
+ function send(payload: Record<string, unknown>) {
91
+ const body = JSON.stringify({ site: SITE, ...payload });
92
+ try {
93
+ // Send as a plain string → text/plain, a CORS-safelisted content type.
94
+ // That keeps the cross-origin beacon a "simple request" (no preflight),
95
+ // so it is delivered from ANY registered custom domain — not just the
96
+ // origins in the API's global CORS list. The server parses the body
97
+ // itself; we never read the response, so CORS never blocks the write.
98
+ if (navigator.sendBeacon && navigator.sendBeacon(ENDPOINT, body)) {
99
+ return;
100
+ }
101
+ } catch {
102
+ // Fall through to fetch.
103
+ }
104
+ // No JSON content-type header here either, so the fallback stays
105
+ // preflight-free (a string body defaults to text/plain).
106
+ fetch(ENDPOINT, {
107
+ method: 'POST',
108
+ body,
109
+ keepalive: true,
110
+ }).catch(() => {});
111
+ }
112
+
113
+ function sendAccessibilityMetrics(
114
+ metrics: Record<string, number>,
115
+ tag: string,
116
+ violations: unknown[],
117
+ ) {
118
+ send({ page: window.location.pathname, metrics, tag, violations });
119
+ }
120
+
121
+ // Collect the finalised Core Web Vitals and send them as ONE batched beacon when
122
+ // the page is backgrounded/unloaded — the standard web-vitals RUM pattern.
123
+ // (reportAllChanges defaults to false, so each metric reports once.) Far fewer
124
+ // beacons + time-series writes than reporting on every change.
125
+ function initWebVitals() {
126
+ const speed: string | undefined = (navigator as any).connection?.effectiveType;
127
+ // Tag each metric by device class so Experience scores can be sliced by device
128
+ // in the Studio — same convention the accessibility audit uses.
129
+ const tag = navigator.userAgent.includes('Mobile') ? 'mobile' : 'desktop';
130
+ const metrics: Record<string, number> = {};
131
+ const add = (m: { name: string; value: number }) => {
132
+ metrics[m.name] = m.value;
133
+ };
134
+ onLCP(add);
135
+ onINP(add);
136
+ onCLS(add);
137
+ onFCP(add);
138
+ onTTFB(add);
139
+
140
+ const flush = () => {
141
+ const names = Object.keys(metrics);
142
+ if (names.length === 0) return;
143
+ // Read the pathname at flush time, not at init time: on Astro View Transition
144
+ // sites the URL changes via soft nav without re-running initWebVitals, so this
145
+ // attributes the batch to the page actually being left rather than the first one.
146
+ const page = window.location.pathname;
147
+ const batch: Record<string, number> = {};
148
+ for (const name of names) {
149
+ batch[name] = metrics[name];
150
+ delete metrics[name]; // send each metric at most once across flushes
151
+ }
152
+ send({ page, metrics: batch, speed, tag });
153
+ };
154
+ addEventListener('visibilitychange', () => {
155
+ if (document.visibilityState === 'hidden') flush();
156
+ });
157
+ addEventListener('pagehide', flush);
158
+ }
159
+
160
+ async function runAccessibilityAudit() {
161
+ try {
162
+ // axe-core is CJS; the ESM interop varies by bundler/consumer — the API lives
163
+ // on the default export in some setups, on the namespace itself in others, so
164
+ // handle both rather than crash on `undefined.run`.
165
+ const axeModule = await import('axe-core');
166
+ const axe: any = (axeModule as any).default ?? axeModule;
167
+ const results = await axe.run(document, { resultTypes: ['violations'] });
168
+
169
+ const byImpact = { critical: 0, serious: 0, moderate: 0, minor: 0 };
170
+ for (const violation of results.violations) {
171
+ const impact = violation.impact as keyof typeof byImpact;
172
+ if (impact && impact in byImpact) byImpact[impact] += violation.nodes.length;
173
+ }
174
+ const total = byImpact.critical + byImpact.serious + byImpact.moderate + byImpact.minor;
175
+ const penalty =
176
+ byImpact.critical * 10 + byImpact.serious * 5 + byImpact.moderate * 2 + byImpact.minor * 1;
177
+ const score = Math.max(0, 100 - penalty);
178
+
179
+ // Compact, capped violation details for the "what's actually wrong" list.
180
+ // axe-core categories live in the `cat.*` tag (e.g. `cat.color` → `color`).
181
+ const details = results.violations
182
+ .map((v) => {
183
+ const cat = (v.tags || []).find((t: string) => t.indexOf('cat.') === 0);
184
+ const firstTarget = (v.nodes[0] as any)?.target;
185
+ return {
186
+ id: v.id,
187
+ impact: v.impact || 'minor',
188
+ category: cat ? cat.slice(4) : 'other',
189
+ help: v.help,
190
+ helpUrl: v.helpUrl,
191
+ count: v.nodes.length,
192
+ sample: Array.isArray(firstTarget) ? firstTarget.join(' ') : '',
193
+ };
194
+ })
195
+ .slice(0, 50);
196
+
197
+ sendAccessibilityMetrics(
198
+ {
199
+ A11Y_SCORE: score,
200
+ A11Y_VIOLATIONS: total,
201
+ A11Y_CRITICAL: byImpact.critical,
202
+ A11Y_SERIOUS: byImpact.serious,
203
+ A11Y_MODERATE: byImpact.moderate,
204
+ A11Y_MINOR: byImpact.minor,
205
+ },
206
+ navigator.userAgent.includes('Mobile') ? 'mobile' : 'desktop',
207
+ details,
208
+ );
209
+ } catch (error) {
210
+ // Best-effort — accessibility metrics never break the page.
211
+ console.warn('[Insights] Accessibility audit failed:', error);
212
+ }
213
+ }
214
+
215
+ function scheduleAccessibilityAudit() {
216
+ if ('requestIdleCallback' in window) {
217
+ (window as any).requestIdleCallback(() => runAccessibilityAudit(), { timeout: 10000 });
218
+ } else {
219
+ setTimeout(runAccessibilityAudit, 3000);
220
+ }
221
+ }
222
+
223
+ // Track audited pages to avoid duplicate audits on soft navigations.
224
+ const auditedPages = new Set<string>();
225
+
226
+ function auditOnce() {
227
+ if (!RUN_A11Y) return;
228
+ const path = window.location.pathname;
229
+ if (auditedPages.has(path)) return;
230
+ auditedPages.add(path);
231
+ scheduleAccessibilityAudit();
232
+ }
233
+
234
+ if (EDIT_MODE) {
235
+ // In the Strife live preview: only the accessibility audit runs. The editor is
236
+ // the right place for the heavy axe-core scan (a11y is deterministic per content
237
+ // version), and it keeps axe-core off every real visitor.
238
+ auditOnce();
239
+ // Astro View Transitions: re-audit the newly loaded page.
240
+ document.addEventListener('astro:page-load', auditOnce);
241
+ } else {
242
+ // A real visitor: Web Vitals only, batched on pagehide. No axe-core download/run.
243
+ initWebVitals();
244
+ }
245
+ </script>
246
+ </Fragment>
247
+ )}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@strifeapp/astro",
3
- "version": "1.3.0",
3
+ "version": "1.4.1",
4
4
  "description": "Official Strife integration for Astro — connect your Astro site to a RavenDB-backed Strife content store via a strife:store virtual module.",
5
5
  "keywords": [
6
6
  "astro-integration",
@@ -40,6 +40,7 @@
40
40
  "import": "./dist/edit-mode-middleware.js"
41
41
  },
42
42
  "./LivePreview.astro": "./dist/LivePreview.astro",
43
+ "./Insights.astro": "./dist/Insights.astro",
43
44
  "./vite-plugin-strife-store": "./dist/vite-plugin-strife-store-entry.js"
44
45
  },
45
46
  "files": [
@@ -60,10 +61,12 @@
60
61
  },
61
62
  "dependencies": {
62
63
  "@strifeapp/strife": "^1.1.0",
64
+ "axe-core": "^4.11.0",
63
65
  "dotenv": "^17.2.3",
64
66
  "jose": "^5.9.6",
65
67
  "ravendb": "^7.1.4",
66
- "serialize-javascript": "^7.0.5"
68
+ "serialize-javascript": "^7.0.5",
69
+ "web-vitals": "^5.1.0"
67
70
  },
68
71
  "devDependencies": {
69
72
  "@types/serialize-javascript": "^5.0.4",