@conduit-client/salesforce-lightning-service-worker 3.6.3 → 3.7.0-dev1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.js CHANGED
@@ -50,7 +50,7 @@ function isPromiseLike(x) {
50
50
  * All rights reserved.
51
51
  * For full license text, see the LICENSE.txt file
52
52
  */
53
- function buildServiceDescriptor(interceptors = {
53
+ function buildServiceDescriptor$1(interceptors = {
54
54
  request: [],
55
55
  retry: void 0,
56
56
  response: [],
@@ -76,6 +76,9 @@ function buildServiceDescriptor(interceptors = {
76
76
  if (retryInterceptor) {
77
77
  return retryInterceptor(args2, retryService, context);
78
78
  } else {
79
+ if (retryService) {
80
+ return retryService.applyRetry(() => fetch(...args2));
81
+ }
79
82
  return fetch(...args2);
80
83
  }
81
84
  }).then((response) => {
@@ -94,119 +97,234 @@ function buildServiceDescriptor(interceptors = {
94
97
  }
95
98
  };
96
99
  }
97
- const CACHE_VERSION = 1;
98
- const CACHE_NAME = `salesforce-lightning-service-worker-${CACHE_VERSION}`;
99
- const CSRF_HEADER = "X-CSRF-Token";
100
- const API_PATH_PREFIX = "/services/data/v";
101
- async function withCache(callback) {
102
- if (caches) {
103
- const cache = await caches.open(CACHE_NAME);
104
- return callback(cache);
100
+ function setHeader(headerName, headerValue, [resource, options = {}], {
101
+ throwOnExisting = false,
102
+ errorMessage = `Unexpected ${headerName} header encountered`
103
+ } = {}) {
104
+ let hasHeaderBeenSet = false;
105
+ if (resource instanceof Request && !(options == null ? void 0 : options.headers)) {
106
+ if (throwOnExisting && resource.headers.has(headerName)) {
107
+ throw new Error(errorMessage);
108
+ }
109
+ resource.headers.set(headerName, headerValue);
110
+ hasHeaderBeenSet = true;
111
+ }
112
+ if ((options == null ? void 0 : options.headers) instanceof Headers) {
113
+ if (throwOnExisting && options.headers.has(headerName)) {
114
+ throw new Error(errorMessage);
115
+ }
116
+ options.headers.set(headerName, headerValue);
105
117
  } else {
106
- return void 0;
118
+ if (throwOnExisting && (options == null ? void 0 : options.headers) && Reflect.has(options.headers, headerName)) {
119
+ throw new Error(errorMessage);
120
+ }
121
+ if (!hasHeaderBeenSet) {
122
+ options.headers = {
123
+ ...options == null ? void 0 : options.headers,
124
+ [headerName]: headerValue
125
+ };
126
+ }
127
+ }
128
+ return [resource, options];
129
+ }
130
+ /*!
131
+ * Copyright (c) 2022, Salesforce, Inc.,
132
+ * All rights reserved.
133
+ * For full license text, see the LICENSE.txt file
134
+ */
135
+ class RetryService {
136
+ constructor(defaultRetryPolicy) {
137
+ this.defaultRetryPolicy = defaultRetryPolicy;
107
138
  }
139
+ applyRetry(operation, retryPolicyOverride) {
140
+ return this.retry(operation, retryPolicyOverride || this.defaultRetryPolicy);
141
+ }
142
+ async retry(operation, policy) {
143
+ const startTime = Date.now();
144
+ let attempt = 0;
145
+ let result = await operation();
146
+ let context = {
147
+ attempt,
148
+ totalElapsedMs: Date.now() - startTime,
149
+ lastResult: result
150
+ };
151
+ while (await policy.shouldRetry(result, context)) {
152
+ const delay = await policy.calculateDelay(result, context);
153
+ await this.delay(delay);
154
+ if (policy.prepareRetry) {
155
+ await policy.prepareRetry(result, context);
156
+ }
157
+ attempt++;
158
+ result = await operation();
159
+ context = {
160
+ attempt,
161
+ totalElapsedMs: Date.now() - startTime,
162
+ lastResult: result
163
+ };
164
+ }
165
+ return result;
166
+ }
167
+ delay(ms) {
168
+ return new Promise((resolve) => {
169
+ setTimeout(resolve, ms);
170
+ });
171
+ }
172
+ }
173
+ class RetryPolicy {
174
+ }
175
+ function buildServiceDescriptor(defaultRetryPolicy) {
176
+ return {
177
+ version: "1.0",
178
+ service: new RetryService(defaultRetryPolicy),
179
+ type: "retry"
180
+ };
181
+ }
182
+ const HEADER = "X-CSRF-Token";
183
+ function buildInterceptor$1(csrfTokenManager, config = {}) {
184
+ const { protectedUrls = [] } = config;
185
+ return async (fetchArgs) => {
186
+ const [input, init] = fetchArgs;
187
+ const request = new Request(input, init);
188
+ if (isProtectedMethod(request.method) && isProtectedUrl(protectedUrls, request.url) && !request.headers.has(HEADER)) {
189
+ const token = await csrfTokenManager.getToken();
190
+ fetchArgs = setHeader(HEADER, token, fetchArgs);
191
+ }
192
+ return resolvedPromiseLike(fetchArgs);
193
+ };
108
194
  }
109
195
  function isProtectedMethod(method) {
110
196
  const normalizedMethod = method.toLowerCase();
111
197
  return normalizedMethod === "post" || normalizedMethod === "put" || normalizedMethod === "patch" || normalizedMethod === "delete";
112
198
  }
113
- function isProtectedUrl(urlString) {
199
+ function isProtectedUrl(protectedUrls, urlString) {
114
200
  const url = new URL(urlString);
115
- return url.pathname.includes(API_PATH_PREFIX);
201
+ return protectedUrls.some((protectedUrl) => url.pathname.includes(protectedUrl));
202
+ }
203
+ function buildInterceptor(csrfTokenManager, config = {}) {
204
+ const headerInterceptor = buildInterceptor$1(csrfTokenManager, config);
205
+ async function makeRequest(fetchArgs) {
206
+ const args = await headerInterceptor(fetchArgs);
207
+ return fetch(...args);
208
+ }
209
+ return (fetchArgs, retryService) => {
210
+ if (!retryService) {
211
+ return makeRequest(fetchArgs);
212
+ }
213
+ return retryService.applyRetry(async () => {
214
+ return makeRequest(fetchArgs);
215
+ });
216
+ };
116
217
  }
117
- async function isTokenInvalid(response) {
218
+ class CsrfTokenRetryPolicy extends RetryPolicy {
219
+ constructor(csrfTokenManager) {
220
+ super(csrfTokenManager);
221
+ this.csrfTokenManager = csrfTokenManager;
222
+ }
223
+ /**
224
+ * Determines if a failed request should be retried due to CSRF token issues.
225
+ */
226
+ async shouldRetry(result, context) {
227
+ if (context.attempt >= 1) {
228
+ return false;
229
+ }
230
+ if (result.status !== 400) {
231
+ return false;
232
+ }
233
+ return await isCsrfError(result);
234
+ }
235
+ /**
236
+ * CSRF token refresh should happen immediately with no delay.
237
+ */
238
+ async calculateDelay(_result, _context) {
239
+ return 0;
240
+ }
241
+ /**
242
+ * Called by retry service before each retry attempt.
243
+ *
244
+ * @param _result - The failed response that triggered the retry (unused, already validated)
245
+ * @param _context - Current retry context (unused but part of interface)
246
+ */
247
+ async prepareRetry(_result, _context) {
248
+ await this.csrfTokenManager.refreshToken();
249
+ }
250
+ }
251
+ async function isCsrfError(response) {
118
252
  var _a;
119
- if (response.status === 400) {
253
+ try {
120
254
  const body = await response.clone().json();
121
255
  return ((_a = body[0]) == null ? void 0 : _a.errorCode) === "INVALID_ACCESS_TOKEN";
256
+ } catch {
257
+ return false;
122
258
  }
123
- return false;
124
259
  }
125
- function createLightningFetch(config = {}) {
126
- const { fireEvent = () => {
127
- }, csrfTokenSource, interceptors } = config;
128
- const { service: fetchService } = buildServiceDescriptor(interceptors);
129
- let tokenUrl = `${API_PATH_PREFIX}65.0/ui-api/session/csrf`;
130
- let tokenProvider = obtainToken;
131
- if (csrfTokenSource) {
132
- if (typeof csrfTokenSource === "string" || csrfTokenSource instanceof URL) {
133
- tokenUrl = csrfTokenSource;
134
- } else if (typeof csrfTokenSource === "function") {
135
- tokenProvider = csrfTokenSource;
136
- }
260
+ class CsrfTokenManager {
261
+ constructor(endpoint, cacheName) {
262
+ this.endpoint = endpoint;
263
+ this.cacheName = cacheName;
264
+ this.tokenPromise = this.obtainToken();
137
265
  }
138
- function generateId() {
139
- return Date.now().toString(36);
266
+ /**
267
+ * Returns the current token value as a Promise.
268
+ * Lazy-loads the token on first call (from cache or by fetching).
269
+ */
270
+ async getToken() {
271
+ return this.tokenPromise;
272
+ }
273
+ /**
274
+ * Obtains and returns a new token value as a promise.
275
+ * This will clear the cached token and fetch a fresh one.
276
+ */
277
+ async refreshToken() {
278
+ await this.withCache((cache) => cache.delete(this.endpoint));
279
+ this.tokenPromise = this.obtainToken();
280
+ return this.tokenPromise;
140
281
  }
141
- async function obtainToken() {
142
- const id = generateId();
143
- fireEvent("csrf_token_obtain_start", id);
144
- let response = await withCache((cache) => cache.match(tokenUrl));
282
+ /**
283
+ * Obtains a CSRF token, using cache when available or fetching a new one.
284
+ *
285
+ * @returns Promise that resolves to the CSRF token string
286
+ */
287
+ async obtainToken() {
288
+ let response = await this.withCache((cache) => cache.match(this.endpoint));
289
+ let cacheMiss = false;
145
290
  if (!response) {
146
- fireEvent("csrf_token_fetch_start", id);
147
- response = await fetchService(tokenUrl, { method: "get" });
148
- fireEvent("csrf_token_fetch_complete", id, { status: response.status });
149
- } else {
150
- fireEvent("csrf_token_cache_hit", id);
291
+ response = await fetch(this.endpoint, { method: "get" });
292
+ cacheMiss = true;
151
293
  }
152
294
  const csrfToken = (await response.clone().json()).csrfToken;
153
- await withCache((cache) => cache.put(tokenUrl, response));
154
- fireEvent("csrf_token_obtain_complete", id);
155
- return csrfToken;
156
- }
157
- let tokenPromise = tokenProvider();
158
- async function refreshToken() {
159
- const id = generateId();
160
- fireEvent("csrf_token_refresh_start", id);
161
- await withCache((cache) => cache.delete(tokenUrl));
162
- tokenPromise = tokenProvider();
163
- fireEvent("csrf_token_refresh_complete", id);
164
- }
165
- async function fetchWithToken(request) {
166
- const headers = new Headers(request.headers);
167
- if (!headers.has(CSRF_HEADER)) {
168
- headers.set(CSRF_HEADER, await tokenPromise);
295
+ if (cacheMiss) {
296
+ await this.withCache((cache) => cache.put(this.endpoint, response));
169
297
  }
170
- return fetchService(request, { headers });
298
+ return csrfToken;
171
299
  }
172
- return async function lightningFetch(input, init) {
173
- const id = generateId();
174
- const request = new Request(input, init);
175
- if (isProtectedMethod(request.method) && isProtectedUrl(request.url)) {
176
- fireEvent("protected_request_start", id, { method: request.method, url: request.url });
177
- const response = await fetchWithToken(request.clone());
178
- if (await isTokenInvalid(response)) {
179
- fireEvent("csrf_token_invalid", id, { status: response.status });
180
- await refreshToken();
181
- const retryResponse = await fetchWithToken(request.clone());
182
- fireEvent("protected_request_complete", id, {
183
- method: request.method,
184
- url: request.url,
185
- status: retryResponse.status,
186
- retried: true
187
- });
188
- return retryResponse;
189
- } else {
190
- fireEvent("protected_request_complete", id, {
191
- method: request.method,
192
- url: request.url,
193
- status: response.status,
194
- retried: false
195
- });
196
- return response;
197
- }
300
+ /**
301
+ * Provides a safe way to interact with the Cache API with fallback for unsupported environments.
302
+ *
303
+ * @param callback - Function that receives the cache instance and returns a promise
304
+ * @returns The result of the callback, or undefined if caches API is not available
305
+ */
306
+ async withCache(callback) {
307
+ if (this.cacheName && caches) {
308
+ const cache = await caches.open(this.cacheName);
309
+ return callback(cache);
198
310
  } else {
199
- fireEvent("unprotected_request", id, { method: request.method, url: request.url });
200
- return fetchService(request);
311
+ return void 0;
201
312
  }
202
- };
313
+ }
314
+ }
315
+ function createFetchService(config) {
316
+ const csrfConfig = config.csrf;
317
+ const csrfTokenManager = new CsrfTokenManager(csrfConfig.endpoint, csrfConfig.cacheName);
318
+ return buildServiceDescriptor$1(
319
+ { retry: buildInterceptor(csrfTokenManager, csrfConfig) },
320
+ buildServiceDescriptor(new CsrfTokenRetryPolicy(csrfTokenManager)).service
321
+ ).service;
203
322
  }
204
- let clientFetch = createLightningFetch();
323
+ let clientFetch;
205
324
  let serviceWorkerLoading = false;
206
325
  let pendingRequests = [];
326
+ let instance;
207
327
  class ConduitClient {
208
- constructor() {
209
- }
210
328
  /**
211
329
  * Makes an HTTP request
212
330
  *
@@ -220,45 +338,94 @@ class ConduitClient {
220
338
  pendingRequests.push({ input, init, resolve });
221
339
  });
222
340
  }
223
- return clientFetch(input, init);
341
+ return Promise.resolve(clientFetch(input, init));
224
342
  }
225
343
  /**
226
- * Factory method to create a new ConduitClient instance
344
+ * Configures global settings for all ConduitClient instances.
345
+ * This will recreate the internal fetch service with the new configuration.
227
346
  *
228
- * @returns A new ConduitClient instance
347
+ * @param config - Configuration options for CSRF handling and other features
229
348
  */
230
- static create() {
231
- return new ConduitClient();
349
+ static initialize(config) {
350
+ if (instance) {
351
+ throw new Error("ConduitClient.initialize can only be called once");
352
+ }
353
+ instance = new ConduitClient();
354
+ if (config.serviceWorkerUrl) {
355
+ return registerServiceWorker(config);
356
+ } else {
357
+ clientFetch = createFetchService(config);
358
+ return Promise.resolve();
359
+ }
232
360
  }
233
361
  /**
234
- * Registers a service worker for enhanced CSRF protection and caching.
235
- * When successfully registered, the client will switch to using native fetch
236
- * as the service worker will handle CSRF protection.
362
+ * Factory method to create a new ConduitClient instance
237
363
  *
238
- * @param scriptURL - path to the service worker script
364
+ * @returns A new ConduitClient instance
239
365
  */
240
- static async registerServiceWorker(scriptURL) {
241
- if ("serviceWorker" in navigator) {
242
- try {
243
- serviceWorkerLoading = true;
244
- const registration = await navigator.serviceWorker.register(scriptURL, {
245
- type: "classic"
246
- });
247
- clientFetch = fetch;
248
- console.log("[Conduit Client] Service registration succeeded:", registration);
249
- } catch (error) {
250
- console.log(
251
- "[Conduit Client] Service Worker registration failed (using decorated `fetch`):",
252
- error
253
- );
254
- } finally {
255
- processQueuedRequests();
366
+ static instance() {
367
+ if (!instance) {
368
+ throw new Error("ConduitClient.initialize must be called first");
369
+ }
370
+ return instance;
371
+ }
372
+ }
373
+ async function registerServiceWorker(config) {
374
+ if ("serviceWorker" in navigator) {
375
+ try {
376
+ serviceWorkerLoading = true;
377
+ const registration = await navigator.serviceWorker.register(config.serviceWorkerUrl, {
378
+ type: "classic"
379
+ });
380
+ clientFetch = fetch;
381
+ console.log("[Conduit Client] Service registration succeeded:", registration);
382
+ if (registration.active) {
383
+ await postConfigToServiceWorker(registration.active, config);
384
+ } else if (registration.waiting || registration.installing) {
385
+ const serviceWorker = registration.waiting || registration.installing;
386
+ await waitForServiceWorkerActive(serviceWorker);
387
+ if (registration.active) {
388
+ await postConfigToServiceWorker(registration.active, config);
389
+ }
256
390
  }
257
- } else {
258
- console.log("[Conduit Client] Service Worker not supported (using decorated `fetch`):");
391
+ } catch (error) {
392
+ console.log(
393
+ "[Conduit Client] Service Worker registration failed (using decorated `fetch`):",
394
+ error
395
+ );
396
+ } finally {
397
+ processQueuedRequests();
259
398
  }
399
+ } else {
400
+ console.log("[Conduit Client] Service Worker not supported (using decorated `fetch`):");
401
+ }
402
+ }
403
+ async function postConfigToServiceWorker(serviceWorker, config) {
404
+ try {
405
+ const { serviceWorkerUrl, ...configData } = config;
406
+ serviceWorker.postMessage({
407
+ type: "fetch-config",
408
+ config: configData
409
+ });
410
+ } catch (error) {
411
+ console.warn("[Conduit Client] Failed to post config to service worker:", error);
260
412
  }
261
413
  }
414
+ function waitForServiceWorkerActive(serviceWorker) {
415
+ return new Promise((resolve) => {
416
+ if (serviceWorker.state === "activated") {
417
+ resolve();
418
+ return;
419
+ }
420
+ function handleStateChange() {
421
+ if (serviceWorker.state === "activated") {
422
+ serviceWorker.removeEventListener("statechange", handleStateChange);
423
+ resolve();
424
+ }
425
+ }
426
+ serviceWorker.addEventListener("statechange", handleStateChange);
427
+ });
428
+ }
262
429
  function processQueuedRequests() {
263
430
  serviceWorkerLoading = false;
264
431
  pendingRequests.forEach(({ input, init, resolve }) => {