fix: adds vercel style guide to @formbricks/js-core (#4520)

This commit is contained in:
Anshuman Pandey
2024-12-30 19:30:37 +05:30
committed by GitHub
parent 1fe625a9b4
commit 93e9ec867c
32 changed files with 519 additions and 450 deletions

View File

@@ -3,7 +3,6 @@
// method -> PUT (to be the same as the signedUrl method)
import { responses } from "@/app/lib/api/response";
import { getBiggerUploadFileSizePermission } from "@/modules/ee/license-check/lib/utils";
import { headers } from "next/headers";
import { NextRequest } from "next/server";
import { ENCRYPTION_KEY, UPLOADS_DIR } from "@formbricks/lib/constants";
import { validateLocalSignedUrl } from "@formbricks/lib/crypto";
@@ -24,8 +23,7 @@ export const OPTIONS = async (): Promise<Response> => {
headers: {
"Access-Control-Allow-Origin": "*",
"Access-Control-Allow-Methods": "GET, POST, PUT, DELETE, OPTIONS",
"Access-Control-Allow-Headers":
"Content-Type, Authorization, X-File-Name, X-File-Type, X-Survey-ID, X-Signature, X-Timestamp, X-UUID",
"Access-Control-Allow-Headers": "Content-Type, Authorization",
},
}
);
@@ -36,15 +34,14 @@ export const POST = async (req: NextRequest, context: Context): Promise<Response
const environmentId = params.environmentId;
const accessType = "private"; // private files are accessible only by authorized users
const headersList = await headers();
const fileType = headersList.get("X-File-Type");
const encodedFileName = headersList.get("X-File-Name");
const surveyId = headersList.get("X-Survey-ID");
const signedSignature = headersList.get("X-Signature");
const signedUuid = headersList.get("X-UUID");
const signedTimestamp = headersList.get("X-Timestamp");
const formData = await req.json();
const fileType = formData.fileType as string;
const encodedFileName = formData.fileName as string;
const surveyId = formData.surveyId as string;
const signedSignature = formData.signature as string;
const signedUuid = formData.uuid as string;
const signedTimestamp = formData.timestamp as string;
if (!fileType) {
return responses.badRequestResponse("contentType is required");
@@ -101,7 +98,6 @@ export const POST = async (req: NextRequest, context: Context): Promise<Response
return responses.unauthorizedResponse();
}
const formData = await req.json();
const base64String = formData.fileBase64String as string;
const buffer = Buffer.from(base64String.split(",")[1], "base64");

View File

@@ -1,6 +1,6 @@
import { type TAttributeUpdateInput } from "@formbricks/types/attributes";
import { type Result } from "@formbricks/types/error-handlers";
import { type ForbiddenError, type NetworkError } from "@formbricks/types/errors";
import { type ApiErrorResponse } from "@formbricks/types/errors";
import { makeRequest } from "../../utils/make-request";
export class AttributeAPI {
@@ -15,10 +15,7 @@ export class AttributeAPI {
async update(
attributeUpdateInput: Omit<TAttributeUpdateInput, "environmentId">
): Promise<
Result<
{ changed: boolean; message: string; details?: Record<string, string> },
NetworkError | Error | ForbiddenError
>
Result<{ changed: boolean; message: string; details?: Record<string, string> }, ApiErrorResponse>
> {
// transform all attributes to string if attributes are present into a new attributes copy
const attributes: Record<string, string> = {};

View File

@@ -1,6 +1,6 @@
import { type TDisplayCreateInput } from "@formbricks/types/displays";
import { type Result } from "@formbricks/types/error-handlers";
import { type ForbiddenError, type NetworkError } from "@formbricks/types/errors";
import { type ApiErrorResponse } from "@formbricks/types/errors";
import { makeRequest } from "../../utils/make-request";
export class DisplayAPI {
@@ -14,7 +14,7 @@ export class DisplayAPI {
async create(
displayInput: Omit<TDisplayCreateInput, "environmentId">
): Promise<Result<{ id: string }, ForbiddenError | NetworkError | Error>> {
): Promise<Result<{ id: string }, ApiErrorResponse>> {
return makeRequest(this.apiHost, `/api/v1/client/${this.environmentId}/displays`, "POST", displayInput);
}
}

View File

@@ -1,5 +1,5 @@
import { type Result } from "@formbricks/types/error-handlers";
import { type ForbiddenError, type NetworkError } from "@formbricks/types/errors";
import { type ApiErrorResponse } from "@formbricks/types/errors";
import { type TResponseInput, type TResponseUpdateInput } from "@formbricks/types/responses";
import { makeRequest } from "../../utils/make-request";
@@ -16,7 +16,7 @@ export class ResponseAPI {
async create(
responseInput: Omit<TResponseInput, "environmentId">
): Promise<Result<{ id: string }, ForbiddenError | NetworkError | Error>> {
): Promise<Result<{ id: string }, ApiErrorResponse>> {
return makeRequest(this.apiHost, `/api/v1/client/${this.environmentId}/responses`, "POST", responseInput);
}
@@ -28,7 +28,7 @@ export class ResponseAPI {
ttc,
variables,
language,
}: TResponseUpdateInputWithResponseId): Promise<Result<object, NetworkError | Error | ForbiddenError>> {
}: TResponseUpdateInputWithResponseId): Promise<Result<object, ApiErrorResponse>> {
return makeRequest(this.apiHost, `/api/v1/client/${this.environmentId}/responses/${responseId}`, "PUT", {
finished,
endingId,

View File

@@ -47,18 +47,18 @@ export class StorageAPI {
const { signedUrl, fileUrl, signingData, presignedFields, updatedFileName } = data;
let requestHeaders: Record<string, string> = {};
let localUploadDetails: Record<string, string> = {};
if (signingData) {
const { signature, timestamp, uuid } = signingData;
requestHeaders = {
"X-File-Type": file.type,
"X-File-Name": encodeURIComponent(updatedFileName),
"X-Survey-ID": surveyId ?? "",
"X-Signature": signature,
"X-Timestamp": String(timestamp),
"X-UUID": uuid,
localUploadDetails = {
fileType: file.type,
fileName: encodeURIComponent(updatedFileName),
surveyId: surveyId ?? "",
signature,
timestamp: String(timestamp),
uuid,
};
}
@@ -91,14 +91,12 @@ export class StorageAPI {
try {
uploadResponse = await fetch(signedUrlCopy, {
method: "POST",
...(signingData
? {
headers: {
...requestHeaders,
},
}
: {}),
body: presignedFields ? formDataForS3 : JSON.stringify(formData),
body: presignedFields
? formDataForS3
: JSON.stringify({
...formData,
...localUploadDetails,
}),
});
} catch (err) {
console.error("Error uploading file", err);

View File

@@ -1,3 +1,5 @@
import { type ApiErrorResponse } from "@formbricks/types/errors";
export interface ApiConfig {
environmentId: string;
apiHost: string;
@@ -8,17 +10,3 @@ export type ApiResponse = ApiSuccessResponse | ApiErrorResponse;
export interface ApiSuccessResponse<T = Record<string, unknown>> {
data: T;
}
export interface ApiErrorResponse {
code:
| "not_found"
| "gone"
| "bad_request"
| "internal_server_error"
| "unauthorized"
| "method_not_allowed"
| "not_authenticated"
| "forbidden";
message: string;
details: Record<string, string | string[] | number | number[] | boolean | boolean[]>;
}

View File

@@ -1,13 +1,13 @@
import { type Result, err, ok, wrapThrowsAsync } from "@formbricks/types/error-handlers";
import { type ForbiddenError, type NetworkError } from "@formbricks/types/errors";
import type { ApiErrorResponse, ApiResponse, ApiSuccessResponse } from "../types";
import { type ApiErrorResponse } from "@formbricks/types/errors";
import { type ApiResponse, type ApiSuccessResponse } from "../types";
export const makeRequest = async <T>(
apiHost: string,
endpoint: string,
method: "GET" | "POST" | "PUT" | "DELETE",
data?: unknown
): Promise<Result<T, NetworkError | Error | ForbiddenError>> => {
): Promise<Result<T, ApiErrorResponse>> => {
const url = new URL(apiHost + endpoint);
const body = data ? JSON.stringify(data) : undefined;
@@ -19,7 +19,8 @@ export const makeRequest = async <T>(
body,
});
if (!res.ok) return err(res.error);
// TODO: Only return api error response relevant keys
if (!res.ok) return err(res.error as unknown as ApiErrorResponse);
const response = res.data;
const json = (await response.json()) as ApiResponse;
@@ -31,7 +32,7 @@ export const makeRequest = async <T>(
status: response.status,
message: errorResponse.message || "Something went wrong",
url,
...(Object.keys(errorResponse.details).length > 0 && { details: errorResponse.details }),
...(Object.keys(errorResponse.details ?? {}).length > 0 && { details: errorResponse.details }),
});
}

View File

@@ -1,4 +1,7 @@
module.exports = {
extends: ["@formbricks/eslint-config/legacy-library.js"],
parser: "@typescript-eslint/parser",
extends: ["@formbricks/eslint-config/library.js"],
parserOptions: {
project: "tsconfig.json",
tsconfigRootDir: __dirname,
},
};

View File

@@ -1,12 +1,13 @@
import { TJsConfigInput, TJsTrackProperties } from "@formbricks/types/js";
/* eslint-disable import/no-default-export -- We need default exports for the js sdk */
import { type TJsConfigInput, type TJsTrackProperties } from "@formbricks/types/js";
import { trackCodeAction } from "./lib/actions";
import { getApi } from "./lib/api";
import { setAttributeInApp } from "./lib/attributes";
import { CommandQueue } from "./lib/commandQueue";
import { CommandQueue } from "./lib/command-queue";
import { ErrorHandler } from "./lib/errors";
import { initialize } from "./lib/initialize";
import { Logger } from "./lib/logger";
import { checkPageUrl } from "./lib/noCodeActions";
import { checkPageUrl } from "./lib/no-code-actions";
import { logoutPerson, resetPerson } from "./lib/person";
const logger = Logger.getInstance();
@@ -14,18 +15,18 @@ const logger = Logger.getInstance();
logger.debug("Create command queue");
const queue = new CommandQueue();
const init = async (initConfig: TJsConfigInput) => {
const init = async (initConfig: TJsConfigInput): Promise<void> => {
ErrorHandler.init(initConfig.errorHandler);
queue.add(false, initialize, initConfig);
await queue.wait();
};
const setEmail = async (email: string): Promise<void> => {
setAttribute("email", email);
await setAttribute("email", email);
await queue.wait();
};
const setAttribute = async (key: string, value: any): Promise<void> => {
const setAttribute = async (key: string, value: string): Promise<void> => {
queue.add(true, setAttributeInApp, key, value);
await queue.wait();
};
@@ -40,8 +41,8 @@ const reset = async (): Promise<void> => {
await queue.wait();
};
const track = async (name: string, properties?: TJsTrackProperties): Promise<void> => {
queue.add<any>(true, trackCodeAction, name, properties);
const track = async (code: string, properties?: TJsTrackProperties): Promise<void> => {
queue.add(true, trackCodeAction, code, properties);
await queue.wait();
};
@@ -61,5 +62,5 @@ const formbricks = {
getApi,
};
export type TFormbricksApp = typeof formbricks;
export default formbricks as TFormbricksApp;
export type TFormbricks = typeof formbricks;
export default formbricks;

View File

@@ -1,6 +1,6 @@
import { TJsTrackProperties } from "@formbricks/types/js";
import { type TJsTrackProperties } from "@formbricks/types/js";
import { Config } from "./config";
import { InvalidCodeError, NetworkError, Result, err, okVoid } from "./errors";
import { type InvalidCodeError, type NetworkError, type Result, err, okVoid } from "./errors";
import { Logger } from "./logger";
import { triggerSurvey } from "./widget";
@@ -12,14 +12,14 @@ export const trackAction = async (
alias?: string,
properties?: TJsTrackProperties
): Promise<Result<void, NetworkError>> => {
const aliasName = alias || name;
const aliasName = alias ?? name;
logger.debug(`Formbricks: Action "${aliasName}" tracked`);
// get a list of surveys that are collecting insights
const activeSurveys = config.get().filteredSurveys;
if (!!activeSurveys && activeSurveys.length > 0) {
if (Boolean(activeSurveys) && activeSurveys.length > 0) {
for (const survey of activeSurveys) {
for (const trigger of survey.triggers) {
if (trigger.actionClass.name === name) {
@@ -41,7 +41,7 @@ export const trackCodeAction = (
const actionClasses = config.get().environmentState.data.actionClasses;
const codeActionClasses = actionClasses.filter((action) => action.type === "code");
const action = codeActionClasses.find((action) => action.key === code);
const action = codeActionClasses.find((codeActionClass) => codeActionClass.key === code);
if (!action) {
return err({

View File

@@ -1,10 +1,10 @@
import { FormbricksAPI } from "@formbricks/api";
import { TAttributes } from "@formbricks/types/attributes";
import { ForbiddenError } from "@formbricks/types/errors";
import { type TAttributes } from "@formbricks/types/attributes";
import { type ApiErrorResponse } from "@formbricks/types/errors";
import { Config } from "./config";
import { MissingPersonError, NetworkError, Result, err, ok, okVoid } from "./errors";
import { type Result, err, ok, okVoid } from "./errors";
import { Logger } from "./logger";
import { fetchPersonState } from "./personState";
import { fetchPersonState } from "./person-state";
import { filterSurveys } from "./utils";
const config = Config.getInstance();
@@ -20,7 +20,7 @@ export const updateAttribute = async (
message: string;
details?: Record<string, string>;
},
NetworkError | ForbiddenError
ApiErrorResponse
>
> => {
const { apiHost, environmentId } = config.get();
@@ -31,7 +31,7 @@ export const updateAttribute = async (
code: "network_error",
status: 500,
message: "Missing userId",
url: `${apiHost}/api/v1/client/${environmentId}/contacts/${userId}/attributes`,
url: new URL(`${apiHost}/api/v1/client/${environmentId}/contacts/${userId ?? ""}/attributes`),
responseMessage: "Missing userId",
});
}
@@ -44,9 +44,9 @@ export const updateAttribute = async (
const res = await api.client.attribute.update({ userId, attributes: { [key]: value } });
if (!res.ok) {
// @ts-expect-error
if (res.error.details?.ignore) {
logger.error(res.error.message ?? `Error updating person with userId ${userId}`);
const responseError = res.error;
if (responseError.details?.ignore) {
logger.error(responseError.message);
return {
ok: true,
value: {
@@ -57,8 +57,8 @@ export const updateAttribute = async (
}
return err({
code: (res.error as ForbiddenError).code ?? "network_error",
status: (res.error as NetworkError | ForbiddenError).status ?? 500,
code: res.error.code,
status: res.error.status,
message: `Error updating person with userId ${userId}`,
url: new URL(`${apiHost}/api/v1/client/${environmentId}/contacts/${userId}/attributes`),
responseMessage: res.error.message,
@@ -66,8 +66,8 @@ export const updateAttribute = async (
}
if (res.data.details) {
Object.entries(res.data.details).forEach(([key, value]) => {
logger.error(`${key}: ${value}`);
Object.entries(res.data.details).forEach(([detailsKey, detailsValue]) => {
logger.error(`${detailsKey}: ${detailsValue}`);
});
}
@@ -103,7 +103,7 @@ export const updateAttributes = async (
environmentId: string,
userId: string,
attributes: TAttributes
): Promise<Result<TAttributes, NetworkError | ForbiddenError>> => {
): Promise<Result<TAttributes, ApiErrorResponse>> => {
// clean attributes and remove existing attributes if config already exists
const updatedAttributes = { ...attributes };
@@ -113,7 +113,7 @@ export const updateAttributes = async (
return ok(updatedAttributes);
}
logger.debug("Updating attributes: " + JSON.stringify(updatedAttributes));
logger.debug(`Updating attributes: ${JSON.stringify(updatedAttributes)}`);
const api = new FormbricksAPI({
apiHost,
@@ -130,27 +130,28 @@ export const updateAttributes = async (
}
return ok(updatedAttributes);
} else {
// @ts-expect-error
if (res.error.details?.ignore) {
logger.error(res.error.message ?? `Error updating person with userId ${userId}`);
return ok(updatedAttributes);
}
return err({
code: (res.error as ForbiddenError).code ?? "network_error",
status: (res.error as NetworkError | ForbiddenError).status ?? 500,
message: `Error updating person with userId ${userId}`,
url: new URL(`${apiHost}/api/v1/client/${environmentId}/people/${userId}/attributes`),
responseMessage: res.error.message,
});
}
const responseError = res.error;
if (responseError.details?.ignore) {
logger.error(responseError.message);
return ok(updatedAttributes);
}
return err({
code: responseError.code,
status: responseError.status,
message: `Error updating person with userId ${userId}`,
url: new URL(`${apiHost}/api/v1/client/${environmentId}/people/${userId}/attributes`),
responseMessage: responseError.responseMessage,
});
};
export const setAttributeInApp = async (
key: string,
value: any
): Promise<Result<void, NetworkError | MissingPersonError>> => {
value: string
): Promise<Result<void, ApiErrorResponse>> => {
if (key === "userId") {
logger.error("Setting userId is no longer supported. Please set the userId in the init call instead.");
return okVoid();
@@ -158,7 +159,7 @@ export const setAttributeInApp = async (
const userId = config.get().personState.data.userId;
logger.debug("Setting attribute: " + key + " to value: " + value);
logger.debug(`Setting attribute: ${key} to value: ${value}`);
if (!userId) {
logger.error(
@@ -194,12 +195,11 @@ export const setAttributeInApp = async (
}
return okVoid();
} else {
const error = result.error;
if (error && error.code === "forbidden") {
logger.error(`Authorization error: ${error.responseMessage}`);
}
}
const error = result.error;
if (error.code === "forbidden") {
logger.error(`Authorization error: ${error.responseMessage ?? ""}`);
}
return err(result.error as NetworkError);
return err(result.error);
};

View File

@@ -0,0 +1,91 @@
import { wrapThrowsAsync } from "@formbricks/types/error-handlers";
import { ErrorHandler, type Result } from "./errors";
import { checkInitialized } from "./initialize";
// Define a base type for acceptable return types
type CommandReturnType = Promise<Result<void, unknown>> | Result<void, unknown> | Promise<void>;
// Define a type for functions that return our accepted return types
type CommandFunction<Args extends unknown[] = unknown[]> = (...args: Args) => CommandReturnType;
type TArgs = unknown[];
// Define a queue item type that's generic over the function and its arguments
interface QueueItem<F extends CommandFunction> {
command: F;
checkInitialized: boolean;
commandArgs: Parameters<F>;
}
export class CommandQueue {
private queue: QueueItem<CommandFunction>[] = [];
private running = false;
private resolvePromise: (() => void) | null = null;
private commandPromise: Promise<void> | null = null;
// Make add generic over the function type and its parameters
public add<Args extends TArgs>(
checkInitializedArg: boolean,
command: CommandFunction<Args>,
...args: Args
): void {
this.queue.push({
command: command as CommandFunction,
checkInitialized: checkInitializedArg,
commandArgs: args,
});
if (!this.running) {
this.commandPromise = new Promise((resolve) => {
this.resolvePromise = resolve;
void this.run();
});
}
}
public async wait(): Promise<void> {
if (this.running) {
await this.commandPromise;
}
}
private async run(): Promise<void> {
this.running = true;
while (this.queue.length > 0) {
const errorHandler = ErrorHandler.getInstance();
const currentItem = this.queue.shift();
if (!currentItem) continue;
if (currentItem.checkInitialized) {
const initResult = checkInitialized();
if (!initResult.ok) {
errorHandler.handle(initResult.error);
continue;
}
}
const executeCommand = async (): Promise<Result<void, unknown>> => {
return (await currentItem.command.apply(null, currentItem.commandArgs)) as Result<void, unknown>;
};
const result = await wrapThrowsAsync(executeCommand)();
if (result.ok) {
if (!result.data.ok) {
errorHandler.handle(result.data.error);
}
}
if (!result.ok) {
errorHandler.handle(result.error);
}
}
this.running = false;
if (this.resolvePromise) {
this.resolvePromise();
this.resolvePromise = null;
this.commandPromise = null;
}
}
}

View File

@@ -1,80 +0,0 @@
import { wrapThrowsAsync } from "@formbricks/types/error-handlers";
import { ErrorHandler, Result } from "./errors";
import { checkInitialized } from "./initialize";
export class CommandQueue {
private queue: {
command: (args: any) => Promise<Result<void, any>> | Result<void, any> | Promise<void>;
checkInitialized: boolean;
commandArgs: any[any];
}[] = [];
private running: boolean = false;
private resolvePromise: (() => void) | null = null;
private commandPromise: Promise<void> | null = null;
public add<A>(
checkInitialized: boolean = true,
command: (...args: A[]) => Promise<Result<void, any>> | Result<void, any> | Promise<void>,
...args: A[]
) {
this.queue.push({ command, checkInitialized, commandArgs: args });
if (!this.running) {
this.commandPromise = new Promise((resolve) => {
this.resolvePromise = resolve;
this.run();
});
}
}
public async wait() {
if (this.running) {
await this.commandPromise;
}
}
private async run() {
this.running = true;
while (this.queue.length > 0) {
const errorHandler = ErrorHandler.getInstance();
const currentItem = this.queue.shift();
if (!currentItem) continue;
// make sure formbricks is initialized
if (currentItem.checkInitialized) {
// call different function based on package type
const initResult = checkInitialized();
if (initResult && initResult.ok !== true) {
errorHandler.handle(initResult.error);
continue;
}
}
const executeCommand = async () => {
return (await currentItem?.command.apply(null, currentItem?.commandArgs)) as Result<void, any>;
};
const result = await wrapThrowsAsync(executeCommand)();
if (!result) continue;
if (result.ok) {
if (result.data && !result.data.ok) {
errorHandler.handle(result.data.error);
}
}
if (result.ok !== true) {
errorHandler.handle(result.error);
}
}
this.running = false;
if (this.resolvePromise) {
this.resolvePromise();
this.resolvePromise = null;
this.commandPromise = null;
}
}
}

View File

@@ -1,6 +1,6 @@
import { TJsConfig, TJsConfigUpdateInput } from "@formbricks/types/js";
import { type TJsConfig, type TJsConfigUpdateInput } from "@formbricks/types/js";
import { JS_LOCAL_STORAGE_KEY } from "./constants";
import { Result, err, ok, wrapThrows } from "./errors";
import { type Result, err, ok, wrapThrows } from "./errors";
export class Config {
private static instance: Config | undefined;
@@ -22,18 +22,16 @@ export class Config {
}
public update(newConfig: TJsConfigUpdateInput): void {
if (newConfig) {
this.config = {
...this.config,
...newConfig,
status: {
value: newConfig.status?.value || "success",
expiresAt: newConfig.status?.expiresAt || null,
},
};
this.config = {
...this.config,
...newConfig,
status: {
value: newConfig.status?.value ?? "success",
expiresAt: newConfig.status?.expiresAt ?? null,
},
};
this.saveToStorage();
}
void this.saveToStorage();
}
public get(): TJsConfig {
@@ -43,7 +41,7 @@ export class Config {
return this.config;
}
public loadFromLocalStorage(): Result<TJsConfig, Error> {
public loadFromLocalStorage(): Result<TJsConfig> {
if (typeof window !== "undefined") {
const savedConfig = localStorage.getItem(JS_LOCAL_STORAGE_KEY);
if (savedConfig) {
@@ -54,8 +52,8 @@ export class Config {
// check if the config has expired
if (
parsedConfig.environmentState?.expiresAt &&
new Date(parsedConfig.environmentState.expiresAt) <= new Date()
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition -- In case of an error, we don't have environmentState
new Date(parsedConfig.environmentState?.expiresAt) <= new Date()
) {
return err(new Error("Config in local storage has expired"));
}
@@ -67,18 +65,18 @@ export class Config {
return err(new Error("No or invalid config in local storage"));
}
private async saveToStorage(): Promise<Result<Promise<void>, Error>> {
return wrapThrows(async () => {
await localStorage.setItem(JS_LOCAL_STORAGE_KEY, JSON.stringify(this.config));
private saveToStorage(): Result<void> {
return wrapThrows(() => {
localStorage.setItem(JS_LOCAL_STORAGE_KEY, JSON.stringify(this.config));
})();
}
// reset the config
public async resetConfig(): Promise<Result<Promise<void>, Error>> {
public resetConfig(): Result<void> {
this.config = null;
return wrapThrows(async () => {
return wrapThrows(() => {
localStorage.removeItem(JS_LOCAL_STORAGE_KEY);
})();
}

View File

@@ -1,5 +1,6 @@
// shared functions for environment and person state(s)
import { TJsEnvironmentState, TJsEnvironmentSyncParams } from "@formbricks/types/js";
import { type ApiErrorResponse } from "@formbricks/types/errors";
import { type TJsEnvironmentState, type TJsEnvironmentSyncParams } from "@formbricks/types/js";
import { Config } from "./config";
import { err } from "./errors";
import { Logger } from "./logger";
@@ -19,9 +20,9 @@ let environmentStateSyncIntervalId: number | null = null;
*/
export const fetchEnvironmentState = async (
{ apiHost, environmentId }: TJsEnvironmentSyncParams,
noCache: boolean = false
noCache = false
): Promise<TJsEnvironmentState> => {
let fetchOptions: RequestInit = {};
const fetchOptions: RequestInit = {};
if (noCache || getIsDebug()) {
fetchOptions.cache = "no-cache";
@@ -33,9 +34,9 @@ export const fetchEnvironmentState = async (
const response = await fetch(url, fetchOptions);
if (!response.ok) {
const jsonRes = await response.json();
const jsonRes = (await response.json()) as { message: string };
const error = err({
const error = err<ApiErrorResponse>({
code: "network_error",
status: response.status,
message: "Error syncing with backend",
@@ -43,14 +44,15 @@ export const fetchEnvironmentState = async (
responseMessage: jsonRes.message,
});
throw error;
// eslint-disable-next-line @typescript-eslint/only-throw-error -- error.error
throw error.error;
}
const data = await response.json();
const data = (await response.json()) as { data: TJsEnvironmentState["data"] };
const { data: state } = data;
return {
data: { ...(state as TJsEnvironmentState["data"]) },
data: { ...state },
expiresAt: new Date(new Date().getTime() + 1000 * 60 * 30), // 30 minutes
};
};
@@ -59,14 +61,14 @@ export const fetchEnvironmentState = async (
* Add a listener to check if the environment state has expired with a certain interval
*/
export const addEnvironmentStateExpiryCheckListener = (): void => {
let updateInterval = 1000 * 60; // every minute
const updateInterval = 1000 * 60; // every minute
if (typeof window !== "undefined" && environmentStateSyncIntervalId === null) {
environmentStateSyncIntervalId = window.setInterval(async () => {
const intervalHandler = async (): Promise<void> => {
const expiresAt = config.get().environmentState.expiresAt;
try {
// check if the environmentState has not expired yet
if (expiresAt && new Date(expiresAt) >= new Date()) {
if (new Date(expiresAt) >= new Date()) {
return;
}
@@ -88,13 +90,15 @@ export const addEnvironmentStateExpiryCheckListener = (): void => {
environmentState,
filteredSurveys,
});
} catch (e) {
console.error(`Error during expiry check: ${e}`);
} catch (e: unknown) {
logger.error(`Error during expiry check: ${e as string}`);
logger.debug("Extending config and try again later.");
const existingConfig = config.get();
config.update(existingConfig);
}
}, updateInterval);
};
environmentStateSyncIntervalId = window.setInterval(() => void intervalHandler(), updateInterval);
}
};

View File

@@ -1,10 +1,17 @@
/* eslint-disable no-console -- Required for logging a warning here */
import { Logger } from "./logger";
export type { ZErrorHandler } from "@formbricks/types/errors";
export type ResultError<T> = { ok: false; error: T };
export interface ResultError<T> {
ok: false;
error: T;
}
export type ResultOk<T> = { ok: true; value: T };
export interface ResultOk<T> {
ok: true;
value: T;
}
export type Result<T, E = Error> = ResultOk<T> | ResultError<E>;
@@ -20,14 +27,14 @@ export const err = <E = Error>(error: E): ResultError<E> => ({
export const wrap =
<T, R>(fn: (value: T) => R) =>
(result: Result<T>): Result<R> =>
result.ok === true ? { ok: true, value: fn(result.value) } : result;
result.ok ? { ok: true, value: fn(result.value) } : result;
export function match<TSuccess, TError, TReturn>(
result: Result<TSuccess, TError>,
onSuccess: (value: TSuccess) => TReturn,
onError: (error: TError) => TReturn
) {
if (result.ok === true) {
): TReturn {
if (result.ok) {
return onSuccess(result.value);
}
@@ -48,7 +55,7 @@ if (result.ok === true) {
}
*/
export const wrapThrows =
<T, A extends any[]>(fn: (...args: A) => T) =>
<T, A extends unknown[]>(fn: (...args: A) => T) =>
(...args: A): Result<T> => {
try {
return {
@@ -63,58 +70,60 @@ export const wrapThrows =
}
};
export type NetworkError = {
export interface NetworkError {
code: "network_error";
status: number;
message: string;
url: string;
responseMessage: string;
};
}
export type MissingFieldError = {
export interface MissingFieldError {
code: "missing_field";
field: string;
};
}
export type InvalidMatchTypeError = {
export interface InvalidMatchTypeError {
code: "invalid_match_type";
message: string;
};
}
export type MissingPersonError = {
export interface MissingPersonError {
code: "missing_person";
message: string;
};
}
export type NotInitializedError = {
export interface NotInitializedError {
code: "not_initialized";
message: string;
};
}
export type AttributeAlreadyExistsError = {
export interface AttributeAlreadyExistsError {
code: "attribute_already_exists";
message: string;
};
}
export type InvalidCodeError = {
export interface InvalidCodeError {
code: "invalid_code";
message: string;
};
}
const logger = Logger.getInstance();
export class ErrorHandler {
private static instance: ErrorHandler | null;
private handleError: (error: any) => void;
public customized: boolean = false;
private handleError: (error: unknown) => void;
public customized = false;
public static initialized = false;
private constructor(errorHandler?: (error: any) => void) {
private constructor(errorHandler?: (error: unknown) => void) {
if (errorHandler) {
this.handleError = errorHandler;
this.customized = true;
} else {
this.handleError = (err) => Logger.getInstance().error(JSON.stringify(err));
this.handleError = (error) => {
Logger.getInstance().error(JSON.stringify(error));
};
}
}
@@ -126,7 +135,7 @@ export class ErrorHandler {
return ErrorHandler.instance;
}
static init(errorHandler?: (error: any) => void): void {
static init(errorHandler?: (error: unknown) => void): void {
this.initialized = true;
ErrorHandler.instance = new ErrorHandler(errorHandler);
@@ -136,7 +145,7 @@ export class ErrorHandler {
logger.debug(`Custom error handler: ${this.customized ? "yes" : "no"}`);
}
public handle(error: any): void {
public handle(error: unknown): void {
console.warn("🧱 Formbricks - Global error: ", error);
this.handleError(error);
}

View File

@@ -1,7 +1,7 @@
import {
addEnvironmentStateExpiryCheckListener,
clearEnvironmentStateExpiryCheckListener,
} from "./environmentState";
} from "./environment-state";
import {
addClickEventListener,
addExitIntentListener,
@@ -11,8 +11,8 @@ import {
removeExitIntentListener,
removePageUrlEventListeners,
removeScrollDepthListener,
} from "./noCodeActions";
import { addPersonStateExpiryCheckListener, clearPersonStateExpiryCheckListener } from "./personState";
} from "./no-code-actions";
import { addPersonStateExpiryCheckListener, clearPersonStateExpiryCheckListener } from "./person-state";
let areRemoveEventListenersAdded = false;

View File

@@ -1,5 +1,6 @@
import { TAttributes } from "@formbricks/types/attributes";
import { type ForbiddenError } from "@formbricks/types/errors";
/* eslint-disable no-console -- required for logging */
import { type TAttributes } from "@formbricks/types/attributes";
import { type ApiErrorResponse } from "@formbricks/types/errors";
import { type TJsConfig, type TJsConfigInput } from "@formbricks/types/js";
import { trackNoCodeAction } from "./actions";
import { updateAttributes } from "./attributes";
@@ -9,22 +10,20 @@ import {
LEGACY_JS_APP_LOCAL_STORAGE_KEY,
LEGACY_JS_WEBSITE_LOCAL_STORAGE_KEY,
} from "./constants";
import { fetchEnvironmentState } from "./environmentState";
import { fetchEnvironmentState } from "./environment-state";
import {
ErrorHandler,
MissingFieldError,
MissingPersonError,
NetworkError,
NotInitializedError,
Result,
type MissingFieldError,
type NotInitializedError,
type Result,
err,
okVoid,
wrapThrows,
} from "./errors";
import { addCleanupEventListeners, addEventListeners, removeAllEventListeners } from "./eventListeners";
import { addCleanupEventListeners, addEventListeners, removeAllEventListeners } from "./event-listeners";
import { Logger } from "./logger";
import { checkPageUrl } from "./noCodeActions";
import { DEFAULT_PERSON_STATE_NO_USER_ID, fetchPersonState } from "./personState";
import { checkPageUrl } from "./no-code-actions";
import { DEFAULT_PERSON_STATE_NO_USER_ID, fetchPersonState } from "./person-state";
import { filterSurveys, getIsDebug } from "./utils";
import { addWidgetContainer, removeWidgetContainer, setIsSurveyRunning } from "./widget";
@@ -32,7 +31,7 @@ const logger = Logger.getInstance();
let isInitialized = false;
export const setIsInitialized = (value: boolean) => {
export const setIsInitialized = (value: boolean): void => {
isInitialized = value;
};
@@ -42,7 +41,13 @@ const migrateLocalStorage = (): { changed: boolean; newState?: TJsConfig } => {
if (oldWebsiteConfig) {
localStorage.removeItem(LEGACY_JS_WEBSITE_LOCAL_STORAGE_KEY);
const parsedOldConfig = JSON.parse(oldWebsiteConfig) as TJsConfig;
const parsedOldConfig = JSON.parse(oldWebsiteConfig) as Partial<{
environmentId: string;
apiHost: string;
environmentState: TJsConfig["environmentState"];
personState: TJsConfig["personState"];
filteredSurveys: TJsConfig["filteredSurveys"];
}>;
if (
parsedOldConfig.environmentId &&
@@ -55,14 +60,20 @@ const migrateLocalStorage = (): { changed: boolean; newState?: TJsConfig } => {
return {
changed: true,
newState: newLocalStorageConfig,
newState: newLocalStorageConfig as TJsConfig,
};
}
}
if (oldAppConfig) {
localStorage.removeItem(LEGACY_JS_APP_LOCAL_STORAGE_KEY);
const parsedOldConfig = JSON.parse(oldAppConfig) as TJsConfig;
const parsedOldConfig = JSON.parse(oldAppConfig) as Partial<{
environmentId: string;
apiHost: string;
environmentState: TJsConfig["environmentState"];
personState: TJsConfig["personState"];
filteredSurveys: TJsConfig["filteredSurveys"];
}>;
if (
parsedOldConfig.environmentId &&
@@ -86,20 +97,24 @@ const migrateProductToProject = (): { changed: boolean; newState?: TJsConfig } =
const existingConfig = localStorage.getItem(JS_LOCAL_STORAGE_KEY);
if (existingConfig) {
const parsedConfig = JSON.parse(existingConfig);
const parsedConfig = JSON.parse(existingConfig) as TJsConfig;
if (parsedConfig.environmentState.data.product) {
const { environmentState, filteredSurveys, ...restConfig } = parsedConfig;
// @ts-expect-error - product is not in the type
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition -- environmentState could be undefined in an error state
if (parsedConfig.environmentState?.data?.product) {
const { environmentState: _, filteredSurveys, ...restConfig } = parsedConfig;
// @ts-expect-error
const fixedFilteredSurveys = filteredSurveys.map((survey) => {
// @ts-expect-error - productOverwrites is not in the type
const { productOverwrites, ...rest } = survey;
return {
...rest,
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment -- productOverwrites is not in the type
projectOverwrites: productOverwrites,
};
});
// @ts-expect-error - product is not in the type
const { product, ...rest } = parsedConfig.environmentState.data;
const newLocalStorageConfig = {
@@ -108,6 +123,7 @@ const migrateProductToProject = (): { changed: boolean; newState?: TJsConfig } =
...parsedConfig.environmentState,
data: {
...rest,
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment -- product is not in the type
project: product,
},
},
@@ -126,11 +142,12 @@ const migrateProductToProject = (): { changed: boolean; newState?: TJsConfig } =
export const initialize = async (
configInput: TJsConfigInput
): Promise<Result<void, MissingFieldError | NetworkError | MissingPersonError | ForbiddenError>> => {
): Promise<Result<void, MissingFieldError | ApiErrorResponse>> => {
const isDebug = getIsDebug();
if (isDebug) {
logger.configure({ logLevel: "debug" });
}
let config = Config.getInstance();
const { changed, newState } = migrateLocalStorage();
@@ -166,12 +183,12 @@ export const initialize = async (
try {
existingConfig = config.get();
logger.debug("Found existing configuration.");
} catch (e) {
} catch {
logger.debug("No existing configuration found.");
}
// formbricks is in error state, skip initialization
if (existingConfig?.status?.value === "error") {
if (existingConfig?.status.value === "error") {
if (isDebug) {
logger.debug(
"Formbricks is in error state, but debug mode is active. Resetting config and continuing."
@@ -180,16 +197,15 @@ export const initialize = async (
return okVoid();
}
logger.debug("Formbricks was set to an error state.");
console.error("🧱 Formbricks - Formbricks was set to an error state.");
const expiresAt = existingConfig?.status?.expiresAt;
const expiresAt = existingConfig.status.expiresAt;
if (expiresAt && new Date(expiresAt) > new Date()) {
logger.debug("Error state is not expired, skipping initialization");
console.error("🧱 Formbricks - Error state is not expired, skipping initialization");
return okVoid();
} else {
logger.debug("Error state is expired. Continue with initialization.");
}
console.error("🧱 Formbricks - Error state is expired. Continuing with initialization.");
}
ErrorHandler.getInstance().printStatus();
@@ -217,8 +233,7 @@ export const initialize = async (
addWidgetContainer();
if (
existingConfig &&
existingConfig.environmentState &&
existingConfig?.environmentState &&
existingConfig.environmentId === configInput.environmentId &&
existingConfig.apiHost === configInput.apiHost
) {
@@ -233,6 +248,7 @@ export const initialize = async (
if (
configInput.userId &&
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition -- personState could be null
(existingConfig.personState === null ||
(existingConfig.personState.expiresAt && new Date(existingConfig.personState.expiresAt) < new Date()))
) {
@@ -278,8 +294,8 @@ export const initialize = async (
});
const surveyNames = filteredSurveys.map((s) => s.name);
logger.debug("Fetched " + surveyNames.length + " surveys during sync: " + surveyNames.join(", "));
} catch (e) {
logger.debug(`Fetched ${surveyNames.length.toString()} surveys during sync: ${surveyNames.join(", ")}`);
} catch {
putFormbricksInErrorState(config);
}
} else {
@@ -319,9 +335,9 @@ export const initialize = async (
configInput.attributes
);
if (res.ok !== true) {
if (!res.ok) {
if (res.error.code === "forbidden") {
logger.error(`Authorization error: ${res.error.responseMessage}`);
logger.error(`Authorization error: ${res.error.responseMessage ?? ""}`);
}
return err(res.error);
}
@@ -341,7 +357,7 @@ export const initialize = async (
attributes: updatedAttributes ?? {},
});
} catch (e) {
handleErrorOnFirstInit(e as Error);
handleErrorOnFirstInit(e);
}
// and track the new session event
@@ -357,13 +373,14 @@ export const initialize = async (
// check page url if initialized after page load
checkPageUrl();
void checkPageUrl();
return okVoid();
};
export const handleErrorOnFirstInit = (e: any) => {
if (e.error.code === "forbidden") {
logger.error(`Authorization error: ${e.error.responseMessage}`);
export const handleErrorOnFirstInit = (e: unknown): void => {
const error = e as ApiErrorResponse;
if (error.code === "forbidden") {
logger.error(`Authorization error: ${error.responseMessage ?? ""}`);
}
if (getIsDebug()) {
@@ -380,7 +397,9 @@ export const handleErrorOnFirstInit = (e: any) => {
};
// can't use config.update here because the config is not yet initialized
wrapThrows(() => localStorage.setItem(JS_LOCAL_STORAGE_KEY, JSON.stringify(initialErrorConfig)))();
wrapThrows(() => {
localStorage.setItem(JS_LOCAL_STORAGE_KEY, JSON.stringify(initialErrorConfig));
})();
throw new Error("Could not initialize formbricks");
};
@@ -404,7 +423,7 @@ export const deinitalize = (): void => {
setIsInitialized(false);
};
export const putFormbricksInErrorState = (config: Config): void => {
export const putFormbricksInErrorState = (formbricksConfig: Config): void => {
if (getIsDebug()) {
logger.debug("Not putting formbricks in error state because debug mode is active (no error state)");
return;
@@ -412,8 +431,8 @@ export const putFormbricksInErrorState = (config: Config): void => {
logger.debug("Putting formbricks in error state");
// change formbricks status to error
config.update({
...config.get(),
formbricksConfig.update({
...formbricksConfig.get(),
status: {
value: "error",
expiresAt: new Date(new Date().getTime() + 10 * 60000), // 10 minutes in the future

View File

@@ -1,3 +1,4 @@
/* eslint-disable no-console -- Required for logging */
type LogLevel = "debug" | "error";
interface LoggerConfig {
@@ -8,8 +9,6 @@ export class Logger {
private static instance: Logger | undefined;
private logLevel: LogLevel = "error";
private constructor() {}
static getInstance(): Logger {
if (!Logger.instance) {
Logger.instance = new Logger();
@@ -18,7 +17,7 @@ export class Logger {
}
configure(config: LoggerConfig): void {
if (config && config.logLevel !== undefined) {
if (config.logLevel !== undefined) {
this.logLevel = config.logLevel;
}
}

View File

@@ -1,7 +1,7 @@
import { TJsEnvironmentStateActionClass } from "@formbricks/types/js";
import { type TJsEnvironmentStateActionClass } from "@formbricks/types/js";
import { trackNoCodeAction } from "./actions";
import { Config } from "./config";
import { ErrorHandler, NetworkError, Result, err, match, okVoid } from "./errors";
import { ErrorHandler, type NetworkError, type Result, type ResultError, err, match, okVoid } from "./errors";
import { Logger } from "./logger";
import { TimeoutStack } from "./timeout-stack";
import { evaluateNoCodeConfigClick, handleUrlFilters } from "./utils";
@@ -18,6 +18,9 @@ const events = ["hashchange", "popstate", "pushstate", "replacestate", "load"];
// Page URL Event Handlers
let arePageUrlEventListenersAdded = false;
let isHistoryPatched = false;
export const setIsHistoryPatched = (value: boolean): void => {
isHistoryPatched = value;
};
export const checkPageUrl = async (): Promise<Result<void, NetworkError>> => {
logger.debug(`Checking page url: ${window.location.href}`);
@@ -34,7 +37,7 @@ export const checkPageUrl = async (): Promise<Result<void, NetworkError>> => {
if (isValidUrl) {
const trackResult = await trackNoCodeAction(event.name);
if (trackResult.ok !== true) {
if (!trackResult.ok) {
return err(trackResult.error);
}
} else {
@@ -52,43 +55,45 @@ export const checkPageUrl = async (): Promise<Result<void, NetworkError>> => {
return okVoid();
};
const checkPageUrlWrapper = () => {
checkPageUrl();
};
const checkPageUrlWrapper = (): ReturnType<typeof checkPageUrl> => checkPageUrl();
export const addPageUrlEventListeners = (): void => {
if (typeof window === "undefined" || arePageUrlEventListenersAdded) return;
// Monkey patch history methods if not already done
if (!isHistoryPatched) {
// eslint-disable-next-line @typescript-eslint/unbound-method -- We need to access the original method
const originalPushState = history.pushState;
// eslint-disable-next-line func-names -- We need an anonymous function here
history.pushState = function (...args) {
const returnValue = originalPushState.apply(this, args);
originalPushState.apply(this, args);
const event = new Event("pushstate");
window.dispatchEvent(event);
return returnValue;
};
isHistoryPatched = true;
}
events.forEach((event) => window.addEventListener(event, checkPageUrlWrapper));
events.forEach((event) => {
window.addEventListener(event, checkPageUrlWrapper as EventListener);
});
arePageUrlEventListenersAdded = true;
};
export const removePageUrlEventListeners = (): void => {
if (typeof window === "undefined" || !arePageUrlEventListenersAdded) return;
events.forEach((event) => window.removeEventListener(event, checkPageUrlWrapper));
events.forEach((event) => {
window.removeEventListener(event, checkPageUrlWrapper as EventListener);
});
arePageUrlEventListenersAdded = false;
};
// Click Event Handlers
let isClickEventListenerAdded = false;
const checkClickMatch = (event: MouseEvent) => {
const checkClickMatch = (event: MouseEvent): void => {
const { environmentState } = appConfig.get();
if (!environmentState) return;
const { actionClasses = [] } = environmentState.data;
@@ -100,18 +105,26 @@ const checkClickMatch = (event: MouseEvent) => {
noCodeClickActionClasses.forEach((action: TJsEnvironmentStateActionClass) => {
if (evaluateNoCodeConfigClick(targetElement, action)) {
trackNoCodeAction(action.name).then((res) => {
match(
res,
(_value: unknown) => {},
(err: any) => errorHandler.handle(err)
);
});
trackNoCodeAction(action.name)
.then((res) => {
match(
res,
(_value: unknown) => undefined,
(actionError: unknown) => {
errorHandler.handle(actionError);
}
);
})
.catch((error: unknown) => {
errorHandler.handle(error);
});
}
});
};
const checkClickMatchWrapper = (e: MouseEvent) => checkClickMatch(e);
const checkClickMatchWrapper = (e: MouseEvent): void => {
checkClickMatch(e);
};
export const addClickEventListener = (): void => {
if (typeof window === "undefined" || isClickEventListenerAdded) return;
@@ -128,9 +141,9 @@ export const removeClickEventListener = (): void => {
// Exit Intent Handlers
let isExitIntentListenerAdded = false;
const checkExitIntent = async (e: MouseEvent) => {
const checkExitIntent = async (e: MouseEvent): Promise<ResultError<NetworkError> | undefined> => {
const { environmentState } = appConfig.get();
const { actionClasses = [] } = environmentState.data ?? {};
const { actionClasses = [] } = environmentState.data;
const noCodeExitIntentActionClasses = actionClasses.filter(
(action) => action.type === "noCode" && action.noCodeConfig?.type === "exitIntent"
@@ -144,23 +157,25 @@ const checkExitIntent = async (e: MouseEvent) => {
if (!isValidUrl) continue;
const trackResult = await trackNoCodeAction(event.name);
if (trackResult.ok !== true) return err(trackResult.error);
if (!trackResult.ok) return err(trackResult.error);
}
}
};
const checkExitIntentWrapper = (e: MouseEvent) => checkExitIntent(e);
const checkExitIntentWrapper = (e: MouseEvent): ReturnType<typeof checkExitIntent> => checkExitIntent(e);
export const addExitIntentListener = (): void => {
if (typeof document !== "undefined" && !isExitIntentListenerAdded) {
document.querySelector("body")!.addEventListener("mouseleave", checkExitIntentWrapper);
document
.querySelector("body")
?.addEventListener("mouseleave", checkExitIntentWrapper as unknown as EventListener);
isExitIntentListenerAdded = true;
}
};
export const removeExitIntentListener = (): void => {
if (isExitIntentListenerAdded) {
document.removeEventListener("mouseleave", checkExitIntentWrapper);
document.removeEventListener("mouseleave", checkExitIntentWrapper as unknown as EventListener);
isExitIntentListenerAdded = false;
}
};
@@ -169,7 +184,7 @@ export const removeExitIntentListener = (): void => {
let scrollDepthListenerAdded = false;
let scrollDepthTriggered = false;
const checkScrollDepth = async () => {
const checkScrollDepth = async (): Promise<Result<void, unknown>> => {
const scrollPosition = window.scrollY;
const windowSize = window.innerHeight;
const bodyHeight = document.documentElement.scrollHeight;
@@ -182,7 +197,7 @@ const checkScrollDepth = async () => {
scrollDepthTriggered = true;
const { environmentState } = appConfig.get();
const { actionClasses = [] } = environmentState.data ?? {};
const { actionClasses = [] } = environmentState.data;
const noCodefiftyPercentScrollActionClasses = actionClasses.filter(
(action) => action.type === "noCode" && action.noCodeConfig?.type === "fiftyPercentScroll"
@@ -195,22 +210,22 @@ const checkScrollDepth = async () => {
if (!isValidUrl) continue;
const trackResult = await trackNoCodeAction(event.name);
if (trackResult.ok !== true) return err(trackResult.error);
if (!trackResult.ok) return err(trackResult.error);
}
}
return okVoid();
};
const checkScrollDepthWrapper = () => checkScrollDepth();
const checkScrollDepthWrapper = (): ReturnType<typeof checkScrollDepth> => checkScrollDepth();
export const addScrollDepthListener = (): void => {
if (typeof window !== "undefined" && !scrollDepthListenerAdded) {
if (document.readyState === "complete") {
window.addEventListener("scroll", checkScrollDepthWrapper);
window.addEventListener("scroll", checkScrollDepthWrapper as EventListener);
} else {
window.addEventListener("load", () => {
window.addEventListener("scroll", checkScrollDepthWrapper);
window.addEventListener("scroll", checkScrollDepthWrapper as EventListener);
});
}
scrollDepthListenerAdded = true;
@@ -219,7 +234,7 @@ export const addScrollDepthListener = (): void => {
export const removeScrollDepthListener = (): void => {
if (scrollDepthListenerAdded) {
window.removeEventListener("scroll", checkScrollDepthWrapper);
window.removeEventListener("scroll", checkScrollDepthWrapper as EventListener);
scrollDepthListenerAdded = false;
}
};

View File

@@ -1,4 +1,4 @@
import { TJsPersonState, TJsPersonSyncParams } from "@formbricks/types/js";
import { type TJsPersonState, type TJsPersonSyncParams } from "@formbricks/types/js";
import { Config } from "./config";
import { err } from "./errors";
import { Logger } from "./logger";
@@ -30,9 +30,9 @@ export const DEFAULT_PERSON_STATE_NO_USER_ID: TJsPersonState = {
*/
export const fetchPersonState = async (
{ apiHost, environmentId, userId }: TJsPersonSyncParams,
noCache: boolean = false
noCache = false
): Promise<TJsPersonState> => {
let fetchOptions: RequestInit = {};
const fetchOptions: RequestInit = {};
if (noCache || getIsDebug()) {
fetchOptions.cache = "no-cache";
@@ -44,7 +44,7 @@ export const fetchPersonState = async (
const response = await fetch(url, fetchOptions);
if (!response.ok) {
const jsonRes = await response.json();
const jsonRes = (await response.json()) as { code: string; message: string };
const error = err({
code: jsonRes.code === "forbidden" ? "forbidden" : "network_error",
@@ -54,10 +54,10 @@ export const fetchPersonState = async (
responseMessage: jsonRes.message,
});
throw error;
throw new Error(error.error.message);
}
const data = await response.json();
const data = (await response.json()) as { data: TJsPersonState["data"] };
const { data: state } = data;
const defaultPersonState: TJsPersonState = {
@@ -76,7 +76,7 @@ export const fetchPersonState = async (
}
return {
data: { ...(state as TJsPersonState["data"]) },
data: { ...state },
expiresAt: new Date(new Date().getTime() + 1000 * 60 * 30), // 30 minutes
};
};
@@ -88,7 +88,7 @@ export const addPersonStateExpiryCheckListener = (): void => {
const updateInterval = 1000 * 60; // every 60 seconds
if (typeof window !== "undefined" && personStateSyncIntervalId === null) {
personStateSyncIntervalId = window.setInterval(async () => {
const intervalHandler = (): void => {
const userId = config.get().personState.data.userId;
if (!userId) {
@@ -103,7 +103,9 @@ export const addPersonStateExpiryCheckListener = (): void => {
expiresAt: new Date(new Date().getTime() + 1000 * 60 * 30), // 30 minutes
},
});
}, updateInterval);
};
personStateSyncIntervalId = window.setInterval(intervalHandler, updateInterval);
}
};

View File

@@ -1,12 +1,14 @@
import { Config } from "./config";
import { NetworkError, Result, err, okVoid } from "./errors";
import { type NetworkError, type Result, err, okVoid } from "./errors";
import { deinitalize, initialize } from "./initialize";
import { Logger } from "./logger";
import { setIsHistoryPatched } from "./no-code-actions";
import { closeSurvey } from "./widget";
const config = Config.getInstance();
const logger = Logger.getInstance();
// eslint-disable-next-line @typescript-eslint/require-await -- There are no promises but our proxy makes the functions async
export const logoutPerson = async (): Promise<void> => {
deinitalize();
config.resetConfig();
@@ -24,7 +26,10 @@ export const resetPerson = async (): Promise<Result<void, NetworkError>> => {
...(userId && { userId }),
attributes: config.get().attributes,
};
await logoutPerson();
setIsHistoryPatched(false);
try {
await initialize(syncParams);
return okVoid();

View File

@@ -1,9 +1,8 @@
export class TimeoutStack {
private static instance: TimeoutStack;
// private timeouts: number[] = [];
private static instance: TimeoutStack | null = null;
private timeouts: { event: string; timeoutId: number }[] = [];
// Private constructor to prevent direct instantiation
// eslint-disable-next-line @typescript-eslint/no-empty-function -- Empty constructor is intentional
private constructor() {}
// Retrieve the singleton instance of TimeoutStack

View File

@@ -1,14 +1,17 @@
import { diffInDays } from "@formbricks/lib/utils/datetime";
import { TActionClassNoCodeConfig, TActionClassPageUrlRule } from "@formbricks/types/action-classes";
import { TAttributes } from "@formbricks/types/attributes";
import {
TJsEnvironmentState,
TJsEnvironmentStateActionClass,
TJsEnvironmentStateSurvey,
TJsPersonState,
TJsTrackProperties,
type TActionClassNoCodeConfig,
type TActionClassPageUrlRule,
} from "@formbricks/types/action-classes";
import { type TAttributes } from "@formbricks/types/attributes";
import {
type TJsEnvironmentState,
type TJsEnvironmentStateActionClass,
type TJsEnvironmentStateSurvey,
type TJsPersonState,
type TJsTrackProperties,
} from "@formbricks/types/js";
import { TResponseHiddenFieldValue } from "@formbricks/types/responses";
import { type TResponseHiddenFieldValue } from "@formbricks/types/responses";
import { Logger } from "./logger";
const logger = Logger.getInstance();
@@ -37,7 +40,7 @@ export const checkUrlMatch = (
};
export const handleUrlFilters = (urlFilters: TActionClassNoCodeConfig["urlFilters"]): boolean => {
if (!urlFilters || urlFilters.length === 0) {
if (urlFilters.length === 0) {
return true;
}
@@ -68,7 +71,7 @@ export const evaluateNoCodeConfigClick = (
if (cssSelector) {
// Split selectors that start with a . or # including the . or #
const individualSelectors = cssSelector.split(/\s*(?=[.#])/);
for (let selector of individualSelectors) {
for (const selector of individualSelectors) {
if (!targetElement.matches(selector)) {
return false;
}
@@ -86,7 +89,7 @@ export const handleHiddenFields = (
hiddenFieldsConfig: TJsEnvironmentStateSurvey["hiddenFields"],
hiddenFields: TJsTrackProperties["hiddenFields"]
): TResponseHiddenFieldValue => {
const { enabled: enabledHiddenFields, fieldIds: hiddenFieldIds } = hiddenFieldsConfig || {};
const { enabled: enabledHiddenFields, fieldIds: hiddenFieldIds } = hiddenFieldsConfig;
let hiddenFieldsObject: TResponseHiddenFieldValue = {};
@@ -94,14 +97,14 @@ export const handleHiddenFields = (
logger.error("Hidden fields are not enabled for this survey");
} else if (hiddenFieldIds && hiddenFields) {
const unknownHiddenFields: string[] = [];
hiddenFieldsObject = Object.keys(hiddenFields).reduce((acc, key) => {
if (hiddenFieldIds?.includes(key)) {
acc[key] = hiddenFields?.[key];
hiddenFieldsObject = Object.keys(hiddenFields).reduce<TResponseHiddenFieldValue>((acc, key) => {
if (hiddenFieldIds.includes(key)) {
acc[key] = hiddenFields[key];
} else {
unknownHiddenFields.push(key);
}
return acc;
}, {} as TResponseHiddenFieldValue);
}, {});
if (unknownHiddenFields.length > 0) {
logger.error(
@@ -113,7 +116,7 @@ export const handleHiddenFields = (
return hiddenFieldsObject;
};
export const shouldDisplayBasedOnPercentage = (displayPercentage: number) => {
export const shouldDisplayBasedOnPercentage = (displayPercentage: number): boolean => {
const randomNum = Math.floor(Math.random() * 10000) / 100;
return randomNum <= displayPercentage;
};
@@ -125,40 +128,39 @@ export const getLanguageCode = (
const language = attributes.language;
const availableLanguageCodes = survey.languages.map((surveyLanguage) => surveyLanguage.language.code);
if (!language) return "default";
else {
const selectedLanguage = survey.languages.find((surveyLanguage) => {
return (
surveyLanguage.language.code === language.toLowerCase() ||
surveyLanguage.language.alias?.toLowerCase() === language.toLowerCase()
);
});
if (selectedLanguage?.default) {
return "default";
}
if (
!selectedLanguage ||
!selectedLanguage?.enabled ||
!availableLanguageCodes.includes(selectedLanguage.language.code)
) {
return undefined;
}
return selectedLanguage.language.code;
const selectedLanguage = survey.languages.find((surveyLanguage) => {
return (
surveyLanguage.language.code === language.toLowerCase() ||
surveyLanguage.language.alias?.toLowerCase() === language.toLowerCase()
);
});
if (selectedLanguage?.default) {
return "default";
}
if (
!selectedLanguage ||
!selectedLanguage.enabled ||
!availableLanguageCodes.includes(selectedLanguage.language.code)
) {
return undefined;
}
return selectedLanguage.language.code;
};
export const getDefaultLanguageCode = (survey: TJsEnvironmentStateSurvey) => {
const defaultSurveyLanguage = survey.languages?.find((surveyLanguage) => {
return surveyLanguage.default === true;
export const getDefaultLanguageCode = (survey: TJsEnvironmentStateSurvey): string | undefined => {
const defaultSurveyLanguage = survey.languages.find((surveyLanguage) => {
return surveyLanguage.default;
});
if (defaultSurveyLanguage) return defaultSurveyLanguage.language.code;
};
export const getIsDebug = () => window.location.search.includes("formbricksDebug=true");
export const getIsDebug = (): boolean => window.location.search.includes("formbricksDebug=true");
/**
* Filters surveys based on the displayOption, recontactDays, and segments
* @param environmentSate The environment state
* @param personState The person state
* @param environmentSate - The environment state
* @param personState - The person state
* @returns The filtered surveys
*/
@@ -170,10 +172,6 @@ export const filterSurveys = (
const { project, surveys } = environmentState.data;
const { displays, responses, lastDisplayAt, segments, userId } = personState.data;
if (!displays) {
return [];
}
// Function to filter surveys based on displayOption criteria
let filteredSurveys = surveys.filter((survey: TJsEnvironmentStateSurvey) => {
switch (survey.displayOption) {
@@ -211,19 +209,19 @@ export const filterSurveys = (
// if survey has recontactDays, check if the last display was more than recontactDays ago
else if (survey.recontactDays !== null) {
const lastDisplaySurvey = displays.filter((display) => display.surveyId === survey.id)[0];
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition -- lastDisplaySurvey could be falsy
if (!lastDisplaySurvey) {
return true;
}
return diffInDays(new Date(), new Date(lastDisplaySurvey.createdAt)) >= survey.recontactDays;
}
// use recontactDays of the project if survey does not have recontactDays
else if (project.recontactDays !== null) {
else if (project.recontactDays) {
return diffInDays(new Date(), new Date(lastDisplayAt)) >= project.recontactDays;
}
// if no recontactDays is set, show the survey
else {
return true;
}
return true;
});
if (!userId) {

View File

@@ -1,15 +1,17 @@
/* eslint-disable no-console -- Required for error logging */
/* eslint-disable @typescript-eslint/no-empty-function -- There are some empty functions here that we need */
import { FormbricksAPI } from "@formbricks/api";
import { ResponseQueue } from "@formbricks/lib/responseQueue";
import { SurveyState } from "@formbricks/lib/surveyState";
import { getStyling } from "@formbricks/lib/utils/styling";
import {
TJsEnvironmentStateSurvey,
TJsFileUploadParams,
TJsPersonState,
TJsTrackProperties,
type TJsEnvironmentStateSurvey,
type TJsFileUploadParams,
type TJsPersonState,
type TJsTrackProperties,
} from "@formbricks/types/js";
import { TResponseHiddenFieldValue, TResponseUpdate } from "@formbricks/types/responses";
import { TUploadFileConfig } from "@formbricks/types/storage";
import { type TResponseHiddenFieldValue, type TResponseUpdate } from "@formbricks/types/responses";
import { type TUploadFileConfig } from "@formbricks/types/storage";
import { Config } from "./config";
import { CONTAINER_ID } from "./constants";
import { Logger } from "./logger";
@@ -27,10 +29,10 @@ const logger = Logger.getInstance();
const timeoutStack = TimeoutStack.getInstance();
let isSurveyRunning = false;
let setIsError = (_: boolean) => {};
let setIsResponseSendingFinished = (_: boolean) => {};
let setIsError = (_: boolean): void => {};
let setIsResponseSendingFinished = (_: boolean): void => {};
export const setIsSurveyRunning = (value: boolean) => {
export const setIsSurveyRunning = (value: boolean): void => {
isSurveyRunning = value;
};
@@ -60,7 +62,7 @@ const renderWidget = async (
survey: TJsEnvironmentStateSurvey,
action?: string,
hiddenFields: TResponseHiddenFieldValue = {}
) => {
): Promise<void> => {
if (isSurveyRunning) {
logger.debug("A survey is already running. Skipping.");
return;
@@ -69,11 +71,11 @@ const renderWidget = async (
setIsSurveyRunning(true);
if (survey.delay) {
logger.debug(`Delaying survey "${survey.name}" by ${survey.delay} seconds.`);
logger.debug(`Delaying survey "${survey.name}" by ${survey.delay.toString()} seconds.`);
}
const { project } = config.get().environmentState.data ?? {};
const { attributes } = config.get() ?? {};
const { project } = config.get().environmentState.data;
const { attributes } = config.get();
const isMultiLanguageSurvey = survey.languages.length > 1;
let languageCode = "default";
@@ -151,7 +153,7 @@ const renderWidget = async (
const existingDisplays = config.get().personState.data.displays;
const newDisplay = { surveyId: survey.id, createdAt: new Date() };
const displays = existingDisplays ? [...existingDisplays, newDisplay] : [newDisplay];
const displays = existingDisplays.length ? [...existingDisplays, newDisplay] : [newDisplay];
const previousConfig = config.get();
const updatedPersonState: TJsPersonState = {
@@ -203,7 +205,7 @@ const renderWidget = async (
...config.get().personState,
data: {
...config.get().personState.data,
responses: [...responses, surveyState.surveyId],
responses: responses.length ? [...responses, surveyState.surveyId] : [surveyState.surveyId],
},
};
@@ -235,7 +237,7 @@ const renderWidget = async (
},
onRetry: () => {
setIsError(false);
responseQueue.processQueue();
void responseQueue.processQueue();
},
hiddenFieldsRecord: hiddenFields,
});
@@ -246,7 +248,7 @@ const renderWidget = async (
}
};
export const closeSurvey = async (): Promise<void> => {
export const closeSurvey = (): void => {
// remove container element from DOM
removeWidgetContainer();
addWidgetContainer();
@@ -276,16 +278,19 @@ export const removeWidgetContainer = (): void => {
const loadFormbricksSurveysExternally = (): Promise<typeof window.formbricksSurveys> => {
return new Promise((resolve, reject) => {
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition -- We need to check if the formbricksSurveys object exists
if (window.formbricksSurveys) {
resolve(window.formbricksSurveys);
} else {
const script = document.createElement("script");
script.src = `${config.get().apiHost}/js/surveys.umd.cjs`;
script.async = true;
script.onload = () => resolve(window.formbricksSurveys);
script.onload = () => {
resolve(window.formbricksSurveys);
};
script.onerror = (error) => {
console.error("Failed to load Formbricks Surveys library:", error);
reject(error);
reject(new Error(`Failed to load Formbricks Surveys library: ${error as string}`));
};
document.head.appendChild(script);
}

View File

@@ -1,7 +1,7 @@
{
"name": "@formbricks/js",
"license": "MIT",
"version": "3.0.1",
"version": "3.0.2",
"description": "Formbricks-js allows you to connect your index to Formbricks, display surveys and trigger events.",
"homepage": "https://formbricks.com",
"repository": {

View File

@@ -1,20 +1,20 @@
import type FormbricksApp from "@formbricks/js-core";
import type Formbricks from "@formbricks/js-core";
import { loadFormbricksToProxy } from "./lib/load-formbricks";
type TFormbricksApp = typeof FormbricksApp;
type TFormbricks = typeof Formbricks;
declare global {
interface Window {
formbricks: TFormbricksApp | undefined;
formbricks: TFormbricks | undefined;
}
}
const formbricksProxyHandler: ProxyHandler<TFormbricksApp> = {
const formbricksProxyHandler: ProxyHandler<TFormbricks> = {
get(_target, prop, _receiver) {
return (...args: unknown[]) => loadFormbricksToProxy(prop as string, ...args);
},
};
const formbricksApp: TFormbricksApp = new Proxy({} as TFormbricksApp, formbricksProxyHandler);
const formbricks: TFormbricks = new Proxy({} as TFormbricks, formbricksProxyHandler);
// eslint-disable-next-line import/no-default-export -- Required for UMD
export default formbricksApp;
export default formbricks;

View File

@@ -1,6 +1,6 @@
{
"name": "@formbricks/react-native",
"version": "1.3.0",
"version": "1.3.1",
"license": "MIT",
"description": "Formbricks React Native SDK allows you to connect your app to Formbricks, display surveys and trigger events.",
"homepage": "https://formbricks.com",

View File

@@ -1,7 +1,6 @@
/* eslint-disable @typescript-eslint/no-unnecessary-condition -- could be undefined */
import { FormbricksAPI } from "@formbricks/api";
import type { TAttributes } from "@formbricks/types/attributes";
import type { ForbiddenError, NetworkError } from "@formbricks/types/errors";
import type { ApiErrorResponse } from "@formbricks/types/errors";
import { type Result, err, ok } from "../../../js-core/src/lib/errors";
import { Logger } from "../../../js-core/src/lib/logger";
@@ -12,7 +11,7 @@ export const updateAttributes = async (
environmentId: string,
userId: string,
attributes: TAttributes
): Promise<Result<TAttributes, NetworkError | ForbiddenError>> => {
): Promise<Result<TAttributes, ApiErrorResponse>> => {
// clean attributes and remove existing attributes if config already exists
const updatedAttributes = { ...attributes };
@@ -22,7 +21,7 @@ export const updateAttributes = async (
return ok(updatedAttributes);
}
logger.debug("Updating attributes: " + JSON.stringify(updatedAttributes));
logger.debug(`Updating attributes: ${JSON.stringify(updatedAttributes)}`);
const api = new FormbricksAPI({
apiHost,
@@ -41,18 +40,18 @@ export const updateAttributes = async (
return ok(updatedAttributes);
}
// @ts-expect-error -- details could be defined and present
// eslint-disable-next-line @typescript-eslint/no-unsafe-member-access -- details could be defined and present
if (res.error.details?.ignore) {
logger.error(res.error.message ?? `Error updating person with userId ${userId}`);
const responseError = res.error;
if (responseError.details?.ignore) {
logger.error(responseError.message);
return ok(updatedAttributes);
}
return err({
code: (res.error as ForbiddenError).code ?? "network_error",
status: (res.error as NetworkError | ForbiddenError).status ?? 500,
code: responseError.code,
status: responseError.status,
message: `Error updating person with userId ${userId}`,
url: new URL(`${apiHost}/api/v1/client/${environmentId}/people/${userId}/attributes`),
responseMessage: res.error.message,
responseMessage: responseError.responseMessage,
});
};

View File

@@ -47,18 +47,18 @@ export class StorageAPI {
const { signedUrl, fileUrl, signingData, presignedFields, updatedFileName } = data;
let requestHeaders: Record<string, string> = {};
let localUploadDetails: Record<string, string> = {};
if (signingData) {
const { signature, timestamp, uuid } = signingData;
requestHeaders = {
"X-File-Type": file.type,
"X-File-Name": encodeURIComponent(updatedFileName),
"X-Survey-ID": surveyId ?? "",
"X-Signature": signature,
"X-Timestamp": String(timestamp),
"X-UUID": uuid,
localUploadDetails = {
fileType: file.type,
fileName: encodeURIComponent(updatedFileName),
surveyId: surveyId ?? "",
signature,
timestamp: String(timestamp),
uuid,
};
}
@@ -91,14 +91,12 @@ export class StorageAPI {
try {
uploadResponse = await fetch(signedUrlCopy, {
method: "POST",
...(signingData
? {
headers: {
...requestHeaders,
},
}
: {}),
body: presignedFields ? formDataForS3 : JSON.stringify(formData),
body: presignedFields
? formDataForS3
: JSON.stringify({
...formData,
...localUploadDetails,
}),
});
} catch (err) {
console.error("Error uploading file", err);

View File

@@ -90,14 +90,17 @@ interface NetworkError {
message: string;
status: number;
url: URL;
responseMessage?: string;
details?: Record<string, string | string[] | number | number[] | boolean | boolean[]>;
}
interface ForbiddenError {
code: "forbidden";
message: string;
responseMessage?: string;
status: number;
url: URL;
responseMessage?: string;
details?: Record<string, string | string[] | number | number[] | boolean | boolean[]>;
}
export const ZErrorHandler = z.function().args(z.any()).returns(z.void());
@@ -115,3 +118,21 @@ export {
AuthorizationError,
};
export type { NetworkError, ForbiddenError };
export interface ApiErrorResponse {
code:
| "not_found"
| "gone"
| "bad_request"
| "internal_server_error"
| "unauthorized"
| "method_not_allowed"
| "not_authenticated"
| "forbidden"
| "network_error";
message: string;
status: number;
url: URL;
details?: Record<string, string | string[] | number | number[] | boolean | boolean[]>;
responseMessage?: string;
}

View File

@@ -31,6 +31,9 @@
"@formbricks/js#lint": {
"dependsOn": ["@formbricks/js-core#build"]
},
"@formbricks/js-core#lint": {
"dependsOn": ["@formbricks/api#build"]
},
"@formbricks/react-native#build": {
"dependsOn": ["^build"],
"outputs": ["dist/**"]