Skip to content

Commit

Permalink
feat: cache product API calls
Browse files Browse the repository at this point in the history
  • Loading branch information
alisey committed Nov 16, 2024
1 parent 303ff33 commit 1d66b0c
Show file tree
Hide file tree
Showing 2 changed files with 104 additions and 6 deletions.
34 changes: 28 additions & 6 deletions src/wix/ecom/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import { createClient, OAuthStrategy, Tokens } from '@wix/sdk';
import { collections, products } from '@wix/stores';
import { getFilteredProductsQuery } from '../products/product-filters';
import { getSortedProductsQuery } from '../products/product-sorting';
import { MemoryCache } from '../utils/memory-cache';
import { EcomApi, WixApiClient } from './types';
import { isNotFoundWixClientError, normalizeWixClientError } from './wix-client-error';

Expand Down Expand Up @@ -70,11 +71,12 @@ export function initializeEcomApiAnonymous() {
return createEcomApi(client);
}

const createEcomApi = (wixClient: WixApiClient): EcomApi =>
withNormalizedWixClientErrors({
const createEcomApi = (wixClient: WixApiClient): EcomApi => {
const api: EcomApi = {
getWixClient() {
return wixClient;
},

async getProducts(params = {}) {
let collectionId = params.categoryId;
if (!collectionId && params.categorySlug) {
Expand Down Expand Up @@ -171,7 +173,7 @@ const createEcomApi = (wixClient: WixApiClient): EcomApi =>
async getCategoryBySlug(slug) {
try {
const { collection } = await wixClient.collections.getCollectionBySlug(slug);
return collection!;
return collection;
} catch (error) {
if (!isNotFoundWixClientError(error)) throw error;
}
Expand Down Expand Up @@ -248,13 +250,18 @@ const createEcomApi = (wixClient: WixApiClient): EcomApi =>
async sendPasswordResetEmail(email: string, redirectUrl: string) {
await wixClient.auth.sendPasswordResetEmail(email, redirectUrl);
},
});
};

normalizeWixClientErrors(api);
cacheNonUserSpecificMethods(api);
return api;
};

/**
* Wraps all methods of the EcomApi with a try-catch block that fixes broken
* error messages in WixClient errors and rethrows them.
*/
const withNormalizedWixClientErrors = (api: EcomApi): EcomApi => {
const normalizeWixClientErrors = (api: EcomApi): void => {
for (const key of Object.keys(api)) {
const original = Reflect.get(api, key);
if (typeof original !== 'function') continue;
Expand All @@ -272,5 +279,20 @@ const withNormalizedWixClientErrors = (api: EcomApi): EcomApi => {
}
});
}
return api;
};

/**
* Wix REST API calls can be slow, e.g. 400ms for a list of categories. On pages
* like 'Browse Products' these delays add up, making short-term caching
* worthwhile. The cache is used on both the server and the client (when the
* client directly calls the Wix API). On the server, the cache is shared
* between visitors, so it is critical to cache only non-user-specific calls.
*/
const cache = new MemoryCache({ ttl: 60_000, maxEntries: 1000, expiryCheckInterval: 10 });

const cacheNonUserSpecificMethods = (api: EcomApi): void => {
cache.wrapMethod(api, 'getProducts');
cache.wrapMethod(api, 'getAllCategories');
cache.wrapMethod(api, 'getCategoryBySlug');
cache.wrapMethod(api, 'getProductPriceBoundsInCategory');
};
76 changes: 76 additions & 0 deletions src/wix/utils/memory-cache.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
export interface MemoryCacheOptions {
/** Time-to-live for cache entries in milliseconds. */
ttl: number;

/**
* Maximum number of entries in the cache. When running in an environment
* with limited memory, such as Netlify Functions, it's important to limit
* the cache size to avoid running out of memory.
*/
maxEntries: number;

/** Interval in milliseconds between checks for expired cache entries. */
expiryCheckInterval: number;
}

export class MemoryCache {
private cache = new Map<string, { value: any; expiry: number }>();
private lastCheckedExpiry = 0;

public constructor(private options: MemoryCacheOptions) {}

public has(key: string): boolean {
this.deleteExpired();
return this.cache.has(key);
}

public get = (key: string): unknown => {
this.deleteExpired();
return this.cache.get(key)?.value;
};

public set = (key: string, value: unknown) => {
if (this.cache.size >= this.options.maxEntries) return;
const expiry = Date.now() + this.options.ttl;
this.cache.set(key, { value, expiry });
};

public delete = (key: string) => {
this.cache.delete(key);
};

/**
* Replaces a method on an object with a cached version. The method name
* must be unique per cache instance, and its arguments must be
* JSON-serializable.
*/
public wrapMethod = <O>(obj: O, methodName: AsyncMethodName<O>): void => {
const method = (obj as any)[methodName];
(obj as any)[methodName] = (...args: unknown[]) => {
const key = JSON.stringify([methodName, args]);
if (this.has(key)) return this.get(key);
const result = method.apply(obj, args).catch((error: unknown) => {
this.delete(key);
throw error;
});
this.set(key, result);
return result;
};
};

private deleteExpired = () => {
const now = Date.now();
if (now - this.lastCheckedExpiry < this.options.expiryCheckInterval) {
return;
}

this.lastCheckedExpiry = now;
for (const [key, { expiry }] of this.cache.entries()) {
if (expiry < now) this.cache.delete(key);
}
};
}

type AsyncMethodName<T> = {
[K in keyof T & string]: T[K] extends (...args: any[]) => Promise<any> ? K : never;
}[keyof T & string];

0 comments on commit 1d66b0c

Please sign in to comment.