Skip to content

Commit

Permalink
chore(clerk-js): Refactor fapiClient to not rely on Clerk instance (#…
Browse files Browse the repository at this point in the history
  • Loading branch information
brkalow authored Jan 21, 2025
1 parent b55db4d commit 9da41ce
Show file tree
Hide file tree
Showing 8 changed files with 105 additions and 82 deletions.
5 changes: 5 additions & 0 deletions .changeset/sour-mugs-nail.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@clerk/clerk-js': patch
---

Internal refactoring of `createFapiClient()` to remove reliance on `Clerk` instance.
5 changes: 5 additions & 0 deletions packages/clerk-js/jest.config.js
Original file line number Diff line number Diff line change
Expand Up @@ -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: '<rootDir>/jest.jsdom-with-timezone.ts',
roots: ['<rootDir>/src'],
Expand Down
3 changes: 0 additions & 3 deletions packages/clerk-js/jest.setup.ts
Original file line number Diff line number Diff line change
Expand Up @@ -33,9 +33,6 @@ if (typeof window !== 'undefined') {
})),
});

global.__PKG_NAME__ = '';
global.__PKG_VERSION__ = '';

//@ts-expect-error
global.IntersectionObserver = class IntersectionObserver {
constructor() {}
Expand Down
60 changes: 25 additions & 35 deletions packages/clerk-js/src/core/__tests__/fapiClient.test.ts
Original file line number Diff line number Diff line change
@@ -1,30 +1,30 @@
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,
});

type RecursivePartial<T> = {
[P in keyof T]?: RecursivePartial<T[P]>;
};

// @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<RecursivePartial<Response>>({
headers: {
Expand All @@ -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(
{},
{
Expand All @@ -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(() => {
Expand All @@ -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`,
);
});

Expand All @@ -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`,
);
});

Expand All @@ -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 = [
Expand All @@ -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',
Expand All @@ -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',
Expand All @@ -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<RecursivePartial<Response>>({
headers: {
get: jest.fn(() => 'sess_43'),
Expand Down
10 changes: 9 additions & 1 deletion packages/clerk-js/src/core/clerk.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
Expand Down
61 changes: 31 additions & 30 deletions packages/clerk-js/src/core/fapiClient.ts
Original file line number Diff line number Diff line change
@@ -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';
Expand Down Expand Up @@ -33,10 +33,7 @@ export type FapiResponse<T> = Response & {
payload: FapiResponseJSON<T> | null;
};

export type FapiRequestCallback<T> = (
request: FapiRequestInit,
response?: FapiResponse<T>,
) => Promise<unknown | false> | unknown | false;
export type FapiRequestCallback<T> = (request: FapiRequestInit, response?: FapiResponse<T>) => unknown;

// TODO: Move to @clerk/types
export interface FapiResponseJSON<T> {
Expand Down Expand Up @@ -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<FapiRequestCallback<unknown>> = [];
const onAfterResponseCallbacks: Array<FapiRequestCallback<unknown>> = [];

Expand All @@ -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;
Expand All @@ -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.
Expand Down Expand Up @@ -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(
{
Expand All @@ -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),
},
Expand All @@ -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<T>(_requestInit: FapiRequestInit, options?: FapiRequestOptions): Promise<FapiResponse<T>> {
async function request<T>(
_requestInit: FapiRequestInit,
requestOptions?: FapiRequestOptions,
): Promise<FapiResponse<T>> {
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,
Expand All @@ -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'
Expand Down
Loading

0 comments on commit 9da41ce

Please sign in to comment.