@gurulu/node 0.1.2 → 1.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.
Files changed (50) hide show
  1. package/LICENSE +22 -0
  2. package/README.md +64 -31
  3. package/dist/context.d.ts +12 -0
  4. package/dist/context.d.ts.map +1 -0
  5. package/dist/core.d.ts +60 -0
  6. package/dist/core.d.ts.map +1 -0
  7. package/dist/errors.d.ts +32 -0
  8. package/dist/errors.d.ts.map +1 -0
  9. package/dist/identify.d.ts +9 -0
  10. package/dist/identify.d.ts.map +1 -0
  11. package/dist/index.d.ts +13 -13
  12. package/dist/index.d.ts.map +1 -0
  13. package/dist/index.js +947 -28
  14. package/dist/middleware/express.d.ts +25 -0
  15. package/dist/middleware/express.d.ts.map +1 -0
  16. package/dist/middleware/express.js +66 -0
  17. package/dist/middleware/fastify.d.ts +20 -0
  18. package/dist/middleware/fastify.d.ts.map +1 -0
  19. package/dist/middleware/fastify.js +68 -0
  20. package/dist/middleware/next.d.ts +10 -0
  21. package/dist/middleware/next.d.ts.map +1 -0
  22. package/dist/middleware/next.js +69 -0
  23. package/dist/queue.d.ts +20 -0
  24. package/dist/queue.d.ts.map +1 -0
  25. package/dist/track.d.ts +10 -0
  26. package/dist/track.d.ts.map +1 -0
  27. package/dist/transport.d.ts +16 -0
  28. package/dist/transport.d.ts.map +1 -0
  29. package/dist/types.d.ts +129 -45
  30. package/dist/types.d.ts.map +1 -0
  31. package/dist/webhooks/custom.d.ts +30 -0
  32. package/dist/webhooks/custom.d.ts.map +1 -0
  33. package/dist/webhooks/custom.js +123 -0
  34. package/dist/webhooks/lemonsqueezy.d.ts +30 -0
  35. package/dist/webhooks/lemonsqueezy.d.ts.map +1 -0
  36. package/dist/webhooks/lemonsqueezy.js +140 -0
  37. package/dist/webhooks/shopify.d.ts +18 -0
  38. package/dist/webhooks/shopify.d.ts.map +1 -0
  39. package/dist/webhooks/shopify.js +142 -0
  40. package/dist/webhooks/stripe.d.ts +31 -0
  41. package/dist/webhooks/stripe.d.ts.map +1 -0
  42. package/dist/webhooks/stripe.js +160 -0
  43. package/package.json +97 -16
  44. package/dist/business-events.d.ts +0 -73
  45. package/dist/business-events.js +0 -111
  46. package/dist/client.d.ts +0 -150
  47. package/dist/client.js +0 -442
  48. package/dist/middleware.d.ts +0 -31
  49. package/dist/middleware.js +0 -138
  50. package/dist/types.js +0 -30
package/dist/index.js CHANGED
@@ -1,28 +1,947 @@
1
- "use strict";
2
- /**
3
- * @gurulu/node public entry point.
4
- *
5
- * Phase 10 W4.2: server SDK hardening adds batch+retry, deterministic
6
- * idempotency keys, dead-letter callbacks, and business event emitters.
7
- */
8
- Object.defineProperty(exports, "__esModule", { value: true });
9
- exports.leadCaptured = exports.orderPlaced = exports.subscriptionStarted = exports.paymentSucceeded = exports.userCreated = exports.honoErrorHandler = exports.guruluFastifyPlugin = exports.expressHandler = exports.expressErrorHandler = exports.setDefaultInstance = exports.addBreadcrumb = exports.captureException = exports.createIdempotencyKey = exports.Gurulu = void 0;
10
- var client_1 = require("./client");
11
- Object.defineProperty(exports, "Gurulu", { enumerable: true, get: function () { return client_1.Gurulu; } });
12
- Object.defineProperty(exports, "createIdempotencyKey", { enumerable: true, get: function () { return client_1.createIdempotencyKey; } });
13
- Object.defineProperty(exports, "captureException", { enumerable: true, get: function () { return client_1.captureException; } });
14
- Object.defineProperty(exports, "addBreadcrumb", { enumerable: true, get: function () { return client_1.addBreadcrumb; } });
15
- Object.defineProperty(exports, "setDefaultInstance", { enumerable: true, get: function () { return client_1.setDefaultInstance; } });
16
- // Framework error-handler middleware.
17
- var middleware_1 = require("./middleware");
18
- Object.defineProperty(exports, "expressErrorHandler", { enumerable: true, get: function () { return middleware_1.expressErrorHandler; } });
19
- Object.defineProperty(exports, "expressHandler", { enumerable: true, get: function () { return middleware_1.expressHandler; } });
20
- Object.defineProperty(exports, "guruluFastifyPlugin", { enumerable: true, get: function () { return middleware_1.guruluFastifyPlugin; } });
21
- Object.defineProperty(exports, "honoErrorHandler", { enumerable: true, get: function () { return middleware_1.honoErrorHandler; } });
22
- // Business event emitters (W4.2).
23
- var business_events_1 = require("./business-events");
24
- Object.defineProperty(exports, "userCreated", { enumerable: true, get: function () { return business_events_1.userCreated; } });
25
- Object.defineProperty(exports, "paymentSucceeded", { enumerable: true, get: function () { return business_events_1.paymentSucceeded; } });
26
- Object.defineProperty(exports, "subscriptionStarted", { enumerable: true, get: function () { return business_events_1.subscriptionStarted; } });
27
- Object.defineProperty(exports, "orderPlaced", { enumerable: true, get: function () { return business_events_1.orderPlaced; } });
28
- Object.defineProperty(exports, "leadCaptured", { enumerable: true, get: function () { return business_events_1.leadCaptured; } });
1
+ // src/context.ts
2
+ import { AsyncLocalStorage } from "node:async_hooks";
3
+ var contextStorage = new AsyncLocalStorage;
4
+ function runWithContext(ctx, fn) {
5
+ return contextStorage.run(ctx, fn);
6
+ }
7
+ function getContext() {
8
+ return contextStorage.getStore();
9
+ }
10
+ function mergeContext(explicit) {
11
+ const stored = contextStorage.getStore();
12
+ if (!stored && !explicit)
13
+ return {};
14
+ if (!stored)
15
+ return explicit ?? {};
16
+ if (!explicit)
17
+ return stored;
18
+ return {
19
+ ...stored,
20
+ ...explicit,
21
+ source_context: { ...stored.source_context, ...explicit.source_context },
22
+ consent_state: explicit.consent_state ?? stored.consent_state
23
+ };
24
+ }
25
+ function generateRequestId() {
26
+ return crypto.randomUUID();
27
+ }
28
+
29
+ // src/middleware/express.ts
30
+ function createExpressMiddleware(options = {}) {
31
+ const cookieName = options.anonymousIdCookie ?? "gurulu_aid";
32
+ const headerName = options.anonymousIdHeader ?? "x-gurulu-anonymous-id";
33
+ const emit = options.emitResponseHeader ?? true;
34
+ return function guruluExpressMiddleware(req, res, next) {
35
+ const headerVal = req.headers[headerName];
36
+ const headerAid = Array.isArray(headerVal) ? headerVal[0] : headerVal;
37
+ const cookieAid = req.cookies?.[cookieName];
38
+ const requestId = pickHeader(req.headers["x-request-id"]) ?? generateRequestId();
39
+ const traceId = pickHeader(req.headers["x-trace-id"]) ?? pickHeader(req.headers.traceparent);
40
+ const ctx = {
41
+ request_id: requestId
42
+ };
43
+ if (headerAid || cookieAid)
44
+ ctx.anonymous_id = headerAid ?? cookieAid;
45
+ if (traceId)
46
+ ctx.trace_id = traceId;
47
+ if (req.ip || req.socket?.remoteAddress)
48
+ ctx.ip = req.ip ?? req.socket?.remoteAddress;
49
+ const ua = pickHeader(req.headers["user-agent"]);
50
+ if (ua)
51
+ ctx.user_agent = ua;
52
+ if (req.hostname)
53
+ ctx.domain = req.hostname;
54
+ if (emit)
55
+ res.setHeader("x-gurulu-request-id", requestId);
56
+ runWithContext(ctx, () => next());
57
+ };
58
+ }
59
+ function pickHeader(v) {
60
+ if (Array.isArray(v))
61
+ return v[0];
62
+ return v;
63
+ }
64
+
65
+ // src/middleware/fastify.ts
66
+ function createFastifyPlugin(options = {}) {
67
+ const cookieName = options.anonymousIdCookie ?? "gurulu_aid";
68
+ const headerName = options.anonymousIdHeader ?? "x-gurulu-anonymous-id";
69
+ const emit = options.emitResponseHeader ?? true;
70
+ return async function guruluFastifyPlugin(fastify) {
71
+ fastify.addHook("onRequest", (req, reply) => {
72
+ return new Promise((resolve) => {
73
+ const headerVal = req.headers[headerName];
74
+ const headerAid = Array.isArray(headerVal) ? headerVal[0] : headerVal;
75
+ const cookieAid = req.cookies?.[cookieName];
76
+ const requestId = pickHeader2(req.headers["x-request-id"]) ?? generateRequestId();
77
+ const traceId = pickHeader2(req.headers["x-trace-id"]) ?? pickHeader2(req.headers.traceparent);
78
+ const ctx = { request_id: requestId };
79
+ if (headerAid || cookieAid)
80
+ ctx.anonymous_id = headerAid ?? cookieAid;
81
+ if (traceId)
82
+ ctx.trace_id = traceId;
83
+ if (req.ip)
84
+ ctx.ip = req.ip;
85
+ const ua = pickHeader2(req.headers["user-agent"]);
86
+ if (ua)
87
+ ctx.user_agent = ua;
88
+ if (req.hostname)
89
+ ctx.domain = req.hostname;
90
+ if (emit)
91
+ reply.header("x-gurulu-request-id", requestId);
92
+ runWithContext(ctx, () => resolve());
93
+ });
94
+ });
95
+ };
96
+ }
97
+ function pickHeader2(v) {
98
+ if (Array.isArray(v))
99
+ return v[0];
100
+ return v;
101
+ }
102
+
103
+ // src/middleware/next.ts
104
+ function createNextHandler(options = {}) {
105
+ const cookieName = options.anonymousIdCookie ?? "gurulu_aid";
106
+ const headerName = options.anonymousIdHeader ?? "x-gurulu-anonymous-id";
107
+ return function wrap(handler) {
108
+ return async function nextRouteHandler(req) {
109
+ const headerAid = req.headers.get(headerName) ?? undefined;
110
+ const cookieAid = readCookie(req.headers.get("cookie"), cookieName);
111
+ const requestId = req.headers.get("x-request-id") ?? generateRequestId();
112
+ const traceId = req.headers.get("x-trace-id") ?? req.headers.get("traceparent") ?? undefined;
113
+ const ctx = { request_id: requestId };
114
+ if (headerAid || cookieAid)
115
+ ctx.anonymous_id = headerAid ?? cookieAid;
116
+ if (traceId)
117
+ ctx.trace_id = traceId;
118
+ const ip = req.headers.get("x-forwarded-for")?.split(",")[0]?.trim();
119
+ if (ip)
120
+ ctx.ip = ip;
121
+ const ua = req.headers.get("user-agent") ?? undefined;
122
+ if (ua)
123
+ ctx.user_agent = ua;
124
+ try {
125
+ ctx.domain = new URL(req.url).host;
126
+ } catch {}
127
+ return await runWithContext(ctx, async () => handler(req, ctx));
128
+ };
129
+ };
130
+ }
131
+ function readCookie(cookieHeader, name) {
132
+ if (!cookieHeader)
133
+ return;
134
+ for (const part of cookieHeader.split(";")) {
135
+ const [k, v] = part.trim().split("=", 2);
136
+ if (k === name)
137
+ return v;
138
+ }
139
+ return;
140
+ }
141
+
142
+ // src/webhooks/custom.ts
143
+ import { createHmac, timingSafeEqual } from "node:crypto";
144
+
145
+ // src/errors.ts
146
+ class GuruluSDKError extends Error {
147
+ code;
148
+ constructor(code, message) {
149
+ super(message);
150
+ this.name = "GuruluSDKError";
151
+ this.code = code;
152
+ }
153
+ }
154
+
155
+ class NotInitializedError extends GuruluSDKError {
156
+ constructor() {
157
+ super("SDK_NOT_INITIALIZED", "Gurulu.init() must be called before track/identify");
158
+ }
159
+ }
160
+
161
+ class InvalidWorkspaceKeyError extends GuruluSDKError {
162
+ constructor(message) {
163
+ super("SDK_INVALID_WORKSPACE_KEY", message);
164
+ }
165
+ }
166
+
167
+ class WebhookSignatureError extends GuruluSDKError {
168
+ constructor(message) {
169
+ super("WEBHOOK_SIGNATURE_INVALID", message);
170
+ }
171
+ }
172
+
173
+ class WebhookMappingNotFoundError extends GuruluSDKError {
174
+ constructor(message) {
175
+ super("WEBHOOK_MAPPING_NOT_FOUND", message);
176
+ }
177
+ }
178
+
179
+ class TransportError extends GuruluSDKError {
180
+ status;
181
+ attempts;
182
+ constructor(message, attempts, status) {
183
+ super("SDK_TRANSPORT_ERROR", message);
184
+ this.attempts = attempts;
185
+ this.status = status;
186
+ }
187
+ }
188
+
189
+ class QueueOverflowError extends GuruluSDKError {
190
+ dropped;
191
+ constructor(dropped) {
192
+ super("SDK_QUEUE_OVERFLOW", `Queue overflow — dropped ${dropped} oldest events`);
193
+ this.dropped = dropped;
194
+ }
195
+ }
196
+
197
+ // src/webhooks/custom.ts
198
+ function defaultVerify(rawBody, signature, secret) {
199
+ const expected = createHmac("sha256", secret).update(rawBody, "utf8").digest("hex");
200
+ const actualBuf = Buffer.from(signature, "hex");
201
+ const expectedBuf = Buffer.from(expected, "hex");
202
+ if (actualBuf.length !== expectedBuf.length)
203
+ return false;
204
+ return timingSafeEqual(actualBuf, expectedBuf);
205
+ }
206
+ function toOutgoing(result) {
207
+ if (!result.event_key) {
208
+ throw new WebhookMappingNotFoundError("Custom mapPayload returned no event_key");
209
+ }
210
+ const consent = {
211
+ analytics: true,
212
+ marketing: true,
213
+ functional: true,
214
+ personalization: false,
215
+ source: "webhook"
216
+ };
217
+ const event = {
218
+ anonymous_id: result.anonymous_id ?? `custom_anon_${result.event_id ?? Date.now()}`,
219
+ event_id: result.event_id,
220
+ event_key: result.event_key,
221
+ event_type: "outcome",
222
+ occurred_at: result.occurred_at ?? new Date().toISOString(),
223
+ producer: "webhook",
224
+ producer_version: "0.1.0",
225
+ properties: result.properties ?? {},
226
+ consent_state: consent
227
+ };
228
+ if (result.person_id)
229
+ event.person_id = result.person_id;
230
+ return event;
231
+ }
232
+ function createCustomWebhook(config, enqueue, options) {
233
+ const headerName = options.signatureHeader.toLowerCase();
234
+ const verifier = options.verifySignature ?? defaultVerify;
235
+ return {
236
+ verify(rawBody, signature) {
237
+ return verifier(rawBody, signature, options.secret);
238
+ },
239
+ async handle(req) {
240
+ const sigRaw = req.headers[headerName];
241
+ const sig = Array.isArray(sigRaw) ? sigRaw[0] : sigRaw;
242
+ if (!sig) {
243
+ throw new WebhookSignatureError(`Header '${options.signatureHeader}' missing`);
244
+ }
245
+ if (!verifier(req.body, sig, options.secret)) {
246
+ throw new WebhookSignatureError("Custom webhook signature mismatch");
247
+ }
248
+ const payload = JSON.parse(req.body);
249
+ const mapped = options.mapPayload(payload);
250
+ const results = Array.isArray(mapped) ? mapped : [mapped];
251
+ const events = results.map(toOutgoing);
252
+ for (const e of events)
253
+ enqueue(e);
254
+ return {
255
+ events_emitted: events.length,
256
+ vendor: "custom",
257
+ event_keys: events.map((e) => e.event_key)
258
+ };
259
+ }
260
+ };
261
+ }
262
+
263
+ // src/webhooks/lemonsqueezy.ts
264
+ import { createHmac as createHmac2, timingSafeEqual as timingSafeEqual2 } from "node:crypto";
265
+ var LEMONSQUEEZY_DEFAULT_MAPPING = {
266
+ order_created: "purchase_completed",
267
+ order_refunded: "refund_processed",
268
+ subscription_created: "subscription_started",
269
+ subscription_updated: "subscription_updated",
270
+ subscription_cancelled: "subscription_cancelled",
271
+ subscription_payment_success: "payment_succeeded"
272
+ };
273
+ function verifyLemonSqueezy(rawBody, signatureHeader, secret) {
274
+ if (!signatureHeader)
275
+ throw new WebhookSignatureError("X-Signature header missing");
276
+ const expected = createHmac2("sha256", secret).update(rawBody, "utf8").digest("hex");
277
+ const actualBuf = Buffer.from(signatureHeader, "hex");
278
+ const expectedBuf = Buffer.from(expected, "hex");
279
+ if (actualBuf.length !== expectedBuf.length || !timingSafeEqual2(actualBuf, expectedBuf)) {
280
+ throw new WebhookSignatureError("Lemon Squeezy signature mismatch");
281
+ }
282
+ }
283
+ function mapLemonSqueezyEvent(payload, options = {}) {
284
+ const eventName = payload.meta?.event_name ?? "";
285
+ const mapping = { ...LEMONSQUEEZY_DEFAULT_MAPPING, ...options.customMapping ?? {} };
286
+ const eventKey = mapping[eventName];
287
+ if (!eventKey) {
288
+ throw new WebhookMappingNotFoundError(`Lemon Squeezy event_name '${eventName}' has no Gurulu mapping`);
289
+ }
290
+ const attr = payload.data?.attributes ?? {};
291
+ const objectId = payload.data?.id ?? "unknown";
292
+ const occurredAt = typeof attr.updated_at === "string" ? attr.updated_at : typeof attr.created_at === "string" ? attr.created_at : new Date().toISOString();
293
+ const customerId = typeof attr.customer_id === "string" || typeof attr.customer_id === "number" ? String(attr.customer_id) : undefined;
294
+ const props = {
295
+ lemonsqueezy_event_name: eventName,
296
+ lemonsqueezy_object_id: objectId,
297
+ lemonsqueezy_object_type: payload.data?.type
298
+ };
299
+ if (typeof attr.total === "number")
300
+ props.amount = attr.total / 100;
301
+ if (typeof attr.currency === "string")
302
+ props.currency = attr.currency;
303
+ if (typeof attr.status === "string")
304
+ props.status = attr.status;
305
+ const consent = {
306
+ analytics: true,
307
+ marketing: true,
308
+ functional: true,
309
+ personalization: false,
310
+ source: "webhook"
311
+ };
312
+ return {
313
+ anonymous_id: customerId ? `lemonsqueezy_${customerId}` : `lemonsqueezy_anon_${objectId}`,
314
+ event_id: `lemonsqueezy_${eventName}_${objectId}`,
315
+ event_key: eventKey,
316
+ event_type: "outcome",
317
+ occurred_at: occurredAt,
318
+ producer: "webhook",
319
+ producer_version: "0.1.0",
320
+ properties: props,
321
+ consent_state: consent
322
+ };
323
+ }
324
+ function createLemonSqueezyHandler(config, enqueue) {
325
+ return {
326
+ verify: verifyLemonSqueezy,
327
+ map: mapLemonSqueezyEvent,
328
+ async handle(req, secret, options) {
329
+ const sigHeader = req.headers["x-signature"];
330
+ const sig = Array.isArray(sigHeader) ? sigHeader[0] : sigHeader;
331
+ verifyLemonSqueezy(req.body, sig, secret);
332
+ const payload = JSON.parse(req.body);
333
+ const event = mapLemonSqueezyEvent(payload, options);
334
+ enqueue(event);
335
+ return {
336
+ events_emitted: 1,
337
+ vendor: "lemonsqueezy",
338
+ event_keys: [event.event_key]
339
+ };
340
+ }
341
+ };
342
+ }
343
+
344
+ // src/webhooks/shopify.ts
345
+ import { createHmac as createHmac3, timingSafeEqual as timingSafeEqual3 } from "node:crypto";
346
+ var SHOPIFY_DEFAULT_MAPPING = {
347
+ "orders/create": "purchase_completed",
348
+ "orders/paid": "purchase_completed",
349
+ "orders/fulfilled": "order_fulfilled",
350
+ "orders/cancelled": "order_cancelled",
351
+ "customers/create": "signup_completed",
352
+ "carts/create": "cart_started",
353
+ "carts/update": "cart_updated",
354
+ "checkouts/create": "checkout_started"
355
+ };
356
+ function verifyShopify(rawBody, signatureHeader, secret) {
357
+ if (!signatureHeader)
358
+ throw new WebhookSignatureError("X-Shopify-Hmac-Sha256 header missing");
359
+ const expected = createHmac3("sha256", secret).update(rawBody, "utf8").digest("base64");
360
+ const actualBuf = Buffer.from(signatureHeader, "base64");
361
+ const expectedBuf = Buffer.from(expected, "base64");
362
+ if (actualBuf.length !== expectedBuf.length || !timingSafeEqual3(actualBuf, expectedBuf)) {
363
+ throw new WebhookSignatureError("Shopify HMAC mismatch");
364
+ }
365
+ }
366
+ function mapShopifyEvent(topic, payload, options = {}) {
367
+ if (!topic)
368
+ throw new WebhookMappingNotFoundError("X-Shopify-Topic header required");
369
+ const mapping = { ...SHOPIFY_DEFAULT_MAPPING, ...options.customMapping ?? {} };
370
+ const eventKey = mapping[topic];
371
+ if (!eventKey) {
372
+ throw new WebhookMappingNotFoundError(`Shopify topic '${topic}' has no Gurulu mapping`);
373
+ }
374
+ const occurredAt = typeof payload.updated_at === "string" ? payload.updated_at : typeof payload.created_at === "string" ? payload.created_at : new Date().toISOString();
375
+ const customer = typeof payload.customer === "object" && payload.customer !== null ? payload.customer.id : undefined;
376
+ const props = {
377
+ shopify_topic: topic,
378
+ shopify_object_id: payload.id
379
+ };
380
+ if (typeof payload.total_price === "string")
381
+ props.amount = Number(payload.total_price);
382
+ if (typeof payload.currency === "string")
383
+ props.currency = payload.currency;
384
+ if (typeof payload.financial_status === "string")
385
+ props.status = payload.financial_status;
386
+ const consent = {
387
+ analytics: true,
388
+ marketing: true,
389
+ functional: true,
390
+ personalization: false,
391
+ source: "webhook"
392
+ };
393
+ return {
394
+ anonymous_id: customer ? `shopify_${customer}` : `shopify_anon_${payload.id ?? "unknown"}`,
395
+ event_id: typeof payload.id === "string" || typeof payload.id === "number" ? `shopify_${topic.replace("/", "_")}_${payload.id}` : undefined,
396
+ event_key: eventKey,
397
+ event_type: "outcome",
398
+ occurred_at: occurredAt,
399
+ producer: "webhook",
400
+ producer_version: "0.1.0",
401
+ properties: props,
402
+ consent_state: consent
403
+ };
404
+ }
405
+ function createShopifyHandler(config, enqueue) {
406
+ return {
407
+ verify: verifyShopify,
408
+ map: mapShopifyEvent,
409
+ async handle(req, secret, options) {
410
+ const sigHeader = req.headers["x-shopify-hmac-sha256"];
411
+ const topicHeader = req.headers["x-shopify-topic"];
412
+ const sig = Array.isArray(sigHeader) ? sigHeader[0] : sigHeader;
413
+ const topic = Array.isArray(topicHeader) ? topicHeader[0] : topicHeader;
414
+ verifyShopify(req.body, sig, secret);
415
+ const payload = JSON.parse(req.body);
416
+ const event = mapShopifyEvent(topic, payload, options);
417
+ enqueue(event);
418
+ return {
419
+ events_emitted: 1,
420
+ vendor: "shopify",
421
+ event_keys: [event.event_key]
422
+ };
423
+ }
424
+ };
425
+ }
426
+
427
+ // src/webhooks/stripe.ts
428
+ import { createHmac as createHmac4, timingSafeEqual as timingSafeEqual4 } from "node:crypto";
429
+ var STRIPE_REPLAY_TOLERANCE_SECONDS = 300;
430
+ var STRIPE_DEFAULT_MAPPING = {
431
+ "charge.succeeded": "purchase_completed",
432
+ "charge.refunded": "refund_processed",
433
+ "payment_intent.succeeded": "payment_succeeded",
434
+ "invoice.paid": "payment_succeeded",
435
+ "invoice.payment_failed": "payment_failed",
436
+ "customer.subscription.created": "subscription_started",
437
+ "customer.subscription.updated": "subscription_updated",
438
+ "customer.subscription.deleted": "subscription_cancelled",
439
+ "checkout.session.completed": "checkout_completed"
440
+ };
441
+ function verifyStripe(rawBody, signatureHeader, secret, now = () => new Date) {
442
+ if (!signatureHeader)
443
+ throw new WebhookSignatureError("Stripe-Signature header missing");
444
+ let timestamp = null;
445
+ let v1 = null;
446
+ for (const part of signatureHeader.split(",").map((s) => s.trim())) {
447
+ const [k, v] = part.split("=", 2);
448
+ if (k === "t")
449
+ timestamp = Number(v);
450
+ if (k === "v1")
451
+ v1 = v ?? null;
452
+ }
453
+ if (!timestamp || !v1)
454
+ throw new WebhookSignatureError("Stripe-Signature missing t= or v1=");
455
+ if (Math.abs(now().getTime() / 1000 - timestamp) > STRIPE_REPLAY_TOLERANCE_SECONDS) {
456
+ throw new WebhookSignatureError("Stripe-Signature timestamp out of tolerance");
457
+ }
458
+ const expected = createHmac4("sha256", secret).update(`${timestamp}.${rawBody}`).digest("hex");
459
+ const actualBuf = Buffer.from(v1, "hex");
460
+ const expectedBuf = Buffer.from(expected, "hex");
461
+ if (actualBuf.length !== expectedBuf.length || !timingSafeEqual4(actualBuf, expectedBuf)) {
462
+ throw new WebhookSignatureError("Stripe-Signature mismatch");
463
+ }
464
+ }
465
+ function mapStripeEvent(payload, options = {}) {
466
+ const type = payload.type ?? "";
467
+ const mapping = { ...STRIPE_DEFAULT_MAPPING, ...options.customMapping ?? {} };
468
+ const eventKey = mapping[type];
469
+ if (!eventKey) {
470
+ throw new WebhookMappingNotFoundError(`Stripe event type '${type}' has no Gurulu mapping`);
471
+ }
472
+ const obj = payload.data?.object ?? {};
473
+ const customer = typeof obj.customer === "string" ? obj.customer : undefined;
474
+ const occurredAt = payload.created ? new Date(payload.created * 1000).toISOString() : new Date().toISOString();
475
+ const props = {
476
+ stripe_event_id: payload.id,
477
+ stripe_event_type: type
478
+ };
479
+ if (typeof obj.amount === "number")
480
+ props.amount = obj.amount / 100;
481
+ if (typeof obj.currency === "string")
482
+ props.currency = obj.currency.toUpperCase();
483
+ if (typeof obj.id === "string")
484
+ props.stripe_object_id = obj.id;
485
+ if (typeof obj.status === "string")
486
+ props.status = obj.status;
487
+ const consent = {
488
+ analytics: true,
489
+ marketing: true,
490
+ functional: true,
491
+ personalization: false,
492
+ source: "webhook"
493
+ };
494
+ const event = {
495
+ anonymous_id: customer ? `stripe_${customer}` : `stripe_anon_${payload.id ?? "unknown"}`,
496
+ event_id: payload.id,
497
+ event_key: eventKey,
498
+ event_type: "outcome",
499
+ occurred_at: occurredAt,
500
+ producer: "webhook",
501
+ producer_version: "0.1.0",
502
+ properties: props,
503
+ consent_state: consent
504
+ };
505
+ return event;
506
+ }
507
+ function createStripeHandler(config, enqueue) {
508
+ return {
509
+ verify: verifyStripe,
510
+ map: mapStripeEvent,
511
+ async handle(req, secret, options) {
512
+ const sigHeader = req.headers["stripe-signature"];
513
+ const sig = Array.isArray(sigHeader) ? sigHeader[0] : sigHeader;
514
+ verifyStripe(req.body, sig, secret, options?.now);
515
+ const payload = JSON.parse(req.body);
516
+ const event = mapStripeEvent(payload, options);
517
+ enqueue(event);
518
+ return {
519
+ events_emitted: 1,
520
+ vendor: "stripe",
521
+ event_keys: [event.event_key]
522
+ };
523
+ }
524
+ };
525
+ }
526
+ // src/identify.ts
527
+ function buildIdentify(input) {
528
+ const ctx = mergeContext(input.explicitContext);
529
+ const traits = input.traits ?? {};
530
+ const { email, phone, ...rest } = traits;
531
+ const payload = {
532
+ anonymous_id: ctx.anonymous_id ?? `srv_anon_${input.externalUserId}`,
533
+ external_user_id: input.externalUserId,
534
+ traits: rest
535
+ };
536
+ if (typeof email === "string" && email.length > 0)
537
+ payload.email = email;
538
+ if (typeof phone === "string" && phone.length > 0)
539
+ payload.phone = phone;
540
+ const consent = ctx.consent_state ?? input.config.defaultConsent;
541
+ if (ctx.domain || consent) {
542
+ payload.context = {};
543
+ if (ctx.domain)
544
+ payload.context.domain = ctx.domain;
545
+ if (consent)
546
+ payload.context.consent_state = consent;
547
+ }
548
+ return payload;
549
+ }
550
+
551
+ // src/transport.ts
552
+ var USER_AGENT = `gurulu-node/0.1.0 (${process.platform}; node ${process.version})`;
553
+ function sleep(ms) {
554
+ return new Promise((resolve) => setTimeout(resolve, ms));
555
+ }
556
+ function shouldRetry(status, attempt, maxAttempts) {
557
+ if (attempt >= maxAttempts)
558
+ return false;
559
+ if (status === undefined)
560
+ return true;
561
+ if (status === 429)
562
+ return true;
563
+ if (status >= 500 && status < 600)
564
+ return true;
565
+ return false;
566
+ }
567
+ async function postWithRetry(config, input) {
568
+ const url = `${config.endpoint.replace(/\/$/, "")}${input.path}`;
569
+ const payload = JSON.stringify(input.body);
570
+ let lastStatus;
571
+ let lastErr;
572
+ for (let attempt = 1;attempt <= config.retryMaxAttempts; attempt++) {
573
+ try {
574
+ const res = await config.fetchImpl(url, {
575
+ method: "POST",
576
+ headers: {
577
+ "content-type": "application/json",
578
+ "user-agent": USER_AGENT,
579
+ "x-gurulu-key": config.workspaceKey
580
+ },
581
+ body: payload
582
+ });
583
+ lastStatus = res.status;
584
+ const text = await res.text();
585
+ const parsed = text ? safeJsonParse(text) : null;
586
+ if (res.ok) {
587
+ return { ok: true, status: res.status, body: parsed };
588
+ }
589
+ if (!shouldRetry(res.status, attempt, config.retryMaxAttempts)) {
590
+ return { ok: false, status: res.status, body: parsed };
591
+ }
592
+ } catch (err) {
593
+ lastErr = err;
594
+ if (!shouldRetry(undefined, attempt, config.retryMaxAttempts)) {
595
+ throw new TransportError(err instanceof Error ? err.message : String(err), attempt, undefined);
596
+ }
597
+ }
598
+ const backoffMs = config.retryBackoffBaseMs * 2 ** (attempt - 1);
599
+ await sleep(backoffMs);
600
+ }
601
+ throw new TransportError(lastErr instanceof Error ? lastErr.message : `Transport failed status=${lastStatus}`, config.retryMaxAttempts, lastStatus);
602
+ }
603
+ function safeJsonParse(text) {
604
+ try {
605
+ return JSON.parse(text);
606
+ } catch {
607
+ return text;
608
+ }
609
+ }
610
+ async function sendBatch(config, events) {
611
+ if (events.length === 0)
612
+ return;
613
+ if (config.testMode) {
614
+ console.log("[gurulu:test_mode] batch", JSON.stringify({ count: events.length, events }));
615
+ return;
616
+ }
617
+ await postWithRetry(config, {
618
+ path: "/v1/ingest/batch",
619
+ body: { events }
620
+ });
621
+ }
622
+ async function sendIdentify(config, payload) {
623
+ if (config.testMode) {
624
+ console.log("[gurulu:test_mode] identify", JSON.stringify(payload));
625
+ return { ok: true, status: 200, body: { test_mode: true } };
626
+ }
627
+ return postWithRetry(config, { path: "/v1/ingest/identify", body: payload });
628
+ }
629
+
630
+ // src/queue.ts
631
+ class EventQueue {
632
+ config;
633
+ buffer = [];
634
+ timer = null;
635
+ flushing = false;
636
+ droppedTotal = 0;
637
+ closed = false;
638
+ constructor(config) {
639
+ this.config = config;
640
+ }
641
+ enqueue(event) {
642
+ if (this.closed)
643
+ return { queued: false, dropped: 0 };
644
+ let dropped = 0;
645
+ if (this.buffer.length >= this.config.maxQueueSize) {
646
+ const removed = this.buffer.shift();
647
+ if (removed) {
648
+ dropped = 1;
649
+ this.droppedTotal += 1;
650
+ console.warn("[gurulu] queue overflow — dropped oldest event", JSON.stringify({ event_key: removed.event_key, dropped_total: this.droppedTotal }));
651
+ }
652
+ }
653
+ this.buffer.push(event);
654
+ this.scheduleFlush();
655
+ if (this.buffer.length >= this.config.maxBatchSize) {
656
+ this.flush();
657
+ }
658
+ return { queued: true, dropped };
659
+ }
660
+ size() {
661
+ return this.buffer.length;
662
+ }
663
+ scheduleFlush() {
664
+ if (this.timer || this.closed)
665
+ return;
666
+ this.timer = setTimeout(() => {
667
+ this.timer = null;
668
+ this.flush();
669
+ }, this.config.flushIntervalMs);
670
+ if (typeof this.timer.unref === "function")
671
+ this.timer.unref();
672
+ }
673
+ async flush() {
674
+ if (this.flushing)
675
+ return { attempted: 0, succeeded: 0, dropped: 0 };
676
+ this.flushing = true;
677
+ if (this.timer) {
678
+ clearTimeout(this.timer);
679
+ this.timer = null;
680
+ }
681
+ const batch = this.buffer.splice(0, this.config.maxBatchSize);
682
+ if (batch.length === 0) {
683
+ this.flushing = false;
684
+ return { attempted: 0, succeeded: 0, dropped: 0 };
685
+ }
686
+ try {
687
+ await sendBatch(this.config, batch);
688
+ this.flushing = false;
689
+ if (this.buffer.length > 0)
690
+ this.scheduleFlush();
691
+ return { attempted: batch.length, succeeded: batch.length, dropped: 0 };
692
+ } catch (err) {
693
+ this.flushing = false;
694
+ console.error("[gurulu] batch flush failed (dropping batch)", err instanceof Error ? err.message : String(err));
695
+ this.droppedTotal += batch.length;
696
+ return { attempted: batch.length, succeeded: 0, dropped: batch.length };
697
+ }
698
+ }
699
+ async drain(timeoutMs = 1e4) {
700
+ this.closed = true;
701
+ if (this.timer) {
702
+ clearTimeout(this.timer);
703
+ this.timer = null;
704
+ }
705
+ const deadline = Date.now() + timeoutMs;
706
+ let attempted = 0;
707
+ let succeeded = 0;
708
+ let dropped = 0;
709
+ while (this.buffer.length > 0 && Date.now() < deadline) {
710
+ const res = await this.flush();
711
+ attempted += res.attempted;
712
+ succeeded += res.succeeded;
713
+ dropped += res.dropped;
714
+ if (res.attempted === 0)
715
+ break;
716
+ }
717
+ if (this.buffer.length > 0) {
718
+ dropped += this.buffer.length;
719
+ console.warn("[gurulu] shutdown timeout — dropped events", this.buffer.length);
720
+ this.buffer.length = 0;
721
+ }
722
+ return { attempted, succeeded, dropped };
723
+ }
724
+ }
725
+
726
+ // src/track.ts
727
+ var counter = 0;
728
+ function genEventId() {
729
+ counter = (counter + 1) % 65535;
730
+ return `${Date.now().toString(36)}-${counter.toString(36)}-${crypto.randomUUID().slice(0, 12)}`;
731
+ }
732
+ function buildEvent(input) {
733
+ const ctx = mergeContext(input.explicitContext);
734
+ const eventType = input.eventType ?? "outcome";
735
+ const utm = ctx.source_context;
736
+ const hasUtm = utm && Object.keys(utm).length > 0;
737
+ const event = {
738
+ anonymous_id: ctx.anonymous_id ?? `srv_anon_${genEventId()}`,
739
+ event_id: genEventId(),
740
+ event_key: input.eventKey,
741
+ event_type: eventType,
742
+ occurred_at: new Date().toISOString(),
743
+ producer: "sdk_server",
744
+ producer_version: "0.1.0",
745
+ properties: input.properties ?? {},
746
+ consent_state: ctx.consent_state ?? input.config.defaultConsent
747
+ };
748
+ if (ctx.person_id)
749
+ event.person_id = ctx.person_id;
750
+ if (ctx.session_id)
751
+ event.session_id = ctx.session_id;
752
+ const ctxObj = {};
753
+ if (ctx.ip)
754
+ ctxObj.ip = ctx.ip;
755
+ if (ctx.user_agent)
756
+ ctxObj.user_agent = ctx.user_agent;
757
+ if (ctx.domain)
758
+ ctxObj.domain = ctx.domain;
759
+ if (ctx.request_id)
760
+ ctxObj.request_id = ctx.request_id;
761
+ if (ctx.trace_id)
762
+ ctxObj.trace_id = ctx.trace_id;
763
+ if (hasUtm && utm)
764
+ ctxObj.utm = utm;
765
+ if (Object.keys(ctxObj).length > 0)
766
+ event.context = ctxObj;
767
+ return event;
768
+ }
769
+
770
+ // src/core.ts
771
+ var DEFAULT_ENDPOINT = "https://ingest.gurulu.io";
772
+ var DEFAULT_CONSENT = {
773
+ analytics: true,
774
+ marketing: true,
775
+ functional: true,
776
+ personalization: false,
777
+ source: "api"
778
+ };
779
+ function resolveConfig(input) {
780
+ if (!input.workspaceKey || typeof input.workspaceKey !== "string") {
781
+ throw new InvalidWorkspaceKeyError("workspaceKey required (sk_xxx format)");
782
+ }
783
+ if (!input.workspaceKey.startsWith("sk_")) {
784
+ throw new InvalidWorkspaceKeyError(`workspaceKey must start with 'sk_' (got prefix ${input.workspaceKey.slice(0, 3)})`);
785
+ }
786
+ const fetchImpl = input.fetchImpl ?? globalThis.fetch;
787
+ if (!fetchImpl) {
788
+ throw new Error("No fetch implementation available. Use Node 20+ or pass `fetchImpl` (e.g. undici).");
789
+ }
790
+ return {
791
+ workspaceKey: input.workspaceKey,
792
+ endpoint: input.endpoint ?? DEFAULT_ENDPOINT,
793
+ flushIntervalMs: input.flushIntervalMs ?? 2000,
794
+ maxBatchSize: input.maxBatchSize ?? 100,
795
+ maxQueueSize: input.maxQueueSize ?? 1000,
796
+ retryMaxAttempts: input.retryMaxAttempts ?? 5,
797
+ retryBackoffBaseMs: input.retryBackoffBaseMs ?? 100,
798
+ testMode: input.testMode ?? false,
799
+ defaultConsent: input.defaultConsent ?? DEFAULT_CONSENT,
800
+ fetchImpl: fetchImpl.bind(globalThis)
801
+ };
802
+ }
803
+
804
+ class Gurulu {
805
+ config = null;
806
+ queue = null;
807
+ sigtermBound = false;
808
+ sigtermHandler = null;
809
+ constructor(initial) {
810
+ if (initial)
811
+ this.init(initial);
812
+ }
813
+ init(input) {
814
+ if (this.config)
815
+ return;
816
+ this.config = resolveConfig(input);
817
+ this.queue = new EventQueue(this.config);
818
+ this.bindSignals();
819
+ }
820
+ requireConfig() {
821
+ if (!this.config || !this.queue)
822
+ throw new NotInitializedError;
823
+ return this.config;
824
+ }
825
+ async identify(externalUserId, traits, explicitContext) {
826
+ const config = this.requireConfig();
827
+ const payload = buildIdentify({ config, externalUserId, traits, explicitContext });
828
+ const res = await sendIdentify(config, payload);
829
+ return { ok: res.ok, status: res.status, body: res.body };
830
+ }
831
+ track(eventKey, properties, explicitContext, eventType = "outcome") {
832
+ const config = this.requireConfig();
833
+ const event = buildEvent({
834
+ config,
835
+ eventKey,
836
+ eventType,
837
+ properties,
838
+ explicitContext
839
+ });
840
+ const res = this.queue?.enqueue(event) ?? { queued: false, dropped: 0 };
841
+ return { queued: res.queued, queueSize: this.queue?.size() ?? 0 };
842
+ }
843
+ async flush() {
844
+ this.requireConfig();
845
+ return await this.queue?.flush() ?? { attempted: 0, succeeded: 0, dropped: 0 };
846
+ }
847
+ async shutdown(timeoutMs = 1e4) {
848
+ if (!this.queue)
849
+ return { attempted: 0, succeeded: 0, dropped: 0 };
850
+ const result = await this.queue.drain(timeoutMs);
851
+ this.unbindSignals();
852
+ this.queue = null;
853
+ this.config = null;
854
+ return result;
855
+ }
856
+ bindSignals() {
857
+ if (this.sigtermBound)
858
+ return;
859
+ if (typeof process === "undefined" || typeof process.once !== "function")
860
+ return;
861
+ this.sigtermHandler = () => {
862
+ this.shutdown();
863
+ };
864
+ process.once("SIGTERM", this.sigtermHandler);
865
+ process.once("SIGINT", this.sigtermHandler);
866
+ this.sigtermBound = true;
867
+ }
868
+ unbindSignals() {
869
+ if (!this.sigtermBound || !this.sigtermHandler)
870
+ return;
871
+ if (typeof process !== "undefined" && typeof process.off === "function") {
872
+ process.off("SIGTERM", this.sigtermHandler);
873
+ process.off("SIGINT", this.sigtermHandler);
874
+ }
875
+ this.sigtermBound = false;
876
+ this.sigtermHandler = null;
877
+ }
878
+ get webhooks() {
879
+ const config = this.requireConfig();
880
+ const enqueue = (event) => {
881
+ this.queue?.enqueue(event);
882
+ };
883
+ return {
884
+ stripe: createStripeHandler(config, enqueue),
885
+ shopify: createShopifyHandler(config, enqueue),
886
+ lemonsqueezy: createLemonSqueezyHandler(config, enqueue),
887
+ custom: (options) => createCustomWebhook(config, enqueue, options)
888
+ };
889
+ }
890
+ get express() {
891
+ return { middleware: (opts) => createExpressMiddleware(opts) };
892
+ }
893
+ get fastify() {
894
+ return { plugin: (opts) => createFastifyPlugin(opts) };
895
+ }
896
+ get next() {
897
+ return { handler: createNextHandler };
898
+ }
899
+ }
900
+ function createGurulu(config) {
901
+ return new Gurulu(config);
902
+ }
903
+ var singleton = null;
904
+ function getDefault() {
905
+ if (!singleton)
906
+ singleton = new Gurulu;
907
+ return singleton;
908
+ }
909
+ function initDefault(config) {
910
+ const inst = getDefault();
911
+ inst.init(config);
912
+ return inst;
913
+ }
914
+
915
+ // src/index.ts
916
+ var VERSION = "0.1.0";
917
+ export {
918
+ verifyStripe,
919
+ verifyShopify,
920
+ verifyLemonSqueezy,
921
+ runWithContext,
922
+ mergeContext,
923
+ mapStripeEvent,
924
+ mapShopifyEvent,
925
+ mapLemonSqueezyEvent,
926
+ initDefault,
927
+ getDefault,
928
+ getContext,
929
+ createNextHandler,
930
+ createGurulu,
931
+ createFastifyPlugin,
932
+ createExpressMiddleware,
933
+ contextStorage,
934
+ WebhookSignatureError,
935
+ WebhookMappingNotFoundError,
936
+ VERSION,
937
+ TransportError,
938
+ STRIPE_REPLAY_TOLERANCE_SECONDS,
939
+ STRIPE_DEFAULT_MAPPING,
940
+ SHOPIFY_DEFAULT_MAPPING,
941
+ QueueOverflowError,
942
+ NotInitializedError,
943
+ LEMONSQUEEZY_DEFAULT_MAPPING,
944
+ InvalidWorkspaceKeyError,
945
+ GuruluSDKError,
946
+ Gurulu
947
+ };