flowgrid-sdk 1.7.7 → 1.7.10

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,812 @@
1
+ "use strict";
2
+ /**
3
+ * @fileoverview FlowGrid Server SDK — Node/edge-safe tracking client.
4
+ * @module server
5
+ *
6
+ * @description
7
+ * Server-side counterpart to the browser `FlowGrid` facade, deliberately
8
+ * scoped to the operations that make sense off-browser:
9
+ *
10
+ * - **Identity** — identify/alias/reset users and auth-level events
11
+ * (signup/login/active-user) from server auth flows.
12
+ * - **Feature usage** — `feature_usage` events from API routes and jobs.
13
+ * - **Ecommerce / revenue** — purchases, refunds, order lifecycle and
14
+ * subscription MRR events from payment webhooks, where money is actually
15
+ * confirmed (immune to ad blockers and closed checkout tabs).
16
+ * - **Error tracking** — API and server-side exceptions as `error_event`s.
17
+ *
18
+ * It speaks the exact same wire contract as the browser SDK (per-op
19
+ * `?call=<type>` routing, suffixed event names like `identify_user`,
20
+ * camelCase properties), so flowgrid-core ingests both identically.
21
+ *
22
+ * Never touches `window`/`document`/`localStorage` and imports no Node
23
+ * built-ins — only global `fetch` (Node ≥ 18) — so it is safe in Next.js
24
+ * API routes and middleware, Express services, workers, and edge runtimes.
25
+ * Like the browser transport, it **never throws** for delivery problems:
26
+ * failures resolve `{ ok: false }` and are debug-noted once per signature.
27
+ *
28
+ * @example
29
+ * ```ts
30
+ * import { FlowGridServer } from "flowgrid-sdk/server";
31
+ *
32
+ * const fg = FlowGridServer.init({
33
+ * webId: process.env.NEXT_PUBLIC_WEB_ID!,
34
+ * apiKey: process.env.FLOWGRID_API_KEY!,
35
+ * });
36
+ *
37
+ * // Identity (auth-level, server-side)
38
+ * await fg.trackAuth({ event: "login", userId: user.id, email: user.email });
39
+ *
40
+ * // Feature usage
41
+ * await fg.trackFeature({ featureId: "team_update", featureName: "Team update", userId: user.id });
42
+ *
43
+ * // Revenue — from a payment webhook (e.g. Stripe checkout.session.completed)
44
+ * await fg.trackPurchase({ order, userId: session.client_reference_id });
45
+ *
46
+ * // API / server error tracking
47
+ * try { … } catch (err) {
48
+ * await fg.trackError(err, { route: "/api/teams/edit", method: "PATCH", statusCode: 500 });
49
+ * throw err;
50
+ * }
51
+ * ```
52
+ */
53
+ Object.defineProperty(exports, "__esModule", { value: true });
54
+ exports.FlowGridServer = void 0;
55
+ exports.visitorIdFromCookies = visitorIdFromCookies;
56
+ exports.sessionIdFromCookies = sessionIdFromCookies;
57
+ exports.checkoutAttributionFromCookies = checkoutAttributionFromCookies;
58
+ exports.attributionFromMetadata = attributionFromMetadata;
59
+ /** Maps {@link ServerSubscriptionInput}'s `event` to the ingest subtype. */
60
+ const SUBSCRIPTION_SUBTYPES = {
61
+ start: "subscription_start",
62
+ change: "subscription_change",
63
+ cancel: "subscription_cancel",
64
+ renew: "subscription_renew",
65
+ trial_converted: "trial_converted",
66
+ trial_expired: "trial_expired",
67
+ };
68
+ /**
69
+ * Read the FlowGrid visitor id from a request's cookies so server events
70
+ * link to the visitor's web session. Checks the tracking-script cookie
71
+ * (`visitor_id`) first, then the SDK's own `fg_visitor_id`.
72
+ *
73
+ * @example
74
+ * ```ts
75
+ * import { cookies } from "next/headers";
76
+ * const visitorId = visitorIdFromCookies(await cookies());
77
+ * await fg.identifyUser(user.id, { email: user.email }, { visitorId });
78
+ * ```
79
+ */
80
+ function visitorIdFromCookies(source) {
81
+ return readFirstCookie(source, ["visitor_id", "fg_visitor_id"]);
82
+ }
83
+ /**
84
+ * Read the FlowGrid session id from a request's cookies. Both trackers
85
+ * mirror it to a cookie (`session_id` for the no-code script,
86
+ * `fg_session_id` for the npm SDK) precisely so checkout-creation routes
87
+ * can read it for revenue attribution.
88
+ */
89
+ function sessionIdFromCookies(source) {
90
+ return readFirstCookie(source, ["session_id", "fg_session_id"]);
91
+ }
92
+ /**
93
+ * The **checkout leg** of the revenue-attribution cycle. Returns
94
+ * `{ flowgrid_visitor_id, flowgrid_session_id }` read from the request
95
+ * cookies, ready to drop into Stripe's `metadata` or Lemon Squeezy's
96
+ * `checkoutData.custom` when you create the checkout. Your payment
97
+ * webhook reads them back with {@link attributionFromMetadata} — that
98
+ * round-trip is what ties the payment to the visitor's session, campaign
99
+ * and journey, since the webhook itself arrives with no cookies.
100
+ *
101
+ * @example
102
+ * ```ts
103
+ * // app/api/checkout/route.ts
104
+ * import { cookies } from "next/headers";
105
+ * import { checkoutAttributionFromCookies } from "flowgrid-sdk/server";
106
+ *
107
+ * const session = await stripe.checkout.sessions.create({
108
+ * // …
109
+ * metadata: { ...checkoutAttributionFromCookies(await cookies()) },
110
+ * });
111
+ * ```
112
+ */
113
+ function checkoutAttributionFromCookies(source) {
114
+ const visitorId = visitorIdFromCookies(source);
115
+ const sessionId = sessionIdFromCookies(source);
116
+ return {
117
+ ...(visitorId ? { flowgrid_visitor_id: visitorId } : {}),
118
+ ...(sessionId ? { flowgrid_session_id: sessionId } : {}),
119
+ };
120
+ }
121
+ /**
122
+ * The **webhook leg** of the revenue-attribution cycle: extract the
123
+ * visitor/session ids your checkout route embedded via
124
+ * {@link checkoutAttributionFromCookies} (or the browser's
125
+ * `fg.checkoutAttribution()`). Accepts the metadata map itself, or a
126
+ * provider object containing it — Stripe objects (`.metadata`) and
127
+ * Lemon Squeezy webhook payloads (`.meta.custom_data`) are unwrapped
128
+ * automatically. Recognises `flowgrid_*`, `fg_*` and bare key spellings.
129
+ * Never throws; missing ids come back `undefined`.
130
+ *
131
+ * @example
132
+ * ```ts
133
+ * // Stripe: checkout.session.completed
134
+ * const { visitorId, sessionId } = attributionFromMetadata(event.data.object);
135
+ * await fg.trackPurchase({ order, userId }, { visitorId, sessionId });
136
+ *
137
+ * // Lemon Squeezy: order_created
138
+ * const { visitorId, sessionId } = attributionFromMetadata(payload);
139
+ * ```
140
+ */
141
+ function attributionFromMetadata(metadata) {
142
+ const map = unwrapMetadata(metadata);
143
+ if (!map)
144
+ return {};
145
+ return {
146
+ visitorId: readFirstKey(map, ["flowgrid_visitor_id", "fg_visitor_id", "visitor_id"]),
147
+ sessionId: readFirstKey(map, ["flowgrid_session_id", "fg_session_id", "session_id"]),
148
+ };
149
+ }
150
+ function readFirstCookie(source, names) {
151
+ for (const name of names) {
152
+ try {
153
+ const raw = source.get(name);
154
+ if (!raw)
155
+ continue;
156
+ const value = typeof raw === "object" && "value" in raw ? raw.value : raw;
157
+ if (typeof value === "string" && value)
158
+ return value;
159
+ }
160
+ catch { /* reader threw — try the next cookie */ }
161
+ }
162
+ return undefined;
163
+ }
164
+ /** Dig the flat metadata map out of a provider object, if wrapped. */
165
+ function unwrapMetadata(metadata) {
166
+ if (!metadata || typeof metadata !== "object")
167
+ return undefined;
168
+ const obj = metadata;
169
+ // Lemon Squeezy webhook payload: { meta: { custom_data: {…} } }
170
+ if (obj.meta?.custom_data && typeof obj.meta.custom_data === "object") {
171
+ return obj.meta.custom_data;
172
+ }
173
+ // Stripe object carrying its metadata: { metadata: {…} }
174
+ if (obj.metadata && typeof obj.metadata === "object")
175
+ return obj.metadata;
176
+ return obj;
177
+ }
178
+ function readFirstKey(map, keys) {
179
+ for (const key of keys) {
180
+ const value = map[key];
181
+ if (typeof value === "string" && value)
182
+ return value;
183
+ }
184
+ return undefined;
185
+ }
186
+ // ============================================================================
187
+ // SERVER CLIENT
188
+ // ============================================================================
189
+ const DEFAULT_ENDPOINT = "https://core.flow-grid.xyz";
190
+ const DEFAULT_TIMEOUT_MS = 10000;
191
+ const DEFAULT_MAX_RETRIES = 2;
192
+ const MAX_PAYLOAD_SIZE = 10000; // 10KB — same cap as the browser transport
193
+ class FlowGridServer {
194
+ /**
195
+ * One-line init. Idempotent — calling twice returns the same instance
196
+ * (matching the browser `FlowGrid.init` contract).
197
+ */
198
+ static init(config) {
199
+ if (FlowGridServer._instance)
200
+ return FlowGridServer._instance;
201
+ FlowGridServer._instance = new FlowGridServer(config);
202
+ return FlowGridServer._instance;
203
+ }
204
+ /** Returns the singleton created by {@link FlowGridServer.init}, if any. */
205
+ static instance() {
206
+ return FlowGridServer._instance;
207
+ }
208
+ /** Reset the singleton — primarily for tests / hot-reload. */
209
+ static reset() {
210
+ FlowGridServer._instance = null;
211
+ }
212
+ /** Explicit constructor for multi-tenant setups; most apps use {@link init}. */
213
+ constructor(config) {
214
+ /** Per-process visitor for events with no user and no explicit visitorId. */
215
+ this.anonymousVisitorId = null;
216
+ if (!config?.webId?.trim()) {
217
+ throw new Error("FlowGridServer.init: `webId` is required.");
218
+ }
219
+ if (!config?.apiKey?.trim()) {
220
+ throw new Error("FlowGridServer.init: `apiKey` is required for server-side tracking.");
221
+ }
222
+ this.webId = config.webId.trim();
223
+ this.apiKey = config.apiKey.trim();
224
+ this.endpoint = normalizeEndpoint(config.endpoint ?? DEFAULT_ENDPOINT);
225
+ this.environment = config.environment
226
+ ?? (typeof process !== "undefined" ? process.env?.NODE_ENV : undefined);
227
+ this.timeoutMs = config.timeoutMs ?? DEFAULT_TIMEOUT_MS;
228
+ this.maxRetries = config.maxRetries ?? DEFAULT_MAX_RETRIES;
229
+ }
230
+ // ==========================================================================
231
+ // IDENTITY — auth-level events
232
+ // ==========================================================================
233
+ /**
234
+ * Identify a user server-side (e.g. inside an auth callback). Writes the
235
+ * visitor identity store exactly like the browser `fg.identifyUser`.
236
+ */
237
+ identifyUser(userId, traits, opts) {
238
+ return this.send("identify", "identify_user", {
239
+ type: "identify",
240
+ userId,
241
+ anonymousId: opts?.anonymousId,
242
+ traits: traits ?? {},
243
+ }, this.resolveVisitorId(opts, userId));
244
+ }
245
+ /** Update traits on an already-identified user. */
246
+ updateTraits(userId, traits, opts) {
247
+ return this.send("update_traits", "update_traits", {
248
+ type: "update_traits",
249
+ userId,
250
+ traits,
251
+ }, this.resolveVisitorId(opts, userId));
252
+ }
253
+ /** Alias a previous user id to a new one (e.g. after an ID migration). */
254
+ aliasUser(previousId, userId, opts) {
255
+ return this.send("alias", "alias_user", {
256
+ type: "alias",
257
+ previousId,
258
+ userId,
259
+ }, this.resolveVisitorId(opts, userId));
260
+ }
261
+ /** Reset a user's identity on logout (server twin of `fg.logout`). */
262
+ logout(userId, opts) {
263
+ return this.send("reset", "reset_identity", {
264
+ type: "reset",
265
+ userId,
266
+ }, this.resolveVisitorId(opts, userId));
267
+ }
268
+ /**
269
+ * Record a completed signup (populates the Acquisition Funnel's
270
+ * "Signed up" stage, same as the browser `fg.signup`).
271
+ */
272
+ signup(userId, method, source, properties, opts) {
273
+ return this.send("activation", "signup_completed", {
274
+ type: "activation",
275
+ subtype: "signup",
276
+ userId,
277
+ method,
278
+ source,
279
+ ...properties,
280
+ }, this.resolveVisitorId(opts, userId));
281
+ }
282
+ /** Record a returning-user login (retention signal, not a funnel conversion). */
283
+ login(userId, method, properties, opts) {
284
+ return this.send("activation", "login_completed", {
285
+ type: "activation",
286
+ subtype: "login",
287
+ userId,
288
+ method,
289
+ ...properties,
290
+ }, this.resolveVisitorId(opts, userId));
291
+ }
292
+ /**
293
+ * Emit an "active user" signal for DAU/WAU/MAU by identity — server twin
294
+ * of `fg.pingActiveUser`. Rides the activation pipe (subtype `active_user`)
295
+ * so the wire `event_name` is `active_user`, the shape the active-users
296
+ * pipes read (the old `custom_event` pipe stored the name out of reach).
297
+ */
298
+ pingActiveUser(userId, traits, opts) {
299
+ const now = new Date();
300
+ return this.send("activation", "active_user", {
301
+ type: "activation",
302
+ subtype: "active_user",
303
+ userId,
304
+ ...(traits?.email ? { email: traits.email } : {}),
305
+ ...(traits?.name ? { name: traits.name } : {}),
306
+ date: now.toISOString().slice(0, 10),
307
+ }, this.resolveVisitorId(opts, userId));
308
+ }
309
+ /**
310
+ * One-call server-side auth tracking: identify + signup/login event +
311
+ * active-user ping — exact parity with the browser `fg.trackAuth`, for
312
+ * auth flows that resolve on the server (OAuth callbacks, session
313
+ * endpoints, credential logins).
314
+ *
315
+ * @example
316
+ * ```ts
317
+ * // In an auth callback / route handler:
318
+ * await fg.trackAuth(
319
+ * { event: "signup", userId: user.id, email: user.email, method: "google" },
320
+ * { visitorId: visitorIdFromCookies(await cookies()) },
321
+ * );
322
+ * ```
323
+ */
324
+ async trackAuth(input, opts) {
325
+ const traits = {
326
+ ...(input.email ? { email: input.email } : {}),
327
+ ...(input.name ? { name: input.name } : {}),
328
+ ...(input.traits ?? {}),
329
+ };
330
+ const method = input.method ?? "email";
331
+ const [identified, event] = await Promise.all([
332
+ this.identifyUser(input.userId, traits, opts),
333
+ input.event === "signup"
334
+ ? this.signup(input.userId, method, input.source, traits, opts)
335
+ : this.login(input.userId, method, traits, opts),
336
+ this.pingActiveUser(input.userId, traits, opts),
337
+ ]);
338
+ return identified.ok && event.ok ? identified : (identified.ok ? event : identified);
339
+ }
340
+ // ==========================================================================
341
+ // FEATURE USAGE
342
+ // ==========================================================================
343
+ /**
344
+ * Track a feature-usage event from the server (API-triggered features,
345
+ * background jobs, webhooks). Same `feature_usage` contract as the
346
+ * browser `fg.track("feature_usage", …)`.
347
+ */
348
+ trackFeature(input, opts) {
349
+ const action = input.action ?? "used";
350
+ return this.send("feature_usage", "feature_usage", {
351
+ type: "feature_usage",
352
+ subtype: action,
353
+ featureId: input.featureId,
354
+ featureName: input.featureName,
355
+ userId: input.userId,
356
+ action,
357
+ duration: input.duration,
358
+ success: input.success,
359
+ category: input.category,
360
+ ...input.properties,
361
+ }, this.resolveVisitorId(opts, input.userId));
362
+ }
363
+ /**
364
+ * Bind a feature's id + name once and get semantic verbs back — server
365
+ * twin of the browser `fg.defineFeature`. Verbs accept per-call
366
+ * `{ userId, visitorId }` since a server process serves many users.
367
+ *
368
+ * @example
369
+ * ```ts
370
+ * const teamUpdate = fg.defineFeature("team_update", "Team update", { category: "teams" });
371
+ * await teamUpdate.used({ userId: user.id });
372
+ * ```
373
+ */
374
+ defineFeature(featureId, featureName, defOpts = {}) {
375
+ const emit = (action, properties, opts) => {
376
+ const { userId, ...rest } = properties ?? {};
377
+ return this.trackFeature({
378
+ featureId,
379
+ featureName,
380
+ action,
381
+ userId: typeof userId === "string" ? userId : undefined,
382
+ category: defOpts.category,
383
+ properties: rest,
384
+ }, opts);
385
+ };
386
+ return {
387
+ featureId,
388
+ featureName,
389
+ viewed: (properties, opts) => emit("viewed", properties, opts),
390
+ used: (properties, opts) => emit("used", properties, opts),
391
+ completed: (properties, opts) => emit("completed", properties, opts),
392
+ abandoned: (properties, opts) => emit("abandoned", properties, opts),
393
+ track: (action, properties, opts) => emit(action, properties, opts),
394
+ };
395
+ }
396
+ // ==========================================================================
397
+ // ECOMMERCE — revenue events from payment webhooks
398
+ // ==========================================================================
399
+ /**
400
+ * Track a confirmed purchase from the server. Fire this from your payment
401
+ * webhook (Stripe `checkout.session.completed`, PayPal capture, order
402
+ * service) — the place where money is actually confirmed — instead of the
403
+ * browser thank-you page, which loses orders to ad blockers, closed tabs
404
+ * and interrupted redirects. Same `ecommerce`/`purchase` wire contract as
405
+ * the browser `purchases.track()`, so revenue dashboards see one stream.
406
+ *
407
+ * @example
408
+ * ```ts
409
+ * // Stripe webhook route
410
+ * await fg.trackPurchase({
411
+ * order: {
412
+ * orderId: session.id,
413
+ * items, subtotal, discountTotal: 0, shippingTotal: 0,
414
+ * taxTotal, total: session.amount_total / 100, currency: "USD",
415
+ * payment: { method: "credit_card", provider: "stripe" },
416
+ * },
417
+ * userId: session.client_reference_id,
418
+ * });
419
+ * ```
420
+ */
421
+ trackPurchase(input, opts) {
422
+ const order = input.order;
423
+ return this.send("ecommerce", "purchase", {
424
+ type: "ecommerce",
425
+ subtype: "purchase",
426
+ order: {
427
+ orderId: order.orderId,
428
+ items: order.items,
429
+ subtotal: order.subtotal,
430
+ discountTotal: order.discountTotal,
431
+ shippingTotal: order.shippingTotal,
432
+ taxTotal: order.taxTotal,
433
+ total: order.total,
434
+ currency: order.currency,
435
+ shipping: order.shipping,
436
+ payment: order.payment,
437
+ coupons: order.coupons,
438
+ affiliation: order.affiliation,
439
+ },
440
+ revenue: order.total,
441
+ itemCount: order.items.reduce((sum, item) => sum + item.quantity, 0),
442
+ userId: input.userId,
443
+ sessionId: opts?.sessionId,
444
+ attribution: input.attribution,
445
+ ...input.properties,
446
+ }, this.resolveVisitorId(opts, input.userId));
447
+ }
448
+ /** Track an order status change from fulfilment systems / order webhooks. */
449
+ trackOrderStatus(orderId, newStatus, previousStatus, opts) {
450
+ return this.send("ecommerce", "order_status_change", {
451
+ type: "ecommerce",
452
+ subtype: "order_status_change",
453
+ orderId,
454
+ newStatus,
455
+ previousStatus,
456
+ userId: opts?.userId,
457
+ sessionId: opts?.sessionId,
458
+ }, this.resolveVisitorId(opts, opts?.userId));
459
+ }
460
+ /** Track an order shipped event (carrier/fulfilment webhook). */
461
+ trackOrderShipped(orderId, shipping, opts) {
462
+ return this.send("ecommerce", "order_shipped", {
463
+ type: "ecommerce",
464
+ subtype: "order_shipped",
465
+ orderId,
466
+ shipping,
467
+ userId: opts?.userId,
468
+ sessionId: opts?.sessionId,
469
+ }, this.resolveVisitorId(opts, opts?.userId));
470
+ }
471
+ /** Track an order delivered event. */
472
+ trackOrderDelivered(orderId, deliveredAt, opts) {
473
+ return this.send("ecommerce", "order_delivered", {
474
+ type: "ecommerce",
475
+ subtype: "order_delivered",
476
+ orderId,
477
+ deliveredAt: deliveredAt ?? new Date().toISOString(),
478
+ userId: opts?.userId,
479
+ sessionId: opts?.sessionId,
480
+ }, this.resolveVisitorId(opts, opts?.userId));
481
+ }
482
+ /**
483
+ * Track a processed refund — fire from your payment provider's refund
484
+ * webhook (e.g. Stripe `charge.refunded`) so net revenue stays accurate.
485
+ */
486
+ trackRefund(input, opts) {
487
+ return this.send("ecommerce", "refund", {
488
+ type: "ecommerce",
489
+ subtype: "refund",
490
+ orderId: input.orderId,
491
+ refundId: input.refundId,
492
+ items: input.items,
493
+ amount: input.amount,
494
+ currency: input.currency,
495
+ reason: input.reason,
496
+ isFullRefund: input.isFullRefund,
497
+ userId: input.userId,
498
+ sessionId: opts?.sessionId,
499
+ initiatedBy: input.initiatedBy,
500
+ ...input.properties,
501
+ }, this.resolveVisitorId(opts, input.userId));
502
+ }
503
+ /**
504
+ * Track subscription lifecycle / MRR events — start, plan change, cancel,
505
+ * renew, trial conversion and trial expiry — from your billing webhooks.
506
+ * These power the MRR/churn dashboards; billing state lives on your
507
+ * server, so this is the authoritative place to emit them.
508
+ *
509
+ * @example
510
+ * ```ts
511
+ * // customer.subscription.created
512
+ * await fg.trackSubscription({
513
+ * event: "start",
514
+ * subscriptionId: sub.id,
515
+ * userId: customer.metadata.userId,
516
+ * plan: "professional",
517
+ * mrr: { amount: 9900, currency: "USD" },
518
+ * billingCycle: "monthly",
519
+ * });
520
+ *
521
+ * // customer.subscription.deleted
522
+ * await fg.trackSubscription({
523
+ * event: "cancel",
524
+ * subscriptionId: sub.id,
525
+ * userId,
526
+ * reason: sub.cancellation_details?.reason ?? "unknown",
527
+ * lostMrr: { amount: 9900, currency: "USD" },
528
+ * });
529
+ * ```
530
+ */
531
+ trackSubscription(input, opts) {
532
+ const subtype = SUBSCRIPTION_SUBTYPES[input.event];
533
+ const { event: _event, properties, ...fields } = input;
534
+ const payload = {
535
+ type: "ecommerce",
536
+ subtype,
537
+ sessionId: opts?.sessionId,
538
+ ...fields,
539
+ ...properties,
540
+ };
541
+ if (input.event === "start") {
542
+ payload.isTrialing = (input.trialDays ?? 0) > 0;
543
+ }
544
+ if (input.event === "change" && input.previousMrr && input.newMrr) {
545
+ payload.mrrChange = input.newMrr.amount - input.previousMrr.amount;
546
+ }
547
+ return this.send("ecommerce", subtype, payload, this.resolveVisitorId(opts, input.userId));
548
+ }
549
+ // ==========================================================================
550
+ // ERROR TRACKING — API routes + server exceptions
551
+ // ==========================================================================
552
+ /**
553
+ * Track a server-side error or exception as an `error_event`. Accepts a
554
+ * thrown value of any shape; pass `route`/`method`/`statusCode` for API
555
+ * errors so the dashboard can break failures down per endpoint.
556
+ *
557
+ * FlowGrid-internal notes (messages starting with `[FlowGrid]`) are
558
+ * filtered out, matching the browser SDK's own error filtering.
559
+ */
560
+ trackError(error, opts = {}) {
561
+ const normalized = normalizeError(error);
562
+ if (normalized.message.startsWith("[FlowGrid]") || normalized.message.startsWith("[Flowgrid]")) {
563
+ return Promise.resolve({ ok: false, reason: "flowgrid-internal" });
564
+ }
565
+ return this.send("error_event", "error_event", {
566
+ type: "error_event",
567
+ message: normalized.message,
568
+ stack: normalized.stack,
569
+ errorType: normalized.type,
570
+ fatal: opts.fatal ?? false,
571
+ context: opts.context ?? opts.route,
572
+ userId: opts.userId,
573
+ ...(opts.route ? { _api_route: opts.route } : {}),
574
+ ...(opts.method ? { _http_method: opts.method } : {}),
575
+ ...(opts.statusCode !== undefined ? { _status_code: opts.statusCode } : {}),
576
+ ...opts.properties,
577
+ }, this.resolveVisitorId(opts, opts.userId));
578
+ }
579
+ /**
580
+ * Wrap an async function (API route handler, job, webhook) so any thrown
581
+ * error is tracked before being rethrown — the failure still surfaces to
582
+ * the caller/framework exactly as before.
583
+ *
584
+ * @example
585
+ * ```ts
586
+ * export const PATCH = fg.withErrorTracking(
587
+ * async (req: NextRequest) => { … },
588
+ * { route: "/api/teams/edit/[teamId]", method: "PATCH" },
589
+ * );
590
+ * ```
591
+ */
592
+ withErrorTracking(fn, opts = {}) {
593
+ return async (...args) => {
594
+ try {
595
+ return await fn(...args);
596
+ }
597
+ catch (error) {
598
+ // Fire-and-forget: error delivery must never delay the rethrow.
599
+ void this.trackError(error, { ...opts, statusCode: opts.statusCode ?? 500 });
600
+ throw error;
601
+ }
602
+ };
603
+ }
604
+ /**
605
+ * Opt-in process-level capture: reports `uncaughtException` and
606
+ * `unhandledRejection` as fatal `error_event`s. Observes only — never
607
+ * swallows; Node's default crash behaviour is unchanged. Returns a
608
+ * detach function. No-op outside Node (edge runtimes have no `process.on`).
609
+ */
610
+ captureUncaught(opts = {}) {
611
+ if (typeof process === "undefined" || typeof process.on !== "function") {
612
+ return () => { };
613
+ }
614
+ const onException = (error) => {
615
+ void this.trackError(error, { ...opts, context: opts.context ?? "uncaughtException", fatal: true });
616
+ };
617
+ const onRejection = (reason) => {
618
+ void this.trackError(reason, { ...opts, context: opts.context ?? "unhandledRejection", fatal: true });
619
+ };
620
+ process.on("uncaughtException", onException);
621
+ process.on("unhandledRejection", onRejection);
622
+ return () => {
623
+ process.removeListener("uncaughtException", onException);
624
+ process.removeListener("unhandledRejection", onRejection);
625
+ };
626
+ }
627
+ // ==========================================================================
628
+ // TRANSPORT
629
+ // ==========================================================================
630
+ /**
631
+ * Visitor resolution, in preference order:
632
+ * 1. explicit per-call `visitorId` (the real browser visitor — best),
633
+ * 2. stable synthetic id derived from the userId (`fg_srv_<hash>`), so a
634
+ * user's server events always land on one visitor,
635
+ * 3. one per-process anonymous visitor (events with no identity at all).
636
+ */
637
+ resolveVisitorId(opts, userId) {
638
+ if (opts?.visitorId)
639
+ return opts.visitorId;
640
+ if (userId)
641
+ return `fg_srv_${fnv1a64(userId)}`;
642
+ if (!this.anonymousVisitorId) {
643
+ this.anonymousVisitorId = `fg_srv_anon_${randomHex(16)}`;
644
+ }
645
+ return this.anonymousVisitorId;
646
+ }
647
+ /** Context stamped on every server event — parallel to the browser device context. */
648
+ serverContext() {
649
+ const proc = typeof process !== "undefined" ? process : undefined;
650
+ return {
651
+ _server: true,
652
+ _runtime: proc?.release?.name ?? "node",
653
+ ...(proc?.version ? { _runtime_version: proc.version } : {}),
654
+ ...(this.environment ? { _environment: this.environment } : {}),
655
+ };
656
+ }
657
+ /**
658
+ * POST one event to the gateway (`?call=<type>`), with timeout, jittered
659
+ * retry on 5xx/network failure, and the never-throw guarantee.
660
+ */
661
+ async send(callType, eventName, properties, visitorId) {
662
+ let body;
663
+ try {
664
+ // Drop undefined values so payloads stay clean (JSON.stringify would
665
+ // drop them anyway, but this keeps the size check honest).
666
+ const cleaned = {};
667
+ for (const [key, value] of Object.entries(properties)) {
668
+ if (value !== undefined)
669
+ cleaned[key] = value;
670
+ }
671
+ body = JSON.stringify({
672
+ web_id: this.webId,
673
+ visitor_id: visitorId,
674
+ event_name: eventName,
675
+ properties: {
676
+ ...this.serverContext(),
677
+ ...cleaned,
678
+ timestamp: new Date().toISOString(),
679
+ },
680
+ });
681
+ }
682
+ catch {
683
+ this.noteFailure(`${eventName}: properties not JSON-serializable`);
684
+ return { ok: false, reason: "not-serializable" };
685
+ }
686
+ if (byteLength(body) > MAX_PAYLOAD_SIZE) {
687
+ this.noteFailure(`${eventName}: payload too large (max 10KB)`);
688
+ return { ok: false, reason: "payload-too-large" };
689
+ }
690
+ const url = `${this.endpoint}/api/v1/sdk/functions?call=${encodeURIComponent(callType)}`;
691
+ for (let attempt = 0;; attempt++) {
692
+ const controller = new AbortController();
693
+ const timeoutId = setTimeout(() => controller.abort(), this.timeoutMs);
694
+ try {
695
+ const res = await fetch(url, {
696
+ method: "POST",
697
+ headers: {
698
+ "Content-Type": "application/json",
699
+ "Authorization": `Bearer ${this.apiKey}`,
700
+ },
701
+ body,
702
+ signal: controller.signal,
703
+ });
704
+ clearTimeout(timeoutId);
705
+ if (res.status >= 500 && attempt < this.maxRetries) {
706
+ await backoff(attempt + 1);
707
+ continue;
708
+ }
709
+ if (!res.ok) {
710
+ this.noteFailure(`${eventName}: ${res.status} ${res.statusText}`);
711
+ return { ok: false, status: res.status, reason: `http-${res.status}` };
712
+ }
713
+ return { ok: true, status: res.status };
714
+ }
715
+ catch (error) {
716
+ clearTimeout(timeoutId);
717
+ if (attempt < this.maxRetries) {
718
+ await backoff(attempt + 1);
719
+ continue;
720
+ }
721
+ const reason = error instanceof Error && error.name === "AbortError"
722
+ ? "timed out"
723
+ : error instanceof Error ? error.message : String(error);
724
+ this.noteFailure(`${eventName}: ${reason}`);
725
+ return { ok: false, reason };
726
+ }
727
+ }
728
+ }
729
+ /**
730
+ * One quiet, non-throwing note per failure signature (same policy as the
731
+ * browser transport): a failed tracking request is FlowGrid's problem,
732
+ * not the host app's — it must never reach the customer's error tracking.
733
+ */
734
+ noteFailure(detail) {
735
+ if (FlowGridServer.failureNoted.has(detail))
736
+ return;
737
+ FlowGridServer.failureNoted.add(detail);
738
+ if (typeof console !== "undefined") {
739
+ console.debug(`[FlowGrid] Server event not delivered (${detail}) — FlowGrid never throws; this note is debug-only.`);
740
+ }
741
+ }
742
+ }
743
+ exports.FlowGridServer = FlowGridServer;
744
+ /** Singleton created by {@link FlowGridServer.init}. */
745
+ FlowGridServer._instance = null;
746
+ /** One quiet debug note per failure signature — mirrors the browser transport. */
747
+ FlowGridServer.failureNoted = new Set();
748
+ // ============================================================================
749
+ // INTERNALS
750
+ // ============================================================================
751
+ function normalizeError(error) {
752
+ if (error instanceof Error) {
753
+ return { message: error.message || String(error), stack: error.stack, type: error.name };
754
+ }
755
+ if (typeof error === "string")
756
+ return { message: error, type: "Error" };
757
+ if (error && typeof error === "object") {
758
+ const obj = error;
759
+ return {
760
+ message: typeof obj.message === "string" && obj.message ? obj.message : safeStringify(error),
761
+ stack: typeof obj.stack === "string" ? obj.stack : undefined,
762
+ type: typeof obj.name === "string" ? obj.name : "Error",
763
+ };
764
+ }
765
+ return { message: String(error), type: "Error" };
766
+ }
767
+ function safeStringify(value) {
768
+ try {
769
+ return JSON.stringify(value) ?? String(value);
770
+ }
771
+ catch {
772
+ return String(value);
773
+ }
774
+ }
775
+ function normalizeEndpoint(endpoint) {
776
+ let clean = endpoint.trim();
777
+ if (!clean.startsWith("http://") && !clean.startsWith("https://")) {
778
+ clean = `https://${clean}`;
779
+ }
780
+ return clean.replace(/\/+$/, "");
781
+ }
782
+ function byteLength(text) {
783
+ return new TextEncoder().encode(text).length;
784
+ }
785
+ /** Jittered exponential backoff (~1s, ~2s windows) — matches the browser transport. */
786
+ function backoff(attempt) {
787
+ const ceiling = Math.min(Math.pow(2, attempt - 1) * 1000, 30000);
788
+ return new Promise((resolve) => setTimeout(resolve, Math.random() * ceiling));
789
+ }
790
+ /** FNV-1a 64-bit — dependency-free stable hash for synthetic visitor ids. */
791
+ function fnv1a64(input) {
792
+ let hash = 0xcbf29ce484222325n;
793
+ const prime = 0x100000001b3n;
794
+ for (let i = 0; i < input.length; i++) {
795
+ hash ^= BigInt(input.charCodeAt(i));
796
+ hash = (hash * prime) & 0xffffffffffffffffn;
797
+ }
798
+ return hash.toString(16).padStart(16, "0");
799
+ }
800
+ function randomHex(length) {
801
+ const bytes = new Uint8Array(length);
802
+ if (typeof crypto !== "undefined" && typeof crypto.getRandomValues === "function") {
803
+ crypto.getRandomValues(bytes);
804
+ }
805
+ else {
806
+ for (let i = 0; i < length; i++)
807
+ bytes[i] = Math.floor(Math.random() * 256);
808
+ }
809
+ return Array.from(bytes, (b) => b.toString(16).padStart(2, "0")).join("");
810
+ }
811
+ exports.default = FlowGridServer;
812
+ //# sourceMappingURL=index.js.map