diff --git a/.changeset/sour-mugs-nail.md b/.changeset/sour-mugs-nail.md new file mode 100644 index 00000000000..6600b11f144 --- /dev/null +++ b/.changeset/sour-mugs-nail.md @@ -0,0 +1,5 @@ +--- +'@clerk/clerk-js': patch +--- + +Internal refactoring of `createFapiClient()` to remove reliance on `Clerk` instance. diff --git a/packages/clerk-js/jest.config.js b/packages/clerk-js/jest.config.js index f6445ee7e19..07f2f206264 100644 --- a/packages/clerk-js/jest.config.js +++ b/packages/clerk-js/jest.config.js @@ -4,6 +4,11 @@ const { name } = require('./package.json'); const config = { displayName: name.replace('@clerk', ''), injectGlobals: true, + globals: { + __PKG_NAME__: '@clerk/clerk-js', + __PKG_VERSION__: 'test', + BUILD_ENABLE_NEW_COMPONENTS: '', + }, testEnvironment: '/jest.jsdom-with-timezone.ts', roots: ['/src'], diff --git a/packages/clerk-js/jest.setup.ts b/packages/clerk-js/jest.setup.ts index c9637424917..1cc099eab25 100644 --- a/packages/clerk-js/jest.setup.ts +++ b/packages/clerk-js/jest.setup.ts @@ -33,9 +33,6 @@ if (typeof window !== 'undefined') { })), }); - global.__PKG_NAME__ = ''; - global.__PKG_VERSION__ = ''; - //@ts-expect-error global.IntersectionObserver = class IntersectionObserver { constructor() {} diff --git a/packages/clerk-js/src/core/__tests__/fapiClient.test.ts b/packages/clerk-js/src/core/__tests__/fapiClient.test.ts index 43564da3f85..d86ccafb138 100644 --- a/packages/clerk-js/src/core/__tests__/fapiClient.test.ts +++ b/packages/clerk-js/src/core/__tests__/fapiClient.test.ts @@ -1,22 +1,22 @@ -import type { Clerk } from '@clerk/types'; +import type { InstanceType } from '@clerk/types'; import { SUPPORTED_FAPI_VERSION } from '../constants'; import { createFapiClient } from '../fapiClient'; -const mockedClerkInstance = { +const baseFapiClientOptions = { frontendApi: 'clerk.example.com', - version: '42.0.0', - session: { - id: 'deadbeef', + getSessionId() { + return 'sess_1qq9oy5GiNHxdR2XWU6gG6mIcBX'; }, -} as Clerk; + instanceType: 'production' as InstanceType, +}; -const fapiClient = createFapiClient(mockedClerkInstance); +const fapiClient = createFapiClient(baseFapiClientOptions); const proxyUrl = 'https://clerk.com/api/__clerk'; const fapiClientWithProxy = createFapiClient({ - ...mockedClerkInstance, + ...baseFapiClientOptions, proxyUrl, }); @@ -24,7 +24,7 @@ type RecursivePartial = { [P in keyof T]?: RecursivePartial; }; -// @ts-ignore +// @ts-ignore -- We don't need to fully satisfy the fetch types for the sake of this mock global.fetch = jest.fn(() => Promise.resolve>({ headers: { @@ -37,10 +37,9 @@ global.fetch = jest.fn(() => const oldWindowLocation = window.location; beforeAll(() => { - // @ts-ignore + // @ts-expect-error -- "The operand of a delete operator must be optional" delete window?.location; - // @ts-ignore window.location = Object.defineProperties( {}, { @@ -51,19 +50,11 @@ beforeAll(() => { value: 'http://test.host', }, }, - ); - - window.Clerk = { - // @ts-ignore - session: { - id: 'sess_1qq9oy5GiNHxdR2XWU6gG6mIcBX', - }, - }; + ) as Location; }); beforeEach(() => { - // @ts-ignore - global.fetch.mockClear(); + (global.fetch as jest.Mock).mockClear(); }); afterAll(() => { @@ -74,43 +65,43 @@ afterAll(() => { describe('buildUrl(options)', () => { it('returns the full frontend API URL', () => { expect(fapiClient.buildUrl({ path: '/foo' }).href).toBe( - `https://clerk.example.com/v1/foo?__clerk_api_version=${SUPPORTED_FAPI_VERSION}&_clerk_js_version=42.0.0`, + `https://clerk.example.com/v1/foo?__clerk_api_version=${SUPPORTED_FAPI_VERSION}&_clerk_js_version=test`, ); }); it('returns the full frontend API URL using proxy url', () => { expect(fapiClientWithProxy.buildUrl({ path: '/foo' }).href).toBe( - `${proxyUrl}/v1/foo?__clerk_api_version=${SUPPORTED_FAPI_VERSION}&_clerk_js_version=42.0.0`, + `${proxyUrl}/v1/foo?__clerk_api_version=${SUPPORTED_FAPI_VERSION}&_clerk_js_version=test`, ); }); it('adds _clerk_session_id as a query parameter if provided and path does not start with client or waitlist', () => { expect(fapiClient.buildUrl({ path: '/foo', sessionId: 'sess_42' }).href).toBe( - `https://clerk.example.com/v1/foo?__clerk_api_version=${SUPPORTED_FAPI_VERSION}&_clerk_js_version=42.0.0&_clerk_session_id=sess_42`, + `https://clerk.example.com/v1/foo?__clerk_api_version=${SUPPORTED_FAPI_VERSION}&_clerk_js_version=test&_clerk_session_id=sess_42`, ); expect(fapiClient.buildUrl({ path: '/client/foo', sessionId: 'sess_42' }).href).toBe( - `https://clerk.example.com/v1/client/foo?__clerk_api_version=${SUPPORTED_FAPI_VERSION}&_clerk_js_version=42.0.0`, + `https://clerk.example.com/v1/client/foo?__clerk_api_version=${SUPPORTED_FAPI_VERSION}&_clerk_js_version=test`, ); expect(fapiClient.buildUrl({ path: '/waitlist', sessionId: 'sess_42' }).href).toBe( - `https://clerk.example.com/v1/waitlist?__clerk_api_version=${SUPPORTED_FAPI_VERSION}&_clerk_js_version=42.0.0`, + `https://clerk.example.com/v1/waitlist?__clerk_api_version=${SUPPORTED_FAPI_VERSION}&_clerk_js_version=test`, ); }); it('parses search params is an object with string values', () => { expect(fapiClient.buildUrl({ path: '/foo', search: { test: '1' } }).href).toBe( - `https://clerk.example.com/v1/foo?test=1&__clerk_api_version=${SUPPORTED_FAPI_VERSION}&_clerk_js_version=42.0.0`, + `https://clerk.example.com/v1/foo?test=1&__clerk_api_version=${SUPPORTED_FAPI_VERSION}&_clerk_js_version=test`, ); }); it('parses string search params ', () => { expect(fapiClient.buildUrl({ path: '/foo', search: 'test=2' }).href).toBe( - `https://clerk.example.com/v1/foo?test=2&__clerk_api_version=${SUPPORTED_FAPI_VERSION}&_clerk_js_version=42.0.0`, + `https://clerk.example.com/v1/foo?test=2&__clerk_api_version=${SUPPORTED_FAPI_VERSION}&_clerk_js_version=test`, ); }); it('parses search params when value contains invalid url symbols', () => { expect(fapiClient.buildUrl({ path: '/foo', search: { bar: 'test=2' } }).href).toBe( - `https://clerk.example.com/v1/foo?bar=test%3D2&__clerk_api_version=${SUPPORTED_FAPI_VERSION}&_clerk_js_version=42.0.0`, + `https://clerk.example.com/v1/foo?bar=test%3D2&__clerk_api_version=${SUPPORTED_FAPI_VERSION}&_clerk_js_version=test`, ); }); @@ -123,7 +114,7 @@ describe('buildUrl(options)', () => { }, }).href, ).toBe( - `https://clerk.example.com/v1/foo?array=item1&array=item2&__clerk_api_version=${SUPPORTED_FAPI_VERSION}&_clerk_js_version=42.0.0`, + `https://clerk.example.com/v1/foo?array=item1&array=item2&__clerk_api_version=${SUPPORTED_FAPI_VERSION}&_clerk_js_version=test`, ); }); @@ -139,7 +130,7 @@ describe('buildUrl(options)', () => { test: undefined, }, }).href, - ).toBe('https://clerk.example.com/v1/foo?array=item1&array=item2&_clerk_js_version=42.0.0'); + ).toBe('https://clerk.example.com/v1/foo?array=item1&array=item2&_clerk_js_version=test'); }); const cases = [ @@ -160,7 +151,7 @@ describe('request', () => { }); expect(fetch).toHaveBeenCalledWith( - `https://clerk.example.com/v1/foo?__clerk_api_version=${SUPPORTED_FAPI_VERSION}&_clerk_js_version=42.0.0&_clerk_session_id=deadbeef`, + `https://clerk.example.com/v1/foo?__clerk_api_version=${SUPPORTED_FAPI_VERSION}&_clerk_js_version=test&_clerk_session_id=sess_1qq9oy5GiNHxdR2XWU6gG6mIcBX`, expect.objectContaining({ credentials: 'include', method: 'GET', @@ -175,7 +166,7 @@ describe('request', () => { }); expect(fetch).toHaveBeenCalledWith( - `${proxyUrl}/v1/foo?__clerk_api_version=${SUPPORTED_FAPI_VERSION}&_clerk_js_version=42.0.0&_clerk_session_id=deadbeef`, + `${proxyUrl}/v1/foo?__clerk_api_version=${SUPPORTED_FAPI_VERSION}&_clerk_js_version=test&_clerk_session_id=sess_1qq9oy5GiNHxdR2XWU6gG6mIcBX`, expect.objectContaining({ credentials: 'include', method: 'GET', @@ -185,8 +176,7 @@ describe('request', () => { }); it('returns array response as array', async () => { - // @ts-ignore - global.fetch.mockResolvedValueOnce( + (global.fetch as jest.Mock).mockResolvedValueOnce( Promise.resolve>({ headers: { get: jest.fn(() => 'sess_43'), diff --git a/packages/clerk-js/src/core/clerk.ts b/packages/clerk-js/src/core/clerk.ts index d555e07225c..8862630bca0 100644 --- a/packages/clerk-js/src/core/clerk.ts +++ b/packages/clerk-js/src/core/clerk.ts @@ -305,7 +305,15 @@ export class Clerk implements ClerkInterface { this.#publishableKey = key; this.#instanceType = publishableKey.instanceType; - this.#fapiClient = createFapiClient(this); + this.#fapiClient = createFapiClient({ + domain: (this.instanceType === 'development' && this.isSatellite && this.domain) || undefined, + frontendApi: this.frontendApi, + // this.instanceType is assigned above + instanceType: this.instanceType as InstanceType, + getSessionId: () => { + return this.session?.id; + }, + }); // This line is used for the piggy-backing mechanism BaseResource.clerk = this; } diff --git a/packages/clerk-js/src/core/fapiClient.ts b/packages/clerk-js/src/core/fapiClient.ts index 3c958b55940..5bbead2349e 100644 --- a/packages/clerk-js/src/core/fapiClient.ts +++ b/packages/clerk-js/src/core/fapiClient.ts @@ -1,7 +1,7 @@ import { isBrowserOnline } from '@clerk/shared/browser'; import { camelToSnake } from '@clerk/shared/underscore'; import { runWithExponentialBackOff } from '@clerk/shared/utils'; -import type { Clerk, ClerkAPIErrorJSON, ClientJSON } from '@clerk/types'; +import type { ClerkAPIErrorJSON, ClientJSON, InstanceType } from '@clerk/types'; import { buildEmailAddress as buildEmailAddressUtil, buildURL as buildUrlUtil, stringifyQueryParams } from '../utils'; import { SUPPORTED_FAPI_VERSION } from './constants'; @@ -33,10 +33,7 @@ export type FapiResponse = Response & { payload: FapiResponseJSON | null; }; -export type FapiRequestCallback = ( - request: FapiRequestInit, - response?: FapiResponse, -) => Promise | unknown | false; +export type FapiRequestCallback = (request: FapiRequestInit, response?: FapiResponse) => unknown; // TODO: Move to @clerk/types export interface FapiResponseJSON { @@ -64,7 +61,15 @@ export interface FapiClient { // List of paths that should not receive the session ID parameter in the URL const unauthorizedPathPrefixes = ['/client', '/waitlist']; -export function createFapiClient(clerkInstance: Clerk): FapiClient { +type FapiClientOptions = { + frontendApi: string; + domain?: string; + proxyUrl?: string; + instanceType: InstanceType; + getSessionId: () => string | undefined; +}; + +export function createFapiClient(options: FapiClientOptions): FapiClient { const onBeforeRequestCallbacks: Array> = []; const onAfterResponseCallbacks: Array> = []; @@ -77,8 +82,7 @@ export function createFapiClient(clerkInstance: Clerk): FapiClient { } async function runBeforeRequestCallbacks(requestInit: FapiRequestInit) { - //@ts-expect-error - const windowCallback = typeof window !== 'undefined' && (window as never).__unstable__onBeforeRequest; + const windowCallback = typeof window !== 'undefined' && (window as any).__unstable__onBeforeRequest; for await (const callback of [windowCallback, ...onBeforeRequestCallbacks].filter(s => s)) { if ((await callback(requestInit)) === false) { return false; @@ -104,16 +108,15 @@ export function createFapiClient(clerkInstance: Clerk): FapiClient { // Append supported FAPI version to the query string searchParams.append('__clerk_api_version', SUPPORTED_FAPI_VERSION); - if (clerkInstance.version) { - searchParams.append('_clerk_js_version', clerkInstance.version); - } + searchParams.append('_clerk_js_version', __PKG_VERSION__); if (rotatingTokenNonce) { searchParams.append('rotating_token_nonce', rotatingTokenNonce); } - if (clerkInstance.instanceType === 'development' && clerkInstance.isSatellite) { - searchParams.append('__domain', clerkInstance.domain); + // if (clerkInstance.instanceType === 'development' && clerkInstance.isSatellite) { + if (options.domain) { + searchParams.append('__domain', options.domain); } // Due to a known Safari bug regarding CORS requests, we are forced to always use GET or POST method. @@ -142,12 +145,10 @@ export function createFapiClient(clerkInstance: Clerk): FapiClient { function buildUrl(requestInit: FapiRequestInit): URL { const { path, pathPrefix = 'v1' } = requestInit; - const { proxyUrl, domain, frontendApi, instanceType } = clerkInstance; + const domainOnlyInProd = options.instanceType === 'production' ? options.domain : ''; - const domainOnlyInProd = instanceType === 'production' ? domain : ''; - - if (proxyUrl) { - const proxyBase = new URL(proxyUrl); + if (options.proxyUrl) { + const proxyBase = new URL(options.proxyUrl); const proxyPath = proxyBase.pathname.slice(1, proxyBase.pathname.length); return buildUrlUtil( { @@ -159,9 +160,11 @@ export function createFapiClient(clerkInstance: Clerk): FapiClient { ); } + const baseUrl = `https://${domainOnlyInProd || options.frontendApi}`; + return buildUrlUtil( { - base: `https://${domainOnlyInProd || frontendApi}`, + base: baseUrl, pathname: `${pathPrefix}${path}`, search: buildQueryString(requestInit), }, @@ -172,38 +175,36 @@ export function createFapiClient(clerkInstance: Clerk): FapiClient { function buildEmailAddress(localPart: string): string { return buildEmailAddressUtil({ localPart, - frontendApi: clerkInstance.frontendApi, + frontendApi: options.frontendApi, }); } - async function request(_requestInit: FapiRequestInit, options?: FapiRequestOptions): Promise> { + async function request( + _requestInit: FapiRequestInit, + requestOptions?: FapiRequestOptions, + ): Promise> { const requestInit = { ..._requestInit }; const { method = 'GET', body } = requestInit; requestInit.url = buildUrl({ ...requestInit, // TODO: Pass these values to the FAPI client instead of calculating them on the spot - sessionId: clerkInstance.session?.id, + sessionId: options.getSessionId(), }); - // Initialize the headers if they're not provided. - if (!requestInit.headers) { - requestInit.headers = new Headers(); - } + // Normalize requestInit.headers + requestInit.headers = new Headers(requestInit.headers); // Set the default content type for non-GET requests. // Skip for FormData, because the browser knows how to construct it later on. // Skip if the content-type header has already been set, somebody intends to override it. - // @ts-ignore if (method !== 'GET' && !(body instanceof FormData) && !requestInit.headers.has('content-type')) { - // @ts-ignore requestInit.headers.set('content-type', 'application/x-www-form-urlencoded'); } // Massage the body depending on the content type if needed. // Currently, this is needed only for form-urlencoded, so that the values reach the server in the form // foo=bar&baz=bar&whatever=1 - // @ts-ignore if (requestInit.headers.get('content-type') === 'application/x-www-form-urlencoded') { // The native BodyInit type is too wide for our use case, @@ -229,7 +230,7 @@ export function createFapiClient(clerkInstance: Clerk): FapiClient { try { if (beforeRequestCallbacksResult) { - const maxTries = options?.fetchMaxTries ?? (isBrowserOnline() ? 4 : 11); + const maxTries = requestOptions?.fetchMaxTries ?? (isBrowserOnline() ? 4 : 11); response = // retry only on GET requests for safety overwrittenRequestMethod === 'GET' diff --git a/packages/clerk-js/src/core/resources/__tests__/Session.test.ts b/packages/clerk-js/src/core/resources/__tests__/Session.test.ts index 4ae056d0681..1f267f62ce3 100644 --- a/packages/clerk-js/src/core/resources/__tests__/Session.test.ts +++ b/packages/clerk-js/src/core/resources/__tests__/Session.test.ts @@ -1,11 +1,19 @@ -import type { OrganizationJSON, SessionJSON } from '@clerk/types'; +import type { InstanceType, OrganizationJSON, SessionJSON } from '@clerk/types'; import { eventBus } from '../../events'; import { createFapiClient } from '../../fapiClient'; -import { clerkMock, createUser, mockDevClerkInstance, mockJwt, mockNetworkFailedFetch } from '../../test/fixtures'; +import { clerkMock, createUser, mockJwt, mockNetworkFailedFetch } from '../../test/fixtures'; import { SessionTokenCache } from '../../tokenCache'; import { BaseResource, Organization, Session } from '../internal'; +const baseFapiClientOptions = { + frontendApi: 'clerk.example.com', + getSessionId() { + return 'sess_1qq9oy5GiNHxdR2XWU6gG6mIcBX'; + }, + instanceType: 'development' as InstanceType, +}; + describe('Session', () => { afterEach(() => { SessionTokenCache.clear(); @@ -183,7 +191,7 @@ describe('Session', () => { } as SessionJSON); mockNetworkFailedFetch(); - BaseResource.clerk = { getFapiClient: () => createFapiClient(mockDevClerkInstance) } as any; + BaseResource.clerk = { getFapiClient: () => createFapiClient(baseFapiClientOptions) } as any; const token = await session.getToken(); diff --git a/packages/clerk-js/src/core/resources/__tests__/Token.test.ts b/packages/clerk-js/src/core/resources/__tests__/Token.test.ts index e9b3e18cf60..94e5e473354 100644 --- a/packages/clerk-js/src/core/resources/__tests__/Token.test.ts +++ b/packages/clerk-js/src/core/resources/__tests__/Token.test.ts @@ -1,27 +1,36 @@ +import type { InstanceType } from '@clerk/types'; + import { SUPPORTED_FAPI_VERSION } from '../../constants'; import { createFapiClient } from '../../fapiClient'; -import { mockDevClerkInstance, mockFetch, mockNetworkFailedFetch } from '../../test/fixtures'; +import { mockFetch, mockNetworkFailedFetch } from '../../test/fixtures'; import { BaseResource } from '../internal'; import { Token } from '../Token'; +const baseFapiClientOptions = { + frontendApi: 'clerk.example.com', + getSessionId() { + return ''; + }, + instanceType: 'development' as InstanceType, +}; + describe('Token', () => { describe('create', () => { afterEach(() => { - // @ts-ignore - global.fetch?.mockClear(); + (global.fetch as jest.Mock)?.mockClear(); BaseResource.clerk = null as any; }); it('with http 500 throws error', async () => { mockFetch(false, 500); - BaseResource.clerk = { getFapiClient: () => createFapiClient(mockDevClerkInstance) } as any; + BaseResource.clerk = { getFapiClient: () => createFapiClient(baseFapiClientOptions) } as any; await expect(Token.create('/path/to/tokens')).rejects.toMatchObject({ message: '500', }); expect(global.fetch).toHaveBeenCalledWith( - `https://clerk.example.com/v1/path/to/tokens?__clerk_api_version=${SUPPORTED_FAPI_VERSION}&_clerk_js_version=test-0.0.0`, + `https://clerk.example.com/v1/path/to/tokens?__clerk_api_version=${SUPPORTED_FAPI_VERSION}&_clerk_js_version=test`, // TODO(dimkl): omit extra params from fetch request (eg path, url) - remove expect.objectContaining expect.objectContaining({ method: 'POST', @@ -53,12 +62,12 @@ describe('Token', () => { it('create returns empty raw string', async () => { mockNetworkFailedFetch(); - BaseResource.clerk = { getFapiClient: () => createFapiClient(mockDevClerkInstance) } as any; + BaseResource.clerk = { getFapiClient: () => createFapiClient(baseFapiClientOptions) } as any; const token = await Token.create('/path/to/tokens'); expect(global.fetch).toHaveBeenCalledWith( - `https://clerk.example.com/v1/path/to/tokens?__clerk_api_version=${SUPPORTED_FAPI_VERSION}&_clerk_js_version=test-0.0.0`, + `https://clerk.example.com/v1/path/to/tokens?__clerk_api_version=${SUPPORTED_FAPI_VERSION}&_clerk_js_version=test`, // TODO(dimkl): omit extra params from fetch request (eg path, url) - remove expect.objectContaining expect.objectContaining({ method: 'POST', @@ -76,14 +85,14 @@ describe('Token', () => { describe('with online browser and network failure', () => { it('throws error', async () => { mockNetworkFailedFetch(); - BaseResource.clerk = { getFapiClient: () => createFapiClient(mockDevClerkInstance) } as any; + BaseResource.clerk = { getFapiClient: () => createFapiClient(baseFapiClientOptions) } as any; await expect(Token.create('/path/to/tokens')).rejects.toThrow( - `ClerkJS: Network error at "https://clerk.example.com/v1/path/to/tokens?__clerk_api_version=${SUPPORTED_FAPI_VERSION}&_clerk_js_version=test-0.0.0" - TypeError: Failed to fetch. Please try again.`, + `ClerkJS: Network error at "https://clerk.example.com/v1/path/to/tokens?__clerk_api_version=${SUPPORTED_FAPI_VERSION}&_clerk_js_version=test" - TypeError: Failed to fetch. Please try again.`, ); expect(global.fetch).toHaveBeenCalledWith( - `https://clerk.example.com/v1/path/to/tokens?__clerk_api_version=${SUPPORTED_FAPI_VERSION}&_clerk_js_version=test-0.0.0`, + `https://clerk.example.com/v1/path/to/tokens?__clerk_api_version=${SUPPORTED_FAPI_VERSION}&_clerk_js_version=test`, // TODO(dimkl): omit extra params from fetch request (eg path, url) - remove expect.objectContaining expect.objectContaining({ method: 'POST',