@forgecart/cli 2.202608160103.0 → 2.202608212152.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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@forgecart/cli",
3
- "version": "2.202608160103.0",
3
+ "version": "2.202608212152.0",
4
4
  "type": "module",
5
5
  "description": "CLI for scaffolding and operating ForgeCart channel storefronts",
6
6
  "bin": {
@@ -145,6 +145,12 @@ export async function POST(request: NextRequest): Promise<NextResponse> {
145
145
 
146
146
  const incomingSession = request.cookies.get(SESSION_COOKIE)?.value ?? null;
147
147
  const forwardedFor = request.headers.get('x-forwarded-for');
148
+ // #1014: mirror the browser's low-entropy Client Hints trio upstream —
149
+ // Chromium sends them on every request; the shop API's device
150
+ // identification prefers them over the frozen UA.
151
+ const secChUa = request.headers.get('sec-ch-ua') ?? undefined;
152
+ const secChUaMobile = request.headers.get('sec-ch-ua-mobile') ?? undefined;
153
+ const secChUaPlatform = request.headers.get('sec-ch-ua-platform') ?? undefined;
148
154
 
149
155
  let sessionToken = incomingSession;
150
156
  const results: TrackItemResult[] = [];
@@ -157,7 +163,7 @@ export async function POST(request: NextRequest): Promise<NextResponse> {
157
163
  results.push({ accepted: false, eventId: null });
158
164
  continue;
159
165
  }
160
- const outcome = await forwardTrackEvent(input, { sessionToken, userAgent, forwardedFor });
166
+ const outcome = await forwardTrackEvent(input, { sessionToken, userAgent, forwardedFor, secChUa, secChUaMobile, secChUaPlatform });
161
167
  results.push({ accepted: outcome.accepted, eventId: outcome.eventId });
162
168
  if (outcome.sessionToken) sessionToken = outcome.sessionToken;
163
169
  }
@@ -73,6 +73,12 @@ const FLUSH_INTERVAL_MS = 500;
73
73
  const ESTABLISHMENT_GRACE_MS = 2_000;
74
74
  /** Same-signal suppression window (per click slug; per page_view path). */
75
75
  const SUPPRESS_MS = 300;
76
+ /**
77
+ * Minimum VISIBLE dwell before a route's scroll_depth summary is worth a
78
+ * row (#1022 phase 2b) — filters bounce-through navigations and StrictMode's
79
+ * dev-only phantom unmount.
80
+ */
81
+ const ENGAGEMENT_MIN_DWELL_MS = 250;
76
82
  const TRACK_ENDPOINT = '/__fc/track';
77
83
 
78
84
  /** One buffered event — the relay forwards these fields to `trackEvent`. */
@@ -277,5 +283,90 @@ export function ForgeTracker({ enabled }: { enabled: boolean }) {
277
283
  };
278
284
  }, [enabled]);
279
285
 
286
+ // scroll_depth — ONE engagement summary per route (#1022 phase 2b): the
287
+ // max scroll depth reached (percent, 0-100) and the VISIBLE dwell time,
288
+ // emitted when the route unmounts (client-side navigation) or the page
289
+ // hides. Rides the dormant `scroll_depth` event type already in the
290
+ // default shop allowlist; the server hoists both properties into the
291
+ // typed `scrollDepth`/`durationMs` ClickHouse columns (CH 005).
292
+ useEffect(() => {
293
+ if (!enabled) return;
294
+ if (window.parent !== window) return;
295
+
296
+ let maxDepthPct = 0;
297
+ let visibleMs = 0;
298
+ let visibleSince: number | null =
299
+ document.visibilityState === 'visible' ? performance.now() : null;
300
+ let ticking = false;
301
+ let emitted = false;
302
+
303
+ const measure = (): void => {
304
+ ticking = false;
305
+ const doc = document.documentElement;
306
+ // A page shorter than the viewport is fully seen — 100 by definition;
307
+ // otherwise percent of total document height brought into view.
308
+ const pct =
309
+ doc.scrollHeight <= window.innerHeight
310
+ ? 100
311
+ : Math.min(
312
+ 100,
313
+ Math.round(((window.scrollY + window.innerHeight) / doc.scrollHeight) * 100),
314
+ );
315
+ if (pct > maxDepthPct) maxDepthPct = pct;
316
+ };
317
+ const onScroll = (): void => {
318
+ if (ticking) return;
319
+ ticking = true;
320
+ requestAnimationFrame(measure);
321
+ };
322
+ const onVisibility = (): void => {
323
+ const now = performance.now();
324
+ if (document.visibilityState === 'visible') {
325
+ visibleSince ??= now;
326
+ return;
327
+ }
328
+ if (visibleSince !== null) {
329
+ visibleMs += now - visibleSince;
330
+ visibleSince = null;
331
+ }
332
+ };
333
+ const emit = (): void => {
334
+ if (emitted) return;
335
+ if (visibleSince !== null) {
336
+ visibleMs += performance.now() - visibleSince;
337
+ visibleSince = null;
338
+ }
339
+ // Dwell floor: a route that never accumulated meaningful visible time
340
+ // has no engagement story — this also swallows StrictMode's dev-only
341
+ // mount/unmount phantom, which would otherwise emit a zero-dwell row.
342
+ if (visibleMs < ENGAGEMENT_MIN_DWELL_MS) return;
343
+ emitted = true;
344
+ enqueue('scroll_depth', {
345
+ path: window.location.pathname,
346
+ scrollDepth: maxDepthPct,
347
+ durationMs: Math.round(visibleMs),
348
+ });
349
+ };
350
+ const onPagehide = (): void => {
351
+ // Emit BEFORE the flush so the summary rides the pagehide beacon —
352
+ // flushOnPagehide drains-and-no-ops on empty, so the sibling pagehide
353
+ // listener's own call stays harmless.
354
+ emit();
355
+ flushOnPagehide();
356
+ };
357
+
358
+ measure(); // above-the-fold baseline before any scroll fires
359
+
360
+ window.addEventListener('scroll', onScroll, { passive: true });
361
+ document.addEventListener('visibilitychange', onVisibility);
362
+ window.addEventListener('pagehide', onPagehide);
363
+ return () => {
364
+ window.removeEventListener('scroll', onScroll);
365
+ document.removeEventListener('visibilitychange', onVisibility);
366
+ window.removeEventListener('pagehide', onPagehide);
367
+ emit(); // client-side route change: summarize the route being left
368
+ };
369
+ }, [enabled, pathname]);
370
+
280
371
  return null;
281
372
  }
@@ -74,6 +74,16 @@ export interface ForwardHeaders {
74
74
  sessionToken: string | null;
75
75
  /** The browser's own User-Agent, forwarded for device enrichment. */
76
76
  userAgent: string;
77
+ /**
78
+ * The browser's low-entropy Client Hints (`sec-ch-ua`, `sec-ch-ua-mobile`,
79
+ * `sec-ch-ua-platform`), mirrored verbatim so device identification can
80
+ * outrank the frozen UA per-field (#1014). Chromium sends the trio on
81
+ * every request — no handshake, no `Accept-CH` — so this is a pure
82
+ * pass-through; absent on non-Chromium browsers.
83
+ */
84
+ secChUa?: string;
85
+ secChUaMobile?: string;
86
+ secChUaPlatform?: string;
77
87
  /**
78
88
  * Client IP as `x-forwarded-for` (the proxy-standard contract the shop API
79
89
  * trusts) — NEVER ForgeCart's edge-secret-gated client-IP override header
@@ -119,7 +129,7 @@ export function isObviousBot(userAgent: string): boolean {
119
129
  */
120
130
  export async function forwardTrackEvent(
121
131
  input: TrackEventInput,
122
- { sessionToken, userAgent, forwardedFor }: ForwardHeaders,
132
+ { sessionToken, userAgent, forwardedFor, secChUa, secChUaMobile, secChUaPlatform }: ForwardHeaders,
123
133
  ): Promise<UpstreamOutcome> {
124
134
  const upstream = getUpstreamConfig();
125
135
  if (!upstream) return { accepted: false, eventId: null, sessionToken: null };
@@ -131,6 +141,9 @@ export async function forwardTrackEvent(
131
141
  if (sessionToken) headers['Authorization'] = `Bearer ${sessionToken}`;
132
142
  if (userAgent) headers['user-agent'] = userAgent;
133
143
  if (forwardedFor) headers['x-forwarded-for'] = forwardedFor;
144
+ if (secChUa) headers['sec-ch-ua'] = secChUa;
145
+ if (secChUaMobile) headers['sec-ch-ua-mobile'] = secChUaMobile;
146
+ if (secChUaPlatform) headers['sec-ch-ua-platform'] = secChUaPlatform;
134
147
 
135
148
  try {
136
149
  const response = await fetch(upstream.shopApiUrl, {