@heyramzi/marketing 3.0.0 → 3.1.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/README.md CHANGED
@@ -26,8 +26,13 @@ The analytics module provides wrappers for Umami tracking across different envir
26
26
  ```typescript
27
27
  import { initServer, trackServer } from '@heyramzi/marketing/analytics/server';
28
28
 
29
- initServer({ websiteId: process.env.UMAMI_WEBSITE_ID });
30
- await trackServer('signup', { plan: 'pro' });
29
+ initServer({
30
+ hostUrl: 'https://umami.example.com',
31
+ websiteId: process.env.UMAMI_WEBSITE_ID,
32
+ hostname: 'example.com',
33
+ });
34
+ // Resolves true once Umami recorded it. On a Worker, await it or pass it to waitUntil.
35
+ await trackServer('signup', { plan: 'pro' }, { url: '/signup', id: user.id });
31
36
  ```
32
37
 
33
38
  #### Script tag and browser tracking
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@heyramzi/marketing",
3
- "version": "3.0.0",
3
+ "version": "3.1.0",
4
4
  "description": "Umami analytics for client, server, and Svelte, plus a satori-based HTML-to-image pipeline.",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -1,282 +1,165 @@
1
- import { describe, it, expect, vi, beforeEach } from "vite-plus/test";
1
+ import { describe, it, expect, vi, beforeEach, afterEach } from "vite-plus/test";
2
2
 
3
- // The server module holds module-level singletons (client, _config).
4
- // Each test resets them by calling vi.resetModules() + re-importing.
3
+ // The server module holds its config at module level, so each test re-imports it.
5
4
 
6
5
  async function freshImport() {
7
6
  vi.resetModules();
8
7
  return import("#src/analytics/server.js");
9
8
  }
10
9
 
11
- beforeEach(() => {
12
- vi.resetModules();
13
- });
14
-
15
- // ---- initServer ----
16
-
17
- describe("initServer", () => {
18
- it("stores config so that subsequent trackServer calls succeed", async () => {
19
- const mockTrack = vi.fn().mockResolvedValue(undefined);
20
-
21
- vi.doMock("@umami/node", () => ({
22
- Umami: class {
23
- track = mockTrack;
24
- },
25
- }));
26
-
27
- const { initServer, trackServer } = await freshImport();
28
- initServer({ hostUrl: "https://umami.example.com", websiteId: "abc-123" });
29
- trackServer("test_event");
30
- await new Promise((r) => setTimeout(r, 0));
10
+ const fetchMock = vi.fn();
31
11
 
32
- expect(mockTrack).toHaveBeenCalledWith({ name: "test_event" });
33
- });
12
+ function answer(body: string, status = 200) {
13
+ fetchMock.mockResolvedValue(new Response(body, { status }));
14
+ }
34
15
 
35
- it("throws when trackServer is called without prior initServer", async () => {
36
- const consoleSpy = vi.spyOn(console, "error").mockImplementation(() => {});
37
- vi.doMock("@umami/node", () => ({ Umami: class {} }));
16
+ function sent() {
17
+ const [url, init] = fetchMock.mock.calls[0] as [string, RequestInit];
18
+ return {
19
+ url,
20
+ headers: init.headers as Record<string, string>,
21
+ body: JSON.parse(init.body as string) as { type: string; payload: Record<string, unknown> },
22
+ };
23
+ }
38
24
 
39
- const { trackServer } = await freshImport();
40
- trackServer("orphan_event");
41
- await new Promise((r) => setTimeout(r, 0));
25
+ beforeEach(() => {
26
+ fetchMock.mockReset();
27
+ answer('{"cache":"x"}');
28
+ vi.stubGlobal("fetch", fetchMock);
29
+ vi.spyOn(console, "error").mockImplementation(() => {});
30
+ });
42
31
 
43
- expect(consoleSpy).toHaveBeenCalledWith(
44
- "[analytics] Server track failed:",
45
- expect.objectContaining({ message: "Call initServer() before tracking events" }),
46
- );
47
- });
32
+ afterEach(() => {
33
+ vi.unstubAllGlobals();
34
+ vi.restoreAllMocks();
48
35
  });
49
36
 
50
- // ---- trackServer ----
37
+ const config = {
38
+ hostUrl: "https://umami.example.com",
39
+ websiteId: "abc-123",
40
+ hostname: "example.com",
41
+ };
51
42
 
52
43
  describe("trackServer", () => {
53
- it("builds a payload with only name when no data or url is given", async () => {
54
- const mockTrack = vi.fn().mockResolvedValue(undefined);
55
- vi.doMock("@umami/node", () => ({
56
- Umami: class {
57
- track = mockTrack;
58
- },
59
- }));
60
-
44
+ it("awaits the POST to /api/send and resolves true", async () => {
61
45
  const { initServer, trackServer } = await freshImport();
62
- initServer({ hostUrl: "https://umami.example.com", websiteId: "abc-123" });
63
- trackServer("signup");
64
- await new Promise((r) => setTimeout(r, 0));
65
-
66
- expect(mockTrack).toHaveBeenCalledWith({ name: "signup" });
67
- });
68
-
69
- it("includes data in the payload when provided", async () => {
70
- const mockTrack = vi.fn().mockResolvedValue(undefined);
71
- vi.doMock("@umami/node", () => ({
72
- Umami: class {
73
- track = mockTrack;
74
- },
75
- }));
46
+ initServer(config);
76
47
 
77
- const { initServer, trackServer } = await freshImport();
78
- initServer({ hostUrl: "https://umami.example.com", websiteId: "abc-123" });
79
- trackServer("feature_used", { feature: "export", count: 5 });
80
- await new Promise((r) => setTimeout(r, 0));
48
+ await expect(trackServer("signup")).resolves.toBe(true);
81
49
 
82
- expect(mockTrack).toHaveBeenCalledWith({
83
- name: "feature_used",
84
- data: { feature: "export", count: 5 },
50
+ const { url, headers, body } = sent();
51
+ expect(url).toBe("https://umami.example.com/api/send");
52
+ expect(headers["User-Agent"]).toMatch(/^Mozilla\/5\.0/);
53
+ expect(body).toEqual({
54
+ type: "event",
55
+ payload: { website: "abc-123", hostname: "example.com", url: "/", name: "signup" },
85
56
  });
86
57
  });
87
58
 
88
- it("includes url in the payload when provided", async () => {
89
- const mockTrack = vi.fn().mockResolvedValue(undefined);
90
- vi.doMock("@umami/node", () => ({
91
- Umami: class {
92
- track = mockTrack;
93
- },
94
- }));
95
-
59
+ it("sends data, url and id", async () => {
96
60
  const { initServer, trackServer } = await freshImport();
97
- initServer({ hostUrl: "https://umami.example.com", websiteId: "abc-123" });
98
- trackServer("page_view", undefined, "/pricing");
99
- await new Promise((r) => setTimeout(r, 0));
100
-
101
- expect(mockTrack).toHaveBeenCalledWith({ name: "page_view", url: "/pricing" });
102
- });
61
+ initServer(config);
103
62
 
104
- it("includes both data and url when both are provided", async () => {
105
- const mockTrack = vi.fn().mockResolvedValue(undefined);
106
- vi.doMock("@umami/node", () => ({
107
- Umami: class {
108
- track = mockTrack;
109
- },
110
- }));
63
+ await trackServer("click", { label: "CTA" }, { url: "/home", id: "cs_123" });
111
64
 
112
- const { initServer, trackServer } = await freshImport();
113
- initServer({ hostUrl: "https://umami.example.com", websiteId: "abc-123" });
114
- trackServer("click", { label: "CTA" }, "/home");
115
- await new Promise((r) => setTimeout(r, 0));
116
-
117
- expect(mockTrack).toHaveBeenCalledWith({
65
+ expect(sent().body.payload).toEqual({
66
+ website: "abc-123",
67
+ hostname: "example.com",
68
+ url: "/home",
118
69
  name: "click",
119
70
  data: { label: "CTA" },
120
- url: "/home",
71
+ id: "cs_123",
121
72
  });
122
73
  });
123
74
 
124
- it("logs an error and does not throw when track rejects", async () => {
125
- const consoleSpy = vi.spyOn(console, "error").mockImplementation(() => {});
126
- const mockTrack = vi.fn().mockRejectedValue(new Error("network failure"));
127
- vi.doMock("@umami/node", () => ({
128
- Umami: class {
129
- track = mockTrack;
130
- },
131
- }));
132
-
75
+ it("still takes the url as a plain string", async () => {
133
76
  const { initServer, trackServer } = await freshImport();
134
- initServer({ hostUrl: "https://umami.example.com", websiteId: "abc-123" });
135
- trackServer("risky_event");
136
- await new Promise((r) => setTimeout(r, 0));
77
+ initServer(config);
137
78
 
138
- expect(consoleSpy).toHaveBeenCalledWith("[analytics] Server track failed:", expect.any(Error));
139
- });
79
+ await trackServer("page_view", undefined, "/pricing");
140
80
 
141
- it("reuses the same Umami instance across multiple calls", async () => {
142
- const mockTrack = vi.fn().mockResolvedValue(undefined);
143
- let constructorCallCount = 0;
144
- vi.doMock("@umami/node", () => ({
145
- Umami: class {
146
- constructor() {
147
- constructorCallCount++;
148
- }
149
- track = mockTrack;
150
- },
151
- }));
81
+ expect(sent().body.payload.url).toBe("/pricing");
82
+ expect(sent().body.payload).not.toHaveProperty("id");
83
+ });
152
84
 
85
+ it("strips a trailing slash from hostUrl and forwards a custom userAgent", async () => {
153
86
  const { initServer, trackServer } = await freshImport();
154
- initServer({ hostUrl: "https://umami.example.com", websiteId: "abc-123" });
155
- trackServer("first");
156
- // Wait for the first getClient() promise to resolve and set the cached client
157
- await new Promise((r) => setTimeout(r, 10));
158
- trackServer("second");
159
- await new Promise((r) => setTimeout(r, 10));
160
-
161
- expect(constructorCallCount).toBe(1);
162
- expect(mockTrack).toHaveBeenCalledTimes(2);
163
- });
164
- });
87
+ initServer({
88
+ ...config,
89
+ hostUrl: "https://umami.example.com/",
90
+ userAgent: "Mozilla/5.0 Seam/1.0",
91
+ });
165
92
 
166
- // ---- trackServerRevenue ----
93
+ await trackServer("ua_test");
167
94
 
168
- describe("trackServerRevenue", () => {
169
- it("tracks a revenue event with revenue and currency merged into data", async () => {
170
- const mockTrack = vi.fn().mockResolvedValue(undefined);
171
- vi.doMock("@umami/node", () => ({
172
- Umami: class {
173
- track = mockTrack;
174
- },
175
- }));
95
+ expect(sent().url).toBe("https://umami.example.com/api/send");
96
+ expect(sent().headers["User-Agent"]).toBe("Mozilla/5.0 Seam/1.0");
97
+ });
176
98
 
177
- const { initServer, trackServerRevenue } = await freshImport();
178
- initServer({ hostUrl: "https://umami.example.com", websiteId: "abc-123" });
179
- trackServerRevenue("purchase", 49.99, "USD");
180
- await new Promise((r) => setTimeout(r, 0));
99
+ // Umami rejects an event with HTTP 200 and the body `beep`.
100
+ it("resolves false when Umami answers beep", async () => {
101
+ answer("beep");
102
+ const { initServer, trackServer } = await freshImport();
103
+ initServer(config);
181
104
 
182
- expect(mockTrack).toHaveBeenCalledWith({
183
- name: "purchase",
184
- data: { revenue: 49.99, currency: "USD" },
185
- });
105
+ await expect(trackServer("dropped")).resolves.toBe(false);
106
+ expect(console.error).toHaveBeenCalled();
186
107
  });
187
108
 
188
- it("merges extra EventData alongside revenue and currency", async () => {
189
- const mockTrack = vi.fn().mockResolvedValue(undefined);
190
- vi.doMock("@umami/node", () => ({
191
- Umami: class {
192
- track = mockTrack;
193
- },
194
- }));
109
+ it("resolves false on an HTTP error", async () => {
110
+ answer("nope", 500);
111
+ const { initServer, trackServer } = await freshImport();
112
+ initServer(config);
195
113
 
196
- const { initServer, trackServerRevenue } = await freshImport();
197
- initServer({ hostUrl: "https://umami.example.com", websiteId: "abc-123" });
198
- trackServerRevenue("subscription", 9.99, "EUR", { plan: "pro" });
199
- await new Promise((r) => setTimeout(r, 0));
114
+ await expect(trackServer("server_error")).resolves.toBe(false);
115
+ });
200
116
 
201
- expect(mockTrack).toHaveBeenCalledWith({
202
- name: "subscription",
203
- data: { plan: "pro", revenue: 9.99, currency: "EUR" },
204
- });
117
+ it("resolves false and never throws when fetch rejects", async () => {
118
+ fetchMock.mockRejectedValue(new Error("network failure"));
119
+ const { initServer, trackServer } = await freshImport();
120
+ initServer(config);
121
+
122
+ await expect(trackServer("risky_event")).resolves.toBe(false);
205
123
  });
206
124
 
207
- it("passes url to the underlying trackServer call", async () => {
208
- const mockTrack = vi.fn().mockResolvedValue(undefined);
209
- vi.doMock("@umami/node", () => ({
210
- Umami: class {
211
- track = mockTrack;
212
- },
213
- }));
125
+ it("resolves false without initServer and sends nothing", async () => {
126
+ const { trackServer } = await freshImport();
214
127
 
215
- const { initServer, trackServerRevenue } = await freshImport();
216
- initServer({ hostUrl: "https://umami.example.com", websiteId: "abc-123" });
217
- trackServerRevenue("checkout", 19.99, "USD", undefined, "/checkout");
218
- await new Promise((r) => setTimeout(r, 0));
219
-
220
- expect(mockTrack).toHaveBeenCalledWith({
221
- name: "checkout",
222
- data: { revenue: 19.99, currency: "USD" },
223
- url: "/checkout",
224
- });
128
+ await expect(trackServer("orphan_event")).resolves.toBe(false);
129
+ expect(fetchMock).not.toHaveBeenCalled();
225
130
  });
226
131
  });
227
132
 
228
- // ---- initServer config forwarding ----
229
-
230
- describe("initServer config forwarding", () => {
231
- it("passes hostUrl and websiteId to the Umami constructor", async () => {
232
- let capturedConfig = null;
233
- const mockTrack = vi.fn().mockResolvedValue(undefined);
234
- vi.doMock("@umami/node", () => ({
235
- Umami: class {
236
- constructor(cfg: unknown) {
237
- capturedConfig = cfg;
238
- }
239
- track = mockTrack;
240
- },
241
- }));
242
-
243
- const { initServer, trackServer } = await freshImport();
244
- initServer({ hostUrl: "https://analytics.myapp.io", websiteId: "xyz-789" });
245
- trackServer("init_test");
246
- await new Promise((r) => setTimeout(r, 0));
247
-
248
- expect(capturedConfig).toEqual({
249
- hostUrl: "https://analytics.myapp.io",
250
- websiteId: "xyz-789",
251
- userAgent: "Server",
133
+ describe("trackServerRevenue", () => {
134
+ it("merges revenue and currency into data and forwards url and id", async () => {
135
+ const { initServer, trackServerRevenue } = await freshImport();
136
+ initServer(config);
137
+
138
+ await expect(
139
+ trackServerRevenue(
140
+ "Purchase Complete",
141
+ 9.99,
142
+ "EUR",
143
+ { plan: "pro" },
144
+ { url: "/buy", id: "cs_1" },
145
+ ),
146
+ ).resolves.toBe(true);
147
+
148
+ expect(sent().body.payload).toMatchObject({
149
+ name: "Purchase Complete",
150
+ url: "/buy",
151
+ id: "cs_1",
152
+ data: { plan: "pro", revenue: 9.99, currency: "EUR" },
252
153
  });
253
154
  });
254
155
 
255
- it("forwards a custom userAgent to the Umami constructor", async () => {
256
- let capturedConfig = null;
257
- const mockTrack = vi.fn().mockResolvedValue(undefined);
258
- vi.doMock("@umami/node", () => ({
259
- Umami: class {
260
- constructor(cfg: unknown) {
261
- capturedConfig = cfg;
262
- }
263
- track = mockTrack;
264
- },
265
- }));
266
-
267
- const { initServer, trackServer } = await freshImport();
268
- initServer({
269
- hostUrl: "https://umami.example.com",
270
- websiteId: "abc-123",
271
- userAgent: "MyBot/1.0",
272
- });
273
- trackServer("ua_test");
274
- await new Promise((r) => setTimeout(r, 0));
156
+ it("resolves false when the send fails", async () => {
157
+ answer("beep");
158
+ const { initServer, trackServerRevenue } = await freshImport();
159
+ initServer(config);
275
160
 
276
- expect(capturedConfig).toEqual({
277
- hostUrl: "https://umami.example.com",
278
- websiteId: "abc-123",
279
- userAgent: "MyBot/1.0",
280
- });
161
+ await expect(
162
+ trackServerRevenue("checkout", 19.99, "USD", undefined, "/checkout"),
163
+ ).resolves.toBe(false);
281
164
  });
282
165
  });
@@ -1,32 +1,31 @@
1
1
  /**
2
- * Server-side Umami tracking via @umami/node.
3
- * Reliable for revenue and critical events — bypasses ad blockers.
4
- *
5
- * Requires `@umami/node` peer dependency.
2
+ * Server-side Umami tracking. A Worker cancels a fetch still in flight once the response
3
+ * returns, so await each tracker or hand it to `waitUntil`.
6
4
  */
7
5
 
8
6
  import type { EventData } from "#src/analytics/types.js";
9
-
10
- type UmamiNodeClient = {
11
- track: (payload: Record<string, unknown>) => Promise<unknown>;
12
- };
13
-
14
- type UmamiNodeModule = {
15
- Umami: new (config: {
16
- hostUrl: string;
17
- websiteId: string;
18
- userAgent?: string;
19
- }) => UmamiNodeClient;
20
- };
21
-
22
- let client: UmamiNodeClient | null = null;
7
+ import { buildSendBody, UMAMI_ENDPOINTS } from "#src/analytics/umami/protocol.js";
23
8
 
24
9
  type ServerConfig = {
25
10
  hostUrl: string;
26
11
  websiteId: string;
12
+ /** Site hostname, such as `getseam.app`. Without it Umami logs the event under `localhost`. */
13
+ hostname?: string;
14
+ /** Umami answers a non-browser user agent with `beep` and drops the event. */
27
15
  userAgent?: string;
28
16
  };
29
17
 
18
+ /** Per-event options. A string is the url, as in 3.0. */
19
+ export type ServerEventOptions = {
20
+ url?: string;
21
+ /** Umami keys the session on it. Without one, every event shares the Worker's session. */
22
+ id?: string;
23
+ };
24
+
25
+ // Umami's bot filter answers `beep` to any agent containing "Server" (tested 19 Oct 2026).
26
+ const DEFAULT_USER_AGENT = "Mozilla/5.0 Marketing/1.0";
27
+ const TIMEOUT_MS = 3000;
28
+
30
29
  let _config: ServerConfig | null = null;
31
30
 
32
31
  /** Initialize the server-side Umami client. Call once at startup. */
@@ -34,30 +33,47 @@ export function initServer(config: ServerConfig): void {
34
33
  _config = config;
35
34
  }
36
35
 
37
- async function getClient(): Promise<UmamiNodeClient> {
38
- if (client) return client;
39
- if (!_config) throw new Error("Call initServer() before tracking events");
40
-
41
- const { Umami } = (await import("@umami/node")) as UmamiNodeModule;
42
- client = new Umami({
43
- hostUrl: _config.hostUrl,
44
- websiteId: _config.websiteId,
45
- userAgent: _config.userAgent ?? "Server",
46
- });
47
- return client;
48
- }
36
+ /**
37
+ * Sends one event and checks Umami recorded it. Never throws.
38
+ * Resolves false on a timeout, an HTTP error or a `beep` rejection.
39
+ */
40
+ export async function trackServer(
41
+ name: string,
42
+ data?: EventData,
43
+ options?: string | ServerEventOptions,
44
+ ): Promise<boolean> {
45
+ const { url, id } =
46
+ typeof options === "string" ? { url: options, id: undefined } : (options ?? {});
47
+ try {
48
+ if (!_config) throw new Error("Call initServer() before tracking events");
49
49
 
50
- /** Fire-and-forget server-side event tracking. Never throws. */
51
- export function trackServer(name: string, data?: EventData, url?: string): void {
52
- const payload: { name: string; data?: EventData; url?: string } = { name };
53
- if (data) payload.data = data;
54
- if (url) payload.url = url;
50
+ const body = buildSendBody({
51
+ websiteId: _config.websiteId,
52
+ hostname: _config.hostname,
53
+ name,
54
+ url,
55
+ data,
56
+ });
57
+ const payload = id ? { ...body.payload, id } : body.payload;
55
58
 
56
- getClient()
57
- .then((c) => c.track(payload))
58
- .catch((err: unknown) => {
59
- console.error("[analytics] Server track failed:", err);
59
+ const response = await fetch(`${_config.hostUrl.replace(/\/$/, "")}${UMAMI_ENDPOINTS.send}`, {
60
+ method: "POST",
61
+ headers: {
62
+ "Content-Type": "application/json",
63
+ "User-Agent": _config.userAgent ?? DEFAULT_USER_AGENT,
64
+ },
65
+ body: JSON.stringify({ ...body, payload }),
66
+ signal: AbortSignal.timeout(TIMEOUT_MS),
60
67
  });
68
+ const text = await response.text();
69
+ if (!response.ok || text.includes("beep")) {
70
+ throw new Error(`HTTP ${response.status}: ${text.slice(0, 120)}`);
71
+ }
72
+ return true;
73
+ } catch (err: unknown) {
74
+ console.error(`[analytics] Server track failed for ${name}:`, err);
75
+ return false;
76
+ }
61
77
  }
62
78
 
63
79
  /** Track a server-side revenue event. Revenue must be in dollars (not cents). */
@@ -66,7 +82,7 @@ export function trackServerRevenue(
66
82
  revenue: number,
67
83
  currency: string,
68
84
  data?: EventData,
69
- url?: string,
70
- ): void {
71
- trackServer(name, { ...data, revenue, currency }, url);
85
+ options?: string | ServerEventOptions,
86
+ ): Promise<boolean> {
87
+ return trackServer(name, { ...data, revenue, currency }, options);
72
88
  }