flowgrid-sdk 1.7.3 → 1.7.8

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,811 @@
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 same `custom_event` pipe).
295
+ */
296
+ pingActiveUser(userId, traits, opts) {
297
+ const now = new Date();
298
+ return this.send("custom_event", "custom_event", {
299
+ type: "custom_event",
300
+ eventName: "active_user",
301
+ category: "general",
302
+ userId,
303
+ ...(traits?.email ? { email: traits.email } : {}),
304
+ ...(traits?.name ? { name: traits.name } : {}),
305
+ date: now.toISOString().slice(0, 10),
306
+ }, this.resolveVisitorId(opts, userId));
307
+ }
308
+ /**
309
+ * One-call server-side auth tracking: identify + signup/login event +
310
+ * active-user ping — exact parity with the browser `fg.trackAuth`, for
311
+ * auth flows that resolve on the server (OAuth callbacks, session
312
+ * endpoints, credential logins).
313
+ *
314
+ * @example
315
+ * ```ts
316
+ * // In an auth callback / route handler:
317
+ * await fg.trackAuth(
318
+ * { event: "signup", userId: user.id, email: user.email, method: "google" },
319
+ * { visitorId: visitorIdFromCookies(await cookies()) },
320
+ * );
321
+ * ```
322
+ */
323
+ async trackAuth(input, opts) {
324
+ const traits = {
325
+ ...(input.email ? { email: input.email } : {}),
326
+ ...(input.name ? { name: input.name } : {}),
327
+ ...(input.traits ?? {}),
328
+ };
329
+ const method = input.method ?? "email";
330
+ const [identified, event] = await Promise.all([
331
+ this.identifyUser(input.userId, traits, opts),
332
+ input.event === "signup"
333
+ ? this.signup(input.userId, method, input.source, traits, opts)
334
+ : this.login(input.userId, method, traits, opts),
335
+ this.pingActiveUser(input.userId, traits, opts),
336
+ ]);
337
+ return identified.ok && event.ok ? identified : (identified.ok ? event : identified);
338
+ }
339
+ // ==========================================================================
340
+ // FEATURE USAGE
341
+ // ==========================================================================
342
+ /**
343
+ * Track a feature-usage event from the server (API-triggered features,
344
+ * background jobs, webhooks). Same `feature_usage` contract as the
345
+ * browser `fg.track("feature_usage", …)`.
346
+ */
347
+ trackFeature(input, opts) {
348
+ const action = input.action ?? "used";
349
+ return this.send("feature_usage", "feature_usage", {
350
+ type: "feature_usage",
351
+ subtype: action,
352
+ featureId: input.featureId,
353
+ featureName: input.featureName,
354
+ userId: input.userId,
355
+ action,
356
+ duration: input.duration,
357
+ success: input.success,
358
+ category: input.category,
359
+ ...input.properties,
360
+ }, this.resolveVisitorId(opts, input.userId));
361
+ }
362
+ /**
363
+ * Bind a feature's id + name once and get semantic verbs back — server
364
+ * twin of the browser `fg.defineFeature`. Verbs accept per-call
365
+ * `{ userId, visitorId }` since a server process serves many users.
366
+ *
367
+ * @example
368
+ * ```ts
369
+ * const teamUpdate = fg.defineFeature("team_update", "Team update", { category: "teams" });
370
+ * await teamUpdate.used({ userId: user.id });
371
+ * ```
372
+ */
373
+ defineFeature(featureId, featureName, defOpts = {}) {
374
+ const emit = (action, properties, opts) => {
375
+ const { userId, ...rest } = properties ?? {};
376
+ return this.trackFeature({
377
+ featureId,
378
+ featureName,
379
+ action,
380
+ userId: typeof userId === "string" ? userId : undefined,
381
+ category: defOpts.category,
382
+ properties: rest,
383
+ }, opts);
384
+ };
385
+ return {
386
+ featureId,
387
+ featureName,
388
+ viewed: (properties, opts) => emit("viewed", properties, opts),
389
+ used: (properties, opts) => emit("used", properties, opts),
390
+ completed: (properties, opts) => emit("completed", properties, opts),
391
+ abandoned: (properties, opts) => emit("abandoned", properties, opts),
392
+ track: (action, properties, opts) => emit(action, properties, opts),
393
+ };
394
+ }
395
+ // ==========================================================================
396
+ // ECOMMERCE — revenue events from payment webhooks
397
+ // ==========================================================================
398
+ /**
399
+ * Track a confirmed purchase from the server. Fire this from your payment
400
+ * webhook (Stripe `checkout.session.completed`, PayPal capture, order
401
+ * service) — the place where money is actually confirmed — instead of the
402
+ * browser thank-you page, which loses orders to ad blockers, closed tabs
403
+ * and interrupted redirects. Same `ecommerce`/`purchase` wire contract as
404
+ * the browser `purchases.track()`, so revenue dashboards see one stream.
405
+ *
406
+ * @example
407
+ * ```ts
408
+ * // Stripe webhook route
409
+ * await fg.trackPurchase({
410
+ * order: {
411
+ * orderId: session.id,
412
+ * items, subtotal, discountTotal: 0, shippingTotal: 0,
413
+ * taxTotal, total: session.amount_total / 100, currency: "USD",
414
+ * payment: { method: "credit_card", provider: "stripe" },
415
+ * },
416
+ * userId: session.client_reference_id,
417
+ * });
418
+ * ```
419
+ */
420
+ trackPurchase(input, opts) {
421
+ const order = input.order;
422
+ return this.send("ecommerce", "purchase", {
423
+ type: "ecommerce",
424
+ subtype: "purchase",
425
+ order: {
426
+ orderId: order.orderId,
427
+ items: order.items,
428
+ subtotal: order.subtotal,
429
+ discountTotal: order.discountTotal,
430
+ shippingTotal: order.shippingTotal,
431
+ taxTotal: order.taxTotal,
432
+ total: order.total,
433
+ currency: order.currency,
434
+ shipping: order.shipping,
435
+ payment: order.payment,
436
+ coupons: order.coupons,
437
+ affiliation: order.affiliation,
438
+ },
439
+ revenue: order.total,
440
+ itemCount: order.items.reduce((sum, item) => sum + item.quantity, 0),
441
+ userId: input.userId,
442
+ sessionId: opts?.sessionId,
443
+ attribution: input.attribution,
444
+ ...input.properties,
445
+ }, this.resolveVisitorId(opts, input.userId));
446
+ }
447
+ /** Track an order status change from fulfilment systems / order webhooks. */
448
+ trackOrderStatus(orderId, newStatus, previousStatus, opts) {
449
+ return this.send("ecommerce", "order_status_change", {
450
+ type: "ecommerce",
451
+ subtype: "order_status_change",
452
+ orderId,
453
+ newStatus,
454
+ previousStatus,
455
+ userId: opts?.userId,
456
+ sessionId: opts?.sessionId,
457
+ }, this.resolveVisitorId(opts, opts?.userId));
458
+ }
459
+ /** Track an order shipped event (carrier/fulfilment webhook). */
460
+ trackOrderShipped(orderId, shipping, opts) {
461
+ return this.send("ecommerce", "order_shipped", {
462
+ type: "ecommerce",
463
+ subtype: "order_shipped",
464
+ orderId,
465
+ shipping,
466
+ userId: opts?.userId,
467
+ sessionId: opts?.sessionId,
468
+ }, this.resolveVisitorId(opts, opts?.userId));
469
+ }
470
+ /** Track an order delivered event. */
471
+ trackOrderDelivered(orderId, deliveredAt, opts) {
472
+ return this.send("ecommerce", "order_delivered", {
473
+ type: "ecommerce",
474
+ subtype: "order_delivered",
475
+ orderId,
476
+ deliveredAt: deliveredAt ?? new Date().toISOString(),
477
+ userId: opts?.userId,
478
+ sessionId: opts?.sessionId,
479
+ }, this.resolveVisitorId(opts, opts?.userId));
480
+ }
481
+ /**
482
+ * Track a processed refund — fire from your payment provider's refund
483
+ * webhook (e.g. Stripe `charge.refunded`) so net revenue stays accurate.
484
+ */
485
+ trackRefund(input, opts) {
486
+ return this.send("ecommerce", "refund", {
487
+ type: "ecommerce",
488
+ subtype: "refund",
489
+ orderId: input.orderId,
490
+ refundId: input.refundId,
491
+ items: input.items,
492
+ amount: input.amount,
493
+ currency: input.currency,
494
+ reason: input.reason,
495
+ isFullRefund: input.isFullRefund,
496
+ userId: input.userId,
497
+ sessionId: opts?.sessionId,
498
+ initiatedBy: input.initiatedBy,
499
+ ...input.properties,
500
+ }, this.resolveVisitorId(opts, input.userId));
501
+ }
502
+ /**
503
+ * Track subscription lifecycle / MRR events — start, plan change, cancel,
504
+ * renew, trial conversion and trial expiry — from your billing webhooks.
505
+ * These power the MRR/churn dashboards; billing state lives on your
506
+ * server, so this is the authoritative place to emit them.
507
+ *
508
+ * @example
509
+ * ```ts
510
+ * // customer.subscription.created
511
+ * await fg.trackSubscription({
512
+ * event: "start",
513
+ * subscriptionId: sub.id,
514
+ * userId: customer.metadata.userId,
515
+ * plan: "professional",
516
+ * mrr: { amount: 9900, currency: "USD" },
517
+ * billingCycle: "monthly",
518
+ * });
519
+ *
520
+ * // customer.subscription.deleted
521
+ * await fg.trackSubscription({
522
+ * event: "cancel",
523
+ * subscriptionId: sub.id,
524
+ * userId,
525
+ * reason: sub.cancellation_details?.reason ?? "unknown",
526
+ * lostMrr: { amount: 9900, currency: "USD" },
527
+ * });
528
+ * ```
529
+ */
530
+ trackSubscription(input, opts) {
531
+ const subtype = SUBSCRIPTION_SUBTYPES[input.event];
532
+ const { event: _event, properties, ...fields } = input;
533
+ const payload = {
534
+ type: "ecommerce",
535
+ subtype,
536
+ sessionId: opts?.sessionId,
537
+ ...fields,
538
+ ...properties,
539
+ };
540
+ if (input.event === "start") {
541
+ payload.isTrialing = (input.trialDays ?? 0) > 0;
542
+ }
543
+ if (input.event === "change" && input.previousMrr && input.newMrr) {
544
+ payload.mrrChange = input.newMrr.amount - input.previousMrr.amount;
545
+ }
546
+ return this.send("ecommerce", subtype, payload, this.resolveVisitorId(opts, input.userId));
547
+ }
548
+ // ==========================================================================
549
+ // ERROR TRACKING — API routes + server exceptions
550
+ // ==========================================================================
551
+ /**
552
+ * Track a server-side error or exception as an `error_event`. Accepts a
553
+ * thrown value of any shape; pass `route`/`method`/`statusCode` for API
554
+ * errors so the dashboard can break failures down per endpoint.
555
+ *
556
+ * FlowGrid-internal notes (messages starting with `[FlowGrid]`) are
557
+ * filtered out, matching the browser SDK's own error filtering.
558
+ */
559
+ trackError(error, opts = {}) {
560
+ const normalized = normalizeError(error);
561
+ if (normalized.message.startsWith("[FlowGrid]") || normalized.message.startsWith("[Flowgrid]")) {
562
+ return Promise.resolve({ ok: false, reason: "flowgrid-internal" });
563
+ }
564
+ return this.send("error_event", "error_event", {
565
+ type: "error_event",
566
+ message: normalized.message,
567
+ stack: normalized.stack,
568
+ errorType: normalized.type,
569
+ fatal: opts.fatal ?? false,
570
+ context: opts.context ?? opts.route,
571
+ userId: opts.userId,
572
+ ...(opts.route ? { _api_route: opts.route } : {}),
573
+ ...(opts.method ? { _http_method: opts.method } : {}),
574
+ ...(opts.statusCode !== undefined ? { _status_code: opts.statusCode } : {}),
575
+ ...opts.properties,
576
+ }, this.resolveVisitorId(opts, opts.userId));
577
+ }
578
+ /**
579
+ * Wrap an async function (API route handler, job, webhook) so any thrown
580
+ * error is tracked before being rethrown — the failure still surfaces to
581
+ * the caller/framework exactly as before.
582
+ *
583
+ * @example
584
+ * ```ts
585
+ * export const PATCH = fg.withErrorTracking(
586
+ * async (req: NextRequest) => { … },
587
+ * { route: "/api/teams/edit/[teamId]", method: "PATCH" },
588
+ * );
589
+ * ```
590
+ */
591
+ withErrorTracking(fn, opts = {}) {
592
+ return async (...args) => {
593
+ try {
594
+ return await fn(...args);
595
+ }
596
+ catch (error) {
597
+ // Fire-and-forget: error delivery must never delay the rethrow.
598
+ void this.trackError(error, { ...opts, statusCode: opts.statusCode ?? 500 });
599
+ throw error;
600
+ }
601
+ };
602
+ }
603
+ /**
604
+ * Opt-in process-level capture: reports `uncaughtException` and
605
+ * `unhandledRejection` as fatal `error_event`s. Observes only — never
606
+ * swallows; Node's default crash behaviour is unchanged. Returns a
607
+ * detach function. No-op outside Node (edge runtimes have no `process.on`).
608
+ */
609
+ captureUncaught(opts = {}) {
610
+ if (typeof process === "undefined" || typeof process.on !== "function") {
611
+ return () => { };
612
+ }
613
+ const onException = (error) => {
614
+ void this.trackError(error, { ...opts, context: opts.context ?? "uncaughtException", fatal: true });
615
+ };
616
+ const onRejection = (reason) => {
617
+ void this.trackError(reason, { ...opts, context: opts.context ?? "unhandledRejection", fatal: true });
618
+ };
619
+ process.on("uncaughtException", onException);
620
+ process.on("unhandledRejection", onRejection);
621
+ return () => {
622
+ process.removeListener("uncaughtException", onException);
623
+ process.removeListener("unhandledRejection", onRejection);
624
+ };
625
+ }
626
+ // ==========================================================================
627
+ // TRANSPORT
628
+ // ==========================================================================
629
+ /**
630
+ * Visitor resolution, in preference order:
631
+ * 1. explicit per-call `visitorId` (the real browser visitor — best),
632
+ * 2. stable synthetic id derived from the userId (`fg_srv_<hash>`), so a
633
+ * user's server events always land on one visitor,
634
+ * 3. one per-process anonymous visitor (events with no identity at all).
635
+ */
636
+ resolveVisitorId(opts, userId) {
637
+ if (opts?.visitorId)
638
+ return opts.visitorId;
639
+ if (userId)
640
+ return `fg_srv_${fnv1a64(userId)}`;
641
+ if (!this.anonymousVisitorId) {
642
+ this.anonymousVisitorId = `fg_srv_anon_${randomHex(16)}`;
643
+ }
644
+ return this.anonymousVisitorId;
645
+ }
646
+ /** Context stamped on every server event — parallel to the browser device context. */
647
+ serverContext() {
648
+ const proc = typeof process !== "undefined" ? process : undefined;
649
+ return {
650
+ _server: true,
651
+ _runtime: proc?.release?.name ?? "node",
652
+ ...(proc?.version ? { _runtime_version: proc.version } : {}),
653
+ ...(this.environment ? { _environment: this.environment } : {}),
654
+ };
655
+ }
656
+ /**
657
+ * POST one event to the gateway (`?call=<type>`), with timeout, jittered
658
+ * retry on 5xx/network failure, and the never-throw guarantee.
659
+ */
660
+ async send(callType, eventName, properties, visitorId) {
661
+ let body;
662
+ try {
663
+ // Drop undefined values so payloads stay clean (JSON.stringify would
664
+ // drop them anyway, but this keeps the size check honest).
665
+ const cleaned = {};
666
+ for (const [key, value] of Object.entries(properties)) {
667
+ if (value !== undefined)
668
+ cleaned[key] = value;
669
+ }
670
+ body = JSON.stringify({
671
+ web_id: this.webId,
672
+ visitor_id: visitorId,
673
+ event_name: eventName,
674
+ properties: {
675
+ ...this.serverContext(),
676
+ ...cleaned,
677
+ timestamp: new Date().toISOString(),
678
+ },
679
+ });
680
+ }
681
+ catch {
682
+ this.noteFailure(`${eventName}: properties not JSON-serializable`);
683
+ return { ok: false, reason: "not-serializable" };
684
+ }
685
+ if (byteLength(body) > MAX_PAYLOAD_SIZE) {
686
+ this.noteFailure(`${eventName}: payload too large (max 10KB)`);
687
+ return { ok: false, reason: "payload-too-large" };
688
+ }
689
+ const url = `${this.endpoint}/api/v1/sdk/functions?call=${encodeURIComponent(callType)}`;
690
+ for (let attempt = 0;; attempt++) {
691
+ const controller = new AbortController();
692
+ const timeoutId = setTimeout(() => controller.abort(), this.timeoutMs);
693
+ try {
694
+ const res = await fetch(url, {
695
+ method: "POST",
696
+ headers: {
697
+ "Content-Type": "application/json",
698
+ "Authorization": `Bearer ${this.apiKey}`,
699
+ },
700
+ body,
701
+ signal: controller.signal,
702
+ });
703
+ clearTimeout(timeoutId);
704
+ if (res.status >= 500 && attempt < this.maxRetries) {
705
+ await backoff(attempt + 1);
706
+ continue;
707
+ }
708
+ if (!res.ok) {
709
+ this.noteFailure(`${eventName}: ${res.status} ${res.statusText}`);
710
+ return { ok: false, status: res.status, reason: `http-${res.status}` };
711
+ }
712
+ return { ok: true, status: res.status };
713
+ }
714
+ catch (error) {
715
+ clearTimeout(timeoutId);
716
+ if (attempt < this.maxRetries) {
717
+ await backoff(attempt + 1);
718
+ continue;
719
+ }
720
+ const reason = error instanceof Error && error.name === "AbortError"
721
+ ? "timed out"
722
+ : error instanceof Error ? error.message : String(error);
723
+ this.noteFailure(`${eventName}: ${reason}`);
724
+ return { ok: false, reason };
725
+ }
726
+ }
727
+ }
728
+ /**
729
+ * One quiet, non-throwing note per failure signature (same policy as the
730
+ * browser transport): a failed tracking request is FlowGrid's problem,
731
+ * not the host app's — it must never reach the customer's error tracking.
732
+ */
733
+ noteFailure(detail) {
734
+ if (FlowGridServer.failureNoted.has(detail))
735
+ return;
736
+ FlowGridServer.failureNoted.add(detail);
737
+ if (typeof console !== "undefined") {
738
+ console.debug(`[FlowGrid] Server event not delivered (${detail}) — FlowGrid never throws; this note is debug-only.`);
739
+ }
740
+ }
741
+ }
742
+ exports.FlowGridServer = FlowGridServer;
743
+ /** Singleton created by {@link FlowGridServer.init}. */
744
+ FlowGridServer._instance = null;
745
+ /** One quiet debug note per failure signature — mirrors the browser transport. */
746
+ FlowGridServer.failureNoted = new Set();
747
+ // ============================================================================
748
+ // INTERNALS
749
+ // ============================================================================
750
+ function normalizeError(error) {
751
+ if (error instanceof Error) {
752
+ return { message: error.message || String(error), stack: error.stack, type: error.name };
753
+ }
754
+ if (typeof error === "string")
755
+ return { message: error, type: "Error" };
756
+ if (error && typeof error === "object") {
757
+ const obj = error;
758
+ return {
759
+ message: typeof obj.message === "string" && obj.message ? obj.message : safeStringify(error),
760
+ stack: typeof obj.stack === "string" ? obj.stack : undefined,
761
+ type: typeof obj.name === "string" ? obj.name : "Error",
762
+ };
763
+ }
764
+ return { message: String(error), type: "Error" };
765
+ }
766
+ function safeStringify(value) {
767
+ try {
768
+ return JSON.stringify(value) ?? String(value);
769
+ }
770
+ catch {
771
+ return String(value);
772
+ }
773
+ }
774
+ function normalizeEndpoint(endpoint) {
775
+ let clean = endpoint.trim();
776
+ if (!clean.startsWith("http://") && !clean.startsWith("https://")) {
777
+ clean = `https://${clean}`;
778
+ }
779
+ return clean.replace(/\/+$/, "");
780
+ }
781
+ function byteLength(text) {
782
+ return new TextEncoder().encode(text).length;
783
+ }
784
+ /** Jittered exponential backoff (~1s, ~2s windows) — matches the browser transport. */
785
+ function backoff(attempt) {
786
+ const ceiling = Math.min(Math.pow(2, attempt - 1) * 1000, 30000);
787
+ return new Promise((resolve) => setTimeout(resolve, Math.random() * ceiling));
788
+ }
789
+ /** FNV-1a 64-bit — dependency-free stable hash for synthetic visitor ids. */
790
+ function fnv1a64(input) {
791
+ let hash = 0xcbf29ce484222325n;
792
+ const prime = 0x100000001b3n;
793
+ for (let i = 0; i < input.length; i++) {
794
+ hash ^= BigInt(input.charCodeAt(i));
795
+ hash = (hash * prime) & 0xffffffffffffffffn;
796
+ }
797
+ return hash.toString(16).padStart(16, "0");
798
+ }
799
+ function randomHex(length) {
800
+ const bytes = new Uint8Array(length);
801
+ if (typeof crypto !== "undefined" && typeof crypto.getRandomValues === "function") {
802
+ crypto.getRandomValues(bytes);
803
+ }
804
+ else {
805
+ for (let i = 0; i < length; i++)
806
+ bytes[i] = Math.floor(Math.random() * 256);
807
+ }
808
+ return Array.from(bytes, (b) => b.toString(16).padStart(2, "0")).join("");
809
+ }
810
+ exports.default = FlowGridServer;
811
+ //# sourceMappingURL=index.js.map