├── .eslintrc.json ├── .github ├── ISSUE_TEMPLATE │ ├── bug_report.yml │ └── config.yml └── workflows │ └── deploy-package.yml ├── .gitignore ├── LICENCE ├── README.md ├── jest.config.js ├── jest.setup.ts ├── package-lock.json ├── package.json ├── src ├── errors.ts ├── index.ts ├── load.client.ts ├── load.server.ts ├── mocks │ └── server.ts ├── types.ts └── utils │ ├── getTinyFrontendModuleConfig.test.ts │ ├── getTinyFrontendModuleConfig.ts │ ├── loadUmdBundle.test.ts │ ├── loadUmdBundle.ts │ ├── retry.test.ts │ └── retry.ts ├── tsconfig.json └── vite.config.ts /.eslintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": [ 3 | "plugin:@cazoo/eslint/react" 4 | ] 5 | } 6 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/bug_report.yml: -------------------------------------------------------------------------------- 1 | name: '🐛 Bug report' 2 | description: Create a report to help us improve 3 | body: 4 | - type: markdown 5 | attributes: 6 | value: | 7 | Thank you for reporting an issue :pray:. 8 | 9 | This issue tracker is for reporting bugs found in `tiny-client` (https://github.com/tiny-frontend/tiny-client). 10 | If you have a question about how to achieve something and are struggling, please post a question 11 | inside of `tiny-client` Discussions tab: https://github.com/tiny-frontend/tiny-client/discussions 12 | 13 | Before submitting a new bug/issue, please check the links below to see if there is a solution or question posted there already: 14 | - `tiny-client` Issues tab: https://github.com/tiny-frontend/tiny-client/issues?q=is%3Aissue+is%3Aopen+sort%3Aupdated-desc 15 | - `tiny-client` closed issues tab: https://github.com/tiny-frontend/tiny-client/issues?q=is%3Aissue+sort%3Aupdated-desc+is%3Aclosed 16 | - `tiny-client` Discussions tab: https://github.com/tiny-frontend/tiny-client/discussions 17 | 18 | The more information you fill in, the better the community can help you. 19 | - type: textarea 20 | id: description 21 | attributes: 22 | label: Describe the bug 23 | description: Provide a clear and concise description of the challenge you are running into. 24 | validations: 25 | required: true 26 | - type: input 27 | id: link 28 | attributes: 29 | label: Your Example Website or App 30 | description: | 31 | Which website or app were you using when the bug happened? 32 | Note: 33 | - Your bug will may get fixed much faster if we can run your code and it doesn't have dependencies other than the `tiny-client` npm package. 34 | - To create a shareable code example you can use Stackblitz (https://stackblitz.com/). Please no localhost URLs. 35 | - Please read these tips for providing a minimal example: https://stackoverflow.com/help/mcve. 36 | placeholder: | 37 | e.g. https://stackblitz.com/edit/...... OR Github Repo 38 | validations: 39 | required: true 40 | - type: textarea 41 | id: steps 42 | attributes: 43 | label: Steps to Reproduce the Bug or Issue 44 | description: Describe the steps we have to take to reproduce the behavior. 45 | placeholder: | 46 | 1. Go to '...' 47 | 2. Click on '....' 48 | 3. Scroll down to '....' 49 | 4. See error 50 | validations: 51 | required: true 52 | - type: textarea 53 | id: expected 54 | attributes: 55 | label: Expected behavior 56 | description: Provide a clear and concise description of what you expected to happen. 57 | placeholder: | 58 | As a user, I expected ___ behavior but i am seeing ___ 59 | validations: 60 | required: true 61 | - type: textarea 62 | id: screenshots_or_videos 63 | attributes: 64 | label: Screenshots or Videos 65 | description: | 66 | If applicable, add screenshots or a video to help explain your problem. 67 | For more information on the supported file image/file types and the file size limits, please refer 68 | to the following link: https://docs.github.com/en/github/writing-on-github/working-with-advanced-formatting/attaching-files 69 | placeholder: | 70 | You can drag your video or image files inside of this editor ↓ 71 | - type: textarea 72 | id: platform 73 | attributes: 74 | label: Platform 75 | value: | 76 | - OS: [e.g. macOS, Windows, Linux] 77 | - Browser: [e.g. Chrome, Safari, Firefox] 78 | - Version: [e.g. 91.1] 79 | validations: 80 | required: true 81 | - type: textarea 82 | id: additional 83 | attributes: 84 | label: Additional context 85 | description: Add any other context about the problem here. 86 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/config.yml: -------------------------------------------------------------------------------- 1 | blank_issues_enabled: false 2 | contact_links: 3 | - name: 🤔 Feature Requests & Questions 4 | url: https://github.com/tiny-frontend/tiny-client/discussions 5 | about: Please ask and answer questions here. 6 | -------------------------------------------------------------------------------- /.github/workflows/deploy-package.yml: -------------------------------------------------------------------------------- 1 | name: Publish Package to npmjs 2 | on: 3 | release: 4 | types: [created] 5 | jobs: 6 | build: 7 | runs-on: ubuntu-latest 8 | steps: 9 | - uses: actions/checkout@v2 10 | # Setup .npmrc file to publish to npm 11 | - uses: actions/setup-node@v2 12 | with: 13 | node-version: '16.x' 14 | registry-url: 'https://registry.npmjs.org' 15 | - run: npm ci 16 | - run: npm run build 17 | - run: npm publish --access public 18 | env: 19 | NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} 20 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | .DS_Store 3 | dist 4 | dist-ssr 5 | *.local 6 | .idea 7 | -------------------------------------------------------------------------------- /LICENCE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2022 tiny frontend 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # tiny frontend client 2 | 3 | Library used to consume [tiny frontend](https://tiny-frontend.github.io/) modules at runtime. 4 | 5 | To learn more about how it works, [check out the docs](https://tiny-frontend.github.io/). 6 | -------------------------------------------------------------------------------- /jest.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | setupFilesAfterEnv: ["./jest.setup.ts"], 3 | preset: "ts-jest", 4 | transform: { 5 | "^.+\\.(ts)?$": "ts-jest", 6 | }, 7 | }; 8 | -------------------------------------------------------------------------------- /jest.setup.ts: -------------------------------------------------------------------------------- 1 | import "isomorphic-unfetch"; // Add global fetch 2 | 3 | import { server } from "./src/mocks/server"; 4 | 5 | beforeAll(() => server.listen()); 6 | afterEach(() => server.resetHandlers()); 7 | afterAll(() => server.close()); 8 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@tiny-frontend/client", 3 | "version": "0.0.10", 4 | "license": "MIT", 5 | "scripts": { 6 | "dev": "vite", 7 | "test": "jest", 8 | "build": "vite build && tsc --emitDeclarationOnly", 9 | "preview": "vite preview" 10 | }, 11 | "types": "./dist/index.d.ts", 12 | "files": [ 13 | "dist" 14 | ], 15 | "main": "./dist/tiny-client.umd.js", 16 | "module": "./dist/tiny-client.es.js", 17 | "exports": { 18 | ".": { 19 | "import": "./dist/tiny-client.es.js", 20 | "require": "./dist/tiny-client.umd.js" 21 | } 22 | }, 23 | "devDependencies": { 24 | "@cazoo/eslint-plugin-eslint": "^1.0.2", 25 | "@types/jest": "^27.5.0", 26 | "eslint": "^7.32.0", 27 | "jest": "^28.0.3", 28 | "msw": "^0.39.2", 29 | "isomorphic-unfetch": "^3.1.0", 30 | "ts-jest": "^28.0.0", 31 | "typescript": "^4.5.5", 32 | "vite": "^2.7.13" 33 | }, 34 | "dependencies": { 35 | "@ungap/global-this": "^0.4.4" 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /src/errors.ts: -------------------------------------------------------------------------------- 1 | export class TinyClientFetchError extends Error { 2 | responseBody: string | undefined; 3 | 4 | constructor( 5 | tinyFrontendName: string, 6 | contractVersion: string, 7 | message: string, 8 | responseBody?: string 9 | ) { 10 | super( 11 | `Failed to fetch tiny frontend ${tinyFrontendName} version ${contractVersion} from API, ${message}` 12 | ); 13 | this.name = "TinyClientFetchError"; 14 | this.responseBody = responseBody; 15 | } 16 | } 17 | 18 | export class TinyClientLoadBundleError extends Error { 19 | constructor(tinyFrontendName: string) { 20 | super(`Failed to load script for tiny frontend ${tinyFrontendName}`); 21 | this.name = "TinyClientLoadBundleError"; 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- 1 | export { loadTinyFrontendClient } from "./load.client"; 2 | export type { TinyFrontendSsrConfig, LoadingOptions } from "./types"; 3 | export * from "./load.server"; 4 | -------------------------------------------------------------------------------- /src/load.client.ts: -------------------------------------------------------------------------------- 1 | import { TinyClientLoadBundleError } from "./errors"; 2 | import { LoadTinyFrontendOptions, TinyFrontendModuleConfig } from "./types"; 3 | import { getTinyFrontendModuleConfig } from "./utils/getTinyFrontendModuleConfig"; 4 | import { loadUmdBundleClientWithCache } from "./utils/loadUmdBundle"; 5 | 6 | export const loadTinyFrontendClient = async ({ 7 | name, 8 | contractVersion, 9 | tinyApiEndpoint, 10 | dependenciesMap = {}, 11 | loadingOptions = {}, 12 | }: LoadTinyFrontendOptions): Promise => { 13 | const { retryPolicy, cacheTtlInMs = 2 * 60 * 1_000 } = loadingOptions; 14 | 15 | const tinyFrontendModuleConfigFromSsr = ( 16 | window as unknown as Record 17 | )[`tinyFrontend${name}Config`]; 18 | 19 | const tinyFrontendModuleConfig = 20 | tinyFrontendModuleConfigFromSsr ?? 21 | (await getTinyFrontendModuleConfig({ 22 | tinyFrontendName: name, 23 | contractVersion, 24 | hostname: tinyApiEndpoint, 25 | retryPolicy, 26 | cacheTtlInMs, 27 | })); 28 | 29 | if (tinyFrontendModuleConfig.cssBundle) { 30 | const cssBundleUrl = `${tinyApiEndpoint}/tiny/bundle/${tinyFrontendModuleConfig.cssBundle}`; 31 | if (!hasStylesheet(cssBundleUrl)) { 32 | const cssElement = document.createElement("link"); 33 | cssElement.rel = "stylesheet"; 34 | cssElement.href = cssBundleUrl; 35 | document.head.appendChild(cssElement); 36 | } 37 | } 38 | 39 | try { 40 | return await loadUmdBundleClientWithCache({ 41 | bundleUrl: `${tinyApiEndpoint}/tiny/bundle/${tinyFrontendModuleConfig.umdBundle}`, 42 | tinyFrontendName: name, 43 | dependenciesMap, 44 | baseCacheKey: `${name}-${contractVersion}`, 45 | retryPolicy, 46 | }); 47 | } catch (err) { 48 | console.error(err); 49 | throw new TinyClientLoadBundleError(name); 50 | } 51 | }; 52 | 53 | const hasStylesheet = (stylesheetHref: string): boolean => 54 | !!document.querySelector(`link[rel="stylesheet"][href="${stylesheetHref}"]`); 55 | -------------------------------------------------------------------------------- /src/load.server.ts: -------------------------------------------------------------------------------- 1 | import "@ungap/global-this"; 2 | 3 | import { TinyClientLoadBundleError } from "./errors"; 4 | import { LoadTinyFrontendOptions, TinyFrontendSsrConfig } from "./types"; 5 | import { getTinyFrontendModuleConfig } from "./utils/getTinyFrontendModuleConfig"; 6 | import { loadUmdBundleServerWithCache } from "./utils/loadUmdBundle"; 7 | 8 | export interface TinyFrontendServerResponse { 9 | tinyFrontend: T; 10 | tinyFrontendStringToAddToSsrResult: string; 11 | tinyFrontendSsrConfig: TinyFrontendSsrConfig; 12 | } 13 | 14 | export const loadTinyFrontendServer = async ({ 15 | name, 16 | contractVersion, 17 | tinyApiEndpoint, 18 | dependenciesMap = {}, 19 | loadingOptions = {}, 20 | }: LoadTinyFrontendOptions): Promise> => { 21 | const { retryPolicy, cacheTtlInMs = 2 * 60 * 1_000 } = loadingOptions; 22 | 23 | const tinyFrontendModuleConfig = await getTinyFrontendModuleConfig({ 24 | tinyFrontendName: name, 25 | contractVersion, 26 | hostname: tinyApiEndpoint, 27 | retryPolicy, 28 | cacheTtlInMs, 29 | }); 30 | 31 | const umdBundleUrl = `${tinyApiEndpoint}/tiny/bundle/${tinyFrontendModuleConfig.umdBundle}`; 32 | const cssBundleUrl = tinyFrontendModuleConfig.cssBundle 33 | ? `${tinyApiEndpoint}/tiny/bundle/${tinyFrontendModuleConfig.cssBundle}` 34 | : undefined; 35 | 36 | try { 37 | const tinyFrontend = await loadUmdBundleServerWithCache({ 38 | bundleUrl: umdBundleUrl, 39 | tinyFrontendName: name, 40 | dependenciesMap, 41 | baseCacheKey: `${name}-${contractVersion}`, 42 | retryPolicy, 43 | }); 44 | 45 | const moduleConfigScript = `window["tinyFrontend${name}Config"] = ${JSON.stringify( 46 | tinyFrontendModuleConfig 47 | )}`; 48 | 49 | const tinyFrontendStringToAddToSsrResult = ` 50 | ${cssBundleUrl ? `` : ""} 51 | 52 | `; 53 | 54 | const tinyFrontendSsrConfig: TinyFrontendSsrConfig = { 55 | cssBundle: cssBundleUrl, 56 | jsBundle: umdBundleUrl, 57 | moduleConfigScript, 58 | }; 59 | 60 | return { 61 | tinyFrontend, 62 | tinyFrontendStringToAddToSsrResult, 63 | tinyFrontendSsrConfig, 64 | }; 65 | } catch (err) { 66 | console.error(err); 67 | throw new TinyClientLoadBundleError(name); 68 | } 69 | }; 70 | -------------------------------------------------------------------------------- /src/mocks/server.ts: -------------------------------------------------------------------------------- 1 | // src/mocks/server.js 2 | import { setupServer } from "msw/node"; 3 | 4 | export const server = setupServer(); 5 | -------------------------------------------------------------------------------- /src/types.ts: -------------------------------------------------------------------------------- 1 | import { RetryPolicy } from "./utils/retry"; 2 | 3 | export interface LoadingOptions { 4 | cacheTtlInMs?: number; 5 | retryPolicy?: RetryPolicy; 6 | } 7 | 8 | export interface LoadTinyFrontendOptions { 9 | name: string; 10 | contractVersion: string; 11 | tinyApiEndpoint: string; 12 | dependenciesMap?: Record; 13 | loadingOptions?: LoadingOptions; 14 | } 15 | 16 | export interface TinyFrontendModuleConfig { 17 | umdBundle: string; 18 | cssBundle?: string; 19 | } 20 | 21 | export interface TinyFrontendSsrConfig { 22 | jsBundle: string; 23 | moduleConfigScript: string; 24 | cssBundle?: string; 25 | } 26 | -------------------------------------------------------------------------------- /src/utils/getTinyFrontendModuleConfig.test.ts: -------------------------------------------------------------------------------- 1 | import { rest } from "msw"; 2 | 3 | import { server } from "../mocks/server"; 4 | import { TinyFrontendModuleConfig } from "../types"; 5 | import { 6 | getTinyFrontendModuleConfig, 7 | moduleConfigPromiseCacheMap, 8 | } from "./getTinyFrontendModuleConfig"; 9 | 10 | describe("[getTinyFrontendModuleConfig]", () => { 11 | afterEach(() => moduleConfigPromiseCacheMap.clear()); 12 | 13 | it("should fetch the latest config and return it", async () => { 14 | server.use( 15 | rest.get( 16 | "https://mock.hostname/api/tiny/latest/MOCK_LIB_NAME/MOCK_LIB_VERSION", 17 | (_, res, ctx) => 18 | res( 19 | ctx.status(200), 20 | ctx.json({ 21 | umdBundle: "mockBundle.js", 22 | cssBundle: "mockBundle.css", 23 | } as TinyFrontendModuleConfig) 24 | ) 25 | ) 26 | ); 27 | 28 | const tinyFrontendModuleConfig = await getTinyFrontendModuleConfig({ 29 | tinyFrontendName: "MOCK_LIB_NAME", 30 | contractVersion: "MOCK_LIB_VERSION", 31 | hostname: "https://mock.hostname/api", 32 | }); 33 | 34 | expect(tinyFrontendModuleConfig).toEqual({ 35 | umdBundle: "mockBundle.js", 36 | cssBundle: "mockBundle.css", 37 | }); 38 | }); 39 | 40 | it.each` 41 | status 42 | ${400} 43 | ${401} 44 | ${403} 45 | ${500} 46 | `("should throw an error on $status", async ({ status }) => { 47 | server.use( 48 | rest.get( 49 | "https://mock.hostname/api/tiny/latest/MOCK_LIB_NAME/MOCK_LIB_VERSION", 50 | (_, res, ctx) => res(ctx.status(status)) 51 | ) 52 | ); 53 | 54 | await expect( 55 | getTinyFrontendModuleConfig({ 56 | tinyFrontendName: "MOCK_LIB_NAME", 57 | contractVersion: "MOCK_LIB_VERSION", 58 | hostname: "https://mock.hostname/api", 59 | }) 60 | ).rejects.toEqual( 61 | new Error( 62 | `Failed to fetch tiny frontend MOCK_LIB_NAME version MOCK_LIB_VERSION from API, with status ${status}` 63 | ) 64 | ); 65 | }); 66 | 67 | it("should throw an error on invalid JSON", async () => { 68 | server.use( 69 | rest.get( 70 | "https://mock.hostname/api/tiny/latest/MOCK_LIB_NAME/MOCK_LIB_VERSION", 71 | (_, res, ctx) => 72 | res(ctx.status(200), ctx.text("THIS IS NOT VALID JSON")) 73 | ) 74 | ); 75 | 76 | await expect( 77 | getTinyFrontendModuleConfig({ 78 | tinyFrontendName: "MOCK_LIB_NAME", 79 | contractVersion: "MOCK_LIB_VERSION", 80 | hostname: "https://mock.hostname/api", 81 | }) 82 | ).rejects.toEqual( 83 | new Error( 84 | `Failed to fetch tiny frontend MOCK_LIB_NAME version MOCK_LIB_VERSION from API, while getting JSON body with error: Unexpected token T in JSON at position 0` 85 | ) 86 | ); 87 | }); 88 | 89 | it("should retry fetching when passed a retry policy", async () => { 90 | let count = 0; 91 | server.use( 92 | rest.get( 93 | "https://mock.hostname/api/tiny/latest/MOCK_LIB_NAME/MOCK_LIB_VERSION", 94 | (_, res, ctx) => { 95 | if (count === 0) { 96 | count++; 97 | return res(ctx.status(400)); 98 | } 99 | return res( 100 | ctx.status(200), 101 | ctx.json({ 102 | umdBundle: "mockBundle.js", 103 | cssBundle: "mockBundle.css", 104 | } as TinyFrontendModuleConfig) 105 | ); 106 | } 107 | ) 108 | ); 109 | 110 | const tinyFrontendModuleConfig = await getTinyFrontendModuleConfig({ 111 | tinyFrontendName: "MOCK_LIB_NAME", 112 | contractVersion: "MOCK_LIB_VERSION", 113 | hostname: "https://mock.hostname/api", 114 | retryPolicy: { 115 | maxRetries: 1, 116 | delayInMs: 10, 117 | }, 118 | }); 119 | 120 | expect(tinyFrontendModuleConfig).toEqual({ 121 | umdBundle: "mockBundle.js", 122 | cssBundle: "mockBundle.css", 123 | }); 124 | }); 125 | 126 | describe("when using cache", () => { 127 | const mockGetTinyFrontendModuleConfigProps = { 128 | tinyFrontendName: "MOCK_LIB_NAME", 129 | contractVersion: "MOCK_LIB_VERSION", 130 | hostname: "https://mock.hostname/api", 131 | }; 132 | 133 | const expectedModuleConfig = { 134 | umdBundle: "mockBundle.js", 135 | cssBundle: "mockBundle.css", 136 | }; 137 | 138 | describe("when loading the bundle succeeds", () => { 139 | let apiCallsCount: number; 140 | 141 | beforeEach(() => { 142 | apiCallsCount = 0; 143 | 144 | server.use( 145 | rest.get( 146 | "https://mock.hostname/api/tiny/latest/MOCK_LIB_NAME/MOCK_LIB_VERSION", 147 | (_, res, ctx) => { 148 | apiCallsCount++; 149 | 150 | return res( 151 | ctx.status(200), 152 | ctx.json({ 153 | umdBundle: "mockBundle.js", 154 | cssBundle: "mockBundle.css", 155 | } as TinyFrontendModuleConfig) 156 | ); 157 | } 158 | ) 159 | ); 160 | }); 161 | 162 | describe("when called in parallel", () => { 163 | it("should reuse results", async () => { 164 | const [config1, config2] = await Promise.all([ 165 | getTinyFrontendModuleConfig(mockGetTinyFrontendModuleConfigProps), 166 | getTinyFrontendModuleConfig(mockGetTinyFrontendModuleConfigProps), 167 | ]); 168 | 169 | expect(config1).toEqual(config2); 170 | 171 | expect(apiCallsCount).toEqual(1); 172 | }); 173 | }); 174 | 175 | describe("when called in sequence", () => { 176 | it("should reuse results", async () => { 177 | const config1 = await getTinyFrontendModuleConfig( 178 | mockGetTinyFrontendModuleConfigProps 179 | ); 180 | expect(config1).toEqual(expectedModuleConfig); 181 | 182 | const config2 = await getTinyFrontendModuleConfig( 183 | mockGetTinyFrontendModuleConfigProps 184 | ); 185 | expect(config2).toEqual(expectedModuleConfig); 186 | 187 | expect(config1).toBe(config2); 188 | 189 | expect(apiCallsCount).toEqual(1); 190 | }); 191 | }); 192 | 193 | describe("when it has a ttl on the second call", () => { 194 | it("should expire after ttl has passed", async () => { 195 | const config1 = await getTinyFrontendModuleConfig( 196 | mockGetTinyFrontendModuleConfigProps 197 | ); 198 | expect(config1).toEqual(expectedModuleConfig); 199 | 200 | await new Promise((resolve) => setTimeout(resolve, 20)); 201 | 202 | const config2 = await getTinyFrontendModuleConfig({ 203 | ...mockGetTinyFrontendModuleConfigProps, 204 | cacheTtlInMs: 10, 205 | }); 206 | expect(config2).toEqual(expectedModuleConfig); 207 | 208 | expect(config1).not.toBe(config2); 209 | 210 | expect(apiCallsCount).toEqual(2); 211 | }); 212 | }); 213 | 214 | describe("when ttl is 0 on the second call", () => { 215 | it("should not use cache at all", async () => { 216 | const [config1, config2] = await Promise.all([ 217 | getTinyFrontendModuleConfig(mockGetTinyFrontendModuleConfigProps), 218 | getTinyFrontendModuleConfig({ 219 | ...mockGetTinyFrontendModuleConfigProps, 220 | cacheTtlInMs: 0, 221 | }), 222 | ]); 223 | 224 | expect(config1).toEqual(expectedModuleConfig); 225 | expect(config2).toEqual(expectedModuleConfig); 226 | expect(config1).not.toBe(config2); 227 | expect(apiCallsCount).toEqual(2); 228 | }); 229 | }); 230 | }); 231 | 232 | describe("when loading the config fails", () => { 233 | describe("when called in parallel", () => { 234 | it("should call the server only once and fail for all", async () => { 235 | let apiCallsCount = 0; 236 | 237 | server.use( 238 | rest.get( 239 | "https://mock.hostname/api/tiny/latest/MOCK_LIB_NAME/MOCK_LIB_VERSION", 240 | (_, res, ctx) => { 241 | apiCallsCount++; 242 | return res(ctx.status(400)); 243 | } 244 | ) 245 | ); 246 | 247 | const promise1 = getTinyFrontendModuleConfig( 248 | mockGetTinyFrontendModuleConfigProps 249 | ); 250 | const promise2 = getTinyFrontendModuleConfig( 251 | mockGetTinyFrontendModuleConfigProps 252 | ); 253 | 254 | await expect(promise1).rejects.toBeDefined(); 255 | await expect(promise2).rejects.toBeDefined(); 256 | 257 | expect(apiCallsCount).toEqual(1); 258 | }); 259 | }); 260 | 261 | describe("when called in sequence", () => { 262 | it("should not cache results and call the server again the second time", async () => { 263 | let apiCallsCount = 0; 264 | 265 | server.use( 266 | rest.get( 267 | "https://mock.hostname/api/tiny/latest/MOCK_LIB_NAME/MOCK_LIB_VERSION", 268 | (_, res, ctx) => { 269 | apiCallsCount++; 270 | return res(ctx.status(400)); 271 | } 272 | ) 273 | ); 274 | 275 | await expect( 276 | getTinyFrontendModuleConfig(mockGetTinyFrontendModuleConfigProps) 277 | ).rejects.toBeDefined(); 278 | await expect( 279 | getTinyFrontendModuleConfig(mockGetTinyFrontendModuleConfigProps) 280 | ).rejects.toBeDefined(); 281 | 282 | expect(apiCallsCount).toEqual(2); 283 | }); 284 | }); 285 | }); 286 | }); 287 | }); 288 | -------------------------------------------------------------------------------- /src/utils/getTinyFrontendModuleConfig.ts: -------------------------------------------------------------------------------- 1 | import { TinyClientFetchError } from "../errors"; 2 | import { TinyFrontendModuleConfig } from "../types"; 3 | import { retry, RetryPolicy } from "./retry"; 4 | 5 | interface GetTinyFrontendModuleConfigProps 6 | extends GetTinyFrontendModuleConfigBaseProps { 7 | cacheTtlInMs?: number; 8 | retryPolicy?: RetryPolicy; 9 | } 10 | 11 | interface ModuleConfigCacheItem { 12 | promise: Promise; 13 | timestamp: number; 14 | } 15 | 16 | const isCacheItemValid = ({ 17 | timestamp, 18 | ttlInMs, 19 | }: { 20 | timestamp: number; 21 | ttlInMs?: number; 22 | }) => ttlInMs == null || Date.now() - timestamp < ttlInMs; 23 | 24 | export const moduleConfigPromiseCacheMap = new Map< 25 | string, 26 | ModuleConfigCacheItem 27 | >(); 28 | 29 | export const getTinyFrontendModuleConfig = async ({ 30 | tinyFrontendName, 31 | contractVersion, 32 | hostname, 33 | retryPolicy = { 34 | maxRetries: 0, 35 | delayInMs: 0, 36 | }, 37 | cacheTtlInMs, 38 | }: GetTinyFrontendModuleConfigProps): Promise => { 39 | const cacheKey = `${tinyFrontendName}-${contractVersion}-${hostname}`; 40 | 41 | const cacheItem = moduleConfigPromiseCacheMap.get(cacheKey); 42 | if ( 43 | cacheItem && 44 | isCacheItemValid({ 45 | ttlInMs: cacheTtlInMs, 46 | timestamp: cacheItem.timestamp, 47 | }) 48 | ) { 49 | return cacheItem.promise; 50 | } 51 | 52 | const moduleConfigPromise = retry( 53 | () => 54 | getTinyFrontendModuleConfigBase({ 55 | tinyFrontendName, 56 | contractVersion: contractVersion, 57 | hostname, 58 | }), 59 | retryPolicy 60 | ).catch((err) => { 61 | moduleConfigPromiseCacheMap.delete(cacheKey); 62 | throw err; 63 | }); 64 | 65 | moduleConfigPromiseCacheMap.set(cacheKey, { 66 | promise: moduleConfigPromise, 67 | timestamp: Date.now(), 68 | }); 69 | 70 | return moduleConfigPromise; 71 | }; 72 | 73 | interface GetTinyFrontendModuleConfigBaseProps { 74 | tinyFrontendName: string; 75 | contractVersion: string; 76 | hostname: string; 77 | retryPolicy?: RetryPolicy; 78 | } 79 | 80 | const getTinyFrontendModuleConfigBase = async ({ 81 | tinyFrontendName, 82 | contractVersion, 83 | hostname, 84 | }: GetTinyFrontendModuleConfigBaseProps): Promise => { 85 | let response; 86 | 87 | try { 88 | response = await fetch( 89 | `${hostname}/tiny/latest/${tinyFrontendName}/${contractVersion}`, 90 | { mode: "cors" } 91 | ); 92 | } catch (err) { 93 | throw new TinyClientFetchError( 94 | tinyFrontendName, 95 | contractVersion, 96 | `with error: ${(err as Record)?.message}` 97 | ); 98 | } 99 | 100 | if (response.status >= 400) { 101 | throw new TinyClientFetchError( 102 | tinyFrontendName, 103 | contractVersion, 104 | `with status ${response.status}`, 105 | await response.text() 106 | ); 107 | } 108 | 109 | let responseJson: TinyFrontendModuleConfig; 110 | let responseText: string | undefined; 111 | 112 | try { 113 | responseText = await response.text(); 114 | responseJson = JSON.parse(responseText); 115 | } catch (err) { 116 | throw new TinyClientFetchError( 117 | tinyFrontendName, 118 | contractVersion, 119 | `while getting JSON body with error: ${ 120 | (err as Record)?.message 121 | }`, 122 | responseText 123 | ); 124 | } 125 | 126 | return responseJson; 127 | }; 128 | -------------------------------------------------------------------------------- /src/utils/loadUmdBundle.test.ts: -------------------------------------------------------------------------------- 1 | import { 2 | DefaultRequestBody, 3 | ResponseComposition, 4 | rest, 5 | RestContext, 6 | } from "msw"; 7 | 8 | import { server } from "../mocks/server"; 9 | import { 10 | loadUmdBundleServerWithCache, 11 | umdBundlesPromiseCacheMap, 12 | } from "./loadUmdBundle"; 13 | 14 | interface MockBundle { 15 | mockExport: string; 16 | } 17 | 18 | describe("[loadUmdBundle]", () => { 19 | afterEach(() => umdBundlesPromiseCacheMap.clear()); 20 | 21 | it("should load and return a UMD bundle", async () => { 22 | server.use( 23 | rest.get("https://mock.hostname/api/mockBundle.js", (_, res, ctx) => 24 | res( 25 | ctx.status(200), 26 | ctx.text('define([], () => ({ mockExport: "Hello World" }))') 27 | ) 28 | ) 29 | ); 30 | 31 | const umdBundle = await loadUmdBundleServerWithCache({ 32 | tinyFrontendName: "mockTinyFrontendName", 33 | bundleUrl: "https://mock.hostname/api/mockBundle.js", 34 | dependenciesMap: {}, 35 | baseCacheKey: "bundle-1.0.0", 36 | }); 37 | 38 | expect(umdBundle).toEqual({ mockExport: "Hello World" }); 39 | }); 40 | 41 | it("should provide dependencies to the UMD bundle", async () => { 42 | server.use( 43 | rest.get("https://mock.hostname/api/mockBundle.js", (_, res, ctx) => 44 | res( 45 | ctx.status(200), 46 | ctx.text(` 47 | define(['myMockDep', 'myMockDep2'], (myMockDep, myMockDep2) => ({ mockExport: \`\${myMockDep} - \${myMockDep2}\` })) 48 | `) 49 | ) 50 | ) 51 | ); 52 | 53 | const umdBundle = await loadUmdBundleServerWithCache({ 54 | tinyFrontendName: "mockTinyFrontendName", 55 | bundleUrl: "https://mock.hostname/api/mockBundle.js", 56 | dependenciesMap: { 57 | myMockDep: "MOCK_DEP", 58 | myMockDep2: "MOCK_DEP_2", 59 | }, 60 | baseCacheKey: "bundle-1.0.0", 61 | }); 62 | 63 | expect(umdBundle).toEqual({ mockExport: "MOCK_DEP - MOCK_DEP_2" }); 64 | }); 65 | 66 | it.each` 67 | status 68 | ${400} 69 | ${401} 70 | ${403} 71 | ${500} 72 | `("should throw an error on $status", async ({ status }) => { 73 | server.use( 74 | rest.get("https://mock.hostname/api/mockBundle.js", (_, res, ctx) => 75 | res(ctx.status(status)) 76 | ) 77 | ); 78 | 79 | await expect( 80 | loadUmdBundleServerWithCache({ 81 | tinyFrontendName: "mockTinyFrontendName", 82 | bundleUrl: "https://mock.hostname/api/mockBundle.js", 83 | dependenciesMap: {}, 84 | baseCacheKey: "bundle-1.0.0", 85 | }) 86 | ).rejects.toEqual( 87 | new Error( 88 | `Failed to fetch umd bundle at URL https://mock.hostname/api/mockBundle.js with status ${status}` 89 | ) 90 | ); 91 | }); 92 | 93 | describe.each` 94 | invalidReason | bundleCode | expectedError 95 | ${"doesn't call define"} | ${"const bob = 'Nothing to see here'"} | ${"Couldn't load umd bundle"} 96 | ${"throws in define"} | ${'define([], () => { throw new Error("FAILED") })'} | ${"FAILED"} 97 | `( 98 | "when the bundle source $invalidReason", 99 | ({ bundleCode, expectedError }) => { 100 | it(`should throw an error saying ${expectedError}`, async () => { 101 | server.use( 102 | rest.get("https://mock.hostname/api/mockBundle.js", (_, res, ctx) => 103 | res(ctx.status(200), ctx.text(bundleCode)) 104 | ) 105 | ); 106 | 107 | await expect( 108 | loadUmdBundleServerWithCache({ 109 | tinyFrontendName: "mockTinyFrontendName", 110 | bundleUrl: "https://mock.hostname/api/mockBundle.js", 111 | dependenciesMap: {}, 112 | baseCacheKey: "bundle-1.0.0", 113 | }) 114 | ).rejects.toEqual(new Error(expectedError)); 115 | }); 116 | } 117 | ); 118 | 119 | describe("when using a retry policy", () => { 120 | describe.each` 121 | failureReason | failureResponseBuilder 122 | ${"server returns non 200"} | ${(res: ResponseComposition, ctx: RestContext) => res(ctx.status(400))} 123 | ${"server returns an invalid module"} | ${(res: ResponseComposition, ctx: RestContext) => res(ctx.status(200), ctx.text('define([], () => { throw new Error("FAILED") })'))} 124 | `("when $failureReason", ({ failureResponseBuilder }) => { 125 | it("should retry and succeed", async () => { 126 | let count = 0; 127 | server.use( 128 | rest.get("https://mock.hostname/api/mockBundle.js", (_, res, ctx) => { 129 | if (count === 0) { 130 | count++; 131 | return failureResponseBuilder(res, ctx); 132 | } 133 | return res( 134 | ctx.status(200), 135 | ctx.text('define([], () => ({ mockExport: "Hello World" }))') 136 | ); 137 | }) 138 | ); 139 | 140 | const umdBundle = await loadUmdBundleServerWithCache({ 141 | tinyFrontendName: "mockTinyFrontendName", 142 | bundleUrl: "https://mock.hostname/api/mockBundle.js", 143 | dependenciesMap: {}, 144 | retryPolicy: { 145 | maxRetries: 1, 146 | delayInMs: 10, 147 | }, 148 | baseCacheKey: "bundle-1.0.0", 149 | }); 150 | 151 | expect(umdBundle).toEqual({ mockExport: "Hello World" }); 152 | }); 153 | }); 154 | }); 155 | 156 | describe("when using cache", () => { 157 | const mockLoadUmdBundleServerWithCacheOptions = { 158 | tinyFrontendName: "mockTinyFrontendName", 159 | bundleUrl: "https://mock.hostname/api/mockBundle.js", 160 | dependenciesMap: {}, 161 | baseCacheKey: "bundle-1.0.0", 162 | }; 163 | 164 | describe("when loading the bundle succeeds", () => { 165 | let apiCallsCount: number; 166 | beforeEach(() => { 167 | apiCallsCount = 0; 168 | 169 | server.use( 170 | rest.get("https://mock.hostname/api/mockBundle.js", (_, res, ctx) => { 171 | apiCallsCount++; 172 | return res( 173 | ctx.status(200), 174 | ctx.text('define([], () => ({ mockExport: "Hello World" }))') 175 | ); 176 | }) 177 | ); 178 | }); 179 | 180 | describe("when called in parallel", () => { 181 | it("should reuse results", async () => { 182 | const [umdBundle1, umdBundle2] = await Promise.all([ 183 | loadUmdBundleServerWithCache( 184 | mockLoadUmdBundleServerWithCacheOptions 185 | ), 186 | loadUmdBundleServerWithCache( 187 | mockLoadUmdBundleServerWithCacheOptions 188 | ), 189 | ]); 190 | 191 | expect(umdBundle1).toEqual({ mockExport: "Hello World" }); 192 | expect(umdBundle2).toEqual({ mockExport: "Hello World" }); 193 | expect(umdBundle1).toBe(umdBundle2); 194 | expect(apiCallsCount).toEqual(1); 195 | }); 196 | }); 197 | 198 | describe("when called in sequence", () => { 199 | it("should reuse results", async () => { 200 | const umdBundle1 = await loadUmdBundleServerWithCache( 201 | mockLoadUmdBundleServerWithCacheOptions 202 | ); 203 | expect(umdBundle1).toEqual({ mockExport: "Hello World" }); 204 | 205 | const umdBundle2 = await loadUmdBundleServerWithCache( 206 | mockLoadUmdBundleServerWithCacheOptions 207 | ); 208 | expect(umdBundle2).toEqual({ mockExport: "Hello World" }); 209 | 210 | expect(umdBundle1).toBe(umdBundle2); 211 | 212 | expect(apiCallsCount).toEqual(1); 213 | }); 214 | }); 215 | 216 | /* 217 | This behaviour avoids memory leaks if the host is a long-running process. 218 | This avoids having more than one bundle in memory for any given contract version. 219 | */ 220 | describe("when bundleUrl changes for a given baseCacheKey", () => { 221 | it("should bust the cache for the initial bundleUrl", async () => { 222 | server.use( 223 | rest.get( 224 | "https://mock.hostname/api/mockBundle2.js", 225 | (_, res, ctx) => 226 | res( 227 | ctx.status(200), 228 | ctx.text( 229 | 'define([], () => ({ mockExport: "This is bundle2" }))' 230 | ) 231 | ) 232 | ) 233 | ); 234 | 235 | const umdBundle1 = await loadUmdBundleServerWithCache( 236 | mockLoadUmdBundleServerWithCacheOptions 237 | ); 238 | expect(umdBundle1).toEqual({ mockExport: "Hello World" }); 239 | 240 | const umdBundle2 = await loadUmdBundleServerWithCache({ 241 | ...mockLoadUmdBundleServerWithCacheOptions, 242 | bundleUrl: "https://mock.hostname/api/mockBundle2.js", 243 | }); 244 | expect(umdBundle2).toEqual({ mockExport: "This is bundle2" }); 245 | 246 | expect(apiCallsCount).toEqual(1); 247 | 248 | const umdBundle1SecondTime = 249 | await loadUmdBundleServerWithCache( 250 | mockLoadUmdBundleServerWithCacheOptions 251 | ); 252 | expect(umdBundle1SecondTime).toEqual({ mockExport: "Hello World" }); 253 | 254 | expect(umdBundle1).not.toBe(umdBundle1SecondTime); 255 | expect(apiCallsCount).toEqual(2); 256 | }); 257 | }); 258 | }); 259 | 260 | describe("when loading the bundle fails", () => { 261 | describe("when called in parallel", () => { 262 | it("should call the server only once and fail for all", async () => { 263 | let apiCallsCount = 0; 264 | 265 | server.use( 266 | rest.get( 267 | "https://mock.hostname/api/mockBundle.js", 268 | (_, res, ctx) => { 269 | apiCallsCount++; 270 | return res(ctx.status(400)); 271 | } 272 | ) 273 | ); 274 | 275 | const promise1 = loadUmdBundleServerWithCache( 276 | mockLoadUmdBundleServerWithCacheOptions 277 | ); 278 | const promise2 = loadUmdBundleServerWithCache( 279 | mockLoadUmdBundleServerWithCacheOptions 280 | ); 281 | 282 | await expect(promise1).rejects.toBeDefined(); 283 | await expect(promise2).rejects.toBeDefined(); 284 | 285 | expect(apiCallsCount).toEqual(1); 286 | }); 287 | }); 288 | 289 | describe("when called in sequence", () => { 290 | it("should not cache results and call the server again the second time", async () => { 291 | let apiCallsCount = 0; 292 | 293 | server.use( 294 | rest.get( 295 | "https://mock.hostname/api/mockBundle.js", 296 | (_, res, ctx) => { 297 | apiCallsCount++; 298 | return res(ctx.status(400)); 299 | } 300 | ) 301 | ); 302 | 303 | await expect( 304 | loadUmdBundleServerWithCache( 305 | mockLoadUmdBundleServerWithCacheOptions 306 | ) 307 | ).rejects.toBeDefined(); 308 | await expect( 309 | loadUmdBundleServerWithCache( 310 | mockLoadUmdBundleServerWithCacheOptions 311 | ) 312 | ).rejects.toBeDefined(); 313 | 314 | expect(apiCallsCount).toEqual(2); 315 | }); 316 | }); 317 | }); 318 | }); 319 | }); 320 | -------------------------------------------------------------------------------- /src/utils/loadUmdBundle.ts: -------------------------------------------------------------------------------- 1 | import "@ungap/global-this"; 2 | 3 | import { retry, RetryPolicy } from "./retry"; 4 | 5 | interface UmdBundleCacheItem { 6 | bundleUrl: string; 7 | promise: Promise; 8 | } 9 | 10 | export const umdBundlesPromiseCacheMap = new Map(); 11 | 12 | interface LoadUmdBundleWithCacheProps { 13 | bundleUrl: string; 14 | tinyFrontendName: string; 15 | dependenciesMap: Record; 16 | baseCacheKey: string; 17 | retryPolicy?: RetryPolicy; 18 | } 19 | 20 | export const loadUmdBundleServerWithCache = ( 21 | props: LoadUmdBundleWithCacheProps 22 | ) => 23 | loadUmdBundleWithCache({ 24 | ...props, 25 | bundleLoader: bundleLoaderServer, 26 | }); 27 | 28 | export const loadUmdBundleClientWithCache = ( 29 | props: LoadUmdBundleWithCacheProps 30 | ) => 31 | loadUmdBundleWithCache({ 32 | ...props, 33 | bundleLoader: bundleLoaderClient, 34 | }); 35 | 36 | const loadUmdBundleWithCache = async ({ 37 | bundleUrl, 38 | tinyFrontendName, 39 | dependenciesMap, 40 | baseCacheKey, 41 | bundleLoader, 42 | retryPolicy = { 43 | maxRetries: 0, 44 | delayInMs: 0, 45 | }, 46 | }: LoadUmdBundleWithCacheProps & { 47 | bundleLoader: BundleLoader; 48 | }): Promise => { 49 | const cacheItem = umdBundlesPromiseCacheMap.get(baseCacheKey); 50 | if (cacheItem && cacheItem.bundleUrl === bundleUrl) { 51 | return cacheItem.promise as Promise; 52 | } 53 | 54 | const umdBundlePromise = retry( 55 | () => 56 | bundleLoader({ 57 | bundleUrl, 58 | dependenciesMap, 59 | tinyFrontendName, 60 | }), 61 | retryPolicy 62 | ).catch((err) => { 63 | umdBundlesPromiseCacheMap.delete(baseCacheKey); 64 | throw err; 65 | }); 66 | 67 | umdBundlesPromiseCacheMap.set(baseCacheKey, { 68 | bundleUrl, 69 | promise: umdBundlePromise, 70 | }); 71 | 72 | return umdBundlePromise; 73 | }; 74 | 75 | type BundleLoader = (props: BundleLoaderProps) => Promise; 76 | 77 | interface BundleLoaderProps { 78 | bundleUrl: string; 79 | tinyFrontendName: string; 80 | dependenciesMap: Record; 81 | } 82 | 83 | const bundleLoaderServer = async ({ 84 | bundleUrl, 85 | dependenciesMap, 86 | }: BundleLoaderProps): Promise => { 87 | const umdBundleSourceResponse = await fetch(bundleUrl); 88 | 89 | if (umdBundleSourceResponse.status >= 400) { 90 | throw new Error( 91 | `Failed to fetch umd bundle at URL ${bundleUrl} with status ${umdBundleSourceResponse.status}` 92 | ); 93 | } 94 | 95 | const umdBundleSource = await umdBundleSourceResponse.text(); 96 | 97 | return evalUmdBundle(umdBundleSource, dependenciesMap); 98 | }; 99 | 100 | const evalUmdBundle = ( 101 | umdBundleSource: string, 102 | dependenciesMap: Record 103 | ): T => { 104 | const previousDefine = globalThis.define; 105 | 106 | let module: T | undefined = undefined; 107 | globalThis.define = ( 108 | dependenciesName: string[], 109 | moduleFactory: (...args: unknown[]) => T 110 | ) => { 111 | module = moduleFactory( 112 | ...dependenciesName.map((dependencyName) => { 113 | const dependency = dependenciesMap[dependencyName]; 114 | if (!dependency) { 115 | console.error( 116 | `Couldn't find dependency ${dependencyName} in provided dependencies map`, 117 | dependenciesMap 118 | ); 119 | } 120 | return dependency; 121 | }) 122 | ); 123 | }; 124 | (globalThis.define as unknown as Record)["amd"] = true; 125 | 126 | try { 127 | new Function(umdBundleSource)(); 128 | } finally { 129 | globalThis.define = previousDefine; 130 | } 131 | 132 | if (!module) { 133 | throw new Error("Couldn't load umd bundle"); 134 | } 135 | 136 | return module; 137 | }; 138 | 139 | const bundleLoaderClient = async ({ 140 | bundleUrl, 141 | tinyFrontendName, 142 | dependenciesMap, 143 | }: BundleLoaderProps): Promise => { 144 | const script = document.createElement("script"); 145 | script.src = bundleUrl; 146 | 147 | const loadPromise = new Promise((resolve, reject) => { 148 | script.addEventListener("load", () => { 149 | resolve( 150 | (window.tinyFrontendExports as Record)[tinyFrontendName] 151 | ); 152 | }); 153 | script.addEventListener("error", (event) => { 154 | try { 155 | document.head.removeChild(script); 156 | } finally { 157 | reject(event.error); 158 | } 159 | }); 160 | }); 161 | 162 | window.tinyFrontendDeps = { 163 | ...window.tinyFrontendDeps, 164 | ...dependenciesMap, 165 | }; 166 | 167 | document.head.appendChild(script); 168 | 169 | return loadPromise; 170 | }; 171 | 172 | declare global { 173 | function define( 174 | deps: string[], 175 | moduleFactory: (...args: unknown[]) => any 176 | ): void; 177 | 178 | interface Window { 179 | tinyFrontendDeps: Record; 180 | tinyFrontendExports: Record; 181 | } 182 | } 183 | -------------------------------------------------------------------------------- /src/utils/retry.test.ts: -------------------------------------------------------------------------------- 1 | import * as retryModule from "./retry"; 2 | 3 | const { retry } = retryModule; 4 | 5 | describe("[retry]", () => { 6 | afterEach(() => jest.clearAllMocks()); 7 | 8 | describe("when there are retries configured", () => { 9 | it("should return if no error occurred", async () => { 10 | const maxRetries = 3; 11 | const result = () => "result"; 12 | 13 | const fnToRetry = jest.fn(() => Promise.resolve(result)); 14 | 15 | await expect( 16 | retry(fnToRetry, { delayInMs: 10, maxRetries }) 17 | ).resolves.toBe(result); 18 | }); 19 | it("should exhaust retries and throw an error after exhausting them", async () => { 20 | const maxRetries = 3; 21 | const error = new Error("Oh no, there was an error!"); 22 | 23 | const fnToRetry = jest.fn(() => Promise.reject(error)); 24 | 25 | await expect( 26 | retry(fnToRetry, { delayInMs: 10, maxRetries }) 27 | ).rejects.toBe(error); 28 | 29 | expect(fnToRetry).toBeCalledTimes(4); 30 | }); 31 | it("should increase the delay as retries fail", async () => { 32 | const maxRetries = 4; 33 | const error = new Error("Oh no, there was an error!"); 34 | 35 | const fnToRetry = jest.fn(() => Promise.reject(error)); 36 | const spy = jest.spyOn(retryModule, "retry"); 37 | 38 | await expect( 39 | retry(fnToRetry, { delayInMs: 10, maxRetries }) 40 | ).rejects.toBe(error); 41 | 42 | expect(spy).toBeCalledWith(fnToRetry, { delayInMs: 20, maxRetries: 3 }); 43 | expect(spy).toBeCalledWith(fnToRetry, { delayInMs: 40, maxRetries: 2 }); 44 | expect(spy).toBeCalledWith(fnToRetry, { delayInMs: 80, maxRetries: 1 }); 45 | }); 46 | }); 47 | 48 | describe("when retries are 0", () => { 49 | it("should not retry", async () => { 50 | const fnToRetry = jest.fn(() => 51 | Promise.reject(new Error("Something went wrong")) 52 | ); 53 | const spy = jest.spyOn(retryModule, "retry"); 54 | 55 | await expect( 56 | retry(fnToRetry, { delayInMs: 10, maxRetries: 0 }) 57 | ).rejects.toBeDefined(); 58 | 59 | expect(spy).toBeCalledTimes(0); 60 | }); 61 | }); 62 | }); 63 | -------------------------------------------------------------------------------- /src/utils/retry.ts: -------------------------------------------------------------------------------- 1 | export interface RetryPolicy { 2 | maxRetries: number; 3 | delayInMs: number; 4 | } 5 | 6 | const wait = (delay: number) => 7 | new Promise((resolve) => setTimeout(resolve, delay)); 8 | 9 | export const retry = async ( 10 | fnToRetry: () => Promise, 11 | retryPolicy: RetryPolicy 12 | ): Promise => { 13 | const { maxRetries, delayInMs } = retryPolicy; 14 | const onError = (error: Error) => { 15 | if (maxRetries <= 0) { 16 | throw error; 17 | } 18 | 19 | return wait(delayInMs).then(() => 20 | retry(fnToRetry, { 21 | delayInMs: delayInMs * 2, 22 | maxRetries: maxRetries - 1, 23 | }) 24 | ); 25 | }; 26 | try { 27 | return await fnToRetry(); 28 | } catch (error) { 29 | return onError(error as Error); 30 | } 31 | }; 32 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "declarationDir": "dist", 4 | "declaration": true, 5 | "target": "ESNext", 6 | "useDefineForClassFields": true, 7 | "module": "ESNext", 8 | "lib": ["ESNext", "DOM"], 9 | "moduleResolution": "Node", 10 | "strict": true, 11 | "sourceMap": true, 12 | "resolveJsonModule": true, 13 | "esModuleInterop": true, 14 | "noUnusedLocals": true, 15 | "noUnusedParameters": true, 16 | "noImplicitReturns": true, 17 | "skipLibCheck": true 18 | }, 19 | "include": ["./src"] 20 | } 21 | -------------------------------------------------------------------------------- /vite.config.ts: -------------------------------------------------------------------------------- 1 | import { defineConfig } from "vite"; 2 | 3 | // https://vitejs.dev/config/ 4 | export default defineConfig({ 5 | build: { 6 | lib: { 7 | entry: "./src/index.ts", 8 | name: "tinyClient", 9 | fileName: (format) => `tiny-client.${format}.js`, 10 | }, 11 | }, 12 | }); 13 | --------------------------------------------------------------------------------