@pulsekit/next 0.0.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.
@@ -0,0 +1,7 @@
1
+ interface PulseTrackerProps {
2
+ endpoint?: string;
3
+ excludePaths?: string[];
4
+ }
5
+ declare function PulseTracker({ endpoint, excludePaths }: PulseTrackerProps): null;
6
+
7
+ export { PulseTracker, type PulseTrackerProps };
@@ -0,0 +1,99 @@
1
+ "use client";
2
+ // src/PulseTracker.tsx
3
+ import { useEffect, useRef } from "react";
4
+ function getSessionId() {
5
+ const key = "pulse_session_id";
6
+ let id = sessionStorage.getItem(key);
7
+ if (!id) {
8
+ id = crypto.randomUUID();
9
+ sessionStorage.setItem(key, id);
10
+ }
11
+ return id;
12
+ }
13
+ function PulseTracker({ endpoint = "/api/pulse", excludePaths }) {
14
+ const vitalsRef = useRef({});
15
+ const hasSentVitalsRef = useRef(false);
16
+ const sessionIdRef = useRef(null);
17
+ useEffect(() => {
18
+ sessionIdRef.current = getSessionId();
19
+ }, []);
20
+ useEffect(() => {
21
+ const tz = Intl.DateTimeFormat().resolvedOptions().timeZone;
22
+ document.cookie = `pulse_tz=${encodeURIComponent(tz)}; path=/; max-age=${60 * 60 * 24 * 365}; SameSite=Lax`;
23
+ if (excludePaths?.includes(window.location.pathname)) return;
24
+ const sessionId = getSessionId();
25
+ sessionIdRef.current = sessionId;
26
+ fetch(endpoint, {
27
+ method: "POST",
28
+ headers: { "Content-Type": "application/json" },
29
+ body: JSON.stringify({
30
+ type: "pageview",
31
+ path: window.location.pathname,
32
+ sessionId
33
+ })
34
+ }).catch(() => {
35
+ });
36
+ }, [endpoint, excludePaths]);
37
+ useEffect(() => {
38
+ if (excludePaths?.includes(window.location.pathname)) return;
39
+ import("web-vitals").then(({ onLCP, onINP, onCLS, onFCP, onTTFB }) => {
40
+ onLCP((m) => {
41
+ vitalsRef.current.lcp = m.value;
42
+ });
43
+ onINP((m) => {
44
+ vitalsRef.current.inp = m.value;
45
+ }, { reportAllChanges: true });
46
+ onCLS((m) => {
47
+ vitalsRef.current.cls = m.value;
48
+ }, { reportAllChanges: true });
49
+ onFCP((m) => {
50
+ vitalsRef.current.fcp = m.value;
51
+ });
52
+ onTTFB((m) => {
53
+ vitalsRef.current.ttfb = m.value;
54
+ });
55
+ }).catch(() => {
56
+ });
57
+ function sendVitals() {
58
+ if (hasSentVitalsRef.current) return;
59
+ const metrics = vitalsRef.current;
60
+ if (Object.keys(metrics).length === 0) return;
61
+ hasSentVitalsRef.current = true;
62
+ const body = JSON.stringify({
63
+ type: "vitals",
64
+ path: window.location.pathname,
65
+ sessionId: sessionIdRef.current,
66
+ meta: { ...metrics }
67
+ });
68
+ if (typeof navigator.sendBeacon === "function") {
69
+ navigator.sendBeacon(
70
+ endpoint,
71
+ new Blob([body], { type: "application/json" })
72
+ );
73
+ } else {
74
+ fetch(endpoint, {
75
+ method: "POST",
76
+ headers: { "Content-Type": "application/json" },
77
+ body,
78
+ keepalive: true
79
+ }).catch(() => {
80
+ });
81
+ }
82
+ }
83
+ function onVisibilityChange() {
84
+ if (document.visibilityState === "hidden") {
85
+ sendVitals();
86
+ }
87
+ }
88
+ document.addEventListener("visibilitychange", onVisibilityChange);
89
+ window.addEventListener("pagehide", sendVitals);
90
+ return () => {
91
+ document.removeEventListener("visibilitychange", onVisibilityChange);
92
+ window.removeEventListener("pagehide", sendVitals);
93
+ };
94
+ }, [endpoint, excludePaths]);
95
+ return null;
96
+ }
97
+ export {
98
+ PulseTracker
99
+ };
@@ -0,0 +1,35 @@
1
+ import { NextRequest, NextResponse } from 'next/server';
2
+ import { SupabaseClient } from '@supabase/supabase-js';
3
+ import { PulseEventPayload } from '@pulsekit/core';
4
+
5
+ interface PulseHandlerConfig {
6
+ supabase: SupabaseClient;
7
+ config?: {
8
+ allowLocalhost?: boolean;
9
+ ignorePaths?: string[];
10
+ siteId?: string;
11
+ };
12
+ }
13
+ interface PulseRequestBody extends PulseEventPayload {
14
+ siteId?: string;
15
+ }
16
+
17
+ declare function createPulseHandler({ supabase, config }: PulseHandlerConfig): (req: NextRequest) => Promise<NextResponse<unknown>>;
18
+
19
+ interface RefreshHandlerConfig {
20
+ supabase: SupabaseClient;
21
+ daysBack?: number;
22
+ }
23
+ declare function createRefreshHandler({ supabase, daysBack, }: RefreshHandlerConfig): () => Promise<NextResponse<{
24
+ error: string;
25
+ }> | NextResponse<{
26
+ ok: boolean;
27
+ }>>;
28
+
29
+ /**
30
+ * Server-side helper that reads the browser timezone from the cookie
31
+ * set by <PulseTimezone />. Falls back to "UTC" if not set.
32
+ */
33
+ declare function getPulseTimezone(): Promise<string>;
34
+
35
+ export { type PulseHandlerConfig, type PulseRequestBody, type RefreshHandlerConfig, createPulseHandler, createRefreshHandler, getPulseTimezone };
package/dist/index.mjs ADDED
@@ -0,0 +1,92 @@
1
+ // src/createPulseHandler.ts
2
+ import { NextResponse } from "next/server";
3
+ import { z } from "zod";
4
+ var EventSchema = z.object({
5
+ type: z.enum(["pageview", "custom", "vitals"]),
6
+ path: z.string().min(1),
7
+ sessionId: z.string().optional(),
8
+ meta: z.record(z.unknown()).optional(),
9
+ siteId: z.string().optional()
10
+ });
11
+ function createPulseHandler({ supabase, config }) {
12
+ return async function handler(req) {
13
+ if (req.method !== "POST") {
14
+ return new NextResponse("Method Not Allowed", { status: 405 });
15
+ }
16
+ const origin = req.headers.get("origin") ?? "";
17
+ const referer = req.headers.get("referer") ?? "";
18
+ const isLocalhost = origin.includes("localhost") || referer.includes("localhost");
19
+ if (!isLocalhost || config?.allowLocalhost === false) {
20
+ }
21
+ let json;
22
+ try {
23
+ json = await req.json();
24
+ } catch {
25
+ return NextResponse.json({ error: "Invalid JSON" }, { status: 400 });
26
+ }
27
+ const parse = EventSchema.safeParse(json);
28
+ if (!parse.success) {
29
+ return NextResponse.json({ error: "Invalid payload" }, { status: 400 });
30
+ }
31
+ const event = parse.data;
32
+ const siteId = event.siteId ?? config?.siteId ?? "default";
33
+ const country = req.headers.get("x-vercel-ip-country") ?? null;
34
+ const region = req.headers.get("x-vercel-ip-country-region") ?? null;
35
+ const city = req.headers.get("x-vercel-ip-city") ?? null;
36
+ const timezone = req.headers.get("x-vercel-ip-timezone") ?? null;
37
+ const latRaw = req.headers.get("x-vercel-ip-latitude");
38
+ const lngRaw = req.headers.get("x-vercel-ip-longitude");
39
+ const latitude = latRaw ? parseFloat(latRaw) : null;
40
+ const longitude = lngRaw ? parseFloat(lngRaw) : null;
41
+ try {
42
+ const { error } = await supabase.schema("analytics").from("pulse_events").insert({
43
+ site_id: siteId,
44
+ session_id: event.sessionId ?? null,
45
+ path: event.path,
46
+ event_type: event.type,
47
+ meta: event.meta ?? null,
48
+ country,
49
+ region,
50
+ city,
51
+ timezone,
52
+ latitude,
53
+ longitude
54
+ });
55
+ if (error) throw error;
56
+ } catch (e) {
57
+ console.error(e);
58
+ return NextResponse.json(
59
+ { error: "Failed to log event" },
60
+ { status: 500 }
61
+ );
62
+ }
63
+ return NextResponse.json({ ok: true });
64
+ };
65
+ }
66
+
67
+ // src/createRefreshHandler.ts
68
+ import { NextResponse as NextResponse2 } from "next/server";
69
+ function createRefreshHandler({
70
+ supabase,
71
+ daysBack = 7
72
+ }) {
73
+ return async function handler() {
74
+ const { error } = await supabase.schema("analytics").rpc("pulse_refresh_aggregates", { days_back: daysBack });
75
+ if (error) {
76
+ return NextResponse2.json({ error: error.message }, { status: 500 });
77
+ }
78
+ return NextResponse2.json({ ok: true });
79
+ };
80
+ }
81
+
82
+ // src/getPulseTimezone.ts
83
+ import { cookies } from "next/headers";
84
+ async function getPulseTimezone() {
85
+ const cookieStore = await cookies();
86
+ return cookieStore.get("pulse_tz")?.value ?? "UTC";
87
+ }
88
+ export {
89
+ createPulseHandler,
90
+ createRefreshHandler,
91
+ getPulseTimezone
92
+ };
package/package.json ADDED
@@ -0,0 +1,41 @@
1
+ {
2
+ "name": "@pulsekit/next",
3
+ "version": "0.0.1",
4
+ "main": "./dist/index.mjs",
5
+ "types": "./dist/index.d.mts",
6
+ "files": [
7
+ "dist"
8
+ ],
9
+ "exports": {
10
+ ".": {
11
+ "import": "./dist/index.mjs",
12
+ "types": "./dist/index.d.mts"
13
+ },
14
+ "./client": {
15
+ "import": "./dist/client.mjs",
16
+ "types": "./dist/client.d.mts"
17
+ }
18
+ },
19
+ "dependencies": {
20
+ "web-vitals": "^4.2.0",
21
+ "zod": "^3.24.0",
22
+ "@pulsekit/core": "0.0.1"
23
+ },
24
+ "devDependencies": {
25
+ "@types/react": "^19.0.0",
26
+ "next": "^15.0.0",
27
+ "react": "^19.0.0",
28
+ "tsup": "^8.0.0",
29
+ "typescript": "^5.7.0"
30
+ },
31
+ "peerDependencies": {
32
+ "@supabase/supabase-js": ">=2.0.0",
33
+ "next": ">=14.0.0",
34
+ "react": ">=18.0.0"
35
+ },
36
+ "scripts": {
37
+ "build": "tsup",
38
+ "dev": "tsup --watch",
39
+ "clean": "rm -rf dist"
40
+ }
41
+ }