Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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: '',
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I guess since you removed that code in another PR we can remove that global?

},
Comment on lines +7 to +11
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It felt more expected to define the global variables here. Moved and added values for the __PKG_ vars.


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) {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Left-over code

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
Loading