@funnelsgrove/runtime 0.1.60 → 0.1.61

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.
@@ -1,8 +1,9 @@
1
- import { buildSdkHeaders, FUNNEL_ID, getFunnelSdkPublishableKey, } from './runtime-api.config.js';
1
+ import { buildSdkHeaders, FUNNEL_ID, FUNNEL_VERSION_ID, getFunnelSdkPublishableKey, } from './runtime-api.config.js';
2
2
  import { funnelSdkService } from './funnel-sdk.service.js';
3
3
  import { mergeFunnelUserAttribution, } from '../runtime/funnel-attribution.js';
4
4
  import { applyUrlUserAttributesToUser, hasUrlUserProfileAttributes, resolveUrlUserAttributes, } from '../runtime/url-user-attributes.js';
5
5
  const DEFAULT_USER_ID_STORAGE_KEY = 'funnel:user-id';
6
+ const DEFAULT_BOOTSTRAP_IDEMPOTENCY_KEY_STORAGE_KEY = 'funnel:bootstrap-idempotency-key';
6
7
  const DEFAULT_FUNNEL_DOMAIN_SIGNAL = 'funnelsgrove';
7
8
  const canUseDom = () => {
8
9
  return typeof window !== 'undefined';
@@ -102,6 +103,100 @@ const buildUserIdStorageKey = (funnelId = FUNNEL_ID) => {
102
103
  }
103
104
  return `funnel:${normalizedFunnelId}:user-id`;
104
105
  };
106
+ const buildBootstrapIdempotencyKeyStorageKey = (funnelId = FUNNEL_ID) => {
107
+ const normalizedFunnelId = normalizeFunnelId(funnelId);
108
+ return normalizedFunnelId
109
+ ? `funnel:${normalizedFunnelId}:bootstrap-idempotency-key`
110
+ : DEFAULT_BOOTSTRAP_IDEMPOTENCY_KEY_STORAGE_KEY;
111
+ };
112
+ const generateBootstrapIdempotencyKey = () => {
113
+ var _a;
114
+ if (canUseDom() && typeof ((_a = window.crypto) === null || _a === void 0 ? void 0 : _a.randomUUID) === 'function') {
115
+ return window.crypto.randomUUID();
116
+ }
117
+ return `${Date.now()}_${Math.random().toString(16).slice(2, 10)}`;
118
+ };
119
+ const parseBootstrapIdempotencyRecord = (value) => {
120
+ if (!value) {
121
+ return null;
122
+ }
123
+ try {
124
+ const parsed = JSON.parse(value);
125
+ const key = asString(parsed.key);
126
+ const fingerprint = asString(parsed.fingerprint);
127
+ return parsed.version === 1 && key && fingerprint
128
+ ? { version: 1, key, fingerprint }
129
+ : null;
130
+ }
131
+ catch (_a) {
132
+ return null;
133
+ }
134
+ };
135
+ const toStableJsonValue = (value) => {
136
+ if (Array.isArray(value)) {
137
+ return value.map(toStableJsonValue);
138
+ }
139
+ if (!isRecord(value)) {
140
+ return value;
141
+ }
142
+ return Object.keys(value).sort().reduce((result, key) => {
143
+ if (value[key] !== undefined) {
144
+ result[key] = toStableJsonValue(value[key]);
145
+ }
146
+ return result;
147
+ }, {});
148
+ };
149
+ const buildFallbackDigest = (value) => {
150
+ var _a;
151
+ const bytes = new TextEncoder().encode(value);
152
+ const hashes = Array.from({ length: 8 }, (_, index) => ((0x811c9dc5 ^ Math.imul(index + 1, 0x9e3779b1)) >>> 0));
153
+ for (const byte of bytes) {
154
+ for (let index = 0; index < hashes.length; index += 1) {
155
+ hashes[index] = Math.imul(((_a = hashes[index]) !== null && _a !== void 0 ? _a : 0) ^ byte, 0x01000193 + index * 2) >>> 0;
156
+ }
157
+ }
158
+ return hashes.map((hash) => hash.toString(16).padStart(8, '0')).join('');
159
+ };
160
+ const digestBootstrapRequest = async (value) => {
161
+ var _a;
162
+ const serialized = JSON.stringify(toStableJsonValue(value));
163
+ const bytes = new TextEncoder().encode(serialized);
164
+ const subtle = (_a = globalThis.crypto) === null || _a === void 0 ? void 0 : _a.subtle;
165
+ if (!subtle) {
166
+ return `fallback:${buildFallbackDigest(serialized)}`;
167
+ }
168
+ const digest = await subtle.digest('SHA-256', bytes);
169
+ const hex = Array.from(new Uint8Array(digest), (byte) => byte.toString(16).padStart(2, '0')).join('');
170
+ return `sha256:${hex}`;
171
+ };
172
+ const getOrCreateBootstrapIdempotencyRecord = (fingerprint) => {
173
+ const created = () => ({
174
+ version: 1,
175
+ key: generateBootstrapIdempotencyKey(),
176
+ fingerprint,
177
+ });
178
+ if (!canUseDom()) {
179
+ return created();
180
+ }
181
+ const storageKey = buildBootstrapIdempotencyKeyStorageKey();
182
+ const existing = parseBootstrapIdempotencyRecord(window.localStorage.getItem(storageKey));
183
+ if ((existing === null || existing === void 0 ? void 0 : existing.fingerprint) === fingerprint) {
184
+ return existing;
185
+ }
186
+ const next = created();
187
+ window.localStorage.setItem(storageKey, JSON.stringify(next));
188
+ return next;
189
+ };
190
+ const clearBootstrapIdempotencyRecord = (completed) => {
191
+ if (!canUseDom()) {
192
+ return;
193
+ }
194
+ const storageKey = buildBootstrapIdempotencyKeyStorageKey();
195
+ const current = parseBootstrapIdempotencyRecord(window.localStorage.getItem(storageKey));
196
+ if ((current === null || current === void 0 ? void 0 : current.key) === completed.key && current.fingerprint === completed.fingerprint) {
197
+ window.localStorage.removeItem(storageKey);
198
+ }
199
+ };
105
200
  const generateUserId = () => {
106
201
  var _a;
107
202
  if (canUseDom() && typeof ((_a = window.crypto) === null || _a === void 0 ? void 0 : _a.randomUUID) === 'function') {
@@ -217,11 +312,12 @@ class ApiService {
217
312
  return persistUserId(resolvedCandidateUserId || fallbackUserId, FUNNEL_ID);
218
313
  }
219
314
  async bootstrapSession(input) {
220
- var _a;
315
+ var _a, _b, _c;
221
316
  const urlUserAttributes = resolveUrlUserAttributes();
222
317
  const inputEmail = (input === null || input === void 0 ? void 0 : input.email) || urlUserAttributes.email || undefined;
223
318
  const inputName = (input === null || input === void 0 ? void 0 : input.name) || urlUserAttributes.name || undefined;
224
- const userId = await this.resolveBootstrapUserId((input === null || input === void 0 ? void 0 : input.userId) || urlUserAttributes.userId);
319
+ const candidateUserId = (input === null || input === void 0 ? void 0 : input.userId) || urlUserAttributes.userId;
320
+ const userId = await this.resolveBootstrapUserId(candidateUserId);
225
321
  const publishableKey = getFunnelSdkPublishableKey();
226
322
  if (!publishableKey) {
227
323
  return toAppUser({
@@ -231,17 +327,29 @@ class ApiService {
231
327
  fallbackDocument: withMergedAttributionDocument({}, input === null || input === void 0 ? void 0 : input.attribution),
232
328
  });
233
329
  }
330
+ const metadata = {
331
+ source: 'funnel-session',
332
+ environment: resolveRuntimeAnalyticsEnvironment(),
333
+ };
334
+ const fingerprint = await digestBootstrapRequest({
335
+ funnelVersionId: asString(FUNNEL_VERSION_ID),
336
+ userId,
337
+ email: (_b = (_a = asString(inputEmail)) === null || _a === void 0 ? void 0 : _a.toLowerCase()) !== null && _b !== void 0 ? _b : null,
338
+ fullName: asString(inputName),
339
+ metadata,
340
+ attribution: mergeFunnelUserAttribution({}, input === null || input === void 0 ? void 0 : input.attribution),
341
+ });
342
+ const idempotencyRecord = getOrCreateBootstrapIdempotencyRecord(fingerprint);
234
343
  const payload = await funnelSdkService.bootstrapUser({
235
344
  userId,
345
+ idempotencyKey: idempotencyRecord.key,
236
346
  email: inputEmail,
237
347
  fullName: inputName,
238
- metadata: {
239
- source: 'funnel-session',
240
- environment: resolveRuntimeAnalyticsEnvironment(),
241
- },
348
+ metadata,
242
349
  attribution: input === null || input === void 0 ? void 0 : input.attribution,
243
350
  });
244
- const persistedUserId = persistUserId(asString((_a = payload.user) === null || _a === void 0 ? void 0 : _a.user_id) ||
351
+ clearBootstrapIdempotencyRecord(idempotencyRecord);
352
+ const persistedUserId = persistUserId(asString((_c = payload.user) === null || _c === void 0 ? void 0 : _c.user_id) ||
245
353
  asString(payload.user_id) ||
246
354
  userId, FUNNEL_ID) || userId;
247
355
  return toAppUser({
@@ -14,6 +14,7 @@ export type FunnelSdkListPaymentPlansResponse = {
14
14
  };
15
15
  export type FunnelSdkBootstrapUserInput = {
16
16
  userId?: string | null;
17
+ idempotencyKey?: string | null;
17
18
  email?: string | null;
18
19
  fullName?: string | null;
19
20
  metadata?: FunnelSdkJsonObject | null;
@@ -106,6 +106,7 @@ class FunnelSdkService {
106
106
  runtimeConfig: input.runtimeConfig,
107
107
  body: {
108
108
  user_id: input.userId,
109
+ idempotencyKey: input.idempotencyKey,
109
110
  email: input.email,
110
111
  fullName: input.fullName,
111
112
  metadata: input.metadata,
package/package.json CHANGED
@@ -1,6 +1,11 @@
1
1
  {
2
2
  "name": "@funnelsgrove/runtime",
3
- "version": "0.1.60",
3
+ "version": "0.1.61",
4
+ "repository": {
5
+ "type": "git",
6
+ "url": "git+https://github.com/The-Solid-Grove/funnelsgrove.git",
7
+ "directory": "apps/funnel-runtime"
8
+ },
4
9
  "type": "module",
5
10
  "private": false,
6
11
  "main": "./dist/index.js",