Skip to content

Commit

Permalink
feat: Content from base url (#871) RELEASE
Browse files Browse the repository at this point in the history
## Related Issues

Fixes descope/etc#8253
  • Loading branch information
nirgur authored Dec 24, 2024
1 parent 5c757ff commit f3e437e
Show file tree
Hide file tree
Showing 11 changed files with 918 additions and 152 deletions.
8 changes: 4 additions & 4 deletions packages/libs/sdk-mixins/jest.config.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -9,10 +9,10 @@ module.exports = {
collectCoverageFrom: ['src/**/*.{js,jsx,ts,tsx}'],
coverageThreshold: {
global: {
branches: 80,
functions: 93.5,
lines: 93.5,
statements: 93.5,
branches: 12,
functions: 17,
lines: 36,
statements: 36,
},
},
// A set of global variables that need to be available in all test environments
Expand Down
14 changes: 12 additions & 2 deletions packages/libs/sdk-mixins/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@
"default": "./dist/cjs/index.js"
}
},
"./themeMixin": {
"./theme-mixin": {
"import": {
"types": "./dist/index.d.ts",
"default": "./dist/esm/mixins/themeMixin/index.js"
Expand All @@ -30,13 +30,23 @@
"types": "./dist/index.d.ts",
"default": "./dist/cjs/mixins/themeMixin/index.js"
}
},
"./static-resources-mixin": {
"import": {
"types": "./dist/index.d.ts",
"default": "./dist/esm/mixins/staticResourcesMixin/index.js"
},
"require": {
"types": "./dist/index.d.ts",
"default": "./dist/cjs/mixins/staticResourcesMixin/index.js"
}
}
},
"type": "module",
"description": "Descope JavaScript SDK mixins",
"scripts": {
"build": "rimraf dist && rollup -c",
"test": "echo no tests yet",
"test": "jest",
"lint": "eslint '+(src|test|examples)/**/*.ts'"
},
"license": "MIT",
Expand Down
6 changes: 5 additions & 1 deletion packages/libs/sdk-mixins/rollup.config.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,11 @@ import noEmit from 'rollup-plugin-no-emit';

import packageJson from './package.json' assert { type: 'json' };

const input = ['./src/index.ts', './src/mixins/themeMixin/index.ts'];
const input = [
'./src/index.ts',
'./src/mixins/themeMixin/index.ts',
'./src/mixins/staticResourcesMixin/index.ts',
];
const external = (id) =>
!id.startsWith('\0') && !id.startsWith('.') && !id.startsWith('/');

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
import { Logger } from '../loggerMixin';

type FetchParams = Parameters<typeof fetch>;
const notLastMsgSuffix = 'Trying the next fallback URL...';

export const fetchWithFallbacks = async (
fallbacks: FetchParams['0'] | FetchParams['0'][],
init: FetchParams['1'],
{
logger,
onSuccess,
}: { logger?: Logger; onSuccess?: (urlIndex: number) => void },
): ReturnType<typeof fetch> => {
const fallbacksArr = Array.isArray(fallbacks) ? fallbacks : [fallbacks];

for (let index = 0; index < fallbacksArr.length; index++) {
const url = fallbacksArr[index];
const isLast = index === fallbacksArr.length - 1;

try {
const res = await fetch(url.toString(), init);
if (res.ok) {
onSuccess?.(index);
return res;
}

const errMsg = `Error fetching URL ${url} [${res.status}]`;

if (isLast) throw new Error(errMsg);

logger?.debug(`${errMsg}. ${notLastMsgSuffix}`);
} catch (e) {
const errMsg = `Error fetching URL ${url} [${e.message}]`;

if (isLast) throw new Error(errMsg);

logger?.debug(`${errMsg}. ${notLastMsgSuffix}`);
}
}
};
Original file line number Diff line number Diff line change
Expand Up @@ -7,24 +7,29 @@ import {
} from './constants';
import { projectIdMixin } from '../projectIdMixin';
import { baseUrlMixin } from '../baseUrlMixin';
import { fetchWithFallbacks } from './fetchWithFallbacks';

type Format = 'text' | 'json';

type CustomUrl = URL & { baseUrl: string };

export function getResourceUrl({
projectId,
filename,
assetsFolder = ASSETS_FOLDER,
baseUrl,
baseUrl = BASE_CONTENT_URL,
}: {
projectId: string;
filename: string;
assetsFolder?: string;
baseUrl?: string;
}) {
const url = new URL(OVERRIDE_CONTENT_URL || baseUrl || BASE_CONTENT_URL);
const url: CustomUrl = new URL(baseUrl) as any;
url.pathname = pathJoin(url.pathname, projectId, assetsFolder, filename);
// we want to keep the baseUrl so we can use it later
url.baseUrl = baseUrl;

return url.toString();
return url;
}

export const staticResourcesMixin = createSingletonMixin(
Expand All @@ -35,30 +40,90 @@ export const staticResourcesMixin = createSingletonMixin(
baseUrlMixin,
)(superclass);

// the logic should be as following:
// if there is a local storage override, use it
// otherwise, if there is a base-static-url attribute, use it
// otherwise, try to use base-url, and check if it's working
// if it's working, use it
// if not, use the default content url
return class StaticResourcesMixinClass extends BaseClass {
#lastBaseUrl?: string;
#workingBaseUrl?: string;

#getResourceUrls(filename: string): CustomUrl[] | CustomUrl {
const overrideUrl = OVERRIDE_CONTENT_URL || this.baseStaticUrl;

if (overrideUrl) {
return getResourceUrl({
projectId: this.projectId,
filename,
baseUrl: overrideUrl,
});
}

const isBaseUrlUpdated = this.#lastBaseUrl !== this.baseUrl;
const shouldFallbackFetch = isBaseUrlUpdated && !!this.baseUrl;

// if the base url has changed, reset the working base url
if (isBaseUrlUpdated) {
this.#lastBaseUrl = this.baseUrl;
this.#workingBaseUrl = undefined;
}

const resourceUrl = getResourceUrl({
projectId: this.projectId,
filename,
baseUrl: this.#workingBaseUrl,
});

// if there is no reason to check the baseUrl, generate the resource url according to the priority
if (!shouldFallbackFetch) {
return resourceUrl;
}

const resourceUrlFromBaseUrl = getResourceUrl({
projectId: this.projectId,
filename,
baseUrl: this.baseUrl + '/pages',
});

return [resourceUrlFromBaseUrl, resourceUrl];
}

async fetchStaticResource<F extends Format>(
filename: string,
format: F,
): Promise<{
body: F extends 'json' ? Record<string, any> : string;
headers: Record<string, string>;
}> {
const resourceUrl = getResourceUrl({
projectId: this.projectId,
filename,
baseUrl: this.baseStaticUrl,
});
const res = await fetch(resourceUrl, { cache: 'default' });
if (!res.ok) {
this.logger.error(
`Error fetching URL ${resourceUrl} [${res.status}]`,
const resourceUrls = this.#getResourceUrls(filename);

// if there are multiple resource urls, it means that there are fallbacks,
// if one of the options (which is not the last) is working, we want to keep using it by updating the workingBaseUrl
const onSuccess = !Array.isArray(resourceUrls)
? null
: (index: number) => {
if (index !== resourceUrls.length - 1) {
const { baseUrl } = resourceUrls[index];
this.#workingBaseUrl = baseUrl;
}
};

try {
const res = await fetchWithFallbacks(
resourceUrls,
{ cache: 'default' },
{ logger: this.logger, onSuccess },
);
}

return {
body: await res[format](),
headers: Object.fromEntries(res.headers.entries()),
};
return {
body: await res[format](),
headers: Object.fromEntries(res.headers.entries()),
};
} catch (e) {
this.logger.error(e.message);
}
}

get baseStaticUrl() {
Expand Down
149 changes: 149 additions & 0 deletions packages/libs/sdk-mixins/test/staticResourcesMixin.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,149 @@
import { staticResourcesMixin } from '../src';

const createResponse = async ({
body,
statusCode = 200,
}: {
body: string;
statusCode: number;
}) => ({
json: async () => JSON.parse(body),
text: async () => body,
ok: statusCode < 400,
});

const createMixin = (config: Record<string, any>) => {
const MixinClass = staticResourcesMixin(
class {
getAttribute(attr: string) {
return config[attr];
}
} as any,
);

const mixin = new MixinClass();

mixin.logger = { debug: jest.fn() };

return mixin;
};

describe('staticResourcesMixin', () => {
afterEach(() => {
jest.clearAllMocks();
});
it('should fetch resource from static base url', async () => {
globalThis.fetch = jest
.fn()
.mockResolvedValue(createResponse({ body: '{}', statusCode: 400 }));
const mixin = createMixin({
'base-static-url': 'https://static.example.com/pages',
'project-id': '123',
'base-url': 'https://example.com',
});
await mixin.fetchStaticResource('file', 'json');

expect(globalThis.fetch).toHaveBeenCalledWith(
'https://static.example.com/pages/123/v2-beta/file',
expect.any(Object),
);
expect(globalThis.fetch).toHaveBeenCalledTimes(1);
});

it('should try to fetch resource from base url if there is no static content', async () => {
globalThis.fetch = jest
.fn()
.mockResolvedValue(createResponse({ body: '{}', statusCode: 400 }));
const mixin = createMixin({
'project-id': '123',
'base-url': 'https://example.com',
});
await mixin.fetchStaticResource('file', 'json');

expect(globalThis.fetch).toHaveBeenNthCalledWith(
1,
'https://example.com/pages/123/v2-beta/file',
expect.any(Object),
);
expect(globalThis.fetch).toHaveBeenNthCalledWith(
2,
'https://static.descope.com/pages/123/v2-beta/file',
expect.any(Object),
);
expect(globalThis.fetch).toHaveBeenCalledTimes(2);
});

it('should fetch resource from default url if there is no base url and base static url', async () => {
globalThis.fetch = jest
.fn()
.mockResolvedValue(createResponse({ body: '{}', statusCode: 400 }));
const mixin = createMixin({ 'project-id': '123' });
await mixin.fetchStaticResource('file', 'json');

expect(globalThis.fetch).toHaveBeenCalledWith(
'https://static.descope.com/pages/123/v2-beta/file',
expect.any(Object),
);
expect(globalThis.fetch).toHaveBeenCalledTimes(1);
});

it('should keep fetching content from base url in case it was ok', async () => {
globalThis.fetch = jest
.fn()
.mockResolvedValueOnce(createResponse({ body: '{}', statusCode: 200 }));
const mixin = createMixin({
'project-id': '123',
'base-url': 'https://example.com',
});
await mixin.fetchStaticResource('file', 'json');

expect(globalThis.fetch).toHaveBeenNthCalledWith(
1,
'https://example.com/pages/123/v2-beta/file',
expect.any(Object),
);
expect(globalThis.fetch).toHaveBeenCalledTimes(1);

globalThis.fetch = jest
.fn()
.mockResolvedValueOnce(createResponse({ body: '{}', statusCode: 400 }));

await mixin.fetchStaticResource('file2', 'json');
expect(globalThis.fetch).toHaveBeenNthCalledWith(
1,
'https://example.com/pages/123/v2-beta/file2',
expect.any(Object),
);
expect(globalThis.fetch).toHaveBeenCalledTimes(1);
});

it('should not keep fetching content from base url in case it was not ok', async () => {
globalThis.fetch = jest
.fn()
.mockResolvedValueOnce(createResponse({ body: '{}', statusCode: 400 }));
const mixin = createMixin({
'project-id': '123',
'base-url': 'https://example.com',
});
await mixin.fetchStaticResource('file', 'json');

expect(globalThis.fetch).toHaveBeenNthCalledWith(
1,
'https://example.com/pages/123/v2-beta/file',
expect.any(Object),
);
expect(globalThis.fetch).toHaveBeenCalledTimes(2);

globalThis.fetch = jest
.fn()
.mockResolvedValueOnce(createResponse({ body: '{}', statusCode: 400 }));

await mixin.fetchStaticResource('file2', 'json');
expect(globalThis.fetch).toHaveBeenNthCalledWith(
1,
'https://static.descope.com/pages/123/v2-beta/file2',
expect.any(Object),
);
expect(globalThis.fetch).toHaveBeenCalledTimes(1);
});
});
Loading

0 comments on commit f3e437e

Please sign in to comment.